Phase 7a: GPU 모델 id-9b:latest 전환 + 워크플로우 배포 자동화

- qwen3.5:9b-q8_0 → id-9b:latest 전체 교체 (워크플로우, Python 스크립트)
- deploy_workflows.sh 생성 (n8n REST API 자동 배포)
- .env.example: CalDAV/IMAP/Karakeep 기본값 수정
- 문서 업데이트: tk_qc_issues 컬렉션, 맥미니 Ollama 기동 안내

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-17 09:13:24 +09:00
parent 924560252e
commit 1543abded6
9 changed files with 120 additions and 28 deletions

View File

@@ -25,7 +25,7 @@ API_BUDGET_LIGHT=20.00
# Ollama (맥미니 — Docker 내부에서 접근)
LOCAL_OLLAMA_URL=http://host.docker.internal:11434
# Ollama (GPU 서버 — RTX 4070Ti Super)
# Ollama (GPU 서버 — RTX 4070Ti Super, 기본 모델: id-9b:latest)
GPU_OLLAMA_URL=http://192.168.1.186:11434
# Qdrant (Docker 내부에서 접근)
@@ -37,21 +37,25 @@ DSM_ACCOUNT=chatbot-api
DSM_PASSWORD=changeme
CHAT_CHANNEL_ID=17
# CalDAV (caldav_bridge.py — Synology Calendar 연동)
CALDAV_BASE_URL=https://192.168.1.227:5001/caldav
CALDAV_USER=chatbot-api
# CalDAV (caldav_bridge.py — Synology Calendar, DSM HTTPS 포트=15001, 경로=caldav.php)
CALDAV_BASE_URL=https://192.168.1.227:15001/caldav.php
CALDAV_USER=hyungi
CALDAV_PASSWORD=changeme
CALDAV_CALENDAR=chatbot
CALDAV_CALENDAR=home
# IMAP (메일 처리 파이프라인)
# IMAP (메일 처리 파이프라인, MailPlus 포트=21680)
IMAP_HOST=192.168.1.227
IMAP_PORT=993
IMAP_USER=chatbot-api
IMAP_PORT=21680
IMAP_USER=hyungi
IMAP_PASSWORD=changeme
# DEVONthink (devonthink_bridge.py — 지식 저장소)
DEVONTHINK_APP_NAME=DEVONthink
# Karakeep (NAS Docker — 북마크/뉴스 저장)
KARAKEEP_URL=http://192.168.1.227:3000
KARAKEEP_API_KEY=changeme
# Bridge Service URLs (n8n Docker → macOS 네이티브 서비스)
HEIC_CONVERTER_URL=http://host.docker.internal:8090
CHAT_BRIDGE_URL=http://host.docker.internal:8091

View File

@@ -66,8 +66,8 @@ DEVONthink 4 (맥미니):
| bot-n8n | Docker (맥미니) | 5678 | 워크플로우 엔진 |
| bot-postgres | Docker (맥미니) | 127.0.0.1:15478 | 설정/로그 DB |
| Qdrant | Docker (맥미니, 기존) | 127.0.0.1:6333 | 벡터 DB (3컬렉션) |
| Ollama (맥미니) | 네이티브 (기존) | 11434 | bge-m3, bge-reranker-v2-m3 (임베딩/리랭킹 전용) |
| Ollama (GPU) | 192.168.1.186 (RTX 4070Ti Super) | 11434 | qwen3.5:9b-q8_0 (분류+local응답) |
| Ollama (맥미니) | 네이티브 (기존) | 11434 | bge-m3, bge-reranker-v2-m3 (임베딩/리랭킹 전용, brew services 자동기동) |
| Ollama (GPU) | 192.168.1.186 (RTX 4070Ti Super) | 11434 | id-9b:latest (이드 특화 분류+응답), qwen3.5:9b-q8_0 (레거시 백업) |
| Claude Haiku Vision | Anthropic API | — | 사진 분석+구조화 (field_report, log_event) |
| heic_converter | 네이티브 (맥미니) | 8090 | HEIC→JPEG 변환 (macOS sips) |
| chat_bridge | 네이티브 (맥미니) | 8091 | DSM Chat API 브릿지 (사진 폴링/다운로드) |
@@ -94,6 +94,7 @@ DEVONthink 4 (맥미니):
|--------|------|
| `documents` | 개인/일반 문서 + 메일 요약 + 뉴스 요약 |
| `tk_company` | TechnicalKorea 회사 문서 + 현장 리포트 |
| `tk_qc_issues` | TechnicalKorea 품질 이슈 |
| `chat_memory` | 가치 있는 대화 기억 (선택적 저장) |
## DB 스키마 (bot-postgres)
@@ -160,3 +161,4 @@ DEVONthink 4 (맥미니):
- response_tier는 Qwen v2 분류기 출력. 기존 complexity 기반 라우팅은 레거시 호환
- n8n v2.11+ Code 노드는 Task Runner 샌드박스(VM)에서 실행됨. `$http.request()`/`this.helpers.httpRequest()`/`fetch()`/`AbortController`/`URL` 사용 불가. `require('http')`/`require('https')`/`require('url')` 사용 (NODE_FUNCTION_ALLOW_BUILTIN=crypto,http,https,url). 각 Code 노드에 `httpPost`/`httpPut` 헬퍼 함수 인라인 정의
- 샌드박스 사용 가능: `Buffer`, `setTimeout`, `TextEncoder`, `FormData`, `$env`, `$input`, `$()`, `$getWorkflowStaticData()`, `$json`, `require('url').parse()` (new URL 불가), `console`
- id-9b:latest가 기본 GPU 모델. qwen3.5:9b-q8_0은 레거시 백업이며 검증 완료 후 삭제 예정

View File

@@ -12,14 +12,15 @@ docker ps -a --filter "name=bot-" --format "table {{.Names}}\t{{.Status}}\t{{.Po
docker ps --filter "name=qdrant" --format "table {{.Names}}\t{{.Status}}"
curl -s http://localhost:6333/collections | python3 -c "import sys,json; [print(f' {c[\"name\"]}') for c in json.loads(sys.stdin.read())['result']['collections']]"
# 맥미니 Ollama 모델 확인
# 맥미니 Ollama 모델 확인 (brew services로 자동기동, 임베딩/리랭킹 전용)
# 중지 상태일 때: brew services start ollama
ollama list
# GPU 서버 Ollama 상태
curl -s http://192.168.1.186:11434/api/tags | python3 -m json.tool
# GPU 서버 Qwen 9B 헬스체크
curl -s http://192.168.1.186:11434/api/generate -d '{"model":"qwen3.5:9b-q8_0","prompt":"hi","stream":false}' | python3 -m json.tool
# GPU 서버 id-9b 헬스체크
curl -s http://192.168.1.186:11434/api/generate -d '{"model":"id-9b:latest","prompt":"hi","stream":false}' | python3 -m json.tool
```
## 접속 정보
@@ -112,6 +113,7 @@ syn-chat-bot/
├── inbox_processor.py ← OmniFocus Inbox 폴링 (LaunchAgent, 5분)
├── news_digest.py ← 뉴스 번역·요약 (LaunchAgent, 매일 07:00)
├── manage_services.sh ← 네이티브 서비스 관리 (start/stop/status)
├── deploy_workflows.sh ← n8n 워크플로우 자동 배포 (REST API)
├── start-bridge.sh ← 브릿지 서비스 시작 헬퍼
├── com.syn-chat-bot.*.plist ← LaunchAgent 설정 (6개)
├── docs/
@@ -140,7 +142,7 @@ curl http://<맥미니IP>:5678/webhook-test/chat
curl http://localhost:11434/api/embeddings -d '{"model":"bge-m3","prompt":"test"}'
# GPU 서버 분류 테스트
curl http://192.168.1.186:11434/api/generate -d '{"model":"qwen3.5:9b-q8_0","prompt":"안녕하세요","stream":false}'
curl http://192.168.1.186:11434/api/generate -d '{"model":"id-9b:latest","prompt":"안녕하세요","stream":false}'
# Qdrant 컬렉션 확인
curl http://localhost:6333/collections

84
deploy_workflows.sh Executable file
View File

@@ -0,0 +1,84 @@
#!/bin/bash
# n8n REST API로 워크플로우 업데이트
# 사용법: ./deploy_workflows.sh [--activate]
# 필요: N8N_API_KEY 환경변수 또는 .env 파일
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
N8N_URL="${N8N_URL:-http://localhost:5678}"
WORKFLOW_DIR="$SCRIPT_DIR/n8n/workflows"
# .env에서 API 키 로드
if [ -z "${N8N_API_KEY:-}" ] && [ -f "$SCRIPT_DIR/.env" ]; then
N8N_API_KEY=$(grep '^N8N_API_KEY=' "$SCRIPT_DIR/.env" | cut -d= -f2-)
fi
if [ -z "${N8N_API_KEY:-}" ]; then
echo "ERROR: N8N_API_KEY not set. Set it in .env or as environment variable."
exit 1
fi
AUTH_HEADER="X-N8N-API-KEY: $N8N_API_KEY"
ACTIVATE="${1:-}"
# 워크플로우 이름 → ID 매핑 (n8n API에서 조회)
get_workflow_id() {
local name="$1"
curl -s -H "$AUTH_HEADER" "$N8N_URL/api/v1/workflows?limit=100" \
| python3 -c "
import sys, json
data = json.loads(sys.stdin.read())
for wf in data.get('data', []):
if wf['name'] == '$name':
print(wf['id'])
break
"
}
deploy_workflow() {
local file="$1"
local name
name=$(python3 -c "import json; print(json.load(open('$file'))['name'])")
local wf_id
wf_id=$(get_workflow_id "$name")
if [ -z "$wf_id" ]; then
echo " Creating new workflow: $name"
wf_id=$(curl -s -X POST "$N8N_URL/api/v1/workflows" \
-H "$AUTH_HEADER" \
-H "Content-Type: application/json" \
-d @"$file" \
| python3 -c "import sys, json; print(json.loads(sys.stdin.read())['id'])")
echo " Created: $wf_id"
else
echo " Updating workflow: $name (id=$wf_id)"
local http_code
http_code=$(curl -s -o /dev/null -w '%{http_code}' -X PUT \
"$N8N_URL/api/v1/workflows/$wf_id" \
-H "$AUTH_HEADER" \
-H "Content-Type: application/json" \
-d @"$file")
if [ "$http_code" -ge 400 ]; then
echo " FAILED (HTTP $http_code)"
return 1
fi
echo " Updated (HTTP $http_code)"
fi
if [ "$ACTIVATE" = "--activate" ]; then
curl -s -X POST "$N8N_URL/api/v1/workflows/$wf_id/activate" \
-H "$AUTH_HEADER" > /dev/null
echo " Activated"
fi
}
echo "=== n8n Workflow Deploy ==="
echo "Target: $N8N_URL"
echo ""
for file in "$WORKFLOW_DIR"/*.json; do
echo "[$(basename "$file")]"
deploy_workflow "$file"
echo ""
done
echo "Done."

View File

@@ -126,7 +126,7 @@ pip install caldav aiohttp
### 4. GPU 서버 (192.168.1.186)
- Ollama 설치 + `ollama pull qwen3.5:9b-q8_0`
- Ollama 설치 + `ollama pull id-9b:latest` (이드 특화 모델, 기존 qwen3.5:9b-q8_0은 레거시 백업)
- 포트 11434 개방
### 5. 환경 변수

View File

@@ -132,7 +132,7 @@ type 판단:
try:
resp = httpx.post(
f"{GPU_OLLAMA_URL}/api/generate",
json={"model": "qwen3.5:9b-q8_0", "prompt": prompt, "stream": False, "format": "json", "think": False},
json={"model": "id-9b:latest", "prompt": prompt, "stream": False, "format": "json", "think": False},
timeout=15,
)
return json.loads(resp.json()["response"])

View File

@@ -36,7 +36,7 @@
},
{
"parameters": {
"jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst item = $input.first().json;\nconst prompt = `메일을 분류하고 요약하세요. JSON만 출력.\n\n{\n \"summary\": \"한국어 2~3문장 요약\",\n \"label\": \"업무|개인|광고|알림\",\n \"has_events\": true/false,\n \"has_tasks\": true/false\n}\n\n보낸 사람: ${item.from}\n제목: ${item.subject}\n본문: ${item.body.substring(0, 3000)}`;\n\ntry {\n const r = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model: 'qwen3.5:9b-q8_0', prompt, stream: false, format: 'json', think: false },\n { timeout: 15000 }\n );\n const cls = JSON.parse(r.response);\n return [{ json: { ...item, summary: cls.summary || item.subject, label: cls.label || '알림', has_events: cls.has_events || false, has_tasks: cls.has_tasks || false } }];\n} catch(e) {\n return [{ json: { ...item, summary: item.subject, label: '알림', has_events: false, has_tasks: false } }];\n}"
"jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst item = $input.first().json;\nconst prompt = `메일을 분류하고 요약하세요. JSON만 출력.\n\n{\n \"summary\": \"한국어 2~3문장 요약\",\n \"label\": \"업무|개인|광고|알림\",\n \"has_events\": true/false,\n \"has_tasks\": true/false\n}\n\n보낸 사람: ${item.from}\n제목: ${item.subject}\n본문: ${item.body.substring(0, 3000)}`;\n\ntry {\n const r = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model: 'id-9b:latest', prompt, stream: false, format: 'json', think: false },\n { timeout: 15000 }\n );\n const cls = JSON.parse(r.response);\n return [{ json: { ...item, summary: cls.summary || item.subject, label: cls.label || '알림', has_events: cls.has_events || false, has_tasks: cls.has_tasks || false } }];\n} catch(e) {\n return [{ json: { ...item, summary: item.subject, label: '알림', has_events: false, has_tasks: false } }];\n}"
},
"id": "m1000001-0000-0000-0000-000000000003",
"name": "Summarize & Classify",

File diff suppressed because one or more lines are too long

View File

@@ -104,7 +104,7 @@ def translate_and_summarize(title: str, content: str, lang: str) -> dict:
resp = httpx.post(
f"{GPU_OLLAMA_URL}/api/generate",
json={
"model": "qwen3.5:9b-q8_0",
"model": "id-9b:latest",
"prompt": f"다음 기사를 2~3문장으로 요약하세요:\n\n제목: {title}\n본문: {content[:3000]}",
"stream": False,
"think": False,