feat: v2.2.92 → v2.2.158 — god-file 분해 + Stocks feature + 대화 연속성
R56–R59: agent.ts 2731→1529줄 god-file 분해 (25 modules) · attrParsers + LLM 메서드 8개 (callNonStreaming, streamChatOnce 등) · executeActions 415줄 → 8 handler 그룹 (file/run/list/brain/calendar/sheets/tasks) · handlePrompt 1100줄 → 7 phase 모듈 (system prompt + budget + autoContinue 등) R50–R55: extension.ts 1145→349줄 (telegram/settings/provider commands 분리) Stocks feature 신규: /stocks slash command (v2.2.152~158) · .astra/stocks.json 저장소 + Yahoo Finance 현재가 갱신 · 8 키워드 필터 (ROE/성장성/유동성/수익성/영업효율/기술력/안정성/PBR) · Naver 시가총액 페이지 JSON API (m.stock.naver.com) 발굴 · LLM Top 5 매력도 분석 + Telegram 자동 보고서 · KST 09:00/15:00 watcher 자동 모니터링 대화 연속성 (v2.2.150~157): · [PRIOR TURN CONCLUSION] block 으로 직전 결론 anchor · thin follow-up 분류 → boilerplate 헤더 suppression · slash 명령 결과 chatHistory mirror (capture wrapper) · echo/parrot 금지 system prompt rule 기타: /stocks 슬래시 자동완성 dropdown UI, Naver JSON API 전환 (cheerio 제거) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 모델 출력 후처리 (post-stream sanitization). 모두 stateless pure transform.
|
||||
*
|
||||
* 1) sanitizeAssistantContent — 모델이 답변에 흘려보낸 내부 마커 (rationale,
|
||||
* reasoning channels, Harmony/GPT-OSS 채널 마커, [PROBLEM]/[GOAL]/[REASONING]
|
||||
* 메타 섹션) 를 제거한 *깨끗한 본문* 만 남긴다.
|
||||
* 2) cleanDegeneratedOutput — 작은 모델이 long context 에서 degenerate 됐을 때
|
||||
* (문자 벽, 영문 메타 코멘트, 중복 paragraph stutter 등) 가독성 복구.
|
||||
* 모델 실패를 *고치는* 게 아니라 "나쁜 답변을 노이즈 벽이 아니라 읽을 수 있는
|
||||
* 형태로 유지" 하는 정도의 책임.
|
||||
* 3) isRestartedAnswer — continuation round 가 답변을 "이어붙이지" 않고 *처음부터*
|
||||
* 다시 시작했는지 detect. 작은 모델이 흔히 "continue from here" 지시를 무시.
|
||||
*
|
||||
* Why one module: 셋 다 *모델 출력을 읽을 만한 텍스트로 정리* 라는 단일 책임을
|
||||
* 공유. 호출 위치도 같은 stream loop 안에 모여 있어 응집도 높음.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 모델 출력에서 *내부* 마커 / 메타 섹션을 제거. Harmony 스타일 채널 마커는
|
||||
* `final` 채널만 남기고 나머지 (thought, analysis, commentary, reasoning) 는 통째로
|
||||
* 제거 — 모델별 closing 형태가 달라 (`<channel|>`, `<|channel|>`, `<|end|>`,
|
||||
* `<|return|>`) 보수적으로 매칭.
|
||||
*/
|
||||
export function sanitizeAssistantContent(text: string): string {
|
||||
const stripped = text
|
||||
.replace(/<rationale>[\s\S]*?<\/rationale>/gi, '')
|
||||
.replace(/^\s*\[PROBLEM\][\s\S]*?\[GOAL\][\s\S]*?\[REASONING\][^\n]*(?:\n+|$)/i, '')
|
||||
.replace(/^\s*\[PROBLEM\][\s\S]*?(?:\n\s*\n|$)/i, '')
|
||||
.replace(/(?:<think(?:ing)?>|<analysis>)[\s\S]*?(?:<\/think(?:ing)?>|<\/analysis>)/gi, '')
|
||||
.replace(/<\|?channel\|?>\s*(?:thought|analysis|commentary|reasoning)\b[\s\S]*?<\|?channel\|?>/gi, '')
|
||||
.replace(/<\|?channel\|?>\s*(?:thought|analysis|commentary|reasoning)\b[\s\S]*?(?=<\|?channel\|?>\s*final\b)/gi, '')
|
||||
.replace(/<\|?channel\|?>\s*final\b\s*(?:<\|?message\|?>)?/gi, '')
|
||||
.replace(/<\|?(?:end|return|start|message)\|?>/gi, '')
|
||||
.trim();
|
||||
return cleanDegeneratedOutput(stripped);
|
||||
}
|
||||
|
||||
/**
|
||||
* Long context 에서 small 모델이 degenerate 됐을 때의 blast radius 봉쇄:
|
||||
* - 문자 벽 (`_______…`, `~~~~~~`) 8+ 반복은 모두 제거. markdown rule 은 3.
|
||||
* - 영문 (Note: …) 류 자기 narration 제거.
|
||||
* - leaked Chronicle / follow-up scaffolding 제거.
|
||||
* - 중복 paragraph (모델 stutter) 합치기.
|
||||
*
|
||||
* 모델 실패를 *고치는* 게 아니라 "노이즈 벽" 을 "읽을 수 있는 텍스트" 로 유지.
|
||||
*/
|
||||
export function cleanDegeneratedOutput(text: string): string {
|
||||
let s = text;
|
||||
s = s.replace(/([_=~.*])\1{7,}/g, '');
|
||||
s = s.replace(/\(Note:[^)]*\)/gi, '');
|
||||
s = s.replace(/\n?Candidate records for this discussion[^\n]*/gi, '');
|
||||
s = s.replace(/\(\s*질문\s*의도\s*[::][^)]*\)/g, '');
|
||||
s = s.replace(/\[\s*핵심\s*확인\s*질문\s*\]\s*/g, '');
|
||||
const paras = s.split(/\n{2,}/);
|
||||
const deduped: string[] = [];
|
||||
for (const p of paras) {
|
||||
const norm = p.trim().replace(/\s+/g, ' ');
|
||||
const prevNorm = deduped.length ? deduped[deduped.length - 1].trim().replace(/\s+/g, ' ') : '';
|
||||
if (norm && norm === prevNorm) { continue; }
|
||||
deduped.push(p);
|
||||
}
|
||||
return deduped.join('\n\n').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Continuation round 가 "이어붙이기" 가 아니라 *처음부터 다시* 답변을 시작했는지
|
||||
* detect — 공유 leading prefix 12자 이상 + 양쪽 다 40자 이상이면 restart 로 판정.
|
||||
* 작은 모델이 "continue from here" 지시를 무시하는 흔한 회귀 대응.
|
||||
*/
|
||||
/**
|
||||
* 모델 답변에 `<rationale>…</rationale>` 블록이 포함됐을 때 그 안의
|
||||
* [PROBLEM] / [GOAL] / [REASONING] 세 섹션을 파싱해 구조화된 객체로 반환.
|
||||
* 블록 자체가 없으면 undefined — 호출자가 옵셔널로 처리.
|
||||
*
|
||||
* 섹션 정규식은 `[PROBLEM]…[GOAL]…[REASONING]…` 순서 가정. 어느 한쪽이 빠지면
|
||||
* 해당 필드는 빈 문자열 / fallback (reasoning 은 raw 전체) 로.
|
||||
*/
|
||||
export function parseRationale(text: string): { problem: string; goal: string; reasoning: string } | undefined {
|
||||
const match = text.match(/<rationale>([\s\S]*?)<\/rationale>/);
|
||||
if (!match) return undefined;
|
||||
|
||||
const raw = match[1];
|
||||
const problem = raw.match(/\[PROBLEM\]([\s\S]*?)(?=\[|$)/)?.[1]?.trim() || '';
|
||||
const goal = raw.match(/\[GOAL\]([\s\S]*?)(?=\[|$)/)?.[1]?.trim() || '';
|
||||
const reasoning = raw.match(/\[REASONING\]([\s\S]*?)(?=\[|$)/)?.[1]?.trim() || raw.trim();
|
||||
|
||||
return { problem, goal, reasoning };
|
||||
}
|
||||
|
||||
export function isRestartedAnswer(soFar: string, cont: string): boolean {
|
||||
const norm = (x: string) => x.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
const a = norm(soFar);
|
||||
const b = norm(cont);
|
||||
if (a.length < 40 || b.length < 40) { return false; }
|
||||
let i = 0;
|
||||
const max = Math.min(a.length, b.length, 80);
|
||||
while (i < max && a[i] === b[i]) { i++; }
|
||||
return i >= 12;
|
||||
}
|
||||
Reference in New Issue
Block a user