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 <noreply@anthropic.com>
This commit is contained in:
6
.claude/commands/simplify.md
Normal file
6
.claude/commands/simplify.md
Normal file
@@ -0,0 +1,6 @@
|
||||
변경된 코드를 리뷰하고 다음을 확인하라:
|
||||
1. 중복 코드가 있는지
|
||||
2. 불필요한 복잡성이 있는지
|
||||
3. 더 간단한 구현이 가능한지
|
||||
|
||||
문제가 있으면 직접 수정하라. 수정 후 변경 사항을 요약하라.
|
||||
9
.claude/commands/status.md
Normal file
9
.claude/commands/status.md
Normal file
@@ -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의 진행 상황 체크리스트와 실제 상태 비교
|
||||
|
||||
결과를 간결하게 요약하라.
|
||||
9
.claude/commands/verify.md
Normal file
9
.claude/commands/verify.md
Normal file
@@ -0,0 +1,9 @@
|
||||
다음 검증을 순서대로 수행하라:
|
||||
|
||||
1. docker-compose config로 문법 검증
|
||||
2. .env.example에 있는 모든 변수가 docker-compose.yml에서 참조되는지 확인
|
||||
3. init.sql 문법 검증 (docker exec로 dry-run)
|
||||
4. 컨테이너가 실행 중이면 헬스체크 확인
|
||||
5. n8n 웹훅 엔드포인트 응답 확인
|
||||
|
||||
각 단계의 결과를 보고하라. 실패한 항목이 있으면 원인과 수정 방안을 제시하라.
|
||||
32
.env.example
Normal file
32
.env.example
Normal file
@@ -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
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
n8n/data/
|
||||
postgres/data/
|
||||
.DS_Store
|
||||
122
CLAUDE.md
Normal file
122
CLAUDE.md
Normal file
@@ -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 기반 라우팅은 레거시 호환
|
||||
212
QUICK_REFERENCE.md
Normal file
212
QUICK_REFERENCE.md
Normal file
@@ -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
|
||||
64
docker-compose.yml
Normal file
64
docker-compose.yml
Normal file
@@ -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
|
||||
331
docs/architecture.md
Normal file
331
docs/architecture.md
Normal file
@@ -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
|
||||
103
docs/claude-code-playbook.md
Normal file
103
docs/claude-code-playbook.md
Normal file
@@ -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 제외 |
|
||||
360
init/init.sql
Normal file
360
init/init.sql
Normal file
@@ -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);
|
||||
219
init/migrate-v2.sql
Normal file
219
init/migrate-v2.sql
Normal file
@@ -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;
|
||||
69
init/setup-qdrant.sh
Normal file
69
init/setup-qdrant.sh
Normal file
@@ -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 "설정 완료!"
|
||||
681
n8n/workflows/main-chat-pipeline.json
Normal file
681
n8n/workflows/main-chat-pipeline.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user