feat(wiki): /wikify 포맷 정본 통일 + 채팅 URL 실데이터 주입 (v2.2.229)

[포맷 통일 — Datacollect 가 정본]
/wikify 와 Datacollect research 가 각자 포맷 사본을 들고 어긋났던 문제.
더 최신인 Datacollect 포맷을 wiki_format.mjs 정본으로 추출(브리지 측)하고,
/wikify 는 GET /api/wiki/template 로 받아 소비. 구버전 브리지면 내장 사본
fallback (정본 v3.1과 동일 내용). 포맷 수정은 이제 wiki_format.mjs 한 곳.

/wikify 가 정본을 따르며 고쳐진 것:
- category "10_Wiki/Topics"(물리 경로 버그) → 논리 도메인 규칙
- 고정 신뢰도 B/0.8 → 소스 평가 동적 부여 (충돌 신뢰도 권고의 입력 품질)
- aliases 빈 배열 → 동의어 3-8개 강제 (어휘갭 검색 보완)
- "## 🔗 관련 문서 링크" → "## 🔗 지식 그래프" + 고아 방지 up-link
- 인라인 [S#] 출처 인용 + 📚 출처 섹션, 비교표·코드 패턴 조건 섹션

[채팅 URL 접근 — 강제 주입 패턴 4번째 적용]
일반 채팅에 URL 을 주면 "접근 불가"라고 답하던 공백: urlContext 가 URL 감지
시 브리지 /api/web-extract(기존 /wikify 인프라 재사용)로 본문 추출 →
컨텍스트 주입 (8K 캡, 잘림 시 /wikify 안내). 실패 시 정직 블록 (브리지 확인
안내 + 추측 금지). 슬래시 명령은 제외 (자체 처리). 주입 성공 로그 포함.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 13:42:43 +09:00
parent 925d91a4e5
commit bfb0d23a2f
8 changed files with 270 additions and 63 deletions
+64
View File
@@ -0,0 +1,64 @@
/**
* URL 컨텍스트 빌더 — 채팅 프롬프트에 URL 이 있으면 본문을 추출해 실데이터로 주입.
*
* 문제: /wikify 는 URL 에 접근하지만(브리지 /api/web-extract), 일반 채팅에 URL 을
* 주면 추출 경로가 없어 모델이 "접근할 수 없습니다"라고 답하거나 내용을 추측했다.
*
* 수정: 강제 주입 패턴의 4번째 적용 (일정→캘린더, 자기평가→인벤토리, 정정→캡처와
* 동일 설계). 이미 검증된 브리지 추출 인프라를 재사용 — 새 크롤러 없음.
* 실패 시(브리지 다운/추출 실패) 정직한 안내 블록 — 모델이 내용을 지어내지 않게.
*/
import { logInfo } from '../../utils';
import { bridgeFetch, BRIDGE_API } from '../../features/datacollect/bridgeClient';
const URL_RE = /https?:\/\/[^\s<>"'`)\]]+/i;
const MAX_BODY_CHARS = 8000;
const EXTRACT_TIMEOUT_MS = 45_000;
/**
* 프롬프트에서 추출 대상 URL 을 찾는다. 슬래시 명령(/wikify 등)은 자체 처리하므로
* 제외. 없으면 null.
*/
export function extractUrlFromPrompt(prompt: string): string | null {
const p = (prompt || '').trim();
if (!p || p.startsWith('/')) return null;
const m = URL_RE.exec(p);
if (!m) return null;
// 문장 끝 구두점이 URL 에 붙는 흔한 오염 제거.
return m[0].replace(/[.,;:!?…」』)]+$/, '');
}
/** URL 본문을 추출해 컨텍스트 블록 생성. 실패해도 throw 하지 않는다 (정직 블록 반환). */
export async function buildUrlContext(url: string): Promise<string> {
try {
const data = await bridgeFetch<{ success: boolean; title?: string; description?: string; text?: string; textLength?: number; truncated?: boolean }>(
BRIDGE_API.web.extract,
{ method: 'POST', body: JSON.stringify({ url }) },
{ timeoutMs: EXTRACT_TIMEOUT_MS },
);
const body = String(data?.text || '').slice(0, MAX_BODY_CHARS);
if (!body.trim() || body.trim().length < 50) {
return [
`[URL CONTENT — ${url}]`,
'상태: 본문 추출 실패 (콘텐츠 없음 또는 JS 전용 렌더링).',
'→ 사용자에게 이 URL 의 본문을 가져오지 못했다고 정직하게 알리고, 내용을 추측해 답하지 마라.',
].join('\n');
}
logInfo('URL 컨텍스트 주입.', { url, chars: body.length, truncated: !!data?.truncated });
return [
`[URL CONTENT — 실데이터 · ${url}]`,
`제목: ${data?.title || '(없음)'}`,
data?.description ? `설명: ${data.description}` : '',
'아래 본문만 근거로 답하라. 본문에 없는 내용은 "본문에서 확인되지 않음"이라고 답하고 지어내지 마라.' +
(body.length >= MAX_BODY_CHARS || data?.truncated ? ' (본문 일부 잘림 — 전체가 필요하면 /wikify 사용을 안내하라.)' : ''),
'',
body,
].filter(Boolean).join('\n');
} catch (e: any) {
return [
`[URL CONTENT — ${url}]`,
`상태: 접근 실패 — ${String(e?.message ?? e).slice(0, 120)}`,
'→ Datacollect 브리지(:3002)가 실행 중인지 확인하라고 사용자에게 안내하고, URL 내용을 추측해 답하지 마라.',
].join('\n');
}
}