65일차 실습자료
This commit is contained in:
1 parent
d6a6c62a3f
commit
152344cb13
42 files changed
+4545
No files matched your search
@@ -0,0 +1,82 @@
|
||||
# Lab 01 · Condition으로 "결과 없음" 경로 만들기
|
||||
|
||||
- 관련 강의: [1단계 · Trigger·Condition·Action](../../Lecture/65/01-STAGE1-TRIGGER-CONDITION-ACTION.md)
|
||||
- 소요 시간: 약 40분
|
||||
- 목표: 64일차에 멈췄던 워크플로우를 Condition으로 고치고, Switch로 여러 갈래를 만든다.
|
||||
|
||||
## 1. 어제 멈춘 장면 다시 보기 (5분)
|
||||
|
||||
```bash
|
||||
cd ~/aio-01d4-stu/Last3Mon/65/workflow-lab
|
||||
uv sync
|
||||
uv run pytest -q
|
||||
uv run miniflow run flows/05-tech-support-ticket.json -i user=lee -i ticket="점심 메뉴 추천해줘"
|
||||
```
|
||||
|
||||
FAQ 검색 결과가 없는데 다음 노드가 첫 번째 FAQ를 꺼내려다 멈춥니다. 오늘은 이 경우를 **다른 길(Action)** 로 보냅니다.
|
||||
|
||||
## 2. Condition이 들어간 워크플로우 실행 (10분)
|
||||
|
||||
```bash
|
||||
uv run miniflow show flows/06-request-with-condition.json
|
||||
uv run miniflow run flows/06-request-with-condition.json -i user=kim -i text="VPN 접속이 계속 끊겨요"
|
||||
uv run miniflow run flows/06-request-with-condition.json -i user=lee -i text="점심 메뉴 추천해줘"
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
start[/"start"/] --> classify{{"classify<br/>llm"}}
|
||||
start --> faq["faq<br/>tool"]
|
||||
faq --> has_faq{"has_faq<br/>condition"}
|
||||
classify --> has_faq
|
||||
has_faq -->|true| draft{{"draft<br/>llm"}} --> auto_reply[\"auto_reply"\]
|
||||
has_faq -->|false| hand_off[\"hand_off"\]
|
||||
```
|
||||
|
||||
확인할 것
|
||||
|
||||
1. 두 실행에서 `has_faq` 노드의 출력(`branch`)은 각각 무엇인가요?
|
||||
2. 실행 기록에서 `skipped`로 표시된 노드는 어느 것인가요?
|
||||
3. 두 번째 실행의 `route` 값은 무엇인가요? 이제 워크플로우가 멈추지 않는 이유를 설명해 보세요.
|
||||
|
||||
## 3. Switch로 담당 팀 배정 (10분)
|
||||
|
||||
```bash
|
||||
uv run miniflow run flows/07-route-by-category.json -i text="비밀번호를 잊어버렸어요"
|
||||
uv run miniflow run flows/07-route-by-category.json -i text="회의실 예약은 어떻게 하나요"
|
||||
```
|
||||
|
||||
- `route` 노드는 분류 결과가 `cases` 중 하나이면 그 길로, 아니면 `default` 길로 보냅니다.
|
||||
- LLM이 `Software`처럼 대문자로 답하거나 `software.`처럼 마침표를 붙이면 정해진 값과 달라 `default`로 갑니다. OpenAI 모델은 대부분 지시를 지키지만, 대체용 작은 모델(Ollama)에서는 자주 일어납니다. 이런 경우를 막으려면 어떤 노드가 필요할까요?
|
||||
|
||||
## 4. 직접 만들기: 긴급 문의 분기 (15분)
|
||||
|
||||
`flows/06-request-with-condition.json`을 복사해 `flows/my-06-urgent.json`을 만들고, 다음 조건을 추가합니다.
|
||||
|
||||
- 문의에 "긴급"이라는 단어가 있으면 FAQ 검색과 상관없이 바로 담당자에게 연결합니다.
|
||||
|
||||
힌트
|
||||
|
||||
```json
|
||||
{"id": "is_urgent", "type": "condition", "check": {"left": "{{input.text}}", "op": "contains", "right": "긴급"}}
|
||||
```
|
||||
|
||||
- `start → is_urgent` 연결을 추가합니다.
|
||||
- `is_urgent`의 `true` 길은 새 output 노드 `urgent_call`로 보냅니다.
|
||||
- `false` 길이 기존 흐름(`has_faq`)으로 이어지도록 `["is_urgent", "has_faq", "false"]`를 추가합니다.
|
||||
|
||||
```bash
|
||||
uv run miniflow validate flows/my-06-urgent.json
|
||||
uv run miniflow run flows/my-06-urgent.json -i user=kim -i text="긴급! VPN이 끊겼어요"
|
||||
uv run miniflow run flows/my-06-urgent.json -i user=kim -i text="VPN이 끊겼어요"
|
||||
```
|
||||
|
||||
## 5. 설계표 작성 (선택)
|
||||
|
||||
[TCA 설계 템플릿](templates/TCA-DESIGN-TEMPLATE.md)을 `my-work/LAB-01-TCA.md`로 복사해, 64일차 Lab 03에서 분석한 시나리오를 Trigger·Condition·Action으로 다시 정리합니다.
|
||||
|
||||
## 확인
|
||||
|
||||
- [ ] `06` 워크플로우의 true·false 두 길을 모두 실행했다.
|
||||
- [ ] `07`에서 `default` 길이 선택되는 경우를 확인했다.
|
||||
- [ ] `my-06-urgent.json`이 `validate`와 두 입력의 실행을 통과했다.
|
||||
@@ -0,0 +1,102 @@
|
||||
# Lab 02 · n8n 화면 익히기와 첫 워크플로우
|
||||
|
||||
- 관련 강의: [2단계 · n8n 실습 환경과 기본 설정](../../Lecture/65/02-STAGE2-N8N-ENVIRONMENT.md)
|
||||
- 소요 시간: 약 45분
|
||||
- 준비: [SETUP-N8N.md](SETUP-N8N.md) 완료, 브라우저에서 http://localhost:5678 로그인
|
||||
|
||||
## 1. 워크플로우 가져오기 (5분)
|
||||
|
||||
1. 왼쪽 위 **+ (Create)** → **Workflow**로 빈 워크플로우를 엽니다.
|
||||
2. 오른쪽 위 **⋯ (더보기)** → **Import from File...** 을 선택합니다.
|
||||
3. `Last3Mon/65/n8n/workflows/01-condition-basics.json`을 고릅니다. Windows 파일 선택 창에서는 주소창에 `\\wsl$\Ubuntu\home\<사용자>\aio-01d4-stu\Last3Mon\65\n8n\workflows`를 입력하면 WSL 폴더로 이동합니다.
|
||||
4. 오른쪽 위 **Save**를 누릅니다.
|
||||
|
||||
같은 방법으로 `02-webhook-router.json`도 새 워크플로우로 가져와 둡니다.
|
||||
|
||||
## 2. Manual Trigger + IF: `65-01 Condition basics` (15분)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
T(["Start<br/>Manual Trigger"]) --> C["Sample requests<br/>Code"] --> I{"Is urgent?<br/>IF"}
|
||||
I -->|true| U["Urgent route<br/>Set"]
|
||||
I -->|false| N["Normal route<br/>Set"]
|
||||
```
|
||||
|
||||
1. 아래쪽 **Execute workflow** 버튼을 누릅니다.
|
||||
2. 각 노드를 더블클릭해 **INPUT / OUTPUT** 데이터를 확인합니다.
|
||||
|
||||
확인할 것
|
||||
|
||||
| 질문 | 내 답 |
|
||||
| --- | --- |
|
||||
| `Sample requests`가 만든 항목(item)은 몇 개인가? | |
|
||||
| `Is urgent?`의 true 출력과 false 출력에는 각각 몇 개의 항목이 있는가? | |
|
||||
| `Urgent route` 출력에 새로 생긴 필드 두 개는? | |
|
||||
|
||||
n8n은 항목(item) 여러 개를 한 번에 흘려보냅니다. IF 노드는 항목마다 조건을 검사해 두 출력으로 나눕니다.
|
||||
|
||||
### 직접 바꿔 보기
|
||||
|
||||
1. `Is urgent?` 노드를 열고 조건을 `{{ $json.text }}` **contains** `계정`으로 바꿔 다시 실행합니다. 결과가 어떻게 달라지나요?
|
||||
2. 원래 조건(`priority` equals `urgent`)으로 되돌립니다.
|
||||
|
||||
## 3. Webhook Trigger + Switch: `65-02 Webhook request router` (20분)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
W(["Request webhook<br/>POST /request-route"]) --> H{"Has text?<br/>IF"}
|
||||
H -->|false| R0["Reject empty<br/>400"]
|
||||
H -->|true| K["Keyword classify<br/>Code"] --> S{"Route by category<br/>Switch"}
|
||||
S -->|account·network·device·software| T1["Team ...<br/>Set"]
|
||||
S -->|other| T5["Team other<br/>Set"]
|
||||
T1 --> R["Send route<br/>Respond"]
|
||||
T5 --> R
|
||||
```
|
||||
|
||||
### 3-1. 테스트 실행 (Test URL)
|
||||
|
||||
1. `Request webhook` 노드를 열고 **Test URL**을 복사합니다. (`http://localhost:5678/webhook-test/request-route`)
|
||||
2. 노드 화면의 **Listen for test event**를 누른 상태에서 WSL 터미널에서 요청을 보냅니다.
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:5678/webhook-test/request-route \
|
||||
-H 'Content-Type: application/json' -d '{"user":"kim","text":"VPN 접속이 계속 끊겨요"}'
|
||||
```
|
||||
|
||||
3. 캔버스에서 실제로 지나간 길이 초록색으로 표시되는지 확인합니다.
|
||||
|
||||
### 3-2. 게시 후 실행 (Production URL)
|
||||
|
||||
1. 오른쪽 위 **Publish**(게시)를 켭니다.
|
||||
2. 이제 `/webhook/`(test 없음) 주소로 언제든 호출할 수 있습니다.
|
||||
|
||||
```bash
|
||||
for t in "VPN 접속이 계속 끊겨요" "모니터가 안 켜져요" "점심 메뉴 추천해줘" ""; do
|
||||
curl -s -w " [HTTP %{http_code}]\n" -X POST http://localhost:5678/webhook/request-route \
|
||||
-H 'Content-Type: application/json' -d "{\"user\":\"kim\",\"text\":\"$t\"}"
|
||||
done
|
||||
```
|
||||
|
||||
3. 왼쪽 메뉴 **Executions**에서 방금 실행된 기록 4건을 열어 봅니다.
|
||||
|
||||
| 입력 | 응답의 `team` 또는 오류 | 지나간 길 |
|
||||
| --- | --- | --- |
|
||||
| VPN 접속이 계속 끊겨요 | | |
|
||||
| 모니터가 안 켜져요 | | |
|
||||
| 점심 메뉴 추천해줘 | | |
|
||||
| (빈 문자열) | | |
|
||||
|
||||
### 3-3. 직접 바꿔 보기
|
||||
|
||||
`Keyword classify`의 규칙과 `Route by category`에 **"보안"** 분류를 추가해 보세요.
|
||||
|
||||
- Code 노드 `rules`에 `{ category: 'security', words: ['해킹', '피싱', '바이러스'] }` 추가
|
||||
- Switch 노드에 `security` 규칙 추가, 새 Set 노드 `Team security`(team: `보안 담당`) 연결
|
||||
- 저장 후 `"피싱 메일을 받은 것 같아요"`로 호출해 확인
|
||||
|
||||
## 확인
|
||||
|
||||
- [ ] 두 워크플로우를 가져와 저장했다.
|
||||
- [ ] IF 노드의 true/false 출력 항목 수를 확인했다.
|
||||
- [ ] Test URL과 Production URL의 차이를 직접 실행해 보았다.
|
||||
- [ ] Executions에서 실행 기록을 열어 보았다.
|
||||
@@ -0,0 +1,125 @@
|
||||
# Lab 03 · AI 문의 처리 워크플로우와 플랫폼 전환
|
||||
|
||||
- 관련 강의: [3단계 · 샘플 AI 워크플로우와 실행 흐름](../../Lecture/65/03-STAGE3-SAMPLE-AI-WORKFLOW.md)
|
||||
- 소요 시간: 약 45분
|
||||
- 목표: LLM이 들어간 Trigger·Condition·Action 워크플로우를 실행하고, 같은 업무를 Python에서 플랫폼과 상관없이 호출한다.
|
||||
|
||||
## 1. AI 문의 처리 워크플로우 가져오기 (10분)
|
||||
|
||||
1. Lab 02와 같은 방법으로 `03-ai-request-flow.json`을 가져와 저장합니다.
|
||||
2. `Settings` 노드를 열어 값을 확인합니다.
|
||||
|
||||
| 필드 | 값 | 바꿔야 하는 경우 |
|
||||
| --- | --- | --- |
|
||||
| `llm_url` | `https://api.openai.com/v1` | 대체: Ollama로 바꿀 때 `http://ollama:11434/v1` |
|
||||
| `model` | `gpt-4.1-mini` | 대체: Ollama로 바꿀 때 `gemma3:4b` |
|
||||
|
||||
`LLM classify`·`LLM draft reply` 노드의 Authentication이 **OpenAI → `OpenAI (.env)`** 인지 확인합니다 (SETUP 6단계에서 등록한 Credential).
|
||||
|
||||
3. **Publish**를 켭니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
W(["AI request webhook<br/>POST /ai-request"]) --> S["Settings<br/>Set"] --> H{"Has text?"}
|
||||
H -->|false| R0["Reject empty"]
|
||||
H -->|true| L1{{"LLM classify<br/>HTTP → OpenAI"}} --> N["Normalize and<br/>search FAQ<br/>Code"] --> F{"FAQ found?"}
|
||||
F -->|true| L2{{"LLM draft reply<br/>HTTP → OpenAI"}} --> A["Auto reply<br/>Set"] --> R["Send result"]
|
||||
F -->|false| P["Hand off to staff<br/>Set"] --> R
|
||||
```
|
||||
|
||||
## 2. 실행과 실행 기록 확인 (10분)
|
||||
|
||||
```bash
|
||||
for t in "VPN 접속이 계속 끊겨요" "비밀번호를 잊어버려서 로그인이 안 돼요" "점심 메뉴 추천해줘"; do
|
||||
curl -s -m 300 -w " [%{time_total}s]\n" -X POST http://localhost:5678/webhook/ai-request \
|
||||
-H 'Content-Type: application/json' -d "{\"user\":\"kim\",\"text\":\"$t\"}"
|
||||
done
|
||||
```
|
||||
|
||||
요청마다 3초 안팎 걸립니다 (OpenAI 호출 2회). "점심 메뉴"처럼 FAQ가 없는 문의는 LLM을 한 번만 불러 1초 안에 끝납니다.
|
||||
|
||||
**Executions**에서 각 실행을 열고 다음을 확인합니다.
|
||||
|
||||
| 확인할 것 | 어디서 |
|
||||
| --- | --- |
|
||||
| LLM이 돌려준 원래 분류값 | `Normalize and search FAQ` 출력의 `raw_label` |
|
||||
| 정리된 분류값 | 같은 출력의 `category` |
|
||||
| 어느 길로 갔나 | `FAQ found?` 노드의 true/false 출력 |
|
||||
| 가장 오래 걸린 노드 | 각 노드 오른쪽 위의 실행 시간 |
|
||||
|
||||
## 3. Python에서 플랫폼과 상관없이 호출하기 (15분)
|
||||
|
||||
같은 업무(`request`)를 n8n과 `miniflow` 두 플랫폼이 모두 처리할 수 있습니다. 호출하는 쪽은 업무 이름만 알고, 어느 플랫폼에서 실행할지는 설정이 정합니다.
|
||||
|
||||
```bash
|
||||
cd ~/aio-01d4-stu/Last3Mon/65/workflow-lab
|
||||
# LLM 설정은 Last3Mon/.env (MINIFLOW_LLM=openai, gpt-4.1-mini)
|
||||
uv run miniflow call request -i user=kim -i text="VPN 접속이 계속 끊겨요"
|
||||
```
|
||||
|
||||
```text
|
||||
Workflow: request platforms: n8n -> miniflow
|
||||
n8n ok 2506.8ms
|
||||
Served by: n8n
|
||||
```
|
||||
|
||||
### 장애 상황 만들기
|
||||
|
||||
```bash
|
||||
cd ../n8n && docker compose stop n8n && cd ../workflow-lab
|
||||
uv run miniflow call request -i user=kim -i text="VPN 접속이 계속 끊겨요"
|
||||
```
|
||||
|
||||
```text
|
||||
n8n failed 11.7ms n8n is not reachable at http://localhost:5678/webhook/ai-request (...)
|
||||
miniflow ok …ms
|
||||
Served by: miniflow
|
||||
```
|
||||
|
||||
n8n이 멈춰도 `miniflow`가 같은 업무를 이어받습니다. 확인이 끝나면 n8n을 다시 켭니다.
|
||||
|
||||
```bash
|
||||
cd ../n8n && docker compose start n8n && cd ../workflow-lab
|
||||
```
|
||||
|
||||
### 순서 바꾸기
|
||||
|
||||
```bash
|
||||
WORKFLOW_PLATFORMS=miniflow,n8n uv run miniflow call request -i user=kim -i text="프린터가 안 돼요"
|
||||
uv run miniflow call request --platforms n8n -i user=kim -i text="프린터가 안 돼요"
|
||||
```
|
||||
|
||||
어떤 플랫폼을 먼저 쓸지 바꿀 때 코드를 고쳤나요? 설정(환경 변수·옵션)만 바꿨나요?
|
||||
|
||||
### LLM도 바꿔 쓸 수 있게: OpenAI → Ollama 대체
|
||||
|
||||
플랫폼(n8n → miniflow)과 마찬가지로 LLM에도 대체 경로가 있습니다. `Last3Mon/.env`의 `MINIFLOW_LLM_FALLBACK=ollama` 덕분에 OpenAI 호출이 실패하면 Ollama로 한 번 더 시도합니다. 잘못된 키를 **이 명령에서만** 넣어 실패를 만들어 봅니다 (`.env`는 고치지 않습니다).
|
||||
|
||||
```bash
|
||||
OPENAI_API_KEY=sk-invalid-key-for-demo-0000000000 \
|
||||
uv run miniflow run flows/06-request-with-condition.json -i user=kim -i text="VPN 접속이 계속 끊겨요"
|
||||
```
|
||||
|
||||
```text
|
||||
[miniflow] openai failed (OpenAI rejected the API key (check OPENAI_API_KEY in .env)); trying ollama
|
||||
...
|
||||
"route": "자동 답변",
|
||||
```
|
||||
|
||||
- 수업 전 측정: OpenAI 대신 Ollama(`gemma3:4b`)가 답해 같은 `자동 답변`이 나왔습니다. 대신 시간이 더 걸립니다 (모델을 처음 올리면 20초 이상).
|
||||
- n8n의 HTTP Request 노드는 Settings의 `llm_url`·`model`을 Ollama 값으로 바꾸면 같은 워크플로우가 동작합니다 (Ollama도 `/v1/chat/completions` 형식을 지원).
|
||||
|
||||
## 4. 정리 질문 (10분)
|
||||
|
||||
`my-work/LAB-03-ANSWERS.md`에 답을 적습니다.
|
||||
|
||||
1. n8n 워크플로우와 `06-request-with-condition.json`은 같은 업무를 처리합니다. 노드 구성에서 같은 점과 다른 점을 두 가지씩 적어 보세요.
|
||||
2. `Normalize and search FAQ` 노드에서 LLM의 분류값을 정해진 값으로 바꾸는 이유는 무엇인가요?
|
||||
3. 플랫폼 전환이 되려면 두 플랫폼의 **입력과 출력 형식**이 같아야 합니다. 이 실습에서 같은 형식으로 맞춘 필드는 무엇인가요?
|
||||
4. 플랫폼을 바꿀 때 바꾸지 않아도 되는 것과, 다시 만들어야 하는 것은 각각 무엇인가요?
|
||||
|
||||
## 확인
|
||||
|
||||
- [ ] `03` 워크플로우의 자동 답변·담당자 전달 두 길을 모두 실행했다.
|
||||
- [ ] Executions에서 `raw_label`과 `category`를 비교했다.
|
||||
- [ ] n8n을 멈춘 상태에서 `miniflow`로 전환되는 것을 확인했다.
|
||||
@@ -0,0 +1,53 @@
|
||||
# 65일차 실습 · Trigger·Condition·Action과 n8n
|
||||
|
||||
- 일자: 2026-10-15 (65일차)
|
||||
- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영
|
||||
- 단원: AI 워크플로우 개념 및 구성 요소 이해
|
||||
- 강의 자료: [Lecture/65](../../Lecture/65/README.md)
|
||||
|
||||
## 구성
|
||||
|
||||
```text
|
||||
Last3Mon/65/
|
||||
├── README.md
|
||||
├── SETUP-N8N.md # 사전 준비 · API 키(.env), n8n 실행, Credential 등록
|
||||
├── LAB-01-CONDITION-IN-MINIFLOW.md # 1단계 · Condition·Switch로 분기 만들기
|
||||
├── LAB-02-N8N-FIRST-WORKFLOWS.md # 2단계 · n8n 화면 익히기, IF·Webhook·Switch
|
||||
├── LAB-03-AI-WORKFLOW-FAILOVER.md # 3단계 · AI 문의 처리 워크플로우와 플랫폼 전환
|
||||
├── templates/
|
||||
│ └── TCA-DESIGN-TEMPLATE.md # Trigger·Condition·Action 설계표
|
||||
├── my-work/ # 여러분의 결과물 (직접 만듭니다)
|
||||
├── n8n/
|
||||
│ ├── compose.yml # n8n 2.42.6 + Ollama (대체용)
|
||||
│ ├── .env.example # 64일차 모델 폴더 연결 설정 (키는 넣지 않음)
|
||||
│ ├── register-credentials.sh # Last3Mon/.env의 키를 n8n Credential로 등록
|
||||
│ └── workflows/ # n8n에 가져올 워크플로우 3개
|
||||
└── workflow-lab/ # miniflow (64일차 버전 + condition·switch·call)
|
||||
```
|
||||
|
||||
## 실습 순서
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
L1["Lab 01<br/>miniflow Condition·Switch<br/>(1~2교시)"] --> L2["Lab 02<br/>n8n 화면·IF·Webhook·Switch<br/>(3~4교시)"] --> L3["Lab 03<br/>AI 문의 처리·플랫폼 전환<br/>(5~6교시)"]
|
||||
```
|
||||
|
||||
| n8n 워크플로우 | Trigger | Condition | Action |
|
||||
| --- | --- | --- | --- |
|
||||
| `01-condition-basics.json` | Manual | IF (긴급 여부) | 처리 경로·처리 기한 지정 |
|
||||
| `02-webhook-router.json` | Webhook `POST /request-route` | IF (내용 있음) → Switch (분류) | 담당 팀 지정, 응답 |
|
||||
| `03-ai-request-flow.json` | Webhook `POST /ai-request` | IF (내용 있음) → IF (FAQ 있음) | LLM 분류·답변 작성, 담당자 전달, 응답 |
|
||||
|
||||
| miniflow 워크플로우 | 새로 쓰는 노드 |
|
||||
| --- | --- |
|
||||
| `06-request-with-condition.json` | `condition` (FAQ 있음?) → 자동 답변 / 담당자 전달 |
|
||||
| `07-route-by-category.json` | `switch` (분류값) → 담당 팀 5갈래 |
|
||||
|
||||
## 준비
|
||||
|
||||
**[SETUP-N8N.md](SETUP-N8N.md)** 를 수업 전날 끝내고, 아래가 모두 되는지 확인합니다.
|
||||
|
||||
```bash
|
||||
cd ~/aio-01d4-stu/Last3Mon/65/workflow-lab && uv sync && uv run pytest -q # 35 passed, 1 skipped
|
||||
cd ../n8n && docker compose ps # n8n, ollama running
|
||||
```
|
||||
@@ -0,0 +1,173 @@
|
||||
# 사전 준비 · n8n 실행과 OpenAI API 키 연결
|
||||
|
||||
65일차부터 노코드 워크플로우 도구 **n8n**을 사용합니다. 수업의 LLM은 **OpenAI API**(기본 모델 `gpt-4.1-mini`)입니다. API 키는 git에 올라가지 않는 `.env` 파일 한 곳에만 두고, n8n과 WSL의 `miniflow`가 모두 그 파일에서 키를 읽습니다. 64일차의 Ollama는 **대체용**으로 같은 Compose에서 함께 실행합니다 (OpenAI가 실패하거나 키가 없을 때).
|
||||
|
||||
> 이미지 다운로드가 약 5GB입니다(n8n 1.1GB, Ollama 4.2GB). **수업 전날 집에서 5단계까지 끝내 주세요.** 9월에 Docker로 Ollama를 써 봤다면 Ollama 이미지는 이미 있을 수 있습니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
B["Windows 브라우저<br/>localhost:5678"] --> N
|
||||
E[".env<br/>OPENAI_API_KEY<br/>(git 제외)"] -.-> M & C
|
||||
subgraph WSL["WSL2 (Ubuntu)"]
|
||||
M["miniflow<br/>(uv 프로젝트)"]
|
||||
subgraph DC["Docker Compose · day65-n8n"]
|
||||
N["n8n :5678"] -. "대체" .-> O["Ollama :11434"]
|
||||
end
|
||||
C["register-credentials.sh"] -->|"Credential 등록"| N
|
||||
M -->|"http://localhost:5678/webhook/..."| N
|
||||
end
|
||||
N -->|"OpenAI (.env) Credential"| AI["OpenAI API<br/>gpt-4.1-mini"]
|
||||
M -->|"OPENAI_API_KEY"| AI
|
||||
```
|
||||
|
||||
## 1. WSL에 Docker 설치 (10분)
|
||||
|
||||
Windows용 Docker Desktop은 쓰지 않습니다. **WSL(Ubuntu) 안에 Docker Engine을 직접 설치**하고, 컨테이너 상태를 보는 도구 **ctop**도 함께 설치합니다. 설치 스크립트와 설명은 [install-sh · 2. 시스템 & 컨테이너 DevOps](https://gl.isitsoft.kr/publicproj/install-sh#-2-%EC%8B%9C%EC%8A%A4%ED%85%9C--%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88-devops-1)의 **Docker**와 **ctop** 항목을 참고합니다.
|
||||
|
||||
WSL 터미널에서 실행합니다.
|
||||
|
||||
```bash
|
||||
# Docker Engine + Compose 플러그인 (Ubuntu 공식 저장소, sudo 비밀번호 입력)
|
||||
curl -fsSL "https://gl.isitsoft.kr/publicproj/install-sh/-/raw/main/install-docker.sh?ref_type=heads" | bash
|
||||
# ctop: 컨테이너별 CPU·메모리를 실시간으로 보는 도구
|
||||
curl -fsSL "https://gl.isitsoft.kr/publicproj/install-sh/-/raw/main/install-ctop.sh?ref_type=heads" | bash
|
||||
```
|
||||
|
||||
설치 스크립트가 내 계정을 `docker` 그룹에 넣습니다. 그룹은 **새로 로그인해야** 적용되므로 WSL 터미널을 모두 닫고 다시 엽니다 (지금 창에서 바로 쓰려면 `newgrp docker`).
|
||||
|
||||
```bash
|
||||
docker version # Client와 Server가 모두 나오면 성공
|
||||
docker compose version
|
||||
sudo systemctl enable --now docker # 노트북을 켤 때 Docker가 자동으로 시작되게 (이미 켜져 있으면 그대로)
|
||||
docker run --rm hello-world # "Hello from Docker!"
|
||||
```
|
||||
|
||||
| 증상 | 해결 |
|
||||
| --- | --- |
|
||||
| `permission denied ... docker.sock` | `docker` 그룹이 아직 적용 안 됨 → WSL 터미널을 모두 닫고 다시 열기 (또는 `newgrp docker`) |
|
||||
| `Cannot connect to the Docker daemon` | `sudo systemctl start docker`. `System has not been booted with systemd`가 나오면 `sudo service docker start` |
|
||||
| 이미 Docker Desktop이 설치되어 있음 | Docker Desktop을 종료하고(트레이 아이콘 → Quit), Settings → Resources → WSL integration에서 Ubuntu를 끈 뒤 위 설치를 진행 (두 Docker가 섞이지 않게) |
|
||||
| `Docker is already installed ... Skipping` | 이미 WSL에 Docker가 있음. `docker version`의 Server 줄만 확인 |
|
||||
|
||||
`ctop`을 실행하면 n8n·Ollama 컨테이너의 CPU·메모리를 실시간으로 볼 수 있습니다 (`q`로 종료). 노트북이 느려질 때 어느 컨테이너 때문인지 확인할 때 씁니다.
|
||||
|
||||
## 2. API 키 `.env` 확인 (1분)
|
||||
|
||||
64일차에 `Last3Mon/.env`를 만들어 키를 넣었다면 아래 표만 확인하고 넘어갑니다 (`cat`으로 열지 말고 `grep -c '^OPENAI_API_KEY=sk-' ~/aio-01d4-stu/Last3Mon/.env`가 `1`이면 됨). 아직 없다면 다음 순서로 만듭니다.
|
||||
|
||||
과정에서 받은 OpenAI API 키를 `Last3Mon/.env` 한 곳에 넣습니다. 64일차 이후 모든 실습이 이 파일을 찾아 읽습니다 (실행한 폴더에서 위로 올라가며 가장 가까운 `.env`).
|
||||
|
||||
```bash
|
||||
cd ~/aio-01d4-stu/Last3Mon
|
||||
cp .env.example .env
|
||||
nano .env # OPENAI_API_KEY= 뒤에 키를 붙여 넣고 저장 (Ctrl+O, Enter, Ctrl+X)
|
||||
git status --short .env # 아무것도 나오지 않으면 git이 무시하는 파일 (정상)
|
||||
```
|
||||
|
||||
| 줄 | 뜻 |
|
||||
| --- | --- |
|
||||
| `OPENAI_API_KEY=` | 받은 키 (`sk-`로 시작). **문서·워크플로우·채팅에 붙여 넣지 않습니다** |
|
||||
| `OPENAI_MODEL=gpt-4.1-mini` | 수업 기본 모델 |
|
||||
| `OPENAI_EMBED_MODEL=text-embedding-3-large` | 71일차부터 쓰는 임베딩 모델 (한국어 규정 검색에서 small보다 정확) |
|
||||
| `MINIFLOW_LLM=openai` | `miniflow`가 OpenAI를 쓰게 함 (`mock`이면 LLM 없이 정해진 답) |
|
||||
| `MINIFLOW_LLM_FALLBACK=ollama` | OpenAI 호출이 실패하면 Ollama로 한 번 더 시도 |
|
||||
|
||||
> `.env`는 `.gitignore`에 들어 있어 커밋되지 않습니다. 키가 들어간 파일을 다른 이름으로 복사해 두지 마세요 (`.env.bak` 같은 이름도 커밋 대상이 될 수 있습니다).
|
||||
|
||||
## 3. WSL의 Ollama 서비스 멈추기 (2분)
|
||||
|
||||
64일차에 WSL에 설치한 Ollama가 11434 포트를 쓰고 있으면 Docker의 Ollama가 시작되지 않습니다. 대체용 Ollama는 Docker 쪽 하나만 사용합니다.
|
||||
|
||||
```bash
|
||||
sudo systemctl stop ollama # 오늘 수업 동안 멈춤
|
||||
sudo systemctl disable ollama # (선택) 노트북을 켤 때 자동으로 시작하지 않게 함
|
||||
```
|
||||
|
||||
64일차 실습을 다시 할 때는 `sudo systemctl start ollama`로 다시 켜면 됩니다.
|
||||
|
||||
## 4. 모델 폴더 연결 설정 (2분)
|
||||
|
||||
64일차에 받은 모델을 다시 받지 않도록, Docker의 Ollama가 같은 모델 폴더를 쓰게 합니다. 이 `.env`는 Compose 설정용이고 API 키는 넣지 않습니다.
|
||||
|
||||
```bash
|
||||
cd ~/aio-01d4-stu/Last3Mon/65/n8n
|
||||
cp .env.example .env
|
||||
ls /usr/share/ollama/.ollama/models 2>/dev/null && echo "서비스 설치 경로 사용" || echo "홈 경로 확인 필요"
|
||||
```
|
||||
|
||||
| 결과 | `.env`에 둘 줄 |
|
||||
| --- | --- |
|
||||
| `서비스 설치 경로 사용` | `OLLAMA_DATA=/usr/share/ollama/.ollama` (기본값 그대로) |
|
||||
| `홈 경로 확인 필요` | `OLLAMA_DATA=/home/<WSL 사용자 이름>/.ollama` 로 바꾸기 |
|
||||
|
||||
## 5. n8n 실행과 첫 설정 (이미지 다운로드 10~30분)
|
||||
|
||||
```bash
|
||||
cd ~/aio-01d4-stu/Last3Mon/65/n8n
|
||||
docker compose up -d
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
- `docker compose ps`에서 `n8n`과 `ollama`가 모두 `running`이면 성공입니다.
|
||||
- Windows 브라우저에서 **http://localhost:5678** 을 열고, 처음 한 번 관리자 계정(이메일·이름·비밀번호)을 만듭니다. 이 계정은 내 노트북 안에만 저장되며, 외부로 가입되는 것이 아닙니다.
|
||||
- 설문 화면이 나오면 건너뛰어도 됩니다.
|
||||
|
||||
## 6. n8n에 Credential 등록 (2분)
|
||||
|
||||
`.env`의 키를 n8n의 **Credential**로 등록합니다. 키를 화면에 직접 붙여 넣지 않아도 되고, 수업 워크플로우가 쓰는 이름·ID로 만들어집니다.
|
||||
|
||||
```bash
|
||||
cd ~/aio-01d4-stu/Last3Mon/65/n8n
|
||||
bash register-credentials.sh
|
||||
```
|
||||
|
||||
```text
|
||||
Successfully imported 2 credentials.
|
||||
Registered 'OpenAI (.env)' and 'Ollama local' from /home/.../Last3Mon/.env
|
||||
```
|
||||
|
||||
n8n 화면 왼쪽 **Credentials**(또는 Overview → Credentials)에 `OpenAI (.env)`와 `Ollama local`이 보이면 성공입니다. 키를 바꾸면 `.env`를 고치고 이 명령을 다시 실행합니다.
|
||||
|
||||
## 7. 확인: OpenAI 호출 한 번
|
||||
|
||||
```bash
|
||||
cd ~/aio-01d4-stu/Last3Mon/65/workflow-lab
|
||||
uv sync
|
||||
uv run miniflow check
|
||||
```
|
||||
|
||||
```text
|
||||
MINIFLOW_LLM : openai
|
||||
.env : /home/.../Last3Mon/.env
|
||||
OPENAI_MODEL : gpt-4.1-mini
|
||||
API key : set
|
||||
Reply (openai, 2.9s): ready
|
||||
```
|
||||
|
||||
## 8. 끄고 켜기
|
||||
|
||||
```bash
|
||||
docker compose stop # 수업이 끝나면 멈춤 (데이터는 유지)
|
||||
docker compose start # 다음에 다시 켬
|
||||
docker compose down # 컨테이너 삭제 (워크플로우·계정·Credential은 n8n-data 볼륨에 유지)
|
||||
```
|
||||
|
||||
## 9. 문제 해결
|
||||
|
||||
| 증상 | 해결 |
|
||||
| --- | --- |
|
||||
| `miniflow check`가 `OPENAI_API_KEY is not set` | 2단계: `Last3Mon/.env`에 키가 있는지, 파일 이름이 정확히 `.env`인지 확인 |
|
||||
| `HTTP 401` / `rejected the API key` | 키를 잘못 붙여 넣음 (앞뒤 공백·따옴표 확인) → `.env` 수정 후 6단계 다시 |
|
||||
| `HTTP 429 insufficient_quota` | 키의 조직에 API 크레딧이 없음 → 강사·운영진에 알림 |
|
||||
| `register-credentials.sh`가 `No .env with OPENAI_API_KEY found` | 2단계를 `Last3Mon` 폴더에서 했는지 확인 |
|
||||
| `port is already allocated` (11434) | 3단계: `sudo systemctl stop ollama` |
|
||||
| `port is already allocated` (5678) | 다른 n8n이 실행 중인지 `docker ps`로 확인 후 멈춤 |
|
||||
| 브라우저에서 `localhost:5678`이 열리지 않음 | `docker compose ps`로 상태 확인, `docker compose logs n8n`으로 오류 확인. WSL 안의 컨테이너도 Windows 브라우저에서 `localhost`로 열리는 것이 기본이며, 계속 안 되면 PowerShell에서 `wsl --shutdown` 후 WSL을 다시 열기 |
|
||||
| 웹훅 호출이 404 (`not registered`) | 워크플로우 오른쪽 위 **Publish** 토글 확인. 같은 워크플로우를 여러 번 가져왔다면 기존 것을 삭제하고 한 번만 가져온 뒤 Publish |
|
||||
|
||||
## 확인
|
||||
|
||||
- [ ] `Last3Mon/.env`에 키를 넣었고, `git status`에 `.env`가 보이지 않는다
|
||||
- [ ] `docker compose ps` → n8n, ollama 모두 running
|
||||
- [ ] n8n Credentials에 `OpenAI (.env)`, `Ollama local`이 있다
|
||||
- [ ] `uv run miniflow check` → `Reply (openai, …): ready`
|
||||
@@ -0,0 +1,7 @@
|
||||
# Compose settings for the day 65 n8n + Ollama (fallback) containers. No API key here:
|
||||
# the OpenAI key goes in Last3Mon/.env, and register-credentials.sh puts it into n8n.
|
||||
# Copy to .env and keep the line that matches how Ollama was installed on day 64.
|
||||
# Ollama installed with the install script (runs as a service):
|
||||
OLLAMA_DATA=/usr/share/ollama/.ollama
|
||||
# Ollama started by hand with `ollama serve`:
|
||||
# OLLAMA_DATA=/home/<your-wsl-user>/.ollama
|
||||
@@ -0,0 +1,35 @@
|
||||
# n8n (LLM: OpenAI API, key from Last3Mon/.env via register-credentials.sh) + Ollama as the fallback.
|
||||
# docker compose up -d (run from this folder)
|
||||
# n8n editor : http://localhost:5678
|
||||
# Ollama API : http://localhost:11434 (n8n uses http://ollama:11434 inside the network)
|
||||
name: day65-n8n
|
||||
|
||||
services:
|
||||
n8n:
|
||||
image: docker.n8n.io/n8nio/n8n:2.42.6
|
||||
ports:
|
||||
- "5678:5678"
|
||||
environment:
|
||||
- GENERIC_TIMEZONE=Asia/Seoul
|
||||
- TZ=Asia/Seoul
|
||||
- N8N_SECURE_COOKIE=false
|
||||
volumes:
|
||||
- n8n-data:/home/node/.n8n
|
||||
- ./workflows:/home/node/workflows:ro
|
||||
depends_on:
|
||||
- ollama
|
||||
restart: unless-stopped
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
ports:
|
||||
- "11434:11434"
|
||||
volumes:
|
||||
# Reuse the models downloaded on day 64 by setting OLLAMA_DATA in .env
|
||||
# (default: a new Docker volume, then run: docker compose exec ollama ollama pull gemma3:4b)
|
||||
- ${OLLAMA_DATA:-ollama-data}:/root/.ollama
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
n8n-data:
|
||||
ollama-data:
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# Register the course credentials in n8n from the .env file, so the API key is never typed into
|
||||
# a workflow or committed to git.
|
||||
# cd Last3Mon/65/n8n && docker compose up -d && bash register-credentials.sh
|
||||
#
|
||||
# Creates (or updates) two credentials with fixed ids that the course workflows already use:
|
||||
# OpenAI (.env) id openAiCourse0001 <- OPENAI_API_KEY (and OPENAI_BASE_URL if set)
|
||||
# Ollama local id ollamaLocal0001 <- http://ollama:11434 (fallback model)
|
||||
#
|
||||
# Outage drill (day 72): OPENAI_API_KEY=sk-invalid-key-for-drill-000000 bash register-credentials.sh
|
||||
# Restore: bash register-credentials.sh
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# The nearest .env (this folder first, then the folders above) that has OPENAI_API_KEY.
|
||||
env_file=""
|
||||
dir="$PWD"
|
||||
while [ "$dir" != "/" ]; do
|
||||
if [ -f "$dir/.env" ] && grep -q '^OPENAI_API_KEY=' "$dir/.env"; then
|
||||
env_file="$dir/.env"
|
||||
break
|
||||
fi
|
||||
dir="$(dirname "$dir")"
|
||||
done
|
||||
if [ -z "$env_file" ]; then
|
||||
echo "No .env with OPENAI_API_KEY found. Copy Last3Mon/.env.example to Last3Mon/.env and fill it in." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
value() { { grep -E "^$1=" "$env_file" || true; } | tail -1 | cut -d= -f2- | sed -e 's/^["'\'']//' -e 's/["'\'']$//'; }
|
||||
key="${OPENAI_API_KEY:-$(value OPENAI_API_KEY)}" # a key exported in the shell wins (used for outage drills)
|
||||
base_url="$(value OPENAI_BASE_URL)"
|
||||
base_url="${base_url:-https://api.openai.com/v1}"
|
||||
if ! printf '%s' "$key" | grep -Eq '^[A-Za-z0-9_-]{20,}$'; then
|
||||
echo "OPENAI_API_KEY in $env_file is empty or does not look like an API key." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
credentials=$(printf '[{"id":"openAiCourse0001","name":"OpenAI (.env)","type":"openAiApi","data":{"apiKey":"%s","url":"%s"}},{"id":"ollamaLocal0001","name":"Ollama local","type":"ollamaApi","data":{"baseUrl":"http://ollama:11434"}}]' "$key" "$base_url")
|
||||
|
||||
# The JSON goes through stdin into a temporary file inside the container, then is deleted.
|
||||
printf '%s' "$credentials" | docker compose exec -T n8n sh -c \
|
||||
'cat > /tmp/course-credentials.json && n8n import:credentials --input=/tmp/course-credentials.json; status=$?; rm -f /tmp/course-credentials.json; exit $status'
|
||||
|
||||
echo "Registered 'OpenAI (.env)' and 'Ollama local' from $env_file"
|
||||
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"name": "65-01 Condition basics",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "26dc330d-6170-5f39-aeb4-d1bc63ed9d26",
|
||||
"name": "Start",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "// Three sample requests. In a real workflow they would come from a form, mail or webhook.\nreturn [\n { json: { id: 'T-1', text: 'VPN이 끊겨서 고객 미팅에 들어갈 수 없어요', priority: 'urgent' } },\n { json: { id: 'T-2', text: '프린터 토너 교체를 요청합니다', priority: 'normal' } },\n { json: { id: 'T-3', text: '계정이 잠겨서 결재를 올릴 수 없어요', priority: 'urgent' } },\n];"
|
||||
},
|
||||
"id": "636773ca-d229-564c-88cb-54b530628910",
|
||||
"name": "Sample requests",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
220,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "loose",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "4db4fdb9-dbaf-51bb-aaf9-16a54ef93027",
|
||||
"leftValue": "={{ $json.priority }}",
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"operation": "equals"
|
||||
},
|
||||
"rightValue": "urgent"
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "e54594bf-eb8b-5d83-96db-ef857683c127",
|
||||
"name": "Is urgent?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.2,
|
||||
"position": [
|
||||
440,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "1f7b6098-3185-56cc-9d8e-4bcfec1e7ba4",
|
||||
"name": "route",
|
||||
"value": "즉시 담당자 연결",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "c01612ed-a8a0-5a0a-83b9-93f6ad2eaf31",
|
||||
"name": "sla_minutes",
|
||||
"value": "30",
|
||||
"type": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"includeOtherFields": true,
|
||||
"options": {}
|
||||
},
|
||||
"id": "1d7d3d65-295f-599e-909f-8d5708d8ff46",
|
||||
"name": "Urgent route",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [
|
||||
680,
|
||||
-100
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "e7c65f93-009f-5a5a-8cad-71abcc7cc569",
|
||||
"name": "route",
|
||||
"value": "일반 접수",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "7148df87-83e5-54ca-9d67-619c60e6fe54",
|
||||
"name": "sla_minutes",
|
||||
"value": "480",
|
||||
"type": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"includeOtherFields": true,
|
||||
"options": {}
|
||||
},
|
||||
"id": "9b40293c-c2f0-55a6-b1ba-232770d31ee9",
|
||||
"name": "Normal route",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [
|
||||
680,
|
||||
100
|
||||
]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Start": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Sample requests",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Sample requests": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Is urgent?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Is urgent?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Urgent route",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Normal route",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"pinData": {},
|
||||
"id": "aeb87743007e5e"
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
{
|
||||
"name": "65-02 Webhook request router",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "request-route",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"id": "8dc39098-cbf2-5da7-b6a3-0541c2838c7d",
|
||||
"name": "Request webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"webhookId": "612ab961-6b30-5c73-a4b0-b113262a92e8"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "loose",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "0e03d4eb-275c-5799-8c08-3e744e4692e2",
|
||||
"leftValue": "={{ $json.body.text }}",
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"operation": "notEmpty",
|
||||
"singleValue": true
|
||||
},
|
||||
"rightValue": ""
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "d19bdfca-d55e-5760-a488-458abc7a5480",
|
||||
"name": "Has text?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.2,
|
||||
"position": [
|
||||
220,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ { \"error\": \"text is required\" } }}",
|
||||
"options": {
|
||||
"responseCode": 400
|
||||
}
|
||||
},
|
||||
"id": "5adfc553-1a2c-55fd-a4bd-d6564197222a",
|
||||
"name": "Reject empty",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"position": [
|
||||
460,
|
||||
160
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "// Rule-based classification (no AI yet): the first rule whose keyword appears wins.\nconst rules = [\n { category: 'account', words: ['비밀번호', '계정', '로그인', '잠겨', '잠김'] },\n { category: 'network', words: ['vpn', '인터넷', '와이파이', '접속', '네트워크'] },\n { category: 'device', words: ['프린터', '모니터', '키보드', '마우스', '노트북'] },\n { category: 'software', words: ['설치', '프로그램', '라이선스', '메일', '업데이트'] },\n];\nconst body = $input.first().json.body;\nconst text = String(body.text).toLowerCase();\nconst hit = rules.find(r => r.words.some(w => text.includes(w)));\nreturn [{ json: { user: body.user || 'anonymous', text: body.text, category: hit ? hit.category : 'other' } }];"
|
||||
},
|
||||
"id": "555904e3-48b4-5e74-bbe5-11a9f9153504",
|
||||
"name": "Keyword classify",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
460,
|
||||
-40
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"rules": {
|
||||
"values": [
|
||||
{
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "loose",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "befaac8f-1c7b-5a01-afb6-1dfca95d48c5",
|
||||
"leftValue": "={{ $json.category }}",
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"operation": "equals"
|
||||
},
|
||||
"rightValue": "account"
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"renameOutput": true,
|
||||
"outputKey": "account"
|
||||
},
|
||||
{
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "loose",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "fd212d44-e661-5050-b426-360a5d39ad96",
|
||||
"leftValue": "={{ $json.category }}",
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"operation": "equals"
|
||||
},
|
||||
"rightValue": "network"
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"renameOutput": true,
|
||||
"outputKey": "network"
|
||||
},
|
||||
{
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "loose",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "eff4a459-e7e8-5c5a-84f1-3670a5bd03c6",
|
||||
"leftValue": "={{ $json.category }}",
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"operation": "equals"
|
||||
},
|
||||
"rightValue": "device"
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"renameOutput": true,
|
||||
"outputKey": "device"
|
||||
},
|
||||
{
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "loose",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "862c0a38-3a85-5499-99f8-f0ffee9a6825",
|
||||
"leftValue": "={{ $json.category }}",
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"operation": "equals"
|
||||
},
|
||||
"rightValue": "software"
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"renameOutput": true,
|
||||
"outputKey": "software"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"fallbackOutput": "extra",
|
||||
"renameFallbackOutput": "other"
|
||||
}
|
||||
},
|
||||
"id": "2c251693-82e5-56cf-9a73-ed6bc2eebdf4",
|
||||
"name": "Route by category",
|
||||
"type": "n8n-nodes-base.switch",
|
||||
"typeVersion": 3.2,
|
||||
"position": [
|
||||
680,
|
||||
-40
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "241fe967-6e70-51db-b65d-a59655f888b0",
|
||||
"name": "team",
|
||||
"value": "계정 관리 담당",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"includeOtherFields": true,
|
||||
"options": {}
|
||||
},
|
||||
"id": "65675630-6d1a-55f5-adaa-b40125e2d453",
|
||||
"name": "Team account",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [
|
||||
920,
|
||||
-240
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "91e77bc6-3674-5f2a-a997-d831997f5a28",
|
||||
"name": "team",
|
||||
"value": "네트워크 담당",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"includeOtherFields": true,
|
||||
"options": {}
|
||||
},
|
||||
"id": "0b91914c-9289-5f56-adc2-6d697240358c",
|
||||
"name": "Team network",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [
|
||||
920,
|
||||
-130
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "3fdfa927-2342-5f8a-95c2-bc5a1e0b87d9",
|
||||
"name": "team",
|
||||
"value": "장비 지원 담당",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"includeOtherFields": true,
|
||||
"options": {}
|
||||
},
|
||||
"id": "0b918a6f-2ed5-5007-90f8-cc7911628ce7",
|
||||
"name": "Team device",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [
|
||||
920,
|
||||
-20
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "251f69b0-7fa1-52d0-b73f-4a3f102fff35",
|
||||
"name": "team",
|
||||
"value": "소프트웨어 담당",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"includeOtherFields": true,
|
||||
"options": {}
|
||||
},
|
||||
"id": "ac9cabd3-3a8a-5055-af0a-3ad481eba531",
|
||||
"name": "Team software",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [
|
||||
920,
|
||||
90
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "7602acd9-08cd-500a-acbd-bd4656159712",
|
||||
"name": "team",
|
||||
"value": "IT 문의 접수 창구",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"includeOtherFields": true,
|
||||
"options": {}
|
||||
},
|
||||
"id": "71c63724-4e06-5a26-bec9-ff4b97f9328c",
|
||||
"name": "Team other",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [
|
||||
920,
|
||||
200
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ $json }}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "365b7747-c4de-5b2e-9dcc-3dc3f3d2d9ac",
|
||||
"name": "Send route",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"position": [
|
||||
1160,
|
||||
-40
|
||||
]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Request webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Has text?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Has text?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Keyword classify",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Reject empty",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Keyword classify": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Route by category",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Route by category": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Team account",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Team network",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Team device",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Team software",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Team other",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Team account": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send route",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Team network": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send route",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Team device": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send route",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Team software": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send route",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Team other": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send route",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"pinData": {},
|
||||
"id": "af8dc0f7ef905c"
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
{
|
||||
"name": "65-03 AI request flow",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "ai-request",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"id": "07b51032-62c2-549b-8cfb-e3f16a851ed7",
|
||||
"name": "AI request webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"webhookId": "26aa9aad-522f-5f3d-8b58-ae0d7c18ce66"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "cf988cf6-5aec-59ef-887c-93252d7d366b",
|
||||
"name": "llm_url",
|
||||
"value": "https://api.openai.com/v1",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "6d229077-7077-5e85-8ebb-8f6046227539",
|
||||
"name": "model",
|
||||
"value": "gpt-4.1-mini",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "8a827ce4-4716-5aad-ad39-9c105afcf895",
|
||||
"name": "user",
|
||||
"value": "={{ $json.body.user || 'anonymous' }}",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "462379ca-3e96-599d-9a1c-64bae0b1095a",
|
||||
"name": "text",
|
||||
"value": "={{ $json.body.text }}",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"includeOtherFields": false,
|
||||
"options": {}
|
||||
},
|
||||
"id": "6c57d7cf-acd9-5007-b34a-b741afa2c060",
|
||||
"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": "f2c175d2-9545-570a-99a6-e7cdfb17162f",
|
||||
"leftValue": "={{ $json.text }}",
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"operation": "notEmpty",
|
||||
"singleValue": true
|
||||
},
|
||||
"rightValue": ""
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "d19bdfca-d55e-5760-a488-458abc7a5480",
|
||||
"name": "Has text?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.2,
|
||||
"position": [
|
||||
440,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ { \"error\": \"text is required\" } }}",
|
||||
"options": {
|
||||
"responseCode": 400
|
||||
}
|
||||
},
|
||||
"id": "5adfc553-1a2c-55fd-a4bd-d6564197222a",
|
||||
"name": "Reject empty",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"position": [
|
||||
680,
|
||||
180
|
||||
]
|
||||
},
|
||||
{
|
||||
"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: \"Classify the IT request into exactly one of: account, network, device, software, other. Reply with the label only.\" }, { role: 'user', content: $('Settings').first().json.text } ] }) }}",
|
||||
"options": {
|
||||
"timeout": 180000
|
||||
},
|
||||
"authentication": "predefinedCredentialType",
|
||||
"nodeCredentialType": "openAiApi"
|
||||
},
|
||||
"id": "d084b606-aad9-5341-98cd-490070f907ac",
|
||||
"name": "LLM classify",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [
|
||||
680,
|
||||
-40
|
||||
],
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "openAiCourse0001",
|
||||
"name": "OpenAI (.env)"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "// 1) Clean up the LLM label 2) Search the FAQ list\nconst allowed = ['account', 'network', 'device', 'software', 'other'];\nconst raw = String($input.first().json.choices?.[0]?.message?.content ?? '').trim().toLowerCase();\nconst category = allowed.find(a => raw.includes(a)) ?? 'other';\n\nconst faqs = [\n {\n \"id\": \"FAQ-001\",\n \"title\": \"비밀번호 재설정\",\n \"keywords\": [\n \"비밀번호\",\n \"패스워드\",\n \"password\",\n \"재설정\",\n \"초기화\"\n ],\n \"answer\": \"사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정합니다.\"\n },\n {\n \"id\": \"FAQ-002\",\n \"title\": \"계정 잠김 해제\",\n \"keywords\": [\n \"계정\",\n \"잠김\",\n \"잠금\",\n \"로그인\",\n \"실패\"\n ],\n \"answer\": \"로그인 5회 실패 시 30분 동안 잠깁니다. 30분 후 다시 시도하거나 헬프데스크에 잠금 해제를 요청합니다.\"\n },\n {\n \"id\": \"FAQ-003\",\n \"title\": \"VPN 접속 오류\",\n \"keywords\": [\n \"vpn\",\n \"원격\",\n \"재택\",\n \"접속\",\n \"연결\"\n ],\n \"answer\": \"VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속합니다.\"\n },\n {\n \"id\": \"FAQ-004\",\n \"title\": \"프린터 출력 안 됨\",\n \"keywords\": [\n \"프린터\",\n \"출력\",\n \"인쇄\",\n \"용지\"\n ],\n \"answer\": \"프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력합니다.\"\n },\n {\n \"id\": \"FAQ-005\",\n \"title\": \"메일함 용량 초과\",\n \"keywords\": [\n \"메일\",\n \"용량\",\n \"메일함\",\n \"첨부\"\n ],\n \"answer\": \"오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제해 용량을 확보합니다.\"\n },\n {\n \"id\": \"FAQ-006\",\n \"title\": \"소프트웨어 설치 요청\",\n \"keywords\": [\n \"설치\",\n \"소프트웨어\",\n \"프로그램\",\n \"라이선스\"\n ],\n \"answer\": \"IT 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다.\"\n }\n];\nconst settings = $('Settings').first().json;\nconst words = String(settings.text).toLowerCase().split(/\\s+/).filter(w => w.length > 1);\nconst scored = faqs\n .map(f => ({ ...f, score: words.filter(w => f.keywords.some(k => w.includes(k) || k.includes(w))).length }))\n .filter(f => f.score > 0)\n .sort((a, b) => b.score - a.score);\nconst faq = scored[0] ?? null;\n\nreturn [{ json: { user: settings.user, text: settings.text, raw_label: raw, category, found: faq !== null, faq } }];"
|
||||
},
|
||||
"id": "aa4be834-45fb-563b-95f5-c6a0dbeb2903",
|
||||
"name": "Normalize and search FAQ",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
920,
|
||||
-40
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "loose",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "6a4b578f-d9da-5899-933d-7e05d901e3eb",
|
||||
"leftValue": "={{ $json.found }}",
|
||||
"operator": {
|
||||
"type": "boolean",
|
||||
"operation": "true",
|
||||
"singleValue": true
|
||||
},
|
||||
"rightValue": ""
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "21f335b6-8a0a-5331-b8df-43d3c9f9aafc",
|
||||
"name": "FAQ found?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.2,
|
||||
"position": [
|
||||
1160,
|
||||
-40
|
||||
]
|
||||
},
|
||||
{
|
||||
"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: \"Write a polite 2-sentence Korean reply using only the FAQ answer. End with the FAQ id in parentheses.\" }, { role: 'user', content: '문의: ' + $('Normalize and search FAQ').first().json.text + '\\nFAQ: ' + JSON.stringify($('Normalize and search FAQ').first().json.faq) } ] }) }}",
|
||||
"options": {
|
||||
"timeout": 180000
|
||||
},
|
||||
"authentication": "predefinedCredentialType",
|
||||
"nodeCredentialType": "openAiApi"
|
||||
},
|
||||
"id": "a1e86429-1ee7-585c-bdaa-f1aa0d10f49b",
|
||||
"name": "LLM draft reply",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [
|
||||
1400,
|
||||
-140
|
||||
],
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "openAiCourse0001",
|
||||
"name": "OpenAI (.env)"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "1e3762b6-7dae-5e2f-9538-8a5b0f5dcae0",
|
||||
"name": "category",
|
||||
"value": "={{ $('Normalize and search FAQ').first().json.category }}",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "230f5390-e5c9-5e6a-90ec-03e6965652f7",
|
||||
"name": "faq_id",
|
||||
"value": "={{ $('Normalize and search FAQ').first().json.faq.id }}",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "1f78eb57-6478-5590-94f9-bacb9b2c89b9",
|
||||
"name": "route",
|
||||
"value": "자동 답변",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "756cec4c-d91f-516a-9d9f-ae74098227e9",
|
||||
"name": "reply",
|
||||
"value": "={{ $json.choices[0].message.content.trim() }}",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"includeOtherFields": false,
|
||||
"options": {}
|
||||
},
|
||||
"id": "da512682-1045-5a8e-83d1-52975eb08e97",
|
||||
"name": "Auto reply",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [
|
||||
1640,
|
||||
-140
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "bf4ced71-6e32-5ed2-ad7d-4124d462e3d2",
|
||||
"name": "category",
|
||||
"value": "={{ $json.category }}",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "79bffc37-152f-5b3e-9018-e30121faed11",
|
||||
"name": "faq_id",
|
||||
"value": "",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "86fde04e-b2a1-5782-97c1-caba13a9ed10",
|
||||
"name": "route",
|
||||
"value": "담당자 확인",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "d760805f-73f1-5d0a-a7cf-8eb67d03e978",
|
||||
"name": "reply",
|
||||
"value": "관련 안내를 찾지 못해 담당자가 확인 후 답변드리겠습니다.",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"includeOtherFields": false,
|
||||
"options": {}
|
||||
},
|
||||
"id": "604eb70d-bff2-5270-b1a4-f94a3a6c681d",
|
||||
"name": "Hand off to staff",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [
|
||||
1400,
|
||||
80
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ $json }}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "2de33965-f060-5f8b-bd91-2a7c72a1e618",
|
||||
"name": "Send result",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"position": [
|
||||
1880,
|
||||
-40
|
||||
]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"AI request webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Settings",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Settings": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Has text?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Has text?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "LLM classify",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Reject empty",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"LLM classify": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Normalize and search FAQ",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Normalize and search FAQ": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "FAQ found?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"FAQ found?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "LLM draft reply",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Hand off to staff",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"LLM draft reply": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Auto reply",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Auto reply": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Hand off to staff": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"pinData": {},
|
||||
"id": "493c961026875d"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# Trigger·Condition·Action 설계표
|
||||
|
||||
- 작성자:
|
||||
- 업무 이름:
|
||||
|
||||
## 1. Trigger (언제 시작하나)
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --- | --- |
|
||||
| 시작 방식 | 수동 / 웹훅·폼 / 일정 / 이벤트 중 선택 |
|
||||
| 시작할 때 받는 데이터 (필드) | |
|
||||
| 데이터가 비었거나 형식이 틀리면? | |
|
||||
|
||||
## 2. Condition (어느 길로 갈까)
|
||||
|
||||
| 번호 | 검사할 값 | 조건 | 참일 때 | 거짓일 때 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| C1 | | | | |
|
||||
| C2 | | | | |
|
||||
|
||||
여러 갈래(Switch)가 필요하면:
|
||||
|
||||
| 값 | 보낼 길 |
|
||||
| --- | --- |
|
||||
| | |
|
||||
| (그 밖의 값, default) | |
|
||||
|
||||
## 3. Action (무엇을 하나)
|
||||
|
||||
| 번호 | 길 | 할 일 | 노드 종류 (LLM·Tool·데이터 처리·저장·응답·사람 확인) |
|
||||
| --- | --- | --- | --- |
|
||||
| A1 | | | |
|
||||
| A2 | | | |
|
||||
|
||||
## 4. 흐름 그림
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
T(["Trigger"]) --> C{"Condition"}
|
||||
C -->|"참"| A1["Action 1"]
|
||||
C -->|"거짓"| A2["Action 2"]
|
||||
```
|
||||
|
||||
## 5. 점검
|
||||
|
||||
- [ ] 모든 길의 끝에 결과(응답·저장·알림)가 있다.
|
||||
- [ ] 정해진 값에 맞지 않는 경우를 받는 default 길이 있다.
|
||||
- [ ] LLM 출력으로 분기할 때 값을 정리하거나 검증하는 단계가 있다.
|
||||
@@ -0,0 +1,5 @@
|
||||
runs/
|
||||
.miniflow-memory.json
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
__pycache__/
|
||||
@@ -0,0 +1,111 @@
|
||||
# workflow-lab (65일차): 조건 분기와 플랫폼 전환
|
||||
|
||||
64일차 `miniflow`에 **조건 분기(condition·switch)** 와 **플랫폼 전환(n8n ↔ miniflow) 실행기**를 더한 버전입니다.
|
||||
|
||||
노코드 워크플로우 도구는 화면에서 **노드를 놓고 선으로 연결**합니다. 이 실습에서는 같은 구조를 JSON 파일로 적고 `miniflow`로 실행합니다. 화면 대신 파일을 쓸 뿐, 노드·연결·데이터 전달이라는 개념은 같습니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
J["flows/*.json<br/>노드와 연결 정의"] --> E["miniflow 엔진<br/>순서 계산 · 실행"]
|
||||
E --> L{{"LLM 노드<br/>openai · ollama · mock · anthropic · gemini"}}
|
||||
E --> T["Tool 노드<br/>get_weather · search_faq"]
|
||||
E --> M[("Memory 노드<br/>.miniflow-memory.json")]
|
||||
E --> R["runs/*.json<br/>실행 기록(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`) |
|
||||
| `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`을 쓰는 코드는 바꾸지 않습니다.
|
||||
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{"id": "FAQ-001", "title": "비밀번호 재설정", "keywords": ["비밀번호", "패스워드", "password", "재설정", "초기화"],
|
||||
"answer": "사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정합니다."},
|
||||
{"id": "FAQ-002", "title": "계정 잠김 해제", "keywords": ["계정", "잠김", "잠금", "로그인", "실패"],
|
||||
"answer": "로그인 5회 실패 시 30분 동안 잠깁니다. 30분 후 다시 시도하거나 헬프데스크에 잠금 해제를 요청합니다."},
|
||||
{"id": "FAQ-003", "title": "VPN 접속 오류", "keywords": ["vpn", "원격", "재택", "접속", "연결"],
|
||||
"answer": "VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속합니다."},
|
||||
{"id": "FAQ-004", "title": "프린터 출력 안 됨", "keywords": ["프린터", "출력", "인쇄", "용지"],
|
||||
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력합니다."},
|
||||
{"id": "FAQ-005", "title": "메일함 용량 초과", "keywords": ["메일", "용량", "메일함", "첨부"],
|
||||
"answer": "오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제해 용량을 확보합니다."},
|
||||
{"id": "FAQ-006", "title": "소프트웨어 설치 요청", "keywords": ["설치", "소프트웨어", "프로그램", "라이선스"],
|
||||
"answer": "IT 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."}
|
||||
]
|
||||
@@ -0,0 +1,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": "좋음"}
|
||||
}
|
||||
@@ -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"]]
|
||||
}
|
||||
@@ -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"]]
|
||||
}
|
||||
@@ -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"]]
|
||||
}
|
||||
@@ -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"]]
|
||||
}
|
||||
@@ -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"]]
|
||||
}
|
||||
@@ -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"]
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"n8n": {"base_url": "http://localhost:5678", "timeout": 120},
|
||||
"workflows": {
|
||||
"request": {
|
||||
"n8n": {"webhook": "ai-request"},
|
||||
"miniflow": {"flow": "flows/06-request-with-condition.json"}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Command line entry point: uv run miniflow <command> ..."""
|
||||
|
||||
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())
|
||||
@@ -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}<br/>{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}
|
||||
@@ -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
|
||||
@@ -0,0 +1,192 @@
|
||||
"""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) -> str:
|
||||
"""Call a local Ollama server through its /api/chat endpoint (no extra package needed)."""
|
||||
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}]
|
||||
body = json.dumps({
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": {"temperature": temperature},
|
||||
}).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), "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)")
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""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())}
|
||||
|
||||
|
||||
TOOLS: dict[str, Callable[..., Any]] = {
|
||||
"get_weather": get_weather,
|
||||
"search_faq": search_faq,
|
||||
"count_chars": count_chars,
|
||||
}
|
||||
|
||||
|
||||
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()]
|
||||
@@ -0,0 +1,3 @@
|
||||
import os
|
||||
|
||||
os.environ["MINIFLOW_DOTENV"] = "0" # tests never read the real .env (it may hold API keys)
|
||||
@@ -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", "", {})
|
||||
@@ -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"
|
||||
@@ -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", "", {})
|
||||
@@ -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", "", {})
|
||||
@@ -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())
|
||||
@@ -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="서울")
|
||||
Generated
+1058
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user