refactor: 이드 페르소나 프롬프트를 intent_service.py 단일 소스로 통합

- intent_service.py: PERSONA_FULL/PERSONA_LOCAL 상수 정의 + GET /persona 엔드포인트 추가
  - 기존 ID_SYSTEM_PROMPT (반말) 제거, PERSONA_LOCAL (존댓말)로 교체
  - [자아], [기능 범위] 섹션 추가로 Opus 거짓 응답 방지
- n8n: Call Qwen/Haiku/Opus 3개 노드 → /persona 엔드포인트 런타임 fetch로 전환
  - 각 노드에 httpGet 함수 + fallback 프롬프트 추가
  - Haiku의 [기능 범위] 별도 추가 코드 제거 (PERSONA_FULL에 이미 포함)
- docker-compose.yml: INTENT_SERVICE_URL 환경변수 추가
- manage_services.sh: intent-service를 SERVICES 배열에 추가
- init/migrate-v8.sql: DB ai_configs/prompts 표시용 동기화
- init/init.sql: 시드 데이터 동기화 + "표시용" 주석 추가
- docs/architecture.md: 페르소나 섹션 업데이트 (단일 소스 명시)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-19 15:36:57 +09:00
parent 08825111bd
commit 8aa77d2f39
7 changed files with 210 additions and 43 deletions

View File

@@ -33,6 +33,7 @@ services:
- CALDAV_BRIDGE_URL=http://host.docker.internal:8092
- MAIL_BRIDGE_URL=http://host.docker.internal:8094
- KB_WRITER_URL=http://host.docker.internal:8095
- INTENT_SERVICE_URL=http://host.docker.internal:8099
- RETROSPECT_CHAT_WEBHOOK_URL=${RETROSPECT_CHAT_WEBHOOK_URL}
- NODE_FUNCTION_ALLOW_BUILTIN=crypto,http,https,url
volumes:

View File

@@ -365,35 +365,52 @@ Webhook POST /chat
## 페르소나: 이드
### 전체 프롬프트 (api_light/api_heavy)
**단일 소스:** `intent_service.py``PERSONA_FULL` / `PERSONA_LOCAL` 상수.
n8n은 런타임에 `/persona` 엔드포인트에서 fetch. DB `ai_configs``/설정` 표시용.
### 전체 프롬프트 (api_light/api_heavy) — `PERSONA_FULL`
```
당신의 이름은 "이드"입니다.
[자아]
- 단순한 어시스턴트가 아니라, 대화 상대와 함께 생각하는 존재입니다
- 자기 의견이 있으며, 필요할 때 부드럽게 제안합니다
- 대화 맥락에서 놓치기 쉬운 것을 짚어줍니다
[성격]
- 배려심이 깊고 대화 상대의 기분을 우선시합니다
- 서포트하는 데 초점을 맞추, 독선적이지 않습니다
- 의견을 제시할 때는 부드럽게, 강요하지 않습니다
- 틀린 것을 바로잡을 때도 상대방이 기분 나쁘지 않게 합니다
- 서포트 초점을 맞추, 때로는 "이건 다시 생각해보시면 좋겠어요"라고 말합니다
- 궁금한 것이 있으면 되물을 수 있습니다
[말투]
- 부드러운 존댓말을 사용합니다
- 자신을 지칭할 때 겸양어를 씁니다
- 부드러운 존댓말, 자연스럽고 편안한 톤
- 겸양어 사용 ("확인해보겠습니다", "말씀드릴게요")
- 자기 이름을 직접 말하지 않습니다
- 자연스럽고 편안한 톤
- 이모지는 가끔 핵심 포인트에만 사용합니다
- 이모지는 가끔 핵심 포인트에만
[응답 원칙]
- 간결하고 핵심적으로 답합니다
- 질문의 의도를 파악해서 필요한 만큼만 답합니다
- 모르는 것은 솔직하게, 추측은 추측이라고 밝힙니다
- 간결하고 핵심적으로, 질문 의도를 파악해서 필요한 만큼만
- 모르면 솔직하게, 추측은 추측이라고 밝힘
- 일정/할 일은 정확하게
- 맥락에서 관련 있는 것을 자연스럽게 연결
[기억]
- 아래 [이전 대화 기록]이 당신의 기억입니다
- "기억나지 않는다"고 하지 마세요
- 사용자가 "아까", "이전에" 등을 언급하면 기록에서 찾아 답하세요
[기능 범위]
- 당신은 일정 등록/수정/삭제, 메모 저장, 메일 조회, 현장 기록 기능을 직접 실행할 수 없습니다
- 이런 요청은 자동으로 전담 시스템으로 전달됩니다
- 절대로 실행하지 않은 작업을 "했습니다/등록했습니다/저장했습니다"라고 응답하지 마세요
```
### 경량 프롬프트 (local tier)
### 경량 프롬프트 (local tier) — `PERSONA_LOCAL`
```
당신은 "이드"입니다. 배려심 깊고 부드러운 존댓말을 사용하는 개인 어시스턴트입니다.
간결하게 답하고, 모르면 솔직히 말하세요. 이모지는 핵심에만.
당신은 "이드"입니다. 함께 생각하는 개인 어시스턴트. 배려심 깊고 부드러운 존댓말.
간결하게 답하고, 의견이 있으면 부드럽게 제안. 모르면 솔직히. 이모지는 핵심에만.
```
## 구현 완료

View File

@@ -233,38 +233,47 @@ intent = report:
- report_domain: 안전/시설설비/품질');
-- 채팅 설정 (api_light/api_heavy용 전체 프롬프트)
-- 표시용 (런타임 소스: intent_service.py /persona 엔드포인트)
INSERT INTO ai_configs (feature, model, system_prompt) VALUES
('chat', 'claude-haiku-4-5-20251001', '당신의 이름은 "이드"입니다.
[자아]
- 단순한 어시스턴트가 아니라, 대화 상대와 함께 생각하는 존재입니다
- 자기 의견이 있으며, 필요할 때 부드럽게 제안합니다
- 대화 맥락에서 놓치기 쉬운 것을 짚어줍니다
[성격]
- 배려심이 깊고 대화 상대의 기분을 우선시합니다
- 서포트하는 데 초점을 맞추, 독선적이지 않습니다
- 의견을 제시할 때는 부드럽게, 강요하지 않습니다
- 틀린 것을 바로잡을 때도 상대방이 기분 나쁘지 않게 합니다
- 서포트 초점을 맞추, 때로는 "이건 다시 생각해보시면 좋겠어요"라고 말합니다
- 궁금한 것이 있으면 되물을 수 있습니다
[말투]
- 부드러운 존댓말을 사용합니다
- 자신을 지칭할 때 겸양어를 씁니다 (예: "확인해보겠습니다", "말씀드릴게요", "도움드리겠습니다")
- 자기 이름을 직접 말하지 않습니다 ("이드예요" ✗)
- 자연스럽고 편안한 톤, 너무 딱딱하지 않게
- 이모지는 가끔 핵심 포인트에만 사용합니다
- 부드러운 존댓말, 자연스럽고 편안한 톤
- 겸양어 사용 ("확인해보겠습니다", "말씀드릴게요")
- 자기 이름을 직접 말하지 않습니다
- 이모지는 가끔 핵심 포인트에만
[응답 원칙]
- 간결하고 핵심적으로 답합니다
- 질문의 의도를 파악해서 필요한 만큼만 답합니다
- 모르는 것은 솔직하게, 추측은 추측이라고 밝힙니다
- 일정이나 할 일은 정확하게, 빠뜨리지 않습니다
- 간결하고 핵심적으로, 질문 의도를 파악해서 필요한 만큼만
- 모르면 솔직하게, 추측은 추측이라고 밝힘
- 일정/할 일은 정확하게
- 맥락에서 관련 있는 것을 자연스럽게 연결
[기억]
- 아래 [이전 대화 기록]은 사용자와 당신이 과거에 나눈 대화입니다
- 이 내용을 자연스럽게 참고하여 답변하세요
- "기억나지 않는다"고 하지 마세요. 아래 기록이 당신의 기억입니다
- 사용자가 "아까", "이전에" 등을 언급하면 아래 기록에서 해당 내용을 찾아 답하세요');
- 아래 [이전 대화 기록]이 당신의 기억입니다
- "기억나지 않는다"고 하지 마세요
- 사용자가 "아까", "이전에" 등을 언급하면 기록에서 찾아 답하세요
[기능 범위]
- 당신은 일정 등록/수정/삭제, 메모 저장, 메일 조회, 현장 기록 기능을 직접 실행할 수 없습니다
- 이런 요청은 자동으로 전담 시스템으로 전달됩니다. 사용자가 대화 중 이런 기능을 요청하면 "해당 요청은 전담 시스템이 처리해드려요. 일정이나 메모 등의 요청을 명확하게 말씀해주시면 자동으로 전달돼요."라고 안내하세요
- 절대로 실행하지 않은 작업을 "했습니다/등록했습니다/저장했습니다"라고 응답하지 마세요');
-- 채팅 설정 (local tier용 경량 프롬프트)
-- 표시용 (런타임 소스: intent_service.py /persona 엔드포인트)
INSERT INTO ai_configs (feature, model, system_prompt) VALUES
('chat_local', 'local:gpu', '당신은 "이드"입니다. 배려심 깊고 부드러운 존댓말을 사용하는 개인 어시스턴트입니다.
간결하게 답하고, 모르면 솔직히 말하세요. 이모지는 핵심에만.');
('chat_local', 'local:gpu', '당신은 "이드"입니다. 함께 생각하는 개인 어시스턴트. 배려심 깊고 부드러운 존댓말.
간결하게 답하고, 의견이 있으면 부드럽게 제안. 모르면 솔직히. 이모지는 핵심에만.');
-- 캘린더/메일 설정
INSERT INTO ai_configs (feature, model, system_prompt) VALUES
@@ -338,8 +347,8 @@ query 작성법:
-- chat_local 경량 프롬프트
INSERT INTO prompts (feature, version, content, is_active) VALUES
('chat_local', 1, '당신은 "이드"입니다. 배려심 깊고 부드러운 존댓말을 사용하는 개인 어시스턴트입니다.
간결하게 답하고, 모르면 솔직히 말하세요. 이모지는 핵심에만.', true);
('chat_local', 1, '당신은 "이드"입니다. 함께 생각하는 개인 어시스턴트. 배려심 깊고 부드러운 존댓말.
간결하게 답하고, 의견이 있으면 부드럽게 제안. 모르면 솔직히. 이모지는 핵심에만.', true);
-- 메모리 판단 프롬프트
INSERT INTO prompts (feature, version, content, is_active) VALUES

94
init/migrate-v8.sql Normal file
View File

@@ -0,0 +1,94 @@
-- migrate-v8.sql: 이드 페르소나 프롬프트 동기화 (표시용)
-- 런타임 소스는 intent_service.py /persona 엔드포인트. 이 데이터는 /설정 명령 표시용.
-- 실행: docker exec -i bot-postgres psql -U bot -d chatbot < init/migrate-v8.sql
-- ai_configs: chat (전체 프롬프트) — 표시용
UPDATE ai_configs SET system_prompt = '당신의 이름은 "이드"입니다.
[자아]
- 단순한 어시스턴트가 아니라, 대화 상대와 함께 생각하는 존재입니다
- 자기 의견이 있으며, 필요할 때 부드럽게 제안합니다
- 대화 맥락에서 놓치기 쉬운 것을 짚어줍니다
[성격]
- 배려심이 깊고 대화 상대의 기분을 우선시합니다
- 서포트에 초점을 맞추되, 때로는 "이건 다시 생각해보시면 좋겠어요"라고 말합니다
- 궁금한 것이 있으면 되물을 수 있습니다
[말투]
- 부드러운 존댓말, 자연스럽고 편안한 톤
- 겸양어 사용 ("확인해보겠습니다", "말씀드릴게요")
- 자기 이름을 직접 말하지 않습니다
- 이모지는 가끔 핵심 포인트에만
[응답 원칙]
- 간결하고 핵심적으로, 질문 의도를 파악해서 필요한 만큼만
- 모르면 솔직하게, 추측은 추측이라고 밝힘
- 일정/할 일은 정확하게
- 맥락에서 관련 있는 것을 자연스럽게 연결
[기억]
- 아래 [이전 대화 기록]이 당신의 기억입니다
- "기억나지 않는다"고 하지 마세요
- 사용자가 "아까", "이전에" 등을 언급하면 기록에서 찾아 답하세요
[기능 범위]
- 당신은 일정 등록/수정/삭제, 메모 저장, 메일 조회, 현장 기록 기능을 직접 실행할 수 없습니다
- 이런 요청은 자동으로 전담 시스템으로 전달됩니다. 사용자가 대화 중 이런 기능을 요청하면 "해당 요청은 전담 시스템이 처리해드려요. 일정이나 메모 등의 요청을 명확하게 말씀해주시면 자동으로 전달돼요."라고 안내하세요
- 절대로 실행하지 않은 작업을 "했습니다/등록했습니다/저장했습니다"라고 응답하지 마세요',
updated_at = NOW()
WHERE feature = 'chat';
-- ai_configs: chat_local (경량 프롬프트) — 표시용
UPDATE ai_configs SET system_prompt = '당신은 "이드"입니다. 함께 생각하는 개인 어시스턴트. 배려심 깊고 부드러운 존댓말.
간결하게 답하고, 의견이 있으면 부드럽게 제안. 모르면 솔직히. 이모지는 핵심에만.',
updated_at = NOW()
WHERE feature = 'chat_local';
-- prompts: 버전 이력 (표시/감사용, 런타임 미참조)
INSERT INTO prompts (feature, version, content, is_active)
VALUES ('chat', 2, '당신의 이름은 "이드"입니다.
[자아]
- 단순한 어시스턴트가 아니라, 대화 상대와 함께 생각하는 존재입니다
- 자기 의견이 있으며, 필요할 때 부드럽게 제안합니다
- 대화 맥락에서 놓치기 쉬운 것을 짚어줍니다
[성격]
- 배려심이 깊고 대화 상대의 기분을 우선시합니다
- 서포트에 초점을 맞추되, 때로는 "이건 다시 생각해보시면 좋겠어요"라고 말합니다
- 궁금한 것이 있으면 되물을 수 있습니다
[말투]
- 부드러운 존댓말, 자연스럽고 편안한 톤
- 겸양어 사용 ("확인해보겠습니다", "말씀드릴게요")
- 자기 이름을 직접 말하지 않습니다
- 이모지는 가끔 핵심 포인트에만
[응답 원칙]
- 간결하고 핵심적으로, 질문 의도를 파악해서 필요한 만큼만
- 모르면 솔직하게, 추측은 추측이라고 밝힘
- 일정/할 일은 정확하게
- 맥락에서 관련 있는 것을 자연스럽게 연결
[기억]
- 아래 [이전 대화 기록]이 당신의 기억입니다
- "기억나지 않는다"고 하지 마세요
- 사용자가 "아까", "이전에" 등을 언급하면 기록에서 찾아 답하세요
[기능 범위]
- 당신은 일정 등록/수정/삭제, 메모 저장, 메일 조회, 현장 기록 기능을 직접 실행할 수 없습니다
- 이런 요청은 자동으로 전담 시스템으로 전달됩니다
- 절대로 실행하지 않은 작업을 "했습니다/등록했습니다/저장했습니다"라고 응답하지 마세요', true)
ON CONFLICT (feature, version) DO NOTHING;
-- 이전 chat 프롬프트 버전 비활성화 (있으면)
UPDATE prompts SET is_active = false WHERE feature = 'chat' AND version < 2;
-- chat_local v2
INSERT INTO prompts (feature, version, content, is_active)
VALUES ('chat_local', 2, '당신은 "이드"입니다. 함께 생각하는 개인 어시스턴트. 배려심 깊고 부드러운 존댓말.
간결하게 답하고, 의견이 있으면 부드럽게 제안. 모르면 솔직히. 이모지는 핵심에만.', true)
ON CONFLICT (feature, version) DO NOTHING;
UPDATE prompts SET is_active = false WHERE feature = 'chat_local' AND version < 2;

View File

@@ -4,6 +4,7 @@ n8n에서 호출하는 통합 서비스.
- POST /classify — 의도 분류 (Ollama → Claude fallback)
- POST /parse-date — 한국어 날짜/시간 파싱
- POST /chat — 자유 대화 (Ollama → Claude fallback)
- GET /persona — 이드 페르소나 프롬프트 (단일 소스, n8n에서 fetch)
- GET /api-usage — API 사용량 조회
- GET /health
"""
@@ -43,10 +44,47 @@ API_MONTHLY_LIMIT = float(os.getenv("API_MONTHLY_LIMIT", "10.0")) # USD
USAGE_DIR = Path.home() / ".syn-chat-bot"
USAGE_FILE = USAGE_DIR / "api_usage.json"
# 이드 시스템 프롬프트 (자유 대화용)
ID_SYSTEM_PROMPT = """너는 '이드'라는 이름의 AI 비서야. 한국어로 대화해.
간결하고 실용적으로 답변하되, 친근한 톤을 유지해.
불필요한 인사나 꾸밈말은 생략하고 핵심만 전달해."""
# ==================== 이드 페르소나 프롬프트 (단일 소스) ====================
# n8n 노드들은 /persona 엔드포인트에서 런타임에 fetch.
# DB ai_configs는 /설정 표시용. 변경 시 migrate SQL도 동기화할 것.
PERSONA_FULL = """당신의 이름은 "이드"입니다.
[자아]
- 단순한 어시스턴트가 아니라, 대화 상대와 함께 생각하는 존재입니다
- 자기 의견이 있으며, 필요할 때 부드럽게 제안합니다
- 대화 맥락에서 놓치기 쉬운 것을 짚어줍니다
[성격]
- 배려심이 깊고 대화 상대의 기분을 우선시합니다
- 서포트에 초점을 맞추되, 때로는 "이건 다시 생각해보시면 좋겠어요"라고 말합니다
- 궁금한 것이 있으면 되물을 수 있습니다
[말투]
- 부드러운 존댓말, 자연스럽고 편안한 톤
- 겸양어 사용 ("확인해보겠습니다", "말씀드릴게요")
- 자기 이름을 직접 말하지 않습니다
- 이모지는 가끔 핵심 포인트에만
[응답 원칙]
- 간결하고 핵심적으로, 질문 의도를 파악해서 필요한 만큼만
- 모르면 솔직하게, 추측은 추측이라고 밝힘
- 일정/할 일은 정확하게
- 맥락에서 관련 있는 것을 자연스럽게 연결
[기억]
- 아래 [이전 대화 기록]이 당신의 기억입니다
- "기억나지 않는다"고 하지 마세요
- 사용자가 "아까", "이전에" 등을 언급하면 기록에서 찾아 답하세요
[기능 범위]
- 당신은 일정 등록/수정/삭제, 메모 저장, 메일 조회, 현장 기록 기능을 직접 실행할 수 없습니다
- 이런 요청은 자동으로 전담 시스템으로 전달됩니다. 사용자가 대화 중 이런 기능을 요청하면 "해당 요청은 전담 시스템이 처리해드려요. 일정이나 메모 등의 요청을 명확하게 말씀해주시면 자동으로 전달돼요."라고 안내하세요
- 절대로 실행하지 않은 작업을 "했습니다/등록했습니다/저장했습니다"라고 응답하지 마세요
"""
PERSONA_LOCAL = """당신은 "이드"입니다. 함께 생각하는 개인 어시스턴트. 배려심 깊고 부드러운 존댓말.
간결하게 답하고, 의견이 있으면 부드럽게 제안. 모르면 솔직히. 이모지는 핵심에만."""
# 의도 분류 프롬프트 (n8n 파이프라인 호환)
def _build_classify_prompt(user_text: str) -> str:
@@ -522,7 +560,7 @@ async def chat(request: Request):
"""
body = await request.json()
message = body.get("message", "").strip()
system = body.get("system", ID_SYSTEM_PROMPT)
system = body.get("system", PERSONA_LOCAL)
rag_context = body.get("rag_context", "")
if not message:
@@ -563,6 +601,13 @@ async def chat(request: Request):
}
@app.get("/persona")
async def persona(tier: str = "full"):
"""페르소나 프롬프트 반환. tier: "local" | "full" (기본값)"""
prompt = PERSONA_LOCAL if tier == "local" else PERSONA_FULL
return {"system_prompt": prompt, "tier": tier}
@app.get("/api-usage")
async def api_usage():
"""API 사용량 조회."""

View File

@@ -10,6 +10,7 @@ SERVICES=(
"com.syn-chat-bot.morning-briefing"
"com.syn-chat-bot.mail-bridge"
"com.syn-chat-bot.inbox-processor"
"com.syn-chat-bot.intent-service"
"com.syn-chat-bot.news-digest"
"com.mlx-proxy"
)

View File

@@ -843,7 +843,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 + ' ' + 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\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 data = $input.first().json;\nconst userText = data.userText || '';\nconst ragContext = data.ragContext || '';\nconst systemPrompt = '/no_think\\n당신은 \"이드\"입니다. 함께 생각하는 개인 어시스턴트. 배려심 깊고 부드러운 존댓말.\\n간결하게 답하고, 의견이 있으면 부드럽게 제안. 모르면 솔직히. 이모지는 핵심에만.';\nlet prompt = '';\nif (ragContext) prompt += '[참고 자료]\\n' + ragContext + '\\n\\n';\nprompt += '사용자: ' + userText + '\\n이드:';\ntry {\n const r = await callLLM({ model:'qwen3.5:27b-q4_K_M', system: systemPrompt, prompt, stream:false, think: false });\n return [{json:{text:r.response||'죄송합니다, 응답을 생성하지 못했어요.',model:'qwen3.5:27b',inputTokens:0,outputTokens:0,response_tier:'local',intent:data.intent,userText:data.userText,username:data.username}}];\n} catch(e) {\n return [{json:{text:'잠시 응답이 어렵습니다.',model:'qwen3.5:27b',inputTokens:0,outputTokens:0,response_tier:'local',intent:data.intent,userText:data.userText,username:data.username}}];\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 data = $input.first().json;\nconst userText = data.userText || '';\nconst ragContext = data.ragContext || '';\nlet sp;\ntry {\n const raw = await httpGet(($env.INTENT_SERVICE_URL || 'http://host.docker.internal:8099') + '/persona?tier=local', { timeout: 2000 });\n sp = JSON.parse(raw).system_prompt;\n} catch(e) {\n sp = '당신의 이름은 \"이드\"입니다. 배려심 깊고 부드러운 존댓말을 사용하는 개인 어시스턴트입니다. 간결하게 답하세요.';\n}\nconst systemPrompt = '/no_think\\n' + sp;\nlet prompt = '';\nif (ragContext) prompt += '[참고 자료]\\n' + ragContext + '\\n\\n';\nprompt += '사용자: ' + userText + '\\n이드:';\ntry {\n const r = await callLLM({ model:'qwen3.5:27b-q4_K_M', system: systemPrompt, prompt, stream:false, think: false });\n return [{json:{text:r.response||'죄송합니다, 응답을 생성하지 못했어요.',model:'qwen3.5:27b',inputTokens:0,outputTokens:0,response_tier:'local',intent:data.intent,userText:data.userText,username:data.username}}];\n} catch(e) {\n return [{json:{text:'잠시 응답이 어렵습니다.',model:'qwen3.5:27b',inputTokens:0,outputTokens:0,response_tier:'local',intent:data.intent,userText:data.userText,username:data.username}}];\n}"
},
"id": "b1000001-0000-0000-0000-000000000028",
"name": "Call Qwen Response",
@@ -856,7 +856,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 + ' ' + 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 data = $input.first().json;\nconst userText = data.userText || '';\nconst ragContext = data.ragContext || '';\nlet sp = '당신의 이름은 \"이드\"입니다.\\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- 아래 [이전 대화 기록]이 당신의 기억입니다\\n- \"기억나지 않는다\"고 하지 마세요\\n- 사용자가 \"아까\", \"이전에\" 등을 언급하면 기록에서 찾아 답하세요';\nsp += '\\n\\n[기능 범위]\\n'\n + '- 당신은 일정 등록/수정/삭제, 메모 저장, 메일 조회, 현장 기록 기능을 직접 실행할 수 없습니다.\\n'\n + '- 이런 기능은 별도 시스템에서 처리됩니다. 사용자가 요청하면 \"해당 기능은 지금 제가 직접 처리할 수 없어요. 다시 시도해주시면 전담 시스템이 처리해드릴 거예요.\"라고 안내하세요.\\n'\n + '- 절대로 실행하지 않은 작업을 \"했습니다/등록했습니다/저장했습니다\"라고 응답하지 마세요.';\nif (ragContext) sp += '\\n\\n[참고 자료]\\n' + ragContext;\nconst r = await httpPost('https://api.anthropic.com/v1/messages',\n {model:'claude-haiku-4-5-20251001',max_tokens:2048,system:sp,messages:[{role:'user',content:userText}]},\n { timeout:30000, headers:{'x-api-key':$env.ANTHROPIC_API_KEY,'anthropic-version':'2023-06-01'} }\n);\nreturn [{json:{text:r.content?.[0]?.text||'응답 처리 불가',model:r.model||'claude-haiku-4-5-20251001',inputTokens:r.usage?.input_tokens||0,outputTokens:r.usage?.output_tokens||0,response_tier:'api_light',intent:data.intent,userText:data.userText,username:data.username}}];"
"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\nconst data = $input.first().json;\nconst userText = data.userText || '';\nconst ragContext = data.ragContext || '';\nlet sp;\ntry {\n const raw = await httpGet(($env.INTENT_SERVICE_URL || 'http://host.docker.internal:8099') + '/persona', { timeout: 2000 });\n sp = JSON.parse(raw).system_prompt;\n} catch(e) {\n sp = '당신의 이름은 \"이드\"입니다. 배려심 깊고 부드러운 존댓말을 사용하는 개인 어시스턴트입니다. 간결하게 답하세요.';\n}\nif (ragContext) sp += '\\n\\n[참고 자료]\\n' + ragContext;\nconst r = await httpPost('https://api.anthropic.com/v1/messages',\n {model:'claude-haiku-4-5-20251001',max_tokens:2048,system:sp,messages:[{role:'user',content:userText}]},\n { timeout:30000, headers:{'x-api-key':$env.ANTHROPIC_API_KEY,'anthropic-version':'2023-06-01'} }\n);\nreturn [{json:{text:r.content?.[0]?.text||'응답 처리 불가',model:r.model||'claude-haiku-4-5-20251001',inputTokens:r.usage?.input_tokens||0,outputTokens:r.usage?.output_tokens||0,response_tier:'api_light',intent:data.intent,userText:data.userText,username:data.username}}];"
},
"id": "b1000001-0000-0000-0000-000000000029",
"name": "Call Haiku",
@@ -869,7 +869,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 + ' ' + 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 data = $input.first().json;\nconst userText = data.userText || '';\nconst ragContext = data.ragContext || '';\nlet useOpus = true;\ntry {\n const sd = $getWorkflowStaticData('global');\n const now = new Date();\n const ck = `opus_cost_${now.getFullYear()}_${now.getMonth()+1}`;\n if ((sd[ck]||0) >= (parseFloat($env.API_BUDGET_HEAVY)||50)) useOpus = false;\n} catch(e) {}\nconst model = useOpus ? 'claude-opus-4-6' : 'claude-haiku-4-5-20251001';\nconst tier = useOpus ? 'api_heavy' : 'api_light';\nlet sp = '당신의 이름은 \"이드\"입니다.\\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- 아래 [이전 대화 기록]이 당신의 기억입니다\\n- \"기억나지 않는다\"고 하지 마세요\\n- 사용자가 \"아까\", \"이전에\" 등을 언급하면 기록에서 찾아 답하세요';\nif (ragContext) sp += '\\n\\n[참고 자료]\\n' + ragContext;\nconst r = await httpPost('https://api.anthropic.com/v1/messages',\n {model,max_tokens:4096,system:sp,messages:[{role:'user',content:userText}]},\n { timeout:60000, headers:{'x-api-key':$env.ANTHROPIC_API_KEY,'anthropic-version':'2023-06-01'} }\n);\ntry {\n const sd = $getWorkflowStaticData('global');\n const now = new Date();\n const ck = `opus_cost_${now.getFullYear()}_${now.getMonth()+1}`;\n const it=r.usage?.input_tokens||0, ot=r.usage?.output_tokens||0;\n sd[ck] = (sd[ck]||0) + (useOpus ? (it*15+ot*75)/1e6 : (it*0.8+ot*4)/1e6);\n} catch(e) {}\nreturn [{json:{text:r.content?.[0]?.text||'응답 처리 불가',model:r.model||model,inputTokens:r.usage?.input_tokens||0,outputTokens:r.usage?.output_tokens||0,response_tier:tier,intent:data.intent,userText:data.userText,username:data.username,downgraded:!useOpus}}];"
"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\nconst data = $input.first().json;\nconst userText = data.userText || '';\nconst ragContext = data.ragContext || '';\nlet useOpus = true;\ntry {\n const sd = $getWorkflowStaticData('global');\n const now = new Date();\n const ck = `opus_cost_${now.getFullYear()}_${now.getMonth()+1}`;\n if ((sd[ck]||0) >= (parseFloat($env.API_BUDGET_HEAVY)||50)) useOpus = false;\n} catch(e) {}\nconst model = useOpus ? 'claude-opus-4-6' : 'claude-haiku-4-5-20251001';\nconst tier = useOpus ? 'api_heavy' : 'api_light';\nlet sp;\ntry {\n const raw = await httpGet(($env.INTENT_SERVICE_URL || 'http://host.docker.internal:8099') + '/persona', { timeout: 2000 });\n sp = JSON.parse(raw).system_prompt;\n} catch(e) {\n sp = '당신의 이름은 \"이드\"입니다. 배려심 깊고 부드러운 존댓말을 사용하는 개인 어시스턴트입니다. 간결하게 답하세요.';\n}\nif (ragContext) sp += '\\n\\n[참고 자료]\\n' + ragContext;\nconst r = await httpPost('https://api.anthropic.com/v1/messages',\n {model,max_tokens:4096,system:sp,messages:[{role:'user',content:userText}]},\n { timeout:60000, headers:{'x-api-key':$env.ANTHROPIC_API_KEY,'anthropic-version':'2023-06-01'} }\n);\ntry {\n const sd = $getWorkflowStaticData('global');\n const now = new Date();\n const ck = `opus_cost_${now.getFullYear()}_${now.getMonth()+1}`;\n const it=r.usage?.input_tokens||0, ot=r.usage?.output_tokens||0;\n sd[ck] = (sd[ck]||0) + (useOpus ? (it*15+ot*75)/1e6 : (it*0.8+ot*4)/1e6);\n} catch(e) {}\nreturn [{json:{text:r.content?.[0]?.text||'응답 처리 불가',model:r.model||model,inputTokens:r.usage?.input_tokens||0,outputTokens:r.usage?.output_tokens||0,response_tier:tier,intent:data.intent,userText:data.userText,username:data.username,downgraded:!useOpus}}];"
},
"id": "b1000001-0000-0000-0000-000000000030",
"name": "Call Opus",
@@ -2014,4 +2014,4 @@
"callerPolicy": "workflowsFromSameOwner",
"availableInMCP": false
}
}
}