47b3b9f93a
- v2.2.299 아키텍처 수렴: coreChat 통일(엔진 휴리스틱 3벌 제거), 기업 모드 검색 오케스트레이터 승격, lib/execUtil(실행 래퍼 6곳·Python 탐지 3벌 단일화), lib/kstSchedule(워처 4개 nowInKst 통합), estimateTokens 통합, 설정 접근 규칙 명문화 - v2.2.300 채팅 화면 정리: LiveReasoningFilter(스트리밍 중 <think>/Harmony 추론 토큰 단위 차단), 확신도·검토요청 footer 기본 숨김(계산·Reflection 은 유지) - v2.2.301 문맥·의도 이해: [답변 전 이해 원칙] 상시 주입, 워크플로우 의도 브리핑, Report QA 루프(규칙 레지스트리+실측치+회귀 게이트, 블로그_v3 개념 이식) - v2.2.302 /benchmark 비즈니스 렌즈(가격·수익·운영)+빌드 프롬프트 모드+QA 연계 - v2.2.303 handoff 모드(측정치 무손실 인수인계 문서)+/claude(Claude Code 터미널 위임) - v2.2.304 이식성: 지식 경로 두뇌-상대 규약(pickWikiDir 상대 해석), 이사 체크리스트 - v2.2.305 Claude 구독 엔진: claude: 프로바이더(CLI 위임, 모델 드롭다운 자동 노출, coreChat 지원 — 워크플로우·QA도 구독 모델 가능) - v2.2.306 Tone Guard: AI 상투어 금지 레지스트리(상담사 화법 실사례 8종+대조 예시) - v2.2.307 /benchmark 레이아웃 골격(sectionRoles 결정론 분석, 롤링 배너 즉답), 파트별 실패 격리, 합성 타임아웃 120→300초 검증: tsc 무오류 + jest 888 통과 + esbuild 정상 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
243 lines
14 KiB
TypeScript
243 lines
14 KiB
TypeScript
/**
|
|
* 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.
|
|
*/
|
|
// [코어 수렴 예외 — 의도적] 이 모듈은 core/services.coreChat 으로 수렴하지 않는다:
|
|
// finish_reason 기반 이어쓰기(continuation), repeat_penalty/top_k 커스텀 샘플링,
|
|
// degeneration 감지·재시도 등 '장문 생성 엔진' 요구가 코어 채팅 API 범위를 벗어난다.
|
|
// 단순 단발 완성이 필요한 새 코드는 이 파일이 아니라 coreChat 을 사용할 것.
|
|
async function lmChat(lmUrl: string, payload: unknown, timeoutMs = 120_000): Promise<any> {
|
|
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;
|
|
/** 호출당 타임아웃(ms). 기본 120초 — /benchmark 4파트 합성처럼 입력 JSON 이 큰
|
|
* 호출은 e4b 급 모델에서 120초를 넘길 수 있어 (실사례: 파트 2 abort) 크게 지정. */
|
|
timeoutMs?: 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,
|
|
timeoutMs = 120_000,
|
|
): Promise<LmResult> {
|
|
const payload: Record<string, unknown> = {
|
|
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, timeoutMs);
|
|
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<string> {
|
|
const cfg = vscode.workspace.getConfiguration('g1nation');
|
|
const lmUrl = (cfg.get<string>('ollamaUrl', 'http://127.0.0.1:11434') || 'http://127.0.0.1:11434').replace(/\/$/, '');
|
|
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
|
|
const baseTemp = opts?.temperature ?? Math.max(0, Math.min(2, cfg.get<number>('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, opts?.timeoutMs);
|
|
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, opts?.timeoutMs);
|
|
} 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<string> {
|
|
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 '';
|
|
}
|