Files
connectai/src/features/datacollect/llm.ts
T
koriweb 8f1ce4d617 v2.2.265: /meet 검수 루프·편집·액션 상세·논의 매듭
/meet 파이프라인 대폭 개선:
- 검수 루프: 회의록 검증 후 지적분만 수정, 통과까지 반복 (전체 재작성 X)
- 편집(Editor): 검수 통과 후 보고서 형태로 구조 정리 (환각 방지 재검증)
- 액션 상세 확장: task 노트에 2~3문장 자기완결 설명 자동 생성
- 주요 논의/쟁점: 각 쟁점을 논점→논의→결론으로 그 자리에서 매듭
- 자동 등록 0건 fix: 기한 없는 확정·기한미정을 작성일(오늘)로 등록 (폴백)

신규 설정:
- g1nation.meetVerifyMaxRounds (기본 2)
- g1nation.meetEditorPass (기본 ON)
- g1nation.meetTaskDateFallback (기본 today)
- g1nation.meetTaskDetailExpand (기본 ON)

신규 프롬프트:
- buildMeetRevisePrompt (지적분만 수정)
- buildMeetEditorPrompt (근거 유지 편집)
- buildTaskDetailPrompt (액션 상세 확장)

신규 파서:
- parseTaskDetails (task 상세 맵핑)

신규 테스트:
- classifyAction dateFallback 동작 5건
- parseTaskDetails 파싱 4건

tsc ✓ · jest 715/715 ✓

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-02 18:22:52 +09:00

235 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.
*/
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;
}
/** 단발 호출 결과 — 이어쓰기 판단을 위해 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<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, 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<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);
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<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 '';
}