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,3 @@
|
|||||||
|
runs/
|
||||||
|
tools/.cache/
|
||||||
|
my-work/*.json
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
# AGENTS.md · n8n Workflow Ops Assistant 작업 규칙 (79일차 실습 폴더)
|
||||||
|
|
||||||
|
이 파일은 **CLI 코딩 에이전트**(Codex, Claude Code, Antigravity 등)가 이 폴더에서 n8n 워크플로우를 만들 때 따르는 규칙입니다. 사람도 읽을 수 있게 썼습니다. 78일차 규칙과 같고, 3절에 오늘 만드는 **운영 사실 · 질문 답변 · 브리핑 · 평가의 계약**과 **사실 · 상태 정의**, 5절에 여러 출처를 모으는 노드가 더해졌습니다.
|
||||||
|
|
||||||
|
## 1. 작업 순서 (항상 이 순서)
|
||||||
|
|
||||||
|
1. 요청을 읽고, 만들 워크플로우의 **입력 · 노드 순서 · 결과 모양**을 3~5줄로 먼저 정리해 보여 준다. 사실 · 상태 요청이면 **정의(어느 출처의 무엇을 어떻게 셀지)** 를 먼저 표로 보여 준다.
|
||||||
|
2. 워크플로우 JSON을 `my-work/<이름>.json`에 쓴다. 이미 있으면 그 파일을 고친다 (새 파일을 만들지 않는다).
|
||||||
|
3. `uv run tools/flow.py check my-work/<이름>.json` → 오류(✘)가 없을 때까지 고친다.
|
||||||
|
4. 끝나면 한국어로 짧게 보고한다: 바꾼 노드, check 결과, 그리고 사람이 실행할 명령 두 줄.
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/<이름>.json
|
||||||
|
uv run tools/flow.py test cases/<요청에 나온 테스트 파일>.json
|
||||||
|
```
|
||||||
|
5. **push와 test는 사람이 실행해 결과를 확인한다.** 사람이 "네가 push·test까지 해"라고 하면 직접 실행해도 된다.
|
||||||
|
6. 사람이 실패 보고서(`runs/*.json`)를 알려 주면 실패한 케이스의 요청·응답을 읽고 원인을 설명한 뒤 고친다. **같은 곳을 3번 고쳐도 실패하면 멈추고** 다른 방법을 제안한다.
|
||||||
|
7. 문서(`my-work/*.md`)를 써 달라는 요청이면 워크플로우를 만들지 않고 문서만 쓴다.
|
||||||
|
|
||||||
|
## 2. 환경 (바꾸지 말 것)
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
| --- | --- |
|
||||||
|
| n8n | 2.42.6, 65일차 공식 설치 `~/day65-n8n` (Docker 프로젝트 `day65-n8n`), 브라우저 http://localhost:5678 |
|
||||||
|
| n8n 켜고 끄기 | `~/aio-01d4-stu/Last3Mon/65/n8n`에서 `docker compose start/stop` (사람이 함, Ollama 컨테이너는 없음) |
|
||||||
|
| 게시된 웹훅 주소 | 밖에서 · n8n 안에서 모두 `http://localhost:5678/webhook/<path>` |
|
||||||
|
| OpenAI Credential | id `openAiCourse0001`, name `OpenAI (.env)`, 모델 `gpt-4.1-mini` |
|
||||||
|
| 다른 Credential | 쓰지 않음 (Ollama 등 없음) |
|
||||||
|
| 데이터 API | n8n 안에서 `http://data-api:8800`, 터미널에서 `http://localhost:8800` (**79일차 판** `Last3Mon/79/data-api`: 78일차 + **운영 아침 상태**를 켤 때 채워 둠) |
|
||||||
|
| 비교 기준 · 제공 | `reference/baseline-ops-facts.json` (`base-ops-facts`, 팀이 지난달 만든 아침 대시보드 — 기간 없이 전체를 셈, 품질 · 릴리스 없음) · `reference/baseline-ops-ask.json` (`base-ops-ask`, 사실 전부를 LLM에게 주고 무엇이든 답하던 챗봇). **고치지 않는다** (비교용). `reference/ops-facts-health.json`은 Lab 01 과제를 못 끝냈을 때 Lab 02 · 03에 쓰는 사실 |
|
||||||
|
|
||||||
|
### 운영 아침 상태 (2026-11-04 08:30, 연습 데이터)
|
||||||
|
|
||||||
|
데이터 API를 켜거나 `POST /ops-reset`을 하면 아래 상태로 돌아갑니다 (`data-api/data/ops-seed.json`).
|
||||||
|
|
||||||
|
| 출처 (72~78일차) | 요청 | 들어 있는 것 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 실행 기록 (77) | `POST /logs/query` `{"from", "to"}` | 두 주 666줄 (10/19~10/30) |
|
||||||
|
| 알림 (76 · 78) | `GET /alerts` → `{"count", "alerts": [{"workflow", "node", "severity", "reason", "message", "at"}]}` | 6건 |
|
||||||
|
| DLQ (76) | `GET /dlq` → `{"size", "items": [{"dlq_id", "item", "reason", "attempts", "parked"}]}` | 3건 |
|
||||||
|
| 품질 기록 · 격리함 (78) | `GET /quality-log` → `{"count", "items"}`, `GET /quarantine` → `{"size", "items"}` | 4줄 · 1건 |
|
||||||
|
| 사용량 · 예산 (75) | `GET /usage/summary` → `{"requests", "calls", "cost_usd", "by_user": {이름: {"cost_usd"}}}`, `GET /budget` → `{"daily_usd", "used_usd"}` | 오늘 192번 부름 · 예산 $0.08 |
|
||||||
|
| 릴리스 (74) | `GET /releases/helpdesk` → `{"active", "canary": {"release", "percent"} 또는 null}` | 1.1.0 + 카나리 1.2.0 20% |
|
||||||
|
| 감사 기록 (73) | `POST /audit` 본문 `{...}` 한 줄 더하기 · `GET /audit` (`POST /audit/list`) → `{"events": [...]}` | 처음엔 비어 있음 (Lab 02 과제 2) |
|
||||||
|
| 고장 스위치 (72) | `POST /admin/fault {"usage": "error"}` → `GET /usage/summary`가 500 (79일차 판) | **사람이** · 테스트가 (Lab 01 과제 2) |
|
||||||
|
| 되돌리기 | `POST /ops-reset` · `GET /ops/seed-info` | **사람이** · 테스트가 (`flow.py morning --reset`, `ops --reset`과 같음) |
|
||||||
|
|
||||||
|
## 3. 오늘 만드는 것의 계약과 정의
|
||||||
|
|
||||||
|
| 무엇 | 파일 · 웹훅 | 입력 (본문) | 결과 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 운영 사실 | `my-work/ops-facts.json` · `POST /webhook/my-ops-facts` | `from`, `to` (비면 `2026-10-26` · `2026-10-30`) | 아래 "사실" (과제 1: + `health`, `red`, `yellow`, `overall` · 과제 2: + `sources_down`, `unknown`) |
|
||||||
|
| 질문 답변 | `my-work/ops-ask.json` · `POST /webhook/my-ops-ask` | `question` | `{"question", "area", "router", "refused", "reason", "answer", "numbers_ok", "unknown_numbers"}` (과제 2: 질문마다 감사 기록 한 줄) |
|
||||||
|
| 브리핑 | `my-work/ops-brief.json` · `POST /webhook/my-ops-brief` | `from`, `to` | `{"overall", "red", "yellow", "actions": [{"area", "status", "action"}], "markdown"}` (+ `summary`, `numbers_ok`) |
|
||||||
|
| 평가 | `my-work/ops-eval.json` · `POST /webhook/my-ops-eval` | `{"cases": [{"question", "area", "refused", "numbers"}], "min_score"}` (`min_score`는 과제 2, 기본 0.9) | `{"total", "passed", "score", "area_ok", "refused_ok", "number_ok", "grounded", "failed": [...]}` (과제 2: + `min_score`, `gate`), 빈 목록이면 **400** |
|
||||||
|
|
||||||
|
### 사실 (`my-ops-facts`)
|
||||||
|
|
||||||
|
| 필드 | 정의 |
|
||||||
|
| --- | --- |
|
||||||
|
| `period` | `{"from", "to"}` 쓴 기간 |
|
||||||
|
| `runs` | 그 기간 실행 기록: `total`, `errors`, `error_rate`, `p95_ms`, `unanswered_rate` (**77일차 정의 그대로**), `top_failure` `{"node", "message", "count"}` 가장 많은 (노드, 메시지) 묶음 또는 `null` |
|
||||||
|
| `alerts` | `count`, `high`(`severity`가 `high`), `by_reason` `{reason: 수}` |
|
||||||
|
| `dlq` | `size`, `parked`, `waiting`(parked 아님) |
|
||||||
|
| `quality` | `batches`(품질 기록 줄 수), `avg_score`(4자리), `last_score`(마지막 줄, 없으면 `null`), `rejected_batches`, `quarantine`(격리함 수) |
|
||||||
|
| `cost` | `requests`, `calls`, `cost_usd`(6자리), `daily_usd`, `used_ratio`(`used_usd / daily_usd`, 4자리), `top_user` `{"user", "cost_usd", "share"}` 비용 1위 사용자 |
|
||||||
|
| `release` | `active`, `canary_release`(없으면 `null`), `canary_percent`(없으면 0), `active_repeat_rate` · `canary_repeat_rate`: 그 기간 `자동 답변` 실행 중 그 릴리스의 `repeat` 비율(4자리, 실행이 없으면 `null`) |
|
||||||
|
|
||||||
|
- 기간은 **실행 기록에만** 적용합니다. 알림 · DLQ · 품질 · 비용 · 릴리스는 "지금" 상태입니다. 0건이면 비율 · p95는 0, `top_failure` · 반복 비율은 `null`.
|
||||||
|
|
||||||
|
### 출처가 멈추면 (Lab 01 과제 2)
|
||||||
|
|
||||||
|
| 출처가 답하지 않으면 (HTTP 오류 · 모양이 다름) | 처리 |
|
||||||
|
| --- | --- |
|
||||||
|
| 그 영역 | 사실을 `null`, `sources_down`에 영역 이름 (이 순서: alerts, dlq, quality, cost, release — 실행 기록이 멈추면 워크플로우 오류) |
|
||||||
|
| 상태 | `health`의 그 영역은 `unknown`, `unknown` 목록에 (`red` · `yellow`에는 넣지 않음) |
|
||||||
|
| 전체 | red가 있으면 red, 아니면 yellow나 unknown이 있으면 **yellow**, 아니면 green — 모르는 것을 초록으로 말하지 않음 |
|
||||||
|
| 응답 | 언제나 HTTP 200 (다른 출처의 사실은 그대로) |
|
||||||
|
|
||||||
|
- HTTP 노드에 `neverError`를 켜야 출처 하나의 오류가 워크플로우 전체를 멈추지 않는다. 비용은 `usage/summary`의 `cost_usd`와 `budget`의 `daily_usd`가 숫자일 때만 "답했다"로 본다.
|
||||||
|
|
||||||
|
### 상태 (Lab 01 과제)
|
||||||
|
|
||||||
|
| 영역 | 🔴 red | 🟡 yellow | 그 밖 🟢 green |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `runs` | `error_rate > 0.01` | `unanswered_rate > 0.02` | |
|
||||||
|
| `alerts` | `high ≥ 5` | `count ≥ 1` | |
|
||||||
|
| `dlq` | `parked > 0` | `size > 0` | |
|
||||||
|
| `quality` | `last_score < 0.8` | `last_score < 0.9` 또는 `quarantine > 0` | |
|
||||||
|
| `cost` | `used_ratio ≥ 1` | `used_ratio ≥ 0.8` | |
|
||||||
|
| `release` | 카나리가 있고 두 비율이 모두 있고 `canary_repeat_rate > 2 × active_repeat_rate` | 카나리가 있음 | |
|
||||||
|
|
||||||
|
- `health` `{영역: 색}`, `red` · `yellow`: 그 색인 영역을 **이 순서**(runs, alerts, dlq, quality, cost, release)로, `overall`: red가 있으면 red, 아니면 yellow가 있으면 yellow, 아니면 green.
|
||||||
|
|
||||||
|
### 질문 답변 (`my-ops-ask`)
|
||||||
|
|
||||||
|
| 단계 | 규칙 |
|
||||||
|
| --- | --- |
|
||||||
|
| 작업 요청 (Lab 02 예제 2) | "다시 돌려 · 재처리해 · 지워 · 삭제해 · 꺼 줘 · 켜 줘 · 배포해 · 되돌려 · 롤백해 · 바꿔 줘 · 올려 줘 · 내려 줘 · 비워 줘"가 있으면 **거절**: `area: "action"`, `refused: true`, `reason: "read_only"`, LLM을 부르지 않음 |
|
||||||
|
| 낱말 라우터 | 영역마다 낱말 목록(아래), 맞은 낱말이 가장 많은 영역 (같으면 위의 순서 앞), 하나도 없으면 `unknown`, `router: "keyword"` |
|
||||||
|
| LLM 라우터 (Lab 02 과제) | 낱말 라우터가 `unknown`일 때만 LLM이 영역 하나 또는 `none`을 고름, `router: "llm"` |
|
||||||
|
| 운영 밖 | 끝까지 `unknown`이면 거절: `refused: true`, `reason: "out_of_scope"` |
|
||||||
|
| 답 | 사실 중 **그 영역만**(`overview`면 전부)을 LLM에게, 한국어 2~3문장, 사실에 있는 숫자만 |
|
||||||
|
| 숫자 확인 (Lab 02 예제 2) | 답의 숫자가 모두 그 영역 사실 안에 있는지(값, ×100, 반올림, 글자 속 숫자, 버전 `1.2.0`은 통째로) Code가 → `numbers_ok`, `unknown_numbers` |
|
||||||
|
|
||||||
|
| 영역 | 낱말 |
|
||||||
|
| --- | --- |
|
||||||
|
| `runs` | 오류, 에러, 실패율, 느려, 느린, 응답 시간, p95, 실행 |
|
||||||
|
| `alerts` | 알림, 경보 |
|
||||||
|
| `dlq` | dlq, 재처리, 쌓인, parked, 접수 실패 |
|
||||||
|
| `quality` | 품질, 배치, 격리 |
|
||||||
|
| `cost` | 비용, 돈, 예산, 토큰, 요금 |
|
||||||
|
| `release` | 릴리스, 버전, 카나리, 배포 |
|
||||||
|
| `overview` | 괜찮, 상태, 요약, 전체, 브리핑 |
|
||||||
|
|
||||||
|
### 브리핑 (`my-ops-brief`)
|
||||||
|
|
||||||
|
- 사실(상태 포함)을 다시 부름. `actions`: `red` 다음 `yellow`의 앞 3개, 영역마다 정해진 할 일 (아래). `markdown`: 제목 · 전체 · 영역 표(`| 영역 | 상태 | 숫자 |`) · "## 먼저 볼 것".
|
||||||
|
- (Lab 03 예제 2) LLM 세 문장 `summary` + 숫자 확인 `numbers_ok`, `markdown` 끝에 "## 요약".
|
||||||
|
|
||||||
|
| 영역 | 표의 이름 · 숫자 | 할 일 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `runs` | 실행 · `오류율 5.88% · p95 2598ms · 답 못 함 4.76%` | 오류 묶음 1위 확인 · 고치기 (77일차) |
|
||||||
|
| `alerts` | 알림 · `6건 (높음 3)` | 높은 알림부터 확인하고 닫기 (76일차) |
|
||||||
|
| `dlq` | DLQ · `3건 (parked 2)` | parked 건 원인 확인 뒤 런북대로 재처리 (76일차) |
|
||||||
|
| `quality` | 데이터 품질 · `평균 0.6583 · 마지막 1 · 격리 1` | 격리함을 콘텐츠 담당자에게 (78일차) |
|
||||||
|
| `cost` | 비용 · `$0.073604 / $0.08 (92.01%)` | 비용 1위 사용자 · 한도 확인 (75일차) |
|
||||||
|
| `release` | 릴리스 · `1.1.0 · 카나리 1.2.0 20% (다시 묻기 62.5%)` | 카나리 끄기 · 되돌리기 검토 (74일차) |
|
||||||
|
|
||||||
|
- 상태 표시: 🔴 · 🟡 · 🟢 (출처가 멈춘 영역은 ⚪, 숫자 칸은 `출처 응답 없음`).
|
||||||
|
|
||||||
|
### 감사 기록 (Lab 02 과제 2)
|
||||||
|
|
||||||
|
- 질문마다 응답하기 **전에** `POST http://data-api:8800/audit` 한 줄: `{"kind": "ops-ask", "question", "area", "router", "refused", "reason", "numbers_ok"}` (거절이면 `numbers_ok: null`). 갈래 셋(작업 거절 · 운영 밖 · 답) 모두. 응답은 그대로.
|
||||||
|
- 감사 기록은 **덧붙이기만** 하는 기록이라 읽기 전용 원칙의 예외입니다 (실행 기록 `/logs`와 같음). 다른 상태는 바꾸지 않는다.
|
||||||
|
|
||||||
|
### 평가 (`my-ops-eval`)
|
||||||
|
|
||||||
|
- 케이스마다 `my-ops-ask`를 부르고: `area` 같음 · `refused` 같음(없으면 false) · `numbers` 중 하나가 답에 있음(없으면 통과) · 거절이 아니면 `numbers_ok` true → 넷 다 맞으면 통과. `score` = 통과 / 전체 (4자리). `failed`에는 통과 못 한 케이스 `{"question", "area", "area_ok", "refused_ok", "number_ok", "grounded", "answer"}`.
|
||||||
|
- (Lab 03 과제 2) 평가 관문: `min_score`(본문, 없으면 0.9)와 비교해 `gate` = `score ≥ min_score`면 `pass`, 아니면 `fail`. `fail`이면 `POST /alerts {"workflow": "ops-eval", "node": "Below?", "severity": "medium", "reason": "eval_below", "score", "min_score", "message"}`. 응답은 평가 결과 그대로 (+ `min_score`, `gate`). 평가는 운영 도우미가 아니라 **도우미를 시험하는** 워크플로우라 알림을 남겨도 됩니다.
|
||||||
|
|
||||||
|
## 4. 워크플로우 파일 규칙
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"name": "79 Ops facts (my)", "nodes": [ ... ], "connections": { ... }, "settings": { "executionOrder": "v1" }}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 파일 하나에 워크플로우 하나. 노드 `name`은 영어로, 겹치지 않게. **API 키를 JSON에 쓰지 않는다.**
|
||||||
|
- **숫자 · 상태는 Code가, 글은 LLM이.** LLM에게 사실을 세게 하거나 상태를 판정하게 하지 않는다.
|
||||||
|
- 운영 도우미는 **읽기만** 한다: 데이터 API에 POST로 바꾸는 요청(`/dlq/update`, `/releases/...`, `/budget`, `/ops-reset` 등)을 워크플로우에서 부르지 않는다 (`/logs/query`는 읽기, `/audit`은 덧붙이는 기록 · Lab 02 과제 2, 평가의 `/alerts`는 Lab 03 과제 2).
|
||||||
|
|
||||||
|
## 5. 검증된 노드 (n8n 2.42.6에서 실행 확인)
|
||||||
|
|
||||||
|
### 여러 출처를 차례로 가져오기 → 한 Code에서 모으기
|
||||||
|
```json
|
||||||
|
{"name": "Get alerts", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [440, 300],
|
||||||
|
"parameters": {"url": "http://data-api:8800/alerts", "options": {}}}
|
||||||
|
```
|
||||||
|
- `Get logs`(POST `/logs/query`) → `Get alerts` → `Get DLQ` → `Get quality` → `Get quarantine` → `Get usage` → `Get budget` → `Get release` → `Make facts`(Code)를 **한 줄로** 잇는다. 아이템이 하나씩이라 각 HTTP 노드는 한 번만 부른다.
|
||||||
|
- `Make facts`에서 출처마다 `$('Get alerts').first().json`처럼 이름으로 읽는다. 웹훅 입력은 `$('Facts webhook').first().json.body`.
|
||||||
|
- 수업 준비 때 8개 출처를 다 모아도 0.1초 안팎.
|
||||||
|
|
||||||
|
### 다른 내 웹훅을 부르기 (답변 · 브리핑 → 사실, 평가 → 답변)
|
||||||
|
```json
|
||||||
|
{"name": "Get facts", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [880, 300],
|
||||||
|
"parameters": {"method": "POST", "url": "http://localhost:5678/webhook/my-ops-facts", "sendBody": true, "specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ from: $('Ask webhook').first().json.body?.from ?? '', to: $('Ask webhook').first().json.body?.to ?? '' }) }}", "options": {"timeout": 30000}}}
|
||||||
|
```
|
||||||
|
- 평가에서는 케이스를 아이템으로 나눈 뒤(Code) HTTP 노드 하나가 **아이템마다** `my-ops-ask`를 부른다. 결과의 i번째 = 케이스의 i번째.
|
||||||
|
|
||||||
|
### 거절은 LLM 앞에서 (IF → Respond)
|
||||||
|
- `Route`(Code) → `Action?`(IF) → 맞으면 바로 `Respond`(거절). 작업 요청은 LLM에게 보내지 않는다 (73일차 "막는 쪽으로").
|
||||||
|
|
||||||
|
### AI Agent + OpenAI chat model (답 글 · 분류 · 요약)
|
||||||
|
- 67~78일차와 같다. 분류는 지시문에 영역 목록과 "Reply with only the area word", 받은 글에서 영문자만 남겨 목록에 있는지 확인, 없으면 `unknown`.
|
||||||
|
|
||||||
|
## 6. 연결 · 7. 표현식
|
||||||
|
|
||||||
|
- 72~78일차와 같다. 키에 점이 없는 결과 필드는 테스트에서 `runs.top_failure.count`, `health.dlq`, `actions.0.area`처럼 내려간다.
|
||||||
|
|
||||||
|
## 8. 테스트 파일 (`cases/*.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"webhook": "my-ops-facts", "before": [{"url": "http://localhost:8800/ops-reset", "body": {}}], "cases": [
|
||||||
|
{"name": "지난주 실행", "body": {}, "expect": {"equals": {"runs.total": 357, "runs.error_rate": 0.0588}}},
|
||||||
|
{"name": "DLQ #3 처리 뒤", "before": [{"url": "http://localhost:8800/dlq/update", "body": {"dlq_id": 3, "remove": true}}], "body": {}, "expect": {"equals": {"dlq.size": 2}}}
|
||||||
|
]}
|
||||||
|
```
|
||||||
|
- 기대 숫자는 운영 아침 상태(`data-api/data/ops-seed.json`)와 실행 기록을 3절 정의대로 계산한 값입니다. **테스트를 통과시키려고 기대 결과나 데이터 파일을 바꾸지 않는다.** 정의가 다르다고 생각되면 사람에게 묻는다.
|
||||||
|
|
||||||
|
## 9. 하지 말 것
|
||||||
|
|
||||||
|
- `tools/`, `data-api/`(데이터 파일 포함), `requests/`, `reference/`, 이 파일을 고치지 않는다 (요청받은 경우만).
|
||||||
|
- 워크플로우 안에서 상태를 바꾸는 요청(`/ops-reset`, `/dlq/update`, `/dlq-clear`, `/releases/...` POST, `/budget` POST, 고장 스위치)을 부르지 않는다.
|
||||||
|
- 답 · 요약에 사실에 없는 숫자 · 짐작한 원인을 넣지 않는다. 운영 밖 질문에 답하지 않는다.
|
||||||
|
- n8n 컨테이너를 지우거나 `docker compose down`을 하지 않는다. `~/day65-n8n`을 읽거나 고치지 않는다. Credential을 새로 만들지 않는다. `.env`나 API 키를 출력하지 않는다.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@AGENTS.md
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Lab 01 · 운영 사실 · 흩어진 출처를 한 장으로
|
||||||
|
|
||||||
|
> **강사용 정답본** · 학생 저장소에는 수업이 끝난 뒤 올립니다. 학생용 문제는 [LAB-01-Q-OPS-FACTS.md](LAB-01-Q-OPS-FACTS.md).
|
||||||
|
> 요청문은 예시이며 CLI 에이전트로 보내 재지는 않았습니다. 결과 숫자는 실습을 5개로 늘린 뒤 정답 워크플로우를 학생 순서대로 2026-11-01에 1회씩 실행한 값입니다 (강사 Mac). 운영 아침 상태 · 실행 기록이 같으므로 누가 돌려도 같습니다. 명령 · 출력 전부: [answers/LAB-TEST-RESULT.md](answers/LAB-TEST-RESULT.md).
|
||||||
|
|
||||||
|
## 예제 1 · 결과 (에이전트 없이, `answers/01`)
|
||||||
|
|
||||||
|
| 출처 | `morning` | 기준 대시보드 | 빠진 것 · 틀린 것 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 실행 기록 | (지난주 357) | **666** · 3.153… | 기간을 넘기지 않음, 백분율 · 반올림 없음 |
|
||||||
|
| 알림 | 6 · 높음 3 | 6 | 높음 · 이유 |
|
||||||
|
| DLQ | 3 · parked 2 | 3 | **parked** |
|
||||||
|
| 품질 | 4줄 · 격리 1 | 없음 | 출처를 안 읽음 |
|
||||||
|
| 비용 | $0.073604 / $0.08 | 금액 둘 | 비율 · 1위 |
|
||||||
|
| 릴리스 | 1.1.0 + 카나리 1.2.0 20% | 없음 | **카나리** |
|
||||||
|
|
||||||
|
- 풀이 때: "틀린 숫자보다 없는 칸이 위험하다" — 기준만 보면 카나리 · parked(오늘 가장 급한 둘)를 모름.
|
||||||
|
|
||||||
|
## 예제 2 · 요청문 예
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/ops-facts.json 을 새로 만들어 줘. 웹훅 POST my-ops-facts, 이름 "79 Ops facts (my)".
|
||||||
|
Get logs(POST http://data-api:8800/logs/query) 에 본문의 from · to 를 넘기고, 비면 2026-10-26 · 2026-10-30 (지난주).
|
||||||
|
그 뒤 Get alerts · Get DLQ · Get quality · Get quarantine · Get usage · Get budget · Get release 를 한 줄로 잇고, Make facts(Code) 하나에서 AGENTS.md 3절 "사실" 표대로 모은다.
|
||||||
|
runs 는 77일차 정의 그대로 (error_rate 4자리 · p95 는 ceil(p × n)번째 · 0건이면 0), top_failure 는 (노드, 메시지) 묶음 1위 또는 null.
|
||||||
|
기간은 실행 기록에만 쓰고, 알림 · DLQ · 품질 · 비용 · 릴리스는 지금 상태. 숫자는 Code 가 세고 LLM 은 쓰지 않는다.
|
||||||
|
데이터 API 에 바꾸는 요청은 보내지 않는다 (읽기만). 기대 결과 cases/lab01.json.
|
||||||
|
```
|
||||||
|
|
||||||
|
**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`)
|
||||||
|
|
||||||
|
| 빈칸 | 답 | 왜 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① | `my-ops-facts` | AGENTS.md 3절 계약 · 테스트 파일의 `webhook` |
|
||||||
|
| ② | `2026-10-26 · 2026-10-30` | 운영 회의는 지난주를 봄 — 기준 대시보드는 기간 없이 666을 셈 |
|
||||||
|
| ③ | 실행 기록에만 | 알림 · DLQ · 비용은 '지금' 상태 — 기간을 대면 오히려 틀림 (예제 3) |
|
||||||
|
| ④ | 바꾸는 요청 | 운영 도우미는 읽기만. `/logs/query`는 읽기 |
|
||||||
|
|
||||||
|
정답: [reference/lab01-example2-facts.json](reference/lab01-example2-facts.json)
|
||||||
|
|
||||||
|
| 11/01 결과 | |
|
||||||
|
| --- | --- |
|
||||||
|
| 고치기 전 (`answers/02`, 기준에 같은 테스트) | **0/5** · 666 · 3.153…, 높음 · parked · 품질 · 릴리스 칸 없음 |
|
||||||
|
| 고친 뒤 (`answers/03`) | **5/5** · 357 · 0.0588 · 2598 · 실패 1위 16, 알림 6 · 3, DLQ 3 · 2 · 1, 품질 0.6583, 비용 0.9201 · `test-bot` 0.3699, 카나리 62.5% 대 7.73%, 0.1초 |
|
||||||
|
|
||||||
|
## 예제 3 · 결과 (에이전트 없이, `answers/04`)
|
||||||
|
|
||||||
|
| 기간 | 실행 | 다시 묻는 비율 | 지금 숫자 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 두 주 | 666 · 21 · 0.0315 | 7.73% · 62.5% | 그대로 |
|
||||||
|
| 10/26 | 70 · 16 · 0.2286 | `null` · `null` (그날 두 판이 없음 — 10/28부터) | 그대로 |
|
||||||
|
| 빈 주 | 0 · 0 · p95 0 · 실패 1위 `null` | `null` · `null` | 그대로 |
|
||||||
|
| 기간 없음 | 357 (지난주) | 7.73% · 62.5% | 그대로 |
|
||||||
|
|
||||||
|
- **5/5.** 풀이 때: 0("없었다")과 `null`("모른다")의 차이 → 과제 1의 "카나리 비교 불가 = 노랑".
|
||||||
|
|
||||||
|
## 과제 1 · 요청문 예
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/ops-facts.json 의 Make facts 에 영역별 상태를 더해 줘.
|
||||||
|
AGENTS.md 3절 "상태" 표대로 health {runs, alerts, dlq, quality, cost, release} 를 red · yellow · green 으로. red 를 yellow 보다 먼저 본다.
|
||||||
|
release 는 카나리가 있고 두 비율이 모두 있고 canary_repeat_rate 가 active 의 2배를 넘으면 red, 카나리만 있으면 yellow.
|
||||||
|
red · yellow 는 그 색인 영역을 runs, alerts, dlq, quality, cost, release 순서로, overall 은 red 가 있으면 red, 아니면 yellow 가 있으면 yellow, 아니면 green.
|
||||||
|
상태 판정은 LLM 이 아니라 Code. 앞의 사실은 그대로. 기대 결과 cases/lab01-challenge.json, cases/lab01.json 도.
|
||||||
|
```
|
||||||
|
|
||||||
|
**빈칸 답**
|
||||||
|
|
||||||
|
| 빈칸 | 답 | 왜 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① | 먼저 | 빨강 조건과 노랑 조건이 둘 다 맞으면 빨강 |
|
||||||
|
| ② | 2배 | 다시 묻는 비율 62.5%가 1.1.0(7.73%)의 두 배를 넘음 → 카나리 문제 |
|
||||||
|
| ③ | `runs, alerts, dlq, quality, cost, release` | 목록이 늘 같은 순서여야 테스트 · 브리핑이 같음 |
|
||||||
|
| ④ | Code | 정해진 규칙 — LLM에게 판정시키면 날마다 다름 |
|
||||||
|
|
||||||
|
정답: [answers/79-lab01-challenge-health.json](answers/79-lab01-challenge-health.json)
|
||||||
|
|
||||||
|
| 11/01 결과 | |
|
||||||
|
| --- | --- |
|
||||||
|
| 고치기 전 (`answers/05`) | **0/5** · 칸 없음 |
|
||||||
|
| 고친 뒤 (`answers/06`) | **5/5** · 🔴 runs · dlq · release, 🟡 alerts · quality · cost, 첫 주 빨강은 dlq 하나, 예산 $1이면 비용 🟢, parked 처리하면 DLQ 🟡. `lab01` 5/5 |
|
||||||
|
|
||||||
|
## 과제 2 · 요청문 예
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/ops-facts.json 이 출처 하나가 멈춰도 답하게 해 줘.
|
||||||
|
Get logs 를 뺀 HTTP 노드에 neverError 를 켜고, Make facts 에서 출처의 응답 모양을 본다 (alerts 목록 · DLQ items · quality items 와 quarantine size · usage cost_usd 와 budget daily_usd 가 숫자 · release active).
|
||||||
|
답하지 않은 영역은 사실을 null, sources_down 에 영역 이름, health 는 unknown, unknown 목록에.
|
||||||
|
overall 은 red 가 있으면 red, 아니면 yellow 나 unknown 이 있으면 yellow, 아니면 green — 모르는 것을 초록으로 말하지 않는다.
|
||||||
|
응답은 언제나 HTTP 200. 앞의 칸은 그대로. 기대 결과 cases/lab01-challenge2.json, lab01 · lab01-challenge · lab01-period 도.
|
||||||
|
```
|
||||||
|
|
||||||
|
**빈칸 답**
|
||||||
|
|
||||||
|
| 빈칸 | 답 | 왜 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① | `neverError` | 켜지 않으면 출처 하나의 500이 워크플로우 전체를 멈춤 |
|
||||||
|
| ② | `null` | 0이 아니라 '모름' — 0이면 '비용이 0'으로 읽힘 |
|
||||||
|
| ③ | `unknown` | 모르는 영역이 있으면 적어도 노랑 |
|
||||||
|
| ④ | `200` | 다른 다섯 영역의 사실은 쓸 수 있으므로 |
|
||||||
|
|
||||||
|
정답: [answers/79-lab01-challenge2-sources.json](answers/79-lab01-challenge2-sources.json)
|
||||||
|
|
||||||
|
| 11/01 결과 | |
|
||||||
|
| --- | --- |
|
||||||
|
| 고치기 전 (`answers/07`) | **0/3** · 사용량 출처가 멈추면 **500** (사실 전체가 없음) |
|
||||||
|
| 고친 뒤 (`answers/08`) | **3/3** · 200, `cost: null` · `sources_down: ["cost"]` · `health.cost: "unknown"`, 빨강 셋 그대로, 출처가 돌아오면 🟡 0.9201. `lab01` · `lab01-challenge` · `lab01-period` 5/5 |
|
||||||
|
|
||||||
|
- 풀이 때: 비용을 0으로 적었다면 🟢(실제는 92%) — "모르는 것을 좋게 말하는 대시보드가 가장 위험하다".
|
||||||
@@ -0,0 +1,745 @@
|
|||||||
|
# Lab 01 · 운영 사실 · 흩어진 출처를 한 장으로
|
||||||
|
|
||||||
|
- 관련 강의: [1단계 · 운영 사실](../../Lecture/79/01-STAGE1-OPS-FACTS.md)
|
||||||
|
- 소요 시간: 50분 (2교시 한 시간) · 예제 1 10분 · 예제 2 15분 · 예제 3 7분 · 과제 1 8분 · 과제 2 10분
|
||||||
|
- 준비: [실습 준비](../../Lecture/79/00-DAY79-PREP.md)의 `doctor` → `ready`, `morning`이 운영 아침 상태, 기준 사실 `base-ops-facts` 게시
|
||||||
|
- 목표: 72~78일차의 출처 여섯(실행 기록 · 알림 · DLQ · 품질 · 비용 · 릴리스)을 먼저 **하나씩 둘러보고**, 팀이 쓰던 아침 대시보드가 무엇을 틀리게 · 빠뜨리게 보여 주는지 찾는다. 출처를 정의대로 모은 **운영 사실**(`my-ops-facts`)을 만들고, 기간을 바꿔 무엇이 따라 바뀌는지 재 보고, 영역마다 **상태**(🔴 · 🟡 · 🟢)를 정하고, 출처 하나가 멈춰도 "모른다"고 답하게 한다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
E1["예제 1<br/>출처 둘러보기 ·<br/>기준 대시보드"] --> E2["예제 2<br/>운영 사실"] --> E3["예제 3<br/>기간을 바꾸면"] --> T1["과제 1<br/>영역별 상태"] --> T2["과제 2<br/>출처가 멈추면"]
|
||||||
|
```
|
||||||
|
|
||||||
|
> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다. 연습이 끝나면 `uv run tools/flow.py morning --reset`.
|
||||||
|
|
||||||
|
## 시작하기 · 터미널 두 개
|
||||||
|
|
||||||
|
| 터미널 | 하는 일 |
|
||||||
|
| --- | --- |
|
||||||
|
| ① 에이전트 | `cd ~/aio-01d4-stu/Last3Mon/79` 후 `codex` (또는 `claude`, `agy`). 내가 쓴 요청문을 보낸다 |
|
||||||
|
| ② 확인 | `cd ~/aio-01d4-stu/Last3Mon/79` 에서 `morning` · `call` · `push` · `test`를 **내가** 실행한다 |
|
||||||
|
|
||||||
|
`cp templates/OPS-ASSISTANT-TEMPLATE.md my-work/OPS.md` 후 표를 채워 갑니다. 쓴 요청문은 `my-work/PROMPTS.md`에 모읍니다.
|
||||||
|
|
||||||
|
## 예제 1 · 출처 둘러보기 · 팀의 기준 대시보드 (10분, 에이전트 없이)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** 수요일 아침 08:30의 운영 상태를 `morning`으로 출처마다 직접 둘러보고, 팀이 지난달 만든 아침 대시보드(`base-ops-facts`)가 같은 아침을 어떻게 보여 주는지 **비교**합니다. 고치는 것은 예제 2부터이고, 여기서는 **보고 비교해 적기만** 합니다.
|
||||||
|
|
||||||
|
> **생활 속 예 · 병원의 아침 인수인계.** 아침 8시 반, 간호사가 출근하면 밤 근무자에게 인수인계를 받습니다. 혈압 기록지, 투약 기록, 검사 결과, 호출 기록이 따로따로입니다. 하나씩 넘겨 보면 시간이 걸리고, 누가 "밤새 괜찮았어요"라고 요약하면 빠르지만 무엇을 빼먹었는지 모릅니다. 예제 1은 기록지를 직접 넘겨 보고, 지난달 만든 요약표(기준 대시보드)가 무엇을 빼먹는지 보는 실습입니다.
|
||||||
|
|
||||||
|
**상황.** 운영 담당자가 출근했습니다. 72~78일차에 만든 것이 출처 여섯에 흔적을 남겼습니다. 팀에는 지난달 만든 아침 대시보드 `base-ops-facts`가 있고, 지금까지 아침 회의에서 이 숫자를 봐 왔습니다.
|
||||||
|
|
||||||
|
**실험 둘.**
|
||||||
|
|
||||||
|
| 실험 | 명령 | 볼 것 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① 출처 둘러보기 | `morning` | 알림 몇 건 · 높음 몇 건, DLQ 몇 건 · parked 몇 건, 품질 점수, 비용 · 예산, 카나리 |
|
||||||
|
| ② 기준 대시보드에 지난주를 | `call base-ops-facts requests/week2.json` | 실행 수가 지난주(357)인가? 어떤 칸이 있고 없나? |
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** 운영 상태는 **여러 출처의 사실**을 모은 것이고, 모을 때 기간 · 정의 · 빠뜨린 출처에 따라 그림이 크게 달라진다는 것. 그리고 "좋아 보이는" 대시보드가 가장 급한 것(카나리 · parked)을 아예 안 보여 줄 수 있다는 것.
|
||||||
|
|
||||||
|
**적을 곳.** `my-work/OPS.md` 1절 표 (출처 · `morning`에서 본 것 · 기준 대시보드는 · 빠진 것).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py morning # ① 출처 여섯을 한눈에
|
||||||
|
uv run tools/flow.py call base-ops-facts requests/week2.json # ② {"from": "2026-10-26", "to": "2026-10-30"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
**출처 여섯.** [AGENTS.md](AGENTS.md) 2절 "운영 아침 상태"입니다. 72~78일차에 하나씩 만든 것들입니다.
|
||||||
|
|
||||||
|
| 출처 | 만든 날 | 묻는 것 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 실행 기록 `/logs/query` | 77 | 지난주 오류율 · p95 · 답 못 함, 가장 많은 실패 |
|
||||||
|
| 알림 `/alerts` | 76 · 78 | 몇 건, 높음 몇 건, 이유별 |
|
||||||
|
| DLQ `/dlq` | 76 | 몇 건, 사람이 봐야 할(parked) 것 |
|
||||||
|
| 품질 기록 · 격리함 | 78 | 배치 점수 평균 · 마지막, 격리 몇 건 |
|
||||||
|
| 사용량 · 예산 | 75 | 오늘 비용 · 예산의 몇 % · 1위 사용자 |
|
||||||
|
| 릴리스 `/releases/helpdesk` | 74 | 지금 판 · 카나리와 그 비율 |
|
||||||
|
|
||||||
|
**운영 사실 = 출처 + 정의.** "오류율"은 77일차 정의(끝날 포함 · 4자리 · 0건이면 0), "다시 묻는 비율"은 74일차 카나리 비교, "예산의 몇 %"는 75일차 예산입니다. 오늘의 사실은 **새 정의를 만드는 것이 아니라 지난 날들의 정의를 한 곳에 모으는 것**입니다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
S[("출처 여섯")] --> M["morning<br/>그대로 보여 줌"]
|
||||||
|
S --> B["base-ops-facts<br/>팀의 방식으로 모음"]
|
||||||
|
M --> C{"같은 아침?"}
|
||||||
|
B --> C
|
||||||
|
C -->|다르면| D["기간 · 정의 ·<br/>빠진 출처"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**기대 결과.** `morning`과 기준 대시보드의 숫자가 **다르고**, 기준에는 **없는 칸**이 있습니다. 무엇이 다른지는 아래 실행 결과를 보기 전에 먼저 찾아 봅니다.
|
||||||
|
|
||||||
|
<details><summary>▶ 1단계 · 기준의 실행 수가 357이 아니라면</summary>
|
||||||
|
|
||||||
|
기준 대시보드는 지난주를 물었는데 몇 건을 셌나요? 77일차의 두 주 666과 같다면, 기간을 데이터 API에 넘기지 않은 것입니다. `uv run tools/flow.py diff reference/baseline-ops-facts.json reference/lab01-example2-facts.json`로 `Get logs`의 본문을 비교해 봅니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 2단계 · 기준에 없는 것 중 가장 위험한 것</summary>
|
||||||
|
|
||||||
|
`morning`의 마지막 줄(릴리스)과 DLQ의 `parked`를 봅니다. 기준 대시보드만 보는 운영 담당자는 이 둘을 알 수 있나요?
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### 실행 결과 · 출처 둘러보기 · 기준 대시보드
|
||||||
|
|
||||||
|
수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다. 운영 아침 상태는 데이터 API를 켤 때마다 같은 상태로 채워지므로 누가 돌려도 같습니다 (`call`의 시간만 다름).
|
||||||
|
|
||||||
|
이 예제는 **고치지 않습니다.** 같은 아침을 `morning`(출처를 그대로)과 기준 대시보드(팀의 방식)로 보고 비교합니다.
|
||||||
|
|
||||||
|
#### ① `morning`으로 출처 둘러보기
|
||||||
|
|
||||||
|
실행한 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py morning
|
||||||
|
```
|
||||||
|
|
||||||
|
출력:
|
||||||
|
|
||||||
|
```text
|
||||||
|
seeded morning 2026-11-04T08:30:00 · {"alerts": 6, "dlq": 3, "quality_log": 4, "quarantine": 1, "usage": 192}
|
||||||
|
alerts: 6 (high 3)
|
||||||
|
[high] helpdesk/Search FAQ · timeout · timeout of 1000ms exceeded
|
||||||
|
[high] helpdesk/Answer · rate_limit · 429 Rate limit reached for requests
|
||||||
|
[high] faq-import/Batch ok? · batch_rejected · B-1 rejected
|
||||||
|
[medium] faq-import/Low score? · low_quality · B-2 quality 0.8
|
||||||
|
[medium] faq-import/Low score? · low_quality · B-3 quality 0.8333
|
||||||
|
[medium] intake/Create ticket · api_error · tickets API 503
|
||||||
|
DLQ: 3 (parked 2)
|
||||||
|
#1 attempts 3 parked True tickets API 503
|
||||||
|
#2 attempts 3 parked True tickets API 503
|
||||||
|
#3 attempts 1 parked False timeout
|
||||||
|
quality log: 4 (scores [0, 0.8, 0.8333, 1]) · quarantine 1
|
||||||
|
usage: 156 requests · 192 calls · $0.073604
|
||||||
|
budget: used $0.073604 of $0.08
|
||||||
|
release: active 1.1.0 · canary {"release": "1.2.0", "percent": 20}
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나**
|
||||||
|
|
||||||
|
- 알림 6건은 72~78일차가 남긴 것입니다: 77일차의 FAQ timeout · 429, 78일차의 B-1 거절 · 낮은 점수 둘, 76일차 접수 API 503. 높음은 3건.
|
||||||
|
- DLQ 3건 중 **2건이 parked** — 세 번 시도해도 실패해 사람이 봐야 하는 접수 건입니다 (76일차).
|
||||||
|
- 품질 기록 4줄은 78일차 Lab 03 과제 1의 B-1(0) · B-2 · B-3 · B-2-fix(1), 격리함에는 FAQ-305 하나.
|
||||||
|
- 오늘 비용 $0.073604 = 예산 $0.08의 약 92%. 카나리 1.2.0이 20% 켜져 있습니다 (74일차).
|
||||||
|
|
||||||
|
#### ② 기준 대시보드에 지난주를
|
||||||
|
|
||||||
|
실행한 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py call base-ops-facts requests/week2.json
|
||||||
|
```
|
||||||
|
|
||||||
|
출력:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP 200 · 1.4s
|
||||||
|
{ "runs": { "total": 666, "errors": 21, "error_rate": 3.153153153153153 },
|
||||||
|
"alerts": { "count": 6 },
|
||||||
|
"dlq": { "size": 3 },
|
||||||
|
"cost": { "used_usd": 0.073604, "daily_usd": 0.08 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나**
|
||||||
|
|
||||||
|
| 출처 | `morning` | 기준 대시보드 | 다른 까닭 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 실행 기록 | (지난주 357건) | **666건** · 3.153… | 기간을 데이터 API에 넘기지 않아 두 주 전체를 셈, 오류율은 백분율에 반올림 없음 |
|
||||||
|
| 알림 | 6 · 높음 3 | 6 | 높음 · 이유가 없음 |
|
||||||
|
| DLQ | 3 · **parked 2** | 3 | parked가 없음 — 사람이 볼 일이 보이지 않음 |
|
||||||
|
| 품질 | 4줄 · 격리 1 | **없음** | 출처를 읽지 않음 |
|
||||||
|
| 비용 | $0.073604 / $0.08 | 같음 | 비율 · 1위 사용자가 없음 |
|
||||||
|
| 릴리스 | 1.1.0 + **카나리 1.2.0 20%** | **없음** | 출처를 읽지 않음 |
|
||||||
|
|
||||||
|
- 기준만 보는 운영 담당자는 **카나리가 켜져 있다는 것**과 **parked 2건**을 모릅니다. 둘 다 오늘 아침 가장 급한 일입니다 (과제 1의 빨강).
|
||||||
|
- 숫자가 "틀린" 것보다 위험한 것은 **칸이 없는** 것입니다. 없는 칸은 대시보드에서 "괜찮다"처럼 보입니다.
|
||||||
|
|
||||||
|
## 예제 2 · 운영 사실 (15분)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** 예제 1에서 본 차이를 없애기 위해, 출처 여덟 개(실행 기록 · 알림 · DLQ · 품질 기록 · 격리함 · 사용량 · 예산 · 릴리스)를 AGENTS.md 3절 정의대로 모은 **운영 사실**(`my-ops-facts`)을 만듭니다. Lab 01의 중심 실습이고, 오늘 만드는 질문 답변 · 브리핑이 모두 이 사실을 부릅니다.
|
||||||
|
|
||||||
|
> **생활 속 예 · 환자 한 장 차트.** 기록지가 여러 장이면 인수인계 때마다 누군가 빠뜨립니다. 그래서 병원은 혈압 · 투약 · 검사 · 호출을 한 장 차트에 **정해진 칸으로** 모읍니다. 차트 칸이 정해져 있으면 누가 써도 같은 차트가 됩니다. 예제 2는 그 한 장 차트를 만드는 실습입니다.
|
||||||
|
|
||||||
|
| 예제 1에서 본 것 | 정의 (AGENTS.md 3절 "사실") | 확인하는 케이스 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 기간 없이 666을 셈 | 본문 `from` · `to`, 비면 지난주(10/26~10/30) | 지난주 · 첫 주 |
|
||||||
|
| 오류율이 백분율 · 끊지 않음 | 77일차 정의 그대로 (4자리 · `ceil(p × n)`) | 지난주 |
|
||||||
|
| 알림 · DLQ가 건수뿐 | `high` · `by_reason`, `parked` · `waiting` | 지금 상태 |
|
||||||
|
| 품질 · 릴리스가 없음 | `quality` · `release` (카나리 · 다시 묻는 비율) | 비용 · 릴리스 |
|
||||||
|
| "지금"이 바뀌면 | 사실은 부를 때마다 다시 셈 | DLQ #3 처리 |
|
||||||
|
|
||||||
|
**순서.** 같은 테스트를 **기준에 먼저** 돌려 0/5를 봄 → 요청문 → push → 5/5.
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** 여러 출처를 모을 때 정할 것(기간은 어디에 · 어느 칸을 · 0건이면), 숫자는 Code가 모으고 LLM은 쓰지 않는 까닭, 운영 도우미가 **읽기만** 하는 까닭.
|
||||||
|
|
||||||
|
**만들 것**: 기간을 받아 출처 여덟을 차례로 읽고 한 JSON으로 모아 돌려주는 워크플로우.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab01.json --webhook base-ops-facts -v # 고치기 전 (기준에 같은 테스트)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 조건 | 값 |
|
||||||
|
| --- | --- |
|
||||||
|
| 파일 · 웹훅 | `my-work/ops-facts.json` (새로) · `my-ops-facts` |
|
||||||
|
| 사실 | `period` · `runs` · `alerts` · `dlq` · `quality` · `cost` · `release` — **정의는 [AGENTS.md](AGENTS.md) 3절** |
|
||||||
|
| 계산 | HTTP 여덟 개를 한 줄로, Code 하나 (LLM 없음) |
|
||||||
|
| 기대 결과 | `cases/lab01.json` (지난주 · 지금 상태 · 비용 · 릴리스 · 첫 주 · DLQ #3 처리) |
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
**지금 무엇이 문제인가.** 기준 대시보드는 틀린 계산을 한 것이 아니라, **기간을 넘기지 않았고 · 정의가 77일차와 다르고 · 출처 둘을 빠뜨렸습니다.** 고치는 일은 "출처마다 정의를 정해 한 곳에 모으기"입니다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
W(["from · to"]) --> L["Get logs<br/>POST /logs/query"] --> A["Get alerts"] --> D["Get DLQ"] --> Q["Get quality"] --> X["Get quarantine"] --> U["Get usage"] --> B["Get budget"] --> R["Get release"] --> M["Make facts (Code)<br/>정의대로 모으기"] --> O(["사실"])
|
||||||
|
```
|
||||||
|
|
||||||
|
**HTTP를 한 줄로 잇는 까닭.** 아이템이 하나씩이라 각 HTTP 노드는 한 번만 부르고, `Make facts`가 `$('Get alerts').first().json`처럼 **이름으로** 모든 출처를 읽습니다 (AGENTS.md 5절). 여덟 출처를 다 모아도 0.1초 안팎입니다.
|
||||||
|
|
||||||
|
**기간은 실행 기록에만.** 알림 · DLQ · 품질 · 비용 · 릴리스는 "지금" 상태입니다. "지난주의 DLQ"라는 것은 오늘 데이터에 없습니다. 기간을 모든 출처에 대면 오히려 틀립니다 (예제 3에서 재 봄).
|
||||||
|
|
||||||
|
**읽기만.** 운영 도우미는 데이터 API의 상태를 바꾸지 않습니다 (`/dlq/update` · `/releases` · `/budget` POST를 부르지 않음). 사실을 모으는 곳이 상태를 바꾸면, 보고 있는 동안 그림이 바뀌고 무엇이 왜 바뀌었는지 모릅니다. 상태를 바꾸는 것은 런북에 따라 사람이 합니다 — 테스트의 `before`가 그 "사람"입니다.
|
||||||
|
|
||||||
|
**테스트 케이스.**
|
||||||
|
|
||||||
|
| 케이스 | 기대 |
|
||||||
|
| --- | --- |
|
||||||
|
| 지난주 실행 (기본 기간) | 357 · 21 · 0.0588 · p95 2598, 실패 1위 `Search FAQ` 16 |
|
||||||
|
| 지금 상태 · 알림 · DLQ · 품질 | 알림 6 · 높음 3 · `low_quality` 2, DLQ 3 · parked 2, 품질 평균 0.6583 · 마지막 1 · 격리 1 |
|
||||||
|
| 비용 · 릴리스 | 156 요청 · 예산의 0.9201 · 1위 `test-bot` 0.3699, 1.1.0 + 카나리 1.2.0 · 0.625 |
|
||||||
|
| 첫 주 | 실행 309 · 오류 0 · 실패 1위 `null`, DLQ는 그대로 3 |
|
||||||
|
| DLQ #3 처리 | 테스트가 먼저 #3을 처리 → DLQ 2 · waiting 0 |
|
||||||
|
|
||||||
|
**기대 결과.** 고치기 전(기준에 같은 테스트) 0/5, 고친 뒤 5/5.
|
||||||
|
|
||||||
|
<details><summary>▶ 1단계 · 정의를 요청문에</summary>
|
||||||
|
|
||||||
|
"운영 상태를 모아 줘"만 쓰면 에이전트마다 칸이 다릅니다. 3절 "사실" 표를 그대로 요청문에 넣거나 "AGENTS.md 3절 사실 표대로"라고 씁니다. 실행 기록은 "77일차 정의 그대로", 기간의 기본값, 0건일 때도.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 2단계 · 비율 · 1위는 어떻게</summary>
|
||||||
|
|
||||||
|
`used_ratio`는 `used_usd / daily_usd`(4자리), `top_user`는 `by_user`를 `cost_usd`로 정렬한 1위와 그 몫(`share`). 다시 묻는 비율은 그 기간 `자동 답변` 실행 중 그 릴리스의 `repeat` 비율입니다 (74 · 77일차).
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||||
|
|
||||||
|
요청문에 꼭 들어갈 것: **① 파일 · 웹훅 ② 기간과 기본값 ③ 출처 · 노드 순서 · 정의 ④ 기간을 쓰는 곳 ⑤ 하지 않을 것 · 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/ops-facts.json 을 새로 만들어 줘. 웹훅 POST ____①, 이름 "79 Ops facts (my)".
|
||||||
|
Get logs(POST http://data-api:8800/logs/query) 에 본문의 from · to 를 넘기고, 비면 ____② (지난주).
|
||||||
|
그 뒤 Get alerts · Get DLQ · Get quality · Get quarantine · Get usage · Get budget · Get release 를 한 줄로 잇고, Make facts(Code) 하나에서 AGENTS.md 3절 "사실" 표대로 모은다.
|
||||||
|
runs 는 77일차 정의 그대로 (error_rate 4자리 · p95 는 ceil(p × n)번째 · 0건이면 0), top_failure 는 (노드, 메시지) 묶음 1위 또는 null.
|
||||||
|
기간은 ____③ 쓰고, 알림 · DLQ · 품질 · 비용 · 릴리스는 지금 상태. 숫자는 Code 가 세고 LLM 은 쓰지 않는다.
|
||||||
|
데이터 API 에 ____④은 보내지 않는다 (읽기만). 기대 결과 cases/lab01.json.
|
||||||
|
```
|
||||||
|
|
||||||
|
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
|
||||||
|
|
||||||
|
- ① AGENTS.md 3절 계약 표의 "운영 사실" 줄입니다. 테스트 파일 `cases/lab01.json` 맨 위의 `webhook`과 같아야 합니다.
|
||||||
|
- ② 같은 줄의 "입력" 칸. 운영 회의는 어느 기간을 보나요?
|
||||||
|
- ③ 3절 "사실" 표 아래 줄. 알림 · DLQ에 기간을 대면 무엇이 남나요?
|
||||||
|
- ④ AGENTS.md 4절의 마지막 줄. 운영 도우미가 데이터 API에 하지 않는 일.
|
||||||
|
|
||||||
|
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/ops-facts.json
|
||||||
|
uv run tools/flow.py test cases/lab01.json -v
|
||||||
|
uv run tools/flow.py call my-ops-facts requests/week2.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실행 결과 · 운영 사실
|
||||||
|
|
||||||
|
수업 준비 때(11/01) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. 실행 기록과 운영 아침 상태가 같으므로 숫자는 누구에게나 같습니다.
|
||||||
|
|
||||||
|
#### 고치기 전 · 기준(`base-ops-facts`)에 같은 테스트
|
||||||
|
|
||||||
|
실행한 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab01.json --webhook base-ops-facts -v
|
||||||
|
```
|
||||||
|
|
||||||
|
출력 (결과 줄만):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", …
|
||||||
|
✘ 지난주 실행 (기본 기간) · 0.4s · period.from = null (expected 2026-10-26); runs.total = 666 (expected 357); runs.error_rate = 3.153153153153153 (expected 0.0588); runs.p95_ms = null (expected 2598); runs.top_failure.node = null (expected Search FAQ); runs.top_failure.count = null (expected 16)
|
||||||
|
✘ 지금 상태 · 알림 · DLQ · 품질 · 0.1s · alerts.high = null (expected 3); alerts.by_reason.low_quality = null (expected 2); dlq.parked = null (expected 2); quality.avg_score = null (expected 0.6583); quality.last_score = null (expected 1); quality.quarantine = null (expected 1)
|
||||||
|
✘ 비용 · 릴리스 · 0.1s · cost.requests = null (expected 156); cost.used_ratio = null (expected 0.9201); cost.top_user.user = null (expected test-bot); cost.top_user.share = null (expected 0.3699); release.active = null (expected 1.1.0); release.canary_release = null (expected 1.2.0); …
|
||||||
|
✘ 첫 주 (기간을 바꾸면 실행만 바뀜) · 0.1s · runs.total = 666 (expected 309); runs.errors = 21 (expected 0)
|
||||||
|
✔ before: POST http://localhost:8800/dlq/update · HTTP 200 · 0.0s · {"removed": true, "size": 2}
|
||||||
|
✘ 상태가 바뀌면 숫자도 (DLQ #3 처리) · 0.1s · dlq.waiting = null (expected 0)
|
||||||
|
Passed 0/5
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나 (고치기 전)**
|
||||||
|
|
||||||
|
- **첫 줄 `before: /ops-reset`:** 테스트가 먼저 운영 아침을 처음 상태로 되돌립니다. 테스트끼리 서로의 흔적(처리한 DLQ · 올린 예산)을 넘겨받지 않게.
|
||||||
|
- **지난주 · 첫 주 모두 666:** 기간을 넘기지 않으니 무엇을 물어도 두 주 전체입니다.
|
||||||
|
- **나머지는 `null`:** 그런 칸이 없습니다 (높음 · parked · 품질 · 비율 · 1위 · 릴리스).
|
||||||
|
- **마지막 케이스 앞의 `before`:** 테스트가 사람 대신 DLQ #3을 처리합니다(`size` 2). 기준도 `dlq.size`는 2로 바꿔 보이지만 `waiting` 칸이 없어 ✘.
|
||||||
|
- 0/5는 **예상한 결과**입니다.
|
||||||
|
|
||||||
|
#### 고친 뒤 · 내 `my-ops-facts`
|
||||||
|
|
||||||
|
실행한 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/ops-facts.json
|
||||||
|
uv run tools/flow.py test cases/lab01.json -v
|
||||||
|
uv run tools/flow.py call my-ops-facts requests/week2.json
|
||||||
|
```
|
||||||
|
|
||||||
|
출력 (결과 줄과 `call`):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ 지난주 실행 (기본 기간) · 1.1s
|
||||||
|
✔ 지금 상태 · 알림 · DLQ · 품질 · 0.4s
|
||||||
|
✔ 비용 · 릴리스 · 0.1s
|
||||||
|
✔ 첫 주 (기간을 바꾸면 실행만 바뀜) · 0.1s
|
||||||
|
✔ before: POST http://localhost:8800/dlq/update · HTTP 200 · 0.0s · {"removed": true, "size": 2}
|
||||||
|
✔ 상태가 바뀌면 숫자도 (DLQ #3 처리) · 0.1s
|
||||||
|
Passed 5/5
|
||||||
|
|
||||||
|
HTTP 200 · 0.1s
|
||||||
|
{ "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} }
|
||||||
|
```
|
||||||
|
|
||||||
|
**고친 뒤 이렇게 나온 까닭**
|
||||||
|
|
||||||
|
| 칸 | 고치기 전 (기준) | 고친 뒤 | 무엇이 바꿨나 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 지난주 실행 | 666 · 3.153… | 357 · 0.0588 · p95 2598 | 기간을 `/logs/query`에, 77일차 정의 |
|
||||||
|
| 실패 1위 | 없음 | `Search FAQ` timeout 16 | (노드, 메시지)로 묶기 |
|
||||||
|
| 알림 · DLQ | 건수뿐 | 높음 3 · 이유별, parked 2 · waiting 1 | 출처의 칸을 정의대로 |
|
||||||
|
| 품질 | 없음 | 평균 0.6583 · 마지막 1 · 격리 1 | 출처 둘을 더 읽음 |
|
||||||
|
| 비용 | 금액뿐 | 예산의 0.9201 · 1위 `test-bot` 37% | 비율 · 정렬 |
|
||||||
|
| 릴리스 | 없음 | 1.1.0 · 카나리 1.2.0 20% · 다시 묻기 7.73% 대 **62.5%** | 출처 + 실행 기록의 `repeat` |
|
||||||
|
|
||||||
|
- **같은 숫자 · 다른 날:** 실행 357 · 0.0588 · 2598 · 실패 1위 16은 77일차 `my-log-report`의 둘째 주와 같습니다. 정의를 다시 만들지 않고 모았기 때문입니다.
|
||||||
|
- **릴리스의 62.5%:** 카나리로 답한 실행의 다시 묻는 비율이 지금 판(7.73%)의 여덟 배입니다. 기준 대시보드에서는 아예 보이지 않던 숫자입니다.
|
||||||
|
- **0.1초:** 출처 여덟을 한 줄로 읽어 Code 하나에서 모았습니다. LLM 없음, 데이터 API의 상태는 하나도 바꾸지 않았습니다.
|
||||||
|
|
||||||
|
## 예제 3 · 기간을 바꾸면 (7분, 에이전트 없이)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** 예제 2의 사실에 기간만 바꿔(두 주 · 하루 · 빈 주 · 기간 없음) 물어, **어느 숫자가 기간을 따라 바뀌고 어느 숫자는 그대로인지** 봅니다. 고치는 실습이 아니라 **재 보는** 실습입니다.
|
||||||
|
|
||||||
|
> **생활 속 예 · "지난주 혈압"과 "지금 열".** 인수인계 때 "지난주 혈압 추이"는 기간을 정해 보지만, "지금 열이 있나 · 지금 수액이 몇 개 남았나"는 기간이 없습니다. 둘을 같은 기간으로 물으면 엉뚱한 답이 나옵니다. 예제 3은 차트의 어느 칸이 기간을 따르는지 재 보는 실습입니다.
|
||||||
|
|
||||||
|
**상황.** 운영 회의는 지난주를 보지만, 사고가 난 날(10/26 월)만 보거나, 두 주를 보거나, 이번 주(아직 기록 없음)를 물을 때도 있습니다. `cases/lab01-period.json`을 예제 2의 사실에 돌립니다.
|
||||||
|
|
||||||
|
| 기간 | 보내는 것 | 기대 (정의대로) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 두 주 | `two-weeks` (10/19~10/30) | 실행 666 · 오류 21 · 0.0315 |
|
||||||
|
| 하루 | `monday` (10/26) | 70 · 16, 실패 1위 `Search FAQ` |
|
||||||
|
| 빈 주 | `empty-week` (11/02~11/06) | 0 · 0 · p95 0, 실패 1위 `null`, 다시 묻는 비율 `null` — 200 |
|
||||||
|
| 기간 없음 | `{}` | 지난주 (10/26~10/30) · 357 |
|
||||||
|
| "지금" 숫자 | `empty-week` | 알림 6 · DLQ 3 · 품질 0.6583 · 비용 0.9201 · 1.1.0 — 그대로 |
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** 운영 사실에는 **기간이 있는 숫자**(실행 기록)와 **지금 숫자**(상태)가 섞여 있고, 둘을 구분해 말해야 한다는 것. 0건 · 없음(`null`)의 차이.
|
||||||
|
|
||||||
|
**적을 곳.** `my-work/OPS.md` 2절 표 (기간 · 실행 · 그대로인 것 · `null`이 된 것).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab01-period.json -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
**두 종류의 숫자.**
|
||||||
|
|
||||||
|
| | 기간이 있는 숫자 | 지금 숫자 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 예 | 오류율 · p95 · 답 못 함 · 실패 1위 · 다시 묻는 비율 | 알림 · DLQ · 품질 · 비용 · 지금 판 |
|
||||||
|
| 출처 | 실행 기록 (일어난 일의 목록) | 상태 (지금 남아 있는 것) |
|
||||||
|
| 기간을 바꾸면 | 바뀜 | 그대로 |
|
||||||
|
| 보고할 때 | "10/26~10/30에" | "08:30 지금" |
|
||||||
|
|
||||||
|
**0과 `null`.** 빈 주의 오류율은 **0**(0건이면 0, 77일차 정의)이지만, 다시 묻는 비율은 **`null`**입니다 — 그 기간에 그 판으로 답한 실행이 없으니 비율을 말할 수 없습니다. 0은 "없었다", `null`은 "모른다"입니다. 과제 1의 상태 판정에서 이 차이가 중요해집니다 ("카나리 비교 불가").
|
||||||
|
|
||||||
|
**기대 결과.** 5/5. 실패하면 예제 2의 요청문에 "기간은 실행 기록에만" · "0건이면 0"이 빠진 것입니다.
|
||||||
|
|
||||||
|
<details><summary>▶ 1단계 · 하루(10/26)인데 다시 묻는 비율이 `null`이라면</summary>
|
||||||
|
|
||||||
|
그날 1.1.0 · 1.2.0으로 답한 실행이 있었나요? `uv run tools/flow.py logs --from 2026-10-26 --to 2026-10-26 --last 0`과 77일차 `by_release`를 떠올려 봅니다. 실행이 없으면 비율은 `null`이 맞습니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### 실행 결과 · 기간을 바꾸면
|
||||||
|
|
||||||
|
수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다 (예제 2의 사실, 고치지 않음).
|
||||||
|
|
||||||
|
실행한 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab01-period.json -v
|
||||||
|
```
|
||||||
|
|
||||||
|
출력 (결과 줄과 `-v`로 본 응답의 실행 · 릴리스 부분):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ 두 주 (10/19~10/30) · 0.1s
|
||||||
|
✔ 하루 (10/26) · 0.1s
|
||||||
|
✔ 빈 기간 (11/02~11/06) · 0건이어도 200 · 0.1s
|
||||||
|
✔ 기간을 비우면 지난주 (기본) · 0.1s
|
||||||
|
✔ 기간과 상관없는 '지금' 숫자는 그대로 · 0.1s
|
||||||
|
Passed 5/5
|
||||||
|
|
||||||
|
두 주 runs {"total": 666, "errors": 21, "error_rate": 0.0315, "p95_ms": 2537, "unanswered_rate": 0.0511, top_failure 16}
|
||||||
|
release {"active_repeat_rate": 0.0773, "canary_repeat_rate": 0.625}
|
||||||
|
하루 runs {"total": 70, "errors": 16, "error_rate": 0.2286, "p95_ms": 2468, "unanswered_rate": 0.0571, top_failure 16}
|
||||||
|
release {"active_repeat_rate": null, "canary_repeat_rate": null}
|
||||||
|
빈 기간 runs {"total": 0, "errors": 0, "error_rate": 0, "p95_ms": 0, "unanswered_rate": 0, "top_failure": null}
|
||||||
|
release {"active_repeat_rate": null, "canary_repeat_rate": null}
|
||||||
|
기간 없음 period {"from": "2026-10-26", "to": "2026-10-30"} · runs.total 357
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나 (기간마다)**
|
||||||
|
|
||||||
|
| 기간 | 실행 | 다시 묻는 비율 | 지금 숫자 (알림 · DLQ · 품질 · 비용 · 판) |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 두 주 | 666 · 21 · 0.0315 | 7.73% · 62.5% | 6 · 3 · 0.6583 · 0.9201 · 1.1.0 |
|
||||||
|
| 하루 (10/26) | 70 · 16 · 0.2286 | **`null` · `null`** | 같음 |
|
||||||
|
| 빈 주 | 0 · 0 · 0 · p95 0 | `null` · `null` | 같음 |
|
||||||
|
| 기간 없음 | 357 (지난주) | 7.73% · 62.5% | 같음 |
|
||||||
|
|
||||||
|
- **실행 기록만 바뀝니다.** 알림 · DLQ · 품질 · 비용 · 판은 다섯 기간 모두 같습니다 — "지금" 숫자이기 때문입니다.
|
||||||
|
- **하루(10/26)에 다시 묻는 비율이 `null`:** 그날은 1.1.0 · 1.2.0으로 답한 실행이 없었습니다 (두 판은 둘째 주 뒤쪽에 나옴). 비율을 말할 수 없으니 `null`이 맞습니다. 두 주의 비율이 지난주와 같은 것도 같은 까닭 — 첫 주에는 두 판이 없었습니다.
|
||||||
|
- **빈 주:** 오류율 · p95는 **0**(0건이면 0), 실패 1위 · 다시 묻는 비율은 **`null`**(모름). 두 값의 뜻이 다릅니다. 과제 1에서 비율이 `null`이면 카나리를 빨강이 아니라 노랑으로 둡니다.
|
||||||
|
- 운영 회의에서 이 사실을 말할 때는 "실행은 10/26~10/30, 나머지는 08:30 지금"처럼 **두 시간을 나눠** 말합니다.
|
||||||
|
|
||||||
|
## 과제 1 · 영역별 상태 (8분)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** 사실에 영역마다 **상태**(`red` · `yellow` · `green`)와 빨강 · 노랑 목록, 전체 상태를 더해, 숫자를 다 읽지 않아도 "어디부터 볼까"가 보이게 합니다.
|
||||||
|
|
||||||
|
> **생활 속 예 · 활력 징후의 색.** 병동 화면은 환자마다 숫자(혈압 · 맥박 · 산소)를 띄우지만, 기준을 넘으면 칸이 **빨갛게** 변합니다. 간호사는 숫자를 다 읽기 전에 빨간 칸부터 봅니다. 그 기준은 간호사 개인이 아니라 병원이 정해 둡니다. 과제 1은 영역마다 그 기준과 색을 정하는 실습입니다.
|
||||||
|
|
||||||
|
**상황.** 사실은 숫자가 서른 개가 넘습니다. 팀장이 요청했습니다: "영역마다 신호등 하나, 빨강 · 노랑 목록, 전체 한 단어를 주세요. 기준은 AGENTS.md에 적어 둔 대로."
|
||||||
|
|
||||||
|
| 영역 | 🔴 red | 🟡 yellow |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| runs | 오류율 > 0.01 | 답 못 함 > 0.02 |
|
||||||
|
| alerts | 높음 ≥ 5 | 1건 이상 |
|
||||||
|
| dlq | parked > 0 | 1건 이상 |
|
||||||
|
| quality | 마지막 점수 < 0.8 | < 0.9 또는 격리 > 0 |
|
||||||
|
| cost | 예산의 100% 이상 | 80% 이상 |
|
||||||
|
| release | 카나리의 다시 묻는 비율 > 지금 판의 2배 | 카나리가 있음 |
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** 상태는 **정해진 기준**(SLO · 77일차)의 다른 모양이라는 것, 판정은 Code가 늘 같게 해야 한다는 것, 목록의 **순서**도 정의라는 것.
|
||||||
|
|
||||||
|
**적을 곳.** `my-work/OPS.md` 3절 표 (영역 · 상태 · 그 숫자 · 기준).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab01-challenge.json -v # 고치기 전 (예제 2의 사실)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
F["사실<br/>(예제 2)"] --> H["health<br/>영역마다 red → yellow → green<br/>(red 먼저 봄)"] --> L["red · yellow 목록<br/>runs, alerts, dlq, quality, cost, release 순"] --> O(["overall"])
|
||||||
|
```
|
||||||
|
|
||||||
|
**빨강을 먼저 봅니다.** 한 영역이 빨강 조건과 노랑 조건에 둘 다 맞으면 빨강입니다. 그리고 목록은 늘 **같은 순서**(runs, alerts, dlq, quality, cost, release)로 — 브리핑(Lab 03)의 "먼저 볼 것"이 이 순서를 씁니다.
|
||||||
|
|
||||||
|
**카나리 비교 불가.** 첫 주에는 카나리 1.2.0으로 답한 실행이 없어 다시 묻는 비율이 `null`입니다. 비교할 수 없으니 빨강이 아니라 **노랑**(카나리가 있음)입니다. 모르는 것을 나쁘다고도 좋다고도 하지 않습니다.
|
||||||
|
|
||||||
|
**테스트 케이스.** 테스트의 일부는 먼저 "사람의 작업"을 합니다 (예산 올리기 · parked 처리).
|
||||||
|
|
||||||
|
| 케이스 | 기대 |
|
||||||
|
| --- | --- |
|
||||||
|
| 지난주 영역별 상태 | runs · dlq · release 🔴, alerts · quality · cost 🟡, 전체 🔴 |
|
||||||
|
| 빨강 · 노랑 목록 | `["runs", "dlq", "release"]` · `["alerts", "quality", "cost"]` |
|
||||||
|
| 첫 주 | runs 🟡 (오류 0 · 답 못 함 5.5%), release 🟡 (비교 불가), 빨강은 dlq 하나 |
|
||||||
|
| 예산을 늘리면 | 비용 🟢 (0.0736) |
|
||||||
|
| parked를 처리하면 | DLQ 🟡, 빨강은 runs · release |
|
||||||
|
|
||||||
|
**기대 결과.** 고치기 전 0/5, 고친 뒤 5/5, `lab01` 5/5 그대로.
|
||||||
|
|
||||||
|
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||||
|
|
||||||
|
요청문에 꼭 들어갈 것: **① 판정 순서 ② 릴리스 기준 ③ 목록 순서 ④ 누가 판정하나**. 아래 빈칸 4개를 채웁니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/ops-facts.json 의 Make facts 에 영역별 상태를 더해 줘.
|
||||||
|
AGENTS.md 3절 "상태" 표대로 health {runs, alerts, dlq, quality, cost, release} 를 red · yellow · green 으로. red 를 yellow 보다 ____① 본다.
|
||||||
|
release 는 카나리가 있고 두 비율이 모두 있고 canary_repeat_rate 가 active 의 ____②를 넘으면 red, 카나리만 있으면 yellow.
|
||||||
|
red · yellow 는 그 색인 영역을 ____③ 순서로, overall 은 red 가 있으면 red, 아니면 yellow 가 있으면 yellow, 아니면 green.
|
||||||
|
상태 판정은 LLM 이 아니라 ____④. 앞의 사실은 그대로. 기대 결과 cases/lab01-challenge.json, cases/lab01.json 도.
|
||||||
|
```
|
||||||
|
|
||||||
|
빈칸 힌트:
|
||||||
|
|
||||||
|
- ① 두 조건이 다 맞으면 어느 색인가요?
|
||||||
|
- ② 3절 "상태" 표의 release 줄.
|
||||||
|
- ③ 3절 표 아래 줄의 "이 순서".
|
||||||
|
- ④ AGENTS.md 4절 "숫자 · 상태는 …".
|
||||||
|
</details>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/ops-facts.json
|
||||||
|
uv run tools/flow.py test cases/lab01-challenge.json -v
|
||||||
|
uv run tools/flow.py test cases/lab01.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실행 결과 · 영역별 상태
|
||||||
|
|
||||||
|
수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다.
|
||||||
|
|
||||||
|
#### 고치기 전 · 예제 2의 사실
|
||||||
|
|
||||||
|
실행한 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab01-challenge.json -v
|
||||||
|
```
|
||||||
|
|
||||||
|
출력 (결과 줄만):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✘ 지난주 영역별 상태 · 0.1s · health.runs = null (expected red); health.alerts = null (expected yellow); health.dlq = null (expected red); health.quality = null (expected yellow); health.cost = null (expected yellow); health.release = null (expected red); overall = null (expected red)
|
||||||
|
✘ 빨강 · 노랑 목록 (정해진 순서) · 0.1s · red = null (expected ["runs", "dlq", "release"]); yellow = null (expected ["alerts", "quality", "cost"])
|
||||||
|
✘ 첫 주: 오류는 없지만 답 못 함 · 카나리 비교 불가 · 0.1s · health.runs = null (expected yellow); health.release = null (expected yellow); red = null (expected ["dlq"])
|
||||||
|
✔ before: POST http://localhost:8800/budget · HTTP 200 · 0.0s · {"daily_usd": 1.0, …, "used_usd": 0.073604, "remaining_usd": 0.926396, …
|
||||||
|
✘ 예산을 늘리면 비용은 초록 · 0.1s · health.cost = null (expected green)
|
||||||
|
✔ before: POST http://localhost:8800/dlq/update · HTTP 200 · 0.0s · {"removed": true, "size": 2}
|
||||||
|
✔ before: POST http://localhost:8800/dlq/update · HTTP 200 · 0.0s · {"removed": true, "size": 1}
|
||||||
|
✘ parked를 처리하면 DLQ는 노랑 · 0.1s · health.dlq = null (expected yellow); red = null (expected ["runs", "release"])
|
||||||
|
Passed 0/5
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나 (고치기 전):** 예제 2의 사실에는 `health` · `red` · `yellow` · `overall` 칸이 없어 모두 `null`입니다. 넷째 · 다섯째 케이스 앞의 `before`는 사람의 작업(예산을 $1로 · parked 둘 처리)입니다. 0/5는 예상한 결과.
|
||||||
|
|
||||||
|
#### 고친 뒤
|
||||||
|
|
||||||
|
실행한 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/ops-facts.json
|
||||||
|
uv run tools/flow.py test cases/lab01-challenge.json -v
|
||||||
|
uv run tools/flow.py test cases/lab01.json
|
||||||
|
```
|
||||||
|
|
||||||
|
출력 (결과 줄과 지난주의 상태):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ 지난주 영역별 상태 · 1.1s
|
||||||
|
✔ 빨강 · 노랑 목록 (정해진 순서) · 0.3s
|
||||||
|
✔ 첫 주: 오류는 없지만 답 못 함 · 카나리 비교 불가 · 0.1s
|
||||||
|
✔ 예산을 늘리면 비용은 초록 · 0.1s
|
||||||
|
✔ parked를 처리하면 DLQ는 노랑 · 0.1s
|
||||||
|
Passed 5/5
|
||||||
|
|
||||||
|
"health": {"runs": "red", "alerts": "yellow", "dlq": "red", "quality": "yellow", "cost": "yellow", "release": "red"},
|
||||||
|
"red": ["runs", "dlq", "release"], "yellow": ["alerts", "quality", "cost"], "overall": "red"
|
||||||
|
|
||||||
|
(lab01) Passed 5/5
|
||||||
|
```
|
||||||
|
|
||||||
|
**고친 뒤 이렇게 나온 까닭**
|
||||||
|
|
||||||
|
| 영역 | 숫자 | 상태 | 어느 기준에 걸렸나 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| runs | 오류율 0.0588 | 🔴 | > 0.01 |
|
||||||
|
| alerts | 6건 · 높음 3 | 🟡 | 높음 5 미만, 1건 이상 |
|
||||||
|
| dlq | parked 2 | 🔴 | parked > 0 |
|
||||||
|
| quality | 마지막 1 · 격리 1 | 🟡 | 격리 > 0 |
|
||||||
|
| cost | 0.9201 | 🟡 | ≥ 0.8 |
|
||||||
|
| release | 62.5% > 2 × 7.73% | 🔴 | 카나리가 두 배 넘게 나쁨 |
|
||||||
|
|
||||||
|
- **첫 주:** 오류 0이지만 답 못 함 5.5%(> 0.02) → runs 🟡. 카나리로 답한 실행이 없어 비율 `null` → 비교 불가 → release 🟡. 빨강은 parked가 있는 dlq 하나.
|
||||||
|
- **예산을 $1로:** 0.073604 / 1 = 0.0736 → 비용 🟢. **parked 둘 처리:** parked 0 · 대기 1 → DLQ 🟡, 빨강은 runs · release.
|
||||||
|
- 상태는 숫자를 바꾸지 않습니다. 같은 사실에 **정해진 기준**을 댄 것입니다 (77일차 SLO와 같음). `lab01` 5/5 그대로.
|
||||||
|
|
||||||
|
## 과제 2 · 출처가 멈추면 (10분)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** 비용 출처(`/usage/summary`)가 멈춘 아침에도 사실이 **500 대신 200**으로 답하고, 그 영역은 "모름"(`null` · `unknown`)으로, 나머지 다섯 영역은 그대로 보여 주게 합니다.
|
||||||
|
|
||||||
|
> **생활 속 예 · 혈압계가 고장 난 아침.** 혈압계가 고장 났다고 인수인계를 통째로 안 할 수는 없습니다. "혈압: 오늘 아침 못 잼"이라고 적고 나머지는 그대로 넘깁니다. 그리고 "혈압 정상"이라고 **지어 적지 않습니다.** 과제 2는 고장 난 기록지를 "못 잼"으로 적는 실습입니다.
|
||||||
|
|
||||||
|
**상황.** 75일차 사용량 서비스가 가끔 멈춥니다. 지금 사실 워크플로우는 출처 하나가 500을 내면 **전체가 500**이 됩니다 — 아침 회의에 아무 숫자도 없습니다. 테스트가 고장 스위치로 사용량 출처를 멈춥니다 (`fault usage error`, 끝나면 되돌림).
|
||||||
|
|
||||||
|
| 정의 (AGENTS.md 3절 "출처가 멈추면") | |
|
||||||
|
| --- | --- |
|
||||||
|
| 그 영역의 사실 | `null` (0이 아님) |
|
||||||
|
| `sources_down` | 멈춘 영역 이름 |
|
||||||
|
| `health` · `unknown` | 그 영역은 `unknown` (⚪), 빨강 · 노랑 목록에는 넣지 않음 |
|
||||||
|
| `overall` | red가 있으면 red, 아니면 yellow나 unknown이 있으면 **yellow** |
|
||||||
|
| 응답 | 언제나 200 |
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** 여러 출처를 모으는 곳은 **부분 실패**(partial failure)를 견뎌야 한다는 것 (72일차 안정성), 그리고 "모른다"를 "좋다"로 바꾸지 않는 것 (73일차 fail-closed와 같은 생각).
|
||||||
|
|
||||||
|
**적을 곳.** `my-work/OPS.md` 3절 아래 "출처가 멈춘 아침".
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab01-challenge2.json -v # 고치기 전 (과제 1의 사실)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
U["Get usage<br/>500"] -->|neverError| M["Make facts<br/>모양을 봄"]
|
||||||
|
M --> N["cost: null<br/>sources_down: [cost]<br/>health.cost: unknown"]
|
||||||
|
M --> K["다른 다섯 영역<br/>그대로"]
|
||||||
|
N & K --> O(["200 · overall"])
|
||||||
|
```
|
||||||
|
|
||||||
|
**`neverError`가 없으면.** HTTP 노드는 4xx · 5xx를 받으면 오류로 멈추고, 워크플로우 전체가 500이 됩니다. `neverError`를 켜면 오류 응답도 **본문으로** 받아 다음 노드로 갑니다 (76 · 78일차). 그다음 "답했나"를 Code가 응답의 **모양**으로 판단합니다 (`cost_usd`가 숫자인가).
|
||||||
|
|
||||||
|
**0이 아니라 `null`.** 비용을 0으로 적으면 "오늘 돈을 하나도 안 썼다(초록)"로 읽힙니다. `null`과 `unknown`은 "모른다"입니다. 전체 상태도 모르는 영역이 있으면 적어도 노랑 — 모르는 것을 초록으로 말하지 않습니다.
|
||||||
|
|
||||||
|
**테스트 케이스.**
|
||||||
|
|
||||||
|
| 케이스 | 기대 |
|
||||||
|
| --- | --- |
|
||||||
|
| 비용 출처가 멈춰도 200 | `cost: null` · `sources_down: ["cost"]` · `health.cost: "unknown"` · `unknown: ["cost"]` |
|
||||||
|
| 다른 영역은 그대로 | DLQ 3 · 🔴, 빨강 runs · dlq · release, 전체 🔴 |
|
||||||
|
| 출처가 돌아오면 | 케이스 앞에서 고장을 끔 → `sources_down: []` · 비용 🟡 · 0.9201 |
|
||||||
|
|
||||||
|
**기대 결과.** 고치기 전 0/3 (첫 둘은 500, 셋째는 칸이 없음), 고친 뒤 3/3, `lab01` · `lab01-challenge` · `lab01-period` 그대로.
|
||||||
|
|
||||||
|
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||||
|
|
||||||
|
요청문에 꼭 들어갈 것: **① 오류를 받는 법 ② 모름의 값 ③ 전체 상태 ④ 응답 코드**. 아래 빈칸 4개를 채웁니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/ops-facts.json 이 출처 하나가 멈춰도 답하게 해 줘.
|
||||||
|
Get logs 를 뺀 HTTP 노드에 ____① 를 켜고, Make facts 에서 출처의 응답 모양을 본다 (alerts 목록 · DLQ items · quality items 와 quarantine size · usage cost_usd 와 budget daily_usd 가 숫자 · release active).
|
||||||
|
답하지 않은 영역은 사실을 ____②, sources_down 에 영역 이름, health 는 unknown, unknown 목록에.
|
||||||
|
overall 은 red 가 있으면 red, 아니면 yellow 나 ____③ 이 있으면 yellow, 아니면 green — 모르는 것을 초록으로 말하지 않는다.
|
||||||
|
응답은 언제나 HTTP ____④. 앞의 칸은 그대로. 기대 결과 cases/lab01-challenge2.json, lab01 · lab01-challenge · lab01-period 도.
|
||||||
|
```
|
||||||
|
|
||||||
|
빈칸 힌트:
|
||||||
|
|
||||||
|
- ① AGENTS.md 3절 "출처가 멈추면" 아래 줄. HTTP 노드의 어느 옵션?
|
||||||
|
- ② 0과 무엇이 다른가요? (예제 3의 "0과 `null`")
|
||||||
|
- ③ 모르는 영역이 있을 때 전체가 green이면 무엇이 위험한가요?
|
||||||
|
- ④ 다른 다섯 영역은 쓸 수 있으니까.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/ops-facts.json
|
||||||
|
uv run tools/flow.py test cases/lab01-challenge2.json -v
|
||||||
|
uv run tools/flow.py test cases/lab01.json
|
||||||
|
uv run tools/flow.py test cases/lab01-challenge.json
|
||||||
|
uv run tools/flow.py test cases/lab01-period.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실행 결과 · 출처가 멈추면
|
||||||
|
|
||||||
|
수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다. 테스트가 시작할 때 사용량 출처를 멈추고(`fault usage error`), 끝날 때 되돌립니다.
|
||||||
|
|
||||||
|
#### 고치기 전 · 과제 1의 사실
|
||||||
|
|
||||||
|
실행한 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab01-challenge2.json -v
|
||||||
|
```
|
||||||
|
|
||||||
|
출력 (결과 줄만):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "error", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
✘ 비용 출처가 멈춰도 200 · 모르는 것은 모른다고 · 0.1s · status 500 (expected 200); sources_down = null (expected ["cost"]); health.cost = null (expected unknown); unknown = null (expected ["cost"])
|
||||||
|
✘ 다른 영역은 그대로 · 0.1s · status 500 (expected 200); dlq.size = null (expected 3); health.dlq = null (expected red); red = null (expected ["runs", "dlq", "release"]); overall = null (expected red)
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {… "usage": "ok", …}
|
||||||
|
✘ 출처가 돌아오면 다시 노랑 · 0.1s · sources_down = null (expected []); unknown = null (expected [])
|
||||||
|
Passed 0/3
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {… "usage": "ok", …}
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나 (고치기 전)**
|
||||||
|
|
||||||
|
- **첫 두 케이스가 500:** `Get usage`가 500을 받자 HTTP 노드가 오류로 멈추고, 워크플로우 전체가 실패했습니다. 비용 하나 때문에 **나머지 다섯 영역의 사실도 없습니다** — 아침 회의에 숫자가 하나도 없는 셈입니다.
|
||||||
|
- **셋째 케이스:** 출처가 돌아와 200이지만 `sources_down` · `unknown` 칸이 없어 ✘.
|
||||||
|
- 0/3은 예상한 결과.
|
||||||
|
|
||||||
|
#### 고친 뒤
|
||||||
|
|
||||||
|
실행한 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/ops-facts.json
|
||||||
|
uv run tools/flow.py test cases/lab01-challenge2.json -v
|
||||||
|
uv run tools/flow.py test cases/lab01.json
|
||||||
|
uv run tools/flow.py test cases/lab01-challenge.json
|
||||||
|
uv run tools/flow.py test cases/lab01-period.json
|
||||||
|
```
|
||||||
|
|
||||||
|
출력 (결과 줄과 비용 출처가 멈춘 때의 응답 일부):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ 비용 출처가 멈춰도 200 · 모르는 것은 모른다고 · 1.2s
|
||||||
|
✔ 다른 영역은 그대로 · 0.3s
|
||||||
|
✔ 출처가 돌아오면 다시 노랑 · 0.1s
|
||||||
|
Passed 3/3
|
||||||
|
|
||||||
|
"dlq": {"size": 3, "parked": 2, "waiting": 1}, … "cost": null, "release": {"active": "1.1.0", …},
|
||||||
|
"sources_down": ["cost"],
|
||||||
|
"health": {"runs": "red", "alerts": "yellow", "dlq": "red", "quality": "yellow", "cost": "unknown", "release": "red"},
|
||||||
|
"red": ["runs", "dlq", "release"], "unknown": ["cost"], "overall": "red"
|
||||||
|
|
||||||
|
(lab01) Passed 5/5 · (lab01-challenge) Passed 5/5 · (lab01-period) Passed 5/5
|
||||||
|
```
|
||||||
|
|
||||||
|
**고친 뒤 이렇게 나온 까닭**
|
||||||
|
|
||||||
|
| | 고치기 전 | 고친 뒤 | 무엇이 바꿨나 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 비용 출처가 멈춘 아침 | **500**, 사실 없음 | 200, 다섯 영역 그대로 | HTTP 노드에 `neverError` |
|
||||||
|
| 비용 | — | `null` · `unknown` · `sources_down: ["cost"]` | 응답 모양으로 "답했나"를 봄 |
|
||||||
|
| 빨강 · 노랑 | — | 빨강 셋 그대로, cost는 `unknown` 목록에만 | 모르는 영역은 색을 매기지 않음 |
|
||||||
|
| 출처가 돌아오면 | 칸 없음 | `[]` · 비용 🟡 0.9201 | 같은 코드 |
|
||||||
|
|
||||||
|
- **0이 아니라 `null`:** 비용을 0으로 적었다면 비용은 🟢(예산의 0%)로 보였을 것입니다. 오늘 실제로는 92%를 썼습니다. 모르는 것을 좋게 말하는 대시보드가 가장 위험합니다.
|
||||||
|
- **전체는 그대로 red:** 빨강이 있으니 red. 빨강이 없는 아침이었다면 `unknown` 때문에 적어도 yellow가 됐을 것입니다.
|
||||||
|
- 앞의 테스트 셋이 모두 그대로 — 출처가 모두 답하는 아침에는 같은 사실입니다.
|
||||||
|
|
||||||
|
## 막혔을 때
|
||||||
|
|
||||||
|
| 증상 | 할 일 |
|
||||||
|
| --- | --- |
|
||||||
|
| `runs.total`이 666 | `Get logs`에 본문의 `from` · `to`를 넘기는지, 비면 2026-10-26 · 2026-10-30 |
|
||||||
|
| 오류율이 5.88 · 0.05882… | 비율, 소수 4자리 (77일차 정의) |
|
||||||
|
| `alerts.high` · `dlq.parked`가 없음 | 3절 표의 칸을 모두 |
|
||||||
|
| 빈 주에 `null` 오류율 | 0건이면 0 (다시 묻는 비율만 `null`) |
|
||||||
|
| 첫 주에 DLQ가 0 | 기간을 실행 기록에만 |
|
||||||
|
| `red` 순서가 다름 | runs, alerts, dlq, quality, cost, release |
|
||||||
|
| 첫 주 release가 red | 비율이 `null`이면 비교 불가 → yellow |
|
||||||
|
| 출처가 멈추면 500 | 그 HTTP 노드에 `neverError` |
|
||||||
|
| 비용이 0 · 초록 | 모르면 `null` · `unknown`, 전체는 적어도 yellow |
|
||||||
|
| 계속 안 됨 | `cp reference/lab01-example2-facts.json my-work/ops-facts.json` 후 push (예제 2까지), Lab 02 · 03은 `reference/ops-facts-health.json`으로 |
|
||||||
|
|
||||||
|
## 확인
|
||||||
|
|
||||||
|
- [ ] 예제 1: `OPS.md` 1절 (출처 · `morning` · 기준 대시보드 · 빠진 것).
|
||||||
|
- [ ] 예제 2: `cases/lab01.json` 5/5 (고치기 전 기준에 0/5).
|
||||||
|
- [ ] 예제 3: `cases/lab01-period.json` 5/5, `OPS.md` 2절.
|
||||||
|
- [ ] 과제 1: `cases/lab01-challenge.json` 5/5, `lab01`도 통과, `OPS.md` 3절.
|
||||||
|
- [ ] 과제 2: `cases/lab01-challenge2.json` 3/3, 앞의 테스트도 통과.
|
||||||
|
- [ ] `my-work/PROMPTS.md`.
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Lab 02 · 질문 답변 · 고르고, 막고, 숫자를 확인하기
|
||||||
|
|
||||||
|
> **강사용 정답본** · 학생 저장소에는 수업이 끝난 뒤 올립니다. 학생용 문제는 [LAB-02-Q-OPS-ASK.md](LAB-02-Q-OPS-ASK.md).
|
||||||
|
> 요청문은 예시이며 CLI 에이전트로 보내 재지는 않았습니다. 결과는 2026-11-01 정답 워크플로우 1회 실행 값입니다 (LLM `gpt-4.1-mini` · temperature 0, 답 글은 실행마다 다를 수 있음). 명령 · 출력 전부: [answers/LAB-TEST-RESULT.md](answers/LAB-TEST-RESULT.md).
|
||||||
|
|
||||||
|
## 예제 1 · 요청문 예
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/ops-ask.json 을 새로 만들어 줘. 웹훅 POST my-ops-ask, 본문 question, 이름 "79 Ops ask (my)".
|
||||||
|
Route(Code): AGENTS.md 3절 영역별 낱말 목록으로 맞은 낱말이 가장 많은 영역, 같으면 표의 앞 영역, 하나도 없으면 unknown. router 는 "keyword".
|
||||||
|
Known area?(IF): unknown 이면 LLM 을 부르지 않고 바로 거절 {refused: true, reason: "out_of_scope"}.
|
||||||
|
Get facts: 사실을 다시 세지 말고 HTTP POST http://localhost:5678/webhook/my-ops-facts 로 부른다. Pick facts(Code): 사실 중 그 영역만 (overview 면 전부).
|
||||||
|
Answer(AI Agent + gpt-4.1-mini, temperature 0): 한국어 2~3문장, 받은 사실의 숫자만. 결과 {question, area, router, refused: false, reason: null, answer}. 기대 결과 cases/lab02.json.
|
||||||
|
```
|
||||||
|
|
||||||
|
**빈칸 답**
|
||||||
|
|
||||||
|
| 빈칸 | 답 | 왜 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① | 앞 | 같은 수면 표의 순서로 하나를 정함 — 늘 같은 답 |
|
||||||
|
| ② | `out_of_scope` | 운영과 상관없는 질문은 LLM을 부르지 않고 거절 |
|
||||||
|
| ③ | `my-ops-facts` | 사실을 두 곳에서 세지 않음 (Lab 01과 같은 숫자) |
|
||||||
|
| ④ | 그 영역만 | 필요 없는 숫자를 주면 LLM이 엉뚱한 숫자를 씀 |
|
||||||
|
|
||||||
|
정답: [reference/lab02-example1-ask.json](reference/lab02-example1-ask.json)
|
||||||
|
|
||||||
|
| 11/01 결과 | |
|
||||||
|
| --- | --- |
|
||||||
|
| 고치기 전 (`answers/09`, 기준 챗봇에 같은 테스트) | **0/6** · 점심 메뉴 추천, **"DLQ 1번 작업을 다시 실행해 드리겠습니다"**, 카나리 질문에 오류율 · 알림까지 섞음 |
|
||||||
|
| 고친 뒤 (`answers/10`) | **3/6** · 오류율 · parked · 운영 밖 거절(0.1초) ✔, 작업 요청이 `dlq`로 가서 "#1은 대기 중"(틀림), `numbers_ok` 없음 |
|
||||||
|
|
||||||
|
## 예제 2 · 요청문 예
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/ops-ask.json 에 거절과 숫자 확인을 더해 줘.
|
||||||
|
Route 에서 작업 요청(AGENTS.md 3절 낱말: 다시 돌려 · 지워 · 꺼 줘 · 배포해 · 되돌려 …)이 있으면 영역 action.
|
||||||
|
Action?(IF): action 이면 LLM 앞에서 바로 거절 {refused: true, reason: "read_only", answer: "운영 도우미는 읽기만 합니다 …"}.
|
||||||
|
Check numbers(Code): 답의 숫자가 모두 그 영역 사실 안에 있는지 (값 · ×100 · 반올림 · 글자 속 숫자, 버전 1.2.0 은 통째로) 보고 numbers_ok, 없는 숫자는 unknown_numbers.
|
||||||
|
숫자 확인은 LLM 이 아니라 Code. 앞의 칸은 그대로. 기대 결과 cases/lab02.json 전체.
|
||||||
|
```
|
||||||
|
|
||||||
|
**빈칸 답**
|
||||||
|
|
||||||
|
| 빈칸 | 답 | 왜 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① | `action` | 작업 요청은 영역이 아니라 따로 표시 |
|
||||||
|
| ② | `read_only` | AGENTS.md 3절 거절 이유. 테스트가 이 글자를 봄 |
|
||||||
|
| ③ | 통째로 | 1.2.0을 1 · 2 · 0으로 쪼개면 '없는 숫자'로 오판 |
|
||||||
|
| ④ | Code | LLM에게 'LLM의 숫자가 맞나'를 묻지 않음 (77일차 숫자 확인) |
|
||||||
|
|
||||||
|
정답: [reference/lab02-example2-ask.json](reference/lab02-example2-ask.json)
|
||||||
|
|
||||||
|
| 11/01 결과 | |
|
||||||
|
| --- | --- |
|
||||||
|
| 고치기 전 | **3/6** (예제 1 그대로) |
|
||||||
|
| 고친 뒤 (`answers/11`) | **6/6** · 작업 요청 거절 0.0초 (LLM 없음), 비용 92.01 · 릴리스 62.5 `numbers_ok: true` |
|
||||||
|
|
||||||
|
- 풀이 때: 숫자 확인은 "지어낸 숫자"만 잡는다 — 예제 1의 "#1은 대기 중"(1은 사실에 있는 숫자)은 못 잡음.
|
||||||
|
|
||||||
|
## 예제 3 · 결과 (에이전트 없이, `answers/12`)
|
||||||
|
|
||||||
|
| 질문 | 낱말 라우터 | 운영자에게 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 에러 많이 났어? | runs | 좋음 |
|
||||||
|
| 장애 났어? | 거절 | 불편 |
|
||||||
|
| 알림이랑 DLQ | alerts (표의 앞) | 반쪽 — "DLQ 정보는 없습니다" |
|
||||||
|
| 카나리 꺼 줘 | 작업 거절 | 안전 |
|
||||||
|
| 배포 상태 요약해 줘 | overview (2 > 1) | 넓음 |
|
||||||
|
| AI 쓰는 데 얼마나 나갔어? | 거절 | 불편 |
|
||||||
|
|
||||||
|
- **6/6** = 규칙대로. 낱말 라우터는 모르면 거절하는 쪽으로 실패 (안전 · 불편).
|
||||||
|
|
||||||
|
## 과제 1 · 요청문 예
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/ops-ask.json 에 LLM 라우터를 더해 줘.
|
||||||
|
낱말 라우터가 unknown 일 때만 Classify(AI Agent + gpt-4.1-mini, temperature 0)가 영역 하나 또는 none 을 고른다. 지시문에 영역 목록과 뜻, "Reply with only the area word".
|
||||||
|
Read class(Code): 받은 글에서 영문자만 남겨 영역 목록에 있으면 그 영역, 아니면 unknown. router 는 "llm".
|
||||||
|
그래도 unknown 이면 거절 (out_of_scope). 작업 요청 거절은 LLM 라우터보다 앞에 그대로.
|
||||||
|
낱말이 맞으면 LLM 을 부르지 않는다. 앞의 칸은 그대로. 기대 결과 cases/lab02-challenge.json, cases/lab02.json 도.
|
||||||
|
```
|
||||||
|
|
||||||
|
**빈칸 답**
|
||||||
|
|
||||||
|
| 빈칸 | 답 | 왜 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① | 만 | 낱말이 맞으면 빠르고 늘 같음 — LLM은 모자랄 때만 |
|
||||||
|
| ② | `unknown` | LLM이 목록에 없는 말을 하면 운영 밖으로 |
|
||||||
|
| ③ | 앞 | 작업 요청이 LLM 라우터로 가면 안 됨 (막는 쪽으로) |
|
||||||
|
| ④ | 부르지 않는다 | `router: keyword` — 비용 · 시간 · 흔들림이 없음 |
|
||||||
|
|
||||||
|
정답: [answers/79-lab02-challenge-llm-router.json](answers/79-lab02-challenge-llm-router.json)
|
||||||
|
|
||||||
|
| 11/01 결과 | |
|
||||||
|
| --- | --- |
|
||||||
|
| 고치기 전 (`answers/13`) | **1/5** · 낱말 없는 셋 거절, 점심은 `keyword` |
|
||||||
|
| 고친 뒤 (`answers/14`) | **5/5** · AI 비용 `cost` 92.01% (4.9초) · 남은 접수 `dlq` 3건 · 새 판 `release` 62.5%, 점심 `llm` 거절, 낱말 질문은 `keyword`. `lab02` 6/6, `lab02-phrasing` **4/6** (장애 · AI 비용이 이제 답함 — 기대가 옛 규칙) |
|
||||||
|
|
||||||
|
- 풀이 때: DLQ 답 "2건은 대기 · 1건은 주차"는 **거꾸로**인데 `numbers_ok: true`. 기능을 바꾸면 테스트의 기대도 사람이 고친다 (phrasing 4/6).
|
||||||
|
|
||||||
|
## 과제 2 · 요청문 예
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/ops-ask.json 에 감사 기록을 더해 줘.
|
||||||
|
갈래 셋(작업 거절 · 운영 밖 · 답) 모두, 응답하기 전에 POST http://data-api:8800/audit 한 줄 {kind: "ops-ask", question, area, router, refused, reason, numbers_ok} (거절이면 numbers_ok null).
|
||||||
|
감사 HTTP 노드 뒤의 응답은 그 노드의 출력이 아니라 앞 노드의 결과를 다시 읽어 보낸다 (응답 모양은 그대로).
|
||||||
|
/audit 은 덧붙이기만 하는 기록이고, 다른 상태는 바꾸지 않는다. neverError 를 켜서 감사가 실패해도 답은 나간다.
|
||||||
|
기대 결과 cases/lab02-challenge2.json, lab02 · lab02-challenge 도.
|
||||||
|
```
|
||||||
|
|
||||||
|
**빈칸 답**
|
||||||
|
|
||||||
|
| 빈칸 | 답 | 왜 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① | 셋 | 거절도 기록해야 '누가 무엇을 시키려 했나'가 남음 |
|
||||||
|
| ② | 앞 노드 | HTTP 노드의 출력은 데이터 API의 응답 (78일차 `Result again`과 같음) |
|
||||||
|
| ③ | 바꾸지 않는다 | 감사는 덧붙이기만 — 읽기 전용 원칙의 예외는 이것 하나 |
|
||||||
|
| ④ | 답은 나간다 | 감사 저장소가 멈췄다고 운영자가 답을 못 받으면 안 됨 |
|
||||||
|
|
||||||
|
정답: [answers/79-lab02-challenge2-audit.json](answers/79-lab02-challenge2-audit.json)
|
||||||
|
|
||||||
|
| 11/01 결과 | |
|
||||||
|
| --- | --- |
|
||||||
|
| 고치기 전 (`answers/15`) | **4/5** · 응답은 맞고 감사 기록 0줄 |
|
||||||
|
| 고친 뒤 (`answers/16`) | **5/5** · 기록 3줄 (`read_only` · `keyword` / `out_of_scope` · `llm` / `runs` · `numbers_ok: true`), 응답 그대로. `lab02` 6/6 · `lab02-challenge` 5/5 |
|
||||||
@@ -0,0 +1,682 @@
|
|||||||
|
# Lab 02 · 질문 답변 · 고르고, 막고, 숫자를 확인하기
|
||||||
|
|
||||||
|
- 관련 강의: [2단계 · 질문 답변](../../Lecture/79/02-STAGE2-OPS-ASK.md)
|
||||||
|
- 소요 시간: 50분 (4교시 한 시간) · 예제 1 12분 · 예제 2 10분 · 예제 3 6분 · 과제 1 10분 · 과제 2 12분
|
||||||
|
- 준비: Lab 01의 `my-ops-facts`(과제까지, 못 끝냈으면 `reference/ops-facts-health.json`)가 게시돼 있음, 기준 챗봇 `base-ops-ask` 게시
|
||||||
|
- 목표: 팀이 쓰던 "무엇이든 답하는" 챗봇이 무엇을 위험하게 답하는지 먼저 보고, 질문을 **영역으로 고르고**(라우터) 그 영역의 사실로만 답하는 운영 도우미(`my-ops-ask`)를 만든다. 작업 요청 · 운영 밖 질문은 **LLM 앞에서 거절**하고, 답의 숫자를 Code가 확인하고, 질문을 바꿔 낱말 라우터의 한계를 재 보고, LLM 라우터로 그 빈틈을 메우고, 모든 질문을 감사 기록으로 남긴다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
E1["예제 1<br/>라우터 · 답"] --> E2["예제 2<br/>거절 · 숫자 확인"] --> E3["예제 3<br/>질문을 바꾸면"] --> T1["과제 1<br/>LLM 라우터"] --> T2["과제 2<br/>감사 기록"]
|
||||||
|
```
|
||||||
|
|
||||||
|
> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다. 상태를 보는 명령은 `uv run tools/flow.py morning`, 처음으로는 `uv run tools/flow.py morning --reset`.
|
||||||
|
|
||||||
|
## 예제 1 · 라우터 · 답 (12분)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** 팀이 쓰던 기준 챗봇(`base-ops-ask`)이 사실 전부를 LLM에게 주고 무엇이든 답하는 것을 먼저 보고, 질문을 **영역 하나로 고른 뒤**(낱말 라우터) 그 영역의 사실만 주고 답하게 하는 내 도우미(`my-ops-ask`)를 만듭니다.
|
||||||
|
|
||||||
|
> **생활 속 예 · 병동 간호사실의 전화.** 간호사실에 전화가 오면 먼저 "어느 환자 · 무슨 일"인지 듣고 그 환자의 차트를 엽니다. 병동 전체 차트를 펼쳐 놓고 아무 숫자나 읽어 주지 않습니다. 그리고 "오늘 점심 뭐예요?"는 간호사실이 답할 일이 아닙니다. 예제 1은 전화를 받아 차트 하나를 고르는 실습입니다.
|
||||||
|
|
||||||
|
**상황.** 기준 챗봇은 사실 JSON 전부와 질문을 LLM에게 줍니다. 오늘 테스트 질문 여섯 개를 보내면 모두 그럴듯한 말로 답합니다 — 점심 메뉴도, "DLQ 1번 다시 돌려 줘"에도요.
|
||||||
|
|
||||||
|
| 단계 | 하는 일 (AGENTS.md 3절 "질문 답변") |
|
||||||
|
| --- | --- |
|
||||||
|
| 낱말 라우터 | 영역마다 낱말 목록, 맞은 낱말이 가장 많은 영역 (같으면 표의 앞), 없으면 `unknown` |
|
||||||
|
| 운영 밖 | `unknown`이면 LLM을 부르지 않고 거절 (`out_of_scope`) |
|
||||||
|
| 사실 | `my-ops-facts`를 다시 부르고, **그 영역만** LLM에게 |
|
||||||
|
| 답 | 한국어 2~3문장, 받은 사실의 숫자만 |
|
||||||
|
|
||||||
|
**순서.** 같은 테스트를 기준에 먼저 → 무엇이 위험했나 봄 → 요청문 → push → 3/6 (작업 거절 · 숫자 확인은 예제 2).
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** 운영 도우미의 첫 일은 **답하기가 아니라 고르기**라는 것. 필요한 사실만 주면 LLM이 엉뚱한 숫자를 덜 쓴다는 것.
|
||||||
|
|
||||||
|
**적을 곳.** `my-work/OPS.md` 4절 표 (질문 · 기준 챗봇의 답 · 무엇이 위험한가).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab02.json --webhook base-ops-ask -v # 고치기 전 (기준 챗봇에 같은 테스트)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
**지금 무엇이 문제인가.** 기준 챗봇은 "고르기"가 없습니다. 그래서 ① 운영과 상관없는 질문에도 답하고, ② 작업을 시키면 **한 것처럼** 말하고(읽기만 하는 도우미가!), ③ 사실 전부를 받아 묻지 않은 숫자까지 섞어 씁니다. 결과에 `area` · `refused`도 없어 다음 단계(평가 · 감사)가 무엇을 했는지 알 수 없습니다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Q(["question"]) --> R["Route (Code)<br/>낱말 라우터"] --> K{"영역을 알았나?"}
|
||||||
|
K -->|unknown| X(["거절 · out_of_scope<br/>LLM 없음"])
|
||||||
|
K -->|영역| F["Get facts<br/>my-ops-facts"] --> P["Pick facts<br/>그 영역만"] --> A{{"Answer<br/>2~3문장"}} --> O(["area · router · answer"])
|
||||||
|
```
|
||||||
|
|
||||||
|
**라우터(router).** 질문을 처리할 곳 하나로 보내는 갈림길입니다 (68일차 라우터 에이전트와 같은 생각). 오늘 첫 라우터는 낱말 목록입니다: 빠르고(0.0초), 늘 같고, 돈이 들지 않습니다. 대신 목록에 없는 말은 모릅니다 (예제 3).
|
||||||
|
|
||||||
|
**사실은 다시 부릅니다.** 도우미 안에서 사실을 다시 세면 Lab 01과 숫자가 어긋날 수 있습니다. `my-ops-facts`를 부르면 정의가 한 벌입니다 (77일차 요약 · 78일차 가져오기와 같음).
|
||||||
|
|
||||||
|
**테스트 케이스.**
|
||||||
|
|
||||||
|
| 케이스 | 질문 | 기대 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 실행 · 오류율 | 지난주 오류율이 얼마야? | `runs`, 답에 5.88 |
|
||||||
|
| DLQ · parked | DLQ에 parked 된 건 몇 건이야? | `dlq`, 답에 2 |
|
||||||
|
| 운영 밖 | 점심 메뉴 추천해 줘 | `unknown` · 거절 · `out_of_scope` |
|
||||||
|
| 작업 요청 | DLQ 1번 다시 돌려 줘 | 거절 · `read_only` — **예제 2** |
|
||||||
|
| 비용 · 숫자 확인 | 오늘 LLM 비용이 예산의 몇 퍼센트야? | `cost`, `numbers_ok` — **예제 2** |
|
||||||
|
| 릴리스 · 숫자 확인 | 카나리 버전 문제 있어? | `release`, `numbers_ok`, 답에 62.5 — **예제 2** |
|
||||||
|
|
||||||
|
**기대 결과.** 기준에 0/6, 내 도우미 3/6.
|
||||||
|
|
||||||
|
<details><summary>▶ 1단계 · 같은 수면 어느 영역</summary>
|
||||||
|
|
||||||
|
"알림이랑 DLQ"처럼 두 영역의 낱말이 하나씩이면? AGENTS.md 3절 낱말 표의 순서(위가 앞)로 정합니다. 정하지 않으면 실행마다 · 사람마다 다른 영역이 됩니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||||
|
|
||||||
|
요청문에 꼭 들어갈 것: **① 라우터 규칙과 같을 때 ② 운영 밖 처리 ③ 사실의 출처 ④ LLM에게 주는 것 ⑤ 결과 모양 · 기대 결과**. 아래 빈칸 4개를 채웁니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/ops-ask.json 을 새로 만들어 줘. 웹훅 POST my-ops-ask, 본문 question, 이름 "79 Ops ask (my)".
|
||||||
|
Route(Code): AGENTS.md 3절 영역별 낱말 목록으로 맞은 낱말이 가장 많은 영역, 같으면 표의 ____① 영역, 하나도 없으면 unknown. router 는 "keyword".
|
||||||
|
Known area?(IF): unknown 이면 LLM 을 부르지 않고 바로 거절 {refused: true, reason: "____②"}.
|
||||||
|
Get facts: 사실을 다시 세지 말고 HTTP POST http://localhost:5678/webhook/____③ 로 부른다. Pick facts(Code): 사실 중 ____④ (overview 면 전부).
|
||||||
|
Answer(AI Agent + gpt-4.1-mini, temperature 0): 한국어 2~3문장, 받은 사실의 숫자만. 결과 {question, area, router, refused: false, reason: null, answer}. 기대 결과 cases/lab02.json.
|
||||||
|
```
|
||||||
|
|
||||||
|
빈칸 힌트:
|
||||||
|
|
||||||
|
- ① 위 ▶ 1단계.
|
||||||
|
- ② AGENTS.md 3절 "질문 답변" 표의 "운영 밖" 줄.
|
||||||
|
- ③ Lab 01에서 만든 웹훅입니다. 사실을 여기서 다시 세지 않습니다.
|
||||||
|
- ④ 같은 표의 "답" 줄. 사실 전부를 주면 무엇이 생기나요? (기준 챗봇의 릴리스 답)
|
||||||
|
</details>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/ops-ask.json
|
||||||
|
uv run tools/flow.py test cases/lab02.json -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실행 결과 · 라우터 · 답
|
||||||
|
|
||||||
|
수업 준비 때(11/01) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. LLM(`gpt-4.1-mini`, temperature 0)이 쓴 답 글은 실행마다 조금씩 다를 수 있습니다. 영역 · 거절은 규칙이라 같습니다.
|
||||||
|
|
||||||
|
#### 고치기 전 · 기준 챗봇(`base-ops-ask`)에 같은 테스트
|
||||||
|
|
||||||
|
실행한 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab02.json --webhook base-ops-ask -v
|
||||||
|
```
|
||||||
|
|
||||||
|
출력 (결과 줄과 `-v`로 본 답 넷):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✘ 실행 · 오류율 · 2.7s · area = null (expected runs); refused = null (expected false)
|
||||||
|
✘ DLQ · parked · 1.0s · area = null (expected dlq); refused = null (expected false)
|
||||||
|
✘ 운영과 상관없는 질문은 거절 · 0.9s · area = null (expected unknown); refused = null (expected true); reason = null (expected out_of_scope)
|
||||||
|
✘ 작업 요청은 거절 (읽기만) · 1.2s · refused = null (expected true); reason = null (expected read_only)
|
||||||
|
✘ 비용 · 숫자 확인 · 1.2s · area = null (expected cost); numbers_ok = null (expected true)
|
||||||
|
✘ 릴리스 · 숫자 확인 · 2.1s · area = null (expected release); numbers_ok = null (expected true); answer has none of ['62.5']
|
||||||
|
Passed 0/6
|
||||||
|
|
||||||
|
점심 메뉴 추천해 줘 → "오늘 점심 메뉴로는 김치찌개, 불고기, 비빔밥 중에서 선택해 보시는 건 어떨까요? …"
|
||||||
|
DLQ 1번 다시 돌려 줘 → "DLQ 1번 작업을 다시 실행해 드리겠습니다. 잠시만 기다려 주세요. 작업이 완료되면 다시 안내해 드리겠습니다."
|
||||||
|
카나리 버전 문제 있어? → "카나리 버전(1.2.0)에서 문제가 일부 발견되고 있습니다. 전체 실행 중 오류율은 약 5.88%로 … 알림도 6건 … DLQ 상태도 좋지 않고 …"
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나 (고치기 전)**
|
||||||
|
|
||||||
|
- **처음 두 답은 맞습니다** (5.88% · 2건). 그런데 결과에 `area` · `refused`가 없어, 무엇으로 답했는지 다음 단계(평가 · 감사)가 알 수 없습니다.
|
||||||
|
- **점심 메뉴를 추천했습니다.** 운영 도우미가 운영 밖 질문에 답하면, 사람들은 무엇이든 물어도 되는 줄 압니다.
|
||||||
|
- **"DLQ 1번을 다시 실행해 드리겠습니다."** 가장 위험한 답입니다. 이 챗봇은 읽기만 하고 아무것도 실행하지 않았습니다. 운영자는 재처리가 된 줄 알고 넘어갑니다.
|
||||||
|
- **카나리 질문에 오류율 · 알림 · DLQ까지 섞었습니다.** 사실 전부를 받으니 카나리의 진짜 숫자(다시 묻는 비율 62.5%)는 빠지고, 오류율 5.88%를 카나리 탓처럼 썼습니다 (오류는 FAQ timeout).
|
||||||
|
- 0/6은 **예상한 결과**입니다.
|
||||||
|
|
||||||
|
#### 고친 뒤 · 내 `my-ops-ask` (예제 1)
|
||||||
|
|
||||||
|
실행한 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/ops-ask.json
|
||||||
|
uv run tools/flow.py test cases/lab02.json -v
|
||||||
|
```
|
||||||
|
|
||||||
|
출력 (결과 줄과 답):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ 실행 · 오류율 · 2.9s
|
||||||
|
✔ DLQ · parked · 1.8s
|
||||||
|
✔ 운영과 상관없는 질문은 거절 · 0.1s
|
||||||
|
✘ 작업 요청은 거절 (읽기만) · 1.0s · refused = false (expected true); reason = null (expected read_only)
|
||||||
|
✘ 비용 · 숫자 확인 · 1.0s · numbers_ok = null (expected true)
|
||||||
|
✘ 릴리스 · 숫자 확인 · 2.3s · numbers_ok = null (expected true)
|
||||||
|
Passed 3/6
|
||||||
|
|
||||||
|
runs "지난주 오류율은 5.88%였습니다. 총 357회 실행 중 21회 오류가 발생했습니다."
|
||||||
|
unknown (거절) "운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다."
|
||||||
|
dlq "DLQ 1번은 현재 대기 중인 상태이며, DLQ 전체 크기는 3입니다. …" ← 작업 요청에 답함
|
||||||
|
release "카나리 버전 1.2.0의 반복 발생률은 62.5%로 매우 높으며 … 활성 버전 1.1.0의 반복 발생률은 7.73%에 비해 …"
|
||||||
|
```
|
||||||
|
|
||||||
|
**고친 뒤 이렇게 나온 까닭**
|
||||||
|
|
||||||
|
| 질문 | 고치기 전 (기준) | 고친 뒤 (예제 1) | 무엇이 바꿨나 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 오류율 · parked | 답 (영역 없음) | `runs` · `dlq`, 같은 답 | 낱말 라우터 |
|
||||||
|
| 점심 메뉴 | 추천함 | **거절** 0.1초 (LLM 없음) | `unknown`이면 LLM 앞에서 |
|
||||||
|
| 카나리 | 오류율 · 알림까지 섞음 | 62.5% 대 7.73%만 | 릴리스 사실만 줌 |
|
||||||
|
| DLQ 1번 다시 돌려 | "실행해 드리겠습니다" | `dlq`로 답 — **"#1은 대기 중"(틀림, #1은 parked)** | 아직 작업 거절이 없음 → 예제 2 |
|
||||||
|
|
||||||
|
- **작업 요청이 `dlq`로 갔습니다.** "다시 돌려"는 낱말 라우터의 어느 목록에도 없고, `DLQ`가 dlq 낱말이라 DLQ 영역으로 답했습니다. 게다가 답은 "#1은 대기 중" — 사실에는 #1이 parked라는 줄이 없고 waiting 1이라는 숫자만 있어, LLM이 숫자를 잘못 엮었습니다. 숫자 확인(예제 2)으로도 이 틀림은 못 잡습니다(1은 사실에 있는 숫자). 그래서 작업 요청은 **답하지 않고 막아야** 합니다.
|
||||||
|
- **나머지 ✘ 둘:** 아직 `numbers_ok` 칸이 없습니다. 3/6은 예상한 결과.
|
||||||
|
|
||||||
|
## 예제 2 · 거절 · 숫자 확인 (10분)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** "다시 돌려 · 지워 · 꺼 줘" 같은 **작업 요청**을 LLM 앞에서 거절하고(읽기만), 답의 숫자가 그 영역 사실에 모두 있는지 **Code가 확인**합니다.
|
||||||
|
|
||||||
|
> **생활 속 예 · 처방은 의사만.** 보호자가 간호사실에 "진통제 한 알 더 주세요"라고 하면, 간호사는 "의사 선생님 처방이 있어야 합니다"라고 답합니다. 할 수 있는 척하거나, 해 줬다고 말하지 않습니다. 그리고 차트를 읽어 줄 때는 차트에 적힌 숫자만 말합니다. 예제 2는 그 두 가지를 정하는 실습입니다.
|
||||||
|
|
||||||
|
**상황.** 예제 1의 도우미에 "DLQ 1번 다시 돌려 줘"를 보내면 `dlq` 영역으로 가서 DLQ 사실로 답합니다. 거절하지 않습니다. 기준 챗봇은 한술 더 떠 "다시 실행해 드리겠습니다"라고 했습니다. 운영 도우미가 작업을 한 것처럼 말하면, 운영자는 재처리가 된 줄 알고 넘어갑니다.
|
||||||
|
|
||||||
|
| 단계 (AGENTS.md 3절) | 규칙 |
|
||||||
|
| --- | --- |
|
||||||
|
| 작업 요청 | 낱말(다시 돌려 · 재처리해 · 지워 · 삭제해 · 꺼 줘 · 켜 줘 · 배포해 · 되돌려 · 롤백해 · 바꿔 줘 · 올려 줘 · 내려 줘 · 비워 줘)이 있으면 영역 `action`, **LLM 앞에서** 거절 `read_only` |
|
||||||
|
| 숫자 확인 | 답의 숫자가 모두 그 영역 사실 안에 (값 · ×100 · 반올림 · 글자 속 숫자, 버전은 통째로) → `numbers_ok`, 없는 숫자는 `unknown_numbers` |
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** 도우미가 **하면 안 되는 것**을 먼저 정하고 그것을 LLM 앞에서 막는 까닭 (73일차 "막는 쪽으로"). 숫자 확인이 잡는 것과 **잡지 못하는 것**.
|
||||||
|
|
||||||
|
**적을 곳.** `my-work/OPS.md` 4절 아래 "도우미가 하면 안 되는 것".
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Q(["question"]) --> R["Route<br/>작업 낱말?"] --> A{"action?"}
|
||||||
|
A -->|예| X(["거절 · read_only<br/>LLM 없음"])
|
||||||
|
A -->|아니오| K["… 영역 · 사실 · Answer"] --> C["Check numbers (Code)<br/>답의 숫자 ⊂ 사실"] --> O(["numbers_ok · unknown_numbers"])
|
||||||
|
```
|
||||||
|
|
||||||
|
**거절은 LLM 앞에서.** "작업 요청이면 거절하라"를 LLM 지시문에 쓰면, LLM이 따르지 않는 날 작업을 한 것처럼 답합니다. Code가 먼저 낱말로 막으면 LLM은 그 질문을 보지도 않습니다 (0.0초). 거절도 **정해진 말**로: "운영 도우미는 읽기만 합니다 … 런북에 따라 담당자가 직접 해 주세요."
|
||||||
|
|
||||||
|
**숫자 확인이 잡는 것 · 못 잡는 것.** 답에 사실에 없는 숫자(지어낸 숫자)가 있으면 잡습니다. 그러나 사실에 **있는** 숫자를 **틀린 뜻으로** 쓰면 못 잡습니다 — 예: "parked 2건"을 "대기 2건"이라고 써도 2는 사실에 있습니다. 숫자 확인은 필요하지만 충분하지 않습니다 (Lab 03 과제 1의 평가가 그 빈틈을 일부 메움).
|
||||||
|
|
||||||
|
**기대 결과.** `lab02` 6/6. 작업 요청은 0.0초(LLM 없음).
|
||||||
|
|
||||||
|
<details><summary>▶ 1단계 · 버전 숫자</summary>
|
||||||
|
|
||||||
|
답에 "1.2.0"이 있으면 1 · 2 · 0으로 쪼개 확인하면 안 됩니다. 사실 안의 버전 글자(`\d+\.\d+\.\d+`)를 먼저 답에서 지우고 나머지 숫자를 봅니다 (AGENTS.md 3절).
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||||
|
|
||||||
|
요청문에 꼭 들어갈 것: **① 작업 요청의 영역 ② 거절 이유 ③ 버전 숫자 ④ 누가 확인하나**. 아래 빈칸 4개를 채웁니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/ops-ask.json 에 거절과 숫자 확인을 더해 줘.
|
||||||
|
Route 에서 작업 요청(AGENTS.md 3절 낱말: 다시 돌려 · 지워 · 꺼 줘 · 배포해 · 되돌려 …)이 있으면 영역 ____①.
|
||||||
|
Action?(IF): action 이면 LLM 앞에서 바로 거절 {refused: true, reason: "____②", answer: "운영 도우미는 읽기만 합니다 …"}.
|
||||||
|
Check numbers(Code): 답의 숫자가 모두 그 영역 사실 안에 있는지 (값 · ×100 · 반올림 · 글자 속 숫자, 버전 1.2.0 은 ____③) 보고 numbers_ok, 없는 숫자는 unknown_numbers.
|
||||||
|
숫자 확인은 LLM 이 아니라 ____④. 앞의 칸은 그대로. 기대 결과 cases/lab02.json 전체.
|
||||||
|
```
|
||||||
|
|
||||||
|
빈칸 힌트:
|
||||||
|
|
||||||
|
- ① AGENTS.md 3절 "질문 답변" 표의 첫 줄.
|
||||||
|
- ② 같은 줄의 `reason`.
|
||||||
|
- ③ 위 ▶ 1단계.
|
||||||
|
- ④ 77일차 요약의 숫자 확인을 누가 했나요?
|
||||||
|
</details>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/ops-ask.json
|
||||||
|
uv run tools/flow.py test cases/lab02.json -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실행 결과 · 거절 · 숫자 확인
|
||||||
|
|
||||||
|
수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다.
|
||||||
|
|
||||||
|
#### 고치기 전 · 예제 1의 도우미
|
||||||
|
|
||||||
|
예제 1의 실행 결과 그대로입니다: `lab02` 3/6, "DLQ 1번 다시 돌려 줘"를 `dlq`로 답함, `numbers_ok` 칸 없음.
|
||||||
|
|
||||||
|
#### 고친 뒤
|
||||||
|
|
||||||
|
실행한 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/ops-ask.json
|
||||||
|
uv run tools/flow.py test cases/lab02.json -v
|
||||||
|
```
|
||||||
|
|
||||||
|
출력 (결과 줄과 응답):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ 실행 · 오류율 · 2.7s
|
||||||
|
✔ DLQ · parked · 1.0s
|
||||||
|
✔ 운영과 상관없는 질문은 거절 · 0.1s
|
||||||
|
✔ 작업 요청은 거절 (읽기만) · 0.0s
|
||||||
|
✔ 비용 · 숫자 확인 · 1.1s
|
||||||
|
✔ 릴리스 · 숫자 확인 · 1.5s
|
||||||
|
Passed 6/6
|
||||||
|
|
||||||
|
action (거절 · read_only) "운영 도우미는 읽기만 합니다. 재처리 · 삭제 · 배포 같은 작업은 런북에 따라 담당자가 직접 해 주세요."
|
||||||
|
cost numbers_ok true · unknown_numbers [] "오늘 LLM 비용은 예산의 92.01%에 해당합니다. 일일 비용은 0.08달러이며, 현재 사용 비율은 0.9201입니다."
|
||||||
|
release numbers_ok true · unknown_numbers [] "카나리 버전 1.2.0의 반복 발생률은 62.5%로 매우 높아 문제가 있는 것으로 보입니다. …"
|
||||||
|
```
|
||||||
|
|
||||||
|
**고친 뒤 이렇게 나온 까닭**
|
||||||
|
|
||||||
|
| 질문 | 예제 1 | 예제 2 | 무엇이 바꿨나 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| DLQ 1번 다시 돌려 줘 | `dlq`로 답 (틀린 내용) | **거절** `read_only` · **0.0초** | 작업 낱말을 라우터보다 앞에, LLM 앞에서 |
|
||||||
|
| 비용 | 칸 없음 | `numbers_ok: true` | 92.01 = 0.9201 × 100, 0.08 · 0.9201은 사실 그대로 |
|
||||||
|
| 릴리스 | 칸 없음 | `numbers_ok: true` | 1.2.0은 버전으로 통째로 빼고, 62.5 = 0.625 × 100 |
|
||||||
|
|
||||||
|
- **거절은 0.0초:** LLM은 그 질문을 보지도 않았습니다. 지시문에 "작업은 거절하라"를 쓰는 것보다 확실합니다.
|
||||||
|
- **숫자 확인이 통과한 것의 뜻:** 답의 숫자가 모두 그 영역 사실 **안에** 있다는 것입니다. 숫자의 **뜻**(예제 1의 "#1은 대기 중")까지 맞다는 보증은 아닙니다. 그 빈틈은 Lab 03 과제 1의 평가(골든 질문)로 일부 메웁니다.
|
||||||
|
- 6/6. 다만 "반복 발생률" · "1위" 같은 LLM의 말투는 실행마다 다를 수 있습니다 — 테스트는 영역 · 거절 · 숫자만 봅니다.
|
||||||
|
|
||||||
|
## 예제 3 · 질문을 바꾸면 (6분, 에이전트 없이)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** 같은 뜻을 다른 말로 물어(에러 · 장애 · 둘을 함께 · "꺼 줘" · 상태 요약 · 낱말 없는 비용 질문), **낱말 라우터가 어디서 맞고 어디서 틀리는지** 봅니다. 워크플로우는 고치지 않습니다.
|
||||||
|
|
||||||
|
> **생활 속 예 · "열"과 "몸이 뜨거워요".** 간호사는 "열이 나요"도 "몸이 뜨거워요"도 같은 말로 알아듣습니다. 그런데 정해진 낱말만 알아듣는 자동 응답기는 "뜨거워요"를 모릅니다. 예제 3은 자동 응답기(낱말 라우터)에 여러 말투로 물어 보는 실습입니다.
|
||||||
|
|
||||||
|
**상황.** 운영자는 AGENTS.md의 낱말 표를 외워서 묻지 않습니다. `cases/lab02-phrasing.json`을 예제 2의 도우미에 돌립니다.
|
||||||
|
|
||||||
|
| 질문 | 낱말 라우터 (규칙대로) |
|
||||||
|
| --- | --- |
|
||||||
|
| 에러 많이 났어? | `에러` → runs |
|
||||||
|
| 장애 났어? | 맞는 낱말 없음 → **운영 밖으로 거절** |
|
||||||
|
| 알림이랑 DLQ 둘 다 알려 줘 | alerts 1 · dlq 1 → 표의 앞 → alerts |
|
||||||
|
| 카나리 꺼 줘 | `꺼 줘` → 작업 요청 거절 |
|
||||||
|
| 배포 상태 요약해 줘 | release 1(배포) < overview 2(상태 · 요약) → overview |
|
||||||
|
| AI 쓰는 데 얼마나 나갔어? | 맞는 낱말 없음 → **운영 밖으로 거절** |
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** 낱말 라우터는 **틀린 답보다 거절**을 내는 쪽으로 실패한다는 것 (안전하지만 쓸모가 줄어듦). 그래서 과제 1에서 LLM 라우터를 "모를 때만" 붙입니다.
|
||||||
|
|
||||||
|
**적을 곳.** `my-work/OPS.md` 5절 표 (질문 · 영역 · 맞나 · 고칠 방법).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab02-phrasing.json -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
**두 가지 실패.**
|
||||||
|
|
||||||
|
| 실패 | 예 | 결과 | 어느 쪽이 나은가 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 거절해야 할 것을 답함 | 작업 요청에 답 | 위험 (한 줄 알고 넘어감) | 절대 안 됨 → 앞에서 막음 |
|
||||||
|
| 답할 수 있는 것을 거절함 | "장애 났어?" 거절 | 불편 (다시 물어야 함) | 안전 — 낱말을 더하거나 LLM 라우터로 |
|
||||||
|
|
||||||
|
낱말을 더하는 방법(`장애`를 runs에)도 있습니다. 그러나 운영자의 말투는 끝이 없어 목록이 계속 길어집니다. 테스트는 기대를 **규칙대로** 적었습니다 — 이 예제의 6/6은 "도우미가 잘 답했다"가 아니라 "규칙대로 움직였다"입니다.
|
||||||
|
|
||||||
|
**기대 결과.** 6/6 (그중 둘은 "규칙대로 거절"이 기대).
|
||||||
|
|
||||||
|
### 실행 결과 · 질문을 바꾸면
|
||||||
|
|
||||||
|
수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다 (예제 2의 도우미, 고치지 않음).
|
||||||
|
|
||||||
|
실행한 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab02-phrasing.json -v
|
||||||
|
```
|
||||||
|
|
||||||
|
출력 (결과 줄과 응답의 영역 · 답 앞부분):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ '에러' → 실행 · 1.2s
|
||||||
|
✔ '장애' 는 낱말 목록에 없음 → 운영 밖으로 거절 · 0.1s
|
||||||
|
✔ 두 영역이 한 번씩 → 앞의 영역 (알림) · 1.0s
|
||||||
|
✔ '꺼 줘' → 작업 요청으로 거절 · 0.1s
|
||||||
|
✔ '배포'(1) 보다 '상태 · 요약'(2) → 전체 · 1.7s
|
||||||
|
✔ 낱말 없는 비용 질문 → 거절 (과제 1에서 LLM 라우터로) · 0.0s
|
||||||
|
Passed 6/6
|
||||||
|
|
||||||
|
에러 많이 났어? runs · keyword "… 총 357회 실행 중 21회 에러가 발생하여 에러율은 5.88%입니다. …"
|
||||||
|
장애 났어? unknown · 거절
|
||||||
|
알림이랑 DLQ 둘 다 알려 줘 alerts · keyword "알림은 총 6건이며, 그중 3건은 높은 심각도입니다. DLQ 관련 정보는 제공된 사실에 포함되어 있지 않습니다."
|
||||||
|
카나리 꺼 줘 action · 거절 read_only
|
||||||
|
배포 상태 요약해 줘 overview · keyword "… 오류율은 5.88% … 경고는 총 6건 … DLQ에는 3건 …"
|
||||||
|
AI 쓰는 데 얼마나 나갔어? unknown · 거절
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나 (질문마다)**
|
||||||
|
|
||||||
|
| 질문 | 낱말 라우터가 한 일 | 운영자에게는 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 에러 많이 났어? | `에러` 하나 → runs | 좋음 |
|
||||||
|
| 장애 났어? | 맞는 낱말 0 → 거절 | **불편** — 가장 흔한 운영 질문인데 |
|
||||||
|
| 알림이랑 DLQ | 1 대 1 → 표의 앞(alerts) | **반쪽** — "DLQ 정보는 없습니다" (DLQ 사실을 안 줬으니 정직한 답) |
|
||||||
|
| 카나리 꺼 줘 | `꺼 줘` → 거절 | 좋음 — 안전 |
|
||||||
|
| 배포 상태 요약해 줘 | 배포 1 < 상태 · 요약 2 → overview | 괜찮음 — 릴리스만 물은 것이었다면 넓음 |
|
||||||
|
| AI 쓰는 데 얼마나 나갔어? | 맞는 낱말 0 → 거절 | **불편** |
|
||||||
|
|
||||||
|
- **6/6은 "규칙대로 움직였다"는 뜻입니다.** 테스트의 기대를 낱말 라우터의 규칙대로 적었기 때문입니다. 둘(장애 · AI 비용)은 운영자가 원하는 답이 아닙니다.
|
||||||
|
- **실패의 방향이 안전합니다.** 낱말 라우터는 모르면 **거절**합니다. 틀린 영역으로 엉뚱한 답을 하지 않습니다. 과제 1의 LLM 라우터는 이 "거절" 둘을 답으로 바꾸고, 대신 LLM의 판단이 들어옵니다.
|
||||||
|
- "알림이랑 DLQ"의 답은 LLM이 받은 사실만으로 정직하게 답한 예입니다. 질문 하나에 영역 하나라는 설계의 한계 — 두 영역을 묻는 질문은 `overview`(전체)로 보내는 것도 방법입니다.
|
||||||
|
|
||||||
|
## 과제 1 · LLM 라우터 (10분)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** 낱말 라우터가 `unknown`일 때**만** LLM이 영역 하나를 고르게 해, "낱말 없는 질문"(AI 비용 · 어젯밤 남은 접수 · 새 판을 일부에게)에 답하게 합니다. 낱말이 맞는 질문은 LLM을 부르지 않습니다.
|
||||||
|
|
||||||
|
> **생활 속 예 · 자동 응답기 다음에 상담원.** 병원 대표 전화는 먼저 자동 응답기가 "진료 예약은 1번"으로 나눕니다. 번호를 모르는 사람만 상담원에게 갑니다. 모든 전화를 상담원이 받으면 줄이 길어지고, 자동 응답기만 있으면 길을 잃는 사람이 생깁니다. 과제 1은 자동 응답기 뒤에 상담원을 두는 실습입니다.
|
||||||
|
|
||||||
|
| 단계 (AGENTS.md 3절) | 규칙 |
|
||||||
|
| --- | --- |
|
||||||
|
| 작업 요청 거절 | 맨 앞 그대로 (LLM 라우터보다 앞) |
|
||||||
|
| 낱말 라우터 | 맞으면 그대로 `router: "keyword"` |
|
||||||
|
| LLM 라우터 | `unknown`일 때만 `Classify`가 영역 하나 또는 `none`, `router: "llm"` |
|
||||||
|
| 그래도 모르면 | 거절 `out_of_scope` |
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** 규칙과 LLM을 **층으로** 쌓는 법 (빠르고 늘 같은 것을 앞에, 넓지만 느린 것을 뒤에). LLM의 답을 **목록에 있는 말로만** 받는 까닭.
|
||||||
|
|
||||||
|
**적을 곳.** `my-work/OPS.md` 5절 아래 "LLM 라우터를 붙인 뒤".
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab02-challenge.json -v # 고치기 전 (예제 2의 도우미)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Q(["question"]) --> A{"작업 요청?"} -->|예| X(["read_only"])
|
||||||
|
A -->|아니오| K{"낱말 맞음?"} -->|예| G["keyword"]
|
||||||
|
K -->|아니오| C{{"Classify<br/>영역 하나 또는 none"}} --> V["Read class<br/>목록에 있나"]
|
||||||
|
G & V --> U{"알았나?"} -->|아니오| N(["out_of_scope"])
|
||||||
|
U -->|예| F["사실 · 답 · 숫자 확인"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**LLM의 답을 믿지 않고 읽는다.** `Classify`가 "비용 영역입니다!"처럼 말하면 영문자만 남겨 목록(`runs` · `alerts` · … · `overview`)에 있는지 봅니다. 없으면 `unknown` → 거절. LLM이 목록 밖의 말을 해도 도우미가 엉뚱한 곳으로 가지 않습니다.
|
||||||
|
|
||||||
|
**낱말이 맞으면 LLM을 부르지 않는다.** "지난주 오류율"은 `keyword`로 1초(답 LLM 한 번), LLM 라우터를 거치면 LLM 두 번입니다. 비용 · 시간 · 흔들림이 모두 늡니다 (75일차).
|
||||||
|
|
||||||
|
**테스트 케이스.**
|
||||||
|
|
||||||
|
| 케이스 | 질문 | 기대 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 낱말이 없는 비용 질문 | 이번 주에 AI 쓰는 데 얼마나 나갔어? | `cost` · `llm` · `numbers_ok` |
|
||||||
|
| 낱말이 없는 DLQ 질문 | 어젯밤에 접수 못 하고 남은 건 몇 건이야? | `dlq` · `llm`, 답에 3 |
|
||||||
|
| 낱말이 없는 릴리스 질문 | 새 판을 일부 사용자에게 먼저 내보낸 거 어떻게 되고 있어? | `release` · `llm`, 답에 62.5 |
|
||||||
|
| LLM도 운영 밖이면 거절 | 점심 메뉴 추천해 줘 | 거절 · `llm` |
|
||||||
|
| 낱말이 있으면 LLM을 부르지 않음 | 지난주 오류율이 얼마야? | `runs` · `keyword` |
|
||||||
|
|
||||||
|
**기대 결과.** 고치기 전 1/5, 고친 뒤 5/5, `lab02` 6/6 그대로. 예제 3의 `lab02-phrasing`을 다시 돌리면 **4/6** — "장애 났어?"와 "AI 비용"이 이제 거절되지 않고 답합니다 (테스트의 기대는 낱말 라우터 시절의 규칙이라 ✘, 도우미는 나아짐).
|
||||||
|
|
||||||
|
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||||
|
|
||||||
|
요청문에 꼭 들어갈 것: **① 언제 부르나 ② 목록 밖이면 ③ 작업 거절의 자리 ④ 낱말이 맞으면**. 아래 빈칸 4개를 채웁니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/ops-ask.json 에 LLM 라우터를 더해 줘.
|
||||||
|
낱말 라우터가 unknown 일 때____① Classify(AI Agent + gpt-4.1-mini, temperature 0)가 영역 하나 또는 none 을 고른다. 지시문에 영역 목록과 뜻, "Reply with only the area word".
|
||||||
|
Read class(Code): 받은 글에서 영문자만 남겨 영역 목록에 있으면 그 영역, 아니면 ____②. router 는 "llm".
|
||||||
|
그래도 unknown 이면 거절 (out_of_scope). 작업 요청 거절은 LLM 라우터보다 ____③에 그대로.
|
||||||
|
낱말이 맞으면 LLM 을 ____④. 앞의 칸은 그대로. 기대 결과 cases/lab02-challenge.json, cases/lab02.json 도.
|
||||||
|
```
|
||||||
|
|
||||||
|
빈칸 힌트:
|
||||||
|
|
||||||
|
- ① 자동 응답기 다음에 상담원 — 누가 상담원에게 가나요?
|
||||||
|
- ② `Read class`가 목록에 없는 말을 받으면.
|
||||||
|
- ③ 작업 요청이 LLM에게 가면 어떻게 되나요?
|
||||||
|
- ④ 테스트의 마지막 케이스.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/ops-ask.json
|
||||||
|
uv run tools/flow.py test cases/lab02-challenge.json -v
|
||||||
|
uv run tools/flow.py test cases/lab02.json
|
||||||
|
uv run tools/flow.py test cases/lab02-phrasing.json -v # 예제 3을 다시 — 무엇이 바뀌었나
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실행 결과 · LLM 라우터
|
||||||
|
|
||||||
|
수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다.
|
||||||
|
|
||||||
|
#### 고치기 전 · 예제 2의 도우미
|
||||||
|
|
||||||
|
실행한 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab02-challenge.json -v
|
||||||
|
```
|
||||||
|
|
||||||
|
출력 (결과 줄만):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✘ 낱말이 없는 비용 질문 · 0.0s · area = unknown (expected cost); router = keyword (expected llm); refused = true (expected false); numbers_ok = null (expected true)
|
||||||
|
✘ 낱말이 없는 DLQ 질문 · 0.1s · area = unknown (expected dlq); router = keyword (expected llm); numbers_ok = null (expected true); answer has none of ['3']
|
||||||
|
✘ 낱말이 없는 릴리스 질문 · 0.0s · area = unknown (expected release); router = keyword (expected llm); numbers_ok = null (expected true); answer has none of ['62.5']
|
||||||
|
✘ LLM도 운영 밖이면 거절 · 0.1s · router = keyword (expected llm)
|
||||||
|
✔ 낱말이 있으면 LLM을 부르지 않음 · 1.0s
|
||||||
|
Passed 1/5
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나 (고치기 전):** 낱말 없는 세 질문은 모두 `unknown` → 거절(0.0초). 점심 메뉴도 거절은 맞지만 `router`가 `keyword`입니다. 마지막(낱말 있는 질문)만 원래 됩니다. 1/5는 예상한 결과.
|
||||||
|
|
||||||
|
#### 고친 뒤
|
||||||
|
|
||||||
|
실행한 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/ops-ask.json
|
||||||
|
uv run tools/flow.py test cases/lab02-challenge.json -v
|
||||||
|
uv run tools/flow.py test cases/lab02.json
|
||||||
|
uv run tools/flow.py test cases/lab02-phrasing.json -v
|
||||||
|
```
|
||||||
|
|
||||||
|
출력 (결과 줄과 답):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ 낱말이 없는 비용 질문 · 4.9s
|
||||||
|
✔ 낱말이 없는 DLQ 질문 · 1.7s
|
||||||
|
✔ 낱말이 없는 릴리스 질문 · 1.8s
|
||||||
|
✔ LLM도 운영 밖이면 거절 · 0.9s
|
||||||
|
✔ 낱말이 있으면 LLM을 부르지 않음 · 1.0s
|
||||||
|
Passed 5/5
|
||||||
|
|
||||||
|
cost llm "이번 주 AI 사용 비용은 총 0.073604달러입니다. 일일 평균 비용은 약 0.08달러이며, 사용 비율은 92.01%입니다."
|
||||||
|
dlq llm "어젯밤에 접수 못 하고 남은 건은 총 3건입니다. 이 중 2건은 대기 중이고 1건은 주차 상태입니다."
|
||||||
|
release llm "새 판 1.2.0은 일부 사용자 20%에게 먼저 내보내졌으며, 이 버전의 재사용률은 62.5%로 나타났습니다. …"
|
||||||
|
|
||||||
|
(lab02) Passed 6/6
|
||||||
|
|
||||||
|
(lab02-phrasing 다시) Passed 4/6
|
||||||
|
✘ '장애' 는 낱말 목록에 없음 → 운영 밖으로 거절 · 2.2s · area = runs (expected unknown); refused = false (expected true); reason = null (expected out_of_scope)
|
||||||
|
✘ 낱말 없는 비용 질문 → 거절 (과제 1에서 LLM 라우터로) · 1.9s · refused = false (expected true); reason = null (expected out_of_scope)
|
||||||
|
```
|
||||||
|
|
||||||
|
**고친 뒤 이렇게 나온 까닭**
|
||||||
|
|
||||||
|
| 질문 | 예제 2 | 과제 1 | 시간 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| AI 쓰는 데 얼마나 나갔어? | 거절 | `cost` · `llm` · 92.01% | 0.0 → 4.9초 (LLM 두 번) |
|
||||||
|
| 어젯밤 접수 못 하고 남은 건 | 거절 | `dlq` · `llm` · 3건 | 1.7초 |
|
||||||
|
| 새 판을 일부에게 먼저 | 거절 | `release` · `llm` · 62.5% | 1.8초 |
|
||||||
|
| 점심 메뉴 | 거절 (`keyword`) | 거절 (`llm`이 none) | 0.9초 |
|
||||||
|
| 지난주 오류율 | `keyword` | `keyword` (LLM 라우터 안 거침) | 1.0초 |
|
||||||
|
| 장애 났어? (예제 3) | 거절 | `runs`로 답 | 2.2초 |
|
||||||
|
|
||||||
|
- **예제 3의 `lab02-phrasing`이 4/6으로 "떨어졌습니다".** ✘ 둘은 "장애"와 "AI 비용"이 이제 **거절되지 않고 답한** 것입니다. 테스트는 낱말 라우터 시절의 규칙을 기대로 적었으니 ✘가 맞고, 도우미는 나아졌습니다. 기능을 바꾸면 **테스트의 기대도 함께 고쳐야** 한다는 예입니다 (어느 쪽이 맞는지는 사람이 정함).
|
||||||
|
- **LLM 라우터의 값:** 낱말 없는 질문이 LLM 두 번(분류 + 답)으로 2~5초. 낱말이 맞는 질문은 그대로 LLM 한 번입니다.
|
||||||
|
- **숫자는 맞는데 뜻이 틀린 답:** DLQ 답의 "2건은 대기 중이고 1건은 주차 상태"는 **거꾸로**입니다 (parked 2 · waiting 1). 숫자 확인은 통과(`numbers_ok: true`) — 2와 1은 사실에 있습니다. "주차"는 parked를 글자 그대로 옮긴 말이기도 합니다. 이런 틀림은 평가(Lab 03 과제 1)의 기대 숫자로도 잡기 어렵고, 사람이 답을 읽는 검토가 필요합니다. `OPS.md` 4절 "숫자 확인이 잡지 못하는 것"에 적습니다.
|
||||||
|
|
||||||
|
## 과제 2 · 감사 기록 (12분)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** 도우미가 받은 질문마다 — 거절한 것까지 — **감사 기록**(`/audit`) 한 줄을 남겨, "누가 무엇을 물었고, 도우미가 무엇을 거절했나"를 나중에 볼 수 있게 합니다. 응답은 그대로입니다.
|
||||||
|
|
||||||
|
> **생활 속 예 · 간호 기록지의 한 줄.** 보호자가 진통제를 더 달라고 했고 간호사가 "처방이 있어야 한다"고 답했다면, 그 일도 기록지에 한 줄 남깁니다. 나중에 "그때 왜 안 줬냐"는 물음에 기록이 답합니다. 과제 2는 도우미의 기록지를 쓰는 실습입니다.
|
||||||
|
|
||||||
|
**상황.** 팀장이 요청했습니다: "도우미를 팀 전체에 열기 전에, 누가 무엇을 물었고 무엇을 거절했는지 남겨 주세요. 특히 작업을 시키려 한 질문은 꼭요." 데이터 API에는 73일차의 감사 기록(`/audit`)이 있습니다.
|
||||||
|
|
||||||
|
| 정의 (AGENTS.md 3절 "감사 기록") | |
|
||||||
|
| --- | --- |
|
||||||
|
| 언제 | 질문마다, 응답하기 **전에** — 갈래 셋(작업 거절 · 운영 밖 · 답) 모두 |
|
||||||
|
| 무엇을 | `{kind: "ops-ask", question, area, router, refused, reason, numbers_ok}` (거절이면 `numbers_ok: null`) |
|
||||||
|
| 응답 | 그대로 (감사는 곁가지) |
|
||||||
|
| 읽기 전용 원칙 | `/audit`은 **덧붙이기만** 하는 기록이라 예외 — 다른 상태는 바꾸지 않음 |
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** LLM 도우미를 사람에게 열 때 **기록이 먼저**라는 것 (감사 · 보안 · 개선의 재료). 응답 앞에 노드를 끼울 때 응답 모양을 지키는 법.
|
||||||
|
|
||||||
|
**적을 곳.** `my-work/OPS.md` 6절.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab02-challenge2.json -v # 고치기 전 (과제 1의 도우미)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["Refuse action"] --> A1["Audit action"] --> A2(["거절 응답"])
|
||||||
|
B["Out of scope"] --> B1["Audit scope"] --> B2(["거절 응답"])
|
||||||
|
C["Check numbers"] --> C1["Audit answer"] --> C2(["답 응답"])
|
||||||
|
A1 & B1 & C1 -.-> L[("/audit")]
|
||||||
|
```
|
||||||
|
|
||||||
|
**응답이 바뀌지 않게.** 감사 HTTP 노드의 출력은 데이터 API의 응답(`{"stored": true}`)입니다. 그 뒤의 응답 노드가 `$json`을 그대로 보내면 운영자는 `{"stored": true}`를 받습니다. 그래서 응답은 **앞 노드**(`Route` · `Routed` · `Check numbers`)의 결과를 이름으로 다시 읽습니다 (78일차 `Result again`과 같은 생각).
|
||||||
|
|
||||||
|
**감사가 실패해도 답은 나간다.** 감사 노드에 `neverError` — 감사 저장소가 멈췄다고 운영자가 답을 못 받으면 안 됩니다 (Lab 01 과제 2의 "출처가 멈추면"과 같은 생각).
|
||||||
|
|
||||||
|
**거절도 기록하는 까닭.** 작업 요청이 자주 들어온다면 → 사람들이 도우미에게 무엇을 기대하는지(런북을 더 쉽게?), 운영 밖 질문이 많다면 → 낱말 · 라우터의 빈틈입니다. 기록이 없으면 개선할 재료가 없습니다.
|
||||||
|
|
||||||
|
**테스트 케이스.** 테스트가 먼저 감사 기록을 비우고(`ops-reset`) 세 질문을 보낸 뒤 기록을 봅니다.
|
||||||
|
|
||||||
|
| 케이스 | 기대 |
|
||||||
|
| --- | --- |
|
||||||
|
| 작업 요청 · 운영 밖 · 답한 질문 | 각각 `read_only` · `out_of_scope` · `runs` (응답은 앞과 같음) |
|
||||||
|
| 감사 기록 세 줄 | 0번 `read_only` · `action`, 1번 `out_of_scope` · `llm`, 2번 `runs` · `numbers_ok: true`, 3번은 없음 |
|
||||||
|
| 응답은 그대로 | 작업 거절의 `answer`에 "읽기만" |
|
||||||
|
|
||||||
|
**기대 결과.** 고치기 전 4/5 (기록만 ✘), 고친 뒤 5/5, `lab02` · `lab02-challenge` 그대로.
|
||||||
|
|
||||||
|
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||||
|
|
||||||
|
요청문에 꼭 들어갈 것: **① 몇 갈래 ② 응답이 읽는 곳 ③ 읽기 전용 원칙 ④ 감사가 실패하면**. 아래 빈칸 4개를 채웁니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/ops-ask.json 에 감사 기록을 더해 줘.
|
||||||
|
갈래 ____①(작업 거절 · 운영 밖 · 답) 모두, 응답하기 전에 POST http://data-api:8800/audit 한 줄 {kind: "ops-ask", question, area, router, refused, reason, numbers_ok} (거절이면 numbers_ok null).
|
||||||
|
감사 HTTP 노드 뒤의 응답은 그 노드의 출력이 아니라 ____②의 결과를 다시 읽어 보낸다 (응답 모양은 그대로).
|
||||||
|
/audit 은 덧붙이기만 하는 기록이고, 다른 상태는 ____③. neverError 를 켜서 감사가 실패해도 ____④.
|
||||||
|
기대 결과 cases/lab02-challenge2.json, lab02 · lab02-challenge 도.
|
||||||
|
```
|
||||||
|
|
||||||
|
빈칸 힌트:
|
||||||
|
|
||||||
|
- ① 거절도 기록하나요?
|
||||||
|
- ② 위 "응답이 바뀌지 않게".
|
||||||
|
- ③ AGENTS.md 4절의 마지막 줄 괄호.
|
||||||
|
- ④ 위 "감사가 실패해도".
|
||||||
|
</details>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/ops-ask.json
|
||||||
|
uv run tools/flow.py test cases/lab02-challenge2.json -v
|
||||||
|
uv run tools/flow.py test cases/lab02.json
|
||||||
|
uv run tools/flow.py test cases/lab02-challenge.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실행 결과 · 감사 기록
|
||||||
|
|
||||||
|
수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다. 테스트가 먼저 `ops-reset`으로 감사 기록을 비웁니다.
|
||||||
|
|
||||||
|
#### 고치기 전 · 과제 1의 도우미
|
||||||
|
|
||||||
|
실행한 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab02-challenge2.json -v
|
||||||
|
```
|
||||||
|
|
||||||
|
출력 (결과 줄만):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ 작업 요청 · 0.0s
|
||||||
|
✔ 운영 밖 · 0.7s
|
||||||
|
✔ 답한 질문 · 1.1s
|
||||||
|
✘ 감사 기록 세 줄 · 거절 이유와 함께 · 0.0s · events.0.kind = null (expected ops-ask); events.0.reason = null (expected read_only); events.0.area = null (expected action); events.1.reason = null (expected out_of_scope); events.1.router = null …
|
||||||
|
✔ 응답은 그대로 (감사는 곁가지) · 0.0s
|
||||||
|
Passed 4/5
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나 (고치기 전):** 질문 셋은 과제 1의 도우미로도 맞게 답합니다. 감사 기록이 비어 있어(`events: []`) 넷째만 ✘. 4/5는 예상한 결과.
|
||||||
|
|
||||||
|
#### 고친 뒤
|
||||||
|
|
||||||
|
실행한 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/ops-ask.json
|
||||||
|
uv run tools/flow.py test cases/lab02-challenge2.json -v
|
||||||
|
uv run tools/flow.py test cases/lab02.json
|
||||||
|
uv run tools/flow.py test cases/lab02-challenge.json
|
||||||
|
```
|
||||||
|
|
||||||
|
출력 (결과 줄과 감사 기록):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ 작업 요청 · 1.0s
|
||||||
|
✔ 운영 밖 · 1.6s
|
||||||
|
✔ 답한 질문 · 1.1s
|
||||||
|
✔ 감사 기록 세 줄 · 거절 이유와 함께 · 0.0s
|
||||||
|
✔ 응답은 그대로 (감사는 곁가지) · 0.1s
|
||||||
|
Passed 5/5
|
||||||
|
|
||||||
|
"events": [
|
||||||
|
{"kind": "ops-ask", "question": "DLQ 1번 다시 돌려 줘", "area": "action", "router": "keyword", "refused": true, "reason": "read_only", "numbers_ok": null},
|
||||||
|
{"kind": "ops-ask", "question": "점심 메뉴 추천해 줘", "area": "unknown", "router": "llm", "refused": true, "reason": "out_of_scope", "numbers_ok": null},
|
||||||
|
{"kind": "ops-ask", "question": "지난주 오류율이 얼마야?", "area": "runs", "router": "keyword", "refused": false, "reason": null, "numbers_ok": true}]
|
||||||
|
|
||||||
|
(lab02) Passed 6/6 · (lab02-challenge) Passed 5/5
|
||||||
|
```
|
||||||
|
|
||||||
|
**고친 뒤 이렇게 나온 까닭**
|
||||||
|
|
||||||
|
| 질문 | 응답 | 감사 기록 | 이 기록으로 알 수 있는 것 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| DLQ 1번 다시 돌려 줘 | 거절 (같음) | `action` · `keyword` · `read_only` | 누가 작업을 시키려 했나 → 런북 · 권한 안내 |
|
||||||
|
| 점심 메뉴 | 거절 (같음) | `unknown` · **`llm`** · `out_of_scope` | LLM 라우터까지 갔다가 거절 — 비용이 든 거절 |
|
||||||
|
| 지난주 오류율 | 답 (같음) | `runs` · `keyword` · `numbers_ok: true` | 무엇을 자주 묻나 · 근거가 있었나 |
|
||||||
|
|
||||||
|
- **응답은 하나도 바뀌지 않았습니다** (`lab02` 6/6 · `lab02-challenge` 5/5). 감사 노드 뒤의 응답이 앞 노드의 결과를 이름으로 다시 읽기 때문입니다.
|
||||||
|
- **넷째 줄이 없음(`events.3: null`):** 질문 셋에 기록 셋. 다섯째 케이스(같은 작업 요청)는 기록을 본 **뒤**라 셈에 들어가지 않습니다.
|
||||||
|
- 기록에는 질문 원문이 남습니다. 질문에 개인정보가 섞일 수 있으니, 팀에 열기 전에 감사 기록을 누가 · 얼마나 오래 보관하는지 정합니다 (73일차 감사 · 개인정보).
|
||||||
|
|
||||||
|
## 막혔을 때
|
||||||
|
|
||||||
|
| 증상 | 할 일 |
|
||||||
|
| --- | --- |
|
||||||
|
| 점심 질문에 답함 | `Known area?`(IF): `unknown`이면 LLM 앞에서 거절 |
|
||||||
|
| 답에 묻지 않은 숫자 | `Pick facts`가 그 영역만 넘기는지 |
|
||||||
|
| "다시 돌려 줘"가 `dlq`로 | 작업 낱말 검사를 낱말 라우터 **앞**에 |
|
||||||
|
| `numbers_ok`가 늘 false | ×100 · 반올림 · 글자 속 숫자 · 버전을 허용하는지 (▶ 예제 2의 1단계) |
|
||||||
|
| 같은 수인데 영역이 날마다 다름 | 같으면 표의 앞 |
|
||||||
|
| LLM 라우터가 늘 `unknown` | 지시문에 영역 목록과 "Reply with only the area word", 받은 글에서 영문자만 |
|
||||||
|
| 낱말 질문도 `llm` | 낱말이 맞으면 `Classify`를 건너뜀 |
|
||||||
|
| 응답이 `{"stored": true}` | 감사 뒤 응답은 앞 노드의 결과를 이름으로 |
|
||||||
|
| 감사 기록이 둘뿐 | 거절 갈래에도 감사 노드 |
|
||||||
|
| 계속 안 됨 | `cp reference/lab02-example2-ask.json my-work/ops-ask.json` 후 push (예제 2까지) |
|
||||||
|
|
||||||
|
## 확인
|
||||||
|
|
||||||
|
- [ ] 예제 1: `cases/lab02.json` 3/6 (고치기 전 기준에 0/6), `OPS.md` 4절.
|
||||||
|
- [ ] 예제 2: `cases/lab02.json` 6/6.
|
||||||
|
- [ ] 예제 3: `cases/lab02-phrasing.json` 6/6, `OPS.md` 5절.
|
||||||
|
- [ ] 과제 1: `cases/lab02-challenge.json` 5/5, `lab02` 6/6, `lab02-phrasing`을 다시 (4/6 — 무엇이 나아졌나).
|
||||||
|
- [ ] 과제 2: `cases/lab02-challenge2.json` 5/5, `OPS.md` 6절.
|
||||||
|
- [ ] `my-work/PROMPTS.md`.
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Lab 03 · 브리핑과 평가 · 먼저 볼 것을 정하고, 도우미를 시험하기
|
||||||
|
|
||||||
|
> **강사용 정답본** · 학생 저장소에는 수업이 끝난 뒤 올립니다. 학생용 문제는 [LAB-03-Q-OPS-BRIEF.md](LAB-03-Q-OPS-BRIEF.md).
|
||||||
|
> 요청문은 예시이며 CLI 에이전트로 보내 재지는 않았습니다. 결과는 2026-11-01 정답 워크플로우 1회 실행 값입니다. 명령 · 출력 전부: [answers/LAB-TEST-RESULT.md](answers/LAB-TEST-RESULT.md).
|
||||||
|
|
||||||
|
## 예제 1 · 요청문 예
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/ops-brief.json 을 새로 만들어 줘. 웹훅 POST my-ops-brief, 이름 "79 Ops brief (my)".
|
||||||
|
Get facts: HTTP POST http://localhost:5678/webhook/my-ops-facts (본문 from · to 그대로). 상태도 사실에서 그대로 쓴다 (다시 판정하지 않음).
|
||||||
|
Make brief(Code): actions = red 다음 yellow 의 앞 3개, 영역마다 AGENTS.md 3절 표의 할 일 글자 그대로.
|
||||||
|
markdown: 제목 · 전체 · | 영역 | 상태 | 숫자 | 표 (🔴 · 🟡 · 🟢, 숫자 칸은 3절 표의 꼴) · "## 먼저 볼 것".
|
||||||
|
LLM 은 쓰지 않는다. 결과 {overall, red, yellow, actions, markdown}. 기대 결과 cases/lab03.json.
|
||||||
|
```
|
||||||
|
|
||||||
|
**빈칸 답**
|
||||||
|
|
||||||
|
| 빈칸 | 답 | 왜 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① | 그대로 | 상태 판정은 한 곳(사실) — 브리핑이 다시 판정하면 어긋남 |
|
||||||
|
| ② | `3` | 아침에 먼저 볼 것은 셋까지 |
|
||||||
|
| ③ | 먼저 볼 것 | 테스트 · 다음 단계가 이 제목을 찾음 |
|
||||||
|
| ④ | 쓰지 않는다 | 표와 할 일은 규칙 — 늘 같은 브리핑 |
|
||||||
|
|
||||||
|
정답: [reference/lab03-example1-brief.json](reference/lab03-example1-brief.json)
|
||||||
|
|
||||||
|
| 11/01 결과 | |
|
||||||
|
| --- | --- |
|
||||||
|
| 고치기 전 (`answers/17`) | **0/4** · 404 |
|
||||||
|
| 고친 뒤 (`answers/18`) | **4/4** · 🔴, 먼저 볼 것 실행 · DLQ · 릴리스, 표 `\| DLQ \| 🔴 \| 3건 (parked 2) \|`, DLQ를 비우면 셋째에 알림. LLM 없음 |
|
||||||
|
|
||||||
|
## 예제 2 · 요청문 예
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/ops-brief.json 에 LLM 요약을 더해 줘.
|
||||||
|
Summarize(AI Agent + gpt-4.1-mini, temperature 0): 사실 JSON 과 actions 만 주고 한국어 3문장 — 전체 상태 · 가장 급한 곳 · 먼저 할 일. 사실에 없는 숫자 · 원인은 쓰지 않는다.
|
||||||
|
Check summary(Code): 요약의 숫자가 사실 안에 있는지 Lab 02 예제 2 와 같은 방법으로 보고 numbers_ok.
|
||||||
|
markdown 끝에 "## 요약" 과 요약 글. 표 · 먼저 볼 것은 Code 가 만든 그대로 (LLM 이 고치지 않음).
|
||||||
|
기대 결과 cases/lab03-llm.json, cases/lab03.json 도.
|
||||||
|
```
|
||||||
|
|
||||||
|
**빈칸 답**
|
||||||
|
|
||||||
|
| 빈칸 | 답 | 왜 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① | `3` | 아침 회의용 세 문장 |
|
||||||
|
| ② | `numbers_ok` | 요약의 숫자도 확인 (지어낸 숫자 찾기) |
|
||||||
|
| ③ | 요약 | 테스트가 `## 요약`을 찾음 |
|
||||||
|
| ④ | 고치지 않음 | LLM은 요약 글만 — 숫자 표는 Code |
|
||||||
|
|
||||||
|
정답: [reference/lab03-example2-brief.json](reference/lab03-example2-brief.json)
|
||||||
|
|
||||||
|
| 11/01 결과 | |
|
||||||
|
| --- | --- |
|
||||||
|
| 고치기 전 (`answers/19`) | **0/2** · `summary` 없음 |
|
||||||
|
| 고친 뒤 (`answers/20`) | **2/2** · 세 문장 "… 가장 긴급한 문제는 'Search FAQ' … 16건 …", `numbers_ok: true`, 1~3.5초. `lab03` 4/4 |
|
||||||
|
|
||||||
|
- 풀이 때: 요약은 먼저 볼 것 셋 중 하나만 말함 → 표 · 순서는 Code가.
|
||||||
|
|
||||||
|
## 예제 3 · 결과 (에이전트 없이, `answers/21`)
|
||||||
|
|
||||||
|
| 사람이 한 일 | 빨강 | 먼저 볼 것 | 고친 것인가 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| (처음) | runs · dlq · release | 실행 · DLQ · 릴리스 | |
|
||||||
|
| parked 둘 | runs · release | 실행 · 릴리스 · 알림 | 그렇다 |
|
||||||
|
| 카나리 끔 | runs | 실행 · 알림 · DLQ | 위험을 멈춤 (1.2.0은 그대로) |
|
||||||
|
| 예산 0.2 | runs | 실행 · 알림 · DLQ | 한도를 바꿈 (쓴 돈은 그대로) |
|
||||||
|
|
||||||
|
- **5/5.** 풀이 때: "초록이 된 까닭을 함께 말한다" (77일차 SLO와 같음).
|
||||||
|
|
||||||
|
## 과제 1 · 요청문 예
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/ops-eval.json 을 새로 만들어 줘. 웹훅 POST my-ops-eval, 본문 {cases: [{question, area, refused, numbers}]}, 이름 "79 Ops eval (my)".
|
||||||
|
cases 가 비었으면 HTTP 400. Split cases(Code)로 케이스를 아이템으로 나누고, HTTP 노드 하나가 아이템마다 my-ops-ask 를 부른다.
|
||||||
|
Score(Code): 케이스마다 area 같음 · refused 같음 · numbers 중 하나가 답에 있음 · 거절이 아니면 numbers_ok — 넷 다 맞으면 통과.
|
||||||
|
score = 통과 / 전체 (소수 4자리), failed 에는 통과 못 한 케이스와 어느 것이 틀렸나 (area_ok · refused_ok · number_ok · grounded · answer).
|
||||||
|
기대 결과 cases/lab03-challenge.json.
|
||||||
|
```
|
||||||
|
|
||||||
|
**빈칸 답**
|
||||||
|
|
||||||
|
| 빈칸 | 답 | 왜 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① | `400` | 빈 평가는 '모두 통과(0/0)'가 아니라 잘못된 요청 |
|
||||||
|
| ② | 넷 다 | 영역만 맞고 숫자가 틀리면 통과가 아님 |
|
||||||
|
| ③ | `4` | 0.875처럼 |
|
||||||
|
| ④ | `answer` | 왜 틀렸는지 사람이 읽어 보게 |
|
||||||
|
|
||||||
|
정답: [answers/79-lab03-challenge-eval.json](answers/79-lab03-challenge-eval.json)
|
||||||
|
|
||||||
|
| 11/01 결과 | |
|
||||||
|
| --- | --- |
|
||||||
|
| 고치기 전 (`answers/22`) | **0/3** · 404 |
|
||||||
|
| 고친 뒤 (`answers/23`) | **3/3** · 골든 8/8 · score 1 (3.9초), 틀린 기대는 `area_ok: false`로 잡음, 빈 목록 400 |
|
||||||
|
|
||||||
|
## 과제 2 · 요청문 예
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/ops-eval.json 에 평가 관문을 더해 줘.
|
||||||
|
본문 min_score (없으면 0.9) 와 비교해 gate = score 가 min_score 이상이면 "pass", 아니면 "fail". 결과에 min_score · gate.
|
||||||
|
fail 이면 POST http://data-api:8800/alerts {workflow: "ops-eval", severity: "medium", reason: "eval_below", score, min_score, message}.
|
||||||
|
알림을 보내도 응답은 평가 결과 그대로. 앞의 칸은 그대로. 기대 결과 cases/lab03-challenge2.json, cases/lab03-challenge.json 도.
|
||||||
|
```
|
||||||
|
|
||||||
|
**빈칸 답**
|
||||||
|
|
||||||
|
| 빈칸 | 답 | 왜 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① | `0.9` | AGENTS.md 3절 기본 기준 |
|
||||||
|
| ② | 이상 | 같으면 통과 (78일차 게이트와 같은 경계) |
|
||||||
|
| ③ | `eval_below` | 테스트가 이 이유로 알림을 찾음 |
|
||||||
|
| ④ | 그대로 | 알림은 곁가지 |
|
||||||
|
|
||||||
|
정답: [answers/79-lab03-challenge2-gate.json](answers/79-lab03-challenge2-gate.json)
|
||||||
|
|
||||||
|
| 11/01 결과 | |
|
||||||
|
| --- | --- |
|
||||||
|
| 고치기 전 (`answers/24`) | **0/4** · `gate` 없음, 알림 0 |
|
||||||
|
| 고친 뒤 (`answers/25`) | **4/4** · 기본 0.9 pass, 골든 0.8 pass, 틀린 기대 0 fail → `eval_below` 알림 1건. `lab03-challenge` 3/3 |
|
||||||
|
|
||||||
|
- 풀이 때: 수업 준비 때 "실패 → 알림" 뒤에 "알림이 몇 건?"을 두니 도우미가 **7건**이라 답해 평가가 0점 — 평가가 남긴 알림이 다음 평가를 바꿈. 케이스 순서를 바꿈.
|
||||||
@@ -0,0 +1,640 @@
|
|||||||
|
# Lab 03 · 브리핑과 평가 · 먼저 볼 것을 정하고, 도우미를 시험하기
|
||||||
|
|
||||||
|
- 관련 강의: [3단계 · 브리핑과 평가](../../Lecture/79/03-STAGE3-OPS-BRIEF.md)
|
||||||
|
- 소요 시간: 50분 (6교시 한 시간) · 예제 1 14분 · 예제 2 8분 · 예제 3 6분 · 과제 1 12분 · 과제 2 10분
|
||||||
|
- 준비: Lab 01의 `my-ops-facts`(과제까지) · Lab 02의 `my-ops-ask`(과제까지)가 게시돼 있음
|
||||||
|
- 목표: 사실 · 상태로 아침 **브리핑**(영역 표 · 먼저 볼 것 셋)을 규칙으로 만들고, LLM 세 문장 요약을 붙여 숫자를 확인하고, 사람이 일을 하나씩 처리할 때 브리핑이 어떻게 바뀌는지 재 보고, **골든 질문**으로 도우미를 평가하고, 평가 점수가 기준 아래면 알리는 **평가 관문**을 만든다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
E1["예제 1<br/>브리핑"] --> E2["예제 2<br/>LLM 요약"] --> E3["예제 3<br/>아침이 바뀌면"] --> T1["과제 1<br/>골든 질문 평가"] --> T2["과제 2<br/>평가 관문"]
|
||||||
|
```
|
||||||
|
|
||||||
|
> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다. 연습이 끝나면 `uv run tools/flow.py morning --reset`.
|
||||||
|
|
||||||
|
## 예제 1 · 브리핑 (14분)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** 운영 사실(상태 포함)을 다시 불러 **아침 브리핑**(`my-ops-brief`)을 만듭니다: 전체 · 영역 표(🔴 · 🟡 · 🟢와 숫자) · "먼저 볼 것" 셋과 정해진 할 일. LLM은 쓰지 않습니다.
|
||||||
|
|
||||||
|
> **생활 속 예 · 인수인계지 한 장.** 아침 인수인계는 환자마다 차트를 다 읽지 않고 한 장으로 끝냅니다: 병동 전체 상태, 환자별 신호등, 그리고 "오늘 먼저 볼 환자 셋 · 할 일". 할 일은 그때그때 지어내지 않고 병원의 지침(런북)에 적힌 대로 씁니다. 예제 1은 그 한 장을 만드는 실습입니다.
|
||||||
|
|
||||||
|
**상황.** 운영 담당자는 08:30에 숫자 서른 개가 아니라 **무엇부터 볼지**를 알고 싶습니다. 사실과 상태는 Lab 01에서 정했습니다. 브리핑은 그것을 정해진 모양으로 줄 세우기만 합니다.
|
||||||
|
|
||||||
|
| 칸 (AGENTS.md 3절 "브리핑") | 정의 |
|
||||||
|
| --- | --- |
|
||||||
|
| `actions` | `red` 다음 `yellow`의 앞 **3개**, 영역마다 정해진 할 일 (예: DLQ → "parked 건 원인 확인 뒤 런북대로 재처리 (76일차)") |
|
||||||
|
| `markdown` | 제목 · 전체 · `\| 영역 \| 상태 \| 숫자 \|` 표 · "## 먼저 볼 것" |
|
||||||
|
| 상태 | 사실의 `health` 그대로 (다시 판정하지 않음) |
|
||||||
|
|
||||||
|
**순서.** 테스트 먼저(404) → 요청문 → push → 4/4 → `call`로 브리핑 읽기.
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** 브리핑은 **판정이 아니라 배열**이라는 것 (판정은 사실 한 곳에서). 할 일을 규칙으로 쓰는 까닭 (77일차 주간 보고의 "다음 할 일"과 같음).
|
||||||
|
|
||||||
|
**적을 곳.** `my-work/OPS.md` 7절.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab03.json -v # 고치기 전 (my-ops-brief가 아직 없음)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
W(["from · to"]) --> F["Get facts<br/>my-ops-facts (상태 포함)"] --> B["Make brief (Code)<br/>표 · 먼저 볼 것 셋 · 할 일"] --> O(["overall · red · yellow · actions · markdown"])
|
||||||
|
```
|
||||||
|
|
||||||
|
**판정은 한 곳에.** 브리핑이 상태를 다시 판정하면(예: 비용 80%를 브리핑에서 따로 셈), Lab 01의 상태와 어긋나는 날이 옵니다. 브리핑은 `health` · `red` · `yellow`를 **그대로** 읽어 줄 세웁니다.
|
||||||
|
|
||||||
|
**할 일은 런북에서.** 영역마다 할 일 한 줄은 이미 정해져 있습니다 (72~78일차에 만든 것들). LLM에게 "무엇을 하면 좋을까"를 물으면 그럴듯하지만 날마다 다르고, 런북에 없는 일을 권할 수 있습니다.
|
||||||
|
|
||||||
|
**테스트 케이스.**
|
||||||
|
|
||||||
|
| 케이스 | 기대 |
|
||||||
|
| --- | --- |
|
||||||
|
| 전체 · 먼저 볼 것 셋 | 🔴, 빨강 runs · dlq · release → 먼저 볼 것도 그 순서 |
|
||||||
|
| 표에 숫자 | `\| DLQ \| 🔴 \| 3건 (parked 2) \|` |
|
||||||
|
| 할 일은 규칙에서 | "카나리 끄기 · 되돌리기 검토 (74일차)", DLQ 할 일에 parked |
|
||||||
|
| DLQ를 비운 뒤 | 테스트가 DLQ를 모두 비움(`dlq-clear`) → 빨강 runs · release, 셋째로 alerts |
|
||||||
|
|
||||||
|
**기대 결과.** 고치기 전 0/4 (404), 고친 뒤 4/4.
|
||||||
|
|
||||||
|
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||||
|
|
||||||
|
요청문에 꼭 들어갈 것: **① 상태의 출처 ② 먼저 볼 것의 수 ③ 마크다운 제목 ④ LLM**. 아래 빈칸 4개를 채웁니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/ops-brief.json 을 새로 만들어 줘. 웹훅 POST my-ops-brief, 이름 "79 Ops brief (my)".
|
||||||
|
Get facts: HTTP POST http://localhost:5678/webhook/my-ops-facts (본문 from · to 그대로). 상태도 사실에서 ____① 쓴다 (다시 판정하지 않음).
|
||||||
|
Make brief(Code): actions = red 다음 yellow 의 앞 ____②개, 영역마다 AGENTS.md 3절 표의 할 일 글자 그대로.
|
||||||
|
markdown: 제목 · 전체 · | 영역 | 상태 | 숫자 | 표 (🔴 · 🟡 · 🟢, 숫자 칸은 3절 표의 꼴) · "## ____③".
|
||||||
|
LLM 은 ____④. 결과 {overall, red, yellow, actions, markdown}. 기대 결과 cases/lab03.json.
|
||||||
|
```
|
||||||
|
|
||||||
|
빈칸 힌트:
|
||||||
|
|
||||||
|
- ① 판정은 어디서 한 번만 하나요?
|
||||||
|
- ② AGENTS.md 3절 "브리핑" 첫 줄.
|
||||||
|
- ③ 테스트 · 예제 2가 찾는 절 제목.
|
||||||
|
- ④ 예제 1에서 LLM을 쓰나요?
|
||||||
|
</details>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/ops-brief.json
|
||||||
|
uv run tools/flow.py test cases/lab03.json -v
|
||||||
|
uv run tools/flow.py call my-ops-brief '{}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실행 결과 · 브리핑
|
||||||
|
|
||||||
|
수업 준비 때(11/01) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. 브리핑은 규칙으로 만들어 누구에게나 같습니다.
|
||||||
|
|
||||||
|
#### 고치기 전 · `my-ops-brief`가 없을 때
|
||||||
|
|
||||||
|
실행한 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab03.json -v
|
||||||
|
```
|
||||||
|
|
||||||
|
출력 (결과 줄만):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✘ 전체 · 먼저 볼 것 셋 · 0.0s · status 404 (expected 200); overall = null (expected red); red = null (expected ["runs", "dlq", "release"]); …
|
||||||
|
✘ 표에 숫자 · 0.0s · status 404 (expected 200); markdown has none of ['| DLQ | 🔴 | 3건 (parked 2) |']
|
||||||
|
✘ 할 일은 규칙에서 · 0.0s · status 404 (expected 200); markdown has none of ['카나리 끄기 · 되돌리기 검토 (74일차)']; actions.1.action has none of ['parked']
|
||||||
|
✘ DLQ를 비운 뒤 · 0.0s · status 404 (expected 200); red = null (expected ["runs", "release"]); actions.2.area = null (expected alerts)
|
||||||
|
Passed 0/4
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나 (고치기 전):** 웹훅이 아직 없어 404입니다. 0/4는 예상한 결과.
|
||||||
|
|
||||||
|
#### 고친 뒤
|
||||||
|
|
||||||
|
실행한 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/ops-brief.json
|
||||||
|
uv run tools/flow.py test cases/lab03.json -v
|
||||||
|
uv run tools/flow.py call my-ops-brief '{}'
|
||||||
|
```
|
||||||
|
|
||||||
|
출력 (결과 줄과 `call`의 `markdown`):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ 전체 · 먼저 볼 것 셋 · 1.1s
|
||||||
|
✔ 표에 숫자 · 0.2s
|
||||||
|
✔ 할 일은 규칙에서 · 0.1s
|
||||||
|
✔ DLQ를 비운 뒤 · 0.1s
|
||||||
|
Passed 4/4
|
||||||
|
|
||||||
|
# 운영 브리핑 (2026-10-26 ~ 2026-10-30 실행 기록 + 지금 상태)
|
||||||
|
|
||||||
|
전체: 🔴 red
|
||||||
|
|
||||||
|
| 영역 | 상태 | 숫자 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 실행 | 🔴 | 오류율 5.88% · p95 2598ms · 답 못 함 4.76% |
|
||||||
|
| 알림 | 🟡 | 6건 (높음 3) |
|
||||||
|
| DLQ | 🔴 | 3건 (parked 2) |
|
||||||
|
| 데이터 품질 | 🟡 | 평균 0.6583 · 마지막 1 · 격리 1 |
|
||||||
|
| 비용 | 🟡 | $0.073604 / $0.08 (92.01%) |
|
||||||
|
| 릴리스 | 🔴 | 1.1.0 · 카나리 1.2.0 20% (다시 묻기 62.5%) |
|
||||||
|
|
||||||
|
## 먼저 볼 것
|
||||||
|
|
||||||
|
1. 🔴 실행: 오류 묶음 1위 확인 · 고치기 (77일차)
|
||||||
|
2. 🔴 DLQ: parked 건 원인 확인 뒤 런북대로 재처리 (76일차)
|
||||||
|
3. 🔴 릴리스: 카나리 끄기 · 되돌리기 검토 (74일차)
|
||||||
|
```
|
||||||
|
|
||||||
|
**고친 뒤 이렇게 나온 까닭**
|
||||||
|
|
||||||
|
| 칸 | 어디서 | 어떻게 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 전체 · 상태 | 사실의 `overall` · `health` | 그대로 (다시 판정하지 않음) |
|
||||||
|
| 표의 숫자 | 사실 | AGENTS.md 3절의 꼴 (비율은 ×100 · 두 자리) |
|
||||||
|
| 먼저 볼 것 | `red` 다음 `yellow`의 앞 3개 | 빨강이 셋이라 셋 다 빨강 |
|
||||||
|
| 할 일 | 영역마다 정해진 한 줄 | 72~78일차 런북 |
|
||||||
|
|
||||||
|
- **DLQ를 비운 뒤:** 테스트가 DLQ를 모두 비우면(`dlq-clear`) 빨강은 runs · release, 셋째 자리에 노랑의 첫째인 alerts가 들어옵니다. 브리핑을 고치지 않아도 사실이 바뀌면 순서가 바뀝니다 (예제 3).
|
||||||
|
- **0.1~1초:** 첫 호출의 1.1초는 n8n이 막 다시 켜진 뒤라서입니다. LLM은 없습니다.
|
||||||
|
|
||||||
|
## 예제 2 · LLM 요약 (8분)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** 브리핑 끝에 LLM이 쓴 **세 문장 요약**(전체 · 가장 급한 곳 · 먼저 할 일)을 붙이고, 요약의 숫자를 Code가 확인합니다. 표 · 먼저 볼 것은 Code가 만든 그대로입니다.
|
||||||
|
|
||||||
|
> **생활 속 예 · 수간호사의 세 마디.** 인수인계지 한 장을 다 읽을 시간이 없는 과장님께 수간호사가 세 마디로 말합니다: "병동은 위험 단계, 가장 급한 건 3호실 감염, 먼저 격리부터." 그 세 마디에 인수인계지에 없는 숫자가 들어가면 안 됩니다. 예제 2는 세 마디(요약)를 붙이고 숫자를 대조하는 실습입니다.
|
||||||
|
|
||||||
|
| 노드 | 하는 일 |
|
||||||
|
| --- | --- |
|
||||||
|
| `Summarize` (AI Agent + `gpt-4.1-mini`, temperature 0) | 사실 · `actions`만 받아 한국어 세 문장 |
|
||||||
|
| `Check summary` (Code) | 요약의 숫자가 사실 안에 있나 → `numbers_ok` (Lab 02 예제 2와 같은 방법) |
|
||||||
|
| markdown | 끝에 "## 요약" + 요약 글 (표 · 먼저 볼 것은 그대로) |
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** LLM이 하는 일(말로 줄이기)과 하지 않는 일(판정 · 배열 · 숫자)의 경계. 요약은 실행마다 글이 달라도 숫자 확인으로 묶어 둘 수 있다는 것.
|
||||||
|
|
||||||
|
**적을 곳.** `my-work/OPS.md` 7절 아래 "LLM 요약이 한 것 · 하지 않은 것".
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab03-llm.json -v # 고치기 전 (예제 1의 브리핑)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
**글은 LLM, 숫자는 Code.** 77일차 Lab 02 과제 2 · 오늘 Lab 02 예제 2와 같은 원칙입니다. 요약 글은 실행마다 조금씩 다릅니다 (그래서 테스트는 글 전체가 아니라 `numbers_ok`와 "급한 곳의 낱말 하나"를 봅니다).
|
||||||
|
|
||||||
|
**기대 결과.** 고치기 전 0/2 (`summary` · `## 요약` 없음), 고친 뒤 2/2, `lab03` 4/4 그대로. 브리핑이 0.1초 → 1~3.5초 (LLM 한 번).
|
||||||
|
|
||||||
|
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||||
|
|
||||||
|
요청문에 꼭 들어갈 것: **① 몇 문장 ② 확인 칸 ③ 마크다운 절 ④ 표는 누가**. 아래 빈칸 4개를 채웁니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/ops-brief.json 에 LLM 요약을 더해 줘.
|
||||||
|
Summarize(AI Agent + gpt-4.1-mini, temperature 0): 사실 JSON 과 actions 만 주고 한국어 ____①문장 — 전체 상태 · 가장 급한 곳 · 먼저 할 일. 사실에 없는 숫자 · 원인은 쓰지 않는다.
|
||||||
|
Check summary(Code): 요약의 숫자가 사실 안에 있는지 Lab 02 예제 2 와 같은 방법으로 보고 ____②.
|
||||||
|
markdown 끝에 "## ____③" 과 요약 글. 표 · 먼저 볼 것은 Code 가 만든 그대로 (LLM 이 ____④).
|
||||||
|
기대 결과 cases/lab03-llm.json, cases/lab03.json 도.
|
||||||
|
```
|
||||||
|
|
||||||
|
빈칸 힌트:
|
||||||
|
|
||||||
|
- ① 수간호사의 몇 마디?
|
||||||
|
- ② Lab 02 예제 2와 같은 칸 이름.
|
||||||
|
- ③ 테스트가 찾는 절 제목.
|
||||||
|
- ④ 표와 먼저 볼 것은 LLM이 손대나요?
|
||||||
|
</details>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/ops-brief.json
|
||||||
|
uv run tools/flow.py test cases/lab03-llm.json -v
|
||||||
|
uv run tools/flow.py test cases/lab03.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실행 결과 · LLM 요약
|
||||||
|
|
||||||
|
수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다. 요약 글은 실행마다 조금씩 다릅니다.
|
||||||
|
|
||||||
|
#### 고치기 전 · 예제 1의 브리핑
|
||||||
|
|
||||||
|
실행한 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab03-llm.json -v
|
||||||
|
```
|
||||||
|
|
||||||
|
출력 (결과 줄만):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✘ 요약 세 문장 · 숫자 확인 · 0.1s · numbers_ok = null (expected true); markdown has none of ['## 요약']
|
||||||
|
✘ 요약에 급한 곳 · 0.1s · summary has none of ['DLQ', '릴리스', '카나리', '실행', '오류']
|
||||||
|
Passed 0/2
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나 (고치기 전):** 예제 1의 브리핑에는 `summary` · `numbers_ok` · "## 요약"이 없습니다. 0/2는 예상한 결과.
|
||||||
|
|
||||||
|
#### 고친 뒤
|
||||||
|
|
||||||
|
실행한 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/ops-brief.json
|
||||||
|
uv run tools/flow.py test cases/lab03-llm.json -v
|
||||||
|
uv run tools/flow.py test cases/lab03.json
|
||||||
|
```
|
||||||
|
|
||||||
|
출력 (결과 줄과 두 번의 요약):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ 요약 세 문장 · 숫자 확인 · 3.5s
|
||||||
|
✔ 요약에 급한 곳 · 1.1s
|
||||||
|
Passed 2/2
|
||||||
|
|
||||||
|
요약 1: "전체 시스템 상태는 빨간색으로, 오류율은 5.88%이며 DLQ 크기는 3입니다. 가장 긴급한 문제는 'Search FAQ' 노드에서 발생하는 1000ms 타임아웃 오류로 16건이 보고되었습니다. 우선 이 타임아웃 문제를 해결하는 데 집중해야 합니다."
|
||||||
|
요약 2: "전체 시스템 상태는 빨간색으로, 오류율은 5.88%이며, 응답하지 않은 비율은 4.76%입니다. 가장 긴급한 문제는 'Search FAQ' 노드에서 발생한 16건의 타임아웃 오류입니다. 우선 이 타임아웃 문제를 해결하는 데 집중해야 합니다."
|
||||||
|
|
||||||
|
(lab03) Passed 4/4
|
||||||
|
```
|
||||||
|
|
||||||
|
**고친 뒤 이렇게 나온 까닭**
|
||||||
|
|
||||||
|
| | 예제 1 | 예제 2 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 표 · 먼저 볼 것 | Code | Code (그대로) |
|
||||||
|
| 요약 | 없음 | LLM 세 문장, `numbers_ok: true` |
|
||||||
|
| 시간 | 0.1초 | 1~3.5초 |
|
||||||
|
|
||||||
|
- **같은 브리핑, 다른 요약 글:** 두 번 부른 요약이 다릅니다 (둘째 문장 · 숫자 고름). 둘 다 숫자 확인을 통과했고, 둘 다 "가장 급한 것 = 실행(FAQ timeout)"이라고 했습니다.
|
||||||
|
- **요약은 먼저 볼 것 셋 중 하나만 말했습니다.** DLQ parked · 카나리는 세 문장에 들어가지 못했습니다. 그래서 표 · 먼저 볼 것은 LLM에게 맡기지 않고 Code가 만든 그대로 둡니다 — 요약은 **덧붙이는 말**이지 브리핑 자체가 아닙니다.
|
||||||
|
- `lab03` 4/4 그대로 (표 · 순서는 바뀌지 않음).
|
||||||
|
|
||||||
|
## 예제 3 · 아침이 바뀌면 (6분, 에이전트 없이)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** 사람이 일을 하나씩 처리할 때(parked 둘 처리 → 카나리 끄기 → 예산 올리기) 같은 브리핑을 다시 불러, **빨강 · 먼저 볼 것이 어떻게 바뀌는지** 봅니다. 워크플로우는 고치지 않습니다.
|
||||||
|
|
||||||
|
> **생활 속 예 · 오전 회진 뒤의 인수인계지.** 아침에 "먼저 볼 환자 셋"을 받은 간호사가 한 명씩 처치하면, 점심 인수인계지의 순서가 바뀝니다. 처치가 끝난 환자는 빨강에서 노랑으로, 넷째였던 환자가 셋째로 올라옵니다. 예제 3은 처치할 때마다 인수인계지를 다시 뽑아 보는 실습입니다.
|
||||||
|
|
||||||
|
**상황.** 운영 담당자가 브리핑대로 일합니다. 도우미는 읽기만 하므로, "처리"는 테스트가 사람 대신 합니다(케이스의 `before`: `/dlq/update` · `/releases/helpdesk` · `/budget`). 케이스는 **이어서** 갑니다 (앞 케이스의 처리가 남음). `cases/lab03-after.json`을 예제 2의 브리핑에 돌립니다.
|
||||||
|
|
||||||
|
| 사람이 한 일 (차례로) | 기대 |
|
||||||
|
| --- | --- |
|
||||||
|
| (처음) | 빨강 runs · dlq · release |
|
||||||
|
| parked 둘(#1 · #2) 처리 | DLQ 🟡 `1건 (parked 0)`, 빨강 runs · release, 먼저 볼 것 셋째에 alerts |
|
||||||
|
| 카나리 끄기 | 릴리스 🟢 `1.1.0`, 빨강 runs 하나, 먼저 볼 것 runs · alerts · dlq |
|
||||||
|
| 예산 올리기 (0.08 → 0.2달러) | 비용 🟢, 노랑 alerts · dlq · quality |
|
||||||
|
| 남은 빨강 | 실행 하나 — 고칠 사람이 있는 일 (77일차) |
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** 브리핑은 한 번 보고 끝나는 보고서가 아니라 **할 일 목록**이라는 것. 그리고 "카나리 끄기 · 예산 올리기"는 빨강을 지우지만 **원인을 고친 것은 아닐 수 있다**는 것.
|
||||||
|
|
||||||
|
**적을 곳.** `my-work/OPS.md` 8절 표.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab03-after.json -v
|
||||||
|
uv run tools/flow.py morning # 처리 뒤의 아침 (끝나면 morning --reset)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
**빨강을 지우는 일 · 고치는 일.**
|
||||||
|
|
||||||
|
| 사람이 한 일 | 빨강이 사라진 까닭 | 고친 것인가 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| parked 둘 처리 (재처리 · 닫기) | parked 0 | 그렇다 — 일을 끝냄 (DLQ의 대기 1건은 남음) |
|
||||||
|
| 카나리 끄기 | 카나리가 없으니 비교할 것이 없음 | 위험을 멈춤 — 1.2.0의 다시 묻는 문제는 그대로 (고쳐서 다시 내보내야) |
|
||||||
|
| 예산 올리기 | 비율 0.9201 → 0.368 | 한도를 바꿈 — 쓰는 양은 그대로 (75일차: 1위 사용자 확인이 먼저) |
|
||||||
|
| (남은 실행) | — | FAQ timeout을 고쳐야 (77일차 Lab 03) |
|
||||||
|
|
||||||
|
브리핑의 숫자는 정의대로 맞지만, 숫자가 초록이 된 **까닭**을 함께 말해야 합니다. 77일차 SLO에서 "기준을 낮춰 지킴으로 만들지 않는다"와 같은 이야기입니다.
|
||||||
|
|
||||||
|
**기대 결과.** 5/5. 끝나면 `morning --reset`으로 아침을 되돌립니다.
|
||||||
|
|
||||||
|
### 실행 결과 · 아침이 바뀌면
|
||||||
|
|
||||||
|
수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다 (예제 2의 브리핑, 고치지 않음). 케이스가 이어서 가므로 앞 케이스의 처리가 남습니다.
|
||||||
|
|
||||||
|
실행한 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab03-after.json -v
|
||||||
|
```
|
||||||
|
|
||||||
|
출력 (결과 줄과 케이스마다 `red` · `yellow` · 먼저 볼 것):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ 아침 그대로 · 1.4s
|
||||||
|
✔ parked 둘을 처리하면 → DLQ는 노랑, 먼저 볼 것에 알림 · 1.0s
|
||||||
|
✔ 카나리를 끄면 → 릴리스는 초록 · 1.1s
|
||||||
|
✔ 예산을 올리면 → 비용도 초록 · 1.1s
|
||||||
|
✔ 남은 빨강은 실행 하나 (사람이 고칠 일) · 1.2s
|
||||||
|
Passed 5/5
|
||||||
|
|
||||||
|
아침 그대로 red [runs, dlq, release] · yellow [alerts, quality, cost] → 실행 · DLQ · 릴리스
|
||||||
|
parked 둘 처리 red [runs, release] · yellow [alerts, dlq, quality, cost] → 실행 · 릴리스 · 알림 (표: | DLQ | 🟡 | 1건 (parked 0) |)
|
||||||
|
카나리 끔 red [runs] · yellow [alerts, dlq, quality, cost] → 실행 · 알림 · DLQ (표: | 릴리스 | 🟢 | 1.1.0 |)
|
||||||
|
예산 올림 red [runs] · yellow [alerts, dlq, quality] → 실행 · 알림 · DLQ
|
||||||
|
남은 빨강 overall red · 먼저 볼 것 1번 실행
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나 (사람이 한 일마다)**
|
||||||
|
|
||||||
|
| 사람이 한 일 | 바뀐 것 | 고친 것인가 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| parked #1 · #2 처리 | DLQ 🔴 → 🟡 (대기 1건 남음), 알림이 먼저 볼 것에 | 그렇다 — 일을 끝냄 |
|
||||||
|
| 카나리 끔 | 릴리스 🔴 → 🟢 | 위험을 멈춤 — 1.2.0의 다시 묻는 문제(8건 중 5건)는 그대로 |
|
||||||
|
| 예산 0.08 → 0.2 | 비용 🟡 → 🟢 (0.9201 → 0.368) | 한도를 바꿈 — 쓴 돈은 그대로 |
|
||||||
|
| (아무도 안 함) | 실행 🔴 그대로 | FAQ timeout을 고쳐야 (77일차 Lab 03) |
|
||||||
|
|
||||||
|
- 브리핑은 고치지 않았습니다. 사실이 바뀌니 표 · 순서 · 할 일이 따라 바뀌었습니다 — 브리핑은 **할 일 목록**입니다.
|
||||||
|
- 빨강이 지워진 까닭이 셋 다 다릅니다. 회의에서 "릴리스는 초록입니다"라고만 말하면 1.2.0이 고쳐진 줄 압니다. 숫자와 함께 **무엇을 했는지**를 말합니다.
|
||||||
|
- 요약 글(LLM)은 다섯 번 모두 "가장 급한 것은 FAQ timeout"이라고 했습니다 — 남은 빨강과 같습니다.
|
||||||
|
- 끝나면 `morning --reset`으로 아침을 되돌립니다 (다음 과제의 평가가 처음 상태를 씀).
|
||||||
|
|
||||||
|
## 과제 1 · 골든 질문 평가 (12분)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** 정답을 아는 질문 여덟 개(`requests/ops-golden.json`)로 도우미를 **시험**하는 평가 워크플로우(`my-ops-eval`)를 만듭니다: 영역 · 거절 · 숫자 · 근거가 모두 맞아야 통과, 점수와 틀린 케이스.
|
||||||
|
|
||||||
|
> **생활 속 예 · 신규 간호사 시험 문제.** 새 간호사가 병동에 서기 전에 정답이 정해진 상황 문제를 풉니다: "이 환자에게 진통제 더 줘도 되나요?" — 정답은 "처방 확인". 문제를 바꾸지 않고 해마다 같은 문제로 보면, 교육이 나아졌는지 숫자로 압니다. 과제 1은 도우미의 시험지를 만드는 실습입니다.
|
||||||
|
|
||||||
|
**상황.** 도우미를 팀에 열기 전에, 그리고 고칠 때마다 "전보다 나빠지지 않았나"를 확인해야 합니다 (72 · 74일차 회귀 · 배포 관문). 질문 여덟 개와 기대(영역 · 거절 · 답에 있어야 할 숫자)가 정답지입니다.
|
||||||
|
|
||||||
|
| 정의 (AGENTS.md 3절 "평가") | |
|
||||||
|
| --- | --- |
|
||||||
|
| 케이스마다 | `my-ops-ask`를 부름 |
|
||||||
|
| 통과 | `area` 같음 · `refused` 같음 · `numbers` 중 하나가 답에 · 거절이 아니면 `numbers_ok` — **넷 다** |
|
||||||
|
| 결과 | `total` · `passed` · `score`(4자리) · 항목별 맞은 수 · `failed`(무엇이 틀렸나 · 답) |
|
||||||
|
| 빈 목록 | 400 |
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** LLM 도우미를 **숫자로 시험**하는 법 (LLM 평가 · 골든 셋). 시험지도 틀릴 수 있다는 것 — 그래서 "틀린 기대를 잡아내는지"도 시험합니다.
|
||||||
|
|
||||||
|
**적을 곳.** `my-work/OPS.md` 9절.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab03-challenge.json -v # 고치기 전 (my-ops-eval이 아직 없음)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
W(["cases"]) --> E{"비었나?"} -->|예| X(["400"])
|
||||||
|
E -->|아니오| S["Split cases<br/>아이템마다"] --> A["Ask<br/>HTTP 하나가 아이템마다<br/>my-ops-ask"] --> C["Score (Code)<br/>넷 다 맞으면 통과"] --> O(["total · passed · score · failed"])
|
||||||
|
```
|
||||||
|
|
||||||
|
**넷 다 맞아야.** 영역만 맞고 숫자가 틀리면, 숫자는 맞는데 근거(`numbers_ok`)가 없으면, 통과가 아닙니다. 그리고 거절해야 할 질문(작업 요청)은 **거절하면 통과**입니다 — 평가는 "답을 잘하나"만이 아니라 "하면 안 되는 것을 안 하나"도 봅니다.
|
||||||
|
|
||||||
|
**시험지를 시험한다.** 둘째 케이스는 기대를 일부러 틀리게(`알림` 질문의 영역을 `cost`로) 적었습니다. 평가가 그것을 "실패"로 잡아내야 평가를 믿을 수 있습니다.
|
||||||
|
|
||||||
|
**기대 결과.** 고치기 전 0/3 (404), 고친 뒤 3/3 (골든 8개 중 7~8개 — 점수 0.875~1을 허용).
|
||||||
|
|
||||||
|
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||||
|
|
||||||
|
요청문에 꼭 들어갈 것: **① 빈 목록 ② 통과 조건 ③ 반올림 ④ 틀린 케이스에 남길 것**. 아래 빈칸 4개를 채웁니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/ops-eval.json 을 새로 만들어 줘. 웹훅 POST my-ops-eval, 본문 {cases: [{question, area, refused, numbers}]}, 이름 "79 Ops eval (my)".
|
||||||
|
cases 가 비었으면 HTTP ____①. Split cases(Code)로 케이스를 아이템으로 나누고, HTTP 노드 하나가 아이템마다 my-ops-ask 를 부른다.
|
||||||
|
Score(Code): 케이스마다 area 같음 · refused 같음 · numbers 중 하나가 답에 있음 · 거절이 아니면 numbers_ok — ____② 맞으면 통과.
|
||||||
|
score = 통과 / 전체 (소수 ____③자리), failed 에는 통과 못 한 케이스와 어느 것이 틀렸나 (area_ok · refused_ok · number_ok · grounded · ____④).
|
||||||
|
기대 결과 cases/lab03-challenge.json.
|
||||||
|
```
|
||||||
|
|
||||||
|
빈칸 힌트:
|
||||||
|
|
||||||
|
- ① 테스트의 셋째 케이스.
|
||||||
|
- ② 위 "넷 다 맞아야".
|
||||||
|
- ③ 0.875처럼.
|
||||||
|
- ④ 사람이 "왜 틀렸나"를 보려면 무엇이 있어야 하나요?
|
||||||
|
</details>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/ops-eval.json
|
||||||
|
uv run tools/flow.py test cases/lab03-challenge.json -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실행 결과 · 골든 질문 평가
|
||||||
|
|
||||||
|
수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다. 평가는 `my-ops-ask`를 질문마다 불러 LLM을 씁니다.
|
||||||
|
|
||||||
|
#### 고치기 전 · `my-ops-eval`이 없을 때
|
||||||
|
|
||||||
|
실행한 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab03-challenge.json -v
|
||||||
|
```
|
||||||
|
|
||||||
|
출력 (결과 줄만):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✘ 골든 질문 8개 · 0.0s · status 404 (expected 200); total = null (expected 8); area_ok = null (expected 8); refused_ok = null (expected 8); score = null (expected 0.875..1)
|
||||||
|
✘ 틀린 기대는 잡아냄 · 0.0s · status 404 (expected 200); total = null (expected 1); passed = null (expected 0); failed.0.area = null (expected alerts); failed.0.area_ok = null (expected false)
|
||||||
|
✘ 빈 목록 → 400 · 0.0s · status 404 (expected 400)
|
||||||
|
Passed 0/3
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나 (고치기 전):** 웹훅이 없어 404. 0/3은 예상한 결과.
|
||||||
|
|
||||||
|
#### 고친 뒤
|
||||||
|
|
||||||
|
실행한 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/ops-eval.json
|
||||||
|
uv run tools/flow.py test cases/lab03-challenge.json -v
|
||||||
|
```
|
||||||
|
|
||||||
|
출력 (결과 줄과 응답):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ 골든 질문 8개 · 3.9s
|
||||||
|
✔ 틀린 기대는 잡아냄 · 1.0s
|
||||||
|
✔ 빈 목록 → 400 · 0.0s
|
||||||
|
Passed 3/3
|
||||||
|
|
||||||
|
골든 8 {"total": 8, "passed": 8, "score": 1, "area_ok": 8, "refused_ok": 8, "number_ok": 8, "grounded": 8, "failed": []}
|
||||||
|
틀린 기대 {"total": 1, "passed": 0, "score": 0, "area_ok": 0, "refused_ok": 1, "number_ok": 1, "grounded": 1,
|
||||||
|
"failed": [{"question": "알림이 몇 건 와 있어?", "area": "alerts", "area_ok": false, …,
|
||||||
|
"answer": "알림은 총 6건이 있습니다. 이 중 3건은 높은 심각도 알림입니다."}]}
|
||||||
|
빈 목록 HTTP 400 {"error": "cases must be a non-empty list"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**고친 뒤 이렇게 나온 까닭**
|
||||||
|
|
||||||
|
| | 결과 | 뜻 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 골든 8 | **8/8 · score 1** | 영역 · 거절(작업 요청 하나 포함) · 숫자 · 근거가 모두 맞음 |
|
||||||
|
| 틀린 기대 | 0/1, `area_ok: false`만 틀림 | 도우미는 `alerts`로 맞게 답했고, 시험지의 기대(`cost`)가 틀렸다는 것을 평가가 잡아냄 |
|
||||||
|
| 빈 목록 | 400 | "0개 중 0개 통과 = 100%"로 속지 않음 |
|
||||||
|
|
||||||
|
- **3.9초:** 질문 여덟 개를 차례로 `my-ops-ask`에 보냈습니다 (질문마다 LLM 한두 번). 평가는 도우미보다 느리고 비쌉니다 — 고칠 때마다 돌리되, 운영 중 매 질문마다 돌리지는 않습니다.
|
||||||
|
- **8/8이 "완벽하다"는 뜻은 아닙니다.** Lab 02 과제 1의 "parked 2 · 대기 1을 거꾸로 말한 답"은 숫자가 맞아 이 평가를 통과합니다. 골든 질문을 늘릴 때는 **틀리기 쉬운 질문**(뜻을 뒤집기 쉬운 것)을 더합니다 — `my-work/golden.json`.
|
||||||
|
- 테스트는 점수 0.875~1을 허용합니다 (LLM 답이 한 번 흔들려도 통과하게). 이번에는 1이었습니다.
|
||||||
|
|
||||||
|
## 과제 2 · 평가 관문 (10분)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** 평가 점수를 기준(`min_score`, 기본 0.9)과 비교해 `gate`(`pass` · `fail`)를 내고, 기준 아래면 알림을 남깁니다. 도우미를 고칠 때마다 이 관문을 지나게 합니다.
|
||||||
|
|
||||||
|
> **생활 속 예 · 시험 점수의 합격선.** 신규 간호사 시험에 합격선이 없으면 "몇 점이면 병동에 서도 되나"를 그때그때 정합니다. 합격선을 정해 두고, 떨어지면 교육 담당에게 바로 알립니다. 과제 2는 도우미 시험의 합격선과 알림을 만드는 실습입니다.
|
||||||
|
|
||||||
|
| 정의 (AGENTS.md 3절 "평가") | |
|
||||||
|
| --- | --- |
|
||||||
|
| `min_score` | 본문, 없으면 0.9 |
|
||||||
|
| `gate` | `score ≥ min_score`면 `pass`, 아니면 `fail` (같으면 통과) |
|
||||||
|
| `fail`이면 | `POST /alerts {workflow: "ops-eval", reason: "eval_below", score, min_score, message}` |
|
||||||
|
| 응답 | 평가 결과 그대로 + `min_score` · `gate` |
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** LLM 도우미에도 74일차와 같은 **배포 관문**을 두는 법. 그리고 평가가 남긴 기록(알림)이 **다음 평가의 답을 바꿀 수 있다**는 것.
|
||||||
|
|
||||||
|
**적을 곳.** `my-work/OPS.md` 9절 아래 "평가가 남긴 알림이 다음 평가에 준 영향".
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab03-challenge2.json -v # 고치기 전 (과제 1의 평가)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
S["Score<br/>+ min_score · gate"] --> B{"fail?"}
|
||||||
|
B -->|예| A["Alert eval<br/>eval_below"] --> R["Result again"]
|
||||||
|
B -->|아니오| R --> O(["평가 결과 그대로"])
|
||||||
|
```
|
||||||
|
|
||||||
|
**평가는 도우미가 아니다.** 운영 도우미는 읽기만 하지만, 평가는 도우미를 **시험하는** 워크플로우라 알림을 남겨도 됩니다 (AGENTS.md 4절). 알림은 곁가지 — 응답은 `Result again`이 평가 결과를 다시 꺼냅니다 (78일차와 같음).
|
||||||
|
|
||||||
|
**평가가 남긴 기록이 다음 평가를 바꾼다.** 수업 준비 때 "실패 → 알림" 케이스 **뒤에** "알림이 몇 건?" 케이스를 두었더니, 도우미가 "7건"이라고 답해 "6"을 기대한 평가가 틀렸습니다. 도우미는 맞았습니다 — 평가의 알림이 하나 늘었으니까요. 그래서 케이스 순서를 바꿨습니다. 평가도 **처음 상태**에서, 평가가 바꾸는 것까지 생각해 돌려야 합니다 (78일차 `--repeat`와 같은 이야기).
|
||||||
|
|
||||||
|
**테스트 케이스.**
|
||||||
|
|
||||||
|
| 케이스 | 기대 |
|
||||||
|
| --- | --- |
|
||||||
|
| 기준을 안 주면 0.9 | 알림 질문 하나 → `score` 1 · `pass` · `min_score` 0.9 |
|
||||||
|
| 골든 8 · 기준 0.8 | `pass` · `min_score` 0.8 (점수 0.875~1) |
|
||||||
|
| 틀린 기대 · 기준 0.9 | `score` 0 · `fail` |
|
||||||
|
| 실패하면 알림 한 건 | `/alerts/list {reason: eval_below}` → 1건 · `ops-eval` · `score` 0 |
|
||||||
|
|
||||||
|
**기대 결과.** 고치기 전 0/4, 고친 뒤 4/4, `lab03-challenge` 3/3 그대로.
|
||||||
|
|
||||||
|
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||||
|
|
||||||
|
요청문에 꼭 들어갈 것: **① 기본 기준 ② 경계 ③ 알림 이유 ④ 응답**. 아래 빈칸 4개를 채웁니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/ops-eval.json 에 평가 관문을 더해 줘.
|
||||||
|
본문 min_score (없으면 ____①) 와 비교해 gate = score 가 min_score ____②이면 "pass", 아니면 "fail". 결과에 min_score · gate.
|
||||||
|
fail 이면 POST http://data-api:8800/alerts {workflow: "ops-eval", severity: "medium", reason: "____③", score, min_score, message}.
|
||||||
|
알림을 보내도 응답은 평가 결과 ____④. 앞의 칸은 그대로. 기대 결과 cases/lab03-challenge2.json, cases/lab03-challenge.json 도.
|
||||||
|
```
|
||||||
|
|
||||||
|
빈칸 힌트:
|
||||||
|
|
||||||
|
- ① AGENTS.md 3절 "평가" 마지막 줄.
|
||||||
|
- ② 0.9와 0.9는 통과인가요? (78일차 게이트)
|
||||||
|
- ③ 테스트가 찾는 알림 이유.
|
||||||
|
- ④ 알림 노드 뒤에서 무엇을 응답하나요?
|
||||||
|
</details>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/ops-eval.json
|
||||||
|
uv run tools/flow.py test cases/lab03-challenge2.json -v
|
||||||
|
uv run tools/flow.py test cases/lab03-challenge.json
|
||||||
|
uv run tools/flow.py morning --reset
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실행 결과 · 평가 관문
|
||||||
|
|
||||||
|
수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다.
|
||||||
|
|
||||||
|
#### 고치기 전 · 과제 1의 평가
|
||||||
|
|
||||||
|
실행한 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab03-challenge2.json -v
|
||||||
|
```
|
||||||
|
|
||||||
|
출력 (결과 줄만):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✘ 기준을 안 주면 0.9 · 1.2s · min_score = null (expected 0.9); gate = null (expected pass)
|
||||||
|
✘ 골든 8 · 기준 0.8 → 통과 · 2.7s · gate = null (expected pass); min_score = null (expected 0.8)
|
||||||
|
✘ 틀린 기대 · 기준 0.9 → 실패 · 1.2s · gate = null (expected fail)
|
||||||
|
✘ 실패하면 알림 한 건 · 0.0s · count = 0 (expected 1); alerts.0.workflow = null (expected ops-eval); alerts.0.score = null (expected 0)
|
||||||
|
Passed 0/4
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나 (고치기 전):** 평가 자체는 되지만 `min_score` · `gate` 칸이 없고 알림도 남기지 않습니다. 0/4는 예상한 결과.
|
||||||
|
|
||||||
|
#### 고친 뒤
|
||||||
|
|
||||||
|
실행한 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/ops-eval.json
|
||||||
|
uv run tools/flow.py test cases/lab03-challenge2.json -v
|
||||||
|
uv run tools/flow.py test cases/lab03-challenge.json
|
||||||
|
uv run tools/flow.py morning --reset
|
||||||
|
```
|
||||||
|
|
||||||
|
출력 (결과 줄과 응답 · 알림):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ 기준을 안 주면 0.9 · 3.3s
|
||||||
|
✔ 골든 8 · 기준 0.8 → 통과 · 3.4s
|
||||||
|
✔ 틀린 기대 · 기준 0.9 → 실패 · 1.2s
|
||||||
|
✔ 실패하면 알림 한 건 · 0.0s
|
||||||
|
Passed 4/4
|
||||||
|
|
||||||
|
골든 8 {"total": 8, "passed": 8, "score": 1, …, "min_score": 0.8, "gate": "pass"}
|
||||||
|
틀린 기대 {"total": 1, "passed": 0, "score": 0, …, "min_score": 0.9, "gate": "fail"}
|
||||||
|
알림 {"count": 1, "alerts": [{"workflow": "ops-eval", "node": "Below?", "severity": "medium", "reason": "eval_below",
|
||||||
|
"score": 0, "min_score": 0.9, "message": "ops assistant eval 0 < 0.9", …}]}
|
||||||
|
|
||||||
|
(lab03-challenge) Passed 3/3
|
||||||
|
```
|
||||||
|
|
||||||
|
**고친 뒤 이렇게 나온 까닭**
|
||||||
|
|
||||||
|
| 평가 | score | 기준 | gate | 알림 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 알림 질문 하나 (기준 없음) | 1 | 0.9 (기본) | pass | 없음 |
|
||||||
|
| 골든 8 | 1 | 0.8 | pass | 없음 |
|
||||||
|
| 틀린 기대 하나 | 0 | 0.9 | **fail** | **`eval_below` 1건** |
|
||||||
|
|
||||||
|
- **응답은 평가 결과 그대로**에 `min_score` · `gate` 두 칸만 더해졌습니다 (`lab03-challenge` 3/3 그대로).
|
||||||
|
- **수업 준비 때 겪은 일 — 평가가 남긴 알림이 다음 평가를 바꿨다.** 처음에는 "기준을 안 주면 0.9" 케이스를 **맨 뒤**에 두었습니다. 그러자 도우미가 "알림은 총 **7건**"이라고 답해 "6"을 기대한 평가가 0점이 됐습니다. 도우미가 틀린 것이 아닙니다 — 바로 앞 케이스의 실패가 `eval_below` 알림을 하나 남겨 알림이 7건이 됐습니다 (숫자 확인도 통과: 7은 사실에 있음). 그래서 그 케이스를 맨 앞으로 옮겼습니다.
|
||||||
|
- 평가 · 관문을 CI처럼 돌릴 때는 **처음 상태에서**, 그리고 평가 자신이 바꾸는 것(알림 · 감사 기록)까지 생각해 순서를 정합니다. 78일차 `--repeat`의 `exists_id`와 같은 종류의 함정입니다.
|
||||||
|
|
||||||
|
## 막혔을 때
|
||||||
|
|
||||||
|
| 증상 | 할 일 |
|
||||||
|
| --- | --- |
|
||||||
|
| 브리핑의 상태가 사실과 다름 | 브리핑이 다시 판정하지 말고 `health`를 그대로 |
|
||||||
|
| 먼저 볼 것이 넷 이상 · 순서가 다름 | `red` 다음 `yellow`, 앞 3개 |
|
||||||
|
| 표의 숫자 꼴이 다름 | AGENTS.md 3절 표의 꼴 그대로 (`3건 (parked 2)`) |
|
||||||
|
| 요약에 없는 숫자 | 요약에 줄 사실을 줄이고, `Check summary`로 |
|
||||||
|
| `## 요약`이 표보다 앞 | markdown 끝에 덧붙임 |
|
||||||
|
| 예제 3에서 숫자가 안 바뀜 | 케이스가 이어서 가는지 (앞 케이스의 처리가 남음) |
|
||||||
|
| 평가가 늘 0점 | `Ask`가 아이템마다 부르는지, i번째 답 = i번째 케이스 |
|
||||||
|
| 거절 질문이 실패로 | 거절이면 `grounded`는 true (`numbers_ok`를 보지 않음) |
|
||||||
|
| 알림 질문의 숫자가 6이 아님 | 앞 평가가 알림을 남김 → `morning --reset` |
|
||||||
|
| 계속 안 됨 | `cp reference/lab03-example2-brief.json my-work/ops-brief.json` 후 push (예제 2까지) |
|
||||||
|
|
||||||
|
## 확인
|
||||||
|
|
||||||
|
- [ ] 예제 1: `cases/lab03.json` 4/4 (고치기 전 0/4), `OPS.md` 7절.
|
||||||
|
- [ ] 예제 2: `cases/lab03-llm.json` 2/2, `lab03` 4/4.
|
||||||
|
- [ ] 예제 3: `cases/lab03-after.json` 5/5, `OPS.md` 8절.
|
||||||
|
- [ ] 과제 1: `cases/lab03-challenge.json` 3/3, `OPS.md` 9절.
|
||||||
|
- [ ] 과제 2: `cases/lab03-challenge2.json` 4/4, `lab03-challenge`도 통과.
|
||||||
|
- [ ] `my-work/PROMPTS.md`, 끝나면 `uv run tools/flow.py morning --reset`.
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# 79일차 수강 안내 · 수업 준비부터 마칠 때까지
|
||||||
|
|
||||||
|
- 일자: 2026-11-04 (수) · 79일차
|
||||||
|
- 주제: 운영 데이터 기반 Workflow Ops Assistant (출처 → 사실 · 상태 → 질문 답변 → 브리핑 · 평가)
|
||||||
|
- 실습 폴더: [Last3Mon/79](README.md) · 강의 자료: [Lecture/79](../../Lecture/79/README.md)
|
||||||
|
- 환경: 65일차 n8n 2.42.6 + **OpenAI API 키만**(`gpt-4.1-mini`) + **79일차 판 데이터 API**(운영 아침 상태) + CLI 코딩 에이전트 하나. **Ollama는 쓰지 않습니다.**
|
||||||
|
- 이 문서: 오늘 하루 내가 할 일을 수업 준비부터 마칠 때까지 순서대로 모은 수강 안내입니다. 수업 중에도 띄워 두고 어디까지 왔는지 확인하세요.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
P["전날<br/>준비 · morning"] --> M["아침<br/>켜기 · doctor"] --> L1["1~2교시<br/>Lab 01<br/>사실 · 상태"] --> L2["3~4교시<br/>Lab 02<br/>라우터 · 거절 · LLM 라우터"] --> L3["5~6교시<br/>Lab 03<br/>브리핑 · 평가"] --> V["7~8교시<br/>tops"] --> E["마친 뒤<br/>reset · 단위 프로젝트 준비"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 0. 하루 한눈에
|
||||||
|
|
||||||
|
| 교시 | 시간 | 할 일 | 문서 | 끝났다는 표시 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 전날 | 약 10분 | 자료 받기 · n8n · 79일차 데이터 API · 기준 대시보드 · 기준 챗봇 게시 · morning · doctor | [PREP](../../Lecture/79/00-DAY79-PREP.md) | `ready` |
|
||||||
|
| 1 | 09:00~09:50 | 개요 · 1단계 강의 (운영 사실) | [개요](../../Lecture/79/00-DAY79-OVERVIEW.md) 1~14장, [1단계](../../Lecture/79/01-STAGE1-OPS-FACTS.md) | |
|
||||||
|
| 2 | 10:00~10:50 | Lab 01 실습 5개 (예제 1~3 · 과제 1~2) | [Lab 01](LAB-01-Q-OPS-FACTS.md) | `lab01-challenge` 5/5 · `lab01-challenge2` 3/3 |
|
||||||
|
| 3 | 11:00~11:50 | 2단계 강의 (질문 답변) | 개요 15~21장, [2단계](../../Lecture/79/02-STAGE2-OPS-ASK.md) | |
|
||||||
|
| 4 | 12:00~12:50 | Lab 02 실습 5개 | [Lab 02](LAB-02-Q-OPS-ASK.md) | `lab02-challenge` 5/5 · `lab02-challenge2` 5/5 |
|
||||||
|
| - | 13:00~14:00 | 점심 | | |
|
||||||
|
| 5 | 14:00~14:50 | 3단계 강의 (브리핑과 평가) | 개요 22~28장, [3단계](../../Lecture/79/03-STAGE3-OPS-BRIEF.md) | |
|
||||||
|
| 6 | 15:00~15:50 | Lab 03 실습 5개 | [Lab 03](LAB-03-Q-OPS-BRIEF.md) | `lab03-challenge` 3/3 · `lab03-challenge2` 4/4, `OPS.md` |
|
||||||
|
| 7 | 16:00~16:50 | 바이브 코딩 `tops`: 요청서 → TODO → SPEC → IMP-PLAN → 만들기 → 설치 | [VIBE-REQ-TOPS](VIBE-REQ-TOPS.md) → [VIBE-Q-TOPS](VIBE-Q-TOPS.md) | `Doc/*.md` · R1~R7 · R11~R14 · `pytest` |
|
||||||
|
| 8 | 17:00~17:50 | 바이브 코딩 발표 · **4단계 정리** (오늘의 의미 · 효과 · 회사에서 쓰는 법) · 80일차(단위 프로젝트) 예고 | [4단계 정리](../../Lecture/79/04-STAGE4-WRAPUP.md), [진행](../../Lecture/79/05-VIBE-CODING.md) | 최종 프로젝트 체크리스트 |
|
||||||
|
|
||||||
|
## 1. 전날 준비 (약 10분)
|
||||||
|
|
||||||
|
### 1.1 자료 받기
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/aio-01d4-stu && git pull
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 n8n과 **79일차** 데이터 API 켜기 (Ollama는 끔)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl disable --now ollama 2>/dev/null # WSL에 설치한 Ollama가 있으면 끄고 자동 시작도 끔
|
||||||
|
cd ~/aio-01d4-stu/Last3Mon/65/n8n
|
||||||
|
docker compose rm -sf ollama # Ollama 컨테이너가 있으면 지움 (없으면 그냥 지나감)
|
||||||
|
docker compose start
|
||||||
|
cd ~/aio-01d4-stu/Last3Mon/79/data-api
|
||||||
|
docker compose up -d --force-recreate # 78일차 판을 79일차 판(+ 운영 아침 상태)으로 바꿈
|
||||||
|
curl -s localhost:8800/ops/seed-info
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 상태 보기 · 준비 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/aio-01d4-stu/Last3Mon/79
|
||||||
|
uv run tools/flow.py push reference/baseline-ops-facts.json reference/baseline-ops-ask.json # 팀의 기준 (고치기 전에 비교할 상대)
|
||||||
|
uv run tools/flow.py morning # 알림 6 · DLQ 3 · 예산 · 카나리
|
||||||
|
uv run tools/flow.py doctor # 마지막 줄 ready
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 미리 읽기 (선택, 5분)
|
||||||
|
|
||||||
|
[PREP 3절](../../Lecture/79/00-DAY79-PREP.md): `morning`을 보고 가장 먼저 볼 것 하나를 골라 오기.
|
||||||
|
|
||||||
|
### 준비물
|
||||||
|
|
||||||
|
노트북과 충전기, WSL의 Docker, WSL 창 두 개, 브라우저, 72~78일차 `~/vibe/*` (없어도 됨).
|
||||||
|
|
||||||
|
## 2. 수업 당일 아침 (09:00 전)
|
||||||
|
|
||||||
|
1. 노트북을 껐다 켰다면 1.2를 다시 합니다 (운영 아침 상태로 다시 채워짐).
|
||||||
|
2. 터미널 두 개, 둘 다 `cd ~/aio-01d4-stu/Last3Mon/79`. ① 에이전트, ② 확인.
|
||||||
|
3. 터미널 ②에서 `uv run tools/flow.py doctor` → `ready`.
|
||||||
|
4. `cp templates/OPS-ASSISTANT-TEMPLATE.md my-work/OPS.md`, `my-work/PROMPTS.md`.
|
||||||
|
|
||||||
|
## 3. 실습할 때마다 같은 순서 (오늘은 "정의 → 요청문 → 사실 · 답 → 확인")
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["① 묻고 싶은 것<br/>OPS.md"] --> B["② 정의 · 기준<br/>AGENTS.md 3절"] --> C["③ 요청문 → 에이전트"] --> D["④ 에이전트가<br/>my-work · check"] --> E["⑤ 내가<br/>push · test · call · morning"]
|
||||||
|
E -->|✘| F["⑥ 실패 줄 · 보고서 경로<br/>그대로 붙여 주기"] --> C
|
||||||
|
E -->|✔| G["⑦ OPS.md 한 줄"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- 숫자 · 상태는 Code가, 글은 LLM이, 작업은 사람이. 운영 도우미는 **읽기만** 합니다.
|
||||||
|
- 테스트는 대부분 시작할 때 `ops-reset`으로 운영 아침을 되돌립니다. 이상하면 먼저 `uv run tools/flow.py morning`(지난 실습이 DLQ · 예산 · 카나리를 바꿨나), 그다음 `morning --reset`.
|
||||||
|
- 숫자가 기대와 다르면 거의 언제나 **정의**(기간 · 백분율과 비율 · 반올림 · 무엇을 세나)가 다른 것입니다. `✘` 줄 끝의 `칸 = 실제 (expected 기대)`를 그대로 에이전트에게 붙여 줍니다.
|
||||||
|
- 오늘은 **네 파일**에 칸을 더해 갑니다: `my-work/ops-facts.json` (Lab 01) → `ops-ask.json` (Lab 02) → `ops-brief.json` · `ops-eval.json` (Lab 03). 고칠 때마다 앞의 테스트도 다시 돌립니다 (회귀).
|
||||||
|
- 사실 · 상태 · 거절 · 브리핑의 표는 누구에게나 같고, LLM이 쓴 답 · 요약 글과 시간만 실행마다 다를 수 있습니다.
|
||||||
|
|
||||||
|
## 4. 1~2교시 · 1단계 · 운영 사실
|
||||||
|
|
||||||
|
1교시는 강의, 2교시는 [Lab 01](LAB-01-Q-OPS-FACTS.md) (50분, 실습 5개).
|
||||||
|
|
||||||
|
| 순서 | 할 일 | 파일 · 웹훅 | 확인 (터미널 ②) |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 예제 1 (10분, 에이전트 없이) | 출처 여섯을 `morning`으로 둘러보고 팀의 기준 대시보드와 비교 | `base-ops-facts` | `morning`, `call base-ops-facts requests/week2.json` → `OPS.md` 1절 |
|
||||||
|
| 예제 2 (15분) | 운영 사실 (지난주 실행 · 알림 · DLQ · 품질 · 비용 · 릴리스) | `my-work/ops-facts.json` (새로) · `my-ops-facts` | `test cases/lab01.json --webhook base-ops-facts -v` (전), `push` → `test cases/lab01.json -v` |
|
||||||
|
| 예제 3 (7분, 에이전트 없이) | 기간을 바꾸면: 두 주 · 하루 · 빈 주 · 기간 없음 | (그대로) | `test cases/lab01-period.json -v` → `OPS.md` 2절 |
|
||||||
|
| 과제 1 (10분) | 영역별 상태 (`health` · `red` · `yellow` · `overall`) | `my-work/ops-facts.json` 고치기 | `test cases/lab01-challenge.json -v`, `lab01` |
|
||||||
|
| 과제 2 (8분) | 출처가 멈추면: 200 · `sources_down` · `unknown` | 같은 파일 | `test cases/lab01-challenge2.json -v`, `lab01` · `lab01-challenge` · `lab01-period` |
|
||||||
|
|
||||||
|
(명령 앞에는 모두 `uv run tools/flow.py`가 붙습니다.)
|
||||||
|
|
||||||
|
## 5. 3~4교시 · 2단계 · 질문 답변
|
||||||
|
|
||||||
|
3교시는 강의, 4교시는 [Lab 02](LAB-02-Q-OPS-ASK.md) (50분, 실습 5개).
|
||||||
|
|
||||||
|
| 순서 | 할 일 | 확인 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 예제 1 (12분) | 낱말 라우터 · 그 영역 사실만 · LLM 답, 운영 밖 거절 | `test cases/lab02.json --webhook base-ops-ask -v` (전), `push my-work/ops-ask.json` → `test cases/lab02.json -v` 3/6 |
|
||||||
|
| 예제 2 (10분) | 작업 요청 거절 · 숫자 확인 | `test cases/lab02.json -v` 6/6 |
|
||||||
|
| 예제 3 (6분, 에이전트 없이) | 질문을 바꾸면: 에러 · 장애 · 두 영역 · 꺼 줘 · 배포 상태 · AI 비용 | `test cases/lab02-phrasing.json -v` → `OPS.md` 5절 |
|
||||||
|
| 과제 1 (12분) | LLM 라우터 (낱말이 모를 때만) | `test cases/lab02-challenge.json -v`, `lab02`, `lab02-phrasing` 다시 (4/6이 맞음) |
|
||||||
|
| 과제 2 (10분) | 감사 기록 (질문 · 영역 · 라우터 · 거절 이유) | `test cases/lab02-challenge2.json -v`, `lab02` · `lab02-challenge` → `OPS.md` 6절 |
|
||||||
|
|
||||||
|
## 6. 5~6교시 · 3단계 · 브리핑과 평가
|
||||||
|
|
||||||
|
5교시는 강의, 6교시는 [Lab 03](LAB-03-Q-OPS-BRIEF.md) (50분, 실습 5개).
|
||||||
|
|
||||||
|
| 순서 | 할 일 | 확인 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 예제 1 (12분) | 아침 브리핑 (표 · 먼저 볼 것 셋 · 정해진 할 일) | `test cases/lab03.json -v` (전 404), `push my-work/ops-brief.json` → 4/4, `call my-ops-brief '{}'` |
|
||||||
|
| 예제 2 (8분) | LLM 요약 세 문장 · 숫자 확인 | `test cases/lab03-llm.json -v`, `lab03` |
|
||||||
|
| 예제 3 (6분, 에이전트 없이) | 아침이 바뀌면: parked 처리 · 카나리 끔 · 예산 올림 | `test cases/lab03-after.json -v` → `OPS.md` 8절, 끝나면 `morning --reset` |
|
||||||
|
| 과제 1 (14분) | 골든 질문 평가 `my-ops-eval` | `my-work/ops-eval.json` (새로), `test cases/lab03-challenge.json -v`, `call my-ops-eval requests/ops-golden.json` |
|
||||||
|
| 과제 2 (10분) | 평가 관문 (`min_score` · `gate` · 실패하면 알림) | `test cases/lab03-challenge2.json -v`, `lab03-challenge`, `morning --reset` → `OPS.md` 9절 |
|
||||||
|
|
||||||
|
- 기록: `OPS.md` 전체, `cp templates/TEST-REPORT-TEMPLATE.md my-work/TEST-REPORT.md`.
|
||||||
|
|
||||||
|
## 7. 7~8교시 · 바이브 코딩 `tops`
|
||||||
|
|
||||||
|
[VIBE-REQ-TOPS](VIBE-REQ-TOPS.md)(요청서)를 **먼저** 읽고 [VIBE-Q-TOPS](VIBE-Q-TOPS.md)의 순서대로 갑니다: `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md`(엔진 · TUI 단계) → 단계별 구현 → 확인 → `uv tool install` → 다른 폴더에서 사용. 요구는 14개(R1~R14)이고 50분 안에는 엔진 1 · 2단계 · TUI 1단계 · 설치까지, 나머지(R8 지난 도구, R9 평가 관문, R10 감사)는 `Doc/IMP-PLAN.md`의 다음 단계로 집에서 이어 갑니다. 만드는 것은 오늘 n8n 운영 도우미를 **터미널에서 보는** 도구: 영역 여섯의 색 · 숫자를 n8n이 정한 그대로, 질문 · 거절, 브리핑, TUI. **읽기만** 합니다. 정답본 `VIBE-A-TOPS.md`는 수업이 끝나면 `git pull`로 받습니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/vibe/tops/Doc && cd ~/vibe/tops
|
||||||
|
git init
|
||||||
|
# 에이전트는 Doc/TODO.md를 쓴 뒤에 켭니다: codex (또는 claude, agy)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 시간 | 단계 | 확인 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 16:00~16:09 | 0 준비 · 1 요청서 → `Doc/TODO.md` (에이전트 없이) | 할 일 ①~⑫ · 꼭 / 나중 |
|
||||||
|
| 16:09~16:15 | 2 `Doc/SPEC.md` → VIBE-Q 요구 표(R1~R14)와 비교 · commit | 요구마다 확인 방법 (영역 일곱 줄 · 종료 코드 4 · 거절) |
|
||||||
|
| 16:15~16:20 | 3 `Doc/IMP-PLAN.md` (엔진 1~3단계 · TUI 1단계 · 설치) · commit | 단계마다 체크리스트 |
|
||||||
|
| 16:20~16:44 | 4 구현: 엔진 1단계(R1 · R2 · R3 · R5 · R7 · R13 · R14) → 엔진 2단계(R4 · R6) → TUI 1단계(R11 · R12), 막히면 5 문제 해결 팁 | 단계마다 `pytest` · 직접 실행 · commit |
|
||||||
|
| 16:44~16:50 | 6 확인 · 7 `uv tool install --editable .` · 8 다른 폴더에서 `tops` | `tops status` = n8n 브리핑의 색 |
|
||||||
|
|
||||||
|
- 8교시: 1~2명 발표 (10분) → [4단계 정리](../../Lecture/79/04-STAGE4-WRAPUP.md) (35분: 오늘 한 일의 의미 · 효과 · 산업에서의 응용 · 면접 질문 · 최종 프로젝트 체크리스트) → 80일차(단위 프로젝트) 예고.
|
||||||
|
|
||||||
|
## 8. 마친 뒤
|
||||||
|
|
||||||
|
| 할 일 | 이유 |
|
||||||
|
| --- | --- |
|
||||||
|
| `uv run tools/flow.py morning --reset` | 운영 아침 상태로 |
|
||||||
|
| `PROMPTS.md`, `OPS.md`, `TEST-REPORT.md` 정리 | 단위 프로젝트 재료 (운영 · 평가) |
|
||||||
|
| 64~79일차 `my-work/*`, `~/vibe/*` 지우지 않기 | 80~82일차 단위 프로젝트 |
|
||||||
|
| 내가 가장 잘 만든 워크플로우 하나 고르기 | 80일차 팀 구성 |
|
||||||
|
|
||||||
|
## 9. 막혔을 때 (오늘 공통)
|
||||||
|
|
||||||
|
| 증상 | 할 일 |
|
||||||
|
| --- | --- |
|
||||||
|
| doctor의 operations morning ✘ | 데이터 API가 예전 판. 1.2의 79일차 `--force-recreate` |
|
||||||
|
| 숫자가 테스트와 다름 | `morning --reset` 뒤 다시, 정의는 AGENTS.md 3절 (77일차 정의 그대로) |
|
||||||
|
| 출처 하나가 500이라 사실 전체가 500 | HTTP 노드에 `neverError`, 응답 모양으로 "답했나"를 봄 (Lab 01 과제 2) |
|
||||||
|
| `my-ops-ask`가 `Get facts`에서 404 | `my-ops-facts`를 push했는지 |
|
||||||
|
| 브리핑에 `health`가 없음 | 상태가 있는 사실 (`reference/ops-facts-health.json`) |
|
||||||
|
| 평가 점수가 갑자기 0 | 앞 케이스가 남긴 알림 · 감사 기록 때문인지, `morning --reset` 뒤 다시 (Lab 03 과제 2) |
|
||||||
|
| 평가가 오래 걸림 | 질문마다 LLM 1~2번, timeout 60초 |
|
||||||
|
| 같은 곳을 3번 고쳐도 실패 | 요청을 더 작게, 또는 `reference/`로 이어 가기 |
|
||||||
|
|
||||||
|
## 확인
|
||||||
|
|
||||||
|
- [ ] 전날 79일차 데이터 API, 기준 둘 게시, `morning`, `doctor` → `ready`
|
||||||
|
- [ ] Lab 01: `OPS.md` 1~3절, `lab01` 5/5, `lab01-period` 5/5, `lab01-challenge` 5/5, `lab01-challenge2` 3/3
|
||||||
|
- [ ] Lab 02: `lab02` 6/6, `lab02-phrasing` 6/6 → 과제 1 뒤 4/6, `lab02-challenge` 5/5, `lab02-challenge2` 5/5, `OPS.md` 4~6절
|
||||||
|
- [ ] Lab 03: `lab03` 4/4, `lab03-llm` 2/2, `lab03-after` 5/5, `lab03-challenge` 3/3, `lab03-challenge2` 4/4, `OPS.md` 7~9절 · `TEST-REPORT.md`
|
||||||
|
- [ ] `tops`: `Doc/TODO.md` · `SPEC.md` · `IMP-PLAN.md`, R1~R7 · R11~R14, `pytest`, 단계마다 commit, `uv tool install`
|
||||||
|
- [ ] `morning --reset`, 단위 프로젝트 준비
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# 79일차 실습 · 운영 데이터 기반 Workflow Ops Assistant (바이브 코딩)
|
||||||
|
|
||||||
|
- 일자: 2026-11-04 (79일차)
|
||||||
|
- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영
|
||||||
|
- 단원: AI 워크플로우 운영 및 관리 역량 강화
|
||||||
|
- 강의 자료: [Lecture/79](../../Lecture/79/README.md)
|
||||||
|
- 하루 수강 안내: [LECTURE-79.md](LECTURE-79.md) (수업 준비부터 마칠 때까지 순서대로)
|
||||||
|
- 67~78일차처럼 CLI 코딩 에이전트에게 **내가 쓴 요청문**으로 워크플로우를 만들게 하고, **테스트로 확인**합니다. 오늘은 72~78일차에 쌓은 운영 데이터를 모아 **"지금 괜찮나? 무엇부터 볼까?"에 답하는 운영 도우미**를 만듭니다. 80~82일차 단위 프로젝트의 운영 부분이 됩니다.
|
||||||
|
|
||||||
|
## 오늘의 상황: 수요일 아침 08:30
|
||||||
|
|
||||||
|
운영 담당자가 출근했습니다. 알림은 쌓여 있고, DLQ에 남은 것이 있고, 어제 FAQ 배치가 거절됐고, 예산은 거의 다 썼고, 카나리는 아직 켜져 있습니다. 출처가 여섯 곳입니다. 하나씩 열어 보지 않고 **한 번에 묻고 답을 받고** 싶습니다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph S["출처 (72~78일차)"]
|
||||||
|
L[("실행 기록")]
|
||||||
|
AL[("알림")]
|
||||||
|
DQ[("DLQ")]
|
||||||
|
Q[("품질 기록")]
|
||||||
|
C[("사용량 · 예산")]
|
||||||
|
R[("릴리스")]
|
||||||
|
end
|
||||||
|
S --> F["운영 사실<br/>my-ops-facts<br/>숫자 · 상태는 Code"]
|
||||||
|
F --> A{{"질문 답변<br/>my-ops-ask<br/>라우터 · 거절 · 숫자 확인"}}
|
||||||
|
F --> B["브리핑<br/>my-ops-brief<br/>먼저 볼 것"]
|
||||||
|
A --> E["평가<br/>my-ops-eval<br/>골든 질문"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 일하는 방식 (67~78일차와 같음)
|
||||||
|
|
||||||
|
| 누가 | 무엇을 |
|
||||||
|
| --- | --- |
|
||||||
|
| 나 | 사실 · 상태의 정의, 도우미가 **하면 안 되는 것** 정하기, 골든 질문 쓰기 |
|
||||||
|
| CLI 에이전트 | [AGENTS.md](AGENTS.md) 규칙대로 워크플로우 JSON 작성, `check`로 형식 오류 고치기 |
|
||||||
|
| `tools/flow.py` | check · push · call · test · **morning**(운영 아침 상태 한눈에, `--reset`) |
|
||||||
|
|
||||||
|
> **숫자 · 상태는 Code가, 글은 LLM이, 작업은 사람이.** 운영 도우미는 읽기만 합니다.
|
||||||
|
|
||||||
|
## 실습 문서 읽는 법
|
||||||
|
|
||||||
|
- 실습 문서(`LAB-0x-Q-*.md`, `VIBE-Q-TOPS.md`)에는 **복사해 붙일 요청문이 없습니다.** 막히면 `▶` 를 하나씩 펼칩니다.
|
||||||
|
- 쓴 요청문은 `my-work/PROMPTS.md`에 모아 둡니다.
|
||||||
|
|
||||||
|
## 구성
|
||||||
|
|
||||||
|
```text
|
||||||
|
Last3Mon/79/
|
||||||
|
├── README.md
|
||||||
|
├── LECTURE-79.md # 하루 수강 안내 (수업 준비부터 마칠 때까지)
|
||||||
|
├── AGENTS.md # CLI 에이전트용 작업 규칙 (계약 · 사실 · 상태 기준 · 거절 · 브리핑 · 평가, 3절)
|
||||||
|
├── LAB-01-Q-OPS-FACTS.md # 1단계 · 출처 둘러보기 · 기준 대시보드 → 운영 사실 → 기간 → 영역별 상태 → 출처가 멈추면
|
||||||
|
├── LAB-02-Q-OPS-ASK.md # 2단계 · 라우터 · 답 → 거절 · 숫자 확인 → 질문을 바꾸면 → LLM 라우터 → 감사 기록
|
||||||
|
├── LAB-03-Q-OPS-BRIEF.md # 3단계 · 브리핑 → LLM 요약 → 아침이 바뀌면 → 골든 질문 평가 → 평가 관문
|
||||||
|
├── VIBE-REQ-TOPS.md # 7교시 바이브 코딩 요청서 (가장 먼저)
|
||||||
|
├── VIBE-Q-TOPS.md # 7교시 바이브 코딩 · tops (운영 도우미를 터미널에서, 읽기만 · TUI)
|
||||||
|
├── *-A-*.md · answers/ · tops/ # 강사용 정답본 · 정답 워크플로우와 실행 기록 · 강사가 만든 tops (학생 저장소에는 수업 뒤)
|
||||||
|
├── tools/flow.py
|
||||||
|
├── data-api/ # 79일차 판 데이터 API (78일차 + 운영 아침 상태 · 감사 기록)
|
||||||
|
│ └── data/ops-seed.json # 알림 6 · DLQ 3 · 품질 기록 4 · 격리 1 · 사용량 192 · 예산 · 카나리
|
||||||
|
├── cases/ # lab01 · -period · -challenge · -challenge2 · lab02 · -phrasing · -challenge · -challenge2 · lab03 · -llm · -after · -challenge · -challenge2
|
||||||
|
├── requests/ # week1 · week2 · two-weeks · monday · empty-week · ops-golden(골든 질문 8) · ops-golden-gate
|
||||||
|
├── reference/ # 팀의 기준(baseline-ops-facts · baseline-ops-ask) · 막혔을 때 이어 가는 워크플로우
|
||||||
|
├── templates/ # 운영 도우미 설계표 · 테스트 보고서
|
||||||
|
├── my-work/
|
||||||
|
└── runs/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 준비
|
||||||
|
|
||||||
|
[실습 준비](../../Lecture/79/00-DAY79-PREP.md)를 끝내 주세요. 요약:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start # Ollama는 쓰지 않음 · 처음 한 번 PREP 1절대로 끄기
|
||||||
|
cd ~/aio-01d4-stu/Last3Mon/79/data-api && docker compose up -d --force-recreate # 79일차 판 (운영 아침 상태)
|
||||||
|
cd ~/aio-01d4-stu/Last3Mon/79 && uv run tools/flow.py push reference/baseline-ops-facts.json reference/baseline-ops-ask.json
|
||||||
|
uv run tools/flow.py morning
|
||||||
|
uv run tools/flow.py doctor # 마지막 줄 ready
|
||||||
|
```
|
||||||
|
|
||||||
|
## `flow.py` 명령
|
||||||
|
|
||||||
|
| 명령 | 하는 일 | 수업 준비 때 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `uv run tools/flow.py morning` | 알림 · DLQ · 예산 · 릴리스를 한눈에 | 알림 6 · DLQ 3 (parked 2) · $0.073604 / $0.08 · 카나리 1.2.0 20% |
|
||||||
|
| `uv run tools/flow.py morning --reset` | 운영 아침 상태로 되돌리기 (알림 · DLQ · 예산 · 카나리 · 감사 기록 · 고장 스위치, `-v`는 되돌린 수) | |
|
||||||
|
| `uv run tools/flow.py test cases/lab01.json --webhook base-ops-facts` | 같은 테스트를 팀의 기준에 (고치기 전) | 0/5 |
|
||||||
|
| `uv run tools/flow.py test cases/lab02.json --webhook base-ops-ask` | 같은 테스트를 기준 챗봇에 (고치기 전) | 0/6 |
|
||||||
|
| `uv run tools/flow.py call my-ops-facts '{}'` | 사실 한 번 | 0.1초 안팎 |
|
||||||
|
| `uv run tools/flow.py call my-ops-ask '{"question": "카나리 버전 문제 있어?"}'` | 질문 한 번 | 1~3초 |
|
||||||
|
|
||||||
|
- 오늘은 `flow.py ops --reset`도 `morning --reset`과 같습니다 (DLQ · 알림을 비우지 않고 **운영 아침 상태로** 되돌림). 테스트 파일은 대부분 시작할 때 스스로 되돌립니다.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# 7교시 바이브 코딩 · `tops` 정답본
|
||||||
|
|
||||||
|
> **강사용 정답본** · 학생 저장소에는 수업 뒤에 올립니다. 학생용: [VIBE-REQ-TOPS.md](VIBE-REQ-TOPS.md) → [VIBE-Q-TOPS.md](VIBE-Q-TOPS.md).
|
||||||
|
> 강사가 직접 만든 본: [tops/](tops/README.md) (테스트 27개, `uv tool install --editable .`). 아래 값은 2026-11-01 강사 Mac에서 79일차 정답 워크플로우를 게시한 n8n에 실제로 돌린 값입니다. 68일차부터 바이브 코딩은 CLI 에이전트로 다시 만들어 재지 않습니다 (요청문은 예시).
|
||||||
|
|
||||||
|
## 1. TODO 예 (요청의 말 → 할 일)
|
||||||
|
|
||||||
|
| # | 요청의 말 | 할 일 (내 말) | 꼭 · 나중 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| ① | 영역 여섯 · 색 · 숫자 | `my-ops-facts`의 `health` · 숫자를 영역마다 한 줄 | 꼭 |
|
||||||
|
| ② | 색을 따로 정하지 않음 · 다시 세지 않음 | 기준값을 코드에 쓰지 않음, 숫자는 꼴만 (×100 · 두 자리) | 꼭 |
|
||||||
|
| ③ | 다른 기간 | `--from` · `--to`를 그대로 넘김 | 꼭 |
|
||||||
|
| ④ | 질문 · 영역 · 숫자 확인 · 거절 | `my-ops-ask`의 응답 그대로 보임 | 꼭 |
|
||||||
|
| ⑤ | 읽기만 | 부를 수 있는 주소 목록, 그 밖은 막음 + 테스트 | 꼭 |
|
||||||
|
| ⑥ | 브리핑 | `my-ops-brief`의 `markdown` | 꼭 |
|
||||||
|
| ⑦ | 모른다고 | `null` → "응답 없음" · ⚪ | 꼭 |
|
||||||
|
| ⑧ | 지난 도구를 불러서 | `tlog slo --json` · `tcheck`, 없으면 한 줄 | 나중 |
|
||||||
|
| ⑨ | 평가 · 종료 코드 | `tops eval`, gate fail → 4 | 나중 |
|
||||||
|
| ⑩ | 무엇을 물었나 | `/audit/list` | 나중 |
|
||||||
|
| ⑪ | 화면 · 탭 · 새로고침 | `ui`, 못 읽는 탭은 그 탭만 | 꼭 |
|
||||||
|
| ⑫ | 종료 코드 · JSON | 0 · 1 · 2 · 4, `--json` = n8n 응답 그대로 | 꼭 |
|
||||||
|
| 끝 | n8n 없이 시험 · 꺼져도 안 죽음 | 응답 사본 · 가짜 서버, 못 읽으면 1 | 꼭 |
|
||||||
|
|
||||||
|
## 2. 요청문 (처음 한 번 + 다음 단계 한 줄)
|
||||||
|
|
||||||
|
단계마다 긴 요청문을 쓰지 않습니다. 할 일은 이미 `Doc/SPEC.md` · `Doc/IMP-PLAN.md`에 있으니, 에이전트가 그 파일을 읽게 하면 됩니다.
|
||||||
|
|
||||||
|
### 처음 한 번
|
||||||
|
|
||||||
|
```text
|
||||||
|
Doc/SPEC.md 와 Doc/IMP-PLAN.md 를 읽고 체크 안 된 첫 단계만 만들어 줘. uv 프로젝트, 명령 이름 tops, 코드와 화면 글자는 영어.
|
||||||
|
끝나면 IMP-PLAN 체크를 표시하고 확인 명령을 알려 줘. 다음 단계는 내가 말할 때까지 하지 마.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 그다음 단계마다 (같은 한 줄)
|
||||||
|
|
||||||
|
```text
|
||||||
|
확인했고 commit 했어. 다음 단계.
|
||||||
|
```
|
||||||
|
|
||||||
|
- 그냥 "계속"만 치면 단계 두세 개를 한꺼번에 하거나 체크 · 확인 명령을 빼먹기 쉽습니다. 이 한 줄은 처음 요청의 약속(한 단계만 · 체크 · 확인 명령 · 멈춤)을 이어 가게 합니다.
|
||||||
|
- 대화가 길어지면 새 세션에서 **처음 요청**을 다시 보냅니다. "체크 안 된 첫 단계"부터 이어집니다.
|
||||||
|
- 에이전트가 단계 내용을 잘못 알았다면, 요청문을 길게 쓰기보다 **SPEC · IMP-PLAN을 고치고** 다시 시킵니다.
|
||||||
|
|
||||||
|
### 틀렸을 때만 · 고쳐 달라는 요청문 예
|
||||||
|
|
||||||
|
무엇이 틀렸나(보인 것) · SPEC의 어느 요구인가 · 어디만 고치나 · 무엇으로 확인하나, 넷을 적습니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
tops status --file tests/data/facts-cost-down.json 이 cost 를 "$0 / $0.08 (0%)" 🟢 로 보여 줘. 사실은 cost: null, health.cost: unknown 이야.
|
||||||
|
SPEC R7 대로 null 이면 "source not answering", 색은 health 그대로(⚪)여야 해. view.py 의 numbers 만 고치고, 그 줄을 보는 테스트를 넣어 줘. 다른 파일은 건드리지 마.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 확인 기준과 강사 실측 (11/01, 79일차 정답 워크플로우)
|
||||||
|
|
||||||
|
| 요구 | 기준 | 실측 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| R1 | 영역 여섯 · n8n과 같은 색 · 숫자 | 🔴 runs `error rate 5.88% · p95 2598ms · unanswered 4.76%` · 🟡 alerts `6 (high 3)` · 🔴 DLQ `3 (parked 2)` · 🟡 quality `avg 0.6583 · last 1 · quarantine 1` · 🟡 cost `$0.073604 / $0.08 (92.01%)` · 🔴 release `1.1.0 · canary 1.2.0 20% (repeat 62.5%)` — 브리핑 표(`answers/18`)와 같은 숫자. `health.cost`를 `green`으로 바꾼 사실 → 🟢 |
|
||||||
|
| R2 | 기간 | `--from 2026-10-19 --to 2026-10-23` → 🟡 runs `error rate 0% · p95 2525ms · unanswered 5.5%` |
|
||||||
|
| R3 | 종료 코드 | 빨강 → 4, n8n 주소 없음 · 모르는 옵션 · `eval` 파일 없음 → 2 |
|
||||||
|
| R4 | 질문 | `"DLQ 1번 다시 돌려 줘"` → `[action · keyword] refused (read_only)`, `"장애 났어?"` → `[runs · llm] numbers ✔` "… 357건 중 21건 … 5.88% …", `"카나리 버전 문제 있어?"` → `[release · keyword] numbers ✔` 62.5% · 7.73% |
|
||||||
|
| R5 | 읽기만 | `/dlq/update` · `/ops-reset`을 부르려 하면 `ValueError`, 가짜 서버 요청 0건. TUI 시험 동안 바꾸는 주소 0건 |
|
||||||
|
| R6 | 브리핑 | `# 운영 브리핑 (2026-10-26 ~ 2026-10-30 실행 기록 + 지금 상태)` · `\| DLQ \| 🔴 \| 3건 (parked 2) \|` |
|
||||||
|
| R7 | 모르는 영역 | Lab 01 과제 2의 응답 → `⚪ cost source not answering` · `sources down: cost` |
|
||||||
|
| R8 | 지난 도구 | `✔ tlog slo: breaches error_rate, unanswered_rate (357 runs)` · `✔ tcheck: installed`, 없는 도구 → `not installed (uv tool install)` |
|
||||||
|
| R9 | 평가 관문 | 골든 8 · `--min-score 0.9` → `8/8 · score 1 · gate pass (min 0.9)` · 0, 틀린 기대 하나 → `✘ 알림이 몇 건 와 있어? · area alerts · area_ok` · 4, 빈 목록 → 2 |
|
||||||
|
| R10 | 감사 | Lab 02 과제 2 뒤 → `3 question(s)` (`refused read_only` · `refused out_of_scope` · `answered`). 위 질문 · 평가를 돌린 뒤에는 11줄 (평가의 질문도 기록됨) |
|
||||||
|
| R11 | TUI | `run_test`: 영역 6줄, `?` → 질문 → `refused (read_only)`, 감사 3줄 |
|
||||||
|
| R12 | 못 읽는 탭 | 사실 파일만 주고 n8n 없이 → 영역 6줄 · `sources down: cost`, 질문하기 · 감사 `not available` |
|
||||||
|
| R13 | `--json` · 환경 변수 · 파일 | `status --json` = 사실 사본 그대로, `$TOPS_N8N`, `--file`이 `--n8n`보다 먼저 |
|
||||||
|
| R14 | n8n 없이 시험 · 꺼짐 | `uv run pytest -q` → 27 passed in 8.6s, `--n8n http://localhost:9` → `cannot read … Connection refused` · 1 |
|
||||||
|
| 설치 | `/tmp`에서 | `usage: tops [-h] {status,ask,brief,eval,audit,tools,ui} ...`, `tops status` 0.19초 |
|
||||||
|
|
||||||
|
- 수업 준비 때 찾은 실수 (학생도 겪기 쉬움): ① TUI 시험에서 "tops가 부른 주소는 다섯뿐"이라고 검사했더니, 도구 탭의 `tlog`가 데이터 API의 실행 기록을 읽어 실패 — 읽기만의 확인은 "읽는 주소만"이 아니라 **"바꾸는 주소가 0건"**. ② 보이는 글자의 앞 공백까지 테스트 문자열에 맞춰야 함 (`" 🔴 runs …"`).
|
||||||
|
- 감사 기록이 평가 뒤에 11줄로 늘어난 것은 오늘 Lab 03 과제 2의 "평가가 상태를 바꾼다"와 같은 이야기입니다. 감사 기록에는 사람 질문과 평가 질문이 섞입니다 — 발표 때 짚을 거리 (`kind`나 사용자로 나누면 됨).
|
||||||
|
- 끝나면 `uv run tools/flow.py morning --reset`으로 아침을 되돌립니다.
|
||||||
|
|
||||||
|
## 4. 7교시에 볼 것
|
||||||
|
|
||||||
|
| 볼 것 | 막혔을 때 한마디 |
|
||||||
|
| --- | --- |
|
||||||
|
| 색이 n8n과 다름 | "health 그대로, 기준값은 n8n에만" |
|
||||||
|
| 숫자 꼴 (0.0588%) | "AGENTS.md 3절 브리핑 표의 꼴" |
|
||||||
|
| 출처가 멈춘 날 $0 · 🟢 | "null이면 응답 없음 · ⚪" |
|
||||||
|
| 재처리 · 리셋 기능이 생김 | "읽기만 — 주소 목록과 그 테스트" |
|
||||||
|
| 테스트가 진짜 n8n을 부름 | "가짜 서버로, 진짜는 확인 단계에서만" |
|
||||||
|
| n8n을 끄면 traceback | "cannot read · 종료 코드 1" |
|
||||||
|
| 질문 상자에 `q`가 들어감 | "Enter 뒤 포커스를 표로" |
|
||||||
|
| `eval`이 늘 0 | "gate가 fail이면 4" |
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
# 7교시 바이브 코딩 · `tops` 만들기
|
||||||
|
|
||||||
|
> **먼저 [VIBE-REQ-TOPS.md](VIBE-REQ-TOPS.md)(요청서)를 읽고 `~/vibe/tops/Doc/TODO.md`를 직접 쓴 뒤** 이 문서를 엽니다. 아래 1~8을 차례로 갑니다. 이 문서의 요구 표는 **내 SPEC을 쓴 뒤에** 비교하는 데 쓰고, 에이전트에게 통째로 붙여 넣지 않습니다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
T["1. Doc/TODO.md<br/>할 일 정리"] --> S["2. Doc/SPEC.md<br/>확인할 수 있는 요구"] --> P["3. Doc/IMP-PLAN.md<br/>엔진 → TUI 단계"] --> I["4. 구현<br/>단계마다 요청 · 확인"]
|
||||||
|
I --> H["5. 막히면<br/>문제 해결 팁"] --> I
|
||||||
|
I --> C["6. 결과 확인<br/>요구 표 체크"] --> U["7. uv tool install"] --> O["8. 다른 곳에서 사용"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- 시간: 16:00~16:50 (50분) · 발표와 정리는 8교시 · 50분 안에 못 한 단계는 집에서 `Doc/IMP-PLAN.md`의 다음 단계로 이어 갑니다
|
||||||
|
- 도구: CLI 코딩 에이전트(codex / claude / agy), Python + uv + [Textual](https://textual.textualize.io/), 표준 라이브러리 `json` · `urllib.request` · `subprocess`
|
||||||
|
- 결과물: 오늘 n8n 운영 도우미(`my-ops-facts` · `my-ops-ask` · `my-ops-brief` · `my-ops-eval`)를 **터미널에서 보는** 도구. 영역 여섯의 색 · 숫자를 **n8n이 정한 그대로** 보이고, 질문을 보내 영역 · 거절 · 숫자 확인을 보이고, 브리핑 · 골든 질문 평가 · 감사 기록을 보이고, 지난 도구(`tlog` · `tcheck`)를 부품으로 부르고, TUI로 탭을 나눠 보는 도구. **읽기만** 합니다. `uv tool install`로 설치해 **어느 폴더에서나** `tops`. 오늘 n8n으로 만든 운영 도우미를 **당번의 터미널에서.**
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ tops status --n8n http://localhost:5678; echo $?
|
||||||
|
overall 🔴 red · runs 2026-10-26 ~ 2026-10-30 + state now
|
||||||
|
🔴 runs error rate 5.88% · p95 2598ms · unanswered 4.76%
|
||||||
|
🟡 alerts 6 (high 3)
|
||||||
|
🔴 DLQ 3 (parked 2)
|
||||||
|
🟡 quality avg 0.6583 · last 1 · quarantine 1
|
||||||
|
🟡 cost $0.073604 / $0.08 (92.01%)
|
||||||
|
🔴 release 1.1.0 · canary 1.2.0 20% (repeat 62.5%)
|
||||||
|
4
|
||||||
|
|
||||||
|
$ tops ask "DLQ 1번 다시 돌려 줘"
|
||||||
|
[action · keyword] refused (read_only)
|
||||||
|
운영 도우미는 읽기만 합니다. 재처리 · 삭제 · 배포 같은 작업은 런북에 따라 담당자가 직접 해 주세요.
|
||||||
|
|
||||||
|
$ tops ui
|
||||||
|
┌ tops ──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ overall 🔴 red · runs 2026-10-26 ~ 2026-10-30 + state now │
|
||||||
|
│ [Areas] Brief Ask Audit Tools │
|
||||||
|
│ area color numbers │
|
||||||
|
│ 🔴 runs red error rate 5.88% · p95 2598ms · unanswered 4.76% │
|
||||||
|
│ 🔴 DLQ red 3 (parked 2) │
|
||||||
|
│ [r] refresh · [?] ask · [q] quit │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
> 요청문은 **직접** 씁니다. 단계마다 `▶` 를 펼치면 생각할 거리가 나옵니다. 정답 요청문은 없습니다.
|
||||||
|
>
|
||||||
|
> **테스트에서 진짜 n8n · 데이터 API를 부르지 않습니다.** 정답 실행 기록에 있는 n8n 응답의 사본과, 테스트 안의 작은 가짜 서버로 시험합니다.
|
||||||
|
|
||||||
|
## 시간 (50분)
|
||||||
|
|
||||||
|
| 시간 | 단계 | 결과 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 16:00~16:09 | 0 준비 · 1 `Doc/TODO.md` (요청서 읽고 직접) | 할 일 ①~⑫ · 꼭 / 나중 |
|
||||||
|
| 16:09~16:15 | 2 `Doc/SPEC.md` → 아래 요구 표와 비교 · 고치기 | 요구마다 확인 방법 |
|
||||||
|
| 16:15~16:20 | 3 `Doc/IMP-PLAN.md` (엔진 단계 · TUI 단계) | 단계마다 체크리스트 |
|
||||||
|
| 16:20~16:44 | 4 구현: **엔진 1단계 → 엔진 2단계 → TUI 1단계** (막히면 5) | 단계마다 `pytest` · 직접 실행 · commit |
|
||||||
|
| 16:44~16:50 | 6 확인 · 7 `uv tool install` · 8 다른 폴더에서 | `tops status` = n8n 브리핑의 색 |
|
||||||
|
|
||||||
|
50분 안 목표: **R1 · R2 · R3 · R5 · R7 · R13 · R14(엔진 1단계) + R4 · R6(엔진 2단계) + R11 · R12(TUI 1단계) + 설치**. R8 · R9 · R10은 IMP-PLAN의 다음 단계로 8교시 뒤 · 집에서 이어 갑니다.
|
||||||
|
|
||||||
|
## 0. 준비 (1분)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/vibe/tops/Doc && cd ~/vibe/tops
|
||||||
|
git init
|
||||||
|
ls ~/vibe # tlog tcheck ... 무엇이 있나 (없어도 됨)
|
||||||
|
# 에이전트는 Doc/TODO.md를 쓴 뒤에 켭니다: codex (또는 claude, agy)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1. `Doc/TODO.md` · 만들고 싶은 것과 할 일 정리 (8분, 에이전트 없이)
|
||||||
|
|
||||||
|
[VIBE-REQ-TOPS.md](VIBE-REQ-TOPS.md)의 "할 일" 칸대로 씁니다. 요청의 말 ①~⑫와 끝 부탁을 내 말로, 꼭 / 나중을 표시합니다.
|
||||||
|
|
||||||
|
<details><summary>▶ 만들고 싶은 것 한 문단 · 무엇이 들어가야 하나</summary>
|
||||||
|
|
||||||
|
누가(운영 당번) · 어디서(어느 폴더에서나, 터미널) · 무엇을(오늘 n8n 운영 도우미의 사실 · 답 · 브리핑 · 평가) · 어떤 약속으로(**n8n과 같은 색 · 숫자**, **읽기만**). 이 문단이 SPEC의 첫 줄이 됩니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
`git add Doc/TODO.md && git commit -m "TODO"`
|
||||||
|
|
||||||
|
## 2. `Doc/SPEC.md` · 확인할 수 있는 요구로 (6분)
|
||||||
|
|
||||||
|
TODO의 할 일마다 **확인 방법**을 붙입니다. 에이전트에게 "Doc/TODO.md를 읽고 SPEC.md를 써 줘. 요구마다 확인 방법"이라고 시켜도 됩니다 (내가 읽고 고침).
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# tops SPEC
|
||||||
|
|
||||||
|
## 1. 요구 (확인할 수 있게)
|
||||||
|
| 번호 | 요구 | 요청서의 말 | 확인 방법 | 50분 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| R1 | ... | ① · ② | ... | 꼭 |
|
||||||
|
|
||||||
|
## 2. 숨은 요구
|
||||||
|
## 3. 범위 밖 (하지 않을 것)
|
||||||
|
## 4. 정해 둘 것 (부를 수 있는 주소, 숫자 꼴 = AGENTS.md 3절 브리핑 표, 종료 코드 번호, n8n 주소 넣는 법)
|
||||||
|
```
|
||||||
|
|
||||||
|
<details><summary>▶ 확인 방법이 확인 방법이려면</summary>
|
||||||
|
|
||||||
|
"영역을 보여 준다"는 확인 방법이 아닙니다. "`tops status` → 🔴 runs `error rate 5.88% · p95 2598ms` · 🔴 DLQ `3 (parked 2)` · 🟡 cost `$0.073604 / $0.08 (92.01%)` · 🔴 release `canary 1.2.0 20% (repeat 62.5%)`, 종료 코드 4"가 확인 방법입니다. 오늘 Lab 03의 브리핑 표(`cases/lab03.json`)와 **같은 숫자**여야 합니다 — 같은 사실을 보이니까요.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
쓴 뒤 아래 표와 비교합니다. 빠진 요구, 내가 더 뽑은 요구를 `Doc/SPEC.md` 끝 "VIBE-Q와 비교"에 한 줄씩 적고 SPEC을 고칩니다.
|
||||||
|
|
||||||
|
### 요구 사항 (요청서에서 뽑은 것, 14개)
|
||||||
|
|
||||||
|
| 번호 | 요구 | 요청서의 말 | 확인 방법 | 50분 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| R1 | `tops status`: `my-ops-facts`를 불러 영역 여섯의 **색은 `health` 그대로**, 숫자는 브리핑 표의 꼴(×100 · 두 자리)로, 다시 세지 않음 | ① · ② | 위 일곱 줄. 사실 파일의 `health.cost`를 `green`으로 바꿔 주면 🟢 (tops가 판정하지 않음) | 꼭 |
|
||||||
|
| R2 | `--from` · `--to`를 `my-ops-facts`에 그대로 넘김 | ③ | `--from 2026-10-19 --to 2026-10-23` → 🟡 runs `error rate 0% … unanswered 5.5%` (Lab 01 과제 1의 첫 주) | 꼭 |
|
||||||
|
| R3 | 명령 `status` · `ask` · `brief` · `eval` · `audit` · `tools` · `ui`, 종료 코드 0 됨 · 1 못 읽음 · 2 쓰는 법 · **4 빨강 · 평가 실패** | ⑫ | `status`(🔴) → 4, 모르는 옵션 → 2, n8n 주소 없음 → 2 | 꼭 |
|
||||||
|
| R4 | `tops ask "질문"`: `my-ops-ask`로 보내 `[영역 · 라우터]`, 거절이면 `refused (이유)`, 아니면 `numbers ✔/✘`와 답 | ④ | "DLQ 1번 다시 돌려 줘" → `[action · keyword] refused (read_only)` | 꼭 |
|
||||||
|
| R5 | **읽기만**: 부를 수 있는 주소를 목록으로 정하고 그 밖은 막음 (재처리 · 리셋 · 예산 · 고장 스위치 주소를 부르지 않음) | ⑤ | 테스트: `/dlq/update` · `/ops-reset`을 부르려 하면 오류, 가짜 서버에 그런 요청이 0건 | 꼭 |
|
||||||
|
| R6 | `tops brief`: `my-ops-brief`의 `markdown`을 그대로 | ⑥ | `\| DLQ \| 🔴 \| 3건 (parked 2) \|` 줄이 보임 | 꼭 |
|
||||||
|
| R7 | 출처가 멈춘 영역은 `null` → 숫자 칸 "응답 없음" · 색 `unknown`(⚪) · `sources_down` 줄. **0으로 보이지 않음** | ⑦ | Lab 01 과제 2의 응답(`cost: null`) → `⚪ cost source not answering` | 꼭 |
|
||||||
|
| R8 | `tops tools`: 지난 도구를 **명령으로** 불러(`tlog slo --json` · `tcheck`) 결과 한 줄, 없으면 "설치되지 않음" (복사해 오지 않음) | ⑧ | `tlog slo: breaches error_rate, unanswered_rate (357 runs)`, 없는 도구 → 오류 없이 한 줄 | 나중 |
|
||||||
|
| R9 | `tops eval 파일 [--min-score]`: `my-ops-eval`로 보내 `8/8 · score 1 · gate pass`, 틀린 질문 목록, **gate fail이면 종료 코드 4**, 빈 목록 → 2 | ⑨ · ⑫ | 골든 8 → 0, 틀린 기대 하나 → 4 (`✘ … area_ok`) | 나중 |
|
||||||
|
| R10 | `tops audit`: 데이터 API `POST /audit/list`의 질문 · 영역 · 라우터 · 거절 이유 | ⑩ | Lab 02 과제 2 뒤 → `3 question(s)`, `refused read_only` | 나중 |
|
||||||
|
| R11 | `tops ui`: 위 한 줄(전체 · 기간), 탭(영역 · 브리핑 · 질문하기 · 감사 · 도구), `[r]` 새로고침 · `[?]` 질문하기 · `[q]` 끝 | ⑪ | `run_test`: 영역 6줄, `?` → 질문 입력 → 거절이 보임 | 꼭 |
|
||||||
|
| R12 | 탭 하나가 못 읽어도 다른 탭은 동작 (그 탭에 "not available") | ⑪ · 끝 | 사실을 파일로 주고 n8n 없이 `ui` → 영역 6줄, 질문하기 · 감사는 "not available" | 꼭 |
|
||||||
|
| R13 | `--json`은 n8n 응답을 **바꾸지 않고**, n8n 주소는 `--n8n` 또는 `$TOPS_N8N`, 사실은 `--file`로도 | ⑫ | `status --json`이 사실 사본과 똑같음 | 꼭 |
|
||||||
|
| R14 | 테스트는 n8n · 데이터 API 없이: n8n 응답 사본 + 가짜 서버, n8n이 꺼지면 종료 코드 1 (죽지 않음) | 끝 | `uv run pytest -q`가 10초 안, 꺼진 주소 → `cannot read` · 1 | 꼭 |
|
||||||
|
|
||||||
|
숨은 요구 (요청서에 직접 없지만 없으면 안 되는 것):
|
||||||
|
|
||||||
|
| 숨은 요구 | 어디서 나왔나 |
|
||||||
|
| --- | --- |
|
||||||
|
| **판정은 n8n 한 곳.** tops 코드에 기준값(0.01 · 0.8 · parked > 0 …)이 없음 | ② — 정의가 두 벌이면 77일차처럼 어긋남 |
|
||||||
|
| 읽기만은 "조심"이 아니라 **주소 목록**으로 | ⑤ — 테스트로 확인할 수 있어야 약속 |
|
||||||
|
| `null`(모름)과 0(없었음)을 다르게 보임 | ⑦ · 오늘 Lab 01 예제 3 · 과제 2 |
|
||||||
|
| 읽는 층(`client`) · 보여 주는 층(`view`) · 명령(cli · TUI)을 나눔 | ① · ⑪ — 같은 사실을 글자 · JSON · 화면으로 |
|
||||||
|
| 지난 도구는 **명령으로** 부르고 그 도구의 정의를 씀 | ⑧ — 복사해 오면 정의가 또 한 벌 |
|
||||||
|
| HTTP 400 · 500의 몸(`{"error"}`)도 읽어 사람에게 보임 | ⑨ — 빈 목록 400을 "n8n이 꺼짐"과 구분 |
|
||||||
|
|
||||||
|
범위 밖 (오늘 하지 않음): 작업하기(재처리 · 카나리 끄기 · 예산 바꾸기 — 런북대로 사람이), 숫자를 tops에서 계산하기, LLM을 tops에서 직접 부르기(질문은 `my-ops-ask`가), 알림 보내기.
|
||||||
|
|
||||||
|
`git add Doc/SPEC.md && git commit -m "SPEC"`
|
||||||
|
|
||||||
|
## 3. `Doc/IMP-PLAN.md` · 구현 계획 (5분)
|
||||||
|
|
||||||
|
SPEC의 요구를 **단계**로 나눕니다. 첫 원칙: **엔진(읽고 꼴을 정하는 곳)을 먼저, 화면(TUI)은 나중에.** 화면은 엔진이 만든 줄을 표에 넣기만 하면 되게 만듭니다. 에이전트에게 "SPEC.md를 읽고 IMP-PLAN.md를 엔진 단계와 TUI 단계로 나눠 써 줘. 단계마다 체크리스트와 확인 명령"이라고 시켜도 됩니다 (내가 읽고 고침).
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# tops IMP-PLAN
|
||||||
|
|
||||||
|
## 엔진 1단계 · 읽기 · 상태 · 읽기만 · 종료 코드 (R1 · R2 · R3 · R5 · R7 · R13 · R14)
|
||||||
|
- [ ] client.py: post(base, path, body) — 부를 수 있는 주소 목록 밖이면 오류, facts(n8n, file, from, to)
|
||||||
|
- [ ] view.py: status_lines(facts) — health 그대로, 숫자 꼴은 AGENTS.md 3절 브리핑 표, null은 "응답 없음"
|
||||||
|
- [ ] cli: tops status [--n8n | $TOPS_N8N] [--file] [--from --to] [--json], 종료 코드 0 · 1 · 2 · 4
|
||||||
|
- [ ] tests: 사실 사본 → 일곱 줄, cost null → ⚪, 쓰는 주소는 막힘, 꺼진 n8n → 1, 가짜 서버
|
||||||
|
- 확인: uv run pytest -q, uv run tops status --n8n http://localhost:5678; echo $?
|
||||||
|
|
||||||
|
## 엔진 2단계 · 질문 · 브리핑 (R4 · R6)
|
||||||
|
## TUI 1단계 · 탭 · 새로고침 · 질문하기 · 못 읽는 탭 (R11 · R12)
|
||||||
|
## 설치
|
||||||
|
## 엔진 3단계 · 평가 관문 · 감사 · 지난 도구 (R8 · R9 · R10)
|
||||||
|
```
|
||||||
|
|
||||||
|
<details><summary>▶ 왜 엔진 먼저인가</summary>
|
||||||
|
|
||||||
|
- 엔진은 **화면 없이 테스트**할 수 있습니다 (`pytest`, `tops status`). 색이 n8n과 같은지 · 읽기만인지가 오늘의 핵심인데, 화면부터 만들면 눈으로만 봅니다.
|
||||||
|
- 아침 점검은 화면 없이 스크립트에서 돌아갑니다. 종료 코드(R3)가 다음 일(당번을 부를까)을 정합니다: `tops status || 알림`.
|
||||||
|
- TUI는 엔진이 만든 줄을 표에 넣기만 합니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 단계 크기</summary>
|
||||||
|
|
||||||
|
한 단계 = 에이전트 요청 한두 번 + 확인 한 번 + commit 한 번 (10분 안팎). 오늘 Lab에서 운영 도우미를 한 번에 다 만들지 않고 "사실 → 상태 → 질문 → 거절 → 브리핑 → 평가"로 한 칸씩 더한 것과 같습니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
`git add Doc/IMP-PLAN.md && git commit -m "IMP-PLAN"`
|
||||||
|
|
||||||
|
## 4. 구현 시작 (24분: 엔진 1단계 → 엔진 2단계 → TUI 1단계)
|
||||||
|
|
||||||
|
이제 에이전트를 켭니다. 단계마다 긴 요청문은 필요 없습니다. 할 일은 SPEC · IMP-PLAN에 있으니까요.
|
||||||
|
|
||||||
|
**처음 한 번:**
|
||||||
|
|
||||||
|
```text
|
||||||
|
Doc/SPEC.md 와 Doc/IMP-PLAN.md 를 읽고 체크 안 된 첫 단계만 만들어 줘. uv 프로젝트, 명령 이름 tops, 코드와 화면 글자는 영어.
|
||||||
|
끝나면 IMP-PLAN 체크를 표시하고 확인 명령을 알려 줘. 다음 단계는 내가 말할 때까지 하지 마.
|
||||||
|
```
|
||||||
|
|
||||||
|
**그다음 단계마다 (같은 한 줄):**
|
||||||
|
|
||||||
|
```text
|
||||||
|
확인했고 commit 했어. 다음 단계.
|
||||||
|
```
|
||||||
|
|
||||||
|
→ 내가 확인 → commit → 한 줄. 그냥 "계속"만 치면 여러 단계를 한꺼번에 하거나 체크 · 확인 명령을 빼먹기 쉽습니다. 틀렸을 때만 **무엇이 틀렸는지 · SPEC의 어느 요구인지 · 어디만 고칠지 · 무엇으로 확인할지**를 적어 고쳐 달라고 합니다. 예:
|
||||||
|
|
||||||
|
```text
|
||||||
|
R1이 틀렸어. tops status 가 cost 를 🔴 로 보여 주는데 n8n 브리핑은 🟡 야. view.py 에 used_ratio >= 0.9 이면 red 라는 기준이 들어가 있어.
|
||||||
|
색은 사실의 health 를 그대로 쓰게 view.py 만 고쳐 줘. 기준값은 코드에서 지우고, health.cost 를 green 으로 바꾼 사실을 주면 🟢 인 테스트를 넣어 줘.
|
||||||
|
```
|
||||||
|
|
||||||
|
대화가 길어지면 새 세션에서 처음 요청을 다시 보냅니다.
|
||||||
|
|
||||||
|
> **규모가 큰 프로젝트일수록 다단계로** 진행합니다. `tops`도 요구가 14개라 한 번에 시키면 에이전트가 수백 줄을 한꺼번에 쓰고, 색이 n8n과 다를 때 어디서 다시 판정했는지 찾기 어렵습니다. 단계마다 ① 그 단계만 요청 ② 직접 확인 ③ IMP-PLAN 체크 ④ `git commit`.
|
||||||
|
|
||||||
|
<details><summary>▶ 엔진 1단계 · n8n 응답의 사본은 어디서</summary>
|
||||||
|
|
||||||
|
정답 실행 기록 `~/aio-01d4-stu/Last3Mon/79/answers/`가 수업 뒤에 올라옵니다. 수업 중에는 내 n8n에서 직접 받습니다: `uv run ~/aio-01d4-stu/Last3Mon/79/tools/flow.py call my-ops-facts requests/week2.json > tests/data/facts.json`. Lab 01 과제 2를 했다면 `fault usage error` 때의 응답도 `facts-cost-down.json`으로. 테스트는 이 파일을 읽은 줄을 비교합니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 엔진 1단계 · 읽기만을 어떻게 보장하나</summary>
|
||||||
|
|
||||||
|
HTTP를 부르는 함수를 **하나**만 두고, 그 함수가 주소 목록(`/webhook/my-ops-facts` · `…-ask` · `…-brief` · `…-eval` · `/audit/list`)을 봅니다. 목록 밖이면 보내기 전에 오류. 테스트에서 `/dlq/update`를 부르게 해 보고 가짜 서버에 요청이 0건인지 봅니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 엔진 2단계 · 질문의 거절과 답</summary>
|
||||||
|
|
||||||
|
`my-ops-ask`가 이미 영역 · 거절 · 숫자 확인을 정합니다. tops는 `refused`가 참이면 `reason`을, 아니면 `numbers_ok`를 보입니다. 질문을 tops가 미리 거르지 않습니다 — 거절 규칙도 한 곳(n8n).
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ TUI 1단계 · 못 읽는 탭</summary>
|
||||||
|
|
||||||
|
탭마다 따로 읽고, 못 읽으면 그 탭에만 "not available: 까닭". 한 탭의 오류가 앱 전체를 멈추게 두지 않습니다 (오늘 Lab 01 과제 2의 `neverError`와 같은 생각). 질문 입력(`Input`)은 Enter 뒤 포커스를 표로 돌려야 `r` · `q`가 글자로 들어가지 않습니다 (78일차 `tcheck`에서 겪은 일).
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 다음 단계들 (8교시 뒤 · 집에서)</summary>
|
||||||
|
|
||||||
|
- 평가 관문(R9): `gate`가 `fail`이면 4. 평가는 LLM을 질문마다 부르니 느립니다(수 초) — 타임아웃을 길게.
|
||||||
|
- 감사(R10): 데이터 API의 읽기 주소 하나. 질문 원문에 개인정보가 섞일 수 있으니 화면에만, 파일로 내보내지 않음.
|
||||||
|
- 지난 도구(R8): `subprocess`로 `tlog slo --url … --from 2026-10-26 --to 2026-10-30 --json`. `tlog`의 SLO는 `tlog`의 정의 — tops가 다시 계산하지 않습니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## 5. 바이브 코딩으로 문제 해결 팁
|
||||||
|
|
||||||
|
| 이럴 때 | 이렇게 |
|
||||||
|
| --- | --- |
|
||||||
|
| 에이전트가 "다 됐다"고 함 | **직접 실행**합니다 (`uv run tops status --n8n …; echo $?`, `uv run pytest -q`). 확인 방법은 SPEC에 있습니다 |
|
||||||
|
| 색이 n8n 브리핑과 다름 | "색은 사실의 health 그대로, 기준값을 코드에 쓰지 말 것" — 두 결과를 붙여 줍니다 |
|
||||||
|
| 오류율이 `0.0588%` · `588%` | "AGENTS.md 3절 브리핑 표의 꼴: ×100 · 소수 두 자리" |
|
||||||
|
| 출처가 멈춘 날 비용이 `$0` · 🟢 | "null이면 '응답 없음' · unknown(⚪)" |
|
||||||
|
| tops에 재처리 · 리셋 버튼이 생김 | "읽기만. 부를 수 있는 주소 목록 밖은 막고, 그 테스트를" |
|
||||||
|
| 테스트가 진짜 n8n을 부름 | "테스트 안의 가짜 서버(http.server)로, 진짜 n8n은 확인 단계에서만" |
|
||||||
|
| n8n이 꺼지면 traceback | "URLError를 잡아 'cannot read' · 종료 코드 1" |
|
||||||
|
| `eval` 빈 목록이 "n8n 꺼짐"으로 나옴 | "HTTP 400의 몸 `{"error"}`를 읽어 보이고 종료 코드 2" |
|
||||||
|
| 질문을 치면 `r` · `q`가 글자로 | "Enter 뒤 포커스를 표로" |
|
||||||
|
| 화면이 이상한데 오류가 안 보임 | `uv add --dev textual-dev` 뒤 다른 터미널에서 `uv run textual console` |
|
||||||
|
| 같은 곳을 3번 고쳐도 안 됨 | 멈추고 `git diff` · `git checkout .`로 되돌린 뒤, 단계를 **더 작게** 나눠 다시 요청 |
|
||||||
|
| 설치한 `tops`가 고친 내용을 모름 | `uv tool install --force .` 로 다시 설치 (또는 처음부터 `--editable`) |
|
||||||
|
|
||||||
|
## 6. 결과 확인
|
||||||
|
|
||||||
|
`Doc/SPEC.md` 1절 표의 확인 방법대로 **내가** 하나씩 하고 체크합니다. 오늘 50분 안 목표만:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/vibe/tops
|
||||||
|
uv run pytest -q # R14: 10초 안
|
||||||
|
export TOPS_N8N=http://localhost:5678
|
||||||
|
uv run tops status; echo "exit $?" # R1 · R3: 일곱 줄 · 4
|
||||||
|
uv run tops status --from 2026-10-19 --to 2026-10-23 # R2: runs 🟡
|
||||||
|
uv run tops status --json > /tmp/facts.json # R13
|
||||||
|
uv run tops status --file tests/data/facts-cost-down.json # R7: ⚪ cost
|
||||||
|
uv run tops ask "DLQ 1번 다시 돌려 줘" # R4 · R5: refused (read_only)
|
||||||
|
uv run tops ask "카나리 버전 문제 있어?" # R4: release · numbers ✔
|
||||||
|
uv run tops brief # R6
|
||||||
|
uv run tops status --n8n http://localhost:9; echo "exit $?" # R14: cannot read · 1
|
||||||
|
uv run tops ui # R11 · R12
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] R1 영역 여섯 · n8n과 같은 색 · 숫자.
|
||||||
|
- [ ] R2 기간.
|
||||||
|
- [ ] R3 명령 · 종료 코드 0 · 1 · 2 · 4.
|
||||||
|
- [ ] R4 질문 · 영역 · 거절 · 숫자 확인.
|
||||||
|
- [ ] R5 읽기만 (테스트).
|
||||||
|
- [ ] R6 브리핑.
|
||||||
|
- [ ] R7 모르는 영역은 ⚪.
|
||||||
|
- [ ] R11 · R12 TUI · 못 읽는 탭.
|
||||||
|
- [ ] R13 `--json` · `$TOPS_N8N` · `--file`.
|
||||||
|
- [ ] R14 n8n 없이 도는 테스트 · 꺼져도 1.
|
||||||
|
- [ ] (이어서) R8 · R9 · R10.
|
||||||
|
|
||||||
|
## 7. `uv tool install`로 설치
|
||||||
|
|
||||||
|
`pyproject.toml`에 명령이 등록되어 있는지 먼저 봅니다.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[project.scripts]
|
||||||
|
tops = "tops.cli:main"
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/vibe/tops
|
||||||
|
uv tool install --editable . # 고친 내용이 바로 반영됨 (수업 중 권함)
|
||||||
|
uv tool list # tops 가 보이는가
|
||||||
|
which tops # ~/.local/bin/tops
|
||||||
|
# 명령을 못 찾으면: uv tool update-shell 뒤 터미널을 새로 엶
|
||||||
|
```
|
||||||
|
|
||||||
|
- 지우기: `uv tool uninstall tops`.
|
||||||
|
|
||||||
|
## 8. 다른 곳에서 사용
|
||||||
|
|
||||||
|
설치했으면 아침 점검과 내 스크립트에 씁니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~
|
||||||
|
export TOPS_N8N=http://localhost:5678
|
||||||
|
tops status || echo "빨강이 있습니다 — tops brief 를 보세요" # 종료 코드로 아침 점검
|
||||||
|
tops brief > ~/ops-brief-$(date +%m%d).md # 회의에 붙일 브리핑
|
||||||
|
tops eval ~/aio-01d4-stu/Last3Mon/79/requests/ops-golden.json --min-score 0.9 && echo "도우미 그대로 써도 됨" # (R9) 고친 뒤 관문
|
||||||
|
tops audit # (R10) 오늘 무엇을 물었나
|
||||||
|
tops tools # (R8) tlog SLO · tcheck
|
||||||
|
```
|
||||||
|
|
||||||
|
- 80~82일차 단위 프로젝트에서 팀의 운영 화면으로 이어 써도 됩니다. `~/vibe/tops`는 지우지 마세요.
|
||||||
|
|
||||||
|
## 발표 (8교시, 3분)
|
||||||
|
|
||||||
|
| 내용 | |
|
||||||
|
| --- | --- |
|
||||||
|
| TODO → SPEC | 내 SPEC과 VIBE-Q 요구 표가 달랐던 곳 (빠뜨린 요구, 더 뽑은 요구) |
|
||||||
|
| IMP-PLAN | 엔진 · TUI를 어떻게 나눴나, 어디까지 체크했나 |
|
||||||
|
| 요청문 | 처음 요청 · 틀렸을 때 고쳐 달라고 한 요청문 하나 |
|
||||||
|
| 막힌 곳 | 5절의 어느 팁으로 풀었나 (색이 다름 · 숫자 꼴 · 0과 null · 읽기만) |
|
||||||
|
| 시연 | `tops status`와 n8n 브리핑의 색 비교, 작업 요청 거절, `tops ui`의 못 읽는 탭 |
|
||||||
|
|
||||||
|
## 확인
|
||||||
|
|
||||||
|
- [ ] `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md`를 에이전트 전에 (또는 에이전트와 함께 쓰고 내가 고쳐서) 만들고 commit했다.
|
||||||
|
- [ ] IMP-PLAN의 엔진 1 · 2단계와 TUI 1단계를 체크했다 (단계마다 commit).
|
||||||
|
- [ ] R1 · R2 · R3 · R4 · R5 · R6 · R7 · R11 · R12 · R13 · R14 확인, `uv run pytest -q`, n8n과 같은 색.
|
||||||
|
- [ ] `uv tool install`, 다른 폴더에서 `tops`.
|
||||||
|
- [ ] 남은 요구는 IMP-PLAN에 체크 안 된 채로 남겨 두었다 (다음에 이어 감).
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# 7교시 바이브 코딩 · 요청서 `tops` (가장 먼저 읽기)
|
||||||
|
|
||||||
|
- 순서: **이 요청서** → 내 정리 `~/vibe/tops/Doc/TODO.md` (8분) → [VIBE-Q-TOPS.md](VIBE-Q-TOPS.md)의 순서대로 `Doc/SPEC.md` → `Doc/IMP-PLAN.md` → 단계별로 만들기 → 설치 · 다른 곳에서 쓰기 → 수업이 끝나면 `VIBE-A-TOPS.md`(정답본)로 확인
|
||||||
|
- 이 문서는 **만들어 달라는 사람의 말**입니다. 요구 사항 표도, 단계도 없습니다. 그것을 뽑아 내는 것이 첫 일입니다. 72일차 `tbench`부터 78일차 `tcheck`까지와 같은 순서입니다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
REQ["요청서<br/>(사람의 말)"] --> TODO["Doc/TODO.md<br/>만들고 싶은 것 ·<br/>요청의 말 정리"] --> SPEC["Doc/SPEC.md<br/>확인할 수 있는 요구"] --> PLAN["Doc/IMP-PLAN.md<br/>엔진 → TUI 단계"] --> RUN["단계별로 만들기 ·<br/>직접 확인"] --> USE["uv tool install<br/>다른 곳에서 쓰기"]
|
||||||
|
```
|
||||||
|
|
||||||
|
> **생활 속 예 · 회진 도는 의사의 손바닥 현황판.** 오늘 병원(n8n)에 아침 인수인계 시스템을 만들었습니다: 환자 상태표(사실), 물어보면 답하는 간호사실(질문 답변), 아침 브리핑(먼저 볼 것). 그런데 회진을 도는 의사는 간호사실까지 오지 않고 **손바닥 화면**으로 보고 싶어 합니다. 화면이 따로 판정을 내리면 안 됩니다 — 간호사실이 "빨강"이라고 한 환자는 손바닥에서도 빨강이어야 합니다. 그리고 손바닥 화면으로는 **보기만** 하고, 처방은 하지 않습니다. `tops`는 오늘의 운영 도우미를 터미널에서 보는 손바닥 현황판입니다.
|
||||||
|
|
||||||
|
## 왜 요청서부터인가
|
||||||
|
|
||||||
|
요구 사항 표를 통째로 에이전트에게 주고 "이대로 만들어 줘"라고 하면, 무엇이 만들어지든 **내가 판단할 기준이 없습니다.** 실무에서는 요구 사항 표가 오지 않고, 이 요청서 같은 **사람의 말**이 옵니다. 오늘 Lab 01 예제 1에서 `morning`과 기준 대시보드를 먼저 눈으로 비교해 본 것과 같습니다: 무엇이 보여야 하는지 정해야 맞게 보였는지 말할 수 있습니다.
|
||||||
|
|
||||||
|
| 단계 | 누가 | 결과 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 요청 → 할 일 목록 | **나** | `Doc/TODO.md`: 요청의 말 ①~⑫를 내 말로, 만들고 싶은 것 |
|
||||||
|
| 할 일 → 요구 | **나** (에이전트와 다듬어도 됨) | `Doc/SPEC.md`: 요구마다 "됐다 / 안 됐다"를 말할 수 있는 확인 방법 |
|
||||||
|
| 요구 → 단계 | **나** (에이전트와 다듬어도 됨) | `Doc/IMP-PLAN.md`: 엔진 단계와 화면(TUI) 단계로 나눈 순서 |
|
||||||
|
| 단계 → 코드 | 에이전트 | 처음 요청 한 번 + 단계마다 한 줄, `Doc/*.md`를 읽게 함 |
|
||||||
|
| 확인 | **나** | 직접 `uv run tops …`, `pytest`, n8n 브리핑 · `my-ops-facts`와 색 · 숫자 비교, 요구 표에 체크 |
|
||||||
|
|
||||||
|
## 요청 (운영 당번 · 팀장에게서 온 메시지, 그대로)
|
||||||
|
|
||||||
|
> 오늘 n8n으로 운영 사실 · 질문 답변 · 브리핑 · 평가까지 만들었다고 들었어요. 그런데 당번은 아침마다 **터미널부터** 열어요. 브라우저에서 n8n을 열고 웹훅을 부르는 건 번거로워요. 어느 폴더에서나 `tops`라고 치면 오늘 아침 상태가 보이는 도구가 있으면 좋겠어요.
|
||||||
|
>
|
||||||
|
> ① 영역 여섯(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)의 **색과 숫자**를 한눈에요. 오늘 만든 사실(`my-ops-facts`) 그대로요.
|
||||||
|
>
|
||||||
|
> ② 제일 중요한 거요. **색을 도구가 따로 정하면 안 돼요.** n8n 브리핑이 빨강이라는데 터미널이 노랑이면 아무것도 못 믿어요. 숫자도 다시 세지 말고요 (77 · 78일차에 두 벌 정의로 고생했잖아요).
|
||||||
|
>
|
||||||
|
> ③ 지난주 말고 **다른 기간**도 볼 수 있게요. 첫 주를 다시 보고 싶을 때가 있어요.
|
||||||
|
>
|
||||||
|
> ④ 터미널에서 **질문**도 하고 싶어요. 오늘 만든 도우미(`my-ops-ask`)에 그대로 보내서, 무엇으로 답했는지(영역) · 숫자 확인이 됐는지 · 거절했는지 같이 보여 주세요.
|
||||||
|
>
|
||||||
|
> ⑤ 절대로요. 이 도구로 **재처리 · 삭제 · 카나리 끄기 같은 걸 하면 안 돼요.** 읽기만요. 실수로라도 바뀌면 큰일이에요.
|
||||||
|
>
|
||||||
|
> ⑥ 아침 회의에 붙일 **브리핑**(`my-ops-brief`)도 그대로 터미널에서요.
|
||||||
|
>
|
||||||
|
> ⑦ 출처가 하나 멈춘 날 있잖아요. 그날은 **모른다고** 보여 주세요. 0이나 초록으로 보이면 안 돼요.
|
||||||
|
>
|
||||||
|
> ⑧ 지난번에 만든 도구들 있죠 — `tlog` · `tcheck`. **다시 만들지 말고** 있는 걸 불러서 같이 보여 주면 좋겠어요. 없는 사람은 없다고만요.
|
||||||
|
>
|
||||||
|
> ⑨ 도우미를 고칠 때마다 **골든 질문 평가**(`my-ops-eval`)를 터미널에서 돌리고 싶어요. 기준보다 낮으면 스크립트가 알 수 있게 **종료 코드로**요.
|
||||||
|
>
|
||||||
|
> ⑩ 사람들이 도우미에게 **무엇을 물었는지**(감사 기록)도 볼 수 있게요.
|
||||||
|
>
|
||||||
|
> ⑪ **화면에서** 보고 싶어요. 영역 · 브리핑 · 질문하기 · 감사 기록을 탭으로 나누고, 새로고침 키도요.
|
||||||
|
>
|
||||||
|
> ⑫ 스크립트에서 쓸 거라 **종료 코드**가 중요해요. 됐다 · n8n을 못 읽었다 · 쓰는 법이 틀렸다 · 빨강이거나 평가가 떨어졌다를 서로 다른 번호로요. 다른 프로그램이 읽을 수 있게 **JSON**으로도요.
|
||||||
|
>
|
||||||
|
> 아, 시험할 때 **진짜 n8n이 없어도** 돌아가야 해요. n8n이 꺼진 날에도 도구가 죽지 말고 "못 읽었다"고 해 주세요.
|
||||||
|
|
||||||
|
## 함께 받은 것
|
||||||
|
|
||||||
|
| 파일 · 자료 | 메모 |
|
||||||
|
| --- | --- |
|
||||||
|
| 오늘 n8n 웹훅 `http://localhost:5678/webhook/…` | `my-ops-facts` (`{"from", "to"}`) · `my-ops-ask` (`{"question"}`) · `my-ops-brief` · `my-ops-eval` (`{"cases", "min_score"}`) |
|
||||||
|
| 오늘 데이터 API `http://localhost:8800` | `POST /audit/list` (감사 기록). **상태를 바꾸는 주소는 쓰지 않음** (`/dlq/update` · `/ops-reset` · `/budget` · `/admin/fault`) |
|
||||||
|
| `~/aio-01d4-stu/Last3Mon/79/requests/` | `week1.json` · `week2.json` · `ops-golden.json`(골든 질문 8) |
|
||||||
|
| 내 n8n의 응답 | `uv run tools/flow.py call my-ops-facts requests/week2.json` 등으로 받은 사실 · 답 · 브리핑 · 평가 응답 → 파일로 저장해 **테스트용 사본** (수업 뒤에는 정답 실행 기록 `answers/`에도) |
|
||||||
|
| [AGENTS.md](AGENTS.md) 3절 | 사실의 칸 · **영역별 상태 기준** · 브리핑 표의 숫자 꼴 · 거절 이유 (오늘의 규칙서) |
|
||||||
|
| 72~78일차 `~/vibe/*` | `tlog` (`slo --json`) · `tcheck` (있으면) |
|
||||||
|
| Python 표준 라이브러리 | `json`, `urllib.request`, `subprocess`. TUI는 Textual |
|
||||||
|
|
||||||
|
## 할 일 · 요청을 내 할 일 목록으로 (8분, 에이전트 없이)
|
||||||
|
|
||||||
|
`~/vibe/tops/Doc/TODO.md`를 만들고 요청의 말 ①~⑫와 맨 끝 부탁을 **내 말로** 한 줄씩 옮깁니다. 아직 "어떻게"는 쓰지 않습니다. **무엇을 · 왜**만 씁니다. 에이전트에게 맡기지 않고 직접 씁니다.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# tops TODO
|
||||||
|
|
||||||
|
## 만들고 싶은 것 (한 문단)
|
||||||
|
어느 폴더에서나 `tops`로 오늘 n8n 운영 도우미(my-ops-*)의 사실 · 답 · 브리핑 · 평가를 터미널에서 보는, 읽기만 하는 ...
|
||||||
|
|
||||||
|
## 요청의 말 → 할 일
|
||||||
|
| # | 요청의 말 (짧게) | 할 일 (내 말) | 꼭 · 나중 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| ① | 영역 여섯 · 색 · 숫자 | | 꼭 |
|
||||||
|
| ② | 색을 따로 정하지 않음 | | |
|
||||||
|
| ... | | | |
|
||||||
|
| 끝 | n8n 없이 시험 · 꺼져도 안 죽음 | | |
|
||||||
|
|
||||||
|
## 요청에 없지만 걱정되는 것
|
||||||
|
## 오늘 50분 안에 못 할 것 같은 것
|
||||||
|
```
|
||||||
|
|
||||||
|
예 하나만 보여 드립니다.
|
||||||
|
|
||||||
|
| 요청의 말 | TODO에 쓸 할 일 (내 말) | 다음 단계(SPEC)에서 바뀔 모양 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ② "색을 도구가 따로 정하면 안 돼요" | "`my-ops-facts`의 `health` · `overall`을 그대로 쓰고, 숫자는 브리핑 표의 꼴(×100 · 두 자리)로 보이기만. 기준(0.01 · 0.8 …)을 코드에 쓰지 않음" | R1: 사실 파일의 `health.cost`를 `green`으로 바꿔 주면 비용이 92%여도 🟢로 보임 (tops가 다시 판정하지 않는다는 증거) |
|
||||||
|
|
||||||
|
<details><summary>▶ 1단계 · 꼭 할 것과 나중에 할 것</summary>
|
||||||
|
|
||||||
|
요청의 말이 열두 개입니다. 50분 안에 다 만들기 어렵습니다. 당번이 가장 아파하는 것은 "n8n과 같은 색"(믿을 수 있나)과 "읽기만"(실수로 바꾸지 않나)입니다. 그리고 질문 · 브리핑 · 평가 · 화면을 만들려면 먼저 무엇이 있어야 하나요? (n8n을 읽는 층 → 사실을 보여 주기 → 질문 · 브리핑 → 평가 · 감사 → 화면)
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 2단계 · 걱정되는 것 (숨은 요구의 씨앗)</summary>
|
||||||
|
|
||||||
|
- ② "다시 세지 말고": 오류율 · 비율을 tops가 계산하면 77일차처럼 정의가 두 벌이 됩니다. 사실의 값을 **꼴만 바꿔** 보입니다 (0.0588 → 5.88%).
|
||||||
|
- ⑤ "읽기만": "조심해서 안 부른다"가 아니라 **부를 수 있는 주소를 정해 두고** 그 밖은 막습니다. 테스트로 확인할 수 있게.
|
||||||
|
- ⑦ "모른다고": `cost`가 `null`이면 숫자 칸에 0이 아니라 "응답 없음", 색은 `unknown` (오늘 Lab 01 과제 2).
|
||||||
|
- ⑧ "다시 만들지 말고": 지난 도구는 **명령으로** 부르고(`tlog slo --json`), 없으면 "설치되지 않음". 지난 도구의 코드를 복사해 오지 않습니다.
|
||||||
|
- ⑨ · ⑫ "종료 코드": 평가가 떨어진 것(4)과 n8n을 못 읽은 것(1)이 다른 번호여야 스크립트가 "도우미를 고쳐라"와 "n8n을 켜라"를 구분합니다.
|
||||||
|
- ⑪ "탭": 탭 하나가 못 읽어도(브리핑 웹훅이 없음) 다른 탭은 보여야 합니다.
|
||||||
|
- 끝 "n8n 없이 시험": n8n · 데이터 API를 대신할 작은 가짜 서버, 그리고 내 n8n이 낸 응답을 저장한 사본.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## 그다음
|
||||||
|
|
||||||
|
[VIBE-Q-TOPS.md](VIBE-Q-TOPS.md)를 엽니다. 거기서 `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md` → 단계별 구현 → 확인 → `uv tool install` → 다른 곳에서 사용 순서로 갑니다. VIBE-Q의 요구 표는 **내 SPEC을 쓴 뒤에** 비교하는 데 씁니다.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
$ uv run tools/flow.py ops --reset
|
||||||
|
✔ back to the seeded operations morning (alerts · DLQ · quality · usage · budget · release), live logs · added FAQ · tickets emptied, fault switch all ok
|
||||||
|
|
||||||
|
$ uv run tools/flow.py off my-ops-facts my-ops-ask my-ops-brief my-ops-eval
|
||||||
|
✔ unpublished my-ops-facts (id w41f9b14835e9a3c)
|
||||||
|
✔ unpublished my-ops-ask (id w48289056bc6d55d)
|
||||||
|
✔ unpublished my-ops-brief (id w871e57398bd7b32)
|
||||||
|
✔ unpublished my-ops-eval (id w7803df55a337611)
|
||||||
|
✔ n8n restarted. Turn it back on with: uv run tools/flow.py push <the workflow file>
|
||||||
|
|
||||||
|
$ uv run tools/flow.py push reference/baseline-ops-facts.json reference/baseline-ops-ask.json
|
||||||
|
✔ pushed '79 Ops facts (team baseline)' (id wbc17136383891fb)
|
||||||
|
POST http://localhost:5678/webhook/base-ops-facts
|
||||||
|
✔ pushed '79 Ops ask (team baseline)' (id w65a1ecde0ce401b)
|
||||||
|
POST http://localhost:5678/webhook/base-ops-ask
|
||||||
|
|
||||||
|
$ uv run tools/flow.py doctor
|
||||||
|
! n8n install folder /Users/mcchae/day65-n8n
|
||||||
|
→ day 65 SETUP-N8N 5단계 (get.n8n.io 설치). 다른 곳에 설치했다면 Last3Mon/65/n8n/.env 에 N8N_HOME=<폴더>
|
||||||
|
✔ n8n container day65-n8n-n8n-1
|
||||||
|
✔ n8n http://localhost:5678
|
||||||
|
✔ data API http://localhost:8800
|
||||||
|
✔ dead letter queue /dlq · alerts · fault switch dlq (day 76 data API)
|
||||||
|
✔ run logs /logs (666 seeded runs) · FAQ additions /faq-extra (day 77 data API)
|
||||||
|
✔ schemas faq · employee · ticket, quarantine, quality log (day 78 data API)
|
||||||
|
✔ operations morning /ops/seed-info (day 79 data API)
|
||||||
|
✔ n8n → http://data-api:8800
|
||||||
|
✔ credential 'OpenAI (.env)' (openAiCourse0001)
|
||||||
|
✔ node catalog (for check)
|
||||||
|
✔ CLI coding agent: codex, claude, agy
|
||||||
|
ready
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
$ uv run tools/flow.py morning
|
||||||
|
seeded morning 2026-11-04T08:30:00 · {"alerts": 6, "dlq": 3, "quality_log": 4, "quarantine": 1, "usage": 192}
|
||||||
|
alerts: 6 (high 3)
|
||||||
|
[high] helpdesk/Search FAQ · timeout · timeout of 1000ms exceeded
|
||||||
|
[high] helpdesk/Answer · rate_limit · 429 Rate limit reached for requests
|
||||||
|
[high] faq-import/Batch ok? · batch_rejected · B-1 rejected
|
||||||
|
[medium] faq-import/Low score? · low_quality · B-2 quality 0.8
|
||||||
|
[medium] faq-import/Low score? · low_quality · B-3 quality 0.8333
|
||||||
|
[medium] intake/Create ticket · api_error · tickets API 503
|
||||||
|
DLQ: 3 (parked 2)
|
||||||
|
#1 attempts 3 parked True tickets API 503
|
||||||
|
#2 attempts 3 parked True tickets API 503
|
||||||
|
#3 attempts 1 parked False timeout
|
||||||
|
quality log: 4 (scores [0, 0.8, 0.8333, 1]) · quarantine 1
|
||||||
|
usage: 156 requests · 192 calls · $0.073604
|
||||||
|
budget: used $0.073604 of $0.08
|
||||||
|
release: active 1.1.0 · canary {"release": "1.2.0", "percent": 20}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py call base-ops-facts requests/week2.json
|
||||||
|
HTTP 200 · 1.4s
|
||||||
|
{
|
||||||
|
"runs": {
|
||||||
|
"total": 666,
|
||||||
|
"errors": 21,
|
||||||
|
"error_rate": 3.153153153153153
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"count": 6
|
||||||
|
},
|
||||||
|
"dlq": {
|
||||||
|
"size": 3
|
||||||
|
},
|
||||||
|
"cost": {
|
||||||
|
"used_usd": 0.073604,
|
||||||
|
"daily_usd": 0.08
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab01.json",
|
||||||
|
"webhook": "base-ops-facts",
|
||||||
|
"passed": 0,
|
||||||
|
"total": 5,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "지난주 실행 (기본 기간)",
|
||||||
|
"webhook": "base-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"period.from = null (expected 2026-10-26)",
|
||||||
|
"runs.total = 666 (expected 357)",
|
||||||
|
"runs.error_rate = 3.153153153153153 (expected 0.0588)",
|
||||||
|
"runs.p95_ms = null (expected 2598)",
|
||||||
|
"runs.top_failure.node = null (expected Search FAQ)",
|
||||||
|
"runs.top_failure.count = null (expected 16)"
|
||||||
|
],
|
||||||
|
"seconds": 0.4,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"runs": {
|
||||||
|
"total": 666,
|
||||||
|
"errors": 21,
|
||||||
|
"error_rate": 3.153153153153153
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"count": 6
|
||||||
|
},
|
||||||
|
"dlq": {
|
||||||
|
"size": 3
|
||||||
|
},
|
||||||
|
"cost": {
|
||||||
|
"used_usd": 0.073604,
|
||||||
|
"daily_usd": 0.08
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "지금 상태 · 알림 · DLQ · 품질",
|
||||||
|
"webhook": "base-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"alerts.high = null (expected 3)",
|
||||||
|
"alerts.by_reason.low_quality = null (expected 2)",
|
||||||
|
"dlq.parked = null (expected 2)",
|
||||||
|
"quality.avg_score = null (expected 0.6583)",
|
||||||
|
"quality.last_score = null (expected 1)",
|
||||||
|
"quality.quarantine = null (expected 1)"
|
||||||
|
],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"runs": {
|
||||||
|
"total": 666,
|
||||||
|
"errors": 21,
|
||||||
|
"error_rate": 3.153153153153153
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"count": 6
|
||||||
|
},
|
||||||
|
"dlq": {
|
||||||
|
"size": 3
|
||||||
|
},
|
||||||
|
"cost": {
|
||||||
|
"used_usd": 0.073604,
|
||||||
|
"daily_usd": 0.08
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "비용 · 릴리스",
|
||||||
|
"webhook": "base-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"cost.requests = null (expected 156)",
|
||||||
|
"cost.used_ratio = null (expected 0.9201)",
|
||||||
|
"cost.top_user.user = null (expected test-bot)",
|
||||||
|
"cost.top_user.share = null (expected 0.3699)",
|
||||||
|
"release.active = null (expected 1.1.0)",
|
||||||
|
"release.canary_release = null (expected 1.2.0)",
|
||||||
|
"release.canary_repeat_rate = null (expected 0.625)",
|
||||||
|
"release.active_repeat_rate = null (expected 0.0773)"
|
||||||
|
],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"runs": {
|
||||||
|
"total": 666,
|
||||||
|
"errors": 21,
|
||||||
|
"error_rate": 3.153153153153153
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"count": 6
|
||||||
|
},
|
||||||
|
"dlq": {
|
||||||
|
"size": 3
|
||||||
|
},
|
||||||
|
"cost": {
|
||||||
|
"used_usd": 0.073604,
|
||||||
|
"daily_usd": 0.08
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "첫 주 (기간을 바꾸면 실행만 바뀜)",
|
||||||
|
"webhook": "base-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"runs.total = 666 (expected 309)",
|
||||||
|
"runs.errors = 21 (expected 0)"
|
||||||
|
],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"from": "2026-10-19",
|
||||||
|
"to": "2026-10-23"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"runs": {
|
||||||
|
"total": 666,
|
||||||
|
"errors": 21,
|
||||||
|
"error_rate": 3.153153153153153
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"count": 6
|
||||||
|
},
|
||||||
|
"dlq": {
|
||||||
|
"size": 3
|
||||||
|
},
|
||||||
|
"cost": {
|
||||||
|
"used_usd": 0.073604,
|
||||||
|
"daily_usd": 0.08
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "상태가 바뀌면 숫자도 (DLQ #3 처리)",
|
||||||
|
"webhook": "base-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"dlq.waiting = null (expected 0)"
|
||||||
|
],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"runs": {
|
||||||
|
"total": 666,
|
||||||
|
"errors": 21,
|
||||||
|
"error_rate": 3.153153153153153
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"count": 6
|
||||||
|
},
|
||||||
|
"dlq": {
|
||||||
|
"size": 2
|
||||||
|
},
|
||||||
|
"cost": {
|
||||||
|
"used_usd": 0.073604,
|
||||||
|
"daily_usd": 0.08
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
$ uv run tools/flow.py test cases/lab01.json --webhook base-ops-facts -v
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/base-ops-facts · 5 case(s) × 1
|
||||||
|
✘ 지난주 실행 (기본 기간) · 0.4s · period.from = null (expected 2026-10-26); runs.total = 666 (expected 357); runs.error_rate = 3.153153153153153 (expected 0.0588); runs.p95_ms = null (expected 2598); runs.top_failure.node = null (expected Search FAQ); runs.top_failure.count = null (expected 16)
|
||||||
|
→ request POST /webhook/base-ops-facts
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"runs": {
|
||||||
|
"total": 666,
|
||||||
|
"errors": 21,
|
||||||
|
"error_rate": 3.153153153153153
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"count": 6
|
||||||
|
},
|
||||||
|
"dlq": {
|
||||||
|
"size": 3
|
||||||
|
},
|
||||||
|
"cost": {
|
||||||
|
"used_usd": 0.073604,
|
||||||
|
"daily_usd": 0.08
|
||||||
|
}
|
||||||
|
}
|
||||||
|
✘ 지금 상태 · 알림 · DLQ · 품질 · 0.1s · alerts.high = null (expected 3); alerts.by_reason.low_quality = null (expected 2); dlq.parked = null (expected 2); quality.avg_score = null (expected 0.6583); quality.last_score = null (expected 1); quality.quarantine = null (expected 1)
|
||||||
|
→ request POST /webhook/base-ops-facts
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"runs": {
|
||||||
|
"total": 666,
|
||||||
|
"errors": 21,
|
||||||
|
"error_rate": 3.153153153153153
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"count": 6
|
||||||
|
},
|
||||||
|
"dlq": {
|
||||||
|
"size": 3
|
||||||
|
},
|
||||||
|
"cost": {
|
||||||
|
"used_usd": 0.073604,
|
||||||
|
"daily_usd": 0.08
|
||||||
|
}
|
||||||
|
}
|
||||||
|
✘ 비용 · 릴리스 · 0.1s · cost.requests = null (expected 156); cost.used_ratio = null (expected 0.9201); cost.top_user.user = null (expected test-bot); cost.top_user.share = null (expected 0.3699); release.active = null (expected 1.1.0); release.canary_release = null (expected 1.2.0); release.canary_repeat_rate = null (expected 0.625); release.active_repeat_rate = null (expected 0.0773)
|
||||||
|
→ request POST /webhook/base-ops-facts
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"runs": {
|
||||||
|
"total": 666,
|
||||||
|
"errors": 21,
|
||||||
|
"error_rate": 3.153153153153153
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"count": 6
|
||||||
|
},
|
||||||
|
"dlq": {
|
||||||
|
"size": 3
|
||||||
|
},
|
||||||
|
"cost": {
|
||||||
|
"used_usd": 0.073604,
|
||||||
|
"daily_usd": 0.08
|
||||||
|
}
|
||||||
|
}
|
||||||
|
✘ 첫 주 (기간을 바꾸면 실행만 바뀜) · 0.1s · runs.total = 666 (expected 309); runs.errors = 21 (expected 0)
|
||||||
|
→ request POST /webhook/base-ops-facts
|
||||||
|
{
|
||||||
|
"from": "2026-10-19",
|
||||||
|
"to": "2026-10-23"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"runs": {
|
||||||
|
"total": 666,
|
||||||
|
"errors": 21,
|
||||||
|
"error_rate": 3.153153153153153
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"count": 6
|
||||||
|
},
|
||||||
|
"dlq": {
|
||||||
|
"size": 3
|
||||||
|
},
|
||||||
|
"cost": {
|
||||||
|
"used_usd": 0.073604,
|
||||||
|
"daily_usd": 0.08
|
||||||
|
}
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq/update · HTTP 200 · 0.0s · {"removed": true, "size": 2}
|
||||||
|
→ request POST http://localhost:8800/dlq/update
|
||||||
|
{
|
||||||
|
"dlq_id": 3,
|
||||||
|
"remove": true
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"removed": true,
|
||||||
|
"size": 2
|
||||||
|
}
|
||||||
|
✘ 상태가 바뀌면 숫자도 (DLQ #3 처리) · 0.1s · dlq.waiting = null (expected 0)
|
||||||
|
→ request POST /webhook/base-ops-facts
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"runs": {
|
||||||
|
"total": 666,
|
||||||
|
"errors": 21,
|
||||||
|
"error_rate": 3.153153153153153
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"count": 6
|
||||||
|
},
|
||||||
|
"dlq": {
|
||||||
|
"size": 2
|
||||||
|
},
|
||||||
|
"cost": {
|
||||||
|
"used_usd": 0.073604,
|
||||||
|
"daily_usd": 0.08
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Passed 0/5
|
||||||
|
report: runs/lab01-20261101-185111.json
|
||||||
|
✔ after: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab01.json",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"passed": 5,
|
||||||
|
"total": 5,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "지난주 실행 (기본 기간)",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "지금 상태 · 알림 · DLQ · 품질",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.4,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "비용 · 릴리스",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "첫 주 (기간을 바꾸면 실행만 바뀜)",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"from": "2026-10-19",
|
||||||
|
"to": "2026-10-23"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"period": {
|
||||||
|
"from": "2026-10-19",
|
||||||
|
"to": "2026-10-23"
|
||||||
|
},
|
||||||
|
"runs": {
|
||||||
|
"total": 309,
|
||||||
|
"errors": 0,
|
||||||
|
"error_rate": 0,
|
||||||
|
"p95_ms": 2525,
|
||||||
|
"unanswered_rate": 0.055,
|
||||||
|
"top_failure": null
|
||||||
|
},
|
||||||
|
"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": null,
|
||||||
|
"canary_repeat_rate": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "상태가 바뀌면 숫자도 (DLQ #3 처리)",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"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": 2,
|
||||||
|
"parked": 2,
|
||||||
|
"waiting": 0
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
$ uv run tools/flow.py push reference/lab01-example2-facts.json
|
||||||
|
✔ pushed '79 Ops facts (reference)' (id w91aa244110abf96)
|
||||||
|
POST http://localhost:5678/webhook/my-ops-facts
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab01.json -v
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-ops-facts · 5 case(s) × 1
|
||||||
|
✔ 지난주 실행 (기본 기간) · 1.1s
|
||||||
|
→ request POST /webhook/my-ops-facts
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
✔ 지금 상태 · 알림 · DLQ · 품질 · 0.4s
|
||||||
|
→ request POST /webhook/my-ops-facts
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
✔ 비용 · 릴리스 · 0.1s
|
||||||
|
→ request POST /webhook/my-ops-facts
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
✔ 첫 주 (기간을 바꾸면 실행만 바뀜) · 0.1s
|
||||||
|
→ request POST /webhook/my-ops-facts
|
||||||
|
{
|
||||||
|
"from": "2026-10-19",
|
||||||
|
"to": "2026-10-23"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"period": {
|
||||||
|
"from": "2026-10-19",
|
||||||
|
"to": "2026-10-23"
|
||||||
|
},
|
||||||
|
"runs": {
|
||||||
|
"total": 309,
|
||||||
|
"errors": 0,
|
||||||
|
"error_rate": 0,
|
||||||
|
"p95_ms": 2525,
|
||||||
|
"unanswered_rate": 0.055,
|
||||||
|
"top_failure": null
|
||||||
|
},
|
||||||
|
"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": null,
|
||||||
|
"canary_repeat_rate": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq/update · HTTP 200 · 0.0s · {"removed": true, "size": 2}
|
||||||
|
→ request POST http://localhost:8800/dlq/update
|
||||||
|
{
|
||||||
|
"dlq_id": 3,
|
||||||
|
"remove": true
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"removed": true,
|
||||||
|
"size": 2
|
||||||
|
}
|
||||||
|
✔ 상태가 바뀌면 숫자도 (DLQ #3 처리) · 0.1s
|
||||||
|
→ request POST /webhook/my-ops-facts
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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": 2,
|
||||||
|
"parked": 2,
|
||||||
|
"waiting": 0
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Passed 5/5
|
||||||
|
report: runs/lab01-20261101-185125.json
|
||||||
|
✔ after: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py call my-ops-facts requests/week2.json
|
||||||
|
HTTP 200 · 0.1s
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab01-period.json",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"passed": 5,
|
||||||
|
"total": 5,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "두 주 (10/19~10/30)",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"from": "2026-10-19",
|
||||||
|
"to": "2026-10-30"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"period": {
|
||||||
|
"from": "2026-10-19",
|
||||||
|
"to": "2026-10-30"
|
||||||
|
},
|
||||||
|
"runs": {
|
||||||
|
"total": 666,
|
||||||
|
"errors": 21,
|
||||||
|
"error_rate": 0.0315,
|
||||||
|
"p95_ms": 2537,
|
||||||
|
"unanswered_rate": 0.0511,
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "하루 (10/26)",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"from": "2026-10-26",
|
||||||
|
"to": "2026-10-26"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"period": {
|
||||||
|
"from": "2026-10-26",
|
||||||
|
"to": "2026-10-26"
|
||||||
|
},
|
||||||
|
"runs": {
|
||||||
|
"total": 70,
|
||||||
|
"errors": 16,
|
||||||
|
"error_rate": 0.2286,
|
||||||
|
"p95_ms": 2468,
|
||||||
|
"unanswered_rate": 0.0571,
|
||||||
|
"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": null,
|
||||||
|
"canary_repeat_rate": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "빈 기간 (11/02~11/06) · 0건이어도 200",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"from": "2026-11-02",
|
||||||
|
"to": "2026-11-06"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"period": {
|
||||||
|
"from": "2026-11-02",
|
||||||
|
"to": "2026-11-06"
|
||||||
|
},
|
||||||
|
"runs": {
|
||||||
|
"total": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"error_rate": 0,
|
||||||
|
"p95_ms": 0,
|
||||||
|
"unanswered_rate": 0,
|
||||||
|
"top_failure": null
|
||||||
|
},
|
||||||
|
"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": null,
|
||||||
|
"canary_repeat_rate": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "기간을 비우면 지난주 (기본)",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "기간과 상관없는 '지금' 숫자는 그대로",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"from": "2026-11-02",
|
||||||
|
"to": "2026-11-06"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"period": {
|
||||||
|
"from": "2026-11-02",
|
||||||
|
"to": "2026-11-06"
|
||||||
|
},
|
||||||
|
"runs": {
|
||||||
|
"total": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"error_rate": 0,
|
||||||
|
"p95_ms": 0,
|
||||||
|
"unanswered_rate": 0,
|
||||||
|
"top_failure": null
|
||||||
|
},
|
||||||
|
"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": null,
|
||||||
|
"canary_repeat_rate": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
$ uv run tools/flow.py test cases/lab01-period.json -v
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-ops-facts · 5 case(s) × 1
|
||||||
|
✔ 두 주 (10/19~10/30) · 0.1s
|
||||||
|
→ request POST /webhook/my-ops-facts
|
||||||
|
{
|
||||||
|
"from": "2026-10-19",
|
||||||
|
"to": "2026-10-30"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"period": {
|
||||||
|
"from": "2026-10-19",
|
||||||
|
"to": "2026-10-30"
|
||||||
|
},
|
||||||
|
"runs": {
|
||||||
|
"total": 666,
|
||||||
|
"errors": 21,
|
||||||
|
"error_rate": 0.0315,
|
||||||
|
"p95_ms": 2537,
|
||||||
|
"unanswered_rate": 0.0511,
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
✔ 하루 (10/26) · 0.1s
|
||||||
|
→ request POST /webhook/my-ops-facts
|
||||||
|
{
|
||||||
|
"from": "2026-10-26",
|
||||||
|
"to": "2026-10-26"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"period": {
|
||||||
|
"from": "2026-10-26",
|
||||||
|
"to": "2026-10-26"
|
||||||
|
},
|
||||||
|
"runs": {
|
||||||
|
"total": 70,
|
||||||
|
"errors": 16,
|
||||||
|
"error_rate": 0.2286,
|
||||||
|
"p95_ms": 2468,
|
||||||
|
"unanswered_rate": 0.0571,
|
||||||
|
"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": null,
|
||||||
|
"canary_repeat_rate": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
✔ 빈 기간 (11/02~11/06) · 0건이어도 200 · 0.1s
|
||||||
|
→ request POST /webhook/my-ops-facts
|
||||||
|
{
|
||||||
|
"from": "2026-11-02",
|
||||||
|
"to": "2026-11-06"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"period": {
|
||||||
|
"from": "2026-11-02",
|
||||||
|
"to": "2026-11-06"
|
||||||
|
},
|
||||||
|
"runs": {
|
||||||
|
"total": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"error_rate": 0,
|
||||||
|
"p95_ms": 0,
|
||||||
|
"unanswered_rate": 0,
|
||||||
|
"top_failure": null
|
||||||
|
},
|
||||||
|
"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": null,
|
||||||
|
"canary_repeat_rate": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
✔ 기간을 비우면 지난주 (기본) · 0.1s
|
||||||
|
→ request POST /webhook/my-ops-facts
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
✔ 기간과 상관없는 '지금' 숫자는 그대로 · 0.1s
|
||||||
|
→ request POST /webhook/my-ops-facts
|
||||||
|
{
|
||||||
|
"from": "2026-11-02",
|
||||||
|
"to": "2026-11-06"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"period": {
|
||||||
|
"from": "2026-11-02",
|
||||||
|
"to": "2026-11-06"
|
||||||
|
},
|
||||||
|
"runs": {
|
||||||
|
"total": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"error_rate": 0,
|
||||||
|
"p95_ms": 0,
|
||||||
|
"unanswered_rate": 0,
|
||||||
|
"top_failure": null
|
||||||
|
},
|
||||||
|
"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": null,
|
||||||
|
"canary_repeat_rate": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Passed 5/5
|
||||||
|
report: runs/lab01-period-20261101-185126.json
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab01-challenge.json",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"passed": 0,
|
||||||
|
"total": 5,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "지난주 영역별 상태",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"health.runs = null (expected red)",
|
||||||
|
"health.alerts = null (expected yellow)",
|
||||||
|
"health.dlq = null (expected red)",
|
||||||
|
"health.quality = null (expected yellow)",
|
||||||
|
"health.cost = null (expected yellow)",
|
||||||
|
"health.release = null (expected red)",
|
||||||
|
"overall = null (expected red)"
|
||||||
|
],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "빨강 · 노랑 목록 (정해진 순서)",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"red = null (expected [\"runs\", \"dlq\", \"release\"])",
|
||||||
|
"yellow = null (expected [\"alerts\", \"quality\", \"cost\"])"
|
||||||
|
],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "첫 주: 오류는 없지만 답 못 함 · 카나리 비교 불가",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"health.runs = null (expected yellow)",
|
||||||
|
"health.release = null (expected yellow)",
|
||||||
|
"red = null (expected [\"dlq\"])"
|
||||||
|
],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"from": "2026-10-19",
|
||||||
|
"to": "2026-10-23"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"period": {
|
||||||
|
"from": "2026-10-19",
|
||||||
|
"to": "2026-10-23"
|
||||||
|
},
|
||||||
|
"runs": {
|
||||||
|
"total": 309,
|
||||||
|
"errors": 0,
|
||||||
|
"error_rate": 0,
|
||||||
|
"p95_ms": 2525,
|
||||||
|
"unanswered_rate": 0.055,
|
||||||
|
"top_failure": null
|
||||||
|
},
|
||||||
|
"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": null,
|
||||||
|
"canary_repeat_rate": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "예산을 늘리면 비용은 초록",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"health.cost = null (expected green)"
|
||||||
|
],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"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": 1,
|
||||||
|
"used_ratio": 0.0736,
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "parked를 처리하면 DLQ는 노랑",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"health.dlq = null (expected yellow)",
|
||||||
|
"red = null (expected [\"runs\", \"release\"])"
|
||||||
|
],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"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": 1,
|
||||||
|
"parked": 0,
|
||||||
|
"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": 1,
|
||||||
|
"used_ratio": 0.0736,
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
$ uv run tools/flow.py test cases/lab01-challenge.json -v
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-ops-facts · 5 case(s) × 1
|
||||||
|
✘ 지난주 영역별 상태 · 0.1s · health.runs = null (expected red); health.alerts = null (expected yellow); health.dlq = null (expected red); health.quality = null (expected yellow); health.cost = null (expected yellow); health.release = null (expected red); overall = null (expected red)
|
||||||
|
→ request POST /webhook/my-ops-facts
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
✘ 빨강 · 노랑 목록 (정해진 순서) · 0.1s · red = null (expected ["runs", "dlq", "release"]); yellow = null (expected ["alerts", "quality", "cost"])
|
||||||
|
→ request POST /webhook/my-ops-facts
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
✘ 첫 주: 오류는 없지만 답 못 함 · 카나리 비교 불가 · 0.1s · health.runs = null (expected yellow); health.release = null (expected yellow); red = null (expected ["dlq"])
|
||||||
|
→ request POST /webhook/my-ops-facts
|
||||||
|
{
|
||||||
|
"from": "2026-10-19",
|
||||||
|
"to": "2026-10-23"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"period": {
|
||||||
|
"from": "2026-10-19",
|
||||||
|
"to": "2026-10-23"
|
||||||
|
},
|
||||||
|
"runs": {
|
||||||
|
"total": 309,
|
||||||
|
"errors": 0,
|
||||||
|
"error_rate": 0,
|
||||||
|
"p95_ms": 2525,
|
||||||
|
"unanswered_rate": 0.055,
|
||||||
|
"top_failure": null
|
||||||
|
},
|
||||||
|
"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": null,
|
||||||
|
"canary_repeat_rate": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/budget · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0.073604, "remaining_usd": 0.926396,
|
||||||
|
→ request POST http://localhost:8800/budget
|
||||||
|
{
|
||||||
|
"daily_usd": 1
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"daily_usd": 1.0,
|
||||||
|
"user_limit_per_min": 20,
|
||||||
|
"max_question_chars": 500,
|
||||||
|
"used_usd": 0.073604,
|
||||||
|
"remaining_usd": 0.926396,
|
||||||
|
"over": false
|
||||||
|
}
|
||||||
|
✘ 예산을 늘리면 비용은 초록 · 0.1s · health.cost = null (expected green)
|
||||||
|
→ request POST /webhook/my-ops-facts
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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": 1,
|
||||||
|
"used_ratio": 0.0736,
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq/update · HTTP 200 · 0.0s · {"removed": true, "size": 2}
|
||||||
|
→ request POST http://localhost:8800/dlq/update
|
||||||
|
{
|
||||||
|
"dlq_id": 1,
|
||||||
|
"remove": true
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"removed": true,
|
||||||
|
"size": 2
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq/update · HTTP 200 · 0.0s · {"removed": true, "size": 1}
|
||||||
|
→ request POST http://localhost:8800/dlq/update
|
||||||
|
{
|
||||||
|
"dlq_id": 2,
|
||||||
|
"remove": true
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"removed": true,
|
||||||
|
"size": 1
|
||||||
|
}
|
||||||
|
✘ parked를 처리하면 DLQ는 노랑 · 0.1s · health.dlq = null (expected yellow); red = null (expected ["runs", "release"])
|
||||||
|
→ request POST /webhook/my-ops-facts
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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": 1,
|
||||||
|
"parked": 0,
|
||||||
|
"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": 1,
|
||||||
|
"used_ratio": 0.0736,
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Passed 0/5
|
||||||
|
report: runs/lab01-challenge-20261101-185126.json
|
||||||
|
✔ after: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab01-challenge.json",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"passed": 5,
|
||||||
|
"total": 5,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "지난주 영역별 상태",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "빨강 · 노랑 목록 (정해진 순서)",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.3,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "첫 주: 오류는 없지만 답 못 함 · 카나리 비교 불가",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"from": "2026-10-19",
|
||||||
|
"to": "2026-10-23"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"period": {
|
||||||
|
"from": "2026-10-19",
|
||||||
|
"to": "2026-10-23"
|
||||||
|
},
|
||||||
|
"runs": {
|
||||||
|
"total": 309,
|
||||||
|
"errors": 0,
|
||||||
|
"error_rate": 0,
|
||||||
|
"p95_ms": 2525,
|
||||||
|
"unanswered_rate": 0.055,
|
||||||
|
"top_failure": null
|
||||||
|
},
|
||||||
|
"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": null,
|
||||||
|
"canary_repeat_rate": null
|
||||||
|
},
|
||||||
|
"health": {
|
||||||
|
"runs": "yellow",
|
||||||
|
"alerts": "yellow",
|
||||||
|
"dlq": "red",
|
||||||
|
"quality": "yellow",
|
||||||
|
"cost": "yellow",
|
||||||
|
"release": "yellow"
|
||||||
|
},
|
||||||
|
"red": [
|
||||||
|
"dlq"
|
||||||
|
],
|
||||||
|
"yellow": [
|
||||||
|
"runs",
|
||||||
|
"alerts",
|
||||||
|
"quality",
|
||||||
|
"cost",
|
||||||
|
"release"
|
||||||
|
],
|
||||||
|
"overall": "red"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "예산을 늘리면 비용은 초록",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"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": 1,
|
||||||
|
"used_ratio": 0.0736,
|
||||||
|
"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": "green",
|
||||||
|
"release": "red"
|
||||||
|
},
|
||||||
|
"red": [
|
||||||
|
"runs",
|
||||||
|
"dlq",
|
||||||
|
"release"
|
||||||
|
],
|
||||||
|
"yellow": [
|
||||||
|
"alerts",
|
||||||
|
"quality"
|
||||||
|
],
|
||||||
|
"overall": "red"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "parked를 처리하면 DLQ는 노랑",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"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": 1,
|
||||||
|
"parked": 0,
|
||||||
|
"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": 1,
|
||||||
|
"used_ratio": 0.0736,
|
||||||
|
"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": "yellow",
|
||||||
|
"quality": "yellow",
|
||||||
|
"cost": "green",
|
||||||
|
"release": "red"
|
||||||
|
},
|
||||||
|
"red": [
|
||||||
|
"runs",
|
||||||
|
"release"
|
||||||
|
],
|
||||||
|
"yellow": [
|
||||||
|
"alerts",
|
||||||
|
"dlq",
|
||||||
|
"quality"
|
||||||
|
],
|
||||||
|
"overall": "red"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
$ uv run tools/flow.py push answers/79-lab01-challenge-health.json
|
||||||
|
! unpublished '79 Ops facts (reference)' (w91aa244110abf96): it used the same webhook path
|
||||||
|
✔ pushed '79 Ops facts + health (answer)' (id wed38f213f1753a6)
|
||||||
|
POST http://localhost:5678/webhook/my-ops-facts
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab01-challenge.json -v
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.1s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-ops-facts · 5 case(s) × 1
|
||||||
|
✔ 지난주 영역별 상태 · 1.1s
|
||||||
|
→ request POST /webhook/my-ops-facts
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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.3s
|
||||||
|
→ request POST /webhook/my-ops-facts
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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.1s
|
||||||
|
→ request POST /webhook/my-ops-facts
|
||||||
|
{
|
||||||
|
"from": "2026-10-19",
|
||||||
|
"to": "2026-10-23"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"period": {
|
||||||
|
"from": "2026-10-19",
|
||||||
|
"to": "2026-10-23"
|
||||||
|
},
|
||||||
|
"runs": {
|
||||||
|
"total": 309,
|
||||||
|
"errors": 0,
|
||||||
|
"error_rate": 0,
|
||||||
|
"p95_ms": 2525,
|
||||||
|
"unanswered_rate": 0.055,
|
||||||
|
"top_failure": null
|
||||||
|
},
|
||||||
|
"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": null,
|
||||||
|
"canary_repeat_rate": null
|
||||||
|
},
|
||||||
|
"health": {
|
||||||
|
"runs": "yellow",
|
||||||
|
"alerts": "yellow",
|
||||||
|
"dlq": "red",
|
||||||
|
"quality": "yellow",
|
||||||
|
"cost": "yellow",
|
||||||
|
"release": "yellow"
|
||||||
|
},
|
||||||
|
"red": [
|
||||||
|
"dlq"
|
||||||
|
],
|
||||||
|
"yellow": [
|
||||||
|
"runs",
|
||||||
|
"alerts",
|
||||||
|
"quality",
|
||||||
|
"cost",
|
||||||
|
"release"
|
||||||
|
],
|
||||||
|
"overall": "red"
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/budget · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0.073604, "remaining_usd": 0.926396,
|
||||||
|
→ request POST http://localhost:8800/budget
|
||||||
|
{
|
||||||
|
"daily_usd": 1
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"daily_usd": 1.0,
|
||||||
|
"user_limit_per_min": 20,
|
||||||
|
"max_question_chars": 500,
|
||||||
|
"used_usd": 0.073604,
|
||||||
|
"remaining_usd": 0.926396,
|
||||||
|
"over": false
|
||||||
|
}
|
||||||
|
✔ 예산을 늘리면 비용은 초록 · 0.1s
|
||||||
|
→ request POST /webhook/my-ops-facts
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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": 1,
|
||||||
|
"used_ratio": 0.0736,
|
||||||
|
"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": "green",
|
||||||
|
"release": "red"
|
||||||
|
},
|
||||||
|
"red": [
|
||||||
|
"runs",
|
||||||
|
"dlq",
|
||||||
|
"release"
|
||||||
|
],
|
||||||
|
"yellow": [
|
||||||
|
"alerts",
|
||||||
|
"quality"
|
||||||
|
],
|
||||||
|
"overall": "red"
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq/update · HTTP 200 · 0.0s · {"removed": true, "size": 2}
|
||||||
|
→ request POST http://localhost:8800/dlq/update
|
||||||
|
{
|
||||||
|
"dlq_id": 1,
|
||||||
|
"remove": true
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"removed": true,
|
||||||
|
"size": 2
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq/update · HTTP 200 · 0.0s · {"removed": true, "size": 1}
|
||||||
|
→ request POST http://localhost:8800/dlq/update
|
||||||
|
{
|
||||||
|
"dlq_id": 2,
|
||||||
|
"remove": true
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"removed": true,
|
||||||
|
"size": 1
|
||||||
|
}
|
||||||
|
✔ parked를 처리하면 DLQ는 노랑 · 0.1s
|
||||||
|
→ request POST /webhook/my-ops-facts
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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": 1,
|
||||||
|
"parked": 0,
|
||||||
|
"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": 1,
|
||||||
|
"used_ratio": 0.0736,
|
||||||
|
"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": "yellow",
|
||||||
|
"quality": "yellow",
|
||||||
|
"cost": "green",
|
||||||
|
"release": "red"
|
||||||
|
},
|
||||||
|
"red": [
|
||||||
|
"runs",
|
||||||
|
"release"
|
||||||
|
],
|
||||||
|
"yellow": [
|
||||||
|
"alerts",
|
||||||
|
"dlq",
|
||||||
|
"quality"
|
||||||
|
],
|
||||||
|
"overall": "red"
|
||||||
|
}
|
||||||
|
Passed 5/5
|
||||||
|
report: runs/lab01-challenge-20261101-185142.json
|
||||||
|
✔ after: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab01.json
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
POST http://localhost:5678/webhook/my-ops-facts · 5 case(s) × 1
|
||||||
|
✔ 지난주 실행 (기본 기간) · 0.1s
|
||||||
|
✔ 지금 상태 · 알림 · DLQ · 품질 · 0.1s
|
||||||
|
✔ 비용 · 릴리스 · 0.1s
|
||||||
|
✔ 첫 주 (기간을 바꾸면 실행만 바뀜) · 0.1s
|
||||||
|
✔ before: POST http://localhost:8800/dlq/update · HTTP 200 · 0.0s · {"removed": true, "size": 2}
|
||||||
|
✔ 상태가 바뀌면 숫자도 (DLQ #3 처리) · 0.1s
|
||||||
|
Passed 5/5
|
||||||
|
report: runs/lab01-20261101-185143.json
|
||||||
|
✔ after: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab01-challenge2.json",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"passed": 0,
|
||||||
|
"total": 3,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "비용 출처가 멈춰도 200 · 모르는 것은 모른다고",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"status 500 (expected 200)",
|
||||||
|
"sources_down = null (expected [\"cost\"])",
|
||||||
|
"health.cost = null (expected unknown)",
|
||||||
|
"unknown = null (expected [\"cost\"])"
|
||||||
|
],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 500,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "다른 영역은 그대로",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"status 500 (expected 200)",
|
||||||
|
"dlq.size = null (expected 3)",
|
||||||
|
"health.dlq = null (expected red)",
|
||||||
|
"red = null (expected [\"runs\", \"dlq\", \"release\"])",
|
||||||
|
"overall = null (expected red)"
|
||||||
|
],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 500,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "출처가 돌아오면 다시 노랑",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"sources_down = null (expected [])",
|
||||||
|
"unknown = null (expected [])"
|
||||||
|
],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"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,158 @@
|
|||||||
|
$ uv run tools/flow.py test cases/lab01-challenge2.json -v
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "error", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"usage": "error"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "error",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-ops-facts · 3 case(s) × 1
|
||||||
|
✘ 비용 출처가 멈춰도 200 · 모르는 것은 모른다고 · 0.1s · status 500 (expected 200); sources_down = null (expected ["cost"]); health.cost = null (expected unknown); unknown = null (expected ["cost"])
|
||||||
|
→ request POST /webhook/my-ops-facts
|
||||||
|
{}
|
||||||
|
← response HTTP 500
|
||||||
|
{
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
✘ 다른 영역은 그대로 · 0.1s · status 500 (expected 200); dlq.size = null (expected 3); health.dlq = null (expected red); red = null (expected ["runs", "dlq", "release"]); overall = null (expected red)
|
||||||
|
→ request POST /webhook/my-ops-facts
|
||||||
|
{}
|
||||||
|
← response HTTP 500
|
||||||
|
{
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"usage": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✘ 출처가 돌아오면 다시 노랑 · 0.1s · sources_down = null (expected []); unknown = null (expected [])
|
||||||
|
→ request POST /webhook/my-ops-facts
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
Passed 0/3
|
||||||
|
report: runs/lab01-challenge2-20261101-185143.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"usage": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab01-challenge2.json",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"passed": 3,
|
||||||
|
"total": 3,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "비용 출처가 멈춰도 200 · 모르는 것은 모른다고",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.2,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "다른 영역은 그대로",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.3,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "출처가 돌아오면 다시 노랑",
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"sources_down": [],
|
||||||
|
"health": {
|
||||||
|
"runs": "red",
|
||||||
|
"alerts": "yellow",
|
||||||
|
"dlq": "red",
|
||||||
|
"quality": "yellow",
|
||||||
|
"cost": "yellow",
|
||||||
|
"release": "red"
|
||||||
|
},
|
||||||
|
"red": [
|
||||||
|
"runs",
|
||||||
|
"dlq",
|
||||||
|
"release"
|
||||||
|
],
|
||||||
|
"yellow": [
|
||||||
|
"alerts",
|
||||||
|
"quality",
|
||||||
|
"cost"
|
||||||
|
],
|
||||||
|
"unknown": [],
|
||||||
|
"overall": "red"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
$ uv run tools/flow.py push answers/79-lab01-challenge2-sources.json
|
||||||
|
! unpublished '79 Ops facts + health (answer)' (wed38f213f1753a6): it used the same webhook path
|
||||||
|
✔ pushed '79 Ops facts + sources down (answer)' (id w5657987a9503a11)
|
||||||
|
POST http://localhost:5678/webhook/my-ops-facts
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab01-challenge2.json -v
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "error", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"usage": "error"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "error",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-ops-facts · 3 case(s) × 1
|
||||||
|
✔ 비용 출처가 멈춰도 200 · 모르는 것은 모른다고 · 1.2s
|
||||||
|
→ request POST /webhook/my-ops-facts
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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.3s
|
||||||
|
→ request POST /webhook/my-ops-facts
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"usage": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ 출처가 돌아오면 다시 노랑 · 0.1s
|
||||||
|
→ request POST /webhook/my-ops-facts
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"sources_down": [],
|
||||||
|
"health": {
|
||||||
|
"runs": "red",
|
||||||
|
"alerts": "yellow",
|
||||||
|
"dlq": "red",
|
||||||
|
"quality": "yellow",
|
||||||
|
"cost": "yellow",
|
||||||
|
"release": "red"
|
||||||
|
},
|
||||||
|
"red": [
|
||||||
|
"runs",
|
||||||
|
"dlq",
|
||||||
|
"release"
|
||||||
|
],
|
||||||
|
"yellow": [
|
||||||
|
"alerts",
|
||||||
|
"quality",
|
||||||
|
"cost"
|
||||||
|
],
|
||||||
|
"unknown": [],
|
||||||
|
"overall": "red"
|
||||||
|
}
|
||||||
|
Passed 3/3
|
||||||
|
report: runs/lab01-challenge2-20261101-185159.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"usage": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab01.json
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
POST http://localhost:5678/webhook/my-ops-facts · 5 case(s) × 1
|
||||||
|
✔ 지난주 실행 (기본 기간) · 0.1s
|
||||||
|
✔ 지금 상태 · 알림 · DLQ · 품질 · 0.1s
|
||||||
|
✔ 비용 · 릴리스 · 0.1s
|
||||||
|
✔ 첫 주 (기간을 바꾸면 실행만 바뀜) · 0.1s
|
||||||
|
✔ before: POST http://localhost:8800/dlq/update · HTTP 200 · 0.0s · {"removed": true, "size": 2}
|
||||||
|
✔ 상태가 바뀌면 숫자도 (DLQ #3 처리) · 0.1s
|
||||||
|
Passed 5/5
|
||||||
|
report: runs/lab01-20261101-185159.json
|
||||||
|
✔ after: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab01-challenge.json
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
POST http://localhost:5678/webhook/my-ops-facts · 5 case(s) × 1
|
||||||
|
✔ 지난주 영역별 상태 · 0.1s
|
||||||
|
✔ 빨강 · 노랑 목록 (정해진 순서) · 0.1s
|
||||||
|
✔ 첫 주: 오류는 없지만 답 못 함 · 카나리 비교 불가 · 0.1s
|
||||||
|
✔ before: POST http://localhost:8800/budget · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0.073604, "remaining_usd": 0.926396,
|
||||||
|
✔ 예산을 늘리면 비용은 초록 · 0.1s
|
||||||
|
✔ before: POST http://localhost:8800/dlq/update · HTTP 200 · 0.0s · {"removed": true, "size": 2}
|
||||||
|
✔ before: POST http://localhost:8800/dlq/update · HTTP 200 · 0.0s · {"removed": true, "size": 1}
|
||||||
|
✔ parked를 처리하면 DLQ는 노랑 · 0.1s
|
||||||
|
Passed 5/5
|
||||||
|
report: runs/lab01-challenge-20261101-185200.json
|
||||||
|
✔ after: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab01-period.json
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
POST http://localhost:5678/webhook/my-ops-facts · 5 case(s) × 1
|
||||||
|
✔ 두 주 (10/19~10/30) · 0.1s
|
||||||
|
✔ 하루 (10/26) · 0.1s
|
||||||
|
✔ 빈 기간 (11/02~11/06) · 0건이어도 200 · 0.1s
|
||||||
|
✔ 기간을 비우면 지난주 (기본) · 0.1s
|
||||||
|
✔ 기간과 상관없는 '지금' 숫자는 그대로 · 0.1s
|
||||||
|
Passed 5/5
|
||||||
|
report: runs/lab01-period-20261101-185201.json
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab02.json",
|
||||||
|
"webhook": "base-ops-ask",
|
||||||
|
"passed": 0,
|
||||||
|
"total": 6,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "실행 · 오류율",
|
||||||
|
"webhook": "base-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"area = null (expected runs)",
|
||||||
|
"refused = null (expected false)"
|
||||||
|
],
|
||||||
|
"seconds": 2.7,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "지난주 오류율이 얼마야?"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "지난주 오류율이 얼마야?",
|
||||||
|
"answer": "지난주 오류율은 5.88%입니다."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "DLQ · parked",
|
||||||
|
"webhook": "base-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"area = null (expected dlq)",
|
||||||
|
"refused = null (expected false)"
|
||||||
|
],
|
||||||
|
"seconds": 1.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "DLQ에 parked 된 건 몇 건이야?"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "DLQ에 parked 된 건 몇 건이야?",
|
||||||
|
"answer": "DLQ에 parked 된 건은 2건입니다."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "운영과 상관없는 질문은 거절",
|
||||||
|
"webhook": "base-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"area = null (expected unknown)",
|
||||||
|
"refused = null (expected true)",
|
||||||
|
"reason = null (expected out_of_scope)"
|
||||||
|
],
|
||||||
|
"seconds": 0.9,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "점심 메뉴 추천해 줘"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "점심 메뉴 추천해 줘",
|
||||||
|
"answer": "오늘 점심 메뉴로는 김치찌개, 불고기, 비빔밥 중에서 선택해 보시는 건 어떨까요? 따뜻하고 든든한 한 끼가 될 거예요!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "작업 요청은 거절 (읽기만)",
|
||||||
|
"webhook": "base-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"refused = null (expected true)",
|
||||||
|
"reason = null (expected read_only)"
|
||||||
|
],
|
||||||
|
"seconds": 1.2,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘",
|
||||||
|
"answer": "DLQ 1번 작업을 다시 실행해 드리겠습니다. 잠시만 기다려 주세요. \n작업이 완료되면 다시 안내해 드리겠습니다."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "비용 · 숫자 확인",
|
||||||
|
"webhook": "base-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"area = null (expected cost)",
|
||||||
|
"numbers_ok = null (expected true)"
|
||||||
|
],
|
||||||
|
"seconds": 1.2,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "오늘 LLM 비용이 예산의 몇 퍼센트야?"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "오늘 LLM 비용이 예산의 몇 퍼센트야?",
|
||||||
|
"answer": "오늘 LLM 비용은 예산의 약 92.01%를 사용했습니다."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "릴리스 · 숫자 확인",
|
||||||
|
"webhook": "base-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"area = null (expected release)",
|
||||||
|
"numbers_ok = null (expected true)",
|
||||||
|
"answer has none of ['62.5']"
|
||||||
|
],
|
||||||
|
"seconds": 2.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "카나리 버전 문제 있어?"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "카나리 버전 문제 있어?",
|
||||||
|
"answer": "카나리 버전(1.2.0)에서 문제가 일부 발견되고 있습니다. 전체 실행 중 오류율은 약 5.88%로 다소 높은 편이며, 특히 \"Search FAQ\" 노드에서 1000ms 타임아웃 초과 오류가 16건 발생했습니다. 알림도 6건으로 다수 존재하며, 타임아웃, 속도 제한, 배치 거부 등 다양한 원인이 포함되어 있습니다. DLQ(Dead Letter Queue) 상태도 좋지 않고, 품질 지표도 낮은 편입니다. 현재 전체 시스템 상태가 빨간색으로 표시되어 있어, 카나리 버전에서 안정성 문제가 있다고 판단됩니다. 따라서 카나리 버전 적용에 신중을 기할 필요가 있습니다."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
$ uv run tools/flow.py test cases/lab02.json --webhook base-ops-ask -v
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/base-ops-ask · 6 case(s) × 1
|
||||||
|
✘ 실행 · 오류율 · 2.7s · area = null (expected runs); refused = null (expected false)
|
||||||
|
→ request POST /webhook/base-ops-ask
|
||||||
|
{
|
||||||
|
"question": "지난주 오류율이 얼마야?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "지난주 오류율이 얼마야?",
|
||||||
|
"answer": "지난주 오류율은 5.88%입니다."
|
||||||
|
}
|
||||||
|
✘ DLQ · parked · 1.0s · area = null (expected dlq); refused = null (expected false)
|
||||||
|
→ request POST /webhook/base-ops-ask
|
||||||
|
{
|
||||||
|
"question": "DLQ에 parked 된 건 몇 건이야?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "DLQ에 parked 된 건 몇 건이야?",
|
||||||
|
"answer": "DLQ에 parked 된 건은 2건입니다."
|
||||||
|
}
|
||||||
|
✘ 운영과 상관없는 질문은 거절 · 0.9s · area = null (expected unknown); refused = null (expected true); reason = null (expected out_of_scope)
|
||||||
|
→ request POST /webhook/base-ops-ask
|
||||||
|
{
|
||||||
|
"question": "점심 메뉴 추천해 줘"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "점심 메뉴 추천해 줘",
|
||||||
|
"answer": "오늘 점심 메뉴로는 김치찌개, 불고기, 비빔밥 중에서 선택해 보시는 건 어떨까요? 따뜻하고 든든한 한 끼가 될 거예요!"
|
||||||
|
}
|
||||||
|
✘ 작업 요청은 거절 (읽기만) · 1.2s · refused = null (expected true); reason = null (expected read_only)
|
||||||
|
→ request POST /webhook/base-ops-ask
|
||||||
|
{
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘",
|
||||||
|
"answer": "DLQ 1번 작업을 다시 실행해 드리겠습니다. 잠시만 기다려 주세요. \n작업이 완료되면 다시 안내해 드리겠습니다."
|
||||||
|
}
|
||||||
|
✘ 비용 · 숫자 확인 · 1.2s · area = null (expected cost); numbers_ok = null (expected true)
|
||||||
|
→ request POST /webhook/base-ops-ask
|
||||||
|
{
|
||||||
|
"question": "오늘 LLM 비용이 예산의 몇 퍼센트야?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "오늘 LLM 비용이 예산의 몇 퍼센트야?",
|
||||||
|
"answer": "오늘 LLM 비용은 예산의 약 92.01%를 사용했습니다."
|
||||||
|
}
|
||||||
|
✘ 릴리스 · 숫자 확인 · 2.1s · area = null (expected release); numbers_ok = null (expected true); answer has none of ['62.5']
|
||||||
|
→ request POST /webhook/base-ops-ask
|
||||||
|
{
|
||||||
|
"question": "카나리 버전 문제 있어?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "카나리 버전 문제 있어?",
|
||||||
|
"answer": "카나리 버전(1.2.0)에서 문제가 일부 발견되고 있습니다. 전체 실행 중 오류율은 약 5.88%로 다소 높은 편이며, 특히 \"Search FAQ\" 노드에서 1000ms 타임아웃 초과 오류가 16건 발생했습니다. 알림도 6건으로 다수 존재하며, 타임아웃, 속도 제한, 배치 거부 등 다양한 원인이 포함되어 있습니다. DLQ(Dead Letter Queue) 상태도 좋지 않고, 품질 지표도 낮은 편입니다. 현재 전체 시스템 상태가 빨간색으로 표시되어 있어, 카나리 버전에서 안정성 문제가 있다고 판단됩니다. 따라서 카나리 버전 적용에 신중을 기할 필요가 있습니다."
|
||||||
|
}
|
||||||
|
Passed 0/6
|
||||||
|
report: runs/lab02-20261101-185220.json
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab02.json",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"passed": 3,
|
||||||
|
"total": 6,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "실행 · 오류율",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 2.9,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "지난주 오류율이 얼마야?"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "지난주 오류율이 얼마야?",
|
||||||
|
"area": "runs",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "지난주 오류율은 5.88%였습니다. 총 357회 실행 중 21회 오류가 발생했습니다."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "DLQ · parked",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.8,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "DLQ에 parked 된 건 몇 건이야?"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "DLQ에 parked 된 건 몇 건이야?",
|
||||||
|
"area": "dlq",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "DLQ에 parked 된 건은 2건입니다. 전체 DLQ 크기는 3건이며, 대기 중인 건은 1건입니다."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "운영과 상관없는 질문은 거절",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "점심 메뉴 추천해 줘"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "점심 메뉴 추천해 줘",
|
||||||
|
"area": "unknown",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "out_of_scope",
|
||||||
|
"answer": "운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "작업 요청은 거절 (읽기만)",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"refused = false (expected true)",
|
||||||
|
"reason = null (expected read_only)"
|
||||||
|
],
|
||||||
|
"seconds": 1.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘",
|
||||||
|
"area": "dlq",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "DLQ 1번은 현재 대기 중인 상태이며, DLQ 전체 크기는 3입니다. 현재 시스템 상태는 빨간색(위험)입니다."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "비용 · 숫자 확인",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"numbers_ok = null (expected true)"
|
||||||
|
],
|
||||||
|
"seconds": 1.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "오늘 LLM 비용이 예산의 몇 퍼센트야?"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "오늘 LLM 비용이 예산의 몇 퍼센트야?",
|
||||||
|
"area": "cost",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "오늘 LLM 비용은 예산의 92.01%에 해당합니다. 일일 비용은 0.08달러이며, 현재 사용 비율은 0.9201입니다."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "릴리스 · 숫자 확인",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"numbers_ok = null (expected true)"
|
||||||
|
],
|
||||||
|
"seconds": 2.3,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "카나리 버전 문제 있어?"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "카나리 버전 문제 있어?",
|
||||||
|
"area": "release",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "카나리 버전 1.2.0의 반복 발생률은 62.5%로 매우 높으며, 현재 전체 시스템 상태는 빨간색(문제 있음)입니다. 활성 버전 1.1.0의 반복 발생률은 7.73%에 비해 카나리 버전에서 문제가 심각한 것으로 나타납니다."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
$ uv run tools/flow.py push reference/lab02-example1-ask.json
|
||||||
|
✔ pushed '79 Ops ask (reference)' (id w7ab48263bf09133)
|
||||||
|
POST http://localhost:5678/webhook/my-ops-ask
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab02.json -v
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-ops-ask · 6 case(s) × 1
|
||||||
|
✔ 실행 · 오류율 · 2.9s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "지난주 오류율이 얼마야?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "지난주 오류율이 얼마야?",
|
||||||
|
"area": "runs",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "지난주 오류율은 5.88%였습니다. 총 357회 실행 중 21회 오류가 발생했습니다."
|
||||||
|
}
|
||||||
|
✔ DLQ · parked · 1.8s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "DLQ에 parked 된 건 몇 건이야?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "DLQ에 parked 된 건 몇 건이야?",
|
||||||
|
"area": "dlq",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "DLQ에 parked 된 건은 2건입니다. 전체 DLQ 크기는 3건이며, 대기 중인 건은 1건입니다."
|
||||||
|
}
|
||||||
|
✔ 운영과 상관없는 질문은 거절 · 0.1s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "점심 메뉴 추천해 줘"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "점심 메뉴 추천해 줘",
|
||||||
|
"area": "unknown",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "out_of_scope",
|
||||||
|
"answer": "운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다."
|
||||||
|
}
|
||||||
|
✘ 작업 요청은 거절 (읽기만) · 1.0s · refused = false (expected true); reason = null (expected read_only)
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘",
|
||||||
|
"area": "dlq",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "DLQ 1번은 현재 대기 중인 상태이며, DLQ 전체 크기는 3입니다. 현재 시스템 상태는 빨간색(위험)입니다."
|
||||||
|
}
|
||||||
|
✘ 비용 · 숫자 확인 · 1.0s · numbers_ok = null (expected true)
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "오늘 LLM 비용이 예산의 몇 퍼센트야?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "오늘 LLM 비용이 예산의 몇 퍼센트야?",
|
||||||
|
"area": "cost",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "오늘 LLM 비용은 예산의 92.01%에 해당합니다. 일일 비용은 0.08달러이며, 현재 사용 비율은 0.9201입니다."
|
||||||
|
}
|
||||||
|
✘ 릴리스 · 숫자 확인 · 2.3s · numbers_ok = null (expected true)
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "카나리 버전 문제 있어?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "카나리 버전 문제 있어?",
|
||||||
|
"area": "release",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "카나리 버전 1.2.0의 반복 발생률은 62.5%로 매우 높으며, 현재 전체 시스템 상태는 빨간색(문제 있음)입니다. 활성 버전 1.1.0의 반복 발생률은 7.73%에 비해 카나리 버전에서 문제가 심각한 것으로 나타납니다."
|
||||||
|
}
|
||||||
|
Passed 3/6
|
||||||
|
report: runs/lab02-20261101-185243.json
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab02.json",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"passed": 6,
|
||||||
|
"total": 6,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "실행 · 오류율",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 2.7,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "지난주 오류율이 얼마야?"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "지난주 오류율이 얼마야?",
|
||||||
|
"area": "runs",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "지난주 오류율은 5.88%였습니다. 총 357회 실행 중 21회 오류가 발생했습니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "DLQ · parked",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "DLQ에 parked 된 건 몇 건이야?"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "DLQ에 parked 된 건 몇 건이야?",
|
||||||
|
"area": "dlq",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "DLQ에 parked 된 건은 2건입니다. 전체 DLQ 크기는 3건이며, 대기 중인 건은 1건입니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "운영과 상관없는 질문은 거절",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "점심 메뉴 추천해 줘"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "점심 메뉴 추천해 줘",
|
||||||
|
"area": "unknown",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "out_of_scope",
|
||||||
|
"answer": "운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "작업 요청은 거절 (읽기만)",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘",
|
||||||
|
"area": "action",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "read_only",
|
||||||
|
"answer": "운영 도우미는 읽기만 합니다. 재처리 · 삭제 · 배포 같은 작업은 런북에 따라 담당자가 직접 해 주세요."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "비용 · 숫자 확인",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "오늘 LLM 비용이 예산의 몇 퍼센트야?"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "오늘 LLM 비용이 예산의 몇 퍼센트야?",
|
||||||
|
"area": "cost",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "오늘 LLM 비용은 예산의 92.01%에 해당합니다. 일일 비용은 0.08달러이며, 현재 사용 비율은 0.9201입니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "릴리스 · 숫자 확인",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.5,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "카나리 버전 문제 있어?"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "카나리 버전 문제 있어?",
|
||||||
|
"area": "release",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "카나리 버전 1.2.0의 반복 발생률은 62.5%로 매우 높아 문제가 있는 것으로 보입니다. 현재 전체 시스템 상태는 빨간색(위험)입니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
$ uv run tools/flow.py push reference/lab02-example2-ask.json
|
||||||
|
! unpublished '79 Ops ask (reference)' (w7ab48263bf09133): it used the same webhook path
|
||||||
|
✔ pushed '79 Ops ask + refuse + number check (reference)' (id w48289056bc6d55d)
|
||||||
|
POST http://localhost:5678/webhook/my-ops-ask
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab02.json -v
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-ops-ask · 6 case(s) × 1
|
||||||
|
✔ 실행 · 오류율 · 2.7s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "지난주 오류율이 얼마야?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "지난주 오류율이 얼마야?",
|
||||||
|
"area": "runs",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "지난주 오류율은 5.88%였습니다. 총 357회 실행 중 21회 오류가 발생했습니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
✔ DLQ · parked · 1.0s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "DLQ에 parked 된 건 몇 건이야?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "DLQ에 parked 된 건 몇 건이야?",
|
||||||
|
"area": "dlq",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "DLQ에 parked 된 건은 2건입니다. 전체 DLQ 크기는 3건이며, 대기 중인 건은 1건입니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
✔ 운영과 상관없는 질문은 거절 · 0.1s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "점심 메뉴 추천해 줘"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "점심 메뉴 추천해 줘",
|
||||||
|
"area": "unknown",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "out_of_scope",
|
||||||
|
"answer": "운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다."
|
||||||
|
}
|
||||||
|
✔ 작업 요청은 거절 (읽기만) · 0.0s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘",
|
||||||
|
"area": "action",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "read_only",
|
||||||
|
"answer": "운영 도우미는 읽기만 합니다. 재처리 · 삭제 · 배포 같은 작업은 런북에 따라 담당자가 직접 해 주세요."
|
||||||
|
}
|
||||||
|
✔ 비용 · 숫자 확인 · 1.1s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "오늘 LLM 비용이 예산의 몇 퍼센트야?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "오늘 LLM 비용이 예산의 몇 퍼센트야?",
|
||||||
|
"area": "cost",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "오늘 LLM 비용은 예산의 92.01%에 해당합니다. 일일 비용은 0.08달러이며, 현재 사용 비율은 0.9201입니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
✔ 릴리스 · 숫자 확인 · 1.5s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "카나리 버전 문제 있어?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "카나리 버전 문제 있어?",
|
||||||
|
"area": "release",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "카나리 버전 1.2.0의 반복 발생률은 62.5%로 매우 높아 문제가 있는 것으로 보입니다. 현재 전체 시스템 상태는 빨간색(위험)입니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
Passed 6/6
|
||||||
|
report: runs/lab02-20261101-185304.json
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab02-phrasing.json",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"passed": 6,
|
||||||
|
"total": 6,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "'에러' → 실행",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.2,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "에러 많이 났어?"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "에러 많이 났어?",
|
||||||
|
"area": "runs",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "2026년 10월 26일부터 30일까지 총 357회 실행 중 21회 에러가 발생하여 에러율은 5.88%입니다. 주요 실패 원인은 \"Search FAQ\" 노드에서 1000ms 타임아웃 초과로 16회 발생했습니다. 현재 시스템 상태는 빨간색(위험)입니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "'장애' 는 낱말 목록에 없음 → 운영 밖으로 거절",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "장애 났어?"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "장애 났어?",
|
||||||
|
"area": "unknown",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "out_of_scope",
|
||||||
|
"answer": "운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "두 영역이 한 번씩 → 앞의 영역 (알림)",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "알림이랑 DLQ 둘 다 알려 줘"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "알림이랑 DLQ 둘 다 알려 줘",
|
||||||
|
"area": "alerts",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "알림은 총 6건이며, 그중 3건은 높은 심각도입니다. DLQ 관련 정보는 제공된 사실에 포함되어 있지 않습니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "'꺼 줘' → 작업 요청으로 거절",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "카나리 꺼 줘"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "카나리 꺼 줘",
|
||||||
|
"area": "action",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "read_only",
|
||||||
|
"answer": "운영 도우미는 읽기만 합니다. 재처리 · 삭제 · 배포 같은 작업은 런북에 따라 담당자가 직접 해 주세요."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "'배포'(1) 보다 '상태 · 요약'(2) → 전체",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.7,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "배포 상태 요약해 줘"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "배포 상태 요약해 줘",
|
||||||
|
"area": "overview",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "2026년 10월 26일부터 30일까지 총 357회 실행 중 오류율은 5.88%이며, 주요 실패 원인은 'Search FAQ' 노드에서 1000ms 타임아웃 초과로 16건 발생했습니다. 경고는 총 6건으로 고위험 3건, 낮은 품질과 API 오류 등이 포함되어 있고, DLQ에는 3건이 대기 중입니다. 전체 배포 상태는 실행, DLQ, 릴리스가 빨간색으로 심각하며, 경고, 품질, 비용은 노란색으로 나타나 전체적으로는 빨간색 상태입니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "낱말 없는 비용 질문 → 거절 (과제 1에서 LLM 라우터로)",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "AI 쓰는 데 얼마나 나갔어?"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "AI 쓰는 데 얼마나 나갔어?",
|
||||||
|
"area": "unknown",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "out_of_scope",
|
||||||
|
"answer": "운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
$ uv run tools/flow.py test cases/lab02-phrasing.json -v
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-ops-ask · 6 case(s) × 1
|
||||||
|
✔ '에러' → 실행 · 1.2s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "에러 많이 났어?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "에러 많이 났어?",
|
||||||
|
"area": "runs",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "2026년 10월 26일부터 30일까지 총 357회 실행 중 21회 에러가 발생하여 에러율은 5.88%입니다. 주요 실패 원인은 \"Search FAQ\" 노드에서 1000ms 타임아웃 초과로 16회 발생했습니다. 현재 시스템 상태는 빨간색(위험)입니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
✔ '장애' 는 낱말 목록에 없음 → 운영 밖으로 거절 · 0.1s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "장애 났어?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "장애 났어?",
|
||||||
|
"area": "unknown",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "out_of_scope",
|
||||||
|
"answer": "운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다."
|
||||||
|
}
|
||||||
|
✔ 두 영역이 한 번씩 → 앞의 영역 (알림) · 1.0s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "알림이랑 DLQ 둘 다 알려 줘"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "알림이랑 DLQ 둘 다 알려 줘",
|
||||||
|
"area": "alerts",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "알림은 총 6건이며, 그중 3건은 높은 심각도입니다. DLQ 관련 정보는 제공된 사실에 포함되어 있지 않습니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
✔ '꺼 줘' → 작업 요청으로 거절 · 0.1s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "카나리 꺼 줘"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "카나리 꺼 줘",
|
||||||
|
"area": "action",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "read_only",
|
||||||
|
"answer": "운영 도우미는 읽기만 합니다. 재처리 · 삭제 · 배포 같은 작업은 런북에 따라 담당자가 직접 해 주세요."
|
||||||
|
}
|
||||||
|
✔ '배포'(1) 보다 '상태 · 요약'(2) → 전체 · 1.7s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "배포 상태 요약해 줘"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "배포 상태 요약해 줘",
|
||||||
|
"area": "overview",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "2026년 10월 26일부터 30일까지 총 357회 실행 중 오류율은 5.88%이며, 주요 실패 원인은 'Search FAQ' 노드에서 1000ms 타임아웃 초과로 16건 발생했습니다. 경고는 총 6건으로 고위험 3건, 낮은 품질과 API 오류 등이 포함되어 있고, DLQ에는 3건이 대기 중입니다. 전체 배포 상태는 실행, DLQ, 릴리스가 빨간색으로 심각하며, 경고, 품질, 비용은 노란색으로 나타나 전체적으로는 빨간색 상태입니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
✔ 낱말 없는 비용 질문 → 거절 (과제 1에서 LLM 라우터로) · 0.0s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "AI 쓰는 데 얼마나 나갔어?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "AI 쓰는 데 얼마나 나갔어?",
|
||||||
|
"area": "unknown",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "out_of_scope",
|
||||||
|
"answer": "운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다."
|
||||||
|
}
|
||||||
|
Passed 6/6
|
||||||
|
report: runs/lab02-phrasing-20261101-185309.json
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab02-challenge.json",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"passed": 1,
|
||||||
|
"total": 5,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "낱말이 없는 비용 질문",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"area = unknown (expected cost)",
|
||||||
|
"router = keyword (expected llm)",
|
||||||
|
"refused = true (expected false)",
|
||||||
|
"numbers_ok = null (expected true)"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "이번 주에 AI 쓰는 데 얼마나 나갔어?"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "이번 주에 AI 쓰는 데 얼마나 나갔어?",
|
||||||
|
"area": "unknown",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "out_of_scope",
|
||||||
|
"answer": "운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "낱말이 없는 DLQ 질문",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"area = unknown (expected dlq)",
|
||||||
|
"router = keyword (expected llm)",
|
||||||
|
"numbers_ok = null (expected true)",
|
||||||
|
"answer has none of ['3']"
|
||||||
|
],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "어젯밤에 접수 못 하고 남은 건 몇 건이야?"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "어젯밤에 접수 못 하고 남은 건 몇 건이야?",
|
||||||
|
"area": "unknown",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "out_of_scope",
|
||||||
|
"answer": "운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "낱말이 없는 릴리스 질문",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"area = unknown (expected release)",
|
||||||
|
"router = keyword (expected llm)",
|
||||||
|
"numbers_ok = null (expected true)",
|
||||||
|
"answer has none of ['62.5']"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "새 판을 일부 사용자에게 먼저 내보낸 거 어떻게 되고 있어?"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "새 판을 일부 사용자에게 먼저 내보낸 거 어떻게 되고 있어?",
|
||||||
|
"area": "unknown",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "out_of_scope",
|
||||||
|
"answer": "운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "LLM도 운영 밖이면 거절",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"router = keyword (expected llm)"
|
||||||
|
],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "점심 메뉴 추천해 줘"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "점심 메뉴 추천해 줘",
|
||||||
|
"area": "unknown",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "out_of_scope",
|
||||||
|
"answer": "운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "낱말이 있으면 LLM을 부르지 않음",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "지난주 오류율이 얼마야?"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "지난주 오류율이 얼마야?",
|
||||||
|
"area": "runs",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "지난주 오류율은 5.88%였습니다. 총 357회 실행 중 21회 오류가 발생했습니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
$ uv run tools/flow.py test cases/lab02-challenge.json -v
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-ops-ask · 5 case(s) × 1
|
||||||
|
✘ 낱말이 없는 비용 질문 · 0.0s · area = unknown (expected cost); router = keyword (expected llm); refused = true (expected false); numbers_ok = null (expected true)
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "이번 주에 AI 쓰는 데 얼마나 나갔어?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "이번 주에 AI 쓰는 데 얼마나 나갔어?",
|
||||||
|
"area": "unknown",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "out_of_scope",
|
||||||
|
"answer": "운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다."
|
||||||
|
}
|
||||||
|
✘ 낱말이 없는 DLQ 질문 · 0.1s · area = unknown (expected dlq); router = keyword (expected llm); numbers_ok = null (expected true); answer has none of ['3']
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "어젯밤에 접수 못 하고 남은 건 몇 건이야?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "어젯밤에 접수 못 하고 남은 건 몇 건이야?",
|
||||||
|
"area": "unknown",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "out_of_scope",
|
||||||
|
"answer": "운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다."
|
||||||
|
}
|
||||||
|
✘ 낱말이 없는 릴리스 질문 · 0.0s · area = unknown (expected release); router = keyword (expected llm); numbers_ok = null (expected true); answer has none of ['62.5']
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "새 판을 일부 사용자에게 먼저 내보낸 거 어떻게 되고 있어?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "새 판을 일부 사용자에게 먼저 내보낸 거 어떻게 되고 있어?",
|
||||||
|
"area": "unknown",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "out_of_scope",
|
||||||
|
"answer": "운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다."
|
||||||
|
}
|
||||||
|
✘ LLM도 운영 밖이면 거절 · 0.1s · router = keyword (expected llm)
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "점심 메뉴 추천해 줘"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "점심 메뉴 추천해 줘",
|
||||||
|
"area": "unknown",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "out_of_scope",
|
||||||
|
"answer": "운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다."
|
||||||
|
}
|
||||||
|
✔ 낱말이 있으면 LLM을 부르지 않음 · 1.0s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "지난주 오류율이 얼마야?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "지난주 오류율이 얼마야?",
|
||||||
|
"area": "runs",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "지난주 오류율은 5.88%였습니다. 총 357회 실행 중 21회 오류가 발생했습니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
Passed 1/5
|
||||||
|
report: runs/lab02-challenge-20261101-185310.json
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab02-challenge.json",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"passed": 5,
|
||||||
|
"total": 5,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "낱말이 없는 비용 질문",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 4.9,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "이번 주에 AI 쓰는 데 얼마나 나갔어?"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "이번 주에 AI 쓰는 데 얼마나 나갔어?",
|
||||||
|
"area": "cost",
|
||||||
|
"router": "llm",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "이번 주 AI 사용 비용은 총 0.073604달러입니다. 일일 평균 비용은 약 0.08달러이며, 사용 비율은 92.01%입니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "낱말이 없는 DLQ 질문",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.7,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "어젯밤에 접수 못 하고 남은 건 몇 건이야?"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "어젯밤에 접수 못 하고 남은 건 몇 건이야?",
|
||||||
|
"area": "dlq",
|
||||||
|
"router": "llm",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "어젯밤에 접수 못 하고 남은 건은 총 3건입니다. 이 중 2건은 대기 중이고 1건은 주차 상태입니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "낱말이 없는 릴리스 질문",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.8,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "새 판을 일부 사용자에게 먼저 내보낸 거 어떻게 되고 있어?"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "새 판을 일부 사용자에게 먼저 내보낸 거 어떻게 되고 있어?",
|
||||||
|
"area": "release",
|
||||||
|
"router": "llm",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "새 판 1.2.0은 일부 사용자 20%에게 먼저 내보내졌으며, 이 버전의 재사용률은 62.5%로 나타났습니다. 현재 전체 시스템 상태는 빨간색(문제 있음)입니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "LLM도 운영 밖이면 거절",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.9,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "점심 메뉴 추천해 줘"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "점심 메뉴 추천해 줘",
|
||||||
|
"area": "unknown",
|
||||||
|
"router": "llm",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "out_of_scope",
|
||||||
|
"answer": "운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "낱말이 있으면 LLM을 부르지 않음",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "지난주 오류율이 얼마야?"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "지난주 오류율이 얼마야?",
|
||||||
|
"area": "runs",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "지난주 오류율은 5.88%였습니다. 총 357회 실행 중 21회 오류가 발생했습니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
$ uv run tools/flow.py push answers/79-lab02-challenge-llm-router.json
|
||||||
|
! unpublished '79 Ops ask + refuse + number check (reference)' (w48289056bc6d55d): it used the same webhook path
|
||||||
|
✔ pushed '79 Ops ask + LLM router (answer)' (id wea68a09a2860083)
|
||||||
|
POST http://localhost:5678/webhook/my-ops-ask
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab02-challenge.json -v
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-ops-ask · 5 case(s) × 1
|
||||||
|
✔ 낱말이 없는 비용 질문 · 4.9s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "이번 주에 AI 쓰는 데 얼마나 나갔어?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "이번 주에 AI 쓰는 데 얼마나 나갔어?",
|
||||||
|
"area": "cost",
|
||||||
|
"router": "llm",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "이번 주 AI 사용 비용은 총 0.073604달러입니다. 일일 평균 비용은 약 0.08달러이며, 사용 비율은 92.01%입니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
✔ 낱말이 없는 DLQ 질문 · 1.7s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "어젯밤에 접수 못 하고 남은 건 몇 건이야?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "어젯밤에 접수 못 하고 남은 건 몇 건이야?",
|
||||||
|
"area": "dlq",
|
||||||
|
"router": "llm",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "어젯밤에 접수 못 하고 남은 건은 총 3건입니다. 이 중 2건은 대기 중이고 1건은 주차 상태입니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
✔ 낱말이 없는 릴리스 질문 · 1.8s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "새 판을 일부 사용자에게 먼저 내보낸 거 어떻게 되고 있어?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "새 판을 일부 사용자에게 먼저 내보낸 거 어떻게 되고 있어?",
|
||||||
|
"area": "release",
|
||||||
|
"router": "llm",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "새 판 1.2.0은 일부 사용자 20%에게 먼저 내보내졌으며, 이 버전의 재사용률은 62.5%로 나타났습니다. 현재 전체 시스템 상태는 빨간색(문제 있음)입니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
✔ LLM도 운영 밖이면 거절 · 0.9s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "점심 메뉴 추천해 줘"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "점심 메뉴 추천해 줘",
|
||||||
|
"area": "unknown",
|
||||||
|
"router": "llm",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "out_of_scope",
|
||||||
|
"answer": "운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다."
|
||||||
|
}
|
||||||
|
✔ 낱말이 있으면 LLM을 부르지 않음 · 1.0s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "지난주 오류율이 얼마야?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "지난주 오류율이 얼마야?",
|
||||||
|
"area": "runs",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "지난주 오류율은 5.88%였습니다. 총 357회 실행 중 21회 오류가 발생했습니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
Passed 5/5
|
||||||
|
report: runs/lab02-challenge-20261101-185333.json
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab02.json
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
POST http://localhost:5678/webhook/my-ops-ask · 6 case(s) × 1
|
||||||
|
✔ 실행 · 오류율 · 1.0s
|
||||||
|
✔ DLQ · parked · 1.1s
|
||||||
|
✔ 운영과 상관없는 질문은 거절 · 0.9s
|
||||||
|
✔ 작업 요청은 거절 (읽기만) · 0.0s
|
||||||
|
✔ 비용 · 숫자 확인 · 1.0s
|
||||||
|
✔ 릴리스 · 숫자 확인 · 1.4s
|
||||||
|
Passed 6/6
|
||||||
|
report: runs/lab02-20261101-185339.json
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab02-phrasing.json -v
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-ops-ask · 6 case(s) × 1
|
||||||
|
✔ '에러' → 실행 · 1.1s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "에러 많이 났어?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "에러 많이 났어?",
|
||||||
|
"area": "runs",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "2026년 10월 26일부터 30일까지 총 357회 실행 중 21회 에러가 발생하여 에러율은 5.88%입니다. 주요 실패 원인은 \"Search FAQ\" 노드에서 1000ms 타임아웃 초과로 16회 발생했습니다. 현재 시스템 상태는 빨간색(위험)입니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
✘ '장애' 는 낱말 목록에 없음 → 운영 밖으로 거절 · 2.2s · area = runs (expected unknown); refused = false (expected true); reason = null (expected out_of_scope)
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "장애 났어?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "장애 났어?",
|
||||||
|
"area": "runs",
|
||||||
|
"router": "llm",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "네, 2026년 10월 26일부터 30일까지 장애가 발생했습니다. 전체 357건 중 21건(5.88%)에서 오류가 있었고, 주요 장애는 'Search FAQ' 노드에서 1000ms 타임아웃 초과로 16건 발생했습니다. 시스템 상태는 빨간색(위험)입니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
✔ 두 영역이 한 번씩 → 앞의 영역 (알림) · 1.0s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "알림이랑 DLQ 둘 다 알려 줘"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "알림이랑 DLQ 둘 다 알려 줘",
|
||||||
|
"area": "alerts",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "알림은 총 6건이며, 이 중 3건은 높은 심각도입니다. DLQ 관련 정보는 제공된 사실에 포함되어 있지 않습니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
✔ '꺼 줘' → 작업 요청으로 거절 · 0.1s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "카나리 꺼 줘"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "카나리 꺼 줘",
|
||||||
|
"area": "action",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "read_only",
|
||||||
|
"answer": "운영 도우미는 읽기만 합니다. 재처리 · 삭제 · 배포 같은 작업은 런북에 따라 담당자가 직접 해 주세요."
|
||||||
|
}
|
||||||
|
✔ '배포'(1) 보다 '상태 · 요약'(2) → 전체 · 1.7s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "배포 상태 요약해 줘"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "배포 상태 요약해 줘",
|
||||||
|
"area": "overview",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "배포 상태 요약입니다. 10월 26일부터 30일까지 총 357회 실행 중 오류율은 5.88%이며, 주요 오류는 'Search FAQ' 노드에서 1000ms 타임아웃이 16회 발생했습니다. 전체 상태는 'red'로, 실행, DLQ, 릴리스가 심각한 문제를 보이고 있습니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
✘ 낱말 없는 비용 질문 → 거절 (과제 1에서 LLM 라우터로) · 1.9s · refused = false (expected true); reason = null (expected out_of_scope)
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "AI 쓰는 데 얼마나 나갔어?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "AI 쓰는 데 얼마나 나갔어?",
|
||||||
|
"area": "cost",
|
||||||
|
"router": "llm",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "2026년 10월 26일부터 30일까지 AI 사용 비용은 총 0.0736달러였습니다. 하루 평균 비용은 약 0.08달러이며, 전체 사용량의 92.01%가 사용되었습니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
Passed 4/6
|
||||||
|
report: runs/lab02-phrasing-20261101-185347.json
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab02-challenge2.json",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"passed": 4,
|
||||||
|
"total": 5,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "작업 요청",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘",
|
||||||
|
"area": "action",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "read_only",
|
||||||
|
"answer": "운영 도우미는 읽기만 합니다. 재처리 · 삭제 · 배포 같은 작업은 런북에 따라 담당자가 직접 해 주세요."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "운영 밖",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.7,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "점심 메뉴 추천해 줘"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "점심 메뉴 추천해 줘",
|
||||||
|
"area": "unknown",
|
||||||
|
"router": "llm",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "out_of_scope",
|
||||||
|
"answer": "운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "답한 질문",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "지난주 오류율이 얼마야?"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "지난주 오류율이 얼마야?",
|
||||||
|
"area": "runs",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "지난주 오류율은 5.88%였습니다. 총 357회 실행 중 21회 오류가 발생했습니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "감사 기록 세 줄 · 거절 이유와 함께",
|
||||||
|
"webhook": "http://localhost:8800/audit/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"events.0.kind = null (expected ops-ask)",
|
||||||
|
"events.0.reason = null (expected read_only)",
|
||||||
|
"events.0.area = null (expected action)",
|
||||||
|
"events.1.reason = null (expected out_of_scope)",
|
||||||
|
"events.1.router = null (expected llm)",
|
||||||
|
"events.2.area = null (expected runs)",
|
||||||
|
"events.2.refused = null (expected false)",
|
||||||
|
"events.2.numbers_ok = null (expected true)"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"events": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "응답은 그대로 (감사는 곁가지)",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘",
|
||||||
|
"area": "action",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "read_only",
|
||||||
|
"answer": "운영 도우미는 읽기만 합니다. 재처리 · 삭제 · 배포 같은 작업은 런북에 따라 담당자가 직접 해 주세요."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
$ uv run tools/flow.py test cases/lab02-challenge2.json -v
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-ops-ask · 5 case(s) × 1
|
||||||
|
✔ 작업 요청 · 0.0s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘",
|
||||||
|
"area": "action",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "read_only",
|
||||||
|
"answer": "운영 도우미는 읽기만 합니다. 재처리 · 삭제 · 배포 같은 작업은 런북에 따라 담당자가 직접 해 주세요."
|
||||||
|
}
|
||||||
|
✔ 운영 밖 · 0.7s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "점심 메뉴 추천해 줘"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "점심 메뉴 추천해 줘",
|
||||||
|
"area": "unknown",
|
||||||
|
"router": "llm",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "out_of_scope",
|
||||||
|
"answer": "운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다."
|
||||||
|
}
|
||||||
|
✔ 답한 질문 · 1.1s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "지난주 오류율이 얼마야?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "지난주 오류율이 얼마야?",
|
||||||
|
"area": "runs",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "지난주 오류율은 5.88%였습니다. 총 357회 실행 중 21회 오류가 발생했습니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
✘ 감사 기록 세 줄 · 거절 이유와 함께 · 0.0s · events.0.kind = null (expected ops-ask); events.0.reason = null (expected read_only); events.0.area = null (expected action); events.1.reason = null (expected out_of_scope); events.1.router = null (expected llm); events.2.area = null (expected runs); events.2.refused = null (expected false); events.2.numbers_ok = null (expected true)
|
||||||
|
→ request POST http://localhost:8800/audit/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"events": []
|
||||||
|
}
|
||||||
|
✔ 응답은 그대로 (감사는 곁가지) · 0.0s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘",
|
||||||
|
"area": "action",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "read_only",
|
||||||
|
"answer": "운영 도우미는 읽기만 합니다. 재처리 · 삭제 · 배포 같은 작업은 런북에 따라 담당자가 직접 해 주세요."
|
||||||
|
}
|
||||||
|
Passed 4/5
|
||||||
|
report: runs/lab02-challenge2-20261101-185349.json
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab02-challenge2.json",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"passed": 5,
|
||||||
|
"total": 5,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "작업 요청",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘",
|
||||||
|
"area": "action",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "read_only",
|
||||||
|
"answer": "운영 도우미는 읽기만 합니다. 재처리 · 삭제 · 배포 같은 작업은 런북에 따라 담당자가 직접 해 주세요."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "운영 밖",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.6,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "점심 메뉴 추천해 줘"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "점심 메뉴 추천해 줘",
|
||||||
|
"area": "unknown",
|
||||||
|
"router": "llm",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "out_of_scope",
|
||||||
|
"answer": "운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "답한 질문",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "지난주 오류율이 얼마야?"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "지난주 오류율이 얼마야?",
|
||||||
|
"area": "runs",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "지난주 오류율은 5.88%였습니다. 총 357회 실행 중 21회 오류가 발생했습니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "감사 기록 세 줄 · 거절 이유와 함께",
|
||||||
|
"webhook": "http://localhost:8800/audit/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "응답은 그대로 (감사는 곁가지)",
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘",
|
||||||
|
"area": "action",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "read_only",
|
||||||
|
"answer": "운영 도우미는 읽기만 합니다. 재처리 · 삭제 · 배포 같은 작업은 런북에 따라 담당자가 직접 해 주세요."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
$ uv run tools/flow.py push answers/79-lab02-challenge2-audit.json
|
||||||
|
! unpublished '79 Ops ask + LLM router (answer)' (wea68a09a2860083): it used the same webhook path
|
||||||
|
✔ pushed '79 Ops ask + audit (answer)' (id w06fbfa6c9a05e92)
|
||||||
|
POST http://localhost:5678/webhook/my-ops-ask
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab02-challenge2.json -v
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-ops-ask · 5 case(s) × 1
|
||||||
|
✔ 작업 요청 · 1.0s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘",
|
||||||
|
"area": "action",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "read_only",
|
||||||
|
"answer": "운영 도우미는 읽기만 합니다. 재처리 · 삭제 · 배포 같은 작업은 런북에 따라 담당자가 직접 해 주세요."
|
||||||
|
}
|
||||||
|
✔ 운영 밖 · 1.6s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "점심 메뉴 추천해 줘"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "점심 메뉴 추천해 줘",
|
||||||
|
"area": "unknown",
|
||||||
|
"router": "llm",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "out_of_scope",
|
||||||
|
"answer": "운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다."
|
||||||
|
}
|
||||||
|
✔ 답한 질문 · 1.1s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "지난주 오류율이 얼마야?"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "지난주 오류율이 얼마야?",
|
||||||
|
"area": "runs",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false,
|
||||||
|
"reason": null,
|
||||||
|
"answer": "지난주 오류율은 5.88%였습니다. 총 357회 실행 중 21회 오류가 발생했습니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
✔ 감사 기록 세 줄 · 거절 이유와 함께 · 0.0s
|
||||||
|
→ request POST http://localhost:8800/audit/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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.1s
|
||||||
|
→ request POST /webhook/my-ops-ask
|
||||||
|
{
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘",
|
||||||
|
"area": "action",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "read_only",
|
||||||
|
"answer": "운영 도우미는 읽기만 합니다. 재처리 · 삭제 · 배포 같은 작업은 런북에 따라 담당자가 직접 해 주세요."
|
||||||
|
}
|
||||||
|
Passed 5/5
|
||||||
|
report: runs/lab02-challenge2-20261101-185406.json
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab02.json
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
POST http://localhost:5678/webhook/my-ops-ask · 6 case(s) × 1
|
||||||
|
✔ 실행 · 오류율 · 1.0s
|
||||||
|
✔ DLQ · parked · 1.2s
|
||||||
|
✔ 운영과 상관없는 질문은 거절 · 0.8s
|
||||||
|
✔ 작업 요청은 거절 (읽기만) · 0.1s
|
||||||
|
✔ 비용 · 숫자 확인 · 1.2s
|
||||||
|
✔ 릴리스 · 숫자 확인 · 1.6s
|
||||||
|
Passed 6/6
|
||||||
|
report: runs/lab02-20261101-185412.json
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab02-challenge.json
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
POST http://localhost:5678/webhook/my-ops-ask · 5 case(s) × 1
|
||||||
|
✔ 낱말이 없는 비용 질문 · 2.3s
|
||||||
|
✔ 낱말이 없는 DLQ 질문 · 1.7s
|
||||||
|
✔ 낱말이 없는 릴리스 질문 · 1.6s
|
||||||
|
✔ LLM도 운영 밖이면 거절 · 0.7s
|
||||||
|
✔ 낱말이 있으면 LLM을 부르지 않음 · 1.2s
|
||||||
|
Passed 5/5
|
||||||
|
report: runs/lab02-challenge-20261101-185419.json
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab03.json",
|
||||||
|
"webhook": "my-ops-brief",
|
||||||
|
"passed": 0,
|
||||||
|
"total": 4,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "전체 · 먼저 볼 것 셋",
|
||||||
|
"webhook": "my-ops-brief",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"status 404 (expected 200)",
|
||||||
|
"overall = null (expected red)",
|
||||||
|
"red = null (expected [\"runs\", \"dlq\", \"release\"])",
|
||||||
|
"actions.0.area = null (expected runs)",
|
||||||
|
"actions.1.area = null (expected dlq)",
|
||||||
|
"actions.2.area = null (expected release)"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 404,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"code": 404,
|
||||||
|
"message": "The requested webhook \"POST my-ops-brief\" is not registered.",
|
||||||
|
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "표에 숫자",
|
||||||
|
"webhook": "my-ops-brief",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"status 404 (expected 200)",
|
||||||
|
"markdown has none of ['| DLQ | 🔴 | 3건 (parked 2) |']"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 404,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"code": 404,
|
||||||
|
"message": "The requested webhook \"POST my-ops-brief\" is not registered.",
|
||||||
|
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "할 일은 규칙에서",
|
||||||
|
"webhook": "my-ops-brief",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"status 404 (expected 200)",
|
||||||
|
"markdown has none of ['카나리 끄기 · 되돌리기 검토 (74일차)']",
|
||||||
|
"actions.1.action has none of ['parked']"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 404,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"code": 404,
|
||||||
|
"message": "The requested webhook \"POST my-ops-brief\" is not registered.",
|
||||||
|
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "DLQ를 비운 뒤",
|
||||||
|
"webhook": "my-ops-brief",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"status 404 (expected 200)",
|
||||||
|
"red = null (expected [\"runs\", \"release\"])",
|
||||||
|
"actions.2.area = null (expected alerts)"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 404,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"code": 404,
|
||||||
|
"message": "The requested webhook \"POST my-ops-brief\" is not registered.",
|
||||||
|
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
$ uv run tools/flow.py test cases/lab03.json -v
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-ops-brief · 4 case(s) × 1
|
||||||
|
✘ 전체 · 먼저 볼 것 셋 · 0.0s · status 404 (expected 200); overall = null (expected red); red = null (expected ["runs", "dlq", "release"]); actions.0.area = null (expected runs); actions.1.area = null (expected dlq); actions.2.area = null (expected release)
|
||||||
|
→ request POST /webhook/my-ops-brief
|
||||||
|
{}
|
||||||
|
← response HTTP 404
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"message": "The requested webhook \"POST my-ops-brief\" is not registered.",
|
||||||
|
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||||
|
}
|
||||||
|
✘ 표에 숫자 · 0.0s · status 404 (expected 200); markdown has none of ['| DLQ | 🔴 | 3건 (parked 2) |']
|
||||||
|
→ request POST /webhook/my-ops-brief
|
||||||
|
{}
|
||||||
|
← response HTTP 404
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"message": "The requested webhook \"POST my-ops-brief\" is not registered.",
|
||||||
|
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||||
|
}
|
||||||
|
✘ 할 일은 규칙에서 · 0.0s · status 404 (expected 200); markdown has none of ['카나리 끄기 · 되돌리기 검토 (74일차)']; actions.1.action has none of ['parked']
|
||||||
|
→ request POST /webhook/my-ops-brief
|
||||||
|
{}
|
||||||
|
← response HTTP 404
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"message": "The requested webhook \"POST my-ops-brief\" is not registered.",
|
||||||
|
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 3}
|
||||||
|
→ request POST http://localhost:8800/dlq-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 3
|
||||||
|
}
|
||||||
|
✘ DLQ를 비운 뒤 · 0.0s · status 404 (expected 200); red = null (expected ["runs", "release"]); actions.2.area = null (expected alerts)
|
||||||
|
→ request POST /webhook/my-ops-brief
|
||||||
|
{}
|
||||||
|
← response HTTP 404
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"message": "The requested webhook \"POST my-ops-brief\" is not registered.",
|
||||||
|
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||||
|
}
|
||||||
|
Passed 0/4
|
||||||
|
report: runs/lab03-20261101-185420.json
|
||||||
|
✔ after: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab03.json",
|
||||||
|
"webhook": "my-ops-brief",
|
||||||
|
"passed": 4,
|
||||||
|
"total": 4,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "전체 · 먼저 볼 것 셋",
|
||||||
|
"webhook": "my-ops-brief",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"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일차)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "표에 숫자",
|
||||||
|
"webhook": "my-ops-brief",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.2,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"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일차)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "할 일은 규칙에서",
|
||||||
|
"webhook": "my-ops-brief",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"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일차)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "DLQ를 비운 뒤",
|
||||||
|
"webhook": "my-ops-brief",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"overall": "red",
|
||||||
|
"red": [
|
||||||
|
"runs",
|
||||||
|
"release"
|
||||||
|
],
|
||||||
|
"yellow": [
|
||||||
|
"alerts",
|
||||||
|
"quality",
|
||||||
|
"cost"
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"area": "runs",
|
||||||
|
"status": "red",
|
||||||
|
"action": "오류 묶음 1위 확인 · 고치기 (77일차)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "release",
|
||||||
|
"status": "red",
|
||||||
|
"action": "카나리 끄기 · 되돌리기 검토 (74일차)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "alerts",
|
||||||
|
"status": "yellow",
|
||||||
|
"action": "높은 알림부터 확인하고 닫기 (76일차)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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 | 🟢 | 0건 (parked 0) |\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. 🔴 릴리스: 카나리 끄기 · 되돌리기 검토 (74일차)\n3. 🟡 알림: 높은 알림부터 확인하고 닫기 (76일차)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
$ uv run tools/flow.py push reference/lab03-example1-brief.json
|
||||||
|
✔ pushed '79 Ops brief (reference)' (id w97397012b52e601)
|
||||||
|
POST http://localhost:5678/webhook/my-ops-brief
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab03.json -v
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-ops-brief · 4 case(s) × 1
|
||||||
|
✔ 전체 · 먼저 볼 것 셋 · 1.1s
|
||||||
|
→ request POST /webhook/my-ops-brief
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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.2s
|
||||||
|
→ request POST /webhook/my-ops-brief
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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.1s
|
||||||
|
→ request POST /webhook/my-ops-brief
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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일차)"
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 3}
|
||||||
|
→ request POST http://localhost:8800/dlq-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 3
|
||||||
|
}
|
||||||
|
✔ DLQ를 비운 뒤 · 0.1s
|
||||||
|
→ request POST /webhook/my-ops-brief
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"overall": "red",
|
||||||
|
"red": [
|
||||||
|
"runs",
|
||||||
|
"release"
|
||||||
|
],
|
||||||
|
"yellow": [
|
||||||
|
"alerts",
|
||||||
|
"quality",
|
||||||
|
"cost"
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"area": "runs",
|
||||||
|
"status": "red",
|
||||||
|
"action": "오류 묶음 1위 확인 · 고치기 (77일차)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "release",
|
||||||
|
"status": "red",
|
||||||
|
"action": "카나리 끄기 · 되돌리기 검토 (74일차)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "alerts",
|
||||||
|
"status": "yellow",
|
||||||
|
"action": "높은 알림부터 확인하고 닫기 (76일차)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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 | 🟢 | 0건 (parked 0) |\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. 🔴 릴리스: 카나리 끄기 · 되돌리기 검토 (74일차)\n3. 🟡 알림: 높은 알림부터 확인하고 닫기 (76일차)"
|
||||||
|
}
|
||||||
|
Passed 4/4
|
||||||
|
report: runs/lab03-20261101-185435.json
|
||||||
|
✔ after: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py call my-ops-brief '{}'
|
||||||
|
HTTP 200 · 0.1s
|
||||||
|
{
|
||||||
|
"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,95 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab03-llm.json",
|
||||||
|
"webhook": "my-ops-brief",
|
||||||
|
"passed": 0,
|
||||||
|
"total": 2,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "요약 세 문장 · 숫자 확인",
|
||||||
|
"webhook": "my-ops-brief",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"numbers_ok = null (expected true)",
|
||||||
|
"markdown has none of ['## 요약']"
|
||||||
|
],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"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일차)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "요약에 급한 곳",
|
||||||
|
"webhook": "my-ops-brief",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"summary has none of ['DLQ', '릴리스', '카나리', '실행', '오류']"
|
||||||
|
],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"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,89 @@
|
|||||||
|
$ uv run tools/flow.py test cases/lab03-llm.json -v
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-ops-brief · 2 case(s) × 1
|
||||||
|
✘ 요약 세 문장 · 숫자 확인 · 0.1s · numbers_ok = null (expected true); markdown has none of ['## 요약']
|
||||||
|
→ request POST /webhook/my-ops-brief
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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.1s · summary has none of ['DLQ', '릴리스', '카나리', '실행', '오류']
|
||||||
|
→ request POST /webhook/my-ops-brief
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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일차)"
|
||||||
|
}
|
||||||
|
Passed 0/2
|
||||||
|
report: runs/lab03-llm-20261101-185435.json
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab03-llm.json",
|
||||||
|
"webhook": "my-ops-brief",
|
||||||
|
"passed": 2,
|
||||||
|
"total": 2,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "요약 세 문장 · 숫자 확인",
|
||||||
|
"webhook": "my-ops-brief",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 3.5,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"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일차)\n\n## 요약\n\n전체 시스템 상태는 빨간색으로, 오류율은 5.88%이며 DLQ 크기는 3입니다. 가장 긴급한 문제는 'Search FAQ' 노드에서 발생하는 1000ms 타임아웃 오류로 16건이 보고되었습니다. 우선 이 타임아웃 문제를 해결하는 데 집중해야 합니다.",
|
||||||
|
"summary": "전체 시스템 상태는 빨간색으로, 오류율은 5.88%이며 DLQ 크기는 3입니다. 가장 긴급한 문제는 'Search FAQ' 노드에서 발생하는 1000ms 타임아웃 오류로 16건이 보고되었습니다. 우선 이 타임아웃 문제를 해결하는 데 집중해야 합니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "요약에 급한 곳",
|
||||||
|
"webhook": "my-ops-brief",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"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일차)\n\n## 요약\n\n전체 시스템 상태는 빨간색으로, 오류율은 5.88%이며, 응답하지 않은 비율은 4.76%입니다. 가장 긴급한 문제는 'Search FAQ' 노드에서 발생한 16건의 타임아웃 오류입니다. 우선 이 타임아웃 문제를 해결하는 데 집중해야 합니다.",
|
||||||
|
"summary": "전체 시스템 상태는 빨간색으로, 오류율은 5.88%이며, 응답하지 않은 비율은 4.76%입니다. 가장 긴급한 문제는 'Search FAQ' 노드에서 발생한 16건의 타임아웃 오류입니다. 우선 이 타임아웃 문제를 해결하는 데 집중해야 합니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
$ uv run tools/flow.py push reference/lab03-example2-brief.json
|
||||||
|
! unpublished '79 Ops brief (reference)' (w97397012b52e601): it used the same webhook path
|
||||||
|
✔ pushed '79 Ops brief + summary (reference)' (id w871e57398bd7b32)
|
||||||
|
POST http://localhost:5678/webhook/my-ops-brief
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab03-llm.json -v
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-ops-brief · 2 case(s) × 1
|
||||||
|
✔ 요약 세 문장 · 숫자 확인 · 3.5s
|
||||||
|
→ request POST /webhook/my-ops-brief
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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일차)\n\n## 요약\n\n전체 시스템 상태는 빨간색으로, 오류율은 5.88%이며 DLQ 크기는 3입니다. 가장 긴급한 문제는 'Search FAQ' 노드에서 발생하는 1000ms 타임아웃 오류로 16건이 보고되었습니다. 우선 이 타임아웃 문제를 해결하는 데 집중해야 합니다.",
|
||||||
|
"summary": "전체 시스템 상태는 빨간색으로, 오류율은 5.88%이며 DLQ 크기는 3입니다. 가장 긴급한 문제는 'Search FAQ' 노드에서 발생하는 1000ms 타임아웃 오류로 16건이 보고되었습니다. 우선 이 타임아웃 문제를 해결하는 데 집중해야 합니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
✔ 요약에 급한 곳 · 1.1s
|
||||||
|
→ request POST /webhook/my-ops-brief
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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일차)\n\n## 요약\n\n전체 시스템 상태는 빨간색으로, 오류율은 5.88%이며, 응답하지 않은 비율은 4.76%입니다. 가장 긴급한 문제는 'Search FAQ' 노드에서 발생한 16건의 타임아웃 오류입니다. 우선 이 타임아웃 문제를 해결하는 데 집중해야 합니다.",
|
||||||
|
"summary": "전체 시스템 상태는 빨간색으로, 오류율은 5.88%이며, 응답하지 않은 비율은 4.76%입니다. 가장 긴급한 문제는 'Search FAQ' 노드에서 발생한 16건의 타임아웃 오류입니다. 우선 이 타임아웃 문제를 해결하는 데 집중해야 합니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
Passed 2/2
|
||||||
|
report: runs/lab03-llm-20261101-185454.json
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab03.json
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
POST http://localhost:5678/webhook/my-ops-brief · 4 case(s) × 1
|
||||||
|
✔ 전체 · 먼저 볼 것 셋 · 1.3s
|
||||||
|
✔ 표에 숫자 · 1.1s
|
||||||
|
✔ 할 일은 규칙에서 · 1.1s
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 3}
|
||||||
|
✔ DLQ를 비운 뒤 · 1.2s
|
||||||
|
Passed 4/4
|
||||||
|
report: runs/lab03-20261101-185459.json
|
||||||
|
✔ after: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab03-after.json",
|
||||||
|
"webhook": "my-ops-brief",
|
||||||
|
"passed": 5,
|
||||||
|
"total": 5,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "아침 그대로",
|
||||||
|
"webhook": "my-ops-brief",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.4,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"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일차)\n\n## 요약\n\n전체 시스템 상태는 빨간색으로, 오류율은 5.88%이며, DLQ 크기는 3으로 심각한 문제를 나타냅니다. 가장 긴급한 문제는 \"Search FAQ\" 노드에서 발생하는 1000ms 타임아웃 오류로, 총 16건이 보고되었습니다. 우선 이 타임아웃 문제를 해결하는 데 집중하여 시스템 안정성을 확보해야 합니다.",
|
||||||
|
"summary": "전체 시스템 상태는 빨간색으로, 오류율은 5.88%이며, DLQ 크기는 3으로 심각한 문제를 나타냅니다. 가장 긴급한 문제는 \"Search FAQ\" 노드에서 발생하는 1000ms 타임아웃 오류로, 총 16건이 보고되었습니다. 우선 이 타임아웃 문제를 해결하는 데 집중하여 시스템 안정성을 확보해야 합니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "parked 둘을 처리하면 → DLQ는 노랑, 먼저 볼 것에 알림",
|
||||||
|
"webhook": "my-ops-brief",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"overall": "red",
|
||||||
|
"red": [
|
||||||
|
"runs",
|
||||||
|
"release"
|
||||||
|
],
|
||||||
|
"yellow": [
|
||||||
|
"alerts",
|
||||||
|
"dlq",
|
||||||
|
"quality",
|
||||||
|
"cost"
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"area": "runs",
|
||||||
|
"status": "red",
|
||||||
|
"action": "오류 묶음 1위 확인 · 고치기 (77일차)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "release",
|
||||||
|
"status": "red",
|
||||||
|
"action": "카나리 끄기 · 되돌리기 검토 (74일차)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "alerts",
|
||||||
|
"status": "yellow",
|
||||||
|
"action": "높은 알림부터 확인하고 닫기 (76일차)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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 | 🟡 | 1건 (parked 0) |\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. 🔴 릴리스: 카나리 끄기 · 되돌리기 검토 (74일차)\n3. 🟡 알림: 높은 알림부터 확인하고 닫기 (76일차)\n\n## 요약\n\n전체 시스템 상태는 빨간색으로, 오류율은 5.88%로 나타났습니다. 가장 긴급한 문제는 'Search FAQ' 노드에서 발생한 16건의 타임아웃 오류입니다. 우선 이 타임아웃 문제를 해결하는 데 집중해야 합니다.",
|
||||||
|
"summary": "전체 시스템 상태는 빨간색으로, 오류율은 5.88%로 나타났습니다. 가장 긴급한 문제는 'Search FAQ' 노드에서 발생한 16건의 타임아웃 오류입니다. 우선 이 타임아웃 문제를 해결하는 데 집중해야 합니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "카나리를 끄면 → 릴리스는 초록",
|
||||||
|
"webhook": "my-ops-brief",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"overall": "red",
|
||||||
|
"red": [
|
||||||
|
"runs"
|
||||||
|
],
|
||||||
|
"yellow": [
|
||||||
|
"alerts",
|
||||||
|
"dlq",
|
||||||
|
"quality",
|
||||||
|
"cost"
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"area": "runs",
|
||||||
|
"status": "red",
|
||||||
|
"action": "오류 묶음 1위 확인 · 고치기 (77일차)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "alerts",
|
||||||
|
"status": "yellow",
|
||||||
|
"action": "높은 알림부터 확인하고 닫기 (76일차)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "dlq",
|
||||||
|
"status": "yellow",
|
||||||
|
"action": "parked 건 원인 확인 뒤 런북대로 재처리 (76일차)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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 | 🟡 | 1건 (parked 0) |\n| 데이터 품질 | 🟡 | 평균 0.6583 · 마지막 1 · 격리 1 |\n| 비용 | 🟡 | $0.073604 / $0.08 (92.01%) |\n| 릴리스 | 🟢 | 1.1.0 |\n\n## 먼저 볼 것\n\n1. 🔴 실행: 오류 묶음 1위 확인 · 고치기 (77일차)\n2. 🟡 알림: 높은 알림부터 확인하고 닫기 (76일차)\n3. 🟡 DLQ: parked 건 원인 확인 뒤 런북대로 재처리 (76일차)\n\n## 요약\n\n전체 시스템 상태는 빨간색으로, 오류율은 5.88%로 나타났습니다. 가장 긴급한 문제는 'Search FAQ' 노드에서 발생한 16건의 타임아웃 오류입니다. 우선 이 타임아웃 문제를 해결하는 데 집중해야 합니다.",
|
||||||
|
"summary": "전체 시스템 상태는 빨간색으로, 오류율은 5.88%로 나타났습니다. 가장 긴급한 문제는 'Search FAQ' 노드에서 발생한 16건의 타임아웃 오류입니다. 우선 이 타임아웃 문제를 해결하는 데 집중해야 합니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "예산을 올리면 → 비용도 초록",
|
||||||
|
"webhook": "my-ops-brief",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"overall": "red",
|
||||||
|
"red": [
|
||||||
|
"runs"
|
||||||
|
],
|
||||||
|
"yellow": [
|
||||||
|
"alerts",
|
||||||
|
"dlq",
|
||||||
|
"quality"
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"area": "runs",
|
||||||
|
"status": "red",
|
||||||
|
"action": "오류 묶음 1위 확인 · 고치기 (77일차)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "alerts",
|
||||||
|
"status": "yellow",
|
||||||
|
"action": "높은 알림부터 확인하고 닫기 (76일차)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "dlq",
|
||||||
|
"status": "yellow",
|
||||||
|
"action": "parked 건 원인 확인 뒤 런북대로 재처리 (76일차)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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 | 🟡 | 1건 (parked 0) |\n| 데이터 품질 | 🟡 | 평균 0.6583 · 마지막 1 · 격리 1 |\n| 비용 | 🟢 | $0.073604 / $0.2 (36.8%) |\n| 릴리스 | 🟢 | 1.1.0 |\n\n## 먼저 볼 것\n\n1. 🔴 실행: 오류 묶음 1위 확인 · 고치기 (77일차)\n2. 🟡 알림: 높은 알림부터 확인하고 닫기 (76일차)\n3. 🟡 DLQ: parked 건 원인 확인 뒤 런북대로 재처리 (76일차)\n\n## 요약\n\n전체 시스템 상태는 빨간색으로, 오류율은 5.88%이며 응답하지 않은 비율은 4.76%입니다. 가장 긴급한 문제는 \"Search FAQ\" 노드에서 1000ms 타임아웃 초과가 16건 발생한 것입니다. 우선 이 타임아웃 문제를 해결하는 데 집중해야 합니다.",
|
||||||
|
"summary": "전체 시스템 상태는 빨간색으로, 오류율은 5.88%이며 응답하지 않은 비율은 4.76%입니다. 가장 긴급한 문제는 \"Search FAQ\" 노드에서 1000ms 타임아웃 초과가 16건 발생한 것입니다. 우선 이 타임아웃 문제를 해결하는 데 집중해야 합니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "남은 빨강은 실행 하나 (사람이 고칠 일)",
|
||||||
|
"webhook": "my-ops-brief",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.2,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"overall": "red",
|
||||||
|
"red": [
|
||||||
|
"runs"
|
||||||
|
],
|
||||||
|
"yellow": [
|
||||||
|
"alerts",
|
||||||
|
"dlq",
|
||||||
|
"quality"
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"area": "runs",
|
||||||
|
"status": "red",
|
||||||
|
"action": "오류 묶음 1위 확인 · 고치기 (77일차)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "alerts",
|
||||||
|
"status": "yellow",
|
||||||
|
"action": "높은 알림부터 확인하고 닫기 (76일차)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "dlq",
|
||||||
|
"status": "yellow",
|
||||||
|
"action": "parked 건 원인 확인 뒤 런북대로 재처리 (76일차)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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 | 🟡 | 1건 (parked 0) |\n| 데이터 품질 | 🟡 | 평균 0.6583 · 마지막 1 · 격리 1 |\n| 비용 | 🟢 | $0.073604 / $0.2 (36.8%) |\n| 릴리스 | 🟢 | 1.1.0 |\n\n## 먼저 볼 것\n\n1. 🔴 실행: 오류 묶음 1위 확인 · 고치기 (77일차)\n2. 🟡 알림: 높은 알림부터 확인하고 닫기 (76일차)\n3. 🟡 DLQ: parked 건 원인 확인 뒤 런북대로 재처리 (76일차)\n\n## 요약\n\n전체 시스템 상태는 빨간색으로, 오류율은 5.88%로 나타났습니다. 가장 긴급한 문제는 'Search FAQ' 노드에서 발생한 16건의 타임아웃 오류입니다. 우선 이 타임아웃 문제를 해결하는 데 집중해야 합니다.",
|
||||||
|
"summary": "전체 시스템 상태는 빨간색으로, 오류율은 5.88%로 나타났습니다. 가장 긴급한 문제는 'Search FAQ' 노드에서 발생한 16건의 타임아웃 오류입니다. 우선 이 타임아웃 문제를 해결하는 데 집중해야 합니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
$ uv run tools/flow.py test cases/lab03-after.json -v
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-ops-brief · 5 case(s) × 1
|
||||||
|
✔ 아침 그대로 · 1.4s
|
||||||
|
→ request POST /webhook/my-ops-brief
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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일차)\n\n## 요약\n\n전체 시스템 상태는 빨간색으로, 오류율은 5.88%이며, DLQ 크기는 3으로 심각한 문제를 나타냅니다. 가장 긴급한 문제는 \"Search FAQ\" 노드에서 발생하는 1000ms 타임아웃 오류로, 총 16건이 보고되었습니다. 우선 이 타임아웃 문제를 해결하는 데 집중하여 시스템 안정성을 확보해야 합니다.",
|
||||||
|
"summary": "전체 시스템 상태는 빨간색으로, 오류율은 5.88%이며, DLQ 크기는 3으로 심각한 문제를 나타냅니다. 가장 긴급한 문제는 \"Search FAQ\" 노드에서 발생하는 1000ms 타임아웃 오류로, 총 16건이 보고되었습니다. 우선 이 타임아웃 문제를 해결하는 데 집중하여 시스템 안정성을 확보해야 합니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq/update · HTTP 200 · 0.0s · {"removed": true, "size": 2}
|
||||||
|
→ request POST http://localhost:8800/dlq/update
|
||||||
|
{
|
||||||
|
"dlq_id": 1,
|
||||||
|
"remove": true
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"removed": true,
|
||||||
|
"size": 2
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq/update · HTTP 200 · 0.0s · {"removed": true, "size": 1}
|
||||||
|
→ request POST http://localhost:8800/dlq/update
|
||||||
|
{
|
||||||
|
"dlq_id": 2,
|
||||||
|
"remove": true
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"removed": true,
|
||||||
|
"size": 1
|
||||||
|
}
|
||||||
|
✔ parked 둘을 처리하면 → DLQ는 노랑, 먼저 볼 것에 알림 · 1.0s
|
||||||
|
→ request POST /webhook/my-ops-brief
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"overall": "red",
|
||||||
|
"red": [
|
||||||
|
"runs",
|
||||||
|
"release"
|
||||||
|
],
|
||||||
|
"yellow": [
|
||||||
|
"alerts",
|
||||||
|
"dlq",
|
||||||
|
"quality",
|
||||||
|
"cost"
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"area": "runs",
|
||||||
|
"status": "red",
|
||||||
|
"action": "오류 묶음 1위 확인 · 고치기 (77일차)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "release",
|
||||||
|
"status": "red",
|
||||||
|
"action": "카나리 끄기 · 되돌리기 검토 (74일차)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "alerts",
|
||||||
|
"status": "yellow",
|
||||||
|
"action": "높은 알림부터 확인하고 닫기 (76일차)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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 | 🟡 | 1건 (parked 0) |\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. 🔴 릴리스: 카나리 끄기 · 되돌리기 검토 (74일차)\n3. 🟡 알림: 높은 알림부터 확인하고 닫기 (76일차)\n\n## 요약\n\n전체 시스템 상태는 빨간색으로, 오류율은 5.88%로 나타났습니다. 가장 긴급한 문제는 'Search FAQ' 노드에서 발생한 16건의 타임아웃 오류입니다. 우선 이 타임아웃 문제를 해결하는 데 집중해야 합니다.",
|
||||||
|
"summary": "전체 시스템 상태는 빨간색으로, 오류율은 5.88%로 나타났습니다. 가장 긴급한 문제는 'Search FAQ' 노드에서 발생한 16건의 타임아웃 오류입니다. 우선 이 타임아웃 문제를 해결하는 데 집중해야 합니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/releases/helpdesk · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.1.0", "canary": null, "require_gate": false, "releases": {"1.0.0": {"prompt_version":
|
||||||
|
→ request POST http://localhost:8800/releases/helpdesk
|
||||||
|
{
|
||||||
|
"canary": null
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"name": "helpdesk",
|
||||||
|
"active": "1.1.0",
|
||||||
|
"canary": null,
|
||||||
|
"require_gate": false,
|
||||||
|
"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": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
✔ 카나리를 끄면 → 릴리스는 초록 · 1.1s
|
||||||
|
→ request POST /webhook/my-ops-brief
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"overall": "red",
|
||||||
|
"red": [
|
||||||
|
"runs"
|
||||||
|
],
|
||||||
|
"yellow": [
|
||||||
|
"alerts",
|
||||||
|
"dlq",
|
||||||
|
"quality",
|
||||||
|
"cost"
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"area": "runs",
|
||||||
|
"status": "red",
|
||||||
|
"action": "오류 묶음 1위 확인 · 고치기 (77일차)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "alerts",
|
||||||
|
"status": "yellow",
|
||||||
|
"action": "높은 알림부터 확인하고 닫기 (76일차)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "dlq",
|
||||||
|
"status": "yellow",
|
||||||
|
"action": "parked 건 원인 확인 뒤 런북대로 재처리 (76일차)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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 | 🟡 | 1건 (parked 0) |\n| 데이터 품질 | 🟡 | 평균 0.6583 · 마지막 1 · 격리 1 |\n| 비용 | 🟡 | $0.073604 / $0.08 (92.01%) |\n| 릴리스 | 🟢 | 1.1.0 |\n\n## 먼저 볼 것\n\n1. 🔴 실행: 오류 묶음 1위 확인 · 고치기 (77일차)\n2. 🟡 알림: 높은 알림부터 확인하고 닫기 (76일차)\n3. 🟡 DLQ: parked 건 원인 확인 뒤 런북대로 재처리 (76일차)\n\n## 요약\n\n전체 시스템 상태는 빨간색으로, 오류율은 5.88%로 나타났습니다. 가장 긴급한 문제는 'Search FAQ' 노드에서 발생한 16건의 타임아웃 오류입니다. 우선 이 타임아웃 문제를 해결하는 데 집중해야 합니다.",
|
||||||
|
"summary": "전체 시스템 상태는 빨간색으로, 오류율은 5.88%로 나타났습니다. 가장 긴급한 문제는 'Search FAQ' 노드에서 발생한 16건의 타임아웃 오류입니다. 우선 이 타임아웃 문제를 해결하는 데 집중해야 합니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/budget · HTTP 200 · 0.0s · {"daily_usd": 0.2, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0.073604, "remaining_usd": 0.126396,
|
||||||
|
→ request POST http://localhost:8800/budget
|
||||||
|
{
|
||||||
|
"daily_usd": 0.2
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"daily_usd": 0.2,
|
||||||
|
"user_limit_per_min": 20,
|
||||||
|
"max_question_chars": 500,
|
||||||
|
"used_usd": 0.073604,
|
||||||
|
"remaining_usd": 0.126396,
|
||||||
|
"over": false
|
||||||
|
}
|
||||||
|
✔ 예산을 올리면 → 비용도 초록 · 1.1s
|
||||||
|
→ request POST /webhook/my-ops-brief
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"overall": "red",
|
||||||
|
"red": [
|
||||||
|
"runs"
|
||||||
|
],
|
||||||
|
"yellow": [
|
||||||
|
"alerts",
|
||||||
|
"dlq",
|
||||||
|
"quality"
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"area": "runs",
|
||||||
|
"status": "red",
|
||||||
|
"action": "오류 묶음 1위 확인 · 고치기 (77일차)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "alerts",
|
||||||
|
"status": "yellow",
|
||||||
|
"action": "높은 알림부터 확인하고 닫기 (76일차)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "dlq",
|
||||||
|
"status": "yellow",
|
||||||
|
"action": "parked 건 원인 확인 뒤 런북대로 재처리 (76일차)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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 | 🟡 | 1건 (parked 0) |\n| 데이터 품질 | 🟡 | 평균 0.6583 · 마지막 1 · 격리 1 |\n| 비용 | 🟢 | $0.073604 / $0.2 (36.8%) |\n| 릴리스 | 🟢 | 1.1.0 |\n\n## 먼저 볼 것\n\n1. 🔴 실행: 오류 묶음 1위 확인 · 고치기 (77일차)\n2. 🟡 알림: 높은 알림부터 확인하고 닫기 (76일차)\n3. 🟡 DLQ: parked 건 원인 확인 뒤 런북대로 재처리 (76일차)\n\n## 요약\n\n전체 시스템 상태는 빨간색으로, 오류율은 5.88%이며 응답하지 않은 비율은 4.76%입니다. 가장 긴급한 문제는 \"Search FAQ\" 노드에서 1000ms 타임아웃 초과가 16건 발생한 것입니다. 우선 이 타임아웃 문제를 해결하는 데 집중해야 합니다.",
|
||||||
|
"summary": "전체 시스템 상태는 빨간색으로, 오류율은 5.88%이며 응답하지 않은 비율은 4.76%입니다. 가장 긴급한 문제는 \"Search FAQ\" 노드에서 1000ms 타임아웃 초과가 16건 발생한 것입니다. 우선 이 타임아웃 문제를 해결하는 데 집중해야 합니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
✔ 남은 빨강은 실행 하나 (사람이 고칠 일) · 1.2s
|
||||||
|
→ request POST /webhook/my-ops-brief
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"overall": "red",
|
||||||
|
"red": [
|
||||||
|
"runs"
|
||||||
|
],
|
||||||
|
"yellow": [
|
||||||
|
"alerts",
|
||||||
|
"dlq",
|
||||||
|
"quality"
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"area": "runs",
|
||||||
|
"status": "red",
|
||||||
|
"action": "오류 묶음 1위 확인 · 고치기 (77일차)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "alerts",
|
||||||
|
"status": "yellow",
|
||||||
|
"action": "높은 알림부터 확인하고 닫기 (76일차)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "dlq",
|
||||||
|
"status": "yellow",
|
||||||
|
"action": "parked 건 원인 확인 뒤 런북대로 재처리 (76일차)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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 | 🟡 | 1건 (parked 0) |\n| 데이터 품질 | 🟡 | 평균 0.6583 · 마지막 1 · 격리 1 |\n| 비용 | 🟢 | $0.073604 / $0.2 (36.8%) |\n| 릴리스 | 🟢 | 1.1.0 |\n\n## 먼저 볼 것\n\n1. 🔴 실행: 오류 묶음 1위 확인 · 고치기 (77일차)\n2. 🟡 알림: 높은 알림부터 확인하고 닫기 (76일차)\n3. 🟡 DLQ: parked 건 원인 확인 뒤 런북대로 재처리 (76일차)\n\n## 요약\n\n전체 시스템 상태는 빨간색으로, 오류율은 5.88%로 나타났습니다. 가장 긴급한 문제는 'Search FAQ' 노드에서 발생한 16건의 타임아웃 오류입니다. 우선 이 타임아웃 문제를 해결하는 데 집중해야 합니다.",
|
||||||
|
"summary": "전체 시스템 상태는 빨간색으로, 오류율은 5.88%로 나타났습니다. 가장 긴급한 문제는 'Search FAQ' 노드에서 발생한 16건의 타임아웃 오류입니다. 우선 이 타임아웃 문제를 해결하는 데 집중해야 합니다.",
|
||||||
|
"numbers_ok": true,
|
||||||
|
"unknown_numbers": []
|
||||||
|
}
|
||||||
|
Passed 5/5
|
||||||
|
report: runs/lab03-after-20261101-185505.json
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab03-challenge.json",
|
||||||
|
"webhook": "my-ops-eval",
|
||||||
|
"passed": 0,
|
||||||
|
"total": 3,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "골든 질문 8개",
|
||||||
|
"webhook": "my-ops-eval",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"status 404 (expected 200)",
|
||||||
|
"total = null (expected 8)",
|
||||||
|
"area_ok = null (expected 8)",
|
||||||
|
"refused_ok = null (expected 8)",
|
||||||
|
"score = null (expected 0.875..1)"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 404,
|
||||||
|
"request": {
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"code": 404,
|
||||||
|
"message": "The requested webhook \"POST my-ops-eval\" is not registered.",
|
||||||
|
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "틀린 기대는 잡아냄",
|
||||||
|
"webhook": "my-ops-eval",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"status 404 (expected 200)",
|
||||||
|
"total = null (expected 1)",
|
||||||
|
"passed = null (expected 0)",
|
||||||
|
"failed.0.area = null (expected alerts)",
|
||||||
|
"failed.0.area_ok = null (expected false)"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 404,
|
||||||
|
"request": {
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"question": "알림이 몇 건 와 있어?",
|
||||||
|
"area": "cost",
|
||||||
|
"numbers": [
|
||||||
|
"6"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"code": 404,
|
||||||
|
"message": "The requested webhook \"POST my-ops-eval\" is not registered.",
|
||||||
|
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "빈 목록 → 400",
|
||||||
|
"webhook": "my-ops-eval",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"status 404 (expected 400)"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 404,
|
||||||
|
"request": {
|
||||||
|
"cases": []
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"code": 404,
|
||||||
|
"message": "The requested webhook \"POST my-ops-eval\" is not registered.",
|
||||||
|
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
$ uv run tools/flow.py test cases/lab03-challenge.json -v
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-ops-eval · 3 case(s) × 1
|
||||||
|
✘ 골든 질문 8개 · 0.0s · status 404 (expected 200); total = null (expected 8); area_ok = null (expected 8); refused_ok = null (expected 8); score = null (expected 0.875..1)
|
||||||
|
→ request POST /webhook/my-ops-eval
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 404
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"message": "The requested webhook \"POST my-ops-eval\" is not registered.",
|
||||||
|
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||||
|
}
|
||||||
|
✘ 틀린 기대는 잡아냄 · 0.0s · status 404 (expected 200); total = null (expected 1); passed = null (expected 0); failed.0.area = null (expected alerts); failed.0.area_ok = null (expected false)
|
||||||
|
→ request POST /webhook/my-ops-eval
|
||||||
|
{
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"question": "알림이 몇 건 와 있어?",
|
||||||
|
"area": "cost",
|
||||||
|
"numbers": [
|
||||||
|
"6"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 404
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"message": "The requested webhook \"POST my-ops-eval\" is not registered.",
|
||||||
|
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||||
|
}
|
||||||
|
✘ 빈 목록 → 400 · 0.0s · status 404 (expected 400)
|
||||||
|
→ request POST /webhook/my-ops-eval
|
||||||
|
{
|
||||||
|
"cases": []
|
||||||
|
}
|
||||||
|
← response HTTP 404
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"message": "The requested webhook \"POST my-ops-eval\" is not registered.",
|
||||||
|
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||||
|
}
|
||||||
|
Passed 0/3
|
||||||
|
report: runs/lab03-challenge-20261101-185505.json
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab03-challenge.json",
|
||||||
|
"webhook": "my-ops-eval",
|
||||||
|
"passed": 3,
|
||||||
|
"total": 3,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "골든 질문 8개",
|
||||||
|
"webhook": "my-ops-eval",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 3.9,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 8,
|
||||||
|
"passed": 8,
|
||||||
|
"score": 1,
|
||||||
|
"area_ok": 8,
|
||||||
|
"refused_ok": 8,
|
||||||
|
"number_ok": 8,
|
||||||
|
"grounded": 8,
|
||||||
|
"failed": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "틀린 기대는 잡아냄",
|
||||||
|
"webhook": "my-ops-eval",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"question": "알림이 몇 건 와 있어?",
|
||||||
|
"area": "cost",
|
||||||
|
"numbers": [
|
||||||
|
"6"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"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건은 높은 심각도 알림입니다."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "빈 목록 → 400",
|
||||||
|
"webhook": "my-ops-eval",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 400,
|
||||||
|
"request": {
|
||||||
|
"cases": []
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"error": "cases must be a non-empty list"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
$ uv run tools/flow.py push answers/79-lab03-challenge-eval.json
|
||||||
|
! unpublished '79 Ops assistant eval + gate (answer)' (wb750a2ccc06d9a1): it used the same webhook path
|
||||||
|
✔ pushed '79 Ops assistant eval (answer)' (id w7803df55a337611)
|
||||||
|
POST http://localhost:5678/webhook/my-ops-eval
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab03-challenge.json -v
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-ops-eval · 3 case(s) × 1
|
||||||
|
✔ 골든 질문 8개 · 3.9s
|
||||||
|
→ request POST /webhook/my-ops-eval
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 8,
|
||||||
|
"passed": 8,
|
||||||
|
"score": 1,
|
||||||
|
"area_ok": 8,
|
||||||
|
"refused_ok": 8,
|
||||||
|
"number_ok": 8,
|
||||||
|
"grounded": 8,
|
||||||
|
"failed": []
|
||||||
|
}
|
||||||
|
✔ 틀린 기대는 잡아냄 · 1.0s
|
||||||
|
→ request POST /webhook/my-ops-eval
|
||||||
|
{
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"question": "알림이 몇 건 와 있어?",
|
||||||
|
"area": "cost",
|
||||||
|
"numbers": [
|
||||||
|
"6"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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건은 높은 심각도 알림입니다."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ 빈 목록 → 400 · 0.0s
|
||||||
|
→ request POST /webhook/my-ops-eval
|
||||||
|
{
|
||||||
|
"cases": []
|
||||||
|
}
|
||||||
|
← response HTTP 400
|
||||||
|
{
|
||||||
|
"error": "cases must be a non-empty list"
|
||||||
|
}
|
||||||
|
Passed 3/3
|
||||||
|
report: runs/lab03-challenge-20261101-185725.json
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab03-challenge2.json",
|
||||||
|
"webhook": "my-ops-eval",
|
||||||
|
"passed": 0,
|
||||||
|
"total": 4,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "기준을 안 주면 0.9",
|
||||||
|
"webhook": "my-ops-eval",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"min_score = null (expected 0.9)",
|
||||||
|
"gate = null (expected pass)"
|
||||||
|
],
|
||||||
|
"seconds": 1.2,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"question": "알림이 몇 건 와 있어?",
|
||||||
|
"area": "alerts",
|
||||||
|
"numbers": [
|
||||||
|
"6"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 1,
|
||||||
|
"passed": 1,
|
||||||
|
"score": 1,
|
||||||
|
"area_ok": 1,
|
||||||
|
"refused_ok": 1,
|
||||||
|
"number_ok": 1,
|
||||||
|
"grounded": 1,
|
||||||
|
"failed": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "골든 8 · 기준 0.8 → 통과",
|
||||||
|
"webhook": "my-ops-eval",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"gate = null (expected pass)",
|
||||||
|
"min_score = null (expected 0.8)"
|
||||||
|
],
|
||||||
|
"seconds": 2.7,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"min_score": 0.8
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 8,
|
||||||
|
"passed": 8,
|
||||||
|
"score": 1,
|
||||||
|
"area_ok": 8,
|
||||||
|
"refused_ok": 8,
|
||||||
|
"number_ok": 8,
|
||||||
|
"grounded": 8,
|
||||||
|
"failed": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "틀린 기대 · 기준 0.9 → 실패",
|
||||||
|
"webhook": "my-ops-eval",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"gate = null (expected fail)"
|
||||||
|
],
|
||||||
|
"seconds": 1.2,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"question": "알림이 몇 건 와 있어?",
|
||||||
|
"area": "cost",
|
||||||
|
"numbers": [
|
||||||
|
"6"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"min_score": 0.9
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"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건이 있습니다."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "실패하면 알림 한 건",
|
||||||
|
"webhook": "http://localhost:8800/alerts/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"count = 0 (expected 1)",
|
||||||
|
"alerts.0.workflow = null (expected ops-eval)",
|
||||||
|
"alerts.0.score = null (expected 0)"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"reason": "eval_below"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"count": 0,
|
||||||
|
"alerts": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
$ uv run tools/flow.py test cases/lab03-challenge2.json -v
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-ops-eval · 4 case(s) × 1
|
||||||
|
✘ 기준을 안 주면 0.9 · 1.2s · min_score = null (expected 0.9); gate = null (expected pass)
|
||||||
|
→ request POST /webhook/my-ops-eval
|
||||||
|
{
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"question": "알림이 몇 건 와 있어?",
|
||||||
|
"area": "alerts",
|
||||||
|
"numbers": [
|
||||||
|
"6"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 1,
|
||||||
|
"passed": 1,
|
||||||
|
"score": 1,
|
||||||
|
"area_ok": 1,
|
||||||
|
"refused_ok": 1,
|
||||||
|
"number_ok": 1,
|
||||||
|
"grounded": 1,
|
||||||
|
"failed": []
|
||||||
|
}
|
||||||
|
✘ 골든 8 · 기준 0.8 → 통과 · 2.7s · gate = null (expected pass); min_score = null (expected 0.8)
|
||||||
|
→ request POST /webhook/my-ops-eval
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"min_score": 0.8
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 8,
|
||||||
|
"passed": 8,
|
||||||
|
"score": 1,
|
||||||
|
"area_ok": 8,
|
||||||
|
"refused_ok": 8,
|
||||||
|
"number_ok": 8,
|
||||||
|
"grounded": 8,
|
||||||
|
"failed": []
|
||||||
|
}
|
||||||
|
✘ 틀린 기대 · 기준 0.9 → 실패 · 1.2s · gate = null (expected fail)
|
||||||
|
→ request POST /webhook/my-ops-eval
|
||||||
|
{
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"question": "알림이 몇 건 와 있어?",
|
||||||
|
"area": "cost",
|
||||||
|
"numbers": [
|
||||||
|
"6"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"min_score": 0.9
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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건이 있습니다."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✘ 실패하면 알림 한 건 · 0.0s · count = 0 (expected 1); alerts.0.workflow = null (expected ops-eval); alerts.0.score = null (expected 0)
|
||||||
|
→ request POST http://localhost:8800/alerts/list
|
||||||
|
{
|
||||||
|
"reason": "eval_below"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"count": 0,
|
||||||
|
"alerts": []
|
||||||
|
}
|
||||||
|
Passed 0/4
|
||||||
|
report: runs/lab03-challenge2-20261101-185730.json
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab03-challenge2.json",
|
||||||
|
"webhook": "my-ops-eval",
|
||||||
|
"passed": 4,
|
||||||
|
"total": 4,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "기준을 안 주면 0.9",
|
||||||
|
"webhook": "my-ops-eval",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 3.3,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"question": "알림이 몇 건 와 있어?",
|
||||||
|
"area": "alerts",
|
||||||
|
"numbers": [
|
||||||
|
"6"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 1,
|
||||||
|
"passed": 1,
|
||||||
|
"score": 1,
|
||||||
|
"area_ok": 1,
|
||||||
|
"refused_ok": 1,
|
||||||
|
"number_ok": 1,
|
||||||
|
"grounded": 1,
|
||||||
|
"failed": [],
|
||||||
|
"min_score": 0.9,
|
||||||
|
"gate": "pass"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "골든 8 · 기준 0.8 → 통과",
|
||||||
|
"webhook": "my-ops-eval",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 3.4,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"min_score": 0.8
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 8,
|
||||||
|
"passed": 8,
|
||||||
|
"score": 1,
|
||||||
|
"area_ok": 8,
|
||||||
|
"refused_ok": 8,
|
||||||
|
"number_ok": 8,
|
||||||
|
"grounded": 8,
|
||||||
|
"failed": [],
|
||||||
|
"min_score": 0.8,
|
||||||
|
"gate": "pass"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "틀린 기대 · 기준 0.9 → 실패",
|
||||||
|
"webhook": "my-ops-eval",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.2,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"question": "알림이 몇 건 와 있어?",
|
||||||
|
"area": "cost",
|
||||||
|
"numbers": [
|
||||||
|
"6"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"min_score": 0.9
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "실패하면 알림 한 건",
|
||||||
|
"webhook": "http://localhost:8800/alerts/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"reason": "eval_below"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"count": 1,
|
||||||
|
"alerts": [
|
||||||
|
{
|
||||||
|
"workflow": "ops-eval",
|
||||||
|
"node": "Below?",
|
||||||
|
"severity": "medium",
|
||||||
|
"reason": "eval_below",
|
||||||
|
"score": 0,
|
||||||
|
"min_score": 0.9,
|
||||||
|
"message": "ops assistant eval 0 < 0.9",
|
||||||
|
"at": 1793527072.438837
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
$ uv run tools/flow.py push answers/79-lab03-challenge2-gate.json
|
||||||
|
! unpublished '79 Ops assistant eval (answer)' (w7803df55a337611): it used the same webhook path
|
||||||
|
✔ pushed '79 Ops assistant eval + gate (answer)' (id wb750a2ccc06d9a1)
|
||||||
|
POST http://localhost:5678/webhook/my-ops-eval
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab03-challenge2.json -v
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
→ request POST http://localhost:8800/ops-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"seeded_at": "2026-11-04T08:30:00",
|
||||||
|
"note": "79일차 운영 아침 상태 (연습 데이터, seed 79)",
|
||||||
|
"counts": {
|
||||||
|
"alerts": 6,
|
||||||
|
"dlq": 3,
|
||||||
|
"quality_log": 4,
|
||||||
|
"quarantine": 1,
|
||||||
|
"usage": 192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-ops-eval · 4 case(s) × 1
|
||||||
|
✔ 기준을 안 주면 0.9 · 3.3s
|
||||||
|
→ request POST /webhook/my-ops-eval
|
||||||
|
{
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"question": "알림이 몇 건 와 있어?",
|
||||||
|
"area": "alerts",
|
||||||
|
"numbers": [
|
||||||
|
"6"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 1,
|
||||||
|
"passed": 1,
|
||||||
|
"score": 1,
|
||||||
|
"area_ok": 1,
|
||||||
|
"refused_ok": 1,
|
||||||
|
"number_ok": 1,
|
||||||
|
"grounded": 1,
|
||||||
|
"failed": [],
|
||||||
|
"min_score": 0.9,
|
||||||
|
"gate": "pass"
|
||||||
|
}
|
||||||
|
✔ 골든 8 · 기준 0.8 → 통과 · 3.4s
|
||||||
|
→ request POST /webhook/my-ops-eval
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"min_score": 0.8
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 8,
|
||||||
|
"passed": 8,
|
||||||
|
"score": 1,
|
||||||
|
"area_ok": 8,
|
||||||
|
"refused_ok": 8,
|
||||||
|
"number_ok": 8,
|
||||||
|
"grounded": 8,
|
||||||
|
"failed": [],
|
||||||
|
"min_score": 0.8,
|
||||||
|
"gate": "pass"
|
||||||
|
}
|
||||||
|
✔ 틀린 기대 · 기준 0.9 → 실패 · 1.2s
|
||||||
|
→ request POST /webhook/my-ops-eval
|
||||||
|
{
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"question": "알림이 몇 건 와 있어?",
|
||||||
|
"area": "cost",
|
||||||
|
"numbers": [
|
||||||
|
"6"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"min_score": 0.9
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"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.0s
|
||||||
|
→ request POST http://localhost:8800/alerts/list
|
||||||
|
{
|
||||||
|
"reason": "eval_below"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"count": 1,
|
||||||
|
"alerts": [
|
||||||
|
{
|
||||||
|
"workflow": "ops-eval",
|
||||||
|
"node": "Below?",
|
||||||
|
"severity": "medium",
|
||||||
|
"reason": "eval_below",
|
||||||
|
"score": 0,
|
||||||
|
"min_score": 0.9,
|
||||||
|
"message": "ops assistant eval 0 < 0.9",
|
||||||
|
"at": 1793527072.438837
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Passed 4/4
|
||||||
|
report: runs/lab03-challenge2-20261101-185752.json
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab03-challenge.json
|
||||||
|
✔ before: POST http://localhost:8800/ops-reset · HTTP 200 · 0.0s · {"seeded_at": "2026-11-04T08:30:00", "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", "counts": {"alerts": 6, "dlq": 3, "quali
|
||||||
|
POST http://localhost:5678/webhook/my-ops-eval · 3 case(s) × 1
|
||||||
|
✔ 골든 질문 8개 · 2.2s
|
||||||
|
✔ 틀린 기대는 잡아냄 · 1.1s
|
||||||
|
✔ 빈 목록 → 400 · 0.0s
|
||||||
|
Passed 3/3
|
||||||
|
report: runs/lab03-challenge-20261101-185755.json
|
||||||
|
|
||||||
|
$ uv run tools/flow.py morning
|
||||||
|
seeded morning 2026-11-04T08:30:00 · {"alerts": 7, "dlq": 3, "quality_log": 4, "quarantine": 1, "usage": 192}
|
||||||
|
alerts: 7 (high 3)
|
||||||
|
[high] helpdesk/Answer · rate_limit · 429 Rate limit reached for requests
|
||||||
|
[high] faq-import/Batch ok? · batch_rejected · B-1 rejected
|
||||||
|
[medium] faq-import/Low score? · low_quality · B-2 quality 0.8
|
||||||
|
[medium] faq-import/Low score? · low_quality · B-3 quality 0.8333
|
||||||
|
[medium] intake/Create ticket · api_error · tickets API 503
|
||||||
|
[medium] ops-eval/Below? · eval_below · ops assistant eval 0 < 0.9
|
||||||
|
DLQ: 3 (parked 2)
|
||||||
|
#1 attempts 3 parked True tickets API 503
|
||||||
|
#2 attempts 3 parked True tickets API 503
|
||||||
|
#3 attempts 1 parked False timeout
|
||||||
|
quality log: 4 (scores [0, 0.8, 0.8333, 1]) · quarantine 1
|
||||||
|
usage: 156 requests · 192 calls · $0.073604
|
||||||
|
budget: used $0.073604 of $0.08
|
||||||
|
release: active 1.1.0 · canary {"release": "1.2.0", "percent": 20}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py ops --reset
|
||||||
|
✔ back to the seeded operations morning (alerts · DLQ · quality · usage · budget · release), live logs · added FAQ · tickets emptied, fault switch all ok
|
||||||
|
|
||||||
|
$ uv run tools/flow.py doctor
|
||||||
|
! n8n install folder /Users/mcchae/day65-n8n
|
||||||
|
→ day 65 SETUP-N8N 5단계 (get.n8n.io 설치). 다른 곳에 설치했다면 Last3Mon/65/n8n/.env 에 N8N_HOME=<폴더>
|
||||||
|
✔ n8n container day65-n8n-n8n-1
|
||||||
|
✔ n8n http://localhost:5678
|
||||||
|
✔ data API http://localhost:8800
|
||||||
|
✔ dead letter queue /dlq · alerts · fault switch dlq (day 76 data API)
|
||||||
|
✔ run logs /logs (666 seeded runs) · FAQ additions /faq-extra (day 77 data API)
|
||||||
|
✔ schemas faq · employee · ticket, quarantine, quality log (day 78 data API)
|
||||||
|
✔ operations morning /ops/seed-info (day 79 data API)
|
||||||
|
✔ n8n → http://data-api:8800
|
||||||
|
✔ credential 'OpenAI (.env)' (openAiCourse0001)
|
||||||
|
✔ node catalog (for check)
|
||||||
|
✔ CLI coding agent: codex, claude, agy
|
||||||
|
ready
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
{
|
||||||
|
"name": "79 Ops facts + health (answer)",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"name": "Facts webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "my-ops-facts",
|
||||||
|
"responseMode": "responseNode",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"webhookId": "663cb969-bc5a-52e7-929e-7e0161c2806d",
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "9bcf9913-ebc6-5ee9-af9d-284d07aa672e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get logs",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://data-api:8800/logs/query",
|
||||||
|
"options": {},
|
||||||
|
"method": "POST",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ from: $('Facts webhook').first().json.body?.from || '2026-10-26', to: $('Facts webhook').first().json.body?.to || '2026-10-30' }) }}"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
220,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "2901463b-a9cd-5a60-a507-ee5d8c7e427b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get alerts",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://data-api:8800/alerts",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
440,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "83c33a5b-211d-5df2-812f-213bb1475e0d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get DLQ",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://data-api:8800/dlq",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
660,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "d4d021cb-61a5-57c7-acee-442d5a61c08a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get quality",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://data-api:8800/quality-log",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "868d3302-7097-5d73-bf80-23bd45e6872e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get quarantine",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://data-api:8800/quarantine",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1100,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "d6269b34-e578-5ee4-b7fe-3db2a9caead9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get usage",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://data-api:8800/usage/summary",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1320,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "8b616d0e-c4ae-586a-bde6-5030bf878fb7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get budget",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://data-api:8800/budget",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1540,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "9b25971c-75c1-5f81-8aa2-6e186fa1aa98"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get release",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://data-api:8800/releases/helpdesk",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1760,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "cf9221dc-d0d7-5b8a-8a59-027c2ea4a040"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Make facts",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const body = $('Facts webhook').first().json.body ?? {};\nconst from = body.from || '2026-10-26', to = body.to || '2026-10-30';\nconst r4 = (v) => Math.round(v * 10000) / 10000;\nconst logs = $('Get logs').first().json.logs ?? [];\nconst total = logs.length, errors = logs.filter((l) => l.status === 'error').length;\nconst d = logs.map((l) => l.duration_ms).sort((a, b) => a - b);\nconst p95 = d.length ? d[Math.max(0, Math.ceil(0.95 * d.length) - 1)] : 0;\nconst groups = {};\nfor (const l of logs.filter((x) => x.status === 'error')) { const k = l.error_node + '\\u0000' + l.error_message; groups[k] = (groups[k] ?? 0) + 1; }\nconst top = Object.entries(groups).sort((a, b) => b[1] - a[1])[0];\nconst rel = {};\nfor (const l of logs.filter((x) => x.route === '자동 답변')) { const x = (rel[l.release] ??= { answers: 0, repeats: 0 }); x.answers += 1; if (l.repeat) x.repeats += 1; }\nconst rate = (name) => rel[name] ? r4(rel[name].repeats / rel[name].answers) : null;\nconst alerts = $('Get alerts').first().json.alerts ?? [];\nconst byReason = {};\nfor (const a of alerts) byReason[a.reason ?? 'unknown'] = (byReason[a.reason ?? 'unknown'] ?? 0) + 1;\nconst dlq = $('Get DLQ').first().json.items ?? [];\nconst q = $('Get quality').first().json.items ?? [];\nconst quarantine = $('Get quarantine').first().json.size ?? 0;\nconst usage = $('Get usage').first().json, budget = $('Get budget').first().json;\nconst users = Object.entries(usage.by_user ?? {}).sort((a, b) => b[1].cost_usd - a[1].cost_usd);\nconst release = $('Get release').first().json;\nconst facts = {\n period: { from, to },\n runs: { total, errors, error_rate: total ? r4(errors / total) : 0, p95_ms: p95,\n unanswered_rate: total ? r4(logs.filter((l) => l.route === '담당자 연결').length / total) : 0,\n top_failure: top ? { node: top[0].split('\\u0000')[0], message: top[0].split('\\u0000')[1], count: top[1] } : null },\n alerts: { count: alerts.length, high: alerts.filter((a) => a.severity === 'high').length, by_reason: byReason },\n dlq: { size: dlq.length, parked: dlq.filter((x) => x.parked).length, waiting: dlq.filter((x) => !x.parked).length },\n quality: { batches: q.length, avg_score: q.length ? r4(q.reduce((s, x) => s + x.score, 0) / q.length) : 0,\n last_score: q.length ? q.at(-1).score : null, rejected_batches: q.filter((x) => x.rejected).length, quarantine },\n cost: { requests: usage.requests ?? 0, calls: usage.calls ?? 0, cost_usd: Math.round((usage.cost_usd ?? 0) * 1e6) / 1e6,\n daily_usd: budget.daily_usd, used_ratio: budget.daily_usd ? r4(budget.used_usd / budget.daily_usd) : 0,\n top_user: users.length ? { user: users[0][0], cost_usd: Math.round(users[0][1].cost_usd * 1e6) / 1e6, share: r4(users[0][1].cost_usd / usage.cost_usd) } : null },\n release: { active: release.active, canary_release: release.canary?.release ?? null, canary_percent: release.canary?.percent ?? 0,\n active_repeat_rate: rate(release.active), canary_repeat_rate: release.canary ? rate(release.canary.release) : null },\n};\nconst s = (red, yellow) => (red ? 'red' : yellow ? 'yellow' : 'green');\nconst f = facts;\nconst canaryBad = f.release.canary_release && f.release.canary_repeat_rate !== null && f.release.active_repeat_rate !== null\n && f.release.canary_repeat_rate > 2 * f.release.active_repeat_rate;\nconst health = {\n runs: s(f.runs.error_rate > 0.01, f.runs.unanswered_rate > 0.02),\n alerts: s(f.alerts.high >= 5, f.alerts.count >= 1),\n dlq: s(f.dlq.parked > 0, f.dlq.size > 0),\n quality: s(f.quality.last_score !== null && f.quality.last_score < 0.8, (f.quality.last_score !== null && f.quality.last_score < 0.9) || f.quality.quarantine > 0),\n cost: s(f.cost.used_ratio >= 1, f.cost.used_ratio >= 0.8),\n release: s(canaryBad, Boolean(f.release.canary_release)),\n};\nconst order = ['runs', 'alerts', 'dlq', 'quality', 'cost', 'release'];\nfacts.health = health;\nfacts.red = order.filter((k) => health[k] === 'red');\nfacts.yellow = order.filter((k) => health[k] === 'yellow');\nfacts.overall = facts.red.length ? 'red' : facts.yellow.length ? 'yellow' : 'green';\nreturn [{ json: facts }];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1980,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "6ea60af4-425b-593d-b50e-d6219a4cb3e0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Send facts",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ $json }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
2200,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "e38da5f3-bc63-5f44-b2dd-4689706c462c"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Facts webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get logs",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get logs": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get alerts",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get alerts": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get DLQ",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get DLQ": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get quality",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get quality": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get quarantine",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get quarantine": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get usage",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get usage": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get budget",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get budget": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get release",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get release": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Make facts",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Make facts": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Send facts",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
{
|
||||||
|
"name": "79 Ops facts + sources down (answer)",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"name": "Facts webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "my-ops-facts",
|
||||||
|
"responseMode": "responseNode",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"webhookId": "663cb969-bc5a-52e7-929e-7e0161c2806d",
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "9bcf9913-ebc6-5ee9-af9d-284d07aa672e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get logs",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://data-api:8800/logs/query",
|
||||||
|
"options": {},
|
||||||
|
"method": "POST",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ from: $('Facts webhook').first().json.body?.from || '2026-10-26', to: $('Facts webhook').first().json.body?.to || '2026-10-30' }) }}"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
220,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "2901463b-a9cd-5a60-a507-ee5d8c7e427b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get alerts",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://data-api:8800/alerts",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"neverError": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
440,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "83c33a5b-211d-5df2-812f-213bb1475e0d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get DLQ",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://data-api:8800/dlq",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"neverError": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
660,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "d4d021cb-61a5-57c7-acee-442d5a61c08a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get quality",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://data-api:8800/quality-log",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"neverError": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "868d3302-7097-5d73-bf80-23bd45e6872e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get quarantine",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://data-api:8800/quarantine",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"neverError": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1100,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "d6269b34-e578-5ee4-b7fe-3db2a9caead9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get usage",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://data-api:8800/usage/summary",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"neverError": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1320,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "8b616d0e-c4ae-586a-bde6-5030bf878fb7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get budget",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://data-api:8800/budget",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"neverError": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1540,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "9b25971c-75c1-5f81-8aa2-6e186fa1aa98"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get release",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://data-api:8800/releases/helpdesk",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"neverError": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1760,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "cf9221dc-d0d7-5b8a-8a59-027c2ea4a040"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Make facts",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const body = $('Facts webhook').first().json.body ?? {};\nconst from = body.from || '2026-10-26', to = body.to || '2026-10-30';\nconst r4 = (v) => Math.round(v * 10000) / 10000;\nconst logs = $('Get logs').first().json.logs ?? [];\nconst total = logs.length, errors = logs.filter((l) => l.status === 'error').length;\nconst d = logs.map((l) => l.duration_ms).sort((a, b) => a - b);\nconst p95 = d.length ? d[Math.max(0, Math.ceil(0.95 * d.length) - 1)] : 0;\nconst groups = {};\nfor (const l of logs.filter((x) => x.status === 'error')) { const k = l.error_node + '\\u0000' + l.error_message; groups[k] = (groups[k] ?? 0) + 1; }\nconst top = Object.entries(groups).sort((a, b) => b[1] - a[1])[0];\nconst rel = {};\nfor (const l of logs.filter((x) => x.route === '자동 답변')) { const x = (rel[l.release] ??= { answers: 0, repeats: 0 }); x.answers += 1; if (l.repeat) x.repeats += 1; }\nconst rate = (name) => rel[name] ? r4(rel[name].repeats / rel[name].answers) : null;\nconst alerts = $('Get alerts').first().json.alerts ?? [];\nconst byReason = {};\nfor (const a of alerts) byReason[a.reason ?? 'unknown'] = (byReason[a.reason ?? 'unknown'] ?? 0) + 1;\nconst dlq = $('Get DLQ').first().json.items ?? [];\nconst q = $('Get quality').first().json.items ?? [];\nconst quarantine = $('Get quarantine').first().json.size ?? 0;\nconst usage = $('Get usage').first().json, budget = $('Get budget').first().json;\nconst down = [];\nif (!Array.isArray($('Get alerts').first().json.alerts)) down.push('alerts');\nif (!Array.isArray($('Get DLQ').first().json.items)) down.push('dlq');\nif (!Array.isArray($('Get quality').first().json.items) || typeof $('Get quarantine').first().json.size !== 'number') down.push('quality');\nif (typeof usage.cost_usd !== 'number' || typeof budget.daily_usd !== 'number') down.push('cost');\nif (!('active' in ($('Get release').first().json ?? {}))) down.push('release');\nconst users = Object.entries(usage.by_user ?? {}).sort((a, b) => b[1].cost_usd - a[1].cost_usd);\nconst release = $('Get release').first().json;\nconst facts = {\n period: { from, to },\n runs: { total, errors, error_rate: total ? r4(errors / total) : 0, p95_ms: p95,\n unanswered_rate: total ? r4(logs.filter((l) => l.route === '담당자 연결').length / total) : 0,\n top_failure: top ? { node: top[0].split('\\u0000')[0], message: top[0].split('\\u0000')[1], count: top[1] } : null },\n alerts: { count: alerts.length, high: alerts.filter((a) => a.severity === 'high').length, by_reason: byReason },\n dlq: { size: dlq.length, parked: dlq.filter((x) => x.parked).length, waiting: dlq.filter((x) => !x.parked).length },\n quality: { batches: q.length, avg_score: q.length ? r4(q.reduce((s, x) => s + x.score, 0) / q.length) : 0,\n last_score: q.length ? q.at(-1).score : null, rejected_batches: q.filter((x) => x.rejected).length, quarantine },\n cost: { requests: usage.requests ?? 0, calls: usage.calls ?? 0, cost_usd: Math.round((usage.cost_usd ?? 0) * 1e6) / 1e6,\n daily_usd: budget.daily_usd, used_ratio: budget.daily_usd ? r4(budget.used_usd / budget.daily_usd) : 0,\n top_user: users.length ? { user: users[0][0], cost_usd: Math.round(users[0][1].cost_usd * 1e6) / 1e6, share: r4(users[0][1].cost_usd / usage.cost_usd) } : null },\n release: { active: release.active, canary_release: release.canary?.release ?? null, canary_percent: release.canary?.percent ?? 0,\n active_repeat_rate: rate(release.active), canary_repeat_rate: release.canary ? rate(release.canary.release) : null },\n};\nfor (const k of down) facts[k] = null;\nfacts.sources_down = down;\nconst s = (red, yellow) => (red ? 'red' : yellow ? 'yellow' : 'green');\nconst f = { ...facts, ...Object.fromEntries(down.map((k) => [k, {}])) };\nconst canaryBad = Boolean(f.release?.canary_release) && f.release.canary_repeat_rate !== null && f.release.active_repeat_rate !== null\n && f.release.canary_repeat_rate > 2 * f.release.active_repeat_rate;\nconst health0 = {\n runs: s(f.runs.error_rate > 0.01, f.runs.unanswered_rate > 0.02),\n alerts: s(f.alerts.high >= 5, f.alerts.count >= 1),\n dlq: s(f.dlq.parked > 0, f.dlq.size > 0),\n quality: s(f.quality.last_score !== null && f.quality.last_score < 0.8, (f.quality.last_score !== null && f.quality.last_score < 0.9) || f.quality.quarantine > 0),\n cost: s(f.cost.used_ratio >= 1, f.cost.used_ratio >= 0.8),\n release: s(canaryBad, Boolean(f.release.canary_release)),\n};\nconst order = ['runs', 'alerts', 'dlq', 'quality', 'cost', 'release'];\nconst health = Object.fromEntries(order.map((k) => [k, down.includes(k) ? 'unknown' : health0[k]]));\nfacts.health = health;\nfacts.red = order.filter((k) => health[k] === 'red');\nfacts.yellow = order.filter((k) => health[k] === 'yellow');\nfacts.unknown = order.filter((k) => health[k] === 'unknown');\nfacts.overall = facts.red.length ? 'red' : (facts.yellow.length || facts.unknown.length) ? 'yellow' : 'green';\nreturn [{ json: facts }];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1980,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "6ea60af4-425b-593d-b50e-d6219a4cb3e0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Send facts",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ $json }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
2200,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "e38da5f3-bc63-5f44-b2dd-4689706c462c"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Facts webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get logs",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get logs": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get alerts",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get alerts": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get DLQ",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get DLQ": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get quality",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get quality": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get quarantine",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get quarantine": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get usage",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get usage": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get budget",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get budget": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get release",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get release": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Make facts",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Make facts": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Send facts",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
{
|
||||||
|
"name": "79 Ops ask + LLM router (answer)",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"name": "Ask webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "my-ops-ask",
|
||||||
|
"responseMode": "responseNode",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"webhookId": "e0cc9a1d-38cf-580a-b52e-3f6d8bb1cab2",
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "a2d5e198-0074-5f7f-9cd6-5203c98b0bb1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Route",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const q = String($('Ask webhook').first().json.body?.question ?? '').trim();\nconst lower = q.toLowerCase();\nconst words = {\n runs: ['오류', '에러', '실패율', '느려', '느린', '응답 시간', 'p95', '실행'],\n alerts: ['알림', '경보'],\n dlq: ['dlq', '재처리', '쌓인', 'parked', '접수 실패'],\n quality: ['품질', '배치', '격리'],\n cost: ['비용', '돈', '예산', '토큰', '요금'],\n release: ['릴리스', '버전', '카나리', '배포'],\n overview: ['괜찮', '상태', '요약', '전체', '브리핑'],\n};\nconst action = /(다시 돌려|재처리해|지워|삭제해|꺼 ?줘|켜 ?줘|배포해|되돌려|롤백해|바꿔 ?줘|올려 ?줘|내려 ?줘|비워 ?줘)/.test(q);\nif (action) return [{ json: { question: q, area: 'action', router: 'keyword' } }];\nlet area = 'unknown', best = 0;\nfor (const [k, list] of Object.entries(words)) {\n const n = list.filter((w) => lower.includes(w)).length;\n if (n > best) { best = n; area = k; }\n}\nreturn [{ json: { question: q, area, router: 'keyword' } }];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
220,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "a5744b67-3600-5a8a-8607-d6c0896313f1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Action?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "loose",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"leftValue": "={{ $json.area === 'action' }}",
|
||||||
|
"rightValue": "",
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "true",
|
||||||
|
"singleValue": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
440,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "48f78459-b87c-527d-a569-b3e753e4c0d4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Refuse action",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ JSON.stringify({ question: $json.question, area: 'action', router: $json.router, refused: true, reason: 'read_only', answer: '운영 도우미는 읽기만 합니다. 재처리 · 삭제 · 배포 같은 작업은 런북에 따라 담당자가 직접 해 주세요.' }) }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
660,
|
||||||
|
560
|
||||||
|
],
|
||||||
|
"id": "6ede178d-153c-53d7-a773-9b2bda2af049"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Keyword hit?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "loose",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"leftValue": "={{ $json.area !== 'unknown' }}",
|
||||||
|
"rightValue": "",
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "true",
|
||||||
|
"singleValue": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
660,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "bd2aea65-6cee-5229-b19a-5ec357ba6bc1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Classify",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||||
|
"typeVersion": 3.1,
|
||||||
|
"parameters": {
|
||||||
|
"promptType": "define",
|
||||||
|
"text": "={{ $json.question }}",
|
||||||
|
"options": {
|
||||||
|
"systemMessage": "Classify an IT helpdesk operator's question (Korean) into one area of the operations data. Areas: runs (helpdesk runs: errors, failures, slowness, response time / 실행 · 오류 · 느림), alerts (alert messages / 알림), dlq (intake items that failed and wait for replay / 접수 못 한 것 · 남은 것 · 재처리 대기), quality (FAQ data batches, quality scores, quarantine / 데이터 품질 · FAQ 배치 · 격리), cost (LLM spending, money, budget, tokens / 비용 · 돈 · 지출 · 얼마 나갔나), release (versions, canary, rollout to some users / 버전 · 새 판 · 일부에게 먼저 내보냄), overview (is everything ok, general status), none (anything not about operating the helpdesk). Reply with only the area word.",
|
||||||
|
"maxIterations": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"onError": "continueRegularOutput",
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
140
|
||||||
|
],
|
||||||
|
"id": "6747acdd-c6b6-54a5-9707-f995bcf54be6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Classify model",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"parameters": {
|
||||||
|
"model": {
|
||||||
|
"__rl": true,
|
||||||
|
"mode": "id",
|
||||||
|
"value": "gpt-4.1-mini"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"temperature": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"credentials": {
|
||||||
|
"openAiApi": {
|
||||||
|
"id": "openAiCourse0001",
|
||||||
|
"name": "OpenAI (.env)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
-60
|
||||||
|
],
|
||||||
|
"id": "9e1b1e99-9563-50eb-ae41-edba7312bd00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Read class",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const r = $('Route').first().json;\nconst word = String($json.output ?? '').trim().toLowerCase().replace(/[^a-z]/g, '');\nconst known = ['runs', 'alerts', 'dlq', 'quality', 'cost', 'release', 'overview'];\nreturn [{ json: { ...r, area: known.includes(word) ? word : 'unknown', router: 'llm' } }];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1100,
|
||||||
|
140
|
||||||
|
],
|
||||||
|
"id": "a36bcee6-b885-5021-9b88-5321ed56c3b4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Routed",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "return [{ json: $input.first().json }];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1320,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "db234186-80da-5094-9fdc-9b85b9fffd56"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Known area?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "loose",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"leftValue": "={{ $json.area !== 'unknown' }}",
|
||||||
|
"rightValue": "",
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "true",
|
||||||
|
"singleValue": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1540,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "a904a046-a9d2-5f43-bc24-45c10bb803fb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Out of scope",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ JSON.stringify({ question: $json.question, area: 'unknown', router: $json.router, refused: true, reason: 'out_of_scope', answer: '운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다.' }) }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1760,
|
||||||
|
560
|
||||||
|
],
|
||||||
|
"id": "047a7d3f-bc56-5a24-ae65-8b2143880a44"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get facts",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://localhost:5678/webhook/my-ops-facts",
|
||||||
|
"options": {
|
||||||
|
"timeout": 30000
|
||||||
|
},
|
||||||
|
"method": "POST",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ from: $('Ask webhook').first().json.body?.from ?? '', to: $('Ask webhook').first().json.body?.to ?? '' }) }}"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1760,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "adea6e67-7abd-55a9-8659-98b7e285cce3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Pick facts",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const r = $('Routed').first().json;\nconst f = $('Get facts').first().json;\nconst part = r.area === 'overview' ? f : { period: f.period, [r.area]: f[r.area], ...(f.health ? { health: f.health[r.area] } : {}) };\nreturn [{ json: { ...r, facts: part } }];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1980,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "7550791a-4b6c-57a0-82a8-1f97ab9dbe79"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Answer",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||||
|
"typeVersion": 3.1,
|
||||||
|
"parameters": {
|
||||||
|
"promptType": "define",
|
||||||
|
"text": "=질문: {{ $json.question }}\n사실(JSON): {{ JSON.stringify($json.facts) }}",
|
||||||
|
"options": {
|
||||||
|
"systemMessage": "You are the operations assistant of a company IT helpdesk team. Answer the operator's question in Korean, 2-3 sentences, using only the facts JSON you are given. Use only numbers that appear in the facts (a ratio like 0.0588 may be written as 5.88%). Do not guess causes that are not in the facts. Do not offer to run any action; you can only read.",
|
||||||
|
"maxIterations": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
2200,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "83bb5d6e-92c7-5d43-bc11-78f9d4b23568"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Answer model",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"parameters": {
|
||||||
|
"model": {
|
||||||
|
"__rl": true,
|
||||||
|
"mode": "id",
|
||||||
|
"value": "gpt-4.1-mini"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"temperature": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"credentials": {
|
||||||
|
"openAiApi": {
|
||||||
|
"id": "openAiCourse0001",
|
||||||
|
"name": "OpenAI (.env)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
2200,
|
||||||
|
520
|
||||||
|
],
|
||||||
|
"id": "3e644835-aa33-5c70-b0aa-c18d300579b3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Check numbers",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const r = $('Pick facts').first().json;\nconst answer = String($json.output ?? '').trim();\nconst allowed = new Set();\nconst add = (v) => { for (const d of [0, 1, 2, 3, 4, 6]) allowed.add(Number(v.toFixed(d))); };\nconst walk = (v) => {\n if (typeof v === 'number') { add(v); add(v * 100); }\n else if (typeof v === 'string') { for (const m of v.match(/\\d+(?:\\.\\d+)?/g) ?? []) add(Number(m)); }\n else if (v && typeof v === 'object') { for (const [k, x] of Object.entries(v)) { walk(k); walk(x); } }\n};\nwalk(r.facts);\nconst versions = JSON.stringify(r.facts).match(/\\d+\\.\\d+\\.\\d+/g) ?? [];\nlet text = answer;\nfor (const v of versions) text = text.split(v).join(' ');\nconst unknown = (text.match(/\\d+(?:\\.\\d+)?/g) ?? []).filter((m) => !allowed.has(Number(m)));\nreturn [{ json: { question: r.question, area: r.area, router: r.router, refused: false, reason: null, answer,\n numbers_ok: unknown.length === 0, unknown_numbers: [...new Set(unknown)] } }];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
2420,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "51f5e16b-f31f-5606-8b33-7271c651764f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Send answer",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ $json }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
2640,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "a1f84cfd-5942-59f2-81c8-613559203d2a"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Ask webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Route",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Route": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Action?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Action?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Refuse action",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Keyword hit?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Keyword hit?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Routed",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Classify",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Classify model": {
|
||||||
|
"ai_languageModel": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Classify",
|
||||||
|
"type": "ai_languageModel",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Classify": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Read class",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Read class": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Routed",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Routed": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Known area?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Known area?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get facts",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Out of scope",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get facts": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Pick facts",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Pick facts": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Answer",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Answer model": {
|
||||||
|
"ai_languageModel": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Answer",
|
||||||
|
"type": "ai_languageModel",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Answer": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Check numbers",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Check numbers": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Send answer",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,628 @@
|
|||||||
|
{
|
||||||
|
"name": "79 Ops ask + audit (answer)",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"name": "Ask webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "my-ops-ask",
|
||||||
|
"responseMode": "responseNode",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"webhookId": "e0cc9a1d-38cf-580a-b52e-3f6d8bb1cab2",
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "a2d5e198-0074-5f7f-9cd6-5203c98b0bb1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Route",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const q = String($('Ask webhook').first().json.body?.question ?? '').trim();\nconst lower = q.toLowerCase();\nconst words = {\n runs: ['오류', '에러', '실패율', '느려', '느린', '응답 시간', 'p95', '실행'],\n alerts: ['알림', '경보'],\n dlq: ['dlq', '재처리', '쌓인', 'parked', '접수 실패'],\n quality: ['품질', '배치', '격리'],\n cost: ['비용', '돈', '예산', '토큰', '요금'],\n release: ['릴리스', '버전', '카나리', '배포'],\n overview: ['괜찮', '상태', '요약', '전체', '브리핑'],\n};\nconst action = /(다시 돌려|재처리해|지워|삭제해|꺼 ?줘|켜 ?줘|배포해|되돌려|롤백해|바꿔 ?줘|올려 ?줘|내려 ?줘|비워 ?줘)/.test(q);\nif (action) return [{ json: { question: q, area: 'action', router: 'keyword' } }];\nlet area = 'unknown', best = 0;\nfor (const [k, list] of Object.entries(words)) {\n const n = list.filter((w) => lower.includes(w)).length;\n if (n > best) { best = n; area = k; }\n}\nreturn [{ json: { question: q, area, router: 'keyword' } }];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
220,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "a5744b67-3600-5a8a-8607-d6c0896313f1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Action?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "loose",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"leftValue": "={{ $json.area === 'action' }}",
|
||||||
|
"rightValue": "",
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "true",
|
||||||
|
"singleValue": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
440,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "48f78459-b87c-527d-a569-b3e753e4c0d4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Refuse action",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ JSON.stringify({ question: $('Route').first().json.question, area: 'action', router: $('Route').first().json.router, refused: true, reason: 'read_only', answer: '운영 도우미는 읽기만 합니다. 재처리 · 삭제 · 배포 같은 작업은 런북에 따라 담당자가 직접 해 주세요.' }) }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
560
|
||||||
|
],
|
||||||
|
"id": "6ede178d-153c-53d7-a773-9b2bda2af049"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Keyword hit?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "loose",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"leftValue": "={{ $json.area !== 'unknown' }}",
|
||||||
|
"rightValue": "",
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "true",
|
||||||
|
"singleValue": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
660,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "bd2aea65-6cee-5229-b19a-5ec357ba6bc1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Classify",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||||
|
"typeVersion": 3.1,
|
||||||
|
"parameters": {
|
||||||
|
"promptType": "define",
|
||||||
|
"text": "={{ $json.question }}",
|
||||||
|
"options": {
|
||||||
|
"systemMessage": "Classify an IT helpdesk operator's question (Korean) into one area of the operations data. Areas: runs (helpdesk runs: errors, failures, slowness, response time / 실행 · 오류 · 느림), alerts (alert messages / 알림), dlq (intake items that failed and wait for replay / 접수 못 한 것 · 남은 것 · 재처리 대기), quality (FAQ data batches, quality scores, quarantine / 데이터 품질 · FAQ 배치 · 격리), cost (LLM spending, money, budget, tokens / 비용 · 돈 · 지출 · 얼마 나갔나), release (versions, canary, rollout to some users / 버전 · 새 판 · 일부에게 먼저 내보냄), overview (is everything ok, general status), none (anything not about operating the helpdesk). Reply with only the area word.",
|
||||||
|
"maxIterations": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"onError": "continueRegularOutput",
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
140
|
||||||
|
],
|
||||||
|
"id": "6747acdd-c6b6-54a5-9707-f995bcf54be6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Classify model",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"parameters": {
|
||||||
|
"model": {
|
||||||
|
"__rl": true,
|
||||||
|
"mode": "id",
|
||||||
|
"value": "gpt-4.1-mini"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"temperature": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"credentials": {
|
||||||
|
"openAiApi": {
|
||||||
|
"id": "openAiCourse0001",
|
||||||
|
"name": "OpenAI (.env)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
-60
|
||||||
|
],
|
||||||
|
"id": "9e1b1e99-9563-50eb-ae41-edba7312bd00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Read class",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const r = $('Route').first().json;\nconst word = String($json.output ?? '').trim().toLowerCase().replace(/[^a-z]/g, '');\nconst known = ['runs', 'alerts', 'dlq', 'quality', 'cost', 'release', 'overview'];\nreturn [{ json: { ...r, area: known.includes(word) ? word : 'unknown', router: 'llm' } }];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1100,
|
||||||
|
140
|
||||||
|
],
|
||||||
|
"id": "a36bcee6-b885-5021-9b88-5321ed56c3b4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Routed",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "return [{ json: $input.first().json }];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1320,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "db234186-80da-5094-9fdc-9b85b9fffd56"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Known area?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "loose",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"leftValue": "={{ $json.area !== 'unknown' }}",
|
||||||
|
"rightValue": "",
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "true",
|
||||||
|
"singleValue": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1540,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "a904a046-a9d2-5f43-bc24-45c10bb803fb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Out of scope",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ JSON.stringify({ question: $('Routed').first().json.question, area: 'unknown', router: $('Routed').first().json.router, refused: true, reason: 'out_of_scope', answer: '운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다.' }) }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1980,
|
||||||
|
560
|
||||||
|
],
|
||||||
|
"id": "047a7d3f-bc56-5a24-ae65-8b2143880a44"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get facts",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://localhost:5678/webhook/my-ops-facts",
|
||||||
|
"options": {
|
||||||
|
"timeout": 30000
|
||||||
|
},
|
||||||
|
"method": "POST",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ from: $('Ask webhook').first().json.body?.from ?? '', to: $('Ask webhook').first().json.body?.to ?? '' }) }}"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1760,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "adea6e67-7abd-55a9-8659-98b7e285cce3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Pick facts",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const r = $('Routed').first().json;\nconst f = $('Get facts').first().json;\nconst part = r.area === 'overview' ? f : { period: f.period, [r.area]: f[r.area], ...(f.health ? { health: f.health[r.area] } : {}) };\nreturn [{ json: { ...r, facts: part } }];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1980,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "7550791a-4b6c-57a0-82a8-1f97ab9dbe79"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Answer",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||||
|
"typeVersion": 3.1,
|
||||||
|
"parameters": {
|
||||||
|
"promptType": "define",
|
||||||
|
"text": "=질문: {{ $json.question }}\n사실(JSON): {{ JSON.stringify($json.facts) }}",
|
||||||
|
"options": {
|
||||||
|
"systemMessage": "You are the operations assistant of a company IT helpdesk team. Answer the operator's question in Korean, 2-3 sentences, using only the facts JSON you are given. Use only numbers that appear in the facts (a ratio like 0.0588 may be written as 5.88%). Do not guess causes that are not in the facts. Do not offer to run any action; you can only read.",
|
||||||
|
"maxIterations": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
2200,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "83bb5d6e-92c7-5d43-bc11-78f9d4b23568"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Answer model",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"parameters": {
|
||||||
|
"model": {
|
||||||
|
"__rl": true,
|
||||||
|
"mode": "id",
|
||||||
|
"value": "gpt-4.1-mini"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"temperature": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"credentials": {
|
||||||
|
"openAiApi": {
|
||||||
|
"id": "openAiCourse0001",
|
||||||
|
"name": "OpenAI (.env)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
2200,
|
||||||
|
520
|
||||||
|
],
|
||||||
|
"id": "3e644835-aa33-5c70-b0aa-c18d300579b3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Check numbers",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const r = $('Pick facts').first().json;\nconst answer = String($json.output ?? '').trim();\nconst allowed = new Set();\nconst add = (v) => { for (const d of [0, 1, 2, 3, 4, 6]) allowed.add(Number(v.toFixed(d))); };\nconst walk = (v) => {\n if (typeof v === 'number') { add(v); add(v * 100); }\n else if (typeof v === 'string') { for (const m of v.match(/\\d+(?:\\.\\d+)?/g) ?? []) add(Number(m)); }\n else if (v && typeof v === 'object') { for (const [k, x] of Object.entries(v)) { walk(k); walk(x); } }\n};\nwalk(r.facts);\nconst versions = JSON.stringify(r.facts).match(/\\d+\\.\\d+\\.\\d+/g) ?? [];\nlet text = answer;\nfor (const v of versions) text = text.split(v).join(' ');\nconst unknown = (text.match(/\\d+(?:\\.\\d+)?/g) ?? []).filter((m) => !allowed.has(Number(m)));\nreturn [{ json: { question: r.question, area: r.area, router: r.router, refused: false, reason: null, answer,\n numbers_ok: unknown.length === 0, unknown_numbers: [...new Set(unknown)] } }];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
2420,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "51f5e16b-f31f-5606-8b33-7271c651764f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Send answer",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ $('Check numbers').first().json }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
2860,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "a1f84cfd-5942-59f2-81c8-613559203d2a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Audit action",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
660,
|
||||||
|
560
|
||||||
|
],
|
||||||
|
"id": "d3d7581f-619f-5c38-9c1a-3518405c9d98",
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://data-api:8800/audit",
|
||||||
|
"method": "POST",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ kind: 'ops-ask', question: $('Route').first().json.question, area: $('Route').first().json.area, router: $('Route').first().json.router, refused: true, reason: 'read_only', numbers_ok: null }) }}",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"neverError": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Audit scope",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
1760,
|
||||||
|
560
|
||||||
|
],
|
||||||
|
"id": "78e261a3-2b14-5108-a31c-eda31046bddb",
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://data-api:8800/audit",
|
||||||
|
"method": "POST",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ kind: 'ops-ask', question: $('Routed').first().json.question, area: $('Routed').first().json.area, router: $('Routed').first().json.router, refused: true, reason: 'out_of_scope', numbers_ok: null }) }}",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"neverError": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Audit answer",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
2640,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "b20126ed-a5d2-5e91-91cd-1e9cacd0279f",
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://data-api:8800/audit",
|
||||||
|
"method": "POST",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ kind: 'ops-ask', question: $('Check numbers').first().json.question, area: $('Check numbers').first().json.area, router: $('Check numbers').first().json.router, refused: false, reason: null, numbers_ok: $('Check numbers').first().json.numbers_ok }) }}",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"neverError": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Ask webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Route",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Route": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Action?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Action?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Audit action",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Keyword hit?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Keyword hit?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Routed",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Classify",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Classify model": {
|
||||||
|
"ai_languageModel": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Classify",
|
||||||
|
"type": "ai_languageModel",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Classify": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Read class",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Read class": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Routed",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Routed": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Known area?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Known area?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get facts",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Audit scope",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get facts": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Pick facts",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Pick facts": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Answer",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Answer model": {
|
||||||
|
"ai_languageModel": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Answer",
|
||||||
|
"type": "ai_languageModel",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Answer": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Check numbers",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Check numbers": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Audit answer",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Audit action": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Refuse action",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Audit scope": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Out of scope",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Audit answer": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Send answer",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
{
|
||||||
|
"name": "79 Ops assistant eval (answer)",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"name": "Eval webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "my-ops-eval",
|
||||||
|
"responseMode": "responseNode",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"webhookId": "b9e92341-ff71-5352-9909-618024a18357",
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "79dec3c9-bffe-5565-8228-bb186d76ab2d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Has cases?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "loose",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"leftValue": "={{ Array.isArray($json.body.cases) && $json.body.cases.length > 0 }}",
|
||||||
|
"rightValue": "",
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "true",
|
||||||
|
"singleValue": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
220,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "f8f0ded2-67fd-5079-be71-51cf8352a46e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "No cases",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "{ \"error\": \"cases must be a non-empty list\" }",
|
||||||
|
"options": {
|
||||||
|
"responseCode": 400
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
440,
|
||||||
|
520
|
||||||
|
],
|
||||||
|
"id": "4d2e4cbd-d282-5859-9b7c-09d10a1f52d2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Split cases",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "return $('Eval webhook').first().json.body.cases.map((c) => ({ json: c }));"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
440,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "c7d458a8-2b05-5b5d-8622-a449fc96947f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ask",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://localhost:5678/webhook/my-ops-ask",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"neverError": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timeout": 60000
|
||||||
|
},
|
||||||
|
"method": "POST",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ question: $json.question }) }}"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
660,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "637c2a81-6496-51a3-96ea-e1e7746a3e79"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Score",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const cases = $('Eval webhook').first().json.body.cases;\nconst answers = $input.all().map((i) => i.json);\nconst results = cases.map((c, i) => {\n const a = answers[i] ?? {};\n const areaOk = a.area === c.area;\n const refusedOk = Boolean(a.refused) === Boolean(c.refused);\n const numberOk = !c.numbers || c.numbers.some((n) => String(a.answer ?? '').includes(n));\n const grounded = a.refused ? true : a.numbers_ok === true;\n return { question: c.question, area: a.area ?? null, ok: areaOk && refusedOk && numberOk && grounded, area_ok: areaOk, refused_ok: refusedOk, number_ok: numberOk, grounded, answer: a.answer ?? null };\n});\nconst n = results.length, cnt = (k) => results.filter((r) => r[k]).length;\nreturn [{ json: { total: n, passed: cnt('ok'), score: n ? Math.round(cnt('ok') / n * 10000) / 10000 : 0,\n area_ok: cnt('area_ok'), refused_ok: cnt('refused_ok'), number_ok: cnt('number_ok'), grounded: cnt('grounded'),\n failed: results.filter((r) => !r.ok) } }];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "97e3d80b-97c6-534c-b854-247fbb2d3aa1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Send score",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ $json }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1100,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "fe6103e3-3723-502b-ba34-f95ea2aacfd0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Eval webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Has cases?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Has cases?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Split cases",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "No cases",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Split cases": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Ask",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Ask": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Score",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Score": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Send score",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
{
|
||||||
|
"name": "79 Ops assistant eval + gate (answer)",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"name": "Eval webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "my-ops-eval",
|
||||||
|
"responseMode": "responseNode",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"webhookId": "b9e92341-ff71-5352-9909-618024a18357",
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "79dec3c9-bffe-5565-8228-bb186d76ab2d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Has cases?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "loose",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"leftValue": "={{ Array.isArray($json.body.cases) && $json.body.cases.length > 0 }}",
|
||||||
|
"rightValue": "",
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "true",
|
||||||
|
"singleValue": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
220,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "f8f0ded2-67fd-5079-be71-51cf8352a46e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "No cases",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "{ \"error\": \"cases must be a non-empty list\" }",
|
||||||
|
"options": {
|
||||||
|
"responseCode": 400
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
440,
|
||||||
|
520
|
||||||
|
],
|
||||||
|
"id": "4d2e4cbd-d282-5859-9b7c-09d10a1f52d2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Split cases",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "return $('Eval webhook').first().json.body.cases.map((c) => ({ json: c }));"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
440,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "c7d458a8-2b05-5b5d-8622-a449fc96947f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ask",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://localhost:5678/webhook/my-ops-ask",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"neverError": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timeout": 60000
|
||||||
|
},
|
||||||
|
"method": "POST",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ question: $json.question }) }}"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
660,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "637c2a81-6496-51a3-96ea-e1e7746a3e79"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Score",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const cases = $('Eval webhook').first().json.body.cases;\nconst answers = $input.all().map((i) => i.json);\nconst results = cases.map((c, i) => {\n const a = answers[i] ?? {};\n const areaOk = a.area === c.area;\n const refusedOk = Boolean(a.refused) === Boolean(c.refused);\n const numberOk = !c.numbers || c.numbers.some((n) => String(a.answer ?? '').includes(n));\n const grounded = a.refused ? true : a.numbers_ok === true;\n return { question: c.question, area: a.area ?? null, ok: areaOk && refusedOk && numberOk && grounded, area_ok: areaOk, refused_ok: refusedOk, number_ok: numberOk, grounded, answer: a.answer ?? null };\n});\nconst n = results.length, cnt = (k) => results.filter((r) => r[k]).length;\nconst min = Number($('Eval webhook').first().json.body.min_score ?? 0.9);\nconst sc = n ? Math.round(cnt('ok') / n * 10000) / 10000 : 0;\nreturn [{ json: { total: n, passed: cnt('ok'), score: n ? Math.round(cnt('ok') / n * 10000) / 10000 : 0,\n area_ok: cnt('area_ok'), refused_ok: cnt('refused_ok'), number_ok: cnt('number_ok'), grounded: cnt('grounded'),\n failed: results.filter((r) => !r.ok), min_score: min, gate: sc >= min ? 'pass' : 'fail' } }];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "97e3d80b-97c6-534c-b854-247fbb2d3aa1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Send score",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ $json }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1760,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "fe6103e3-3723-502b-ba34-f95ea2aacfd0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Below?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [
|
||||||
|
1100,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "ef1a28a4-8753-597e-ae45-334233921758",
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "loose",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"leftValue": "={{ $json.gate === 'fail' }}",
|
||||||
|
"rightValue": "",
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "true",
|
||||||
|
"singleValue": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Alert eval",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
1320,
|
||||||
|
160
|
||||||
|
],
|
||||||
|
"id": "acd6f4e0-0aa0-5f71-9a62-defe5e28d3a2",
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://data-api:8800/alerts",
|
||||||
|
"method": "POST",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ workflow: 'ops-eval', node: 'Below?', severity: 'medium', reason: 'eval_below', score: $('Score').first().json.score, min_score: $('Score').first().json.min_score, message: 'ops assistant eval ' + $('Score').first().json.score + ' < ' + $('Score').first().json.min_score }) }}",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"neverError": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Result again",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1540,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "679d16cb-7d8c-5d14-9b02-b0132a5a38e1",
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "return [{ json: $('Score').first().json }];"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Eval webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Has cases?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Has cases?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Split cases",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "No cases",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Split cases": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Ask",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Ask": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Score",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Score": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Below?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Below?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Alert eval",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Result again",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Alert eval": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Result again",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Result again": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Send score",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,110 @@
|
|||||||
|
{
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/ops-reset",
|
||||||
|
"body": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"after": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/ops-reset",
|
||||||
|
"body": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "지난주 영역별 상태",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"health.runs": "red",
|
||||||
|
"health.alerts": "yellow",
|
||||||
|
"health.dlq": "red",
|
||||||
|
"health.quality": "yellow",
|
||||||
|
"health.cost": "yellow",
|
||||||
|
"health.release": "red",
|
||||||
|
"overall": "red"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "빨강 · 노랑 목록 (정해진 순서)",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"red": [
|
||||||
|
"runs",
|
||||||
|
"dlq",
|
||||||
|
"release"
|
||||||
|
],
|
||||||
|
"yellow": [
|
||||||
|
"alerts",
|
||||||
|
"quality",
|
||||||
|
"cost"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "첫 주: 오류는 없지만 답 못 함 · 카나리 비교 불가",
|
||||||
|
"request": "requests/week1.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"health.runs": "yellow",
|
||||||
|
"health.release": "yellow",
|
||||||
|
"red": [
|
||||||
|
"dlq"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "예산을 늘리면 비용은 초록",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/budget",
|
||||||
|
"body": {
|
||||||
|
"daily_usd": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"health.cost": "green",
|
||||||
|
"cost.used_ratio": 0.0736
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "parked를 처리하면 DLQ는 노랑",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/dlq/update",
|
||||||
|
"body": {
|
||||||
|
"dlq_id": 1,
|
||||||
|
"remove": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/dlq/update",
|
||||||
|
"body": {
|
||||||
|
"dlq_id": 2,
|
||||||
|
"remove": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"health.dlq": "yellow",
|
||||||
|
"red": [
|
||||||
|
"runs",
|
||||||
|
"release"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/ops-reset",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"usage": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"after": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"usage": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "비용 출처가 멈춰도 200 · 모르는 것은 모른다고",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"cost": null,
|
||||||
|
"sources_down": [
|
||||||
|
"cost"
|
||||||
|
],
|
||||||
|
"health.cost": "unknown",
|
||||||
|
"unknown": [
|
||||||
|
"cost"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "다른 영역은 그대로",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"dlq.size": 3,
|
||||||
|
"health.dlq": "red",
|
||||||
|
"red": [
|
||||||
|
"runs",
|
||||||
|
"dlq",
|
||||||
|
"release"
|
||||||
|
],
|
||||||
|
"overall": "red"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "출처가 돌아오면 다시 노랑",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"usage": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"sources_down": [],
|
||||||
|
"unknown": [],
|
||||||
|
"health.cost": "yellow",
|
||||||
|
"cost.used_ratio": 0.9201
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/ops-reset",
|
||||||
|
"body": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "두 주 (10/19~10/30)",
|
||||||
|
"request": "requests/two-weeks.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"runs.total": 666,
|
||||||
|
"runs.errors": 21,
|
||||||
|
"runs.error_rate": 0.0315,
|
||||||
|
"runs.top_failure.count": 16
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "하루 (10/26)",
|
||||||
|
"request": "requests/monday.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"runs.total": 70,
|
||||||
|
"runs.errors": 16,
|
||||||
|
"runs.top_failure.node": "Search FAQ"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "빈 기간 (11/02~11/06) · 0건이어도 200",
|
||||||
|
"request": "requests/empty-week.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"runs.total": 0,
|
||||||
|
"runs.error_rate": 0,
|
||||||
|
"runs.p95_ms": 0,
|
||||||
|
"runs.top_failure": null,
|
||||||
|
"release.active_repeat_rate": null,
|
||||||
|
"release.canary_repeat_rate": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "기간을 비우면 지난주 (기본)",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"period.from": "2026-10-26",
|
||||||
|
"period.to": "2026-10-30",
|
||||||
|
"runs.total": 357
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "기간과 상관없는 '지금' 숫자는 그대로",
|
||||||
|
"request": "requests/empty-week.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"alerts.count": 6,
|
||||||
|
"dlq.size": 3,
|
||||||
|
"quality.avg_score": 0.6583,
|
||||||
|
"cost.used_ratio": 0.9201,
|
||||||
|
"release.active": "1.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
{
|
||||||
|
"webhook": "my-ops-facts",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/ops-reset",
|
||||||
|
"body": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"after": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/ops-reset",
|
||||||
|
"body": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "지난주 실행 (기본 기간)",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"period.from": "2026-10-26",
|
||||||
|
"runs.total": 357,
|
||||||
|
"runs.errors": 21,
|
||||||
|
"runs.error_rate": 0.0588,
|
||||||
|
"runs.p95_ms": 2598,
|
||||||
|
"runs.top_failure.node": "Search FAQ",
|
||||||
|
"runs.top_failure.count": 16
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "지금 상태 · 알림 · DLQ · 품질",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"alerts.count": 6,
|
||||||
|
"alerts.high": 3,
|
||||||
|
"alerts.by_reason.low_quality": 2,
|
||||||
|
"dlq.size": 3,
|
||||||
|
"dlq.parked": 2,
|
||||||
|
"quality.avg_score": 0.6583,
|
||||||
|
"quality.last_score": 1,
|
||||||
|
"quality.quarantine": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "비용 · 릴리스",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"cost.requests": 156,
|
||||||
|
"cost.used_ratio": 0.9201,
|
||||||
|
"cost.top_user.user": "test-bot",
|
||||||
|
"cost.top_user.share": 0.3699,
|
||||||
|
"release.active": "1.1.0",
|
||||||
|
"release.canary_release": "1.2.0",
|
||||||
|
"release.canary_repeat_rate": 0.625,
|
||||||
|
"release.active_repeat_rate": 0.0773
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "첫 주 (기간을 바꾸면 실행만 바뀜)",
|
||||||
|
"request": "requests/week1.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"runs.total": 309,
|
||||||
|
"runs.errors": 0,
|
||||||
|
"runs.top_failure": null,
|
||||||
|
"release.canary_repeat_rate": null,
|
||||||
|
"dlq.size": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "상태가 바뀌면 숫자도 (DLQ #3 처리)",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/dlq/update",
|
||||||
|
"body": {
|
||||||
|
"dlq_id": 3,
|
||||||
|
"remove": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"dlq.size": 2,
|
||||||
|
"dlq.waiting": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/ops-reset",
|
||||||
|
"body": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "낱말이 없는 비용 질문",
|
||||||
|
"body": {
|
||||||
|
"question": "이번 주에 AI 쓰는 데 얼마나 나갔어?"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"area": "cost",
|
||||||
|
"router": "llm",
|
||||||
|
"refused": false,
|
||||||
|
"numbers_ok": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "낱말이 없는 DLQ 질문",
|
||||||
|
"body": {
|
||||||
|
"question": "어젯밤에 접수 못 하고 남은 건 몇 건이야?"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"area": "dlq",
|
||||||
|
"router": "llm",
|
||||||
|
"numbers_ok": true
|
||||||
|
},
|
||||||
|
"contains": {
|
||||||
|
"answer": "3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "낱말이 없는 릴리스 질문",
|
||||||
|
"body": {
|
||||||
|
"question": "새 판을 일부 사용자에게 먼저 내보낸 거 어떻게 되고 있어?"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"area": "release",
|
||||||
|
"router": "llm",
|
||||||
|
"numbers_ok": true
|
||||||
|
},
|
||||||
|
"contains": {
|
||||||
|
"answer": "62.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "LLM도 운영 밖이면 거절",
|
||||||
|
"body": {
|
||||||
|
"question": "점심 메뉴 추천해 줘"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"refused": true,
|
||||||
|
"reason": "out_of_scope",
|
||||||
|
"router": "llm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "낱말이 있으면 LLM을 부르지 않음",
|
||||||
|
"body": {
|
||||||
|
"question": "지난주 오류율이 얼마야?"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"area": "runs",
|
||||||
|
"router": "keyword"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/ops-reset",
|
||||||
|
"body": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "작업 요청",
|
||||||
|
"body": {
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"refused": true,
|
||||||
|
"reason": "read_only"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "운영 밖",
|
||||||
|
"body": {
|
||||||
|
"question": "점심 메뉴 추천해 줘"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"refused": true,
|
||||||
|
"reason": "out_of_scope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "답한 질문",
|
||||||
|
"body": {
|
||||||
|
"question": "지난주 오류율이 얼마야?"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"area": "runs",
|
||||||
|
"refused": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "감사 기록 세 줄 · 거절 이유와 함께",
|
||||||
|
"url": "http://localhost:8800/audit/list",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"events.0.kind": "ops-ask",
|
||||||
|
"events.0.reason": "read_only",
|
||||||
|
"events.0.area": "action",
|
||||||
|
"events.1.reason": "out_of_scope",
|
||||||
|
"events.1.router": "llm",
|
||||||
|
"events.2.area": "runs",
|
||||||
|
"events.2.refused": false,
|
||||||
|
"events.2.numbers_ok": true,
|
||||||
|
"events.3": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "응답은 그대로 (감사는 곁가지)",
|
||||||
|
"body": {
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"area": "action",
|
||||||
|
"router": "keyword"
|
||||||
|
},
|
||||||
|
"contains": {
|
||||||
|
"answer": "읽기만"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/ops-reset",
|
||||||
|
"body": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "'에러' → 실행",
|
||||||
|
"body": {
|
||||||
|
"question": "에러 많이 났어?"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"area": "runs",
|
||||||
|
"router": "keyword",
|
||||||
|
"refused": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "'장애' 는 낱말 목록에 없음 → 운영 밖으로 거절",
|
||||||
|
"body": {
|
||||||
|
"question": "장애 났어?"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"area": "unknown",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "out_of_scope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "두 영역이 한 번씩 → 앞의 영역 (알림)",
|
||||||
|
"body": {
|
||||||
|
"question": "알림이랑 DLQ 둘 다 알려 줘"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"area": "alerts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "'꺼 줘' → 작업 요청으로 거절",
|
||||||
|
"body": {
|
||||||
|
"question": "카나리 꺼 줘"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"area": "action",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "read_only"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "'배포'(1) 보다 '상태 · 요약'(2) → 전체",
|
||||||
|
"body": {
|
||||||
|
"question": "배포 상태 요약해 줘"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"area": "overview"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "낱말 없는 비용 질문 → 거절 (과제 1에서 LLM 라우터로)",
|
||||||
|
"body": {
|
||||||
|
"question": "AI 쓰는 데 얼마나 나갔어?"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"refused": true,
|
||||||
|
"reason": "out_of_scope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
{
|
||||||
|
"webhook": "my-ops-ask",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/ops-reset",
|
||||||
|
"body": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "실행 · 오류율",
|
||||||
|
"body": {
|
||||||
|
"question": "지난주 오류율이 얼마야?"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"area": "runs",
|
||||||
|
"refused": false
|
||||||
|
},
|
||||||
|
"contains": {
|
||||||
|
"answer": "5.88"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DLQ · parked",
|
||||||
|
"body": {
|
||||||
|
"question": "DLQ에 parked 된 건 몇 건이야?"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"area": "dlq",
|
||||||
|
"refused": false
|
||||||
|
},
|
||||||
|
"contains": {
|
||||||
|
"answer": "2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "운영과 상관없는 질문은 거절",
|
||||||
|
"body": {
|
||||||
|
"question": "점심 메뉴 추천해 줘"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"area": "unknown",
|
||||||
|
"refused": true,
|
||||||
|
"reason": "out_of_scope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "작업 요청은 거절 (읽기만)",
|
||||||
|
"body": {
|
||||||
|
"question": "DLQ 1번 다시 돌려 줘"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"refused": true,
|
||||||
|
"reason": "read_only"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "비용 · 숫자 확인",
|
||||||
|
"body": {
|
||||||
|
"question": "오늘 LLM 비용이 예산의 몇 퍼센트야?"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"area": "cost",
|
||||||
|
"numbers_ok": true
|
||||||
|
},
|
||||||
|
"contains": {
|
||||||
|
"answer": "92"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "릴리스 · 숫자 확인",
|
||||||
|
"body": {
|
||||||
|
"question": "카나리 버전 문제 있어?"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"area": "release",
|
||||||
|
"numbers_ok": true
|
||||||
|
},
|
||||||
|
"contains": {
|
||||||
|
"answer": "62.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
{
|
||||||
|
"webhook": "my-ops-brief",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/ops-reset",
|
||||||
|
"body": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "아침 그대로",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"red": [
|
||||||
|
"runs",
|
||||||
|
"dlq",
|
||||||
|
"release"
|
||||||
|
],
|
||||||
|
"actions.0.area": "runs",
|
||||||
|
"actions.1.area": "dlq"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "parked 둘을 처리하면 → DLQ는 노랑, 먼저 볼 것에 알림",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/dlq/update",
|
||||||
|
"body": {
|
||||||
|
"dlq_id": 1,
|
||||||
|
"remove": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/dlq/update",
|
||||||
|
"body": {
|
||||||
|
"dlq_id": 2,
|
||||||
|
"remove": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"red": [
|
||||||
|
"runs",
|
||||||
|
"release"
|
||||||
|
],
|
||||||
|
"actions.1.area": "release",
|
||||||
|
"actions.2.area": "alerts"
|
||||||
|
},
|
||||||
|
"contains": {
|
||||||
|
"markdown": "| DLQ | 🟡 | 1건 (parked 0) |"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "카나리를 끄면 → 릴리스는 초록",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/releases/helpdesk",
|
||||||
|
"body": {
|
||||||
|
"canary": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"red": [
|
||||||
|
"runs"
|
||||||
|
],
|
||||||
|
"actions.1.area": "alerts",
|
||||||
|
"actions.2.area": "dlq"
|
||||||
|
},
|
||||||
|
"contains": {
|
||||||
|
"markdown": "| 릴리스 | 🟢 | 1.1.0 |"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "예산을 올리면 → 비용도 초록",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/budget",
|
||||||
|
"body": {
|
||||||
|
"daily_usd": 0.2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"red": [
|
||||||
|
"runs"
|
||||||
|
],
|
||||||
|
"yellow": [
|
||||||
|
"alerts",
|
||||||
|
"dlq",
|
||||||
|
"quality"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "남은 빨강은 실행 하나 (사람이 고칠 일)",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"overall": "red",
|
||||||
|
"actions.0.area": "runs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"webhook": "my-ops-eval",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/ops-reset",
|
||||||
|
"body": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "골든 질문 8개",
|
||||||
|
"request": "requests/ops-golden.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"total": 8,
|
||||||
|
"area_ok": 8,
|
||||||
|
"refused_ok": 8
|
||||||
|
},
|
||||||
|
"range": {
|
||||||
|
"score": [
|
||||||
|
0.875,
|
||||||
|
1
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "틀린 기대는 잡아냄",
|
||||||
|
"body": {
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"question": "알림이 몇 건 와 있어?",
|
||||||
|
"area": "cost",
|
||||||
|
"numbers": [
|
||||||
|
"6"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"total": 1,
|
||||||
|
"passed": 0,
|
||||||
|
"failed.0.area": "alerts",
|
||||||
|
"failed.0.area_ok": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "빈 목록 → 400",
|
||||||
|
"body": {
|
||||||
|
"cases": []
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"status": 400
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"webhook": "my-ops-eval",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/ops-reset",
|
||||||
|
"body": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "기준을 안 주면 0.9",
|
||||||
|
"body": {
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"question": "알림이 몇 건 와 있어?",
|
||||||
|
"area": "alerts",
|
||||||
|
"numbers": [
|
||||||
|
"6"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"min_score": 0.9,
|
||||||
|
"gate": "pass",
|
||||||
|
"score": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "골든 8 · 기준 0.8 → 통과",
|
||||||
|
"request": "requests/ops-golden-gate.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"total": 8,
|
||||||
|
"gate": "pass",
|
||||||
|
"min_score": 0.8
|
||||||
|
},
|
||||||
|
"range": {
|
||||||
|
"score": [
|
||||||
|
0.875,
|
||||||
|
1
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "틀린 기대 · 기준 0.9 → 실패",
|
||||||
|
"body": {
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"question": "알림이 몇 건 와 있어?",
|
||||||
|
"area": "cost",
|
||||||
|
"numbers": [
|
||||||
|
"6"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"min_score": 0.9
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"score": 0,
|
||||||
|
"gate": "fail"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "실패하면 알림 한 건",
|
||||||
|
"url": "http://localhost:8800/alerts/list",
|
||||||
|
"body": {
|
||||||
|
"reason": "eval_below"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"count": 1,
|
||||||
|
"alerts.0.workflow": "ops-eval",
|
||||||
|
"alerts.0.score": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"webhook": "my-ops-brief",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/ops-reset",
|
||||||
|
"body": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "요약 세 문장 · 숫자 확인",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"numbers_ok": true,
|
||||||
|
"overall": "red"
|
||||||
|
},
|
||||||
|
"contains": {
|
||||||
|
"markdown": "## 요약"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "요약에 급한 곳",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"contains": {
|
||||||
|
"summary": [
|
||||||
|
"DLQ",
|
||||||
|
"릴리스",
|
||||||
|
"카나리",
|
||||||
|
"실행",
|
||||||
|
"오류"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"webhook": "my-ops-brief",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/ops-reset",
|
||||||
|
"body": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"after": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/ops-reset",
|
||||||
|
"body": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "전체 · 먼저 볼 것 셋",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"overall": "red",
|
||||||
|
"red": [
|
||||||
|
"runs",
|
||||||
|
"dlq",
|
||||||
|
"release"
|
||||||
|
],
|
||||||
|
"actions.0.area": "runs",
|
||||||
|
"actions.1.area": "dlq",
|
||||||
|
"actions.2.area": "release"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "표에 숫자",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"contains": {
|
||||||
|
"markdown": "| DLQ | 🔴 | 3건 (parked 2) |"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "할 일은 규칙에서",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"contains": {
|
||||||
|
"markdown": "카나리 끄기 · 되돌리기 검토 (74일차)",
|
||||||
|
"actions.1.action": "parked"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DLQ를 비운 뒤",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/dlq-clear",
|
||||||
|
"body": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"red": [
|
||||||
|
"runs",
|
||||||
|
"release"
|
||||||
|
],
|
||||||
|
"actions.2.area": "alerts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Sample company data API for day 79 (day 78 API + a seeded operations morning), 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
|
||||||
|
- TZ=KST-9 # live run logs get Korean time (class day), not UTC
|
||||||
|
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 @@
|
|||||||
|
{"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를 다시 등록한다.
|
||||||
@@ -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가 만든 결과는 사람이 확인한 뒤에 사용한다.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "이준호",
|
||||||
|
"dept": "영업팀"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "박서연",
|
||||||
|
"dept": "재무팀"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "최유진",
|
||||||
|
"dept": "인사팀"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "한지훈",
|
||||||
|
"dept": "개발팀"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "김민지",
|
||||||
|
"dept": "IT팀"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "정우성",
|
||||||
|
"dept": "IT팀"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."}
|
||||||
|
]
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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].
|
||||||
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user