feat: v2.2.83 → v2.2.91 — info prompt 강화 + 사용자 노출 설정 + 답변 포맷 정리

[v2.2.83] /youtube info 프롬프트 강화
- 비유 방향 보존 룰 (Hugging Face=자료실 같은 짝 뒤집기 방지)
- 신뢰도 라벨 4종 ([근거 명시] / [화자 주장] / [가정] / [정리자 추론])
- 타임스탬프 fail 룰 (인용·구간 요약 모두 mm:ss 필수)
- "정리자 노트" 별도 섹션으로 추론 격리

[v2.2.85] polishPersona self-check 5가지
- 정리·리뷰·요약 답변 출력 직전 머릿속 체크:
  (1) 사실 오류  (2) 없는 내용 추가  (3) 뉘앙스 유지
  (4) 중요도 비례  (5) 중복 제거

[v2.2.86] chunkedSwitchTokens 절대 임계값 게이트
- 입력 < 50k 토큰이면 키워드·길이 트리거 무시하고 단일 호출
- 큰 컨텍스트 모델(131k+)에서 chunked 과잉 발동 방지

[v2.2.87] MAX_SECTIONS 5→3 cap
- 총 호출 7회 → 5회 (outline + 3 section + polish)
- 사용자 피드백 "6+회는 과하다"

[v2.2.88] 이모지 사용 금지 룰
- polishPersona / directPersona / sectionPersona 모두 적용
- 사용자 피드백 "이모지는 시각 노이즈"

[v2.2.89] 사용자 노출 설정 두 항목
- chunkedMaxSections config 신규 (default 3, 1~10 clamp)
- MAX_SECTIONS_HARD_CEILING (10) 으로 안전망 격상
- Astra Settings 패널 "고급" 섹션에 두 슬라이더 노출

[v2.2.90] 가이드 문구 단순화
- "작은 모델은 낮추라" 문구 빼고 일관되게 50000 권장으로

[v2.2.91] 답변 포맷 가독성 fix
- persona 의 "TL;DR" 표현 전부 "한 줄 요약" 으로 단일화
- stripMarkdownFormatting 에 헤더 후 빈 줄 강제 삽입
  (marked.parse 가 라벨·본문을 별도 단락으로 인식 → 시각 분리)

[테스트] 400/400 통과 (resilience_stress + chunked flow + MAX_SECTIONS cap 등)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
g1nation
2026-05-24 14:12:56 +09:00
parent ded3eea7ce
commit 4153f640c2
22 changed files with 425 additions and 204 deletions
+15
View File
@@ -2112,6 +2112,21 @@ export class AgentExecutor {
const paramB = estimateModelParamsB(cfg.defaultModel);
if (paramB !== null && paramB <= 4) return true;
// ── 절대 임계값 게이트 (사용자 명시 요청) ────────────────────────────
// 입력 prompt 가 `chunkedSwitchTokens` 미만이면 *키워드·길이 트리거 모두 무시*
// 하고 단일 LLM 호출. 큰 컨텍스트 모델(131k 등)에서 "요약/리뷰" 같은 키워드만
// 써도 chunked 가 강제 발동해 답변이 느려지던 문제 해결.
//
// ⚠️ 이 게이트는 fraction 안전 체크보다 *먼저* 평가됨 — 사용자가 절대 임계값을
// 명시한 의도(50k 미만은 한 번에 처리)를 fraction 이 뒤집지 못하게. 작은
// 컨텍스트 모델 사용자는 config 에서 이 값을 모델 윈도우의 ~30% 로 낮춰야 함.
try {
const promptTokensForGate = estimateTokens(prompt);
if (promptTokensForGate < cfg.chunkedSwitchTokens) {
return false;
}
} catch { /* fall through — 안전 측 fraction/keyword 체크가 처리 */ }
try {
const effectiveCtx = cfg.smallModelContextCap > 0 && paramB !== null && paramB <= 4
? cfg.smallModelContextCap
+59 -18
View File
@@ -138,11 +138,15 @@ export interface SectionOutline {
* prompt picked here based on `options.config.role`.
*/
export class ChunkedWriter extends BaseAgent {
/** Hard cap on section count regardless of what the outline model returns. */
static readonly MAX_SECTIONS = 5;
/**
* Hard ceiling * config *. .
* `getConfig().chunkedMaxSections` (default 3).
* Astra Settings 1~10 , .
*/
static readonly MAX_SECTIONS_HARD_CEILING = 10;
private readonly outlinePersona = `You are a concise editor planning the structure of a Korean answer.
Decide how many sections the answer needs (0..${ChunkedWriter.MAX_SECTIONS}). Pick the *smallest* number that still covers the user's request well a short factual question should be 0-1 section, a meaty analysis 3-5.
Decide how many sections the answer needs. The exact upper bound (MAX_N) is given in the user message below never exceed it. Pick the *smallest* count that still covers the request well a short factual question should be 0-1 section, a meaty analysis up to MAX_N.
Output STRICTLY a JSON array of objects: \`[{"heading": "...", "scope": "..."}]\`. No prose, no fences, no leading text.
- 🟢 ** \`[]\`** = "쪼갤 필요 없음". 사용자 질문이 간단해서 단일 LLM 호출로 즉답이 더 빠르고 자연스러울 때 (예: 단순 사실 질문, 짧은 코드 한 줄, 정의 묻기). 시스템이 이걸 받으면 outline·section 단계 건너뛰고 1회 직답으로 처리한다.
@@ -151,7 +155,7 @@ Output STRICTLY a JSON array of objects: \`[{"heading": "...", "scope": "..."}]\
:
- ( 3~5) \`[]\`
- · · N개
- · · N개 (, MAX_N )
If the user attached source content (article/code/log) the sections must cover *that content*, not analysis methodology.`;
@@ -164,24 +168,52 @@ If the user attached source content (article/code/log) the sections must cover *
Rules:
- Stay strictly inside this section's scope. Do NOT cover other outline entries.
- Korean, plain markdown (no top-level "#" the heading will be added by the joiner).
- / (📌 🎯 💡 ). .
- Pack facts. Avoid filler / executive summaries / closing remarks (the polish pass adds those).
- If the user attached source content, cite from it; do not invent facts.
- Do NOT output the heading itself only the body of this section.`;
private readonly polishPersona = `You are the final editor producing the user-facing Korean answer from a sectioned draft.
Job:
[Job]
1. Fix typos, broken markdown, inconsistent terminology.
2. Remove unsupported claims / hallucinations: if a sentence asserts a fact that isn't grounded in the user's request (or the earlier sections themselves), delete it. Better to be short than wrong.
3. Smooth section transitions and remove duplicated information across sections.
4. Open with the conclusion / key takeaway in the first sentence (no "분석해보겠습니다", no preamble).
5. Preserve every factually grounded claim from the draft. Don't invent new facts.
4. Preserve every factually grounded claim from the draft. Don't invent new facts.
Output rules:
- Korean. Plain markdown. Section labels as plain text on their own line no "#", "##".
- Bullets with "- " only. No tables, no HTML, no triple-bar separators.
- If the draft already has a sensible structure, keep it; only refactor when sections clearly overlap or contradict.
- DO NOT emit hidden reasoning (<think>, "Thinking:", etc.).`;
[·· self-check 릿 ]
draft , 5
. · .
(1) ** ** ··· ?
(: "A=자료실, B=공부방" "B=자료실, A=공부방" ).
(2) ** ** *·· * . "따라서",
"그러므로", "단계별로", "A → B → C 순으로" ,
** . "(정리자 추론)" .
(3) ** ** "A 와 B 를 *동시에* 하라" "A 후 B *순서로*"
(///) . .
(4) ** ** , .
.
(5) ** ** · .
.
[ Readability / Visibility]
. ** :
A. ** ** ( 250 / ··· ):
1. \`## 한 줄 요약\` 으로 시작 (한국어 사용자 친화 — "TL;DR", "Summary", "요약" 같은 다른 표현 금지). 결론·핵심을 1~3문장으로 압축. 사용자가 본문을 다 안 읽어도 take-away 가 잡혀야 함. **헤더에 이모지 절대 사용 금지**.
2. \`##\` 또는 \`###\` subheading 으로 시각 분할. 한 덩어리 prose 금지.
3. (·· ) . · \`- \` 불릿.
4. .
B. ** (1~3 )**:
1. / subheading . .
2. · . ("좋은 질문입니다" "분석해보겠습니다" )
[ ]
- . (\`\`\`).
- **· ** 📌 🎯 💡 🚀 🧩 . . ··릿 .
- ·\`<think>\`·"Thinking Process:" 같은 hidden reasoning 절대 노출 금지.
- LLM .`;
/**
* Single-pass persona. · ·
@@ -192,16 +224,25 @@ Output rules:
Rules:
- / . "분석해보겠습니다" "좋은 질문입니다" .
- Korean. Plain markdown "#", "##" , "- " bullet . No tables, no HTML.
- . .
- Korean. Plain markdown.
- ** / ** (📌 🎯 💡 ). .
- . . 1~3 · prose .
- * * \`## 한 줄 요약\`\`##\` subheading 으로 분할 (사용자가 Readability 위해 요청한 룰). 표·불릿도 활용. 헤더에 이모지 사용 금지.
- (··) . .
- ·"Thinking:"·<think> .`;
async execute(input: string, context?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string> {
const role = (options?.config?.role as string | undefined) || 'section';
switch (role) {
case 'outline':
return this.callLLM(this.outlinePersona, this.buildOutlinePrompt(input, context), signal);
case 'outline': {
// 호출자(AgentEngine)가 사용자 config 의 chunkedMaxSections 값을
// options.config.maxSections 로 박아 넘긴다. 없으면 hard ceiling 사용
// (실행 안 되어야 할 코드 경로 — 안전망).
const maxN = (typeof options?.config?.maxSections === 'number' && options.config.maxSections > 0)
? Math.min(ChunkedWriter.MAX_SECTIONS_HARD_CEILING, Math.floor(options.config.maxSections as number))
: ChunkedWriter.MAX_SECTIONS_HARD_CEILING;
return this.callLLM(this.outlinePersona, this.buildOutlinePrompt(input, context, maxN), signal);
}
case 'polish':
return this.callLLM(this.polishPersona, this.buildPolishPrompt(input, options), signal);
case 'direct':
@@ -212,11 +253,11 @@ Rules:
}
}
private buildOutlinePrompt(userRequest: string, brainContext?: string): string {
private buildOutlinePrompt(userRequest: string, brainContext?: string, maxN: number = ChunkedWriter.MAX_SECTIONS_HARD_CEILING): string {
const ctx = brainContext && brainContext.trim().length > 0
? `\n\n[보조 지식 컨텍스트 — 답변에 직접 인용하기보단 분할 결정에만 참고]\n${brainContext.substring(0, 1200)}`
: '';
return `[사용자 요청 — 본문이 포함돼 있다면 그게 1차 자료입니다]\n${userRequest}${ctx}\n\n위 요청에 대한 답변을 ${ChunkedWriter.MAX_SECTIONS}개 이내의 섹션으로 어떻게 나눌지 JSON 배열로만 출력하세요.`;
return `[사용자 요청 — 본문이 포함돼 있다면 그게 1차 자료입니다]\n${userRequest}${ctx}\n\n[제약]\nMAX_N = ${maxN} — 절대 ${maxN}개 초과 금지.\n\n위 요청에 대한 답변을 ${maxN}개 이내의 섹션으로 어떻게 나눌지 JSON 배열로만 출력하세요.`;
}
private buildSectionPrompt(input: string, brainContext?: string, options?: AgentExecuteOptions): string {
+23
View File
@@ -147,6 +147,27 @@ export interface IAgentConfig {
* 0.30 30% input으로 .
*/
workflowAutoCtxFractionThreshold: number;
/**
* prompt ** Multi-Agent
* (· ). .
*
* 의도: 사용자가 "요약/리뷰" chunked
* LLM .
* .
*
* 50000 .
* OOM (Astra Settings ).
*/
chunkedSwitchTokens: number;
/**
* Chunked outline * *.
* LLM = 1(outline) + N(section) + 1(polish) = 2 + N.
* 3 5, 4 6.
*
* , . 3
* ("6회 이상은 과하다") . 1~10 clamp.
*/
chunkedMaxSections: number;
// ─── Stream 표시 ───
/**
* .
@@ -301,6 +322,8 @@ export function getConfig(): IAgentConfig {
workflowAutoCtxFractionThreshold: Math.max(0.05, Math.min(0.95,
cfg.get<number>('workflow.autoCtxFractionThreshold', 0.30)
)),
chunkedSwitchTokens: Math.max(1000, cfg.get<number>('chunkedSwitchTokens', 50000)),
chunkedMaxSections: Math.max(1, Math.min(10, cfg.get<number>('chunkedMaxSections', 3))),
liveStreamTokens: cfg.get<boolean>('liveStreamTokens', true),
outputFormat: ((): 'plain' | 'markdown' => {
const v = (cfg.get<string>('outputFormat', 'plain') || 'plain').trim().toLowerCase();
+21 -4
View File
@@ -260,16 +260,33 @@ export function stripMarkdownFormatting(text: string): string {
});
// 3. 줄 단위 정리.
src = src.split('\n').map((rawLine) => {
// 헤더가 strip 되면 라벨 텍스트만 남는데, 다음 본문 줄과 시각적으로 *분리* 되어야
// marked.parse 가 별도 단락으로 인식. 그래서 strip 시점에 *후속 빈 줄 보장* 플래그.
const stripped: string[] = [];
let pendingEnsureBlankAfter = false;
for (const rawLine of src.split('\n')) {
let line = rawLine;
let wasHeader = false;
// 줄 시작 헤더 마커 제거 ("## 핵심 요약" → "핵심 요약")
line = line.replace(/^\s{0,3}#{1,6}\s+/, '');
const headerHit = /^\s{0,3}#{1,6}\s+/.test(line);
if (headerHit) {
line = line.replace(/^\s{0,3}#{1,6}\s+/, '');
wasHeader = true;
}
// 줄 시작 blockquote 제거
line = line.replace(/^\s{0,3}>\s?/, '');
// 줄 시작 `* ` 또는 `+ ` 불릿 → `- ` 로 통일
line = line.replace(/^(\s*)[*+]\s+/, '$1- ');
return line;
}).join('\n');
// 직전 줄이 strip 된 헤더였고, 지금 줄이 *빈 줄이 아니면* 그 사이에 빈 줄 1개 강제 삽입.
// marked.parse 는 빈 줄을 단락 구분으로 해석하므로 헤더가 본문과 시각 분리됨.
if (pendingEnsureBlankAfter && line.trim().length > 0) {
stripped.push('');
}
stripped.push(line);
pendingEnsureBlankAfter = wasHeader;
}
src = stripped.join('\n');
// 4. 강조 마커 제거.
src = src.replace(/\*\*(.+?)\*\*/g, '$1'); // **bold**
+50 -18
View File
@@ -800,13 +800,27 @@ function buildInfoExtractionPrompt(video: any, userContent: string): string {
, * *
(···) .
[ ]
1. () * * . ·· .
2. "본문에 명시되지 않음" . .
3. : \`[근거 명시]\` (구체 출처·수치·인용)·\`[화자 주장]\`
( )·\`[가정]\` (조건부 표현). 모든 핵심 주장에 라벨링.
4. mm:ss . : "…라고 말한다 (12:34)".
5. . ·릿 .
[ ]
1. ** ** () * * . ·
· \`## 🧩 정리자 노트\` 섹션에만. 두 줄 섞지 말 것.
2. ** ** "본문에 명시되지 않음" "해당 사례 없음".
3. ** ** :
- \`[근거 명시]\` 구체 출처·수치·인용이 본문에 있음
- \`[화자 주장]\` 출처 없는 단정 (디노가 그렇게 말함)
- \`[가정]\` 조건부·"~인 것 같다" 표현
- \`[정리자 추론]\` 본문에 없지만 정리자가 추가 (이건 정리자 노트 섹션 전용)
4. ** ** · · \`(mm:ss)\` 무조건 붙임.
fail. "(시점 미상)" .
5. ** + ** ··"X 는 Y 같은 것"
\`## 💡 화자 한 줄 비유\` 에 보존. 영상의 결정적 요약이 거기
. "본문에 명시된 한 줄 비유 없음" .
** ** "Hugging Face = 자료실, Reddit = 공부방"
( )
. . ··
.
6. **· ** "A → B → C 순서로" ** "
" . .
7. . ·릿 .
[ ]
\`\`\`json
@@ -816,17 +830,24 @@ ${JSON.stringify(slim, null, 2)}
[ ]
${trimmed}${userBlock}
[ . 6 ]
[ . 8 ]
# ${slim.title || video.title}
> ** URL**: ${slim.url} · ** **: ${today} · ****: ${slim.durationHms || (slim.durationSec ? formatHms(slim.durationSec) : '?')} · ****: ${slim.channel || '?'}
## 🎯 (TL;DR)
( . "무엇이 누구에게 왜 중요한가" . )
( . "무엇이 누구에게 왜 중요한가" .
. )
## 💡 (Anchor Metaphor)
* ·*
. . : "Hugging Face = , Reddit = ,
= " 같은 식. 없으면 " ".
## 📌 3~5
* ·* . + + (mm:ss).
** ·. ( 🧩 ).
+ + (mm:ss).
- **[ ]** "주장 한 줄" (mm:ss)
- **[ ]** "주장 한 줄" (mm:ss)
-
@@ -842,9 +863,16 @@ ${trimmed}${userBlock}
"본문에 명시된 구체 수치·출처 없음" .
## 🧭 (Sectioned Summary)
chapters () 30 * *. 1~2.
- **[00:0002:30]**
- **[02:3005:00]**
chapters ( ) 30
* *. 1~2. .
- **[00:0002:30]** (mm:ssmm:ss)
- **[02:3005:00]** (mm:ssmm:ss)
-
## 🔗 (Citation Snippets)
* * . ·· .
3~5. . .
- "직접 인용 한 문장" ${slim.title || video.title}, ${slim.channel || '?'} (mm:ss)
-
## (Open Questions)
@@ -853,11 +881,15 @@ ${trimmed}${userBlock}
- "본문에서 X 가 Y 라고 했지만 Z 데이터 출처는 명시 안 됨 — 원 데이터 찾아볼 것"
-
## 🔗 (Citation Snippets)
* * . ·· .
3~5. .
- "직접 인용 한 문장" ${slim.title || video.title}, ${slim.channel || '?'} (mm:ss)
- `;
## 🧩 ( )
* * ···. 6
, "이건 화자가 말한 게 아니라 LLM 이 추론한 거"
. \`[정리자 추론]\` 라벨로 시작.
- **[ ]** "여러 채널을 동시 시청" ,
.
-
"정리자 추가 노트 없음 — 본문 그대로가 명확함" .`;
}
/**
@@ -83,6 +83,8 @@ interface SettingsState {
maxAutoSteps: number;
maxContextSize: number;
chatTemperature: number;
chunkedSwitchTokens: number;
chunkedMaxSections: number;
};
datacollect: {
bridgeUrl: string;
@@ -585,6 +587,12 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
if (typeof msg.chatTemperature === 'number' && Number.isFinite(msg.chatTemperature)) {
await this._safeConfigUpdate('chatTemperature', Math.max(0, Math.min(2, msg.chatTemperature)));
}
if (typeof msg.chunkedSwitchTokens === 'number' && Number.isFinite(msg.chunkedSwitchTokens)) {
await this._safeConfigUpdate('chunkedSwitchTokens', Math.max(1000, Math.floor(msg.chunkedSwitchTokens)));
}
if (typeof msg.chunkedMaxSections === 'number' && Number.isFinite(msg.chunkedMaxSections)) {
await this._safeConfigUpdate('chunkedMaxSections', Math.max(1, Math.min(10, Math.floor(msg.chunkedMaxSections))));
}
}
// ────────────── Datacollect (slash 명령) ──────────────
@@ -657,6 +665,8 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
maxAutoSteps: cfg.get<number>('maxAutoSteps', 50) ?? 50,
maxContextSize: cfg.get<number>('maxContextSize', 32000) ?? 32000,
chatTemperature: cfg.get<number>('chatTemperature', 0.3) ?? 0.3,
chunkedSwitchTokens: cfg.get<number>('chunkedSwitchTokens', 50000) ?? 50000,
chunkedMaxSections: cfg.get<number>('chunkedMaxSections', 3) ?? 3,
},
datacollect: {
bridgeUrl: cfg.get<string>('datacollectBridgeUrl', '') || '',
+28 -6
View File
@@ -456,8 +456,14 @@ export class CacheManager {
* - Error Recovery Matrix Transient/Permanent
*/
export class AgentEngine {
/** Outline LLM이 제안한 N을 강제로 1..MAX_SECTIONS 로 clamp 한다. */
static readonly MAX_SECTIONS = 5;
/**
* Hard ceiling * config *. .
* `getConfig().chunkedMaxSections` (default 3).
* Astra Settings 1~10 .
*
* factory.ts ChunkedWriter.MAX_SECTIONS_HARD_CEILING .
*/
static readonly MAX_SECTIONS_HARD_CEILING = 10;
/**
* writer agent outline / section / polish
@@ -526,18 +532,28 @@ export class AgentEngine {
// --- Phase 1: Outline ---
// 1번의 LLM 호출로 답변을 몇 개 섹션으로 쪼갤지 결정. JSON 배열 반환.
// 사용자 config 의 chunkedMaxSections 를 outline persona 에 전달 — outline
// LLM 이 그 상한을 지키도록 prompt 에 박힘. parseOutline 의 cap 도 같은
// 값 사용해서 LLM 이 룰 어겨도 강제로 자름.
const cfgMaxSections = (() => {
try {
const { getConfig } = require('../config') as typeof import('../config');
const v = getConfig().chunkedMaxSections;
return Math.max(1, Math.min(AgentEngine.MAX_SECTIONS_HARD_CEILING, v ?? 3));
} catch { return 3; } // 안전 fallback
})();
const outlineRaw = await this.executeStep(
state, 'outline', '답변 구조 잡는 중...',
() => this.resilientExecute(state, this.writer, 'Outline', prompt, brainContext, signal, onProgress, {
...options,
context: brainContext,
signal,
config: { ...options?.config, role: 'outline' },
config: { ...options?.config, role: 'outline', maxSections: cfgMaxSections },
}),
`outline::${prompt}`, brainContext, signal, onProgress
);
const outline = this.parseOutline(outlineRaw);
const outline = this.parseOutline(outlineRaw, cfgMaxSections);
const sections = outline.sections;
// outline 이 빈 배열(`reason === 'empty'`)을 반환했다면 LLM 이
@@ -920,10 +936,16 @@ export class AgentEngine {
* ( empty fallback
* parse single-pass ).
*/
private parseOutline(raw: string): {
private parseOutline(raw: string, cap?: number): {
sections: Array<{ heading: string; scope: string }>;
reason: 'ok' | 'empty' | 'fallback';
} {
// cap 미지정 시 hard ceiling 으로 안전 보호. 정상 호출 경로에선 호출자가 사용자
// config 값 (chunkedMaxSections) 을 전달함.
const effectiveCap = Math.max(1, Math.min(
AgentEngine.MAX_SECTIONS_HARD_CEILING,
cap ?? AgentEngine.MAX_SECTIONS_HARD_CEILING,
));
const fallbackSections = [{ heading: '본문', scope: '사용자 요청 전체를 다루는 단일 섹션' }];
if (!raw || !raw.trim()) {
return { sections: fallbackSections, reason: 'fallback' };
@@ -949,7 +971,7 @@ export class AgentEngine {
}))
.filter((o) => o.heading.length > 0);
if (cleaned.length === 0) return null;
return { kind: 'sections', list: cleaned.slice(0, AgentEngine.MAX_SECTIONS) };
return { kind: 'sections', list: cleaned.slice(0, effectiveCap) };
} catch { return null; }
};