Initial commit
This commit is contained in:
commit
5eedbdd039
30 files changed
+2827
No files matched your search
@@ -0,0 +1,93 @@
|
||||
# Lab 01 · 첫 번째 워크플로우 실행과 실행 기록 읽기
|
||||
|
||||
- 관련 강의: [1단계 · AI 워크플로우의 개념과 전체 아키텍처](../../Lecture/64/01-STAGE1-AI-WORKFLOW-CONCEPT.md)
|
||||
- 소요 시간: 약 35분
|
||||
- 목표: 노드·연결·페이로드·실행 기록을 직접 눈으로 확인한다.
|
||||
|
||||
## 1. 설치 확인 (5분)
|
||||
|
||||
[사전 준비 안내](SETUP-WSL-OLLAMA.md)를 마친 상태에서 WSL 터미널에서 실행합니다.
|
||||
|
||||
```bash
|
||||
cd ~/<수업 저장소>/Last3Mon/64/workflow-lab
|
||||
uv run pytest -q # 12 passed, 1 skipped
|
||||
uv run miniflow check # Reply (ollama, ...) 가 나오면 준비 완료
|
||||
```
|
||||
|
||||
## 2. 가장 단순한 워크플로우 실행 (10분)
|
||||
|
||||
`flows/01-llm-only.json`을 열어 구조를 확인합니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
start[/"start<br/>input"/] --> summarize{{"summarize<br/>llm"}} --> result[\"result<br/>output"\]
|
||||
```
|
||||
|
||||
```bash
|
||||
uv run miniflow run flows/01-llm-only.json -i text="다음 주 화요일 10시에 기획 회의를 진행합니다. 각 팀은 진행 현황을 준비해 주세요."
|
||||
```
|
||||
|
||||
출력 예시:
|
||||
|
||||
```text
|
||||
Workflow: 01 LLM only
|
||||
------------------------------------------------------------------------
|
||||
[1/3] start input ok 0.0ms {"text": "다음 주 화요일 ..."}
|
||||
[2/3] summarize llm ok 7071.5ms "다음 주 화요일 10시에 기획 회의가 진행됩니다. 각 팀은 ..."
|
||||
[3/3] result output ok 0.1ms "다음 주 화요일 10시에 기획 회의가 진행됩니다. 각 팀은 ..."
|
||||
------------------------------------------------------------------------
|
||||
Status: completed
|
||||
Trace saved: runs/20261014-101500-01-llm-only.json
|
||||
```
|
||||
|
||||
노트북의 Ollama 모델(`gemma3:4b`)이 요약을 만들었습니다. 요약 문장과 소요 시간은 실행할 때마다, 노트북마다 다릅니다.
|
||||
|
||||
같은 명령을 **Mock LLM**으로도 실행해 비교해 봅니다. Mock은 노드에 적어 둔 정해진 답을 돌려줍니다.
|
||||
|
||||
```bash
|
||||
MINIFLOW_LLM=mock uv run miniflow run flows/01-llm-only.json -i text="다음 주 화요일 10시에 기획 회의를 진행합니다."
|
||||
```
|
||||
|
||||
## 3. 실행 기록 읽기 (10분)
|
||||
|
||||
`runs/` 폴더에 생긴 JSON 파일을 열고 다음 질문에 답해 보세요.
|
||||
|
||||
1. `summarize` 노드의 `prompt` 필드에는 무엇이 들어 있나요? `{{input.text}}`는 어떻게 바뀌었나요?
|
||||
2. `provider` 값은 무엇인가요? Mock으로 실행한 기록과 비교하면 무엇이 다른가요?
|
||||
3. 각 노드의 `ms` 값을 보면 어느 노드가 가장 오래 걸렸나요? Mock으로 실행했을 때와 비교하면 얼마나 차이 나나요?
|
||||
|
||||
```bash
|
||||
uv run miniflow show flows/01-llm-only.json
|
||||
```
|
||||
|
||||
위 명령은 워크플로우를 Mermaid 문법으로 출력합니다. 출력을 [Mermaid Live Editor](https://mermaid.live)에 붙여 넣으면 그림으로 볼 수 있습니다.
|
||||
|
||||
## 4. 지난 프로젝트를 노드로 다시 그리기 (10분)
|
||||
|
||||
지난 실습 프로젝트 하나를 골라 **노드와 연결**로 다시 그립니다. 아래 예시를 참고하세요.
|
||||
|
||||
`mini_agent_03_mcp` (8월)를 워크플로우로 그리면:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
i[/"사용자 질문"/] --> l1{{"LLM<br/>Tool 선택"}}
|
||||
l1 --> t1["Tool<br/>Travel MCP"]
|
||||
l1 --> t2["Tool<br/>Policy MCP"]
|
||||
t1 --> l2{{"LLM<br/>최종 답변"}}
|
||||
t2 --> l2
|
||||
l2 --> o[\"답변"\]
|
||||
```
|
||||
|
||||
다음 중 하나를 골라 `Last3Mon/64/my-work/LAB-01-MY-DIAGRAM.md` 파일에 Mermaid로 그려 보세요.
|
||||
|
||||
- `mini_agent_04_rag`: 질문 → 검색 → 답변 → 캐시
|
||||
- `mini_agent_05_memory`: 질문 → 기억 조회 → 답변 → 기억 저장
|
||||
- `mini_multi_agent_04_distributed_collaboration`: 계획 → 병렬 Agent 3개 → Join → 일정
|
||||
|
||||
그린 뒤, 각 노드가 LLM·Tool·Memory·데이터 처리 중 무엇인지 표시합니다.
|
||||
|
||||
## 확인
|
||||
|
||||
- [ ] `pytest`가 모두 통과했다.
|
||||
- [ ] `01-llm-only.json`을 실행하고 실행 기록을 열어 보았다.
|
||||
- [ ] 지난 프로젝트 하나를 노드와 연결로 그렸다.
|
||||
@@ -0,0 +1,120 @@
|
||||
# Lab 02 · LLM·Tool·Memory 연결하기
|
||||
|
||||
- 관련 강의: [2단계 · LLM·Tool·Memory 노드와 데이터 흐름](../../Lecture/64/02-STAGE2-LLM-TOOL-MEMORY.md)
|
||||
- 소요 시간: 약 45분
|
||||
- 목표: Tool 결과와 Memory가 LLM 노드로 흘러가는 과정을 실행 기록으로 확인하고, 워크플로우를 직접 수정한다.
|
||||
|
||||
## 1. Tool → LLM: 근거 제공 패턴 (10분)
|
||||
|
||||
```bash
|
||||
uv run miniflow tools
|
||||
uv run miniflow run flows/02-llm-tool.json -i city=제주 -i question="우산이 필요할까?"
|
||||
```
|
||||
|
||||
```text
|
||||
[2/4] weather tool ok 0.1ms {"city": "제주", "condition": "비", "temp_c": 20, "rain_chance": 80, ...}
|
||||
[3/4] answer llm ok 3783.8ms "네, 비가 오므로 우산이 필요할 것 같습니다."
|
||||
```
|
||||
|
||||
확인할 것
|
||||
|
||||
1. 실행 기록에서 `answer` 노드의 `prompt`를 찾아, `{{nodes.weather.output}}`이 실제 데이터로 바뀐 것을 확인합니다.
|
||||
2. `city=평양`으로 실행해 봅니다. 어느 노드에서 멈추나요? 그 뒤의 노드는 실행되었나요?
|
||||
|
||||
## 2. Memory: 실행과 실행 사이를 잇기 (10분)
|
||||
|
||||
```bash
|
||||
uv run miniflow memory --clear
|
||||
uv run miniflow run flows/03-save-profile.json -i user=minji -i preference="실내 전시"
|
||||
uv run miniflow run flows/04-recommend-with-memory.json -i user=minji -i city=부산
|
||||
uv run miniflow memory
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph R1["실행 1 · 03-save-profile"]
|
||||
a[/"minji, 실내 전시"/] --> b[("Memory 쓰기<br/>user:minji")]
|
||||
end
|
||||
subgraph R2["실행 2 · 04-recommend-with-memory"]
|
||||
c[/"minji, 부산"/] --> d[("Memory 읽기")]
|
||||
c --> e["날씨 Tool"]
|
||||
d --> f{{"LLM 추천"}}
|
||||
e --> f
|
||||
f --> g[("Memory 쓰기<br/>last_city")]
|
||||
end
|
||||
b -. "다음 실행에서 읽음" .-> d
|
||||
```
|
||||
|
||||
확인할 것
|
||||
|
||||
1. `04`를 `city=강릉`으로 한 번 더 실행합니다. `recall` 노드 출력에 `last_city`가 무엇으로 나오나요?
|
||||
2. `user=jun`처럼 저장하지 않은 사용자로 실행하면 추천 문장이 어떻게 바뀌나요? 왜 그런가요?
|
||||
|
||||
## 3. 워크플로우 수정하기 (15분)
|
||||
|
||||
`flows/02-llm-tool.json`을 복사해 `flows/my-02-with-count.json`을 만들고 다음을 수정합니다.
|
||||
|
||||
1. `answer` 노드 뒤에 `count_chars` Tool 노드를 추가합니다.
|
||||
- id: `length`
|
||||
- args: `{"text": "{{nodes.answer.output}}"}`
|
||||
2. `result` 노드의 값을 아래처럼 바꿔 두 결과를 함께 내보냅니다.
|
||||
```json
|
||||
{"answer": "{{nodes.answer.output}}", "length": "{{nodes.length.output}}"}
|
||||
```
|
||||
3. 연결(edges)을 `answer → length → result` 순서로 고칩니다.
|
||||
|
||||
```bash
|
||||
uv run miniflow validate flows/my-02-with-count.json
|
||||
uv run miniflow show flows/my-02-with-count.json
|
||||
uv run miniflow run flows/my-02-with-count.json -i city=서울 -i question="산책하기 좋을까?"
|
||||
```
|
||||
|
||||
`validate`가 오류를 내면 오류 메시지를 읽고 고칩니다. 대부분 노드 id 오타나 연결 누락입니다.
|
||||
|
||||
## 4. Mock과 실제 LLM 비교 (10분)
|
||||
|
||||
지금까지는 Ollama 모델이 답했습니다. 같은 워크플로우를 Mock과 번갈아 실행해 비교합니다.
|
||||
|
||||
```bash
|
||||
uv run miniflow run flows/05-tech-support-ticket.json -i user=kim -i ticket="프린터에서 출력이 안 됩니다"
|
||||
uv run miniflow run flows/05-tech-support-ticket.json -i user=kim -i ticket="프린터에서 출력이 안 됩니다"
|
||||
MINIFLOW_LLM=mock uv run miniflow run flows/05-tech-support-ticket.json -i user=kim -i ticket="프린터에서 출력이 안 됩니다"
|
||||
```
|
||||
|
||||
| 비교할 것 | Mock | Ollama |
|
||||
| --- | --- | --- |
|
||||
| `classify`·`draft` 노드의 `ms` | | |
|
||||
| 두 번 실행한 답변이 같은가? | | |
|
||||
| 분류 결과가 정해진 값(`device`)과 정확히 같은가? | | |
|
||||
|
||||
수업 전에 확인한 결과(CPU 1개, 메모리 8GB 환경)는 다음과 같았습니다. 내 노트북 결과와 비교해 보세요.
|
||||
|
||||
| 모델 | 분류 4건 정답 | 분류 노드 | 답변 초안 노드 |
|
||||
| --- | --- | --- | --- |
|
||||
| `gemma3:1b` | 0/4 (전부 `software`, 한 번은 `Software`) | 약 1초 | 4~7초 |
|
||||
| `gemma3:4b` | 4/4 | 약 1.7초 | 7~11초 |
|
||||
|
||||
작은 모델은 빠르지만 판단이 틀리거나 대소문자처럼 형식이 흔들립니다. 다음 노드가 분류값을 비교해야 한다면, LLM 출력을 그대로 믿지 말고 **검증하는 단계**가 필요합니다. (73일차 입력·출력 검증)
|
||||
|
||||
(선택) OpenAI/Anthropic/Gemini 중 API 키가 있으면 클라우드 모델과도 비교할 수 있습니다. 가지고 있는 키 하나만 설정하면 됩니다.
|
||||
|
||||
| 제공사 | 설치 | 설정 |
|
||||
| --- | --- | --- |
|
||||
| OpenAI | `uv sync --extra openai` | `export MINIFLOW_LLM=openai`, `export OPENAI_API_KEY=...` |
|
||||
| Anthropic | `uv sync --extra anthropic` | `export MINIFLOW_LLM=anthropic`, `export ANTHROPIC_API_KEY=...` |
|
||||
| Gemini (10일차에 발급한 키) | `uv sync --extra gemini` | `export MINIFLOW_LLM=gemini`, `export GEMINI_API_KEY=...` |
|
||||
|
||||
## 도전 과제: 인자 추출 패턴 (LLM → Tool)
|
||||
|
||||
지금은 사용자가 `city=부산`처럼 도시를 직접 입력합니다. "다음 주에 부산 가는데 날씨 어때?" 같은 문장만 받아서 도시를 뽑아내도록 바꿔 보세요.
|
||||
|
||||
- `inputs`를 `["question"]`으로 바꾸고, 맨 앞에 도시 이름만 답하는 `llm` 노드(`extract_city`)를 추가합니다.
|
||||
- 프롬프트에 "도시 이름만 답하세요. 예: 부산"처럼 출력 형식을 정해 줍니다. Mock으로도 확인하려면 `mock.rules`에 `{"if_contains": ["부산"], "then": "부산"}` 같은 규칙을 넣습니다.
|
||||
- `weather` 노드의 args를 `{"city": "{{nodes.extract_city.output}}"}`로 바꿉니다.
|
||||
|
||||
## 확인
|
||||
|
||||
- [ ] Tool 결과가 LLM 프롬프트에 들어간 것을 실행 기록에서 찾았다.
|
||||
- [ ] Memory가 두 번의 실행을 이어 주는 것을 확인했다.
|
||||
- [ ] `my-02-with-count.json`을 만들어 `validate`와 `run`을 통과했다.
|
||||
- [ ] Mock과 Ollama의 결과·속도 차이를 표로 정리했다.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Lab 03 · 업무 자동화 시나리오 분석과 워크플로우 설계
|
||||
|
||||
- 관련 강의: [3단계 · 업무 자동화 시나리오 분석](../../Lecture/64/03-STAGE3-AUTOMATION-SCENARIO.md)
|
||||
- 소요 시간: 약 45분 (개인 또는 2인 1조)
|
||||
- 목표: 하나의 업무를 분석해 노드와 연결로 바꾸고, 실제로 실행해 검증한다.
|
||||
- 제출물
|
||||
- `my-work/LAB-03-SCENARIO-ANALYSIS.md` (분석 문서)
|
||||
- `workflow-lab/flows/my-scenario.json` (워크플로우)
|
||||
|
||||
## 1. 예시 먼저 실행해 보기 (10분)
|
||||
|
||||
```bash
|
||||
cd Last3Mon/64/workflow-lab
|
||||
uv run miniflow memory --clear
|
||||
uv run miniflow run flows/05-tech-support-ticket.json -i user=kim -i ticket="비밀번호를 잊어버려서 로그인이 안 돼요"
|
||||
uv run miniflow run flows/05-tech-support-ticket.json -i user=kim -i ticket="VPN 접속이 계속 끊겨요"
|
||||
uv run miniflow run flows/05-tech-support-ticket.json -i user=lee -i ticket="점심 메뉴 추천해줘"
|
||||
```
|
||||
|
||||
| 실행 | 확인할 것 |
|
||||
| --- | --- |
|
||||
| 1번째 | `category`와 `faq_id`가 무엇인가? |
|
||||
| 2번째 | `history` 노드 출력에 1번째 문의가 남아 있는가? |
|
||||
| 3번째 | 어느 노드에서 왜 멈추었는가? `draft` 노드는 무엇이라고 답했는가? 이 경우 실제 업무에서는 어떻게 처리해야 할까? |
|
||||
|
||||
3번째 실행에서는 FAQ 검색 결과가 없는데도 LLM이 그럴듯한 답변 초안을 만들고, 마지막 `result` 노드에서 멈춥니다. Mock으로 실행(`MINIFLOW_LLM=mock`)하면 `draft` 노드에서 바로 멈춥니다. **근거가 없을 때 실제 LLM이 어떻게 행동하는지** 실행 기록에서 확인해 보세요.
|
||||
|
||||
`uv run miniflow show flows/05-tech-support-ticket.json`으로 구조도 확인합니다.
|
||||
|
||||
## 2. 시나리오 분석 (15분)
|
||||
|
||||
1. 아래 시나리오 중 하나를 고릅니다. 직접 겪은 업무로 만들어도 됩니다.
|
||||
|
||||
| 번호 | 시나리오 |
|
||||
| --- | --- |
|
||||
| S1 | 회의록 요약 후 할 일 정리 |
|
||||
| S2 | 쇼핑몰 상품 리뷰 분류와 답글 초안 |
|
||||
| S3 | 신규 입사자 질문 응답 |
|
||||
| S4 | 출장 전 날씨·준비물 안내 |
|
||||
| S5 | IT 문의 처리 확장 (분류별 담당 부서 안내) |
|
||||
|
||||
2. 템플릿을 복사합니다.
|
||||
|
||||
```bash
|
||||
mkdir -p Last3Mon/64/my-work
|
||||
cp Last3Mon/64/templates/SCENARIO-ANALYSIS-TEMPLATE.md Last3Mon/64/my-work/LAB-03-SCENARIO-ANALYSIS.md
|
||||
```
|
||||
|
||||
3. 모든 항목을 채우고 AS-IS·TO-BE를 Mermaid로 그립니다.
|
||||
4. TO-BE의 각 단계를 LLM·Tool·Memory·데이터 처리·사람 확인 중 하나로 분류합니다.
|
||||
|
||||
## 3. 워크플로우로 옮기기 (15분)
|
||||
|
||||
TO-BE를 `flows/my-scenario.json`으로 작성합니다. 지금 사용할 수 있는 노드와 Tool로 표현할 수 있는 부분만 작성하고, 나머지(메일 발송, 사람 확인 등)는 분석 문서에 "향후 노드"로 적습니다.
|
||||
|
||||
시작용 뼈대:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My scenario",
|
||||
"description": "시나리오 설명",
|
||||
"inputs": ["user", "text"],
|
||||
"nodes": [
|
||||
{"id": "start", "type": "input"},
|
||||
{"id": "history", "type": "memory_read", "key": "my:{{input.user}}"},
|
||||
{"id": "judge", "type": "llm",
|
||||
"prompt": "다음 내용을 분류해 주세요: {{input.text}}",
|
||||
"mock": {"rules": [{"if_contains": ["키워드"], "then": "분류값"}], "default": "other"}},
|
||||
{"id": "save", "type": "memory_write", "key": "my:{{input.user}}",
|
||||
"value": {"last_text": "{{input.text}}", "last_label": "{{nodes.judge.output}}"}},
|
||||
{"id": "result", "type": "output", "value": {"label": "{{nodes.judge.output}}"}}
|
||||
],
|
||||
"edges": [["start", "history"], ["start", "judge"], ["history", "save"], ["judge", "save"], ["save", "result"]]
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
uv run miniflow validate flows/my-scenario.json
|
||||
uv run miniflow show flows/my-scenario.json
|
||||
uv run miniflow run flows/my-scenario.json -i user=me -i text="..."
|
||||
```
|
||||
|
||||
Tip
|
||||
|
||||
- LLM 노드의 프롬프트에는 답의 형식(정해진 값, 문장 수 등)을 분명히 적습니다. Ollama 답이 매번 달라 흐름 확인이 어려우면 `mock`의 `rules`·`template`을 함께 적고 `MINIFLOW_LLM=mock`으로 구조부터 확인합니다.
|
||||
- 사용할 수 있는 Tool은 `uv run miniflow tools`로 확인합니다 (`get_weather`, `search_faq`, `count_chars`).
|
||||
- `show` 결과를 분석 문서의 "구현한 워크플로우" 항목에 붙여 넣습니다.
|
||||
|
||||
## 4. 검증 (5분)
|
||||
|
||||
정상 입력 2개와 문제가 생길 수 있는 입력 1개로 실행하고, 결과를 분석 문서의 "실행 결과" 표에 기록합니다.
|
||||
|
||||
## 확인
|
||||
|
||||
- [ ] 분석 템플릿의 모든 항목을 채웠다.
|
||||
- [ ] AS-IS와 TO-BE를 Mermaid로 그렸다.
|
||||
- [ ] `my-scenario.json`이 `validate`를 통과했다.
|
||||
- [ ] 정상 입력과 문제 입력의 실행 결과를 비교했다.
|
||||
@@ -0,0 +1,51 @@
|
||||
# 64일차 실습 · AI 워크플로우 구조 이해와 업무 자동화 시나리오 분석
|
||||
|
||||
- 일자: 2026-10-14 (64일차)
|
||||
- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영
|
||||
- 단원: AI 워크플로우 개념 및 구성 요소 이해
|
||||
- 강의 자료: [Lecture/64](../../Lecture/64/README.md)
|
||||
|
||||
## 구성
|
||||
|
||||
```text
|
||||
Last3Mon/64/
|
||||
├── README.md
|
||||
├── SETUP-WSL-OLLAMA.md # 사전 준비 · WSL과 Ollama 로컬 LLM
|
||||
├── LAB-01-FIRST-WORKFLOW.md # 1단계 · 첫 워크플로우 실행과 실행 기록 읽기
|
||||
├── LAB-02-LLM-TOOL-MEMORY.md # 2단계 · LLM·Tool·Memory 연결하기
|
||||
├── LAB-03-SCENARIO-ANALYSIS.md # 3단계 · 업무 자동화 시나리오 분석과 설계
|
||||
├── templates/
|
||||
│ └── SCENARIO-ANALYSIS-TEMPLATE.md
|
||||
├── my-work/ # 여러분의 결과물 (직접 만듭니다)
|
||||
└── workflow-lab/ # 미니 워크플로우 엔진 (uv 프로젝트)
|
||||
├── flows/ # 예제 워크플로우 01~05
|
||||
├── data/ # 날씨·FAQ 샘플 데이터
|
||||
├── src/miniflow/ # 엔진 소스
|
||||
└── tests/
|
||||
```
|
||||
|
||||
## 실습 순서
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
L1["Lab 01<br/>워크플로우 실행·실행 기록<br/>(1~2교시)"] --> L2["Lab 02<br/>LLM·Tool·Memory 연결<br/>(3~4교시)"] --> L3["Lab 03<br/>시나리오 분석·설계<br/>(5~6교시)"]
|
||||
```
|
||||
|
||||
| 예제 워크플로우 | 패턴 | 사용하는 노드 |
|
||||
| --- | --- | --- |
|
||||
| `01-llm-only.json` | LLM 단독 | input → llm → output |
|
||||
| `02-llm-tool.json` | 근거 제공 (Tool → LLM) | input → tool → llm → output |
|
||||
| `03-save-profile.json` | 기억 저장 | input → memory_write → output |
|
||||
| `04-recommend-with-memory.json` | 개인화 순환 (Memory → LLM → Memory) | memory_read·tool → llm → memory_write |
|
||||
| `05-tech-support-ticket.json` | 업무 시나리오 (분류 + FAQ 검색 + 이력) | memory_read·llm·tool → llm → memory_write |
|
||||
|
||||
## 준비 (수업 전날)
|
||||
|
||||
실습은 Windows 11의 WSL(Ubuntu)에서, LLM은 노트북에서 실행하는 Ollama(`gemma3:4b`, 8GB 노트북은 `gemma3:1b`)로 진행합니다. API 키는 필요 없습니다.
|
||||
|
||||
먼저 **[실습 준비 안내](../../Lecture/64/00-DAY64-PREP.md)** 로 전체 준비 항목을 확인하고, 설치는 **[SETUP-WSL-OLLAMA.md](SETUP-WSL-OLLAMA.md)** 의 순서대로 진행한 뒤 마지막에 아래 두 명령이 통과하는지 확인합니다.
|
||||
|
||||
```bash
|
||||
uv run pytest -q # 12 passed, 1 skipped
|
||||
uv run miniflow check # Reply (ollama, ...)
|
||||
```
|
||||
@@ -0,0 +1,144 @@
|
||||
# 사전 준비 · WSL에서 실습 환경과 Ollama 로컬 LLM 준비
|
||||
|
||||
64일차 실습은 Windows 11의 **WSL(Ubuntu)** 에서 진행하고, LLM은 노트북에서 직접 실행하는 **Ollama**를 사용합니다. API 키가 필요 없고 사용량 제한도 없습니다.
|
||||
|
||||
> 모델 파일이 1~3GB입니다. 수업 중에 21명이 동시에 받으면 강의실 네트워크가 느려지므로 **수업 전날 4단계(모델 다운로드)까지 끝내 주세요.**
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph WIN["Windows 11"]
|
||||
T["Windows Terminal"]
|
||||
subgraph WSL["WSL2 · Ubuntu"]
|
||||
M["miniflow<br/>(uv 프로젝트)"] -->|"http://localhost:11434"| O["Ollama 서버"]
|
||||
O --> G[("gemma3:4b 모델")]
|
||||
end
|
||||
T --> WSL
|
||||
end
|
||||
```
|
||||
|
||||
## 1. WSL 확인 (5분)
|
||||
|
||||
PowerShell에서 확인합니다.
|
||||
|
||||
```powershell
|
||||
wsl --list --verbose
|
||||
```
|
||||
|
||||
`Ubuntu`가 `VERSION 2`로 보이면 됩니다. 없으면 `wsl --install -d Ubuntu`로 설치하고 노트북을 다시 시작합니다.
|
||||
|
||||
이후 명령은 모두 **WSL(Ubuntu) 터미널**에서 실행합니다.
|
||||
|
||||
## 2. 노트북 메모리에 맞는 모델 고르기
|
||||
|
||||
WSL은 기본으로 **Windows 전체 메모리의 절반**까지만 사용합니다.
|
||||
|
||||
| 노트북 메모리 | WSL이 쓸 수 있는 메모리 | 사용할 모델 |
|
||||
| --- | --- | --- |
|
||||
| 8GB | 약 4GB | `gemma3:1b` |
|
||||
| 16GB 이상 | 약 8GB 이상 | `gemma3:4b` (권장) |
|
||||
|
||||
내 노트북 메모리는 PowerShell에서 확인합니다.
|
||||
|
||||
```powershell
|
||||
(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB
|
||||
```
|
||||
|
||||
메모리가 부족하다는 오류가 나면 `C:\Users\<사용자>\.wslconfig` 파일을 만들어 상한을 올린 뒤 PowerShell에서 `wsl --shutdown` 하고 WSL을 다시 엽니다.
|
||||
|
||||
```ini
|
||||
[wsl2]
|
||||
memory=10GB
|
||||
swap=4GB
|
||||
```
|
||||
|
||||
## 3. 실습 코드와 uv 준비 (10분)
|
||||
|
||||
```bash
|
||||
# uv (이미 설치되어 있으면 건너뜀)
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
source ~/.bashrc
|
||||
|
||||
# 실습 폴더는 WSL 홈(~) 아래에 둡니다. /mnt/c 아래는 느리고 권한 문제가 생깁니다.
|
||||
cd ~
|
||||
# 수업 저장소를 받는 방법은 강의 시간에 안내합니다.
|
||||
cd ~/<수업 저장소>/Last3Mon/64/workflow-lab
|
||||
uv sync
|
||||
uv run pytest -q
|
||||
```
|
||||
|
||||
`12 passed, 1 skipped`가 나오면 됩니다. (`skipped` 1개는 선택 기능인 Anthropic 연결 테스트로, 해당 패키지를 설치하지 않으면 건너뜁니다.)
|
||||
|
||||
## 4. Ollama 설치와 모델 다운로드 (10~20분)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://ollama.com/install.sh | sh
|
||||
ollama --version
|
||||
|
||||
# 메모리 16GB 이상
|
||||
ollama pull gemma3:4b
|
||||
# 메모리 8GB
|
||||
ollama pull gemma3:1b
|
||||
|
||||
ollama list
|
||||
```
|
||||
|
||||
`ollama list`에 모델이 보이면 됩니다. 설치 스크립트가 Ollama 서버를 자동으로 실행합니다. `ollama list`에서 연결 오류가 나면 다른 터미널 창에서 `ollama serve`를 실행해 두세요.
|
||||
|
||||
## 5. miniflow와 Ollama 연결 (5분)
|
||||
|
||||
`~/.bashrc` 맨 아래에 추가합니다. 8GB 노트북은 모델 이름을 `gemma3:1b`로 바꿉니다.
|
||||
|
||||
```bash
|
||||
cat >> ~/.bashrc <<'EOF'
|
||||
export MINIFLOW_LLM=ollama
|
||||
export OLLAMA_MODEL=gemma3:4b
|
||||
EOF
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
연결을 확인합니다.
|
||||
|
||||
```bash
|
||||
cd ~/<수업 저장소>/Last3Mon/64/workflow-lab
|
||||
uv run miniflow check
|
||||
```
|
||||
|
||||
```text
|
||||
MINIFLOW_LLM : ollama
|
||||
OLLAMA_HOST : http://localhost:11434
|
||||
OLLAMA_MODEL : gemma3:4b
|
||||
Reply (ollama, 18.8s): Ready
|
||||
```
|
||||
|
||||
첫 호출은 모델을 메모리에 올리느라 10~20초 걸립니다. 두 번째부터는 빨라집니다.
|
||||
|
||||
## 6. 이미 Windows나 Docker에 Ollama가 있는 경우
|
||||
|
||||
9월 실습에서 Docker로 Ollama를 실행해 둔 경우에는 WSL에 새로 설치하지 않고 그대로 써도 됩니다.
|
||||
|
||||
| 상황 | 설정 |
|
||||
| --- | --- |
|
||||
| Docker Desktop으로 실행한 Ollama (`11434` 포트 공개) | 컨테이너를 켜 두면 WSL에서도 `http://localhost:11434`로 연결됩니다. `uv run miniflow check`로 확인 |
|
||||
| Windows용 Ollama 앱 | `.wslconfig`에 `networkingMode=mirrored`를 추가하고 `wsl --shutdown` 후 다시 열면 `localhost`로 연결됩니다 |
|
||||
|
||||
```ini
|
||||
[wsl2]
|
||||
networkingMode=mirrored
|
||||
```
|
||||
|
||||
## 7. 문제 해결
|
||||
|
||||
| 증상 | 해결 |
|
||||
| --- | --- |
|
||||
| `Cannot reach Ollama at http://localhost:11434` | `ollama serve` 실행, 또는 6절의 연결 설정 확인 |
|
||||
| `Model 'gemma3:4b' not found on Ollama` | `ollama pull gemma3:4b` |
|
||||
| 응답이 1분 넘게 걸림, WSL이 멈춤 | 메모리 부족. `gemma3:1b`로 바꾸거나 `.wslconfig`의 `memory` 상향 |
|
||||
| `uv: command not found` | `source ~/.bashrc` 또는 터미널 다시 열기 |
|
||||
| 한글이 깨져 보임 | Windows Terminal 사용 (기본 UTF-8) |
|
||||
| Ollama가 없거나 문제가 계속됨 | `export MINIFLOW_LLM=mock`으로 Mock LLM 사용 (결과는 정해진 답) |
|
||||
|
||||
## 확인
|
||||
|
||||
- [ ] `uv run pytest -q` → `12 passed, 1 skipped`
|
||||
- [ ] `ollama list`에 모델이 보인다
|
||||
- [ ] `uv run miniflow check`에서 `Reply (ollama, ...)`가 나온다
|
||||
@@ -0,0 +1,76 @@
|
||||
# 업무 자동화 시나리오 분석
|
||||
|
||||
- 작성자:
|
||||
- 선택한 시나리오:
|
||||
|
||||
## 1. 자동화 후보 판단
|
||||
|
||||
| 기준 | 점수 (1~5) | 근거 |
|
||||
| --- | :-: | --- |
|
||||
| 반복성 | | |
|
||||
| 절차의 명확성 | | |
|
||||
| 데이터 준비 | | |
|
||||
| 판단의 난이도 (AI가 잘하는 판단인가) | | |
|
||||
| 실패했을 때 영향 (작을수록 높은 점수) | | |
|
||||
| 합계 | | |
|
||||
|
||||
## 2. 분석 항목
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --- | --- |
|
||||
| 목표 | |
|
||||
| 시작 조건 | |
|
||||
| 입력 데이터 (필드) | |
|
||||
| 처리 단계 | |
|
||||
| 필요한 Tool·데이터 | |
|
||||
| 기억 (읽기 / 쓰기) | |
|
||||
| 결과와 전달 | |
|
||||
| 사람 확인 단계 | |
|
||||
| 예외 상황 | |
|
||||
| 성공 기준 | |
|
||||
|
||||
## 3. AS-IS (현재 방식)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["단계 1"] --> B["단계 2"] --> C["단계 3"]
|
||||
```
|
||||
|
||||
| 단계 | 담당 | 소요 시간 | 문제점 |
|
||||
| --- | --- | --- | --- |
|
||||
| | | | |
|
||||
|
||||
## 4. TO-BE (자동화 이후)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
T(["시작"]) --> L{{"LLM 노드"}} --> X["Tool 노드"] --> M[("Memory 노드")] --> O(["결과"])
|
||||
```
|
||||
|
||||
## 5. 단계 → 노드 매핑
|
||||
|
||||
| TO-BE 단계 | 노드 종류 (LLM·Tool·Memory·데이터 처리·사람) | 입력 | 출력 | 이유 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| | | | | |
|
||||
|
||||
## 6. 구현한 워크플로우
|
||||
|
||||
- 파일: `workflow-lab/flows/my-scenario.json`
|
||||
- 이번 실습에서 구현하지 못한 노드 (향후 노드):
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["uv run miniflow show flows/my-scenario.json 결과로 바꾸세요"]
|
||||
```
|
||||
|
||||
## 7. 실행 결과
|
||||
|
||||
| 구분 | 입력 | 결과 (status / result) | 메모 |
|
||||
| --- | --- | --- | --- |
|
||||
| 정상 1 | | | |
|
||||
| 정상 2 | | | |
|
||||
| 문제 입력 | | | 어느 노드에서, 왜 |
|
||||
|
||||
## 8. 개선 아이디어
|
||||
|
||||
-
|
||||
@@ -0,0 +1,5 @@
|
||||
runs/
|
||||
.miniflow-memory.json
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
__pycache__/
|
||||
@@ -0,0 +1,75 @@
|
||||
# workflow-lab: JSON으로 정의하는 미니 AI 워크플로우
|
||||
|
||||
노코드 워크플로우 도구는 화면에서 **노드를 놓고 선으로 연결**합니다. 이 실습에서는 같은 구조를 JSON 파일로 적고 `miniflow`로 실행합니다. 화면 대신 파일을 쓸 뿐, 노드·연결·데이터 전달이라는 개념은 같습니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
J["flows/*.json<br/>노드와 연결 정의"] --> E["miniflow 엔진<br/>순서 계산 · 실행"]
|
||||
E --> L{{"LLM 노드<br/>ollama · mock · openai · 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 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` |
|
||||
|
||||
## 4. 데이터 전달 규칙
|
||||
|
||||
앞 노드의 결과는 `{{...}}`로 꺼내 씁니다.
|
||||
|
||||
| 표현 | 의미 |
|
||||
| --- | --- |
|
||||
| `{{input.city}}` | 실행할 때 받은 입력 `city` |
|
||||
| `{{nodes.weather.output}}` | `weather` 노드의 결과 전체 |
|
||||
| `{{nodes.weather.output.temp_c}}` | 결과 안의 특정 값 |
|
||||
| `{{nodes.faq.output.0.answer}}` | 목록의 첫 번째 항목 안의 값 |
|
||||
|
||||
## 5. LLM 선택
|
||||
|
||||
`MINIFLOW_LLM` 환경 변수로 LLM을 고릅니다. 수업 기본값은 노트북에서 실행하는 **Ollama**입니다. 준비 방법은 [SETUP-WSL-OLLAMA.md](../SETUP-WSL-OLLAMA.md)를 참고하세요.
|
||||
|
||||
| `MINIFLOW_LLM` | LLM | 필요한 것 |
|
||||
| --- | --- | --- |
|
||||
| `ollama` (수업 기본) | 로컬 모델 (`OLLAMA_MODEL`, 기본 `gemma3:4b`) | Ollama 설치, 모델 다운로드 |
|
||||
| `mock` (설정하지 않았을 때) | 노드의 `mock` 설정대로 정해진 답 | 없음 (자동 테스트, Ollama가 안 될 때) |
|
||||
| `openai` | OpenAI (`OPENAI_MODEL`) | `uv sync --extra openai`, `OPENAI_API_KEY` |
|
||||
| `anthropic` | Anthropic Claude (`ANTHROPIC_MODEL`, 기본 `claude-opus-5-5`) | `uv sync --extra anthropic`, `ANTHROPIC_API_KEY` |
|
||||
| `gemini` | Google Gemini (`GEMINI_MODEL`) | `uv sync --extra gemini`, `GEMINI_API_KEY` |
|
||||
|
||||
```bash
|
||||
export MINIFLOW_LLM=ollama
|
||||
export OLLAMA_MODEL=gemma3:4b # 8GB 노트북은 gemma3:1b
|
||||
export OLLAMA_HOST=http://localhost:11434 # 기본값이라 생략 가능
|
||||
uv run miniflow check # 연결 확인
|
||||
```
|
||||
@@ -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,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 = []
|
||||
|
||||
[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,126 @@
|
||||
"""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}")
|
||||
if provider == "ollama":
|
||||
print(f"OLLAMA_HOST : {os.getenv('OLLAMA_HOST', 'http://localhost:11434')}")
|
||||
print(f"OLLAMA_MODEL : {os.getenv('OLLAMA_MODEL', 'gemma3:4b')}")
|
||||
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_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)
|
||||
|
||||
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,163 @@
|
||||
"""Load, validate and run a workflow made of nodes and edges."""
|
||||
|
||||
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"}
|
||||
REQUIRED_FIELDS = {
|
||||
"llm": ["prompt"],
|
||||
"tool": ["tool"],
|
||||
"memory_read": ["key"],
|
||||
"memory_write": ["key", "value"],
|
||||
"output": ["value"],
|
||||
}
|
||||
|
||||
|
||||
class WorkflowError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Workflow:
|
||||
name: str
|
||||
nodes: dict[str, dict[str, Any]]
|
||||
edges: list[tuple[str, str]]
|
||||
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. Day 64 workflows must flow in one direction.")
|
||||
return ordered
|
||||
|
||||
def to_mermaid(self) -> str:
|
||||
shapes = {
|
||||
"input": ('[/"', '"/]'),
|
||||
"llm": ('{{"', '"}}'),
|
||||
"tool": ('["', '"]'),
|
||||
"memory_read": ('[("', '")]'),
|
||||
"memory_write": ('[("', '")]'),
|
||||
"output": ('[\\"', '"\\]'),
|
||||
}
|
||||
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}")
|
||||
lines += [f" {source} --> {target}" for source, target in self.edges]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
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']}'")
|
||||
nodes[node_id] = node
|
||||
if not nodes:
|
||||
raise WorkflowError("Workflow has no nodes")
|
||||
|
||||
edges = [tuple(edge) for edge in data.get("edges", [])]
|
||||
for source, target 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}'")
|
||||
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 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), {}
|
||||
raise WorkflowError(f"Unsupported node type: {kind}")
|
||||
|
||||
|
||||
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]] = []
|
||||
order = workflow.order()
|
||||
result: Any = None
|
||||
for index, node_id in enumerate(order, start=1):
|
||||
node = workflow.nodes[node_id]
|
||||
started = time.perf_counter()
|
||||
step = {"step": index, "id": node_id, "type": node["type"]}
|
||||
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}
|
||||
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,145 @@
|
||||
"""LLM providers: mock (default, no API key), ollama (local), openai, anthropic, gemini.
|
||||
|
||||
Select with the MINIFLOW_LLM environment variable.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
from miniflow.template import render
|
||||
|
||||
|
||||
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) -> str:
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError as error:
|
||||
raise LLMError("Install the extra first: uv sync --extra openai") from error
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
raise LLMError("OPENAI_API_KEY is not set")
|
||||
client = OpenAI()
|
||||
response = client.responses.create(
|
||||
model=os.getenv("OPENAI_MODEL", "gpt-4.1-mini"),
|
||||
instructions=system or None,
|
||||
input=prompt,
|
||||
temperature=temperature,
|
||||
)
|
||||
return response.output_text
|
||||
|
||||
|
||||
def complete(node: dict[str, Any], prompt: str, system: str, context: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Return (reply, provider_name)."""
|
||||
provider = os.getenv("MINIFLOW_LLM", "mock").lower()
|
||||
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), "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,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,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,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 @@
|
||||
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
+1054
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user