Phase 7a-2: id-9b Modelfile (no-think) + 이드 페르소나 강화

- Modelfile.id-9b 생성: qwen3.5:9b-q8_0 기반, no-think ChatML 템플릿
- 모든 Ollama 호출(8개 노드+2개 Python)에 system: '/no_think' 이중 방어
- Call Haiku/Opus: 이드 페르소나 [자아]/[성격]/[말투]/[응답 원칙]/[기억] 강화
- Call Qwen Response: system 파라미터 분리 + 경량 자아 추가
- Claude API 노드에는 /no_think 미적용

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-17 10:34:10 +09:00
parent 1543abded6
commit 30edc34cab
6 changed files with 56 additions and 19 deletions

View File

@@ -161,4 +161,4 @@ DEVONthink 4 (맥미니):
- response_tier는 Qwen v2 분류기 출력. 기존 complexity 기반 라우팅은 레거시 호환 - response_tier는 Qwen v2 분류기 출력. 기존 complexity 기반 라우팅은 레거시 호환
- n8n v2.11+ Code 노드는 Task Runner 샌드박스(VM)에서 실행됨. `$http.request()`/`this.helpers.httpRequest()`/`fetch()`/`AbortController`/`URL` 사용 불가. `require('http')`/`require('https')`/`require('url')` 사용 (NODE_FUNCTION_ALLOW_BUILTIN=crypto,http,https,url). 각 Code 노드에 `httpPost`/`httpPut` 헬퍼 함수 인라인 정의 - n8n v2.11+ Code 노드는 Task Runner 샌드박스(VM)에서 실행됨. `$http.request()`/`this.helpers.httpRequest()`/`fetch()`/`AbortController`/`URL` 사용 불가. `require('http')`/`require('https')`/`require('url')` 사용 (NODE_FUNCTION_ALLOW_BUILTIN=crypto,http,https,url). 각 Code 노드에 `httpPost`/`httpPut` 헬퍼 함수 인라인 정의
- 샌드박스 사용 가능: `Buffer`, `setTimeout`, `TextEncoder`, `FormData`, `$env`, `$input`, `$()`, `$getWorkflowStaticData()`, `$json`, `require('url').parse()` (new URL 불가), `console` - 샌드박스 사용 가능: `Buffer`, `setTimeout`, `TextEncoder`, `FormData`, `$env`, `$input`, `$()`, `$getWorkflowStaticData()`, `$json`, `require('url').parse()` (new URL 불가), `console`
- id-9b:latest가 기본 GPU 모델. qwen3.5:9b-q8_0은 레거시 백업이며 검증 완료 후 삭제 예정 - id-9b:latest가 기본 GPU 모델 (Modelfile: `ollama/Modelfile.id-9b`, FROM qwen3.5:9b-q8_0 + no-think 템플릿). 모든 Ollama 호출에 `system: '/no_think'` 이중 방어 적용

View File

@@ -132,7 +132,7 @@ type 판단:
try: try:
resp = httpx.post( resp = httpx.post(
f"{GPU_OLLAMA_URL}/api/generate", f"{GPU_OLLAMA_URL}/api/generate",
json={"model": "id-9b:latest", "prompt": prompt, "stream": False, "format": "json", "think": False}, json={"model": "id-9b:latest", "system": "/no_think", "prompt": prompt, "stream": False, "format": "json", "think": False},
timeout=15, timeout=15,
) )
return json.loads(resp.json()["response"]) return json.loads(resp.json()["response"])

View File

@@ -22,7 +22,10 @@
"name": "IMAP Trigger", "name": "IMAP Trigger",
"type": "n8n-nodes-base.imapEmail", "type": "n8n-nodes-base.imapEmail",
"typeVersion": 2, "typeVersion": 2,
"position": [0, 300] "position": [
0,
300
]
}, },
{ {
"parameters": { "parameters": {
@@ -32,17 +35,23 @@
"name": "Parse Mail", "name": "Parse Mail",
"type": "n8n-nodes-base.code", "type": "n8n-nodes-base.code",
"typeVersion": 1, "typeVersion": 1,
"position": [220, 300] "position": [
220,
300
]
}, },
{ {
"parameters": { "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\nconst item = $input.first().json;\nconst prompt = `메일을 분류하고 요약하세요. JSON만 출력.\n\n{\n \"summary\": \"한국어 2~3문장 요약\",\n \"label\": \"업무|개인|광고|알림\",\n \"has_events\": true/false,\n \"has_tasks\": true/false\n}\n\n보낸 사람: ${item.from}\n제목: ${item.subject}\n본문: ${item.body.substring(0, 3000)}`;\n\ntry {\n const r = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model: 'id-9b:latest', prompt, stream: false, format: 'json', think: false },\n { timeout: 15000 }\n );\n const cls = JSON.parse(r.response);\n return [{ json: { ...item, summary: cls.summary || item.subject, label: cls.label || '알림', has_events: cls.has_events || false, has_tasks: cls.has_tasks || false } }];\n} catch(e) {\n return [{ json: { ...item, summary: item.subject, label: '알림', has_events: false, has_tasks: false } }];\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\nconst item = $input.first().json;\nconst prompt = `메일을 분류하고 요약하세요. JSON만 출력.\n\n{\n \"summary\": \"한국어 2~3문장 요약\",\n \"label\": \"업무|개인|광고|알림\",\n \"has_events\": true/false,\n \"has_tasks\": true/false\n}\n\n보낸 사람: ${item.from}\n제목: ${item.subject}\n본문: ${item.body.substring(0, 3000)}`;\n\ntry {\n const r = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model: 'id-9b:latest', system: '/no_think', prompt, stream: false, format: 'json', think: false },\n { timeout: 15000 }\n );\n const cls = JSON.parse(r.response);\n return [{ json: { ...item, summary: cls.summary || item.subject, label: cls.label || '알림', has_events: cls.has_events || false, has_tasks: cls.has_tasks || false } }];\n} catch(e) {\n return [{ json: { ...item, summary: item.subject, label: '알림', has_events: false, has_tasks: false } }];\n}"
}, },
"id": "m1000001-0000-0000-0000-000000000003", "id": "m1000001-0000-0000-0000-000000000003",
"name": "Summarize & Classify", "name": "Summarize & Classify",
"type": "n8n-nodes-base.code", "type": "n8n-nodes-base.code",
"typeVersion": 1, "typeVersion": 1,
"position": [440, 300] "position": [
440,
300
]
}, },
{ {
"parameters": { "parameters": {
@@ -54,7 +63,10 @@
"name": "Save to mail_logs", "name": "Save to mail_logs",
"type": "n8n-nodes-base.postgres", "type": "n8n-nodes-base.postgres",
"typeVersion": 2.5, "typeVersion": 2.5,
"position": [660, 300], "position": [
660,
300
],
"credentials": { "credentials": {
"postgres": { "postgres": {
"id": "KaxU8iKtraFfsrTF", "id": "KaxU8iKtraFfsrTF",
@@ -70,7 +82,10 @@
"name": "Embed & Save", "name": "Embed & Save",
"type": "n8n-nodes-base.code", "type": "n8n-nodes-base.code",
"typeVersion": 1, "typeVersion": 1,
"position": [880, 300] "position": [
880,
300
]
}, },
{ {
"parameters": { "parameters": {
@@ -108,7 +123,10 @@
"name": "Is Important?", "name": "Is Important?",
"type": "n8n-nodes-base.if", "type": "n8n-nodes-base.if",
"typeVersion": 2.2, "typeVersion": 2.2,
"position": [1100, 300] "position": [
1100,
300
]
}, },
{ {
"parameters": { "parameters": {
@@ -125,7 +143,10 @@
"name": "Notify Chat", "name": "Notify Chat",
"type": "n8n-nodes-base.httpRequest", "type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2, "typeVersion": 4.2,
"position": [1320, 200] "position": [
1320,
200
]
} }
], ],
"connections": { "connections": {
@@ -200,4 +221,4 @@
"settings": { "settings": {
"executionOrder": "v1" "executionOrder": "v1"
} }
} }

File diff suppressed because one or more lines are too long

View File

@@ -105,6 +105,7 @@ def translate_and_summarize(title: str, content: str, lang: str) -> dict:
f"{GPU_OLLAMA_URL}/api/generate", f"{GPU_OLLAMA_URL}/api/generate",
json={ json={
"model": "id-9b:latest", "model": "id-9b:latest",
"system": "/no_think",
"prompt": f"다음 기사를 2~3문장으로 요약하세요:\n\n제목: {title}\n본문: {content[:3000]}", "prompt": f"다음 기사를 2~3문장으로 요약하세요:\n\n제목: {title}\n본문: {content[:3000]}",
"stream": False, "stream": False,
"think": False, "think": False,

15
ollama/Modelfile.id-9b Normal file
View File

@@ -0,0 +1,15 @@
FROM qwen3.5:9b-q8_0
SYSTEM "/no_think"
PARAMETER temperature 0.7
PARAMETER num_ctx 8192
TEMPLATE """{{- if .System }}<|im_start|>system
{{ .System }}<|im_end|>
{{ end }}{{- range .Messages }}{{- if eq .Role "user" }}<|im_start|>user
{{ .Content }}<|im_end|>
{{ else if eq .Role "assistant" }}<|im_start|>assistant
{{ .Content }}<|im_end|>
{{ end }}{{- end }}<|im_start|>assistant
"""