diff --git a/package-lock.json b/package-lock.json index c96f940..e8a06d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e46ac22..48cc6ef 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/agent.ts b/src/agent.ts index 9b506e7..0c54500 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -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 가 되어 주간 회귀 테스트·약점 프로필의 원료가 된다. diff --git a/src/features/datacollect/bridgeClient.ts b/src/features/datacollect/bridgeClient.ts index f268706..5893398 100644 --- a/src/features/datacollect/bridgeClient.ts +++ b/src/features/datacollect/bridgeClient.ts @@ -59,6 +59,7 @@ export const BRIDGE_API = { }, wiki: { save: '/api/wiki/save', + template: '/api/wiki/template', }, lm: { proxy: '/api/lm', diff --git a/src/features/datacollect/handlers.ts b/src/features/datacollect/handlers.ts index c4c9c5e..d253e84 100644 --- a/src/features/datacollect/handlers.ts +++ b/src/features/datacollect/handlers.ts @@ -428,12 +428,21 @@ async function wikifyOne(url: string, userContent: string, view: Webview | undef } const model = (cfg.get('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(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`); diff --git a/src/features/datacollect/prompts/wikifyPrompt.ts b/src/features/datacollect/prompts/wikifyPrompt.ts index df1463e..5aa8075 100644 --- a/src/features/datacollect/prompts/wikifyPrompt.ts +++ b/src/features/datacollect/prompts/wikifyPrompt.ts @@ -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 { + 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)}`; } diff --git a/src/lib/contextBuilders/urlContext.ts b/src/lib/contextBuilders/urlContext.ts new file mode 100644 index 0000000..1c364dd --- /dev/null +++ b/src/lib/contextBuilders/urlContext.ts @@ -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 { + 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'); + } +} diff --git a/tests/urlContext.test.ts b/tests/urlContext.test.ts new file mode 100644 index 0000000..2f33d93 --- /dev/null +++ b/tests/urlContext.test.ts @@ -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('{{'); + }); +});