From d7dc9dd68a2d410b5289cacacf23d55c2ed31036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B1=84=EB=AC=B8=EC=B0=BD=40MBA?= Date: Thu, 15 Oct 2026 22:45:16 +0900 Subject: [PATCH] =?UTF-8?q?66=EC=9D=BC=EC=B0=A8=20=EA=B5=90=EC=95=88/?= =?UTF-8?q?=EC=8B=A4=EC=8A=B5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Last3Mon/66/LAB-01-DESIGN-DATA-FLOW.md | 108 ++ Last3Mon/66/LAB-02-BUILD-PARSE-TRANSFORM.md | 191 +++ Last3Mon/66/LAB-03-BUILD-LOOP-PARALLEL.md | 258 ++++ Last3Mon/66/README.md | 60 + Last3Mon/66/n8n/requests/empty.json | 4 + Last3Mon/66/n8n/requests/no-action.json | 4 + Last3Mon/66/n8n/requests/weekly.json | 4 + .../01-meeting-actions-solution.json | 584 +++++++++ .../lab02-example1-parse-practice.json | 83 ++ .../lab02-example2-meeting-part1.json | 241 ++++ .../lab03-examples-loop-merge-practice.json | 229 ++++ .../66/templates/DATA-FLOW-DESIGN-TEMPLATE.md | 59 + Last3Mon/66/templates/LAB-01-WORKSHEET.md | 47 + Last3Mon/66/workflow-lab/.gitignore | 5 + Last3Mon/66/workflow-lab/README.md | 125 ++ Last3Mon/66/workflow-lab/data/faq.json | 14 + .../66/workflow-lab/data/meeting-notes.json | 10 + Last3Mon/66/workflow-lab/data/weather.json | 6 + .../66/workflow-lab/flows/01-llm-only.json | 14 + .../66/workflow-lab/flows/02-llm-tool.json | 15 + .../workflow-lab/flows/03-save-profile.json | 12 + .../flows/04-recommend-with-memory.json | 19 + .../flows/05-tech-support-ticket.json | 30 + .../flows/06-request-with-condition.json | 38 + .../flows/07-route-by-category.json | 29 + .../flows/08-meeting-actions.json | 168 +++ Last3Mon/66/workflow-lab/platforms.json | 24 + Last3Mon/66/workflow-lab/pyproject.toml | 28 + .../66/workflow-lab/src/miniflow/__init__.py | 5 + Last3Mon/66/workflow-lab/src/miniflow/cli.py | 158 +++ .../66/workflow-lab/src/miniflow/engine.py | 255 ++++ .../66/workflow-lab/src/miniflow/envfile.py | 50 + Last3Mon/66/workflow-lab/src/miniflow/llm.py | 193 +++ .../66/workflow-lab/src/miniflow/memory.py | 33 + .../66/workflow-lab/src/miniflow/platforms.py | 117 ++ .../66/workflow-lab/src/miniflow/template.py | 48 + .../66/workflow-lab/src/miniflow/tools.py | 85 ++ Last3Mon/66/workflow-lab/tests/conftest.py | 3 + .../tests/test_anthropic_provider.py | 68 ++ .../66/workflow-lab/tests/test_branching.py | 104 ++ .../tests/test_meeting_actions.py | 52 + .../tests/test_ollama_provider.py | 66 + .../tests/test_openai_provider.py | 80 ++ .../66/workflow-lab/tests/test_platforms.py | 79 ++ .../66/workflow-lab/tests/test_workflows.py | 80 ++ Last3Mon/66/workflow-lab/uv.lock | 1058 +++++++++++++++++ Lecture/66/00-DAY66-OVERVIEW.md | 305 +++++ Lecture/66/00-DAY66-PREP.md | 80 ++ Lecture/66/01-STAGE1-DESIGN-AND-DATA-FLOW.md | 260 ++++ Lecture/66/02-STAGE2-PARSE-AND-TRANSFORM.md | 272 +++++ .../66/03-STAGE3-LOOP-PARALLEL-AND-FINISH.md | 244 ++++ Lecture/66/README.md | 40 + 52 files changed, 6144 insertions(+) create mode 100644 Last3Mon/66/LAB-01-DESIGN-DATA-FLOW.md create mode 100644 Last3Mon/66/LAB-02-BUILD-PARSE-TRANSFORM.md create mode 100644 Last3Mon/66/LAB-03-BUILD-LOOP-PARALLEL.md create mode 100644 Last3Mon/66/README.md create mode 100644 Last3Mon/66/n8n/requests/empty.json create mode 100644 Last3Mon/66/n8n/requests/no-action.json create mode 100644 Last3Mon/66/n8n/requests/weekly.json create mode 100644 Last3Mon/66/n8n/workflows/01-meeting-actions-solution.json create mode 100644 Last3Mon/66/n8n/workflows/lab02-example1-parse-practice.json create mode 100644 Last3Mon/66/n8n/workflows/lab02-example2-meeting-part1.json create mode 100644 Last3Mon/66/n8n/workflows/lab03-examples-loop-merge-practice.json create mode 100644 Last3Mon/66/templates/DATA-FLOW-DESIGN-TEMPLATE.md create mode 100644 Last3Mon/66/templates/LAB-01-WORKSHEET.md create mode 100644 Last3Mon/66/workflow-lab/.gitignore create mode 100644 Last3Mon/66/workflow-lab/README.md create mode 100644 Last3Mon/66/workflow-lab/data/faq.json create mode 100644 Last3Mon/66/workflow-lab/data/meeting-notes.json create mode 100644 Last3Mon/66/workflow-lab/data/weather.json create mode 100644 Last3Mon/66/workflow-lab/flows/01-llm-only.json create mode 100644 Last3Mon/66/workflow-lab/flows/02-llm-tool.json create mode 100644 Last3Mon/66/workflow-lab/flows/03-save-profile.json create mode 100644 Last3Mon/66/workflow-lab/flows/04-recommend-with-memory.json create mode 100644 Last3Mon/66/workflow-lab/flows/05-tech-support-ticket.json create mode 100644 Last3Mon/66/workflow-lab/flows/06-request-with-condition.json create mode 100644 Last3Mon/66/workflow-lab/flows/07-route-by-category.json create mode 100644 Last3Mon/66/workflow-lab/flows/08-meeting-actions.json create mode 100644 Last3Mon/66/workflow-lab/platforms.json create mode 100644 Last3Mon/66/workflow-lab/pyproject.toml create mode 100644 Last3Mon/66/workflow-lab/src/miniflow/__init__.py create mode 100644 Last3Mon/66/workflow-lab/src/miniflow/cli.py create mode 100644 Last3Mon/66/workflow-lab/src/miniflow/engine.py create mode 100644 Last3Mon/66/workflow-lab/src/miniflow/envfile.py create mode 100644 Last3Mon/66/workflow-lab/src/miniflow/llm.py create mode 100644 Last3Mon/66/workflow-lab/src/miniflow/memory.py create mode 100644 Last3Mon/66/workflow-lab/src/miniflow/platforms.py create mode 100644 Last3Mon/66/workflow-lab/src/miniflow/template.py create mode 100644 Last3Mon/66/workflow-lab/src/miniflow/tools.py create mode 100644 Last3Mon/66/workflow-lab/tests/conftest.py create mode 100644 Last3Mon/66/workflow-lab/tests/test_anthropic_provider.py create mode 100644 Last3Mon/66/workflow-lab/tests/test_branching.py create mode 100644 Last3Mon/66/workflow-lab/tests/test_meeting_actions.py create mode 100644 Last3Mon/66/workflow-lab/tests/test_ollama_provider.py create mode 100644 Last3Mon/66/workflow-lab/tests/test_openai_provider.py create mode 100644 Last3Mon/66/workflow-lab/tests/test_platforms.py create mode 100644 Last3Mon/66/workflow-lab/tests/test_workflows.py create mode 100644 Last3Mon/66/workflow-lab/uv.lock create mode 100644 Lecture/66/00-DAY66-OVERVIEW.md create mode 100644 Lecture/66/00-DAY66-PREP.md create mode 100644 Lecture/66/01-STAGE1-DESIGN-AND-DATA-FLOW.md create mode 100644 Lecture/66/02-STAGE2-PARSE-AND-TRANSFORM.md create mode 100644 Lecture/66/03-STAGE3-LOOP-PARALLEL-AND-FINISH.md create mode 100644 Lecture/66/README.md diff --git a/Last3Mon/66/LAB-01-DESIGN-DATA-FLOW.md b/Last3Mon/66/LAB-01-DESIGN-DATA-FLOW.md new file mode 100644 index 0000000..439a61f --- /dev/null +++ b/Last3Mon/66/LAB-01-DESIGN-DATA-FLOW.md @@ -0,0 +1,108 @@ +# Lab 01 · 회의록 자동 정리 워크플로우 설계하기 + +- 관련 강의: [1단계 · 단일 업무 자동화 설계와 데이터 흐름](../../Lecture/66/01-STAGE1-DESIGN-AND-DATA-FLOW.md) +- 소요 시간: 약 22분 (예제 1 5분 · 예제 2 7분 · 과제 10분) +- 목표: 노드를 만들기 전에 **연결선마다 몇 개의 항목이 흐르는지** 읽고 쓸 수 있다. +- 결과물: `my-work/LAB-01-DESIGN.md` + +```mermaid +flowchart LR + E1["예제 1
어제 만든 흐름 읽기"] --> E2["예제 2
오늘 흐름의 빈칸 채우기"] --> T["과제
요청이 바뀌면
어디를 고치나?"] +``` + +먼저 활동지를 복사합니다. + +```bash +cd ~/aio-01d4-stu/Last3Mon/66 +mkdir -p my-work +cp templates/LAB-01-WORKSHEET.md my-work/LAB-01-DESIGN.md +``` + +## 예제 1 · 어제 만든 흐름 읽기 (5분) + +65일차 Lab 02A에서 직접 만든 `65-00 My first workflow`입니다. + +```mermaid +flowchart LR + T(["Start
Manual Trigger"]) --> S["Sample request
Edit Fields"] --> I{"Is urgent?
If"} + I -->|true| U["Urgent route"] + I -->|false| N["Normal route"] +``` + +활동지 1절의 표를 채웁니다. 항목(item)은 노드 사이를 지나가는 **데이터 한 덩어리**입니다. + +| 연결 | 항목 수 | 지나가는 필드 | +| --- | --- | --- | +| Start → Sample request | 1 | (없음) | +| Sample request → Is urgent? | ( ? ) | `user`, `text` | +| Is urgent? → Urgent route | ( ? ) | `user`, `text` | + +
답 보기 + +모두 **1개**입니다. If 노드는 항목을 **나누어 보낼 뿐** 개수를 늘리지 않습니다. 요청 1건은 true나 false 중 **한쪽 길로만** 갑니다. +
+ +## 예제 2 · 오늘 흐름의 빈칸 채우기 (7분) + +오늘 만들 회의록 워크플로우입니다. 회의록 1건에서 할 일 4건이 나옵니다 (예시: `n8n/requests/weekly.json`). + +```mermaid +flowchart LR + W(["Meeting webhook"]) --> S["Settings"] --> H{"Has notes?"} + H -->|false| R0["Reject empty"] + H -->|true| L1{{"LLM extract actions"}} --> P["Parse actions"] --> J["Join results"] + H -->|true| L2{{"LLM summarize"}} --> K["Keep summary"] --> J + J --> A{"Any actions?"} + A -->|true| SO["One item per action
Split Out"] --> N["Normalize action"] --> AG["Collect actions
Aggregate"] --> B["Build report"] --> R["Send result"] + A -->|false| NA["No actions"] --> R +``` + +활동지 2절의 빈칸 다섯 개를 채웁니다. + +| 번호 | 질문 | 답 | +| --- | --- | --- | +| ① | `Parse actions` → `Join results` 의 항목 수 | ( ? ) | +| ② | `One item per action` → `Normalize action` 의 항목 수 | ( ? ) | +| ③ | `Collect actions` → `Build report` 의 항목 수 | ( ? ) | +| ④ | `LLM extract actions`와 `LLM summarize`를 **동시에** 돌려도 되는 이유 | ( ? ) | +| ⑤ | 할 일 찾기·요약·`미정` 채우기·우선순위 정하기 중 **LLM이 하는 일** 두 가지 | ( ? ) | + +
답 보기 + +| 번호 | 답 | +| --- | --- | +| ① | 1 (할 일 4건이 `actions` **목록 하나**에 들어 있음) | +| ② | **4** (Split Out이 목록을 할 일 하나씩으로 펼침, 1 → 4) | +| ③ | **1** (Aggregate가 다시 목록 하나로 모음, 4 → 1) | +| ④ | 할 일 찾기와 요약은 **서로의 결과가 필요 없기** 때문 | +| ⑤ | 할 일 찾기, 요약. `미정` 채우기와 우선순위는 **규칙**(항상 같은 답) | +
+ +## 과제 · 요청이 바뀌면 어디를 고치나? (10분) + +팀장이 새로 요청했습니다. + +> "담당자나 기한이 정해지지 않은 할 일은 **확인 필요**로 표시해 주세요. 그리고 확인이 필요한 할 일이 **몇 건인지**도 결과에 넣어 주세요." + +예제 2의 그림을 보면서 활동지 3절의 질문에 답합니다. 노드를 새로 만들 필요가 있는지, 있던 노드를 고치면 되는지 생각해 보세요. + +1. 이 판단은 LLM이 할 일인가요, 규칙이 할 일인가요? 왜 그런가요? +2. "이 할 일은 확인 필요인가?"는 할 일 **하나마다** 정하는 일입니다. 그림에서 할 일이 하나씩 흐르는 노드는 어디인가요? 거기에 어떤 필드를 더하나요? +3. "몇 건인지"는 할 일을 **모두 모은 뒤**에야 셀 수 있습니다. 어느 노드에서 세나요? +4. 할 일이 하나도 없는 회의(`No actions` 길)의 결과에는 무엇을 넣어야 하나요? +5. 이 변경으로 연결선의 항목 수가 바뀌는 곳이 있나요? + +> 정답은 하나가 아닙니다. 위치와 이유를 한 줄씩 적으면 됩니다. 이 설계는 **Lab 03 과제**에서 실제로 만듭니다. + +
힌트 + +- 규칙으로 쓸 수 있는지 문장으로 적어 보세요: "담당자가 `미정`이거나 기한이 `미정`이면 확인 필요". +- 할 일 하나마다 우선순위(`priority`)를 정하던 노드를 떠올려 보세요. +- 결과 형식은 두 길이 **같은 필드**로 끝나야 합니다 (1단계 2.1절). +
+ +## 확인 + +- [ ] 예제 1의 표를 채우고, If가 항목 수를 바꾸지 않는다는 것을 설명할 수 있다. +- [ ] 예제 2의 ①~⑤를 채웠다 (1 → 4 → 1). +- [ ] 과제 1~5에 위치와 이유를 한 줄씩 적었다. diff --git a/Last3Mon/66/LAB-02-BUILD-PARSE-TRANSFORM.md b/Last3Mon/66/LAB-02-BUILD-PARSE-TRANSFORM.md new file mode 100644 index 0000000..f764397 --- /dev/null +++ b/Last3Mon/66/LAB-02-BUILD-PARSE-TRANSFORM.md @@ -0,0 +1,191 @@ +# Lab 02 · LLM 답을 데이터로 바꾸기 (입력 → LLM → 파싱) + +- 관련 강의: [2단계 · 데이터 파싱과 변환 노드](../../Lecture/66/02-STAGE2-PARSE-AND-TRANSFORM.md) +- 소요 시간: 약 55분 (예제 1 15분 · 예제 2 15분 · 과제 25분) +- 준비: 65일차 n8n 실행 (`cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start`), Windows 브라우저에서 http://localhost:5678 +- 목표: LLM의 답은 **글자**라는 것을 눈으로 확인하고, Code 노드로 **목록**으로 바꾼다. 그다음 뽑을 정보를 하나 늘려 본다. + +```mermaid +flowchart LR + E1["예제 1
LLM 없이
글자 → 목록"] --> E2["예제 2
진짜 LLM이 붙은
1부 실행"] --> T["과제
참석자도 뽑기"] +``` + +> 노드 이름은 뒤의 표현식에서 그대로 쓰므로 **안내와 똑같이** 지어 주세요. 노드 이름은 노드를 열고 맨 위 제목을 눌러 바꿉니다. + +## 예제 1 · LLM 없이 "글자 → 목록" (15분) + +LLM을 부르기 전에, LLM이 돌려줄 법한 답을 **직접 적어 넣고** 파싱부터 연습합니다. 돈도 시간도 들지 않고 결과가 항상 같습니다. + +```mermaid +flowchart LR + T(["Start
Manual Trigger"]) --> F["Fake LLM answer
Edit Fields"] --> P["Parse actions
Code"] +``` + +1. 왼쪽 위 **+ (Create) → Workflow**. 이름을 `66 Practice 1 · parse text`로 바꿉니다. +2. **Add first step**에서 `Manual Trigger`(Trigger manually)를 추가하고 이름을 `Start`로 바꿉니다. +3. 오른쪽 `+` → **Edit Fields (Set)**, 이름 `Fake LLM answer`. **Add Field**로 필드 하나를 만듭니다. + +| Name | Type | Value (Fixed 모드 그대로) | +| --- | --- | --- | +| `content` | String | 아래 한 줄 | + +```text +{"actions": [{"task": "신규 고객 문의 응답 템플릿 정리", "owner": "이준호", "due": "금요일"}, {"task": "회의실 예약 규칙 개선", "owner": "", "due": ""}]} +``` + +4. 오른쪽 `+` → **Code**, 이름 `Parse actions`. Language `JavaScript`, Mode `Run Once for All Items`. 원래 있던 코드를 지우고 아래를 붙여 넣습니다. + +```javascript +// Parse the answer text (JSON written as text) into a clean list of action items. +const raw = String($input.first().json.content ?? '').trim().replace(/^```(json)?\s*|```$/g, ''); +let data; +try { + data = JSON.parse(raw); +} catch (error) { + return [{ json: { actions: [], count: 0, error: 'answer is not valid JSON' } }]; +} +const items = Array.isArray(data) ? data : (data.actions ?? []); +const actions = items + .filter(item => item && String(item.task ?? '').trim()) + .map(item => ({ + task: String(item.task).trim(), + owner: String(item.owner || '미정').trim() || '미정', + due: String(item.due || '미정').trim() || '미정', + })); +return [{ json: { actions, count: actions.length } }]; +``` + +5. 아래쪽 **Execute workflow**를 누르고 두 노드의 OUTPUT을 비교합니다. (오른쪽 위 `Table / JSON / Schema` 보기를 바꿔 가며 봅니다.) + +| 노드 | 보이는 것 | +| --- | --- | +| Fake LLM answer | `content` **한 칸**에 긴 글자 한 줄 | +| Parse actions | `actions`가 **목록 2개**로 펼쳐짐, `count: 2`, 두 번째 할 일의 `owner`·`due`가 `미정` | + +6. **답을 일부러 망가뜨려 봅니다.** `Fake LLM answer`의 값에서 **맨 끝 `}` 하나를 지우고** 다시 실행합니다. + +| 결과 | 뜻 | +| --- | --- | +| `{"actions": [], "count": 0, "error": "answer is not valid JSON"}` | 망가진 답이 와도 **같은 모양**으로 끝나고, 이유(`error`)가 남음 | + +확인한 뒤 지운 `}`를 다시 넣고 **Save** 합니다. + +## 예제 2 · 진짜 LLM이 붙은 1부 실행하기 (15분) + +이번에는 같은 파싱 앞에 **Webhook과 LLM**이 붙은 워크플로우를 가져와 실행합니다. + +```mermaid +flowchart LR + W(["Meeting webhook"]) --> S["Settings"] --> H{"Has notes?"} + H -->|false| R0["Reject empty
(400)"] + H -->|true| L{{"LLM extract actions"}} --> P["Parse actions"] --> R["Send result"] +``` + +1. **+ (Create) → Workflow** → 오른쪽 위 **⋯ → Import from File...** → `Last3Mon/66/n8n/workflows/lab02-example2-meeting-part1.json` + - Windows 파일 선택 창 주소창: `\\wsl$\Ubuntu\home\<사용자>\aio-01d4-stu\Last3Mon\66\n8n\workflows` +2. 이름이 `66 Meeting notes (my)`인지 확인하고 **Save** 합니다. (Lab 03에서 이 워크플로우를 이어서 완성합니다.) +3. 노드 세 개를 열어 **어디서 무엇을 하는지** 확인합니다. + +| 노드 | 볼 곳 | +| --- | --- | +| `Settings` | `title`, `notes`가 Webhook 입력(`$json.body`)에서 꺼낸 값 | +| `LLM extract actions` | 아래로 내려 **JSON** 칸: ① `schema`(답의 모양) ② `role: 'system'`(지시문) ③ `role: 'user'`(회의록) | +| `Parse actions` | 예제 1과 같은 코드. 첫 줄만 `choices[0].message.content`(LLM 답이 있는 곳)를 읽음 | + +4. `Meeting webhook` 노드를 열고 **Listen for test event**를 누른 뒤, WSL 터미널에서 보냅니다. + +```bash +cd ~/aio-01d4-stu/Last3Mon/66 +curl -s -m 300 -X POST http://localhost:5678/webhook-test/my-meeting-actions \ + -H 'Content-Type: application/json' -d @n8n/requests/weekly.json +``` + +5. 결과와 캔버스의 OUTPUT을 확인합니다. (수업 준비 때 `gpt-4.1-mini`로 실제 실행한 결과) + +```text +{"actions":[{"task":"신규 고객 문의 응답 템플릿 정리","owner":"이준호","due":"금요일"}, + {"task":"VPN 장애 보고서 작성","owner":"박서연","due":"다음 주 월요일"}, + {"task":"서버 점검 일정 공지","owner":"김민지","due":"오늘"}, + {"task":"회의실 예약 규칙 개선 담당자 선정","owner":"미정","due":"10월 23일"}],"count":4} +``` + +| 노드 | 확인할 것 | +| --- | --- | +| LLM extract actions | `choices[0].message.content`가 따옴표로 둘러싸인 **글자** (예제 1의 `Fake LLM answer`와 같은 모양) | +| Parse actions | `actions`가 **목록 4개**, 4번째 `owner`가 `미정` (`due`는 LLM이 "다음 회의는 10월 23일"을 보고 `10월 23일`로 채우는 경우가 많음) | + +6. Listen을 다시 누르고 `empty.json` → HTTP **400** `{"error":"notes is required"}`, `no-action.json` → `{"actions":[],"count":0}` 도 확인합니다. (Test URL은 **한 번에 한 요청**만 받습니다.) + +## 과제 · 참석자도 뽑기 (25분) + +팀장이 요청했습니다. + +> "할 일과 함께 **회의 참석자 명단**도 받아 보고 싶어요." + +예제 2의 `66 Meeting notes (my)`를 고쳐서, 결과에 `attendees`(참석자 이름 목록)가 들어가게 만드세요. + +| 요청 | 기대 결과 | +| --- | --- | +| `weekly.json` | 할 일 4건과 함께 `"attendees":["김민지","이준호","박서연"]` | +| `no-action.json` | `"attendees":[]` (참석자가 적혀 있지 않음) | +| `empty.json` | 그대로 HTTP 400 | + +**먼저 생각해 보기 (3분)**: LLM에게 무엇을 더 알려 줘야 하나요? LLM 답이 바뀌면 그다음 노드는 무엇을 더 꺼내야 하나요? 고칠 곳은 **세 군데**입니다. 활동지나 메모장에 세 곳을 먼저 적은 뒤 고칩니다. + +
힌트 1 · 고칠 세 곳 + +| 곳 | 노드 | 무엇을 | +| --- | --- | --- | +| ① 답의 모양 | `LLM extract actions`의 `schema` | `properties`에 `attendees`(글자 목록) 추가, `required`에도 추가 | +| ② 지시문 | `LLM extract actions`의 `role: 'system'` 문장 | 참석자 이름도 `attendees`에 넣으라고 한 문장 추가 | +| ③ 꺼내기 | `Parse actions`의 마지막 `return` | `attendees`도 돌려주기 (없으면 `[]`) | +
+ +
힌트 2 · ① schema에 넣을 한 줄 + +`actions: { ... } },` 줄 **바로 아래**에 넣습니다. + +```text + attendees: { type: 'array', items: { type: 'string' } }, +``` + +그리고 `required: ['actions'],` 를 `required: ['actions', 'attendees'],` 로 바꿉니다. +
+ +
힌트 3 · ② 지시문과 ③ 코드 + +② `"... when there is no action item.` 뒤, 닫는 따옴표 `"` **앞**에 붙입니다. + +```text + Also list the attendee names in \"attendees\" (use [] when not stated). +``` + +③ 마지막 `return` 줄을 아래 두 줄로 바꿉니다. + +```javascript +const attendees = Array.isArray(data.attendees) ? data.attendees.map(name => String(name).trim()).filter(Boolean) : []; +return [{ json: { actions, count: actions.length, attendees } }]; +``` +
+ +**테스트**: Listen for test event → `weekly.json`, 다시 Listen → `no-action.json`, 다시 Listen → `empty.json`. 세 결과를 위 표와 비교합니다. + +> 참석자를 넣었더니 **할 일이 3건**으로 줄었다면 한 번 더 보내 보세요. 수업 준비 때 8번 중 1번 이런 일이 있었습니다. 뽑을 것을 늘리면 원래 뽑던 결과도 흔들릴 수 있으니, 고친 뒤에는 **원래 기대 결과(할 일 4건)도 다시** 확인합니다. + +**더 생각해 보기 (빨리 끝난 사람)** + +- ①만 고치고 ②를 안 고치면? ③을 안 고치면? 하나씩 되돌려 실행해 보고, 어느 노드의 OUTPUT에서 `attendees`가 사라지는지 찾아보세요. +- 참석자 명단은 LLM이 찾는 일인가요, 규칙이 하는 일인가요? + +## 막혔을 때 + +- `invalid syntax`: JSON 칸에서 `}`와 `}`가 **붙은** 곳이 있는지 봅니다. 표현식 `{{ }}` 안에서 `}}`가 붙으면 표현식이 거기서 끝납니다. `} }`처럼 띄어 씁니다. +- `401`: `LLM extract actions`의 Credential이 `OpenAI (.env)`인지 확인합니다. 없다면 65일차 SETUP 6단계(`register-credentials.sh`)를 다시 실행합니다. +- 고치다가 망가졌다면: 워크플로우를 지우고 예제 2의 파일을 **다시 가져옵니다**. +- Test URL 404: Listen for test event를 다시 누릅니다. + +## 확인 + +- [ ] 예제 1: `Fake LLM answer`(글자)와 `Parse actions`(목록)의 차이를 OUTPUT으로 확인했다. 망가진 답에서 `error`를 봤다. +- [ ] 예제 2: `weekly` 4건, `empty` 400, `no-action` 0건을 확인했다. +- [ ] 과제: `weekly`에서 `attendees` 3명, `no-action`에서 `[]`를 받았다. diff --git a/Last3Mon/66/LAB-03-BUILD-LOOP-PARALLEL.md b/Last3Mon/66/LAB-03-BUILD-LOOP-PARALLEL.md new file mode 100644 index 0000000..f409353 --- /dev/null +++ b/Last3Mon/66/LAB-03-BUILD-LOOP-PARALLEL.md @@ -0,0 +1,258 @@ +# Lab 03 · 반복·병렬로 회의록 워크플로우 완성하기 + +- 관련 강의: [3단계 · 반복·병렬 제어 구조와 완성](../../Lecture/66/03-STAGE3-LOOP-PARALLEL-AND-FINISH.md) +- 소요 시간: 약 65분 (예제 1 12분 · 예제 2 10분 · **과제 40분**) +- 목표: 연습용 데이터로 **펼치기 → 항목마다 처리 → 모으기**와 **병렬 → 합치기**를 먼저 익히고, 이것을 Lab 02의 내 워크플로우에 옮겨 완성한다. + +```mermaid +flowchart LR + E1["예제 1
펼치고 모으기
(1 → 3 → 1)"] --> E2["예제 2
두 길을 합치기
(Merge)"] --> T["과제 40분
내 회의록 워크플로우 완성
+ 확인 필요 표시"] +``` + +## 예제 1 · 펼치고 모으기 (12분) + +LLM 없이, 할 일 3건이 든 목록을 직접 만들어 연습합니다. + +```mermaid +flowchart LR + T(["Start"]) --> S["Sample actions
Edit Fields"] --> SO["One item per action
Split Out"] --> N["Normalize action
Edit Fields"] --> AG["Collect actions
Aggregate"] +``` + +1. 새 워크플로우 `66 Practice 2 · loop and merge`, 첫 노드는 `Manual Trigger`(이름 `Start`). +2. **Edit Fields (Set)** `Sample actions`: 필드 하나 + +| Name | Type | Value (Expression 모드) | +| --- | --- | --- | +| `actions` | Array | 아래 한 줄 | + +```text +{{ [ { task: '신규 고객 문의 응답 템플릿 정리', owner: '이준호', due: '금요일' }, { task: '서버 점검 일정 공지', owner: '김민지', due: '오늘' }, { task: '회의실 예약 규칙 개선', owner: '미정', due: '미정' } ] }} +``` + +3. **Split Out** `One item per action`: Fields To Split Out `actions` +4. **Edit Fields (Set)** `Normalize action`: 필드 네 개 + +| Name | Type | Value (Expression 모드) | +| --- | --- | --- | +| `task` | String | `{{ $json.task }}` | +| `owner` | String | `{{ $json.owner }}` | +| `due` | String | `{{ $json.due }}` | +| `priority` | String | `{{ ['오늘', '내일', '긴급'].some(w => $json.due.includes(w)) ? '높음' : '보통' }}` | + +5. **Aggregate** `Collect actions`: Aggregate `All Item Data (Into a Single List)`, Put Output in Field `actions` +6. **Execute workflow** 후, 캔버스의 노드 사이 연결선 위에 표시되는 **항목 수**를 읽습니다. + +| 노드 | 항목 수 | 보이는 것 | +| --- | --- | --- | +| Sample actions | 1 | `actions` 목록 하나 (3건) | +| One item per action | **3** | 할 일 하나씩 | +| Normalize action | **3** | + `priority` (`서버 점검 일정 공지`만 `높음`) | +| Collect actions | **1** | 다시 `actions` 목록 하나 | + +> 펼쳤으면(1 → 3) 반드시 모읍니다(3 → 1). 모으지 않으면 뒤의 노드가 할 일마다 따로 실행됩니다. + +## 예제 2 · 두 길을 합치기 (10분) + +같은 워크플로우에 **두 번째 길**을 하나 더 만들고 Merge로 합칩니다. + +```mermaid +flowchart LR + T(["Start"]) --> S["Sample actions"] --> X["... Collect actions"] --> J["Join results
Merge"] + T --> SS["Sample summary
Edit Fields"] --> J +``` + +1. `Start` 노드의 오른쪽 점에서 **선을 하나 더** 끌어 **Edit Fields (Set)** `Sample summary` 추가 + +| Name | Type | Value | +| --- | --- | --- | +| `summary` | String | `주간 회의에서 할 일 3건을 정했습니다. 서버 점검 공지는 오늘 안에 합니다.` | + +2. **Merge** `Join results`: Mode `Combine`, Combine By `Position` + - `Collect actions` → `Join results`의 **Input 1** + - `Sample summary` → `Join results`의 **Input 2** +3. **Execute workflow** → `Join results`의 OUTPUT이 **항목 1개**이고 `actions`(3건)와 `summary`가 **함께** 들어 있는지 확인합니다. + +```text +{"actions":[ ...3건, priority 포함 ... ], "summary":"주간 회의에서 할 일 3건을 정했습니다. 서버 점검 공지는 오늘 안에 합니다."} +``` + +4. **Save** 합니다. 이 워크플로우의 노드는 과제에서 **복사해 붙여** 씁니다. + +> 막혔다면 `n8n/workflows/lab03-examples-loop-merge-practice.json`을 가져와 비교합니다. + +## 과제 · 내 회의록 워크플로우 완성하기 (40분) + +### 상황 + +팀장의 요청을 모두 담은 회의록 워크플로우를 완성합니다. 예제 1·2는 **가짜 데이터**였습니다. 이제 그 자리에 Lab 02의 **진짜 LLM 결과**를 넣습니다. + +> "회의록을 보내면 **2문장 요약**, **우선순위가 붙은 할 일 목록**, **공유용 정리문**을 한 번에 받고 싶어요. 할 일이 없는 회의면 `할 일 없음`으로 알려 주세요. 그리고 담당자나 기한이 정해지지 않은 할 일은 **확인 필요**로 표시하고 몇 건인지 알려 주세요." (Lab 01 과제에서 설계한 내용) + +### 완성 그림 + +`66 Meeting notes (my)`(Lab 02)를 아래 모양으로 만듭니다. 굵은 테두리가 새로 넣을 노드입니다. + +```mermaid +flowchart LR + W(["Meeting webhook"]) --> S["Settings"] --> H{"Has notes?"} + H -->|false| R0["Reject empty"] + H -->|true| L1{{"LLM extract actions"}} --> P["Parse actions"] --> J["Join results"] + H -->|true| L2{{"LLM summarize"}} --> K["Keep summary"] --> J + J --> A{"Any actions?"} + A -->|true| SO["One item per action"] --> N["Normalize action"] --> AG["Collect actions"] --> B["Build report"] --> R["Send result"] + A -->|false| NA["No actions"] --> R + classDef new stroke-width:3px + class L2,K,J,A,SO,N,AG,B,NA new +``` + +### 기대 결과 (이 표가 정답 기준입니다) + +| 요청 | 기대 결과 | +| --- | --- | +| `weekly.json` | `route: 할 일 정리`, `count: 4`, `need_check: 1`, `summary`(2문장 안팎), 3번째 할 일 `priority: 높음`, 4번째 할 일 `check: true`, `report_text` 4번째 줄 끝에 `※ 확인 필요` | +| `no-action.json` | `route: 할 일 없음`, `count: 0`, `need_check: 0`, `summary`, `actions: []` | +| `empty.json` | HTTP 400 `{"error":"notes is required"}` | + +### 진행 순서 (시간은 기준) + +각 단계를 마칠 때마다 **Listen for test event → `weekly.json`** 으로 실행해 OUTPUT을 확인하고 다음으로 갑니다. + +#### 1단계 · 요약 길을 만들고 합치기 (10분) + +- `Parse actions` → `Send result` 연결선을 지웁니다. (선 위에 마우스 → 휴지통) +- `LLM extract actions`를 오른쪽 클릭 → **Duplicate** → 이름 `LLM summarize`. `Has notes?`의 **true**에서 선을 하나 더 끌어 연결하고, JSON 칸을 아래로 **통째로** 바꿉니다. + +```text +{{ JSON.stringify({ model: $('Settings').first().json.model, temperature: 0, messages: [ { role: 'system', content: "You summarize meeting notes. Reply only with the summary in Korean, at most 2 sentences. No English, no comments." }, { role: 'user', content: '다음 회의록을 요약하세요.\n\n회의록:\n' + $('Settings').first().json.notes } ] }) }} +``` + +- **생각할 것**: 요약은 LLM 답(`choices[0].message.content`)에서 꺼내야 합니다. 예제 2의 `Sample summary`처럼 `summary` 필드 하나를 만드는 `Keep summary`를 직접 만들어 보세요. 그다음 `Join results`(예제 2에서 복사)의 Input 1·2에 어느 노드를 연결하나요? + +
힌트 + +- `Keep summary`: Edit Fields, `summary`(String) = `{{ $json.choices[0].message.content.trim() }}` +- `Parse actions` → Input 1, `Keep summary` → Input 2. 합친 결과에 `actions`, `count`, `summary`가 함께 있으면 성공. +
+ +#### 2단계 · 할 일이 있을 때와 없을 때 (5분) + +- `Join results` 다음에 **If** `Any actions?`를 넣습니다. +- **생각할 것**: 할 일이 있는지 무엇으로 알 수 있나요? (`Parse actions`가 만든 필드를 떠올려 보세요.) + +
힌트 + +`{{ $json.count }}` · **Number → is greater than** · `0` +
+ +#### 3단계 · 펼치고 모아서 정리문 만들기 (12분) + +- 예제 1·2의 워크플로우에서 `One item per action`, `Normalize action`, `Collect actions` **세 노드를 드래그로 함께 선택 → Ctrl+C**, 내 워크플로우에서 **Ctrl+V** 합니다. `Any actions?`의 **true**에 연결합니다. +- `Collect actions` 다음에 **Code** `Build report`를 넣고 아래 코드를 붙여 넣은 뒤 `Send result`에 연결합니다. + +```javascript +// Build the final result from the collected actions. +const settings = $('Settings').first().json; +const joined = $('Join results').first().json; +const actions = $input.first().json.actions; +const lines = actions.map((a, i) => `${i + 1}. [${a.priority}] ${a.task} - ${a.owner} (${a.due})`); +return [{ json: { + title: settings.title, + route: '할 일 정리', + count: actions.length, + summary: joined.summary, + actions, + report_text: `${settings.title}\n${joined.summary}\n\n${lines.join('\n')}`, +} }]; +``` + +- `Any actions?`의 **false**에 **Edit Fields** `No actions`를 넣고 `Send result`에 연결합니다. +- **생각할 것**: `No actions`의 결과는 할 일이 있을 때와 **같은 필드**여야 받는 쪽이 편합니다. `Build report`가 돌려주는 필드를 보고 `No actions`의 필드를 정해 보세요. `summary`는 어디서 가져오나요? + +
힌트 + +| Name | Type | Value | +| --- | --- | --- | +| `title` | String | `{{ $('Settings').first().json.title }}` | +| `route` | String | `할 일 없음` | +| `count` | Number | `0` | +| `summary` | String | `{{ $json.summary }}` | +| `actions` | Array | `{{ [] }}` | + +`Any actions?`의 false로 들어온 항목은 `Join results`의 결과이므로 `$json.summary`가 있습니다. +
+ +여기까지 `weekly.json`으로 `count: 4`와 정리문(`report_text`)이 나오면, `no-action.json`으로 `할 일 없음`도 확인합니다. + +#### 4단계 · 확인 필요 표시 (Lab 01 과제 구현) (8분) + +Lab 01 과제에서 적은 내 설계를 펼쳐 놓고 만듭니다. + +- **생각할 것 ①**: 할 일 하나마다 `check`(참/거짓)를 붙일 노드는 어디인가요? `priority`를 만든 방식을 참고해, "담당자가 `미정`이거나 기한이 `미정`이면 참"을 표현식으로 써 보세요. +- **생각할 것 ②**: `need_check`(확인 필요 건수)는 할 일을 다 모은 뒤에 셀 수 있습니다. `Build report` 코드의 어디에 무엇을 더하나요? `No actions`에는요? +- (선택) `report_text`에서 확인이 필요한 줄 끝에 `※ 확인 필요`를 붙여 보세요. + +
힌트 1 · check 필드 + +`Normalize action`에 필드를 하나 더합니다. Name `check`, Type **Boolean**, Value (Expression 모드) + +```text +{{ $json.owner === '미정' || $json.due === '미정' }} +``` +
+ +
힌트 2 · need_check와 표시 + +`Build report`의 `count: actions.length,` 아래에 한 줄 + +```javascript + need_check: actions.filter(a => a.check).length, +``` + +`lines`를 만드는 줄 끝의 `` (${a.due})` `` 를 `` (${a.due})${a.check ? ' ※ 확인 필요' : ''}` `` 로 바꾸면 표시가 붙습니다. `No actions`에는 `need_check`(Number) `0`을 더합니다. +
+ +#### 5단계 · 세 요청 확인과 게시 (5분) + +```bash +cd ~/aio-01d4-stu/Last3Mon/66 +curl -s -m 300 -w " [HTTP %{http_code}]\n" -X POST http://localhost:5678/webhook-test/my-meeting-actions \ + -H 'Content-Type: application/json' -d @n8n/requests/weekly.json +``` + +요청마다 Listen for test event를 다시 누르고 `no-action.json`, `empty.json`도 보냅니다. 위 **기대 결과** 표와 모두 같으면 오른쪽 위 **Publish** 합니다. + +**실행 기록으로 검증**: `weekly.json` 실행에서 `Join results` 1 → `One item per action` 4 → `Normalize action` 4 → `Collect actions` 1 → `Build report` 1 인지 소리 내어 읽어 봅니다. + +**Lab 02 과제(참석자)를 한 사람**: `Build report`의 결과에 `attendees`가 없습니다. 왜 사라졌을까요? (`Build report`는 결과를 **새로** 만듭니다.) `attendees: joined.attendees ?? [],` 한 줄로 살려 보세요. + +### 막혔을 때 + +| 증상 | 볼 곳 | +| --- | --- | +| 응답에 첫 할 일 **하나만** 옴 | `Normalize action`에서 `Send result`로 바로 연결하지 않았나? `Collect actions`를 거쳐야 함 | +| `Build report`에서 오류 | `Collect actions`를 빼먹었거나, 노드 이름(`Settings`, `Join results`)이 다름 | +| `summary`가 비어 있음 | `Keep summary`가 `Join results`의 Input 2에 연결됐는지 | +| `no-action`에서 HTTP 200인데 **내용이 비어 있음** | `Any actions?`의 false → `No actions` → `Send result`가 연결됐는지 (수업 준비 때 실측: false 길이 끊기면 오류 없이 빈 응답) | +| `need_check`가 4 (할 일 수와 같음) | `check`의 Type이 **String**이면 `"false"`라는 글자가 되어 모두 참으로 셈 (실측). Type을 **Boolean**으로 | + +- 구조가 헷갈리면 `n8n/workflows/01-meeting-actions-solution.json`(확인 필요 표시 **전**까지의 정답)을 새 워크플로우로 가져와 비교합니다. + +## 마무리 · Python에서 부르기 (8교시에 함께) + +게시한 뒤 `workflow-lab/platforms.json`의 `meeting` → `n8n` → `webhook` 값을 `my-meeting-actions`로 바꾸고 부릅니다. + +```bash +cd ~/aio-01d4-stu/Last3Mon/66/workflow-lab +uv run miniflow call meeting -i title="주간 회의" \ + -i notes="$(python3 -c "import json;print(json.load(open('data/meeting-notes.json'))['weekly']['notes'])")" +git checkout platforms.json # 끝나면 되돌리기 +``` + +코드는 그대로 두고 **설정 한 줄**만 바꿔 호출 대상이 내 워크플로우로 바뀝니다. n8n을 멈추면 같은 업무를 `miniflow`의 `flows/08-meeting-actions.json`이 이어받습니다. + +## 확인 + +- [ ] 예제 1: 항목 수 1 → 3 → 3 → 1을 확인했다. +- [ ] 예제 2: `Join results`에서 `actions`와 `summary`가 한 항목으로 합쳐졌다. +- [ ] 과제: 세 요청의 기대 결과를 모두 확인했다 (`need_check: 1` 포함). +- [ ] 과제: 실행 기록에서 1 → 4 → 4 → 1 → 1을 확인했다. diff --git a/Last3Mon/66/README.md b/Last3Mon/66/README.md new file mode 100644 index 0000000..224b4e4 --- /dev/null +++ b/Last3Mon/66/README.md @@ -0,0 +1,60 @@ +# 66일차 실습 · 단일 업무 자동화 워크플로우 직접 만들기 + +- 일자: 2026-10-16 (66일차) +- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영 +- 단원: AI 워크플로우 설계 및 구현 +- 강의 자료: [Lecture/66](../../Lecture/66/README.md) + +## 오늘 만드는 업무: 회의록 → 할 일 정리 + +```mermaid +flowchart LR + W(["Meeting webhook"]) --> S["Settings"] --> H{"Has notes?"} + H -->|false| R0["Reject empty"] + H -->|true| L1{{"LLM extract actions"}} --> P["Parse actions"] --> J["Join results"] + H -->|true| L2{{"LLM summarize"}} --> K["Keep summary"] --> J + J --> A{"Any actions?"} + A -->|true| SO["One item per action"] --> N["Normalize action"] --> AG["Collect actions"] --> B["Build report"] --> R["Send result"] + A -->|false| NA["No actions"] --> R +``` + +## 실습 방식 + +모든 Lab은 **쉬운 예제 2개 → 예제를 고치는 과제** 순서입니다. 예제는 안내를 그대로 따라 하면 되고, 과제에서 생각합니다. + +| Lab | 시간 | 예제 1 | 예제 2 | 과제 | +| --- | --- | --- | --- | --- | +| [Lab 01](LAB-01-DESIGN-DATA-FLOW.md) 설계 | 2교시 22분 | 어제 만든 흐름의 항목 수 읽기 | 오늘 흐름의 빈칸 채우기 (1 → 4 → 1) | "확인 필요" 요청이 오면 어디를 고치나 | +| [Lab 02](LAB-02-BUILD-PARSE-TRANSFORM.md) 파싱 | 3교시 끝 + 4교시 | LLM 없이 글자 → 목록 | 진짜 LLM이 붙은 1부 가져와 실행 | 참석자도 뽑기 (세 곳 고치기) | +| [Lab 03](LAB-03-BUILD-LOOP-PARALLEL.md) 완성 | 5교시 끝 + 6교시 | 펼치고 모으기 (1 → 3 → 1) | 두 길을 Merge로 합치기 | **40분** · 내 워크플로우 완성 + 확인 필요 표시 | + +## 구성 + +```text +Last3Mon/66/ +├── README.md +├── LAB-01-DESIGN-DATA-FLOW.md # 1단계 · 설계 (예제 2개 + 과제) +├── LAB-02-BUILD-PARSE-TRANSFORM.md # 2단계 · 입력 → LLM → 파싱 (예제 2개 + 과제) +├── LAB-03-BUILD-LOOP-PARALLEL.md # 3단계 · 반복·병렬로 완성 (예제 2개 + 40분 과제), Python 호출 +├── templates/ +│ ├── LAB-01-WORKSHEET.md # Lab 01 활동지 +│ └── DATA-FLOW-DESIGN-TEMPLATE.md # 내 업무 설계서 (복습 과제용) +├── my-work/ # 여러분의 결과물 (직접 만듭니다) +├── n8n/ +│ ├── requests/ # 테스트 요청 (weekly · no-action · empty) +│ └── workflows/ +│ ├── lab02-example1-parse-practice.json # Lab 02 예제 1 (막혔을 때 비교) +│ ├── lab02-example2-meeting-part1.json # Lab 02 예제 2 (가져와서 시작) +│ ├── lab03-examples-loop-merge-practice.json # Lab 03 예제 1·2 (막혔을 때 비교) +│ └── 01-meeting-actions-solution.json # Lab 03 과제 구조 비교용 (확인 필요 표시 전까지) +└── workflow-lab/ # miniflow (65일차 + JSON 출력·parse_actions·08 회의록 flow) +``` + +## 준비 + +- 65일차의 n8n(Credential `OpenAI (.env)`)과 `Last3Mon/.env`를 그대로 사용합니다: `cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start` +- 실습 엔진 확인: + +```bash +cd ~/aio-01d4-stu/Last3Mon/66/workflow-lab && uv sync && uv run pytest -q # 43 passed, 1 skipped +``` diff --git a/Last3Mon/66/n8n/requests/empty.json b/Last3Mon/66/n8n/requests/empty.json new file mode 100644 index 0000000..c533010 --- /dev/null +++ b/Last3Mon/66/n8n/requests/empty.json @@ -0,0 +1,4 @@ +{ + "title": "빈 회의록", + "notes": "" +} \ No newline at end of file diff --git a/Last3Mon/66/n8n/requests/no-action.json b/Last3Mon/66/n8n/requests/no-action.json new file mode 100644 index 0000000..a63f15c --- /dev/null +++ b/Last3Mon/66/n8n/requests/no-action.json @@ -0,0 +1,4 @@ +{ + "title": "분기 실적 공유", + "notes": "오늘은 지난 분기 실적을 공유하고 질의응답을 했습니다. 별도 결정 사항은 없습니다." +} \ No newline at end of file diff --git a/Last3Mon/66/n8n/requests/weekly.json b/Last3Mon/66/n8n/requests/weekly.json new file mode 100644 index 0000000..4fc72ec --- /dev/null +++ b/Last3Mon/66/n8n/requests/weekly.json @@ -0,0 +1,4 @@ +{ + "title": "10월 16일 주간 회의", + "notes": "참석: 김민지, 이준호, 박서연. 1) 신규 고객 문의 응답 템플릿을 이준호가 금요일까지 정리한다. 2) 박서연은 다음 주 월요일까지 VPN 장애 보고서를 작성한다. 3) 서버 점검 일정은 김민지가 오늘 중 공지한다. 4) 회의실 예약 규칙 개선은 담당자를 다음 회의에서 정한다. 다음 회의는 10월 23일." +} \ No newline at end of file diff --git a/Last3Mon/66/n8n/workflows/01-meeting-actions-solution.json b/Last3Mon/66/n8n/workflows/01-meeting-actions-solution.json new file mode 100644 index 0000000..4840335 --- /dev/null +++ b/Last3Mon/66/n8n/workflows/01-meeting-actions-solution.json @@ -0,0 +1,584 @@ +{ + "name": "66-01 Meeting notes to action items", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "meeting-actions", + "responseMode": "responseNode", + "options": {} + }, + "id": "b0f12ecc-f34f-5b93-ab1a-b4f0e776cd07", + "name": "Meeting webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + 0, + 0 + ], + "webhookId": "fd884467-4d6e-5e1e-b81c-8d909d0348a5" + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "a65c3a2e-b4c8-5087-a475-c6a858d9b7d6", + "name": "llm_url", + "value": "https://api.openai.com/v1", + "type": "string" + }, + { + "id": "e30dbc5a-e549-5565-9f8f-96e55670f69f", + "name": "model", + "value": "gpt-4.1-mini", + "type": "string" + }, + { + "id": "4ef048c7-0ac0-5913-b743-12cda43aa2b2", + "name": "title", + "value": "={{ $json.body.title || '제목 없음' }}", + "type": "string" + }, + { + "id": "b901c535-f38f-5a59-975a-05074ce8044c", + "name": "notes", + "value": "={{ $json.body.notes }}", + "type": "string" + } + ] + }, + "includeOtherFields": false, + "options": {} + }, + "id": "99de8b0b-c43f-58dc-ad3d-543621575770", + "name": "Settings", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 220, + 0 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "41964561-0243-53d7-b1cd-0e64054d7460", + "leftValue": "={{ $json.notes }}", + "operator": { + "type": "string", + "operation": "notEmpty", + "singleValue": true + }, + "rightValue": "" + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "21232562-8565-5643-96a1-d643847e0ffb", + "name": "Has notes?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [ + 440, + 0 + ] + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ { \"error\": \"notes is required\" } }}", + "options": { + "responseCode": 400 + } + }, + "id": "076e2cf3-ed8d-5b8a-bd2c-026784a818e1", + "name": "Reject empty", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [ + 680, + 260 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $('Settings').first().json.llm_url }}/chat/completions", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: $('Settings').first().json.model, temperature: 0, response_format: { type: 'json_schema', json_schema: { name: 'reply', schema: {\"type\": \"object\", \"properties\": {\"actions\": {\"type\": \"array\", \"items\": {\"type\": \"object\", \"properties\": {\"task\": {\"type\": \"string\"}, \"owner\": {\"type\": \"string\"}, \"due\": {\"type\": \"string\"} }, \"required\": [\"task\", \"owner\", \"due\"]} } }, \"required\": [\"actions\"]} } }, messages: [ { role: 'system', content: \"Extract action items from the meeting notes. Answer in JSON: {\\\"actions\\\": [{\\\"task\\\": str, \\\"owner\\\": str, \\\"due\\\": str}]}. Write task in Korean. Use \\\"미정\\\" when the owner or due date is not stated. Return {\\\"actions\\\": []} when there is no action item.\" }, { role: 'user', content: $('Settings').first().json.notes } ] }) }}", + "options": { + "timeout": 180000 + }, + "authentication": "predefinedCredentialType", + "nodeCredentialType": "openAiApi" + }, + "id": "b704079a-e151-5b7f-814a-1e94e3ccd940", + "name": "LLM extract actions", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 680, + -120 + ], + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + } + }, + { + "parameters": { + "jsCode": "// Parse the LLM answer (JSON text) into a clean list of action items.\nconst raw = String($input.first().json.choices?.[0]?.message?.content ?? '').trim().replace(/^```(json)?\\s*|```$/g, '');\nlet data;\ntry {\n data = JSON.parse(raw);\n} catch (error) {\n return [{ json: { actions: [], count: 0, error: 'answer is not valid JSON' } }];\n}\nconst items = Array.isArray(data) ? data : (data.actions ?? []);\nconst actions = items\n .filter(item => item && String(item.task ?? '').trim())\n .map(item => ({\n task: String(item.task).trim(),\n owner: String(item.owner || '미정').trim() || '미정',\n due: String(item.due || '미정').trim() || '미정',\n }));\nreturn [{ json: { actions, count: actions.length } }];" + }, + "id": "c78c7bb3-c72d-5ca2-ac9f-d541c024fa75", + "name": "Parse actions", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 900, + -120 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $('Settings').first().json.llm_url }}/chat/completions", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: $('Settings').first().json.model, temperature: 0, messages: [ { role: 'system', content: \"You summarize meeting notes. Reply only with the summary in Korean, at most 2 sentences. No English, no comments.\" }, { role: 'user', content: '다음 회의록을 요약하세요.\\n\\n회의록:\\n' + $('Settings').first().json.notes } ] }) }}", + "options": { + "timeout": 180000 + }, + "authentication": "predefinedCredentialType", + "nodeCredentialType": "openAiApi" + }, + "id": "ddb065b8-063a-5741-a301-577f004c04d3", + "name": "LLM summarize", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 680, + 100 + ], + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + } + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "1a7470e6-3557-5a05-b3c2-2b52762c1d54", + "name": "summary", + "value": "={{ $json.choices[0].message.content.trim() }}", + "type": "string" + } + ] + }, + "includeOtherFields": false, + "options": {} + }, + "id": "0e8b9b2f-e48c-591d-a712-a351cd715fd3", + "name": "Keep summary", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 900, + 100 + ] + }, + { + "parameters": { + "mode": "combine", + "combineBy": "combineByPosition", + "options": {} + }, + "id": "506361cb-50b7-5d5d-9dcc-76121628d788", + "name": "Join results", + "type": "n8n-nodes-base.merge", + "typeVersion": 3, + "position": [ + 1120, + 0 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "cdd4276c-6039-5731-aadf-bafdf9fdf535", + "leftValue": "={{ $json.count }}", + "operator": { + "type": "number", + "operation": "gt" + }, + "rightValue": 0 + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "bf7c1717-f207-5071-a0a8-961a65bfdbe3", + "name": "Any actions?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [ + 1340, + 0 + ] + }, + { + "parameters": { + "fieldToSplitOut": "actions", + "options": {} + }, + "id": "a9f538fb-61c0-5a8d-9051-9778db98a4d1", + "name": "One item per action", + "type": "n8n-nodes-base.splitOut", + "typeVersion": 1, + "position": [ + 1560, + -120 + ] + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "99cb6b63-de3c-560f-8170-b33332fb5cb9", + "name": "task", + "value": "={{ $json.task }}", + "type": "string" + }, + { + "id": "ee9068cb-7666-5a5b-8937-d9c748333d6a", + "name": "owner", + "value": "={{ $json.owner }}", + "type": "string" + }, + { + "id": "067da7cb-3f07-5f20-b45d-6f5fe45ca441", + "name": "due", + "value": "={{ $json.due }}", + "type": "string" + }, + { + "id": "1efe8c52-179a-5a38-8648-f78d96bb8e88", + "name": "priority", + "value": "={{ ['오늘', '내일', '긴급'].some(w => $json.due.includes(w)) ? '높음' : '보통' }}", + "type": "string" + } + ] + }, + "includeOtherFields": false, + "options": {} + }, + "id": "000677ea-2fe7-5e70-9983-db85cafbde43", + "name": "Normalize action", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 1780, + -120 + ] + }, + { + "parameters": { + "aggregate": "aggregateAllItemData", + "destinationFieldName": "actions", + "options": {} + }, + "id": "1adf8b41-b947-52ea-939b-8c630e7b7c2b", + "name": "Collect actions", + "type": "n8n-nodes-base.aggregate", + "typeVersion": 1, + "position": [ + 2000, + -120 + ] + }, + { + "parameters": { + "jsCode": "// Build the final result from the collected actions.\nconst settings = $('Settings').first().json;\nconst joined = $('Join results').first().json;\nconst actions = $input.first().json.actions;\nconst lines = actions.map((a, i) => `${i + 1}. [${a.priority}] ${a.task} - ${a.owner} (${a.due})`);\nreturn [{ json: {\n title: settings.title,\n route: '할 일 정리',\n count: actions.length,\n summary: joined.summary,\n actions,\n report_text: `${settings.title}\\n${joined.summary}\\n\\n${lines.join('\\n')}`,\n} }];" + }, + "id": "5d19a847-10d1-565b-90ec-4476443e4a35", + "name": "Build report", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2220, + -120 + ] + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "4fbd9776-6e85-560c-a217-529108a8b787", + "name": "title", + "value": "={{ $('Settings').first().json.title }}", + "type": "string" + }, + { + "id": "709b7545-af8b-5be2-8b4a-add33d70849a", + "name": "route", + "value": "할 일 없음", + "type": "string" + }, + { + "id": "6563e214-e619-5c86-a3a9-3b40608432ab", + "name": "count", + "value": "0", + "type": "number" + }, + { + "id": "1b86f1f8-0486-5c45-853d-8ab5cf62c80c", + "name": "summary", + "value": "={{ $json.summary }}", + "type": "string" + }, + { + "id": "406a4e0c-7283-5c3e-9762-52690b884289", + "name": "actions", + "value": "={{ [] }}", + "type": "array" + } + ] + }, + "includeOtherFields": false, + "options": {} + }, + "id": "19b6ad75-3169-59d3-bc1b-5196f43d8d59", + "name": "No actions", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 1560, + 120 + ] + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "id": "b773d7a3-74dc-59f1-8e61-0279ce658cdb", + "name": "Send result", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [ + 2440, + 0 + ] + } + ], + "connections": { + "Meeting webhook": { + "main": [ + [ + { + "node": "Settings", + "type": "main", + "index": 0 + } + ] + ] + }, + "Settings": { + "main": [ + [ + { + "node": "Has notes?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Has notes?": { + "main": [ + [ + { + "node": "LLM extract actions", + "type": "main", + "index": 0 + }, + { + "node": "LLM summarize", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Reject empty", + "type": "main", + "index": 0 + } + ] + ] + }, + "LLM extract actions": { + "main": [ + [ + { + "node": "Parse actions", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse actions": { + "main": [ + [ + { + "node": "Join results", + "type": "main", + "index": 0 + } + ] + ] + }, + "LLM summarize": { + "main": [ + [ + { + "node": "Keep summary", + "type": "main", + "index": 0 + } + ] + ] + }, + "Keep summary": { + "main": [ + [ + { + "node": "Join results", + "type": "main", + "index": 1 + } + ] + ] + }, + "Join results": { + "main": [ + [ + { + "node": "Any actions?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Any actions?": { + "main": [ + [ + { + "node": "One item per action", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "No actions", + "type": "main", + "index": 0 + } + ] + ] + }, + "One item per action": { + "main": [ + [ + { + "node": "Normalize action", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalize action": { + "main": [ + [ + { + "node": "Collect actions", + "type": "main", + "index": 0 + } + ] + ] + }, + "Collect actions": { + "main": [ + [ + { + "node": "Build report", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build report": { + "main": [ + [ + { + "node": "Send result", + "type": "main", + "index": 0 + } + ] + ] + }, + "No actions": { + "main": [ + [ + { + "node": "Send result", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "pinData": {}, + "id": "a6c07c353caf55" +} \ No newline at end of file diff --git a/Last3Mon/66/n8n/workflows/lab02-example1-parse-practice.json b/Last3Mon/66/n8n/workflows/lab02-example1-parse-practice.json new file mode 100644 index 0000000..dc4b11b --- /dev/null +++ b/Last3Mon/66/n8n/workflows/lab02-example1-parse-practice.json @@ -0,0 +1,83 @@ +{ + "name": "66 Practice 1 · parse text", + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "name": "Start", + "position": [ + 0, + 0 + ], + "id": "188d4e06-7014-58e1-8053-cfebb3d2b1ac" + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "d932ecca-eec2-5729-96c4-d0fe4e05ed98", + "name": "content", + "value": "{\"actions\": [{\"task\": \"신규 고객 문의 응답 템플릿 정리\", \"owner\": \"이준호\", \"due\": \"금요일\"}, {\"task\": \"회의실 예약 규칙 개선\", \"owner\": \"\", \"due\": \"\"}]}", + "type": "string" + } + ] + }, + "includeOtherFields": false, + "options": {} + }, + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "name": "Fake LLM answer", + "position": [ + 220, + 0 + ], + "id": "92fb2178-5944-5b66-8399-23529f0255f0" + }, + { + "parameters": { + "jsCode": "// Parse the answer text (JSON written as text) into a clean list of action items.\nconst raw = String($input.first().json.content ?? '').trim().replace(/^```(json)?\\s*|```$/g, '');\nlet data;\ntry {\n data = JSON.parse(raw);\n} catch (error) {\n return [{ json: { actions: [], count: 0, error: 'answer is not valid JSON' } }];\n}\nconst items = Array.isArray(data) ? data : (data.actions ?? []);\nconst actions = items\n .filter(item => item && String(item.task ?? '').trim())\n .map(item => ({\n task: String(item.task).trim(),\n owner: String(item.owner || '미정').trim() || '미정',\n due: String(item.due || '미정').trim() || '미정',\n }));\nreturn [{ json: { actions, count: actions.length } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "name": "Parse actions", + "position": [ + 440, + 0 + ], + "id": "b97d203a-dcbf-5d31-9265-106a5f6a83aa" + } + ], + "connections": { + "Start": { + "main": [ + [ + { + "node": "Fake LLM answer", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fake LLM answer": { + "main": [ + [ + { + "node": "Parse actions", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "pinData": {}, + "id": "a6c07c353c6610" +} diff --git a/Last3Mon/66/n8n/workflows/lab02-example2-meeting-part1.json b/Last3Mon/66/n8n/workflows/lab02-example2-meeting-part1.json new file mode 100644 index 0000000..0ceadef --- /dev/null +++ b/Last3Mon/66/n8n/workflows/lab02-example2-meeting-part1.json @@ -0,0 +1,241 @@ +{ + "name": "66 Meeting notes (my)", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "my-meeting-actions", + "responseMode": "responseNode", + "options": {} + }, + "id": "c3c17991-1bf8-5a91-abf1-f1cfdf0f12be", + "name": "Meeting webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + 0, + 0 + ], + "webhookId": "f65c6c90-0042-57c2-832a-bce70f02d66b" + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "a65c3a2e-b4c8-5087-a475-c6a858d9b7d6", + "name": "llm_url", + "value": "https://api.openai.com/v1", + "type": "string" + }, + { + "id": "e30dbc5a-e549-5565-9f8f-96e55670f69f", + "name": "model", + "value": "gpt-4.1-mini", + "type": "string" + }, + { + "id": "4ef048c7-0ac0-5913-b743-12cda43aa2b2", + "name": "title", + "value": "={{ $json.body.title || '제목 없음' }}", + "type": "string" + }, + { + "id": "b901c535-f38f-5a59-975a-05074ce8044c", + "name": "notes", + "value": "={{ $json.body.notes }}", + "type": "string" + } + ] + }, + "includeOtherFields": false, + "options": {} + }, + "id": "9f1fe720-925b-5518-9038-88631e8fc018", + "name": "Settings", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 220, + 0 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "41964561-0243-53d7-b1cd-0e64054d7460", + "leftValue": "={{ $json.notes }}", + "operator": { + "type": "string", + "operation": "notEmpty", + "singleValue": true + }, + "rightValue": "" + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "ceaaa37c-8541-574a-9fae-1bc55fd90a2a", + "name": "Has notes?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [ + 440, + 0 + ] + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ { \"error\": \"notes is required\" } }}", + "options": { + "responseCode": 400 + } + }, + "id": "35a8cb08-bc4c-502f-9176-e6414457c99e", + "name": "Reject empty", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [ + 660, + 120 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $('Settings').first().json.llm_url }}/chat/completions", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({\n model: $('Settings').first().json.model,\n temperature: 0,\n response_format: { type: 'json_schema', json_schema: { name: 'reply', schema: {\n type: 'object',\n properties: {\n actions: { type: 'array', items: { type: 'object',\n properties: { task: { type: 'string' }, owner: { type: 'string' }, due: { type: 'string' } },\n required: ['task', 'owner', 'due'] } },\n },\n required: ['actions'],\n } } },\n messages: [\n { role: 'system', content: \"Extract action items from the meeting notes. Answer in JSON: {\\\"actions\\\": [{\\\"task\\\": str, \\\"owner\\\": str, \\\"due\\\": str}]}. Write task in Korean. Use \\\"미정\\\" when the owner or due date is not stated. Return {\\\"actions\\\": []} when there is no action item.\" },\n { role: 'user', content: $('Settings').first().json.notes },\n ],\n}) }}", + "options": { + "timeout": 180000 + }, + "authentication": "predefinedCredentialType", + "nodeCredentialType": "openAiApi" + }, + "id": "bab5c8f4-53c5-5623-b7c8-1f75f962e60b", + "name": "LLM extract actions", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 660, + -100 + ], + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + } + }, + { + "parameters": { + "jsCode": "// Parse the LLM answer (JSON text) into a clean list of action items.\nconst raw = String($input.first().json.choices?.[0]?.message?.content ?? '').trim().replace(/^```(json)?\\s*|```$/g, '');\nlet data;\ntry {\n data = JSON.parse(raw);\n} catch (error) {\n return [{ json: { actions: [], count: 0, error: 'answer is not valid JSON' } }];\n}\nconst items = Array.isArray(data) ? data : (data.actions ?? []);\nconst actions = items\n .filter(item => item && String(item.task ?? '').trim())\n .map(item => ({\n task: String(item.task).trim(),\n owner: String(item.owner || '미정').trim() || '미정',\n due: String(item.due || '미정').trim() || '미정',\n }));\nreturn [{ json: { actions, count: actions.length } }];" + }, + "id": "2d12968a-6877-53a7-a7e4-ec7c51d893b1", + "name": "Parse actions", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 880, + -100 + ] + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "id": "155b7034-4293-50ca-a6f6-71db885a2e06", + "name": "Send result", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [ + 1100, + -100 + ] + } + ], + "connections": { + "Meeting webhook": { + "main": [ + [ + { + "node": "Settings", + "type": "main", + "index": 0 + } + ] + ] + }, + "Settings": { + "main": [ + [ + { + "node": "Has notes?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Has notes?": { + "main": [ + [ + { + "node": "LLM extract actions", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Reject empty", + "type": "main", + "index": 0 + } + ] + ] + }, + "LLM extract actions": { + "main": [ + [ + { + "node": "Parse actions", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse actions": { + "main": [ + [ + { + "node": "Send result", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "pinData": {}, + "id": "a6c07c353c6611" +} diff --git a/Last3Mon/66/n8n/workflows/lab03-examples-loop-merge-practice.json b/Last3Mon/66/n8n/workflows/lab03-examples-loop-merge-practice.json new file mode 100644 index 0000000..3681231 --- /dev/null +++ b/Last3Mon/66/n8n/workflows/lab03-examples-loop-merge-practice.json @@ -0,0 +1,229 @@ +{ + "name": "66 Practice 2 · loop and merge", + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "name": "Start", + "position": [ + 0, + 0 + ], + "id": "188d4e06-7014-58e1-8053-cfebb3d2b1ac" + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "8ee7c660-4702-575c-a2b7-cd64a157d621", + "name": "actions", + "value": "={{ [ { task: '신규 고객 문의 응답 템플릿 정리', owner: '이준호', due: '금요일' }, { task: '서버 점검 일정 공지', owner: '김민지', due: '오늘' }, { task: '회의실 예약 규칙 개선', owner: '미정', due: '미정' } ] }}", + "type": "array" + } + ] + }, + "includeOtherFields": false, + "options": {} + }, + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "name": "Sample actions", + "position": [ + 220, + -100 + ], + "id": "f85d4eae-f60b-5f2e-a6f3-592638343b0c" + }, + { + "parameters": { + "fieldToSplitOut": "actions", + "options": {} + }, + "id": "5fa4a092-6961-574a-89fa-c2e05a5113f3", + "name": "One item per action", + "type": "n8n-nodes-base.splitOut", + "typeVersion": 1, + "position": [ + 440, + -100 + ] + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "99cb6b63-de3c-560f-8170-b33332fb5cb9", + "name": "task", + "value": "={{ $json.task }}", + "type": "string" + }, + { + "id": "ee9068cb-7666-5a5b-8937-d9c748333d6a", + "name": "owner", + "value": "={{ $json.owner }}", + "type": "string" + }, + { + "id": "067da7cb-3f07-5f20-b45d-6f5fe45ca441", + "name": "due", + "value": "={{ $json.due }}", + "type": "string" + }, + { + "id": "1efe8c52-179a-5a38-8648-f78d96bb8e88", + "name": "priority", + "value": "={{ ['오늘', '내일', '긴급'].some(w => $json.due.includes(w)) ? '높음' : '보통' }}", + "type": "string" + } + ] + }, + "includeOtherFields": false, + "options": {} + }, + "id": "3605a441-d86c-5f4a-98f5-2edfae18a003", + "name": "Normalize action", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 660, + -100 + ] + }, + { + "parameters": { + "aggregate": "aggregateAllItemData", + "destinationFieldName": "actions", + "options": {} + }, + "id": "44e52bee-7b5a-547d-9c1c-3579d5499c85", + "name": "Collect actions", + "type": "n8n-nodes-base.aggregate", + "typeVersion": 1, + "position": [ + 880, + -100 + ] + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "78c8fca5-5936-5934-b362-52f09a6df40f", + "name": "summary", + "value": "주간 회의에서 할 일 3건을 정했습니다. 서버 점검 공지는 오늘 안에 합니다.", + "type": "string" + } + ] + }, + "includeOtherFields": false, + "options": {} + }, + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "name": "Sample summary", + "position": [ + 220, + 120 + ], + "id": "d551b1bf-b437-5eb6-bde7-9391f51d468a" + }, + { + "parameters": { + "mode": "combine", + "combineBy": "combineByPosition", + "options": {} + }, + "id": "41b96eeb-592f-5a28-a9a3-dc7e9b077939", + "name": "Join results", + "type": "n8n-nodes-base.merge", + "typeVersion": 3, + "position": [ + 1100, + 0 + ] + } + ], + "connections": { + "Start": { + "main": [ + [ + { + "node": "Sample actions", + "type": "main", + "index": 0 + }, + { + "node": "Sample summary", + "type": "main", + "index": 0 + } + ] + ] + }, + "Sample actions": { + "main": [ + [ + { + "node": "One item per action", + "type": "main", + "index": 0 + } + ] + ] + }, + "One item per action": { + "main": [ + [ + { + "node": "Normalize action", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalize action": { + "main": [ + [ + { + "node": "Collect actions", + "type": "main", + "index": 0 + } + ] + ] + }, + "Collect actions": { + "main": [ + [ + { + "node": "Join results", + "type": "main", + "index": 0 + } + ] + ] + }, + "Sample summary": { + "main": [ + [ + { + "node": "Join results", + "type": "main", + "index": 1 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "pinData": {}, + "id": "a6c07c353c6612" +} diff --git a/Last3Mon/66/templates/DATA-FLOW-DESIGN-TEMPLATE.md b/Last3Mon/66/templates/DATA-FLOW-DESIGN-TEMPLATE.md new file mode 100644 index 0000000..016d15a --- /dev/null +++ b/Last3Mon/66/templates/DATA-FLOW-DESIGN-TEMPLATE.md @@ -0,0 +1,59 @@ +# 업무 자동화 워크플로우 설계서 (데이터 흐름 포함) + +- 작성자: +- 업무 이름: +- 한 줄 목표: + +## 1. Trigger·Condition·Action + +| 구분 | 내용 | +| --- | --- | +| Trigger (시작과 입력 필드) | | +| Condition (분기 지점) | | +| Action (각 길에서 하는 일) | | + +## 2. 노드 목록 + +| 번호 | 노드 이름 | 종류 (Trigger·Set·IF·Switch·LLM·Code·Split Out·Aggregate·Merge·Respond 등) | 하는 일 | +| --- | --- | --- | --- | +| N1 | | | | +| N2 | | | | + +## 3. 노드 간 데이터 (연결선마다) + +| 연결 | 항목 수 | 주요 필드와 형식 | 예시 | +| --- | --- | --- | --- | +| N1 → N2 | 1개 | `title`(문자), `notes`(문자) | `{"title": "주간 회의", "notes": "..."}` | +| | | | | + +항목 수가 바뀌는 곳(1개 → 여러 개, 여러 개 → 1개)은 반드시 표시합니다. + +## 4. 제어 구조 + +| 구조 | 어디에 | 이유 | +| --- | --- | --- | +| 조건 분기 (IF·Switch) | | | +| 반복 (항목마다 처리) | | | +| 병렬 (Fork-Join) | | | + +## 5. 흐름 그림 + +```mermaid +flowchart LR + T(["Trigger"]) --> A["노드"] --> C{"조건"} + C -->|"참"| B["노드"] + C -->|"거짓"| D["노드"] +``` + +## 6. 최종 결과 형식 + +```json +{"route": "", "count": 0, "summary": "", "actions": []} +``` + +## 7. 점검 + +- [ ] 모든 길의 끝에 결과가 있다. +- [ ] 항목 수가 바뀌는 곳을 표시했다. +- [ ] LLM 출력이 다음 노드로 가기 전에 정리·검증하는 노드가 있다. +- [ ] 비어 있거나 잘못된 입력을 맨 앞에서 거절한다. diff --git a/Last3Mon/66/templates/LAB-01-WORKSHEET.md b/Last3Mon/66/templates/LAB-01-WORKSHEET.md new file mode 100644 index 0000000..a32a165 --- /dev/null +++ b/Last3Mon/66/templates/LAB-01-WORKSHEET.md @@ -0,0 +1,47 @@ +# Lab 01 활동지 · 회의록 워크플로우 설계 + +- 작성자: + +## 1. 예제 1 · 어제 만든 흐름 읽기 + +| 연결 | 항목 수 | 지나가는 필드 | +| --- | --- | --- | +| Start → Sample request | 1 | (없음) | +| Sample request → Is urgent? | | `user`, `text` | +| Is urgent? → Urgent route | | `user`, `text` | + +If 노드가 항목 수를 바꾸지 않는 이유: + +## 2. 예제 2 · 오늘 흐름의 빈칸 + +| 번호 | 질문 | 답 | +| --- | --- | --- | +| ① | `Parse actions` → `Join results` 의 항목 수 | | +| ② | `One item per action` → `Normalize action` 의 항목 수 | | +| ③ | `Collect actions` → `Build report` 의 항목 수 | | +| ④ | 두 LLM 노드를 동시에 돌려도 되는 이유 | | +| ⑤ | LLM이 하는 일 두 가지 | | + +## 3. 과제 · "확인 필요" 표시 추가 + +요청: 담당자나 기한이 정해지지 않은 할 일은 **확인 필요**로 표시하고, 그런 할 일이 몇 건인지 결과에 넣는다. + +| 번호 | 질문 | 내 답 (위치와 이유 한 줄) | +| --- | --- | --- | +| 1 | LLM이 할 일인가, 규칙이 할 일인가? | | +| 2 | 할 일 하나마다 표시하는 곳과 더할 필드 | | +| 3 | 몇 건인지 세는 곳과 더할 필드 | | +| 4 | `No actions` 길의 결과에 넣을 것 | | +| 5 | 항목 수가 바뀌는 곳이 생기나? | | + +바뀐 결과 형식 (예시 JSON): + +```json +{"route": "할 일 정리", "count": 4, "summary": "", "actions": []} +``` + +## 4. Lab 03을 마친 뒤 (복습 과제) + +내 설계와 실제로 만든 워크플로우에서 달라진 점: + +- diff --git a/Last3Mon/66/workflow-lab/.gitignore b/Last3Mon/66/workflow-lab/.gitignore new file mode 100644 index 0000000..e938d04 --- /dev/null +++ b/Last3Mon/66/workflow-lab/.gitignore @@ -0,0 +1,5 @@ +runs/ +.miniflow-memory.json +.venv/ +.pytest_cache/ +__pycache__/ diff --git a/Last3Mon/66/workflow-lab/README.md b/Last3Mon/66/workflow-lab/README.md new file mode 100644 index 0000000..3c586b3 --- /dev/null +++ b/Last3Mon/66/workflow-lab/README.md @@ -0,0 +1,125 @@ +# workflow-lab (66일차): JSON 출력 파싱과 회의록 워크플로우 + +65일차 버전(조건 분기·플랫폼 전환)에 **LLM JSON 출력(`json_schema`)** 과 **`parse_actions` Tool**, 회의록 워크플로우(`08`)를 더한 버전입니다. + +노코드 워크플로우 도구는 화면에서 **노드를 놓고 선으로 연결**합니다. 이 실습에서는 같은 구조를 JSON 파일로 적고 `miniflow`로 실행합니다. 화면 대신 파일을 쓸 뿐, 노드·연결·데이터 전달이라는 개념은 같습니다. + +```mermaid +flowchart LR + J["flows/*.json
노드와 연결 정의"] --> E["miniflow 엔진
순서 계산 · 실행"] + E --> L{{"LLM 노드
openai · ollama · mock · anthropic · gemini"}} + E --> T["Tool 노드
get_weather · search_faq"] + E --> M[("Memory 노드
.miniflow-memory.json")] + E --> R["runs/*.json
실행 기록(Trace)"] +``` + +## 1. 설치와 확인 + +```bash +cd Last3Mon/64/workflow-lab +uv sync +uv run miniflow tools +uv run pytest -q +``` + +## 2. 명령어 + +| 명령 | 하는 일 | +| --- | --- | +| `uv run miniflow run flows/01-llm-only.json -i text="..."` | 워크플로우 실행. 입력은 `-i 이름=값`으로 여러 개 전달 | +| `uv run miniflow validate flows/02-llm-tool.json` | 실행하지 않고 구조만 검사, 실행 순서 출력 | +| `uv run miniflow show flows/04-recommend-with-memory.json` | 워크플로우를 Mermaid 다이어그램으로 출력 | +| `uv run miniflow tools` | 사용할 수 있는 Tool 목록 | +| `uv run miniflow call request -i user=kim -i text="..."` | 같은 업무를 n8n → miniflow 순서로 실행 (앞 플랫폼이 실패하면 다음으로 전환) | +| `uv run miniflow check` | 설정한 LLM에 연결되는지 확인 | +| `uv run miniflow memory` / `--clear` | Memory 내용 보기 / 지우기 | + +실행할 때마다 각 노드의 입력·출력·소요 시간이 `runs/` 폴더에 JSON으로 저장됩니다. + +## 3. 노드 종류 + +| type | 역할 | 필수 필드 | +| --- | --- | --- | +| `input` | 실행 시 받은 입력을 흐름에 넣음 | 없음 | +| `llm` | 프롬프트를 LLM에 보내고 답을 받음 | `prompt` (선택: `system`, `temperature`, `mock`, `json_schema`) | + +`json_schema`를 적으면 LLM이 그 모양의 JSON으로만 답합니다 (OpenAI 구조화 출력, Ollama `format`). 답은 JSON **문자열**이므로 `parse_actions` 같은 Tool로 정리해서 씁니다. +| `tool` | 등록된 함수를 호출해 데이터를 가져옴 | `tool`, `args` | +| `memory_read` | 저장된 기억을 키로 읽음 | `key` | +| `memory_write` | 기억을 키에 저장(기존 값과 병합) | `key`, `value` | +| `output` | 최종 결과를 만듦 (분기마다 하나씩 둘 수 있음) | `value` | +| `condition` | 조건을 검사해 `true`/`false` 중 한 길로 보냄 | `check` (`left`, `op`, `right`) | +| `switch` | 값에 따라 여러 길 중 하나로 보냄. 맞는 값이 없으면 `default` | `value`, `cases` | + +`condition`의 `op`: `equals`, `not_equals`, `contains`, `empty`, `not_empty`, `in`, `gt`, `lt` + +분기 노드에서 나가는 연결에는 세 번째 값으로 길 이름을 적습니다. 실행 규칙은 다음과 같습니다. + +- 길 이름이 있는 연결은 **관문**입니다. 분기 노드가 다른 길을 골랐거나 분기 노드 자체가 건너뛰어졌으면, 연결된 노드는 실행되지 않고 실행 기록에 `skipped`로 남습니다. +- 길 이름이 없는 일반 연결은 하나라도 이어지면 실행됩니다. 그래서 갈라졌던 두 길이 다시 만나는 노드는 정상적으로 실행됩니다. + +```json +"edges": [["has_faq", "draft", "true"], ["has_faq", "hand_off", "false"], ["route", "team_device", "device"]] +``` + +## 4. 데이터 전달 규칙 + +앞 노드의 결과는 `{{...}}`로 꺼내 씁니다. + +| 표현 | 의미 | +| --- | --- | +| `{{input.city}}` | 실행할 때 받은 입력 `city` | +| `{{nodes.weather.output}}` | `weather` 노드의 결과 전체 | +| `{{nodes.weather.output.temp_c}}` | 결과 안의 특정 값 | +| `{{nodes.faq.output.0.answer}}` | 목록의 첫 번째 항목 안의 값 | + +## 5. LLM 선택 + +`MINIFLOW_LLM`으로 LLM을 고릅니다. 수업 기본값은 **OpenAI API**(`gpt-4.1-mini`)이고, 설정은 `Last3Mon/.env` 한 곳에 둡니다. `miniflow`는 실행한 폴더에서 위로 올라가며 가장 가까운 `.env`를 읽습니다 (셸에서 `export`한 값이 있으면 그 값이 우선). `.env`는 `.gitignore`에 들어 있어 커밋되지 않습니다. 준비 방법은 [65일차 SETUP-N8N.md](../../65/SETUP-N8N.md) 2절을 참고하세요. + +| `MINIFLOW_LLM` | LLM | 필요한 것 | +| --- | --- | --- | +| `openai` (수업 기본) | OpenAI (`OPENAI_MODEL`, 기본 `gpt-4.1-mini`) | `.env`의 `OPENAI_API_KEY` | +| `ollama` (대체) | 로컬 모델 (`OLLAMA_MODEL`, 기본 `gemma3:4b`) | 65일차 Compose의 Ollama, 모델 다운로드 | +| `mock` (설정하지 않았을 때) | 노드의 `mock` 설정대로 정해진 답 | 없음 (자동 테스트, 키 없이 연습) | +| `anthropic` | Anthropic Claude (`ANTHROPIC_MODEL`, 기본 `claude-opus-5-5`) | `uv sync --extra anthropic`, `ANTHROPIC_API_KEY` | +| `gemini` | Google Gemini (`GEMINI_MODEL`) | `uv sync --extra gemini`, `GEMINI_API_KEY` | + +`MINIFLOW_LLM_FALLBACK=ollama`이면 OpenAI 호출이 실패할 때(키 없음, 크레딧 부족, 네트워크 오류) Ollama로 한 번 더 시도하고, 화면에 `[miniflow] openai failed (...); trying ollama`를 남깁니다. + +```bash +cat ../../.env | grep -v API_KEY # 설정 확인 (키는 화면에 띄우지 않기) +uv run miniflow check # 연결 확인: Reply (openai, …): ready +uv run miniflow check # 대체 모델 확인 (한 번만 바꿔 실행) +``` + +## 6. 플랫폼 전환 실행 (`call`) + +`platforms.json`에 업무 이름(`request`)과 플랫폼별 실행 방법을 적어 둡니다. 실행 코드는 업무 이름만 알고, 어느 플랫폼에서 실행할지는 설정이 정합니다. + +```json +{"workflows": {"request": { + "n8n": {"webhook": "ai-request"}, + "miniflow": {"flow": "flows/06-request-with-condition.json"} +}}} +``` + +| 환경 변수 | 의미 | 기본값 | +| --- | --- | --- | +| `WORKFLOW_PLATFORMS` | 시도할 플랫폼 순서 | `n8n,miniflow` | +| `N8N_BASE_URL` | n8n 주소 | `http://localhost:5678` | +| `N8N_TIMEOUT` | n8n 응답 대기 시간(초) | `120` | + +플랫폼을 추가할 때는 `src/miniflow/platforms.py`에 `run(workflow, inputs)`를 가진 어댑터 클래스를 만들고 `register_platform()`으로 등록합니다. `call`을 쓰는 코드는 바꾸지 않습니다. + +## 7. 회의록 워크플로우 (`08-meeting-actions.json`) + +```bash +uv run miniflow run flows/08-meeting-actions.json -i title="주간 회의" \ + -i notes="$(python3 -c "import json;print(json.load(open('data/meeting-notes.json'))['weekly']['notes'])")" +uv run miniflow call meeting -i title="주간 회의" -i notes="..." # n8n → miniflow 순서로 실행 +``` + +| Tool | 하는 일 | +| --- | --- | +| `parse_actions(text)` | LLM의 JSON 답을 `{"actions": [{task, owner, due}], "count": n}`로 정리. 비어 있는 담당자·기한은 `미정`, JSON이 아니면 `count: 0` | diff --git a/Last3Mon/66/workflow-lab/data/faq.json b/Last3Mon/66/workflow-lab/data/faq.json new file mode 100644 index 0000000..10eb7f1 --- /dev/null +++ b/Last3Mon/66/workflow-lab/data/faq.json @@ -0,0 +1,14 @@ +[ + {"id": "FAQ-001", "title": "비밀번호 재설정", "keywords": ["비밀번호", "패스워드", "password", "재설정", "초기화"], + "answer": "사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정합니다."}, + {"id": "FAQ-002", "title": "계정 잠김 해제", "keywords": ["계정", "잠김", "잠금", "로그인", "실패"], + "answer": "로그인 5회 실패 시 30분 동안 잠깁니다. 30분 후 다시 시도하거나 헬프데스크에 잠금 해제를 요청합니다."}, + {"id": "FAQ-003", "title": "VPN 접속 오류", "keywords": ["vpn", "원격", "재택", "접속", "연결"], + "answer": "VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속합니다."}, + {"id": "FAQ-004", "title": "프린터 출력 안 됨", "keywords": ["프린터", "출력", "인쇄", "용지"], + "answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력합니다."}, + {"id": "FAQ-005", "title": "메일함 용량 초과", "keywords": ["메일", "용량", "메일함", "첨부"], + "answer": "오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제해 용량을 확보합니다."}, + {"id": "FAQ-006", "title": "소프트웨어 설치 요청", "keywords": ["설치", "소프트웨어", "프로그램", "라이선스"], + "answer": "IT 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."} +] diff --git a/Last3Mon/66/workflow-lab/data/meeting-notes.json b/Last3Mon/66/workflow-lab/data/meeting-notes.json new file mode 100644 index 0000000..6e28c48 --- /dev/null +++ b/Last3Mon/66/workflow-lab/data/meeting-notes.json @@ -0,0 +1,10 @@ +{ + "weekly": { + "title": "10월 16일 주간 회의", + "notes": "참석: 김민지, 이준호, 박서연. 1) 신규 고객 문의 응답 템플릿을 이준호가 금요일까지 정리한다. 2) 박서연은 다음 주 월요일까지 VPN 장애 보고서를 작성한다. 3) 서버 점검 일정은 김민지가 오늘 중 공지한다. 4) 회의실 예약 규칙 개선은 담당자를 다음 회의에서 정한다. 다음 회의는 10월 23일." + }, + "no_action": { + "title": "분기 실적 공유", + "notes": "오늘은 지난 분기 실적을 공유하고 질의응답을 했습니다. 별도 결정 사항은 없습니다." + } +} diff --git a/Last3Mon/66/workflow-lab/data/weather.json b/Last3Mon/66/workflow-lab/data/weather.json new file mode 100644 index 0000000..20fe5fc --- /dev/null +++ b/Last3Mon/66/workflow-lab/data/weather.json @@ -0,0 +1,6 @@ +{ + "서울": {"condition": "맑음", "temp_c": 19, "rain_chance": 10, "air": "보통"}, + "부산": {"condition": "흐림", "temp_c": 21, "rain_chance": 40, "air": "좋음"}, + "제주": {"condition": "비", "temp_c": 20, "rain_chance": 80, "air": "좋음"}, + "강릉": {"condition": "맑음", "temp_c": 17, "rain_chance": 0, "air": "좋음"} +} diff --git a/Last3Mon/66/workflow-lab/flows/01-llm-only.json b/Last3Mon/66/workflow-lab/flows/01-llm-only.json new file mode 100644 index 0000000..14268f0 --- /dev/null +++ b/Last3Mon/66/workflow-lab/flows/01-llm-only.json @@ -0,0 +1,14 @@ +{ + "name": "01 LLM only", + "description": "입력 → LLM → 출력. 가장 단순한 AI 워크플로우", + "inputs": ["text"], + "nodes": [ + {"id": "start", "type": "input"}, + {"id": "summarize", "type": "llm", + "system": "You are a helpful assistant that writes concise Korean summaries.", + "prompt": "다음 글을 3줄 이내의 한국어로 요약해 주세요.\n\n{{input.text}}", + "mock": {"template": "(Mock 응답) 실제 LLM을 연결하면 아래 글이 3줄로 요약됩니다.\n원문: {{input.text}}"}}, + {"id": "result", "type": "output", "value": "{{nodes.summarize.output}}"} + ], + "edges": [["start", "summarize"], ["summarize", "result"]] +} diff --git a/Last3Mon/66/workflow-lab/flows/02-llm-tool.json b/Last3Mon/66/workflow-lab/flows/02-llm-tool.json new file mode 100644 index 0000000..ddd9a39 --- /dev/null +++ b/Last3Mon/66/workflow-lab/flows/02-llm-tool.json @@ -0,0 +1,15 @@ +{ + "name": "02 LLM + Tool", + "description": "Tool이 가져온 데이터를 LLM이 근거로 사용해 답변", + "inputs": ["city", "question"], + "nodes": [ + {"id": "start", "type": "input"}, + {"id": "weather", "type": "tool", "tool": "get_weather", "args": {"city": "{{input.city}}"}}, + {"id": "answer", "type": "llm", + "system": "Answer only from the given weather data. Reply in Korean.", + "prompt": "날씨 데이터: {{nodes.weather.output}}\n질문: {{input.question}}", + "mock": {"template": "{{nodes.weather.output.city}}의 오늘 날씨는 {{nodes.weather.output.condition}}, 기온 {{nodes.weather.output.temp_c}}도, 강수 확률 {{nodes.weather.output.rain_chance}}%입니다."}}, + {"id": "result", "type": "output", "value": "{{nodes.answer.output}}"} + ], + "edges": [["start", "weather"], ["weather", "answer"], ["answer", "result"]] +} diff --git a/Last3Mon/66/workflow-lab/flows/03-save-profile.json b/Last3Mon/66/workflow-lab/flows/03-save-profile.json new file mode 100644 index 0000000..171f9e8 --- /dev/null +++ b/Last3Mon/66/workflow-lab/flows/03-save-profile.json @@ -0,0 +1,12 @@ +{ + "name": "03 Save profile to memory", + "description": "사용자 선호를 Memory에 저장 (다음 실행에서 다시 사용)", + "inputs": ["user", "preference"], + "nodes": [ + {"id": "start", "type": "input"}, + {"id": "remember", "type": "memory_write", "key": "user:{{input.user}}", + "value": {"preference": "{{input.preference}}"}}, + {"id": "result", "type": "output", "value": {"saved_for": "{{input.user}}", "memory": "{{nodes.remember.output}}"}} + ], + "edges": [["start", "remember"], ["remember", "result"]] +} diff --git a/Last3Mon/66/workflow-lab/flows/04-recommend-with-memory.json b/Last3Mon/66/workflow-lab/flows/04-recommend-with-memory.json new file mode 100644 index 0000000..6832320 --- /dev/null +++ b/Last3Mon/66/workflow-lab/flows/04-recommend-with-memory.json @@ -0,0 +1,19 @@ +{ + "name": "04 LLM + Tool + Memory", + "description": "Memory(선호) + Tool(날씨) → LLM 추천 → Memory(방문 기록) 갱신", + "inputs": ["user", "city"], + "nodes": [ + {"id": "start", "type": "input"}, + {"id": "recall", "type": "memory_read", "key": "user:{{input.user}}"}, + {"id": "weather", "type": "tool", "tool": "get_weather", "args": {"city": "{{input.city}}"}}, + {"id": "recommend", "type": "llm", + "system": "You are a travel assistant. Use the user's memory and the weather. Reply in Korean, 2 sentences.", + "prompt": "사용자 기억: {{nodes.recall.output}}\n날씨: {{nodes.weather.output}}\n오늘 {{input.city}}에서 할 일을 추천해 주세요.", + "mock": {"template": "{{input.user}}님은 '{{nodes.recall.output.preference}}'을(를) 좋아하시죠. {{input.city}}은(는) 오늘 {{nodes.weather.output.condition}}이니 그에 맞는 일정을 추천합니다."}}, + {"id": "remember", "type": "memory_write", "key": "user:{{input.user}}", + "value": {"last_city": "{{input.city}}", "last_recommendation": "{{nodes.recommend.output}}"}}, + {"id": "result", "type": "output", "value": "{{nodes.recommend.output}}"} + ], + "edges": [["start", "recall"], ["start", "weather"], ["recall", "recommend"], ["weather", "recommend"], + ["recommend", "remember"], ["remember", "result"]] +} diff --git a/Last3Mon/66/workflow-lab/flows/05-tech-support-ticket.json b/Last3Mon/66/workflow-lab/flows/05-tech-support-ticket.json new file mode 100644 index 0000000..8b89684 --- /dev/null +++ b/Last3Mon/66/workflow-lab/flows/05-tech-support-ticket.json @@ -0,0 +1,30 @@ +{ + "name": "05 Tech support ticket", + "description": "업무 자동화 시나리오: 문의 접수 → 분류 → FAQ 검색 → 답변 초안 → 이력 저장", + "inputs": ["user", "ticket"], + "nodes": [ + {"id": "start", "type": "input"}, + {"id": "history", "type": "memory_read", "key": "ticket:{{input.user}}"}, + {"id": "classify", "type": "llm", + "system": "Classify the ticket into one of: account, network, device, software, other. Reply with the label only.", + "prompt": "문의: {{input.ticket}}", + "mock": {"rules": [ + {"if_contains": ["비밀번호", "계정", "로그인", "잠김"], "then": "account"}, + {"if_contains": ["vpn", "인터넷", "와이파이", "접속"], "then": "network"}, + {"if_contains": ["프린터", "모니터", "키보드"], "then": "device"}, + {"if_contains": ["설치", "프로그램", "메일"], "then": "software"} + ], "default": "other"}}, + {"id": "faq", "type": "tool", "tool": "search_faq", "args": {"query": "{{input.ticket}}", "top_k": 2}}, + {"id": "draft", "type": "llm", + "system": "Write a polite Korean reply using only the FAQ results. Mention the FAQ id.", + "prompt": "문의: {{input.ticket}}\n분류: {{nodes.classify.output}}\n이전 문의: {{nodes.history.output}}\nFAQ 검색 결과: {{nodes.faq.output}}", + "mock": {"template": "안녕하세요 {{input.user}}님. 문의하신 내용은 [{{nodes.faq.output.0.title}}]에 해당합니다. {{nodes.faq.output.0.answer}} (참고: {{nodes.faq.output.0.id}})"}}, + {"id": "log", "type": "memory_write", "key": "ticket:{{input.user}}", + "value": {"last_ticket": "{{input.ticket}}", "last_category": "{{nodes.classify.output}}"}}, + {"id": "result", "type": "output", + "value": {"category": "{{nodes.classify.output}}", "faq_id": "{{nodes.faq.output.0.id}}", "reply": "{{nodes.draft.output}}"}} + ], + "edges": [["start", "history"], ["start", "classify"], ["start", "faq"], + ["history", "draft"], ["classify", "draft"], ["faq", "draft"], + ["draft", "log"], ["log", "result"]] +} diff --git a/Last3Mon/66/workflow-lab/flows/06-request-with-condition.json b/Last3Mon/66/workflow-lab/flows/06-request-with-condition.json new file mode 100644 index 0000000..907c79f --- /dev/null +++ b/Last3Mon/66/workflow-lab/flows/06-request-with-condition.json @@ -0,0 +1,38 @@ +{ + "name": "06 Request flow with condition", + "description": "Trigger(입력) → 분류·FAQ 검색 → Condition(FAQ 있음?) → Action(자동 답변 또는 담당자 전달)", + "inputs": ["user", "text"], + "nodes": [ + {"id": "start", "type": "input"}, + {"id": "history", "type": "memory_read", "key": "request:{{input.user}}"}, + {"id": "classify", "type": "llm", + "system": "Classify the IT request into exactly one of: account, network, device, software, other. Reply with the label only.", + "prompt": "문의: {{input.text}}", + "mock": {"rules": [ + {"if_contains": ["비밀번호", "계정", "로그인", "잠김", "잠겨"], "then": "account"}, + {"if_contains": ["vpn", "인터넷", "와이파이", "접속"], "then": "network"}, + {"if_contains": ["프린터", "모니터", "키보드"], "then": "device"}, + {"if_contains": ["설치", "프로그램", "메일"], "then": "software"} + ], "default": "other"}}, + {"id": "faq", "type": "tool", "tool": "search_faq", "args": {"query": "{{input.text}}", "top_k": 1}}, + {"id": "log", "type": "memory_write", "key": "request:{{input.user}}", + "value": {"last_text": "{{input.text}}", "last_category": "{{nodes.classify.output}}"}}, + {"id": "has_faq", "type": "condition", "check": {"left": "{{nodes.faq.output}}", "op": "not_empty"}}, + {"id": "draft", "type": "llm", + "system": "Write a polite 2-sentence Korean reply using only the FAQ answer. End with the FAQ id in parentheses.", + "prompt": "문의: {{input.text}}\n이전 문의: {{nodes.history.output}}\nFAQ: {{nodes.faq.output}}", + "mock": {"template": "{{input.user}}님, {{nodes.faq.output.0.answer}} ({{nodes.faq.output.0.id}})"}}, + {"id": "auto_reply", "type": "output", + "value": {"category": "{{nodes.classify.output}}", "faq_id": "{{nodes.faq.output.0.id}}", "route": "자동 답변", "reply": "{{nodes.draft.output}}"}}, + {"id": "hand_off", "type": "output", + "value": {"category": "{{nodes.classify.output}}", "faq_id": "", "route": "담당자 확인", "reply": "관련 안내를 찾지 못해 담당자가 확인 후 답변드리겠습니다."}} + ], + "edges": [ + ["start", "history"], ["start", "classify"], ["start", "faq"], + ["classify", "log"], + ["faq", "has_faq"], ["classify", "has_faq"], + ["has_faq", "draft", "true"], ["history", "draft"], + ["draft", "auto_reply"], + ["has_faq", "hand_off", "false"] + ] +} diff --git a/Last3Mon/66/workflow-lab/flows/07-route-by-category.json b/Last3Mon/66/workflow-lab/flows/07-route-by-category.json new file mode 100644 index 0000000..76b5dba --- /dev/null +++ b/Last3Mon/66/workflow-lab/flows/07-route-by-category.json @@ -0,0 +1,29 @@ +{ + "name": "07 Route by category", + "description": "LLM 분류 결과로 Switch 분기 → 담당 팀 배정 (정해진 값이 아니면 default)", + "inputs": ["text"], + "nodes": [ + {"id": "start", "type": "input"}, + {"id": "classify", "type": "llm", + "system": "Classify the IT request into exactly one of: account, network, device, software, other. Reply with the label only.", + "prompt": "문의: {{input.text}}", + "mock": {"rules": [ + {"if_contains": ["비밀번호", "계정", "로그인", "잠김", "잠겨"], "then": "account"}, + {"if_contains": ["vpn", "인터넷", "와이파이", "접속"], "then": "network"}, + {"if_contains": ["프린터", "모니터", "키보드"], "then": "device"}, + {"if_contains": ["설치", "프로그램", "메일"], "then": "software"} + ], "default": "other"}}, + {"id": "route", "type": "switch", "value": "{{nodes.classify.output}}", "cases": ["account", "network", "device", "software"]}, + {"id": "team_account", "type": "output", "value": {"category": "account", "team": "계정 관리 담당"}}, + {"id": "team_network", "type": "output", "value": {"category": "network", "team": "네트워크 담당"}}, + {"id": "team_device", "type": "output", "value": {"category": "device", "team": "장비 지원 담당"}}, + {"id": "team_software", "type": "output", "value": {"category": "software", "team": "소프트웨어 담당"}}, + {"id": "team_default", "type": "output", "value": {"category": "{{nodes.classify.output}}", "team": "IT 문의 접수 창구"}} + ], + "edges": [ + ["start", "classify"], ["classify", "route"], + ["route", "team_account", "account"], ["route", "team_network", "network"], + ["route", "team_device", "device"], ["route", "team_software", "software"], + ["route", "team_default", "default"] + ] +} diff --git a/Last3Mon/66/workflow-lab/flows/08-meeting-actions.json b/Last3Mon/66/workflow-lab/flows/08-meeting-actions.json new file mode 100644 index 0000000..2517cff --- /dev/null +++ b/Last3Mon/66/workflow-lab/flows/08-meeting-actions.json @@ -0,0 +1,168 @@ +{ + "name": "08 Meeting notes to action items", + "description": "회의록 → (병렬) 할 일 추출·요약 → 할 일이 있으면 정리, 없으면 '할 일 없음'", + "inputs": [ + "title", + "notes" + ], + "nodes": [ + { + "id": "start", + "type": "input" + }, + { + "id": "has_notes", + "type": "condition", + "check": { + "left": "{{input.notes}}", + "op": "not_empty" + } + }, + { + "id": "extract", + "type": "llm", + "system": "Extract action items from the meeting notes. Answer in JSON: {\"actions\": [{\"task\": str, \"owner\": str, \"due\": str}]}. Write task in Korean. Use \"미정\" when the owner or due date is not stated. Return {\"actions\": []} when there is no action item.", + "prompt": "회의록:\n{{input.notes}}", + "temperature": 0, + "json_schema": { + "type": "object", + "properties": { + "actions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "task": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "due": { + "type": "string" + } + }, + "required": [ + "task", + "owner", + "due" + ] + } + } + }, + "required": [ + "actions" + ] + }, + "mock": { + "rules": [ + { + "if_contains": [ + "결정 사항은 없습니다" + ], + "then": "{\"actions\": []}" + } + ], + "default": "{\"actions\": [{\"task\": \"신규 고객 문의 응답 템플릿 정리\", \"owner\": \"이준호\", \"due\": \"금요일\"}, {\"task\": \"VPN 장애 보고서 작성\", \"owner\": \"박서연\", \"due\": \"다음 주 월요일\"}, {\"task\": \"서버 점검 일정 공지\", \"owner\": \"김민지\", \"due\": \"오늘\"}, {\"task\": \"회의실 예약 규칙 개선 담당자 지정\", \"owner\": \"\", \"due\": \"\"}]}" + } + }, + { + "id": "parse", + "type": "tool", + "tool": "parse_actions", + "args": { + "text": "{{nodes.extract.output}}" + } + }, + { + "id": "summarize", + "type": "llm", + "system": "You summarize meeting notes. Reply only with the summary in Korean, at most 2 sentences. No English, no comments.", + "prompt": "다음 회의록을 요약하세요.\n\n회의록:\n{{input.notes}}", + "mock": { + "template": "(Mock 요약) {{input.title}}의 주요 내용을 정리했습니다." + } + }, + { + "id": "any_actions", + "type": "condition", + "check": { + "left": "{{nodes.parse.output.count}}", + "op": "gt", + "right": 0 + } + }, + { + "id": "report", + "type": "output", + "value": { + "title": "{{input.title}}", + "route": "할 일 정리", + "count": "{{nodes.parse.output.count}}", + "summary": "{{nodes.summarize.output}}", + "actions": "{{nodes.parse.output.actions}}" + } + }, + { + "id": "no_actions", + "type": "output", + "value": { + "title": "{{input.title}}", + "route": "할 일 없음", + "count": 0, + "summary": "{{nodes.summarize.output}}", + "actions": [] + } + }, + { + "id": "reject", + "type": "output", + "value": { + "error": "notes is required" + } + } + ], + "edges": [ + [ + "start", + "has_notes" + ], + [ + "has_notes", + "extract", + "true" + ], + [ + "has_notes", + "summarize", + "true" + ], + [ + "has_notes", + "reject", + "false" + ], + [ + "extract", + "parse" + ], + [ + "parse", + "any_actions" + ], + [ + "summarize", + "any_actions" + ], + [ + "any_actions", + "report", + "true" + ], + [ + "any_actions", + "no_actions", + "false" + ] + ] +} \ No newline at end of file diff --git a/Last3Mon/66/workflow-lab/platforms.json b/Last3Mon/66/workflow-lab/platforms.json new file mode 100644 index 0000000..4eeb3b4 --- /dev/null +++ b/Last3Mon/66/workflow-lab/platforms.json @@ -0,0 +1,24 @@ +{ + "n8n": { + "base_url": "http://localhost:5678", + "timeout": 120 + }, + "workflows": { + "request": { + "n8n": { + "webhook": "ai-request" + }, + "miniflow": { + "flow": "flows/06-request-with-condition.json" + } + }, + "meeting": { + "n8n": { + "webhook": "meeting-actions" + }, + "miniflow": { + "flow": "flows/08-meeting-actions.json" + } + } + } +} \ No newline at end of file diff --git a/Last3Mon/66/workflow-lab/pyproject.toml b/Last3Mon/66/workflow-lab/pyproject.toml new file mode 100644 index 0000000..da7e3f6 --- /dev/null +++ b/Last3Mon/66/workflow-lab/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "miniflow" +version = "0.1.0" +description = "A tiny JSON-defined AI workflow runner for learning LLM, Tool and Memory nodes" +readme = "README.md" +requires-python = ">=3.11" +dependencies = ["openai>=1.40"] # course default LLM (key in .env) + +[project.optional-dependencies] +gemini = ["google-genai>=1.0"] +openai = ["openai>=1.40"] +anthropic = ["anthropic>=1.13"] + +[project.scripts] +miniflow = "miniflow.cli:main" + +[dependency-groups] +dev = ["pytest>=8.0"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/miniflow"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/Last3Mon/66/workflow-lab/src/miniflow/__init__.py b/Last3Mon/66/workflow-lab/src/miniflow/__init__.py new file mode 100644 index 0000000..b4e5140 --- /dev/null +++ b/Last3Mon/66/workflow-lab/src/miniflow/__init__.py @@ -0,0 +1,5 @@ +"""miniflow: run small AI workflows described as JSON nodes and edges.""" + +from miniflow.engine import Workflow, WorkflowError, load_workflow, run_workflow + +__all__ = ["Workflow", "WorkflowError", "load_workflow", "run_workflow"] diff --git a/Last3Mon/66/workflow-lab/src/miniflow/cli.py b/Last3Mon/66/workflow-lab/src/miniflow/cli.py new file mode 100644 index 0000000..a269ae6 --- /dev/null +++ b/Last3Mon/66/workflow-lab/src/miniflow/cli.py @@ -0,0 +1,158 @@ +"""Command line entry point: uv run miniflow ...""" + +import argparse +import json +import sys +from datetime import datetime +from pathlib import Path + +from miniflow import memory, tools +from miniflow.engine import WorkflowError, load_workflow, run_workflow + + +def parse_inputs(pairs: list[str]) -> dict[str, str]: + inputs = {} + for pair in pairs: + if "=" not in pair: + raise WorkflowError(f"Input must look like name=value, got '{pair}'") + name, value = pair.split("=", 1) + inputs[name.strip()] = value + return inputs + + +def cmd_run(args: argparse.Namespace) -> int: + workflow = load_workflow(args.flow) + print(f"Workflow: {workflow.name}") + if workflow.description: + print(f" {workflow.description}") + print("-" * 72) + report = run_workflow(workflow, parse_inputs(args.input)) + print("-" * 72) + print(f"Status: {report['status']}") + print("Result:") + result = report["result"] + print(result if isinstance(result, str) else json.dumps(result, ensure_ascii=False, indent=2)) + + runs = Path("runs") + runs.mkdir(exist_ok=True) + log_file = runs / f"{datetime.now():%Y%m%d-%H%M%S}-{Path(args.flow).stem}.json" + log_file.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"Trace saved: {log_file}") + return 0 if report["status"] == "completed" else 1 + + +def cmd_validate(args: argparse.Namespace) -> int: + workflow = load_workflow(args.flow) + print(f"OK: {workflow.name} ({len(workflow.nodes)} nodes, {len(workflow.edges)} edges)") + print("Execution order: " + " -> ".join(workflow.order())) + return 0 + + +def cmd_show(args: argparse.Namespace) -> int: + print(load_workflow(args.flow).to_mermaid()) + return 0 + + +def cmd_tools(_: argparse.Namespace) -> int: + for name, doc in tools.describe_tools(): + print(f"{name:<14} {doc}") + return 0 + + +def cmd_check(_: argparse.Namespace) -> int: + import os + import time + + from miniflow import llm + + provider = os.getenv("MINIFLOW_LLM", "mock").lower() + print(f"MINIFLOW_LLM : {provider}") + print(f".env : {llm.envfile.LOADED[0] if llm.envfile.LOADED else '(none)'}") + if provider == "openai": + print(f"OPENAI_MODEL : {os.getenv('OPENAI_MODEL', 'gpt-4.1-mini')}") + print(f"API key : {'set' if os.getenv('OPENAI_API_KEY') else 'MISSING (put OPENAI_API_KEY in .env)'}") + if provider == "ollama" or os.getenv("MINIFLOW_LLM_FALLBACK", "").lower() == "ollama": + print(f"OLLAMA_HOST : {os.getenv('OLLAMA_HOST', 'http://localhost:11434')}") + print(f"OLLAMA_MODEL : {os.getenv('OLLAMA_MODEL', 'gemma3:4b')}") + if os.getenv("MINIFLOW_LLM_FALLBACK"): + print(f"FALLBACK : {os.getenv('MINIFLOW_LLM_FALLBACK')}") + started = time.perf_counter() + try: + reply, used = llm.complete({"temperature": 0}, "Reply with exactly one word: ready", "", {}) + except llm.LLMError as error: + print(f"FAILED: {error}") + return 1 + print(f"Reply ({used}, {time.perf_counter() - started:.1f}s): {reply[:80]}") + return 0 + + +def cmd_call(args: argparse.Namespace) -> int: + import os + + from miniflow.platforms import FailoverRunner, PlatformError + + order = [name.strip() for name in (args.platforms or os.getenv("WORKFLOW_PLATFORMS", "n8n,miniflow")).split(",")] + print(f"Workflow: {args.workflow} platforms: {' -> '.join(order)}") + try: + report = FailoverRunner(order).run(args.workflow, parse_inputs(args.input)) + except PlatformError as error: + print(f"FAILED: {error}") + return 1 + for attempt in report["attempts"]: + detail = attempt.get("error", "") + print(f" {attempt['platform']:<10} {attempt['status']:<7} {attempt['ms']:>8}ms {detail}") + print(f"Served by: {report['served_by']}") + print(json.dumps(report["result"], ensure_ascii=False, indent=2)) + return 0 + + +def cmd_memory(args: argparse.Namespace) -> int: + if args.clear: + memory.clear() + print("Memory cleared") + return 0 + path = memory.memory_path() + print(path.read_text(encoding="utf-8") if path.exists() else "{}") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="miniflow", description="Run JSON-defined AI workflows") + sub = parser.add_subparsers(dest="command", required=True) + + run = sub.add_parser("run", help="run a workflow file") + run.add_argument("flow") + run.add_argument("--input", "-i", action="append", default=[], metavar="NAME=VALUE") + run.set_defaults(func=cmd_run) + + validate = sub.add_parser("validate", help="check a workflow file without running it") + validate.add_argument("flow") + validate.set_defaults(func=cmd_validate) + + show = sub.add_parser("show", help="print the workflow as a Mermaid diagram") + show.add_argument("flow") + show.set_defaults(func=cmd_show) + + sub.add_parser("tools", help="list available tools").set_defaults(func=cmd_tools) + sub.add_parser("check", help="check the configured LLM provider").set_defaults(func=cmd_check) + + call = sub.add_parser("call", help="run a business workflow on platforms with failover") + call.add_argument("workflow", help="workflow name in platforms.json, e.g. request") + call.add_argument("--input", "-i", action="append", default=[], metavar="NAME=VALUE") + call.add_argument("--platforms", help="comma-separated order, default: $WORKFLOW_PLATFORMS or n8n,miniflow") + call.set_defaults(func=cmd_call) + + mem = sub.add_parser("memory", help="show or clear the memory store") + mem.add_argument("--clear", action="store_true") + mem.set_defaults(func=cmd_memory) + + args = parser.parse_args(argv) + try: + return args.func(args) + except (WorkflowError, FileNotFoundError, json.JSONDecodeError) as error: + print(f"Error: {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Last3Mon/66/workflow-lab/src/miniflow/engine.py b/Last3Mon/66/workflow-lab/src/miniflow/engine.py new file mode 100644 index 0000000..3e3bcc0 --- /dev/null +++ b/Last3Mon/66/workflow-lab/src/miniflow/engine.py @@ -0,0 +1,255 @@ +"""Load, validate and run a workflow made of nodes and edges. + +Day 65 adds branching: `condition` (true/false) and `switch` (one case per value) +nodes, and edges that carry a branch label: ["check", "draft", "true"]. +""" + +import json +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from miniflow import llm, memory, tools +from miniflow.template import render + +NODE_TYPES = {"input", "llm", "tool", "memory_read", "memory_write", "output", "condition", "switch"} +BRANCH_TYPES = {"condition", "switch"} +REQUIRED_FIELDS = { + "llm": ["prompt"], + "tool": ["tool"], + "memory_read": ["key"], + "memory_write": ["key", "value"], + "output": ["value"], + "condition": ["check"], + "switch": ["value", "cases"], +} +CHECK_OPS = {"equals", "not_equals", "contains", "not_empty", "empty", "in", "gt", "lt"} + +Edge = tuple[str, str, str | None] + + +class WorkflowError(ValueError): + pass + + +@dataclass +class Workflow: + name: str + nodes: dict[str, dict[str, Any]] + edges: list[Edge] + description: str = "" + inputs: list[str] = field(default_factory=list) + + def order(self) -> list[str]: + """Topological order of node ids (Kahn's algorithm, file order breaks ties).""" + indegree = {node_id: 0 for node_id in self.nodes} + for _, target, _ in self.edges: + indegree[target] += 1 + ready = [node_id for node_id in self.nodes if indegree[node_id] == 0] + ordered = [] + while ready: + current = ready.pop(0) + ordered.append(current) + for source, target, _ in self.edges: + if source == current: + indegree[target] -= 1 + if indegree[target] == 0: + ready.append(target) + if len(ordered) != len(self.nodes): + raise WorkflowError("Edges contain a cycle. Workflows must flow in one direction.") + return ordered + + def to_mermaid(self) -> str: + shapes = { + "input": ('[/"', '"/]'), + "llm": ('{{"', '"}}'), + "tool": ('["', '"]'), + "memory_read": ('[("', '")]'), + "memory_write": ('[("', '")]'), + "output": ('[\\"', '"\\]'), + "condition": ('{"', '"}'), + "switch": ('{"', '"}'), + } + lines = ["flowchart LR"] + for node_id, node in self.nodes.items(): + left, right = shapes[node["type"]] + label = f"{node_id}
{node['type']}" + if node["type"] == "tool": + label += f": {node['tool']}" + lines.append(f" {node_id}{left}{label}{right}") + for source, target, branch in self.edges: + arrow = f" -->|{branch}| " if branch is not None else " --> " + lines.append(f" {source}{arrow}{target}") + return "\n".join(lines) + + +def _parse_edge(raw: list[Any]) -> Edge: + if not isinstance(raw, list) or len(raw) not in (2, 3): + raise WorkflowError(f"Edge must be [source, target] or [source, target, branch]: {raw}") + return (raw[0], raw[1], str(raw[2]) if len(raw) == 3 else None) + + +def load_workflow(path: str | Path) -> Workflow: + data = json.loads(Path(path).read_text(encoding="utf-8")) + nodes: dict[str, dict[str, Any]] = {} + for node in data.get("nodes", []): + node_id = node.get("id") + if not node_id: + raise WorkflowError("Every node needs an 'id'") + if node_id in nodes: + raise WorkflowError(f"Duplicate node id: {node_id}") + if node.get("type") not in NODE_TYPES: + raise WorkflowError(f"Node '{node_id}' has unknown type '{node.get('type')}'. Use one of {sorted(NODE_TYPES)}") + missing = [name for name in REQUIRED_FIELDS.get(node["type"], []) if name not in node] + if missing: + raise WorkflowError(f"Node '{node_id}' ({node['type']}) is missing: {', '.join(missing)}") + if node["type"] == "tool" and node["tool"] not in tools.TOOLS: + raise WorkflowError(f"Node '{node_id}' uses unknown tool '{node['tool']}'") + if node["type"] == "condition" and node["check"].get("op") not in CHECK_OPS: + raise WorkflowError(f"Node '{node_id}' uses unknown check op. Use one of {sorted(CHECK_OPS)}") + nodes[node_id] = node + if not nodes: + raise WorkflowError("Workflow has no nodes") + + edges = [_parse_edge(edge) for edge in data.get("edges", [])] + for source, target, branch in edges: + for node_id in (source, target): + if node_id not in nodes: + raise WorkflowError(f"Edge {source} -> {target} points to unknown node '{node_id}'") + kind = nodes[source]["type"] + if branch is not None and kind not in BRANCH_TYPES: + raise WorkflowError(f"Edge {source} -> {target} has branch '{branch}' but '{source}' is a {kind} node") + if kind == "condition" and branch not in ("true", "false"): + raise WorkflowError(f"Edge {source} -> {target} needs branch 'true' or 'false'") + if kind == "switch" and branch not in [*map(str, nodes[source]["cases"]), "default"]: + raise WorkflowError(f"Edge {source} -> {target} needs a branch from cases {nodes[source]['cases']} or 'default'") + workflow = Workflow( + name=data.get("name", Path(path).stem), + description=data.get("description", ""), + inputs=data.get("inputs", []), + nodes=nodes, + edges=edges, + ) + workflow.order() + return workflow + + +def evaluate_check(check: dict[str, Any], context: dict[str, Any]) -> bool: + left = render(check.get("left", ""), context) + right = render(check.get("right"), context) + op = check["op"] + if op == "equals": + return left == right + if op == "not_equals": + return left != right + if op == "contains": + return str(right) in str(left) + if op == "empty": + return left in (None, "", [], {}) + if op == "not_empty": + return left not in (None, "", [], {}) + if op == "in": + return left in (right or []) + if op == "gt": + return float(left) > float(right) + if op == "lt": + return float(left) < float(right) + raise WorkflowError(f"Unsupported check op: {op}") + + +def run_node(node: dict[str, Any], context: dict[str, Any]) -> tuple[Any, dict[str, Any]]: + """Execute one node. Returns (output, extra trace info).""" + kind = node["type"] + if kind == "input": + return context["input"], {} + if kind == "llm": + system = str(render(node.get("system", ""), context)) + prompt = str(render(node["prompt"], context)) + reply, provider = llm.complete(node, prompt, system, context) + return reply, {"provider": provider, "prompt": prompt} + if kind == "tool": + args = render(node.get("args", {}), context) + return tools.call_tool(node["tool"], args), {"args": args} + if kind == "memory_read": + key = str(render(node["key"], context)) + return memory.read(key), {"key": key} + if kind == "memory_write": + key = str(render(node["key"], context)) + return memory.write(key, render(node["value"], context)), {"key": key} + if kind == "output": + return render(node["value"], context), {} + if kind == "condition": + passed = evaluate_check(node["check"], context) + return {"result": passed, "branch": "true" if passed else "false"}, {} + if kind == "switch": + value = render(node["value"], context) + cases = [str(case) for case in node["cases"]] + branch = str(value) if str(value) in cases else "default" + return {"value": value, "branch": branch}, {} + raise WorkflowError(f"Unsupported node type: {kind}") + + +def _is_active(node_id: str, workflow: Workflow, status: dict[str, str], context: dict[str, Any]) -> bool: + """Decide whether a node runs. + + - A branch edge works as a gate: if its source was skipped or chose another + branch, the node is skipped. + - Otherwise the node runs if at least one incoming edge is taken, so a node + where two exclusive branches meet again (plain edges) still runs. + """ + incoming = [(source, branch) for source, target, branch in workflow.edges if target == node_id] + if not incoming: + return True + taken = False + for source, branch in incoming: + ran = status.get(source) == "ok" + if branch is not None: + if not ran or context["nodes"][source]["output"].get("branch") != branch: + return False + taken = True + elif ran: + taken = True + return taken + + +def run_workflow(workflow: Workflow, inputs: dict[str, Any], verbose: bool = True) -> dict[str, Any]: + missing = [name for name in workflow.inputs if name not in inputs] + if missing: + raise WorkflowError(f"Missing inputs: {', '.join(missing)} (pass with --input name=value)") + + context: dict[str, Any] = {"input": inputs, "nodes": {}} + trace: list[dict[str, Any]] = [] + status: dict[str, str] = {} + order = workflow.order() + result: Any = None + for index, node_id in enumerate(order, start=1): + node = workflow.nodes[node_id] + step = {"step": index, "id": node_id, "type": node["type"]} + if not _is_active(node_id, workflow, status, context): + status[node_id] = "skipped" + step["status"] = "skipped" + trace.append(step) + if verbose: + print(f"[{index}/{len(order)}] {node_id:<14} {node['type']:<12} skipped (branch not taken)") + continue + started = time.perf_counter() + try: + output, extra = run_node(node, context) + except Exception as error: + step.update(status="failed", error=f"{type(error).__name__}: {error}") + trace.append(step) + if verbose: + print(f"[{index}/{len(order)}] {node_id:<14} {node['type']:<12} FAILED {step['error']}") + return {"workflow": workflow.name, "status": "failed", "result": None, "trace": trace} + elapsed = round((time.perf_counter() - started) * 1000, 1) + context["nodes"][node_id] = {"output": output} + status[node_id] = "ok" + step.update(status="ok", ms=elapsed, output=output, **extra) + trace.append(step) + if node["type"] == "output": + result = output + if verbose: + preview = json.dumps(output, ensure_ascii=False) + print(f"[{index}/{len(order)}] {node_id:<14} {node['type']:<12} ok {elapsed:>6}ms {preview[:70]}") + return {"workflow": workflow.name, "status": "completed", "result": result, "trace": trace} diff --git a/Last3Mon/66/workflow-lab/src/miniflow/envfile.py b/Last3Mon/66/workflow-lab/src/miniflow/envfile.py new file mode 100644 index 0000000..ca0e044 --- /dev/null +++ b/Last3Mon/66/workflow-lab/src/miniflow/envfile.py @@ -0,0 +1,50 @@ +"""Load settings from the nearest .env file (KEY=VALUE lines, no extra package needed). + +Search order: the current folder and its parents, then the folders above this package +(so one Last3Mon/.env serves every day). Only the nearest file is read, and variables +already set in the shell win. Set MINIFLOW_DOTENV=0 to skip (the tests do this). +The .env file holds API keys: it is listed in .gitignore and must never be committed. +""" + +import os +from pathlib import Path + +LOADED: list[str] = [] # the .env file that was read, if any + + +def parse(text: str) -> dict[str, str]: + values = {} + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.removeprefix("export ").strip() + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + value = value[1:-1] + elif " #" in value: + value = value.split(" #", 1)[0].rstrip() + values[key] = value + return values + + +def candidates() -> list[Path]: + paths: list[Path] = [] + for start in (Path.cwd(), Path(__file__).resolve().parent): + for folder in (start, *start.parents): + if folder / ".env" not in paths: + paths.append(folder / ".env") + return paths + + +def load() -> Path | None: + if os.getenv("MINIFLOW_DOTENV", "1") == "0": + return None + for path in candidates(): + if path.is_file(): + for key, value in parse(path.read_text(encoding="utf-8")).items(): + os.environ.setdefault(key, value) + LOADED.append(str(path)) + return path + return None diff --git a/Last3Mon/66/workflow-lab/src/miniflow/llm.py b/Last3Mon/66/workflow-lab/src/miniflow/llm.py new file mode 100644 index 0000000..b390884 --- /dev/null +++ b/Last3Mon/66/workflow-lab/src/miniflow/llm.py @@ -0,0 +1,193 @@ +"""LLM providers: mock (default, no API key), openai (course default, key in .env), ollama (local), anthropic, gemini. + +Select with MINIFLOW_LLM (usually set in .env). MINIFLOW_LLM_FALLBACK=ollama retries a failed call locally. +""" + +import json +import os +import sys +import time +import urllib.error +import urllib.request +from typing import Any + +from miniflow import envfile +from miniflow.template import render + +envfile.load() # OPENAI_API_KEY and other settings from .env + + +class LLMError(RuntimeError): + pass + + +def mock_reply(prompt: str, mock: dict[str, Any] | None, context: dict[str, Any]) -> str: + """Deterministic reply so the workflow runs without any API key. + + mock.rules: first rule whose keywords appear in the prompt wins. + mock.template: rendered with the workflow context. + """ + mock = mock or {} + lowered = prompt.lower() + for rule in mock.get("rules", []): + if any(word.lower() in lowered for word in rule.get("if_contains", [])): + return str(render(rule["then"], context)) + if "template" in mock: + return str(render(mock["template"], context)) + if "default" in mock: + return str(render(mock["default"], context)) + first_line = prompt.strip().splitlines()[0] if prompt.strip() else "" + return f"[mock-llm] received {len(prompt)} chars. first line: {first_line[:60]}" + + +def call_ollama(system: str, prompt: str, temperature: float, schema: dict | None = None) -> str: + """Call a local Ollama server through its /api/chat endpoint (no extra package needed). + + When `schema` is given, Ollama is asked to answer with JSON that follows it. + """ + host = os.getenv("OLLAMA_HOST", "http://localhost:11434").rstrip("/") + if not host.startswith("http"): + host = f"http://{host}" + model = os.getenv("OLLAMA_MODEL", "gemma3:4b") + messages = ([{"role": "system", "content": system}] if system else []) + [{"role": "user", "content": prompt}] + payload = {"model": model, "messages": messages, "stream": False, "options": {"temperature": temperature}} + if schema: + payload["format"] = schema + body = json.dumps(payload).encode("utf-8") + request = urllib.request.Request(f"{host}/api/chat", data=body, headers={"Content-Type": "application/json"}) + timeout = float(os.getenv("OLLAMA_TIMEOUT", "180")) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + data = json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as error: + detail = error.read().decode("utf-8", errors="replace") + if error.code == 404: + raise LLMError(f"Model '{model}' not found on Ollama. Run: ollama pull {model}") from error + raise LLMError(f"Ollama returned HTTP {error.code}: {detail[:200]}") from error + except (urllib.error.URLError, TimeoutError, ConnectionError) as error: + raise LLMError(f"Cannot reach Ollama at {host}. Is 'ollama serve' running? ({error})") from error + return data.get("message", {}).get("content", "").strip() + + +def call_anthropic(system: str, prompt: str) -> str: + """Call Claude through the Anthropic SDK. + + Current Claude models reject sampling parameters, so temperature is not sent. + A server-side fallback model answers if the first model declines the request. + """ + try: + import anthropic + except ImportError as error: + raise LLMError("Install the extra first: uv sync --extra anthropic") from error + client = anthropic.Anthropic() + try: + response = client.beta.messages.create( + model=os.getenv("ANTHROPIC_MODEL", "claude-opus-5-5"), + max_tokens=16000, + system=system or anthropic.omit, + messages=[{"role": "user", "content": prompt}], + betas=["server-side-fallback-2026-07-01"], + fallbacks="default", + ) + except anthropic.AuthenticationError as error: + raise LLMError("Anthropic credentials are missing or invalid (set ANTHROPIC_API_KEY)") from error + except anthropic.APIConnectionError as error: + raise LLMError(f"Cannot reach the Anthropic API ({error})") from error + except anthropic.APIStatusError as error: + raise LLMError(f"Anthropic API returned HTTP {error.status_code}: {error.message}") from error + if response.stop_reason == "refusal": + raise LLMError("Claude declined this request (stop_reason=refusal)") + return "".join(block.text for block in response.content if block.type == "text").strip() + + +def call_gemini(system: str, prompt: str, temperature: float) -> str: + try: + from google import genai + from google.genai import types + except ImportError as error: + raise LLMError("Install the extra first: uv sync --extra gemini") from error + if not os.getenv("GEMINI_API_KEY"): + raise LLMError("GEMINI_API_KEY is not set") + client = genai.Client(api_key=os.environ["GEMINI_API_KEY"]) + response = client.models.generate_content( + model=os.getenv("GEMINI_MODEL", "gemini-2.5-flash"), + contents=prompt, + config=types.GenerateContentConfig(system_instruction=system or None, temperature=temperature), + ) + return response.text or "" + + +def call_openai(system: str, prompt: str, temperature: float, schema: dict | None = None, + max_tokens: int | None = None) -> str: + """Call the OpenAI Responses API (OPENAI_MODEL, default gpt-4.1-mini). + + When `schema` is given, the answer is JSON that follows it (structured output). + A model that does not accept temperature is called again without it. + """ + try: + import openai + except ImportError as error: + raise LLMError("The openai package is missing. Run: uv sync") from error + if not os.getenv("OPENAI_API_KEY"): + raise LLMError("OPENAI_API_KEY is not set (put it in the .env file)") + client = openai.OpenAI(timeout=float(os.getenv("OPENAI_TIMEOUT", "60")), + max_retries=int(os.getenv("OPENAI_MAX_RETRIES", "2"))) + model = os.getenv("OPENAI_MODEL", "gpt-4.1-mini") + request: dict[str, Any] = {"model": model, "input": prompt, "temperature": temperature} + if system: + request["instructions"] = system + if max_tokens: + request["max_output_tokens"] = max(16, int(max_tokens)) # the API minimum is 16 + if schema: + request["text"] = {"format": {"type": "json_schema", "name": "reply", "schema": schema, "strict": False}} + started = time.perf_counter() + try: + try: + response = client.responses.create(**request) + except openai.BadRequestError as error: + if "temperature" not in str(error): + raise + request.pop("temperature") + response = client.responses.create(**request) + except openai.AuthenticationError as error: + raise LLMError("OpenAI rejected the API key (check OPENAI_API_KEY in .env)") from error + except openai.APIConnectionError as error: + raise LLMError(f"Cannot reach the OpenAI API ({error})") from error + except openai.APIStatusError as error: + raise LLMError(f"OpenAI API returned HTTP {error.status_code}: {error.message}") from error + return (response.output_text or "").strip() + + +def complete(node: dict[str, Any], prompt: str, system: str, context: dict[str, Any]) -> tuple[str, str]: + """Return (reply, provider_name). + + With MINIFLOW_LLM_FALLBACK set (for example ollama), a call that fails on the main provider + is tried once more there, and the reply says which provider answered. + """ + primary = os.getenv("MINIFLOW_LLM", "mock").lower() + try: + return _complete_once(node, prompt, system, context, primary) + except LLMError as error: + fallback = os.getenv("MINIFLOW_LLM_FALLBACK", "").strip().lower() + if not fallback or fallback == primary or type(error).__name__ == "BudgetError": + raise + print(f"[miniflow] {primary} failed ({error}); trying {fallback}", file=sys.stderr) + try: + return _complete_once(node, prompt, system, context, fallback) + except LLMError as second: + raise LLMError(f"{primary} failed: {error} / fallback {fallback} failed: {second}") from second + + +def _complete_once(node: dict[str, Any], prompt: str, system: str, context: dict[str, Any], provider: str) -> tuple[str, str]: + temperature = float(node.get("temperature", 0.2)) + if provider == "mock": + return mock_reply(prompt, node.get("mock"), context), "mock" + if provider == "ollama": + return call_ollama(system, prompt, temperature, node.get("json_schema")), "ollama" + if provider == "anthropic": + return call_anthropic(system, prompt), "anthropic" + if provider == "gemini": + return call_gemini(system, prompt, temperature), "gemini" + if provider == "openai": + return call_openai(system, prompt, temperature, node.get("json_schema")), "openai" + raise LLMError(f"Unknown MINIFLOW_LLM value: {provider} (use mock, ollama, openai, anthropic or gemini)") diff --git a/Last3Mon/66/workflow-lab/src/miniflow/memory.py b/Last3Mon/66/workflow-lab/src/miniflow/memory.py new file mode 100644 index 0000000..6909c47 --- /dev/null +++ b/Last3Mon/66/workflow-lab/src/miniflow/memory.py @@ -0,0 +1,33 @@ +"""A JSON-file memory store shared across workflow runs.""" + +import json +import os +from pathlib import Path +from typing import Any + + +def memory_path() -> Path: + return Path(os.getenv("MINIFLOW_MEMORY_FILE", ".miniflow-memory.json")) + + +def _load() -> dict[str, Any]: + path = memory_path() + if not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def read(key: str) -> dict[str, Any]: + return _load().get(key, {}) + + +def write(key: str, value: dict[str, Any]) -> dict[str, Any]: + store = _load() + merged = {**store.get(key, {}), **value} + store[key] = merged + memory_path().write_text(json.dumps(store, ensure_ascii=False, indent=2), encoding="utf-8") + return merged + + +def clear() -> None: + memory_path().unlink(missing_ok=True) diff --git a/Last3Mon/66/workflow-lab/src/miniflow/platforms.py b/Last3Mon/66/workflow-lab/src/miniflow/platforms.py new file mode 100644 index 0000000..ba1a5e2 --- /dev/null +++ b/Last3Mon/66/workflow-lab/src/miniflow/platforms.py @@ -0,0 +1,117 @@ +"""Run the same business workflow on interchangeable platforms, with failover. + +Application code calls `FailoverRunner(["n8n", "miniflow"]).run("request", inputs)`. +Which platforms exist and how each one runs a workflow is decided by adapters +and `platforms.json`, so the calling code never changes when a platform is swapped. +""" + +import json +import os +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any, Protocol + +from miniflow.engine import WorkflowError, load_workflow, run_workflow + +PROJECT_DIR = Path(__file__).resolve().parents[2] +CONFIG_FILE = PROJECT_DIR / "platforms.json" + + +class PlatformError(RuntimeError): + """A platform could not run the workflow (down, timeout, bad answer).""" + + +class WorkflowPlatform(Protocol): + name: str + + def run(self, workflow: str, inputs: dict[str, Any]) -> dict[str, Any]: ... + + +def load_config(path: Path = CONFIG_FILE) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +class N8nPlatform: + """Calls an n8n workflow through its production webhook URL.""" + + name = "n8n" + + def __init__(self, config: dict[str, Any]): + self.workflows = config["workflows"] + self.base_url = os.getenv("N8N_BASE_URL", config.get("n8n", {}).get("base_url", "http://localhost:5678")) + self.timeout = float(os.getenv("N8N_TIMEOUT", config.get("n8n", {}).get("timeout", 120))) + + def run(self, workflow: str, inputs: dict[str, Any]) -> dict[str, Any]: + settings = self.workflows.get(workflow, {}).get("n8n") + if not settings: + raise PlatformError(f"n8n has no mapping for workflow '{workflow}'") + url = f"{self.base_url.rstrip('/')}/webhook/{settings['webhook']}" + request = urllib.request.Request(url, data=json.dumps(inputs).encode("utf-8"), + headers={"Content-Type": "application/json"}, method="POST") + try: + with urllib.request.urlopen(request, timeout=self.timeout) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as error: + raise PlatformError(f"n8n returned HTTP {error.code} for {url}") from error + except (urllib.error.URLError, TimeoutError, ConnectionError) as error: + raise PlatformError(f"n8n is not reachable at {url} ({error})") from error + except json.JSONDecodeError as error: + raise PlatformError("n8n answered with something that is not JSON") from error + + +class MiniflowPlatform: + """Runs the matching JSON flow with the local miniflow engine.""" + + name = "miniflow" + + def __init__(self, config: dict[str, Any]): + self.workflows = config["workflows"] + + def run(self, workflow: str, inputs: dict[str, Any]) -> dict[str, Any]: + settings = self.workflows.get(workflow, {}).get("miniflow") + if not settings: + raise PlatformError(f"miniflow has no mapping for workflow '{workflow}'") + try: + report = run_workflow(load_workflow(PROJECT_DIR / settings["flow"]), inputs, verbose=False) + except WorkflowError as error: + raise PlatformError(str(error)) from error + if report["status"] != "completed": + failed = report["trace"][-1] + raise PlatformError(f"miniflow failed at node '{failed['id']}': {failed.get('error')}") + return report["result"] + + +PLATFORMS: dict[str, type] = {"n8n": N8nPlatform, "miniflow": MiniflowPlatform} + + +def register_platform(name: str, adapter: type) -> None: + """Add another platform adapter without touching the calling code.""" + PLATFORMS[name] = adapter + + +class FailoverRunner: + """Try platforms in order; when one raises PlatformError, move on to the next.""" + + def __init__(self, order: list[str], config: dict[str, Any] | None = None): + config = config or load_config() + unknown = [name for name in order if name not in PLATFORMS] + if unknown: + raise PlatformError(f"Unknown platform(s): {', '.join(unknown)}. Registered: {', '.join(PLATFORMS)}") + self.platforms = [PLATFORMS[name](config) for name in order] + + def run(self, workflow: str, inputs: dict[str, Any]) -> dict[str, Any]: + attempts = [] + for platform in self.platforms: + started = time.perf_counter() + try: + result = platform.run(workflow, inputs) + except PlatformError as error: + attempts.append({"platform": platform.name, "status": "failed", "error": str(error), + "ms": round((time.perf_counter() - started) * 1000, 1)}) + continue + attempts.append({"platform": platform.name, "status": "ok", + "ms": round((time.perf_counter() - started) * 1000, 1)}) + return {"workflow": workflow, "served_by": platform.name, "result": result, "attempts": attempts} + raise PlatformError("All platforms failed: " + "; ".join(f"{a['platform']}: {a['error']}" for a in attempts)) diff --git a/Last3Mon/66/workflow-lab/src/miniflow/template.py b/Last3Mon/66/workflow-lab/src/miniflow/template.py new file mode 100644 index 0000000..7d3566b --- /dev/null +++ b/Last3Mon/66/workflow-lab/src/miniflow/template.py @@ -0,0 +1,48 @@ +"""Resolve {{path.to.value}} placeholders against the workflow context.""" + +import json +import re +from typing import Any + +PLACEHOLDER = re.compile(r"\{\{\s*([a-zA-Z0-9_.\-]+)\s*\}\}") + + +class TemplateError(KeyError): + pass + + +def lookup(context: dict[str, Any], path: str) -> Any: + value: Any = context + for part in path.split("."): + if isinstance(value, dict) and part in value: + value = value[part] + elif isinstance(value, list) and part.isdigit() and int(part) < len(value): + value = value[int(part)] + else: + raise TemplateError(f"Cannot resolve '{{{{{path}}}}}': '{part}' not found") + return value + + +def _to_text(value: Any) -> str: + if isinstance(value, str): + return value + return json.dumps(value, ensure_ascii=False) + + +def render(template: Any, context: dict[str, Any]) -> Any: + """Render strings, lists and dicts recursively. + + A string that is exactly one placeholder keeps the original type + (dict, list, number). Mixed text is rendered as a string. + """ + if isinstance(template, dict): + return {key: render(value, context) for key, value in template.items()} + if isinstance(template, list): + return [render(item, context) for item in template] + if not isinstance(template, str): + return template + + whole = PLACEHOLDER.fullmatch(template.strip()) + if whole: + return lookup(context, whole.group(1)) + return PLACEHOLDER.sub(lambda m: _to_text(lookup(context, m.group(1))), template) diff --git a/Last3Mon/66/workflow-lab/src/miniflow/tools.py b/Last3Mon/66/workflow-lab/src/miniflow/tools.py new file mode 100644 index 0000000..b915df2 --- /dev/null +++ b/Last3Mon/66/workflow-lab/src/miniflow/tools.py @@ -0,0 +1,85 @@ +"""Tools a workflow can call. Each tool reads local sample data, so no network is needed.""" + +import json +from collections.abc import Callable +from pathlib import Path +from typing import Any + +DATA_DIR = Path(__file__).resolve().parents[2] / "data" + + +class ToolError(RuntimeError): + pass + + +def _load(name: str) -> Any: + return json.loads((DATA_DIR / name).read_text(encoding="utf-8")) + + +def get_weather(city: str) -> dict[str, Any]: + """Return today's sample weather for a city.""" + table = _load("weather.json") + if city not in table: + raise ToolError(f"Unknown city '{city}'. Available: {', '.join(table)}") + return {"city": city, **table[city]} + + +def search_faq(query: str, top_k: int = 2) -> list[dict[str, Any]]: + """Return FAQ entries ranked by keyword overlap with the query.""" + words = {word for word in query.lower().split() if len(word) > 1} + scored = [] + for item in _load("faq.json"): + keywords = {keyword.lower() for keyword in item["keywords"]} + score = sum(1 for word in words if any(keyword in word or word in keyword for keyword in keywords)) + if score: + scored.append({"score": score, **item}) + scored.sort(key=lambda item: item["score"], reverse=True) + return scored[: int(top_k)] + + +def count_chars(text: str) -> dict[str, int]: + """Count characters and words of a text.""" + return {"chars": len(text), "words": len(text.split())} + + +def parse_actions(text: str) -> dict[str, Any]: + """Parse an LLM answer into a clean list of action items (task, owner, due).""" + cleaned = str(text).strip() + if cleaned.startswith("```"): + cleaned = cleaned.strip("`").split("\n", 1)[-1] + try: + data = json.loads(cleaned) + except json.JSONDecodeError: + return {"actions": [], "count": 0, "error": "answer is not valid JSON"} + items = data.get("actions", []) if isinstance(data, dict) else data + actions = [] + for item in items if isinstance(items, list) else []: + if not isinstance(item, dict) or not str(item.get("task", "")).strip(): + continue + actions.append({ + "task": str(item["task"]).strip(), + "owner": str(item.get("owner") or "미정").strip() or "미정", + "due": str(item.get("due") or "미정").strip() or "미정", + }) + return {"actions": actions, "count": len(actions)} + + +TOOLS: dict[str, Callable[..., Any]] = { + "get_weather": get_weather, + "search_faq": search_faq, + "count_chars": count_chars, + "parse_actions": parse_actions, +} + + +def call_tool(name: str, args: dict[str, Any]) -> Any: + if name not in TOOLS: + raise ToolError(f"Tool '{name}' is not registered. Available: {', '.join(TOOLS)}") + try: + return TOOLS[name](**args) + except TypeError as error: + raise ToolError(f"Wrong arguments for tool '{name}': {error}") from error + + +def describe_tools() -> list[tuple[str, str]]: + return [(name, (func.__doc__ or "").strip()) for name, func in TOOLS.items()] diff --git a/Last3Mon/66/workflow-lab/tests/conftest.py b/Last3Mon/66/workflow-lab/tests/conftest.py new file mode 100644 index 0000000..aa78190 --- /dev/null +++ b/Last3Mon/66/workflow-lab/tests/conftest.py @@ -0,0 +1,3 @@ +import os + +os.environ["MINIFLOW_DOTENV"] = "0" # tests never read the real .env (it may hold API keys) diff --git a/Last3Mon/66/workflow-lab/tests/test_anthropic_provider.py b/Last3Mon/66/workflow-lab/tests/test_anthropic_provider.py new file mode 100644 index 0000000..bfa7c99 --- /dev/null +++ b/Last3Mon/66/workflow-lab/tests/test_anthropic_provider.py @@ -0,0 +1,68 @@ +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest + +pytest.importorskip("anthropic") + +from miniflow import llm # noqa: E402 + + +class FakeAnthropic(BaseHTTPRequestHandler): + received: list[dict] = [] + headers_seen: list[dict] = [] + stop_reason = "end_turn" + + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + FakeAnthropic.received.append(body) + FakeAnthropic.headers_seen.append(dict(self.headers)) + reply = { + "id": "msg_test", "type": "message", "role": "assistant", "model": body["model"], + "content": [{"type": "text", "text": " network "}], + "stop_reason": FakeAnthropic.stop_reason, "stop_sequence": None, + "usage": {"input_tokens": 10, "output_tokens": 2}, + } + payload = json.dumps(reply).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, *args): + pass + + +@pytest.fixture() +def fake_server(monkeypatch): + server = HTTPServer(("127.0.0.1", 0), FakeAnthropic) + threading.Thread(target=server.serve_forever, daemon=True).start() + FakeAnthropic.received.clear() + FakeAnthropic.headers_seen.clear() + FakeAnthropic.stop_reason = "end_turn" + monkeypatch.setenv("MINIFLOW_LLM", "anthropic") + monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") + monkeypatch.setenv("ANTHROPIC_BASE_URL", f"http://127.0.0.1:{server.server_port}") + monkeypatch.delenv("ANTHROPIC_MODEL", raising=False) + yield server + server.shutdown() + + +def test_anthropic_request_and_reply(fake_server): + reply, provider = llm.complete({"temperature": 0}, "문의: VPN", "Reply with the label only.", {}) + assert (reply, provider) == ("network", "anthropic") + sent = FakeAnthropic.received[-1] + assert sent["model"] == "claude-opus-5-5" + assert sent["system"] == "Reply with the label only." + assert sent["messages"] == [{"role": "user", "content": "문의: VPN"}] + assert sent["fallbacks"] == "default" + assert "temperature" not in sent + assert "server-side-fallback-2026-07-01" in FakeAnthropic.headers_seen[-1].get("anthropic-beta", "") + + +def test_refusal_is_reported(fake_server): + FakeAnthropic.stop_reason = "refusal" + with pytest.raises(llm.LLMError, match="declined"): + llm.complete({}, "hi", "", {}) diff --git a/Last3Mon/66/workflow-lab/tests/test_branching.py b/Last3Mon/66/workflow-lab/tests/test_branching.py new file mode 100644 index 0000000..1916bd7 --- /dev/null +++ b/Last3Mon/66/workflow-lab/tests/test_branching.py @@ -0,0 +1,104 @@ +import json +from pathlib import Path + +import pytest + +from miniflow import WorkflowError, load_workflow, run_workflow +from miniflow.engine import evaluate_check + +FLOWS = Path(__file__).resolve().parents[1] / "flows" + + +@pytest.fixture(autouse=True) +def isolated(tmp_path, monkeypatch): + monkeypatch.setenv("MINIFLOW_MEMORY_FILE", str(tmp_path / "memory.json")) + monkeypatch.setenv("MINIFLOW_LLM", "mock") + + +def statuses(report): + return {step["id"]: step["status"] for step in report["trace"]} + + +def test_condition_true_branch_gives_auto_reply(): + report = run_workflow(load_workflow(FLOWS / "06-request-with-condition.json"), + {"user": "kim", "text": "VPN 접속이 계속 끊겨요"}, verbose=False) + assert report["status"] == "completed" + assert report["result"]["route"] == "자동 답변" + assert report["result"]["faq_id"] == "FAQ-003" + assert statuses(report)["hand_off"] == "skipped" + + +def test_condition_false_branch_hands_off(): + report = run_workflow(load_workflow(FLOWS / "06-request-with-condition.json"), + {"user": "lee", "text": "점심 메뉴 추천해줘"}, verbose=False) + assert report["status"] == "completed" + assert report["result"]["route"] == "담당자 확인" + assert statuses(report)["draft"] == "skipped" + + +@pytest.mark.parametrize("text,team", [ + ("비밀번호를 잊어버렸어요", "계정 관리 담당"), + ("프린터 출력이 안 돼요", "장비 지원 담당"), + ("회의실 예약은 어떻게 하나요", "IT 문의 접수 창구"), +]) +def test_switch_routes(text, team): + report = run_workflow(load_workflow(FLOWS / "07-route-by-category.json"), {"text": text}, verbose=False) + assert report["result"]["team"] == team + + +def test_join_after_exclusive_branches_runs(tmp_path): + flow = { + "inputs": ["n"], + "nodes": [ + {"id": "start", "type": "input"}, + {"id": "big", "type": "condition", "check": {"left": "{{input.n}}", "op": "gt", "right": 10}}, + {"id": "yes", "type": "output", "value": "big"}, + {"id": "no", "type": "output", "value": "small"}, + {"id": "done", "type": "output", "value": "done"}, + ], + "edges": [["start", "big"], ["big", "yes", "true"], ["big", "no", "false"], ["yes", "done"], ["no", "done"]], + } + path = tmp_path / "join.json" + path.write_text(json.dumps(flow), encoding="utf-8") + report = run_workflow(load_workflow(path), {"n": "3"}, verbose=False) + assert statuses(report) == {"start": "ok", "big": "ok", "yes": "skipped", "no": "ok", "done": "ok"} + + +def test_branch_label_validation(tmp_path): + flow = { + "nodes": [{"id": "a", "type": "input"}, {"id": "b", "type": "output", "value": 1}], + "edges": [["a", "b", "true"]], + } + path = tmp_path / "bad.json" + path.write_text(json.dumps(flow), encoding="utf-8") + with pytest.raises(WorkflowError): + load_workflow(path) + + +@pytest.mark.parametrize("check,expected", [ + ({"left": "abc", "op": "contains", "right": "b"}, True), + ({"left": "", "op": "empty"}, True), + ({"left": "network", "op": "in", "right": ["network", "device"]}, True), + ({"left": "5", "op": "lt", "right": 3}, False), +]) +def test_evaluate_check(check, expected): + assert evaluate_check(check, {"input": {}, "nodes": {}}) is expected + + +def test_branch_gate_blocks_even_with_other_plain_input(tmp_path): + """A node behind a skipped condition stays skipped even if another plain edge reaches it.""" + flow = json.loads((FLOWS / "06-request-with-condition.json").read_text(encoding="utf-8")) + flow["nodes"].insert(1, {"id": "is_urgent", "type": "condition", + "check": {"left": "{{input.text}}", "op": "contains", "right": "긴급"}}) + flow["nodes"].append({"id": "urgent_call", "type": "output", "value": {"route": "긴급 담당자 연결"}}) + flow["edges"] += [["start", "is_urgent"], ["is_urgent", "urgent_call", "true"], ["is_urgent", "has_faq", "false"]] + path = tmp_path / "urgent.json" + path.write_text(json.dumps(flow, ensure_ascii=False), encoding="utf-8") + + urgent = run_workflow(load_workflow(path), {"user": "kim", "text": "긴급! VPN이 끊겼어요"}, verbose=False) + assert urgent["result"]["route"] == "긴급 담당자 연결" + assert statuses(urgent)["draft"] == "skipped" + + normal = run_workflow(load_workflow(path), {"user": "kim", "text": "VPN이 끊겼어요"}, verbose=False) + assert normal["result"]["route"] == "자동 답변" + assert statuses(normal)["urgent_call"] == "skipped" diff --git a/Last3Mon/66/workflow-lab/tests/test_meeting_actions.py b/Last3Mon/66/workflow-lab/tests/test_meeting_actions.py new file mode 100644 index 0000000..0ca72fc --- /dev/null +++ b/Last3Mon/66/workflow-lab/tests/test_meeting_actions.py @@ -0,0 +1,52 @@ +import json +from pathlib import Path + +import pytest + +from miniflow import load_workflow, run_workflow +from miniflow.tools import parse_actions + +ROOT = Path(__file__).resolve().parents[1] +NOTES = json.loads((ROOT / "data" / "meeting-notes.json").read_text(encoding="utf-8")) + + +@pytest.fixture(autouse=True) +def isolated(tmp_path, monkeypatch): + monkeypatch.setenv("MINIFLOW_MEMORY_FILE", str(tmp_path / "memory.json")) + monkeypatch.setenv("MINIFLOW_LLM", "mock") + + +def run(inputs): + return run_workflow(load_workflow(ROOT / "flows" / "08-meeting-actions.json"), inputs, verbose=False) + + +def test_weekly_meeting_gives_four_actions_with_defaults(): + report = run(NOTES["weekly"]) + assert report["result"]["route"] == "할 일 정리" + assert report["result"]["count"] == 4 + assert report["result"]["actions"][3] == {"task": "회의실 예약 규칙 개선 담당자 지정", "owner": "미정", "due": "미정"} + + +def test_meeting_without_actions(): + report = run(NOTES["no_action"]) + assert report["result"]["route"] == "할 일 없음" + statuses = {step["id"]: step["status"] for step in report["trace"]} + assert statuses["report"] == "skipped" + + +def test_empty_notes_rejected_before_llm(): + report = run({"title": "빈 회의록", "notes": ""}) + assert report["result"] == {"error": "notes is required"} + statuses = {step["id"]: step["status"] for step in report["trace"]} + assert statuses["extract"] == statuses["summarize"] == "skipped" + + +@pytest.mark.parametrize("text,count", [ + ('{"actions": [{"task": "보고서 작성", "owner": "김", "due": "금"}]}', 1), + ('```json\n{"actions": [{"task": "A", "owner": "", "due": null}]}\n```', 1), + ('[{"task": "목록 형태", "owner": "이"}]', 1), + ('{"actions": [{"task": ""}, "문자열"]}', 0), + ("할 일이 없습니다", 0), +]) +def test_parse_actions(text, count): + assert parse_actions(text)["count"] == count diff --git a/Last3Mon/66/workflow-lab/tests/test_ollama_provider.py b/Last3Mon/66/workflow-lab/tests/test_ollama_provider.py new file mode 100644 index 0000000..683e605 --- /dev/null +++ b/Last3Mon/66/workflow-lab/tests/test_ollama_provider.py @@ -0,0 +1,66 @@ +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest + +from miniflow import llm + + +class FakeOllama(BaseHTTPRequestHandler): + received: list[dict] = [] + + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + FakeOllama.received.append(body) + if body["model"] == "missing-model": + self.send_response(404) + self.end_headers() + self.wfile.write(b'{"error":"model not found"}') + return + reply = {"message": {"role": "assistant", "content": " network \n"}, "done": True} + payload = json.dumps(reply).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, *args): + pass + + +@pytest.fixture() +def fake_server(monkeypatch): + server = HTTPServer(("127.0.0.1", 0), FakeOllama) + threading.Thread(target=server.serve_forever, daemon=True).start() + FakeOllama.received.clear() + monkeypatch.setenv("MINIFLOW_LLM", "ollama") + monkeypatch.setenv("OLLAMA_HOST", f"127.0.0.1:{server.server_port}") + monkeypatch.setenv("OLLAMA_MODEL", "gemma3:1b") + yield server + server.shutdown() + + +def test_ollama_request_and_reply(fake_server): + reply, provider = llm.complete({"temperature": 0}, "문의: VPN", "Reply with the label only.", {}) + assert (reply, provider) == ("network", "ollama") + sent = FakeOllama.received[-1] + assert sent["model"] == "gemma3:1b" + assert sent["stream"] is False + assert sent["messages"][0] == {"role": "system", "content": "Reply with the label only."} + assert sent["messages"][1]["content"] == "문의: VPN" + + +def test_missing_model_gives_pull_hint(fake_server, monkeypatch): + monkeypatch.setenv("OLLAMA_MODEL", "missing-model") + with pytest.raises(llm.LLMError, match="ollama pull missing-model"): + llm.complete({}, "hi", "", {}) + + +def test_unreachable_server(monkeypatch): + monkeypatch.setenv("MINIFLOW_LLM", "ollama") + monkeypatch.setenv("OLLAMA_HOST", "http://127.0.0.1:9") + monkeypatch.setenv("OLLAMA_TIMEOUT", "2") + with pytest.raises(llm.LLMError, match="Cannot reach Ollama"): + llm.complete({}, "hi", "", {}) diff --git a/Last3Mon/66/workflow-lab/tests/test_openai_provider.py b/Last3Mon/66/workflow-lab/tests/test_openai_provider.py new file mode 100644 index 0000000..1c33aea --- /dev/null +++ b/Last3Mon/66/workflow-lab/tests/test_openai_provider.py @@ -0,0 +1,80 @@ +"""OpenAI provider, .env loading and the Ollama fallback, without network access.""" + +import types + +import pytest + +from miniflow import envfile, llm + + +def test_parse_env_lines(): + values = envfile.parse('# comment\nexport A=1\nB="two words"\nC=3 # note\n\nbroken line\n') + assert values == {"A": "1", "B": "two words", "C": "3"} + + +def test_load_keeps_shell_values(tmp_path, monkeypatch): + (tmp_path / ".env").write_text("MINIFLOW_TEST_A=from-file\nMINIFLOW_TEST_B=from-file\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("MINIFLOW_DOTENV", "1") + monkeypatch.setenv("MINIFLOW_TEST_A", "from-shell") + monkeypatch.delenv("MINIFLOW_TEST_B", raising=False) + assert envfile.load() == tmp_path / ".env" + assert llm.os.environ["MINIFLOW_TEST_A"] == "from-shell" + assert llm.os.environ["MINIFLOW_TEST_B"] == "from-file" + monkeypatch.delenv("MINIFLOW_TEST_B") + + +def test_openai_without_key(monkeypatch): + monkeypatch.setenv("MINIFLOW_LLM", "openai") + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("MINIFLOW_LLM_FALLBACK", raising=False) + with pytest.raises(llm.LLMError, match="OPENAI_API_KEY"): + llm.complete({}, "hello", "", {}) + + +def test_openai_request_shape(monkeypatch): + sent = {} + + class FakeResponses: + def create(self, **kwargs): + sent.update(kwargs) + return types.SimpleNamespace(output_text='{"ok": true}', usage=None) + + class FakeClient: + def __init__(self, **_): + self.responses = FakeResponses() + + import openai + + monkeypatch.setattr(openai, "OpenAI", FakeClient) + monkeypatch.setenv("MINIFLOW_LLM", "openai") + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + monkeypatch.setenv("OPENAI_MODEL", "test-model") + schema = {"type": "object", "properties": {"ok": {"type": "boolean"}}} + reply, used = llm.complete({"temperature": 0, "json_schema": schema}, "hello", "be brief", {}) + assert (reply, used) == ('{"ok": true}', "openai") + assert sent["model"] == "test-model" and sent["instructions"] == "be brief" and sent["temperature"] == 0 + assert sent["text"]["format"]["schema"] == schema + + +def test_fallback_to_second_provider(monkeypatch, capsys): + monkeypatch.setenv("MINIFLOW_LLM", "openai") + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.setenv("MINIFLOW_LLM_FALLBACK", "ollama") + monkeypatch.setattr(llm, "call_ollama", lambda *args, **kwargs: "from ollama") + reply, used = llm.complete({}, "hello", "", {}) + assert (reply, used) == ("from ollama", "ollama") + assert "openai failed" in capsys.readouterr().err + + +def test_both_providers_fail(monkeypatch): + monkeypatch.setenv("MINIFLOW_LLM", "openai") + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.setenv("MINIFLOW_LLM_FALLBACK", "ollama") + + def broken(*args, **kwargs): + raise llm.LLMError("Cannot reach Ollama") + + monkeypatch.setattr(llm, "call_ollama", broken) + with pytest.raises(llm.LLMError, match="OPENAI_API_KEY.*fallback ollama failed"): + llm.complete({}, "hello", "", {}) diff --git a/Last3Mon/66/workflow-lab/tests/test_platforms.py b/Last3Mon/66/workflow-lab/tests/test_platforms.py new file mode 100644 index 0000000..e1f1c5c --- /dev/null +++ b/Last3Mon/66/workflow-lab/tests/test_platforms.py @@ -0,0 +1,79 @@ +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest + +from miniflow.platforms import FailoverRunner, PlatformError, load_config + + +class FakeN8n(BaseHTTPRequestHandler): + mode = "ok" + received: list = [] + + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + FakeN8n.received.append((self.path, body)) + if FakeN8n.mode == "error": + self.send_response(500) + self.end_headers() + return + payload = json.dumps({"category": "network", "faq_id": "FAQ-003", "route": "자동 답변", "reply": "n8n reply"}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, *args): + pass + + +@pytest.fixture() +def n8n(monkeypatch, tmp_path): + server = HTTPServer(("127.0.0.1", 0), FakeN8n) + threading.Thread(target=server.serve_forever, daemon=True).start() + FakeN8n.mode, FakeN8n.received = "ok", [] + monkeypatch.setenv("N8N_BASE_URL", f"http://127.0.0.1:{server.server_port}") + monkeypatch.setenv("MINIFLOW_LLM", "mock") + monkeypatch.setenv("MINIFLOW_MEMORY_FILE", str(tmp_path / "memory.json")) + yield server + server.shutdown() + + +INPUTS = {"user": "kim", "text": "VPN 접속이 계속 끊겨요"} + + +def test_n8n_serves_when_healthy(n8n): + report = FailoverRunner(["n8n", "miniflow"]).run("request", INPUTS) + assert report["served_by"] == "n8n" + assert FakeN8n.received[-1] == ("/webhook/ai-request", INPUTS) + + +def test_failover_to_miniflow_when_n8n_errors(n8n): + FakeN8n.mode = "error" + report = FailoverRunner(["n8n", "miniflow"]).run("request", INPUTS) + assert report["served_by"] == "miniflow" + assert [a["status"] for a in report["attempts"]] == ["failed", "ok"] + assert report["result"]["route"] == "자동 답변" + + +def test_failover_when_n8n_is_down(monkeypatch, tmp_path): + monkeypatch.setenv("N8N_BASE_URL", "http://127.0.0.1:9") + monkeypatch.setenv("N8N_TIMEOUT", "2") + monkeypatch.setenv("MINIFLOW_LLM", "mock") + monkeypatch.setenv("MINIFLOW_MEMORY_FILE", str(tmp_path / "memory.json")) + report = FailoverRunner(["n8n", "miniflow"]).run("request", {"user": "lee", "text": "점심 메뉴 추천해줘"}) + assert report["served_by"] == "miniflow" + assert report["result"]["route"] == "담당자 확인" + + +def test_all_platforms_failing_raises(n8n): + FakeN8n.mode = "error" + with pytest.raises(PlatformError, match="All platforms failed"): + FailoverRunner(["n8n"]).run("request", INPUTS) + + +def test_unknown_platform_is_rejected(): + with pytest.raises(PlatformError, match="Unknown platform"): + FailoverRunner(["nowhere"], load_config()) diff --git a/Last3Mon/66/workflow-lab/tests/test_workflows.py b/Last3Mon/66/workflow-lab/tests/test_workflows.py new file mode 100644 index 0000000..e3853e2 --- /dev/null +++ b/Last3Mon/66/workflow-lab/tests/test_workflows.py @@ -0,0 +1,80 @@ +import json +from pathlib import Path + +import pytest + +from miniflow import WorkflowError, load_workflow, run_workflow +from miniflow.template import TemplateError, render + +FLOWS = Path(__file__).resolve().parents[1] / "flows" + + +@pytest.fixture(autouse=True) +def isolated_memory(tmp_path, monkeypatch): + monkeypatch.setenv("MINIFLOW_MEMORY_FILE", str(tmp_path / "memory.json")) + monkeypatch.setenv("MINIFLOW_LLM", "mock") + + +def run(name, **inputs): + return run_workflow(load_workflow(FLOWS / name), inputs, verbose=False) + + +def test_every_sample_flow_is_valid(): + for path in FLOWS.glob("*.json"): + assert load_workflow(path).order() + + +def test_template_keeps_type_for_whole_placeholder(): + context = {"nodes": {"tool": {"output": {"temp": 20}}}} + assert render("{{nodes.tool.output}}", context) == {"temp": 20} + assert render("temp={{nodes.tool.output.temp}}", context) == "temp=20" + with pytest.raises(TemplateError): + render("{{nodes.missing.output}}", context) + + +def test_llm_only(): + report = run("01-llm-only.json", text="오늘 회의에서 일정이 확정되었습니다.") + assert report["status"] == "completed" + assert "Mock" in report["result"] + + +def test_tool_data_flows_into_llm(): + report = run("02-llm-tool.json", city="제주", question="우산이 필요할까?") + assert "제주" in report["result"] and "80%" in report["result"] + + +def test_unknown_city_fails_at_tool_node(): + report = run("02-llm-tool.json", city="평양", question="날씨?") + assert report["status"] == "failed" + assert report["trace"][-1]["id"] == "weather" + + +def test_memory_survives_between_runs(): + run("03-save-profile.json", user="minji", preference="실내 전시") + report = run("04-recommend-with-memory.json", user="minji", city="부산") + assert "실내 전시" in report["result"] + again = run("04-recommend-with-memory.json", user="minji", city="강릉") + recall = next(step for step in again["trace"] if step["id"] == "recall") + assert recall["output"]["last_city"] == "부산" + + +def test_tech_support_ticket(): + report = run("05-tech-support-ticket.json", user="kim", ticket="비밀번호를 잊어버려서 로그인이 안 돼요") + assert report["result"]["category"] == "account" + assert report["result"]["faq_id"] == "FAQ-001" + + +def test_cycle_is_rejected(tmp_path): + flow = { + "nodes": [{"id": "a", "type": "input"}, {"id": "b", "type": "output", "value": "x"}], + "edges": [["a", "b"], ["b", "a"]], + } + path = tmp_path / "cycle.json" + path.write_text(json.dumps(flow), encoding="utf-8") + with pytest.raises(WorkflowError): + load_workflow(path) + + +def test_missing_input_is_reported(): + with pytest.raises(WorkflowError): + run("02-llm-tool.json", city="서울") diff --git a/Last3Mon/66/workflow-lab/uv.lock b/Last3Mon/66/workflow-lab/uv.lock new file mode 100644 index 0000000..73f1e10 --- /dev/null +++ b/Last3Mon/66/workflow-lab/uv.lock @@ -0,0 +1,1058 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "(python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version < '3.12' and sys_platform == 'emscripten')", +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anthropic" +version = "1.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "docstring-parser" }, + { name = "httpx2" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/ed/e76e50601b7c476bcb9a2a7a4c4dab6f1a9b2c03e3922de8b3ec599ccbc5/anthropic-1.13.0.tar.gz", hash = "sha256:ad11d9bb9adafdfea26113943bcde9973e2a439ebeeda6c89ff3e3d85bb2f5c1", size = 1567362, upload-time = "2026-10-09T15:29:08.803Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/9a/a835340c91bc9a233bd54c05fc438fe07cbef71ca667f24f3892a268ae20/anthropic-1.13.0-py3-none-any.whl", hash = "sha256:157bd74dbf6a595cf9e5fcac557c791fd297924dc48261f57470c2ed6f4e6671", size = 1958595, upload-time = "2026-10-09T15:29:10.689Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[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 = "cryptography" +version = "50.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" }, + { url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" }, + { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7a/f08d34ce09d60f89ebd391e2ebc6ba2b995e6dd7552f41820f8085f94e53/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:92e665960f25fcdc73725b9cec7a3824f279ba97a98653afe9ffac2e43668f67", size = 4716035, upload-time = "2026-09-30T15:29:48.681Z" }, + { url = "https://files.pythonhosted.org/packages/45/67/e18fb65592451a2acb76e9f2fbe14e0f47a8318b4c5430f1633851d03daa/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:eef4c2f3423810b3070ab391f85436d2f8bbfcb286ac15cbc73190b3563b1f1a", size = 4726917, upload-time = "2026-09-30T15:29:50.608Z" }, + { url = "https://files.pythonhosted.org/packages/83/28/38fdce17e60f6b825e69fc3b7f75e70a6612759980704697e1de4cbfaf6e/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7c6d0330c472d96f6a6afe24d80dfdf15176c33096f0a4397ae4c60f3dd3be48", size = 4715341, upload-time = "2026-09-30T15:29:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b1/d9121a717e0f893c64bd6ca7702614778d7df2a5c309128a002421788516/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:1ba34f04897fcdaa73f74145c25f3ec146fbd56593853e88adc2e811303c5f42", size = 4726322, upload-time = "2026-09-30T15:29:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/36/8b/e6d153808bf353e152abd2fd4d8f09670d956ac78379ac46e60d7efbf04c/cryptography-50.0.2-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:3dc4fd8058cea1644971207d530e1a03a184a805ffc8ebdddf0599d78a331b81", size = 3873097, upload-time = "2026-09-30T15:29:56.097Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1d/1271f287ff7170ddafc2aad36260c4eec20ccd2fea70f38455e9d56d427b/cryptography-50.0.2-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:7b75de3c8b3be1cdb1052747c929440c3eea46c1bc2cb8a6e3a48388e9b7b452", size = 3805376, upload-time = "2026-09-30T15:29:58.729Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + +[[package]] +name = "google-auth" +version = "2.59.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/e3/9f752a968e487fbc10b2c2a1dd561b62489f923972911009577bb88fe7bb/google_auth-2.59.1.tar.gz", hash = "sha256:ce50fc533ac02f489a2b183a0c156672c376ecb2091b1127bc7efba2975fff27", size = 384298, upload-time = "2026-09-30T17:26:25.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/38/311ac322f46ecd789f1342bb7adb0a14b940cf54b5db0e92d4f449aa6cf8/google_auth-2.59.1-py3-none-any.whl", hash = "sha256:89c3f931683a482ac97e61df7eb9da5e08a91703f3c752b2377d72cfb7d69e6a", size = 263519, upload-time = "2026-09-30T17:26:24.017Z" }, +] + +[package.optional-dependencies] +requests = [ + { name = "requests" }, +] + +[[package]] +name = "google-genai" +version = "2.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "google-auth", extra = ["requests"] }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "sniffio" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/16/4af15d65cb72ebc21a99c5e64f09072e97334c05a48d4cede19c347e6ad2/google_genai-2.28.0.tar.gz", hash = "sha256:970cb2eaf1951949851c3e0a5b70265cfea8a408fb7361c7c980a84d90672bf2", size = 707359, upload-time = "2026-10-02T17:58:14.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/4b/47278c34757293ba8d9156090d38e1c45fac9a47c50176acff7f77ae9a66/google_genai-2.28.0-py3-none-any.whl", hash = "sha256:cdee15e2fbfea08ea61bce6e5bfbb3301db4bd13a52dccfa8c8d1167e37ca1cc", size = 1168554, upload-time = "2026-10-02T17:58:12.585Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11", marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, + { name = "truststore", marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + +[[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.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jiter" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/1f/8176d92e001f86505424b41664032ae26a882bc9ca41a32c803f373f9195/jiter-0.17.0.tar.gz", hash = "sha256:03e432f226a453851079fb84cd17c6da9991eab723e28d716f14ae3d906e0c12", size = 229037, upload-time = "2026-09-12T15:14:14.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/50/17afdaffcc8af4bf4fddf2b6c26d066553aa2221983f2affcde435fc2532/jiter-0.17.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:cfafd7be8b16ceadd298db542cead37cddc211c4c49e04ad2596924df18625b1", size = 290699, upload-time = "2026-09-12T15:11:30.085Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/c185d32d5b3657ad84da26c84a9eb15f00aa1b39d6882fcc0052dba2d7c2/jiter-0.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8adca2e793288e5f1bb29279bb439d0d3cfbb50eddca7e7e6ffd42ff4f482406", size = 324246, upload-time = "2026-09-12T15:11:31.3Z" }, + { url = "https://files.pythonhosted.org/packages/24/7a/8b8903bfe91a90a8fa1ec9b45d9fda5b6287a386693b69d720a882d73f3c/jiter-0.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30c692d567ba206c7cca38c9d1d0ccc70c9786290173c184d871ca12e9981ed7", size = 343275, upload-time = "2026-09-12T15:11:32.758Z" }, + { url = "https://files.pythonhosted.org/packages/a2/5d/6821fae2abc71a3c3a84bef8598d31fc4f27d9edfb55bd8f6c08afb8ef93/jiter-0.17.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81c83c0abe614446a283d994d2c07c4f58632dea2cdf66ba9e2921bb8ccd593e", size = 328034, upload-time = "2026-09-12T15:11:33.9Z" }, + { url = "https://files.pythonhosted.org/packages/f3/51/8e7a963b1c2dfdc01d6228b004f50a2a3d7c46f0549d7b096a5d15ef81d5/jiter-0.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:073dc68c1a700c8fc480e877864a6b6ffc887533e261f4380c08c16bf09d057a", size = 342457, upload-time = "2026-09-12T15:11:36.414Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/8b2a267e3bda45d9298331cacfe3521e761f0a2b05ad10a23c6548d08358/jiter-0.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:492f37230bbf9581ab2c17bcda862c249afb9ae2e3ab2dd6db59943bc4cc3153", size = 351271, upload-time = "2026-09-12T15:11:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/8f/5c74e5e142a6736833d7a991ab04d5c0738038dc44db05af0bb3cd2559e8/jiter-0.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5888fe5abc1ca2fa834a3e1b4c7ef0dcece286a7d7e95a609ef0934b777b9fc9", size = 346052, upload-time = "2026-09-12T15:11:39.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/9c/f54920f06d1696e80b1be841d56412871c6856b1b1e3b541b1ed35346554/jiter-0.17.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:84ac78df457e1ee3f7e733bd114823302ae8c5ad5542d7e6647d92ffaa090a04", size = 328915, upload-time = "2026-09-12T15:11:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/21/53/080f126863bceb055db9f1fd5431485eb493b35545a3c35e961ac18cd924/jiter-0.17.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7573e80232c5bcf80c24c038cf7e53a463f5c3b1dd1dd4109d66304f4dccc233", size = 337240, upload-time = "2026-09-12T15:11:42.356Z" }, + { url = "https://files.pythonhosted.org/packages/f0/76/3ab742823a0e0e70e143c6c90a482d9d90396ac2445e7b9483eb7245d3b5/jiter-0.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:11902505d401691720f5785c15b02204248526edee11b635cd6c40cd52b81599", size = 484917, upload-time = "2026-09-12T15:11:43.549Z" }, + { url = "https://files.pythonhosted.org/packages/14/e0/8ca71bc8b9cc9ed96c9da565863e00f3bb875a8fb82abcf03e975e067902/jiter-0.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:64846211a2debe7c071d2146d2283d2b0c1c93dc8fd5fb7794faac2ca6061b5c", size = 521868, upload-time = "2026-09-12T15:11:44.704Z" }, + { url = "https://files.pythonhosted.org/packages/c1/02/81f8719dcedb75713082a2048405376c81f6546b75af8167f3bba01a1ed0/jiter-0.17.0-cp311-cp311-win32.whl", hash = "sha256:c19b9357309b8cc6de8a48fca8e44a8c9c2feaaa2f5896d037fa505d48fcab80", size = 187844, upload-time = "2026-09-12T15:11:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8c/59693f348488f01ed12d862e99ab8da14961152d3e9c39b9b1ef363f3572/jiter-0.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:e654b6b04e39c9cb19cb8b04c6ddf1f2db07751fa14156413969fd78bad0e5cb", size = 231402, upload-time = "2026-09-12T15:11:47.083Z" }, + { url = "https://files.pythonhosted.org/packages/fe/89/fb35e286463cb9f01edc2c4e47df6e5477ee36bca5096414e9ea87985588/jiter-0.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:3ad556afc289f15d2b181b941982d01f06190863c07440185b9f354e1bd2def3", size = 187006, upload-time = "2026-09-12T15:11:48.245Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f8/07bd8c3a23f7a8a6875e6a820bbffe1483a18f18f9398a91b5495123176e/jiter-0.17.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ebf918dfd6a74adc1b9ad71f63c4ab00902fcd3b7fd39f2e24d871db8d713b91", size = 291633, upload-time = "2026-09-12T15:11:49.431Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5e/0de4c6f84ffefa6809ffc2d550b9a314365acf7e7ec9b6c7375d49047900/jiter-0.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:61aed66ee042b3b49ef85fdf75714234d055d89d8496ac1c6e47f89e7a30d5e4", size = 321695, upload-time = "2026-09-12T15:11:52.727Z" }, + { url = "https://files.pythonhosted.org/packages/20/ac/befe2e82065bee37a0252081666ed2f48c1ac5f5c6c318c2de8168ba393d/jiter-0.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76eb4a5c20e86f9f848286f167024890f2862258a965d254774deb7fc1545ca1", size = 341967, upload-time = "2026-09-12T15:11:54.231Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cd/9797c1e529746750ae589da7c1a8c24373f00d88e11a989f9e5eb1959079/jiter-0.17.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bcc064f99183a9cbe7f26ed648c352031a74145cd61ed75d34632c73eb46a5a8", size = 326546, upload-time = "2026-09-12T15:11:55.41Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fd/e6914c38d6347bab4ebff2b1f0c0f191db276e7a1d5c376176757da42fe3/jiter-0.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73b64e69c4150748e020356d958af94bec33c70a0a93d665cfa8f6d580fe1a63", size = 340995, upload-time = "2026-09-12T15:11:58.211Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7d/611b3abf6f88945b5474da5cdc6d1a185e805ac9bf446bb7766dcda6ea87/jiter-0.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0bc7f684b65bcda9c20434267577db71bf9905ceddd32b60d1d93278d8c8d3a", size = 352188, upload-time = "2026-09-12T15:11:59.414Z" }, + { url = "https://files.pythonhosted.org/packages/52/f8/b6e513ecbdf3b3cebe587c2279281ecf775b729a58cf4cc7bdf898ded029/jiter-0.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c21265b251d99bbb40080d178a8953e35601d3a1564e05c4de4c0d2ca616797", size = 345025, upload-time = "2026-09-12T15:12:00.697Z" }, + { url = "https://files.pythonhosted.org/packages/28/a8/fe26d06c5a6c5a4cfe703c5154c8a140da1305671eb3681aba9422d4f393/jiter-0.17.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:f3d7f7b34114f7ddc6d72a8e882d49de636b35d9fd12b4d420d3c5729f6c9812", size = 329180, upload-time = "2026-09-12T15:12:01.831Z" }, + { url = "https://files.pythonhosted.org/packages/e1/58/e6d66a26af40a20e62486feb7e222fd50f6e7aaa4f107abd89675dcc835b/jiter-0.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5078ab00664307fab2019b522a93aeb191122789f085daf5fd9e362154021d4a", size = 335805, upload-time = "2026-09-12T15:12:03.056Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3e/96520aa2fef5ef831d95483a902140bfab83dcac9eaa74f7df61b5e50a1b/jiter-0.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:470e1b1e4c42f1ead2189166a299691871a2df5056c976e7fb96feafaf5f9d44", size = 484121, upload-time = "2026-09-12T15:12:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8f/5d9d92fe538bf36ff481a2278c48147e59c1cf8eb2f7be665260665febe5/jiter-0.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:6eb6aedeb7352b8f3b6af9cbd67983840165c00428e63f1b420a85885128ea31", size = 521310, upload-time = "2026-09-12T15:12:05.612Z" }, + { url = "https://files.pythonhosted.org/packages/50/06/a09f979b22e652afbc3de66c709b2ba92edcef555f7535ab937c86b4f21a/jiter-0.17.0-cp312-cp312-win32.whl", hash = "sha256:362bb47423886d45a9f705d2d9d4008c6eedd4e41eb1bab4e96fb6daa06b33fd", size = 185029, upload-time = "2026-09-12T15:12:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d9/98265a005b2473ec2be5a84e2b64c2f65382c673879f1574845cd4bcd77c/jiter-0.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:9bd3caac219df476dd0cc3fe01d2f1581ed588906feac767abd9614c1c12f8b3", size = 227381, upload-time = "2026-09-12T15:12:08.823Z" }, + { url = "https://files.pythonhosted.org/packages/a8/11/2e05bf5a56e57a543ebb8f585074adf09383e99d7b062dac92eab1f4d57f/jiter-0.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:36ee6e69027396664e59995b9a635a947a5304ee9837279584a0bb8145c8f6b8", size = 183610, upload-time = "2026-09-12T15:12:10.374Z" }, + { url = "https://files.pythonhosted.org/packages/40/eb/2c4a8075ed5ea02b56911e9375d4c8d7784572ff4af32e5a99ae0d071044/jiter-0.17.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1b18434638228c0c184281609bf3d9459026a0f1ea48fb76c205e3ef72069caa", size = 290991, upload-time = "2026-09-12T15:12:11.641Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b1/34bfa29599d420423baac6ff7cada6674fe63d5a7a2ccb3900b904678783/jiter-0.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ec89771f4272b989487a6364e519db6bbaba323e8bbf949ac89a45ea9c18b7a3", size = 321425, upload-time = "2026-09-12T15:12:13.855Z" }, + { url = "https://files.pythonhosted.org/packages/11/71/a5ac64a62a04aebd556afadab14a6b730001e16df87266ded943a100a1d9/jiter-0.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e3f052c671d5f425cca5ea5901cf11a831369fba4a55a3862cab93c323b4c3b", size = 343138, upload-time = "2026-09-12T15:12:15.046Z" }, + { url = "https://files.pythonhosted.org/packages/01/dd/f761e320ea473314cb68612bc6a435393464dbd198051399b36848b4ebf3/jiter-0.17.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:785a216bbaf8f15fc974e964ced7322cd3d774bb0e86949edd78c6bffd6ba35b", size = 325805, upload-time = "2026-09-12T15:12:16.506Z" }, + { url = "https://files.pythonhosted.org/packages/19/1a/27d8e40f0fb29bbc7a5adf30907144396a115dbe93d5d8976c054a6dfe96/jiter-0.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d85c558c9f8532bba287a990ac63767c7daf756f0d8c030219f62499b1fa228a", size = 340230, upload-time = "2026-09-12T15:12:17.682Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c0/30bcde78a28155461f965d16b7aca4ffca6d17494d905f7a0bb072e6c64e/jiter-0.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c23849235d2142ce444b2b8c6eceee9f82f4cc0bd5c9081602e4155c6197807", size = 351343, upload-time = "2026-09-12T15:12:19.337Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/91420b156315ae22732f5ee1a7b5725a030aab9dc8fd7dcdacfb4aa588d3/jiter-0.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58df29268a95e910f17db7ec9178eb7f15aa8619aaca3575275c4e6b3f4fe4c5", size = 344990, upload-time = "2026-09-12T15:12:20.705Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a2/ae6d5672644cc11127970277c9aeb0fa6fae376845587f5b0a8e8828167c/jiter-0.17.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:a277f97eba7d66b1ee27eb5dab5b774ff46a10c78d89a1d3dcce04ce1357c8ca", size = 328624, upload-time = "2026-09-12T15:12:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/04/62/45cb1162f6aa586536e4a973fc339d72dc6b08cca030d70a838a307aa778/jiter-0.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe15ddf316f1f1f643347d3a474e74ce61880c79a11ec5dca53df20c071bd3e8", size = 334731, upload-time = "2026-09-12T15:12:25.229Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5f/45c1574b644da7deda0b7591c349520dcf83ce45b24d7ca19922dab1fc27/jiter-0.17.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:02adebb7ce6413c44d40af9ad59d1c1cd79630ccdcb6f7bdd2d461e48c03d8f9", size = 483649, upload-time = "2026-09-12T15:12:27.557Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d3/ebea1ecb5b241c519f192b30215c79a8e47f42f1621acbcd6f8830728416/jiter-0.17.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:55d0e0e613a3f9ad600cf436e0e2b8057d1b52bcf1d91b2d36ac53451231e6a8", size = 520758, upload-time = "2026-09-12T15:12:28.99Z" }, + { url = "https://files.pythonhosted.org/packages/64/e6/682b641ff0765ea9bdc349dbc7d223de5c8af8ec1abda0db3406992f92fe/jiter-0.17.0-cp313-cp313-win32.whl", hash = "sha256:2c45ad7c973ef33fe5114a953377b35a95240f4542c0724d9f781e47dc24bac7", size = 184334, upload-time = "2026-09-12T15:12:30.813Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d2/9a49aac2b27af4cc5015e368c0cc3588491a532f717a668ffce1f1ac57da/jiter-0.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:a3cebb1fe4a1abb00465f3f8a17e09112603e8b7c59e5c3adbcd9f7815a64acd", size = 226601, upload-time = "2026-09-12T15:12:32.096Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ce/9a43e9f614608eafa78de22aedcff54cd21324467b5d442d5c9b00244145/jiter-0.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:96b8b0c6dc5d78682f54a450785e075aa929cde768304cad363cd4efba5a82ac", size = 183103, upload-time = "2026-09-12T15:12:34.396Z" }, + { url = "https://files.pythonhosted.org/packages/01/9e/23065f8e2c7a4c372c1b6f6622e4cfab4dc786cb5150052b1527e6a6a840/jiter-0.17.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:00d783a779c5664e16dbad5e3a3c3a75e128b07dd5f4765159658d9210a50ca5", size = 292210, upload-time = "2026-09-12T15:12:35.613Z" }, + { url = "https://files.pythonhosted.org/packages/ea/81/67b58647560bc82a4490d722caa8561d7a86a9f45d4fa620b7e5fe282c7a/jiter-0.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0619d806e260ecf0c2a64521942c94af5d547c9ec99b55ae4f51b538b5576a76", size = 321512, upload-time = "2026-09-12T15:12:36.907Z" }, + { url = "https://files.pythonhosted.org/packages/c7/07/6658359a25f55927f7f8bf0e16465dee2ccd0b2a1a5208acc0df8972e074/jiter-0.17.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc0288ce39190ee33fe6e4ec73161eed34e7e2da509b525546ca061778d62b64", size = 343897, upload-time = "2026-09-12T15:12:38.189Z" }, + { url = "https://files.pythonhosted.org/packages/46/04/5d50a9f0319cbdc37fd53c27f8c313d46afc34f1b048219ae6d8ea068da4/jiter-0.17.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a52a430d04225ffde633e6840bf2381d34c019ff98526b5929755b9052fb199", size = 326519, upload-time = "2026-09-12T15:12:39.532Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c7/d02517832b29eb8275fdd0f4ce0f17b80f58cc4c3ebecd4d9ace990d633d/jiter-0.17.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37f33d327900bf2879613b3363fd48df97b4232d0c41f54bcf2e790c2fc40a71", size = 341369, upload-time = "2026-09-12T15:12:41.486Z" }, + { url = "https://files.pythonhosted.org/packages/3b/07/499b5f5603501cdd93a73a6a176dfad9c96555a3ae58ca9f8e3acba63dc9/jiter-0.17.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cf564d43c4388149ca58ee571d0f5ccf875e20d1fd4662fd94cc0d1ea3b10ef", size = 352160, upload-time = "2026-09-12T15:12:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/f5/75/b04013c7743269d4533ef4e746fc0ed678a143968dd7448658e3f51daad2/jiter-0.17.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:523c499235fb65add25d4bb01b1c4709ce695efdc7deb6c0a7bc515b5c44e0fb", size = 345018, upload-time = "2026-09-12T15:12:44.192Z" }, + { url = "https://files.pythonhosted.org/packages/1d/96/cbb6fd1e42a77c8412ec4643db95059b30cdfc635e387cc9193e098ce268/jiter-0.17.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:455e4ab35cb2a4a91a8404e08fd3c621bae433922e59bf1c494fe20a426b013b", size = 329244, upload-time = "2026-09-12T15:12:45.491Z" }, + { url = "https://files.pythonhosted.org/packages/15/67/d3be402f398566a379bf40ae65be5c3505b14d9e95e0802a597ddde7ddee/jiter-0.17.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6871973bfbd4408f7f1c632b30bbb5bbd9671c1bc8650af6823e24b7be13709b", size = 335693, upload-time = "2026-09-12T15:12:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8d/98e2c4130b93d64f1d67c89060b928d04102549bf05e64451c9e6024f9ca/jiter-0.17.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:77f6aac0137309b31448c1bdcda4c6c77077664a6d018ece8d94019c68a5a5b9", size = 484329, upload-time = "2026-09-12T15:12:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/78/5e/8da91e49f0fbca37c3489fb4cf3ad6676d4965f00ae5468bca3a2513737a/jiter-0.17.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:93946d89fa04d5ba64dd323a8dd8d901676cb8a3c81d99ae4f6c051a9b4c3f2f", size = 521358, upload-time = "2026-09-12T15:12:49.856Z" }, + { url = "https://files.pythonhosted.org/packages/be/21/5388684a5a38af3557cd9c2424b9827c71809cff24373c75ef9d0d3dfba9/jiter-0.17.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:70f19a2ca8429f91e82eeffb2f51cb87bc2d6e953b009b91a92d29c3a16ccb03", size = 110459, upload-time = "2026-09-12T15:12:51.747Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ad/58b3a93525d2ffca7f54d9dee441381990082bd1172fbeb8d6a3f72a4dc3/jiter-0.17.0-cp314-cp314-win32.whl", hash = "sha256:71dbd74314c5df52a1bccf7b8bca46d14e943af7a2012e73b23f49977ef194c8", size = 185043, upload-time = "2026-09-12T15:12:54.477Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4a/1aa520eb6c359b262c14ff995ca7283837208ddfb1202082ce9d73cf214d/jiter-0.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:ac3c6ee3264d6f5c44c617f90bc7e8b9e1587e7d6708c9d8f811cb65582ee312", size = 227163, upload-time = "2026-09-12T15:12:55.931Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e4/5997f648794bd9b499491d0ff480b096cc9a9c65bdba29f57568e6aa1705/jiter-0.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:6219adaf59711ba7063a52496e8ec6d3fa3e209d7827d83eee3b2abc780a1744", size = 183505, upload-time = "2026-09-12T15:12:58.196Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/84a5ec271d09f7590b6073af5ee4abb44eab4ccace453b7e2c5ce45234ca/jiter-0.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:59bddbe6f9ffecc68d641e1e2d619ce64cf8a9e9eeb74e5c518f74fc87abf1b0", size = 321527, upload-time = "2026-09-12T15:12:59.394Z" }, + { url = "https://files.pythonhosted.org/packages/39/71/9e1fd0045f5920b4c36be35c3f0f0dfd123668684f8ad352619d7aa44183/jiter-0.17.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cb41cd1432f1dc19a231cf70b54d42b2c9f05085155859263fce06fa4d41388", size = 340865, upload-time = "2026-09-12T15:13:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2b/14627fd2bc377f3dd09491bcace6b90e34b4d7fea2f1f3295031ff91f528/jiter-0.17.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd7790aa79c8b518e512ebcdfce9f11d8ef5f30efd43720c8a19a548b39fa489", size = 325412, upload-time = "2026-09-12T15:13:02.152Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/8d5808f7bf0f456bde79e6393587183a0cee5f83d179fe1f7f1eff2ba067/jiter-0.17.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dbbfe4e3c21c8166980cddc5bee1a315df082454f007947dfb6fb73800768165", size = 340473, upload-time = "2026-09-12T15:13:03.485Z" }, + { url = "https://files.pythonhosted.org/packages/4f/da/1d8c7c6c4ae6b2423b94a81b6b907d37b28f87664e077427b531bf1b5313/jiter-0.17.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c286860abfe8b100cac1c02e225e5776eb9216edd71ba17cdb237da4af32bc9", size = 350757, upload-time = "2026-09-12T15:13:04.828Z" }, + { url = "https://files.pythonhosted.org/packages/eb/96/c1813dcca15c5a370145a448aaea7d1f83f6f0228a5f1130e79340ee385f/jiter-0.17.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f753eb70b1474a29e635e7542ff7312e6d6b951e0b25e8a2e8c34eeb1ddcd478", size = 345203, upload-time = "2026-09-12T15:13:06.131Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f7/fc61cbcf2992d169ede13648fc3fd8e2d3171a3669dde43cd4db556549ac/jiter-0.17.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:eae86b1f027031e39db2e0e9c4842221edb7b8cd474d23f87a79b3bd4b651768", size = 328322, upload-time = "2026-09-12T15:13:07.392Z" }, + { url = "https://files.pythonhosted.org/packages/8f/88/46418a3abbdffb7dc41b314200360f24f75faaeb35573e81c92de322cce9/jiter-0.17.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5bf350452a43173e69e1fc74847c57a60e3d7515807287f29849baa2a85d8718", size = 336570, upload-time = "2026-09-12T15:13:08.666Z" }, + { url = "https://files.pythonhosted.org/packages/f0/28/b8a55b949be6306df8888e365a8df05441de8a7b11289f6957004302e41e/jiter-0.17.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:da139721f4b7cafdbff580a4f511ea24cb91f4909330c6b926a1ca53836c0a59", size = 482879, upload-time = "2026-09-12T15:13:10.037Z" }, + { url = "https://files.pythonhosted.org/packages/75/3b/21d0afa53ba0680962c39f3eb95ed2946f8793369ed44b0c82b490723081/jiter-0.17.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:8079849db9a1371bfd90bad088458a8fb836261879df2233cc9632464ecf64e1", size = 520406, upload-time = "2026-09-12T15:13:11.456Z" }, + { url = "https://files.pythonhosted.org/packages/ef/03/bcbaf8b6b9ea23c2c074411f8ecfbb02d820abac5d0cb8f4e280209174a2/jiter-0.17.0-cp314-cp314t-win32.whl", hash = "sha256:8f770b0c77e5fac482e1ba03ca1a7e18286bfb213d749932a00a7e4cd5de5e06", size = 184434, upload-time = "2026-09-12T15:13:13.037Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b5/5d6ce2c93ef6fe1241b37a9005547f9b6d58db1f07f39fe95807d4b98f51/jiter-0.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c4289293e5278d9314b00f15c37f2120fa51d3d68565292e715524c750e775a9", size = 227392, upload-time = "2026-09-12T15:13:14.933Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4b/1e52baf90187606e33a7b8cfa8f96f5829acd7f01870077eb01059ab76d0/jiter-0.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4dfbfe5a6e1e80a7082af559f66386405025ec278833e0c649f69cbc6e1004cc", size = 182776, upload-time = "2026-09-12T15:13:16.239Z" }, + { url = "https://files.pythonhosted.org/packages/05/fc/efe3ac75564ab10f53517958f5ccdc231fc7334af66c76776cb554a88967/jiter-0.17.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:84963d3f395ef5e9a32ce47155e08a7962fa292c159a10cb98b931cef1416925", size = 292143, upload-time = "2026-09-12T15:13:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4c/46982118d91f9ffe9714319d21ec4f98d9b7e0cfd9062826c524a54de24e/jiter-0.17.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ffa0380ad091de7d3fc33e17a97ff479851ee18a0a2a3ee56ff3215cdc886656", size = 321341, upload-time = "2026-09-12T15:13:19.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/12/9b1ac6ecc6307049913db54839ddba1c11c1ef72c5a8bbb5514bc3b50d1b/jiter-0.17.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:755079792868ce5d4938e83b91a0939b34fb858a1ca65a104f2d771bea57faa1", size = 344383, upload-time = "2026-09-12T15:13:20.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b6/527cc72af836d824e9d4d666e64f0a1ca7eafd662a8da9657b78592172ba/jiter-0.17.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3bf4dc2b84a464117fb097d15a25c58d100d2692888e3b0d92df5b48ed16b7c0", size = 326841, upload-time = "2026-09-12T15:13:21.83Z" }, + { url = "https://files.pythonhosted.org/packages/d1/41/567f98617e88005b249503b933803f633ec6ba2d427cf4cc35e5c832125c/jiter-0.17.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02a360707033d8cef53f7f3480817a1489177a259ec6ec01e98c37e0b922ddca", size = 341354, upload-time = "2026-09-12T15:13:23.323Z" }, + { url = "https://files.pythonhosted.org/packages/40/da/b29cda895b785f7d426e224638a885b6145a08ce853b381f34afe3e88c5d/jiter-0.17.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:300ce01ab0215e3dea4d00090143c909aedc65c0f809b3c07983e1d038f291b9", size = 351985, upload-time = "2026-09-12T15:13:26.526Z" }, + { url = "https://files.pythonhosted.org/packages/f7/5c/8a73829e7389e72ea298a450f2b3cb58e71a3e464b45f6d8753740f1c4f5/jiter-0.17.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:746243a080b4ca790b8499af3d7cf9825d5f5987933950cd818e767ee353d826", size = 346052, upload-time = "2026-09-12T15:13:27.887Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2f/98d6001026932c095ba440925570123043bed29f5ff56158dfe729a9e81b/jiter-0.17.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:b550585523339b71cb852b811aae49d08d7601ad8ffe9f5dc1562f4c3d22fd87", size = 329159, upload-time = "2026-09-12T15:13:31.569Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/708dc1d2678f092c31c12754e860cd8353e6a85ecbdb1010157edca0da9e/jiter-0.17.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0239520085cac678e77a606fd7e3f1c60c371d719790c5e3807388d3da4354c2", size = 336001, upload-time = "2026-09-12T15:13:32.846Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/75a5ae38862f4eaf0fe2f8a9fbf6484c4890df04c06dcdffc45e36bca61a/jiter-0.17.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:eb2295da7c3769f6719b227a237aa6a5cfa6550e478bc838001b592c57e16575", size = 484281, upload-time = "2026-09-12T15:13:35.333Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/6636fae811c27c7f93e1b11fb5800de6a5c9e4269a27cf718e0b31218ad1/jiter-0.17.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:e088612ff90ebc9247e1a43074b72835804261c47e6a6c01cb3ddcb55360d688", size = 521300, upload-time = "2026-09-12T15:13:37.101Z" }, + { url = "https://files.pythonhosted.org/packages/61/aa/12df7e0b0b1a2602e3d5a5a7104d7d9700f254b400f134a9b50955c4d231/jiter-0.17.0-cp315-cp315-win32.whl", hash = "sha256:0b52d52035b3907c5b1f6277857b29c1cbfc965e24e0f27330dbed83edb591ec", size = 185138, upload-time = "2026-09-12T15:13:38.901Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ec/3dd2e495032cddde05723c1f4c743b67a23e55d2af244692a7f58f0cdae3/jiter-0.17.0-cp315-cp315-win_amd64.whl", hash = "sha256:10f5558eed511b830488003449d942bd75829ad6257dc58cb9a03e596a7777b1", size = 226950, upload-time = "2026-09-12T15:13:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c7/ef85704e0a57e9cadb2babc05f6d7c5df4a1c75da1a6ee31e1986b0099a5/jiter-0.17.0-cp315-cp315-win_arm64.whl", hash = "sha256:fa13acf1046f95df808c64b1310705e143fab87aee73ae00cc42d640867fd2c1", size = 183618, upload-time = "2026-09-12T15:13:41.432Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9a/a4b348349de68762b58d6713973d363ad80a1c741d0bf8def7975f0ecb26/jiter-0.17.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:af2f7501580f274b63c4b2283bc425f5df7edf06ae5b171e5f87d912ff359a20", size = 321155, upload-time = "2026-09-12T15:13:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/c1/70/aebd6d0b5f0677de3a3d0bdc4a05fac949b97c4ede454c8809f180ac7b17/jiter-0.17.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10c5349312e5cb02b7a21e123a57665afa895953f05bf252a9dd4c13a572b7ab", size = 340985, upload-time = "2026-09-12T15:13:44.115Z" }, + { url = "https://files.pythonhosted.org/packages/a7/82/4c3b49796b5eb62f3f5046f957683f4ba0135fe1a60957c11180512460df/jiter-0.17.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:86f3f9343a288eb85a81ef20a752b2f84564296636db54a9fff0b5c8deaf1df2", size = 325670, upload-time = "2026-09-12T15:13:45.901Z" }, + { url = "https://files.pythonhosted.org/packages/bc/43/f6341ecb4872202a4ef150486fcee0e1ace4aa3da39b71b82061452cdd3a/jiter-0.17.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4607ec7d93355fbc25b8dc5189153cf21d66063b9f9cd04dd2774e6e783f9b6a", size = 340339, upload-time = "2026-09-12T15:13:47.442Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c4/bc2c86e08fa065e03cb2fbc53b367c3640a7d257ef9d877b29118ea636b7/jiter-0.17.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:10cd64a5720ad7f809ac5466ff1705813f1b6b510f195a73acafba0ac0e1f675", size = 350705, upload-time = "2026-09-12T15:13:48.848Z" }, + { url = "https://files.pythonhosted.org/packages/9d/67/91f12aa111cca6e3a197c3e36bf60a034bf9f122f6d41112a639e44217d8/jiter-0.17.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efe9f61bb30174d2f5c8396445c360c96c44e78164d0815dfe627ccf57849574", size = 345011, upload-time = "2026-09-12T15:13:50.215Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cb/9f5556e8f6ec89755fb5a709d8eb8270c9a324e31079eda0dfbeca451b6e/jiter-0.17.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:370d8fe5bf201dc6925e8a84c81ac7291f74d9fd1778234fc79d517064a5c76b", size = 328268, upload-time = "2026-09-12T15:13:51.809Z" }, + { url = "https://files.pythonhosted.org/packages/22/98/153f20680fb75781a490fb849940e2b00f95035c7aa054df592f36ed33fc/jiter-0.17.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6b303d88e6a0bda789ec4b7801c7bad68e27230ba1fe4baffc756d1fbd32dc9d", size = 337024, upload-time = "2026-09-12T15:13:53.095Z" }, + { url = "https://files.pythonhosted.org/packages/af/59/b16c9be3a5035df4466cc72e888188c027562de90a723d290ab6814cb9d4/jiter-0.17.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:30793a24a31e968969757c9e08d830cbb15a2cd3c4959b4498b38f4b1c2258eb", size = 482766, upload-time = "2026-09-12T15:13:55.713Z" }, + { url = "https://files.pythonhosted.org/packages/d0/55/667dea313094024bef082175d6bfe8976f90d1c00c926af9df1d8e0eab48/jiter-0.17.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:686c93d86f2b426c803024b805bd161a6cd10e9627c23e901640eab646c0ad8a", size = 520367, upload-time = "2026-09-12T15:13:57.674Z" }, + { url = "https://files.pythonhosted.org/packages/21/e3/4b1a43501fb9ed17b01d137e380cb0e8fdcb39a254ce31aa2ab95bc861ac/jiter-0.17.0-cp315-cp315t-win32.whl", hash = "sha256:86d703d9faa1ffc8ae4e9de0fa007712ed2171b5c0d93811a8e2e105ac729b0d", size = 184603, upload-time = "2026-09-12T15:13:59.27Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f2/b8ee0372b6ebdf1bde5cc44495d5291d17f961065f5b48f8616cc67cac2e/jiter-0.17.0-cp315-cp315t-win_amd64.whl", hash = "sha256:42b0260445251b1bc520a63baa94a32d88e0f931fba234f1764db7feb7c72174", size = 227936, upload-time = "2026-09-12T15:14:00.472Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b4/923a1215daba959aed8355973315cb3f81f53e0d01c5b211870a27b41f45/jiter-0.17.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d47687806f9c54c84ea38733507081337922beca90ce819c7d852dd485bc0f23", size = 182977, upload-time = "2026-09-12T15:14:01.799Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3b/05a917204413e2e09906dfa35240c1021227aeb56c7305abea9562c598b4/jiter-0.17.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:eaba834b72d573547b9d966465b3394b749d5e14208cc70acb63aca37619ab33", size = 288143, upload-time = "2026-09-12T15:14:02.998Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e1/a1cd3c0cf8f79945939e4f8caae9990529f67d7f77f671769f73956329b9/jiter-0.17.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:51e1519d676a9f14dad9c2a411170d43b022ddb7989562df4e849b261ce127b2", size = 281847, upload-time = "2026-09-12T15:14:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/72/b4/9b797679e09f4a46c32986aeb3670bd9bc562fc0b373c7a0ee5c5dce1206/jiter-0.17.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ce4feb52493e3513335b2accdcd75605652e4632772d3c8c2f7b86954d7f39", size = 304797, upload-time = "2026-09-12T15:14:05.733Z" }, + { url = "https://files.pythonhosted.org/packages/25/4a/0d77415b27a00d970e4e710f7c1de62e96a11c4cab3ed1add0015af04626/jiter-0.17.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29f49b325e0234e4ad9ecca5b861ffbd09b95ccac9bd46fa55841b6e56eea5fe", size = 307815, upload-time = "2026-09-12T15:14:07.105Z" }, + { url = "https://files.pythonhosted.org/packages/17/31/4bb27f54333d3b9ef1e5bd3312dc0b4bbe59c68bb0885fdb40583a6b1567/jiter-0.17.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:454c4997d73cc466c71fd565d91e603b0274e48ea0c6b0b7a7aee6967e4ceb7c", size = 288415, upload-time = "2026-09-12T15:14:08.455Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/879570ecf82574eaea77c5eb10309f4b630dece5f2a556e9814a90ba3f2d/jiter-0.17.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:40d2c240f8f80b5b0f201b29f0ae129c81448c60c772227a41747b5e0026f6a2", size = 279113, upload-time = "2026-09-12T15:14:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/77/7a/1f0b8a35fbd079a4f1752c31a15dc99cf277f863747c459be0af39e900e5/jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e05f5adbf68c4bd11e1610f394034d984152988e84be6f8314235ce6f2139e5", size = 303708, upload-time = "2026-09-12T15:14:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8b/d76219ebdbcf3d4209d9d21a0810db4c8d0a6f88e3ee87d30bdea4e90d30/jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2c0bf24c72fd0491405dce5d40194f2070e9021ce648c1a1d46234b93d848ff", size = 307147, upload-time = "2026-09-12T15:14:12.897Z" }, +] + +[[package]] +name = "miniflow" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "openai" }, +] + +[package.optional-dependencies] +anthropic = [ + { name = "anthropic" }, +] +gemini = [ + { name = "google-genai" }, +] +openai = [ + { name = "openai" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=1.13" }, + { name = "google-genai", marker = "extra == 'gemini'", specifier = ">=1.0" }, + { name = "openai", specifier = ">=1.40" }, + { name = "openai", marker = "extra == 'openai'", specifier = ">=1.40" }, +] +provides-extras = ["gemini", "openai", "anthropic"] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.0" }] + +[[package]] +name = "openai" +version = "3.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/4f/e57670227cb7b61362d8f9bfba54d4e9f7bde799798c342782788bc12d6c/openai-3.24.0.tar.gz", hash = "sha256:1e7463f7d78773ab2ce4fe85710481aa5bd5ffefd54c8de4b067506cd2d42895", size = 1797218, upload-time = "2026-10-02T15:55:18.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/01/32baec3bdb83d36da27281d7eeac2647241345cf8ff538bc21d1013ddeec/openai-3.24.0-py3-none-any.whl", hash = "sha256:b59703a3e3b5df78b269e90049d383a1dda58c90104cc4be59db21078db4b7a4", size = 2127166, upload-time = "2026-10-02T15:55:16.228Z" }, +] + +[[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 = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + +[[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.1.1" +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/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[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 = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[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 = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[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" }, +] + +[[package]] +name = "websockets" +version = "16.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/03/47debfe28e9d6d354be5d777b67fd44c359b9eb299a5d103500bd7cc3e37/websockets-16.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d0fcf657e9f13ff4b177960ab2200237b12994232dfb6df16f1cfe1d4339f93c", size = 179566, upload-time = "2026-07-17T22:48:49.596Z" }, + { url = "https://files.pythonhosted.org/packages/72/93/31efa1ed78c17e5cfc229fd449e3966e1b9cc15753204cd585cc8dd01f4a/websockets-16.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b852788aa51764e2d8e4cf5493d559326bcae5e38d16ba25ffa322b034df272a", size = 177250, upload-time = "2026-07-17T22:48:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/542378ab3972b0c1cf1df3df3eff9591cea0d30c58c3aa3c4ddbc244e787/websockets-16.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22", size = 177528, upload-time = "2026-07-17T22:48:52.59Z" }, + { url = "https://files.pythonhosted.org/packages/33/d9/162321f63c7eed558e9e1798ed7a1e34a4f6dab51f35419e4ed7a4907979/websockets-16.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2", size = 186859, upload-time = "2026-07-17T22:48:53.915Z" }, + { url = "https://files.pythonhosted.org/packages/de/09/87df740f7430ce564bd52402e9c9458d4d0459cc7d2ee29e530c8204851b/websockets-16.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d7f0f8bdb25d2c632b72527325e4776430fd5bc61b9118de4e2b8ddb5f5b01", size = 188095, upload-time = "2026-07-17T22:48:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/d2/12/3d2703af7cc095f3c81904c92208cc1ae79affbc67376944b50ee9301f73/websockets-16.1.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7dfcad78ea1492ee3a9ec765cb7f51bbc17d477107aaf6b22abf7b2558d1c5a0", size = 191385, upload-time = "2026-07-17T22:48:56.742Z" }, + { url = "https://files.pythonhosted.org/packages/1d/69/986aa0234a964a00f5149cfc46e136e96c8faad1c783474550f40d31aef4/websockets-16.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb9a0a6dc3d1b3986cb88091b6899f0396651e0f74e2c9766ab8d6ffc3842e29", size = 188653, upload-time = "2026-07-17T22:48:58.134Z" }, + { url = "https://files.pythonhosted.org/packages/35/6b/10f9d03e3970a69ba67bd3b46b87a929b586d0300fadbfe14f57c1f85490/websockets-16.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29dfa8114c4a620c69591c5973860f768eac29d3fd6904f37f34266cb219c512", size = 187426, upload-time = "2026-07-17T22:48:59.515Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/bb3aad62bf63d8bb3f0634b2eabffcfb3677a34bd19492110ff6869cf703/websockets-16.1.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff9417c0ada4d0f7d212f928303e5579bdf3ace4c802fa4afabb30995da58c3", size = 184882, upload-time = "2026-07-17T22:49:00.916Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4c/c09a2ea9bfbeccce52fdc383e5f28af4bc8843338aabac28c81489af6120/websockets-16.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fe0b50da2d84535fb4f7b4bfa951280f97ce3d558a0443b541166d609e67b57", size = 187584, upload-time = "2026-07-17T22:49:02.283Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8b/31bb4eb4d9eaacf1fdd39d115772a8aeaedfc19b5dc262e57ffbc8a9d42c/websockets-16.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:34420aaa64440ebd51ac72ca8a45ef4626429438c9b02e633ae412ed43f925d3", size = 186174, upload-time = "2026-07-17T22:49:03.973Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e4/dc02d725610a1ad49e193ef91a548194d71bdc6cdf27da83067dd1f73995/websockets-16.1.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a6a61aff018180c9c50b7b0da33bfd29d378af3497429c95006c589a23a11648", size = 187986, upload-time = "2026-07-17T22:49:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/e0/73/30ed84c8bfd14c73d4af29d5ed9323c3073b48e0b7b23b67070f4e7fd59b/websockets-16.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:04fd29a0e2fe9414a95b00e92c67ae51bf900c50c0f8a4b2dafdad621f49ea1d", size = 185565, upload-time = "2026-07-17T22:49:06.959Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d3/4be8d4959f51e31b4f8fc0ece12b45bd3b6c0d15ea23b9990d9c11fc805f/websockets-16.1.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5c31aa7e39ee3e8a358573257f1c0bb5c52430d1b637030dd9c8cc2c282926be", size = 186598, upload-time = "2026-07-17T22:49:08.293Z" }, + { url = "https://files.pythonhosted.org/packages/26/fa/abb38597a52d84ed9cfacadc7a0c6f2db282c0ab23cdf72b58a666a21227/websockets-16.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d14bfb217eb4701e850f1525c9d29d79c44794cdf1c299ead25f39f8c78dea81", size = 186834, upload-time = "2026-07-17T22:49:09.766Z" }, + { url = "https://files.pythonhosted.org/packages/59/80/1119ad08a228b90c4eb77fbe48df7836731a605f5f881ba701ca826a4a65/websockets-16.1.1-cp311-cp311-win32.whl", hash = "sha256:2e28e602bb13da44fbe518c1781a88e3b9d4c3d48d02c9bad83e546164336f57", size = 179940, upload-time = "2026-07-17T22:49:11.196Z" }, + { url = "https://files.pythonhosted.org/packages/71/b2/e511c1c6f64a95c2f3fc54bffda0e14eaa7e9442be605c29270f7589b918/websockets-16.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a", size = 180239, upload-time = "2026-07-17T22:49:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" }, + { url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" }, + { url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" }, + { url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" }, + { url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" }, + { url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" }, + { url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" }, + { url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" }, + { url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" }, + { url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" }, + { url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" }, + { url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" }, + { url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" }, + { url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" }, + { url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" }, + { url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" }, + { url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" }, + { url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" }, + { url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" }, + { url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" }, + { url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" }, + { url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" }, + { url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" }, + { url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" }, + { url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ed/71fea6e141590cafc40b14dc5943b0845606bee87bdb52a21b6a73eb4311/websockets-16.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:820fb8450edddae3812fd58cbc08e2bf22812cb248ecb5f06dbb82119a56e869", size = 177185, upload-time = "2026-07-17T22:50:56.665Z" }, + { url = "https://files.pythonhosted.org/packages/01/ec/00e7eeca200facf9266a83e4cbbf1bed0e67fba1d4d45031d3e5b3d81b5c/websockets-16.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:125f22dbefaf1554fea66fc83851490edb284ce4f501d37ffed2752f418332d9", size = 177459, upload-time = "2026-07-17T22:50:58.197Z" }, + { url = "https://files.pythonhosted.org/packages/75/fd/5774c4b33f7c0d8f0c51809c8b3a93456c48e3543579262cfa64eb5f522e/websockets-16.1.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30bbe120437b5648a77d3519b7024ea09530e0b5b18d3698c5a0ae536fe0cc2e", size = 178294, upload-time = "2026-07-17T22:50:59.641Z" }, + { url = "https://files.pythonhosted.org/packages/37/c3/48e2c03d2bd79bb45948841c592d24156312dd5f58cdf8f549febe652fb6/websockets-16.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6b9dadbef0cccd9f4c4ee96b08898afa73e26803bbe0f6aeb5bb12b0074206d", size = 179190, upload-time = "2026-07-17T22:51:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3f/73e511ecf2496ceac57dd4ed8388efe2bcf0769338a2dbf242c8366ae87e/websockets-16.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56cd5fc4f10a9ea8aa0804bddb7b42506cf9e136046f3b4c27de8fec9e2ecba5", size = 180330, upload-time = "2026-07-17T22:51:02.603Z" }, + { url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" }, +] diff --git a/Lecture/66/00-DAY66-OVERVIEW.md b/Lecture/66/00-DAY66-OVERVIEW.md new file mode 100644 index 0000000..5ccaca9 --- /dev/null +++ b/Lecture/66/00-DAY66-OVERVIEW.md @@ -0,0 +1,305 @@ +# 66일차 개요 · 단일 업무 자동화 워크플로우 구현 + +> 노코드 멀티 에이전트 워크플로우 설계 및 운영 · 2026-10-16 +> 15장 · 각 장은 가로줄(`---`)로 나뉩니다. 1교시에는 1~3장과 14장만 보고, 나머지 장은 각 단계를 설명할 때 함께 띄웁니다. + +| 장 | 범위 | +| --- | --- | +| 1~3 | 표지 · 오늘의 목표 · 시간표 | +| 4~7 | 1단계 · 업무 설계와 데이터 흐름 | +| 8~10 | 2단계 · 파싱과 변환 노드 | +| 11~13 | 3단계 · 반복·병렬과 완성 | +| 14~15 | 실습 · 퀴즈와 예고 | + +--- + +## 1 / 15 · 표지 + +```mermaid +flowchart LR + M["회의록
1건"] --> A1["할 일"] & A2["할 일"] & A3["할 일"] & A4["할 일"] + A1 & A2 & A3 & A4 --> R["결과
1건"] + classDef d fill:#0F3D3E,stroke:#0F3D3E,color:#fff + classDef o fill:#F2A541,stroke:#F2A541,color:#fff + classDef t fill:#0F7C80,stroke:#0F7C80,color:#fff + class M d + class A1,A2,A3,A4 o + class R t +``` + +**66일차** + +# 단일 업무 자동화 워크플로우 구현 + +쉬운 예제 2개 → 고쳐 보는 과제 · 데이터 파싱·변환·집계 · 반복과 병렬 + +노코드 멀티 에이전트 워크플로우 설계 및 운영 · 2026-10-16 + +--- + +## 2 / 15 · 오늘 할 수 있게 될 세 가지 + +| | 목표 | 내용 | +| :-: | --- | --- | +| **①** | **업무 하나를 노드와 데이터로 설계한다** | 목적·입출력·성공 기준 · TCA → 노드 목록 · 연결선마다 항목 수와 필드 · 제어 구조 고르기 | +| **②** | **LLM의 답을 데이터로 바꾼다** | JSON 스키마 출력 · 문자열 파싱 · 검증과 기본값 · 실패 이유 남기기 · Edit Fields 변환 | +| **③** | **반복과 병렬로 워크플로우를 완성한다** | Split Out → Aggregate · Fork-Join(Merge) · 같은 결과 형식 · Python 호출 | + +--- + +## 3 / 15 · 오늘의 시간표 + +| ① 1~2교시 | ② 3~4교시 | ③ 5~6교시 | ④ 7~8교시 | +| --- | --- | --- | --- | +| **1단계** | **2단계** | **3단계** | **정리** | +| 업무 설계와 데이터 흐름 | 파싱과 변환 노드 | 반복·병렬과 완성 | 퀴즈 A·B·C · 오늘의 정리·예고 | +| Lab 01 예제 2 + 과제 | Lab 02 예제 2 + 과제 | Lab 03 예제 2 + **과제 40분** | 퀴즈 20분 · Python 호출 | + +> 09:00 시작 · 50분 수업 10분 휴식 · 12:00~13:00 점심 + +--- + +## 4 / 15 · 오늘의 업무: 회의록 → 할 일 정리 + +| 지금 방식 | 자동화 후 | +| --- | --- | +| 회의록을 읽고 할 일·담당자·기한을 직접 찾음 | 회의록을 보내면 요약·할 일 목록·정리문 | +| 빠진 담당자·기한을 놓치기 쉬움 | 빠진 값은 `미정`으로 바로 보임 | +| 급한 일이 묻힘 | 오늘·내일 마감은 우선순위 `높음` | + +| 범위를 좁히는 세 줄 | 오늘의 답 | +| --- | --- | +| **목적 한 문장** | 회의록을 받으면 할 일 목록과 공유용 정리문을 돌려준다 | +| **입력과 출력** | `{title, notes}` → `{title, route, count, summary, actions, report_text}` | +| **성공 기준** | `weekly`에서 할 일 4건, 빠진 값은 `미정`, 오늘 마감은 `높음` | + +> **단일 업무로 좁히기** 오늘은 회의록 → 할 일 정리 → 응답까지. 알림·일정 등록은 나중에 확장 + +--- + +## 5 / 15 · 예시 데이터로 결과를 먼저 그려 보기 + +| # | 할 일 | 담당자 | 기한 | 우선순위 | 누가 정하나 | +| --- | --- | --- | --- | --- | --- | +| 1 | 신규 고객 문의 응답 템플릿 정리 | 이준호 | 금요일 | 보통 | 찾기는 **LLM** | +| 2 | VPN 장애 보고서 작성 | 박서연 | 다음 주 월요일 | 보통 | | +| 3 | 서버 점검 일정 공지 | 김민지 | 오늘 | **높음** | 우선순위는 **규칙** | +| 4 | 회의실 예약 규칙 개선 담당자 선정 | **미정** | 10월 23일 | 보통 | 빈 값 채우기는 **규칙** (기한은 LLM이 "다음 회의"에서 추론) | + +> **LLM은 찾기만, 채우기·우선순위·정리문은 규칙** · 결과 형식(JSON)은 노드를 만들기 전에 약속한다 · 할 일이 없어도 같은 필드(`route: 할 일 없음`, `count: 0`, `actions: []`) + +--- + +## 6 / 15 · 상자(항목)가 몇 개 지나가나 · 1 → 4 → 1 + +```mermaid +flowchart LR + A["회의록
1개"] -->|"LLM + 파싱"| B["1개
안에 actions [4]"] -->|"Split Out
1 → 4"| C["할 일
4개"] -->|"항목마다 정리"| D["정리된 할 일
4개"] -->|"Aggregate
4 → 1"| E["결과
1개"] + classDef d fill:#0F3D3E,stroke:#0F3D3E,color:#fff + classDef t fill:#0F7C80,stroke:#0F7C80,color:#fff + classDef o fill:#F2A541,stroke:#F2A541,color:#fff + classDef m fill:#5B8E7D,stroke:#5B8E7D,color:#fff + class A d + class B,E t + class C o + class D m +``` + +| 노드 | 상자 수 | +| --- | --- | +| Edit Fields · **If** · Switch | **그대로** (If는 한쪽 길로 보낼 뿐) | +| **Split Out** | **1 → N** (목록을 펼침) | +| **Aggregate** | **N → 1** (다시 모음) | +| Merge | 두 길 → 하나 | + +> **Lab 01 예제 1** 어제 흐름은 모두 1 · **예제 2** 파싱 직후는 아직 1 (목록이 상자 하나에 들어 있음) → Split Out 뒤에 4 + +--- + +## 7 / 15 · 제어 구조 고르기 · 요청이 바뀌면 + +| 조건 분기 | 반복 | 병렬 (Fork-Join) | +| :-: | :-: | :-: | +| **입력에 따라 다른 결과** | **목록의 항목마다 같은 처리** | **서로 결과가 필요 없는 일** | +| 내용 없음 → 거절
할 일 없음 → 다른 결과 | 할 일마다 우선순위 | 할 일 찾기와 요약 | +| If · Switch | Split Out → … → Aggregate | 가지 나누기 + Merge | + +| 요청이 바뀌면 묻는 세 질문 | 고칠 곳 | +| --- | --- | +| 할 일 **하나마다** 정하는 값? | Split Out과 Aggregate **사이** (예: `priority`) | +| **모두 모은 뒤**에야 아는 값? | Aggregate **뒤** (예: `count`) | +| 결과 필드가 늘었나? | **모든 길**의 끝 | + +> **Lab 01 과제** "담당자·기한이 없으면 확인 필요, 몇 건인지도" → 오후 Lab 03 과제에서 실제로 만든다 + +--- + +## 8 / 15 · LLM의 답은 글자다 → 파싱 + +```text +Fake LLM answer content: "{\"actions\": [{\"task\": \"...\", \"owner\": \"\", ...}]}" ← 글자 한 줄 + +Parse actions { actions: [ {task, owner: "미정", due: "미정"}, ... ], count: 2 } ← 목록 +``` + +| ① 꺼내기 | ② 글자 → 데이터 | ③ 빈 할 일 버리기 | ④ 빈 값은 `미정` | +| :-: | :-: | :-: | :-: | +| `content` (진짜 LLM은 `choices[0].message.content`) | 실패하면 빈 목록 + `error` | | | + +> **Lab 02 예제 1** LLM 없이 가짜 답으로 파싱 연습 · 끝 `}`를 지우면 `error: answer is not valid JSON` → 실패해도 **같은 모양**, 이유는 남긴다 + +--- + +## 9 / 15 · 답의 모양 알려 주기 · 하나 더 뽑기 + +| LLM 노드 JSON 칸 | 하는 일 | 비유 | +| --- | --- | --- | +| ① 스키마 `response_format` | 답의 **모양** | 빈칸이 정해진 답안지 | +| ② 지시문 `system` | 무엇을 찾을지, 없으면 `미정` | 문제 설명 | +| ③ 회의록 `user` | 읽을 글 | 지문 | + +```mermaid +flowchart LR + A["① 스키마에 칸 추가"] --> B["② 지시문에 한 문장"] --> L{{"LLM 답
(글자)"}} --> C["③ 파싱 코드 return"] --> R["결과"] + classDef t fill:#0F7C80,stroke:#0F7C80,color:#fff + classDef o fill:#F2A541,stroke:#F2A541,color:#fff + class A,B,C t + class L o +``` + +> **스키마는 모양만, 내용은 보장 안 함** · **Lab 02 과제** 참석자도 뽑기 = 세 곳 고치기 (③을 빠뜨리면 LLM 답에는 있어도 결과에서 사라짐) · `}}`가 붙으면 `invalid syntax` → `} }` + +--- + +## 10 / 15 · Edit Fields와 만들고 확인하는 순서 + +| 하고 싶은 일 | Edit Fields 표현식 | +| --- | --- | +| 웹훅 입력 꺼내기 | `{{ $json.body.notes }}` | +| 비었으면 기본값 | `{{ $json.body.title \|\| '제목 없음' }}` | +| 여러 단어 중 하나라도 포함 | `{{ ['오늘','내일','긴급'].some(w => $json.due.includes(w)) }}` | + +```mermaid +flowchart LR + A["노드 하나 추가"] --> B["이름 짓기"] --> C["설정"] --> D["실행"] --> E["OUTPUT 확인"] + E -->|"다음 노드"| A + classDef t fill:#0F7C80,stroke:#0F7C80,color:#fff + classDef o fill:#F2A541,stroke:#F2A541,color:#fff + class A,B,C t + class D,E o +``` + +> `{{ }}` 값은 **Expression** 모드 · 같은 요청 파일(`weekly`·`no-action`·`empty`)로 매번 확인 · Test URL은 요청마다 Listen 다시 + +--- + +## 11 / 15 · 반복과 병렬 · Lab 03 예제 1·2 + +```mermaid +flowchart LR + T(["Start"]) --> S["Sample actions
[3]"] -->|"1 → 3"| SO["Split Out"] --> N["Normalize
3번"] -->|"3 → 1"| AG["Aggregate"] -->|"Input 1"| J["Merge
Position"] + T --> SS["Sample summary"] -->|"Input 2"| J + J --> O["{actions, summary}
1개"] + classDef o fill:#F2A541,stroke:#F2A541,color:#fff + classDef m fill:#5B8E7D,stroke:#5B8E7D,color:#fff + classDef p fill:#7A6FB0,stroke:#7A6FB0,color:#fff + class S,SS o + class SO,N,AG m + class J p +``` + +| 펼쳤으면 반드시 모은다 (수업 전 실측) | 결과 | +| --- | --- | +| 모으지 않고 바로 응답 | 200, **첫 할 일 하나만** (조용히 틀림) | +| 모으지 않고 `Build report` | 500 | + +> Merge는 **두 입력이 모두 와야** 합침 · 한 길이 실패하면 응답 없음 · 항목마다 LLM을 부르면 Loop Over Items, 시간·비용은 항목 수에 비례 + +--- + +## 12 / 15 · 가짜를 진짜로 · Lab 03 과제 40분 + +| 예제의 가짜 | 과제의 진짜 | +| --- | --- | +| `Sample actions` | `LLM extract actions` → `Parse actions` (Lab 02) | +| `Sample summary` | `LLM summarize` → `Keep summary` | +| Merge · Split Out · Normalize · Aggregate | **복사해 붙이기** | +| (없음) | `Any actions?` · `Build report` · `No actions` · **확인 필요** | + +```mermaid +flowchart LR + P["할 일 목록"] --> J["Merge"] + K["요약"] --> J + J --> A{"할 일
있음?"} -->|"예"| L["펼치고 · 정리 · 모으기"] --> B["Build report"] + A -->|"아니오"| NA["No actions"] + classDef o fill:#F2A541,stroke:#F2A541,color:#fff + classDef t fill:#0F7C80,stroke:#0F7C80,color:#fff + classDef p fill:#7A6FB0,stroke:#7A6FB0,color:#fff + class A o + class P,K,L,B,NA t + class J p +``` + +> Merge를 **먼저** → 할 일 없음 길에도 요약이 있다 · 두 길 모두 `{title, route, count, summary, actions, need_check}` + +--- + +## 13 / 15 · 기대 결과로 검증하고, 밖에서 부르기 + +| 요청 | 기대 결과 (수업 전 측정) | +| --- | --- | +| `weekly` | **할 일 정리** · `count: 4` · 오늘 마감 1건 `높음` · `need_check: 1` · 약 3~4초 | +| `no-action` | **할 일 없음** · `count: 0` · `need_check: 0` · 약 2초 | +| `empty` | **HTTP 400** · 1초 미만 | + +| 오류 없이 틀리는 모양 (실측) | 원인 | +| --- | --- | +| 할 일 하나만 | Aggregate 빠짐 | +| `no-action`이 빈 응답 | false 길 끊김 | +| `need_check: 4` | `check`를 String으로 | + +```mermaid +flowchart LR + APP["call('meeting')"] --> CFG["platforms.json
웹훅 경로 한 줄"] + CFG --> N8["n8n · 내 워크플로우"] + CFG --> MF["miniflow · 08 flow"] + classDef d fill:#0F3D3E,stroke:#0F3D3E,color:#fff + classDef o fill:#F2A541,stroke:#F2A541,color:#fff + classDef t fill:#0F7C80,stroke:#0F7C80,color:#fff + classDef m fill:#5B8E7D,stroke:#5B8E7D,color:#fff + class APP d + class CFG o + class N8 t + class MF m +``` + +> 실행 기록 항목 수 1 → 4 → 4 → 1 → 1 · 같은 입력·같은 결과 형식 → **설정 한 줄**로 대상 변경 (8교시 함께) + +--- + +## 14 / 15 · 오늘의 실습 + +| | 실습 | 예제 1 | 예제 2 | 과제 (생각하기) | +| --- | --- | --- | --- | --- | +| **1** | **Lab 01** · 설계 | 어제 흐름의 항목 수 | 오늘 흐름 빈칸 (1 → 4 → 1) | "확인 필요" 요청 → 어디를 고치나 | +| **2** | **Lab 02** · 파싱 | LLM 없이 글자 → 목록 | LLM 붙은 1부 가져와 실행 | 참석자도 뽑기 (세 곳) | +| **3** | **Lab 03** · 완성 | 펼치고 모으기 (1 → 3 → 1) | 두 길을 Merge로 | **40분** · 내 워크플로우 완성 + 확인 필요 | + +예제는 따라 하기, 과제는 생각하기 · 환경: 65일차 n8n · Credential `OpenAI (.env)` 그대로 · 막히면 `n8n/workflows`의 파일과 비교 + +--- + +## 15 / 15 · 7교시 퀴즈 · 그리고 내일 + +| A · 기본 | B · 응용 | C · 심화 | +| :-: | :-: | :-: | +| 데이터 노드 · 제어 구조 용어 | 항목 수 · 표현식 · 노드 고르기 | 흐름 오류 찾기 · 설계 판단 | + +- 단계별 10문항 · 20분 · 원하는 단계 선택 +- 마지막 주관식 '더 알고 싶은 것'은 선택 · 점수 없음 + +> **67일차 예고** +> AI Agent가 Tool을 골라 쓰는 **Agent·Tool·데이터 연계** +> 실행 결과 검증 + +### *오늘의 한 문장: 연결선마다 무엇이 몇 개 흐르는지 알면, 워크플로우는 틀리지 않는다* diff --git a/Lecture/66/00-DAY66-PREP.md b/Lecture/66/00-DAY66-PREP.md new file mode 100644 index 0000000..e00e931 --- /dev/null +++ b/Lecture/66/00-DAY66-PREP.md @@ -0,0 +1,80 @@ +# 66일차 실습 준비 · 수업 전에 미리 해 두면 좋은 것 + +- 수업일: 2026-10-16 (66일차) · 단일 업무 자동화 워크플로우 직접 만들기 +- 준비 시간: 약 20분 (새로 받을 이미지·모델 없음) +- 먼저 WSL에서 `cd ~/aio-01d4-stu && git pull`로 오늘 자료를 받습니다 (수강생 저장소: https://gl.isitsoft.kr/publicproj/aio-01d4-stu). +- **수업 전날까지** 1~2절을 끝내 주세요. 3~4절은 시간이 될 때 읽어 오면 좋습니다. + +```mermaid +flowchart LR + A["1. 어제 환경 켜기
n8n · .env 키"] --> B["2. 확인
pytest · OpenAI · n8n 로그인"] + B --> C["3. 미리 읽기
JSON 객체·목록"] + C --> D["4. 생각해 오기
항목 수가 바뀌는 곳"] +``` + +--- + +## 1. 어제 환경 다시 켜기 (전날 필수, 5분) + +오늘은 65일차에 만든 n8n(Credential `OpenAI (.env)`)과 `Last3Mon/.env`를 그대로 씁니다. Ollama는 대체용으로 함께 켜 둡니다. + +```bash +sudo systemctl stop ollama # WSL의 Ollama가 켜져 있다면 멈춤 +cd ~/aio-01d4-stu/Last3Mon/65/n8n +docker compose start +docker compose ps # n8n, ollama 모두 running +``` + +브라우저에서 http://localhost:5678 에 로그인합니다. 65일차 준비를 하지 못했다면 [n8n 준비 안내](../../Last3Mon/65/SETUP-N8N.md)를 먼저 끝내 주세요. + +## 2. 실습 엔진 확인 (전날 필수, 5분) + +```bash +cd ~/aio-01d4-stu/Last3Mon/66/workflow-lab +uv sync +uv run pytest -q # 43 passed, 1 skipped +``` + +--- + +## 3. 미리 읽어 오면 좋은 내용 (선택, 약 10분) + +### 3.1 JSON의 객체와 목록 + +| 모양 | 이름 | 예 | +| --- | --- | --- | +| `{ }` | 객체 (이름: 값의 묶음) | `{"task": "보고서 작성", "owner": "박서연", "due": "월요일"}` | +| `[ ]` | 목록 (값을 순서대로) | `[{...}, {...}, {...}]` | +| `"..."` | 문자열 (글자) | `"{\"task\": \"보고서\"}"` ← JSON처럼 보이지만 **글자** | + +오늘 LLM은 할 일 목록을 **글자**로 돌려줍니다. 이 글자를 실제 목록으로 바꾸는 작업을 **파싱**이라고 합니다. 64일차 준비 안내의 JSON 기초도 함께 보세요. + +### 3.2 오늘 처음 쓰는 n8n 노드 + +| 노드 | 한 줄 설명 | +| --- | --- | +| Edit Fields (Set) | 필드를 만들고 값을 계산 | +| Code | JavaScript로 데이터 가공 | +| Split Out | 목록 하나를 항목 여러 개로 펼침 | +| Aggregate | 항목 여러 개를 목록 하나로 모음 | +| Merge | 두 갈래의 결과를 하나로 합침 | + +--- + +## 4. 생각해 오기 (선택, 5분) + +회의록 1건에서 할 일 4건이 나왔습니다. 할 일마다 우선순위를 정한 뒤 다시 하나의 결과로 돌려주려면, 데이터의 개수가 어떻게 바뀌어야 할까요? + +```text +회의록 1건 → ? → ? → 결과 1건 +``` + +## 5. 당일 준비물 + +- 충전된 노트북과 충전기, WSL의 Docker, Windows Terminal(WSL), 웹 브라우저 + +## 확인 + +- [ ] `docker compose ps` → n8n, ollama running +- [ ] `uv run pytest -q` → `43 passed, 1 skipped` +- [ ] JSON 객체·목록·문자열의 차이를 읽었다 diff --git a/Lecture/66/01-STAGE1-DESIGN-AND-DATA-FLOW.md b/Lecture/66/01-STAGE1-DESIGN-AND-DATA-FLOW.md new file mode 100644 index 0000000..3a4bc29 --- /dev/null +++ b/Lecture/66/01-STAGE1-DESIGN-AND-DATA-FLOW.md @@ -0,0 +1,260 @@ +# 1단계 · 단일 업무 자동화 설계와 데이터 흐름 + +- 시간: 1~2교시 (1교시 앞부분은 [오늘의 개요](00-DAY66-OVERVIEW.md)) +- 학습 목표 + - 하나의 업무를 목적 하나로 좁히고, 결과를 먼저 그려 볼 수 있다. + - 노드 사이를 지나가는 **항목(item)** 의 개수를 읽고 쓸 수 있다 (1 → 4 → 1). + - LLM이 할 일과 규칙으로 할 일을 나눌 수 있다. + - 조건 분기·반복·병렬 중 어떤 제어 구조가 필요한지 고를 수 있다. + - 요청이 바뀌었을 때 **어느 노드를 고치면 되는지** 찾을 수 있다. +- 실습: [Lab 01 · 회의록 자동 정리 워크플로우 설계하기 (예제 2개 + 과제)](../../Last3Mon/66/LAB-01-DESIGN-DATA-FLOW.md) + +```mermaid +flowchart LR + A["1. 오늘의 업무"] --> B["2. 결과 먼저"] --> C["3. 항목 읽기
(Lab 01 예제 1)"] --> D["4. 1 → 4 → 1
(Lab 01 예제 2)"] --> E["5. 제어 구조"] --> F["6. 요청이 바뀌면
(Lab 01 과제)"] +``` + +오늘 수업은 모두 같은 순서로 갑니다. **설명 → 쉬운 예제 2개를 함께 → 그것을 고치는 과제를 혼자**. 이 교안의 절도 Lab의 예제·과제 순서에 맞춰 놓았습니다. + +--- + +## 1. 오늘의 업무: 회의록 → 할 일 정리 + +64일차 시나리오 S1 "회의록 요약 후 할 일 정리"를 n8n으로 만듭니다. + +| 지금 (AS-IS) | 자동화 후 (TO-BE) | +| --- | --- | +| 누군가 회의록을 읽고 할 일·담당자·기한을 찾아 정리 | 회의록을 보내면 요약·할 일 목록·정리문이 바로 나옴 | +| 담당자나 기한이 빠진 일을 놓침 | 빠진 값은 `미정`으로 보임 | +| 급한 일이 묻힘 | 오늘·내일 마감은 우선순위 `높음` | + +### 1.1 목적 하나로 좁히기 + +처음 만드는 자동화는 **목적 하나**만 해결합니다. 메일 발송·일정 등록까지 한 번에 넣으면 어디가 틀렸는지 찾기 어렵습니다. + +| 질문 | 오늘의 답 (한 문장씩) | +| --- | --- | +| 목적 | 회의록을 받으면 할 일 목록과 정리문을 돌려준다 | +| 입력 → 출력 | `{title, notes}` → `{title, route, count, summary, actions, report_text}` | +| 성공 기준 | 예시 회의록(`weekly`)에서 할 일 4건, 빠진 값은 `미정`, 오늘 마감은 `높음` | + +세 줄이 한 문장씩으로 끝나지 않으면 아직 범위가 넓은 것입니다. 회의록이 **비어 있으면** LLM을 부르기 전에 맨 앞에서 거절(HTTP 400)합니다. 쓸데없는 호출과 비용을 막는 가장 싼 방법입니다. + +--- + +## 2. 결과를 먼저 그려 보기 + +노드를 생각하기 전에 **예시 입력 한 건과 기대하는 결과**를 손으로 적습니다. + +> 참석: 김민지, 이준호, 박서연. 1) 신규 고객 문의 응답 템플릿을 이준호가 금요일까지 정리한다. 2) 박서연은 다음 주 월요일까지 VPN 장애 보고서를 작성한다. 3) 서버 점검 일정은 김민지가 오늘 중 공지한다. 4) 회의실 예약 규칙 개선은 담당자를 다음 회의에서 정한다. 다음 회의는 10월 23일. + +| # | 할 일 | 담당자 | 기한 | 우선순위 | +| --- | --- | --- | --- | --- | +| 1 | 신규 고객 문의 응답 템플릿 정리 | 이준호 | 금요일 | 보통 | +| 2 | VPN 장애 보고서 작성 | 박서연 | 다음 주 월요일 | 보통 | +| 3 | 서버 점검 일정 공지 | 김민지 | 오늘 | **높음** | +| 4 | 회의실 예약 규칙 개선 담당자 선정 | **미정** | 10월 23일 | 보통 | + +이 표를 채우다 보면 일이 두 종류로 나뉩니다. + +| 누가 | 하는 일 | 왜 | +| --- | --- | --- | +| **LLM** | 문장 속에서 할 일·담당자·기한 **찾기**, 요약 | 사람 말을 읽어야 함 | +| **규칙** | 빈 값을 `미정`으로 **채우기**, 우선순위 **정하기**, 정리문 **만들기** | 항상 같은 답이어야 함 | + +> 4번의 기한은 회의록에 직접 적혀 있지 않지만, 수업 전 측정(`gpt-4.1-mini`)에서 LLM은 매번 "다음 회의는 10월 23일"을 읽고 `10월 23일`로 채웠습니다. 담당자는 적혀 있지 않아 `미정`입니다. LLM이 **추론한 값**을 받아들일지도 설계에서 정할 일입니다. + +### 2.1 결과 형식을 먼저 약속하기 + +결과를 받는 쪽(사람, 다른 시스템, 65일차의 플랫폼 전환 코드)이 이 모양에 맞춰 만들어지므로, 노드를 만들기 **전에** 정합니다. + +```json +{"title": "10월 16일 주간 회의", "route": "할 일 정리", "count": 4, "summary": "요약 2문장", + "actions": [{"task": "...", "owner": "...", "due": "...", "priority": "높음 | 보통"}], + "report_text": "공유용 정리문"} +``` + +할 일이 없어도 **같은 필드**를 쓰고 `route: 할 일 없음`, `count: 0`, `actions: []`로 둡니다 (65일차 "모든 길이 같은 모양으로 끝나게"). + +--- + +## 3. 항목(item) 읽기 · Lab 01 예제 1 + +n8n에서 노드와 노드 사이를 지나가는 데이터 한 덩어리를 **항목(item)** 이라고 합니다. 택배 상자 하나라고 생각하면 됩니다. 상자 안에는 필드(`user`, `text` ...)가 들어 있습니다. + +어제 Lab 02A에서 만든 흐름을 보면서 상자가 몇 개 지나가는지 셉니다. + +```mermaid +flowchart LR + T(["Start"]) -->|"1"| S["Sample request
Edit Fields"] -->|"1"| I{"Is urgent?
If"} + I -->|"true · 1"| U["Urgent route"] + I -.->|"false · 0"| N["Normal route"] +``` + +- 모든 연결선이 **1개**입니다. If는 상자를 **한쪽 길로 보낼 뿐** 개수를 바꾸지 않습니다. +- 개수를 바꾸는 노드는 따로 있습니다. 오늘 새로 쓰는 노드입니다. + +| 노드 | 하는 일 | 항목 수 | +| --- | --- | --- | +| Edit Fields (Set) | 필드 만들기·값 계산 | 그대로 | +| If · Switch | 길 나누기 | 그대로 (한쪽으로) | +| Code | 무엇이든 (파싱·검증) | 코드가 정함 | +| **Split Out** | 상자 속 목록을 **상자 여러 개로 펼침** | **1 → N** | +| **Aggregate** | 상자 여러 개를 **목록 하나로 모음** | **N → 1** | +| **Merge** | 두 길에서 온 상자를 **합침** | 둘 → 하나 | +| Filter | 조건에 맞는 상자만 남김 | 줄어듦 | + +--- + +## 4. 오늘 흐름의 항목 수 1 → 4 → 1 · Lab 01 예제 2 + +회의록 1건이 할 일 4건이 되었다가 다시 결과 1건이 됩니다. + +```mermaid +flowchart LR + A["회의록
상자 1개"] -->|"LLM + 파싱"| B["상자 1개
안에 actions 목록 [4]"] -->|"Split Out
1 → 4"| C["할 일
상자 4개"] -->|"항목마다 정리"| D["정리된 할 일
상자 4개"] -->|"Aggregate
4 → 1"| E["결과
상자 1개"] +``` + +가장 자주 헷갈리는 곳은 **B**입니다. 할 일이 4건이라도 파싱 직후에는 **상자 1개 안에 목록으로** 들어 있습니다. Split Out을 지나야 비로소 상자 4개가 됩니다. + +오늘 완성할 전체 그림과 각 노드의 역할입니다. (오후 Lab 03 과제의 완성 그림과 같습니다.) + +```mermaid +flowchart LR + W(["Meeting webhook"]) --> S["Settings"] --> H{"Has notes?"} + H -->|false| R0["Reject empty
400"] + H -->|true| L1{{"LLM extract actions"}} --> P["Parse actions"] --> J["Join results
Merge"] + H -->|true| L2{{"LLM summarize"}} --> K["Keep summary"] --> J + J --> A{"Any actions?"} + A -->|true| SO["One item per action
Split Out"] --> N["Normalize action"] --> AG["Collect actions
Aggregate"] --> B["Build report"] --> R["Send result"] + A -->|false| NA["No actions"] --> R +``` + +| 설계에서 | n8n 노드 | 언제 만드나 | +| --- | --- | --- | +| 회의록 받기, 설정 모으기, 비었으면 거절 | Webhook, Edit Fields, If, Respond | Lab 02 예제 2 (가져옴) | +| 할 일 찾기 (LLM) → 글자를 목록으로 | HTTP Request, Code | Lab 02 예제 1·2 | +| 요약 (LLM)과 합치기 | HTTP Request, Edit Fields, Merge | Lab 03 예제 2 → 과제 | +| 할 일마다 우선순위 → 모으기 → 정리문 | Split Out, Edit Fields, Aggregate, Code | Lab 03 예제 1 → 과제 | +| 할 일이 없을 때 | If, Edit Fields | Lab 03 과제 | + +### 4.1 연결선 표에 적는 세 가지 + +| 적는 것 | 예 | 빠뜨리면 | +| --- | --- | --- | +| **항목 수** | Split Out 뒤 4 | 응답이 할 일 하나만 나가는 것을 늦게 알아챔 | +| **필드 이름** | `actions`, `count`, `summary` | 표현식(`{{ $json.actions }}`)에 없는 이름 → 빈 값 | +| **형식** | LLM 바로 뒤는 **글자** | LLM 답을 목록으로 착각 → 2단계의 파싱이 필요 | + +--- + +## 5. 제어 구조 고르기 + +질문 세 개로 고릅니다. + +| 질문 | 예이면 | n8n | 오늘의 예 | +| --- | --- | --- | --- | +| 입력에 따라 **다른 결과**를 내야 하나? | 조건 분기 | If, Switch | 내용 없음 → 거절, 할 일 없음 → 다른 결과 | +| 목록의 **항목마다** 같은 일을 하나? | 반복 | Split Out → … → Aggregate | 할 일마다 우선순위 | +| 서로의 **결과가 필요 없는** 일이 둘 이상인가? | 병렬 (Fork-Join) | 가지 나누기 + Merge | 할 일 찾기와 요약 | + +### 5.1 연습 + +| 업무 | 제어 구조 | +| --- | --- | +| 문의 메일 50통을 각각 분류 | 반복 | +| 리뷰의 감정 분석과 키워드 추출 | 병렬 | +| 감정이 부정이면 담당자에게, 아니면 자동 답글 | 조건 분기 | +| 회의록 요약을 만든 뒤 그 요약으로 제목 짓기 | **순서** (제목에 요약 결과가 필요 → 병렬 불가) | +| 출장자마다 날씨 조회 후 우산 필요 여부 판단 | 반복 + 조건 분기 | + +실제 업무는 여러 구조가 섞이고, **앞 결과가 필요하면 병렬로 나눌 수 없습니다.** + +--- + +## 6. 요청이 바뀌면 어디를 고치나 · Lab 01 과제 + +설계를 해 두면 요청이 바뀌었을 때 고칠 곳을 금방 찾습니다. 찾는 순서는 세 질문입니다. + +```mermaid +flowchart LR + Q1{"LLM이 할 일?
규칙이 할 일?"} --> Q2{"할 일 하나마다?
모두 모은 뒤?"} --> Q3{"모든 길이
같은 필드로 끝나나?"} +``` + +| 질문 | 고칠 곳 | +| --- | --- | +| 할 일 **하나마다** 정하는 값인가? | 상자가 하나씩 흐르는 곳 = Split Out과 Aggregate **사이** | +| **모두 모은 뒤**에야 알 수 있는 값인가? (개수, 합계) | Aggregate **뒤** | +| 결과에 필드가 늘었나? | **모든 길**의 끝 (할 일 없음 길 포함) | + +연습 삼아 이미 있는 값으로 확인해 봅니다. + +| 값 | 하나마다? 모은 뒤? | 그래서 만드는 곳 | +| --- | --- | --- | +| `priority` (오늘·내일이면 높음) | 하나마다 | `Normalize action` | +| `count` (할 일 개수) | 모은 뒤 | `Build report` | +| `route` | 길마다 | `Build report` / `No actions` | + +Lab 01 과제는 이 방법으로 "확인 필요" 요청을 설계하고, 오후 Lab 03 과제에서 실제로 만듭니다. + +--- + +## 7. Lab 01 안내 + +- 실습 안내: [Last3Mon/66/LAB-01-DESIGN-DATA-FLOW.md](../../Last3Mon/66/LAB-01-DESIGN-DATA-FLOW.md) (22분, 활동지 `templates/LAB-01-WORKSHEET.md`) + +| 순서 | 시간 | 할 일 | 교안 | +| --- | --- | --- | --- | +| 예제 1 | 5분 | 어제 흐름의 연결선 항목 수 읽기 | 3절 | +| 예제 2 | 7분 | 오늘 흐름의 빈칸 다섯 개 (1 → 4 → 1, 병렬 이유, LLM이 하는 일) | 2·4·5절 | +| 과제 | 10분 | "담당자·기한이 없는 할 일은 **확인 필요**로, 몇 건인지도" → 어디를 고치나 | 6절 | + +- 전체 설계서(`templates/DATA-FLOW-DESIGN-TEMPLATE.md`)는 오늘 복습 과제에서 **내 업무**로 써 봅니다. + +--- + +## 8. 자주 하는 설계 실수 + +| 실수 | 증상 | 고치는 법 | +| --- | --- | --- | +| 한 번에 알림·일정까지 넣음 | 어디가 틀렸는지 찾기 어려움 | 목적 하나로, 나머지는 "나중에" | +| LLM에게 우선순위·기본값까지 맡김 | 같은 회의록인데 결과가 매번 다름 | 찾기는 LLM, 채우기·계산은 규칙 | +| 항목 수를 안 적음 | 응답에 할 일이 하나만 | 1 → N → 1 표시 | +| 결과가 필요한 일을 병렬로 | 요약 전에 제목을 지으려 함 | "앞 결과가 필요한가?" | + +--- + +## 9. 스스로 확인하기 + +
Q1. If 노드를 지나면 항목 수는 어떻게 되나요? + +그대로입니다. If는 항목을 true나 false 한쪽 길로 보낼 뿐 개수를 바꾸지 않습니다. 개수를 바꾸는 노드는 Split Out(1 → N), Aggregate(N → 1) 등입니다. +
+ +
Q2. 할 일이 4건인데 `Parse actions` 바로 뒤의 항목 수가 1인 이유는? + +4건이 `actions`라는 **목록 하나**에 담겨 상자 1개로 지나가기 때문입니다. Split Out을 지나야 상자 4개가 됩니다. +
+ +
Q3. 우선순위를 LLM이 아니라 규칙으로 정하는 이유는? + +"기한에 오늘·내일·긴급이 있으면 높음"은 규칙으로 항상 같게 정할 수 있습니다. LLM에 맡기면 결과가 흔들리고 시간·비용이 듭니다. +
+ +
Q4. "요약을 만든 뒤 그 요약으로 제목 짓기"를 병렬로 나눌 수 없는 이유는? + +제목 짓기가 요약 결과를 필요로 하기 때문입니다. 병렬은 서로의 결과가 필요 없을 때만 가능합니다. +
+ +
Q5. 할 일 개수(`count`)를 `Normalize action`이 아니라 `Build report`에서 세는 이유는? + +`Normalize action`은 할 일 하나마다 따로 실행되어 전체 개수를 모릅니다. 모두 모은(Aggregate) 뒤에야 셀 수 있습니다. +
+ +## 정리 + +- 처음 만드는 자동화는 목적 하나로 좁히고, 예시 입력의 기대 결과를 먼저 그립니다. +- LLM은 **찾기**, 규칙은 **채우기·계산·정리문**을 맡습니다. 결과 형식은 노드보다 먼저 약속합니다. +- 노드 사이를 지나가는 상자(항목)의 개수를 셉니다. 오늘 흐름은 **1 → 4 → 1**입니다. +- 결과가 필요 없는 일은 병렬, 항목마다 하는 일은 반복, 입력에 따라 다른 일은 조건 분기입니다. +- 요청이 바뀌면 "하나마다? 모은 뒤? 모든 길?"로 고칠 곳을 찾습니다. diff --git a/Lecture/66/02-STAGE2-PARSE-AND-TRANSFORM.md b/Lecture/66/02-STAGE2-PARSE-AND-TRANSFORM.md new file mode 100644 index 0000000..97003c5 --- /dev/null +++ b/Lecture/66/02-STAGE2-PARSE-AND-TRANSFORM.md @@ -0,0 +1,272 @@ +# 2단계 · 데이터 파싱과 변환 노드 + +- 시간: 3~4교시 (3교시 끝에 Lab 02 예제 1, 4교시에 예제 2와 과제) +- 학습 목표 + - LLM의 답은 JSON처럼 **보이는 글자**라는 것을 OUTPUT에서 구별할 수 있다. + - Code 노드로 글자를 목록으로 파싱하고, 빈 값을 채우고, 실패 이유를 남길 수 있다. + - 지시문과 JSON 스키마로 LLM에게 답의 모양을 알려 줄 수 있다. + - 뽑을 정보를 하나 늘릴 때 고칠 곳(알려 주기 · 받기 · 꺼내기)을 찾을 수 있다. + - Edit Fields와 표현식으로 간단한 변환을 할 수 있다. +- 실습: [Lab 02 · LLM 답을 데이터로 바꾸기 (예제 2개 + 과제)](../../Last3Mon/66/LAB-02-BUILD-PARSE-TRANSFORM.md) + +```mermaid +flowchart LR + A["1. LLM 답은 글자
(예제 1)"] --> B["2. 파싱
(예제 1)"] --> C["3. 모양 알려 주기
(예제 2)"] --> D["4. 하나 더 뽑기
(과제)"] --> E["5. Edit Fields"] --> F["6. 만들고 확인하는 순서"] +``` + +--- + +## 1. LLM의 답은 "글자"다 · Lab 02 예제 1 + +LLM에게 "JSON으로 답해"라고 하면 이런 답이 옵니다. + +```text +{"actions": [{"task": "신규 고객 문의 응답 템플릿 정리", "owner": "이준호", "due": "금요일"}, {"task": "회의실 예약 규칙 개선", "owner": "", "due": ""}]} +``` + +사람 눈에는 목록처럼 보이지만, n8n에게는 **따옴표로 둘러싸인 긴 글자 한 줄**입니다. 글자 상태로는 `actions`를 꺼낼 수도, 할 일 개수를 셀 수도 없습니다. + +| 상태 | n8n OUTPUT에서 보이는 모습 | 할 수 있는 것 | +| --- | --- | --- | +| **글자** (문자열) | `content` 한 칸에 긴 글자 | 읽기만 | +| **데이터** (객체·목록) | Table 보기에서 `actions`가 줄줄이 펼쳐짐 | `$json.actions`, `count`, Split Out | + +글자를 데이터로 바꾸는 일을 **파싱(parsing)** 이라고 합니다. 27일차 Structured Output에서 Pydantic으로 하던 일을 오늘은 Code 노드가 합니다. + +예제 1에서는 LLM을 부르지 않고 위 글자를 `Fake LLM answer` 노드에 **직접 적어 넣고** 파싱부터 연습합니다. 돈도 시간도 들지 않고 매번 같은 결과가 나와서, 파싱만 따로 이해하기 좋습니다. + +```mermaid +flowchart LR + T(["Start"]) --> F["Fake LLM answer
content = 글자"] --> P["Parse actions
글자 → 목록"] +``` + +--- + +## 2. 파싱: 글자를 목록으로 · Lab 02 예제 1 + +`Parse actions`의 코드는 네 단계입니다. 한 줄씩 외울 필요는 없고 **번호의 뜻**만 알면 됩니다. + +```javascript +const raw = String($input.first().json.content ?? '').trim().replace(/^```(json)?\s*|```$/g, ''); // ① 글자 꺼내기 +let data; +try { + data = JSON.parse(raw); // ② 글자 → 데이터 +} catch (error) { + return [{ json: { actions: [], count: 0, error: 'answer is not valid JSON' } }]; // 실패해도 멈추지 않고 이유를 남김 +} +const items = Array.isArray(data) ? data : (data.actions ?? []); +const actions = items + .filter(item => item && String(item.task ?? '').trim()) // ③ 빈 할 일 버리기 + .map(item => ({ + task: String(item.task).trim(), + owner: String(item.owner || '미정').trim() || '미정', // ④ 빈 값은 '미정' + due: String(item.due || '미정').trim() || '미정', + })); +return [{ json: { actions, count: actions.length } }]; // 항상 같은 모양으로 돌려줌 +``` + +| 번호 | 하는 일 | 예제 1에서 확인 | +| --- | --- | --- | +| ① | 앞 노드에서 글자를 꺼냄 (모델이 ```json 으로 감싸면 벗김) | `content` | +| ② | 글자 → 데이터. 실패하면 빈 목록 + `error` | 끝 `}`를 지우면 `error: answer is not valid JSON` | +| ③ | 할 일이 빈 칸인 것은 버림 | | +| ④ | 담당자·기한이 비면 `미정` | 두 번째 할 일의 `owner`·`due`가 `미정` | + +예제 2에서는 ①만 바뀝니다. 가짜 답 대신 진짜 LLM 답이 있는 곳 `choices[0].message.content`를 읽습니다. + +### 2.1 어떤 답이 와도 같은 모양으로 + +| LLM 답 | 그대로 `JSON.parse` | 오늘 코드의 결과 | +| --- | --- | --- | +| `{"actions": [...]}` | 성공 | 그대로 | +| ```` ```json {"actions": [...]} ``` ```` | 실패 | ①에서 감싼 표시를 벗기고 성공 | +| `[{"task": ...}]` (목록만) | 성공 | 목록 그대로 사용 | +| `할 일은 다음과 같습니다 ...` (문장) | 실패 | `{actions: [], count: 0, error: ...}` | +| `{"actions": [{"task": "", ...}]}` | 성공 | 빈 할 일 버림 | +| `{"owner": null}` | 성공 | `owner: "미정"` | + +마지막 열이 모두 `{actions, count}` 모양입니다. 다음 노드는 어떤 답이 와도 같은 필드를 받습니다. + +### 2.2 실패를 숨기지 않기 + +| 결과 | `count` | `error` | 뜻 | +| --- | --- | --- | --- | +| 할 일 없는 회의 (`no-action`) | 0 | 없음 | 정상 | +| LLM 답이 깨짐 | 0 | `answer is not valid JSON` | 문제 → 실행 기록 확인 | + +`error` 한 칸이 "정말 할 일이 없는 회의"와 "답이 깨진 경우"를 구별합니다. 76일차(예외 대응)에서 이런 경우를 모아 알림을 보내는 Error Workflow를 만듭니다. + +--- + +## 3. LLM에게 답의 모양 알려 주기 · Lab 02 예제 2 + +예제 2는 예제 1의 앞에 **Webhook과 진짜 LLM**이 붙은 워크플로우를 가져와 실행합니다. + +```mermaid +flowchart LR + W(["Meeting webhook"]) --> S["Settings"] --> H{"Has notes?"} + H -->|false| R0["Reject empty
400"] + H -->|true| L{{"LLM extract actions"}} --> P["Parse actions"] --> R["Send result"] +``` + +`LLM extract actions` 노드의 JSON 칸은 세 부분입니다. (실습 파일은 읽기 쉽게 여러 줄로 적혀 있습니다.) + +```text +{{ JSON.stringify({ + model: $('Settings').first().json.model, + temperature: 0, + response_format: { type: 'json_schema', json_schema: { name: 'reply', schema: { ← ① 스키마: 답의 모양 + type: 'object', + properties: { + actions: { type: 'array', items: { type: 'object', + properties: { task: { type: 'string' }, owner: { type: 'string' }, due: { type: 'string' } }, + required: ['task', 'owner', 'due'] } }, + }, + required: ['actions'], + } } }, + messages: [ + { role: 'system', content: "Extract action items ... Use \"미정\" when ..." }, ← ② 지시문: 무엇을 찾을지 + { role: 'user', content: $('Settings').first().json.notes }, ← ③ 회의록 원문 + ], +}) }} +``` + +| 부분 | 하는 일 | 비유 | +| --- | --- | --- | +| ① 스키마 (`response_format`) | "이 칸들로만 답해라" — 답의 **모양**을 정함 | 빈칸이 정해진 답안지 | +| ② 지시문 (`system`) | 무엇을 찾고, 없으면 무엇을 쓸지 | 문제 설명 | +| ③ 회의록 (`user`) | 읽을 글 | 지문 | + +### 3.1 스키마는 모양만 지킨다 + +| | 지시문만 | 지시문 + 스키마 | +| --- | --- | --- | +| 모양 | 가끔 어긋남 (설명 문장, ```json) | 정한 모양으로만 | +| 필드 이름 | 가끔 `assignee`, `deadline` | 스키마 이름 그대로 | +| **내용이 맞나** | 보장 안 함 | **보장 안 함** | + +스키마를 써도 담당자를 잘못 찾거나 할 일을 빠뜨리는 것은 막지 못합니다. 4번 할 일의 기한 `10월 23일`(회의록에 직접 적히지 않은 추론)이 그 예입니다. 그래서 스키마를 써도 2절의 파싱·검증은 그대로 둡니다. 그리고 답은 여전히 **글자**(`choices[0].message.content`)로 옵니다. + +Anthropic·Gemini에도 비슷한 구조화 출력 기능이 있고, 대체용 Ollama도 같은 `response_format`을 받습니다. + +> **표현식 주의**: `{{ }}` 안에서 `}`와 `}`가 **붙으면** 표현식이 거기서 끝나 `invalid syntax`가 납니다. 실습 파일에서 `} }`처럼 띄어 쓴 곳은 일부러 띄운 것입니다. + +--- + +## 4. 뽑을 것을 하나 늘리려면 · Lab 02 과제 + +새 정보(예: 참석자)가 결과까지 오려면 데이터가 지나가는 길을 따라 **세 곳**이 모두 알아야 합니다. + +```mermaid +flowchart LR + A["① 알려 주기
스키마에 칸 추가"] --> B["② 부탁하기
지시문에 한 문장"] --> L{{"LLM 답
(글자)"}} --> C["③ 꺼내기
파싱 코드의 return"] --> R["결과"] +``` + +| 곳 | 빠뜨리면 | +| --- | --- | +| ① 스키마 | LLM이 그 칸을 만들지 않을 수 있음 | +| ② 지시문 | 무엇을 넣을지 몰라 비우거나 엉뚱한 값 | +| ③ 파싱 코드 | LLM 답에는 있어도 **새 상자를 만들며 버려짐** → 결과에 없음 | + +③이 가장 잘 빠집니다. 파싱 코드는 `return [{ json: { actions, count } }]`처럼 **필요한 필드만 골라 새 상자를 만들기** 때문입니다. 어디서 사라졌는지 모를 때는 노드를 하나씩 열어 OUTPUT에 그 필드가 있는지 봅니다. + +뽑을 것을 늘린 뒤에는 **원래 기대 결과(할 일 4건)도 다시** 확인합니다. 요청이 바뀌면 원래 뽑던 결과도 흔들릴 수 있습니다. + +--- + +## 5. 변환: Edit Fields와 표현식 + +코드 없이 필드를 만들거나 값을 계산할 때는 Edit Fields(Set)를 씁니다. 예제 2의 `Settings`가 그 예입니다. + +| 하고 싶은 일 | 설정 예 | +| --- | --- | +| 입력값 꺼내기 | `notes` = `{{ $json.body.notes }}` (웹훅 입력은 `body` 안에) | +| 비었으면 기본값 | `title` = `{{ $json.body.title \|\| '제목 없음' }}` | +| 공백 다듬기 | `summary` = `{{ $json.choices[0].message.content.trim() }}` | +| 조건으로 값 정하기 | `priority` = `{{ ['오늘', '내일', '긴급'].some(w => $json.due.includes(w)) ? '높음' : '보통' }}` | +| 빈 목록 | `actions` (Array) = `{{ [] }}` | + +- `{{ }}`가 들어간 값은 입력칸이 **Expression** 모드(초록색)여야 합니다. Fixed 모드면 글자 그대로 들어갑니다. +- 필드 형식(String·Number·Array·Boolean)을 맞춥니다. 예를 들어 `count`가 글자 `"0"`이면 숫자 비교가 원하는 대로 되지 않을 수 있습니다. + +| Edit Fields | Code | +| --- | --- | +| 필드 몇 개를 만들거나 바꿀 때 | 파싱, 여러 줄 검증, 실패 처리 (`try / catch`) | +| 누구나 읽기 쉬움 | 자유롭지만 실수하기 쉬움 | + +--- + +## 6. 만들고 확인하는 순서 + +```mermaid +flowchart LR + A["노드 하나 추가"] --> B["이름 짓기
(표현식이 이름을 씀)"] --> C["설정"] --> D["실행"] --> E["OUTPUT 확인"] + E -->|"다음 노드"| A +``` + +- **한 번에 다 만들지 않습니다.** 노드 하나를 붙일 때마다 실행해 OUTPUT을 봅니다. +- 노드 이름을 안내와 똑같이 짓습니다. `$('Settings')`처럼 표현식이 이름을 씁니다. +- 같은 요청 파일(`weekly`·`no-action`·`empty`)로 매번 확인해야 고친 것의 효과를 비교할 수 있습니다. +- Test URL은 **한 번에 한 요청**만 받습니다. 요청마다 `Listen for test event`를 다시 누릅니다. + +--- + +## 7. 막혔을 때 + +| 증상 | 원인 | 해결 | +| --- | --- | --- | +| `invalid syntax` | 표현식 안에서 `}}`가 붙음 | `} }`로 띄어 쓰기 | +| `401` | Credential이 `OpenAI (.env)`가 아님 | Authentication 확인 | +| 값에 `{{ $json.body.notes }}` 글자가 그대로 | Fixed 모드 | Expression 모드 | +| `actions`가 비고 `error`가 있음 | LLM 답이 JSON이 아님 | 앞 노드 OUTPUT의 `content` 확인 | +| 새 필드가 결과에 없음 | 파싱 코드의 `return`에 안 넣음 | 4절 ③ | +| 고치다 망가짐 | | 예제 2 파일을 다시 가져오기 | + +--- + +## 8. Lab 02 안내 + +- 실습 안내: [Last3Mon/66/LAB-02-BUILD-PARSE-TRANSFORM.md](../../Last3Mon/66/LAB-02-BUILD-PARSE-TRANSFORM.md) + +| 순서 | 시간 | 할 일 | 교안 | +| --- | --- | --- | --- | +| 예제 1 | 3교시 끝 15분 (함께) | `Fake LLM answer`(글자) → `Parse actions`(목록), 끝 `}`를 지워 `error` 보기 | 1~2절 | +| 예제 2 | 4교시 15분 | `lab02-example2-meeting-part1.json` 가져오기 → `weekly`·`empty`·`no-action`, JSON 칸의 ①②③ 찾기 | 3절 | +| 과제 | 4교시 25분 | 결과에 **참석자(`attendees`)** 추가 | 4절 | + +--- + +## 9. 스스로 확인하기 + +
Q1. `Fake LLM answer`의 `content`와 `Parse actions`의 `actions`는 무엇이 다른가요? + +`content`는 글자 한 줄이고, `actions`는 파싱해서 만든 목록입니다. 목록이어야 개수를 세고 Split Out으로 펼칠 수 있습니다. +
+ +
Q2. 스키마(`response_format`)를 지정했는데도 파싱·검증이 필요한 이유는? + +스키마는 모양만 지켜 줍니다. 답은 여전히 글자로 오므로 파싱이 필요하고, 빈 값이나 잘못 찾은 내용은 검증으로 걸러야 합니다. +
+ +
Q3. 파싱에 실패하면 빈 목록을 돌려주면서도 `error`를 남기는 이유는? + +흐름이 멈추지 않게 하면서도, "정말 할 일이 없는 회의"와 "LLM 답이 깨진 경우"를 구별하기 위해서입니다. +
+ +
Q4. 스키마와 지시문에 `attendees`를 넣었는데 결과에 없습니다. 어디를 보나요? + +`LLM extract actions`의 OUTPUT에는 있는데 `Parse actions`의 OUTPUT에 없다면, 파싱 코드의 `return`이 `attendees`를 넣지 않은 것입니다. 노드를 앞에서부터 하나씩 열어 어디서 사라졌는지 찾습니다. +
+ +
Q5. 기한을 스키마에서 날짜 형식이 아니라 글자(`string`)로 둔 이유는? + +회의록에는 "금요일", "다음 주 월요일"처럼 날짜가 아닌 표현이 많습니다. 날짜로 바꾸려면 회의 날짜를 아는 계산이 필요하므로, 필요하면 규칙으로 따로 합니다. +
+ +## 정리 + +- LLM의 JSON 답은 **글자**입니다. Code 노드로 파싱해야 목록이 됩니다. +- 파싱은 꺼내기 → 바꾸기(실패하면 `error`) → 빈 할 일 버리기 → `미정` 채우기, 그리고 항상 같은 모양으로 돌려줍니다. +- 스키마는 답의 **모양**, 지시문은 **무엇을 찾을지**를 알려 줍니다. 스키마도 내용은 보장하지 않습니다. +- 뽑을 것을 늘리면 **알려 주기(스키마) · 부탁하기(지시문) · 꺼내기(파싱)** 세 곳을 고칩니다. +- 간단한 변환은 Edit Fields, 파싱·검증은 Code. 노드 하나마다 실행해 OUTPUT을 확인합니다. diff --git a/Lecture/66/03-STAGE3-LOOP-PARALLEL-AND-FINISH.md b/Lecture/66/03-STAGE3-LOOP-PARALLEL-AND-FINISH.md new file mode 100644 index 0000000..da0d5f9 --- /dev/null +++ b/Lecture/66/03-STAGE3-LOOP-PARALLEL-AND-FINISH.md @@ -0,0 +1,244 @@ +# 3단계 · 반복·병렬 제어 구조와 완성 + +- 시간: 5~6교시 (5교시 후반에 Lab 03 예제 1·2, 6교시에 과제 40분), 4절 밖에서 부르기는 8교시 +- 학습 목표 + - 펼치기(Split Out) → 항목마다 처리 → 모으기(Aggregate)로 반복을 만들 수 있다. + - 두 길로 나눈 일을 Merge로 합치는 Fork-Join을 만들 수 있다. + - 연습용 가짜 데이터 자리에 진짜 LLM 결과를 넣어 워크플로우를 완성할 수 있다. + - 기대 결과 표와 실행 기록의 항목 수로 결과를 검증할 수 있다. +- 실습: [Lab 03 · 반복·병렬로 회의록 워크플로우 완성하기 (예제 2개 + 40분 과제)](../../Last3Mon/66/LAB-03-BUILD-LOOP-PARALLEL.md) + +```mermaid +flowchart LR + A["1. 반복
(예제 1)"] --> B["2. 병렬
(예제 2)"] --> C["3. 가짜를 진짜로
(과제)"] --> D["4. 기대 결과로 검증
(과제)"] --> E["5. 밖에서 부르기
(8교시)"] +``` + +--- + +## 1. 반복: 펼치고 · 항목마다 · 모으기 · Lab 03 예제 1 + +n8n의 노드는 **들어온 상자(항목)마다 한 번씩** 실행됩니다. 그래서 목록을 상자 여러 개로 펼치기만 하면 반복이 됩니다. 별도의 "for 문" 노드가 필요 없습니다. + +예제 1은 할 일 3건이 든 가짜 목록으로 연습합니다. + +```mermaid +flowchart LR + S["Sample actions
상자 1개
actions [3]"] -->|"1"| SO["One item per action
Split Out"] + SO -->|"3"| N["Normalize action
Edit Fields
(3번 실행)"] + N -->|"3"| AG["Collect actions
Aggregate"] + AG -->|"1"| OUT["상자 1개
actions [3] + priority"] +``` + +| 노드 | 설정 | 하는 일 | +| --- | --- | --- | +| Split Out | Fields To Split Out: `actions` | 목록 → 할 일 하나씩 상자로 (**1 → 3**) | +| Edit Fields | `task`, `owner`, `due`, `priority` | 상자마다 실행 (3번) | +| Aggregate | All Item Data, 결과 필드 `actions` | 상자들 → 다시 목록 하나 (**3 → 1**) | + +**펼쳤으면 반드시 모은다.** 이것이 반복의 짝입니다. 오후 과제에서는 진짜 회의록이라 같은 모양이 **1 → 4 → 1**이 됩니다. + +### 1.1 모으지 않으면? (5교시 시연, 수업 전 실측) + +| 틀린 곳 | `weekly` 결과 | +| --- | --- | +| `Normalize action` → 바로 응답 | HTTP 200인데 **첫 할 일 하나만** — 응답 노드는 첫 상자로 한 번만 답함 | +| Aggregate 없이 `Build report` | HTTP 500 `Error in workflow` — 목록을 기대했는데 할 일 하나짜리 상자가 옴 | + +첫 번째가 더 위험합니다. 오류 없이 **조용히 틀린 답**이 나갑니다. 그래서 실행 기록에서 노드마다 **항목 수**를 읽는 습관을 들입니다. + +### 1.2 상자마다 실행될 때 `$json`이 가리키는 것 + +| 위치 | `$json` | 다른 값이 필요하면 | +| --- | --- | --- | +| Split Out 뒤 (상자마다) | 할 일 **하나** (`task`, `owner`, `due`) | 회의 제목: `$('Settings').first().json.title` | +| Aggregate 뒤 (한 번) | 모은 결과 `{actions: [...]}` | 요약: `$('Join results').first().json.summary` | + +`$('노드 이름')`은 "그 노드의 OUTPUT을 가져와"라는 뜻입니다. 노드 이름을 안내와 똑같이 지어야 하는 이유입니다. + +### 1.3 Loop Over Items는 언제? + +| 상황 | 선택 | +| --- | --- | +| 상자마다 같은 변환만 (오늘) | Split Out + 일반 노드 | +| 상자마다 외부 API·LLM을 부르는데 한꺼번에 보내면 호출 제한에 걸림 | **Loop Over Items**로 몇 개씩 나눠 차례로 | + +반복 안에 LLM을 두면 **시간과 비용이 항목 수에 비례**합니다 (75일차 비용 관리). + +--- + +## 2. 병렬: 나누고 · 합치기 · Lab 03 예제 2 + +할 일 찾기와 요약은 서로의 결과가 필요 없습니다. 한 노드에서 **두 길로 나누고(Fork)**, 둘 다 끝나면 **합칩니다(Join)**. 합치는 노드가 Merge입니다. + +예제 2는 예제 1에 가짜 요약 길을 하나 더 붙입니다. + +```mermaid +flowchart LR + T(["Start"]) --> S["Sample actions → … → Collect actions
{actions}"] -->|"Input 1"| J["Join results
Merge · Combine · Position"] + T --> SS["Sample summary
{summary}"] -->|"Input 2"| J + J --> O["상자 1개
{actions, summary}"] +``` + +| Merge 설정 | 뜻 | +| --- | --- | +| Mode `Combine` | 두 입력의 상자를 하나로 합침 | +| Combine By `Position` | 1번 입력의 첫 상자 + 2번 입력의 첫 상자 → 상자 하나 | + +- Merge는 **두 입력이 모두 올 때까지** 기다립니다. +- n8n은 두 길을 동시에가 아니라 **차례로** 실행합니다. 그래도 그림에서 두 일이 서로 독립이라는 것이 드러나고, 한쪽을 고쳐도 다른 쪽에 영향이 없습니다. + +### 2.1 Merge의 다른 모드 + +| 모드 | 하는 일 | 쓰는 때 | +| --- | --- | --- | +| Append | 두 입력을 이어 붙임 (2 + 3 = 5개) | 같은 모양 목록 두 개 | +| **Combine · By Position** | 같은 순서끼리 합침 (1 + 1 = 1개) | **오늘** | +| Combine · By Matching Fields | 같은 값(`customer_id` 등)끼리 짝 | 주문 목록 + 고객 정보 | +| Choose Branch | 한쪽만 내보냄 | 한쪽 결과만 필요할 때 | + +### 2.2 한 길이 실패하면 + +요약 길의 LLM 호출이 실패하면, 할 일 찾기가 성공해도 Merge가 기다리다 **결과가 나오지 않습니다.** 길이 늘수록 실패할 곳도 늘어납니다. 대처는 72일차(재시도)와 76일차(Error Workflow)에서 배웁니다. + +--- + +## 3. 가짜를 진짜로 바꾸기 · Lab 03 과제 + +예제 1·2는 가짜 데이터였습니다. 과제는 그 자리에 Lab 02의 **진짜 LLM 결과**를 넣어 `66 Meeting notes (my)`를 완성합니다. + +| 예제의 가짜 | 과제의 진짜 | 하는 일 | +| --- | --- | --- | +| `Sample actions` | `LLM extract actions` → `Parse actions` (Lab 02) | 할 일 목록 | +| `Sample summary` | `LLM summarize` → `Keep summary` (새로) | 요약 | +| `Join results` | 그대로 복사 | 두 결과 합치기 | +| Split Out → Normalize → Aggregate | 그대로 복사 | 할 일마다 우선순위, 다시 모으기 | +| (없음) | `Any actions?`, `Build report`, `No actions` (새로) | 할 일이 있을 때 / 없을 때 | + +```mermaid +flowchart LR + H{"Has notes?"} -->|true| L1{{"LLM extract actions"}} --> P["Parse actions"] --> J["Join results"] + H -->|true| L2{{"LLM summarize"}} --> K["Keep summary"] --> J + J --> A{"Any actions?"} + A -->|true| SO["Split Out → Normalize → Aggregate"] --> B["Build report"] --> R["Send result"] + A -->|false| NA["No actions"] --> R + classDef new stroke-width:3px + class L2,K,J,A,SO,B,NA new +``` + +### 3.1 순서에 담긴 이유 + +| 질문 | 답 | +| --- | --- | +| 왜 Merge를 `Any actions?` **앞**에 두나? | 할 일이 없는 회의에도 **요약**은 있어야 하니까. 합친 뒤에 나누면 두 길 모두 `summary`를 가짐 | +| 왜 `Any actions?`가 Split Out **앞**에 있나? | 할 일이 0건이면 펼칠 것도, 정리문에 쓸 것도 없음 → 펼치기 전에 길을 나눠 `할 일 없음` 결과를 따로 만듦 | +| 정리문(`report_text`)은 왜 코드로? | 매번 같은 모양이어야 메신저·메일에 붙이기 좋고, 시간·비용이 들지 않음 | + +### 3.2 두 길을 같은 필드로 끝내기 + +| 필드 | 할 일 정리 (`Build report`) | 할 일 없음 (`No actions`) | +| --- | --- | --- | +| `title` | 회의 제목 | 회의 제목 | +| `route` | `할 일 정리` | `할 일 없음` | +| `count` | 4 | 0 | +| `summary` | 요약 | 요약 | +| `actions` | 할 일 4개 (+ `priority`) | `[]` | +| (과제) `need_check` | 확인 필요 건수 | 0 | + +과제의 마지막 단계 **확인 필요**는 Lab 01 과제에서 설계한 것입니다. 1단계 6절의 질문 "하나마다? 모은 뒤? 모든 길?"을 다시 꺼내 봅니다. + +--- + +## 4. 기대 결과로 검증하기 · Lab 03 과제 + +"오류가 안 났다"는 "맞았다"가 아닙니다. 두 가지로 확인합니다. + +**① 기대 결과 표와 비교** (수업 전 측정) + +| 요청 | 기대 결과 | 처리 시간 | +| --- | --- | --- | +| `weekly` (할 일 4건) | `할 일 정리`, `count: 4`, 오늘 마감 1건 `높음`, `need_check: 1` | 약 3~4초 | +| `no-action` (결정 사항 없음) | `할 일 없음`, `count: 0`, `need_check: 0` | 약 2초 | +| `empty` (빈 회의록) | HTTP 400 | 1초 미만 | + +**② 실행 기록의 항목 수 읽기**: `Join results` 1 → `One item per action` 4 → `Normalize action` 4 → `Collect actions` 1 → `Build report` 1 + +수업 준비 때 실제로 본 **오류 없이 틀리는** 모양입니다. + +| 증상 | 원인 | +| --- | --- | +| 할 일 하나만 옴 | Aggregate를 거치지 않음 (1.1절) | +| `no-action`에서 HTTP 200인데 빈 응답 | `Any actions?`의 false 길이 끊김 | +| `need_check: 4` | `check`의 형식이 Boolean이 아니라 String → 글자 `"false"`도 참으로 셈 | + +--- + +## 5. 밖에서 부르기: 설정 한 줄로 대상 바꾸기 · 8교시 + +65일차의 플랫폼 전환 실행기로 오늘의 업무 `meeting`도 부를 수 있습니다. + +```mermaid +flowchart LR + APP["call('meeting', {title, notes})"] --> CFG["platforms.json
meeting → n8n webhook · miniflow flow"] + CFG --> N8["n8n
/webhook/my-meeting-actions"] + CFG --> MF["miniflow
flows/08-meeting-actions.json"] +``` + +- 내가 만든 워크플로우를 부르려면 `platforms.json`의 웹훅 경로 **한 줄만** `my-meeting-actions`로 바꿉니다. 호출 코드는 그대로입니다. +- n8n이 멈추면 같은 업무를 `miniflow`의 `08` 워크플로우가 이어받습니다. 이것이 되려면 두 플랫폼이 **같은 입력(`title`, `notes`)과 같은 결과 필드**를 지켜야 합니다. + +| 하는 일 | n8n (오늘 만든 것) | miniflow `08-meeting-actions.json` | +| --- | --- | --- | +| 입력 | Webhook `body.title`, `body.notes` | `-i title=... -i notes=...` | +| 할 일 찾기 | HTTP Request → OpenAI (스키마) | LLM 노드 | +| 파싱·검증 | Code `Parse actions` | `parse_actions` Tool | +| 결과 | `{title, route, count, summary, actions, ...}` | 같은 필드 | + +--- + +## 6. Lab 03 안내 + +- 실습 안내: [Last3Mon/66/LAB-03-BUILD-LOOP-PARALLEL.md](../../Last3Mon/66/LAB-03-BUILD-LOOP-PARALLEL.md) + +| 순서 | 시간 | 할 일 | 교안 | +| --- | --- | --- | --- | +| 예제 1 | 5교시 끝 12분 | 가짜 할 일 3건 펼치고 모으기, 항목 수 1 → 3 → 3 → 1 | 1절 | +| 예제 2 | 5교시 끝 10분 | `Sample summary`를 Merge(Position)로 합치기 | 2절 | +| **과제** | 6교시 40분 | 예제 노드를 복사해 내 워크플로우 완성 + **확인 필요** | 3~4절 | +| 마무리 | 8교시 함께 | `platforms.json` 한 줄 바꾸고 `miniflow call meeting` | 5절 | + +--- + +## 7. 스스로 확인하기 + +
Q1. `One item per action`이 4개, `Collect actions`가 1개입니다. 그 사이의 `Normalize action`은 몇 번 실행되었나요? + +4번입니다. 대부분의 n8n 노드는 들어온 항목마다 한 번씩 실행됩니다. +
+ +
Q2. `Build report`에서 회의 제목을 `$json.title`이 아니라 `$('Settings').first().json.title`로 꺼내는 이유는? + +`Build report` 바로 앞은 Aggregate이고, 그 결과에는 `actions`만 있습니다. 제목은 앞쪽 `Settings` 노드에 있으므로 이름으로 꺼냅니다. +
+ +
Q3. Merge를 `Any actions?` 앞에 두는 이유는? + +할 일이 없는 회의에도 요약이 필요하기 때문입니다. 먼저 합치면 할 일 정리 길과 할 일 없음 길 모두 `summary`를 가집니다. +
+ +
Q4. 주문 목록과 고객 정보를 `customer_id`로 합치려면 Merge를 어떻게 설정하나요? + +Combine · By Matching Fields로 `customer_id`를 기준으로 합칩니다. By Position은 순서가 같을 때만 맞습니다. +
+ +
Q5. 요약 길의 LLM 호출이 실패하면 할 일 찾기가 성공해도 응답이 나오지 않는 이유는? + +Merge는 두 입력이 모두 와야 합치기 때문입니다. 병렬 길이 늘면 실패할 곳도 늘어납니다. +
+ +## 정리 + +- 반복은 **펼치고(Split Out) · 항목마다 · 모으기(Aggregate)**. 펼쳤으면 반드시 모읍니다. +- 병렬은 **나누고 · Merge로 합치기**. Merge는 두 입력을 모두 기다립니다. +- 예제의 가짜 데이터 자리에 진짜 LLM 결과를 넣으면 워크플로우가 완성됩니다. +- 모든 길이 같은 필드로 끝나게 하고, **기대 결과 표와 항목 수**로 검증합니다. 오류가 없어도 틀릴 수 있습니다. +- 같은 입력·같은 결과 형식을 지키면 설정 한 줄로 호출 대상을 바꾸고, 플랫폼 전환도 그대로 됩니다. diff --git a/Lecture/66/README.md b/Lecture/66/README.md new file mode 100644 index 0000000..358bbf7 --- /dev/null +++ b/Lecture/66/README.md @@ -0,0 +1,40 @@ +# 66일차 · 단일 업무 자동화 워크플로우 구현 + +- 일자: 2026-10-16 (66일차) +- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영 +- 단원: AI 워크플로우 설계 및 구현 +- 학습 내용: 노코드 Workflow 도구 활용, 단일 업무 자동화 Workflow 구현 +- 실습 폴더: [Last3Mon/66](../../Last3Mon/66/README.md) +- 실습 환경: 65일차 n8n 2.42.6 + OpenAI API(`gpt-4.1-mini`, Credential `OpenAI (.env)`) 그대로 사용, Ollama는 대체용 +- **실습 준비: [00-DAY66-PREP.md](00-DAY66-PREP.md) (수업 전날까지 1~2절 완료)** + +## 오늘의 시간표 + +| 교시 | 시간 | 내용 | 자료 | +| --- | --- | --- | --- | +| 1 | 09:00~09:50 | 오늘의 개요 · 1단계 시작 | [00-DAY66-OVERVIEW.md](00-DAY66-OVERVIEW.md), [1단계](01-STAGE1-DESIGN-AND-DATA-FLOW.md) | +| 2 | 10:00~10:50 | 1단계 · 단일 업무 설계와 데이터 흐름, Lab 01 | [1단계](01-STAGE1-DESIGN-AND-DATA-FLOW.md), [Lab 01](../../Last3Mon/66/LAB-01-DESIGN-DATA-FLOW.md) | +| 3 | 11:00~11:50 | 2단계 · LLM JSON 출력, 파싱과 변환, Lab 02 예제 1 | [2단계](02-STAGE2-PARSE-AND-TRANSFORM.md), [Lab 02](../../Last3Mon/66/LAB-02-BUILD-PARSE-TRANSFORM.md) | +| - | 12:00~13:00 | 점심 | | +| 4 | 13:00~13:50 | 2단계 · Lab 02 예제 2와 과제 (참석자 뽑기) | [Lab 02](../../Last3Mon/66/LAB-02-BUILD-PARSE-TRANSFORM.md) | +| 5 | 14:00~14:50 | 3단계 · 반복·병렬 제어 구조와 완성, Lab 03 예제 1·2 | [3단계](03-STAGE3-LOOP-PARALLEL-AND-FINISH.md), [Lab 03](../../Last3Mon/66/LAB-03-BUILD-LOOP-PARALLEL.md) | +| 6 | 15:00~15:50 | 3단계 · Lab 03 과제 40분 (내 워크플로우 완성 + 확인 필요) · 풀이 | [Lab 03](../../Last3Mon/66/LAB-03-BUILD-LOOP-PARALLEL.md) | +| 7 | 16:00~16:50 | 퀴즈 (A·B·C 중 선택, 20분) · 풀이 | [퀴즈와 정리](04-QUIZ-AND-WRAPUP.md) | +| 8 | 17:00~17:50 | 오늘의 정리 · Python에서 부르기(함께) · 다음 시간 예고 · 복습 과제 | [퀴즈와 정리](04-QUIZ-AND-WRAPUP.md) | + +```mermaid +flowchart LR + P["개요"] --> S1["1단계
설계·데이터 흐름"] --> S2["2단계
파싱·변환"] --> S3["3단계
반복·병렬·완성"] --> Q["퀴즈·정리"] +``` + +## 자료 목록 + +| 파일 | 내용 | +| --- | --- | +| `00-DAY66-PREP.md` | 실습 준비: 어제 환경 켜기, JSON 객체·목록, 새 노드 미리 보기 (수업 전) | +| `00-DAY66-OVERVIEW.md` | 개요 15장 (PPT 대신 Markdown, 가로줄로 장 구분). 1교시에 앞부분, 각 단계 설명 때 해당 장을 함께 띄움 | +| `01-STAGE1-DESIGN-AND-DATA-FLOW.md` | 목적 하나로 좁히기, 결과 먼저, 항목(상자) 읽기 1 → 4 → 1, 제어 구조, 요청이 바뀌면 (Lab 01 예제·과제 순서) | +| `02-STAGE2-PARSE-AND-TRANSFORM.md` | LLM 답은 글자, 파싱, 답의 모양 알려 주기(스키마), 하나 더 뽑기, Edit Fields (Lab 02 예제·과제 순서) | +| `03-STAGE3-LOOP-PARALLEL-AND-FINISH.md` | 펼치고·모으기, Merge로 합치기, 가짜를 진짜로, 기대 결과로 검증, 밖에서 부르기 (Lab 03 예제·과제 순서) | +| `04-QUIZ-AND-WRAPUP.md` | 퀴즈 안내, 오늘의 정리, 자주 막히는 부분, 67일차 예고 | +| `quiz/` | 퀴즈 문항(A·B·C), Google Form 생성 스크립트, 응답 분석 엑셀 |