diff --git a/init/migrate-v7.sql b/init/migrate-v7.sql new file mode 100644 index 0000000..2be652d --- /dev/null +++ b/init/migrate-v7.sql @@ -0,0 +1,8 @@ +-- migrate-v7.sql: calendar_events에 todo 지원 추가 +-- 실행: docker exec -i bot-postgres psql -U bot -d chatbot < init/migrate-v7.sql + +-- event_type: 'event' (VEVENT) | 'todo' (VTODO) +ALTER TABLE calendar_events ADD COLUMN IF NOT EXISTS event_type VARCHAR(10) DEFAULT 'event'; + +-- start_time을 nullable로 변경 (todo는 시작시간 없이 due만 가질 수 있음) +ALTER TABLE calendar_events ALTER COLUMN start_time DROP NOT NULL; diff --git a/n8n/workflows/main-chat-pipeline.json b/n8n/workflows/main-chat-pipeline.json index f2614b7..f035527 100644 --- a/n8n/workflows/main-chat-pipeline.json +++ b/n8n/workflows/main-chat-pipeline.json @@ -417,7 +417,7 @@ }, { "parameters": { - "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpGet(url, { timeout = 5000 } = {}) {\n return new Promise((resolve, reject) => {\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.get({ hostname: u.hostname, port: u.port, path: u.path, timeout }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => resolve(body));\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error('timeout')); });\n });\n}\n\nasync function callLLM(body) {\n const LLM = $env.LOCAL_LLM_URL || 'http://host.docker.internal:11435';\n const FB = $env.LOCAL_EMBED_URL || 'http://host.docker.internal:11434';\n try {\n await httpGet(LLM + '/health', { timeout: 3000 });\n return await httpPost(LLM + '/api/generate', body, { timeout: 120000 });\n } catch(e) {\n return await httpPost(FB + '/api/generate', body, { timeout: 120000 });\n }\n}\n\nconst input = $('Parse Input').first().json;\nconst userText = input.text;\nconst username = input.username;\nconst startTime = Date.now();\n\nconst classifierPrompt = `사용자 메시지를 분석하고 아래 JSON 형식으로만 응답하세요. 다른 텍스트는 출력하지 마세요.\n\n{\n \"intent\": \"greeting|question|log_event|calendar|reminder|mail|note|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\nintent 분류:\n- log_event: 사실 기록/등록 요청 (\"~구입\",\"~완료\",\"~교체\",\"~점검\",\"~수령\",\"~입고\",\"~등록\")\n- report: 긴급 사고/재해 신고만 (\"사고\",\"부상\",\"화재\",\"누수\",\"폭발\",\"붕괴\" + 즉각 대응 필요)\n- question: 정보 질문/조회\n- greeting: 인사/잡담/감사\n※ 애매하면 log_event로 분류 (기록 누락보다 안전)\n\n- calendar: 일정 등록/조회/삭제 (\"일정\",\"회의\",\"미팅\",\"약속\",\"~시에 ~등록\",\"오늘 일정\",\"내일 뭐 있어\")\n- reminder: 알림 설정 (\"~시에 알려줘\",\"리마인드\",\"~까지 알려줘\") → 현재 미지원, calendar로 처리\n- mail: 메일 관련 조회 (\"메일 확인\",\"받은 메일\",\"이메일\",\"메일 왔어?\")\n ※ \"매일\"은 \"메일\"의 오타일 수 있음 — \"매일 확인\",\"매일 왔어\" 등 문맥으로 판단\n- note: 메모/기록 요청 (\"기록해\",\"메모해\",\"저장해\",\"적어둬\")\n\nresponse_tier 판단:\n- local: 인사, 잡담, 감사, log_event, report, calendar, reminder, note, 단순 질문, 정의/개념 설명, 짧은 답변 가능한 질문, mail 간단조회\n- api_light: 장문 요약(200자 이상 텍스트), 다국어 번역, 비교 분석, RAG 결과 종합 정리\n- api_heavy: 법률 해석, 복잡한 다단계 추론, 다중 문서 교차 분석\n※ 판단이 애매하면 local 우선\n\nneeds_rag 판단:\n- true: 회사문서/절차 질문, 이전 기록 조회(\"최근\",\"아까\",\"전에\",\"뭐였지\"), 기술질문\n- false: 인사, 잡담, 일반상식, log_event, report\nrag_target: documents(개인문서), tk_company(회사문서/구매/점검/안전/품질 조회), chat_memory(이전대화,\"아까\",\"최근\",\"기억\")\n\n사용자 메시지: ${userText}`;\n\ntry {\n const response = await callLLM({ model: 'qwen3.5:27b-q4_K_M', system: '/no_think', prompt: classifierPrompt, stream: false, format: 'json', think: false });\n const latency = Date.now() - startTime;\n let cls = {};\n try { cls = JSON.parse(response.response); } catch(e) {}\n return [{ json: {\n intent: cls.intent || 'question', response_tier: cls.response_tier || 'api_light',\n needs_rag: cls.needs_rag || false, rag_target: Array.isArray(cls.rag_target) ? cls.rag_target : [],\n department_hint: cls.department_hint || null, report_domain: cls.report_domain || null,\n query: cls.query || userText, userText, username, latency, fallback: false\n } }];\n} catch(e) {\n const t = userText;\n let intent = 'question';\n let response_tier = 'api_light';\n let needs_rag = false;\n let rag_target = [];\n\n if (/일정|회의|미팅|약속|스케줄|캘린더/.test(t) && /등록|잡아|추가|만들|넣어|수정|삭제|취소/.test(t)) {\n intent = 'calendar'; response_tier = 'local';\n } else if (/일정|스케줄|뭐\\s*있/.test(t) && /오늘|내일|이번|다음/.test(t)) {\n intent = 'calendar'; response_tier = 'local';\n } else if (/기록해|메모해|저장해|적어둬|메모\\s*저장|노트/.test(t)) {\n intent = 'note'; response_tier = 'local';\n } else if (/메일|이메일|받은\\s*편지|mail/.test(t) || (/매일/.test(t) && /확인|왔|온|요약|읽/.test(t))) {\n intent = 'mail'; response_tier = 'local';\n } else if (/\\d+시/.test(t) && /알려|리마인드|알림/.test(t)) {\n intent = 'calendar'; response_tier = 'local';\n } else if (/구입|완료|교체|점검|수령|입고|발주/.test(t) && !/\\?|까$|나$/.test(t)) {\n intent = 'log_event'; response_tier = 'local';\n } else {\n if (userText.length <= 30 && !/요약|번역|분석|비교/.test(t)) {\n response_tier = 'local';\n }\n needs_rag = /회사|절차|문서|안전|품질|규정|아까|전에|기억/.test(t);\n if (needs_rag) {\n rag_target = ['documents'];\n if (/회사|절차|안전|품질/.test(t)) rag_target.push('tk_company');\n if (/아까|이전|전에|기억/.test(t)) rag_target.push('chat_memory');\n }\n }\n\n return [{ json: {\n intent, response_tier, needs_rag, rag_target,\n department_hint: null, report_domain: null, query: userText,\n userText, username, latency: Date.now() - startTime,\n fallback: true, fallback_method: 'keyword'\n } }];\n}" + "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpGet(url, { timeout = 5000 } = {}) {\n return new Promise((resolve, reject) => {\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.get({ hostname: u.hostname, port: u.port, path: u.path, timeout }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => resolve(body));\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error('timeout')); });\n });\n}\n\nasync function callLLM(body) {\n const LLM = $env.LOCAL_LLM_URL || 'http://host.docker.internal:11435';\n const FB = $env.LOCAL_EMBED_URL || 'http://host.docker.internal:11434';\n try {\n await httpGet(LLM + '/health', { timeout: 3000 });\n return await httpPost(LLM + '/api/generate', body, { timeout: 120000 });\n } catch(e) {\n return await httpPost(FB + '/api/generate', body, { timeout: 120000 });\n }\n}\n\nconst input = $('Parse Input').first().json;\nconst userText = input.text;\nconst username = input.username;\nconst startTime = Date.now();\n\nconst classifierPrompt = `사용자 메시지를 분석하고 아래 JSON 형식으로만 응답하세요. 다른 텍스트는 출력하지 마세요.\n\n{\n \"intent\": \"greeting|question|log_event|calendar|todo|reminder|mail|note|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\nintent 분류:\n- log_event: 사실 기록/등록 요청 (\"~구입\",\"~완료\",\"~교체\",\"~점검\",\"~수령\",\"~입고\",\"~등록\")\n- report: 긴급 사고/재해 신고만 (\"사고\",\"부상\",\"화재\",\"누수\",\"폭발\",\"붕괴\" + 즉각 대응 필요)\n- question: 정보 질문/조회\n- greeting: 인사/잡담/감사\n※ 애매하면 log_event로 분류 (기록 누락보다 안전)\n\n- calendar: 일정 등록/조회/삭제 (\"일정\",\"회의\",\"미팅\",\"약속\",\"~시에 ~등록\",\"오늘 일정\",\"내일 뭐 있어\")\n- todo: 작업/할일 등록 (\"~까지 해야 해\",\"할 일 추가\",\"작업 등록\",\"~보고서 작성해야 해\") ※ 일정/회의/미팅/약속은 calendar\n- reminder: 알림 설정 (\"~시에 알려줘\",\"리마인드\",\"~까지 알려줘\") → 현재 미지원, calendar로 처리\n- mail: 메일 관련 조회 (\"메일 확인\",\"받은 메일\",\"이메일\",\"메일 왔어?\")\n ※ \"매일\"은 \"메일\"의 오타일 수 있음 — \"매일 확인\",\"매일 왔어\" 등 문맥으로 판단\n- note: 메모/기록 요청 (\"기록해\",\"메모해\",\"저장해\",\"적어둬\")\n\nresponse_tier 판단:\n- local: 인사, 잡담, 감사, log_event, report, calendar, reminder, note, 단순 질문, 정의/개념 설명, 짧은 답변 가능한 질문, mail 간단조회\n- api_light: 장문 요약(200자 이상 텍스트), 다국어 번역, 비교 분석, RAG 결과 종합 정리\n- api_heavy: 법률 해석, 복잡한 다단계 추론, 다중 문서 교차 분석\n※ 판단이 애매하면 local 우선\n\nneeds_rag 판단:\n- true: 회사문서/절차 질문, 이전 기록 조회(\"최근\",\"아까\",\"전에\",\"뭐였지\"), 기술질문\n- false: 인사, 잡담, 일반상식, log_event, report\nrag_target: documents(개인문서), tk_company(회사문서/구매/점검/안전/품질 조회), chat_memory(이전대화,\"아까\",\"최근\",\"기억\")\n\n사용자 메시지: ${userText}`;\n\ntry {\n const response = await callLLM({ model: 'qwen3.5:27b-q4_K_M', system: '/no_think', prompt: classifierPrompt, stream: false, format: 'json', think: false });\n const latency = Date.now() - startTime;\n let cls = {};\n try { cls = JSON.parse(response.response); } catch(e) {}\n return [{ json: {\n intent: cls.intent || 'question', response_tier: cls.response_tier || 'api_light',\n needs_rag: cls.needs_rag || false, rag_target: Array.isArray(cls.rag_target) ? cls.rag_target : [],\n department_hint: cls.department_hint || null, report_domain: cls.report_domain || null,\n query: cls.query || userText, userText, username, latency, fallback: false\n } }];\n} catch(e) {\n const t = userText;\n let intent = 'question';\n let response_tier = 'api_light';\n let needs_rag = false;\n let rag_target = [];\n\n if (/일정|회의|미팅|약속|스케줄|캘린더/.test(t) && /등록|잡아|추가|만들|넣어|수정|삭제|취소/.test(t)) {\n intent = 'calendar'; response_tier = 'local';\n } else if (/일정|스케줄|뭐\\s*있/.test(t) && /오늘|내일|이번|다음/.test(t)) {\n intent = 'calendar'; response_tier = 'local';\n } else if (/할\\s*일\\s*(추가|등록)/.test(t)) {\n intent = 'todo'; response_tier = 'local';\n } else if (!/일정|회의|미팅|약속|스케줄|캘린더/.test(t) && /까지|해야|할\\s*일|작업/.test(t) && /작성|보고서|정리|준비|제출/.test(t)) {\n intent = 'todo'; response_tier = 'local';\n } else if (/기록해|메모해|저장해|적어둬|메모\\s*저장|노트/.test(t)) {\n intent = 'note'; response_tier = 'local';\n } else if (/메일|이메일|받은\\s*편지|mail/.test(t) || (/매일/.test(t) && /확인|왔|온|요약|읽/.test(t))) {\n intent = 'mail'; response_tier = 'local';\n } else if (/\\d+시/.test(t) && /알려|리마인드|알림/.test(t)) {\n intent = 'calendar'; response_tier = 'local';\n } else if (/구입|완료|교체|점검|수령|입고|발주/.test(t) && !/\\?|까$|나$/.test(t)) {\n intent = 'log_event'; response_tier = 'local';\n } else {\n if (userText.length <= 30 && !/요약|번역|분석|비교/.test(t)) {\n response_tier = 'local';\n }\n needs_rag = /회사|절차|문서|안전|품질|규정|아까|전에|기억/.test(t);\n if (needs_rag) {\n rag_target = ['documents'];\n if (/회사|절차|안전|품질/.test(t)) rag_target.push('tk_company');\n if (/아까|이전|전에|기억/.test(t)) rag_target.push('chat_memory');\n }\n }\n\n return [{ json: {\n intent, response_tier, needs_rag, rag_target,\n department_hint: null, report_domain: null, query: userText,\n userText, username, latency: Date.now() - startTime,\n fallback: true, fallback_method: 'keyword'\n } }];\n}" }, "id": "b1000001-0000-0000-0000-000000000020", "name": "Qwen Classify v2", @@ -591,6 +591,27 @@ } }, "renameOutput": "Note" + }, + { + "conditions": { + "conditions": [ + { + "leftValue": "={{ $json.intent }}", + "rightValue": "todo", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and", + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + } + }, + "renameOutput": "Todo" } ] }, @@ -1199,7 +1220,7 @@ }, { "parameters": { - "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\nfunction httpPut(url, body, { timeout = 10000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst cls = $('Qwen Classify v2').first().json;\nconst pid = $json.pid || Date.now();\nconst userText = cls.userText;\nconst username = cls.username;\n\n// \"기록해\", \"메모해\", \"저장해\", \"적어둬\" 등 제거\nconst content = userText\n .replace(/[.]\\s*(기록해|메모해|저장해|적어둬|기록|메모|저장)[.]?\\s*$/g, '')\n .replace(/^\\s*(기록해|메모해|저장해|적어둬)[.:]\\s*/g, '')\n .trim() || userText;\n\n// 제목: 앞 30자\nconst title = content.substring(0, 30).replace(/\\n/g, ' ') + (content.length > 30 ? '...' : '');\nconst now = new Date();\nconst dateStr = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;\nconst fullTitle = `${dateStr} ${title}`;\n\n// kb_writer 파일 저장\nconst kbUrl = $env.KB_WRITER_URL || 'http://host.docker.internal:8095';\nlet saved = false;\ntry {\n const result = await httpPost(`${kbUrl}/save`, {\n title: fullTitle,\n content: content,\n type: 'note',\n tags: ['synology-chat', 'note'],\n username: username || 'unknown',\n topic: 'general',\n qdrant_id: pid\n }, { timeout: 10000 });\n saved = result.success === true;\n} catch(e) {}\n\n// Qdrant 임베딩 (graceful)\ntry {\n const emb = await httpPost(`${$env.LOCAL_EMBED_URL}/api/embeddings`, { model: 'bge-m3', prompt: content });\n if (emb.embedding && Array.isArray(emb.embedding)) {\n const qu = $env.QDRANT_URL || 'http://host.docker.internal:6333';\n await httpPut(`${qu}/collections/chat_memory/points`, { points: [{ id: pid, vector: emb.embedding, payload: {\n text: content, feature: 'note', intent: 'note',\n username: username || 'unknown', topic: 'general', timestamp: pid\n }}]});\n }\n} catch(e) {}\n\nconst responseText = saved\n ? `기록했습니다: ${title}`\n : `기록을 시도했지만 저장에 실패했습니다. 내용: ${title}`;\n\nreturn [{ json: { text: responseText, userText, username, response_tier: 'local', intent: 'note', model: 'qwen3.5:27b', inputTokens: 0, outputTokens: 0 } }];" + "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\nfunction httpPut(url, body, { timeout = 10000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst cls = $('Qwen Classify v2').first().json;\nconst pid = $json.pid || Date.now();\nconst userText = cls.userText;\nconst username = cls.username;\n\n// \"기록해\", \"메모해\", \"저장해\", \"적어둬\" 등 트리거 워드 제거\nconst cleanText = userText\n .replace(/[.]\\s*(기록해|메모해|저장해|적어둬|기록|메모|저장)[.]?\\s*$/g, '')\n .replace(/^\\s*(기록해|메모해|저장해|적어둬)[.:]\\s*/g, '')\n .trim() || userText;\n\n// 제목: cls.title 우선, 없으면 앞 30자\nconst titlePreview = (cls.title?.trim() || cleanText).substring(0, 30).replace(/\\n/g, ' ');\nconst title = titlePreview + (cleanText.length > 30 ? '...' : '');\nconst now = new Date();\nconst dateStr = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;\nconst fullTitle = `${dateStr} ${title}`;\n\n// kb_writer 파일 저장\nconst kbUrl = $env.KB_WRITER_URL || 'http://host.docker.internal:8095';\nlet saved = false;\ntry {\n const result = await httpPost(`${kbUrl}/save`, {\n title: fullTitle,\n content: cleanText,\n type: 'note',\n tags: ['synology-chat', 'note'],\n username: username || 'unknown',\n topic: 'general',\n qdrant_id: pid\n }, { timeout: 10000 });\n saved = result.success === true;\n} catch(e) {}\n\n// Qdrant 임베딩 (graceful)\ntry {\n const emb = await httpPost(`${$env.LOCAL_EMBED_URL}/api/embeddings`, { model: 'bge-m3', prompt: cleanText});\n if (emb.embedding && Array.isArray(emb.embedding)) {\n const qu = $env.QDRANT_URL || 'http://host.docker.internal:6333';\n await httpPut(`${qu}/collections/chat_memory/points`, { points: [{ id: pid, vector: emb.embedding, payload: {\n text: cleanText, feature: 'note', intent: 'note',\n username: username || 'unknown', topic: 'general', timestamp: pid\n }}]});\n }\n} catch(e) {}\n\nconst responseText = saved\n ? `메모 저장 완료: ${title} (${cleanText.length}자)`\n : `메모 저장 실패: ${title}`;\n\nreturn [{ json: { text: responseText, userText, username, response_tier: 'local', intent: 'note', model: 'qwen3.5:27b', inputTokens: 0, outputTokens: 0 } }];" }, "id": "b1000001-0000-0000-0000-000000000069", "name": "Handle Note", @@ -1209,6 +1230,19 @@ 1760, 2100 ] + }, + { + "parameters": { + "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpGet(url, { timeout = 5000 } = {}) {\n return new Promise((resolve, reject) => {\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.get({ hostname: u.hostname, port: u.port, path: u.path, timeout }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => resolve(body));\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error('timeout')); });\n });\n}\n\nasync function callLLM(body) {\n const LLM = $env.LOCAL_LLM_URL || 'http://host.docker.internal:11435';\n const FB = $env.LOCAL_EMBED_URL || 'http://host.docker.internal:11434';\n try {\n await httpGet(LLM + '/health', { timeout: 3000 });\n return await httpPost(LLM + '/api/generate', body, { timeout: 120000 });\n } catch(e) {\n return await httpPost(FB + '/api/generate', body, { timeout: 120000 });\n }\n}\n\nconst cls = $('Qwen Classify v2').first().json;\nconst userText = cls.userText;\nconst username = cls.username;\n\nconst now = new Date();\nconst today = now.toISOString().split('T')[0];\nconst dayNames = ['일','월','화','수','목','금','토'];\nconst dayOfWeek = dayNames[now.getDay()];\n\nlet todoData;\ntry {\n const extractPrompt = `사용자 메시지에서 작업(todo) 정보를 추출하세요. JSON으로만 응답하세요.\n\n{\n \"title\": \"작업 제목\",\n \"due\": \"YYYY-MM-DDTHH:MM:SS (기한, 없으면 null)\",\n \"description\": \"상세 설명 (없으면 null)\"\n}\n\n규칙:\n- \"내일\" = 오늘+1일, \"모레\" = 오늘+2일, \"금요일까지\" = 이번주 금요일\n- 기한이 날짜만 있고 시간 미지정 시 18:00 기본값\n- \"해야 해\", \"까지\" 등의 표현에서 기한 추론\n- title은 핵심 작업만 간결하게\n\n현재: ${today} (${dayOfWeek}요일)\n메시지: ${userText}`;\n\n const r = await callLLM({ model: 'qwen3.5:27b-q4_K_M', system: '/no_think', prompt: extractPrompt, stream: false, format: 'json', think: false });\n todoData = JSON.parse(r.response);\n} catch(e) {\n return [{ json: { text: '작업 정보를 파악하지 못했습니다. 다시 말씀해주세요.', userText, username, response_tier: 'local', intent: 'todo', model: 'qwen3.5:27b', inputTokens: 0, outputTokens: 0 } }];\n}\n\nif (!todoData.title) {\n return [{ json: { text: '작업 제목을 파악하지 못했습니다. 다시 말씀해주세요.', userText, username, response_tier: 'local', intent: 'todo', model: 'qwen3.5:27b', inputTokens: 0, outputTokens: 0 } }];\n}\n\nconst caldavUrl = $env.CALDAV_BRIDGE_URL || 'http://host.docker.internal:8092';\n\nlet caldavSynced = true;\ntry {\n const body = { title: todoData.title };\n if (todoData.due) body.due = todoData.due;\n if (todoData.description) body.description = todoData.description;\n await httpPost(`${caldavUrl}/calendar/create-todo`, body, { timeout: 10000 });\n} catch(e) {\n caldavSynced = false;\n}\n\nlet responseText = `작업 등록 완료: ${todoData.title}`;\nif (todoData.due) {\n const dueDt = new Date(todoData.due);\n const dueDay = dayNames[dueDt.getDay()];\n responseText += ` (기한: ${dueDt.getMonth()+1}/${dueDt.getDate()} ${dueDay})`;\n}\nif (!caldavSynced) responseText += '\\n\\u26a0\\ufe0f 작업은 기록했지만 캘린더 동기화에 실패했습니다.';\n\nconst safe = s => (s||'').replace(/'/g, \"''\");\nconst startTime = todoData.due ? new Date(todoData.due).toISOString() : new Date().toISOString();\nconst insertSQL = `INSERT INTO calendar_events (title,start_time,event_type,description,created_by,source) VALUES ('${safe(todoData.title)}','${startTime}','todo',${todoData.description?\"'\"+safe(todoData.description)+\"'\":'NULL'},'${safe(username)}','chat')`;\n\nreturn [{ json: { text: responseText, insertSQL, userText, username, response_tier: 'local', intent: 'todo', model: 'qwen3.5:27b', inputTokens: 0, outputTokens: 0 } }];" + }, + "id": "b1000001-0000-0000-0000-000000000071", + "name": "Handle Todo", + "type": "n8n-nodes-base.code", + "typeVersion": 1, + "position": [ + 1760, + 2100 + ] } ], "connections": { @@ -1560,6 +1594,13 @@ "index": 0 } ], + [ + { + "node": "Handle Todo", + "type": "main", + "index": 0 + } + ], [ { "node": "Needs RAG?", @@ -1940,6 +1981,32 @@ } ] ] + }, + "Handle Todo": { + "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": "Has Calendar Insert?", + "type": "main", + "index": 0 + } + ] + ] } }, "settings": {