75일차 답 추가: 실습 정답 문서 · VIBE-A · 정답 워크플로우와 25단계 실행 기록 · tcost
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
01dc892303
commit
ebff58c72e
79 files changed
+18753
No files matched your search
@@ -0,0 +1,119 @@
|
||||
# Lab 01 · 자원과 토큰을 재고, 비용을 계산하고, 기록하기
|
||||
|
||||
> **강사용 정답본** · 학생 저장소에 올리지 않습니다. 학생용 문제는 [LAB-01-Q-MEASURE-USAGE.md](LAB-01-Q-MEASURE-USAGE.md).
|
||||
> 요청문은 예시이며 CLI 에이전트로 보내 재지는 않았습니다. 결과 숫자는 실습을 5개로 늘린 뒤 정답 워크플로우를 학생 순서대로 2026-10-28에 1회씩 실행한 값입니다 (강사 Mac, `gpt-4.1-mini` · `gpt-5.4-nano`, 단가는 OpenAI 모델 문서 2026-10-28 확인). 명령 · 출력 전부: [answers/LAB-TEST-RESULT.md](answers/LAB-TEST-RESULT.md).
|
||||
|
||||
## 예제 1 · 결과 (에이전트 없이)
|
||||
|
||||
| 자원 (`docker stats`, `answers/01`) | CPU | 메모리 |
|
||||
| --- | --- | --- |
|
||||
| n8n | 0.27% | 642.6MiB |
|
||||
| runners | 0.21% | 100.5MiB |
|
||||
| data-api | 0.02% | 20.3MiB |
|
||||
|
||||
| `call base-cost` | 단계 | 입력 | 출력 |
|
||||
| --- | --- | --- | --- |
|
||||
| VPN | classify | 78 | 6 |
|
||||
| VPN | answer | **1,781** | 78 |
|
||||
| VPN | review | 140 | 66 |
|
||||
| VPN | 합 | 1,999 | 150 |
|
||||
| T-1002 | 합 (answer) | 1,973 (**1,791**) | 97 |
|
||||
|
||||
- 손 계산: (1,999 × 0.40 + 150 × 1.60) ÷ 1,000,000 = **$0.00104**. 하루 1,000건 × 22일 ≈ $22.9.
|
||||
- 풀이 때: "answer 입력이 왜 질문 종류와 관계없이 1,780토큰인가?" → 지시문에 붙은 사내 규정 전체. Lab 02 예제 1로.
|
||||
|
||||
## 예제 2 · 요청문 예
|
||||
|
||||
```text
|
||||
my-work/cost.json (baseline 을 복사한 것) 의 웹훅 경로를 my-cost 로, 이름을 "75 IT helpdesk (my)" 로 바꿔 줘.
|
||||
Has question? 뒤에 Get prices(HTTP GET http://data-api:8800/ prices ) 를 넣어 줘.
|
||||
Build result 에서 단계마다 cost_usd = (prompt_tokens × input + completion_tokens × output) ÷ 1000000 를
|
||||
그 단계 model 의 단가로 계산해 usage.by_step.<단계>.cost_usd 에, 합을 cost_usd 에 넣어 줘. 소수 8 자리에서 반올림.
|
||||
기대 결과 cases/lab01.json.
|
||||
```
|
||||
|
||||
**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`)
|
||||
|
||||
| 빈칸 | 답 | 왜 |
|
||||
| --- | --- | --- |
|
||||
| ① | `my-cost` | `cases/lab01.json`의 `webhook` (AGENTS.md 3절) |
|
||||
| ② | `prices` | 단가를 주는 주소 `GET /prices` (AGENTS.md 2절) |
|
||||
| ③ | `1000000` | 단가 단위가 "per 1M tokens" |
|
||||
| ④ | `8` | AGENTS.md 3절 `cost_usd` 반올림 자리 |
|
||||
|
||||
정답: [reference/lab01-example2-cost.json](reference/lab01-example2-cost.json)
|
||||
|
||||
| 10/28 결과 | |
|
||||
| --- | --- |
|
||||
| `cases/lab01.json` (`answers/02`) | **3/3** · VPN $0.001040 (손 계산과 같음) · 접수 $0.000946 |
|
||||
|
||||
## 예제 3 · 결과 (에이전트 없이)
|
||||
|
||||
| `lab01-mix` (`answers/03`) | 비용 ($) |
|
||||
| --- | --- |
|
||||
| FAQ · VPN · 프린터 · 비밀번호 · 메일 | 0.001040 · 0.000960 · 0.000953 · 0.000965 (평균 0.000980) |
|
||||
| 접수 · T-1002 · T-1001 | 0.000946 · 0.000890 (평균 0.000918) |
|
||||
| 규칙에 없는 질문 | 0.000830 |
|
||||
| 합계 | **7/7**, 모두 3 calls |
|
||||
|
||||
- 가중(60 · 30 · 10%): $0.000946 × 1,000 × 22 ≈ **$20.8**. 단순 평균: ≈ $20.7. 두 어림이 거의 같은 까닭이 이 예제의 답입니다 — **고정비(규정 전체)가 커서 질문 종류가 비용을 거의 바꾸지 않습니다.**
|
||||
- Lab 02 과제 1 뒤에는 질문마다 $0.000097 ~ $0.000166으로 벌어집니다 (고정비가 사라지면 변동비가 보임). 시간이 되면 이것을 마지막에 다시 돌려 보게 합니다.
|
||||
|
||||
## 과제 1 · 요청문 예
|
||||
|
||||
```text
|
||||
my-work/cost.json 의 Build result 뒤에 Record usage(HTTP POST http://data-api:8800/usage) 를 넣어 줘.
|
||||
본문은 items: usage.by_step 의 단계마다 한 줄 {request_id, user, step, model, prompt_tokens, completion_tokens, cost_usd, route}.
|
||||
request_id 는 $execution.id , user 는 웹훅 본문의 user .
|
||||
기록 응답이 오류여도 멈추지 않게 options.response 에 neverError: true , 그다음 Result again(Code) 이 Build result 의 결과를 다시 꺼내 Send result 로.
|
||||
기대 결과 cases/lab01-challenge.json.
|
||||
```
|
||||
|
||||
**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`)
|
||||
|
||||
| 빈칸 | 답 | 왜 |
|
||||
| --- | --- | --- |
|
||||
| ① | `$execution.id` | 이번 실행의 번호 — 한 요청의 호출 세 줄을 묶음 (AGENTS.md 5절) |
|
||||
| ② | `user` | 요청 본문의 사람 이름 칸 (`requests/faq-vpn.json`) |
|
||||
| ③ | `neverError: true` | 4xx · 5xx 응답도 성공 쪽으로 (74일차 Lab 02 과제 2와 같은 옵션) |
|
||||
| ④ | `Build result` | 사용자에게 보낼 답을 만든 노드. `Record usage` 뒤의 `$json`은 기록 API의 응답 |
|
||||
|
||||
정답: [answers/75-lab01-challenge-record.json](answers/75-lab01-challenge-record.json)
|
||||
|
||||
| 10/28 결과 | 고치기 전 (예제 2 정답, `answers/04`) | 고친 뒤 (`answers/05`) |
|
||||
| --- | --- | --- |
|
||||
| `cases/lab01-challenge.json` | 3/4 (요약 `requests` 0) | **4/4** |
|
||||
| `flow.py usage` | | requests 3 · calls 9 · $0.002951 (이준호 $0.001040 · 박서연 $0.000959 · 한지훈 $0.000953) |
|
||||
|
||||
## 과제 2 · 요청문 예
|
||||
|
||||
```text
|
||||
my-work/cost.json 의 Record usage 에 options.timeout 3000 과 onError continueRegularOutput 를 넣어 줘.
|
||||
Result again 은 Build result 의 결과에 recorded 를 더해: 기록 응답의 stored 가 1 이상이면 true, 아니면 false.
|
||||
기록이 실패해도 사용자에게 가는 answer · cost_usd 는 그대로 .
|
||||
기대 결과 cases/lab01-challenge2.json, cases/lab01-challenge.json.
|
||||
```
|
||||
|
||||
**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`)
|
||||
|
||||
| 빈칸 | 답 | 왜 |
|
||||
| --- | --- | --- |
|
||||
| ① | `3000` | 3초를 ms로 |
|
||||
| ② | `continueRegularOutput` | 실패한 항목을 **같은 출력**으로 계속 (74일차의 `continueErrorOutput`은 두 번째 출력으로 따로) |
|
||||
| ③ | `stored` | 기록 API가 성공하면 `{"stored": <항목 수>, "total"}` |
|
||||
| ④ | 그대로 | 기록은 운영 쪽 일. 답은 바뀌면 안 됨 |
|
||||
|
||||
정답: [answers/75-lab01-challenge2-safe-record.json](answers/75-lab01-challenge2-safe-record.json) (과제 1 정답 + `Record usage`의 `timeout` · `onError`, `Result again`의 `recorded`)
|
||||
|
||||
| 10/28 결과 (`cases/lab01-challenge2.json`) | 고치기 전 (과제 1 정답, `answers/06`) | 고친 뒤 (`answers/07`) |
|
||||
| --- | --- | --- |
|
||||
| 기록 API 고장 (`usage error`) | ✘ 답은 나감 · `recorded` 없음 (3.4초) | ✔ `recorded: false` (4.4초) |
|
||||
| 기록 API 느림 (`usage slow` 8초) | ✘ **11.2초** · `recorded` 없음 | ✔ `recorded: false` · **6.1초** |
|
||||
| 고친 뒤 · 기록됨 | ✘ `recorded` 없음 | ✔ `recorded: true` |
|
||||
| 요약 · 기록된 요청 1건 | ✔ (고장 두 건은 기록되지 않음) | ✔ |
|
||||
| 합계 | 1/4 | **4/4** |
|
||||
| `lab01-challenge` | | 4/4 |
|
||||
|
||||
- 고치기 전의 고장 케이스도 답은 나갑니다 (과제 1의 `neverError` 덕분에 500 응답을 받아 넘김). 그래서 이 과제의 핵심은 **시간(느림)**과 **표시(`recorded`)**입니다. 풀이 때 이 차이를 짚습니다.
|
||||
- **수업 준비 중 실제로 본 것**: 처음 데이터 API는 느림 모드에서 8초 뒤 **결국 저장**했습니다. 워크플로우는 3초에 포기해 `recorded: false`라고 답했는데, 다음 테스트를 시작할 때 늦게 저장된 한 줄이 끼어들어 `requests`가 하나 많았습니다. "시간 초과로 포기해도 상대 서버의 일은 끝나지 않을 수 있다"는 실제 운영 문제라 풀이 때 이야기합니다. 데이터 API는 "늦게 답하고 저장하지 않음"(503)으로 바꿨습니다.
|
||||
- **다음 단계로 넘기는 질문**: "기록이 빠진 요청은 Lab 03의 예산에서도 빠진다. 그럼 예산은 실제보다 남아 보이지 않나?" → Lab 03 예제 1 ▶ 2단계.
|
||||
@@ -0,0 +1,153 @@
|
||||
# Lab 02 · 보내는 것 줄이기, 단계마다 맞는 모델, LLM 한 번, 긴 질문의 상한
|
||||
|
||||
> **강사용 정답본** · 학생 저장소에 올리지 않습니다. 학생용 문제는 [LAB-02-Q-REDUCE-COST.md](LAB-02-Q-REDUCE-COST.md).
|
||||
> 요청문은 예시이며 CLI 에이전트로 보내 재지는 않았습니다. 결과 숫자는 정답 워크플로우를 학생 순서대로 2026-10-28에 1회씩 실행한 값입니다. 명령 · 출력 전부: [answers/LAB-TEST-RESULT.md](answers/LAB-TEST-RESULT.md).
|
||||
|
||||
## 한눈에 · VPN 질문 하나의 비용
|
||||
|
||||
| 단계 | `answer` 입력 | LLM 호출 | VPN 비용 ($) | 품질 테스트 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 기준 (Lab 01 과제 2 정답, `answers/08`) | 1,781 | 3 | 0.001007 | ✔ |
|
||||
| 예제 1 · 보내는 것 줄이기 (`answers/09`) | **165** | 3 | 0.000346 | ✔ |
|
||||
| 예제 2 · 분류 · 검토 nano (`answers/10`) | 165 | 3 | 0.000278 | ✔ |
|
||||
| 과제 1 · LLM 한 번 (`answers/13`) | 약 165 | **1** | **0.000156** | ✔ |
|
||||
|
||||
## 예제 1 · 요청문 예
|
||||
|
||||
```text
|
||||
my-work/cost.json 의 Answer 지시문에서 참고 자료 (사내 규정 전체) 을 빼고 답 규칙만 남겨 줘.
|
||||
사용자 메시지에는 질문과, FAQ 는 id · title · answer 만, 접수 건은 ticket_id · title · status · note · error 만 보내 줘 (contact · assignee 는 빼고).
|
||||
답 길이 상한은 max_completion_tokens 200.
|
||||
Classify · Review 단계는 그대로 .
|
||||
기대 결과 cases/lab02.json 앞 세 케이스.
|
||||
```
|
||||
|
||||
**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`)
|
||||
|
||||
| 빈칸 | 답 | 왜 |
|
||||
| --- | --- | --- |
|
||||
| ① | 참고 자료 (사내 규정 전체) | `Answer` 지시문 끝에 붙은 규정 문서 여덟 개. 답은 FAQ · 접수 건으로만 하라고 돼 있어 쓰이지 않음 |
|
||||
| ② | `answer` | FAQ 항목에서 답 글자가 든 칸 |
|
||||
| ③ | `max_completion_tokens` | 출력 상한. `gpt-5.4-nano`는 `max_tokens`를 받지 않음 |
|
||||
| ④ | 그대로 | 한 번에 하나만 (예제 2에서 바꿈) |
|
||||
|
||||
정답: [reference/lab02-example1-lean.json](reference/lab02-example1-lean.json)
|
||||
|
||||
| 10/28 결과 (`cases/lab02.json`) | 고치기 전 (`answers/08`) | 고친 뒤 (`answers/09`) |
|
||||
| --- | --- | --- |
|
||||
| VPN · 보내는 양 | ✘ answer 입력 1,781 | ✔ 165 |
|
||||
| 접수 · 보내는 양 | ✘ 1,791 | ✔ 152 |
|
||||
| 비밀번호 · 품질 | ✔ | ✔ |
|
||||
| 분류는 작은 모델 (예제 2) | ✘ | ✘ (예제 2에서) |
|
||||
| 프린터 · 품질 | ✔ | ✔ |
|
||||
| 합계 | 2/5 | 4/5 |
|
||||
|
||||
- `diff`: 바뀐 곳은 `Answer`의 본문 하나 (3,491 → 822자).
|
||||
|
||||
## 예제 2 · 요청문 예
|
||||
|
||||
```text
|
||||
my-work/cost.json 의 Classify · Review 두 단계의 model 을 gpt-5.4-nano 로 바꿔 줘. Answer 는 gpt-4.1-mini 그대로 .
|
||||
Build result 의 단계 목록(steps) 의 model 도 같이 바꿔, 비용이 그 모델의 단가 로 계산되게.
|
||||
기대 결과 cases/lab02.json 전체, cases/lab01.json.
|
||||
```
|
||||
|
||||
**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`)
|
||||
|
||||
| 빈칸 | 답 | 왜 |
|
||||
| --- | --- | --- |
|
||||
| ① | `Classify` | 세 낱말 중 하나를 고르는 쉬운 단계 |
|
||||
| ② | `gpt-5.4-nano` | `/prices`의 작은 모델 |
|
||||
| ③ | `gpt-4.1-mini` 그대로 | 사실을 틀리면 안 되는 단계. 바꿔도 되는지는 예제 3에서 재고 정함 (74일차 2.0.0은 golden을 통과했지만 "비용을 본 뒤"로 미룸) |
|
||||
| ④ | 단가 | `/prices`에서 읽은 그 모델의 input · output |
|
||||
|
||||
정답: [reference/lab02-example2-models.json](reference/lab02-example2-models.json)
|
||||
|
||||
| 10/28 결과 (`answers/10`) | |
|
||||
| --- | --- |
|
||||
| `cases/lab02.json` | **5/5** · VPN $0.000278 (예제 1 $0.000346) |
|
||||
| `cases/lab01.json` | 3/3 |
|
||||
|
||||
- 줄어든 폭이 작은 까닭: 분류 · 검토는 입력이 적은 단계이고, nano의 출력 단가는 mini의 0.78배라 크게 싸지 않습니다. "작은 모델이면 몇 배 싸다"는 막연한 기대를 단가표로 확인하게 합니다.
|
||||
|
||||
## 예제 3 · 결과 (에이전트 없이)
|
||||
|
||||
| `answers/11` | 답 mini (내 것) | 답 nano (`nano-answer`) |
|
||||
| --- | --- | --- |
|
||||
| `lab02` 낱말 검사 | 5/5 | **5/5** |
|
||||
| `lab01-mix` 일곱 질문 평균 | $0.000233 | $0.000198 (약 15% 덜) |
|
||||
|
||||
답을 읽고 본 것 (`nano-answer`, `-v`):
|
||||
|
||||
| 질문 | 답에서 |
|
||||
| --- | --- |
|
||||
| T-1002 | "T-1002의 처리 상태는 `**처리 중**`이며, `**인증서 발급 승인 대기 중**`입니다 …" — 마크다운 기호 |
|
||||
| VPN | "… 접수 건은 ticket_id가 없어 상태를 확인할 수 없으며, 요청하신 티켓 기록은 찾지 못했습니다 …" — 묻지 않은 안쪽 말 |
|
||||
| 비밀번호 · 프린터 | 끝에 "접수 건은 찾지 못했습니다." — 군더더기 |
|
||||
|
||||
- 결정: 아끼는 돈(15%)보다 잃는 것(형식 · 군더더기)이 커서 **답은 mini로 둡니다.** 풀이 때 "그럼 테스트를 어떻게 바꾸면 이걸 잡나?" → `not_contains: ["**", "ticket_id"]` 같은 형식 검사 (Q 문서 ▶ 1단계).
|
||||
- mini 답에도 "현재 접수된 관련 티켓은 없습니다" 같은 군더더기가 있습니다 (`answers/10`). 둘을 나란히 읽고 무엇이 더 나쁜지 이야기하게 합니다.
|
||||
|
||||
## 과제 1 · 요청문 예
|
||||
|
||||
```text
|
||||
my-work/cost.json 의 Get ticket 뒤에 Rule classify(Code) 를 넣어 질문에 T-숫자 4개가 있으면 'ticket', FAQ 검색 결과가 있으면 ' faq ', 둘 다 아니면 'unknown'.
|
||||
Known?(If) 로 unknown 일 때만 Classify(nano) → Parse category → Answer, 아니면 바로 Answer.
|
||||
Review 단계는 빼고, 답은 Answer 의 글을 그대로 쓴다.
|
||||
Build result 는 지나간 LLM 단계만 세어 llm_calls 와 비용을 (지나가지 않은 노드는 isExecuted 로 확인).
|
||||
기대 결과 cases/lab02-challenge.json, cases/lab02.json, cases/lab01.json, cases/lab01-challenge.json.
|
||||
```
|
||||
|
||||
**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`)
|
||||
|
||||
| 빈칸 | 답 | 왜 |
|
||||
| --- | --- | --- |
|
||||
| ① | `faq` | FAQ 검색에 걸린 질문의 분류 |
|
||||
| ② | `Review` (검토) | 답 단계의 지시문이 이미 말투 · 길이를 정함 |
|
||||
| ③ | `Answer` | 이제 LLM 답을 만드는 유일한 단계 |
|
||||
| ④ | `isExecuted` | 지나가지 않은 `Classify`를 읽으면 오류 (AGENTS.md 5절) |
|
||||
|
||||
정답: [answers/75-lab02-challenge-one-call.json](answers/75-lab02-challenge-one-call.json)
|
||||
|
||||
| 10/28 결과 (`cases/lab02-challenge.json`) | 고치기 전 (예제 2 정답, `answers/12`) | 고친 뒤 (`answers/13`) |
|
||||
| --- | --- | --- |
|
||||
| FAQ 넷 | ✘ 3 calls · $0.000241 ~ 0.000297 | ✔ 1 call · $0.000138 ~ 0.000166 |
|
||||
| 접수 둘 | ✘ 3 calls · $0.000164 · 0.000238 | ✔ 1 call · $0.000108 · 0.000115 |
|
||||
| 규칙으로 모르는 질문 | ✘ 3 calls | ✔ 2 calls · $0.000097 (nano 분류 $0.0000254 + 답) |
|
||||
| 합계 | 0/7 | **7/7** |
|
||||
| `lab02` · `lab01` · `lab01-challenge` | | 5/5 · 3/3 · 4/4 (요청 3건 $0.000409) |
|
||||
|
||||
- 시간도 3초대 → 1초대 (호출이 줄어서). 72일차 "LLM 호출을 줄이면 빨라지고 싸진다"를 숫자로 다시 확인.
|
||||
|
||||
## 과제 2 · 요청문 예
|
||||
|
||||
```text
|
||||
my-work/cost.json 의 Has question? 뒤에 Get budget(HTTP GET http://data-api:8800/budget) 을, Get prices 뒤에 Prepare question(Code) 을 넣어 줘.
|
||||
질문이 Get budget 의 max_question_chars 보다 길면 앞 40% 와 뒤 60 % 만 남기고 가운데를 ' … ' 로 줄여 줘.
|
||||
Search FAQ 의 q, Rule classify, Answer 는 모두 원래 질문 대신 Prepare question 의 question 을 쓰게.
|
||||
결과에 trimmed(true/false) 와 question_chars (원래 글자 수) 를 더해 줘.
|
||||
기대 결과 cases/lab02-challenge2.json, cases/lab02-challenge.json.
|
||||
```
|
||||
|
||||
**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`)
|
||||
|
||||
| 빈칸 | 답 | 왜 |
|
||||
| --- | --- | --- |
|
||||
| ① | `max_question_chars` | `/budget`의 글자 수 상한 (사람이 정함, 기본 500) |
|
||||
| ② | `60` | 앞 40% + 뒤 60% |
|
||||
| ③ | `Prepare question` | 줄인 질문을 만드는 노드 |
|
||||
| ④ | `question_chars` | "짧은 질문은 그대로" 케이스가 보는 칸 |
|
||||
|
||||
정답: [answers/75-lab02-challenge2-long-question.json](answers/75-lab02-challenge2-long-question.json)
|
||||
|
||||
| 10/28 결과 (`cases/lab02-challenge2.json`) | 고치기 전 (과제 1 정답, `answers/14`) | 고친 뒤 (`answers/15`) |
|
||||
| --- | --- | --- |
|
||||
| 긴 로그 + 끝에 질문 · 프린터 (6,298자) | ✘ **$0.001196** · `trimmed` 없음 | ✔ **$0.000237** · "대기열" |
|
||||
| 앞에 질문 + 긴 로그 · VPN (6,103자) | ✘ $0.001099 | ✔ $0.000260 · "인증서" |
|
||||
| 짧은 질문은 그대로 | ✘ `trimmed` 없음 | ✔ `trimmed: false` · `question_chars: 24` |
|
||||
| 사람이 상한을 늘리면 자르지 않음 | ✘ `trimmed` 없음 | ✔ `trimmed: false` · $0.001196 |
|
||||
| 합계 | 0/4 | **4/4** |
|
||||
| `lab02-challenge` | | 7/7 |
|
||||
|
||||
- **수업 준비 때 정한 것**: 처음에는 긴 질문도 $0.0002 이하를 기대했는데, 500자로 줄여도 $0.00024 ~ 0.00026, 300자로 줄여도 $0.00021 ~ 0.00022였습니다. 답(출력)만으로 약 $0.0001이 들기 때문입니다. 그래서 상한을 500자로 두고 기대를 **$0.0003 이하**(줄이기 전의 약 4분의 1)로 정했습니다. 풀이 때 "입력을 아무리 줄여도 남는 비용은?" → 출력 · FAQ.
|
||||
- `cases/lab02-challenge2.json`의 `before` · `after`가 `/budget/reset`으로 상한을 500으로 되돌립니다.
|
||||
@@ -0,0 +1,153 @@
|
||||
# Lab 03 · 하루 예산, 사용자별 한도, 비용 보고서, 바꾸면 얼마
|
||||
|
||||
> **강사용 정답본** · 학생 저장소에 올리지 않습니다. 학생용 문제는 [LAB-03-Q-BUDGET-AND-LIMITS.md](LAB-03-Q-BUDGET-AND-LIMITS.md).
|
||||
> 요청문은 예시이며 CLI 에이전트로 보내 재지는 않았습니다. 결과 숫자는 정답 워크플로우를 학생 순서대로 2026-10-28에 1회씩 실행한 값입니다. 명령 · 출력 전부: [answers/LAB-TEST-RESULT.md](answers/LAB-TEST-RESULT.md).
|
||||
|
||||
## 예제 1 · 요청문 예
|
||||
|
||||
```text
|
||||
my-work/cost.json 의 Get ticket 뒤에 Within budget?(If) 을 넣어 Get budget 의 over 이 false 일 때만 Rule classify 로, 넘었으면 Over budget(Code) → Send over budget.
|
||||
Over budget 은 LLM 없이: 접수 건이 있으면 그 상태 · 메모, FAQ 가 있으면 첫 FAQ 의 answer 원문에 "(오늘 자동 답변 한도를 넘어 FAQ 원문으로 안내합니다.)" 를 붙여.
|
||||
결과는 route ' 한도 초과 ', llm_calls 0, cost_usd 0 , usage 의 토큰도 0.
|
||||
기대 결과 cases/lab03.json.
|
||||
```
|
||||
|
||||
**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`)
|
||||
|
||||
| 빈칸 | 답 | 왜 |
|
||||
| --- | --- | --- |
|
||||
| ① | `over` | `/budget`이 주는 true/false (`used_usd >= daily_usd`) |
|
||||
| ② | `answer` | FAQ 항목의 답 글자 |
|
||||
| ③ | `한도 초과` | AGENTS.md 3절 `route` (72일차 LLM이 멈췄을 때의 `간단 답변`과 다르게) |
|
||||
| ④ | `0` | LLM을 부르지 않음 |
|
||||
|
||||
정답: [reference/lab03-example1-budget.json](reference/lab03-example1-budget.json)
|
||||
|
||||
| 10/28 결과 (`cases/lab03.json`) | 고치기 전 (Lab 02 과제 2 정답, `answers/16`) | 고친 뒤 (`answers/17`) |
|
||||
| --- | --- | --- |
|
||||
| 한도를 넘으면 · FAQ 원문 | ✘ `자동 답변` · 1 call · $0.000154 | ✔ `한도 초과` · 0 calls · $0 · "인증서" |
|
||||
| 한도를 넘으면 · 접수 원문 | ✘ `자동 답변` · $0.000115 | ✔ `한도 초과` · "처리 중" |
|
||||
| 한도 안이면 평소대로 | ✔ | ✔ $0.000156 |
|
||||
| 합계 | 1/3 | **3/3** |
|
||||
|
||||
## 예제 2 · 요청문 예
|
||||
|
||||
```text
|
||||
my-work/cost.json 의 Get budget 바로 뒤(가장 앞)에 Recent requests(HTTP GET http://data-api:8800/usage/user/<user>?window= 60 ) 를 넣어 줘.
|
||||
Under user limit?(If) 로 count 가 Get budget 의 user_limit_per_min 보다 작으면 Get prices 로, 아니면 Too many(Respond, HTTP 429 ).
|
||||
Too many 의 본문은 {route: '잠시 후', answer, retry_after: 60, llm_calls: 0, cost_usd: 0}. 이 갈래는 FAQ · 접수 조회도 하지 않는다 .
|
||||
기대 결과 cases/lab03-limit.json, cases/lab03.json, cases/lab02-challenge.json.
|
||||
```
|
||||
|
||||
**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`)
|
||||
|
||||
| 빈칸 | 답 | 왜 |
|
||||
| --- | --- | --- |
|
||||
| ① | `60` | 1분 = 60초 (이동 창) |
|
||||
| ② | `user_limit_per_min` | `/budget`의 사용자별 1분 한도 |
|
||||
| ③ | `429` | Too Many Requests |
|
||||
| ④ | 하지 않는다 | 맨 앞에서 돌려보내 0원 · 0.0초 |
|
||||
|
||||
정답: [reference/lab03-example2-limit.json](reference/lab03-example2-limit.json)
|
||||
|
||||
| 10/28 결과 (`cases/lab03-limit.json`) | 고치기 전 (예제 1 정답, `answers/18`) | 고친 뒤 (`answers/19`) |
|
||||
| --- | --- | --- |
|
||||
| 이준호 1 · 2번째 | ✔ | ✔ |
|
||||
| 이준호 3번째 | ✘ 200 · 1.3초 · $0.000156 | ✔ **429 · 0.0초 · $0** |
|
||||
| 다른 사람 | ✔ | ✔ |
|
||||
| 합계 | 3/4 | **4/4** |
|
||||
| `lab03` · `lab02-challenge` | | 3/3 · 7/7 |
|
||||
|
||||
## 예제 3 · 결과 (에이전트 없이)
|
||||
|
||||
| `answers/20` | 예산 $0.0003 | `--reset` 뒤 ($1.0) |
|
||||
| --- | --- | --- |
|
||||
| 1 · 2 · 3번째 (VPN · 프린터 · 비밀번호) | `자동 답변` $0.000141 · 0.000152 · 0.000136 | `자동 답변` |
|
||||
| 4 · 5 · 6번째 (메일 · 접수 · VPN) | **`한도 초과` · 0 calls · $0** (0.0 ~ 0.1초) | `자동 답변` |
|
||||
| 쓴 돈 | $0.000430 (예산을 $0.00013, **43%** 넘음) | |
|
||||
|
||||
- 두 번째까지 $0.000293으로 예산 아래 → 세 번째도 답함 → 넘음. "예산은 넘은 뒤 멈춘다"를 숫자로.
|
||||
- `usage`의 route별에는 `자동 답변` 3줄만 — 한도 초과 세 건은 기록이 없습니다.
|
||||
- `budget --daily 0.0003` 직후의 사용액 $0.000934는 앞 단계 기록입니다 (테스트의 `before`가 비우고 시작). 학생 화면에서도 비슷하게 보이므로 풀이 때 짚습니다.
|
||||
- 풀이 질문: "넘지 않게 하려면?" → 남은 금액 < 한 건 최대 비용이면 멈춤. 한 건 최대 비용은 Lab 02 과제 2의 입력 상한(긴 질문 $0.00026) + 출력 상한으로 정해집니다.
|
||||
|
||||
## 과제 1 · 요청문 예
|
||||
|
||||
```text
|
||||
my-work/cost-report.json 에 새 워크플로우: 웹훅 my-cost-report 에서 Get summary(GET http://data-api:8800/usage/summary) → Get budget(GET http://data-api:8800/budget) → Make report(Code) → Send report.
|
||||
avg_usd = cost_usd ÷ requests (calls 아님). projected_month_usd = avg_usd × requests_per_day × workdays (기본 22 , requests_per_day 가 없으면 지금 요청 수).
|
||||
top_user 는 by_user 에서 cost_usd 가 가장 큰 사람. by_model 은 모델마다 cost_usd. 금액은 소수 6 자리에서 반올림 (반올림은 마지막에). LLM 은 쓰지 않는다.
|
||||
결과 칸은 AGENTS.md 3절 비용 보고서. 기대 결과 cases/lab03-challenge.json.
|
||||
```
|
||||
|
||||
**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`)
|
||||
|
||||
| 빈칸 | 답 | 왜 |
|
||||
| --- | --- | --- |
|
||||
| ① | `my-cost-report` | `cases/lab03-challenge.json`의 `webhook` |
|
||||
| ② | `requests` | 요청 수 (`request_id`가 다른 것). `calls`로 나누면 평균이 작아짐 |
|
||||
| ③ | `22` | AGENTS.md 3절 `workdays` 기본값 |
|
||||
| ④ | `6` | AGENTS.md 3절 보고서 반올림 자리 |
|
||||
|
||||
정답: [answers/75-lab03-challenge-report.json](answers/75-lab03-challenge-report.json)
|
||||
|
||||
손 계산 (`before`의 네 줄):
|
||||
|
||||
| | 값 |
|
||||
| --- | --- |
|
||||
| 요청 · 호출 | r1 · r2 · r3 → 3 · 4줄 |
|
||||
| 비용 합 | 0.00056 + 0.000045 + 0.00112 + 0.000264 = **0.001989** |
|
||||
| 평균 | 0.001989 ÷ 3 = 0.000663 |
|
||||
| 100건 × 22일 | 0.000663 × 2,200 = **1.4586** |
|
||||
| 사람별 | 이준호 0.000869 · 박서연 0.00112 → **박서연** |
|
||||
| 남은 예산 | 1.0 − 0.001989 = 0.998011 |
|
||||
|
||||
| 10/28 결과 | 고치기 전 (`answers/21`) | 고친 뒤 (`answers/22`) |
|
||||
| --- | --- | --- |
|
||||
| `cases/lab03-challenge.json` | 0/5 (404, 웹훅 없음) | **5/5** |
|
||||
| 실제 사용량 (`lab02-challenge` 일곱 건) · `requests_per_day` 1000 | | 평균 $0.000127 · **한 달 $2.80** · top 이준호 |
|
||||
|
||||
- 이 기록의 "고치기 전"은 수업 준비 때 미리 올려 둔 `my-cost-report`를 `off`로 내린 뒤 잰 것입니다. 학생은 처음이라 그대로 404.
|
||||
- 한 달 $2.80은 Lab 01 예제 3의 어림 $20.8의 약 7분의 1. 오늘 줄인 것의 결과를 한 숫자로 보여 줍니다.
|
||||
|
||||
## 과제 2 · 요청문 예
|
||||
|
||||
```text
|
||||
my-work/cost-report.json 의 Get budget 뒤에 Get prices(GET http://data-api:8800/prices) 를 넣어 줘.
|
||||
본문에 what_if_model 이 있으면 Make report 에서 by_model 의 prompt_tokens · completion_tokens 합을 그 모델의 단가(input · output) 로 다시 계산해
|
||||
what_if: {model, cost_usd, saving_usd, projected_month_usd} 를 더해 줘. saving_usd = 지금 cost_usd − what_if.cost_usd .
|
||||
단가표에 없는 모델이면 Known model?(If) 로 갈라 HTTP 400 {error: 'unknown model', model, known}.
|
||||
what_if_model 이 없으면 결과는 과제 1 과 같다 .
|
||||
기대 결과 cases/lab03-challenge2.json, cases/lab03-challenge.json.
|
||||
```
|
||||
|
||||
**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`)
|
||||
|
||||
| 빈칸 | 답 | 왜 |
|
||||
| --- | --- | --- |
|
||||
| ① | 단가(`input` · `output`) | `/prices`의 그 모델 값 |
|
||||
| ② | `what_if.cost_usd` | 새 모델로 다시 계산한 비용 |
|
||||
| ③ | `400` | 74일차 "없는 릴리스"와 같음 |
|
||||
| ④ | 같다 | 과제 1 테스트가 그대로 통과해야 함 |
|
||||
|
||||
정답: [answers/75-lab03-challenge2-what-if.json](answers/75-lab03-challenge2-what-if.json)
|
||||
|
||||
손 계산: 입력 합 1,000 + 100 + 2,000 + 500 = 3,600, 출력 합 100 + 20 + 200 + 40 = 360.
|
||||
|
||||
| what-if | 식 | 값 |
|
||||
| --- | --- | --- |
|
||||
| 모두 nano | (3,600 × 0.20 + 360 × 1.25) ÷ 10⁶ | **0.00117** · 아낌 0.000819 · 한 달 0.858 |
|
||||
| 모두 mini | (3,600 × 0.40 + 360 × 1.60) ÷ 10⁶ | 0.002016 · 아낌 **−0.000027** |
|
||||
|
||||
| 10/28 결과 (`cases/lab03-challenge2.json`) | 고치기 전 (과제 1 정답, `answers/23`) | 고친 뒤 (`answers/24`) |
|
||||
| --- | --- | --- |
|
||||
| 모두 nano였다면 | ✘ `what_if` 없음 | ✔ |
|
||||
| 원래 숫자는 그대로 | ✔ | ✔ |
|
||||
| 모두 mini였다면 · 음수 | ✘ | ✔ |
|
||||
| 없는 모델은 400 | ✘ 200 | ✔ 400 · `known` |
|
||||
| what_if 없이 | ✔ | ✔ |
|
||||
| 합계 | 2/5 | **5/5** |
|
||||
| `lab03-challenge` · `lab02-challenge` | | 5/5 · 7/7 |
|
||||
| 실제 사용량 일곱 건 · nano | | $0.000929 → $0.00061 (34% 덜) · 한 달 $2.92 → $1.92 |
|
||||
|
||||
- 풀이 때: what-if는 34% 덜 든다고 하지만 같은 토큰 수를 가정한 숫자입니다. Lab 02 예제 3에서 **실제로** 답 단계만 nano로 바꿔 잰 평균은 15% 덜 들었고 (다른 구성이라 바로 비교할 수는 없음) 답의 형식이 나빠졌습니다. "숫자로 해 볼 만한지 보고, 바꿀지는 게이트로" — 74일차와 잇습니다.
|
||||
@@ -0,0 +1,123 @@
|
||||
# 7교시 바이브 코딩 · `tcost` 정답본
|
||||
|
||||
> **강사용 정답본** · 학생 저장소에는 수업 뒤에 올립니다. 학생용은 [VIBE-REQ-TCOST.md](VIBE-REQ-TCOST.md)(요청서) → [VIBE-Q-TCOST.md](VIBE-Q-TCOST.md).
|
||||
> 강사가 직접 만든 본: [tcost/](tcost/README.md) (2026-10-28, Claude Code가 아래 SPEC · IMP-PLAN을 보고 직접 씀, 테스트 13개). CLI 에이전트에 아래 요청문을 보내 재지는 않았습니다. 강사 바이브 코딩 시간은 6절에 수업 때 적습니다.
|
||||
|
||||
## 1. `Doc/TODO.md` 예
|
||||
|
||||
```markdown
|
||||
# tcost TODO
|
||||
|
||||
## 만들고 싶은 것
|
||||
어느 폴더에서나 `tcost`로 LLM을 부르기 전에 글 · 워크플로우의 토큰과 비용을 계산하고,
|
||||
테스트 보고서의 실제 사용량을 모아 한 달을 예상하는 도구. 화면(TUI)도, 다른 프로그램용 결과도. OpenAI는 부르지 않음.
|
||||
|
||||
## 요청의 말 → 할 일
|
||||
| # | 요청의 말 | 할 일 | 꼭 · 나중 |
|
||||
| --- | --- | --- | --- |
|
||||
| ① | 글 파일의 토큰 · 모델마다 비용 | 토큰 수, 한 번 · 1,000번 | 꼭 |
|
||||
| ② | 출력도 | 출력 토큰을 가정해 더함 | 꼭 |
|
||||
| ③ | 단가는 파일로, 날짜 · 출처, 한 달 넘으면 알림 | 단가 파일 · 오래됨 알림 | 꼭 |
|
||||
| ④ | 워크플로우의 LLM 단계 | 노드 · 모델 · 고정 지시문 토큰 | 나중 |
|
||||
| ⑤ | 보고서 모으기 | 합 · 평균 · 모델별 · 사람별 · 시간대별 | 꼭 |
|
||||
| ⑥ | 한 달에 얼마 | 평균 × 하루 × 일하는 날 | 꼭 |
|
||||
| ⑦ | 두 모델 나란히 | 몇 % 싼지 | 나중 |
|
||||
| ⑧ | 화면 | 탭 셋 · 막대 | 꼭 |
|
||||
| ⑨ | 예산이면 하루 몇 번 | 예산 ÷ 한 번 비용 | 나중 |
|
||||
| ⑩ | 다른 프로그램 · 넘으면 실패 | JSON · 종료 코드 | 나중 |
|
||||
| ⑪ | 폴더 통째로 | 글 · 워크플로우 · 보고서로 나눔 | 나중 |
|
||||
| ⑫ | 없는 모델은 0원 아님 | 오류 · 목록 | 나중 |
|
||||
| 끝 | OpenAI를 부르지 않기 | 토큰은 이 컴퓨터에서, 테스트도 | 꼭 |
|
||||
|
||||
## 걱정되는 것
|
||||
- 내가 센 토큰과 OpenAI가 센 토큰이 다를 수 있다 → 보고서의 prompt_tokens와 비교
|
||||
- 평균의 분모 (요청 수 · 호출 수)
|
||||
- 보고서에 usage가 없는 결과 (400 · 429 · 요약)
|
||||
- 오늘 날짜에 따라 달라지는 테스트
|
||||
```
|
||||
|
||||
## 2. `Doc/SPEC.md` 요점
|
||||
|
||||
VIBE-Q의 요구 표(R1~R14)와 같습니다. 학생 SPEC과 비교할 때 자주 빠지는 것:
|
||||
|
||||
| 자주 빠지는 것 | 왜 필요한가 |
|
||||
| --- | --- |
|
||||
| 비용 식 · 반올림을 데이터 API 계약과 같게 (소수 8자리) | 보고서의 `cost_usd`와 나란히 놓으려면 |
|
||||
| 평균은 요청 수로 | `calls`로 나누면 한 달이 작게 나옴 (Lab 03 과제 1) |
|
||||
| `usage`가 없는 결과는 **건너뛰고 수를 알림** | 건너뛴 것을 0원으로 세면 평균이 작아짐 |
|
||||
| "오늘"을 함수에 넘김 | 날짜 알림 테스트가 날마다 같게 |
|
||||
| 고정 지시문만 센다고 밝힘 | 실제 입력(질문 · FAQ 포함)보다 작게 나옴 |
|
||||
| 단가 파일을 패키지에 함께 | `uv tool install` 뒤 다른 폴더에서 단가를 못 찾음 |
|
||||
|
||||
## 3. `Doc/IMP-PLAN.md`
|
||||
|
||||
[tcost/Doc/IMP-PLAN.md](tcost/Doc/IMP-PLAN.md) — 엔진 1단계(R1 · R2 · R3 · R13) → 엔진 2단계(R5 · R6) → TUI 1단계(R8) → 설치(R14) → 엔진 3단계(R4 · R7 · R12) → 엔진 4단계(R9 · R10 · R11).
|
||||
|
||||
## 4. 단계별 요청문 예
|
||||
|
||||
### 엔진 1단계
|
||||
|
||||
```text
|
||||
Doc/SPEC.md 와 Doc/IMP-PLAN.md 를 읽고 "엔진 1단계"만 만들어 줘. uv 프로젝트, 명령 이름 tcost, 코드와 화면 글자는 영어.
|
||||
- prices.json: {"checked": "2026-10-28", "source": ..., "models": {"gpt-4.1-mini": {"input": 0.40, "output": 1.60}, "gpt-5.4-nano": {"input": 0.20, "output": 1.25}}}
|
||||
패키지 안에 두고 --prices 로 바꿀 수 있게. cost(model, in, out) = (in × input + out × output) / 1,000,000, 소수 8자리.
|
||||
warning(today): checked 가 30일 넘었거나 없으면 알림 글.
|
||||
- tokens.count(text): tiktoken o200k_base. API 는 부르지 않는다.
|
||||
- tcost count <파일> [--out N] [--model M]: 파일마다 토큰과 모델마다 한 번 · 1,000번 비용 표.
|
||||
- tests: 1,000토큰 mini → 0.0004, --out 100 → 0.00056, checked + 45일 → 알림. 네트워크 없이.
|
||||
끝나면 IMP-PLAN 체크를 표시하고 확인 명령을 알려 줘.
|
||||
```
|
||||
|
||||
### 엔진 2단계
|
||||
|
||||
```text
|
||||
엔진 2단계만: usage.collect(paths) 가 flow.py 테스트 보고서(runs/*.json, results[].request · response)를 읽어
|
||||
response.usage.by_step 이 있는 결과만 요청 하나로 세고, 단계마다 호출 하나로 토큰 · cost_usd 를 모델별 · 사람별(request.user) · 시간대별(파일 이름 -YYYYMMDD-HHMMSS 의 HH) 로 더해 줘.
|
||||
usage 가 없는 결과는 skipped 로 센다. avg = 비용 ÷ 요청 수, month(per_day, days) = avg × per_day × days (소수 6자리).
|
||||
tcost usage <폴더> [--per-day N --days 22]. tests: 결과 셋 중 400 하나 → 요청 2 · skipped 1, 사람별 합, 한 달.
|
||||
```
|
||||
|
||||
### TUI 1단계
|
||||
|
||||
```text
|
||||
TUI 1단계만: tcost <폴더> [폴더 …] 이면 Textual 화면. TabbedContent 탭 셋:
|
||||
Prompts(글 파일 · 워크플로우 LLM 단계마다 토큰과 모델마다 1,000번 비용 DataTable), Usage(보고서 합 · 모델별 · 사람별 표, 시간대별 막대 ▁▂▃▄▅▆▇█),
|
||||
Compare(토큰이 많은 지시문 열 개, 모델마다 출력 100 가정 한 번 비용). [r] 다시 읽기, [q] 끝.
|
||||
엔진은 고치지 말고 불러 쓰기만. pytest run_test 로 세 탭이 채워지는지.
|
||||
```
|
||||
|
||||
## 5. 결과 확인 (강사 본, 2026-10-28)
|
||||
|
||||
| 요구 | 확인 | 결과 |
|
||||
| --- | --- | --- |
|
||||
| R1 · R2 | pytest (1,000토큰 · `--out 100`) | $0.0004 · $0.00056 |
|
||||
| R3 | pytest (확인 + 45일, 날짜 없는 단가표) | 알림 둘 |
|
||||
| R4 | `tcost workflow reference/baseline-cost.json` | `Answer` 지시문 **1,670토큰** (n8n이 잰 답 단계 입력 1,781 중 고정 몫, 나머지 약 110이 질문 · FAQ · 접수 건) |
|
||||
| R4 | `tcost workflow answers/75-lab02-challenge2-long-question.json` | `Answer` 75토큰 · 출력 상한 200, `Classify` nano |
|
||||
| R5 · R6 | `tcost usage answers --per-day 1000` | 보고서 23 · 요청 82 · $0.038746 · skipped 26 (요약 · 400 · 429 · 보고서 케이스) |
|
||||
| R7 | 긴 프린터 질문 `compare --out 60` | mini $0.001141 · nano $0.000598 (48% cheaper) |
|
||||
| R8 | pytest `run_test`, `tcost reference runs` | 세 탭, 지시문 19줄 · 보고서 51개 |
|
||||
| R9 | 긴 프린터 질문 `budget 1.0 --out 60` | mini 876번 · nano 1,673번 |
|
||||
| R10 | 긴 프린터 질문 `count --out 60 --max-usd 0.0003` | 종료 코드 1 |
|
||||
| R11 | `tcost scan .` (75일차 폴더) | `cases/*.json` · `.md` 문서는 skipped |
|
||||
| R12 | `tcost count x --model gpt-4.1-nano` | `unknown model 'gpt-4.1-nano' (known: gpt-4.1-mini, gpt-5.4-nano)`, 종료 코드 2 |
|
||||
| R13 | 테스트 13개 | 네트워크 · 키 없이 |
|
||||
| R14 | `uv tool install --editable .`, `/tmp`에서 `tcost --help` | 됨 |
|
||||
|
||||
부르기 전 계산과 실제의 비교 (긴 프린터 질문 6,298자):
|
||||
|
||||
| | 입력 토큰 | 비용 (mini) |
|
||||
| --- | --- | --- |
|
||||
| `tcost count --out 60` (질문 글만) | 2,613 | $0.001141 |
|
||||
| n8n 실제 (Lab 02 과제 1 정답, `answers/14`) | (질문 + 지시문 + FAQ) | $0.001196 |
|
||||
|
||||
- 풀이 거리: 미리 계산은 질문 글만 셌고, 실제는 지시문 · FAQ가 더해져 조금 더 큽니다. "부르기 전 계산은 **하한**에 가깝다"는 것, 그래서 `tcost workflow`(고정 지시문)와 `count`(질문)를 **더해** 어림해야 한다는 것을 짚습니다.
|
||||
- 처음 만든 본은 폴더의 `.md`를 모두 지시문으로 셌습니다. 75일차 폴더를 열면 실습 문서 · 결과 기록까지 "지시문"으로 나와 쓸 수 없어서, 글 파일을 `.txt` · `.prompt`로 좁히고 화면은 폴더 여럿(`tcost reference runs`)을 받게 바꿨습니다. **요청서의 ⑪ "알아서 나눠"가 무엇을 뜻하는지는 직접 써 봐야 드러난다**는 예입니다.
|
||||
|
||||
## 6. 강사 바이브 코딩 (8교시, 수업 때 적음)
|
||||
|
||||
| 주제 | 요청문 요지 | 걸린 시간 · 결과 |
|
||||
| --- | --- | --- |
|
||||
| 실제와 맞춰 보기 | "보고서의 질문 글을 세어 prompt_tokens와 차이를 % 로" | |
|
||||
| 캐시 입력 단가 | "cached_tokens 가 있으면 cached input 단가로" | |
|
||||
| 비용 관문 붙이기 | "tcost workflow --max-usd 를 tver save 앞에" | |
|
||||
@@ -0,0 +1,26 @@
|
||||
$ uv run tools/flow.py budget --reset
|
||||
✔ budget back to data/budget.json
|
||||
budget $1.0 / used $0.000000 · remaining $1.000000 · over False · per user 20/min · question 500 chars
|
||||
|
||||
$ uv run tools/flow.py usage --clear
|
||||
✔ usage cleared: {"cleared": 0}
|
||||
|
||||
$ uv run tools/flow.py fault all ok
|
||||
✔ fault switch: {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "slow_seconds": 8.0}
|
||||
|
||||
$ uv run tools/flow.py push reference/baseline-cost.json
|
||||
✔ pushed '75 IT helpdesk baseline (provided)' (id wdb6a99a8f13c6a1)
|
||||
POST http://localhost:5678/webhook/base-cost
|
||||
|
||||
$ uv run tools/flow.py doctor
|
||||
! n8n install folder /Users/mcchae/day65-n8n
|
||||
→ day 65 SETUP-N8N 5단계 (get.n8n.io 설치). 다른 곳에 설치했다면 Last3Mon/65/n8n/.env 에 N8N_HOME=<폴더>
|
||||
✔ n8n container day65-n8n-n8n-1
|
||||
✔ n8n http://localhost:5678
|
||||
✔ data API http://localhost:8800
|
||||
✔ prices /prices · budget /budget (day 75 data API)
|
||||
✔ n8n → http://data-api:8800
|
||||
✔ credential 'OpenAI (.env)' (openAiCourse0001)
|
||||
✔ node catalog (for check)
|
||||
✔ CLI coding agent: codex, claude, agy
|
||||
ready
|
||||
@@ -0,0 +1,70 @@
|
||||
$ docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}'
|
||||
NAME CPU % MEM USAGE / LIMIT
|
||||
day67-data-data-api-1 0.02% 20.3MiB / 7.818GiB
|
||||
day65-n8n-runners-1 0.21% 100.5MiB / 7.818GiB
|
||||
day65-n8n-n8n-1 0.27% 642.6MiB / 7.818GiB
|
||||
|
||||
$ uv run tools/flow.py call base-cost requests/faq-vpn.json
|
||||
HTTP 200 · 4.3s
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속이 자주 끊길 경우, VPN 클라이언트를 최신 버전으로 업데이트하고 인증서 만료 여부를 IT 포털에서 확인하시기 바랍니다. 인증서는 만료 30일 전부터 재발급 신청이 가능합니다. 추가 도움이 필요하시면 IT 문의로 연락해 주십시오.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1999,
|
||||
"completion_tokens": 150,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 78,
|
||||
"completion_tokens": 6
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1781,
|
||||
"completion_tokens": 78
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 140,
|
||||
"completion_tokens": 66
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$ uv run tools/flow.py call base-cost requests/ticket-own.json
|
||||
HTTP 200 · 3.3s
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "티켓 T-1002는 현재 인증서 발급 승인 대기 중이며, 완료 예정일은 10월 20일입니다. 추가 문의 사항이 있으시면 연락 주시기 바랍니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1973,
|
||||
"completion_tokens": 97,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 71,
|
||||
"completion_tokens": 6
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1791,
|
||||
"completion_tokens": 49
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 111,
|
||||
"completion_tokens": 42
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$ curl -s localhost:8800/prices
|
||||
{"currency": "USD", "unit": "per 1M tokens", "note": "OpenAI 모델 문서의 공개 단가 (2026-10-28 확인). 수업 전에 가격표를 확인해 바꾸세요.", "models": {"gpt-4.1-mini": {"input": 0.4, "output": 1.6}, "gpt-5.4-nano": {"input": 0.2, "output": 1.25}}}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"cases": "lab01.json",
|
||||
"webhook": "my-cost",
|
||||
"passed": 3,
|
||||
"total": 3,
|
||||
"results": [
|
||||
{
|
||||
"case": "VPN · 비용과 단계별 사용량",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 5.1,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속이 자주 끊길 경우, VPN 클라이언트를 최신 버전으로 업데이트하고 인증서 만료 여부를 IT 포털에서 확인하시기 바랍니다. 인증서는 만료 30일 전부터 재발급 신청이 가능합니다. 추가 도움이 필요하시면 IT 문의로 연락해 주십시오.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1999,
|
||||
"completion_tokens": 150,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 78,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.08e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1781,
|
||||
"completion_tokens": 78,
|
||||
"cost_usd": 0.0008372
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 140,
|
||||
"completion_tokens": 66,
|
||||
"cost_usd": 0.0001616
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0010396
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "접수 현황 · 비용",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 3.3,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "T-1002 처리 상태 알려 주세요",
|
||||
"user": "박서연"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "티켓 T-1002는 현재 인증서 발급 승인 대기 중이며, 10월 20일에 완료될 예정입니다. 추가 문의 사항이 있으시면 연락해 주시기 바랍니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1973,
|
||||
"completion_tokens": 98,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 71,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 3.8e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1791,
|
||||
"completion_tokens": 49,
|
||||
"cost_usd": 0.0007948
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 111,
|
||||
"completion_tokens": 43,
|
||||
"cost_usd": 0.0001132
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.000946
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "빈 질문은 LLM 없이",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 0.0,
|
||||
"status": 400,
|
||||
"request": {
|
||||
"question": "",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"error": "question is required"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
$ uv run tools/flow.py push reference/lab01-example2-cost.json
|
||||
! unpublished '75 IT helpdesk + user limit (reference)' (w885dc4a9b362094): it used the same webhook path
|
||||
✔ pushed '75 IT helpdesk + cost (reference)' (id w7aad5ed3acdb113)
|
||||
POST http://localhost:5678/webhook/my-cost
|
||||
|
||||
$ uv run tools/flow.py test cases/lab01.json -v
|
||||
POST http://localhost:5678/webhook/my-cost · 3 case(s) × 1
|
||||
✔ VPN · 비용과 단계별 사용량 · 5.1s · 자동 답변 · 3 calls · $0.001040
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속이 자주 끊길 경우, VPN 클라이언트를 최신 버전으로 업데이트하고 인증서 만료 여부를 IT 포털에서 확인하시기 바랍니다. 인증서는 만료 30일 전부터 재발급 신청이 가능합니다. 추가 도움이 필요하시면 IT 문의로 연락해 주십시오.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1999,
|
||||
"completion_tokens": 150,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 78,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.08e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1781,
|
||||
"completion_tokens": 78,
|
||||
"cost_usd": 0.0008372
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 140,
|
||||
"completion_tokens": 66,
|
||||
"cost_usd": 0.0001616
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0010396
|
||||
}
|
||||
✔ 접수 현황 · 비용 · 3.3s · 자동 답변 · 3 calls · $0.000946
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "T-1002 처리 상태 알려 주세요",
|
||||
"user": "박서연"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "티켓 T-1002는 현재 인증서 발급 승인 대기 중이며, 10월 20일에 완료될 예정입니다. 추가 문의 사항이 있으시면 연락해 주시기 바랍니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1973,
|
||||
"completion_tokens": 98,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 71,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 3.8e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1791,
|
||||
"completion_tokens": 49,
|
||||
"cost_usd": 0.0007948
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 111,
|
||||
"completion_tokens": 43,
|
||||
"cost_usd": 0.0001132
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.000946
|
||||
}
|
||||
✔ 빈 질문은 LLM 없이 · 0.0s
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 400
|
||||
{
|
||||
"error": "question is required"
|
||||
}
|
||||
Passed 3/3
|
||||
report: runs/lab01-20261028-210102.json
|
||||
@@ -0,0 +1,324 @@
|
||||
{
|
||||
"cases": "lab01-mix.json",
|
||||
"webhook": "my-cost",
|
||||
"passed": 7,
|
||||
"total": 7,
|
||||
"results": [
|
||||
{
|
||||
"case": "FAQ · VPN",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 3.2,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속이 자주 끊길 경우 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 IT 포털에서 확인하시기 바랍니다. 인증서는 만료 30일 전부터 재발급 신청이 가능합니다. 추가 도움이 필요하시면 IT 문의로 연락해 주십시오.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1999,
|
||||
"completion_tokens": 150,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 78,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.08e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1781,
|
||||
"completion_tokens": 78,
|
||||
"cost_usd": 0.0008372
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 140,
|
||||
"completion_tokens": 66,
|
||||
"cost_usd": 0.0001616
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0010396
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "FAQ · 프린터",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 3.9,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "최유진"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 확인하시고, 인쇄 대기열을 비운 후 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 IT 담당자에게 문의해 주시기 바랍니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1967,
|
||||
"completion_tokens": 108,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 72,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 3.84e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1779,
|
||||
"completion_tokens": 54,
|
||||
"cost_usd": 0.000798
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 116,
|
||||
"completion_tokens": 48,
|
||||
"cost_usd": 0.0001232
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0009596
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "FAQ · 비밀번호",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 3.5,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
|
||||
"user": "한지훈"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 추가 도움이 필요하시면 IT 문의로 연락해 주시기 바랍니다.",
|
||||
"sources": [
|
||||
"FAQ-001"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1978,
|
||||
"completion_tokens": 101,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 81,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.2e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1789,
|
||||
"completion_tokens": 46,
|
||||
"cost_usd": 0.0007892
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 108,
|
||||
"completion_tokens": 49,
|
||||
"cost_usd": 0.0001216
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0009528
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "FAQ · 메일",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 3.4,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "메일함 용량이 꽉 찼대요",
|
||||
"user": "김민지"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "메일함 용량이 가득 찼을 경우, 오래된 메일이나 큰 첨부 파일을 삭제하거나 보관함으로 이동하여 용량을 확보하시기 바랍니다. 추가 도움이 필요하시면 IT 담당자에게 문의해 주십시오.",
|
||||
"sources": [
|
||||
"FAQ-005"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1969,
|
||||
"completion_tokens": 111,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 74,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 3.92e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1780,
|
||||
"completion_tokens": 52,
|
||||
"cost_usd": 0.0007952
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 115,
|
||||
"completion_tokens": 53,
|
||||
"cost_usd": 0.0001308
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0009652
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "접수 · T-1002",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 3.0,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "T-1002 처리 상태 알려 주세요",
|
||||
"user": "박서연"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "티켓 T-1002는 현재 인증서 발급 승인 대기 중이며, 10월 20일에 완료될 예정입니다. 추가 문의 사항이 있으시면 연락해 주시기 바랍니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1973,
|
||||
"completion_tokens": 98,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 71,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 3.8e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1791,
|
||||
"completion_tokens": 49,
|
||||
"cost_usd": 0.0007948
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 111,
|
||||
"completion_tokens": 43,
|
||||
"cost_usd": 0.0001132
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.000946
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "접수 · T-1001",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 3.2,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "T-1001 어떻게 됐나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "티켓 T-1001은 처리 완료되었으며, 새 배터리로 교체되었습니다. 추가 문의 사항이 있으시면 연락해 주시기 바랍니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1944,
|
||||
"completion_tokens": 70,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 72,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 3.84e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1781,
|
||||
"completion_tokens": 29,
|
||||
"cost_usd": 0.0007588
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 91,
|
||||
"completion_tokens": 35,
|
||||
"cost_usd": 9.24e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0008896
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "규칙에 없는 질문",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 2.6,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "회의실 화면 공유가 안 되는데 누구에게 물어보나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "회의실 화면 공유 문제는 IT 문의로 연락 주시면 도움을 드리겠습니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1883,
|
||||
"completion_tokens": 48,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 78,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.08e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1719,
|
||||
"completion_tokens": 24,
|
||||
"cost_usd": 0.000726
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 86,
|
||||
"completion_tokens": 18,
|
||||
"cost_usd": 6.32e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.00083
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
$ uv run tools/flow.py test cases/lab01-mix.json
|
||||
POST http://localhost:5678/webhook/my-cost · 7 case(s) × 1
|
||||
✔ FAQ · VPN · 3.2s · 자동 답변 · 3 calls · $0.001040
|
||||
✔ FAQ · 프린터 · 3.9s · 자동 답변 · 3 calls · $0.000960
|
||||
✔ FAQ · 비밀번호 · 3.5s · 자동 답변 · 3 calls · $0.000953
|
||||
✔ FAQ · 메일 · 3.4s · 자동 답변 · 3 calls · $0.000965
|
||||
✔ 접수 · T-1002 · 3.0s · 자동 답변 · 3 calls · $0.000946
|
||||
✔ 접수 · T-1001 · 3.2s · 자동 답변 · 3 calls · $0.000890
|
||||
✔ 규칙에 없는 질문 · 2.6s · 자동 답변 · 3 calls · $0.000830
|
||||
Passed 7/7
|
||||
report: runs/lab01-mix-20261028-210125.json
|
||||
@@ -0,0 +1,169 @@
|
||||
{
|
||||
"cases": "lab01-challenge.json",
|
||||
"webhook": "my-cost",
|
||||
"passed": 3,
|
||||
"total": 4,
|
||||
"results": [
|
||||
{
|
||||
"case": "기록 1 · VPN",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 3.5,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속이 자주 끊길 경우, VPN 클라이언트를 최신 버전으로 업데이트하고 인증서 만료 여부를 IT 포털에서 확인하시기 바랍니다. 인증서는 만료 30일 전부터 재발급 신청이 가능합니다. 추가 도움이 필요하시면 IT 문의로 연락해 주십시오.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1999,
|
||||
"completion_tokens": 150,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 78,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.08e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1781,
|
||||
"completion_tokens": 78,
|
||||
"cost_usd": 0.0008372
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 140,
|
||||
"completion_tokens": 66,
|
||||
"cost_usd": 0.0001616
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0010396
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "기록 2 · 접수",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 3.0,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "T-1002 처리 상태 알려 주세요",
|
||||
"user": "박서연"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "티켓 T-1002는 현재 인증서 발급 승인 대기 중이며, 10월 20일에 완료될 예정입니다. 추가 문의 사항이 있으시면 연락해 주시기 바랍니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1973,
|
||||
"completion_tokens": 98,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 71,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 3.8e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1791,
|
||||
"completion_tokens": 49,
|
||||
"cost_usd": 0.0007948
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 111,
|
||||
"completion_tokens": 43,
|
||||
"cost_usd": 0.0001132
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.000946
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "기록 3 · 다른 사람",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 3.4,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
|
||||
"user": "한지훈"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 추가 도움이 필요하시면 IT 문의로 연락해 주시기 바랍니다.",
|
||||
"sources": [
|
||||
"FAQ-001"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1979,
|
||||
"completion_tokens": 102,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 81,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.2e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1789,
|
||||
"completion_tokens": 47,
|
||||
"cost_usd": 0.0007908
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 109,
|
||||
"completion_tokens": 49,
|
||||
"cost_usd": 0.000122
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0009548
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "사용량 요약 · 요청 3건",
|
||||
"webhook": "http://localhost:8800/usage/summary",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"requests = 0 (expected 3)",
|
||||
"cost_usd = 0.0 (expected 1e-06..0.05)",
|
||||
"by_user has none of ['이준호', '박서연', '한지훈']",
|
||||
"by_model has none of ['gpt-4.1-mini']"
|
||||
],
|
||||
"seconds": 0.0,
|
||||
"status": 200,
|
||||
"request": {},
|
||||
"response": {
|
||||
"requests": 0,
|
||||
"calls": 0,
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"cost_usd": 0.0,
|
||||
"by_model": {},
|
||||
"by_user": {},
|
||||
"by_route": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
$ uv run tools/flow.py test cases/lab01-challenge.json -v
|
||||
✔ before: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||
→ request POST http://localhost:8800/usage-clear
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"cleared": 0
|
||||
}
|
||||
POST http://localhost:5678/webhook/my-cost · 4 case(s) × 1
|
||||
✔ 기록 1 · VPN · 3.5s · 자동 답변 · 3 calls · $0.001040
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속이 자주 끊길 경우, VPN 클라이언트를 최신 버전으로 업데이트하고 인증서 만료 여부를 IT 포털에서 확인하시기 바랍니다. 인증서는 만료 30일 전부터 재발급 신청이 가능합니다. 추가 도움이 필요하시면 IT 문의로 연락해 주십시오.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1999,
|
||||
"completion_tokens": 150,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 78,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.08e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1781,
|
||||
"completion_tokens": 78,
|
||||
"cost_usd": 0.0008372
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 140,
|
||||
"completion_tokens": 66,
|
||||
"cost_usd": 0.0001616
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0010396
|
||||
}
|
||||
✔ 기록 2 · 접수 · 3.0s · 자동 답변 · 3 calls · $0.000946
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "T-1002 처리 상태 알려 주세요",
|
||||
"user": "박서연"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "티켓 T-1002는 현재 인증서 발급 승인 대기 중이며, 10월 20일에 완료될 예정입니다. 추가 문의 사항이 있으시면 연락해 주시기 바랍니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1973,
|
||||
"completion_tokens": 98,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 71,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 3.8e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1791,
|
||||
"completion_tokens": 49,
|
||||
"cost_usd": 0.0007948
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 111,
|
||||
"completion_tokens": 43,
|
||||
"cost_usd": 0.0001132
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.000946
|
||||
}
|
||||
✔ 기록 3 · 다른 사람 · 3.4s · 자동 답변 · 3 calls · $0.000955
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
|
||||
"user": "한지훈"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 추가 도움이 필요하시면 IT 문의로 연락해 주시기 바랍니다.",
|
||||
"sources": [
|
||||
"FAQ-001"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1979,
|
||||
"completion_tokens": 102,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 81,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.2e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1789,
|
||||
"completion_tokens": 47,
|
||||
"cost_usd": 0.0007908
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 109,
|
||||
"completion_tokens": 49,
|
||||
"cost_usd": 0.000122
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0009548
|
||||
}
|
||||
✘ 사용량 요약 · 요청 3건 · 0.0s · $0.000000 · requests = 0 (expected 3); cost_usd = 0.0 (expected 1e-06..0.05); by_user has none of ['이준호', '박서연', '한지훈']; by_model has none of ['gpt-4.1-mini']
|
||||
→ request POST http://localhost:8800/usage/summary
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"requests": 0,
|
||||
"calls": 0,
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"cost_usd": 0.0,
|
||||
"by_model": {},
|
||||
"by_user": {},
|
||||
"by_route": {}
|
||||
}
|
||||
Passed 3/4
|
||||
report: runs/lab01-challenge-20261028-210135.json
|
||||
@@ -0,0 +1,197 @@
|
||||
{
|
||||
"cases": "lab01-challenge.json",
|
||||
"webhook": "my-cost",
|
||||
"passed": 4,
|
||||
"total": 4,
|
||||
"results": [
|
||||
{
|
||||
"case": "기록 1 · VPN",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 5.0,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속이 자주 끊길 경우, VPN 클라이언트를 최신 버전으로 업데이트하고 인증서 만료 여부를 IT 포털에서 확인하시기 바랍니다. 인증서는 만료 30일 전부터 재발급 신청이 가능합니다. 추가 도움이 필요하시면 IT 문의로 연락해 주십시오.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1999,
|
||||
"completion_tokens": 150,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 78,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.08e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1781,
|
||||
"completion_tokens": 78,
|
||||
"cost_usd": 0.0008372
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 140,
|
||||
"completion_tokens": 66,
|
||||
"cost_usd": 0.0001616
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0010396
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "기록 2 · 접수",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 3.5,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "T-1002 처리 상태 알려 주세요",
|
||||
"user": "박서연"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "티켓 T-1002는 현재 '처리 중' 상태이며, 인증서 발급 승인 대기 중입니다. 완료 예정일은 10월 20일입니다. 추가 문의 사항이 있으시면 연락해 주시기 바랍니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1973,
|
||||
"completion_tokens": 106,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 71,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 3.8e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1791,
|
||||
"completion_tokens": 49,
|
||||
"cost_usd": 0.0007948
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 111,
|
||||
"completion_tokens": 51,
|
||||
"cost_usd": 0.000126
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0009588
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "기록 3 · 다른 사람",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 3.4,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
|
||||
"user": "한지훈"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 추가 도움이 필요하시면 IT 문의로 연락해 주시기 바랍니다.",
|
||||
"sources": [
|
||||
"FAQ-001"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1978,
|
||||
"completion_tokens": 101,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 81,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.2e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1789,
|
||||
"completion_tokens": 46,
|
||||
"cost_usd": 0.0007892
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 108,
|
||||
"completion_tokens": 49,
|
||||
"cost_usd": 0.0001216
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0009528
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "사용량 요약 · 요청 3건",
|
||||
"webhook": "http://localhost:8800/usage/summary",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 0.0,
|
||||
"status": 200,
|
||||
"request": {},
|
||||
"response": {
|
||||
"requests": 3,
|
||||
"calls": 9,
|
||||
"prompt_tokens": 5950,
|
||||
"completion_tokens": 357,
|
||||
"cost_usd": 0.0029512,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": {
|
||||
"calls": 9,
|
||||
"prompt_tokens": 5950,
|
||||
"completion_tokens": 357,
|
||||
"cost_usd": 0.0029512
|
||||
}
|
||||
},
|
||||
"by_user": {
|
||||
"이준호": {
|
||||
"calls": 3,
|
||||
"prompt_tokens": 1999,
|
||||
"completion_tokens": 150,
|
||||
"cost_usd": 0.0010396
|
||||
},
|
||||
"박서연": {
|
||||
"calls": 3,
|
||||
"prompt_tokens": 1973,
|
||||
"completion_tokens": 106,
|
||||
"cost_usd": 0.0009588
|
||||
},
|
||||
"한지훈": {
|
||||
"calls": 3,
|
||||
"prompt_tokens": 1978,
|
||||
"completion_tokens": 101,
|
||||
"cost_usd": 0.0009528
|
||||
}
|
||||
},
|
||||
"by_route": {
|
||||
"자동 답변": {
|
||||
"calls": 9,
|
||||
"prompt_tokens": 5950,
|
||||
"completion_tokens": 357,
|
||||
"cost_usd": 0.0029512
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
$ uv run tools/flow.py push answers/75-lab01-challenge-record.json
|
||||
! unpublished '75 IT helpdesk + user limit (reference)' (w3b18d2741bd22c6): it used the same webhook path
|
||||
✔ pushed '75 IT helpdesk + usage record (answer)' (id waec469b37d74b05)
|
||||
POST http://localhost:5678/webhook/my-cost
|
||||
|
||||
$ uv run tools/flow.py test cases/lab01-challenge.json -v
|
||||
✔ before: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||
→ request POST http://localhost:8800/usage-clear
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"cleared": 0
|
||||
}
|
||||
POST http://localhost:5678/webhook/my-cost · 4 case(s) × 1
|
||||
✔ 기록 1 · VPN · 5.0s · 자동 답변 · 3 calls · $0.001040
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속이 자주 끊길 경우, VPN 클라이언트를 최신 버전으로 업데이트하고 인증서 만료 여부를 IT 포털에서 확인하시기 바랍니다. 인증서는 만료 30일 전부터 재발급 신청이 가능합니다. 추가 도움이 필요하시면 IT 문의로 연락해 주십시오.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1999,
|
||||
"completion_tokens": 150,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 78,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.08e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1781,
|
||||
"completion_tokens": 78,
|
||||
"cost_usd": 0.0008372
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 140,
|
||||
"completion_tokens": 66,
|
||||
"cost_usd": 0.0001616
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0010396
|
||||
}
|
||||
✔ 기록 2 · 접수 · 3.5s · 자동 답변 · 3 calls · $0.000959
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "T-1002 처리 상태 알려 주세요",
|
||||
"user": "박서연"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "티켓 T-1002는 현재 '처리 중' 상태이며, 인증서 발급 승인 대기 중입니다. 완료 예정일은 10월 20일입니다. 추가 문의 사항이 있으시면 연락해 주시기 바랍니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1973,
|
||||
"completion_tokens": 106,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 71,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 3.8e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1791,
|
||||
"completion_tokens": 49,
|
||||
"cost_usd": 0.0007948
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 111,
|
||||
"completion_tokens": 51,
|
||||
"cost_usd": 0.000126
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0009588
|
||||
}
|
||||
✔ 기록 3 · 다른 사람 · 3.4s · 자동 답변 · 3 calls · $0.000953
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
|
||||
"user": "한지훈"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 추가 도움이 필요하시면 IT 문의로 연락해 주시기 바랍니다.",
|
||||
"sources": [
|
||||
"FAQ-001"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1978,
|
||||
"completion_tokens": 101,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 81,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.2e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1789,
|
||||
"completion_tokens": 46,
|
||||
"cost_usd": 0.0007892
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 108,
|
||||
"completion_tokens": 49,
|
||||
"cost_usd": 0.0001216
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0009528
|
||||
}
|
||||
✔ 사용량 요약 · 요청 3건 · 0.0s · $0.002951
|
||||
→ request POST http://localhost:8800/usage/summary
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"requests": 3,
|
||||
"calls": 9,
|
||||
"prompt_tokens": 5950,
|
||||
"completion_tokens": 357,
|
||||
"cost_usd": 0.0029512,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": {
|
||||
"calls": 9,
|
||||
"prompt_tokens": 5950,
|
||||
"completion_tokens": 357,
|
||||
"cost_usd": 0.0029512
|
||||
}
|
||||
},
|
||||
"by_user": {
|
||||
"이준호": {
|
||||
"calls": 3,
|
||||
"prompt_tokens": 1999,
|
||||
"completion_tokens": 150,
|
||||
"cost_usd": 0.0010396
|
||||
},
|
||||
"박서연": {
|
||||
"calls": 3,
|
||||
"prompt_tokens": 1973,
|
||||
"completion_tokens": 106,
|
||||
"cost_usd": 0.0009588
|
||||
},
|
||||
"한지훈": {
|
||||
"calls": 3,
|
||||
"prompt_tokens": 1978,
|
||||
"completion_tokens": 101,
|
||||
"cost_usd": 0.0009528
|
||||
}
|
||||
},
|
||||
"by_route": {
|
||||
"자동 답변": {
|
||||
"calls": 9,
|
||||
"prompt_tokens": 5950,
|
||||
"completion_tokens": 357,
|
||||
"cost_usd": 0.0029512
|
||||
}
|
||||
}
|
||||
}
|
||||
Passed 4/4
|
||||
report: runs/lab01-challenge-20261028-211025.json
|
||||
|
||||
$ uv run tools/flow.py usage
|
||||
requests 3 · calls 9 · tokens in 5950 / out 357 · $0.002951
|
||||
model gpt-4.1-mini 9 calls · in 5950 · out 357 · $0.002951
|
||||
user 이준호 3 calls · in 1999 · out 150 · $0.001040
|
||||
user 박서연 3 calls · in 1973 · out 106 · $0.000959
|
||||
user 한지훈 3 calls · in 1978 · out 101 · $0.000953
|
||||
route 자동 답변 9 calls · in 5950 · out 357 · $0.002951
|
||||
budget $1.0 / used $0.002951 · remaining $0.997049 · over False · per user 20/min · question 500 chars
|
||||
@@ -0,0 +1,193 @@
|
||||
{
|
||||
"cases": "lab01-challenge2.json",
|
||||
"webhook": "my-cost",
|
||||
"passed": 1,
|
||||
"total": 4,
|
||||
"results": [
|
||||
{
|
||||
"case": "기록 API 고장(500) · 답은 나감",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"recorded = null (expected false)"
|
||||
],
|
||||
"seconds": 3.4,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속이 자주 끊길 경우, VPN 클라이언트를 최신 버전으로 업데이트하고 인증서 만료 여부를 IT 포털에서 확인하시기 바랍니다. 인증서는 만료 30일 전부터 재발급 신청이 가능합니다. 추가 도움이 필요하시면 IT 문의로 연락해 주십시오.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1999,
|
||||
"completion_tokens": 150,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 78,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.08e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1781,
|
||||
"completion_tokens": 78,
|
||||
"cost_usd": 0.0008372
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 140,
|
||||
"completion_tokens": 66,
|
||||
"cost_usd": 0.0001616
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0010396
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "기록 API 느림(8초) · 답은 나감",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"recorded = null (expected false)"
|
||||
],
|
||||
"seconds": 11.2,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "최유진"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 확인하시고, 인쇄 대기열을 비운 후 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 IT 담당자에게 문의해 주시기 바랍니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1968,
|
||||
"completion_tokens": 109,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 72,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 3.84e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1779,
|
||||
"completion_tokens": 55,
|
||||
"cost_usd": 0.0007996
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 117,
|
||||
"completion_tokens": 48,
|
||||
"cost_usd": 0.0001236
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0009616
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "고친 뒤 · 기록됨",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"recorded = null (expected true)"
|
||||
],
|
||||
"seconds": 3.5,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
|
||||
"user": "한지훈"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 추가 도움이 필요하시면 IT 문의로 연락해 주시기 바랍니다.",
|
||||
"sources": [
|
||||
"FAQ-001"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1978,
|
||||
"completion_tokens": 101,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 81,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.2e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1789,
|
||||
"completion_tokens": 46,
|
||||
"cost_usd": 0.0007892
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 108,
|
||||
"completion_tokens": 49,
|
||||
"cost_usd": 0.0001216
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0009528
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "요약 · 기록된 요청은 1건",
|
||||
"webhook": "http://localhost:8800/usage/summary",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 0.0,
|
||||
"status": 200,
|
||||
"request": {},
|
||||
"response": {
|
||||
"requests": 1,
|
||||
"calls": 3,
|
||||
"prompt_tokens": 1978,
|
||||
"completion_tokens": 101,
|
||||
"cost_usd": 0.0009528,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": {
|
||||
"calls": 3,
|
||||
"prompt_tokens": 1978,
|
||||
"completion_tokens": 101,
|
||||
"cost_usd": 0.0009528
|
||||
}
|
||||
},
|
||||
"by_user": {
|
||||
"한지훈": {
|
||||
"calls": 3,
|
||||
"prompt_tokens": 1978,
|
||||
"completion_tokens": 101,
|
||||
"cost_usd": 0.0009528
|
||||
}
|
||||
},
|
||||
"by_route": {
|
||||
"자동 답변": {
|
||||
"calls": 3,
|
||||
"prompt_tokens": 1978,
|
||||
"completion_tokens": 101,
|
||||
"cost_usd": 0.0009528
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
$ uv run tools/flow.py test cases/lab01-challenge2.json -v
|
||||
✔ before: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 9}
|
||||
→ request POST http://localhost:8800/usage-clear
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"cleared": 9
|
||||
}
|
||||
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "slow_seconds": 8}
|
||||
→ request POST http://localhost:8800/admin/fault
|
||||
{
|
||||
"usage": "ok"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"faq": "ok",
|
||||
"tickets": "ok",
|
||||
"prompts": "ok",
|
||||
"usage": "ok",
|
||||
"slow_seconds": 8
|
||||
}
|
||||
POST http://localhost:5678/webhook/my-cost · 4 case(s) × 1
|
||||
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "error", "slow_seconds": 8}
|
||||
→ request POST http://localhost:8800/admin/fault
|
||||
{
|
||||
"usage": "error"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"faq": "ok",
|
||||
"tickets": "ok",
|
||||
"prompts": "ok",
|
||||
"usage": "error",
|
||||
"slow_seconds": 8
|
||||
}
|
||||
✘ 기록 API 고장(500) · 답은 나감 · 3.4s · 자동 답변 · 3 calls · $0.001040 · recorded = null (expected false)
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속이 자주 끊길 경우, VPN 클라이언트를 최신 버전으로 업데이트하고 인증서 만료 여부를 IT 포털에서 확인하시기 바랍니다. 인증서는 만료 30일 전부터 재발급 신청이 가능합니다. 추가 도움이 필요하시면 IT 문의로 연락해 주십시오.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1999,
|
||||
"completion_tokens": 150,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 78,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.08e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1781,
|
||||
"completion_tokens": 78,
|
||||
"cost_usd": 0.0008372
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 140,
|
||||
"completion_tokens": 66,
|
||||
"cost_usd": 0.0001616
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0010396
|
||||
}
|
||||
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "slow", "slow_seconds": 8.0}
|
||||
→ request POST http://localhost:8800/admin/fault
|
||||
{
|
||||
"usage": "slow",
|
||||
"slow_seconds": 8
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"faq": "ok",
|
||||
"tickets": "ok",
|
||||
"prompts": "ok",
|
||||
"usage": "slow",
|
||||
"slow_seconds": 8.0
|
||||
}
|
||||
✘ 기록 API 느림(8초) · 답은 나감 · 11.2s · 자동 답변 · 3 calls · $0.000962 · recorded = null (expected false)
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "최유진"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 확인하시고, 인쇄 대기열을 비운 후 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 IT 담당자에게 문의해 주시기 바랍니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1968,
|
||||
"completion_tokens": 109,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 72,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 3.84e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1779,
|
||||
"completion_tokens": 55,
|
||||
"cost_usd": 0.0007996
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 117,
|
||||
"completion_tokens": 48,
|
||||
"cost_usd": 0.0001236
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0009616
|
||||
}
|
||||
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "slow_seconds": 8.0}
|
||||
→ request POST http://localhost:8800/admin/fault
|
||||
{
|
||||
"usage": "ok"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"faq": "ok",
|
||||
"tickets": "ok",
|
||||
"prompts": "ok",
|
||||
"usage": "ok",
|
||||
"slow_seconds": 8.0
|
||||
}
|
||||
✘ 고친 뒤 · 기록됨 · 3.5s · 자동 답변 · 3 calls · $0.000953 · recorded = null (expected true)
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
|
||||
"user": "한지훈"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 추가 도움이 필요하시면 IT 문의로 연락해 주시기 바랍니다.",
|
||||
"sources": [
|
||||
"FAQ-001"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1978,
|
||||
"completion_tokens": 101,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 81,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.2e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1789,
|
||||
"completion_tokens": 46,
|
||||
"cost_usd": 0.0007892
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 108,
|
||||
"completion_tokens": 49,
|
||||
"cost_usd": 0.0001216
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0009528
|
||||
}
|
||||
✔ 요약 · 기록된 요청은 1건 · 0.0s · $0.000953
|
||||
→ request POST http://localhost:8800/usage/summary
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"requests": 1,
|
||||
"calls": 3,
|
||||
"prompt_tokens": 1978,
|
||||
"completion_tokens": 101,
|
||||
"cost_usd": 0.0009528,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": {
|
||||
"calls": 3,
|
||||
"prompt_tokens": 1978,
|
||||
"completion_tokens": 101,
|
||||
"cost_usd": 0.0009528
|
||||
}
|
||||
},
|
||||
"by_user": {
|
||||
"한지훈": {
|
||||
"calls": 3,
|
||||
"prompt_tokens": 1978,
|
||||
"completion_tokens": 101,
|
||||
"cost_usd": 0.0009528
|
||||
}
|
||||
},
|
||||
"by_route": {
|
||||
"자동 답변": {
|
||||
"calls": 3,
|
||||
"prompt_tokens": 1978,
|
||||
"completion_tokens": 101,
|
||||
"cost_usd": 0.0009528
|
||||
}
|
||||
}
|
||||
}
|
||||
Passed 1/4
|
||||
report: runs/lab01-challenge2-20261028-211043.json
|
||||
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "slow_seconds": 8.0}
|
||||
→ request POST http://localhost:8800/admin/fault
|
||||
{
|
||||
"usage": "ok"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"faq": "ok",
|
||||
"tickets": "ok",
|
||||
"prompts": "ok",
|
||||
"usage": "ok",
|
||||
"slow_seconds": 8.0
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
{
|
||||
"cases": "lab01-challenge2.json",
|
||||
"webhook": "my-cost",
|
||||
"passed": 4,
|
||||
"total": 4,
|
||||
"results": [
|
||||
{
|
||||
"case": "기록 API 고장(500) · 답은 나감",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 4.4,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속이 자주 끊길 경우, VPN 클라이언트를 최신 버전으로 업데이트하고 인증서 만료 여부를 IT 포털에서 확인하시기 바랍니다. 인증서는 만료 30일 전부터 재발급 신청이 가능합니다. 추가 도움이 필요하시면 IT 문의로 연락해 주십시오.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1999,
|
||||
"completion_tokens": 150,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 78,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.08e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1781,
|
||||
"completion_tokens": 78,
|
||||
"cost_usd": 0.0008372
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 140,
|
||||
"completion_tokens": 66,
|
||||
"cost_usd": 0.0001616
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0010396,
|
||||
"recorded": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "기록 API 느림(8초) · 답은 나감",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 6.1,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "최유진"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 확인하시고, 인쇄 대기열을 비운 후 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 IT 담당자에게 문의해 주시기 바랍니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1967,
|
||||
"completion_tokens": 108,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 72,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 3.84e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1779,
|
||||
"completion_tokens": 54,
|
||||
"cost_usd": 0.000798
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 116,
|
||||
"completion_tokens": 48,
|
||||
"cost_usd": 0.0001232
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0009596,
|
||||
"recorded": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "고친 뒤 · 기록됨",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 3.7,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
|
||||
"user": "한지훈"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 추가 도움이 필요하시면 IT 문의로 연락해 주시기 바랍니다.",
|
||||
"sources": [
|
||||
"FAQ-001"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1979,
|
||||
"completion_tokens": 102,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 81,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.2e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1789,
|
||||
"completion_tokens": 47,
|
||||
"cost_usd": 0.0007908
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 109,
|
||||
"completion_tokens": 49,
|
||||
"cost_usd": 0.000122
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0009548,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "요약 · 기록된 요청은 1건",
|
||||
"webhook": "http://localhost:8800/usage/summary",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 0.0,
|
||||
"status": 200,
|
||||
"request": {},
|
||||
"response": {
|
||||
"requests": 1,
|
||||
"calls": 3,
|
||||
"prompt_tokens": 1979,
|
||||
"completion_tokens": 102,
|
||||
"cost_usd": 0.0009548,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": {
|
||||
"calls": 3,
|
||||
"prompt_tokens": 1979,
|
||||
"completion_tokens": 102,
|
||||
"cost_usd": 0.0009548
|
||||
}
|
||||
},
|
||||
"by_user": {
|
||||
"한지훈": {
|
||||
"calls": 3,
|
||||
"prompt_tokens": 1979,
|
||||
"completion_tokens": 102,
|
||||
"cost_usd": 0.0009548
|
||||
}
|
||||
},
|
||||
"by_route": {
|
||||
"자동 답변": {
|
||||
"calls": 3,
|
||||
"prompt_tokens": 1979,
|
||||
"completion_tokens": 102,
|
||||
"cost_usd": 0.0009548
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
$ uv run tools/flow.py push answers/75-lab01-challenge2-safe-record.json
|
||||
! unpublished '75 IT helpdesk + usage record (answer)' (waec469b37d74b05): it used the same webhook path
|
||||
✔ pushed '75 IT helpdesk + safe record (answer)' (id w2ae10aa66addee4)
|
||||
POST http://localhost:5678/webhook/my-cost
|
||||
|
||||
$ uv run tools/flow.py test cases/lab01-challenge2.json -v
|
||||
✔ before: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 3}
|
||||
→ request POST http://localhost:8800/usage-clear
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"cleared": 3
|
||||
}
|
||||
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "slow_seconds": 8.0}
|
||||
→ request POST http://localhost:8800/admin/fault
|
||||
{
|
||||
"usage": "ok"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"faq": "ok",
|
||||
"tickets": "ok",
|
||||
"prompts": "ok",
|
||||
"usage": "ok",
|
||||
"slow_seconds": 8.0
|
||||
}
|
||||
POST http://localhost:5678/webhook/my-cost · 4 case(s) × 1
|
||||
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "error", "slow_seconds": 8.0}
|
||||
→ request POST http://localhost:8800/admin/fault
|
||||
{
|
||||
"usage": "error"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"faq": "ok",
|
||||
"tickets": "ok",
|
||||
"prompts": "ok",
|
||||
"usage": "error",
|
||||
"slow_seconds": 8.0
|
||||
}
|
||||
✔ 기록 API 고장(500) · 답은 나감 · 4.4s · 자동 답변 · 3 calls · $0.001040
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속이 자주 끊길 경우, VPN 클라이언트를 최신 버전으로 업데이트하고 인증서 만료 여부를 IT 포털에서 확인하시기 바랍니다. 인증서는 만료 30일 전부터 재발급 신청이 가능합니다. 추가 도움이 필요하시면 IT 문의로 연락해 주십시오.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1999,
|
||||
"completion_tokens": 150,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 78,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.08e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1781,
|
||||
"completion_tokens": 78,
|
||||
"cost_usd": 0.0008372
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 140,
|
||||
"completion_tokens": 66,
|
||||
"cost_usd": 0.0001616
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0010396,
|
||||
"recorded": false
|
||||
}
|
||||
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "slow", "slow_seconds": 8.0}
|
||||
→ request POST http://localhost:8800/admin/fault
|
||||
{
|
||||
"usage": "slow",
|
||||
"slow_seconds": 8
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"faq": "ok",
|
||||
"tickets": "ok",
|
||||
"prompts": "ok",
|
||||
"usage": "slow",
|
||||
"slow_seconds": 8.0
|
||||
}
|
||||
✔ 기록 API 느림(8초) · 답은 나감 · 6.1s · 자동 답변 · 3 calls · $0.000960
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "최유진"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 확인하시고, 인쇄 대기열을 비운 후 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 IT 담당자에게 문의해 주시기 바랍니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1967,
|
||||
"completion_tokens": 108,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 72,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 3.84e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1779,
|
||||
"completion_tokens": 54,
|
||||
"cost_usd": 0.000798
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 116,
|
||||
"completion_tokens": 48,
|
||||
"cost_usd": 0.0001232
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0009596,
|
||||
"recorded": false
|
||||
}
|
||||
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "slow_seconds": 8.0}
|
||||
→ request POST http://localhost:8800/admin/fault
|
||||
{
|
||||
"usage": "ok"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"faq": "ok",
|
||||
"tickets": "ok",
|
||||
"prompts": "ok",
|
||||
"usage": "ok",
|
||||
"slow_seconds": 8.0
|
||||
}
|
||||
✔ 고친 뒤 · 기록됨 · 3.7s · 자동 답변 · 3 calls · $0.000955
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
|
||||
"user": "한지훈"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 추가 도움이 필요하시면 IT 문의로 연락해 주시기 바랍니다.",
|
||||
"sources": [
|
||||
"FAQ-001"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1979,
|
||||
"completion_tokens": 102,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 81,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.2e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1789,
|
||||
"completion_tokens": 47,
|
||||
"cost_usd": 0.0007908
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 109,
|
||||
"completion_tokens": 49,
|
||||
"cost_usd": 0.000122
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0009548,
|
||||
"recorded": true
|
||||
}
|
||||
✔ 요약 · 기록된 요청은 1건 · 0.0s · $0.000955
|
||||
→ request POST http://localhost:8800/usage/summary
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"requests": 1,
|
||||
"calls": 3,
|
||||
"prompt_tokens": 1979,
|
||||
"completion_tokens": 102,
|
||||
"cost_usd": 0.0009548,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": {
|
||||
"calls": 3,
|
||||
"prompt_tokens": 1979,
|
||||
"completion_tokens": 102,
|
||||
"cost_usd": 0.0009548
|
||||
}
|
||||
},
|
||||
"by_user": {
|
||||
"한지훈": {
|
||||
"calls": 3,
|
||||
"prompt_tokens": 1979,
|
||||
"completion_tokens": 102,
|
||||
"cost_usd": 0.0009548
|
||||
}
|
||||
},
|
||||
"by_route": {
|
||||
"자동 답변": {
|
||||
"calls": 3,
|
||||
"prompt_tokens": 1979,
|
||||
"completion_tokens": 102,
|
||||
"cost_usd": 0.0009548
|
||||
}
|
||||
}
|
||||
}
|
||||
Passed 4/4
|
||||
report: runs/lab01-challenge2-20261028-211109.json
|
||||
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "slow_seconds": 8.0}
|
||||
→ request POST http://localhost:8800/admin/fault
|
||||
{
|
||||
"usage": "ok"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"faq": "ok",
|
||||
"tickets": "ok",
|
||||
"prompts": "ok",
|
||||
"usage": "ok",
|
||||
"slow_seconds": 8.0
|
||||
}
|
||||
|
||||
$ uv run tools/flow.py test cases/lab01-challenge.json
|
||||
✔ before: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 3}
|
||||
POST http://localhost:5678/webhook/my-cost · 4 case(s) × 1
|
||||
✔ 기록 1 · VPN · 3.7s · 자동 답변 · 3 calls · $0.001040
|
||||
✔ 기록 2 · 접수 · 3.4s · 자동 답변 · 3 calls · $0.000978
|
||||
✔ 기록 3 · 다른 사람 · 3.2s · 자동 답변 · 3 calls · $0.000953
|
||||
✔ 사용량 요약 · 요청 3건 · 0.0s · $0.002970
|
||||
Passed 4/4
|
||||
report: runs/lab01-challenge-20261028-211120.json
|
||||
@@ -0,0 +1,245 @@
|
||||
{
|
||||
"cases": "lab02.json",
|
||||
"webhook": "my-cost",
|
||||
"passed": 2,
|
||||
"total": 5,
|
||||
"results": [
|
||||
{
|
||||
"case": "예제 1 · VPN · 보내는 양 줄이기",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"usage.by_step.answer.prompt_tokens = 1781 (expected 1..600)"
|
||||
],
|
||||
"seconds": 3.4,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류가 발생하면 VPN 클라이언트를 최신 버전으로 업데이트하고 인증서 만료 여부를 확인하시기 바랍니다. 인증서가 만료된 경우 IT 포털에서 재발급받으실 수 있습니다. 추가 도움이 필요하시면 IT 문의로 연락해 주십시오.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1986,
|
||||
"completion_tokens": 133,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 78,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.08e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1781,
|
||||
"completion_tokens": 65,
|
||||
"cost_usd": 0.0008164
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 127,
|
||||
"completion_tokens": 62,
|
||||
"cost_usd": 0.00015
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0010072,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "예제 1 · 접수 · 보내는 양 줄이기",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"usage.by_step.answer.prompt_tokens = 1791 (expected 1..600)"
|
||||
],
|
||||
"seconds": 3.5,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "T-1002 처리 상태 알려 주세요",
|
||||
"user": "박서연"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "티켓 T-1002는 현재 인증서 발급 승인 대기 중이며, 완료 예정일은 10월 20일입니다. 추가 문의 사항이 있으시면 연락 주시기 바랍니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1969,
|
||||
"completion_tokens": 93,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 71,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 3.8e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1791,
|
||||
"completion_tokens": 45,
|
||||
"cost_usd": 0.0007884
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 107,
|
||||
"completion_tokens": 42,
|
||||
"cost_usd": 0.00011
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0009364,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "예제 1 · 비밀번호 · 품질 그대로",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 3.5,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
|
||||
"user": "한지훈"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 추가 도움이 필요하시면 IT 문의로 연락해 주시기 바랍니다.",
|
||||
"sources": [
|
||||
"FAQ-001"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1979,
|
||||
"completion_tokens": 102,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 81,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.2e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1789,
|
||||
"completion_tokens": 47,
|
||||
"cost_usd": 0.0007908
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 109,
|
||||
"completion_tokens": 49,
|
||||
"cost_usd": 0.000122
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0009548,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "예제 2 · 분류는 작은 모델",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"usage.by_step.classify.model = gpt-4.1-mini (expected gpt-5.4-nano)"
|
||||
],
|
||||
"seconds": 3.2,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "회의실 화면 공유가 안 되는데 누구에게 물어보나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "회의실 화면 공유 문제는 IT 문의로 연락 주시면 도움을 드리겠습니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1883,
|
||||
"completion_tokens": 48,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 78,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.08e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1719,
|
||||
"completion_tokens": 24,
|
||||
"cost_usd": 0.000726
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 86,
|
||||
"completion_tokens": 18,
|
||||
"cost_usd": 6.32e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.00083,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "예제 2 · 프린터 · 품질 그대로",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 3.5,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "최유진"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 확인하시고, 인쇄 대기열을 비운 후 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 IT 담당자에게 문의해 주시기 바랍니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1967,
|
||||
"completion_tokens": 108,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 72,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 3.84e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1779,
|
||||
"completion_tokens": 54,
|
||||
"cost_usd": 0.000798
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 116,
|
||||
"completion_tokens": 48,
|
||||
"cost_usd": 0.0001232
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0009596,
|
||||
"recorded": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
$ uv run tools/flow.py test cases/lab02.json -v
|
||||
POST http://localhost:5678/webhook/my-cost · 5 case(s) × 1
|
||||
✘ 예제 1 · VPN · 보내는 양 줄이기 · 3.4s · 자동 답변 · 3 calls · $0.001007 · usage.by_step.answer.prompt_tokens = 1781 (expected 1..600)
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류가 발생하면 VPN 클라이언트를 최신 버전으로 업데이트하고 인증서 만료 여부를 확인하시기 바랍니다. 인증서가 만료된 경우 IT 포털에서 재발급받으실 수 있습니다. 추가 도움이 필요하시면 IT 문의로 연락해 주십시오.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1986,
|
||||
"completion_tokens": 133,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 78,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.08e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1781,
|
||||
"completion_tokens": 65,
|
||||
"cost_usd": 0.0008164
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 127,
|
||||
"completion_tokens": 62,
|
||||
"cost_usd": 0.00015
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0010072,
|
||||
"recorded": true
|
||||
}
|
||||
✘ 예제 1 · 접수 · 보내는 양 줄이기 · 3.5s · 자동 답변 · 3 calls · $0.000936 · usage.by_step.answer.prompt_tokens = 1791 (expected 1..600)
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "T-1002 처리 상태 알려 주세요",
|
||||
"user": "박서연"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "티켓 T-1002는 현재 인증서 발급 승인 대기 중이며, 완료 예정일은 10월 20일입니다. 추가 문의 사항이 있으시면 연락 주시기 바랍니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1969,
|
||||
"completion_tokens": 93,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 71,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 3.8e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1791,
|
||||
"completion_tokens": 45,
|
||||
"cost_usd": 0.0007884
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 107,
|
||||
"completion_tokens": 42,
|
||||
"cost_usd": 0.00011
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0009364,
|
||||
"recorded": true
|
||||
}
|
||||
✔ 예제 1 · 비밀번호 · 품질 그대로 · 3.5s · 자동 답변 · 3 calls · $0.000955
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
|
||||
"user": "한지훈"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 추가 도움이 필요하시면 IT 문의로 연락해 주시기 바랍니다.",
|
||||
"sources": [
|
||||
"FAQ-001"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1979,
|
||||
"completion_tokens": 102,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 81,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.2e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1789,
|
||||
"completion_tokens": 47,
|
||||
"cost_usd": 0.0007908
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 109,
|
||||
"completion_tokens": 49,
|
||||
"cost_usd": 0.000122
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0009548,
|
||||
"recorded": true
|
||||
}
|
||||
✘ 예제 2 · 분류는 작은 모델 · 3.2s · 자동 답변 · 3 calls · $0.000830 · usage.by_step.classify.model = gpt-4.1-mini (expected gpt-5.4-nano)
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "회의실 화면 공유가 안 되는데 누구에게 물어보나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "회의실 화면 공유 문제는 IT 문의로 연락 주시면 도움을 드리겠습니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1883,
|
||||
"completion_tokens": 48,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 78,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.08e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1719,
|
||||
"completion_tokens": 24,
|
||||
"cost_usd": 0.000726
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 86,
|
||||
"completion_tokens": 18,
|
||||
"cost_usd": 6.32e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.00083,
|
||||
"recorded": true
|
||||
}
|
||||
✔ 예제 2 · 프린터 · 품질 그대로 · 3.5s · 자동 답변 · 3 calls · $0.000960
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "최유진"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 확인하시고, 인쇄 대기열을 비운 후 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 IT 담당자에게 문의해 주시기 바랍니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 1967,
|
||||
"completion_tokens": 108,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 72,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 3.84e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1779,
|
||||
"completion_tokens": 54,
|
||||
"cost_usd": 0.000798
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 116,
|
||||
"completion_tokens": 48,
|
||||
"cost_usd": 0.0001232
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0009596,
|
||||
"recorded": true
|
||||
}
|
||||
Passed 2/5
|
||||
report: runs/lab02-20261028-210311.json
|
||||
@@ -0,0 +1,241 @@
|
||||
{
|
||||
"cases": "lab02.json",
|
||||
"webhook": "my-cost",
|
||||
"passed": 4,
|
||||
"total": 5,
|
||||
"results": [
|
||||
{
|
||||
"case": "예제 1 · VPN · 보내는 양 줄이기",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 4.7,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류가 발생할 경우, VPN 클라이언트를 최신 버전으로 업데이트하고 인증서 만료 여부를 확인한 후 다시 접속해 주시기 바랍니다. 현재 관련된 접수된 티켓은 없습니다. 추가 도움이 필요하시면 언제든지 문의해 주시기 바랍니다.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 361,
|
||||
"completion_tokens": 126,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 78,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.08e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 56,
|
||||
"cost_usd": 0.0001556
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 118,
|
||||
"completion_tokens": 64,
|
||||
"cost_usd": 0.0001496
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.000346,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "예제 1 · 접수 · 보내는 양 줄이기",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 3.2,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "T-1002 처리 상태 알려 주세요",
|
||||
"user": "박서연"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "T-1002 티켓은 현재 인증서 발급 승인 대기 중이며, 10월 20일에 완료될 예정입니다. 추가 문의 사항이 있으시면 언제든지 알려주시기 바랍니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 330,
|
||||
"completion_tokens": 95,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 71,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 3.8e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 152,
|
||||
"completion_tokens": 45,
|
||||
"cost_usd": 0.0001328
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 107,
|
||||
"completion_tokens": 44,
|
||||
"cost_usd": 0.0001132
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.000284,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "예제 1 · 비밀번호 · 품질 그대로",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 3.1,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
|
||||
"user": "한지훈"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 추가 도움이 필요하시면 문의해 주시기 바랍니다.",
|
||||
"sources": [
|
||||
"FAQ-001"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 355,
|
||||
"completion_tokens": 95,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 81,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.2e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 169,
|
||||
"completion_tokens": 43,
|
||||
"cost_usd": 0.0001364
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 105,
|
||||
"completion_tokens": 46,
|
||||
"cost_usd": 0.0001156
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.000294,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "예제 2 · 분류는 작은 모델",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"usage.by_step.classify.model = gpt-4.1-mini (expected gpt-5.4-nano)"
|
||||
],
|
||||
"seconds": 2.7,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "회의실 화면 공유가 안 되는데 누구에게 물어보나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "죄송합니다만, 해당 정보를 찾지 못했습니다. 담당 직원이 곧 도움을 드리겠습니다. 감사합니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 278,
|
||||
"completion_tokens": 45,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 78,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.08e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 124,
|
||||
"completion_tokens": 14,
|
||||
"cost_usd": 7.2e-05
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 76,
|
||||
"completion_tokens": 25,
|
||||
"cost_usd": 7.04e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001832,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "예제 2 · 프린터 · 품질 그대로",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 3.2,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "최유진"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 확인하시고, 인쇄 대기열을 비운 후 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원에게 문의해 주시기 바랍니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 352,
|
||||
"completion_tokens": 107,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 72,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 3.84e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 164,
|
||||
"completion_tokens": 54,
|
||||
"cost_usd": 0.000152
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 116,
|
||||
"completion_tokens": 47,
|
||||
"cost_usd": 0.0001216
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.000312,
|
||||
"recorded": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
$ uv run tools/flow.py push reference/lab02-example1-lean.json
|
||||
! unpublished '75 IT helpdesk + safe record (answer)' (w2ae10aa66addee4): it used the same webhook path
|
||||
✔ pushed '75 IT helpdesk + lean prompt (reference)' (id wd453c4a61b08d35)
|
||||
POST http://localhost:5678/webhook/my-cost
|
||||
|
||||
$ uv run tools/flow.py test cases/lab02.json -v
|
||||
POST http://localhost:5678/webhook/my-cost · 5 case(s) × 1
|
||||
✔ 예제 1 · VPN · 보내는 양 줄이기 · 4.7s · 자동 답변 · 3 calls · $0.000346
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류가 발생할 경우, VPN 클라이언트를 최신 버전으로 업데이트하고 인증서 만료 여부를 확인한 후 다시 접속해 주시기 바랍니다. 현재 관련된 접수된 티켓은 없습니다. 추가 도움이 필요하시면 언제든지 문의해 주시기 바랍니다.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 361,
|
||||
"completion_tokens": 126,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 78,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.08e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 56,
|
||||
"cost_usd": 0.0001556
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 118,
|
||||
"completion_tokens": 64,
|
||||
"cost_usd": 0.0001496
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.000346,
|
||||
"recorded": true
|
||||
}
|
||||
✔ 예제 1 · 접수 · 보내는 양 줄이기 · 3.2s · 자동 답변 · 3 calls · $0.000284
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "T-1002 처리 상태 알려 주세요",
|
||||
"user": "박서연"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "T-1002 티켓은 현재 인증서 발급 승인 대기 중이며, 10월 20일에 완료될 예정입니다. 추가 문의 사항이 있으시면 언제든지 알려주시기 바랍니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 330,
|
||||
"completion_tokens": 95,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 71,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 3.8e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 152,
|
||||
"completion_tokens": 45,
|
||||
"cost_usd": 0.0001328
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 107,
|
||||
"completion_tokens": 44,
|
||||
"cost_usd": 0.0001132
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.000284,
|
||||
"recorded": true
|
||||
}
|
||||
✔ 예제 1 · 비밀번호 · 품질 그대로 · 3.1s · 자동 답변 · 3 calls · $0.000294
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
|
||||
"user": "한지훈"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 추가 도움이 필요하시면 문의해 주시기 바랍니다.",
|
||||
"sources": [
|
||||
"FAQ-001"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 355,
|
||||
"completion_tokens": 95,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 81,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.2e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 169,
|
||||
"completion_tokens": 43,
|
||||
"cost_usd": 0.0001364
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 105,
|
||||
"completion_tokens": 46,
|
||||
"cost_usd": 0.0001156
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.000294,
|
||||
"recorded": true
|
||||
}
|
||||
✘ 예제 2 · 분류는 작은 모델 · 2.7s · 자동 답변 · 3 calls · $0.000183 · usage.by_step.classify.model = gpt-4.1-mini (expected gpt-5.4-nano)
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "회의실 화면 공유가 안 되는데 누구에게 물어보나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "죄송합니다만, 해당 정보를 찾지 못했습니다. 담당 직원이 곧 도움을 드리겠습니다. 감사합니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 278,
|
||||
"completion_tokens": 45,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 78,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 4.08e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 124,
|
||||
"completion_tokens": 14,
|
||||
"cost_usd": 7.2e-05
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 76,
|
||||
"completion_tokens": 25,
|
||||
"cost_usd": 7.04e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001832,
|
||||
"recorded": true
|
||||
}
|
||||
✔ 예제 2 · 프린터 · 품질 그대로 · 3.2s · 자동 답변 · 3 calls · $0.000312
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "최유진"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 확인하시고, 인쇄 대기열을 비운 후 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원에게 문의해 주시기 바랍니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 352,
|
||||
"completion_tokens": 107,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 72,
|
||||
"completion_tokens": 6,
|
||||
"cost_usd": 3.84e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 164,
|
||||
"completion_tokens": 54,
|
||||
"cost_usd": 0.000152
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 116,
|
||||
"completion_tokens": 47,
|
||||
"cost_usd": 0.0001216
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.000312,
|
||||
"recorded": true
|
||||
}
|
||||
Passed 4/5
|
||||
report: runs/lab02-20261028-210339.json
|
||||
|
||||
$ uv run tools/flow.py diff answers/75-lab01-challenge2-safe-record.json reference/lab02-example1-lean.json
|
||||
75 IT helpdesk + safe record (answer) → 75 IT helpdesk + lean prompt (reference)
|
||||
~ 'Answer' parameters.jsonBody: changed (3491 → 822 chars)
|
||||
1 change(s)
|
||||
@@ -0,0 +1,239 @@
|
||||
{
|
||||
"cases": "lab02.json",
|
||||
"webhook": "my-cost",
|
||||
"passed": 5,
|
||||
"total": 5,
|
||||
"results": [
|
||||
{
|
||||
"case": "예제 1 · VPN · 보내는 양 줄이기",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 4.3,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 티켓은 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 359,
|
||||
"completion_tokens": 123,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 77,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.54e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 56,
|
||||
"cost_usd": 0.0001556
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 117,
|
||||
"completion_tokens": 59,
|
||||
"cost_usd": 9.715e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.00027815,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "예제 1 · 접수 · 보내는 양 줄이기",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 3.1,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "T-1002 처리 상태 알려 주세요",
|
||||
"user": "박서연"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "T-1002 티켓은 현재 처리 중이며, 인증서 발급 승인 대기 중입니다. 10월 20일에 완료될 예정입니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 317,
|
||||
"completion_tokens": 79,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 70,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.4e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 152,
|
||||
"completion_tokens": 34,
|
||||
"cost_usd": 0.0001152
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 95,
|
||||
"completion_tokens": 37,
|
||||
"cost_usd": 6.525e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.00020445,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "예제 1 · 비밀번호 · 품질 그대로",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 3.6,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
|
||||
"user": "한지훈"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 ‘비밀번호 찾기’에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 도움이 필요하시면 언제든 문의해 주세요.",
|
||||
"sources": [
|
||||
"FAQ-001"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 354,
|
||||
"completion_tokens": 99,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 80,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.6e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 169,
|
||||
"completion_tokens": 44,
|
||||
"cost_usd": 0.000138
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 105,
|
||||
"completion_tokens": 47,
|
||||
"cost_usd": 7.975e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.00024375,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "예제 2 · 분류는 작은 모델",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 2.7,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "회의실 화면 공유가 안 되는데 누구에게 물어보나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "죄송합니다. 요청하신 내용을 찾지 못했습니다. 담당 직원이 도움을 드리겠습니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 276,
|
||||
"completion_tokens": 47,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 77,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.54e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 124,
|
||||
"completion_tokens": 14,
|
||||
"cost_usd": 7.2e-05
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 75,
|
||||
"completion_tokens": 25,
|
||||
"cost_usd": 4.625e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.00014365,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "예제 2 · 프린터 · 품질 그대로",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 3.7,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "최유진"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 확인한 뒤 인쇄 대기열을 비우고 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 350,
|
||||
"completion_tokens": 119,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 71,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.42e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 164,
|
||||
"completion_tokens": 54,
|
||||
"cost_usd": 0.000152
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 115,
|
||||
"completion_tokens": 57,
|
||||
"cost_usd": 9.425e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.00027045,
|
||||
"recorded": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
$ uv run tools/flow.py push reference/lab02-example2-models.json
|
||||
! unpublished '75 IT helpdesk + lean prompt (reference)' (wd453c4a61b08d35): it used the same webhook path
|
||||
✔ pushed '75 IT helpdesk + small models (reference)' (id w5619722541404a5)
|
||||
POST http://localhost:5678/webhook/my-cost
|
||||
|
||||
$ uv run tools/flow.py test cases/lab02.json -v
|
||||
POST http://localhost:5678/webhook/my-cost · 5 case(s) × 1
|
||||
✔ 예제 1 · VPN · 보내는 양 줄이기 · 4.3s · 자동 답변 · 3 calls · $0.000278
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 티켓은 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 359,
|
||||
"completion_tokens": 123,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 77,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.54e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 56,
|
||||
"cost_usd": 0.0001556
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 117,
|
||||
"completion_tokens": 59,
|
||||
"cost_usd": 9.715e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.00027815,
|
||||
"recorded": true
|
||||
}
|
||||
✔ 예제 1 · 접수 · 보내는 양 줄이기 · 3.1s · 자동 답변 · 3 calls · $0.000204
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "T-1002 처리 상태 알려 주세요",
|
||||
"user": "박서연"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "T-1002 티켓은 현재 처리 중이며, 인증서 발급 승인 대기 중입니다. 10월 20일에 완료될 예정입니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 317,
|
||||
"completion_tokens": 79,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 70,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.4e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 152,
|
||||
"completion_tokens": 34,
|
||||
"cost_usd": 0.0001152
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 95,
|
||||
"completion_tokens": 37,
|
||||
"cost_usd": 6.525e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.00020445,
|
||||
"recorded": true
|
||||
}
|
||||
✔ 예제 1 · 비밀번호 · 품질 그대로 · 3.6s · 자동 답변 · 3 calls · $0.000244
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
|
||||
"user": "한지훈"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 ‘비밀번호 찾기’에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 도움이 필요하시면 언제든 문의해 주세요.",
|
||||
"sources": [
|
||||
"FAQ-001"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 354,
|
||||
"completion_tokens": 99,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 80,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.6e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 169,
|
||||
"completion_tokens": 44,
|
||||
"cost_usd": 0.000138
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 105,
|
||||
"completion_tokens": 47,
|
||||
"cost_usd": 7.975e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.00024375,
|
||||
"recorded": true
|
||||
}
|
||||
✔ 예제 2 · 분류는 작은 모델 · 2.7s · 자동 답변 · 3 calls · $0.000144
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "회의실 화면 공유가 안 되는데 누구에게 물어보나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "죄송합니다. 요청하신 내용을 찾지 못했습니다. 담당 직원이 도움을 드리겠습니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 276,
|
||||
"completion_tokens": 47,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 77,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.54e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 124,
|
||||
"completion_tokens": 14,
|
||||
"cost_usd": 7.2e-05
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 75,
|
||||
"completion_tokens": 25,
|
||||
"cost_usd": 4.625e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.00014365,
|
||||
"recorded": true
|
||||
}
|
||||
✔ 예제 2 · 프린터 · 품질 그대로 · 3.7s · 자동 답변 · 3 calls · $0.000270
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "최유진"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 확인한 뒤 인쇄 대기열을 비우고 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 350,
|
||||
"completion_tokens": 119,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 71,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.42e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 164,
|
||||
"completion_tokens": 54,
|
||||
"cost_usd": 0.000152
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 115,
|
||||
"completion_tokens": 57,
|
||||
"cost_usd": 9.425e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.00027045,
|
||||
"recorded": true
|
||||
}
|
||||
Passed 5/5
|
||||
report: runs/lab02-20261028-210408.json
|
||||
|
||||
$ uv run tools/flow.py test cases/lab01.json
|
||||
POST http://localhost:5678/webhook/my-cost · 3 case(s) × 1
|
||||
✔ VPN · 비용과 단계별 사용량 · 3.3s · 자동 답변 · 3 calls · $0.000278
|
||||
✔ 접수 현황 · 비용 · 3.5s · 자동 답변 · 3 calls · $0.000204
|
||||
✔ 빈 질문은 LLM 없이 · 0.0s
|
||||
Passed 3/3
|
||||
report: runs/lab01-20261028-210415.json
|
||||
@@ -0,0 +1,239 @@
|
||||
{
|
||||
"cases": "lab02.json",
|
||||
"webhook": "nano-answer",
|
||||
"passed": 5,
|
||||
"total": 5,
|
||||
"results": [
|
||||
{
|
||||
"case": "예제 1 · VPN · 보내는 양 줄이기",
|
||||
"webhook": "nano-answer",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 5.0,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "FAQ에 따르면 VPN 접속이 끊길 때는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속해 보시기 바랍니다. 접수 건은 ticket_id가 없어 상태를 확인할 수 없으며, 요청하신 티켓 기록은 찾지 못했습니다. 담당 직원이 추가로 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 383,
|
||||
"completion_tokens": 176,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 77,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.54e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 164,
|
||||
"completion_tokens": 84,
|
||||
"cost_usd": 0.0001378
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 142,
|
||||
"completion_tokens": 84,
|
||||
"cost_usd": 0.0001334
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0002966,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "예제 1 · 접수 · 보내는 양 줄이기",
|
||||
"webhook": "nano-answer",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 2.8,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "T-1002 처리 상태 알려 주세요",
|
||||
"user": "박서연"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "T-1002의 처리 상태는 **처리 중**이며, **인증서 발급 승인 대기 중**입니다. 또한 **10월 20일 완료 예정**으로 기록되어 있습니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 325,
|
||||
"completion_tokens": 100,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 70,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.4e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 151,
|
||||
"completion_tokens": 46,
|
||||
"cost_usd": 8.77e-05
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 104,
|
||||
"completion_tokens": 46,
|
||||
"cost_usd": 7.83e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.00019,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "예제 1 · 비밀번호 · 품질 그대로",
|
||||
"webhook": "nano-answer",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 3.7,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
|
||||
"user": "한지훈"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "사내 포털 로그인 화면의 ‘비밀번호 찾기’에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 접수 건은 찾지 못했습니다.",
|
||||
"sources": [
|
||||
"FAQ-001"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 346,
|
||||
"completion_tokens": 88,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 80,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.6e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 168,
|
||||
"completion_tokens": 40,
|
||||
"cost_usd": 8.36e-05
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 98,
|
||||
"completion_tokens": 40,
|
||||
"cost_usd": 6.96e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001792,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "예제 2 · 분류는 작은 모델",
|
||||
"webhook": "nano-answer",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 3.2,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "회의실 화면 공유가 안 되는데 누구에게 물어보나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "제공된 FAQ와 접수 건 기록에서 “회의실 화면 공유” 관련 안내를 찾지 못했습니다. 또한 해당 티켓 정보가 없어 상태를 확인할 수 없습니다. 담당자에게 확인해 드리겠습니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 307,
|
||||
"completion_tokens": 108,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 77,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.54e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 123,
|
||||
"completion_tokens": 50,
|
||||
"cost_usd": 8.71e-05
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 107,
|
||||
"completion_tokens": 50,
|
||||
"cost_usd": 8.39e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001964,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "예제 2 · 프린터 · 품질 그대로",
|
||||
"webhook": "nano-answer",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 3.3,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "최유진"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 출력이 안 될 때는 프린터 전원과 네트워크 연결을 확인한 뒤, 인쇄 대기열을 비우고 다시 출력해 보시기 바랍니다. 접수 건은 찾지 못했습니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 340,
|
||||
"completion_tokens": 109,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 71,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.42e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 163,
|
||||
"completion_tokens": 48,
|
||||
"cost_usd": 9.26e-05
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 106,
|
||||
"completion_tokens": 53,
|
||||
"cost_usd": 8.745e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.00020425,
|
||||
"recorded": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
$ uv run tools/flow.py push reference/lab02-example3-nano-answer.json
|
||||
✔ pushed '75 IT helpdesk, answer on nano too (compare)' (id wf141d1eb1ccfef8)
|
||||
POST http://localhost:5678/webhook/nano-answer
|
||||
|
||||
$ uv run tools/flow.py test cases/lab02.json --webhook nano-answer -v
|
||||
POST http://localhost:5678/webhook/nano-answer · 5 case(s) × 1
|
||||
✔ 예제 1 · VPN · 보내는 양 줄이기 · 5.0s · 자동 답변 · 3 calls · $0.000297
|
||||
→ request POST /webhook/nano-answer
|
||||
{
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "FAQ에 따르면 VPN 접속이 끊길 때는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속해 보시기 바랍니다. 접수 건은 ticket_id가 없어 상태를 확인할 수 없으며, 요청하신 티켓 기록은 찾지 못했습니다. 담당 직원이 추가로 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 383,
|
||||
"completion_tokens": 176,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 77,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.54e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 164,
|
||||
"completion_tokens": 84,
|
||||
"cost_usd": 0.0001378
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 142,
|
||||
"completion_tokens": 84,
|
||||
"cost_usd": 0.0001334
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0002966,
|
||||
"recorded": true
|
||||
}
|
||||
✔ 예제 1 · 접수 · 보내는 양 줄이기 · 2.8s · 자동 답변 · 3 calls · $0.000190
|
||||
→ request POST /webhook/nano-answer
|
||||
{
|
||||
"question": "T-1002 처리 상태 알려 주세요",
|
||||
"user": "박서연"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "T-1002의 처리 상태는 **처리 중**이며, **인증서 발급 승인 대기 중**입니다. 또한 **10월 20일 완료 예정**으로 기록되어 있습니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 325,
|
||||
"completion_tokens": 100,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 70,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.4e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 151,
|
||||
"completion_tokens": 46,
|
||||
"cost_usd": 8.77e-05
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 104,
|
||||
"completion_tokens": 46,
|
||||
"cost_usd": 7.83e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.00019,
|
||||
"recorded": true
|
||||
}
|
||||
✔ 예제 1 · 비밀번호 · 품질 그대로 · 3.7s · 자동 답변 · 3 calls · $0.000179
|
||||
→ request POST /webhook/nano-answer
|
||||
{
|
||||
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
|
||||
"user": "한지훈"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "사내 포털 로그인 화면의 ‘비밀번호 찾기’에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 접수 건은 찾지 못했습니다.",
|
||||
"sources": [
|
||||
"FAQ-001"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 346,
|
||||
"completion_tokens": 88,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 80,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.6e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 168,
|
||||
"completion_tokens": 40,
|
||||
"cost_usd": 8.36e-05
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 98,
|
||||
"completion_tokens": 40,
|
||||
"cost_usd": 6.96e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001792,
|
||||
"recorded": true
|
||||
}
|
||||
✔ 예제 2 · 분류는 작은 모델 · 3.2s · 자동 답변 · 3 calls · $0.000196
|
||||
→ request POST /webhook/nano-answer
|
||||
{
|
||||
"question": "회의실 화면 공유가 안 되는데 누구에게 물어보나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "제공된 FAQ와 접수 건 기록에서 “회의실 화면 공유” 관련 안내를 찾지 못했습니다. 또한 해당 티켓 정보가 없어 상태를 확인할 수 없습니다. 담당자에게 확인해 드리겠습니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 307,
|
||||
"completion_tokens": 108,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 77,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.54e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 123,
|
||||
"completion_tokens": 50,
|
||||
"cost_usd": 8.71e-05
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 107,
|
||||
"completion_tokens": 50,
|
||||
"cost_usd": 8.39e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001964,
|
||||
"recorded": true
|
||||
}
|
||||
✔ 예제 2 · 프린터 · 품질 그대로 · 3.3s · 자동 답변 · 3 calls · $0.000204
|
||||
→ request POST /webhook/nano-answer
|
||||
{
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "최유진"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 출력이 안 될 때는 프린터 전원과 네트워크 연결을 확인한 뒤, 인쇄 대기열을 비우고 다시 출력해 보시기 바랍니다. 접수 건은 찾지 못했습니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 340,
|
||||
"completion_tokens": 109,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 71,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.42e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 163,
|
||||
"completion_tokens": 48,
|
||||
"cost_usd": 9.26e-05
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 106,
|
||||
"completion_tokens": 53,
|
||||
"cost_usd": 8.745e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.00020425,
|
||||
"recorded": true
|
||||
}
|
||||
Passed 5/5
|
||||
report: runs/lab02-20261028-210444.json
|
||||
|
||||
$ uv run tools/flow.py test cases/lab01-mix.json
|
||||
POST http://localhost:5678/webhook/my-cost · 7 case(s) × 1
|
||||
✔ FAQ · VPN · 3.6s · 자동 답변 · 3 calls · $0.000278
|
||||
✔ FAQ · 프린터 · 3.1s · 자동 답변 · 3 calls · $0.000270
|
||||
✔ FAQ · 비밀번호 · 3.2s · 자동 답변 · 3 calls · $0.000241
|
||||
✔ FAQ · 메일 · 3.1s · 자동 답변 · 3 calls · $0.000297
|
||||
✔ 접수 · T-1002 · 3.0s · 자동 답변 · 3 calls · $0.000204
|
||||
✔ 접수 · T-1001 · 3.3s · 자동 답변 · 3 calls · $0.000195
|
||||
✔ 규칙에 없는 질문 · 2.8s · 자동 답변 · 3 calls · $0.000144
|
||||
Passed 7/7
|
||||
report: runs/lab01-mix-20261028-210506.json
|
||||
|
||||
$ uv run tools/flow.py test cases/lab01-mix.json --webhook nano-answer
|
||||
POST http://localhost:5678/webhook/nano-answer · 7 case(s) × 1
|
||||
✔ FAQ · VPN · 3.7s · 자동 답변 · 3 calls · $0.000217
|
||||
✔ FAQ · 프린터 · 3.1s · 자동 답변 · 3 calls · $0.000245
|
||||
✔ FAQ · 비밀번호 · 3.0s · 자동 답변 · 3 calls · $0.000179
|
||||
✔ FAQ · 메일 · 3.3s · 자동 답변 · 3 calls · $0.000209
|
||||
✔ 접수 · T-1002 · 3.3s · 자동 답변 · 3 calls · $0.000190
|
||||
✔ 접수 · T-1001 · 3.2s · 자동 답변 · 3 calls · $0.000153
|
||||
✔ 규칙에 없는 질문 · 3.2s · 자동 답변 · 3 calls · $0.000196
|
||||
Passed 7/7
|
||||
report: runs/lab01-mix-20261028-210529.json
|
||||
@@ -0,0 +1,350 @@
|
||||
{
|
||||
"cases": "lab02-challenge.json",
|
||||
"webhook": "my-cost",
|
||||
"passed": 0,
|
||||
"total": 7,
|
||||
"results": [
|
||||
{
|
||||
"case": "faq-vpn · LLM 한 번 · $0.0002 이하",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"llm_calls = 3 (expected 1)",
|
||||
"cost_usd = 0.00027815 (expected 1e-06..0.0002)"
|
||||
],
|
||||
"seconds": 3.0,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 티켓은 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 359,
|
||||
"completion_tokens": 123,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 77,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.54e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 56,
|
||||
"cost_usd": 0.0001556
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 117,
|
||||
"completion_tokens": 59,
|
||||
"cost_usd": 9.715e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.00027815,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "faq-password · LLM 한 번 · $0.0002 이하",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"llm_calls = 3 (expected 1)",
|
||||
"cost_usd = 0.0002407 (expected 1e-06..0.0002)"
|
||||
],
|
||||
"seconds": 3.6,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
|
||||
"user": "한지훈"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 ‘비밀번호 찾기’에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 추가 도움이 필요하시면 문의해 주세요.",
|
||||
"sources": [
|
||||
"FAQ-001"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 353,
|
||||
"completion_tokens": 97,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 80,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.6e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 169,
|
||||
"completion_tokens": 43,
|
||||
"cost_usd": 0.0001364
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 104,
|
||||
"completion_tokens": 46,
|
||||
"cost_usd": 7.83e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0002407,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "faq-printer · LLM 한 번 · $0.0002 이하",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"llm_calls = 3 (expected 1)",
|
||||
"cost_usd = 0.0002674 (expected 1e-06..0.0002)"
|
||||
],
|
||||
"seconds": 3.5,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "최유진"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 확인한 뒤 인쇄 대기열을 비우고 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 349,
|
||||
"completion_tokens": 117,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 71,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.42e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 164,
|
||||
"completion_tokens": 53,
|
||||
"cost_usd": 0.0001504
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 114,
|
||||
"completion_tokens": 56,
|
||||
"cost_usd": 9.28e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0002674,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "faq-mail · LLM 한 번 · $0.0002 이하",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"llm_calls = 3 (expected 1)",
|
||||
"cost_usd = 0.00029665 (expected 1e-06..0.0002)"
|
||||
],
|
||||
"seconds": 3.5,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "메일함 용량이 꽉 찼대요",
|
||||
"user": "김민지"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "메일함 용량이 초과된 경우, 오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제하여 용량을 확보하시기 바랍니다. 현재 접수된 관련 티켓은 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-005"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 364,
|
||||
"completion_tokens": 135,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 73,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.46e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 167,
|
||||
"completion_tokens": 62,
|
||||
"cost_usd": 0.000166
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 124,
|
||||
"completion_tokens": 65,
|
||||
"cost_usd": 0.00010605
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.00029665,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "ticket-own · LLM 한 번 · $0.0002 이하",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"llm_calls = 3 (expected 1)",
|
||||
"cost_usd = 0.000238 (expected 1e-06..0.0002)"
|
||||
],
|
||||
"seconds": 4.0,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "T-1002 처리 상태 알려 주세요",
|
||||
"user": "박서연"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "T-1002 티켓은 현재 처리 중이며, 인증서 발급 승인 대기 중입니다. 10월 20일에 완료될 예정입니다. 추가 문의 사항이 있으시면 알려주시기 바랍니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 328,
|
||||
"completion_tokens": 101,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 70,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.4e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 152,
|
||||
"completion_tokens": 45,
|
||||
"cost_usd": 0.0001328
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 106,
|
||||
"completion_tokens": 48,
|
||||
"cost_usd": 8.12e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.000238,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "ticket-done · LLM 한 번 · $0.0002 이하",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"llm_calls = 3 (expected 1)"
|
||||
],
|
||||
"seconds": 3.2,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "T-1001 어떻게 됐나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "티켓 T-1001은 처리 완료되었으며, 새 배터리로 교체했습니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 297,
|
||||
"completion_tokens": 55,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 71,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.42e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 143,
|
||||
"completion_tokens": 22,
|
||||
"cost_usd": 9.24e-05
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 83,
|
||||
"completion_tokens": 25,
|
||||
"cost_usd": 4.785e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.00016445,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "규칙으로 모르는 질문은 분류 + 답",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"llm_calls = 3 (expected 2)"
|
||||
],
|
||||
"seconds": 3.1,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "회의실 화면 공유가 안 되는데 누구에게 물어보나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "죄송합니다. 요청하신 내용을 찾지 못했습니다. 담당 직원이 도움을 드리겠습니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 276,
|
||||
"completion_tokens": 47,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 77,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.54e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 124,
|
||||
"completion_tokens": 14,
|
||||
"cost_usd": 7.2e-05
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 75,
|
||||
"completion_tokens": 25,
|
||||
"cost_usd": 4.625e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.00014365,
|
||||
"recorded": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
$ uv run tools/flow.py test cases/lab02-challenge.json -v
|
||||
POST http://localhost:5678/webhook/my-cost · 7 case(s) × 1
|
||||
✘ faq-vpn · LLM 한 번 · $0.0002 이하 · 3.0s · 자동 답변 · 3 calls · $0.000278 · llm_calls = 3 (expected 1); cost_usd = 0.00027815 (expected 1e-06..0.0002)
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 티켓은 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 359,
|
||||
"completion_tokens": 123,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 77,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.54e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 56,
|
||||
"cost_usd": 0.0001556
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 117,
|
||||
"completion_tokens": 59,
|
||||
"cost_usd": 9.715e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.00027815,
|
||||
"recorded": true
|
||||
}
|
||||
✘ faq-password · LLM 한 번 · $0.0002 이하 · 3.6s · 자동 답변 · 3 calls · $0.000241 · llm_calls = 3 (expected 1); cost_usd = 0.0002407 (expected 1e-06..0.0002)
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
|
||||
"user": "한지훈"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 ‘비밀번호 찾기’에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 추가 도움이 필요하시면 문의해 주세요.",
|
||||
"sources": [
|
||||
"FAQ-001"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 353,
|
||||
"completion_tokens": 97,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 80,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.6e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 169,
|
||||
"completion_tokens": 43,
|
||||
"cost_usd": 0.0001364
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 104,
|
||||
"completion_tokens": 46,
|
||||
"cost_usd": 7.83e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0002407,
|
||||
"recorded": true
|
||||
}
|
||||
✘ faq-printer · LLM 한 번 · $0.0002 이하 · 3.5s · 자동 답변 · 3 calls · $0.000267 · llm_calls = 3 (expected 1); cost_usd = 0.0002674 (expected 1e-06..0.0002)
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "최유진"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 확인한 뒤 인쇄 대기열을 비우고 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 349,
|
||||
"completion_tokens": 117,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 71,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.42e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 164,
|
||||
"completion_tokens": 53,
|
||||
"cost_usd": 0.0001504
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 114,
|
||||
"completion_tokens": 56,
|
||||
"cost_usd": 9.28e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0002674,
|
||||
"recorded": true
|
||||
}
|
||||
✘ faq-mail · LLM 한 번 · $0.0002 이하 · 3.5s · 자동 답변 · 3 calls · $0.000297 · llm_calls = 3 (expected 1); cost_usd = 0.00029665 (expected 1e-06..0.0002)
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "메일함 용량이 꽉 찼대요",
|
||||
"user": "김민지"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "메일함 용량이 초과된 경우, 오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제하여 용량을 확보하시기 바랍니다. 현재 접수된 관련 티켓은 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-005"
|
||||
],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 364,
|
||||
"completion_tokens": 135,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 73,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.46e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 167,
|
||||
"completion_tokens": 62,
|
||||
"cost_usd": 0.000166
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 124,
|
||||
"completion_tokens": 65,
|
||||
"cost_usd": 0.00010605
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.00029665,
|
||||
"recorded": true
|
||||
}
|
||||
✘ ticket-own · LLM 한 번 · $0.0002 이하 · 4.0s · 자동 답변 · 3 calls · $0.000238 · llm_calls = 3 (expected 1); cost_usd = 0.000238 (expected 1e-06..0.0002)
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "T-1002 처리 상태 알려 주세요",
|
||||
"user": "박서연"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "T-1002 티켓은 현재 처리 중이며, 인증서 발급 승인 대기 중입니다. 10월 20일에 완료될 예정입니다. 추가 문의 사항이 있으시면 알려주시기 바랍니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 328,
|
||||
"completion_tokens": 101,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 70,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.4e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 152,
|
||||
"completion_tokens": 45,
|
||||
"cost_usd": 0.0001328
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 106,
|
||||
"completion_tokens": 48,
|
||||
"cost_usd": 8.12e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.000238,
|
||||
"recorded": true
|
||||
}
|
||||
✘ ticket-done · LLM 한 번 · $0.0002 이하 · 3.2s · 자동 답변 · 3 calls · $0.000164 · llm_calls = 3 (expected 1)
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "T-1001 어떻게 됐나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "티켓 T-1001은 처리 완료되었으며, 새 배터리로 교체했습니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 297,
|
||||
"completion_tokens": 55,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 71,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.42e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 143,
|
||||
"completion_tokens": 22,
|
||||
"cost_usd": 9.24e-05
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 83,
|
||||
"completion_tokens": 25,
|
||||
"cost_usd": 4.785e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.00016445,
|
||||
"recorded": true
|
||||
}
|
||||
✘ 규칙으로 모르는 질문은 분류 + 답 · 3.1s · 자동 답변 · 3 calls · $0.000144 · llm_calls = 3 (expected 2)
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "회의실 화면 공유가 안 되는데 누구에게 물어보나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "죄송합니다. 요청하신 내용을 찾지 못했습니다. 담당 직원이 도움을 드리겠습니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 3,
|
||||
"usage": {
|
||||
"prompt_tokens": 276,
|
||||
"completion_tokens": 47,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 77,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.54e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 124,
|
||||
"completion_tokens": 14,
|
||||
"cost_usd": 7.2e-05
|
||||
},
|
||||
"review": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 75,
|
||||
"completion_tokens": 25,
|
||||
"cost_usd": 4.625e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.00014365,
|
||||
"recorded": true
|
||||
}
|
||||
Passed 0/7
|
||||
report: runs/lab02-challenge-20261028-210554.json
|
||||
@@ -0,0 +1,253 @@
|
||||
{
|
||||
"cases": "lab02-challenge.json",
|
||||
"webhook": "my-cost",
|
||||
"passed": 7,
|
||||
"total": 7,
|
||||
"results": [
|
||||
{
|
||||
"case": "faq-vpn · LLM 한 번 · $0.0002 이하",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 2.8,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 티켓은 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 56,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 56,
|
||||
"cost_usd": 0.0001556
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001556,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "faq-password · LLM 한 번 · $0.0002 이하",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 1.2,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
|
||||
"user": "한지훈"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 도움이 필요하시면 언제든 문의해 주세요.",
|
||||
"sources": [
|
||||
"FAQ-001"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 169,
|
||||
"completion_tokens": 44,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 169,
|
||||
"completion_tokens": 44,
|
||||
"cost_usd": 0.000138
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.000138,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "faq-printer · LLM 한 번 · $0.0002 이하",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 1.2,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "최유진"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 164,
|
||||
"completion_tokens": 53,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 164,
|
||||
"completion_tokens": 53,
|
||||
"cost_usd": 0.0001504
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001504,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "faq-mail · LLM 한 번 · $0.0002 이하",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 1.5,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "메일함 용량이 꽉 찼대요",
|
||||
"user": "김민지"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "메일함 용량이 초과된 경우, 오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제하여 용량을 확보하시기 바랍니다. 현재 접수된 관련 티켓은 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-005"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 167,
|
||||
"completion_tokens": 62,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 167,
|
||||
"completion_tokens": 62,
|
||||
"cost_usd": 0.000166
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.000166,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "ticket-own · LLM 한 번 · $0.0002 이하",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 1.4,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "T-1002 처리 상태 알려 주세요",
|
||||
"user": "박서연"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "T-1002 티켓은 현재 처리 중이며, 인증서 발급 승인 대기 중입니다. 10월 20일에 완료될 예정입니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 152,
|
||||
"completion_tokens": 34,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 152,
|
||||
"completion_tokens": 34,
|
||||
"cost_usd": 0.0001152
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001152,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "ticket-done · LLM 한 번 · $0.0002 이하",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 0.9,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "T-1001 어떻게 됐나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "티켓 T-1001은 처리 완료되었으며, 새 배터리로 교체했습니다. 추가 문의 사항이 있으시면 말씀해 주세요.",
|
||||
"sources": [],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 143,
|
||||
"completion_tokens": 32,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 143,
|
||||
"completion_tokens": 32,
|
||||
"cost_usd": 0.0001084
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001084,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "규칙으로 모르는 질문은 분류 + 답",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 1.5,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "회의실 화면 공유가 안 되는데 누구에게 물어보나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "찾지 못했습니다. 담당 직원이 도움을 드리겠습니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 2,
|
||||
"usage": {
|
||||
"prompt_tokens": 201,
|
||||
"completion_tokens": 22,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 77,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.54e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 124,
|
||||
"completion_tokens": 14,
|
||||
"cost_usd": 7.2e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 9.74e-05,
|
||||
"recorded": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
$ uv run tools/flow.py push answers/75-lab02-challenge-one-call.json
|
||||
! unpublished '75 IT helpdesk + small models (reference)' (w5619722541404a5): it used the same webhook path
|
||||
✔ pushed '75 IT helpdesk + one LLM call (answer)' (id wbf73b5f2c309119)
|
||||
POST http://localhost:5678/webhook/my-cost
|
||||
|
||||
$ uv run tools/flow.py test cases/lab02-challenge.json -v
|
||||
POST http://localhost:5678/webhook/my-cost · 7 case(s) × 1
|
||||
✔ faq-vpn · LLM 한 번 · $0.0002 이하 · 2.8s · 자동 답변 · 1 call · $0.000156
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 티켓은 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 56,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 56,
|
||||
"cost_usd": 0.0001556
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001556,
|
||||
"recorded": true
|
||||
}
|
||||
✔ faq-password · LLM 한 번 · $0.0002 이하 · 1.2s · 자동 답변 · 1 call · $0.000138
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
|
||||
"user": "한지훈"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 도움이 필요하시면 언제든 문의해 주세요.",
|
||||
"sources": [
|
||||
"FAQ-001"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 169,
|
||||
"completion_tokens": 44,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 169,
|
||||
"completion_tokens": 44,
|
||||
"cost_usd": 0.000138
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.000138,
|
||||
"recorded": true
|
||||
}
|
||||
✔ faq-printer · LLM 한 번 · $0.0002 이하 · 1.2s · 자동 답변 · 1 call · $0.000150
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "최유진"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 164,
|
||||
"completion_tokens": 53,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 164,
|
||||
"completion_tokens": 53,
|
||||
"cost_usd": 0.0001504
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001504,
|
||||
"recorded": true
|
||||
}
|
||||
✔ faq-mail · LLM 한 번 · $0.0002 이하 · 1.5s · 자동 답변 · 1 call · $0.000166
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "메일함 용량이 꽉 찼대요",
|
||||
"user": "김민지"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "메일함 용량이 초과된 경우, 오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제하여 용량을 확보하시기 바랍니다. 현재 접수된 관련 티켓은 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-005"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 167,
|
||||
"completion_tokens": 62,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 167,
|
||||
"completion_tokens": 62,
|
||||
"cost_usd": 0.000166
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.000166,
|
||||
"recorded": true
|
||||
}
|
||||
✔ ticket-own · LLM 한 번 · $0.0002 이하 · 1.4s · 자동 답변 · 1 call · $0.000115
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "T-1002 처리 상태 알려 주세요",
|
||||
"user": "박서연"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "T-1002 티켓은 현재 처리 중이며, 인증서 발급 승인 대기 중입니다. 10월 20일에 완료될 예정입니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 152,
|
||||
"completion_tokens": 34,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 152,
|
||||
"completion_tokens": 34,
|
||||
"cost_usd": 0.0001152
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001152,
|
||||
"recorded": true
|
||||
}
|
||||
✔ ticket-done · LLM 한 번 · $0.0002 이하 · 0.9s · 자동 답변 · 1 call · $0.000108
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "T-1001 어떻게 됐나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "티켓 T-1001은 처리 완료되었으며, 새 배터리로 교체했습니다. 추가 문의 사항이 있으시면 말씀해 주세요.",
|
||||
"sources": [],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 143,
|
||||
"completion_tokens": 32,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 143,
|
||||
"completion_tokens": 32,
|
||||
"cost_usd": 0.0001084
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001084,
|
||||
"recorded": true
|
||||
}
|
||||
✔ 규칙으로 모르는 질문은 분류 + 답 · 1.5s · 자동 답변 · 2 calls · $0.000097
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "회의실 화면 공유가 안 되는데 누구에게 물어보나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "찾지 못했습니다. 담당 직원이 도움을 드리겠습니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 2,
|
||||
"usage": {
|
||||
"prompt_tokens": 201,
|
||||
"completion_tokens": 22,
|
||||
"by_step": {
|
||||
"classify": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 77,
|
||||
"completion_tokens": 8,
|
||||
"cost_usd": 2.54e-05
|
||||
},
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 124,
|
||||
"completion_tokens": 14,
|
||||
"cost_usd": 7.2e-05
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 9.74e-05,
|
||||
"recorded": true
|
||||
}
|
||||
Passed 7/7
|
||||
report: runs/lab02-challenge-20261028-210617.json
|
||||
|
||||
$ uv run tools/flow.py test cases/lab02.json
|
||||
POST http://localhost:5678/webhook/my-cost · 5 case(s) × 1
|
||||
✔ 예제 1 · VPN · 보내는 양 줄이기 · 1.2s · 자동 답변 · 1 call · $0.000156
|
||||
✔ 예제 1 · 접수 · 보내는 양 줄이기 · 1.0s · 자동 답변 · 1 call · $0.000115
|
||||
✔ 예제 1 · 비밀번호 · 품질 그대로 · 1.5s · 자동 답변 · 1 call · $0.000136
|
||||
✔ 예제 2 · 분류는 작은 모델 · 2.1s · 자동 답변 · 2 calls · $0.000097
|
||||
✔ 예제 2 · 프린터 · 품질 그대로 · 1.2s · 자동 답변 · 1 call · $0.000152
|
||||
Passed 5/5
|
||||
report: runs/lab02-20261028-210624.json
|
||||
|
||||
$ uv run tools/flow.py test cases/lab01.json
|
||||
POST http://localhost:5678/webhook/my-cost · 3 case(s) × 1
|
||||
✔ VPN · 비용과 단계별 사용량 · 1.4s · 자동 답변 · 1 call · $0.000156
|
||||
✔ 접수 현황 · 비용 · 1.2s · 자동 답변 · 1 call · $0.000115
|
||||
✔ 빈 질문은 LLM 없이 · 0.0s
|
||||
Passed 3/3
|
||||
report: runs/lab01-20261028-210627.json
|
||||
|
||||
$ uv run tools/flow.py test cases/lab01-challenge.json
|
||||
✔ before: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 157}
|
||||
POST http://localhost:5678/webhook/my-cost · 4 case(s) × 1
|
||||
✔ 기록 1 · VPN · 1.2s · 자동 답변 · 1 call · $0.000156
|
||||
✔ 기록 2 · 접수 · 1.0s · 자동 답변 · 1 call · $0.000115
|
||||
✔ 기록 3 · 다른 사람 · 1.4s · 자동 답변 · 1 call · $0.000138
|
||||
✔ 사용량 요약 · 요청 3건 · 0.0s · $0.000409
|
||||
Passed 4/4
|
||||
report: runs/lab01-challenge-20261028-210630.json
|
||||
@@ -0,0 +1,160 @@
|
||||
{
|
||||
"cases": "lab02-challenge2.json",
|
||||
"webhook": "my-cost",
|
||||
"passed": 0,
|
||||
"total": 4,
|
||||
"results": [
|
||||
{
|
||||
"case": "긴 로그 + 끝에 질문 · 프린터",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"trimmed = null (expected true)",
|
||||
"cost_usd = 0.001196 (expected 1e-06..0.0003)"
|
||||
],
|
||||
"seconds": 1.3,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "아래는 프린터 로그를 그대로 붙인 것입니다.\n2026-10-29 09:10:05 spooler[2231]: job 4100 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:10:35 spooler[2231]: job 4101 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:11:05 spooler[2231]: job 4102 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:11:35 spooler[2231]: job 4103 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:12:05 spooler[2231]: job 4104 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:12:35 spooler[2231]: job 4105 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:13:05 spooler[2231]: job 4106 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:13:35 spooler[2231]: job 4107 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:14:05 spooler[2231]: job 4108 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:14:35 spooler[2231]: job 4109 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:15:05 spooler[2231]: job 4110 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:15:35 spooler[2231]: job 4111 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:16:05 spooler[2231]: job 4112 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:16:35 spooler[2231]: job 4113 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:17:05 spooler[2231]: job 4114 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:17:35 spooler[2231]: job 4115 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:18:05 spooler[2231]: job 4116 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:18:35 spooler[2231]: job 4117 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:19:05 spooler[2231]: job 4118 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:19:35 spooler[2231]: job 4119 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:20:05 spooler[2231]: job 4120 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:20:35 spooler[2231]: job 4121 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:21:05 spooler[2231]: job 4122 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:21:35 spooler[2231]: job 4123 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:22:05 spooler[2231]: job 4124 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:22:35 spooler[2231]: job 4125 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:23:05 spooler[2231]: job 4126 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:23:35 spooler[2231]: job 4127 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:24:05 spooler[2231]: job 4128 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:24:35 spooler[2231]: job 4129 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:25:05 spooler[2231]: job 4130 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:25:35 spooler[2231]: job 4131 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:26:05 spooler[2231]: job 4132 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:26:35 spooler[2231]: job 4133 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:27:05 spooler[2231]: job 4134 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:27:35 spooler[2231]: job 4135 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:28:05 spooler[2231]: job 4136 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:28:35 spooler[2231]: job 4137 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:29:05 spooler[2231]: job 4138 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:29:35 spooler[2231]: job 4139 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:30:05 spooler[2231]: job 4140 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:30:35 spooler[2231]: job 4141 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:31:05 spooler[2231]: job 4142 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:31:35 spooler[2231]: job 4143 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:32:05 spooler[2231]: job 4144 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:32:35 spooler[2231]: job 4145 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:33:05 spooler[2231]: job 4146 state=held queue=FL3-PRN-01 reason=paused-Line truncated
|
||||
"user": "최유진"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 먼저 확인하시고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 티켓은 없으며, 추가 도움이 필요하시면 직원이 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 2766,
|
||||
"completion_tokens": 56,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 2766,
|
||||
"completion_tokens": 56,
|
||||
"cost_usd": 0.001196
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.001196,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "앞에 질문 + 긴 로그 · VPN",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"trimmed = null (expected true)",
|
||||
"cost_usd = 0.0010992 (expected 1e-06..0.0003)"
|
||||
],
|
||||
"seconds": 1.4,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요? 아래는 접속 프로그램 로그입니다.\n2026-10-29 08:10:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=40 state=reconnecting\n2026-10-29 08:10:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=41 state=reconnecting\n2026-10-29 08:11:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=42 state=reconnecting\n2026-10-29 08:11:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=43 state=reconnecting\n2026-10-29 08:12:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=44 state=reconnecting\n2026-10-29 08:12:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=45 state=reconnecting\n2026-10-29 08:13:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=46 state=reconnecting\n2026-10-29 08:13:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=47 state=reconnecting\n2026-10-29 08:14:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=48 state=reconnecting\n2026-10-29 08:14:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=49 state=reconnecting\n2026-10-29 08:15:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=50 state=reconnecting\n2026-10-29 08:15:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=51 state=reconnecting\n2026-10-29 08:16:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=52 state=reconnecting\n2026-10-29 08:16:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=53 state=reconnecting\n2026-10-29 08:17:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=54 state=reconnecting\n2026-10-29 08:17:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=55 state=reconnecting\n2026-10-29 08:18:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=56 state=reconnecting\n2026-10-29 08:18:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=57 state=reconnecting\n2026-10-29 08:19:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=58 state=reconnecting\n2026-10-29 08:19:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=59 state=reconnecting\n2026-10-29 08:20:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=60 state=reconnecting\n2026-10-29 08:20:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=61 state=reconnecting\n2026-10-29 08:21:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=62 state=reconnecting\n2026-10-29 08:21:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=63 state=reconnecting\n2026-10-29 08:22:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=64 state=reconnecting\n2026-10-29 08:22:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=65 state=reconnecting\n2026-10-29 08:23:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=66 state=reconnecting\n2026-10-29 08:23:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=67 state=reconnecting\n2026-10-29 08:24:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=68 state=reconnecting\n2026-10-29 08:24:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=69 state=reconnecting\n2026-10-29 08:25:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=40 state=reconnecting\n2026-10-29 08:25:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=41 state=reconnecting\n2026-10-29 08:26:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=42 state=reconnecting\n2026-10-29 08:26:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=43 state=reconnecting\n2026-10-29 08:27:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=44 state=reconnecting\n2026-10-29 08:27:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=45 state=reconnecting\n2026-10-29 08:28:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=46 state=reconnecting\n2026-10-29 08:28:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=47 state=reconnecting\n2026-10-29 08:29:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=48 state=reconnecting\n2026-10-29 08:29:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=49 state=reconnecting\n2026-10-29 08:30:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=50 state=reconnecting\n2026-10-29 08:30:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=51 state=reconnecting\n2026-10-29 08:31:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=52 state=reconnecting\n2026-10-29 08:31:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=53 state=reconnecting\n2026-10-29 08:32:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=54 state=reconnecting\n2026-10-29 08:32:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=55 state=reconnecting\n2026-10-29 08:33:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=56 state=reconnecting\n2026-10-29 08:33:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=57 sLine truncated
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 티켓이 없어 별도의 상태 안내는 어렵습니다. 추가 도움이 필요하시면 IT 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003",
|
||||
"FAQ-006"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 2496,
|
||||
"completion_tokens": 63,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 2496,
|
||||
"completion_tokens": 63,
|
||||
"cost_usd": 0.0010992
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0010992,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "짧은 질문은 그대로",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"trimmed = null (expected false)",
|
||||
"question_chars = null (expected 24)"
|
||||
],
|
||||
"seconds": 1.2,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 티켓은 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 56,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 56,
|
||||
"cost_usd": 0.0001556
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001556,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "사람이 한도를 늘리면 자르지 않음",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"trimmed = null (expected false)"
|
||||
],
|
||||
"seconds": 1.2,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "아래는 프린터 로그를 그대로 붙인 것입니다.\n2026-10-29 09:10:05 spooler[2231]: job 4100 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:10:35 spooler[2231]: job 4101 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:11:05 spooler[2231]: job 4102 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:11:35 spooler[2231]: job 4103 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:12:05 spooler[2231]: job 4104 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:12:35 spooler[2231]: job 4105 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:13:05 spooler[2231]: job 4106 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:13:35 spooler[2231]: job 4107 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:14:05 spooler[2231]: job 4108 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:14:35 spooler[2231]: job 4109 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:15:05 spooler[2231]: job 4110 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:15:35 spooler[2231]: job 4111 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:16:05 spooler[2231]: job 4112 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:16:35 spooler[2231]: job 4113 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:17:05 spooler[2231]: job 4114 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:17:35 spooler[2231]: job 4115 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:18:05 spooler[2231]: job 4116 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:18:35 spooler[2231]: job 4117 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:19:05 spooler[2231]: job 4118 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:19:35 spooler[2231]: job 4119 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:20:05 spooler[2231]: job 4120 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:20:35 spooler[2231]: job 4121 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:21:05 spooler[2231]: job 4122 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:21:35 spooler[2231]: job 4123 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:22:05 spooler[2231]: job 4124 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:22:35 spooler[2231]: job 4125 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:23:05 spooler[2231]: job 4126 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:23:35 spooler[2231]: job 4127 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:24:05 spooler[2231]: job 4128 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:24:35 spooler[2231]: job 4129 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:25:05 spooler[2231]: job 4130 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:25:35 spooler[2231]: job 4131 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:26:05 spooler[2231]: job 4132 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:26:35 spooler[2231]: job 4133 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:27:05 spooler[2231]: job 4134 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:27:35 spooler[2231]: job 4135 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:28:05 spooler[2231]: job 4136 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:28:35 spooler[2231]: job 4137 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:29:05 spooler[2231]: job 4138 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:29:35 spooler[2231]: job 4139 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:30:05 spooler[2231]: job 4140 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:30:35 spooler[2231]: job 4141 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:31:05 spooler[2231]: job 4142 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:31:35 spooler[2231]: job 4143 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:32:05 spooler[2231]: job 4144 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:32:35 spooler[2231]: job 4145 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:33:05 spooler[2231]: job 4146 state=held queue=FL3-PRN-01 reason=paused-Line truncated
|
||||
"user": "최유진"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 먼저 확인하시고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 티켓은 없으며, 추가 도움이 필요하시면 직원이 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 2766,
|
||||
"completion_tokens": 56,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 2766,
|
||||
"completion_tokens": 56,
|
||||
"cost_usd": 0.001196
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.001196,
|
||||
"recorded": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
$ uv run tools/flow.py test cases/lab02-challenge2.json -v
|
||||
✔ before: POST http://localhost:8800/budget/reset · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0.0004088, "remaining_usd": 0.999591
|
||||
→ request POST http://localhost:8800/budget/reset
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 1.0,
|
||||
"user_limit_per_min": 20,
|
||||
"max_question_chars": 500,
|
||||
"used_usd": 0.0004088,
|
||||
"remaining_usd": 0.9995912,
|
||||
"over": false
|
||||
}
|
||||
POST http://localhost:5678/webhook/my-cost · 4 case(s) × 1
|
||||
✘ 긴 로그 + 끝에 질문 · 프린터 · 1.3s · 자동 답변 · 1 call · $0.001196 · trimmed = null (expected true); cost_usd = 0.001196 (expected 1e-06..0.0003)
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "아래는 프린터 로그를 그대로 붙인 것입니다.\n2026-10-29 09:10:05 spooler[2231]: job 4100 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:10:35 spooler[2231]: job 4101 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:11:05 spooler[2231]: job 4102 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:11:35 spooler[2231]: job 4103 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:12:05 spooler[2231]: job 4104 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:12:35 spooler[2231]: job 4105 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:13:05 spooler[2231]: job 4106 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:13:35 spooler[2231]: job 4107 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:14:05 spooler[2231]: job 4108 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:14:35 spooler[2231]: job 4109 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:15:05 spooler[2231]: job 4110 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:15:35 spooler[2231]: job 4111 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:16:05 spooler[2231]: job 4112 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:16:35 spooler[2231]: job 4113 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:17:05 spooler[2231]: job 4114 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:17:35 spooler[2231]: job 4115 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:18:05 spooler[2231]: job 4116 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:18:35 spooler[2231]: job 4117 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:19:05 spooler[2231]: job 4118 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:19:35 spooler[2231]: job 4119 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:20:05 spooler[2231]: job 4120 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:20:35 spooler[2231]: job 4121 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:21:05 spooler[2231]: job 4122 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:21:35 spooler[2231]: job 4123 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:22:05 spooler[2231]: job 4124 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:22:35 spooler[2231]: job 4125 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:23:05 spooler[2231]: job 4126 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:23:35 spooler[2231]: job 4127 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:24:05 spooler[2231]: job 4128 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:24:35 spooler[2231]: job 4129 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:25:05 spooler[2231]: job 4130 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:25:35 spooler[2231]: job 4131 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:26:05 spooler[2231]: job 4132 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:26:35 spooler[2231]: job 4133 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:27:05 spooler[2231]: job 4134 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:27:35 spooler[2231]: job 4135 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:28:05 spooler[2231]: job 4136 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:28:35 spooler[2231]: job 4137 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:29:05 spooler[2231]: job 4138 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:29:35 spooler[2231]: job 4139 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:30:05 spooler[2231]: job 4140 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:30:35 spooler[2231]: job 4141 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:31:05 spooler[2231]: job 4142 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:31:35 spooler[2231]: job 4143 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:32:05 spooler[2231]: job 4144 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:32:35 spooler[2231]: job 4145 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:33:05 spooler[2231]: job 4146 state=held queue=FL3-PRN-01 reason=paused-Line truncated
|
||||
"user": "최유진"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 먼저 확인하시고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 티켓은 없으며, 추가 도움이 필요하시면 직원이 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 2766,
|
||||
"completion_tokens": 56,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 2766,
|
||||
"completion_tokens": 56,
|
||||
"cost_usd": 0.001196
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.001196,
|
||||
"recorded": true
|
||||
}
|
||||
✘ 앞에 질문 + 긴 로그 · VPN · 1.4s · 자동 답변 · 1 call · $0.001099 · trimmed = null (expected true); cost_usd = 0.0010992 (expected 1e-06..0.0003)
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요? 아래는 접속 프로그램 로그입니다.\n2026-10-29 08:10:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=40 state=reconnecting\n2026-10-29 08:10:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=41 state=reconnecting\n2026-10-29 08:11:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=42 state=reconnecting\n2026-10-29 08:11:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=43 state=reconnecting\n2026-10-29 08:12:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=44 state=reconnecting\n2026-10-29 08:12:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=45 state=reconnecting\n2026-10-29 08:13:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=46 state=reconnecting\n2026-10-29 08:13:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=47 state=reconnecting\n2026-10-29 08:14:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=48 state=reconnecting\n2026-10-29 08:14:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=49 state=reconnecting\n2026-10-29 08:15:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=50 state=reconnecting\n2026-10-29 08:15:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=51 state=reconnecting\n2026-10-29 08:16:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=52 state=reconnecting\n2026-10-29 08:16:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=53 state=reconnecting\n2026-10-29 08:17:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=54 state=reconnecting\n2026-10-29 08:17:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=55 state=reconnecting\n2026-10-29 08:18:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=56 state=reconnecting\n2026-10-29 08:18:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=57 state=reconnecting\n2026-10-29 08:19:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=58 state=reconnecting\n2026-10-29 08:19:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=59 state=reconnecting\n2026-10-29 08:20:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=60 state=reconnecting\n2026-10-29 08:20:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=61 state=reconnecting\n2026-10-29 08:21:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=62 state=reconnecting\n2026-10-29 08:21:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=63 state=reconnecting\n2026-10-29 08:22:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=64 state=reconnecting\n2026-10-29 08:22:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=65 state=reconnecting\n2026-10-29 08:23:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=66 state=reconnecting\n2026-10-29 08:23:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=67 state=reconnecting\n2026-10-29 08:24:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=68 state=reconnecting\n2026-10-29 08:24:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=69 state=reconnecting\n2026-10-29 08:25:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=40 state=reconnecting\n2026-10-29 08:25:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=41 state=reconnecting\n2026-10-29 08:26:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=42 state=reconnecting\n2026-10-29 08:26:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=43 state=reconnecting\n2026-10-29 08:27:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=44 state=reconnecting\n2026-10-29 08:27:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=45 state=reconnecting\n2026-10-29 08:28:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=46 state=reconnecting\n2026-10-29 08:28:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=47 state=reconnecting\n2026-10-29 08:29:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=48 state=reconnecting\n2026-10-29 08:29:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=49 state=reconnecting\n2026-10-29 08:30:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=50 state=reconnecting\n2026-10-29 08:30:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=51 state=reconnecting\n2026-10-29 08:31:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=52 state=reconnecting\n2026-10-29 08:31:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=53 state=reconnecting\n2026-10-29 08:32:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=54 state=reconnecting\n2026-10-29 08:32:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=55 state=reconnecting\n2026-10-29 08:33:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=56 state=reconnecting\n2026-10-29 08:33:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=57 sLine truncated
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 티켓이 없어 별도의 상태 안내는 어렵습니다. 추가 도움이 필요하시면 IT 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003",
|
||||
"FAQ-006"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 2496,
|
||||
"completion_tokens": 63,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 2496,
|
||||
"completion_tokens": 63,
|
||||
"cost_usd": 0.0010992
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0010992,
|
||||
"recorded": true
|
||||
}
|
||||
✘ 짧은 질문은 그대로 · 1.2s · 자동 답변 · 1 call · $0.000156 · trimmed = null (expected false); question_chars = null (expected 24)
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 티켓은 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 56,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 56,
|
||||
"cost_usd": 0.0001556
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001556,
|
||||
"recorded": true
|
||||
}
|
||||
✔ before: POST http://localhost:8800/budget · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 10000, "used_usd": 0.0028596, "remaining_usd": 0.9971
|
||||
→ request POST http://localhost:8800/budget
|
||||
{
|
||||
"max_question_chars": 10000
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 1.0,
|
||||
"user_limit_per_min": 20,
|
||||
"max_question_chars": 10000,
|
||||
"used_usd": 0.0028596,
|
||||
"remaining_usd": 0.9971404,
|
||||
"over": false
|
||||
}
|
||||
✘ 사람이 한도를 늘리면 자르지 않음 · 1.2s · 자동 답변 · 1 call · $0.001196 · trimmed = null (expected false)
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "아래는 프린터 로그를 그대로 붙인 것입니다.\n2026-10-29 09:10:05 spooler[2231]: job 4100 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:10:35 spooler[2231]: job 4101 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:11:05 spooler[2231]: job 4102 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:11:35 spooler[2231]: job 4103 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:12:05 spooler[2231]: job 4104 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:12:35 spooler[2231]: job 4105 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:13:05 spooler[2231]: job 4106 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:13:35 spooler[2231]: job 4107 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:14:05 spooler[2231]: job 4108 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:14:35 spooler[2231]: job 4109 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:15:05 spooler[2231]: job 4110 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:15:35 spooler[2231]: job 4111 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:16:05 spooler[2231]: job 4112 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:16:35 spooler[2231]: job 4113 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:17:05 spooler[2231]: job 4114 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:17:35 spooler[2231]: job 4115 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:18:05 spooler[2231]: job 4116 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:18:35 spooler[2231]: job 4117 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:19:05 spooler[2231]: job 4118 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:19:35 spooler[2231]: job 4119 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:20:05 spooler[2231]: job 4120 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:20:35 spooler[2231]: job 4121 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:21:05 spooler[2231]: job 4122 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:21:35 spooler[2231]: job 4123 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:22:05 spooler[2231]: job 4124 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:22:35 spooler[2231]: job 4125 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:23:05 spooler[2231]: job 4126 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:23:35 spooler[2231]: job 4127 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:24:05 spooler[2231]: job 4128 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:24:35 spooler[2231]: job 4129 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:25:05 spooler[2231]: job 4130 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:25:35 spooler[2231]: job 4131 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:26:05 spooler[2231]: job 4132 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:26:35 spooler[2231]: job 4133 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:27:05 spooler[2231]: job 4134 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:27:35 spooler[2231]: job 4135 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:28:05 spooler[2231]: job 4136 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:28:35 spooler[2231]: job 4137 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:29:05 spooler[2231]: job 4138 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:29:35 spooler[2231]: job 4139 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:30:05 spooler[2231]: job 4140 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:30:35 spooler[2231]: job 4141 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:31:05 spooler[2231]: job 4142 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:31:35 spooler[2231]: job 4143 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:32:05 spooler[2231]: job 4144 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:32:35 spooler[2231]: job 4145 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:33:05 spooler[2231]: job 4146 state=held queue=FL3-PRN-01 reason=paused-Line truncated
|
||||
"user": "최유진"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 먼저 확인하시고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 티켓은 없으며, 추가 도움이 필요하시면 직원이 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 2766,
|
||||
"completion_tokens": 56,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 2766,
|
||||
"completion_tokens": 56,
|
||||
"cost_usd": 0.001196
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.001196,
|
||||
"recorded": true
|
||||
}
|
||||
Passed 0/4
|
||||
report: runs/lab02-challenge2-20261028-210636.json
|
||||
✔ after: POST http://localhost:8800/budget/reset · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0.0040556, "remaining_usd": 0.995944
|
||||
→ request POST http://localhost:8800/budget/reset
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 1.0,
|
||||
"user_limit_per_min": 20,
|
||||
"max_question_chars": 500,
|
||||
"used_usd": 0.0040556,
|
||||
"remaining_usd": 0.9959444,
|
||||
"over": false
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
{
|
||||
"cases": "lab02-challenge2.json",
|
||||
"webhook": "my-cost",
|
||||
"passed": 4,
|
||||
"total": 4,
|
||||
"results": [
|
||||
{
|
||||
"case": "긴 로그 + 끝에 질문 · 프린터",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 2.3,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "아래는 프린터 로그를 그대로 붙인 것입니다.\n2026-10-29 09:10:05 spooler[2231]: job 4100 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:10:35 spooler[2231]: job 4101 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:11:05 spooler[2231]: job 4102 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:11:35 spooler[2231]: job 4103 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:12:05 spooler[2231]: job 4104 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:12:35 spooler[2231]: job 4105 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:13:05 spooler[2231]: job 4106 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:13:35 spooler[2231]: job 4107 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:14:05 spooler[2231]: job 4108 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:14:35 spooler[2231]: job 4109 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:15:05 spooler[2231]: job 4110 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:15:35 spooler[2231]: job 4111 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:16:05 spooler[2231]: job 4112 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:16:35 spooler[2231]: job 4113 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:17:05 spooler[2231]: job 4114 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:17:35 spooler[2231]: job 4115 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:18:05 spooler[2231]: job 4116 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:18:35 spooler[2231]: job 4117 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:19:05 spooler[2231]: job 4118 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:19:35 spooler[2231]: job 4119 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:20:05 spooler[2231]: job 4120 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:20:35 spooler[2231]: job 4121 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:21:05 spooler[2231]: job 4122 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:21:35 spooler[2231]: job 4123 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:22:05 spooler[2231]: job 4124 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:22:35 spooler[2231]: job 4125 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:23:05 spooler[2231]: job 4126 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:23:35 spooler[2231]: job 4127 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:24:05 spooler[2231]: job 4128 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:24:35 spooler[2231]: job 4129 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:25:05 spooler[2231]: job 4130 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:25:35 spooler[2231]: job 4131 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:26:05 spooler[2231]: job 4132 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:26:35 spooler[2231]: job 4133 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:27:05 spooler[2231]: job 4134 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:27:35 spooler[2231]: job 4135 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:28:05 spooler[2231]: job 4136 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:28:35 spooler[2231]: job 4137 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:29:05 spooler[2231]: job 4138 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:29:35 spooler[2231]: job 4139 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:30:05 spooler[2231]: job 4140 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:30:35 spooler[2231]: job 4141 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:31:05 spooler[2231]: job 4142 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:31:35 spooler[2231]: job 4143 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:32:05 spooler[2231]: job 4144 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:32:35 spooler[2231]: job 4145 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:33:05 spooler[2231]: job 4146 state=held queue=FL3-PRN-01 reason=paused-Line truncated
|
||||
"user": "최유진"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 확인하시고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 티켓은 없으며, 추가 도움이 필요하시면 직원이 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 372,
|
||||
"completion_tokens": 55,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 372,
|
||||
"completion_tokens": 55,
|
||||
"cost_usd": 0.0002368
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0002368,
|
||||
"trimmed": true,
|
||||
"question_chars": 6298,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "앞에 질문 + 긴 로그 · VPN",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 1.1,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요? 아래는 접속 프로그램 로그입니다.\n2026-10-29 08:10:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=40 state=reconnecting\n2026-10-29 08:10:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=41 state=reconnecting\n2026-10-29 08:11:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=42 state=reconnecting\n2026-10-29 08:11:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=43 state=reconnecting\n2026-10-29 08:12:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=44 state=reconnecting\n2026-10-29 08:12:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=45 state=reconnecting\n2026-10-29 08:13:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=46 state=reconnecting\n2026-10-29 08:13:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=47 state=reconnecting\n2026-10-29 08:14:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=48 state=reconnecting\n2026-10-29 08:14:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=49 state=reconnecting\n2026-10-29 08:15:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=50 state=reconnecting\n2026-10-29 08:15:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=51 state=reconnecting\n2026-10-29 08:16:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=52 state=reconnecting\n2026-10-29 08:16:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=53 state=reconnecting\n2026-10-29 08:17:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=54 state=reconnecting\n2026-10-29 08:17:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=55 state=reconnecting\n2026-10-29 08:18:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=56 state=reconnecting\n2026-10-29 08:18:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=57 state=reconnecting\n2026-10-29 08:19:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=58 state=reconnecting\n2026-10-29 08:19:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=59 state=reconnecting\n2026-10-29 08:20:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=60 state=reconnecting\n2026-10-29 08:20:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=61 state=reconnecting\n2026-10-29 08:21:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=62 state=reconnecting\n2026-10-29 08:21:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=63 state=reconnecting\n2026-10-29 08:22:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=64 state=reconnecting\n2026-10-29 08:22:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=65 state=reconnecting\n2026-10-29 08:23:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=66 state=reconnecting\n2026-10-29 08:23:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=67 state=reconnecting\n2026-10-29 08:24:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=68 state=reconnecting\n2026-10-29 08:24:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=69 state=reconnecting\n2026-10-29 08:25:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=40 state=reconnecting\n2026-10-29 08:25:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=41 state=reconnecting\n2026-10-29 08:26:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=42 state=reconnecting\n2026-10-29 08:26:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=43 state=reconnecting\n2026-10-29 08:27:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=44 state=reconnecting\n2026-10-29 08:27:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=45 state=reconnecting\n2026-10-29 08:28:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=46 state=reconnecting\n2026-10-29 08:28:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=47 state=reconnecting\n2026-10-29 08:29:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=48 state=reconnecting\n2026-10-29 08:29:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=49 state=reconnecting\n2026-10-29 08:30:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=50 state=reconnecting\n2026-10-29 08:30:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=51 state=reconnecting\n2026-10-29 08:31:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=52 state=reconnecting\n2026-10-29 08:31:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=53 state=reconnecting\n2026-10-29 08:32:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=54 state=reconnecting\n2026-10-29 08:32:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=55 state=reconnecting\n2026-10-29 08:33:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=56 state=reconnecting\n2026-10-29 08:33:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=57 sLine truncated
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하는 것을 권장합니다. 현재 접수된 티켓이 없어 상태를 확인할 수 없으며, 추가 도움이 필요하면 IT 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003",
|
||||
"FAQ-006"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 394,
|
||||
"completion_tokens": 64,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 394,
|
||||
"completion_tokens": 64,
|
||||
"cost_usd": 0.00026
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.00026,
|
||||
"trimmed": true,
|
||||
"question_chars": 6103,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "짧은 질문은 그대로",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 1.1,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 47,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 47,
|
||||
"cost_usd": 0.0001412
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001412,
|
||||
"trimmed": false,
|
||||
"question_chars": 24,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "사람이 한도를 늘리면 자르지 않음",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 1.3,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "아래는 프린터 로그를 그대로 붙인 것입니다.\n2026-10-29 09:10:05 spooler[2231]: job 4100 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:10:35 spooler[2231]: job 4101 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:11:05 spooler[2231]: job 4102 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:11:35 spooler[2231]: job 4103 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:12:05 spooler[2231]: job 4104 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:12:35 spooler[2231]: job 4105 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:13:05 spooler[2231]: job 4106 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:13:35 spooler[2231]: job 4107 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:14:05 spooler[2231]: job 4108 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:14:35 spooler[2231]: job 4109 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:15:05 spooler[2231]: job 4110 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:15:35 spooler[2231]: job 4111 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:16:05 spooler[2231]: job 4112 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:16:35 spooler[2231]: job 4113 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:17:05 spooler[2231]: job 4114 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:17:35 spooler[2231]: job 4115 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:18:05 spooler[2231]: job 4116 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:18:35 spooler[2231]: job 4117 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:19:05 spooler[2231]: job 4118 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:19:35 spooler[2231]: job 4119 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:20:05 spooler[2231]: job 4120 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:20:35 spooler[2231]: job 4121 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:21:05 spooler[2231]: job 4122 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:21:35 spooler[2231]: job 4123 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:22:05 spooler[2231]: job 4124 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:22:35 spooler[2231]: job 4125 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:23:05 spooler[2231]: job 4126 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:23:35 spooler[2231]: job 4127 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:24:05 spooler[2231]: job 4128 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:24:35 spooler[2231]: job 4129 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:25:05 spooler[2231]: job 4130 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:25:35 spooler[2231]: job 4131 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:26:05 spooler[2231]: job 4132 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:26:35 spooler[2231]: job 4133 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:27:05 spooler[2231]: job 4134 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:27:35 spooler[2231]: job 4135 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:28:05 spooler[2231]: job 4136 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:28:35 spooler[2231]: job 4137 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:29:05 spooler[2231]: job 4138 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:29:35 spooler[2231]: job 4139 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:30:05 spooler[2231]: job 4140 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:30:35 spooler[2231]: job 4141 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:31:05 spooler[2231]: job 4142 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:31:35 spooler[2231]: job 4143 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:32:05 spooler[2231]: job 4144 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:32:35 spooler[2231]: job 4145 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:33:05 spooler[2231]: job 4146 state=held queue=FL3-PRN-01 reason=paused-Line truncated
|
||||
"user": "최유진"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 먼저 확인하시고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 티켓은 없으며, 추가 도움이 필요하시면 직원이 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 2766,
|
||||
"completion_tokens": 56,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 2766,
|
||||
"completion_tokens": 56,
|
||||
"cost_usd": 0.001196
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.001196,
|
||||
"trimmed": false,
|
||||
"question_chars": 6298,
|
||||
"recorded": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
$ uv run tools/flow.py push answers/75-lab02-challenge2-long-question.json
|
||||
! unpublished '75 IT helpdesk + one LLM call (answer)' (wbf73b5f2c309119): it used the same webhook path
|
||||
✔ pushed '75 IT helpdesk + long question cap (answer)' (id w3d4ba9195f92a57)
|
||||
POST http://localhost:5678/webhook/my-cost
|
||||
|
||||
$ uv run tools/flow.py test cases/lab02-challenge2.json -v
|
||||
✔ before: POST http://localhost:8800/budget/reset · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0.0040556, "remaining_usd": 0.995944
|
||||
→ request POST http://localhost:8800/budget/reset
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 1.0,
|
||||
"user_limit_per_min": 20,
|
||||
"max_question_chars": 500,
|
||||
"used_usd": 0.0040556,
|
||||
"remaining_usd": 0.9959444,
|
||||
"over": false
|
||||
}
|
||||
POST http://localhost:5678/webhook/my-cost · 4 case(s) × 1
|
||||
✔ 긴 로그 + 끝에 질문 · 프린터 · 2.3s · 자동 답변 · 1 call · $0.000237
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "아래는 프린터 로그를 그대로 붙인 것입니다.\n2026-10-29 09:10:05 spooler[2231]: job 4100 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:10:35 spooler[2231]: job 4101 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:11:05 spooler[2231]: job 4102 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:11:35 spooler[2231]: job 4103 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:12:05 spooler[2231]: job 4104 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:12:35 spooler[2231]: job 4105 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:13:05 spooler[2231]: job 4106 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:13:35 spooler[2231]: job 4107 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:14:05 spooler[2231]: job 4108 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:14:35 spooler[2231]: job 4109 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:15:05 spooler[2231]: job 4110 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:15:35 spooler[2231]: job 4111 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:16:05 spooler[2231]: job 4112 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:16:35 spooler[2231]: job 4113 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:17:05 spooler[2231]: job 4114 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:17:35 spooler[2231]: job 4115 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:18:05 spooler[2231]: job 4116 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:18:35 spooler[2231]: job 4117 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:19:05 spooler[2231]: job 4118 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:19:35 spooler[2231]: job 4119 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:20:05 spooler[2231]: job 4120 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:20:35 spooler[2231]: job 4121 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:21:05 spooler[2231]: job 4122 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:21:35 spooler[2231]: job 4123 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:22:05 spooler[2231]: job 4124 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:22:35 spooler[2231]: job 4125 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:23:05 spooler[2231]: job 4126 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:23:35 spooler[2231]: job 4127 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:24:05 spooler[2231]: job 4128 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:24:35 spooler[2231]: job 4129 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:25:05 spooler[2231]: job 4130 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:25:35 spooler[2231]: job 4131 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:26:05 spooler[2231]: job 4132 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:26:35 spooler[2231]: job 4133 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:27:05 spooler[2231]: job 4134 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:27:35 spooler[2231]: job 4135 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:28:05 spooler[2231]: job 4136 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:28:35 spooler[2231]: job 4137 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:29:05 spooler[2231]: job 4138 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:29:35 spooler[2231]: job 4139 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:30:05 spooler[2231]: job 4140 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:30:35 spooler[2231]: job 4141 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:31:05 spooler[2231]: job 4142 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:31:35 spooler[2231]: job 4143 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:32:05 spooler[2231]: job 4144 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:32:35 spooler[2231]: job 4145 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:33:05 spooler[2231]: job 4146 state=held queue=FL3-PRN-01 reason=paused-Line truncated
|
||||
"user": "최유진"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 확인하시고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 티켓은 없으며, 추가 도움이 필요하시면 직원이 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 372,
|
||||
"completion_tokens": 55,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 372,
|
||||
"completion_tokens": 55,
|
||||
"cost_usd": 0.0002368
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0002368,
|
||||
"trimmed": true,
|
||||
"question_chars": 6298,
|
||||
"recorded": true
|
||||
}
|
||||
✔ 앞에 질문 + 긴 로그 · VPN · 1.1s · 자동 답변 · 1 call · $0.000260
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요? 아래는 접속 프로그램 로그입니다.\n2026-10-29 08:10:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=40 state=reconnecting\n2026-10-29 08:10:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=41 state=reconnecting\n2026-10-29 08:11:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=42 state=reconnecting\n2026-10-29 08:11:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=43 state=reconnecting\n2026-10-29 08:12:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=44 state=reconnecting\n2026-10-29 08:12:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=45 state=reconnecting\n2026-10-29 08:13:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=46 state=reconnecting\n2026-10-29 08:13:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=47 state=reconnecting\n2026-10-29 08:14:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=48 state=reconnecting\n2026-10-29 08:14:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=49 state=reconnecting\n2026-10-29 08:15:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=50 state=reconnecting\n2026-10-29 08:15:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=51 state=reconnecting\n2026-10-29 08:16:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=52 state=reconnecting\n2026-10-29 08:16:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=53 state=reconnecting\n2026-10-29 08:17:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=54 state=reconnecting\n2026-10-29 08:17:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=55 state=reconnecting\n2026-10-29 08:18:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=56 state=reconnecting\n2026-10-29 08:18:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=57 state=reconnecting\n2026-10-29 08:19:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=58 state=reconnecting\n2026-10-29 08:19:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=59 state=reconnecting\n2026-10-29 08:20:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=60 state=reconnecting\n2026-10-29 08:20:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=61 state=reconnecting\n2026-10-29 08:21:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=62 state=reconnecting\n2026-10-29 08:21:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=63 state=reconnecting\n2026-10-29 08:22:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=64 state=reconnecting\n2026-10-29 08:22:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=65 state=reconnecting\n2026-10-29 08:23:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=66 state=reconnecting\n2026-10-29 08:23:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=67 state=reconnecting\n2026-10-29 08:24:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=68 state=reconnecting\n2026-10-29 08:24:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=69 state=reconnecting\n2026-10-29 08:25:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=40 state=reconnecting\n2026-10-29 08:25:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=41 state=reconnecting\n2026-10-29 08:26:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=42 state=reconnecting\n2026-10-29 08:26:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=43 state=reconnecting\n2026-10-29 08:27:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=44 state=reconnecting\n2026-10-29 08:27:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=45 state=reconnecting\n2026-10-29 08:28:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=46 state=reconnecting\n2026-10-29 08:28:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=47 state=reconnecting\n2026-10-29 08:29:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=48 state=reconnecting\n2026-10-29 08:29:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=49 state=reconnecting\n2026-10-29 08:30:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=50 state=reconnecting\n2026-10-29 08:30:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=51 state=reconnecting\n2026-10-29 08:31:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=52 state=reconnecting\n2026-10-29 08:31:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=3 rtt_ms=53 state=reconnecting\n2026-10-29 08:32:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=4 rtt_ms=54 state=reconnecting\n2026-10-29 08:32:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=0 rtt_ms=55 state=reconnecting\n2026-10-29 08:33:10 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=1 rtt_ms=56 state=reconnecting\n2026-10-29 08:33:40 vpnclient[812]: tunnel=corp-gw-2 keepalive missed=2 rtt_ms=57 sLine truncated
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하는 것을 권장합니다. 현재 접수된 티켓이 없어 상태를 확인할 수 없으며, 추가 도움이 필요하면 IT 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003",
|
||||
"FAQ-006"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 394,
|
||||
"completion_tokens": 64,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 394,
|
||||
"completion_tokens": 64,
|
||||
"cost_usd": 0.00026
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.00026,
|
||||
"trimmed": true,
|
||||
"question_chars": 6103,
|
||||
"recorded": true
|
||||
}
|
||||
✔ 짧은 질문은 그대로 · 1.1s · 자동 답변 · 1 call · $0.000141
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 47,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 47,
|
||||
"cost_usd": 0.0001412
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001412,
|
||||
"trimmed": false,
|
||||
"question_chars": 24,
|
||||
"recorded": true
|
||||
}
|
||||
✔ before: POST http://localhost:8800/budget · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 10000, "used_usd": 0.0046936, "remaining_usd": 0.9953
|
||||
→ request POST http://localhost:8800/budget
|
||||
{
|
||||
"max_question_chars": 10000
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 1.0,
|
||||
"user_limit_per_min": 20,
|
||||
"max_question_chars": 10000,
|
||||
"used_usd": 0.0046936,
|
||||
"remaining_usd": 0.9953064,
|
||||
"over": false
|
||||
}
|
||||
✔ 사람이 한도를 늘리면 자르지 않음 · 1.3s · 자동 답변 · 1 call · $0.001196
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "아래는 프린터 로그를 그대로 붙인 것입니다.\n2026-10-29 09:10:05 spooler[2231]: job 4100 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:10:35 spooler[2231]: job 4101 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:11:05 spooler[2231]: job 4102 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:11:35 spooler[2231]: job 4103 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:12:05 spooler[2231]: job 4104 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:12:35 spooler[2231]: job 4105 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:13:05 spooler[2231]: job 4106 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:13:35 spooler[2231]: job 4107 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:14:05 spooler[2231]: job 4108 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:14:35 spooler[2231]: job 4109 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:15:05 spooler[2231]: job 4110 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:15:35 spooler[2231]: job 4111 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:16:05 spooler[2231]: job 4112 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:16:35 spooler[2231]: job 4113 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:17:05 spooler[2231]: job 4114 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:17:35 spooler[2231]: job 4115 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:18:05 spooler[2231]: job 4116 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:18:35 spooler[2231]: job 4117 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:19:05 spooler[2231]: job 4118 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:19:35 spooler[2231]: job 4119 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:20:05 spooler[2231]: job 4120 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:20:35 spooler[2231]: job 4121 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:21:05 spooler[2231]: job 4122 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:21:35 spooler[2231]: job 4123 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:22:05 spooler[2231]: job 4124 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:22:35 spooler[2231]: job 4125 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:23:05 spooler[2231]: job 4126 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:23:35 spooler[2231]: job 4127 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:24:05 spooler[2231]: job 4128 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:24:35 spooler[2231]: job 4129 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:25:05 spooler[2231]: job 4130 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:25:35 spooler[2231]: job 4131 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:26:05 spooler[2231]: job 4132 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:26:35 spooler[2231]: job 4133 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:27:05 spooler[2231]: job 4134 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:27:35 spooler[2231]: job 4135 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:28:05 spooler[2231]: job 4136 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:28:35 spooler[2231]: job 4137 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:29:05 spooler[2231]: job 4138 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:29:35 spooler[2231]: job 4139 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:30:05 spooler[2231]: job 4140 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:30:35 spooler[2231]: job 4141 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:31:05 spooler[2231]: job 4142 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=2\n2026-10-29 09:31:35 spooler[2231]: job 4143 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=3\n2026-10-29 09:32:05 spooler[2231]: job 4144 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=0\n2026-10-29 09:32:35 spooler[2231]: job 4145 state=held queue=FL3-PRN-01 reason=paused-by-driver retry=1\n2026-10-29 09:33:05 spooler[2231]: job 4146 state=held queue=FL3-PRN-01 reason=paused-Line truncated
|
||||
"user": "최유진"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 먼저 확인하시고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 티켓은 없으며, 추가 도움이 필요하시면 직원이 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 2766,
|
||||
"completion_tokens": 56,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 2766,
|
||||
"completion_tokens": 56,
|
||||
"cost_usd": 0.001196
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.001196,
|
||||
"trimmed": false,
|
||||
"question_chars": 6298,
|
||||
"recorded": true
|
||||
}
|
||||
Passed 4/4
|
||||
report: runs/lab02-challenge2-20261028-210653.json
|
||||
✔ after: POST http://localhost:8800/budget/reset · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0.0058896, "remaining_usd": 0.994110
|
||||
→ request POST http://localhost:8800/budget/reset
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 1.0,
|
||||
"user_limit_per_min": 20,
|
||||
"max_question_chars": 500,
|
||||
"used_usd": 0.0058896,
|
||||
"remaining_usd": 0.9941104,
|
||||
"over": false
|
||||
}
|
||||
|
||||
$ uv run tools/flow.py test cases/lab02-challenge.json
|
||||
POST http://localhost:5678/webhook/my-cost · 7 case(s) × 1
|
||||
✔ faq-vpn · LLM 한 번 · $0.0002 이하 · 1.3s · 자동 답변 · 1 call · $0.000156
|
||||
✔ faq-password · LLM 한 번 · $0.0002 이하 · 1.3s · 자동 답변 · 1 call · $0.000138
|
||||
✔ faq-printer · LLM 한 번 · $0.0002 이하 · 1.7s · 자동 답변 · 1 call · $0.000152
|
||||
✔ faq-mail · LLM 한 번 · $0.0002 이하 · 1.4s · 자동 답변 · 1 call · $0.000166
|
||||
✔ ticket-own · LLM 한 번 · $0.0002 이하 · 1.2s · 자동 답변 · 1 call · $0.000115
|
||||
✔ ticket-done · LLM 한 번 · $0.0002 이하 · 1.3s · 자동 답변 · 1 call · $0.000108
|
||||
✔ 규칙으로 모르는 질문은 분류 + 답 · 2.0s · 자동 답변 · 2 calls · $0.000097
|
||||
Passed 7/7
|
||||
report: runs/lab02-challenge-20261028-210703.json
|
||||
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"cases": "lab03.json",
|
||||
"webhook": "my-cost",
|
||||
"passed": 1,
|
||||
"total": 3,
|
||||
"results": [
|
||||
{
|
||||
"case": "한도를 넘으면 · FAQ 원문",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"route = 자동 답변 (expected 한도 초과)",
|
||||
"cost_usd = 0.000154 (expected 0)",
|
||||
"llm_calls = 1 (expected 0)"
|
||||
],
|
||||
"seconds": 1.1,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 티켓이 없어 추가 지원이 필요하면 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 55,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 55,
|
||||
"cost_usd": 0.000154
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.000154,
|
||||
"trimmed": false,
|
||||
"question_chars": 24,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "한도를 넘으면 · 접수 원문",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"route = 자동 답변 (expected 한도 초과)",
|
||||
"cost_usd = 0.0001152 (expected 0)"
|
||||
],
|
||||
"seconds": 1.2,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "T-1002 처리 상태 알려 주세요",
|
||||
"user": "박서연"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "T-1002 티켓은 현재 처리 중이며, 인증서 발급 승인 대기 중입니다. 10월 20일에 완료될 예정입니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 152,
|
||||
"completion_tokens": 34,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 152,
|
||||
"completion_tokens": 34,
|
||||
"cost_usd": 0.0001152
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001152,
|
||||
"trimmed": false,
|
||||
"question_chars": 19,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "한도 안이면 평소대로",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 1.4,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 47,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 47,
|
||||
"cost_usd": 0.0001412
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001412,
|
||||
"trimmed": false,
|
||||
"question_chars": 24,
|
||||
"recorded": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
$ uv run tools/flow.py budget
|
||||
budget $1.0 / used $0.006822 · remaining $0.993178 · over False · per user 20/min · question 500 chars
|
||||
|
||||
$ uv run tools/flow.py test cases/lab03.json -v
|
||||
POST http://localhost:5678/webhook/my-cost · 3 case(s) × 1
|
||||
✔ before: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 19}
|
||||
→ request POST http://localhost:8800/usage-clear
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"cleared": 19
|
||||
}
|
||||
✔ before: POST http://localhost:8800/budget · HTTP 200 · 0.0s · {"daily_usd": 0.001, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0, "remaining_usd": 0.001, "over":
|
||||
→ request POST http://localhost:8800/budget
|
||||
{
|
||||
"daily_usd": 0.001
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 0.001,
|
||||
"user_limit_per_min": 20,
|
||||
"max_question_chars": 500,
|
||||
"used_usd": 0,
|
||||
"remaining_usd": 0.001,
|
||||
"over": false
|
||||
}
|
||||
✔ before: POST http://localhost:8800/usage · HTTP 200 · 0.0s · {"stored": 1, "total": 1}
|
||||
→ request POST http://localhost:8800/usage
|
||||
{
|
||||
"request_id": "seed",
|
||||
"user": "seed",
|
||||
"step": "answer",
|
||||
"model": "gpt-4.1-mini",
|
||||
"cost_usd": 0.002
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"stored": 1,
|
||||
"total": 1
|
||||
}
|
||||
✘ 한도를 넘으면 · FAQ 원문 · 1.1s · 자동 답변 · 1 call · $0.000154 · route = 자동 답변 (expected 한도 초과); cost_usd = 0.000154 (expected 0); llm_calls = 1 (expected 0)
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 티켓이 없어 추가 지원이 필요하면 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 55,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 55,
|
||||
"cost_usd": 0.000154
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.000154,
|
||||
"trimmed": false,
|
||||
"question_chars": 24,
|
||||
"recorded": true
|
||||
}
|
||||
✘ 한도를 넘으면 · 접수 원문 · 1.2s · 자동 답변 · 1 call · $0.000115 · route = 자동 답변 (expected 한도 초과); cost_usd = 0.0001152 (expected 0)
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "T-1002 처리 상태 알려 주세요",
|
||||
"user": "박서연"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "T-1002 티켓은 현재 처리 중이며, 인증서 발급 승인 대기 중입니다. 10월 20일에 완료될 예정입니다.",
|
||||
"sources": [],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 152,
|
||||
"completion_tokens": 34,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 152,
|
||||
"completion_tokens": 34,
|
||||
"cost_usd": 0.0001152
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001152,
|
||||
"trimmed": false,
|
||||
"question_chars": 19,
|
||||
"recorded": true
|
||||
}
|
||||
✔ before: POST http://localhost:8800/budget/reset · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0.0022692, "remaining_usd": 0.997730
|
||||
→ request POST http://localhost:8800/budget/reset
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 1.0,
|
||||
"user_limit_per_min": 20,
|
||||
"max_question_chars": 500,
|
||||
"used_usd": 0.0022692,
|
||||
"remaining_usd": 0.9977308,
|
||||
"over": false
|
||||
}
|
||||
✔ before: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 3}
|
||||
→ request POST http://localhost:8800/usage-clear
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"cleared": 3
|
||||
}
|
||||
✔ 한도 안이면 평소대로 · 1.4s · 자동 답변 · 1 call · $0.000141
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 47,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 47,
|
||||
"cost_usd": 0.0001412
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001412,
|
||||
"trimmed": false,
|
||||
"question_chars": 24,
|
||||
"recorded": true
|
||||
}
|
||||
Passed 1/3
|
||||
report: runs/lab03-20261028-210707.json
|
||||
✔ after: POST http://localhost:8800/budget/reset · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0.0001412, "remaining_usd": 0.999858
|
||||
→ request POST http://localhost:8800/budget/reset
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 1.0,
|
||||
"user_limit_per_min": 20,
|
||||
"max_question_chars": 500,
|
||||
"used_usd": 0.0001412,
|
||||
"remaining_usd": 0.9998588,
|
||||
"over": false
|
||||
}
|
||||
✔ after: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 1}
|
||||
→ request POST http://localhost:8800/usage-clear
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"cleared": 1
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"cases": "lab03.json",
|
||||
"webhook": "my-cost",
|
||||
"passed": 3,
|
||||
"total": 3,
|
||||
"results": [
|
||||
{
|
||||
"case": "한도를 넘으면 · FAQ 원문",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 1.0,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "한도 초과",
|
||||
"answer": "VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속합니다. (오늘 자동 답변 한도를 넘어 FAQ 원문으로 안내합니다.)",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 0,
|
||||
"usage": {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"by_step": {}
|
||||
},
|
||||
"cost_usd": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "한도를 넘으면 · 접수 원문",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 0.2,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "T-1002 처리 상태 알려 주세요",
|
||||
"user": "박서연"
|
||||
},
|
||||
"response": {
|
||||
"route": "한도 초과",
|
||||
"answer": "T-1002 접수 건은 현재 '처리 중' 상태입니다. 인증서 발급 승인 대기 중이며 10월 20일 완료 예정입니다. (오늘 자동 답변 한도를 넘어 원문으로 안내합니다.)",
|
||||
"sources": [],
|
||||
"llm_calls": 0,
|
||||
"usage": {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"by_step": {}
|
||||
},
|
||||
"cost_usd": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "한도 안이면 평소대로",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 1.6,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 티켓은 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 56,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 56,
|
||||
"cost_usd": 0.0001556
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001556,
|
||||
"trimmed": false,
|
||||
"question_chars": 24,
|
||||
"recorded": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
$ uv run tools/flow.py push reference/lab03-example1-budget.json
|
||||
! unpublished '75 IT helpdesk + long question cap (answer)' (w3d4ba9195f92a57): it used the same webhook path
|
||||
✔ pushed '75 IT helpdesk + daily budget (reference)' (id wd38606e31f611a9)
|
||||
POST http://localhost:5678/webhook/my-cost
|
||||
|
||||
$ uv run tools/flow.py test cases/lab03.json -v
|
||||
POST http://localhost:5678/webhook/my-cost · 3 case(s) × 1
|
||||
✔ before: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||
→ request POST http://localhost:8800/usage-clear
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"cleared": 0
|
||||
}
|
||||
✔ before: POST http://localhost:8800/budget · HTTP 200 · 0.0s · {"daily_usd": 0.001, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0, "remaining_usd": 0.001, "over":
|
||||
→ request POST http://localhost:8800/budget
|
||||
{
|
||||
"daily_usd": 0.001
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 0.001,
|
||||
"user_limit_per_min": 20,
|
||||
"max_question_chars": 500,
|
||||
"used_usd": 0,
|
||||
"remaining_usd": 0.001,
|
||||
"over": false
|
||||
}
|
||||
✔ before: POST http://localhost:8800/usage · HTTP 200 · 0.0s · {"stored": 1, "total": 1}
|
||||
→ request POST http://localhost:8800/usage
|
||||
{
|
||||
"request_id": "seed",
|
||||
"user": "seed",
|
||||
"step": "answer",
|
||||
"model": "gpt-4.1-mini",
|
||||
"cost_usd": 0.002
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"stored": 1,
|
||||
"total": 1
|
||||
}
|
||||
✔ 한도를 넘으면 · FAQ 원문 · 1.0s · 한도 초과 · 0 calls · $0.000000
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "한도 초과",
|
||||
"answer": "VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속합니다. (오늘 자동 답변 한도를 넘어 FAQ 원문으로 안내합니다.)",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 0,
|
||||
"usage": {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"by_step": {}
|
||||
},
|
||||
"cost_usd": 0
|
||||
}
|
||||
✔ 한도를 넘으면 · 접수 원문 · 0.2s · 한도 초과 · 0 calls · $0.000000
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "T-1002 처리 상태 알려 주세요",
|
||||
"user": "박서연"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "한도 초과",
|
||||
"answer": "T-1002 접수 건은 현재 '처리 중' 상태입니다. 인증서 발급 승인 대기 중이며 10월 20일 완료 예정입니다. (오늘 자동 답변 한도를 넘어 원문으로 안내합니다.)",
|
||||
"sources": [],
|
||||
"llm_calls": 0,
|
||||
"usage": {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"by_step": {}
|
||||
},
|
||||
"cost_usd": 0
|
||||
}
|
||||
✔ before: POST http://localhost:8800/budget/reset · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0.002, "remaining_usd": 0.998, "over
|
||||
→ request POST http://localhost:8800/budget/reset
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 1.0,
|
||||
"user_limit_per_min": 20,
|
||||
"max_question_chars": 500,
|
||||
"used_usd": 0.002,
|
||||
"remaining_usd": 0.998,
|
||||
"over": false
|
||||
}
|
||||
✔ before: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 1}
|
||||
→ request POST http://localhost:8800/usage-clear
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"cleared": 1
|
||||
}
|
||||
✔ 한도 안이면 평소대로 · 1.6s · 자동 답변 · 1 call · $0.000156
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 티켓은 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 56,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 56,
|
||||
"cost_usd": 0.0001556
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001556,
|
||||
"trimmed": false,
|
||||
"question_chars": 24,
|
||||
"recorded": true
|
||||
}
|
||||
Passed 3/3
|
||||
report: runs/lab03-20261028-210722.json
|
||||
✔ after: POST http://localhost:8800/budget/reset · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0.0001556, "remaining_usd": 0.999844
|
||||
→ request POST http://localhost:8800/budget/reset
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 1.0,
|
||||
"user_limit_per_min": 20,
|
||||
"max_question_chars": 500,
|
||||
"used_usd": 0.0001556,
|
||||
"remaining_usd": 0.9998444,
|
||||
"over": false
|
||||
}
|
||||
✔ after: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 1}
|
||||
→ request POST http://localhost:8800/usage-clear
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"cleared": 1
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"cases": "lab03-limit.json",
|
||||
"webhook": "my-cost",
|
||||
"passed": 3,
|
||||
"total": 4,
|
||||
"results": [
|
||||
{
|
||||
"case": "이준호 1번째",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 1.3,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 티켓은 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 56,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 56,
|
||||
"cost_usd": 0.0001556
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001556,
|
||||
"trimmed": false,
|
||||
"question_chars": 24,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "이준호 2번째",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 1.6,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 164,
|
||||
"completion_tokens": 54,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 164,
|
||||
"completion_tokens": 54,
|
||||
"cost_usd": 0.000152
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.000152,
|
||||
"trimmed": false,
|
||||
"question_chars": 12,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "이준호 3번째 · 1분에 2번 넘음",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"took 1.3s (expected at most 1s)",
|
||||
"status 200 (expected 429)",
|
||||
"route = 자동 답변 (expected 잠시 후)",
|
||||
"cost_usd = 0.0001556 (expected 0)"
|
||||
],
|
||||
"seconds": 1.3,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 티켓은 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 56,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 56,
|
||||
"cost_usd": 0.0001556
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001556,
|
||||
"trimmed": false,
|
||||
"question_chars": 24,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "다른 사람은 그대로",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 1.2,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
|
||||
"user": "한지훈"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 추가 도움이 필요하시면 문의해 주세요.",
|
||||
"sources": [
|
||||
"FAQ-001"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 169,
|
||||
"completion_tokens": 43,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 169,
|
||||
"completion_tokens": 43,
|
||||
"cost_usd": 0.0001364
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001364,
|
||||
"trimmed": false,
|
||||
"question_chars": 27,
|
||||
"recorded": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
$ uv run tools/flow.py test cases/lab03-limit.json -v
|
||||
✔ before: POST http://localhost:8800/budget/reset · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0, "remaining_usd": 1.0, "over": fal
|
||||
→ request POST http://localhost:8800/budget/reset
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 1.0,
|
||||
"user_limit_per_min": 20,
|
||||
"max_question_chars": 500,
|
||||
"used_usd": 0,
|
||||
"remaining_usd": 1.0,
|
||||
"over": false
|
||||
}
|
||||
✔ before: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||
→ request POST http://localhost:8800/usage-clear
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"cleared": 0
|
||||
}
|
||||
✔ before: POST http://localhost:8800/budget · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 2, "max_question_chars": 500, "used_usd": 0, "remaining_usd": 1.0, "over": fals
|
||||
→ request POST http://localhost:8800/budget
|
||||
{
|
||||
"user_limit_per_min": 2
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 1.0,
|
||||
"user_limit_per_min": 2,
|
||||
"max_question_chars": 500,
|
||||
"used_usd": 0,
|
||||
"remaining_usd": 1.0,
|
||||
"over": false
|
||||
}
|
||||
POST http://localhost:5678/webhook/my-cost · 4 case(s) × 1
|
||||
✔ 이준호 1번째 · 1.3s · 자동 답변 · 1 call · $0.000156
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 티켓은 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 56,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 56,
|
||||
"cost_usd": 0.0001556
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001556,
|
||||
"trimmed": false,
|
||||
"question_chars": 24,
|
||||
"recorded": true
|
||||
}
|
||||
✔ 이준호 2번째 · 1.6s · 자동 답변 · 1 call · $0.000152
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 164,
|
||||
"completion_tokens": 54,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 164,
|
||||
"completion_tokens": 54,
|
||||
"cost_usd": 0.000152
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.000152,
|
||||
"trimmed": false,
|
||||
"question_chars": 12,
|
||||
"recorded": true
|
||||
}
|
||||
✘ 이준호 3번째 · 1분에 2번 넘음 · 1.3s · 자동 답변 · 1 call · $0.000156 · took 1.3s (expected at most 1s); status 200 (expected 429); route = 자동 답변 (expected 잠시 후); cost_usd = 0.0001556 (expected 0)
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 티켓은 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 56,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 56,
|
||||
"cost_usd": 0.0001556
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001556,
|
||||
"trimmed": false,
|
||||
"question_chars": 24,
|
||||
"recorded": true
|
||||
}
|
||||
✔ 다른 사람은 그대로 · 1.2s · 자동 답변 · 1 call · $0.000136
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
|
||||
"user": "한지훈"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 추가 도움이 필요하시면 문의해 주세요.",
|
||||
"sources": [
|
||||
"FAQ-001"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 169,
|
||||
"completion_tokens": 43,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 169,
|
||||
"completion_tokens": 43,
|
||||
"cost_usd": 0.0001364
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001364,
|
||||
"trimmed": false,
|
||||
"question_chars": 27,
|
||||
"recorded": true
|
||||
}
|
||||
Passed 3/4
|
||||
report: runs/lab03-limit-20261028-210727.json
|
||||
✔ after: POST http://localhost:8800/budget/reset · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0.0005996, "remaining_usd": 0.999400
|
||||
→ request POST http://localhost:8800/budget/reset
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 1.0,
|
||||
"user_limit_per_min": 20,
|
||||
"max_question_chars": 500,
|
||||
"used_usd": 0.0005996,
|
||||
"remaining_usd": 0.9994004,
|
||||
"over": false
|
||||
}
|
||||
✔ after: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 4}
|
||||
→ request POST http://localhost:8800/usage-clear
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"cleared": 4
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"cases": "lab03-limit.json",
|
||||
"webhook": "my-cost",
|
||||
"passed": 4,
|
||||
"total": 4,
|
||||
"results": [
|
||||
{
|
||||
"case": "이준호 1번째",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 2.1,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 47,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 47,
|
||||
"cost_usd": 0.0001412
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001412,
|
||||
"trimmed": false,
|
||||
"question_chars": 24,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "이준호 2번째",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 1.6,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 164,
|
||||
"completion_tokens": 53,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 164,
|
||||
"completion_tokens": 53,
|
||||
"cost_usd": 0.0001504
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001504,
|
||||
"trimmed": false,
|
||||
"question_chars": 12,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "이준호 3번째 · 1분에 2번 넘음",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 0.0,
|
||||
"status": 429,
|
||||
"request": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "잠시 후",
|
||||
"answer": "요청이 많아 잠시 뒤에 다시 물어봐 주세요.",
|
||||
"retry_after": 60,
|
||||
"llm_calls": 0,
|
||||
"cost_usd": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "다른 사람은 그대로",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 1.9,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
|
||||
"user": "한지훈"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 추가 도움이 필요하시면 문의해 주세요.",
|
||||
"sources": [
|
||||
"FAQ-001"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 169,
|
||||
"completion_tokens": 43,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 169,
|
||||
"completion_tokens": 43,
|
||||
"cost_usd": 0.0001364
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001364,
|
||||
"trimmed": false,
|
||||
"question_chars": 27,
|
||||
"recorded": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
$ uv run tools/flow.py push reference/lab03-example2-limit.json
|
||||
! unpublished '75 IT helpdesk + safe record (answer)' (w2ae10aa66addee4): it used the same webhook path
|
||||
✔ pushed '75 IT helpdesk + user limit (reference)' (id w3b18d2741bd22c6)
|
||||
POST http://localhost:5678/webhook/my-cost
|
||||
|
||||
$ uv run tools/flow.py test cases/lab03-limit.json -v
|
||||
✔ before: POST http://localhost:8800/budget/reset · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0.0029704, "remaining_usd": 0.997029
|
||||
→ request POST http://localhost:8800/budget/reset
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 1.0,
|
||||
"user_limit_per_min": 20,
|
||||
"max_question_chars": 500,
|
||||
"used_usd": 0.0029704,
|
||||
"remaining_usd": 0.9970296,
|
||||
"over": false
|
||||
}
|
||||
✔ before: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 9}
|
||||
→ request POST http://localhost:8800/usage-clear
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"cleared": 9
|
||||
}
|
||||
✔ before: POST http://localhost:8800/budget · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 2, "max_question_chars": 500, "used_usd": 0, "remaining_usd": 1.0, "over": fals
|
||||
→ request POST http://localhost:8800/budget
|
||||
{
|
||||
"user_limit_per_min": 2
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 1.0,
|
||||
"user_limit_per_min": 2,
|
||||
"max_question_chars": 500,
|
||||
"used_usd": 0,
|
||||
"remaining_usd": 1.0,
|
||||
"over": false
|
||||
}
|
||||
POST http://localhost:5678/webhook/my-cost · 4 case(s) × 1
|
||||
✔ 이준호 1번째 · 2.1s · 자동 답변 · 1 call · $0.000141
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 47,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 47,
|
||||
"cost_usd": 0.0001412
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001412,
|
||||
"trimmed": false,
|
||||
"question_chars": 24,
|
||||
"recorded": true
|
||||
}
|
||||
✔ 이준호 2번째 · 1.6s · 자동 답변 · 1 call · $0.000150
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 164,
|
||||
"completion_tokens": 53,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 164,
|
||||
"completion_tokens": 53,
|
||||
"cost_usd": 0.0001504
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001504,
|
||||
"trimmed": false,
|
||||
"question_chars": 12,
|
||||
"recorded": true
|
||||
}
|
||||
✔ 이준호 3번째 · 1분에 2번 넘음 · 0.0s · 잠시 후 · 0 calls · $0.000000
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
}
|
||||
← response HTTP 429
|
||||
{
|
||||
"route": "잠시 후",
|
||||
"answer": "요청이 많아 잠시 뒤에 다시 물어봐 주세요.",
|
||||
"retry_after": 60,
|
||||
"llm_calls": 0,
|
||||
"cost_usd": 0
|
||||
}
|
||||
✔ 다른 사람은 그대로 · 1.9s · 자동 답변 · 1 call · $0.000136
|
||||
→ request POST /webhook/my-cost
|
||||
{
|
||||
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
|
||||
"user": "한지훈"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"route": "자동 답변",
|
||||
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 추가 도움이 필요하시면 문의해 주세요.",
|
||||
"sources": [
|
||||
"FAQ-001"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 169,
|
||||
"completion_tokens": 43,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 169,
|
||||
"completion_tokens": 43,
|
||||
"cost_usd": 0.0001364
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001364,
|
||||
"trimmed": false,
|
||||
"question_chars": 27,
|
||||
"recorded": true
|
||||
}
|
||||
Passed 4/4
|
||||
report: runs/lab03-limit-20261028-211138.json
|
||||
✔ after: POST http://localhost:8800/budget/reset · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0.000428, "remaining_usd": 0.999572,
|
||||
→ request POST http://localhost:8800/budget/reset
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 1.0,
|
||||
"user_limit_per_min": 20,
|
||||
"max_question_chars": 500,
|
||||
"used_usd": 0.000428,
|
||||
"remaining_usd": 0.999572,
|
||||
"over": false
|
||||
}
|
||||
✔ after: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 3}
|
||||
→ request POST http://localhost:8800/usage-clear
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"cleared": 3
|
||||
}
|
||||
|
||||
$ uv run tools/flow.py test cases/lab03.json
|
||||
POST http://localhost:5678/webhook/my-cost · 3 case(s) × 1
|
||||
✔ before: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||
✔ before: POST http://localhost:8800/budget · HTTP 200 · 0.0s · {"daily_usd": 0.001, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0, "remaining_usd": 0.001, "over":
|
||||
✔ before: POST http://localhost:8800/usage · HTTP 200 · 0.0s · {"stored": 1, "total": 1}
|
||||
✔ 한도를 넘으면 · FAQ 원문 · 0.1s · 한도 초과 · 0 calls · $0.000000
|
||||
✔ 한도를 넘으면 · 접수 원문 · 0.1s · 한도 초과 · 0 calls · $0.000000
|
||||
✔ before: POST http://localhost:8800/budget/reset · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0.002, "remaining_usd": 0.998, "over
|
||||
✔ before: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 1}
|
||||
✔ 한도 안이면 평소대로 · 1.2s · 자동 답변 · 1 call · $0.000156
|
||||
Passed 3/3
|
||||
report: runs/lab03-20261028-211139.json
|
||||
✔ after: POST http://localhost:8800/budget/reset · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0.0001556, "remaining_usd": 0.999844
|
||||
✔ after: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 1}
|
||||
|
||||
$ uv run tools/flow.py test cases/lab02-challenge.json
|
||||
POST http://localhost:5678/webhook/my-cost · 7 case(s) × 1
|
||||
✔ faq-vpn · LLM 한 번 · $0.0002 이하 · 1.3s · 자동 답변 · 1 call · $0.000141
|
||||
✔ faq-password · LLM 한 번 · $0.0002 이하 · 1.3s · 자동 답변 · 1 call · $0.000138
|
||||
✔ faq-printer · LLM 한 번 · $0.0002 이하 · 1.3s · 자동 답변 · 1 call · $0.000152
|
||||
✔ faq-mail · LLM 한 번 · $0.0002 이하 · 1.5s · 자동 답변 · 1 call · $0.000166
|
||||
✔ ticket-own · LLM 한 번 · $0.0002 이하 · 1.3s · 자동 답변 · 1 call · $0.000115
|
||||
✔ ticket-done · LLM 한 번 · $0.0002 이하 · 1.1s · 자동 답변 · 1 call · $0.000108
|
||||
✔ 규칙으로 모르는 질문은 분류 + 답 · 1.7s · 자동 답변 · 2 calls · $0.000097
|
||||
Passed 7/7
|
||||
report: runs/lab02-challenge-20261028-211149.json
|
||||
@@ -0,0 +1,198 @@
|
||||
{
|
||||
"cases": "lab03-drill.json",
|
||||
"webhook": "my-cost",
|
||||
"passed": 6,
|
||||
"total": 6,
|
||||
"results": [
|
||||
{
|
||||
"case": "1번째 · VPN",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 1.1,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 47,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 165,
|
||||
"completion_tokens": 47,
|
||||
"cost_usd": 0.0001412
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001412,
|
||||
"trimmed": false,
|
||||
"question_chars": 24,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "2번째 · 프린터",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 1.0,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "최유진"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
|
||||
"sources": [
|
||||
"FAQ-004"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 164,
|
||||
"completion_tokens": 54,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 164,
|
||||
"completion_tokens": 54,
|
||||
"cost_usd": 0.000152
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.000152,
|
||||
"trimmed": false,
|
||||
"question_chars": 12,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "3번째 · 비밀번호",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 1.3,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
|
||||
"user": "한지훈"
|
||||
},
|
||||
"response": {
|
||||
"route": "자동 답변",
|
||||
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 추가 도움이 필요하시면 문의해 주세요.",
|
||||
"sources": [
|
||||
"FAQ-001"
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 169,
|
||||
"completion_tokens": 43,
|
||||
"by_step": {
|
||||
"answer": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 169,
|
||||
"completion_tokens": 43,
|
||||
"cost_usd": 0.0001364
|
||||
}
|
||||
}
|
||||
},
|
||||
"cost_usd": 0.0001364,
|
||||
"trimmed": false,
|
||||
"question_chars": 27,
|
||||
"recorded": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "4번째 · 메일",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 0.1,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "메일함 용량이 꽉 찼대요",
|
||||
"user": "김민지"
|
||||
},
|
||||
"response": {
|
||||
"route": "한도 초과",
|
||||
"answer": "오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제해 용량을 확보합니다. (오늘 자동 답변 한도를 넘어 FAQ 원문으로 안내합니다.)",
|
||||
"sources": [
|
||||
"FAQ-005"
|
||||
],
|
||||
"llm_calls": 0,
|
||||
"usage": {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"by_step": {}
|
||||
},
|
||||
"cost_usd": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "5번째 · 접수",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 0.1,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "T-1002 처리 상태 알려 주세요",
|
||||
"user": "박서연"
|
||||
},
|
||||
"response": {
|
||||
"route": "한도 초과",
|
||||
"answer": "T-1002 접수 건은 현재 '처리 중' 상태입니다. 인증서 발급 승인 대기 중이며 10월 20일 완료 예정입니다. (오늘 자동 답변 한도를 넘어 원문으로 안내합니다.)",
|
||||
"sources": [],
|
||||
"llm_calls": 0,
|
||||
"usage": {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"by_step": {}
|
||||
},
|
||||
"cost_usd": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "6번째 · VPN 다시",
|
||||
"webhook": "my-cost",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 0.0,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호"
|
||||
},
|
||||
"response": {
|
||||
"route": "한도 초과",
|
||||
"answer": "VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속합니다. (오늘 자동 답변 한도를 넘어 FAQ 원문으로 안내합니다.)",
|
||||
"sources": [
|
||||
"FAQ-003"
|
||||
],
|
||||
"llm_calls": 0,
|
||||
"usage": {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"by_step": {}
|
||||
},
|
||||
"cost_usd": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
$ uv run tools/flow.py budget --daily 0.0003
|
||||
✔ budget changed: {"daily_usd": 0.0003}
|
||||
budget $0.0003 / used $0.000934 · remaining $-0.000634 · over True · per user 20/min · question 500 chars
|
||||
|
||||
$ uv run tools/flow.py test cases/lab03-drill.json
|
||||
✔ before: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 8}
|
||||
POST http://localhost:5678/webhook/my-cost · 6 case(s) × 1
|
||||
✔ 1번째 · VPN · 1.1s · 자동 답변 · 1 call · $0.000141
|
||||
✔ 2번째 · 프린터 · 1.0s · 자동 답변 · 1 call · $0.000152
|
||||
✔ 3번째 · 비밀번호 · 1.3s · 자동 답변 · 1 call · $0.000136
|
||||
✔ 4번째 · 메일 · 0.1s · 한도 초과 · 0 calls · $0.000000
|
||||
✔ 5번째 · 접수 · 0.1s · 한도 초과 · 0 calls · $0.000000
|
||||
✔ 6번째 · VPN 다시 · 0.0s · 한도 초과 · 0 calls · $0.000000
|
||||
Passed 6/6
|
||||
report: runs/lab03-drill-20261028-210759.json
|
||||
|
||||
$ uv run tools/flow.py usage
|
||||
requests 3 · calls 3 · tokens in 498 / out 144 · $0.000430
|
||||
model gpt-4.1-mini 3 calls · in 498 · out 144 · $0.000430
|
||||
user 최유진 1 calls · in 164 · out 54 · $0.000152
|
||||
user 이준호 1 calls · in 165 · out 47 · $0.000141
|
||||
user 한지훈 1 calls · in 169 · out 43 · $0.000136
|
||||
route 자동 답변 3 calls · in 498 · out 144 · $0.000430
|
||||
budget $0.0003 / used $0.000430 · remaining $-0.000130 · over True · per user 20/min · question 500 chars
|
||||
|
||||
$ uv run tools/flow.py budget --reset
|
||||
✔ budget back to data/budget.json
|
||||
budget $1.0 / used $0.000430 · remaining $0.999570 · over False · per user 20/min · question 500 chars
|
||||
|
||||
$ uv run tools/flow.py test cases/lab03-drill.json
|
||||
✔ before: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 3}
|
||||
POST http://localhost:5678/webhook/my-cost · 6 case(s) × 1
|
||||
✔ 1번째 · VPN · 1.4s · 자동 답변 · 1 call · $0.000141
|
||||
✔ 2번째 · 프린터 · 1.2s · 자동 답변 · 1 call · $0.000152
|
||||
✔ 3번째 · 비밀번호 · 1.6s · 자동 답변 · 1 call · $0.000136
|
||||
✔ 4번째 · 메일 · 1.8s · 자동 답변 · 1 call · $0.000166
|
||||
✔ 5번째 · 접수 · 1.4s · 자동 답변 · 1 call · $0.000133
|
||||
✔ 6번째 · VPN 다시 · 1.5s · 자동 답변 · 1 call · $0.000156
|
||||
Passed 6/6
|
||||
report: runs/lab03-drill-20261028-210808.json
|
||||
|
||||
$ uv run tools/flow.py usage --clear
|
||||
✔ usage cleared: {"cleared": 6}
|
||||
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"cases": "lab03-challenge.json",
|
||||
"webhook": "my-cost-report",
|
||||
"passed": 0,
|
||||
"total": 5,
|
||||
"results": [
|
||||
{
|
||||
"case": "요약 숫자",
|
||||
"webhook": "my-cost-report",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"status 404 (expected 200)",
|
||||
"requests = null (expected 3)",
|
||||
"calls = null (expected 4)",
|
||||
"cost_usd = null (expected 0.001989)",
|
||||
"avg_usd = null (expected 0.000663)",
|
||||
"top_user = null (expected 박서연)"
|
||||
],
|
||||
"seconds": 0.0,
|
||||
"status": 404,
|
||||
"request": {
|
||||
"requests_per_day": 100,
|
||||
"workdays": 22
|
||||
},
|
||||
"response": {
|
||||
"code": 404,
|
||||
"message": "The requested webhook \"POST my-cost-report\" is not registered.",
|
||||
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "모델별 · 한 달 예상",
|
||||
"webhook": "my-cost-report",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"status 404 (expected 200)",
|
||||
"projected_month_usd = null (expected 1.4586)",
|
||||
"by_model has none of ['\"gpt-4.1-mini\": 0.001944']"
|
||||
],
|
||||
"seconds": 0.0,
|
||||
"status": 404,
|
||||
"request": {
|
||||
"requests_per_day": 100,
|
||||
"workdays": 22
|
||||
},
|
||||
"response": {
|
||||
"code": 404,
|
||||
"message": "The requested webhook \"POST my-cost-report\" is not registered.",
|
||||
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "작은 모델 몫",
|
||||
"webhook": "my-cost-report",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"status 404 (expected 200)",
|
||||
"by_model has none of ['\"gpt-5.4-nano\": 4.5e-05']"
|
||||
],
|
||||
"seconds": 0.0,
|
||||
"status": 404,
|
||||
"request": {},
|
||||
"response": {
|
||||
"code": 404,
|
||||
"message": "The requested webhook \"POST my-cost-report\" is not registered.",
|
||||
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "예산 남은 금액",
|
||||
"webhook": "my-cost-report",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"status 404 (expected 200)",
|
||||
"daily_budget_usd = null (expected 1.0)",
|
||||
"remaining_usd = null (expected 0.998011)",
|
||||
"over_budget = null (expected false)"
|
||||
],
|
||||
"seconds": 0.0,
|
||||
"status": 404,
|
||||
"request": {},
|
||||
"response": {
|
||||
"code": 404,
|
||||
"message": "The requested webhook \"POST my-cost-report\" is not registered.",
|
||||
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "예산을 넘으면",
|
||||
"webhook": "my-cost-report",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"status 404 (expected 200)",
|
||||
"over_budget = null (expected true)"
|
||||
],
|
||||
"seconds": 0.0,
|
||||
"status": 404,
|
||||
"request": {},
|
||||
"response": {
|
||||
"code": 404,
|
||||
"message": "The requested webhook \"POST my-cost-report\" is not registered.",
|
||||
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
$ uv run tools/flow.py off my-cost-report
|
||||
✔ unpublished my-cost-report (id wc7211a27bb523f0)
|
||||
✔ n8n restarted. Turn it back on with: uv run tools/flow.py push <the workflow file>
|
||||
|
||||
$ uv run tools/flow.py test cases/lab03-challenge.json -v
|
||||
✔ before: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 8}
|
||||
→ request POST http://localhost:8800/usage-clear
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"cleared": 8
|
||||
}
|
||||
✔ before: POST http://localhost:8800/budget/reset · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0, "remaining_usd": 1.0, "over": fal
|
||||
→ request POST http://localhost:8800/budget/reset
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 1.0,
|
||||
"user_limit_per_min": 20,
|
||||
"max_question_chars": 500,
|
||||
"used_usd": 0,
|
||||
"remaining_usd": 1.0,
|
||||
"over": false
|
||||
}
|
||||
✔ before: POST http://localhost:8800/usage · HTTP 200 · 0.0s · {"stored": 4, "total": 4}
|
||||
→ request POST http://localhost:8800/usage
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"request_id": "r1",
|
||||
"user": "이준호",
|
||||
"step": "answer",
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1000,
|
||||
"completion_tokens": 100,
|
||||
"cost_usd": 0.00056,
|
||||
"route": "자동 답변"
|
||||
},
|
||||
{
|
||||
"request_id": "r1",
|
||||
"user": "이준호",
|
||||
"step": "classify",
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 20,
|
||||
"cost_usd": 4.5e-05,
|
||||
"route": "자동 답변"
|
||||
},
|
||||
{
|
||||
"request_id": "r2",
|
||||
"user": "박서연",
|
||||
"step": "answer",
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 2000,
|
||||
"completion_tokens": 200,
|
||||
"cost_usd": 0.00112,
|
||||
"route": "자동 답변"
|
||||
},
|
||||
{
|
||||
"request_id": "r3",
|
||||
"user": "이준호",
|
||||
"step": "answer",
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 500,
|
||||
"completion_tokens": 40,
|
||||
"cost_usd": 0.000264,
|
||||
"route": "자동 답변"
|
||||
}
|
||||
]
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"stored": 4,
|
||||
"total": 4
|
||||
}
|
||||
POST http://localhost:5678/webhook/my-cost-report · 5 case(s) × 1
|
||||
✘ 요약 숫자 · 0.0s · status 404 (expected 200); requests = null (expected 3); calls = null (expected 4); cost_usd = null (expected 0.001989); avg_usd = null (expected 0.000663); top_user = null (expected 박서연)
|
||||
→ request POST /webhook/my-cost-report
|
||||
{
|
||||
"requests_per_day": 100,
|
||||
"workdays": 22
|
||||
}
|
||||
← response HTTP 404
|
||||
{
|
||||
"code": 404,
|
||||
"message": "The requested webhook \"POST my-cost-report\" is not registered.",
|
||||
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||
}
|
||||
✘ 모델별 · 한 달 예상 · 0.0s · status 404 (expected 200); projected_month_usd = null (expected 1.4586); by_model has none of ['"gpt-4.1-mini": 0.001944']
|
||||
→ request POST /webhook/my-cost-report
|
||||
{
|
||||
"requests_per_day": 100,
|
||||
"workdays": 22
|
||||
}
|
||||
← response HTTP 404
|
||||
{
|
||||
"code": 404,
|
||||
"message": "The requested webhook \"POST my-cost-report\" is not registered.",
|
||||
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||
}
|
||||
✘ 작은 모델 몫 · 0.0s · status 404 (expected 200); by_model has none of ['"gpt-5.4-nano": 4.5e-05']
|
||||
→ request POST /webhook/my-cost-report
|
||||
{}
|
||||
← response HTTP 404
|
||||
{
|
||||
"code": 404,
|
||||
"message": "The requested webhook \"POST my-cost-report\" is not registered.",
|
||||
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||
}
|
||||
✘ 예산 남은 금액 · 0.0s · status 404 (expected 200); daily_budget_usd = null (expected 1.0); remaining_usd = null (expected 0.998011); over_budget = null (expected false)
|
||||
→ request POST /webhook/my-cost-report
|
||||
{}
|
||||
← response HTTP 404
|
||||
{
|
||||
"code": 404,
|
||||
"message": "The requested webhook \"POST my-cost-report\" is not registered.",
|
||||
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||
}
|
||||
✔ before: POST http://localhost:8800/budget · HTTP 200 · 0.0s · {"daily_usd": 0.001, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0.001989, "remaining_usd": -0.0009
|
||||
→ request POST http://localhost:8800/budget
|
||||
{
|
||||
"daily_usd": 0.001
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 0.001,
|
||||
"user_limit_per_min": 20,
|
||||
"max_question_chars": 500,
|
||||
"used_usd": 0.001989,
|
||||
"remaining_usd": -0.000989,
|
||||
"over": true
|
||||
}
|
||||
✘ 예산을 넘으면 · 0.0s · status 404 (expected 200); over_budget = null (expected true)
|
||||
→ request POST /webhook/my-cost-report
|
||||
{}
|
||||
← response HTTP 404
|
||||
{
|
||||
"code": 404,
|
||||
"message": "The requested webhook \"POST my-cost-report\" is not registered.",
|
||||
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||
}
|
||||
Passed 0/5
|
||||
report: runs/lab03-challenge-20261028-211157.json
|
||||
✔ after: POST http://localhost:8800/budget/reset · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0.001989, "remaining_usd": 0.998011,
|
||||
→ request POST http://localhost:8800/budget/reset
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 1.0,
|
||||
"user_limit_per_min": 20,
|
||||
"max_question_chars": 500,
|
||||
"used_usd": 0.001989,
|
||||
"remaining_usd": 0.998011,
|
||||
"over": false
|
||||
}
|
||||
✔ after: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 4}
|
||||
→ request POST http://localhost:8800/usage-clear
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"cleared": 4
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"cases": "lab03-challenge.json",
|
||||
"webhook": "my-cost-report",
|
||||
"passed": 5,
|
||||
"total": 5,
|
||||
"results": [
|
||||
{
|
||||
"case": "요약 숫자",
|
||||
"webhook": "my-cost-report",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 0.8,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"requests_per_day": 100,
|
||||
"workdays": 22
|
||||
},
|
||||
"response": {
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 1.4586,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.998011,
|
||||
"over_budget": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "모델별 · 한 달 예상",
|
||||
"webhook": "my-cost-report",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 0.0,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"requests_per_day": 100,
|
||||
"workdays": 22
|
||||
},
|
||||
"response": {
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 1.4586,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.998011,
|
||||
"over_budget": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "작은 모델 몫",
|
||||
"webhook": "my-cost-report",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 0.1,
|
||||
"status": 200,
|
||||
"request": {},
|
||||
"response": {
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 0.043758,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.998011,
|
||||
"over_budget": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "예산 남은 금액",
|
||||
"webhook": "my-cost-report",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 0.0,
|
||||
"status": 200,
|
||||
"request": {},
|
||||
"response": {
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 0.043758,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.998011,
|
||||
"over_budget": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "예산을 넘으면",
|
||||
"webhook": "my-cost-report",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 0.1,
|
||||
"status": 200,
|
||||
"request": {},
|
||||
"response": {
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 0.043758,
|
||||
"daily_budget_usd": 0.001,
|
||||
"remaining_usd": -0.000989,
|
||||
"over_budget": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
$ uv run tools/flow.py push answers/75-lab03-challenge-report.json
|
||||
✔ pushed '75 Cost report (answer)' (id w1f9c668c0305daf)
|
||||
POST http://localhost:5678/webhook/my-cost-report
|
||||
|
||||
$ uv run tools/flow.py test cases/lab03-challenge.json -v
|
||||
✔ before: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||
→ request POST http://localhost:8800/usage-clear
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"cleared": 0
|
||||
}
|
||||
✔ before: POST http://localhost:8800/budget/reset · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0, "remaining_usd": 1.0, "over": fal
|
||||
→ request POST http://localhost:8800/budget/reset
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 1.0,
|
||||
"user_limit_per_min": 20,
|
||||
"max_question_chars": 500,
|
||||
"used_usd": 0,
|
||||
"remaining_usd": 1.0,
|
||||
"over": false
|
||||
}
|
||||
✔ before: POST http://localhost:8800/usage · HTTP 200 · 0.0s · {"stored": 4, "total": 4}
|
||||
→ request POST http://localhost:8800/usage
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"request_id": "r1",
|
||||
"user": "이준호",
|
||||
"step": "answer",
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1000,
|
||||
"completion_tokens": 100,
|
||||
"cost_usd": 0.00056,
|
||||
"route": "자동 답변"
|
||||
},
|
||||
{
|
||||
"request_id": "r1",
|
||||
"user": "이준호",
|
||||
"step": "classify",
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 20,
|
||||
"cost_usd": 4.5e-05,
|
||||
"route": "자동 답변"
|
||||
},
|
||||
{
|
||||
"request_id": "r2",
|
||||
"user": "박서연",
|
||||
"step": "answer",
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 2000,
|
||||
"completion_tokens": 200,
|
||||
"cost_usd": 0.00112,
|
||||
"route": "자동 답변"
|
||||
},
|
||||
{
|
||||
"request_id": "r3",
|
||||
"user": "이준호",
|
||||
"step": "answer",
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 500,
|
||||
"completion_tokens": 40,
|
||||
"cost_usd": 0.000264,
|
||||
"route": "자동 답변"
|
||||
}
|
||||
]
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"stored": 4,
|
||||
"total": 4
|
||||
}
|
||||
POST http://localhost:5678/webhook/my-cost-report · 5 case(s) × 1
|
||||
✔ 요약 숫자 · 0.8s · $0.001989
|
||||
→ request POST /webhook/my-cost-report
|
||||
{
|
||||
"requests_per_day": 100,
|
||||
"workdays": 22
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 1.4586,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.998011,
|
||||
"over_budget": false
|
||||
}
|
||||
✔ 모델별 · 한 달 예상 · 0.0s · $0.001989
|
||||
→ request POST /webhook/my-cost-report
|
||||
{
|
||||
"requests_per_day": 100,
|
||||
"workdays": 22
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 1.4586,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.998011,
|
||||
"over_budget": false
|
||||
}
|
||||
✔ 작은 모델 몫 · 0.1s · $0.001989
|
||||
→ request POST /webhook/my-cost-report
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 0.043758,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.998011,
|
||||
"over_budget": false
|
||||
}
|
||||
✔ 예산 남은 금액 · 0.0s · $0.001989
|
||||
→ request POST /webhook/my-cost-report
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 0.043758,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.998011,
|
||||
"over_budget": false
|
||||
}
|
||||
✔ before: POST http://localhost:8800/budget · HTTP 200 · 0.0s · {"daily_usd": 0.001, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0.001989, "remaining_usd": -0.0009
|
||||
→ request POST http://localhost:8800/budget
|
||||
{
|
||||
"daily_usd": 0.001
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 0.001,
|
||||
"user_limit_per_min": 20,
|
||||
"max_question_chars": 500,
|
||||
"used_usd": 0.001989,
|
||||
"remaining_usd": -0.000989,
|
||||
"over": true
|
||||
}
|
||||
✔ 예산을 넘으면 · 0.1s · $0.001989
|
||||
→ request POST /webhook/my-cost-report
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 0.043758,
|
||||
"daily_budget_usd": 0.001,
|
||||
"remaining_usd": -0.000989,
|
||||
"over_budget": true
|
||||
}
|
||||
Passed 5/5
|
||||
report: runs/lab03-challenge-20261028-211208.json
|
||||
✔ after: POST http://localhost:8800/budget/reset · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0.001989, "remaining_usd": 0.998011,
|
||||
→ request POST http://localhost:8800/budget/reset
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 1.0,
|
||||
"user_limit_per_min": 20,
|
||||
"max_question_chars": 500,
|
||||
"used_usd": 0.001989,
|
||||
"remaining_usd": 0.998011,
|
||||
"over": false
|
||||
}
|
||||
✔ after: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 4}
|
||||
→ request POST http://localhost:8800/usage-clear
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"cleared": 4
|
||||
}
|
||||
|
||||
$ uv run tools/flow.py test cases/lab02-challenge.json
|
||||
POST http://localhost:5678/webhook/my-cost · 7 case(s) × 1
|
||||
✔ faq-vpn · LLM 한 번 · $0.0002 이하 · 1.3s · 자동 답변 · 1 call · $0.000141
|
||||
✔ faq-password · LLM 한 번 · $0.0002 이하 · 1.5s · 자동 답변 · 1 call · $0.000136
|
||||
✔ faq-printer · LLM 한 번 · $0.0002 이하 · 1.4s · 자동 답변 · 1 call · $0.000150
|
||||
✔ faq-mail · LLM 한 번 · $0.0002 이하 · 1.7s · 자동 답변 · 1 call · $0.000140
|
||||
✔ ticket-own · LLM 한 번 · $0.0002 이하 · 1.2s · 자동 답변 · 1 call · $0.000115
|
||||
✔ ticket-done · LLM 한 번 · $0.0002 이하 · 1.0s · 자동 답변 · 1 call · $0.000108
|
||||
✔ 규칙으로 모르는 질문은 분류 + 답 · 1.7s · 자동 답변 · 2 calls · $0.000097
|
||||
Passed 7/7
|
||||
report: runs/lab02-challenge-20261028-211218.json
|
||||
|
||||
$ uv run tools/flow.py call my-cost-report '{"requests_per_day": 1000}'
|
||||
HTTP 200 · 0.1s
|
||||
{
|
||||
"requests": 7,
|
||||
"calls": 8,
|
||||
"cost_usd": 0.000889,
|
||||
"avg_usd": 0.000127,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.000864,
|
||||
"gpt-5.4-nano": 2.5e-05
|
||||
},
|
||||
"top_user": "이준호",
|
||||
"projected_month_usd": 2.795257,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.999111,
|
||||
"over_budget": false
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
{
|
||||
"cases": "lab03-challenge2.json",
|
||||
"webhook": "my-cost-report",
|
||||
"passed": 2,
|
||||
"total": 5,
|
||||
"results": [
|
||||
{
|
||||
"case": "모두 nano였다면",
|
||||
"webhook": "my-cost-report",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"what_if.model = null (expected gpt-5.4-nano)",
|
||||
"what_if.cost_usd = null (expected 0.00117)",
|
||||
"what_if.saving_usd = null (expected 0.000819)",
|
||||
"what_if.projected_month_usd = null (expected 0.858)"
|
||||
],
|
||||
"seconds": 0.0,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"requests_per_day": 100,
|
||||
"workdays": 22,
|
||||
"what_if_model": "gpt-5.4-nano"
|
||||
},
|
||||
"response": {
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 1.4586,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.998011,
|
||||
"over_budget": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "원래 숫자는 그대로",
|
||||
"webhook": "my-cost-report",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 0.1,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"requests_per_day": 100,
|
||||
"workdays": 22,
|
||||
"what_if_model": "gpt-5.4-nano"
|
||||
},
|
||||
"response": {
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 1.4586,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.998011,
|
||||
"over_budget": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "모두 mini였다면 · 아끼는 돈이 음수",
|
||||
"webhook": "my-cost-report",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"what_if.cost_usd = null (expected 0.002016)",
|
||||
"what_if.saving_usd = null (expected -2.7e-05)"
|
||||
],
|
||||
"seconds": 0.0,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"what_if_model": "gpt-4.1-mini"
|
||||
},
|
||||
"response": {
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 0.043758,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.998011,
|
||||
"over_budget": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "없는 모델은 400",
|
||||
"webhook": "my-cost-report",
|
||||
"run": 1,
|
||||
"passed": false,
|
||||
"problems": [
|
||||
"status 200 (expected 400)",
|
||||
"error = null (expected unknown model)",
|
||||
"known has none of ['gpt-5.4-nano']"
|
||||
],
|
||||
"seconds": 0.0,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"what_if_model": "gpt-9"
|
||||
},
|
||||
"response": {
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 0.043758,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.998011,
|
||||
"over_budget": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "what_if 없이 부르면 과제 1과 같음",
|
||||
"webhook": "my-cost-report",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 0.0,
|
||||
"status": 200,
|
||||
"request": {},
|
||||
"response": {
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 0.043758,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.998011,
|
||||
"over_budget": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
$ uv run tools/flow.py test cases/lab03-challenge2.json -v
|
||||
✔ before: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 8}
|
||||
→ request POST http://localhost:8800/usage-clear
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"cleared": 8
|
||||
}
|
||||
✔ before: POST http://localhost:8800/budget/reset · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0, "remaining_usd": 1.0, "over": fal
|
||||
→ request POST http://localhost:8800/budget/reset
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 1.0,
|
||||
"user_limit_per_min": 20,
|
||||
"max_question_chars": 500,
|
||||
"used_usd": 0,
|
||||
"remaining_usd": 1.0,
|
||||
"over": false
|
||||
}
|
||||
✔ before: POST http://localhost:8800/usage · HTTP 200 · 0.0s · {"stored": 4, "total": 4}
|
||||
→ request POST http://localhost:8800/usage
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"request_id": "r1",
|
||||
"user": "이준호",
|
||||
"step": "answer",
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1000,
|
||||
"completion_tokens": 100,
|
||||
"cost_usd": 0.00056,
|
||||
"route": "자동 답변"
|
||||
},
|
||||
{
|
||||
"request_id": "r1",
|
||||
"user": "이준호",
|
||||
"step": "classify",
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 20,
|
||||
"cost_usd": 4.5e-05,
|
||||
"route": "자동 답변"
|
||||
},
|
||||
{
|
||||
"request_id": "r2",
|
||||
"user": "박서연",
|
||||
"step": "answer",
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 2000,
|
||||
"completion_tokens": 200,
|
||||
"cost_usd": 0.00112,
|
||||
"route": "자동 답변"
|
||||
},
|
||||
{
|
||||
"request_id": "r3",
|
||||
"user": "이준호",
|
||||
"step": "answer",
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 500,
|
||||
"completion_tokens": 40,
|
||||
"cost_usd": 0.000264,
|
||||
"route": "자동 답변"
|
||||
}
|
||||
]
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"stored": 4,
|
||||
"total": 4
|
||||
}
|
||||
POST http://localhost:5678/webhook/my-cost-report · 5 case(s) × 1
|
||||
✘ 모두 nano였다면 · 0.0s · $0.001989 · what_if.model = null (expected gpt-5.4-nano); what_if.cost_usd = null (expected 0.00117); what_if.saving_usd = null (expected 0.000819); what_if.projected_month_usd = null (expected 0.858)
|
||||
→ request POST /webhook/my-cost-report
|
||||
{
|
||||
"requests_per_day": 100,
|
||||
"workdays": 22,
|
||||
"what_if_model": "gpt-5.4-nano"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 1.4586,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.998011,
|
||||
"over_budget": false
|
||||
}
|
||||
✔ 원래 숫자는 그대로 · 0.1s · $0.001989
|
||||
→ request POST /webhook/my-cost-report
|
||||
{
|
||||
"requests_per_day": 100,
|
||||
"workdays": 22,
|
||||
"what_if_model": "gpt-5.4-nano"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 1.4586,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.998011,
|
||||
"over_budget": false
|
||||
}
|
||||
✘ 모두 mini였다면 · 아끼는 돈이 음수 · 0.0s · $0.001989 · what_if.cost_usd = null (expected 0.002016); what_if.saving_usd = null (expected -2.7e-05)
|
||||
→ request POST /webhook/my-cost-report
|
||||
{
|
||||
"what_if_model": "gpt-4.1-mini"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 0.043758,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.998011,
|
||||
"over_budget": false
|
||||
}
|
||||
✘ 없는 모델은 400 · 0.0s · $0.001989 · status 200 (expected 400); error = null (expected unknown model); known has none of ['gpt-5.4-nano']
|
||||
→ request POST /webhook/my-cost-report
|
||||
{
|
||||
"what_if_model": "gpt-9"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 0.043758,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.998011,
|
||||
"over_budget": false
|
||||
}
|
||||
✔ what_if 없이 부르면 과제 1과 같음 · 0.0s · $0.001989
|
||||
→ request POST /webhook/my-cost-report
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 0.043758,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.998011,
|
||||
"over_budget": false
|
||||
}
|
||||
Passed 2/5
|
||||
report: runs/lab03-challenge2-20261028-211219.json
|
||||
✔ after: POST http://localhost:8800/budget/reset · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0.001989, "remaining_usd": 0.998011,
|
||||
→ request POST http://localhost:8800/budget/reset
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 1.0,
|
||||
"user_limit_per_min": 20,
|
||||
"max_question_chars": 500,
|
||||
"used_usd": 0.001989,
|
||||
"remaining_usd": 0.998011,
|
||||
"over": false
|
||||
}
|
||||
✔ after: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 4}
|
||||
→ request POST http://localhost:8800/usage-clear
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"cleared": 4
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"cases": "lab03-challenge2.json",
|
||||
"webhook": "my-cost-report",
|
||||
"passed": 5,
|
||||
"total": 5,
|
||||
"results": [
|
||||
{
|
||||
"case": "모두 nano였다면",
|
||||
"webhook": "my-cost-report",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 0.9,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"requests_per_day": 100,
|
||||
"workdays": 22,
|
||||
"what_if_model": "gpt-5.4-nano"
|
||||
},
|
||||
"response": {
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 1.4586,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.998011,
|
||||
"over_budget": false,
|
||||
"what_if": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"cost_usd": 0.00117,
|
||||
"saving_usd": 0.000819,
|
||||
"projected_month_usd": 0.858
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "원래 숫자는 그대로",
|
||||
"webhook": "my-cost-report",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 0.1,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"requests_per_day": 100,
|
||||
"workdays": 22,
|
||||
"what_if_model": "gpt-5.4-nano"
|
||||
},
|
||||
"response": {
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 1.4586,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.998011,
|
||||
"over_budget": false,
|
||||
"what_if": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"cost_usd": 0.00117,
|
||||
"saving_usd": 0.000819,
|
||||
"projected_month_usd": 0.858
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "모두 mini였다면 · 아끼는 돈이 음수",
|
||||
"webhook": "my-cost-report",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 0.0,
|
||||
"status": 200,
|
||||
"request": {
|
||||
"what_if_model": "gpt-4.1-mini"
|
||||
},
|
||||
"response": {
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 0.043758,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.998011,
|
||||
"over_budget": false,
|
||||
"what_if": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"cost_usd": 0.002016,
|
||||
"saving_usd": -2.7e-05,
|
||||
"projected_month_usd": 0.044352
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "없는 모델은 400",
|
||||
"webhook": "my-cost-report",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 0.2,
|
||||
"status": 400,
|
||||
"request": {
|
||||
"what_if_model": "gpt-9"
|
||||
},
|
||||
"response": {
|
||||
"error": "unknown model",
|
||||
"model": "gpt-9",
|
||||
"known": [
|
||||
"gpt-4.1-mini",
|
||||
"gpt-5.4-nano"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "what_if 없이 부르면 과제 1과 같음",
|
||||
"webhook": "my-cost-report",
|
||||
"run": 1,
|
||||
"passed": true,
|
||||
"problems": [],
|
||||
"seconds": 0.0,
|
||||
"status": 200,
|
||||
"request": {},
|
||||
"response": {
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 0.043758,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.998011,
|
||||
"over_budget": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
$ uv run tools/flow.py push answers/75-lab03-challenge2-what-if.json
|
||||
! unpublished '75 Cost report (answer)' (w1f9c668c0305daf): it used the same webhook path
|
||||
✔ pushed '75 Cost report + what-if (answer)' (id wc7211a27bb523f0)
|
||||
POST http://localhost:5678/webhook/my-cost-report
|
||||
|
||||
$ uv run tools/flow.py test cases/lab03-challenge2.json -v
|
||||
✔ before: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||
→ request POST http://localhost:8800/usage-clear
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"cleared": 0
|
||||
}
|
||||
✔ before: POST http://localhost:8800/budget/reset · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0, "remaining_usd": 1.0, "over": fal
|
||||
→ request POST http://localhost:8800/budget/reset
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 1.0,
|
||||
"user_limit_per_min": 20,
|
||||
"max_question_chars": 500,
|
||||
"used_usd": 0,
|
||||
"remaining_usd": 1.0,
|
||||
"over": false
|
||||
}
|
||||
✔ before: POST http://localhost:8800/usage · HTTP 200 · 0.0s · {"stored": 4, "total": 4}
|
||||
→ request POST http://localhost:8800/usage
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"request_id": "r1",
|
||||
"user": "이준호",
|
||||
"step": "answer",
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 1000,
|
||||
"completion_tokens": 100,
|
||||
"cost_usd": 0.00056,
|
||||
"route": "자동 답변"
|
||||
},
|
||||
{
|
||||
"request_id": "r1",
|
||||
"user": "이준호",
|
||||
"step": "classify",
|
||||
"model": "gpt-5.4-nano",
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 20,
|
||||
"cost_usd": 4.5e-05,
|
||||
"route": "자동 답변"
|
||||
},
|
||||
{
|
||||
"request_id": "r2",
|
||||
"user": "박서연",
|
||||
"step": "answer",
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 2000,
|
||||
"completion_tokens": 200,
|
||||
"cost_usd": 0.00112,
|
||||
"route": "자동 답변"
|
||||
},
|
||||
{
|
||||
"request_id": "r3",
|
||||
"user": "이준호",
|
||||
"step": "answer",
|
||||
"model": "gpt-4.1-mini",
|
||||
"prompt_tokens": 500,
|
||||
"completion_tokens": 40,
|
||||
"cost_usd": 0.000264,
|
||||
"route": "자동 답변"
|
||||
}
|
||||
]
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"stored": 4,
|
||||
"total": 4
|
||||
}
|
||||
POST http://localhost:5678/webhook/my-cost-report · 5 case(s) × 1
|
||||
✔ 모두 nano였다면 · 0.9s · $0.001989
|
||||
→ request POST /webhook/my-cost-report
|
||||
{
|
||||
"requests_per_day": 100,
|
||||
"workdays": 22,
|
||||
"what_if_model": "gpt-5.4-nano"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 1.4586,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.998011,
|
||||
"over_budget": false,
|
||||
"what_if": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"cost_usd": 0.00117,
|
||||
"saving_usd": 0.000819,
|
||||
"projected_month_usd": 0.858
|
||||
}
|
||||
}
|
||||
✔ 원래 숫자는 그대로 · 0.1s · $0.001989
|
||||
→ request POST /webhook/my-cost-report
|
||||
{
|
||||
"requests_per_day": 100,
|
||||
"workdays": 22,
|
||||
"what_if_model": "gpt-5.4-nano"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 1.4586,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.998011,
|
||||
"over_budget": false,
|
||||
"what_if": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"cost_usd": 0.00117,
|
||||
"saving_usd": 0.000819,
|
||||
"projected_month_usd": 0.858
|
||||
}
|
||||
}
|
||||
✔ 모두 mini였다면 · 아끼는 돈이 음수 · 0.0s · $0.001989
|
||||
→ request POST /webhook/my-cost-report
|
||||
{
|
||||
"what_if_model": "gpt-4.1-mini"
|
||||
}
|
||||
← response HTTP 200
|
||||
{
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 0.043758,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.998011,
|
||||
"over_budget": false,
|
||||
"what_if": {
|
||||
"model": "gpt-4.1-mini",
|
||||
"cost_usd": 0.002016,
|
||||
"saving_usd": -2.7e-05,
|
||||
"projected_month_usd": 0.044352
|
||||
}
|
||||
}
|
||||
✔ 없는 모델은 400 · 0.2s
|
||||
→ request POST /webhook/my-cost-report
|
||||
{
|
||||
"what_if_model": "gpt-9"
|
||||
}
|
||||
← response HTTP 400
|
||||
{
|
||||
"error": "unknown model",
|
||||
"model": "gpt-9",
|
||||
"known": [
|
||||
"gpt-4.1-mini",
|
||||
"gpt-5.4-nano"
|
||||
]
|
||||
}
|
||||
✔ what_if 없이 부르면 과제 1과 같음 · 0.0s · $0.001989
|
||||
→ request POST /webhook/my-cost-report
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"requests": 3,
|
||||
"calls": 4,
|
||||
"cost_usd": 0.001989,
|
||||
"avg_usd": 0.000663,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.001944,
|
||||
"gpt-5.4-nano": 4.5e-05
|
||||
},
|
||||
"top_user": "박서연",
|
||||
"projected_month_usd": 0.043758,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.998011,
|
||||
"over_budget": false
|
||||
}
|
||||
Passed 5/5
|
||||
report: runs/lab03-challenge2-20261028-211231.json
|
||||
✔ after: POST http://localhost:8800/budget/reset · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0.001989, "remaining_usd": 0.998011,
|
||||
→ request POST http://localhost:8800/budget/reset
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"daily_usd": 1.0,
|
||||
"user_limit_per_min": 20,
|
||||
"max_question_chars": 500,
|
||||
"used_usd": 0.001989,
|
||||
"remaining_usd": 0.998011,
|
||||
"over": false
|
||||
}
|
||||
✔ after: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 4}
|
||||
→ request POST http://localhost:8800/usage-clear
|
||||
{}
|
||||
← response HTTP 200
|
||||
{
|
||||
"cleared": 4
|
||||
}
|
||||
|
||||
$ uv run tools/flow.py test cases/lab03-challenge.json
|
||||
✔ before: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||
✔ before: POST http://localhost:8800/budget/reset · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0, "remaining_usd": 1.0, "over": fal
|
||||
✔ before: POST http://localhost:8800/usage · HTTP 200 · 0.0s · {"stored": 4, "total": 4}
|
||||
POST http://localhost:5678/webhook/my-cost-report · 5 case(s) × 1
|
||||
✔ 요약 숫자 · 0.1s · $0.001989
|
||||
✔ 모델별 · 한 달 예상 · 0.1s · $0.001989
|
||||
✔ 작은 모델 몫 · 0.1s · $0.001989
|
||||
✔ 예산 남은 금액 · 0.0s · $0.001989
|
||||
✔ before: POST http://localhost:8800/budget · HTTP 200 · 0.0s · {"daily_usd": 0.001, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0.001989, "remaining_usd": -0.0009
|
||||
✔ 예산을 넘으면 · 0.0s · $0.001989
|
||||
Passed 5/5
|
||||
report: runs/lab03-challenge-20261028-211232.json
|
||||
✔ after: POST http://localhost:8800/budget/reset · HTTP 200 · 0.0s · {"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500, "used_usd": 0.001989, "remaining_usd": 0.998011,
|
||||
✔ after: POST http://localhost:8800/usage-clear · HTTP 200 · 0.0s · {"cleared": 4}
|
||||
|
||||
$ uv run tools/flow.py test cases/lab02-challenge.json
|
||||
POST http://localhost:5678/webhook/my-cost · 7 case(s) × 1
|
||||
✔ faq-vpn · LLM 한 번 · $0.0002 이하 · 1.7s · 자동 답변 · 1 call · $0.000156
|
||||
✔ faq-password · LLM 한 번 · $0.0002 이하 · 1.4s · 자동 답변 · 1 call · $0.000136
|
||||
✔ faq-printer · LLM 한 번 · $0.0002 이하 · 1.2s · 자동 답변 · 1 call · $0.000150
|
||||
✔ faq-mail · LLM 한 번 · $0.0002 이하 · 1.4s · 자동 답변 · 1 call · $0.000166
|
||||
✔ ticket-own · LLM 한 번 · $0.0002 이하 · 1.0s · 자동 답변 · 1 call · $0.000115
|
||||
✔ ticket-done · LLM 한 번 · $0.0002 이하 · 1.1s · 자동 답변 · 1 call · $0.000108
|
||||
✔ 규칙으로 모르는 질문은 분류 + 답 · 2.2s · 자동 답변 · 2 calls · $0.000097
|
||||
Passed 7/7
|
||||
report: runs/lab02-challenge-20261028-211242.json
|
||||
|
||||
$ uv run tools/flow.py call my-cost-report '{"requests_per_day": 1000, "what_if_model": "gpt-5.4-nano"}'
|
||||
HTTP 200 · 0.1s
|
||||
{
|
||||
"requests": 7,
|
||||
"calls": 8,
|
||||
"cost_usd": 0.000929,
|
||||
"avg_usd": 0.000133,
|
||||
"by_model": {
|
||||
"gpt-4.1-mini": 0.000904,
|
||||
"gpt-5.4-nano": 2.5e-05
|
||||
},
|
||||
"top_user": "이준호",
|
||||
"projected_month_usd": 2.920971,
|
||||
"daily_budget_usd": 1,
|
||||
"remaining_usd": 0.999071,
|
||||
"over_budget": false,
|
||||
"what_if": {
|
||||
"model": "gpt-5.4-nano",
|
||||
"cost_usd": 0.00061,
|
||||
"saving_usd": 0.00032,
|
||||
"projected_month_usd": 1.9162
|
||||
}
|
||||
}
|
||||
|
||||
$ uv run tools/flow.py usage --clear
|
||||
✔ usage cleared: {"cleared": 8}
|
||||
|
||||
$ uv run tools/flow.py budget --reset
|
||||
✔ budget back to data/budget.json
|
||||
budget $1.0 / used $0.000000 · remaining $1.000000 · over False · per user 20/min · question 500 chars
|
||||
|
||||
$ uv run tools/flow.py doctor
|
||||
! n8n install folder /Users/mcchae/day65-n8n
|
||||
→ day 65 SETUP-N8N 5단계 (get.n8n.io 설치). 다른 곳에 설치했다면 Last3Mon/65/n8n/.env 에 N8N_HOME=<폴더>
|
||||
✔ n8n container day65-n8n-n8n-1
|
||||
✔ n8n http://localhost:5678
|
||||
✔ data API http://localhost:8800
|
||||
✔ prices /prices · budget /budget (day 75 data API)
|
||||
✔ n8n → http://data-api:8800
|
||||
✔ credential 'OpenAI (.env)' (openAiCourse0001)
|
||||
✔ node catalog (for check)
|
||||
✔ CLI coding agent: codex, claude, agy
|
||||
ready
|
||||
@@ -0,0 +1,421 @@
|
||||
{
|
||||
"name": "75 IT helpdesk + usage record (answer)",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Question webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "my-cost",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"webhookId": "797db9f9-e9ff-5363-9d0a-58d23a2047f0",
|
||||
"position": [
|
||||
0,
|
||||
300
|
||||
],
|
||||
"id": "3ccc3834-a583-51bd-a80f-b20124e32b64"
|
||||
},
|
||||
{
|
||||
"name": "Has question?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.2,
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "loose",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "c1",
|
||||
"leftValue": "={{ $json.body.question }}",
|
||||
"rightValue": "",
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"operation": "notEmpty",
|
||||
"singleValue": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
220,
|
||||
300
|
||||
],
|
||||
"id": "7e27dce9-fa3b-5c88-98b3-1dce956d16d2"
|
||||
},
|
||||
{
|
||||
"name": "Reject empty",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "{ \"error\": \"question is required\" }",
|
||||
"options": {
|
||||
"responseCode": 400
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
440,
|
||||
560
|
||||
],
|
||||
"id": "f3591be9-5676-5bb7-ab46-6984719af080"
|
||||
},
|
||||
{
|
||||
"name": "Get prices",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/prices",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
1100,
|
||||
300
|
||||
],
|
||||
"id": "baa4796d-564b-5cc0-949a-d79738b12ba7"
|
||||
},
|
||||
{
|
||||
"name": "Search FAQ",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/faq",
|
||||
"options": {},
|
||||
"sendQuery": true,
|
||||
"queryParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "q",
|
||||
"value": "={{ $('Question webhook').first().json.body.question }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
1540,
|
||||
300
|
||||
],
|
||||
"id": "e2005518-7a82-579a-acda-cc74b33968a9"
|
||||
},
|
||||
{
|
||||
"name": "Get ticket",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "=http://data-api:8800/tickets/{{ (($('Question webhook').first().json.body.question.match(/T-?\\d{4}/i) || ['NONE'])[0]).toUpperCase().replace(/^T(\\d)/, 'T-$1') }}",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"sendQuery": true,
|
||||
"queryParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "requester",
|
||||
"value": "={{ $('Question webhook').first().json.body.user ?? '' }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
1760,
|
||||
300
|
||||
],
|
||||
"id": "9d6c9b87-54bc-5433-bad3-6e33744e10ca"
|
||||
},
|
||||
{
|
||||
"name": "Classify",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://api.openai.com/v1/chat/completions",
|
||||
"authentication": "predefinedCredentialType",
|
||||
"nodeCredentialType": "openAiApi",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, messages: [{ role: 'system', content: \"You sort one company IT help-desk question. Categories: faq (how to fix or use something), ticket (asks about the status of a ticket like T-1002), other. Return only JSON, no code fence: {\\\"category\\\": \\\"faq\\\"}\" }, { role: 'user', content: $('Question webhook').first().json.body.question }] }) }}",
|
||||
"options": {
|
||||
"timeout": 60000
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "openAiCourse0001",
|
||||
"name": "OpenAI (.env)"
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
2420,
|
||||
300
|
||||
],
|
||||
"id": "07e553a6-f8eb-5513-a566-db99f5b11d29"
|
||||
},
|
||||
{
|
||||
"name": "Answer",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://api.openai.com/v1/chat/completions",
|
||||
"authentication": "predefinedCredentialType",
|
||||
"nodeCredentialType": "openAiApi",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, messages: [{ role: 'system', content: \"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 합니다체.\\n\\n참고 자료 (사내 규정 전체):\\n# 업무용 장비 지급·반납 규정\\n\\n## 1. 지급과 교체\\n- 업무용 노트북은 입사일에 지급한다.\\n- 노트북 교체 주기는 4년이다. 고장이나 배터리 교체는 IT 문의로 접수한다.\\n\\n## 2. 분실과 도난\\n- 업무용 장비를 잃어버리거나 도난당하면 24시간 이내에 IT 문의와 보안팀에 신고한다.\\n- 신고가 접수되면 IT 담당자가 원격으로 장비의 데이터를 잠근다.\\n\\n## 3. 반납\\n- 퇴사하거나 장비를 교체할 때는 5영업일 이내에 장비를 IT 담당자에게 반납한다.\\n- 반납 전에 개인 파일을 지우지 말고, 업무 파일은 사내 드라이브로 옮긴다.\\n\\n## 4. 개인 장비 사용\\n- 개인 휴대폰이나 태블릿으로는 업무 메일과 메신저만 쓸 수 있다.\\n- 개인 장비로 업무 메일을 보려면 회사의 모바일 관리 앱(MDM)을 먼저 설치한다.\\n\\n# 메일·문서 보안 규정\\n\\n## 1. 메일함\\n- 개인 메일함 용량은 10GB이다. 용량이 90%를 넘으면 안내 메일이 발송된다.\\n- 오래된 메일은 보관함으로 옮겨 용량을 확보한다.\\n\\n## 2. 첨부 파일\\n- 메일 첨부 파일은 25MB까지 보낼 수 있다.\\n- 25MB를 넘는 파일은 사내 드라이브에 올리고 공유 링크를 보낸다.\\n- 개인정보가 들어 있는 파일을 외부로 보낼 때는 반드시 암호를 걸고, 암호는 메신저 등 다른 경로로 알려 준다.\\n\\n## 3. 문서 등급과 보관\\n- 대외비 문서에는 문서 첫 장에 '대외비'라고 표시한다.\\n- 업무 문서는 3년 동안 보관한 뒤 정리한다.\\n\\n# 회의실 이용 규정\\n\\n## 1. 예약\\n- 회의실은 사내 포털에서 예약한다. 한 번에 최대 3시간까지 예약할 수 있다.\\n- 예약 시작 후 10분이 지나도록 아무도 사용하지 않으면 예약이 자동으로 취소된다.\\n\\n## 2. 장비\\n- 대회의실의 화상회의 장비를 쓰려면 회의 시작 30분 전까지 IT 문의로 요청한다.\\n- 프로젝터는 B회의실과 대회의실에 있다.\\n\\n## 3. 사용 후\\n- 회의가 끝나면 화이트보드를 지우고 의자를 정리한다.\\n\\n# 비밀번호·계정 보안 규정\\n\\n## 1. 비밀번호 만들기\\n- 비밀번호는 12자 이상으로 만든다.\\n- 영문 대문자, 영문 소문자, 숫자, 특수문자 중 3종류 이상을 섞는다.\\n- 이름, 생일, 전화번호, 회사 이름처럼 추측하기 쉬운 단어는 쓰지 않는다.\\n\\n## 2. 변경 주기\\n- 비밀번호는 90일마다 바꾼다. 만료 7일 전부터 사내 포털에 변경 안내가 표시된다.\\n- 최근에 쓴 비밀번호 5개는 다시 쓸 수 없다.\\n\\n## 3. 로그인 실패와 잠금\\n- 로그인에 5번 연속 실패하면 계정이 30분 동안 잠긴다.\\n- 30분이 지나면 자동으로 풀린다. 급한 경우 IT 문의로 잠금 해제를 요청한다.\\n\\n## 4. 다단계 인증\\n- 회사 밖에서 사내 시스템에 접속할 때는 OTP 다단계 인증을 반드시 사용한다.\\n- OTP 앱이 설치된 휴대폰을 바꾸면 IT 포털에서 OTP를 다시 등록한다.\\n\\n# 개인정보 처리 지침\\n\\n## 1. 수집\\n- 업무에 꼭 필요한 개인정보만 수집한다.\\n- 주민등록번호는 법에서 정한 경우가 아니면 수집하지 않는다.\\n\\n## 2. 이용과 보관\\n- 고객 개인정보가 보이는 화면은 캡처하거나 촬영하지 않는다.\\n- 개인정보가 들어 있는 출력물은 다 쓴 뒤 바로 파쇄한다.\\n\\n## 3. 유출 대응\\n- 개인정보가 유출되었거나 유출이 의심되면 1시간 이내에 보안팀에 신고한다.\\n- 신고할 때는 유출된 정보의 종류, 대략적인 건수, 발견한 시각을 함께 알린다.\\n\\n# 원격근무·VPN 이용 규정\\n\\n## 1. 접속 원칙\\n- 회사 밖에서 사내 시스템에 접속할 때는 반드시 회사 VPN을 먼저 연결한다.\\n- 카페, 공항 같은 공용 와이파이에서는 VPN 없이 사내 시스템에 접속하지 않는다.\\n\\n## 2. VPN 인증서\\n- VPN 인증서의 유효 기간은 1년이다.\\n- 만료 30일 전부터 IT 포털의 '인증서 재발급' 메뉴에서 새 인증서를 신청한다.\\n- 인증서가 만료되면 VPN에 접속할 수 없으므로, 만료 전에 재발급한다.\\n\\n## 3. 재택근무 중 보안\\n- 자리를 비울 때는 화면을 잠그고, 5분 동안 사용하지 않으면 자동으로 잠Line truncated
|
||||
"options": {
|
||||
"timeout": 60000
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "openAiCourse0001",
|
||||
"name": "OpenAI (.env)"
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
2640,
|
||||
300
|
||||
],
|
||||
"id": "fd329729-e718-56ff-805c-aa298d26c0b2"
|
||||
},
|
||||
{
|
||||
"name": "Review",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://api.openai.com/v1/chat/completions",
|
||||
"authentication": "predefinedCredentialType",
|
||||
"nodeCredentialType": "openAiApi",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, messages: [{ role: 'system', content: \"You review a Korean IT help-desk answer before it is sent. Keep the facts exactly. Make it polite (합니다체), remove anything not supported by the data, keep at most 3 sentences. Return only the final answer text.\" }, { role: 'user', content: '초안: ' + $('Answer').first().json.choices[0].message.content }] }) }}",
|
||||
"options": {
|
||||
"timeout": 60000
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "openAiCourse0001",
|
||||
"name": "OpenAI (.env)"
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
2860,
|
||||
300
|
||||
],
|
||||
"id": "f289c264-5e1f-523d-8e18-67ba4cbb166c"
|
||||
},
|
||||
{
|
||||
"name": "Build result",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const ran = (n) => { try { return $(n).isExecuted; } catch (e) { return false; } };\nconst steps = [{\"step\": \"classify\", \"node\": \"Classify\", \"model\": \"gpt-4.1-mini\"}, {\"step\": \"answer\", \"node\": \"Answer\", \"model\": \"gpt-4.1-mini\"}, {\"step\": \"review\", \"node\": \"Review\", \"model\": \"gpt-4.1-mini\"}];\nconst prices = $('Get prices').first().json.models;\nconst by_step = {};\nlet prompt_tokens = 0, completion_tokens = 0, llm_calls = 0, cost_usd = 0;\nfor (const s of steps) {\n if (!ran(s.node)) continue;\n const u = $(s.node).first().json.usage ?? {};\n const row = { model: s.model, prompt_tokens: u.prompt_tokens ?? 0, completion_tokens: u.completion_tokens ?? 0 };\n const p = prices[s.model] ?? { input: 0, output: 0 };\n row.cost_usd = Math.round((row.prompt_tokens * p.input + row.completion_tokens * p.output) / 1e6 * 1e8) / 1e8;\n by_step[s.step] = row;\n prompt_tokens += row.prompt_tokens; completion_tokens += row.completion_tokens; cost_usd += row.cost_usd; llm_calls += 1;\n}\nconst results = $('Search FAQ').first().json.results ?? [];\nconst answer = String($('Review').first().json.choices[0].message.content ?? '').trim();\nconst result = { route: '자동 답변', answer, sources: results.map((r) => r.id), llm_calls,\n usage: { prompt_tokens, completion_tokens, by_step }, cost_usd: Math.round(cost_usd * 1e8) / 1e8 };\nreturn [{ json: result }];"
|
||||
},
|
||||
"position": [
|
||||
3520,
|
||||
300
|
||||
],
|
||||
"id": "d85f4d2b-0957-5a81-a54b-35ba55509871"
|
||||
},
|
||||
{
|
||||
"name": "Record usage",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "http://data-api:8800/usage",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ items: Object.entries($json.usage.by_step).map(([step, row]) => ({ request_id: $execution.id, user: $('Question webhook').first().json.body.user ?? '', step, model: row.model, prompt_tokens: row.prompt_tokens, completion_tokens: row.completion_tokens, cost_usd: row.cost_usd, route: $json.route })) }) }}",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
3740,
|
||||
300
|
||||
],
|
||||
"id": "a46b3503-5039-5cb6-890e-69149fe23f55"
|
||||
},
|
||||
{
|
||||
"name": "Result again",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "return [{ json: $('Build result').first().json }];"
|
||||
},
|
||||
"position": [
|
||||
3960,
|
||||
300
|
||||
],
|
||||
"id": "1b8127ae-a43b-5096-8f94-c96892a085db"
|
||||
},
|
||||
{
|
||||
"name": "Send result",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ $json }}",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
4180,
|
||||
300
|
||||
],
|
||||
"id": "6cbcbfe3-9306-5287-b0c6-79f3fe0f9a5e"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Question webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Has question?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Has question?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get prices",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Reject empty",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get prices": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Search FAQ",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Search FAQ": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get ticket",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get ticket": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Classify",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Classify": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Answer",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Answer": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Review",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Review": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Build result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Build result": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Record usage",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Record usage": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Result again",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Result again": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
{
|
||||
"name": "75 IT helpdesk + safe record (answer)",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Question webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "my-cost",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"webhookId": "797db9f9-e9ff-5363-9d0a-58d23a2047f0",
|
||||
"position": [
|
||||
0,
|
||||
300
|
||||
],
|
||||
"id": "3ccc3834-a583-51bd-a80f-b20124e32b64"
|
||||
},
|
||||
{
|
||||
"name": "Has question?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.2,
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "loose",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "c1",
|
||||
"leftValue": "={{ $json.body.question }}",
|
||||
"rightValue": "",
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"operation": "notEmpty",
|
||||
"singleValue": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
220,
|
||||
300
|
||||
],
|
||||
"id": "7e27dce9-fa3b-5c88-98b3-1dce956d16d2"
|
||||
},
|
||||
{
|
||||
"name": "Reject empty",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "{ \"error\": \"question is required\" }",
|
||||
"options": {
|
||||
"responseCode": 400
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
440,
|
||||
560
|
||||
],
|
||||
"id": "f3591be9-5676-5bb7-ab46-6984719af080"
|
||||
},
|
||||
{
|
||||
"name": "Get prices",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/prices",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
1100,
|
||||
300
|
||||
],
|
||||
"id": "baa4796d-564b-5cc0-949a-d79738b12ba7"
|
||||
},
|
||||
{
|
||||
"name": "Search FAQ",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/faq",
|
||||
"options": {},
|
||||
"sendQuery": true,
|
||||
"queryParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "q",
|
||||
"value": "={{ $('Question webhook').first().json.body.question }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
1540,
|
||||
300
|
||||
],
|
||||
"id": "e2005518-7a82-579a-acda-cc74b33968a9"
|
||||
},
|
||||
{
|
||||
"name": "Get ticket",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "=http://data-api:8800/tickets/{{ (($('Question webhook').first().json.body.question.match(/T-?\\d{4}/i) || ['NONE'])[0]).toUpperCase().replace(/^T(\\d)/, 'T-$1') }}",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"sendQuery": true,
|
||||
"queryParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "requester",
|
||||
"value": "={{ $('Question webhook').first().json.body.user ?? '' }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
1760,
|
||||
300
|
||||
],
|
||||
"id": "9d6c9b87-54bc-5433-bad3-6e33744e10ca"
|
||||
},
|
||||
{
|
||||
"name": "Classify",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://api.openai.com/v1/chat/completions",
|
||||
"authentication": "predefinedCredentialType",
|
||||
"nodeCredentialType": "openAiApi",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, messages: [{ role: 'system', content: \"You sort one company IT help-desk question. Categories: faq (how to fix or use something), ticket (asks about the status of a ticket like T-1002), other. Return only JSON, no code fence: {\\\"category\\\": \\\"faq\\\"}\" }, { role: 'user', content: $('Question webhook').first().json.body.question }] }) }}",
|
||||
"options": {
|
||||
"timeout": 60000
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "openAiCourse0001",
|
||||
"name": "OpenAI (.env)"
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
2420,
|
||||
300
|
||||
],
|
||||
"id": "07e553a6-f8eb-5513-a566-db99f5b11d29"
|
||||
},
|
||||
{
|
||||
"name": "Answer",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://api.openai.com/v1/chat/completions",
|
||||
"authentication": "predefinedCredentialType",
|
||||
"nodeCredentialType": "openAiApi",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, messages: [{ role: 'system', content: \"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 합니다체.\\n\\n참고 자료 (사내 규정 전체):\\n# 업무용 장비 지급·반납 규정\\n\\n## 1. 지급과 교체\\n- 업무용 노트북은 입사일에 지급한다.\\n- 노트북 교체 주기는 4년이다. 고장이나 배터리 교체는 IT 문의로 접수한다.\\n\\n## 2. 분실과 도난\\n- 업무용 장비를 잃어버리거나 도난당하면 24시간 이내에 IT 문의와 보안팀에 신고한다.\\n- 신고가 접수되면 IT 담당자가 원격으로 장비의 데이터를 잠근다.\\n\\n## 3. 반납\\n- 퇴사하거나 장비를 교체할 때는 5영업일 이내에 장비를 IT 담당자에게 반납한다.\\n- 반납 전에 개인 파일을 지우지 말고, 업무 파일은 사내 드라이브로 옮긴다.\\n\\n## 4. 개인 장비 사용\\n- 개인 휴대폰이나 태블릿으로는 업무 메일과 메신저만 쓸 수 있다.\\n- 개인 장비로 업무 메일을 보려면 회사의 모바일 관리 앱(MDM)을 먼저 설치한다.\\n\\n# 메일·문서 보안 규정\\n\\n## 1. 메일함\\n- 개인 메일함 용량은 10GB이다. 용량이 90%를 넘으면 안내 메일이 발송된다.\\n- 오래된 메일은 보관함으로 옮겨 용량을 확보한다.\\n\\n## 2. 첨부 파일\\n- 메일 첨부 파일은 25MB까지 보낼 수 있다.\\n- 25MB를 넘는 파일은 사내 드라이브에 올리고 공유 링크를 보낸다.\\n- 개인정보가 들어 있는 파일을 외부로 보낼 때는 반드시 암호를 걸고, 암호는 메신저 등 다른 경로로 알려 준다.\\n\\n## 3. 문서 등급과 보관\\n- 대외비 문서에는 문서 첫 장에 '대외비'라고 표시한다.\\n- 업무 문서는 3년 동안 보관한 뒤 정리한다.\\n\\n# 회의실 이용 규정\\n\\n## 1. 예약\\n- 회의실은 사내 포털에서 예약한다. 한 번에 최대 3시간까지 예약할 수 있다.\\n- 예약 시작 후 10분이 지나도록 아무도 사용하지 않으면 예약이 자동으로 취소된다.\\n\\n## 2. 장비\\n- 대회의실의 화상회의 장비를 쓰려면 회의 시작 30분 전까지 IT 문의로 요청한다.\\n- 프로젝터는 B회의실과 대회의실에 있다.\\n\\n## 3. 사용 후\\n- 회의가 끝나면 화이트보드를 지우고 의자를 정리한다.\\n\\n# 비밀번호·계정 보안 규정\\n\\n## 1. 비밀번호 만들기\\n- 비밀번호는 12자 이상으로 만든다.\\n- 영문 대문자, 영문 소문자, 숫자, 특수문자 중 3종류 이상을 섞는다.\\n- 이름, 생일, 전화번호, 회사 이름처럼 추측하기 쉬운 단어는 쓰지 않는다.\\n\\n## 2. 변경 주기\\n- 비밀번호는 90일마다 바꾼다. 만료 7일 전부터 사내 포털에 변경 안내가 표시된다.\\n- 최근에 쓴 비밀번호 5개는 다시 쓸 수 없다.\\n\\n## 3. 로그인 실패와 잠금\\n- 로그인에 5번 연속 실패하면 계정이 30분 동안 잠긴다.\\n- 30분이 지나면 자동으로 풀린다. 급한 경우 IT 문의로 잠금 해제를 요청한다.\\n\\n## 4. 다단계 인증\\n- 회사 밖에서 사내 시스템에 접속할 때는 OTP 다단계 인증을 반드시 사용한다.\\n- OTP 앱이 설치된 휴대폰을 바꾸면 IT 포털에서 OTP를 다시 등록한다.\\n\\n# 개인정보 처리 지침\\n\\n## 1. 수집\\n- 업무에 꼭 필요한 개인정보만 수집한다.\\n- 주민등록번호는 법에서 정한 경우가 아니면 수집하지 않는다.\\n\\n## 2. 이용과 보관\\n- 고객 개인정보가 보이는 화면은 캡처하거나 촬영하지 않는다.\\n- 개인정보가 들어 있는 출력물은 다 쓴 뒤 바로 파쇄한다.\\n\\n## 3. 유출 대응\\n- 개인정보가 유출되었거나 유출이 의심되면 1시간 이내에 보안팀에 신고한다.\\n- 신고할 때는 유출된 정보의 종류, 대략적인 건수, 발견한 시각을 함께 알린다.\\n\\n# 원격근무·VPN 이용 규정\\n\\n## 1. 접속 원칙\\n- 회사 밖에서 사내 시스템에 접속할 때는 반드시 회사 VPN을 먼저 연결한다.\\n- 카페, 공항 같은 공용 와이파이에서는 VPN 없이 사내 시스템에 접속하지 않는다.\\n\\n## 2. VPN 인증서\\n- VPN 인증서의 유효 기간은 1년이다.\\n- 만료 30일 전부터 IT 포털의 '인증서 재발급' 메뉴에서 새 인증서를 신청한다.\\n- 인증서가 만료되면 VPN에 접속할 수 없으므로, 만료 전에 재발급한다.\\n\\n## 3. 재택근무 중 보안\\n- 자리를 비울 때는 화면을 잠그고, 5분 동안 사용하지 않으면 자동으로 잠Line truncated
|
||||
"options": {
|
||||
"timeout": 60000
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "openAiCourse0001",
|
||||
"name": "OpenAI (.env)"
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
2640,
|
||||
300
|
||||
],
|
||||
"id": "fd329729-e718-56ff-805c-aa298d26c0b2"
|
||||
},
|
||||
{
|
||||
"name": "Review",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://api.openai.com/v1/chat/completions",
|
||||
"authentication": "predefinedCredentialType",
|
||||
"nodeCredentialType": "openAiApi",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, messages: [{ role: 'system', content: \"You review a Korean IT help-desk answer before it is sent. Keep the facts exactly. Make it polite (합니다체), remove anything not supported by the data, keep at most 3 sentences. Return only the final answer text.\" }, { role: 'user', content: '초안: ' + $('Answer').first().json.choices[0].message.content }] }) }}",
|
||||
"options": {
|
||||
"timeout": 60000
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "openAiCourse0001",
|
||||
"name": "OpenAI (.env)"
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
2860,
|
||||
300
|
||||
],
|
||||
"id": "f289c264-5e1f-523d-8e18-67ba4cbb166c"
|
||||
},
|
||||
{
|
||||
"name": "Build result",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const ran = (n) => { try { return $(n).isExecuted; } catch (e) { return false; } };\nconst steps = [{\"step\": \"classify\", \"node\": \"Classify\", \"model\": \"gpt-4.1-mini\"}, {\"step\": \"answer\", \"node\": \"Answer\", \"model\": \"gpt-4.1-mini\"}, {\"step\": \"review\", \"node\": \"Review\", \"model\": \"gpt-4.1-mini\"}];\nconst prices = $('Get prices').first().json.models;\nconst by_step = {};\nlet prompt_tokens = 0, completion_tokens = 0, llm_calls = 0, cost_usd = 0;\nfor (const s of steps) {\n if (!ran(s.node)) continue;\n const u = $(s.node).first().json.usage ?? {};\n const row = { model: s.model, prompt_tokens: u.prompt_tokens ?? 0, completion_tokens: u.completion_tokens ?? 0 };\n const p = prices[s.model] ?? { input: 0, output: 0 };\n row.cost_usd = Math.round((row.prompt_tokens * p.input + row.completion_tokens * p.output) / 1e6 * 1e8) / 1e8;\n by_step[s.step] = row;\n prompt_tokens += row.prompt_tokens; completion_tokens += row.completion_tokens; cost_usd += row.cost_usd; llm_calls += 1;\n}\nconst results = $('Search FAQ').first().json.results ?? [];\nconst answer = String($('Review').first().json.choices[0].message.content ?? '').trim();\nconst result = { route: '자동 답변', answer, sources: results.map((r) => r.id), llm_calls,\n usage: { prompt_tokens, completion_tokens, by_step }, cost_usd: Math.round(cost_usd * 1e8) / 1e8 };\nreturn [{ json: result }];"
|
||||
},
|
||||
"position": [
|
||||
3520,
|
||||
300
|
||||
],
|
||||
"id": "d85f4d2b-0957-5a81-a54b-35ba55509871"
|
||||
},
|
||||
{
|
||||
"name": "Record usage",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "http://data-api:8800/usage",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ items: Object.entries($json.usage.by_step).map(([step, row]) => ({ request_id: $execution.id, user: $('Question webhook').first().json.body.user ?? '', step, model: row.model, prompt_tokens: row.prompt_tokens, completion_tokens: row.completion_tokens, cost_usd: row.cost_usd, route: $json.route })) }) }}",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
},
|
||||
"timeout": 3000
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
3740,
|
||||
300
|
||||
],
|
||||
"onError": "continueRegularOutput",
|
||||
"id": "a46b3503-5039-5cb6-890e-69149fe23f55"
|
||||
},
|
||||
{
|
||||
"name": "Result again",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "return [{ json: { ...$('Build result').first().json, recorded: Number($json.stored ?? 0) > 0 } }];"
|
||||
},
|
||||
"position": [
|
||||
3960,
|
||||
300
|
||||
],
|
||||
"id": "1b8127ae-a43b-5096-8f94-c96892a085db"
|
||||
},
|
||||
{
|
||||
"name": "Send result",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ $json }}",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
4180,
|
||||
300
|
||||
],
|
||||
"id": "6cbcbfe3-9306-5287-b0c6-79f3fe0f9a5e"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Question webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Has question?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Has question?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get prices",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Reject empty",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get prices": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Search FAQ",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Search FAQ": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get ticket",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get ticket": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Classify",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Classify": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Answer",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Answer": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Review",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Review": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Build result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Build result": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Record usage",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Record usage": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Result again",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Result again": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
{
|
||||
"name": "75 IT helpdesk + one LLM call (answer)",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Question webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "my-cost",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"webhookId": "797db9f9-e9ff-5363-9d0a-58d23a2047f0",
|
||||
"position": [
|
||||
0,
|
||||
300
|
||||
],
|
||||
"id": "3ccc3834-a583-51bd-a80f-b20124e32b64"
|
||||
},
|
||||
{
|
||||
"name": "Has question?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.2,
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "loose",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "c1",
|
||||
"leftValue": "={{ $json.body.question }}",
|
||||
"rightValue": "",
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"operation": "notEmpty",
|
||||
"singleValue": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
220,
|
||||
300
|
||||
],
|
||||
"id": "7e27dce9-fa3b-5c88-98b3-1dce956d16d2"
|
||||
},
|
||||
{
|
||||
"name": "Reject empty",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "{ \"error\": \"question is required\" }",
|
||||
"options": {
|
||||
"responseCode": 400
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
440,
|
||||
560
|
||||
],
|
||||
"id": "f3591be9-5676-5bb7-ab46-6984719af080"
|
||||
},
|
||||
{
|
||||
"name": "Get prices",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/prices",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
1100,
|
||||
300
|
||||
],
|
||||
"id": "baa4796d-564b-5cc0-949a-d79738b12ba7"
|
||||
},
|
||||
{
|
||||
"name": "Search FAQ",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/faq",
|
||||
"options": {},
|
||||
"sendQuery": true,
|
||||
"queryParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "q",
|
||||
"value": "={{ $('Question webhook').first().json.body.question }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
1540,
|
||||
300
|
||||
],
|
||||
"id": "e2005518-7a82-579a-acda-cc74b33968a9"
|
||||
},
|
||||
{
|
||||
"name": "Get ticket",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "=http://data-api:8800/tickets/{{ (($('Question webhook').first().json.body.question.match(/T-?\\d{4}/i) || ['NONE'])[0]).toUpperCase().replace(/^T(\\d)/, 'T-$1') }}",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"sendQuery": true,
|
||||
"queryParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "requester",
|
||||
"value": "={{ $('Question webhook').first().json.body.user ?? '' }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
1760,
|
||||
300
|
||||
],
|
||||
"id": "9d6c9b87-54bc-5433-bad3-6e33744e10ca"
|
||||
},
|
||||
{
|
||||
"name": "Rule classify",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const q = String($('Question webhook').first().json.body.question ?? '');\nconst results = $('Search FAQ').first().json.results ?? [];\nconst category = /T-?\\d{4}/i.test(q) ? 'ticket' : (results.length ? 'faq' : 'unknown');\nreturn [{ json: { category } }];"
|
||||
},
|
||||
"position": [
|
||||
2420,
|
||||
300
|
||||
],
|
||||
"id": "ff9310ae-451e-5825-bb13-67013e490b85"
|
||||
},
|
||||
{
|
||||
"name": "Known?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.2,
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "loose",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "c1",
|
||||
"leftValue": "={{ $json.category }}",
|
||||
"rightValue": "unknown",
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"operation": "notEquals"
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
2640,
|
||||
300
|
||||
],
|
||||
"id": "14570236-0f99-5931-8173-d03f18288d68"
|
||||
},
|
||||
{
|
||||
"name": "Classify",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://api.openai.com/v1/chat/completions",
|
||||
"authentication": "predefinedCredentialType",
|
||||
"nodeCredentialType": "openAiApi",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ model: 'gpt-5.4-nano', temperature: 0, messages: [{ role: 'system', content: \"You sort one company IT help-desk question. Categories: faq (how to fix or use something), ticket (asks about the status of a ticket like T-1002), other. Return only JSON, no code fence: {\\\"category\\\": \\\"faq\\\"}\" }, { role: 'user', content: $('Question webhook').first().json.body.question }] }) }}",
|
||||
"options": {
|
||||
"timeout": 60000
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
2860,
|
||||
520
|
||||
],
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "openAiCourse0001",
|
||||
"name": "OpenAI (.env)"
|
||||
}
|
||||
},
|
||||
"id": "07e553a6-f8eb-5513-a566-db99f5b11d29"
|
||||
},
|
||||
{
|
||||
"name": "Parse category",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const raw = String($json.choices?.[0]?.message?.content ?? '').replace(/^```(json)?\\s*|```$/g, '');\nlet category = 'other';\ntry { category = JSON.parse(raw).category ?? 'other'; } catch (error) { category = 'other'; }\nreturn [{ json: { category } }];"
|
||||
},
|
||||
"position": [
|
||||
3080,
|
||||
520
|
||||
],
|
||||
"id": "1a1ea3bc-20b6-5218-b12e-9bafa39f3d8c"
|
||||
},
|
||||
{
|
||||
"name": "Answer",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://api.openai.com/v1/chat/completions",
|
||||
"authentication": "predefinedCredentialType",
|
||||
"nodeCredentialType": "openAiApi",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, max_completion_tokens: 200, messages: [{ role: 'system', content: \"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 합니다체.\" }, { role: 'user', content: '질문: ' + $('Question webhook').first().json.body.question + '\\nFAQ: ' + JSON.stringify(($('Search FAQ').first().json.results ?? []).map((f) => ({ id: f.id, title: f.title, answer: f.answer }))) + '\\n접수 건: ' + JSON.stringify((({ ticket_id, title, status, note, error }) => ({ ticket_id, title, status, note, error }))($('Get ticket').first().json)) }] }) }}",
|
||||
"options": {
|
||||
"timeout": 60000
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
3300,
|
||||
300
|
||||
],
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "openAiCourse0001",
|
||||
"name": "OpenAI (.env)"
|
||||
}
|
||||
},
|
||||
"id": "fd329729-e718-56ff-805c-aa298d26c0b2"
|
||||
},
|
||||
{
|
||||
"name": "Build result",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const ran = (n) => { try { return $(n).isExecuted; } catch (e) { return false; } };\nconst steps = [{\"step\": \"classify\", \"node\": \"Classify\", \"model\": \"gpt-5.4-nano\"}, {\"step\": \"answer\", \"node\": \"Answer\", \"model\": \"gpt-4.1-mini\"}];\nconst prices = $('Get prices').first().json.models;\nconst by_step = {};\nlet prompt_tokens = 0, completion_tokens = 0, llm_calls = 0, cost_usd = 0;\nfor (const s of steps) {\n if (!ran(s.node)) continue;\n const u = $(s.node).first().json.usage ?? {};\n const row = { model: s.model, prompt_tokens: u.prompt_tokens ?? 0, completion_tokens: u.completion_tokens ?? 0 };\n const p = prices[s.model] ?? { input: 0, output: 0 };\n row.cost_usd = Math.round((row.prompt_tokens * p.input + row.completion_tokens * p.output) / 1e6 * 1e8) / 1e8;\n by_step[s.step] = row;\n prompt_tokens += row.prompt_tokens; completion_tokens += row.completion_tokens; cost_usd += row.cost_usd; llm_calls += 1;\n}\nconst results = $('Search FAQ').first().json.results ?? [];\nconst answer = String($('Answer').first().json.choices[0].message.content ?? '').trim();\nconst result = { route: '자동 답변', answer, sources: results.map((r) => r.id), llm_calls,\n usage: { prompt_tokens, completion_tokens, by_step }, cost_usd: Math.round(cost_usd * 1e8) / 1e8 };\nreturn [{ json: result }];"
|
||||
},
|
||||
"position": [
|
||||
3520,
|
||||
300
|
||||
],
|
||||
"id": "d85f4d2b-0957-5a81-a54b-35ba55509871"
|
||||
},
|
||||
{
|
||||
"name": "Record usage",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "http://data-api:8800/usage",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ items: Object.entries($json.usage.by_step).map(([step, row]) => ({ request_id: $execution.id, user: $('Question webhook').first().json.body.user ?? '', step, model: row.model, prompt_tokens: row.prompt_tokens, completion_tokens: row.completion_tokens, cost_usd: row.cost_usd, route: $json.route })) }) }}",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
},
|
||||
"timeout": 3000
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
3740,
|
||||
300
|
||||
],
|
||||
"onError": "continueRegularOutput",
|
||||
"id": "a46b3503-5039-5cb6-890e-69149fe23f55"
|
||||
},
|
||||
{
|
||||
"name": "Result again",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "return [{ json: { ...$('Build result').first().json, recorded: Number($json.stored ?? 0) > 0 } }];"
|
||||
},
|
||||
"position": [
|
||||
3960,
|
||||
300
|
||||
],
|
||||
"id": "1b8127ae-a43b-5096-8f94-c96892a085db"
|
||||
},
|
||||
{
|
||||
"name": "Send result",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ $json }}",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
4180,
|
||||
300
|
||||
],
|
||||
"id": "6cbcbfe3-9306-5287-b0c6-79f3fe0f9a5e"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Question webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Has question?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Has question?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get prices",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Reject empty",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get prices": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Search FAQ",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Search FAQ": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get ticket",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get ticket": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Rule classify",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Rule classify": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Known?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Known?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Answer",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Classify",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Answer": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Build result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Build result": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Record usage",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Record usage": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Result again",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Result again": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Classify": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Parse category",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Parse category": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Answer",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
{
|
||||
"name": "75 IT helpdesk + long question cap (answer)",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Question webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "my-cost",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"webhookId": "797db9f9-e9ff-5363-9d0a-58d23a2047f0",
|
||||
"position": [
|
||||
0,
|
||||
300
|
||||
],
|
||||
"id": "3ccc3834-a583-51bd-a80f-b20124e32b64"
|
||||
},
|
||||
{
|
||||
"name": "Has question?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.2,
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "loose",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "c1",
|
||||
"leftValue": "={{ $json.body.question }}",
|
||||
"rightValue": "",
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"operation": "notEmpty",
|
||||
"singleValue": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
220,
|
||||
300
|
||||
],
|
||||
"id": "7e27dce9-fa3b-5c88-98b3-1dce956d16d2"
|
||||
},
|
||||
{
|
||||
"name": "Reject empty",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "{ \"error\": \"question is required\" }",
|
||||
"options": {
|
||||
"responseCode": 400
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
440,
|
||||
560
|
||||
],
|
||||
"id": "f3591be9-5676-5bb7-ab46-6984719af080"
|
||||
},
|
||||
{
|
||||
"name": "Get budget",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/budget",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"id": "4d62a650-0746-5e07-b811-2da4fdd28051"
|
||||
},
|
||||
{
|
||||
"name": "Get prices",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/prices",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
1100,
|
||||
300
|
||||
],
|
||||
"id": "baa4796d-564b-5cc0-949a-d79738b12ba7"
|
||||
},
|
||||
{
|
||||
"name": "Prepare question",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const q = String($('Question webhook').first().json.body.question ?? '');\nconst max = Number($('Get budget').first().json.max_question_chars ?? 500);\nif (q.length <= max) return [{ json: { question: q, trimmed: false, question_chars: q.length } }];\nconst head = Math.floor(max * 0.4);\nconst question = q.slice(0, head) + ' … ' + q.slice(q.length - (max - head));\nreturn [{ json: { question, trimmed: true, question_chars: q.length } }];"
|
||||
},
|
||||
"position": [
|
||||
1320,
|
||||
300
|
||||
],
|
||||
"id": "07d55a70-f59d-584f-bbae-3cfcf59973ac"
|
||||
},
|
||||
{
|
||||
"name": "Search FAQ",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/faq",
|
||||
"options": {},
|
||||
"sendQuery": true,
|
||||
"queryParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "q",
|
||||
"value": "={{ $('Prepare question').first().json.question }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
1540,
|
||||
300
|
||||
],
|
||||
"id": "e2005518-7a82-579a-acda-cc74b33968a9"
|
||||
},
|
||||
{
|
||||
"name": "Get ticket",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "=http://data-api:8800/tickets/{{ (($('Question webhook').first().json.body.question.match(/T-?\\d{4}/i) || ['NONE'])[0]).toUpperCase().replace(/^T(\\d)/, 'T-$1') }}",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"sendQuery": true,
|
||||
"queryParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "requester",
|
||||
"value": "={{ $('Question webhook').first().json.body.user ?? '' }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
1760,
|
||||
300
|
||||
],
|
||||
"id": "9d6c9b87-54bc-5433-bad3-6e33744e10ca"
|
||||
},
|
||||
{
|
||||
"name": "Rule classify",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const q = String($('Prepare question').first().json.question ?? '');\nconst results = $('Search FAQ').first().json.results ?? [];\nconst category = /T-?\\d{4}/i.test(q) ? 'ticket' : (results.length ? 'faq' : 'unknown');\nreturn [{ json: { category } }];"
|
||||
},
|
||||
"position": [
|
||||
2420,
|
||||
300
|
||||
],
|
||||
"id": "ff9310ae-451e-5825-bb13-67013e490b85"
|
||||
},
|
||||
{
|
||||
"name": "Known?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.2,
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "loose",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "c1",
|
||||
"leftValue": "={{ $json.category }}",
|
||||
"rightValue": "unknown",
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"operation": "notEquals"
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
2640,
|
||||
300
|
||||
],
|
||||
"id": "14570236-0f99-5931-8173-d03f18288d68"
|
||||
},
|
||||
{
|
||||
"name": "Classify",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://api.openai.com/v1/chat/completions",
|
||||
"authentication": "predefinedCredentialType",
|
||||
"nodeCredentialType": "openAiApi",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ model: 'gpt-5.4-nano', temperature: 0, messages: [{ role: 'system', content: \"You sort one company IT help-desk question. Categories: faq (how to fix or use something), ticket (asks about the status of a ticket like T-1002), other. Return only JSON, no code fence: {\\\"category\\\": \\\"faq\\\"}\" }, { role: 'user', content: $('Prepare question').first().json.question }] }) }}",
|
||||
"options": {
|
||||
"timeout": 60000
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
2860,
|
||||
520
|
||||
],
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "openAiCourse0001",
|
||||
"name": "OpenAI (.env)"
|
||||
}
|
||||
},
|
||||
"id": "07e553a6-f8eb-5513-a566-db99f5b11d29"
|
||||
},
|
||||
{
|
||||
"name": "Parse category",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const raw = String($json.choices?.[0]?.message?.content ?? '').replace(/^```(json)?\\s*|```$/g, '');\nlet category = 'other';\ntry { category = JSON.parse(raw).category ?? 'other'; } catch (error) { category = 'other'; }\nreturn [{ json: { category } }];"
|
||||
},
|
||||
"position": [
|
||||
3080,
|
||||
520
|
||||
],
|
||||
"id": "1a1ea3bc-20b6-5218-b12e-9bafa39f3d8c"
|
||||
},
|
||||
{
|
||||
"name": "Answer",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://api.openai.com/v1/chat/completions",
|
||||
"authentication": "predefinedCredentialType",
|
||||
"nodeCredentialType": "openAiApi",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, max_completion_tokens: 200, messages: [{ role: 'system', content: \"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 합니다체.\" }, { role: 'user', content: '질문: ' + $('Prepare question').first().json.question + '\\nFAQ: ' + JSON.stringify(($('Search FAQ').first().json.results ?? []).map((f) => ({ id: f.id, title: f.title, answer: f.answer }))) + '\\n접수 건: ' + JSON.stringify((({ ticket_id, title, status, note, error }) => ({ ticket_id, title, status, note, error }))($('Get ticket').first().json)) }] }) }}",
|
||||
"options": {
|
||||
"timeout": 60000
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
3300,
|
||||
300
|
||||
],
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "openAiCourse0001",
|
||||
"name": "OpenAI (.env)"
|
||||
}
|
||||
},
|
||||
"id": "fd329729-e718-56ff-805c-aa298d26c0b2"
|
||||
},
|
||||
{
|
||||
"name": "Build result",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const ran = (n) => { try { return $(n).isExecuted; } catch (e) { return false; } };\nconst steps = [{\"step\": \"classify\", \"node\": \"Classify\", \"model\": \"gpt-5.4-nano\"}, {\"step\": \"answer\", \"node\": \"Answer\", \"model\": \"gpt-4.1-mini\"}];\nconst prices = $('Get prices').first().json.models;\nconst by_step = {};\nlet prompt_tokens = 0, completion_tokens = 0, llm_calls = 0, cost_usd = 0;\nfor (const s of steps) {\n if (!ran(s.node)) continue;\n const u = $(s.node).first().json.usage ?? {};\n const row = { model: s.model, prompt_tokens: u.prompt_tokens ?? 0, completion_tokens: u.completion_tokens ?? 0 };\n const p = prices[s.model] ?? { input: 0, output: 0 };\n row.cost_usd = Math.round((row.prompt_tokens * p.input + row.completion_tokens * p.output) / 1e6 * 1e8) / 1e8;\n by_step[s.step] = row;\n prompt_tokens += row.prompt_tokens; completion_tokens += row.completion_tokens; cost_usd += row.cost_usd; llm_calls += 1;\n}\nconst results = $('Search FAQ').first().json.results ?? [];\nconst answer = String($('Answer').first().json.choices[0].message.content ?? '').trim();\nconst result = { route: '자동 답변', answer, sources: results.map((r) => r.id), llm_calls,\n usage: { prompt_tokens, completion_tokens, by_step }, cost_usd: Math.round(cost_usd * 1e8) / 1e8 };\nconst prepared = $('Prepare question').first().json;\nresult.trimmed = prepared.trimmed;\nresult.question_chars = prepared.question_chars;\nreturn [{ json: result }];"
|
||||
},
|
||||
"position": [
|
||||
3520,
|
||||
300
|
||||
],
|
||||
"id": "d85f4d2b-0957-5a81-a54b-35ba55509871"
|
||||
},
|
||||
{
|
||||
"name": "Record usage",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "http://data-api:8800/usage",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ items: Object.entries($json.usage.by_step).map(([step, row]) => ({ request_id: $execution.id, user: $('Question webhook').first().json.body.user ?? '', step, model: row.model, prompt_tokens: row.prompt_tokens, completion_tokens: row.completion_tokens, cost_usd: row.cost_usd, route: $json.route })) }) }}",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
},
|
||||
"timeout": 3000
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
3740,
|
||||
300
|
||||
],
|
||||
"onError": "continueRegularOutput",
|
||||
"id": "a46b3503-5039-5cb6-890e-69149fe23f55"
|
||||
},
|
||||
{
|
||||
"name": "Result again",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "return [{ json: { ...$('Build result').first().json, recorded: Number($json.stored ?? 0) > 0 } }];"
|
||||
},
|
||||
"position": [
|
||||
3960,
|
||||
300
|
||||
],
|
||||
"id": "1b8127ae-a43b-5096-8f94-c96892a085db"
|
||||
},
|
||||
{
|
||||
"name": "Send result",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ $json }}",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
4180,
|
||||
300
|
||||
],
|
||||
"id": "6cbcbfe3-9306-5287-b0c6-79f3fe0f9a5e"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Question webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Has question?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Has question?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get budget",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Reject empty",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get budget": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get prices",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get prices": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Prepare question",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Prepare question": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Search FAQ",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Search FAQ": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get ticket",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get ticket": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Rule classify",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Rule classify": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Known?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Known?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Answer",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Classify",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Answer": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Build result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Build result": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Record usage",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Record usage": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Result again",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Result again": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Classify": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Parse category",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Parse category": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Answer",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"name": "75 Cost report (answer)",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Report webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "my-cost-report",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
0,
|
||||
300
|
||||
],
|
||||
"id": "45dddbe3-765d-5071-8c44-f1406634465b",
|
||||
"webhookId": "49a4308f-5df9-5973-963a-3d7f6bbeb9b7"
|
||||
},
|
||||
{
|
||||
"name": "Get summary",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/usage/summary",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
220,
|
||||
300
|
||||
],
|
||||
"id": "cf6e061b-9073-57ea-a564-1769eec23afd"
|
||||
},
|
||||
{
|
||||
"name": "Get budget",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/budget",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"id": "fb9bdc4e-453a-5261-9b27-f913533bebb5"
|
||||
},
|
||||
{
|
||||
"name": "Make report",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const s = $('Get summary').first().json;\nconst b = $('Get budget').first().json;\nconst body = $('Report webhook').first().json.body ?? {};\nconst perDay = Number(body.requests_per_day ?? s.requests);\nconst workdays = Number(body.workdays ?? 22);\nconst avg = s.requests ? s.cost_usd / s.requests : 0;\nconst round = (v) => Math.round(v * 1e6) / 1e6;\nconst users = Object.entries(s.by_user).sort((a, b2) => b2[1].cost_usd - a[1].cost_usd);\nconst models = Object.fromEntries(Object.entries(s.by_model).map(([k, v]) => [k, round(v.cost_usd)]));\nconst result = { requests: s.requests, calls: s.calls, cost_usd: round(s.cost_usd), avg_usd: round(avg),\n by_model: models, top_user: users.length ? users[0][0] : null,\n projected_month_usd: round(avg * perDay * workdays), daily_budget_usd: b.daily_usd, remaining_usd: round(b.remaining_usd), over_budget: b.over };\nreturn [{ json: result }];"
|
||||
},
|
||||
"position": [
|
||||
880,
|
||||
300
|
||||
],
|
||||
"id": "ee73c6a8-12a8-57fc-ab34-81fd258667cc"
|
||||
},
|
||||
{
|
||||
"name": "Send report",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ $json }}",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
1100,
|
||||
300
|
||||
],
|
||||
"id": "c124f9d7-3b8c-5134-8343-936873621946"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Report webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get summary",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get summary": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get budget",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get budget": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Make report",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Make report": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send report",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
{
|
||||
"name": "75 Cost report + what-if (answer)",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Report webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "my-cost-report",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
0,
|
||||
300
|
||||
],
|
||||
"id": "45dddbe3-765d-5071-8c44-f1406634465b",
|
||||
"webhookId": "49a4308f-5df9-5973-963a-3d7f6bbeb9b7"
|
||||
},
|
||||
{
|
||||
"name": "Get summary",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/usage/summary",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
220,
|
||||
300
|
||||
],
|
||||
"id": "cf6e061b-9073-57ea-a564-1769eec23afd"
|
||||
},
|
||||
{
|
||||
"name": "Get budget",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/budget",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"id": "fb9bdc4e-453a-5261-9b27-f913533bebb5"
|
||||
},
|
||||
{
|
||||
"name": "Get prices",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/prices",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
660,
|
||||
300
|
||||
],
|
||||
"id": "c0db1fd8-4b10-5824-a526-85ebbd70230a"
|
||||
},
|
||||
{
|
||||
"name": "Make report",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const s = $('Get summary').first().json;\nconst b = $('Get budget').first().json;\nconst body = $('Report webhook').first().json.body ?? {};\nconst perDay = Number(body.requests_per_day ?? s.requests);\nconst workdays = Number(body.workdays ?? 22);\nconst avg = s.requests ? s.cost_usd / s.requests : 0;\nconst round = (v) => Math.round(v * 1e6) / 1e6;\nconst users = Object.entries(s.by_user).sort((a, b2) => b2[1].cost_usd - a[1].cost_usd);\nconst models = Object.fromEntries(Object.entries(s.by_model).map(([k, v]) => [k, round(v.cost_usd)]));\nconst result = { requests: s.requests, calls: s.calls, cost_usd: round(s.cost_usd), avg_usd: round(avg),\n by_model: models, top_user: users.length ? users[0][0] : null,\n projected_month_usd: round(avg * perDay * workdays), daily_budget_usd: b.daily_usd, remaining_usd: round(b.remaining_usd), over_budget: b.over };\nconst target = body.what_if_model;\nif (target) {\n const prices = $('Get prices').first().json.models;\n const p = prices[target];\n if (!p) return [{ json: { error: 'unknown model', model: target, known: Object.keys(prices) } }];\n const rows = Object.values(s.by_model);\n const tokensIn = rows.reduce((sum, r) => sum + r.prompt_tokens, 0);\n const tokensOut = rows.reduce((sum, r) => sum + r.completion_tokens, 0);\n const cost = (tokensIn * p.input + tokensOut * p.output) / 1e6;\n result.what_if = { model: target, cost_usd: round(cost), saving_usd: round(s.cost_usd - cost),\n projected_month_usd: round((s.requests ? cost / s.requests : 0) * perDay * workdays) };\n}\nreturn [{ json: result }];"
|
||||
},
|
||||
"position": [
|
||||
880,
|
||||
300
|
||||
],
|
||||
"id": "ee73c6a8-12a8-57fc-ab34-81fd258667cc"
|
||||
},
|
||||
{
|
||||
"name": "Known model?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.2,
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "loose",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "c1",
|
||||
"leftValue": "={{ $json.error ?? '' }}",
|
||||
"rightValue": "unknown model",
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"operation": "notEquals"
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
1100,
|
||||
300
|
||||
],
|
||||
"id": "4ba2d3fa-9968-50d3-a29c-4c19ef3f7592"
|
||||
},
|
||||
{
|
||||
"name": "Send report",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ $json }}",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
1320,
|
||||
300
|
||||
],
|
||||
"id": "c124f9d7-3b8c-5134-8343-936873621946"
|
||||
},
|
||||
{
|
||||
"name": "Reject model",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ $json }}",
|
||||
"options": {
|
||||
"responseCode": 400
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
1320,
|
||||
520
|
||||
],
|
||||
"id": "95cf4c50-09ed-52f3-81e5-bfd72d87904b"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Report webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get summary",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get summary": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get budget",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get budget": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get prices",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get prices": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Make report",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Make report": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Known model?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Known model?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send report",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Reject model",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,42 @@
|
||||
# tcost IMP-PLAN
|
||||
|
||||
## 엔진 1단계 · 단가와 토큰 (R1 · R2 · R3 · R13)
|
||||
- [x] prices.load(): `checked` · `source` · `models`, `--prices` · `$TCOST_PRICES` · `~/.tcost/prices.json` · 함께 든 파일
|
||||
- [x] Prices.cost(model, in, out): (in × input + out × output) / 1,000,000, 소수 8자리
|
||||
- [x] warning(): 30일 넘었거나 날짜가 없으면 알림
|
||||
- [x] tokens.count(): tiktoken `o200k_base`, 이 컴퓨터에서 (API를 부르지 않음)
|
||||
- [x] cli: `tcost count <파일 · 폴더> [--out N] [--model M]`
|
||||
- [x] tests: 1,000토큰 × 0.40 = $0.0004, `--out 100` 더하기, 날짜 경고
|
||||
- 확인: `uv run pytest -q`, `uv run tcost count <지시문 파일> --out 100`
|
||||
|
||||
## 엔진 2단계 · 보고서 모으기 · 한 달 (R5 · R6)
|
||||
- [x] usage.collect(paths): `runs/*.json`에서 `usage.by_step`이 있는 결과만, 없는 것은 skipped
|
||||
- [x] 합 · 요청 수 · 호출 수, 모델별 · 사람별 · 시간대별(파일 이름의 시각)
|
||||
- [x] month(per_day, days): 평균 × 하루 요청 × 일하는 날
|
||||
- [x] cli: `tcost usage <폴더> [--per-day 1000 --days 22]`
|
||||
- [x] tests: 결과 3개 중 400 하나 skipped, 사람별 합, 한 달
|
||||
- 확인: `uv run tcost usage ~/aio-01d4-stu/Last3Mon/75/runs --per-day 1000`
|
||||
|
||||
## TUI 1단계 · 탭 셋 (R8)
|
||||
- [x] Prompts: 글 파일 · 워크플로우 LLM 단계마다 토큰, 모델별 1,000번 비용
|
||||
- [x] Usage: 보고서 합 · 모델별 · 사람별 표, 시간대별 막대
|
||||
- [x] Compare: 토큰이 많은 지시문 열 개를 모델별로 (출력 100 가정)
|
||||
- [x] 엔진은 고치지 않고 불러 쓰기만, `[r]` 다시 읽기
|
||||
- [x] tests: `run_test`로 세 탭이 채워짐
|
||||
- 확인: `uv run tcost ~/aio-01d4-stu/Last3Mon/75/reference ~/aio-01d4-stu/Last3Mon/75/runs`
|
||||
|
||||
## 설치 (R14)
|
||||
- [x] `[project.scripts] tcost = "tcost.cli:main"`, 단가 파일을 wheel에 함께
|
||||
- [x] `uv tool install --editable .`, `/tmp`에서 `tcost --help`
|
||||
|
||||
## 엔진 3단계 · 워크플로우 · 비교 · 모르는 모델 (R4 · R7 · R12)
|
||||
- [x] workflow.llm_steps(): HTTP `chat/completions` 노드의 `jsonBody`에서 모델 · system 글 · `max_completion_tokens`, AI Agent 노드도
|
||||
- [x] cli: `tcost workflow <파일 · 폴더>`, `tcost compare <파일> --model a --model b`
|
||||
- [x] 단가표에 없는 모델은 0원이 아니라 오류(알려진 목록)
|
||||
- [x] tests: baseline `Answer` 1,000토큰 넘음 · lean 200 아래, `gpt-9` → 종료 코드 2
|
||||
|
||||
## 엔진 4단계 · 예산 · 다른 프로그램 · 폴더 (R9 · R10 · R11)
|
||||
- [x] cli: `tcost budget <달러> <파일>` 모델별 하루 몇 번
|
||||
- [x] `--json`, `--max-usd`: 넘으면 종료 코드 1, 오류 2
|
||||
- [x] scan(): `.txt` · `.prompt` 글 (`.md` 문서는 skipped), `nodes` · `connections` 워크플로우, `results` 보고서, 나머지 skipped
|
||||
- [x] tests: 테스트 파일은 skipped, 긴 글 `--max-usd 0.0002` → 1
|
||||
@@ -0,0 +1,54 @@
|
||||
# tcost · 강사가 수업 준비 때 직접 만든 본 (2026-10-28)
|
||||
|
||||
> **정답 자료** · 학생 저장소에는 수업 뒤 [VIBE-A-TCOST.md](../VIBE-A-TCOST.md)와 함께 올립니다.
|
||||
> 이 본은 강사가 CLI 에이전트에 단계 요청문을 보내 만든 것이 아니라, Claude Code가 VIBE-A의 SPEC · IMP-PLAN을 보고 직접 썼습니다. 학생이 만든 것과 파일 구성이 달라도 됩니다. 요구 R1~R14의 **확인 방법**이 같으면 됩니다.
|
||||
|
||||
## 설치 · 실행
|
||||
|
||||
```bash
|
||||
cd ~/aio-01d4-stu/Last3Mon/75/tcost
|
||||
uv sync && uv run pytest -q # 테스트 13개 (API를 부르지 않음)
|
||||
uv tool install --editable . # 어느 폴더에서나 tcost (R14)
|
||||
cd /tmp && tcost --help
|
||||
```
|
||||
|
||||
| 하고 싶은 것 | 명령 |
|
||||
| --- | --- |
|
||||
| 글 파일의 토큰 · 비용 | `tcost count prompt.txt --out 100` (폴더면 `.txt` · `.prompt`만) |
|
||||
| 워크플로우의 LLM 단계 | `tcost workflow reference/baseline-cost.json` |
|
||||
| 보고서의 실제 사용량 · 한 달 | `tcost usage runs --per-day 1000 --days 22` |
|
||||
| 두 모델 비교 | `tcost compare prompt.txt --model gpt-4.1-mini --model gpt-5.4-nano --out 100` |
|
||||
| 예산으로 하루 몇 번 | `tcost budget 1.0 prompt.txt --out 100` |
|
||||
| 폴더에서 찾은 것 | `tcost scan .` |
|
||||
| 화면(TUI) | `tcost reference my-work runs` (폴더 여럿, `[r]` 다시 읽기, `[q]` 끝) |
|
||||
| 비용 관문 | `tcost count prompt.txt --out 200 --max-usd 0.0002; echo $?` (넘으면 1) |
|
||||
|
||||
- 단가: `--prices <파일>`, `$TCOST_PRICES`, `~/.tcost/prices.json`, 없으면 함께 든 `src/tcost/prices.json` (2026-10-28 확인). 확인한 지 30일이 넘으면 알림.
|
||||
- 종료 코드: 0 괜찮음 · 1 `--max-usd`를 넘음 · 2 오류 (파일이 없음 · 모르는 모델 · 워크플로우가 아님).
|
||||
- 토큰은 이 컴퓨터에서 셉니다 (tiktoken `o200k_base`). OpenAI가 세는 값과 조금 다를 수 있습니다.
|
||||
|
||||
## 파일
|
||||
|
||||
| 파일 | 하는 일 | 요구 |
|
||||
| --- | --- | --- |
|
||||
| `src/tcost/prices.py` · `prices.json` | 단가표(날짜 · 출처), 비용 식, 오래됨 알림, 모르는 모델 | R1 · R2 · R3 · R12 |
|
||||
| `src/tcost/tokens.py` | 토큰 세기 (API 없이) | R1 · R13 |
|
||||
| `src/tcost/workflow.py` | 워크플로우의 LLM 단계 · 고정 지시문 | R4 |
|
||||
| `src/tcost/usage.py` | 보고서 모으기 · 한 달 | R5 · R6 |
|
||||
| `src/tcost/scan.py` | 폴더 나누기 | R11 |
|
||||
| `src/tcost/cli.py` | 명령 · `--json` · `--max-usd` · 종료 코드 | R7 · R9 · R10 |
|
||||
| `src/tcost/app.py` | Textual 화면 | R8 |
|
||||
| `tests/` | baseline · lean 워크플로우와 작은 보고서(`tests/data`, 비밀 없음) | |
|
||||
| `Doc/IMP-PLAN.md` | 만든 순서 (엔진 1 → 2 → TUI 1 → 설치 → 엔진 3 · 4) | |
|
||||
|
||||
## 2026-10-28에 확인한 것
|
||||
|
||||
| 확인 | 결과 |
|
||||
| --- | --- |
|
||||
| `uv run pytest -q` | 13 passed |
|
||||
| `tcost workflow reference/baseline-cost.json` | `Answer` 지시문 **1,670토큰** (n8n이 잰 답 단계 입력 1,781 중 고정 몫) |
|
||||
| `tcost workflow answers/75-lab02-challenge2-long-question.json` | `Answer` 75토큰 · 출력 상한 200 |
|
||||
| `tcost usage answers --per-day 1000` | 보고서 23개 · 요청 82 · $0.038746 · skipped 26 |
|
||||
| 긴 프린터 질문(6,298자)만 `count --out 60` | 2,613토큰 · mini $0.001141 (n8n 실제 $0.001196), `--max-usd 0.0003` → 종료 코드 1 |
|
||||
| `tcost count x --model gpt-4.1-nano` | `unknown model 'gpt-4.1-nano'`, 종료 코드 2 |
|
||||
| `uv tool install --editable .` → `/tmp`에서 `tcost --help` | 됨 |
|
||||
@@ -0,0 +1,26 @@
|
||||
[project]
|
||||
name = "tcost"
|
||||
version = "0.1.0"
|
||||
description = "Count tokens, estimate LLM cost before calling, and sum the usage of n8n test reports"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["textual>=0.80", "tiktoken>=0.7"]
|
||||
|
||||
[project.scripts]
|
||||
tcost = "tcost.cli:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/tcost"]
|
||||
|
||||
[tool.hatch.build.targets.wheel.force-include]
|
||||
"src/tcost/prices.json" = "tcost/prices.json"
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pytest>=8", "pytest-asyncio>=0.23"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
@@ -0,0 +1 @@
|
||||
"""tcost: count tokens, estimate LLM cost before calling, and sum the usage of n8n test reports."""
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Textual screen (R8): tabs Prompts · Usage · Compare for one or more folders. Reads only; never calls an API."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import DataTable, Footer, Header, Static, TabbedContent, TabPane
|
||||
|
||||
from . import tokens
|
||||
from .scan import scan
|
||||
from .usage import collect
|
||||
from .workflow import is_workflow, llm_steps, load
|
||||
|
||||
BARS = " ▁▂▃▄▅▆▇█"
|
||||
|
||||
|
||||
def is_workflow_file(path: Path) -> bool:
|
||||
try:
|
||||
return is_workflow(json.loads(path.read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def bars(values: dict[str, float]) -> str:
|
||||
top = max(values.values(), default=0) or 1
|
||||
return " ".join(f"{key} {BARS[round(value / top * 8)]}" for key, value in values.items())
|
||||
|
||||
|
||||
class CostApp(App):
|
||||
TITLE = "tcost"
|
||||
BINDINGS = [("r", "reload", "Reload"), ("q", "quit", "Quit")]
|
||||
CSS = "#hours { height: auto; margin: 1 1; } #note { height: auto; margin: 0 1; }"
|
||||
|
||||
def __init__(self, paths: str | list[str], prices):
|
||||
super().__init__()
|
||||
self.paths = [Path(p) for p in ([paths] if isinstance(paths, str) else paths)]
|
||||
self.prices = prices
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
with TabbedContent(initial="prompts"):
|
||||
with TabPane("Prompts", id="prompts"):
|
||||
yield DataTable(id="prompt-table")
|
||||
with TabPane("Usage", id="usage"):
|
||||
yield Static("", id="usage-head")
|
||||
yield DataTable(id="usage-table")
|
||||
yield Static("", id="hours")
|
||||
with TabPane("Compare", id="compare"):
|
||||
yield DataTable(id="compare-table")
|
||||
yield Static("", id="note", markup=False)
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self):
|
||||
self.sub_title = f"{' '.join(map(str, self.paths))} · prices {self.prices.checked or '?'}"
|
||||
self.action_reload()
|
||||
|
||||
def action_reload(self):
|
||||
texts, workflows, reports = [], [], []
|
||||
for path in self.paths:
|
||||
if path.is_dir():
|
||||
found = scan(path)
|
||||
texts, workflows, reports = texts + found.texts, workflows + found.workflows, reports + found.reports
|
||||
elif path.suffix == ".json":
|
||||
(workflows if is_workflow_file(path) else reports).append(path)
|
||||
else:
|
||||
texts.append(path)
|
||||
models = sorted(self.prices.models)
|
||||
rows: list[tuple[str, int, str]] = []
|
||||
for path in texts:
|
||||
rows.append((path.name, tokens.count(path.read_text(encoding="utf-8")), ""))
|
||||
for path in workflows:
|
||||
for step in llm_steps(load(path)):
|
||||
rows.append((f"{path.name} · {step.node}", step.system_tokens, step.model))
|
||||
table = self.query_one("#prompt-table", DataTable)
|
||||
table.clear(columns=True)
|
||||
table.add_columns("prompt", "tokens", *[f"{m} /1,000" for m in models])
|
||||
for name, n, _ in rows:
|
||||
table.add_row(name, n, *[f"${self.prices.cost(m, n) * 1000:.4f}" for m in models])
|
||||
usage = collect(reports)
|
||||
self.query_one("#usage-head", Static).update(
|
||||
f"{usage.reports} report(s) · {usage.requests} request(s) · ${usage.total['cost_usd']:.6f}"
|
||||
f" · avg ${usage.avg_usd:.6f} · skipped {usage.skipped}")
|
||||
table = self.query_one("#usage-table", DataTable)
|
||||
table.clear(columns=True)
|
||||
table.add_columns("by", "name", "calls", "in", "out", "cost")
|
||||
for title, data in (("model", usage.by_model), ("user", usage.by_user)):
|
||||
for key, row in sorted(data.items(), key=lambda kv: -kv[1]["cost_usd"]):
|
||||
table.add_row(title, key, row["calls"], row["prompt_tokens"], row["completion_tokens"], f"${row['cost_usd']:.6f}")
|
||||
self.query_one("#hours", Static).update("by hour " + bars({k: v["cost_usd"] for k, v in sorted(usage.by_hour.items())}))
|
||||
table = self.query_one("#compare-table", DataTable)
|
||||
table.clear(columns=True)
|
||||
table.add_columns("prompt", "tokens", *[f"{m} /call (out 100)" for m in models])
|
||||
for name, n, _ in sorted(rows, key=lambda r: -r[1])[:10]:
|
||||
table.add_row(name, n, *[f"${self.prices.cost(m, n, 100):.6f}" for m in models])
|
||||
note = self.prices.warning()
|
||||
self.query_one("#note", Static).update(f"! {note}" if note else f"{len(rows)} prompt(s) · {len(reports)} report(s) · [r] reload")
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Command line. Exit codes: 0 fine · 1 a cost is over --max-usd · 2 error (no file, unknown model, bad price table)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from . import prices as price_table
|
||||
from . import tokens
|
||||
from .prices import UnknownModel
|
||||
from .scan import TEXT, scan
|
||||
from .usage import collect
|
||||
from .workflow import NotWorkflow, llm_steps, load
|
||||
|
||||
COMMANDS = ("count", "workflow", "usage", "compare", "budget", "scan")
|
||||
|
||||
|
||||
def money(value: float) -> str:
|
||||
return f"${value:.6f}" if value < 1 else f"${value:,.2f}"
|
||||
|
||||
|
||||
def text_files(paths: list[str]) -> list[Path]:
|
||||
files: list[Path] = []
|
||||
for path in map(Path, paths):
|
||||
if path.is_dir():
|
||||
files += scan(path).texts
|
||||
elif path.exists():
|
||||
files.append(path)
|
||||
else:
|
||||
raise FileNotFoundError(f"{path}: no such file")
|
||||
return files
|
||||
|
||||
|
||||
def warn(prices) -> None:
|
||||
if message := prices.warning():
|
||||
print(f"! {message}", file=sys.stderr)
|
||||
|
||||
|
||||
def run_count(args, prices) -> int:
|
||||
"""R1 · R2: tokens of each file, and what one call / 1,000 calls cost on each model (input + --out tokens)."""
|
||||
models = args.model or sorted(prices.models)
|
||||
rows = []
|
||||
for path in text_files(args.files):
|
||||
n = tokens.count(path.read_text(encoding="utf-8"))
|
||||
rows.append({"file": str(path), "tokens": n, "out": args.out,
|
||||
"cost": {m: prices.cost(m, n, args.out) for m in models}})
|
||||
return show_rows(args, rows, models, "file", prices)
|
||||
|
||||
|
||||
def run_workflow(args, prices) -> int:
|
||||
"""R4: each LLM step of a workflow, the fixed system prompt it sends, and that part's cost per call."""
|
||||
models: set[str] = set()
|
||||
rows = []
|
||||
for path in map(Path, args.files):
|
||||
files = scan(path).workflows if path.is_dir() else [path]
|
||||
for file in files:
|
||||
for step in llm_steps(load(file)):
|
||||
out = step.max_out if args.out is None else args.out
|
||||
cost = prices.cost(step.model, step.system_tokens, out or 0)
|
||||
models.add(step.model)
|
||||
rows.append({"file": file.name, "node": step.node, "model": step.model, "tokens": step.system_tokens,
|
||||
"out": out or 0, "dynamic": step.dynamic, "cost": {step.model: cost}})
|
||||
if args.json:
|
||||
print(json.dumps({"steps": rows, "prices_checked": prices.checked}, ensure_ascii=False, indent=2))
|
||||
return over(args, rows)
|
||||
print(f"{'file':<34} {'node':<16} {'model':<13} {'system':>7} {'out':>5} {'per call':>11} {'per 1,000':>10}")
|
||||
for row in rows:
|
||||
cost = row["cost"][row["model"]]
|
||||
note = " (built at run time)" if row["dynamic"] else ""
|
||||
print(f"{row['file'][:34]:<34} {row['node'][:16]:<16} {row['model']:<13} {row['tokens']:>7} {row['out']:>5}"
|
||||
f" {money(cost):>11} {money(cost * 1000):>10}{note}")
|
||||
print("system prompts only: questions, FAQ and ticket data are added at run time (see `tcost usage`).")
|
||||
return over(args, rows)
|
||||
|
||||
|
||||
def show_rows(args, rows, models, label, prices) -> int:
|
||||
if args.json:
|
||||
print(json.dumps({"rows": rows, "prices_checked": prices.checked}, ensure_ascii=False, indent=2))
|
||||
return over(args, rows)
|
||||
head = "".join(f" {m + ' /call':>20} {'/1,000':>10}" for m in models)
|
||||
print(f"{label:<40} {'tokens':>7} {'out':>5}{head}")
|
||||
for row in rows:
|
||||
cells = "".join(f" {money(row['cost'][m]):>20} {money(row['cost'][m] * 1000):>10}" for m in models)
|
||||
print(f"{row[label][-40:]:<40} {row['tokens']:>7} {row['out']:>5}{cells}")
|
||||
return over(args, rows)
|
||||
|
||||
|
||||
def over(args, rows) -> int:
|
||||
"""R10: exit 1 when any one call costs more than --max-usd (a cost gate before deploying)."""
|
||||
if args.max_usd is None:
|
||||
return 0
|
||||
worst = max((cost for row in rows for cost in row["cost"].values()), default=0.0)
|
||||
if worst > args.max_usd:
|
||||
print(f"✘ one call costs up to {money(worst)} (more than --max-usd {money(args.max_usd)})", file=sys.stderr)
|
||||
return 1
|
||||
print(f"✔ every call is at most {money(args.max_usd)}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
def run_usage(args, prices) -> int:
|
||||
"""R5 · R6: what the test reports really used, and a month at --per-day requests."""
|
||||
usage = collect(args.paths)
|
||||
if args.json:
|
||||
data = usage.to_dict()
|
||||
if args.per_day:
|
||||
data["month_usd"] = usage.month(args.per_day, args.days)
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
t = usage.total
|
||||
print(f"{usage.reports} report(s) · {usage.requests} request(s) · {t['calls']} call(s) · tokens in {t['prompt_tokens']}"
|
||||
f" / out {t['completion_tokens']} · {money(t['cost_usd'])} · avg {money(usage.avg_usd)} per request"
|
||||
f" · skipped {usage.skipped} (no usage)")
|
||||
for title, table in (("model", usage.by_model), ("user", usage.by_user), ("hour", usage.by_hour)):
|
||||
for key, row in sorted(table.items(), key=lambda kv: (-kv[1]["cost_usd"]) if title != "hour" else kv[0]):
|
||||
print(f" {title:<6} {key:<14} {row['calls']:>5} calls · in {row['prompt_tokens']:>8} · out {row['completion_tokens']:>7}"
|
||||
f" · {money(row['cost_usd'])}")
|
||||
if args.per_day:
|
||||
print(f"month: {money(usage.avg_usd)} × {args.per_day} a day × {args.days} days = {money(usage.month(args.per_day, args.days))}")
|
||||
return 0
|
||||
|
||||
|
||||
def run_compare(args, prices) -> int:
|
||||
"""R7: the same file on two (or more) models side by side, and how much the cheaper one saves."""
|
||||
if len(args.model or []) < 2:
|
||||
raise ValueError("compare needs two models: --model gpt-4.1-mini --model gpt-5.4-nano")
|
||||
n = tokens.count(Path(args.file).read_text(encoding="utf-8"))
|
||||
costs = {m: prices.cost(m, n, args.out) for m in args.model}
|
||||
base = costs[args.model[0]]
|
||||
if args.json:
|
||||
print(json.dumps({"file": args.file, "tokens": n, "out": args.out, "cost": costs}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
print(f"{args.file}: {n} tokens in · {args.out} out")
|
||||
for model, cost in costs.items():
|
||||
pct = (base - cost) / base * 100 if base else 0
|
||||
saving = "" if model == args.model[0] or not base else \
|
||||
f"{abs(pct):.0f}% {'cheaper' if pct > 0 else 'dearer'} than {args.model[0]}"
|
||||
print(f" {model:<14} per call {money(cost):>11} · per 1,000 {money(cost * 1000):>10} {saving}")
|
||||
return 0
|
||||
|
||||
|
||||
def run_budget(args, prices) -> int:
|
||||
"""R9: how many calls of this file a daily budget pays for, on each model."""
|
||||
n = tokens.count(Path(args.file).read_text(encoding="utf-8"))
|
||||
rows = {m: prices.cost(m, n, args.out) for m in (args.model or sorted(prices.models))}
|
||||
if args.json:
|
||||
print(json.dumps({m: {"per_call": c, "calls": int(args.usd // c) if c else None} for m, c in rows.items()}, indent=2))
|
||||
return 0
|
||||
print(f"budget {money(args.usd)} a day · {args.file}: {n} tokens in · {args.out} out")
|
||||
for model, cost in rows.items():
|
||||
print(f" {model:<14} {money(cost):>11} a call → {int(args.usd // cost) if cost else '∞':>8} call(s) a day")
|
||||
return 0
|
||||
|
||||
|
||||
def run_scan(args, prices) -> int:
|
||||
found = scan(args.folder)
|
||||
for title, files in (("text", found.texts), ("workflow", found.workflows), ("report", found.reports), ("skipped", found.skipped)):
|
||||
print(f"{title:<9} {len(files):>4} " + ", ".join(p.name for p in files[:6]) + (" …" if len(files) > 6 else ""))
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
argv = sys.argv[1:] if argv is None else argv
|
||||
if argv and argv[0] not in COMMANDS and not argv[0].startswith("-"):
|
||||
from .app import CostApp # `tcost <folder> [folder ...]` opens the TUI
|
||||
prices = price_table.load(argv[argv.index("--prices") + 1] if "--prices" in argv else None)
|
||||
paths = argv[:argv.index("--prices")] if "--prices" in argv else argv
|
||||
CostApp(paths, prices).run()
|
||||
sys.exit(0)
|
||||
p = argparse.ArgumentParser(prog="tcost", description="Count tokens and estimate LLM cost before calling (no API call), "
|
||||
"and sum the usage of n8n test reports. `tcost <folder> [folder ...]` opens the TUI.")
|
||||
p.add_argument("--prices", help="price table (default $TCOST_PRICES, ~/.tcost/prices.json, else the one shipped with tcost)")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
def common(item, out_default=0):
|
||||
item.add_argument("--out", type=int, default=out_default, help="output tokens assumed per call")
|
||||
item.add_argument("--model", action="append", help="model name from the price table (repeat for more)")
|
||||
item.add_argument("--max-usd", type=float, help="exit 1 when one call costs more than this")
|
||||
item.add_argument("--json", action="store_true", help="JSON for other programs")
|
||||
|
||||
item = sub.add_parser("count", help="tokens of text files and their cost on each model")
|
||||
item.add_argument("files", nargs="+", help="files or folders (.txt .prompt)")
|
||||
common(item)
|
||||
item = sub.add_parser("workflow", help="LLM steps of n8n workflows and the fixed prompt each sends")
|
||||
item.add_argument("files", nargs="+", help="workflow files or folders")
|
||||
common(item, None)
|
||||
item = sub.add_parser("usage", help="sum usage and cost_usd of flow.py test reports (runs/*.json)")
|
||||
item.add_argument("paths", nargs="+")
|
||||
item.add_argument("--per-day", type=int, help="requests a day, for a month projection")
|
||||
item.add_argument("--days", type=int, default=22, help="working days in a month (default 22)")
|
||||
item.add_argument("--json", action="store_true")
|
||||
item = sub.add_parser("compare", help="one file on two models side by side")
|
||||
item.add_argument("file")
|
||||
common(item)
|
||||
item = sub.add_parser("budget", help="how many calls a daily budget pays for")
|
||||
item.add_argument("usd", type=float)
|
||||
item.add_argument("file")
|
||||
common(item)
|
||||
item = sub.add_parser("scan", help="what tcost finds in a folder (texts, workflows, reports, skipped)")
|
||||
item.add_argument("folder")
|
||||
args = p.parse_args(argv)
|
||||
try:
|
||||
prices = price_table.load(args.prices)
|
||||
warn(prices)
|
||||
for model in getattr(args, "model", None) or []:
|
||||
prices.rate(model) # R12: an unknown model name stops here, it is never priced as 0
|
||||
runner = {"count": run_count, "workflow": run_workflow, "usage": run_usage, "compare": run_compare,
|
||||
"budget": run_budget, "scan": run_scan}[args.cmd]
|
||||
sys.exit(runner(args, prices))
|
||||
except (UnknownModel, FileNotFoundError, NotWorkflow, ValueError, json.JSONDecodeError) as error:
|
||||
print(f"✘ {error}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"checked": "2026-10-28",
|
||||
"source": "https://developers.openai.com/docs/models (model pages)",
|
||||
"unit": "USD per 1M tokens",
|
||||
"models": {
|
||||
"gpt-4.1-mini": {"input": 0.40, "output": 1.60},
|
||||
"gpt-5.4-nano": {"input": 0.20, "output": 1.25}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Price table: USD per 1M tokens, with the date it was checked and where it came from."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
BUNDLED = Path(__file__).with_name("prices.json")
|
||||
STALE_DAYS = 30
|
||||
|
||||
|
||||
class UnknownModel(KeyError):
|
||||
"""A model name that is not in the price table (never priced as 0)."""
|
||||
|
||||
def __init__(self, model: str, known: list[str]):
|
||||
super().__init__(model)
|
||||
self.model, self.known = model, known
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"unknown model {self.model!r} (known: {', '.join(self.known)})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Prices:
|
||||
models: dict[str, dict[str, float]]
|
||||
checked: str = ""
|
||||
source: str = ""
|
||||
path: str = ""
|
||||
|
||||
def rate(self, model: str) -> dict[str, float]:
|
||||
if model not in self.models:
|
||||
raise UnknownModel(model, sorted(self.models))
|
||||
return self.models[model]
|
||||
|
||||
def cost(self, model: str, tokens_in: int, tokens_out: int = 0) -> float:
|
||||
"""(in × input + out × output) / 1,000,000, rounded to 8 places (the day 75 contract)."""
|
||||
rate = self.rate(model)
|
||||
return round((tokens_in * rate["input"] + tokens_out * rate["output"]) / 1_000_000, 8)
|
||||
|
||||
def age_days(self, today: dt.date | None = None) -> int | None:
|
||||
try:
|
||||
checked = dt.date.fromisoformat(self.checked)
|
||||
except ValueError:
|
||||
return None
|
||||
return ((today or dt.date.today()) - checked).days
|
||||
|
||||
def warning(self, today: dt.date | None = None) -> str:
|
||||
age = self.age_days(today)
|
||||
if age is None:
|
||||
return f"prices in {self.path} have no 'checked' date. Check the price list and add one."
|
||||
if age > STALE_DAYS:
|
||||
return f"prices were checked {age} days ago ({self.checked}). Prices change: check {self.source or 'the price list'}."
|
||||
return ""
|
||||
|
||||
|
||||
def default_path() -> Path:
|
||||
"""--prices, else $TCOST_PRICES, else ~/.tcost/prices.json, else the file shipped with tcost."""
|
||||
for candidate in (os.getenv("TCOST_PRICES"), Path.home() / ".tcost" / "prices.json"):
|
||||
if candidate and Path(candidate).expanduser().exists():
|
||||
return Path(candidate).expanduser()
|
||||
return BUNDLED
|
||||
|
||||
|
||||
def load(path: str | Path | None = None) -> Prices:
|
||||
path = Path(path).expanduser() if path else default_path()
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
models = data.get("models") or {}
|
||||
if not models:
|
||||
raise ValueError(f"{path}: no 'models' in the price table")
|
||||
return Prices({name: {"input": float(v["input"]), "output": float(v["output"])} for name, v in models.items()},
|
||||
str(data.get("checked", "")), str(data.get("source", "")), str(path))
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Sort a folder into what tcost can read (R11): text files, workflows, test reports, and the rest (skipped)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from .usage import is_report
|
||||
from .workflow import is_workflow
|
||||
|
||||
TEXT = {".txt", ".prompt"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Found:
|
||||
texts: list[Path] = field(default_factory=list)
|
||||
workflows: list[Path] = field(default_factory=list)
|
||||
reports: list[Path] = field(default_factory=list)
|
||||
skipped: list[Path] = field(default_factory=list)
|
||||
|
||||
|
||||
def scan(folder: str | Path) -> Found:
|
||||
found = Found()
|
||||
for path in sorted(Path(folder).rglob("*")):
|
||||
if not path.is_file() or any(part.startswith(".") for part in path.relative_to(folder).parts):
|
||||
continue
|
||||
if path.suffix in TEXT:
|
||||
found.texts.append(path)
|
||||
elif path.suffix == ".json":
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
found.skipped.append(path)
|
||||
continue
|
||||
(found.workflows if is_workflow(data) else found.reports if is_report(data) else found.skipped).append(path)
|
||||
else:
|
||||
found.skipped.append(path)
|
||||
return found
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Count tokens on this computer (no API call). o200k_base is the encoding of the gpt-4.1 / gpt-5 family."""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
import tiktoken
|
||||
|
||||
ENCODING = "o200k_base"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _encoder():
|
||||
return tiktoken.get_encoding(ENCODING)
|
||||
|
||||
|
||||
def count(text: str) -> int:
|
||||
return len(_encoder().encode(text or ""))
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Sum the usage of flow.py test reports (runs/*.json) (R5) and project a month from it (R6)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
STAMP = re.compile(r"-(\d{8})-(\d{2})\d{4}\.json$")
|
||||
|
||||
|
||||
def _row() -> dict:
|
||||
return {"calls": 0, "prompt_tokens": 0, "completion_tokens": 0, "cost_usd": 0.0}
|
||||
|
||||
|
||||
def _add(row: dict, calls: int, tokens_in: int, tokens_out: int, cost: float) -> None:
|
||||
row["calls"] += calls
|
||||
row["prompt_tokens"] += tokens_in
|
||||
row["completion_tokens"] += tokens_out
|
||||
row["cost_usd"] = round(row["cost_usd"] + cost, 8)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Usage:
|
||||
reports: int = 0
|
||||
requests: int = 0
|
||||
skipped: int = 0
|
||||
total: dict = field(default_factory=_row)
|
||||
by_model: dict = field(default_factory=dict)
|
||||
by_user: dict = field(default_factory=dict)
|
||||
by_hour: dict = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def avg_usd(self) -> float:
|
||||
return self.total["cost_usd"] / self.requests if self.requests else 0.0
|
||||
|
||||
def month(self, per_day: int, days: int = 22) -> float:
|
||||
"""avg cost per request × requests a day × working days (rounded to 6 places, like the day 75 report)."""
|
||||
return round(self.avg_usd * per_day * days, 6)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"reports": self.reports, "requests": self.requests, "skipped": self.skipped, **self.total,
|
||||
"avg_usd": round(self.avg_usd, 8), "by_model": self.by_model, "by_user": self.by_user, "by_hour": self.by_hour}
|
||||
|
||||
|
||||
def is_report(data: object) -> bool:
|
||||
return isinstance(data, dict) and isinstance(data.get("results"), list) and "cases" in data
|
||||
|
||||
|
||||
def report_files(paths: list[str | Path]) -> list[Path]:
|
||||
files: list[Path] = []
|
||||
for path in map(Path, paths):
|
||||
files += sorted(path.glob("*.json")) if path.is_dir() else [path]
|
||||
return files
|
||||
|
||||
|
||||
def collect(paths: list[str | Path]) -> Usage:
|
||||
usage = Usage()
|
||||
for path in report_files(paths):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
if not is_report(data):
|
||||
continue
|
||||
usage.reports += 1
|
||||
stamp = STAMP.search(path.name)
|
||||
hour = f"{stamp.group(2)}h" if stamp else "?"
|
||||
for result in data["results"]:
|
||||
response = result.get("response")
|
||||
steps = (response or {}).get("usage", {}).get("by_step") if isinstance(response, dict) else None
|
||||
if not isinstance(steps, dict):
|
||||
usage.skipped += 1 # a 400 / 429 answer, a summary case, or a report from another day
|
||||
continue
|
||||
usage.requests += 1
|
||||
user = str((result.get("request") or {}).get("user") or "?")
|
||||
for step in steps.values():
|
||||
tokens_in, tokens_out = int(step.get("prompt_tokens") or 0), int(step.get("completion_tokens") or 0)
|
||||
cost = float(step.get("cost_usd") or 0)
|
||||
_add(usage.total, 1, tokens_in, tokens_out, cost)
|
||||
for table, key in ((usage.by_model, str(step.get("model", "?"))), (usage.by_user, user), (usage.by_hour, hour)):
|
||||
_add(table.setdefault(key, _row()), 1, tokens_in, tokens_out, cost)
|
||||
return usage
|
||||
@@ -0,0 +1,76 @@
|
||||
"""LLM steps of an n8n workflow (R4): which node calls which model, and the fixed text it sends every time."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from . import tokens
|
||||
|
||||
CHAT_URL = "chat/completions"
|
||||
MODEL = re.compile(r"""\bmodel['"]?\s*:\s*['"]([\w.\-]+)['"]""")
|
||||
SYSTEM = re.compile(r"""role['"]?\s*:\s*['"]system['"]\s*,\s*['"]?content['"]?\s*:\s*("(?:[^"\\]|\\.)*")""", re.S)
|
||||
LIMIT = re.compile(r"""\bmax_(?:completion_)?tokens['"]?\s*:\s*(\d+)""")
|
||||
|
||||
|
||||
class NotWorkflow(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Step:
|
||||
node: str
|
||||
model: str
|
||||
system: str # the system prompt as sent (empty when it is built at run time)
|
||||
system_tokens: int
|
||||
max_out: int | None # max_completion_tokens, if the node sets one
|
||||
dynamic: bool # True when the system prompt comes from another node (counted as 0 here)
|
||||
|
||||
|
||||
def is_workflow(data: object) -> bool:
|
||||
return isinstance(data, dict) and isinstance(data.get("nodes"), list) and isinstance(data.get("connections"), dict)
|
||||
|
||||
|
||||
def load(path: str | Path) -> dict:
|
||||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
if not is_workflow(data):
|
||||
raise NotWorkflow(f"{path}: not an n8n workflow (needs 'nodes' and 'connections')")
|
||||
return data
|
||||
|
||||
|
||||
def _http_step(node: dict) -> Step | None:
|
||||
params = node.get("parameters") or {}
|
||||
if CHAT_URL not in str(params.get("url", "")):
|
||||
return None
|
||||
body = str(params.get("jsonBody", ""))
|
||||
model = MODEL.search(body)
|
||||
system = SYSTEM.search(body)
|
||||
limit = LIMIT.search(body)
|
||||
text = json.loads(system.group(1)) if system else ""
|
||||
return Step(node["name"], model.group(1) if model else "?", text, tokens.count(text),
|
||||
int(limit.group(1)) if limit else None, system is None)
|
||||
|
||||
|
||||
def _agent_steps(workflow: dict) -> list[Step]:
|
||||
"""AI Agent nodes (days 66~74): the model comes from the chat model node joined by ai_languageModel."""
|
||||
nodes = {n["name"]: n for n in workflow["nodes"]}
|
||||
model_of: dict[str, str] = {}
|
||||
for source, outputs in workflow["connections"].items():
|
||||
for link in (outputs.get("ai_languageModel") or [[]])[0]:
|
||||
value = ((nodes.get(source) or {}).get("parameters") or {}).get("model")
|
||||
model_of[link["node"]] = value.get("value", "?") if isinstance(value, dict) else str(value or "?")
|
||||
steps = []
|
||||
for node in workflow["nodes"]:
|
||||
if not node.get("type", "").endswith(".agent"):
|
||||
continue
|
||||
text = str(((node.get("parameters") or {}).get("options") or {}).get("systemMessage", ""))
|
||||
dynamic = "{{" in text
|
||||
text = "" if dynamic else text.removeprefix("=")
|
||||
steps.append(Step(node["name"], model_of.get(node["name"], "?"), text, tokens.count(text), None, dynamic))
|
||||
return steps
|
||||
|
||||
|
||||
def llm_steps(workflow: dict) -> list[Step]:
|
||||
steps = [step for node in workflow["nodes"] if (step := _http_step(node))]
|
||||
return steps + _agent_steps(workflow)
|
||||
@@ -0,0 +1,30 @@
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tcost import prices
|
||||
|
||||
DATA = Path(__file__).parent / "data"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def table():
|
||||
return prices.load(prices.BUNDLED)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def folder(tmp_path):
|
||||
"""A day folder: two prompt files, two workflows, one test report, and a test case file (skipped)."""
|
||||
(tmp_path / "prompts").mkdir()
|
||||
(tmp_path / "prompts" / "short.txt").write_text("Answer in Korean in at most 3 sentences.", encoding="utf-8")
|
||||
(tmp_path / "prompts" / "long.prompt").write_text("규정 " * 400, encoding="utf-8")
|
||||
(tmp_path / "reference").mkdir()
|
||||
for name in ("baseline-cost.json", "lab02-example1-lean.json"):
|
||||
shutil.copy(DATA / name, tmp_path / "reference" / name)
|
||||
(tmp_path / "runs").mkdir()
|
||||
shutil.copy(DATA / "report-20261028-153000.json", tmp_path / "runs")
|
||||
(tmp_path / "cases").mkdir()
|
||||
(tmp_path / "cases" / "lab01.json").write_text(json.dumps({"webhook": "my-cost", "cases": []}), encoding="utf-8")
|
||||
return tmp_path
|
||||
@@ -0,0 +1,337 @@
|
||||
{
|
||||
"name": "75 IT helpdesk baseline (provided)",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Question webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "base-cost",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"webhookId": "ed2b30c2-2c16-5aaa-8ef1-30e5db0ed1b8",
|
||||
"position": [
|
||||
0,
|
||||
300
|
||||
],
|
||||
"id": "40660866-5455-5c0b-9b7d-738dd69e81b6"
|
||||
},
|
||||
{
|
||||
"name": "Has question?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.2,
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "loose",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "c1",
|
||||
"leftValue": "={{ $json.body.question }}",
|
||||
"rightValue": "",
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"operation": "notEmpty",
|
||||
"singleValue": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
220,
|
||||
300
|
||||
],
|
||||
"id": "bc6dc9fb-3a9a-598e-87b5-84c56b87bf41"
|
||||
},
|
||||
{
|
||||
"name": "Reject empty",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "{ \"error\": \"question is required\" }",
|
||||
"options": {
|
||||
"responseCode": 400
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
440,
|
||||
560
|
||||
],
|
||||
"id": "9f9c9beb-967b-5ef1-b638-79897016df4a"
|
||||
},
|
||||
{
|
||||
"name": "Search FAQ",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/faq",
|
||||
"options": {},
|
||||
"sendQuery": true,
|
||||
"queryParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "q",
|
||||
"value": "={{ $('Question webhook').first().json.body.question }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"id": "e42438af-4520-5622-994e-6a726a8c787d"
|
||||
},
|
||||
{
|
||||
"name": "Get ticket",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "=http://data-api:8800/tickets/{{ (($('Question webhook').first().json.body.question.match(/T-?\\d{4}/i) || ['NONE'])[0]).toUpperCase().replace(/^T(\\d)/, 'T-$1') }}",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"sendQuery": true,
|
||||
"queryParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "requester",
|
||||
"value": "={{ $('Question webhook').first().json.body.user ?? '' }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
660,
|
||||
300
|
||||
],
|
||||
"id": "be57699b-a3ee-587d-991b-a7266e49e31a"
|
||||
},
|
||||
{
|
||||
"name": "Classify",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://api.openai.com/v1/chat/completions",
|
||||
"authentication": "predefinedCredentialType",
|
||||
"nodeCredentialType": "openAiApi",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, messages: [{ role: 'system', content: \"You sort one company IT help-desk question. Categories: faq (how to fix or use something), ticket (asks about the status of a ticket like T-1002), other. Return only JSON, no code fence: {\\\"category\\\": \\\"faq\\\"}\" }, { role: 'user', content: $('Question webhook').first().json.body.question }] }) }}",
|
||||
"options": {
|
||||
"timeout": 60000
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "openAiCourse0001",
|
||||
"name": "OpenAI (.env)"
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
880,
|
||||
300
|
||||
],
|
||||
"id": "f6ffca0c-45a6-526a-9ede-878a9e9090e0"
|
||||
},
|
||||
{
|
||||
"name": "Answer",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://api.openai.com/v1/chat/completions",
|
||||
"authentication": "predefinedCredentialType",
|
||||
"nodeCredentialType": "openAiApi",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, messages: [{ role: 'system', content: \"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 합니다체.\\n\\n참고 자료 (사내 규정 전체):\\n# 업무용 장비 지급·반납 규정\\n\\n## 1. 지급과 교체\\n- 업무용 노트북은 입사일에 지급한다.\\n- 노트북 교체 주기는 4년이다. 고장이나 배터리 교체는 IT 문의로 접수한다.\\n\\n## 2. 분실과 도난\\n- 업무용 장비를 잃어버리거나 도난당하면 24시간 이내에 IT 문의와 보안팀에 신고한다.\\n- 신고가 접수되면 IT 담당자가 원격으로 장비의 데이터를 잠근다.\\n\\n## 3. 반납\\n- 퇴사하거나 장비를 교체할 때는 5영업일 이내에 장비를 IT 담당자에게 반납한다.\\n- 반납 전에 개인 파일을 지우지 말고, 업무 파일은 사내 드라이브로 옮긴다.\\n\\n## 4. 개인 장비 사용\\n- 개인 휴대폰이나 태블릿으로는 업무 메일과 메신저만 쓸 수 있다.\\n- 개인 장비로 업무 메일을 보려면 회사의 모바일 관리 앱(MDM)을 먼저 설치한다.\\n\\n# 메일·문서 보안 규정\\n\\n## 1. 메일함\\n- 개인 메일함 용량은 10GB이다. 용량이 90%를 넘으면 안내 메일이 발송된다.\\n- 오래된 메일은 보관함으로 옮겨 용량을 확보한다.\\n\\n## 2. 첨부 파일\\n- 메일 첨부 파일은 25MB까지 보낼 수 있다.\\n- 25MB를 넘는 파일은 사내 드라이브에 올리고 공유 링크를 보낸다.\\n- 개인정보가 들어 있는 파일을 외부로 보낼 때는 반드시 암호를 걸고, 암호는 메신저 등 다른 경로로 알려 준다.\\n\\n## 3. 문서 등급과 보관\\n- 대외비 문서에는 문서 첫 장에 '대외비'라고 표시한다.\\n- 업무 문서는 3년 동안 보관한 뒤 정리한다.\\n\\n# 회의실 이용 규정\\n\\n## 1. 예약\\n- 회의실은 사내 포털에서 예약한다. 한 번에 최대 3시간까지 예약할 수 있다.\\n- 예약 시작 후 10분이 지나도록 아무도 사용하지 않으면 예약이 자동으로 취소된다.\\n\\n## 2. 장비\\n- 대회의실의 화상회의 장비를 쓰려면 회의 시작 30분 전까지 IT 문의로 요청한다.\\n- 프로젝터는 B회의실과 대회의실에 있다.\\n\\n## 3. 사용 후\\n- 회의가 끝나면 화이트보드를 지우고 의자를 정리한다.\\n\\n# 비밀번호·계정 보안 규정\\n\\n## 1. 비밀번호 만들기\\n- 비밀번호는 12자 이상으로 만든다.\\n- 영문 대문자, 영문 소문자, 숫자, 특수문자 중 3종류 이상을 섞는다.\\n- 이름, 생일, 전화번호, 회사 이름처럼 추측하기 쉬운 단어는 쓰지 않는다.\\n\\n## 2. 변경 주기\\n- 비밀번호는 90일마다 바꾼다. 만료 7일 전부터 사내 포털에 변경 안내가 표시된다.\\n- 최근에 쓴 비밀번호 5개는 다시 쓸 수 없다.\\n\\n## 3. 로그인 실패와 잠금\\n- 로그인에 5번 연속 실패하면 계정이 30분 동안 잠긴다.\\n- 30분이 지나면 자동으로 풀린다. 급한 경우 IT 문의로 잠금 해제를 요청한다.\\n\\n## 4. 다단계 인증\\n- 회사 밖에서 사내 시스템에 접속할 때는 OTP 다단계 인증을 반드시 사용한다.\\n- OTP 앱이 설치된 휴대폰을 바꾸면 IT 포털에서 OTP를 다시 등록한다.\\n\\n# 개인정보 처리 지침\\n\\n## 1. 수집\\n- 업무에 꼭 필요한 개인정보만 수집한다.\\n- 주민등록번호는 법에서 정한 경우가 아니면 수집하지 않는다.\\n\\n## 2. 이용과 보관\\n- 고객 개인정보가 보이는 화면은 캡처하거나 촬영하지 않는다.\\n- 개인정보가 들어 있는 출력물은 다 쓴 뒤 바로 파쇄한다.\\n\\n## 3. 유출 대응\\n- 개인정보가 유출되었거나 유출이 의심되면 1시간 이내에 보안팀에 신고한다.\\n- 신고할 때는 유출된 정보의 종류, 대략적인 건수, 발견한 시각을 함께 알린다.\\n\\n# 원격근무·VPN 이용 규정\\n\\n## 1. 접속 원칙\\n- 회사 밖에서 사내 시스템에 접속할 때는 반드시 회사 VPN을 먼저 연결한다.\\n- 카페, 공항 같은 공용 와이파이에서는 VPN 없이 사내 시스템에 접속하지 않는다.\\n\\n## 2. VPN 인증서\\n- VPN 인증서의 유효 기간은 1년이다.\\n- 만료 30일 전부터 IT 포털의 '인증서 재발급' 메뉴에서 새 인증서를 신청한다.\\n- 인증서가 만료되면 VPN에 접속할 수 없으므로, 만료 전에 재발급한다.\\n\\n## 3. 재택근무 중 보안\\n- 자리를 비울 때는 화면을 잠그고, 5분 동안 사용하지 않으면 자동으로 잠Line truncated
|
||||
"options": {
|
||||
"timeout": 60000
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "openAiCourse0001",
|
||||
"name": "OpenAI (.env)"
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
1100,
|
||||
300
|
||||
],
|
||||
"id": "f343fe5b-4ba5-58d6-9880-2f88e6e56b37"
|
||||
},
|
||||
{
|
||||
"name": "Review",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://api.openai.com/v1/chat/completions",
|
||||
"authentication": "predefinedCredentialType",
|
||||
"nodeCredentialType": "openAiApi",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, messages: [{ role: 'system', content: \"You review a Korean IT help-desk answer before it is sent. Keep the facts exactly. Make it polite (합니다체), remove anything not supported by the data, keep at most 3 sentences. Return only the final answer text.\" }, { role: 'user', content: '초안: ' + $('Answer').first().json.choices[0].message.content }] }) }}",
|
||||
"options": {
|
||||
"timeout": 60000
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "openAiCourse0001",
|
||||
"name": "OpenAI (.env)"
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
1320,
|
||||
300
|
||||
],
|
||||
"id": "f0ccdaf6-2e82-5b98-9b1d-61479d53fc9a"
|
||||
},
|
||||
{
|
||||
"name": "Build result",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const ran = (n) => { try { return $(n).isExecuted; } catch (e) { return false; } };\nconst steps = [{\"step\": \"classify\", \"node\": \"Classify\", \"model\": \"gpt-4.1-mini\"}, {\"step\": \"answer\", \"node\": \"Answer\", \"model\": \"gpt-4.1-mini\"}, {\"step\": \"review\", \"node\": \"Review\", \"model\": \"gpt-4.1-mini\"}];\nconst by_step = {};\nlet prompt_tokens = 0, completion_tokens = 0, llm_calls = 0;\nfor (const s of steps) {\n if (!ran(s.node)) continue;\n const u = $(s.node).first().json.usage ?? {};\n const row = { model: s.model, prompt_tokens: u.prompt_tokens ?? 0, completion_tokens: u.completion_tokens ?? 0 };\n by_step[s.step] = row;\n prompt_tokens += row.prompt_tokens; completion_tokens += row.completion_tokens; llm_calls += 1;\n}\nconst results = $('Search FAQ').first().json.results ?? [];\nconst answer = String($('Review').first().json.choices[0].message.content ?? '').trim();\nconst result = { route: '자동 답변', answer, sources: results.map((r) => r.id), llm_calls,\n usage: { prompt_tokens, completion_tokens, by_step } };\nreturn [{ json: result }];"
|
||||
},
|
||||
"position": [
|
||||
1540,
|
||||
300
|
||||
],
|
||||
"id": "e888a357-ae1a-5a73-bad7-aab2e26e2f93"
|
||||
},
|
||||
{
|
||||
"name": "Send result",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ $json }}",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
1760,
|
||||
300
|
||||
],
|
||||
"id": "01f85767-942d-5931-bd59-036b1567805e"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Question webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Has question?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Has question?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Search FAQ",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Reject empty",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Search FAQ": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get ticket",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get ticket": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Classify",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Classify": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Answer",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Answer": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Review",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Review": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Build result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Build result": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
{
|
||||
"name": "75 IT helpdesk + lean prompt (reference)",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Question webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "my-cost",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"webhookId": "797db9f9-e9ff-5363-9d0a-58d23a2047f0",
|
||||
"position": [
|
||||
0,
|
||||
300
|
||||
],
|
||||
"id": "3ccc3834-a583-51bd-a80f-b20124e32b64"
|
||||
},
|
||||
{
|
||||
"name": "Has question?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.2,
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "loose",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "c1",
|
||||
"leftValue": "={{ $json.body.question }}",
|
||||
"rightValue": "",
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"operation": "notEmpty",
|
||||
"singleValue": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
220,
|
||||
300
|
||||
],
|
||||
"id": "7e27dce9-fa3b-5c88-98b3-1dce956d16d2"
|
||||
},
|
||||
{
|
||||
"name": "Reject empty",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "{ \"error\": \"question is required\" }",
|
||||
"options": {
|
||||
"responseCode": 400
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
440,
|
||||
560
|
||||
],
|
||||
"id": "f3591be9-5676-5bb7-ab46-6984719af080"
|
||||
},
|
||||
{
|
||||
"name": "Get prices",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/prices",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
1100,
|
||||
300
|
||||
],
|
||||
"id": "baa4796d-564b-5cc0-949a-d79738b12ba7"
|
||||
},
|
||||
{
|
||||
"name": "Search FAQ",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/faq",
|
||||
"options": {},
|
||||
"sendQuery": true,
|
||||
"queryParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "q",
|
||||
"value": "={{ $('Question webhook').first().json.body.question }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
1540,
|
||||
300
|
||||
],
|
||||
"id": "e2005518-7a82-579a-acda-cc74b33968a9"
|
||||
},
|
||||
{
|
||||
"name": "Get ticket",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "=http://data-api:8800/tickets/{{ (($('Question webhook').first().json.body.question.match(/T-?\\d{4}/i) || ['NONE'])[0]).toUpperCase().replace(/^T(\\d)/, 'T-$1') }}",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"sendQuery": true,
|
||||
"queryParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "requester",
|
||||
"value": "={{ $('Question webhook').first().json.body.user ?? '' }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
1760,
|
||||
300
|
||||
],
|
||||
"id": "9d6c9b87-54bc-5433-bad3-6e33744e10ca"
|
||||
},
|
||||
{
|
||||
"name": "Classify",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://api.openai.com/v1/chat/completions",
|
||||
"authentication": "predefinedCredentialType",
|
||||
"nodeCredentialType": "openAiApi",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, messages: [{ role: 'system', content: \"You sort one company IT help-desk question. Categories: faq (how to fix or use something), ticket (asks about the status of a ticket like T-1002), other. Return only JSON, no code fence: {\\\"category\\\": \\\"faq\\\"}\" }, { role: 'user', content: $('Question webhook').first().json.body.question }] }) }}",
|
||||
"options": {
|
||||
"timeout": 60000
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "openAiCourse0001",
|
||||
"name": "OpenAI (.env)"
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
2420,
|
||||
300
|
||||
],
|
||||
"id": "07e553a6-f8eb-5513-a566-db99f5b11d29"
|
||||
},
|
||||
{
|
||||
"name": "Answer",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://api.openai.com/v1/chat/completions",
|
||||
"authentication": "predefinedCredentialType",
|
||||
"nodeCredentialType": "openAiApi",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, max_completion_tokens: 200, messages: [{ role: 'system', content: \"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 합니다체.\" }, { role: 'user', content: '질문: ' + $('Question webhook').first().json.body.question + '\\nFAQ: ' + JSON.stringify(($('Search FAQ').first().json.results ?? []).map((f) => ({ id: f.id, title: f.title, answer: f.answer }))) + '\\n접수 건: ' + JSON.stringify((({ ticket_id, title, status, note, error }) => ({ ticket_id, title, status, note, error }))($('Get ticket').first().json)) }] }) }}",
|
||||
"options": {
|
||||
"timeout": 60000
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
2640,
|
||||
300
|
||||
],
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "openAiCourse0001",
|
||||
"name": "OpenAI (.env)"
|
||||
}
|
||||
},
|
||||
"id": "fd329729-e718-56ff-805c-aa298d26c0b2"
|
||||
},
|
||||
{
|
||||
"name": "Review",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://api.openai.com/v1/chat/completions",
|
||||
"authentication": "predefinedCredentialType",
|
||||
"nodeCredentialType": "openAiApi",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, messages: [{ role: 'system', content: \"You review a Korean IT help-desk answer before it is sent. Keep the facts exactly. Make it polite (합니다체), remove anything not supported by the data, keep at most 3 sentences. Return only the final answer text.\" }, { role: 'user', content: '초안: ' + $('Answer').first().json.choices[0].message.content }] }) }}",
|
||||
"options": {
|
||||
"timeout": 60000
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "openAiCourse0001",
|
||||
"name": "OpenAI (.env)"
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
2860,
|
||||
300
|
||||
],
|
||||
"id": "f289c264-5e1f-523d-8e18-67ba4cbb166c"
|
||||
},
|
||||
{
|
||||
"name": "Build result",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const ran = (n) => { try { return $(n).isExecuted; } catch (e) { return false; } };\nconst steps = [{\"step\": \"classify\", \"node\": \"Classify\", \"model\": \"gpt-4.1-mini\"}, {\"step\": \"answer\", \"node\": \"Answer\", \"model\": \"gpt-4.1-mini\"}, {\"step\": \"review\", \"node\": \"Review\", \"model\": \"gpt-4.1-mini\"}];\nconst prices = $('Get prices').first().json.models;\nconst by_step = {};\nlet prompt_tokens = 0, completion_tokens = 0, llm_calls = 0, cost_usd = 0;\nfor (const s of steps) {\n if (!ran(s.node)) continue;\n const u = $(s.node).first().json.usage ?? {};\n const row = { model: s.model, prompt_tokens: u.prompt_tokens ?? 0, completion_tokens: u.completion_tokens ?? 0 };\n const p = prices[s.model] ?? { input: 0, output: 0 };\n row.cost_usd = Math.round((row.prompt_tokens * p.input + row.completion_tokens * p.output) / 1e6 * 1e8) / 1e8;\n by_step[s.step] = row;\n prompt_tokens += row.prompt_tokens; completion_tokens += row.completion_tokens; cost_usd += row.cost_usd; llm_calls += 1;\n}\nconst results = $('Search FAQ').first().json.results ?? [];\nconst answer = String($('Review').first().json.choices[0].message.content ?? '').trim();\nconst result = { route: '자동 답변', answer, sources: results.map((r) => r.id), llm_calls,\n usage: { prompt_tokens, completion_tokens, by_step }, cost_usd: Math.round(cost_usd * 1e8) / 1e8 };\nreturn [{ json: result }];"
|
||||
},
|
||||
"position": [
|
||||
3520,
|
||||
300
|
||||
],
|
||||
"id": "d85f4d2b-0957-5a81-a54b-35ba55509871"
|
||||
},
|
||||
{
|
||||
"name": "Record usage",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "http://data-api:8800/usage",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ items: Object.entries($json.usage.by_step).map(([step, row]) => ({ request_id: $execution.id, user: $('Question webhook').first().json.body.user ?? '', step, model: row.model, prompt_tokens: row.prompt_tokens, completion_tokens: row.completion_tokens, cost_usd: row.cost_usd, route: $json.route })) }) }}",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
},
|
||||
"timeout": 3000
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
3740,
|
||||
300
|
||||
],
|
||||
"onError": "continueRegularOutput",
|
||||
"id": "a46b3503-5039-5cb6-890e-69149fe23f55"
|
||||
},
|
||||
{
|
||||
"name": "Result again",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "return [{ json: { ...$('Build result').first().json, recorded: Number($json.stored ?? 0) > 0 } }];"
|
||||
},
|
||||
"position": [
|
||||
3960,
|
||||
300
|
||||
],
|
||||
"id": "1b8127ae-a43b-5096-8f94-c96892a085db"
|
||||
},
|
||||
{
|
||||
"name": "Send result",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ $json }}",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
4180,
|
||||
300
|
||||
],
|
||||
"id": "6cbcbfe3-9306-5287-b0c6-79f3fe0f9a5e"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Question webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Has question?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Has question?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get prices",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Reject empty",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get prices": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Search FAQ",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Search FAQ": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get ticket",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get ticket": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Classify",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Classify": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Answer",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Answer": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Review",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Review": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Build result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Build result": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Record usage",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Record usage": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Result again",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Result again": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{"cases": "lab02-challenge.json", "webhook": "my-cost", "passed": 2, "total": 3, "results": [
|
||||
{"case": "vpn", "request": {"question": "VPN", "user": "이준호"}, "status": 200,
|
||||
"response": {"llm_calls": 1, "cost_usd": 0.00056, "usage": {"by_step": {"answer": {"model": "gpt-4.1-mini", "prompt_tokens": 1000, "completion_tokens": 100, "cost_usd": 0.00056}}}}},
|
||||
{"case": "other", "request": {"question": "회의실", "user": "박서연"}, "status": 200,
|
||||
"response": {"llm_calls": 2, "cost_usd": 0.0001845, "usage": {"by_step": {
|
||||
"classify": {"model": "gpt-5.4-nano", "prompt_tokens": 100, "completion_tokens": 20, "cost_usd": 0.000045},
|
||||
"answer": {"model": "gpt-4.1-mini", "prompt_tokens": 200, "completion_tokens": 37, "cost_usd": 0.0001392}}}}},
|
||||
{"case": "empty", "request": {"question": ""}, "status": 400, "response": {"error": "question is required"}}
|
||||
]}
|
||||
@@ -0,0 +1,17 @@
|
||||
"""R8: the three tabs fill from one folder, without calling any API."""
|
||||
from textual.widgets import DataTable, TabbedContent
|
||||
|
||||
from tcost.app import CostApp
|
||||
|
||||
|
||||
async def test_tabs(folder, table):
|
||||
app = CostApp(str(folder), table)
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
prompts = app.query_one("#prompt-table", DataTable)
|
||||
assert prompts.row_count == 2 + 6 # two prompt files + six LLM steps of two workflows
|
||||
app.query_one(TabbedContent).active = "usage"
|
||||
await pilot.pause()
|
||||
assert "2 request(s)" in str(app.query_one("#usage-head").render())
|
||||
assert "15h" in str(app.query_one("#hours").render())
|
||||
assert app.query_one("#compare-table", DataTable).row_count == 8
|
||||
@@ -0,0 +1,46 @@
|
||||
"""R7 · R9 · R10 · R12: commands, --json and exit codes (0 fine · 1 over --max-usd · 2 error)."""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from tcost.cli import main
|
||||
|
||||
|
||||
def run(capsys, *argv):
|
||||
with pytest.raises(SystemExit) as end:
|
||||
main(list(argv))
|
||||
out = capsys.readouterr()
|
||||
return end.value.code, out.out, out.err
|
||||
|
||||
|
||||
def test_count_and_gate(capsys, folder):
|
||||
code, out, _ = run(capsys, "count", str(folder / "prompts"), "--out", "100")
|
||||
assert code == 0 and "long.prompt" in out and "gpt-5.4-nano /call" in out
|
||||
code, _, err = run(capsys, "count", str(folder / "prompts" / "long.prompt"), "--out", "200", "--max-usd", "0.0002")
|
||||
assert code == 1 and "more than --max-usd" in err
|
||||
code, out, _ = run(capsys, "count", str(folder / "prompts" / "short.txt"), "--json", "--model", "gpt-4.1-mini")
|
||||
assert code == 0 and json.loads(out)["rows"][0]["tokens"] > 0
|
||||
|
||||
|
||||
def test_unknown_model_and_missing_file(capsys, folder):
|
||||
code, _, err = run(capsys, "count", str(folder / "prompts"), "--model", "gpt-9")
|
||||
assert code == 2 and "unknown model 'gpt-9'" in err
|
||||
code, _, err = run(capsys, "count", str(folder / "nothing.txt"))
|
||||
assert code == 2 and "no such file" in err
|
||||
|
||||
|
||||
def test_compare_and_budget(capsys, folder):
|
||||
code, out, _ = run(capsys, "compare", str(folder / "prompts" / "long.prompt"), "--model", "gpt-4.1-mini",
|
||||
"--model", "gpt-5.4-nano", "--out", "100")
|
||||
assert code == 0 and "cheaper than gpt-4.1-mini" in out
|
||||
code, out, _ = run(capsys, "budget", "1.0", str(folder / "prompts" / "short.txt"), "--out", "100", "--json")
|
||||
rows = json.loads(out)
|
||||
assert code == 0 and rows["gpt-5.4-nano"]["calls"] > rows["gpt-4.1-mini"]["calls"]
|
||||
|
||||
|
||||
def test_usage_and_workflow(capsys, folder):
|
||||
code, out, _ = run(capsys, "usage", str(folder / "runs"), "--per-day", "1000")
|
||||
assert code == 0 and "2 request(s)" in out and "skipped 1" in out and "month:" in out
|
||||
code, out, _ = run(capsys, "workflow", str(folder / "reference"), "--json")
|
||||
steps = json.loads(out)["steps"]
|
||||
assert code == 0 and len(steps) == 6
|
||||
@@ -0,0 +1,70 @@
|
||||
"""R1 · R2 · R3 · R4 · R5 · R6 · R11 · R12 · R13 without a screen and without any API call."""
|
||||
import datetime as dt
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from tcost import tokens, usage, workflow
|
||||
from tcost.prices import UnknownModel, load
|
||||
from tcost.scan import scan
|
||||
|
||||
from conftest import DATA
|
||||
|
||||
|
||||
def test_cost_formula(table):
|
||||
assert table.cost("gpt-4.1-mini", 1000) == 0.0004 # 1,000 × 0.40 / 1M
|
||||
assert table.cost("gpt-4.1-mini", 1000, 100) == 0.00056 # R2: + 100 × 1.60 / 1M
|
||||
assert table.cost("gpt-5.4-nano", 100, 20) == 0.000045
|
||||
|
||||
|
||||
def test_unknown_model_is_not_free(table):
|
||||
with pytest.raises(UnknownModel) as error:
|
||||
table.cost("gpt-9", 1000)
|
||||
assert "gpt-5.4-nano" in str(error.value)
|
||||
|
||||
|
||||
def test_price_table_date(table, tmp_path):
|
||||
assert table.checked and table.source # R3: date and source with the prices
|
||||
assert table.warning(dt.date.fromisoformat(table.checked)) == ""
|
||||
assert "days ago" in table.warning(dt.date.fromisoformat(table.checked) + dt.timedelta(days=45))
|
||||
old = tmp_path / "old.json"
|
||||
old.write_text(json.dumps({"models": {"m": {"input": 1, "output": 2}}}), encoding="utf-8")
|
||||
assert "no 'checked' date" in load(old).warning()
|
||||
|
||||
|
||||
def test_count_offline():
|
||||
assert tokens.count("") == 0
|
||||
assert 0 < tokens.count("VPN 접속이 자꾸 끊겨요") < tokens.count("VPN 접속이 자꾸 끊겨요. " * 10)
|
||||
|
||||
|
||||
def test_workflow_steps():
|
||||
base = {s.node: s for s in workflow.llm_steps(workflow.load(DATA / "baseline-cost.json"))}
|
||||
lean = {s.node: s for s in workflow.llm_steps(workflow.load(DATA / "lab02-example1-lean.json"))}
|
||||
assert set(base) == {"Classify", "Answer", "Review"}
|
||||
assert base["Answer"].model == "gpt-4.1-mini"
|
||||
assert base["Answer"].system_tokens > 1000 > 200 > lean["Answer"].system_tokens # the rules: R4
|
||||
assert lean["Answer"].max_out == 200 and base["Answer"].max_out is None
|
||||
|
||||
|
||||
def test_not_a_workflow(tmp_path):
|
||||
path = tmp_path / "cases.json"
|
||||
path.write_text('{"webhook": "x", "cases": []}', encoding="utf-8")
|
||||
with pytest.raises(workflow.NotWorkflow):
|
||||
workflow.load(path)
|
||||
|
||||
|
||||
def test_usage_from_reports(folder):
|
||||
found = usage.collect([folder / "runs"])
|
||||
assert (found.reports, found.requests, found.skipped) == (1, 2, 1) # the 400 answer has no usage
|
||||
assert found.total["calls"] == 3 and found.total["cost_usd"] == 0.0007442
|
||||
assert found.by_user["이준호"]["cost_usd"] == 0.00056
|
||||
assert set(found.by_model) == {"gpt-4.1-mini", "gpt-5.4-nano"}
|
||||
assert found.by_hour == {"15h": found.total}
|
||||
assert found.month(1000, 22) == round(0.0007442 / 2 * 1000 * 22, 6) # R6
|
||||
|
||||
|
||||
def test_scan_sorts_folder(folder):
|
||||
found = scan(folder)
|
||||
assert [p.name for p in found.texts] == ["long.prompt", "short.txt"]
|
||||
assert len(found.workflows) == 2 and len(found.reports) == 1
|
||||
assert [p.name for p in found.skipped] == ["lab01.json"] # R11: a test file is not a workflow
|
||||
Generated
+569
@@ -0,0 +1,569 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.10.23"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.5.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/22/67/6a0b94a7960d5e1b5eacd2fb529f3fccc47db4644f7f0a7cfdcfc3be578a/charset_normalizer-3.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6", size = 370642, upload-time = "2026-09-30T04:35:06.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/94/01009e13b94041599004edf32e56e382c24e570f60f79bab8efe45cfe1eb/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5", size = 259280, upload-time = "2026-09-30T04:35:08.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/85/3b5358f60a13210f0b67d3755c168ef758701b021e655d88d4da28554467/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74", size = 245335, upload-time = "2026-09-30T04:35:10.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/75/77c1c479b09ecd751d1e767b251ea5c14d4d50ff757bf404afab2692f600/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4685902cf26edf013ed7a3da0f426ebba7a00ebb9541386d835afbf002c11cab", size = 291707, upload-time = "2026-09-30T04:35:11.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/0d/363f78cacb70f58f15f4b083961bbd9d292f335d3f5c66fc4f1cfe69cb90/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4495c5002a7b28557e7e222e77e0b661183e432b7d6d2e788101e3f240e05b8c", size = 286747, upload-time = "2026-09-30T04:35:13.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/ed/cf505d3011ffceb12c2067a7a5d3cfe92b875d4d44bb0ff0d69375e2c184/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:211d5a3eb6af8f513b8d4ca19a8c1b7accab1b5f0d3175f9826b03c1a920dc1f", size = 269972, upload-time = "2026-09-30T04:35:14.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/d8/f0a93a431d170e7ca681d4f6650fee3de934d18560e474e7267eb4b0f987/charset_normalizer-3.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef4fcbf3327382cd4c9f540babd61248208af7b93eec4de397b4d5f58a09e288", size = 269339, upload-time = "2026-09-30T04:35:16.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bd/9b2bd1c5b7af02462c9752d33994834ff972a96b4c483eefde9e594488e2/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd16aabe4a02a297c23417aa17ac6299dbd8c49f673bcd645b4929b11f5a4400", size = 261683, upload-time = "2026-09-30T04:35:17.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/a5/cac540ab0fd61f3fec88ad3dbb64509e71424593d73cfdfff5ab3e4db279/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:fb9e68df06293761f9fe66ade60a9bc6d0f5e42b8acf2939a9158af86ab0e5bd", size = 248143, upload-time = "2026-09-30T04:35:18.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/7a/ff467301deef2089fad87f72df9e000a26a78fec7acbb18e1999371b8369/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:59f63901b0031c3136cf64704dcb21de0bbae62ce2c9529bc39d27665463de37", size = 292358, upload-time = "2026-09-30T04:35:20.326Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/77/22d7e785d1e210afc2e2f58600dd1799d17a35665faf84383f002826c5f8/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:304d5463e65a35d7bb0850550e0780395395f6fcf452f04db7d5ca7cecc425ac", size = 268766, upload-time = "2026-09-30T04:35:21.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/91/e8e946267f1c2d9e2bd651726e2fbd2addf02c4d36cea5069e32ca9d7bb5/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9cf9b1a857e25c4baceeb3624e92a56df3668f398c4acba74e174d81fb4d1d3a", size = 287432, upload-time = "2026-09-30T04:35:23.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/88/7561d8a88d555e7df6623abe7c0070b4baf47549b9408783a2ae0a1a6cf7/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:114e4d0c92d618409ed82a99e22b5c5e768fe995f2973f78265f4524f49d4640", size = 272470, upload-time = "2026-09-30T04:35:24.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/7e/578c702301ec036f01455f30744a08d2b42f6ab35b9b2d4bf8cae0ef2a80/charset_normalizer-3.5.2-cp311-cp311-win32.whl", hash = "sha256:2625388c6c754520c37abaf3b41eb34d1cc4a373f457898f08606c8e362b891d", size = 186688, upload-time = "2026-09-30T04:35:26.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/fc/fdf8cf52ff21cd5bf158f20978991cf985325842f74283eb6df26c8a39d8/charset_normalizer-3.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:87e50a3e7cb90af586b6c5faf23e302a970415ac73bd7bd90a515a04b427ef96", size = 214932, upload-time = "2026-09-30T04:35:27.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/66/3e45a506d8110b632541faf9a9470185aa9878f1ed44020f31346c1c5e5b/charset_normalizer-3.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:254eb48b9fa5ee9898a3c445825a1f340fe53712a098904b39b0bddba8ea3cb1", size = 202828, upload-time = "2026-09-30T04:35:29.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linkify-it-py"
|
||||
version = "2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/45/98/7a1a5f31fd5c7ba93e963b168e244b8e3dd705b3d2a718e3c3307583bf57/linkify_it_py-2.2.0.tar.gz", hash = "sha256:907acd2d17ac1fbb9ddb62c8957ccbd6158cac602231a15c3b0cd1e215f03cee", size = 32939, upload-time = "2026-08-29T07:07:08.305Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl", hash = "sha256:3adc40eb5af300b2605fcfdb968c24e1d780a90f1f2221af7c15e5111e94d443", size = 21971, upload-time = "2026-08-29T07:07:07.164Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mdurl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
linkify = [
|
||||
{ name = "linkify-it-py" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdit-py-plugins"
|
||||
version = "0.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.13.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/35/e9/bf409046a91b6a9ca652e9b0ac3c39481674f6774e23e58b52315516099a/platformdirs-4.13.7.tar.gz", hash = "sha256:f7f351c027d6632c30471124e1fdb6ac0fef9ccc5cbac1b34cb333431b1fc3e1", size = 63906, upload-time = "2026-10-28T05:49:31.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/46/ff/8ed4a836cbf7de39708a2d305d6b67575990908d072bc3565dd8ad71748e/platformdirs-4.13.7-py3-none-any.whl", hash = "sha256:47664602fbecad0a6efa561fb690d20dbc26875fe8aedf65ba362935df619bbb", size = 33111, upload-time = "2026-10-28T05:49:30.545Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/9a/887535e477847f5159377ab35303923644840b13c0ca03c0a467f84d4680/pytest_asyncio-1.4.1.tar.gz", hash = "sha256:e8266eb094e5ee9ffdb2068c9836f33e209ea77a970eb846933d416b5602669b", size = 145237, upload-time = "2026-10-25T21:05:37.135Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/46/d7/cd9e2708afdbf059658adaf08bdb0792ca52489871051d6ce7d498b51277/pytest_asyncio-1.4.1-py3-none-any.whl", hash = "sha256:507afa78ec77c6661164ef1a2368bc7820383fd1a14b2a7f813de8b5a92949cc", size = 17061, upload-time = "2026-10-25T21:05:35.473Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "2026.9.29"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/f2/af1da9d3ceed77bfcdce40427d49ba0be94e4fe84245e3bfef68c10e75b6/regex-2026.9.29.tar.gz", hash = "sha256:8b5fcc4771732191b2b7d1dd68d8f0353f47f8d90b6150f6dce58bf1112442cb", size = 419199, upload-time = "2026-09-29T00:49:58.298Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/6b/6dea87689c3a06a6e79d254bf824e6f3e3d724b5ba027c6112559aa6cd2c/regex-2026.9.29-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6abb75ab16bc3281714a5b99548a2225db70dba1f995f6d7f7419b76eb5a8fbe", size = 495413, upload-time = "2026-09-29T00:46:14.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/a5/0c791a0e83ad1013d262c13247c4c77e0f4a8d05bdc167df96aba6681c0d/regex-2026.9.29-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b7b893976e7fe42053da64f2aa27239c24252fd2ec6df471e1be197c0addc3b1", size = 295145, upload-time = "2026-09-29T00:46:16.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/07/9bf3607d8d13a12e436ab9d63f9791e10706827d535695b23964ad79fd79/regex-2026.9.29-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:066d0e3dbfdd739bce2bf8c2a41dd16f73e3d8adc2eb06dd803a36a307f56075", size = 292123, upload-time = "2026-09-29T00:46:17.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/6b/32c2e6fc617e1d3f247e250fea31a9a35b1265bd32f585968aa13b9999b9/regex-2026.9.29-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7020ed44df30b3aa492c00ee3b52d0548c1f30c2c6c5bb13ae897680900d3413", size = 799537, upload-time = "2026-09-29T00:46:18.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/72/f041177f3c7a4606f7c81a95fe7eea03e2a0c4e8bff9e439a01432cbc9f2/regex-2026.9.29-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4613d7d9dda60fcba95f846cc6f808017f1843f392cf9daad14a6534493d71", size = 873255, upload-time = "2026-09-29T00:46:20.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/4e/a78948e11dd715e0e46716c2e0f3404b3fe6a44e2a2e9abdc7d965cab2b3/regex-2026.9.29-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bec37990e3d6121f29ecfb594bd8f1bf009e9f7926daba2e50e3b27d3892a783", size = 914570, upload-time = "2026-09-29T00:46:22.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/70/aa08d1d2b294894b365e5f8ba5380fe3f8546acdb81f10639dfd74209c37/regex-2026.9.29-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:612b709381c0355b70d89cdb51b7f670591ed5cbbc0e3b5337488019dc667b65", size = 804978, upload-time = "2026-09-29T00:46:23.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/32/1b03534c4715aca3b564416d28d518083ed4dab3bc913267600d2256140d/regex-2026.9.29-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a760da040b47767b4b873adfb7c3b691e9ba2fc60f113f9d0b88f1a62f323e85", size = 779297, upload-time = "2026-09-29T00:46:25.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/a7/378f6f558d9e4444af315a307c5953565a511d1e3666f1bb7bdc82012b6b/regex-2026.9.29-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:49ee178ca31c94621294bf9b8b676a92a2e6bba8af0529591753719e57edb621", size = 786961, upload-time = "2026-09-29T00:46:26.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/13/79f0b1846f5f342f92ddbd4b27b18bcb86da96d902c1a0be26520bde98d7/regex-2026.9.29-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:5eeb8edc6110d9194a4d0d54610f64c37a31c605b5dbb7e407fc6ec7fa34a4a1", size = 863234, upload-time = "2026-09-29T00:46:28.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/19/05af70dec9f2eed6ba34e08d2dcc6a48e7ae5e307659d5fe4201a5d7bbee/regex-2026.9.29-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ccb64d887a9db1cd76dbc0f92051a1a478a2a67e7f56c62d915cb881d7734704", size = 766487, upload-time = "2026-09-29T00:46:29.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/e1/9c7486d4afe8fdd1fe0ad60139f8aa91427381f409af6a29b609d8fdcb3a/regex-2026.9.29-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9e4482589065c8ecd761cff522dcd85f2d39e62f551e37e025d1c7d54772def3", size = 854792, upload-time = "2026-09-29T00:46:31.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/c7/49d008ff5f741d9a9799d7315556f3a12b983ff0fcd2cdfb62904bedafbf/regex-2026.9.29-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d60030baaa7bfbb02d650c126cdcddcb6e33dbff14d819434c8fa2fdcaeeeba5", size = 793050, upload-time = "2026-09-29T00:46:32.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/a1/46ba549e65562ca04608b24179b8a7bb6f146ae0e7c6d7f5e70f3339c8ba/regex-2026.9.29-cp311-cp311-win32.whl", hash = "sha256:18ae8eed4526e35bdb754d61562b90bf5c00a67fdcf3cc1380dd59597486631b", size = 268940, upload-time = "2026-09-29T00:46:34.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/4a/aab232183c70fdcf77bcf0c51819da02ec522e393e6a0bf00bcf2142e21f/regex-2026.9.29-cp311-cp311-win_amd64.whl", hash = "sha256:1043aedf5917caa861bcb25a9c11460049656bdf0017a90a309fa8f255467725", size = 280641, upload-time = "2026-09-29T00:46:35.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/b1/7c05954af0f51de376df2ba97f7f78a8b79334c7e5b3d2d9f2aead1f4d3d/regex-2026.9.29-cp311-cp311-win_arm64.whl", hash = "sha256:352cf115a810b357caa35193ab656ecf5ef41056855e82f292c99e8514f8d954", size = 279411, upload-time = "2026-09-29T00:46:37.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/48/3fdcde9a0baa84d7d25571223265d6e434e114763b438601d54a8028bf3e/regex-2026.9.29-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dc79d36d0618752265f0d575915bdc5c5130ecb9c9f6b3bcefeae32e4bdfafcf", size = 497903, upload-time = "2026-09-29T00:46:38.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/1c/4ee3e97c76f53940488dfe7a7e18705e78daac8cd7fb161d246b9e328449/regex-2026.9.29-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3a21a9509d0ee88e7a70e1ad228cd2f0e0fd1e187458db132e8a8d18c97daf9d", size = 296416, upload-time = "2026-09-29T00:46:40.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/14/f3f0ba083d2094392d5eabf56db5ea6ba469fd6e927afd187042054ea68a/regex-2026.9.29-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f57dc6b8fef170f105d2cf5cdce254f47b137d7755086cf7050f47e16582abba", size = 293633, upload-time = "2026-09-29T00:46:41.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/72/67e7a8ce17f1aea49df215564048efb49cc8c2b31a0e0fc30f36838f8516/regex-2026.9.29-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f93bc1c3486ef3747e07c9d7c1d0a147b8fbaab975f80e348aed6f71309dfaca", size = 805885, upload-time = "2026-09-29T00:46:43.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/78/25436bcfd4d2260b4b4090094d55d7ab53ec8a1ab4865a0b8bcb33c7d5c0/regex-2026.9.29-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e1d3a4cb7993b708f0ada8d0c84590efd853f169e7147d2202c9da503180242", size = 878344, upload-time = "2026-09-29T00:46:45.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/e6/a09ec3a23ae41d6179880e67f0aace9284b2d95f2d7b326eff203f8eec5e/regex-2026.9.29-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dabee8f4935e731fb46b2a3091bdda0d3d94b3bbfb907d2b4f12eefce4009619", size = 919181, upload-time = "2026-09-29T00:46:47.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/83/d2fbd2e4e3afb1167daa825187d196f313cbaa1a4768f311fb041bb0e3d2/regex-2026.9.29-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39ab5894d971f9ac68baa6eca5c50387db579cfcacf36ae8df3feceb1815e6d0", size = 807783, upload-time = "2026-09-29T00:46:48.894Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/0b/eb429a7016610d44fc89a597163f8c9127505f0d7dc724dc9effbb6a3ac0/regex-2026.9.29-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c1a9a6651197fbed6f0212591418b9def774fc3f8324f78d1bf0e6a63e5f8aa1", size = 783465, upload-time = "2026-09-29T00:46:50.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/07/58a3c0153c7476898430f6a7cf3d9062a1d17fbea4f43399ecaf411c7b4c/regex-2026.9.29-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87fb80cbe3557e27e7b28b995c2b2eedf689b8886f941ab93e0e288f0976518a", size = 793519, upload-time = "2026-09-29T00:46:52.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/e8/161b94d39164520e21a7befe0245569bf7fda4c7cf1fc4e2df2b5def49da/regex-2026.9.29-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3c5c2ef13797466aa64170cbb66ad98a32351dd4127694cea7199f80f213750d", size = 869293, upload-time = "2026-09-29T00:46:54.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/07/3b02ed829aa2decdc1955d222bd1e2f99d1c8bb4873bbb9a66b2f0a36bff/regex-2026.9.29-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:59b49507f47479e299a9e1bc41b5cb83a7afda0540625f1dbae886615978acbf", size = 770239, upload-time = "2026-09-29T00:46:56.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/5b/ba61f6fe062eb8562e742367d177bb75370434138ef6c9d2a27114f8d613/regex-2026.9.29-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0dd8af32e9f7b56b7f95cc1fd79b23054c3bdc172392ae560acc24d57b7ffe71", size = 861973, upload-time = "2026-09-29T00:46:57.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/27/767259b20e8a842948990f5e99138d6c077248fd42f8b5468b1d9ca4b814/regex-2026.9.29-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db5e82ba15c142425b8406690032df89e39cca4a2e8afbbb9a3d84edc2373ac3", size = 796470, upload-time = "2026-09-29T00:46:59.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/05/2566c4ba849b68a8ab81a6bf428fa79d20aae7ddee83979103c0381df254/regex-2026.9.29-cp312-cp312-win32.whl", hash = "sha256:d0c3082bf79bcd6a614d55916590ad4b8f93200e10b97f463ea5d9d07c9b5f23", size = 269327, upload-time = "2026-09-29T00:47:01.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/19/489bc8db91196381c935752df01ba3f607140daece33b78d88573f028e64/regex-2026.9.29-cp312-cp312-win_amd64.whl", hash = "sha256:fdd88ed5e20b1bcdd234421e454962c971aa44b653bdb7f1ea9ef683e90fb649", size = 280334, upload-time = "2026-09-29T00:47:04.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/47/fb88ba779d0e5e7d4b0ec1aceeb13845948a2cb876bd572a2d1dfdba090b/regex-2026.9.29-cp312-cp312-win_arm64.whl", hash = "sha256:4fe97894d1b306c919b4e50def1e6f6c522f4d03a7283811f4d108f1ce5d3ac2", size = 279614, upload-time = "2026-09-29T00:47:06.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/d5/6080f7d1a6e7e36aa720f806ac93c035ba39c209ae6cc510e8ef4c0279c6/regex-2026.9.29-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f1a0d5117230dd46b399a30a38afa44f79c99f3168988fdc4f425c3f928b39df", size = 497622, upload-time = "2026-09-29T00:47:08.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/71/c87fc7a2e21a42f9d57489db32951c37eef56d153840459a80d464f0321d/regex-2026.9.29-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f0fe9834e5aeccaf19a0d8feb296d66a24be1a7c9922002f842a682cd5abb787", size = 296281, upload-time = "2026-09-29T00:47:09.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/9e/aa0f4cde3bc4688c1d58b0cd8415edd708339bc0bc401a195b0b1e8c8f0c/regex-2026.9.29-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c90fcf7804ea0a54b896ce0f2b9565350220b8d4890fd0db461a476a4c687963", size = 293472, upload-time = "2026-09-29T00:47:11.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/d4/e835c487850ed922a8d6074f953b888c8ea99775c76b9ed5f8a4d72eab92/regex-2026.9.29-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e11edba5bc344a32b029a7af9d4b3173982dd79eeafa0b9dbd787364414b0509", size = 805966, upload-time = "2026-09-29T00:47:13.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/57/ba8809847fbae8d2cbc71367c6ded510a7ec88bf52493c65efc1acf4effb/regex-2026.9.29-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb90e7177944b6684738c1fc36aabd2dd00d1de3be7dbe09f91e196f1bc0dc81", size = 878346, upload-time = "2026-09-29T00:47:14.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/52/e3da19fc3cc15ef67ab67e121e87887c3bccfdb683a7a9ec557c460ca5b7/regex-2026.9.29-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d06fcdecc10fc7954d7c8f27a03c96055fe525274dc84a7b0dbdc3d6b9e03dab", size = 919250, upload-time = "2026-09-29T00:47:16.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/8e/c1ed81f55f992f6aa0b699a592a50c1ce9e6d44ff1aee2c14c0537dcef9c/regex-2026.9.29-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d49c18f1ea294cf4adde2e5ac256e98c82ea9d708462ce4bf799dffa7cfe8a2c", size = 807902, upload-time = "2026-09-29T00:47:18.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/bc/5a6886eb470e41040e21e05b75024a18b6ebfe7ea400b72094a60f949101/regex-2026.9.29-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e778bfccd63075167709136afbc251c1f683758d5bf49c803c60ac3f894ce6b", size = 783514, upload-time = "2026-09-29T00:47:19.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/52/6d951d453b023c6edb880f1ba474291b53b8ce1cc438b96a9db6d791d991/regex-2026.9.29-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:686ac5350fceae63830bb98805fcb8039325bf4c06d9f6f048ff65229d5bffa5", size = 793466, upload-time = "2026-09-29T00:47:21.552Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/b9/d5a41adc08360f5eee0dc4846c578f002366947211fc8af5a69a64ee7b9f/regex-2026.9.29-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:26ec4ccce55aa533fbd603d08911b01101a8fcfec987845ac3ae2c7087b2bde3", size = 869464, upload-time = "2026-09-29T00:47:23.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/32/d76c9d91f5d798e2e9e67f6f85ec4ae35445ac425f7454797311cecb80ca/regex-2026.9.29-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a655d34b2a6943af32401f3d94f72e9d731f6ad16285815550bf2b4ee69d420a", size = 770278, upload-time = "2026-09-29T00:47:25.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/00/aeebdb540c620a0f7317f6d6fad80a47729ecf0599a24b5c34ec155351f5/regex-2026.9.29-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0c992c19cd45058a4b92f68f139c93db168b48fb1f322c9a7cd620806afb6b51", size = 861949, upload-time = "2026-09-29T00:47:27.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/62/d0314bcedfd3586197e4596931fa220260eb2385bf53184e5b9ae67db24b/regex-2026.9.29-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ebb8912f565b8cdbbf27debfe00df04202c20e2f651b9e32767930c5eace3621", size = 796602, upload-time = "2026-09-29T00:47:29.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/c7/d5a8c13a613facb03e0fb55c1ebaaf7bb35d8e2c1abe8bef8dca809fc1d9/regex-2026.9.29-cp313-cp313-win32.whl", hash = "sha256:4d7d93613b01b0199961330e49cfc52d479b3d5776c56c691db31130c0a07d91", size = 269306, upload-time = "2026-09-29T00:47:31.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/a7/bf93a3a6afa5f7bc16b7afb94ae581b01cae620b8ad56bd8f9572a985959/regex-2026.9.29-cp313-cp313-win_amd64.whl", hash = "sha256:61956f074ecd123f55adca68ee3eab46e6a07ad3f8e64e6db95dfacb444f55c4", size = 280307, upload-time = "2026-09-29T00:47:32.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/7d/388274e53605a86297f433a08102a7bbdcf9379d47683d307ccaefd88e2c/regex-2026.9.29-cp313-cp313-win_arm64.whl", hash = "sha256:bfc71e6d970419c1309b3640305298643e2a734cad3f7cfb6d2ddee4175ab53d", size = 279599, upload-time = "2026-09-29T00:47:34.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/1f/d9dc6f02f569625faf67a4daec926cd5023472dcd69bb44286dccd5a5ab3/regex-2026.9.29-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:957bb708e8057ab1649ba566456429d691ec9b90d1c9ad1af1ba7ffbbeaf05f2", size = 497598, upload-time = "2026-09-29T00:47:36.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/83/9b693a3fd1451381e812031a8961ec5b3b8f0c8cc6871f14c5223642804d/regex-2026.9.29-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c9b602fae1e00b7c035d661ce85575365719192a7b46784bd71cf64c68053aa0", size = 296250, upload-time = "2026-09-29T00:47:38.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/5f/52bc2abc3fef040cd9de76ab29c918d6a717a454ae2b9dd7938b0c95656d/regex-2026.9.29-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0166844493626c5015c6088ee15c9ca2fd060ca15b7641d1657da6a58432ae33", size = 293518, upload-time = "2026-09-29T00:47:39.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/fc/cf50671215ee0057046980b4571ef8646a005819bb67f0957e779ed107a5/regex-2026.9.29-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b97a38fb4c732b6832db6bf108963adbcd82ef1268ba2025dce390f45af75efa", size = 806037, upload-time = "2026-09-29T00:47:41.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/4b/dddef8fc15c63e4347cc9efb138d0cd306f30e6c98acbcc81a8f780083b9/regex-2026.9.29-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a540abfab208e1b7ef2df231c40ef3b6cbb30a0aad6204e9b6a81c10a6794628", size = 878881, upload-time = "2026-09-29T00:47:43.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/cb/38daabed32d28f7e58a06e9344ce00dc67952e9996bc578ed6a29fe1240e/regex-2026.9.29-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ddfa987262763c3c22a8367d2a49c244b018a74c3a8e3ab1a864119ad45c5633", size = 918684, upload-time = "2026-09-29T00:47:45.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/4d/041d9458a645fee4fce4d642a89d27271a3cfcd91095104f6dde44da70bf/regex-2026.9.29-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f7f7aa47b229f2b39a2ae2596d2ad5625d77b5eb9856fac2dab3eb506cdd0a0", size = 807176, upload-time = "2026-09-29T00:47:47.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/c4/4383eed7aa5aef67616cb1b3f3ad06b7c624c4e6cced48630cd5ce133d85/regex-2026.9.29-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d9b77b25b4f395f92de6099ab08e8ae2bc7e51dfe157f22900902243a5cc90c7", size = 784315, upload-time = "2026-09-29T00:47:49.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/a6/0086ad31cebb183c637d3198547075aa493afde308e1ff61fccccb29ba6e/regex-2026.9.29-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34b6925af9853bf461950e6508910f179fd6e9b1a7ec8548e069606b7e51a26b", size = 793748, upload-time = "2026-09-29T00:47:51.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/a0/f9005cba3f629a859573fc5d1224ea4e1f97919ec8581d018e03a351a604/regex-2026.9.29-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:addd736a0547d553283adaf4e05d7104e7f2c7b0b092e9b4d28756825f14531f", size = 870302, upload-time = "2026-09-29T00:47:53.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/4f/e1a3e46bb5315a4e18b01a990e7a28e2a16595609d50c442baf2815a3c65/regex-2026.9.29-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fe3fa1dd453ed5c7f5ea23a26218329790ed7197a99b90e94330e313959a7f52", size = 770299, upload-time = "2026-09-29T00:47:55.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/fe/f303b4acfda44e1ff1379368748c1ef2dad04a6a8e9c0ecbc970b19d97ca/regex-2026.9.29-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0cc63b5e47c12a48d90c7e9d7de6a035dd14f62868aaedbb4e0ff8ba2b8bfe7b", size = 861570, upload-time = "2026-09-29T00:47:57.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/b6/b4f7e99249f596017c60ccad5faf9310fc8e3e59bb2244940a90a1b0bdff/regex-2026.9.29-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:724184b4aafed865e4f13ca313fdcb43024300c028ec67319cfa16847d84685e", size = 795967, upload-time = "2026-09-29T00:47:59.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/d3/fc865a4638d9f6762192b6bab5b7aa1f33a90e9e99578c2e111e2a63c8c3/regex-2026.9.29-cp314-cp314-win32.whl", hash = "sha256:c6c8fabf1dafc1f1ddcbb67896d3f93efb092e8c4b6322d7389b944e76a484e5", size = 274758, upload-time = "2026-09-29T00:48:01.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/e2/c2b466924ccbeb874862968ca638051b15a8fd29d994a0e99004a5cbf78e/regex-2026.9.29-cp314-cp314-win_amd64.whl", hash = "sha256:1c2a0026062abcc321a53db4a185ceba0b59a66b5d37b0808917a88b55a5257f", size = 283817, upload-time = "2026-09-29T00:48:03.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/42/ea0f8dbaa924fa75c6338935eaee2f44dab369b27f02db1e03d74344b049/regex-2026.9.29-cp314-cp314-win_arm64.whl", hash = "sha256:121a76a0985db80ceae9e171c337f8c927868e37d01b54e3ce87bc87f9c6a208", size = 283663, upload-time = "2026-09-29T00:48:05.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/48/d58e5081119f5c223bbb37d2340acde3d069e1df8e8cd166c37502eee4da/regex-2026.9.29-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e31f72490b7c12f7790e1e25c3afffd20503ee1bfb43461d7838b871ff244b19", size = 501393, upload-time = "2026-09-29T00:48:07.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/3c/c49945287d4f9efee7d41f98072f8ad880efb8f430595a612fbdea996a4e/regex-2026.9.29-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:80ea96f5c1a30bf09007d48466521d9c294bebe197c708c3359096e3e3691632", size = 298237, upload-time = "2026-09-29T00:48:09.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/1f/688cb61c3d4cf7bcc1ed444b5cc49399eba3e51c469ae285cf87fea3022e/regex-2026.9.29-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:554bffadcbcb6d5f4e5fb10a61cc52084b9a63d1dab5f10bcd2c4343972e8e2c", size = 295936, upload-time = "2026-09-29T00:48:11.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/a3/de43ac6b877b7d09c19a3a426b1bd5acdd209eaaf68f406466f80439ccf6/regex-2026.9.29-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:864e9b87ac33c3fb9fb4ad48166d4fdb579c351d5c77deb0d34bccb36a775cd9", size = 816905, upload-time = "2026-09-29T00:48:13.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/14/9940763201c51d537786304984c67d0fc3d2ed18837ffb6f09a869f6b6c9/regex-2026.9.29-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:044265d77d94f5e3cb2fd72c76723807c429cb8c533e9d4672d0334a6f14f588", size = 881527, upload-time = "2026-09-29T00:48:15.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/e1/c842d8df0b23245ebf202f8ab9c39fd48e2db39959454ec39a41c8c72082/regex-2026.9.29-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2089fe39c406784d90101c726755ffa1497bb74638fd434300d2b88006186de8", size = 923115, upload-time = "2026-09-29T00:48:17.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/c1/98622479e3c354a446a75232e522d747d2b3df23092dcd8a5309380a2020/regex-2026.9.29-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0def9fb6abac55492d6d51cddb7225d07d6f279e774e0adc08569a54a5fc8d46", size = 820674, upload-time = "2026-09-29T00:48:19.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/d0/5808c95f9c79ed27b5eedaafc3df6239ec56a49f2e23ea8f831b18427c82/regex-2026.9.29-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:888d60953908dcf761aa320c3e390ab8556efbdb551ace63921de90f6ae0848d", size = 793306, upload-time = "2026-09-29T00:48:21.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/d3/021ca2638671ad20603bcd9b4d5bfa35d2610cd216a043ea7f0b44ea39f6/regex-2026.9.29-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed511a0708e2297e1d6431e7fb217e3402791e491e02da800658ace4973df1bb", size = 803103, upload-time = "2026-09-29T00:48:23.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/2d/755c6d13ef9c657378013676c391c7a402166b3f419a464a3e058dcbe533/regex-2026.9.29-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e1172147d28d8fbcf8cb8d26c41506169f5ad8fe9ec969cb116835a19d4d8eca", size = 872175, upload-time = "2026-09-29T00:48:26.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/fc/e1cab183b9dafe8597f58c1c766da9bf96204d3b2f232bcf3eeb75ff7b6c/regex-2026.9.29-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:92f05c9c42bde5785dc48770bc2194d9f7442544156f951e19cd31b096cec562", size = 777362, upload-time = "2026-09-29T00:48:28.389Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/7c/e10ea17fba31fb4a1f9d13ed53a2d2a9066a2aea58d7557e263f6d99e7b0/regex-2026.9.29-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f37964e4a5e993d2fd45147741e9dff7f34a2d8c00ab94c4ea0514a4677f959e", size = 865606, upload-time = "2026-09-29T00:48:30.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/6e/69824d9aee1fd41c54ea7264654a47c8d9d84d8a228e11c2bcf4c201ed81/regex-2026.9.29-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:951733b1bbdb71e377cec567b409f1a7881b47cfcad84121aa74cb575fa425ea", size = 807945, upload-time = "2026-09-29T00:48:32.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/22/857050a86e21ce60193e02a8ef662521f2e263a645c8b1b905fc136b61a7/regex-2026.9.29-cp314-cp314t-win32.whl", hash = "sha256:65b408d8fcb273e3499e7ef2ce796810da1becd208c7fb4373692a242d79d461", size = 276758, upload-time = "2026-09-29T00:48:34.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/96/56808fe029553d7d4c703414f2a527faad2ea2bfa9ca094a2e7f8762b530/regex-2026.9.29-cp314-cp314t-win_amd64.whl", hash = "sha256:bf48516e35cf848390ea68850aba53e7c333720d2945b4d2c25b69fc5171723f", size = 286527, upload-time = "2026-09-29T00:48:36.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/aa/074e2cfb3d8101a6a764aba5f7c5d1e21de087483e35bdc0c4ce2eb60364/regex-2026.9.29-cp314-cp314t-win_arm64.whl", hash = "sha256:9173db3be74a35cb6731701094b98120f7ee4876a287882a59cdea1fa7da342f", size = 285954, upload-time = "2026-09-29T00:48:38.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/dc/d84990386c9dfdf8c377f00f371b241fdc9a2c8aea0e3d66941b2e51be0b/regex-2026.9.29-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c3589f40749acce747510bf5d589d54e376cb0930ea58b35effac97e5312b0c1", size = 497836, upload-time = "2026-09-29T00:48:40.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/ab/a569ebde875fa12ff8c6c9a30e07503620f195e4be4d54c3d3ee8eecc283/regex-2026.9.29-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:32ab11df9677ca80bcbb5fe4eb1da9109a5019239a054836efc6fa1c64e683cf", size = 296244, upload-time = "2026-09-29T00:48:42.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/3e/7d548e82a108e7c8b2d5246650e397a2f8db599f9b2e975466939c5b4e70/regex-2026.9.29-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7c03031610e3e6ed1768a2b7a8fc84637c1257b50c5eacaf094c6e17a84fc563", size = 293748, upload-time = "2026-09-29T00:48:44.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/34/a8e19a52f452bbb07b32a2bef70dcdf90c2737049749f74cc12d7486fb4f/regex-2026.9.29-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:42e82e578c904445d4c8a35b8f28052cf567593215fa5db06266fbc6f77aaa2e", size = 807840, upload-time = "2026-09-29T00:48:46.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/7b/11fbd4640b3bb82b72822a63c20ade4013d562d291703a9debeedc24e682/regex-2026.9.29-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b65c72739f981377c9c22e0c5c3cd7f42da7bd8a3c9209330fac772c7d893ed", size = 879330, upload-time = "2026-09-29T00:48:49.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/55/de58c74f1f4e31586d83eb39c56872d686c4e0d0966d151884c833b94ced/regex-2026.9.29-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4408b2b27a95ca8cc48b7411945753773353b5c93b307754781086c99d3a576f", size = 919251, upload-time = "2026-09-29T00:48:51.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/42/a8c480f6dd5ac59fa28ddae79afd9d7ac7e596fdb61813adc65bb6e674b8/regex-2026.9.29-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a714befaacbd10092ffe4cea0d3c5f008fb9efe9bc322c715bcdfdee414b9a3d", size = 808808, upload-time = "2026-09-29T00:48:53.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/60/0bc0d1ec8b37ad64be6fa30e035251f11de9667a0fac9e82ee74517d81be/regex-2026.9.29-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33026515aebc0e70d1c89978e53e8d695d35d9e472f8d5b34465ba3c74028650", size = 789907, upload-time = "2026-09-29T00:48:56.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/84/116a3ef19b3acfe81077f0bf2cbc7714a5e94bc8935b7243ab61cb0f1c3c/regex-2026.9.29-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:31b003f9a070335e2a8233ee9b14a3ca8e6d792012ae011f741bf0aaf11744c5", size = 795770, upload-time = "2026-09-29T00:48:58.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/ba/e38c3f203e7e7e18c957d48e6cb6dbf96c11e95a44efa4a480522afc5d6d/regex-2026.9.29-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:c03c6eb6ece86dfdcbb34799efaa339b093132e1aceed491ba5e08fe06cdf699", size = 870671, upload-time = "2026-09-29T00:49:00.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/0f/9ee0b0cb76c55f63684bd7fff554978e8773b4fc86e2bcb2d50772dc1086/regex-2026.9.29-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:a5300757f8a68f5b6cc33f57338d72a0e3589c5cc9ad5f8504ea06f028be582a", size = 778631, upload-time = "2026-09-29T00:49:02.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/19/e6e3eeb226af5872c4958002f6edef4e4f40ea4cc5f5665023f2019eb045/regex-2026.9.29-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:80c7cadd3fd2bfde5df8aa0787e315812cad0c313a753095d02f4c2b6c01677b", size = 862187, upload-time = "2026-09-29T00:49:05.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/62/823c102e106bb2711d6b7dfe5981552fe4467b2969c46a20c5c383cf498c/regex-2026.9.29-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3f1e6cb402a89457582cd696f982559217d13484a193202c394015297968c86d", size = 798423, upload-time = "2026-09-29T00:49:07.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/e0/e927776258fa70b2f6feffc3be584ffc85ba4c1e20a320f0aee9a632fc7d/regex-2026.9.29-cp315-cp315-win32.whl", hash = "sha256:a64b85a4760337cfefdb27d42da6ed8b58e8cde3f2d57b6ef43e76ef6ea9ef47", size = 274757, upload-time = "2026-09-29T00:49:10.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/04/358de85d1860238e1b4fa98fc2c80c990124a25d2e14739e28cc02c25562/regex-2026.9.29-cp315-cp315-win_amd64.whl", hash = "sha256:b3e445b66c80b4eb4234e855ce94d9adc183eedbd632816228d89930b91b2c5b", size = 283824, upload-time = "2026-09-29T00:49:12.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/d3/d5c5b264784a5ab2b0f8cf620c1eeb4dbf3440d306761905e7d99345bef5/regex-2026.9.29-cp315-cp315-win_arm64.whl", hash = "sha256:8f39588af4731c8923c26810eb3b33f76f17633985e40f59c3cd45a33805a895", size = 283664, upload-time = "2026-09-29T00:49:15.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/dc/f63ec2c201445ce1150fe780f5c56f16a10124d9a9da3a93161dbb0d8892/regex-2026.9.29-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:fb99cc9d45f48895d9d67f6a0b8a57f08d39c174d9f25ad97a313e0470267b1c", size = 501549, upload-time = "2026-09-29T00:49:17.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/5c/d2a698dc6bfc11fbce03f1cb0249c13284e93b79ed11f893edf6fac431c9/regex-2026.9.29-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:720537c7ea6f80dc61913184edb0ce2497a306b39ef19f28505b322553d52bdb", size = 298187, upload-time = "2026-09-29T00:49:20.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/b7/88dcdb38cd3935d4ee9e9ce9b8e56cb3b3518d1f020acfa7dd62ad289bf8/regex-2026.9.29-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0fd2c901cc307a745ad4bc87f20060d7a0825a3371d1e93488af22e7a387f78f", size = 296157, upload-time = "2026-09-29T00:49:22.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/8e/ba6c01dde33a69fc294b38b43f6677baaa5735a6248f39708031a738158a/regex-2026.9.29-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b11b589e00095ec69cf79841a76360f9b079e95b0368a25b5ebb951ab0c157ff", size = 818468, upload-time = "2026-09-29T00:49:24.612Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/f1/2586693e3a2d6b1247852593d37a6c17b42a92ee44f7cdcb9a0c1494e64a/regex-2026.9.29-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d7cab119d0df0b9413f106b4d7fc34f2872d3574ed3806fb48959c830b1537da", size = 882825, upload-time = "2026-09-29T00:49:26.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/51/084f3e7bdcd0e9c33665c938cf5d134dc3548cbb4a75f0197ec7bfd754b1/regex-2026.9.29-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b89efc38431793d28b7cd91227e2f952ad7c48df19132b17f43a5fec3c14143b", size = 923314, upload-time = "2026-09-29T00:49:29.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f1/066c6fc23b7dc229789c21c880b5ba5ad689fb95fed12e078266f55a1f9b/regex-2026.9.29-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80a5ea3b4fd9d6a5b9a44f7976a9acaaab35aa3c1f6b29e5bd857dfabaded223", size = 822222, upload-time = "2026-09-29T00:49:32.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/56/592cd46fdb8f2f8682a1d7fd1310e4d0bcb93fbd0e6bbe4141ac28240227/regex-2026.9.29-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:19959129885356df0e97556856f77eb2888380dac18bed075a7c05c5128c618d", size = 794882, upload-time = "2026-09-29T00:49:35.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/4d/d65384bb071c864b01aa8314e3a6a687845ebd57588390976edc960c218b/regex-2026.9.29-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:6a1a824fbed817e0a891103886b68f063b1e83cc51bc97192a90a60195a9291f", size = 805887, upload-time = "2026-09-29T00:49:37.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/b6/358de0d8f40d5178e4f7e7e121cfd5b961c812b77a055d11f5079e3f8fd7/regex-2026.9.29-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1ba8c6a416569ce0d37e83e28a254a61dc99a419084dfb6476cea02d997f74fa", size = 872901, upload-time = "2026-09-29T00:49:39.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/06/6bfded72d043240c6b52bbb5e16f639d81affbf7484b4fe2ec45f3d4afc9/regex-2026.9.29-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:446654b29bfaa30500d80947eda42cef1449dc8a87f4e3cf061cc8485d3a1f0b", size = 782970, upload-time = "2026-09-29T00:49:42.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/20/9f418a50baa78b3ed8308fcb0cc49e472dd000b7ef935a7295af202ea744/regex-2026.9.29-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bf3c49863c23a1ad6da9c30351aed6cff8d5ddbeb63c5c8420ae54e98c7d0138", size = 865441, upload-time = "2026-09-29T00:49:45.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/29/817c7eacdeaf8463123e949bd394c39ad024eea1ec38ddf5ad141da2f3bd/regex-2026.9.29-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:01000ddf0e3ffef97f2413ceb514f6313040106b6d18a03ee00a4fe35c1eb1db", size = 809431, upload-time = "2026-09-29T00:49:47.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/0b/83aab3b5b739947f744135a7a3a446e25433ebc92b05e01aae197ccbfdda/regex-2026.9.29-cp315-cp315t-win32.whl", hash = "sha256:c4e38dd8f39c43a91d2410ad2b85610701b0979342c3df1d69eaf8e838c757d8", size = 276964, upload-time = "2026-09-29T00:49:50.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/f2/6314b5fc68789b5dcc38885bc6e3d6986b34fb3372b7231088ee5cecaa05/regex-2026.9.29-cp315-cp315t-win_amd64.whl", hash = "sha256:e2c89e9b762c57f59d5e99ee8b20202adb892e35f8d3485741340999ca55058e", size = 286487, upload-time = "2026-09-29T00:49:53.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/bc/97b2245c8c7b2dd01f2db74f2bea003cd33c15009b4996a2447f46b5325c/regex-2026.9.29-cp315-cp315t-win_arm64.whl", hash = "sha256:e8c65ef3862a8ad6e86492b6ed9327805dd66904c012bd3649dc67d822ed6c34", size = 285955, upload-time = "2026-09-29T00:49:55.655Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.34.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "15.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tcost"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "textual" },
|
||||
{ name = "tiktoken" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "textual", specifier = ">=0.80" },
|
||||
{ name = "tiktoken", specifier = ">=0.7" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pytest", specifier = ">=8" },
|
||||
{ name = "pytest-asyncio", specifier = ">=0.23" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "textual"
|
||||
version = "8.2.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py", extra = ["linkify"] },
|
||||
{ name = "mdit-py-plugins" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "pygments" },
|
||||
{ name = "rich" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiktoken"
|
||||
version = "0.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "regex" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/62/167a842aa0429d45f5e797354fd4343a96f6043d67d0513c675c7b8d36e6/tiktoken-0.14.0.tar.gz", hash = "sha256:231dec90efcdccf1b565a1416107736f1e09b1a08fe736ef9d6363e626d03874", size = 38898, upload-time = "2026-08-17T19:49:49.514Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/c5/9d848b7f408241171e1f843deb8bfa626086452bc9c78beee500829583e3/tiktoken-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c2edf09b381fafbc014ae8e018ed25087abb9a3dafa8465a0ea63c6558c47a79", size = 1094971, upload-time = "2026-08-17T19:48:40.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/a9/d94302340304328961d6f0c35ca4e60617fbb57a5cf667e2ed1692cb9e57/tiktoken-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cd8ca1305c1c902fe42c486165f2e4808d9997625c98ffb05b9e0366d99d3948", size = 1042916, upload-time = "2026-08-17T19:48:41.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/b6/31da98ee871383509cae2ba96a9ddef1965e3c4f8cb6dc7bcda3379398db/tiktoken-0.14.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:1f83081065ee5833d35b49e9180f3d8d15622a603dd1c435da0da6cc12b3662f", size = 1188650, upload-time = "2026-08-17T19:48:42.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/65/8c5dddd7cb67f6571d154a58d7c6e2f07da54bf84c49b6a1839965b7c35e/tiktoken-0.14.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f5e7665f6624e052e5e7f6a36919ab69279decdc976d7b16b4fa15e1897d0513", size = 1206378, upload-time = "2026-08-17T19:48:44.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/04/522ec59d30dd9a2f3ab837011cd4fc5d1178dc4a2fa07c9fa4b90af6ba9d/tiktoken-0.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:144a3fc369f92b7d548995217c5d6e84038d3572157a0f6f34080d65291d0f78", size = 1253694, upload-time = "2026-08-17T19:48:45.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/84/9019e272bad188a1c61ecf44f25a9ba2368744644e3ac1f3d6516f3c9e80/tiktoken-0.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:151d37a150c8f3dfc5f4345597b10e101876bd1bd13494e0185af6b508758d2e", size = 1317873, upload-time = "2026-08-17T19:48:46.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/7f/fff1217240343c0c11b5938b98aeae0e3a266cacfac25f86f91cdcd748f0/tiktoken-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:c77d4a3e1deb2707819df92046b89aad1ac81d27e07616b797cbff3f62c037da", size = 944395, upload-time = "2026-08-17T19:48:48.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/da/e273746b9d24a63c776bc60fba914351573ad9c575b52601eb5e60632564/tiktoken-0.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8e947aefe98ef74cce94923f90e48c98fe34eb1ec0a6bfdfadfc5a96359bfc36", size = 1094408, upload-time = "2026-08-17T19:48:49.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/9f/fe6b1aca23331aa5271df5a4bd07bf68a7059254d47faee1b8272592a777/tiktoken-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d6cebe67765569df3dafac8474e4eccf5c19d24140492567a5e58a11445732a4", size = 1038499, upload-time = "2026-08-17T19:48:50.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/35/e9f47647c9e163bd1de30fe1a491669b7248cfc67b7404c35c009a701e1a/tiktoken-0.14.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:7db45b98e94adf4173a5cd7422b150999a7ee11ff847783a14f6e1b80cc38cb6", size = 1186355, upload-time = "2026-08-17T19:48:51.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/11/9976ad86980a00cdef05e730a0127a2578a1bc6d11644d8d47246de2eb26/tiktoken-0.14.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7896eea257fe497a2b7134474d909156c6744ce8da35bce88011a960e008aa0d", size = 1204197, upload-time = "2026-08-17T19:48:53.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/9c/7035b0bcfaa68d1ee4803fc5be5214ad865669b05bd20e7105ae8a18afc6/tiktoken-0.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b950248272f1b303dc32986396e2dccfa10cf6d1e83ec8f0bba1776660305482", size = 1250635, upload-time = "2026-08-17T19:48:54.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/1d/69cabf18bed7f4366da076735816abce0d4db3fae491ae338a6612128777/tiktoken-0.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3de75343041a1c57333b1e707ac8a9769738241d7d6a55d39e12cf84548337c6", size = 1316085, upload-time = "2026-08-17T19:48:55.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/bd/a2e884fb1402cba5be08836590320012b2d8ada0e2eef9911a64df4bcd2d/tiktoken-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:087538c080e5ff421abd3a0785ed63c5111d06af98e6cd0d374dbe5969147ca3", size = 941208, upload-time = "2026-08-17T19:48:56.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/53/ee1453623bf65f019328721ccb6587846d2c5b7b82f34e73ca09101f072e/tiktoken-0.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9c5fe393aab56469f04e432ff851216d3def3436cf5f07e442a240164bf500f", size = 1094198, upload-time = "2026-08-17T19:48:57.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/5f/6448cfe278c3664ba9ec5b5ac08344341f7dc3d42888476e215a14eda2be/tiktoken-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbe2cc3bba939bcdaf103e03df9d5039d33887080b315624be28ec69059e5f94", size = 1038820, upload-time = "2026-08-17T19:48:59.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/3b/d67eac1bcce9dee3abe23aff5e3ded3116bbebaf67b80a0811c06d3806fc/tiktoken-0.14.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2157f52e4b4d7ac5ecc7457b3716834706e7ef9a46f5144029bfeb7cf71f4e06", size = 1186175, upload-time = "2026-08-17T19:49:00.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/62/cae690d9783146b0f81f564ada0f8f611de68178c0c9c7e1e969f0516b48/tiktoken-0.14.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:26e60f6a956ee171ab728b37b8439905d7ea1db435c30f9822f291e9861c861d", size = 1203884, upload-time = "2026-08-17T19:49:01.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/1e/633e30237b94e383cf814145499079f3bb9cdd4aeafc1bc42e01b0f810a6/tiktoken-0.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:380873f330b741c4435574f37edb20813d04603ace2d53e0a63560e1fec83010", size = 1250980, upload-time = "2026-08-17T19:49:02.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/56/4c12f07b812f84206f38d723eb1ebfdd34bad9309b5dbc0bee6bbcff4cbf/tiktoken-0.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3fd7c14b1cb45b486c39fc9b3443bb341f3e2fc7e6f31247f3435a5836651632", size = 1315434, upload-time = "2026-08-17T19:49:03.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/e0/c65603f0c44811def666d3fbf611bf2af3b5e1ef613e06c19411419830b3/tiktoken-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:90a762670c7f968184723769a06ed51f5cf5ce5dcd1e30164f25c72d85c2d1f1", size = 940883, upload-time = "2026-08-17T19:49:04.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/b0/1cf129f4af8fc513931f931023def596b7c4bfc77026513cd9d851da9e88/tiktoken-0.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e067f4cbcc5d036e8aff7fe7a6b530a8f4de2e4616ad9005a24a1879e24e6450", size = 1096273, upload-time = "2026-08-17T19:49:05.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/85/2ae74575e321148484147e10b53c3b1717c59ebaa9edb4fe18b1f5c055f8/tiktoken-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f2af4a336ea56d6c14f27741a0e1d8294a35dd0b038bcf990d232ebb54eb994b", size = 1040269, upload-time = "2026-08-17T19:49:06.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/29/92a1120a12e4bcf2d5464350d1a91b68a433d63ce656bb7f806c27aec09c/tiktoken-0.14.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:f702e0aeeb6506e57687e881c59e844ebe8f0a6a097ddafe20e3ab25f387be4e", size = 1186101, upload-time = "2026-08-17T19:49:08.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/7d/144af98dc5ad68108451a82e2f5a17f80e2663f5115058b8dfd215c1ad02/tiktoken-0.14.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e3442bbb2f0c588cec876061e37ae67b455b9df9978b003c8fe30e45f2ef5b42", size = 1204457, upload-time = "2026-08-17T19:49:09.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/1f/be7cb06ab2108f612f3e92e7b76cf391e192db0db37a984616f0cc32aafc/tiktoken-0.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:979c1524f753b662b0f3cd261b135afe6659cce33caaa7a5ea00dd1756b3055c", size = 1251716, upload-time = "2026-08-17T19:49:10.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/6b/81f158d0f90adb826cd704069c2129a046cb784a2a09861009519fc41cf4/tiktoken-0.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2cc19ac87b41c9493c9778ff5847f0c8bbcf5bd0ec6b87ce06c1c802adc8a771", size = 1315432, upload-time = "2026-08-17T19:49:11.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/ec/f5fa35ec13f07279fdcaf3cc9c04bbb154ea591d23978651f2b672593e8a/tiktoken-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:eceeff0c62419bc78d4b6e70a4762a4d25df3ae8f2d5946e3853ce93e7a57098", size = 988046, upload-time = "2026-08-17T19:49:13.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/c9/7756717408d3d0dfea3f046c9466144b28afde39ff69d5808f2475dcd7f5/tiktoken-0.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6eb94895c45f26bb8f5546e5fd8a069efcf6e3f108ea9d5cbe3bf6f7f3983438", size = 1096261, upload-time = "2026-08-17T19:49:14.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/29/46ad8061f57bd9f8b2ea0aa82bf574e0f2aa040b0857a1582adba9957899/tiktoken-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:86951a971c53979ec857bd8c4a32dc227ab0fd33f6c12a3bd62d3fbf5f0bfcaa", size = 1040183, upload-time = "2026-08-17T19:49:15.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/7c/3184d17b868456f17b60b1a75f5ec0405618a43aa753336df341d8f11781/tiktoken-0.14.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:e2eca764c53490f8930dbce329e0769f11108d87d908282a80c5c130e26e7037", size = 1186719, upload-time = "2026-08-17T19:49:16.84Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/e8/46de4400d5bf859f640feee85bd7e32235f68ddf25db53c63be78e581e3a/tiktoken-0.14.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:26cc4b4840fa0e9f4b72ed489883e12f57e00d1021ca794720e3c29a12f0edef", size = 1204660, upload-time = "2026-08-17T19:49:17.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/ce/af8964c38bc8226dd8950305b7a255fa33345d5572f78af7275a313d28e0/tiktoken-0.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2fc834fbe3f6a0736905c36ab709537e6840dbd63b982dc9e0216ae7d305ba1a", size = 1250932, upload-time = "2026-08-17T19:49:19.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/4b/323631116fc986d9cc5bbeb2b8223c7c85e61a8bb94ea5ab4951023b149b/tiktoken-0.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ca4db6ff5c5bf600f9b7761a0070ed44dfe5797a76bd432fb978bc480ef40c58", size = 1315190, upload-time = "2026-08-17T19:49:20.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/8b/ba48a73729c9270989b36f37ab2ed5525e52690d715097c9fa791aaa5d05/tiktoken-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7aab286a020660a039097912a088236b985d18a3090d73f136c4413d29d37ca0", size = 987717, upload-time = "2026-08-17T19:49:21.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/10/b73b7e319179e0f60b32475f783b044f9cece872c53b6662664e9084b0d0/tiktoken-0.14.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:14b47e3674f2624803a8acc8fb367b7e24fc53055f9df3296482fe9a3a34a232", size = 1096280, upload-time = "2026-08-17T19:49:22.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/6b/09999a9bf1d559670d1680e8f8e419ac0e2c5f6aac82e9bfdf70f260b30a/tiktoken-0.14.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:19d643d701fdaa70e5b9c7f8f96abcaffe77ca5e482a3a1a7dde46feb4284695", size = 1040433, upload-time = "2026-08-17T19:49:23.998Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/7b/8537be0836f3df99b2a636b44399bfa43cd757f2b8b4097dacb794cf24a7/tiktoken-0.14.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:e4ddf863b59347deaa92302dcd90e5eb003cdc9be06ec2b692c38d1bdd9efd49", size = 1186989, upload-time = "2026-08-17T19:49:25.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/9d/f9c56d7a943a4468abf9ef37661bb9b8e0cd3aa8aa87368c7146cc3f3222/tiktoken-0.14.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:60c47ca69ddda0dea8256fffd12e1b86f4b59734a20e4a70c61f63cc5f021df4", size = 1204615, upload-time = "2026-08-17T19:49:26.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/d2/98a38579db25c4a8a84e31dd95d9072ec5f21f7e70de591da0412e29b25b/tiktoken-0.14.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:728303a072163130c5b477b1f20d6211895569c1d5302c24ffc93a3009160871", size = 1251828, upload-time = "2026-08-17T19:49:27.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/83/467be424746c039c5493c0f4102feab16b9b48eb6f5c089b2a2438e3cde2/tiktoken-0.14.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3c5349c9f916283bba32bec8af69b763e4faa304dc004d0eaaea66a3cf004c1f", size = 1316260, upload-time = "2026-08-17T19:49:29.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/ee/ddf46ca78e371f5890e96b6e7d089a85b3536432be219851eb0481786ca8/tiktoken-0.14.0-cp315-cp315-win_amd64.whl", hash = "sha256:1b6e4adcfd285c44502aed51df98aaaca4f0fea028165dbf8a9e857b9f98d8ea", size = 988230, upload-time = "2026-08-17T19:49:30.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/00/5162e90c851a28da18ed382d34898b79a8022548e5619a64e14c03ce7c3d/tiktoken-0.14.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:11d8211b290855d2721334ff17dd9b3a17bfb26872be01f25d73612ef7ece890", size = 1096186, upload-time = "2026-08-17T19:49:31.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/97/a5a7bfccf25b1bb65e82bae8edff11ac3c9c041c374b7b4a823d60c38133/tiktoken-0.14.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d0781223705199b289faa59601bb9c2441712d4c600dd13c43d8fd6a33d22cd5", size = 1039947, upload-time = "2026-08-17T19:49:32.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/ba/ef427fc638f1439181c5e12dd26b70e881861f89c007aa7e5b36300f8342/tiktoken-0.14.0-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:2ea70afba6b9eddbf22c165142e5f0a2ad7aa36a452873c48b57bb2aeb8492ae", size = 1186997, upload-time = "2026-08-17T19:49:34.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/88/2f3f85a968cdc514152129af0a060ebcccb067005a2f29b0d5ef3c838514/tiktoken-0.14.0-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:78571efc311c30b73f31eb949a921d6dac39a5d9dc42d1cfa8f8db157b3447b1", size = 1205211, upload-time = "2026-08-17T19:49:35.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/f6/80760e98a08e6649d2d68afb6035af713121dfb615acce8c4f73810ec438/tiktoken-0.14.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86f66c85e796f5d05d5c4a60ec1d40cbfebc47a32464053528c797163fa9ab89", size = 1251479, upload-time = "2026-08-17T19:49:36.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/84/50966fb6918a0fb9b32721277e5342bf729a2d74350074d662fbedf9772e/tiktoken-0.14.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:149d97453c4c98c04b081d64a85e635921269b532710d6faf81e9e82b790e7d3", size = 1316673, upload-time = "2026-08-17T19:49:37.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/5e/9b01afd037bfa22a0033963fa091e0f75b6fb15cd85bffb42ff86e697323/tiktoken-0.14.0-cp315-cp315t-win_amd64.whl", hash = "sha256:561e7580f84a79859af1ef6f676968e9030fcc3fe195700b15235bca64f009c9", size = 987929, upload-time = "2026-08-17T19:49:38.947Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
||||
]
|
||||
Reference in new issue
Block a user