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
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "astra",
"version": "2.2.228",
"version": "2.2.229",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "astra",
"version": "2.2.228",
"version": "2.2.229",
"license": "MIT",
"dependencies": {
"@lmstudio/sdk": "^1.5.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "astra",
"displayName": "Astra",
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
"version": "2.2.228",
"version": "2.2.229",
"publisher": "g1nation",
"license": "MIT",
"icon": "assets/icon.png",
+15
View File
@@ -21,6 +21,7 @@ import { AgentWorkflowManager } from './agents/AgentWorkflowManager';
import { buildAstraModeArchitectureContext } from './lib/contextBuilders/astraModeArchitecture';
import { isScheduleRequest, buildScheduleContext } from './lib/contextBuilders/scheduleContext';
import { isSelfAssessRequest, buildSelfAssessContext } from './lib/contextBuilders/selfAssessContext';
import { extractUrlFromPrompt, buildUrlContext } from './lib/contextBuilders/urlContext';
import { looksLikeCorrection, captureCorrection } from './intelligence/correctionLoop';
import { shouldUseMultiAgentWorkflow } from './lib/contextBuilders/multiAgentRouting';
import { buildThinkingPartnerResponseContract } from './lib/contextBuilders/thinkingPartnerContract';
@@ -553,6 +554,20 @@ export class AgentExecutor {
}
}
// [URL 실데이터] 채팅 프롬프트에 URL 이 있으면 브리지로 본문을 추출해 주입.
// /wikify 만 URL 접근이 가능하고 일반 채팅은 "접근 불가"라고 답하던 공백 수정.
if (prompt && loopDepth === 0 && !isCasualConversation) {
const url = extractUrlFromPrompt(prompt);
if (url) {
try {
contextBlock += `\n\n${await buildUrlContext(url)}`;
logInfo('URL 컨텍스트 주입 시도.', { url });
} catch (e: any) {
logError('URL 컨텍스트 주입 실패 (계속 진행).', { error: e?.message ?? String(e) });
}
}
}
// [Correction Loop ①] 이 발화가 직전 답변에 대한 *정정*이면 fire-and-forget
// 캡처 — 오류 분류 → 태깅 레슨 + 회귀 케이스(.astra/eval/corrections.jsonl).
// 정정 자체가 Ground Truth 가 되어 주간 회귀 테스트·약점 프로필의 원료가 된다.
+1
View File
@@ -59,6 +59,7 @@ export const BRIDGE_API = {
},
wiki: {
save: '/api/wiki/save',
template: '/api/wiki/template',
},
lm: {
proxy: '/api/lm',
+11 -2
View File
@@ -428,12 +428,21 @@ async function wikifyOne(url: string, userContent: string, view: Webview | undef
}
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
const wikiSystem = '당신은 지식 큐레이터입니다. 제공된 웹사이트 본문을 P-Reinforce v3.0 규격의 고밀도 위키 문서로 정리합니다. 본문에 없는 내용은 절대 지어내지 않으며, 모든 문서는 한국어로 작성합니다.';
const wikiSystem = '당신은 지식 큐레이터입니다. 제공된 웹사이트 본문을 P-Reinforce 규격의 고밀도 위키 문서로 정리합니다. 본문에 없는 내용은 절대 지어내지 않으며, 모든 문서는 한국어로 작성합니다.';
// 포맷 정본을 브리지에서 가져온다 (wiki_format.mjs — research 와 동일 포맷 보장).
// 구버전 브리지(엔드포인트 없음)면 wikifyPrompt 의 내장 사본 fallback.
let canonicalFormat: import('./prompts/wikifyPrompt').CanonicalWikiFormat | null = null;
try {
canonicalFormat = await bridgeFetch<any>(BRIDGE_API.wiki.template, { method: 'GET' }, { timeoutMs: 5000 });
chunk(view, `📐 포맷 정본 v${canonicalFormat?.version ?? '?'} (브리지)\n`);
} catch {
chunk(view, `📐 포맷 내장 사본 사용 (브리지 템플릿 미제공 — 브리지 갱신 시 정본 적용)\n`);
}
chunk(view, `🧪 P-Reinforce 위키 합성 (모델 \`${model}\`)…`);
let report: string;
try {
const synthT0 = Date.now();
report = await callLmSynthesis(buildWikifyPrompt(data, userContent), wikiSystem);
report = await callLmSynthesis(buildWikifyPrompt(data, userContent, canonicalFormat), wikiSystem);
if (!report) throw new Error('LLM 응답이 비어 있습니다.');
report = report.replace(/\[\[([^\[\]]+?)\](?!\])/g, '[[$1]]');
chunk(view, ` ✓ (${Math.round((Date.now() - synthT0) / 1000)}s)\n\n`);
+128 -58
View File
@@ -1,8 +1,119 @@
/**
* 추출된 웹사이트 본문 → Datacollect Research(P-Reinforce v3.0)와 동일한 위키 문서
* 프롬프트. Bridge의 /api/research/synthesize 템플릿을 웹 본문 소스용으로 이식.
* 추출된 웹사이트 본문 → P-Reinforce 위키 문서 프롬프트.
*
* 포맷 정본은 Datacollect 브리지의 wiki_format.mjs — 핸들러가
* GET /api/wiki/template 로 받아 `canonical` 로 주입한다 (양쪽 포맷 통일).
* 브리지가 구버전(엔드포인트 없음)일 때만 아래 내장 사본(fallback)을 쓴다.
* 내장 사본은 정본을 따라 갱신하되, 차이가 생기면 정본이 항상 옳다.
*/
export function buildWikifyPrompt(extracted: any, userContent: string): string {
export interface CanonicalWikiFormat {
version: string;
/** 규칙 7-15 (별칭·출처·동적 신뢰도·그래프·논리 도메인 분류…), {{ROOT}} placeholder. */
commonRules: string;
/** Frontmatter 골격 — {{ID}} {{TITLE}} {{TODAY}} {{TAGS}} {{SOURCES}}. */
frontmatter: string;
/** 본문 섹션 골격 — {{TOPIC}} {{ROOT}} {{TODAY}} {{ORIGIN}}. */
sections: string;
}
/** 정본 골격의 placeholder 치환. */
function fill(tpl: string, vars: Record<string, string>): string {
return tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? '');
}
// ── 내장 fallback (정본 wiki_format.mjs v3.1 사본 — 브리지 구버전 대비) ────────
const FALLBACK_VERSION = '3.1-embedded';
const FALLBACK_COMMON_RULES = `
7. [별칭] Frontmatter 'aliases'에 동의어·약어·영문/국문 표기 3-8개를 YAML 리스트로 채우시오.
8. [출처] '세부 내용' 주장 끝에 [S1],[S2] 인라인 표기 + 문서 끝 '## 📚 출처'에 번호별 제목/URL 나열, Frontmatter 'raw_sources'에도 동일 기입(placeholder 금지).
9. [신뢰도] 소스 평가로 source_trust_level(S/A/B/C/D)·confidence_score(0.00-1.00) 부여(문서마다 다르게). 이 값은 지식 충돌 시 우선순위 판단에 사용되므로 정직하게 평가하시오.
10. [그래프] '## 🔗 지식 그래프' 항상 작성: 루트 [[{{ROOT}}]] + 관련 개념 2개+ 링크.
11. [분류] category는 폴더경로 말고 논리 도메인(AI_and_ML/Frontend/Architecture 등, 없으면 "Topic").
12. [비교] 대안·경쟁 기술이 있으면 '## ⚖️ 비교 및 선택 기준'에 표(|항목|장점|단점|언제 선택|), 없으면 생략.
13. [코드] 소스에 코드가 있으면 '## 💻 코드 패턴'에 최소 스니펫(언어/버전), 없으면 "소스에 코드 예시 없음".
14. [완결성] 빈 섹션·(TODO)·플레이스홀더 금지. 근거 없으면 "소스에서 확인되지 않음".
15. [인용정렬] 인라인 [S#]는 '## 📚 출처' 번호와 1부터 순차 일치.`;
const FALLBACK_FRONTMATTER = `---
id: {{ID}}
title: "{{TITLE}}"
category: "Topic"
status: "draft"
verification_status: "conceptual"
canonical_id: ""
aliases: []
duplicate_of: ""
source_trust_level: "B"
confidence_score: 0.85
created_at: {{TODAY}}
updated_at: {{TODAY}}
review_reason: ""
merge_history: []
tags: [{{TAGS}}]
raw_sources: {{SOURCES}}
applied_in: []
github_commit: ""
---`;
const FALLBACK_SECTIONS = `# [[{{TOPIC}}]]
## 🎯 한 줄 통찰 (One-line insight)
(이 주제의 핵심 가치를 관통하는 강력한 한 줄 정의)
## 🧠 핵심 개념 (Core concepts)
(이 주제를 구성하는 가장 중요한 3-5가지 핵심 기둥/개념)
## 🧩 추출된 패턴 (Extracted patterns)
(소스에서 발견된 반복되는 구조, 전략, 설계 패턴 또는 휴리스틱)
## ⚖️ 비교 및 선택 기준 (Comparison & decision criteria)
(이 주제에 대안·경쟁 기술이 있을 때만 작성. 대안이 없으면 이 섹션 전체를 생략.)
| 항목 (Option) | 장점 | 단점 | 언제 선택 |
|---|---|---|---|
## 📖 세부 내용 (Details)
(소스에서 합성된 상세하고 전문적인 설명. 논리적 단락이나 글머리 기호로 구분하시오.)
## ⚖️ 모순 및 업데이트 (Contradictions & updates)
(소스 내에서 상충되는 정보가 있거나, 기존 상식과 다른 최신 정보가 있다면 서술하시오.)
## 🛠️ 적용 사례 (Applied in summary)
(소스에서 실제 적용된 코드·커밋·프로젝트·결정이 발견되면 요약. 없으면 "현재 발견된 실제 적용 사례가 없습니다.")
## 💻 코드 패턴 (Code patterns)
(소스에 코드·구현·API 사용법이 있으면 최소 스니펫을 언어/버전과 함께. 없으면 "소스에 코드 예시 없음".)
## ✅ 검증 상태 및 신뢰도
- **상태:** draft
- **검증 단계:** conceptual (실제 적용 사례 발견 시 applied/validated로 승격 가능)
- **출처 신뢰도:** (소스 평가에 따라 S/A/B/C/D 중 부여 — Frontmatter source_trust_level과 일치)
- **신뢰 점수:** (0.00-1.00 평가값 — Frontmatter confidence_score와 일치)
- **중복 검사 결과:** 신규 생성 (New discovery)
## 🔗 지식 그래프 (Knowledge Graph)
(항상 작성 — 이 문서를 위키 그래프에 연결한다. 고아 문서 금지.)
- **상위/루트:** [[{{ROOT}}]]
- **관련 개념:** (직접 관련된 핵심 개념 2개 이상을 [[대괄호 두 개]]로.)
- **참조 맥락:** (이 지식이 어떤 작업·주제·결정에서 참조될지 한 줄로.)
## 📚 출처 (Sources)
('세부 내용'의 [S1], [S2] 인라인 표기와 번호를 일치시키시오.)
- [S1]
## 📝 변경 이력 (Change history)
- {{TODAY}}: {{ORIGIN}}`;
// ── 프롬프트 빌드 ────────────────────────────────────────────────────────────
export function buildWikifyPrompt(extracted: any, userContent: string, canonical?: CanonicalWikiFormat | null): string {
const fmt: CanonicalWikiFormat = canonical && canonical.commonRules && canonical.frontmatter && canonical.sections
? canonical
: { version: FALLBACK_VERSION, commonRules: FALLBACK_COMMON_RULES, frontmatter: FALLBACK_FRONTMATTER, sections: FALLBACK_SECTIONS };
const today = new Date().toISOString().slice(0, 10);
const topic = (userContent.trim() || extracted?.title || extracted?.url || '웹사이트 지식').trim();
const url = extracted?.url || '';
@@ -10,15 +121,23 @@ export function buildWikifyPrompt(extracted: any, userContent: string): string {
const headings = Array.isArray(extracted?.headings) ? extracted.headings.slice(0, 40) : [];
const body = String(extracted?.text || '').slice(0, 30000);
return `임무: 아래 웹사이트에서 추출한 본문을 근거로 P-Reinforce v3.0 규격에 맞춰 고밀도 지식 문서를 작성하시오.
const vars = {
ID: idSlug, TITLE: topic, TOPIC: topic, ROOT: topic, TODAY: today,
TAGS: '"web", "wikify"', SOURCES: `["${url}"]`,
ORIGIN: `Astra /wikify 로 ${url} 본문에서 초안 생성.`,
};
return `임무: 아래 웹사이트에서 추출한 본문을 근거로 P-Reinforce v${fmt.version.replace('-embedded', '')} 규격에 맞춰 고밀도 지식 문서를 작성하시오.
주제: '${topic}'
[필수 규칙]
1. 반드시 아래 [웹사이트 본문]의 내용만을 근거로 작성하시오. 외부 지식을 절대 섞지 마시오.
2. 반드시 아래 Markdown 템플릿과 Frontmatter 형식을 정확히 따르시오. 섹션 이름과 구조를 변경하지 마시오.
3. 본문에 없는 정보는 지어내지 말고, 해당 섹션에 "본문에서 확인되지 않음"이라고 명시하시오.
4. 한국어로 작성하시오. 관련 개념·고유명사는 [[대괄호 두 개]]로 감싸 위키 링크로 만드시오. 위키 링크는 반드시 \`[[\` 로 열고 \`]]\` 로 닫으시오 (닫는 대괄호를 빠뜨리지 마시오).
5. 원문이 JSON Schema·API 명세·기술 스펙·설정 레퍼런스인 경우, '📖 세부 내용'에 등장하는 모든 필드·속성·파라미터를 **누락 없이** 마크다운 표로 정리하시오. 표 컬럼은 [필드 | 타입 | 필수/선택 | 제약·설명]. 원문의 \`required\` 배열에 있는 항목만 '필수'로 표기하고, 그 목록을 임의로 바꾸거나 추가하지 마시오. \`additionalProperties\`·\`const\`·\`enum\` 같은 제약과 중첩 객체 구조도 원문 그대로 반영하시오. 최상위 필드를 하나라도 빠뜨리지 마시오.
3. 본문에 없는 정보는 지어내지 말고, 해당 섹션에 "소스에서 확인되지 않음"이라고 명시하시오.
4. 한국어로 작성하시오. 관련 개념·고유명사는 [[대괄호 두 개]]로 감싸 위키 링크로 만드시오. 위키 링크는 반드시 \`[[\` 로 열고 \`]]\` 로 닫으시오.
5. 원문이 JSON Schema·API 명세·기술 스펙·설정 레퍼런스인 경우, '📖 세부 내용'에 등장하는 모든 필드·속성·파라미터를 **누락 없이** 마크다운 표로 정리하시오. 표 컬럼은 [필드 | 타입 | 필수/선택 | 제약·설명]. 원문의 \`required\` 배열에 있는 항목만 '필수'로 표기하고, 그 목록을 임의로 바꾸거나 추가하지 마시오.
6. 이 문서의 출처는 웹 본문 1건이므로 '## 📚 출처'의 [S1]은 위 URL을 가리키며, source_trust_level 은 사이트 성격(공식 문서/학술 S~A, 기술 블로그 B, 커뮤니티/익명 C~D)으로 평가하시오.
${fill(fmt.commonRules, vars)}
[웹사이트 메타]
- URL: ${url}
@@ -33,56 +152,7 @@ ${body}
[출력 템플릿 - 이 형식을 정확히 따르시오]
---
id: ${idSlug}
title: "${topic}"
category: "10_Wiki/Topics"
status: "draft"
verification_status: "conceptual"
canonical_id: ""
aliases: []
duplicate_of: ""
source_trust_level: "B"
confidence_score: 0.8
created_at: ${today}
updated_at: ${today}
review_reason: ""
merge_history: []
tags: ["web", "wikify"]
raw_sources: ["${url}"]
applied_in: []
github_commit: ""
---
${fill(fmt.frontmatter, vars)}
# [[${topic}]]
## 🎯 한 줄 통찰 (One-line insight)
(이 웹사이트/주제의 핵심 가치를 관통하는 강력한 한 줄 정의)
## 🧠 핵심 개념 (Core concepts)
(본문을 구성하는 가장 중요한 3-5가지 핵심 개념/기둥)
## 🧩 추출된 패턴 (Extracted patterns)
(본문에서 발견된 반복되는 구조, 전략, 주장 또는 접근법)
## 📖 세부 내용 (Details)
(본문에서 합성된 상세하고 전문적인 설명. 논리적 단락이나 글머리 기호로 구분하시오. 원문이 명세·스키마·API 레퍼런스라면 위 규칙 5에 따라 모든 필드를 표로 빠짐없이 정리하시오.)
## ⚖️ 모순 및 업데이트 (Contradictions & updates)
(본문 내에서 상충되는 정보나 주목할 최신 정보가 있다면 서술. 없으면 "본문에서 확인되지 않음".)
## 🛠️ 적용 사례 (Applied in summary)
(본문에 구체적 사례·수치·제품·프로젝트·의사결정이 있으면 요약하여 기술. 없으면 "본문에서 확인되지 않음".)
## ✅ 검증 상태 및 신뢰도
- **상태:** draft
- **검증 단계:** conceptual
- **출처 신뢰도:** B (Primary Source — 웹사이트 본문 직접 추출)
- **중복 검사 결과:** 신규 생성 (New discovery)
## 🔗 관련 문서 링크 (Related document links)
(이 주제와 직접 연결되는 핵심 개념 3-7개를 [[위키링크]]로 제시하고, 각 링크마다 연결 이유를 한 줄로 적으시오. 본문에 등장한 개념을 우선 사용.)
## 📝 변경 이력 (Change history)
- ${today}: Astra /wikify 로 ${url} 본문에서 초안 생성.`;
${fill(fmt.sections, vars)}`;
}
+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');
}
}
+48
View File
@@ -0,0 +1,48 @@
/**
* URL 컨텍스트 + wikify 정본 포맷 — 순수 로직 테스트.
*/
import { extractUrlFromPrompt } from '../src/lib/contextBuilders/urlContext';
import { buildWikifyPrompt, type CanonicalWikiFormat } from '../src/features/datacollect/prompts/wikifyPrompt';
describe('extractUrlFromPrompt', () => {
test('URL 추출 + 끝 구두점 제거', () => {
expect(extractUrlFromPrompt('이 글 요약해줘 https://example.com/post.')).toBe('https://example.com/post');
expect(extractUrlFromPrompt('https://a.io/x?q=1 내용 확인')).toBe('https://a.io/x?q=1');
});
test('슬래시 명령은 제외 (/wikify 가 자체 처리)', () => {
expect(extractUrlFromPrompt('/wikify https://example.com')).toBeNull();
});
test('URL 없으면 null', () => {
expect(extractUrlFromPrompt('오늘 일정 알려줘')).toBeNull();
expect(extractUrlFromPrompt('')).toBeNull();
});
});
describe('buildWikifyPrompt — 정본/fallback 포맷', () => {
const extracted = { url: 'https://ex.com/doc', title: '문서', text: '본문 내용입니다. '.repeat(10), headings: ['h1'] };
test('정본 주입 시 placeholder 가 채워진다', () => {
const canonical: CanonicalWikiFormat = {
version: '9.9',
commonRules: '\n7. 규칙 루트={{ROOT}}',
frontmatter: '---\nid: {{ID}}\ntitle: "{{TITLE}}"\ntags: [{{TAGS}}]\nraw_sources: {{SOURCES}}\n---',
sections: '# [[{{TOPIC}}]]\n- {{TODAY}}: {{ORIGIN}}',
};
const p = buildWikifyPrompt(extracted, '테스트 주제', canonical);
expect(p).toContain('P-Reinforce v9.9');
expect(p).toContain('규칙 루트=테스트 주제');
expect(p).toContain('title: "테스트 주제"');
expect(p).toContain('raw_sources: ["https://ex.com/doc"]');
expect(p).not.toContain('{{'); // placeholder 잔존 금지
});
test('정본 없으면 내장 사본 — 현대화 항목 포함', () => {
const p = buildWikifyPrompt(extracted, '주제', null);
expect(p).toContain('category는 폴더경로 말고 논리 도메인'); // 물리 경로 버그 수정
expect(p).toContain("aliases'에 동의어"); // 별칭 강제
expect(p).toContain('지식 충돌 시 우선순위 판단'); // 동적 신뢰도 — 충돌 권고 입력
expect(p).toContain('## 🔗 지식 그래프'); // 섹션명 통일 (구 "관련 문서 링크" 대체)
expect(p).not.toContain('category: "10_Wiki/Topics"');
expect(p).not.toContain('{{');
});
});