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>
220 lines
8.2 KiB
SQL
220 lines
8.2 KiB
SQL
-- 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;
|