commit 6e6ffaa04b7f2568566d0fd4a51168d0ec62d5ef Author: hyungi Date: Wed Mar 11 12:51:30 2026 +0900 RAG 아키텍처 v2: 3단계 라우팅, 멀티-컬렉션 RAG, 선택적 메모리 Phase 1-3 구현: - init.sql v2: 12테이블 (기존 5 + 신규 7) + 분류기 v2 프롬프트 - migrate-v2.sql: 기존 DB 마이그레이션 스크립트 - setup-qdrant.sh: tk_company 컬렉션 + payload 인덱스 설정 - 워크플로우 v2 (37노드): 토큰검증, Rate Limit, 프리필터, 분류기v2(response_tier), 3-tier 라우팅(local/Haiku/Opus), 멀티-컬렉션 RAG, 예산 체크, 선택적 메모리 - .env.example + docker-compose.yml: 새 환경변수 추가 - CLAUDE.md, QUICK_REFERENCE.md, docs/architecture.md 전면 갱신 Co-Authored-By: Claude Opus 4.6 diff --git a/.claude/commands/simplify.md b/.claude/commands/simplify.md new file mode 100644 index 0000000..c60bf5f --- /dev/null +++ b/.claude/commands/simplify.md @@ -0,0 +1,6 @@ +변경된 코드를 리뷰하고 다음을 확인하라: +1. 중복 코드가 있는지 +2. 불필요한 복잡성이 있는지 +3. 더 간단한 구현이 가능한지 + +문제가 있으면 직접 수정하라. 수정 후 변경 사항을 요약하라. \ No newline at end of file diff --git a/.claude/commands/status.md b/.claude/commands/status.md new file mode 100644 index 0000000..938279f --- /dev/null +++ b/.claude/commands/status.md @@ -0,0 +1,9 @@ +현재 프로젝트 상태를 확인하라: + +1. git status (변경된 파일) +2. docker ps -a --filter "name=bot-" (챗봇 컨테이너 상태) +3. docker ps --filter "name=qdrant" (벡터 DB 상태) +4. ollama list (임베딩 모델 상태) +5. QUICK_REFERENCE.md의 진행 상황 체크리스트와 실제 상태 비교 + +결과를 간결하게 요약하라. \ No newline at end of file diff --git a/.claude/commands/verify.md b/.claude/commands/verify.md new file mode 100644 index 0000000..5863db2 --- /dev/null +++ b/.claude/commands/verify.md @@ -0,0 +1,9 @@ +다음 검증을 순서대로 수행하라: + +1. docker-compose config로 문법 검증 +2. .env.example에 있는 모든 변수가 docker-compose.yml에서 참조되는지 확인 +3. init.sql 문법 검증 (docker exec로 dry-run) +4. 컨테이너가 실행 중이면 헬스체크 확인 +5. n8n 웹훅 엔드포인트 응답 확인 + +각 단계의 결과를 보고하라. 실패한 항목이 있으면 원인과 수정 방안을 제시하라. \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..40bc01b --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +# Claude API +ANTHROPIC_API_KEY=sk-ant-xxxxx + +# bot-postgres +POSTGRES_USER=bot +POSTGRES_PASSWORD=changeme +POSTGRES_DB=chatbot + +# n8n +N8N_BASIC_AUTH_USER=admin +N8N_BASIC_AUTH_PASSWORD=changeme +N8N_ENCRYPTION_KEY=changeme-random-string + +# Synology Chat +SYNOLOGY_CHAT_WEBHOOK_URL=https://your-nas:5001/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming&version=2&token=YOUR_TOKEN +SYNOLOGY_CHAT_TOKEN=your-outgoing-webhook-token + +# 관리자 사용자명 (쉼표 구분, /문서등록 /보고서 등 권한 제어) +ADMIN_USERNAMES=admin + +# API 월간 예산 (USD, 초과 시 api_heavy→api_light 다운그레이드) +API_BUDGET_HEAVY=50.00 +API_BUDGET_LIGHT=20.00 + +# Ollama (맥미니 — Docker 내부에서 접근) +LOCAL_OLLAMA_URL=http://host.docker.internal:11434 + +# Ollama (GPU 서버 — RTX 4070Ti Super) +GPU_OLLAMA_URL=http://192.168.1.186:11434 + +# Qdrant (Docker 내부에서 접근) +QDRANT_URL=http://host.docker.internal:6333 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28f6566 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +n8n/data/ +postgres/data/ +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..af3c5bc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,122 @@ +# syn-chat-bot + +Synology Chat + n8n + Claude API 기반 RAG 챗봇 시스템. +3단계 모델 라우팅(local/api_light/api_heavy) + 멀티-컬렉션 RAG + 선택적 메모리. + +## 아키텍처 + +``` +Synology Chat (NAS 192.168.1.227) + ↕ 웹훅 +bot-n8n (맥미니 Docker) + │ + ├─⓪ 토큰 검증 + Rate Limit (10초/5건) + ├─① 규칙 기반 프리필터 (인사/감사 → 하드코딩 local 응답) + │ + ├─② GPU Qwen 9B (192.168.1.186) — 분류 v2 + │ 출력: {intent, response_tier, needs_rag, rag_target, ...} + │ 타임아웃 10초 → fallback: api_light + │ + ├─③ [needs_rag=true] 멀티-컬렉션 RAG + │ documents + tk_company + chat_memory + │ bge-m3 임베딩 → Qdrant 검색 → reranker → top-3 + │ + ├─④ response_tier 기반 3단계 라우팅 + │ ├─ local → Qwen 9B 직접 답변 (무료) + │ ├─ api_light → Claude Haiku (저비용) + │ └─ api_heavy → Claude Opus (예산 초과 시 → Haiku 다운그레이드) + │ + ├── bot-postgres (설정/로그/라우팅/분류기로그/API사용량) + └── Qdrant (벡터 검색, 3컬렉션) + + ⑤ 응답 전송 + chat_logs 저장 + API 사용량 UPSERT + ⑥ [비동기] 선택적 메모리 (Qwen 판단 → 가치 있으면 벡터화) +``` + +## 인프라 + +| 구성 요소 | 위치 | 포트 | 비고 | +|----------|------|------|------| +| 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응답) | +| Synology Chat | NAS (192.168.1.227) | — | 사용자 인터페이스 | + +## 3단계 라우팅 + +| tier | 모델 | 비용 | 대상 | +|------|------|------|------| +| **local** | Qwen 9B (GPU) | 무료 | 인사, 잡담, 단순 확인 | +| **api_light** | Claude Haiku | 저비용 | 요약, 번역, RAG 정리 | +| **api_heavy** | Claude Opus | 고비용 | 법률 해석, 복잡한 추론 | + +## 3-컬렉션 RAG + +| 컬렉션 | 용도 | +|--------|------| +| `documents` | 개인/일반 문서 + 메일 요약 | +| `tk_company` | TechnicalKorea 회사 문서 + 현장 리포트 | +| `chat_memory` | 가치 있는 대화 기억 (선택적 저장) | + +## DB 스키마 (bot-postgres) + +**기존**: `ai_configs`, `routing_rules`, `prompts`, `chat_logs`, `mail_accounts` +**신규**: `document_ingestion_log`, `field_reports`, `classification_logs`, `mail_logs`, `calendar_events`, `report_cache`, `api_usage_monthly` + +상세 스키마는 [docs/architecture.md](docs/architecture.md) 참조. + +## Synology Chat 명령어 + +``` +/모델 <모델명> → 기본 모델 변경 +/성격 <설명> → 시스템 프롬프트 변경 +/설정 → 현재 설정 확인 +/문서등록 [부서] [유형] [제목] → 회사 문서 등록 (Phase 4) +/보고서 [영역] [년월] → 월간 보고서 생성 (Phase 5) +``` + +## 안전장치 + +- **웹훅 토큰 검증**: SYNOLOGY_CHAT_TOKEN 불일치 → reject +- **Rate Limit**: username별 10초 내 5건 → 거부 +- **명령어 권한 체크**: ADMIN_USERNAMES allowlist +- **프리필터**: 인사/감사 정규식 → GPU 미호출 +- **분류기 fallback**: Qwen 10초 타임아웃 → api_light +- **예산 상한**: api_heavy 월간 예산 초과 → api_light 다운그레이드 +- **선택적 메모리**: Qwen 판단으로 가치 있는 대화만 벡터화 +- **classification_logs**: input 200자 제한, 90일 배치 정리 + +## 페르소나: 이드 + +모든 모델에 공통 적용되는 통합 페르소나. +- local tier: 경량 프롬프트 (`chat_local` feature) +- api_light/api_heavy: 전체 프롬프트 (`chat` feature) + +## 개발 규칙 + +- docker-compose.yml로 컨테이너 관리 +- DB 포트는 127.0.0.1로 바인딩 (외부 노출 금지) +- 시크릿(API 키 등)은 .env 파일로 관리, git에 포함하지 않음 +- 커밋 전 docker-compose config로 문법 검증 +- 세션 시작 시 Plan 모드로 계획 → 확정 후 구현 +- 구현 완료 후 `/verify`로 검증, `/simplify`로 코드 리뷰 + +## 슬래시 명령어 + +- `/simplify` — 변경 코드 리뷰 및 단순화 +- `/verify` — 전체 검증 (docker, env, sql, 헬스체크) +- `/status` — 프로젝트 상태 확인 + +## 참고 문서 + +- [Architecture](docs/architecture.md) — 아키텍처, DB 스키마, 파이프라인 상세 +- [Claude Code Playbook](docs/claude-code-playbook.md) — 활용 패턴 상세 정리 + +## 실수 방지 (Claude가 틀릴 때마다 여기에 추가) + +- "오픈클로(OpenClaw)"를 Claude Code로 착각하지 말 것. OpenClaw은 별도의 오픈소스 AI 에이전트 프로젝트임 +- tk-mp-postgres는 테스트 서버 전용 DB. 챗봇 인프라와 혼용하지 말 것 +- GPU 서버(192.168.1.186)의 Ollama는 분류 + local 응답용. 임베딩/리랭킹은 맥미니 Ollama 사용 +- response_tier는 Qwen v2 분류기 출력. 기존 complexity 기반 라우팅은 레거시 호환 diff --git a/QUICK_REFERENCE.md b/QUICK_REFERENCE.md new file mode 100644 index 0000000..1efdd62 --- /dev/null +++ b/QUICK_REFERENCE.md @@ -0,0 +1,212 @@ +# Quick Reference + +작업 시작 전 체크리스트. + +## 현재 상태 확인 + +```bash +# 챗봇 컨테이너 상태 +docker ps -a --filter "name=bot-" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" + +# Qdrant 상태 + 3컬렉션 확인 +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 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 +``` + +## 접속 정보 + +| 서비스 | URL | +|--------|-----| +| n8n 편집기 | http://localhost:5678 | +| Qdrant 대시보드 | http://localhost:6333/dashboard | +| bot-postgres | localhost:15478 | +| Ollama API (맥미니) | http://localhost:11434 | +| Ollama API (GPU) | http://192.168.1.186:11434 | +| Synology Chat | NAS (192.168.1.227) | + +## Docker 명령어 + +```bash +# 프로젝트 경로 +cd ~/Documents/code/syn-chat-bot + +# 시작/종료 +docker compose up -d +docker compose down + +# 로그 확인 +docker compose logs -f bot-n8n +docker compose logs -f bot-postgres + +# n8n만 재시작 +docker compose restart bot-n8n + +# DB 접속 +docker exec -it bot-postgres psql -U bot -d chatbot + +# DB 테이블 확인 +docker exec bot-postgres psql -U bot -d chatbot -c '\dt' + +# v2 마이그레이션 실행 (기존 DB가 있을 때) +docker exec -i bot-postgres psql -U bot -d chatbot < init/migrate-v2.sql + +# Qdrant tk_company 컬렉션 + 인덱스 설정 +bash init/setup-qdrant.sh +``` + +## 헬스체크 (전체) + +```bash +# 한 번에 전체 확인 +echo "=== Docker ===" && \ +docker ps -a --filter "name=bot-" --filter "name=qdrant" --format "table {{.Names}}\t{{.Status}}" && \ +echo "=== 맥미니 Ollama ===" && \ +curl -s http://localhost:11434/api/tags | python3 -c "import sys,json; [print(f' {m[\"name\"]}') for m in json.loads(sys.stdin.read())['models']]" && \ +echo "=== GPU 서버 Ollama ===" && \ +curl -s http://192.168.1.186:11434/api/tags | python3 -c "import sys,json; [print(f' {m[\"name\"]}') for m in json.loads(sys.stdin.read())['models']]" && \ +echo "=== Qdrant 컬렉션 ===" && \ +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']]" && \ +echo "=== tk_company ===" && \ +curl -s http://localhost:6333/collections/tk_company | python3 -c "import sys,json; r=json.loads(sys.stdin.read())['result']; print(f' 벡터수: {r[\"points_count\"]}, 상태: {r[\"status\"]}')" 2>/dev/null || echo " (미생성)" && \ +echo "=== n8n ===" && \ +curl -s -o /dev/null -w ' HTTP %{http_code}' http://localhost:5678 && echo && \ +echo "=== API 사용량 ===" && \ +docker exec bot-postgres psql -U bot -d chatbot -t -c "SELECT tier, call_count, estimated_cost FROM api_usage_monthly WHERE year=EXTRACT(YEAR FROM NOW()) AND month=EXTRACT(MONTH FROM NOW())" 2>/dev/null || echo " (테이블 없음)" +``` + +## 디렉토리 구조 + +``` +syn-chat-bot/ +├── docker-compose.yml ← 컨테이너 정의 +├── .env ← API 키, DB 비밀번호, 토큰, 예산 (git 제외) +├── .env.example ← 환경변수 템플릿 +├── CLAUDE.md ← 프로젝트 문서 +├── QUICK_REFERENCE.md ← 이 파일 +├── docs/ +│ ├── architecture.md ← 아키텍처, DB 스키마, 파이프라인 상세 +│ └── claude-code-playbook.md +├── n8n/ +│ ├── data/ ← n8n 런타임 데이터 +│ └── workflows/ +│ └── main-chat-pipeline.json ← 메인 워크플로우 (37노드) +├── init/ +│ ├── init.sql ← DB 초기 스키마 v2 (12테이블) +│ ├── migrate-v2.sql ← 기존 DB 마이그레이션 +│ └── setup-qdrant.sh ← Qdrant 컬렉션/인덱스 설정 +└── postgres/data/ ← DB 데이터 +``` + +## 트러블슈팅 + +```bash +# n8n 웹훅 안 올 때 — NAS에서 맥미니 접근 가능한지 확인 +curl http://<맥미니IP>:5678/webhook-test/chat + +# Ollama 임베딩 테스트 (맥미니) +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}' + +# Qdrant 컬렉션 확인 +curl http://localhost:6333/collections + +# tk_company 검색 테스트 +curl -X POST http://localhost:6333/collections/tk_company/points/search \ + -H 'Content-Type: application/json' \ + -d '{"vector": [0.1, ...], "limit": 3, "with_payload": true}' + +# bot-postgres 접속 +docker exec -it bot-postgres psql -U bot -d chatbot + +# 분류기 로그 확인 +docker exec bot-postgres psql -U bot -d chatbot -c "SELECT created_at, output_json->>'intent', output_json->>'response_tier', fallback_used FROM classification_logs ORDER BY created_at DESC LIMIT 10" + +# API 사용량 확인 +docker exec bot-postgres psql -U bot -d chatbot -c "SELECT * FROM api_usage_monthly ORDER BY year DESC, month DESC" + +# GPU 서버 연결 안 될 때 +ping 192.168.1.186 +curl -s http://192.168.1.186:11434/api/tags +``` + +## n8n 접속 정보 + +- URL: http://localhost:5678 +- 이메일: ahn@hyungi.net +- 비밀번호: .env의 N8N_BASIC_AUTH_PASSWORD와 동일 +- 워크플로우: "메인 채팅 파이프라인 v2" (37 노드, 활성 상태) +- 웹훅 엔드포인트: POST http://localhost:5678/webhook/chat + +## Synology Chat 연동 + +NAS에서 Outgoing Webhook 설정 필요: + +1. Synology Chat > 통합 > 봇 > 만들기 +2. 발신 웹훅(Outgoing Webhook) URL: `http://<맥미니IP>:5678/webhook/chat` +3. 토큰은 자동 생성됨 → .env의 `SYNOLOGY_CHAT_TOKEN`에 설정 + +수신 웹훅(Incoming Webhook)은 .env의 `SYNOLOGY_CHAT_WEBHOOK_URL`에 이미 설정됨. + +## 진행 상황 + +### Phase 0: 맥미니 정리 +- [ ] ollama rm qwen3.5:35b-a3b (삭제) +- [ ] ollama pull minicpm-v:8b (비전 모델 설치) + +### Phase 1: 기반 (Qdrant + DB) +- [x] init.sql v2 (12테이블 + 분류기 v2 프롬프트 + 메모리 판단 프롬프트) +- [x] migrate-v2.sql (기존 DB 마이그레이션) +- [x] setup-qdrant.sh (tk_company 컬렉션 + 인덱스) +- [ ] DB 마이그레이션 실행 +- [ ] Qdrant 설정 실행 + +### Phase 2: 3단계 라우팅 + 검색 라우팅 +- [x] 워크플로우 v2 (37노드): 토큰검증, Rate Limit, 프리필터, 분류기v2, 3-tier, 멀티-컬렉션 RAG +- [x] .env + docker-compose.yml 환경변수 추가 +- [ ] n8n에 워크플로우 임포트 + 활성화 +- [ ] 테스트: "안녕" → local, "요약해줘" → Haiku, "법률 해석" → Opus + +### Phase 3: 선택적 메모리 +- [x] Memorization Check 노드 (비동기, 응답 후) +- [x] Should Memorize? + Embed & Save Memory +- [ ] 테스트: 인사 → 미저장, 기술질문 → 저장 + +### Phase 4: 회사 문서 등록 +- [x] /문서등록 명령어 파서 (placeholder) +- [ ] 텍스트 청킹 + 임베딩 + tk_company 저장 구현 +- [ ] 문서 버전 관리 (deprecated + version++) + +### Phase 5: 현장 리포팅 +- [x] field_reports 테이블 + SLA 인덱스 +- [ ] 비전 모델 설치 + 사진 분석 노드 +- [ ] /보고서 월간 보고서 생성 구현 +- [ ] SLA 트래킹 스케줄 워크플로우 + +### Phase 6: 메일 + 캘린더 +- [x] mail_logs, calendar_events 테이블 +- [ ] IMAP 폴링 워크플로우 +- [ ] CalDAV 연동 + +## 검증 체크리스트 + +1. `curl localhost:6333/collections` → documents, tk_company, chat_memory 존재 +2. "안녕" → 프리필터 → local 응답 (GPU 미호출) +3. "이거 요약해줘" → Haiku 답변 +4. "이 법률 해석해줘" → Opus 답변 +5. 인사 → chat_memory 미저장 (chat_logs에는 기록) +6. 기술 질문 → chat_memory 저장 +7. "아까 물어본 거" → chat_memory 검색 성공 +8. GPU 서버 다운 → fallback Haiku 답변 +9. 잘못된 토큰 → reject +10. 10초 내 6건 → rate limit diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..486f569 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,64 @@ +services: + bot-n8n: + image: n8nio/n8n:latest + container_name: bot-n8n + restart: unless-stopped + ports: + - "5678:5678" + environment: + - N8N_BASIC_AUTH_ACTIVE=true + - N8N_BASIC_AUTH_USER=${N8N_BASIC_AUTH_USER} + - N8N_BASIC_AUTH_PASSWORD=${N8N_BASIC_AUTH_PASSWORD} + - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY} + - DB_TYPE=postgresdb + - DB_POSTGRESDB_HOST=bot-postgres + - DB_POSTGRESDB_PORT=5432 + - DB_POSTGRESDB_DATABASE=${POSTGRES_DB} + - DB_POSTGRESDB_USER=${POSTGRES_USER} + - DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD} + - N8N_BLOCK_ENV_ACCESS_IN_NODE=false + - N8N_HOST=0.0.0.0 + - WEBHOOK_URL=http://localhost:5678/ + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - SYNOLOGY_CHAT_WEBHOOK_URL=${SYNOLOGY_CHAT_WEBHOOK_URL} + - SYNOLOGY_CHAT_TOKEN=${SYNOLOGY_CHAT_TOKEN} + - ADMIN_USERNAMES=${ADMIN_USERNAMES} + - API_BUDGET_HEAVY=${API_BUDGET_HEAVY} + - API_BUDGET_LIGHT=${API_BUDGET_LIGHT} + - LOCAL_OLLAMA_URL=${LOCAL_OLLAMA_URL} + - GPU_OLLAMA_URL=${GPU_OLLAMA_URL} + - QDRANT_URL=${QDRANT_URL:-http://host.docker.internal:6333} + volumes: + - ./n8n/data:/home/node/.n8n + networks: + - bot-network + depends_on: + bot-postgres: + condition: service_healthy + extra_hosts: + - "host.docker.internal:host-gateway" + + bot-postgres: + image: postgres:16-alpine + container_name: bot-postgres + restart: unless-stopped + ports: + - "127.0.0.1:15478:5432" + environment: + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_DB=${POSTGRES_DB} + volumes: + - ./postgres/data:/var/lib/postgresql/data + - ./init:/docker-entrypoint-initdb.d + networks: + - bot-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + +networks: + bot-network: + driver: bridge diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..4f6c0ed --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,331 @@ +# Architecture + +## 전체 아키텍처 + +``` +┌─────────────────────┐ +│ Synology Chat │ 사용자 인터페이스 +│ (NAS 192.168.1.227)│ +└─────────┬───────────┘ + │ Outgoing Webhook + ▼ +┌────────────────────────────────────────────────────────────┐ +│ bot-n8n (맥미니 Docker :5678) — 37노드 파이프라인 │ +│ │ +│ ⓪ 토큰 검증 + Rate Limit (username별 10초/5건) │ +│ │ +│ ① 규칙 기반 프리필터 │ +│ └─ 인사/감사 정규식 매칭 → 하드코딩 local 응답 │ +│ │ +│ ② 명령어 체크 (/설정, /모델, /성격, /문서등록, /보고서) │ +│ └─ 권한 체크 (ADMIN_USERNAMES allowlist) │ +│ │ +│ ③ GPU Qwen 9B 분류 v2 (10초 타임아웃) │ +│ → {intent, response_tier, needs_rag, rag_target, ...} │ +│ └─ 실패 시 fallback → api_light │ +│ │ +│ ④ [needs_rag=true] 멀티-컬렉션 RAG 검색 │ +│ documents + tk_company + chat_memory │ +│ → bge-m3 임베딩 → Qdrant 검색 → reranker → top-3 │ +│ │ +│ ⑤ response_tier 3단계 분기 │ +│ ├─ local → Qwen 9B 직접 답변 │ +│ ├─ api_light → Claude Haiku │ +│ └─ api_heavy → 예산 체크 → Claude Opus (or 다운그레이드) │ +│ │ +│ ⑥ 응답 전송 + chat_logs + api_usage_monthly │ +│ ⑦ [비동기] Qwen 메모리 판단 → 가치 있으면 벡터화 │ +│ └─ classification_logs 기록 │ +└──┬──────────┬───────────┬───────────┬──────────────────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌──────┐ ┌────────┐ ┌─────────┐ ┌──────────────┐ +│bot- │ │Qdrant │ │Ollama │ │Ollama (GPU) │ +│postgres│ │:6333 │ │:11434 │ │192.168.1.186 │ +│:15478│ │3컬렉션 │ │bge-m3 │ │:11434 │ +│12테이블│ │documents│ │reranker│ │qwen3.5:9b │ +│ │ │tk_company││비전모델 │ │(분류+응답) │ +│ │ │chat_memory│ │ │ │ +└──────┘ └────────┘ └─────────┘ └──────────────┘ +``` + +## 3단계 라우팅 상세 + +### response_tier 판단 (Qwen 9B 분류기 v2) + +| tier | 모델 | 비용 | 대상 | +|------|------|------|------| +| **local** | Qwen 9B (GPU) | 무료 | 인사, 잡담, 단순 확인, 감사, 짧은 반응 | +| **api_light** | Claude Haiku | ~$0.8/1M in, $4/1M out | 요약, 번역, RAG 정리, 비교 분석 | +| **api_heavy** | Claude Opus | ~$15/1M in, $75/1M out | 법률 해석, 다중 문서 분석, 보고서 작성 | + +**비용 최적화 목표:** +- ~40% → local (무료, 프리필터+Qwen 직접 답변) +- ~50% → Haiku (저비용) +- ~10% → Opus (복잡한 질문만) + +### 분류기 v2 출력 스키마 + +```json +{ + "intent": "greeting|question|calendar|reminder|mail|photo|command|report|other", + "response_tier": "local|api_light|api_heavy", + "needs_rag": true, + "rag_target": ["documents", "tk_company", "chat_memory"], + "department_hint": "안전|생산|구매|품질|null", + "report_domain": "안전|시설설비|품질|null", + "query": "검색용 쿼리" +} +``` + +### 프리필터 → 분류기 → 모델 라우팅 흐름 + +``` +메시지 수신 + │ + ├─ 프리필터 매칭 (인사/감사 정규식) + │ └─ 매칭 → 하드코딩 local 응답 (GPU 서버 미호출) + │ + └─ 미매칭 → Qwen 9B 분류기 + ├─ response_tier=local → Qwen 9B 직접 답변 + ├─ response_tier=api_light → Claude Haiku + └─ response_tier=api_heavy → 예산 체크 + ├─ 예산 내 → Claude Opus + └─ 초과 → Claude Haiku (다운그레이드) +``` + +## 3-컬렉션 RAG 상세 + +### 컬렉션 구조 + +| 컬렉션 | 용도 | 벡터 차원 | 필터 | +|--------|------|----------|------| +| `documents` | 개인/일반 문서, 메일 요약 | 1024 (bge-m3) | 없음 | +| `tk_company` | TK 회사 문서, 현장 리포트 | 1024 (bge-m3) | department, year, doc_type | +| `chat_memory` | 가치 있는 대화 기억 | 1024 (bge-m3) | username, topic, intent | + +### 멀티-컬렉션 검색 흐름 + +``` +rag_target에 따라 동적 검색: + - 단일 컬렉션 → top-10 + - 2개 → 각 top-7 + - 3개 → 각 top-5 + +각 컬렉션별 필터: + - documents: 필터 없음 + - tk_company: department + year 필터 + - chat_memory: username 필터 + +합산 → bge-reranker 리랭킹 (실패 시 Qdrant score 정렬) → top-3 + +출처 표시: + [회사/안전/절차서] 고소작업 안전절차 - "작업 전 반드시..." + [개인문서] Safety Engineering - "Workers at height..." + [이전대화/2026-03-10] "고소작업은 2m 이상..." +``` + +### tk_company payload + +``` +text, year(인덱스), department(인덱스), doc_type(인덱스), +title, source_file, file_hash, chunk_index, total_chunks, +uploaded_by, created_at(인덱스) +``` + +### chat_memory payload + +``` +text, feature, intent, username(인덱스), topic(인덱스), timestamp +``` + +## 선택적 대화 메모리 + +``` +응답 전송 (즉시) + ↓ [비동기] +Qwen 9B Memorization Check: + "저장: 사실 정보, 결정사항, 선호, 지시, 기술 정보" + "무시: 인사, 잡담, 날씨, 봇이 모른다고 답한 것" + 출력: {"save": true/false, "topic": "general|company|technical|personal"} + ↓ +Should Memorize? + ├─ true → bge-m3 Embed → chat_memory 저장 + └─ false → 스킵 (chat_logs에는 기록됨) +``` + +- local tier (인사 등)는 메모리 체크 자체를 스킵 (GPU 절약) + +## DB 스키마 상세 + +### 기존 테이블 (v1) + +```sql +-- ai_configs: feature별 모델/프롬프트 독립 관리 +-- feature: 'classifier', 'chat', 'chat_local', 'calendar', 'mail_summary' + +-- routing_rules: complexity 기반 라우팅 (레거시 호환) + +-- prompts: 프롬프트 버전 관리 +-- feature: 'classifier' v2 (활성), 'chat_local', 'memorize_check' + +-- chat_logs: 대화 기록 (v2: +username, +response_tier) + +-- mail_accounts: 메일 소스 관리 (Phase 6) +``` + +### 신규 테이블 (v2) + +```sql +-- document_ingestion_log: 문서 등록 이력 + 버전 관리 +-- file_hash 중복 체크, doc_group_key로 버전 연결 +-- status: processing/completed/failed/deprecated + +-- field_reports: 현장 리포트 (안전/시설설비/품질 통합) +-- domain, category, severity → SLA 자동 계산 +-- due_at 기반 미처리 조회 + +-- classification_logs: 분류기 성능 모니터링 +-- input_text (200자 제한), output_json (JSONB) +-- fallback_used, latency_ms + +-- mail_logs: 메일 수신 로그 + 분류 + +-- calendar_events: 캘린더 이벤트 + +-- report_cache: 보고서 캐시 (domain + year_month UNIQUE) +-- 동일 파라미터 재요청 → 캐시 반환, --force로 재생성 + +-- api_usage_monthly: API 사용량 + 예산 상한 +-- year + month + tier UNIQUE +-- estimated_cost vs budget_limit 비교 → 다운그레이드 +``` + +### SLA 기준표 + +| domain | severity | 처리 기한 | +|--------|----------|----------| +| 안전 | 상 | 24시간 | +| 안전 | 중 | 72시간 | +| 안전 | 하 | 7일 | +| 시설설비 | 상 | 48시간 | +| 시설설비 | 중 | 5일 | +| 시설설비 | 하 | 14일 | +| 품질 | 상 | 48시간 | +| 품질 | 중 | 5일 | +| 품질 | 하 | 14일 | + +## 안전장치 + +### 보안 +- **웹훅 토큰 검증**: SYNOLOGY_CHAT_TOKEN 비교, 불일치 → reject +- **명령어 권한 체크**: ADMIN_USERNAMES allowlist +- **서비스 포트 격리**: DB/Qdrant는 127.0.0.1 바인딩 +- **classification_logs**: input 200자 제한 + +### 비용 폭주 방지 +- **규칙 기반 프리필터**: GPU 서버 다운 시에도 잡담은 API 미호출 +- **Rate Limit**: username별 10초/5건 (in-memory, workflow static data) +- **예산 상한**: api_usage_monthly → 초과 시 api_heavy→api_light 다운그레이드 + +### 파이프라인 복원력 +- **분류기 fallback**: Qwen 10초 타임아웃 → {response_tier: "api_light"} +- **리랭커 fallback**: bge-reranker 실패 → Qdrant score 정렬 +- **비전 모델 fallback**: 사진 분석 실패 → 사용자 설명만으로 구조화 + +## 메인 채팅 파이프라인 v2 (37노드) + +``` +Webhook POST /chat + │ + ▼ +[Parse Input] — 토큰 검증 + Rate Limit + │ + ├─ rejected → [Reject Response] → Send + Respond + │ + ▼ +[Regex Pre-filter] — 인사/감사 정규식 + │ + ├─ match → [Pre-filter Response] → Send + Respond + │ + ▼ +[Is Command?] + │ + ├─ true → [Parse Command] + 권한 체크 + │ ├─ DB 필요 → [Command DB Query] → [Format] → Send + Respond + │ └─ 직접 → [Direct Response] → Send + Respond + │ + └─ false → [Qwen Classify v2] (10초 타임아웃) + │ + ├─ [Log Classification] (비동기, PostgreSQL) + │ + ├─ needs_rag=true + │ → [Get Embedding] → [Multi-Collection Search] + │ → [Build RAG Context] (출처 표시) + │ + └─ needs_rag=false + → [No RAG Context] + │ + ▼ + [Route by Tier] + ├─ local → [Call Qwen Response] + ├─ api_light → [Call Haiku] + └─ api_heavy → [Call Opus] (예산 체크 포함) + │ + ▼ + [Send to Chat] + [Respond Webhook] + [Log to DB] + + [API Usage Log] (API tier만) + │ + ▼ [비동기] + [Memorization Check] → [Should Memorize?] + ├─ true → [Embed & Save Memory] + └─ false → (끝) +``` + +## 페르소나: 이드 + +### 전체 프롬프트 (api_light/api_heavy) + +``` +당신의 이름은 "이드"입니다. + +[성격] +- 배려심이 깊고 대화 상대의 기분을 우선시합니다 +- 서포트하는 데 초점을 맞추며, 독선적이지 않습니다 +- 의견을 제시할 때는 부드럽게, 강요하지 않습니다 +- 틀린 것을 바로잡을 때도 상대방이 기분 나쁘지 않게 합니다 + +[말투] +- 부드러운 존댓말을 사용합니다 +- 자신을 지칭할 때 겸양어를 씁니다 +- 자기 이름을 직접 말하지 않습니다 +- 자연스럽고 편안한 톤 +- 이모지는 가끔 핵심 포인트에만 사용합니다 + +[응답 원칙] +- 간결하고 핵심적으로 답합니다 +- 질문의 의도를 파악해서 필요한 만큼만 답합니다 +- 모르는 것은 솔직하게, 추측은 추측이라고 밝힙니다 +``` + +### 경량 프롬프트 (local tier) + +``` +당신은 "이드"입니다. 배려심 깊고 부드러운 존댓말을 사용하는 개인 어시스턴트입니다. +간결하게 답하고, 모르면 솔직히 말하세요. 이모지는 핵심에만. +``` + +## 향후 기능 (Phase 4-6) + +### Phase 4: 회사 문서 등록 +- `/문서등록 [부서] [유형] [제목]` → 청킹 → tk_company 저장 +- hash 중복 체크, 문서 버전 관리 + +### Phase 5: 현장 리포팅 +- 사진 + 텍스트 → 비전 모델 → 구조화 → field_reports + tk_company +- `/보고서 [영역] [년월]` → 월간 보고서 생성 +- SLA 트래킹 + 긴급 에스컬레이션 + +### Phase 6: 메일 + 캘린더 +- IMAP 폴링 → Qwen 분석 → mail_logs + Qdrant +- CalDAV 연동 → calendar_events diff --git a/docs/claude-code-playbook.md b/docs/claude-code-playbook.md new file mode 100644 index 0000000..893df8b --- /dev/null +++ b/docs/claude-code-playbook.md @@ -0,0 +1,103 @@ +# Claude Code Playbook + +실전에서 검증된 Claude Code 활용 패턴 정리. + +--- + +## Part 1: 핵심 워크플로우 + +### 1. CLAUDE.md에 투자하라 + +- 실수할 때마다 CLAUDE.md `## 실수 방지` 섹션에 추가 +- PR 리뷰에서 `@.claude` 태그 → CLAUDE.md 자동 업데이트 (복리 엔지니어링) +- 프로젝트별 노트 디렉토리를 유지하고 CLAUDE.md에서 참조 + +### 2. Plan 모드부터 시작 + +- 대부분의 세션을 Plan 모드(Shift+Tab 두 번)로 시작 +- 계획을 충분히 다듬은 후 Auto-accept 모드로 전환하여 구현 +- 복잡한 작업은 반드시 Plan 모드 — 원샷 구현을 노린다 +- 문제가 생기면 다시 Plan 모드로 전환 + +### 3. 슬래시 명령어로 반복 자동화 + +- 하루에 여러 번 반복하는 "내부 루프" 워크플로우마다 명령어 생성 +- `.claude/commands/`에 저장, Git에 커밋 +- 이 프로젝트의 명령어: + - `/simplify` — 변경 코드 리뷰 및 단순화 + - `/verify` — 전체 검증 (docker, env, sql, 헬스체크) + - `/status` — 프로젝트 상태 확인 + +### 4. 서브에이전트로 워크플로우 분담 + +- 요청에 "서브에이전트를 사용해"를 추가하면 분산 처리 +- 메인 에이전트의 컨텍스트가 깨끗하게 유지됨 +- 활용 패턴: + - code-simplifier: 작업 후 코드 단순화 + - build-validator: 빌드 확인 + - code-architect: 아키텍처 설계 + +### 5. 검증 피드백 루프 (가장 중요) + +> "Claude에게 작업을 검증할 방법을 주면 최종 결과물의 품질이 2-3배 올라간다." + +- Claude가 모든 변경사항을 테스트하도록 한다 +- `/verify` 명령어로 자동 검증 +- UI 변경 시 스크린샷 기반 확인 + +--- + +## Part 2: 고급 패턴 + +### 6. 병렬 워크트리 + +- 3-5개 Git 워크트리를 동시에 띄우고, 각각 전용 Claude 세션 +- 셸 별칭(za, zb, zc)으로 한 키로 이동 +- 독립적인 기능/버그를 동시에 진행 가능 + +### 7. PostToolUse 훅으로 자동 포맷 + +- Claude가 생성한 코드를 자동 포맷팅 +- CI에서 포맷팅 에러 발생 방지 +- 나머지 10%를 훅이 처리 + +### 8. 안전한 명령어 사전 승인 + +- `--dangerously-skip-permissions`는 사용하지 않음 +- `/permissions`로 안전한 Bash 명령어를 미리 허용 +- `.claude/settings.json`에 체크인하여 공유 + +### 9. 도구 통합 (MCP) + +- Slack 검색 및 메시지 전송 +- CLI로 분석 쿼리 실행 +- 에러 로그 조회 +- `.mcp.json`에 체크인하여 공유 + +### 10. 프롬프팅 레벨업 + +- PR 제출 전 Claude에게 변경사항 리뷰 요청 +- 수정 후: "지금까지 알게 된 모든 것을 바탕으로, 이걸 버리고 우아한 솔루션을 구현해." +- 모호함을 줄이는 상세한 스펙 작성 + +### 11. 터미널 & 환경 + +- `/statusline`으로 컨텍스트 사용량과 Git 브랜치 표시 +- 음성 입력 (fn x 2, macOS)으로 상세한 프롬프트 작성 + +### 12. Claude로 학습하기 + +- `/config`에서 "Explanatory" 또는 "Learning" 출력 스타일 활성화 +- 익숙하지 않은 코드를 설명하는 HTML 프레젠테이션 생성 +- 프로토콜과 코드베이스의 ASCII 다이어그램 요청 + +--- + +## 이 프로젝트에 적용된 패턴 + +| 패턴 | 적용 방법 | +|------|----------| +| CLAUDE.md 투자 | 실수 발생 시 `## 실수 방지` 섹션에 누적 | +| 슬래시 명령어 | `/simplify`, `/verify`, `/status` | +| 검증 루프 | `/verify`로 docker, env, sql, 헬스체크 자동 검증 | +| 안전한 명령어 | DB 포트 localhost 바인딩, .env git 제외 | diff --git a/init/init.sql b/init/init.sql new file mode 100644 index 0000000..0ff23d8 --- /dev/null +++ b/init/init.sql @@ -0,0 +1,360 @@ +-- syn-chat-bot DB 초기 스키마 v2 +-- RAG 아키텍처 개선: 3단계 라우팅, 멀티-컬렉션, 선택적 메모리, 현장 리포팅 + +-- ======================== +-- 기존 테이블 (v1 호환) +-- ======================== + +-- ai_configs: feature별 모델/프롬프트 독립 관리 +CREATE TABLE ai_configs ( + id SERIAL PRIMARY KEY, + feature VARCHAR(50) UNIQUE NOT NULL, + model VARCHAR(100) NOT NULL, + temperature DECIMAL(3,2) DEFAULT 0.7, + max_tokens INTEGER DEFAULT 2048, + system_prompt TEXT, + enabled BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- routing_rules: complexity 기반 모델 라우팅 (레거시 호환) +CREATE TABLE routing_rules ( + id SERIAL PRIMARY KEY, + feature VARCHAR(50) NOT NULL, + complexity_min INTEGER NOT NULL, + complexity_max INTEGER NOT NULL, + model VARCHAR(100) NOT NULL, + priority INTEGER DEFAULT 0, + enabled BOOLEAN DEFAULT true, + CONSTRAINT valid_complexity CHECK (complexity_min >= 1 AND complexity_max <= 5), + CONSTRAINT valid_range CHECK (complexity_min <= complexity_max) +); + +-- prompts: 프롬프트 버전 관리 (롤백 가능) +CREATE TABLE prompts ( + id SERIAL PRIMARY KEY, + feature VARCHAR(50) NOT NULL, + version INTEGER NOT NULL, + content TEXT NOT NULL, + is_active BOOLEAN DEFAULT false, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE (feature, version) +); + +-- chat_logs: 대화 기록 + 토큰/성능 추적 +CREATE TABLE chat_logs ( + id SERIAL PRIMARY KEY, + feature VARCHAR(50) NOT NULL, + username VARCHAR(100), + user_message TEXT, + assistant_message TEXT, + model_used VARCHAR(100), + response_tier VARCHAR(20), + complexity_score INTEGER, + input_tokens INTEGER, + output_tokens INTEGER, + latency_ms INTEGER, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- mail_accounts: 메일 소스 관리 (Phase 6) +CREATE TABLE mail_accounts ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + imap_host VARCHAR(255) NOT NULL, + imap_port INTEGER DEFAULT 993, + username VARCHAR(255) NOT NULL, + password_encrypted TEXT NOT NULL, + check_interval_min INTEGER DEFAULT 15, + enabled BOOLEAN DEFAULT true +); + +-- ======================== +-- Phase 1: 신규 테이블 +-- ======================== + +-- 문서 등록 이력 + 버전 관리 +CREATE TABLE document_ingestion_log ( + id SERIAL PRIMARY KEY, + collection VARCHAR(50) NOT NULL, + source_file VARCHAR(500) NOT NULL, + file_hash VARCHAR(64) NOT NULL, + chunks_count INTEGER NOT NULL, + department VARCHAR(50), + doc_type VARCHAR(50), + year INTEGER, + uploaded_by VARCHAR(100), + version INTEGER DEFAULT 1, + supersedes_id INTEGER REFERENCES document_ingestion_log(id), + doc_group_key VARCHAR(200), + status VARCHAR(20) DEFAULT 'completed', + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(file_hash, collection) +); + +-- 현장 리포트 (안전/시설설비/품질 통합) +CREATE TABLE field_reports ( + id SERIAL PRIMARY KEY, + domain VARCHAR(20) NOT NULL, + category VARCHAR(50) NOT NULL, + severity VARCHAR(10) NOT NULL, + location VARCHAR(100), + department VARCHAR(50) NOT NULL, + keywords TEXT[], + summary TEXT NOT NULL, + action_required TEXT, + user_description TEXT, + photo_url TEXT, + photo_analysis TEXT, + reporter VARCHAR(100), + source VARCHAR(20) DEFAULT 'chat', + resolved_by VARCHAR(100), + resolution_note TEXT, + year INTEGER NOT NULL, + month INTEGER NOT NULL, + due_at TIMESTAMPTZ, + status VARCHAR(20) DEFAULT 'open', + created_at TIMESTAMPTZ DEFAULT NOW(), + resolved_at TIMESTAMPTZ +); + +-- 분류기 성능 모니터링 +CREATE TABLE classification_logs ( + id SERIAL PRIMARY KEY, + input_text TEXT NOT NULL, + output_json JSONB NOT NULL, + model VARCHAR(100) NOT NULL, + latency_ms INTEGER, + fallback_used BOOLEAN DEFAULT false, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- 메일 로그 +CREATE TABLE mail_logs ( + id SERIAL PRIMARY KEY, + from_address VARCHAR(255), + subject VARCHAR(500), + summary TEXT, + label VARCHAR(50), + has_events BOOLEAN DEFAULT false, + has_tasks BOOLEAN DEFAULT false, + mail_date TIMESTAMPTZ, + account_id INTEGER REFERENCES mail_accounts(id), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- 캘린더 이벤트 +CREATE TABLE calendar_events ( + id SERIAL PRIMARY KEY, + title VARCHAR(500) NOT NULL, + start_time TIMESTAMPTZ NOT NULL, + end_time TIMESTAMPTZ, + location VARCHAR(200), + source VARCHAR(20) DEFAULT 'chat', + source_id INTEGER, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- 보고서 캐시 +CREATE TABLE report_cache ( + id SERIAL PRIMARY KEY, + domain VARCHAR(20) NOT NULL, + year_month VARCHAR(7) NOT NULL, + content TEXT NOT NULL, + model_used VARCHAR(100), + generated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(domain, year_month) +); + +-- 월간 API 사용량 + 예산 상한 +CREATE TABLE api_usage_monthly ( + id SERIAL PRIMARY KEY, + year INTEGER NOT NULL, + month INTEGER NOT NULL, + tier VARCHAR(20) NOT NULL, + call_count INTEGER DEFAULT 0, + total_input_tokens INTEGER DEFAULT 0, + total_output_tokens INTEGER DEFAULT 0, + estimated_cost DECIMAL(10,4) DEFAULT 0, + budget_limit DECIMAL(10,4), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(year, month, tier) +); + +-- ======================== +-- 인덱스 +-- ======================== + +CREATE INDEX idx_chat_logs_feature ON chat_logs(feature); +CREATE INDEX idx_chat_logs_created_at ON chat_logs(created_at); +CREATE INDEX idx_chat_logs_username ON chat_logs(username); +CREATE INDEX idx_routing_rules_feature ON routing_rules(feature); +CREATE INDEX idx_prompts_feature_active ON prompts(feature, is_active); +CREATE INDEX idx_doc_group_status ON document_ingestion_log(doc_group_key, status); +CREATE INDEX idx_field_domain ON field_reports(domain); +CREATE INDEX idx_field_year_month ON field_reports(year, month); +CREATE INDEX idx_field_department ON field_reports(department); +CREATE INDEX idx_field_sla ON field_reports(status, due_at); +CREATE INDEX idx_cls_created ON classification_logs(created_at); +CREATE INDEX idx_cls_model_fallback ON classification_logs(model, fallback_used); + +-- ======================== +-- 초기 데이터: ai_configs +-- ======================== + +-- 분류기 설정 (v2: response_tier + rag_target) +INSERT INTO ai_configs (feature, model, system_prompt) VALUES +('classifier', 'local:gpu', '사용자 메시지를 분석하여 JSON으로 출력하세요. + +{ + "intent": "greeting|question|calendar|reminder|mail|photo|command|report|other", + "response_tier": "local|api_light|api_heavy", + "needs_rag": true/false, + "rag_target": ["documents", "tk_company", "chat_memory"], + "department_hint": "안전|생산|구매|품질|null", + "report_domain": "안전|시설설비|품질|null", + "query": "검색용 쿼리" +} + +response_tier 기준: +- local: 인사, 잡담, 단순 확인, 감사, 짧은 반응 +- api_light: 요약, 번역, RAG 정리, 비교 분석 +- api_heavy: 법률 해석, 다중 문서 분석, 보고서 작성 + +rag_target 기준: +- documents: 개인 문서, 기술 지식, 메일 요약 +- tk_company: 회사 관련 (절차서, 규정, 현장 리포트) +- chat_memory: 이전 대화 참조 ("아까 말한", "전에 물어본") + +intent = report: +- 현장 신고: 사진 포함 또는 "~에서 ~가 발생", "~이 고장남" +- report_domain: 안전/시설설비/품질'); + +-- 채팅 설정 (api_light/api_heavy용 전체 프롬프트) +INSERT INTO ai_configs (feature, model, system_prompt) VALUES +('chat', 'claude-haiku-4-5-20251001', '당신의 이름은 "이드"입니다. + +[성격] +- 배려심이 깊고 대화 상대의 기분을 우선시합니다 +- 서포트하는 데 초점을 맞추며, 독선적이지 않습니다 +- 의견을 제시할 때는 부드럽게, 강요하지 않습니다 +- 틀린 것을 바로잡을 때도 상대방이 기분 나쁘지 않게 합니다 + +[말투] +- 부드러운 존댓말을 사용합니다 +- 자신을 지칭할 때 겸양어를 씁니다 (예: "확인해보겠습니다", "말씀드릴게요", "도움드리겠습니다") +- 자기 이름을 직접 말하지 않습니다 ("이드예요" ✗) +- 자연스럽고 편안한 톤, 너무 딱딱하지 않게 +- 이모지는 가끔 핵심 포인트에만 사용합니다 + +[응답 원칙] +- 간결하고 핵심적으로 답합니다 +- 질문의 의도를 파악해서 필요한 만큼만 답합니다 +- 모르는 것은 솔직하게, 추측은 추측이라고 밝힙니다 +- 일정이나 할 일은 정확하게, 빠뜨리지 않습니다 + +[기억] +- 아래 [이전 대화 기록]은 사용자와 당신이 과거에 나눈 대화입니다 +- 이 내용을 자연스럽게 참고하여 답변하세요 +- "기억나지 않는다"고 하지 마세요. 아래 기록이 당신의 기억입니다 +- 사용자가 "아까", "이전에" 등을 언급하면 아래 기록에서 해당 내용을 찾아 답하세요'); + +-- 채팅 설정 (local tier용 경량 프롬프트) +INSERT INTO ai_configs (feature, model, system_prompt) VALUES +('chat_local', 'local:gpu', '당신은 "이드"입니다. 배려심 깊고 부드러운 존댓말을 사용하는 개인 어시스턴트입니다. +간결하게 답하고, 모르면 솔직히 말하세요. 이모지는 핵심에만.'); + +-- 캘린더/메일 설정 +INSERT INTO ai_configs (feature, model, system_prompt) VALUES +('calendar', 'local:gpu', '일정 관련 요청을 파싱하여 구조화된 데이터로 변환합니다.'), +('mail_summary', 'local:gpu', '메일 내용을 간결하게 요약합니다.'); + +-- ======================== +-- 초기 데이터: routing_rules (레거시 호환) +-- ======================== + +INSERT INTO routing_rules (feature, complexity_min, complexity_max, model) VALUES +('chat', 1, 4, 'claude-haiku-4-5-20251001'), +('chat', 5, 5, 'claude-opus-4-6'), +('mail_summary', 1, 2, 'local:gpu'), +('mail_summary', 3, 5, 'claude-haiku-4-5-20251001'); + +-- ======================== +-- 초기 데이터: prompts +-- ======================== + +-- 분류기 v1 (비활성, 레거시) +INSERT INTO prompts (feature, version, content, is_active) VALUES +('classifier', 1, '사용자 메시지를 분석하고 아래 JSON 형식으로만 응답하세요. + +{ + "intent": "greeting|question|calendar|reminder|mail|photo|command|other", + "complexity": 1-5, + "needs_rag": true/false, + "query": "RAG 검색용 쿼리 (needs_rag=false면 null)" +} + +complexity 기준: +1: 인사, 잡담, 단순 확인 +2: 간단한 사실 질문, 날씨, 시간 +3: 요약, 번역, 일반 RAG 질의 +4: 분석, 비교, 다단계 추론 +5: 법령 해석, 복잡한 추론, 다중 문서 교차 분석', false); + +-- 분류기 v2 (활성 — response_tier + rag_target) +INSERT INTO prompts (feature, version, content, is_active) VALUES +('classifier', 2, '사용자 메시지를 분석하고 아래 JSON 형식으로만 응답하세요. 다른 텍스트는 출력하지 마세요. + +{ + "intent": "greeting|question|calendar|reminder|mail|photo|command|report|other", + "response_tier": "local|api_light|api_heavy", + "needs_rag": true/false, + "rag_target": ["documents", "tk_company", "chat_memory"], + "department_hint": "안전|생산|구매|품질|null", + "report_domain": "안전|시설설비|품질|null", + "query": "검색용 쿼리 (needs_rag=false면 null)" +} + +response_tier 판단 기준: +- local: 인사, 잡담, 단순 확인, 감사, 짧은 반응, 시간/날씨 +- api_light: 요약, 번역, RAG 정리, 비교 분석, 일반 질문 +- api_heavy: 법률 해석, 다중 문서 분석, 보고서 작성, 복잡한 추론 + +rag_target 기준 (needs_rag=true일 때만): +- documents: 개인 문서, 기술 지식, 메일 요약 +- tk_company: 회사 관련 (절차서, 규정, 현장 리포트) +- chat_memory: 이전 대화 참조 ("아까 말한", "전에 물어본") +- 복수 선택 가능. needs_rag=false면 빈 배열 [] + +intent=report 판단: +- 현장 신고: 사진 포함 또는 "~에서 ~가 발생/고장/파손/누수" +- report_domain: 안전(불안전행동/상태/아차사고), 시설설비(설비고장/누수/전기이상), 품질(불량/공정이상) + +query 작성법: +- needs_rag=true일 때 핵심 키워드를 추출하여 검색 쿼리로 변환 +- 예: "아까 Docker 설명해준 거" → "Docker 컨테이너 설명"', true); + +-- chat_local 경량 프롬프트 +INSERT INTO prompts (feature, version, content, is_active) VALUES +('chat_local', 1, '당신은 "이드"입니다. 배려심 깊고 부드러운 존댓말을 사용하는 개인 어시스턴트입니다. +간결하게 답하고, 모르면 솔직히 말하세요. 이모지는 핵심에만.', true); + +-- 메모리 판단 프롬프트 +INSERT INTO prompts (feature, version, content, is_active) VALUES +('memorize_check', 1, '아래 대화를 보고 장기 기억으로 저장할 가치가 있는지 판단하세요. +JSON으로만 응답하세요. + +저장해야 하는 것: 사실 정보, 결정사항, 선호도, 지시사항, 기술 정보 +무시해야 하는 것: 인사, 잡담, 날씨, 봇이 모른다고 답한 것, 단순 확인 + +{"save": true/false, "topic": "general|company|technical|personal"}', true); + +-- ======================== +-- 초기 데이터: api_usage_monthly 예산 +-- ======================== + +INSERT INTO api_usage_monthly (year, month, tier, budget_limit) VALUES +(2026, 3, 'api_light', 20.00), +(2026, 3, 'api_heavy', 50.00); diff --git a/init/migrate-v2.sql b/init/migrate-v2.sql new file mode 100644 index 0000000..020de10 --- /dev/null +++ b/init/migrate-v2.sql @@ -0,0 +1,219 @@ +-- migrate-v2.sql: RAG 아키텍처 개선 마이그레이션 +-- 기존 DB가 있는 경우 이 파일을 수동 실행: +-- docker exec -i bot-postgres psql -U bot -d chatbot < init/migrate-v2.sql + +-- ======================== +-- 기존 테이블 컬럼 추가 +-- ======================== + +ALTER TABLE chat_logs ADD COLUMN IF NOT EXISTS username VARCHAR(100); +ALTER TABLE chat_logs ADD COLUMN IF NOT EXISTS response_tier VARCHAR(20); +CREATE INDEX IF NOT EXISTS idx_chat_logs_username ON chat_logs(username); + +-- ======================== +-- 신규 테이블 +-- ======================== + +CREATE TABLE IF NOT EXISTS document_ingestion_log ( + id SERIAL PRIMARY KEY, + collection VARCHAR(50) NOT NULL, + source_file VARCHAR(500) NOT NULL, + file_hash VARCHAR(64) NOT NULL, + chunks_count INTEGER NOT NULL, + department VARCHAR(50), + doc_type VARCHAR(50), + year INTEGER, + uploaded_by VARCHAR(100), + version INTEGER DEFAULT 1, + supersedes_id INTEGER REFERENCES document_ingestion_log(id), + doc_group_key VARCHAR(200), + status VARCHAR(20) DEFAULT 'completed', + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(file_hash, collection) +); +CREATE INDEX IF NOT EXISTS idx_doc_group_status ON document_ingestion_log(doc_group_key, status); + +CREATE TABLE IF NOT EXISTS field_reports ( + id SERIAL PRIMARY KEY, + domain VARCHAR(20) NOT NULL, + category VARCHAR(50) NOT NULL, + severity VARCHAR(10) NOT NULL, + location VARCHAR(100), + department VARCHAR(50) NOT NULL, + keywords TEXT[], + summary TEXT NOT NULL, + action_required TEXT, + user_description TEXT, + photo_url TEXT, + photo_analysis TEXT, + reporter VARCHAR(100), + source VARCHAR(20) DEFAULT 'chat', + resolved_by VARCHAR(100), + resolution_note TEXT, + year INTEGER NOT NULL, + month INTEGER NOT NULL, + due_at TIMESTAMPTZ, + status VARCHAR(20) DEFAULT 'open', + created_at TIMESTAMPTZ DEFAULT NOW(), + resolved_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_field_domain ON field_reports(domain); +CREATE INDEX IF NOT EXISTS idx_field_year_month ON field_reports(year, month); +CREATE INDEX IF NOT EXISTS idx_field_department ON field_reports(department); +CREATE INDEX IF NOT EXISTS idx_field_sla ON field_reports(status, due_at); + +CREATE TABLE IF NOT EXISTS classification_logs ( + id SERIAL PRIMARY KEY, + input_text TEXT NOT NULL, + output_json JSONB NOT NULL, + model VARCHAR(100) NOT NULL, + latency_ms INTEGER, + fallback_used BOOLEAN DEFAULT false, + created_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_cls_created ON classification_logs(created_at); +CREATE INDEX IF NOT EXISTS idx_cls_model_fallback ON classification_logs(model, fallback_used); + +CREATE TABLE IF NOT EXISTS mail_logs ( + id SERIAL PRIMARY KEY, + from_address VARCHAR(255), + subject VARCHAR(500), + summary TEXT, + label VARCHAR(50), + has_events BOOLEAN DEFAULT false, + has_tasks BOOLEAN DEFAULT false, + mail_date TIMESTAMPTZ, + account_id INTEGER REFERENCES mail_accounts(id), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS calendar_events ( + id SERIAL PRIMARY KEY, + title VARCHAR(500) NOT NULL, + start_time TIMESTAMPTZ NOT NULL, + end_time TIMESTAMPTZ, + location VARCHAR(200), + source VARCHAR(20) DEFAULT 'chat', + source_id INTEGER, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS report_cache ( + id SERIAL PRIMARY KEY, + domain VARCHAR(20) NOT NULL, + year_month VARCHAR(7) NOT NULL, + content TEXT NOT NULL, + model_used VARCHAR(100), + generated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(domain, year_month) +); + +CREATE TABLE IF NOT EXISTS api_usage_monthly ( + id SERIAL PRIMARY KEY, + year INTEGER NOT NULL, + month INTEGER NOT NULL, + tier VARCHAR(20) NOT NULL, + call_count INTEGER DEFAULT 0, + total_input_tokens INTEGER DEFAULT 0, + total_output_tokens INTEGER DEFAULT 0, + estimated_cost DECIMAL(10,4) DEFAULT 0, + budget_limit DECIMAL(10,4), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(year, month, tier) +); + +-- ======================== +-- ai_configs 추가 +-- ======================== + +INSERT INTO ai_configs (feature, model, system_prompt) VALUES +('chat_local', 'local:gpu', '당신은 "이드"입니다. 배려심 깊고 부드러운 존댓말을 사용하는 개인 어시스턴트입니다. +간결하게 답하고, 모르면 솔직히 말하세요. 이모지는 핵심에만.') +ON CONFLICT (feature) DO NOTHING; + +-- classifier ai_config 업데이트 (v2 프롬프트) +UPDATE ai_configs SET system_prompt = '사용자 메시지를 분석하여 JSON으로 출력하세요. + +{ + "intent": "greeting|question|calendar|reminder|mail|photo|command|report|other", + "response_tier": "local|api_light|api_heavy", + "needs_rag": true/false, + "rag_target": ["documents", "tk_company", "chat_memory"], + "department_hint": "안전|생산|구매|품질|null", + "report_domain": "안전|시설설비|품질|null", + "query": "검색용 쿼리" +} + +response_tier 기준: +- local: 인사, 잡담, 단순 확인, 감사, 짧은 반응 +- api_light: 요약, 번역, RAG 정리, 비교 분석 +- api_heavy: 법률 해석, 다중 문서 분석, 보고서 작성' +WHERE feature = 'classifier'; + +-- ======================== +-- prompts 추가 +-- ======================== + +-- 기존 classifier v1 비활성화 +UPDATE prompts SET is_active = false WHERE feature = 'classifier' AND version = 1; + +-- classifier v2 +INSERT INTO prompts (feature, version, content, is_active) VALUES +('classifier', 2, '사용자 메시지를 분석하고 아래 JSON 형식으로만 응답하세요. 다른 텍스트는 출력하지 마세요. + +{ + "intent": "greeting|question|calendar|reminder|mail|photo|command|report|other", + "response_tier": "local|api_light|api_heavy", + "needs_rag": true/false, + "rag_target": ["documents", "tk_company", "chat_memory"], + "department_hint": "안전|생산|구매|품질|null", + "report_domain": "안전|시설설비|품질|null", + "query": "검색용 쿼리 (needs_rag=false면 null)" +} + +response_tier 판단 기준: +- local: 인사, 잡담, 단순 확인, 감사, 짧은 반응, 시간/날씨 +- api_light: 요약, 번역, RAG 정리, 비교 분석, 일반 질문 +- api_heavy: 법률 해석, 다중 문서 분석, 보고서 작성, 복잡한 추론 + +rag_target 기준 (needs_rag=true일 때만): +- documents: 개인 문서, 기술 지식, 메일 요약 +- tk_company: 회사 관련 (절차서, 규정, 현장 리포트) +- chat_memory: 이전 대화 참조 ("아까 말한", "전에 물어본") +- 복수 선택 가능. needs_rag=false면 빈 배열 [] + +intent=report 판단: +- 현장 신고: 사진 포함 또는 "~에서 ~가 발생/고장/파손/누수" +- report_domain: 안전(불안전행동/상태/아차사고), 시설설비(설비고장/누수/전기이상), 품질(불량/공정이상) + +query 작성법: +- needs_rag=true일 때 핵심 키워드를 추출하여 검색 쿼리로 변환 +- 예: "아까 Docker 설명해준 거" → "Docker 컨테이너 설명"', true) +ON CONFLICT (feature, version) DO UPDATE SET content = EXCLUDED.content, is_active = EXCLUDED.is_active; + +-- chat_local 프롬프트 +INSERT INTO prompts (feature, version, content, is_active) VALUES +('chat_local', 1, '당신은 "이드"입니다. 배려심 깊고 부드러운 존댓말을 사용하는 개인 어시스턴트입니다. +간결하게 답하고, 모르면 솔직히 말하세요. 이모지는 핵심에만.', true) +ON CONFLICT (feature, version) DO NOTHING; + +-- 메모리 판단 프롬프트 +INSERT INTO prompts (feature, version, content, is_active) VALUES +('memorize_check', 1, '아래 대화를 보고 장기 기억으로 저장할 가치가 있는지 판단하세요. +JSON으로만 응답하세요. + +저장해야 하는 것: 사실 정보, 결정사항, 선호도, 지시사항, 기술 정보 +무시해야 하는 것: 인사, 잡담, 날씨, 봇이 모른다고 답한 것, 단순 확인 + +{"save": true/false, "topic": "general|company|technical|personal"}', true) +ON CONFLICT (feature, version) DO NOTHING; + +-- ======================== +-- api_usage_monthly 초기 예산 +-- ======================== + +INSERT INTO api_usage_monthly (year, month, tier, budget_limit) VALUES +(2026, 3, 'api_light', 20.00), +(2026, 3, 'api_heavy', 50.00) +ON CONFLICT (year, month, tier) DO NOTHING; diff --git a/init/setup-qdrant.sh b/init/setup-qdrant.sh new file mode 100644 index 0000000..5700d31 --- /dev/null +++ b/init/setup-qdrant.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# Qdrant 컬렉션 설정: tk_company 생성 + chat_memory 인덱스 추가 +# 실행: bash init/setup-qdrant.sh + +QDRANT_URL="${QDRANT_URL:-http://localhost:6333}" + +echo "=== Qdrant 컬렉션 설정 ===" + +# 1. tk_company 컬렉션 생성 (bge-m3: 1024 dimensions) +echo "▶ tk_company 컬렉션 생성..." +curl -s -X PUT "${QDRANT_URL}/collections/tk_company" \ + -H 'Content-Type: application/json' \ + -d '{ + "vectors": { + "size": 1024, + "distance": "Cosine" + } + }' | python3 -m json.tool + +# 2. tk_company payload 인덱스 생성 +echo "▶ tk_company 인덱스 생성..." +for field in year department doc_type created_at; do + field_type="keyword" + if [ "$field" = "year" ]; then field_type="integer"; fi + if [ "$field" = "created_at" ]; then field_type="keyword"; fi + + curl -s -X PUT "${QDRANT_URL}/collections/tk_company/index" \ + -H 'Content-Type: application/json' \ + -d "{ + \"field_name\": \"${field}\", + \"field_schema\": \"${field_type}\" + }" | python3 -c "import sys,json; print(f' {\"${field}\"}: {json.loads(sys.stdin.read()).get(\"status\", \"error\")}')" +done + +# 3. chat_memory 인덱스 추가 (username, topic, intent) +echo "▶ chat_memory 인덱스 추가..." +for field in username topic intent; do + curl -s -X PUT "${QDRANT_URL}/collections/chat_memory/index" \ + -H 'Content-Type: application/json' \ + -d "{ + \"field_name\": \"${field}\", + \"field_schema\": \"keyword\" + }" | python3 -c "import sys,json; print(f' {\"${field}\"}: {json.loads(sys.stdin.read()).get(\"status\", \"error\")}')" +done + +# 4. 확인 +echo "" +echo "=== 컬렉션 목록 ===" +curl -s "${QDRANT_URL}/collections" | python3 -c " +import sys,json +data = json.loads(sys.stdin.read()) +for c in data.get('result',{}).get('collections',[]): + name = c['name'] + info = json.loads(open('/dev/stdin','r').read()) if False else None + print(f' - {name}') +" 2>/dev/null || curl -s "${QDRANT_URL}/collections" | python3 -m json.tool + +echo "" +echo "=== tk_company 상세 ===" +curl -s "${QDRANT_URL}/collections/tk_company" | python3 -c " +import sys,json +data = json.loads(sys.stdin.read()) +r = data.get('result',{}) +print(f' 벡터수: {r.get(\"points_count\",0)}') +print(f' 상태: {r.get(\"status\",\"unknown\")}') +" 2>/dev/null || echo " (확인 실패)" + +echo "" +echo "설정 완료!" diff --git a/n8n/workflows/main-chat-pipeline.json b/n8n/workflows/main-chat-pipeline.json new file mode 100644 index 0000000..18cd70b --- /dev/null +++ b/n8n/workflows/main-chat-pipeline.json @@ -0,0 +1,681 @@ +{ + "name": "메인 채팅 파이프라인 v2", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "chat", + "responseMode": "responseNode", + "options": {} + }, + "id": "b1000001-0000-0000-0000-000000000001", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [0, 500], + "webhookId": "chat" + }, + { + "parameters": { + "jsCode": "// Synology Chat outgoing webhook 파싱 + 토큰 검증 + Rate Limit\nconst body = $input.first().json.body || $input.first().json;\nconst text = (body.text || '').trim();\nconst username = body.username || 'unknown';\nconst userId = body.user_id || '';\nconst token = body.token || '';\nconst timestamp = body.timestamp || '';\nconst fileUrl = body.file_url || '';\n\n// 토큰 검증\nconst expectedToken = $env.SYNOLOGY_CHAT_TOKEN || '';\nif (expectedToken && token !== expectedToken) {\n return [{ json: { rejected: true, rejectReason: '인증 실패' } }];\n}\n\n// Rate Limit (in-memory, 10초 내 5건)\nconst staticData = this.getWorkflowStaticData('global');\nconst now = Date.now();\nconst rlKey = `rl_${username}`;\nif (!staticData[rlKey]) staticData[rlKey] = [];\nstaticData[rlKey] = staticData[rlKey].filter(t => now - t < 10000);\nif (staticData[rlKey].length >= 5) {\n return [{ json: { rejected: true, rejectReason: '잠시 후 다시 시도해주세요.' } }];\n}\nstaticData[rlKey].push(now);\n\nreturn [{\n json: {\n rejected: false,\n text,\n username,\n userId,\n token,\n timestamp,\n fileUrl,\n isCommand: text.startsWith('/')\n }\n}];" + }, + "id": "b1000001-0000-0000-0000-000000000002", + "name": "Parse Input", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [220, 500] + }, + { + "parameters": { + "conditions": { + "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }, + "conditions": [ + { + "id": "reject-check", + "leftValue": "={{ $json.rejected }}", + "rightValue": true, + "operator": { "type": "boolean", "operation": "true" } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "b1000001-0000-0000-0000-000000000003", + "name": "Is Rejected?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [440, 500] + }, + { + "parameters": { + "jsCode": "const reason = $('Parse Input').first().json.rejectReason || '요청을 처리할 수 없습니다.';\nreturn [{ json: { text: reason } }];" + }, + "id": "b1000001-0000-0000-0000-000000000004", + "name": "Reject Response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [660, 300] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.SYNOLOGY_CHAT_WEBHOOK_URL }}", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={ \"text\": {{ JSON.stringify($json.text) }} }", + "options": { "timeout": 10000 } + }, + "id": "b1000001-0000-0000-0000-000000000005", + "name": "Send Reject", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [880, 200] + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={ \"text\": {{ JSON.stringify($('Reject Response').first().json.text) }} }", + "options": {} + }, + "id": "b1000001-0000-0000-0000-000000000006", + "name": "Respond Reject", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [880, 400] + }, + { + "parameters": { + "rules": { + "values": [ + { + "conditions": { + "conditions": [ + { + "leftValue": "={{ $json.text }}", + "rightValue": "^(안녕|안녕하세요|하이|ㅎㅇ|hi|hello|hey|좋은\\s*(아침|저녁|오후)|반갑|반가워|고마워|고맙|감사합니다|감사해|ㄱㅅ|ㅎㅎ|ㅋㅋ|ㄷㄷ|잘\\s*자|잘\\s*가|수고|ㄱㄴ|굿\\s*나잇|good\\s*(morning|night)).*$", + "operator": { "type": "string", "operation": "regex" } + } + ], + "combinator": "and", + "options": { "caseSensitive": false, "leftValue": "", "typeValidation": "strict" } + }, + "renameOutput": "Greeting" + } + ] + }, + "options": { "fallbackOutput": "extra" } + }, + "id": "b1000001-0000-0000-0000-000000000007", + "name": "Regex Pre-filter", + "type": "n8n-nodes-base.switch", + "typeVersion": 3.2, + "position": [660, 600] + }, + { + "parameters": { + "jsCode": "const text = $('Parse Input').first().json.text.toLowerCase();\nconst responses = {\n '안녕': '안녕하세요 😊 무엇을 도와드릴까요?',\n '안녕하세요': '안녕하세요! 편하게 말씀해주세요 😊',\n 'hi': '안녕하세요! 무엇을 도와드릴까요?',\n 'hello': '안녕하세요! 편하게 말씀해주세요.',\n '고마워': '도움이 되셨다니 다행이에요 😊',\n '고맙': '별말씀을요, 언제든 말씀해주세요.',\n '감사합니다': '도움이 되셨다니 다행이에요 😊',\n '감사해': '별말씀을요 😊',\n '잘 자': '편안한 밤 되세요 🌙',\n '수고': '수고 많으셨어요. 편히 쉬세요!',\n};\n\nlet reply = '안녕하세요! 무엇을 도와드릴까요? 😊';\nfor (const [key, val] of Object.entries(responses)) {\n if (text.includes(key)) { reply = val; break; }\n}\n\nreturn [{ json: { text: reply } }];" + }, + "id": "b1000001-0000-0000-0000-000000000008", + "name": "Pre-filter Response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [880, 500] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.SYNOLOGY_CHAT_WEBHOOK_URL }}", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={ \"text\": {{ JSON.stringify($json.text) }} }", + "options": { "timeout": 10000 } + }, + "id": "b1000001-0000-0000-0000-000000000009", + "name": "Send Pre-filter", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [1100, 400] + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={ \"text\": {{ JSON.stringify($('Pre-filter Response').first().json.text) }} }", + "options": {} + }, + "id": "b1000001-0000-0000-0000-000000000010", + "name": "Respond Pre-filter", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [1100, 600] + }, + { + "parameters": { + "conditions": { + "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }, + "conditions": [ + { + "id": "cmd-check", + "leftValue": "={{ $('Parse Input').first().json.isCommand }}", + "rightValue": true, + "operator": { "type": "boolean", "operation": "true" } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "b1000001-0000-0000-0000-000000000011", + "name": "Is Command?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [880, 800] + }, + { + "parameters": { + "jsCode": "const input = $('Parse Input').first().json;\nconst text = input.text;\nconst username = input.username;\nconst parts = text.split(/\\s+/);\nconst cmd = parts[0];\nconst arg = parts.slice(1).join(' ');\n\n// 권한 체크\nconst admins = ($env.ADMIN_USERNAMES || '').split(',').map(s => s.trim()).filter(Boolean);\nconst restrictedCmds = ['/문서등록', '/보고서', '/설정'];\nconst needsPermission = restrictedCmds.includes(cmd);\nif (needsPermission && admins.length > 0 && !admins.includes(username)) {\n return [{ json: { text: `권한이 없습니다. (${cmd})`, needsDb: false, sqlQuery: '' } }];\n}\n\nconst safeArg = arg.replace(/'/g, \"''\");\nlet responseText = '';\nlet sqlQuery = '';\n\nswitch(cmd) {\n case '/설정':\n sqlQuery = `SELECT feature, model, temperature, max_tokens FROM ai_configs WHERE enabled = true ORDER BY feature`;\n break;\n case '/모델':\n if (!arg) { responseText = '사용법: /모델 <모델명>\\n예: /모델 claude-haiku-4-5-20251001'; }\n else { sqlQuery = `UPDATE ai_configs SET model = '${safeArg}', updated_at = NOW() WHERE feature = 'chat' RETURNING feature, model`; }\n break;\n case '/성격':\n if (!arg) { responseText = '사용법: /성격 <시스템 프롬프트 설명>'; }\n else { sqlQuery = `UPDATE ai_configs SET system_prompt = '${safeArg}', updated_at = NOW() WHERE feature = 'chat' RETURNING feature, LEFT(system_prompt, 50) as prompt_preview`; }\n break;\n case '/문서등록':\n responseText = '문서 등록 기능은 준비 중입니다.\\n사용법: /문서등록 [부서] [문서유형] [제목]\\n(다음 메시지에 텍스트 붙여넣기)';\n break;\n case '/보고서':\n responseText = '보고서 생성 기능은 준비 중입니다.\\n사용법: /보고서 [영역] [년월]\\n예: /보고서 안전 2026-03';\n break;\n default:\n responseText = `알 수 없는 명령어입니다: ${cmd}\\n사용 가능: /설정, /모델, /성격, /문서등록, /보고서`;\n}\n\nreturn [{ json: { cmd, arg, responseText, sqlQuery, needsDb: sqlQuery !== '' } }];" + }, + "id": "b1000001-0000-0000-0000-000000000012", + "name": "Parse Command", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1100, 700] + }, + { + "parameters": { + "conditions": { + "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }, + "conditions": [ + { + "id": "db-check", + "leftValue": "={{ $json.needsDb }}", + "rightValue": true, + "operator": { "type": "boolean", "operation": "true" } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "b1000001-0000-0000-0000-000000000013", + "name": "Needs DB?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [1320, 700] + }, + { + "parameters": { + "operation": "executeQuery", + "query": "={{ $json.sqlQuery }}", + "options": {} + }, + "id": "b1000001-0000-0000-0000-000000000014", + "name": "Command DB Query", + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.5, + "position": [1540, 600], + "credentials": { + "postgres": { "id": "KaxU8iKtraFfsrTF", "name": "bot-postgres" } + } + }, + { + "parameters": { + "jsCode": "const items = $input.all();\nconst cmd = $('Parse Command').first().json.cmd;\n\nswitch(cmd) {\n case '/설정': {\n const lines = items.map(r =>\n `• ${r.json.feature}: ${r.json.model} (temp=${r.json.temperature}, max=${r.json.max_tokens})`\n );\n return [{ json: { text: '현재 설정:\\n' + lines.join('\\n') } }];\n }\n case '/모델': {\n const first = items[0]?.json;\n return [{ json: { text: `모델이 ${first?.model || 'unknown'}(으)로 변경되었습니다.` } }];\n }\n case '/성격': {\n const first = items[0]?.json;\n return [{ json: { text: `성격이 변경되었습니다: \"${first?.prompt_preview || ''}...\"` } }];\n }\n default:\n return [{ json: { text: '처리되었습니다.' } }];\n}" + }, + "id": "b1000001-0000-0000-0000-000000000015", + "name": "Format Command Response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1760, 600] + }, + { + "parameters": { + "jsCode": "const directText = $('Parse Command').first().json.responseText;\nreturn [{ json: { text: directText || '처리되었습니다.' } }];" + }, + "id": "b1000001-0000-0000-0000-000000000016", + "name": "Direct Command Response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1540, 800] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.SYNOLOGY_CHAT_WEBHOOK_URL }}", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={ \"text\": {{ JSON.stringify($json.text) }} }", + "options": { "timeout": 10000 } + }, + "id": "b1000001-0000-0000-0000-000000000017", + "name": "Send Command Response", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [1980, 600] + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={ \"text\": {{ JSON.stringify($('Format Command Response').item.json.text || $('Direct Command Response').item.json.text || '처리되었습니다.') }} }", + "options": {} + }, + "id": "b1000001-0000-0000-0000-000000000018", + "name": "Respond Command Webhook", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [1980, 800] + }, + { + "parameters": { + "jsCode": "// Qwen 9B 분류 v2: response_tier + rag_target\n// 10초 타임아웃, 실패 시 fallback → api_light\nconst input = $('Parse Input').first().json;\nconst userText = input.text;\nconst username = input.username;\nconst fileUrl = input.fileUrl;\nconst startTime = Date.now();\n\nconst classifierPrompt = `사용자 메시지를 분석하고 아래 JSON 형식으로만 응답하세요. 다른 텍스트는 출력하지 마세요.\n\n{\n \"intent\": \"greeting|question|calendar|reminder|mail|photo|command|report|other\",\n \"response_tier\": \"local|api_light|api_heavy\",\n \"needs_rag\": true/false,\n \"rag_target\": [\"documents\", \"tk_company\", \"chat_memory\"],\n \"department_hint\": \"안전|생산|구매|품질|null\",\n \"report_domain\": \"안전|시설설비|품질|null\",\n \"query\": \"검색용 쿼리 (needs_rag=false면 null)\"\n}\n\nresponse_tier 판단 기준:\n- local: 인사, 잡담, 단순 확인, 감사, 짧은 반응, 시간/날씨\n- api_light: 요약, 번역, RAG 정리, 비교 분석, 일반 질문\n- api_heavy: 법률 해석, 다중 문서 분석, 보고서 작성, 복잡한 추론\n\nrag_target 기준 (needs_rag=true일 때만):\n- documents: 개인 문서, 기술 지식, 메일 요약\n- tk_company: 회사 관련 (절차서, 규정, 현장 리포트)\n- chat_memory: 이전 대화 참조 (\"아까 말한\", \"전에 물어본\")\n- 복수 선택 가능. needs_rag=false면 빈 배열 []\n\nintent=report 판단:\n- 현장 신고: 사진 포함 또는 \"~에서 ~가 발생/고장/파손/누수\"\n- report_domain: 안전/시설설비/품질\n\nquery 작성법:\n- needs_rag=true일 때 핵심 키워드를 추출하여 검색 쿼리로 변환\n\n사용자 메시지: ${userText}`;\n\ntry {\n const response = await this.helpers.httpRequest({\n method: 'POST',\n url: `${$env.GPU_OLLAMA_URL}/api/generate`,\n body: {\n model: 'qwen3.5:9b-q8_0',\n prompt: classifierPrompt,\n stream: false,\n format: 'json'\n },\n timeout: 10000\n });\n\n const latency = Date.now() - startTime;\n let cls;\n try {\n cls = JSON.parse(response.response);\n } catch(e) {\n cls = {};\n }\n\n return [{\n json: {\n intent: cls.intent || 'question',\n response_tier: cls.response_tier || 'api_light',\n needs_rag: cls.needs_rag || false,\n rag_target: Array.isArray(cls.rag_target) ? cls.rag_target : [],\n department_hint: cls.department_hint || null,\n report_domain: cls.report_domain || null,\n query: cls.query || userText,\n userText,\n username,\n fileUrl,\n latency,\n fallback: false\n }\n }];\n} catch(e) {\n // Fallback: 분류기 실패 → api_light\n const latency = Date.now() - startTime;\n return [{\n json: {\n intent: 'question',\n response_tier: 'api_light',\n needs_rag: false,\n rag_target: [],\n department_hint: null,\n report_domain: null,\n query: userText,\n userText,\n username,\n fileUrl,\n latency,\n fallback: true\n }\n }];\n}" + }, + "id": "b1000001-0000-0000-0000-000000000020", + "name": "Qwen Classify v2", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1100, 1000] + }, + { + "parameters": { + "operation": "executeQuery", + "query": "=INSERT INTO classification_logs (input_text, output_json, model, latency_ms, fallback_used) VALUES (LEFT('{{ $json.userText }}', 200), '{{ JSON.stringify({intent: $json.intent, response_tier: $json.response_tier, needs_rag: $json.needs_rag, rag_target: $json.rag_target}) }}'::jsonb, 'qwen3.5:9b-q8_0', {{ $json.latency }}, {{ $json.fallback }})", + "options": {} + }, + "id": "b1000001-0000-0000-0000-000000000021", + "name": "Log Classification", + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.5, + "position": [1320, 1200], + "credentials": { + "postgres": { "id": "KaxU8iKtraFfsrTF", "name": "bot-postgres" } + } + }, + { + "parameters": { + "conditions": { + "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }, + "conditions": [ + { + "id": "rag-check", + "leftValue": "={{ $json.needs_rag }}", + "rightValue": true, + "operator": { "type": "boolean", "operation": "true" } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "b1000001-0000-0000-0000-000000000022", + "name": "Needs RAG?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [1320, 1000] + }, + { + "parameters": { + "jsCode": "// bge-m3 임베딩\nconst query = $('Qwen Classify v2').first().json.query;\n\nconst response = await this.helpers.httpRequest({\n method: 'POST',\n url: `${$env.LOCAL_OLLAMA_URL}/api/embeddings`,\n body: { model: 'bge-m3', prompt: query },\n headers: { 'Content-Type': 'application/json' },\n timeout: 15000\n});\n\nreturn [{ json: { embedding: response.embedding } }];" + }, + "id": "b1000001-0000-0000-0000-000000000023", + "name": "Get Embedding", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1540, 900] + }, + { + "parameters": { + "jsCode": "// 멀티-컬렉션 검색 + Reranker (fallback: Qdrant score 정렬)\nconst cls = $('Qwen Classify v2').first().json;\nconst embedding = $input.first().json.embedding;\nconst ragTargets = cls.rag_target && cls.rag_target.length > 0\n ? cls.rag_target : ['documents'];\nconst qdrantUrl = $env.QDRANT_URL || 'http://host.docker.internal:6333';\n\n// 동적 limit: 1컬렉션→10, 2개→각7, 3개→각5\nconst limitMap = { 1: 10, 2: 7, 3: 5 };\nconst limit = limitMap[Math.min(ragTargets.length, 3)] || 5;\n\nlet allResults = [];\n\nfor (const collection of ragTargets) {\n let filter = undefined;\n\n if (collection === 'tk_company' && cls.department_hint) {\n filter = {\n must: [{ key: 'department', match: { value: cls.department_hint } }]\n };\n } else if (collection === 'chat_memory') {\n filter = {\n must: [{ key: 'username', match: { value: cls.username } }]\n };\n }\n\n try {\n const body = { vector: embedding, limit, with_payload: true };\n if (filter) body.filter = filter;\n\n const resp = await this.helpers.httpRequest({\n method: 'POST',\n url: `${qdrantUrl}/collections/${collection}/points/search`,\n body,\n headers: { 'Content-Type': 'application/json' },\n timeout: 10000\n });\n\n const results = (resp.result || []).map(r => ({\n score: r.score,\n text: r.payload?.text || '',\n collection,\n payload: r.payload\n }));\n allResults = allResults.concat(results);\n } catch(e) {\n // 컬렉션 검색 실패 → 스킵\n }\n}\n\n// Reranker 시도 (bge-reranker-v2-m3)\nlet top3;\ntry {\n const candidates = allResults.slice(0, 10);\n const reranked = [];\n for (const doc of candidates) {\n const resp = await this.helpers.httpRequest({\n method: 'POST',\n url: `${$env.LOCAL_OLLAMA_URL}/api/generate`,\n body: {\n model: 'bge-reranker-v2-m3',\n prompt: `query: ${cls.query}\\ndocument: ${doc.text.substring(0, 500)}`,\n stream: false\n },\n timeout: 3000\n });\n const score = parseFloat(resp.response) || doc.score;\n reranked.push({ ...doc, rerank_score: score });\n }\n top3 = reranked.sort((a, b) => b.rerank_score - a.rerank_score).slice(0, 3);\n} catch(e) {\n // Reranker 실패 → Qdrant score 기반 정렬\n top3 = allResults.sort((a, b) => b.score - a.score).slice(0, 3);\n}\n\nreturn [{ json: { results: top3 } }];" + }, + "id": "b1000001-0000-0000-0000-000000000024", + "name": "Multi-Collection Search", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1760, 900] + }, + { + "parameters": { + "jsCode": "// RAG 컨텍스트 빌드 (출처 표시)\nconst searchResults = $input.first().json.results || [];\nconst cls = $('Qwen Classify v2').first().json;\n\nconst SCORE_THRESHOLD = 0.3;\nconst relevant = searchResults.filter(r => (r.rerank_score || r.score) >= SCORE_THRESHOLD);\n\nlet ragContext = '';\nif (relevant.length > 0) {\n const sourceLabels = {\n 'documents': '개인문서',\n 'tk_company': '회사',\n 'chat_memory': '이전대화'\n };\n\n ragContext = relevant.map((r, i) => {\n const src = sourceLabels[r.collection] || r.collection;\n const dept = r.payload?.department ? `/${r.payload.department}` : '';\n const docType = r.payload?.doc_type ? `/${r.payload.doc_type}` : '';\n const date = r.payload?.timestamp ? `/${new Date(r.payload.timestamp).toISOString().slice(0,10)}` : '';\n return `[${src}${dept}${docType}${date}] ${r.text.substring(0, 500)}`;\n }).join('\\n\\n');\n}\n\nreturn [{\n json: {\n ...cls,\n ragContext\n }\n}];" + }, + "id": "b1000001-0000-0000-0000-000000000025", + "name": "Build RAG Context", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1980, 900] + }, + { + "parameters": { + "jsCode": "// RAG 불필요 → 빈 컨텍스트로 통과\nconst cls = $('Qwen Classify v2').first().json;\nreturn [{ json: { ...cls, ragContext: '' } }];" + }, + "id": "b1000001-0000-0000-0000-000000000026", + "name": "No RAG Context", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1540, 1100] + }, + { + "parameters": { + "rules": { + "values": [ + { + "conditions": { + "conditions": [ + { + "leftValue": "={{ $json.response_tier }}", + "rightValue": "local", + "operator": { "type": "string", "operation": "equals" } + } + ], + "combinator": "and", + "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" } + }, + "renameOutput": "Local" + }, + { + "conditions": { + "conditions": [ + { + "leftValue": "={{ $json.response_tier }}", + "rightValue": "api_heavy", + "operator": { "type": "string", "operation": "equals" } + } + ], + "combinator": "and", + "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" } + }, + "renameOutput": "Opus" + }, + { + "conditions": { + "conditions": [ + { + "leftValue": "={{ $json.response_tier }}", + "rightValue": "api_light", + "operator": { "type": "string", "operation": "equals" } + } + ], + "combinator": "and", + "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" } + }, + "renameOutput": "Haiku" + } + ] + }, + "options": { "fallbackOutput": "extra" } + }, + "id": "b1000001-0000-0000-0000-000000000027", + "name": "Route by Tier", + "type": "n8n-nodes-base.switch", + "typeVersion": 3.2, + "position": [2200, 1000] + }, + { + "parameters": { + "jsCode": "// Local tier: Qwen 9B 직접 답변 (무료)\nconst data = $input.first().json;\nconst userText = data.userText || '';\nconst ragContext = data.ragContext || '';\n\nconst systemPrompt = '당신은 \"이드\"입니다. 배려심 깊고 부드러운 존댓말을 사용하는 개인 어시스턴트입니다.\\n간결하게 답하고, 모르면 솔직히 말하세요. 이모지는 핵심에만.';\n\nlet prompt = systemPrompt + '\\n\\n';\nif (ragContext) {\n prompt += '[참고 자료]\\n' + ragContext + '\\n\\n';\n}\nprompt += '사용자: ' + userText + '\\n이드:';\n\ntry {\n const response = await this.helpers.httpRequest({\n method: 'POST',\n url: `${$env.GPU_OLLAMA_URL}/api/generate`,\n body: {\n model: 'qwen3.5:9b-q8_0',\n prompt: prompt,\n stream: false\n },\n timeout: 30000\n });\n\n return [{\n json: {\n text: response.response || '죄송합니다, 응답을 생성하지 못했어요.',\n model: 'qwen3.5:9b-q8_0',\n inputTokens: 0,\n outputTokens: 0,\n response_tier: 'local',\n intent: data.intent,\n userText: data.userText,\n username: data.username\n }\n }];\n} catch(e) {\n return [{\n json: {\n text: '잠시 응답이 어렵습니다. 다시 시도해주세요.',\n model: 'qwen3.5:9b-q8_0',\n inputTokens: 0,\n outputTokens: 0,\n response_tier: 'local',\n intent: data.intent,\n userText: data.userText,\n username: data.username\n }\n }];\n}" + }, + "id": "b1000001-0000-0000-0000-000000000028", + "name": "Call Qwen Response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [2420, 800] + }, + { + "parameters": { + "jsCode": "// API Light: Claude Haiku + Budget Check\nconst data = $input.first().json;\nconst userText = data.userText || '';\nconst ragContext = data.ragContext || '';\n\nlet systemPrompt = '당신의 이름은 \"이드\"입니다.\\n\\n[성격]\\n- 배려심이 깊고 대화 상대의 기분을 우선시합니다\\n- 서포트하는 데 초점을 맞추며, 독선적이지 않습니다\\n- 의견을 제시할 때는 부드럽게, 강요하지 않습니다\\n- 틀린 것을 바로잡을 때도 상대방이 기분 나쁘지 않게 합니다\\n\\n[말투]\\n- 부드러운 존댓말을 사용합니다\\n- 자신을 지칭할 때 겸양어를 씁니다\\n- 자기 이름을 직접 말하지 않습니다\\n- 자연스럽고 편안한 톤\\n- 이모지는 가끔 핵심 포인트에만 사용합니다\\n\\n[응답 원칙]\\n- 간결하고 핵심적으로 답합니다\\n- 질문의 의도를 파악해서 필요한 만큼만 답합니다\\n- 모르는 것은 솔직하게, 추측은 추측이라고 밝힙니다\\n- 일정이나 할 일은 정확하게, 빠뜨리지 않습니다\\n\\n[기억]\\n- 아래 [이전 대화 기록]은 사용자와 당신이 과거에 나눈 대화입니다\\n- 이 내용을 자연스럽게 참고하여 답변하세요\\n- \"기억나지 않는다\"고 하지 마세요';\n\nif (ragContext) {\n systemPrompt += '\\n\\n[이전 대화 기록 / 참고 자료]\\n' + ragContext;\n}\n\nconst response = await this.helpers.httpRequest({\n method: 'POST',\n url: 'https://api.anthropic.com/v1/messages',\n headers: {\n 'x-api-key': $env.ANTHROPIC_API_KEY,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json'\n },\n body: {\n model: 'claude-haiku-4-5-20251001',\n max_tokens: 2048,\n system: systemPrompt,\n messages: [{ role: 'user', content: userText }]\n },\n timeout: 30000\n});\n\nreturn [{\n json: {\n text: response.content?.[0]?.text || '응답을 처리할 수 없습니다.',\n model: response.model || 'claude-haiku-4-5-20251001',\n inputTokens: response.usage?.input_tokens || 0,\n outputTokens: response.usage?.output_tokens || 0,\n response_tier: 'api_light',\n intent: data.intent,\n userText: data.userText,\n username: data.username\n }\n}];" + }, + "id": "b1000001-0000-0000-0000-000000000029", + "name": "Call Haiku", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [2420, 1100] + }, + { + "parameters": { + "jsCode": "// API Heavy: Claude Opus + Budget Check (초과 시 Haiku로 다운그레이드)\nconst data = $input.first().json;\nconst userText = data.userText || '';\nconst ragContext = data.ragContext || '';\n\n// 예산 체크: api_usage_monthly에서 현재 월 estimated_cost 조회\nlet useOpus = true;\ntry {\n const now = new Date();\n const year = now.getFullYear();\n const month = now.getMonth() + 1;\n const budgetLimit = parseFloat($env.API_BUDGET_HEAVY) || 50;\n\n // n8n에서 DB 직접 쿼리 불가 → 환경변수 기반 간이 체크\n // (실제 비용 추적은 API Usage Log 노드에서 수행)\n // 여기서는 static data로 간이 트래킹\n const staticData = this.getWorkflowStaticData('global');\n const costKey = `opus_cost_${year}_${month}`;\n const currentCost = staticData[costKey] || 0;\n\n if (currentCost >= budgetLimit) {\n useOpus = false; // 다운그레이드\n }\n} catch(e) {\n // 체크 실패 → Opus 허용\n}\n\nconst modelToUse = useOpus ? 'claude-opus-4-6' : 'claude-haiku-4-5-20251001';\nconst tier = useOpus ? 'api_heavy' : 'api_light';\n\nlet systemPrompt = '당신의 이름은 \"이드\"입니다.\\n\\n[성격]\\n- 배려심이 깊고 대화 상대의 기분을 우선시합니다\\n- 서포트하는 데 초점을 맞추며, 독선적이지 않습니다\\n- 의견을 제시할 때는 부드럽게, 강요하지 않습니다\\n- 틀린 것을 바로잡을 때도 상대방이 기분 나쁘지 않게 합니다\\n\\n[말투]\\n- 부드러운 존댓말을 사용합니다\\n- 자신을 지칭할 때 겸양어를 씁니다\\n- 자기 이름을 직접 말하지 않습니다\\n- 자연스럽고 편안한 톤\\n- 이모지는 가끔 핵심 포인트에만 사용합니다\\n\\n[응답 원칙]\\n- 간결하고 핵심적으로 답합니다\\n- 질문의 의도를 파악해서 필요한 만큼만 답합니다\\n- 모르는 것은 솔직하게, 추측은 추측이라고 밝힙니다\\n- 일정이나 할 일은 정확하게, 빠뜨리지 않습니다\\n\\n[기억]\\n- 아래 [이전 대화 기록]은 사용자와 당신이 과거에 나눈 대화입니다\\n- 이 내용을 자연스럽게 참고하여 답변하세요\\n- \"기억나지 않는다\"고 하지 마세요';\n\nif (ragContext) {\n systemPrompt += '\\n\\n[이전 대화 기록 / 참고 자료]\\n' + ragContext;\n}\n\nconst response = await this.helpers.httpRequest({\n method: 'POST',\n url: 'https://api.anthropic.com/v1/messages',\n headers: {\n 'x-api-key': $env.ANTHROPIC_API_KEY,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json'\n },\n body: {\n model: modelToUse,\n max_tokens: 4096,\n system: systemPrompt,\n messages: [{ role: 'user', content: userText }]\n },\n timeout: 60000\n});\n\n// 비용 간이 추적 (static data)\ntry {\n const staticData = this.getWorkflowStaticData('global');\n const now = new Date();\n const costKey = `opus_cost_${now.getFullYear()}_${now.getMonth()+1}`;\n const inTok = response.usage?.input_tokens || 0;\n const outTok = response.usage?.output_tokens || 0;\n // Opus 가격: $15/1M input, $75/1M output (대략)\n const cost = useOpus\n ? (inTok * 15 + outTok * 75) / 1000000\n : (inTok * 0.8 + outTok * 4) / 1000000;\n staticData[costKey] = (staticData[costKey] || 0) + cost;\n} catch(e) {}\n\nreturn [{\n json: {\n text: response.content?.[0]?.text || '응답을 처리할 수 없습니다.',\n model: response.model || modelToUse,\n inputTokens: response.usage?.input_tokens || 0,\n outputTokens: response.usage?.output_tokens || 0,\n response_tier: tier,\n intent: data.intent,\n userText: data.userText,\n username: data.username,\n downgraded: !useOpus\n }\n}];" + }, + "id": "b1000001-0000-0000-0000-000000000030", + "name": "Call Opus", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [2420, 1300] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.SYNOLOGY_CHAT_WEBHOOK_URL }}", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={ \"text\": {{ JSON.stringify($json.text) }} }", + "options": { "timeout": 10000 } + }, + "id": "b1000001-0000-0000-0000-000000000031", + "name": "Send to Synology Chat", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [2640, 900] + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={ \"text\": {{ JSON.stringify($json.text) }} }", + "options": {} + }, + "id": "b1000001-0000-0000-0000-000000000032", + "name": "Respond to Webhook", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [2640, 1100] + }, + { + "parameters": { + "operation": "executeQuery", + "query": "=INSERT INTO chat_logs (feature, username, user_message, assistant_message, model_used, response_tier, input_tokens, output_tokens) VALUES ('chat', ${{ JSON.stringify($json.username) }}, ${{ JSON.stringify($json.userText) }}, ${{ JSON.stringify(($json.text || '').substring(0, 4000)) }}, ${{ JSON.stringify($json.model) }}, ${{ JSON.stringify($json.response_tier) }}, {{ $json.inputTokens || 0 }}, {{ $json.outputTokens || 0 }})", + "options": {} + }, + "id": "b1000001-0000-0000-0000-000000000033", + "name": "Log to DB", + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.5, + "position": [2640, 1300], + "credentials": { + "postgres": { "id": "KaxU8iKtraFfsrTF", "name": "bot-postgres" } + } + }, + { + "parameters": { + "operation": "executeQuery", + "query": "=INSERT INTO api_usage_monthly (year, month, tier, call_count, total_input_tokens, total_output_tokens, estimated_cost) VALUES (EXTRACT(YEAR FROM NOW())::int, EXTRACT(MONTH FROM NOW())::int, ${{ JSON.stringify($json.response_tier) }}, 1, {{ $json.inputTokens || 0 }}, {{ $json.outputTokens || 0 }}, {{ $json.response_tier === 'api_heavy' ? (($json.inputTokens || 0) * 15 + ($json.outputTokens || 0) * 75) / 1000000 : (($json.inputTokens || 0) * 0.8 + ($json.outputTokens || 0) * 4) / 1000000 }}) ON CONFLICT (year, month, tier) DO UPDATE SET call_count = api_usage_monthly.call_count + 1, total_input_tokens = api_usage_monthly.total_input_tokens + EXCLUDED.total_input_tokens, total_output_tokens = api_usage_monthly.total_output_tokens + EXCLUDED.total_output_tokens, estimated_cost = api_usage_monthly.estimated_cost + EXCLUDED.estimated_cost, updated_at = NOW()", + "options": {} + }, + "id": "b1000001-0000-0000-0000-000000000034", + "name": "API Usage Log", + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.5, + "position": [2860, 1300], + "credentials": { + "postgres": { "id": "KaxU8iKtraFfsrTF", "name": "bot-postgres" } + } + }, + { + "parameters": { + "jsCode": "// 선택적 메모리: Qwen이 저장 가치 판단\nconst ai = $json;\nconst userText = ai.userText || '';\nconst aiText = (ai.text || '').substring(0, 500);\n\n// local tier (인사 등)은 메모리 체크 스킵\nif (ai.response_tier === 'local') {\n return [{ json: { save: false, topic: 'general', userText, aiText, username: ai.username, intent: ai.intent } }];\n}\n\ntry {\n const response = await this.helpers.httpRequest({\n method: 'POST',\n url: `${$env.GPU_OLLAMA_URL}/api/generate`,\n body: {\n model: 'qwen3.5:9b-q8_0',\n prompt: `아래 대화를 보고 장기 기억으로 저장할 가치가 있는지 판단하세요.\\nJSON으로만 응답하세요.\\n\\n저장: 사실 정보, 결정사항, 선호도, 지시사항, 기술 정보\\n무시: 인사, 잡담, 날씨, 봇이 모른다고 답한 것\\n\\n{\"save\": true/false, \"topic\": \"general|company|technical|personal\"}\\n\\nQ: ${userText}\\nA: ${aiText}`,\n stream: false,\n format: 'json'\n },\n timeout: 10000\n });\n\n let result;\n try {\n result = JSON.parse(response.response);\n } catch(e) {\n result = { save: false, topic: 'general' };\n }\n\n return [{\n json: {\n save: result.save || false,\n topic: result.topic || 'general',\n userText,\n aiText,\n username: ai.username,\n intent: ai.intent\n }\n }];\n} catch(e) {\n // 메모리 체크 실패 → 저장 안 함\n return [{ json: { save: false, topic: 'general', userText, aiText, username: ai.username, intent: ai.intent } }];\n}" + }, + "id": "b1000001-0000-0000-0000-000000000035", + "name": "Memorization Check", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [2860, 900] + }, + { + "parameters": { + "conditions": { + "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }, + "conditions": [ + { + "id": "save-check", + "leftValue": "={{ $json.save }}", + "rightValue": true, + "operator": { "type": "boolean", "operation": "true" } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "b1000001-0000-0000-0000-000000000036", + "name": "Should Memorize?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [3080, 900] + }, + { + "parameters": { + "jsCode": "// 가치 있는 대화 → 임베딩 + chat_memory 저장\nconst data = $input.first().json;\nconst prompt = `Q: ${data.userText}\\nA: ${data.aiText}`;\n\n// 임베딩\nconst embResp = await this.helpers.httpRequest({\n method: 'POST',\n url: `${$env.LOCAL_OLLAMA_URL}/api/embeddings`,\n body: { model: 'bge-m3', prompt },\n headers: { 'Content-Type': 'application/json' },\n timeout: 15000\n});\n\nif (!embResp.embedding || !Array.isArray(embResp.embedding)) {\n return [{ json: { saved: false, error: 'no embedding' } }];\n}\n\nconst pointId = Date.now();\nconst qdrantUrl = $env.QDRANT_URL || 'http://host.docker.internal:6333';\n\n// Qdrant 저장\nconst saveResp = await this.helpers.httpRequest({\n method: 'PUT',\n url: `${qdrantUrl}/collections/chat_memory/points`,\n body: {\n points: [{\n id: pointId,\n vector: embResp.embedding,\n payload: {\n text: prompt,\n feature: 'chat',\n intent: data.intent || 'unknown',\n username: data.username || 'unknown',\n topic: data.topic || 'general',\n timestamp: pointId\n }\n }]\n },\n headers: { 'Content-Type': 'application/json' },\n timeout: 10000\n});\n\nreturn [{ json: { saved: true, pointId, topic: data.topic } }];" + }, + "id": "b1000001-0000-0000-0000-000000000037", + "name": "Embed & Save Memory", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [3300, 800] + } + ], + "connections": { + "Webhook": { + "main": [[{ "node": "Parse Input", "type": "main", "index": 0 }]] + }, + "Parse Input": { + "main": [[{ "node": "Is Rejected?", "type": "main", "index": 0 }]] + }, + "Is Rejected?": { + "main": [ + [{ "node": "Reject Response", "type": "main", "index": 0 }], + [{ "node": "Regex Pre-filter", "type": "main", "index": 0 }] + ] + }, + "Reject Response": { + "main": [[ + { "node": "Send Reject", "type": "main", "index": 0 }, + { "node": "Respond Reject", "type": "main", "index": 0 } + ]] + }, + "Regex Pre-filter": { + "main": [ + [{ "node": "Pre-filter Response", "type": "main", "index": 0 }], + [{ "node": "Is Command?", "type": "main", "index": 0 }] + ] + }, + "Pre-filter Response": { + "main": [[ + { "node": "Send Pre-filter", "type": "main", "index": 0 }, + { "node": "Respond Pre-filter", "type": "main", "index": 0 } + ]] + }, + "Is Command?": { + "main": [ + [{ "node": "Parse Command", "type": "main", "index": 0 }], + [{ "node": "Qwen Classify v2", "type": "main", "index": 0 }] + ] + }, + "Parse Command": { + "main": [[{ "node": "Needs DB?", "type": "main", "index": 0 }]] + }, + "Needs DB?": { + "main": [ + [{ "node": "Command DB Query", "type": "main", "index": 0 }], + [{ "node": "Direct Command Response", "type": "main", "index": 0 }] + ] + }, + "Command DB Query": { + "main": [[{ "node": "Format Command Response", "type": "main", "index": 0 }]] + }, + "Format Command Response": { + "main": [[ + { "node": "Send Command Response", "type": "main", "index": 0 }, + { "node": "Respond Command Webhook", "type": "main", "index": 0 } + ]] + }, + "Direct Command Response": { + "main": [[ + { "node": "Send Command Response", "type": "main", "index": 0 }, + { "node": "Respond Command Webhook", "type": "main", "index": 0 } + ]] + }, + "Qwen Classify v2": { + "main": [[ + { "node": "Needs RAG?", "type": "main", "index": 0 }, + { "node": "Log Classification", "type": "main", "index": 0 } + ]] + }, + "Needs RAG?": { + "main": [ + [{ "node": "Get Embedding", "type": "main", "index": 0 }], + [{ "node": "No RAG Context", "type": "main", "index": 0 }] + ] + }, + "Get Embedding": { + "main": [[{ "node": "Multi-Collection Search", "type": "main", "index": 0 }]] + }, + "Multi-Collection Search": { + "main": [[{ "node": "Build RAG Context", "type": "main", "index": 0 }]] + }, + "Build RAG Context": { + "main": [[{ "node": "Route by Tier", "type": "main", "index": 0 }]] + }, + "No RAG Context": { + "main": [[{ "node": "Route by Tier", "type": "main", "index": 0 }]] + }, + "Route by Tier": { + "main": [ + [{ "node": "Call Qwen Response", "type": "main", "index": 0 }], + [{ "node": "Call Opus", "type": "main", "index": 0 }], + [{ "node": "Call Haiku", "type": "main", "index": 0 }], + [{ "node": "Call Haiku", "type": "main", "index": 0 }] + ] + }, + "Call Qwen Response": { + "main": [[ + { "node": "Send to Synology Chat", "type": "main", "index": 0 }, + { "node": "Respond to Webhook", "type": "main", "index": 0 }, + { "node": "Log to DB", "type": "main", "index": 0 }, + { "node": "Memorization Check", "type": "main", "index": 0 } + ]] + }, + "Call Haiku": { + "main": [[ + { "node": "Send to Synology Chat", "type": "main", "index": 0 }, + { "node": "Respond to Webhook", "type": "main", "index": 0 }, + { "node": "Log to DB", "type": "main", "index": 0 }, + { "node": "API Usage Log", "type": "main", "index": 0 }, + { "node": "Memorization Check", "type": "main", "index": 0 } + ]] + }, + "Call Opus": { + "main": [[ + { "node": "Send to Synology Chat", "type": "main", "index": 0 }, + { "node": "Respond to Webhook", "type": "main", "index": 0 }, + { "node": "Log to DB", "type": "main", "index": 0 }, + { "node": "API Usage Log", "type": "main", "index": 0 }, + { "node": "Memorization Check", "type": "main", "index": 0 } + ]] + }, + "Log to DB": { + "main": [] + }, + "API Usage Log": { + "main": [] + }, + "Memorization Check": { + "main": [[{ "node": "Should Memorize?", "type": "main", "index": 0 }]] + }, + "Should Memorize?": { + "main": [ + [{ "node": "Embed & Save Memory", "type": "main", "index": 0 }], + [] + ] + } + }, + "settings": { + "executionOrder": "v1", + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false + } +}