diff --git a/Last3Mon/79/.gitignore b/Last3Mon/79/.gitignore new file mode 100644 index 0000000..be55978 --- /dev/null +++ b/Last3Mon/79/.gitignore @@ -0,0 +1,3 @@ +runs/ +tools/.cache/ +my-work/*.json diff --git a/Last3Mon/79/AGENTS.md b/Last3Mon/79/AGENTS.md new file mode 100644 index 0000000..bf8f550 --- /dev/null +++ b/Last3Mon/79/AGENTS.md @@ -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/` | +| 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 키를 출력하지 않는다. diff --git a/Last3Mon/79/CLAUDE.md b/Last3Mon/79/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/Last3Mon/79/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Last3Mon/79/LAB-01-A-OPS-FACTS.md b/Last3Mon/79/LAB-01-A-OPS-FACTS.md new file mode 100644 index 0000000..7cab9d8 --- /dev/null +++ b/Last3Mon/79/LAB-01-A-OPS-FACTS.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%) — "모르는 것을 좋게 말하는 대시보드가 가장 위험하다". diff --git a/Last3Mon/79/LAB-01-Q-OPS-FACTS.md b/Last3Mon/79/LAB-01-Q-OPS-FACTS.md new file mode 100644 index 0000000..ff51ce7 --- /dev/null +++ b/Last3Mon/79/LAB-01-Q-OPS-FACTS.md @@ -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
출처 둘러보기 ·
기준 대시보드"] --> E2["예제 2
운영 사실"] --> E3["예제 3
기간을 바꾸면"] --> T1["과제 1
영역별 상태"] --> T2["과제 2
출처가 멈추면"] +``` + +> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다. 연습이 끝나면 `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
그대로 보여 줌"] + S --> B["base-ops-facts
팀의 방식으로 모음"] + M --> C{"같은 아침?"} + B --> C + C -->|다르면| D["기간 · 정의 ·
빠진 출처"] +``` + +**기대 결과.** `morning`과 기준 대시보드의 숫자가 **다르고**, 기준에는 **없는 칸**이 있습니다. 무엇이 다른지는 아래 실행 결과를 보기 전에 먼저 찾아 봅니다. + +
▶ 1단계 · 기준의 실행 수가 357이 아니라면 + +기준 대시보드는 지난주를 물었는데 몇 건을 셌나요? 77일차의 두 주 666과 같다면, 기간을 데이터 API에 넘기지 않은 것입니다. `uv run tools/flow.py diff reference/baseline-ops-facts.json reference/lab01-example2-facts.json`로 `Get logs`의 본문을 비교해 봅니다. +
+ +
▶ 2단계 · 기준에 없는 것 중 가장 위험한 것 + +`morning`의 마지막 줄(릴리스)과 DLQ의 `parked`를 봅니다. 기준 대시보드만 보는 운영 담당자는 이 둘을 알 수 있나요? +
+ +### 실행 결과 · 출처 둘러보기 · 기준 대시보드 + +수업 준비 때(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
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)
정의대로 모으기"] --> 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. + +
▶ 1단계 · 정의를 요청문에 + +"운영 상태를 모아 줘"만 쓰면 에이전트마다 칸이 다릅니다. 3절 "사실" 표를 그대로 요청문에 넣거나 "AGENTS.md 3절 사실 표대로"라고 씁니다. 실행 기록은 "77일차 정의 그대로", 기간의 기본값, 0건일 때도. +
+ +
▶ 2단계 · 비율 · 1위는 어떻게 + +`used_ratio`는 `used_usd / daily_usd`(4자리), `top_user`는 `by_user`를 `cost_usd`로 정렬한 1위와 그 몫(`share`). 다시 묻는 비율은 그 기간 `자동 답변` 실행 중 그 릴리스의 `repeat` 비율입니다 (74 · 77일차). +
+ +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 파일 · 웹훅 ② 기간과 기본값 ③ 출처 · 노드 순서 · 정의 ④ 기간을 쓰는 곳 ⑤ 하지 않을 것 · 기대 결과**. 아래 빈칸 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에 하지 않는 일. + +채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. +
+ +```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"이 빠진 것입니다. + +
▶ 1단계 · 하루(10/26)인데 다시 묻는 비율이 `null`이라면 + +그날 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`이 맞습니다. +
+ +### 실행 결과 · 기간을 바꾸면 + +수업 준비 때(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["사실
(예제 2)"] --> H["health
영역마다 red → yellow → green
(red 먼저 봄)"] --> L["red · yellow 목록
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 그대로. + +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 판정 순서 ② 릴리스 기준 ③ 목록 순서 ④ 누가 판정하나**. 아래 빈칸 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절 "숫자 · 상태는 …". +
+ +```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
500"] -->|neverError| M["Make facts
모양을 봄"] + M --> N["cost: null
sources_down: [cost]
health.cost: unknown"] + M --> K["다른 다섯 영역
그대로"] + 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` 그대로. + +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 오류를 받는 법 ② 모름의 값 ③ 전체 상태 ④ 응답 코드**. 아래 빈칸 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이면 무엇이 위험한가요? +- ④ 다른 다섯 영역은 쓸 수 있으니까. +
+ +```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`. diff --git a/Last3Mon/79/LAB-02-A-OPS-ASK.md b/Last3Mon/79/LAB-02-A-OPS-ASK.md new file mode 100644 index 0000000..17b6ee5 --- /dev/null +++ b/Last3Mon/79/LAB-02-A-OPS-ASK.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 | diff --git a/Last3Mon/79/LAB-02-Q-OPS-ASK.md b/Last3Mon/79/LAB-02-Q-OPS-ASK.md new file mode 100644 index 0000000..b089e93 --- /dev/null +++ b/Last3Mon/79/LAB-02-Q-OPS-ASK.md @@ -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
라우터 · 답"] --> E2["예제 2
거절 · 숫자 확인"] --> E3["예제 3
질문을 바꾸면"] --> T1["과제 1
LLM 라우터"] --> T2["과제 2
감사 기록"] +``` + +> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다. 상태를 보는 명령은 `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)
낱말 라우터"] --> K{"영역을 알았나?"} + K -->|unknown| X(["거절 · out_of_scope
LLM 없음"]) + K -->|영역| F["Get facts
my-ops-facts"] --> P["Pick facts
그 영역만"] --> A{{"Answer
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. + +
▶ 1단계 · 같은 수면 어느 영역 + +"알림이랑 DLQ"처럼 두 영역의 낱말이 하나씩이면? AGENTS.md 3절 낱말 표의 순서(위가 앞)로 정합니다. 정하지 않으면 실행마다 · 사람마다 다른 영역이 됩니다. +
+ +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 라우터 규칙과 같을 때 ② 운영 밖 처리 ③ 사실의 출처 ④ 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에서 만든 웹훅입니다. 사실을 여기서 다시 세지 않습니다. +- ④ 같은 표의 "답" 줄. 사실 전부를 주면 무엇이 생기나요? (기준 챗봇의 릴리스 답) +
+ +```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
작업 낱말?"] --> A{"action?"} + A -->|예| X(["거절 · read_only
LLM 없음"]) + A -->|아니오| K["… 영역 · 사실 · Answer"] --> C["Check numbers (Code)
답의 숫자 ⊂ 사실"] --> 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 없음). + +
▶ 1단계 · 버전 숫자 + +답에 "1.2.0"이 있으면 1 · 2 · 0으로 쪼개 확인하면 안 됩니다. 사실 안의 버전 글자(`\d+\.\d+\.\d+`)를 먼저 답에서 지우고 나머지 숫자를 봅니다 (AGENTS.md 3절). +
+ +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 작업 요청의 영역 ② 거절 이유 ③ 버전 숫자 ④ 누가 확인하나**. 아래 빈칸 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일차 요약의 숫자 확인을 누가 했나요? +
+ +```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
영역 하나 또는 none"}} --> V["Read class
목록에 있나"] + 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 비용"이 이제 거절되지 않고 답합니다 (테스트의 기대는 낱말 라우터 시절의 규칙이라 ✘, 도우미는 나아짐). + +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 언제 부르나 ② 목록 밖이면 ③ 작업 거절의 자리 ④ 낱말이 맞으면**. 아래 빈칸 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에게 가면 어떻게 되나요? +- ④ 테스트의 마지막 케이스. +
+ +```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` 그대로. + +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 몇 갈래 ② 응답이 읽는 곳 ③ 읽기 전용 원칙 ④ 감사가 실패하면**. 아래 빈칸 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절의 마지막 줄 괄호. +- ④ 위 "감사가 실패해도". +
+ +```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`. diff --git a/Last3Mon/79/LAB-03-A-OPS-BRIEF.md b/Last3Mon/79/LAB-03-A-OPS-BRIEF.md new file mode 100644 index 0000000..22c6be9 --- /dev/null +++ b/Last3Mon/79/LAB-03-A-OPS-BRIEF.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점 — 평가가 남긴 알림이 다음 평가를 바꿈. 케이스 순서를 바꿈. diff --git a/Last3Mon/79/LAB-03-Q-OPS-BRIEF.md b/Last3Mon/79/LAB-03-Q-OPS-BRIEF.md new file mode 100644 index 0000000..8da7f54 --- /dev/null +++ b/Last3Mon/79/LAB-03-Q-OPS-BRIEF.md @@ -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
브리핑"] --> E2["예제 2
LLM 요약"] --> E3["예제 3
아침이 바뀌면"] --> T1["과제 1
골든 질문 평가"] --> T2["과제 2
평가 관문"] +``` + +> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다. 연습이 끝나면 `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
my-ops-facts (상태 포함)"] --> B["Make brief (Code)
표 · 먼저 볼 것 셋 · 할 일"] --> 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. + +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 상태의 출처 ② 먼저 볼 것의 수 ③ 마크다운 제목 ④ 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을 쓰나요? +
+ +```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 한 번). + +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 몇 문장 ② 확인 칸 ③ 마크다운 절 ④ 표는 누가**. 아래 빈칸 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이 손대나요? +
+ +```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
아이템마다"] --> A["Ask
HTTP 하나가 아이템마다
my-ops-ask"] --> C["Score (Code)
넷 다 맞으면 통과"] --> O(["total · passed · score · failed"]) +``` + +**넷 다 맞아야.** 영역만 맞고 숫자가 틀리면, 숫자는 맞는데 근거(`numbers_ok`)가 없으면, 통과가 아닙니다. 그리고 거절해야 할 질문(작업 요청)은 **거절하면 통과**입니다 — 평가는 "답을 잘하나"만이 아니라 "하면 안 되는 것을 안 하나"도 봅니다. + +**시험지를 시험한다.** 둘째 케이스는 기대를 일부러 틀리게(`알림` 질문의 영역을 `cost`로) 적었습니다. 평가가 그것을 "실패"로 잡아내야 평가를 믿을 수 있습니다. + +**기대 결과.** 고치기 전 0/3 (404), 고친 뒤 3/3 (골든 8개 중 7~8개 — 점수 0.875~1을 허용). + +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 빈 목록 ② 통과 조건 ③ 반올림 ④ 틀린 케이스에 남길 것**. 아래 빈칸 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처럼. +- ④ 사람이 "왜 틀렸나"를 보려면 무엇이 있어야 하나요? +
+ +```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
+ min_score · gate"] --> B{"fail?"} + B -->|예| A["Alert eval
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 그대로. + +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 기본 기준 ② 경계 ③ 알림 이유 ④ 응답**. 아래 빈칸 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일차 게이트) +- ③ 테스트가 찾는 알림 이유. +- ④ 알림 노드 뒤에서 무엇을 응답하나요? +
+ +```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`. diff --git a/Last3Mon/79/LECTURE-79.md b/Last3Mon/79/LECTURE-79.md new file mode 100644 index 0000000..7e8d7a3 --- /dev/null +++ b/Last3Mon/79/LECTURE-79.md @@ -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["전날
준비 · morning"] --> M["아침
켜기 · doctor"] --> L1["1~2교시
Lab 01
사실 · 상태"] --> L2["3~4교시
Lab 02
라우터 · 거절 · LLM 라우터"] --> L3["5~6교시
Lab 03
브리핑 · 평가"] --> V["7~8교시
tops"] --> E["마친 뒤
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["① 묻고 싶은 것
OPS.md"] --> B["② 정의 · 기준
AGENTS.md 3절"] --> C["③ 요청문 → 에이전트"] --> D["④ 에이전트가
my-work · check"] --> E["⑤ 내가
push · test · call · morning"] + E -->|✘| F["⑥ 실패 줄 · 보고서 경로
그대로 붙여 주기"] --> 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`, 단위 프로젝트 준비 diff --git a/Last3Mon/79/README.md b/Last3Mon/79/README.md new file mode 100644 index 0000000..3bcb44b --- /dev/null +++ b/Last3Mon/79/README.md @@ -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["운영 사실
my-ops-facts
숫자 · 상태는 Code"] + F --> A{{"질문 답변
my-ops-ask
라우터 · 거절 · 숫자 확인"}} + F --> B["브리핑
my-ops-brief
먼저 볼 것"] + A --> E["평가
my-ops-eval
골든 질문"] +``` + +## 일하는 방식 (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 · 알림을 비우지 않고 **운영 아침 상태로** 되돌림). 테스트 파일은 대부분 시작할 때 스스로 되돌립니다. diff --git a/Last3Mon/79/VIBE-A-TOPS.md b/Last3Mon/79/VIBE-A-TOPS.md new file mode 100644 index 0000000..7fb497f --- /dev/null +++ b/Last3Mon/79/VIBE-A-TOPS.md @@ -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" | diff --git a/Last3Mon/79/VIBE-Q-TOPS.md b/Last3Mon/79/VIBE-Q-TOPS.md new file mode 100644 index 0000000..2082963 --- /dev/null +++ b/Last3Mon/79/VIBE-Q-TOPS.md @@ -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
할 일 정리"] --> S["2. Doc/SPEC.md
확인할 수 있는 요구"] --> P["3. Doc/IMP-PLAN.md
엔진 → TUI 단계"] --> I["4. 구현
단계마다 요청 · 확인"] + I --> H["5. 막히면
문제 해결 팁"] --> I + I --> C["6. 결과 확인
요구 표 체크"] --> 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)의 "할 일" 칸대로 씁니다. 요청의 말 ①~⑫와 끝 부탁을 내 말로, 꼭 / 나중을 표시합니다. + +
▶ 만들고 싶은 것 한 문단 · 무엇이 들어가야 하나 + +누가(운영 당번) · 어디서(어느 폴더에서나, 터미널) · 무엇을(오늘 n8n 운영 도우미의 사실 · 답 · 브리핑 · 평가) · 어떤 약속으로(**n8n과 같은 색 · 숫자**, **읽기만**). 이 문단이 SPEC의 첫 줄이 됩니다. +
+ +`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 주소 넣는 법) +``` + +
▶ 확인 방법이 확인 방법이려면 + +"영역을 보여 준다"는 확인 방법이 아닙니다. "`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`)와 **같은 숫자**여야 합니다 — 같은 사실을 보이니까요. +
+ +쓴 뒤 아래 표와 비교합니다. 빠진 요구, 내가 더 뽑은 요구를 `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) +``` + +
▶ 왜 엔진 먼저인가 + +- 엔진은 **화면 없이 테스트**할 수 있습니다 (`pytest`, `tops status`). 색이 n8n과 같은지 · 읽기만인지가 오늘의 핵심인데, 화면부터 만들면 눈으로만 봅니다. +- 아침 점검은 화면 없이 스크립트에서 돌아갑니다. 종료 코드(R3)가 다음 일(당번을 부를까)을 정합니다: `tops status || 알림`. +- TUI는 엔진이 만든 줄을 표에 넣기만 합니다. +
+ +
▶ 단계 크기 + +한 단계 = 에이전트 요청 한두 번 + 확인 한 번 + commit 한 번 (10분 안팎). 오늘 Lab에서 운영 도우미를 한 번에 다 만들지 않고 "사실 → 상태 → 질문 → 거절 → 브리핑 → 평가"로 한 칸씩 더한 것과 같습니다. +
+ +`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`. + +
▶ 엔진 1단계 · n8n 응답의 사본은 어디서 + +정답 실행 기록 `~/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`으로. 테스트는 이 파일을 읽은 줄을 비교합니다. +
+ +
▶ 엔진 1단계 · 읽기만을 어떻게 보장하나 + +HTTP를 부르는 함수를 **하나**만 두고, 그 함수가 주소 목록(`/webhook/my-ops-facts` · `…-ask` · `…-brief` · `…-eval` · `/audit/list`)을 봅니다. 목록 밖이면 보내기 전에 오류. 테스트에서 `/dlq/update`를 부르게 해 보고 가짜 서버에 요청이 0건인지 봅니다. +
+ +
▶ 엔진 2단계 · 질문의 거절과 답 + +`my-ops-ask`가 이미 영역 · 거절 · 숫자 확인을 정합니다. tops는 `refused`가 참이면 `reason`을, 아니면 `numbers_ok`를 보입니다. 질문을 tops가 미리 거르지 않습니다 — 거절 규칙도 한 곳(n8n). +
+ +
▶ TUI 1단계 · 못 읽는 탭 + +탭마다 따로 읽고, 못 읽으면 그 탭에만 "not available: 까닭". 한 탭의 오류가 앱 전체를 멈추게 두지 않습니다 (오늘 Lab 01 과제 2의 `neverError`와 같은 생각). 질문 입력(`Input`)은 Enter 뒤 포커스를 표로 돌려야 `r` · `q`가 글자로 들어가지 않습니다 (78일차 `tcheck`에서 겪은 일). +
+ +
▶ 다음 단계들 (8교시 뒤 · 집에서) + +- 평가 관문(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가 다시 계산하지 않습니다. +
+ +## 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에 체크 안 된 채로 남겨 두었다 (다음에 이어 감). diff --git a/Last3Mon/79/VIBE-REQ-TOPS.md b/Last3Mon/79/VIBE-REQ-TOPS.md new file mode 100644 index 0000000..6c63ac8 --- /dev/null +++ b/Last3Mon/79/VIBE-REQ-TOPS.md @@ -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["요청서
(사람의 말)"] --> TODO["Doc/TODO.md
만들고 싶은 것 ·
요청의 말 정리"] --> SPEC["Doc/SPEC.md
확인할 수 있는 요구"] --> PLAN["Doc/IMP-PLAN.md
엔진 → TUI 단계"] --> RUN["단계별로 만들기 ·
직접 확인"] --> USE["uv tool install
다른 곳에서 쓰기"] +``` + +> **생활 속 예 · 회진 도는 의사의 손바닥 현황판.** 오늘 병원(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가 다시 판정하지 않는다는 증거) | + +
▶ 1단계 · 꼭 할 것과 나중에 할 것 + +요청의 말이 열두 개입니다. 50분 안에 다 만들기 어렵습니다. 당번이 가장 아파하는 것은 "n8n과 같은 색"(믿을 수 있나)과 "읽기만"(실수로 바꾸지 않나)입니다. 그리고 질문 · 브리핑 · 평가 · 화면을 만들려면 먼저 무엇이 있어야 하나요? (n8n을 읽는 층 → 사실을 보여 주기 → 질문 · 브리핑 → 평가 · 감사 → 화면) +
+ +
▶ 2단계 · 걱정되는 것 (숨은 요구의 씨앗) + +- ② "다시 세지 말고": 오류율 · 비율을 tops가 계산하면 77일차처럼 정의가 두 벌이 됩니다. 사실의 값을 **꼴만 바꿔** 보입니다 (0.0588 → 5.88%). +- ⑤ "읽기만": "조심해서 안 부른다"가 아니라 **부를 수 있는 주소를 정해 두고** 그 밖은 막습니다. 테스트로 확인할 수 있게. +- ⑦ "모른다고": `cost`가 `null`이면 숫자 칸에 0이 아니라 "응답 없음", 색은 `unknown` (오늘 Lab 01 과제 2). +- ⑧ "다시 만들지 말고": 지난 도구는 **명령으로** 부르고(`tlog slo --json`), 없으면 "설치되지 않음". 지난 도구의 코드를 복사해 오지 않습니다. +- ⑨ · ⑫ "종료 코드": 평가가 떨어진 것(4)과 n8n을 못 읽은 것(1)이 다른 번호여야 스크립트가 "도우미를 고쳐라"와 "n8n을 켜라"를 구분합니다. +- ⑪ "탭": 탭 하나가 못 읽어도(브리핑 웹훅이 없음) 다른 탭은 보여야 합니다. +- 끝 "n8n 없이 시험": n8n · 데이터 API를 대신할 작은 가짜 서버, 그리고 내 n8n이 낸 응답을 저장한 사본. +
+ +## 그다음 + +[VIBE-Q-TOPS.md](VIBE-Q-TOPS.md)를 엽니다. 거기서 `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md` → 단계별 구현 → 확인 → `uv tool install` → 다른 곳에서 사용 순서로 갑니다. VIBE-Q의 요구 표는 **내 SPEC을 쓴 뒤에** 비교하는 데 씁니다. diff --git a/Last3Mon/79/answers/00-prep.txt b/Last3Mon/79/answers/00-prep.txt new file mode 100644 index 0000000..a42acf5 --- /dev/null +++ b/Last3Mon/79/answers/00-prep.txt @@ -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 + +$ 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 diff --git a/Last3Mon/79/answers/01-lab01-read.txt b/Last3Mon/79/answers/01-lab01-read.txt new file mode 100644 index 0000000..16295d2 --- /dev/null +++ b/Last3Mon/79/answers/01-lab01-read.txt @@ -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 + } +} diff --git a/Last3Mon/79/answers/02-lab01-before.json b/Last3Mon/79/answers/02-lab01-before.json new file mode 100644 index 0000000..6729acc --- /dev/null +++ b/Last3Mon/79/answers/02-lab01-before.json @@ -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 + } + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/79/answers/02-lab01-before.txt b/Last3Mon/79/answers/02-lab01-before.txt new file mode 100644 index 0000000..052635d --- /dev/null +++ b/Last3Mon/79/answers/02-lab01-before.txt @@ -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 + } + } diff --git a/Last3Mon/79/answers/03-lab01-facts.json b/Last3Mon/79/answers/03-lab01-facts.json new file mode 100644 index 0000000..67807ea --- /dev/null +++ b/Last3Mon/79/answers/03-lab01-facts.json @@ -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 + } + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/79/answers/03-lab01-facts.txt b/Last3Mon/79/answers/03-lab01-facts.txt new file mode 100644 index 0000000..c1903bb --- /dev/null +++ b/Last3Mon/79/answers/03-lab01-facts.txt @@ -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 + } +} diff --git a/Last3Mon/79/answers/04-lab01-period.json b/Last3Mon/79/answers/04-lab01-period.json new file mode 100644 index 0000000..bb1fbe3 --- /dev/null +++ b/Last3Mon/79/answers/04-lab01-period.json @@ -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 + } + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/79/answers/04-lab01-period.txt b/Last3Mon/79/answers/04-lab01-period.txt new file mode 100644 index 0000000..57ce070 --- /dev/null +++ b/Last3Mon/79/answers/04-lab01-period.txt @@ -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 diff --git a/Last3Mon/79/answers/05-lab01-challenge-before.json b/Last3Mon/79/answers/05-lab01-challenge-before.json new file mode 100644 index 0000000..523d1bf --- /dev/null +++ b/Last3Mon/79/answers/05-lab01-challenge-before.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 + } + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/79/answers/05-lab01-challenge-before.txt b/Last3Mon/79/answers/05-lab01-challenge-before.txt new file mode 100644 index 0000000..e233f24 --- /dev/null +++ b/Last3Mon/79/answers/05-lab01-challenge-before.txt @@ -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 + } + } diff --git a/Last3Mon/79/answers/06-lab01-challenge.json b/Last3Mon/79/answers/06-lab01-challenge.json new file mode 100644 index 0000000..66ecab7 --- /dev/null +++ b/Last3Mon/79/answers/06-lab01-challenge.json @@ -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" + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/79/answers/06-lab01-challenge.txt b/Last3Mon/79/answers/06-lab01-challenge.txt new file mode 100644 index 0000000..5adb416 --- /dev/null +++ b/Last3Mon/79/answers/06-lab01-challenge.txt @@ -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 diff --git a/Last3Mon/79/answers/07-lab01-challenge2-before.json b/Last3Mon/79/answers/07-lab01-challenge2-before.json new file mode 100644 index 0000000..421e173 --- /dev/null +++ b/Last3Mon/79/answers/07-lab01-challenge2-before.json @@ -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" + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/79/answers/07-lab01-challenge2-before.txt b/Last3Mon/79/answers/07-lab01-challenge2-before.txt new file mode 100644 index 0000000..44ac9ec --- /dev/null +++ b/Last3Mon/79/answers/07-lab01-challenge2-before.txt @@ -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 + } diff --git a/Last3Mon/79/answers/08-lab01-challenge2.json b/Last3Mon/79/answers/08-lab01-challenge2.json new file mode 100644 index 0000000..b275abf --- /dev/null +++ b/Last3Mon/79/answers/08-lab01-challenge2.json @@ -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" + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/79/answers/08-lab01-challenge2.txt b/Last3Mon/79/answers/08-lab01-challenge2.txt new file mode 100644 index 0000000..51da1b8 --- /dev/null +++ b/Last3Mon/79/answers/08-lab01-challenge2.txt @@ -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 diff --git a/Last3Mon/79/answers/09-lab02-before.json b/Last3Mon/79/answers/09-lab02-before.json new file mode 100644 index 0000000..59e8793 --- /dev/null +++ b/Last3Mon/79/answers/09-lab02-before.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) 상태도 좋지 않고, 품질 지표도 낮은 편입니다. 현재 전체 시스템 상태가 빨간색으로 표시되어 있어, 카나리 버전에서 안정성 문제가 있다고 판단됩니다. 따라서 카나리 버전 적용에 신중을 기할 필요가 있습니다." + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/79/answers/09-lab02-before.txt b/Last3Mon/79/answers/09-lab02-before.txt new file mode 100644 index 0000000..4ad346f --- /dev/null +++ b/Last3Mon/79/answers/09-lab02-before.txt @@ -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 diff --git a/Last3Mon/79/answers/10-lab02-ask.json b/Last3Mon/79/answers/10-lab02-ask.json new file mode 100644 index 0000000..d1324d6 --- /dev/null +++ b/Last3Mon/79/answers/10-lab02-ask.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%에 비해 카나리 버전에서 문제가 심각한 것으로 나타납니다." + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/79/answers/10-lab02-ask.txt b/Last3Mon/79/answers/10-lab02-ask.txt new file mode 100644 index 0000000..36824f7 --- /dev/null +++ b/Last3Mon/79/answers/10-lab02-ask.txt @@ -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 diff --git a/Last3Mon/79/answers/11-lab02-refuse.json b/Last3Mon/79/answers/11-lab02-refuse.json new file mode 100644 index 0000000..af81b11 --- /dev/null +++ b/Last3Mon/79/answers/11-lab02-refuse.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": [] + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/79/answers/11-lab02-refuse.txt b/Last3Mon/79/answers/11-lab02-refuse.txt new file mode 100644 index 0000000..89dda79 --- /dev/null +++ b/Last3Mon/79/answers/11-lab02-refuse.txt @@ -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 diff --git a/Last3Mon/79/answers/12-lab02-phrasing.json b/Last3Mon/79/answers/12-lab02-phrasing.json new file mode 100644 index 0000000..7238a50 --- /dev/null +++ b/Last3Mon/79/answers/12-lab02-phrasing.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 · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다." + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/79/answers/12-lab02-phrasing.txt b/Last3Mon/79/answers/12-lab02-phrasing.txt new file mode 100644 index 0000000..5010d25 --- /dev/null +++ b/Last3Mon/79/answers/12-lab02-phrasing.txt @@ -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 diff --git a/Last3Mon/79/answers/13-lab02-challenge-before.json b/Last3Mon/79/answers/13-lab02-challenge-before.json new file mode 100644 index 0000000..282a6ec --- /dev/null +++ b/Last3Mon/79/answers/13-lab02-challenge-before.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": [] + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/79/answers/13-lab02-challenge-before.txt b/Last3Mon/79/answers/13-lab02-challenge-before.txt new file mode 100644 index 0000000..3e2bf64 --- /dev/null +++ b/Last3Mon/79/answers/13-lab02-challenge-before.txt @@ -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 diff --git a/Last3Mon/79/answers/14-lab02-challenge.json b/Last3Mon/79/answers/14-lab02-challenge.json new file mode 100644 index 0000000..544869c --- /dev/null +++ b/Last3Mon/79/answers/14-lab02-challenge.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": [] + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/79/answers/14-lab02-challenge.txt b/Last3Mon/79/answers/14-lab02-challenge.txt new file mode 100644 index 0000000..3a0c4b9 --- /dev/null +++ b/Last3Mon/79/answers/14-lab02-challenge.txt @@ -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 diff --git a/Last3Mon/79/answers/15-lab02-challenge2-before.json b/Last3Mon/79/answers/15-lab02-challenge2-before.json new file mode 100644 index 0000000..e904672 --- /dev/null +++ b/Last3Mon/79/answers/15-lab02-challenge2-before.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": "운영 도우미는 읽기만 합니다. 재처리 · 삭제 · 배포 같은 작업은 런북에 따라 담당자가 직접 해 주세요." + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/79/answers/15-lab02-challenge2-before.txt b/Last3Mon/79/answers/15-lab02-challenge2-before.txt new file mode 100644 index 0000000..6048519 --- /dev/null +++ b/Last3Mon/79/answers/15-lab02-challenge2-before.txt @@ -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 diff --git a/Last3Mon/79/answers/16-lab02-challenge2.json b/Last3Mon/79/answers/16-lab02-challenge2.json new file mode 100644 index 0000000..3ef3b9a --- /dev/null +++ b/Last3Mon/79/answers/16-lab02-challenge2.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": "운영 도우미는 읽기만 합니다. 재처리 · 삭제 · 배포 같은 작업은 런북에 따라 담당자가 직접 해 주세요." + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/79/answers/16-lab02-challenge2.txt b/Last3Mon/79/answers/16-lab02-challenge2.txt new file mode 100644 index 0000000..808baf1 --- /dev/null +++ b/Last3Mon/79/answers/16-lab02-challenge2.txt @@ -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 diff --git a/Last3Mon/79/answers/17-lab03-before.json b/Last3Mon/79/answers/17-lab03-before.json new file mode 100644 index 0000000..8ec6087 --- /dev/null +++ b/Last3Mon/79/answers/17-lab03-before.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)" + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/79/answers/17-lab03-before.txt b/Last3Mon/79/answers/17-lab03-before.txt new file mode 100644 index 0000000..c459f96 --- /dev/null +++ b/Last3Mon/79/answers/17-lab03-before.txt @@ -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 + } + } diff --git a/Last3Mon/79/answers/18-lab03-brief.json b/Last3Mon/79/answers/18-lab03-brief.json new file mode 100644 index 0000000..a645c12 --- /dev/null +++ b/Last3Mon/79/answers/18-lab03-brief.json @@ -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일차)" + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/79/answers/18-lab03-brief.txt b/Last3Mon/79/answers/18-lab03-brief.txt new file mode 100644 index 0000000..f9b19b9 --- /dev/null +++ b/Last3Mon/79/answers/18-lab03-brief.txt @@ -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일차)" +} diff --git a/Last3Mon/79/answers/19-lab03-llm-before.json b/Last3Mon/79/answers/19-lab03-llm-before.json new file mode 100644 index 0000000..cbf54b2 --- /dev/null +++ b/Last3Mon/79/answers/19-lab03-llm-before.json @@ -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일차)" + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/79/answers/19-lab03-llm-before.txt b/Last3Mon/79/answers/19-lab03-llm-before.txt new file mode 100644 index 0000000..97a0ac9 --- /dev/null +++ b/Last3Mon/79/answers/19-lab03-llm-before.txt @@ -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 diff --git a/Last3Mon/79/answers/20-lab03-llm.json b/Last3Mon/79/answers/20-lab03-llm.json new file mode 100644 index 0000000..7ac1de3 --- /dev/null +++ b/Last3Mon/79/answers/20-lab03-llm.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": [] + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/79/answers/20-lab03-llm.txt b/Last3Mon/79/answers/20-lab03-llm.txt new file mode 100644 index 0000000..ef12586 --- /dev/null +++ b/Last3Mon/79/answers/20-lab03-llm.txt @@ -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 diff --git a/Last3Mon/79/answers/21-lab03-after.json b/Last3Mon/79/answers/21-lab03-after.json new file mode 100644 index 0000000..2d3736b --- /dev/null +++ b/Last3Mon/79/answers/21-lab03-after.json @@ -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": [] + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/79/answers/21-lab03-after.txt b/Last3Mon/79/answers/21-lab03-after.txt new file mode 100644 index 0000000..1e3c02c --- /dev/null +++ b/Last3Mon/79/answers/21-lab03-after.txt @@ -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 diff --git a/Last3Mon/79/answers/22-lab03-challenge-before.json b/Last3Mon/79/answers/22-lab03-challenge-before.json new file mode 100644 index 0000000..945736e --- /dev/null +++ b/Last3Mon/79/answers/22-lab03-challenge-before.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)" + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/79/answers/22-lab03-challenge-before.txt b/Last3Mon/79/answers/22-lab03-challenge-before.txt new file mode 100644 index 0000000..aba7b3c --- /dev/null +++ b/Last3Mon/79/answers/22-lab03-challenge-before.txt @@ -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 diff --git a/Last3Mon/79/answers/23-lab03-challenge.json b/Last3Mon/79/answers/23-lab03-challenge.json new file mode 100644 index 0000000..bc6e255 --- /dev/null +++ b/Last3Mon/79/answers/23-lab03-challenge.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" + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/79/answers/23-lab03-challenge.txt b/Last3Mon/79/answers/23-lab03-challenge.txt new file mode 100644 index 0000000..c8279d6 --- /dev/null +++ b/Last3Mon/79/answers/23-lab03-challenge.txt @@ -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 diff --git a/Last3Mon/79/answers/24-lab03-challenge2-before.json b/Last3Mon/79/answers/24-lab03-challenge2-before.json new file mode 100644 index 0000000..156cf1f --- /dev/null +++ b/Last3Mon/79/answers/24-lab03-challenge2-before.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": [] + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/79/answers/24-lab03-challenge2-before.txt b/Last3Mon/79/answers/24-lab03-challenge2-before.txt new file mode 100644 index 0000000..b79bcb6 --- /dev/null +++ b/Last3Mon/79/answers/24-lab03-challenge2-before.txt @@ -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 diff --git a/Last3Mon/79/answers/25-lab03-challenge2.json b/Last3Mon/79/answers/25-lab03-challenge2.json new file mode 100644 index 0000000..10a561a --- /dev/null +++ b/Last3Mon/79/answers/25-lab03-challenge2.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 + } + ] + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/79/answers/25-lab03-challenge2.txt b/Last3Mon/79/answers/25-lab03-challenge2.txt new file mode 100644 index 0000000..3bb56be --- /dev/null +++ b/Last3Mon/79/answers/25-lab03-challenge2.txt @@ -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 diff --git a/Last3Mon/79/answers/79-lab01-challenge-health.json b/Last3Mon/79/answers/79-lab01-challenge-health.json new file mode 100644 index 0000000..798342b --- /dev/null +++ b/Last3Mon/79/answers/79-lab01-challenge-health.json @@ -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" + } +} diff --git a/Last3Mon/79/answers/79-lab01-challenge2-sources.json b/Last3Mon/79/answers/79-lab01-challenge2-sources.json new file mode 100644 index 0000000..50f0a07 --- /dev/null +++ b/Last3Mon/79/answers/79-lab01-challenge2-sources.json @@ -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" + } +} diff --git a/Last3Mon/79/answers/79-lab02-challenge-llm-router.json b/Last3Mon/79/answers/79-lab02-challenge-llm-router.json new file mode 100644 index 0000000..2774b12 --- /dev/null +++ b/Last3Mon/79/answers/79-lab02-challenge-llm-router.json @@ -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" + } +} diff --git a/Last3Mon/79/answers/79-lab02-challenge2-audit.json b/Last3Mon/79/answers/79-lab02-challenge2-audit.json new file mode 100644 index 0000000..101d451 --- /dev/null +++ b/Last3Mon/79/answers/79-lab02-challenge2-audit.json @@ -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" + } +} diff --git a/Last3Mon/79/answers/79-lab03-challenge-eval.json b/Last3Mon/79/answers/79-lab03-challenge-eval.json new file mode 100644 index 0000000..5efb081 --- /dev/null +++ b/Last3Mon/79/answers/79-lab03-challenge-eval.json @@ -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" + } +} diff --git a/Last3Mon/79/answers/79-lab03-challenge2-gate.json b/Last3Mon/79/answers/79-lab03-challenge2-gate.json new file mode 100644 index 0000000..a68c27c --- /dev/null +++ b/Last3Mon/79/answers/79-lab03-challenge2-gate.json @@ -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" + } +} diff --git a/Last3Mon/79/answers/LAB-TEST-RESULT.md b/Last3Mon/79/answers/LAB-TEST-RESULT.md new file mode 100644 index 0000000..af3ea11 --- /dev/null +++ b/Last3Mon/79/answers/LAB-TEST-RESULT.md @@ -0,0 +1,5403 @@ +# 79일차 Lab 테스트 결과 · 실제로 만든 시나리오와 `test -v` 결과 + +> **정답 자료** · 79일차 n8n 워크플로우를 **만드는 프롬프트(codex) → push → `call` · `test -v` · `quality` 결과**를 학생이 가는 순서대로 한 문서에 모았습니다. 수업이 끝난 뒤 공개합니다. + +- 실행: 2026-11-01, 실습을 Lab마다 5개(예제 3 + 과제 2)로 늘린 뒤 강사 노트북의 65일차 n8n 2.42.6 + 79일차 판 데이터 API(78일차 + 2026-11-04 08:30 운영 아침 상태), OpenAI `gpt-4.1-mini`(답 · 라우터 · 요약, temperature 0), 케이스 파일마다 **한 번씩**, 모두 26단계 (00~25) +- 명령: `~/aio-01d4-stu/Last3Mon/79`에서 `uv run tools/flow.py ...`. 아래 단계마다 **실행한 명령**과 **화면에 나온 출력 전부**를 그대로 붙이고, 그 아래 **쉽게 말하면**으로 결과를 풀어 썼습니다 (특히 ✘가 나온 까닭과 그것이 예상한 것인지). 같은 출력이 `NN-<이름>.txt`에, 그 단계 첫 `test`의 보고서가 `NN-<이름>.json`에 있습니다 (00 · 01단계는 보고서 없음). +- 사실 · 상태 · 거절 · 브리핑의 표는 Code라 **늘 같습니다.** 실행마다 다를 수 있는 것은 시간과 LLM이 쓴 답 · 요약 글입니다 (영역 · 거절 · `numbers_ok`는 이번 실행에서 모두 기대대로). 정답의 잘못으로 실패한 케이스는 없습니다. ✘는 모두 "고치기 전" 단계와, LLM 라우터를 붙인 뒤 다시 돌린 `lab02-phrasing`(기대가 옛 규칙)에서 본 것입니다. +- "고치기 전" 상태: 00단계에서 `my-*` 워크플로우를 모두 **게시 해제**(`off`)하고 `ops --reset`으로 운영 아침을 되돌려 학생이 처음 여는 상태와 같게 했습니다. + +## `-v`가 보여 주는 것 + +```text +✔ 비용 · 숫자 확인 · 1.1s ← 케이스 · 시간 + → request POST /webhook/my-ops-ask ← 보낸 요청 JSON (질문) + { "question": "오늘 LLM 비용이 예산의 몇 퍼센트야?" } + ← response HTTP 200 ← 받은 응답 JSON (영역 · 답 · 숫자 확인) + { "area": "cost", "router": "keyword", "refused": false, "answer": "… 92.01% …", "numbers_ok": true, … } +``` + +오늘은 응답의 **영역 · 거절 · 숫자 확인**(무엇으로 답했나 · 하면 안 되는 것을 안 했나 · 숫자가 사실에 있나)을 보고, 기대와 다를 때 `✘` 줄 끝의 `칸 = 실제 (expected 기대)`를 봅니다. LLM의 답 글은 실행마다 다를 수 있어 테스트는 글 전체를 보지 않습니다. + +## 이 문서 하나로 따라가기 + +단계마다 **① codex에 넣는 프롬프트 → ② codex가 `my-work/*.json`을 쓰고 `check` → ③ 내가 `push` → ④ 내가 `test -v` · `call` · `morning`** 순서입니다. 에이전트 없이 하는 예제(Lab 01 예제 1 · 3, Lab 02 예제 3, Lab 03 예제 3)와 고치기 전 단계는 프롬프트가 없습니다. + +```mermaid +flowchart LR + P["① 프롬프트
터미널 ① codex"] --> C["② codex
my-work/*.json · check"] --> U["③ push
터미널 ②"] --> T["④ test -v · call · morning
영역 · 거절 · 기대와 다른 칸"] + T -->|✘| X["실패 줄 · 보고서 경로를
codex에 그대로"] --> C +``` + +- **프롬프트는 정답본(`LAB-0x-A-*.md`)의 요청문 예**를 그대로 썼습니다 (실습 문서 빈칸 채우기 뼈대의 `____`를 채운 글자). +- **이 기록의 워크플로우는 수업 준비 때 만든 정답 파일**입니다. 프롬프트를 codex에 보내 다시 만들어 재지는 않았습니다 (68일차부터 수업 준비는 정답 워크플로우만 실행). +- 학생은 `my-work/ops-facts.json` · `ops-ask.json` · `ops-brief.json` · `ops-eval.json`을 거듭 고쳐 push하고, 이 기록은 같은 웹훅의 단계별 정답 파일을 차례로 push했습니다 (`push`가 앞 판을 내린다는 `! unpublished … same webhook path` 줄이 그것). 그래서 `test` 명령과 결과 모양은 같습니다. + +## 시나리오 한눈에 + +```mermaid +flowchart LR + L1["Lab 01 (00~08)
기준 666 · 칸 없음 → 사실 357 · 여섯 영역
기간 · 상태 🔴 셋 🟡 셋
출처가 멈춰도 200 · unknown"] --> L2["Lab 02 (09~16)
기준 챗봇 '다시 실행해 드리겠습니다'
→ 라우터 · 거절 · 숫자 확인
낱말 · LLM 라우터 · 감사 기록"] + L2 --> L3["Lab 03 (17~25)
브리핑 · 요약 · 아침이 바뀌면
골든 8/8 · 평가 관문 eval_below"] +``` + +| 단계 | 내용 | +| --- | --- | +| 00 | 수업 준비 · 운영 아침으로 · 기준 둘 게시 · doctor | +| 01 | Lab 01 예제 1 · 출처 둘러보기 · 기준 대시보드 | +| 02 | Lab 01 예제 2 하기 전 → `lab01` 0/5 (기준에) | +| 03 | Lab 01 예제 2 · 운영 사실 → 5/5 | +| 04 | Lab 01 예제 3 · 기간을 바꾸면 → 5/5 | +| 05 | Lab 01 과제 1 하기 전 → `lab01-challenge` 0/5 | +| 06 | Lab 01 과제 1 · 영역별 상태 → 5/5 | +| 07 | Lab 01 과제 2 하기 전 → `lab01-challenge2` 0/3 | +| 08 | Lab 01 과제 2 · 출처가 멈추면 → 3/3 | +| 09 | Lab 02 하기 전 → `lab02` 0/6 (기준 챗봇에) | +| 10 | Lab 02 예제 1 · 라우터 · 답 → `lab02` 3/6 | +| 11 | Lab 02 예제 2 · 거절 · 숫자 확인 → 6/6 | +| 12 | Lab 02 예제 3 · 질문을 바꾸면 → 6/6 (규칙대로) | +| 13 | Lab 02 과제 1 하기 전 → `lab02-challenge` 1/5 | +| 14 | Lab 02 과제 1 · LLM 라우터 → 5/5 | +| 15 | Lab 02 과제 2 하기 전 → `lab02-challenge2` 4/5 | +| 16 | Lab 02 과제 2 · 감사 기록 → 5/5 | +| 17 | Lab 03 예제 1 하기 전 → `lab03` 0/4 | +| 18 | Lab 03 예제 1 · 브리핑 → 4/4 | +| 19 | Lab 03 예제 2 하기 전 → `lab03-llm` 0/2 | +| 20 | Lab 03 예제 2 · LLM 요약 → 2/2 | +| 21 | Lab 03 예제 3 · 아침이 바뀌면 → 5/5 | +| 22 | Lab 03 과제 1 하기 전 → `lab03-challenge` 0/3 | +| 23 | Lab 03 과제 1 · 골든 질문 평가 → 3/3 | +| 24 | Lab 03 과제 2 하기 전 → `lab03-challenge2` 0/4 | +| 25 | Lab 03 과제 2 · 평가 관문 → 4/4 | + +## 준비 + +### 00 · 수업 준비 · 운영 아침으로 · 기준 둘 게시 · doctor + +실행한 명령: + +```bash +uv run tools/flow.py ops --reset +uv run tools/flow.py off my-ops-facts my-ops-ask my-ops-brief my-ops-eval +uv run tools/flow.py push reference/baseline-ops-facts.json reference/baseline-ops-ask.json +uv run tools/flow.py doctor +``` + +출력 (그대로): + +```text +✔ back to the seeded operations morning (alerts · DLQ · quality · usage · budget · release), live logs · added FAQ · tickets emptied, fault switch all ok + +✔ 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 + +✔ 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 + +! 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 +``` + +**쉽게 말하면** + +- `ops --reset`이 79일차에는 운영 아침 상태(알림 6 · DLQ 3 · 품질 4 · 격리 1 · 사용량 192 · 예산 · 카나리)로 되돌립니다. +- 앞서 게시한 `my-ops-facts` · `my-ops-ask` · `my-ops-brief` · `my-ops-eval`을 내려 학생이 처음 여는 상태를 만들고, 팀의 기준 `base-ops-facts` · `base-ops-ask`만 게시했습니다. +- `operations morning /ops/seed-info (day 79 data API)` ✔는 데이터 API가 79일차 판이라는 뜻입니다. `ready`. + +## Lab 01 · 운영 사실 · 흩어진 출처를 한 장으로 + +### 01 · Lab 01 예제 1 · 출처 둘러보기 · 기준 대시보드 + +실행한 명령: + +```bash +uv run tools/flow.py morning +uv run tools/flow.py call base-ops-facts requests/week2.json +``` + +출력 (그대로): + +```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} + +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`: 알림 6(높음 3) · DLQ 3(parked 2) · 품질 점수 0 · 0.8 · 0.8333 · 1 · 격리 1 · 비용 $0.073604 / $0.08 · 카나리 1.2.0 20%. +- 기준 대시보드는 지난주를 물어도 **666건**(두 주 전체) · 오류율 3.153…(백분율), 그리고 parked · 품질 · 카나리가 **아예 없습니다**. 오늘 가장 급한 둘이 보이지 않습니다. 예상한 결과. + +### 02 · Lab 01 예제 2 하기 전 → `lab01` 0/5 (기준에) + +실행한 명령: + +```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", "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 + } + } +``` + +**쉽게 말하면** + +- 기간을 넘기지 않아 지난주 · 첫 주 모두 666, 나머지 칸은 없어 `null`. 테스트는 시작할 때 `ops-reset`으로 아침을 되돌립니다. 예상한 결과. + +### 03 · Lab 01 예제 2 · 운영 사실 → 5/5 + +codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/ops-facts.json` · 이 기록에서 push한 같은 역할의 정답 `reference/lab01-example2-facts.json`): + +```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. +``` + +실행한 명령: + +```bash +uv run tools/flow.py push reference/lab01-example2-facts.json +uv run tools/flow.py test cases/lab01.json -v +uv run tools/flow.py call my-ops-facts requests/week2.json +``` + +출력 (그대로): + +```text +✔ pushed '79 Ops facts (reference)' (id w91aa244110abf96) + POST http://localhost:5678/webhook/my-ops-facts + +✔ 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 + } + } + +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 + } +} +``` + +**쉽게 말하면** + +- 지난주 357 · 21 · 0.0588 · p95 2598 · 실패 1위 `Search FAQ` timeout 16 (77일차 숫자와 같음), 알림 6 · 높음 3, DLQ 3 · parked 2 · 대기 1, 품질 0.6583, 비용 0.9201 · 1위 `test-bot` 37%, 카나리 다시 묻기 62.5% 대 1.1.0 7.73%. +- 출처 여덟을 한 줄로 읽어 Code 하나에서 0.1초. LLM 없음, 상태를 바꾸는 요청 없음. 예상한 결과. + +### 04 · Lab 01 예제 3 · 기간을 바꾸면 → 5/5 + +실행한 명령: + +```bash +uv run tools/flow.py test cases/lab01-period.json -v +``` + +출력 (그대로): + +```text +✔ 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 +``` + +**쉽게 말하면** + +- 두 주 666 · 하루 70 · 빈 주 0 · 기간 없음 357 — 실행 기록만 바뀌고 알림 · DLQ · 품질 · 비용 · 판은 다섯 번 모두 같습니다. +- 10/26 · 빈 주의 다시 묻는 비율은 `null` (두 판은 10/28부터). 빈 주의 오류율 · p95는 0, 실패 1위는 `null` — 0과 '모름'은 다릅니다. 예상한 결과. + +### 05 · Lab 01 과제 1 하기 전 → `lab01-challenge` 0/5 + +실행한 명령: + +```bash +uv run tools/flow.py test cases/lab01-challenge.json -v +``` + +출력 (그대로): + +```text +✔ 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 + } + } +``` + +**쉽게 말하면** + +- `health` · `red` · `yellow` · `overall`이 없어 `null`. 넷째 · 다섯째 케이스 앞의 `before`는 사람의 작업(예산 $1 · parked 둘 처리)입니다. 예상한 결과. + +### 06 · Lab 01 과제 1 · 영역별 상태 → 5/5 + +codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/ops-facts.json` · 이 기록에서 push한 같은 역할의 정답 `answers/79-lab01-challenge-health.json`): + +```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 도. +``` + +실행한 명령: + +```bash +uv run tools/flow.py push answers/79-lab01-challenge-health.json +uv run tools/flow.py test cases/lab01-challenge.json -v +uv run tools/flow.py test cases/lab01.json +``` + +출력 (그대로): + +```text +! 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 + +✔ 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 + } + } + +✔ 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 +``` + +**쉽게 말하면** + +- 🔴 runs(0.0588) · dlq(parked 2) · release(62.5% > 2 × 7.73%), 🟡 alerts · quality(격리 1) · cost(0.9201), 전체 🔴. +- 첫 주는 runs 🟡 · release 🟡(비교 불가), 예산 $1이면 비용 🟢, parked를 처리하면 DLQ 🟡. `lab01` 5/5 그대로. 예상한 결과. + +### 07 · Lab 01 과제 2 하기 전 → `lab01-challenge2` 0/3 + +실행한 명령: + +```bash +uv run tools/flow.py test cases/lab01-challenge2.json -v +``` + +출력 (그대로): + +```text +✔ 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 + } +``` + +**쉽게 말하면** + +- 사용량 출처를 멈추자(`fault usage error`) 사실 워크플로우 전체가 **500** — 비용 하나 때문에 다섯 영역의 사실도 없습니다. 예상한 결과. + +### 08 · Lab 01 과제 2 · 출처가 멈추면 → 3/3 + +codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/ops-facts.json` · 이 기록에서 push한 같은 역할의 정답 `answers/79-lab01-challenge2-sources.json`): + +```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 도. +``` + +실행한 명령: + +```bash +uv run tools/flow.py push answers/79-lab01-challenge2-sources.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 +! 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 + +✔ 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 + } + +✔ 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 + +✔ 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 + +✔ 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 +``` + +**쉽게 말하면** + +- 200, `cost: null` · `sources_down: ["cost"]` · `health.cost: "unknown"`, 빨강 셋 그대로 · 전체 🔴. 출처가 돌아오면 `[]` · 비용 🟡. +- 비용을 0으로 적었다면 🟢(실제는 92%)였을 것입니다. `lab01` · `lab01-challenge` · `lab01-period` 그대로. 예상한 결과. + +## Lab 02 · 질문 답변 · 고르고, 막고, 숫자를 확인하기 + +### 09 · Lab 02 하기 전 → `lab02` 0/6 (기준 챗봇에) + +실행한 명령: + +```bash +uv run tools/flow.py test cases/lab02.json --webhook base-ops-ask -v +``` + +출력 (그대로): + +```text +✔ 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 +``` + +**쉽게 말하면** + +- 기준 챗봇은 점심 메뉴를 추천하고, "DLQ 1번 작업을 다시 실행해 드리겠습니다"라고 답하고(아무것도 실행하지 않음), 카나리 질문에 오류율 · 알림을 섞었습니다. `area` · `refused`도 없습니다. 예상한 결과. + +### 10 · Lab 02 예제 1 · 라우터 · 답 → `lab02` 3/6 + +codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/ops-ask.json` · 이 기록에서 push한 같은 역할의 정답 `reference/lab02-example1-ask.json`): + +```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. +``` + +실행한 명령: + +```bash +uv run tools/flow.py push reference/lab02-example1-ask.json +uv run tools/flow.py test cases/lab02.json -v +``` + +출력 (그대로): + +```text +✔ pushed '79 Ops ask (reference)' (id w7ab48263bf09133) + POST http://localhost:5678/webhook/my-ops-ask + +✔ 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 +``` + +**쉽게 말하면** + +- 오류율 `runs` 5.88% · parked `dlq` 2건 · 점심은 LLM 없이 거절(0.1초). 카나리는 릴리스 사실만 받아 62.5% 대 7.73%. +- 작업 요청은 `dlq`로 가서 "#1은 대기 중"(틀림, #1은 parked)이라 답했습니다. 남은 ✘ 셋은 예제 2. 예상한 결과. + +### 11 · Lab 02 예제 2 · 거절 · 숫자 확인 → 6/6 + +codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/ops-ask.json` · 이 기록에서 push한 같은 역할의 정답 `reference/lab02-example2-ask.json`): + +```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 전체. +``` + +실행한 명령: + +```bash +uv run tools/flow.py push reference/lab02-example2-ask.json +uv run tools/flow.py test cases/lab02.json -v +``` + +출력 (그대로): + +```text +! 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 + +✔ 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 +``` + +**쉽게 말하면** + +- 작업 요청은 LLM 앞에서 거절 `read_only` (0.0초). 비용 92.01 · 릴리스 62.5 `numbers_ok: true` (1.2.0은 버전으로 통째로). +- 숫자 확인은 지어낸 숫자만 잡습니다. 예제 1의 "#1은 대기 중"처럼 사실에 있는 숫자를 틀린 뜻으로 쓴 것은 못 잡습니다. 예상한 결과. + +### 12 · Lab 02 예제 3 · 질문을 바꾸면 → 6/6 (규칙대로) + +실행한 명령: + +```bash +uv run tools/flow.py test cases/lab02-phrasing.json -v +``` + +출력 (그대로): + +```text +✔ 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 +``` + +**쉽게 말하면** + +- 에러 → runs, 카나리 꺼 줘 → 작업 거절, 알림이랑 DLQ → alerts(표의 앞), 배포 상태 요약 → overview(2 > 1). +- "장애 났어?" · "AI 쓰는 데 얼마나 나갔어?"는 맞는 낱말이 없어 거절 — 규칙대로라 ✔이지만 운영자에게는 불편. 낱말 라우터는 모르면 거절하는 쪽으로 실패합니다. 예상한 결과. + +### 13 · Lab 02 과제 1 하기 전 → `lab02-challenge` 1/5 + +실행한 명령: + +```bash +uv run tools/flow.py test cases/lab02-challenge.json -v +``` + +출력 (그대로): + +```text +✔ 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 +``` + +**쉽게 말하면** + +- 낱말 없는 세 질문은 거절, 점심은 `keyword` 거절. 낱말이 있는 질문만 원래 됩니다. 예상한 결과. + +### 14 · Lab 02 과제 1 · LLM 라우터 → 5/5 + +codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/ops-ask.json` · 이 기록에서 push한 같은 역할의 정답 `answers/79-lab02-challenge-llm-router.json`): + +```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 도. +``` + +실행한 명령: + +```bash +uv run tools/flow.py push answers/79-lab02-challenge-llm-router.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 +! 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 + +✔ 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 + +✔ 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 + +✔ 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 +``` + +**쉽게 말하면** + +- 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` — 숫자 확인의 한계. 예상한 결과. + +### 15 · Lab 02 과제 2 하기 전 → `lab02-challenge2` 4/5 + +실행한 명령: + +```bash +uv run tools/flow.py test cases/lab02-challenge2.json -v +``` + +출력 (그대로): + +```text +✔ 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 +``` + +**쉽게 말하면** + +- 응답 넷은 맞고, 감사 기록이 비어 있어 기록 케이스만 ✘. 예상한 결과. + +### 16 · Lab 02 과제 2 · 감사 기록 → 5/5 + +codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/ops-ask.json` · 이 기록에서 push한 같은 역할의 정답 `answers/79-lab02-challenge2-audit.json`): + +```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 도. +``` + +실행한 명령: + +```bash +uv run tools/flow.py push answers/79-lab02-challenge2-audit.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 +! 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 + +✔ 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 + +✔ 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 + +✔ 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 +``` + +**쉽게 말하면** + +- 기록 3줄: `read_only` · `keyword` / `out_of_scope` · `llm` / `runs` · `numbers_ok: true`. 응답은 그대로 (`lab02` 6/6 · `lab02-challenge` 5/5). 예상한 결과. + +## Lab 03 · 브리핑과 평가 · 먼저 볼 것을 정하고, 도우미를 시험하기 + +### 17 · Lab 03 예제 1 하기 전 → `lab03` 0/4 + +실행한 명령: + +```bash +uv run tools/flow.py test cases/lab03.json -v +``` + +출력 (그대로): + +```text +✔ 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 + } + } +``` + +**쉽게 말하면** + +- `my-ops-brief`가 없어 404. 예상한 결과. + +### 18 · Lab 03 예제 1 · 브리핑 → 4/4 + +codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/ops-brief.json` · 이 기록에서 push한 같은 역할의 정답 `reference/lab03-example1-brief.json`): + +```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. +``` + +실행한 명령: + +```bash +uv run tools/flow.py push reference/lab03-example1-brief.json +uv run tools/flow.py test cases/lab03.json -v +uv run tools/flow.py call my-ops-brief '{}' +``` + +출력 (그대로): + +```text +✔ pushed '79 Ops brief (reference)' (id w97397012b52e601) + POST http://localhost:5678/webhook/my-ops-brief + +✔ 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 + } + } + +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일차)" +} +``` + +**쉽게 말하면** + +- 🔴 전체, 표 여섯 줄 (`| DLQ | 🔴 | 3건 (parked 2) |`), 먼저 볼 것 실행 · DLQ · 릴리스와 정해진 할 일. DLQ를 비우면 셋째에 알림. LLM 없음. 예상한 결과. + +### 19 · Lab 03 예제 2 하기 전 → `lab03-llm` 0/2 + +실행한 명령: + +```bash +uv run tools/flow.py test cases/lab03-llm.json -v +``` + +출력 (그대로): + +```text +✔ 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 +``` + +**쉽게 말하면** + +- `summary` · `## 요약`이 없음. 예상한 결과. + +### 20 · Lab 03 예제 2 · LLM 요약 → 2/2 + +codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/ops-brief.json` · 이 기록에서 push한 같은 역할의 정답 `reference/lab03-example2-brief.json`): + +```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 도. +``` + +실행한 명령: + +```bash +uv run tools/flow.py push reference/lab03-example2-brief.json +uv run tools/flow.py test cases/lab03-llm.json -v +uv run tools/flow.py test cases/lab03.json +``` + +출력 (그대로): + +```text +! 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 + +✔ 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 + +✔ 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 +``` + +**쉽게 말하면** + +- 세 문장 "… 가장 긴급한 문제는 'Search FAQ' 노드 … 16건 …", `numbers_ok: true`, 1~3.5초. 두 번 부르면 글이 조금 다르지만 숫자 확인은 둘 다 통과. +- 요약은 먼저 볼 것 셋 중 하나만 말합니다 — 표 · 순서는 Code가. `lab03` 4/4. 예상한 결과. + +### 21 · Lab 03 예제 3 · 아침이 바뀌면 → 5/5 + +실행한 명령: + +```bash +uv run tools/flow.py test cases/lab03-after.json -v +``` + +출력 (그대로): + +```text +✔ 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 +``` + +**쉽게 말하면** + +- parked 둘 처리 → DLQ 🟡 · 먼저 볼 것에 알림, 카나리 끔 → 릴리스 🟢 · 빨강은 실행 하나, 예산 0.2 → 비용 🟢. +- 빨강이 사라진 까닭이 셋 다 다릅니다 (일을 끝냄 · 위험을 멈춤 · 한도를 바꿈). 남은 빨강(FAQ timeout)은 고칠 사람이 있는 일. 예상한 결과. + +### 22 · Lab 03 과제 1 하기 전 → `lab03-challenge` 0/3 + +실행한 명령: + +```bash +uv run tools/flow.py test cases/lab03-challenge.json -v +``` + +출력 (그대로): + +```text +✔ 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 +``` + +**쉽게 말하면** + +- `my-ops-eval`이 없어 404. 예상한 결과. + +### 23 · Lab 03 과제 1 · 골든 질문 평가 → 3/3 + +codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/ops-eval.json` · 이 기록에서 push한 같은 역할의 정답 `answers/79-lab03-challenge-eval.json`): + +```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. +``` + +실행한 명령: + +```bash +uv run tools/flow.py push answers/79-lab03-challenge-eval.json +uv run tools/flow.py test cases/lab03-challenge.json -v +``` + +출력 (그대로): + +```text +! 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 + +✔ 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 +``` + +**쉽게 말하면** + +- 골든 8/8 · score 1 (3.9초), 틀린 기대(알림 질문의 영역을 `cost`로)는 `area_ok: false`로 잡음, 빈 목록 400. +- 8/8이어도 '숫자는 맞고 뜻이 틀린 답'은 통과합니다 — 골든 질문에 틀리기 쉬운 것을 더해 갑니다. 예상한 결과. + +### 24 · Lab 03 과제 2 하기 전 → `lab03-challenge2` 0/4 + +실행한 명령: + +```bash +uv run tools/flow.py test cases/lab03-challenge2.json -v +``` + +출력 (그대로): + +```text +✔ 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 +``` + +**쉽게 말하면** + +- `min_score` · `gate`가 없고 알림도 없음. 예상한 결과. + +### 25 · Lab 03 과제 2 · 평가 관문 → 4/4 + +codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/ops-eval.json` · 이 기록에서 push한 같은 역할의 정답 `answers/79-lab03-challenge2-gate.json`): + +```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 도. +``` + +실행한 명령: + +```bash +uv run tools/flow.py push answers/79-lab03-challenge2-gate.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 +uv run tools/flow.py ops --reset +uv run tools/flow.py doctor +``` + +출력 (그대로): + +```text +! 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 + +✔ 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 + +✔ 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 + +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} + +✔ back to the seeded operations morning (alerts · DLQ · quality · usage · budget · release), live logs · added FAQ · tickets emptied, fault switch all ok + +! 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.9 pass · 골든 0.8 pass · 틀린 기대 0 fail → `eval_below` 알림 1건. `lab03-challenge` 3/3. +- 수업 준비 때 실패 케이스 **뒤에** '알림이 몇 건?'을 두니 도우미가 7건(평가가 남긴 알림 포함)이라 답해 0점 — 평가가 바꾼 상태가 다음 평가를 바꿈. 순서를 바꿨습니다. +- 마지막 `ops --reset`으로 운영 아침을 되돌리고 `doctor`. 예상한 결과. + +## 다시 돌려 보기 + +강사 노트북에서 같은 순서를 다시 돌리려면 (강사 저장소 `~/work/AIO-01D4/Last3Mon/79`, 정답 파일이 있는 곳): + +```bash +cd ~/work/AIO-01D4/Last3Mon/79 +uv run tools/flow.py ops --reset +uv run tools/flow.py push reference/baseline-ops-facts.json reference/baseline-ops-ask.json answers/79-lab01-challenge2-sources.json +uv run tools/flow.py test cases/lab01-period.json -v && uv run tools/flow.py test cases/lab01-challenge2.json -v +uv run tools/flow.py test cases/lab02.json --webhook base-ops-ask +uv run tools/flow.py push reference/lab02-example2-ask.json && uv run tools/flow.py test cases/lab02-phrasing.json -v +uv run tools/flow.py push answers/79-lab02-challenge2-audit.json reference/lab03-example2-brief.json answers/79-lab03-challenge2-gate.json +uv run tools/flow.py test cases/lab02-challenge2.json -v && uv run tools/flow.py test cases/lab03-after.json -v +uv run tools/flow.py test cases/lab03-challenge2.json -v +uv run tools/flow.py ops --reset && uv run tools/flow.py doctor +``` + +- 결과의 ✔ · ✘(판정)는 같게 나와야 합니다. 사실 · 상태 · 거절은 늘 같고, 시간과 LLM 답 글은 다를 수 있습니다 (LLM 라우터가 다른 영역을 고르면 그 질문을 `OPS.md` 5절에). diff --git a/Last3Mon/79/cases/lab01-challenge.json b/Last3Mon/79/cases/lab01-challenge.json new file mode 100644 index 0000000..24efd19 --- /dev/null +++ b/Last3Mon/79/cases/lab01-challenge.json @@ -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" + ] + } + } + } + ] +} diff --git a/Last3Mon/79/cases/lab01-challenge2.json b/Last3Mon/79/cases/lab01-challenge2.json new file mode 100644 index 0000000..029bbb1 --- /dev/null +++ b/Last3Mon/79/cases/lab01-challenge2.json @@ -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 + } + } + } + ] +} diff --git a/Last3Mon/79/cases/lab01-period.json b/Last3Mon/79/cases/lab01-period.json new file mode 100644 index 0000000..b92ad92 --- /dev/null +++ b/Last3Mon/79/cases/lab01-period.json @@ -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" + } + } + } + ] +} diff --git a/Last3Mon/79/cases/lab01.json b/Last3Mon/79/cases/lab01.json new file mode 100644 index 0000000..b9395e7 --- /dev/null +++ b/Last3Mon/79/cases/lab01.json @@ -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 + } + } + } + ] +} diff --git a/Last3Mon/79/cases/lab02-challenge.json b/Last3Mon/79/cases/lab02-challenge.json new file mode 100644 index 0000000..9a5dbed --- /dev/null +++ b/Last3Mon/79/cases/lab02-challenge.json @@ -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" + } + } + } + ] +} diff --git a/Last3Mon/79/cases/lab02-challenge2.json b/Last3Mon/79/cases/lab02-challenge2.json new file mode 100644 index 0000000..e62149a --- /dev/null +++ b/Last3Mon/79/cases/lab02-challenge2.json @@ -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": "읽기만" + } + } + } + ] +} diff --git a/Last3Mon/79/cases/lab02-phrasing.json b/Last3Mon/79/cases/lab02-phrasing.json new file mode 100644 index 0000000..54107b4 --- /dev/null +++ b/Last3Mon/79/cases/lab02-phrasing.json @@ -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" + } + } + } + ] +} diff --git a/Last3Mon/79/cases/lab02.json b/Last3Mon/79/cases/lab02.json new file mode 100644 index 0000000..8821478 --- /dev/null +++ b/Last3Mon/79/cases/lab02.json @@ -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" + } + } + } + ] +} diff --git a/Last3Mon/79/cases/lab03-after.json b/Last3Mon/79/cases/lab03-after.json new file mode 100644 index 0000000..80e4f20 --- /dev/null +++ b/Last3Mon/79/cases/lab03-after.json @@ -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" + } + } + } + ] +} diff --git a/Last3Mon/79/cases/lab03-challenge.json b/Last3Mon/79/cases/lab03-challenge.json new file mode 100644 index 0000000..c28dfe8 --- /dev/null +++ b/Last3Mon/79/cases/lab03-challenge.json @@ -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 + } + } + ] +} diff --git a/Last3Mon/79/cases/lab03-challenge2.json b/Last3Mon/79/cases/lab03-challenge2.json new file mode 100644 index 0000000..77139c9 --- /dev/null +++ b/Last3Mon/79/cases/lab03-challenge2.json @@ -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 + } + } + } + ] +} diff --git a/Last3Mon/79/cases/lab03-llm.json b/Last3Mon/79/cases/lab03-llm.json new file mode 100644 index 0000000..e326999 --- /dev/null +++ b/Last3Mon/79/cases/lab03-llm.json @@ -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", + "릴리스", + "카나리", + "실행", + "오류" + ] + } + } + } + ] +} diff --git a/Last3Mon/79/cases/lab03.json b/Last3Mon/79/cases/lab03.json new file mode 100644 index 0000000..664331e --- /dev/null +++ b/Last3Mon/79/cases/lab03.json @@ -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" + } + } + } + ] +} diff --git a/Last3Mon/79/data-api/compose.yml b/Last3Mon/79/data-api/compose.yml new file mode 100644 index 0000000..48eb855 --- /dev/null +++ b/Last3Mon/79/data-api/compose.yml @@ -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 diff --git a/Last3Mon/79/data-api/data/budget.json b/Last3Mon/79/data-api/data/budget.json new file mode 100644 index 0000000..6c9b313 --- /dev/null +++ b/Last3Mon/79/data-api/data/budget.json @@ -0,0 +1 @@ +{"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500} diff --git a/Last3Mon/79/data-api/data/docs/device-issue-return.md b/Last3Mon/79/data-api/data/docs/device-issue-return.md new file mode 100644 index 0000000..31f66bf --- /dev/null +++ b/Last3Mon/79/data-api/data/docs/device-issue-return.md @@ -0,0 +1,17 @@ +# 업무용 장비 지급·반납 규정 + +## 1. 지급과 교체 +- 업무용 노트북은 입사일에 지급한다. +- 노트북 교체 주기는 4년이다. 고장이나 배터리 교체는 IT 문의로 접수한다. + +## 2. 분실과 도난 +- 업무용 장비를 잃어버리거나 도난당하면 24시간 이내에 IT 문의와 보안팀에 신고한다. +- 신고가 접수되면 IT 담당자가 원격으로 장비의 데이터를 잠근다. + +## 3. 반납 +- 퇴사하거나 장비를 교체할 때는 5영업일 이내에 장비를 IT 담당자에게 반납한다. +- 반납 전에 개인 파일을 지우지 말고, 업무 파일은 사내 드라이브로 옮긴다. + +## 4. 개인 장비 사용 +- 개인 휴대폰이나 태블릿으로는 업무 메일과 메신저만 쓸 수 있다. +- 개인 장비로 업무 메일을 보려면 회사의 모바일 관리 앱(MDM)을 먼저 설치한다. diff --git a/Last3Mon/79/data-api/data/docs/email-and-documents.md b/Last3Mon/79/data-api/data/docs/email-and-documents.md new file mode 100644 index 0000000..ca38d0d --- /dev/null +++ b/Last3Mon/79/data-api/data/docs/email-and-documents.md @@ -0,0 +1,14 @@ +# 메일·문서 보안 규정 + +## 1. 메일함 +- 개인 메일함 용량은 10GB이다. 용량이 90%를 넘으면 안내 메일이 발송된다. +- 오래된 메일은 보관함으로 옮겨 용량을 확보한다. + +## 2. 첨부 파일 +- 메일 첨부 파일은 25MB까지 보낼 수 있다. +- 25MB를 넘는 파일은 사내 드라이브에 올리고 공유 링크를 보낸다. +- 개인정보가 들어 있는 파일을 외부로 보낼 때는 반드시 암호를 걸고, 암호는 메신저 등 다른 경로로 알려 준다. + +## 3. 문서 등급과 보관 +- 대외비 문서에는 문서 첫 장에 '대외비'라고 표시한다. +- 업무 문서는 3년 동안 보관한 뒤 정리한다. diff --git a/Last3Mon/79/data-api/data/docs/meeting-room.md b/Last3Mon/79/data-api/data/docs/meeting-room.md new file mode 100644 index 0000000..4aed1c6 --- /dev/null +++ b/Last3Mon/79/data-api/data/docs/meeting-room.md @@ -0,0 +1,12 @@ +# 회의실 이용 규정 + +## 1. 예약 +- 회의실은 사내 포털에서 예약한다. 한 번에 최대 3시간까지 예약할 수 있다. +- 예약 시작 후 10분이 지나도록 아무도 사용하지 않으면 예약이 자동으로 취소된다. + +## 2. 장비 +- 대회의실의 화상회의 장비를 쓰려면 회의 시작 30분 전까지 IT 문의로 요청한다. +- 프로젝터는 B회의실과 대회의실에 있다. + +## 3. 사용 후 +- 회의가 끝나면 화이트보드를 지우고 의자를 정리한다. diff --git a/Last3Mon/79/data-api/data/docs/password-policy.md b/Last3Mon/79/data-api/data/docs/password-policy.md new file mode 100644 index 0000000..c069749 --- /dev/null +++ b/Last3Mon/79/data-api/data/docs/password-policy.md @@ -0,0 +1,18 @@ +# 비밀번호·계정 보안 규정 + +## 1. 비밀번호 만들기 +- 비밀번호는 12자 이상으로 만든다. +- 영문 대문자, 영문 소문자, 숫자, 특수문자 중 3종류 이상을 섞는다. +- 이름, 생일, 전화번호, 회사 이름처럼 추측하기 쉬운 단어는 쓰지 않는다. + +## 2. 변경 주기 +- 비밀번호는 90일마다 바꾼다. 만료 7일 전부터 사내 포털에 변경 안내가 표시된다. +- 최근에 쓴 비밀번호 5개는 다시 쓸 수 없다. + +## 3. 로그인 실패와 잠금 +- 로그인에 5번 연속 실패하면 계정이 30분 동안 잠긴다. +- 30분이 지나면 자동으로 풀린다. 급한 경우 IT 문의로 잠금 해제를 요청한다. + +## 4. 다단계 인증 +- 회사 밖에서 사내 시스템에 접속할 때는 OTP 다단계 인증을 반드시 사용한다. +- OTP 앱이 설치된 휴대폰을 바꾸면 IT 포털에서 OTP를 다시 등록한다. diff --git a/Last3Mon/79/data-api/data/docs/privacy.md b/Last3Mon/79/data-api/data/docs/privacy.md new file mode 100644 index 0000000..a5ba949 --- /dev/null +++ b/Last3Mon/79/data-api/data/docs/privacy.md @@ -0,0 +1,13 @@ +# 개인정보 처리 지침 + +## 1. 수집 +- 업무에 꼭 필요한 개인정보만 수집한다. +- 주민등록번호는 법에서 정한 경우가 아니면 수집하지 않는다. + +## 2. 이용과 보관 +- 고객 개인정보가 보이는 화면은 캡처하거나 촬영하지 않는다. +- 개인정보가 들어 있는 출력물은 다 쓴 뒤 바로 파쇄한다. + +## 3. 유출 대응 +- 개인정보가 유출되었거나 유출이 의심되면 1시간 이내에 보안팀에 신고한다. +- 신고할 때는 유출된 정보의 종류, 대략적인 건수, 발견한 시각을 함께 알린다. diff --git a/Last3Mon/79/data-api/data/docs/remote-work-vpn.md b/Last3Mon/79/data-api/data/docs/remote-work-vpn.md new file mode 100644 index 0000000..ae0c917 --- /dev/null +++ b/Last3Mon/79/data-api/data/docs/remote-work-vpn.md @@ -0,0 +1,15 @@ +# 원격근무·VPN 이용 규정 + +## 1. 접속 원칙 +- 회사 밖에서 사내 시스템에 접속할 때는 반드시 회사 VPN을 먼저 연결한다. +- 카페, 공항 같은 공용 와이파이에서는 VPN 없이 사내 시스템에 접속하지 않는다. + +## 2. VPN 인증서 +- VPN 인증서의 유효 기간은 1년이다. +- 만료 30일 전부터 IT 포털의 '인증서 재발급' 메뉴에서 새 인증서를 신청한다. +- 인증서가 만료되면 VPN에 접속할 수 없으므로, 만료 전에 재발급한다. + +## 3. 재택근무 중 보안 +- 자리를 비울 때는 화면을 잠그고, 5분 동안 사용하지 않으면 자동으로 잠기게 설정한다. +- 가족과 함께 쓰는 컴퓨터로는 업무를 하지 않는다. +- VPN 접속 기록은 1년 동안 보관된다. diff --git a/Last3Mon/79/data-api/data/docs/security-incident.md b/Last3Mon/79/data-api/data/docs/security-incident.md new file mode 100644 index 0000000..a6b514e --- /dev/null +++ b/Last3Mon/79/data-api/data/docs/security-incident.md @@ -0,0 +1,11 @@ +# 보안 사고 대응 절차 + +## 1. 피싱 메일 +- 보낸 사람이 의심스럽거나 급하게 클릭을 요구하는 메일은 링크를 누르지 않는다. +- 메일 화면의 '피싱 신고' 버튼으로 신고한다. +- 이미 링크를 눌렀거나 비밀번호를 입력했다면 즉시 비밀번호를 바꾸고 IT 문의로 알린다. + +## 2. 악성코드 감염 의심 +- 컴퓨터가 갑자기 느려지거나 모르는 프로그램이 실행되면 악성코드 감염을 의심한다. +- 감염이 의심되면 먼저 랜선을 뽑고 와이파이를 꺼서 네트워크에서 분리한 뒤 IT 문의로 알린다. +- 컴퓨터를 끄거나 파일을 지우지 않는다. 원인 분석에 필요하다. diff --git a/Last3Mon/79/data-api/data/docs/software-usage.md b/Last3Mon/79/data-api/data/docs/software-usage.md new file mode 100644 index 0000000..edabe96 --- /dev/null +++ b/Last3Mon/79/data-api/data/docs/software-usage.md @@ -0,0 +1,14 @@ +# 소프트웨어 사용 규정 + +## 1. 설치와 승인 +- 업무용 컴퓨터에는 승인된 소프트웨어만 설치한다. +- 새 소프트웨어가 필요하면 IT 포털의 '소프트웨어 신청'에서 요청한다. 승인까지 보통 2영업일이 걸린다. +- 무료 소프트웨어라도 회사 업무에 쓰려면 상업적 이용이 허용되는지 라이선스를 확인한다. + +## 2. 금지 사항 +- 불법으로 복제한 소프트웨어는 설치하지 않는다. 적발되면 징계 대상이 된다. + +## 3. 생성형 AI 서비스 +- 회사가 승인한 생성형 AI 서비스만 업무에 사용한다. +- 고객 정보, 개인정보, 대외비 문서는 생성형 AI 서비스에 입력하지 않는다. +- 생성형 AI가 만든 결과는 사람이 확인한 뒤에 사용한다. diff --git a/Last3Mon/79/data-api/data/employees.json b/Last3Mon/79/data-api/data/employees.json new file mode 100644 index 0000000..7b4a980 --- /dev/null +++ b/Last3Mon/79/data-api/data/employees.json @@ -0,0 +1,26 @@ +[ + { + "name": "이준호", + "dept": "영업팀" + }, + { + "name": "박서연", + "dept": "재무팀" + }, + { + "name": "최유진", + "dept": "인사팀" + }, + { + "name": "한지훈", + "dept": "개발팀" + }, + { + "name": "김민지", + "dept": "IT팀" + }, + { + "name": "정우성", + "dept": "IT팀" + } +] diff --git a/Last3Mon/79/data-api/data/faq.json b/Last3Mon/79/data-api/data/faq.json new file mode 100644 index 0000000..10eb7f1 --- /dev/null +++ b/Last3Mon/79/data-api/data/faq.json @@ -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 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."} +] diff --git a/Last3Mon/79/data-api/data/ops-seed.json b/Last3Mon/79/data-api/data/ops-seed.json new file mode 100644 index 0000000..fdc984d --- /dev/null +++ b/Last3Mon/79/data-api/data/ops-seed.json @@ -0,0 +1,2114 @@ +{ + "seeded_at": "2026-11-04T08:30:00", + "note": "79일차 운영 아침 상태 (연습 데이터, seed 79)", + "budget": { + "daily_usd": 0.08, + "user_limit_per_min": 20, + "max_question_chars": 500 + }, + "releases": { + "helpdesk": { + "active": "1.1.0", + "canary": { + "release": "1.2.0", + "percent": 20 + } + } + }, + "alerts": [ + { + "workflow": "helpdesk", + "node": "Search FAQ", + "severity": "high", + "reason": "timeout", + "message": "timeout of 1000ms exceeded", + "at": "2026-10-26T09:10:00" + }, + { + "workflow": "helpdesk", + "node": "Answer", + "severity": "high", + "reason": "rate_limit", + "message": "429 Rate limit reached for requests", + "at": "2026-10-29T15:20:00" + }, + { + "workflow": "faq-import", + "node": "Batch ok?", + "severity": "high", + "reason": "batch_rejected", + "batch": "B-1", + "message": "B-1 rejected", + "at": "2026-11-03T10:40:00" + }, + { + "workflow": "faq-import", + "node": "Low score?", + "severity": "medium", + "reason": "low_quality", + "batch": "B-2", + "score": 0.8, + "message": "B-2 quality 0.8", + "at": "2026-11-03T11:05:00" + }, + { + "workflow": "faq-import", + "node": "Low score?", + "severity": "medium", + "reason": "low_quality", + "batch": "B-3", + "score": 0.8333, + "message": "B-3 quality 0.8333", + "at": "2026-11-03T11:20:00" + }, + { + "workflow": "intake", + "node": "Create ticket", + "severity": "medium", + "reason": "api_error", + "message": "tickets API 503", + "at": "2026-11-03T22:15:00" + } + ], + "dlq": [ + { + "dlq_id": 1, + "workflow": "intake", + "item": { + "key": "K-1031-07", + "title": "모니터 깜빡임", + "requester": "강민수" + }, + "reason": "tickets API 503", + "attempts": 3, + "parked": true, + "at": "2026-11-03T22:15:00" + }, + { + "dlq_id": 2, + "workflow": "intake", + "item": { + "key": "K-1031-08", + "title": "프린터 용지 걸림", + "requester": "서지아" + }, + "reason": "tickets API 503", + "attempts": 3, + "parked": true, + "at": "2026-11-03T22:16:00" + }, + { + "dlq_id": 3, + "workflow": "intake", + "item": { + "key": "K-1104-01", + "title": "VPN 접속 안 됨", + "requester": "윤하늘" + }, + "reason": "timeout", + "attempts": 1, + "parked": false, + "at": "2026-11-04T07:50:00" + } + ], + "quality_log": [ + { + "batch": "B-1", + "dataset": "faq", + "total": 13, + "valid": 5, + "quarantined": 0, + "rejected": true, + "score": 0, + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1 + }, + "at": "2026-11-03T10:40:00" + }, + { + "batch": "B-2", + "dataset": "faq", + "total": 10, + "valid": 8, + "quarantined": 2, + "rejected": false, + "score": 0.8, + "codes": { + "pii": 1, + "llm_mismatch": 1 + }, + "at": "2026-11-03T11:05:00" + }, + { + "batch": "B-3", + "dataset": "faq", + "total": 6, + "valid": 5, + "quarantined": 1, + "rejected": false, + "score": 0.8333, + "codes": { + "answer_too_short": 1 + }, + "at": "2026-11-03T11:20:00" + }, + { + "batch": "B-2-fix", + "dataset": "faq", + "total": 2, + "valid": 2, + "quarantined": 0, + "rejected": false, + "score": 1, + "codes": {}, + "at": "2026-11-03T14:30:00" + } + ], + "quarantine": [ + { + "row_id": "FAQ-305", + "batch": "B-3", + "row": { + "id": "FAQ-305", + "title": "교육 신청", + "keywords": [ + "교육" + ], + "answer": "짧" + }, + "errors": [ + "answer_too_short" + ], + "at": "2026-11-03T11:20:00" + } + ], + "usage": [ + { + "request_id": "r-1", + "user": "강민수", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 532, + "completion_tokens": 80, + "cost_usd": 0.0003408, + "route": "자동 답변" + }, + { + "request_id": "r-2", + "user": "서지아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 557, + "completion_tokens": 123, + "cost_usd": 0.0004196, + "route": "자동 답변" + }, + { + "request_id": "r-2", + "user": "서지아", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 419, + "completion_tokens": 68, + "cost_usd": 6.91e-05, + "route": "자동 답변" + }, + { + "request_id": "r-3", + "user": "배수아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 408, + "completion_tokens": 122, + "cost_usd": 0.0003584, + "route": "자동 답변" + }, + { + "request_id": "r-3", + "user": "배수아", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 563, + "completion_tokens": 99, + "cost_usd": 9.59e-05, + "route": "자동 답변" + }, + { + "request_id": "r-4", + "user": "강민수", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 591, + "completion_tokens": 107, + "cost_usd": 0.0004076, + "route": "자동 답변" + }, + { + "request_id": "r-5", + "user": "한유나", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 477, + "completion_tokens": 73, + "cost_usd": 0.0003076, + "route": "자동 답변" + }, + { + "request_id": "r-6", + "user": "임도윤", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 504, + "completion_tokens": 65, + "cost_usd": 0.0003056, + "route": "자동 답변" + }, + { + "request_id": "r-7", + "user": "배수아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 496, + "completion_tokens": 70, + "cost_usd": 0.0003104, + "route": "자동 답변" + }, + { + "request_id": "r-8", + "user": "오세훈", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 540, + "completion_tokens": 74, + "cost_usd": 0.0003344, + "route": "자동 답변" + }, + { + "request_id": "r-9", + "user": "서지아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 449, + "completion_tokens": 104, + "cost_usd": 0.000346, + "route": "자동 답변" + }, + { + "request_id": "r-9", + "user": "서지아", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 436, + "completion_tokens": 100, + "cost_usd": 8.36e-05, + "route": "자동 답변" + }, + { + "request_id": "r-10", + "user": "김민지", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 396, + "completion_tokens": 64, + "cost_usd": 0.0002608, + "route": "자동 답변" + }, + { + "request_id": "r-10", + "user": "김민지", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 529, + "completion_tokens": 130, + "cost_usd": 0.0001049, + "route": "자동 답변" + }, + { + "request_id": "r-11", + "user": "배수아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 417, + "completion_tokens": 100, + "cost_usd": 0.0003268, + "route": "자동 답변" + }, + { + "request_id": "r-12", + "user": "서지아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 557, + "completion_tokens": 94, + "cost_usd": 0.0003732, + "route": "자동 답변" + }, + { + "request_id": "r-13", + "user": "배수아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 385, + "completion_tokens": 127, + "cost_usd": 0.0003572, + "route": "자동 답변" + }, + { + "request_id": "r-14", + "user": "강민수", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 508, + "completion_tokens": 139, + "cost_usd": 0.0004256, + "route": "자동 답변" + }, + { + "request_id": "r-15", + "user": "이서준", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 536, + "completion_tokens": 130, + "cost_usd": 0.0004224, + "route": "자동 답변" + }, + { + "request_id": "r-16", + "user": "배수아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 589, + "completion_tokens": 114, + "cost_usd": 0.000418, + "route": "자동 답변" + }, + { + "request_id": "r-16", + "user": "배수아", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 411, + "completion_tokens": 61, + "cost_usd": 6.55e-05, + "route": "자동 답변" + }, + { + "request_id": "r-17", + "user": "오세훈", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 563, + "completion_tokens": 89, + "cost_usd": 0.0003676, + "route": "자동 답변" + }, + { + "request_id": "r-18", + "user": "정다은", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 505, + "completion_tokens": 89, + "cost_usd": 0.0003444, + "route": "자동 답변" + }, + { + "request_id": "r-19", + "user": "서지아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 443, + "completion_tokens": 130, + "cost_usd": 0.0003852, + "route": "자동 답변" + }, + { + "request_id": "r-20", + "user": "이서준", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 609, + "completion_tokens": 78, + "cost_usd": 0.0003684, + "route": "자동 답변" + }, + { + "request_id": "r-20", + "user": "이서준", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 501, + "completion_tokens": 71, + "cost_usd": 7.85e-05, + "route": "자동 답변" + }, + { + "request_id": "r-21", + "user": "이서준", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 472, + "completion_tokens": 130, + "cost_usd": 0.0003968, + "route": "자동 답변" + }, + { + "request_id": "r-22", + "user": "강민수", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 397, + "completion_tokens": 82, + "cost_usd": 0.00029, + "route": "자동 답변" + }, + { + "request_id": "r-23", + "user": "한유나", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 558, + "completion_tokens": 85, + "cost_usd": 0.0003592, + "route": "자동 답변" + }, + { + "request_id": "r-24", + "user": "윤하늘", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 452, + "completion_tokens": 115, + "cost_usd": 0.0003648, + "route": "자동 답변" + }, + { + "request_id": "r-25", + "user": "임도윤", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 441, + "completion_tokens": 84, + "cost_usd": 0.0003108, + "route": "자동 답변" + }, + { + "request_id": "r-26", + "user": "임도윤", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 425, + "completion_tokens": 73, + "cost_usd": 0.0002868, + "route": "자동 답변" + }, + { + "request_id": "r-26", + "user": "임도윤", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 391, + "completion_tokens": 135, + "cost_usd": 9.31e-05, + "route": "자동 답변" + }, + { + "request_id": "r-27", + "user": "임도윤", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 579, + "completion_tokens": 74, + "cost_usd": 0.00035, + "route": "자동 답변" + }, + { + "request_id": "r-28", + "user": "오세훈", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 553, + "completion_tokens": 99, + "cost_usd": 0.0003796, + "route": "자동 답변" + }, + { + "request_id": "r-29", + "user": "배수아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 588, + "completion_tokens": 128, + "cost_usd": 0.00044, + "route": "자동 답변" + }, + { + "request_id": "r-30", + "user": "임도윤", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 509, + "completion_tokens": 97, + "cost_usd": 0.0003588, + "route": "자동 답변" + }, + { + "request_id": "r-31", + "user": "임도윤", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 610, + "completion_tokens": 102, + "cost_usd": 0.0004072, + "route": "자동 답변" + }, + { + "request_id": "r-32", + "user": "임도윤", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 418, + "completion_tokens": 88, + "cost_usd": 0.000308, + "route": "자동 답변" + }, + { + "request_id": "r-33", + "user": "이서준", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 560, + "completion_tokens": 108, + "cost_usd": 0.0003968, + "route": "자동 답변" + }, + { + "request_id": "r-33", + "user": "이서준", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 586, + "completion_tokens": 137, + "cost_usd": 0.0001134, + "route": "자동 답변" + }, + { + "request_id": "r-34", + "user": "이서준", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 604, + "completion_tokens": 77, + "cost_usd": 0.0003648, + "route": "자동 답변" + }, + { + "request_id": "r-35", + "user": "한유나", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 540, + "completion_tokens": 118, + "cost_usd": 0.0004048, + "route": "자동 답변" + }, + { + "request_id": "r-36", + "user": "이서준", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 397, + "completion_tokens": 121, + "cost_usd": 0.0003524, + "route": "자동 답변" + }, + { + "request_id": "r-37", + "user": "김민지", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 505, + "completion_tokens": 116, + "cost_usd": 0.0003876, + "route": "자동 답변" + }, + { + "request_id": "r-37", + "user": "김민지", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 480, + "completion_tokens": 130, + "cost_usd": 0.0001, + "route": "자동 답변" + }, + { + "request_id": "r-38", + "user": "임도윤", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 415, + "completion_tokens": 68, + "cost_usd": 0.0002748, + "route": "자동 답변" + }, + { + "request_id": "r-39", + "user": "정다은", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 429, + "completion_tokens": 91, + "cost_usd": 0.0003172, + "route": "자동 답변" + }, + { + "request_id": "r-39", + "user": "정다은", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 453, + "completion_tokens": 119, + "cost_usd": 9.29e-05, + "route": "자동 답변" + }, + { + "request_id": "r-40", + "user": "이서준", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 459, + "completion_tokens": 120, + "cost_usd": 0.0003756, + "route": "자동 답변" + }, + { + "request_id": "r-41", + "user": "서지아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 466, + "completion_tokens": 110, + "cost_usd": 0.0003624, + "route": "자동 답변" + }, + { + "request_id": "r-42", + "user": "임도윤", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 399, + "completion_tokens": 87, + "cost_usd": 0.0002988, + "route": "자동 답변" + }, + { + "request_id": "r-43", + "user": "서지아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 429, + "completion_tokens": 100, + "cost_usd": 0.0003316, + "route": "자동 답변" + }, + { + "request_id": "r-44", + "user": "배수아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 587, + "completion_tokens": 118, + "cost_usd": 0.0004236, + "route": "자동 답변" + }, + { + "request_id": "r-45", + "user": "김민지", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 544, + "completion_tokens": 127, + "cost_usd": 0.0004208, + "route": "자동 답변" + }, + { + "request_id": "r-46", + "user": "윤하늘", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 539, + "completion_tokens": 136, + "cost_usd": 0.0004332, + "route": "자동 답변" + }, + { + "request_id": "r-47", + "user": "한유나", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 425, + "completion_tokens": 73, + "cost_usd": 0.0002868, + "route": "자동 답변" + }, + { + "request_id": "r-47", + "user": "한유나", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 472, + "completion_tokens": 86, + "cost_usd": 8.16e-05, + "route": "자동 답변" + }, + { + "request_id": "r-48", + "user": "배수아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 412, + "completion_tokens": 129, + "cost_usd": 0.0003712, + "route": "자동 답변" + }, + { + "request_id": "r-48", + "user": "배수아", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 394, + "completion_tokens": 133, + "cost_usd": 9.26e-05, + "route": "자동 답변" + }, + { + "request_id": "r-49", + "user": "오세훈", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 452, + "completion_tokens": 64, + "cost_usd": 0.0002832, + "route": "자동 답변" + }, + { + "request_id": "r-50", + "user": "윤하늘", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 503, + "completion_tokens": 134, + "cost_usd": 0.0004156, + "route": "자동 답변" + }, + { + "request_id": "r-51", + "user": "임도윤", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 487, + "completion_tokens": 88, + "cost_usd": 0.0003356, + "route": "자동 답변" + }, + { + "request_id": "r-51", + "user": "임도윤", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 461, + "completion_tokens": 81, + "cost_usd": 7.85e-05, + "route": "자동 답변" + }, + { + "request_id": "r-52", + "user": "배수아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 521, + "completion_tokens": 139, + "cost_usd": 0.0004308, + "route": "자동 답변" + }, + { + "request_id": "r-53", + "user": "배수아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 581, + "completion_tokens": 116, + "cost_usd": 0.000418, + "route": "자동 답변" + }, + { + "request_id": "r-54", + "user": "배수아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 518, + "completion_tokens": 91, + "cost_usd": 0.0003528, + "route": "자동 답변" + }, + { + "request_id": "r-54", + "user": "배수아", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 399, + "completion_tokens": 110, + "cost_usd": 8.39e-05, + "route": "자동 답변" + }, + { + "request_id": "r-55", + "user": "강민수", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 386, + "completion_tokens": 120, + "cost_usd": 0.0003464, + "route": "자동 답변" + }, + { + "request_id": "r-56", + "user": "강민수", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 542, + "completion_tokens": 90, + "cost_usd": 0.0003608, + "route": "자동 답변" + }, + { + "request_id": "r-57", + "user": "한유나", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 618, + "completion_tokens": 111, + "cost_usd": 0.0004248, + "route": "자동 답변" + }, + { + "request_id": "r-57", + "user": "한유나", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 402, + "completion_tokens": 88, + "cost_usd": 7.54e-05, + "route": "자동 답변" + }, + { + "request_id": "r-58", + "user": "이서준", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 493, + "completion_tokens": 127, + "cost_usd": 0.0004004, + "route": "자동 답변" + }, + { + "request_id": "r-59", + "user": "오세훈", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 448, + "completion_tokens": 88, + "cost_usd": 0.00032, + "route": "자동 답변" + }, + { + "request_id": "r-59", + "user": "오세훈", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 451, + "completion_tokens": 111, + "cost_usd": 8.95e-05, + "route": "자동 답변" + }, + { + "request_id": "r-60", + "user": "강민수", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 552, + "completion_tokens": 86, + "cost_usd": 0.0003584, + "route": "자동 답변" + }, + { + "request_id": "r-60", + "user": "강민수", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 602, + "completion_tokens": 73, + "cost_usd": 8.94e-05, + "route": "자동 답변" + }, + { + "request_id": "r-61", + "user": "배수아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 535, + "completion_tokens": 97, + "cost_usd": 0.0003692, + "route": "자동 답변" + }, + { + "request_id": "r-61", + "user": "배수아", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 538, + "completion_tokens": 110, + "cost_usd": 9.78e-05, + "route": "자동 답변" + }, + { + "request_id": "r-62", + "user": "이서준", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 548, + "completion_tokens": 112, + "cost_usd": 0.0003984, + "route": "자동 답변" + }, + { + "request_id": "r-63", + "user": "임도윤", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 447, + "completion_tokens": 100, + "cost_usd": 0.0003388, + "route": "자동 답변" + }, + { + "request_id": "r-64", + "user": "배수아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 566, + "completion_tokens": 118, + "cost_usd": 0.0004152, + "route": "자동 답변" + }, + { + "request_id": "r-65", + "user": "윤하늘", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 526, + "completion_tokens": 78, + "cost_usd": 0.0003352, + "route": "자동 답변" + }, + { + "request_id": "r-65", + "user": "윤하늘", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 510, + "completion_tokens": 75, + "cost_usd": 8.1e-05, + "route": "자동 답변" + }, + { + "request_id": "r-66", + "user": "이서준", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 521, + "completion_tokens": 114, + "cost_usd": 0.0003908, + "route": "자동 답변" + }, + { + "request_id": "r-67", + "user": "김민지", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 465, + "completion_tokens": 117, + "cost_usd": 0.0003732, + "route": "자동 답변" + }, + { + "request_id": "r-68", + "user": "배수아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 601, + "completion_tokens": 138, + "cost_usd": 0.0004612, + "route": "자동 답변" + }, + { + "request_id": "r-69", + "user": "한유나", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 598, + "completion_tokens": 87, + "cost_usd": 0.0003784, + "route": "자동 답변" + }, + { + "request_id": "r-70", + "user": "정다은", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 444, + "completion_tokens": 134, + "cost_usd": 0.000392, + "route": "자동 답변" + }, + { + "request_id": "r-71", + "user": "이서준", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 413, + "completion_tokens": 76, + "cost_usd": 0.0002868, + "route": "자동 답변" + }, + { + "request_id": "r-71", + "user": "이서준", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 562, + "completion_tokens": 101, + "cost_usd": 9.66e-05, + "route": "자동 답변" + }, + { + "request_id": "r-72", + "user": "한유나", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 555, + "completion_tokens": 81, + "cost_usd": 0.0003516, + "route": "자동 답변" + }, + { + "request_id": "r-73", + "user": "정다은", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 423, + "completion_tokens": 78, + "cost_usd": 0.000294, + "route": "자동 답변" + }, + { + "request_id": "r-74", + "user": "임도윤", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 464, + "completion_tokens": 96, + "cost_usd": 0.0003392, + "route": "자동 답변" + }, + { + "request_id": "r-74", + "user": "임도윤", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 461, + "completion_tokens": 105, + "cost_usd": 8.81e-05, + "route": "자동 답변" + }, + { + "request_id": "r-75", + "user": "배수아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 398, + "completion_tokens": 73, + "cost_usd": 0.000276, + "route": "자동 답변" + }, + { + "request_id": "r-76", + "user": "임도윤", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 444, + "completion_tokens": 101, + "cost_usd": 0.0003392, + "route": "자동 답변" + }, + { + "request_id": "r-76", + "user": "임도윤", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 561, + "completion_tokens": 83, + "cost_usd": 8.93e-05, + "route": "자동 답변" + }, + { + "request_id": "r-77", + "user": "임도윤", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 542, + "completion_tokens": 137, + "cost_usd": 0.000436, + "route": "자동 답변" + }, + { + "request_id": "r-78", + "user": "한유나", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 561, + "completion_tokens": 71, + "cost_usd": 0.000338, + "route": "자동 답변" + }, + { + "request_id": "r-79", + "user": "윤하늘", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 490, + "completion_tokens": 137, + "cost_usd": 0.0004152, + "route": "자동 답변" + }, + { + "request_id": "r-80", + "user": "강민수", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 418, + "completion_tokens": 116, + "cost_usd": 0.0003528, + "route": "자동 답변" + }, + { + "request_id": "r-80", + "user": "강민수", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 400, + "completion_tokens": 77, + "cost_usd": 7.08e-05, + "route": "자동 답변" + }, + { + "request_id": "r-81", + "user": "김민지", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 386, + "completion_tokens": 95, + "cost_usd": 0.0003064, + "route": "자동 답변" + }, + { + "request_id": "r-82", + "user": "임도윤", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 501, + "completion_tokens": 140, + "cost_usd": 0.0004244, + "route": "자동 답변" + }, + { + "request_id": "r-83", + "user": "이서준", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 554, + "completion_tokens": 128, + "cost_usd": 0.0004264, + "route": "자동 답변" + }, + { + "request_id": "r-83", + "user": "이서준", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 512, + "completion_tokens": 63, + "cost_usd": 7.64e-05, + "route": "자동 답변" + }, + { + "request_id": "r-84", + "user": "오세훈", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 592, + "completion_tokens": 67, + "cost_usd": 0.000344, + "route": "자동 답변" + }, + { + "request_id": "r-85", + "user": "강민수", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 591, + "completion_tokens": 85, + "cost_usd": 0.0003724, + "route": "자동 답변" + }, + { + "request_id": "r-86", + "user": "김민지", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 477, + "completion_tokens": 104, + "cost_usd": 0.0003572, + "route": "자동 답변" + }, + { + "request_id": "r-86", + "user": "김민지", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 432, + "completion_tokens": 136, + "cost_usd": 9.76e-05, + "route": "자동 답변" + }, + { + "request_id": "r-87", + "user": "이서준", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 501, + "completion_tokens": 108, + "cost_usd": 0.0003732, + "route": "자동 답변" + }, + { + "request_id": "r-88", + "user": "이서준", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 398, + "completion_tokens": 131, + "cost_usd": 0.0003688, + "route": "자동 답변" + }, + { + "request_id": "r-88", + "user": "이서준", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 437, + "completion_tokens": 65, + "cost_usd": 6.97e-05, + "route": "자동 답변" + }, + { + "request_id": "r-89", + "user": "강민수", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 598, + "completion_tokens": 134, + "cost_usd": 0.0004536, + "route": "자동 답변" + }, + { + "request_id": "r-90", + "user": "김민지", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 492, + "completion_tokens": 85, + "cost_usd": 0.0003328, + "route": "자동 답변" + }, + { + "request_id": "r-90", + "user": "김민지", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 586, + "completion_tokens": 93, + "cost_usd": 9.58e-05, + "route": "자동 답변" + }, + { + "request_id": "r-91", + "user": "한유나", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 488, + "completion_tokens": 60, + "cost_usd": 0.0002912, + "route": "자동 답변" + }, + { + "request_id": "r-92", + "user": "임도윤", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 474, + "completion_tokens": 90, + "cost_usd": 0.0003336, + "route": "자동 답변" + }, + { + "request_id": "r-92", + "user": "임도윤", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 580, + "completion_tokens": 114, + "cost_usd": 0.0001036, + "route": "자동 답변" + }, + { + "request_id": "r-93", + "user": "김민지", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 615, + "completion_tokens": 99, + "cost_usd": 0.0004044, + "route": "자동 답변" + }, + { + "request_id": "r-93", + "user": "김민지", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 419, + "completion_tokens": 135, + "cost_usd": 9.59e-05, + "route": "자동 답변" + }, + { + "request_id": "r-94", + "user": "한유나", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 423, + "completion_tokens": 68, + "cost_usd": 0.000278, + "route": "자동 답변" + }, + { + "request_id": "r-95", + "user": "배수아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 420, + "completion_tokens": 76, + "cost_usd": 0.0002896, + "route": "자동 답변" + }, + { + "request_id": "r-95", + "user": "배수아", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 389, + "completion_tokens": 78, + "cost_usd": 7.01e-05, + "route": "자동 답변" + }, + { + "request_id": "r-96", + "user": "임도윤", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 596, + "completion_tokens": 126, + "cost_usd": 0.00044, + "route": "자동 답변" + }, + { + "request_id": "r-97", + "user": "윤하늘", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 573, + "completion_tokens": 73, + "cost_usd": 0.000346, + "route": "자동 답변" + }, + { + "request_id": "r-97", + "user": "윤하늘", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 513, + "completion_tokens": 102, + "cost_usd": 9.21e-05, + "route": "자동 답변" + }, + { + "request_id": "r-98", + "user": "강민수", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 598, + "completion_tokens": 69, + "cost_usd": 0.0003496, + "route": "자동 답변" + }, + { + "request_id": "r-98", + "user": "강민수", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 545, + "completion_tokens": 80, + "cost_usd": 8.65e-05, + "route": "자동 답변" + }, + { + "request_id": "r-99", + "user": "배수아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 470, + "completion_tokens": 106, + "cost_usd": 0.0003576, + "route": "자동 답변" + }, + { + "request_id": "r-99", + "user": "배수아", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 546, + "completion_tokens": 97, + "cost_usd": 9.34e-05, + "route": "자동 답변" + }, + { + "request_id": "r-100", + "user": "김민지", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 528, + "completion_tokens": 71, + "cost_usd": 0.0003248, + "route": "자동 답변" + }, + { + "request_id": "r-101", + "user": "임도윤", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 440, + "completion_tokens": 99, + "cost_usd": 0.0003344, + "route": "자동 답변" + }, + { + "request_id": "r-102", + "user": "윤하늘", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 389, + "completion_tokens": 78, + "cost_usd": 0.0002804, + "route": "자동 답변" + }, + { + "request_id": "r-103", + "user": "서지아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 574, + "completion_tokens": 140, + "cost_usd": 0.0004536, + "route": "자동 답변" + }, + { + "request_id": "r-104", + "user": "배수아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 401, + "completion_tokens": 110, + "cost_usd": 0.0003364, + "route": "자동 답변" + }, + { + "request_id": "r-104", + "user": "배수아", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 530, + "completion_tokens": 91, + "cost_usd": 8.94e-05, + "route": "자동 답변" + }, + { + "request_id": "r-105", + "user": "오세훈", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 424, + "completion_tokens": 93, + "cost_usd": 0.0003184, + "route": "자동 답변" + }, + { + "request_id": "r-106", + "user": "서지아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 431, + "completion_tokens": 60, + "cost_usd": 0.0002684, + "route": "자동 답변" + }, + { + "request_id": "r-107", + "user": "서지아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 500, + "completion_tokens": 82, + "cost_usd": 0.0003312, + "route": "자동 답변" + }, + { + "request_id": "r-108", + "user": "한유나", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 532, + "completion_tokens": 64, + "cost_usd": 0.0003152, + "route": "자동 답변" + }, + { + "request_id": "r-108", + "user": "한유나", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 568, + "completion_tokens": 61, + "cost_usd": 8.12e-05, + "route": "자동 답변" + }, + { + "request_id": "r-109", + "user": "임도윤", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 546, + "completion_tokens": 93, + "cost_usd": 0.0003672, + "route": "자동 답변" + }, + { + "request_id": "r-110", + "user": "배수아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 573, + "completion_tokens": 120, + "cost_usd": 0.0004212, + "route": "자동 답변" + }, + { + "request_id": "r-111", + "user": "정다은", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 605, + "completion_tokens": 137, + "cost_usd": 0.0004612, + "route": "자동 답변" + }, + { + "request_id": "r-112", + "user": "이서준", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 485, + "completion_tokens": 60, + "cost_usd": 0.00029, + "route": "자동 답변" + }, + { + "request_id": "r-113", + "user": "배수아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 423, + "completion_tokens": 124, + "cost_usd": 0.0003676, + "route": "자동 답변" + }, + { + "request_id": "r-114", + "user": "임도윤", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 391, + "completion_tokens": 81, + "cost_usd": 0.000286, + "route": "자동 답변" + }, + { + "request_id": "r-115", + "user": "임도윤", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 535, + "completion_tokens": 111, + "cost_usd": 0.0003916, + "route": "자동 답변" + }, + { + "request_id": "r-116", + "user": "한유나", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 573, + "completion_tokens": 114, + "cost_usd": 0.0004116, + "route": "자동 답변" + }, + { + "request_id": "r-117", + "user": "이서준", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 572, + "completion_tokens": 80, + "cost_usd": 0.0003568, + "route": "자동 답변" + }, + { + "request_id": "r-117", + "user": "이서준", + "step": "judge", + "model": "gpt-5.4-nano", + "prompt_tokens": 489, + "completion_tokens": 82, + "cost_usd": 8.17e-05, + "route": "자동 답변" + }, + { + "request_id": "r-118", + "user": "윤하늘", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 618, + "completion_tokens": 120, + "cost_usd": 0.0004392, + "route": "자동 답변" + }, + { + "request_id": "r-119", + "user": "배수아", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 469, + "completion_tokens": 86, + "cost_usd": 0.0003252, + "route": "자동 답변" + }, + { + "request_id": "r-120", + "user": "김민지", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 528, + "completion_tokens": 80, + "cost_usd": 0.0003392, + "route": "자동 답변" + }, + { + "request_id": "r-121", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 906, + "completion_tokens": 169, + "cost_usd": 0.0006328, + "route": "자동 답변" + }, + { + "request_id": "r-122", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1181, + "completion_tokens": 236, + "cost_usd": 0.00085, + "route": "자동 답변" + }, + { + "request_id": "r-123", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1011, + "completion_tokens": 173, + "cost_usd": 0.0006812, + "route": "자동 답변" + }, + { + "request_id": "r-124", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1268, + "completion_tokens": 242, + "cost_usd": 0.0008944, + "route": "자동 답변" + }, + { + "request_id": "r-125", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1062, + "completion_tokens": 173, + "cost_usd": 0.0007016, + "route": "자동 답변" + }, + { + "request_id": "r-126", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1079, + "completion_tokens": 243, + "cost_usd": 0.0008204, + "route": "자동 답변" + }, + { + "request_id": "r-127", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 936, + "completion_tokens": 198, + "cost_usd": 0.0006912, + "route": "자동 답변" + }, + { + "request_id": "r-128", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1047, + "completion_tokens": 198, + "cost_usd": 0.0007356, + "route": "자동 답변" + }, + { + "request_id": "r-129", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1223, + "completion_tokens": 201, + "cost_usd": 0.0008108, + "route": "자동 답변" + }, + { + "request_id": "r-130", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1189, + "completion_tokens": 192, + "cost_usd": 0.0007828, + "route": "자동 답변" + }, + { + "request_id": "r-131", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1221, + "completion_tokens": 161, + "cost_usd": 0.000746, + "route": "자동 답변" + }, + { + "request_id": "r-132", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1195, + "completion_tokens": 188, + "cost_usd": 0.0007788, + "route": "자동 답변" + }, + { + "request_id": "r-133", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 979, + "completion_tokens": 166, + "cost_usd": 0.0006572, + "route": "자동 답변" + }, + { + "request_id": "r-134", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1048, + "completion_tokens": 169, + "cost_usd": 0.0006896, + "route": "자동 답변" + }, + { + "request_id": "r-135", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1166, + "completion_tokens": 202, + "cost_usd": 0.0007896, + "route": "자동 답변" + }, + { + "request_id": "r-136", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1277, + "completion_tokens": 160, + "cost_usd": 0.0007668, + "route": "자동 답변" + }, + { + "request_id": "r-137", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1133, + "completion_tokens": 172, + "cost_usd": 0.0007284, + "route": "자동 답변" + }, + { + "request_id": "r-138", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 962, + "completion_tokens": 208, + "cost_usd": 0.0007176, + "route": "자동 답변" + }, + { + "request_id": "r-139", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1109, + "completion_tokens": 202, + "cost_usd": 0.0007668, + "route": "자동 답변" + }, + { + "request_id": "r-140", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 930, + "completion_tokens": 211, + "cost_usd": 0.0007096, + "route": "자동 답변" + }, + { + "request_id": "r-141", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1192, + "completion_tokens": 222, + "cost_usd": 0.000832, + "route": "자동 답변" + }, + { + "request_id": "r-142", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1239, + "completion_tokens": 207, + "cost_usd": 0.0008268, + "route": "자동 답변" + }, + { + "request_id": "r-143", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 905, + "completion_tokens": 214, + "cost_usd": 0.0007044, + "route": "자동 답변" + }, + { + "request_id": "r-144", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 963, + "completion_tokens": 179, + "cost_usd": 0.0006716, + "route": "자동 답변" + }, + { + "request_id": "r-145", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1277, + "completion_tokens": 257, + "cost_usd": 0.000922, + "route": "자동 답변" + }, + { + "request_id": "r-146", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1140, + "completion_tokens": 249, + "cost_usd": 0.0008544, + "route": "자동 답변" + }, + { + "request_id": "r-147", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 936, + "completion_tokens": 219, + "cost_usd": 0.0007248, + "route": "자동 답변" + }, + { + "request_id": "r-148", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1013, + "completion_tokens": 204, + "cost_usd": 0.0007316, + "route": "자동 답변" + }, + { + "request_id": "r-149", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 965, + "completion_tokens": 246, + "cost_usd": 0.0007796, + "route": "자동 답변" + }, + { + "request_id": "r-150", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1207, + "completion_tokens": 204, + "cost_usd": 0.0008092, + "route": "자동 답변" + }, + { + "request_id": "r-151", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1051, + "completion_tokens": 193, + "cost_usd": 0.0007292, + "route": "자동 답변" + }, + { + "request_id": "r-152", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1156, + "completion_tokens": 227, + "cost_usd": 0.0008256, + "route": "자동 답변" + }, + { + "request_id": "r-153", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1048, + "completion_tokens": 168, + "cost_usd": 0.000688, + "route": "자동 답변" + }, + { + "request_id": "r-154", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1130, + "completion_tokens": 177, + "cost_usd": 0.0007352, + "route": "자동 답변" + }, + { + "request_id": "r-155", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1221, + "completion_tokens": 150, + "cost_usd": 0.0007284, + "route": "자동 답변" + }, + { + "request_id": "r-156", + "user": "test-bot", + "step": "answer", + "model": "gpt-4.1-mini", + "prompt_tokens": 1109, + "completion_tokens": 169, + "cost_usd": 0.000714, + "route": "자동 답변" + } + ] +} \ No newline at end of file diff --git a/Last3Mon/79/data-api/data/prices.json b/Last3Mon/79/data-api/data/prices.json new file mode 100644 index 0000000..9068a2c --- /dev/null +++ b/Last3Mon/79/data-api/data/prices.json @@ -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} + } +} diff --git a/Last3Mon/79/data-api/data/prompts/helpdesk-p1.md b/Last3Mon/79/data-api/data/prompts/helpdesk-p1.md new file mode 100644 index 0000000..72a2224 --- /dev/null +++ b/Last3Mon/79/data-api/data/prompts/helpdesk-p1.md @@ -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 합니다체. diff --git a/Last3Mon/79/data-api/data/prompts/helpdesk-p2.md b/Last3Mon/79/data-api/data/prompts/helpdesk-p2.md new file mode 100644 index 0000000..a76b826 --- /dev/null +++ b/Last3Mon/79/data-api/data/prompts/helpdesk-p2.md @@ -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]. diff --git a/Last3Mon/79/data-api/data/prompts/helpdesk-p3.md b/Last3Mon/79/data-api/data/prompts/helpdesk-p3.md new file mode 100644 index 0000000..25cbf94 --- /dev/null +++ b/Last3Mon/79/data-api/data/prompts/helpdesk-p3.md @@ -0,0 +1 @@ +You are the company IT help desk. Keep every answer to one short Korean sentence. Do not repeat the steps from the data; tell the employee to check the IT portal instead. diff --git a/Last3Mon/79/data-api/data/releases.json b/Last3Mon/79/data-api/data/releases.json new file mode 100644 index 0000000..be2b51b --- /dev/null +++ b/Last3Mon/79/data-api/data/releases.json @@ -0,0 +1,12 @@ +{ + "helpdesk": { + "active": "1.0.0", + "releases": { + "1.0.0": {"prompt_version": "p1", "model": "gpt-4.1-mini", "note": "첫 릴리스"}, + "1.1.0": {"prompt_version": "p2", "model": "gpt-4.1-mini", "note": "FAQ 답 끝에 출처 번호"}, + "1.2.0": {"prompt_version": "p3", "model": "gpt-4.1-mini", "note": "답을 한 문장으로 짧게"}, + "2.0.0": {"prompt_version": "p2", "model": "gpt-5.4-nano", "note": "더 작은 모델 (비용)"}, + "2.1.0": {"prompt_version": "p9", "model": "gpt-4.1-mini", "note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"} + } + } +} diff --git a/Last3Mon/79/data-api/data/run-logs.json b/Last3Mon/79/data-api/data/run-logs.json new file mode 100644 index 0000000..069909e --- /dev/null +++ b/Last3Mon/79/data-api/data/run-logs.json @@ -0,0 +1,668 @@ +[ +{"execution_id": 5001, "workflow": "helpdesk", "started_at": "2026-10-19T09:01:19", "user": "박서연", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1436, "steps": {"faq_ms": 39, "answer_ms": 1370}, "cost_usd": 0.00012}, +{"execution_id": 5002, "workflow": "helpdesk", "started_at": "2026-10-19T09:05:24", "user": "강민수", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1494, "steps": {"faq_ms": 39, "answer_ms": 1398}, "cost_usd": 0.00012}, +{"execution_id": 5003, "workflow": "helpdesk", "started_at": "2026-10-19T09:05:59", "user": "강민수", "topic": "와이파이", "question": "회의실 와이파이가 안 잡혀요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 58, "steps": {"faq_ms": 30, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5004, "workflow": "helpdesk", "started_at": "2026-10-19T09:06:28", "user": "오세훈", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 964, "steps": {"faq_ms": 16, "answer_ms": 926}, "cost_usd": 0.00012}, +{"execution_id": 5005, "workflow": "helpdesk", "started_at": "2026-10-19T09:14:28", "user": "오세훈", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1445, "steps": {"faq_ms": 31, "answer_ms": 1363}, "cost_usd": 0.00012}, +{"execution_id": 5006, "workflow": "helpdesk", "started_at": "2026-10-19T09:15:29", "user": "윤하늘", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1029, "steps": {"faq_ms": 31, "answer_ms": 947}, "cost_usd": 0.00012}, +{"execution_id": 5007, "workflow": "helpdesk", "started_at": "2026-10-19T09:22:27", "user": "오세훈", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2066, "steps": {"faq_ms": 30, "answer_ms": 2001}, "cost_usd": 0.00021}, +{"execution_id": 5008, "workflow": "helpdesk", "started_at": "2026-10-19T09:31:29", "user": "윤하늘", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1010, "steps": {"faq_ms": 30, "answer_ms": 952}, "cost_usd": 0.00012}, +{"execution_id": 5009, "workflow": "helpdesk", "started_at": "2026-10-19T09:39:15", "user": "한지훈", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 965, "steps": {"faq_ms": 19, "answer_ms": 912}, "cost_usd": 0.00012}, +{"execution_id": 5010, "workflow": "helpdesk", "started_at": "2026-10-19T09:39:22", "user": "오세훈", "topic": "설치", "question": "라이선스 신청은 어디서 해요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1255, "steps": {"faq_ms": 23, "answer_ms": 1206}, "cost_usd": 0.00012}, +{"execution_id": 5011, "workflow": "helpdesk", "started_at": "2026-10-19T09:41:40", "user": "오세훈", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1177, "steps": {"faq_ms": 24, "answer_ms": 1132}, "cost_usd": 0.00012}, +{"execution_id": 5012, "workflow": "helpdesk", "started_at": "2026-10-19T09:44:12", "user": "박서연", "topic": "메일", "question": "메일함 용량이 꽉 찼대요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2041, "steps": {"faq_ms": 19, "answer_ms": 1996}, "cost_usd": 0.00021}, +{"execution_id": 5013, "workflow": "helpdesk", "started_at": "2026-10-19T09:47:36", "user": "임도윤", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1019, "steps": {"faq_ms": 20, "answer_ms": 955}, "cost_usd": 0.00012}, +{"execution_id": 5014, "workflow": "helpdesk", "started_at": "2026-10-19T09:48:17", "user": "윤하늘", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1192, "steps": {"faq_ms": 35, "answer_ms": 1119}, "cost_usd": 0.00012}, +{"execution_id": 5015, "workflow": "helpdesk", "started_at": "2026-10-19T09:50:29", "user": "박서연", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1361, "steps": {"faq_ms": 21, "answer_ms": 1297}, "cost_usd": 0.00012}, +{"execution_id": 5016, "workflow": "helpdesk", "started_at": "2026-10-19T09:50:43", "user": "임도윤", "topic": "설치", "question": "라이선스 신청은 어디서 해요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1383, "steps": {"faq_ms": 39, "answer_ms": 1299}, "cost_usd": 0.00012}, +{"execution_id": 5017, "workflow": "helpdesk", "started_at": "2026-10-19T09:53:21", "user": "오세훈", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1425, "steps": {"faq_ms": 38, "answer_ms": 1348}, "cost_usd": 0.00012}, +{"execution_id": 5018, "workflow": "helpdesk", "started_at": "2026-10-19T09:54:31", "user": "김민지", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1424, "steps": {"faq_ms": 26, "answer_ms": 1375}, "cost_usd": 0.00012}, +{"execution_id": 5019, "workflow": "helpdesk", "started_at": "2026-10-19T09:54:42", "user": "서지아", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1490, "steps": {"faq_ms": 38, "answer_ms": 1392}, "cost_usd": 0.00012}, +{"execution_id": 5020, "workflow": "helpdesk", "started_at": "2026-10-19T09:56:28", "user": "최유진", "topic": "설치", "question": "라이선스 신청은 어디서 해요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1484, "steps": {"faq_ms": 35, "answer_ms": 1396}, "cost_usd": 0.00012}, +{"execution_id": 5021, "workflow": "helpdesk", "started_at": "2026-10-19T09:58:00", "user": "서지아", "topic": "설치", "question": "라이선스 신청은 어디서 해요? 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2229, "steps": {"faq_ms": 37, "answer_ms": 2133}, "cost_usd": 0.00021}, +{"execution_id": 5022, "workflow": "helpdesk", "started_at": "2026-10-19T10:02:50", "user": "이준호", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1226, "steps": {"faq_ms": 19, "answer_ms": 1153}, "cost_usd": 0.00012}, +{"execution_id": 5023, "workflow": "helpdesk", "started_at": "2026-10-19T10:04:03", "user": "박서연", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요? 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 2297, "steps": {"faq_ms": 16, "answer_ms": 2221}, "cost_usd": 0.00021}, +{"execution_id": 5024, "workflow": "helpdesk", "started_at": "2026-10-19T10:19:26", "user": "서지아", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1222, "steps": {"faq_ms": 20, "answer_ms": 1177}, "cost_usd": 0.00012}, +{"execution_id": 5025, "workflow": "helpdesk", "started_at": "2026-10-19T10:34:18", "user": "강민수", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1041, "steps": {"faq_ms": 17, "answer_ms": 991}, "cost_usd": 0.00012}, +{"execution_id": 5026, "workflow": "helpdesk", "started_at": "2026-10-19T10:39:52", "user": "배수아", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1066, "steps": {"faq_ms": 22, "answer_ms": 997}, "cost_usd": 0.00012}, +{"execution_id": 5027, "workflow": "helpdesk", "started_at": "2026-10-19T10:44:26", "user": "오세훈", "topic": "와이파이", "question": "와이파이가 자주 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 64, "steps": {"faq_ms": 32, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5028, "workflow": "helpdesk", "started_at": "2026-10-19T10:44:41", "user": "박서연", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1307, "steps": {"faq_ms": 35, "answer_ms": 1231}, "cost_usd": 0.00012}, +{"execution_id": 5029, "workflow": "helpdesk", "started_at": "2026-10-19T10:45:28", "user": "임도윤", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1144, "steps": {"faq_ms": 31, "answer_ms": 1080}, "cost_usd": 0.00012}, +{"execution_id": 5030, "workflow": "helpdesk", "started_at": "2026-10-19T10:47:45", "user": "한지훈", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1392, "steps": {"faq_ms": 19, "answer_ms": 1325}, "cost_usd": 0.00012}, +{"execution_id": 5031, "workflow": "helpdesk", "started_at": "2026-10-19T10:49:17", "user": "윤하늘", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1187, "steps": {"faq_ms": 18, "answer_ms": 1145}, "cost_usd": 0.00012}, +{"execution_id": 5032, "workflow": "helpdesk", "started_at": "2026-10-19T10:51:56", "user": "최유진", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1200, "steps": {"faq_ms": 33, "answer_ms": 1114}, "cost_usd": 0.00012}, +{"execution_id": 5033, "workflow": "helpdesk", "started_at": "2026-10-19T11:03:12", "user": "배수아", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1046, "steps": {"faq_ms": 40, "answer_ms": 952}, "cost_usd": 0.00012}, +{"execution_id": 5034, "workflow": "helpdesk", "started_at": "2026-10-19T11:09:52", "user": "윤하늘", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1172, "steps": {"faq_ms": 27, "answer_ms": 1098}, "cost_usd": 0.00012}, +{"execution_id": 5035, "workflow": "helpdesk", "started_at": "2026-10-19T11:11:20", "user": "김민지", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1460, "steps": {"faq_ms": 24, "answer_ms": 1384}, "cost_usd": 0.00012}, +{"execution_id": 5036, "workflow": "helpdesk", "started_at": "2026-10-19T11:22:11", "user": "임도윤", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1139, "steps": {"faq_ms": 27, "answer_ms": 1069}, "cost_usd": 0.00012}, +{"execution_id": 5037, "workflow": "helpdesk", "started_at": "2026-10-19T11:32:53", "user": "서지아", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1011, "steps": {"faq_ms": 19, "answer_ms": 963}, "cost_usd": 0.00012}, +{"execution_id": 5038, "workflow": "helpdesk", "started_at": "2026-10-19T11:39:38", "user": "윤하늘", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1404, "steps": {"faq_ms": 17, "answer_ms": 1351}, "cost_usd": 0.00012}, +{"execution_id": 5039, "workflow": "helpdesk", "started_at": "2026-10-19T11:55:34", "user": "최유진", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 975, "steps": {"faq_ms": 37, "answer_ms": 912}, "cost_usd": 0.00012}, +{"execution_id": 5040, "workflow": "helpdesk", "started_at": "2026-10-19T11:57:59", "user": "오세훈", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1285, "steps": {"faq_ms": 35, "answer_ms": 1200}, "cost_usd": 0.00012}, +{"execution_id": 5041, "workflow": "helpdesk", "started_at": "2026-10-19T12:01:02", "user": "오세훈", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2691, "steps": {"faq_ms": 16, "answer_ms": 2638}, "cost_usd": 0.00021}, +{"execution_id": 5042, "workflow": "helpdesk", "started_at": "2026-10-19T12:32:12", "user": "박서연", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1071, "steps": {"faq_ms": 31, "answer_ms": 1018}, "cost_usd": 0.00012}, +{"execution_id": 5043, "workflow": "helpdesk", "started_at": "2026-10-19T12:59:00", "user": "서지아", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1380, "steps": {"faq_ms": 32, "answer_ms": 1306}, "cost_usd": 0.00012}, +{"execution_id": 5044, "workflow": "helpdesk", "started_at": "2026-10-19T13:16:29", "user": "이준호", "topic": "와이파이", "question": "와이파이가 자주 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 49, "steps": {"faq_ms": 17, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5045, "workflow": "helpdesk", "started_at": "2026-10-19T13:20:05", "user": "윤하늘", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1241, "steps": {"faq_ms": 18, "answer_ms": 1179}, "cost_usd": 0.00012}, +{"execution_id": 5046, "workflow": "helpdesk", "started_at": "2026-10-19T13:31:30", "user": "서지아", "topic": "설치", "question": "라이선스 신청은 어디서 해요? 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2542, "steps": {"faq_ms": 20, "answer_ms": 2470}, "cost_usd": 0.00021}, +{"execution_id": 5047, "workflow": "helpdesk", "started_at": "2026-10-19T13:33:24", "user": "배수아", "topic": "와이파이", "question": "사내 Wi-Fi 비밀번호가 뭐예요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 70, "steps": {"faq_ms": 30, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5048, "workflow": "helpdesk", "started_at": "2026-10-19T13:37:03", "user": "이준호", "topic": "VPN", "question": "VPN이 자꾸 끊겨요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2109, "steps": {"faq_ms": 32, "answer_ms": 2052}, "cost_usd": 0.00021}, +{"execution_id": 5049, "workflow": "helpdesk", "started_at": "2026-10-19T13:51:37", "user": "배수아", "topic": "와이파이", "question": "와이파이가 자주 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 86, "steps": {"faq_ms": 28, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5050, "workflow": "helpdesk", "started_at": "2026-10-19T13:54:13", "user": "한지훈", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1049, "steps": {"faq_ms": 39, "answer_ms": 959}, "cost_usd": 0.00012}, +{"execution_id": 5051, "workflow": "helpdesk", "started_at": "2026-10-19T14:00:54", "user": "윤하늘", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1446, "steps": {"faq_ms": 30, "answer_ms": 1385}, "cost_usd": 0.00012}, +{"execution_id": 5052, "workflow": "helpdesk", "started_at": "2026-10-19T14:03:35", "user": "이준호", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1454, "steps": {"faq_ms": 26, "answer_ms": 1392}, "cost_usd": 0.00012}, +{"execution_id": 5053, "workflow": "helpdesk", "started_at": "2026-10-19T14:04:41", "user": "강민수", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1463, "steps": {"faq_ms": 28, "answer_ms": 1381}, "cost_usd": 0.00012}, +{"execution_id": 5054, "workflow": "helpdesk", "started_at": "2026-10-19T14:05:53", "user": "윤하늘", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1193, "steps": {"faq_ms": 26, "answer_ms": 1136}, "cost_usd": 0.00012}, +{"execution_id": 5055, "workflow": "helpdesk", "started_at": "2026-10-19T14:25:07", "user": "최유진", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1467, "steps": {"faq_ms": 18, "answer_ms": 1396}, "cost_usd": 0.00012}, +{"execution_id": 5056, "workflow": "helpdesk", "started_at": "2026-10-19T14:33:52", "user": "최유진", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1174, "steps": {"faq_ms": 22, "answer_ms": 1111}, "cost_usd": 0.00012}, +{"execution_id": 5057, "workflow": "helpdesk", "started_at": "2026-10-19T14:44:25", "user": "이준호", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1305, "steps": {"faq_ms": 20, "answer_ms": 1263}, "cost_usd": 0.00012}, +{"execution_id": 5058, "workflow": "helpdesk", "started_at": "2026-10-19T15:01:03", "user": "이준호", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1359, "steps": {"faq_ms": 21, "answer_ms": 1311}, "cost_usd": 0.00012}, +{"execution_id": 5059, "workflow": "helpdesk", "started_at": "2026-10-19T15:20:46", "user": "박서연", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1280, "steps": {"faq_ms": 21, "answer_ms": 1224}, "cost_usd": 0.00012}, +{"execution_id": 5060, "workflow": "helpdesk", "started_at": "2026-10-19T15:24:37", "user": "임도윤", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2415, "steps": {"faq_ms": 36, "answer_ms": 2322}, "cost_usd": 0.00021}, +{"execution_id": 5061, "workflow": "helpdesk", "started_at": "2026-10-19T16:05:17", "user": "김민지", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요? 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2426, "steps": {"faq_ms": 23, "answer_ms": 2350}, "cost_usd": 0.00021}, +{"execution_id": 5062, "workflow": "helpdesk", "started_at": "2026-10-19T16:12:46", "user": "윤하늘", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1356, "steps": {"faq_ms": 34, "answer_ms": 1275}, "cost_usd": 0.00012}, +{"execution_id": 5063, "workflow": "helpdesk", "started_at": "2026-10-19T16:24:12", "user": "배수아", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1061, "steps": {"faq_ms": 21, "answer_ms": 1007}, "cost_usd": 0.00012}, +{"execution_id": 5064, "workflow": "helpdesk", "started_at": "2026-10-19T16:29:13", "user": "오세훈", "topic": "프린터", "question": "프린터 출력이 안 돼요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2829, "steps": {"faq_ms": 19, "answer_ms": 2768}, "cost_usd": 0.00021}, +{"execution_id": 5065, "workflow": "helpdesk", "started_at": "2026-10-19T16:36:15", "user": "강민수", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1179, "steps": {"faq_ms": 24, "answer_ms": 1131}, "cost_usd": 0.00012}, +{"execution_id": 5066, "workflow": "helpdesk", "started_at": "2026-10-19T16:36:24", "user": "박서연", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1391, "steps": {"faq_ms": 40, "answer_ms": 1306}, "cost_usd": 0.00012}, +{"execution_id": 5067, "workflow": "helpdesk", "started_at": "2026-10-19T16:38:28", "user": "강민수", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1399, "steps": {"faq_ms": 29, "answer_ms": 1321}, "cost_usd": 0.00012}, +{"execution_id": 5068, "workflow": "helpdesk", "started_at": "2026-10-19T16:41:41", "user": "김민지", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 983, "steps": {"faq_ms": 36, "answer_ms": 920}, "cost_usd": 0.00012}, +{"execution_id": 5069, "workflow": "helpdesk", "started_at": "2026-10-19T16:44:49", "user": "이준호", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1019, "steps": {"faq_ms": 24, "answer_ms": 948}, "cost_usd": 0.00012}, +{"execution_id": 5070, "workflow": "helpdesk", "started_at": "2026-10-19T17:12:08", "user": "오세훈", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2537, "steps": {"faq_ms": 15, "answer_ms": 2484}, "cost_usd": 0.00021}, +{"execution_id": 5071, "workflow": "helpdesk", "started_at": "2026-10-19T17:33:10", "user": "김민지", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1420, "steps": {"faq_ms": 21, "answer_ms": 1378}, "cost_usd": 0.00012}, +{"execution_id": 5072, "workflow": "helpdesk", "started_at": "2026-10-19T17:40:29", "user": "윤하늘", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1391, "steps": {"faq_ms": 25, "answer_ms": 1343}, "cost_usd": 0.00012}, +{"execution_id": 5073, "workflow": "helpdesk", "started_at": "2026-10-19T17:42:44", "user": "서지아", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1320, "steps": {"faq_ms": 22, "answer_ms": 1243}, "cost_usd": 0.00012}, +{"execution_id": 5074, "workflow": "helpdesk", "started_at": "2026-10-20T09:05:18", "user": "서지아", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1346, "steps": {"faq_ms": 17, "answer_ms": 1297}, "cost_usd": 0.00012}, +{"execution_id": 5075, "workflow": "helpdesk", "started_at": "2026-10-20T09:07:09", "user": "김민지", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1059, "steps": {"faq_ms": 40, "answer_ms": 991}, "cost_usd": 0.00012}, +{"execution_id": 5076, "workflow": "helpdesk", "started_at": "2026-10-20T09:11:00", "user": "정우성", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1044, "steps": {"faq_ms": 16, "answer_ms": 981}, "cost_usd": 0.00012}, +{"execution_id": 5077, "workflow": "helpdesk", "started_at": "2026-10-20T09:12:27", "user": "오세훈", "topic": "VPN", "question": "VPN이 자꾸 끊겨요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2367, "steps": {"faq_ms": 33, "answer_ms": 2282}, "cost_usd": 0.00021}, +{"execution_id": 5078, "workflow": "helpdesk", "started_at": "2026-10-20T09:18:33", "user": "오세훈", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1075, "steps": {"faq_ms": 37, "answer_ms": 1008}, "cost_usd": 0.00012}, +{"execution_id": 5079, "workflow": "helpdesk", "started_at": "2026-10-20T09:23:10", "user": "오세훈", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 971, "steps": {"faq_ms": 31, "answer_ms": 908}, "cost_usd": 0.00012}, +{"execution_id": 5080, "workflow": "helpdesk", "started_at": "2026-10-20T09:45:03", "user": "한지훈", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2947, "steps": {"faq_ms": 16, "answer_ms": 2878}, "cost_usd": 0.00021}, +{"execution_id": 5081, "workflow": "helpdesk", "started_at": "2026-10-20T11:00:01", "user": "윤하늘", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2516, "steps": {"faq_ms": 24, "answer_ms": 2469}, "cost_usd": 0.00021}, +{"execution_id": 5082, "workflow": "helpdesk", "started_at": "2026-10-20T11:05:05", "user": "서지아", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1129, "steps": {"faq_ms": 18, "answer_ms": 1086}, "cost_usd": 0.00012}, +{"execution_id": 5083, "workflow": "helpdesk", "started_at": "2026-10-20T11:16:13", "user": "이준호", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1190, "steps": {"faq_ms": 18, "answer_ms": 1145}, "cost_usd": 0.00012}, +{"execution_id": 5084, "workflow": "helpdesk", "started_at": "2026-10-20T11:36:20", "user": "오세훈", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1045, "steps": {"faq_ms": 37, "answer_ms": 959}, "cost_usd": 0.00012}, +{"execution_id": 5085, "workflow": "helpdesk", "started_at": "2026-10-20T11:45:27", "user": "임도윤", "topic": "와이파이", "question": "회의실 와이파이가 안 잡혀요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 42, "steps": {"faq_ms": 16, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5086, "workflow": "helpdesk", "started_at": "2026-10-20T12:06:54", "user": "정우성", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1198, "steps": {"faq_ms": 32, "answer_ms": 1131}, "cost_usd": 0.00012}, +{"execution_id": 5087, "workflow": "helpdesk", "started_at": "2026-10-20T12:21:58", "user": "윤하늘", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1452, "steps": {"faq_ms": 31, "answer_ms": 1366}, "cost_usd": 0.00012}, +{"execution_id": 5088, "workflow": "helpdesk", "started_at": "2026-10-20T12:24:14", "user": "최유진", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1301, "steps": {"faq_ms": 31, "answer_ms": 1245}, "cost_usd": 0.00012}, +{"execution_id": 5089, "workflow": "helpdesk", "started_at": "2026-10-20T12:28:56", "user": "정우성", "topic": "와이파이", "question": "회의실 와이파이가 안 잡혀요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 57, "steps": {"faq_ms": 34, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5090, "workflow": "helpdesk", "started_at": "2026-10-20T12:33:44", "user": "오세훈", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1029, "steps": {"faq_ms": 20, "answer_ms": 952}, "cost_usd": 0.00012}, +{"execution_id": 5091, "workflow": "helpdesk", "started_at": "2026-10-20T12:34:16", "user": "한지훈", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1190, "steps": {"faq_ms": 26, "answer_ms": 1123}, "cost_usd": 0.00012}, +{"execution_id": 5092, "workflow": "helpdesk", "started_at": "2026-10-20T12:38:55", "user": "정우성", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 2442, "steps": {"faq_ms": 39, "answer_ms": 2348}, "cost_usd": 0.00021}, +{"execution_id": 5093, "workflow": "helpdesk", "started_at": "2026-10-20T12:40:04", "user": "이준호", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1175, "steps": {"faq_ms": 20, "answer_ms": 1095}, "cost_usd": 0.00012}, +{"execution_id": 5094, "workflow": "helpdesk", "started_at": "2026-10-20T12:49:44", "user": "박서연", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1439, "steps": {"faq_ms": 17, "answer_ms": 1367}, "cost_usd": 0.00012}, +{"execution_id": 5095, "workflow": "helpdesk", "started_at": "2026-10-20T12:51:11", "user": "임도윤", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1216, "steps": {"faq_ms": 27, "answer_ms": 1163}, "cost_usd": 0.00012}, +{"execution_id": 5096, "workflow": "helpdesk", "started_at": "2026-10-20T12:55:39", "user": "윤하늘", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1058, "steps": {"faq_ms": 29, "answer_ms": 988}, "cost_usd": 0.00012}, +{"execution_id": 5097, "workflow": "helpdesk", "started_at": "2026-10-20T12:58:26", "user": "정우성", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1227, "steps": {"faq_ms": 27, "answer_ms": 1152}, "cost_usd": 0.00012}, +{"execution_id": 5098, "workflow": "helpdesk", "started_at": "2026-10-20T13:11:22", "user": "강민수", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1119, "steps": {"faq_ms": 34, "answer_ms": 1057}, "cost_usd": 0.00012}, +{"execution_id": 5099, "workflow": "helpdesk", "started_at": "2026-10-20T13:25:11", "user": "정우성", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1071, "steps": {"faq_ms": 29, "answer_ms": 989}, "cost_usd": 0.00012}, +{"execution_id": 5100, "workflow": "helpdesk", "started_at": "2026-10-20T13:37:51", "user": "서지아", "topic": "VPN", "question": "VPN이 자꾸 끊겨요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2675, "steps": {"faq_ms": 35, "answer_ms": 2582}, "cost_usd": 0.00021}, +{"execution_id": 5101, "workflow": "helpdesk", "started_at": "2026-10-20T13:41:06", "user": "오세훈", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1034, "steps": {"faq_ms": 15, "answer_ms": 991}, "cost_usd": 0.00012}, +{"execution_id": 5102, "workflow": "helpdesk", "started_at": "2026-10-20T13:42:27", "user": "정우성", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1454, "steps": {"faq_ms": 29, "answer_ms": 1381}, "cost_usd": 0.00012}, +{"execution_id": 5103, "workflow": "helpdesk", "started_at": "2026-10-20T13:50:11", "user": "서지아", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1063, "steps": {"faq_ms": 40, "answer_ms": 975}, "cost_usd": 0.00012}, +{"execution_id": 5104, "workflow": "helpdesk", "started_at": "2026-10-20T14:15:19", "user": "이준호", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요? 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2933, "steps": {"faq_ms": 35, "answer_ms": 2860}, "cost_usd": 0.00021}, +{"execution_id": 5105, "workflow": "helpdesk", "started_at": "2026-10-20T14:17:09", "user": "강민수", "topic": "설치", "question": "라이선스 신청은 어디서 해요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1176, "steps": {"faq_ms": 35, "answer_ms": 1118}, "cost_usd": 0.00012}, +{"execution_id": 5106, "workflow": "helpdesk", "started_at": "2026-10-20T14:21:14", "user": "오세훈", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1346, "steps": {"faq_ms": 40, "answer_ms": 1284}, "cost_usd": 0.00012}, +{"execution_id": 5107, "workflow": "helpdesk", "started_at": "2026-10-20T14:41:11", "user": "최유진", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1065, "steps": {"faq_ms": 29, "answer_ms": 982}, "cost_usd": 0.00012}, +{"execution_id": 5108, "workflow": "helpdesk", "started_at": "2026-10-20T15:00:49", "user": "이준호", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1332, "steps": {"faq_ms": 23, "answer_ms": 1269}, "cost_usd": 0.00012}, +{"execution_id": 5109, "workflow": "helpdesk", "started_at": "2026-10-20T15:12:19", "user": "오세훈", "topic": "와이파이", "question": "와이파이가 자주 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 65, "steps": {"faq_ms": 25, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5110, "workflow": "helpdesk", "started_at": "2026-10-20T15:13:58", "user": "오세훈", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요? 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2310, "steps": {"faq_ms": 28, "answer_ms": 2244}, "cost_usd": 0.00021}, +{"execution_id": 5111, "workflow": "helpdesk", "started_at": "2026-10-20T15:15:20", "user": "임도윤", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1453, "steps": {"faq_ms": 25, "answer_ms": 1393}, "cost_usd": 0.00012}, +{"execution_id": 5112, "workflow": "helpdesk", "started_at": "2026-10-20T15:38:59", "user": "박서연", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1365, "steps": {"faq_ms": 36, "answer_ms": 1278}, "cost_usd": 0.00012}, +{"execution_id": 5113, "workflow": "helpdesk", "started_at": "2026-10-20T15:48:32", "user": "임도윤", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1025, "steps": {"faq_ms": 27, "answer_ms": 949}, "cost_usd": 0.00012}, +{"execution_id": 5114, "workflow": "helpdesk", "started_at": "2026-10-20T15:50:38", "user": "한지훈", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1284, "steps": {"faq_ms": 22, "answer_ms": 1233}, "cost_usd": 0.00012}, +{"execution_id": 5115, "workflow": "helpdesk", "started_at": "2026-10-20T15:54:53", "user": "박서연", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1484, "steps": {"faq_ms": 36, "answer_ms": 1394}, "cost_usd": 0.00012}, +{"execution_id": 5116, "workflow": "helpdesk", "started_at": "2026-10-20T15:55:27", "user": "배수아", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1291, "steps": {"faq_ms": 24, "answer_ms": 1208}, "cost_usd": 0.00012}, +{"execution_id": 5117, "workflow": "helpdesk", "started_at": "2026-10-20T16:35:26", "user": "서지아", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1437, "steps": {"faq_ms": 16, "answer_ms": 1365}, "cost_usd": 0.00012}, +{"execution_id": 5118, "workflow": "helpdesk", "started_at": "2026-10-20T16:40:36", "user": "한지훈", "topic": "프린터", "question": "프린터 출력이 안 돼요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2498, "steps": {"faq_ms": 20, "answer_ms": 2440}, "cost_usd": 0.00021}, +{"execution_id": 5119, "workflow": "helpdesk", "started_at": "2026-10-20T16:49:26", "user": "오세훈", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 973, "steps": {"faq_ms": 34, "answer_ms": 913}, "cost_usd": 0.00012}, +{"execution_id": 5120, "workflow": "helpdesk", "started_at": "2026-10-20T16:53:51", "user": "한지훈", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1252, "steps": {"faq_ms": 15, "answer_ms": 1209}, "cost_usd": 0.00012}, +{"execution_id": 5121, "workflow": "helpdesk", "started_at": "2026-10-20T16:55:57", "user": "김민지", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1307, "steps": {"faq_ms": 40, "answer_ms": 1235}, "cost_usd": 0.00012}, +{"execution_id": 5122, "workflow": "helpdesk", "started_at": "2026-10-20T16:58:53", "user": "정우성", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1134, "steps": {"faq_ms": 29, "answer_ms": 1049}, "cost_usd": 0.00012}, +{"execution_id": 5123, "workflow": "helpdesk", "started_at": "2026-10-20T17:15:15", "user": "박서연", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1091, "steps": {"faq_ms": 15, "answer_ms": 1033}, "cost_usd": 0.00012}, +{"execution_id": 5124, "workflow": "helpdesk", "started_at": "2026-10-20T17:22:32", "user": "김민지", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 971, "steps": {"faq_ms": 28, "answer_ms": 922}, "cost_usd": 0.00012}, +{"execution_id": 5125, "workflow": "helpdesk", "started_at": "2026-10-20T17:33:03", "user": "배수아", "topic": "와이파이", "question": "와이파이가 자주 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 59, "steps": {"faq_ms": 38, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5126, "workflow": "helpdesk", "started_at": "2026-10-20T17:37:01", "user": "이준호", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1182, "steps": {"faq_ms": 19, "answer_ms": 1141}, "cost_usd": 0.00012}, +{"execution_id": 5127, "workflow": "helpdesk", "started_at": "2026-10-20T17:38:42", "user": "한지훈", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요? 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2318, "steps": {"faq_ms": 16, "answer_ms": 2258}, "cost_usd": 0.00021}, +{"execution_id": 5128, "workflow": "helpdesk", "started_at": "2026-10-20T17:45:23", "user": "서지아", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1382, "steps": {"faq_ms": 17, "answer_ms": 1341}, "cost_usd": 0.00012}, +{"execution_id": 5129, "workflow": "helpdesk", "started_at": "2026-10-20T17:46:18", "user": "정우성", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1021, "steps": {"faq_ms": 34, "answer_ms": 966}, "cost_usd": 0.00012}, +{"execution_id": 5130, "workflow": "helpdesk", "started_at": "2026-10-20T17:50:20", "user": "한지훈", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1466, "steps": {"faq_ms": 31, "answer_ms": 1387}, "cost_usd": 0.00012}, +{"execution_id": 5131, "workflow": "helpdesk", "started_at": "2026-10-21T09:00:36", "user": "정우성", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1072, "steps": {"faq_ms": 28, "answer_ms": 985}, "cost_usd": 0.00012}, +{"execution_id": 5132, "workflow": "helpdesk", "started_at": "2026-10-21T09:05:01", "user": "서지아", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1216, "steps": {"faq_ms": 34, "answer_ms": 1123}, "cost_usd": 0.00012}, +{"execution_id": 5133, "workflow": "helpdesk", "started_at": "2026-10-21T09:21:57", "user": "서지아", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요? 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2649, "steps": {"faq_ms": 31, "answer_ms": 2595}, "cost_usd": 0.00021}, +{"execution_id": 5134, "workflow": "helpdesk", "started_at": "2026-10-21T09:27:45", "user": "배수아", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1352, "steps": {"faq_ms": 26, "answer_ms": 1288}, "cost_usd": 0.00012}, +{"execution_id": 5135, "workflow": "helpdesk", "started_at": "2026-10-21T09:36:27", "user": "이준호", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1023, "steps": {"faq_ms": 18, "answer_ms": 963}, "cost_usd": 0.00012}, +{"execution_id": 5136, "workflow": "helpdesk", "started_at": "2026-10-21T09:41:52", "user": "정우성", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1267, "steps": {"faq_ms": 27, "answer_ms": 1186}, "cost_usd": 0.00012}, +{"execution_id": 5137, "workflow": "helpdesk", "started_at": "2026-10-21T09:45:14", "user": "오세훈", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1309, "steps": {"faq_ms": 38, "answer_ms": 1221}, "cost_usd": 0.00012}, +{"execution_id": 5138, "workflow": "helpdesk", "started_at": "2026-10-21T09:47:37", "user": "이준호", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1377, "steps": {"faq_ms": 17, "answer_ms": 1323}, "cost_usd": 0.00012}, +{"execution_id": 5139, "workflow": "helpdesk", "started_at": "2026-10-21T09:53:17", "user": "김민지", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2594, "steps": {"faq_ms": 36, "answer_ms": 2532}, "cost_usd": 0.00021}, +{"execution_id": 5140, "workflow": "helpdesk", "started_at": "2026-10-21T10:09:21", "user": "윤하늘", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1219, "steps": {"faq_ms": 36, "answer_ms": 1134}, "cost_usd": 0.00012}, +{"execution_id": 5141, "workflow": "helpdesk", "started_at": "2026-10-21T10:48:23", "user": "강민수", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요? 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2874, "steps": {"faq_ms": 19, "answer_ms": 2834}, "cost_usd": 0.00021}, +{"execution_id": 5142, "workflow": "helpdesk", "started_at": "2026-10-21T11:09:13", "user": "배수아", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1286, "steps": {"faq_ms": 31, "answer_ms": 1205}, "cost_usd": 0.00012}, +{"execution_id": 5143, "workflow": "helpdesk", "started_at": "2026-10-21T11:17:24", "user": "최유진", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1156, "steps": {"faq_ms": 21, "answer_ms": 1111}, "cost_usd": 0.00012}, +{"execution_id": 5144, "workflow": "helpdesk", "started_at": "2026-10-21T11:24:52", "user": "오세훈", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1119, "steps": {"faq_ms": 18, "answer_ms": 1060}, "cost_usd": 0.00012}, +{"execution_id": 5145, "workflow": "helpdesk", "started_at": "2026-10-21T11:27:44", "user": "배수아", "topic": "설치", "question": "라이선스 신청은 어디서 해요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1141, "steps": {"faq_ms": 30, "answer_ms": 1065}, "cost_usd": 0.00012}, +{"execution_id": 5146, "workflow": "helpdesk", "started_at": "2026-10-21T11:31:35", "user": "오세훈", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1471, "steps": {"faq_ms": 36, "answer_ms": 1400}, "cost_usd": 0.00012}, +{"execution_id": 5147, "workflow": "helpdesk", "started_at": "2026-10-21T11:34:31", "user": "이준호", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1429, "steps": {"faq_ms": 23, "answer_ms": 1376}, "cost_usd": 0.00012}, +{"execution_id": 5148, "workflow": "helpdesk", "started_at": "2026-10-21T12:01:57", "user": "최유진", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1053, "steps": {"faq_ms": 23, "answer_ms": 1004}, "cost_usd": 0.00012}, +{"execution_id": 5149, "workflow": "helpdesk", "started_at": "2026-10-21T12:18:52", "user": "오세훈", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1081, "steps": {"faq_ms": 27, "answer_ms": 1034}, "cost_usd": 0.00012}, +{"execution_id": 5150, "workflow": "helpdesk", "started_at": "2026-10-21T12:22:51", "user": "윤하늘", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1009, "steps": {"faq_ms": 20, "answer_ms": 943}, "cost_usd": 0.00012}, +{"execution_id": 5151, "workflow": "helpdesk", "started_at": "2026-10-21T12:39:07", "user": "정우성", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1066, "steps": {"faq_ms": 33, "answer_ms": 991}, "cost_usd": 0.00012}, +{"execution_id": 5152, "workflow": "helpdesk", "started_at": "2026-10-21T12:40:09", "user": "오세훈", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1384, "steps": {"faq_ms": 19, "answer_ms": 1321}, "cost_usd": 0.00012}, +{"execution_id": 5153, "workflow": "helpdesk", "started_at": "2026-10-21T12:40:23", "user": "오세훈", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1203, "steps": {"faq_ms": 23, "answer_ms": 1123}, "cost_usd": 0.00012}, +{"execution_id": 5154, "workflow": "helpdesk", "started_at": "2026-10-21T12:43:33", "user": "정우성", "topic": "와이파이", "question": "회의실 와이파이가 안 잡혀요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 52, "steps": {"faq_ms": 18, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5155, "workflow": "helpdesk", "started_at": "2026-10-21T12:56:06", "user": "오세훈", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1336, "steps": {"faq_ms": 31, "answer_ms": 1283}, "cost_usd": 0.00012}, +{"execution_id": 5156, "workflow": "helpdesk", "started_at": "2026-10-21T13:08:28", "user": "박서연", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1421, "steps": {"faq_ms": 24, "answer_ms": 1348}, "cost_usd": 0.00012}, +{"execution_id": 5157, "workflow": "helpdesk", "started_at": "2026-10-21T13:08:58", "user": "임도윤", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1361, "steps": {"faq_ms": 23, "answer_ms": 1308}, "cost_usd": 0.00012}, +{"execution_id": 5158, "workflow": "helpdesk", "started_at": "2026-10-21T13:34:53", "user": "김민지", "topic": "설치", "question": "라이선스 신청은 어디서 해요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1465, "steps": {"faq_ms": 20, "answer_ms": 1400}, "cost_usd": 0.00012}, +{"execution_id": 5159, "workflow": "helpdesk", "started_at": "2026-10-21T13:56:38", "user": "박서연", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2471, "steps": {"faq_ms": 18, "answer_ms": 2421}, "cost_usd": 0.00021}, +{"execution_id": 5160, "workflow": "helpdesk", "started_at": "2026-10-21T14:09:18", "user": "최유진", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1253, "steps": {"faq_ms": 25, "answer_ms": 1202}, "cost_usd": 0.00012}, +{"execution_id": 5161, "workflow": "helpdesk", "started_at": "2026-10-21T14:14:04", "user": "정우성", "topic": "와이파이", "question": "와이파이가 자주 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 57, "steps": {"faq_ms": 24, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5162, "workflow": "helpdesk", "started_at": "2026-10-21T14:18:17", "user": "박서연", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요? 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2476, "steps": {"faq_ms": 37, "answer_ms": 2407}, "cost_usd": 0.00021}, +{"execution_id": 5163, "workflow": "helpdesk", "started_at": "2026-10-21T14:19:31", "user": "오세훈", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1222, "steps": {"faq_ms": 31, "answer_ms": 1138}, "cost_usd": 0.00012}, +{"execution_id": 5164, "workflow": "helpdesk", "started_at": "2026-10-21T14:28:43", "user": "김민지", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1376, "steps": {"faq_ms": 30, "answer_ms": 1288}, "cost_usd": 0.00012}, +{"execution_id": 5165, "workflow": "helpdesk", "started_at": "2026-10-21T14:36:34", "user": "김민지", "topic": "설치", "question": "라이선스 신청은 어디서 해요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1174, "steps": {"faq_ms": 33, "answer_ms": 1098}, "cost_usd": 0.00012}, +{"execution_id": 5166, "workflow": "helpdesk", "started_at": "2026-10-21T14:45:44", "user": "박서연", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 967, "steps": {"faq_ms": 22, "answer_ms": 918}, "cost_usd": 0.00012}, +{"execution_id": 5167, "workflow": "helpdesk", "started_at": "2026-10-21T15:00:15", "user": "김민지", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요? 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2547, "steps": {"faq_ms": 16, "answer_ms": 2480}, "cost_usd": 0.00021}, +{"execution_id": 5168, "workflow": "helpdesk", "started_at": "2026-10-21T15:24:31", "user": "윤하늘", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1209, "steps": {"faq_ms": 18, "answer_ms": 1139}, "cost_usd": 0.00012}, +{"execution_id": 5169, "workflow": "helpdesk", "started_at": "2026-10-21T15:27:11", "user": "박서연", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1379, "steps": {"faq_ms": 38, "answer_ms": 1296}, "cost_usd": 0.00012}, +{"execution_id": 5170, "workflow": "helpdesk", "started_at": "2026-10-21T15:34:50", "user": "이준호", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1040, "steps": {"faq_ms": 35, "answer_ms": 983}, "cost_usd": 0.00012}, +{"execution_id": 5171, "workflow": "helpdesk", "started_at": "2026-10-21T15:44:21", "user": "오세훈", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1120, "steps": {"faq_ms": 21, "answer_ms": 1039}, "cost_usd": 0.00012}, +{"execution_id": 5172, "workflow": "helpdesk", "started_at": "2026-10-21T15:48:59", "user": "최유진", "topic": "와이파이", "question": "사내 Wi-Fi 비밀번호가 뭐예요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 39, "steps": {"faq_ms": 18, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5173, "workflow": "helpdesk", "started_at": "2026-10-21T15:54:52", "user": "배수아", "topic": "설치", "question": "라이선스 신청은 어디서 해요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1103, "steps": {"faq_ms": 29, "answer_ms": 1051}, "cost_usd": 0.00012}, +{"execution_id": 5174, "workflow": "helpdesk", "started_at": "2026-10-21T16:21:14", "user": "이준호", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1442, "steps": {"faq_ms": 32, "answer_ms": 1361}, "cost_usd": 0.00012}, +{"execution_id": 5175, "workflow": "helpdesk", "started_at": "2026-10-21T16:32:35", "user": "한지훈", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1002, "steps": {"faq_ms": 33, "answer_ms": 926}, "cost_usd": 0.00012}, +{"execution_id": 5176, "workflow": "helpdesk", "started_at": "2026-10-21T16:33:02", "user": "강민수", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1111, "steps": {"faq_ms": 28, "answer_ms": 1038}, "cost_usd": 0.00012}, +{"execution_id": 5177, "workflow": "helpdesk", "started_at": "2026-10-21T16:41:28", "user": "임도윤", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1379, "steps": {"faq_ms": 38, "answer_ms": 1312}, "cost_usd": 0.00012}, +{"execution_id": 5178, "workflow": "helpdesk", "started_at": "2026-10-21T16:47:46", "user": "이준호", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1256, "steps": {"faq_ms": 27, "answer_ms": 1179}, "cost_usd": 0.00012}, +{"execution_id": 5179, "workflow": "helpdesk", "started_at": "2026-10-21T16:50:19", "user": "김민지", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1273, "steps": {"faq_ms": 23, "answer_ms": 1204}, "cost_usd": 0.00012}, +{"execution_id": 5180, "workflow": "helpdesk", "started_at": "2026-10-21T17:06:50", "user": "임도윤", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1381, "steps": {"faq_ms": 33, "answer_ms": 1316}, "cost_usd": 0.00012}, +{"execution_id": 5181, "workflow": "helpdesk", "started_at": "2026-10-21T17:12:13", "user": "한지훈", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1142, "steps": {"faq_ms": 40, "answer_ms": 1065}, "cost_usd": 0.00012}, +{"execution_id": 5182, "workflow": "helpdesk", "started_at": "2026-10-21T17:14:56", "user": "한지훈", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1328, "steps": {"faq_ms": 28, "answer_ms": 1277}, "cost_usd": 0.00012}, +{"execution_id": 5183, "workflow": "helpdesk", "started_at": "2026-10-21T17:15:38", "user": "한지훈", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1386, "steps": {"faq_ms": 21, "answer_ms": 1330}, "cost_usd": 0.00012}, +{"execution_id": 5184, "workflow": "helpdesk", "started_at": "2026-10-21T17:17:23", "user": "윤하늘", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1378, "steps": {"faq_ms": 37, "answer_ms": 1289}, "cost_usd": 0.00012}, +{"execution_id": 5185, "workflow": "helpdesk", "started_at": "2026-10-21T17:27:56", "user": "배수아", "topic": "설치", "question": "라이선스 신청은 어디서 해요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1032, "steps": {"faq_ms": 16, "answer_ms": 963}, "cost_usd": 0.00012}, +{"execution_id": 5186, "workflow": "helpdesk", "started_at": "2026-10-21T17:44:54", "user": "서지아", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1036, "steps": {"faq_ms": 23, "answer_ms": 975}, "cost_usd": 0.00012}, +{"execution_id": 5187, "workflow": "helpdesk", "started_at": "2026-10-21T17:45:11", "user": "한지훈", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1112, "steps": {"faq_ms": 26, "answer_ms": 1049}, "cost_usd": 0.00012}, +{"execution_id": 5188, "workflow": "helpdesk", "started_at": "2026-10-21T17:46:37", "user": "오세훈", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1061, "steps": {"faq_ms": 21, "answer_ms": 984}, "cost_usd": 0.00012}, +{"execution_id": 5189, "workflow": "helpdesk", "started_at": "2026-10-21T17:46:53", "user": "이준호", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1359, "steps": {"faq_ms": 30, "answer_ms": 1281}, "cost_usd": 0.00012}, +{"execution_id": 5190, "workflow": "helpdesk", "started_at": "2026-10-21T17:57:20", "user": "임도윤", "topic": "와이파이", "question": "와이파이가 자주 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 78, "steps": {"faq_ms": 26, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5191, "workflow": "helpdesk", "started_at": "2026-10-22T09:09:32", "user": "김민지", "topic": "와이파이", "question": "사내 Wi-Fi 비밀번호가 뭐예요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 84, "steps": {"faq_ms": 33, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5192, "workflow": "helpdesk", "started_at": "2026-10-22T09:12:25", "user": "오세훈", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1239, "steps": {"faq_ms": 26, "answer_ms": 1170}, "cost_usd": 0.00012}, +{"execution_id": 5193, "workflow": "helpdesk", "started_at": "2026-10-22T09:43:33", "user": "최유진", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1331, "steps": {"faq_ms": 35, "answer_ms": 1243}, "cost_usd": 0.00012}, +{"execution_id": 5194, "workflow": "helpdesk", "started_at": "2026-10-22T09:55:01", "user": "한지훈", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1259, "steps": {"faq_ms": 33, "answer_ms": 1204}, "cost_usd": 0.00012}, +{"execution_id": 5195, "workflow": "helpdesk", "started_at": "2026-10-22T10:06:23", "user": "박서연", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1172, "steps": {"faq_ms": 17, "answer_ms": 1134}, "cost_usd": 0.00012}, +{"execution_id": 5196, "workflow": "helpdesk", "started_at": "2026-10-22T10:10:47", "user": "윤하늘", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1182, "steps": {"faq_ms": 16, "answer_ms": 1138}, "cost_usd": 0.00012}, +{"execution_id": 5197, "workflow": "helpdesk", "started_at": "2026-10-22T10:16:46", "user": "박서연", "topic": "VPN", "question": "VPN이 자꾸 끊겨요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2859, "steps": {"faq_ms": 37, "answer_ms": 2767}, "cost_usd": 0.00021}, +{"execution_id": 5198, "workflow": "helpdesk", "started_at": "2026-10-22T10:26:16", "user": "한지훈", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1209, "steps": {"faq_ms": 23, "answer_ms": 1139}, "cost_usd": 0.00012}, +{"execution_id": 5199, "workflow": "helpdesk", "started_at": "2026-10-22T10:38:33", "user": "배수아", "topic": "와이파이", "question": "회의실 와이파이가 안 잡혀요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 70, "steps": {"faq_ms": 23, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5200, "workflow": "helpdesk", "started_at": "2026-10-22T10:45:18", "user": "정우성", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1101, "steps": {"faq_ms": 33, "answer_ms": 1046}, "cost_usd": 0.00012}, +{"execution_id": 5201, "workflow": "helpdesk", "started_at": "2026-10-22T10:50:08", "user": "박서연", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1122, "steps": {"faq_ms": 38, "answer_ms": 1030}, "cost_usd": 0.00012}, +{"execution_id": 5202, "workflow": "helpdesk", "started_at": "2026-10-22T11:12:40", "user": "한지훈", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1082, "steps": {"faq_ms": 40, "answer_ms": 983}, "cost_usd": 0.00012}, +{"execution_id": 5203, "workflow": "helpdesk", "started_at": "2026-10-22T11:29:02", "user": "강민수", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1113, "steps": {"faq_ms": 39, "answer_ms": 1046}, "cost_usd": 0.00012}, +{"execution_id": 5204, "workflow": "helpdesk", "started_at": "2026-10-22T11:31:15", "user": "이준호", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1329, "steps": {"faq_ms": 33, "answer_ms": 1247}, "cost_usd": 0.00012}, +{"execution_id": 5205, "workflow": "helpdesk", "started_at": "2026-10-22T11:45:19", "user": "정우성", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1193, "steps": {"faq_ms": 36, "answer_ms": 1109}, "cost_usd": 0.00012}, +{"execution_id": 5206, "workflow": "helpdesk", "started_at": "2026-10-22T11:49:41", "user": "윤하늘", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1434, "steps": {"faq_ms": 33, "answer_ms": 1341}, "cost_usd": 0.00012}, +{"execution_id": 5207, "workflow": "helpdesk", "started_at": "2026-10-22T12:04:52", "user": "이준호", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1262, "steps": {"faq_ms": 25, "answer_ms": 1191}, "cost_usd": 0.00012}, +{"execution_id": 5208, "workflow": "helpdesk", "started_at": "2026-10-22T12:08:04", "user": "배수아", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1125, "steps": {"faq_ms": 24, "answer_ms": 1064}, "cost_usd": 0.00012}, +{"execution_id": 5209, "workflow": "helpdesk", "started_at": "2026-10-22T12:21:54", "user": "임도윤", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1320, "steps": {"faq_ms": 18, "answer_ms": 1248}, "cost_usd": 0.00012}, +{"execution_id": 5210, "workflow": "helpdesk", "started_at": "2026-10-22T12:36:29", "user": "한지훈", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1253, "steps": {"faq_ms": 33, "answer_ms": 1183}, "cost_usd": 0.00012}, +{"execution_id": 5211, "workflow": "helpdesk", "started_at": "2026-10-22T12:41:28", "user": "서지아", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1415, "steps": {"faq_ms": 30, "answer_ms": 1363}, "cost_usd": 0.00012}, +{"execution_id": 5212, "workflow": "helpdesk", "started_at": "2026-10-22T12:47:19", "user": "오세훈", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1134, "steps": {"faq_ms": 19, "answer_ms": 1066}, "cost_usd": 0.00012}, +{"execution_id": 5213, "workflow": "helpdesk", "started_at": "2026-10-22T12:58:07", "user": "최유진", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1146, "steps": {"faq_ms": 18, "answer_ms": 1082}, "cost_usd": 0.00012}, +{"execution_id": 5214, "workflow": "helpdesk", "started_at": "2026-10-22T13:04:42", "user": "한지훈", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1201, "steps": {"faq_ms": 23, "answer_ms": 1141}, "cost_usd": 0.00012}, +{"execution_id": 5215, "workflow": "helpdesk", "started_at": "2026-10-22T13:13:01", "user": "오세훈", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 988, "steps": {"faq_ms": 21, "answer_ms": 920}, "cost_usd": 0.00012}, +{"execution_id": 5216, "workflow": "helpdesk", "started_at": "2026-10-22T13:15:44", "user": "이준호", "topic": "설치", "question": "라이선스 신청은 어디서 해요? 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2720, "steps": {"faq_ms": 35, "answer_ms": 2657}, "cost_usd": 0.00021}, +{"execution_id": 5217, "workflow": "helpdesk", "started_at": "2026-10-22T13:22:45", "user": "오세훈", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1186, "steps": {"faq_ms": 39, "answer_ms": 1116}, "cost_usd": 0.00012}, +{"execution_id": 5218, "workflow": "helpdesk", "started_at": "2026-10-22T13:25:46", "user": "오세훈", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1190, "steps": {"faq_ms": 37, "answer_ms": 1106}, "cost_usd": 0.00012}, +{"execution_id": 5219, "workflow": "helpdesk", "started_at": "2026-10-22T13:26:43", "user": "배수아", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1033, "steps": {"faq_ms": 37, "answer_ms": 960}, "cost_usd": 0.00012}, +{"execution_id": 5220, "workflow": "helpdesk", "started_at": "2026-10-22T13:37:08", "user": "최유진", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1424, "steps": {"faq_ms": 18, "answer_ms": 1386}, "cost_usd": 0.00012}, +{"execution_id": 5221, "workflow": "helpdesk", "started_at": "2026-10-22T13:46:07", "user": "서지아", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1105, "steps": {"faq_ms": 20, "answer_ms": 1026}, "cost_usd": 0.00012}, +{"execution_id": 5222, "workflow": "helpdesk", "started_at": "2026-10-22T13:47:08", "user": "한지훈", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1353, "steps": {"faq_ms": 25, "answer_ms": 1287}, "cost_usd": 0.00012}, +{"execution_id": 5223, "workflow": "helpdesk", "started_at": "2026-10-22T13:52:49", "user": "최유진", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요? 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2497, "steps": {"faq_ms": 35, "answer_ms": 2440}, "cost_usd": 0.00021}, +{"execution_id": 5224, "workflow": "helpdesk", "started_at": "2026-10-22T13:55:58", "user": "김민지", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1119, "steps": {"faq_ms": 27, "answer_ms": 1048}, "cost_usd": 0.00012}, +{"execution_id": 5225, "workflow": "helpdesk", "started_at": "2026-10-22T13:56:36", "user": "배수아", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1035, "steps": {"faq_ms": 28, "answer_ms": 978}, "cost_usd": 0.00012}, +{"execution_id": 5226, "workflow": "helpdesk", "started_at": "2026-10-22T14:07:51", "user": "이준호", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 998, "steps": {"faq_ms": 33, "answer_ms": 921}, "cost_usd": 0.00012}, +{"execution_id": 5227, "workflow": "helpdesk", "started_at": "2026-10-22T14:15:32", "user": "오세훈", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1030, "steps": {"faq_ms": 36, "answer_ms": 935}, "cost_usd": 0.00012}, +{"execution_id": 5228, "workflow": "helpdesk", "started_at": "2026-10-22T14:25:55", "user": "임도윤", "topic": "설치", "question": "라이선스 신청은 어디서 해요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1384, "steps": {"faq_ms": 34, "answer_ms": 1327}, "cost_usd": 0.00012}, +{"execution_id": 5229, "workflow": "helpdesk", "started_at": "2026-10-22T14:51:54", "user": "서지아", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1260, "steps": {"faq_ms": 17, "answer_ms": 1215}, "cost_usd": 0.00012}, +{"execution_id": 5230, "workflow": "helpdesk", "started_at": "2026-10-22T14:54:14", "user": "최유진", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1316, "steps": {"faq_ms": 26, "answer_ms": 1266}, "cost_usd": 0.00012}, +{"execution_id": 5231, "workflow": "helpdesk", "started_at": "2026-10-22T15:03:52", "user": "임도윤", "topic": "프린터", "question": "프린터 출력이 안 돼요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2467, "steps": {"faq_ms": 29, "answer_ms": 2413}, "cost_usd": 0.00021}, +{"execution_id": 5232, "workflow": "helpdesk", "started_at": "2026-10-22T15:10:43", "user": "서지아", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2354, "steps": {"faq_ms": 31, "answer_ms": 2280}, "cost_usd": 0.00021}, +{"execution_id": 5233, "workflow": "helpdesk", "started_at": "2026-10-22T15:22:35", "user": "서지아", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1256, "steps": {"faq_ms": 28, "answer_ms": 1185}, "cost_usd": 0.00012}, +{"execution_id": 5234, "workflow": "helpdesk", "started_at": "2026-10-22T15:50:43", "user": "윤하늘", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1367, "steps": {"faq_ms": 17, "answer_ms": 1329}, "cost_usd": 0.00012}, +{"execution_id": 5235, "workflow": "helpdesk", "started_at": "2026-10-22T15:53:03", "user": "한지훈", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 941, "steps": {"faq_ms": 17, "answer_ms": 903}, "cost_usd": 0.00012}, +{"execution_id": 5236, "workflow": "helpdesk", "started_at": "2026-10-22T16:02:28", "user": "정우성", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 991, "steps": {"faq_ms": 38, "answer_ms": 914}, "cost_usd": 0.00012}, +{"execution_id": 5237, "workflow": "helpdesk", "started_at": "2026-10-22T16:19:25", "user": "정우성", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 965, "steps": {"faq_ms": 20, "answer_ms": 914}, "cost_usd": 0.00012}, +{"execution_id": 5238, "workflow": "helpdesk", "started_at": "2026-10-22T16:52:28", "user": "정우성", "topic": "설치", "question": "라이선스 신청은 어디서 해요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1214, "steps": {"faq_ms": 35, "answer_ms": 1125}, "cost_usd": 0.00012}, +{"execution_id": 5239, "workflow": "helpdesk", "started_at": "2026-10-22T17:03:58", "user": "배수아", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1263, "steps": {"faq_ms": 15, "answer_ms": 1215}, "cost_usd": 0.00012}, +{"execution_id": 5240, "workflow": "helpdesk", "started_at": "2026-10-22T17:20:32", "user": "강민수", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1256, "steps": {"faq_ms": 30, "answer_ms": 1201}, "cost_usd": 0.00012}, +{"execution_id": 5241, "workflow": "helpdesk", "started_at": "2026-10-22T17:21:11", "user": "김민지", "topic": "VPN", "question": "VPN이 자꾸 끊겨요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2480, "steps": {"faq_ms": 38, "answer_ms": 2386}, "cost_usd": 0.00021}, +{"execution_id": 5242, "workflow": "helpdesk", "started_at": "2026-10-22T17:31:39", "user": "배수아", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1196, "steps": {"faq_ms": 36, "answer_ms": 1131}, "cost_usd": 0.00012}, +{"execution_id": 5243, "workflow": "helpdesk", "started_at": "2026-10-22T17:33:39", "user": "박서연", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 954, "steps": {"faq_ms": 18, "answer_ms": 907}, "cost_usd": 0.00012}, +{"execution_id": 5244, "workflow": "helpdesk", "started_at": "2026-10-22T17:40:12", "user": "윤하늘", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1280, "steps": {"faq_ms": 16, "answer_ms": 1220}, "cost_usd": 0.00012}, +{"execution_id": 5245, "workflow": "helpdesk", "started_at": "2026-10-22T17:46:29", "user": "박서연", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1381, "steps": {"faq_ms": 32, "answer_ms": 1301}, "cost_usd": 0.00012}, +{"execution_id": 5246, "workflow": "helpdesk", "started_at": "2026-10-23T09:03:08", "user": "이준호", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1266, "steps": {"faq_ms": 29, "answer_ms": 1181}, "cost_usd": 0.00012}, +{"execution_id": 5247, "workflow": "helpdesk", "started_at": "2026-10-23T09:03:49", "user": "강민수", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1329, "steps": {"faq_ms": 18, "answer_ms": 1257}, "cost_usd": 0.00012}, +{"execution_id": 5248, "workflow": "helpdesk", "started_at": "2026-10-23T09:04:11", "user": "임도윤", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1297, "steps": {"faq_ms": 25, "answer_ms": 1229}, "cost_usd": 0.00012}, +{"execution_id": 5249, "workflow": "helpdesk", "started_at": "2026-10-23T09:06:41", "user": "김민지", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1231, "steps": {"faq_ms": 22, "answer_ms": 1179}, "cost_usd": 0.00012}, +{"execution_id": 5250, "workflow": "helpdesk", "started_at": "2026-10-23T09:09:24", "user": "강민수", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2970, "steps": {"faq_ms": 36, "answer_ms": 2882}, "cost_usd": 0.00021}, +{"execution_id": 5251, "workflow": "helpdesk", "started_at": "2026-10-23T09:13:05", "user": "정우성", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1207, "steps": {"faq_ms": 16, "answer_ms": 1131}, "cost_usd": 0.00012}, +{"execution_id": 5252, "workflow": "helpdesk", "started_at": "2026-10-23T09:20:45", "user": "강민수", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1267, "steps": {"faq_ms": 19, "answer_ms": 1228}, "cost_usd": 0.00012}, +{"execution_id": 5253, "workflow": "helpdesk", "started_at": "2026-10-23T09:51:04", "user": "배수아", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1418, "steps": {"faq_ms": 27, "answer_ms": 1371}, "cost_usd": 0.00012}, +{"execution_id": 5254, "workflow": "helpdesk", "started_at": "2026-10-23T10:03:57", "user": "임도윤", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1441, "steps": {"faq_ms": 19, "answer_ms": 1364}, "cost_usd": 0.00012}, +{"execution_id": 5255, "workflow": "helpdesk", "started_at": "2026-10-23T10:34:14", "user": "윤하늘", "topic": "설치", "question": "라이선스 신청은 어디서 해요? 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2507, "steps": {"faq_ms": 29, "answer_ms": 2424}, "cost_usd": 0.00021}, +{"execution_id": 5256, "workflow": "helpdesk", "started_at": "2026-10-23T10:35:00", "user": "오세훈", "topic": "와이파이", "question": "와이파이가 자주 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 45, "steps": {"faq_ms": 16, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5257, "workflow": "helpdesk", "started_at": "2026-10-23T10:37:36", "user": "오세훈", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1169, "steps": {"faq_ms": 19, "answer_ms": 1101}, "cost_usd": 0.00012}, +{"execution_id": 5258, "workflow": "helpdesk", "started_at": "2026-10-23T10:42:06", "user": "정우성", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1138, "steps": {"faq_ms": 29, "answer_ms": 1059}, "cost_usd": 0.00012}, +{"execution_id": 5259, "workflow": "helpdesk", "started_at": "2026-10-23T11:08:29", "user": "최유진", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1447, "steps": {"faq_ms": 18, "answer_ms": 1383}, "cost_usd": 0.00012}, +{"execution_id": 5260, "workflow": "helpdesk", "started_at": "2026-10-23T11:08:48", "user": "오세훈", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 965, "steps": {"faq_ms": 16, "answer_ms": 910}, "cost_usd": 0.00012}, +{"execution_id": 5261, "workflow": "helpdesk", "started_at": "2026-10-23T11:26:37", "user": "윤하늘", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요? 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2480, "steps": {"faq_ms": 27, "answer_ms": 2398}, "cost_usd": 0.00021}, +{"execution_id": 5262, "workflow": "helpdesk", "started_at": "2026-10-23T11:31:47", "user": "오세훈", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1364, "steps": {"faq_ms": 35, "answer_ms": 1297}, "cost_usd": 0.00012}, +{"execution_id": 5263, "workflow": "helpdesk", "started_at": "2026-10-23T12:01:36", "user": "서지아", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1382, "steps": {"faq_ms": 18, "answer_ms": 1308}, "cost_usd": 0.00012}, +{"execution_id": 5264, "workflow": "helpdesk", "started_at": "2026-10-23T12:07:37", "user": "박서연", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1317, "steps": {"faq_ms": 33, "answer_ms": 1252}, "cost_usd": 0.00012}, +{"execution_id": 5265, "workflow": "helpdesk", "started_at": "2026-10-23T12:18:58", "user": "서지아", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1049, "steps": {"faq_ms": 20, "answer_ms": 1001}, "cost_usd": 0.00012}, +{"execution_id": 5266, "workflow": "helpdesk", "started_at": "2026-10-23T12:19:33", "user": "한지훈", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1322, "steps": {"faq_ms": 28, "answer_ms": 1255}, "cost_usd": 0.00012}, +{"execution_id": 5267, "workflow": "helpdesk", "started_at": "2026-10-23T12:29:30", "user": "강민수", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1202, "steps": {"faq_ms": 29, "answer_ms": 1138}, "cost_usd": 0.00012}, +{"execution_id": 5268, "workflow": "helpdesk", "started_at": "2026-10-23T12:38:15", "user": "정우성", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1023, "steps": {"faq_ms": 35, "answer_ms": 952}, "cost_usd": 0.00012}, +{"execution_id": 5269, "workflow": "helpdesk", "started_at": "2026-10-23T12:56:58", "user": "최유진", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1224, "steps": {"faq_ms": 20, "answer_ms": 1173}, "cost_usd": 0.00012}, +{"execution_id": 5270, "workflow": "helpdesk", "started_at": "2026-10-23T13:03:47", "user": "박서연", "topic": "설치", "question": "라이선스 신청은 어디서 해요? 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2326, "steps": {"faq_ms": 27, "answer_ms": 2272}, "cost_usd": 0.00021}, +{"execution_id": 5271, "workflow": "helpdesk", "started_at": "2026-10-23T13:09:50", "user": "한지훈", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1284, "steps": {"faq_ms": 33, "answer_ms": 1224}, "cost_usd": 0.00012}, +{"execution_id": 5272, "workflow": "helpdesk", "started_at": "2026-10-23T13:46:52", "user": "한지훈", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1405, "steps": {"faq_ms": 22, "answer_ms": 1343}, "cost_usd": 0.00012}, +{"execution_id": 5273, "workflow": "helpdesk", "started_at": "2026-10-23T14:02:06", "user": "배수아", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1322, "steps": {"faq_ms": 25, "answer_ms": 1262}, "cost_usd": 0.00012}, +{"execution_id": 5274, "workflow": "helpdesk", "started_at": "2026-10-23T14:12:59", "user": "박서연", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2530, "steps": {"faq_ms": 29, "answer_ms": 2451}, "cost_usd": 0.00021}, +{"execution_id": 5275, "workflow": "helpdesk", "started_at": "2026-10-23T14:19:20", "user": "강민수", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1162, "steps": {"faq_ms": 34, "answer_ms": 1100}, "cost_usd": 0.00012}, +{"execution_id": 5276, "workflow": "helpdesk", "started_at": "2026-10-23T14:28:51", "user": "박서연", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1265, "steps": {"faq_ms": 20, "answer_ms": 1189}, "cost_usd": 0.00012}, +{"execution_id": 5277, "workflow": "helpdesk", "started_at": "2026-10-23T14:30:05", "user": "윤하늘", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1455, "steps": {"faq_ms": 20, "answer_ms": 1386}, "cost_usd": 0.00012}, +{"execution_id": 5278, "workflow": "helpdesk", "started_at": "2026-10-23T14:31:58", "user": "정우성", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1040, "steps": {"faq_ms": 21, "answer_ms": 983}, "cost_usd": 0.00012}, +{"execution_id": 5279, "workflow": "helpdesk", "started_at": "2026-10-23T14:42:28", "user": "임도윤", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1076, "steps": {"faq_ms": 23, "answer_ms": 1031}, "cost_usd": 0.00012}, +{"execution_id": 5280, "workflow": "helpdesk", "started_at": "2026-10-23T14:44:50", "user": "서지아", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1320, "steps": {"faq_ms": 34, "answer_ms": 1250}, "cost_usd": 0.00012}, +{"execution_id": 5281, "workflow": "helpdesk", "started_at": "2026-10-23T14:45:05", "user": "임도윤", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1327, "steps": {"faq_ms": 20, "answer_ms": 1268}, "cost_usd": 0.00012}, +{"execution_id": 5282, "workflow": "helpdesk", "started_at": "2026-10-23T14:46:56", "user": "김민지", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 967, "steps": {"faq_ms": 15, "answer_ms": 925}, "cost_usd": 0.00012}, +{"execution_id": 5283, "workflow": "helpdesk", "started_at": "2026-10-23T14:47:56", "user": "임도윤", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1344, "steps": {"faq_ms": 19, "answer_ms": 1280}, "cost_usd": 0.00012}, +{"execution_id": 5284, "workflow": "helpdesk", "started_at": "2026-10-23T14:54:58", "user": "윤하늘", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1253, "steps": {"faq_ms": 33, "answer_ms": 1199}, "cost_usd": 0.00012}, +{"execution_id": 5285, "workflow": "helpdesk", "started_at": "2026-10-23T14:58:37", "user": "오세훈", "topic": "설치", "question": "라이선스 신청은 어디서 해요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1476, "steps": {"faq_ms": 17, "answer_ms": 1400}, "cost_usd": 0.00012}, +{"execution_id": 5286, "workflow": "helpdesk", "started_at": "2026-10-23T15:02:03", "user": "이준호", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1474, "steps": {"faq_ms": 39, "answer_ms": 1384}, "cost_usd": 0.00012}, +{"execution_id": 5287, "workflow": "helpdesk", "started_at": "2026-10-23T15:09:29", "user": "강민수", "topic": "와이파이", "question": "와이파이가 자주 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 65, "steps": {"faq_ms": 27, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5288, "workflow": "helpdesk", "started_at": "2026-10-23T15:23:50", "user": "배수아", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1338, "steps": {"faq_ms": 24, "answer_ms": 1291}, "cost_usd": 0.00012}, +{"execution_id": 5289, "workflow": "helpdesk", "started_at": "2026-10-23T15:28:11", "user": "한지훈", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1217, "steps": {"faq_ms": 29, "answer_ms": 1144}, "cost_usd": 0.00012}, +{"execution_id": 5290, "workflow": "helpdesk", "started_at": "2026-10-23T15:29:08", "user": "한지훈", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 999, "steps": {"faq_ms": 31, "answer_ms": 923}, "cost_usd": 0.00012}, +{"execution_id": 5291, "workflow": "helpdesk", "started_at": "2026-10-23T15:30:00", "user": "이준호", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1466, "steps": {"faq_ms": 25, "answer_ms": 1400}, "cost_usd": 0.00012}, +{"execution_id": 5292, "workflow": "helpdesk", "started_at": "2026-10-23T15:39:17", "user": "배수아", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1001, "steps": {"faq_ms": 15, "answer_ms": 958}, "cost_usd": 0.00012}, +{"execution_id": 5293, "workflow": "helpdesk", "started_at": "2026-10-23T15:56:51", "user": "한지훈", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1127, "steps": {"faq_ms": 15, "answer_ms": 1057}, "cost_usd": 0.00012}, +{"execution_id": 5294, "workflow": "helpdesk", "started_at": "2026-10-23T15:57:24", "user": "강민수", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1185, "steps": {"faq_ms": 16, "answer_ms": 1134}, "cost_usd": 0.00012}, +{"execution_id": 5295, "workflow": "helpdesk", "started_at": "2026-10-23T16:04:45", "user": "정우성", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1102, "steps": {"faq_ms": 29, "answer_ms": 1027}, "cost_usd": 0.00012}, +{"execution_id": 5296, "workflow": "helpdesk", "started_at": "2026-10-23T16:14:42", "user": "박서연", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1355, "steps": {"faq_ms": 37, "answer_ms": 1297}, "cost_usd": 0.00012}, +{"execution_id": 5297, "workflow": "helpdesk", "started_at": "2026-10-23T16:16:18", "user": "윤하늘", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1159, "steps": {"faq_ms": 26, "answer_ms": 1080}, "cost_usd": 0.00012}, +{"execution_id": 5298, "workflow": "helpdesk", "started_at": "2026-10-23T16:24:14", "user": "최유진", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1227, "steps": {"faq_ms": 32, "answer_ms": 1150}, "cost_usd": 0.00012}, +{"execution_id": 5299, "workflow": "helpdesk", "started_at": "2026-10-23T16:29:56", "user": "이준호", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1321, "steps": {"faq_ms": 31, "answer_ms": 1259}, "cost_usd": 0.00012}, +{"execution_id": 5300, "workflow": "helpdesk", "started_at": "2026-10-23T16:42:02", "user": "이준호", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1242, "steps": {"faq_ms": 28, "answer_ms": 1186}, "cost_usd": 0.00012}, +{"execution_id": 5301, "workflow": "helpdesk", "started_at": "2026-10-23T16:42:02", "user": "서지아", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1101, "steps": {"faq_ms": 30, "answer_ms": 1038}, "cost_usd": 0.00012}, +{"execution_id": 5302, "workflow": "helpdesk", "started_at": "2026-10-23T17:06:42", "user": "배수아", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1205, "steps": {"faq_ms": 20, "answer_ms": 1128}, "cost_usd": 0.00012}, +{"execution_id": 5303, "workflow": "helpdesk", "started_at": "2026-10-23T17:32:14", "user": "강민수", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1185, "steps": {"faq_ms": 37, "answer_ms": 1097}, "cost_usd": 0.00012}, +{"execution_id": 5304, "workflow": "helpdesk", "started_at": "2026-10-23T17:34:22", "user": "김민지", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1380, "steps": {"faq_ms": 17, "answer_ms": 1306}, "cost_usd": 0.00012}, +{"execution_id": 5305, "workflow": "helpdesk", "started_at": "2026-10-23T17:34:54", "user": "윤하늘", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 959, "steps": {"faq_ms": 28, "answer_ms": 901}, "cost_usd": 0.00012}, +{"execution_id": 5306, "workflow": "helpdesk", "started_at": "2026-10-23T17:38:27", "user": "오세훈", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 967, "steps": {"faq_ms": 37, "answer_ms": 908}, "cost_usd": 0.00012}, +{"execution_id": 5307, "workflow": "helpdesk", "started_at": "2026-10-23T17:39:06", "user": "최유진", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2412, "steps": {"faq_ms": 34, "answer_ms": 2354}, "cost_usd": 0.00021}, +{"execution_id": 5308, "workflow": "helpdesk", "started_at": "2026-10-23T17:50:58", "user": "한지훈", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요? 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2525, "steps": {"faq_ms": 24, "answer_ms": 2471}, "cost_usd": 0.00021}, +{"execution_id": 5309, "workflow": "helpdesk", "started_at": "2026-10-23T17:51:55", "user": "윤하늘", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 985, "steps": {"faq_ms": 25, "answer_ms": 921}, "cost_usd": 0.00012}, +{"execution_id": 5310, "workflow": "helpdesk", "started_at": "2026-10-26T09:03:54", "user": "서지아", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "error", "error_node": "Search FAQ", "error_message": "timeout of 1000ms exceeded", "llm_calls": 0, "repeat": false, "route": null, "duration_ms": 1055, "steps": {"faq_ms": 1000, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5311, "workflow": "helpdesk", "started_at": "2026-10-26T09:10:38", "user": "김민지", "topic": "설치", "question": "라이선스 신청은 어디서 해요?", "release": "1.0.0", "channel": "stable", "status": "error", "error_node": "Search FAQ", "error_message": "timeout of 1000ms exceeded", "llm_calls": 0, "repeat": false, "route": null, "duration_ms": 1052, "steps": {"faq_ms": 1000, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5312, "workflow": "helpdesk", "started_at": "2026-10-26T09:14:53", "user": "배수아", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "error", "error_node": "Search FAQ", "error_message": "timeout of 1000ms exceeded", "llm_calls": 0, "repeat": false, "route": null, "duration_ms": 1049, "steps": {"faq_ms": 1000, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5313, "workflow": "helpdesk", "started_at": "2026-10-26T09:16:45", "user": "이준호", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "error", "error_node": "Search FAQ", "error_message": "timeout of 1000ms exceeded", "llm_calls": 0, "repeat": false, "route": null, "duration_ms": 1025, "steps": {"faq_ms": 1000, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5314, "workflow": "helpdesk", "started_at": "2026-10-26T09:24:13", "user": "강민수", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "error", "error_node": "Search FAQ", "error_message": "timeout of 1000ms exceeded", "llm_calls": 0, "repeat": false, "route": null, "duration_ms": 1032, "steps": {"faq_ms": 1000, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5315, "workflow": "helpdesk", "started_at": "2026-10-26T09:25:23", "user": "강민수", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "error", "error_node": "Search FAQ", "error_message": "timeout of 1000ms exceeded", "llm_calls": 0, "repeat": false, "route": null, "duration_ms": 1048, "steps": {"faq_ms": 1000, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5316, "workflow": "helpdesk", "started_at": "2026-10-26T09:31:47", "user": "임도윤", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.0.0", "channel": "stable", "status": "error", "error_node": "Search FAQ", "error_message": "timeout of 1000ms exceeded", "llm_calls": 0, "repeat": false, "route": null, "duration_ms": 1036, "steps": {"faq_ms": 1000, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5317, "workflow": "helpdesk", "started_at": "2026-10-26T09:36:47", "user": "오세훈", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "error", "error_node": "Search FAQ", "error_message": "timeout of 1000ms exceeded", "llm_calls": 0, "repeat": false, "route": null, "duration_ms": 1050, "steps": {"faq_ms": 1000, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5318, "workflow": "helpdesk", "started_at": "2026-10-26T09:38:44", "user": "윤하늘", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "error", "error_node": "Search FAQ", "error_message": "timeout of 1000ms exceeded", "llm_calls": 0, "repeat": false, "route": null, "duration_ms": 1028, "steps": {"faq_ms": 1000, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5319, "workflow": "helpdesk", "started_at": "2026-10-26T09:39:12", "user": "윤하늘", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "error", "error_node": "Search FAQ", "error_message": "timeout of 1000ms exceeded", "llm_calls": 0, "repeat": false, "route": null, "duration_ms": 1024, "steps": {"faq_ms": 1000, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5320, "workflow": "helpdesk", "started_at": "2026-10-26T09:43:08", "user": "윤하늘", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "error", "error_node": "Search FAQ", "error_message": "timeout of 1000ms exceeded", "llm_calls": 0, "repeat": false, "route": null, "duration_ms": 1040, "steps": {"faq_ms": 1000, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5321, "workflow": "helpdesk", "started_at": "2026-10-26T09:45:30", "user": "서지아", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "error", "error_node": "Search FAQ", "error_message": "timeout of 1000ms exceeded", "llm_calls": 0, "repeat": false, "route": null, "duration_ms": 1025, "steps": {"faq_ms": 1000, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5322, "workflow": "helpdesk", "started_at": "2026-10-26T09:54:51", "user": "이준호", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "error", "error_node": "Search FAQ", "error_message": "timeout of 1000ms exceeded", "llm_calls": 0, "repeat": false, "route": null, "duration_ms": 1051, "steps": {"faq_ms": 1000, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5323, "workflow": "helpdesk", "started_at": "2026-10-26T09:55:54", "user": "배수아", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "error", "error_node": "Search FAQ", "error_message": "timeout of 1000ms exceeded", "llm_calls": 0, "repeat": false, "route": null, "duration_ms": 1038, "steps": {"faq_ms": 1000, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5324, "workflow": "helpdesk", "started_at": "2026-10-26T09:57:18", "user": "서지아", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "error", "error_node": "Search FAQ", "error_message": "timeout of 1000ms exceeded", "llm_calls": 0, "repeat": false, "route": null, "duration_ms": 1028, "steps": {"faq_ms": 1000, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5325, "workflow": "helpdesk", "started_at": "2026-10-26T09:58:36", "user": "최유진", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "error", "error_node": "Search FAQ", "error_message": "timeout of 1000ms exceeded", "llm_calls": 0, "repeat": false, "route": null, "duration_ms": 1054, "steps": {"faq_ms": 1000, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5326, "workflow": "helpdesk", "started_at": "2026-10-26T10:16:34", "user": "한지훈", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1378, "steps": {"faq_ms": 17, "answer_ms": 1325}, "cost_usd": 0.00012}, +{"execution_id": 5327, "workflow": "helpdesk", "started_at": "2026-10-26T10:32:24", "user": "강민수", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1374, "steps": {"faq_ms": 19, "answer_ms": 1298}, "cost_usd": 0.00012}, +{"execution_id": 5328, "workflow": "helpdesk", "started_at": "2026-10-26T10:33:57", "user": "이준호", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1371, "steps": {"faq_ms": 34, "answer_ms": 1299}, "cost_usd": 0.00012}, +{"execution_id": 5329, "workflow": "helpdesk", "started_at": "2026-10-26T10:43:32", "user": "윤하늘", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1043, "steps": {"faq_ms": 18, "answer_ms": 970}, "cost_usd": 0.00012}, +{"execution_id": 5330, "workflow": "helpdesk", "started_at": "2026-10-26T11:05:27", "user": "배수아", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1070, "steps": {"faq_ms": 31, "answer_ms": 997}, "cost_usd": 0.00012}, +{"execution_id": 5331, "workflow": "helpdesk", "started_at": "2026-10-26T11:09:20", "user": "임도윤", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1137, "steps": {"faq_ms": 38, "answer_ms": 1055}, "cost_usd": 0.00012}, +{"execution_id": 5332, "workflow": "helpdesk", "started_at": "2026-10-26T11:20:04", "user": "정우성", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1092, "steps": {"faq_ms": 28, "answer_ms": 1044}, "cost_usd": 0.00012}, +{"execution_id": 5333, "workflow": "helpdesk", "started_at": "2026-10-26T11:21:18", "user": "오세훈", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1432, "steps": {"faq_ms": 28, "answer_ms": 1379}, "cost_usd": 0.00012}, +{"execution_id": 5334, "workflow": "helpdesk", "started_at": "2026-10-26T11:23:07", "user": "윤하늘", "topic": "VPN", "question": "VPN이 자꾸 끊겨요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2770, "steps": {"faq_ms": 33, "answer_ms": 2717}, "cost_usd": 0.00021}, +{"execution_id": 5335, "workflow": "helpdesk", "started_at": "2026-10-26T11:31:10", "user": "배수아", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 966, "steps": {"faq_ms": 34, "answer_ms": 909}, "cost_usd": 0.00012}, +{"execution_id": 5336, "workflow": "helpdesk", "started_at": "2026-10-26T11:47:57", "user": "강민수", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1333, "steps": {"faq_ms": 32, "answer_ms": 1251}, "cost_usd": 0.00012}, +{"execution_id": 5337, "workflow": "helpdesk", "started_at": "2026-10-26T12:03:07", "user": "이준호", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 961, "steps": {"faq_ms": 22, "answer_ms": 915}, "cost_usd": 0.00012}, +{"execution_id": 5338, "workflow": "helpdesk", "started_at": "2026-10-26T12:12:52", "user": "서지아", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 971, "steps": {"faq_ms": 33, "answer_ms": 916}, "cost_usd": 0.00012}, +{"execution_id": 5339, "workflow": "helpdesk", "started_at": "2026-10-26T12:16:08", "user": "한지훈", "topic": "프린터", "question": "프린터 출력이 안 돼요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2562, "steps": {"faq_ms": 21, "answer_ms": 2482}, "cost_usd": 0.00021}, +{"execution_id": 5340, "workflow": "helpdesk", "started_at": "2026-10-26T12:18:40", "user": "배수아", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1027, "steps": {"faq_ms": 38, "answer_ms": 961}, "cost_usd": 0.00012}, +{"execution_id": 5341, "workflow": "helpdesk", "started_at": "2026-10-26T12:33:56", "user": "강민수", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 980, "steps": {"faq_ms": 21, "answer_ms": 926}, "cost_usd": 0.00012}, +{"execution_id": 5342, "workflow": "helpdesk", "started_at": "2026-10-26T12:35:59", "user": "배수아", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1436, "steps": {"faq_ms": 24, "answer_ms": 1385}, "cost_usd": 0.00012}, +{"execution_id": 5343, "workflow": "helpdesk", "started_at": "2026-10-26T12:42:54", "user": "한지훈", "topic": "설치", "question": "라이선스 신청은 어디서 해요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1056, "steps": {"faq_ms": 33, "answer_ms": 972}, "cost_usd": 0.00012}, +{"execution_id": 5344, "workflow": "helpdesk", "started_at": "2026-10-26T12:45:29", "user": "배수아", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1358, "steps": {"faq_ms": 31, "answer_ms": 1279}, "cost_usd": 0.00012}, +{"execution_id": 5345, "workflow": "helpdesk", "started_at": "2026-10-26T12:49:51", "user": "윤하늘", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2468, "steps": {"faq_ms": 31, "answer_ms": 2383}, "cost_usd": 0.00021}, +{"execution_id": 5346, "workflow": "helpdesk", "started_at": "2026-10-26T12:58:20", "user": "오세훈", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1129, "steps": {"faq_ms": 32, "answer_ms": 1062}, "cost_usd": 0.00012}, +{"execution_id": 5347, "workflow": "helpdesk", "started_at": "2026-10-26T13:11:11", "user": "오세훈", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1279, "steps": {"faq_ms": 39, "answer_ms": 1184}, "cost_usd": 0.00012}, +{"execution_id": 5348, "workflow": "helpdesk", "started_at": "2026-10-26T13:13:43", "user": "배수아", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1045, "steps": {"faq_ms": 18, "answer_ms": 1001}, "cost_usd": 0.00012}, +{"execution_id": 5349, "workflow": "helpdesk", "started_at": "2026-10-26T13:18:13", "user": "오세훈", "topic": "VPN", "question": "VPN이 자꾸 끊겨요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 2809, "steps": {"faq_ms": 25, "answer_ms": 2759}, "cost_usd": 0.00021}, +{"execution_id": 5350, "workflow": "helpdesk", "started_at": "2026-10-26T13:39:56", "user": "정우성", "topic": "와이파이", "question": "회의실 와이파이가 안 잡혀요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 68, "steps": {"faq_ms": 35, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5351, "workflow": "helpdesk", "started_at": "2026-10-26T13:54:37", "user": "이준호", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1415, "steps": {"faq_ms": 17, "answer_ms": 1367}, "cost_usd": 0.00012}, +{"execution_id": 5352, "workflow": "helpdesk", "started_at": "2026-10-26T14:02:45", "user": "한지훈", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1105, "steps": {"faq_ms": 19, "answer_ms": 1065}, "cost_usd": 0.00012}, +{"execution_id": 5353, "workflow": "helpdesk", "started_at": "2026-10-26T14:07:14", "user": "최유진", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1176, "steps": {"faq_ms": 16, "answer_ms": 1107}, "cost_usd": 0.00012}, +{"execution_id": 5354, "workflow": "helpdesk", "started_at": "2026-10-26T14:13:00", "user": "최유진", "topic": "설치", "question": "라이선스 신청은 어디서 해요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1341, "steps": {"faq_ms": 38, "answer_ms": 1264}, "cost_usd": 0.00012}, +{"execution_id": 5355, "workflow": "helpdesk", "started_at": "2026-10-26T14:14:29", "user": "최유진", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1116, "steps": {"faq_ms": 25, "answer_ms": 1050}, "cost_usd": 0.00012}, +{"execution_id": 5356, "workflow": "helpdesk", "started_at": "2026-10-26T14:16:35", "user": "배수아", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1134, "steps": {"faq_ms": 36, "answer_ms": 1061}, "cost_usd": 0.00012}, +{"execution_id": 5357, "workflow": "helpdesk", "started_at": "2026-10-26T14:20:39", "user": "임도윤", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요? 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2164, "steps": {"faq_ms": 29, "answer_ms": 2083}, "cost_usd": 0.00021}, +{"execution_id": 5358, "workflow": "helpdesk", "started_at": "2026-10-26T14:44:34", "user": "정우성", "topic": "와이파이", "question": "사내 Wi-Fi 비밀번호가 뭐예요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 77, "steps": {"faq_ms": 21, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5359, "workflow": "helpdesk", "started_at": "2026-10-26T14:49:06", "user": "배수아", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 997, "steps": {"faq_ms": 23, "answer_ms": 930}, "cost_usd": 0.00012}, +{"execution_id": 5360, "workflow": "helpdesk", "started_at": "2026-10-26T14:51:56", "user": "이준호", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1297, "steps": {"faq_ms": 28, "answer_ms": 1225}, "cost_usd": 0.00012}, +{"execution_id": 5361, "workflow": "helpdesk", "started_at": "2026-10-26T15:00:37", "user": "이준호", "topic": "설치", "question": "라이선스 신청은 어디서 해요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1371, "steps": {"faq_ms": 28, "answer_ms": 1292}, "cost_usd": 0.00012}, +{"execution_id": 5362, "workflow": "helpdesk", "started_at": "2026-10-26T15:07:35", "user": "정우성", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1314, "steps": {"faq_ms": 15, "answer_ms": 1270}, "cost_usd": 0.00012}, +{"execution_id": 5363, "workflow": "helpdesk", "started_at": "2026-10-26T15:15:19", "user": "김민지", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1027, "steps": {"faq_ms": 16, "answer_ms": 957}, "cost_usd": 0.00012}, +{"execution_id": 5364, "workflow": "helpdesk", "started_at": "2026-10-26T15:19:00", "user": "김민지", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1376, "steps": {"faq_ms": 28, "answer_ms": 1321}, "cost_usd": 0.00012}, +{"execution_id": 5365, "workflow": "helpdesk", "started_at": "2026-10-26T15:32:07", "user": "서지아", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1445, "steps": {"faq_ms": 32, "answer_ms": 1357}, "cost_usd": 0.00012}, +{"execution_id": 5366, "workflow": "helpdesk", "started_at": "2026-10-26T15:35:50", "user": "강민수", "topic": "와이파이", "question": "회의실 와이파이가 안 잡혀요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 42, "steps": {"faq_ms": 17, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5367, "workflow": "helpdesk", "started_at": "2026-10-26T15:37:33", "user": "김민지", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1033, "steps": {"faq_ms": 18, "answer_ms": 976}, "cost_usd": 0.00012}, +{"execution_id": 5368, "workflow": "helpdesk", "started_at": "2026-10-26T15:37:48", "user": "윤하늘", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1256, "steps": {"faq_ms": 34, "answer_ms": 1171}, "cost_usd": 0.00012}, +{"execution_id": 5369, "workflow": "helpdesk", "started_at": "2026-10-26T15:46:42", "user": "배수아", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1255, "steps": {"faq_ms": 21, "answer_ms": 1201}, "cost_usd": 0.00012}, +{"execution_id": 5370, "workflow": "helpdesk", "started_at": "2026-10-26T15:54:55", "user": "배수아", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1448, "steps": {"faq_ms": 23, "answer_ms": 1381}, "cost_usd": 0.00012}, +{"execution_id": 5371, "workflow": "helpdesk", "started_at": "2026-10-26T15:58:26", "user": "김민지", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1260, "steps": {"faq_ms": 30, "answer_ms": 1174}, "cost_usd": 0.00012}, +{"execution_id": 5372, "workflow": "helpdesk", "started_at": "2026-10-26T16:13:15", "user": "김민지", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1082, "steps": {"faq_ms": 22, "answer_ms": 1019}, "cost_usd": 0.00012}, +{"execution_id": 5373, "workflow": "helpdesk", "started_at": "2026-10-26T16:28:24", "user": "김민지", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1437, "steps": {"faq_ms": 15, "answer_ms": 1364}, "cost_usd": 0.00012}, +{"execution_id": 5374, "workflow": "helpdesk", "started_at": "2026-10-26T16:30:28", "user": "오세훈", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1161, "steps": {"faq_ms": 33, "answer_ms": 1082}, "cost_usd": 0.00012}, +{"execution_id": 5375, "workflow": "helpdesk", "started_at": "2026-10-26T16:50:03", "user": "이준호", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1015, "steps": {"faq_ms": 28, "answer_ms": 933}, "cost_usd": 0.00012}, +{"execution_id": 5376, "workflow": "helpdesk", "started_at": "2026-10-26T16:58:05", "user": "박서연", "topic": "와이파이", "question": "회의실 와이파이가 안 잡혀요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 67, "steps": {"faq_ms": 34, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5377, "workflow": "helpdesk", "started_at": "2026-10-26T17:25:02", "user": "강민수", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1164, "steps": {"faq_ms": 23, "answer_ms": 1116}, "cost_usd": 0.00012}, +{"execution_id": 5378, "workflow": "helpdesk", "started_at": "2026-10-26T17:43:43", "user": "김민지", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1213, "steps": {"faq_ms": 23, "answer_ms": 1169}, "cost_usd": 0.00012}, +{"execution_id": 5379, "workflow": "helpdesk", "started_at": "2026-10-26T17:59:19", "user": "임도윤", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1047, "steps": {"faq_ms": 35, "answer_ms": 980}, "cost_usd": 0.00012}, +{"execution_id": 5380, "workflow": "helpdesk", "started_at": "2026-10-27T09:05:17", "user": "배수아", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1213, "steps": {"faq_ms": 37, "answer_ms": 1154}, "cost_usd": 0.00012}, +{"execution_id": 5381, "workflow": "helpdesk", "started_at": "2026-10-27T09:06:19", "user": "오세훈", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1360, "steps": {"faq_ms": 32, "answer_ms": 1269}, "cost_usd": 0.00012}, +{"execution_id": 5382, "workflow": "helpdesk", "started_at": "2026-10-27T09:08:26", "user": "최유진", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1257, "steps": {"faq_ms": 20, "answer_ms": 1183}, "cost_usd": 0.00012}, +{"execution_id": 5383, "workflow": "helpdesk", "started_at": "2026-10-27T09:36:33", "user": "서지아", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1057, "steps": {"faq_ms": 31, "answer_ms": 972}, "cost_usd": 0.00012}, +{"execution_id": 5384, "workflow": "helpdesk", "started_at": "2026-10-27T09:47:52", "user": "서지아", "topic": "VPN", "question": "VPN이 자꾸 끊겨요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2795, "steps": {"faq_ms": 34, "answer_ms": 2711}, "cost_usd": 0.00021}, +{"execution_id": 5385, "workflow": "helpdesk", "started_at": "2026-10-27T10:11:37", "user": "임도윤", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1130, "steps": {"faq_ms": 17, "answer_ms": 1061}, "cost_usd": 0.00012}, +{"execution_id": 5386, "workflow": "helpdesk", "started_at": "2026-10-27T10:17:59", "user": "김민지", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1131, "steps": {"faq_ms": 20, "answer_ms": 1053}, "cost_usd": 0.00012}, +{"execution_id": 5387, "workflow": "helpdesk", "started_at": "2026-10-27T10:19:59", "user": "정우성", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1019, "steps": {"faq_ms": 20, "answer_ms": 956}, "cost_usd": 0.00012}, +{"execution_id": 5388, "workflow": "helpdesk", "started_at": "2026-10-27T10:30:26", "user": "오세훈", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1257, "steps": {"faq_ms": 40, "answer_ms": 1179}, "cost_usd": 0.00012}, +{"execution_id": 5389, "workflow": "helpdesk", "started_at": "2026-10-27T10:45:47", "user": "이준호", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1051, "steps": {"faq_ms": 39, "answer_ms": 992}, "cost_usd": 0.00012}, +{"execution_id": 5390, "workflow": "helpdesk", "started_at": "2026-10-27T10:57:46", "user": "배수아", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1146, "steps": {"faq_ms": 33, "answer_ms": 1075}, "cost_usd": 0.00012}, +{"execution_id": 5391, "workflow": "helpdesk", "started_at": "2026-10-27T11:05:11", "user": "박서연", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1201, "steps": {"faq_ms": 37, "answer_ms": 1138}, "cost_usd": 0.00012}, +{"execution_id": 5392, "workflow": "helpdesk", "started_at": "2026-10-27T11:10:30", "user": "윤하늘", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1097, "steps": {"faq_ms": 23, "answer_ms": 1026}, "cost_usd": 0.00012}, +{"execution_id": 5393, "workflow": "helpdesk", "started_at": "2026-10-27T11:11:42", "user": "강민수", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요? 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2431, "steps": {"faq_ms": 19, "answer_ms": 2369}, "cost_usd": 0.00021}, +{"execution_id": 5394, "workflow": "helpdesk", "started_at": "2026-10-27T11:24:06", "user": "박서연", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1173, "steps": {"faq_ms": 34, "answer_ms": 1079}, "cost_usd": 0.00012}, +{"execution_id": 5395, "workflow": "helpdesk", "started_at": "2026-10-27T11:32:42", "user": "배수아", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1411, "steps": {"faq_ms": 21, "answer_ms": 1332}, "cost_usd": 0.00012}, +{"execution_id": 5396, "workflow": "helpdesk", "started_at": "2026-10-27T12:08:48", "user": "한지훈", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1487, "steps": {"faq_ms": 35, "answer_ms": 1400}, "cost_usd": 0.00012}, +{"execution_id": 5397, "workflow": "helpdesk", "started_at": "2026-10-27T12:10:52", "user": "오세훈", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1254, "steps": {"faq_ms": 26, "answer_ms": 1204}, "cost_usd": 0.00012}, +{"execution_id": 5398, "workflow": "helpdesk", "started_at": "2026-10-27T12:12:38", "user": "최유진", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1443, "steps": {"faq_ms": 18, "answer_ms": 1394}, "cost_usd": 0.00012}, +{"execution_id": 5399, "workflow": "helpdesk", "started_at": "2026-10-27T12:18:22", "user": "오세훈", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1449, "steps": {"faq_ms": 27, "answer_ms": 1369}, "cost_usd": 0.00012}, +{"execution_id": 5400, "workflow": "helpdesk", "started_at": "2026-10-27T12:22:57", "user": "정우성", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1057, "steps": {"faq_ms": 35, "answer_ms": 974}, "cost_usd": 0.00012}, +{"execution_id": 5401, "workflow": "helpdesk", "started_at": "2026-10-27T12:25:55", "user": "이준호", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1240, "steps": {"faq_ms": 35, "answer_ms": 1172}, "cost_usd": 0.00012}, +{"execution_id": 5402, "workflow": "helpdesk", "started_at": "2026-10-27T12:29:32", "user": "배수아", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1111, "steps": {"faq_ms": 36, "answer_ms": 1033}, "cost_usd": 0.00012}, +{"execution_id": 5403, "workflow": "helpdesk", "started_at": "2026-10-27T12:36:02", "user": "김민지", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1001, "steps": {"faq_ms": 37, "answer_ms": 909}, "cost_usd": 0.00012}, +{"execution_id": 5404, "workflow": "helpdesk", "started_at": "2026-10-27T12:43:07", "user": "박서연", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1162, "steps": {"faq_ms": 31, "answer_ms": 1090}, "cost_usd": 0.00012}, +{"execution_id": 5405, "workflow": "helpdesk", "started_at": "2026-10-27T13:02:28", "user": "한지훈", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1021, "steps": {"faq_ms": 31, "answer_ms": 936}, "cost_usd": 0.00012}, +{"execution_id": 5406, "workflow": "helpdesk", "started_at": "2026-10-27T13:30:18", "user": "오세훈", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1020, "steps": {"faq_ms": 15, "answer_ms": 971}, "cost_usd": 0.00012}, +{"execution_id": 5407, "workflow": "helpdesk", "started_at": "2026-10-27T13:30:51", "user": "한지훈", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1108, "steps": {"faq_ms": 21, "answer_ms": 1036}, "cost_usd": 0.00012}, +{"execution_id": 5408, "workflow": "helpdesk", "started_at": "2026-10-27T13:34:31", "user": "임도윤", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1317, "steps": {"faq_ms": 39, "answer_ms": 1220}, "cost_usd": 0.00012}, +{"execution_id": 5409, "workflow": "helpdesk", "started_at": "2026-10-27T13:51:47", "user": "김민지", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1242, "steps": {"faq_ms": 17, "answer_ms": 1197}, "cost_usd": 0.00012}, +{"execution_id": 5410, "workflow": "helpdesk", "started_at": "2026-10-27T13:58:55", "user": "이준호", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1250, "steps": {"faq_ms": 22, "answer_ms": 1195}, "cost_usd": 0.00012}, +{"execution_id": 5411, "workflow": "helpdesk", "started_at": "2026-10-27T14:03:27", "user": "배수아", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1442, "steps": {"faq_ms": 27, "answer_ms": 1360}, "cost_usd": 0.00012}, +{"execution_id": 5412, "workflow": "helpdesk", "started_at": "2026-10-27T14:11:03", "user": "서지아", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1359, "steps": {"faq_ms": 22, "answer_ms": 1310}, "cost_usd": 0.00012}, +{"execution_id": 5413, "workflow": "helpdesk", "started_at": "2026-10-27T14:17:02", "user": "최유진", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1421, "steps": {"faq_ms": 20, "answer_ms": 1373}, "cost_usd": 0.00012}, +{"execution_id": 5414, "workflow": "helpdesk", "started_at": "2026-10-27T14:17:56", "user": "서지아", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1228, "steps": {"faq_ms": 35, "answer_ms": 1140}, "cost_usd": 0.00012}, +{"execution_id": 5415, "workflow": "helpdesk", "started_at": "2026-10-27T14:46:37", "user": "배수아", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1386, "steps": {"faq_ms": 29, "answer_ms": 1328}, "cost_usd": 0.00012}, +{"execution_id": 5416, "workflow": "helpdesk", "started_at": "2026-10-27T14:47:15", "user": "김민지", "topic": "와이파이", "question": "와이파이가 자주 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 67, "steps": {"faq_ms": 19, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5417, "workflow": "helpdesk", "started_at": "2026-10-27T15:07:07", "user": "임도윤", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1130, "steps": {"faq_ms": 23, "answer_ms": 1082}, "cost_usd": 0.00012}, +{"execution_id": 5418, "workflow": "helpdesk", "started_at": "2026-10-27T15:07:38", "user": "정우성", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1458, "steps": {"faq_ms": 22, "answer_ms": 1391}, "cost_usd": 0.00012}, +{"execution_id": 5419, "workflow": "helpdesk", "started_at": "2026-10-27T15:21:10", "user": "임도윤", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1245, "steps": {"faq_ms": 29, "answer_ms": 1173}, "cost_usd": 0.00012}, +{"execution_id": 5420, "workflow": "helpdesk", "started_at": "2026-10-27T15:26:25", "user": "이준호", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1278, "steps": {"faq_ms": 27, "answer_ms": 1219}, "cost_usd": 0.00012}, +{"execution_id": 5421, "workflow": "helpdesk", "started_at": "2026-10-27T15:39:48", "user": "임도윤", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 965, "steps": {"faq_ms": 16, "answer_ms": 901}, "cost_usd": 0.00012}, +{"execution_id": 5422, "workflow": "helpdesk", "started_at": "2026-10-27T15:41:13", "user": "박서연", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1423, "steps": {"faq_ms": 35, "answer_ms": 1339}, "cost_usd": 0.00012}, +{"execution_id": 5423, "workflow": "helpdesk", "started_at": "2026-10-27T15:48:40", "user": "이준호", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1092, "steps": {"faq_ms": 38, "answer_ms": 994}, "cost_usd": 0.00012}, +{"execution_id": 5424, "workflow": "helpdesk", "started_at": "2026-10-27T15:53:03", "user": "박서연", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1194, "steps": {"faq_ms": 18, "answer_ms": 1135}, "cost_usd": 0.00012}, +{"execution_id": 5425, "workflow": "helpdesk", "started_at": "2026-10-27T16:01:21", "user": "최유진", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2715, "steps": {"faq_ms": 28, "answer_ms": 2665}, "cost_usd": 0.00021}, +{"execution_id": 5426, "workflow": "helpdesk", "started_at": "2026-10-27T16:18:54", "user": "한지훈", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2762, "steps": {"faq_ms": 21, "answer_ms": 2701}, "cost_usd": 0.00021}, +{"execution_id": 5427, "workflow": "helpdesk", "started_at": "2026-10-27T16:22:30", "user": "오세훈", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1133, "steps": {"faq_ms": 26, "answer_ms": 1055}, "cost_usd": 0.00012}, +{"execution_id": 5428, "workflow": "helpdesk", "started_at": "2026-10-27T16:23:05", "user": "윤하늘", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1312, "steps": {"faq_ms": 16, "answer_ms": 1248}, "cost_usd": 0.00012}, +{"execution_id": 5429, "workflow": "helpdesk", "started_at": "2026-10-27T16:24:21", "user": "최유진", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1036, "steps": {"faq_ms": 40, "answer_ms": 962}, "cost_usd": 0.00012}, +{"execution_id": 5430, "workflow": "helpdesk", "started_at": "2026-10-27T16:26:22", "user": "임도윤", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1134, "steps": {"faq_ms": 27, "answer_ms": 1055}, "cost_usd": 0.00012}, +{"execution_id": 5431, "workflow": "helpdesk", "started_at": "2026-10-27T16:28:18", "user": "임도윤", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1449, "steps": {"faq_ms": 18, "answer_ms": 1371}, "cost_usd": 0.00012}, +{"execution_id": 5432, "workflow": "helpdesk", "started_at": "2026-10-27T16:42:09", "user": "한지훈", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1244, "steps": {"faq_ms": 20, "answer_ms": 1165}, "cost_usd": 0.00012}, +{"execution_id": 5433, "workflow": "helpdesk", "started_at": "2026-10-27T16:42:47", "user": "강민수", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1182, "steps": {"faq_ms": 36, "answer_ms": 1114}, "cost_usd": 0.00012}, +{"execution_id": 5434, "workflow": "helpdesk", "started_at": "2026-10-27T16:42:57", "user": "정우성", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1033, "steps": {"faq_ms": 34, "answer_ms": 961}, "cost_usd": 0.00012}, +{"execution_id": 5435, "workflow": "helpdesk", "started_at": "2026-10-27T16:46:56", "user": "김민지", "topic": "설치", "question": "라이선스 신청은 어디서 해요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1298, "steps": {"faq_ms": 25, "answer_ms": 1250}, "cost_usd": 0.00012}, +{"execution_id": 5436, "workflow": "helpdesk", "started_at": "2026-10-27T17:20:33", "user": "배수아", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2677, "steps": {"faq_ms": 29, "answer_ms": 2619}, "cost_usd": 0.00021}, +{"execution_id": 5437, "workflow": "helpdesk", "started_at": "2026-10-27T17:21:09", "user": "최유진", "topic": "와이파이", "question": "사내 Wi-Fi 비밀번호가 뭐예요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 55, "steps": {"faq_ms": 24, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5438, "workflow": "helpdesk", "started_at": "2026-10-27T17:38:18", "user": "배수아", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1054, "steps": {"faq_ms": 15, "answer_ms": 1011}, "cost_usd": 0.00012}, +{"execution_id": 5439, "workflow": "helpdesk", "started_at": "2026-10-27T17:41:12", "user": "이준호", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1382, "steps": {"faq_ms": 28, "answer_ms": 1300}, "cost_usd": 0.00012}, +{"execution_id": 5440, "workflow": "helpdesk", "started_at": "2026-10-27T17:52:56", "user": "강민수", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1007, "steps": {"faq_ms": 29, "answer_ms": 943}, "cost_usd": 0.00012}, +{"execution_id": 5441, "workflow": "helpdesk", "started_at": "2026-10-27T17:59:35", "user": "김민지", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.0.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1270, "steps": {"faq_ms": 40, "answer_ms": 1173}, "cost_usd": 0.00012}, +{"execution_id": 5442, "workflow": "helpdesk", "started_at": "2026-10-28T09:05:18", "user": "윤하늘", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1067, "steps": {"faq_ms": 15, "answer_ms": 1015}, "cost_usd": 0.00012}, +{"execution_id": 5443, "workflow": "helpdesk", "started_at": "2026-10-28T09:09:34", "user": "최유진", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1329, "steps": {"faq_ms": 30, "answer_ms": 1241}, "cost_usd": 0.00012}, +{"execution_id": 5444, "workflow": "helpdesk", "started_at": "2026-10-28T09:12:19", "user": "이준호", "topic": "와이파이", "question": "사내 Wi-Fi 비밀번호가 뭐예요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 61, "steps": {"faq_ms": 29, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5445, "workflow": "helpdesk", "started_at": "2026-10-28T09:18:23", "user": "박서연", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1426, "steps": {"faq_ms": 33, "answer_ms": 1337}, "cost_usd": 0.00012}, +{"execution_id": 5446, "workflow": "helpdesk", "started_at": "2026-10-28T09:26:34", "user": "윤하늘", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1305, "steps": {"faq_ms": 23, "answer_ms": 1257}, "cost_usd": 0.00012}, +{"execution_id": 5447, "workflow": "helpdesk", "started_at": "2026-10-28T09:32:48", "user": "김민지", "topic": "메일", "question": "메일함 용량이 꽉 찼대요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2459, "steps": {"faq_ms": 32, "answer_ms": 2383}, "cost_usd": 0.00021}, +{"execution_id": 5448, "workflow": "helpdesk", "started_at": "2026-10-28T09:38:14", "user": "정우성", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2790, "steps": {"faq_ms": 21, "answer_ms": 2742}, "cost_usd": 0.00021}, +{"execution_id": 5449, "workflow": "helpdesk", "started_at": "2026-10-28T09:45:16", "user": "윤하늘", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요? 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2637, "steps": {"faq_ms": 36, "answer_ms": 2555}, "cost_usd": 0.00021}, +{"execution_id": 5450, "workflow": "helpdesk", "started_at": "2026-10-28T09:57:51", "user": "윤하늘", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1035, "steps": {"faq_ms": 21, "answer_ms": 979}, "cost_usd": 0.00012}, +{"execution_id": 5451, "workflow": "helpdesk", "started_at": "2026-10-28T10:24:20", "user": "김민지", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1236, "steps": {"faq_ms": 33, "answer_ms": 1171}, "cost_usd": 0.00012}, +{"execution_id": 5452, "workflow": "helpdesk", "started_at": "2026-10-28T10:29:37", "user": "서지아", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1451, "steps": {"faq_ms": 24, "answer_ms": 1378}, "cost_usd": 0.00012}, +{"execution_id": 5453, "workflow": "helpdesk", "started_at": "2026-10-28T10:34:02", "user": "배수아", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1327, "steps": {"faq_ms": 24, "answer_ms": 1263}, "cost_usd": 0.00012}, +{"execution_id": 5454, "workflow": "helpdesk", "started_at": "2026-10-28T10:34:50", "user": "박서연", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1277, "steps": {"faq_ms": 22, "answer_ms": 1222}, "cost_usd": 0.00012}, +{"execution_id": 5455, "workflow": "helpdesk", "started_at": "2026-10-28T10:47:02", "user": "서지아", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2600, "steps": {"faq_ms": 33, "answer_ms": 2509}, "cost_usd": 0.00021}, +{"execution_id": 5456, "workflow": "helpdesk", "started_at": "2026-10-28T10:54:18", "user": "임도윤", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1085, "steps": {"faq_ms": 30, "answer_ms": 1023}, "cost_usd": 0.00012}, +{"execution_id": 5457, "workflow": "helpdesk", "started_at": "2026-10-28T10:59:18", "user": "정우성", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1183, "steps": {"faq_ms": 30, "answer_ms": 1108}, "cost_usd": 0.00012}, +{"execution_id": 5458, "workflow": "helpdesk", "started_at": "2026-10-28T11:07:24", "user": "정우성", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1358, "steps": {"faq_ms": 31, "answer_ms": 1305}, "cost_usd": 0.00012}, +{"execution_id": 5459, "workflow": "helpdesk", "started_at": "2026-10-28T11:16:44", "user": "이준호", "topic": "VPN", "question": "VPN이 자꾸 끊겨요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2601, "steps": {"faq_ms": 33, "answer_ms": 2514}, "cost_usd": 0.00021}, +{"execution_id": 5460, "workflow": "helpdesk", "started_at": "2026-10-28T11:24:59", "user": "정우성", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1277, "steps": {"faq_ms": 36, "answer_ms": 1216}, "cost_usd": 0.00012}, +{"execution_id": 5461, "workflow": "helpdesk", "started_at": "2026-10-28T11:27:22", "user": "최유진", "topic": "와이파이", "question": "와이파이가 자주 끊겨요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 67, "steps": {"faq_ms": 20, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5462, "workflow": "helpdesk", "started_at": "2026-10-28T11:30:20", "user": "서지아", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1283, "steps": {"faq_ms": 25, "answer_ms": 1202}, "cost_usd": 0.00012}, +{"execution_id": 5463, "workflow": "helpdesk", "started_at": "2026-10-28T11:39:11", "user": "강민수", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1177, "steps": {"faq_ms": 21, "answer_ms": 1109}, "cost_usd": 0.00012}, +{"execution_id": 5464, "workflow": "helpdesk", "started_at": "2026-10-28T11:46:47", "user": "김민지", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1110, "steps": {"faq_ms": 34, "answer_ms": 1022}, "cost_usd": 0.00012}, +{"execution_id": 5465, "workflow": "helpdesk", "started_at": "2026-10-28T11:52:06", "user": "한지훈", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1148, "steps": {"faq_ms": 24, "answer_ms": 1101}, "cost_usd": 0.00012}, +{"execution_id": 5466, "workflow": "helpdesk", "started_at": "2026-10-28T12:04:17", "user": "박서연", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1065, "steps": {"faq_ms": 21, "answer_ms": 1018}, "cost_usd": 0.00012}, +{"execution_id": 5467, "workflow": "helpdesk", "started_at": "2026-10-28T12:18:35", "user": "오세훈", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 962, "steps": {"faq_ms": 28, "answer_ms": 900}, "cost_usd": 0.00012}, +{"execution_id": 5468, "workflow": "helpdesk", "started_at": "2026-10-28T12:28:32", "user": "임도윤", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1171, "steps": {"faq_ms": 26, "answer_ms": 1123}, "cost_usd": 0.00012}, +{"execution_id": 5469, "workflow": "helpdesk", "started_at": "2026-10-28T12:40:52", "user": "임도윤", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1189, "steps": {"faq_ms": 30, "answer_ms": 1104}, "cost_usd": 0.00012}, +{"execution_id": 5470, "workflow": "helpdesk", "started_at": "2026-10-28T12:43:33", "user": "정우성", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1143, "steps": {"faq_ms": 20, "answer_ms": 1080}, "cost_usd": 0.00012}, +{"execution_id": 5471, "workflow": "helpdesk", "started_at": "2026-10-28T12:52:44", "user": "서지아", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1404, "steps": {"faq_ms": 17, "answer_ms": 1342}, "cost_usd": 0.00012}, +{"execution_id": 5472, "workflow": "helpdesk", "started_at": "2026-10-28T12:55:32", "user": "윤하늘", "topic": "와이파이", "question": "회의실 와이파이가 안 잡혀요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 54, "steps": {"faq_ms": 24, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5473, "workflow": "helpdesk", "started_at": "2026-10-28T13:54:05", "user": "정우성", "topic": "메일", "question": "메일함 용량이 꽉 찼대요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.2.0", "channel": "canary", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 2515, "steps": {"faq_ms": 30, "answer_ms": 2432}, "cost_usd": 0.00021}, +{"execution_id": 5474, "workflow": "helpdesk", "started_at": "2026-10-28T13:58:47", "user": "윤하늘", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.2.0", "channel": "canary", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1431, "steps": {"faq_ms": 33, "answer_ms": 1341}, "cost_usd": 0.00012}, +{"execution_id": 5475, "workflow": "helpdesk", "started_at": "2026-10-28T14:09:21", "user": "이준호", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1293, "steps": {"faq_ms": 29, "answer_ms": 1204}, "cost_usd": 0.00012}, +{"execution_id": 5476, "workflow": "helpdesk", "started_at": "2026-10-28T14:15:07", "user": "배수아", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1227, "steps": {"faq_ms": 36, "answer_ms": 1154}, "cost_usd": 0.00012}, +{"execution_id": 5477, "workflow": "helpdesk", "started_at": "2026-10-28T14:16:43", "user": "이준호", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1060, "steps": {"faq_ms": 35, "answer_ms": 993}, "cost_usd": 0.00012}, +{"execution_id": 5478, "workflow": "helpdesk", "started_at": "2026-10-28T14:29:54", "user": "배수아", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1484, "steps": {"faq_ms": 39, "answer_ms": 1400}, "cost_usd": 0.00012}, +{"execution_id": 5479, "workflow": "helpdesk", "started_at": "2026-10-28T14:40:54", "user": "김민지", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1421, "steps": {"faq_ms": 23, "answer_ms": 1340}, "cost_usd": 0.00012}, +{"execution_id": 5480, "workflow": "helpdesk", "started_at": "2026-10-28T14:46:11", "user": "임도윤", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1066, "steps": {"faq_ms": 39, "answer_ms": 988}, "cost_usd": 0.00012}, +{"execution_id": 5481, "workflow": "helpdesk", "started_at": "2026-10-28T14:55:16", "user": "윤하늘", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.2.0", "channel": "canary", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1094, "steps": {"faq_ms": 26, "answer_ms": 1039}, "cost_usd": 0.00012}, +{"execution_id": 5482, "workflow": "helpdesk", "started_at": "2026-10-28T15:17:02", "user": "최유진", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.2.0", "channel": "canary", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1302, "steps": {"faq_ms": 35, "answer_ms": 1247}, "cost_usd": 0.00012}, +{"execution_id": 5483, "workflow": "helpdesk", "started_at": "2026-10-28T15:22:08", "user": "박서연", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요? 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 2422, "steps": {"faq_ms": 23, "answer_ms": 2379}, "cost_usd": 0.00021}, +{"execution_id": 5484, "workflow": "helpdesk", "started_at": "2026-10-28T15:42:34", "user": "윤하늘", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.2.0", "channel": "canary", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1116, "steps": {"faq_ms": 34, "answer_ms": 1031}, "cost_usd": 0.00012}, +{"execution_id": 5485, "workflow": "helpdesk", "started_at": "2026-10-28T15:46:10", "user": "최유진", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.2.0", "channel": "canary", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1032, "steps": {"faq_ms": 15, "answer_ms": 969}, "cost_usd": 0.00012}, +{"execution_id": 5486, "workflow": "helpdesk", "started_at": "2026-10-28T16:08:50", "user": "이준호", "topic": "설치", "question": "라이선스 신청은 어디서 해요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1272, "steps": {"faq_ms": 23, "answer_ms": 1220}, "cost_usd": 0.00012}, +{"execution_id": 5487, "workflow": "helpdesk", "started_at": "2026-10-28T16:10:12", "user": "서지아", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1463, "steps": {"faq_ms": 38, "answer_ms": 1395}, "cost_usd": 0.00012}, +{"execution_id": 5488, "workflow": "helpdesk", "started_at": "2026-10-28T16:11:45", "user": "오세훈", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1425, "steps": {"faq_ms": 30, "answer_ms": 1343}, "cost_usd": 0.00012}, +{"execution_id": 5489, "workflow": "helpdesk", "started_at": "2026-10-28T16:12:56", "user": "이준호", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1282, "steps": {"faq_ms": 19, "answer_ms": 1204}, "cost_usd": 0.00012}, +{"execution_id": 5490, "workflow": "helpdesk", "started_at": "2026-10-28T16:18:20", "user": "한지훈", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1165, "steps": {"faq_ms": 35, "answer_ms": 1080}, "cost_usd": 0.00012}, +{"execution_id": 5491, "workflow": "helpdesk", "started_at": "2026-10-28T16:30:38", "user": "임도윤", "topic": "설치", "question": "라이선스 신청은 어디서 해요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 970, "steps": {"faq_ms": 36, "answer_ms": 900}, "cost_usd": 0.00012}, +{"execution_id": 5492, "workflow": "helpdesk", "started_at": "2026-10-28T16:33:28", "user": "한지훈", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1319, "steps": {"faq_ms": 20, "answer_ms": 1261}, "cost_usd": 0.00012}, +{"execution_id": 5493, "workflow": "helpdesk", "started_at": "2026-10-28T16:34:08", "user": "배수아", "topic": "VPN", "question": "VPN이 자꾸 끊겨요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2240, "steps": {"faq_ms": 33, "answer_ms": 2156}, "cost_usd": 0.00021}, +{"execution_id": 5494, "workflow": "helpdesk", "started_at": "2026-10-28T16:36:38", "user": "최유진", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.2.0", "channel": "canary", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1165, "steps": {"faq_ms": 27, "answer_ms": 1104}, "cost_usd": 0.00012}, +{"execution_id": 5495, "workflow": "helpdesk", "started_at": "2026-10-28T16:56:31", "user": "한지훈", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1251, "steps": {"faq_ms": 28, "answer_ms": 1182}, "cost_usd": 0.00012}, +{"execution_id": 5496, "workflow": "helpdesk", "started_at": "2026-10-28T17:03:12", "user": "오세훈", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1046, "steps": {"faq_ms": 40, "answer_ms": 957}, "cost_usd": 0.00012}, +{"execution_id": 5497, "workflow": "helpdesk", "started_at": "2026-10-28T17:04:33", "user": "최유진", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.2.0", "channel": "canary", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1071, "steps": {"faq_ms": 19, "answer_ms": 1024}, "cost_usd": 0.00012}, +{"execution_id": 5498, "workflow": "helpdesk", "started_at": "2026-10-28T17:07:24", "user": "김민지", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1428, "steps": {"faq_ms": 32, "answer_ms": 1358}, "cost_usd": 0.00012}, +{"execution_id": 5499, "workflow": "helpdesk", "started_at": "2026-10-28T17:10:33", "user": "서지아", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1429, "steps": {"faq_ms": 33, "answer_ms": 1368}, "cost_usd": 0.00012}, +{"execution_id": 5500, "workflow": "helpdesk", "started_at": "2026-10-28T17:14:01", "user": "한지훈", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1170, "steps": {"faq_ms": 28, "answer_ms": 1087}, "cost_usd": 0.00012}, +{"execution_id": 5501, "workflow": "helpdesk", "started_at": "2026-10-28T17:19:46", "user": "서지아", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1353, "steps": {"faq_ms": 18, "answer_ms": 1306}, "cost_usd": 0.00012}, +{"execution_id": 5502, "workflow": "helpdesk", "started_at": "2026-10-28T17:22:17", "user": "이준호", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1107, "steps": {"faq_ms": 25, "answer_ms": 1055}, "cost_usd": 0.00012}, +{"execution_id": 5503, "workflow": "helpdesk", "started_at": "2026-10-28T17:25:43", "user": "한지훈", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2791, "steps": {"faq_ms": 37, "answer_ms": 2694}, "cost_usd": 0.00021}, +{"execution_id": 5504, "workflow": "helpdesk", "started_at": "2026-10-28T17:28:57", "user": "이준호", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1092, "steps": {"faq_ms": 20, "answer_ms": 1037}, "cost_usd": 0.00012}, +{"execution_id": 5505, "workflow": "helpdesk", "started_at": "2026-10-28T17:41:11", "user": "오세훈", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1163, "steps": {"faq_ms": 24, "answer_ms": 1101}, "cost_usd": 0.00012}, +{"execution_id": 5506, "workflow": "helpdesk", "started_at": "2026-10-29T09:00:22", "user": "윤하늘", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1178, "steps": {"faq_ms": 20, "answer_ms": 1131}, "cost_usd": 0.00012}, +{"execution_id": 5507, "workflow": "helpdesk", "started_at": "2026-10-29T09:12:41", "user": "이준호", "topic": "와이파이", "question": "회의실 와이파이가 안 잡혀요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 58, "steps": {"faq_ms": 28, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5508, "workflow": "helpdesk", "started_at": "2026-10-29T09:15:12", "user": "서지아", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2425, "steps": {"faq_ms": 32, "answer_ms": 2373}, "cost_usd": 0.00021}, +{"execution_id": 5509, "workflow": "helpdesk", "started_at": "2026-10-29T09:16:40", "user": "최유진", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1089, "steps": {"faq_ms": 20, "answer_ms": 1023}, "cost_usd": 0.00012}, +{"execution_id": 5510, "workflow": "helpdesk", "started_at": "2026-10-29T09:32:19", "user": "최유진", "topic": "VPN", "question": "VPN이 자꾸 끊겨요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2533, "steps": {"faq_ms": 35, "answer_ms": 2453}, "cost_usd": 0.00021}, +{"execution_id": 5511, "workflow": "helpdesk", "started_at": "2026-10-29T09:34:06", "user": "배수아", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1119, "steps": {"faq_ms": 40, "answer_ms": 1046}, "cost_usd": 0.00012}, +{"execution_id": 5512, "workflow": "helpdesk", "started_at": "2026-10-29T09:37:33", "user": "서지아", "topic": "설치", "question": "라이선스 신청은 어디서 해요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1240, "steps": {"faq_ms": 21, "answer_ms": 1196}, "cost_usd": 0.00012}, +{"execution_id": 5513, "workflow": "helpdesk", "started_at": "2026-10-29T09:45:29", "user": "배수아", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1236, "steps": {"faq_ms": 21, "answer_ms": 1173}, "cost_usd": 0.00012}, +{"execution_id": 5514, "workflow": "helpdesk", "started_at": "2026-10-29T09:46:48", "user": "이준호", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요? 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2465, "steps": {"faq_ms": 21, "answer_ms": 2397}, "cost_usd": 0.00021}, +{"execution_id": 5515, "workflow": "helpdesk", "started_at": "2026-10-29T09:47:05", "user": "강민수", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 990, "steps": {"faq_ms": 25, "answer_ms": 931}, "cost_usd": 0.00012}, +{"execution_id": 5516, "workflow": "helpdesk", "started_at": "2026-10-29T09:50:00", "user": "정우성", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 971, "steps": {"faq_ms": 17, "answer_ms": 904}, "cost_usd": 0.00012}, +{"execution_id": 5517, "workflow": "helpdesk", "started_at": "2026-10-29T10:02:39", "user": "서지아", "topic": "와이파이", "question": "와이파이가 자주 끊겨요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 58, "steps": {"faq_ms": 16, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5518, "workflow": "helpdesk", "started_at": "2026-10-29T10:11:09", "user": "한지훈", "topic": "설치", "question": "라이선스 신청은 어디서 해요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1311, "steps": {"faq_ms": 37, "answer_ms": 1225}, "cost_usd": 0.00012}, +{"execution_id": 5519, "workflow": "helpdesk", "started_at": "2026-10-29T10:18:40", "user": "박서연", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1096, "steps": {"faq_ms": 34, "answer_ms": 1015}, "cost_usd": 0.00012}, +{"execution_id": 5520, "workflow": "helpdesk", "started_at": "2026-10-29T10:37:00", "user": "서지아", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1171, "steps": {"faq_ms": 25, "answer_ms": 1107}, "cost_usd": 0.00012}, +{"execution_id": 5521, "workflow": "helpdesk", "started_at": "2026-10-29T10:38:22", "user": "강민수", "topic": "프린터", "question": "프린터 출력이 안 돼요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2382, "steps": {"faq_ms": 31, "answer_ms": 2312}, "cost_usd": 0.00021}, +{"execution_id": 5522, "workflow": "helpdesk", "started_at": "2026-10-29T10:54:10", "user": "임도윤", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1463, "steps": {"faq_ms": 31, "answer_ms": 1388}, "cost_usd": 0.00012}, +{"execution_id": 5627, "workflow": "helpdesk", "started_at": "2026-10-29T11:00:00", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1150, "steps": {"faq_ms": 20, "answer_ms": 1100}, "cost_usd": 0.00012}, +{"execution_id": 5628, "workflow": "helpdesk", "started_at": "2026-10-29T11:00:30", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1150, "steps": {"faq_ms": 20, "answer_ms": 1100}, "cost_usd": 0.00012}, +{"execution_id": 5629, "workflow": "helpdesk", "started_at": "2026-10-29T11:01:00", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1150, "steps": {"faq_ms": 20, "answer_ms": 1100}, "cost_usd": 0.00012}, +{"execution_id": 5630, "workflow": "helpdesk", "started_at": "2026-10-29T11:01:30", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1150, "steps": {"faq_ms": 20, "answer_ms": 1100}, "cost_usd": 0.00012}, +{"execution_id": 5631, "workflow": "helpdesk", "started_at": "2026-10-29T11:02:00", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1150, "steps": {"faq_ms": 20, "answer_ms": 1100}, "cost_usd": 0.00012}, +{"execution_id": 5632, "workflow": "helpdesk", "started_at": "2026-10-29T11:02:30", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1150, "steps": {"faq_ms": 20, "answer_ms": 1100}, "cost_usd": 0.00012}, +{"execution_id": 5633, "workflow": "helpdesk", "started_at": "2026-10-29T11:03:00", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1150, "steps": {"faq_ms": 20, "answer_ms": 1100}, "cost_usd": 0.00012}, +{"execution_id": 5634, "workflow": "helpdesk", "started_at": "2026-10-29T11:03:30", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1150, "steps": {"faq_ms": 20, "answer_ms": 1100}, "cost_usd": 0.00012}, +{"execution_id": 5635, "workflow": "helpdesk", "started_at": "2026-10-29T11:04:00", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1150, "steps": {"faq_ms": 20, "answer_ms": 1100}, "cost_usd": 0.00012}, +{"execution_id": 5636, "workflow": "helpdesk", "started_at": "2026-10-29T11:04:30", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1150, "steps": {"faq_ms": 20, "answer_ms": 1100}, "cost_usd": 0.00012}, +{"execution_id": 5637, "workflow": "helpdesk", "started_at": "2026-10-29T11:05:00", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1150, "steps": {"faq_ms": 20, "answer_ms": 1100}, "cost_usd": 0.00012}, +{"execution_id": 5638, "workflow": "helpdesk", "started_at": "2026-10-29T11:05:30", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1150, "steps": {"faq_ms": 20, "answer_ms": 1100}, "cost_usd": 0.00012}, +{"execution_id": 5639, "workflow": "helpdesk", "started_at": "2026-10-29T11:06:00", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1150, "steps": {"faq_ms": 20, "answer_ms": 1100}, "cost_usd": 0.00012}, +{"execution_id": 5640, "workflow": "helpdesk", "started_at": "2026-10-29T11:06:30", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1150, "steps": {"faq_ms": 20, "answer_ms": 1100}, "cost_usd": 0.00012}, +{"execution_id": 5641, "workflow": "helpdesk", "started_at": "2026-10-29T11:07:00", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1150, "steps": {"faq_ms": 20, "answer_ms": 1100}, "cost_usd": 0.00012}, +{"execution_id": 5642, "workflow": "helpdesk", "started_at": "2026-10-29T11:07:30", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1150, "steps": {"faq_ms": 20, "answer_ms": 1100}, "cost_usd": 0.00012}, +{"execution_id": 5643, "workflow": "helpdesk", "started_at": "2026-10-29T11:08:00", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1150, "steps": {"faq_ms": 20, "answer_ms": 1100}, "cost_usd": 0.00012}, +{"execution_id": 5644, "workflow": "helpdesk", "started_at": "2026-10-29T11:08:30", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1150, "steps": {"faq_ms": 20, "answer_ms": 1100}, "cost_usd": 0.00012}, +{"execution_id": 5645, "workflow": "helpdesk", "started_at": "2026-10-29T11:09:00", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1150, "steps": {"faq_ms": 20, "answer_ms": 1100}, "cost_usd": 0.00012}, +{"execution_id": 5646, "workflow": "helpdesk", "started_at": "2026-10-29T11:09:30", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1150, "steps": {"faq_ms": 20, "answer_ms": 1100}, "cost_usd": 0.00012}, +{"execution_id": 5647, "workflow": "helpdesk", "started_at": "2026-10-29T11:10:00", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "잠시 후", "duration_ms": 30, "steps": {"faq_ms": 0, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5648, "workflow": "helpdesk", "started_at": "2026-10-29T11:10:30", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "잠시 후", "duration_ms": 30, "steps": {"faq_ms": 0, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5649, "workflow": "helpdesk", "started_at": "2026-10-29T11:11:00", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "잠시 후", "duration_ms": 30, "steps": {"faq_ms": 0, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5650, "workflow": "helpdesk", "started_at": "2026-10-29T11:11:30", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "잠시 후", "duration_ms": 30, "steps": {"faq_ms": 0, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5651, "workflow": "helpdesk", "started_at": "2026-10-29T11:12:00", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "잠시 후", "duration_ms": 30, "steps": {"faq_ms": 0, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5652, "workflow": "helpdesk", "started_at": "2026-10-29T11:12:30", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "잠시 후", "duration_ms": 30, "steps": {"faq_ms": 0, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5653, "workflow": "helpdesk", "started_at": "2026-10-29T11:13:00", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "잠시 후", "duration_ms": 30, "steps": {"faq_ms": 0, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5654, "workflow": "helpdesk", "started_at": "2026-10-29T11:13:30", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "잠시 후", "duration_ms": 30, "steps": {"faq_ms": 0, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5655, "workflow": "helpdesk", "started_at": "2026-10-29T11:14:00", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "잠시 후", "duration_ms": 30, "steps": {"faq_ms": 0, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5656, "workflow": "helpdesk", "started_at": "2026-10-29T11:14:30", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "잠시 후", "duration_ms": 30, "steps": {"faq_ms": 0, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5657, "workflow": "helpdesk", "started_at": "2026-10-29T11:15:00", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "잠시 후", "duration_ms": 30, "steps": {"faq_ms": 0, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5658, "workflow": "helpdesk", "started_at": "2026-10-29T11:15:30", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "잠시 후", "duration_ms": 30, "steps": {"faq_ms": 0, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5659, "workflow": "helpdesk", "started_at": "2026-10-29T11:16:00", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "잠시 후", "duration_ms": 30, "steps": {"faq_ms": 0, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5660, "workflow": "helpdesk", "started_at": "2026-10-29T11:16:30", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "잠시 후", "duration_ms": 30, "steps": {"faq_ms": 0, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5661, "workflow": "helpdesk", "started_at": "2026-10-29T11:17:00", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "잠시 후", "duration_ms": 30, "steps": {"faq_ms": 0, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5662, "workflow": "helpdesk", "started_at": "2026-10-29T11:17:30", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "잠시 후", "duration_ms": 30, "steps": {"faq_ms": 0, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5663, "workflow": "helpdesk", "started_at": "2026-10-29T11:18:00", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "잠시 후", "duration_ms": 30, "steps": {"faq_ms": 0, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5664, "workflow": "helpdesk", "started_at": "2026-10-29T11:18:30", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "잠시 후", "duration_ms": 30, "steps": {"faq_ms": 0, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5523, "workflow": "helpdesk", "started_at": "2026-10-29T11:19:00", "user": "윤하늘", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1033, "steps": {"faq_ms": 30, "answer_ms": 976}, "cost_usd": 0.00012}, +{"execution_id": 5665, "workflow": "helpdesk", "started_at": "2026-10-29T11:19:00", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "잠시 후", "duration_ms": 30, "steps": {"faq_ms": 0, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5666, "workflow": "helpdesk", "started_at": "2026-10-29T11:19:30", "user": "test-bot", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "잠시 후", "duration_ms": 30, "steps": {"faq_ms": 0, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5524, "workflow": "helpdesk", "started_at": "2026-10-29T11:19:51", "user": "박서연", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1234, "steps": {"faq_ms": 34, "answer_ms": 1162}, "cost_usd": 0.00012}, +{"execution_id": 5525, "workflow": "helpdesk", "started_at": "2026-10-29T11:37:10", "user": "정우성", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1393, "steps": {"faq_ms": 29, "answer_ms": 1343}, "cost_usd": 0.00012}, +{"execution_id": 5526, "workflow": "helpdesk", "started_at": "2026-10-29T11:46:28", "user": "박서연", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1053, "steps": {"faq_ms": 24, "answer_ms": 986}, "cost_usd": 0.00012}, +{"execution_id": 5527, "workflow": "helpdesk", "started_at": "2026-10-29T11:47:09", "user": "박서연", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1001, "steps": {"faq_ms": 19, "answer_ms": 953}, "cost_usd": 0.00012}, +{"execution_id": 5528, "workflow": "helpdesk", "started_at": "2026-10-29T12:09:31", "user": "강민수", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1452, "steps": {"faq_ms": 19, "answer_ms": 1384}, "cost_usd": 0.00012}, +{"execution_id": 5529, "workflow": "helpdesk", "started_at": "2026-10-29T12:10:50", "user": "서지아", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2682, "steps": {"faq_ms": 32, "answer_ms": 2597}, "cost_usd": 0.00021}, +{"execution_id": 5530, "workflow": "helpdesk", "started_at": "2026-10-29T12:11:49", "user": "배수아", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1196, "steps": {"faq_ms": 37, "answer_ms": 1119}, "cost_usd": 0.00012}, +{"execution_id": 5531, "workflow": "helpdesk", "started_at": "2026-10-29T12:14:46", "user": "김민지", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1107, "steps": {"faq_ms": 27, "answer_ms": 1047}, "cost_usd": 0.00012}, +{"execution_id": 5532, "workflow": "helpdesk", "started_at": "2026-10-29T12:34:02", "user": "임도윤", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1229, "steps": {"faq_ms": 23, "answer_ms": 1167}, "cost_usd": 0.00012}, +{"execution_id": 5533, "workflow": "helpdesk", "started_at": "2026-10-29T12:34:12", "user": "강민수", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1182, "steps": {"faq_ms": 24, "answer_ms": 1105}, "cost_usd": 0.00012}, +{"execution_id": 5534, "workflow": "helpdesk", "started_at": "2026-10-29T12:40:43", "user": "이준호", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1109, "steps": {"faq_ms": 32, "answer_ms": 1055}, "cost_usd": 0.00012}, +{"execution_id": 5535, "workflow": "helpdesk", "started_at": "2026-10-29T12:41:16", "user": "정우성", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1401, "steps": {"faq_ms": 22, "answer_ms": 1345}, "cost_usd": 0.00012}, +{"execution_id": 5536, "workflow": "helpdesk", "started_at": "2026-10-29T13:01:48", "user": "윤하늘", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요? 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2803, "steps": {"faq_ms": 37, "answer_ms": 2721}, "cost_usd": 0.00021}, +{"execution_id": 5537, "workflow": "helpdesk", "started_at": "2026-10-29T13:05:30", "user": "이준호", "topic": "와이파이", "question": "회의실 와이파이가 안 잡혀요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 55, "steps": {"faq_ms": 32, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5538, "workflow": "helpdesk", "started_at": "2026-10-29T13:08:34", "user": "배수아", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1339, "steps": {"faq_ms": 26, "answer_ms": 1259}, "cost_usd": 0.00012}, +{"execution_id": 5539, "workflow": "helpdesk", "started_at": "2026-10-29T13:47:06", "user": "박서연", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2777, "steps": {"faq_ms": 37, "answer_ms": 2715}, "cost_usd": 0.00021}, +{"execution_id": 5540, "workflow": "helpdesk", "started_at": "2026-10-29T13:52:46", "user": "임도윤", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1127, "steps": {"faq_ms": 26, "answer_ms": 1044}, "cost_usd": 0.00012}, +{"execution_id": 5541, "workflow": "helpdesk", "started_at": "2026-10-29T13:55:03", "user": "배수아", "topic": "와이파이", "question": "와이파이가 자주 끊겨요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 78, "steps": {"faq_ms": 19, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5542, "workflow": "helpdesk", "started_at": "2026-10-29T14:02:58", "user": "임도윤", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1207, "steps": {"faq_ms": 18, "answer_ms": 1147}, "cost_usd": 0.00012}, +{"execution_id": 5543, "workflow": "helpdesk", "started_at": "2026-10-29T14:04:16", "user": "배수아", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1084, "steps": {"faq_ms": 27, "answer_ms": 1008}, "cost_usd": 0.00012}, +{"execution_id": 5544, "workflow": "helpdesk", "started_at": "2026-10-29T14:08:14", "user": "강민수", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1277, "steps": {"faq_ms": 18, "answer_ms": 1235}, "cost_usd": 0.00012}, +{"execution_id": 5545, "workflow": "helpdesk", "started_at": "2026-10-29T14:10:22", "user": "이준호", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2620, "steps": {"faq_ms": 34, "answer_ms": 2539}, "cost_usd": 0.00021}, +{"execution_id": 5546, "workflow": "helpdesk", "started_at": "2026-10-29T14:15:08", "user": "최유진", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1358, "steps": {"faq_ms": 37, "answer_ms": 1290}, "cost_usd": 0.00012}, +{"execution_id": 5547, "workflow": "helpdesk", "started_at": "2026-10-29T14:39:23", "user": "한지훈", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1455, "steps": {"faq_ms": 31, "answer_ms": 1400}, "cost_usd": 0.00012}, +{"execution_id": 5548, "workflow": "helpdesk", "started_at": "2026-10-29T15:10:18", "user": "김민지", "topic": "설치", "question": "라이선스 신청은 어디서 해요?", "release": "1.1.0", "channel": "stable", "status": "error", "error_node": "Answer", "error_message": "429 Rate limit reached for requests", "llm_calls": 1, "repeat": false, "route": null, "duration_ms": 355, "steps": {"faq_ms": 38, "answer_ms": 277}, "cost_usd": 0.00012}, +{"execution_id": 5549, "workflow": "helpdesk", "started_at": "2026-10-29T15:23:26", "user": "정우성", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.1.0", "channel": "stable", "status": "error", "error_node": "Answer", "error_message": "429 Rate limit reached for requests", "llm_calls": 1, "repeat": false, "route": null, "duration_ms": 435, "steps": {"faq_ms": 31, "answer_ms": 347}, "cost_usd": 0.00021}, +{"execution_id": 5550, "workflow": "helpdesk", "started_at": "2026-10-29T15:26:37", "user": "최유진", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.1.0", "channel": "stable", "status": "error", "error_node": "Answer", "error_message": "429 Rate limit reached for requests", "llm_calls": 1, "repeat": false, "route": null, "duration_ms": 315, "steps": {"faq_ms": 33, "answer_ms": 253}, "cost_usd": 0.00012}, +{"execution_id": 5551, "workflow": "helpdesk", "started_at": "2026-10-29T15:42:02", "user": "윤하늘", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.1.0", "channel": "stable", "status": "error", "error_node": "Answer", "error_message": "429 Rate limit reached for requests", "llm_calls": 1, "repeat": false, "route": null, "duration_ms": 341, "steps": {"faq_ms": 30, "answer_ms": 256}, "cost_usd": 0.00012}, +{"execution_id": 5552, "workflow": "helpdesk", "started_at": "2026-10-29T15:48:53", "user": "박서연", "topic": "설치", "question": "라이선스 신청은 어디서 해요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1177, "steps": {"faq_ms": 17, "answer_ms": 1103}, "cost_usd": 0.00012}, +{"execution_id": 5553, "workflow": "helpdesk", "started_at": "2026-10-29T15:50:37", "user": "서지아", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.1.0", "channel": "stable", "status": "error", "error_node": "Answer", "error_message": "429 Rate limit reached for requests", "llm_calls": 1, "repeat": false, "route": null, "duration_ms": 303, "steps": {"faq_ms": 27, "answer_ms": 253}, "cost_usd": 0.00012}, +{"execution_id": 5554, "workflow": "helpdesk", "started_at": "2026-10-29T15:56:53", "user": "임도윤", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1428, "steps": {"faq_ms": 37, "answer_ms": 1353}, "cost_usd": 0.00012}, +{"execution_id": 5555, "workflow": "helpdesk", "started_at": "2026-10-29T16:07:05", "user": "정우성", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 989, "steps": {"faq_ms": 17, "answer_ms": 925}, "cost_usd": 0.00012}, +{"execution_id": 5556, "workflow": "helpdesk", "started_at": "2026-10-29T16:22:05", "user": "최유진", "topic": "메일", "question": "메일함 용량이 꽉 찼대요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2050, "steps": {"faq_ms": 34, "answer_ms": 1992}, "cost_usd": 0.00021}, +{"execution_id": 5557, "workflow": "helpdesk", "started_at": "2026-10-29T16:42:20", "user": "서지아", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1078, "steps": {"faq_ms": 36, "answer_ms": 986}, "cost_usd": 0.00012}, +{"execution_id": 5558, "workflow": "helpdesk", "started_at": "2026-10-29T16:51:03", "user": "정우성", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 993, "steps": {"faq_ms": 34, "answer_ms": 919}, "cost_usd": 0.00012}, +{"execution_id": 5559, "workflow": "helpdesk", "started_at": "2026-10-29T16:51:38", "user": "박서연", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1349, "steps": {"faq_ms": 20, "answer_ms": 1283}, "cost_usd": 0.00012}, +{"execution_id": 5560, "workflow": "helpdesk", "started_at": "2026-10-29T16:51:52", "user": "오세훈", "topic": "와이파이", "question": "회의실 와이파이가 안 잡혀요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 66, "steps": {"faq_ms": 40, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5561, "workflow": "helpdesk", "started_at": "2026-10-29T17:54:51", "user": "박서연", "topic": "와이파이", "question": "와이파이가 자주 끊겨요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 98, "steps": {"faq_ms": 39, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5562, "workflow": "helpdesk", "started_at": "2026-10-29T17:58:33", "user": "박서연", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1267, "steps": {"faq_ms": 32, "answer_ms": 1182}, "cost_usd": 0.00012}, +{"execution_id": 5563, "workflow": "helpdesk", "started_at": "2026-10-30T09:00:08", "user": "강민수", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1191, "steps": {"faq_ms": 21, "answer_ms": 1144}, "cost_usd": 0.00012}, +{"execution_id": 5564, "workflow": "helpdesk", "started_at": "2026-10-30T09:08:53", "user": "임도윤", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1204, "steps": {"faq_ms": 33, "answer_ms": 1131}, "cost_usd": 0.00012}, +{"execution_id": 5565, "workflow": "helpdesk", "started_at": "2026-10-30T09:09:00", "user": "김민지", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 979, "steps": {"faq_ms": 26, "answer_ms": 901}, "cost_usd": 0.00012}, +{"execution_id": 5566, "workflow": "helpdesk", "started_at": "2026-10-30T09:15:26", "user": "윤하늘", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 987, "steps": {"faq_ms": 29, "answer_ms": 916}, "cost_usd": 0.00012}, +{"execution_id": 5567, "workflow": "helpdesk", "started_at": "2026-10-30T09:44:21", "user": "서지아", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1332, "steps": {"faq_ms": 36, "answer_ms": 1260}, "cost_usd": 0.00012}, +{"execution_id": 5568, "workflow": "helpdesk", "started_at": "2026-10-30T09:49:41", "user": "오세훈", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1222, "steps": {"faq_ms": 39, "answer_ms": 1132}, "cost_usd": 0.00012}, +{"execution_id": 5569, "workflow": "helpdesk", "started_at": "2026-10-30T09:50:58", "user": "최유진", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1384, "steps": {"faq_ms": 24, "answer_ms": 1333}, "cost_usd": 0.00012}, +{"execution_id": 5570, "workflow": "helpdesk", "started_at": "2026-10-30T10:01:40", "user": "오세훈", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1429, "steps": {"faq_ms": 26, "answer_ms": 1365}, "cost_usd": 0.00012}, +{"execution_id": 5571, "workflow": "helpdesk", "started_at": "2026-10-30T10:03:49", "user": "오세훈", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1389, "steps": {"faq_ms": 30, "answer_ms": 1313}, "cost_usd": 0.00012}, +{"execution_id": 5572, "workflow": "helpdesk", "started_at": "2026-10-30T10:10:51", "user": "정우성", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1331, "steps": {"faq_ms": 16, "answer_ms": 1261}, "cost_usd": 0.00012}, +{"execution_id": 5573, "workflow": "helpdesk", "started_at": "2026-10-30T10:10:54", "user": "최유진", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요? 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2598, "steps": {"faq_ms": 16, "answer_ms": 2532}, "cost_usd": 0.00021}, +{"execution_id": 5574, "workflow": "helpdesk", "started_at": "2026-10-30T10:13:50", "user": "한지훈", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요? 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2679, "steps": {"faq_ms": 30, "answer_ms": 2604}, "cost_usd": 0.00021}, +{"execution_id": 5575, "workflow": "helpdesk", "started_at": "2026-10-30T10:23:17", "user": "박서연", "topic": "VPN", "question": "VPN이 자꾸 끊겨요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 2458, "steps": {"faq_ms": 20, "answer_ms": 2415}, "cost_usd": 0.00021}, +{"execution_id": 5576, "workflow": "helpdesk", "started_at": "2026-10-30T10:26:25", "user": "한지훈", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1391, "steps": {"faq_ms": 30, "answer_ms": 1318}, "cost_usd": 0.00012}, +{"execution_id": 5577, "workflow": "helpdesk", "started_at": "2026-10-30T10:45:24", "user": "윤하늘", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1228, "steps": {"faq_ms": 17, "answer_ms": 1186}, "cost_usd": 0.00012}, +{"execution_id": 5578, "workflow": "helpdesk", "started_at": "2026-10-30T10:52:05", "user": "정우성", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1378, "steps": {"faq_ms": 37, "answer_ms": 1305}, "cost_usd": 0.00012}, +{"execution_id": 5579, "workflow": "helpdesk", "started_at": "2026-10-30T10:53:40", "user": "이준호", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1293, "steps": {"faq_ms": 36, "answer_ms": 1215}, "cost_usd": 0.00012}, +{"execution_id": 5580, "workflow": "helpdesk", "started_at": "2026-10-30T10:59:17", "user": "오세훈", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1121, "steps": {"faq_ms": 39, "answer_ms": 1044}, "cost_usd": 0.00012}, +{"execution_id": 5581, "workflow": "helpdesk", "started_at": "2026-10-30T11:00:38", "user": "윤하늘", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1431, "steps": {"faq_ms": 31, "answer_ms": 1343}, "cost_usd": 0.00012}, +{"execution_id": 5582, "workflow": "helpdesk", "started_at": "2026-10-30T11:06:37", "user": "이준호", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1130, "steps": {"faq_ms": 29, "answer_ms": 1069}, "cost_usd": 0.00012}, +{"execution_id": 5583, "workflow": "helpdesk", "started_at": "2026-10-30T11:15:33", "user": "정우성", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요? 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2311, "steps": {"faq_ms": 18, "answer_ms": 2233}, "cost_usd": 0.00021}, +{"execution_id": 5584, "workflow": "helpdesk", "started_at": "2026-10-30T11:17:32", "user": "임도윤", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1413, "steps": {"faq_ms": 21, "answer_ms": 1352}, "cost_usd": 0.00012}, +{"execution_id": 5585, "workflow": "helpdesk", "started_at": "2026-10-30T11:19:14", "user": "이준호", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1282, "steps": {"faq_ms": 36, "answer_ms": 1198}, "cost_usd": 0.00012}, +{"execution_id": 5586, "workflow": "helpdesk", "started_at": "2026-10-30T11:31:10", "user": "박서연", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2189, "steps": {"faq_ms": 36, "answer_ms": 2106}, "cost_usd": 0.00021}, +{"execution_id": 5587, "workflow": "helpdesk", "started_at": "2026-10-30T11:32:13", "user": "정우성", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1437, "steps": {"faq_ms": 16, "answer_ms": 1376}, "cost_usd": 0.00012}, +{"execution_id": 5588, "workflow": "helpdesk", "started_at": "2026-10-30T11:42:56", "user": "오세훈", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1298, "steps": {"faq_ms": 35, "answer_ms": 1207}, "cost_usd": 0.00012}, +{"execution_id": 5589, "workflow": "helpdesk", "started_at": "2026-10-30T11:50:40", "user": "박서연", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2667, "steps": {"faq_ms": 31, "answer_ms": 2585}, "cost_usd": 0.00021}, +{"execution_id": 5590, "workflow": "helpdesk", "started_at": "2026-10-30T11:58:02", "user": "최유진", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1008, "steps": {"faq_ms": 37, "answer_ms": 930}, "cost_usd": 0.00012}, +{"execution_id": 5591, "workflow": "helpdesk", "started_at": "2026-10-30T11:59:39", "user": "김민지", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1111, "steps": {"faq_ms": 30, "answer_ms": 1030}, "cost_usd": 0.00012}, +{"execution_id": 5592, "workflow": "helpdesk", "started_at": "2026-10-30T12:25:42", "user": "한지훈", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1372, "steps": {"faq_ms": 25, "answer_ms": 1320}, "cost_usd": 0.00012}, +{"execution_id": 5593, "workflow": "helpdesk", "started_at": "2026-10-30T12:26:17", "user": "서지아", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1056, "steps": {"faq_ms": 34, "answer_ms": 966}, "cost_usd": 0.00012}, +{"execution_id": 5594, "workflow": "helpdesk", "started_at": "2026-10-30T13:06:37", "user": "오세훈", "topic": "비밀번호", "question": "비밀번호를 잊어버렸어요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1222, "steps": {"faq_ms": 27, "answer_ms": 1153}, "cost_usd": 0.00012}, +{"execution_id": 5595, "workflow": "helpdesk", "started_at": "2026-10-30T13:16:09", "user": "윤하늘", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1028, "steps": {"faq_ms": 33, "answer_ms": 966}, "cost_usd": 0.00012}, +{"execution_id": 5596, "workflow": "helpdesk", "started_at": "2026-10-30T13:30:18", "user": "김민지", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1013, "steps": {"faq_ms": 25, "answer_ms": 950}, "cost_usd": 0.00012}, +{"execution_id": 5597, "workflow": "helpdesk", "started_at": "2026-10-30T13:31:16", "user": "이준호", "topic": "접수 현황", "question": "T-1002 처리 상태 알려 주세요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2576, "steps": {"faq_ms": 23, "answer_ms": 2531}, "cost_usd": 0.00021}, +{"execution_id": 5598, "workflow": "helpdesk", "started_at": "2026-10-30T13:39:46", "user": "이준호", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1462, "steps": {"faq_ms": 35, "answer_ms": 1373}, "cost_usd": 0.00012}, +{"execution_id": 5599, "workflow": "helpdesk", "started_at": "2026-10-30T14:37:02", "user": "정우성", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1022, "steps": {"faq_ms": 18, "answer_ms": 962}, "cost_usd": 0.00012}, +{"execution_id": 5600, "workflow": "helpdesk", "started_at": "2026-10-30T14:37:22", "user": "이준호", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1385, "steps": {"faq_ms": 40, "answer_ms": 1319}, "cost_usd": 0.00012}, +{"execution_id": 5601, "workflow": "helpdesk", "started_at": "2026-10-30T14:47:38", "user": "이준호", "topic": "설치", "question": "라이선스 신청은 어디서 해요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1184, "steps": {"faq_ms": 37, "answer_ms": 1124}, "cost_usd": 0.00012}, +{"execution_id": 5602, "workflow": "helpdesk", "started_at": "2026-10-30T14:48:57", "user": "이준호", "topic": "메일", "question": "메일함 용량이 꽉 찼대요 어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.어제부터 계속 그래서 업무를 못 하고 있고, 다른 사람들도 같은 문제를 겪는 것 같아 자세히 적습니다.", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 2285, "steps": {"faq_ms": 23, "answer_ms": 2227}, "cost_usd": 0.00021}, +{"execution_id": 5603, "workflow": "helpdesk", "started_at": "2026-10-30T15:04:50", "user": "강민수", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1048, "steps": {"faq_ms": 25, "answer_ms": 989}, "cost_usd": 0.00012}, +{"execution_id": 5604, "workflow": "helpdesk", "started_at": "2026-10-30T15:24:56", "user": "윤하늘", "topic": "설치", "question": "라이선스 신청은 어디서 해요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1363, "steps": {"faq_ms": 17, "answer_ms": 1313}, "cost_usd": 0.00012}, +{"execution_id": 5605, "workflow": "helpdesk", "started_at": "2026-10-30T15:32:08", "user": "서지아", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1093, "steps": {"faq_ms": 18, "answer_ms": 1028}, "cost_usd": 0.00012}, +{"execution_id": 5606, "workflow": "helpdesk", "started_at": "2026-10-30T15:37:02", "user": "박서연", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1377, "steps": {"faq_ms": 21, "answer_ms": 1296}, "cost_usd": 0.00012}, +{"execution_id": 5607, "workflow": "helpdesk", "started_at": "2026-10-30T15:40:15", "user": "한지훈", "topic": "와이파이", "question": "회의실 와이파이가 안 잡혀요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 78, "steps": {"faq_ms": 36, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5608, "workflow": "helpdesk", "started_at": "2026-10-30T15:48:57", "user": "정우성", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1237, "steps": {"faq_ms": 25, "answer_ms": 1183}, "cost_usd": 0.00012}, +{"execution_id": 5609, "workflow": "helpdesk", "started_at": "2026-10-30T15:50:00", "user": "임도윤", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 953, "steps": {"faq_ms": 17, "answer_ms": 913}, "cost_usd": 0.00012}, +{"execution_id": 5610, "workflow": "helpdesk", "started_at": "2026-10-30T15:59:39", "user": "윤하늘", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1347, "steps": {"faq_ms": 34, "answer_ms": 1286}, "cost_usd": 0.00012}, +{"execution_id": 5611, "workflow": "helpdesk", "started_at": "2026-10-30T16:05:39", "user": "강민수", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1312, "steps": {"faq_ms": 31, "answer_ms": 1246}, "cost_usd": 0.00012}, +{"execution_id": 5612, "workflow": "helpdesk", "started_at": "2026-10-30T16:15:36", "user": "한지훈", "topic": "설치", "question": "새 프로그램 설치는 어떻게 신청하나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1458, "steps": {"faq_ms": 34, "answer_ms": 1397}, "cost_usd": 0.00012}, +{"execution_id": 5613, "workflow": "helpdesk", "started_at": "2026-10-30T16:44:46", "user": "박서연", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1237, "steps": {"faq_ms": 26, "answer_ms": 1174}, "cost_usd": 0.00012}, +{"execution_id": 5614, "workflow": "helpdesk", "started_at": "2026-10-30T16:47:29", "user": "임도윤", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 974, "steps": {"faq_ms": 17, "answer_ms": 921}, "cost_usd": 0.00012}, +{"execution_id": 5615, "workflow": "helpdesk", "started_at": "2026-10-30T16:51:37", "user": "이준호", "topic": "VPN", "question": "VPN이 자꾸 끊겨요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1122, "steps": {"faq_ms": 38, "answer_ms": 1061}, "cost_usd": 0.00012}, +{"execution_id": 5616, "workflow": "helpdesk", "started_at": "2026-10-30T16:57:12", "user": "정우성", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1456, "steps": {"faq_ms": 34, "answer_ms": 1372}, "cost_usd": 0.00012}, +{"execution_id": 5617, "workflow": "helpdesk", "started_at": "2026-10-30T16:58:06", "user": "오세훈", "topic": "프린터", "question": "프린터 출력이 안 돼요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1196, "steps": {"faq_ms": 16, "answer_ms": 1134}, "cost_usd": 0.00012}, +{"execution_id": 5618, "workflow": "helpdesk", "started_at": "2026-10-30T17:00:07", "user": "이준호", "topic": "접수 현황", "question": "제 접수 건 어떻게 됐나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1129, "steps": {"faq_ms": 16, "answer_ms": 1085}, "cost_usd": 0.00012}, +{"execution_id": 5619, "workflow": "helpdesk", "started_at": "2026-10-30T17:01:06", "user": "김민지", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1421, "steps": {"faq_ms": 40, "answer_ms": 1323}, "cost_usd": 0.00012}, +{"execution_id": 5620, "workflow": "helpdesk", "started_at": "2026-10-30T17:01:18", "user": "윤하늘", "topic": "비밀번호", "question": "비밀번호 재설정은 어떻게 하나요?", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1296, "steps": {"faq_ms": 23, "answer_ms": 1253}, "cost_usd": 0.00012}, +{"execution_id": 5621, "workflow": "helpdesk", "started_at": "2026-10-30T17:05:34", "user": "김민지", "topic": "VPN", "question": "재택 중 VPN 접속이 안 돼요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1289, "steps": {"faq_ms": 30, "answer_ms": 1230}, "cost_usd": 0.00012}, +{"execution_id": 5622, "workflow": "helpdesk", "started_at": "2026-10-30T17:05:38", "user": "최유진", "topic": "프린터", "question": "인쇄 대기열이 안 비워져요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": true, "route": "자동 답변", "duration_ms": 1311, "steps": {"faq_ms": 30, "answer_ms": 1239}, "cost_usd": 0.00012}, +{"execution_id": 5623, "workflow": "helpdesk", "started_at": "2026-10-30T17:11:46", "user": "이준호", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1175, "steps": {"faq_ms": 38, "answer_ms": 1104}, "cost_usd": 0.00012}, +{"execution_id": 5624, "workflow": "helpdesk", "started_at": "2026-10-30T17:20:51", "user": "오세훈", "topic": "와이파이", "question": "회의실 와이파이가 안 잡혀요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 0, "repeat": false, "route": "담당자 연결", "duration_ms": 79, "steps": {"faq_ms": 27, "answer_ms": 0}, "cost_usd": 0.0}, +{"execution_id": 5625, "workflow": "helpdesk", "started_at": "2026-10-30T17:24:39", "user": "김민지", "topic": "메일", "question": "첨부 파일이 너무 커서 안 보내져요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1205, "steps": {"faq_ms": 26, "answer_ms": 1127}, "cost_usd": 0.00012}, +{"execution_id": 5626, "workflow": "helpdesk", "started_at": "2026-10-30T17:59:20", "user": "박서연", "topic": "메일", "question": "메일함 용량이 꽉 찼대요", "release": "1.1.0", "channel": "stable", "status": "success", "error_node": null, "error_message": null, "llm_calls": 1, "repeat": false, "route": "자동 답변", "duration_ms": 1076, "steps": {"faq_ms": 16, "answer_ms": 1004}, "cost_usd": 0.00012} +] diff --git a/Last3Mon/79/data-api/data/schemas/employee.json b/Last3Mon/79/data-api/data/schemas/employee.json new file mode 100644 index 0000000..61c5940 --- /dev/null +++ b/Last3Mon/79/data-api/data/schemas/employee.json @@ -0,0 +1,11 @@ +{ + "name": "employee", + "key": "email", + "fields": { + "name": {"type": "string", "required": true, "max": 20}, + "dept": {"type": "enum", "required": true, "values": ["영업팀", "재무팀", "인사팀", "개발팀", "IT팀", "총무팀"]}, + "email": {"type": "email", "required": true}, + "phone": {"type": "phone", "required": false}, + "joined": {"type": "date", "required": true} + } +} diff --git a/Last3Mon/79/data-api/data/schemas/faq.json b/Last3Mon/79/data-api/data/schemas/faq.json new file mode 100644 index 0000000..4de0d5f --- /dev/null +++ b/Last3Mon/79/data-api/data/schemas/faq.json @@ -0,0 +1,10 @@ +{ + "name": "faq", + "key": "id", + "fields": { + "id": {"type": "pattern", "required": true, "pattern": "^FAQ-\\d{3}$"}, + "title": {"type": "string", "required": true, "max": 40}, + "keywords": {"type": "list", "required": true, "min_items": 1, "max_items": 8}, + "answer": {"type": "string", "required": true, "min": 10, "max": 300} + } +} diff --git a/Last3Mon/79/data-api/data/schemas/ticket.json b/Last3Mon/79/data-api/data/schemas/ticket.json new file mode 100644 index 0000000..fc62533 --- /dev/null +++ b/Last3Mon/79/data-api/data/schemas/ticket.json @@ -0,0 +1,12 @@ +{ + "name": "ticket", + "key": "ticket_id", + "fields": { + "ticket_id": {"type": "pattern", "required": true, "pattern": "^T-\\d{4}$"}, + "title": {"type": "string", "required": true, "min": 4, "max": 40}, + "requester": {"type": "string", "required": true, "max": 20}, + "priority": {"type": "enum", "required": true, "values": ["low", "normal", "high"]}, + "opened": {"type": "date", "required": true}, + "contact": {"type": "phone", "required": false} + } +} diff --git a/Last3Mon/79/data-api/data/tickets.json b/Last3Mon/79/data-api/data/tickets.json new file mode 100644 index 0000000..9d9d3ea --- /dev/null +++ b/Last3Mon/79/data-api/data/tickets.json @@ -0,0 +1,6 @@ +{ + "T-1001": {"title": "노트북 배터리 교체", "requester": "이준호", "status": "처리 완료", "assignee": "김민지", "updated": "2026-10-15", "note": "새 배터리로 교체했습니다.", "contact": {"phone": "010-3141-5926", "email": "junho.lee@example.com"}}, + "T-1002": {"title": "VPN 인증서 재발급", "requester": "박서연", "status": "처리 중", "assignee": "정우성", "updated": "2026-10-16", "note": "인증서 발급 승인 대기 중이며 10월 20일 완료 예정입니다.", "contact": {"phone": "010-2718-2818", "email": "seoyeon.park@example.com"}}, + "T-1003": {"title": "모니터 추가 지급", "requester": "최유진", "status": "접수됨", "assignee": "미정", "updated": "2026-10-16", "note": "재고 확인 후 담당자를 배정합니다.", "contact": {"phone": "010-1414-2135", "email": "yujin.choi@example.com"}}, + "T-1004": {"title": "메일함 용량 증설", "requester": "한지훈", "status": "반려", "assignee": "김민지", "updated": "2026-10-14", "note": "용량 기준을 넘지 않아 반려되었습니다. 오래된 메일을 보관함으로 옮겨 주세요.", "contact": {"phone": "010-1732-0508", "email": "jihoon.han@example.com"}} +} diff --git a/Last3Mon/79/data-api/server.py b/Last3Mon/79/data-api/server.py new file mode 100644 index 0000000..da2054c --- /dev/null +++ b/Last3Mon/79/data-api/server.py @@ -0,0 +1,739 @@ +"""Sample company data API for the day 79 labs (day 78 API + a seeded operations morning; +Python standard library only). + +GET /health -> {"status": "ok"} (never affected by the fault switch) +GET /faq?q= -> {"query": ..., "results": [FAQ entries]} +GET /tickets/ -> ticket record, or 404 {"error": "ticket not found"} +GET /tickets/?requester= + -> same, but 403 {"error": "not your ticket"} when the ticket belongs to someone else +GET /employees -> {"employees": [{"name", "dept"}]} +GET /employees/ -> {"name", "dept"}, or 404 {"error": "unknown employee"} +GET /docs -> {"docs": [{"name", "title"}]} company IT policy documents (markdown) +GET /docs/ -> {"name", "title", "text"}, or 404 {"error": "doc not found"} + +Day 72 additions (state lives in memory; restarting the container resets it): +GET /admin/fault -> {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +POST /admin/fault body {"faq": "ok"|"slow"|"error"|"flaky", "tickets": ..., "prompts": ..., "usage": ..., "dlq": ..., "slow_seconds": 8} + "slow" waits slow_seconds before answering, "error" answers HTTP 500, + "flaky" fails every second call; for POST /tickets the first try of each key fails + and the next one works (a short hiccup). +GET /cache/ -> {"key", "value", "hits"} or 404 {"error": "not cached"} +POST /cache/ body {"value": , "ttl": 600} (PUT works too) +POST /cache-clear -> {"cleared": } +GET /cache-stats -> {"entries", "hits", "misses"} + +Day 73 addition (in memory, newest 1000 kept): +POST /audit body {"route", "blocked_by", "reason", "masked", ...} -> {"stored": true, "count"} +GET /audit -> {"events": [{"t", ...the body}]} +POST /audit-clear -> {"cleared": } + +Day 74 additions (prompt registry from data/prompts, releases from data/releases.json, state in memory): +GET /prompts/ -> {"name", "versions": ["p1", ...]} +GET /prompts// -> {"name", "version", "text"}, or 404 {"error": "prompt not found"} (fault switch "prompts") +GET /releases/ -> {"name", "active", "canary": null | {"release", "percent"}, "require_gate": false, "releases": {...}} +POST /releases/ body {"active": "1.1.0"} and/or {"canary": {"release": "1.2.0", "percent": 50}} or {"canary": null} + When require_gate is on, a release can become active or canary only if its latest gate + record says "promote"; otherwise 409 {"error": "not gated", "release", "last_gate"}. +POST /releases//policy body {"require_gate": true|false} +POST /releases//reset -> back to data/releases.json (active, no canary, require_gate off; gate records stay) +POST /gates/ body {"release", "verdict": "promote"|"rollback", "passed", "total", "source", ...} + -> {"stored": true, "count"} +GET /gates/ -> {"name", "gates": [{"t", ...the body}]} (newest last, 200 kept) +POST /gates//clear -> {"cleared": } + +Day 75 additions (usage and budget in memory): +GET /prices -> {"currency", "unit", "models": {"gpt-4.1-mini": {"input", "output"}, ...}} (USD per 1M tokens) +POST /usage body {"items": [{"request_id", "user", "step", "model", "prompt_tokens", + "completion_tokens", "cost_usd", "route"}]} (one item per LLM call, or one item + without "items") -> {"stored": n, "total"} (fault switch "usage": error -> 500, + slow -> waits slow_seconds, then 503 and nothing is stored) +GET /usage/summary -> {"requests" (distinct request_id), "calls", "prompt_tokens", "completion_tokens", + "cost_usd", "by_model", "by_user", "by_route"} (POST works too) +GET /usage/user/?window=60 -> {"user", "window", "count"} distinct requests of that user in the last N seconds +POST /usage-clear -> {"cleared": n} +GET /budget -> {"daily_usd", "used_usd", "remaining_usd", "over", "user_limit_per_min", "max_question_chars"} +POST /budget body {"daily_usd": 0.5} and/or {"user_limit_per_min": 3} and/or {"max_question_chars": 500} +POST /budget/reset -> back to data/budget.json + +Day 76 additions (ticket creation, dead letter queue and alerts in memory): +POST /tickets body {"key", "title", "requester", "category", "priority"} -> 201 {"ticket_id", "duplicate": false} + same "key" again -> 200 {"ticket_id" (the first one), "duplicate": true} (idempotency) + fault switch "tickets": slow waits slow_seconds and then still creates the ticket + (a caller that gave up does not know it was made), error -> 500, flaky -> see above +GET /tickets-created -> {"count", "tickets": [...]} (POST /tickets-created/list too); POST /tickets-reset +POST /dlq body {"item", "reason", "workflow"} or {"items": [...]} -> {"stored", "size"} + the same item.id again does not add a row: its "attempts" goes up by 1 (fault switch "dlq") +GET /dlq -> {"size", "parked", "items": [{"dlq_id", "item", "reason", "attempts", "parked", ...}]} (POST /dlq/list too) +POST /dlq/update body {"dlq_id", "remove": true} or {"dlq_id", "attempts": n, "parked": true, "reason"} +POST /dlq-clear +POST /alerts body {"workflow", "node", "message", "severity", "reason", "execution_id", ...} -> {"stored", "count"} +GET /alerts -> {"count", "alerts": [...]} +POST /alerts/list body {} or filters {"workflow", "reason", "severity", "within": seconds} -> {"count", "alerts"} +POST /alerts/latest -> the newest alert or {}; POST /alerts-clear + +Day 77 additions (two weeks of seeded run logs from data/run-logs.json + live ones in memory): +GET /logs?from=2026-10-19&to=2026-10-30&tag=&source=all|seed|live&limit= + -> {"count", "logs": [...]} dates inclusive (from <= started_at[:10] <= to), empty = all +POST /logs/query same filters in the body {"from", "to", "tag", "source"} +POST /logs body {"items": [{... one run ...}]} -> {"stored", "live"} (started_at = now when missing) +POST /logs-reset remove live logs (the seeded two weeks stay); body {"tag"} removes only that tag -> {"cleared"} +GET /faq-extra -> {"count", "faq": [...]} the FAQ entries added today +POST /faq/add body {"id", "title", "keywords": [...], "answer"} -> {"added", "size"}; POST /faq-reset + +Day 78 additions (data quality; state in memory): +GET /faq-all -> {"count", "faqs": [...]} the FAQ now (file + added) (POST /faq/list too) +POST /quarantine body {"items": [{"batch", "row": {...}, "errors": [...]}]}; the same batch + row id + (row.id, else row.email, else row.ticket_id) replaces the old one -> {"stored", "size"} +GET /quarantine -> {"size", "items": [{"row_id", "batch", "row", "errors", "at"}]} (POST /quarantine/list too) +POST /quarantine/remove body {"ids": ["FAQ-209"]} -> {"removed", "size"}; POST /quarantine-clear +GET /schemas -> {"schemas": ["employee", "faq", "ticket"]} (POST /schemas/list too) +GET /schemas/ -> a data schema {"name", "key", "fields"}, or 404 {"error": "schema not found"} +POST /quality-log body {"batch", "dataset", "total", "valid", "quarantined", "rejected", "score", "codes": {...}} +GET /quality-log -> {"count", "items": [...]} in the order stored (POST /quality-log/list too); POST /quality-log-clear + +Day 79 additions (the operations morning of 2026-11-04 08:30 from data/ops-seed.json, loaded at start): +GET /ops/seed-info -> {"seeded_at", "note", "counts": {...}} +POST /ops-reset back to the seeded morning: alerts, DLQ, quality log, quarantine, usage, budget and releases from + data/ops-seed.json; live logs, added FAQ, cache, created tickets, audit and the fault switch are cleared +GET /usage/summary now also follows the fault switch "usage" (error -> 500, slow -> waits): a source that is down +POST /audit/list -> {"events": [...]} (the same as GET /audit, for test files that POST) +""" + +import datetime +import json +import os +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, unquote, urlparse + +DATA_DIR = Path(os.getenv("DATA_DIR", Path(__file__).resolve().parent / "data")) +PORT = int(os.getenv("PORT", "8800")) + +LOCK = threading.Lock() +FAULT = {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +FLAKY: dict = {} +CACHE: dict[str, dict] = {} +STATS = {"hits": 0, "misses": 0} +AUDIT: list[dict] = [] +RELEASES: dict = {} +GATES: dict[str, list[dict]] = {} +USAGE: list[dict] = [] +BUDGET: dict = {} +BUDGET_KEYS = {"daily_usd": float, "user_limit_per_min": int, "max_question_chars": int} +CREATED: dict = {} +DLQ: list[dict] = [] +ALERTS: list[dict] = [] +NEXT = {"ticket": 2001, "dlq": 1} +SEED_LOGS: list[dict] = [] +LIVE_LOGS: list[dict] = [] +FAQ_EXTRA: list[dict] = [] +QUARANTINE: list[dict] = [] +QUALITY_LOG: list[dict] = [] + + +def row_key(row) -> str: + row = row if isinstance(row, dict) else {} + return str(row.get("id") or row.get("email") or row.get("ticket_id") or "") + + +def dt_now(): + return datetime.datetime.now().isoformat(timespec="seconds") + + +def query_logs(params): + if not SEED_LOGS: + SEED_LOGS.extend(load("run-logs.json")) + source = params.get("source") or "all" + rows = (SEED_LOGS if source in ("all", "seed") else []) + (LIVE_LOGS if source in ("all", "live") else []) + start, end, tag = params.get("from") or "", params.get("to") or "", params.get("tag") or "" + out = [] + for row in rows: + day = str(row.get("started_at", ""))[:10] + if (start and day < start) or (end and day > end) or (tag and row.get("tag") != tag): + continue + out.append(row) + return out + + +def dlq_view(): + return {"size": len(DLQ), "parked": sum(1 for e in DLQ if e.get("parked")), "items": DLQ} + + +def alerts_view(body=None): + body = body or {} + now = time.time() + rows = [a for a in ALERTS + if all(not body.get(k) or a.get(k) == body[k] for k in ("workflow", "reason", "severity")) + and (not body.get("within") or now - a["at"] <= float(body["within"]))] + return {"count": len(rows), "alerts": rows} + + +def budget(): + if not BUDGET: + BUDGET.update(load("budget.json")) + used = round(sum(float(item.get("cost_usd") or 0) for item in USAGE), 8) + return {**BUDGET, "used_usd": used, "remaining_usd": round(BUDGET["daily_usd"] - used, 8), "over": used >= BUDGET["daily_usd"]} + + +def summary(): + def add(table, key, item): + row = table.setdefault(str(key), {"calls": 0, "prompt_tokens": 0, "completion_tokens": 0, "cost_usd": 0.0}) + row["calls"] += 1 + row["prompt_tokens"] += int(item.get("prompt_tokens") or 0) + row["completion_tokens"] += int(item.get("completion_tokens") or 0) + row["cost_usd"] = round(row["cost_usd"] + float(item.get("cost_usd") or 0), 8) + total = {"requests": 0, "calls": 0, "prompt_tokens": 0, "completion_tokens": 0, "cost_usd": 0.0} + by_model, by_user, by_route = {}, {}, {} + seen = set() + for index, item in enumerate(USAGE): + key = item.get("request_id") or f"item-{index}" + if key not in seen: + seen.add(key) + total["requests"] += 1 + total["calls"] += 1 + total["prompt_tokens"] += int(item.get("prompt_tokens") or 0) + total["completion_tokens"] += int(item.get("completion_tokens") or 0) + total["cost_usd"] = round(total["cost_usd"] + float(item.get("cost_usd") or 0), 8) + add(by_model, item.get("model", "?"), item) + add(by_user, item.get("user", "?"), item) + add(by_route, item.get("route", "?"), item) + return {**total, "by_model": by_model, "by_user": by_user, "by_route": by_route} + + +def load(name): + return json.loads((DATA_DIR / name).read_text(encoding="utf-8")) + + +def ops_seed(): + """Load data/ops-seed.json into the in-memory state (called at start and by POST /ops-reset).""" + seed = load("ops-seed.json") + ALERTS[:] = [dict(item) for item in seed["alerts"]] + DLQ[:] = [json.loads(json.dumps(item)) for item in seed["dlq"]] + NEXT["dlq"] = max([item["dlq_id"] for item in DLQ] + [0]) + 1 + QUALITY_LOG[:] = [dict(item) for item in seed["quality_log"]] + QUARANTINE[:] = [dict(item) for item in seed["quarantine"]] + USAGE[:] = [dict(item) for item in seed["usage"]] + BUDGET.clear() + BUDGET.update(seed["budget"]) + RELEASES.clear() + for name, item in releases().items(): + item.update(seed["releases"].get(name, {})) + LIVE_LOGS.clear() + FAQ_EXTRA.clear() + CACHE.clear() + CREATED.clear() + AUDIT.clear() + for target in list(FAULT): + if target != "slow_seconds": + FAULT[target] = "ok" + FAULT["slow_seconds"] = 8 + FLAKY.clear() + return seed_info(seed) + + +def seed_info(seed=None): + seed = seed or load("ops-seed.json") + return {"seeded_at": seed["seeded_at"], "note": seed["note"], + "counts": {"alerts": len(ALERTS), "dlq": len(DLQ), "quality_log": len(QUALITY_LOG), "quarantine": len(QUARANTINE), + "usage": len(USAGE)}} + + +def releases(): + if not RELEASES: + for name, item in load("releases.json").items(): + RELEASES[name] = {"active": item["active"], "canary": None, "require_gate": False, "releases": item["releases"]} + return RELEASES + + +def last_gate(name, release): + for record in reversed(GATES.get(name, [])): + if record.get("release") == release: + return record + return None + + +def search_faq(query, top_k=2): + words = {word for word in query.lower().split() if len(word) > 1} + scored = [] + for item in load("faq.json") + FAQ_EXTRA: + keywords = {keyword.lower() for keyword in item["keywords"]} + score = sum(1 for word in words if any(keyword in word or word in keyword for keyword in keywords)) + if score: + scored.append({"score": score, **item}) + scored.sort(key=lambda item: item["score"], reverse=True) + return scored[:top_k] + + +class Handler(BaseHTTPRequestHandler): + def send_json(self, status, body): + data = json.dumps(body, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def url(self): + # Raw UTF-8 in the request line arrives as latin-1 text; percent-encoded paths are unchanged. + return urlparse(self.path.encode("latin-1").decode("utf-8", errors="replace")) + + def body(self): + length = int(self.headers.get("Content-Length") or 0) + if not length: + return {} + try: + return json.loads(self.rfile.read(length).decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + + def faulty(self, target, key=""): + """Apply the fault switch to one endpoint group. Returns True when the response was already sent.""" + mode = FAULT.get(target, "ok") + if mode == "flaky": + with LOCK: + slot = f"{target}:{key}" if key else target + FLAKY[slot] = FLAKY.get(slot, 0) + 1 + fail = FLAKY[slot] == 1 if key else FLAKY[slot] % 2 == 0 + if fail: + self.send_json(500, {"error": f"{target} service failed this time (flaky fault switch)"}) + return True + return False + if mode == "slow": + time.sleep(float(FAULT.get("slow_seconds", 8))) + elif mode == "error": + self.send_json(500, {"error": f"{target} service is failing (fault switch)"}) + return True + return False + + def do_GET(self): + url = self.url() + if url.path == "/health": + return self.send_json(200, {"status": "ok"}) + if url.path == "/admin/fault": + return self.send_json(200, FAULT) + if url.path == "/audit": + with LOCK: + return self.send_json(200, {"events": list(AUDIT)}) + if url.path == "/tickets-created": + with LOCK: + return self.send_json(200, {"count": len(CREATED), "tickets": list(CREATED.values())}) + if url.path == "/dlq": + with LOCK: + return self.send_json(200, dlq_view()) + if url.path == "/alerts": + with LOCK: + return self.send_json(200, alerts_view()) + if url.path == "/prices": + return self.send_json(200, load("prices.json")) + if url.path == "/usage/summary": + if self.faulty("usage"): + return None + with LOCK: + return self.send_json(200, summary()) + if url.path == "/ops/seed-info": + with LOCK: + return self.send_json(200, seed_info()) + if url.path.startswith("/usage/user/"): + name = unquote(url.path.removeprefix("/usage/user/")) + window = float(parse_qs(url.query).get("window", ["60"])[0]) + now = time.time() + with LOCK: + count = len({item.get("request_id") or f"item-{i}" for i, item in enumerate(USAGE) + if item.get("user") == name and now - item["at"] <= window}) + return self.send_json(200, {"user": name, "window": window, "count": count}) + if url.path == "/budget": + with LOCK: + return self.send_json(200, budget()) + if url.path == "/cache-stats": + with LOCK: + return self.send_json(200, {"entries": len(CACHE), **STATS}) + if url.path.startswith("/cache/"): + key = unquote(url.path.removeprefix("/cache/")) + with LOCK: + entry = CACHE.get(key) + if entry and entry["expires"] > time.time(): + entry["hits"] += 1 + STATS["hits"] += 1 + return self.send_json(200, {"key": key, "value": entry["value"], "hits": entry["hits"]}) + CACHE.pop(key, None) + STATS["misses"] += 1 + return self.send_json(404, {"error": "not cached", "key": key}) + if url.path == "/faq": + if self.faulty("faq"): + return None + query = parse_qs(url.query).get("q", [""])[0] + return self.send_json(200, {"query": query, "results": search_faq(query)}) + if url.path == "/logs": + params = {k: v[0] for k, v in parse_qs(url.query).items()} + with LOCK: + rows = query_logs(params) + limit = int(params.get("limit", 0) or 0) + return self.send_json(200, {"count": len(rows), "logs": rows[:limit] if limit else rows}) + if url.path == "/faq-extra": + return self.send_json(200, {"count": len(FAQ_EXTRA), "faq": FAQ_EXTRA}) + if url.path == "/faq-all": + with LOCK: + faqs = load("faq.json") + FAQ_EXTRA + return self.send_json(200, {"count": len(faqs), "faqs": faqs}) + if url.path == "/schemas": + names = sorted(p.stem for p in (DATA_DIR / "schemas").glob("*.json")) + return self.send_json(200, {"schemas": names}) + if url.path.startswith("/schemas/"): + name = unquote(url.path.removeprefix("/schemas/")).strip("/") + path = DATA_DIR / "schemas" / f"{name}.json" + if "/" in name or not name or not path.exists(): + return self.send_json(404, {"error": "schema not found", "name": name}) + return self.send_json(200, load(f"schemas/{name}.json")) + if url.path == "/quarantine": + with LOCK: + return self.send_json(200, {"size": len(QUARANTINE), "items": QUARANTINE}) + if url.path == "/quality-log": + with LOCK: + return self.send_json(200, {"count": len(QUALITY_LOG), "items": QUALITY_LOG}) + if url.path == "/employees": + return self.send_json(200, {"employees": load("employees.json")}) + if url.path.startswith("/employees/"): + name = unquote(url.path.removeprefix("/employees/")).strip() + for person in load("employees.json"): + if person["name"] == name: + return self.send_json(200, person) + return self.send_json(404, {"error": "unknown employee", "name": name}) + if url.path == "/docs": + docs = [{"name": f.stem, "title": f.read_text(encoding="utf-8").splitlines()[0].lstrip("# ").strip()} + for f in sorted((DATA_DIR / "docs").glob("*.md"))] + return self.send_json(200, {"docs": docs}) + if url.path.startswith("/docs/"): + name = unquote(url.path.removeprefix("/docs/")).strip() + path = DATA_DIR / "docs" / f"{name}.md" + if "/" in name or not path.exists(): + return self.send_json(404, {"error": "doc not found", "name": name}) + text = path.read_text(encoding="utf-8") + return self.send_json(200, {"name": name, "title": text.splitlines()[0].lstrip("# ").strip(), "text": text}) + if url.path.startswith("/tickets/"): + if self.faulty("tickets"): + return None + ticket_id = unquote(url.path.removeprefix("/tickets/")).strip().upper() + ticket = load("tickets.json").get(ticket_id) + if ticket is None: + return self.send_json(404, {"error": "ticket not found", "ticket_id": ticket_id}) + requester = parse_qs(url.query).get("requester", [None])[0] + if requester is not None and requester.strip() != ticket["requester"]: + return self.send_json(403, {"error": "not your ticket", "ticket_id": ticket_id}) + return self.send_json(200, {"ticket_id": ticket_id, **ticket}) + if url.path.startswith("/prompts/"): + if self.faulty("prompts"): + return None + parts = [unquote(p) for p in url.path.removeprefix("/prompts/").split("/") if p] + folder = DATA_DIR / "prompts" + if len(parts) == 1: + versions = sorted(f.stem.removeprefix(parts[0] + "-") for f in folder.glob(f"{parts[0]}-*.md")) + return self.send_json(200 if versions else 404, {"name": parts[0], "versions": versions}) + if len(parts) == 2 and "/" not in parts[1] and (folder / f"{parts[0]}-{parts[1]}.md").exists(): + text = (folder / f"{parts[0]}-{parts[1]}.md").read_text(encoding="utf-8").strip() + return self.send_json(200, {"name": parts[0], "version": parts[1], "text": text}) + return self.send_json(404, {"error": "prompt not found", "path": url.path}) + if url.path.startswith("/releases/"): + name = unquote(url.path.removeprefix("/releases/")).strip("/") + with LOCK: + item = releases().get(name) + if item is None: + return self.send_json(404, {"error": "unknown release name", "name": name}) + return self.send_json(200, {"name": name, **item}) + if url.path.startswith("/gates/"): + name = unquote(url.path.removeprefix("/gates/")).strip("/") + with LOCK: + return self.send_json(200, {"name": name, "gates": list(GATES.get(name, []))}) + return self.send_json(404, {"error": "unknown path"}) + + def do_POST(self): + url = self.url() + body = self.body() + if body is None or not isinstance(body, dict): + return self.send_json(400, {"error": "body must be a JSON object"}) + if url.path == "/admin/fault": + for target in ("faq", "tickets", "prompts", "usage", "dlq"): + if target in body: + if body[target] not in ("ok", "slow", "error", "flaky"): + return self.send_json(400, {"error": f"{target} must be ok, slow, error or flaky"}) + FAULT[target] = body[target] + for slot in [s for s in FLAKY if s == target or s.startswith(target + ":")]: + del FLAKY[slot] + if "slow_seconds" in body: + FAULT["slow_seconds"] = max(0.0, min(float(body["slow_seconds"]), 60.0)) + return self.send_json(200, FAULT) + if url.path == "/logs/query": + with LOCK: + rows = query_logs(body) + return self.send_json(200, {"count": len(rows), "logs": rows}) + if url.path == "/logs": + items = body.get("items") if isinstance(body.get("items"), list) else [body] + now = dt_now() + with LOCK: + for item in items: + LIVE_LOGS.append({"started_at": now, "source": "live", **item}) + return self.send_json(200, {"stored": len(items), "live": len(LIVE_LOGS)}) + if url.path == "/logs-reset": + tag = body.get("tag") if isinstance(body, dict) else None + with LOCK: + count = len(LIVE_LOGS) + if tag: + LIVE_LOGS[:] = [log for log in LIVE_LOGS if log.get("tag") != tag] + else: + LIVE_LOGS.clear() + count -= len(LIVE_LOGS) + return self.send_json(200, {"cleared": count}) + if url.path == "/faq/add": + if not body.get("id") or not body.get("answer") or not isinstance(body.get("keywords"), list): + return self.send_json(400, {"error": "id, keywords (list) and answer are required"}) + with LOCK: + FAQ_EXTRA[:] = [f for f in FAQ_EXTRA if f["id"] != body["id"]] + FAQ_EXTRA.append({k: body.get(k) for k in ("id", "title", "keywords", "answer")}) + return self.send_json(200, {"added": body["id"], "size": len(load("faq.json")) + len(FAQ_EXTRA)}) + if url.path == "/faq-reset": + with LOCK: + count = len(FAQ_EXTRA) + FAQ_EXTRA.clear() + return self.send_json(200, {"cleared": count}) + if url.path == "/schemas/list": + return self.send_json(200, {"schemas": sorted(p.stem for p in (DATA_DIR / "schemas").glob("*.json"))}) + if url.path == "/faq/list": + with LOCK: + faqs = load("faq.json") + FAQ_EXTRA + return self.send_json(200, {"count": len(faqs), "faqs": faqs}) + if url.path == "/quarantine": + items = body.get("items") if isinstance(body.get("items"), list) else [body] + with LOCK: + for item in items: + key = row_key(item.get("row")) + QUARANTINE[:] = [q for q in QUARANTINE if not (q.get("batch") == item.get("batch") and q.get("row_id") == key)] + QUARANTINE.append({"row_id": key, **item, "at": dt_now()}) + return self.send_json(200, {"stored": len(items), "size": len(QUARANTINE)}) + if url.path == "/quarantine/list": + with LOCK: + return self.send_json(200, {"size": len(QUARANTINE), "items": QUARANTINE}) + if url.path == "/quarantine/remove": + ids = {str(i) for i in body.get("ids") or []} + with LOCK: + before = len(QUARANTINE) + QUARANTINE[:] = [q for q in QUARANTINE if q.get("row_id") not in ids] + return self.send_json(200, {"removed": before - len(QUARANTINE), "size": len(QUARANTINE)}) + if url.path == "/quarantine-clear": + with LOCK: + count = len(QUARANTINE) + QUARANTINE.clear() + return self.send_json(200, {"cleared": count}) + if url.path == "/quality-log": + with LOCK: + QUALITY_LOG.append({**body, "at": dt_now()}) + return self.send_json(200, {"stored": 1, "count": len(QUALITY_LOG)}) + if url.path == "/quality-log/list": + with LOCK: + return self.send_json(200, {"count": len(QUALITY_LOG), "items": QUALITY_LOG}) + if url.path == "/quality-log-clear": + with LOCK: + count = len(QUALITY_LOG) + QUALITY_LOG.clear() + return self.send_json(200, {"cleared": count}) + if url.path == "/tickets": + key = str(body.get("key") or "").strip() + if self.faulty("tickets", key): + return None + if not key or not str(body.get("title") or "").strip(): + return self.send_json(400, {"error": "key and title are required"}) + with LOCK: + if key in CREATED: + return self.send_json(200, {"ticket_id": CREATED[key]["ticket_id"], "duplicate": True}) + ticket_id = f"T-{NEXT['ticket']}" + NEXT["ticket"] += 1 + CREATED[key] = {"ticket_id": ticket_id, "key": key, **{k: body.get(k) for k in ("title", "requester", "category", "priority")}} + return self.send_json(201, {"ticket_id": ticket_id, "duplicate": False}) + if url.path == "/tickets-reset": + with LOCK: + count = len(CREATED) + CREATED.clear() + NEXT["ticket"] = 2001 + return self.send_json(200, {"cleared": count}) + if url.path == "/tickets-created/list": + with LOCK: + return self.send_json(200, {"count": len(CREATED), "tickets": list(CREATED.values())}) + if url.path == "/dlq": + if self.faulty("dlq"): + return None + items = body.get("items") if isinstance(body.get("items"), list) else [body] + with LOCK: + for item in items: + item_id = (item.get("item") or {}).get("id") + same = next((e for e in DLQ if item_id and (e.get("item") or {}).get("id") == item_id), None) + if same: + same["attempts"] += 1 + same["reason"] = item.get("reason", same.get("reason")) + same["at"] = time.time() + continue + DLQ.append({"dlq_id": f"D-{NEXT['dlq']}", "attempts": 1, "parked": False, **item, "at": time.time()}) + NEXT["dlq"] += 1 + return self.send_json(200, {"stored": len(items), "size": len(DLQ)}) + if url.path == "/dlq/list": + with LOCK: + return self.send_json(200, dlq_view()) + if url.path == "/dlq/update": + with LOCK: + for index, entry in enumerate(DLQ): + if entry["dlq_id"] == body.get("dlq_id"): + if body.get("remove"): + DLQ.pop(index) + return self.send_json(200, {"removed": True, "size": len(DLQ)}) + for key in ("attempts", "parked", "reason"): + if key in body: + entry[key] = body[key] + return self.send_json(200, {"updated": True, "entry": entry}) + return self.send_json(404, {"error": "dlq item not found"}) + if url.path == "/dlq-clear": + with LOCK: + count = len(DLQ) + DLQ.clear() + NEXT["dlq"] = 1 + return self.send_json(200, {"cleared": count}) + if url.path == "/alerts": + with LOCK: + ALERTS.append({**body, "at": time.time()}) + return self.send_json(200, {"stored": 1, "count": len(ALERTS)}) + if url.path == "/alerts/list": + with LOCK: + return self.send_json(200, alerts_view(body)) + if url.path == "/alerts/latest": + with LOCK: + return self.send_json(200, ALERTS[-1] if ALERTS else {}) + if url.path == "/alerts-clear": + with LOCK: + count = len(ALERTS) + ALERTS.clear() + return self.send_json(200, {"cleared": count}) + if url.path == "/ops-reset": + with LOCK: + return self.send_json(200, ops_seed()) + if url.path == "/audit/list": + with LOCK: + return self.send_json(200, {"events": list(AUDIT)}) + if url.path == "/audit": + with LOCK: + AUDIT.append({"t": round(time.time(), 3), **body}) + del AUDIT[:-1000] + count = len(AUDIT) + return self.send_json(200, {"stored": True, "count": count}) + if url.path == "/usage": + if FAULT.get("usage") == "slow": + # a hung record service: answers late and keeps nothing (a caller that gave up must not be counted later) + time.sleep(float(FAULT.get("slow_seconds", 8))) + return self.send_json(503, {"error": "usage service timed out (fault switch)", "stored": 0}) + if self.faulty("usage"): + return None + items = body.get("items") if isinstance(body.get("items"), list) else [body] + with LOCK: + for item in items: + USAGE.append({**item, "at": time.time()}) + return self.send_json(200, {"stored": len(items), "total": len(USAGE)}) + if url.path == "/usage/summary": + with LOCK: + return self.send_json(200, summary()) + if url.path == "/usage-clear": + with LOCK: + count = len(USAGE) + USAGE.clear() + return self.send_json(200, {"cleared": count}) + if url.path in ("/budget", "/budget/reset"): + with LOCK: + if url.path == "/budget/reset": + BUDGET.clear() + budget() + for key, kind in BUDGET_KEYS.items(): + if key in body: + BUDGET[key] = kind(body[key]) + return self.send_json(200, budget()) + if url.path == "/audit-clear": + with LOCK: + count = len(AUDIT) + AUDIT.clear() + return self.send_json(200, {"cleared": count}) + if url.path == "/cache-clear": + with LOCK: + count = len(CACHE) + CACHE.clear() + STATS.update(hits=0, misses=0) + return self.send_json(200, {"cleared": count}) + if url.path.startswith("/cache/"): + key = unquote(url.path.removeprefix("/cache/")) + if not key or "value" not in body: + return self.send_json(400, {"error": "key in the path and value in the body are required"}) + ttl = max(1.0, min(float(body.get("ttl", 600)), 86400.0)) + with LOCK: + CACHE[key] = {"value": body["value"], "expires": time.time() + ttl, "hits": 0} + return self.send_json(200, {"key": key, "stored": True, "ttl": ttl}) + if url.path.startswith("/gates/"): + name = unquote(url.path.removeprefix("/gates/")).strip("/") + clear = name.endswith("/clear") + name = name.removesuffix("/clear") + with LOCK: + if clear: + count = len(GATES.pop(name, [])) + return self.send_json(200, {"cleared": count}) + if not body.get("release") or body.get("verdict") not in ("promote", "rollback"): + return self.send_json(400, {"error": "release and verdict (promote or rollback) are required"}) + records = GATES.setdefault(name, []) + records.append({"t": round(time.time(), 3), **body}) + del records[:-200] + return self.send_json(200, {"stored": True, "count": len(records)}) + if url.path.startswith("/releases/"): + name = unquote(url.path.removeprefix("/releases/")).strip("/") + action = "" + for suffix in ("/reset", "/policy"): + if name.endswith(suffix): + action, name = suffix[1:], name.removesuffix(suffix) + with LOCK: + if action == "reset": + RELEASES.pop(name, None) + item = releases().get(name) + if item is None: + return self.send_json(404, {"error": "unknown release name", "name": name}) + if action == "reset": + return self.send_json(200, {"name": name, **item}) + if action == "policy": + if not isinstance(body.get("require_gate"), bool): + return self.send_json(400, {"error": "require_gate must be true or false"}) + item["require_gate"] = body["require_gate"] + return self.send_json(200, {"name": name, **item}) + wanted = [] + if "active" in body: + if body["active"] not in item["releases"]: + return self.send_json(400, {"error": "unknown release", "known": sorted(item["releases"])}) + wanted.append(body["active"]) + canary = body.get("canary") + if "canary" in body and canary is not None: + if not isinstance(canary, dict) or canary.get("release") not in item["releases"] \ + or not 0 <= int(canary.get("percent", -1)) <= 100: + return self.send_json(400, {"error": "canary needs a known release and percent 0..100"}) + canary = {"release": canary["release"], "percent": int(canary["percent"])} + wanted.append(canary["release"]) + if item["require_gate"]: + for release in wanted: + gate = last_gate(name, release) + if not gate or gate.get("verdict") != "promote": + return self.send_json(409, {"error": "not gated", "release": release, "last_gate": gate}) + if "active" in body: + item["active"] = body["active"] + if "canary" in body: + item["canary"] = canary + return self.send_json(200, {"name": name, **item}) + return self.send_json(404, {"error": "unknown path"}) + + do_PUT = do_POST + + def log_message(self, fmt, *args): + print(f"{self.address_string()} {fmt % args}", flush=True) + + +if __name__ == "__main__": + ops_seed() + print(f"data-api listening on :{PORT} (data: {DATA_DIR})", flush=True) + ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever() diff --git a/Last3Mon/79/my-work/README.md b/Last3Mon/79/my-work/README.md new file mode 100644 index 0000000..636b1a9 --- /dev/null +++ b/Last3Mon/79/my-work/README.md @@ -0,0 +1,7 @@ +# 수업 결과물 + +- CLI 코딩 에이전트가 만든 워크플로우 JSON (`ops-facts.json`, `ops-ask.json`, `ops-brief.json`, `ops-eval.json`)이 여기에 저장됩니다 (git에 올라가지 않음). +- `OPS.md`: [운영 도우미 설계표](../templates/OPS-ASSISTANT-TEMPLATE.md)를 복사해 채웁니다. +- `golden.json`: Lab 03 과제에서 `requests/ops-golden.json`을 복사해 내 골든 질문을 더한 것. +- `PROMPTS.md`: 오늘 쓴 요청문을 모아 둡니다. +- `TEST-REPORT.md`: [테스트 보고서](../templates/TEST-REPORT-TEMPLATE.md) (테스트 13개의 결과 · 실패와 고친 내용). diff --git a/Last3Mon/79/reference/baseline-ops-ask.json b/Last3Mon/79/reference/baseline-ops-ask.json new file mode 100644 index 0000000..c037ddc --- /dev/null +++ b/Last3Mon/79/reference/baseline-ops-ask.json @@ -0,0 +1,180 @@ +{ + "name": "79 Ops ask (team baseline)", + "nodes": [ + { + "name": "Ask webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "base-ops-ask", + "responseMode": "responseNode", + "options": {} + }, + "position": [ + 0, + 300 + ], + "id": "cacb2fe4-6d95-5bdc-ace5-d418b067f9f2", + "webhookId": "17b262ab-6142-5063-99bc-1689af27b62b" + }, + { + "name": "Get facts", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://localhost:5678/webhook/my-ops-facts", + "options": { + "response": { + "response": { + "neverError": true + } + }, + "timeout": 30000 + }, + "method": "POST", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({}) }}" + }, + "position": [ + 220, + 300 + ], + "id": "d19a4c49-1318-5d1f-b074-feb88ba1895f" + }, + { + "name": "Answer", + "type": "@n8n/n8n-nodes-langchain.agent", + "typeVersion": 3.1, + "parameters": { + "promptType": "define", + "text": "=질문: {{ $('Ask webhook').first().json.body.question }}\n운영 데이터: {{ JSON.stringify($json) }}", + "options": { + "systemMessage": "You are a helpful operations assistant for an IT helpdesk team. Answer the operator's request in Korean.", + "maxIterations": 1 + } + }, + "onError": "continueRegularOutput", + "position": [ + 440, + 300 + ], + "id": "1471ab65-56b3-5a9e-9857-3588af91bb7d" + }, + { + "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": [ + 440, + 520 + ], + "id": "522004dd-9a0f-5713-8527-eb56487ebff0" + }, + { + "name": "Make answer", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "return [{ json: { question: $('Ask webhook').first().json.body.question, answer: String($json.output ?? '') } }];" + }, + "position": [ + 660, + 300 + ], + "id": "2404ff7d-7934-5db7-957f-3f9ea817018f" + }, + { + "name": "Send result", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 880, + 300 + ], + "id": "ee9a10b6-d4d1-5801-9147-89e67b2bed92" + } + ], + "connections": { + "Ask webhook": { + "main": [ + [ + { + "node": "Get facts", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get facts": { + "main": [ + [ + { + "node": "Answer", + "type": "main", + "index": 0 + } + ] + ] + }, + "Answer": { + "main": [ + [ + { + "node": "Make answer", + "type": "main", + "index": 0 + } + ] + ] + }, + "Make answer": { + "main": [ + [ + { + "node": "Send result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Answer model": { + "ai_languageModel": [ + [ + { + "node": "Answer", + "type": "ai_languageModel", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/79/reference/baseline-ops-facts.json b/Last3Mon/79/reference/baseline-ops-facts.json new file mode 100644 index 0000000..f95f50a --- /dev/null +++ b/Last3Mon/79/reference/baseline-ops-facts.json @@ -0,0 +1,205 @@ +{ + "name": "79 Ops facts (team baseline)", + "nodes": [ + { + "name": "Facts webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "base-ops-facts", + "responseMode": "responseNode", + "options": {} + }, + "position": [ + 0, + 300 + ], + "id": "e31e497c-4628-5404-98d8-d6cbafc7c5ee", + "webhookId": "29a51e92-50af-5329-aef4-8662be597525" + }, + { + "name": "Get logs", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/logs/query", + "options": { + "response": { + "response": { + "neverError": true + } + } + }, + "method": "POST", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({}) }}" + }, + "position": [ + 220, + 300 + ], + "id": "46df7394-80a0-51d6-b749-5ac183227830" + }, + { + "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": "d1bbadd5-3cbc-536a-8173-ac7a85579305" + }, + { + "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": "3f4c95d0-be36-5206-8b5a-741d1c43e7e0" + }, + { + "name": "Get budget", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/budget", + "options": { + "response": { + "response": { + "neverError": true + } + } + } + }, + "position": [ + 880, + 300 + ], + "id": "63abc78c-ec4f-5c79-9b9a-f073d8a04a97" + }, + { + "name": "Make facts", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "// team baseline: the morning dashboard we put together last month\nconst logs = $('Get logs').first().json.logs ?? [];\nconst total = logs.length, errors = logs.filter((l) => l.status === 'error').length;\nconst alerts = $('Get alerts').first().json, dlq = $('Get DLQ').first().json, budget = $('Get budget').first().json;\nreturn [{ json: {\n runs: { total, errors, error_rate: total ? errors / total * 100 : null },\n alerts: { count: alerts.count },\n dlq: { size: dlq.size },\n cost: { used_usd: budget.used_usd, daily_usd: budget.daily_usd },\n} }];" + }, + "position": [ + 1100, + 300 + ], + "id": "7dc18d76-23f3-583c-95e3-4d6c246c218a" + }, + { + "name": "Send result", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 1320, + 300 + ], + "id": "627c47e0-68e1-5f61-892b-8a56a7d2a1cc" + } + ], + "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 budget", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get budget": { + "main": [ + [ + { + "node": "Make facts", + "type": "main", + "index": 0 + } + ] + ] + }, + "Make facts": { + "main": [ + [ + { + "node": "Send result", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/79/reference/lab01-example2-facts.json b/Last3Mon/79/reference/lab01-example2-facts.json new file mode 100644 index 0000000..f916101 --- /dev/null +++ b/Last3Mon/79/reference/lab01-example2-facts.json @@ -0,0 +1,281 @@ +{ + "name": "79 Ops facts (reference)", + "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": "2b858e10-bcb6-507a-b018-ccad5055474c" + }, + { + "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": "bf44c4e1-c21f-5ce3-8b3c-7a403c9de155" + }, + { + "name": "Get alerts", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/alerts", + "options": {} + }, + "position": [ + 440, + 300 + ], + "id": "00bb14a7-97da-5f51-83c0-ba80bb44de43" + }, + { + "name": "Get DLQ", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/dlq", + "options": {} + }, + "position": [ + 660, + 300 + ], + "id": "91d75ee3-35c2-5d59-8d9f-4245564a9be2" + }, + { + "name": "Get quality", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/quality-log", + "options": {} + }, + "position": [ + 880, + 300 + ], + "id": "ec7984e1-42a4-544a-bf05-c45c5c620978" + }, + { + "name": "Get quarantine", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/quarantine", + "options": {} + }, + "position": [ + 1100, + 300 + ], + "id": "a617d18d-5214-5f24-bc5c-49efd6e41e8a" + }, + { + "name": "Get usage", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/usage/summary", + "options": {} + }, + "position": [ + 1320, + 300 + ], + "id": "37980fb4-f88c-56a4-81de-79e4a1544312" + }, + { + "name": "Get budget", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/budget", + "options": {} + }, + "position": [ + 1540, + 300 + ], + "id": "910bbd7e-c6fe-5a06-abd7-fd4b60dc33da" + }, + { + "name": "Get release", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/releases/helpdesk", + "options": {} + }, + "position": [ + 1760, + 300 + ], + "id": "ad4454f9-c31e-5c86-b44d-70c35b2c6f10" + }, + { + "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};\n\nreturn [{ json: facts }];" + }, + "position": [ + 1980, + 300 + ], + "id": "207a0874-b62e-5dd2-bf56-3d8f273ca7c1" + }, + { + "name": "Send facts", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 2200, + 300 + ], + "id": "2e25d833-7a9c-52cf-a2ae-7c20cccb5ba6" + } + ], + "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" + } +} diff --git a/Last3Mon/79/reference/lab02-example1-ask.json b/Last3Mon/79/reference/lab02-example1-ask.json new file mode 100644 index 0000000..9c78215 --- /dev/null +++ b/Last3Mon/79/reference/lab02-example1-ask.json @@ -0,0 +1,289 @@ +{ + "name": "79 Ops ask (reference)", + "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": "5bab6bdc-b287-574f-bfc6-713e57603960" + }, + { + "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};\n\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": "454883f1-d845-5ef3-8ac0-2273b7b626f8" + }, + { + "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": [ + 440, + 300 + ], + "id": "79d63d99-7ccf-589f-b573-0494c689fddf" + }, + { + "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": [ + 660, + 560 + ], + "id": "d350c7de-a80a-5f09-8fa6-bbb42b78784d" + }, + { + "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": [ + 660, + 300 + ], + "id": "7c08a9c6-99a6-5967-a699-678a1fd11701" + }, + { + "name": "Pick facts", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const r = $('Route').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": [ + 880, + 300 + ], + "id": "d7524d66-8ab6-5122-bd66-bce5849d9f09" + }, + { + "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": [ + 1100, + 300 + ], + "id": "f049e910-3871-5073-bbdb-63ce3e969af5" + }, + { + "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": [ + 1100, + 520 + ], + "id": "05b19df9-03bd-5ba0-bb97-58a5433ddf1e" + }, + { + "name": "Make answer", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const r = $('Pick facts').first().json;\nreturn [{ json: { question: r.question, area: r.area, router: r.router, refused: false, reason: null, answer: String($json.output ?? '').trim() } }];" + }, + "position": [ + 1320, + 300 + ], + "id": "d8e2b63f-ecb2-5934-b1ee-d7685e3d0a50" + }, + { + "name": "Send answer", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 1540, + 300 + ], + "id": "3b97fc93-c615-5711-a0a6-210980ab11d7" + } + ], + "connections": { + "Ask webhook": { + "main": [ + [ + { + "node": "Route", + "type": "main", + "index": 0 + } + ] + ] + }, + "Route": { + "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": "Make answer", + "type": "main", + "index": 0 + } + ] + ] + }, + "Make answer": { + "main": [ + [ + { + "node": "Send answer", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/79/reference/lab02-example2-ask.json b/Last3Mon/79/reference/lab02-example2-ask.json new file mode 100644 index 0000000..e9ecdf5 --- /dev/null +++ b/Last3Mon/79/reference/lab02-example2-ask.json @@ -0,0 +1,356 @@ +{ + "name": "79 Ops ask + refuse + number check (reference)", + "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": "dc6271e7-1050-5416-bf0a-26688d2d29ab" + }, + { + "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": "f9ebba3b-b71b-5cef-81d3-d2701e982ee8" + }, + { + "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": "1c6e30aa-01a8-51bd-b897-d46bf26619bc" + }, + { + "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": "0648af6b-cd57-59c3-933b-b2aa0b83b4cb" + }, + { + "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": [ + 660, + 300 + ], + "id": "fde1fecc-4f44-52dd-93ef-12ac37a6e362" + }, + { + "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": [ + 880, + 560 + ], + "id": "c218ca92-1811-5f8a-82cd-ab5f63d67c9a" + }, + { + "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": [ + 880, + 300 + ], + "id": "45a16e6c-5137-5047-9e26-2baadb544368" + }, + { + "name": "Pick facts", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const r = $('Route').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": [ + 1100, + 300 + ], + "id": "bb44be68-2a9f-5615-abf7-3931749039e6" + }, + { + "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": [ + 1320, + 300 + ], + "id": "53eb5e17-4cef-5f54-bdef-371560ec5368" + }, + { + "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": [ + 1320, + 520 + ], + "id": "2db947c2-c91c-57a4-ab27-5376b329d71c" + }, + { + "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": [ + 1540, + 300 + ], + "id": "1f4d46b1-ab0c-521c-babb-3cbd460c2ea0" + }, + { + "name": "Send answer", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 1760, + 300 + ], + "id": "f18b4f0d-663b-59cd-a45b-44523b719d9b" + } + ], + "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": "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" + } +} diff --git a/Last3Mon/79/reference/lab03-example1-brief.json b/Last3Mon/79/reference/lab03-example1-brief.json new file mode 100644 index 0000000..b1a6a14 --- /dev/null +++ b/Last3Mon/79/reference/lab03-example1-brief.json @@ -0,0 +1,132 @@ +{ + "name": "79 Ops brief (reference)", + "nodes": [ + { + "name": "Brief webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "my-ops-brief", + "responseMode": "responseNode", + "options": {} + }, + "webhookId": "d8018a49-2915-5dd1-adc6-aa234e759aa9", + "position": [ + 0, + 300 + ], + "id": "f5d30f9a-d747-55da-969f-360018e9323a" + }, + { + "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: $json.body?.from ?? '', to: $json.body?.to ?? '' }) }}" + }, + "position": [ + 220, + 300 + ], + "id": "f4e1e0cf-6d75-58c5-963f-ede1302344e8" + }, + { + "name": "Make brief", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const f = $('Get facts').first().json;\nconst label = { runs: '실행', alerts: '알림', dlq: 'DLQ', quality: '데이터 품질', cost: '비용', release: '릴리스' };\nconst mark = { red: '🔴', yellow: '🟡', green: '🟢', unknown: '⚪' };\nconst actionsFor = {\"runs\": \"오류 묶음 1위 확인 · 고치기 (77일차)\", \"alerts\": \"높은 알림부터 확인하고 닫기 (76일차)\", \"dlq\": \"parked 건 원인 확인 뒤 런북대로 재처리 (76일차)\", \"quality\": \"격리함을 콘텐츠 담당자에게 (78일차)\", \"cost\": \"비용 1위 사용자 · 한도 확인 (75일차)\", \"release\": \"카나리 끄기 · 되돌리기 검토 (74일차)\"};\nconst pct = (v) => (Math.round(v * 10000) / 100) + '%';\nconst g = (k, fn) => (f[k] === null ? '출처 응답 없음' : fn());\nconst key = {\n runs: g('runs', () => `오류율 ${pct(f.runs.error_rate)} · p95 ${f.runs.p95_ms}ms · 답 못 함 ${pct(f.runs.unanswered_rate)}`),\n alerts: g('alerts', () => `${f.alerts.count}건 (높음 ${f.alerts.high})`),\n dlq: g('dlq', () => `${f.dlq.size}건 (parked ${f.dlq.parked})`),\n quality: g('quality', () => `평균 ${f.quality.avg_score} · 마지막 ${f.quality.last_score} · 격리 ${f.quality.quarantine}`),\n cost: g('cost', () => `$${f.cost.cost_usd} / $${f.cost.daily_usd} (${pct(f.cost.used_ratio)})`),\n release: g('release', () => `${f.release.active}` + (f.release.canary_release ? ` · 카나리 ${f.release.canary_release} ${f.release.canary_percent}% (다시 묻기 ${f.release.canary_repeat_rate === null ? '-' : pct(f.release.canary_repeat_rate)})` : '')),\n};\nconst order = ['runs', 'alerts', 'dlq', 'quality', 'cost', 'release'];\nconst focus = [...f.red, ...f.yellow].slice(0, 3);\nconst actions = focus.map((k) => ({ area: k, status: f.health[k], action: actionsFor[k] }));\nconst lines = [\n `# 운영 브리핑 (${f.period.from} ~ ${f.period.to} 실행 기록 + 지금 상태)`, '',\n `전체: ${mark[f.overall]} ${f.overall}`, '',\n '| 영역 | 상태 | 숫자 |', '| --- | --- | --- |',\n ...order.map((k) => `| ${label[k]} | ${mark[f.health[k]]} | ${key[k]} |`), '',\n '## 먼저 볼 것', '',\n ...actions.map((a, i) => `${i + 1}. ${mark[a.status]} ${label[a.area]}: ${a.action}`),\n];\nreturn [{ json: { overall: f.overall, red: f.red, yellow: f.yellow, actions, markdown: lines.join('\\n'), facts: f } }];" + }, + "position": [ + 440, + 300 + ], + "id": "7ac33296-1304-5a8b-a95a-1129228a81e4" + }, + { + "name": "Drop facts", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const { facts, ...rest } = $json; return [{ json: rest }];" + }, + "position": [ + 660, + 300 + ], + "id": "8932dd61-126a-5bb6-bd78-c2e8f62e131e" + }, + { + "name": "Send brief", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 880, + 300 + ], + "id": "5f04ca50-933a-5d1c-82a6-b22f607ee3f4" + } + ], + "connections": { + "Brief webhook": { + "main": [ + [ + { + "node": "Get facts", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get facts": { + "main": [ + [ + { + "node": "Make brief", + "type": "main", + "index": 0 + } + ] + ] + }, + "Make brief": { + "main": [ + [ + { + "node": "Drop facts", + "type": "main", + "index": 0 + } + ] + ] + }, + "Drop facts": { + "main": [ + [ + { + "node": "Send brief", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/79/reference/lab03-example2-brief.json b/Last3Mon/79/reference/lab03-example2-brief.json new file mode 100644 index 0000000..7f0c749 --- /dev/null +++ b/Last3Mon/79/reference/lab03-example2-brief.json @@ -0,0 +1,198 @@ +{ + "name": "79 Ops brief + summary (reference)", + "nodes": [ + { + "name": "Brief webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "my-ops-brief", + "responseMode": "responseNode", + "options": {} + }, + "webhookId": "d8018a49-2915-5dd1-adc6-aa234e759aa9", + "position": [ + 0, + 300 + ], + "id": "4c8cf041-481f-5b3f-bda4-66b2e588a2ff" + }, + { + "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: $json.body?.from ?? '', to: $json.body?.to ?? '' }) }}" + }, + "position": [ + 220, + 300 + ], + "id": "a841ddde-8f4b-56c1-ae8a-c81c8d9d1690" + }, + { + "name": "Make brief", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const f = $('Get facts').first().json;\nconst label = { runs: '실행', alerts: '알림', dlq: 'DLQ', quality: '데이터 품질', cost: '비용', release: '릴리스' };\nconst mark = { red: '🔴', yellow: '🟡', green: '🟢', unknown: '⚪' };\nconst actionsFor = {\"runs\": \"오류 묶음 1위 확인 · 고치기 (77일차)\", \"alerts\": \"높은 알림부터 확인하고 닫기 (76일차)\", \"dlq\": \"parked 건 원인 확인 뒤 런북대로 재처리 (76일차)\", \"quality\": \"격리함을 콘텐츠 담당자에게 (78일차)\", \"cost\": \"비용 1위 사용자 · 한도 확인 (75일차)\", \"release\": \"카나리 끄기 · 되돌리기 검토 (74일차)\"};\nconst pct = (v) => (Math.round(v * 10000) / 100) + '%';\nconst g = (k, fn) => (f[k] === null ? '출처 응답 없음' : fn());\nconst key = {\n runs: g('runs', () => `오류율 ${pct(f.runs.error_rate)} · p95 ${f.runs.p95_ms}ms · 답 못 함 ${pct(f.runs.unanswered_rate)}`),\n alerts: g('alerts', () => `${f.alerts.count}건 (높음 ${f.alerts.high})`),\n dlq: g('dlq', () => `${f.dlq.size}건 (parked ${f.dlq.parked})`),\n quality: g('quality', () => `평균 ${f.quality.avg_score} · 마지막 ${f.quality.last_score} · 격리 ${f.quality.quarantine}`),\n cost: g('cost', () => `$${f.cost.cost_usd} / $${f.cost.daily_usd} (${pct(f.cost.used_ratio)})`),\n release: g('release', () => `${f.release.active}` + (f.release.canary_release ? ` · 카나리 ${f.release.canary_release} ${f.release.canary_percent}% (다시 묻기 ${f.release.canary_repeat_rate === null ? '-' : pct(f.release.canary_repeat_rate)})` : '')),\n};\nconst order = ['runs', 'alerts', 'dlq', 'quality', 'cost', 'release'];\nconst focus = [...f.red, ...f.yellow].slice(0, 3);\nconst actions = focus.map((k) => ({ area: k, status: f.health[k], action: actionsFor[k] }));\nconst lines = [\n `# 운영 브리핑 (${f.period.from} ~ ${f.period.to} 실행 기록 + 지금 상태)`, '',\n `전체: ${mark[f.overall]} ${f.overall}`, '',\n '| 영역 | 상태 | 숫자 |', '| --- | --- | --- |',\n ...order.map((k) => `| ${label[k]} | ${mark[f.health[k]]} | ${key[k]} |`), '',\n '## 먼저 볼 것', '',\n ...actions.map((a, i) => `${i + 1}. ${mark[a.status]} ${label[a.area]}: ${a.action}`),\n];\nreturn [{ json: { overall: f.overall, red: f.red, yellow: f.yellow, actions, markdown: lines.join('\\n'), facts: f } }];" + }, + "position": [ + 440, + 300 + ], + "id": "d8d05c5a-6a5d-5108-8983-3ee982fc0663" + }, + { + "name": "Summarize", + "type": "@n8n/n8n-nodes-langchain.agent", + "typeVersion": 3.1, + "parameters": { + "promptType": "define", + "text": "=사실(JSON): {{ JSON.stringify($json.facts) }}", + "options": { + "systemMessage": "You write the morning operations summary for an IT helpdesk team lead. Korean, exactly 3 sentences: overall state, the most urgent area, what to do first. Use only numbers from the facts JSON (a ratio like 0.0588 may be written as 5.88%). Do not guess causes.", + "maxIterations": 1 + } + }, + "position": [ + 660, + 300 + ], + "id": "eb2cf7af-1b3a-5ef8-a3c0-7fbd0c5fd155" + }, + { + "name": "Summary 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": [ + 660, + 520 + ], + "id": "a16dab74-014f-5d6e-9da6-ccc337283b08" + }, + { + "name": "Check summary", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const r = $('Make brief').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)));\nconst { facts, ...rest } = r;\nreturn [{ json: { ...rest, summary: answer, markdown: r.markdown + '\\n\\n## 요약\\n\\n' + answer, numbers_ok: unknown.length === 0, unknown_numbers: [...new Set(unknown)] } }];" + }, + "position": [ + 880, + 300 + ], + "id": "eaf526e2-064c-5d5c-ad13-67d237d99c4c" + }, + { + "name": "Send brief", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 1100, + 300 + ], + "id": "9c60667c-d76b-585b-b79d-462504312f77" + } + ], + "connections": { + "Brief webhook": { + "main": [ + [ + { + "node": "Get facts", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get facts": { + "main": [ + [ + { + "node": "Make brief", + "type": "main", + "index": 0 + } + ] + ] + }, + "Make brief": { + "main": [ + [ + { + "node": "Summarize", + "type": "main", + "index": 0 + } + ] + ] + }, + "Summary model": { + "ai_languageModel": [ + [ + { + "node": "Summarize", + "type": "ai_languageModel", + "index": 0 + } + ] + ] + }, + "Summarize": { + "main": [ + [ + { + "node": "Check summary", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check summary": { + "main": [ + [ + { + "node": "Send brief", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/79/reference/ops-facts-health.json b/Last3Mon/79/reference/ops-facts-health.json new file mode 100644 index 0000000..4e2e2dc --- /dev/null +++ b/Last3Mon/79/reference/ops-facts-health.json @@ -0,0 +1,281 @@ +{ + "name": "79 Ops facts + health (provided for Lab 02-03)", + "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": "fcc947d9-3c20-5ca9-a9f1-966a2ef67e09" + }, + { + "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": "2a61508c-26f2-511f-b5d0-4f2dee19a446" + }, + { + "name": "Get alerts", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/alerts", + "options": {} + }, + "position": [ + 440, + 300 + ], + "id": "720fe6aa-a30d-5d21-a1ad-fb66166923c2" + }, + { + "name": "Get DLQ", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/dlq", + "options": {} + }, + "position": [ + 660, + 300 + ], + "id": "05f59149-ec34-5045-97d1-238457326818" + }, + { + "name": "Get quality", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/quality-log", + "options": {} + }, + "position": [ + 880, + 300 + ], + "id": "1e25a884-a558-5e19-8776-978b058878cb" + }, + { + "name": "Get quarantine", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/quarantine", + "options": {} + }, + "position": [ + 1100, + 300 + ], + "id": "31851fd7-0d3f-5000-9ebc-349ffae0174b" + }, + { + "name": "Get usage", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/usage/summary", + "options": {} + }, + "position": [ + 1320, + 300 + ], + "id": "05b55404-3a20-5921-ae5f-83d3adfede60" + }, + { + "name": "Get budget", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/budget", + "options": {} + }, + "position": [ + 1540, + 300 + ], + "id": "a2a75df9-ba29-5a1a-8b7f-1c04a62a8fa6" + }, + { + "name": "Get release", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/releases/helpdesk", + "options": {} + }, + "position": [ + 1760, + 300 + ], + "id": "1097609c-e4f6-5332-8ffc-b31da541e5c6" + }, + { + "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": "f2c672e6-06db-5af2-9559-e0dd5ff7fa67" + }, + { + "name": "Send facts", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 2200, + 300 + ], + "id": "5fc1c214-e990-5522-9897-4644f07837a3" + } + ], + "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" + } +} diff --git a/Last3Mon/79/requests/empty-week.json b/Last3Mon/79/requests/empty-week.json new file mode 100644 index 0000000..8e90827 --- /dev/null +++ b/Last3Mon/79/requests/empty-week.json @@ -0,0 +1,4 @@ +{ + "from": "2026-11-02", + "to": "2026-11-06" +} diff --git a/Last3Mon/79/requests/monday.json b/Last3Mon/79/requests/monday.json new file mode 100644 index 0000000..33bfd85 --- /dev/null +++ b/Last3Mon/79/requests/monday.json @@ -0,0 +1,4 @@ +{ + "from": "2026-10-26", + "to": "2026-10-26" +} diff --git a/Last3Mon/79/requests/ops-golden-gate.json b/Last3Mon/79/requests/ops-golden-gate.json new file mode 100644 index 0000000..d73f99b --- /dev/null +++ b/Last3Mon/79/requests/ops-golden-gate.json @@ -0,0 +1,59 @@ +{ + "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 +} diff --git a/Last3Mon/79/requests/ops-golden.json b/Last3Mon/79/requests/ops-golden.json new file mode 100644 index 0000000..e2b06ee --- /dev/null +++ b/Last3Mon/79/requests/ops-golden.json @@ -0,0 +1,58 @@ +{ + "cases": [ + { + "question": "지난주 오류율이 얼마야?", + "area": "runs", + "numbers": [ + "5.88" + ] + }, + { + "question": "알림이 몇 건 와 있어?", + "area": "alerts", + "numbers": [ + "6" + ] + }, + { + "question": "DLQ에 parked 된 건 몇 건이야?", + "area": "dlq", + "numbers": [ + "2" + ] + }, + { + "question": "FAQ 배치 품질 점수 평균은?", + "area": "quality", + "numbers": [ + "0.6583", + "65.83", + "0.66" + ] + }, + { + "question": "오늘 LLM 비용이 예산의 몇 퍼센트야?", + "area": "cost", + "numbers": [ + "92" + ] + }, + { + "question": "카나리 버전 문제 있어?", + "area": "release", + "numbers": [ + "62.5" + ] + }, + { + "question": "DLQ 1번 다시 돌려 줘", + "area": "action", + "refused": true + }, + { + "question": "점심 메뉴 추천해 줘", + "area": "unknown", + "refused": true + } + ] +} diff --git a/Last3Mon/79/requests/two-weeks.json b/Last3Mon/79/requests/two-weeks.json new file mode 100644 index 0000000..1821eb8 --- /dev/null +++ b/Last3Mon/79/requests/two-weeks.json @@ -0,0 +1,4 @@ +{ + "from": "2026-10-19", + "to": "2026-10-30" +} diff --git a/Last3Mon/79/requests/week1.json b/Last3Mon/79/requests/week1.json new file mode 100644 index 0000000..0875857 --- /dev/null +++ b/Last3Mon/79/requests/week1.json @@ -0,0 +1,4 @@ +{ + "from": "2026-10-19", + "to": "2026-10-23" +} diff --git a/Last3Mon/79/requests/week2.json b/Last3Mon/79/requests/week2.json new file mode 100644 index 0000000..cb42c0d --- /dev/null +++ b/Last3Mon/79/requests/week2.json @@ -0,0 +1,4 @@ +{ + "from": "2026-10-26", + "to": "2026-10-30" +} diff --git a/Last3Mon/79/templates/OPS-ASSISTANT-TEMPLATE.md b/Last3Mon/79/templates/OPS-ASSISTANT-TEMPLATE.md new file mode 100644 index 0000000..5314cef --- /dev/null +++ b/Last3Mon/79/templates/OPS-ASSISTANT-TEMPLATE.md @@ -0,0 +1,77 @@ +# 운영 도우미 설계표 · IT 헬프데스크 (2026-11-04 아침) + +- 이름: +- 이 파일을 `my-work/OPS.md`로 복사해 채웁니다. 숫자는 `runs/`의 보고서나 `flow.py` 출력에서 옮깁니다. + +## 1. 출처 둘러보기 · 기준 대시보드 (Lab 01 예제 1) + +| 출처 | 어디서 (몇 일차) | `morning`에서 본 것 | 기준 대시보드(`base-ops-facts`)는 | 빠진 것 · 틀린 것 | +| --- | --- | --- | --- | --- | +| 실행 기록 | | | | | +| 알림 | | | | | +| DLQ | | | | | +| 품질 기록 · 격리함 | | | | | +| 사용량 · 예산 | | | | | +| 릴리스 | | | | | + +- 가장 먼저 볼 곳과 이유: + +## 2. 기간을 바꾸면 (Lab 01 예제 3) + +| 기간 | 실행 · 오류 | 그대로인 것 | `null`이 된 것 | +| --- | --- | --- | --- | +| 두 주 | | | | +| 하루 (10/26) | | | | +| 빈 주 (11/02~11/06) | | | | + +## 3. 상태 기준 (Lab 01 과제 1 · 2) + +| 영역 | 상태 | 그 숫자 | 기준 | 바꾸고 싶은 것 | +| --- | --- | --- | --- | --- | +| | | | | | + +- 출처가 멈춘 아침 (`fault usage error`): `cost` · `health.cost` · `overall` = + +## 4. 도우미가 하면 안 되는 것 (Lab 02 예제 1 · 2) + +| 질문 | 기준 챗봇의 답 | 무엇이 위험한가 | 내 도우미 | +| --- | --- | --- | --- | +| DLQ 1번 다시 돌려 줘 | | | | +| 점심 메뉴 추천해 줘 | | | | +| 카나리 버전 문제 있어? | | | | + +- 숫자 확인이 잡지 못하는 것: + +## 5. 질문을 바꾸면 · LLM 라우터 (Lab 02 예제 3 · 과제 1) + +| 질문 | 낱말 라우터 | LLM 라우터를 붙인 뒤 | 맞나 | +| --- | --- | --- | --- | +| | | | | + +## 6. 감사 기록 (Lab 02 과제 2) + +| 질문 | 기록의 `area` · `refused` · `reason` | 이 기록으로 알 수 있는 것 | +| --- | --- | --- | +| | | | + +## 7. 브리핑 (Lab 03 예제 1 · 2) + +- 먼저 볼 것 셋: +- LLM 요약이 한 것 · 하지 않은 것: + +## 8. 아침이 바뀌면 (Lab 03 예제 3) + +| 사람이 한 일 | 빨강 | 먼저 볼 것 | +| --- | --- | --- | +| (처음) | | | +| parked 둘 처리 | | | +| 카나리 끔 | | | +| 예산 올림 | | | + +## 9. 평가 · 관문 (Lab 03 과제 1 · 2) + +| 평가 | score | gate | 틀린 케이스 | +| --- | --- | --- | --- | +| 골든 8 | | | | + +- 평가가 남긴 알림이 다음 평가에 준 영향: diff --git a/Last3Mon/79/templates/TEST-REPORT-TEMPLATE.md b/Last3Mon/79/templates/TEST-REPORT-TEMPLATE.md new file mode 100644 index 0000000..3bddf24 --- /dev/null +++ b/Last3Mon/79/templates/TEST-REPORT-TEMPLATE.md @@ -0,0 +1,31 @@ +# 테스트 보고서 · Workflow Ops Assistant + +- 이름: +- 사용한 CLI 에이전트: codex / claude / agy +- 워크플로우 파일: `my-work/ops-facts.json`, `my-work/ops-ask.json`, `my-work/ops-brief.json`, `my-work/ops-eval.json` + +| 테스트 파일 | 결과 | +| --- | --- | +| `cases/lab01.json` (운영 사실) | Passed /5 | +| `cases/lab01-period.json` (기간을 바꾸면, 에이전트 없이) | Passed /5 | +| `cases/lab01-challenge.json` (영역별 상태) | Passed /5 | +| `cases/lab01-challenge2.json` (출처가 멈추면) | Passed /3 | +| `cases/lab02.json` (라우터 · 거절 · 숫자 확인) | Passed /6 | +| `cases/lab02-phrasing.json` (질문을 바꾸면, 에이전트 없이) | Passed /6 | +| `cases/lab02-challenge.json` (LLM 라우터) | Passed /5 | +| `cases/lab02-challenge2.json` (감사 기록) | Passed /5 | +| `cases/lab03.json` (브리핑) | Passed /4 | +| `cases/lab03-llm.json` (요약) | Passed /2 | +| `cases/lab03-after.json` (아침이 바뀌면, 에이전트 없이) | Passed /5 | +| `cases/lab03-challenge.json` (골든 질문 평가) | Passed /3 | +| `cases/lab03-challenge2.json` (평가 관문) | Passed /4 | + +## 실패와 고친 내용 + +| 실패한 케이스 | 처음 결과 | 내가 생각한 원인 | 에이전트가 고친 곳 | 고친 뒤 | +| --- | --- | --- | --- | --- | +| | | | | | + +## 도우미가 틀린 답 (숫자는 맞는데 뜻이 틀린 것 포함) + +- diff --git a/Last3Mon/79/tools/flow.py b/Last3Mon/79/tools/flow.py new file mode 100644 index 0000000..9536787 --- /dev/null +++ b/Last3Mon/79/tools/flow.py @@ -0,0 +1,1373 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# /// +"""Course helper: check, push and test the n8n workflows that a CLI coding agent wrote. + + uv run tools/flow.py doctor # is everything ready? + uv run tools/flow.py check my-work/helpdesk.json # find mistakes before n8n sees the file + uv run tools/flow.py push my-work/ops-facts.json # import + publish (one n8n restart) + uv run tools/flow.py off my-ops-facts # unpublish by file or webhook path + uv run tools/flow.py diff reference/baseline-ops-facts.json my-work/ops-facts.json [--full] # what changed, node by node + uv run tools/flow.py call my-ops-ask '{"question": "지난주 오류율이 얼마야?"}' [-v] + uv run tools/flow.py test cases/lab01.json [-v] [--webhook base-ops-facts] + uv run tools/flow.py morning [--reset] # the seeded operations morning at a glance · back to it (day 79) + uv run tools/flow.py quality # quarantine · quality log (day 78) + uv run tools/flow.py logs [--from 2026-10-26 --to 2026-10-26] [--last 10] # the run logs (day 77) + uv run tools/flow.py ops [--reset] # live logs · added FAQ · quarantine · DLQ · alerts · fault switch + uv run tools/flow.py bench my-cost requests/faq-vpn.json -n 10 # p50 · p95 · max · failures + uv run tools/flow.py usage [--clear] # recorded calls, tokens and cost by model / user / route + uv run tools/flow.py budget [--daily 0.001] [--per-min 2] [--reset] # the daily budget and per-user limit + uv run tools/flow.py cache [--clear] # the answer cache (Lab 02 task 2) + uv run tools/flow.py fault usage slow|error|ok # make the data API slow or failing (drills) + uv run tools/flow.py llm down|up # outage drill: a broken OpenAI key in n8n, then restore + +Every command takes -v (--verbose) for details: the request and response JSON of each call (call, test, bench, +fault, doctor), the nodes and links of a workflow (check, push), and the docker / n8n CLI commands that ran. + +Run it from the day folder (Last3Mon/79). n8n is the day 65 install (~/day65-n8n, Docker project +day65-n8n), started from Last3Mon/65/n8n. Environment overrides: + N8N_CONTAINER (default: the running n8n service of project day65-n8n, else day65-n8n-n8n-1) + N8N_HOME (default ~/day65-n8n), N8N_URL (default http://localhost:5678) +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import json +import math +import os +import re +import shutil +import subprocess +import sys +import time +import urllib.error +import urllib.request +import uuid +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +CACHE = Path(__file__).resolve().parent / ".cache" +def n8n_home() -> Path: + """Where the official n8n install lives: N8N_HOME, else Last3Mon/65/n8n/.env, else ~/day65-n8n.""" + if os.getenv("N8N_HOME"): + return Path(os.environ["N8N_HOME"]).expanduser() + env_file = ROOT.parent / "65" / "n8n" / ".env" + if env_file.exists(): + for line in env_file.read_text(encoding="utf-8").splitlines(): + if line.startswith("N8N_HOME="): + value = line.split("=", 1)[1].strip().strip("'\"") + if value: + return (env_file.parent / Path(value).expanduser()).resolve() + return Path.home() / "day65-n8n" + + +N8N_HOME = n8n_home() +START_HINT = "cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start (n8n install: ~/day65-n8n)" + + +def find_container() -> str: + """The n8n container of the day 65 project, whichever folder it was started from.""" + if os.getenv("N8N_CONTAINER"): + return os.environ["N8N_CONTAINER"] + if shutil.which("docker"): + result = subprocess.run(["docker", "ps", "-a", "--filter", "label=com.docker.compose.project=day65-n8n", + "--filter", "label=com.docker.compose.service=n8n", "--format", "{{.Names}}"], + capture_output=True, text=True) + names = result.stdout.split() + if names: + return names[0] + return "day65-n8n-n8n-1" + + +CONTAINER = find_container() +N8N_URL = os.getenv("N8N_URL", "http://localhost:5678").rstrip("/") + +# Versions written in AGENTS.md. Other versions still work if n8n knows them (warning only). +RECOMMENDED = { + "n8n-nodes-base.webhook": 2, + "n8n-nodes-base.respondToWebhook": 1.1, + "n8n-nodes-base.if": 2.2, + "n8n-nodes-base.switch": 3.2, + "n8n-nodes-base.set": 3.4, + "n8n-nodes-base.code": 2, + "n8n-nodes-base.httpRequest": 4.2, + "n8n-nodes-base.httpRequestTool": 4.2, + "@n8n/n8n-nodes-langchain.agent": 3.1, + "@n8n/n8n-nodes-langchain.lmChatOpenAi": 1.2, + "@n8n/n8n-nodes-langchain.vectorStoreInMemory": 1.3, + "@n8n/n8n-nodes-langchain.embeddingsOpenAi": 1.2, + "@n8n/n8n-nodes-langchain.documentDefaultDataLoader": 1.1, +} +FAULT_TARGETS = ("faq", "tickets", "prompts", "usage", "dlq") +NODE_SETTINGS = ("retryOnFail", "maxTries", "waitBetweenTries", "onError", "alwaysOutputData", "executeOnce") +USES_DATA_API = True # day 79: the seeded operations morning (logs · alerts · DLQ · quality · usage · budget · release) comes from the data API +AGENT_HOOKS: tuple[str, ...] = () # no ready-made agents on day 79 (base-ops-facts and base-ops-ask are pushed by hand) +COURSE_CREDENTIALS = {"openAiCourse0001": "OpenAI (.env)"} # OpenAI only from day 66 (no Ollama) +CONNECTION_TYPES = {"main", "ai_tool", "ai_languageModel", "ai_memory", "ai_outputParser", "ai_embedding", "ai_document"} +SECRET = re.compile(r"\bsk-[A-Za-z0-9_-]{16,}") + +OK, BAD, WARN = "✔", "✘", "!" +VERBOSE = False # -v on any command (set in main) + + +# ---------------------------------------------------------------- helpers +def die(message: str) -> None: + print(f"{BAD} {message}", file=sys.stderr) + sys.exit(1) + + +def docker(*args: str, stdin: str | None = None, check: bool = True) -> subprocess.CompletedProcess: + if not shutil.which("docker"): + die("docker command not found. Start Docker first.") + result = subprocess.run(["docker", *args], input=stdin, capture_output=True, text=True) + if VERBOSE: + show_command(args, result) + if check and result.returncode != 0: + die(f"docker {' '.join(args[:2])} failed:\n{(result.stderr or result.stdout).strip()}") + return result + + +def show_json(label: str, value: object) -> None: + """Print one JSON value under a label, indented below the result line (-v). API keys are masked.""" + text = json.dumps(value, ensure_ascii=False, indent=2) if not isinstance(value, str) else value + print(f" {label}") + for row in SECRET.sub("sk-***", text).splitlines(): + print(f" {row}") + + +def detail(label: str, value: object) -> None: + """show_json, only with -v.""" + if VERBOSE: + show_json(label, value) + + +def show_command(args: tuple[str, ...], result: subprocess.CompletedProcess) -> None: + """-v: one docker command and the end of its output. Data dumps (cat of an export) are not printed.""" + command = " ".join(arg if len(arg) <= 90 else arg[:87] + "..." for arg in args) + print(f" $ docker {command} → exit {result.returncode}") + if "cat /tmp" in " ".join(args): + return + rows = [row for row in (result.stdout + result.stderr).splitlines() if row.strip()] + for row in rows[-6:]: + print(f" {SECRET.sub('sk-***', row)[:160]}") + + +def n8n(*args: str, stdin: str | None = None, check: bool = True) -> subprocess.CompletedProcess: + return docker("exec", "-i", CONTAINER, "n8n", *args, stdin=stdin, check=check) + + +def container_running() -> bool: + if not shutil.which("docker"): + return False + result = subprocess.run(["docker", "inspect", "-f", "{{.State.Running}}", CONTAINER], capture_output=True, text=True) + return result.stdout.strip() == "true" + + +def http(method: str, url: str, body: object | None = None, timeout: float = 300) -> tuple[int, object, float]: + data = None if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8") + request = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json"}) + start = time.monotonic() + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + status, raw = response.status, response.read() + except urllib.error.HTTPError as error: + status, raw = error.code, error.read() + except (urllib.error.URLError, TimeoutError, ConnectionError) as error: + return 0, {"error": str(getattr(error, "reason", error))}, time.monotonic() - start + text = raw.decode("utf-8", errors="replace") + try: + payload: object = json.loads(text) if text.strip() else "" + except json.JSONDecodeError: + payload = text + return status, payload, time.monotonic() - start + + +def load_json(path: Path) -> object: + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + die(f"file not found: {path}") + except json.JSONDecodeError as error: + die(f"{path} is not valid JSON (line {error.lineno}, column {error.colno}): {error.msg}") + + +def resolve(path: str) -> Path: + candidate = Path(path) + if candidate.exists() or candidate.is_absolute(): + return candidate + return ROOT / path + + +def node_versions() -> dict[str, list[float]]: + """Node types and versions that this n8n really has (exported once, then cached).""" + cache = CACHE / "node-versions.json" + if cache.exists(): + return json.loads(cache.read_text()) + if not container_running(): + return {} + n8n("export:nodes", "--output=/tmp/course-nodes.json") + raw = docker("exec", CONTAINER, "sh", "-c", "cat /tmp/course-nodes.json && rm -f /tmp/course-nodes.json").stdout + versions: dict[str, set[float]] = {} + for node in json.loads(raw): + value = node.get("version") + versions.setdefault(node["name"], set()).update(value if isinstance(value, list) else [value]) + result = {name: sorted(values) for name, values in versions.items()} + CACHE.mkdir(exist_ok=True) + cache.write_text(json.dumps(result)) + return result + + +def walk_strings(value: object, where: str = ""): + if isinstance(value, str): + yield where, value + elif isinstance(value, dict): + for key, item in value.items(): + yield from walk_strings(item, f"{where}.{key}" if where else key) + elif isinstance(value, list): + for index, item in enumerate(value): + yield from walk_strings(item, f"{where}[{index}]") + + +def webhooks(workflow: dict) -> list[tuple[str, str]]: + found = [] + for node in workflow.get("nodes", []): + if node.get("type") == "n8n-nodes-base.webhook": + parameters = node.get("parameters", {}) + found.append((str(parameters.get("httpMethod", "GET")).upper(), str(parameters.get("path", "")).strip("/"))) + return found + + +# ---------------------------------------------------------------- check +def check_workflow(workflow: object) -> tuple[list[str], list[str]]: + errors: list[str] = [] + warnings: list[str] = [] + if not isinstance(workflow, dict): + return ["the file must be one workflow object { name, nodes, connections }"], warnings + if not str(workflow.get("name", "")).strip(): + errors.append("missing workflow 'name'") + nodes = workflow.get("nodes") + connections = workflow.get("connections") + if not isinstance(nodes, list) or not nodes: + return errors + ["'nodes' must be a non-empty list"], warnings + if not isinstance(connections, dict): + errors.append("'connections' must be an object") + connections = {} + + known = node_versions() + names: dict[str, dict] = {} + for index, node in enumerate(nodes): + label = f"node #{index + 1}" + if not isinstance(node, dict): + errors.append(f"{label} is not an object") + continue + name = node.get("name") + if not name: + errors.append(f"{label} has no 'name'") + continue + label = f"node '{name}'" + if name in names: + errors.append(f"{label} appears twice (node names must be unique)") + names[name] = node + node_type, version = node.get("type"), node.get("typeVersion") + if not node_type or version is None: + errors.append(f"{label} needs 'type' and 'typeVersion'") + elif known and node_type not in known: + errors.append(f"{label}: type '{node_type}' does not exist in this n8n") + elif known and version not in known[node_type]: + errors.append(f"{label}: {node_type} has no typeVersion {version} (available: {known[node_type]})") + elif node_type in RECOMMENDED and version != RECOMMENDED[node_type]: + warnings.append(f"{label}: typeVersion {version} (AGENTS.md uses {RECOMMENDED[node_type]})") + if not isinstance(node.get("parameters"), dict): + errors.append(f"{label} needs 'parameters' (use {{}} when empty)") + position = node.get("position") + if not (isinstance(position, list) and len(position) == 2): + errors.append(f"{label} needs 'position': [x, y]") + for kind, credential in (node.get("credentials") or {}).items(): + credential_id = (credential or {}).get("id") + if credential_id not in COURSE_CREDENTIALS: + errors.append(f"{label}: credential '{kind}' must use a course id {list(COURSE_CREDENTIALS)}, got {credential_id!r}") + for where, text in walk_strings(node.get("parameters", {})): + if SECRET.search(text): + errors.append(f"{label}: parameters.{where} looks like an API key. Use a credential instead.") + if "{{" in text and "}}" in text and not text.startswith("="): + warnings.append(f"{label}: parameters.{where} has {{{{ }}}} but does not start with '=' (it stays plain text)") + # day 75: OpenAI no longer serves gpt-4.1-nano, and gpt-5.4-nano refuses max_tokens over HTTP + if "gpt-4.1-nano" in text: + errors.append(f"{label}: parameters.{where} uses gpt-4.1-nano (OpenAI no longer serves it). Use gpt-5.4-nano.") + if "chat/completions" in str(node.get("parameters", {}).get("url", "")) and re.search(r"\bmax_tokens\b", text): + errors.append(f"{label}: parameters.{where} has max_tokens. Use max_completion_tokens (gpt-5.4-nano refuses max_tokens).") + + # day 76: node settings live next to "parameters", not inside it (n8n ignores them there) + misplaced = [key for key in NODE_SETTINGS if key in (node.get("parameters") or {})] + if misplaced: + errors.append(f"{label}: {', '.join(misplaced)} must be at the node level next to 'parameters', not inside it") + if node.get("retryOnFail") and not 1 < int(node.get("maxTries") or 3) <= 5: + errors.append(f"{label}: maxTries must be 2..5 with retryOnFail (n8n allows up to 5)") + if "onError" in node and node["onError"] not in ("stopWorkflow", "continueRegularOutput", "continueErrorOutput"): + errors.append(f"{label}: onError must be stopWorkflow, continueRegularOutput or continueErrorOutput") + + targets_of: dict[str, set[str]] = {} + for source, outputs in connections.items(): + if source not in names: + errors.append(f"connections: source '{source}' is not a node name") + continue + if not isinstance(outputs, dict): + errors.append(f"connections['{source}'] must be an object like {{\"main\": [[...]]}}") + continue + for kind, branches in outputs.items(): + if kind not in CONNECTION_TYPES: + errors.append(f"connections['{source}']: unknown connection type '{kind}'") + for branch in branches or []: + for link in branch or []: + target = (link or {}).get("node") + if target not in names: + errors.append(f"connections['{source}'] points to '{target}', which is not a node name") + elif link.get("type") != kind: + errors.append(f"connections['{source}'] -> '{target}': link type must be '{kind}'") + else: + targets_of.setdefault(target, set()).add(kind) + if kind != "main": + targets_of.setdefault(f"{source}::from", set()).add(kind) + + has_respond = any(node.get("type") == "n8n-nodes-base.respondToWebhook" for node in names.values()) + for name, node in names.items(): + node_type = node.get("type", "") + parameters = node.get("parameters", {}) if isinstance(node.get("parameters"), dict) else {} + if node_type == "n8n-nodes-base.webhook": + if not str(parameters.get("path", "")).strip("/"): + errors.append(f"node '{name}': webhook needs a 'path'") + if parameters.get("responseMode") == "responseNode" and not has_respond: + errors.append(f"node '{name}': responseMode is 'responseNode' but there is no Respond to Webhook node") + if node_type == "@n8n/n8n-nodes-langchain.agent" and "ai_languageModel" not in targets_of.get(name, set()): + errors.append(f"node '{name}': AI Agent needs a chat model connected with 'ai_languageModel'") + if node_type.endswith("Tool") or node_type.startswith("@n8n/n8n-nodes-langchain.tool"): + if "ai_tool" not in targets_of.get(f"{name}::from", set()): + errors.append(f"node '{name}': a tool must be connected to an AI Agent with 'ai_tool'") + if node_type.startswith("@n8n/n8n-nodes-langchain.lmChat"): + if "ai_languageModel" not in targets_of.get(f"{name}::from", set()): + errors.append(f"node '{name}': a chat model must be connected to an AI Agent with 'ai_languageModel'") + is_sub_node = bool(targets_of.get(f"{name}::from")) # model, tool, embeddings, loader: connected by ai_* links + if node_type != "n8n-nodes-base.webhook" and not node_type.startswith("@n8n/n8n-nodes-langchain.lm") and not is_sub_node \ + and not (node_type.endswith("Tool") or node_type.startswith("@n8n/n8n-nodes-langchain.tool")) \ + and "main" not in targets_of.get(name, set()) and "trigger" not in node_type.lower(): + warnings.append(f"node '{name}': nothing is connected into it (it never runs)") + error_workflow = (workflow.get("settings") or {}).get("errorWorkflow") + if error_workflow and not re.fullmatch(r"[A-Za-z0-9]{10,21}", str(error_workflow)): + errors.append(f"settings.errorWorkflow must be the id of the error workflow (e.g. errHandler76001), got {error_workflow!r}") + if any(node.get("type") == "n8n-nodes-base.errorTrigger" for node in names.values()) and not workflow.get("id"): + warnings.append("an error workflow needs a fixed top-level \"id\" (other workflows name it in settings.errorWorkflow)") + if not webhooks(workflow) and not any(node.get("type") == "n8n-nodes-base.errorTrigger" for node in names.values()): + warnings.append("no Webhook node: 'push' works, but 'call' and 'test' need a webhook path") + return errors, warnings + + +def show_workflow(workflow: object) -> None: + """-v: the nodes, links and webhooks of a workflow file.""" + if not VERBOSE or not isinstance(workflow, dict): + return + print(f" workflow '{workflow.get('name')}' · {len(workflow.get('nodes') or [])} node(s)") + for node in workflow.get("nodes") or []: + if isinstance(node, dict): + extra = [key for key in ("onError", "retryOnFail") if key in node] + print(f" {node.get('name')} · {node.get('type')} v{node.get('typeVersion')}" + (f" · {', '.join(extra)}" if extra else "")) + print(" links") + for source, outputs in (workflow.get("connections") or {}).items(): + for kind, branches in (outputs or {}).items(): + for index, branch in enumerate(branches or []): + targets = ", ".join(str((link or {}).get("node")) for link in branch or []) + if targets: + print(f" {source} --{kind}[{index}]--> {targets}") + for method, hook in webhooks(workflow): + print(f" webhook {method} /webhook/{hook}") + + +def cmd_check(args: argparse.Namespace) -> int: + path = resolve(args.file) + workflow = load_json(path) + show_workflow(workflow) + errors, warnings = check_workflow(workflow) + for warning in warnings: + print(f"{WARN} {warning}") + for error in errors: + print(f"{BAD} {error}") + if errors: + print(f"{BAD} {path.name}: {len(errors)} error(s). Fix them and run check again.") + return 1 + print(f"{OK} {path.name}: no errors" + (f" ({len(warnings)} warning(s))" if warnings else "")) + return 0 + + +# ---------------------------------------------------------------- push +def stable_id(path: Path) -> str: + return "w" + hashlib.sha1(path.stem.encode("utf-8")).hexdigest()[:15] + + +def wait_until_ready(paths: list[tuple[str, str]], seconds: int = 90) -> bool: + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + status, _, _ = http("GET", f"{N8N_URL}/healthz", timeout=3) + if status == 200: + break + time.sleep(1) + else: + return False + for method, path in paths: + while time.monotonic() < deadline: + # A webhook that is registered for POST answers a GET with a hint, not "is not registered". + status, payload, _ = http("GET" if method != "GET" else "HEAD", f"{N8N_URL}/webhook/{path}", timeout=5) + text = json.dumps(payload, ensure_ascii=False) + if "not registered for" in text or status not in (0, 404, 500, 502, 503): + break + time.sleep(1) + time.sleep(2) # webhooks of the other published workflows finish registering right after + return time.monotonic() < deadline + + +def exported_workflows() -> list[dict]: + exported = docker("exec", CONTAINER, "sh", "-c", + "n8n export:workflow --all --output=/tmp/course-all.json >/dev/null 2>&1; " + "cat /tmp/course-all.json 2>/dev/null; rm -f /tmp/course-all.json", check=False).stdout + try: + return json.loads(exported) if exported.strip() else [] + except json.JSONDecodeError: + return [] + + +def prepare(path: Path) -> dict | None: + workflow = load_json(path) + show_workflow(workflow) + errors, warnings = check_workflow(workflow) + for warning in warnings: + print(f"{WARN} {path.name}: {warning}") + if errors: + for error in errors: + print(f"{BAD} {path.name}: {error}") + return None + workflow = dict(workflow) + if not re.fullmatch(r"[A-Za-z0-9]{10,}", str(workflow.get("id", ""))): + workflow["id"] = stable_id(path) + workflow["active"] = False + workflow.setdefault("settings", {}).setdefault("executionOrder", "v1") + for node in workflow["nodes"]: + node.setdefault("id", str(uuid.uuid5(uuid.NAMESPACE_URL, f"{workflow['id']}/{node['name']}"))) + if node["type"] == "n8n-nodes-base.webhook": + node.setdefault("webhookId", str(uuid.uuid5(uuid.NAMESPACE_URL, f"{workflow['id']}/webhook/{node['name']}"))) + return workflow + + +def cmd_push(args: argparse.Namespace) -> int: + paths = [resolve(file) for file in args.files] + workflows = [prepare(path) for path in paths] + if None in workflows: + print(f"{BAD} not pushed. Fix the errors first (uv run tools/flow.py check ).") + return 1 + if not container_running(): + die(f"n8n container '{CONTAINER}' is not running. {START_HINT}") + mine_ids = {workflow["id"] for workflow in workflows} + hooks = {hook for workflow in workflows for hook in webhooks(workflow)} + + # Another published workflow with the same webhook path would block this one. + for other in exported_workflows(): + if other.get("id") not in mine_ids and other.get("active") and hooks & set(webhooks(other)): + n8n("unpublish:workflow", f"--id={other['id']}") + print(f"{WARN} unpublished '{other.get('name')}' ({other['id']}): it used the same webhook path") + + for workflow in workflows: + docker("exec", "-i", CONTAINER, "sh", "-c", + "cat > /tmp/course-workflow.json && n8n import:workflow --input=/tmp/course-workflow.json; " + "status=$?; rm -f /tmp/course-workflow.json; exit $status", + stdin=json.dumps(workflow, ensure_ascii=False)) + n8n("publish:workflow", f"--id={workflow['id']}") + # The CLI changes the database only; one restart makes the running n8n load the new versions (about 5 s). + docker("restart", CONTAINER) + ready = wait_until_ready(sorted(hooks)) + for workflow in workflows: + print(f"{OK} pushed '{workflow['name']}' (id {workflow['id']})") + for method, hook in sorted(webhooks(workflow)): + print(f" {method} {N8N_URL}/webhook/{hook}") + if not ready: + print(f"{WARN} n8n is still starting. Wait a few seconds before 'call' or 'test'.") + return 0 + + +def cmd_off(args: argparse.Namespace) -> int: + """Unpublish workflows, given as files or webhook paths, to see how the others behave when one role is down.""" + if not container_running(): + die(f"n8n container '{CONTAINER}' is not running. {START_HINT}") + exported = exported_workflows() + detail("published now", [f"{item.get('id')} · {item.get('name')} · " + ", ".join(f"/{hook}" for _, hook in webhooks(item)) + for item in exported if item.get("active")]) + wanted: dict[str, str] = {} + for item in args.files: + path = resolve(item) if (ROOT / item).exists() or Path(item).exists() else None + if path: + workflow = load_json(path) + workflow_id = workflow.get("id") if re.fullmatch(r"[A-Za-z0-9]{10,}", str(workflow.get("id", ""))) else stable_id(path) + wanted[workflow_id] = item + hooks = {hook for _, hook in webhooks(workflow)} + else: + hooks = {item.strip("/")} + for other in exported: + if other.get("active") and hooks & {hook for _, hook in webhooks(other)}: + wanted[other["id"]] = item + active = {item.get("id") for item in exported if item.get("active")} + for workflow_id, item in wanted.items(): + if workflow_id in active: + n8n("unpublish:workflow", f"--id={workflow_id}") + print(f"{OK} unpublished {item} (id {workflow_id})") + docker("restart", CONTAINER) + wait_until_ready([]) + print(f"{OK} n8n restarted. Turn it back on with: uv run tools/flow.py push ") + return 0 + + +# ---------------------------------------------------------------- call / test +def request_body(spec: str | dict, base: Path = ROOT) -> object: + if isinstance(spec, dict): + return spec + text = spec.strip() + if text.startswith("{"): + try: + return json.loads(text) + except json.JSONDecodeError as error: + die(f"request is not valid JSON: {error.msg}") + path = Path(text) + if not path.exists(): + path = base / text + return load_json(path) + + +def cmd_call(args: argparse.Namespace) -> int: + body = request_body(args.request) if args.request else {} + hook = args.path.strip('/') + status, payload, seconds = http("POST", f"{N8N_URL}/webhook/{hook}", body) + print(f"HTTP {status} · {seconds:.1f}s") + if args.verbose: + show_json(f"→ request POST /webhook/{hook}", body) + show_json(f"← response HTTP {status}", payload) + return 0 if 200 <= status < 300 else 1 + print(json.dumps(payload, ensure_ascii=False, indent=2) if not isinstance(payload, str) else payload) + return 0 if 200 <= status < 300 else 1 + + +def field(payload: object, dotted: str) -> object: + value = payload + for part in dotted.split("."): + if isinstance(value, dict): + value = value.get(part) + elif isinstance(value, list) and part.isdigit() and int(part) < len(value): + value = value[int(part)] + else: + return None + return value + + +def as_text(value: object) -> str: + return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False) + + +def judge(expect: dict, status: int, payload: object, seconds: float = 0.0) -> list[str]: + problems = [] + if "max_seconds" in expect and seconds > float(expect["max_seconds"]): + problems.append(f"took {seconds:.1f}s (expected at most {expect['max_seconds']}s)") + if status != expect.get("status", 200): + problems.append(f"status {status} (expected {expect.get('status', 200)})") + for name, wanted in (expect.get("equals") or {}).items(): + actual = field(payload, name) + if actual != wanted: + problems.append(f"{name} = {as_text(actual)[:60]} (expected {as_text(wanted)})") + for name, bounds in (expect.get("range") or {}).items(): + actual = field(payload, name) + low, high = bounds + if not isinstance(actual, (int, float)) or isinstance(actual, bool) or not low <= actual <= high: + problems.append(f"{name} = {as_text(actual)[:40]} (expected {low}..{high})") + for name, words in (expect.get("contains") or {}).items(): + words = [words] if isinstance(words, str) else words + text = as_text(field(payload, name) or "") + if not any(word in text for word in words): + problems.append(f"{name} has none of {words}") + for name, words in (expect.get("not_contains") or {}).items(): + words = [words] if isinstance(words, str) else words + text = as_text(field(payload, name) or "") + leaked = [word for word in words if word in text] + if leaked: + problems.append(f"{name} must not have {leaked}") + return problems + + +def cmd_test(args: argparse.Namespace) -> int: + path = resolve(args.cases) + suite = load_json(path) + hook = (args.webhook or suite.get("webhook", "")).strip("/") + cases = suite.get("cases", []) + if not hook or not cases: + die(f"{path.name} needs \"webhook\" and a non-empty \"cases\" list") + # "before"/"after": calls made once around the cases, e.g. turn a fault on and off again. + if not args.no_before: + for step in suite.get("before", []): + run_step("before", step) + try: + return run_cases(args, path, suite, hook, cases) + finally: + for step in suite.get("after", []): + run_step("after", step) + + +def run_step(label: str, step: dict) -> None: + """A step is {"webhook": path} (POST to n8n), {"url": ...} (POST to the data API, e.g. the fault switch) + or {"llm": "down"|"up"} (the outage drill) or {"sleep": 3} (wait, e.g. for an error workflow that runs late). + "status": 409 means that status is expected (e.g. a refused deploy).""" + if "sleep" in step: + print(f"… {label}: wait {step['sleep']}s") + time.sleep(float(step["sleep"])) + return + if step.get("llm") in ("down", "up"): + code = cmd_llm(argparse.Namespace(state=step["llm"])) + if code and label == "before": + die("could not change the OpenAI credential (llm drill)") + if step["llm"] == "down": + time.sleep(1) + return + if step.get("url"): + target = str(step["url"]) + else: + target = f"{N8N_URL}/webhook/{str(step.get('webhook', '')).strip('/')}" + body = request_body(step.get("body") or step.get("request", "{}")) + status, payload, seconds = http("POST", target, body) + shown = json.dumps(payload, ensure_ascii=False)[:120] if not isinstance(payload, str) else payload[:120] + good = status == step["status"] if "status" in step else 200 <= status < 300 + mark = OK if good else (WARN if step.get("allow_fail") else BAD) + note = " (allow_fail: rejected on purpose)" if not good and step.get("allow_fail") else "" + print(f"{mark} {label}: POST {target.replace(N8N_URL, '')} · HTTP {status} · {seconds:.1f}s{note} · {shown}") + detail(f"→ request POST {target.replace(N8N_URL, '')}", body) + detail(f"← response HTTP {status}", payload) + if not good and label == "before" and not step.get("allow_fail"): + die(f"the 'before' step failed ({target}). Is it pushed / is the data API the day 79 version?") + + +def parse_sets(items: list[str] | None) -> dict: + """--set release=1.1.0 --set no_cache=true → {"release": "1.1.0", "no_cache": true}""" + out: dict = {} + for item in items or []: + key, _, value = item.partition("=") + try: + out[key] = json.loads(value) + except json.JSONDecodeError: + out[key] = value + return out + + +def run_cases(args: argparse.Namespace, path: Path, suite: dict, hook: str, cases: list) -> int: + print(f"POST {N8N_URL}/webhook/{hook} · {len(cases)} case(s) × {args.repeat}") + results, passed = [], 0 + for case in cases: + for step in case.get("before", []): + run_step("before", step) + try: + passed += run_case(args, case, hook, results) + finally: + for step in case.get("after", []): + run_step("after", step) + return finish(path, hook, results, passed) + + +def run_case(args: argparse.Namespace, case: dict, hook: str, results: list) -> int: + passed = 0 + body = request_body(case.get("body") or case.get("request", "{}")) + if getattr(args, "set", None) and isinstance(body, dict): + body = {**body, **parse_sets(args.set)} + case_hook = (case.get("webhook") or hook).strip("/") if not args.webhook else hook + # "url": POST to that address instead of the webhook (e.g. check /usage/summary after the questions) + target = str(case["url"]) if case.get("url") else f"{N8N_URL}/webhook/{case_hook}" + shown = target.replace(N8N_URL, "") + for run in range(1, args.repeat + 1): + status, payload, seconds = http("POST", target, body) + problems = judge(case.get("expect", {}), status, payload, seconds) + passed += not problems + mark = OK if not problems else BAD + suffix = f" #{run}" if args.repeat > 1 else "" + print(f"{mark} {case.get('name', '?')}{suffix} · {seconds:.1f}s{cost_note(payload)}" + ("" if not problems else " · " + "; ".join(problems))) + if args.verbose: + show_json(f"→ request POST {shown}", body) + show_json(f"← response HTTP {status}", payload) + results.append({"case": case.get("name"), "webhook": case_hook if not case.get("url") else shown, "run": run, "passed": not problems, "problems": problems, + "seconds": round(seconds, 1), "status": status, "request": body, "response": payload}) + return passed + + +def cost_note(payload: object) -> str: + """' · 자동 답변 · 1 call · $0.000123' when the answer says how many LLM calls it made and what they cost (day 75).""" + if not isinstance(payload, dict) or not isinstance(payload.get("cost_usd"), (int, float)): + return "" + calls = payload.get("llm_calls") + route = f" · {payload['route']}" if isinstance(payload.get("route"), str) else "" + return route + (f" · {calls} call{'s' if calls != 1 else ''}" if isinstance(calls, int) else "") + f" · ${payload['cost_usd']:.6f}" + + +LAST_RESULTS: list = [] + + +def finish(path: Path, hook: str, results: list, passed: int) -> int: + LAST_RESULTS[:] = results + total = len(results) + print(f"Passed {passed}/{total}") + runs = ROOT / "runs" + runs.mkdir(exist_ok=True) + stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S") + report = runs / f"{path.stem}-{stamp}.json" + report.write_text(json.dumps({"cases": str(path.name), "webhook": hook, "passed": passed, "total": total, + "results": results}, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"report: {report.relative_to(ROOT)}") + return 0 if passed == total else 1 + + +# ---------------------------------------------------------------- gate / diff / release +def cmd_gate(args: argparse.Namespace) -> int: + """A release gate: run a test file and decide PASS/FAIL from the pass rate and p95 of the case times.""" + cmd_test(args) + results = LAST_RESULTS + total = len(results) + passed = sum(1 for item in results if item["passed"]) + times = [item["seconds"] for item in results if item.get("status") == 200] + p95 = percentile(times, 0.95) if times else 0.0 + rate = passed / total if total else 0.0 + reasons = [] + if rate < args.min_pass: + reasons.append(f"pass rate {rate:.0%} < {args.min_pass:.0%}") + if args.max_p95 is not None and p95 > args.max_p95: + reasons.append(f"p95 {p95:.1f}s > {args.max_p95}s") + sets = parse_sets(args.set) + label = ", ".join(f"{k}={v}" for k, v in sets.items()) or "as is" + print(f"gate [{label}]: pass {passed}/{total} ({rate:.0%}) · p95 {p95:.1f}s → " + + ("PASS" if not reasons else "FAIL · " + "; ".join(reasons))) + if sets.get("release") and not args.no_record: + record = {"release": str(sets["release"]), "verdict": "rollback" if reasons else "promote", "passed": passed, + "total": total, "p95": round(p95, 2), "cases": Path(args.cases).name, "source": "flow.py gate"} + status, payload, _ = http("POST", f"http://localhost:8800/gates/{args.name}", record, timeout=10) + print(f"{OK if status == 200 else WARN} gate record → /gates/{args.name}: {record['release']} {record['verdict']}" + + ("" if status == 200 else f" (not stored: HTTP {status})")) + detail(f"← response HTTP {status}", payload) + return 0 if not reasons else 1 + + +def flat(value: object, prefix: str = "") -> dict: + if isinstance(value, dict): + out: dict = {} + for key, item in value.items(): + out.update(flat(item, f"{prefix}.{key}" if prefix else str(key))) + return out + if isinstance(value, list): + out = {} + for index, item in enumerate(value): + out.update(flat(item, f"{prefix}[{index}]")) + return out or {prefix: []} + return {prefix: value} + + +def cmd_diff(args: argparse.Namespace) -> int: + """Node-by-node difference of two workflow files: added/removed nodes, changed settings, links.""" + old, new = load_json(resolve(args.old)), load_json(resolve(args.new)) + old_nodes = {node["name"]: node for node in old.get("nodes", [])} + new_nodes = {node["name"]: node for node in new.get("nodes", [])} + print(f"{old.get('name', args.old)} → {new.get('name', args.new)}") + changed = 0 + for name in sorted(set(new_nodes) - set(old_nodes)): + changed += 1 + print(f"+ node '{name}' ({new_nodes[name].get('type', '?').split('.')[-1]})") + for name in sorted(set(old_nodes) - set(new_nodes)): + changed += 1 + print(f"- node '{name}' ({old_nodes[name].get('type', '?').split('.')[-1]})") + skip = ("position", "id", "webhookId") + for name in sorted(set(old_nodes) & set(new_nodes)): + a = flat({k: v for k, v in old_nodes[name].items() if k not in skip}) + b = flat({k: v for k, v in new_nodes[name].items() if k not in skip}) + for key in sorted(set(a) | set(b)): + if a.get(key) == b.get(key): + continue + changed += 1 + before, after = as_text(a.get(key, "(none)")), as_text(b.get(key, "(none)")) + if len(before) > 70 or len(after) > 70: + print(f"~ '{name}' {key}: changed ({len(before)} → {len(after)} chars)") + if args.full or args.verbose: + print(f" before: {before}\n after: {after}") + else: + print(f"~ '{name}' {key}: {before} → {after}") + + def links(workflow: dict) -> set: + return {(src, kind, dst["node"]) for src, kinds in workflow.get("connections", {}).items() + for kind, outs in kinds.items() for out in outs for dst in out} + + for src, kind, dst in sorted(links(new) - links(old)): + changed += 1 + print(f"+ link {src} → {dst} ({kind})") + for src, kind, dst in sorted(links(old) - links(new)): + changed += 1 + print(f"- link {src} → {dst} ({kind})") + print(f"{changed} change(s)") + return 0 + + +def bucket(user: str) -> int: + """Same rule as AGENTS.md: sum of the character codes of the user name, modulo 100.""" + return sum(ord(ch) for ch in user) % 100 + + +def cmd_release(args: argparse.Namespace) -> int: + url = f"http://localhost:8800/releases/{args.name}" + if args.reset: + status, payload, _ = http("POST", url + "/reset", {}, timeout=10) + elif args.require_gate: + status, payload, _ = http("POST", url + "/policy", {"require_gate": args.require_gate == "on"}, timeout=10) + elif args.version or args.canary or args.no_canary: + body: dict = {} + if args.version: + body["active"] = args.version + if args.canary: + body["canary"] = {"release": args.canary, "percent": args.percent} + if args.no_canary: + body["canary"] = None + status, payload, _ = http("POST", url, body, timeout=10) + else: + status, payload, _ = http("GET", url, timeout=10) + detail(f"← response HTTP {status}", payload) + if status == 409 and isinstance(payload, dict): + gate = payload.get("last_gate") + print(f"{BAD} refused: {payload.get('release')} has no passing gate record" + + (f" (last gate: {gate.get('verdict')} {gate.get('passed')}/{gate.get('total')} · {gate.get('source')})" if gate else " (never gated)") + + "\n → run the gate (flow.py gate ... --set release=) or my-release-check first") + return 1 + if status != 200 or not isinstance(payload, dict): + print(f"{BAD} {as_text(payload)}") + return 1 + _, gates, _ = http("GET", f"http://localhost:8800/gates/{args.name}", timeout=10) + latest = {} + for record in (gates.get("gates", []) if isinstance(gates, dict) else []): + latest[record.get("release")] = record + canary = payload.get("canary") + print(f"{OK} {args.name}: active {payload['active']}" + + (f" · canary {canary['release']} {canary['percent']}%" if canary else " · no canary") + + (" · deploy needs a passing gate" if payload.get("require_gate") else "")) + for version, info in sorted(payload.get("releases", {}).items()): + mark = "*" if version == payload["active"] else ("c" if canary and version == canary["release"] else " ") + gate = latest.get(version) + seen = f" gate {gate['verdict']} {gate.get('passed')}/{gate.get('total')}" if gate else "" + print(f" {mark} {version} prompt {info.get('prompt_version')} model {info.get('model')} {info.get('note', '')}{seen}") + if canary: + names = ["이준호", "박서연", "최유진", "한지훈", "김민지", "정우성"] + print(" canary users: " + (", ".join(f"{n}({bucket(n)})" for n in names if bucket(n) < canary["percent"]) or "none")) + return 0 + + +# ---------------------------------------------------------------- bench / fault / llm +def percentile(values: list[float], share: float) -> float: + ordered = sorted(values) + if not ordered: + return 0.0 + # nearest rank, as in the lecture: position = ceil(share × n), counted from 1 (10 runs: p50 = 5th, p95 = 10th) + index = min(len(ordered) - 1, max(0, math.ceil(share * len(ordered) - 1e-9) - 1)) + return ordered[index] + + +# ---------------------------------------------------------------- bench / fault / llm +def percentile(values: list[float], share: float) -> float: + ordered = sorted(values) + if not ordered: + return 0.0 + # nearest rank, as in the lecture: position = ceil(share × n), counted from 1 (10 runs: p50 = 5th, p95 = 10th) + index = min(len(ordered) - 1, max(0, math.ceil(share * len(ordered) - 1e-9) - 1)) + return ordered[index] + + +def cmd_bench(args: argparse.Namespace) -> int: + body = request_body(args.request) if args.request else {} + if args.set and isinstance(body, dict): + body = {**body, **parse_sets(args.set)} + hook = args.path.strip("/") + print(f"POST {N8N_URL}/webhook/{hook} × {args.n}") + detail(f"→ request POST /webhook/{hook} (the same body every time)", body) + seconds_list, failures, rows = [], 0, [] + for run in range(1, args.n + 1): + status, payload, seconds = http("POST", f"{N8N_URL}/webhook/{hook}", body) + good = 200 <= status < 300 + failures += not good + seconds_list.append(seconds) + extra = "" + if isinstance(payload, dict): + extra = " · " + ", ".join(f"{key}={as_text(payload[key])[:40]}" for key in ("route", "llm_calls", "cached") if key in payload) + if isinstance(payload.get("version"), dict): + extra += f", release={payload['version'].get('release')}" + print(f"{OK if good else BAD} #{run} · HTTP {status} · {seconds:.2f}s{extra}") + detail(f"← response HTTP {status}", payload) + rows.append({"run": run, "status": status, "seconds": round(seconds, 3), "response": payload}) + p50, p95 = percentile(seconds_list, 0.50), percentile(seconds_list, 0.95) + print(f"p50 {p50:.2f}s · p95 {p95:.2f}s · max {max(seconds_list):.2f}s · failures {failures}/{args.n}") + if VERBOSE: + print(" sorted " + " ".join(f"{value:.2f}" for value in sorted(seconds_list))) + steps: dict[str, list[float]] = {} + for row in rows: + timings = row["response"].get("timings") if isinstance(row["response"], dict) else None + for key, value in (timings or {}).items(): + if isinstance(value, (int, float)): + steps.setdefault(key, []).append(value) + if steps: + print(" timings (average ms, runs) " + " · ".join(f"{key} {sum(values) / len(values):.0f} ({len(values)})" + for key, values in steps.items())) + runs = ROOT / "runs" + runs.mkdir(exist_ok=True) + report = runs / f"bench-{hook}-{dt.datetime.now().strftime('%Y%m%d-%H%M%S')}.json" + report.write_text(json.dumps({"webhook": hook, "request": body, "n": args.n, "p50": round(p50, 3), "p95": round(p95, 3), + "max": round(max(seconds_list), 3), "failures": failures, "runs": rows}, + ensure_ascii=False, indent=2), encoding="utf-8") + print(f"report: {report.relative_to(ROOT)}") + return 0 if not failures else 1 + + +# ---------------------------------------------------------------- logs (day 77) +def cmd_logs(args: argparse.Namespace) -> int: + body = {"from": args.date_from or "", "to": args.date_to or "", "tag": args.tag or "", "source": args.source} + status, payload, seconds = http("POST", "http://localhost:8800/logs/query", body, timeout=20) + detail(f"→ POST http://localhost:8800/logs/query · HTTP {status} · {seconds:.2f}s", body) + if status != 200 or not isinstance(payload, dict): + print(f"{BAD} {as_text(payload)} (is the data API the day 79 version? uv run tools/flow.py doctor)") + return 1 + rows = payload["logs"] + errors = sum(1 for r in rows if r.get("status") == "error") + print(f"{len(rows)} run(s) · errors {errors}" + (f" ({errors / len(rows):.1%})" if rows else "")) + routes: dict[str, int] = {} + for row in rows: + key = str(row.get("route") or "오류") + routes[key] = routes.get(key, 0) + 1 + if routes: + print(" route: " + " · ".join(f"{k} {v}" for k, v in sorted(routes.items(), key=lambda kv: -kv[1]))) + for row in rows[-args.last:] if args.last else []: + print(f" {row.get('started_at')} {str(row.get('status')):<7} {str(row.get('route')):<6} {row.get('duration_ms'):>5}ms " + f"{row.get('topic')} {str(row.get('error_node') or '')} {str(row.get('question'))[:24]}") + if rows: + detail("← last run (all fields)", rows[-1]) + return 0 + + +# ---------------------------------------------------------------- quality (day 78) +QUALITY_RESET = ("/faq-reset", "/quarantine-clear", "/quality-log-clear", "/alerts-clear") + + +def cmd_quality(args: argparse.Namespace) -> int: + base = "http://localhost:8800" + if args.reset: + ok = True + for path in QUALITY_RESET: + status, payload, seconds = http("POST", base + path, {}, timeout=10) + detail(f"→ POST {base + path} · HTTP {status} · {seconds:.2f}s", payload) + ok = ok and status == 200 + print(f"{OK if ok else BAD} added FAQ, quarantine, quality log and alerts emptied") + return 0 if ok else 1 + _, faqs, _ = http("GET", base + "/faq-all", timeout=10) + _, quarantine, _ = http("GET", base + "/quarantine", timeout=10) + _, log, _ = http("GET", base + "/quality-log", timeout=10) + _, alerts, _ = http("GET", base + "/alerts", timeout=10) + if not all(isinstance(x, dict) for x in (faqs, quarantine, log, alerts)): + print(f"{BAD} data API did not answer: is it the day 79 version? (uv run tools/flow.py doctor)") + return 1 + added = [str(f.get("id")) for f in faqs.get("faqs", [])[6:]] + print(f"FAQ: {faqs.get('count')} ({', '.join(added) if added else 'nothing added'})") + print(f"quarantine: {quarantine.get('size')}") + for item in quarantine.get("items", [])[-8:]: + print(f" {item.get('batch')} {item.get('row_id')} {', '.join(map(str, item.get('errors') or []))[:70]}") + print(f"quality log: {log.get('count')}") + for item in log.get("items", [])[-6:]: + print(f" {item.get('batch')} {item.get('dataset', '-')} total {item.get('total')} valid {item.get('valid')} " + f"score {item.get('score')} rejected {item.get('rejected')}") + print(f"alerts: {alerts.get('count')}") + for item in alerts.get("alerts", [])[-3:]: + print(f" [{item.get('severity')}] {item.get('workflow')} · {item.get('reason')} · {str(item.get('message'))[:60]}") + detail("← GET /quarantine", quarantine) + detail("← GET /quality-log", log) + return 0 + + +# ---------------------------------------------------------------- morning (day 79) +def cmd_morning(args: argparse.Namespace) -> int: + base = "http://localhost:8800" + if args.reset: + status, info, seconds = http("POST", base + "/ops-reset", {}, timeout=10) + detail(f"→ POST {base}/ops-reset · HTTP {status} · {seconds:.2f}s", info) + if status != 200 or not isinstance(info, dict): + print(f"{BAD} {as_text(info)} (is the data API the day 79 version? uv run tools/flow.py doctor)") + return 1 + print(f"{OK} back to the seeded morning {info.get('seeded_at')}: {as_text(info.get('counts'))}") + return 0 + _, info, _ = http("GET", base + "/ops/seed-info", timeout=10) + _, alerts, _ = http("GET", base + "/alerts", timeout=10) + _, dlq, _ = http("GET", base + "/dlq", timeout=10) + _, qlog, _ = http("GET", base + "/quality-log", timeout=10) + _, quarantine, _ = http("GET", base + "/quarantine", timeout=10) + _, usage, _ = http("GET", base + "/usage/summary", timeout=10) + _, budget, _ = http("GET", base + "/budget", timeout=10) + _, release, _ = http("GET", base + "/releases/helpdesk", timeout=10) + if not isinstance(info, dict) or "seeded_at" not in info: + print(f"{BAD} no /ops/seed-info: is the data API the day 79 version? (uv run tools/flow.py doctor)") + return 1 + print(f"seeded morning {info['seeded_at']} · {as_text(info['counts'])}") + if isinstance(alerts, dict): + high = sum(1 for a in alerts.get("alerts", []) if a.get("severity") == "high") + print(f"alerts: {alerts.get('count')} (high {high})") + for item in alerts.get("alerts", [])[-6:]: + print(f" [{item.get('severity', '-')}] {item.get('workflow')}/{item.get('node')} · {item.get('reason')} · {str(item.get('message'))[:50]}") + if isinstance(dlq, dict): + print(f"DLQ: {dlq.get('size')} (parked {dlq.get('parked', 0)})") + for item in dlq.get("items", []): + print(f" #{item.get('dlq_id')} attempts {item.get('attempts')} parked {item.get('parked')} {item.get('reason')}") + if isinstance(qlog, dict) and isinstance(quarantine, dict): + scores = [i.get("score") for i in qlog.get("items", [])] + print(f"quality log: {qlog.get('count')} (scores {scores}) · quarantine {quarantine.get('size')}") + if isinstance(usage, dict) and "cost_usd" in usage: + print(f"usage: {usage.get('requests')} requests · {usage.get('calls')} calls · ${usage.get('cost_usd')}") + elif usage is not None: + print(f"{WARN} usage summary: {as_text(usage)[:80]}") + if isinstance(budget, dict): + print(f"budget: used ${budget.get('used_usd')} of ${budget.get('daily_usd')}") + if isinstance(release, dict): + print(f"release: active {release.get('active')} · canary {as_text(release.get('canary'))}") + detail("← GET /alerts", alerts) + detail("← GET /dlq", dlq) + return 0 + + +# ---------------------------------------------------------------- ops (day 76 · 77 · 78 · 79) +def cmd_ops(args: argparse.Namespace) -> int: + base = "http://localhost:8800" + if args.reset: + for path in ("/ops-reset",): + status, payload, seconds = http("POST", base + path, {}, timeout=10) + detail(f"→ POST {base + path} · HTTP {status} · {seconds:.2f}s", payload) + body = {target: "ok" for target in FAULT_TARGETS} + status, payload, _ = http("POST", base + "/admin/fault", body, timeout=10) + detail(f"→ POST {base}/admin/fault · HTTP {status}", payload) + print(f"{OK if status == 200 else BAD} back to the seeded operations morning (alerts · DLQ · quality · usage · budget · release), live logs · added FAQ · tickets emptied, fault switch all ok") + return 0 if status == 200 else 1 + _, created, _ = http("GET", base + "/tickets-created", timeout=10) + _, dlq, _ = http("GET", base + "/dlq", timeout=10) + _, alerts, _ = http("GET", base + "/alerts", timeout=10) + _, fault, _ = http("GET", base + "/admin/fault", timeout=10) + if not all(isinstance(x, dict) for x in (created, dlq, alerts, fault)): + print(f"{BAD} data API did not answer: is it the day 79 version? (uv run tools/flow.py doctor)") + return 1 + on = {key: value for key, value in fault.items() if key in FAULT_TARGETS and value != "ok"} + if "slow" in on.values(): + on["slow_seconds"] = fault.get("slow_seconds") + print(f"fault switch: {'all ok' if not on else as_text(on)}") + _, live, _ = http("GET", base + "/logs?source=live", timeout=10) + _, extra, _ = http("GET", base + "/faq-extra", timeout=10) + if isinstance(live, dict): + tags: dict[str, int] = {} + for row in live.get("logs", []): + tags[str(row.get("tag") or "-")] = tags.get(str(row.get("tag") or "-"), 0) + 1 + print(f"live run logs: {live.get('count')}" + (" (" + " · ".join(f"tag {k} {v}" for k, v in tags.items()) + ")" if tags else "")) + if isinstance(extra, dict): + print(f"added FAQ: {extra.get('count')}" + (" (" + ", ".join(str(f.get("id")) for f in extra.get("faq", [])) + ")" if extra.get("count") else "")) + _, quarantine, _ = http("GET", base + "/quarantine", timeout=10) + _, qlog, _ = http("GET", base + "/quality-log", timeout=10) + if isinstance(quarantine, dict) and isinstance(qlog, dict): + print(f"quarantine: {quarantine.get('size')} · quality log: {qlog.get('count')} (details: uv run tools/flow.py quality)") + print(f"created tickets: {created.get('count')}") + for item in created.get("tickets", [])[-5:]: + print(f" {item.get('ticket_id')} key {item.get('key')} {item.get('category')}/{item.get('priority')} {str(item.get('title'))[:30]}") + print(f"DLQ: {dlq.get('size')} (parked {dlq.get('parked', 0)})") + for item in dlq.get("items", [])[-5:]: + print(f" {item.get('dlq_id')} {(item.get('item') or {}).get('id')} attempts {item.get('attempts')} " + f"parked {item.get('parked')} {str(item.get('reason'))[:50]}") + print(f"alerts: {alerts.get('count')}") + for item in alerts.get("alerts", [])[-3:]: + extra = f" · count {item['count']}" if "count" in item else "" + print(f" [{item.get('severity')}] {item.get('workflow')} · {item.get('reason')} · {item.get('node', '-')}{extra} · {str(item.get('message'))[:60]}") + detail("← GET /dlq", dlq) + detail("← GET /alerts", alerts) + return 0 + + +# ---------------------------------------------------------------- usage / budget (day 75) +def cmd_usage(args: argparse.Namespace) -> int: + if args.clear: + status, payload, _ = http("POST", "http://localhost:8800/usage-clear", {}, timeout=10) + print(f"{OK if status == 200 else BAD} usage cleared: {as_text(payload)}") + return 0 if status == 200 else 1 + status, payload, seconds = http("GET", "http://localhost:8800/usage/summary", timeout=10) + detail(f"← GET /usage/summary · HTTP {status} · {seconds:.2f}s", payload) + if status != 200 or not isinstance(payload, dict): + print(f"{BAD} {as_text(payload)}") + return 1 + print(f"requests {payload['requests']} · calls {payload['calls']} · tokens in {payload['prompt_tokens']}" + f" / out {payload['completion_tokens']} · ${payload['cost_usd']:.6f}") + for title in ("by_model", "by_user", "by_route"): + for key, row in sorted(payload.get(title, {}).items(), key=lambda kv: -kv[1]["cost_usd"]): + print(f" {title[3:]:<6} {key:<14} {row['calls']:>4} calls · in {row['prompt_tokens']:>7}" + f" · out {row['completion_tokens']:>6} · ${row['cost_usd']:.6f}") + show_budget() + return 0 + + +def show_budget() -> dict: + status, b, seconds = http("GET", "http://localhost:8800/budget", timeout=10) + detail(f"← GET /budget · HTTP {status} · {seconds:.2f}s", b) + if isinstance(b, dict) and "daily_usd" in b: + print(f"budget ${b['daily_usd']} / used ${b['used_usd']:.6f} · remaining ${b['remaining_usd']:.6f} · over {b['over']}" + f" · per user {b.get('user_limit_per_min')}/min · question {b.get('max_question_chars')} chars") + return b + print(f"{BAD} budget: {as_text(b)}") + return {} + + +def cmd_budget(args: argparse.Namespace) -> int: + """Show or change the daily budget, the per-user limit and the question length limit (people decide these).""" + body = {key: value for key, value in (("daily_usd", args.daily), ("user_limit_per_min", args.per_min), + ("max_question_chars", args.chars)) if value is not None} + if args.reset or body: + target = "http://localhost:8800/budget/reset" if args.reset else "http://localhost:8800/budget" + status, payload, seconds = http("POST", target, body, timeout=10) + detail(f"→ request POST {target}", body) + detail(f"← response HTTP {status} · {seconds:.2f}s", payload) + if status != 200: + print(f"{BAD} {as_text(payload)}") + return 1 + print(f"{OK} " + ("budget back to data/budget.json" if args.reset else f"budget changed: {as_text(body)}")) + return 0 if show_budget() else 1 + + + + +def cmd_fault(args: argparse.Namespace) -> int: + targets = list(FAULT_TARGETS) if args.target == "all" else [args.target] + body: dict = {target: args.mode for target in targets} + if args.seconds is not None: + body["slow_seconds"] = args.seconds + status, payload, seconds = http("POST", "http://localhost:8800/admin/fault", body, timeout=10) + print(f"{OK if status == 200 else BAD} fault switch: {as_text(payload)}") + detail("→ request POST http://localhost:8800/admin/fault", body) + detail(f"← response HTTP {status} · {seconds:.2f}s", payload) + return 0 if status == 200 else 1 + + +def cmd_llm(args: argparse.Namespace) -> int: + """Outage drill: register the course OpenAI credential with a broken key (down) or the real key from .env (up).""" + script = ROOT.parent / "65" / "n8n" / "register-credentials.sh" + if not script.exists(): + die(f"{script} not found") + env = dict(os.environ, N8N_CONTAINER=CONTAINER) + if args.state == "down": + env["OPENAI_API_KEY"] = "sk-invalid-key-for-drill-000000" + else: + env.pop("OPENAI_API_KEY", None) + result = subprocess.run(["bash", str(script)], env=env, capture_output=True, text=True) + detail(f"$ bash {script.relative_to(ROOT.parent)} → exit {result.returncode}", (result.stdout + result.stderr).strip()) + if result.returncode != 0: + print(result.stderr.strip() or result.stdout.strip()) + return 1 + print(f"{OK} OpenAI credential: " + ("broken key (drill). Restore with: uv run tools/flow.py llm up" if args.state == "down" + else "real key from .env restored")) + return 0 + + +# ---------------------------------------------------------------- doctor +def cmd_doctor(args: argparse.Namespace) -> int: + failures = 0 + + def line(good: bool, text: str, hint: str = "", seen: object = None) -> None: + nonlocal failures + failures += not good + print(f"{OK if good else BAD} {text}" + ("" if good or not hint else f"\n → {hint}")) + if seen is not None: + detail("seen", seen) + + installed = (N8N_HOME / "compose.yml").exists() + print(f"{OK if installed else WARN} n8n install folder {N8N_HOME}" + ("" if installed else + "\n → day 65 SETUP-N8N 5단계 (get.n8n.io 설치). 다른 곳에 설치했다면 Last3Mon/65/n8n/.env 에 N8N_HOME=<폴더>")) + running = container_running() + line(running, f"n8n container {CONTAINER}", START_HINT) + status, payload, _ = http("GET", f"{N8N_URL}/healthz", timeout=5) + line(status == 200, f"n8n {N8N_URL}", "wait 10 seconds after starting n8n, then run doctor again", + {"GET": "/healthz", "status": status, "response": payload}) + status, payload, _ = http("GET", "http://localhost:8800/health", timeout=5) + if USES_DATA_API: + line(status == 200, "data API http://localhost:8800", "cd ~/aio-01d4-stu/Last3Mon/79/data-api && docker compose up -d --force-recreate (start n8n first)", + {"GET": "/health", "status": status, "response": payload}) + fault_status, fault, _ = http("GET", "http://localhost:8800/admin/fault", timeout=5) + price_status, prices, _ = http("GET", "http://localhost:8800/prices", timeout=5) + budget_status, b, _ = http("GET", "http://localhost:8800/budget", timeout=5) + models = prices.get("models", {}) if isinstance(prices, dict) else {} + dlq_status, dlq, _ = http("GET", "http://localhost:8800/dlq", timeout=5) + line(price_status == 200 and budget_status == 200 and "gpt-5.4-nano" in models and isinstance(b, dict) + and "max_question_chars" in b and dlq_status == 200 and isinstance(dlq, dict) and "parked" in dlq + and isinstance(fault, dict) and "dlq" in fault, + "dead letter queue /dlq · alerts · fault switch dlq (day 76 data API)", + {"GET": "/prices", "status": price_status, "response": prices, "budget": b, "dlq": dlq, "fault": fault}) + logs_status, logs, _ = http("GET", "http://localhost:8800/logs?source=seed&limit=1", timeout=5) + extra_status, extra, _ = http("GET", "http://localhost:8800/faq-extra", timeout=5) + line(logs_status == 200 and isinstance(logs, dict) and logs.get("count") == 666 and extra_status == 200, + "run logs /logs (666 seeded runs) · FAQ additions /faq-extra (day 77 data API)", + "the data API is an older day's version: cd ~/aio-01d4-stu/Last3Mon/79/data-api && docker compose up -d --force-recreate && sleep 3", + {"GET": "/logs", "status": logs_status, "count": logs.get("count") if isinstance(logs, dict) else None, "faq_extra": extra}) + live_status, live, _ = http("GET", "http://localhost:8800/logs?source=live", timeout=5) + if isinstance(live, dict) and live.get("count"): + print(f"{WARN} {live.get('count')} live run log(s) left from an earlier run\n → uv run tools/flow.py ops --reset") + schema_status, schemas, _ = http("GET", "http://localhost:8800/schemas", timeout=5) + q_status, quarantine, _ = http("GET", "http://localhost:8800/quarantine", timeout=5) + log_status, qlog, _ = http("GET", "http://localhost:8800/quality-log", timeout=5) + line(schema_status == 200 and isinstance(schemas, dict) and {"faq", "employee", "ticket"} <= set(schemas.get("schemas", [])) + and q_status == 200 and log_status == 200, + "schemas faq · employee · ticket, quarantine, quality log (day 78 data API)", + "the data API is an older day's version: cd ~/aio-01d4-stu/Last3Mon/79/data-api && docker compose up -d --force-recreate && sleep 3", + {"GET": "/schemas", "status": schema_status, "response": schemas, "quarantine": q_status, "quality_log": log_status}) + seed_status, seed_info, _ = http("GET", "http://localhost:8800/ops/seed-info", timeout=5) + seed = load_json(ROOT / "data-api" / "data" / "ops-seed.json") + line(seed_status == 200 and isinstance(seed_info, dict) and "seeded_at" in seed_info, + "operations morning /ops/seed-info (day 79 data API)", + "the data API is an older day's version: cd ~/aio-01d4-stu/Last3Mon/79/data-api && docker compose up -d --force-recreate && sleep 3", + {"GET": "/ops/seed-info", "status": seed_status, "response": seed_info}) + want = {"alerts": len(seed["alerts"]), "dlq": len(seed["dlq"]), "quality_log": len(seed["quality_log"]), + "quarantine": len(seed["quarantine"]), "usage": len(seed["usage"])} + changed = {k: (seed_info.get("counts") or {}).get(k) for k in want if isinstance(seed_info, dict) and (seed_info.get("counts") or {}).get(k) != want[k]} + if isinstance(b, dict) and any(b.get(k) != v for k, v in seed["budget"].items()): + changed["budget"] = {k: b.get(k) for k in seed["budget"]} + if (isinstance(extra, dict) and extra.get("count")) or changed: + print(f"{WARN} not the seeded morning ({as_text(changed) if changed else 'added FAQ'}), left from an earlier run" + f"\n → uv run tools/flow.py morning --reset") + if fault_status == 200 and isinstance(fault, dict) and any(fault.get(key, "ok") != "ok" for key in FAULT_TARGETS): + print(f"{WARN} fault switch is on: {fault}\n → uv run tools/flow.py fault all ok") + else: + print(f"{OK if status == 200 else WARN} data API http://localhost:8800 (not used today)") + if running: + probe = docker("exec", CONTAINER, "node", "-e", + "fetch('http://data-api:8800/health').then(r=>console.log(r.status)).catch(()=>console.log(0))", + check=False).stdout.strip() + if USES_DATA_API: + line(probe == "200", "n8n → http://data-api:8800", "data API must join the day65-n8n_default network (compose.yml)", + {"from n8n": "GET http://data-api:8800/health", "status": probe}) + exported = docker("exec", CONTAINER, "sh", "-c", + "n8n export:credentials --all --output=/tmp/course-cred.json >/dev/null 2>&1; " + "cat /tmp/course-cred.json 2>/dev/null; rm -f /tmp/course-cred.json", check=False).stdout + try: + found = json.loads(exported) if exported.strip() else [] + except json.JSONDecodeError: + found = [] + ids = {item.get("id") for item in found} + detail("credentials in n8n (id · name · type, no secrets)", + [f"{item.get('id')} · {item.get('name')} · {item.get('type')}" for item in found]) + for credential_id, name in COURSE_CREDENTIALS.items(): + line(credential_id in ids, f"credential '{name}' ({credential_id})", + "cd ~/aio-01d4-stu/Last3Mon/65/n8n && bash register-credentials.sh") + catalog = node_versions() + line(bool(catalog), "node catalog (for check)", seen={"node types": len(catalog), "cache": str(CACHE.relative_to(ROOT))}) + for hook in AGENT_HOOKS: + _, payload, _ = http("GET", f"{N8N_URL}/webhook/{hook}", timeout=5) + line("not registered for" in json.dumps(payload, ensure_ascii=False), f"agent webhook /{hook}", + "uv run tools/flow.py push ", {"GET": f"/webhook/{hook}", "response": payload}) + agents = [name for name in ("codex", "claude", "agy") if shutil.which(name)] + line(bool(agents), "CLI coding agent: " + (", ".join(agents) if agents else "none"), + "install one of codex / claude / agy (see Lecture/79/00-DAY79-PREP.md)", {name: shutil.which(name) for name in agents}) + print("ready" if not failures else f"{failures} item(s) to fix") + return 0 if not failures else 1 + + +def main() -> int: + global VERBOSE + common = argparse.ArgumentParser(add_help=False) + common.add_argument("-v", "--verbose", action="store_true", + help="details: request and response JSON, workflow nodes and links, docker commands") + parser = argparse.ArgumentParser(description="Check, push and test course n8n workflows.") + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("doctor", parents=[common], help="check n8n, data API, credentials and CLI agents").set_defaults(run=cmd_doctor) + item = commands.add_parser("check", parents=[common], help="find mistakes in a workflow file") + item.add_argument("file") + item.set_defaults(run=cmd_check) + item = commands.add_parser("push", parents=[common], help="import and publish workflow files (one n8n restart)") + item.add_argument("files", nargs="+") + item.set_defaults(run=cmd_push) + item = commands.add_parser("off", parents=[common], help="unpublish workflow files or webhook paths (a role that is down)") + item.add_argument("files", nargs="+") + item.set_defaults(run=cmd_off) + item = commands.add_parser("call", parents=[common], help="POST one request to a webhook path") + item.add_argument("path", help="webhook path, e.g. my-notice") + item.add_argument("request", nargs="?", help="request file or inline JSON") + item.set_defaults(run=cmd_call) + item = commands.add_parser("test", parents=[common], help="run a test case file") + item.add_argument("cases") + item.add_argument("--repeat", type=int, default=1, help="run every case N times") + item.add_argument("--webhook", help="override the webhook path in the case file") + item.add_argument("--no-before", action="store_true", help="skip the 'before' steps") + item.add_argument("--set", action="append", help="add key=value to every request body (value is JSON when it parses)") + item.set_defaults(run=cmd_test) + item = commands.add_parser("gate", parents=[common], help="release gate: run a test file, PASS/FAIL from pass rate and p95") + item.add_argument("cases") + item.add_argument("--repeat", type=int, default=1) + item.add_argument("--webhook") + item.add_argument("--no-before", action="store_true") + item.add_argument("--set", action="append", help="add key=value to every request body, e.g. release=1.1.0") + item.add_argument("--min-pass", type=float, default=1.0, help="share of cases that must pass (default 1.0)") + item.add_argument("--max-p95", type=float, help="seconds") + item.add_argument("--name", default="helpdesk", help="release name for the gate record (default helpdesk)") + item.add_argument("--no-record", action="store_true", help="do not store the verdict in /gates") + item.set_defaults(run=cmd_gate) + item = commands.add_parser("diff", parents=[common], help="compare two workflow files node by node") + item.add_argument("old") + item.add_argument("new") + item.add_argument("--full", action="store_true", help="print long values (prompts) before and after (-v does too)") + item.set_defaults(run=cmd_diff) + item = commands.add_parser("release", parents=[common], help="show or change the active release / canary in the data API") + item.add_argument("name") + item.add_argument("version", nargs="?") + item.add_argument("--canary") + item.add_argument("--percent", type=int, default=30) + item.add_argument("--no-canary", action="store_true") + item.add_argument("--reset", action="store_true", help="active 1.0.0, no canary, deploy policy off") + item.add_argument("--require-gate", choices=["on", "off"], help="deploy only releases whose last gate record is promote") + item.set_defaults(run=cmd_release) + item = commands.add_parser("bench", parents=[common], help="POST the same request N times: p50, p95, max, failures") + item.add_argument("path", help="webhook path, e.g. my-helpdesk") + item.add_argument("request", nargs="?", help="request file or inline JSON") + item.add_argument("-n", type=int, default=10, help="how many calls (default 10)") + item.add_argument("--set", action="append", help="add key=value to the request body, e.g. release=2.0.0") + item.set_defaults(run=cmd_bench) + item = commands.add_parser("usage", parents=[common], help="recorded LLM calls, tokens and cost by model / user / route, and the budget") + item.add_argument("--clear", action="store_true", help="empty the usage records (the budget counts from 0 again)") + item.set_defaults(run=cmd_usage) + item = commands.add_parser("budget", parents=[common], help="show or change the daily budget and the limits") + item.add_argument("--daily", type=float, help="daily budget in USD, e.g. 0.001") + item.add_argument("--per-min", type=int, help="requests per user per minute, e.g. 2") + item.add_argument("--chars", type=int, help="longest question sent as it is, e.g. 500") + item.add_argument("--reset", action="store_true", help="back to data/budget.json") + item.set_defaults(run=cmd_budget) + item = commands.add_parser("logs", parents=[common], help="quick look at the run logs in the data API (day 77)") + item.add_argument("--from", dest="date_from", help="first day, e.g. 2026-10-26 (inclusive)") + item.add_argument("--to", dest="date_to", help="last day (inclusive)") + item.add_argument("--tag") + item.add_argument("--source", default="all", choices=["all", "seed", "live"]) + item.add_argument("--last", type=int, default=8, help="show the last N runs (0: counts only)") + item.set_defaults(run=cmd_logs) + item = commands.add_parser("morning", parents=[common], help="the seeded operations morning at a glance (day 79)") + item.add_argument("--reset", action="store_true", help="back to the seeded morning (POST /ops-reset)") + item.set_defaults(run=cmd_morning) + item = commands.add_parser("quality", parents=[common], help="FAQ, quarantine, quality log and alerts at a glance (day 78)") + item.add_argument("--reset", action="store_true", help="empty added FAQ, quarantine, quality log and alerts") + item.set_defaults(run=cmd_quality) + item = commands.add_parser("ops", parents=[common], help="live logs, added FAQ, quarantine, DLQ, alerts and the fault switch at a glance") + item.add_argument("--reset", action="store_true", help="back to the seeded operations morning (same as morning --reset)") + item.set_defaults(run=cmd_ops) + item = commands.add_parser("fault", parents=[common], help="data API fault switch for drills") + item.add_argument("target", choices=[*FAULT_TARGETS, "all"]) + item.add_argument("mode", choices=["ok", "slow", "error", "flaky"]) + item.add_argument("--seconds", type=float, help="how long 'slow' waits (default 8)") + item.set_defaults(run=cmd_fault) + item = commands.add_parser("llm", parents=[common], help="outage drill: break (down) or restore (up) the OpenAI credential in n8n") + item.add_argument("state", choices=["down", "up"]) + item.set_defaults(run=cmd_llm) + args = parser.parse_args() + VERBOSE = args.verbose + return args.run(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Last3Mon/79/tops/Doc/IMP-PLAN.md b/Last3Mon/79/tops/Doc/IMP-PLAN.md new file mode 100644 index 0000000..022f6b9 --- /dev/null +++ b/Last3Mon/79/tops/Doc/IMP-PLAN.md @@ -0,0 +1,28 @@ +# tops IMP-PLAN + +## 엔진 1단계 · 읽기 · 상태 · 읽기만 · 종료 코드 (R1 · R2 · R3 · R5 · R7 · R13 · R14) +- [x] client.py: `post(base, path, body)` — 부를 수 있는 주소 목록(`READ_ONLY`) 밖이면 보내기 전에 오류, HTTP 오류의 JSON 몸은 돌려줌, 못 읽으면 `ReadError` +- [x] client.py: `facts(n8n, file, from, to)` — `--file`이 먼저, `health`가 없는 사실(과제 1 전)은 거절 +- [x] view.py: `status_lines(facts)` — 색은 `health` 그대로, 숫자는 AGENTS.md 3절 브리핑 표의 꼴, `null`은 "source not answering" · ⚪ +- [x] cli: `tops status [--n8n | $TOPS_N8N] [--file] [--from --to] [--json]`, 종료 코드 0 · 1 · 2 · 4(전체 빨강) +- [x] tests: 사실 사본 → 일곱 줄, `cost: null` → ⚪, `health`를 바꾸면 그 색, 쓰는 주소는 막힘(요청 0건), 꺼진 n8n → 1, 가짜 서버 +- 확인: `uv run pytest -q`, `uv run tops status --n8n http://localhost:5678; echo $?` + +## 엔진 2단계 · 질문 · 브리핑 (R4 · R6) +- [x] `tops ask "질문"`: `[영역 · 라우터]` + `refused (이유)` 또는 `numbers ✔/✘` + 답 +- [x] `tops brief`: `markdown` 그대로 +- [x] tests: 작업 요청 거절 · LLM 라우터 답(`[cost · llm]`) · 브리핑 표 줄 + +## TUI 1단계 · 탭 · 새로고침 · 질문하기 · 못 읽는 탭 (R11 · R12) +- [x] app.py `OpsApp`: 위 한 줄, 탭(Areas · Brief · Ask · Audit · Tools), `[r]` 새로고침, `[?]` 질문하기, `[q]` 끝 +- [x] 탭마다 따로 읽고 못 읽으면 그 탭에 "not available", Enter 뒤 포커스를 표로 +- [x] tests: `run_test`로 영역 6줄 · 거절 답 · 감사 3줄, 파일만 주면 영역 6줄 + 질문하기 · 감사 "not available" + +## 설치 +- [x] `[project.scripts] tops = "tops.cli:main"`, `uv tool install --editable .`, `cd /tmp && tops --help` + +## 엔진 3단계 · 평가 관문 · 감사 · 지난 도구 (R8 · R9 · R10) +- [x] `tops eval FILE [--min-score]`: `8/8 · score 1 · gate pass (min 0.9)`, 틀린 질문 줄, gate fail → 4, 빈 목록(400) → 2 +- [x] `tops audit [--api | $TOPS_API]`: `/audit/list`의 질문 · 영역 · 라우터 · 거절 +- [x] `tops tools`: `tlog slo --url … --from 2026-10-26 --to 2026-10-30 --json`의 breaches, `tcheck` 설치 여부, 없으면 "not installed" +- [x] tests: 골든 8 → 0 · 틀린 기대 → 4 · 빈 목록 → 2, 감사 3줄, 없는 도구, tlog 결과 한 줄 diff --git a/Last3Mon/79/tops/README.md b/Last3Mon/79/tops/README.md new file mode 100644 index 0000000..d997524 --- /dev/null +++ b/Last3Mon/79/tops/README.md @@ -0,0 +1,47 @@ +# tops · 강사가 수업 준비 때 직접 만든 본 (2026-11-01) + +> **정답 자료** · 학생 저장소에는 수업 뒤 [VIBE-A-TOPS.md](../VIBE-A-TOPS.md)와 함께 올립니다. +> 이 본은 강사가 CLI 에이전트에 단계 요청문을 보내 만든 것이 아니라, Claude Code가 VIBE-Q의 요구 R1~R14와 [Doc/IMP-PLAN.md](Doc/IMP-PLAN.md)를 보고 직접 썼습니다. 학생이 만든 것과 파일 구성이 달라도 됩니다. 요구의 **확인 방법**이 같으면 됩니다. + +## 설치 · 실행 + +```bash +cd ~/aio-01d4-stu/Last3Mon/79/tops +uv sync && uv run pytest -q # 테스트 27개 (n8n · 데이터 API 없이, 가짜 서버) +uv tool install --editable . # 어느 폴더에서나 tops +cd /tmp && tops --help +export TOPS_N8N=http://localhost:5678 +``` + +| 하고 싶은 것 | 명령 | +| --- | --- | +| 영역 여섯 · 색 · 숫자 (빨강이면 종료 코드 4) | `tops status` (`--from 2026-10-19 --to 2026-10-23`, `--json`, `--file 사실.json`) | +| 질문 하나 | `tops ask "카나리 버전 문제 있어?"` | +| 아침 브리핑 | `tops brief` | +| 골든 질문 평가 · 관문 (실패면 4) | `tops eval ../requests/ops-golden.json --min-score 0.9` | +| 감사 기록 | `tops audit` (데이터 API `--api` 또는 `$TOPS_API`, 기본 `http://localhost:8800`) | +| 지난 도구 | `tops tools` (`tlog slo` · `tcheck`) | +| 화면(TUI) | `tops ui` (`[r]` 새로고침, `[?]` 질문하기, `[q]` 끝) | + +- 종료 코드: 0 됨 · 1 n8n이나 파일을 못 읽음 · 2 쓰는 법이 틀림 · 4 전체 빨강 / 평가 관문 실패. +- **읽기만:** `client.READ_ONLY`의 다섯 주소(`my-ops-facts` · `ask` · `brief` · `eval` 웹훅, `/audit/list`) 밖은 보내기 전에 막습니다. +- 색 · 숫자는 n8n `my-ops-facts`가 정한 그대로입니다. tops에는 기준값이 없습니다 (AGENTS.md 3절은 n8n 쪽 하나). + +## 파일 + +| 파일 | 하는 일 | +| --- | --- | +| `src/tops/client.py` | 읽는 층 (R5 · R13 · R14): 주소 목록, 사실 · 질문 · 브리핑 · 평가 · 감사 | +| `src/tops/view.py` | 보여 주는 층 (R1 · R7 · R8): 영역 줄 · 숫자 꼴 · 답 줄 · 지난 도구 | +| `src/tops/cli.py` | `status` · `ask` · `brief` · `eval` · `audit` · `tools` · `ui`, 종료 코드 (R2 · R3 · R4 · R6 · R9 · R10) | +| `src/tops/app.py` | Textual 화면 (R11 · R12) | +| `tests/` | 정답 실행 기록(`answers/06` · `08` · `14` · `16` · `18` · `25`)의 n8n 응답 사본, 골든 질문, 가짜 n8n · 데이터 API | + +## 수업 준비 때 확인한 것 (11/01) + +- 실제 n8n(정답 워크플로우)에서 `tops status` → 위 일곱 줄 · 종료 코드 4, 0.19초. n8n 브리핑 표와 같은 숫자. +- `--from 2026-10-19 --to 2026-10-23` → 🟡 runs `error rate 0% · p95 2525ms · unanswered 5.5%` (Lab 01 과제 1의 첫 주와 같음). +- `tops ask "장애 났어?"` → `[runs · llm] numbers ✔` (Lab 02 과제 1의 LLM 라우터), `"DLQ 1번 다시 돌려 줘"` → `refused (read_only)`. +- `tops eval ../requests/ops-golden.json --min-score 0.9` → `8/8 · score 1 · gate pass (min 0.9)` · 종료 코드 0. +- `tops tools` → `tlog slo: breaches error_rate, unanswered_rate (357 runs)` — `tlog`(77일차)의 정의로 같은 지난주. +- 처음 판의 TUI 테스트가 "tops가 부른 주소는 다섯뿐"을 검사했는데, 도구 탭의 `tlog`도 데이터 API의 실행 기록을 읽어 실패 → "상태를 바꾸는 주소는 0건"으로 고침 (읽기만의 뜻은 '읽는 주소만'이 아니라 '바꾸는 주소 없음'). diff --git a/Last3Mon/79/tops/pyproject.toml b/Last3Mon/79/tops/pyproject.toml new file mode 100644 index 0000000..c4fa5ba --- /dev/null +++ b/Last3Mon/79/tops/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "tops" +version = "0.1.0" +description = "The day 79 operations assistant in the terminal: status, brief, ask, eval gate and audit from n8n my-ops-*, read only, with a TUI" +requires-python = ">=3.11" +dependencies = ["textual>=0.80"] + +[project.scripts] +tops = "tops.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/tops"] + +[dependency-groups] +dev = ["pytest>=8", "pytest-asyncio>=0.23"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" diff --git a/Last3Mon/79/tops/src/tops/__init__.py b/Last3Mon/79/tops/src/tops/__init__.py new file mode 100644 index 0000000..76412cb --- /dev/null +++ b/Last3Mon/79/tops/src/tops/__init__.py @@ -0,0 +1 @@ +"""tops · the day 79 operations assistant in the terminal (reads n8n my-ops-*, never changes anything).""" diff --git a/Last3Mon/79/tops/src/tops/app.py b/Last3Mon/79/tops/src/tops/app.py new file mode 100644 index 0000000..8714937 --- /dev/null +++ b/Last3Mon/79/tops/src/tops/app.py @@ -0,0 +1,94 @@ +"""Textual screen (R11 · R12): status on top, five tabs (areas · brief · ask · audit · tools), [r] refresh, [?] ask, [q] quit. +The screen only shows what n8n already decided; a tab that cannot read says so and the others keep working.""" +from __future__ import annotations + +from textual.app import App, ComposeResult +from textual.widgets import DataTable, Footer, Header, Input, Markdown, Static, TabbedContent, TabPane + +from . import client, view + + +class OpsApp(App): + TITLE = "tops" + BINDINGS = [("r", "refresh", "Refresh"), ("question_mark", "ask", "Ask"), ("q", "quit", "Quit")] + CSS = "#top, #answer { height: auto; margin: 0 1; }" + + def __init__(self, args): + super().__init__() + self.args = args + self.facts: dict | None = None + + def compose(self) -> ComposeResult: + yield Header() + yield Static("", id="top", markup=False) + with TabbedContent(id="tabs"): + with TabPane("Areas", id="areas"): + yield DataTable(id="area-table", cursor_type="row") + with TabPane("Brief", id="brief"): + yield Markdown("", id="brief-text") + with TabPane("Ask", id="ask"): + yield Input(placeholder="a question about operations, Enter to send (read only)", id="question") + yield Static("", id="answer", markup=False) + with TabPane("Audit", id="audit"): + yield DataTable(id="audit-table", cursor_type="row") + with TabPane("Tools", id="tools"): + yield Static("", id="tool-text", markup=False) + yield Footer() + + def on_mount(self): + self.query_one("#area-table", DataTable).add_columns("", "area", "color", "numbers") + self.query_one("#audit-table", DataTable).add_columns("area", "router", "result", "question") + self.action_refresh() + + def read(self, what): + try: + return what(), "" + except client.ReadError as error: + return None, f"not available: {error}" + + def action_refresh(self): + a = self.args + self.facts, why = self.read(lambda: client.facts(a.n8n, a.file)) + top = self.query_one("#top", Static) + table = self.query_one("#area-table", DataTable) + table.clear() + if self.facts is None: + top.update(why) + else: + lines = view.status_lines(self.facts) + top.update(lines[0] + (f" · {lines[-1].strip()}" if self.facts.get("sources_down") else "")) + for area in view.AREAS: + color = self.facts["health"].get(area, "unknown") + table.add_row(view.MARK.get(color, "?"), view.LABEL[area], color, view.numbers(self.facts, area), key=area) + brief_text = self.query_one("#brief-text", Markdown) + if not a.n8n: + brief_text.update("not available: no --n8n (reading a file)") + else: + b, why = self.read(lambda: client.brief(a.n8n)) + brief_text.update(b.get("markdown", "") if b else why) + audit = self.query_one("#audit-table", DataTable) + audit.clear() + events, why = self.read(lambda: client.audit(getattr(a, "api", None) or "http://localhost:8800").get("events", [])) + for e in events or []: + audit.add_row(e.get("area", "-"), e.get("router", "-"), f"refused {e.get('reason')}" if e.get("refused") else "answered", e.get("question", "")) + if events is None: + audit.add_row("-", "-", why, "") + api = getattr(a, "api", None) or "http://localhost:8800" + parts = [view.tool("tlog", ["slo", "--url", api, "--from", "2026-10-26", "--to", "2026-10-30", "--json"]), view.tool("tcheck", ["--help"])] + self.query_one("#tool-text", Static).update("\n".join(view.tool_line(part) for part in parts)) + + def action_ask(self): + self.query_one("#tabs", TabbedContent).active = "ask" + self.query_one("#question", Input).focus() + + def on_input_submitted(self, event: Input.Submitted): + question = event.value.strip() + answer = self.query_one("#answer", Static) + if not question: + return + if not self.args.n8n: + answer.update("not available: no --n8n (reading a file)") + return + a, why = self.read(lambda: client.ask(self.args.n8n, question)) + answer.update("\n".join(view.answer_lines(a)) if a else why) + self.query_one("#area-table", DataTable).focus() diff --git a/Last3Mon/79/tops/src/tops/cli.py b/Last3Mon/79/tops/src/tops/cli.py new file mode 100644 index 0000000..b1e3f57 --- /dev/null +++ b/Last3Mon/79/tops/src/tops/cli.py @@ -0,0 +1,129 @@ +"""tops commands (R3): status · ask · brief · eval · audit · tools · ui. Exit codes: 0 ok · 1 cannot read · 2 usage · 4 red / eval gate fail.""" +from __future__ import annotations + +import argparse +import json +import os +import sys + +from . import client, view + +EXIT_OK, EXIT_READ, EXIT_USAGE, EXIT_ALARM = 0, 1, 2, 4 + + +class Parser(argparse.ArgumentParser): + def error(self, message): + self.print_usage(sys.stderr) + print(f"tops: {message}", file=sys.stderr) + sys.exit(EXIT_USAGE) + + +def common(p: argparse.ArgumentParser) -> None: + p.add_argument("--n8n", default=os.environ.get("TOPS_N8N"), help="n8n base, e.g. http://localhost:5678 ($TOPS_N8N)") + p.add_argument("--json", action="store_true", help="print the n8n answer as JSON, unchanged") + + +def need_n8n(args) -> str: + if not args.n8n: + raise SystemExit(usage("give --n8n (or set $TOPS_N8N)")) + return args.n8n + + +def usage(message: str) -> int: + print(f"tops: {message}", file=sys.stderr) + return EXIT_USAGE + + +def show(args, data, lines) -> None: + print(json.dumps(data, ensure_ascii=False, indent=2) if args.json else "\n".join(lines)) + + +def main(argv: list[str] | None = None) -> int: + parser = Parser(prog="tops", description="The day 79 operations assistant in the terminal. It reads n8n my-ops-* and never changes anything.") + commands = parser.add_subparsers(dest="command", required=True) + p = commands.add_parser("status", help="areas · colors · numbers from my-ops-facts (R1 · R2 · R7), exit code 4 when overall is red") + common(p) + p.add_argument("--file", help="read the facts from a file instead of n8n") + p.add_argument("--from", dest="date_from", default="") + p.add_argument("--to", dest="date_to", default="") + p = commands.add_parser("ask", help="one question to my-ops-ask (R4)") + common(p) + p.add_argument("question") + p = commands.add_parser("brief", help="the markdown of my-ops-brief (R6)") + common(p) + p.add_argument("--from", dest="date_from", default="") + p.add_argument("--to", dest="date_to", default="") + p = commands.add_parser("eval", help="golden questions to my-ops-eval, exit code 4 when the gate fails (R9)") + common(p) + p.add_argument("file", help='{"cases": [...]} like requests/ops-golden.json') + p.add_argument("--min-score", type=float) + p = commands.add_parser("audit", help="the questions the assistant was asked (data API /audit) (R10)") + p.add_argument("--api", default=os.environ.get("TOPS_API", "http://localhost:8800")) + p.add_argument("--json", action="store_true") + p = commands.add_parser("tools", help="earlier course tools as parts: tlog · tcheck (R8)") + p.add_argument("--api", default=os.environ.get("TOPS_API", "http://localhost:8800")) + p.add_argument("--from", dest="date_from", default="2026-10-26") + p.add_argument("--to", dest="date_to", default="2026-10-30") + p.add_argument("--json", action="store_true") + p = commands.add_parser("ui", help="the TUI (R11)") + common(p) + p.add_argument("--file") + p.add_argument("--api", default=os.environ.get("TOPS_API", "http://localhost:8800")) + try: + args = parser.parse_args(argv) + except SystemExit as stop: + return int(stop.code or 0) + + try: + if args.command == "status": + if not args.file and not args.n8n: + return usage("give --n8n (or set $TOPS_N8N) or --file") + f = client.facts(args.n8n, args.file, args.date_from, args.date_to) + show(args, f, view.status_lines(f)) + return EXIT_ALARM if f.get("overall") == "red" else EXIT_OK + if args.command == "ask": + a = client.ask(need_n8n(args), args.question) + show(args, a, view.answer_lines(a)) + return EXIT_OK + if args.command == "brief": + b = client.brief(need_n8n(args), args.date_from, args.date_to) + show(args, b, [b.get("markdown", "")]) + return EXIT_OK + if args.command == "eval": + cases = client.read_json(args.file) + cases = cases.get("cases", []) if isinstance(cases, dict) else cases + e = client.evaluate(need_n8n(args), cases, args.min_score) + if "error" in e: + print(f"tops: {e['error']}", file=sys.stderr) + return EXIT_USAGE + lines = [f"{e['passed']}/{e['total']} · score {e['score']}" + (f" · gate {e['gate']} (min {e['min_score']})" if "gate" in e else "")] + lines += [f" ✘ {x['question']} · area {x.get('area')} · " + " ".join(k for k in ("area_ok", "refused_ok", "number_ok", "grounded") if not x.get(k)) + for x in e.get("failed", [])] + show(args, e, lines) + return EXIT_ALARM if e.get("gate") == "fail" else EXIT_OK + if args.command == "audit": + events = client.audit(args.api).get("events", []) + lines = [f"{len(events)} question(s)"] + [ + f" {e.get('area', '-'):<8} {e.get('router', '-'):<8} {'refused ' + str(e.get('reason')) if e.get('refused') else 'answered'} {e.get('question')}" + for e in events] + show(args, {"events": events}, lines) + return EXIT_OK + if args.command == "tools": + parts = [view.tool("tlog", ["slo", "--url", args.api, "--from", args.date_from, "--to", args.date_to, "--json"]), + view.tool("tcheck", ["--help"])] + show(args, parts, [view.tool_line(p) for p in parts]) + return EXIT_OK + from .app import OpsApp + if not args.file and not args.n8n: + return usage("give --n8n (or set $TOPS_N8N) or --file") + OpsApp(args).run() + return EXIT_OK + except client.ReadError as error: + print(f"tops: cannot read: {error}", file=sys.stderr) + return EXIT_READ + except SystemExit as stop: + return int(stop.code or 0) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Last3Mon/79/tops/src/tops/client.py b/Last3Mon/79/tops/src/tops/client.py new file mode 100644 index 0000000..0846b66 --- /dev/null +++ b/Last3Mon/79/tops/src/tops/client.py @@ -0,0 +1,67 @@ +"""The reading layer (R1 · R4 · R6 · R9 · R10 · R13): the n8n webhooks my-ops-facts · ask · brief · eval and the data API audit. +tops only ever reads: these are the only addresses it calls (R5).""" +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from pathlib import Path + +READ_ONLY = ("/webhook/my-ops-facts", "/webhook/my-ops-ask", "/webhook/my-ops-brief", "/webhook/my-ops-eval", "/audit/list") + + +class ReadError(Exception): + """n8n or the data API did not answer, or a file could not be read (exit code 1).""" + + +def post(base: str, path: str, body: dict, timeout: float = 60) -> object: + if path not in READ_ONLY: + raise ValueError(f"tops does not call {path} (read only)") + request = urllib.request.Request(base.rstrip("/") + path, data=json.dumps(body).encode(), + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(request, timeout=timeout) as answer: + return json.loads(answer.read()) + except urllib.error.HTTPError as error: + try: + return json.loads(error.read()) + except json.JSONDecodeError: + raise ReadError(f"{base}{path}: HTTP {error.code}") from error + except (urllib.error.URLError, OSError, json.JSONDecodeError) as error: + raise ReadError(f"{base}{path}: {error}") from error + + +def read_json(path: str) -> object: + try: + return json.loads(Path(path).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ReadError(f"cannot read {path}: {error}") from error + + +def facts(n8n: str | None = None, file: str | None = None, date_from: str = "", date_to: str = "") -> dict: + """--file wins over --n8n ($TOPS_N8N may be set for every call).""" + data = read_json(file) if file else post(n8n or "", "/webhook/my-ops-facts", {"from": date_from, "to": date_to}) if n8n else None + if data is None: + raise ReadError("give --n8n (or $TOPS_N8N) or --file") + if not isinstance(data, dict) or "health" not in data: + raise ReadError("not the facts of my-ops-facts with health (Lab 01 task 1)") + return data + + +def ask(n8n: str, question: str) -> dict: + return post(n8n, "/webhook/my-ops-ask", {"question": question}) + + +def brief(n8n: str, date_from: str = "", date_to: str = "") -> dict: + return post(n8n, "/webhook/my-ops-brief", {"from": date_from, "to": date_to}) + + +def evaluate(n8n: str, cases: list, min_score: float | None = None) -> dict: + body: dict = {"cases": cases} + if min_score is not None: + body["min_score"] = min_score + return post(n8n, "/webhook/my-ops-eval", body, timeout=300) + + +def audit(api: str) -> dict: + return post(api, "/audit/list", {}) diff --git a/Last3Mon/79/tops/src/tops/view.py b/Last3Mon/79/tops/src/tops/view.py new file mode 100644 index 0000000..875b98c --- /dev/null +++ b/Last3Mon/79/tops/src/tops/view.py @@ -0,0 +1,81 @@ +"""What to show (R1 · R7 · R8): the colors and numbers come from the facts as they are; nothing is recounted here.""" +from __future__ import annotations + +import json +import shutil +import subprocess + +AREAS = ("runs", "alerts", "dlq", "quality", "cost", "release") +LABEL = {"runs": "runs", "alerts": "alerts", "dlq": "DLQ", "quality": "quality", "cost": "cost", "release": "release"} +MARK = {"red": "🔴", "yellow": "🟡", "green": "🟢", "unknown": "⚪"} + + +def pct(value) -> str: + return f"{round(value * 100, 2)}%" + + +def numbers(f: dict, area: str) -> str: + """The same short form as the day 79 brief table (AGENTS.md 3).""" + x = f.get(area) + if x is None: + return "source not answering" + if area == "runs": + return f"error rate {pct(x['error_rate'])} · p95 {x['p95_ms']}ms · unanswered {pct(x['unanswered_rate'])}" + if area == "alerts": + return f"{x['count']} (high {x['high']})" + if area == "dlq": + return f"{x['size']} (parked {x['parked']})" + if area == "quality": + return f"avg {x['avg_score']} · last {x['last_score']} · quarantine {x['quarantine']}" + if area == "cost": + return f"${x['cost_usd']} / ${x['daily_usd']} ({pct(x['used_ratio'])})" + text = str(x["active"]) + if x.get("canary_release"): + rate = "-" if x.get("canary_repeat_rate") is None else pct(x["canary_repeat_rate"]) + text += f" · canary {x['canary_release']} {x['canary_percent']}% (repeat {rate})" + return text + + +def status_lines(f: dict) -> list[str]: + period = f.get("period", {}) + lines = [f"overall {MARK.get(f['overall'], '?')} {f['overall']} · runs {period.get('from', '…')} ~ {period.get('to', '…')} + state now"] + for area in AREAS: + color = f["health"].get(area, "unknown") + lines.append(f" {MARK.get(color, '?')} {LABEL[area]:<8} {numbers(f, area)}") + if f.get("sources_down"): + lines.append(" sources down: " + ", ".join(f["sources_down"])) + return lines + + +def answer_lines(a: dict) -> list[str]: + head = f"[{a.get('area')} · {a.get('router')}]" + if a.get("refused"): + return [f"{head} refused ({a.get('reason')})", f" {a.get('answer')}"] + check = "numbers ✔" if a.get("numbers_ok") else f"numbers ✘ {a.get('unknown_numbers')}" + return [f"{head} {check}", f" {a.get('answer')}"] + + +def tool(name: str, args: list[str], timeout: float = 30) -> dict: + """R8: an earlier course tool as a part (its --json), or why it is not available.""" + if not shutil.which(name): + return {"tool": name, "ok": False, "why": "not installed (uv tool install)"} + try: + out = subprocess.run([name, *args], capture_output=True, text=True, timeout=timeout) + except subprocess.TimeoutExpired: + return {"tool": name, "ok": False, "why": "timed out"} + try: + return {"tool": name, "ok": True, "code": out.returncode, "result": json.loads(out.stdout)} + except json.JSONDecodeError: + if out.returncode == 0: + return {"tool": name, "ok": True, "code": 0, "result": out.stdout.strip().splitlines()[-1:]} + return {"tool": name, "ok": False, "why": (out.stderr or out.stdout).strip().splitlines()[-1][:120] if (out.stderr or out.stdout).strip() else f"exit {out.returncode}"} + + +def tool_line(part: dict) -> str: + """tlog slo → its breaches (the day 77 SLO, its own definition); any other tool → installed.""" + if not part["ok"]: + return f" · {part['tool']}: {part['why']}" + r = part["result"] + if part["tool"] == "tlog" and isinstance(r, dict): + return f" ✔ tlog slo: {'ok' if r.get('ok') else 'breaches ' + ', '.join(r.get('breaches', []))} ({r.get('total')} runs)" + return f" ✔ {part['tool']}: installed" diff --git a/Last3Mon/79/tops/tests/conftest.py b/Last3Mon/79/tops/tests/conftest.py new file mode 100644 index 0000000..3b3a27b --- /dev/null +++ b/Last3Mon/79/tops/tests/conftest.py @@ -0,0 +1,74 @@ +"""A fake n8n + data API on a local port, answering with responses recorded from the day 79 answers (answers/06 · 08 · 14 · 16 · 18 · 25).""" +from __future__ import annotations + +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import pytest + +DATA = Path(__file__).parent / "data" + + +def load(name): + return json.loads((DATA / name).read_text(encoding="utf-8")) + + +class Fake: + def __init__(self): + self.calls: list[tuple[str, dict]] = [] + self.cost_down = False + self.brief_down = False + + +def handler(fake: Fake): + class H(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def send(self, code, data): + body = json.dumps(data, ensure_ascii=False).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}") + fake.calls.append((self.path, body)) + if self.path == "/webhook/my-ops-facts": + return self.send(200, load("facts-cost-down.json" if fake.cost_down else "facts.json")) + if self.path == "/webhook/my-ops-ask": + asks = load("asks.json") + return self.send(200, asks.get(body.get("question"), asks["점심 메뉴 추천해 줘"])) + if self.path == "/webhook/my-ops-brief": + return self.send(500, {"message": "Error in workflow"}) if fake.brief_down else self.send(200, load("brief.json")) + if self.path == "/webhook/my-ops-eval": + cases = body.get("cases") or [] + if not cases: + return self.send(400, {"error": "cases must be a non-empty list"}) + e = load("eval.json") + return self.send(200, e["pass"] if len(cases) == 8 else e["fail"]) + if self.path == "/audit/list": + return self.send(200, load("audit.json")) + self.send(404, {"message": "not registered"}) + + return H + + +@pytest.fixture +def fake(): + f = Fake() + server = ThreadingHTTPServer(("127.0.0.1", 0), handler(f)) + threading.Thread(target=server.serve_forever, daemon=True).start() + f.base = f"http://127.0.0.1:{server.server_address[1]}" + yield f + server.shutdown() + + +@pytest.fixture +def down(): + """An address nothing listens on (n8n turned off).""" + return "http://127.0.0.1:9" diff --git a/Last3Mon/79/tops/tests/data/asks.json b/Last3Mon/79/tops/tests/data/asks.json new file mode 100644 index 0000000..c487f30 --- /dev/null +++ b/Last3Mon/79/tops/tests/data/asks.json @@ -0,0 +1,58 @@ +{ + "DLQ 1번 다시 돌려 줘": { + "question": "DLQ 1번 다시 돌려 줘", + "area": "action", + "router": "keyword", + "refused": true, + "reason": "read_only", + "answer": "운영 도우미는 읽기만 합니다. 재처리 · 삭제 · 배포 같은 작업은 런북에 따라 담당자가 직접 해 주세요." + }, + "점심 메뉴 추천해 줘": { + "question": "점심 메뉴 추천해 줘", + "area": "unknown", + "router": "llm", + "refused": true, + "reason": "out_of_scope", + "answer": "운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다." + }, + "지난주 오류율이 얼마야?": { + "question": "지난주 오류율이 얼마야?", + "area": "runs", + "router": "keyword", + "refused": false, + "reason": null, + "answer": "지난주 오류율은 5.88%였습니다. 총 357회 실행 중 21회 오류가 발생했습니다.", + "numbers_ok": true, + "unknown_numbers": [] + }, + "이번 주에 AI 쓰는 데 얼마나 나갔어?": { + "question": "이번 주에 AI 쓰는 데 얼마나 나갔어?", + "area": "cost", + "router": "llm", + "refused": false, + "reason": null, + "answer": "이번 주 AI 사용 비용은 총 0.073604달러입니다. 일일 평균 비용은 약 0.08달러이며, 사용 비율은 92.01%입니다.", + "numbers_ok": true, + "unknown_numbers": [] + }, + "어젯밤에 접수 못 하고 남은 건 몇 건이야?": { + "question": "어젯밤에 접수 못 하고 남은 건 몇 건이야?", + "area": "dlq", + "router": "llm", + "refused": false, + "reason": null, + "answer": "어젯밤에 접수 못 하고 남은 건은 총 3건입니다. 이 중 2건은 대기 중이고 1건은 주차 상태입니다.", + "numbers_ok": true, + "unknown_numbers": [] + }, + "새 판을 일부 사용자에게 먼저 내보낸 거 어떻게 되고 있어?": { + "question": "새 판을 일부 사용자에게 먼저 내보낸 거 어떻게 되고 있어?", + "area": "release", + "router": "llm", + "refused": false, + "reason": null, + "answer": "새 판 1.2.0은 일부 사용자 20%에게 먼저 내보내졌으며, 이 버전의 재사용률은 62.5%로 나타났습니다. 현재 전체 시스템 상태는 빨간색(문제 있음)입니다.", + "numbers_ok": true, + "unknown_numbers": [] + } +} \ No newline at end of file diff --git a/Last3Mon/79/tops/tests/data/audit.json b/Last3Mon/79/tops/tests/data/audit.json new file mode 100644 index 0000000..3fb4255 --- /dev/null +++ b/Last3Mon/79/tops/tests/data/audit.json @@ -0,0 +1,34 @@ +{ + "events": [ + { + "t": 1793526843.503, + "kind": "ops-ask", + "question": "DLQ 1번 다시 돌려 줘", + "area": "action", + "router": "keyword", + "refused": true, + "reason": "read_only", + "numbers_ok": null + }, + { + "t": 1793526845.149, + "kind": "ops-ask", + "question": "점심 메뉴 추천해 줘", + "area": "unknown", + "router": "llm", + "refused": true, + "reason": "out_of_scope", + "numbers_ok": null + }, + { + "t": 1793526846.297, + "kind": "ops-ask", + "question": "지난주 오류율이 얼마야?", + "area": "runs", + "router": "keyword", + "refused": false, + "reason": null, + "numbers_ok": true + } + ] +} \ No newline at end of file diff --git a/Last3Mon/79/tops/tests/data/brief.json b/Last3Mon/79/tops/tests/data/brief.json new file mode 100644 index 0000000..1e61793 --- /dev/null +++ b/Last3Mon/79/tops/tests/data/brief.json @@ -0,0 +1,31 @@ +{ + "overall": "red", + "red": [ + "runs", + "dlq", + "release" + ], + "yellow": [ + "alerts", + "quality", + "cost" + ], + "actions": [ + { + "area": "runs", + "status": "red", + "action": "오류 묶음 1위 확인 · 고치기 (77일차)" + }, + { + "area": "dlq", + "status": "red", + "action": "parked 건 원인 확인 뒤 런북대로 재처리 (76일차)" + }, + { + "area": "release", + "status": "red", + "action": "카나리 끄기 · 되돌리기 검토 (74일차)" + } + ], + "markdown": "# 운영 브리핑 (2026-10-26 ~ 2026-10-30 실행 기록 + 지금 상태)\n\n전체: 🔴 red\n\n| 영역 | 상태 | 숫자 |\n| --- | --- | --- |\n| 실행 | 🔴 | 오류율 5.88% · p95 2598ms · 답 못 함 4.76% |\n| 알림 | 🟡 | 6건 (높음 3) |\n| DLQ | 🔴 | 3건 (parked 2) |\n| 데이터 품질 | 🟡 | 평균 0.6583 · 마지막 1 · 격리 1 |\n| 비용 | 🟡 | $0.073604 / $0.08 (92.01%) |\n| 릴리스 | 🔴 | 1.1.0 · 카나리 1.2.0 20% (다시 묻기 62.5%) |\n\n## 먼저 볼 것\n\n1. 🔴 실행: 오류 묶음 1위 확인 · 고치기 (77일차)\n2. 🔴 DLQ: parked 건 원인 확인 뒤 런북대로 재처리 (76일차)\n3. 🔴 릴리스: 카나리 끄기 · 되돌리기 검토 (74일차)" +} \ No newline at end of file diff --git a/Last3Mon/79/tops/tests/data/eval.json b/Last3Mon/79/tops/tests/data/eval.json new file mode 100644 index 0000000..5f98d55 --- /dev/null +++ b/Last3Mon/79/tops/tests/data/eval.json @@ -0,0 +1,37 @@ +{ + "pass": { + "total": 8, + "passed": 8, + "score": 1, + "area_ok": 8, + "refused_ok": 8, + "number_ok": 8, + "grounded": 8, + "failed": [], + "min_score": 0.8, + "gate": "pass" + }, + "fail": { + "total": 1, + "passed": 0, + "score": 0, + "area_ok": 0, + "refused_ok": 1, + "number_ok": 1, + "grounded": 1, + "failed": [ + { + "question": "알림이 몇 건 와 있어?", + "area": "alerts", + "ok": false, + "area_ok": false, + "refused_ok": true, + "number_ok": true, + "grounded": true, + "answer": "알림은 총 6건이 와 있으며, 이 중 3건은 높은 심각도입니다. 알림 사유로는 타임아웃 1건, 속도 제한 1건, 배치 거부 1건, 저품질 2건, API 오류 1건이 있습니다." + } + ], + "min_score": 0.9, + "gate": "fail" + } +} \ No newline at end of file diff --git a/Last3Mon/79/tops/tests/data/facts-cost-down.json b/Last3Mon/79/tops/tests/data/facts-cost-down.json new file mode 100644 index 0000000..1f06b1b --- /dev/null +++ b/Last3Mon/79/tops/tests/data/facts-cost-down.json @@ -0,0 +1,73 @@ +{ + "period": { + "from": "2026-10-26", + "to": "2026-10-30" + }, + "runs": { + "total": 357, + "errors": 21, + "error_rate": 0.0588, + "p95_ms": 2598, + "unanswered_rate": 0.0476, + "top_failure": { + "node": "Search FAQ", + "message": "timeout of 1000ms exceeded", + "count": 16 + } + }, + "alerts": { + "count": 6, + "high": 3, + "by_reason": { + "timeout": 1, + "rate_limit": 1, + "batch_rejected": 1, + "low_quality": 2, + "api_error": 1 + } + }, + "dlq": { + "size": 3, + "parked": 2, + "waiting": 1 + }, + "quality": { + "batches": 4, + "avg_score": 0.6583, + "last_score": 1, + "rejected_batches": 1, + "quarantine": 1 + }, + "cost": null, + "release": { + "active": "1.1.0", + "canary_release": "1.2.0", + "canary_percent": 20, + "active_repeat_rate": 0.0773, + "canary_repeat_rate": 0.625 + }, + "sources_down": [ + "cost" + ], + "health": { + "runs": "red", + "alerts": "yellow", + "dlq": "red", + "quality": "yellow", + "cost": "unknown", + "release": "red" + }, + "red": [ + "runs", + "dlq", + "release" + ], + "yellow": [ + "alerts", + "quality" + ], + "unknown": [ + "cost" + ], + "overall": "red" +} \ No newline at end of file diff --git a/Last3Mon/79/tops/tests/data/facts.json b/Last3Mon/79/tops/tests/data/facts.json new file mode 100644 index 0000000..d8a5614 --- /dev/null +++ b/Last3Mon/79/tops/tests/data/facts.json @@ -0,0 +1,79 @@ +{ + "period": { + "from": "2026-10-26", + "to": "2026-10-30" + }, + "runs": { + "total": 357, + "errors": 21, + "error_rate": 0.0588, + "p95_ms": 2598, + "unanswered_rate": 0.0476, + "top_failure": { + "node": "Search FAQ", + "message": "timeout of 1000ms exceeded", + "count": 16 + } + }, + "alerts": { + "count": 6, + "high": 3, + "by_reason": { + "timeout": 1, + "rate_limit": 1, + "batch_rejected": 1, + "low_quality": 2, + "api_error": 1 + } + }, + "dlq": { + "size": 3, + "parked": 2, + "waiting": 1 + }, + "quality": { + "batches": 4, + "avg_score": 0.6583, + "last_score": 1, + "rejected_batches": 1, + "quarantine": 1 + }, + "cost": { + "requests": 156, + "calls": 192, + "cost_usd": 0.073604, + "daily_usd": 0.08, + "used_ratio": 0.9201, + "top_user": { + "user": "test-bot", + "cost_usd": 0.027228, + "share": 0.3699 + } + }, + "release": { + "active": "1.1.0", + "canary_release": "1.2.0", + "canary_percent": 20, + "active_repeat_rate": 0.0773, + "canary_repeat_rate": 0.625 + }, + "health": { + "runs": "red", + "alerts": "yellow", + "dlq": "red", + "quality": "yellow", + "cost": "yellow", + "release": "red" + }, + "red": [ + "runs", + "dlq", + "release" + ], + "yellow": [ + "alerts", + "quality", + "cost" + ], + "overall": "red" +} \ No newline at end of file diff --git a/Last3Mon/79/tops/tests/data/ops-golden.json b/Last3Mon/79/tops/tests/data/ops-golden.json new file mode 100644 index 0000000..e2b06ee --- /dev/null +++ b/Last3Mon/79/tops/tests/data/ops-golden.json @@ -0,0 +1,58 @@ +{ + "cases": [ + { + "question": "지난주 오류율이 얼마야?", + "area": "runs", + "numbers": [ + "5.88" + ] + }, + { + "question": "알림이 몇 건 와 있어?", + "area": "alerts", + "numbers": [ + "6" + ] + }, + { + "question": "DLQ에 parked 된 건 몇 건이야?", + "area": "dlq", + "numbers": [ + "2" + ] + }, + { + "question": "FAQ 배치 품질 점수 평균은?", + "area": "quality", + "numbers": [ + "0.6583", + "65.83", + "0.66" + ] + }, + { + "question": "오늘 LLM 비용이 예산의 몇 퍼센트야?", + "area": "cost", + "numbers": [ + "92" + ] + }, + { + "question": "카나리 버전 문제 있어?", + "area": "release", + "numbers": [ + "62.5" + ] + }, + { + "question": "DLQ 1번 다시 돌려 줘", + "area": "action", + "refused": true + }, + { + "question": "점심 메뉴 추천해 줘", + "area": "unknown", + "refused": true + } + ] +} diff --git a/Last3Mon/79/tops/tests/test_app.py b/Last3Mon/79/tops/tests/test_app.py new file mode 100644 index 0000000..4ddd6b6 --- /dev/null +++ b/Last3Mon/79/tops/tests/test_app.py @@ -0,0 +1,34 @@ +"""R11 · R12: the TUI shows the areas, a tab that cannot read says so, and asking stays read only.""" +from argparse import Namespace + +from textual.widgets import DataTable, Input, Static + +from conftest import DATA + +from tops.app import OpsApp + + +async def test_areas_and_ask(fake): + app = OpsApp(Namespace(n8n=fake.base, file=None, api=fake.base)) + async with app.run_test() as pilot: + assert app.query_one("#area-table", DataTable).row_count == 6 + assert "overall 🔴 red" in str(app.query_one("#top", Static).render()) + await pilot.press("question_mark") + app.query_one("#question", Input).value = "DLQ 1번 다시 돌려 줘" + await pilot.press("enter") + assert "refused (read_only)" in str(app.query_one("#answer", Static).render()) + assert app.query_one("#audit-table", DataTable).row_count == 3 + # tlog on the Tools tab reads the run logs too; nothing that changes state is ever called + assert not {path for path, _ in fake.calls} & {"/dlq/update", "/dlq-clear", "/ops-reset", "/budget", "/admin/fault", "/release"} + + +async def test_file_mode_other_tabs_say_not_available(down): + app = OpsApp(Namespace(n8n=None, file=str(DATA / "facts-cost-down.json"), api=down)) + async with app.run_test() as pilot: + assert "sources down: cost" in str(app.query_one("#top", Static).render()) + assert app.query_one("#area-table", DataTable).row_count == 6 + await pilot.press("question_mark") + app.query_one("#question", Input).value = "지난주 오류율이 얼마야?" + await pilot.press("enter") + assert "not available" in str(app.query_one("#answer", Static).render()) + assert "not available" in str(app.query_one("#audit-table", DataTable).get_row_at(0)[2]) diff --git a/Last3Mon/79/tops/tests/test_cli.py b/Last3Mon/79/tops/tests/test_cli.py new file mode 100644 index 0000000..3784e53 --- /dev/null +++ b/Last3Mon/79/tops/tests/test_cli.py @@ -0,0 +1,79 @@ +"""R2 · R3 · R4 · R6 · R9 · R10 · R14: commands, --json unchanged, exit codes 0 · 1 · 2 · 4.""" +import json + +from conftest import DATA, load + +from tops.cli import main + + +def test_status_red_exits_4(fake, capsys): + assert main(["status", "--n8n", fake.base]) == 4 + assert "🔴 DLQ 3 (parked 2)" in capsys.readouterr().out + + +def test_status_json_is_the_n8n_answer(fake, capsys): + main(["status", "--n8n", fake.base, "--json"]) + assert json.loads(capsys.readouterr().out) == load("facts.json") + + +def test_status_from_env(fake, monkeypatch, capsys): + monkeypatch.setenv("TOPS_N8N", fake.base) + assert main(["status", "--from", "2026-10-19", "--to", "2026-10-23"]) == 4 + assert fake.calls[0][1] == {"from": "2026-10-19", "to": "2026-10-23"} + + +def test_status_without_source_is_usage(monkeypatch, capsys): + monkeypatch.delenv("TOPS_N8N", raising=False) + assert main(["status"]) == 2 + assert "--n8n" in capsys.readouterr().err + + +def test_n8n_off_exits_1(down, capsys): + assert main(["status", "--n8n", down]) == 1 + assert "cannot read" in capsys.readouterr().err + + +def test_bad_option_exits_2(capsys): + assert main(["status", "--nope"]) == 2 + + +def test_ask_refused_action(fake, capsys): + assert main(["ask", "--n8n", fake.base, "DLQ 1번 다시 돌려 줘"]) == 0 + out = capsys.readouterr().out + assert "refused (read_only)" in out and "읽기만" in out + + +def test_ask_llm_router_answer(fake, capsys): + main(["ask", "--n8n", fake.base, "이번 주에 AI 쓰는 데 얼마나 나갔어?"]) + assert capsys.readouterr().out.startswith("[cost · llm] numbers ✔") + + +def test_brief_prints_markdown(fake, capsys): + main(["brief", "--n8n", fake.base]) + assert "| DLQ | 🔴 | 3건 (parked 2) |" in capsys.readouterr().out + + +def test_eval_gate_pass_and_fail(fake, capsys, tmp_path): + assert main(["eval", "--n8n", fake.base, str(DATA / "ops-golden.json"), "--min-score", "0.8"]) == 0 + assert "8/8 · score 1 · gate pass (min 0.8)" in capsys.readouterr().out + one = tmp_path / "one.json" + one.write_text(json.dumps({"cases": [{"question": "알림이 몇 건 와 있어?", "area": "cost"}]}, ensure_ascii=False)) + assert main(["eval", "--n8n", fake.base, str(one)]) == 4 + assert "✘ 알림이 몇 건 와 있어? · area alerts · area_ok" in capsys.readouterr().out + assert fake.calls[-1][1] == {"cases": [{"question": "알림이 몇 건 와 있어?", "area": "cost"}]} + + +def test_eval_empty_is_usage(fake, tmp_path, capsys): + empty = tmp_path / "empty.json" + empty.write_text('{"cases": []}') + assert main(["eval", "--n8n", fake.base, str(empty)]) == 2 + + +def test_audit_lists_questions(fake, capsys): + main(["audit", "--api", fake.base]) + out = capsys.readouterr().out + assert out.startswith("3 question(s)") and "refused read_only" in out and "answered" in out + + +def test_tools_never_fail(capsys): + assert main(["tools"]) == 0 diff --git a/Last3Mon/79/tops/tests/test_client.py b/Last3Mon/79/tops/tests/test_client.py new file mode 100644 index 0000000..55a39d2 --- /dev/null +++ b/Last3Mon/79/tops/tests/test_client.py @@ -0,0 +1,38 @@ +"""R5 · R13: tops calls only the read addresses; n8n off is a read error, not a crash.""" +import pytest + +from tops import client + + +def test_only_read_addresses(fake): + with pytest.raises(ValueError): + client.post(fake.base, "/dlq/update", {"id": 1}) + with pytest.raises(ValueError): + client.post(fake.base, "/ops-reset", {}) + assert fake.calls == [] + + +def test_facts_sends_the_period(fake): + client.facts(fake.base, date_from="2026-10-19", date_to="2026-10-23") + assert fake.calls == [("/webhook/my-ops-facts", {"from": "2026-10-19", "to": "2026-10-23"})] + + +def test_file_wins_over_n8n(fake, tmp_path): + f = client.facts(fake.base, file=str(__import__("pathlib").Path(__file__).parent / "data" / "facts.json")) + assert f["overall"] == "red" and fake.calls == [] + + +def test_facts_without_health_is_refused(tmp_path): + p = tmp_path / "old.json" + p.write_text('{"runs": {}}') + with pytest.raises(client.ReadError, match="health"): + client.facts(file=str(p)) + + +def test_n8n_off_is_read_error(down): + with pytest.raises(client.ReadError): + client.ask(down, "지난주 오류율이 얼마야?") + + +def test_http_error_body_is_returned(fake): + assert client.evaluate(fake.base, []) == {"error": "cases must be a non-empty list"} diff --git a/Last3Mon/79/tops/tests/test_view.py b/Last3Mon/79/tops/tests/test_view.py new file mode 100644 index 0000000..b9a9657 --- /dev/null +++ b/Last3Mon/79/tops/tests/test_view.py @@ -0,0 +1,41 @@ +"""R1 · R7: the colors and numbers come from the facts unchanged.""" +from conftest import load + +from tops import view + + +def test_status_shows_every_area_with_the_facts_colors(): + lines = view.status_lines(load("facts.json")) + assert lines[0].startswith("overall 🔴 red · runs 2026-10-26 ~ 2026-10-30") + assert " 🔴 runs error rate 5.88% · p95 2598ms · unanswered 4.76%" in lines + assert " 🔴 DLQ 3 (parked 2)" in lines + assert " 🟡 cost $0.073604 / $0.08 (92.01%)" in lines + assert " 🔴 release 1.1.0 · canary 1.2.0 20% (repeat 62.5%)" in lines + + +def test_source_down_is_unknown_not_zero(): + lines = view.status_lines(load("facts-cost-down.json")) + assert " ⚪ cost source not answering" in lines + assert lines[-1] == " sources down: cost" + + +def test_colors_are_not_recounted(): + f = load("facts.json") + f["health"]["cost"] = "green" # n8n says green: tops shows green even at 92% + assert any(line.startswith(" 🟢 cost") for line in view.status_lines(f)) + + +def test_answer_lines_refused_and_checked(): + asks = load("asks.json") + assert view.answer_lines(asks["DLQ 1번 다시 돌려 줘"])[0] == "[action · keyword] refused (read_only)" + assert view.answer_lines(asks["지난주 오류율이 얼마야?"])[0] == "[runs · keyword] numbers ✔" + + +def test_missing_tool_is_reported_not_raised(): + part = view.tool("no-such-course-tool", ["--json"]) + assert part == {"tool": "no-such-course-tool", "ok": False, "why": "not installed (uv tool install)"} + + +def test_tool_line_reads_tlog_slo(): + part = {"tool": "tlog", "ok": True, "code": 4, "result": {"ok": False, "breaches": ["error_rate", "unanswered_rate"], "total": 357}} + assert view.tool_line(part) == " ✔ tlog slo: breaches error_rate, unanswered_rate (357 runs)" diff --git a/Last3Mon/79/tops/uv.lock b/Last3Mon/79/tops/uv.lock new file mode 100644 index 0000000..7a58c28 --- /dev/null +++ b/Last3Mon/79/tops/uv.lock @@ -0,0 +1,195 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/98/7a1a5f31fd5c7ba93e963b168e244b8e3dd705b3d2a718e3c3307583bf57/linkify_it_py-2.2.0.tar.gz", hash = "sha256:907acd2d17ac1fbb9ddb62c8957ccbd6158cac602231a15c3b0cd1e215f03cee", size = 32939, upload-time = "2026-08-29T07:07:08.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl", hash = "sha256:3adc40eb5af300b2605fcfdb968c24e1d780a90f1f2221af7c15e5111e94d443", size = 21971, upload-time = "2026-08-29T07:07:07.164Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/4e/2684728d9f2c8c4110738dcadca7e0bed4b60c8dc68c40c2a9e0cf017900/platformdirs-4.14.2.tar.gz", hash = "sha256:6d13b4c1d66b7548dcec9e3db13c1ba9d9adb65041b5d07869313afc07e6e41b", size = 70792, upload-time = "2026-10-31T15:10:34.029Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/2d/98af31fdcd7c95c7114e2eb7c2eb0fa55d2a012c37ba4eb3722b7272abcb/platformdirs-4.14.2-py3-none-any.whl", hash = "sha256:dc1f93390f2a9265e44e5afc8667ec7faf737739604e39bc538fec889720c36b", size = 35291, upload-time = "2026-10-31T15:10:32.615Z" }, +] + +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/9a/887535e477847f5159377ab35303923644840b13c0ca03c0a467f84d4680/pytest_asyncio-1.4.1.tar.gz", hash = "sha256:e8266eb094e5ee9ffdb2068c9836f33e209ea77a970eb846933d416b5602669b", size = 145237, upload-time = "2026-10-25T21:05:37.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/d7/cd9e2708afdbf059658adaf08bdb0792ca52489871051d6ce7d498b51277/pytest_asyncio-1.4.1-py3-none-any.whl", hash = "sha256:507afa78ec77c6661164ef1a2368bc7820383fd1a14b2a7f813de8b5a92949cc", size = 17061, upload-time = "2026-10-25T21:05:35.473Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "textual" +version = "8.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, +] + +[[package]] +name = "tops" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "textual" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [{ name = "textual", specifier = ">=0.80" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8" }, + { name = "pytest-asyncio", specifier = ">=0.23" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] diff --git a/Lecture/79/00-DAY79-OVERVIEW.md b/Lecture/79/00-DAY79-OVERVIEW.md new file mode 100644 index 0000000..057bbfb --- /dev/null +++ b/Lecture/79/00-DAY79-OVERVIEW.md @@ -0,0 +1,446 @@ +# 79일차 개요 · 운영 데이터 기반 Workflow Ops Assistant + +> 노코드 멀티 에이전트 워크플로우 설계 및 운영 · 2026-11-04 +> 30장 · 각 장은 가로줄(`---`)로 나뉩니다. 1교시에는 1~6장을 보고, 나머지 장은 각 단계 강의(1 · 3 · 5교시) 때 함께 띄웁니다. +> 숫자는 모두 강사 노트북 실측입니다 (65일차 n8n 2.42.6, 79일차 판 데이터 API의 2026-11-04 08:30 운영 아침 상태 · 실행 기록 666건, OpenAI `gpt-4.1-mini`(답 · 라우터 · 요약, temperature 0), 11/01 정답 워크플로우를 학생 순서대로 1회). + +| 장 | 범위 | 띄우는 때 | +| --- | --- | --- | +| 1~6 | 표지 · 목표 · 시간표 · 수요일 아침 08:30 · 일하는 순서 · 실습 15개 | 1교시 시작 | +| 7~14 | 1단계 · 운영 사실 | 1교시 강의 | +| 15~21 | 2단계 · 질문 답변 | 3교시 강의 | +| 22~28 | 3단계 · 브리핑과 평가 | 5교시 강의 | +| 29~30 | 바이브 코딩 · 정리와 예고 (8교시 35분 정리는 [04-STAGE4-WRAPUP](04-STAGE4-WRAPUP.md)) | 7교시 · 8교시 | + +--- + +## 1 / 30 · 표지 + +```mermaid +flowchart LR + S[("출처 여섯
실행 · 알림 · DLQ ·
품질 · 비용 · 릴리스")] --> F["사실 · 상태
Code"] --> A["질문 답변
라우터 · 거절 · 숫자 확인"] --> B["브리핑 · 평가
먼저 볼 것 · 골든 질문"] + classDef d fill:#0F3D3E,stroke:#0F3D3E,color:#fff + classDef o fill:#F2A541,stroke:#F2A541,color:#fff + class S d + class F,A,B o +``` + +**79일차** + +# 운영 데이터 기반 Workflow Ops Assistant + +숫자 · 상태는 Code가 · 글은 LLM이 · 작업은 사람이 + +노코드 멀티 에이전트 워크플로우 설계 및 운영 · 2026-11-04 + +--- + +## 2 / 30 · 오늘 할 수 있게 될 세 가지 + +| | 목표 | 내용 | +| --- | --- | --- | +| 1 | **사실로 모은다** | 흩어진 출처 여섯을 정의가 정해진 사실 하나로, 기간과 지금을 나누고, 0과 `null`을 구분하고, 영역마다 색을 매기고, 출처가 멈춰도 나머지를 내놓는다 | +| 2 | **답하고 막는다** | 질문을 영역 하나로 고르고 그 사실만으로 답하고, 운영 밖 · 작업 요청은 LLM 앞에서 거절하고, 숫자를 확인하고, 낱말이 모를 때만 LLM 라우터를, 감사 기록을 남긴다 | +| 3 | **정리하고 시험한다** | 먼저 볼 것 · 할 일을 규칙으로 정한 브리핑, 덧붙이는 LLM 요약, 사람이 일한 뒤의 브리핑, 골든 질문 평가와 평가 관문 | + +--- + +## 3 / 30 · 오늘의 시간표 + +| 교시 | 시간 | 내용 | +| --- | --- | --- | +| 1 | 09:00~09:50 | 개요 · **1단계 강의** · 운영 사실 | +| 2 | 10:00~10:50 | **Lab 01 실습 5개** · 출처 둘러보기 · 운영 사실 · 기간 · 영역별 상태 · 출처가 멈추면 | +| 3 | 11:00~11:50 | **2단계 강의** · 질문 답변 | +| 4 | 12:00~12:50 | **Lab 02 실습 5개** · 라우터 · 거절 · 숫자 확인 · 질문을 바꾸면 · LLM 라우터 · 감사 기록 | +| - | 13:00~14:00 | 점심 | +| 5 | 14:00~14:50 | **3단계 강의** · 브리핑과 평가 | +| 6 | 15:00~15:50 | **Lab 03 실습 5개** · 브리핑 · LLM 요약 · 아침이 바뀌면 · 골든 질문 평가 · 평가 관문 | +| 7 | 16:00~16:50 | 바이브 코딩 `tops`: 요청서 → TODO → SPEC → IMP-PLAN → 만들기 → 설치 | +| 8 | 17:00~17:50 | 발표 · **4단계 정리** (의미 · 효과 · 회사에서 쓰는 법) · 80일차(단위 프로젝트) 예고 | + +--- + +## 4 / 30 · 오늘의 출발점: 수요일 아침 08:30 + +운영 담당자가 출근했습니다. 72~78일차가 남긴 것이 여섯 군데에 흩어져 있습니다. + +| 출처 | `morning` | 팀의 기준 대시보드 | +| --- | --- | --- | +| 실행 기록 | 지난주 357건 | **666건** · 3.153… (기간 무시 · 백분율) | +| 알림 | 6 (높음 3) | 6 | +| DLQ | 3, **parked 2** | 3 | +| 품질 · 격리 | 4줄 · 격리 1 | **없음** | +| 비용 | $0.073604 / $0.08 (92%) | 금액 둘 | +| 릴리스 | 1.1.0 + **카나리 1.2.0 20%** | **없음** | + +그리고 팀의 기준 챗봇에 "DLQ 1번 다시 돌려 줘"라고 하면: **"DLQ 1번 작업을 다시 실행해 드리겠습니다."** (아무것도 실행하지 않았음) + +--- + +## 5 / 30 · 일하는 순서 (오늘은 "정의 → 요청문 → 사실 · 답 → 확인") + +```mermaid +flowchart LR + A["① 먼저 보기
morning · call · test(전)"] --> B["② 정의 · 기준 · 요청문"] --> C["③ 에이전트
my-work · check"] --> D["④ 내가
push · test -v · call"] + D -->|"✘ 칸 = 실제 (expected 기대)"| C + D -->|✔| E["⑤ OPS.md 한 줄"] +``` + +| 누가 | 무엇을 | +| --- | --- | +| 나 | 무엇을 물을지 · 사실의 정의 · 색의 기준 · 도우미가 **하면 안 되는 것** · 골든 질문 정하기, 그리고 **작업하기** | +| CLI 에이전트 | `AGENTS.md`대로 워크플로우 JSON, `check` | +| `flow.py` | `test --webhook`(기준과 비교), `morning`(운영 아침 한눈에, `--reset`), `call` | + +--- + +## 6 / 30 · 실습 15개 (Lab마다 예제 3 · 과제 2) + +| | 예제 1 | 예제 2 | 예제 3 | 과제 1 | 과제 2 | +| --- | --- | --- | --- | --- | --- | +| Lab 01 | 출처 둘러보기 · 기준 대시보드 (에이전트 없이) | 운영 사실 | 기간을 바꾸면 (에이전트 없이) | 영역별 상태 | 출처가 멈추면 | +| Lab 02 | 라우터 · 그 영역 사실 · 운영 밖 거절 | 작업 거절 · 숫자 확인 | 질문을 바꾸면 (에이전트 없이) | LLM 라우터 | 감사 기록 | +| Lab 03 | 아침 브리핑 | LLM 요약 | 아침이 바뀌면 (에이전트 없이) | 골든 질문 평가 | 평가 관문 | + +--- + +## 7 / 30 · 1단계 · 운영 도우미가 하는 일 + +> 생활 속 예: 병원 아침 인수인계 — 차트 열두 개를 다 읽지 않고, 정해진 항목을 정해진 순서로 넘긴다. "지금 괜찮나? 무엇부터 볼까?" + +| 업계에서 쓰는 말 | 하는 일 | +| --- | --- | +| 운영 대시보드 (Grafana · Datadog) | 숫자 · 색을 한 화면에 | +| ChatOps (Slack 봇 · PagerDuty 연동) | 대화창에서 묻고 답 받기 | +| AIOps (Datadog Bits AI · Dynatrace Davis) | 운영 데이터에 AI (요약 · 분류 · 이상 탐지) | +| 아침 브리핑 · 당번 인수인계 | 먼저 볼 것과 할 일 | + +- 오늘의 원칙: **숫자 · 상태는 Code가, 글은 LLM이, 작업은 사람이.** 도우미는 읽기만. + +--- + +## 8 / 30 · 출처 여섯 = 묻는 영역 여섯 + +> 생활 속 예: 인수인계 양식의 칸 — 체온 · 수액 · 투약 · 검사 · 식사 · 특이 사항. + +| 영역 | 묻는 것 | 남긴 날 | +| --- | --- | --- | +| 실행 | 오류 · 느림 · 답 못 함 | 77일차 실행 기록 | +| 알림 | 무엇이 울렸나 · 얼마나 급한가 | 76~78일차 | +| DLQ | 사람이 볼 일이 남았나 | 76일차 | +| 품질 | 데이터를 믿을 수 있나 | 78일차 | +| 비용 | 한도 안인가 · 누가 쓰나 | 75일차 | +| 릴리스 | 무슨 판이 나가나 · 카나리는 괜찮나 | 74일차 | + +- SRE의 네 가지 황금 신호(지연 · 트래픽 · 오류 · 포화)에 **품질 · 비용 · 릴리스**가 더해진 것이 AI 워크플로우 운영. + +--- + +## 9 / 30 · 없는 칸이 가장 위험하다 (Lab 01 예제 1) + +> 생활 속 예: "모두 체온 정상"만 적힌 메모 — 거짓은 없지만 수액 칸이 없다. + +| 대시보드의 실수 | 누가 알아채나 | +| --- | --- | +| 틀린 숫자 | 누군가 "이상한데?" | +| **없는 칸** | 아무도 — "괜찮다"처럼 보임 | + +- 기준 대시보드만 보면 오늘 가장 급한 둘(**parked 2건 · 카나리 1.2.0**)을 모름. +- 그래서 먼저 정하는 것은 계산이 아니라 **무엇을 물어야 하나**. + +--- + +## 10 / 30 · 사실 하나로 · 정의는 다시 만들지 않는다 (Lab 01 예제 2) + +> 생활 속 예: 병원 공용 차트 양식 — "고열 = 38도 이상"을 양식에 박아 두고 모두 그것을 읽는다. + +| 영역 | 내 `my-ops-facts`의 지난주 | +| --- | --- | +| 실행 | 357 · 0.0588 · p95 2598 · 답 못 함 0.0476 · 실패 1위 `Search FAQ` timeout 16 | +| 알림 · DLQ | 6 · 높음 3, 3 · parked 2 · 대기 1 | +| 품질 · 비용 | 평균 0.6583 · 격리 1, 0.9201 · 1위 `test-bot` 37% | +| 릴리스 | 카나리 다시 묻기 **62.5%** 대 1.1.0 7.73% | + +- 77일차 숫자와 **같음** — 시맨틱 레이어(dbt Semantic Layer · Cube). 출처 여덟 → Code 하나, 0.1초. `lab01` 0/5 → 5/5. + +--- + +## 11 / 30 · 기간과 지금 · 0과 `null` (Lab 01 예제 3) + +> 생활 속 예: "밤새 열이 두 번"(기간) · "지금 37.2도"(지금) · "소변량은 안 쟀음"(모름 ≠ 0). + +| 기간 | 실행 | 다시 묻는 비율 | 지금 숫자 | +| --- | --- | --- | --- | +| 두 주 | 666 · 0.0315 | 7.73% · 62.5% | 그대로 | +| 10/26 | 70 · 0.2286 | **`null`** (판이 10/28부터) | 그대로 | +| 빈 주 | 0 · 오류율 0 · 실패 1위 **`null`** | `null` | 그대로 | + +- 기간은 **실행 기록에만**. 0 = 셌고 없었다, `null` = 말할 수 없다. + +--- + +## 12 / 30 · 상태는 규칙으로 (Lab 01 과제 1) + +> 생활 속 예: 병동 현황판의 빨강 · 노랑 자석 — 간호사의 기분이 아니라 기준표로 붙인다. + +| 영역 | 🔴 | 🟡 | 지난주 | +| --- | --- | --- | --- | +| 실행 | 오류율 > 0.01 | 답 못 함 > 0.02 | 🔴 | +| 알림 | 높음 ≥ 5 | 1건 이상 | 🟡 | +| DLQ | parked > 0 | 남음 | 🔴 | +| 품질 | 마지막 < 0.8 | < 0.9 · 격리 | 🟡 | +| 비용 | ≥ 100% | ≥ 80% | 🟡 | +| 릴리스 | 카나리 > 2배 | 카나리 켜짐 | 🔴 | + +- RAG 상태 · SLO 판정 (77일차). 비율이 `null`이면 빨강이 아니라 노랑 — "모르니 지켜보자". + +--- + +## 13 / 30 · 출처가 멈추면 (Lab 01 과제 2) + +> 생활 속 예: 검사실 전화가 안 되는 아침 — 나머지는 넘기고 "검사 결과: 받지 못함". "정상"이라고 적지 않는다. + +| | 고치기 전 | 고친 뒤 | +| --- | --- | --- | +| 사용량 출처가 500 | 사실 전체가 **500** | 200 · 다섯 영역 그대로 | +| 비용 | — | `null` · ⚪ `unknown` · `sources_down: ["cost"]` | +| 전체 | — | 🔴 (빨강이 없어도 unknown이면 적어도 🟡) | + +- 우아한 저하(graceful degradation). 비용을 0으로 적었다면 🟢 — 실제는 92%. + +--- + +## 14 / 30 · 1단계 정리 · 사실 + +| 원칙 | 오늘 | +| --- | --- | +| 무엇을 물을지 먼저 | 영역 여섯 — 없는 칸이 가장 위험 | +| 정의는 한 곳 | 77일차 숫자 그대로, 다음 단계는 사실만 읽음 | +| 시간 종류 · 모름 | 기간은 실행에만, 0과 `null` | +| 색은 기준표로 | 🔴 셋 · 🟡 셋, 비교 불가는 🟡 | +| 부분 실패 | 출처 하나가 멈춰도 200 · `unknown` | + +--- + +## 15 / 30 · 2단계 · 기준 챗봇의 세 실수 + +> 생활 속 예: 무엇이든 대답하는 새내기 간호사 — 맛집도 알려 주고, 권한도 없이 "약 바꿔 드릴게요". + +| 질문 | 기준 챗봇 (`base-ops-ask`) | 위험 | +| --- | --- | --- | +| 점심 메뉴 추천해 줘 | 김치찌개 · 불고기 · 비빔밥 | 범위 밖 | +| DLQ 1번 다시 돌려 줘 | "**다시 실행해 드리겠습니다**" | 하지 않은 일을 했다고 | +| 카나리 버전 문제 있어? | 오류율 · 알림 · DLQ를 섞음 (62.5% 없음) | 원인을 잘못 짚음 | + +- `lab02` **0/6**. 가드레일(NeMo Guardrails) · 범위 제한 · 근거 제한을 **워크플로우의 갈래**로. + +--- + +## 16 / 30 · 라우터 · 그 영역 사실만 (Lab 02 예제 1) + +> 생활 속 예: 병원 안내 데스크 — 진단하지 않고 어디로 보낼지만, 심장내과에 정형외과 차트는 주지 않는다. + +| 방식 | 장점 | 단점 | +| --- | --- | --- | +| 낱말 목록 (Code) | 0초 · 공짜 · 늘 같음 | 목록에 없는 말은 모름 | +| LLM 분류 | 처음 보는 말도 | 1~3초 · 비용 | + +- 오류율 → `runs` 5.88% · 카나리 → `release` 62.5% 대 7.73% · 점심 → 거절 0.1초 (LLM 없음). +- 그러나 "DLQ 1번 다시 돌려 줘" → `dlq`로 답 "**#1은 대기 중**" (틀림). `lab02` 3/6. + +--- + +## 17 / 30 · 거절은 LLM 앞에서 · 읽기만 (Lab 02 예제 2) + +> 생활 속 예: 처방은 의사만 — 간호사의 성격이 아니라 병원의 규칙. "전해 드릴게요"는 되고 "바꿔 드릴게요"는 안 된다. + +| 막는 곳 | 확실한가 | +| --- | --- | +| 지시문 "작업은 거절해" | 대개 (LLM이 따를 때만) | +| **라우터 앞 Code 갈래** | 늘 — 0초, LLM은 보지도 않음 | +| 쓰는 API를 아예 안 부름 | 늘 — 할 수단이 없음 | + +- "DLQ 1번 다시 돌려 줘" → `action` · `read_only` · **0.0초** · "런북에 따라 담당자가 직접". 최소 권한 · human-in-the-loop. + +--- + +## 18 / 30 · 숫자 확인과 그 한계 (Lab 02 예제 2) + +> 생활 속 예: 메모의 숫자를 차트와 맞춰 보기 — 없는 숫자는 잡지만, "2번 환자 39.1"을 3번으로 적은 것은 못 잡는다. + +| 잡는 것 | 못 잡는 것 | +| --- | --- | +| 지어낸 숫자 · 계산 실수 | 사실에 있는 숫자를 **다른 뜻**으로 | +| | 두 숫자를 **거꾸로** | + +- 비용 92.01 (= 0.9201 × 100) · 릴리스 62.5 (`1.2.0`은 버전으로 통째로) → `numbers_ok: true`. `lab02` **6/6**. +- 예제 1의 "#1은 대기 중"은 통과 (1은 사실에 있음) — 그래서 작업 요청은 **답하지 않고 막는다**. + +--- + +## 19 / 30 · 질문을 바꾸면 (Lab 02 예제 3) + +> 생활 속 예: 안내 데스크의 단어장 — "숨이 차요"는 없다. 짐작하지 않고 "모르겠습니다"는 불편하지만 안전. + +| 질문 | 낱말 라우터 | 운영자에게 | +| --- | --- | --- | +| 에러 많이 났어? | runs | 좋음 | +| 장애 났어? | 거절 | **불편** | +| 알림이랑 DLQ | alerts (같으면 앞) | 반쪽 | +| 카나리 꺼 줘 | 작업 거절 | 안전 | +| AI 쓰는 데 얼마나 나갔어? | 거절 | **불편** | + +- 6/6 = **규칙대로**. 실패의 방향이 "모르면 거절"이라 안전. + +--- + +## 20 / 30 · LLM 라우터 · 낱말이 모를 때만 (Lab 02 과제 1) + +> 생활 속 예: 단어장에 없을 때만 선배를 부른다 — 모든 질문마다 부르면 선배가 아무 일도 못 한다. + +| 질문 | 영역 · 라우터 | 시간 | +| --- | --- | --- | +| AI 쓰는 데 얼마나 나갔어? | `cost` · `llm` · 92.01% | 4.9초 | +| 어젯밤 접수 못 하고 남은 건 | `dlq` · `llm` · 3건 | 1.7초 | +| 지난주 오류율 | `runs` · `keyword` (그대로) | 1.0초 | + +- 모델 계단(cascade). 예제 3 다시 → **4/6** (장애 · AI 비용이 이제 답함 — 기대가 옛 규칙). +- "2건은 대기 · 1건은 주차" — **거꾸로**인데 `numbers_ok: true`. + +--- + +## 21 / 30 · 감사 기록 (Lab 02 과제 2) + +> 생활 속 예: 간호사실 전화 기록부 — 몇 시 · 무엇을 · 어떻게 답했나. "약 바꿔 달라는 전화가 하루 열 번"이 보인다. + +| 질문 | 기록 | +| --- | --- | +| DLQ 1번 다시 돌려 줘 | `action` · `keyword` · `read_only` | +| 점심 메뉴 | `unknown` · **`llm`** · `out_of_scope` (비용이 든 거절) | +| 지난주 오류율 | `runs` · `keyword` · `numbers_ok: true` | + +- LLM 관측성 (Langfuse · LangSmith). 덧붙이기만 — 읽기 전용의 예외, 응답은 그대로. 질문 원문의 개인정보 주의. + +--- + +## 22 / 30 · 3단계 · 브리핑 · 먼저 볼 것은 규칙으로 (Lab 03 예제 1) + +> 생활 속 예: 인수인계는 병실 순서가 아니라 빨강 환자부터, 환자마다 다음 할 일이 정해져 있다. + +| 먼저 볼 것 | 할 일 (런북) | +| --- | --- | +| 🔴 실행 | 오류 묶음 1위 확인 · 고치기 (77일차) | +| 🔴 DLQ | parked 건 원인 확인 뒤 런북대로 재처리 (76일차) | +| 🔴 릴리스 | 카나리 끄기 · 되돌리기 검토 (74일차) | + +- red 다음 yellow의 앞 3개, 표의 색은 사실 그대로. LLM 없음, `lab03` 4/4. + +--- + +## 23 / 30 · LLM 요약은 덧붙이는 말 (Lab 03 예제 2) + +> 생활 속 예: 인수인계 끝의 한마디 "오늘은 3호실이 제일 걱정" — 기억에 남지만 표를 대신하면 다른 두 환자가 빠진다. + +- 요약: "전체 시스템 상태는 빨간색으로, 오류율은 5.88% … 가장 긴급한 문제는 'Search FAQ' 노드 … 16건 …" +- `numbers_ok: true`, 두 번 부르면 글이 조금 다름. +- 먼저 볼 것 셋 중 **하나**만 말함 (DLQ · 카나리 없음) → 표 · 순서는 Code가. + +--- + +## 24 / 30 · 아침이 바뀌면 (Lab 03 예제 3) + +> 생활 속 예: 빨강 자석을 뗀 까닭 — 열이 내려서 · 다른 병동으로 옮겨서 · 기준을 바꿔서. 셋째를 "나았다"고 하면 큰일. + +| 사람이 한 일 | 바뀐 것 | 뜻 | +| --- | --- | --- | +| parked 둘 처리 | DLQ 🔴 → 🟡 | 일을 끝냄 | +| 카나리 끔 | 릴리스 🔴 → 🟢 | 위험을 멈춤 (1.2.0은 그대로) | +| 예산 0.08 → 0.2 | 비용 🟡 → 🟢 | 한도를 바꿈 (쓴 돈은 그대로) | + +- 남은 빨강은 실행 하나 (FAQ timeout). "초록입니다"가 아니라 **무엇을 했는지**를 말한다. + +--- + +## 25 / 30 · 골든 질문 평가 (Lab 03 과제 1) + +> 생활 속 예: 새 간호사의 모의 인수인계 시험 — 정해진 질문 여덟 개, 누가 채점해도 같다. + +| 칸 | 묻는 것 | +| --- | --- | +| `area_ok` | 맞는 영역으로 | +| `refused_ok` | 거절할 것을 거절 | +| `number_ok` | 기대 숫자가 답에 | +| `grounded` | 답의 숫자가 사실에 | + +- 골든 8 → **8/8 · score 1** (3.9초). OpenAI Evals · promptfoo · RAGAS. + +--- + +## 26 / 30 · 평가는 시험지도 시험한다 (Lab 03 과제 1) + +| 케이스 | 결과 | 뜻 | +| --- | --- | --- | +| "알림이 몇 건?" 기대 영역 `cost` (일부러 틀림) | `area_ok: false`만 | 도우미는 맞고 **시험지가** 틀림 | +| 빈 목록 | 400 | "0개 중 0개 = 100%"로 속지 않음 | + +- 8/8이어도 "parked 2 · 대기 1을 거꾸로" 같은 답은 통과 → 골든 세트에 **틀리기 쉬운 질문**을 더해 간다. + +--- + +## 27 / 30 · 평가 관문 (Lab 03 과제 2) + +> 생활 속 예: 합격선 80점 — 없으면 50점도 병동에 나간다. 떨어지면 수간호사에게 알림. + +| 평가 | score | 기준 | gate | +| --- | --- | --- | --- | +| 기준 없음 | 1 | 0.9 (기본) | pass | +| 골든 8 | 1 | 0.8 | pass | +| 틀린 기대 | 0 | 0.9 | **fail** → `eval_below` 알림 | + +- 74일차 배포 게이트 · 78일차 품질 게이트와 같은 모양. LLMOps의 회귀 시험 (CI에서 평가). + +--- + +## 28 / 30 · 병원 아침으로 본 오늘 하루 + +> 생활 속 예: 오늘 만든 것이 모두 병원 아침에 있다. + +| 오늘 | 병원 | +| --- | --- | +| 출처 여섯 · 사실 · 정의 | 차트 여섯 · 인수인계 양식 · 공용 차트 기준 | +| 기간 · 0과 `null` · 상태 | 밤새 · 지금 · "안 쟀음" · 빨강 · 노랑 자석 | +| 출처가 멈추면 | 검사 결과 "받지 못함" | +| 라우터 · 거절 · 숫자 확인 | 안내 데스크 · 처방은 의사만 · 차트와 맞춰 보기 | +| LLM 라우터 · 감사 | 선배 간호사 · 전화 기록부 | +| 브리핑 · 요약 · 아침이 바뀌면 | 빨강 환자부터 · 끝의 한마디 · 자석을 뗀 까닭 | +| 평가 · 관문 · 평가가 바꾸는 것 | 모의 시험 · 합격선 · 시험 중 울린 호출벨 | + +--- + +## 29 / 30 · 7교시 바이브 코딩 · `tops` + +요청서(운영 당번 · 팀장의 말 ①~⑫) → `Doc/TODO.md` → `SPEC.md` → `IMP-PLAN.md` → 단계별로 → `uv tool install`. + +| 오늘 n8n에서 | `tops`로 터미널에서 (당번의 아침) | +| --- | --- | +| `my-ops-facts` | `tops status` — **n8n이 정한 색 그대로**, 빨강이면 종료 코드 4 | +| `my-ops-ask` | `tops ask "…"` — 영역 · 거절 · 숫자 확인 | +| `my-ops-brief` · `my-ops-eval` | `tops brief` · `tops eval` (관문 실패면 4) | +| 감사 · 지난 도구 | `tops audit` · `tops tools` (`tlog slo` · `tcheck`) | +| (없음) | TUI 탭 다섯, 못 읽는 탭만 "not available", **읽기만** (주소 목록) | + +- 강사 본: 실제 n8n에서 `tops status` 일곱 줄 · 종료 코드 4 · 0.19초, 테스트 27개 8.6초 (n8n 없이). + +--- + +## 30 / 30 · 오늘의 정리 · 80일차 예고 + +| 단계 | 오늘 숫자 | +| --- | --- | +| 사실 | 기준 666 · 칸 없음 → 357 · 여섯 영역, 기간 넷, 🔴 셋 🟡 셋, 출처가 멈춰도 200 · `unknown` | +| 질문 답변 | 기준 0/6 ("다시 실행해 드리겠습니다") → 3/6 → 6/6, 말투 여섯, LLM 라우터 5/5 (phrasing 4/6), 감사 3줄 | +| 브리핑 · 평가 | 먼저 볼 것 셋 · 요약 세 문장, 사람이 일한 뒤 빨강 하나, 골든 8/8, 관문 fail → 알림 | + +- 8교시 [4단계 정리](04-STAGE4-WRAPUP.md): 의미 · 효과 · 업계 이름 · 면접 · 최종 프로젝트 체크리스트. +- 80~82일차: **단위 프로젝트** — 팀마다 64~79일차의 워크플로우를 묶어 하나의 서비스로. 오늘의 운영 도우미가 그 서비스의 운영 부분이 된다. diff --git a/Lecture/79/00-DAY79-PREP.md b/Lecture/79/00-DAY79-PREP.md new file mode 100644 index 0000000..e52d334 --- /dev/null +++ b/Lecture/79/00-DAY79-PREP.md @@ -0,0 +1,93 @@ +# 79일차 실습 준비 · 수업 전에 미리 해 두면 좋은 것 + +- 수업일: 2026-11-04 (79일차) · 운영 데이터 기반 Workflow Ops Assistant +- 준비 시간: 약 10분 +- 먼저 WSL에서 `cd ~/aio-01d4-stu && git pull`로 오늘 자료를 받습니다 (수강생 저장소: https://gl.isitsoft.kr/publicproj/aio-01d4-stu). +- **수업 전날까지** 1~2절을 끝내 주세요. + +```mermaid +flowchart LR + A["1. n8n · 데이터 API 켜기
(79일차 판)"] --> B["2. morning ·
doctor ready"] --> C["3. 미리 읽기"] +``` + +--- + +## 1. n8n과 데이터 API 켜기 (전날 필수, 3분) + +오늘은 **79일차 판 데이터 API**(78일차 + 운영 아침 상태)를 씁니다. 켜는 순간 알림 · DLQ · 품질 기록 · 사용량 · 예산 · 릴리스가 "수요일 아침 08:30" 상태로 채워집니다. + +```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 +curl -s localhost:8800/ops/seed-info # {"seeded_at": "2026-11-04T08:30:00", ... "counts": {"alerts": 6, ... +``` + +- **66일차부터 LLM은 OpenAI API 키(`Last3Mon/.env`, Credential `OpenAI (.env)`)만 씁니다.** Ollama는 쓰지 않습니다. +- `network day65-n8n_default not found` → n8n이 켜져 있지 않음. 위 n8n 부분부터 다시. + +## 2. 상태 보기와 준비 확인 (전날 필수, 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 +uv run tools/flow.py doctor +``` + +```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 + ... +DLQ: 3 (parked 2) +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} +... +✔ operations morning /ops/seed-info (day 79 data API) +✔ credential 'OpenAI (.env)' (openAiCourse0001) +ready +``` + +| ✘ 항목 | 할 일 | +| --- | --- | +| n8n container · data API | 1절 | +| operations morning | 데이터 API가 예전 판 → 1절 `--force-recreate` | +| credential | `cd ~/aio-01d4-stu/Last3Mon/65/n8n && bash register-credentials.sh` | +| CLI coding agent | [67일차 준비](../67/00-DAY67-PREP.md) 2절 | + +--- + +## 3. 미리 읽어 오면 좋은 내용 (선택, 약 5분) + +### 3.1 "지금 괜찮아요?" + +72~78일차에 운영 데이터를 많이 남겼습니다: 실행 기록 · 알림 · DLQ · 품질 기록 · 사용량 · 릴리스. 그런데 아침에 "지금 괜찮나?"에 답하려면 여섯 곳을 열어야 합니다. 오늘은 그것을 **한 번에 묻고 답하는** 운영 도우미를 만듭니다. `flow.py morning`을 보고 가장 먼저 볼 것 하나를 골라 오세요. + +### 3.2 용어 + +| 용어 | 한 줄 설명 | +| --- | --- | +| 운영 사실 | 여러 출처를 정해진 정의로 모은 하나의 JSON (숫자는 Code가) | +| 영역 상태 | 영역마다 🔴 · 🟡 · 🟢 (규칙으로) | +| 라우터 | 질문을 보고 어느 영역의 사실을 쓸지 고르는 곳 | +| 골든 질문 | 정답(영역 · 숫자)을 알고 있는 질문 — 도우미를 시험하는 데 씀 | +| 평가 관문 | 골든 질문 점수가 기준(0.9)보다 낮으면 실패 · 알림 | +| 감사 기록 | 질문마다 영역 · 거절 이유를 남기는 한 줄 | + +## 4. 당일 준비물 + +- 충전된 노트북과 충전기, WSL의 Docker, Windows Terminal(WSL) 창 **두 개**, 웹 브라우저 +- 77 · 78일차 `~/vibe/tlog` · `~/vibe/tcheck` (7교시 `tops`에서 부품으로 부름, 없어도 됨) + +## 확인 + +- [ ] `curl -s localhost:8800/ops/seed-info`에 `"alerts": 6` +- [ ] `flow.py morning`이 알림 · DLQ · 예산 · 카나리를 보여 줌 +- [ ] `base-ops-facts` · `base-ops-ask` 게시 +- [ ] `uv run tools/flow.py doctor` → 마지막 줄 `ready` diff --git a/Lecture/79/01-STAGE1-OPS-FACTS.md b/Lecture/79/01-STAGE1-OPS-FACTS.md new file mode 100644 index 0000000..9179ef0 --- /dev/null +++ b/Lecture/79/01-STAGE1-OPS-FACTS.md @@ -0,0 +1,269 @@ +# 1단계 · 운영 사실 + +- 교시: 1교시 강의 (09:00~09:50) · 2교시 [Lab 01](../../Last3Mon/79/LAB-01-Q-OPS-FACTS.md) 실습 5개 (10:00~10:50) +- 이 단계가 끝나면: 운영 담당자가 아침에 묻는 것을 영역 여섯으로 나눠 말하고, 흩어진 출처를 **정의가 정해진 사실 하나**로 모으고, 기간에 따르는 숫자와 "지금" 숫자를 나누고, 0(없었음)과 `null`(모름)을 구분하고, 정해진 기준으로 영역마다 색을 매기고, 출처 하나가 멈춰도 나머지 사실을 내놓을 수 있다. +- 숫자: 모두 강사 노트북 실측입니다 (65일차 n8n 2.42.6, 79일차 판 데이터 API — 2026-11-04 08:30 운영 아침 상태, 실행 기록 666건, 11/01 실습을 5개로 늘린 뒤 정답 워크플로우를 학생 순서대로 1회). 사실 · 상태는 Code라 누가 돌려도 같습니다. 출력 전부는 수업 뒤 공개되는 `Last3Mon/79/answers/LAB-TEST-RESULT.md`에 있습니다. +- 실습 가리키기: 이 문서에서 "Lab 01 예제 1"은 2교시 [Lab 01 문서](../../Last3Mon/79/LAB-01-Q-OPS-FACTS.md)의 그 절입니다. 파일 경로(`cases/` · `requests/` · `my-work/` · `reference/`)는 실습 폴더 `~/aio-01d4-stu/Last3Mon/79` 기준입니다. + +```mermaid +flowchart LR + A["1. 운영 도우미"] --> B["2. 출처 여섯 ·
없는 칸"] --> C["3. 사실 하나로"] --> D["4. 기간과 지금 ·
0과 null"] --> E["5. 상태는 규칙으로"] + E --> F["6. 출처가 멈추면"] --> G["7. 요청문으로"] +``` + +## 0. 이 시간 (50분) + +| 시간 | 절 · 개요 | 하는 일 | +| --- | --- | --- | +| 09:00~09:06 | 개요 1~6장 | 오늘 하루, 수요일 아침 08:30, 실습 15개 | +| 09:06~09:13 | 1 · 개요 7장 | 운영 도우미가 하는 일 · 하지 않는 일 | +| 09:13~09:20 | 2 · 개요 8~9장 | 출처 여섯, 기준 대시보드의 없는 칸 | +| 09:20~09:28 | 3 · 개요 10장 | 사실 하나로 — 정의는 다시 만들지 않는다 | +| 09:28~09:34 | 4 · 개요 11장 | 기간과 지금, 0과 `null` | +| 09:34~09:43 | 5 · 6 · 개요 12~14장 | 상태는 규칙으로, 출처가 멈추면 | +| 09:43~09:50 | 7 · 8 · 확인 질문 | 요청문, 자주 하는 실수 | + +### 2교시 Lab 01에서 이어서 하는 것 + +| 2교시 실습 (시각) | 이 강의의 절 | 실습에서 하는 일 | 쓰는 파일 · 명령 | +| --- | --- | --- | --- | +| 시작 (10:00) | — | 운영 도우미 설계표 복사 | `cp templates/OPS-ASSISTANT-TEMPLATE.md my-work/OPS.md` (오늘의 사실 · 기준 · 거절을 모두 여기에) | +| Lab 01 예제 1 (10:00~10:10, 에이전트 없이) | 1 · 2 | 출처를 `morning`으로 둘러보고 기준 대시보드와 비교 | `morning`, `call base-ops-facts requests/week2.json` → `OPS.md` 1절 | +| Lab 01 예제 2 (10:10~10:25) | 3 | 운영 사실 `my-ops-facts` | `my-work/ops-facts.json` (새로), 테스트 `cases/lab01.json` (전: `--webhook base-ops-facts`) | +| Lab 01 예제 3 (10:25~10:32, 에이전트 없이) | 4 | 기간을 바꾸면 | `test cases/lab01-period.json -v` → `OPS.md` 2절 | +| Lab 01 과제 1 (10:32~10:42) | 5 | 영역별 상태 | 테스트 `cases/lab01-challenge.json` → `OPS.md` 3절 | +| Lab 01 과제 2 (10:42~10:50) | 6 | 출처가 멈추면 | 테스트 `cases/lab01-challenge2.json` (사용량 출처를 멈춤) | + +## 1. 운영 도우미: 아침 08:30에 묻는 것 (2교시 Lab 01 예제 1) + +### 일반 설명 + +> **생활 속 예 · 병원 아침 인수인계.** 아침 8시 반, 밤 근무 간호사가 낮 근무에게 병동을 넘깁니다. "열이 오른 환자 둘, 수액이 끝나 가는 환자 하나, 검사 결과를 기다리는 환자 셋." 차트 열두 개를 처음부터 다 읽지 않고, **정해진 항목**을 **정해진 순서**로 말합니다. 넘겨받는 사람은 "지금 괜찮나? 무엇부터 볼까?"만 알면 됩니다. 오늘 만드는 운영 도우미가 이 인수인계입니다. + +**운영 도우미(operations assistant)는 "지금 괜찮나? 무엇부터 볼까?"에 답하는 프로그램입니다.** 서비스를 운영하면 정보가 여러 곳에 흩어집니다 — 실행 기록, 울린 알림, 처리하지 못하고 쌓인 일, 데이터 품질, 쓴 비용, 지금 나가 있는 판(버전). 운영 담당자는 아침마다 이것을 하나씩 열어 봅니다. 업계에서는 이것을 한 화면 · 한 대화로 모으는 일을 이렇게 부릅니다. + +| 이름 | 하는 일 | 예 | +| --- | --- | --- | +| 운영 대시보드 | 숫자 · 색을 한 화면에 | Grafana, Datadog 대시보드 | +| ChatOps | 대화창에서 운영 상태를 묻고 답을 받음 | Slack 봇, PagerDuty · Opsgenie의 Slack 연동 | +| AIOps | 운영 데이터에 AI(이상 탐지 · 요약 · 분류)를 씀 | Datadog Bits AI, Dynatrace Davis | +| 아침 브리핑 · 스탠드업 리포트 | 정해진 시간에 먼저 볼 것을 정리 | 당번 인수인계 문서 | + +원칙은 하나입니다: **숫자 · 상태는 Code(프로그램)가, 글은 LLM이, 작업은 사람이.** 숫자를 세고 색을 정하는 일은 언제나 같은 답이 나와야 해서 프로그램이 하고, 사람이 읽기 좋은 문장만 LLM이 씁니다. 그리고 운영 도우미는 **읽기만** 합니다. 실패한 일을 다시 돌리거나, 새 판을 끄거나, 예산을 바꾸는 **작업**은 사람이 **런북**(runbook, "이런 일이 생기면 이렇게 한다"를 미리 적어 둔 작업 안내서)대로 합니다 (2단계). + +### 오늘 실습에서 + +`uv run tools/flow.py morning`으로 본 2026-11-04 08:30 (Lab 01 예제 1): + +| 출처 | 지금 | 남긴 날 | +| --- | --- | --- | +| 알림 | 6건 (높음 3): FAQ timeout · 429 · B-1 거절 · 낮은 점수 둘 · 접수 API 503 | 76 · 77 · 78일차 | +| DLQ | 3건, **parked 2** | 76일차 | +| 품질 기록 · 격리함 | 4줄 (점수 0 · 0.8 · 0.8333 · 1) · 격리 1 | 78일차 | +| 사용량 · 예산 | $0.073604 / $0.08 (약 92%) | 75일차 | +| 릴리스 | 1.1.0 + **카나리 1.2.0 20%** | 74일차 | +| 실행 기록 | 666건 (10/19~10/30) | 77일차 | + +## 2. 출처 여섯과 기준 대시보드: 없는 칸이 가장 위험하다 (2교시 Lab 01 예제 1) + +### 일반 설명 + +> **생활 속 예 · 체온만 적힌 인수인계 메모.** 밤 근무가 "모두 체온 정상"이라고만 적어 두었습니다. 거짓은 없습니다. 그런데 메모에 "수액" 칸이 없어서, 수액이 끝난 환자가 있다는 것을 낮 근무는 모릅니다. 틀린 숫자는 누군가 고치지만, **없는 칸**은 아무도 묻지 않습니다. + +**대시보드의 실수는 두 가지입니다: 틀린 숫자와 없는 칸.** 틀린 숫자는 누군가 "이상한데?" 하고 알아챕니다. 없는 칸은 "그 영역은 괜찮다"처럼 보입니다. 그래서 운영 도우미를 만들 때는 먼저 **무엇을 물어야 하는지**(영역)를 정합니다. + +| 영역 | 묻는 것 | 빠지면 | +| --- | --- | --- | +| 실행 | 오류가 늘었나, 느려졌나, 답을 못 하나 | 장애를 사용자가 먼저 앎 | +| 알림 | 무엇이 울렸나, 얼마나 급한가 | 높은 알림이 묻힘 | +| DLQ (처리하지 못한 일을 모아 두는 곳) | 사람이 볼 일이 남았나 | 접수 건이 사라짐 (76일차) | +| 품질 | 들어온 데이터를 믿을 수 있나 | 틀린 FAQ로 답함 (78일차) | +| 비용 | 한도 안인가, 누가 쓰나 | 오후에 서비스가 멈춤 (75일차) | +| 릴리스 | 지금 무슨 판(버전)이 나가나, 새 판은 괜찮나 | 일부 사용자에게 먼저 내보낸 새 판(카나리)이 나쁜데 계속 나감 (74일차) | + +Google SRE의 "네 가지 황금 신호"(지연 · 트래픽 · 오류 · 포화)가 서비스 하나의 영역이라면, 오늘의 여섯은 **AI 워크플로우 운영**의 영역입니다 — 품질 · 비용 · 릴리스가 더해집니다. + +### 오늘 실습에서 + +팀의 기준 대시보드(`base-ops-facts`)에 지난주를 물으면 (Lab 01 예제 1): + +| 출처 | `morning` | 기준 대시보드 | +| --- | --- | --- | +| 실행 | (지난주 357건) | **666건** · 오류율 3.153153… (기간 무시 · 백분율) | +| 알림 · DLQ | 6 · 높음 3, 3 · parked 2 | 6, 3 (높음 · parked 없음) | +| 품질 · 릴리스 | 4줄 · 격리 1, 카나리 1.2.0 20% | **칸 없음** | + +- 기준만 보는 사람은 오늘 가장 급한 둘(**parked 2건 · 카나리**)을 모릅니다. + +## 3. 사실 하나로: 정의는 다시 만들지 않는다 (2교시 Lab 01 예제 2) + +### 일반 설명 + +> **생활 속 예 · 병원 공용 차트 양식.** 간호사마다 자기 공책에 "고열"을 다르게 적으면(37.5도? 38도?) 인수인계 때마다 다툽니다. 병원은 그래서 차트 양식 하나에 "고열 = 38도 이상"을 박아 두고 모두 그것을 씁니다. 새 화면을 만들 때도 양식을 새로 쓰지 않고 그 차트를 읽습니다. + +**운영 사실(facts)은 여섯 출처를 정해진 정의로 모은 JSON 하나입니다.** 다음 단계(질문 답변 · 브리핑 · 평가 · 터미널 도구)는 모두 이 사실만 읽습니다. 정의가 사실 한 곳에 있으면, 질문 답변이 말하는 오류율과 브리핑 표의 오류율이 **같습니다.** 업계에서는 지표의 정의를 한 곳에 두는 층을 **시맨틱 레이어**(semantic layer, metrics layer — dbt Semantic Layer · Cube · LookML)라고 부릅니다. + +| 원칙 | 까닭 | +| --- | --- | +| 77일차 정의 그대로 (오류율 · p95 · 답 못 함) | 정의가 두 벌이면 같은 날 다른 숫자 (77일차에 겪음) | +| 비율은 0~1, 소수 4자리 | 백분율(3.15)과 비율(0.0315)을 섞으면 기준(0.01)과 비교가 틀림 | +| 계산은 Code 하나 | LLM은 숫자를 세지 않음 — 세는 순간 날마다 달라짐 | +| 출처는 읽기만 | 사실을 만드는 데 상태를 바꿀 까닭이 없음 | + +### 오늘 실습에서 + +내 `my-ops-facts`의 지난주 (Lab 01 예제 2, `lab01` 5/5): + +| 영역 | 사실 | +| --- | --- | +| 실행 | 357건 · 오류 21 · 0.0588 · p95 2598ms · 답 못 함 0.0476 · 실패 1위 `Search FAQ` timeout 16 | +| 알림 · DLQ | 6 · 높음 3 · 이유별, 3 · parked 2 · 대기 1 | +| 품질 | 4배치 · 평균 0.6583 · 마지막 1 · 거절 1 · 격리 1 | +| 비용 | 156요청 · 192호출 · $0.073604 / $0.08 = 0.9201 · 1위 `test-bot` 37% | +| 릴리스 | 1.1.0 · 카나리 1.2.0 20% · 다시 묻기 **7.73% 대 62.5%** | + +- 357 · 0.0588 · 2598 · 16은 77일차 `my-log-report`의 둘째 주와 **같은 숫자**입니다. 출처 여덟을 읽어 Code 하나에서 0.1초. + +## 4. 기간과 지금: 0과 `null`은 다르다 (2교시 Lab 01 예제 3) + +### 일반 설명 + +> **생활 속 예 · "어젯밤 몇 번 열이 났나"와 "지금 체온".** 인수인계에는 두 가지 시간이 섞입니다. "밤새 열이 두 번" — 기간의 숫자. "지금 37.2도" — 지금의 숫자. 그리고 "어젯밤 소변량은 재지 않았음"은 "소변 0"과 전혀 다릅니다. 0이라고 적으면 큰일 납니다. + +**숫자에는 시간의 종류가 있습니다.** + +| 종류 | 뜻 | 흔한 예 | +| --- | --- | --- | +| 기간(window) 숫자 | 정한 기간에 일어난 일의 합 · 비율 | 지난주 주문 수, 지난달 오류율 | +| 지금(point-in-time) 숫자 | 바로 이 순간의 상태 | 지금 쌓여 있는 문의 수, 지금 남은 예산 | + +기간을 바꾸면 **기간 숫자만** 바뀌어야 합니다. "지난달을 보여 줘"라고 했는데 지금 쌓인 문의 수까지 지난달 값으로 바뀐다면 정의가 섞인 것입니다. 보고할 때도 "실행은 지난주, 나머지는 오늘 아침 지금"처럼 두 시간을 나눠 말합니다. + +그리고 **"없었다"와 "모른다"를 나눕니다.** + +| 값 | 뜻 | 예 | +| --- | --- | --- | +| 0 | 셀 것이 있었고, 그 결과가 0 | 주문이 0건인 날의 오류율 = 0 | +| `null` (값 없음) | 셀 수 없었다 · 말할 수 없다 | 주문이 0건인 날의 "가장 많이 실패한 상품", 그 판이 한 번도 쓰이지 않은 날의 그 판 오류율 | + +`null`을 0으로 바꾸면 "모른다"가 "좋다"로 보입니다. 다음 절의 색 판정에서 바로 문제가 됩니다. 데이터베이스의 `NULL`, 통계의 결측값(missing value)과 같은 생각입니다. + +### 오늘 실습에서 + +같은 사실에 기간 넷 (Lab 01 예제 3, 5/5): + +| 기간 | 실행 | 다시 묻는 비율 (1.1.0 · 1.2.0) | 지금 숫자 | +| --- | --- | --- | --- | +| 두 주 | 666 · 0.0315 | 7.73% · 62.5% | 그대로 | +| 하루 (10/26) | 70 · 0.2286 | **`null` · `null`** — 두 판은 10/28부터 | 그대로 | +| 빈 주 (11/02~) | 0 · 오류율 0 · p95 0 · 실패 1위 `null` | `null` · `null` | 그대로 | +| 기간 없음 | 357 (지난주) | 7.73% · 62.5% | 그대로 | + +## 5. 상태는 규칙으로: 숫자에 색을 매기기 (2교시 Lab 01 과제 1) + +### 일반 설명 + +> **생활 속 예 · 병동 현황판의 빨강 · 노랑 자석.** 현황판에 환자마다 색 자석을 붙입니다. 빨강은 "지금 가서 봐야 함", 노랑은 "오늘 안에 봐야 함". 색은 간호사의 기분이 아니라 **기준표**로 붙입니다 — "체온 38.5도 이상이면 빨강". 그래야 누가 붙여도 같은 색입니다. + +**상태(health)는 숫자에 정해진 기준을 대어 영역마다 색을 매긴 것입니다.** 업계에서는 RAG 상태(Red · Amber · Green), 헬스 체크(health check), SLO 판정(77일차)이라고 부릅니다. 사람이 숫자 여섯 개를 보고 매번 "괜찮은가"를 따지지 않아도, 색만 보면 무엇부터 볼지 알 수 있습니다. + +| 색 | 뜻 | 기준을 정하는 법 (예) | +| --- | --- | --- | +| 🔴 빨강 | 지금 가서 봐야 함 | 약속(SLO)을 어김, 사람이 처리해야 할 일이 남음 | +| 🟡 노랑 | 오늘 안에 봐야 함 | 약속에 가까워짐, 지켜볼 일이 생김 | +| 🟢 초록 | 괜찮음 | 위 기준에 하나도 걸리지 않음 | + +기준은 숫자의 **뜻**에서 나옵니다: 오류율은 팀이 정한 SLO, 비용은 예산의 몇 %, 쌓인 일은 "사람이 볼 일이 남았나". 그리고 기준표를 **프로그램이** 적용해야 누가 봐도 같은 색이 나옵니다. LLM에게 "괜찮은지 판단해 줘"라고 하면 같은 숫자에 날마다 다른 색이 나올 수 있습니다. + +색은 숫자를 바꾸지 않습니다. 같은 숫자에 **기준**을 댄 것입니다. 그리고 비교할 수 없으면(앞 절의 `null`) 빨강이라 하지 않고 노랑으로 둡니다 — "모르니 지켜보자". + +### 오늘 실습에서 + +지난주 사실의 상태 (Lab 01 과제 1, 5/5): + +| 🔴 red | 🟡 yellow | 전체 | +| --- | --- | --- | +| runs (0.0588) · dlq (parked 2) · release (62.5% > 2 × 7.73%) | alerts · quality (격리 1) · cost (0.9201) | 🔴 | + +- 첫 주: 오류 0이지만 답 못 함 5.5% → runs 🟡, 카나리 비율 `null` → release 🟡 (비교 불가). 예산을 $1로 올리면 비용 🟢, parked 둘을 처리하면 DLQ 🟡. + +## 6. 출처가 멈추면: 부분 실패를 견디기 (2교시 Lab 01 과제 2) + +### 일반 설명 + +> **생활 속 예 · 검사실 전화가 안 될 때.** 아침에 검사실 전화가 먹통이라 혈액 검사 결과를 못 받았습니다. 그렇다고 인수인계 전체를 미루지 않습니다. 체온 · 수액 · 투약은 그대로 넘기고, "검사 결과: **받지 못함**"이라고 적습니다. "검사 결과 정상"이라고 적으면 안 됩니다. + +**출처 하나의 실패가 전체를 멈추게 두지 않습니다.** 정보를 여섯 곳에서 모으면, 언젠가는 그중 한 곳이 응답하지 않는 날이 옵니다. 분산 시스템에서는 이것을 **부분 실패**(partial failure)라고 부르고, 일부가 멈춰도 나머지 기능은 계속 주는 설계를 **우아한 저하**(graceful degradation)라고 부릅니다. 원칙은 셋입니다. + +| 원칙 | 하는 일 | 하지 않으면 | +| --- | --- | --- | +| 나머지는 그대로 | 출처마다 실패를 따로 받아, 하나가 실패해도 다음 단계로 넘어감 | 한 곳 때문에 전체가 오류로 끝나 숫자가 하나도 없음 | +| 모르는 것은 모른다고 | 그 영역의 값은 "값 없음", 상태는 "모름", 어느 출처가 멈췄는지 적음 | 0으로 적어 초록으로 보임 | +| 전체는 좋게 말하지 않음 | 빨강이 없어도 "모름"이 하나라도 있으면 적어도 노랑 | 실제로는 위험한데 "모두 초록" | + +이 셋을 지키면 대시보드를 보는 사람은 "무엇은 괜찮고, 무엇은 지금 확인할 수 없는지"를 정확히 압니다. + +### 오늘 실습에서 + +테스트가 사용량 출처를 멈추고(`fault usage error`) 부르면 (Lab 01 과제 2): + +| | 고치기 전 | 고친 뒤 | +| --- | --- | --- | +| 응답 | **500** — 다섯 영역의 사실도 없음 | 200 · `cost: null` · `sources_down: ["cost"]` | +| 상태 | — | `health.cost: "unknown"`, 빨강 셋 그대로 · 전체 🔴 | +| 출처가 돌아오면 | — | `[]` · 비용 🟡 0.9201 | + +- 비용을 0으로 적었다면 🟢(예산의 0%)였을 것입니다. 실제로는 92%를 썼습니다. + +## 7. 요청문(프롬프트)으로 에이전트에게 맡기기 + +사실 요청문은 "운영 대시보드 만들어 줘"가 아니라 **정의**를 적습니다 (AGENTS.md 1절: 에이전트는 먼저 "어느 출처의 무엇을 어떻게 세는지"를 표로 보여 줌). + +| 넣을 것 | 예 | +| --- | --- | +| 파일 · 웹훅 · 이름 | "`my-work/ops-facts.json`, 웹훅 `my-ops-facts`, 이름 `79 Ops facts (my)`" | +| 기간 | "`from` · `to`를 `/logs/query`에, 비면 2026-10-26 ~ 2026-10-30, 기간은 실행 기록에만" | +| 정의 | "AGENTS.md 3절 사실 표 그대로, 실행은 77일차 정의, 비율은 0~1 소수 4자리" | +| 0과 `null` | "0건이면 비율 · p95는 0, 실패 1위 · 다시 묻는 비율은 null" | +| 하지 않을 것 | "LLM은 쓰지 않는다, 상태를 바꾸는 요청을 보내지 않는다" | +| 기대 결과 | "`cases/lab01.json`, 앞의 테스트도 그대로" | + +## 8. 자주 하는 실수 + +| 실수 | 결과 | 바로잡기 | +| --- | --- | --- | +| "대시보드 만들어 줘"만 씀 | 에이전트가 고른 칸만 (기준처럼) | 영역 여섯과 정의를 요청문에 | +| 기간을 데이터 API에 안 넘김 | 무엇을 물어도 666건 | `from` · `to`를 그대로 | +| 오류율을 백분율로 | 3.15 > 0.01 → 언제나 빨강 | 비율 0~1 | +| 지금 숫자에도 기간을 댐 | 빈 주에 알림 0 | 기간은 실행 기록에만 | +| `null`을 0으로 | 모르는데 🟢 | `null` 그대로, 상태는 비교 불가 | +| 색을 LLM에게 정하게 | 같은 사실에 날마다 다른 색 | 기준표를 Code로 | +| 출처 하나의 오류를 그대로 둠 | 사실 전체가 500 | `neverError` + 응답 모양으로 판단 | +| 사실을 만들며 상태를 바꿈 | 볼 때마다 아침이 달라짐 | 읽는 요청만 | + +## 확인 질문 + +
1. 기준 대시보드는 숫자를 하나도 "틀리게" 계산하지 않았는데도 위험하다고 했습니다. 왜인가요? + +계산이 맞아도 **칸이 없으면** 그 영역은 "괜찮다"처럼 보입니다. 기준에는 parked · 품질 · 릴리스 칸이 없어, 오늘 가장 급한 parked 2건과 카나리 1.2.0을 모릅니다. 대시보드를 만들 때 먼저 정하는 것은 계산이 아니라 **무엇을 물어야 하는가**(영역)입니다. (실행 666건은 기간을 무시한 것이라 지난주를 물은 사람에게는 틀린 답이기도 합니다.) +
+ +
2. 오늘 사실의 실행 숫자(357 · 0.0588 · 2598)가 77일차 숫자와 같다는 것이 왜 중요한가요? + +정의를 다시 만들지 않았다는 증거입니다. 오늘 오류율을 새로 정의했다면 77일차 보고서와 오늘 도우미가 같은 주에 다른 오류율을 말했을 것입니다. 사실 한 곳에 정의를 두면 질문 답변 · 브리핑 · 터미널 도구가 모두 같은 숫자를 말합니다 (시맨틱 레이어). +
+ +
3. 빈 주에 오류율은 0인데 실패 1위는 `null`입니다. 둘 다 "없다"인데 왜 다르게 두나요? + +오류율은 "실행 0건 중 오류 0건"이라 0이라고 말할 수 있습니다 (정의로 정함). 실패 1위는 셀 것이 없어 **말할 수 없습니다.** `null`로 두면 상태 판정이 "비교 불가 → 노랑"으로 갈 수 있고, 0이나 빈 문자열로 두면 "실패 없음 → 초록"으로 읽힙니다. +
+ +
4. 카나리의 다시 묻는 비율이 `null`이면 릴리스를 왜 빨강이 아니라 노랑으로 두나요? + +빨강은 "카나리가 두 배 넘게 나쁘다"는 **증거**가 있을 때입니다. 비율이 없으면 증거도 없습니다. 그렇다고 초록도 아닙니다 — 카나리는 켜져 있고 아직 모릅니다. "켜져 있으니 지켜보자"가 노랑입니다. +
+ +
5. 사용량 출처가 멈췄을 때 비용을 0으로 두면 무엇이 문제인가요? 전체 상태는 어떻게 되어야 하나요? + +비용 0 → 예산의 0% → 🟢로 보입니다. 실제로는 92%를 썼습니다. 모르는 것을 좋게 말하는 대시보드가 가장 위험합니다. 비용은 `null` · `unknown`으로 두고 `sources_down`에 적습니다. 전체는 빨강이 있으면 red, 없더라도 `unknown`이 있으면 적어도 yellow — "모두 초록"이라고 말하지 않습니다. +
diff --git a/Lecture/79/02-STAGE2-OPS-ASK.md b/Lecture/79/02-STAGE2-OPS-ASK.md new file mode 100644 index 0000000..6795504 --- /dev/null +++ b/Lecture/79/02-STAGE2-OPS-ASK.md @@ -0,0 +1,247 @@ +# 2단계 · 질문 답변 + +- 교시: 3교시 강의 (11:00~11:50) · 4교시 [Lab 02](../../Last3Mon/79/LAB-02-Q-OPS-ASK.md) 실습 5개 (12:00~12:50) +- 이 단계가 끝나면: 질문을 영역 하나로 고르고(라우터), 그 영역의 사실만 LLM에게 주어 답하게 하고, 운영 밖 질문과 작업 요청을 LLM 앞에서 거절하고, 답의 숫자가 사실에 있는지 Code로 확인하고, 낱말이 모르는 질문에만 LLM 라우터를 쓰고, 질문마다 감사 기록을 남길 수 있다. +- 숫자: 모두 강사 노트북 실측입니다 (79일차 판 데이터 API, OpenAI `gpt-4.1-mini` · temperature 0, 11/01 정답 워크플로우 1회). 영역 · 거절 · 숫자 확인은 규칙이라 같고, LLM이 쓴 답 글은 실행마다 조금씩 다를 수 있습니다. +- 실습 가리키기: 이 문서에서 "Lab 02 예제 1"은 4교시 [Lab 02 문서](../../Last3Mon/79/LAB-02-Q-OPS-ASK.md)의 그 절입니다. + +```mermaid +flowchart LR + A["1. 챗봇의 세 실수"] --> B["2. 라우터 ·
그 영역만"] --> C["3. 거절은
LLM 앞에서"] --> D["4. 숫자 확인"] --> E["5. 질문을 바꾸면"] + E --> F["6. LLM 라우터"] --> G["7. 감사 기록"] --> H["8. 요청문으로"] +``` + +## 0. 이 시간 (50분) + +| 시간 | 절 · 개요 | 하는 일 | +| --- | --- | --- | +| 11:00~11:07 | 1 · 개요 15장 | 기준 챗봇의 세 실수 | +| 11:07~11:15 | 2 · 개요 16장 | 라우터 · 그 영역의 사실만 | +| 11:15~11:23 | 3 · 개요 17장 | 거절은 LLM 앞에서 — 읽기만 | +| 11:23~11:30 | 4 · 개요 18장 | 숫자 확인과 그 한계 | +| 11:30~11:38 | 5 · 6 · 개요 19~20장 | 질문을 바꾸면, LLM 라우터 | +| 11:38~11:43 | 7 · 개요 21장 | 감사 기록 | +| 11:43~11:50 | 8 · 9 · 확인 질문 | 요청문, 자주 하는 실수 | + +### 4교시 Lab 02에서 이어서 하는 것 + +| 4교시 실습 (시각) | 이 강의의 절 | 실습에서 하는 일 | 쓰는 파일 · 명령 | +| --- | --- | --- | --- | +| Lab 02 예제 1 (12:00~12:12) | 1 · 2 | 낱말 라우터 · 그 영역 사실 · 운영 밖 거절 | `my-work/ops-ask.json` (새로), 테스트 `cases/lab02.json` (전: `--webhook base-ops-ask`) | +| Lab 02 예제 2 (12:12~12:22) | 3 · 4 | 작업 요청 거절 · 숫자 확인 | 같은 파일, `lab02` 6/6 → `OPS.md` 4절 | +| Lab 02 예제 3 (12:22~12:28, 에이전트 없이) | 5 | 질문을 바꾸면 | `test cases/lab02-phrasing.json -v` → `OPS.md` 5절 | +| Lab 02 과제 1 (12:28~12:40) | 6 | LLM 라우터 | 테스트 `cases/lab02-challenge.json`, `lab02-phrasing` 다시 | +| Lab 02 과제 2 (12:40~12:50) | 7 | 감사 기록 | 테스트 `cases/lab02-challenge2.json` → `OPS.md` 6절 | + +## 1. 기준 챗봇의 세 실수 (4교시 Lab 02 예제 1) + +### 일반 설명 + +> **생활 속 예 · 무엇이든 대답하는 새내기 간호사.** 의욕 넘치는 새내기가 간호사실 전화를 받습니다. 보호자가 "근처 맛집 있어요?" 하면 맛집을 알려 주고, "우리 아버지 약 좀 바꿔 주세요" 하면 "네, 바꿔 드릴게요"라고 합니다. 바꿀 권한도 없는데요. 질문마다 차트 전체를 읽어 주느라 정작 물은 것은 묻힙니다. + +**사실 전부를 LLM에게 주고 "무엇이든 답해"라고 하면** 그럴듯한 답이 나옵니다. 그러나 운영 도우미로는 세 가지가 위험합니다. + +| 실수 | 왜 위험한가 | +| --- | --- | +| 운영 밖 질문에 답함 | 사람들이 무엇이든 물어도 되는 줄 앎, 비용 · 책임 | +| **작업을 한 것처럼** 답함 | "다시 실행해 드리겠습니다" — 아무것도 안 했는데 운영자는 된 줄 앎 | +| 물은 것 밖의 숫자를 섞음 | 카나리 질문에 오류율을 섞어 원인을 잘못 짚음 | + +업계의 RAG 챗봇에서도 같은 문제를 범위 제한(scope) · 가드레일(guardrails, 예: NeMo Guardrails · Guardrails AI) · 근거 제한(grounding)으로 다룹니다. 오늘은 이것을 **워크플로우의 갈래**로 만듭니다. + +### 오늘 실습에서 + +기준 챗봇(`base-ops-ask`)에 같은 테스트 (Lab 02 예제 1, **0/6**): + +| 질문 | 기준의 답 | +| --- | --- | +| 점심 메뉴 추천해 줘 | "김치찌개, 불고기, 비빔밥 중에서 …" | +| DLQ 1번 다시 돌려 줘 | "**DLQ 1번 작업을 다시 실행해 드리겠습니다.** 잠시만 기다려 주세요." | +| 카나리 버전 문제 있어? | "… 오류율은 약 5.88% … 알림도 6건 … DLQ 상태도 좋지 않고 …" (진짜 숫자 62.5%는 없음) | + +## 2. 라우터: 질문을 영역 하나로, 사실은 그 영역만 (4교시 Lab 02 예제 1) + +### 일반 설명 + +> **생활 속 예 · 병원 안내 데스크.** "가슴이 아파요"는 심장내과로, "다리가 부었어요"는 정형외과로 보냅니다. 안내 데스크는 진단하지 않고 **어디로 보낼지**만 정합니다. 그리고 심장내과 의사에게는 정형외과 차트까지 주지 않습니다. + +**라우터(router)는 질문을 영역 하나로 고르는 단계입니다.** 업계에서는 의도 분류(intent classification) · 시맨틱 라우팅(semantic routing)이라고 부릅니다. 고른 뒤에는 **그 영역의 사실만** LLM에게 줍니다. + +| 방식 | 장점 | 단점 | +| --- | --- | --- | +| 낱말 목록 (Code) | 0초 · 공짜 · 늘 같음 · 왜 그 영역인지 설명됨 | 목록에 없는 말은 모름 | +| LLM 분류 | 처음 보는 말도 이해 | 1~3초 · 비용 · 가끔 다름 (6절) | +| 임베딩 유사도 | 비슷한 뜻을 찾음 | 기준선 · 예문 관리 (66일차) | + +그 영역 사실만 주는 까닭: 받은 숫자가 적을수록 LLM이 **엉뚱한 숫자를 섞을** 여지가 줄고, 토큰도 줄어듭니다. 답할 수 없으면 "제공된 사실에 없다"고 말하게 됩니다. + +### 오늘 실습에서 + +낱말 라우터 + 그 영역 사실 + 운영 밖 거절 (Lab 02 예제 1, `lab02` **3/6**): + +| 질문 | 영역 | 답 | +| --- | --- | --- | +| 지난주 오류율이 얼마야? | `runs` | "지난주 오류율은 5.88%였습니다. 총 357회 실행 중 21회 …" | +| 점심 메뉴 | `unknown` → 거절 | 0.1초, LLM 없음 | +| 카나리 버전 문제 있어? | `release` | "… 62.5%로 매우 높으며 … 1.1.0의 7.73%에 비해 …" | +| DLQ 1번 다시 돌려 줘 | `dlq` (작업 거절이 아직 없음) | "DLQ 1번은 현재 **대기 중**" — 틀림 (#1은 parked) | + +## 3. 거절은 LLM 앞에서: 읽기만 (4교시 Lab 02 예제 2) + +### 일반 설명 + +> **생활 속 예 · 처방은 의사만.** 간호사실 전화로 "약을 바꿔 달라"는 요청이 오면, 간호사는 친절하게 거절하고 **누구에게 말해야 하는지** 알려 줍니다. "의사 선생님께 전해 드릴게요"는 되지만 "바꿔 드릴게요"는 안 됩니다. 이것은 간호사의 성격이 아니라 병원의 **규칙**입니다. + +**하면 안 되는 것은 LLM에게 "하지 마"라고 부탁하지 않고, LLM에게 가기 전에 막습니다.** 지시문에 적은 "작업은 거절하라"는 대부분 지켜지지만 언제나는 아닙니다 (프롬프트 주입, 73일차). 프로그램의 갈래로 막으면 LLM은 그 질문을 **보지도 않습니다.** 업계에서는 이것을 최소 권한(least privilege), 읽기 전용 에이전트, "실행 버튼은 사람이 누른다"(human-in-the-loop)로 설계합니다. + +| 막는 곳 | 확실한가 | 비용 | +| --- | --- | --- | +| 지시문 ("작업 요청은 거절해") | 대개 — LLM이 따를 때만 | LLM 한 번 | +| **LLM 앞의 프로그램 갈래** | 늘 — 정한 낱말이 있으면 | 0초 | +| 권한 (작업하는 기능을 아예 주지 않음) | 늘 — 할 수단이 없음 | — | + +세 겹을 함께 쓰는 것이 흔합니다: 작업하는 기능을 주지 않고, 작업을 시키는 말은 프로그램이 먼저 거절하고, 지시문에도 적어 둡니다. 거절할 때는 "안 됩니다"로 끝내지 않고 **다음에 할 일**(누구에게 · 어떤 안내서대로)을 함께 알려 줍니다. + +### 오늘 실습에서 + +작업 낱말("다시 돌려 · 재처리해 · 꺼 줘 · 롤백해 …")을 라우터보다 앞에 (Lab 02 예제 2, `lab02` **6/6**): "DLQ 1번 다시 돌려 줘" → `area: "action"` · `refused: true` · `reason: "read_only"` · **0.0초** · "운영 도우미는 읽기만 합니다. 재처리 · 삭제 · 배포 같은 작업은 런북에 따라 담당자가 직접 해 주세요." + +## 4. 숫자 확인: 지어낸 숫자를 잡는다, 뜻은 못 잡는다 (4교시 Lab 02 예제 2) + +### 일반 설명 + +> **생활 속 예 · 인수인계 메모를 차트와 맞춰 보기.** 수간호사가 메모의 숫자를 차트와 하나씩 맞춰 봅니다. "체온 39.1" — 차트에 없으면 빨간 펜. 그런데 "2번 환자 39.1"을 "3번 환자 39.1"로 잘못 적은 것은? 숫자는 차트에 있으니 통과합니다. 숫자 맞추기로는 **누구의** 숫자인지까지는 못 봅니다. + +**숫자 확인(numbers check)은 LLM이 쓴 답의 숫자가 모두 받은 자료 안에 있는지 프로그램으로 보는 것입니다.** LLM은 그럴듯한 숫자를 지어낼 수 있습니다(환각). 답의 숫자를 하나씩 자료와 맞춰 보면 이것을 0원 · 0초로 잡을 수 있습니다. 업계에서는 근거 확인(groundedness · faithfulness check, 예: RAGAS faithfulness)의 가장 단순한 형태로 봅니다. + +| 잡는 것 | 못 잡는 것 | +| --- | --- | +| 자료에 없는 숫자 (지어낸 "7%") | 자료에 있는 숫자를 **다른 뜻**으로 쓴 답 ("A가 3건"을 "B가 3건"으로) | +| 계산 실수 (비율 0.92를 "9.2%"로) | 두 숫자를 **서로 바꿔** 쓴 답 ("성공 2 · 실패 1"을 "성공 1 · 실패 2"로) | +| | 숫자 없이 틀린 말 ("문제없이 안정적입니다") | + +어떤 형태를 같은 숫자로 볼지(값 그대로 · 백분율로 ×100 · 반올림 · 버전 번호 `1.2.0`은 하나의 값)도 정해 두어야 합니다. 정하지 않으면 맞는 답도 실패로 나옵니다. 그리고 숫자 확인을 통과했다고 답이 **맞다는** 뜻은 아닙니다 — 뜻까지는 평가(3단계)와 사람의 검토로 봅니다. + +### 오늘 실습에서 + +비용 답 "… 예산의 92.01% … 0.08달러 … 0.9201" → `numbers_ok: true` (92.01 = 0.9201 × 100). 릴리스 답의 `1.2.0`은 버전으로 통째로 빼고 62.5 = 0.625 × 100 (Lab 02 예제 2). 예제 1의 "#1은 대기 중"은 1이 사실에 있어 **통과** — 숫자 확인의 빈틈입니다. 그래서 작업 요청은 답하지 않고 막습니다. + +## 5. 질문을 바꾸면: 규칙대로 움직인다는 것 (4교시 Lab 02 예제 3) + +### 일반 설명 + +> **생활 속 예 · 안내 데스크의 단어장.** 안내 데스크 직원이 단어장을 보고 과를 정합니다. "가슴" → 심장내과. 그런데 "숨이 차요"는 단어장에 없습니다. 똑똑한 척 짐작하지 않고 "어느 과인지 모르겠습니다"라고 하면 불편하지만 **안전**합니다. 엉뚱한 과로 보내는 것보다 낫습니다. + +**규칙 기반 라우터는 규칙대로만 움직입니다.** 테스트가 통과했다는 것은 "규칙대로 움직였다"는 뜻이지 "운영자가 원하는 답"이라는 뜻이 아닙니다. 실패에도 방향이 있습니다. + +| 실패의 방향 | 예 | 결과 | +| --- | --- | --- | +| 모르면 거절 (보수적) | "장애 났어?" → 거절 | 불편 — 다시 물어야 | +| 모르면 짐작 (공격적) | 엉뚱한 영역으로 답 | 위험 — 틀린 답을 믿음 | + +운영 도우미는 보수적인 쪽에서 시작해, 자주 거절되는 질문을 보고 낱말을 더하거나(가장 싸고 확실) LLM 라우터를 붙입니다(6절). + +### 오늘 실습에서 + +여섯 가지 말투 (Lab 02 예제 3, 6/6 — 규칙대로): + +| 질문 | 낱말 라우터 | 운영자에게는 | +| --- | --- | --- | +| 에러 많이 났어? | runs | 좋음 | +| 장애 났어? | 거절 | 불편 | +| 알림이랑 DLQ 둘 다 | alerts (같으면 앞) → "DLQ 정보는 사실에 없습니다" | 반쪽 (정직하지만) | +| 카나리 꺼 줘 | 작업 거절 | 안전 | +| 배포 상태 요약해 줘 | overview (상태 · 요약 2 > 배포 1) | 넓음 | +| AI 쓰는 데 얼마나 나갔어? | 거절 | 불편 | + +## 6. LLM 라우터: 낱말이 모를 때만 (4교시 Lab 02 과제 1) + +### 일반 설명 + +> **생활 속 예 · 단어장에 없으면 선배 간호사에게.** 안내 데스크가 단어장에 없는 말을 들으면 그때만 선배를 부릅니다. 선배는 "숨이 차다면 호흡기내과"라고 알려 줍니다. 모든 질문마다 선배를 부르면 선배가 아무 일도 못 합니다. + +**LLM 라우터는 낱말 라우터가 "모르겠음"이라고 할 때만 부릅니다.** 업계에서는 이것을 모델 계단(model cascade) · 대체(fallback) 설계라고 부릅니다: 싸고 확실한 방법을 먼저 쓰고, 그것이 모를 때만 비싼 방법을 씁니다. LLM에게는 자유롭게 쓰게 하지 않고, 영역 목록과 "운영과 상관없음" 중 **하나만 고르게** 합니다. 고를 수 있는 답이 정해져 있어야 프로그램이 그 답을 믿고 다음 갈래로 보낼 수 있습니다. + +| | 낱말 라우터만 | + LLM 라우터 | +| --- | --- | --- | +| 낱말이 맞는 질문 | LLM 한 번 (답 쓰기) | 그대로 — LLM 라우터를 거치지 않음 | +| 낱말이 없는 질문 | 바로 거절 (0초) | LLM 두 번 (고르기 + 답 쓰기), 몇 초 더 | +| 운영과 상관없는 질문 | 바로 거절 | LLM이 "상관없음"을 골라 거절 — 비용이 든 거절 | + +기능이 바뀌면 **예전 규칙으로 쓴 테스트의 기대도** 사람이 다시 정합니다. 예전에는 거절이 맞았던 질문에 이제 답한다면, 테스트가 실패한 것이 아니라 기대가 낡은 것입니다. + +### 오늘 실습에서 + +낱말 없는 세 질문 (Lab 02 과제 1, **5/5**): "이번 주에 AI 쓰는 데 얼마나 나갔어?" → `cost` · `llm` · 92.01% (4.9초), "어젯밤에 접수 못 하고 남은 건" → `dlq` 3건, "새 판을 일부 사용자에게 먼저" → `release` 62.5%. 예제 3을 다시 돌리면 **4/6** — "장애" · "AI 비용"이 이제 답합니다 (기대가 옛 규칙). 그리고 DLQ 답 "2건은 대기 중이고 1건은 주차 상태"는 **거꾸로**인데 `numbers_ok: true` — 4절의 빈틈이 실제로 나왔습니다. + +## 7. 감사 기록: 무엇을 물었고 왜 거절했나 (4교시 Lab 02 과제 2) + +### 일반 설명 + +> **생활 속 예 · 간호사실 전화 기록부.** 간호사실은 전화마다 "몇 시 · 누가 · 무엇을 · 어떻게 답했나"를 적습니다. 나중에 "약 바꿔 달라는 전화가 하루 열 번" 같은 것이 보이면 안내문을 바꿉니다. 문제가 생기면 기록부가 증거입니다. + +**감사 기록(audit log)은 질문마다 "무엇을 물었고, 어떻게 처리했나"를 한 줄로 남기는 것입니다.** 어느 영역으로 보냈는지, 어떤 라우터가 골랐는지, 거절했다면 왜인지, 답의 숫자가 확인됐는지를 적습니다. 업계에서는 LLM 관측성(LLM observability — Langfuse · LangSmith · Helicone)과 감사 로그로 다룹니다. 이 기록으로 알 수 있는 것: + +| 알고 싶은 것 | 기록에서 보는 것 | +| --- | --- | +| 무엇을 자주 묻나 | 영역별 질문 수 → 낱말 목록 · 브리핑을 고칠 곳 | +| 누가 작업을 시키려 했나 | "작업 요청이라 거절"한 줄 → 안내서 · 권한 안내가 필요한 곳 | +| LLM 라우터가 얼마나 불리나 | LLM이 고른 질문의 비율 → 비용 | +| 근거 없는 답이 있었나 | 숫자 확인에 실패한 답 | + +감사 기록은 **덧붙이기만** 하는 기록이라 "읽기만"의 예외입니다 (실행 기록을 남기는 것과 같음). 운영 상태는 바꾸지 않고, 사용자가 받는 답도 바꾸지 않습니다. 다만 질문 원문에 개인정보가 섞일 수 있으니 얼마나 보관할지 · 누가 볼지를 정합니다 (73일차). + +### 오늘 실습에서 + +질문 셋 → 기록 셋 (Lab 02 과제 2, **5/5**): `read_only` · `keyword` / `out_of_scope` · **`llm`** / `runs` · `numbers_ok: true`. 응답은 그대로 (`lab02` 6/6 · `lab02-challenge` 5/5) — 감사 노드 뒤의 응답이 앞 노드의 결과를 이름으로 다시 읽습니다. + +## 8. 요청문(프롬프트)으로 에이전트에게 맡기기 + +| 넣을 것 | 예 | +| --- | --- | +| 파일 · 웹훅 · 사실 | "`my-work/ops-ask.json`, 웹훅 `my-ops-ask`, 사실은 `my-ops-facts`를 불러서" | +| 갈래의 순서 | "작업 낱말 → 거절 / 낱말 라우터 → (unknown이면 LLM 라우터) → 운영 밖 거절 / 그 영역 사실만 LLM" | +| 거절은 LLM 앞 | "거절 갈래는 LLM 노드를 지나지 않는다" | +| 숫자 확인의 허용 형식 | "값 그대로 · ×100 · 반올림, 버전 1.2.0은 통째로" | +| 결과 모양 | "`area` · `router` · `refused` · `reason` · `answer` · `numbers_ok` · `unknown_numbers`" | +| 기대 결과 | "`cases/lab02.json`, 앞의 테스트도 그대로" | + +## 9. 자주 하는 실수 + +| 실수 | 결과 | 바로잡기 | +| --- | --- | --- | +| 사실 전부를 LLM에게 | 물은 것 밖의 숫자를 섞음 | 그 영역만 | +| "작업은 거절해"를 지시문에만 | 가끔 "해 드리겠습니다" | Code 갈래로 LLM 앞에서 | +| 작업 낱말을 라우터 뒤에 | "DLQ 다시 돌려"가 `dlq`로 답함 | 작업 검사를 맨 앞에 | +| 숫자 확인에 ×100을 허용 안 함 | 맞는 답이 실패 | 허용 형식을 정함 | +| 숫자 확인을 "근거 확인 끝"으로 믿음 | 뜻이 틀린 답이 통과 | 평가 · 사람 검토 (3단계) | +| 모든 질문에 LLM 라우터 | 느리고 비쌈 | 낱말이 모를 때만 | +| 감사 노드가 응답을 바꿈 | 테스트가 깨짐 | 응답은 앞 노드의 결과를 이름으로 | +| 기능을 바꾸고 옛 테스트를 그대로 | "실패"가 사실은 나아진 것 | 기대를 사람이 다시 정함 | + +## 확인 질문 + +
1. 기준 챗봇의 "DLQ 1번 작업을 다시 실행해 드리겠습니다"가 점심 메뉴 추천보다 왜 더 위험한가요? + +점심 메뉴는 쓸모없을 뿐이지만, "다시 실행해 드리겠습니다"는 운영자가 **된 줄 알고 넘어가게** 합니다. 챗봇은 읽기만 하고 아무것도 실행하지 않았습니다. DLQ #1은 그대로 남고, 아무도 다시 보지 않습니다. 하지 않은 일을 했다고 말하는 것이 운영 도우미의 가장 나쁜 실패입니다. +
+ +
2. 작업 요청 거절을 지시문("작업은 거절하라")이 아니라 LLM 앞의 Code 갈래로 하는 까닭은? + +지시문은 LLM이 따를 때만 지켜집니다. 질문에 따라(또는 프롬프트 주입으로) 어길 수 있습니다. Code 갈래는 낱말이 있으면 **언제나** 거절하고, LLM은 그 질문을 보지도 않습니다 (0초 · 0원). 지켜야 하는 규칙일수록 확실한 곳에 둡니다. +
+ +
3. 숫자 확인이 통과했는데 답이 틀릴 수 있나요? 오늘 실제로 있었던 예는? + +있습니다. 숫자 확인은 "답의 숫자가 사실 안에 있나"만 봅니다. 과제 1의 DLQ 답 "2건은 대기 중이고 1건은 주차"는 parked 2 · 대기 1을 **거꾸로** 말했지만, 2와 1이 사실에 있어 `numbers_ok: true`였습니다. 예제 1의 "#1은 대기 중"도 같습니다. 뜻까지 보려면 평가(골든 질문)와 사람의 검토가 필요합니다. +
+ +
4. LLM 라우터를 붙인 뒤 `lab02-phrasing`이 6/6에서 4/6이 됐습니다. 도우미가 나빠진 건가요? + +아닙니다. ✘ 둘은 "장애 났어?" · "AI 쓰는 데 얼마나 나갔어?"가 이제 **거절되지 않고 답한** 것입니다. 테스트의 기대가 낱말 라우터 시절의 규칙(거절)으로 쓰여 있었습니다. 기능을 바꾸면 테스트의 기대도 사람이 다시 정해야 합니다 — 어느 쪽이 맞는지는 테스트가 아니라 사람이 정합니다. +
+ +
5. 감사 기록은 데이터 API에 쓰는 요청인데, "운영 도우미는 읽기만"과 어긋나지 않나요? + +감사 기록은 **덧붙이기만** 하는 곳입니다. 알림 · DLQ · 예산 · 릴리스 같은 운영 상태를 하나도 바꾸지 않습니다 (실행 기록을 남기는 것과 같음). "읽기만"의 뜻은 "운영 상태를 바꾸지 않는다"이고, 무엇을 물었는지 남기는 것은 오히려 그 약속을 확인하는 증거가 됩니다. +
diff --git a/Lecture/79/03-STAGE3-OPS-BRIEF.md b/Lecture/79/03-STAGE3-OPS-BRIEF.md new file mode 100644 index 0000000..4c7bf9f --- /dev/null +++ b/Lecture/79/03-STAGE3-OPS-BRIEF.md @@ -0,0 +1,224 @@ +# 3단계 · 브리핑과 평가 + +- 교시: 5교시 강의 (14:00~14:50) · 6교시 [Lab 03](../../Last3Mon/79/LAB-03-Q-OPS-BRIEF.md) 실습 5개 (15:00~15:50) +- 이 단계가 끝나면: 사실 · 상태로 "먼저 볼 것"과 정해진 할 일을 규칙으로 정한 브리핑을 만들고, LLM 요약을 덧붙이는 말로만 쓰고, 사람이 일을 하면 브리핑이 어떻게 바뀌는지 읽고, 골든 질문으로 도우미를 평가하고, 평가 점수로 관문을 세워 떨어지면 알릴 수 있다. +- 숫자: 모두 강사 노트북 실측입니다 (79일차 판 데이터 API, OpenAI `gpt-4.1-mini` · temperature 0, 11/01 정답 워크플로우 1회). 브리핑의 표 · 순서는 Code라 같고, 요약 글과 시간은 실행마다 다를 수 있습니다. +- 실습 가리키기: 이 문서에서 "Lab 03 예제 1"은 6교시 [Lab 03 문서](../../Last3Mon/79/LAB-03-Q-OPS-BRIEF.md)의 그 절입니다. + +```mermaid +flowchart LR + A["1. 브리핑 ·
먼저 볼 것"] --> B["2. 요약은
덧붙이는 말"] --> C["3. 아침이 바뀌면"] --> D["4. 골든 질문 평가"] --> E["5. 평가 관문"] + E --> F["6. 평가가 바꾸는 것"] --> G["7. 요청문으로"] +``` + +## 0. 이 시간 (50분) + +| 시간 | 절 · 개요 | 하는 일 | +| --- | --- | --- | +| 14:00~14:09 | 1 · 개요 22장 | 브리핑 — 먼저 볼 것은 규칙으로 | +| 14:09~14:16 | 2 · 개요 23장 | LLM 요약은 덧붙이는 말 | +| 14:16~14:23 | 3 · 개요 24장 | 아침이 바뀌면 — 초록이 된 까닭 | +| 14:23~14:33 | 4 · 개요 25~26장 | 골든 질문 평가 | +| 14:33~14:43 | 5 · 6 · 개요 27~28장 | 평가 관문, 평가가 바꾸는 것 | +| 14:43~14:50 | 7 · 8 · 확인 질문 | 요청문, 자주 하는 실수 | + +### 6교시 Lab 03에서 이어서 하는 것 + +| 6교시 실습 (시각) | 이 강의의 절 | 실습에서 하는 일 | 쓰는 파일 · 명령 | +| --- | --- | --- | --- | +| Lab 03 예제 1 (15:00~15:12) | 1 | 아침 브리핑 | `my-work/ops-brief.json` (새로), 테스트 `cases/lab03.json` | +| Lab 03 예제 2 (15:12~15:20) | 2 | LLM 요약 · 숫자 확인 | 같은 파일, 테스트 `cases/lab03-llm.json` → `OPS.md` 7절 | +| Lab 03 예제 3 (15:20~15:26, 에이전트 없이) | 3 | 아침이 바뀌면 | `test cases/lab03-after.json -v` → `OPS.md` 8절, `morning --reset` | +| Lab 03 과제 1 (15:26~15:40) | 4 | 골든 질문 평가 | `my-work/ops-eval.json` (새로), 테스트 `cases/lab03-challenge.json` | +| Lab 03 과제 2 (15:40~15:50) | 5 · 6 | 평가 관문 | 테스트 `cases/lab03-challenge2.json` → `OPS.md` 9절, `morning --reset` | + +## 1. 브리핑: 먼저 볼 것은 규칙으로 (6교시 Lab 03 예제 1) + +### 일반 설명 + +> **생활 속 예 · 인수인계는 빨강 환자부터.** 낮 근무가 인수인계를 받을 때, 밤 근무는 병실 번호 순서가 아니라 **급한 순서**로 말합니다. 빨강 자석 환자 먼저, 그다음 노랑. 그리고 환자마다 "다음에 할 일"이 정해져 있습니다 — "고열이면 해열제 · 1시간 뒤 다시 체온". 말하는 사람이 바뀌어도 순서와 할 일이 같습니다. + +**브리핑은 숫자와 상태를 "먼저 볼 것"과 "할 일"로 바꾼 한 장입니다.** 업계에서는 당번 인수인계 문서(on-call handoff), 장애 대응 안내서(runbook), 아침 스탠드업 리포트라고 부릅니다. 브리핑의 세 부분은 모두 규칙으로 정할 수 있습니다. + +| 부분 | 무엇을 보고 만드나 | 규칙 | +| --- | --- | --- | +| 전체 상태 · 영역 표 | 이미 매긴 색과 숫자 | 다시 판정하지 않고 그대로 옮김 | +| 먼저 볼 것 | 빨강 영역, 그다음 노랑 영역 | 정해진 영역 순서로 앞의 몇 개 | +| 할 일 | 영역마다 미리 적어 둔 한 줄 | 안내서(runbook)의 문장 그대로 | + +먼저 볼 것을 LLM에게 고르게 하면 같은 아침에 다른 순서가 나옵니다. 할 일을 LLM에게 쓰게 하면 안내서에 없는 일("서버를 재시작하세요")을 권할 수 있습니다. 순서와 할 일처럼 **정답이 정해진 것은 프로그램이**, 사람이 읽기 좋은 요약만 LLM이 합니다 (다음 절). + +### 오늘 실습에서 + +`my-ops-brief` (Lab 03 예제 1, **4/4**, LLM 없음): + +| 영역 | 상태 | 숫자 | +| --- | --- | --- | +| 실행 | 🔴 | 오류율 5.88% · p95 2598ms · 답 못 함 4.76% | +| DLQ | 🔴 | 3건 (parked 2) | +| 릴리스 | 🔴 | 1.1.0 · 카나리 1.2.0 20% (다시 묻기 62.5%) | +| 알림 · 품질 · 비용 | 🟡 | 6건 (높음 3) · 평균 0.6583 · 마지막 1 · 격리 1 · $0.073604 / $0.08 (92.01%) | + +- 먼저 볼 것: ① 🔴 실행 "오류 묶음 1위 확인 · 고치기 (77일차)" ② 🔴 DLQ "parked 건 원인 확인 뒤 런북대로 재처리 (76일차)" ③ 🔴 릴리스 "카나리 끄기 · 되돌리기 검토 (74일차)". DLQ를 비우면 셋째 자리에 알림. + +## 2. LLM 요약은 덧붙이는 말 (6교시 Lab 03 예제 2) + +### 일반 설명 + +> **생활 속 예 · 인수인계 끝의 한마디.** 표와 할 일 목록을 다 넘긴 뒤, 밤 근무가 한마디 덧붙입니다. "오늘은 3호실이 제일 걱정이에요." 사람에게는 이 한마디가 기억에 남습니다. 그러나 이 한마디가 표를 **대신**하면 안 됩니다 — 한마디에는 다른 두 환자가 빠졌으니까요. + +**LLM 요약은 브리핑에 덧붙이는 사람의 말입니다.** 사람은 표보다 문장을 빨리 읽습니다. 그래서 세 문장 요약을 붙이되, 둘을 지킵니다. + +| 원칙 | 까닭 | +| --- | --- | +| 표 · 먼저 볼 것 · 할 일은 Code가 만든 그대로 | 요약은 길이 때문에 일부만 말함 | +| 요약에도 숫자 확인 (2단계) | 지어낸 숫자를 회의에 가져가지 않게 | + +업계의 "AI 요약" 기능(인시던트 요약 · 회의 요약)도 원문 링크 · 원 데이터를 함께 두는 까닭이 같습니다. + +### 오늘 실습에서 + +두 번 부른 요약 (Lab 03 예제 2, **2/2**, `numbers_ok: true` 둘 다): + +- "전체 시스템 상태는 빨간색으로, 오류율은 5.88%이며 DLQ 크기는 3입니다. 가장 긴급한 문제는 'Search FAQ' 노드에서 발생하는 1000ms 타임아웃 오류로 16건이 보고되었습니다. …" +- "전체 시스템 상태는 빨간색으로, 오류율은 5.88%이며, 응답하지 않은 비율은 4.76%입니다. 가장 긴급한 문제는 'Search FAQ' 노드에서 발생한 16건의 타임아웃 오류입니다. …" +- 둘 다 먼저 볼 것 셋 중 **하나**(실행)만 말했습니다. DLQ parked · 카나리는 세 문장에 없습니다. + +## 3. 아침이 바뀌면: 초록이 된 까닭을 함께 말한다 (6교시 Lab 03 예제 3) + +### 일반 설명 + +> **생활 속 예 · 빨강 자석을 뗀 까닭.** 오후에 빨강 자석이 셋 다 떨어졌습니다. 하나는 열이 내려서, 하나는 다른 병동으로 옮겨서, 하나는 "38.5도 기준을 39도로 바꿔서"입니다. 셋 다 "빨강 아님"이지만 뜻이 전혀 다릅니다. 셋째를 "나았다"고 보고하면 큰일입니다. + +**브리핑은 사실이 바뀌면 다시 만들어지는 할 일 목록입니다.** 브리핑을 고치지 않아도, 사람이 일을 하면 표 · 순서 · 할 일이 따라 바뀝니다. 그런데 색이 바뀐 **까닭**은 여러 가지입니다. + +| 까닭 | 예 | 뜻 | +| --- | --- | --- | +| 일을 끝냄 | 사람이 볼 일로 남아 있던 건을 처리 | 고쳤다 | +| 위험을 멈춤 | 문제가 있는 새 판을 끔 | 원인은 그대로 | +| 기준 · 한도를 바꿈 | 예산 올림 | 쓴 돈은 그대로 | + +77일차 SLO에서 "기준을 바꾸면 초록이 된다"를 본 것과 같습니다. 회의에서 "초록입니다"만 말하지 않고 **무엇을 했는지**를 함께 말합니다. + +### 오늘 실습에서 + +케이스가 이어서 가는 테스트 (Lab 03 예제 3, 5/5): + +| 사람이 한 일 | 빨강 | 먼저 볼 것 | +| --- | --- | --- | +| (아침 그대로) | runs · dlq · release | 실행 · DLQ · 릴리스 | +| parked 둘 처리 | runs · release | 실행 · 릴리스 · 알림 | +| 카나리 끔 | runs | 실행 · 알림 · DLQ | +| 예산 0.08 → 0.2 | runs (비용 0.9201 → 0.368, 🟢) | 실행 · 알림 · DLQ | + +- 남은 빨강(FAQ timeout)은 아무도 고치지 않았습니다 — 77일차 Lab 03의 일입니다. + +## 4. 골든 질문 평가: 도우미를 시험한다 (6교시 Lab 03 과제 1) + +### 일반 설명 + +> **생활 속 예 · 새 간호사의 모의 인수인계 시험.** 새 간호사가 병동에 투입되기 전, 수간호사가 정해진 질문 여덟 개로 시험합니다. "3호실 체온은?" "약을 바꿔 달라는 전화가 오면?" 답이 정해져 있어 누가 채점해도 같습니다. 교육 내용이 바뀔 때마다 같은 시험을 다시 봅니다. + +**평가(evaluation)는 답을 미리 알고 있는 질문으로 도우미를 시험하는 것입니다.** 이 질문 묶음을 골든 세트(golden set) · 평가 데이터셋이라고 부르고, 업계에서는 LLM 평가 도구(OpenAI Evals · promptfoo · RAGAS · DeepEval · LangSmith)로 돌립니다. 질문마다 무엇을 확인할지 미리 정합니다. + +| 확인할 것 | 묻는 것 | +| --- | --- | +| 영역 | 맞는 영역으로 보냈나 | +| 거절 | 거절해야 할 질문(작업 · 운영 밖)을 거절했나 | +| 기대 숫자 | 꼭 나와야 할 숫자가 답에 있나 | +| 근거 | 답의 숫자가 모두 자료에 있나 (숫자 확인) | + +점수는 "모두 맞은 질문 수 ÷ 전체 질문 수"입니다. 평가는 질문마다 LLM을 부르므로 도우미 한 번보다 느리고 비쌉니다 — 도우미를 고칠 때마다 돌리되, 운영 중 질문마다 돌리지는 않습니다. 그리고 질문이 하나도 없으면 오류로 돌려보냅니다: "0개 중 0개 맞음 = 100점"으로 속지 않게. + +### 오늘 실습에서 + +`my-ops-eval`에 골든 질문 8개 (Lab 03 과제 1, **3/3**): **8/8 · score 1** (3.9초). 기대를 일부러 틀리게 쓴 질문("알림이 몇 건?"의 영역을 `cost`로) → `area_ok: false`만 틀림 — 도우미는 맞았고 **시험지가** 틀렸다는 것을 평가가 보여 줍니다. 그러나 8/8이어도 2단계의 "parked 2 · 대기 1을 거꾸로" 같은 답은 통과합니다 — 골든 질문에 틀리기 쉬운 것을 더해 갑니다. + +## 5. 평가 관문: 점수가 떨어지면 멈추고 알린다 (6교시 Lab 03 과제 2) + +### 일반 설명 + +> **생활 속 예 · 합격선 80점.** 모의 시험에 합격선이 없으면 50점을 받아도 병동에 나갑니다. 합격선을 정해 두면 "80점 아래면 다시 교육"이 자동으로 정해집니다. 그리고 떨어진 사람이 있으면 수간호사에게 알림이 갑니다. + +**평가 관문(eval gate)은 평가 점수를 기준과 비교해 통과 · 실패를 정하는 것입니다.** 74일차 배포 관문 · 78일차 품질 관문과 같은 모양입니다. 업계에서는 프롬프트나 모델을 바꿀 때마다 CI(자동 시험)에서 평가를 돌리고, 점수가 떨어지면 배포를 막습니다 (LLMOps의 회귀 시험). + +| 정할 것 | 흔한 방법 | +| --- | --- | +| 기준 점수 | 입력으로 받고, 없으면 기본값 (예: 0.9) | +| 통과 · 실패 | 점수 ≥ 기준이면 통과, 아니면 실패 | +| 실패하면 | 알림을 남겨 사람이 알게 함 — 운영 브리핑에도 보임 | + +기준을 코드에 박지 않고 입력으로 받는 까닭은 78일차 기준과 같습니다: 기준은 팀이 고르는 값이고, 바꿀 때마다 프로그램을 고치지 않게. + +### 오늘 실습에서 + +(Lab 03 과제 2, **4/4**): 알림 질문 하나 · 기준 없음 → 0.9 · pass, 골든 8 · 0.8 → pass, 틀린 기대 · 0.9 → **fail** → 알림 `{"workflow": "ops-eval", "reason": "eval_below", "score": 0, "min_score": 0.9}`. + +## 6. 평가가 바꾸는 것: 시험이 시험을 바꾼다 (6교시 Lab 03 과제 2) + +### 일반 설명 + +> **생활 속 예 · 모의 시험 중에 울린 호출벨.** 모의 시험 문제 "지금 호출벨이 몇 개 울렸나?"를 내기 직전에, 앞 문제를 채점하던 수간호사가 호출벨을 하나 눌렀습니다. 새 간호사는 정확히 "7개"라고 답했는데, 정답지는 "6개". 새 간호사가 틀린 것이 아닙니다 — 시험이 병동을 바꿨습니다. + +**평가가 남기는 흔적(알림 · 기록 · 사용량)이 다음 평가의 답을 바꿀 수 있습니다.** 운영 데이터를 읽는 도우미를 평가할 때 특히 그렇습니다. 앞 시험이 알림을 하나 남기면, 다음 질문 "알림이 몇 건이야?"의 맞는 답이 바뀝니다. 업계에서는 이것을 시험 격리(test isolation), 고정된 처음 상태(fixture)로 다룹니다. + +| 지키는 것 | 하는 일 | +| --- | --- | +| 처음 상태에서 | 평가 전에 운영 데이터를 정해진 처음 상태로 되돌림 | +| 순서를 생각 | 상태를 바꾸는 시험(실패 → 알림)은 그 상태를 읽는 시험 **뒤에** | +| 평가 자신의 흔적 | 기록에 평가 질문이 섞이므로, 평가가 남긴 것인지 표시해 나눔 | + +### 오늘 실습에서 + +수업 준비 때 "기준을 안 주면 0.9" 케이스를 **맨 뒤**에 두었더니, 도우미가 "알림은 총 **7건**"이라 답해 "6"을 기대한 평가가 0점이 됐습니다. 바로 앞 케이스의 실패가 `eval_below` 알림을 하나 남겼기 때문입니다 (숫자 확인도 통과 — 7은 사실에 있음). 그 케이스를 맨 앞으로 옮겼습니다. 78일차 `--repeat`의 `exists_id`와 같은 종류의 함정입니다. + +## 7. 요청문(프롬프트)으로 에이전트에게 맡기기 + +| 넣을 것 | 예 | +| --- | --- | +| 파일 · 웹훅 | "`my-work/ops-brief.json` · `my-ops-brief`, `my-work/ops-eval.json` · `my-ops-eval`" | +| 브리핑 규칙 | "먼저 볼 것은 red 다음 yellow의 앞 3개, 할 일은 AGENTS.md 3절 표의 문장 그대로, 표의 숫자 형식도 그 표대로" | +| 요약의 자리 | "요약은 markdown 끝 ## 요약, 표 · 순서는 바꾸지 않음, 숫자 확인" | +| 평가 규칙 | "케이스마다 my-ops-ask, 넷 다 맞으면 통과, 빈 목록은 400" | +| 관문 | "min_score 없으면 0.9, fail이면 /alerts에 eval_below, 응답은 평가 결과 + min_score · gate" | +| 기대 결과 | "`cases/lab03-challenge2.json`, `lab03-challenge` 그대로" | + +## 8. 자주 하는 실수 + +| 실수 | 결과 | 바로잡기 | +| --- | --- | --- | +| 먼저 볼 것을 LLM이 고름 | 같은 아침에 다른 순서 | red · yellow 순서 규칙 | +| 할 일을 LLM이 씀 | 런북에 없는 일을 권함 | 영역마다 정해진 문장 | +| 요약이 표를 대신 | DLQ · 카나리가 빠짐 | 표는 Code, 요약은 끝에 | +| 상태를 브리핑에서 다시 판정 | 사실과 다른 색 | 사실의 `health` 그대로 | +| "초록입니다"만 말함 | 한도를 바꾼 것을 고친 줄 앎 | 무엇을 했는지 함께 | +| 골든 질문을 쉬운 것만 | 8/8인데 틀린 답이 나감 | 틀리기 쉬운 질문을 더함 | +| 빈 목록에 score 1 | "0개 중 0개 = 100%" | 400 | +| 평가 순서를 생각 안 함 | 앞 케이스의 알림이 답을 바꿈 | 처음 상태 · 상태를 바꾸는 케이스는 뒤로 | + +## 확인 질문 + +
1. 브리핑의 "먼저 볼 것"과 "할 일"을 LLM에게 맡기지 않는 까닭은? + +같은 아침에 같은 순서 · 같은 할 일이 나와야 당번이 믿습니다. LLM은 실행마다 다른 순서를 고를 수 있고, 런북에 없는 일을 권할 수 있습니다. 먼저 볼 것(red 다음 yellow)과 할 일(영역마다 정해진 문장)은 정답이 정해진 판정이라 Code로 충분합니다. LLM은 사람이 읽기 좋은 요약 한 단락에만. +
+ +
2. 요약이 숫자 확인을 통과했는데도 "표를 대신하면 안 된다"고 했습니다. 왜인가요? + +숫자 확인은 "틀린 숫자가 없다"만 보장합니다. 빠진 것은 보지 않습니다. 오늘 두 요약 모두 먼저 볼 것 셋 중 실행 하나만 말했고 DLQ parked · 카나리는 없었습니다. 요약만 보고 회의를 하면 급한 둘을 놓칩니다. +
+ +
3. 카나리를 끄자 릴리스가 🟢가 됐습니다. 회의에서 "릴리스 문제는 해결됐습니다"라고 말해도 되나요? + +아닙니다. 카나리를 끈 것은 **위험을 멈춘** 것이고, 1.2.0의 다시 묻는 문제(62.5%)는 그대로입니다. 다시 켜면 같은 문제가 납니다. "카나리를 껐고, 1.2.0의 원인은 아직"이라고 말해야 합니다. 예산을 올려 비용이 🟢가 된 것도 "한도를 바꿨다"이지 "덜 썼다"가 아닙니다. +
+ +
4. 골든 질문 평가에서 8/8을 받았습니다. 도우미가 완벽하다는 뜻인가요? + +아닙니다. 8/8은 "이 여덟 질문에서 영역 · 거절 · 숫자 · 근거가 맞았다"는 뜻입니다. 2단계의 "parked 2 · 대기 1을 거꾸로 말한 답"은 숫자가 사실에 있어 통과합니다. 평가는 질문을 늘려 갈수록 나아집니다 — 실제로 틀렸던 질문, 뜻을 뒤집기 쉬운 질문을 골든 세트에 더합니다. +
+ +
5. 수업 준비 때 평가 점수가 갑자기 0이 된 일이 있었습니다. 원인과 고친 방법은? + +앞 케이스(틀린 기대 → 관문 실패)가 `eval_below` 알림을 하나 남겨 알림이 7건이 됐고, 다음 케이스 "알림이 몇 건?"에 도우미가 정확히 7건이라 답했는데 기대는 6이었습니다. 도우미가 아니라 **평가가 바꾼 상태**가 원인입니다. 그 케이스를 상태를 바꾸는 케이스보다 앞으로 옮겼습니다. 평가는 처음 상태에서, 평가 자신이 바꾸는 것까지 생각해 순서를 정합니다. +
diff --git a/Lecture/79/04-STAGE4-WRAPUP.md b/Lecture/79/04-STAGE4-WRAPUP.md new file mode 100644 index 0000000..da01405 --- /dev/null +++ b/Lecture/79/04-STAGE4-WRAPUP.md @@ -0,0 +1,213 @@ +# 4단계 · 정리: 오늘 한 일의 의미 · 효과 · 실전 응용 + +- 교시: 8교시 17:10~17:45 (35분, 바이브 코딩 발표 10분 뒤) · 자료: 이 문서 + [개요](00-DAY79-OVERVIEW.md) 30장 +- 이 시간이 끝나면: 오늘 1~3단계에서 한 일이 **무엇을 위한 것이었는지**, 숫자로 **무엇이 달라졌고 무엇을 치렀는지**, 회사에서는 같은 생각을 **어떤 이름과 도구로** 쓰는지 말할 수 있고, 단위 프로젝트 · 최종 프로젝트에 무엇을 가져갈지 정할 수 있다. +- 숫자: 오늘 교안의 실측(강사 노트북, 11/01 정답 워크플로우 1회)입니다. "예시"라고 적은 계산은 가정을 둔 어림입니다. + +```mermaid +flowchart LR + A["1. 오늘 한 장"] --> B["2. 왜 운영 도우미를"] --> C["3. 효과와 값을 숫자로"] --> D["4. 회사에서 부르는 이름"] + D --> E["5. 산업별 응용"] --> F["6. 운영 도우미 설계서"] --> G["7. 하지 말아야 할 때"] --> H["8. 직무 · 면접"] --> I["9. 프로젝트로"] +``` + +## 0. 이 시간 (35분) + +| 시간 | 절 | 하는 일 | +| --- | --- | --- | +| 17:10~17:15 | 1 · 2 | 오늘 한 장, 왜 운영 도우미를 | +| 17:15~17:20 | 3 | 오늘 숫자로 본 효과와 값, 한 달로 보면 (예시) | +| 17:20~17:28 | 4 · 5 | 같은 생각의 업계 이름 · 도구, 산업별 응용 예 | +| 17:28~17:33 | 6 · 7 | 운영 도우미 설계서, 하지 말아야 할 때 | +| 17:33~17:40 | 8 · 9 | 직무와 면접 질문, 프로젝트 체크리스트 | +| 17:40~17:45 | 10 · 토론 | 다음 일차와의 연결, 질문 둘 | + +## 1. 오늘 한 장 + +| 단계 | 한 일 | 오늘 숫자 | +| --- | --- | --- | +| 1단계 · 운영 사실 | 출처 둘러보기, 사실 하나로, 기간, 영역별 상태, 출처가 멈추면 | 기준 **666 · 칸 없음 · 0/5** → 357 · 0.0588 · 여섯 영역 · **5/5**, 기간 넷 (10/26 비율 `null`), 🔴 셋 · 🟡 셋, 사용량 출처가 멈추면 **500 → 200 · `unknown`** | +| 2단계 · 질문 답변 | 라우터 · 거절, 숫자 확인, 질문을 바꾸면, LLM 라우터, 감사 기록 | 기준 챗봇 **0/6** ("다시 실행해 드리겠습니다") → 3/6 → **6/6** (작업 거절 0.0초), 말투 여섯 중 불편 둘, LLM 라우터 5/5 (phrasing 4/6), 감사 3줄 | +| 3단계 · 브리핑과 평가 | 브리핑, LLM 요약, 아침이 바뀌면, 골든 질문 평가, 평가 관문 | 먼저 볼 것 셋 · 할 일 셋 (LLM 없음), 요약은 셋 중 하나만, 사람이 일한 뒤 빨강 하나, 골든 **8/8 · score 1**, 관문 fail → `eval_below` 알림 | + +## 2. 왜 운영 도우미를 + +> **생활 속 예 · 병원의 인수인계.** 잘 돌아가는 병동은 사고가 난 뒤가 아니라 **아침 인수인계**에서 위험을 잡습니다. 인수인계가 간호사 한 사람의 기억에만 있으면 그 사람이 쉬는 날 빈칸이 생깁니다. 그래서 양식 · 색 자석 · 기록부 · 모의 시험을 둡니다. 개요 28장 "병원 아침으로 본 오늘 하루" 표도 함께 봅니다. + +| 운영 담당자가 출처를 하나씩 열면 | 사실 · 도우미 · 브리핑으로 하면 | +| --- | --- | +| 여섯 곳을 여는 데 아침 시간 (빠뜨리는 곳이 생김) | 사실 한 번 0.1초, 영역 여섯 | +| 대시보드에 없는 칸은 "괜찮다" (parked · 카나리) | 영역마다 칸이 있음, 모르면 ⚪ | +| 같은 오류율이 화면마다 다름 | 정의는 사실 한 곳 (77일차 숫자 그대로) | +| "괜찮아?"에 사람마다 다른 답 | 기준표로 🔴 · 🟡 | +| 챗봇이 "해 드리겠습니다" | 작업 요청은 LLM 앞에서 거절, 런북 안내 | +| 무엇부터 볼지 그날 기분 | 먼저 볼 것 셋 · 정해진 할 일 | +| 도우미를 고친 뒤 "잘 되겠지" | 골든 질문 평가 · 관문 | + +**오늘의 원칙 세 줄**: 숫자 · 상태는 Code가, 글은 LLM이, 작업은 사람이. 운영 도우미는 **읽기만** 하고, 모르는 것은 모른다고 말합니다. + +## 3. 효과와 값을 숫자로 + +### 3.1 오늘 실측 전 · 후 + +| 무엇 | 전 (팀의 기준) | 후 | 바꾼 것 | +| --- | --- | --- | --- | +| 지난주 실행 | 666 · 3.153… | 357 · 0.0588 | 기간 · 77일차 정의 | +| 영역 | 넷 (칸 일부) | 여섯 + 색 | 무엇을 물을지 먼저 | +| 출처가 멈춘 아침 | 사실 전체 500 | 다섯 영역 + `unknown` | `neverError` · 모르는 것은 `null` | +| "DLQ 1번 다시 돌려 줘" | "다시 실행해 드리겠습니다" | 거절 `read_only` 0.0초 | Code 갈래 | +| 점심 메뉴 | 추천 | 거절 0.1초 | 운영 밖 갈래 | +| 카나리 질문 | 오류율 · 알림을 섞음 | 62.5% 대 7.73% | 그 영역 사실만 | +| "장애 났어?" | (낱말 라우터) 거절 | `runs`로 답 (LLM 라우터) | 낱말이 모를 때만 LLM | +| 무엇부터 | 없음 | 실행 · DLQ · 릴리스 + 할 일 | 브리핑 규칙 | +| 도우미가 괜찮나 | 모름 | 골든 8/8 · 관문 | 평가 | + +**치른 값**: + +- **시간 · 비용**: 질문 하나에 LLM 한 번(1~3초), LLM 라우터를 거치면 두 번(2~5초). 평가는 질문마다 LLM — 골든 8개에 3.9초. 사실 · 상태 · 브리핑 표는 0.1~1초 · 0원. +- **불편**: 낱말 라우터는 "장애"를 거절했습니다. 안전한 쪽의 실패지만 사용자는 다시 물어야 합니다. +- **남은 빈틈**: 숫자 확인 · 평가 모두 "숫자는 맞고 뜻이 틀린 답"(parked 2 · 대기 1을 거꾸로)을 통과시켰습니다. 사람의 검토와 골든 질문을 늘리는 일이 남습니다. +- **평가의 부작용**: 평가가 남긴 알림이 다음 평가의 답을 바꿨습니다. 평가 순서 · 처음 상태를 관리하는 일이 생깁니다. + +### 3.2 한 달로 보면 (예시) + +가정: 운영 담당자 한 명이 평일 20일 아침마다 여섯 출처를 여는 데 15분, 사실 · 브리핑으로는 3분. 도우미에게 하루 20번 묻고, 질문 하나의 LLM 비용은 오늘 단가로 1원 안팎. + +| | 출처를 하나씩 | 도우미 · 브리핑 (예시) | +| --- | --- | --- | +| 아침 점검 시간 | 15분 × 20일 = 5시간 | 3분 × 20일 = 1시간 | +| 놓치는 것 | 대시보드에 칸이 없는 영역 | 영역 여섯이 늘 보임, 모르면 ⚪ | +| 잘못된 작업 | 챗봇의 "해 드리겠습니다"를 믿는 일 | 0 (거절 · 런북) | +| LLM 비용 | 0 | 하루 20질문 × 20일 = 400질문 — 몇백 원 | + +- 숫자는 가정입니다. 그래도 **아침 시간 · 놓친 위험 · LLM 비용**을 같은 표에서 말할 수 있다는 것이 사실과 감사 기록을 남긴 효과입니다. + +## 4. 회사에서는 같은 생각을 이렇게 부른다 + +| 오늘 한 것 | 업계 이름 | 쓰는 도구 · 표준 (예) | +| --- | --- | --- | +| 출처 여섯 · 영역 | 운영 신호, golden signals | Google SRE, RED · USE 방법 | +| 사실 하나로 · 정의 한 곳 | semantic layer, metrics layer | dbt Semantic Layer · Cube · LookML | +| 기간 · 지금, 0과 `null` | window vs point-in-time, missing data | Prometheus range · instant query | +| 영역별 상태 | health check, RAG status, SLO | Grafana · Datadog 상태 위젯, SLO 대시보드 | +| 출처가 멈추면 | graceful degradation, partial failure | 서킷 브레이커 · 타임아웃 · fallback | +| 운영 도우미 · 질문 답변 | ChatOps, AIOps, ops copilot | Slack 봇 · PagerDuty · Datadog Bits AI | +| 라우터 · LLM 라우터 | intent classification, semantic routing, model cascade | Rasa · LangChain router · 임베딩 라우팅 | +| 작업 거절 · 읽기만 | guardrails, least privilege, human-in-the-loop | NeMo Guardrails · Guardrails AI · 권한 분리 | +| 숫자 확인 | groundedness · faithfulness check | RAGAS faithfulness · 사내 검증기 | +| 감사 기록 | audit log, LLM observability | Langfuse · LangSmith · Helicone | +| 브리핑 · 할 일 | on-call handoff, runbook | PagerDuty runbook · 인수인계 문서 | +| 골든 질문 평가 · 관문 | eval set, regression eval, eval gate | OpenAI Evals · promptfoo · DeepEval · CI 평가 | +| `tops` | 운영 CLI · TUI 대시보드 | `k9s` · `htop` 같은 터미널 화면 | + +- 오늘은 n8n 워크플로우 · 작은 데이터 API · `flow.py`로 직접 만들었습니다. 회사에서는 위 도구가 대신하는 경우가 많습니다. 그래도 **무엇을 물을지(영역), 무엇을 믿을지(정의), 무엇을 하면 안 되는지(거절), 언제 괜찮다고 할지(기준 · 관문)**는 오늘처럼 사람이 정합니다. + +## 5. 산업별 응용 (예) + +아래는 오늘 한 일을 다른 업무에 옮긴 예입니다 (실제 특정 회사의 사례가 아니라 흔한 모양). + +| 업무 | 오늘의 무엇을 | 이렇게 | +| --- | --- | --- | +| 사내 헬프데스크 AI (72~79일차) | 사실 · 브리핑 · 평가 | 당번이 아침마다 FAQ 실패 · DLQ · 비용 · 카나리를 한 장으로, 바꿀 때마다 골든 질문 | +| 쇼핑몰 · 커머스 운영 | 영역별 상태 · 거절 | 주문 실패율 · 결제 오류 · 재고 동기화 · 광고비를 색으로, "환불해 줘"는 도우미가 거절하고 담당자에게 | +| 콜센터 · CS | 라우터 · 감사 | 상담원이 묻는 운영 질문을 영역으로, 무엇을 자주 묻는지 보고 매뉴얼 개선 | +| 클라우드 · 플랫폼 팀 (SRE) | 사실 · 출처가 멈추면 | 모니터링 · 배포 · 비용 출처를 모은 ChatOps 봇, 출처 하나가 죽어도 나머지 상태 | +| 제조 · 물류 관제 | 브리핑 · 아침이 바뀌면 | 라인별 불량률 · 설비 알림 · 출하 지연을 아침 브리핑으로, 조치 뒤 다시 | +| 금융 · 의료 (규제) | 읽기만 · 감사 · 평가 관문 | AI 도우미는 조회만, 질문 · 답을 모두 기록, 모델을 바꿀 때 평가 점수로 승인 | + +**토론 거리**: 내 회사(또는 가고 싶은 회사)의 운영 하나를 골라, "영역 여섯과 도우미가 절대 하면 안 되는 일 셋"을 정해 봅니다. + +## 6. 운영 도우미 설계서 (프로젝트용 틀) + +오늘의 AGENTS.md 3절과 `my-work/OPS.md`가 운영 도우미 설계서였습니다. 단위 · 최종 프로젝트에서도 이 틀로 한 장을 씁니다. + +| 칸 | 정할 것 | 오늘의 예 | +| --- | --- | --- | +| 영역 | 무엇을 물어야 하나 | 실행 · 알림 · DLQ · 품질 · 비용 · 릴리스 | +| 출처 · 정의 | 어디서 · 어떻게 세나 · 기간인가 지금인가 | `/logs/query` · 77일차 정의 · 기간은 실행에만 | +| 상태 기준 | 🔴 · 🟡의 선, 모르면 | 오류율 > 0.01 · parked > 0 · 비교 불가는 🟡 | +| 하면 안 되는 것 | 작업 낱말 · 운영 밖 · 거절 말 | 재처리 · 롤백 · 비워 줘 → `read_only` + 런북 | +| 근거 | 답에 쓸 사실 · 숫자 확인의 허용 형식 | 그 영역만 · ×100 · 버전 통째로 | +| 먼저 볼 것 · 할 일 | 순서 · 영역마다 런북 한 줄 | red 다음 yellow 앞 3개 | +| 평가 | 골든 질문 · 기준 · 실패하면 | 8개 · 0.9 · `eval_below` 알림 | +| 기록 | 감사 기록의 칸 · 보관 · 볼 사람 | 질문 · 영역 · 라우터 · 거절 이유 | + +- 설계서가 없으면 "도우미가 잘 답한다"를 말할 수 없습니다. **물을 것 · 믿을 것 · 하지 않을 것 · 시험이 있어야 "괜찮다"고 말할 수 있습니다.** + +## 7. 하지 말아야 할 때 (흔한 실수) + +| 하기 쉬운 일 | 왜 위험한가 | 대신 | +| --- | --- | --- | +| 사실 전부를 LLM에게 "알아서 답해" | 숫자를 섞고 지어냄 | 라우터 · 그 영역만 · 숫자 확인 | +| 숫자 · 색을 LLM이 정함 | 같은 아침에 다른 답 | Code · 기준표 | +| 모르는 것을 0 · 초록으로 | 92%인데 🟢 | `null` · ⚪ · 전체는 적어도 🟡 | +| 도우미에게 작업 권한을 줌 | "해 드리겠습니다"가 진짜가 됨 · 사고 | 읽기만, 작업은 런북 · 사람 | +| "작업은 거절해"를 지시문에만 | 가끔 어김 | LLM 앞 Code 갈래 | +| 숫자 확인을 믿고 끝 | 뜻이 틀린 답이 통과 | 평가 · 사람 검토 | +| 요약만 보고 회의 | 급한 영역이 빠짐 | 표 · 먼저 볼 것은 Code | +| 초록만 보고 "해결" | 한도를 바꾼 것을 고친 줄 앎 | 무엇을 했는지 함께 | +| 평가 없이 프롬프트 · 모델을 바꿈 | 조용히 나빠짐 | 골든 질문 · 관문 | + +## 8. 직무와 면접 + +오늘 한 일은 다음 직무들이 날마다 하는 일입니다: **SRE · 플랫폼 · DevOps 엔지니어, AI · LLM 애플리케이션 엔지니어, MLOps · LLMOps 엔지니어, 데이터 · 분석 엔지니어(지표 정의), 운영 · CS 자동화 담당, 업무 자동화(n8n · RPA) 개발자.** 채용 공고의 "운영 대시보드 · 모니터링", "ChatOps · 사내 AI 봇", "LLM 평가 · 가드레일", "지표 정의 · 시맨틱 레이어", "온콜 · 런북"이 오늘 내용입니다. + +면접에서 흔히 받는 질문과 오늘 숫자로 답하는 법: + +
1. "여러 시스템의 운영 데이터를 한 대시보드로 모을 때 무엇을 먼저 정하나요?" + +계산보다 **물어야 할 영역**을 먼저 정했습니다. 실행 · 알림 · DLQ · 품질 · 비용 · 릴리스 여섯이고, 기존 대시보드는 품질 · 릴리스 칸이 없어 parked 2건과 카나리를 놓쳤습니다. 그다음 정의를 한 곳(사실)에 두었습니다 — 오류율은 지난번 보고서와 같은 정의라 357건 중 0.0588로 같은 숫자가 나왔습니다. 기간 숫자와 지금 숫자를 나누고, 모르는 값은 0이 아니라 `null`로 둡니다. +
+ +
2. "출처 중 하나가 장애일 때 대시보드는 어떻게 동작해야 하나요?" + +나머지 영역은 그대로 보여 주고, 멈춘 영역은 "모름"으로 표시합니다. 오늘 사용량 출처를 멈추자 처음 판은 사실 전체가 500이었고, 고친 판은 200에 `cost: null` · `sources_down: ["cost"]` · 상태 `unknown`을 냈습니다. 비용을 0으로 적으면 초록으로 보이는데 실제로는 예산의 92%를 쓴 날이었습니다. 전체 상태도 빨강이 없더라도 모르는 영역이 있으면 적어도 노랑입니다. +
+ +
3. "운영 챗봇이 위험한 작업을 하지 않게 하려면?" + +세 겹으로 막았습니다. 쓰는 API를 아예 부르지 않고(권한), 작업 낱말("다시 돌려 · 롤백해 · 꺼 줘")이 있으면 LLM에게 가기 전에 Code가 거절하고(0초), 지시문에도 적습니다. 기존 챗봇은 "DLQ 1번 작업을 다시 실행해 드리겠습니다"라고 답했는데, 실제로는 아무것도 실행하지 않아 운영자가 된 줄 알 수 있었습니다. 거절 말에는 런북과 담당자를 넣었고, 거절도 감사 기록에 남깁니다. +
+ +
4. "LLM이 숫자를 지어내지 않게 하려면? 그걸로 충분한가요?" + +질문의 영역 사실만 주고, 답의 숫자가 모두 그 사실 안에 있는지 Code로 확인합니다 (×100 · 반올림 · 버전은 허용). 지어낸 숫자는 잡지만 충분하지 않습니다 — 오늘 "parked 2 · 대기 1"을 거꾸로 말한 답이 숫자 확인을 통과했습니다. 그래서 골든 질문 평가를 두고, 틀리기 쉬운 질문을 골든 세트에 더하고, 중요한 답은 사람이 검토합니다. +
+ +
5. "LLM 기반 기능을 바꿀 때 품질이 떨어지지 않았는지 어떻게 아나요?" + +정답을 아는 골든 질문으로 평가하고 점수에 관문을 둡니다. 오늘 8개 질문에 영역 · 거절 · 기대 숫자 · 근거 넷을 보고 8/8 · score 1, 기준 0.9 아래면 실패와 알림. 평가 자체도 시험했습니다 — 일부러 틀린 기대를 넣으면 잡아내고, 빈 목록은 400. 그리고 평가가 남긴 알림이 다음 평가의 답을 바꾼 일이 있어, 처음 상태에서 순서를 정해 돌립니다. +
+ +## 9. 프로젝트로 가져갈 것 + +단위 프로젝트(80~82일차) · 최종 프로젝트(85일차~)의 워크플로우에 아래를 넣으면 "운영할 수 있는 프로젝트"가 됩니다. + +- [ ] 운영 도우미 설계서 한 장 (6절 틀): 영역 · 출처 · 정의 · 상태 기준 · 하면 안 되는 것 · 평가 · 기록 +- [ ] 사실 웹훅 하나 (정의는 한 곳), 기간은 기간 숫자에만, 모르면 `null` +- [ ] 영역별 상태 (기준표) · 출처가 멈춰도 200 · `unknown` +- [ ] 질문 답변: 라우터 · 그 영역만 · 작업 · 운영 밖은 LLM 앞에서 거절 · 숫자 확인 · 감사 기록 +- [ ] 아침 브리핑: 먼저 볼 것 · 정해진 할 일 (Code), 요약은 덧붙이는 말 +- [ ] 골든 질문 평가 · 관문 (기준은 입력으로, 실패하면 알림), 처음 상태에서 +- [ ] `OPS.md` · `TEST-REPORT.md`, 터미널 도구(`tops`)로 당번의 아침 + +## 10. 다음 일차와의 연결 + +| 일차 | 오늘의 무엇을 잇나 | +| --- | --- | +| 80~82 | 단위 프로젝트 — 팀마다 64~79일차의 워크플로우를 묶은 서비스, 오늘의 운영 도우미가 그 서비스의 운영 부분 (발표 때 "지금 괜찮나?"를 시연) | +| 85~ | 최종 프로젝트 — 9절 체크리스트를 팀 워크플로우에 | + +## 토론 (5분) + +1. 오늘 도우미는 "읽기만" 했습니다. DLQ 재처리처럼 런북이 분명한 작업이라면, 사람이 **승인 버튼**을 누르는 방식으로 도우미가 해도 될까요? 무엇이 더 있어야 할까요? +2. "장애 났어?"를 거절하는 낱말 라우터와, 가끔 엉뚱한 영역을 고르는 LLM 라우터 중 우리 팀의 아침에는 어느 쪽이 나을까요? + +## 정리 + +- [ ] 운영 도우미는 "지금 괜찮나? 무엇부터 볼까?"에 답하고 **읽기만** 한다 +- [ ] 먼저 물을 영역을 정하고, 정의는 사실 한 곳에 (기준 666 · 칸 없음 vs 357 · 여섯 영역) +- [ ] 기간과 지금을 나누고, 0과 `null`을 구분하고, 출처가 멈추면 `unknown` +- [ ] 숫자 · 상태는 Code, 글은 LLM, 작업은 사람 — 거절은 LLM 앞에서 (0/6 → 6/6) +- [ ] 숫자 확인은 지어낸 숫자만 잡는다 — 뜻은 평가 · 사람이 +- [ ] 브리핑의 순서 · 할 일은 규칙으로, 초록이 된 까닭을 함께 말한다 +- [ ] 골든 질문 평가 · 관문으로 도우미를 시험한다 (8/8, fail → 알림), 평가가 바꾸는 상태까지 diff --git a/Lecture/79/05-VIBE-CODING.md b/Lecture/79/05-VIBE-CODING.md new file mode 100644 index 0000000..02f021e --- /dev/null +++ b/Lecture/79/05-VIBE-CODING.md @@ -0,0 +1,97 @@ +# 7·8교시 · 바이브 코딩 (`tops`) + +- 교시: 7교시 16:00~16:50 (각자 만들기), 8교시 17:00~17:50 (발표 10분 · [4단계 정리](04-STAGE4-WRAPUP.md) 35분 · 예고 5분) +- 과제: [VIBE-REQ-TOPS](../../Last3Mon/79/VIBE-REQ-TOPS.md)(요청서, 요청의 말 ①~⑫) → [VIBE-Q-TOPS](../../Last3Mon/79/VIBE-Q-TOPS.md)의 8단계: `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md`(엔진 · TUI 단계) → 단계별 구현 → 확인 → `uv tool install` → 다른 폴더에서 사용 → 수업 뒤 `VIBE-A-TOPS.md` 공개 +- 요구 14개(R1~R14). 50분 안에는 **엔진 1단계(R1 · R2 · R3 · R5 · R7 · R13 · R14) + 엔진 2단계(R4 · R6) + TUI 1단계(R11 · R12) + 설치**, 나머지(R8 지난 도구, R9 평가 관문, R10 감사)는 IMP-PLAN의 다음 단계로 집에서. +- 72일차 `tbench`부터 78일차 `tcheck`와 같은 흐름입니다: 사람의 말(요청서)을 **확인할 수 있는 요구**로 바꾸는 일을 학생이 먼저 하고, VIBE-Q는 그 다음 단계(비교 대상)로 씁니다. 에이전트에게는 처음 요청 한 번과 "확인했고 commit 했어. 다음 단계." 한 줄만 씁니다. +- 오늘은 `tops`: 오늘 n8n 운영 도우미(`my-ops-facts` · `my-ops-ask` · `my-ops-brief` · `my-ops-eval`)를 **터미널에서 보는** 도구. 색 · 숫자는 n8n이 정한 그대로, 질문 · 거절 · 브리핑 · 평가 관문 · 감사, 지난 도구(`tlog` · `tcheck`)를 부품으로, TUI. **읽기만** 합니다. + +```mermaid +flowchart LR + R["요청서 → TODO · SPEC ·
IMP-PLAN 20분"] --> A["7교시
엔진 1 · 2 · TUI · 설치 30분"] --> B["발표
10분"] --> C["4단계 정리
35분"] --> F["80일차 예고
5분"] +``` + +> **생활 속 예 · 회진 도는 의사의 손바닥 현황판.** 오늘 병원(n8n)에 인수인계 시스템을 만들었습니다. 회진하는 의사는 간호사실까지 오지 않고 손바닥 화면으로 봅니다. 손바닥 화면이 따로 판정하면 안 되고(간호사실이 빨강이면 손바닥도 빨강), 손바닥으로는 **보기만** 하고 처방은 하지 않습니다. + +> 이 문서는 7 · 8교시 진행 문서입니다. 8교시의 정리 35분은 [04-STAGE4-WRAPUP](04-STAGE4-WRAPUP.md)으로 합니다. + +## 1. 7교시 · 각자 만들기 (50분) + +| 시간 | 단계 | 확인 | +| --- | --- | --- | +| 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단계 → 엔진 2단계 → TUI 1단계, 막히면 5 문제 해결 팁 | 단계마다 `pytest` · 직접 실행 · commit | +| 16:44~16:50 | 6 확인 · 7 `uv tool install --editable .` · 8 다른 폴더에서 `tops` | `tops status` = n8n 브리핑의 색 · 숫자 | + +| 볼 것 | 막혔을 때 한마디 | +| --- | --- | +| TODO · SPEC 없이 VIBE-Q를 통째로 붙여 넣었나 | "요청서로 Doc/TODO.md부터. 일곱 줄 · 종료 코드 4가 SPEC에 없으면 맞았는지 모릅니다" | +| IMP-PLAN이 한 덩어리 · 첫 요청에 14개 | "엔진 1단계만. 단계마다 확인 · 체크 · commit" | +| 색이 n8n 브리핑과 다름 | "health 그대로, 기준값(0.01 · 0.8)은 n8n에만" | +| 오류율이 `0.0588%` · `588%` | "AGENTS.md 3절 브리핑 표의 형식: ×100 · 두 자리" | +| 출처가 멈춘 날 비용이 `$0` · 🟢 | "null이면 응답 없음 · ⚪" | +| tops에 재처리 · 리셋 기능이 생김 | "읽기만 — 부를 수 있는 주소 목록, 그 밖은 막는 테스트" | +| 테스트가 진짜 n8n을 부름 | "가짜 서버(http.server)와 n8n 응답 사본으로" | +| n8n을 끄면 traceback | "cannot read · 종료 코드 1" | +| 실행해 보지 않고 다음 요청 | "에이전트가 됐다고 해도 직접 `uv run tops status; echo $?`" | + +정답 예(R1~R14를 모두 넣은 `tops`와 정답본 `VIBE-A-TOPS.md`)는 수업 뒤 공개됩니다. + +## 2. 8교시 · 발표 (10분) + +- 1~2명. **`tops status`와 n8n 브리핑의 색 · 숫자가 같다는 것을 보여 줄 수 있는 사람**, n8n을 끄거나 사실 파일만 주고 `tops ui`의 "not available" 탭을 보여 줄 수 있는 사람을 먼저 고릅니다. +- 3분 발표 · 2분 질문: **내 SPEC과 VIBE-Q가 달랐던 곳** / IMP-PLAN을 어떻게 나눴나 / 처음 요청 · 고쳐 달라는 요청문 / 에이전트가 틀린 곳(다시 판정 · 숫자 형식 · 0과 null · 쓰는 주소) / 시연. + +## 3. 강사 정리 (8교시 4단계 정리 안에서) + +| | n8n (오늘 Lab) | tops | +| --- | --- | --- | +| 사실 · 상태 | `my-ops-facts` (Lab 01) | `tops status` — `health` 그대로, 숫자는 형식만 (R1 · R2 · R7) | +| 질문 · 거절 | `my-ops-ask` (Lab 02) | `tops ask` — 영역 · 라우터 · 거절 이유 · 숫자 확인 (R4) | +| 브리핑 | `my-ops-brief` (Lab 03) | `tops brief` (R6) | +| 평가 관문 | `my-ops-eval` (Lab 03 과제) | `tops eval` — fail이면 종료 코드 4 (R9) | +| 감사 | `/audit` (Lab 02 과제 2) | `tops audit` (R10) | +| 지난 도구 | — | `tops tools` — `tlog slo` · `tcheck` (R8) | +| 읽기만 | 쓰는 API를 부르지 않음 · 작업 거절 | 주소 목록 `READ_ONLY` + 테스트 (R5) | + +- 풀이 거리: 강사 본은 실제 n8n에서 일곱 줄 · 종료 코드 4 · 0.19초, `tops tools`의 `tlog slo`가 **357 runs**로 같은 지난주를 셌습니다 — 정의를 복사하지 않고 그 도구를 불렀기 때문입니다. TUI 시험에서 "부른 주소는 다섯뿐"을 검사했다가 도구 탭의 `tlog`가 실행 기록을 읽어 실패했습니다 → 읽기만의 뜻은 "읽는 주소만"이 아니라 **"바꾸는 주소가 0건"**. +- `tops audit`를 평가 뒤에 보면 평가의 질문까지 섞여 11줄 — Lab 03 과제 2의 "평가가 상태를 바꾼다"와 같은 이야기. + +## 4. 강사 바이브 코딩 (시간이 남으면 · 수업 뒤, 15분) + +| 주제 | 요청문 요지 | 보여 줄 것 | +| --- | --- | --- | +| 아침 cron | "평일 08:30에 `tops status`가 4면 `tops brief`를 파일로 남기는 cron 한 줄과 설명" | 종료 코드로 아침 점검을 자동으로 | +| 승인 버튼 (생각해 보기) | "`tops ui`의 DLQ 줄에서 `[a]`를 누르면 런북 문장과 담당자만 보여 주고 아무것도 실행하지 않는 화면" | 읽기만을 지키면서 다음 할 일을 안내 | +| 팀 화면 | "`--n8n`을 팀 VM 주소로, 영역마다 마지막으로 바뀐 시각" | 단위 프로젝트의 운영 화면 | + +걸린 시간과 결과를 `Last3Mon/79/VIBE-A-TOPS.md`에 적어 둡니다. + +## 5. 보충 · 팁 (읽을거리, 4단계 정리 7절과 함께) + +| 팁 | 설명 | +| --- | --- | +| 요청서 → 요구가 먼저 | 확인 방법(일곱 줄 · 종료 코드 · 거절 줄)이 있는 요구가 있어야 에이전트의 "완료"를 판단할 수 있음 | +| 기대 출력은 이미 있다 | 오늘 브리핑 표(`cases/lab03.json`)와 같은 숫자 — SPEC과 테스트에 그대로 | +| n8n의 답을 사본으로 | `call my-ops-facts …`의 응답을 `tests/data/`에 두면 n8n 없이도 시험 | +| 판정은 한 곳 | 색 · 거절은 n8n에만, tops는 보여 주기만 | +| 읽기만은 주소 목록으로 | "조심"은 약속이 아님 — 테스트로 확인할 수 있는 목록 | +| 0과 `null` | 모르는 것을 좋게 보이지 않기 | +| 지난 도구는 명령으로 | 복사하면 정의가 또 한 벌 | +| 종료 코드로 말한다 | 0 · 1 · 2 · 4 — cron · 스크립트가 다음 일을 정함 | +| 단계마다 `git commit` | 에이전트가 망가뜨려도 돌아갈 곳 | + +## 6. 80일차 예고 (5분) + +- 오늘: 72~78일차의 운영 데이터를 사실 · 상태로 모으고, 묻고 답하고 막고, 브리핑하고, 도우미를 시험했습니다. 다음 수업(80~82일차): **단위 프로젝트** — 팀마다 64~79일차의 워크플로우를 묶어 하나의 서비스로 만들고, 오늘의 운영 도우미를 그 서비스의 운영 부분으로 씁니다. 자세한 일정은 [80-82-Project](../80-82-Project/README.md). +- 64~79일차 `my-work/*`, `~/vibe/*`(`tops` 포함)는 지우지 마세요. 팀 구성 전에 "내가 가장 잘 만든 워크플로우 하나"를 골라 옵니다. + +## 확인 (강사) + +- [ ] 16:00 요청서 → TODO(에이전트 없이) → SPEC → IMP-PLAN 20분을 지켰다, 16:20부터 엔진 1단계. +- [ ] 7교시 시작 전 n8n에 `my-ops-facts`(Lab 01 과제 1 이상) · `my-ops-ask`가 게시돼 있어 색 비교 · 질문을 할 수 있다. +- [ ] 수업 뒤: 요청이 있을 때 학생 저장소에 정답(LAB-A · `answers/` · VIBE-A · `tops/`)을 올린다. +- [ ] 발표자 1~2명, 강사 정리 표, 팁, 예고. +- [ ] 수업이 끝날 때 학생들의 데이터 API가 운영 아침 상태인지 (`morning --reset`, `doctor`). diff --git a/Lecture/79/README.md b/Lecture/79/README.md new file mode 100644 index 0000000..bf3191c --- /dev/null +++ b/Lecture/79/README.md @@ -0,0 +1,42 @@ +# 79일차 · 운영 데이터 기반 Workflow Ops Assistant (바이브 코딩) + +- 일자: 2026-11-04 (79일차) +- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영 +- 단원: AI 워크플로우 운영 및 관리 역량 강화 +- 학습 내용: 운영 데이터 기반 Workflow Ops Assistant 구현 +- 실습 폴더: [Last3Mon/79](../../Last3Mon/79/README.md) · 하루 수강 안내: [LECTURE-79.md](../../Last3Mon/79/LECTURE-79.md) +- 실습 환경: 65일차 n8n 2.42.6 + OpenAI API(`gpt-4.1-mini`) + 79일차 데이터 API(운영 아침 상태) + **CLI 코딩 에이전트** +- **실습 준비: [00-DAY79-PREP.md](00-DAY79-PREP.md) (수업 전날까지 1~2절 완료)** +- 72일차부터의 방식: 단계마다 **1교시 강의 + 1교시 실습(5개: 예제 3 · 과제 2)**, 8교시는 발표와 4단계 정리. + +## 오늘의 시간표 + +| 교시 | 시간 | 내용 | 자료 | +| --- | --- | --- | --- | +| 1 | 09:00~09:50 | 오늘의 개요 · 1단계 강의 · 운영 사실 | [개요](00-DAY79-OVERVIEW.md) 1~14장, [1단계](01-STAGE1-OPS-FACTS.md) | +| 2 | 10:00~10:50 | Lab 01 실습 5개 (출처 둘러보기 · 운영 사실 · 기간 · 영역별 상태 · 출처가 멈추면) | [Lab 01](../../Last3Mon/79/LAB-01-Q-OPS-FACTS.md) | +| 3 | 11:00~11:50 | 2단계 강의 · 질문 답변 | 개요 15~21장, [2단계](02-STAGE2-OPS-ASK.md) | +| 4 | 12:00~12:50 | Lab 02 실습 5개 (라우터 · 거절 · 숫자 확인 · 질문을 바꾸면 · LLM 라우터 · 감사 기록) | [Lab 02](../../Last3Mon/79/LAB-02-Q-OPS-ASK.md) | +| - | 13:00~14:00 | 점심 | | +| 5 | 14:00~14:50 | 3단계 강의 · 브리핑과 평가 | 개요 22~28장, [3단계](03-STAGE3-OPS-BRIEF.md) | +| 6 | 15:00~15:50 | Lab 03 실습 5개 (브리핑 · LLM 요약 · 아침이 바뀌면 · 골든 질문 평가 · 평가 관문) | [Lab 03](../../Last3Mon/79/LAB-03-Q-OPS-BRIEF.md) | +| 7 | 16:00~16:50 | 바이브 코딩 · `tops`: 요청서 → TODO → SPEC → IMP-PLAN → 만들기 → 설치 | [VIBE-REQ-TOPS](../../Last3Mon/79/VIBE-REQ-TOPS.md) → [VIBE-Q-TOPS](../../Last3Mon/79/VIBE-Q-TOPS.md), [진행](05-VIBE-CODING.md) | +| 8 | 17:00~17:50 | 바이브 코딩 발표 · **4단계 정리** (의미 · 효과 · 실전 응용) · 80일차(단위 프로젝트) 예고 | [4단계 정리](04-STAGE4-WRAPUP.md), [진행](05-VIBE-CODING.md) | + +```mermaid +flowchart LR + P["개요"] --> S1["1단계
출처 여섯 · 사실 하나 ·
기간 · 상태 · 출처가 멈추면"] --> S2["2단계
라우터 · 거절 · 숫자 확인 ·
LLM 라우터 · 감사"] --> S3["3단계
브리핑 · 요약 · 아침이 바뀌면 ·
평가 · 관문"] --> S4["4단계 정리
의미 · 효과 · 응용"] + S3 --> V["7·8교시
바이브 코딩 tops"] +``` + +## 자료 목록 + +| 파일 | 내용 | +| --- | --- | +| `00-DAY79-PREP.md` | 실습 준비: n8n · 79일차 판 데이터 API 켜기, 기준 둘 게시, `flow.py morning` · `doctor` (수업 전) | +| `00-DAY79-OVERVIEW.md` | 개요 30장 (PPT 대신 Markdown, 가로줄로 장 구분, 장마다 생활 속 예 한 줄) | +| `01-STAGE1-OPS-FACTS.md` | 운영 도우미, 출처 여섯과 없는 칸, 사실 하나로(시맨틱 레이어), 기간과 지금 · 0과 `null`, 상태는 규칙으로, 출처가 멈추면 | +| `02-STAGE2-OPS-ASK.md` | 기준 챗봇의 세 실수, 라우터 · 그 영역만, 거절은 LLM 앞에서, 숫자 확인과 그 한계, 질문을 바꾸면, LLM 라우터, 감사 기록 | +| `03-STAGE3-OPS-BRIEF.md` | 브리핑 · 먼저 볼 것은 규칙으로, LLM 요약은 덧붙이는 말, 아침이 바뀌면, 골든 질문 평가, 평가 관문, 평가가 바꾸는 것 | +| `04-STAGE4-WRAPUP.md` | 8교시 35분 정리: 오늘 한 장, 왜 운영 도우미를, 효과와 값, 업계 이름 · 도구, 산업별 응용, 운영 도우미 설계서, 하지 말아야 할 때, 직무 · 면접, 최종 프로젝트 체크리스트 | +| `05-VIBE-CODING.md` | 7·8교시 바이브 코딩 진행 (`tops`), 발표, 강사 정리, 추가 바이브 코딩, 팁, 80일차 예고 |