Files
connectai/src/retrieval/conflictBlock.ts
T
koriweb 990ea0ae5f feat: v2.2.173-193 — 4인 팀 운영 슬래시 13개 + ASTRA 검증 엔진 6종
4인 팀 운영 슬래시 (v2.2.173~189):
- 일과 리듬: /morning, /evening, /weekly, /standup
- 트래커 (event-sourced .astra/*.jsonl): /runway, /customers, /hire
- 작업·결정: /task, /blocked, /onesie, /decisions
- 외부 출력: /draft, /feedback
- 분석: /cohort (MoM 추세)

ASTRA 추론·검색 엔진 (v2.2.183~192):
- v2.2.183 Conflict Surface — scoring.conflictSeverity 를 [CONFLICT WARNINGS] 블록으로
  서피스 + 교차-문서 발산(Jaccard) 감지
- v2.2.184 Chain-of-Verification — [VERIFICATION CHECKLIST] 답변 작성 전 그라운딩 자기 점검
  (instructional, strictMode 옵션)
- v2.2.185 Actionability Scoring — 최근 슬래시 명령 + 열린 파일 신호로 검색 결과 재가중
- v2.2.186 Temporal Markers + Distillation Loop — LongTerm/Episodic 만료 필터 +
  30일+ stale episode → LongTerm 'episode-digest' 승급 (수동 /memory distill + 세션 종료 자동)
- v2.2.187 Hierarchical Context Window + LLM Semantic Re-rank — 3-level 추상도 매칭
  + 토큰 예산 통과 후 LLM 1회로 의도-부합 재정렬 (opt-in)
- v2.2.190 Intent Clarification + Citation Trace — 모호 차원 감지 시 역질문 우선
  + 답변 끝 사용 출처 한 줄 정리
- v2.2.191 Post-hoc Self-Check — 답변 완료 후 별도 LLM 호출 1회로 답함/그라운딩/모순 평가,
  footer 한 줄로 표시 (opt-in, semantic re-rank 와 같은 안전 fallback 패턴)
- v2.2.192 Terminology Dictionary — .astra/glossary.md 사용자 편집 파일 + Term Check
  지침 통합 + /glossary init/path/reload
- v2.2.193 /help — 카테고리별 명령 목록 + 6종 verification 블록 현재 on/off

신규 모듈:
- src/retrieval/{conflictBlock,coveBlock,actionabilityScoring,hierarchicalLevel,
  semanticRerank,intentClarification,citationTrace,terminologyBlock}.ts
- src/memory/distillation.ts + types.ts 에 expiresAt/promoted/episode-digest 추가
- src/agent/postHocSelfCheck.ts
- src/features/{customers,feedback,hire,runway}/*.ts (event-sourced stores)

ASTRA 검증 5종 자동 주입 (buildAstraModeSystemPrompt, casual 모드 제외):
[INTENT CLARIFICATION GUIDANCE] (답변 시작 전) → [TERMINOLOGY DICTIONARY] +
[CONFLICT WARNINGS] + [VERIFICATION CHECKLIST] (작성 중) → [CITATION TRACE] (끝)
+ 6번째: Post-hoc Self-Check footer (답변 완료 후, opt-in)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 16:05:30 +09:00

205 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Conflict Surface — [CONFLICT WARNINGS] 시스템 프롬프트 블록 생성.
*
* 기존 scoring.ts 가 문서당 conflictSeverity(NONE/LOW/MEDIUM/HIGH) 를 *이미*
* 계산하지만(반대/논란/vs 등 indicator 단어 카운트), LLM 은 그 사실을 모름.
* buildAstraModeSystemPrompt 의 v4 정책 텍스트는 이미 "[CONFLICT WARNING] 플래그"
* 를 *언급*하나, 실제 어떤 문서가 충돌인지 LLM 에게 *전달되지 않음* — 정책이
* 명시되어 있지만 데이터가 없어 무용한 상태.
*
* 이 모듈이 그 갭을 메움:
* 1. 자기-신호(self-flag) — chunk.metadata.conflictSeverity ≥ threshold
* 2. 교차-문서 발산(cross-divergence) — 같은 주제 2 chunks, Jaccard < 임계
*
* 둘을 합쳐 마크다운 블록 한 개로. 결과가 비면 빈 문자열 반환 — 호출자가
* 안전하게 무조건 join 가능.
*/
import { RetrievalChunk, ConflictSeverity } from './types';
import { tokenize } from './scoring';
/** 사용자 설정 임계값. 'low' = LOW 부터, 'medium' = MEDIUM 부터, 'high' = HIGH 만. */
export type ConflictThresholdSetting = 'low' | 'medium' | 'high';
export interface ConflictBlockOptions {
/** 자기-신호 surface 시 최소 severity. 기본 'medium'. */
selfFlagThreshold: ConflictThresholdSetting;
/** 교차 발산 감지 enable. 기본 true. */
crossDivergenceEnabled: boolean;
/** 자기-신호 / 교차 발산 각각 표시 최대 건수. 기본 5. */
maxPerSection: number;
/** Chunk 미리보기 길이. 기본 220 chars. */
excerptLength: number;
}
const DEFAULT_OPTIONS: ConflictBlockOptions = {
selfFlagThreshold: 'medium',
crossDivergenceEnabled: true,
maxPerSection: 5,
excerptLength: 220,
};
function severityRank(s: ConflictSeverity | undefined): number {
switch (s) {
case 'HIGH': return 3;
case 'MEDIUM': return 2;
case 'LOW': return 1;
default: return 0;
}
}
function thresholdRank(t: ConflictThresholdSetting): number {
switch (t) {
case 'high': return 3;
case 'medium': return 2;
case 'low': return 1;
}
}
function severityEmoji(s: ConflictSeverity | undefined): string {
switch (s) {
case 'HIGH': return '🔴';
case 'MEDIUM': return '🟡';
case 'LOW': return '🟠';
default: return '⚪';
}
}
function shortExcerpt(text: string, n: number): string {
if (!text) return '';
const cleaned = text.replace(/\s+/g, ' ').trim();
return cleaned.length <= n ? cleaned : cleaned.slice(0, n) + '…';
}
/** 두 토큰 집합의 Jaccard 유사도. */
function jaccard(a: Set<string>, b: Set<string>): number {
if (a.size === 0 || b.size === 0) return 0;
let intersect = 0;
for (const t of a) if (b.has(t)) intersect++;
const union = a.size + b.size - intersect;
return union === 0 ? 0 : intersect / union;
}
/**
* 교차-문서 발산 후보 쌍 찾기.
*
* 휴리스틱:
* 1. 각 chunk 의 title 토큰(최대 5개) 으로 "주제 키" 생성
* 2. 동일 주제 키 2개 이상 공유하는 chunk 쌍을 후보로
* 3. 본문 토큰 Jaccard < 0.30 이면 발산으로 판정 (같은 주제 다른 내용)
* 4. 점수 = (공유 토픽 토큰 수) × (1 - Jaccard) — 발산이 클수록 우선
*
* 한 chunk 가 여러 쌍에 등장 가능 — 상위 N 쌍만 반환.
*/
interface DivergencePair {
a: RetrievalChunk;
b: RetrievalChunk;
sharedTopicTokens: string[];
contentJaccard: number;
score: number;
}
function findCrossDivergence(chunks: RetrievalChunk[], topicJaccardMax: number = 0.30): DivergencePair[] {
if (chunks.length < 2) return [];
// Pre-compute title topic tokens + content token sets — n^2 비교 전에 한 번만.
const titleTokenSets: Set<string>[] = [];
const contentTokenSets: Set<string>[] = [];
for (const c of chunks) {
const titleTokens = tokenize(c.title || '').filter((t) => t.length >= 2);
titleTokenSets.push(new Set(titleTokens.slice(0, 8)));
contentTokenSets.push(new Set(tokenize(c.content || '')));
}
const pairs: DivergencePair[] = [];
for (let i = 0; i < chunks.length; i++) {
for (let j = i + 1; j < chunks.length; j++) {
// 1. 같은 주제 — title 토큰 공유 ≥ 2
const shared: string[] = [];
for (const t of titleTokenSets[i]) if (titleTokenSets[j].has(t)) shared.push(t);
if (shared.length < 2) continue;
// 2. 본문 발산 — Jaccard < 임계
const cj = jaccard(contentTokenSets[i], contentTokenSets[j]);
if (cj >= topicJaccardMax) continue;
pairs.push({
a: chunks[i],
b: chunks[j],
sharedTopicTokens: shared,
contentJaccard: cj,
score: shared.length * (1 - cj),
});
}
}
pairs.sort((p, q) => q.score - p.score);
return pairs;
}
/**
* 시스템 프롬프트용 [CONFLICT WARNINGS] 블록 생성. 충돌 없으면 빈 문자열 반환.
*
* 호출 측은 무조건 join 해도 안전 — 빈 문자열이면 프롬프트에 추가 줄바꿈 없음.
*/
export function buildConflictWarningsBlock(
chunks: RetrievalChunk[],
options: Partial<ConflictBlockOptions> = {},
): string {
const opts: ConflictBlockOptions = { ...DEFAULT_OPTIONS, ...options };
if (!chunks || chunks.length === 0) return '';
// ─── Section 1: self-flag ───
const threshold = thresholdRank(opts.selfFlagThreshold);
const selfFlagged = chunks
.filter((c) => severityRank(c.metadata?.conflictSeverity) >= threshold)
.sort((a, b) => severityRank(b.metadata?.conflictSeverity) - severityRank(a.metadata?.conflictSeverity))
.slice(0, opts.maxPerSection);
// ─── Section 2: cross-doc divergence ───
const divergence = opts.crossDivergenceEnabled
? findCrossDivergence(chunks).slice(0, opts.maxPerSection)
: [];
if (selfFlagged.length === 0 && divergence.length === 0) return '';
const lines: string[] = [];
lines.push('[CONFLICT WARNINGS]');
lines.push('다음 검색된 출처에서 충돌 신호 감지. 단일 결론을 강요하지 말고, 상충되는 관점을 명시하고 사용자 판단에 위임할 것.');
lines.push('');
if (selfFlagged.length > 0) {
lines.push('## 자기-신호 (출처 내부에서 충돌/논란 키워드 감지)');
for (const c of selfFlagged) {
const sev = c.metadata?.conflictSeverity || 'NONE';
const emoji = severityEmoji(sev);
const src = c.source;
const title = c.title || '(제목 없음)';
lines.push(`- ${emoji} **[${sev}]** \`${src}\` · ${title}`);
lines.push(` > ${shortExcerpt(c.content, opts.excerptLength)}`);
}
lines.push('');
}
if (divergence.length > 0) {
lines.push('## 교차-문서 발산 (같은 주제·다른 내용 ─ 잠재적 모순)');
for (const p of divergence) {
const topics = p.sharedTopicTokens.slice(0, 5).join(' · ');
const cjPct = (p.contentJaccard * 100).toFixed(0);
lines.push(`- 🔀 **공유 주제**: ${topics} _(본문 중복 ${cjPct}%)_`);
lines.push(` - A: \`${p.a.source}\` · ${p.a.title || '(제목 없음)'}`);
lines.push(` > ${shortExcerpt(p.a.content, opts.excerptLength)}`);
lines.push(` - B: \`${p.b.source}\` · ${p.b.title || '(제목 없음)'}`);
lines.push(` > ${shortExcerpt(p.b.content, opts.excerptLength)}`);
}
lines.push('');
}
lines.push('[지침]');
lines.push('1. 답변에 위 출처 중 하나라도 사용한다면, 충돌 가능성을 명시 (예: "출처 A 는 X 라 하나 출처 B 는 Y").');
lines.push('2. 어느 쪽이 옳다고 단정하지 말고, 사용자가 판단할 수 있도록 근거를 분리해 제시.');
lines.push('3. 충돌이 답변과 무관하면 무시 가능 — 다만 무관 판단 자체도 한 줄로 기록.');
lines.push('[/CONFLICT WARNINGS]');
return lines.join('\n');
}