/** * Datacollect LLM 호출 인프라 — OpenAI 호환 chat completion 단발 호출. * * v2.2.201 에서 slashRouter.ts 에서 분리. 옛 위치는 slashRouter.callLmSynthesis * 였으나 datacollect handlers + teamops/communication 양쪽이 import 하므로 * 별도 인프라 모듈로. * * v2.2.261: LLM 을 **확장(PC)에서 직접** 호출한다(과거엔 bridge `/api/lm` 프록시 경유). * 확장은 PC 의 Node 프로세스라 `g1nation.ollamaUrl`(보통 127.0.0.1) 이 PC 의 LM Studio/ * Ollama 를 정확히 가리키고, Node fetch 는 CORS 가 없어 프록시가 불필요하다. Bridge 를 * NAS 로 옮긴 뒤 프록시 경유 시 PC→NAS→PC 로 LLM 을 찾으려다(=NAS 의 127.0.0.1) 실패하던 * 문제를 제거 — 스크래핑/저장은 NAS, LLM 추론은 PC 로 깔끔히 분리된다. * * `repairKoreanGlitches` 는 callLmSynthesis 출력 위생용 — 모델이 한·영 혼합 * 토큰 깨짐 (예: "핵ess") 을 뱉으면 LLM 1회 추가 호출로 교정. */ import * as vscode from 'vscode'; /** * LM 서버(OpenAI 호환 `/v1/chat/completions`)를 확장에서 직접 호출. LM Studio/Ollama 는 * 인증이 없으므로 토큰 불필요. 타임아웃 가드 포함, 비정상 응답이면 throw. */ async function lmChat(lmUrl: string, payload: unknown, timeoutMs = 120_000): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch(`${lmUrl}/v1/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: controller.signal, }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`LM 서버 오류 ${res.status} (${lmUrl}): ${text.slice(0, 200)}`); } return await res.json(); } finally { clearTimeout(timer); } } /** * 모델 출력 붕괴(degeneration) 감지 — 작은/약한 모델이 긴 한국어 입력에서 * 반복 루프나 토큰 깨짐(예: "톤을 톤을 톤을…", "서비스 기프트 서비스 기프트…", * 깨진 유니코드)에 빠지는 경우를 잡는다. 이런 출력은 사용 불가이므로 재시도 트리거. */ export function looksDegenerate(text: string): boolean { if (!text) return false; // 1) 대체문자(���) — 인코딩/토큰 깨짐의 확실한 신호 if (/�/.test(text)) return true; const compact = text.replace(/\s+/g, ' '); // 2) 같은 구절(3~20자)이 5회 이상 반복(사이 공백 허용) — 단, 표 구분선·기호 반복은 제외 const rep = compact.match(/(.{3,20}?)(?:\s*\1){4,}/); if (rep && !/^[\s\-|=_.·•*#]+$/.test(rep[1])) return true; // 3) 한글+영문 깨짐 합성 표기가 과다 (정상 교정 한도를 크게 초과) if ((text.match(/[가-힣][a-z]{2,}/gi) || []).length > 12) return true; return false; } interface LmOpts { /** 실패·빈응답·degeneration 시 재시도 횟수 (기본 2 = 최대 3회 시도). */ retries?: number; /** 기본 샘플링 온도 override. */ temperature?: number; /** 출력 토큰 상한(max_tokens). 미지정이면 payload 에 넣지 않아 서버 기본값을 따른다. * 회의록처럼 긴 출력은 크게 지정해 서버 기본 상한에 잘리는 것을 막는다. */ maxTokens?: number; /** 출력이 length(토큰 상한)로 잘렸을 때 끊긴 지점부터 이어쓰기 최대 횟수(기본 3). 0이면 이어쓰기 안 함. * 회의록 "누락" 의 실제 원인(출력 잘림)을 보완 — 잘린 부분을 이어붙여 완결성을 확보. */ maxContinuations?: number; } /** 단발 호출 결과 — 이어쓰기 판단을 위해 finish_reason 을 함께 반환. */ interface LmResult { text: string; finish: string; } const SELF_REFLECT_RE = /\n*(?:```[\w]*\s*)?\[Self-Reflector Check\][\s\S]*$/i; /** LM 단발 호출 — 샘플링/토큰상한을 받고 (정제 텍스트 + finish_reason)을 돌려주는 내부 코어. */ async function callLmOnce( lmUrl: string, model: string, sys: string, prompt: string, sampling: { temperature: number; repeat_penalty: number; top_k: number }, maxTokens?: number, ): Promise { const payload: Record = { model, messages: [ { role: 'system', content: sys }, { role: 'user', content: prompt }, ], temperature: sampling.temperature, top_p: 0.85, top_k: sampling.top_k, repeat_penalty: sampling.repeat_penalty, }; // max_tokens 는 지정됐을 때만 넣는다 — 미지정 caller(벤치마크 등)의 기존 동작(서버 기본값)을 바꾸지 않기 위해. if (maxTokens && maxTokens > 0) payload.max_tokens = maxTokens; const res = await lmChat(lmUrl, payload, 120_000); const choice = res?.choices?.[0]; const content = choice?.message?.content ?? choice?.text ?? res?.answer ?? res?.response ?? ''; const finish = String(choice?.finish_reason ?? choice?.native_finish_reason ?? '').toLowerCase(); const text = String(content).replace(SELF_REFLECT_RE, '').trim(); return { text, finish }; } /** 이어쓰기 프롬프트 — 이미 쓴 뒷부분을 주고, 끊긴 지점부터 중복 없이 이어붙일 텍스트만 요청. */ function buildContinuePrompt(soFarTail: string): string { return '아래는 지금 작성 중이던 문서(회의록/보고서)의 **마지막 부분**이다. 토큰 한도로 중간에 끊겼다.\n' + '끊긴 바로 그 지점부터 **이어서** 계속 작성하라. 규칙:\n' + '- 이미 쓴 내용을 반복하거나 요약하지 말 것. 새 서두·인사·머리말 금지.\n' + '- 끊긴 문장/표 행/섹션을 자연스럽게 이어붙일 **뒷부분만** 출력한다(중복 헤더 재출력 금지).\n' + '- 원문서와 동일한 형식·문체(개조식·명사형 종결)를 유지한다.\n\n' + '[지금까지 출력의 마지막 부분]\n' + soFarTail; } /** * Bridge `/api/lm` 프록시로 OpenAI 호환 chat completion 호출. * LLM 서버/모델은 Astra 설정(g1nation.ollamaUrl / defaultModel)을 사용한다. * * v2.2.252: 약한 로컬 모델의 출력 붕괴(반복·깨짐)와 LM 서버 일시 오류에 대비해 * 재시도를 내장한다. 재시도할수록 repeat_penalty 를 올리고 top_k 를 좁혀 반복 * 루프를 깬다. 모든 시도가 실패하면 마지막 에러를 그대로 throw (호출부가 처리). */ export async function callLmSynthesis(prompt: string, systemPrompt?: string, opts?: LmOpts): Promise { const cfg = vscode.workspace.getConfiguration('g1nation'); const lmUrl = (cfg.get('ollamaUrl', 'http://127.0.0.1:11434') || 'http://127.0.0.1:11434').replace(/\/$/, ''); const model = (cfg.get('defaultModel', '') || 'gemma4:e2b').trim(); const baseTemp = opts?.temperature ?? Math.max(0, Math.min(2, cfg.get('datacollectSynthesisTemperature', 0.1) ?? 0.1)); const baseSys = systemPrompt || '당신은 시니어 UX/UI 분석가이자 프론트엔드 아키텍트다. 모든 보고서는 한국어로, 제공된 JSON 스캔 데이터의 구체적 수치를 인용해 작성한다. 원본 레퍼런스 사이트를 처음부터 다시 만들기 위한 명세를 작성하는 것이 미션이며, 다른 서비스로 재해석·확장하지 않는다.'; const sys = baseSys + '\n\n[출력 위생 규칙 — 반드시 준수]\n' + '- 자연스러운 한국어로 작성하고, 한 단어 안에 한글과 영문 알파벳을 섞지 마시오 ("결ently", "인orp" 같은 깨진 합성 표기 절대 금지).\n' + '- 외래어·기술 용어는 완전한 한글 표기 또는 완전한 영문 단어 중 하나로 일관되게 쓰시오.\n' + '- 같은 단어·구절을 반복하지 말고, 한 항목을 다 쓰면 다음 항목으로 진행하시오 (반복 루프 금지).\n' + '- [Self-Reflector Check], Consistency/Completeness/Accuracy 같은 내부 검증·체크 로그 블록을 출력에 절대 포함하지 마시오. 최종 사용자 결과물만 출력하시오.'; const maxRetries = Math.max(0, opts?.retries ?? 2); const maxCont = Math.max(0, opts?.maxContinuations ?? 3); let lastErr: unknown = null; for (let attempt = 0; attempt <= maxRetries; attempt++) { // 재시도할수록 반복을 더 강하게 억제: repeat_penalty ↑, top_k ↓. const sampling = { temperature: baseTemp, repeat_penalty: 1.1 + attempt * 0.15, // 1.1 → 1.25 → 1.4 top_k: Math.max(10, 20 - attempt * 5), // 20 → 15 → 10 }; try { const first = await callLmOnce(lmUrl, model, sys, prompt, sampling, opts?.maxTokens); let out = first.text; if (!out) { lastErr = new Error('LLM 응답이 비어 있습니다.'); continue; } if (looksDegenerate(out)) { lastErr = new Error('모델 출력이 붕괴(반복/깨짐)했습니다.'); continue; // 다음 시도에서 반복 억제 강화 } // ── 이어쓰기: 출력이 토큰 상한(finish=length)으로 잘렸으면 끊긴 지점부터 이어붙인다. // 회의록 후반부·긴 표가 통째로 사라지던 "누락"을 완결성으로 보완한다. let finish = first.finish; for (let c = 0; c < maxCont && finish === 'length'; c++) { let next: LmResult; try { next = await callLmOnce(lmUrl, model, sys, buildContinuePrompt(out.slice(-1600)), sampling, opts?.maxTokens); } catch { break; } // 이어쓰기 호출 실패 시 지금까지분으로 마감 if (!next.text || looksDegenerate(next.text)) break; // 이어쓰기가 붕괴하면 중단 out = `${out}\n${next.text}`.trim(); finish = next.finish; } if (/[가-힣][a-z]{2,}/.test(out)) { out = await repairKoreanGlitches(out, lmUrl, model); } return out; } catch (e) { lastErr = e; // bridge/LM 서버 오류 — 다음 시도로 (degeneration 으로 인한 서버측 parse 실패 포함) } } throw lastErr instanceof Error ? lastErr : new Error(String(lastErr)); } /** * 한글+영문이 한 단어로 깨진 표기(LLM 토큰 꼬임)를 LLM 1회 호출로 교정. */ async function repairKoreanGlitches(text: string, lmUrl: string, model: string): Promise { try { const res = await lmChat(lmUrl, { model, messages: [ { role: 'system', content: '당신은 한국어 교정기입니다. 입력 텍스트에서 한글과 영문 알파벳이 한 단어로 잘못 합쳐진 깨진 표기(예: "핵ess"→"핵심", "결ently"→"결국")만 자연스러운 한국어로 교정합니다. 그 외 내용·문장·마크다운 구조·표·정상적인 영문 용어(API, JSON 등)는 한 글자도 바꾸지 않으며, 교정된 전체 텍스트만 그대로 출력합니다(설명·주석 금지).' }, { role: 'user', content: text }, ], temperature: 0, top_p: 0.7, top_k: 20, }, 120_000); const fixed = String( res?.choices?.[0]?.message?.content ?? res?.choices?.[0]?.text ?? res?.answer ?? res?.response ?? '', ).replace(/\n*(?:```[\w]*\s*)?\[Self-Reflector Check\][\s\S]*$/i, '').trim(); if (!fixed || fixed.length < text.length * 0.7) return text; return fixed; } catch { return text; } } /** * Datacollect bridge 가 자주 뱉는 환경 의존성 에러(Python 패키지 미설치, Python * 자체 부재 등) 를 패턴 매칭해서 사용자에게 *해결 명령까지* 알려주는 가이드 텍스트. * 없으면 빈 문자열 반환. slashRouter 의 catch 블록에서 일반 에러 메시지 뒤에 append. */ export function bridgeErrorRemedy(rawMsg: string): string { const msg = String(rawMsg || ''); const pkgMatch = msg.match(/필수 패키지가 없습니다?[:\s]+([\w\-,\s.]+)/i) || msg.match(/missing (?:python )?packages?[:\s]+([\w\-,\s.]+)/i); if (pkgMatch) { const pkgs = pkgMatch[1].split(/[,\s]+/).map((s) => s.trim()).filter(Boolean).join(' '); return `\n\n💡 **해결**: Datacollect bridge 가 도는 환경에서 아래 명령으로 누락된 Python 패키지를 설치하세요.\n\n` + '```bash\n' + `# macOS (homebrew Python — PEP 668 보호 우회):\n` + `python3 -m pip install --user --break-system-packages ${pkgs}\n\n` + `# 또는 가상환경(venv) 사용 시 그 venv 활성화 후:\n` + `pip install ${pkgs}\n` + '```\n\n' + `설치 후 **bridge 재시작은 보통 불필요** — bridge 는 Python 을 child process 로 spawn 하므로 다음 호출이 바로 새 패키지를 인식합니다. 그래도 안 되면 \`npm run bridge\` 재시작.\n`; } if (/Python 3이 설치돼 있지 않거나 PATH/i.test(msg) || /command not found.*python/i.test(msg)) { return `\n\n💡 **해결**: Python 3 이 설치돼 있어야 합니다. https://www.python.org 에서 설치 후 터미널에서 \`python3 --version\` 으로 확인하세요. 이미 설치돼 있으면 PATH 설정 확인 필요.`; } if (/ECONNREFUSED|fetch failed/i.test(msg) || /연결할 수 없습니다/i.test(msg)) { return `\n\n💡 **해결**: Datacollect bridge 가 떠 있지 않습니다. \`Datacollector_MAC\` 프로젝트에서 \`npm run bridge\` 실행 후 다시 시도하세요.`; } return ''; }