diff --git a/Last3Mon/64/LAB-01-FIRST-WORKFLOW.md b/Last3Mon/64/LAB-01-FIRST-WORKFLOW.md
index 9217fd4..e301dd9 100644
--- a/Last3Mon/64/LAB-01-FIRST-WORKFLOW.md
+++ b/Last3Mon/64/LAB-01-FIRST-WORKFLOW.md
@@ -6,12 +6,12 @@
## 1. 설치 확인 (5분)
-[사전 준비 안내](SETUP-WSL-OLLAMA.md)를 마친 상태에서 WSL 터미널에서 실행합니다.
+[사전 준비 안내](SETUP-WSL-LLM.md)를 마친 상태에서 WSL 터미널에서 실행합니다.
```bash
cd ~/<수업 저장소>/Last3Mon/64/workflow-lab
-uv run pytest -q # 12 passed, 1 skipped
-uv run miniflow check # Reply (ollama, ...) 가 나오면 준비 완료
+uv run pytest -q # 18 passed, 1 skipped
+uv run miniflow check # Reply (openai, ...) 가 나오면 준비 완료
```
## 2. 가장 단순한 워크플로우 실행 (10분)
@@ -33,14 +33,22 @@ uv run miniflow run flows/01-llm-only.json -i text="다음 주 화요일 10시
Workflow: 01 LLM only
------------------------------------------------------------------------
[1/3] start input ok 0.0ms {"text": "다음 주 화요일 ..."}
-[2/3] summarize llm ok 7071.5ms "다음 주 화요일 10시에 기획 회의가 진행됩니다. 각 팀은 ..."
-[3/3] result output ok 0.1ms "다음 주 화요일 10시에 기획 회의가 진행됩니다. 각 팀은 ..."
+[2/3] summarize llm ok 1645.8ms "다음 주 화요일 10시에 기획 회의가 있습니다. 각 팀은 진행 현황을 준비해 주세요."
+[3/3] result output ok 0.0ms "다음 주 화요일 10시에 기획 회의가 있습니다. 각 팀은 진행 현황을 준비해 주세요."
------------------------------------------------------------------------
Status: completed
Trace saved: runs/20261014-101500-01-llm-only.json
```
-노트북의 Ollama 모델(`gemma3:4b`)이 요약을 만들었습니다. 요약 문장과 소요 시간은 실행할 때마다, 노트북마다 다릅니다.
+OpenAI의 `gpt-4.1-mini`가 요약을 만들었습니다(약 1.4~1.6초). 요약 문장과 소요 시간은 실행할 때마다 조금씩 다를 수 있습니다.
+
+같은 명령을 **대체 모델(Ollama)** 로도 실행해 봅니다. `.env`보다 명령 앞에 적은 값이 우선합니다.
+
+```bash
+MINIFLOW_LLM=ollama uv run miniflow run flows/01-llm-only.json -i text="다음 주 화요일 10시에 기획 회의를 진행합니다. 각 팀은 진행 현황을 준비해 주세요."
+```
+
+수업 전 측정에서 `gemma3:4b`는 12.7초가 걸렸고, 원문에 없는 "회의에서 진행 상황을 공유하고 논의할 예정입니다"를 덧붙였습니다. 요약 노드도 **내용을 지어낼 수 있습니다**.
같은 명령을 **Mock LLM**으로도 실행해 비교해 봅니다. Mock은 노드에 적어 둔 정해진 답을 돌려줍니다.
@@ -54,7 +62,7 @@ MINIFLOW_LLM=mock uv run miniflow run flows/01-llm-only.json -i text="다음 주
1. `summarize` 노드의 `prompt` 필드에는 무엇이 들어 있나요? `{{input.text}}`는 어떻게 바뀌었나요?
2. `provider` 값은 무엇인가요? Mock으로 실행한 기록과 비교하면 무엇이 다른가요?
-3. 각 노드의 `ms` 값을 보면 어느 노드가 가장 오래 걸렸나요? Mock으로 실행했을 때와 비교하면 얼마나 차이 나나요?
+3. 각 노드의 `ms` 값을 보면 어느 노드가 가장 오래 걸렸나요? Mock, Ollama로 실행했을 때와 비교하면 얼마나 차이 나나요?
```bash
uv run miniflow show flows/01-llm-only.json
diff --git a/Last3Mon/64/LAB-02-LLM-TOOL-MEMORY.md b/Last3Mon/64/LAB-02-LLM-TOOL-MEMORY.md
index 0bb615d..5111c02 100644
--- a/Last3Mon/64/LAB-02-LLM-TOOL-MEMORY.md
+++ b/Last3Mon/64/LAB-02-LLM-TOOL-MEMORY.md
@@ -13,7 +13,7 @@ uv run miniflow run flows/02-llm-tool.json -i city=제주 -i question="우산이
```text
[2/4] weather tool ok 0.1ms {"city": "제주", "condition": "비", "temp_c": 20, "rain_chance": 80, ...}
-[3/4] answer llm ok 3783.8ms "네, 비가 오므로 우산이 필요할 것 같습니다."
+[3/4] answer llm ok 1426.2ms "네, 제주에 비가 오고 비 올 확률이 80%이므로 우산이 필요합니다."
```
확인할 것
@@ -71,36 +71,37 @@ uv run miniflow run flows/my-02-with-count.json -i city=서울 -i question="산
`validate`가 오류를 내면 오류 메시지를 읽고 고칩니다. 대부분 노드 id 오타나 연결 누락입니다.
-## 4. Mock과 실제 LLM 비교 (10분)
+## 4. Mock·OpenAI·대체 모델 비교 (10분)
-지금까지는 Ollama 모델이 답했습니다. 같은 워크플로우를 Mock과 번갈아 실행해 비교합니다.
+지금까지는 OpenAI `gpt-4.1-mini`가 답했습니다. 같은 워크플로우를 Mock, 대체 모델(Ollama)과 번갈아 실행해 비교합니다.
```bash
uv run miniflow run flows/05-tech-support-ticket.json -i user=kim -i ticket="프린터에서 출력이 안 됩니다"
uv run miniflow run flows/05-tech-support-ticket.json -i user=kim -i ticket="프린터에서 출력이 안 됩니다"
MINIFLOW_LLM=mock uv run miniflow run flows/05-tech-support-ticket.json -i user=kim -i ticket="프린터에서 출력이 안 됩니다"
+MINIFLOW_LLM=ollama uv run miniflow run flows/05-tech-support-ticket.json -i user=kim -i ticket="프린터에서 출력이 안 됩니다"
```
-| 비교할 것 | Mock | Ollama |
-| --- | --- | --- |
-| `classify`·`draft` 노드의 `ms` | | |
-| 두 번 실행한 답변이 같은가? | | |
-| 분류 결과가 정해진 값(`device`)과 정확히 같은가? | | |
+| 비교할 것 | Mock | OpenAI | Ollama |
+| --- | --- | --- | --- |
+| `classify`·`draft` 노드의 `ms` | | | |
+| 두 번 실행한 답변이 같은가? | | | |
+| 분류 결과가 정해진 값(`device`)과 정확히 같은가? | | | |
-수업 전에 확인한 결과(CPU 1개, 메모리 8GB 환경)는 다음과 같았습니다. 내 노트북 결과와 비교해 보세요.
+수업 전에 같은 문의 4건(비밀번호, VPN, 프린터, 메일 프로그램 설치)으로 확인한 결과입니다. 내 노트북 결과와 비교해 보세요. Ollama 시간은 노트북 성능에 따라 크게 다릅니다.
| 모델 | 분류 4건 정답 | 분류 노드 | 답변 초안 노드 |
| --- | --- | --- | --- |
-| `gemma3:1b` | 0/4 (전부 `software`, 한 번은 `Software`) | 약 1초 | 4~7초 |
-| `gemma3:4b` | 4/4 | 약 1.7초 | 7~11초 |
+| `gpt-4.1-mini` (OpenAI, 기본) | 4/4 | 약 1~2.6초 | 약 1.2~1.7초 |
+| `gemma3:4b` (대체) | 4/4 | 약 1.6~2.3초 | 7~11초 |
+| `gemma3:1b` (8GB 노트북 대체) | 1/4 (나머지 `software`, 대문자 `Software`가 나온 적도 있음) | 약 1초 | 4~7초 |
-작은 모델은 빠르지만 판단이 틀리거나 대소문자처럼 형식이 흔들립니다. 다음 노드가 분류값을 비교해야 한다면, LLM 출력을 그대로 믿지 말고 **검증하는 단계**가 필요합니다. (73일차 입력·출력 검증)
+작은 모델은 빠르지만 판단이 틀리거나 대소문자처럼 형식이 흔들립니다. 8GB 노트북은 `OLLAMA_MODEL=gemma3:1b MINIFLOW_LLM=ollama`로, 메모리가 넉넉하면 두 모델을 모두 돌려 봅니다. 다음 노드가 분류값을 비교해야 한다면, LLM 출력을 그대로 믿지 말고 **검증하는 단계**가 필요합니다. (73일차 입력·출력 검증)
-(선택) OpenAI/Anthropic/Gemini 중 API 키가 있으면 클라우드 모델과도 비교할 수 있습니다. 가지고 있는 키 하나만 설정하면 됩니다.
+(선택) Anthropic·Gemini 키가 있으면 다른 제공사 모델과도 비교할 수 있습니다. 가지고 있는 키 하나만 설정하면 됩니다.
| 제공사 | 설치 | 설정 |
| --- | --- | --- |
-| OpenAI | `uv sync --extra openai` | `export MINIFLOW_LLM=openai`, `export OPENAI_API_KEY=...` |
| Anthropic | `uv sync --extra anthropic` | `export MINIFLOW_LLM=anthropic`, `export ANTHROPIC_API_KEY=...` |
| Gemini (10일차에 발급한 키) | `uv sync --extra gemini` | `export MINIFLOW_LLM=gemini`, `export GEMINI_API_KEY=...` |
@@ -117,4 +118,4 @@ MINIFLOW_LLM=mock uv run miniflow run flows/05-tech-support-ticket.json -i user=
- [ ] Tool 결과가 LLM 프롬프트에 들어간 것을 실행 기록에서 찾았다.
- [ ] Memory가 두 번의 실행을 이어 주는 것을 확인했다.
- [ ] `my-02-with-count.json`을 만들어 `validate`와 `run`을 통과했다.
-- [ ] Mock과 Ollama의 결과·속도 차이를 표로 정리했다.
+- [ ] Mock·OpenAI·Ollama의 결과·속도 차이를 표로 정리했다.
diff --git a/Last3Mon/64/LAB-03-SCENARIO-ANALYSIS.md b/Last3Mon/64/LAB-03-SCENARIO-ANALYSIS.md
index b8883b7..6662784 100644
--- a/Last3Mon/64/LAB-03-SCENARIO-ANALYSIS.md
+++ b/Last3Mon/64/LAB-03-SCENARIO-ANALYSIS.md
@@ -23,7 +23,14 @@ uv run miniflow run flows/05-tech-support-ticket.json -i user=lee -i ticket="점
| 2번째 | `history` 노드 출력에 1번째 문의가 남아 있는가? |
| 3번째 | 어느 노드에서 왜 멈추었는가? `draft` 노드는 무엇이라고 답했는가? 이 경우 실제 업무에서는 어떻게 처리해야 할까? |
-3번째 실행에서는 FAQ 검색 결과가 없는데도 LLM이 그럴듯한 답변 초안을 만들고, 마지막 `result` 노드에서 멈춥니다. Mock으로 실행(`MINIFLOW_LLM=mock`)하면 `draft` 노드에서 바로 멈춥니다. **근거가 없을 때 실제 LLM이 어떻게 행동하는지** 실행 기록에서 확인해 보세요.
+3번째 실행에서는 FAQ 검색 결과가 없는데도 LLM이 답변 초안을 만들고, 마지막 `result` 노드에서 멈춥니다. Mock으로 실행(`MINIFLOW_LLM=mock`)하면 `draft` 노드에서 바로 멈춥니다. **근거가 없을 때 실제 LLM이 어떻게 행동하는지** 실행 기록에서 확인해 보세요.
+
+| 모델 (수업 전 측정) | `draft`가 쓴 답 | `result` |
+| --- | --- | --- |
+| `gpt-4.1-mini` | "점심 메뉴 추천에 관한 FAQ가 현재 제공되지 않아 … 양해 부탁드립니다" (솔직하게 답함) | `'0' not found`로 실패 |
+| 대체 `gemma3:4b` (`MINIFLOW_LLM=ollama`) | "… FAQ 검색 결과가 없습니다. (FAQ ID: other-123)" (**없는 FAQ 번호를 지어냄**) | `'0' not found`로 실패 |
+
+좋은 모델은 근거가 없다고 말했지만, 워크플로우는 어느 쪽이든 마지막에 가서야 멈춥니다. 모델이 바뀌어도 안전하려면 "FAQ가 없으면 담당자에게" 같은 **경로**가 필요합니다 (65일차 Condition).
`uv run miniflow show flows/05-tech-support-ticket.json`으로 구조도 확인합니다.
@@ -82,7 +89,7 @@ uv run miniflow run flows/my-scenario.json -i user=me -i text="..."
Tip
-- LLM 노드의 프롬프트에는 답의 형식(정해진 값, 문장 수 등)을 분명히 적습니다. Ollama 답이 매번 달라 흐름 확인이 어려우면 `mock`의 `rules`·`template`을 함께 적고 `MINIFLOW_LLM=mock`으로 구조부터 확인합니다.
+- LLM 노드의 프롬프트에는 답의 형식(정해진 값, 문장 수 등)을 분명히 적습니다. LLM 답이 매번 달라 흐름 확인이 어려우면 `mock`의 `rules`·`template`을 함께 적고 `MINIFLOW_LLM=mock`으로 구조부터 확인합니다.
- 사용할 수 있는 Tool은 `uv run miniflow tools`로 확인합니다 (`get_weather`, `search_faq`, `count_chars`).
- `show` 결과를 분석 문서의 "구현한 워크플로우" 항목에 붙여 넣습니다.
diff --git a/Last3Mon/64/README.md b/Last3Mon/64/README.md
index ceddf6c..0a59fc9 100644
--- a/Last3Mon/64/README.md
+++ b/Last3Mon/64/README.md
@@ -10,7 +10,7 @@
```text
Last3Mon/64/
├── README.md
-├── SETUP-WSL-OLLAMA.md # 사전 준비 · WSL과 Ollama 로컬 LLM
+├── SETUP-WSL-LLM.md # 사전 준비 · WSL, OpenAI API 키(.env), 대체 Ollama
├── LAB-01-FIRST-WORKFLOW.md # 1단계 · 첫 워크플로우 실행과 실행 기록 읽기
├── LAB-02-LLM-TOOL-MEMORY.md # 2단계 · LLM·Tool·Memory 연결하기
├── LAB-03-SCENARIO-ANALYSIS.md # 3단계 · 업무 자동화 시나리오 분석과 설계
@@ -41,11 +41,12 @@ flowchart LR
## 준비 (수업 전날)
-실습은 Windows 11의 WSL(Ubuntu)에서, LLM은 노트북에서 실행하는 Ollama(`gemma3:4b`, 8GB 노트북은 `gemma3:1b`)로 진행합니다. API 키는 필요 없습니다.
+실습은 Windows 11의 WSL(Ubuntu)에서 진행합니다. LLM은 OpenAI API(`gpt-4.1-mini`, 키는 `Last3Mon/.env`)를 쓰고, 키·인터넷에 문제가 있으면 노트북의 Ollama(`gemma3:4b`, 8GB 노트북은 `gemma3:1b`)가 대신 답합니다.
-먼저 **[실습 준비 안내](../../Lecture/64/00-DAY64-PREP.md)** 로 전체 준비 항목을 확인하고, 설치는 **[SETUP-WSL-OLLAMA.md](SETUP-WSL-OLLAMA.md)** 의 순서대로 진행한 뒤 마지막에 아래 두 명령이 통과하는지 확인합니다.
+먼저 **[실습 준비 안내](../../Lecture/64/00-DAY64-PREP.md)** 로 전체 준비 항목을 확인하고, 설치는 **[SETUP-WSL-LLM.md](SETUP-WSL-LLM.md)** 의 순서대로 진행한 뒤 마지막에 아래 명령이 통과하는지 확인합니다.
```bash
-uv run pytest -q # 12 passed, 1 skipped
-uv run miniflow check # Reply (ollama, ...)
+uv run pytest -q # 18 passed, 1 skipped
+uv run miniflow check # Reply (openai, ...)
+OPENAI_API_KEY=sk-wrong uv run miniflow check # Reply (ollama, ...) : 대체 경로
```
diff --git a/Last3Mon/64/SETUP-WSL-LLM.md b/Last3Mon/64/SETUP-WSL-LLM.md
new file mode 100644
index 0000000..5497c94
--- /dev/null
+++ b/Last3Mon/64/SETUP-WSL-LLM.md
@@ -0,0 +1,181 @@
+# 사전 준비 · WSL 실습 환경과 LLM (OpenAI API 기본, Ollama 대체)
+
+64일차 실습은 Windows 11의 **WSL(Ubuntu)** 에서 진행합니다. LLM은 **OpenAI API**(기본 모델 `gpt-4.1-mini`)를 쓰고, 키가 없거나 인터넷·API에 문제가 있으면 노트북에서 직접 실행하는 **Ollama**(`gemma3:4b`)가 대신 답합니다. 이 구성은 65일차 이후 모든 실습에서 그대로 씁니다.
+
+> Ollama 모델 파일이 1~3GB입니다. 수업 중에 21명이 동시에 받으면 강의실 네트워크가 느려지므로 **수업 전날 5단계(모델 다운로드)까지 끝내 주세요.**
+
+```mermaid
+flowchart LR
+ subgraph WIN["Windows 11"]
+ T["Windows Terminal"]
+ subgraph WSL["WSL2 · Ubuntu"]
+ E[".env
OPENAI_API_KEY
(git 제외)"] -.-> M
+ M["miniflow
(uv 프로젝트)"] -.->|"대체
localhost:11434"| O["Ollama 서버"]
+ O --> G[("gemma3:4b 모델")]
+ end
+ T --> WSL
+ end
+ M -->|"기본"| AI["OpenAI API
gpt-4.1-mini"]
+```
+
+## 1. WSL 확인 (5분)
+
+PowerShell에서 확인합니다.
+
+```powershell
+wsl --list --verbose
+```
+
+`Ubuntu`가 `VERSION 2`로 보이면 됩니다. 없으면 `wsl --install -d Ubuntu`로 설치하고 노트북을 다시 시작합니다.
+
+이후 명령은 모두 **WSL(Ubuntu) 터미널**에서 실행합니다.
+
+## 2. 실습 코드와 uv 준비 (10분)
+
+```bash
+# uv (이미 설치되어 있으면 건너뜀)
+curl -LsSf https://astral.sh/uv/install.sh | sh
+source ~/.bashrc
+
+# 실습 폴더는 WSL 홈(~) 아래에 둡니다. /mnt/c 아래는 느리고 권한 문제가 생깁니다.
+cd ~
+# 수업 자료를 받는 방법은 강사가 따로 안내합니다.
+cd ~/<수업 저장소>/Last3Mon/64/workflow-lab
+uv sync
+uv run pytest -q
+```
+
+`18 passed, 1 skipped`가 나오면 됩니다. (`skipped` 1개는 선택 기능인 Anthropic 연결 테스트로, 해당 패키지를 설치하지 않으면 건너뜁니다.) 테스트는 `.env`를 읽지 않고 Mock LLM으로만 돌기 때문에 키가 없어도 통과합니다.
+
+## 3. OpenAI API 키를 `.env`에 넣기 (3분)
+
+과정에서 받은 OpenAI API 키를 `Last3Mon/.env` 한 곳에 넣습니다. `miniflow`는 실행한 폴더에서 위로 올라가며 가장 가까운 `.env`를 읽으므로, 64일차부터 모든 일차가 이 파일 하나를 씁니다.
+
+```bash
+cd ~/<수업 저장소>/Last3Mon
+cp .env.example .env
+nano .env # OPENAI_API_KEY= 뒤에 키를 붙여 넣고 저장 (Ctrl+O, Enter, Ctrl+X)
+```
+
+| 줄 | 뜻 |
+| --- | --- |
+| `OPENAI_API_KEY=` | 받은 키 (`sk-`로 시작). **문서·워크플로우·채팅·화면 캡처에 올리지 않습니다** |
+| `OPENAI_MODEL=gpt-4.1-mini` | 수업 기본 모델 |
+| `MINIFLOW_LLM=openai` | `miniflow`가 OpenAI를 씀. 키를 받지 못했으면 `ollama`로 바꿈 |
+| `MINIFLOW_LLM_FALLBACK=ollama` | OpenAI 호출이 실패하면 Ollama로 한 번 더 시도 |
+| `OLLAMA_MODEL=gemma3:4b` | 대체 모델. 8GB 노트북은 `gemma3:1b` |
+
+> `.env`는 `.gitignore`에 들어 있어 커밋되지 않습니다. 키가 들어간 파일을 다른 이름으로 복사해 두지 마세요.
+>
+> 셸에 `export MINIFLOW_LLM=...`을 적어 두었다면(`~/.bashrc`) 그 값이 `.env`보다 우선합니다. 지난 실습에서 넣은 줄이 있으면 지우고 터미널을 다시 엽니다.
+
+## 4. 노트북 메모리에 맞는 대체 모델 고르기
+
+WSL은 기본으로 **Windows 전체 메모리의 절반**까지만 사용합니다.
+
+| 노트북 메모리 | WSL이 쓸 수 있는 메모리 | 대체 모델 (`OLLAMA_MODEL`) |
+| --- | --- | --- |
+| 8GB | 약 4GB | `gemma3:1b` |
+| 16GB 이상 | 약 8GB 이상 | `gemma3:4b` (권장) |
+
+내 노트북 메모리는 PowerShell에서 확인합니다.
+
+```powershell
+(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB
+```
+
+메모리가 부족하다는 오류가 나면 `C:\Users\<사용자>\.wslconfig` 파일을 만들어 상한을 올린 뒤 PowerShell에서 `wsl --shutdown` 하고 WSL을 다시 엽니다.
+
+```ini
+[wsl2]
+memory=10GB
+swap=4GB
+```
+
+## 5. Ollama 설치와 모델 다운로드 (10~20분)
+
+OpenAI가 기본이지만 Ollama는 **꼭** 준비합니다. 키·인터넷에 문제가 있을 때 대신 답하고, 2단계 실습에서 모델 크기에 따른 차이를 직접 비교합니다.
+
+```bash
+curl -fsSL https://ollama.com/install.sh | sh
+ollama --version
+
+# 메모리 16GB 이상
+ollama pull gemma3:4b
+# 메모리 8GB
+ollama pull gemma3:1b
+
+ollama list
+```
+
+`ollama list`에 모델이 보이면 됩니다. 설치 스크립트가 Ollama 서버를 자동으로 실행합니다. `ollama list`에서 연결 오류가 나면 다른 터미널 창에서 `ollama serve`를 실행해 두세요.
+
+## 6. 연결 확인 (5분)
+
+```bash
+cd ~/<수업 저장소>/Last3Mon/64/workflow-lab
+uv run miniflow check
+```
+
+```text
+MINIFLOW_LLM : openai
+.env : /home/<사용자>/<수업 저장소>/Last3Mon/.env
+OPENAI_MODEL : gpt-4.1-mini
+API key : set
+OLLAMA_HOST : http://localhost:11434
+OLLAMA_MODEL : gemma3:4b
+FALLBACK : ollama
+Reply (openai, 2.0s): ready
+```
+
+대체 경로도 한 번 확인합니다. 키를 일부러 틀리게 넣으면(이 명령에서만) Ollama가 대신 답합니다.
+
+```bash
+OPENAI_API_KEY=sk-wrong uv run miniflow check
+```
+
+```text
+[miniflow] openai failed (OpenAI rejected the API key (check OPENAI_API_KEY in .env)); trying ollama
+Reply (ollama, 20.9s): Ready
+```
+
+Ollama의 첫 호출은 모델을 메모리에 올리느라 10~20초 걸립니다. 두 번째부터는 빨라집니다.
+
+## 7. 이미 Windows나 Docker에 Ollama가 있는 경우
+
+9월 실습에서 Docker로 Ollama를 실행해 둔 경우에는 WSL에 새로 설치하지 않고 그대로 써도 됩니다.
+
+| 상황 | 설정 |
+| --- | --- |
+| Docker Desktop으로 실행한 Ollama (`11434` 포트 공개) | 컨테이너를 켜 두면 WSL에서도 `http://localhost:11434`로 연결됩니다. 6절의 대체 경로 확인으로 점검 |
+| Windows용 Ollama 앱 | `.wslconfig`에 `networkingMode=mirrored`를 추가하고 `wsl --shutdown` 후 다시 열면 `localhost`로 연결됩니다 |
+
+```ini
+[wsl2]
+networkingMode=mirrored
+```
+
+## 8. 문제 해결
+
+| 증상 | 해결 |
+| --- | --- |
+| `API key : MISSING` | `Last3Mon/.env`에 `OPENAI_API_KEY`가 비어 있음. 키를 넣거나, 키가 없으면 `MINIFLOW_LLM=ollama` |
+| `.env : (none)` | `.env` 위치 확인 (`Last3Mon/.env`), 실습 폴더 안에서 실행했는지 확인 |
+| `OpenAI rejected the API key` | 키를 잘못 붙여 넣음 (앞뒤 공백·줄바꿈 확인) |
+| `HTTP 429 ... insufficient_quota` | 키의 크레딧이 없음. 강사에게 알리고 그동안 `MINIFLOW_LLM=ollama` |
+| `Cannot reach the OpenAI API` | 인터넷·프록시 문제. 그동안 `MINIFLOW_LLM=ollama` |
+| `MINIFLOW_LLM : ollama`로 나옴 | `~/.bashrc`의 `export MINIFLOW_LLM=ollama`가 `.env`보다 우선. 그 줄을 지우고 터미널 다시 열기 |
+| `Cannot reach Ollama at http://localhost:11434` | `ollama serve` 실행, 또는 7절의 연결 설정 확인 |
+| `Model 'gemma3:4b' not found on Ollama` | `ollama pull gemma3:4b` |
+| Ollama 응답이 1분 넘게 걸림, WSL이 멈춤 | 메모리 부족. `gemma3:1b`로 바꾸거나 `.wslconfig`의 `memory` 상향 |
+| `uv: command not found` | `source ~/.bashrc` 또는 터미널 다시 열기 |
+| 한글이 깨져 보임 | Windows Terminal 사용 (기본 UTF-8) |
+| OpenAI와 Ollama 모두 안 됨 | `export MINIFLOW_LLM=mock`으로 Mock LLM 사용 (결과는 정해진 답) |
+
+## 확인
+
+- [ ] `uv run pytest -q` → `18 passed, 1 skipped`
+- [ ] `Last3Mon/.env`에 키를 넣었다 (키는 이 파일에만 있다)
+- [ ] `ollama list`에 모델이 보인다
+- [ ] `uv run miniflow check`에서 `Reply (openai, ...)`가 나온다
+- [ ] `OPENAI_API_KEY=sk-wrong uv run miniflow check`에서 `Reply (ollama, ...)`가 나온다
diff --git a/Last3Mon/64/SETUP-WSL-OLLAMA.md b/Last3Mon/64/SETUP-WSL-OLLAMA.md
deleted file mode 100644
index fe4fc66..0000000
--- a/Last3Mon/64/SETUP-WSL-OLLAMA.md
+++ /dev/null
@@ -1,144 +0,0 @@
-# 사전 준비 · WSL에서 실습 환경과 Ollama 로컬 LLM 준비
-
-64일차 실습은 Windows 11의 **WSL(Ubuntu)** 에서 진행하고, LLM은 노트북에서 직접 실행하는 **Ollama**를 사용합니다. API 키가 필요 없고 사용량 제한도 없습니다.
-
-> 모델 파일이 1~3GB입니다. 수업 중에 21명이 동시에 받으면 강의실 네트워크가 느려지므로 **수업 전날 4단계(모델 다운로드)까지 끝내 주세요.**
-
-```mermaid
-flowchart LR
- subgraph WIN["Windows 11"]
- T["Windows Terminal"]
- subgraph WSL["WSL2 · Ubuntu"]
- M["miniflow
(uv 프로젝트)"] -->|"http://localhost:11434"| O["Ollama 서버"]
- O --> G[("gemma3:4b 모델")]
- end
- T --> WSL
- end
-```
-
-## 1. WSL 확인 (5분)
-
-PowerShell에서 확인합니다.
-
-```powershell
-wsl --list --verbose
-```
-
-`Ubuntu`가 `VERSION 2`로 보이면 됩니다. 없으면 `wsl --install -d Ubuntu`로 설치하고 노트북을 다시 시작합니다.
-
-이후 명령은 모두 **WSL(Ubuntu) 터미널**에서 실행합니다.
-
-## 2. 노트북 메모리에 맞는 모델 고르기
-
-WSL은 기본으로 **Windows 전체 메모리의 절반**까지만 사용합니다.
-
-| 노트북 메모리 | WSL이 쓸 수 있는 메모리 | 사용할 모델 |
-| --- | --- | --- |
-| 8GB | 약 4GB | `gemma3:1b` |
-| 16GB 이상 | 약 8GB 이상 | `gemma3:4b` (권장) |
-
-내 노트북 메모리는 PowerShell에서 확인합니다.
-
-```powershell
-(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB
-```
-
-메모리가 부족하다는 오류가 나면 `C:\Users\<사용자>\.wslconfig` 파일을 만들어 상한을 올린 뒤 PowerShell에서 `wsl --shutdown` 하고 WSL을 다시 엽니다.
-
-```ini
-[wsl2]
-memory=10GB
-swap=4GB
-```
-
-## 3. 실습 코드와 uv 준비 (10분)
-
-```bash
-# uv (이미 설치되어 있으면 건너뜀)
-curl -LsSf https://astral.sh/uv/install.sh | sh
-source ~/.bashrc
-
-# 실습 폴더는 WSL 홈(~) 아래에 둡니다. /mnt/c 아래는 느리고 권한 문제가 생깁니다.
-cd ~
-# 수업 저장소를 받는 방법은 강의 시간에 안내합니다.
-cd ~/<수업 저장소>/Last3Mon/64/workflow-lab
-uv sync
-uv run pytest -q
-```
-
-`12 passed, 1 skipped`가 나오면 됩니다. (`skipped` 1개는 선택 기능인 Anthropic 연결 테스트로, 해당 패키지를 설치하지 않으면 건너뜁니다.)
-
-## 4. Ollama 설치와 모델 다운로드 (10~20분)
-
-```bash
-curl -fsSL https://ollama.com/install.sh | sh
-ollama --version
-
-# 메모리 16GB 이상
-ollama pull gemma3:4b
-# 메모리 8GB
-ollama pull gemma3:1b
-
-ollama list
-```
-
-`ollama list`에 모델이 보이면 됩니다. 설치 스크립트가 Ollama 서버를 자동으로 실행합니다. `ollama list`에서 연결 오류가 나면 다른 터미널 창에서 `ollama serve`를 실행해 두세요.
-
-## 5. miniflow와 Ollama 연결 (5분)
-
-`~/.bashrc` 맨 아래에 추가합니다. 8GB 노트북은 모델 이름을 `gemma3:1b`로 바꿉니다.
-
-```bash
-cat >> ~/.bashrc <<'EOF'
-export MINIFLOW_LLM=ollama
-export OLLAMA_MODEL=gemma3:4b
-EOF
-source ~/.bashrc
-```
-
-연결을 확인합니다.
-
-```bash
-cd ~/<수업 저장소>/Last3Mon/64/workflow-lab
-uv run miniflow check
-```
-
-```text
-MINIFLOW_LLM : ollama
-OLLAMA_HOST : http://localhost:11434
-OLLAMA_MODEL : gemma3:4b
-Reply (ollama, 18.8s): Ready
-```
-
-첫 호출은 모델을 메모리에 올리느라 10~20초 걸립니다. 두 번째부터는 빨라집니다.
-
-## 6. 이미 Windows나 Docker에 Ollama가 있는 경우
-
-9월 실습에서 Docker로 Ollama를 실행해 둔 경우에는 WSL에 새로 설치하지 않고 그대로 써도 됩니다.
-
-| 상황 | 설정 |
-| --- | --- |
-| Docker Desktop으로 실행한 Ollama (`11434` 포트 공개) | 컨테이너를 켜 두면 WSL에서도 `http://localhost:11434`로 연결됩니다. `uv run miniflow check`로 확인 |
-| Windows용 Ollama 앱 | `.wslconfig`에 `networkingMode=mirrored`를 추가하고 `wsl --shutdown` 후 다시 열면 `localhost`로 연결됩니다 |
-
-```ini
-[wsl2]
-networkingMode=mirrored
-```
-
-## 7. 문제 해결
-
-| 증상 | 해결 |
-| --- | --- |
-| `Cannot reach Ollama at http://localhost:11434` | `ollama serve` 실행, 또는 6절의 연결 설정 확인 |
-| `Model 'gemma3:4b' not found on Ollama` | `ollama pull gemma3:4b` |
-| 응답이 1분 넘게 걸림, WSL이 멈춤 | 메모리 부족. `gemma3:1b`로 바꾸거나 `.wslconfig`의 `memory` 상향 |
-| `uv: command not found` | `source ~/.bashrc` 또는 터미널 다시 열기 |
-| 한글이 깨져 보임 | Windows Terminal 사용 (기본 UTF-8) |
-| Ollama가 없거나 문제가 계속됨 | `export MINIFLOW_LLM=mock`으로 Mock LLM 사용 (결과는 정해진 답) |
-
-## 확인
-
-- [ ] `uv run pytest -q` → `12 passed, 1 skipped`
-- [ ] `ollama list`에 모델이 보인다
-- [ ] `uv run miniflow check`에서 `Reply (ollama, ...)`가 나온다
diff --git a/Last3Mon/64/workflow-lab/README.md b/Last3Mon/64/workflow-lab/README.md
index 761547a..c39c32f 100644
--- a/Last3Mon/64/workflow-lab/README.md
+++ b/Last3Mon/64/workflow-lab/README.md
@@ -5,7 +5,7 @@
```mermaid
flowchart LR
J["flows/*.json
노드와 연결 정의"] --> E["miniflow 엔진
순서 계산 · 실행"]
- E --> L{{"LLM 노드
ollama · mock · openai · anthropic · gemini"}}
+ 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)"]
@@ -57,19 +57,18 @@ uv run pytest -q
## 5. LLM 선택
-`MINIFLOW_LLM` 환경 변수로 LLM을 고릅니다. 수업 기본값은 노트북에서 실행하는 **Ollama**입니다. 준비 방법은 [SETUP-WSL-OLLAMA.md](../SETUP-WSL-OLLAMA.md)를 참고하세요.
+`MINIFLOW_LLM`으로 LLM을 고릅니다. 보통은 `Last3Mon/.env`에 적어 두고(실행한 폴더에서 위로 올라가며 가장 가까운 `.env`를 읽음), 셸이나 명령 앞에 적은 값이 `.env`보다 우선합니다. 수업 기본값은 **OpenAI API**이고, `MINIFLOW_LLM_FALLBACK=ollama`이면 OpenAI 호출이 실패할 때 노트북의 Ollama로 한 번 더 시도합니다. 준비 방법은 [SETUP-WSL-LLM.md](../SETUP-WSL-LLM.md)를 참고하세요.
| `MINIFLOW_LLM` | LLM | 필요한 것 |
| --- | --- | --- |
-| `ollama` (수업 기본) | 로컬 모델 (`OLLAMA_MODEL`, 기본 `gemma3:4b`) | Ollama 설치, 모델 다운로드 |
-| `mock` (설정하지 않았을 때) | 노드의 `mock` 설정대로 정해진 답 | 없음 (자동 테스트, Ollama가 안 될 때) |
-| `openai` | OpenAI (`OPENAI_MODEL`) | `uv sync --extra openai`, `OPENAI_API_KEY` |
+| `openai` (수업 기본) | OpenAI (`OPENAI_MODEL`, 기본 `gpt-4.1-mini`) | `.env`의 `OPENAI_API_KEY` (패키지는 `uv sync`에 포함) |
+| `ollama` (대체) | 로컬 모델 (`OLLAMA_MODEL`, 기본 `gemma3:4b`) | Ollama 설치, 모델 다운로드 |
+| `mock` (`.env`도 설정도 없을 때) | 노드의 `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` |
```bash
-export MINIFLOW_LLM=ollama
-export OLLAMA_MODEL=gemma3:4b # 8GB 노트북은 gemma3:1b
-export OLLAMA_HOST=http://localhost:11434 # 기본값이라 생략 가능
-uv run miniflow check # 연결 확인
+uv run miniflow check # .env 설정으로 연결 확인 → Reply (openai, ...)
+OPENAI_API_KEY=sk-wrong uv run miniflow check # 대체 경로 확인 → Reply (ollama, ...)
+MINIFLOW_LLM=ollama OLLAMA_MODEL=gemma3:1b uv run miniflow check # 대체 모델만 따로
```
diff --git a/Last3Mon/64/workflow-lab/pyproject.toml b/Last3Mon/64/workflow-lab/pyproject.toml
index 80652eb..da7e3f6 100644
--- a/Last3Mon/64/workflow-lab/pyproject.toml
+++ b/Last3Mon/64/workflow-lab/pyproject.toml
@@ -4,7 +4,7 @@ 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 = []
+dependencies = ["openai>=1.40"] # course default LLM (key in .env)
[project.optional-dependencies]
gemini = ["google-genai>=1.0"]
diff --git a/Last3Mon/64/workflow-lab/src/miniflow/cli.py b/Last3Mon/64/workflow-lab/src/miniflow/cli.py
index e3c9e20..2be006c 100644
--- a/Last3Mon/64/workflow-lab/src/miniflow/cli.py
+++ b/Last3Mon/64/workflow-lab/src/miniflow/cli.py
@@ -67,9 +67,15 @@ def cmd_check(_: argparse.Namespace) -> int:
provider = os.getenv("MINIFLOW_LLM", "mock").lower()
print(f"MINIFLOW_LLM : {provider}")
- if provider == "ollama":
+ 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", "", {})
diff --git a/Last3Mon/64/workflow-lab/src/miniflow/envfile.py b/Last3Mon/64/workflow-lab/src/miniflow/envfile.py
new file mode 100644
index 0000000..ca0e044
--- /dev/null
+++ b/Last3Mon/64/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/64/workflow-lab/src/miniflow/llm.py b/Last3Mon/64/workflow-lab/src/miniflow/llm.py
index 5ea3e0b..eb8a51c 100644
--- a/Last3Mon/64/workflow-lab/src/miniflow/llm.py
+++ b/Last3Mon/64/workflow-lab/src/miniflow/llm.py
@@ -1,16 +1,21 @@
-"""LLM providers: mock (default, no API key), ollama (local), openai, anthropic, gemini.
+"""LLM providers: mock (default, no API key), openai (course default, key in .env), ollama (local), anthropic, gemini.
-Select with the MINIFLOW_LLM environment variable.
+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
@@ -111,26 +116,68 @@ def call_gemini(system: str, prompt: str, temperature: float) -> str:
return response.text or ""
-def call_openai(system: str, prompt: str, temperature: float) -> str:
+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:
- from openai import OpenAI
+ import openai
except ImportError as error:
- raise LLMError("Install the extra first: uv sync --extra openai") from 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")
- client = OpenAI()
- response = client.responses.create(
- model=os.getenv("OPENAI_MODEL", "gpt-4.1-mini"),
- instructions=system or None,
- input=prompt,
- temperature=temperature,
- )
- return response.output_text
+ 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)."""
- provider = os.getenv("MINIFLOW_LLM", "mock").lower()
+ """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"
@@ -141,5 +188,5 @@ def complete(node: dict[str, Any], prompt: str, system: str, context: dict[str,
if provider == "gemini":
return call_gemini(system, prompt, temperature), "gemini"
if provider == "openai":
- return call_openai(system, prompt, temperature), "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/64/workflow-lab/tests/conftest.py b/Last3Mon/64/workflow-lab/tests/conftest.py
new file mode 100644
index 0000000..aa78190
--- /dev/null
+++ b/Last3Mon/64/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/64/workflow-lab/tests/test_openai_provider.py b/Last3Mon/64/workflow-lab/tests/test_openai_provider.py
new file mode 100644
index 0000000..1c33aea
--- /dev/null
+++ b/Last3Mon/64/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/64/workflow-lab/uv.lock b/Last3Mon/64/workflow-lab/uv.lock
index 034f1f2..73f1e10 100644
--- a/Last3Mon/64/workflow-lab/uv.lock
+++ b/Last3Mon/64/workflow-lab/uv.lock
@@ -647,6 +647,9 @@ wheels = [
name = "miniflow"
version = "0.1.0"
source = { editable = "." }
+dependencies = [
+ { name = "openai" },
+]
[package.optional-dependencies]
anthropic = [
@@ -668,6 +671,7 @@ dev = [
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"]
diff --git a/Lecture/64/00-DAY64-OVERVIEW.pptx b/Lecture/64/00-DAY64-OVERVIEW.pptx
index 6ae1ba3..dbb81ec 100644
Binary files a/Lecture/64/00-DAY64-OVERVIEW.pptx and b/Lecture/64/00-DAY64-OVERVIEW.pptx differ
diff --git a/Lecture/64/00-DAY64-PREP.md b/Lecture/64/00-DAY64-PREP.md
index 411e21e..b12374f 100644
--- a/Lecture/64/00-DAY64-PREP.md
+++ b/Lecture/64/00-DAY64-PREP.md
@@ -6,7 +6,7 @@
```mermaid
flowchart LR
- A["1. 실습 환경
WSL · uv · Ollama"] --> B["2. 확인 명령
pytest · check"]
+ A["1. 실습 환경
WSL · uv · API 키 · Ollama"] --> B["2. 확인 명령
pytest · check"]
B --> C["3. 미리 읽기
JSON · 지난 실습"]
C --> D["4. 생각해 오기
내 업무 시나리오"]
```
@@ -15,19 +15,20 @@ flowchart LR
## 1. 실습 환경 준비 (전날 필수, 약 40분)
-오늘 실습은 Windows 11 노트북의 **WSL(Ubuntu)** 에서, LLM은 노트북에서 직접 실행하는 **Ollama**로 진행합니다. API 키는 필요 없습니다.
+오늘 실습은 Windows 11 노트북의 **WSL(Ubuntu)** 에서 진행합니다. LLM은 과정에서 받은 키로 **OpenAI API**(`gpt-4.1-mini`)를 쓰고, 키·인터넷에 문제가 있으면 노트북에서 직접 실행하는 **Ollama**(`gemma3:4b`)가 대신 답합니다. 이 구성은 65일차 이후에도 그대로 씁니다.
-자세한 순서는 **[사전 준비 안내 (SETUP-WSL-OLLAMA)](../../Last3Mon/64/SETUP-WSL-OLLAMA.md)** 를 따라 하세요. 할 일만 요약하면 다음과 같습니다.
+자세한 순서는 **[사전 준비 안내 (SETUP-WSL-LLM)](../../Last3Mon/64/SETUP-WSL-LLM.md)** 를 따라 하세요. 할 일만 요약하면 다음과 같습니다.
| 순서 | 할 일 | 시간 |
| --- | --- | --- |
| 1 | WSL(Ubuntu) 동작 확인 | 5분 |
-| 2 | 노트북 메모리 확인 → 사용할 모델 결정 (8GB: `gemma3:1b`, 16GB 이상: `gemma3:4b`) | 2분 |
-| 3 | uv 설치, 수업 저장소 받기 (WSL 홈 `~` 아래) | 10분 |
-| 4 | Ollama 설치와 모델 다운로드 | 10~20분 |
-| 5 | `~/.bashrc`에 `MINIFLOW_LLM`, `OLLAMA_MODEL` 설정 | 3분 |
+| 2 | uv 설치, 수업 자료 받기 (WSL 홈 `~` 아래) | 10분 |
+| 3 | `Last3Mon/.env`에 OpenAI API 키 넣기 | 3분 |
+| 4 | 노트북 메모리 확인 → 대체 모델 결정 (8GB: `gemma3:1b`, 16GB 이상: `gemma3:4b`) | 2분 |
+| 5 | Ollama 설치와 모델 다운로드 (대체용) | 10~20분 |
+| 6 | 연결 확인 (OpenAI, 대체 Ollama) | 5분 |
-> 모델 파일이 1~3GB라서 수업 중에 여러 명이 동시에 받으면 강의실 네트워크가 매우 느려집니다. **다운로드는 꼭 집에서 미리** 해 주세요.
+> Ollama 모델 파일이 1~3GB라서 수업 중에 여러 명이 동시에 받으면 강의실 네트워크가 매우 느려집니다. **다운로드는 꼭 집에서 미리** 해 주세요.
## 2. 준비가 끝났는지 확인 (전날 필수, 5분)
@@ -35,17 +36,19 @@ WSL 터미널에서 실습 폴더로 이동해 아래 두 명령을 실행합니
```bash
cd ~/<수업 저장소>/Last3Mon/64/workflow-lab
-uv run pytest -q # 12 passed, 1 skipped 가 나오면 성공
-uv run miniflow check # Reply (ollama, ...) 가 나오면 성공
+uv run pytest -q # 18 passed, 1 skipped 가 나오면 성공
+uv run miniflow check # Reply (openai, ...) 가 나오면 성공
+OPENAI_API_KEY=sk-wrong uv run miniflow check # Reply (ollama, ...) 가 나오면 대체도 준비 완료
```
| 결과 | 의미 |
| --- | --- |
-| 두 명령 모두 성공 | 준비 완료 |
-| `pytest`는 성공, `check`가 실패 | Ollama 설정 문제. 안내서 7절(문제 해결) 확인 |
+| 세 명령 모두 성공 | 준비 완료 |
+| `pytest`는 성공, 첫 `check`가 실패 | `.env`의 키 문제. 안내서 8절(문제 해결) 확인 |
+| 첫 `check`는 성공, 두 번째가 실패 | 대체용 Ollama 문제. 수업은 OpenAI로 할 수 있으니 1교시 전에 말씀해 주세요 |
| 둘 다 실패 | uv 또는 저장소 위치 문제. 안내서 3절부터 다시 확인 |
-해결이 안 되면 수업 당일 1교시 전에 말씀해 주세요. 그동안은 `export MINIFLOW_LLM=mock`으로 Mock LLM을 써서 실습을 따라올 수 있습니다.
+해결이 안 되면 수업 당일 1교시 전에 말씀해 주세요. 키를 아직 못 받았으면 `.env`의 `MINIFLOW_LLM=ollama`로, 둘 다 안 되면 `export MINIFLOW_LLM=mock`으로 Mock LLM을 써서 실습을 따라올 수 있습니다.
---
@@ -118,14 +121,16 @@ uv run miniflow check # Reply (ollama, ...) 가 나오면 성공
## 5. 당일 준비물
-- 충전된 노트북과 충전기 (Ollama는 배터리를 많이 사용합니다)
+- 충전된 노트북과 충전기 (대체용 Ollama는 배터리를 많이 사용합니다)
- Windows Terminal (WSL 터미널), VS Code
- 웹 브라우저 (워크플로우 그림 확인용 [Mermaid Live Editor](https://mermaid.live))
-- (선택) OpenAI/Anthropic/Gemini 중 가지고 있는 API 키 하나 (예: 10일차에 발급한 Gemini 키) — 로컬 모델과 클라우드 모델을 비교해 볼 수 있습니다
+- 과정에서 받은 OpenAI API 키 (`Last3Mon/.env`에 넣어 오기). 키는 채팅·문서·화면 캡처에 올리지 않습니다
+- (선택) Anthropic·Gemini 키가 있으면 (예: 10일차에 발급한 Gemini 키) 같은 워크플로우를 다른 제공사 모델로도 비교해 볼 수 있습니다
## 확인
-- [ ] `uv run pytest -q` → `12 passed, 1 skipped`
-- [ ] `uv run miniflow check` → `Reply (ollama, ...)`
+- [ ] `uv run pytest -q` → `18 passed, 1 skipped`
+- [ ] `uv run miniflow check` → `Reply (openai, ...)`
+- [ ] `OPENAI_API_KEY=sk-wrong uv run miniflow check` → `Reply (ollama, ...)`
- [ ] JSON 기초(3.1)를 읽었다
- [ ] Lab 03에서 분석할 업무 후보를 하나 생각했다