78일차 데이터 품질 검증 자동화: 실습 15개(Q) · 기준 둘 · 강의(개요 30장 · 단계 문서 · 4단계 정리 · 05 바이브) · 요청서와 VIBE-Q(tcheck), 정답은 수업 뒤
This commit is contained in:
1 parent
fbed1b22f6
commit
4ec7ae123b
76 files changed
+11631
No files matched your search
@@ -0,0 +1,3 @@
|
||||
runs/
|
||||
tools/.cache/
|
||||
my-work/*.json
|
||||
@@ -0,0 +1,219 @@
|
||||
# AGENTS.md · n8n 데이터 품질 검사 작업 규칙 (78일차 실습 폴더)
|
||||
|
||||
이 파일은 **CLI 코딩 에이전트**(Codex, Claude Code, Antigravity 등)가 이 폴더에서 n8n 워크플로우를 만들 때 따르는 규칙입니다. 사람도 읽을 수 있게 썼습니다. 77일차 규칙과 같고, 3절에 오늘 만드는 **검사 · 가져오기 · 스키마 검사 · 품질 보고서의 계약**과 **품질 규칙(오류 코드)**, 5절에 검사 · 격리 노드가 더해졌습니다.
|
||||
|
||||
## 1. 작업 순서 (항상 이 순서)
|
||||
|
||||
1. 요청을 읽고, 만들 워크플로우의 **입력 · 노드 순서 · 결과 모양**을 3~5줄로 먼저 정리해 보여 준다. 규칙 요청이면 **규칙(무엇을 보고 어떤 코드를 붙이는지)** 을 먼저 표로 보여 준다.
|
||||
2. 워크플로우 JSON을 `my-work/<이름>.json`에 쓴다. 이미 있으면 그 파일을 고친다 (새 파일을 만들지 않는다).
|
||||
3. `uv run tools/flow.py check my-work/<이름>.json` → 오류(✘)가 없을 때까지 고친다.
|
||||
4. 끝나면 한국어로 짧게 보고한다: 바꾼 노드, check 결과, 그리고 사람이 실행할 명령 두 줄.
|
||||
```bash
|
||||
uv run tools/flow.py push my-work/<이름>.json
|
||||
uv run tools/flow.py test cases/<요청에 나온 테스트 파일>.json
|
||||
```
|
||||
5. **push와 test는 사람이 실행해 결과를 확인한다.** 사람이 "네가 push·test까지 해"라고 하면 직접 실행해도 된다.
|
||||
6. 사람이 실패 보고서(`runs/*.json`)를 알려 주면 실패한 케이스의 요청·응답을 읽고 원인을 설명한 뒤 고친다. **같은 곳을 3번 고쳐도 실패하면 멈추고** 다른 방법을 제안한다.
|
||||
7. 문서(`my-work/*.md`)를 써 달라는 요청이면 워크플로우를 만들지 않고 문서만 쓴다.
|
||||
|
||||
## 2. 환경 (바꾸지 말 것)
|
||||
|
||||
| 항목 | 값 |
|
||||
| --- | --- |
|
||||
| n8n | 2.42.6, 65일차 공식 설치 `~/day65-n8n` (Docker 프로젝트 `day65-n8n`), 브라우저 http://localhost:5678 |
|
||||
| n8n 켜고 끄기 | `~/aio-01d4-stu/Last3Mon/65/n8n`에서 `docker compose start/stop` (사람이 함, Ollama 컨테이너는 없음) |
|
||||
| 게시된 웹훅 주소 | 밖에서 · n8n 안에서 모두 `http://localhost:5678/webhook/<path>` |
|
||||
| OpenAI Credential | id `openAiCourse0001`, name `OpenAI (.env)`, 모델 `gpt-4.1-mini` |
|
||||
| 다른 Credential | 쓰지 않음 (Ollama 등 없음) |
|
||||
| 데이터 API | n8n 안에서 `http://data-api:8800`, 터미널에서 `http://localhost:8800` (**78일차 판** `Last3Mon/78/data-api`: 77일차 + 격리함 · 스키마 · 품질 기록) |
|
||||
| 비교 기준 · 제공 | `reference/baseline-faq-check.json` (`base-faq-check`, 팀이 지난달 급히 만든 검사 — 빈 값만 봄) · `reference/baseline-faq-import.json` (`base-faq-import`, 모든 줄을 그대로 넣던 가져오기). **고치지 않는다** (비교용) |
|
||||
|
||||
### 데이터 API (78일차 판, 오늘 쓰는 것)
|
||||
|
||||
| 요청 | 응답 |
|
||||
| --- | --- |
|
||||
| `GET /faq-all` | `{"count", "faqs": [{"id", "title", "keywords", "answer"}]}` — 지금 FAQ 전체 (처음 6개 + 더한 것) |
|
||||
| `POST /faq/add` 본문 `{"id", "title", "keywords", "answer"}` | FAQ 한 줄 더하기 (같은 id면 바꿈) |
|
||||
| `POST /quarantine` 본문 `{"items": [{"batch", "row": {...}, "errors": [...]}]}` | 격리함에 넣기. 같은 배치 · 같은 줄 id(`row.id`, 없으면 `row.email`, `row.ticket_id`)면 바꿈 |
|
||||
| `GET /quarantine` (`POST /quarantine/list`) | `{"size", "items": [{"row_id", "batch", "row", "errors", "at"}]}` |
|
||||
| `POST /quarantine/remove` 본문 `{"ids": ["FAQ-209"]}` | 격리함에서 빼기 |
|
||||
| `GET /schemas` (`POST /schemas/list`) | `{"schemas": ["employee", "faq", "ticket"]}` |
|
||||
| `GET /schemas/<name>` | 데이터 스키마 `{"name", "key", "fields"}`, 없으면 404 |
|
||||
| `POST /quality-log` 본문 `{"batch", "dataset", "total", "valid", "quarantined", "rejected", "score", "codes"}` | 품질 기록 한 줄 |
|
||||
| `GET /quality-log` (`POST /quality-log/list`) | `{"count", "items": [...]}` 넣은 순서 |
|
||||
| `POST /alerts` · `GET /alerts` | 알림 (76일차와 같음) |
|
||||
| `POST /faq-reset` · `/quarantine-clear` · `/quality-log-clear` · `/alerts-clear` | 처음으로 (**사람이** · 테스트가, `flow.py quality --reset`) |
|
||||
|
||||
### 배치 (오늘 들어오는 데이터)
|
||||
|
||||
```json
|
||||
{"batch": "B-2", "items": [{"id": "FAQ-201", "title": "회의실 예약", "keywords": ["회의실", "예약"], "answer": "사내 포털의 ..."}]}
|
||||
```
|
||||
- `requests/faq-batch-1.json`(13줄) · `faq-batch-2.json`(10줄) · `faq-batch-3.json`(6줄) · `faq-batch-2-fixed.json`(고친 2줄) · `faq-edges.json`(경계 10줄) · `faq-batch-1-loose.json` · `faq-batch-2-limit-0.1.json` · `faq-batch-2-strict.json`(같은 배치 + `max_invalid_ratio`).
|
||||
- `employees-new.json`(CSV 9줄, `{"schema": "employee", "batch", "csv"}`) · `tickets-new.json`(목록 9줄, `{"schema": "ticket", "batch", "items"}`).
|
||||
|
||||
## 3. 오늘 만드는 것의 계약과 품질 규칙
|
||||
|
||||
| 무엇 | 파일 · 웹훅 | 입력 (본문) | 결과 |
|
||||
| --- | --- | --- | --- |
|
||||
| FAQ 검사 | `my-work/faq-check.json` · `POST /webhook/my-faq-check` | 배치 | `{"batch", "total", "valid", "invalid", "rows": [{"id", "ok", "errors", "warnings"}], "codes"}`, `items`가 목록이 아니면 **400**. (Lab 01 과제 2) `fix_list` · `message` 더함 |
|
||||
| FAQ 가져오기 | `my-work/faq-import.json` · `POST /webhook/my-faq-import` | 배치 (+ 선택 `max_invalid_ratio`, 기본 0.3) | `{"batch", "total", "imported", "quarantined", "rejected", "score", "max_invalid_ratio", "codes", "imported_ids", "quarantined_ids"}`, 배치를 거절하면 **422** `{"error": "batch rejected", "batch", "invalid_ratio", "max_invalid_ratio", "codes"}`. (Lab 02 과제 2) 둘 다 `llm_calls` 더함 |
|
||||
| 스키마 검사 | `my-work/check.json` · `POST /webhook/my-check` | `{"schema", "batch", "items"}` 또는 `{"schema", "batch", "csv"}` | `{"schema", "batch", "total", "valid", "invalid", "rows": [{"row", "key", "ok", "errors"}], "codes"}`, 모르는 스키마면 **400**. (Lab 03 과제 2) 검사마다 품질 기록 한 줄 |
|
||||
| 품질 보고서 | `my-work/quality-report.json` · `POST /webhook/my-quality-report` | `{"dataset"}`(선택) | 아래 |
|
||||
|
||||
### FAQ 품질 규칙 (오류 코드)
|
||||
|
||||
| 코드 | 규칙 |
|
||||
| --- | --- |
|
||||
| `id_format` | `id`가 `FAQ-` + 숫자 3자리가 아님 |
|
||||
| `title_missing` · `title_too_long` | 제목이 비었음 · 40자 넘음 |
|
||||
| `keywords_missing` | 키워드가 목록이 아니거나 비었음 |
|
||||
| `answer_missing` · `answer_too_short` · `answer_too_long` | 답이 비었음 · 10자 미만 · 300자 넘음 |
|
||||
| `duplicate_in_batch` | 같은 배치 안에서 앞에 나온 `id` (두 번째부터) |
|
||||
| `pii` | 답에 휴대전화 번호(`010-1234-5678` · `01012345678` 꼴, `-` · `.` · 공백 없어도) 또는 메일 주소. 사무실 번호(`02-123-4567`)는 아님 |
|
||||
| `external_link` | 답에 `http(s)://` 링크가 있는데 호스트가 **정확히** `intra.example.com`이 아님 (`intra.example.com.evil.io`는 바깥) |
|
||||
| `exists_id` · `same_title` | (Lab 01 과제) 이미 있는 FAQ와 `id` · 제목이 같음 |
|
||||
| `keyword_used:<낱말>` | (Lab 01 과제, **경고**) 이미 있는 FAQ가 쓰는 키워드 — 통과는 시킴 |
|
||||
| `llm_mismatch` | (Lab 02) 규칙은 통과했지만 LLM이 "답이 제목의 질문에 답하지 않음"으로 판정 |
|
||||
|
||||
- 줄은 `errors`가 비었을 때만 `ok`. `warnings`는 통과에 영향이 없다.
|
||||
- 공백만 있는 값은 빈 값으로 본다. 글자 수는 앞뒤 공백을 뺀 뒤 센다 (제목 40자 · 답 10자는 통과).
|
||||
|
||||
### 고침 목록 (Lab 01 과제 2)
|
||||
|
||||
| 필드 | 정의 |
|
||||
| --- | --- |
|
||||
| `fix_list` | 통과하지 못한 줄만 줄 순서대로 `[{"row" (1부터), "id", "codes" (그 줄의 errors), "fix": [코드마다 아래 말]}]` |
|
||||
| `message` | `"<batch>: <total>줄 중 <valid>줄 통과, <invalid>줄 고칠 것"` |
|
||||
|
||||
| 코드 | `fix`의 말 |
|
||||
| --- | --- |
|
||||
| `id_format` | id를 FAQ-숫자 3자리로 (예: FAQ-104) |
|
||||
| `title_missing` · `title_too_long` | 제목을 써 주세요 · 제목을 40자 이내로 줄여 주세요 |
|
||||
| `keywords_missing` | 키워드를 하나 이상 넣어 주세요 |
|
||||
| `answer_missing` · `answer_too_short` · `answer_too_long` | 답을 써 주세요 · 답을 10자 이상으로 써 주세요 · 답을 300자 이내로 줄여 주세요 |
|
||||
| `duplicate_in_batch` | 같은 배치에 같은 id가 또 있습니다. 하나만 남겨 주세요 |
|
||||
| `pii` | 개인 전화번호 · 메일 주소를 빼고 IT 포털 안내로 바꿔 주세요 |
|
||||
| `external_link` | 바깥 링크 대신 사내 주소(intra.example.com)를 써 주세요 |
|
||||
| `exists_id` | 이미 있는 id입니다. 새 id를 쓰거나, 고치려는 것이면 FAQ 담당자에게 알려 주세요 |
|
||||
| `same_title` | 같은 제목의 FAQ가 이미 있습니다. 제목을 바꾸거나 기존 FAQ와 합쳐 주세요 |
|
||||
|
||||
### 가져오기 게이트
|
||||
|
||||
| 단계 | 규칙 |
|
||||
| --- | --- |
|
||||
| 배치 게이트 | `invalid / total`이 **기준을 넘으면** 배치 전체를 거절 (422, 아무것도 넣지 않음). 기준은 본문의 `max_invalid_ratio`, 없으면 **0.3**. 같으면 통과. 응답에 쓴 기준 `max_invalid_ratio`를 함께, `invalid_ratio`는 소수 4자리 |
|
||||
| 줄 게이트 | 통과한 줄만 `/faq/add`, 나머지는 **이유와 함께** `/quarantine` |
|
||||
| LLM 검사 (Lab 02 예제 2) | 규칙을 통과한 줄만 LLM에게 (비용 · 75일차) |
|
||||
| `score` | `imported / total`, 소수 4자리 (거절된 배치는 0) |
|
||||
| `llm_calls` (Lab 02 과제 2) | LLM 검사에 보낸 줄 수 (= 규칙을 통과한 줄 수). 거절된 배치는 0 |
|
||||
| 품질 기록 · 알림 (Lab 02 과제) | 배치마다 `/quality-log` 한 줄 (거절도), 거절이면 알림 `reason: "batch_rejected"`, `score < 0.9`면 알림 `reason: "low_quality"` |
|
||||
|
||||
### 스키마 (`/schemas/<name>`)
|
||||
|
||||
```json
|
||||
{"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}}}
|
||||
```
|
||||
|
||||
| 타입 | 확인 | 코드 |
|
||||
| --- | --- | --- |
|
||||
| 모든 필드 | `required`인데 비었음 | `missing:<필드>` |
|
||||
| `string` | `min` · `max` 글자 수 | `too_short:<필드>` · `too_long:<필드>` |
|
||||
| `enum` | `values` 안에 있나 | `not_allowed:<필드>` |
|
||||
| `email` · `phone` · `date` · `pattern` | 형식 (`0\d{1,2}-\d{3,4}-\d{4}`, 실제 있는 날짜 `YYYY-MM-DD`, 스키마의 정규식) | `bad_format:<필드>` |
|
||||
| `list` | 목록인가, `min_items` · `max_items` | `bad_format` · `too_few` · `too_many:<필드>` |
|
||||
| 키 (`key`) | 배치 안에서 두 번째부터 | `duplicate_in_batch` |
|
||||
|
||||
- CSV는 첫 줄이 열 이름, 쉼표로 나눔 (따옴표 없음). `row`는 1부터 (열 이름 줄 제외).
|
||||
|
||||
### 품질 보고서 (`my-quality-report`)
|
||||
|
||||
| 필드 | 정의 |
|
||||
| --- | --- |
|
||||
| `count` · `batches` | 품질 기록 줄 수 · `[{"batch", "score", "rejected", "total"}]` 넣은 순서 (`dataset`을 주면 그 데이터셋만) |
|
||||
| `avg_score` | `score` 평균, 소수 4자리 (0건이면 0) |
|
||||
| `worst_batch` | `score`가 가장 낮은 배치 (같으면 먼저 나온 것, 없으면 `null`) |
|
||||
| `rejected_batches` | 거절된 배치 수 |
|
||||
| `top_codes` | 모든 기록의 `codes`를 더해 `[{"code", "count"}]`, 많은 순 (같으면 코드 글자순) |
|
||||
| `trend` | 마지막 `score` − 첫 `score`가 0.05 넘으면 `좋아짐`, −0.05 미만이면 `나빠짐`, 아니면(또는 1건 이하) `그대로` |
|
||||
| `by_dataset` (Lab 03 과제 2) | 모든 기록을 `dataset`마다 `{"count", "avg_score", "worst_batch", "top_code"}` (처음 나온 순서, `top_code`는 `top_codes`의 첫 코드 또는 `null`) |
|
||||
| `worst_dataset` (Lab 03 과제 2) | `avg_score`가 가장 낮은 데이터셋 (같으면 먼저 나온 것, 없으면 `null`) |
|
||||
|
||||
### 검사도 기록 (Lab 03 과제 2)
|
||||
|
||||
- `my-check`는 검사가 끝나면 `/quality-log`에 한 줄: `{"batch", "dataset": <schema>, "total", "valid", "quarantined": invalid, "rejected": false, "score": valid / total (소수 4자리, 0줄이면 0), "codes"}`. 응답은 검사 결과 그대로.
|
||||
|
||||
## 4. 워크플로우 파일 규칙
|
||||
|
||||
```json
|
||||
{"name": "78 FAQ check (my)", "nodes": [ ... ], "connections": { ... }, "settings": { "executionOrder": "v1" }}
|
||||
```
|
||||
|
||||
- 파일 하나에 워크플로우 하나. 노드 `name`은 영어로, 겹치지 않게. **API 키를 JSON에 쓰지 않는다.**
|
||||
- **규칙은 Code가 확인한다.** LLM은 규칙으로 알 수 없는 것(답이 질문에 맞나)만, 규칙을 통과한 줄에만.
|
||||
- 검사 결과의 `rows`는 들어온 줄과 **같은 순서 · 같은 수**.
|
||||
|
||||
## 5. 검증된 노드 (n8n 2.42.6에서 실행 확인)
|
||||
|
||||
### 본문 확인 (IF) → 줄마다 규칙 (Code)
|
||||
```json
|
||||
{"name": "Is a list?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [220, 300],
|
||||
"parameters": {"conditions": {"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "loose", "version": 2},
|
||||
"conditions": [{"id": "c1", "leftValue": "={{ Array.isArray($json.body.items) }}", "rightValue": "",
|
||||
"operator": {"type": "boolean", "operation": "true", "singleValue": true}}], "combinator": "and"}, "options": {}}}
|
||||
```
|
||||
```json
|
||||
{"name": "Check rows", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [440, 300],
|
||||
"parameters": {"jsCode": "const body = $('Check webhook').first().json.body;\nconst seen = new Set();\nconst rows = body.items.map((r) => {\n const errors = [];\n ...\n return { id: String(r?.id ?? ''), ok: errors.length === 0, errors, warnings: [] };\n});\n...\nreturn [{ json: { batch: body.batch ?? '', total: rows.length, valid, invalid: rows.length - valid, rows, codes } }];"}}
|
||||
```
|
||||
|
||||
### 다른 내 웹훅을 부르기 (가져오기 → 검사)
|
||||
```json
|
||||
{"name": "Run check", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [220, 300],
|
||||
"parameters": {"method": "POST", "url": "http://localhost:5678/webhook/my-faq-check", "sendBody": true, "specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify($json.body) }}", "options": {"timeout": 30000, "response": {"response": {"neverError": true}}}}}
|
||||
```
|
||||
- 400 · 422도 오류로 멈추지 않고 본문을 받는다 (`neverError`). 다음 IF에서 `Array.isArray($json.rows)`로 갈라 본다.
|
||||
|
||||
### 줄을 나눠 저장하기 — 한 줄로 (분기 없이)
|
||||
```json
|
||||
{"name": "Store row", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [1320, 300],
|
||||
"parameters": {"method": "POST", "url": "={{ $json.ok ? 'http://data-api:8800/faq/add' : 'http://data-api:8800/quarantine' }}",
|
||||
"sendBody": true, "specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify($json.ok ? $json.row : { items: [{ batch: $json.batch, row: $json.row, errors: $json.errors }] }) }}",
|
||||
"options": {"response": {"response": {"neverError": true}}}}}
|
||||
```
|
||||
- 앞의 Code가 줄마다 아이템 하나(`{id, ok, errors, row, batch}`)를 내면 HTTP 노드는 **아이템마다** 한 번씩 부른다. 주소를 표현식으로 고르면 IF로 갈라 다시 합칠(Merge) 필요가 없다.
|
||||
- 그 뒤 요약 Code는 `$('Decide').all()`처럼 **저장 전 노드**에서 줄을 다시 읽는다 (HTTP 노드의 출력은 API 응답이다).
|
||||
- 수업 준비 때: IF로 두 갈래(넣기 · 격리)로 나눴다가 Merge로 합치니, LLM 검사를 더한 뒤 격리 갈래가 두 번 실행되면서 `$('Bad rows').all()`이 **마지막 실행만** 보여 격리 수가 틀렸다. 한 줄로 바꿔 해결.
|
||||
|
||||
### LLM 검사 (규칙을 통과한 줄만)
|
||||
- `Only passed`(Code: `return $input.all().filter((i) => i.json.ok);`) → AI Agent(`Judge`) + OpenAI chat model → `Decide`(Code).
|
||||
- 지시문: `{"match": true|false, "reason"}` JSON만. `Decide`는 `$('Judge').all()[i]`를 `Only passed`의 i번째 줄과 맞추고, 읽을 수 없으면 `match: false`.
|
||||
- Agent의 `onError`는 `continueRegularOutput` (LLM 오류여도 그 줄을 격리로 보내고 계속).
|
||||
|
||||
## 6. 연결 · 7. 표현식
|
||||
|
||||
- 72~77일차와 같다. 다른 노드 뒤에서 웹훅 입력은 `$('Import webhook').first().json.body`.
|
||||
- 테스트에서 목록 안은 숫자로 내려간다: `rows.2.errors`, `items.1.score`, `top_codes.0.code`.
|
||||
|
||||
## 8. 테스트 파일 (`cases/*.json`)
|
||||
|
||||
```json
|
||||
{"webhook": "my-faq-import", "before": [{"url": "http://localhost:8800/faq-reset", "body": {}}], "cases": [
|
||||
{"name": "나쁜 배치는 통째로 거절", "request": "requests/faq-batch-1.json", "expect": {"status": 422}},
|
||||
{"name": "격리함", "url": "http://localhost:8800/quarantine/list", "body": {}, "expect": {"contains": {"items": "\"pii\""}}}
|
||||
]}
|
||||
```
|
||||
- `before`의 `"allow_fail": true`: 일부러 거절되는 배치를 미리 넣을 때 (Lab 03 과제).
|
||||
- 기대 결과는 3절 규칙대로 배치 파일을 확인한 값입니다. **테스트를 통과시키려고 기대 결과나 배치 파일을 바꾸지 않는다.** 규칙이 다르다고 생각되면 사람에게 묻는다.
|
||||
|
||||
## 9. 하지 말 것
|
||||
|
||||
- `tools/`, `data-api/`(스키마 포함), `requests/`, `reference/`, 이 파일을 고치지 않는다 (요청받은 경우만).
|
||||
- 워크플로우 안에서 `/faq-reset` · `/quarantine-clear` · `/quality-log-clear` · `/alerts-clear` · 고장 스위치를 부르지 않는다.
|
||||
- 나쁜 줄을 고쳐서(빈 제목을 채우는 등) 넣지 않는다. **고치는 것은 사람**, 워크플로우는 막고 알린다.
|
||||
- n8n 컨테이너를 지우거나 `docker compose down`을 하지 않는다. `~/day65-n8n`을 읽거나 고치지 않는다. Credential을 새로 만들지 않는다. `.env`나 API 키를 출력하지 않는다.
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1,768 @@
|
||||
# Lab 01 · 품질 규칙 · 눈으로 검사하고, 규칙으로 바꾸기
|
||||
|
||||
- 관련 강의: [1단계 · 품질 규칙](../../Lecture/78/01-STAGE1-QUALITY-RULES.md)
|
||||
- 소요 시간: 50분 (2교시 한 시간) · 예제 1 10분 · 예제 2 15분 · 예제 3 7분 · 과제 1 8분 · 과제 2 10분
|
||||
- 준비: [실습 준비](../../Lecture/78/00-DAY78-PREP.md)의 `doctor` → `ready`, 기준 검사 `base-faq-check` · 기준 가져오기 `base-faq-import` 게시
|
||||
- 목표: 콘텐츠 담당자가 보낸 FAQ 배치를 먼저 **눈으로** 검사하고, 팀이 쓰던 기준 검사가 무엇을 놓치는지 찾는다. 같은 배치에 늘 같은 판정을 내리는 **검사 워크플로우**를 만들고(규칙은 말이 먼저), 경계(40자 · 공백 · 전화번호 꼴 · 비슷한 주소)에서 재 보고, 이미 있는 FAQ와 맞춰 보고, 보낸 사람에게 돌려줄 **고침 목록**을 만든다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
E1["예제 1<br/>눈으로 검사 ·<br/>기준 검사"] --> E2["예제 2<br/>검사 워크플로우"] --> E3["예제 3<br/>경계에서 재 보기"] --> T1["과제 1<br/>이미 있는 데이터"] --> T2["과제 2<br/>고침 목록"]
|
||||
```
|
||||
|
||||
> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다. 연습이 끝나면 `uv run tools/flow.py quality --reset`.
|
||||
|
||||
## 시작하기 · 터미널 두 개
|
||||
|
||||
| 터미널 | 하는 일 |
|
||||
| --- | --- |
|
||||
| ① 에이전트 | `cd ~/aio-01d4-stu/Last3Mon/78` 후 `codex` (또는 `claude`, `agy`). 내가 쓴 요청문을 보낸다 |
|
||||
| ② 확인 | `cd ~/aio-01d4-stu/Last3Mon/78` 에서 `call` · `push` · `test` · `quality`를 **내가** 실행한다 |
|
||||
|
||||
`cp templates/QUALITY-RULES-TEMPLATE.md my-work/QUALITY.md` 후 표를 채워 갑니다. 쓴 요청문은 `my-work/PROMPTS.md`에 모읍니다.
|
||||
|
||||
## 예제 1 · 눈으로 검사 · 팀의 기준 검사 (10분, 에이전트 없이)
|
||||
|
||||
### 이 실습의 의도
|
||||
|
||||
**한 줄로:** 배치 B-1의 13줄을 직접 읽고 "넣어도 되나"를 내 눈으로 정한 뒤, 팀이 지난달 급히 만든 기준 검사(`base-faq-check`)의 판정과 **비교**합니다. 고치는 것은 예제 2부터이고, 여기서는 **읽고 비교해 적기만** 합니다.
|
||||
|
||||
> **생활 속 예 · 식당 주방의 식재료 검수.** 아침마다 납품 상자가 들어오면 주방장이 뚜껑을 열어 봅니다. 상한 채소, 라벨이 없는 고기, 주문하지 않은 물건을 골라냅니다. 그런데 새로 온 알바생은 "상자가 비어 있지 않으면 받는다"만 알고 있습니다. 예제 1은 주방장의 눈(내 검사)과 알바생의 기준(기준 검사)이 같은 상자를 어떻게 다르게 받는지 보는 실습입니다.
|
||||
|
||||
**상황.** 77일차에 와이파이 FAQ 한 줄이 답 못 한 질문 34건을 풀었습니다. 그래서 콘텐츠 담당자가 FAQ를 한꺼번에(배치로) 보내기 시작했습니다. 첫 배치 B-1(`requests/faq-batch-1.json`, 13줄)이 왔습니다. 팀에는 지난달 만든 검사 `base-faq-check`가 있고, 지금까지 이 검사를 통과하면 FAQ에 넣어 왔습니다.
|
||||
|
||||
**실험 둘.**
|
||||
|
||||
| 실험 | 하는 것 | 볼 것 |
|
||||
| --- | --- | --- |
|
||||
| ① 눈으로 | `requests/faq-batch-1.json`을 열어 13줄을 읽음 | 줄마다 넣어도 되나 · 이유 한 마디 |
|
||||
| ② 기준 검사 | `call base-faq-check requests/faq-batch-1.json` | 기준이 나쁘다고 한 줄은 몇 개인가? ①과 같은가? |
|
||||
|
||||
**끝나면 알게 되는 것.** "나쁜 FAQ"는 빈 값만이 아닙니다. 형식(id) · 길이 · 중복 · **개인정보** · **바깥 링크** · 질문과 다른 답까지 여러 종류이고, 그중 무엇을 **규칙으로** 잡을 수 있는지가 이 Lab의 출발점입니다.
|
||||
|
||||
**적을 곳.** `my-work/QUALITY.md` 1절 표 (줄 id · 내 눈 · 이유 · 기준 검사는 · 규칙으로 바꾸면).
|
||||
|
||||
```bash
|
||||
# ① 눈으로 (편집기로 열거나)
|
||||
python3 -m json.tool requests/faq-batch-1.json | less
|
||||
|
||||
# ② 팀의 기준 검사에 같은 배치를
|
||||
uv run tools/flow.py call base-faq-check requests/faq-batch-1.json
|
||||
```
|
||||
|
||||
### 이해하기
|
||||
|
||||
**FAQ 한 줄에 무엇이 있나.** [AGENTS.md](AGENTS.md) 2절 "배치"입니다.
|
||||
|
||||
| 칸 | 뜻 | 나쁘면 생기는 일 |
|
||||
| --- | --- | --- |
|
||||
| `id` | `FAQ-` + 숫자 3자리 | 다른 FAQ를 덮어씀 · 찾을 수 없음 |
|
||||
| `title` | 질문의 주제 (40자 이내) | 검색에 걸리지 않음 · 화면이 깨짐 |
|
||||
| `keywords` | 검색에 쓰는 낱말 목록 | 아무 질문에도 나오지 않음 |
|
||||
| `answer` | 직원에게 보여 줄 답 (10~300자) | 빈 답 · 개인 전화번호 노출 · 위험한 링크 |
|
||||
|
||||
**품질 검사는 "규칙 + 판정"입니다.** "나쁜 FAQ"라는 말만으로는 판정이 하나로 정해지지 않습니다. 공백만 있는 제목은 빈 값인가? `01012345678`도 전화번호인가? `intra.example.com.evil.io`는 사내 주소인가? 이것을 미리 정해 둔 것이 **품질 규칙**이고, 규칙이 같아야 오늘 배치와 다음 주 배치, 내 판정과 옆 사람의 판정을 비교할 수 있습니다. 77일차의 "지표는 정의가 먼저"와 같은 말입니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
B[("B-1 13줄")] --> A["내 눈<br/>규칙표를 들고"]
|
||||
B --> G["base-faq-check<br/>팀의 방식"]
|
||||
A --> C{"같은 판정?"}
|
||||
G --> C
|
||||
C -->|다르면| D["무엇을 보지 않았나<br/>(규칙이 없는 것)"]
|
||||
```
|
||||
|
||||
**기대 결과.** 내 눈으로는 나쁜 줄이 기준 검사보다 **많습니다.** 어느 줄을 기준이 놓쳤는지 `QUALITY.md` 1절에 적습니다. 답은 아래 실행 결과를 보기 전에 먼저 찾아 봅니다.
|
||||
|
||||
<details><summary>▶ 1단계 · 무엇을 보며 읽나</summary>
|
||||
|
||||
[AGENTS.md](AGENTS.md) 3절 "FAQ 품질 규칙" 표를 옆에 두고 줄마다 코드 하나씩 대어 봅니다. 답 안의 숫자와 `@`, `http`를 특히 봅니다.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 2단계 · 규칙으로 못 잡는 줄</summary>
|
||||
|
||||
FAQ-113의 제목과 답을 함께 읽어 보세요. 빈 값도, 길이도, 형식도 문제없는데 넣으면 안 되는 줄입니다. 이런 줄은 Lab 02 예제 2(LLM 검사)에서 다룹니다.
|
||||
</details>
|
||||
|
||||
### 실행 결과 · 눈으로 검사 · 기준 검사
|
||||
|
||||
수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다. 배치 파일과 규칙은 바뀌지 않으므로 판정은 누가 돌려도 같습니다 (시간만 다름).
|
||||
|
||||
이 예제는 **고치지 않습니다.** 같은 배치를 내 눈(규칙표)과 기준 검사에 대어 비교합니다. 고치는 것은 예제 2입니다.
|
||||
|
||||
#### ① 눈으로 · AGENTS.md 3절 규칙표를 들고
|
||||
|
||||
13줄을 규칙표에 대어 본 결과입니다 (강사가 직접 읽은 것).
|
||||
|
||||
| 줄 | id | 넣어도 되나 | 이유 한 마디 | 규칙 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | FAQ-101 | 예 | | |
|
||||
| 2 | FAQ-102 | 예 | | |
|
||||
| 3 | FAQ-103 | 아니오 | 제목이 비었음 | `title_missing` |
|
||||
| 4 | FAQ-10 | 아니오 | id가 두 자리 | `id_format` |
|
||||
| 5 | FAQ-105 | 아니오 | 답이 비었음 | `answer_missing` |
|
||||
| 6 | FAQ-106 | 아니오 | 같은 문장을 여섯 번, 300자 넘음 | `answer_too_long` |
|
||||
| 7 | FAQ-102 | 아니오 | 2째 줄과 id가 같음 | `duplicate_in_batch` |
|
||||
| 8 | FAQ-108 | 아니오 | 키워드가 빈 목록 | `keywords_missing` |
|
||||
| 9 | FAQ-109 | 아니오 | 답에 개인 휴대전화 `010-1234-5678` | `pii` |
|
||||
| 10 | FAQ-110 | 아니오 | 답에 `http://bit.ly/…` (바깥 링크) | `external_link` |
|
||||
| 11 | FAQ-003 | ? | 이미 있는 FAQ-003(VPN)을 덮어씀 | (과제 1) |
|
||||
| 12 | FAQ-112 | ? | 이미 있는 "VPN 접속 오류"와 제목이 같음 | (과제 1) |
|
||||
| 13 | FAQ-113 | 아니오 | 제목은 "프린터 토너"인데 답은 메일함 이야기 | 규칙으로는 못 잡음 (Lab 02 예제 2) |
|
||||
|
||||
- 눈으로는 **8줄이 분명히 나쁘고**, 2줄은 이미 있는 FAQ를 알아야 판단할 수 있고(과제 1), 1줄은 뜻을 읽어야 압니다(LLM). 좋은 줄은 2줄뿐입니다.
|
||||
|
||||
#### ② 팀의 기준 검사에 같은 배치
|
||||
|
||||
실행한 명령:
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py call base-faq-check requests/faq-batch-1.json
|
||||
```
|
||||
|
||||
출력 (줄마다 판정을 한 줄로 줄임, 실제는 `rows` 목록):
|
||||
|
||||
```text
|
||||
HTTP 200 · 0.9s
|
||||
{ "batch": "B-1", "total": 13, "valid": 10, "invalid": 3,
|
||||
"rows": [ FAQ-101 ok · FAQ-102 ok · FAQ-103 title_missing · FAQ-10 ok · FAQ-105 answer_missing ·
|
||||
FAQ-106 ok · FAQ-102 ok · FAQ-108 keywords_missing · FAQ-109 ok · FAQ-110 ok ·
|
||||
FAQ-003 ok · FAQ-112 ok · FAQ-113 ok ],
|
||||
"codes": { "title_missing": 1, "answer_missing": 1, "keywords_missing": 1 } }
|
||||
```
|
||||
|
||||
**왜 이렇게 나왔나**
|
||||
|
||||
- 기준은 13줄 중 **10줄을 통과**시켰습니다. 나쁘다고 한 것은 빈 값 셋(제목 · 답 · 키워드)뿐입니다. 기준의 Code에는 규칙이 이 셋밖에 없습니다.
|
||||
- 눈으로 나쁜 8줄 중 **5줄을 놓쳤습니다**: id 두 자리(FAQ-10), 300자 넘는 답(FAQ-106), 배치 안 중복(둘째 FAQ-102), **개인 전화번호**(FAQ-109), **바깥 링크**(FAQ-110). 이 판정대로 넣으면 직원이 보안 사고를 물을 때 김민지 님의 휴대전화가 화면에 나옵니다.
|
||||
- 기준이 "틀린 계산"을 한 것이 아니라 **규칙이 없는** 것입니다. 오늘의 규칙은 AGENTS.md 3절이고, 예제 2에서 그대로 만듭니다.
|
||||
- 0.9초는 n8n이 막 다시 켜진 뒤의 첫 실행이라서입니다 (두 번째부터 0.1초 안팎).
|
||||
|
||||
## 예제 2 · 검사 워크플로우 (15분)
|
||||
|
||||
### 이 실습의 의도
|
||||
|
||||
**한 줄로:** 예제 1에서 본 차이를 없애기 위해, AGENTS.md 3절 규칙대로 판정하는 **내 검사**(`my-faq-check`)를 만듭니다. Lab 01의 중심 실습이고, 오늘 만드는 가져오기 · 보고서가 모두 이 검사를 부릅니다.
|
||||
|
||||
> **생활 속 예 · 검수 기준표.** 주방장마다 보는 눈이 다르면 같은 상자가 어떤 날은 받히고 어떤 날은 반품됩니다. 그래서 식당은 "라벨 없는 고기는 반품, 냉장품은 5도 이하, 유통기한 3일 미만은 반품"처럼 **검수 기준표**를 벽에 붙입니다. 예제 2는 그 기준표(규칙)를 요청문에 그대로 넣어, 누가 만들어도 같은 판정을 내리는 검사를 만드는 실습입니다.
|
||||
|
||||
| 예제 1에서 본 것 | 규칙 (AGENTS.md 3절) | 확인하는 케이스 |
|
||||
| --- | --- | --- |
|
||||
| 빈 값만 봄 | 제목 · 답 · 키워드가 비었으면 각각 코드 | 빈 값 |
|
||||
| 형식 · 길이를 안 봄 | `id_format` · `title_too_long` · `answer_too_long` | 형식 |
|
||||
| 배치 안 중복 · 개인정보 · 바깥 링크를 안 봄 | `duplicate_in_batch` · `pii` · `external_link` | 중복 · 개인정보 · 링크 |
|
||||
| 목록이 아닌 본문에 멈춤 (500) | `items`가 목록이 아니면 **400** | 목록이 아닌 본문 |
|
||||
|
||||
**순서.** 같은 테스트를 **기준에 먼저** 돌려 몇 개가 ✘인지 봄 → 요청문을 써서 에이전트가 새 파일을 만들게 함 → push → 5/5.
|
||||
|
||||
**끝나면 알게 되는 것.** 규칙을 요청문에 적으면 에이전트도 같은 판정을 낸다는 것, 줄마다 **이유(코드)**를 붙여야 사람이 고칠 수 있다는 것, 규칙은 LLM이 아니라 Code가 확인해야 하는 까닭.
|
||||
|
||||
**만들 것**: 배치를 받아 줄마다 규칙을 대어 `ok` · `errors`를 붙여 돌려주는 워크플로우. 아무것도 넣지 않습니다 (넣는 것은 Lab 02).
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py test cases/lab01.json --webhook base-faq-check -v # 고치기 전 (기준에 같은 테스트)
|
||||
```
|
||||
|
||||
| 조건 | 값 |
|
||||
| --- | --- |
|
||||
| 파일 · 웹훅 | `my-work/faq-check.json` (새로) · `my-faq-check` |
|
||||
| 규칙 | `id_format` · `title_missing` · `title_too_long` · `keywords_missing` · `answer_missing` · `answer_too_short` · `answer_too_long` · `duplicate_in_batch` · `pii` · `external_link` — **[AGENTS.md](AGENTS.md) 3절** |
|
||||
| 계산 | Code 노드 하나 (LLM 없음) |
|
||||
| 기대 결과 | `cases/lab01.json` (빈 값 · 형식 · 중복 · 개인정보 · 링크 · B-2 · 목록이 아닌 본문) |
|
||||
|
||||
### 이해하기
|
||||
|
||||
**지금 무엇이 문제인가.** 기준 검사는 틀린 판정을 한 것이 아니라 **규칙이 셋뿐**이었습니다. 그래서 고치는 일은 "버그 찾기"보다 "규칙을 정해 그대로 대기"입니다. 규칙이 요청문에 없으면 에이전트도 자기 방식(빈 값만, 공백은 값으로, `http`면 다 막기…)으로 판정합니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph 기준
|
||||
A1(["배치"]) --> B1["빈 값 셋만<br/>(!title · !answer · !keywords)"] --> C1["목록이 아니면 500"]
|
||||
end
|
||||
subgraph 내 검사
|
||||
A2(["배치"]) --> G2{"items가 목록?"} -->|아니오| R2(["400"])
|
||||
G2 -->|예| B2["Code: 규칙 10개<br/>줄마다 errors"] --> C2(["rows · codes"])
|
||||
end
|
||||
```
|
||||
|
||||
**오류 코드를 붙이는 까닭.** "B-1은 나쁘다"로는 아무도 고칠 수 없습니다. "FAQ-109는 `pii`(답에 개인 전화번호)"라고 해야 콘텐츠 담당자가 그 줄을 고칩니다. 코드는 짧은 영어로 정해 두고(`pii`), 사람이 읽는 말은 과제 2에서 붙입니다. 코드가 같아야 배치마다 "가장 흔한 문제"를 셀 수 있습니다 (Lab 03).
|
||||
|
||||
| 같은 줄 | 기준 검사 | 내 검사 |
|
||||
| --- | --- | --- |
|
||||
| 답에 `010-1234-5678` | `ok` | `pii` |
|
||||
| 답에 `http://bit.ly/…` | `ok` | `external_link` |
|
||||
| `id`가 `FAQ-10` | `ok` | `id_format` |
|
||||
|
||||
**왜 Code인가.** 길이 · 형식 · 중복 · 전화번호 꼴은 **정해진 답이 있는** 판정입니다. Code는 같은 줄에 늘 같은 판정을 0.1초 안에 냅니다. LLM에게 "이 FAQ가 괜찮아?"라고 물으면 날마다 답이 다를 수 있고 줄마다 돈이 듭니다 (75일차). LLM은 규칙으로 알 수 없는 것(답이 질문에 맞나)에만, Lab 02 예제 2에서 씁니다.
|
||||
|
||||
**테스트 케이스.**
|
||||
|
||||
| 케이스 | 보내는 것 | 기대 |
|
||||
| --- | --- | --- |
|
||||
| 빈 값 (제목 · 답 · 키워드) | B-1 | 3째 줄 `title_missing` · 5째 `answer_missing` · 8째 `keywords_missing` |
|
||||
| 형식 (id · 답 길이) | B-1 | 4째 `id_format` · 6째 `answer_too_long` |
|
||||
| 배치 안 중복 · 개인정보 · 바깥 링크 | B-1 | 7째 `duplicate_in_batch` · 9째 `pii` · 10째 `external_link`, 1째 `ok` |
|
||||
| B-2 · 메일 주소만 걸림 | B-2 | 10줄 중 9줄 통과, 사내 링크(7째)는 통과 |
|
||||
| 목록이 아닌 본문 | `not-a-list` | HTTP 400 |
|
||||
|
||||
(케이스 파일은 0부터 세므로 3째 줄이 `rows.2`입니다.)
|
||||
|
||||
**기대 결과.** 고치기 전(기준에 같은 테스트) 1/5, 고친 뒤 5/5.
|
||||
|
||||
<details><summary>▶ 1단계 · 규칙을 요청문에</summary>
|
||||
|
||||
"나쁜 FAQ를 찾아 줘"만 쓰면 에이전트마다 판정이 다릅니다. 3절의 규칙 표를 그대로 요청문에 넣거나 "AGENTS.md 3절 FAQ 품질 규칙대로"라고 씁니다. 공백만 있는 값, 경계 글자 수도.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 2단계 · 목록이 아닌 본문은 누가</summary>
|
||||
|
||||
Code에 들어가기 **전에** IF로 `Array.isArray($json.body.items)`를 봅니다 ([AGENTS.md](AGENTS.md) 5절). Code 안에서 `items.map`이 터지면 n8n은 500을 돌려주고, 부른 쪽은 무엇이 틀렸는지 모릅니다.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||
|
||||
요청문에 꼭 들어갈 것: **① 파일 · 웹훅 ② 모양이 틀린 본문의 처리 ③ 규칙과 경계(공백 · 글자 수) ④ 헷갈리는 규칙(전화번호 꼴 · 사내 주소) ⑤ 결과 모양 · LLM 없음 · 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
|
||||
|
||||
```text
|
||||
my-work/faq-check.json 을 새로 만들어 줘. 웹훅 POST ____①, 이름 "78 FAQ check (my)".
|
||||
Is a list?(IF): 본문 items 가 목록이 아니면 Reject body 로 HTTP ____② {"error": "items must be a list"}.
|
||||
Check rows(Code) 하나에서 줄마다 AGENTS.md 3절 FAQ 품질 규칙대로 errors 를 붙인다. 공백만 있는 값은 빈 값, 글자 수는 ____③ 센다.
|
||||
pii 는 하이픈 없는 휴대전화도, external_link 는 호스트가 ____④ 이 아니면.
|
||||
결과 {batch, total, valid, invalid, rows: [{id, ok, errors, warnings}], codes}, rows 는 들어온 순서 그대로. LLM 은 쓰지 않는다. 기대 결과 cases/lab01.json.
|
||||
```
|
||||
|
||||
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
|
||||
|
||||
- ① AGENTS.md 3절 계약 표의 "FAQ 검사" 줄입니다. 테스트 파일 `cases/lab01.json` 맨 위의 `webhook`과 같아야 합니다.
|
||||
- ② "목록이 아닌 본문" 케이스의 기대 HTTP 상태입니다. 기준 검사는 여기서 무엇을 돌려줬나요?
|
||||
- ③ 3절 규칙 표 아래 줄("공백만 있는 값은…")입니다. 공백 세 칸짜리 제목, 앞에 공백이 붙은 답을 생각해 봅니다.
|
||||
- ④ 3절 `external_link` 줄의 굵은 글씨입니다. 예제 3에서 이 말이 왜 필요한지 봅니다.
|
||||
|
||||
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다.
|
||||
</details>
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py push my-work/faq-check.json
|
||||
uv run tools/flow.py test cases/lab01.json -v
|
||||
uv run tools/flow.py call my-faq-check requests/faq-batch-1.json
|
||||
```
|
||||
|
||||
### 실행 결과 · 검사 워크플로우
|
||||
|
||||
수업 준비 때(11/01) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. 판정은 규칙대로라 누구에게나 같고, 시간만 조금씩 다릅니다.
|
||||
|
||||
#### 고치기 전 · 기준(`base-faq-check`)에 같은 테스트
|
||||
|
||||
실행한 명령:
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py test cases/lab01.json --webhook base-faq-check -v
|
||||
```
|
||||
|
||||
출력 (결과 줄만, `-v`의 요청 · 응답은 줄임):
|
||||
|
||||
```text
|
||||
POST http://localhost:5678/webhook/base-faq-check · 5 case(s) × 1
|
||||
✔ 빈 값 (제목 · 답 · 키워드) · 0.0s
|
||||
✘ 형식 (id · 답 길이) · 0.1s · rows.3.errors has none of ['id_format']; rows.5.errors has none of ['answer_too_long']
|
||||
✘ 배치 안 중복 · 개인정보 · 바깥 링크 · 0.0s · rows.6.errors has none of ['duplicate_in_batch']; rows.8.errors has none of ['pii']; rows.9.errors has none of ['external_link']
|
||||
✘ 배치 2 · 메일 주소만 걸림, 사내 링크는 통과 · 0.1s · valid = 10 (expected 9); codes.pii = null (expected 1); rows.8.ok = true (expected false)
|
||||
✘ 목록이 아닌 본문 → 400 · 0.1s · status 500 (expected 400)
|
||||
Passed 1/5
|
||||
```
|
||||
|
||||
**왜 이렇게 나왔나 (고치기 전)**
|
||||
|
||||
- **빈 값 ✔:** 기준이 가진 규칙 셋이 바로 이 케이스입니다. 맞은 것은 이것뿐입니다.
|
||||
- **형식 · 중복 · 개인정보 · 링크 ✘:** 그런 규칙이 없으니 `errors`에 해당 코드가 없습니다 ("has none of"). 예제 1에서 눈으로 찾은 다섯 줄 그대로입니다.
|
||||
- **B-2 ✘:** B-2의 FAQ-209는 답에 메일 주소(`security.desk@example.com`)가 있습니다. 기준은 10줄 모두 통과시켜 `valid` 10, `pii` 코드 없음.
|
||||
- **목록이 아닌 본문 → 500:** `items`가 글자(`"FAQ-101"`)인데 기준 Code가 `.map`을 부르다 오류가 나서 n8n이 500을 돌려줍니다. 부른 쪽은 "서버가 고장났다"로 알고, 본문이 틀렸다는 것을 모릅니다.
|
||||
- 1/5는 **예상한 결과**입니다.
|
||||
|
||||
#### 고친 뒤 · 내 `my-faq-check`
|
||||
|
||||
실행한 명령:
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py push my-work/faq-check.json
|
||||
uv run tools/flow.py test cases/lab01.json -v
|
||||
uv run tools/flow.py call my-faq-check requests/faq-batch-1.json
|
||||
```
|
||||
|
||||
출력 (결과 줄과 `call`, `rows`는 줄마다 한 줄로 줄임):
|
||||
|
||||
```text
|
||||
✔ 빈 값 (제목 · 답 · 키워드) · 0.9s
|
||||
✔ 형식 (id · 답 길이) · 0.0s
|
||||
✔ 배치 안 중복 · 개인정보 · 바깥 링크 · 0.1s
|
||||
✔ 배치 2 · 메일 주소만 걸림, 사내 링크는 통과 · 0.0s
|
||||
✔ 목록이 아닌 본문 → 400 · 0.1s
|
||||
Passed 5/5
|
||||
|
||||
HTTP 200 · 0.1s
|
||||
{ "batch": "B-1", "total": 13, "valid": 5, "invalid": 8,
|
||||
"rows": [ FAQ-101 ok · FAQ-102 ok · FAQ-103 title_missing · FAQ-10 id_format · FAQ-105 answer_missing ·
|
||||
FAQ-106 answer_too_long · FAQ-102 duplicate_in_batch · FAQ-108 keywords_missing ·
|
||||
FAQ-109 pii · FAQ-110 external_link · FAQ-003 ok · FAQ-112 ok · FAQ-113 ok ],
|
||||
"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 } }
|
||||
```
|
||||
|
||||
**고친 뒤 이렇게 나온 까닭**
|
||||
|
||||
| 줄 · 케이스 | 고치기 전 (기준) | 고친 뒤 | 무엇이 바꿨나 |
|
||||
| --- | --- | --- | --- |
|
||||
| B-1 통과 | 10줄 | 5줄 | 규칙 3개 → 10개 (AGENTS.md 3절) |
|
||||
| FAQ-10 · FAQ-106 | ok | `id_format` · `answer_too_long` | 형식 · 길이 규칙 |
|
||||
| 둘째 FAQ-102 | ok | `duplicate_in_batch` | 앞에 나온 id를 기억 (`seen`) |
|
||||
| FAQ-109 · B-2 FAQ-209 | ok | `pii` | 휴대전화 꼴 · 메일 주소 꼴 |
|
||||
| FAQ-110 | ok | `external_link` | 호스트가 `intra.example.com`이 아닌 링크 |
|
||||
| 목록이 아닌 본문 | 500 | 400 | Code 앞의 IF |
|
||||
|
||||
- **눈과 맞춰 보면:** 눈으로 분명히 나쁜 8줄과 워크플로우의 `invalid` 8줄이 **같은 줄**입니다. 남은 통과 5줄 중 FAQ-003 · FAQ-112는 과제 1(이미 있는 데이터), FAQ-113은 Lab 02 예제 2(LLM)에서 걸러집니다.
|
||||
- **B-2의 7째 줄(FAQ-207)은 통과:** 답에 `https://intra.example.com/msg`가 있지만 사내 주소입니다. "링크면 다 막기"가 아니라는 것이 B-2 케이스로 확인됩니다.
|
||||
- **`codes`:** 코드마다 몇 줄인지 셉니다. 지금은 모두 1이지만, 배치가 쌓이면 "가장 흔한 문제"가 됩니다 (Lab 03 과제 1).
|
||||
- **0.1초:** 13줄을 Code 하나에서 판정했습니다. LLM은 부르지 않았습니다.
|
||||
- 이제 이 검사가 오늘의 **기준 판정**입니다. 과제 · Lab 02 가져오기 · Lab 03 보고서가 모두 이 검사를 부르거나 이 결과를 씁니다.
|
||||
|
||||
## 예제 3 · 경계에서 재 보기 (7분, 에이전트 없이)
|
||||
|
||||
### 이 실습의 의도
|
||||
|
||||
**한 줄로:** 예제 2의 내 검사와 기준 검사에 **경계의 줄**(40자 · 41자 · 공백만 · 10자 · 9자 · 하이픈 없는 전화번호 · 사무실 번호 · 사내 주소처럼 보이는 바깥 주소 · 소문자 id)을 같이 보내, 평소에는 안 보이던 규칙의 차이를 드러냅니다. 고치는 실습이 아니라 **재 보는** 실습입니다.
|
||||
|
||||
> **생활 속 예 · 냉장고 온도 5도.** 검수 기준표에 "냉장품 5도 이하"라고 적혀 있습니다. 4도와 8도는 누구나 판정합니다. 하지만 딱 5도는? 5.1도는? 온도계가 없으면 "차갑네" 하고 받습니다. 예제 3은 딱 기준선에 걸린 줄들로 두 검사를 재 보는 실습입니다.
|
||||
|
||||
**상황.** 보통 줄(빈 제목, 긴 답)에서는 사람도 기준 검사도 판정이 쉽습니다. 그런데 실제 배치에는 경계의 줄이 섞여 옵니다: 딱 40자인 제목, 공백만 친 제목, `01012345678`처럼 하이픈을 뺀 번호, `intra.example.com.evil.io`처럼 앞부분만 사내 주소인 링크. 같은 테스트 `cases/lab01-edges.json`(배치 `requests/faq-edges.json`, 10줄)을 두 검사에 돌립니다.
|
||||
|
||||
| 경계 | 줄 | 기대 (규칙대로) |
|
||||
| --- | --- | --- |
|
||||
| 제목 40자 · 41자 | FAQ-401 · 402 | 40자는 통과, 41자는 `title_too_long` |
|
||||
| 공백만 있는 제목 | FAQ-403 | `title_missing` |
|
||||
| 답 10자 · 9자 | FAQ-404 · 405 | 10자는 통과, 9자는 `answer_too_short` |
|
||||
| 하이픈 없는 휴대전화 · 사무실 번호 | FAQ-406 · 407 | `01012345678`은 `pii`, `02-123-4567`은 통과 |
|
||||
| 사내 주소 · 사내 주소처럼 보이는 바깥 주소 | FAQ-408 · 409 | `https://intra.example.com/help`는 통과, `http://intra.example.com.evil.io/edu`는 `external_link` |
|
||||
| 소문자 id | `faq-410` | `id_format` |
|
||||
|
||||
**끝나면 알게 되는 것.** 규칙은 경계에서 시험받는다는 것, "넘으면"과 "이상이면", 공백, 꼴(정규식)을 **처음부터** 말로 정해 둬야 한다는 것. 그리고 테스트에 경계 줄을 넣는 까닭.
|
||||
|
||||
**적을 곳.** `my-work/QUALITY.md` 3절 표 (경계 · 내 검사 · 기준 검사 · 규칙에서 정한 말).
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py test cases/lab01-edges.json -v # 내 검사
|
||||
uv run tools/flow.py test cases/lab01-edges.json --webhook base-faq-check # 같은 케이스를 기준에
|
||||
```
|
||||
|
||||
### 이해하기
|
||||
|
||||
**경계(edge)란.** 값의 범위 끝에 있는 경우입니다: 딱 기준선의 길이, 빈 값처럼 보이는 공백, 비슷하지만 다른 꼴. 검사의 실수는 대부분 경계에서 나옵니다. 보통 줄만 시험하면 "잘 된다"고 믿게 됩니다.
|
||||
|
||||
| 경계 | 규칙이 말로 정해져 있지 않으면 생기는 일 |
|
||||
| --- | --- |
|
||||
| 딱 40자 | `>`와 `≥`의 차이로 멀쩡한 제목이 막히거나, 41자가 들어감 |
|
||||
| 공백만 | `" "`는 빈 문자열이 아니라서 "제목 있음"으로 통과 |
|
||||
| 전화번호 꼴 | `010-1234-5678`만 찾으면 `01012345678`이 그대로 나감 |
|
||||
| 비슷한 주소 | "`intra.example.com`으로 시작하나"만 보면 `intra.example.com.evil.io`가 사내 주소로 통과 |
|
||||
|
||||
**사내 주소처럼 보이는 바깥 주소.** 링크의 **호스트**(주소에서 `://` 뒤, 첫 `/` 앞)가 무엇인지가 중요합니다. `http://intra.example.com.evil.io/edu`의 호스트는 `intra.example.com.evil.io`이고, 이것은 `evil.io`라는 **바깥** 회사의 주소입니다. 피싱 메일이 자주 쓰는 수법이라 73일차 보안과도 이어집니다. 그래서 AGENTS.md 3절은 "호스트가 **정확히** `intra.example.com`"이라고 씁니다.
|
||||
|
||||
**`--webhook base-faq-check`.** 같은 케이스 파일을 다른 웹훅에 돌리는 옵션입니다. 테스트 파일은 그대로 두고 두 워크플로우를 비교할 때 씁니다 (77일차와 같음).
|
||||
|
||||
**기대 결과.** 내 검사 6/6. 기준 검사는 대부분 ✘ — 어느 케이스가 왜 ✘인지가 이 예제의 답입니다. 내 검사가 ✘라면 요청문에 경계의 말이 빠진 것입니다: 에이전트에게 실패한 케이스를 보여 주고 3절의 말을 다시 짚어 고칩니다.
|
||||
|
||||
<details><summary>▶ 1단계 · 내 검사가 FAQ-409를 통과시켰다면</summary>
|
||||
|
||||
`Check rows`의 링크 규칙을 봅니다. `/https?:\/\/(?!intra\.example\.com)/`처럼 "뒤에 `intra.example.com`이 오나"만 보면 `.evil.io`가 더 붙은 주소도 통과합니다. 호스트가 그 뒤에서 **끝나는지**(다음 글자가 `/` · 공백 · 끝인지)까지 봐야 합니다.
|
||||
</details>
|
||||
|
||||
### 실행 결과 · 경계에서 재 보기
|
||||
|
||||
수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다.
|
||||
|
||||
이 예제는 **고치지 않습니다.** 같은 케이스 파일을 내 검사와 기준 검사에 돌려 비교합니다. 기준을 고치지 않는 것은, 오늘은 기준과 **비교하는** 연습이기 때문입니다.
|
||||
|
||||
#### 내 검사 (`my-faq-check`)
|
||||
|
||||
실행한 명령:
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py test cases/lab01-edges.json -v
|
||||
```
|
||||
|
||||
출력 (결과 줄만):
|
||||
|
||||
```text
|
||||
POST http://localhost:5678/webhook/my-faq-check · 6 case(s) × 1
|
||||
✔ 제목 40자는 통과, 41자는 너무 김 · 0.1s
|
||||
✔ 공백만 있는 제목은 빈 값 · 0.1s
|
||||
✔ 답 10자는 통과, 9자는 너무 짧음 · 0.0s
|
||||
✔ 하이픈 없는 휴대전화도 개인정보, 사무실 번호는 통과 · 0.1s
|
||||
✔ 사내 주소는 통과, 사내 주소처럼 보이는 바깥 링크는 막음 · 0.0s
|
||||
✔ 소문자 id · 합계 10줄 중 4줄 통과 · 0.1s
|
||||
Passed 6/6
|
||||
```
|
||||
|
||||
**왜 이렇게 나왔나:** 내 검사는 3절의 말("40자 **넘으면**", "앞뒤 공백을 뺀 뒤", "`-` · `.` · 공백 없어도", "호스트가 **정확히**")을 그대로 Code로 옮겼으므로 경계에서도 규칙대로입니다. 10줄 중 통과는 4줄(40자 제목 · 10자 답 · 사무실 번호 · 사내 주소)입니다.
|
||||
|
||||
#### 같은 케이스를 기준에 (`base-faq-check`)
|
||||
|
||||
실행한 명령:
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py test cases/lab01-edges.json --webhook base-faq-check
|
||||
```
|
||||
|
||||
출력:
|
||||
|
||||
```text
|
||||
POST http://localhost:5678/webhook/base-faq-check · 6 case(s) × 1
|
||||
✘ 제목 40자는 통과, 41자는 너무 김 · 0.0s · rows.1.ok = true (expected false); rows.1.errors has none of ['title_too_long']
|
||||
✘ 공백만 있는 제목은 빈 값 · 0.1s · rows.2.errors has none of ['title_missing']
|
||||
✘ 답 10자는 통과, 9자는 너무 짧음 · 0.0s · rows.4.errors has none of ['answer_too_short']
|
||||
✘ 하이픈 없는 휴대전화도 개인정보, 사무실 번호는 통과 · 0.1s · rows.5.errors has none of ['pii']
|
||||
✘ 사내 주소는 통과, 사내 주소처럼 보이는 바깥 링크는 막음 · 0.0s · rows.8.errors has none of ['external_link']
|
||||
✘ 소문자 id · 합계 10줄 중 4줄 통과 · 0.1s · valid = 10 (expected 4); rows.9.errors has none of ['id_format']
|
||||
Passed 0/6
|
||||
```
|
||||
|
||||
**왜 이렇게 나왔나 (케이스마다)**
|
||||
|
||||
| 경계 | 기준의 결과 | 까닭 |
|
||||
| --- | --- | --- |
|
||||
| 41자 제목 | 통과 | 길이 규칙이 없음 |
|
||||
| 공백만 있는 제목 | 통과 | `!r.title` — `" "`는 빈 문자열이 아니라 "있음" |
|
||||
| 9자 답 | 통과 | 길이 규칙이 없음 |
|
||||
| `01012345678` | 통과 | 전화번호 규칙이 없음 |
|
||||
| `intra.example.com.evil.io` | 통과 | 링크 규칙이 없음 |
|
||||
| `faq-410` | 통과 | 형식 규칙이 없음 → 10줄 모두 통과 |
|
||||
|
||||
- 기준은 **하나도** 맞지 않았습니다. 77일차 기준 보고서는 "우연히" 맞는 경계가 둘 있었지만, 기준 검사는 경계에 대한 말이 아예 없어 모두 통과시킵니다.
|
||||
- 이 표가 말하는 것: 같은 줄을 보고도 **규칙의 말 한 마디**(넘으면 · 공백 · 꼴 · 정확히)에 따라 판정이 갈립니다. 내 검사가 6/6인 것은 요청문에 그 말을 넣었기 때문입니다. 수업 준비 때 강사의 첫 판도 `intra.example.com.evil.io`를 통과시켰습니다 — "뒤에 `intra.example.com`이 오나"만 보고 끝나는지는 보지 않았기 때문입니다 (▶ 1단계).
|
||||
|
||||
## 과제 1 · 이미 있는 데이터와 맞춰 보기 (8분)
|
||||
|
||||
### 이 실습의 의도
|
||||
|
||||
**한 줄로:** 배치 안만 보던 검사에 **이미 있는 FAQ**(처음 6개 + 오늘 더한 것)를 맞춰 보아, 같은 id로 덮어쓰거나 같은 제목이 둘이 되는 줄을 막습니다.
|
||||
|
||||
> **생활 속 예 · 냉장고에 이미 있는 것.** 납품 상자만 보면 멀쩡한 우유입니다. 그런데 냉장고에 어제 받은 우유가 아직 그대로라면? 같은 칸에 넣으면 날짜가 섞이고, 어느 것을 먼저 쓸지 헷갈립니다. 과제 1은 상자를 받기 전에 냉장고(이미 있는 FAQ)를 먼저 열어 보는 실습입니다.
|
||||
|
||||
**상황.** B-1의 FAQ-003("VPN 접속 오류 (새 판)")은 이미 있는 FAQ-003을 덮어쓰고, FAQ-112의 제목 "VPN 접속 오류"는 이미 있는 FAQ-003의 제목과 같습니다. 둘 다 배치 안만 보면 멀쩡합니다. 한편 키워드가 겹치는 것(`vpn`)은 흔하고 나쁘지 않아서, 막지 말고 **알려만** 주기로 했습니다.
|
||||
|
||||
| 칸 | 정의 (AGENTS.md 3절) |
|
||||
| --- | --- |
|
||||
| `exists_id` (오류) | 이미 있는 FAQ와 `id`가 같음 |
|
||||
| `same_title` (오류) | 이미 있는 FAQ와 제목이 같음 (앞뒤 공백을 뺀 뒤) |
|
||||
| `keyword_used:<낱말>` (**경고**) | 이미 있는 FAQ가 쓰는 키워드 (대소문자 무시) — 통과는 시킴 |
|
||||
|
||||
**끝나면 알게 되는 것.** 검사는 **들어오는 데이터만이 아니라 이미 있는 데이터와의 관계**도 본다는 것 (참조 무결성). 오류와 경고를 나누는 까닭.
|
||||
|
||||
**적을 곳.** `my-work/QUALITY.md` 2절 "워크플로우가 놓친 줄"을 다시.
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py test cases/lab01-challenge.json -v # 고치기 전 (예제 2의 검사)
|
||||
```
|
||||
|
||||
### 이해하기
|
||||
|
||||
**지금 무엇이 없나.** 예제 2의 검사는 배치 하나만 받아 그 안에서 판정합니다. 이미 있는 FAQ를 모르니 "덮어씀"과 "같은 제목"을 볼 수 없습니다. 그래서 검사 앞에 FAQ 전체를 가져오는 노드 하나가 더 필요합니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
W(["배치"]) --> L{"items가 목록?"} --> F["Get FAQ<br/>GET /faq-all"] --> C["Check rows<br/>앞의 규칙 + exists_id · same_title<br/>keyword_used (경고)"] --> R(["rows · codes"])
|
||||
```
|
||||
|
||||
**오류와 경고.** `errors`가 하나라도 있으면 그 줄은 들어가지 못합니다. `warnings`는 통과에 영향이 없고 사람이 보고 판단합니다. 모든 것을 오류로 막으면 콘텐츠 담당자는 아무것도 넣지 못하고, 모든 것을 통과시키면 검사가 의미 없습니다. "막을 것"과 "알릴 것"을 나누는 것도 규칙입니다.
|
||||
|
||||
**테스트 케이스.**
|
||||
|
||||
| 케이스 | 기대 |
|
||||
| --- | --- |
|
||||
| 이미 있는 id (FAQ-003) | 11째 줄 `exists_id` |
|
||||
| 이미 있는 제목 · 쓰인 키워드는 경고 | 12째 줄 `same_title`, `warnings`에 `keyword_used:vpn` |
|
||||
| 배치 1 · 통과 3건 | 13줄 중 3줄 통과 (예제 2는 5줄) |
|
||||
| 막 더한 FAQ도 기존 데이터 | 테스트가 먼저 FAQ 한 줄을 더하고 같은 id를 보냄 → `exists_id` |
|
||||
|
||||
**기대 결과.** 고치기 전 0/4, 고친 뒤 4/4, `lab01` 5/5 그대로.
|
||||
|
||||
<details><summary>▶ 1단계 · 이미 있는 FAQ는 어디서</summary>
|
||||
|
||||
[AGENTS.md](AGENTS.md) 2절 데이터 API 표의 첫 줄입니다. 처음 6개만이 아니라 **오늘 더한 것까지** 돌려주는 주소여야 "막 더한 FAQ" 케이스가 통과합니다.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||
|
||||
요청문에 꼭 들어갈 것: **① 가져올 곳 ② 새 오류 코드 ③ 오류가 아닌 것 ④ 앞의 규칙**. 아래 빈칸 4개를 채웁니다.
|
||||
|
||||
```text
|
||||
my-work/faq-check.json 에 이미 있는 데이터와 맞춰 보기를 더해 줘.
|
||||
Is a list? 와 Check rows 사이에 Get FAQ(HTTP GET http://data-api:8800/____①) 를 둔다.
|
||||
이미 있는 id 면 errors 에 exists_id, 이미 있는 제목이면 ____②.
|
||||
이미 있는 FAQ 가 쓰는 키워드는 errors 가 아니라 ____③ 에 keyword_used:<낱말> (대소문자 무시) — 통과는 시킨다.
|
||||
앞의 규칙은 ____④. 기대 결과 cases/lab01-challenge.json, cases/lab01.json 도.
|
||||
```
|
||||
|
||||
빈칸 힌트:
|
||||
|
||||
- ① AGENTS.md 2절 데이터 API 표에서 "지금 FAQ 전체"를 돌려주는 주소입니다.
|
||||
- ② 3절 규칙 표의 `(Lab 01 과제)` 줄 두 코드 중 제목에 대한 것.
|
||||
- ③ 3절 규칙 표의 `keyword_used` 줄에서 굵은 글씨. 결과의 어느 칸에 넣나요?
|
||||
- ④ 같은 Code에 더하는 것이니, 예제 2의 규칙은 어떻게 둬야 `lab01`이 계속 통과할까요?
|
||||
</details>
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py push my-work/faq-check.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
|
||||
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0}
|
||||
POST http://localhost:5678/webhook/my-faq-check · 4 case(s) × 1
|
||||
✘ 이미 있는 id (FAQ-003) · 0.1s · rows.10.errors has none of ['exists_id']
|
||||
✘ 이미 있는 제목 · 쓰인 키워드는 경고 · 0.1s · rows.11.errors has none of ['same_title']; rows.11.warnings has none of ['keyword_used:vpn']
|
||||
✘ 배치 1 · 통과 3건 · 0.0s · valid = 5 (expected 3); invalid = 8 (expected 10)
|
||||
✔ before: POST http://localhost:8800/faq/add · HTTP 200 · 0.0s · {"added": "FAQ-201", "size": 7}
|
||||
✘ 막 더한 FAQ도 기존 데이터 · 0.1s · rows.0.errors has none of ['exists_id']
|
||||
Passed 0/4
|
||||
✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 1}
|
||||
```
|
||||
|
||||
**왜 이렇게 나왔나 (고치기 전)**
|
||||
|
||||
- 예제 2의 검사는 이미 있는 FAQ를 **읽지 않습니다.** 그래서 FAQ-003(덮어씀) · FAQ-112(같은 제목)를 통과시키고, 통과가 3이 아니라 5입니다.
|
||||
- 넷째 케이스 앞의 `before: POST /faq/add`는 **케이스 하나만의 준비**입니다: 테스트가 FAQ-201을 먼저 더하고, 같은 id를 보내 "막 더한 것도 기존 데이터인가"를 봅니다. `size` 7 = 처음 6개 + 1.
|
||||
- 맨 끝 `after`는 더한 FAQ를 다시 비웁니다 (`cleared` 1). 0/4는 예상한 결과.
|
||||
|
||||
#### 고친 뒤
|
||||
|
||||
실행한 명령:
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py push my-work/faq-check.json
|
||||
uv run tools/flow.py test cases/lab01-challenge.json -v
|
||||
uv run tools/flow.py test cases/lab01.json
|
||||
```
|
||||
|
||||
출력 (결과 줄만):
|
||||
|
||||
```text
|
||||
POST http://localhost:5678/webhook/my-faq-check · 4 case(s) × 1
|
||||
✔ 이미 있는 id (FAQ-003) · 0.9s
|
||||
✔ 이미 있는 제목 · 쓰인 키워드는 경고 · 0.0s
|
||||
✔ 배치 1 · 통과 3건 · 0.1s
|
||||
✔ before: POST http://localhost:8800/faq/add · HTTP 200 · 0.0s · {"added": "FAQ-201", "size": 7}
|
||||
✔ 막 더한 FAQ도 기존 데이터 · 0.0s
|
||||
Passed 4/4
|
||||
|
||||
(lab01) Passed 5/5
|
||||
```
|
||||
|
||||
**고친 뒤 이렇게 나온 까닭**
|
||||
|
||||
| 줄 | 고치기 전 | 고친 뒤 | 무엇이 바꿨나 |
|
||||
| --- | --- | --- | --- |
|
||||
| 11째 FAQ-003 | ok | `exists_id` | `Get FAQ`(`/faq-all`)로 이미 있는 id를 앎 |
|
||||
| 12째 FAQ-112 | ok | `same_title`, 경고 `keyword_used:vpn` | 이미 있는 제목 · 키워드 |
|
||||
| B-1 통과 | 5 | 3 (FAQ-101 · 102 · 113) | 위 두 줄이 빠짐 |
|
||||
| 막 더한 FAQ-201 | ok | `exists_id` | `/faq-all`은 더한 것까지 돌려줌 |
|
||||
|
||||
- **경고는 통과에 영향이 없습니다:** FAQ-112는 `same_title` 때문에 막혔고, `keyword_used:vpn`은 그와 따로 붙은 알림입니다. 키워드만 겹치는 줄은 통과합니다.
|
||||
- **남은 통과 3줄:** FAQ-101 · FAQ-102는 좋은 줄이고, FAQ-113(토너 · 메일함)은 아직 규칙으로 못 잡습니다 (Lab 02 예제 2).
|
||||
- `lab01` 5/5: 앞의 규칙은 그대로입니다. 이 검사가 앞으로 Lab 02 가져오기가 부르는 판입니다.
|
||||
|
||||
## 과제 2 · 보낸 사람에게 돌려줄 고침 목록 (10분)
|
||||
|
||||
### 이 실습의 의도
|
||||
|
||||
**한 줄로:** 검사 결과에 **사람이 읽는 고침 목록**(`fix_list`)과 한 줄 요약(`message`)을 더해, 콘텐츠 담당자가 배치를 받아 들고 바로 고칠 수 있게 합니다.
|
||||
|
||||
> **생활 속 예 · 반품 사유서.** 주방장이 상자를 돌려보낼 때 "안 받아요"만 말하면 납품업체는 무엇이 잘못됐는지 모릅니다. 그래서 반품 사유서에 "3번 상자 · 라벨 없음, 7번 상자 · 냉장 온도 9도"처럼 적어 줍니다. 다음 납품이 나아지는 것은 사유서 덕분입니다. 과제 2는 검사 결과를 반품 사유서로 만드는 실습입니다.
|
||||
|
||||
**상황.** 콘텐츠 담당자는 n8n도 오류 코드도 모릅니다. `{"errors": ["pii"]}`를 받으면 "pii가 뭐지?"부터 묻습니다. 팀장이 요청했습니다: "배치를 돌려보낼 때 줄 번호와 고칠 것을 사람 말로 붙여 주세요. 그리고 맨 위에 한 줄로 몇 줄 통과했는지."
|
||||
|
||||
| 칸 | 정의 (AGENTS.md 3절 "고침 목록") |
|
||||
| --- | --- |
|
||||
| `fix_list` | 통과하지 못한 줄만 줄 순서대로 `{row (1부터), id, codes, fix}` |
|
||||
| `fix` | 코드마다 3절 표의 말 그대로 (예: `pii` → "개인 전화번호 · 메일 주소를 빼고 IT 포털 안내로 바꿔 주세요") |
|
||||
| `message` | `"B-1: 13줄 중 3줄 통과, 10줄 고칠 것"` |
|
||||
|
||||
**끝나면 알게 되는 것.** 검사의 결과는 기계(다음 워크플로우)와 사람(보낸 사람) **둘 다** 읽는다는 것. 같은 코드에 늘 같은 말을 쓰는 까닭과, 그래도 고치는 것은 사람이라는 원칙.
|
||||
|
||||
**적을 곳.** `my-work/QUALITY.md` 4절.
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py test cases/lab01-challenge2.json -v # 고치기 전 (과제 1까지의 검사)
|
||||
```
|
||||
|
||||
### 이해하기
|
||||
|
||||
**코드와 말을 나누는 까닭.** 코드(`pii`)는 짧고 바뀌지 않아서 기계가 셉니다 (Lab 03 `top_codes`). 말("개인 전화번호 · 메일 주소를 빼고…")은 사람이 읽고 고칩니다. 둘을 한 표로 묶어 두면, 말을 고쳐도 코드는 그대로라 보고서가 깨지지 않습니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
C["Check rows<br/>rows · errors (코드)"] --> F["fix_list<br/>코드 → 표의 말"] --> M["message<br/>한 줄 요약"] --> P(["콘텐츠 담당자"])
|
||||
C --> N(["다음 워크플로우<br/>(Lab 02 가져오기)"])
|
||||
```
|
||||
|
||||
**왜 LLM으로 만들지 않나.** "이 오류를 친절하게 설명해 줘"를 LLM에게 맡기면 그럴듯하지만, 같은 코드에 날마다 다른 말이 나오고 줄마다 돈이 듭니다. 고칠 것이 이미 정해져 있으면(코드마다 한 문장) 표로 충분합니다. 77일차 주간 보고의 "다음 할 일을 규칙으로"와 같습니다.
|
||||
|
||||
**고쳐 주지 않는 까닭.** `fix_list`는 **고칠 것을 알려 줄 뿐** 고치지 않습니다. 워크플로우가 빈 제목을 짐작해서 채워 넣으면, 틀린 FAQ가 "검사를 통과한 FAQ"로 들어가고 아무도 그 사실을 모릅니다 (README의 원칙: 고치는 것은 사람).
|
||||
|
||||
**테스트 케이스.**
|
||||
|
||||
| 케이스 | 기대 |
|
||||
| --- | --- |
|
||||
| B-1 · 고칠 줄 10개, 줄 순서대로 | 첫 항목은 3째 줄 FAQ-103, 열째 항목은 FAQ-112, 열한째는 없음 |
|
||||
| 고칠 것은 사람이 읽는 말로 | "제목을 써 주세요" · "개인 전화번호…" · "이미 있는 id…" |
|
||||
| 한 줄 요약 | `"B-1: 13줄 중 3줄 통과, 10줄 고칠 것"` |
|
||||
| B-2 · 메일 주소 한 줄만 | FAQ-209 하나, `"B-2: 10줄 중 9줄 통과, 1줄 고칠 것"` |
|
||||
| 앞의 칸은 그대로 | 통과 3 · 고칠 것 10, `exists_id` |
|
||||
|
||||
**기대 결과.** 고치기 전 1/5 (앞의 칸만 ✔), 고친 뒤 5/5, `lab01` · `lab01-challenge` · `lab01-edges`도 그대로.
|
||||
|
||||
<details><summary>▶ 1단계 · 줄 번호는 어디서</summary>
|
||||
|
||||
`rows`는 들어온 줄과 같은 순서입니다 (AGENTS.md 4절). `rows.map((r, i) => …)`의 `i + 1`이 사람이 세는 줄 번호이고, 통과한 줄을 거르는 것은 그 **뒤에** 합니다. 먼저 거르면 번호가 당겨집니다.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||
|
||||
요청문에 꼭 들어갈 것: **① 순서와 번호 ② 말의 출처 ③ 요약 글자 ④ 하지 않을 것**. 아래 빈칸 4개를 채웁니다.
|
||||
|
||||
```text
|
||||
my-work/faq-check.json 의 결과에 보낸 사람에게 돌려줄 고침 목록을 더해 줘.
|
||||
fix_list: 통과하지 못한 줄만 ____① {row (1부터), id, codes (그 줄의 errors), fix}.
|
||||
fix 는 코드마다 AGENTS.md 3절 "고침 목록" 표의 말을 ____② 쓴다 (LLM 으로 만들지 않는다).
|
||||
message: "<batch>: <total>줄 중 <valid>줄 통과, <invalid>줄 ____③".
|
||||
나쁜 줄을 고쳐 주지는 않는다 — 고치는 것은 ____④. 앞의 칸은 그대로. 기대 결과 cases/lab01-challenge2.json, lab01 · lab01-challenge 도.
|
||||
```
|
||||
|
||||
빈칸 힌트:
|
||||
|
||||
- ① 보낸 사람이 자기 파일에서 줄을 찾으려면 `fix_list`가 어떤 순서여야 하나요?
|
||||
- ② 3절 "고침 목록" 표의 말을 어떻게 쓰나요? 바꿔 쓰거나 LLM에게 맡기면 테스트가 통과할까요?
|
||||
- ③ 테스트의 `message` 기대값을 글자 그대로 봅니다.
|
||||
- ④ README "일하는 방식" 표에서 "나쁜 데이터를 고치기"는 누구의 일인가요?
|
||||
</details>
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py push my-work/faq-check.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-edges.json
|
||||
```
|
||||
|
||||
### 실행 결과 · 고침 목록
|
||||
|
||||
수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다.
|
||||
|
||||
#### 고치기 전 · 과제 1까지의 검사
|
||||
|
||||
실행한 명령:
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py test cases/lab01-challenge2.json -v
|
||||
```
|
||||
|
||||
출력 (결과 줄만):
|
||||
|
||||
```text
|
||||
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0}
|
||||
POST http://localhost:5678/webhook/my-faq-check · 5 case(s) × 1
|
||||
✘ B-1 · 고칠 줄 10개, 줄 순서대로 · 0.0s · fix_list.0.row = null (expected 3); fix_list.0.id = null (expected FAQ-103); fix_list.9.id = null (expected FAQ-112)
|
||||
✘ 고칠 것은 사람이 읽는 말로 · 0.1s · fix_list.0.fix has none of ['제목을 써 주세요']; fix_list.6.fix has none of ['개인 전화번호']; fix_list.8.fix has none of ['이미 있는 id']
|
||||
✘ 한 줄 요약 · 0.0s · message = null (expected B-1: 13줄 중 3줄 통과, 10줄 고칠 것)
|
||||
✘ B-2 · 메일 주소 한 줄만 · 0.1s · fix_list.0.id = null (expected FAQ-209); fix_list.0.codes.0 = null (expected pii); message = null (expected B-2: 10줄 중 9줄 통과, 1줄 고칠 것)
|
||||
✔ 앞의 칸은 그대로 · 0.0s
|
||||
Passed 1/5
|
||||
```
|
||||
|
||||
**왜 이렇게 나왔나 (고치기 전):** 과제 1의 검사에는 `fix_list` · `message` 칸이 없어 모두 `null`입니다. "앞의 칸은 그대로"는 이미 있는 칸(통과 3 · 고칠 것 10 · `exists_id`)을 보는 케이스라 지금도 ✔입니다. 1/5는 예상한 결과.
|
||||
|
||||
#### 고친 뒤
|
||||
|
||||
실행한 명령:
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py push my-work/faq-check.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-edges.json
|
||||
```
|
||||
|
||||
출력 (결과 줄, `-v`로 본 B-1 응답의 `fix_list` 앞부분과 `message`):
|
||||
|
||||
```text
|
||||
✔ B-1 · 고칠 줄 10개, 줄 순서대로 · 0.9s
|
||||
✔ 고칠 것은 사람이 읽는 말로 · 0.1s
|
||||
✔ 한 줄 요약 · 0.1s
|
||||
✔ B-2 · 메일 주소 한 줄만 · 0.1s
|
||||
✔ 앞의 칸은 그대로 · 0.2s
|
||||
Passed 5/5
|
||||
|
||||
"fix_list": [
|
||||
{"row": 3, "id": "FAQ-103", "codes": ["title_missing"], "fix": ["제목을 써 주세요"]},
|
||||
{"row": 4, "id": "FAQ-10", "codes": ["id_format"], "fix": ["id를 FAQ-숫자 3자리로 (예: FAQ-104)"]},
|
||||
{"row": 5, "id": "FAQ-105", "codes": ["answer_missing"], "fix": ["답을 써 주세요"]},
|
||||
…
|
||||
{"row": 9, "id": "FAQ-109", "codes": ["pii"], "fix": ["개인 전화번호 · 메일 주소를 빼고 IT 포털 안내로 바꿔 주세요"]},
|
||||
…
|
||||
{"row": 12, "id": "FAQ-112", "codes": ["same_title"], "fix": ["같은 제목의 FAQ가 이미 있습니다. 제목을 바꾸거나 기존 FAQ와 합쳐 주세요"]}],
|
||||
"message": "B-1: 13줄 중 3줄 통과, 10줄 고칠 것"
|
||||
|
||||
(lab01) Passed 5/5 · (lab01-challenge) Passed 4/4 · (lab01-edges) Passed 6/6
|
||||
```
|
||||
|
||||
**고친 뒤 이렇게 나온 까닭**
|
||||
|
||||
| 칸 | 고치기 전 | 고친 뒤 | 무엇이 바꿨나 |
|
||||
| --- | --- | --- | --- |
|
||||
| `fix_list` | 없음 | 고칠 줄 10개, 3째 줄부터 12째 줄까지 순서대로 | 통과하지 못한 줄만, 번호(`row`)를 먼저 붙이고 거름 |
|
||||
| `fix` | 없음 | 코드마다 3절 표의 말 | 표를 그대로 Code에 (LLM 없음) |
|
||||
| `message` | 없음 | `B-1: 13줄 중 3줄 통과, 10줄 고칠 것` | 3절의 글자 그대로 |
|
||||
| B-2 | — | FAQ-209 하나, `10줄 중 9줄 통과` | 같은 검사, 다른 배치 |
|
||||
|
||||
- **`row`는 1부터, 줄 순서 그대로:** 콘텐츠 담당자가 자기 파일의 3째 줄을 바로 엽니다. 1 · 2 · 13째 줄(통과)은 목록에 없습니다.
|
||||
- **한 줄에 코드가 둘이면 말도 둘:** 오늘 B-1에는 없지만, 제목도 비고 키워드도 비면 `fix`가 두 문장입니다.
|
||||
- **앞의 테스트가 모두 그대로:** `lab01` 5/5 · `lab01-challenge` 4/4 · `lab01-edges` 6/6. 결과에 칸을 더했을 뿐 판정은 바뀌지 않았습니다.
|
||||
- **고쳐 주지 않았습니다:** FAQ-103의 제목을 워크플로우가 지어 넣지 않았습니다. 고칠 것을 알려 줄 뿐이고, 고친 배치는 사람이 다시 보냅니다 (Lab 03 예제 2).
|
||||
|
||||
## 막혔을 때
|
||||
|
||||
| 증상 | 할 일 |
|
||||
| --- | --- |
|
||||
| 목록이 아닌 본문에 500 | Code 앞에 IF(`Array.isArray($json.body.items)`) → 400 응답 |
|
||||
| 공백 제목이 통과 | `String(r?.title ?? '').trim()`으로 앞뒤 공백을 뺀 뒤 판정 |
|
||||
| 40자 제목이 막힘 · 41자가 통과 | "40자 **넘으면**" — `> 40` |
|
||||
| `01012345678`이 통과 | 하이픈 · 점 · 공백이 **없어도** 되는 꼴 (`[-. ]?`) |
|
||||
| FAQ-409가 통과 | 호스트가 `intra.example.com`에서 **끝나는지**까지 (▶ 예제 3의 1단계) |
|
||||
| `exists_id`가 안 붙음 | `Get FAQ`가 `/faq-all`(더한 것 포함)을 부르는지, `Check rows`가 `$('Get FAQ')`를 읽는지 |
|
||||
| `fix_list`의 번호가 당겨짐 | 줄 번호를 붙인 **뒤에** 통과한 줄을 거름 |
|
||||
| `message`가 다름 | 쉼표 · 띄어쓰기까지 3절 그대로 |
|
||||
| 계속 안 됨 | `cp reference/lab01-example2-check.json my-work/faq-check.json` 후 push (예제 2까지) |
|
||||
|
||||
## 확인
|
||||
|
||||
- [ ] 예제 1: `QUALITY.md` 1절 (내 눈 · 기준 검사 · 규칙으로 바꾸면).
|
||||
- [ ] 예제 2: `cases/lab01.json` 5/5 (고치기 전 기준에 1/5).
|
||||
- [ ] 예제 3: `cases/lab01-edges.json` 내 검사 6/6, 기준이 ✘인 까닭을 `QUALITY.md` 3절에.
|
||||
- [ ] 과제 1: `cases/lab01-challenge.json` 4/4, `lab01`도 통과.
|
||||
- [ ] 과제 2: `cases/lab01-challenge2.json` 5/5, `lab01` · `lab01-challenge` · `lab01-edges`도 통과, `QUALITY.md` 4절.
|
||||
- [ ] `my-work/PROMPTS.md`.
|
||||
@@ -0,0 +1,737 @@
|
||||
# Lab 02 · 품질 게이트 · 막고, 격리하고, 알리기
|
||||
|
||||
- 관련 강의: [2단계 · 품질 게이트](../../Lecture/78/02-STAGE2-QUALITY-GATE.md)
|
||||
- 소요 시간: 50분 (4교시 한 시간) · 예제 1 12분 · 예제 2 10분 · 예제 3 6분 · 과제 1 10분 · 과제 2 12분
|
||||
- 준비: Lab 01의 `my-faq-check`(과제 2까지)가 게시돼 있음, 기준 가져오기 `base-faq-import` 게시
|
||||
- 목표: 검사를 통과한 줄만 FAQ에 넣는 **가져오기 게이트**를 만든다. 나쁜 줄이 많은 배치는 통째로 거절하고, 나머지 나쁜 줄은 이유와 함께 **격리함**에 둔다. 규칙으로 못 잡는 줄은 **LLM 검사**로 거르고, 거절 기준을 바꾸면 무엇이 달라지는지 재 보고, 배치마다 **품질 기록 · 알림**을 남기고, LLM을 몇 번 부르는지 숫자로 본다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
E1["예제 1<br/>배치 게이트 · 격리"] --> E2["예제 2<br/>LLM 검사"] --> E3["예제 3<br/>기준을 바꾸면"] --> T1["과제 1<br/>품질 기록 · 알림"] --> T2["과제 2<br/>LLM 호출 수 ·<br/>늘 같은가"]
|
||||
```
|
||||
|
||||
> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다. 상태를 보는 명령은 `uv run tools/flow.py quality`, 처음으로는 `uv run tools/flow.py quality --reset`.
|
||||
|
||||
## 예제 1 · 배치 게이트와 격리 (12분)
|
||||
|
||||
### 이 실습의 의도
|
||||
|
||||
**한 줄로:** 팀이 쓰던 기준 가져오기(`base-faq-import`)가 나쁜 줄까지 FAQ에 넣는 것을 먼저 보고, Lab 01의 검사를 불러 **통과한 줄만 넣고, 나쁜 줄은 격리하고, 너무 나쁜 배치는 통째로 거절하는** 내 가져오기(`my-faq-import`)를 만듭니다.
|
||||
|
||||
> **생활 속 예 · 식당의 반품과 보류 칸.** 주방장은 상자를 받을 때 두 번 판단합니다. 상자 열에 일곱이 상했으면 **트럭째 돌려보냅니다** (하나하나 골라낼 시간에 다시 받는 게 낫습니다). 대부분 괜찮으면 좋은 것만 냉장고에 넣고, 이상한 것은 **보류 칸**에 사유를 붙여 둡니다. 예제 1은 "트럭째 반품"(배치 게이트)과 "보류 칸"(격리함)을 만드는 실습입니다.
|
||||
|
||||
**상황.** 기준 가져오기는 검사 없이 모든 줄을 `/faq/add`로 보냅니다. 데이터 API가 답 없는 줄 정도는 막아 주지만, 개인 전화번호 · 메일 주소 · 바깥 링크가 든 FAQ는 그대로 들어갑니다. 직원이 질문하면 헬프데스크가 그 답을 그대로 보여 줍니다.
|
||||
|
||||
| 단계 | 규칙 (AGENTS.md 3절 "가져오기 게이트") |
|
||||
| --- | --- |
|
||||
| 검사 | 규칙을 다시 쓰지 않고 `my-faq-check`를 부름 |
|
||||
| 배치 게이트 | 나쁜 줄 비율(`invalid / total`)이 기준(기본 **0.3**)을 **넘으면** 422, 아무것도 넣지 않음 |
|
||||
| 줄 게이트 | 통과한 줄만 `/faq/add`, 나머지는 이유(`errors`)와 함께 `/quarantine` |
|
||||
| 결과 | `imported` · `quarantined` · `score`(= `imported / total`) · `max_invalid_ratio`(쓴 기준) |
|
||||
|
||||
**순서.** 같은 테스트를 기준에 먼저 → `quality`로 무엇이 들어갔나 봄 → 요청문 → push → 6개 중 앞 넷과 FAQ-305 ✔ (FAQ-210은 예제 2).
|
||||
|
||||
**끝나면 알게 되는 것.** 검사(무엇이 나쁜가)와 게이트(그래서 무엇을 하나)는 다른 일이라는 것. 나쁜 데이터는 **버리지 않고 이유와 함께 격리**해야 고칠 수 있다는 것.
|
||||
|
||||
**적을 곳.** `my-work/QUALITY.md` 5절 첫 줄 (0.3 · B-1).
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py test cases/lab02.json --webhook base-faq-import -v # 고치기 전 (기준에 같은 테스트)
|
||||
uv run tools/flow.py quality # 무엇이 들어갔나
|
||||
```
|
||||
|
||||
### 이해하기
|
||||
|
||||
**지금 무엇이 문제인가.** 기준 가져오기는 "검사"가 없습니다. 데이터 API는 모양(답 · 키워드가 있나)만 보고, **내용**(개인정보 · 링크)은 보지 않습니다. 그래서 고치는 일은 가져오기 앞에 Lab 01의 검사를 세우고, 그 판정으로 **갈래를 나누는** 것입니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
W(["배치"]) --> C["Run check<br/>my-faq-check"] --> K{"rows가 있나?"}
|
||||
K -->|아니오| R4(["400"])
|
||||
K -->|예| G{"invalid / total<br/>≤ 기준?"}
|
||||
G -->|아니오| R(["422 · 아무것도 넣지 않음"])
|
||||
G -->|예| S["Split rows"] --> D["Store row<br/>ok → /faq/add<br/>아니면 → /quarantine"] --> M(["imported · quarantined · score"])
|
||||
```
|
||||
|
||||
**왜 배치를 통째로 거절하나.** 13줄 중 10줄이 나쁘면, 좋은 3줄만 넣는 것보다 **보낸 사람에게 돌려보내는** 게 낫습니다. 나쁜 줄이 그만큼 많으면 배치를 만드는 방법 자체가 잘못됐을 가능성이 크고, 격리함에 10줄이 쌓이면 사람이 하나하나 봐야 합니다. 기준(0.3)은 "열 줄 중 세 줄까지는 골라내 받는다"는 팀의 약속입니다. 예제 3에서 이 기준을 바꿔 봅니다.
|
||||
|
||||
**격리함(quarantine).** 넣지 않은 줄을 **이유와 함께** 보관하는 곳입니다. 버리면 무엇이 왜 빠졌는지 아무도 모르고, 고친 뒤 다시 넣을 수도 없습니다 (Lab 03 예제 2). 76일차의 DLQ(실패한 요청을 두는 곳)와 같은 생각입니다.
|
||||
|
||||
| | 버리기 | 격리 |
|
||||
| --- | --- | --- |
|
||||
| 무엇이 빠졌나 | 모름 | `row` 그대로 |
|
||||
| 왜 | 모름 | `errors` |
|
||||
| 고쳐서 다시 | 못 함 | 고친 줄을 다시 보냄 → 격리함에서 뺌 |
|
||||
|
||||
**규칙을 다시 쓰지 않는 까닭.** 가져오기 안에 규칙을 한 벌 더 쓰면, Lab 01에서 규칙을 고칠 때 두 곳을 고쳐야 하고 언젠가 어긋납니다. 77일차 요약이 보고서를 **부른** 것처럼, 가져오기는 검사를 **부릅니다** (AGENTS.md 5절 "다른 내 웹훅을 부르기").
|
||||
|
||||
**테스트 케이스.**
|
||||
|
||||
| 케이스 | 보내는 것 | 기대 |
|
||||
| --- | --- | --- |
|
||||
| 나쁜 배치는 통째로 거절 | B-1 | 422, `invalid_ratio` 0.3 이상 |
|
||||
| 배치 2 · 통과한 줄만 넣고 나쁜 줄은 격리 | B-2 | 거절 아님, FAQ-201은 넣고 FAQ-209는 격리 |
|
||||
| FAQ에 들어갔고 메일 주소는 안 들어감 | (FAQ 목록) | FAQ-208 있음, `security.desk` 없음 |
|
||||
| 격리함에 이유와 함께 | (격리함) | `"pii"` |
|
||||
| LLM · 제목과 답이 다른 줄 | B-2 | FAQ-210 격리 · `llm_mismatch` 1 — **예제 2** |
|
||||
| LLM · 규칙에 걸린 줄은 그대로 | B-3 | 5줄 넣고 FAQ-305 격리 |
|
||||
|
||||
**기대 결과.** 기준에 0/6, 내 가져오기 5/6 (FAQ-210 케이스만 ✘ — 예제 2에서).
|
||||
|
||||
<details><summary>▶ 1단계 · 검사 결과에서 무엇을 쓰나</summary>
|
||||
|
||||
`Run check`의 응답에 `total` · `invalid` · `rows`가 있습니다. 배치 게이트는 `invalid / total`, 줄 게이트는 `rows[i].ok`. 줄의 원래 내용(`row`)은 검사 결과에 없으니 웹훅 본문의 `items[i]`와 짝지어야 합니다 (`Split rows`).
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 2단계 · 넣기와 격리를 IF로 가르면</summary>
|
||||
|
||||
[AGENTS.md](AGENTS.md) 5절 "줄을 나눠 저장하기 — 한 줄로"를 읽어 보세요. HTTP 노드 **하나**에서 주소를 표현식으로 고르면, 갈래를 다시 합치지 않아도 되고 예제 2에서 LLM을 더해도 격리 수가 틀리지 않습니다.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||
|
||||
요청문에 꼭 들어갈 것: **① 부를 검사 ② 기준과 기본값 ③ 거절의 모양 ④ 저장하는 노드 ⑤ 결과 모양 · 기대 결과**. 아래 빈칸 4개를 채웁니다.
|
||||
|
||||
```text
|
||||
my-work/faq-import.json 을 새로 만들어 줘. 웹훅 POST my-faq-import, 이름 "78 FAQ import (my)".
|
||||
Run check: 규칙을 다시 쓰지 말고 HTTP POST http://localhost:5678/webhook/____① 로 부른다 (본문 그대로, neverError, timeout 30000).
|
||||
Batch ok?(IF): invalid / total 이 기준 이하일 때만 받는다. 기준은 본문 max_invalid_ratio, 없으면 ____②. 넘으면 HTTP ____③ {error: "batch rejected", batch, invalid_ratio (4자리), max_invalid_ratio, codes}.
|
||||
Split rows → Store row: 줄마다 ok 면 POST /faq/add, 아니면 POST /quarantine {items: [{batch, row, errors}]} — IF 로 가르지 말고 HTTP 노드 ____④에서 주소를 표현식으로.
|
||||
Summarize: {batch, total, imported, quarantined, rejected: false, score (imported / total, 4자리), max_invalid_ratio, codes, imported_ids, quarantined_ids}. 기대 결과 cases/lab02.json 앞 네 케이스.
|
||||
```
|
||||
|
||||
빈칸 힌트:
|
||||
|
||||
- ① Lab 01에서 만든 웹훅입니다. 규칙을 여기 다시 쓰지 않습니다.
|
||||
- ② AGENTS.md 3절 "가져오기 게이트" 표의 배치 게이트 줄에서 굵은 숫자. 본문에 기준이 없을 때 씁니다.
|
||||
- ③ 같은 표의 배치 게이트 줄, 또는 3절 계약 표 "FAQ 가져오기"의 거절 응답. 400과 무엇이 다른가요?
|
||||
- ④ AGENTS.md 5절의 수업 준비 메모를 봅니다. 노드가 몇 개여야 Merge가 필요 없나요?
|
||||
</details>
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py push my-work/faq-import.json
|
||||
uv run tools/flow.py test cases/lab02.json -v
|
||||
uv run tools/flow.py quality
|
||||
```
|
||||
|
||||
### 실행 결과 · 배치 게이트와 격리
|
||||
|
||||
수업 준비 때(11/01) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. 검사 · 게이트는 규칙대로라 누구에게나 같고, 시간만 다릅니다.
|
||||
|
||||
#### 고치기 전 · 기준(`base-faq-import`)에 같은 테스트
|
||||
|
||||
실행한 명령:
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py test cases/lab02.json --webhook base-faq-import -v
|
||||
uv run tools/flow.py quality
|
||||
```
|
||||
|
||||
출력 (결과 줄과, `-v`로 본 첫 두 응답):
|
||||
|
||||
```text
|
||||
✘ 나쁜 배치는 통째로 거절 (422) · 0.1s · status 200 (expected 422); invalid_ratio = null (expected 0.3..1)
|
||||
✘ 배치 2 · 통과한 줄만 넣고 나쁜 줄은 격리 · 1.1s · quarantined_ids has none of ['FAQ-209']
|
||||
✘ FAQ에 들어갔고 메일 주소는 안 들어감 · 0.0s · faqs must not have ['security.desk']
|
||||
✘ 격리함에 이유와 함께 · 0.0s · items has none of ['"pii"']
|
||||
✘ LLM · 제목과 답이 다른 줄 (FAQ-210) · 1.1s · imported = 10 (expected 8); quarantined = 0 (expected 2); codes.llm_mismatch = null (expected 1); quarantined_ids has none of ['FAQ-210']
|
||||
✘ LLM · 규칙에 걸린 줄은 그대로 (FAQ-305) · 0.1s · imported = 6 (expected 5); quarantined_ids has none of ['FAQ-305']
|
||||
Passed 0/6
|
||||
✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 16}
|
||||
|
||||
← B-1 HTTP 200 {"batch": "B-1", "total": 13, "imported": 12, "quarantined": 0, "rejected": false, "score": 0.9231,
|
||||
"imported_ids": ["FAQ-101", "FAQ-102", "FAQ-103", "FAQ-10", "FAQ-106", "FAQ-102", "FAQ-108", "FAQ-109", "FAQ-110", "FAQ-003", "FAQ-112", "FAQ-113"]}
|
||||
← B-2 HTTP 200 {"batch": "B-2", "total": 10, "imported": 10, "quarantined": 0, "score": 1, ...}
|
||||
|
||||
FAQ: 6 (nothing added)
|
||||
quarantine: 0
|
||||
```
|
||||
|
||||
**왜 이렇게 나왔나 (고치기 전)**
|
||||
|
||||
- **B-1을 13줄 중 12줄 넣었습니다.** 빠진 것은 답이 빈 FAQ-105 하나뿐인데, 그것도 기준이 막은 게 아니라 데이터 API의 `/faq/add`가 "답이 필요하다"며 거절한 것입니다. 제목 없는 FAQ-103, 개인 전화번호 FAQ-109, 바깥 링크 FAQ-110, 그리고 **이미 있던 FAQ-003(VPN)과 같은 id를 한 벌 더** 넣었습니다 (FAQ 목록에 FAQ-003이 둘 — 검색이 어느 답을 줄지 모름). 기준의 `score` 0.9231은 "잘 들어갔다"는 숫자로 보이지만 실제로는 나쁜 줄이 대부분입니다.
|
||||
- **B-2도 10줄 모두** 넣었습니다: 메일 주소가 든 FAQ-209, 답이 다른 FAQ-210까지. 그래서 "메일 주소는 안 들어감" 케이스가 `security.desk`를 FAQ 목록에서 찾았습니다.
|
||||
- **격리함이 0:** 기준에는 격리라는 갈래가 없습니다. 무엇을 넣지 않았는지 기록이 없습니다.
|
||||
- 끝의 `after: faq-reset`이 테스트가 넣은 16줄을 비워서, 바로 뒤 `quality`는 다시 FAQ 6입니다. 테스트가 끝날 때 상태를 되돌리는 것도 테스트의 일입니다.
|
||||
- 0/6은 **예상한 결과**입니다.
|
||||
|
||||
#### 고친 뒤 · 내 `my-faq-import` (예제 1)
|
||||
|
||||
실행한 명령:
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py push my-work/faq-import.json
|
||||
uv run tools/flow.py test cases/lab02.json -v
|
||||
uv run tools/flow.py quality
|
||||
```
|
||||
|
||||
출력 (결과 줄과 `-v`의 응답 둘):
|
||||
|
||||
```text
|
||||
✔ 나쁜 배치는 통째로 거절 (422) · 0.9s
|
||||
✔ 배치 2 · 통과한 줄만 넣고 나쁜 줄은 격리 · 1.4s
|
||||
✔ FAQ에 들어갔고 메일 주소는 안 들어감 · 0.0s
|
||||
✔ 격리함에 이유와 함께 · 0.0s
|
||||
✘ LLM · 제목과 답이 다른 줄 (FAQ-210) · 1.2s · imported = 9 (expected 8); quarantined = 1 (expected 2); codes.llm_mismatch = null (expected 1); quarantined_ids has none of ['FAQ-210']
|
||||
✔ LLM · 규칙에 걸린 줄은 그대로 (FAQ-305) · 0.1s
|
||||
Passed 5/6
|
||||
|
||||
← B-1 HTTP 422 {"error": "batch rejected", "batch": "B-1", "invalid_ratio": 0.7692, "max_invalid_ratio": 0.3,
|
||||
"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, "exists_id": 1, "same_title": 1}}
|
||||
← B-2 HTTP 200 {"batch": "B-2", "total": 10, "imported": 9, "quarantined": 1, "rejected": false, "score": 0.9,
|
||||
"max_invalid_ratio": 0.3, "codes": {"pii": 1}, "quarantined_ids": ["FAQ-209"], ...}
|
||||
|
||||
FAQ: 6 (nothing added)
|
||||
quarantine: 2
|
||||
```
|
||||
|
||||
**고친 뒤 이렇게 나온 까닭**
|
||||
|
||||
| 배치 | 고치기 전 (기준) | 고친 뒤 | 무엇이 바꿨나 |
|
||||
| --- | --- | --- | --- |
|
||||
| B-1 | 12줄 넣음 (FAQ-003이 둘) | **422**, 아무것도 넣지 않음 | 나쁜 줄 10/13 = 0.7692 > 0.3 (배치 게이트) |
|
||||
| B-2 | 10줄 넣음 (메일 주소 포함) | 9줄 넣고 FAQ-209 격리 | 줄 게이트 + `pii` |
|
||||
| B-3 | 6줄 넣음 (답 "짧") | 5줄 넣고 FAQ-305 격리 | `answer_too_short` |
|
||||
| 격리함 | 없음 | 줄마다 `row` · `errors` | `/quarantine` |
|
||||
|
||||
- **B-1의 `invalid_ratio` 0.7692:** Lab 01 과제 2까지의 검사로 13줄 중 10줄이 나쁩니다. 422 본문의 `codes`를 그대로 콘텐츠 담당자에게 돌려주면 무엇을 고칠지 압니다 (고침 목록은 `my-faq-check`를 부르면 나옴).
|
||||
- **FAQ-210 ✘ 하나:** 규칙은 모두 통과했으므로 아직 들어갑니다 (`imported` 9). 예제 2에서 LLM이 막습니다. **예상한 ✘**입니다.
|
||||
- **`quality`의 격리함 2줄:** 마지막 두 케이스가 남긴 B-2의 FAQ-209와 B-3의 FAQ-305입니다. 테스트가 FAQ는 비웠지만 격리함은 사람이 볼 곳이라 그대로 둡니다.
|
||||
- **1~1.4초:** 검사 웹훅을 한 번 부르고(0.1초), 줄마다 HTTP 한 번씩. LLM은 아직 없습니다.
|
||||
|
||||
## 예제 2 · LLM 검사 (10분)
|
||||
|
||||
### 이 실습의 의도
|
||||
|
||||
**한 줄로:** 규칙을 모두 통과했지만 **답이 제목의 질문에 답하지 않는** 줄(FAQ-210 "모니터 밝기 조절"인데 답은 연차 휴가)을 LLM에게 물어 거릅니다. 규칙을 통과한 줄에만 묻습니다.
|
||||
|
||||
> **생활 속 예 · 맛보기.** 검수 기준표로 무게 · 온도 · 유통기한은 잴 수 있습니다. 하지만 "이 소스, 주문한 맛이 맞나?"는 기준표로 못 잽니다. 주방장이 한 숟가락 맛봅니다. 그런데 상한 것까지 맛볼 필요는 없습니다 — 이미 기준표에서 떨어진 것은 맛보지 않습니다. 예제 2는 기준표를 통과한 것만 맛보는(LLM 검사) 실습입니다.
|
||||
|
||||
**상황.** B-2의 FAQ-210은 제목 · 키워드 · 길이 · 형식이 모두 멀쩡합니다. 그런데 답이 연차 휴가 이야기입니다. 이런 줄이 들어가면 "모니터 밝기"를 물은 직원이 연차 휴가 안내를 받습니다. 규칙(Code)으로는 "뜻이 맞나"를 알 수 없습니다.
|
||||
|
||||
| 노드 | 하는 일 |
|
||||
| --- | --- |
|
||||
| `Only passed` (Code) | 규칙을 **통과한** 줄만 남김 |
|
||||
| `Judge` (AI Agent + `gpt-4.1-mini`, temperature 0) | 제목과 답만 보고 `{"match": true/false, "reason"}` |
|
||||
| `Decide` (Code) | `match`가 `false`이거나 읽을 수 없으면 `llm_mismatch`로 격리 |
|
||||
|
||||
**끝나면 알게 되는 것.** **규칙은 Code가, 뜻은 LLM이** — 둘을 나누는 선. LLM 판정을 읽을 수 없을 때 넣지 않는 쪽으로(fail-closed, 73일차) 정하는 까닭. 줄마다 1초쯤 걸린다는 것.
|
||||
|
||||
**적을 곳.** `my-work/QUALITY.md` 6절 (B-2 · B-3의 줄 · 규칙 통과 · LLM이 막은 줄).
|
||||
|
||||
### 이해하기
|
||||
|
||||
**지금 무엇이 없나.** 예제 1의 가져오기는 검사 결과의 `ok`만 봅니다. FAQ-210은 `ok`이므로 들어갑니다. 그래서 `Split rows`와 `Store row` 사이에 "통과한 줄만 LLM에게 묻고, 그 판정으로 `ok`를 다시 정하는" 세 노드를 넣습니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
S["Split rows<br/>(줄마다)"] --> P["Only passed<br/>규칙 통과만"] --> J{{"Judge<br/>답이 질문에 맞나?"}} --> D["Decide<br/>판정을 줄에 맞춤<br/>읽을 수 없으면 false"] --> T["Store row"]
|
||||
S -.->|규칙에 걸린 줄| D
|
||||
```
|
||||
|
||||
**규칙을 통과한 줄에만 묻는 까닭.** 규칙에 걸린 줄은 이미 격리됩니다. 그 줄을 LLM에게 물어도 결과는 같고, 돈과 시간만 듭니다 (75일차: LLM 호출 하나가 1~2초와 비용). B-2는 10줄 중 9줄만, B-3은 6줄 중 5줄만 묻습니다. 과제 2에서 이 수를 응답에 넣어 확인합니다.
|
||||
|
||||
**판정을 읽을 수 없으면.** LLM이 JSON이 아닌 말을 하거나, 오류가 나면 그 줄을 **넣지 않습니다** (`match: false`). "모르면 넣지 않는다"가 품질 게이트의 기본입니다. 반대로 "모르면 넣는다"로 하면 LLM이 멈춘 날 모든 줄이 검사 없이 들어갑니다.
|
||||
|
||||
**`Decide`가 줄을 맞추는 법.** `Judge`는 `Only passed`가 넘긴 줄 수만큼 결과를 냅니다. `Decide`는 `$('Judge').all()[i]`를 `$('Only passed').all()[i]`와 짝짓고, 그다음 `Split rows`의 줄 순서대로 판정을 다시 끼워 넣습니다. 그래야 결과의 `imported_ids` · `quarantined_ids`가 들어온 순서와 맞습니다.
|
||||
|
||||
**기대 결과.** `lab02` 6/6. B-2 가져오기가 1초 안팎에서 **8~12초**로 늘어납니다 (9줄 × LLM 한 번).
|
||||
|
||||
<details><summary>▶ 1단계 · 지시문</summary>
|
||||
|
||||
LLM에게는 **제목과 답만** 줍니다. "답이 제목의 질문에 답하나? 문체는 보지 말 것. JSON만"이라고 씁니다. 규칙이 이미 본 것(길이 · 형식)을 다시 묻지 않습니다.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||
|
||||
요청문에 꼭 들어갈 것: **① 누구에게 묻나 ② 읽을 수 없을 때 ③ 붙일 코드 ④ LLM 오류일 때**. 아래 빈칸 4개를 채웁니다.
|
||||
|
||||
```text
|
||||
my-work/faq-import.json 에 LLM 검사를 더해 줘.
|
||||
Split rows 뒤에 Only passed(Code) 로 규칙을 ____① 줄만 Judge(AI Agent + OpenAI gpt-4.1-mini, temperature 0) 에게 보낸다.
|
||||
지시문: 제목과 답만 주고 "답이 제목의 질문에 답하나?" 를 {"match": true|false, "reason"} JSON 으로만.
|
||||
Decide(Code): Judge 의 i번째 결과를 Only passed 의 i번째 줄과 맞추고, 읽을 수 없으면 match ____②. match 가 false 면 errors 에 ____③.
|
||||
Judge 의 onError 는 ____④. 규칙에 걸린 줄은 LLM 에 보내지 않고 그대로 격리. 앞의 칸은 그대로. 기대 결과 cases/lab02.json 전체.
|
||||
```
|
||||
|
||||
빈칸 힌트:
|
||||
|
||||
- ① AGENTS.md 3절 "가져오기 게이트" 표의 LLM 검사 줄. 어떤 줄만 LLM에게 보내나요?
|
||||
- ② "모르면 넣지 않는다"를 `match` 값으로 쓰면.
|
||||
- ③ AGENTS.md 3절 규칙 표의 마지막 코드.
|
||||
- ④ AGENTS.md 5절 "LLM 검사" 셋째 줄. LLM이 멈춰도 워크플로우가 멈추지 않게.
|
||||
</details>
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py push my-work/faq-import.json
|
||||
uv run tools/flow.py test cases/lab02.json -v
|
||||
```
|
||||
|
||||
### 실행 결과 · LLM 검사
|
||||
|
||||
수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다. LLM(`gpt-4.1-mini`, temperature 0)을 부르므로 시간은 그때그때 다릅니다.
|
||||
|
||||
#### 고치기 전 · 예제 1의 가져오기
|
||||
|
||||
예제 1의 실행 결과 그대로입니다: `lab02` 5/6, FAQ-210이 `imported_ids`에 들어가 `llm_mismatch`가 없음.
|
||||
|
||||
#### 고친 뒤
|
||||
|
||||
실행한 명령:
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py push my-work/faq-import.json
|
||||
uv run tools/flow.py test cases/lab02.json -v
|
||||
```
|
||||
|
||||
출력 (결과 줄과 `-v`의 B-2 · B-3 응답):
|
||||
|
||||
```text
|
||||
✔ 나쁜 배치는 통째로 거절 (422) · 1.0s
|
||||
✔ 배치 2 · 통과한 줄만 넣고 나쁜 줄은 격리 · 9.3s
|
||||
✔ FAQ에 들어갔고 메일 주소는 안 들어감 · 0.0s
|
||||
✔ 격리함에 이유와 함께 · 0.0s
|
||||
✔ LLM · 제목과 답이 다른 줄 (FAQ-210) · 8.4s
|
||||
✔ LLM · 규칙에 걸린 줄은 그대로 (FAQ-305) · 4.4s
|
||||
Passed 6/6
|
||||
|
||||
← B-2 {"total": 10, "imported": 8, "quarantined": 2, "score": 0.8, "codes": {"pii": 1, "llm_mismatch": 1},
|
||||
"imported_ids": ["FAQ-201", …, "FAQ-208"], "quarantined_ids": ["FAQ-209", "FAQ-210"]}
|
||||
← B-3 {"total": 6, "imported": 5, "quarantined": 1, "score": 0.8333, "codes": {"answer_too_short": 1},
|
||||
"quarantined_ids": ["FAQ-305"]}
|
||||
```
|
||||
|
||||
**고친 뒤 이렇게 나온 까닭**
|
||||
|
||||
| 줄 · 케이스 | 고치기 전 (예제 1) | 고친 뒤 | 무엇이 바꿨나 |
|
||||
| --- | --- | --- | --- |
|
||||
| FAQ-210 (밝기 · 연차 휴가) | 넣음 | 격리 `llm_mismatch` | `Judge`가 `match: false` |
|
||||
| B-2 | 9줄 · `score` 0.9 | 8줄 · 0.8 | 위 한 줄 |
|
||||
| B-3 | 5줄 | 5줄 (같음) | 5줄 모두 질문에 맞는 답 · FAQ-305는 규칙에서 이미 격리 |
|
||||
| B-2 시간 | 1.4초 | 9.3초 | 규칙을 통과한 9줄 × LLM 한 번 |
|
||||
|
||||
- **FAQ-305는 LLM에 가지 않았습니다:** 답이 "짧" 한 글자라 규칙(`answer_too_short`)에서 걸렸습니다. LLM에게 물었다면 돈을 쓰고도 같은 결과입니다. 과제 2에서 몇 번 불렀는지 숫자로 봅니다.
|
||||
- **LLM이 막은 것은 FAQ-210 하나:** B-2의 다른 8줄과 B-3의 5줄은 모두 `match: true`. "규칙은 통과했지만 뜻이 틀린 줄"은 드뭅니다. 그 드문 줄을 위해 줄마다 1초쯤을 씁니다 — 이 대가를 알고 쓰는 것이 중요합니다.
|
||||
- **시간 9초:** 직원이 기다리는 헬프데스크 답이라면 너무 깁니다. 하지만 가져오기는 콘텐츠 담당자가 배치를 보낼 때 한 번 도는 일이라 괜찮다고 판단했습니다. 같은 LLM 검사도 **어디에 쓰느냐**에 따라 허용 시간이 다릅니다.
|
||||
|
||||
## 예제 3 · 기준을 바꾸면 (6분, 에이전트 없이)
|
||||
|
||||
### 이 실습의 의도
|
||||
|
||||
**한 줄로:** 예제 1에서 정한 거절 기준(0.3)을 본문의 `max_invalid_ratio`로 **0.8 · 0.1 · 0.05**로 바꿔 보내, 같은 배치가 받히기도 하고 거절되기도 하는 것을 봅니다. 워크플로우는 고치지 않습니다.
|
||||
|
||||
> **생활 속 예 · 반품 기준을 느슨하게 하면.** "상자 열에 셋까지는 골라 받는다"를 "열에 여덟까지"로 바꾸면 트럭을 돌려보내는 일은 줄지만, 보류 칸이 넘치고 주방장이 하루 종일 골라내야 합니다. "열에 하나도 안 된다"로 바꾸면 보류 칸은 비지만 거의 모든 트럭이 돌아갑니다. 예제 3은 반품 기준을 바꿔 가며 무엇이 늘고 주는지 재 보는 실습입니다.
|
||||
|
||||
**상황.** 팀에서 "0.3은 너무 엄하다"와 "B-2도 한 줄 나쁘면 돌려보내자"는 말이 나왔습니다. 말로 다투기 전에, 같은 배치를 기준만 바꿔 보내 결과를 재 봅니다. `cases/lab02-gate.json`을 예제 2의 내 가져오기에 돌립니다.
|
||||
|
||||
| 실험 | 보내는 것 | 기준 | 기대 |
|
||||
| --- | --- | --- | --- |
|
||||
| ① B-1 기본 | `faq-batch-1.json` | 0.3 (없음) | 거절 · `invalid_ratio` 0.7692 |
|
||||
| ② B-1 느슨하게 | `faq-batch-1-loose.json` | 0.8 | 받음 · 넣는 것 2줄 · 격리 11줄 |
|
||||
| ③ FAQ만 처음으로 | `/faq-reset` | — | ②가 넣은 2줄을 비움 (격리함은 그대로) |
|
||||
| ④ B-2 엄하게 | `faq-batch-2-strict.json` | 0.05 | 거절 (아무것도 넣지 않음) |
|
||||
| ⑤ B-2 · 딱 같음 | `faq-batch-2-limit-0.1.json` | 0.1 | 나쁜 줄 10% = 기준 → 받음 |
|
||||
| ⑥ 격리함 | — | — | 13줄 (B-1 11 + B-2 2) |
|
||||
|
||||
**끝나면 알게 되는 것.** 기준은 **맞고 틀리는 값이 아니라 고르는 값**이고, 고르면 대가가 따른다는 것 (거절된 배치 수 ↔ 격리함의 줄 수 ↔ 사람의 일). "넘으면"과 "같으면"의 경계. 기준을 바꿔도 워크플로우를 고치지 않게 하려면 기준을 **입력**으로 받아야 한다는 것.
|
||||
|
||||
**적을 곳.** `my-work/QUALITY.md` 5절 표 넷째 줄까지, 그리고 "우리 팀의 기준과 그 이유".
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py test cases/lab02-gate.json -v
|
||||
uv run tools/flow.py quality
|
||||
```
|
||||
|
||||
### 이해하기
|
||||
|
||||
**기준 하나가 두 실수를 맞바꿉니다.**
|
||||
|
||||
| 기준 | 잘못 받는 일 (나쁜 배치를 받음) | 잘못 돌려보내는 일 (괜찮은 배치를 거절) |
|
||||
| --- | --- | --- |
|
||||
| 느슨하게 (0.8) | 늘어남 — 격리함이 넘치고 사람이 골라냄 | 줄어듦 |
|
||||
| 엄하게 (0.05) | 줄어듦 | 늘어남 — 한 줄 실수로 배치 전체가 돌아감 |
|
||||
|
||||
77일차의 SLO 기준과 같습니다. 기준을 낮추면 알림(일)이 늘고, 높이면 문제를 놓칩니다. 그래서 기준은 **팀이 정하고 적어 두는** 값입니다.
|
||||
|
||||
**경계 · 딱 같으면.** AGENTS.md 3절은 "기준을 **넘으면** 거절, 같으면 통과"라고 씁니다. B-2는 10줄 중 1줄이 나빠 `invalid_ratio`가 정확히 0.1입니다. 기준 0.05면 거절하고, 0.1이면 받습니다. Lab 01 예제 3의 "40자는 통과"와 같은 종류의 경계입니다.
|
||||
|
||||
**③ FAQ를 비우고 ④를 ⑤보다 먼저 하는 까닭.** ②에서 B-1의 FAQ-102("회의실 예약")가 들어가면, B-2의 FAQ-201도 제목이 "회의실 예약"이라 `same_title`(Lab 01 과제 1)로 나빠집니다. 그러면 B-2의 나쁜 줄이 2줄(0.2)이 되어 ⑤가 거절됩니다. 또 ⑤가 B-2를 넣은 **뒤에** 엄한 기준을 보내면 9줄이 `exists_id`라 비율이 0.9가 됩니다. 실험을 하나씩 떼어 보려고 FAQ를 비우고, 아무것도 넣지 않는 ④를 먼저 합니다. 이것도 배울 점입니다: **앞 배치가 넣은 데이터가 다음 배치의 판정을 바꿉니다.**
|
||||
|
||||
**기대 결과.** 6/6. `quality`에 격리함 13줄.
|
||||
|
||||
<details><summary>▶ 1단계 · 6/6이 아니라면</summary>
|
||||
|
||||
예제 1의 요청문에 "기준은 본문 `max_invalid_ratio`, 없으면 0.3"과 "응답에 `max_invalid_ratio`"가 들어갔는지 봅니다. 0.3을 IF 안에 숫자로 박아 두면 ②와 ④가 바뀌지 않습니다. 이 예제는 에이전트 없이 하는 실험이지만, 실패하면 예제 1의 가져오기로 돌아가 고칩니다.
|
||||
</details>
|
||||
|
||||
### 실행 결과 · 기준을 바꾸면
|
||||
|
||||
수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다 (예제 2의 가져오기, LLM 포함).
|
||||
|
||||
이 예제는 **고치지 않습니다.** 같은 배치를 본문의 기준(`max_invalid_ratio`)만 바꿔 보냅니다.
|
||||
|
||||
실행한 명령:
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py test cases/lab02-gate.json -v
|
||||
uv run tools/flow.py quality
|
||||
```
|
||||
|
||||
출력 (결과 줄, `-v`의 응답 요약, `quality`):
|
||||
|
||||
```text
|
||||
POST http://localhost:5678/webhook/my-faq-import · 6 case(s) × 1
|
||||
✔ B-1 · 기본 기준 0.3 → 거절 · 0.1s
|
||||
✔ B-1 · 기준을 0.8로 풀면 → 받음, 넣는 것은 2줄 · 9.0s
|
||||
✔ FAQ만 처음으로 (B-1이 넣은 2줄을 비움, 격리함은 그대로) · 0.0s
|
||||
✔ B-2 · 기준 0.05 → 거절 · 0.1s
|
||||
✔ B-2 · 기준 0.1 · 나쁜 줄이 딱 10% → 받음 · 8.7s
|
||||
✔ 격리함이 13줄 (B-1 11 + B-2 2) · 0.0s
|
||||
Passed 6/6
|
||||
|
||||
← ① B-1 기준 0.3 HTTP 422 {"invalid_ratio": 0.7692, "max_invalid_ratio": 0.3}
|
||||
← ② B-1 기준 0.8 HTTP 200 {"total": 13, "imported": 2, "quarantined": 11, "score": 0.1538, "max_invalid_ratio": 0.8,
|
||||
"imported_ids": ["FAQ-101", "FAQ-102"], "codes": {…, "llm_mismatch": 1}}
|
||||
← ④ B-2 기준 0.05 HTTP 422 {"invalid_ratio": 0.1, "max_invalid_ratio": 0.05, "codes": {"pii": 1}}
|
||||
← ⑤ B-2 기준 0.1 HTTP 200 {"imported": 8, "quarantined": 2, "score": 0.8, "max_invalid_ratio": 0.1}
|
||||
|
||||
FAQ: 14 (FAQ-202, FAQ-201, FAQ-203, FAQ-204, FAQ-205, FAQ-207, FAQ-208, FAQ-206)
|
||||
quarantine: 13
|
||||
```
|
||||
|
||||
**왜 이렇게 나왔나 (실험마다)**
|
||||
|
||||
| 실험 | 기준 | 결과 | 까닭 |
|
||||
| --- | --- | --- | --- |
|
||||
| ① B-1 | 0.3 | 거절 | 0.7692 > 0.3 |
|
||||
| ② B-1 | 0.8 | **받음 · 넣음 2 · 격리 11** | 0.7692 ≤ 0.8. 규칙을 통과한 3줄 중 FAQ-113(토너 · 메일함)은 LLM이 막음 |
|
||||
| ④ B-2 | 0.05 | 거절 | 0.1 > 0.05 — 메일 주소 한 줄 때문에 9줄도 못 들어감 |
|
||||
| ⑤ B-2 | 0.1 | 받음 | 0.1 = 0.1, "넘으면 거절"이라 같으면 받음 |
|
||||
| ⑥ 격리함 | | 13줄 | ②의 11줄 + ⑤의 2줄 (④는 아무것도 남기지 않음) |
|
||||
|
||||
- **② 느슨한 기준의 대가:** B-1을 받았지만 넣은 것은 2줄, `score` 0.1538입니다. 격리함에 11줄이 쌓였고, 사람이 11줄을 하나씩 열어 봐야 합니다. 거절했다면 콘텐츠 담당자가 고침 목록(Lab 01 과제 2)을 받아 한 번에 고쳐 다시 보냈을 일입니다.
|
||||
- **④ 엄한 기준의 대가:** B-2는 9줄이 멀쩡한데 한 줄 때문에 모두 돌아갔습니다. 그날 직원들은 회의실 예약 · 출입증 FAQ를 못 봅니다.
|
||||
- **⑤ 경계:** 같은 B-2가 0.05에서는 거절, 0.1에서는 받힘. "넘으면"을 "이상이면"으로 쓰면 결과가 바뀝니다. Lab 01 예제 3과 같은 종류의 경계입니다.
|
||||
- **③을 넣은 까닭 (실험 설계):** ②가 넣은 FAQ-102("회의실 예약")를 그대로 두면 B-2의 FAQ-201이 `same_title`로 나빠져 비율이 0.2가 됩니다. ④를 ⑤보다 먼저 한 것도, ⑤가 B-2를 넣은 뒤에 ④를 보내면 9줄이 `exists_id`라 비율이 0.9가 되기 때문입니다 (수업 준비 때 실제로 겪음). **앞 배치가 넣은 데이터가 다음 배치의 판정을 바꿉니다.**
|
||||
- **`quality`의 FAQ 14:** 처음 6 + ⑤의 8줄. 테스트에 `after`가 없어 남아 있습니다 (다음 과제의 테스트가 비움).
|
||||
- 결론을 `QUALITY.md` 5절에: 강사는 **0.3을 그대로** 두었습니다 — "열에 셋까지는 골라 받고, 그보다 나쁘면 돌려보내 고치게 한다." 정답은 없고, 팀이 정해 적어 두는 것이 답입니다.
|
||||
|
||||
## 과제 1 · 품질 기록과 알림 (10분)
|
||||
|
||||
### 이 실습의 의도
|
||||
|
||||
**한 줄로:** 가져오기가 끝날 때마다 **품질 기록**(`/quality-log`) 한 줄을 남기고, 배치를 거절했거나 점수가 0.9 미만이면 **알림**을 보냅니다. 거절한 배치도 기록합니다.
|
||||
|
||||
> **생활 속 예 · 검수 일지와 사장님 호출.** 주방장은 상자마다 검수 일지에 "몇 개 받음 · 몇 개 반품 · 이유"를 적습니다. 일지가 있어야 "요즘 그 납품업체가 나빠졌다"를 말할 수 있습니다. 트럭째 반품한 날이나 절반 넘게 보류한 날은 사장님께 바로 전화합니다. 과제 1은 검수 일지(품질 기록)와 사장님 호출(알림)을 붙이는 실습입니다.
|
||||
|
||||
**상황.** 지금은 가져오기 결과가 부른 사람의 화면에만 남고 사라집니다. 팀장이 요청했습니다: "배치마다 점수를 남겨 주세요. 다음 주에 추세를 보겠습니다 (Lab 03 과제 1). 거절했거나 점수가 낮으면 바로 알려 주세요."
|
||||
|
||||
| 언제 | 무엇을 (AGENTS.md 3절) |
|
||||
| --- | --- |
|
||||
| 배치마다 (거절도) | `/quality-log` 한 줄 `{batch, dataset: "faq", total, valid, quarantined, rejected, score, codes}` |
|
||||
| 거절 | `/alerts` `reason: "batch_rejected"` |
|
||||
| `score` < 0.9 | `/alerts` `reason: "low_quality"` |
|
||||
|
||||
**끝나면 알게 되는 것.** 품질은 한 번 재고 끝나는 것이 아니라 **기록이 쌓여야** 관리된다는 것. 알림을 보내도 부른 쪽이 받는 결과는 바뀌지 않아야 한다는 것 (곁가지).
|
||||
|
||||
**적을 곳.** `my-work/QUALITY.md` 7절.
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py test cases/lab02-challenge.json -v # 고치기 전 (예제 2의 가져오기)
|
||||
```
|
||||
|
||||
### 이해하기
|
||||
|
||||
**두 갈래 모두 기록.** 거절 갈래(422)와 받는 갈래(200) **둘 다** 기록을 남깁니다. 거절을 기록하지 않으면 품질 보고서에서 "가장 나쁜 배치"가 사라집니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
G{"Batch ok?"} -->|거절| L1["Log rejected<br/>rejected: true · score 0"] --> A1["Alert rejected<br/>batch_rejected"] --> R1(["422"])
|
||||
G -->|받음| S["… Summarize"] --> L2["Log quality"] --> Q{"score < 0.9?"}
|
||||
Q -->|예| A2["Alert low score<br/>low_quality"] --> R2["Result again"]
|
||||
Q -->|아니오| R2 --> O(["200 · 같은 결과"])
|
||||
```
|
||||
|
||||
**`Result again`이 필요한 까닭.** `Log quality` · `Alert low score`는 HTTP 노드라 그 출력은 **데이터 API의 응답**(`{"stored": 1}`)입니다. 그대로 응답하면 부른 쪽은 가져오기 결과를 잃습니다. 그래서 끝에서 `Summarize`의 결과를 다시 꺼냅니다 (Lab 03 예제 2에서도 씀).
|
||||
|
||||
**테스트 케이스.**
|
||||
|
||||
| 케이스 | 기대 |
|
||||
| --- | --- |
|
||||
| 거절도 품질 기록 | B-1 → 422 |
|
||||
| 배치 2 가져오기 | `score` 0.8 |
|
||||
| 품질 기록 두 줄 (거절 · 0.8) | 첫 줄 `rejected: true`, 둘째 줄 `score` 0.8 · `valid` 8 · `quarantined` 2 |
|
||||
| 알림 두 건 | `batch_rejected` |
|
||||
| 알림에 낮은 점수 | `low_quality` |
|
||||
|
||||
**기대 결과.** 고치기 전 2/5 (가져오기 두 케이스는 원래 됨), 고친 뒤 5/5, `lab02` 6/6 그대로.
|
||||
|
||||
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||
|
||||
요청문에 꼭 들어갈 것: **① 거절도 기록 ② 알림 이유 ③ 낮은 점수의 기준 ④ 응답**. 아래 빈칸 4개를 채웁니다.
|
||||
|
||||
```text
|
||||
my-work/faq-import.json 에 품질 기록과 알림을 더해 줘.
|
||||
배치마다 POST http://data-api:8800/quality-log 한 줄 {batch, dataset: "faq", total, valid: imported, quarantined, rejected, score, codes} — 거절된 배치도 ____① 으로.
|
||||
거절이면 POST /alerts {workflow: "faq-import", severity: "high", reason: "____②", batch, message}.
|
||||
받은 배치의 score 가 ____③ 미만이면 reason "low_quality" 알림.
|
||||
알림을 보내도 응답은 ____④ (Summarize 의 결과를 다시). 기대 결과 cases/lab02-challenge.json, cases/lab02.json 도.
|
||||
```
|
||||
|
||||
빈칸 힌트:
|
||||
|
||||
- ① 거절된 배치의 기록에서 `rejected`와 `score`는 무엇인가요? (3절 `score` 줄)
|
||||
- ② 3절 "가져오기 게이트" 표의 마지막 줄. 테스트가 이 글자를 찾습니다.
|
||||
- ③ 같은 줄의 숫자.
|
||||
- ④ 위 "Result again이 필요한 까닭".
|
||||
</details>
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py push my-work/faq-import.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 quality
|
||||
```
|
||||
|
||||
### 실행 결과 · 품질 기록과 알림
|
||||
|
||||
수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다.
|
||||
|
||||
#### 고치기 전 · 예제 2의 가져오기
|
||||
|
||||
실행한 명령:
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py test cases/lab02-challenge.json -v
|
||||
```
|
||||
|
||||
출력 (결과 줄만):
|
||||
|
||||
```text
|
||||
✔ 거절도 품질 기록 · 0.1s
|
||||
✔ 배치 2 가져오기 · 8.7s
|
||||
✘ 품질 기록 두 줄 (거절 · 0.8) · 0.0s · count = 0 (expected 2); items.0.rejected = null (expected true); items.1.score = null (expected 0.8); items.1.valid = null (expected 8); items.1.quarantined = null (expected 2)
|
||||
✘ 알림 두 건 (거절 · 낮은 점수) · 0.0s · count = 0 (expected 2); alerts has none of ['batch_rejected']
|
||||
✘ 알림에 낮은 점수 · 0.0s · alerts has none of ['low_quality']
|
||||
Passed 2/5
|
||||
```
|
||||
|
||||
**왜 이렇게 나왔나 (고치기 전):** 앞 두 케이스는 가져오기 자체(422 · `score` 0.8)라 예제 2로도 됩니다. 뒤 셋은 `/quality-log/list` · `/alerts/list`를 보는데, 예제 2는 아무것도 남기지 않으니 `count` 0. 예상한 결과.
|
||||
|
||||
#### 고친 뒤
|
||||
|
||||
실행한 명령:
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py push my-work/faq-import.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 quality
|
||||
```
|
||||
|
||||
출력 (결과 줄과 `quality`):
|
||||
|
||||
```text
|
||||
✔ 거절도 품질 기록 · 1.3s
|
||||
✔ 배치 2 가져오기 · 9.4s
|
||||
✔ 품질 기록 두 줄 (거절 · 0.8) · 0.0s
|
||||
✔ 알림 두 건 (거절 · 낮은 점수) · 0.0s
|
||||
✔ 알림에 낮은 점수 · 0.0s
|
||||
Passed 5/5
|
||||
|
||||
(lab02) Passed 6/6
|
||||
|
||||
FAQ: 6 (nothing added)
|
||||
quarantine: 3
|
||||
quality log: 4
|
||||
alerts: 4
|
||||
```
|
||||
|
||||
**고친 뒤 이렇게 나온 까닭**
|
||||
|
||||
| 배치 | 응답 | 품질 기록 | 알림 |
|
||||
| --- | --- | --- | --- |
|
||||
| B-1 | 422 (그대로) | `rejected: true` · `score` 0 | `batch_rejected` |
|
||||
| B-2 | 200 · `score` 0.8 (그대로) | `valid` 8 · `quarantined` 2 · 0.8 | `low_quality` (0.8 < 0.9) |
|
||||
|
||||
- **응답은 바뀌지 않았습니다:** 기록 · 알림은 곁가지이고, 끝의 `Result again`이 `Summarize`의 결과를 다시 꺼내 응답합니다. 그래서 `lab02` 6/6이 그대로입니다.
|
||||
- **`quality`의 기록 4 · 알림 4 · 격리 3:** 뒤이어 돌린 `lab02`가 남긴 것입니다. `lab02`의 `before`가 기록 · 알림을 비운 뒤 B-1(거절) · B-2(0.8) · B-2 다시(0.8) · B-3(0.8333)을 보내 기록 4줄, 네 배치 모두 거절이거나 0.9 미만이라 알림 4건. 격리함 3줄은 둘째 B-2의 FAQ-209 · 210과 B-3의 FAQ-305입니다. 테스트마다 `before`가 무엇을 비우는지 읽어 보면 숫자가 맞습니다.
|
||||
- **B-2가 알림을 받는 까닭:** 10줄 중 2줄을 격리하면 0.8. 열 줄에 한 줄 넘게 막혔다는 것은 "사람이 격리함을 볼 일이 생겼다"는 뜻이라 기준 0.9를 썼습니다.
|
||||
|
||||
## 과제 2 · LLM을 몇 번 부르나 · 늘 같은가 (12분)
|
||||
|
||||
### 이 실습의 의도
|
||||
|
||||
**한 줄로:** 가져오기 결과에 LLM에게 보낸 줄 수(`llm_calls`)를 더해 "규칙을 통과한 줄에만 묻는다"를 **숫자로** 확인하고, 같은 테스트를 세 번 돌려 LLM 판정이 **늘 같은지** 봅니다.
|
||||
|
||||
> **생활 속 예 · 맛보기 횟수와 맛보는 사람.** 사장님이 묻습니다. "오늘 맛보기를 몇 번 했지? 상한 것까지 맛본 건 아니지?" 그리고 "어제 맛본 사람과 오늘 맛본 사람의 판정이 같나?" 맛보기(LLM)는 비싸고, 사람마다 · 날마다 다를 수 있기 때문입니다. 과제 2는 맛보기 횟수를 일지에 적고, 같은 상자를 세 번 맛보게 하는 실습입니다.
|
||||
|
||||
**상황.** 예제 2에서 B-2 가져오기가 1초에서 10초 안팎으로 늘었습니다. 팀장이 묻습니다: "LLM을 줄마다 부르는 거죠? 정말 통과한 줄에만? 그리고 LLM이 오늘은 FAQ-210을 막고 내일은 넣으면 곤란합니다."
|
||||
|
||||
| 칸 | 정의 (AGENTS.md 3절) |
|
||||
| --- | --- |
|
||||
| `llm_calls` | LLM 검사에 보낸 줄 수 (= 규칙을 통과한 줄 수) |
|
||||
| 거절된 배치 | 422 본문에도 `llm_calls: 0` |
|
||||
|
||||
**끝나면 알게 되는 것.** LLM 비용은 **부른 횟수**로 설명할 수 있다는 것 (75일차). temperature 0이어도 "늘 같은가"는 **재 봐야** 안다는 것. 같은 배치를 다시 보내면 무엇이 달라지는지(이미 넣은 FAQ).
|
||||
|
||||
**적을 곳.** `my-work/QUALITY.md` 6절 (`llm_calls` · 세 번 돌려 같았나 · 다시 보내면).
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py test cases/lab02-challenge2.json -v # 고치기 전 (과제 1까지의 가져오기)
|
||||
```
|
||||
|
||||
### 이해하기
|
||||
|
||||
**호출 수로 비용을 말한다.** 줄 하나에 LLM 한 번이므로, `llm_calls`는 곧 이 배치의 LLM 비용입니다. B-2(10줄)는 9번, B-3(6줄)은 5번, 거절된 B-1(13줄)은 0번. "규칙을 앞에 두면 LLM 비용이 줄어든다"를 말이 아니라 숫자로 보입니다.
|
||||
|
||||
| 배치 | 줄 | 규칙에 걸림 | `llm_calls` | LLM 없이 모든 줄을 물었다면 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| B-1 (거절) | 13 | 10 | 0 | 13 |
|
||||
| B-2 | 10 | 1 | 9 | 10 |
|
||||
| B-3 | 6 | 1 | 5 | 6 |
|
||||
|
||||
**늘 같은가 — 세 번 돌리기.** `test`를 세 번 돌립니다. 테스트 파일의 `before`가 매번 FAQ · 격리함을 비우므로, 세 번 모두 **같은 처음 상태**에서 같은 배치를 보냅니다. 세 번 모두 FAQ-210이 막히고 `llm_calls` · `imported`가 같으면 "늘 같다"고 말할 수 있습니다.
|
||||
|
||||
**`--repeat`를 쓰면 안 되는 까닭.** `--repeat 2`는 `before`를 한 번만 하고 같은 배치를 **연달아** 보냅니다. 두 번째 B-2는 첫 번째가 넣은 FAQ-201~208과 id가 같아 모두 `exists_id`(Lab 01 과제 1) → 나쁜 줄 9/10 → **거절**됩니다. 판정이 흔들린 것이 아니라 **데이터가 바뀐** 것입니다. 마지막 명령(`--no-before --repeat 2`)으로 이것도 직접 봅니다.
|
||||
|
||||
**기대 결과.** 고치기 전 0/3, 고친 뒤 3/3 × 세 번, `lab02` · `lab02-challenge`도 그대로. 마지막 `--no-before`는 거절이 나오는 것이 정상입니다.
|
||||
|
||||
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||
|
||||
요청문에 꼭 들어갈 것: **① 무엇을 세나 ② 거절일 때 ③ 이 칸의 뜻 ④ 확인 방법**. 아래 빈칸 4개를 채웁니다.
|
||||
|
||||
```text
|
||||
my-work/faq-import.json 의 결과에 llm_calls 를 더해 줘.
|
||||
llm_calls = LLM 검사에 보낸 줄 수 (Only passed 의 줄 ____①). 거절된 배치(422)의 본문에도 llm_calls ____②.
|
||||
LLM 은 규칙을 ____③ 줄에만 부른다는 것을 숫자로 보이게 하는 칸이다. 다른 칸은 그대로.
|
||||
기대 결과 cases/lab02-challenge2.json 을 ____④ 돌려 늘 같은지, lab02 · lab02-challenge 도.
|
||||
```
|
||||
|
||||
빈칸 힌트:
|
||||
|
||||
- ① `Only passed`가 넘긴 것의 무엇인가요?
|
||||
- ② 거절된 배치는 LLM 앞에서 끝납니다.
|
||||
- ③ 3절 "가져오기 게이트" 표의 LLM 검사 줄.
|
||||
- ④ "늘 같은가"를 보려면 테스트를 몇 번, 어떻게 돌리나요? (`--repeat`가 아닌 까닭은 위에)
|
||||
</details>
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py push my-work/faq-import.json
|
||||
uv run tools/flow.py test cases/lab02-challenge2.json -v
|
||||
uv run tools/flow.py test cases/lab02-challenge2.json
|
||||
uv run tools/flow.py test cases/lab02-challenge2.json
|
||||
uv run tools/flow.py test cases/lab02-challenge2.json --no-before --repeat 2 # 같은 배치를 연달아 다시 보내면
|
||||
uv run tools/flow.py test cases/lab02.json
|
||||
uv run tools/flow.py test cases/lab02-challenge.json
|
||||
```
|
||||
|
||||
### 실행 결과 · LLM 호출 수 · 늘 같은가
|
||||
|
||||
수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다.
|
||||
|
||||
#### 고치기 전 · 과제 1까지의 가져오기
|
||||
|
||||
실행한 명령:
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py test cases/lab02-challenge2.json -v
|
||||
```
|
||||
|
||||
출력 (결과 줄만):
|
||||
|
||||
```text
|
||||
✘ 거절된 배치는 LLM을 부르지 않음 · 0.1s · llm_calls = null (expected 0)
|
||||
✘ B-2 · 규칙을 통과한 9줄만 LLM에 · 8.6s · llm_calls = null (expected 9)
|
||||
✘ B-3 · 5줄만 LLM에 · 4.3s · llm_calls = null (expected 5)
|
||||
Passed 0/3
|
||||
```
|
||||
|
||||
**왜 이렇게 나왔나 (고치기 전):** 가져오기 자체는 되지만(시간 8.6 · 4.3초) `llm_calls` 칸이 없습니다. 몇 번 불렀는지는 시간으로 짐작할 뿐입니다. 예상한 결과.
|
||||
|
||||
#### 고친 뒤 · 세 번 돌리기
|
||||
|
||||
실행한 명령:
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py push my-work/faq-import.json
|
||||
uv run tools/flow.py test cases/lab02-challenge2.json -v
|
||||
uv run tools/flow.py test cases/lab02-challenge2.json
|
||||
uv run tools/flow.py test cases/lab02-challenge2.json
|
||||
```
|
||||
|
||||
출력 (세 번의 결과 줄과 첫 번째의 응답):
|
||||
|
||||
```text
|
||||
✔ 거절된 배치는 LLM을 부르지 않음 · 1.1s ✔ · 0.1s ✔ · 0.1s
|
||||
✔ B-2 · 규칙을 통과한 9줄만 LLM에 · 9.4s ✔ · 8.1s ✔ · 7.6s
|
||||
✔ B-3 · 5줄만 LLM에 · 4.1s ✔ · 4.4s ✔ · 4.0s
|
||||
Passed 3/3 Passed 3/3 Passed 3/3
|
||||
|
||||
← B-1 HTTP 422 {"invalid_ratio": 0.7692, "max_invalid_ratio": 0.3, …, "llm_calls": 0}
|
||||
← B-2 HTTP 200 {"imported": 8, "quarantined": 2, "quarantined_ids": ["FAQ-209", "FAQ-210"], "llm_calls": 9}
|
||||
← B-3 HTTP 200 {"imported": 5, "quarantined": 1, "quarantined_ids": ["FAQ-305"], "llm_calls": 5}
|
||||
```
|
||||
|
||||
**고친 뒤 이렇게 나온 까닭**
|
||||
|
||||
| 배치 | 줄 | `llm_calls` | 세 번 모두 | 시간 (세 번) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| B-1 (거절) | 13 | 0 | 같음 | 0.1초 안팎 |
|
||||
| B-2 | 10 | 9 | FAQ-210만 막음 · 넣음 8 | 9.4 · 8.1 · 7.6초 |
|
||||
| B-3 | 6 | 5 | 넣음 5 | 4.1 · 4.4 · 4.0초 |
|
||||
|
||||
- **호출 수 = 규칙을 통과한 줄 수:** 9 = 10 − 1(FAQ-209), 5 = 6 − 1(FAQ-305). 거절된 B-1은 0. "규칙을 앞에 둔다"가 이제 숫자로 보입니다. 75일차처럼 호출당 비용을 알면 배치의 LLM 비용을 바로 셀 수 있습니다.
|
||||
- **세 번 모두 같은 판정:** temperature 0에서 이 13줄은 세 번 모두 같은 판정이었습니다. 다만 "세 번 같았다"는 "늘 같다"의 증거일 뿐 보증은 아닙니다. 판정이 갈리는 줄이 생기면 그 줄은 사람이 보도록 격리하는 쪽이 안전합니다 (읽을 수 없으면 `false`와 같은 생각).
|
||||
- **시간은 매번 다릅니다:** 같은 9번의 호출이 7.6~9.4초. 시간이 아니라 `llm_calls`로 비용을 말하는 까닭입니다.
|
||||
|
||||
#### 같은 배치를 연달아 다시 보내면 (`--no-before --repeat 2`)
|
||||
|
||||
실행한 명령:
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py test cases/lab02-challenge2.json --no-before --repeat 2
|
||||
```
|
||||
|
||||
출력:
|
||||
|
||||
```text
|
||||
POST http://localhost:5678/webhook/my-faq-import · 3 case(s) × 2
|
||||
✔ 거절된 배치는 LLM을 부르지 않음 #1 · 0.1s
|
||||
✔ 거절된 배치는 LLM을 부르지 않음 #2 · 0.1s
|
||||
✘ B-2 · 규칙을 통과한 9줄만 LLM에 #1 · 0.1s · status 422 (expected 200); llm_calls = 0 (expected 9); imported = null (expected 8); quarantined_ids has none of ['FAQ-210']
|
||||
✘ B-2 · 규칙을 통과한 9줄만 LLM에 #2 · 0.1s · status 422 (expected 200); llm_calls = 0 (expected 9); imported = null (expected 8); quarantined_ids has none of ['FAQ-210']
|
||||
✘ B-3 · 5줄만 LLM에 #1 · 0.1s · status 422 (expected 200); llm_calls = 0 (expected 5); imported = null (expected 5)
|
||||
✘ B-3 · 5줄만 LLM에 #2 · 0.1s · status 422 (expected 200); llm_calls = 0 (expected 5); imported = null (expected 5)
|
||||
Passed 2/6
|
||||
```
|
||||
|
||||
**왜 이렇게 나왔나:** `--no-before`라 바로 앞 실행이 넣은 FAQ-201~208 · FAQ-301 · 302 · 303 · 304 · 306이 그대로 있습니다. B-2를 다시 보내면 FAQ-201~208의 8줄이 `exists_id`(Lab 01 과제 1), FAQ-209는 `pii` → 나쁜 줄 9/10 = 0.9로 **거절**됩니다 (FAQ-210은 지난번에 격리돼 FAQ에 없으니 규칙은 통과). B-3도 5줄이 `exists_id` + FAQ-305 → 6/6으로 거절. 거절이니 LLM은 한 번도 부르지 않습니다(`llm_calls` 0). LLM 판정이 흔들린 것이 아니라 **데이터가 바뀐** 것입니다. 그래서 "늘 같은가"는 `--repeat`가 아니라 처음 상태(`before`)에서 세 번 돌려 봅니다. 이 ✘는 **예상한 결과**이고, 같은 배치를 두 번 보내도 FAQ가 두 벌 생기지 않는다는 확인이기도 합니다.
|
||||
|
||||
뒤이어 `lab02` 6/6 · `lab02-challenge` 5/5 그대로 (`answers/16`).
|
||||
|
||||
## 막혔을 때
|
||||
|
||||
| 증상 | 할 일 |
|
||||
| --- | --- |
|
||||
| B-1이 422가 아님 | `Batch ok?`가 `invalid / total <= 기준`인지 (`<`가 아니라 `<=`), 기준 기본값 0.3 |
|
||||
| 422 본문에 `invalid_ratio`가 없음 | `Reject batch`의 응답에 `$('Run check')`의 값으로 |
|
||||
| 격리 수가 틀림 | 넣기 · 격리를 IF로 갈랐다가 합쳤는지 → HTTP 노드 하나로 (AGENTS.md 5절) |
|
||||
| `security.desk`가 FAQ에 | `Store row`의 주소 표현식이 `ok`를 보는지 |
|
||||
| FAQ-210이 들어감 | `Judge`에 제목과 답이 다 가는지, `Decide`가 `match === true`일 때만 `ok` |
|
||||
| `Decide`에서 줄이 어긋남 | `Judge`의 i번째는 `Only passed`의 i번째 (`Split rows`가 아님) |
|
||||
| 품질 기록에 거절이 없음 | 거절 갈래에도 `Log rejected` |
|
||||
| 응답이 `{"stored": 1}` | 끝에 `Result again` (Summarize를 다시) |
|
||||
| `llm_calls`가 `null` | 거절 응답과 받는 응답 **둘 다**에 |
|
||||
| 두 번째 실행이 거절 | `--repeat`로 같은 배치를 연달아 보냄 → `test`를 따로 세 번 |
|
||||
| 계속 안 됨 | `cp reference/lab02-example2-import.json my-work/faq-import.json` 후 push (예제 2까지) |
|
||||
|
||||
## 확인
|
||||
|
||||
- [ ] 예제 1: `cases/lab02.json` 5/6 (고치기 전 기준에 0/6), `quality`로 격리함 확인.
|
||||
- [ ] 예제 2: `cases/lab02.json` 6/6.
|
||||
- [ ] 예제 3: `cases/lab02-gate.json` 6/6, `QUALITY.md` 5절과 우리 팀의 기준.
|
||||
- [ ] 과제 1: `cases/lab02-challenge.json` 5/5, `lab02`도 통과, `QUALITY.md` 7절.
|
||||
- [ ] 과제 2: `cases/lab02-challenge2.json` 3/3 × 세 번, `QUALITY.md` 6절.
|
||||
- [ ] `my-work/PROMPTS.md`.
|
||||
@@ -0,0 +1,778 @@
|
||||
# Lab 03 · 품질 관리 · 스키마로 넓히고, 고쳐서 다시, 추세로 보기
|
||||
|
||||
- 관련 강의: [3단계 · 품질 관리](../../Lecture/78/03-STAGE3-QUALITY-MANAGEMENT.md)
|
||||
- 소요 시간: 50분 (6교시 한 시간) · 예제 1 14분 · 예제 2 8분 · 예제 3 6분 · 과제 1 12분 · 과제 2 10분
|
||||
- 준비: Lab 02의 `my-faq-import`(과제 2까지)가 게시돼 있음
|
||||
- 목표: FAQ에만 맞춘 규칙을 **스키마**(규칙을 적은 데이터)로 넓혀 직원 CSV도 같은 워크플로우로 검사한다. 고친 줄을 다시 넣으면 격리함에서 빼고, **새 스키마**를 더해도 워크플로우를 고치지 않는 것을 확인한다. 품질 기록으로 **보고서**(평균 · 가장 나쁜 배치 · 흔한 문제 · 추세)를 만들고, 데이터셋마다 나눠 본다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
E1["예제 1<br/>스키마 검사"] --> E2["예제 2<br/>고쳐서 다시"] --> E3["예제 3<br/>스키마만 바꾸면"] --> T1["과제 1<br/>품질 보고서"] --> T2["과제 2<br/>데이터셋별 품질"]
|
||||
```
|
||||
|
||||
> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다. 연습이 끝나면 `uv run tools/flow.py quality --reset`.
|
||||
|
||||
## 예제 1 · 스키마 검사 (14분)
|
||||
|
||||
### 이 실습의 의도
|
||||
|
||||
**한 줄로:** 인사팀이 새 직원 9명을 CSV로 보냈습니다. FAQ 검사로는 읽지도 못합니다. 규칙을 코드에 쓰지 않고 데이터 API의 **스키마**(`/schemas/employee`)에서 읽어 판정하는 검사(`my-check`)를 만들어, 같은 워크플로우로 FAQ도 직원도 검사합니다.
|
||||
|
||||
> **생활 속 예 · 품목마다 다른 검수 카드.** 채소 · 고기 · 생선은 보는 것이 다릅니다 (채소는 무른 곳, 고기는 온도와 라벨, 생선은 눈 색). 품목마다 주방장을 따로 두지 않고, 품목마다 **검수 카드**를 만들어 두면 한 사람이 카드를 바꿔 들고 무엇이든 검수합니다. 예제 1은 검수 카드(스키마)를 읽어 판정하는 검사를 만드는 실습입니다.
|
||||
|
||||
**상황.** `requests/employees-new.json`은 `{"schema": "employee", "batch": "HR-1", "csv": "name,dept,email,phone,joined\n…"}` 꼴입니다. Lab 01의 `my-faq-check`에 보내면 `items`가 없어 400입니다. FAQ용 규칙을 복사해 직원용 Code를 하나 더 만들 수도 있지만, 그러면 데이터가 늘 때마다 워크플로우가 늘어납니다.
|
||||
|
||||
| 스키마의 칸 (`/schemas/employee`) | 뜻 |
|
||||
| --- | --- |
|
||||
| `key` | 배치 안에서 겹치면 안 되는 칸 (`email`) |
|
||||
| `fields.<이름>.type` | `string` · `enum` · `email` · `phone` · `date` · `pattern` · `list` |
|
||||
| `required` · `min` · `max` · `values` · `pattern` | 타입마다 확인할 것 |
|
||||
|
||||
**순서.** FAQ 검사에 직원 CSV를 먼저 보내 봄 → 요청문 → push → 4/4.
|
||||
|
||||
**끝나면 알게 되는 것.** 규칙을 **데이터로** 두면(스키마) 검사 하나로 여러 데이터를 볼 수 있다는 것. 스키마로 되는 규칙(형식 · 목록 · 필수)과 안 되는 규칙(개인정보 · 이미 있는 데이터 · 뜻)의 경계.
|
||||
|
||||
**적을 곳.** `my-work/QUALITY.md` 8절 표.
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py call my-faq-check requests/employees-new.json # 고치기 전 · FAQ 검사에 직원 CSV
|
||||
uv run tools/flow.py test cases/lab03.json -v # 고치기 전 · my-check가 아직 없음
|
||||
```
|
||||
|
||||
### 이해하기
|
||||
|
||||
**지금 무엇이 문제인가.** Lab 01의 검사는 FAQ의 칸 이름(`title` · `answer`)과 규칙이 Code 안에 박혀 있습니다. 직원 데이터는 칸도 규칙도 다릅니다. 고치는 일은 "규칙을 Code 밖(스키마)으로 꺼내고, Code는 스키마를 읽어 타입마다 같은 방식으로 판정하게" 하는 것입니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
W(["schema · batch ·<br/>items 또는 csv"]) --> S["Get schema<br/>GET /schemas/<schema>"] --> K{"fields가 있나?"}
|
||||
K -->|아니오| R(["400 unknown schema"])
|
||||
K -->|예| C["Check rows<br/>csv → 줄로<br/>fields를 읽어 타입마다"] --> O(["rows · codes"])
|
||||
```
|
||||
|
||||
**스키마(schema)란.** 데이터의 모양을 적은 데이터입니다: 어떤 칸이 있고, 칸마다 타입은 무엇이고, 꼭 있어야 하나. 업계에서는 JSON Schema · 데이터베이스의 테이블 정의 · Great Expectations의 기대값 등이 같은 일을 합니다. 오늘 스키마는 AGENTS.md 3절 "스키마" 표의 작은 판입니다.
|
||||
|
||||
| 직원 CSV의 줄 | 스키마의 규칙 | 코드 |
|
||||
| --- | --- | --- |
|
||||
| `minsu.kang@example` | `email` 형식 | `bad_format:email` |
|
||||
| `마케팅팀` | `enum` — 여섯 팀 중 하나 | `not_allowed:dept` |
|
||||
| `2026-13-01` | `date` — 실제 있는 날짜 | `bad_format:joined` |
|
||||
| 이름이 빈 줄 | `required` | `missing:name` |
|
||||
| 같은 메일이 두 번 | `key` | `duplicate_in_batch` |
|
||||
|
||||
**CSV.** 첫 줄이 열 이름이고 쉼표로 나눕니다 (오늘은 따옴표 없음). `row`는 사람이 세는 번호(1부터, 열 이름 줄 제외)입니다.
|
||||
|
||||
**테스트 케이스.**
|
||||
|
||||
| 케이스 | 기대 |
|
||||
| --- | --- |
|
||||
| 직원 CSV · 9줄 중 4줄 통과 | `schema` employee, 7째 줄(사무실 번호 `02-123-4567`)은 통과 |
|
||||
| 직원 CSV · 줄마다 이유 | 위 표의 코드들 |
|
||||
| 같은 워크플로우로 FAQ 스키마 | B-3을 `schema: faq`로 → 6줄 중 5줄, 5째 줄 `too_short:answer` |
|
||||
| 모르는 스키마 | 400 |
|
||||
|
||||
**기대 결과.** 고치기 전: FAQ 검사는 400, `lab03` 0/4 (웹훅이 없음). 고친 뒤 4/4.
|
||||
|
||||
<details><summary>▶ 1단계 · 타입마다 한 번씩</summary>
|
||||
|
||||
`for (const [name, f] of Object.entries(schema.fields))`로 칸을 돌며, 비었으면 `required`만 보고 다음 칸으로, 아니면 `f.type`마다 확인합니다. 칸 이름(`email`)을 Code에 쓰지 않는 것이 핵심입니다.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 2단계 · `2026-13-01`이 통과한다면</summary>
|
||||
|
||||
`YYYY-MM-DD` 꼴만 보면 13월도 통과합니다. `new Date(v + 'T00:00:00Z').toISOString().slice(0, 10) === v`처럼 실제 있는 날짜인지까지 봅니다 (예제 3의 `2026-02-30`도).
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||
|
||||
요청문에 꼭 들어갈 것: **① 모르는 스키마 ② CSV 읽기 ③ 규칙의 출처 ④ 줄 번호 · 결과 모양 · 기대 결과**. 아래 빈칸 4개를 채웁니다.
|
||||
|
||||
```text
|
||||
my-work/check.json 을 새로 만들어 줘. 웹훅 POST my-check, 이름 "78 Schema check (my)".
|
||||
Get schema: HTTP GET http://data-api:8800/schemas/<본문 schema> (neverError). 스키마가 없으면(fields 가 없음) HTTP ____① {error: "unknown schema", schema}.
|
||||
Check rows(Code): 본문 csv 가 있으면 첫 줄을 ____②으로 쉼표로 나눠 줄로 바꾸고, items 가 있으면 그대로.
|
||||
필드 규칙은 코드에 쓰지 말고 스키마의 ____③ 를 읽어 AGENTS.md 3절 타입 표대로 (required · string · enum · email · phone · date · pattern · list), 키는 배치 안 중복.
|
||||
결과 {schema, batch, total, valid, invalid, rows: [{row (____④), key, ok, errors}], codes}. 기대 결과 cases/lab03.json.
|
||||
```
|
||||
|
||||
빈칸 힌트:
|
||||
|
||||
- ① 테스트의 "모르는 스키마" 케이스가 기대하는 상태.
|
||||
- ② CSV의 첫 줄은 무엇인가요?
|
||||
- ③ 규칙을 Code에 쓰지 않으면, 스키마 JSON의 어느 칸을 읽나요?
|
||||
- ④ AGENTS.md 3절 스키마 표 아래 줄.
|
||||
</details>
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py push my-work/check.json
|
||||
uv run tools/flow.py test cases/lab03.json -v
|
||||
```
|
||||
|
||||
### 실행 결과 · 스키마 검사
|
||||
|
||||
수업 준비 때(11/01) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. 판정은 스키마대로라 누구에게나 같습니다.
|
||||
|
||||
#### 고치기 전 · FAQ 검사에 직원 CSV, 그리고 `my-check`가 없을 때
|
||||
|
||||
실행한 명령:
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py call my-faq-check requests/employees-new.json
|
||||
uv run tools/flow.py test cases/lab03.json -v
|
||||
```
|
||||
|
||||
출력:
|
||||
|
||||
```text
|
||||
HTTP 400 · 0.0s
|
||||
{"error": "items must be a list"}
|
||||
|
||||
POST http://localhost:5678/webhook/my-check · 4 case(s) × 1
|
||||
✘ 직원 CSV · 9줄 중 4줄 통과 · 0.0s · status 404 (expected 200); schema = null (expected employee); total = null (expected 9); valid = null (expected 4); rows.6.ok = null (expected true)
|
||||
✘ 직원 CSV · 줄마다 이유 · 0.0s · status 404 (expected 200); rows.2.errors has none of ['bad_format:email']; …
|
||||
✘ 같은 워크플로우로 FAQ 스키마 · 0.0s · status 404 (expected 200); schema = null (expected faq); …
|
||||
✘ 모르는 스키마 → 400 · 0.0s · status 404 (expected 400)
|
||||
Passed 0/4
|
||||
```
|
||||
|
||||
**왜 이렇게 나왔나 (고치기 전)**
|
||||
|
||||
- **FAQ 검사는 400:** 직원 배치에는 `items`가 없고 `csv` 글자가 있습니다. Lab 01의 검사는 `items` 목록만 압니다. 칸 이름(`title` · `answer`)도 규칙도 FAQ에 박혀 있어, 설령 줄로 바꿔 넣어도 모든 줄이 `title_missing`일 것입니다 (예제 3에서 접수 건으로 확인).
|
||||
- **`lab03` 404:** `my-check` 웹훅이 아직 없습니다. 0/4는 예상한 결과.
|
||||
|
||||
#### 고친 뒤 · 내 `my-check`
|
||||
|
||||
실행한 명령:
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py push my-work/check.json
|
||||
uv run tools/flow.py test cases/lab03.json -v
|
||||
```
|
||||
|
||||
출력 (결과 줄과 `-v`의 직원 응답을 줄마다 한 줄로):
|
||||
|
||||
```text
|
||||
✔ 직원 CSV · 9줄 중 4줄 통과 · 0.9s
|
||||
✔ 직원 CSV · 줄마다 이유 · 0.0s
|
||||
✔ 같은 워크플로우로 FAQ 스키마 · 0.1s
|
||||
✔ 모르는 스키마 → 400 · 0.0s
|
||||
Passed 4/4
|
||||
|
||||
{ "schema": "employee", "batch": "HR-1", "total": 9, "valid": 4, "invalid": 5,
|
||||
"rows": [ 1 sehoon.oh@example.com ok · 2 haneul.yoon@example.com ok · 3 minsu.kang@example bad_format:email ·
|
||||
4 jia.seo@example.com not_allowed:dept · 5 doyun.lim@example.com bad_format:joined ·
|
||||
6 noname@example.com missing:name · 7 sua.bae@example.com ok · 8 sua.bae@example.com duplicate_in_batch ·
|
||||
9 daeun.jung@example.com ok ],
|
||||
"codes": { "bad_format:email": 1, "not_allowed:dept": 1, "bad_format:joined": 1, "missing:name": 1, "duplicate_in_batch": 1 } }
|
||||
FAQ 스키마로 B-3: 6줄 중 5줄 · 5 FAQ-305 too_short:answer
|
||||
```
|
||||
|
||||
**고친 뒤 이렇게 나온 까닭**
|
||||
|
||||
| 줄 | 값 | 판정 | 스키마의 어느 규칙 |
|
||||
| --- | --- | --- | --- |
|
||||
| 2 | `phone` 빈칸 | 통과 | `phone`은 `required: false` — 비었으면 형식을 보지 않음 |
|
||||
| 3 | `minsu.kang@example` | `bad_format:email` | `email` — `@` 뒤에 점이 있는 도메인 |
|
||||
| 4 | `마케팅팀` | `not_allowed:dept` | `enum` 여섯 팀 |
|
||||
| 5 | `2026-13-01` | `bad_format:joined` | `date` — 13월은 없음 |
|
||||
| 6 | 이름 빈칸 | `missing:name` | `required` |
|
||||
| 7 | `02-123-4567` | 통과 | `phone` 꼴 `0\d{1,2}-\d{3,4}-\d{4}` (사무실 번호도 맞음) |
|
||||
| 8 | 7째와 같은 메일 | `duplicate_in_batch` | `key: email` |
|
||||
|
||||
- **같은 워크플로우, 다른 스키마:** B-3을 `schema: faq`로 보내면 6줄 중 5줄, FAQ-305가 `too_short:answer`. Lab 01 검사의 `answer_too_short`와 같은 판정을 **스키마의 `min: 10`**으로 냈습니다.
|
||||
- **스키마로 안 되는 것:** FAQ 스키마 검사는 개인정보 · 바깥 링크 · 이미 있는 FAQ를 보지 않습니다 (B-3에는 그런 줄이 없어 결과가 같았을 뿐). 그래서 FAQ는 Lab 01의 검사를 계속 씁니다. `QUALITY.md` 8절에 이 경계를 적습니다.
|
||||
- **`row`는 1부터:** CSV의 열 이름 줄을 빼고 사람이 세는 번호입니다 (케이스 파일은 0부터 세므로 3째 줄이 `rows.2`).
|
||||
|
||||
## 예제 2 · 고쳐서 다시 넣기 (8분)
|
||||
|
||||
### 이 실습의 의도
|
||||
|
||||
**한 줄로:** 콘텐츠 담당자가 격리된 B-2의 두 줄(FAQ-209 메일 주소 · FAQ-210 다른 답)을 고쳐 다시 보냈습니다(`faq-batch-2-fixed.json`). 넣는 것은 지금 가져오기로도 되지만, **격리함에 옛 줄이 남습니다.** 넣은 줄을 격리함에서 빼는 노드를 더합니다.
|
||||
|
||||
> **생활 속 예 · 보류 칸 비우기.** 납품업체가 라벨을 붙여 다시 가져온 고기를 냉장고에 넣었는데, 보류 칸의 옛 상자에 "라벨 없음" 쪽지가 그대로 붙어 있습니다. 다음 날 아침 주방장은 "아직 해결 안 됐네" 하고 또 전화합니다. 예제 2는 다시 받은 것을 보류 칸에서 치우는 실습입니다.
|
||||
|
||||
**상황.** 테스트가 먼저 B-2를 가져와 격리함에 2줄을 만들고, 고친 배치를 보냅니다. 격리함은 "사람이 볼 일"의 목록입니다. 해결된 줄이 남아 있으면 목록을 믿을 수 없습니다.
|
||||
|
||||
| 노드 | 하는 일 |
|
||||
| --- | --- |
|
||||
| `Release fixed` (HTTP) | `POST /quarantine/remove {ids: imported_ids}` — 이번에 넣은 줄만 |
|
||||
| `Result again` (Code) | 응답은 가져오기 결과 그대로 (Lab 02 과제 1과 같음) |
|
||||
|
||||
**끝나면 알게 되는 것.** 격리 → 사람이 고침 → 다시 넣음 → 격리함에서 뺌, 이 **되돌림 고리**가 품질 관리의 한 바퀴라는 것. 고쳤는데 또 틀린 줄은 격리함에 남아야 한다는 것.
|
||||
|
||||
**적을 곳.** `my-work/QUALITY.md` 9절 (B-2-fix 줄).
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py test cases/lab03-fix.json -v # 고치기 전 (Lab 02 과제 2까지의 가져오기)
|
||||
uv run tools/flow.py quality
|
||||
```
|
||||
|
||||
### 이해하기
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Q[("격리함<br/>FAQ-209 · FAQ-210")] -.->|사람이 고침| F(["B-2-fix"]) --> I["my-faq-import<br/>검사 · LLM · 넣기"] --> X["Release fixed<br/>넣은 id만 뺌"] --> Q
|
||||
I --> L["품질 기록 · score 1"]
|
||||
```
|
||||
|
||||
**넣은 줄만 빼는 까닭.** 고친 줄이 또 규칙이나 LLM에 걸리면 그 줄은 다시 격리됩니다 (같은 배치 · 같은 id면 바뀜 — AGENTS.md 2절). 이번에 **넣은** id만 빼야, 아직 해결되지 않은 줄이 목록에서 사라지지 않습니다.
|
||||
|
||||
**테스트 케이스.**
|
||||
|
||||
| 케이스 | 기대 |
|
||||
| --- | --- |
|
||||
| 고친 두 줄 다시 가져오기 | 넣음 2 · 격리 0 · `score` 1 |
|
||||
| 격리함이 비었나 | `size` 0 |
|
||||
| FAQ에 FAQ-209 · FAQ-210 | FAQ 16개 (처음 6 + B-2 8 + 고친 2) |
|
||||
| 품질 기록 · 점수 1 | 둘째 줄 `score` 1 |
|
||||
|
||||
**기대 결과.** 고치기 전 3/4 (격리함만 ✘ — 2줄 그대로), 고친 뒤 4/4.
|
||||
|
||||
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||
|
||||
요청문에 꼭 들어갈 것: **① 뺄 id ② 걸린 줄 ③ 앞의 기록 ④ 응답**. 아래 빈칸 4개를 채웁니다.
|
||||
|
||||
```text
|
||||
my-work/faq-import.json 에서 고쳐서 다시 넣은 줄을 격리함에서 빼 줘.
|
||||
Summarize 뒤에 Release fixed: POST http://data-api:8800/quarantine/remove {ids: ____①}.
|
||||
넣은 줄만 뺀다 — 이번에도 걸린 줄은 격리함에 ____②. 품질 기록 · 알림은 ____③.
|
||||
응답은 Summarize 의 결과를 ____④ (Release fixed 의 응답이 아님). 기대 결과 cases/lab03-fix.json.
|
||||
```
|
||||
|
||||
빈칸 힌트:
|
||||
|
||||
- ① `Summarize`의 결과 칸 중 이번에 넣은 id 목록.
|
||||
- ② 고쳤는데 또 틀린 줄은 격리함에서 어떻게 되어야 하나요?
|
||||
- ③ Lab 02 과제 1에서 더한 것.
|
||||
- ④ HTTP 노드 뒤에서 응답하면 무엇이 나가나요? (Lab 02 과제 1의 "Result again")
|
||||
</details>
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py push my-work/faq-import.json
|
||||
uv run tools/flow.py test cases/lab03-fix.json -v
|
||||
uv run tools/flow.py quality
|
||||
```
|
||||
|
||||
### 실행 결과 · 고쳐서 다시 넣기
|
||||
|
||||
수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다.
|
||||
|
||||
#### 고치기 전 · Lab 02 과제 2까지의 가져오기
|
||||
|
||||
실행한 명령:
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py test cases/lab03-fix.json -v
|
||||
uv run tools/flow.py quality
|
||||
```
|
||||
|
||||
출력 (결과 줄과 `quality`):
|
||||
|
||||
```text
|
||||
✔ before: POST /webhook/my-faq-import · HTTP 200 · 8.4s · {"batch": "B-2", "total": 10, "imported": 8, "quarantined": 2, …
|
||||
POST http://localhost:5678/webhook/my-faq-import · 4 case(s) × 1
|
||||
✔ 고친 두 줄 다시 가져오기 · 2.5s
|
||||
✘ 격리함이 비었나 · 0.0s · size = 2 (expected 0)
|
||||
✔ FAQ에 FAQ-209 · FAQ-210 · 0.0s
|
||||
✔ 품질 기록 · 점수 1 · 0.0s
|
||||
Passed 3/4
|
||||
|
||||
FAQ: 6 (nothing added)
|
||||
quarantine: 2
|
||||
quality log: 2
|
||||
alerts: 1
|
||||
```
|
||||
|
||||
**왜 이렇게 나왔나 (고치기 전)**
|
||||
|
||||
- `before`가 B-2를 가져와 FAQ-209 · FAQ-210을 격리했습니다. 고친 배치(B-2-fix)는 두 줄 모두 규칙 · LLM을 통과해 **넣는 것은 됩니다** (`score` 1, FAQ 16개).
|
||||
- 그런데 **격리함에 옛 두 줄이 그대로**입니다 (`size` 2). 고친 배치의 `batch`는 `B-2-fix`라 "같은 배치 · 같은 id면 바꿈"에도 해당하지 않습니다. 격리함만 보면 FAQ-209 · 210이 아직 문제인 것처럼 보입니다.
|
||||
- `quality`의 알림 1: B-2(0.8)의 `low_quality`. B-2-fix는 1이라 알림이 없습니다. 3/4는 예상한 결과.
|
||||
|
||||
#### 고친 뒤
|
||||
|
||||
실행한 명령:
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py push my-work/faq-import.json
|
||||
uv run tools/flow.py test cases/lab03-fix.json -v
|
||||
uv run tools/flow.py quality
|
||||
```
|
||||
|
||||
출력:
|
||||
|
||||
```text
|
||||
✔ before: POST /webhook/my-faq-import · HTTP 200 · 10.7s · {"batch": "B-2", "total": 10, "imported": 8, "quarantined": 2, …
|
||||
✔ 고친 두 줄 다시 가져오기 · 2.0s
|
||||
✔ 격리함이 비었나 · 0.0s
|
||||
✔ FAQ에 FAQ-209 · FAQ-210 · 0.0s
|
||||
✔ 품질 기록 · 점수 1 · 0.0s
|
||||
Passed 4/4
|
||||
|
||||
FAQ: 6 (nothing added)
|
||||
quarantine: 0
|
||||
quality log: 2
|
||||
alerts: 1
|
||||
```
|
||||
|
||||
**고친 뒤 이렇게 나온 까닭**
|
||||
|
||||
| | 고치기 전 | 고친 뒤 | 무엇이 바꿨나 |
|
||||
| --- | --- | --- | --- |
|
||||
| 넣은 줄 | FAQ-209 · 210 | 같음 | (가져오기는 원래 됨) |
|
||||
| 격리함 | 2줄 (해결된 줄이 남음) | 0 | `Release fixed` → `/quarantine/remove {ids: imported_ids}` |
|
||||
| 응답 | `score` 1 | 같음 | `Result again` |
|
||||
| 품질 기록 | B-2 0.8 · B-2-fix 1 | 같음 | (Lab 02 과제 1 그대로) |
|
||||
|
||||
- **되돌림 고리가 한 바퀴:** 격리(B-2) → 사람이 고침(B-2-fix) → 다시 넣음 → 격리함에서 뺌. 품질 기록에는 0.8 → 1이 남아 과제 1의 추세가 됩니다.
|
||||
- **넣은 줄만 뺐습니다:** 고친 FAQ-210의 답이 또 틀렸다면 `imported_ids`에 없으니 격리함에 남았을 것입니다.
|
||||
- `quality`의 FAQ 6: 테스트의 `after`가 FAQ를 비웠습니다. 기록 · 알림은 그대로 남습니다.
|
||||
|
||||
## 예제 3 · 스키마만 바꾸면 (6분, 에이전트 없이)
|
||||
|
||||
### 이 실습의 의도
|
||||
|
||||
**한 줄로:** 데이터 API에 세 번째 스키마 `ticket`(접수 건)이 있습니다. 예제 1의 `my-check`를 **고치지 않고** 접수 건 9줄을 보내, 새 데이터셋이 스키마만으로 검사되는 것을 봅니다. 같은 줄을 FAQ 검사에 보내 비교합니다.
|
||||
|
||||
> **생활 속 예 · 새 품목의 검수 카드 한 장.** 식당이 디저트를 시작해 생크림이 들어오기 시작했습니다. 주방장을 새로 뽑지 않고 "생크림 카드"를 한 장 더 만들면 내일부터 같은 사람이 검수합니다. 예제 3은 카드 한 장(스키마)만 더해 새 품목을 검수하는 실습입니다.
|
||||
|
||||
**상황.** 헬프데스크 팀이 76일차부터 만든 접수 건을 다른 시스템에서 옮겨 오려 합니다 (`requests/tickets-new.json`, `{"schema": "ticket", "items": [...]}` 9줄). 스키마 `ticket`은 수업 전에 데이터 API에 들어 있습니다 (`/schemas/ticket`).
|
||||
|
||||
| 칸 | 규칙 (`/schemas/ticket`) |
|
||||
| --- | --- |
|
||||
| `ticket_id` (key) | `pattern` `^T-\d{4}$` |
|
||||
| `title` | `string` 4~40자 |
|
||||
| `requester` | `string` 필수, 20자 이내 |
|
||||
| `priority` | `enum` `low` · `normal` · `high` |
|
||||
| `opened` | `date` |
|
||||
| `contact` | `phone`, **선택** (없어도 됨, 있으면 형식) |
|
||||
|
||||
**끝나면 알게 되는 것.** 규칙을 데이터로 두면 새 데이터셋에 **워크플로우를 고치지 않는다**는 것 (설정으로 넓히기). 그리고 FAQ 검사처럼 규칙이 코드에 박힌 검사는 다른 데이터에 쓸 수 없다는 것.
|
||||
|
||||
**적을 곳.** `my-work/QUALITY.md` 8절 아래 "새 스키마 ticket".
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py call my-faq-check requests/tickets-new.json # 같은 줄을 FAQ 검사에
|
||||
uv run tools/flow.py test cases/lab03-schema.json -v # 내 스키마 검사 (고치지 않음)
|
||||
```
|
||||
|
||||
### 이해하기
|
||||
|
||||
**코드에 박힌 규칙 · 데이터로 둔 규칙.**
|
||||
|
||||
| | FAQ 검사 (`my-faq-check`) | 스키마 검사 (`my-check`) |
|
||||
| --- | --- | --- |
|
||||
| 규칙이 있는 곳 | Code 안 | 데이터 API의 스키마 |
|
||||
| 새 데이터셋 | Code를 하나 더 · 고쳐서 push | 스키마 파일 하나 (워크플로우 그대로) |
|
||||
| 잘하는 것 | 데이터에 맞춘 규칙 (개인정보 · 이미 있는 FAQ · 고침 말) | 모양 · 형식 · 목록 · 필수 · 중복 |
|
||||
|
||||
둘 중 하나만 고르는 것이 아닙니다. 모양은 스키마로 넓게, 그 데이터만의 규칙은 Code로 깊게. 이 경계를 `QUALITY.md` 8절에 적습니다.
|
||||
|
||||
**선택 칸.** `contact`는 `required: false`입니다. 없으면 통과(9째 줄), 있는데 형식이 틀리면 `bad_format:contact`(6째 줄)입니다. "없어도 된다"와 "아무거나 돼도 된다"는 다릅니다.
|
||||
|
||||
**테스트 케이스.**
|
||||
|
||||
| 케이스 | 기대 |
|
||||
| --- | --- |
|
||||
| 스키마 목록에 ticket이 있다 | `/schemas/list`에 `ticket` |
|
||||
| 새 스키마 ticket · 9줄 중 2줄 통과 | 1째 · 9째 줄만 |
|
||||
| 형식 · 목록 · 날짜 · 길이 | `bad_format:ticket_id` · `not_allowed:priority` · `bad_format:opened`(2월 30일) · `too_short:title` |
|
||||
| 선택 칸 형식 · 중복 · 빈 칸 | `bad_format:contact` · `duplicate_in_batch` · `missing:requester`, 9째 줄 통과 |
|
||||
| 직원 CSV는 그대로 | 9줄 중 4줄 |
|
||||
|
||||
**기대 결과.** FAQ 검사는 9줄 모두 나쁨(칸 이름이 다르니 `id_format` · `title_missing`…). 스키마 검사는 고치지 않고 5/5.
|
||||
|
||||
<details><summary>▶ 1단계 · 5/5가 아니라면</summary>
|
||||
|
||||
예제 1의 Code가 칸 이름이나 타입을 직원 · FAQ에만 맞춰 썼는지 봅니다 (예: `pattern`을 `id`에만, `min`을 `answer`에만). 스키마의 `fields`를 돌며 타입마다 판정하면 새 스키마에도 그대로 됩니다. 실패하면 예제 1로 돌아가 고칩니다.
|
||||
</details>
|
||||
|
||||
### 실행 결과 · 스키마만 바꾸면
|
||||
|
||||
수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다 (예제 1의 `my-check`, 고치지 않음).
|
||||
|
||||
이 예제는 **고치지 않습니다.** 새 스키마의 데이터를 두 검사에 보냅니다.
|
||||
|
||||
#### 같은 줄을 FAQ 검사에
|
||||
|
||||
실행한 명령:
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py call my-faq-check requests/tickets-new.json
|
||||
```
|
||||
|
||||
출력 (줄마다 한 줄로, `fix_list`는 줄임):
|
||||
|
||||
```text
|
||||
HTTP 200 · 0.1s
|
||||
{ "batch": "TK-1", "total": 9, "valid": 0, "invalid": 9,
|
||||
"rows": [ 1 "" id_format,keywords_missing,answer_missing ·
|
||||
2~9 "" id_format,keywords_missing,answer_missing,duplicate_in_batch ],
|
||||
"codes": { "id_format": 9, "keywords_missing": 9, "answer_missing": 9, "duplicate_in_batch": 8 },
|
||||
"message": "TK-1: 9줄 중 0줄 통과, 9줄 고칠 것" }
|
||||
```
|
||||
|
||||
**왜 이렇게 나왔나:** 접수 건에는 `id` · `keywords` · `answer` 칸이 없습니다. FAQ 검사는 그 칸만 보므로 9줄 모두 "id 형식 · 키워드 없음 · 답 없음"입니다. `id`가 모두 빈 글자라 둘째 줄부터 `duplicate_in_batch`까지 붙었습니다. 판정이 틀린 것이 아니라 **다른 데이터에 FAQ 규칙을 댄** 것입니다. `fix_list`의 "답을 써 주세요"는 접수 건에는 말이 되지 않습니다.
|
||||
|
||||
#### 내 스키마 검사 (`my-check`, 고치지 않음)
|
||||
|
||||
실행한 명령:
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py test cases/lab03-schema.json -v
|
||||
```
|
||||
|
||||
출력 (결과 줄과 `-v`의 접수 건 응답을 줄마다 한 줄로):
|
||||
|
||||
```text
|
||||
POST http://localhost:5678/webhook/my-check · 5 case(s) × 1
|
||||
✔ 스키마 목록에 ticket이 있다 · 0.0s
|
||||
✔ 새 스키마 ticket · 워크플로우는 그대로 · 9줄 중 2줄 통과 · 0.1s
|
||||
✔ 형식 · 목록 · 날짜 · 길이 · 0.1s
|
||||
✔ 선택 칸 형식 · 중복 · 빈 칸, 선택 칸이 없어도 통과 · 0.0s
|
||||
✔ 직원 CSV는 그대로 9줄 중 4줄 · 0.1s
|
||||
Passed 5/5
|
||||
|
||||
{ "schema": "ticket", "batch": "TK-1", "total": 9, "valid": 2, "invalid": 7,
|
||||
"rows": [ 1 T-2001 ok · 2 T-12 bad_format:ticket_id · 3 T-2003 not_allowed:priority · 4 T-2004 bad_format:opened ·
|
||||
5 T-2005 too_short:title · 6 T-2006 bad_format:contact · 7 T-2001 duplicate_in_batch ·
|
||||
8 T-2008 missing:requester · 9 T-2009 ok ] }
|
||||
```
|
||||
|
||||
**왜 이렇게 나왔나 (줄마다)**
|
||||
|
||||
| 줄 | 값 | 판정 | 규칙 |
|
||||
| --- | --- | --- | --- |
|
||||
| 2 | `T-12` | `bad_format:ticket_id` | `pattern ^T-\d{4}$` |
|
||||
| 3 | `urgent` | `not_allowed:priority` | `enum` low · normal · high |
|
||||
| 4 | `2026-02-30` | `bad_format:opened` | `date` — 2월 30일은 없음 |
|
||||
| 5 | `프린터` (3자) | `too_short:title` | `min: 4` |
|
||||
| 6 | `010-12-3456` | `bad_format:contact` | 선택 칸이지만 **있으면** 형식 |
|
||||
| 7 | 1째와 같은 `T-2001` | `duplicate_in_batch` | `key: ticket_id` |
|
||||
| 8 | `requester` 빈칸 | `missing:requester` | `required` |
|
||||
| 9 | `contact` 없음 | 통과 | 선택 칸이라 없어도 됨 |
|
||||
|
||||
- **워크플로우를 한 줄도 고치지 않았습니다.** 데이터 API에 `ticket.json` 한 장이 있었을 뿐입니다. 직원 CSV(9줄 중 4줄)도 그대로입니다.
|
||||
- 같은 접수 건 9줄을 FAQ 검사는 "모두 나쁨", 스키마 검사는 "2줄 통과 · 줄마다 다른 이유"로 봤습니다. **규칙이 데이터에 맞아야** 판정이 쓸모 있습니다.
|
||||
- 이것이 "규칙을 데이터로 둔다"의 효과입니다. 실제로는 스키마를 바꾸는 것도 사람이 검토해야 하는 일(스키마 변경 = 규칙 변경)이라, 스키마 파일을 저장소에 두고 리뷰합니다 (74일차 배포 관문과 같은 생각).
|
||||
|
||||
## 과제 1 · 품질 보고서 (12분)
|
||||
|
||||
### 이 실습의 의도
|
||||
|
||||
**한 줄로:** Lab 02 과제 1부터 쌓인 품질 기록으로 **보고서**(`my-quality-report`)를 만듭니다: 배치 수 · 평균 점수 · 가장 나쁜 배치 · 거절 수 · 가장 흔한 문제 · 추세.
|
||||
|
||||
> **생활 속 예 · 한 달 검수 일지 정리.** 월말에 사장님이 묻습니다. "이번 달 납품은 어땠어? 제일 나빴던 날은? 반품 이유는 뭐가 제일 많았어? 나아지고 있어?" 일지를 한 장씩 넘기며 말로 답하면 사람마다 답이 다릅니다. 과제 1은 일지를 늘 같은 방식으로 정리하는 보고서를 만드는 실습입니다.
|
||||
|
||||
**상황.** 테스트가 먼저 B-1(거절) · B-2 · B-3 · B-2-fix를 차례로 가져와 기록 네 줄을 만듭니다. 팀장이 회의에서 쓸 숫자입니다.
|
||||
|
||||
| 칸 | 정의 (AGENTS.md 3절 "품질 보고서") |
|
||||
| --- | --- |
|
||||
| `count` · `batches` | 기록 줄 수 · `[{batch, score, rejected, total}]` 넣은 순서 |
|
||||
| `avg_score` | 평균, 소수 4자리 (0건이면 0) |
|
||||
| `worst_batch` | 가장 낮은 `score` (같으면 먼저 나온 것) |
|
||||
| `rejected_batches` | 거절 수 |
|
||||
| `top_codes` | `codes`를 모두 더해 많은 순 (같으면 코드 글자순) |
|
||||
| `trend` | 마지막 − 첫 > 0.05 `좋아짐`, < −0.05 `나빠짐`, 아니면 `그대로` |
|
||||
|
||||
**끝나면 알게 되는 것.** 품질도 77일차 지표처럼 **정의가 먼저**라는 것 (거절을 평균에 넣나, 같으면 무엇을 고르나). 숫자는 Code가 세고, 말은 사람이 한다는 것.
|
||||
|
||||
**적을 곳.** `my-work/QUALITY.md` 9절 표와 "회의에 어떻게 말할까".
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py test cases/lab03-challenge.json -v # 고치기 전 (my-quality-report가 아직 없음)
|
||||
```
|
||||
|
||||
### 이해하기
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
L[("품질 기록<br/>B-1 · B-2 · B-3 · B-2-fix")] --> G["Get log"] --> M["Make report (Code)<br/>평균 · 최저 · 거절 · 흔한 코드 · 추세"] --> R(["보고서"])
|
||||
```
|
||||
|
||||
**거절을 평균에 넣는 까닭.** B-1은 거절이라 `score` 0입니다. 평균에서 빼면 0.8778, 넣으면 0.6583입니다. 뺀 숫자는 "받은 배치는 괜찮았다"이고, 넣은 숫자는 "보내온 배치는 그만큼이었다"입니다. 오늘은 **넣습니다** — 콘텐츠 담당자가 보내는 배치의 품질을 보는 보고서이기 때문입니다. 무엇을 넣었는지는 보고서와 함께 말합니다.
|
||||
|
||||
**같으면 무엇을 고르나.** B-2와 B-3이 같은 점수라면? `top_codes`에서 `pii`와 `title_missing`이 같은 수라면? 정하지 않으면 실행마다 순서가 바뀔 수 있습니다. 그래서 "먼저 나온 것" · "코드 글자순"으로 정합니다.
|
||||
|
||||
**테스트 케이스.**
|
||||
|
||||
| 케이스 | 기대 |
|
||||
| --- | --- |
|
||||
| 배치 네 개 · 평균 · 가장 나쁜 배치 | 4 · 0.6583 · B-1 · 거절 1 |
|
||||
| 가장 많은 이유 · 흐름 | `pii` 2, `좋아짐` (0 → 1) |
|
||||
| 배치마다 점수 순서 | 0.8 · 0.8333 · B-2-fix |
|
||||
| 다른 데이터셋으로 거르기 | `employee` → 0건 · 평균 0 |
|
||||
|
||||
**기대 결과.** 고치기 전 0/4 (404), 고친 뒤 4/4.
|
||||
|
||||
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||
|
||||
요청문에 꼭 들어갈 것: **① 반올림 ② 같을 때 (배치) ③ 같을 때 (코드) ④ 추세의 나머지**. 아래 빈칸 4개를 채웁니다.
|
||||
|
||||
```text
|
||||
my-work/quality-report.json 을 새로 만들어 줘. 웹훅 POST my-quality-report, 이름 "78 Quality report (my)".
|
||||
Get log: HTTP GET http://data-api:8800/quality-log. 본문에 dataset 이 있으면 그 데이터셋만.
|
||||
Make report(Code) 하나에서 AGENTS.md 3절 "품질 보고서" 정의대로 count · batches · avg_score (소수 ____①자리, 0건이면 0) · worst_batch (같으면 ____②) · rejected_batches.
|
||||
top_codes: 모든 기록의 codes 를 더해 많은 순, 같으면 코드 ____③. trend: 마지막 score − 첫 score 가 0.05 넘으면 좋아짐, −0.05 미만이면 나빠짐, 아니면 ____④.
|
||||
LLM 은 쓰지 않는다. 기대 결과 cases/lab03-challenge.json.
|
||||
```
|
||||
|
||||
빈칸 힌트:
|
||||
|
||||
- ① 테스트의 0.6583은 몇 자리인가요?
|
||||
- ② · ③ 3절 표의 괄호 안.
|
||||
- ④ 차이가 ±0.05 안이거나 기록이 1건뿐이면.
|
||||
</details>
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py push my-work/quality-report.json
|
||||
uv run tools/flow.py test cases/lab03-challenge.json -v
|
||||
uv run tools/flow.py call my-quality-report '{}'
|
||||
```
|
||||
|
||||
### 실행 결과 · 품질 보고서
|
||||
|
||||
수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다. 테스트의 `before`가 네 배치를 가져오므로 LLM을 부르는 데 15초쯤 걸립니다.
|
||||
|
||||
#### 고치기 전 · `my-quality-report`가 없을 때
|
||||
|
||||
실행한 명령:
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py test cases/lab03-challenge.json -v
|
||||
```
|
||||
|
||||
출력 (`before`와 결과 줄):
|
||||
|
||||
```text
|
||||
! before: POST /webhook/my-faq-import · HTTP 422 · 0.1s (allow_fail: rejected on purpose) · {"error": "batch rejected", "batch": "B-1", …
|
||||
✔ before: POST /webhook/my-faq-import · HTTP 200 · 8.4s · {"batch": "B-2", …, "score": 0.8, …
|
||||
✔ before: POST /webhook/my-faq-import · HTTP 200 · 4.6s · {"batch": "B-3", …, "score": 0.8333, …
|
||||
✔ before: POST /webhook/my-faq-import · HTTP 200 · 1.9s · {"batch": "B-2-fix", …, "score": 1, …
|
||||
POST http://localhost:5678/webhook/my-quality-report · 4 case(s) × 1
|
||||
✘ 배치 네 개 · 평균 · 가장 나쁜 배치 · 0.0s · status 404 (expected 200); count = null (expected 4); avg_score = null (expected 0.6583); …
|
||||
✘ 가장 많은 이유 · 흐름 · 0.0s · status 404 (expected 200); …
|
||||
✘ 배치마다 점수 순서 · 0.0s · status 404 (expected 200); …
|
||||
✘ 다른 데이터셋으로 거르기 · 0.0s · status 404 (expected 200); …
|
||||
Passed 0/4
|
||||
```
|
||||
|
||||
**왜 이렇게 나왔나 (고치기 전)**
|
||||
|
||||
- **첫 줄의 `!`:** B-1은 일부러 거절되는 배치라 `before`에 `"allow_fail": true`가 있습니다. 422여도 테스트를 멈추지 않고 `!`(일부러 거절)로 표시합니다. 거절도 품질 기록에 남아야 보고서에 "가장 나쁜 배치"가 생깁니다.
|
||||
- 기록 네 줄은 생겼지만 보고서 웹훅이 없어 404. 0/4는 예상한 결과.
|
||||
|
||||
#### 고친 뒤
|
||||
|
||||
실행한 명령:
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py push my-work/quality-report.json
|
||||
uv run tools/flow.py test cases/lab03-challenge.json -v
|
||||
uv run tools/flow.py call my-quality-report '{}'
|
||||
```
|
||||
|
||||
출력 (결과 줄과 `call`):
|
||||
|
||||
```text
|
||||
✔ 배치 네 개 · 평균 · 가장 나쁜 배치 · 0.0s
|
||||
✔ 가장 많은 이유 · 흐름 · 0.1s
|
||||
✔ 배치마다 점수 순서 · 0.0s
|
||||
✔ 다른 데이터셋으로 거르기 · 0.1s
|
||||
Passed 4/4
|
||||
|
||||
HTTP 200 · 0.0s
|
||||
{ "count": 4,
|
||||
"batches": [ {"batch": "B-1", "score": 0, "rejected": true, "total": 13}, {"batch": "B-2", "score": 0.8, …},
|
||||
{"batch": "B-3", "score": 0.8333, …}, {"batch": "B-2-fix", "score": 1, …} ],
|
||||
"avg_score": 0.6583, "worst_batch": "B-1", "rejected_batches": 1,
|
||||
"top_codes": [ {"code": "pii", "count": 2}, {"code": "answer_missing", "count": 1}, {"code": "answer_too_long", "count": 1}, … ],
|
||||
"trend": "좋아짐" }
|
||||
```
|
||||
|
||||
**고친 뒤 이렇게 나온 까닭**
|
||||
|
||||
| 칸 | 값 | 어떻게 |
|
||||
| --- | --- | --- |
|
||||
| `avg_score` | 0.6583 | (0 + 0.8 + 0.8333 + 1) / 4, 거절도 넣음 |
|
||||
| `worst_batch` | B-1 | `score` 0 |
|
||||
| `top_codes` 1위 | `pii` 2 | B-1의 FAQ-109(전화) + B-2의 FAQ-209(메일) |
|
||||
| `top_codes` 2위부터 | 1건씩, 글자순 | `answer_missing` · `answer_too_long` · `answer_too_short` · … |
|
||||
| `trend` | `좋아짐` | 1(B-2-fix) − 0(B-1) = 1 > 0.05 |
|
||||
|
||||
- **회의에 말한다면:** "배치 넷, 평균 0.66(거절 포함). 가장 흔한 문제는 개인정보 2건 — 콘텐츠 담당자에게 '전화번호 · 메일 대신 IT 포털'을 안내하자. 추세는 좋아짐."
|
||||
- **`trend`의 함정:** 첫 기록이 거절(0)이고 마지막이 고친 두 줄(1)이라 "좋아짐"이 크게 나옵니다. 고친 두 줄짜리 배치와 13줄짜리 배치를 같은 무게로 비교한 것입니다. 숫자는 정의대로 맞지만, **무엇과 무엇을 비교했는지**를 함께 말해야 합니다 (77일차 표본 크기와 같은 이야기).
|
||||
- **`dataset: employee`로 거르면 0건 · 평균 0:** 0으로 나누지 않습니다.
|
||||
|
||||
## 과제 2 · 데이터셋별 품질 (10분)
|
||||
|
||||
### 이 실습의 의도
|
||||
|
||||
**한 줄로:** 스키마 검사(`my-check`)도 검사할 때마다 품질 기록을 남기게 하고, 보고서에 **데이터셋마다**(`faq` · `employee` · `ticket`) 평균 · 가장 나쁜 배치 · 가장 흔한 문제와 **가장 손볼 데이터셋**을 더합니다.
|
||||
|
||||
> **생활 속 예 · 납품업체별 성적표.** 한 달 평균만 보면 "괜찮다"인데, 업체별로 나누면 채소 가게는 늘 좋고 생선 가게만 절반이 반품입니다. 사장님이 전화할 곳은 생선 가게입니다. 과제 2는 검수 일지를 품목(데이터셋)별 성적표로 나누는 실습입니다.
|
||||
|
||||
**상황.** 지금 품질 기록은 FAQ 가져오기만 남깁니다. 직원 CSV · 접수 건은 검사 결과가 부른 사람의 화면에만 있다가 사라집니다. 팀장이 요청했습니다: "인사팀 · 헬프데스크 데이터도 같은 보고서에서 보고 싶어요. 어느 데이터가 제일 나쁜지."
|
||||
|
||||
| 바꿀 곳 | 무엇을 (AGENTS.md 3절) |
|
||||
| --- | --- |
|
||||
| `my-check` | 검사가 끝나면 `/quality-log` 한 줄 `{batch, dataset: <schema>, total, valid, quarantined: invalid, rejected: false, score, codes}`, 응답은 그대로 |
|
||||
| `my-quality-report` | `by_dataset` (데이터셋마다 `{count, avg_score, worst_batch, top_code}`) · `worst_dataset` |
|
||||
|
||||
**끝나면 알게 되는 것.** 같은 기록 모양을 쓰면 **다른 워크플로우의 품질도 한 보고서**에서 본다는 것. 전체 평균이 가리는 것을 나눠 보면 보인다는 것 (77일차 기간별 · 시간대별과 같은 생각).
|
||||
|
||||
**적을 곳.** `my-work/QUALITY.md` 9절 "가장 손볼 데이터셋과 그 이유".
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py test cases/lab03-challenge2.json -v # 고치기 전 (예제 1의 검사 · 과제 1의 보고서)
|
||||
```
|
||||
|
||||
### 이해하기
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
C1["my-check employee"] --> L[("품질 기록")]
|
||||
C2["my-check ticket"] --> L
|
||||
I["my-faq-import B-3"] --> L
|
||||
L --> R["my-quality-report<br/>by_dataset · worst_dataset"]
|
||||
```
|
||||
|
||||
**전체 평균이 가리는 것.** 세 기록의 평균은 (0.4444 + 0.2222 + 0.8333) / 3 = 0.5만큼입니다. 이것만으로는 무엇을 고칠지 모릅니다. 나누면 접수 건(0.2222)이 가장 나쁘고, 직원 CSV는 메일 형식이 가장 흔한 문제입니다.
|
||||
|
||||
**테스트 케이스.** 테스트가 먼저 직원 CSV · 접수 건을 검사하고 B-3을 가져옵니다.
|
||||
|
||||
| 케이스 | 기대 |
|
||||
| --- | --- |
|
||||
| 검사도 품질 기록 | 기록 3줄 — `employee` 0.4444 · `ticket` · `faq` |
|
||||
| 데이터셋마다 나눠 보기 | `employee` 0.4444 · `ticket` 0.2222 · `faq` 0.8333 |
|
||||
| 가장 나쁜 데이터셋 · 그 이유 | `ticket`, 직원의 `top_code` `bad_format:email` |
|
||||
| 한 데이터셋만 (앞의 칸은 그대로) | `{"dataset": "ticket"}` → 1건 · TK-1 · `그대로` |
|
||||
|
||||
**기대 결과.** 고치기 전 0/4, 고친 뒤 4/4, `lab03` · `lab03-challenge`도 그대로.
|
||||
|
||||
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||
|
||||
요청문에 꼭 들어갈 것: **① 데이터셋 이름 ② 순서 ③ 가장 손볼 곳 ④ 앞의 것**. 두 워크플로우를 한 요청문에서 고칩니다.
|
||||
|
||||
```text
|
||||
두 워크플로우를 고쳐 줘.
|
||||
① my-work/check.json: 검사가 끝나면 POST http://data-api:8800/quality-log 한 줄 {batch, dataset: ____①, total, valid, quarantined: invalid, rejected: false, score: valid / total (4자리), codes}. 응답은 검사 결과 그대로.
|
||||
② my-work/quality-report.json: by_dataset — 모든 기록을 dataset 마다 {count, avg_score, worst_batch, top_code} (____② 순서), worst_dataset — avg_score 가 가장 ____③ 데이터셋.
|
||||
본문의 dataset 거르기와 앞의 칸은 ____④. 기대 결과 cases/lab03-challenge2.json, lab03 · lab03-challenge 도.
|
||||
```
|
||||
|
||||
빈칸 힌트:
|
||||
|
||||
- ① 검사 결과의 어느 칸이 `employee` · `ticket`인가요?
|
||||
- ② 보고서가 늘 같은 순서로 나오려면.
|
||||
- ③ `avg_score`가 어때야 "가장 손볼"인가요?
|
||||
- ④ 과제 1의 테스트가 계속 통과하려면.
|
||||
</details>
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py push my-work/check.json my-work/quality-report.json
|
||||
uv run tools/flow.py test cases/lab03-challenge2.json -v
|
||||
uv run tools/flow.py test cases/lab03.json
|
||||
uv run tools/flow.py test cases/lab03-challenge.json
|
||||
uv run tools/flow.py quality
|
||||
```
|
||||
|
||||
### 실행 결과 · 데이터셋별 품질
|
||||
|
||||
수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다.
|
||||
|
||||
#### 고치기 전 · 예제 1의 검사 · 과제 1의 보고서
|
||||
|
||||
실행한 명령:
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py test cases/lab03-challenge2.json -v
|
||||
```
|
||||
|
||||
출력 (`before`와 결과 줄):
|
||||
|
||||
```text
|
||||
✔ before: POST /webhook/my-check · HTTP 200 · 0.1s · {"schema": "employee", "batch": "HR-1", "total": 9, "valid": 4, …
|
||||
✔ before: POST /webhook/my-check · HTTP 200 · 0.1s · {"schema": "ticket", "batch": "TK-1", "total": 9, "valid": 2, …
|
||||
✔ before: POST /webhook/my-faq-import · HTTP 200 · 4.0s · {"batch": "B-3", …, "score": 0.8333, …
|
||||
✘ 검사도 품질 기록 (직원 · 티켓 · FAQ) · 0.0s · count = 1 (expected 3); items.0.dataset = faq (expected employee); items.0.score = 0.8333 (expected 0.4444); …
|
||||
✘ 데이터셋마다 나눠 보기 · 0.2s · count = 1 (expected 3); by_dataset.employee.avg_score = null (expected 0.4444); …
|
||||
✘ 가장 나쁜 데이터셋 · 그 이유 · 0.1s · worst_dataset = null (expected ticket); by_dataset.employee.top_code = null (expected bad_format:email)
|
||||
✘ 한 데이터셋만 (앞의 칸은 그대로) · 0.0s · count = 0 (expected 1); worst_batch = null (expected TK-1)
|
||||
Passed 0/4
|
||||
```
|
||||
|
||||
**왜 이렇게 나왔나 (고치기 전)**
|
||||
|
||||
- 검사 두 번(직원 · 접수 건)은 **결과만 돌려주고 기록을 남기지 않았습니다.** 기록은 B-3 가져오기의 한 줄뿐이라 `count` 1, 첫 줄이 `faq`.
|
||||
- 과제 1의 보고서에는 `by_dataset` · `worst_dataset`이 없어 `null`. `ticket`으로 거르면 기록이 없어 0건. 0/4는 예상한 결과.
|
||||
|
||||
#### 고친 뒤
|
||||
|
||||
실행한 명령:
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py push my-work/check.json my-work/quality-report.json
|
||||
uv run tools/flow.py test cases/lab03-challenge2.json -v
|
||||
uv run tools/flow.py test cases/lab03.json
|
||||
uv run tools/flow.py test cases/lab03-challenge.json
|
||||
uv run tools/flow.py quality
|
||||
```
|
||||
|
||||
출력 (결과 줄, `-v`의 보고서에서 `by_dataset`, `quality`):
|
||||
|
||||
```text
|
||||
✔ 검사도 품질 기록 (직원 · 티켓 · FAQ) · 0.0s
|
||||
✔ 데이터셋마다 나눠 보기 · 0.1s
|
||||
✔ 가장 나쁜 데이터셋 · 그 이유 · 0.0s
|
||||
✔ 한 데이터셋만 (앞의 칸은 그대로) · 0.1s
|
||||
Passed 4/4
|
||||
|
||||
"avg_score": 0.5, "worst_batch": "TK-1", "trend": "좋아짐",
|
||||
"by_dataset": {
|
||||
"employee": {"count": 1, "avg_score": 0.4444, "worst_batch": "HR-1", "top_code": "bad_format:email"},
|
||||
"ticket": {"count": 1, "avg_score": 0.2222, "worst_batch": "TK-1", "top_code": "bad_format:contact"},
|
||||
"faq": {"count": 1, "avg_score": 0.8333, "worst_batch": "B-3", "top_code": "answer_too_short"} },
|
||||
"worst_dataset": "ticket"
|
||||
|
||||
(lab03) Passed 4/4 · (lab03-challenge) Passed 4/4
|
||||
quality log: 4
|
||||
```
|
||||
|
||||
**고친 뒤 이렇게 나온 까닭**
|
||||
|
||||
| 데이터셋 | 기록 | `avg_score` | `top_code` | 읽는 법 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `employee` | HR-1 | 0.4444 (4/9) | `bad_format:email` | 다섯 가지 문제가 1건씩 — 글자순 첫째가 메일 |
|
||||
| `ticket` | TK-1 | 0.2222 (2/9) | `bad_format:contact` | 일곱 가지가 1건씩, 가장 나쁨 → `worst_dataset` |
|
||||
| `faq` | B-3 | 0.8333 (5/6) | `answer_too_short` | FAQ-305 한 줄 |
|
||||
|
||||
- **검사도 기록:** `my-check`의 `Log quality`가 `dataset`에 스키마 이름을 넣었습니다. 가져오기와 같은 기록 모양이라 보고서를 고치지 않고도 `count` 3이 됐습니다.
|
||||
- **전체 평균 0.5가 가리는 것:** 셋을 섞으면 "절반쯤 괜찮다"이지만, 나누면 접수 건이 0.2222로 가장 손볼 곳입니다. 헬프데스크 팀에 "옮기기 전에 접수 건 일곱 줄을 고쳐 달라"고 말할 근거입니다.
|
||||
- **섞은 `trend`는 뜻이 없습니다:** 전체 `trend`가 `좋아짐`(0.4444 → 0.8333)이지만, 이것은 직원 CSV와 FAQ를 비교한 것입니다. 추세는 **같은 데이터셋 안에서** 봐야 합니다 (`{"dataset": "faq"}`처럼 걸러서). `top_code`가 1건씩 같은 수일 때 글자순 첫째라는 것도 함께 말해야 오해가 없습니다.
|
||||
- `lab03` · `lab03-challenge`가 그대로 4/4입니다. `lab03-challenge`의 `quality log: 4`는 그 테스트가 마지막에 남긴 네 배치입니다 (`my-check`는 그 테스트에서 부르지 않음).
|
||||
|
||||
## 막혔을 때
|
||||
|
||||
| 증상 | 할 일 |
|
||||
| --- | --- |
|
||||
| 모르는 스키마에 500 | `Get schema`에 `neverError`, 다음 IF에서 `fields`가 있는지 |
|
||||
| CSV 줄이 하나 더 | 끝 줄바꿈 — `csv.trim()` 뒤에 나눔 |
|
||||
| `2026-13-01` · `2026-02-30`이 통과 | 실제 있는 날짜인지 (▶ 예제 1의 2단계) |
|
||||
| 빈 `phone`이 `bad_format` | 비었으면 `required`만 보고 다음 칸으로 |
|
||||
| 격리함이 비지 않음 | `Release fixed`에 `imported_ids` |
|
||||
| 응답이 `{"removed": …}` | 끝에 `Result again` |
|
||||
| `avg_score`가 0.8778 | 거절 배치(`score` 0)도 평균에 |
|
||||
| `top_codes` 순서가 바뀜 | 같은 수면 코드 글자순 |
|
||||
| 기록이 1줄 (FAQ만) | `my-check`에 `Log quality`를 더하고 **push** (두 파일 모두) |
|
||||
| `by_dataset`이 비어 있음 | 거르기 **전**의 모든 기록으로 나눔 |
|
||||
| 계속 안 됨 | `cp reference/lab03-example1-schema-check.json my-work/check.json` 후 push (예제 1까지) |
|
||||
|
||||
## 확인
|
||||
|
||||
- [ ] 예제 1: `cases/lab03.json` 4/4 (고치기 전 FAQ 검사는 400, `lab03` 0/4), `QUALITY.md` 8절.
|
||||
- [ ] 예제 2: `cases/lab03-fix.json` 4/4 (고치기 전 3/4).
|
||||
- [ ] 예제 3: `cases/lab03-schema.json` 5/5 (워크플로우를 고치지 않음).
|
||||
- [ ] 과제 1: `cases/lab03-challenge.json` 4/4, `QUALITY.md` 9절.
|
||||
- [ ] 과제 2: `cases/lab03-challenge2.json` 4/4, `lab03` · `lab03-challenge`도 통과.
|
||||
- [ ] `my-work/PROMPTS.md`, 끝나면 `uv run tools/flow.py quality --reset`.
|
||||
@@ -0,0 +1,178 @@
|
||||
# 78일차 수강 안내 · 수업 준비부터 마칠 때까지
|
||||
|
||||
- 일자: 2026-11-03 (화) · 78일차
|
||||
- 주제: 데이터 품질 검증 자동화와 품질 관리 Workflow (규칙 → 검사 → 게이트 · 격리 → 기록 · 추세)
|
||||
- 실습 폴더: [Last3Mon/78](README.md) · 강의 자료: [Lecture/78](../../Lecture/78/README.md)
|
||||
- 환경: 65일차 n8n 2.42.6 + **OpenAI API 키만**(`gpt-4.1-mini`) + **78일차 판 데이터 API**(격리함 · 스키마 · 품질 기록) + CLI 코딩 에이전트 하나. **Ollama는 쓰지 않습니다.**
|
||||
- 이 문서: 오늘 하루 내가 할 일을 수업 준비부터 마칠 때까지 순서대로 모은 수강 안내입니다. 수업 중에도 띄워 두고 어디까지 왔는지 확인하세요.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
P["전날<br/>준비 · quality"] --> M["아침<br/>켜기 · doctor"] --> L1["1~2교시<br/>Lab 01<br/>품질 규칙 · 검사"] --> L2["3~4교시<br/>Lab 02<br/>게이트 · 격리 · 알림"] --> L3["5~6교시<br/>Lab 03<br/>스키마 · 고쳐서 다시 · 보고서"] --> V["7~8교시<br/>tcheck"] --> E["마친 뒤<br/>reset · 79 준비"]
|
||||
```
|
||||
|
||||
## 0. 하루 한눈에
|
||||
|
||||
| 교시 | 시간 | 할 일 | 문서 | 끝났다는 표시 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 전날 | 약 10분 | 자료 받기 · n8n · 78일차 데이터 API · 기준 검사 · 기준 가져오기 게시 · quality · doctor | [PREP](../../Lecture/78/00-DAY78-PREP.md) | `ready` |
|
||||
| 1 | 09:00~09:50 | 개요 · 1단계 강의 (품질 규칙) | [개요](../../Lecture/78/00-DAY78-OVERVIEW.md) 1~14장, [1단계](../../Lecture/78/01-STAGE1-QUALITY-RULES.md) | |
|
||||
| 2 | 10:00~10:50 | Lab 01 실습 5개 (예제 1~3 · 과제 1~2) | [Lab 01](LAB-01-Q-QUALITY-RULES.md) | `lab01-challenge` 4/4 · `lab01-challenge2` 5/5 |
|
||||
| 3 | 11:00~11:50 | 2단계 강의 (품질 게이트) | 개요 15~21장, [2단계](../../Lecture/78/02-STAGE2-QUALITY-GATE.md) | |
|
||||
| 4 | 12:00~12:50 | Lab 02 실습 5개 | [Lab 02](LAB-02-Q-QUALITY-GATE.md) | `lab02-challenge` 5/5 · `lab02-challenge2` 3/3 × 3 |
|
||||
| - | 13:00~14:00 | 점심 | | |
|
||||
| 5 | 14:00~14:50 | 3단계 강의 (품질 관리) | 개요 22~28장, [3단계](../../Lecture/78/03-STAGE3-QUALITY-MANAGEMENT.md) | |
|
||||
| 6 | 15:00~15:50 | Lab 03 실습 5개 | [Lab 03](LAB-03-Q-QUALITY-MANAGEMENT.md) | `lab03-challenge` 4/4 · `lab03-challenge2` 4/4, `QUALITY.md` |
|
||||
| 7 | 16:00~16:50 | 바이브 코딩 `tcheck`: 요청서 → TODO → SPEC → IMP-PLAN → 만들기 → 설치 | [VIBE-REQ-TCHECK](VIBE-REQ-TCHECK.md) → [VIBE-Q-TCHECK](VIBE-Q-TCHECK.md) | `Doc/*.md` · R1~R7 · R11~R14 · `pytest` |
|
||||
| 8 | 17:00~17:50 | 바이브 코딩 발표 · **4단계 정리** (오늘의 의미 · 효과 · 회사에서 쓰는 법) · 79일차 예고 | [4단계 정리](../../Lecture/78/04-STAGE4-WRAPUP.md), [진행](../../Lecture/78/05-VIBE-CODING.md) | 최종 프로젝트 체크리스트 |
|
||||
|
||||
## 1. 전날 준비 (약 10분)
|
||||
|
||||
### 1.1 자료 받기
|
||||
|
||||
```bash
|
||||
cd ~/aio-01d4-stu && git pull
|
||||
```
|
||||
|
||||
### 1.2 n8n과 **78일차** 데이터 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/78/data-api
|
||||
docker compose up -d --force-recreate # 77일차 판을 78일차 판(+ 격리함 · 스키마 · 품질 기록)으로 바꿈
|
||||
curl -s localhost:8800/schemas/employee | head -c 80
|
||||
```
|
||||
|
||||
### 1.3 상태 보기 · 준비 확인
|
||||
|
||||
```bash
|
||||
cd ~/aio-01d4-stu/Last3Mon/78
|
||||
uv run tools/flow.py push reference/baseline-faq-check.json reference/baseline-faq-import.json # 팀의 기준 (비교용)
|
||||
uv run tools/flow.py quality # FAQ 6 · quarantine 0 · quality log 0
|
||||
uv run tools/flow.py doctor # 마지막 줄 ready
|
||||
```
|
||||
|
||||
### 1.4 미리 읽기 (선택, 5분)
|
||||
|
||||
[PREP 3절](../../Lecture/78/00-DAY78-PREP.md): `requests/faq-batch-1.json`에서 넣으면 안 되는 줄을 찾아보기.
|
||||
|
||||
### 준비물
|
||||
|
||||
노트북과 충전기, WSL의 Docker, WSL 창 두 개, 브라우저, 77일차 `~/vibe/tlog` (지우지 않기).
|
||||
|
||||
## 2. 수업 당일 아침 (09:00 전)
|
||||
|
||||
1. 노트북을 껐다 켰다면 1.2를 다시 합니다 (더한 FAQ · 격리함 · 품질 기록은 처음으로).
|
||||
2. 터미널 두 개, 둘 다 `cd ~/aio-01d4-stu/Last3Mon/78`. ① 에이전트, ② 확인.
|
||||
3. 터미널 ②에서 `uv run tools/flow.py doctor` → `ready`.
|
||||
4. `cp templates/QUALITY-RULES-TEMPLATE.md my-work/QUALITY.md`, `my-work/PROMPTS.md`.
|
||||
|
||||
## 3. 실습할 때마다 같은 순서 (오늘은 "규칙 표 → 요청문 → 검사 → 확인")
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["① 규칙 하나<br/>QUALITY.md"] --> B["② 코드 표<br/>AGENTS.md 3절"] --> C["③ 요청문 → 에이전트"] --> D["④ 에이전트가<br/>my-work · check"] --> E["⑤ 내가<br/>push · test · quality"]
|
||||
E -->|✘| F["⑥ 실패 줄 · 보고서 경로<br/>그대로 붙여 주기"] --> C
|
||||
E -->|✔| G["⑦ QUALITY.md 한 줄"]
|
||||
```
|
||||
|
||||
- 규칙은 Code가, 뜻은 LLM이, 고치는 것은 사람이.
|
||||
- 테스트가 이상하면 먼저 `uv run tools/flow.py quality` (지난 실습의 FAQ · 격리함이 남았나). 테스트는 `before`에서 처음으로 돌립니다.
|
||||
- 판정이 기대와 다르면 거의 언제나 **규칙의 말**(넘으면/이상 · 공백 · 꼴 · 정확히)이 다른 것입니다. `✘` 줄 끝의 `칸 = 실제 (expected 기대)` · `has none of [코드]`를 그대로 에이전트에게 붙여 줍니다.
|
||||
- 오늘 검사는 **한 파일**(`my-work/faq-check.json`), 가져오기도 **한 파일**(`my-work/faq-import.json`)에 칸을 더해 갑니다. 고칠 때마다 앞의 테스트도 다시 돌립니다 (회귀).
|
||||
- 규칙 판정(Code)은 누구에게나 같고, LLM 검사(Lab 02)와 시간만 실행마다 다를 수 있습니다.
|
||||
|
||||
## 4. 1~2교시 · 1단계 · 품질 규칙
|
||||
|
||||
1교시는 강의, 2교시는 [Lab 01](LAB-01-Q-QUALITY-RULES.md) (50분, 실습 5개).
|
||||
|
||||
| 순서 | 할 일 | 파일 · 웹훅 | 확인 (터미널 ②) |
|
||||
| --- | --- | --- | --- |
|
||||
| 예제 1 (10분, 에이전트 없이) | B-1 13줄을 눈으로 검사, 팀의 기준 검사와 비교 | `base-faq-check` | `call base-faq-check requests/faq-batch-1.json` → `QUALITY.md` 1절 |
|
||||
| 예제 2 (15분) | 규칙대로 판정하는 내 검사 (줄마다 `ok` · `errors`, 목록이 아니면 400) | `my-work/faq-check.json` (새로) · `my-faq-check` | `test cases/lab01.json --webhook base-faq-check -v` (전), `push` → `test cases/lab01.json -v` |
|
||||
| 예제 3 (7분, 에이전트 없이) | 경계에서 재 보기: 40 · 41자, 공백, 10 · 9자, 전화번호 꼴, 비슷한 주소 | (그대로) | `test cases/lab01-edges.json -v`, `--webhook base-faq-check` → `QUALITY.md` 3절 |
|
||||
| 과제 1 (8분) | 이미 있는 FAQ와 맞춰 보기 (`exists_id` · `same_title` · 경고) | `my-work/faq-check.json` 고치기 | `test cases/lab01-challenge.json -v`, `lab01` |
|
||||
| 과제 2 (10분) | 보낸 사람에게 돌려줄 고침 목록 (`fix_list` · `message`) | 같은 파일 | `test cases/lab01-challenge2.json -v`, `lab01` · `lab01-challenge` · `lab01-edges` |
|
||||
|
||||
(명령 앞에는 모두 `uv run tools/flow.py`가 붙습니다.)
|
||||
|
||||
## 5. 3~4교시 · 2단계 · 품질 게이트
|
||||
|
||||
3교시는 강의, 4교시는 [Lab 02](LAB-02-Q-QUALITY-GATE.md) (50분, 실습 5개).
|
||||
|
||||
| 순서 | 할 일 | 확인 |
|
||||
| --- | --- | --- |
|
||||
| 예제 1 (12분) | 가져오기: 검사를 부르고 배치 게이트(기본 0.3 · `max_invalid_ratio`) · 넣기 · 격리 | `test cases/lab02.json --webhook base-faq-import -v` (전), `push my-work/faq-import.json` → `test cases/lab02.json -v` 5/6, `quality` |
|
||||
| 예제 2 (10분) | LLM 검사 (규칙을 통과한 줄만, 읽을 수 없으면 넣지 않음) | `test cases/lab02.json -v` 6/6 |
|
||||
| 예제 3 (6분, 에이전트 없이) | 기준을 0.8 · 0.05 · 0.1로 바꾸면 | `test cases/lab02-gate.json -v`, `quality` → `QUALITY.md` 5절 |
|
||||
| 과제 1 (10분) | 품질 기록 · 알림 (거절 · 0.9 아래) | `test cases/lab02-challenge.json -v`, `lab02` |
|
||||
| 과제 2 (12분) | `llm_calls`, 세 번 돌려 늘 같은가 | `test cases/lab02-challenge2.json -v` × 3, `--no-before --repeat 2` → `QUALITY.md` 6절 |
|
||||
|
||||
## 6. 5~6교시 · 3단계 · 품질 관리
|
||||
|
||||
5교시는 강의, 6교시는 [Lab 03](LAB-03-Q-QUALITY-MANAGEMENT.md) (50분, 실습 5개).
|
||||
|
||||
| 순서 | 할 일 | 확인 |
|
||||
| --- | --- | --- |
|
||||
| 예제 1 (14분) | 스키마 검사 `my-check` (직원 CSV · FAQ, 모르는 스키마 400) | `call my-faq-check requests/employees-new.json` (전), `push my-work/check.json` → `test cases/lab03.json -v` |
|
||||
| 예제 2 (8분) | 고쳐서 다시: 넣은 줄을 격리함에서 빼기 | `test cases/lab03-fix.json -v` (전 3/4 → 4/4), `quality` |
|
||||
| 예제 3 (6분, 에이전트 없이) | 새 스키마 `ticket` — 워크플로우를 고치지 않고 | `call my-faq-check requests/tickets-new.json`, `test cases/lab03-schema.json -v` → `QUALITY.md` 8절 |
|
||||
| 과제 1 (12분) | 품질 보고서 (평균 · 가장 나쁜 배치 · 흔한 문제 · 추세) | `my-work/quality-report.json` (새로), `test cases/lab03-challenge.json -v`, `call my-quality-report '{}'` |
|
||||
| 과제 2 (10분) | 검사도 기록, 데이터셋별 품질 · 가장 손볼 데이터셋 | `push my-work/check.json my-work/quality-report.json`, `test cases/lab03-challenge2.json -v`, `lab03` · `lab03-challenge` |
|
||||
|
||||
- 기록: `QUALITY.md` 전체, `cp templates/TEST-REPORT-TEMPLATE.md my-work/TEST-REPORT.md`.
|
||||
|
||||
## 7. 7~8교시 · 바이브 코딩 `tcheck`
|
||||
|
||||
[VIBE-REQ-TCHECK](VIBE-REQ-TCHECK.md)(요청서)를 **먼저** 읽고 [VIBE-Q-TCHECK](VIBE-Q-TCHECK.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`의 다음 단계로 집에서 이어 갑니다. 만드는 것은 CSV · JSON을 데이터 API의 스키마로 검사해 **n8n `my-check`와 같은 판정**을 내고, 깨끗한 줄 · 나쁜 줄을 따로 내보내고, 배치 게이트를 종료 코드로 알리고, TUI로 탭을 나눠 보는 도구. 정답본 `VIBE-A-TCHECK.md`는 수업이 끝나면 `git pull`로 받습니다.
|
||||
|
||||
```bash
|
||||
mkdir -p ~/vibe/tcheck/Doc && cd ~/vibe/tcheck
|
||||
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/9 · 접수 건 2/9 · 줄마다 코드, 종료 코드) |
|
||||
| 16:15~16:20 | 3 `Doc/IMP-PLAN.md` (엔진 1~3단계 · TUI 1단계 · 설치) · commit | 단계마다 체크리스트 |
|
||||
| 16:20~16:44 | 4 구현: 엔진 1단계(R1 · R2 · R3 · R4 · R12 · R13) → 엔진 2단계(R5 · R6 · R7) → TUI 1단계(R11), 막히면 5 문제 해결 팁 | 단계마다 `pytest` · 직접 실행 · commit |
|
||||
| 16:44~16:50 | 6 확인 · 7 `uv tool install --editable .` · 8 다른 폴더에서 `tcheck` | `tcheck check …/employees-new.json --url http://localhost:8800` = `my-check`의 판정 |
|
||||
|
||||
- 8교시: 1~2명 발표 (10분) → [4단계 정리](../../Lecture/78/04-STAGE4-WRAPUP.md) (35분: 오늘 한 일의 의미 · 효과 · 산업에서의 응용 · 면접 질문 · 최종 프로젝트 체크리스트) → 79일차 예고.
|
||||
|
||||
## 8. 마친 뒤
|
||||
|
||||
| 할 일 | 이유 |
|
||||
| --- | --- |
|
||||
| `uv run tools/flow.py quality --reset`, `doctor` → `ready` | 더한 FAQ · 격리함 · 품질 기록 · 알림을 처음 상태로 |
|
||||
| `PROMPTS.md`, `QUALITY.md`, `TEST-REPORT.md` 정리 | 최종 프로젝트 재료 (데이터 품질 규칙 · 게이트 · 보고) |
|
||||
| `my-work/*.json`, `~/vibe/tcheck` 지우지 않기 | 79일차 Ops Assistant(`tops`) |
|
||||
| 79일차 준비: [00-DAY79-PREP](../../Lecture/79/00-DAY79-PREP.md) | 운영 데이터 기반 Ops Assistant |
|
||||
|
||||
## 9. 막혔을 때 (오늘 공통)
|
||||
|
||||
| 증상 | 할 일 |
|
||||
| --- | --- |
|
||||
| doctor의 `schemas faq · employee · ticket` ✘ | 데이터 API가 예전 판. 1.2의 78일차 `--force-recreate` · `sleep 3` |
|
||||
| doctor에 `quarantine … left from an earlier run` | `uv run tools/flow.py quality --reset` |
|
||||
| 가져오기가 404 · 빈 응답 | `my-faq-check`을 push했는지 (가져오기가 부름) |
|
||||
| 공백 제목 · 41자 · `01012345678` · `evil.io`가 통과 | 규칙의 말을 요청문에 (Lab 01 예제 3) |
|
||||
| `quarantined` 수가 틀림 | 넣기 · 격리를 HTTP 노드 하나로 (AGENTS.md 5절) |
|
||||
| LLM 검사가 늘 `llm_mismatch` | 지시문에 JSON만, `Decide`에서 코드 펜스 지우기, i번째 짝 맞추기 |
|
||||
| 두 번째 실행이 거절 | 같은 배치를 다시 보냄 → `exists_id` (테스트는 `before`로 처음 상태에서) |
|
||||
| 앞에서 통과한 테스트가 깨짐 | 같은 파일에 칸을 더할 때 앞의 규칙을 지웠는지 — `uv run tools/flow.py diff reference/<앞 단계>.json my-work/<파일>.json` |
|
||||
| 같은 곳을 3번 고쳐도 실패 | 요청을 더 작게, 또는 `reference/`로 이어 가기 |
|
||||
|
||||
## 확인
|
||||
|
||||
- [ ] 전날 78일차 데이터 API, 기준 둘 게시, `quality`, `doctor` → `ready`
|
||||
- [ ] Lab 01: `QUALITY.md` 1~4절, `lab01` 5/5 · `lab01-edges` 6/6 · `lab01-challenge` 4/4 · `lab01-challenge2` 5/5
|
||||
- [ ] Lab 02: `lab02` 6/6 · `lab02-gate` 6/6 · `lab02-challenge` 5/5 · `lab02-challenge2` 3/3 × 3, `QUALITY.md` 5~7절
|
||||
- [ ] Lab 03: `lab03` 4/4 · `lab03-fix` 4/4 · `lab03-schema` 5/5 · `lab03-challenge` 4/4 · `lab03-challenge2` 4/4, `QUALITY.md` 8 · 9절 · `TEST-REPORT.md`
|
||||
- [ ] `tcheck`: TODO · SPEC · IMP-PLAN, R1~R7 · R11~R14, `pytest`, 단계마다 commit
|
||||
- [ ] reset, 79일차 준비
|
||||
@@ -0,0 +1,88 @@
|
||||
# 78일차 실습 · 데이터 품질 검증 자동화와 품질 관리 Workflow (바이브 코딩)
|
||||
|
||||
- 일자: 2026-11-03 (78일차)
|
||||
- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영
|
||||
- 단원: AI 워크플로우 운영 및 관리 역량 강화
|
||||
- 강의 자료: [Lecture/78](../../Lecture/78/README.md)
|
||||
- 하루 수강 안내: [LECTURE-78.md](LECTURE-78.md) (수업 준비부터 마칠 때까지 순서대로)
|
||||
- 67~77일차처럼 CLI 코딩 에이전트에게 **내가 쓴 요청문**으로 워크플로우를 만들게 하고, **테스트로 확인**합니다. 오늘은 워크플로우가 믿고 쓰는 **데이터가 깨끗한지** 자동으로 검사하고, 나쁘면 막고, 알리고, 기록합니다.
|
||||
|
||||
## 오늘의 상황: 콘텐츠 담당자가 FAQ를 배치로 보낸다
|
||||
|
||||
77일차에 와이파이 FAQ 한 줄이 답 못 한 질문 34건을 풀었습니다. 그래서 콘텐츠 담당자가 FAQ를 한꺼번에 보내기 시작했습니다. 그런데 빈 제목, 개인 전화번호, 바깥 링크, 질문과 다른 답이 섞여 들어옵니다. 나쁜 FAQ는 **답을 못 하는 것보다 나쁩니다** (틀린 답을 자신 있게 함).
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
B[("FAQ 배치<br/>B-1 · B-2 · B-3")] --> C["검사<br/>my-faq-check<br/>규칙 · 오류 코드"]
|
||||
C --> G{"배치 게이트<br/>나쁜 줄 30% 넘나?"}
|
||||
G -->|넘음| R["거절 422 · 알림"]
|
||||
G -->|아님| J{{"LLM 검사<br/>답이 질문에 맞나<br/>(통과한 줄만)"}}
|
||||
J --> I["FAQ에 넣기"]
|
||||
J --> Q["격리함<br/>이유와 함께"]
|
||||
I --> L[("품질 기록<br/>score")]
|
||||
Q --> L
|
||||
Q -.->|사람이 고침| B
|
||||
L --> T["품질 보고서<br/>추세"]
|
||||
```
|
||||
|
||||
## 일하는 방식 (67~77일차와 같음)
|
||||
|
||||
| 누가 | 무엇을 |
|
||||
| --- | --- |
|
||||
| 나 | 품질 규칙 정하기, 나쁜 데이터를 **고치기**, 기준(30% · 0.9) 정하기 |
|
||||
| CLI 에이전트 | [AGENTS.md](AGENTS.md) 규칙대로 워크플로우 JSON 작성, `check`로 형식 오류 고치기 |
|
||||
| `tools/flow.py` | check · push · call · test · **quality**(FAQ · 격리함 · 품질 기록 한눈에, `--reset`) |
|
||||
|
||||
> **규칙은 Code가, 뜻은 LLM이, 고치는 것은 사람이.** 워크플로우는 나쁜 데이터를 고쳐서 넣지 않고 막고 알립니다.
|
||||
|
||||
## 실습 문서 읽는 법
|
||||
|
||||
- 실습 문서(`LAB-0x-Q-*.md`, `VIBE-Q-TCHECK.md`)에는 **복사해 붙일 요청문이 없습니다.** 막히면 `▶` 를 하나씩 펼칩니다.
|
||||
- 쓴 요청문은 `my-work/PROMPTS.md`에 모아 둡니다.
|
||||
|
||||
## 구성
|
||||
|
||||
```text
|
||||
Last3Mon/78/
|
||||
├── README.md
|
||||
├── LECTURE-78.md # 하루 수강 안내 (수업 준비부터 마칠 때까지)
|
||||
├── AGENTS.md # CLI 에이전트용 작업 규칙 (계약 · 품질 규칙 · 고침 목록 · 게이트 · 스키마 · 보고서, 3절)
|
||||
├── LAB-01-Q-QUALITY-RULES.md # 1단계 · 눈으로 · 기준 검사 → 검사 워크플로우 → 경계 → 이미 있는 데이터 → 고침 목록
|
||||
├── LAB-02-Q-QUALITY-GATE.md # 2단계 · 게이트 · 격리 → LLM 검사 → 기준을 바꾸면 → 기록 · 알림 → LLM 호출 수
|
||||
├── LAB-03-Q-QUALITY-MANAGEMENT.md # 3단계 · 스키마 검사 → 고쳐서 다시 → 스키마만 바꾸면 → 보고서 → 데이터셋별
|
||||
├── VIBE-REQ-TCHECK.md # 7교시 바이브 코딩 요청서 (가장 먼저)
|
||||
├── VIBE-Q-TCHECK.md # 7교시 바이브 코딩 · tcheck (CSV · JSON 스키마 검사 · 게이트 · TUI)
|
||||
├── *-A-*.md · answers/ · tcheck/ # 강사용 정답본 · 정답 워크플로우와 실행 기록 · 강사가 만든 tcheck (학생 저장소에는 수업 뒤)
|
||||
├── tools/flow.py
|
||||
├── data-api/ # 78일차 판 데이터 API (77일차 + 격리함 · 스키마 · 품질 기록)
|
||||
│ └── data/schemas/ # faq.json · employee.json · ticket.json
|
||||
├── cases/ # lab01 · -edges · -challenge · -challenge2 · lab02 · -gate · -challenge · -challenge2 · lab03 · -fix · -schema · -challenge · -challenge2
|
||||
├── requests/ # faq-batch-1 · 2 · 3 · 2-fixed · edges · 1-loose · 2-limit-0.1 · 2-strict · employees-new · tickets-new · not-a-list
|
||||
├── reference/ # 팀의 기준(baseline-faq-check · baseline-faq-import) · 막혔을 때 이어 가는 워크플로우
|
||||
├── templates/ # 품질 규칙표 · 테스트 보고서
|
||||
├── my-work/
|
||||
└── runs/
|
||||
```
|
||||
|
||||
## 준비
|
||||
|
||||
[실습 준비](../../Lecture/78/00-DAY78-PREP.md)를 끝내 주세요. 요약:
|
||||
|
||||
```bash
|
||||
cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start # Ollama는 쓰지 않음 · 처음 한 번 PREP 1절대로 끄기
|
||||
cd ~/aio-01d4-stu/Last3Mon/78/data-api && docker compose up -d --force-recreate # 78일차 판 (격리함 · 스키마)
|
||||
cd ~/aio-01d4-stu/Last3Mon/78 && uv run tools/flow.py push reference/baseline-faq-check.json reference/baseline-faq-import.json
|
||||
uv run tools/flow.py quality
|
||||
uv run tools/flow.py doctor # 마지막 줄 ready
|
||||
```
|
||||
|
||||
## `flow.py` 명령
|
||||
|
||||
| 명령 | 하는 일 | 수업 준비 때 |
|
||||
| --- | --- | --- |
|
||||
| `uv run tools/flow.py quality` | FAQ 수(더한 id), 격리함, 품질 기록, 알림 수 | 처음: FAQ 6 · 격리 0 · 기록 0 · 알림 0 |
|
||||
| `uv run tools/flow.py quality --reset` | 더한 FAQ · 격리함 · 품질 기록 · 알림을 처음으로 (`-v`는 지운 수) | |
|
||||
| `uv run tools/flow.py test cases/lab01.json --webhook base-faq-check` | 같은 테스트를 팀의 기준에 (고치기 전) | 1/5 |
|
||||
| `uv run tools/flow.py ops [--reset]` | 77일차 실행 기록 · 76일차 접수 건 · DLQ까지 한눈에 · 모두 비우기 | |
|
||||
| `uv run tools/flow.py call my-faq-check requests/faq-batch-1.json` | 검사 한 번 | 0.1초 안팎 |
|
||||
| `uv run tools/flow.py call my-faq-import requests/faq-batch-2.json` | 가져오기 한 번 | 규칙만 1~2초, LLM 검사 8~10초 |
|
||||
@@ -0,0 +1,327 @@
|
||||
# 7교시 바이브 코딩 · `tcheck` 만들기
|
||||
|
||||
> **먼저 [VIBE-REQ-TCHECK.md](VIBE-REQ-TCHECK.md)(요청서)를 읽고 `~/vibe/tcheck/Doc/TODO.md`를 직접 쓴 뒤** 이 문서를 엽니다. 아래 1~8을 차례로 갑니다. 이 문서의 요구 표는 **내 SPEC을 쓴 뒤에** 비교하는 데 쓰고, 에이전트에게 통째로 붙여 넣지 않습니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
T["1. Doc/TODO.md<br/>할 일 정리"] --> S["2. Doc/SPEC.md<br/>확인할 수 있는 요구"] --> P["3. Doc/IMP-PLAN.md<br/>엔진 → TUI 단계"] --> I["4. 구현<br/>단계마다 요청 · 확인"]
|
||||
I --> H["5. 막히면<br/>문제 해결 팁"] --> I
|
||||
I --> C["6. 결과 확인<br/>요구 표 체크"] --> U["7. uv tool install"] --> O["8. 다른 곳에서 사용"]
|
||||
```
|
||||
|
||||
- 시간: 16:00~16:50 (50분) · 발표와 정리는 8교시 · 50분 안에 못 한 단계는 집에서 `Doc/IMP-PLAN.md`의 다음 단계로 이어 갑니다
|
||||
- 도구: CLI 코딩 에이전트(codex / claude / agy), Python + uv + [Textual](https://textual.textualize.io/), 표준 라이브러리 `csv` · `json` · `re` · `urllib.request`
|
||||
- 결과물: CSV · JSON(오늘 요청 파일 모양 포함)을 **데이터 API의 스키마**(또는 스키마 파일)로 검사해, 줄마다 오늘의 코드(AGENTS.md 3절)를 붙이고 **n8n `my-check`와 같은 판정**을 내고, 깨끗한 줄 · 나쁜 줄을 따로 파일로 내보내고, **배치 게이트를 판정해 종료 코드로** 알리고, FAQ 내용 규칙 · 고침 목록 · 품질 기록까지 하고, TUI로 탭을 나눠 보는 도구. `uv tool install`로 설치해 **어느 폴더에서나** `tcheck`. 오늘 n8n 워크플로우로 한 일을 **보내기 전에, 내 노트북에서.**
|
||||
|
||||
```text
|
||||
$ tcheck check ~/aio-01d4-stu/Last3Mon/78/requests/employees-new.json --url http://localhost:8800
|
||||
employee · HR-1 · 9 rows · valid 4 · invalid 5 · score 0.4444
|
||||
3 minsu.kang@example bad_format:email
|
||||
4 jia.seo@example.com not_allowed:dept
|
||||
5 doyun.lim@example.com bad_format:joined
|
||||
6 noname@example.com missing:name
|
||||
8 sua.bae@example.com duplicate_in_batch
|
||||
codes: bad_format:email 1 · bad_format:joined 1 · duplicate_in_batch 1 · missing:name 1 · not_allowed:dept 1
|
||||
|
||||
$ tcheck gate ~/aio-01d4-stu/Last3Mon/78/requests/employees-new.json --url http://localhost:8800; echo $?
|
||||
✘ rejected · HR-1 · invalid 5/9 = 0.5556 (limit 0.3)
|
||||
4
|
||||
|
||||
$ tcheck ui ~/aio-01d4-stu/Last3Mon/78/requests/tickets-new.json --url http://localhost:8800
|
||||
┌ tcheck · tickets-new.json · schema ticket ─────────────────────────────┐
|
||||
│ TK-1 · 9 rows · valid 2 · invalid 7 · score 0.2222 │
|
||||
│ [Problem rows] By code Clean rows │
|
||||
│ row key errors │
|
||||
│ 2 T-12 bad_format:ticket_id │
|
||||
│ 3 T-2003 not_allowed:priority │
|
||||
│ [/] filter · [e] export clean rows · [Esc] clear · [q] quit │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
> 요청문은 **직접** 씁니다. 단계마다 `▶` 를 펼치면 생각할 거리가 나옵니다. 정답 요청문은 없습니다.
|
||||
>
|
||||
> **테스트에서 진짜 n8n · 데이터 API를 부르지 않습니다.** 스키마 · 배치의 사본, n8n이 낸 결과의 사본, 테스트 안의 작은 가짜 데이터 API로 시험합니다.
|
||||
|
||||
## 시간 (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 다른 폴더에서 | `tcheck check …` = `my-check`의 판정 |
|
||||
|
||||
50분 안 목표: **R1 · R2 · R3 · R4 · R12 · R13(엔진 1단계) + R5 · R6 · R7(엔진 2단계) + R11(TUI 1단계) + R14(설치)**. R8 · R9 · R10은 IMP-PLAN의 다음 단계로 8교시 뒤 · 집에서 이어 갑니다.
|
||||
|
||||
## 0. 준비 (1분)
|
||||
|
||||
```bash
|
||||
mkdir -p ~/vibe/tcheck/Doc && cd ~/vibe/tcheck
|
||||
git init
|
||||
# 에이전트는 Doc/TODO.md를 쓴 뒤에 켭니다: codex (또는 claude, agy)
|
||||
```
|
||||
|
||||
## 1. `Doc/TODO.md` · 만들고 싶은 것과 할 일 정리 (8분, 에이전트 없이)
|
||||
|
||||
[VIBE-REQ-TCHECK.md](VIBE-REQ-TCHECK.md)의 "할 일" 틀대로 씁니다. 요청의 말 ①~⑫를 **내 말로** 옮기고, 꼭 · 나중을 표시하고, 걱정되는 것과 50분 안에 못 할 것을 적습니다. "어떻게"는 아직 쓰지 않습니다.
|
||||
|
||||
<details><summary>▶ TODO와 SPEC은 무엇이 다른가</summary>
|
||||
|
||||
TODO는 **내가 이해한 것**(무엇을 · 왜)입니다. SPEC은 그것을 **확인할 수 있게** 바꾼 것(됐다 · 안 됐다를 말하는 방법)입니다. 오늘 확인 방법은 대부분 **판정**입니다: 직원 9줄 중 4줄, 3째 줄 `bad_format:email`처럼, n8n이 낸 판정과 같은지.
|
||||
</details>
|
||||
|
||||
`git add Doc/TODO.md && git commit -m "TODO"`
|
||||
|
||||
## 2. `Doc/SPEC.md` · 확인할 수 있는 요구로 (6분)
|
||||
|
||||
TODO의 할 일마다 **확인 방법**(기대 판정 · 종료 코드 · JSON 칸 · 파일)을 붙여 SPEC을 만듭니다. 에이전트에게 "TODO.md를 읽고 SPEC.md 초안을 써 줘. 요구마다 확인 방법을 붙여"라고 시켜도 됩니다. 그러면 **반드시 내가 읽고 고칩니다.**
|
||||
|
||||
```markdown
|
||||
# tcheck SPEC
|
||||
|
||||
## 1. 요구 (확인할 수 있게)
|
||||
| 번호 | 요구 | 요청의 어느 말 | 확인 방법 | 꼭 · 나중 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| R1 | | ① | | 꼭 |
|
||||
|
||||
## 2. 숨은 요구
|
||||
## 3. 범위 밖 (하지 않을 것)
|
||||
## 4. 정해 둘 것 (판정 규칙 = AGENTS.md 3절 스키마 표, 정규식이 JavaScript와 같게, 종료 코드 번호, 기본 게이트 기준, 읽을 파일 모양)
|
||||
```
|
||||
|
||||
<details><summary>▶ 확인 방법은 n8n이 낸 판정으로</summary>
|
||||
|
||||
"스키마로 검사한다"는 확인 방법이 아닙니다. "직원 CSV → 9줄 중 4줄, 3째 `bad_format:email` · 4째 `not_allowed:dept` · 5째 `bad_format:joined` · 6째 `missing:name` · 8째 `duplicate_in_batch`, 접수 건 → 9줄 중 2줄, FAQ B-3(`schema: faq`) → 6줄 중 5줄"이 확인 방법입니다. 오늘 Lab 03의 `cases/lab03.json` · `lab03-schema.json`에 있는 판정을 그대로 가져오면 됩니다 — 규칙이 같으면 판정도 같아야 합니다.
|
||||
</details>
|
||||
|
||||
쓴 뒤 아래 표와 비교합니다. 빠진 요구, 내가 더 뽑은 요구를 `Doc/SPEC.md` 끝 "VIBE-Q와 비교"에 한 줄씩 적고 SPEC을 고칩니다.
|
||||
|
||||
### 요구 사항 (요청서에서 뽑은 것, 14개)
|
||||
|
||||
| 번호 | 요구 | 요청서의 말 | 확인 방법 | 50분 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| R1 | `.csv`(첫 줄 열 이름) · JSON 목록 · `{"items": [...]}` · 오늘 요청 파일(`{"schema", "batch", "csv" \| "items"}`) 읽기 | ① | `employees-new.json`(CSV가 든 요청) · CSV 파일 · `tickets-new.json` 모두 9줄 | 꼭 |
|
||||
| R2 | 스키마는 데이터 API `GET /schemas/<name>` 또는 `--schema-file`, **필드 이름을 코드에 쓰지 않고** `fields`를 돌며 타입마다 | ② | `--schema employee` · `ticket` · `faq` 모두 같은 코드로, 없는 스키마 → 종료 코드 1 | 꼭 |
|
||||
| R3 | 줄마다 AGENTS.md 3절 코드 (`missing:` · `too_long:` · `too_short:` · `not_allowed:` · `bad_format:` · `too_few:` · `too_many:` · `duplicate_in_batch`), 줄 번호 1부터 | ③ | 직원 3 · 4 · 5 · 6 · 8째 줄의 코드 | 꼭 |
|
||||
| R4 | 같은 배치면 n8n `my-check`와 **같은 판정** (공백 = 빈 값, 선택 칸은 비면 통과, 실제 있는 날짜, 정규식은 JavaScript처럼) | ④ | 직원 · 접수 건 · FAQ B-3의 `rows` · `codes` · `valid`가 `my-check` 응답과 하나도 다르지 않음 | 꼭 |
|
||||
| R5 | `--json`: `my-check`와 같은 모양 `{schema, batch, total, valid, invalid, rows, codes}` | ⑤ | `tcheck check … --json`과 `call my-check …`를 칸마다 비교 | 꼭 |
|
||||
| R6 | `--out` 깨끗한 줄 · `--rejects` 나쁜 줄 + `errors` 칸 (원래 줄은 고치지 않음) | ⑥ | 직원 → 깨끗한 4줄 · 나쁜 5줄 파일 | 꼭 |
|
||||
| R7 | `tcheck gate --max-invalid 0.3`: 나쁜 줄 비율이 **넘으면** 종료 코드 4, 같으면 통과 | ⑦ | 직원(0.5556) → 4, `--max-invalid 0.6` → 0, B-2 `--content answer` 0.1 · 기준 0.1 → 0 | 꼭 |
|
||||
| R8 | `--content answer`: Lab 01 내용 규칙 `pii`(하이픈 없는 휴대전화 · 메일) · `external_link`(호스트가 정확히 `intra.example.com`) | ⑧ | B-2 → FAQ-209 `pii`, `faq-edges.json` → FAQ-406 `pii` · FAQ-409 `external_link` | 나중 |
|
||||
| R9 | `--fix`: 나쁜 줄마다 사람이 읽는 말(`fix_list`, 줄 순서) + `message` (`TK-1: 9줄 중 2줄 통과, 7줄 고칠 것`) | ⑨ | 접수 건 → 일곱 줄, 3째 "priority은(는) low · normal · high 중 하나로" | 나중 |
|
||||
| R10 | `--log`: 데이터 API `POST /quality-log`에 `my-check`(Lab 03 과제 2)와 같은 한 줄 | ⑩ | `flow.py quality` → `HR-1 employee … score 0.4444` | 나중 |
|
||||
| R11 | `tcheck ui`: 위 요약 한 줄, 탭(문제 줄 · 코드별 · 깨끗한 줄), `[/]` 글자로 거르기, `[e]` 깨끗한 줄 내보내기 | ⑪ | `run_test`: 5 · 5 · 4줄, `email`로 거르면 1줄, `e` → 파일 | 꼭 |
|
||||
| R12 | 테스트는 n8n · 데이터 API 없이: 스키마 · 배치 사본, n8n 결과 사본, 가짜 데이터 API | 끝 | `uv run pytest -q`가 10초 안, n8n과 같은 판정 | 꼭 |
|
||||
| R13 | 규칙은 한 곳(엔진 모듈 하나), cli · 화면은 부르기만. 종료 코드 0 됨 · 1 못 읽음 · 2 쓰는 법 · 4 거절 | ④ · ⑫ | 없는 파일 · 없는 스키마 → 1, 스키마를 안 줌 → 2, 거절 → 4 | 꼭 |
|
||||
| R14 | `uv tool install`로 설치해 **어느 폴더에서나** `tcheck` | 첫 문단 | `cd /tmp && tcheck --help`, `uv tool list`에 `tcheck` | 꼭 |
|
||||
|
||||
숨은 요구 (요청서에 직접 없지만 없으면 안 되는 것):
|
||||
|
||||
| 숨은 요구 | 어디서 나왔나 |
|
||||
| --- | --- |
|
||||
| **규칙은 한 곳**(엔진 모듈 하나). JSON · 파일 · 게이트 · 화면은 그 결과를 쓰기만 | ④ — 규칙이 두 곳이면 기준 검사처럼 어긋남 |
|
||||
| 정규식을 n8n(JavaScript)과 같게: `\w` · `\d`는 ASCII만 | ④ — Python `re`의 `\w`는 한글도 맞음 |
|
||||
| 날짜는 꼴(`YYYY-MM-DD`)만이 아니라 **실제 있는 날** | ④ · 오늘 Lab 03 (13월 · 2월 30일) |
|
||||
| 공백만 있는 칸 = 빈 칸, 선택 칸은 비면 형식을 보지 않음 | ④ · 오늘 Lab 01 예제 3 · Lab 03 예제 3 |
|
||||
| 반올림은 half up (JavaScript `Math.round`) | ④ · 77일차 `tlog`와 같은 까닭 |
|
||||
| 나쁜 줄을 **고쳐서** 내보내지 않음 | ⑥ — 오늘의 원칙 "고치는 것은 사람" |
|
||||
| 읽는 층(`load`) · 규칙 층(`checks`) · 보여 주는 층(cli · TUI)을 나눔 | ② · ⑪ — 파일 모양 · 스키마가 늘어도 규칙은 그대로 |
|
||||
|
||||
범위 밖 (오늘 하지 않음): LLM 검사(오늘 n8n Lab 02 예제 2에서 함, 비용 · 키가 필요), 데이터 API에 넣기(`/faq/add` — 넣는 것은 n8n 가져오기의 일), 이미 있는 데이터와 맞춰 보기(Lab 01 과제 1), 알림 보내기.
|
||||
|
||||
`git add Doc/SPEC.md && git commit -m "SPEC"`
|
||||
|
||||
## 3. `Doc/IMP-PLAN.md` · 구현 계획 (5분)
|
||||
|
||||
SPEC의 요구를 **단계**로 나눕니다. 첫 원칙: **엔진(판정하는 곳)을 먼저, 화면(TUI)은 나중에.** 화면은 엔진의 결과를 표에 넣기만 하면 되게 만듭니다. 에이전트에게 "SPEC.md를 읽고 IMP-PLAN.md를 엔진 단계와 TUI 단계로 나눠 써 줘. 단계마다 체크리스트와 확인 명령"이라고 시켜도 됩니다 (내가 읽고 고침).
|
||||
|
||||
```markdown
|
||||
# tcheck IMP-PLAN
|
||||
|
||||
## 엔진 1단계 · 읽기 · 스키마 · 코드 · n8n과 같은 판정 (R1 · R2 · R3 · R4 · R12 · R13)
|
||||
- [ ] load.py: read_rows(path) — csv · JSON 목록 · {"items"} · 요청 파일, read_schema(name, url, file)
|
||||
- [ ] checks.py: check(rows, schema, batch) — fields를 돌며 타입마다, 키 중복, row 1부터
|
||||
- [ ] cli: tcheck check FILE [--schema] [--url], 종료 코드 0 · 1 · 2
|
||||
- [ ] tests: 직원 4/9 · 접수 건 2/9 · FAQ B-3 5/6, n8n 결과 사본과 rows · codes가 같음, 가짜 데이터 API
|
||||
- 확인: uv run pytest -q, uv run tcheck check …/employees-new.json --url http://localhost:8800
|
||||
|
||||
## 엔진 2단계 · JSON · 내보내기 · 게이트 (R5 · R6 · R7)
|
||||
## TUI 1단계 · 탭 · 거르기 · 내보내기 (R11)
|
||||
## 설치 (R14)
|
||||
## 엔진 3단계 · 내용 규칙 · 고침 목록 · 품질 기록 (R8 · R9 · R10)
|
||||
```
|
||||
|
||||
<details><summary>▶ 왜 엔진 먼저인가</summary>
|
||||
|
||||
- 엔진은 **화면 없이 테스트**할 수 있습니다 (`pytest`, `tcheck check`). 판정이 n8n과 같은지가 오늘의 핵심인데, 화면부터 만들면 눈으로만 봅니다.
|
||||
- 보내기 전 게이트는 화면 없이 스크립트에서 돌아갑니다. 종료 코드(R13)가 다음 일(보낼까 말까)을 정합니다 (74일차 게이트와 같음).
|
||||
- TUI는 엔진의 결과를 표에 넣기만 합니다. 판정이 정해져야 TUI를 만들 수 있습니다.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 단계 크기</summary>
|
||||
|
||||
한 단계 = 에이전트 요청 한두 번 + 확인 한 번 + commit 한 번 (10분 안팎). 오늘 Lab에서 검사를 한 번에 다 만들지 않고 "규칙 → 경계 → 이미 있는 데이터 → 고침 목록"으로 한 칸씩 더한 것과 같습니다.
|
||||
</details>
|
||||
|
||||
`git add Doc/IMP-PLAN.md && git commit -m "IMP-PLAN"`
|
||||
|
||||
## 4. 구현 시작 (24분: 엔진 1단계 → 엔진 2단계 → TUI 1단계)
|
||||
|
||||
이제 에이전트를 켭니다. 단계마다 긴 요청문은 필요 없습니다. 할 일은 SPEC · IMP-PLAN에 있으니까요.
|
||||
|
||||
**처음 한 번:**
|
||||
|
||||
```text
|
||||
Doc/SPEC.md 와 Doc/IMP-PLAN.md 를 읽고 체크 안 된 첫 단계만 만들어 줘. uv 프로젝트, 명령 이름 tcheck, 코드와 화면 글자는 영어.
|
||||
끝나면 IMP-PLAN 체크를 표시하고 확인 명령을 알려 줘. 다음 단계는 내가 말할 때까지 하지 마.
|
||||
```
|
||||
|
||||
**그다음 단계마다 (같은 한 줄):**
|
||||
|
||||
```text
|
||||
확인했고 commit 했어. 다음 단계.
|
||||
```
|
||||
|
||||
→ 내가 확인 → commit → 한 줄. 그냥 "계속"만 치면 여러 단계를 한꺼번에 하거나 체크 · 확인 명령을 빼먹기 쉽습니다. 틀렸을 때만 **무엇이 틀렸는지 · SPEC의 어느 요구인지 · 어디만 고칠지 · 무엇으로 확인할지**를 적어 고쳐 달라고 합니다. 예:
|
||||
|
||||
```text
|
||||
R4가 틀렸어. 직원 CSV 2째 줄(phone 빈칸)이 bad_format:phone 인데 n8n은 통과야.
|
||||
선택 칸(required false)은 비었으면 형식을 보지 않게 checks.py 만 고쳐 줘. 확인은 uv run pytest -q 와 tcheck check …/employees-new.json.
|
||||
```
|
||||
|
||||
대화가 길어지면 새 세션에서 처음 요청을 다시 보냅니다.
|
||||
|
||||
> **규모가 큰 프로젝트일수록 다단계로** 진행합니다. `tcheck`도 요구가 14개라 한 번에 시키면 에이전트가 수백 줄을 한꺼번에 쓰고, 판정이 n8n과 다를 때 어디가 다른지 찾기 어렵습니다. 단계마다 ① 그 단계만 요청 ② 직접 확인 ③ IMP-PLAN 체크 ④ `git commit`.
|
||||
|
||||
<details><summary>▶ 엔진 1단계 · 같은 판정이 나오는지 어떻게 보나</summary>
|
||||
|
||||
스키마 셋(`~/aio-01d4-stu/Last3Mon/78/data-api/data/schemas/`)과 배치(`requests/employees-new.json` · `tickets-new.json` · `faq-batch-3.json`)를 `tests/data/`에 복사하고, n8n이 낸 결과도 파일로 둡니다: `uv run ~/aio-01d4-stu/Last3Mon/78/tools/flow.py call my-check requests/employees-new.json`의 응답을 `tests/data/n8n-employee.json`으로. 테스트는 `rows` · `codes`를 통째로 비교합니다.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 엔진 1단계 · 메일 · 날짜가 n8n과 다를 때</summary>
|
||||
|
||||
- 메일 `^[\w.+-]+@[\w-]+(\.[\w-]+)+$`: Python에서는 `re.ASCII`를 붙여야 JavaScript의 `\w`(영문 · 숫자 · `_`)와 같습니다.
|
||||
- 날짜: `datetime.date.fromisoformat(v).isoformat() == v`로 실제 있는 날인지까지.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 엔진 2단계 · 게이트의 종료 코드</summary>
|
||||
|
||||
"프로그램이 실패했다"(1 · 2)와 "거절될 배치다"(4)를 다른 번호로 둡니다. 스크립트가 1이면 "도구를 고쳐라", 4면 "파일을 고쳐라"로 다르게 할 수 있습니다: `tcheck gate faq.json --schema faq && curl … /webhook/my-faq-import`.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ TUI 1단계 · 화면 나누기</summary>
|
||||
|
||||
판정(엔진)과 화면을 나눕니다 (`tlog`와 같음). 탭 셋은 같은 결과를 다르게 거른 것입니다. 거르기 상자(`Input`)는 `[/]`로 보이고 Enter로 적용, 닫을 때 포커스를 표로 돌려야 다음 키(`e`)가 글자로 들어가지 않습니다.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 다음 단계들 (8교시 뒤 · 집에서)</summary>
|
||||
|
||||
- 내용 규칙(R8): 스키마에 없는 규칙을 **어느 칸에** 댈지 옵션으로 받습니다 (`--content answer`). 칸 이름이 코드에 박히지 않게.
|
||||
- 고침 목록(R9): 코드의 앞부분(`missing` · `bad_format` …)마다 말 하나, 스키마의 `values` · `max`를 말에 넣음.
|
||||
- 품질 기록(R10): 오늘 Lab 03 과제 2의 `my-check`가 남기는 한 줄과 같은 모양 — 보고서가 데이터셋을 구분합니다.
|
||||
</details>
|
||||
|
||||
## 5. 바이브 코딩으로 문제 해결 팁
|
||||
|
||||
| 이럴 때 | 이렇게 |
|
||||
| --- | --- |
|
||||
| 에이전트가 "다 됐다"고 함 | **직접 실행**합니다 (`uv run tcheck check … --url …`, `uv run pytest -q`). 확인 방법은 SPEC에 있습니다 |
|
||||
| 판정이 n8n과 한 줄 다름 | "AGENTS.md 3절 스키마 표 그대로" — 어느 줄 · 어느 코드가 다른지 두 결과를 붙여 줍니다 |
|
||||
| 필드 이름이 코드에 있음 (`if name == "email"`) | "스키마의 fields를 돌며 type마다. 필드 이름을 코드에 쓰지 말 것" |
|
||||
| `2026-02-30`이 통과 | "실제 있는 날짜인지까지" |
|
||||
| 빈 선택 칸이 `bad_format` | "비었으면 required만 보고 다음 칸으로" |
|
||||
| 테스트가 데이터 API를 부름 | "테스트 안의 가짜 서버(http.server)로, 진짜 API는 확인 단계에서만" |
|
||||
| 규칙이 cli · 화면에 흩어짐 | "checks.py 하나에, 나머지는 부르기만" |
|
||||
| `gate`가 늘 0 | "비율이 기준보다 크면 거절(>), 거절이면 종료 코드 4" |
|
||||
| 나쁜 줄 파일에 고친 값이 들어감 | "원래 줄 그대로 + errors 칸만" |
|
||||
| 화면이 이상한데 오류가 안 보임 | `uv add --dev textual-dev` 뒤 다른 터미널에서 `uv run textual console` |
|
||||
| 같은 곳을 3번 고쳐도 안 됨 | 멈추고 `git diff` · `git checkout .`로 되돌린 뒤, 단계를 **더 작게** 나눠 다시 요청 |
|
||||
| 설치한 `tcheck`가 고친 내용을 모름 | `uv tool install --force .` 로 다시 설치 (또는 처음부터 `--editable`) |
|
||||
|
||||
## 6. 결과 확인
|
||||
|
||||
`Doc/SPEC.md` 1절 표의 확인 방법대로 **내가** 하나씩 하고 체크합니다. 오늘 50분 안 목표만:
|
||||
|
||||
```bash
|
||||
cd ~/vibe/tcheck
|
||||
uv run pytest -q # R12: 10초 안
|
||||
R=~/aio-01d4-stu/Last3Mon/78/requests
|
||||
export TCHECK_URL=http://localhost:8800
|
||||
uv run tcheck check $R/employees-new.json # R1 · R3: 9줄 중 4줄
|
||||
uv run tcheck check $R/tickets-new.json # R2: 새 스키마 · 2줄
|
||||
uv run tcheck check $R/faq-batch-3.json --schema faq # R2: 5줄
|
||||
uv run tcheck check $R/employees-new.json --json > /tmp/tcheck.json # R5
|
||||
(cd ~/aio-01d4-stu/Last3Mon/78 && uv run tools/flow.py call my-check requests/employees-new.json) # R4: 판정 비교
|
||||
uv run tcheck check $R/employees-new.json --out /tmp/clean.csv --rejects /tmp/rejects.csv # R6
|
||||
uv run tcheck gate $R/employees-new.json; echo "exit $?" # R7: 4
|
||||
uv run tcheck check nope.csv --schema faq; echo "exit $?" # R13: 1
|
||||
uv run tcheck check $R/employees-new.json --schema nothing; echo "exit $?" # R13: 1
|
||||
uv run tcheck ui $R/tickets-new.json # R11
|
||||
```
|
||||
|
||||
- [ ] R1 CSV · JSON · 요청 파일.
|
||||
- [ ] R2 스키마 셋, 필드 이름이 코드에 없음.
|
||||
- [ ] R3 줄마다 코드, 줄 번호 1부터.
|
||||
- [ ] R4 n8n `my-check`와 같은 판정.
|
||||
- [ ] R5 `--json` 칸 이름 · 값.
|
||||
- [ ] R6 깨끗한 줄 · 나쁜 줄 파일.
|
||||
- [ ] R7 게이트 · 종료 코드 4.
|
||||
- [ ] R11 TUI.
|
||||
- [ ] R12 n8n 없이 도는 테스트.
|
||||
- [ ] R13 규칙 한 곳 · 종료 코드 0 · 1 · 2 · 4.
|
||||
- [ ] (이어서) R8 · R9 · R10.
|
||||
|
||||
## 7. `uv tool install`로 설치
|
||||
|
||||
`pyproject.toml`에 명령이 등록되어 있는지 먼저 봅니다.
|
||||
|
||||
```toml
|
||||
[project.scripts]
|
||||
tcheck = "tcheck.cli:main"
|
||||
```
|
||||
|
||||
```bash
|
||||
cd ~/vibe/tcheck
|
||||
uv tool install --editable . # 고친 내용이 바로 반영됨 (수업 중 권함)
|
||||
uv tool list # tcheck 가 보이는가
|
||||
which tcheck # ~/.local/bin/tcheck
|
||||
# 명령을 못 찾으면: uv tool update-shell 뒤 터미널을 새로 엶
|
||||
```
|
||||
|
||||
- 지우기: `uv tool uninstall tcheck`.
|
||||
|
||||
## 8. 다른 곳에서 사용
|
||||
|
||||
설치했으면 오늘 Lab과 내 파일에 씁니다.
|
||||
|
||||
```bash
|
||||
cd ~
|
||||
export TCHECK_URL=http://localhost:8800 # --url 을 매번 쓰지 않게
|
||||
R=~/aio-01d4-stu/Last3Mon/78/requests
|
||||
tcheck gate $R/faq-batch-1.json --schema faq --content answer; echo $? # (R8) 보내기 전에: 거절될 배치 → 4
|
||||
tcheck gate $R/faq-batch-2.json --schema faq --content answer && echo "보내도 됨"
|
||||
tcheck check $R/tickets-new.json --fix # (R9) 헬프데스크 팀에 돌려줄 말
|
||||
tcheck check $R/employees-new.json --log # (R10) 품질 기록 → my-quality-report에 나옴
|
||||
tcheck check ~/Downloads/명단.csv --schema employee --out 깨끗한.csv --rejects 고칠것.csv
|
||||
```
|
||||
|
||||
- `~/vibe/tcheck`는 지우지 마세요. 79일차 `tops`에서 다시 씁니다.
|
||||
|
||||
## 발표 (8교시, 3분)
|
||||
|
||||
| 내용 | |
|
||||
| --- | --- |
|
||||
| TODO → SPEC | 내 SPEC과 VIBE-Q 요구 표가 달랐던 곳 (빠뜨린 요구, 더 뽑은 요구) |
|
||||
| IMP-PLAN | 엔진 · TUI를 어떻게 나눴나, 어디까지 체크했나 |
|
||||
| 요청문 | 처음 요청 · 틀렸을 때 고쳐 달라고 한 요청문 하나 |
|
||||
| 막힌 곳 | 5절의 어느 팁으로 풀었나 (판정이 다름 · 날짜 · 선택 칸 · 테스트가 API를 부름) |
|
||||
| 시연 | `tcheck check` 와 `my-check` 의 판정 비교, `tcheck gate` 종료 코드, `tcheck 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 확인, `uv run pytest -q`, n8n과 같은 판정.
|
||||
- [ ] R14 `uv tool install`, 다른 폴더에서 `tcheck`.
|
||||
- [ ] 남은 요구는 IMP-PLAN에 체크 안 된 채로 남겨 두었다 (다음에 이어 감).
|
||||
@@ -0,0 +1,112 @@
|
||||
# 7교시 바이브 코딩 · 요청서 `tcheck` (가장 먼저 읽기)
|
||||
|
||||
- 순서: **이 요청서** → 내 정리 `~/vibe/tcheck/Doc/TODO.md` (8분) → [VIBE-Q-TCHECK.md](VIBE-Q-TCHECK.md)의 순서대로 `Doc/SPEC.md` → `Doc/IMP-PLAN.md` → 단계별로 만들기 → 설치 · 다른 곳에서 쓰기 → 수업이 끝나면 `VIBE-A-TCHECK.md`(정답본)로 확인
|
||||
- 이 문서는 **만들어 달라는 사람의 말**입니다. 요구 사항 표도, 단계도 없습니다. 그것을 뽑아 내는 것이 첫 일입니다. 72일차 `tbench`부터 77일차 `tlog`까지와 같은 순서입니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
REQ["요청서<br/>(사람의 말)"] --> TODO["Doc/TODO.md<br/>만들고 싶은 것 ·<br/>요청의 말 정리"] --> SPEC["Doc/SPEC.md<br/>확인할 수 있는 요구"] --> PLAN["Doc/IMP-PLAN.md<br/>엔진 → TUI 단계"] --> RUN["단계별로 만들기 ·<br/>직접 확인"] --> USE["uv tool install<br/>다른 곳에서 쓰기"]
|
||||
```
|
||||
|
||||
> **생활 속 예 · 주머니에 넣는 검수 카드.** 오늘 주방(n8n)에 검수 기준표와 반품 칸을 만들었습니다. 그런데 납품업체가 "보내기 전에 우리 창고에서 미리 검수해 보고 싶다"고 합니다. 주방에 와서 볼 수는 없으니, 같은 검수 카드를 **주머니에 넣어** 창고에서 쓰게 하면 됩니다. 단, 창고의 판정과 주방의 판정이 **같아야** 합니다. `tcheck`는 오늘의 스키마로 터미널에서 검사하는 검수 카드입니다.
|
||||
|
||||
## 왜 요청서부터인가
|
||||
|
||||
요구 사항 표를 통째로 에이전트에게 주고 "이대로 만들어 줘"라고 하면, 무엇이 만들어지든 **내가 판단할 기준이 없습니다.** 실무에서는 요구 사항 표가 오지 않고, 이 요청서 같은 **사람의 말**이 옵니다. 오늘 Lab 01 예제 1에서 기준 검사의 판정을 먼저 눈으로 비교해 본 것과 같습니다: 무엇이 맞는 판정인지 정해야 맞았는지 말할 수 있습니다.
|
||||
|
||||
| 단계 | 누가 | 결과 |
|
||||
| --- | --- | --- |
|
||||
| 요청 → 할 일 목록 | **나** | `Doc/TODO.md`: 요청의 말 ①~⑫를 내 말로, 만들고 싶은 것 |
|
||||
| 할 일 → 요구 | **나** (에이전트와 다듬어도 됨) | `Doc/SPEC.md`: 요구마다 "됐다 / 안 됐다"를 말할 수 있는 확인 방법 |
|
||||
| 요구 → 단계 | **나** (에이전트와 다듬어도 됨) | `Doc/IMP-PLAN.md`: 엔진 단계와 화면(TUI) 단계로 나눈 순서 |
|
||||
| 단계 → 코드 | 에이전트 | 처음 요청 한 번 + 단계마다 한 줄, `Doc/*.md`를 읽게 함 |
|
||||
| 확인 | **나** | 직접 `uv run tcheck …`, `pytest`, n8n `my-check`와 판정 비교, 요구 표에 체크 |
|
||||
|
||||
## 요청 (인사팀 · 콘텐츠 담당에게서 온 메시지, 그대로)
|
||||
|
||||
> 오늘 n8n으로 검사 · 게이트 · 스키마 검사까지 만들었다고 들었어요. 그런데 저희는 데이터를 **보내기 전에** 미리 검사해 보고 싶어요. 보냈다가 거절당하고, 고쳐서 또 보내는 게 하루에 몇 번씩이에요. 제 노트북 **어느 폴더에서나** `tcheck 파일`이라고 치면 검사해 주는 작은 도구가 있으면 좋겠어요.
|
||||
>
|
||||
> ① 저희 파일은 **CSV**도 있고 **JSON**도 있어요. 오늘 n8n에 보내는 요청 파일 모양 그대로도 읽어 주세요.
|
||||
>
|
||||
> ② 규칙은 **데이터 API의 스키마**에서 가져와 주세요. 직원 · FAQ · 접수 건, 앞으로 더 늘어나요. 스키마가 늘 때마다 도구를 고치면 안 돼요. 인터넷이 안 되는 날을 위해 스키마 **파일**로도요.
|
||||
>
|
||||
> ③ 몇째 줄이 **왜** 틀렸는지 알려 주세요. 이유는 오늘 n8n이 쓰는 그 **코드**로요 (`missing:name`, `bad_format:email` …). 말이 다르면 n8n 화면과 맞춰 보기 어려워요.
|
||||
>
|
||||
> ④ 제일 중요한 거요. **n8n이 내리는 판정과 똑같아야** 해요. 제 노트북에서 통과했는데 n8n에서 거절되면 이 도구를 믿을 수 없어요.
|
||||
>
|
||||
> ⑤ 다른 프로그램이 읽을 수 있게 **JSON으로도** 주세요. 칸 이름은 n8n 검사 결과와 같게요.
|
||||
>
|
||||
> ⑥ **깨끗한 줄만** 새 파일로 뽑아 주세요. 틀린 줄은 이유와 함께 **따로** 파일로요. 틀린 줄 파일을 담당자에게 돌려보낼 거예요.
|
||||
>
|
||||
> ⑦ 보내기 전에 "이 배치는 **거절될까?**"를 알고 싶어요. 오늘 정한 그 기준(나쁜 줄 30%)으로, 기준은 바꿀 수 있게요. 스크립트에서 쓸 거라 거절이면 **종료 코드로** 알려 주세요.
|
||||
>
|
||||
> ⑧ FAQ는 스키마로 안 잡히는 게 있잖아요. 답 안의 **개인 전화번호 · 메일 · 바깥 링크**도 봐 주세요. 오늘 Lab 01 규칙 그대로요.
|
||||
>
|
||||
> ⑨ 틀린 줄마다 **사람이 읽는 말**로 무엇을 고칠지 써 주세요. 맨 위에 "몇 줄 중 몇 줄 통과"도 한 줄로요.
|
||||
>
|
||||
> ⑩ 검사한 결과를 오늘 만든 **품질 기록**에도 남길 수 있게요. 그래야 보고서에 저희 데이터셋도 나와요.
|
||||
>
|
||||
> ⑪ **화면에서** 보고 싶어요. 틀린 줄 · 코드별 · 깨끗한 줄을 탭으로 나눠서, 글자로 거르고, 깨끗한 줄을 바로 내보낼 수 있게요.
|
||||
>
|
||||
> ⑫ 스크립트에서 쓸 거라 **종료 코드**가 중요해요. 됐다 · 파일이나 스키마를 못 읽었다 · 쓰는 법이 틀렸다 · 거절될 배치다를 서로 다른 번호로요.
|
||||
>
|
||||
> 아, 시험할 때 **진짜 n8n이나 데이터 API가 없어도** 돌아가야 해요. 그리고 오늘 배치로 **n8n과 같은 판정인지** 꼭 확인해 주세요.
|
||||
|
||||
## 함께 받은 것
|
||||
|
||||
| 파일 · 자료 | 메모 |
|
||||
| --- | --- |
|
||||
| 오늘 데이터 API `http://localhost:8800` | `GET /schemas` · `GET /schemas/<name>` (`faq` · `employee` · `ticket`), `POST /quality-log` |
|
||||
| `~/aio-01d4-stu/Last3Mon/78/data-api/data/schemas/*.json` | 스키마 셋 (파일로 읽기 · 테스트용 사본) |
|
||||
| `~/aio-01d4-stu/Last3Mon/78/requests/` | `employees-new.json`(CSV 9줄) · `tickets-new.json` · `faq-batch-1~3.json` |
|
||||
| [AGENTS.md](AGENTS.md) 3절 | **스키마 타입 표 · 코드** (오늘의 규칙서), FAQ 품질 규칙 · 고침 목록 · 게이트 · 품질 기록 |
|
||||
| 내 `my-check` | 같은 배치를 물어 판정을 비교할 상대: `uv run tools/flow.py call my-check requests/employees-new.json` |
|
||||
| Python 표준 라이브러리 | `csv`, `json`, `re`, `urllib.request`. TUI는 Textual |
|
||||
|
||||
## 할 일 · 요청을 내 할 일 목록으로 (8분, 에이전트 없이)
|
||||
|
||||
`~/vibe/tcheck/Doc/TODO.md`를 만들고 요청의 말 ①~⑫와 맨 끝 부탁을 **내 말로** 한 줄씩 옮깁니다. 아직 "어떻게"는 쓰지 않습니다. **무엇을 · 왜**만 씁니다. 에이전트에게 맡기지 않고 직접 씁니다.
|
||||
|
||||
```markdown
|
||||
# tcheck TODO
|
||||
|
||||
## 만들고 싶은 것 (한 문단)
|
||||
어느 폴더에서나 `tcheck 파일`로 CSV · JSON을 데이터 API의 스키마로 검사해, 오늘 n8n my-check와 같은 판정을 ...
|
||||
|
||||
## 요청의 말 → 할 일
|
||||
| # | 요청의 말 (짧게) | 할 일 (내 말) | 꼭 · 나중 |
|
||||
| --- | --- | --- | --- |
|
||||
| ① | CSV · JSON · 요청 파일 | | 꼭 |
|
||||
| ② | 스키마는 API에서, 파일로도 | | |
|
||||
| ... | | | |
|
||||
| 끝 | n8n 없이 시험 · 같은 판정 확인 | | |
|
||||
|
||||
## 요청에 없지만 걱정되는 것
|
||||
## 오늘 50분 안에 못 할 것 같은 것
|
||||
```
|
||||
|
||||
예 하나만 보여 드립니다.
|
||||
|
||||
| 요청의 말 | TODO에 쓸 할 일 (내 말) | 다음 단계(SPEC)에서 바뀔 모양 |
|
||||
| --- | --- | --- |
|
||||
| ④ "n8n이 내리는 판정과 똑같아야" | "AGENTS.md 3절 스키마 표 그대로: 공백은 빈 값, 선택 칸은 비면 통과, 실제 있는 날짜, 키 중복, 줄 번호 1부터" | R4: 직원 CSV → 9줄 중 4줄, 3째 `bad_format:email` …, 같은 배치 `my-check` 응답의 `rows` · `codes`와 **모두 같음** |
|
||||
|
||||
<details><summary>▶ 1단계 · 꼭 할 것과 나중에 할 것</summary>
|
||||
|
||||
요청의 말이 열두 개입니다. 50분 안에 다 만들기 어렵습니다. 담당자가 가장 아파하는 것은 "n8n과 똑같은 판정"(믿을 수 있나)과 "거절될까?"(보냈다가 돌아오는 일)입니다. 그리고 내보내기 · 화면 · 고침 말을 만들려면 먼저 무엇이 있어야 하나요? (읽기 → 스키마대로 판정 → JSON → 내보내기 · 게이트 → 화면)
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 2단계 · 걱정되는 것 (숨은 요구의 씨앗)</summary>
|
||||
|
||||
- ② "스키마가 늘 때마다 고치면 안 돼요": 필드 이름(`email` · `dept`)이 코드에 나오면 안 됩니다. 스키마의 `fields`를 돌며 **타입마다** 판정합니다 (오늘 Lab 03 예제 3).
|
||||
- ③ · ④ "같은 코드 · 같은 판정": 정규식의 `\w`는 Python에서 한글까지, JavaScript에서는 영문 · 숫자 · `_`만입니다. n8n Code 노드는 JavaScript입니다. 날짜 `2026-02-30`, 공백만 있는 칸, 선택 칸이 빈 경우도.
|
||||
- ⑥ "따로 파일로": 원래 줄은 그대로 두고, 나쁜 줄에 이유 칸 하나만 더합니다 (고쳐서 넣지 않음 — 오늘의 원칙).
|
||||
- ⑦ "30%": 넘으면 거절, **같으면** 통과 (오늘 Lab 02 예제 3의 경계).
|
||||
- ⑧ "바깥 링크": `intra.example.com.evil.io`는 바깥입니다 (오늘 Lab 01 예제 3).
|
||||
- ⑫ "거절될 배치": 프로그램이 실패한 것(1 · 2)과 다른 번호여야 스크립트가 둘을 구분합니다.
|
||||
- 끝 "n8n 없이 시험": 데이터 API를 대신할 작은 가짜 서버, 스키마 · 배치의 사본, 그리고 n8n이 낸 결과의 사본.
|
||||
</details>
|
||||
|
||||
## 그다음
|
||||
|
||||
[VIBE-Q-TCHECK.md](VIBE-Q-TCHECK.md)를 엽니다. 거기서 `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md` → 단계별 구현 → 확인 → `uv tool install` → 다른 곳에서 사용 순서로 갑니다. VIBE-Q의 요구 표는 **내 SPEC을 쓴 뒤에** 비교하는 데 씁니다.
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"webhook": "my-faq-check",
|
||||
"before": [
|
||||
{
|
||||
"url": "http://localhost:8800/faq-reset",
|
||||
"body": {}
|
||||
}
|
||||
],
|
||||
"after": [
|
||||
{
|
||||
"url": "http://localhost:8800/faq-reset",
|
||||
"body": {}
|
||||
}
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"name": "이미 있는 id (FAQ-003)",
|
||||
"request": "requests/faq-batch-1.json",
|
||||
"expect": {
|
||||
"contains": {
|
||||
"rows.10.errors": "exists_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "이미 있는 제목 · 쓰인 키워드는 경고",
|
||||
"request": "requests/faq-batch-1.json",
|
||||
"expect": {
|
||||
"contains": {
|
||||
"rows.11.errors": "same_title",
|
||||
"rows.11.warnings": "keyword_used:vpn"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "배치 1 · 통과 3건",
|
||||
"request": "requests/faq-batch-1.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"valid": 3,
|
||||
"invalid": 10
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "막 더한 FAQ도 기존 데이터",
|
||||
"before": [
|
||||
{
|
||||
"url": "http://localhost:8800/faq/add",
|
||||
"body": {
|
||||
"id": "FAQ-201",
|
||||
"title": "회의실 예약",
|
||||
"keywords": [
|
||||
"회의실"
|
||||
],
|
||||
"answer": "사내 포털의 '회의실 예약' 메뉴에서 예약합니다."
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": "requests/faq-batch-2.json",
|
||||
"expect": {
|
||||
"contains": {
|
||||
"rows.0.errors": "exists_id"
|
||||
},
|
||||
"equals": {
|
||||
"rows.1.ok": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"webhook": "my-faq-check",
|
||||
"before": [
|
||||
{
|
||||
"url": "http://localhost:8800/faq-reset",
|
||||
"body": {}
|
||||
}
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"name": "B-1 · 고칠 줄 10개, 줄 순서대로",
|
||||
"request": "requests/faq-batch-1.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"fix_list.0.row": 3,
|
||||
"fix_list.0.id": "FAQ-103",
|
||||
"fix_list.9.id": "FAQ-112",
|
||||
"fix_list.10": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "고칠 것은 사람이 읽는 말로",
|
||||
"request": "requests/faq-batch-1.json",
|
||||
"expect": {
|
||||
"contains": {
|
||||
"fix_list.0.fix": "제목을 써 주세요",
|
||||
"fix_list.6.fix": "개인 전화번호",
|
||||
"fix_list.8.fix": "이미 있는 id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "한 줄 요약",
|
||||
"request": "requests/faq-batch-1.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"message": "B-1: 13줄 중 3줄 통과, 10줄 고칠 것"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "B-2 · 메일 주소 한 줄만",
|
||||
"request": "requests/faq-batch-2.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"fix_list.0.id": "FAQ-209",
|
||||
"fix_list.0.codes.0": "pii",
|
||||
"fix_list.1": null,
|
||||
"message": "B-2: 10줄 중 9줄 통과, 1줄 고칠 것"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "앞의 칸은 그대로",
|
||||
"request": "requests/faq-batch-1.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"valid": 3,
|
||||
"invalid": 10
|
||||
},
|
||||
"contains": {
|
||||
"rows.10.errors": "exists_id"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"webhook": "my-faq-check",
|
||||
"cases": [
|
||||
{
|
||||
"name": "제목 40자는 통과, 41자는 너무 김",
|
||||
"request": "requests/faq-edges.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"rows.0.ok": true,
|
||||
"rows.1.ok": false
|
||||
},
|
||||
"contains": {
|
||||
"rows.1.errors": "title_too_long"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "공백만 있는 제목은 빈 값",
|
||||
"request": "requests/faq-edges.json",
|
||||
"expect": {
|
||||
"contains": {
|
||||
"rows.2.errors": "title_missing"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "답 10자는 통과, 9자는 너무 짧음",
|
||||
"request": "requests/faq-edges.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"rows.3.ok": true
|
||||
},
|
||||
"contains": {
|
||||
"rows.4.errors": "answer_too_short"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "하이픈 없는 휴대전화도 개인정보, 사무실 번호는 통과",
|
||||
"request": "requests/faq-edges.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"rows.6.ok": true
|
||||
},
|
||||
"contains": {
|
||||
"rows.5.errors": "pii"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "사내 주소는 통과, 사내 주소처럼 보이는 바깥 링크는 막음",
|
||||
"request": "requests/faq-edges.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"rows.7.ok": true
|
||||
},
|
||||
"contains": {
|
||||
"rows.8.errors": "external_link"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "소문자 id · 합계 10줄 중 4줄 통과",
|
||||
"request": "requests/faq-edges.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"total": 10,
|
||||
"valid": 4
|
||||
},
|
||||
"contains": {
|
||||
"rows.9.errors": "id_format"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"webhook": "my-faq-check",
|
||||
"cases": [
|
||||
{
|
||||
"name": "빈 값 (제목 · 답 · 키워드)",
|
||||
"request": "requests/faq-batch-1.json",
|
||||
"expect": {
|
||||
"contains": {
|
||||
"rows.2.errors": "title_missing",
|
||||
"rows.4.errors": "answer_missing",
|
||||
"rows.7.errors": "keywords_missing"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "형식 (id · 답 길이)",
|
||||
"request": "requests/faq-batch-1.json",
|
||||
"expect": {
|
||||
"contains": {
|
||||
"rows.3.errors": "id_format",
|
||||
"rows.5.errors": "answer_too_long"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "배치 안 중복 · 개인정보 · 바깥 링크",
|
||||
"request": "requests/faq-batch-1.json",
|
||||
"expect": {
|
||||
"contains": {
|
||||
"rows.6.errors": "duplicate_in_batch",
|
||||
"rows.8.errors": "pii",
|
||||
"rows.9.errors": "external_link"
|
||||
},
|
||||
"equals": {
|
||||
"rows.0.ok": true,
|
||||
"total": 13
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "배치 2 · 메일 주소만 걸림, 사내 링크는 통과",
|
||||
"request": "requests/faq-batch-2.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"total": 10,
|
||||
"valid": 9,
|
||||
"codes.pii": 1,
|
||||
"rows.8.ok": false,
|
||||
"rows.6.ok": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "목록이 아닌 본문 → 400",
|
||||
"request": "requests/not-a-list.json",
|
||||
"expect": {
|
||||
"status": 400
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"webhook": "my-faq-import",
|
||||
"before": [
|
||||
{
|
||||
"url": "http://localhost:8800/faq-reset",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8800/quarantine-clear",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8800/quality-log-clear",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8800/alerts-clear",
|
||||
"body": {}
|
||||
}
|
||||
],
|
||||
"after": [
|
||||
{
|
||||
"url": "http://localhost:8800/faq-reset",
|
||||
"body": {}
|
||||
}
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"name": "거절도 품질 기록",
|
||||
"request": "requests/faq-batch-1.json",
|
||||
"expect": {
|
||||
"status": 422
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "배치 2 가져오기",
|
||||
"request": "requests/faq-batch-2.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"score": 0.8
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "품질 기록 두 줄 (거절 · 0.8)",
|
||||
"url": "http://localhost:8800/quality-log/list",
|
||||
"body": {},
|
||||
"expect": {
|
||||
"equals": {
|
||||
"count": 2,
|
||||
"items.0.rejected": true,
|
||||
"items.1.score": 0.8,
|
||||
"items.1.valid": 8,
|
||||
"items.1.quarantined": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "알림 두 건 (거절 · 낮은 점수)",
|
||||
"url": "http://localhost:8800/alerts/list",
|
||||
"body": {},
|
||||
"expect": {
|
||||
"equals": {
|
||||
"count": 2
|
||||
},
|
||||
"contains": {
|
||||
"alerts": "batch_rejected"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "알림에 낮은 점수",
|
||||
"url": "http://localhost:8800/alerts/list",
|
||||
"body": {},
|
||||
"expect": {
|
||||
"contains": {
|
||||
"alerts": "low_quality"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"webhook": "my-faq-import",
|
||||
"before": [
|
||||
{
|
||||
"url": "http://localhost:8800/faq-reset",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8800/quarantine-clear",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8800/quality-log-clear",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8800/alerts-clear",
|
||||
"body": {}
|
||||
}
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"name": "거절된 배치는 LLM을 부르지 않음",
|
||||
"request": "requests/faq-batch-1.json",
|
||||
"expect": {
|
||||
"status": 422,
|
||||
"equals": {
|
||||
"llm_calls": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "B-2 · 규칙을 통과한 9줄만 LLM에",
|
||||
"request": "requests/faq-batch-2.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"llm_calls": 9,
|
||||
"imported": 8
|
||||
},
|
||||
"contains": {
|
||||
"quarantined_ids": "FAQ-210"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "B-3 · 5줄만 LLM에",
|
||||
"request": "requests/faq-batch-3.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"llm_calls": 5,
|
||||
"imported": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"webhook": "my-faq-import",
|
||||
"before": [
|
||||
{
|
||||
"url": "http://localhost:8800/faq-reset",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8800/quarantine-clear",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8800/quality-log-clear",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8800/alerts-clear",
|
||||
"body": {}
|
||||
}
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"name": "B-1 · 기본 기준 0.3 → 거절",
|
||||
"request": "requests/faq-batch-1.json",
|
||||
"expect": {
|
||||
"status": 422,
|
||||
"equals": {
|
||||
"max_invalid_ratio": 0.3,
|
||||
"invalid_ratio": 0.7692
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "B-1 · 기준을 0.8로 풀면 → 받음, 넣는 것은 2줄",
|
||||
"request": "requests/faq-batch-1-loose.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"rejected": false,
|
||||
"max_invalid_ratio": 0.8,
|
||||
"imported": 2,
|
||||
"quarantined": 11
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "FAQ만 처음으로 (B-1이 넣은 2줄을 비움, 격리함은 그대로)",
|
||||
"url": "http://localhost:8800/faq-reset",
|
||||
"body": {},
|
||||
"expect": {
|
||||
"equals": {
|
||||
"cleared": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "B-2 · 기준 0.05 → 거절",
|
||||
"request": "requests/faq-batch-2-strict.json",
|
||||
"expect": {
|
||||
"status": 422,
|
||||
"equals": {
|
||||
"invalid_ratio": 0.1,
|
||||
"max_invalid_ratio": 0.05
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "B-2 · 기준 0.1 · 나쁜 줄이 딱 10% → 받음",
|
||||
"request": "requests/faq-batch-2-limit-0.1.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"rejected": false,
|
||||
"max_invalid_ratio": 0.1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "격리함이 13줄 (B-1 11 + B-2 2)",
|
||||
"url": "http://localhost:8800/quarantine/list",
|
||||
"body": {},
|
||||
"expect": {
|
||||
"equals": {
|
||||
"size": 13
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"webhook": "my-faq-import",
|
||||
"before": [
|
||||
{
|
||||
"url": "http://localhost:8800/faq-reset",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8800/quarantine-clear",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8800/quality-log-clear",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8800/alerts-clear",
|
||||
"body": {}
|
||||
}
|
||||
],
|
||||
"after": [
|
||||
{
|
||||
"url": "http://localhost:8800/faq-reset",
|
||||
"body": {}
|
||||
}
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"name": "나쁜 배치는 통째로 거절 (422)",
|
||||
"request": "requests/faq-batch-1.json",
|
||||
"expect": {
|
||||
"status": 422,
|
||||
"equals": {
|
||||
"batch": "B-1"
|
||||
},
|
||||
"range": {
|
||||
"invalid_ratio": [
|
||||
0.3,
|
||||
1
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "배치 2 · 통과한 줄만 넣고 나쁜 줄은 격리",
|
||||
"request": "requests/faq-batch-2.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"rejected": false,
|
||||
"total": 10
|
||||
},
|
||||
"contains": {
|
||||
"imported_ids": "FAQ-201",
|
||||
"quarantined_ids": "FAQ-209"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "FAQ에 들어갔고 메일 주소는 안 들어감",
|
||||
"url": "http://localhost:8800/faq/list",
|
||||
"body": {},
|
||||
"expect": {
|
||||
"contains": {
|
||||
"faqs": "FAQ-208"
|
||||
},
|
||||
"not_contains": {
|
||||
"faqs": "security.desk"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "격리함에 이유와 함께",
|
||||
"url": "http://localhost:8800/quarantine/list",
|
||||
"body": {},
|
||||
"expect": {
|
||||
"contains": {
|
||||
"items": "\"pii\""
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "LLM · 제목과 답이 다른 줄 (FAQ-210)",
|
||||
"before": [
|
||||
{
|
||||
"url": "http://localhost:8800/faq-reset",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8800/quarantine-clear",
|
||||
"body": {}
|
||||
}
|
||||
],
|
||||
"request": "requests/faq-batch-2.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"imported": 8,
|
||||
"quarantined": 2,
|
||||
"codes.llm_mismatch": 1
|
||||
},
|
||||
"contains": {
|
||||
"quarantined_ids": "FAQ-210"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "LLM · 규칙에 걸린 줄은 그대로 (FAQ-305)",
|
||||
"request": "requests/faq-batch-3.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"imported": 5
|
||||
},
|
||||
"contains": {
|
||||
"quarantined_ids": "FAQ-305"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"webhook": "my-quality-report",
|
||||
"before": [
|
||||
{
|
||||
"url": "http://localhost:8800/faq-reset",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8800/quarantine-clear",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8800/quality-log-clear",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8800/alerts-clear",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"webhook": "my-faq-import",
|
||||
"request": "requests/faq-batch-1.json",
|
||||
"allow_fail": true
|
||||
},
|
||||
{
|
||||
"webhook": "my-faq-import",
|
||||
"request": "requests/faq-batch-2.json"
|
||||
},
|
||||
{
|
||||
"webhook": "my-faq-import",
|
||||
"request": "requests/faq-batch-3.json"
|
||||
},
|
||||
{
|
||||
"webhook": "my-faq-import",
|
||||
"request": "requests/faq-batch-2-fixed.json"
|
||||
}
|
||||
],
|
||||
"after": [
|
||||
{
|
||||
"url": "http://localhost:8800/faq-reset",
|
||||
"body": {}
|
||||
}
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"name": "배치 네 개 · 평균 · 가장 나쁜 배치",
|
||||
"body": {},
|
||||
"expect": {
|
||||
"equals": {
|
||||
"count": 4,
|
||||
"avg_score": 0.6583,
|
||||
"worst_batch": "B-1",
|
||||
"rejected_batches": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "가장 많은 이유 · 흐름",
|
||||
"body": {},
|
||||
"expect": {
|
||||
"equals": {
|
||||
"top_codes.0.code": "pii",
|
||||
"top_codes.0.count": 2,
|
||||
"trend": "좋아짐"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "배치마다 점수 순서",
|
||||
"body": {},
|
||||
"expect": {
|
||||
"equals": {
|
||||
"batches.1.score": 0.8,
|
||||
"batches.2.score": 0.8333,
|
||||
"batches.3.batch": "B-2-fix"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "다른 데이터셋으로 거르기",
|
||||
"body": {
|
||||
"dataset": "employee"
|
||||
},
|
||||
"expect": {
|
||||
"equals": {
|
||||
"count": 0,
|
||||
"avg_score": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"webhook": "my-quality-report",
|
||||
"before": [
|
||||
{
|
||||
"url": "http://localhost:8800/faq-reset",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8800/quarantine-clear",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8800/quality-log-clear",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8800/alerts-clear",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"webhook": "my-check",
|
||||
"body": "requests/employees-new.json"
|
||||
},
|
||||
{
|
||||
"webhook": "my-check",
|
||||
"body": "requests/tickets-new.json"
|
||||
},
|
||||
{
|
||||
"webhook": "my-faq-import",
|
||||
"body": "requests/faq-batch-3.json"
|
||||
}
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"name": "검사도 품질 기록 (직원 · 티켓 · FAQ)",
|
||||
"url": "http://localhost:8800/quality-log/list",
|
||||
"body": {},
|
||||
"expect": {
|
||||
"equals": {
|
||||
"count": 3,
|
||||
"items.0.dataset": "employee",
|
||||
"items.0.score": 0.4444,
|
||||
"items.1.dataset": "ticket",
|
||||
"items.2.dataset": "faq"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "데이터셋마다 나눠 보기",
|
||||
"body": {},
|
||||
"expect": {
|
||||
"equals": {
|
||||
"count": 3,
|
||||
"by_dataset.employee.avg_score": 0.4444,
|
||||
"by_dataset.ticket.avg_score": 0.2222,
|
||||
"by_dataset.faq.avg_score": 0.8333
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "가장 나쁜 데이터셋 · 그 이유",
|
||||
"body": {},
|
||||
"expect": {
|
||||
"equals": {
|
||||
"worst_dataset": "ticket",
|
||||
"by_dataset.employee.top_code": "bad_format:email"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "한 데이터셋만 (앞의 칸은 그대로)",
|
||||
"body": {
|
||||
"dataset": "ticket"
|
||||
},
|
||||
"expect": {
|
||||
"equals": {
|
||||
"count": 1,
|
||||
"worst_batch": "TK-1",
|
||||
"trend": "그대로"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"webhook": "my-faq-import",
|
||||
"before": [
|
||||
{
|
||||
"url": "http://localhost:8800/faq-reset",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8800/quarantine-clear",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8800/quality-log-clear",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8800/alerts-clear",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"webhook": "my-faq-import",
|
||||
"request": "requests/faq-batch-2.json"
|
||||
}
|
||||
],
|
||||
"after": [
|
||||
{
|
||||
"url": "http://localhost:8800/faq-reset",
|
||||
"body": {}
|
||||
}
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"name": "고친 두 줄 다시 가져오기",
|
||||
"request": "requests/faq-batch-2-fixed.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"imported": 2,
|
||||
"quarantined": 0,
|
||||
"score": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "격리함이 비었나",
|
||||
"url": "http://localhost:8800/quarantine/list",
|
||||
"body": {},
|
||||
"expect": {
|
||||
"equals": {
|
||||
"size": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "FAQ에 FAQ-209 · FAQ-210",
|
||||
"url": "http://localhost:8800/faq/list",
|
||||
"body": {},
|
||||
"expect": {
|
||||
"contains": {
|
||||
"faqs": "FAQ-210"
|
||||
},
|
||||
"equals": {
|
||||
"count": 16
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "품질 기록 · 점수 1",
|
||||
"url": "http://localhost:8800/quality-log/list",
|
||||
"body": {},
|
||||
"expect": {
|
||||
"equals": {
|
||||
"count": 2,
|
||||
"items.1.score": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"webhook": "my-check",
|
||||
"cases": [
|
||||
{
|
||||
"name": "스키마 목록에 ticket이 있다",
|
||||
"url": "http://localhost:8800/schemas/list",
|
||||
"body": {},
|
||||
"expect": {
|
||||
"contains": {
|
||||
"schemas": "ticket"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "새 스키마 ticket · 워크플로우는 그대로 · 9줄 중 2줄 통과",
|
||||
"request": "requests/tickets-new.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"schema": "ticket",
|
||||
"total": 9,
|
||||
"valid": 2,
|
||||
"rows.0.ok": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "형식 · 목록 · 날짜 · 길이",
|
||||
"request": "requests/tickets-new.json",
|
||||
"expect": {
|
||||
"contains": {
|
||||
"rows.1.errors": "bad_format:ticket_id",
|
||||
"rows.2.errors": "not_allowed:priority",
|
||||
"rows.3.errors": "bad_format:opened",
|
||||
"rows.4.errors": "too_short:title"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "선택 칸 형식 · 중복 · 빈 칸, 선택 칸이 없어도 통과",
|
||||
"request": "requests/tickets-new.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"rows.8.ok": true
|
||||
},
|
||||
"contains": {
|
||||
"rows.5.errors": "bad_format:contact",
|
||||
"rows.6.errors": "duplicate_in_batch",
|
||||
"rows.7.errors": "missing:requester"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "직원 CSV는 그대로 9줄 중 4줄",
|
||||
"request": "requests/employees-new.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"schema": "employee",
|
||||
"valid": 4
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"webhook": "my-check",
|
||||
"cases": [
|
||||
{
|
||||
"name": "직원 CSV · 9줄 중 4줄 통과",
|
||||
"request": "requests/employees-new.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"schema": "employee",
|
||||
"total": 9,
|
||||
"valid": 4,
|
||||
"rows.6.ok": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "직원 CSV · 줄마다 이유",
|
||||
"request": "requests/employees-new.json",
|
||||
"expect": {
|
||||
"contains": {
|
||||
"rows.2.errors": "bad_format:email",
|
||||
"rows.3.errors": "not_allowed:dept",
|
||||
"rows.4.errors": "bad_format:joined",
|
||||
"rows.5.errors": "missing:name",
|
||||
"rows.7.errors": "duplicate_in_batch"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "같은 워크플로우로 FAQ 스키마",
|
||||
"body": {
|
||||
"schema": "faq",
|
||||
"batch": "B-3",
|
||||
"items": [
|
||||
{
|
||||
"id": "FAQ-301",
|
||||
"title": "택배 수령",
|
||||
"keywords": [
|
||||
"택배"
|
||||
],
|
||||
"answer": "회사로 온 택배는 1층 안내 데스크에서 사원증을 보여 주고 받습니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-302",
|
||||
"title": "주차 등록",
|
||||
"keywords": [
|
||||
"주차"
|
||||
],
|
||||
"answer": "주차 등록은 총무팀 포털에서 차량 번호를 적어 신청합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-303",
|
||||
"title": "명함 신청",
|
||||
"keywords": [
|
||||
"명함"
|
||||
],
|
||||
"answer": "명함은 인사 포털에서 신청하면 일주일 안에 자리로 배달됩니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-304",
|
||||
"title": "사내 식당 메뉴",
|
||||
"keywords": [
|
||||
"식당",
|
||||
"메뉴"
|
||||
],
|
||||
"answer": "사내 식당 메뉴는 매주 월요일 아침 사내 게시판에 올라옵니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-305",
|
||||
"title": "교육 신청",
|
||||
"keywords": [
|
||||
"교육"
|
||||
],
|
||||
"answer": "짧"
|
||||
},
|
||||
{
|
||||
"id": "FAQ-306",
|
||||
"title": "건강검진",
|
||||
"keywords": [
|
||||
"건강검진"
|
||||
],
|
||||
"answer": "건강검진은 매년 9월에 인사팀이 안내하며 지정 병원에서 받습니다."
|
||||
}
|
||||
]
|
||||
},
|
||||
"expect": {
|
||||
"equals": {
|
||||
"schema": "faq",
|
||||
"total": 6,
|
||||
"valid": 5
|
||||
},
|
||||
"contains": {
|
||||
"rows.4.errors": "too_short:answer"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "모르는 스키마 → 400",
|
||||
"body": {
|
||||
"schema": "invoice",
|
||||
"items": []
|
||||
},
|
||||
"expect": {
|
||||
"status": 400
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# Sample company data API for day 78 (day 77 API + quarantine + schemas + quality log), attached to the day 65 n8n network
|
||||
# (n8n installed in ~/day65-n8n, Docker project day65-n8n, network day65-n8n_default).
|
||||
# docker compose up -d (run from this folder, after day 65 n8n is running)
|
||||
# from n8n : http://data-api:8800
|
||||
# from WSL : http://localhost:8800
|
||||
name: day67-data
|
||||
|
||||
services:
|
||||
data-api:
|
||||
image: python:3.12-slim
|
||||
command: python /app/server.py
|
||||
environment:
|
||||
- DATA_DIR=/data
|
||||
- PYTHONUNBUFFERED=1
|
||||
- TZ=KST-9 # live run logs get Korean time (class day), not UTC
|
||||
ports:
|
||||
- "8800:8800"
|
||||
volumes:
|
||||
- ./server.py:/app/server.py:ro
|
||||
- ./data:/data:ro
|
||||
networks:
|
||||
- n8n
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
n8n:
|
||||
external: true
|
||||
name: day65-n8n_default
|
||||
@@ -0,0 +1 @@
|
||||
{"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500}
|
||||
@@ -0,0 +1,17 @@
|
||||
# 업무용 장비 지급·반납 규정
|
||||
|
||||
## 1. 지급과 교체
|
||||
- 업무용 노트북은 입사일에 지급한다.
|
||||
- 노트북 교체 주기는 4년이다. 고장이나 배터리 교체는 IT 문의로 접수한다.
|
||||
|
||||
## 2. 분실과 도난
|
||||
- 업무용 장비를 잃어버리거나 도난당하면 24시간 이내에 IT 문의와 보안팀에 신고한다.
|
||||
- 신고가 접수되면 IT 담당자가 원격으로 장비의 데이터를 잠근다.
|
||||
|
||||
## 3. 반납
|
||||
- 퇴사하거나 장비를 교체할 때는 5영업일 이내에 장비를 IT 담당자에게 반납한다.
|
||||
- 반납 전에 개인 파일을 지우지 말고, 업무 파일은 사내 드라이브로 옮긴다.
|
||||
|
||||
## 4. 개인 장비 사용
|
||||
- 개인 휴대폰이나 태블릿으로는 업무 메일과 메신저만 쓸 수 있다.
|
||||
- 개인 장비로 업무 메일을 보려면 회사의 모바일 관리 앱(MDM)을 먼저 설치한다.
|
||||
@@ -0,0 +1,14 @@
|
||||
# 메일·문서 보안 규정
|
||||
|
||||
## 1. 메일함
|
||||
- 개인 메일함 용량은 10GB이다. 용량이 90%를 넘으면 안내 메일이 발송된다.
|
||||
- 오래된 메일은 보관함으로 옮겨 용량을 확보한다.
|
||||
|
||||
## 2. 첨부 파일
|
||||
- 메일 첨부 파일은 25MB까지 보낼 수 있다.
|
||||
- 25MB를 넘는 파일은 사내 드라이브에 올리고 공유 링크를 보낸다.
|
||||
- 개인정보가 들어 있는 파일을 외부로 보낼 때는 반드시 암호를 걸고, 암호는 메신저 등 다른 경로로 알려 준다.
|
||||
|
||||
## 3. 문서 등급과 보관
|
||||
- 대외비 문서에는 문서 첫 장에 '대외비'라고 표시한다.
|
||||
- 업무 문서는 3년 동안 보관한 뒤 정리한다.
|
||||
@@ -0,0 +1,12 @@
|
||||
# 회의실 이용 규정
|
||||
|
||||
## 1. 예약
|
||||
- 회의실은 사내 포털에서 예약한다. 한 번에 최대 3시간까지 예약할 수 있다.
|
||||
- 예약 시작 후 10분이 지나도록 아무도 사용하지 않으면 예약이 자동으로 취소된다.
|
||||
|
||||
## 2. 장비
|
||||
- 대회의실의 화상회의 장비를 쓰려면 회의 시작 30분 전까지 IT 문의로 요청한다.
|
||||
- 프로젝터는 B회의실과 대회의실에 있다.
|
||||
|
||||
## 3. 사용 후
|
||||
- 회의가 끝나면 화이트보드를 지우고 의자를 정리한다.
|
||||
@@ -0,0 +1,18 @@
|
||||
# 비밀번호·계정 보안 규정
|
||||
|
||||
## 1. 비밀번호 만들기
|
||||
- 비밀번호는 12자 이상으로 만든다.
|
||||
- 영문 대문자, 영문 소문자, 숫자, 특수문자 중 3종류 이상을 섞는다.
|
||||
- 이름, 생일, 전화번호, 회사 이름처럼 추측하기 쉬운 단어는 쓰지 않는다.
|
||||
|
||||
## 2. 변경 주기
|
||||
- 비밀번호는 90일마다 바꾼다. 만료 7일 전부터 사내 포털에 변경 안내가 표시된다.
|
||||
- 최근에 쓴 비밀번호 5개는 다시 쓸 수 없다.
|
||||
|
||||
## 3. 로그인 실패와 잠금
|
||||
- 로그인에 5번 연속 실패하면 계정이 30분 동안 잠긴다.
|
||||
- 30분이 지나면 자동으로 풀린다. 급한 경우 IT 문의로 잠금 해제를 요청한다.
|
||||
|
||||
## 4. 다단계 인증
|
||||
- 회사 밖에서 사내 시스템에 접속할 때는 OTP 다단계 인증을 반드시 사용한다.
|
||||
- OTP 앱이 설치된 휴대폰을 바꾸면 IT 포털에서 OTP를 다시 등록한다.
|
||||
@@ -0,0 +1,13 @@
|
||||
# 개인정보 처리 지침
|
||||
|
||||
## 1. 수집
|
||||
- 업무에 꼭 필요한 개인정보만 수집한다.
|
||||
- 주민등록번호는 법에서 정한 경우가 아니면 수집하지 않는다.
|
||||
|
||||
## 2. 이용과 보관
|
||||
- 고객 개인정보가 보이는 화면은 캡처하거나 촬영하지 않는다.
|
||||
- 개인정보가 들어 있는 출력물은 다 쓴 뒤 바로 파쇄한다.
|
||||
|
||||
## 3. 유출 대응
|
||||
- 개인정보가 유출되었거나 유출이 의심되면 1시간 이내에 보안팀에 신고한다.
|
||||
- 신고할 때는 유출된 정보의 종류, 대략적인 건수, 발견한 시각을 함께 알린다.
|
||||
@@ -0,0 +1,15 @@
|
||||
# 원격근무·VPN 이용 규정
|
||||
|
||||
## 1. 접속 원칙
|
||||
- 회사 밖에서 사내 시스템에 접속할 때는 반드시 회사 VPN을 먼저 연결한다.
|
||||
- 카페, 공항 같은 공용 와이파이에서는 VPN 없이 사내 시스템에 접속하지 않는다.
|
||||
|
||||
## 2. VPN 인증서
|
||||
- VPN 인증서의 유효 기간은 1년이다.
|
||||
- 만료 30일 전부터 IT 포털의 '인증서 재발급' 메뉴에서 새 인증서를 신청한다.
|
||||
- 인증서가 만료되면 VPN에 접속할 수 없으므로, 만료 전에 재발급한다.
|
||||
|
||||
## 3. 재택근무 중 보안
|
||||
- 자리를 비울 때는 화면을 잠그고, 5분 동안 사용하지 않으면 자동으로 잠기게 설정한다.
|
||||
- 가족과 함께 쓰는 컴퓨터로는 업무를 하지 않는다.
|
||||
- VPN 접속 기록은 1년 동안 보관된다.
|
||||
@@ -0,0 +1,11 @@
|
||||
# 보안 사고 대응 절차
|
||||
|
||||
## 1. 피싱 메일
|
||||
- 보낸 사람이 의심스럽거나 급하게 클릭을 요구하는 메일은 링크를 누르지 않는다.
|
||||
- 메일 화면의 '피싱 신고' 버튼으로 신고한다.
|
||||
- 이미 링크를 눌렀거나 비밀번호를 입력했다면 즉시 비밀번호를 바꾸고 IT 문의로 알린다.
|
||||
|
||||
## 2. 악성코드 감염 의심
|
||||
- 컴퓨터가 갑자기 느려지거나 모르는 프로그램이 실행되면 악성코드 감염을 의심한다.
|
||||
- 감염이 의심되면 먼저 랜선을 뽑고 와이파이를 꺼서 네트워크에서 분리한 뒤 IT 문의로 알린다.
|
||||
- 컴퓨터를 끄거나 파일을 지우지 않는다. 원인 분석에 필요하다.
|
||||
@@ -0,0 +1,14 @@
|
||||
# 소프트웨어 사용 규정
|
||||
|
||||
## 1. 설치와 승인
|
||||
- 업무용 컴퓨터에는 승인된 소프트웨어만 설치한다.
|
||||
- 새 소프트웨어가 필요하면 IT 포털의 '소프트웨어 신청'에서 요청한다. 승인까지 보통 2영업일이 걸린다.
|
||||
- 무료 소프트웨어라도 회사 업무에 쓰려면 상업적 이용이 허용되는지 라이선스를 확인한다.
|
||||
|
||||
## 2. 금지 사항
|
||||
- 불법으로 복제한 소프트웨어는 설치하지 않는다. 적발되면 징계 대상이 된다.
|
||||
|
||||
## 3. 생성형 AI 서비스
|
||||
- 회사가 승인한 생성형 AI 서비스만 업무에 사용한다.
|
||||
- 고객 정보, 개인정보, 대외비 문서는 생성형 AI 서비스에 입력하지 않는다.
|
||||
- 생성형 AI가 만든 결과는 사람이 확인한 뒤에 사용한다.
|
||||
@@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"name": "이준호",
|
||||
"dept": "영업팀"
|
||||
},
|
||||
{
|
||||
"name": "박서연",
|
||||
"dept": "재무팀"
|
||||
},
|
||||
{
|
||||
"name": "최유진",
|
||||
"dept": "인사팀"
|
||||
},
|
||||
{
|
||||
"name": "한지훈",
|
||||
"dept": "개발팀"
|
||||
},
|
||||
{
|
||||
"name": "김민지",
|
||||
"dept": "IT팀"
|
||||
},
|
||||
{
|
||||
"name": "정우성",
|
||||
"dept": "IT팀"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{"id": "FAQ-001", "title": "비밀번호 재설정", "keywords": ["비밀번호", "패스워드", "password", "재설정", "초기화"],
|
||||
"answer": "사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정합니다."},
|
||||
{"id": "FAQ-002", "title": "계정 잠김 해제", "keywords": ["계정", "잠김", "잠금", "로그인", "실패"],
|
||||
"answer": "로그인 5회 실패 시 30분 동안 잠깁니다. 30분 후 다시 시도하거나 헬프데스크에 잠금 해제를 요청합니다."},
|
||||
{"id": "FAQ-003", "title": "VPN 접속 오류", "keywords": ["vpn", "원격", "재택", "접속", "연결"],
|
||||
"answer": "VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속합니다."},
|
||||
{"id": "FAQ-004", "title": "프린터 출력 안 됨", "keywords": ["프린터", "출력", "인쇄", "용지"],
|
||||
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력합니다."},
|
||||
{"id": "FAQ-005", "title": "메일함 용량 초과", "keywords": ["메일", "용량", "메일함", "첨부"],
|
||||
"answer": "오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제해 용량을 확보합니다."},
|
||||
{"id": "FAQ-006", "title": "소프트웨어 설치 요청", "keywords": ["설치", "소프트웨어", "프로그램", "라이선스"],
|
||||
"answer": "IT 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."}
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"currency": "USD",
|
||||
"unit": "per 1M tokens",
|
||||
"note": "OpenAI 모델 문서의 공개 단가 (2026-10-28 확인). 수업 전에 가격표를 확인해 바꾸세요.",
|
||||
"models": {
|
||||
"gpt-4.1-mini": {"input": 0.40, "output": 1.60},
|
||||
"gpt-5.4-nano": {"input": 0.20, "output": 1.25}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say "찾지 못했습니다" and that a staff member will help. Use 합니다체.
|
||||
@@ -0,0 +1,2 @@
|
||||
You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say "찾지 못했습니다" and that a staff member will help. Use 합니다체.
|
||||
When the answer comes from a FAQ entry, end with its id in brackets, for example [FAQ-003].
|
||||
@@ -0,0 +1 @@
|
||||
You are the company IT help desk. Keep every answer to one short Korean sentence. Do not repeat the steps from the data; tell the employee to check the IT portal instead.
|
||||
@@ -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": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
]
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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"}}
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
"""Sample company data API for the day 78 labs (day 77 API + quarantine + schemas + quality log;
|
||||
Python standard library only).
|
||||
|
||||
GET /health -> {"status": "ok"} (never affected by the fault switch)
|
||||
GET /faq?q=<question> -> {"query": ..., "results": [FAQ entries]}
|
||||
GET /tickets/<id> -> ticket record, or 404 {"error": "ticket not found"}
|
||||
GET /tickets/<id>?requester=<name>
|
||||
-> same, but 403 {"error": "not your ticket"} when the ticket belongs to someone else
|
||||
GET /employees -> {"employees": [{"name", "dept"}]}
|
||||
GET /employees/<name> -> {"name", "dept"}, or 404 {"error": "unknown employee"}
|
||||
GET /docs -> {"docs": [{"name", "title"}]} company IT policy documents (markdown)
|
||||
GET /docs/<name> -> {"name", "title", "text"}, or 404 {"error": "doc not found"}
|
||||
|
||||
Day 72 additions (state lives in memory; restarting the container resets it):
|
||||
GET /admin/fault -> {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "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> -> {"key", "value", "hits"} or 404 {"error": "not cached"}
|
||||
POST /cache/<key> body {"value": <anything>, "ttl": 600} (PUT works too)
|
||||
POST /cache-clear -> {"cleared": <count>}
|
||||
GET /cache-stats -> {"entries", "hits", "misses"}
|
||||
|
||||
Day 73 addition (in memory, newest 1000 kept):
|
||||
POST /audit body {"route", "blocked_by", "reason", "masked", ...} -> {"stored": true, "count"}
|
||||
GET /audit -> {"events": [{"t", ...the body}]}
|
||||
POST /audit-clear -> {"cleared": <count>}
|
||||
|
||||
Day 74 additions (prompt registry from data/prompts, releases from data/releases.json, state in memory):
|
||||
GET /prompts/<name> -> {"name", "versions": ["p1", ...]}
|
||||
GET /prompts/<name>/<ver> -> {"name", "version", "text"}, or 404 {"error": "prompt not found"} (fault switch "prompts")
|
||||
GET /releases/<name> -> {"name", "active", "canary": null | {"release", "percent"}, "require_gate": false, "releases": {...}}
|
||||
POST /releases/<name> body {"active": "1.1.0"} and/or {"canary": {"release": "1.2.0", "percent": 50}} or {"canary": null}
|
||||
When require_gate is on, a release can become active or canary only if its latest gate
|
||||
record says "promote"; otherwise 409 {"error": "not gated", "release", "last_gate"}.
|
||||
POST /releases/<name>/policy body {"require_gate": true|false}
|
||||
POST /releases/<name>/reset -> back to data/releases.json (active, no canary, require_gate off; gate records stay)
|
||||
POST /gates/<name> body {"release", "verdict": "promote"|"rollback", "passed", "total", "source", ...}
|
||||
-> {"stored": true, "count"}
|
||||
GET /gates/<name> -> {"name", "gates": [{"t", ...the body}]} (newest last, 200 kept)
|
||||
POST /gates/<name>/clear -> {"cleared": <count>}
|
||||
|
||||
Day 75 additions (usage and budget in memory):
|
||||
GET /prices -> {"currency", "unit", "models": {"gpt-4.1-mini": {"input", "output"}, ...}} (USD per 1M tokens)
|
||||
POST /usage body {"items": [{"request_id", "user", "step", "model", "prompt_tokens",
|
||||
"completion_tokens", "cost_usd", "route"}]} (one item per LLM call, or one item
|
||||
without "items") -> {"stored": n, "total"} (fault switch "usage": error -> 500,
|
||||
slow -> waits slow_seconds, then 503 and nothing is stored)
|
||||
GET /usage/summary -> {"requests" (distinct request_id), "calls", "prompt_tokens", "completion_tokens",
|
||||
"cost_usd", "by_model", "by_user", "by_route"} (POST works too)
|
||||
GET /usage/user/<name>?window=60 -> {"user", "window", "count"} distinct requests of that user in the last N seconds
|
||||
POST /usage-clear -> {"cleared": n}
|
||||
GET /budget -> {"daily_usd", "used_usd", "remaining_usd", "over", "user_limit_per_min", "max_question_chars"}
|
||||
POST /budget body {"daily_usd": 0.5} and/or {"user_limit_per_min": 3} and/or {"max_question_chars": 500}
|
||||
POST /budget/reset -> back to data/budget.json
|
||||
|
||||
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/<name> -> 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
|
||||
"""
|
||||
|
||||
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 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":
|
||||
with LOCK:
|
||||
return self.send_json(200, summary())
|
||||
if url.path.startswith("/usage/user/"):
|
||||
name = unquote(url.path.removeprefix("/usage/user/"))
|
||||
window = float(parse_qs(url.query).get("window", ["60"])[0])
|
||||
now = time.time()
|
||||
with LOCK:
|
||||
count = len({item.get("request_id") or f"item-{i}" for i, item in enumerate(USAGE)
|
||||
if item.get("user") == name and now - item["at"] <= window})
|
||||
return self.send_json(200, {"user": name, "window": window, "count": count})
|
||||
if url.path == "/budget":
|
||||
with LOCK:
|
||||
return self.send_json(200, budget())
|
||||
if url.path == "/cache-stats":
|
||||
with LOCK:
|
||||
return self.send_json(200, {"entries": len(CACHE), **STATS})
|
||||
if url.path.startswith("/cache/"):
|
||||
key = unquote(url.path.removeprefix("/cache/"))
|
||||
with LOCK:
|
||||
entry = CACHE.get(key)
|
||||
if entry and entry["expires"] > time.time():
|
||||
entry["hits"] += 1
|
||||
STATS["hits"] += 1
|
||||
return self.send_json(200, {"key": key, "value": entry["value"], "hits": entry["hits"]})
|
||||
CACHE.pop(key, None)
|
||||
STATS["misses"] += 1
|
||||
return self.send_json(404, {"error": "not cached", "key": key})
|
||||
if url.path == "/faq":
|
||||
if self.faulty("faq"):
|
||||
return None
|
||||
query = parse_qs(url.query).get("q", [""])[0]
|
||||
return self.send_json(200, {"query": query, "results": search_faq(query)})
|
||||
if url.path == "/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 == "/audit":
|
||||
with LOCK:
|
||||
AUDIT.append({"t": round(time.time(), 3), **body})
|
||||
del AUDIT[:-1000]
|
||||
count = len(AUDIT)
|
||||
return self.send_json(200, {"stored": True, "count": count})
|
||||
if url.path == "/usage":
|
||||
if FAULT.get("usage") == "slow":
|
||||
# a hung record service: answers late and keeps nothing (a caller that gave up must not be counted later)
|
||||
time.sleep(float(FAULT.get("slow_seconds", 8)))
|
||||
return self.send_json(503, {"error": "usage service timed out (fault switch)", "stored": 0})
|
||||
if self.faulty("usage"):
|
||||
return None
|
||||
items = body.get("items") if isinstance(body.get("items"), list) else [body]
|
||||
with LOCK:
|
||||
for item in items:
|
||||
USAGE.append({**item, "at": time.time()})
|
||||
return self.send_json(200, {"stored": len(items), "total": len(USAGE)})
|
||||
if url.path == "/usage/summary":
|
||||
with LOCK:
|
||||
return self.send_json(200, summary())
|
||||
if url.path == "/usage-clear":
|
||||
with LOCK:
|
||||
count = len(USAGE)
|
||||
USAGE.clear()
|
||||
return self.send_json(200, {"cleared": count})
|
||||
if url.path in ("/budget", "/budget/reset"):
|
||||
with LOCK:
|
||||
if url.path == "/budget/reset":
|
||||
BUDGET.clear()
|
||||
budget()
|
||||
for key, kind in BUDGET_KEYS.items():
|
||||
if key in body:
|
||||
BUDGET[key] = kind(body[key])
|
||||
return self.send_json(200, budget())
|
||||
if url.path == "/audit-clear":
|
||||
with LOCK:
|
||||
count = len(AUDIT)
|
||||
AUDIT.clear()
|
||||
return self.send_json(200, {"cleared": count})
|
||||
if url.path == "/cache-clear":
|
||||
with LOCK:
|
||||
count = len(CACHE)
|
||||
CACHE.clear()
|
||||
STATS.update(hits=0, misses=0)
|
||||
return self.send_json(200, {"cleared": count})
|
||||
if url.path.startswith("/cache/"):
|
||||
key = unquote(url.path.removeprefix("/cache/"))
|
||||
if not key or "value" not in body:
|
||||
return self.send_json(400, {"error": "key in the path and value in the body are required"})
|
||||
ttl = max(1.0, min(float(body.get("ttl", 600)), 86400.0))
|
||||
with LOCK:
|
||||
CACHE[key] = {"value": body["value"], "expires": time.time() + ttl, "hits": 0}
|
||||
return self.send_json(200, {"key": key, "stored": True, "ttl": ttl})
|
||||
if url.path.startswith("/gates/"):
|
||||
name = unquote(url.path.removeprefix("/gates/")).strip("/")
|
||||
clear = name.endswith("/clear")
|
||||
name = name.removesuffix("/clear")
|
||||
with LOCK:
|
||||
if clear:
|
||||
count = len(GATES.pop(name, []))
|
||||
return self.send_json(200, {"cleared": count})
|
||||
if not body.get("release") or body.get("verdict") not in ("promote", "rollback"):
|
||||
return self.send_json(400, {"error": "release and verdict (promote or rollback) are required"})
|
||||
records = GATES.setdefault(name, [])
|
||||
records.append({"t": round(time.time(), 3), **body})
|
||||
del records[:-200]
|
||||
return self.send_json(200, {"stored": True, "count": len(records)})
|
||||
if url.path.startswith("/releases/"):
|
||||
name = unquote(url.path.removeprefix("/releases/")).strip("/")
|
||||
action = ""
|
||||
for suffix in ("/reset", "/policy"):
|
||||
if name.endswith(suffix):
|
||||
action, name = suffix[1:], name.removesuffix(suffix)
|
||||
with LOCK:
|
||||
if action == "reset":
|
||||
RELEASES.pop(name, None)
|
||||
item = releases().get(name)
|
||||
if item is None:
|
||||
return self.send_json(404, {"error": "unknown release name", "name": name})
|
||||
if action == "reset":
|
||||
return self.send_json(200, {"name": name, **item})
|
||||
if action == "policy":
|
||||
if not isinstance(body.get("require_gate"), bool):
|
||||
return self.send_json(400, {"error": "require_gate must be true or false"})
|
||||
item["require_gate"] = body["require_gate"]
|
||||
return self.send_json(200, {"name": name, **item})
|
||||
wanted = []
|
||||
if "active" in body:
|
||||
if body["active"] not in item["releases"]:
|
||||
return self.send_json(400, {"error": "unknown release", "known": sorted(item["releases"])})
|
||||
wanted.append(body["active"])
|
||||
canary = body.get("canary")
|
||||
if "canary" in body and canary is not None:
|
||||
if not isinstance(canary, dict) or canary.get("release") not in item["releases"] \
|
||||
or not 0 <= int(canary.get("percent", -1)) <= 100:
|
||||
return self.send_json(400, {"error": "canary needs a known release and percent 0..100"})
|
||||
canary = {"release": canary["release"], "percent": int(canary["percent"])}
|
||||
wanted.append(canary["release"])
|
||||
if item["require_gate"]:
|
||||
for release in wanted:
|
||||
gate = last_gate(name, release)
|
||||
if not gate or gate.get("verdict") != "promote":
|
||||
return self.send_json(409, {"error": "not gated", "release": release, "last_gate": gate})
|
||||
if "active" in body:
|
||||
item["active"] = body["active"]
|
||||
if "canary" in body:
|
||||
item["canary"] = canary
|
||||
return self.send_json(200, {"name": name, **item})
|
||||
return self.send_json(404, {"error": "unknown path"})
|
||||
|
||||
do_PUT = do_POST
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
print(f"{self.address_string()} {fmt % args}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"data-api listening on :{PORT} (data: {DATA_DIR})", flush=True)
|
||||
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
|
||||
@@ -0,0 +1,6 @@
|
||||
# 수업 결과물
|
||||
|
||||
- CLI 코딩 에이전트가 만든 워크플로우 JSON (`faq-check.json`, `faq-import.json`, `check.json`, `quality-report.json`)이 여기에 저장됩니다 (git에 올라가지 않음).
|
||||
- `QUALITY.md`: [품질 규칙표](../templates/QUALITY-RULES-TEMPLATE.md)를 복사해 채웁니다.
|
||||
- `PROMPTS.md`: 오늘 쓴 요청문을 모아 둡니다.
|
||||
- `TEST-REPORT.md`: [테스트 보고서](../templates/TEST-REPORT-TEMPLATE.md) (테스트 13개의 결과 · 실패와 고친 내용).
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"name": "78 FAQ check (team baseline)",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Check webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "base-faq-check",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
0,
|
||||
300
|
||||
],
|
||||
"id": "154bbb29-0cd9-55c8-a318-872a5690cb25",
|
||||
"webhookId": "879f1ed6-94a8-57f2-9cc3-8dfa0880a80a"
|
||||
},
|
||||
{
|
||||
"name": "Check rows",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const body = $('Check webhook').first().json.body;\n// team baseline: only empty values (written in a hurry last month)\nconst rows = body.items.map((r) => {\n const errors = [];\n if (!r.title) errors.push('title_missing');\n if (!r.answer) errors.push('answer_missing');\n if (!r.keywords || r.keywords.length === 0) errors.push('keywords_missing');\n return { id: String(r.id ?? ''), ok: errors.length === 0, errors, warnings: [] };\n});\nconst codes = {};\nfor (const row of rows) for (const e of row.errors) codes[e] = (codes[e] ?? 0) + 1;\nconst valid = rows.filter((r) => r.ok).length;\nreturn [{ json: { batch: body.batch ?? '', total: rows.length, valid, invalid: rows.length - valid, rows, codes } }];"
|
||||
},
|
||||
"position": [
|
||||
220,
|
||||
300
|
||||
],
|
||||
"id": "072e3548-3d37-52bc-816a-4f5e6c8e795b"
|
||||
},
|
||||
{
|
||||
"name": "Send result",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ $json }}",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"id": "4707a40b-6783-5666-bb73-227a848955ac"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Check webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Check rows",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Check rows": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"name": "78 FAQ import (team baseline)",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Import webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "base-faq-import",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
0,
|
||||
300
|
||||
],
|
||||
"id": "43f87175-3a0a-573c-b690-db9ee8f98200",
|
||||
"webhookId": "14d69cee-d6ad-5163-b231-403811afe39d"
|
||||
},
|
||||
{
|
||||
"name": "Split rows",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const body = $('Import webhook').first().json.body;\n// team baseline: put every row in, the data API will complain if something is really wrong\nreturn body.items.map((row) => ({ json: { row, batch: body.batch ?? '' } }));"
|
||||
},
|
||||
"position": [
|
||||
220,
|
||||
300
|
||||
],
|
||||
"id": "a9733297-069f-5009-8455-7dbe7ca59923"
|
||||
},
|
||||
{
|
||||
"name": "Add FAQ",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/faq/add",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"method": "POST",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify($json.row) }}"
|
||||
},
|
||||
"position": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"id": "f2026680-4cfd-551d-bc90-827b8a136c3f"
|
||||
},
|
||||
{
|
||||
"name": "Summarize",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const rows = $('Split rows').all().map((i) => i.json);\nconst answers = $('Add FAQ').all().map((i) => i.json);\nconst imported = rows.filter((r, i) => answers[i]?.added !== undefined);\nreturn [{ json: { batch: rows[0]?.batch ?? '', total: rows.length, imported: imported.length, quarantined: 0, rejected: false,\n score: rows.length ? Math.round(imported.length / rows.length * 10000) / 10000 : 0,\n imported_ids: imported.map((r) => String(r.row.id)), quarantined_ids: [] } }];"
|
||||
},
|
||||
"position": [
|
||||
660,
|
||||
300
|
||||
],
|
||||
"id": "e758bb72-6ab3-577f-b05f-46e5c117d634"
|
||||
},
|
||||
{
|
||||
"name": "Send result",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ $json }}",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
880,
|
||||
300
|
||||
],
|
||||
"id": "42bdf7fe-b73c-5c8c-b599-277a48f829b0"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Import webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Split rows",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Split rows": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Add FAQ",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Add FAQ": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Summarize",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Summarize": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"name": "78 FAQ check (reference)",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Check webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "my-faq-check",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
0,
|
||||
300
|
||||
],
|
||||
"id": "7f191b41-c9cb-53ba-8811-77f4c563ae66",
|
||||
"webhookId": "32a0de1e-2479-5dbe-8d14-3d95f1e3cb9c"
|
||||
},
|
||||
{
|
||||
"name": "Is a list?",
|
||||
"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.items) }}",
|
||||
"rightValue": "",
|
||||
"operator": {
|
||||
"type": "boolean",
|
||||
"operation": "true",
|
||||
"singleValue": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
220,
|
||||
300
|
||||
],
|
||||
"id": "a43d0f21-b799-5552-a9e0-34296447da2f"
|
||||
},
|
||||
{
|
||||
"name": "Reject body",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "{ \"error\": \"items must be a list\" }",
|
||||
"options": {
|
||||
"responseCode": 400
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
440,
|
||||
560
|
||||
],
|
||||
"id": "7baa4654-ee8f-597e-887f-ff49602a8280"
|
||||
},
|
||||
{
|
||||
"name": "Check rows",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const body = $('Check webhook').first().json.body;\nconst seen = new Set();\nconst rows = body.items.map((r) => {\n const errors = [], warnings = [];\n const id = String(r?.id ?? '');\n const title = String(r?.title ?? '').trim();\n const answer = String(r?.answer ?? '').trim();\n if (!/^FAQ-\\d{3}$/.test(id)) errors.push('id_format');\n if (!title) errors.push('title_missing'); else if (title.length > 40) errors.push('title_too_long');\n if (!Array.isArray(r?.keywords) || r.keywords.length === 0) errors.push('keywords_missing');\n if (!answer) errors.push('answer_missing'); else if (answer.length < 10) errors.push('answer_too_short'); else if (answer.length > 300) errors.push('answer_too_long');\n if (seen.has(id)) errors.push('duplicate_in_batch');\n seen.add(id);\n if (/01[016789][-. ]?\\d{3,4}[-. ]?\\d{4}/.test(answer) || /[\\w.+-]+@[\\w-]+\\.[\\w.]+/.test(answer)) errors.push('pii');\n if (/https?:\\/\\/(?!intra\\.example\\.com(?![\\w.-]))/i.test(answer)) errors.push('external_link');\n return { id, ok: errors.length === 0, errors, warnings };\n});\nconst codes = {};\nfor (const row of rows) for (const e of row.errors) codes[e] = (codes[e] ?? 0) + 1;\nconst valid = rows.filter((r) => r.ok).length;\nconst out = { batch: body.batch ?? '', total: rows.length, valid, invalid: rows.length - valid,\n with_warnings: rows.filter((r) => r.warnings.length).length, rows, codes };\nreturn [{ json: out }];"
|
||||
},
|
||||
"position": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"id": "143f98e1-71b7-5da7-b547-7fd5f9516f92"
|
||||
},
|
||||
{
|
||||
"name": "Send result",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ $json }}",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
660,
|
||||
300
|
||||
],
|
||||
"id": "c47d329d-d69b-562e-8383-2538c1c9adf1"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Is a list?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Check rows",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Reject body",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Check rows": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Check webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Is a list?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
{
|
||||
"name": "78 FAQ import + gate (reference)",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Import webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "my-faq-import",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
0,
|
||||
300
|
||||
],
|
||||
"id": "a56860cf-55b7-5565-b18d-ca3e5e9abe79",
|
||||
"webhookId": "f3d72461-ee99-519a-a141-33709a703108"
|
||||
},
|
||||
{
|
||||
"name": "Run check",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://localhost:5678/webhook/my-faq-check",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
},
|
||||
"timeout": 30000
|
||||
},
|
||||
"method": "POST",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify($json.body) }}"
|
||||
},
|
||||
"position": [
|
||||
220,
|
||||
300
|
||||
],
|
||||
"id": "e9b3fe87-c7c3-5eaf-897f-45406aefb704"
|
||||
},
|
||||
{
|
||||
"name": "Checked?",
|
||||
"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.rows) && $json.rows.length > 0 }}",
|
||||
"rightValue": "",
|
||||
"operator": {
|
||||
"type": "boolean",
|
||||
"operation": "true",
|
||||
"singleValue": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"id": "78c41fec-cc62-5ce7-9c47-ae10eedd7245"
|
||||
},
|
||||
{
|
||||
"name": "Reject body",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ JSON.stringify({ error: $json.error ?? 'check failed' }) }}",
|
||||
"options": {
|
||||
"responseCode": 400
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
660,
|
||||
760
|
||||
],
|
||||
"id": "57d70716-121e-589f-9617-31756fb52264"
|
||||
},
|
||||
{
|
||||
"name": "Batch ok?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.2,
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "loose",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "c1",
|
||||
"leftValue": "={{ $json.invalid / $json.total <= ($('Import webhook').first().json.body.max_invalid_ratio ?? 0.3) }}",
|
||||
"rightValue": "",
|
||||
"operator": {
|
||||
"type": "boolean",
|
||||
"operation": "true",
|
||||
"singleValue": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
660,
|
||||
300
|
||||
],
|
||||
"id": "a60e6dbb-88a3-59b1-9a97-6efe355e18f1"
|
||||
},
|
||||
{
|
||||
"name": "Reject batch",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ JSON.stringify({ error: 'batch rejected', batch: $('Run check').first().json.batch, invalid_ratio: Math.round($('Run check').first().json.invalid / $('Run check').first().json.total * 10000) / 10000, max_invalid_ratio: ($('Import webhook').first().json.body.max_invalid_ratio ?? 0.3), codes: $('Run check').first().json.codes }) }}",
|
||||
"options": {
|
||||
"responseCode": 422
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
880,
|
||||
560
|
||||
],
|
||||
"id": "a0faba36-ffdc-5d7c-a3ad-27655254174b"
|
||||
},
|
||||
{
|
||||
"name": "Split rows",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const c = $('Run check').first().json;\nconst items = $('Import webhook').first().json.body.items;\nreturn c.rows.map((r, i) => ({ json: { ...r, row: items[i], batch: c.batch } }));"
|
||||
},
|
||||
"position": [
|
||||
880,
|
||||
300
|
||||
],
|
||||
"id": "666c96f6-be31-54d9-a0dc-924fb089b786"
|
||||
},
|
||||
{
|
||||
"name": "Decide",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "return $input.all();"
|
||||
},
|
||||
"position": [
|
||||
1100,
|
||||
300
|
||||
],
|
||||
"id": "8e4f7788-d9d4-513c-8702-1a4d0218faa5"
|
||||
},
|
||||
{
|
||||
"name": "Store row",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "={{ $json.ok ? 'http://data-api:8800/faq/add' : 'http://data-api:8800/quarantine' }}",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"method": "POST",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify($json.ok ? $json.row : { items: [{ batch: $json.batch, row: $json.row, errors: $json.errors }] }) }}"
|
||||
},
|
||||
"position": [
|
||||
1320,
|
||||
300
|
||||
],
|
||||
"id": "e35c6cb5-7cc0-5d07-8d38-68d6f7a116d4"
|
||||
},
|
||||
{
|
||||
"name": "Summarize",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const c = $('Run check').first().json;\nconst rows = $('Decide').all().map((i) => i.json);\nconst imported = rows.filter((r) => r.ok);\nconst quarantined = rows.filter((r) => !r.ok);\nconst codes = { ...c.codes };\nfor (const r of quarantined) for (const e of r.errors) if (e === 'llm_mismatch') codes[e] = (codes[e] ?? 0) + 1;\nreturn [{ json: { batch: c.batch, total: c.total, imported: imported.length, quarantined: quarantined.length, rejected: false,\n score: Math.round(imported.length / c.total * 10000) / 10000, max_invalid_ratio: ($('Import webhook').first().json.body.max_invalid_ratio ?? 0.3),\n codes, imported_ids: imported.map((r) => r.id), quarantined_ids: quarantined.map((r) => r.id) } }];"
|
||||
},
|
||||
"position": [
|
||||
1540,
|
||||
300
|
||||
],
|
||||
"id": "80a8e374-c3d2-5f31-bce1-160bd265176c"
|
||||
},
|
||||
{
|
||||
"name": "Send result",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ $json }}",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
1760,
|
||||
300
|
||||
],
|
||||
"id": "861cf41c-8519-549b-ba20-7cd0943caf23"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Import webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Run check",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Run check": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Checked?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Checked?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Batch ok?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Reject body",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Batch ok?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Split rows",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Reject batch",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Split rows": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Decide",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Decide": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Store row",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Store row": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Summarize",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Summarize": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
{
|
||||
"name": "78 FAQ import + LLM check (reference)",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Import webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "my-faq-import",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
0,
|
||||
300
|
||||
],
|
||||
"id": "0c06837b-c353-5d57-87a3-9902cc7cb89b",
|
||||
"webhookId": "5df7e654-f73a-5bd7-a684-9c0125bb37a0"
|
||||
},
|
||||
{
|
||||
"name": "Run check",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://localhost:5678/webhook/my-faq-check",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
},
|
||||
"timeout": 30000
|
||||
},
|
||||
"method": "POST",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify($json.body) }}"
|
||||
},
|
||||
"position": [
|
||||
220,
|
||||
300
|
||||
],
|
||||
"id": "113c3307-ae38-53f2-b868-6836377ce3de"
|
||||
},
|
||||
{
|
||||
"name": "Checked?",
|
||||
"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.rows) && $json.rows.length > 0 }}",
|
||||
"rightValue": "",
|
||||
"operator": {
|
||||
"type": "boolean",
|
||||
"operation": "true",
|
||||
"singleValue": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"id": "5cebd576-bdd0-5d87-8d21-50dedd8f7330"
|
||||
},
|
||||
{
|
||||
"name": "Reject body",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ JSON.stringify({ error: $json.error ?? 'check failed' }) }}",
|
||||
"options": {
|
||||
"responseCode": 400
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
660,
|
||||
760
|
||||
],
|
||||
"id": "28279a25-ff11-545e-8665-853b391d4cc2"
|
||||
},
|
||||
{
|
||||
"name": "Batch ok?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.2,
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "loose",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "c1",
|
||||
"leftValue": "={{ $json.invalid / $json.total <= ($('Import webhook').first().json.body.max_invalid_ratio ?? 0.3) }}",
|
||||
"rightValue": "",
|
||||
"operator": {
|
||||
"type": "boolean",
|
||||
"operation": "true",
|
||||
"singleValue": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
660,
|
||||
300
|
||||
],
|
||||
"id": "79dafedb-7560-5604-92df-e74301c2e851"
|
||||
},
|
||||
{
|
||||
"name": "Reject batch",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ JSON.stringify({ error: 'batch rejected', batch: $('Run check').first().json.batch, invalid_ratio: Math.round($('Run check').first().json.invalid / $('Run check').first().json.total * 10000) / 10000, max_invalid_ratio: ($('Import webhook').first().json.body.max_invalid_ratio ?? 0.3), codes: $('Run check').first().json.codes }) }}",
|
||||
"options": {
|
||||
"responseCode": 422
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
880,
|
||||
560
|
||||
],
|
||||
"id": "0fe0e610-b892-5a76-bf63-7d8e0bb39480"
|
||||
},
|
||||
{
|
||||
"name": "Split rows",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const c = $('Run check').first().json;\nconst items = $('Import webhook').first().json.body.items;\nreturn c.rows.map((r, i) => ({ json: { ...r, row: items[i], batch: c.batch } }));"
|
||||
},
|
||||
"position": [
|
||||
880,
|
||||
300
|
||||
],
|
||||
"id": "7a40a393-288a-53ae-a82e-b98d947ee93b"
|
||||
},
|
||||
{
|
||||
"name": "Only passed",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "return $input.all().filter((i) => i.json.ok);"
|
||||
},
|
||||
"position": [
|
||||
1100,
|
||||
300
|
||||
],
|
||||
"id": "eb586a0d-12f4-5fde-9d68-043c55aef1f4"
|
||||
},
|
||||
{
|
||||
"name": "Judge",
|
||||
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||
"typeVersion": 3.1,
|
||||
"parameters": {
|
||||
"promptType": "define",
|
||||
"text": "=제목: {{ $json.row.title }}\n답: {{ $json.row.answer }}",
|
||||
"options": {
|
||||
"systemMessage": "You check a company IT FAQ entry before it is published. Does the answer actually answer the title's topic? Ignore style. Return only JSON, no code fence: {\"match\": true, \"reason\": \"short reason in Korean\"}",
|
||||
"maxIterations": 1
|
||||
}
|
||||
},
|
||||
"onError": "continueRegularOutput",
|
||||
"position": [
|
||||
1320,
|
||||
300
|
||||
],
|
||||
"id": "00774c60-9e1b-5b13-9372-05bfff2c5e5f"
|
||||
},
|
||||
{
|
||||
"name": "Judge 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": "b9d81b74-5416-5c1f-83d5-27198a9c3e4c"
|
||||
},
|
||||
{
|
||||
"name": "Decide",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const judged = $('Only passed').all().map((item, i) => {\n let v = { match: false, reason: 'judge unreadable' };\n try { v = JSON.parse(String($('Judge').all()[i].json.output ?? '').replace(/^```(json)?\\s*|```$/g, '')); } catch (e) {}\n return { ...item.json, ok: v.match === true, errors: v.match === true ? [] : ['llm_mismatch'], reason: String(v.reason ?? '') };\n});\nlet k = 0;\nreturn $('Split rows').all().map((i) => ({ json: i.json.ok ? judged[k++] : i.json }));"
|
||||
},
|
||||
"position": [
|
||||
1540,
|
||||
300
|
||||
],
|
||||
"id": "efbd675d-b385-598c-8d25-59047ac9b1ec"
|
||||
},
|
||||
{
|
||||
"name": "Store row",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "={{ $json.ok ? 'http://data-api:8800/faq/add' : 'http://data-api:8800/quarantine' }}",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"method": "POST",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify($json.ok ? $json.row : { items: [{ batch: $json.batch, row: $json.row, errors: $json.errors }] }) }}"
|
||||
},
|
||||
"position": [
|
||||
1760,
|
||||
300
|
||||
],
|
||||
"id": "31549ecc-3322-5f92-a41e-c3006f0b0421"
|
||||
},
|
||||
{
|
||||
"name": "Summarize",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const c = $('Run check').first().json;\nconst rows = $('Decide').all().map((i) => i.json);\nconst imported = rows.filter((r) => r.ok);\nconst quarantined = rows.filter((r) => !r.ok);\nconst codes = { ...c.codes };\nfor (const r of quarantined) for (const e of r.errors) if (e === 'llm_mismatch') codes[e] = (codes[e] ?? 0) + 1;\nreturn [{ json: { batch: c.batch, total: c.total, imported: imported.length, quarantined: quarantined.length, rejected: false,\n score: Math.round(imported.length / c.total * 10000) / 10000, max_invalid_ratio: ($('Import webhook').first().json.body.max_invalid_ratio ?? 0.3),\n codes, imported_ids: imported.map((r) => r.id), quarantined_ids: quarantined.map((r) => r.id) } }];"
|
||||
},
|
||||
"position": [
|
||||
1980,
|
||||
300
|
||||
],
|
||||
"id": "b07acdff-4b76-5814-84cc-bd6011916683"
|
||||
},
|
||||
{
|
||||
"name": "Send result",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ $json }}",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
2200,
|
||||
300
|
||||
],
|
||||
"id": "e1ac883e-0b0e-5106-b01e-7fb7363f9c74"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Import webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Run check",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Run check": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Checked?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Checked?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Batch ok?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Reject body",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Batch ok?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Split rows",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Reject batch",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Split rows": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Only passed",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Only passed": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Judge",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Judge": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Decide",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Judge model": {
|
||||
"ai_languageModel": [
|
||||
[
|
||||
{
|
||||
"node": "Judge",
|
||||
"type": "ai_languageModel",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Decide": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Store row",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Store row": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Summarize",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Summarize": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
{
|
||||
"name": "78 Schema check (reference)",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Check webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "my-check",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
0,
|
||||
300
|
||||
],
|
||||
"id": "c042a07f-a8f7-565f-b562-64bdc1e951e1",
|
||||
"webhookId": "a46fe9fe-4604-5c34-8435-2d87f4063861"
|
||||
},
|
||||
{
|
||||
"name": "Get schema",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "=http://data-api:8800/schemas/{{ encodeURIComponent($json.body.schema ?? '') }}",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
220,
|
||||
300
|
||||
],
|
||||
"id": "e7a886c9-f582-578b-bb91-75b1b0464de0"
|
||||
},
|
||||
{
|
||||
"name": "Known schema?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.2,
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "loose",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "c1",
|
||||
"leftValue": "={{ $json.fields !== undefined }}",
|
||||
"rightValue": "",
|
||||
"operator": {
|
||||
"type": "boolean",
|
||||
"operation": "true",
|
||||
"singleValue": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"id": "368be2eb-9471-523a-88c7-adb0d427375b"
|
||||
},
|
||||
{
|
||||
"name": "Unknown schema",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ JSON.stringify({ error: 'unknown schema', schema: $('Check webhook').first().json.body.schema ?? null }) }}",
|
||||
"options": {
|
||||
"responseCode": 400
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
660,
|
||||
560
|
||||
],
|
||||
"id": "482f3c8d-6a2c-5b9a-9f5c-056b8bf51986"
|
||||
},
|
||||
{
|
||||
"name": "Check rows",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const body = $('Check webhook').first().json.body;\nconst schema = $json;\nlet items = body.items;\nif (typeof body.csv === 'string') {\n const [head, ...lines] = body.csv.trim().split(/\\r?\\n/);\n const cols = head.split(',').map((c) => c.trim());\n items = lines.map((line) => Object.fromEntries(line.split(',').map((v, i) => [cols[i], v.trim()])));\n}\nconst isDate = (v) => { if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(v)) return false; const d = new Date(v + 'T00:00:00Z'); return !isNaN(d) && d.toISOString().slice(0, 10) === v; };\nconst seen = new Set();\nconst rows = (Array.isArray(items) ? items : []).map((r, index) => {\n const errors = [];\n for (const [name, f] of Object.entries(schema.fields)) {\n const v = r?.[name];\n const empty = v === undefined || v === null || (typeof v === 'string' && v.trim() === '') || (Array.isArray(v) && v.length === 0);\n if (empty) { if (f.required) errors.push(`missing:${name}`); continue; }\n const s = String(v);\n if (f.type === 'string') { if (f.max && s.length > f.max) errors.push(`too_long:${name}`); if (f.min && s.length < f.min) errors.push(`too_short:${name}`); }\n if (f.type === 'enum' && !f.values.includes(s)) errors.push(`not_allowed:${name}`);\n if (f.type === 'email' && !/^[\\w.+-]+@[\\w-]+(\\.[\\w-]+)+$/.test(s)) errors.push(`bad_format:${name}`);\n if (f.type === 'phone' && !/^0\\d{1,2}-\\d{3,4}-\\d{4}$/.test(s)) errors.push(`bad_format:${name}`);\n if (f.type === 'date' && !isDate(s)) errors.push(`bad_format:${name}`);\n if (f.type === 'pattern' && !new RegExp(f.pattern).test(s)) errors.push(`bad_format:${name}`);\n if (f.type === 'list') { if (!Array.isArray(v)) errors.push(`bad_format:${name}`); else { if (f.min_items && v.length < f.min_items) errors.push(`too_few:${name}`); if (f.max_items && v.length > f.max_items) errors.push(`too_many:${name}`); } }\n }\n const key = String(r?.[schema.key] ?? '');\n if (key && seen.has(key)) errors.push('duplicate_in_batch');\n if (key) seen.add(key);\n return { row: index + 1, key, ok: errors.length === 0, errors };\n});\nconst codes = {};\nfor (const row of rows) for (const e of row.errors) codes[e] = (codes[e] ?? 0) + 1;\nconst valid = rows.filter((r) => r.ok).length;\nreturn [{ json: { schema: schema.name, batch: body.batch ?? '', total: rows.length, valid, invalid: rows.length - valid, rows, codes } }];"
|
||||
},
|
||||
"position": [
|
||||
660,
|
||||
300
|
||||
],
|
||||
"id": "d5d18640-aebb-5227-905c-92435b892892"
|
||||
},
|
||||
{
|
||||
"name": "Send result",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ $json }}",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
880,
|
||||
300
|
||||
],
|
||||
"id": "3cf4a033-8e67-5be5-8392-473991da1f4d"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Check webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get schema",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get schema": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Known schema?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Known schema?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Check rows",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Unknown schema",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Check rows": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
{
|
||||
"name": "78 FAQ import + release fixed rows (reference)",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Import webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "my-faq-import",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
0,
|
||||
300
|
||||
],
|
||||
"id": "d3a86b18-edfc-51e7-95c7-8aa2c58fdbda",
|
||||
"webhookId": "c4169baa-ac65-5afd-ac30-52094389dbf7"
|
||||
},
|
||||
{
|
||||
"name": "Run check",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://localhost:5678/webhook/my-faq-check",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
},
|
||||
"timeout": 30000
|
||||
},
|
||||
"method": "POST",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify($json.body) }}"
|
||||
},
|
||||
"position": [
|
||||
220,
|
||||
300
|
||||
],
|
||||
"id": "1d6a0bcd-c4ae-5b95-b04a-8c021adbd523"
|
||||
},
|
||||
{
|
||||
"name": "Checked?",
|
||||
"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.rows) && $json.rows.length > 0 }}",
|
||||
"rightValue": "",
|
||||
"operator": {
|
||||
"type": "boolean",
|
||||
"operation": "true",
|
||||
"singleValue": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"id": "b753a2cd-44be-5af3-8683-b154c2443f0f"
|
||||
},
|
||||
{
|
||||
"name": "Reject body",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ JSON.stringify({ error: $json.error ?? 'check failed' }) }}",
|
||||
"options": {
|
||||
"responseCode": 400
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
660,
|
||||
760
|
||||
],
|
||||
"id": "36f0ca97-2abb-53d3-a0da-4fe3e3d2ae74"
|
||||
},
|
||||
{
|
||||
"name": "Batch ok?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.2,
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "loose",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "c1",
|
||||
"leftValue": "={{ $json.invalid / $json.total <= ($('Import webhook').first().json.body.max_invalid_ratio ?? 0.3) }}",
|
||||
"rightValue": "",
|
||||
"operator": {
|
||||
"type": "boolean",
|
||||
"operation": "true",
|
||||
"singleValue": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
660,
|
||||
300
|
||||
],
|
||||
"id": "edceb049-33db-5490-bac7-bed1554f414f"
|
||||
},
|
||||
{
|
||||
"name": "Reject batch",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ JSON.stringify({ error: 'batch rejected', batch: $('Run check').first().json.batch, invalid_ratio: Math.round($('Run check').first().json.invalid / $('Run check').first().json.total * 10000) / 10000, max_invalid_ratio: ($('Import webhook').first().json.body.max_invalid_ratio ?? 0.3), codes: $('Run check').first().json.codes, llm_calls: 0 }) }}",
|
||||
"options": {
|
||||
"responseCode": 422
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
1320,
|
||||
560
|
||||
],
|
||||
"id": "256d2a55-b303-51f1-a8a5-3ea934fb7f3b"
|
||||
},
|
||||
{
|
||||
"name": "Log rejected",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/quality-log",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"method": "POST",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ batch: $json.batch, dataset: 'faq', total: $json.total, valid: $json.valid, quarantined: 0, rejected: true, score: 0, codes: $json.codes }) }}"
|
||||
},
|
||||
"position": [
|
||||
880,
|
||||
560
|
||||
],
|
||||
"id": "7f06265c-c872-5e48-8c72-0ea9beaf1cdc"
|
||||
},
|
||||
{
|
||||
"name": "Alert rejected",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/alerts",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"method": "POST",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ workflow: 'faq-import', node: 'Batch ok?', severity: 'high', reason: 'batch_rejected', batch: $('Run check').first().json.batch, message: $('Run check').first().json.batch + ' rejected' }) }}"
|
||||
},
|
||||
"position": [
|
||||
1100,
|
||||
560
|
||||
],
|
||||
"id": "4ba91d1f-4b86-552d-b572-08706cd4d170"
|
||||
},
|
||||
{
|
||||
"name": "Split rows",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const c = $('Run check').first().json;\nconst items = $('Import webhook').first().json.body.items;\nreturn c.rows.map((r, i) => ({ json: { ...r, row: items[i], batch: c.batch } }));"
|
||||
},
|
||||
"position": [
|
||||
880,
|
||||
300
|
||||
],
|
||||
"id": "ebce06e1-7140-5292-8687-4ab2b641c047"
|
||||
},
|
||||
{
|
||||
"name": "Only passed",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "return $input.all().filter((i) => i.json.ok);"
|
||||
},
|
||||
"position": [
|
||||
1100,
|
||||
300
|
||||
],
|
||||
"id": "b7a235dd-8495-5b00-a635-3dbd3920a1c4"
|
||||
},
|
||||
{
|
||||
"name": "Judge",
|
||||
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||
"typeVersion": 3.1,
|
||||
"parameters": {
|
||||
"promptType": "define",
|
||||
"text": "=제목: {{ $json.row.title }}\n답: {{ $json.row.answer }}",
|
||||
"options": {
|
||||
"systemMessage": "You check a company IT FAQ entry before it is published. Does the answer actually answer the title's topic? Ignore style. Return only JSON, no code fence: {\"match\": true, \"reason\": \"short reason in Korean\"}",
|
||||
"maxIterations": 1
|
||||
}
|
||||
},
|
||||
"onError": "continueRegularOutput",
|
||||
"position": [
|
||||
1320,
|
||||
300
|
||||
],
|
||||
"id": "6273964f-6cb4-56ab-885d-2dc3faf7f7ff"
|
||||
},
|
||||
{
|
||||
"name": "Judge 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": "dd634caa-4367-5cdb-aac1-559d0114d7ff"
|
||||
},
|
||||
{
|
||||
"name": "Decide",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const judged = $('Only passed').all().map((item, i) => {\n let v = { match: false, reason: 'judge unreadable' };\n try { v = JSON.parse(String($('Judge').all()[i].json.output ?? '').replace(/^```(json)?\\s*|```$/g, '')); } catch (e) {}\n return { ...item.json, ok: v.match === true, errors: v.match === true ? [] : ['llm_mismatch'], reason: String(v.reason ?? '') };\n});\nlet k = 0;\nreturn $('Split rows').all().map((i) => ({ json: i.json.ok ? judged[k++] : i.json }));"
|
||||
},
|
||||
"position": [
|
||||
1540,
|
||||
300
|
||||
],
|
||||
"id": "af54c0ea-0a6a-569a-8cc5-9e4b831fda3c"
|
||||
},
|
||||
{
|
||||
"name": "Store row",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "={{ $json.ok ? 'http://data-api:8800/faq/add' : 'http://data-api:8800/quarantine' }}",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"method": "POST",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify($json.ok ? $json.row : { items: [{ batch: $json.batch, row: $json.row, errors: $json.errors }] }) }}"
|
||||
},
|
||||
"position": [
|
||||
1760,
|
||||
300
|
||||
],
|
||||
"id": "b1b7176d-9a05-5f4b-82e7-71b8264ed74c"
|
||||
},
|
||||
{
|
||||
"name": "Summarize",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const c = $('Run check').first().json;\nconst rows = $('Decide').all().map((i) => i.json);\nconst imported = rows.filter((r) => r.ok);\nconst quarantined = rows.filter((r) => !r.ok);\nconst codes = { ...c.codes };\nfor (const r of quarantined) for (const e of r.errors) if (e === 'llm_mismatch') codes[e] = (codes[e] ?? 0) + 1;\nreturn [{ json: { batch: c.batch, total: c.total, imported: imported.length, quarantined: quarantined.length, rejected: false,\n score: Math.round(imported.length / c.total * 10000) / 10000, max_invalid_ratio: ($('Import webhook').first().json.body.max_invalid_ratio ?? 0.3),\n codes, imported_ids: imported.map((r) => r.id), quarantined_ids: quarantined.map((r) => r.id), llm_calls: $('Only passed').all().length } }];"
|
||||
},
|
||||
"position": [
|
||||
1980,
|
||||
300
|
||||
],
|
||||
"id": "9e940251-c204-5813-9457-72980199a956"
|
||||
},
|
||||
{
|
||||
"name": "Release fixed",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/quarantine/remove",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"method": "POST",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ ids: $json.imported_ids }) }}"
|
||||
},
|
||||
"position": [
|
||||
2200,
|
||||
300
|
||||
],
|
||||
"id": "7239d9dc-daf2-5aea-a13c-be25358337a2"
|
||||
},
|
||||
{
|
||||
"name": "Log quality",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/quality-log",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"method": "POST",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ batch: $('Summarize').first().json.batch, dataset: 'faq', total: $('Summarize').first().json.total, valid: $('Summarize').first().json.imported, quarantined: $('Summarize').first().json.quarantined, rejected: false, score: $('Summarize').first().json.score, codes: $('Summarize').first().json.codes }) }}"
|
||||
},
|
||||
"position": [
|
||||
2420,
|
||||
300
|
||||
],
|
||||
"id": "67ebf40c-0364-5bde-9f95-eb77a10689c3"
|
||||
},
|
||||
{
|
||||
"name": "Low score?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.2,
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "loose",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "c1",
|
||||
"leftValue": "={{ $('Summarize').first().json.score < 0.9 }}",
|
||||
"rightValue": "",
|
||||
"operator": {
|
||||
"type": "boolean",
|
||||
"operation": "true",
|
||||
"singleValue": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
2640,
|
||||
300
|
||||
],
|
||||
"id": "4f8eb1e7-753f-5515-8485-e9b0ff7d8bc7"
|
||||
},
|
||||
{
|
||||
"name": "Alert low score",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/alerts",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"method": "POST",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ workflow: 'faq-import', node: 'Low score?', severity: 'high', reason: 'low_quality', batch: $('Summarize').first().json.batch, score: $('Summarize').first().json.score, message: $('Summarize').first().json.batch + ' quality ' + $('Summarize').first().json.score }) }}"
|
||||
},
|
||||
"position": [
|
||||
2860,
|
||||
160
|
||||
],
|
||||
"id": "68033c5c-b22b-535f-9ddc-ff603ab7d3c2"
|
||||
},
|
||||
{
|
||||
"name": "Result again",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "return [{ json: $('Summarize').first().json }];"
|
||||
},
|
||||
"position": [
|
||||
3080,
|
||||
300
|
||||
],
|
||||
"id": "2ca2f7fc-885a-5ac9-9121-62b79d203f74"
|
||||
},
|
||||
{
|
||||
"name": "Send result",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ $json }}",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
3300,
|
||||
300
|
||||
],
|
||||
"id": "c58470f8-9dd9-521a-be5b-746bbdb86d3a"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Import webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Run check",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Run check": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Checked?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Checked?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Batch ok?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Reject body",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Batch ok?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Split rows",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Log rejected",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Log rejected": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Alert rejected",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Alert rejected": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Reject batch",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Split rows": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Only passed",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Only passed": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Judge",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Judge": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Decide",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Judge model": {
|
||||
"ai_languageModel": [
|
||||
[
|
||||
{
|
||||
"node": "Judge",
|
||||
"type": "ai_languageModel",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Decide": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Store row",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Store row": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Summarize",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Summarize": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Release fixed",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Release fixed": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Log quality",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Log quality": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Low score?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Low score?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Alert low score",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Result again",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Alert low score": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Result again",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Result again": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"schema": "employee",
|
||||
"batch": "HR-1",
|
||||
"csv": "name,dept,email,phone,joined\n오세훈,영업팀,sehoon.oh@example.com,010-2222-3333,2026-10-01\n윤하늘,재무팀,haneul.yoon@example.com,,2026-10-05\n강민수,개발팀,minsu.kang@example,010-4444-5555,2026-10-06\n서지아,마케팅팀,jia.seo@example.com,010-6666-7777,2026-10-07\n임도윤,인사팀,doyun.lim@example.com,010-8888-9999,2026-13-01\n,IT팀,noname@example.com,,2026-10-09\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n정다은,IT팀,daeun.jung@example.com,010-1111-2222,2026-10-14"
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"max_invalid_ratio": 0.8,
|
||||
"batch": "B-1",
|
||||
"items": [
|
||||
{
|
||||
"id": "FAQ-101",
|
||||
"title": "사내 와이파이 연결",
|
||||
"keywords": [
|
||||
"와이파이",
|
||||
"wifi",
|
||||
"무선"
|
||||
],
|
||||
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-102",
|
||||
"title": "회의실 예약",
|
||||
"keywords": [
|
||||
"회의실",
|
||||
"예약"
|
||||
],
|
||||
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-103",
|
||||
"title": "",
|
||||
"keywords": [
|
||||
"출입증"
|
||||
],
|
||||
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-10",
|
||||
"title": "노트북 반납",
|
||||
"keywords": [
|
||||
"노트북",
|
||||
"반납"
|
||||
],
|
||||
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-105",
|
||||
"title": "모니터 추가 신청",
|
||||
"keywords": [
|
||||
"모니터"
|
||||
],
|
||||
"answer": ""
|
||||
},
|
||||
{
|
||||
"id": "FAQ-106",
|
||||
"title": "회의실 예약 규칙",
|
||||
"keywords": [
|
||||
"회의실 규칙"
|
||||
],
|
||||
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
|
||||
},
|
||||
{
|
||||
"id": "FAQ-102",
|
||||
"title": "회의실 예약 (중복)",
|
||||
"keywords": [
|
||||
"회의실"
|
||||
],
|
||||
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-108",
|
||||
"title": "화상회의 장비",
|
||||
"keywords": [],
|
||||
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-109",
|
||||
"title": "보안 사고 신고",
|
||||
"keywords": [
|
||||
"보안",
|
||||
"신고"
|
||||
],
|
||||
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-110",
|
||||
"title": "사내 메신저 설치",
|
||||
"keywords": [
|
||||
"메신저"
|
||||
],
|
||||
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-003",
|
||||
"title": "VPN 접속 오류 (새 판)",
|
||||
"keywords": [
|
||||
"재택근무"
|
||||
],
|
||||
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-112",
|
||||
"title": "VPN 접속 오류",
|
||||
"keywords": [
|
||||
"vpn",
|
||||
"재택근무"
|
||||
],
|
||||
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-113",
|
||||
"title": "프린터 토너 교체",
|
||||
"keywords": [
|
||||
"토너"
|
||||
],
|
||||
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"batch": "B-1",
|
||||
"items": [
|
||||
{
|
||||
"id": "FAQ-101",
|
||||
"title": "사내 와이파이 연결",
|
||||
"keywords": [
|
||||
"와이파이",
|
||||
"wifi",
|
||||
"무선"
|
||||
],
|
||||
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-102",
|
||||
"title": "회의실 예약",
|
||||
"keywords": [
|
||||
"회의실",
|
||||
"예약"
|
||||
],
|
||||
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-103",
|
||||
"title": "",
|
||||
"keywords": [
|
||||
"출입증"
|
||||
],
|
||||
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-10",
|
||||
"title": "노트북 반납",
|
||||
"keywords": [
|
||||
"노트북",
|
||||
"반납"
|
||||
],
|
||||
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-105",
|
||||
"title": "모니터 추가 신청",
|
||||
"keywords": [
|
||||
"모니터"
|
||||
],
|
||||
"answer": ""
|
||||
},
|
||||
{
|
||||
"id": "FAQ-106",
|
||||
"title": "회의실 예약 규칙",
|
||||
"keywords": [
|
||||
"회의실 규칙"
|
||||
],
|
||||
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
|
||||
},
|
||||
{
|
||||
"id": "FAQ-102",
|
||||
"title": "회의실 예약 (중복)",
|
||||
"keywords": [
|
||||
"회의실"
|
||||
],
|
||||
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-108",
|
||||
"title": "화상회의 장비",
|
||||
"keywords": [],
|
||||
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-109",
|
||||
"title": "보안 사고 신고",
|
||||
"keywords": [
|
||||
"보안",
|
||||
"신고"
|
||||
],
|
||||
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-110",
|
||||
"title": "사내 메신저 설치",
|
||||
"keywords": [
|
||||
"메신저"
|
||||
],
|
||||
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-003",
|
||||
"title": "VPN 접속 오류 (새 판)",
|
||||
"keywords": [
|
||||
"재택근무"
|
||||
],
|
||||
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-112",
|
||||
"title": "VPN 접속 오류",
|
||||
"keywords": [
|
||||
"vpn",
|
||||
"재택근무"
|
||||
],
|
||||
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-113",
|
||||
"title": "프린터 토너 교체",
|
||||
"keywords": [
|
||||
"토너"
|
||||
],
|
||||
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"batch": "B-2-fix",
|
||||
"items": [
|
||||
{
|
||||
"id": "FAQ-209",
|
||||
"title": "보안 사고 신고",
|
||||
"keywords": [
|
||||
"보안",
|
||||
"신고"
|
||||
],
|
||||
"answer": "보안 사고는 IT 포털의 '보안 신고' 메뉴로 바로 알려 주세요. 급하면 IT 문의 창구로 연락합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-210",
|
||||
"title": "모니터 밝기 조절",
|
||||
"keywords": [
|
||||
"밝기"
|
||||
],
|
||||
"answer": "모니터 밝기는 모니터 아래 버튼이나 윈도우 '디스플레이 설정'에서 조절합니다."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"max_invalid_ratio": 0.1,
|
||||
"batch": "B-2",
|
||||
"items": [
|
||||
{
|
||||
"id": "FAQ-201",
|
||||
"title": "회의실 예약",
|
||||
"keywords": [
|
||||
"회의실",
|
||||
"예약"
|
||||
],
|
||||
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-202",
|
||||
"title": "출입증 재발급",
|
||||
"keywords": [
|
||||
"출입증",
|
||||
"재발급"
|
||||
],
|
||||
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-203",
|
||||
"title": "노트북 반납",
|
||||
"keywords": [
|
||||
"노트북",
|
||||
"반납"
|
||||
],
|
||||
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-204",
|
||||
"title": "모니터 추가 신청",
|
||||
"keywords": [
|
||||
"모니터",
|
||||
"추가"
|
||||
],
|
||||
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-205",
|
||||
"title": "윈도우 업데이트",
|
||||
"keywords": [
|
||||
"업데이트",
|
||||
"윈도우"
|
||||
],
|
||||
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-206",
|
||||
"title": "화상회의 장비 대여",
|
||||
"keywords": [
|
||||
"화상회의",
|
||||
"카메라"
|
||||
],
|
||||
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-207",
|
||||
"title": "사내 메신저 설치",
|
||||
"keywords": [
|
||||
"메신저"
|
||||
],
|
||||
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-208",
|
||||
"title": "공유 드라이브 권한",
|
||||
"keywords": [
|
||||
"공유 드라이브",
|
||||
"권한"
|
||||
],
|
||||
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-209",
|
||||
"title": "보안 사고 신고",
|
||||
"keywords": [
|
||||
"보안",
|
||||
"신고"
|
||||
],
|
||||
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-210",
|
||||
"title": "모니터 밝기 조절",
|
||||
"keywords": [
|
||||
"밝기"
|
||||
],
|
||||
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"max_invalid_ratio": 0.05,
|
||||
"batch": "B-2",
|
||||
"items": [
|
||||
{
|
||||
"id": "FAQ-201",
|
||||
"title": "회의실 예약",
|
||||
"keywords": [
|
||||
"회의실",
|
||||
"예약"
|
||||
],
|
||||
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-202",
|
||||
"title": "출입증 재발급",
|
||||
"keywords": [
|
||||
"출입증",
|
||||
"재발급"
|
||||
],
|
||||
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-203",
|
||||
"title": "노트북 반납",
|
||||
"keywords": [
|
||||
"노트북",
|
||||
"반납"
|
||||
],
|
||||
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-204",
|
||||
"title": "모니터 추가 신청",
|
||||
"keywords": [
|
||||
"모니터",
|
||||
"추가"
|
||||
],
|
||||
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-205",
|
||||
"title": "윈도우 업데이트",
|
||||
"keywords": [
|
||||
"업데이트",
|
||||
"윈도우"
|
||||
],
|
||||
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-206",
|
||||
"title": "화상회의 장비 대여",
|
||||
"keywords": [
|
||||
"화상회의",
|
||||
"카메라"
|
||||
],
|
||||
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-207",
|
||||
"title": "사내 메신저 설치",
|
||||
"keywords": [
|
||||
"메신저"
|
||||
],
|
||||
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-208",
|
||||
"title": "공유 드라이브 권한",
|
||||
"keywords": [
|
||||
"공유 드라이브",
|
||||
"권한"
|
||||
],
|
||||
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-209",
|
||||
"title": "보안 사고 신고",
|
||||
"keywords": [
|
||||
"보안",
|
||||
"신고"
|
||||
],
|
||||
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-210",
|
||||
"title": "모니터 밝기 조절",
|
||||
"keywords": [
|
||||
"밝기"
|
||||
],
|
||||
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"batch": "B-2",
|
||||
"items": [
|
||||
{
|
||||
"id": "FAQ-201",
|
||||
"title": "회의실 예약",
|
||||
"keywords": [
|
||||
"회의실",
|
||||
"예약"
|
||||
],
|
||||
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-202",
|
||||
"title": "출입증 재발급",
|
||||
"keywords": [
|
||||
"출입증",
|
||||
"재발급"
|
||||
],
|
||||
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-203",
|
||||
"title": "노트북 반납",
|
||||
"keywords": [
|
||||
"노트북",
|
||||
"반납"
|
||||
],
|
||||
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-204",
|
||||
"title": "모니터 추가 신청",
|
||||
"keywords": [
|
||||
"모니터",
|
||||
"추가"
|
||||
],
|
||||
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-205",
|
||||
"title": "윈도우 업데이트",
|
||||
"keywords": [
|
||||
"업데이트",
|
||||
"윈도우"
|
||||
],
|
||||
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-206",
|
||||
"title": "화상회의 장비 대여",
|
||||
"keywords": [
|
||||
"화상회의",
|
||||
"카메라"
|
||||
],
|
||||
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-207",
|
||||
"title": "사내 메신저 설치",
|
||||
"keywords": [
|
||||
"메신저"
|
||||
],
|
||||
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-208",
|
||||
"title": "공유 드라이브 권한",
|
||||
"keywords": [
|
||||
"공유 드라이브",
|
||||
"권한"
|
||||
],
|
||||
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-209",
|
||||
"title": "보안 사고 신고",
|
||||
"keywords": [
|
||||
"보안",
|
||||
"신고"
|
||||
],
|
||||
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-210",
|
||||
"title": "모니터 밝기 조절",
|
||||
"keywords": [
|
||||
"밝기"
|
||||
],
|
||||
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"batch": "B-3",
|
||||
"items": [
|
||||
{
|
||||
"id": "FAQ-301",
|
||||
"title": "택배 수령",
|
||||
"keywords": [
|
||||
"택배"
|
||||
],
|
||||
"answer": "회사로 온 택배는 1층 안내 데스크에서 사원증을 보여 주고 받습니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-302",
|
||||
"title": "주차 등록",
|
||||
"keywords": [
|
||||
"주차"
|
||||
],
|
||||
"answer": "주차 등록은 총무팀 포털에서 차량 번호를 적어 신청합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-303",
|
||||
"title": "명함 신청",
|
||||
"keywords": [
|
||||
"명함"
|
||||
],
|
||||
"answer": "명함은 인사 포털에서 신청하면 일주일 안에 자리로 배달됩니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-304",
|
||||
"title": "사내 식당 메뉴",
|
||||
"keywords": [
|
||||
"식당",
|
||||
"메뉴"
|
||||
],
|
||||
"answer": "사내 식당 메뉴는 매주 월요일 아침 사내 게시판에 올라옵니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-305",
|
||||
"title": "교육 신청",
|
||||
"keywords": [
|
||||
"교육"
|
||||
],
|
||||
"answer": "짧"
|
||||
},
|
||||
{
|
||||
"id": "FAQ-306",
|
||||
"title": "건강검진",
|
||||
"keywords": [
|
||||
"건강검진"
|
||||
],
|
||||
"answer": "건강검진은 매년 9월에 인사팀이 안내하며 지정 병원에서 받습니다."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"batch": "E-1",
|
||||
"items": [
|
||||
{
|
||||
"id": "FAQ-401",
|
||||
"title": "사내 메신저 알림이 오지 않을 때 확인할 것과 다시 설정하는 법가가가가가",
|
||||
"keywords": [
|
||||
"메신저",
|
||||
"알림"
|
||||
],
|
||||
"answer": "메신저 설정의 '알림' 탭에서 알림을 다시 켭니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-402",
|
||||
"title": "사내 메신저 알림이 오지 않을 때 확인할 것과 다시 설정하는 법가가가가가요",
|
||||
"keywords": [
|
||||
"메신저"
|
||||
],
|
||||
"answer": "메신저 설정의 '알림' 탭에서 알림을 다시 켭니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-403",
|
||||
"title": " ",
|
||||
"keywords": [
|
||||
"사원증"
|
||||
],
|
||||
"answer": "사원증 사진은 인사 포털에서 바꿉니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-404",
|
||||
"title": "장비 신청",
|
||||
"keywords": [
|
||||
"장비"
|
||||
],
|
||||
"answer": "IT 포털에서 신청"
|
||||
},
|
||||
{
|
||||
"id": "FAQ-405",
|
||||
"title": "장비 반납",
|
||||
"keywords": [
|
||||
"반납"
|
||||
],
|
||||
"answer": "IT포털에서 신청"
|
||||
},
|
||||
{
|
||||
"id": "FAQ-406",
|
||||
"title": "야간 장애 연락",
|
||||
"keywords": [
|
||||
"야간",
|
||||
"장애"
|
||||
],
|
||||
"answer": "야간 장애는 당직자 01012345678 로 전화합니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-407",
|
||||
"title": "IT 문의 창구",
|
||||
"keywords": [
|
||||
"문의",
|
||||
"창구"
|
||||
],
|
||||
"answer": "IT 문의 창구 번호는 02-123-4567 (평일 9~18시) 입니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-408",
|
||||
"title": "도움말 모음",
|
||||
"keywords": [
|
||||
"도움말"
|
||||
],
|
||||
"answer": "도움말은 https://intra.example.com/help 에 모여 있습니다."
|
||||
},
|
||||
{
|
||||
"id": "FAQ-409",
|
||||
"title": "보안 교육 자료",
|
||||
"keywords": [
|
||||
"보안 교육"
|
||||
],
|
||||
"answer": "보안 교육 자료는 http://intra.example.com.evil.io/edu 에서 받습니다."
|
||||
},
|
||||
{
|
||||
"id": "faq-410",
|
||||
"title": "출장 노트북",
|
||||
"keywords": [
|
||||
"출장"
|
||||
],
|
||||
"answer": "출장용 노트북은 IT팀에 사흘 전까지 신청합니다."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"batch": "B-x",
|
||||
"items": "FAQ-101"
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"schema": "ticket",
|
||||
"batch": "TK-1",
|
||||
"items": [
|
||||
{
|
||||
"ticket_id": "T-2001",
|
||||
"title": "모니터가 켜지지 않음",
|
||||
"requester": "박서연",
|
||||
"priority": "normal",
|
||||
"opened": "2026-10-28",
|
||||
"contact": "010-2345-6789"
|
||||
},
|
||||
{
|
||||
"ticket_id": "T-12",
|
||||
"title": "메일 첨부가 안 열림",
|
||||
"requester": "이준호",
|
||||
"priority": "low",
|
||||
"opened": "2026-10-28"
|
||||
},
|
||||
{
|
||||
"ticket_id": "T-2003",
|
||||
"title": "VPN이 계속 끊김",
|
||||
"requester": "최유진",
|
||||
"priority": "urgent",
|
||||
"opened": "2026-10-29"
|
||||
},
|
||||
{
|
||||
"ticket_id": "T-2004",
|
||||
"title": "키보드 교체 요청",
|
||||
"requester": "한지훈",
|
||||
"priority": "low",
|
||||
"opened": "2026-02-30"
|
||||
},
|
||||
{
|
||||
"ticket_id": "T-2005",
|
||||
"title": "프린터",
|
||||
"requester": "김민지",
|
||||
"priority": "normal",
|
||||
"opened": "2026-10-29"
|
||||
},
|
||||
{
|
||||
"ticket_id": "T-2006",
|
||||
"title": "공유 드라이브 권한",
|
||||
"requester": "박서연",
|
||||
"priority": "normal",
|
||||
"opened": "2026-10-30",
|
||||
"contact": "010-12-3456"
|
||||
},
|
||||
{
|
||||
"ticket_id": "T-2001",
|
||||
"title": "모니터가 또 꺼짐",
|
||||
"requester": "박서연",
|
||||
"priority": "high",
|
||||
"opened": "2026-10-30"
|
||||
},
|
||||
{
|
||||
"ticket_id": "T-2008",
|
||||
"title": "화상회의 카메라 대여",
|
||||
"requester": "",
|
||||
"priority": "low",
|
||||
"opened": "2026-10-30"
|
||||
},
|
||||
{
|
||||
"ticket_id": "T-2009",
|
||||
"title": "새 직원 계정 만들기",
|
||||
"requester": "이준호",
|
||||
"priority": "high",
|
||||
"opened": "2026-10-30"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# 품질 규칙표 · FAQ 배치 · 직원 목록 · 접수 건
|
||||
|
||||
- 이름:
|
||||
- 이 파일을 `my-work/QUALITY.md`로 복사해 채웁니다. 숫자는 `runs/`의 보고서나 `flow.py` 출력에서 옮깁니다.
|
||||
|
||||
## 1. 눈으로 검사 · 기준 검사 (Lab 01 예제 1, `requests/faq-batch-1.json`)
|
||||
|
||||
| 줄 id | 넣어도 되나 (내 눈) | 이유 한 마디 | 기준 검사(`base-faq-check`)는 | 규칙으로 바꾸면 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| | | | | |
|
||||
|
||||
- 내 눈으로 나쁜 줄: __ 줄 · 기준 검사가 나쁘다고 한 줄: __ 줄
|
||||
- 규칙으로 못 잡는 것:
|
||||
|
||||
## 2. 짐작과 검사 결과 (Lab 01 예제 2)
|
||||
|
||||
| | 내 짐작 | 기준 검사 | 내 검사 (`my-faq-check`) |
|
||||
| --- | --- | --- | --- |
|
||||
| 나쁜 줄 수 | | | |
|
||||
| 내가 놓친 줄 | | | |
|
||||
| 워크플로우가 놓친 줄 | | | |
|
||||
|
||||
## 3. 경계 (Lab 01 예제 3, `requests/faq-edges.json`)
|
||||
|
||||
| 경계 | 내 검사 | 기준 검사 | 규칙에서 정한 말 (AGENTS.md 3절) |
|
||||
| --- | --- | --- | --- |
|
||||
| 제목 40자 · 41자 | | | |
|
||||
| 공백만 있는 제목 | | | |
|
||||
| 답 10자 · 9자 | | | |
|
||||
| 하이픈 없는 휴대전화 · 사무실 번호 | | | |
|
||||
| 사내 주소처럼 보이는 바깥 링크 | | | |
|
||||
|
||||
## 4. 보낸 사람에게 (Lab 01 과제 2)
|
||||
|
||||
- B-1의 `message`:
|
||||
- `fix_list`에서 가장 많은 고칠 것:
|
||||
- 고침 말을 LLM으로 만들지 않은 까닭:
|
||||
|
||||
## 5. 게이트 기준 (Lab 02 예제 1 · 3)
|
||||
|
||||
| 기준 `max_invalid_ratio` | 배치 | 받았나 | 넣은 줄 · 격리 | 이렇게 정하면 좋은 점 · 나쁜 점 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 0.3 (기본) | B-1 | | | |
|
||||
| 0.8 | B-1 | | | |
|
||||
| 0.1 | B-2 | | | |
|
||||
| 0.05 | B-2 | | | |
|
||||
|
||||
- 우리 팀의 기준과 그 이유:
|
||||
|
||||
## 6. LLM 검사 (Lab 02 예제 2 · 과제 2)
|
||||
|
||||
| 배치 | 줄 | 규칙 통과 | `llm_calls` | LLM이 막은 줄 | 세 번 돌려 같았나 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| B-2 | | | | | |
|
||||
| B-3 | | | | | |
|
||||
|
||||
- 같은 배치를 다시 보내면 (`--no-before`):
|
||||
|
||||
## 7. 품질 기록 · 알림 (Lab 02 과제 1)
|
||||
|
||||
| 배치 | score | 거절 | 알림 이유 |
|
||||
| --- | --- | --- | --- |
|
||||
| | | | |
|
||||
|
||||
## 8. 스키마로 하는 것 · 못 하는 것 (Lab 03 예제 1 · 3)
|
||||
|
||||
| 규칙 | 스키마로 | 따로 (Code · LLM) |
|
||||
| --- | --- | --- |
|
||||
| | | |
|
||||
|
||||
- 새 스키마 `ticket`: 워크플로우를 고쳤나 · 9줄 중 통과:
|
||||
|
||||
## 9. 품질 보고 (Lab 03 과제 1 · 2)
|
||||
|
||||
| 배치 | 데이터셋 | score | 거절 | 가장 흔한 문제 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| | | | | |
|
||||
|
||||
- 평균 · 추세를 회의에 어떻게 말할까 (거절 배치를 넣나):
|
||||
- 가장 손볼 데이터셋과 그 이유:
|
||||
@@ -0,0 +1,31 @@
|
||||
# 테스트 보고서 · 데이터 품질 검사
|
||||
|
||||
- 이름:
|
||||
- 사용한 CLI 에이전트: codex / claude / agy
|
||||
- 워크플로우 파일: `my-work/faq-check.json`, `my-work/faq-import.json`, `my-work/check.json`, `my-work/quality-report.json`
|
||||
|
||||
| 테스트 파일 | 결과 |
|
||||
| --- | --- |
|
||||
| `cases/lab01.json` (규칙) | Passed /5 |
|
||||
| `cases/lab01-edges.json` (경계, 에이전트 없이) | Passed /6 |
|
||||
| `cases/lab01-challenge.json` (기존 데이터) | Passed /4 |
|
||||
| `cases/lab01-challenge2.json` (고침 목록) | Passed /5 |
|
||||
| `cases/lab02.json` (게이트 · 격리 · LLM) | Passed /6 |
|
||||
| `cases/lab02-gate.json` (기준을 바꾸면, 에이전트 없이) | Passed /6 |
|
||||
| `cases/lab02-challenge.json` (품질 기록 · 알림) | Passed /5 |
|
||||
| `cases/lab02-challenge2.json` (LLM 호출 수, 세 번) | Passed /3 · /3 · /3 |
|
||||
| `cases/lab03.json` (스키마) | Passed /4 |
|
||||
| `cases/lab03-fix.json` (고쳐서 다시) | Passed /4 |
|
||||
| `cases/lab03-schema.json` (새 스키마, 에이전트 없이) | Passed /5 |
|
||||
| `cases/lab03-challenge.json` (품질 보고서) | Passed /4 |
|
||||
| `cases/lab03-challenge2.json` (데이터셋별) | Passed /4 |
|
||||
|
||||
## 실패와 고친 내용
|
||||
|
||||
| 실패한 케이스 | 처음 결과 | 내가 생각한 원인 | 에이전트가 고친 곳 | 고친 뒤 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| | | | | |
|
||||
|
||||
## 규칙에서 헷갈린 것
|
||||
|
||||
-
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,451 @@
|
||||
# 78일차 개요 · 데이터 품질 검증 자동화와 품질 관리 Workflow
|
||||
|
||||
> 노코드 멀티 에이전트 워크플로우 설계 및 운영 · 2026-11-03
|
||||
> 30장 · 각 장은 가로줄(`---`)로 나뉩니다. 1교시에는 1~6장을 보고, 나머지 장은 각 단계 강의(1 · 3 · 5교시) 때 함께 띄웁니다.
|
||||
> 숫자는 모두 강사 노트북 실측입니다 (65일차 n8n 2.42.6, 78일차 판 데이터 API의 FAQ 배치 셋 · 직원 CSV · 접수 건, OpenAI `gpt-4.1-mini`(LLM 검사, temperature 0), 11/01 정답 워크플로우를 학생 순서대로 1회).
|
||||
|
||||
| 장 | 범위 | 띄우는 때 |
|
||||
| --- | --- | --- |
|
||||
| 1~6 | 표지 · 목표 · 시간표 · 같은 배치, 다른 판정 · 일하는 순서 · 실습 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
|
||||
B[("들어오는 데이터<br/>FAQ · 직원 · 접수 건")] --> R["규칙<br/>무엇이 나쁜가"] --> G["게이트<br/>막기 · 격리 · 알림"] --> M["관리<br/>스키마 · 고쳐서 다시 · 추세"]
|
||||
classDef d fill:#0F3D3E,stroke:#0F3D3E,color:#fff
|
||||
classDef o fill:#F2A541,stroke:#F2A541,color:#fff
|
||||
class B d
|
||||
class R,G,M o
|
||||
```
|
||||
|
||||
**78일차**
|
||||
|
||||
# 데이터 품질 검증 자동화와 품질 관리 Workflow
|
||||
|
||||
규칙은 말로 정하고 · 나쁜 데이터는 막고 알리고 · 고치는 것은 사람이
|
||||
|
||||
<sub>노코드 멀티 에이전트 워크플로우 설계 및 운영 · 2026-11-03</sub>
|
||||
|
||||
---
|
||||
|
||||
## 2 / 30 · 오늘 할 수 있게 될 세 가지
|
||||
|
||||
| | 목표 | 내용 |
|
||||
| --- | --- | --- |
|
||||
| 1 | **규칙을 세운다** | 나쁜 데이터를 눈으로 찾고, 규칙과 오류 코드로 바꾸고, 경계에서 시험하고, 이미 있는 데이터와 맞춰 보고, 보낸 사람에게 고칠 것을 돌려준다 |
|
||||
| 2 | **막는다** | 검사를 불러 나쁜 배치는 거절하고 나쁜 줄은 격리하고, 규칙으로 못 잡는 것은 LLM에게, 기준의 대가를 재고, 기록 · 알림을 남기고, LLM 호출을 숫자로 본다 |
|
||||
| 3 | **관리한다** | 규칙을 스키마(데이터)로 꺼내 넓히고, 고친 줄을 다시 넣어 격리함을 비우고, 새 스키마로 늘리고, 품질 보고서 · 데이터셋별 품질을 본다 |
|
||||
|
||||
---
|
||||
|
||||
## 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 검사 · 기준을 바꾸면 · 기록 · 알림 · LLM 호출 수 |
|
||||
| - | 13:00~14:00 | 점심 |
|
||||
| 5 | 14:00~14:50 | **3단계 강의** · 품질 관리 |
|
||||
| 6 | 15:00~15:50 | **Lab 03 실습 5개** · 스키마 검사 · 고쳐서 다시 · 새 스키마 · 품질 보고서 · 데이터셋별 |
|
||||
| 7 | 16:00~16:50 | 바이브 코딩 `tcheck`: 요청서 → TODO → SPEC → IMP-PLAN → 만들기 → 설치 |
|
||||
| 8 | 17:00~17:50 | 발표 · **4단계 정리** (의미 · 효과 · 회사에서 쓰는 법) · 79일차 예고 |
|
||||
|
||||
---
|
||||
|
||||
## 4 / 30 · 오늘의 출발점: 같은 배치, 다른 판정
|
||||
|
||||
77일차에 와이파이 FAQ 한 줄이 답 못 한 질문 34건을 풀었습니다. 그래서 콘텐츠 담당자가 FAQ를 배치로 보내기 시작했습니다. 첫 배치 B-1, 13줄.
|
||||
|
||||
| 질문 | 규칙표를 들고 눈으로 | 팀의 기준 검사 · 가져오기 |
|
||||
| --- | --- | --- |
|
||||
| 넣어도 되는 줄? | **2줄** (분명히 나쁜 8 · 판단 보류 3) | **10줄** 통과 |
|
||||
| 개인 전화번호 FAQ-109 | 나쁨 | 통과 |
|
||||
| 바깥 링크 FAQ-110 | 나쁨 | 통과 |
|
||||
| 실제로 FAQ에 들어간 것 | — | **12줄** (FAQ-003이 둘, 전화번호 · 단축 링크까지) |
|
||||
|
||||
나쁜 FAQ는 **답을 못 하는 것보다 나쁩니다** — 틀린 답을 자신 있게 보여 줍니다.
|
||||
|
||||
---
|
||||
|
||||
## 5 / 30 · 일하는 순서 (오늘은 "규칙 → 요청문 → 검사 → 확인")
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["① 먼저 보기<br/>눈 · call · test(전)"] --> B["② 규칙 · 요청문"] --> C["③ 에이전트<br/>my-work · check"] --> D["④ 내가<br/>push · test -v · quality"]
|
||||
D -->|"✘ has none of [코드]"| C
|
||||
D -->|✔| E["⑤ QUALITY.md 한 줄"]
|
||||
```
|
||||
|
||||
| 누가 | 무엇을 |
|
||||
| --- | --- |
|
||||
| 나 | 나쁜 데이터를 찾고 규칙으로 정하기, 기준 정하기, 나쁜 데이터를 **고치기** |
|
||||
| CLI 에이전트 | `AGENTS.md`대로 워크플로우 JSON, `check` |
|
||||
| `flow.py` | `test --webhook`(기준과 비교), `quality`(FAQ · 격리함 · 기록 · 알림), `diff` |
|
||||
|
||||
---
|
||||
|
||||
## 6 / 30 · 실습 15개 (Lab마다 예제 3 · 과제 2)
|
||||
|
||||
| | 예제 1 | 예제 2 | 예제 3 | 과제 1 | 과제 2 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Lab 01 | 눈으로 검사 · 기준 검사 (에이전트 없이) | 검사 워크플로우 | 경계에서 재 보기 (에이전트 없이) | 이미 있는 데이터 | 고침 목록 |
|
||||
| Lab 02 | 배치 게이트 · 격리 | LLM 검사 | 기준을 바꾸면 (에이전트 없이) | 품질 기록 · 알림 | LLM 호출 수 · 늘 같은가 |
|
||||
| Lab 03 | 스키마 검사 | 고쳐서 다시 넣기 | 스키마만 바꾸면 (에이전트 없이) | 품질 보고서 | 데이터셋별 품질 |
|
||||
|
||||
---
|
||||
|
||||
## 7 / 30 · 1단계 · 데이터 품질이란
|
||||
|
||||
> 생활 속 예: 식당 주방의 아침 식재료 검수 — 상자가 비어 있지 않다고 받는 게 아니라, 라벨 · 온도 · 날짜 · 주문한 물건인지를 본다.
|
||||
|
||||
| 품질 차원 (업계에서 쓰는 말) | 뜻 | 오늘 B-1에서 |
|
||||
| --- | --- | --- |
|
||||
| 완전성 (completeness) | 빠진 칸이 없나 | 빈 제목 · 빈 답 · 빈 키워드 |
|
||||
| 유효성 (validity) | 꼴 · 길이 · 값의 범위 | id 두 자리 · 300자 넘는 답 |
|
||||
| 고유성 (uniqueness) | 겹치지 않나 | 같은 배치에 FAQ-102가 둘 |
|
||||
| 일관성 (consistency) | 이미 있는 데이터와 맞나 | 이미 있는 FAQ-003 · 같은 제목 |
|
||||
| 정확성 (accuracy) | 내용이 맞나 | 토너 제목에 메일함 답 (LLM) |
|
||||
| 안전 (보안 · 개인정보) | 내보내도 되나 | 개인 휴대전화 · 단축 링크 |
|
||||
|
||||
---
|
||||
|
||||
## 8 / 30 · 규칙은 말이 먼저
|
||||
|
||||
> 생활 속 예: 벽에 붙은 검수 기준표 — "라벨 없는 고기는 반품, 냉장품은 5도 이하". 주방장이 바뀌어도 판정이 같다.
|
||||
|
||||
| | 기준 검사 (`base-faq-check`) | 오늘의 규칙 (AGENTS.md 3절) |
|
||||
| --- | --- | --- |
|
||||
| 규칙 수 | 3 (빈 값만) | 10 (+ 과제에서 이미 있는 데이터 · LLM) |
|
||||
| B-1 통과 | 10/13 | **5/13** — 눈으로 찾은 8줄과 같은 줄이 나쁨 |
|
||||
| 목록이 아닌 본문 | 500 (Code가 터짐) | **400** |
|
||||
| `lab01` 테스트 | 1/5 | **5/5** |
|
||||
|
||||
- 기준이 틀린 계산을 한 게 아니라 **규칙이 없었습니다.** 규칙을 요청문에 적으면 에이전트도 같은 판정을 냅니다.
|
||||
|
||||
---
|
||||
|
||||
## 9 / 30 · 오류 코드: 기계와 사람이 함께 읽는다
|
||||
|
||||
> 생활 속 예: 반품 상자에 붙이는 사유 스티커 — "온도"라고 짧게 붙여 두면 월말에 셀 수 있고, 납품업체에는 사유서로 풀어 쓴다.
|
||||
|
||||
| 같은 줄 | 코드 (기계가 셈) | 말 (사람이 고침, 과제 2) |
|
||||
| --- | --- | --- |
|
||||
| FAQ-103 | `title_missing` | 제목을 써 주세요 |
|
||||
| FAQ-109 | `pii` | 개인 전화번호 · 메일 주소를 빼고 IT 포털 안내로 바꿔 주세요 |
|
||||
| FAQ-003 | `exists_id` | 이미 있는 id입니다. 새 id를 쓰거나 … |
|
||||
|
||||
- 코드는 짧고 바뀌지 않음 → `codes`로 세고 Lab 03 보고서의 "가장 흔한 문제"가 됩니다. 말은 표 하나에서 — LLM으로 만들지 않습니다.
|
||||
|
||||
---
|
||||
|
||||
## 10 / 30 · 규칙은 Code가 확인한다
|
||||
|
||||
> 생활 속 예: 온도는 온도계가, 무게는 저울이 — 주방장의 감으로 재지 않는다.
|
||||
|
||||
| 판정 | 누가 | 까닭 |
|
||||
| --- | --- | --- |
|
||||
| 빈 값 · 길이 · 꼴 · 중복 · 전화번호 꼴 | **Code** | 정해진 답, 늘 같게, 0.1초 |
|
||||
| 이미 있는 데이터 | Code + 데이터 API | 목록을 읽어 비교 |
|
||||
| 답이 질문에 맞나 | **LLM** (Lab 02) | 뜻은 규칙으로 못 잼 |
|
||||
|
||||
- 13줄 검사 0.1초, LLM 없음. LLM에게 "이 FAQ 괜찮아?"를 물으면 날마다 판정이 다를 수 있고 줄마다 돈이 듭니다 (75일차).
|
||||
|
||||
---
|
||||
|
||||
## 11 / 30 · 경계에서 재 보기 (Lab 01 예제 3)
|
||||
|
||||
> 생활 속 예: 냉장고 온도 딱 5도 — 4도와 8도는 누구나 판정하지만, 5도와 5.1도는 기준표의 말("이하")이 정한다.
|
||||
|
||||
| 경계 | 규칙의 말 | 내 검사 | 기준 |
|
||||
| --- | --- | --- | --- |
|
||||
| 제목 40자 · 41자 | 40자 **넘으면** | ✔ 통과 · 막음 | ✘ 둘 다 통과 |
|
||||
| 공백만 있는 제목 | 공백 = 빈 값 | ✔ 막음 | ✘ 통과 |
|
||||
| `01012345678` · `02-123-4567` | 하이픈 **없어도** 휴대전화 | ✔ 막음 · 통과 | ✘ 둘 다 통과 |
|
||||
| `intra.example.com.evil.io` | 호스트가 **정확히** | ✔ 막음 | ✘ 통과 |
|
||||
|
||||
- **6/6 대 0/6.** 강사의 첫 판도 `evil.io`를 통과시켰습니다 — "앞이 같나"만 보고 "끝나나"를 안 봄.
|
||||
|
||||
---
|
||||
|
||||
## 12 / 30 · 이미 있는 데이터와 맞춰 보기 (Lab 01 과제 1)
|
||||
|
||||
> 생활 속 예: 상자를 받기 전에 냉장고를 연다 — 어제 받은 우유가 그대로면 같은 칸에 넣지 않는다.
|
||||
|
||||
| 줄 | 배치 안만 보면 | 이미 있는 FAQ와 맞추면 |
|
||||
| --- | --- | --- |
|
||||
| FAQ-003 "VPN 접속 오류 (새 판)" | 통과 | `exists_id` (오류) |
|
||||
| FAQ-112 "VPN 접속 오류" | 통과 | `same_title` (오류) + `keyword_used:vpn` (**경고**) |
|
||||
| B-1 통과 | 5줄 | **3줄** |
|
||||
|
||||
- 오류는 막고, 경고는 알리고 통과. 모든 것을 막으면 아무것도 못 넣고, 다 통과시키면 검사가 의미 없습니다 (참조 무결성).
|
||||
|
||||
---
|
||||
|
||||
## 13 / 30 · 보낸 사람에게 돌려준다 (Lab 01 과제 2)
|
||||
|
||||
> 생활 속 예: 반품 사유서 — "3번 상자 · 라벨 없음". 다음 납품이 나아지는 것은 사유서 덕분이다.
|
||||
|
||||
```text
|
||||
"message": "B-1: 13줄 중 3줄 통과, 10줄 고칠 것"
|
||||
{"row": 3, "id": "FAQ-103", "codes": ["title_missing"], "fix": ["제목을 써 주세요"]}
|
||||
{"row": 9, "id": "FAQ-109", "codes": ["pii"], "fix": ["개인 전화번호 · 메일 주소를 빼고 IT 포털 안내로 바꿔 주세요"]}
|
||||
```
|
||||
|
||||
- 업계 이름: **데이터 계약**(data contract) — 보내는 쪽과 받는 쪽이 모양 · 규칙을 미리 약속하고, 어기면 이유와 함께 돌려보냄.
|
||||
- 워크플로우는 **고쳐 주지 않습니다.** 빈 제목을 짐작해 채우면 틀린 FAQ가 "검사를 통과한 FAQ"가 됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 14 / 30 · 1단계 정리 · 규칙
|
||||
|
||||
| 정한 것 | 오늘 |
|
||||
| --- | --- |
|
||||
| 무엇이 나쁜가 | 차원 여섯 · 코드 열둘 (AGENTS.md 3절) |
|
||||
| 경계의 말 | 넘으면 · 공백 · 꼴 · 정확히 |
|
||||
| 막을 것 · 알릴 것 | 오류 vs 경고 |
|
||||
| 누구에게 무엇을 | 기계에는 코드, 사람에게는 말 |
|
||||
|
||||
- 숫자: 기준 1/5 → 5/5, 경계 6/6 대 0/6, B-1 통과 10 → 5 → 3, 고침 목록 10줄.
|
||||
|
||||
---
|
||||
|
||||
## 15 / 30 · 2단계 · 검사와 게이트는 다른 일
|
||||
|
||||
> 생활 속 예: 검수(무엇이 상했나)와 반품 결정(그래서 트럭을 돌려보내나)은 다른 일이다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
W(["배치"]) --> C["검사<br/>my-faq-check 를 부름"] --> G{"나쁜 줄 비율<br/>≤ 기준?"}
|
||||
G -->|아니오| R(["422 · 아무것도 안 넣음"])
|
||||
G -->|예| S["줄마다<br/>ok → FAQ · 아니면 → 격리함"]
|
||||
```
|
||||
|
||||
- 규칙을 다시 쓰지 않고 **검사를 부릅니다** — 규칙은 한 벌 (77일차 요약이 보고서를 부른 것처럼).
|
||||
|
||||
---
|
||||
|
||||
## 16 / 30 · 배치 게이트 · 줄 게이트 · 격리 (Lab 02 예제 1)
|
||||
|
||||
> 생활 속 예: 열에 일곱이 상했으면 트럭째 반품, 대부분 괜찮으면 좋은 것만 냉장고에, 이상한 것은 보류 칸에 사유를 붙여.
|
||||
|
||||
| 배치 | 기준 가져오기 | 내 가져오기 |
|
||||
| --- | --- | --- |
|
||||
| B-1 (나쁜 줄 10/13) | 12줄 넣음 | **422** · 0.7692 > 0.3 |
|
||||
| B-2 (메일 주소 1) | 10줄 넣음 | 9줄 · FAQ-209 격리 · `score` 0.9 |
|
||||
| B-3 (답 "짧") | 6줄 넣음 | 5줄 · FAQ-305 격리 |
|
||||
|
||||
- 격리함 = 버리지 않고 **이유와 함께** 보관 (76일차 DLQ와 같은 생각). 업계 이름: quarantine · dead-letter.
|
||||
|
||||
---
|
||||
|
||||
## 17 / 30 · LLM 검사 · 뜻은 LLM이 (Lab 02 예제 2)
|
||||
|
||||
> 생활 속 예: 무게 · 온도는 기준표로, "주문한 맛이 맞나"는 주방장이 한 숟가락 — 단, 이미 상한 것은 맛보지 않는다.
|
||||
|
||||
| | 규칙만 | + LLM |
|
||||
| --- | --- | --- |
|
||||
| FAQ-210 "모니터 밝기" · 답은 연차 휴가 | 넣음 | **격리** `llm_mismatch` |
|
||||
| B-2 결과 | 9줄 · 0.9 | 8줄 · 0.8 |
|
||||
| B-2 시간 | 1.4초 | **9.3초** (규칙을 통과한 9줄 × 1번) |
|
||||
|
||||
- 판정을 읽을 수 없으면 **넣지 않음** (fail-closed, 73일차). LLM이 막은 줄은 FAQ-210 **하나** — 드문 줄을 위해 줄마다 1초.
|
||||
|
||||
---
|
||||
|
||||
## 18 / 30 · 기준은 고르는 값 (Lab 02 예제 3)
|
||||
|
||||
> 생활 속 예: 반품 기준을 "열에 여덟까지"로 풀면 보류 칸이 넘치고, "하나도 안 돼"로 조이면 트럭이 다 돌아간다.
|
||||
|
||||
| 배치 | 기준 | 결과 |
|
||||
| --- | --- | --- |
|
||||
| B-1 | 0.3 | 거절 |
|
||||
| B-1 | 0.8 | 받음 — 넣음 **2** · 격리 **11** (사람이 11줄을 봄) |
|
||||
| B-2 | 0.05 | 거절 — 멀쩡한 9줄도 돌아감 |
|
||||
| B-2 | 0.1 | 받음 — **같으면 통과** |
|
||||
|
||||
- 앞 배치가 넣은 FAQ가 다음 배치의 판정을 바꿉니다 (`same_title` · `exists_id`) — 실험은 하나씩 떼어서.
|
||||
|
||||
---
|
||||
|
||||
## 19 / 30 · 품질 기록 · 알림 (Lab 02 과제 1)
|
||||
|
||||
> 생활 속 예: 검수 일지와 사장님 호출 — 일지가 쌓여야 "요즘 그 업체가 나빠졌다"를 말하고, 트럭째 반품한 날은 바로 전화한다.
|
||||
|
||||
| 배치 | 응답 | 기록 | 알림 |
|
||||
| --- | --- | --- | --- |
|
||||
| B-1 | 422 | `rejected: true` · 0 | `batch_rejected` |
|
||||
| B-2 | 200 · 0.8 | 8 · 2 · 0.8 | `low_quality` (0.9 미만) |
|
||||
|
||||
- 거절도 기록해야 보고서에 "가장 나쁜 배치"가 생깁니다. 알림은 곁가지 — 응답은 그대로 (`Result again`).
|
||||
|
||||
---
|
||||
|
||||
## 20 / 30 · LLM을 몇 번 · 늘 같은가 (Lab 02 과제 2)
|
||||
|
||||
> 생활 속 예: "오늘 맛보기 몇 번 했지? 상한 것까지 맛본 건 아니지? 어제와 오늘 판정이 같나?"
|
||||
|
||||
| 배치 | 줄 | `llm_calls` | 세 번 |
|
||||
| --- | --- | --- | --- |
|
||||
| B-1 (거절) | 13 | **0** | 같음 |
|
||||
| B-2 | 10 | **9** | FAQ-210만 · 9.4 · 8.1 · 7.6초 |
|
||||
| B-3 | 6 | **5** | 같음 |
|
||||
|
||||
- `--repeat`로 같은 배치를 연달아 보내면 2/6 — 판정이 흔들린 게 아니라 이미 넣은 FAQ 때문에 `exists_id`. "늘 같은가"는 **처음 상태에서** 세 번.
|
||||
|
||||
---
|
||||
|
||||
## 21 / 30 · 2단계 정리 · 게이트
|
||||
|
||||
| 정한 것 | 오늘 |
|
||||
| --- | --- |
|
||||
| 배치 · 줄의 두 관문 | 0.3 · `ok` |
|
||||
| 버리지 않기 | 격리함 + 이유 |
|
||||
| LLM은 어디에 | 규칙을 통과한 줄에만, 모르면 넣지 않음 |
|
||||
| 기준의 대가 | 거절 ↔ 격리 ↔ 사람의 일 |
|
||||
| 남기기 | 품질 기록 · 알림 · `llm_calls` |
|
||||
|
||||
- 숫자: 기준 0/6 → 5/6 → 6/6, B-1 12줄 → 0줄, FAQ-210 격리, 격리 13, LLM 9 · 5 · 0.
|
||||
|
||||
---
|
||||
|
||||
## 22 / 30 · 3단계 · 스키마: 규칙을 데이터로
|
||||
|
||||
> 생활 속 예: 품목마다 다른 검수 카드 — 채소 · 고기 · 생선마다 주방장을 두지 않고, 카드를 바꿔 들면 한 사람이 다 검수한다.
|
||||
|
||||
```json
|
||||
{"name": "employee", "key": "email", "fields": {
|
||||
"dept": {"type": "enum", "required": true, "values": ["영업팀", "재무팀", "인사팀", "개발팀", "IT팀", "총무팀"]},
|
||||
"joined": {"type": "date", "required": true}, "phone": {"type": "phone", "required": false}, …}}
|
||||
```
|
||||
|
||||
- 직원 CSV 9줄 → **4줄 통과** (메일 꼴 · `마케팅팀` · `2026-13-01` · 이름 빈칸 · 메일 중복). 같은 워크플로우로 FAQ B-3 → 5/6.
|
||||
- 업계 이름: JSON Schema · 테이블 정의 · Great Expectations의 "기대값".
|
||||
|
||||
---
|
||||
|
||||
## 23 / 30 · 스키마로 되는 것 · 안 되는 것
|
||||
|
||||
> 생활 속 예: 검수 카드에는 무게 · 온도 · 라벨 칸이 있지만, "이 납품업체와 이미 계약한 품목인가"는 장부를 봐야 안다.
|
||||
|
||||
| 규칙 | 스키마로 | 따로 |
|
||||
| --- | --- | --- |
|
||||
| 필수 · 길이 · 꼴 · 목록 · 날짜 · 중복 | ✔ | |
|
||||
| 답 안의 개인정보 · 바깥 링크 | | Code (Lab 01) |
|
||||
| 이미 있는 데이터 | | Code + 데이터 API |
|
||||
| 답이 질문에 맞나 | | LLM |
|
||||
|
||||
- 모양은 스키마로 **넓게**, 그 데이터만의 규칙은 Code로 **깊게**.
|
||||
|
||||
---
|
||||
|
||||
## 24 / 30 · 되돌림 고리 (Lab 03 예제 2)
|
||||
|
||||
> 생활 속 예: 라벨을 붙여 다시 가져온 고기를 받았으면 보류 칸의 옛 쪽지를 뗀다 — 안 떼면 내일 또 전화한다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Q[("격리함<br/>FAQ-209 · 210")] -.->|사람이 고침| F(["B-2-fix"]) --> I["가져오기"] --> X["넣은 id만<br/>격리함에서 뺌"] --> Q
|
||||
```
|
||||
|
||||
| | 고치기 전 | 고친 뒤 |
|
||||
| --- | --- | --- |
|
||||
| 넣기 | 2줄 · `score` 1 | 같음 |
|
||||
| 격리함 | **2** (해결된 줄이 남음) | **0** |
|
||||
|
||||
---
|
||||
|
||||
## 25 / 30 · 스키마만 바꾸면 (Lab 03 예제 3)
|
||||
|
||||
> 생활 속 예: 디저트를 시작해 생크림이 들어온다 — 주방장을 새로 뽑지 않고 "생크림 카드" 한 장.
|
||||
|
||||
| 접수 건 9줄 | FAQ 검사 | 스키마 검사 (고치지 않음) |
|
||||
| --- | --- | --- |
|
||||
| 통과 | 0줄 (칸이 달라 모두 나쁨) | **2줄** |
|
||||
| 이유 | `id_format` · `answer_missing` 9 | `T-12` · `urgent` · **2월 30일** · 3자 제목 · 연락처 꼴 · 중복 · 요청자 빈칸 |
|
||||
|
||||
- 워크플로우를 한 줄도 고치지 않았습니다 — `ticket.json` 한 장. 그래서 **스키마 변경 = 규칙 변경**, 리뷰가 필요 (74일차 관문).
|
||||
|
||||
---
|
||||
|
||||
## 26 / 30 · 품질 보고서 · 품질도 정의가 먼저 (Lab 03 과제 1)
|
||||
|
||||
> 생활 속 예: 월말 검수 일지 정리 — "이번 달 어땠어?"에 늘 같은 방식으로 답한다.
|
||||
|
||||
| 칸 | 값 | 정한 것 |
|
||||
| --- | --- | --- |
|
||||
| 배치 · 평균 | 4 · **0.6583** | 거절(0)도 넣음 (빼면 0.8778) |
|
||||
| 가장 나쁜 배치 | B-1 | 같으면 먼저 나온 것 |
|
||||
| 가장 흔한 문제 | `pii` 2 | 같으면 코드 글자순 |
|
||||
| 추세 | 좋아짐 | 마지막 − 첫 > 0.05 |
|
||||
|
||||
- 함정: 추세가 "13줄 거절 → 고친 2줄"을 비교한 것 — 숫자는 정의대로지만 **무엇을 비교했는지** 함께 말한다.
|
||||
|
||||
---
|
||||
|
||||
## 27 / 30 · 데이터셋별 · 평균이 가리는 것 (Lab 03 과제 2)
|
||||
|
||||
> 생활 속 예: 한 달 평균은 "괜찮다"인데 업체별로 나누면 생선 가게만 절반이 반품 — 전화할 곳은 생선 가게.
|
||||
|
||||
| 데이터셋 | 평균 | 가장 흔한 문제 |
|
||||
| --- | --- | --- |
|
||||
| employee (HR-1) | 0.4444 | `bad_format:email` |
|
||||
| ticket (TK-1) | **0.2222** ← 가장 손볼 곳 | `bad_format:contact` |
|
||||
| faq (B-3) | 0.8333 | `answer_too_short` |
|
||||
| 전체 | 0.5 | — |
|
||||
|
||||
- 검사도 같은 모양으로 기록하니 보고서를 고치지 않고 셋이 보임. 섞은 추세는 뜻이 없음 — 추세는 데이터셋 안에서.
|
||||
|
||||
---
|
||||
|
||||
## 28 / 30 · 식당 주방으로 본 오늘 하루
|
||||
|
||||
> 생활 속 예: 오늘 만든 것이 모두 식당 주방에 있다 (3단계 8절 표).
|
||||
|
||||
| 오늘 | 주방 |
|
||||
| --- | --- |
|
||||
| 품질 규칙 · 오류 코드 | 검수 기준표 · 반품 사유 스티커 |
|
||||
| 경계 | 냉장고 온도 딱 5도 |
|
||||
| 이미 있는 데이터 · 고침 목록 | 냉장고 먼저 열기 · 반품 사유서 |
|
||||
| 배치 게이트 · 격리 · 기준 | 트럭째 반품 · 보류 칸 · 반품 기준 |
|
||||
| LLM 검사 · 호출 수 | 맛보기 · 맛보기 횟수 |
|
||||
| 품질 기록 · 알림 | 검수 일지 · 사장님 호출 |
|
||||
| 스키마 · 새 스키마 · 되돌림 | 품목별 검수 카드 · 생크림 카드 · 보류 칸 쪽지 떼기 |
|
||||
| 보고서 · 데이터셋별 | 월말 일지 정리 · 업체별 성적표 |
|
||||
|
||||
---
|
||||
|
||||
## 29 / 30 · 7교시 바이브 코딩 · `tcheck`
|
||||
|
||||
요청서(인사팀 · 콘텐츠 담당의 말 ①~⑫) → `Doc/TODO.md` → `SPEC.md` → `IMP-PLAN.md` → 단계별로 → `uv tool install`.
|
||||
|
||||
| 오늘 n8n에서 | `tcheck`로 터미널에서 (보내기 전에) |
|
||||
| --- | --- |
|
||||
| `my-check` (스키마) | `tcheck check` — **같은 배치면 같은 판정** |
|
||||
| 배치 게이트 | `tcheck gate --max-invalid 0.3` — 거절이면 종료 코드 4 |
|
||||
| 격리 | `--out` 깨끗한 줄 · `--rejects` 나쁜 줄 + 이유 |
|
||||
| 내용 규칙 · 고침 목록 · 품질 기록 | `--content answer` · `--fix` · `--log` (나중 단계) |
|
||||
| (없음) | CSV · JSON · 요청 파일, TUI 탭 · 거르기 · 내보내기 |
|
||||
|
||||
- 강사 본: 직원 · 접수 건 · FAQ B-3의 `rows` · `codes`가 `my-check` 응답과 **모두 같음**, 직원 `tcheck gate` → 종료 코드 4, 테스트 22개 3초 (n8n 없이).
|
||||
|
||||
---
|
||||
|
||||
## 30 / 30 · 오늘의 정리 · 79일차 예고
|
||||
|
||||
| 단계 | 오늘 숫자 |
|
||||
| --- | --- |
|
||||
| 규칙 | 기준 10/13 통과 · 1/5 → 규칙대로 5/13 · 5/5, 경계 6/6 대 0/6, 이미 있는 데이터 3/13, 고침 목록 10줄 |
|
||||
| 게이트 | 기준 가져오기 12줄 넣음 → B-1 거절 · B-2 9줄 · LLM이 FAQ-210, 기준 0.8이면 격리 11, LLM 호출 9 · 5 · 0 |
|
||||
| 관리 | 직원 4/9 · 접수 건 2/9 (워크플로우 그대로), 격리 2 → 0, 평균 0.6583, ticket 0.2222가 가장 손볼 곳 |
|
||||
|
||||
- 8교시 [4단계 정리](04-STAGE4-WRAPUP.md): 의미 · 효과 · 업계 이름 · 면접 · 최종 프로젝트 체크리스트.
|
||||
- 79일차 (11/4): **운영 데이터 기반 Workflow Ops Assistant** — 77일차 실행 기록 · 78일차 품질 기록 · 76일차 알림을 한데 모아, 운영자가 "지금 무엇이 문제야?"라고 물으면 숫자로 답하는 도우미를 만든다.
|
||||
@@ -0,0 +1,91 @@
|
||||
# 78일차 실습 준비 · 수업 전에 미리 해 두면 좋은 것
|
||||
|
||||
- 수업일: 2026-11-03 (78일차) · 데이터 품질 검증 자동화와 품질 관리 Workflow
|
||||
- 준비 시간: 약 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 켜기<br/>(78일차 판)"] --> B["2. quality ·<br/>doctor ready"] --> C["3. 미리 읽기"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. n8n과 데이터 API 켜기 (전날 필수, 3분)
|
||||
|
||||
오늘은 **78일차 판 데이터 API**(77일차 + 격리함 · 스키마 · 품질 기록)를 씁니다. 같은 이름이라 이 폴더에서 다시 만들면 바뀝니다.
|
||||
|
||||
```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/78/data-api
|
||||
docker compose up -d --force-recreate
|
||||
curl -s localhost:8800/schemas/employee | head -c 80 # {"name": "employee", "key": "email", ...
|
||||
```
|
||||
|
||||
- **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/78
|
||||
uv run tools/flow.py push reference/baseline-faq-check.json reference/baseline-faq-import.json # 팀의 기준 (Lab 01 · 02에서 비교용)
|
||||
uv run tools/flow.py quality
|
||||
uv run tools/flow.py doctor
|
||||
```
|
||||
|
||||
```text
|
||||
FAQ: 6 (nothing added)
|
||||
quarantine: 0
|
||||
quality log: 0
|
||||
alerts: 0
|
||||
...
|
||||
✔ schemas faq · employee · ticket, quarantine, quality log (day 78 data API)
|
||||
✔ credential 'OpenAI (.env)' (openAiCourse0001)
|
||||
✔ CLI coding agent: codex, claude, agy
|
||||
ready
|
||||
```
|
||||
|
||||
| ✘ 항목 | 할 일 |
|
||||
| --- | --- |
|
||||
| n8n container · data API | 1절 |
|
||||
| schemas | 데이터 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 "AI가 틀린 답을 자신 있게 했어요"
|
||||
|
||||
77일차에 FAQ 한 줄이 문제를 풀었습니다. 그런데 그 FAQ가 틀렸다면? 답을 못 하는 헬프데스크보다 **틀린 답을 하는** 헬프데스크가 더 위험합니다. 워크플로우가 믿고 쓰는 데이터는 들어오는 곳에서 검사해야 합니다. `requests/faq-batch-1.json`을 열어 넣으면 안 되는 줄을 찾아보세요.
|
||||
|
||||
### 3.2 용어
|
||||
|
||||
| 용어 | 한 줄 설명 |
|
||||
| --- | --- |
|
||||
| 품질 규칙 | "깨끗하다"를 누가 봐도 같게 판단하는 규칙 (예: 답 10자 이상) |
|
||||
| 오류 코드 | 줄이 걸린 이유를 짧은 이름으로 (`pii`, `bad_format:email`) |
|
||||
| 게이트 | 기준을 넘지 못하면 들여보내지 않는 곳 (74일차 릴리스 게이트와 같은 생각) |
|
||||
| 격리함 | 나쁜 줄을 버리지 않고 이유와 함께 두는 곳 (76일차 DLQ와 같은 생각) |
|
||||
| 스키마 | 데이터의 모양(필드 · 타입 · 필수)을 적은 문서 |
|
||||
| 경계 | 기준선 바로 위 · 아래의 값 (딱 40자, 공백만, 하이픈 없는 번호) |
|
||||
| 기준 검사 · 기준 가져오기 | 팀이 지난달 급히 만든 워크플로우 (`base-faq-check` · `base-faq-import`) — 오늘 내 것과 비교할 상대 |
|
||||
|
||||
## 4. 당일 준비물
|
||||
|
||||
- 충전된 노트북과 충전기, WSL의 Docker, Windows Terminal(WSL) 창 **두 개**, 웹 브라우저
|
||||
- 77일차 `~/vibe/tlog` (지우지 않기, 79일차에 씀)
|
||||
- 7교시 [요청서](../../Last3Mon/78/VIBE-REQ-TCHECK.md)를 미리 한 번 읽어 오면 좋습니다
|
||||
|
||||
## 확인
|
||||
|
||||
- [ ] `curl -s localhost:8800/schemas/employee`에 `"key": "email"`
|
||||
- [ ] `flow.py push`로 `base-faq-check` · `base-faq-import` 게시
|
||||
- [ ] `flow.py quality`가 FAQ 6 · 격리 0을 보여 줌
|
||||
- [ ] `uv run tools/flow.py doctor` → 마지막 줄 `ready`
|
||||
@@ -0,0 +1,256 @@
|
||||
# 1단계 · 품질 규칙
|
||||
|
||||
- 교시: 1교시 강의 (09:00~09:50) · 2교시 [Lab 01](../../Last3Mon/78/LAB-01-Q-QUALITY-RULES.md) 실습 5개 (10:00~10:50)
|
||||
- 이 단계가 끝나면: 들어오는 데이터의 "나쁨"을 품질 차원으로 나눠 말하고, 그것을 **말로 정한 규칙과 오류 코드**로 바꾸고, 규칙을 Code로 확인하는 검사를 만들고, 경계(딱 기준선 · 공백 · 꼴 · 비슷한 주소)에서 재 보고, 이미 있는 데이터와 맞춰 보고, 보낸 사람에게 고칠 것을 돌려줄 수 있다.
|
||||
- 숫자: 모두 강사 노트북 실측입니다 (65일차 n8n 2.42.6, 78일차 판 데이터 API, FAQ 배치 B-1 13줄 · B-2 10줄 · 경계 배치 10줄, 11/01 실습을 5개로 늘린 뒤 정답 워크플로우를 학생 순서대로 1회). 규칙 판정은 누가 돌려도 같습니다. 출력 전부는 수업 뒤 공개되는 `Last3Mon/78/answers/LAB-TEST-RESULT.md`에 있습니다.
|
||||
- 실습 가리키기: 이 문서에서 "Lab 01 예제 1"은 2교시 [Lab 01 문서](../../Last3Mon/78/LAB-01-Q-QUALITY-RULES.md)의 그 절입니다. 파일 경로(`cases/` · `requests/` · `my-work/` · `reference/`)는 실습 폴더 `~/aio-01d4-stu/Last3Mon/78` 기준입니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["1. 데이터 품질"] --> B["2. 규칙은 말이 먼저"] --> C["3. 오류 코드"] --> D["4. 규칙은 Code가"] --> E["5. 경계"]
|
||||
E --> F["6. 이미 있는 데이터"] --> G["7. 보낸 사람에게"] --> H["8. 요청문으로"]
|
||||
```
|
||||
|
||||
## 0. 이 시간 (50분)
|
||||
|
||||
| 시간 | 절 · 개요 | 하는 일 |
|
||||
| --- | --- | --- |
|
||||
| 09:00~09:06 | 개요 1~6장 | 오늘 하루, 같은 배치 다른 판정, 실습 15개 |
|
||||
| 09:06~09:13 | 1 · 개요 7장 | 데이터 품질의 차원 |
|
||||
| 09:13~09:20 | 2 · 3 · 개요 8~9장 | 규칙은 말이 먼저, 오류 코드 |
|
||||
| 09:20~09:26 | 4 · 개요 10장 | 규칙은 Code가 |
|
||||
| 09:26~09:33 | 5 · 개요 11장 | 경계 |
|
||||
| 09:33~09:43 | 6 · 7 · 개요 12~14장 | 이미 있는 데이터 · 오류와 경고, 고침 목록 · 데이터 계약 |
|
||||
| 09:43~09:50 | 8 · 9 · 확인 질문 | 요청문, 자주 하는 실수 |
|
||||
|
||||
### 2교시 Lab 01에서 이어서 하는 것
|
||||
|
||||
| 2교시 실습 (시각) | 이 강의의 절 | 실습에서 하는 일 | 쓰는 파일 · 명령 |
|
||||
| --- | --- | --- | --- |
|
||||
| 시작 (10:00) | — | 품질 규칙표 복사 | `cp templates/QUALITY-RULES-TEMPLATE.md my-work/QUALITY.md` (오늘의 판정을 모두 여기에) |
|
||||
| Lab 01 예제 1 (10:00~10:10, 에이전트 없이) | 1 · 2 | B-1을 눈으로 검사, 같은 배치를 기준 검사에 | `call base-faq-check requests/faq-batch-1.json` → `QUALITY.md` 1절 |
|
||||
| Lab 01 예제 2 (10:10~10:25) | 2 · 3 · 4 | 규칙대로 판정하는 내 검사 | `my-work/faq-check.json` (새로), 테스트 `cases/lab01.json` (전: `--webhook base-faq-check`) |
|
||||
| Lab 01 예제 3 (10:25~10:32, 에이전트 없이) | 5 | 경계에서 두 검사를 재 보기 | `test cases/lab01-edges.json -v` · `--webhook base-faq-check` → `QUALITY.md` 3절 |
|
||||
| Lab 01 과제 1 (10:32~10:40) | 6 | 이미 있는 FAQ와 맞춰 보기 | 테스트 `cases/lab01-challenge.json` |
|
||||
| Lab 01 과제 2 (10:40~10:50) | 7 | 보낸 사람에게 돌려줄 고침 목록 | 테스트 `cases/lab01-challenge2.json` → `QUALITY.md` 4절 |
|
||||
|
||||
## 1. 데이터 품질: 무엇이 "나쁜" 데이터인가 (2교시 Lab 01 예제 1)
|
||||
|
||||
### 일반 설명
|
||||
|
||||
> **생활 속 예 · 식당 주방의 아침 검수.** 아침마다 납품 트럭이 오면 주방장이 상자를 엽니다. 무른 채소, 라벨 없는 고기, 주문하지 않은 물건, 유통기한이 지난 우유를 골라냅니다. "상자가 비어 있지 않으면 받는다"로는 식당이 망합니다. 워크플로우가 믿고 쓰는 데이터(오늘은 FAQ)가 이 식재료이고, 오늘은 검수를 자동으로 하는 날입니다.
|
||||
|
||||
**데이터 품질(data quality)은 "이 데이터를 믿고 써도 되나"입니다.** 77일차까지는 실행 기록을 **믿고** 셌습니다. 그런데 워크플로우가 읽는 데이터(FAQ · 직원 목록 · 접수 건)가 틀리면, 워크플로우는 틀린 답을 **자신 있게** 냅니다 — 답을 못 하는 것보다 나쁩니다. 업계에서는 "나쁨"을 몇 가지 **차원**으로 나눠 말합니다.
|
||||
|
||||
| 차원 | 묻는 것 | 예 |
|
||||
| --- | --- | --- |
|
||||
| 완전성 (completeness) | 꼭 있어야 할 칸이 있나 | 빈 제목 · 빈 답 |
|
||||
| 유효성 (validity) | 꼴 · 길이 · 값의 범위가 맞나 | id 꼴, 300자 넘는 답, 없는 날짜 |
|
||||
| 고유성 (uniqueness) | 겹치지 않나 | 같은 id가 둘 |
|
||||
| 일관성 (consistency) | 다른 데이터와 맞나 | 이미 있는 id · 같은 제목 |
|
||||
| 정확성 (accuracy) | 내용이 사실 · 질문과 맞나 | 토너 제목에 메일함 답 |
|
||||
| 적시성 (timeliness) | 지금도 맞나 | 지난 정책의 답 (오늘은 다루지 않음) |
|
||||
|
||||
여기에 FAQ처럼 **사람에게 그대로 보여 주는** 데이터는 안전(개인정보 · 위험한 링크)도 품질입니다. 업계 도구로는 Great Expectations · Soda · dbt tests · Deequ 같은 "데이터 검사" 도구가 있고, 데이터 파이프라인(ETL)의 한 단계로 검사를 둡니다. 오늘은 n8n 워크플로우로 같은 일을 합니다.
|
||||
|
||||
### 오늘 실습에서
|
||||
|
||||
B-1 13줄을 AGENTS.md 3절 규칙표를 들고 눈으로 읽으면 (Lab 01 예제 1):
|
||||
|
||||
| 판정 | 줄 | 차원 |
|
||||
| --- | --- | --- |
|
||||
| 분명히 나쁨 (8) | FAQ-103 빈 제목 · FAQ-105 빈 답 · FAQ-108 빈 키워드 | 완전성 |
|
||||
| | FAQ-10 id 두 자리 · FAQ-106 300자 넘는 답 | 유효성 |
|
||||
| | 둘째 FAQ-102 | 고유성 |
|
||||
| | FAQ-109 개인 휴대전화 · FAQ-110 단축 링크 | 안전 |
|
||||
| 판단 보류 (3) | FAQ-003 · FAQ-112 (이미 있는 FAQ와 겹침), FAQ-113 (답이 다른 이야기) | 일관성 · 정확성 |
|
||||
| 좋음 (2) | FAQ-101 · FAQ-102 | |
|
||||
|
||||
- 같은 배치를 팀의 기준 검사에 보내면 **10줄 통과** — 빈 값 셋만 잡았습니다 (2절).
|
||||
|
||||
## 2. 규칙은 말이 먼저: 같은 배치, 다른 판정 (2교시 Lab 01 예제 1 · 2)
|
||||
|
||||
### 일반 설명
|
||||
|
||||
> **생활 속 예 · 벽에 붙은 검수 기준표.** 주방장마다 보는 눈이 다르면 같은 상자가 어떤 날은 받히고 어떤 날은 반품됩니다. 그래서 식당은 "라벨 없는 고기는 반품, 냉장품은 5도 이하"처럼 기준표를 벽에 붙입니다. 사람이 바뀌어도 판정이 같아지는 것은 기준표 덕분입니다.
|
||||
|
||||
**검사 = 규칙 + 판정.** "나쁜 FAQ를 걸러 줘"라는 말만으로는 판정이 하나로 정해지지 않습니다. 77일차의 "지표는 정의가 먼저"와 같습니다. 규칙은 적어도 이것을 정해야 합니다.
|
||||
|
||||
| 정해야 할 것 | 예 | 정하지 않으면 |
|
||||
| --- | --- | --- |
|
||||
| 무엇을 보나 | 제목 · 답 · 키워드 · id | 한두 칸만 봄 |
|
||||
| 꼴 · 범위 | `FAQ-` + 숫자 3자리, 답 10~300자 | 사람마다 다름 |
|
||||
| 경계 | 40자 **넘으면**, 공백은 빈 값 | 딱 기준선의 줄이 날마다 다르게 |
|
||||
| 판정의 단위 | 줄마다 · 배치마다 | "B-1은 나쁘다"로 끝남 |
|
||||
| 오류와 경고 | 막을 것 · 알릴 것 (6절) | 모든 것을 막거나 모두 통과 |
|
||||
|
||||
규칙이 요청문에 없으면 에이전트도 자기 방식으로 판정합니다 — 오늘 팀의 기준 검사처럼요.
|
||||
|
||||
### 오늘 실습에서
|
||||
|
||||
| | 기준 검사 (`base-faq-check`) | 내 검사 (`my-faq-check`, Lab 01 예제 2) |
|
||||
| --- | --- | --- |
|
||||
| 규칙 | 빈 제목 · 빈 답 · 빈 키워드 | AGENTS.md 3절 열 가지 |
|
||||
| B-1 | 13줄 중 **10줄** 통과 | **5줄** 통과 — 눈으로 찾은 8줄과 같은 줄이 나쁨 |
|
||||
| B-2 | 10줄 모두 통과 | 9줄 (FAQ-209 메일 주소), 사내 링크 FAQ-207은 통과 |
|
||||
| `items`가 목록이 아님 | **500** (Code가 터짐) | **400** `{"error": "items must be a list"}` |
|
||||
| `cases/lab01.json` | **1/5** | **5/5** |
|
||||
|
||||
- 기준이 틀린 계산을 한 것이 아니라 **규칙이 셋뿐**이었습니다. "버그 찾기"가 아니라 "규칙을 정해 그대로 대기"가 고치는 일입니다.
|
||||
|
||||
## 3. 오류 코드: 기계와 사람이 함께 읽는다 (2교시 Lab 01 예제 2)
|
||||
|
||||
### 일반 설명
|
||||
|
||||
> **생활 속 예 · 반품 사유 스티커.** 주방장은 반품 상자에 "온도" · "라벨"처럼 짧은 스티커를 붙입니다. 월말에 스티커를 세면 "이번 달 반품의 절반이 온도"가 보입니다. 납품업체에는 스티커를 풀어 쓴 사유서를 보냅니다. 짧은 표시와 긴 설명을 나눠 두는 것입니다.
|
||||
|
||||
**줄마다 이유를 코드로 붙입니다.** "B-1은 나쁘다"로는 아무도 고칠 수 없습니다. "FAQ-109는 `pii`"라고 해야 그 줄을 고칩니다. 코드는 짧고 바뀌지 않는 영어(`title_missing` · `pii`)로 정해 두고, 사람이 읽는 말은 따로 붙입니다 (7절). 코드가 같아야 배치마다 몇 번 나왔는지 세고(`codes`), 품질 보고서에서 "가장 흔한 문제"를 고를 수 있습니다 (3단계).
|
||||
|
||||
| 코드의 성질 | 까닭 |
|
||||
| --- | --- |
|
||||
| 짧고 정해진 이름 | 기계가 셈 · 비교 (`codes.pii`) |
|
||||
| 한 줄에 여럿 | 제목도 비고 키워드도 비면 둘 다 |
|
||||
| 판정과 따로 | `ok`는 `errors`가 비었을 때만 — 규칙을 더해도 판정 방식은 그대로 |
|
||||
|
||||
### 오늘 실습에서
|
||||
|
||||
B-1을 내 검사로 보면 `codes`에 여덟 가지가 1건씩입니다: `title_missing` · `id_format` · `answer_missing` · `answer_too_long` · `duplicate_in_batch` · `keywords_missing` · `pii` · `external_link`. 결과의 `rows`는 들어온 줄과 같은 순서 · 같은 수입니다 (AGENTS.md 4절) — 테스트가 `rows.8.errors`처럼 줄 번호로 봅니다.
|
||||
|
||||
## 4. 규칙은 Code가 확인한다 (2교시 Lab 01 예제 2)
|
||||
|
||||
### 일반 설명
|
||||
|
||||
> **생활 속 예 · 온도는 온도계로.** 냉장고 온도를 주방장의 손등으로 재지 않습니다. 온도계는 누가 들어도 같은 숫자를 줍니다. 대신 "이 소스, 맛이 맞나?"는 온도계로 잴 수 없어 사람이 맛봅니다.
|
||||
|
||||
**정해진 답이 있는 판정은 Code로.** 빈 값 · 길이 · 꼴(정규식) · 중복 · 전화번호 꼴은 같은 줄에 늘 같은 판정이 나와야 합니다. Code는 그렇게 하고, 빠르고(13줄 0.1초), 돈이 들지 않습니다. LLM에게 "이 FAQ 괜찮아?"를 물으면 그럴듯하지만, 날마다 판정이 다를 수 있고 줄마다 비용 · 시간이 듭니다 (75일차). LLM은 규칙으로 알 수 없는 것(답이 질문에 맞나)에만 씁니다 — 2단계에서.
|
||||
|
||||
| 판정 | 누가 |
|
||||
| --- | --- |
|
||||
| 꼴 · 길이 · 빈 값 · 중복 · 전화 · 링크 | Code |
|
||||
| 이미 있는 데이터와 겹침 | Code + 데이터 API (6절) |
|
||||
| 뜻 (답이 질문에 맞나) | LLM (2단계) |
|
||||
| 고치기 | 사람 |
|
||||
|
||||
### 오늘 실습에서
|
||||
|
||||
내 검사는 `Check webhook` → `Is a list?`(IF) → `Check rows`(Code 하나) → `Send result`. Code에 들어가기 **전에** 모양을 봅니다: `items`가 목록이 아니면 400으로 돌려보내야 부른 쪽이 "내 본문이 틀렸다"를 압니다. 기준 검사처럼 Code 안에서 터지면 500 — "서버가 고장났다"로 읽힙니다.
|
||||
|
||||
## 5. 경계: 규칙은 기준선에서 시험받는다 (2교시 Lab 01 예제 3)
|
||||
|
||||
### 일반 설명
|
||||
|
||||
> **생활 속 예 · 냉장고 온도 딱 5도.** 기준표에 "냉장품 5도 이하"라고 적혀 있습니다. 4도와 8도는 누구나 판정합니다. 하지만 딱 5도는? "이하"라는 한 글자가 정합니다. 온도계 없이 "차갑네"로 판정하면 5.1도도 받습니다.
|
||||
|
||||
**경계(edge)는 기준선 바로 위 · 아래 · 위에 있는 값입니다.** 검사의 실수는 대부분 경계에서 나옵니다. 보통 줄(빈 제목, 아주 긴 답)만 시험하면 "잘 된다"고 믿게 됩니다. 규칙에는 경계의 **말**이 들어가야 합니다.
|
||||
|
||||
| 경계 | 정해야 할 말 | 말이 없으면 |
|
||||
| --- | --- | --- |
|
||||
| 딱 기준 길이 | "넘으면" / "이상이면" | 40자 제목이 막히거나 41자가 들어감 |
|
||||
| 공백만 있는 값 | "공백은 빈 값" | `" "`가 "있음"으로 통과 |
|
||||
| 꼴의 변형 | "하이픈 · 점 · 공백이 없어도" | `01012345678`이 그대로 나감 |
|
||||
| 비슷한 주소 | "호스트가 **정확히**" | `intra.example.com.evil.io`가 사내 주소로 (피싱이 쓰는 수법, 73일차) |
|
||||
|
||||
### 오늘 실습에서
|
||||
|
||||
경계 배치(`requests/faq-edges.json`, 10줄)를 두 검사에 (Lab 01 예제 3):
|
||||
|
||||
| 경계 | 내 검사 | 기준 |
|
||||
| --- | --- | --- |
|
||||
| 40자 · 41자 제목 | ✔ 통과 · `title_too_long` | ✘ 둘 다 통과 |
|
||||
| 공백만 있는 제목 | ✔ `title_missing` | ✘ 통과 |
|
||||
| 10자 · 9자 답 | ✔ 통과 · `answer_too_short` | ✘ 둘 다 통과 |
|
||||
| `01012345678` · `02-123-4567` | ✔ `pii` · 통과 | ✘ 둘 다 통과 |
|
||||
| 사내 주소 · `evil.io` | ✔ 통과 · `external_link` | ✘ 둘 다 통과 |
|
||||
| `faq-410` | ✔ `id_format` | ✘ 통과 |
|
||||
|
||||
- **6/6 대 0/6.** 강사의 첫 판도 `evil.io`를 통과시켰습니다 — 정규식이 "뒤에 `intra.example.com`이 오나"만 보고 거기서 **끝나는지**는 보지 않았기 때문입니다. 테스트에 경계 줄을 넣어 두면 이런 실수를 미리 잡습니다.
|
||||
|
||||
## 6. 이미 있는 데이터: 오류와 경고 (2교시 Lab 01 과제 1)
|
||||
|
||||
### 일반 설명
|
||||
|
||||
> **생활 속 예 · 냉장고를 먼저 연다.** 납품 상자만 보면 멀쩡한 우유입니다. 그런데 냉장고에 어제 받은 우유가 그대로라면 같은 칸에 넣으면 안 됩니다. 반면 "같은 회사 제품"인 것은 흔하고 괜찮습니다 — 알아만 두면 됩니다.
|
||||
|
||||
**들어오는 데이터만이 아니라 이미 있는 데이터와의 관계도 봅니다.** 데이터베이스에서는 참조 무결성(referential integrity) · 유일 제약(unique constraint)이라고 부릅니다. 같은 id로 들어오면 기존 데이터를 덮어쓰거나 두 벌이 되고, 같은 제목이 둘이면 검색이 어느 답을 줄지 모릅니다.
|
||||
|
||||
그리고 **막을 것과 알릴 것**을 나눕니다. 모든 것을 오류로 막으면 아무것도 들어가지 못하고, 모두 통과시키면 검사가 의미 없습니다.
|
||||
|
||||
| | 오류 (`errors`) | 경고 (`warnings`) |
|
||||
| --- | --- | --- |
|
||||
| 뜻 | 들어가면 안 됨 | 사람이 보고 판단 |
|
||||
| 판정 | `ok: false` | `ok`에 영향 없음 |
|
||||
| 예 | 이미 있는 id · 같은 제목 | 이미 쓰는 키워드 |
|
||||
|
||||
### 오늘 실습에서
|
||||
|
||||
검사 앞에 `Get FAQ`(`GET /faq-all`, 처음 6개 + 오늘 더한 것)를 두면 (Lab 01 과제 1): FAQ-003 `exists_id`, FAQ-112 `same_title` + 경고 `keyword_used:vpn`, B-1 통과 5 → **3**줄. 테스트가 막 더한 FAQ-201도 `exists_id` — "이미 있는 데이터"는 파일이 아니라 **지금** 데이터 API에 있는 것입니다.
|
||||
|
||||
## 7. 보낸 사람에게: 고침 목록 · 데이터 계약 (2교시 Lab 01 과제 2)
|
||||
|
||||
### 일반 설명
|
||||
|
||||
> **생활 속 예 · 반품 사유서.** "안 받아요"만 말하면 납품업체는 무엇이 잘못됐는지 모릅니다. "3번 상자 · 라벨 없음, 7번 상자 · 냉장 온도 9도"라고 적어 주면 다음 납품이 나아집니다.
|
||||
|
||||
**검사의 결과는 기계와 사람이 둘 다 읽습니다.** 다음 워크플로우(가져오기)는 `rows` · `codes`를 읽고, 보낸 사람(콘텐츠 담당자)은 사람의 말을 읽습니다. 고칠 것이 코드마다 정해져 있으면 표 하나로 충분합니다 — LLM으로 만들면 같은 코드에 날마다 다른 말이 나오고 돈이 듭니다.
|
||||
|
||||
업계에서는 보내는 쪽과 받는 쪽이 데이터의 모양 · 규칙을 미리 약속하는 것을 **데이터 계약**(data contract)이라고 부릅니다. 약속을 어긴 데이터는 받지 않고 **이유와 함께** 돌려보냅니다. 오늘의 AGENTS.md 3절이 작은 데이터 계약이고, 고침 목록이 돌려보내는 이유입니다.
|
||||
|
||||
그리고 워크플로우는 **고쳐 주지 않습니다.** 빈 제목을 짐작해 채우면 틀린 FAQ가 "검사를 통과한 FAQ"로 들어가고, 아무도 그 사실을 모릅니다.
|
||||
|
||||
### 오늘 실습에서
|
||||
|
||||
결과에 `fix_list` · `message`를 더하면 (Lab 01 과제 2): B-1 → `"B-1: 13줄 중 3줄 통과, 10줄 고칠 것"`, 고칠 줄 10개(3째 FAQ-103 "제목을 써 주세요" … 12째 FAQ-112 "같은 제목의 FAQ가 이미 있습니다 …"), B-2 → FAQ-209 하나. `row`는 1부터, 줄 순서 그대로 — 보낸 사람이 자기 파일에서 바로 찾게.
|
||||
|
||||
## 8. 요청문(프롬프트)으로 에이전트에게 맡기기
|
||||
|
||||
검사 요청문은 "나쁜 FAQ를 걸러 줘"가 아니라 **규칙**을 적습니다 (AGENTS.md 1절: 에이전트는 먼저 "무엇을 보고 어떤 코드를 붙이는지"를 표로 보여 줌).
|
||||
|
||||
| 넣을 것 | 예 |
|
||||
| --- | --- |
|
||||
| 파일 · 웹훅 · 이름 | "`my-work/faq-check.json`, 웹훅 `my-faq-check`, 이름 `78 FAQ check (my)`" |
|
||||
| 모양이 틀린 본문 | "`items`가 목록이 아니면 400" |
|
||||
| 규칙과 경계 | "AGENTS.md 3절 규칙대로, 공백만 있는 값은 빈 값, 글자 수는 앞뒤 공백을 뺀 뒤" |
|
||||
| 헷갈리는 규칙 | "pii는 하이픈 없는 휴대전화도, external_link는 호스트가 정확히 intra.example.com이 아니면" |
|
||||
| 결과 모양 · 하지 않을 것 | "`rows`는 들어온 순서 그대로, LLM은 쓰지 않는다" |
|
||||
| 기대 결과 | "`cases/lab01.json`, 앞의 테스트도 그대로" |
|
||||
|
||||
## 9. 자주 하는 실수
|
||||
|
||||
| 실수 | 결과 | 바로잡기 |
|
||||
| --- | --- | --- |
|
||||
| "나쁜 FAQ 걸러 줘"만 씀 | 에이전트마다 다른 규칙 | 규칙 표를 요청문에 |
|
||||
| 공백을 값으로 봄 | `" "` 제목이 통과 | 앞뒤 공백을 뺀 뒤 판정 |
|
||||
| `>`와 `>=`를 섞음 | 딱 40자 제목이 막힘 | "넘으면"을 말로 |
|
||||
| 전화번호를 `010-` 꼴만 찾음 | `01012345678`이 나감 | 구분자가 없어도 되는 꼴 |
|
||||
| 주소를 "앞이 같나"로 봄 | `evil.io`가 통과 | 호스트가 정확히 · 끝나는지까지 |
|
||||
| Code 안에서 본문 모양을 봄 | 500 | Code 앞에 IF → 400 |
|
||||
| 겹치는 키워드도 오류로 | 넣을 수 있는 FAQ가 없음 | 오류와 경고를 나눔 |
|
||||
| 나쁜 줄을 고쳐서 넣음 | 틀린 데이터가 "통과"로 | 고침 목록을 돌려주고 사람이 고침 |
|
||||
| 규칙을 LLM에게 판정시킴 | 날마다 다르고 비쌈 | 규칙은 Code, 뜻만 LLM |
|
||||
|
||||
## 확인 질문
|
||||
|
||||
<details><summary>1. 기준 검사는 B-1을 10줄 통과, 내 검사는 5줄 통과라고 합니다. 기준 검사가 "틀린" 건가요?</summary>
|
||||
|
||||
계산은 맞습니다. 기준은 빈 값 세 가지만 보는 **규칙**으로 판정했고, 그 규칙대로라면 10줄이 통과입니다. 문제는 그 규칙으로 FAQ에 개인 전화번호와 단축 링크가 들어간다는 것입니다. 무엇을 막을지는 팀이 정하고(AGENTS.md 3절), 모든 검사가 그 규칙을 씁니다.
|
||||
</details>
|
||||
|
||||
<details><summary>2. 오류 이유를 "제목이 비었어요" 같은 문장 대신 `title_missing` 같은 코드로 붙이는 까닭은?</summary>
|
||||
|
||||
코드는 짧고 바뀌지 않아 기계가 셀 수 있습니다 (`codes`, 품질 보고서의 가장 흔한 문제). 문장은 사람마다 · 날마다 달라 셀 수 없습니다. 사람에게 보여 줄 말은 코드마다 표로 정해 따로 붙입니다 (과제 2).
|
||||
</details>
|
||||
|
||||
<details><summary>3. 경계 테스트에서 기준 검사가 0/6이었습니다. 보통 배치(B-1)에서는 1/5를 맞혔는데, 경계에서는 왜 하나도 못 맞혔나요?</summary>
|
||||
|
||||
경계 배치의 줄은 빈 값이 아닌 것들입니다 (41자 · 공백 · 하이픈 없는 번호 · 비슷한 주소 · 소문자 id). 기준이 가진 규칙(빈 값)에 걸릴 줄이 하나도 없으니 모두 통과입니다. 보통 배치에서 맞힌 1/5는 "빈 값" 케이스가 마침 기준의 규칙과 같았기 때문입니다.
|
||||
</details>
|
||||
|
||||
<details><summary>4. 이미 쓰는 키워드(`vpn`)를 오류가 아니라 경고로 둔 까닭은?</summary>
|
||||
|
||||
키워드가 겹치는 것은 흔하고 나쁘지 않습니다 (VPN 관련 FAQ가 여럿일 수 있음). 오류로 막으면 새 FAQ를 거의 넣을 수 없습니다. 반면 같은 id · 같은 제목은 덮어쓰거나 검색이 헷갈리므로 막습니다. 막을 것과 알릴 것을 나누는 것도 규칙입니다.
|
||||
</details>
|
||||
|
||||
<details><summary>5. 고침 목록을 만들 수 있다면, 워크플로우가 아예 고쳐서 넣으면 더 편하지 않나요?</summary>
|
||||
|
||||
빈 제목을 워크플로우가 짐작해 채우면, 틀린 내용이 "검사를 통과한 FAQ"로 들어가고 아무도 모릅니다. 보낸 사람도 무엇이 틀렸는지 배우지 못해 다음 배치도 같습니다. 워크플로우는 막고 알리고, 고치는 것은 사람입니다 (오늘의 원칙). 공백 정리처럼 뜻이 바뀌지 않는 "다듬기"와 내용을 바꾸는 "고치기"는 다릅니다.
|
||||
</details>
|
||||
@@ -0,0 +1,239 @@
|
||||
# 2단계 · 품질 게이트
|
||||
|
||||
- 교시: 3교시 강의 (11:00~11:50) · 4교시 [Lab 02](../../Last3Mon/78/LAB-02-Q-QUALITY-GATE.md) 실습 5개 (12:00~12:50)
|
||||
- 이 단계가 끝나면: 검사(무엇이 나쁜가)와 게이트(그래서 무엇을 하나)를 나누고, 나쁜 배치는 통째로 거절 · 나쁜 줄은 이유와 함께 격리하는 가져오기를 만들고, 규칙으로 못 잡는 줄만 LLM에게 묻고, 거절 기준의 대가를 재고, 배치마다 품질 기록 · 알림을 남기고, LLM 호출을 숫자로 볼 수 있다.
|
||||
- 숫자: 모두 강사 노트북 실측입니다 (65일차 n8n 2.42.6, 78일차 판 데이터 API, FAQ 배치 B-1 · B-2 · B-3, OpenAI `gpt-4.1-mini` temperature 0, 11/01 정답 워크플로우를 학생 순서대로 1회 · Lab 02 과제 2는 세 번). 규칙 판정은 늘 같고, 시간과 LLM 판정은 실행마다 다를 수 있습니다.
|
||||
- 실습 가리키기: 이 문서에서 "Lab 02 예제 1"은 4교시 [Lab 02 문서](../../Last3Mon/78/LAB-02-Q-QUALITY-GATE.md)의 그 절입니다. 파일 경로는 실습 폴더 `~/aio-01d4-stu/Last3Mon/78` 기준입니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["1. 검사와 게이트"] --> B["2. 배치 · 줄 · 격리"] --> C["3. LLM 검사"] --> D["4. 기준은 고르는 값"]
|
||||
D --> E["5. 기록 · 알림"] --> F["6. LLM 호출 수 · 늘 같은가"] --> G["7. 요청문으로"]
|
||||
```
|
||||
|
||||
## 0. 이 시간 (50분)
|
||||
|
||||
| 시간 | 절 · 개요 | 하는 일 |
|
||||
| --- | --- | --- |
|
||||
| 11:00~11:08 | 1 · 개요 15장 | 검사와 게이트, 기준 가져오기가 넣은 것 |
|
||||
| 11:08~11:17 | 2 · 개요 16장 | 배치 게이트 · 줄 게이트 · 격리함 |
|
||||
| 11:17~11:26 | 3 · 개요 17장 | LLM 검사, 규칙을 통과한 줄만, fail-closed |
|
||||
| 11:26~11:33 | 4 · 개요 18장 | 기준을 바꾸면 |
|
||||
| 11:33~11:42 | 5 · 6 · 개요 19~21장 | 품질 기록 · 알림, LLM 호출 수 · 늘 같은가 |
|
||||
| 11:42~11:50 | 7 · 8 · 확인 질문 | 요청문, 자주 하는 실수 |
|
||||
|
||||
### 4교시 Lab 02에서 이어서 하는 것
|
||||
|
||||
| 4교시 실습 (시각) | 이 강의의 절 | 실습에서 하는 일 | 쓰는 파일 · 명령 |
|
||||
| --- | --- | --- | --- |
|
||||
| Lab 02 예제 1 (12:00~12:12) | 1 · 2 | 기준 가져오기에 먼저, 그다음 게이트 · 격리 | `test cases/lab02.json --webhook base-faq-import -v`, `my-work/faq-import.json` (새로) → `lab02` 5/6, `quality` |
|
||||
| Lab 02 예제 2 (12:12~12:22) | 3 | LLM 검사 (통과한 줄만) | 같은 파일 → `lab02` 6/6 |
|
||||
| Lab 02 예제 3 (12:22~12:28, 에이전트 없이) | 4 | 기준 0.8 · 0.05 · 0.1 | `test cases/lab02-gate.json -v`, `quality` → `QUALITY.md` 5절 |
|
||||
| Lab 02 과제 1 (12:28~12:38) | 5 | 품질 기록 · 알림 | 테스트 `cases/lab02-challenge.json` → `QUALITY.md` 7절 |
|
||||
| Lab 02 과제 2 (12:38~12:50) | 6 | `llm_calls`, 세 번 돌리기 | 테스트 `cases/lab02-challenge2.json` × 3 → `QUALITY.md` 6절 |
|
||||
|
||||
## 1. 검사와 게이트는 다른 일 (4교시 Lab 02 예제 1)
|
||||
|
||||
### 일반 설명
|
||||
|
||||
> **생활 속 예 · 검수와 반품 결정.** 주방장이 "이 상자는 일곱 개가 상했네"라고 보는 것(검수)과, "그러니 트럭째 돌려보내자"고 정하는 것(결정)은 다른 일입니다. 같은 검수 결과로도 식당마다 결정이 다를 수 있습니다.
|
||||
|
||||
**검사(validation)는 판정을, 게이트(gate)는 그 판정으로 할 일을 정합니다.** 검사는 Lab 01에서 만들었습니다. 게이트는 그 결과를 읽어 넣을지 · 막을지 · 격리할지를 정합니다. 둘을 나누면 검사를 고칠 때 게이트를, 게이트 기준을 바꿀 때 검사를 건드리지 않습니다. 업계에서는 데이터 파이프라인의 **품질 관문**(quality gate) · 74일차 배포 관문(release gate)과 같은 모양입니다.
|
||||
|
||||
**규칙은 한 벌.** 게이트 안에 규칙을 다시 쓰면, 규칙을 고칠 때 두 곳을 고쳐야 하고 언젠가 어긋납니다. 77일차 요약이 보고서를 **불렀듯이**, 가져오기는 검사 웹훅을 부릅니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
W(["배치"]) --> C["Run check<br/>POST my-faq-check"] --> G{"invalid / total<br/>≤ 기준?"}
|
||||
G -->|아니오| R(["422 · 아무것도 넣지 않음"])
|
||||
G -->|예| S["Split rows"] --> D["Store row<br/>ok → /faq/add · 아니면 → /quarantine"] --> M(["imported · quarantined · score"])
|
||||
```
|
||||
|
||||
### 오늘 실습에서
|
||||
|
||||
먼저 팀이 쓰던 기준 가져오기(`base-faq-import`)에 같은 테스트를 돌리면 **0/6** (Lab 02 예제 1). 기준은 검사 없이 모든 줄을 `/faq/add`로 보냅니다.
|
||||
|
||||
| 배치 | 기준 가져오기가 넣은 것 |
|
||||
| --- | --- |
|
||||
| B-1 | 13줄 중 **12줄** — 빈 제목 · 개인 전화번호 · 단축 링크 · 이미 있는 FAQ-003과 같은 id까지 (빈 답 하나만 데이터 API가 막음) |
|
||||
| B-2 | 10줄 모두 — 메일 주소 · 다른 답까지 |
|
||||
| B-3 | 6줄 모두 — 답 "짧"까지 |
|
||||
|
||||
- 기준의 `score`는 0.9231 · 1 — "잘 들어갔다"처럼 보이는 숫자가 실제로는 나쁜 줄을 넣은 수입니다.
|
||||
|
||||
## 2. 배치 게이트 · 줄 게이트 · 격리함 (4교시 Lab 02 예제 1)
|
||||
|
||||
### 일반 설명
|
||||
|
||||
> **생활 속 예 · 트럭째 반품과 보류 칸.** 상자 열에 일곱이 상했으면 하나씩 골라내느니 트럭째 돌려보냅니다 (다시 받는 게 빠름). 대부분 괜찮으면 좋은 것만 냉장고에 넣고, 이상한 것은 **보류 칸**에 사유 쪽지를 붙여 둡니다. 버리지 않습니다 — 업체가 바꿔 오면 확인해야 하니까요.
|
||||
|
||||
**게이트는 두 겹입니다.**
|
||||
|
||||
| 관문 | 기준 | 넘지 못하면 |
|
||||
| --- | --- | --- |
|
||||
| 배치 게이트 | 나쁜 줄 비율 ≤ 기준 (오늘 기본 0.3, 같으면 통과) | 배치 전체 거절 — 422, 아무것도 넣지 않음 |
|
||||
| 줄 게이트 | 그 줄의 `ok` | 그 줄만 격리함으로 |
|
||||
|
||||
**격리(quarantine)는 버리지 않는 것입니다.** 넣지 않은 줄을 원래 내용(`row`)과 이유(`errors`)와 함께 보관합니다. 버리면 무엇이 왜 빠졌는지 모르고, 고친 뒤 다시 넣을 수도 없습니다. 76일차의 DLQ(dead-letter queue, 실패한 요청을 두는 곳)와 같은 생각이고, 데이터 파이프라인에서는 quarantine table · error table이라고 부릅니다.
|
||||
|
||||
**응답 코드.** 400은 "모양이 틀렸다"(읽을 수 없음), 422는 "읽었지만 받을 수 없다"(내용이 나쁨)입니다. 부른 쪽이 다음에 할 일이 다르기 때문에 나눕니다.
|
||||
|
||||
### 오늘 실습에서
|
||||
|
||||
| 배치 | 내 가져오기 (Lab 02 예제 1) |
|
||||
| --- | --- |
|
||||
| B-1 (나쁜 줄 10/13) | **422** `invalid_ratio` 0.7692 > `max_invalid_ratio` 0.3, 아무것도 넣지 않음 |
|
||||
| B-2 | 9줄 넣고 FAQ-209 격리 (`pii`), `score` 0.9 |
|
||||
| B-3 | 5줄 넣고 FAQ-305 격리 (`answer_too_short`) |
|
||||
|
||||
- 넣기와 격리를 IF로 두 갈래로 나눴다가 Merge로 합치면, LLM 검사를 더한 뒤 격리 수가 틀립니다 (수업 준비 때 겪음). HTTP 노드 **하나**가 주소를 표현식으로 고르게 하면 갈래가 필요 없습니다 (AGENTS.md 5절).
|
||||
- FAQ-210(답이 다른 이야기)은 규칙을 모두 통과해 아직 들어갑니다 → `lab02` 5/6. 3절에서 막습니다.
|
||||
|
||||
## 3. LLM 검사: 뜻은 LLM이, 통과한 줄에만 (4교시 Lab 02 예제 2)
|
||||
|
||||
### 일반 설명
|
||||
|
||||
> **생활 속 예 · 한 숟가락 맛보기.** 무게 · 온도 · 유통기한은 기준표로 잽니다. "주문한 맛이 맞나"는 주방장이 맛봅니다. 그런데 이미 상한 것까지 맛보지는 않습니다 — 기준표에서 떨어진 것은 맛볼 필요가 없습니다.
|
||||
|
||||
**규칙으로 알 수 없는 것만 LLM에게.** "답이 제목의 질문에 답하나?"는 정해진 답이 없는 판정이라 규칙(Code)으로 쓰기 어렵습니다. LLM은 이런 뜻의 판정을 잘합니다. 다만 세 가지를 지킵니다.
|
||||
|
||||
| 원칙 | 까닭 |
|
||||
| --- | --- |
|
||||
| **규칙을 통과한 줄에만** | 규칙에 걸린 줄은 이미 격리 — LLM에 물어도 결과가 같고 돈 · 시간만 듦 (75일차) |
|
||||
| **JSON으로만 답하게** | `{"match": true/false, "reason"}` — Code가 읽음 |
|
||||
| **읽을 수 없으면 넣지 않음** (fail-closed) | LLM이 멈추거나 이상한 말을 한 날 모든 줄이 검사 없이 들어가지 않게 (73일차) |
|
||||
|
||||
업계에서는 "LLM-as-a-judge"라고 부르고, RAG 품질 평가 · 콘텐츠 검수에 씁니다. 판정의 근거(`reason`)를 함께 남겨 사람이 다시 볼 수 있게 합니다.
|
||||
|
||||
### 오늘 실습에서
|
||||
|
||||
| | 규칙만 (예제 1) | + LLM (예제 2) |
|
||||
| --- | --- | --- |
|
||||
| FAQ-210 "모니터 밝기 조절" · 답은 연차 휴가 | 넣음 | 격리 `llm_mismatch` |
|
||||
| B-2 | 9줄 · `score` 0.9 | 8줄 · 0.8 |
|
||||
| B-3 | 5줄 | 5줄 (FAQ-305는 규칙에서 이미 격리, LLM에 가지 않음) |
|
||||
| B-2 시간 | 1.4초 | **9.3초** (규칙을 통과한 9줄 × LLM 한 번) |
|
||||
|
||||
- LLM이 막은 줄은 FAQ-210 **하나**뿐입니다. 드문 한 줄을 위해 줄마다 1초쯤을 씁니다. 가져오기(배치를 보낼 때 한 번)라 괜찮다고 판단했지만, 직원이 기다리는 헬프데스크 답이라면 다른 판단이 필요합니다.
|
||||
|
||||
## 4. 기준은 고르는 값 (4교시 Lab 02 예제 3)
|
||||
|
||||
### 일반 설명
|
||||
|
||||
> **생활 속 예 · 반품 기준을 풀면, 조이면.** "열에 셋까지 골라 받는다"를 "열에 여덟까지"로 풀면 트럭을 돌려보내는 일은 줄지만 보류 칸이 넘치고 주방장이 하루 종일 골라냅니다. "하나도 안 된다"로 조이면 보류 칸은 비지만 거의 모든 트럭이 돌아가 재료가 모자랍니다.
|
||||
|
||||
**기준 하나가 두 실수를 맞바꿉니다.** 통계에서는 1종 오류(괜찮은 것을 막음)와 2종 오류(나쁜 것을 받음)라고 부릅니다. 기준은 **맞고 틀리는 값이 아니라 고르는 값**이고, 팀이 정해 적어 둡니다. 77일차 SLO 기준 · 이상 시간대 문턱과 같습니다.
|
||||
|
||||
| 기준 | 잘못 받는 일 | 잘못 돌려보내는 일 | 누구의 일이 느나 |
|
||||
| --- | --- | --- | --- |
|
||||
| 느슨하게 | 늘어남 | 줄어듦 | 격리함을 보는 사람 |
|
||||
| 엄하게 | 줄어듦 | 늘어남 | 다시 보내는 사람, FAQ가 늦게 열림 |
|
||||
|
||||
기준을 바꿀 때마다 워크플로우를 고치지 않으려면 기준을 **입력**으로 받습니다 (`max_invalid_ratio`, 없으면 0.3).
|
||||
|
||||
### 오늘 실습에서
|
||||
|
||||
같은 가져오기에 기준만 바꿔 (Lab 02 예제 3, 에이전트 없이):
|
||||
|
||||
| 배치 | 기준 | 결과 |
|
||||
| --- | --- | --- |
|
||||
| B-1 | 0.3 | 거절 (0.7692) |
|
||||
| B-1 | 0.8 | 받음 — 넣음 **2** · 격리 **11** · `score` 0.1538 |
|
||||
| B-2 | 0.05 | 거절 (0.1) — 멀쩡한 9줄도 돌아감 |
|
||||
| B-2 | 0.1 | 받음 — 딱 같으면 통과 |
|
||||
|
||||
- 실험을 하나씩 떼어 봐야 합니다: B-1을 받으면 FAQ-102 "회의실 예약"이 들어가 B-2의 FAQ-201이 `same_title`로 나빠지고, B-2를 넣은 뒤 다시 보내면 9줄이 `exists_id`입니다 (수업 준비 때 둘 다 겪음). **앞 배치가 넣은 데이터가 다음 배치의 판정을 바꿉니다.**
|
||||
- 강사는 0.3을 그대로 두었습니다: "열에 셋까지는 골라 받고, 그보다 나쁘면 돌려보내 고치게 한다."
|
||||
|
||||
## 5. 품질 기록 · 알림 (4교시 Lab 02 과제 1)
|
||||
|
||||
### 일반 설명
|
||||
|
||||
> **생활 속 예 · 검수 일지와 사장님 호출.** 주방장은 상자마다 일지에 "몇 개 받음 · 몇 개 반품 · 이유"를 적습니다. 일지가 쌓여야 "요즘 그 업체가 나빠졌다"를 말할 수 있습니다. 트럭째 반품한 날은 사장님께 바로 전화합니다.
|
||||
|
||||
**품질은 한 번 재고 끝나지 않습니다 — 기록이 쌓여야 관리됩니다.** 배치마다 같은 모양으로 한 줄(배치 · 데이터셋 · 줄 수 · 통과 · 격리 · 거절 · 점수 · 코드)을 남기면 3단계에서 추세 · 가장 나쁜 배치 · 가장 흔한 문제를 셉니다. 거절한 배치도 **반드시** 기록합니다 — 빼면 가장 나쁜 배치가 보고서에서 사라집니다.
|
||||
|
||||
**알림은 사람이 할 일이 있을 때만** (76일차). 거절(보낸 사람에게 알려야 함) · 낮은 점수(격리함을 봐야 함)일 때 보냅니다. 그리고 알림을 보내도 **부른 쪽이 받는 결과는 그대로**입니다 — 알림은 곁가지.
|
||||
|
||||
### 오늘 실습에서
|
||||
|
||||
| 배치 | 응답 | 품질 기록 | 알림 |
|
||||
| --- | --- | --- | --- |
|
||||
| B-1 | 422 | `rejected: true` · `score` 0 | `batch_rejected` |
|
||||
| B-2 | 200 · 0.8 | `valid` 8 · `quarantined` 2 · 0.8 | `low_quality` (0.9 미만) |
|
||||
|
||||
- HTTP 노드(`Log quality` · `Alert low score`)의 출력은 데이터 API의 응답(`{"stored": 1}`)이라, 끝에 `Result again`이 가져오기 결과를 다시 꺼내 응답합니다. 그래서 `lab02` 6/6이 그대로입니다.
|
||||
|
||||
## 6. LLM 호출 수 · 늘 같은가 (4교시 Lab 02 과제 2)
|
||||
|
||||
### 일반 설명
|
||||
|
||||
> **생활 속 예 · 맛보기 횟수와 맛보는 사람.** 사장님이 묻습니다: "오늘 맛보기를 몇 번 했지? 상한 것까지 맛본 건 아니지?" 그리고 "어제 맛본 사람과 오늘 맛본 사람의 판정이 같나?"
|
||||
|
||||
**LLM 비용은 호출 수로 말합니다.** 시간은 매번 다르지만(같은 9번이 7.6~9.4초) 호출 수는 정해집니다. 호출당 비용을 알면(75일차) 배치의 비용이 바로 나옵니다.
|
||||
|
||||
**"늘 같은가"는 재 봐야 압니다.** temperature 0이어도 LLM 판정이 늘 같다는 보증은 없습니다. 같은 입력을 **같은 처음 상태**에서 여러 번 돌려 판정이 같은지 봅니다 (72일차 반복 시험). 그리고 같은 배치를 처음 상태로 되돌리지 않고 다시 보내면 **데이터가 바뀐 것**이지 판정이 흔들린 것이 아닙니다 — 둘을 구분해야 합니다.
|
||||
|
||||
### 오늘 실습에서
|
||||
|
||||
| 배치 | 줄 | `llm_calls` | 세 번 |
|
||||
| --- | --- | --- | --- |
|
||||
| B-1 (거절) | 13 | **0** | 같음 |
|
||||
| B-2 | 10 | **9** (= 10 − FAQ-209) | FAQ-210만 막음 · 9.4 · 8.1 · 7.6초 |
|
||||
| B-3 | 6 | **5** (= 6 − FAQ-305) | 넣음 5 |
|
||||
|
||||
- `--no-before --repeat 2`(처음 상태로 되돌리지 않고 연달아)는 **2/6**: 앞 실행이 넣은 FAQ 때문에 B-2 · B-3이 `exists_id`로 거절되고 LLM은 0번. 같은 배치를 두 번 보내도 FAQ가 두 벌 생기지 않는다는 확인이기도 합니다.
|
||||
|
||||
## 7. 요청문(프롬프트)으로 에이전트에게 맡기기
|
||||
|
||||
가져오기 요청문은 "검사해서 넣어 줘"가 아니라 **관문과 갈래**를 적습니다.
|
||||
|
||||
| 넣을 것 | 예 |
|
||||
| --- | --- |
|
||||
| 부를 검사 | "규칙을 다시 쓰지 말고 `my-faq-check`를 부른다 (neverError, timeout 30000)" |
|
||||
| 배치 게이트 | "`invalid / total`이 기준 이하일 때만, 기준은 본문 `max_invalid_ratio`, 없으면 0.3, 넘으면 422" |
|
||||
| 줄 게이트 · 격리 | "ok면 `/faq/add`, 아니면 `/quarantine {items: [{batch, row, errors}]}` — HTTP 노드 하나에서" |
|
||||
| LLM 검사 | "규칙을 통과한 줄만, JSON으로만, 읽을 수 없으면 match false, onError continueRegularOutput" |
|
||||
| 기록 · 알림 | "거절도 기록, 거절 · 0.9 미만이면 알림, 응답은 같은 결과" |
|
||||
| 기대 결과 | "`cases/lab02.json`, 앞의 테스트도 그대로" |
|
||||
|
||||
## 8. 자주 하는 실수
|
||||
|
||||
| 실수 | 결과 | 바로잡기 |
|
||||
| --- | --- | --- |
|
||||
| 가져오기 안에 규칙을 다시 씀 | 검사와 어긋남 | 검사 웹훅을 부름 |
|
||||
| 나쁜 줄을 버림 | 무엇이 빠졌는지 모름 · 다시 못 넣음 | 이유와 함께 격리 |
|
||||
| 넣기 · 격리를 IF로 갈랐다 합침 | LLM 뒤 격리 수가 틀림 | HTTP 노드 하나 |
|
||||
| 모든 줄을 LLM에 | 비용 · 시간 | 규칙을 통과한 줄만 |
|
||||
| LLM 판정을 읽을 수 없으면 넣음 | LLM이 멈춘 날 검사 없이 들어감 | fail-closed |
|
||||
| 기준을 IF에 숫자로 박음 | 바꿀 때마다 워크플로우 수정 | 입력(`max_invalid_ratio`)으로 |
|
||||
| 거절을 기록하지 않음 | 가장 나쁜 배치가 보고서에서 사라짐 | 두 갈래 모두 기록 |
|
||||
| 알림 노드의 출력으로 응답 | 부른 쪽이 `{"stored": 1}`을 받음 | 결과를 다시 꺼냄 |
|
||||
| `--repeat`로 "늘 같은가"를 봄 | 이미 넣은 데이터로 거절 | 처음 상태에서 따로 세 번 |
|
||||
|
||||
## 확인 질문
|
||||
|
||||
<details><summary>1. 기준 가져오기의 B-1 `score`는 0.9231, 내 가져오기는 422(거절)입니다. 점수가 높은 기준이 더 좋은 것 아닌가요?</summary>
|
||||
|
||||
기준의 점수는 "넣은 줄 / 전체 줄"인데, 넣은 12줄에 개인 전화번호 · 단축 링크 · 빈 제목이 들어 있습니다. 검사 없는 점수는 품질이 아니라 **양**입니다. 점수는 같은 규칙으로 판정한 뒤에야 품질을 말합니다.
|
||||
</details>
|
||||
|
||||
<details><summary>2. 400과 422를 나누는 까닭은?</summary>
|
||||
|
||||
400은 본문의 모양이 틀려 읽을 수조차 없다는 뜻(`items`가 목록이 아님)이고, 422는 읽었지만 내용이 기준을 넘어 받을 수 없다는 뜻입니다. 부른 쪽은 400이면 보내는 코드를, 422면 보내는 데이터를 고칩니다.
|
||||
</details>
|
||||
|
||||
<details><summary>3. LLM 검사를 모든 줄에 하면 더 안전하지 않나요?</summary>
|
||||
|
||||
규칙에 걸린 줄은 이미 격리되므로 LLM이 무엇이라 하든 결과가 같습니다. 같은 결과에 돈과 시간만 씁니다 (B-1 13줄 · B-2 10줄 대신 0 · 9번). 더 안전해지려면 오히려 "LLM 판정을 읽을 수 없으면 넣지 않는다"(fail-closed)를 지켜야 합니다.
|
||||
</details>
|
||||
|
||||
<details><summary>4. 기준을 0.8로 풀었더니 B-1을 받았습니다. 넣은 줄은 2줄뿐인데 무엇이 나빠졌나요?</summary>
|
||||
|
||||
격리함에 11줄이 쌓였습니다. 사람이 11줄을 하나씩 열어 고칠지 정해야 합니다. 거절했다면 콘텐츠 담당자가 고침 목록(Lab 01 과제 2)을 받아 한 번에 고쳐 다시 보냈을 일입니다. 기준을 푼 대가는 "격리함을 보는 사람의 일"입니다.
|
||||
</details>
|
||||
|
||||
<details><summary>5. `--repeat 2`로 돌렸더니 두 번째에 B-2가 거절됐습니다. LLM 판정이 흔들린 건가요?</summary>
|
||||
|
||||
아닙니다. 첫 실행이 FAQ-201~208을 넣었기 때문에, 같은 배치를 다시 보내면 그 8줄이 `exists_id`가 되어 나쁜 줄이 9/10, 거절입니다. 거절이니 LLM은 한 번도 부르지 않았습니다 (`llm_calls` 0). "늘 같은가"를 보려면 매번 처음 상태(테스트의 `before`)에서 돌립니다.
|
||||
</details>
|
||||
@@ -0,0 +1,236 @@
|
||||
# 3단계 · 품질 관리
|
||||
|
||||
- 교시: 5교시 강의 (14:00~14:50) · 6교시 [Lab 03](../../Last3Mon/78/LAB-03-Q-QUALITY-MANAGEMENT.md) 실습 5개 (15:00~15:50)
|
||||
- 이 단계가 끝나면: FAQ에 박힌 규칙을 **스키마**(규칙을 적은 데이터)로 꺼내 다른 데이터에도 쓰고, 스키마로 되는 규칙과 안 되는 규칙을 나누고, 격리된 줄이 고쳐져 돌아오면 격리함을 비우는 되돌림 고리를 만들고, 새 데이터셋을 워크플로우를 고치지 않고 늘리고, 품질 기록으로 보고서 · 데이터셋별 품질을 정의대로 말할 수 있다.
|
||||
- 숫자: 모두 강사 노트북 실측입니다 (65일차 n8n 2.42.6, 78일차 판 데이터 API의 스키마 셋 `faq` · `employee` · `ticket`, 직원 CSV 9줄 · 접수 건 9줄 · FAQ 배치, 11/01 정답 워크플로우를 학생 순서대로 1회).
|
||||
- 실습 가리키기: 이 문서에서 "Lab 03 예제 1"은 6교시 [Lab 03 문서](../../Last3Mon/78/LAB-03-Q-QUALITY-MANAGEMENT.md)의 그 절입니다. 파일 경로는 실습 폴더 `~/aio-01d4-stu/Last3Mon/78` 기준입니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["1. 스키마"] --> B["2. 되는 것 · 안 되는 것"] --> C["3. 되돌림 고리"] --> D["4. 설정으로 넓히기"]
|
||||
D --> E["5. 품질 보고서"] --> F["6. 데이터셋별"] --> G["7. 하루를 한 그림으로"]
|
||||
```
|
||||
|
||||
## 0. 이 시간 (50분)
|
||||
|
||||
| 시간 | 절 · 개요 | 하는 일 |
|
||||
| --- | --- | --- |
|
||||
| 14:00~14:10 | 1 · 개요 22장 | 스키마: 규칙을 데이터로 |
|
||||
| 14:10~14:16 | 2 · 개요 23장 | 스키마로 되는 것 · 안 되는 것 |
|
||||
| 14:16~14:22 | 3 · 개요 24장 | 격리 → 고침 → 다시 → 빼기 |
|
||||
| 14:22~14:28 | 4 · 개요 25장 | 새 스키마, 스키마 변경 = 규칙 변경 |
|
||||
| 14:28~14:40 | 5 · 6 · 개요 26~27장 | 품질 보고서의 정의, 데이터셋별 · 평균이 가리는 것 |
|
||||
| 14:40~14:50 | 7 · 8 · 확인 질문 | 하루 정리 표, 자주 하는 실수 |
|
||||
|
||||
### 6교시 Lab 03에서 이어서 하는 것
|
||||
|
||||
| 6교시 실습 (시각) | 이 강의의 절 | 실습에서 하는 일 | 쓰는 파일 · 명령 |
|
||||
| --- | --- | --- | --- |
|
||||
| Lab 03 예제 1 (15:00~15:14) | 1 · 2 | FAQ 검사에 직원 CSV(전), 스키마 검사 | `call my-faq-check requests/employees-new.json`, `my-work/check.json` (새로) → `lab03` 4/4, `QUALITY.md` 8절 |
|
||||
| Lab 03 예제 2 (15:14~15:22) | 3 | 고친 줄을 격리함에서 빼기 | `my-work/faq-import.json` 고치기, `test cases/lab03-fix.json -v` (전 3/4 → 4/4) |
|
||||
| Lab 03 예제 3 (15:22~15:28, 에이전트 없이) | 4 | 새 스키마 `ticket` | `call my-faq-check requests/tickets-new.json`, `test cases/lab03-schema.json -v` |
|
||||
| Lab 03 과제 1 (15:28~15:40) | 5 | 품질 보고서 | `my-work/quality-report.json` (새로), 테스트 `cases/lab03-challenge.json` → `QUALITY.md` 9절 |
|
||||
| Lab 03 과제 2 (15:40~15:50) | 6 | 검사도 기록 · 데이터셋별 | 두 파일 고치기, 테스트 `cases/lab03-challenge2.json` |
|
||||
|
||||
## 1. 스키마: 규칙을 데이터로 (6교시 Lab 03 예제 1)
|
||||
|
||||
### 일반 설명
|
||||
|
||||
> **생활 속 예 · 품목마다 다른 검수 카드.** 채소는 무른 곳, 고기는 온도와 라벨, 생선은 눈 색을 봅니다. 품목마다 주방장을 따로 두지 않고 품목마다 **검수 카드**를 만들어 두면, 한 사람이 카드를 바꿔 들고 무엇이든 검수합니다. 새 품목이 오면 카드를 한 장 더 만듭니다.
|
||||
|
||||
**스키마(schema)는 데이터의 모양을 적은 데이터입니다.** 어떤 칸이 있고, 칸마다 타입은 무엇이고, 꼭 있어야 하는지, 범위는 어떤지. 검사 코드는 칸 이름을 모르고 **타입마다** 판정합니다: "이 칸은 `email`이니 메일 꼴인지", "이 칸은 `enum`이니 목록 안에 있는지". 그러면 데이터가 늘어도 검사 코드는 그대로이고 스키마만 늘어납니다.
|
||||
|
||||
| 업계에서 | 같은 생각 |
|
||||
| --- | --- |
|
||||
| JSON Schema · OpenAPI | API 요청 · 응답의 모양 |
|
||||
| 데이터베이스 테이블 정의 | `NOT NULL` · `CHECK` · `UNIQUE` |
|
||||
| Great Expectations · Soda · dbt tests | 데이터셋마다 "기대값" 목록 |
|
||||
| pydantic · zod | 코드 안의 모델 (스키마를 코드로 씀) |
|
||||
|
||||
**CSV.** 회사 데이터는 CSV로 많이 옵니다. 첫 줄이 열 이름이고, 값은 모두 글자입니다. 그래서 날짜 · 메일도 "글자의 꼴"로 판정하고, 줄 번호는 사람이 세는 번호(열 이름 줄을 빼고 1부터)로 돌려줍니다.
|
||||
|
||||
### 오늘 실습에서
|
||||
|
||||
Lab 01의 FAQ 검사에 인사팀의 직원 CSV를 보내면 400 (`items`가 없음) — FAQ의 칸 이름과 규칙이 Code에 박혀 있습니다 (Lab 03 예제 1 고치기 전). 스키마 검사 `my-check`는 `GET /schemas/employee`를 읽어:
|
||||
|
||||
| 줄 | 값 | 코드 | 스키마의 규칙 |
|
||||
| --- | --- | --- | --- |
|
||||
| 3 | `minsu.kang@example` | `bad_format:email` | `email` |
|
||||
| 4 | `마케팅팀` | `not_allowed:dept` | `enum` 여섯 팀 |
|
||||
| 5 | `2026-13-01` | `bad_format:joined` | `date` — 실제 있는 날 |
|
||||
| 6 | 이름 빈칸 | `missing:name` | `required` |
|
||||
| 8 | 7째와 같은 메일 | `duplicate_in_batch` | `key: email` |
|
||||
|
||||
- 9줄 중 **4줄** 통과. 빈 전화(2째)와 사무실 번호(7째)는 통과 — 선택 칸은 비면 형식을 보지 않습니다. 같은 워크플로우로 `schema: faq` B-3 → 6줄 중 5줄 (FAQ-305 `too_short:answer`).
|
||||
|
||||
## 2. 스키마로 되는 것 · 안 되는 것 (6교시 Lab 03 예제 1)
|
||||
|
||||
### 일반 설명
|
||||
|
||||
> **생활 속 예 · 검수 카드에 없는 칸.** 카드에는 무게 · 온도 · 라벨 칸이 있습니다. 하지만 "이 업체와 이미 계약한 품목인가"는 장부를 봐야 알고, "맛이 맞나"는 맛봐야 압니다. 카드가 할 일과 다른 사람이 할 일이 나뉩니다.
|
||||
|
||||
**스키마는 모양을 넓게, Code · LLM은 그 데이터만의 규칙을 깊게.** 스키마로 거의 모든 데이터의 기본 모양(필수 · 길이 · 꼴 · 목록 · 날짜 · 중복)을 봅니다. 그러나 데이터마다 다른 규칙 — 답 안의 개인정보, 이미 있는 데이터와의 관계, 뜻 — 은 스키마의 타입으로 적기 어렵습니다. 둘 중 하나를 고르는 것이 아니라 층을 나눕니다.
|
||||
|
||||
| 층 | 무엇 | 오늘 |
|
||||
| --- | --- | --- |
|
||||
| 스키마 | 모양 | `my-check` (모든 데이터셋) |
|
||||
| 데이터별 Code | 내용 규칙 · 이미 있는 데이터 | `my-faq-check` (FAQ만) |
|
||||
| LLM | 뜻 | `Judge` (FAQ 가져오기) |
|
||||
| 사람 | 고치기 · 기준 정하기 | 콘텐츠 담당자 · 팀 |
|
||||
|
||||
### 오늘 실습에서
|
||||
|
||||
FAQ를 `my-check`(`schema: faq`)로만 보면 B-3에서 Lab 01의 검사와 판정이 같았습니다 — B-3에는 개인정보 · 링크 · 이미 있는 FAQ가 없었기 때문입니다. B-2를 스키마로만 보면 FAQ-209(메일 주소)가 **통과**합니다. 그래서 FAQ는 Lab 01의 검사를 계속 쓰고, `QUALITY.md` 8절에 이 경계를 적습니다.
|
||||
|
||||
## 3. 되돌림 고리: 격리 → 고침 → 다시 → 빼기 (6교시 Lab 03 예제 2)
|
||||
|
||||
### 일반 설명
|
||||
|
||||
> **생활 속 예 · 보류 칸의 쪽지 떼기.** 업체가 라벨을 붙여 다시 가져온 고기를 냉장고에 넣었습니다. 그런데 보류 칸의 옛 상자에 "라벨 없음" 쪽지가 그대로라면, 다음 날 아침 주방장은 "아직 해결 안 됐네" 하고 또 전화합니다.
|
||||
|
||||
**격리함은 "사람이 볼 일"의 목록입니다.** 해결된 줄이 남아 있으면 목록을 믿을 수 없습니다. 그래서 고쳐서 다시 넣은 줄은 격리함에서 뺍니다. 다만 **넣은 줄만** 뺍니다 — 고쳤는데 또 틀린 줄은 다시 사람에게 가야 합니다. 이 고리(격리 → 사람이 고침 → 다시 넣음 → 격리함 비우기)가 품질 관리의 한 바퀴이고, 76일차 DLQ의 replay와 같은 모양입니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Q[("격리함")] -.->|사람이 고침| F(["고친 배치"]) --> I["가져오기<br/>검사 · LLM · 넣기"] --> X["넣은 id만 뺌"] --> Q
|
||||
I --> L["품질 기록"]
|
||||
```
|
||||
|
||||
### 오늘 실습에서
|
||||
|
||||
| | 고치기 전 (Lab 02 과제 2까지) | 고친 뒤 (`Release fixed`) |
|
||||
| --- | --- | --- |
|
||||
| B-2-fix 두 줄 넣기 | 됨 · `score` 1 · FAQ 16 | 같음 |
|
||||
| 격리함 | **2** (해결된 FAQ-209 · 210이 남음) | **0** |
|
||||
| 품질 기록 | B-2 0.8 → B-2-fix 1 | 같음 |
|
||||
|
||||
- 고친 배치의 이름이 `B-2-fix`라 "같은 배치 · 같은 id면 바꿈"에도 걸리지 않습니다. 격리함을 비우는 일은 저절로 되지 않습니다.
|
||||
|
||||
## 4. 설정으로 넓히기: 스키마만 바꾸면 (6교시 Lab 03 예제 3)
|
||||
|
||||
### 일반 설명
|
||||
|
||||
> **생활 속 예 · 생크림 카드 한 장.** 식당이 디저트를 시작해 생크림이 들어옵니다. 주방장을 새로 뽑지 않고 "생크림 카드"를 한 장 더 만들면 내일부터 같은 사람이 검수합니다. 단, 카드에 "냉장 3도 이하"를 잘못 "30도"로 쓰면 그날부터 상한 크림이 들어옵니다.
|
||||
|
||||
**규칙을 데이터로 두면, 새 데이터셋은 설정 한 장입니다** (configuration over code). 워크플로우를 고쳐 push하지 않고 스키마 파일만 더합니다. 그만큼 **스키마 변경 = 규칙 변경**입니다. 스키마 파일도 저장소에 두고 리뷰 · 테스트를 거쳐 바꿉니다 (74일차 배포 관문과 같은 생각). 업계에서는 스키마 레지스트리(schema registry)로 버전을 관리합니다.
|
||||
|
||||
### 오늘 실습에서
|
||||
|
||||
데이터 API에 세 번째 스키마 `ticket`이 있습니다. 예제 1의 `my-check`를 고치지 않고 접수 건 9줄을 보내면 (Lab 03 예제 3):
|
||||
|
||||
| | FAQ 검사 (`my-faq-check`) | 스키마 검사 (`my-check`, 그대로) |
|
||||
| --- | --- | --- |
|
||||
| 통과 | **0줄** — 칸이 달라 모두 `id_format` · `answer_missing` | **2줄** |
|
||||
| 이유 | 9줄 모두 같은 세 코드 + 빈 id 중복 8 | `T-12` · `urgent` · **2026-02-30** · 3자 제목 · `010-12-3456` · 중복 · 요청자 빈칸 — 줄마다 다름 |
|
||||
|
||||
- 선택 칸 `contact`가 없는 9째 줄은 통과, 있는데 꼴이 틀린 6째 줄은 `bad_format:contact`. "없어도 된다"와 "아무거나 돼도 된다"는 다릅니다.
|
||||
|
||||
## 5. 품질 보고서: 품질도 정의가 먼저 (6교시 Lab 03 과제 1)
|
||||
|
||||
### 일반 설명
|
||||
|
||||
> **생활 속 예 · 월말 검수 일지 정리.** 사장님이 묻습니다: "이번 달 납품은 어땠어? 제일 나빴던 날은? 반품 이유는 뭐가 제일 많았어? 나아지고 있어?" 일지를 넘기며 말로 답하면 사람마다 답이 다릅니다.
|
||||
|
||||
**품질 지표도 77일차 지표처럼 정의가 먼저입니다.** 업계에서는 데이터 품질 점수 · 데이터 품질 대시보드라고 부르고, 데이터 관측(data observability) 도구(Monte Carlo · Bigeye 등)가 같은 일을 합니다. 정해야 할 것은 이렇습니다.
|
||||
|
||||
| 정해야 할 것 | 오늘의 정의 (AGENTS.md 3절) | 정하지 않으면 |
|
||||
| --- | --- | --- |
|
||||
| 거절을 평균에 넣나 | 넣음 (보내온 배치의 품질) | 0.6583과 0.8778 중 무엇인지 모름 |
|
||||
| 같으면 무엇을 | 먼저 나온 배치 · 코드 글자순 | 실행마다 순서가 바뀜 |
|
||||
| 추세 | 마지막 − 첫, ±0.05 | "좋아진 것 같다" |
|
||||
| 0건 | 평균 0, `null` 아님 | 다음 계산이 깨짐 |
|
||||
|
||||
숫자는 Code가 세고, 그 숫자를 **무엇과 무엇을 비교했는지와 함께** 말하는 것은 사람입니다.
|
||||
|
||||
### 오늘 실습에서
|
||||
|
||||
테스트가 B-1(거절) · B-2 · B-3 · B-2-fix를 차례로 가져온 기록 네 줄 (Lab 03 과제 1):
|
||||
|
||||
| 칸 | 값 |
|
||||
| --- | --- |
|
||||
| `avg_score` | **0.6583** = (0 + 0.8 + 0.8333 + 1) / 4 |
|
||||
| `worst_batch` | B-1 · 거절 1 |
|
||||
| `top_codes` 1위 | `pii` 2 (B-1 FAQ-109 전화 + B-2 FAQ-209 메일) |
|
||||
| `trend` | 좋아짐 (0 → 1) |
|
||||
|
||||
- `trend`의 함정: 13줄짜리 거절 배치와 고친 2줄짜리 배치를 같은 무게로 비교한 것입니다. 숫자는 정의대로 맞지만, 회의에서는 "첫 배치는 거절, 마지막은 고친 두 줄"을 함께 말합니다 (77일차 표본 크기와 같은 이야기).
|
||||
|
||||
## 6. 데이터셋별: 평균이 가리는 것 (6교시 Lab 03 과제 2)
|
||||
|
||||
### 일반 설명
|
||||
|
||||
> **생활 속 예 · 업체별 성적표.** 한 달 평균만 보면 "괜찮다"인데 업체별로 나누면 채소 가게는 늘 좋고 생선 가게만 절반이 반품입니다. 사장님이 전화할 곳은 생선 가게입니다.
|
||||
|
||||
**같은 기록 모양을 쓰면 다른 워크플로우의 품질도 한 보고서에서 봅니다.** 검사(스키마 검사)도 가져오기와 같은 모양으로 기록을 남기면, 보고서는 고치지 않고도 직원 · 접수 건 데이터셋을 셉니다. 그리고 전체 평균은 나눠 봐야 할 것을 가립니다 — 77일차에 두 주 오류율이 월요일 09시를 가렸듯이.
|
||||
|
||||
**추세는 같은 데이터셋 안에서.** 서로 다른 데이터셋(직원 → FAQ)을 이어 "좋아졌다"고 말하면 뜻이 없습니다.
|
||||
|
||||
### 오늘 실습에서
|
||||
|
||||
| 데이터셋 | 기록 | `avg_score` | `top_code` |
|
||||
| --- | --- | --- | --- |
|
||||
| employee | HR-1 | 0.4444 (4/9) | `bad_format:email` |
|
||||
| ticket | TK-1 | **0.2222** (2/9) → `worst_dataset` | `bad_format:contact` |
|
||||
| faq | B-3 | 0.8333 (5/6) | `answer_too_short` |
|
||||
| 전체 | | 0.5 | |
|
||||
|
||||
- 전체 0.5는 "절반쯤 괜찮다"지만 나누면 접수 건이 가장 손볼 곳 — 헬프데스크 팀에 "옮기기 전에 일곱 줄을 고쳐 달라"고 말할 근거입니다. `top_code`는 1건씩 같은 수 중 글자순 첫째라는 것도 함께 말합니다.
|
||||
- 전체 `trend`가 `좋아짐`(0.4444 → 0.8333)으로 나오지만 직원과 FAQ를 비교한 것이라 버립니다.
|
||||
|
||||
## 7. 식당 주방으로 본 오늘 하루
|
||||
|
||||
| 오늘 | 식당 주방 | 단계 |
|
||||
| --- | --- | --- |
|
||||
| 데이터 품질 · 차원 | 아침 식재료 검수 | 1 |
|
||||
| 품질 규칙 · 오류 코드 | 검수 기준표 · 반품 사유 스티커 | 1 |
|
||||
| 경계 | 냉장고 온도 딱 5도 | 1 |
|
||||
| 이미 있는 데이터 · 오류와 경고 | 냉장고 먼저 열기 | 1 |
|
||||
| 고침 목록 · 데이터 계약 | 반품 사유서 | 1 |
|
||||
| 배치 게이트 · 격리함 | 트럭째 반품 · 보류 칸 | 2 |
|
||||
| LLM 검사 | 한 숟가락 맛보기 (상한 것은 안 맛봄) | 2 |
|
||||
| 기준을 바꾸면 | 반품 기준을 풀면 · 조이면 | 2 |
|
||||
| 품질 기록 · 알림 | 검수 일지 · 사장님 호출 | 2 |
|
||||
| LLM 호출 수 · 늘 같은가 | 맛보기 횟수 · 맛보는 사람 | 2 |
|
||||
| 스키마 · 새 스키마 | 품목별 검수 카드 · 생크림 카드 | 3 |
|
||||
| 되돌림 고리 | 보류 칸의 쪽지 떼기 | 3 |
|
||||
| 품질 보고서 · 데이터셋별 | 월말 일지 정리 · 업체별 성적표 | 3 |
|
||||
|
||||
## 8. 자주 하는 실수
|
||||
|
||||
| 실수 | 결과 | 바로잡기 |
|
||||
| --- | --- | --- |
|
||||
| 필드 이름을 검사 코드에 씀 | 새 데이터셋마다 워크플로우 수정 | 스키마의 `fields`를 돌며 타입마다 |
|
||||
| 날짜를 꼴(`YYYY-MM-DD`)만 봄 | 13월 · 2월 30일이 통과 | 실제 있는 날인지 |
|
||||
| 빈 선택 칸에 형식 검사 | 멀쩡한 줄이 `bad_format` | 비었으면 `required`만 |
|
||||
| 스키마만으로 FAQ를 검사 | 메일 주소 FAQ가 통과 | 내용 규칙은 Code로 따로 |
|
||||
| 고친 줄을 넣고 격리함을 그대로 | 해결된 일이 "할 일"로 남음 | 넣은 id만 뺌 |
|
||||
| 스키마를 리뷰 없이 바꿈 | 규칙이 몰래 바뀜 | 스키마도 저장소 · 리뷰 · 테스트 |
|
||||
| 거절을 평균에서 뺌 (말없이) | 품질이 좋아 보임 | 정의를 정하고 함께 말함 |
|
||||
| 전체 평균 · 섞은 추세만 봄 | 손볼 데이터셋이 가려짐 | 데이터셋별로, 추세는 그 안에서 |
|
||||
|
||||
## 확인 질문
|
||||
|
||||
<details><summary>1. 스키마 검사를 만들었으니 Lab 01의 FAQ 검사는 지워도 되나요?</summary>
|
||||
|
||||
아닙니다. 스키마는 모양(필수 · 길이 · 꼴 · 중복)을 보고, FAQ 검사는 FAQ만의 내용 규칙(답 안의 개인정보 · 바깥 링크)과 이미 있는 FAQ와의 관계를 봅니다. B-2를 스키마로만 보면 메일 주소 FAQ-209가 통과합니다. 모양은 스키마로 넓게, 내용은 Code로 깊게.
|
||||
</details>
|
||||
|
||||
<details><summary>2. 고친 배치를 다시 넣었는데 격리함에서 빼지 않으면 무엇이 문제인가요? 넣기는 됐잖아요.</summary>
|
||||
|
||||
격리함은 "사람이 볼 일"의 목록입니다. 해결된 FAQ-209 · 210이 남아 있으면 다음 사람이 또 고치려 하거나, 목록 전체를 믿지 않게 됩니다. 넣은 줄만 빼고, 또 틀린 줄은 남겨 다시 사람에게 보냅니다.
|
||||
</details>
|
||||
|
||||
<details><summary>3. 새 스키마를 더할 때 워크플로우를 고치지 않아도 된다면, 스키마는 누구나 바꿔도 되나요?</summary>
|
||||
|
||||
반대입니다. 워크플로우를 고치지 않고 판정이 바뀐다는 것은, 스키마 한 줄이 곧 규칙이라는 뜻입니다. `min: 4`를 `min: 40`으로 잘못 쓰면 그날부터 모든 접수 건이 막힙니다. 스키마도 코드처럼 저장소에 두고 리뷰 · 테스트를 거쳐 바꿉니다.
|
||||
</details>
|
||||
|
||||
<details><summary>4. 품질 보고서의 평균은 0.6583입니다. 거절된 B-1을 빼면 0.8778인데, 어느 쪽을 보고해야 하나요?</summary>
|
||||
|
||||
무엇을 보고 싶은지에 따라 정하고, 정의에 적어 둡니다. 오늘은 콘텐츠 담당자가 **보내오는** 배치의 품질을 보므로 거절도 넣습니다(0.6583). "받은 배치의 품질"이 궁금하면 뺀 숫자(0.8778)를 따로 말합니다. 정하지 않고 그때그때 고르면 숫자를 비교할 수 없습니다.
|
||||
</details>
|
||||
|
||||
<details><summary>5. 데이터셋별로 나누니 접수 건이 0.2222로 가장 나쁩니다. 그런데 기록이 한 배치씩뿐입니다. 이 결론을 믿어도 되나요?</summary>
|
||||
|
||||
"이번 배치는 접수 건이 가장 나쁘다"까지는 말할 수 있습니다. "접수 건 데이터가 늘 나쁘다"는 배치가 쌓여야 말할 수 있습니다 (77일차 표본 크기). 9줄 중 7줄이 줄마다 다른 이유로 나쁘다는 것은 옮기기 전에 원본을 손봐야 한다는 신호로는 충분합니다.
|
||||
</details>
|
||||
@@ -0,0 +1,207 @@
|
||||
# 4단계 · 정리: 오늘 한 일의 의미 · 효과 · 실전 응용
|
||||
|
||||
- 교시: 8교시 17:10~17:45 (35분, 바이브 코딩 발표 10분 뒤) · 자료: 이 문서 + [개요](00-DAY78-OVERVIEW.md) 30장
|
||||
- 이 시간이 끝나면: 오늘 1~3단계에서 한 일이 **무엇을 위한 것이었는지**, 숫자로 **무엇이 달라졌고 무엇을 치렀는지**, 회사에서는 같은 생각을 **어떤 이름과 도구로** 쓰는지 말할 수 있고, 최종 프로젝트에 무엇을 가져갈지 정할 수 있다.
|
||||
- 숫자: 오늘 교안의 실측(강사 노트북, 11/01 정답 워크플로우 1회)입니다. "예시"라고 적은 계산은 가정을 둔 어림입니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["1. 오늘 한 장"] --> B["2. 왜 데이터 품질을<br/>자동으로"] --> 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단계 · 품질 규칙 | 눈으로 · 기준 검사, 규칙대로 판정하는 검사, 경계, 이미 있는 데이터, 고침 목록 | 기준 **10/13 통과 · 1/5** → 규칙대로 **5/13 · 5/5**, 경계 6/6 대 **0/6**, 이미 있는 데이터까지 3/13, 고침 목록 10줄 |
|
||||
| 2단계 · 품질 게이트 | 배치 게이트 · 격리, LLM 검사, 기준을 바꾸면, 기록 · 알림, LLM 호출 수 | 기준 가져오기 **12줄 넣음 · 0/6** → B-1 거절 · B-2 9줄 → LLM이 FAQ-210을 막아 8줄 · 6/6, 기준 0.8이면 격리 **11**, LLM 호출 9 · 5 · 0 |
|
||||
| 3단계 · 품질 관리 | 스키마 검사, 고쳐서 다시, 새 스키마, 품질 보고서, 데이터셋별 | 직원 CSV 4/9, 격리 2 → 0, 접수 건 2/9 (**워크플로우 그대로**), 평균 0.6583, ticket **0.2222** |
|
||||
|
||||
## 2. 왜 데이터 품질을 자동으로
|
||||
|
||||
> **생활 속 예 · 식당의 검수.** 장사가 잘되는 식당은 손님이 "맛이 이상해요"라고 하기 전에 아침 검수에서 상한 재료를 골라냅니다. 손님이 알아챘을 때는 이미 늦습니다 — 접시는 나갔고 믿음은 깎였습니다. 그리고 검수가 주방장 한 사람의 눈에만 있으면, 그 사람이 쉬는 날 상한 재료가 들어옵니다. 그래서 기준표 · 반품 칸 · 일지를 둡니다. 3단계 7절의 "식당 주방으로 본 오늘 하루" 표도 함께 봅니다.
|
||||
|
||||
| 사람이 눈으로만 검사하면 | 규칙 · 게이트 · 기록으로 하면 |
|
||||
| --- | --- |
|
||||
| 바쁜 날 놓침 (기준 검사 10/13 통과) | 같은 배치에 늘 같은 판정 (5/13) |
|
||||
| "이 정도면 괜찮겠지" — 경계가 그날 기분 | 40자 넘으면 · 공백은 빈 값 · 호스트가 정확히 |
|
||||
| 나쁜 FAQ가 직원 화면에 (전화번호 · 단축 링크) | 거절 · 격리, 넣지 않음 |
|
||||
| 고칠 것을 말로 전달 | 고침 목록 (줄 · 코드 · 말) |
|
||||
| 새 데이터마다 사람을 새로 | 스키마 한 장 |
|
||||
| "요즘 품질 어때?"에 감으로 | 품질 기록 → 평균 · 가장 나쁜 배치 · 흔한 문제 · 데이터셋별 |
|
||||
|
||||
**오늘의 원칙 세 줄**: 규칙은 Code가, 뜻은 LLM이, 고치는 것은 사람이. 워크플로우는 나쁜 데이터를 고쳐서 넣지 않고 **막고 알리고 기록합니다.**
|
||||
|
||||
## 3. 효과와 값을 숫자로
|
||||
|
||||
### 3.1 오늘 실측 전 · 후
|
||||
|
||||
| 무엇 | 전 (팀의 기준) | 후 | 바꾼 것 |
|
||||
| --- | --- | --- | --- |
|
||||
| B-1 13줄 판정 | 10줄 통과 | 5줄 → 이미 있는 데이터까지 3줄 | 규칙 3 → 10 (+ 이미 있는 데이터) |
|
||||
| 경계 10줄 | 10줄 통과 (0/6) | 4줄 통과 (6/6) | 경계의 말 |
|
||||
| B-1이 FAQ에 | **12줄** (전화번호 · 단축 링크 · 같은 id) | **0줄** · 422 | 배치 게이트 |
|
||||
| B-2가 FAQ에 | 10줄 (메일 주소 · 다른 답) | 8줄, 2줄은 이유와 함께 격리 | 줄 게이트 + LLM 검사 |
|
||||
| 나쁜 줄의 행방 | 모름 (격리함 0) | 격리함 + 이유, 고치면 빼기 | 격리 · 되돌림 고리 |
|
||||
| 직원 · 접수 건 | 검사 못 함 (400) | 4/9 · 2/9 | 스키마 |
|
||||
| 새 데이터셋 | 워크플로우를 하나 더 | 스키마 파일 한 장 | 규칙을 데이터로 |
|
||||
| "품질 어때?" | 감 | 0.6583 · B-1 · `pii` · ticket 0.2222 | 품질 기록 · 보고서 |
|
||||
|
||||
**치른 값**:
|
||||
|
||||
- **시간**: LLM 검사로 B-2 가져오기가 1.4초 → 9.3초. 가져오기라 괜찮았지만, 사람이 기다리는 곳에서는 다른 판단이 필요합니다.
|
||||
- **사람의 일**: 격리함은 누군가 봐야 합니다. 기준을 0.8로 풀면 11줄. 기준은 그 일의 양을 정하는 값입니다.
|
||||
- **규칙을 지키는 일**: 규칙(AGENTS.md · 스키마)을 바꾸면 모든 판정이 바뀝니다. 바꿀 때는 누가 · 왜를 남기고 테스트(경계 포함)를 함께 고칩니다.
|
||||
- **오탐 · 미탐**: `01012345678`은 잡지만 `010 1234 5678`처럼 띄어 쓴 변형이나 이미지 속 번호는 못 잡습니다. 규칙은 완벽하지 않고, 그래서 기록과 사람의 검토가 함께 갑니다.
|
||||
|
||||
### 3.2 한 달로 보면 (예시)
|
||||
|
||||
가정: 콘텐츠 담당자가 하루 한 배치(10~13줄), 한 달 평일 20배치, 나쁜 FAQ 한 줄이 들어가면 직원 문의 5건이 틀린 답을 받고, 그중 1건이 담당자 확인(5분)으로 이어짐.
|
||||
|
||||
| | 검사 없이 (기준 가져오기, 오늘 비율) | 게이트 뒤 (예시) |
|
||||
| --- | --- | --- |
|
||||
| 들어가는 나쁜 줄 | B-1에서 12줄 중 약 9줄 · B-2에서 2줄 → 배치당 수 줄 | 거의 0 (규칙 · LLM에 걸리는 것) |
|
||||
| 틀린 답 · 담당자 시간 | 한 달 수십 줄 × 5건 → 담당자 확인 수십 번 | 격리함을 보는 시간 (하루 몇 분) |
|
||||
| LLM 비용 | 0 | 규칙을 통과한 줄만 — 한 배치 5~9번 |
|
||||
|
||||
- 숫자는 가정입니다. 그래도 **나쁜 데이터 한 줄의 값(틀린 답 · 사람의 시간 · 개인정보 사고)과 막는 값(시간 · LLM 호출 · 격리함)을 같은 표에서 말할 수 있다**는 것이 오늘 만든 기록의 효과입니다. 개인정보 한 줄은 횟수로 셀 수 없는 비용입니다.
|
||||
|
||||
## 4. 회사에서는 같은 생각을 이렇게 부른다
|
||||
|
||||
| 오늘 한 것 | 업계 이름 | 쓰는 도구 · 표준 (예) |
|
||||
| --- | --- | --- |
|
||||
| 품질 차원 | data quality dimensions (완전성 · 유효성 · 고유성 · 일관성 · 정확성 · 적시성) | DAMA-DMBOK, ISO 8000 |
|
||||
| 품질 규칙 · 오류 코드 | data validation rules, expectations | Great Expectations · Soda · Deequ · dbt tests |
|
||||
| 경계 테스트 | boundary value testing | 단위 테스트 · property-based testing (Hypothesis) |
|
||||
| 이미 있는 데이터와 맞추기 | referential integrity, unique constraint | 데이터베이스 제약 · dbt `relationships` · `unique` |
|
||||
| 고침 목록 · 규칙 약속 | data contract, validation report | Data Contract Specification, 스키마 레지스트리 |
|
||||
| 배치 게이트 · 품질 관문 | quality gate, circuit breaker | Airflow · Dagster 센서, CI의 데이터 테스트 |
|
||||
| 격리함 · 되돌림 | quarantine table, dead-letter queue, replay | Kafka DLQ · Delta Lake quarantine |
|
||||
| LLM 검사 | LLM-as-a-judge, content moderation | OpenAI moderation · 사내 평가기 |
|
||||
| 기준의 대가 | precision / recall, 1종 · 2종 오류 | 혼동 행렬, 임계값 조정 |
|
||||
| 품질 기록 · 보고서 | data quality score, data observability | Monte Carlo · Bigeye · Soda Cloud |
|
||||
| 스키마 | JSON Schema · OpenAPI · table schema | pydantic · zod · Avro · Protobuf |
|
||||
| 새 스키마로 넓히기 | configuration over code, schema registry | Confluent Schema Registry |
|
||||
| `tcheck` | 데이터 검사 CLI · pre-commit 검사 | `csvkit` · `great_expectations checkpoint` · pre-commit 훅 |
|
||||
|
||||
- 오늘은 n8n 워크플로우 · 작은 데이터 API · `flow.py`로 직접 만들었습니다. 회사에서는 위 도구가 대신하는 경우가 많습니다. 그래도 **무엇이 나쁜지(규칙), 어디서 막을지(관문), 얼마나 막을지(기준), 누가 고칠지(주인)**는 오늘처럼 사람이 정합니다.
|
||||
|
||||
## 5. 산업별 응용 (예)
|
||||
|
||||
아래는 오늘 한 일을 다른 업무에 옮긴 예입니다 (실제 특정 회사의 사례가 아니라 흔한 모양).
|
||||
|
||||
| 업무 | 오늘의 무엇을 | 이렇게 |
|
||||
| --- | --- | --- |
|
||||
| 고객센터 챗봇 · FAQ (오늘) | 규칙 · LLM 검사 · 고침 목록 | 콘텐츠 팀이 올리는 답에 개인정보 · 링크 · 질문과 맞나 검사, 반려 사유를 자동으로 |
|
||||
| 쇼핑몰 상품 등록 | 스키마 · 배치 게이트 | 판매자가 올린 상품 CSV를 가격 · 재고 · 카테고리 스키마로, 나쁜 줄이 많으면 통째로 반려 |
|
||||
| 인사 · 회계 데이터 이관 | 스키마 · 격리 · 되돌림 | 새 시스템으로 옮길 때 나쁜 줄을 격리하고 담당 부서가 고쳐 다시 |
|
||||
| RAG · 지식 검색 | 품질 게이트 · 중복 · 이미 있는 데이터 | 문서를 색인하기 전에 빈 문서 · 중복 · 개인정보 · 오래된 판을 막음 (80~82일차 단위 프로젝트) |
|
||||
| 데이터 파이프라인 · ETL | 품질 기록 · 데이터셋별 | 밤마다 적재한 테이블의 품질 점수, 떨어지면 알림, 테이블별 성적표 |
|
||||
| 의료 · 금융 (규제) | 데이터 계약 · 감사 | 들어온 데이터의 판정과 이유를 모두 기록, 누가 언제 고쳤는지 |
|
||||
|
||||
**토론 거리**: 내 회사(또는 가고 싶은 회사)의 데이터 하나를 골라, "스키마 다섯 칸과 막을 규칙 셋을 정한다면?"을 정해 봅니다.
|
||||
|
||||
## 6. 데이터 계약서 (최종 프로젝트용 틀)
|
||||
|
||||
오늘의 AGENTS.md 3절이 데이터 계약서였습니다. 최종 프로젝트에서도 데이터마다 이 틀로 한 장을 씁니다.
|
||||
|
||||
| 칸 | 타입 · 꼴 | 필수 | 규칙 (코드) | 위반이면 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `id` | `pattern ^FAQ-\d{3}$` | ✔ | `bad_format:id`, 중복 `duplicate_in_batch`, 이미 있음 `exists_id` | 격리 |
|
||||
| `title` | `string` ≤ 40 | ✔ | `missing:title` · `too_long:title`, 같은 제목 `same_title` | 격리 |
|
||||
| `answer` | `string` 10~300 | ✔ | 개인정보 `pii` · 바깥 링크 `external_link` · 뜻 `llm_mismatch` | 격리 |
|
||||
| 배치 | — | — | 나쁜 줄 비율 > 0.3 | 거절 + 알림 + 고침 목록 |
|
||||
| 기록 | `{batch, dataset, total, valid, quarantined, rejected, score, codes}` | — | score < 0.9 | 알림 |
|
||||
|
||||
- 계약이 없으면 품질을 말할 수 없습니다. **규칙과 그 말이 있어야 "좋아졌다"고 말할 수 있습니다.**
|
||||
|
||||
## 7. 하지 말아야 할 때 (흔한 실수)
|
||||
|
||||
| 하기 쉬운 일 | 왜 위험한가 | 대신 |
|
||||
| --- | --- | --- |
|
||||
| 규칙 없이 "나쁜 것 걸러 줘" | 판정이 사람 · 에이전트마다 다름 | 규칙 표 · 코드 |
|
||||
| 경계의 말을 안 정함 | 딱 기준선의 줄이 날마다 다르게 | 넘으면 · 공백 · 꼴 · 정확히 |
|
||||
| 나쁜 데이터를 고쳐서 넣음 | 틀린 것이 "통과"로, 원인이 숨음 | 막고 알리고, 사람이 고침 |
|
||||
| 나쁜 줄을 버림 | 무엇이 빠졌는지 모름 | 격리 + 이유 |
|
||||
| 모든 것을 LLM에게 판정 | 늘 같지 않고 비쌈 | 규칙은 Code, 뜻만 LLM, 통과한 줄에만 |
|
||||
| LLM을 모르면 통과 | LLM이 멈춘 날 검사 없이 | fail-closed |
|
||||
| 기준을 아무렇게 | 격리함 폭발 또는 모두 반려 | 대가를 재고 팀이 정해 적음 |
|
||||
| 스키마를 몰래 바꿈 | 규칙이 바뀐 줄 모름 | 저장소 · 리뷰 · 테스트 |
|
||||
| 평균 하나로 보고 | 손볼 데이터셋이 가려짐 | 데이터셋별, 추세는 그 안에서 |
|
||||
|
||||
## 8. 직무와 면접
|
||||
|
||||
오늘 한 일은 다음 직무들이 날마다 하는 일입니다: **데이터 엔지니어 · 분석 엔지니어, 데이터 품질 · 거버넌스 담당, 백엔드 · 플랫폼 엔지니어, AI · LLM 애플리케이션 엔지니어(RAG 데이터 준비), 콘텐츠 · CS 운영, 업무 자동화(n8n · RPA) 개발자.** 채용 공고의 "데이터 품질 관리", "데이터 검증 · 정합성", "ETL · 파이프라인 모니터링", "데이터 거버넌스", "스키마 설계"가 오늘 내용입니다.
|
||||
|
||||
면접에서 흔히 받는 질문과 오늘 숫자로 답하는 법:
|
||||
|
||||
<details><summary>1. "데이터 품질을 어떻게 정의하고 측정하나요?"</summary>
|
||||
|
||||
품질 차원(완전성 · 유효성 · 고유성 · 일관성 · 정확성)으로 나누고, 차원마다 규칙과 오류 코드를 정했습니다. 줄마다 코드를 붙이고 배치마다 통과 비율을 점수로 기록했습니다. 오늘 FAQ 배치 B-1은 13줄 중 3줄만 통과했고, 가장 흔한 문제는 개인정보(`pii`)였습니다. 데이터셋별로 나누니 접수 건이 0.2222로 가장 나빴습니다.
|
||||
</details>
|
||||
|
||||
<details><summary>2. "나쁜 데이터가 들어오면 어떻게 하나요? 고쳐서 넣으면 안 되나요?"</summary>
|
||||
|
||||
두 관문을 둡니다. 나쁜 줄 비율이 기준(0.3)을 넘으면 배치를 통째로 거절하고 고침 목록과 함께 돌려보내고, 넘지 않으면 좋은 줄만 넣고 나쁜 줄은 이유와 함께 격리합니다. 고쳐서 넣지 않는 까닭은, 틀린 내용이 "검사를 통과한 데이터"가 되고 원인(보내는 쪽)이 그대로 남기 때문입니다. 고쳐 돌아온 줄은 다시 검사해 넣고 격리함에서 뺍니다.
|
||||
</details>
|
||||
|
||||
<details><summary>3. "LLM을 데이터 검사에 쓴다면 무엇을 조심하나요?"</summary>
|
||||
|
||||
규칙으로 판정할 수 있는 것은 Code로 하고, 뜻(답이 질문에 맞나)만 LLM에게, 그것도 규칙을 통과한 줄에만 묻습니다. 오늘 B-2는 10줄 중 9줄만 물었고, LLM이 막은 줄은 하나였습니다. 판정을 읽을 수 없으면 넣지 않고(fail-closed), 호출 수를 기록해 비용을 말하고, 같은 처음 상태에서 여러 번 돌려 판정이 같은지 봤습니다.
|
||||
</details>
|
||||
|
||||
<details><summary>4. "검사 기준(임계값)은 어떻게 정하나요?"</summary>
|
||||
|
||||
기준을 바꿔 가며 대가를 쟀습니다. 0.3이면 B-1을 거절, 0.8로 풀면 받지만 격리함에 11줄이 쌓이고, 0.05로 조이면 B-2가 한 줄 때문에 거절됩니다. 거절(보내는 쪽의 일)과 격리(검토하는 쪽의 일)를 맞바꾸는 값이라, 팀이 일의 양을 보고 정해 적어 둡니다. 경계("같으면 통과")도 말로 정합니다.
|
||||
</details>
|
||||
|
||||
<details><summary>5. "새로운 데이터셋이 생기면 검사를 어떻게 늘리나요?"</summary>
|
||||
|
||||
규칙을 코드가 아니라 스키마로 둡니다. 검사 코드는 칸 이름을 모르고 타입마다 판정하므로, 오늘 접수 건(`ticket`) 스키마 한 장을 더하자 워크플로우를 고치지 않고 9줄을 검사했습니다. 대신 스키마 변경이 곧 규칙 변경이라 저장소에 두고 리뷰 · 테스트를 거칩니다. 데이터마다 다른 내용 규칙(개인정보 등)은 Code로 따로 둡니다.
|
||||
</details>
|
||||
|
||||
## 9. 최종 프로젝트로 가져갈 것
|
||||
|
||||
최종 프로젝트(85일차~)의 워크플로우에 아래를 넣으면 "들어오는 데이터를 믿을 수 있는 프로젝트"가 됩니다.
|
||||
|
||||
- [ ] 데이터마다 계약서 한 장 (6절 틀): 칸 · 타입 · 필수 · 규칙 코드 · 위반이면
|
||||
- [ ] 검사 웹훅 하나 (규칙은 한 곳), 줄마다 코드, 목록이 아니면 400
|
||||
- [ ] 경계 테스트 (딱 기준선 · 공백 · 꼴의 변형 · 비슷한 값)
|
||||
- [ ] 가져오기 게이트: 배치 거절(기준은 입력으로) · 줄 격리 + 이유, LLM은 통과한 줄에만 · fail-closed
|
||||
- [ ] 고침 목록을 보낸 사람에게, 고쳐 돌아오면 격리함 비우기
|
||||
- [ ] 스키마로 모양을 넓게, 데이터만의 규칙은 Code로
|
||||
- [ ] 품질 기록 (같은 모양) · 보고서 (평균 · 최저 · 흔한 문제 · 데이터셋별), 거절 · 낮은 점수 알림, `QUALITY.md`
|
||||
|
||||
## 10. 다음 일차와의 연결
|
||||
|
||||
| 일차 | 오늘의 무엇을 잇나 |
|
||||
| --- | --- |
|
||||
| 79 | 운영 데이터 기반 Ops Assistant(`tops`) — 77일차 실행 기록 · 오늘의 품질 기록 · 76일차 알림을 모아 "지금 무엇이 문제야?"에 숫자로 답하는 도우미, `tlog` · `tcheck`의 결과까지 |
|
||||
| 80~82 | 단위 프로젝트 — RAG 지식 문서를 색인하기 전에 오늘의 품질 관문, 팀 워크플로우에 9절 체크리스트 |
|
||||
|
||||
## 토론 (5분)
|
||||
|
||||
1. 오늘 "고치는 것은 사람"을 여러 번 했습니다. 공백을 지우는 것처럼 워크플로우가 "다듬어도 되는" 것과 "고치면 안 되는" 것의 경계는 어디일까요?
|
||||
2. B-1을 거절할까요, 기준을 풀어 2줄이라도 받을까요? 와이파이 FAQ처럼 그 배치에 **급한** 한 줄이 있다면 결정이 바뀌나요?
|
||||
|
||||
## 정리
|
||||
|
||||
- [ ] 데이터 품질은 차원으로 나눠 말하고, **규칙과 오류 코드**로 판정한다 (기준 10/13 vs 규칙 5/13)
|
||||
- [ ] 경계의 말을 정하고 경계에서 시험한다 (6/6 vs 0/6)
|
||||
- [ ] 검사와 게이트를 나눈다: 배치 거절 · 줄 격리 + 이유, 고치지 않고 돌려준다
|
||||
- [ ] 규칙은 Code, 뜻은 LLM(통과한 줄에만 · fail-closed), 고치는 것은 사람
|
||||
- [ ] 기준은 고르는 값 — 대가를 재고 적어 둔다
|
||||
- [ ] 규칙을 스키마로 꺼내 넓히고, 품질을 기록 · 보고서 · 데이터셋별로 관리한다
|
||||
- [ ] 최종 프로젝트에 체크리스트 7개를 가져간다
|
||||
@@ -0,0 +1,96 @@
|
||||
# 7·8교시 · 바이브 코딩 (`tcheck`)
|
||||
|
||||
- 교시: 7교시 16:00~16:50 (각자 만들기), 8교시 17:00~17:50 (발표 10분 · [4단계 정리](04-STAGE4-WRAPUP.md) 35분 · 예고 5분)
|
||||
- 과제: [VIBE-REQ-TCHECK](../../Last3Mon/78/VIBE-REQ-TCHECK.md)(요청서, 요청의 말 ①~⑫) → [VIBE-Q-TCHECK](../../Last3Mon/78/VIBE-Q-TCHECK.md)의 8단계: `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md`(엔진 · TUI 단계) → 단계별 구현 → 확인 → `uv tool install` → 다른 폴더에서 사용 → 수업 뒤 `VIBE-A-TCHECK.md` 공개
|
||||
- 요구 14개(R1~R14). 50분 안에는 **엔진 1단계(R1 · R2 · R3 · R4 · R12 · R13) + 엔진 2단계(R5 · R6 · R7) + TUI 1단계(R11) + 설치(R14)**, 나머지(R8 내용 규칙, R9 고침 목록, R10 품질 기록)는 IMP-PLAN의 다음 단계로 집에서.
|
||||
- 72일차 `tbench`부터 77일차 `tlog`와 같은 흐름입니다: 사람의 말(요청서)을 **확인할 수 있는 요구**로 바꾸는 일을 학생이 먼저 하고, VIBE-Q는 그 다음 단계(비교 대상)로 씁니다. 에이전트에게는 처음 요청 한 번과 "확인했고 commit 했어. 다음 단계." 한 줄만 씁니다.
|
||||
- 오늘은 `tcheck`: CSV · JSON(오늘 요청 파일 모양 포함)을 데이터 API의 스키마로 검사해 **n8n `my-check`와 같은 판정**을 내고, 깨끗한 줄 · 나쁜 줄을 따로 내보내고, 배치 게이트를 종료 코드로 알리고, TUI로 탭을 나눠 보는 도구. 오늘 n8n 워크플로우로 한 일을 **보내기 전에, 내 노트북에서.**
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
R["요청서 → TODO · SPEC ·<br/>IMP-PLAN 20분"] --> A["7교시<br/>엔진 1 · 2 · TUI · 설치 30분"] --> B["발표<br/>10분"] --> C["4단계 정리<br/>35분"] --> F["79일차 예고<br/>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/9 · 접수 건 2/9 · 줄마다 코드, 종료 코드) |
|
||||
| 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 다른 폴더에서 `tcheck` | `tcheck check …/employees-new.json --url http://localhost:8800` = `my-check`의 판정 |
|
||||
|
||||
| 볼 것 | 막혔을 때 한마디 |
|
||||
| --- | --- |
|
||||
| TODO · SPEC 없이 VIBE-Q를 통째로 붙여 넣었나 | "요청서로 Doc/TODO.md부터. 직원 4/9가 SPEC에 없으면 맞았는지 모릅니다" |
|
||||
| IMP-PLAN이 한 덩어리 · 첫 요청에 14개 | "엔진 1단계만. 단계마다 확인 · 체크 · commit" |
|
||||
| 판정이 n8n과 다름 | "어느 줄 · 어느 코드가 다른지 붙여 주고, AGENTS.md 3절 스키마 표 그대로" |
|
||||
| 필드 이름이 코드에 (`if name == "email"`) | "스키마의 fields를 돌며 type마다" — 오늘 Lab 03 예제 3 |
|
||||
| `2026-02-30` · `2026-13-01`이 통과 | "실제 있는 날짜인지까지" |
|
||||
| 빈 전화번호가 `bad_format` | "선택 칸은 비었으면 형식을 보지 않음" |
|
||||
| 테스트가 진짜 API를 부름 | "가짜 서버(http.server)와 스키마 · n8n 결과 사본으로" |
|
||||
| `gate`가 늘 0 | "넘으면 거절, 거절이면 종료 코드 4, 같으면 통과" |
|
||||
| 실행해 보지 않고 다음 요청 | "에이전트가 됐다고 해도 직접 `uv run tcheck …; echo $?`" |
|
||||
|
||||
정답 예(R1~R14를 모두 넣은 `tcheck`와 정답본 `VIBE-A-TCHECK.md`)는 수업 뒤 공개됩니다.
|
||||
|
||||
## 2. 8교시 · 발표 (10분)
|
||||
|
||||
- 1~2명. **`tcheck check --json`과 `my-check`의 판정이 같다는 것을 보여 줄 수 있는 사람**, `tcheck gate`의 종료 코드 4를 보여 줄 수 있는 사람을 먼저 고릅니다.
|
||||
- 3분 발표 · 2분 질문: **내 SPEC과 VIBE-Q가 달랐던 곳** / IMP-PLAN을 어떻게 나눴나 / 처음 요청 · 고쳐 달라는 요청문 / 에이전트가 틀린 곳(판정이 달랐던 줄) / 시연.
|
||||
|
||||
## 3. 강사 정리 (8교시 4단계 정리 안에서)
|
||||
|
||||
| | n8n (오늘 Lab) | tcheck |
|
||||
| --- | --- | --- |
|
||||
| 읽기 | 웹훅 본문 (`items` · `csv`) | 파일: `.csv` · JSON 목록 · `{"items"}` · 요청 파일 (R1) |
|
||||
| 규칙 | `my-check`의 `Check rows` (스키마) | `checks.check` — 같은 타입 판정, 같은 코드 (R2 · R3 · R4) |
|
||||
| 내용 규칙 | `my-faq-check` (Lab 01) | `--content answer` (R8) |
|
||||
| 게이트 | `my-faq-import`의 `Batch ok?` (Lab 02) | `tcheck gate` · 종료 코드 4 (R7) |
|
||||
| 격리 | `/quarantine` | `--out` · `--rejects` (R6) |
|
||||
| 고침 목록 | Lab 01 과제 2 | `--fix` (R9) |
|
||||
| 품질 기록 | Lab 03 과제 2 | `--log` (R10) |
|
||||
|
||||
- 풀이 거리: 강사 본은 직원 HR-1 · 접수 건 TK-1 · FAQ B-3에서 `rows` · `codes` · `valid`가 `my-check`와 **하나도 다르지 않았습니다.** Python `re`의 `\w`는 한글도 맞아 메일 판정이 JavaScript와 다를 수 있었고(`re.ASCII`), 날짜를 꼴만 보면 2월 30일이 통과합니다. "같은 규칙을 두 곳에 쓰면 반드시 어긋난다 — 그래서 n8n 안에서는 검사를 다시 부르고, 밖에서는 테스트로 같은 판정을 확인한다."
|
||||
- 같은 FAQ B-1을 `tcheck --schema faq --content answer`로 보면 Lab 01 예제 2의 8줄과 같은 줄이 나쁩니다 — 코드 이름만 다릅니다 (`title_missing` ↔ `missing:title`).
|
||||
|
||||
## 4. 강사 바이브 코딩 (시간이 남으면 · 수업 뒤, 15분)
|
||||
|
||||
| 주제 | 요청문 요지 | 보여 줄 것 |
|
||||
| --- | --- | --- |
|
||||
| pre-commit 훅 | "저장소에 `faq/*.json`을 커밋할 때 `tcheck gate --schema faq --content answer`가 4면 커밋을 막는 pre-commit 훅" | 보내기 전 검사를 개발 흐름에 |
|
||||
| 스키마 버전 비교 | "`tcheck diff-schema old.json new.json`: 같은 파일로 두 스키마의 판정이 달라지는 줄" | 스키마 변경 = 규칙 변경, 리뷰 근거 |
|
||||
| 이미 있는 데이터 | "`--existing http://localhost:8800/faq-all`로 `exists_id` · `same_title`" | Lab 01 과제 1을 도구로 |
|
||||
|
||||
걸린 시간과 결과를 `Last3Mon/78/VIBE-A-TCHECK.md`에 적어 둡니다.
|
||||
|
||||
## 5. 보충 · 팁 (읽을거리, 4단계 정리 7절과 함께)
|
||||
|
||||
| 팁 | 설명 |
|
||||
| --- | --- |
|
||||
| 요청서 → 요구가 먼저 | 확인 방법(기대 판정 · 종료 코드 · JSON 칸)이 있는 요구가 있어야 에이전트의 "완료"를 판단할 수 있음 |
|
||||
| 기대 판정은 이미 있다 | 오늘 `cases/lab03.json` · `lab03-schema.json`의 판정을 SPEC과 테스트에 그대로 |
|
||||
| n8n의 답을 사본으로 | `call my-check`의 응답을 `tests/data/`에 두면 n8n 없이도 "같은가"를 시험 |
|
||||
| 규칙은 한 곳 | 판정 함수는 `checks.py`에만, JSON · 파일 · 게이트 · 화면은 부르기만 |
|
||||
| 필드 이름을 코드에 쓰지 않기 | 스키마가 늘어도 코드는 그대로 |
|
||||
| 정규식도 정의 | 다른 언어와 판정을 맞출 때는 `\w` · `\d`의 뜻까지 |
|
||||
| 종료 코드로 말한다 | 0 · 1 · 2 · 4 — 스크립트 · pre-commit · CI가 다음 일을 정함 |
|
||||
| 단계마다 `git commit` | 에이전트가 망가뜨려도 돌아갈 곳 |
|
||||
|
||||
## 6. 79일차 예고 (5분)
|
||||
|
||||
- 오늘: 들어오는 데이터를 규칙으로 검사하고 · 막고 · 격리하고 · 기록했습니다. 다음 수업(79일차, 11/4): **운영 데이터 기반 Workflow Ops Assistant 구현** — 77일차 실행 기록, 오늘의 품질 기록, 76일차 알림을 한데 모아, 운영자가 "지금 무엇이 문제야?"라고 물으면 숫자로 답하는 도우미를 만듭니다.
|
||||
- 바이브 코딩: 오늘 `tcheck`와 77일차 `tlog`는 지우지 마세요. 79일차 `tops`에서 씁니다.
|
||||
|
||||
## 확인 (강사)
|
||||
|
||||
- [ ] 16:00 요청서 → TODO(에이전트 없이) → SPEC → IMP-PLAN 20분을 지켰다, 16:20부터 엔진 1단계.
|
||||
- [ ] 7교시 시작 전 n8n에 `my-check`(Lab 03 예제 1 이상)가 게시돼 있어 판정 비교를 할 수 있다.
|
||||
- [ ] 수업 뒤: 요청이 있을 때 학생 저장소에 정답(LAB-A · `answers/` · VIBE-A · `tcheck/`)을 올린다.
|
||||
- [ ] 발표자 1~2명, 강사 정리 표, 팁, 예고.
|
||||
- [ ] 수업이 끝날 때 학생들의 데이터 API가 처음 상태인지 (`quality --reset`, `doctor`).
|
||||
@@ -0,0 +1,40 @@
|
||||
# 78일차 · 데이터 품질 검증 자동화와 품질 관리 Workflow (바이브 코딩)
|
||||
|
||||
- 일자: 2026-11-03 (78일차)
|
||||
- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영
|
||||
- 단원: AI 워크플로우 운영 및 관리 역량 강화
|
||||
- 학습 내용: 데이터 품질 검증 자동화 및 품질 관리 Workflow 적용
|
||||
- 실습 폴더: [Last3Mon/78](../../Last3Mon/78/README.md)
|
||||
- 실습 환경: 65일차 n8n 2.42.6 + OpenAI API(`gpt-4.1-mini`) + 78일차 데이터 API(격리함 · 스키마 · 품질 기록) + **CLI 코딩 에이전트**
|
||||
- **실습 준비: [00-DAY78-PREP.md](00-DAY78-PREP.md) (수업 전날까지 1~2절 완료)**
|
||||
|
||||
## 오늘의 시간표
|
||||
|
||||
| 교시 | 시간 | 내용 | 자료 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 09:00~09:50 | 오늘의 개요 · 1단계 · 품질 규칙 | [개요](00-DAY78-OVERVIEW.md), [1단계](01-STAGE1-QUALITY-RULES.md) |
|
||||
| 2 | 10:00~10:50 | Lab 01 실습 5개 (눈으로 · 기준 검사, 검사 워크플로우, 경계, 이미 있는 데이터, 고침 목록) | [Lab 01](../../Last3Mon/78/LAB-01-Q-QUALITY-RULES.md) |
|
||||
| 3 | 11:00~11:50 | 2단계 · 품질 게이트 | [2단계](02-STAGE2-QUALITY-GATE.md) |
|
||||
| 4 | 12:00~12:50 | Lab 02 실습 5개 (배치 게이트 · 격리, LLM 검사, 기준을 바꾸면, 기록 · 알림, LLM 호출 수) | [Lab 02](../../Last3Mon/78/LAB-02-Q-QUALITY-GATE.md) |
|
||||
| - | 13:00~14:00 | 점심 | |
|
||||
| 5 | 14:00~14:50 | 3단계 · 품질 관리 | [3단계](03-STAGE3-QUALITY-MANAGEMENT.md) |
|
||||
| 6 | 15:00~15:50 | Lab 03 실습 5개 (스키마 검사, 고쳐서 다시, 새 스키마, 품질 보고서, 데이터셋별) | [Lab 03](../../Last3Mon/78/LAB-03-Q-QUALITY-MANAGEMENT.md) |
|
||||
| 7 | 16:00~16:50 | 바이브 코딩 · `tcheck` (요청서 → TODO → SPEC → IMP-PLAN → 만들기 → 설치) | [요청서](../../Last3Mon/78/VIBE-REQ-TCHECK.md), [VIBE-Q-TCHECK](../../Last3Mon/78/VIBE-Q-TCHECK.md), [진행](05-VIBE-CODING.md) |
|
||||
| 8 | 17:00~17:50 | 바이브 코딩 발표 · **4단계 정리** (의미 · 효과 · 실전 응용) · 79일차 예고 | [4단계 정리](04-STAGE4-WRAPUP.md), [진행](05-VIBE-CODING.md) |
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
P["개요"] --> S1["1단계<br/>품질 차원 · 규칙 · 코드 · 경계 · 이미 있는 데이터 · 고침 목록"] --> S2["2단계<br/>배치 게이트 · 격리 · LLM 검사 · 기준 · 기록 · 알림"] --> S3["3단계<br/>스키마 · 되돌림 · 새 스키마 · 보고서 · 데이터셋별"] --> V["7·8교시<br/>tcheck · 4단계 정리"]
|
||||
```
|
||||
|
||||
## 자료 목록
|
||||
|
||||
| 파일 | 내용 |
|
||||
| --- | --- |
|
||||
| `00-DAY78-PREP.md` | 실습 준비: n8n · 78일차 판 데이터 API 켜기, 기준 둘 게시, `flow.py quality` · `doctor` (수업 전) |
|
||||
| `00-DAY78-OVERVIEW.md` | 개요 30장 (PPT 대신 Markdown, 가로줄로 장 구분, 장마다 생활 속 예) |
|
||||
| `01-STAGE1-QUALITY-RULES.md` | 데이터 품질의 차원, 규칙은 말이 먼저, 오류 코드, 규칙은 Code가, 경계, 이미 있는 데이터 · 오류와 경고, 고침 목록 · 데이터 계약 |
|
||||
| `02-STAGE2-QUALITY-GATE.md` | 검사와 게이트, 배치 · 줄 게이트 · 격리함, LLM 검사(통과한 줄만 · fail-closed), 기준은 고르는 값, 기록 · 알림, LLM 호출 수 · 늘 같은가 |
|
||||
| `03-STAGE3-QUALITY-MANAGEMENT.md` | 스키마, 스키마로 되는 것 · 안 되는 것, 되돌림 고리, 설정으로 넓히기, 품질 보고서의 정의, 데이터셋별, 식당 주방으로 본 하루 |
|
||||
| `04-STAGE4-WRAPUP.md` | 8교시 4단계 정리 35분: 의미 · 효과 · 업계 이름 · 산업별 응용 · 데이터 계약서 · 면접 · 최종 프로젝트 체크리스트 |
|
||||
| `05-VIBE-CODING.md` | 7·8교시 바이브 코딩 진행 (`tcheck`), 발표, 강사 정리, 추가 바이브 코딩, 팁 |
|
||||
Reference in new issue
Block a user