/** * ============================================================ * 위키 문서 포맷 — 단일 정본 모듈 (v2.2.278) * * 배경: 위키 문서를 만드는 기능이 늘 때마다(/wikify → /meet → /email → …) * 포맷이 하나씩 새로 생기는 파편화가 진행됐다. 이 모듈이 그 흐름을 끊는다 — * **앞으로 위키 문서를 만드는 모든 기능은 여기만 쓴다.** * * 2층 구조: * ① 봉투(frontmatter) 통일 — `ensureFrontmatter()` * 저장되는 모든 문서에 정본 frontmatter 를 보장한다. LLM 이 정본 규격으로 * 이미 생성한 문서(--- 시작)는 건드리지 않고, 보고서형 문서(/meet 회의록, * /benchmark, /review, /youtube)에는 결정론 봉투를 씌운다. * → wikiSave.persistWikiDoc 이 저장 직전 자동 호출 — 호출자는 신경 쓸 것 없음. * ② 지식 문서 프롬프트 공용 빌더 — `buildKnowledgeWikiPrompt()` * P-Reinforce 정본 템플릿(frontmatter+섹션)에 소스 종류별 특수 규칙만 * 데이터로 주입한다. 새 소스 추가 = KnowledgeSource 하나 정의가 전부. * * 정본 템플릿은 Datacollect 브리지 wiki_format.mjs (getCanonicalFormat 이 * 10분 캐시로 fetch, 실패 시 wikifyPrompt 의 내장 사본 fallback). * ============================================================ */ import { bridgeFetch, BRIDGE_API } from './bridgeClient'; import type { CanonicalWikiFormat } from './prompts/wikifyPrompt'; // ── 정본 템플릿 fetch (일원화 + 캐시) ──────────────────────────────────────── let _canonicalCache: { fmt: CanonicalWikiFormat | null; at: number } | undefined; const CANONICAL_TTL_MS = 10 * 60_000; /** 브리지의 포맷 정본. 실패 시 null (호출부 빌더가 내장 사본으로 fallback). */ export async function getCanonicalFormat(): Promise { if (_canonicalCache && Date.now() - _canonicalCache.at < CANONICAL_TTL_MS) return _canonicalCache.fmt; let fmt: CanonicalWikiFormat | null = null; try { fmt = await bridgeFetch(BRIDGE_API.wiki.template, { method: 'GET' }, { timeoutMs: 5000 }); if (!fmt || !fmt.commonRules || !fmt.frontmatter || !fmt.sections) fmt = null; } catch { fmt = null; } _canonicalCache = { fmt, at: Date.now() }; return fmt; } // ── ① 봉투(frontmatter) 통일 ───────────────────────────────────────────────── export interface EnvelopeOpts { /** 문서 종류 — frontmatter category·tags 로 들어감 (예: 'meeting', 'benchmark', 'review', 'youtube'). */ docType: string; /** raw_sources 항목 (URL·파일명·"email:제목" 등). */ sources?: string[]; /** 변경 이력 첫 줄 문구. 생략 시 docType 기반 기본 문구. */ origin?: string; } /** title → frontmatter id 슬러그 (wikifyPrompt 의 규칙과 동일). */ export function slugifyId(title: string, fallback: string): string { return (title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9가-힣-]/g, '').slice(0, 80)) || fallback; } /** 문서가 이미 frontmatter(---) 로 시작하는지 — 코드펜스·공백 관대 판정. */ export function hasFrontmatter(content: string): boolean { const t = (content || '').trimStart(); return t.startsWith('---') || t.replace(/^```(?:markdown|md)?\s*/i, '').trimStart().startsWith('---'); } /** * 정본 frontmatter 봉투 보장 — 이미 있으면 원문 그대로, 없으면 결정론 봉투 prepend. * 봉투 필드는 정본(wiki_format.mjs v3.1) frontmatter 와 같은 이름을 사용해 * 다운스트림(brain 인덱스·그래프·신뢰도 소비자)이 문서 종류와 무관하게 동작한다. */ export function ensureFrontmatter(title: string, content: string, opts: EnvelopeOpts): string { if (hasFrontmatter(content)) return content; const today = new Date().toISOString().slice(0, 10); const sources = (opts.sources || []).map(s => JSON.stringify(String(s))); const fm = [ '---', `id: ${slugifyId(title, opts.docType + '-doc')}`, `title: ${JSON.stringify(title)}`, `category: ${JSON.stringify(opts.docType)}`, 'status: "draft"', 'verification_status: "conceptual"', 'aliases: []', 'source_trust_level: "B"', 'confidence_score: 0.80', `created_at: ${today}`, `updated_at: ${today}`, `tags: ["${opts.docType}", "astra"]`, `raw_sources: [${sources.join(', ')}]`, '---', '', ].join('\n'); return fm + content + `\n\n## 📝 변경 이력 (Change history)\n- ${today}: ${opts.origin || `Astra ${opts.docType} 산출물로 생성.`}\n`; } // ── ② 지식 문서 프롬프트 공용 빌더 ─────────────────────────────────────────── /** * 지식화(위키화) 대상 소스 기술자 — 새 소스 종류는 이 객체 하나로 추가한다. * (예: 슬랙 스레드 지식화를 만들면 kind:'slack' + 특수 규칙 몇 줄이 전부.) */ export interface KnowledgeSource { /** 소스 종류 라벨 — tags·출처 평가 힌트에 사용. */ kind: string; /** 문서 주제(제목). */ topic: string; /** 근거 원문 (추출 본문·트랜스크립트 등). 빌더가 30,000자 컷. */ body: string; /** [소스 메타] 블록에 표시할 키-값 (URL·참여자·기간 등). */ meta?: Record; /** frontmatter tags (kind 는 자동 포함). */ tags?: string[]; /** raw_sources / [S1] 표기 값 (예: URL, "email:제목"). */ sourceRef: string; /** 소스 종류별 특수 규칙 (번호는 빌더가 5번부터 이어붙임). */ extraRules?: string[]; /** source_trust_level 평가 힌트 한 줄. */ trustHint?: string; /** 변경 이력 문구. */ origin?: string; } /** 정본 골격 placeholder 치환 (wikifyPrompt.fill 과 동일 규칙). */ function fill(tpl: string, vars: Record): string { return tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? ''); } /** * P-Reinforce 정본 지식 문서 프롬프트 — 모든 지식화 기능의 단일 진입점. * canonical 미제공(null) 시 wikifyPrompt 내장 사본 사용. */ export function buildKnowledgeWikiPrompt(src: KnowledgeSource, canonical?: CanonicalWikiFormat | null): string { // 내장 사본은 wikifyPrompt 가 소유 — 순환 없이 lazy require. const { EMBEDDED_FALLBACK } = require('./prompts/wikifyPrompt') as { EMBEDDED_FALLBACK: CanonicalWikiFormat }; const fmt = canonical && canonical.commonRules && canonical.frontmatter && canonical.sections ? canonical : EMBEDDED_FALLBACK; const today = new Date().toISOString().slice(0, 10); const topic = (src.topic || `${src.kind} 지식`).trim(); const tags = [...new Set([src.kind, ...(src.tags || [])])].map(t => `"${t}"`).join(', '); const vars = { ID: slugifyId(topic, `${src.kind}-wiki`), TITLE: topic, TOPIC: topic, ROOT: topic, TODAY: today, TAGS: tags, SOURCES: `[${JSON.stringify(src.sourceRef)}]`, ORIGIN: src.origin || `Astra ${src.kind} 지식화로 초안 생성.`, }; const baseRules = [ `1. 반드시 아래 [소스 본문]의 내용만을 근거로 작성하시오. 외부 지식을 절대 섞지 마시오.`, `2. 반드시 아래 Markdown 템플릿과 Frontmatter 형식을 정확히 따르시오. 섹션 이름과 구조를 변경하지 마시오.`, `3. 본문에 없는 정보는 지어내지 말고, 해당 섹션에 "소스에서 확인되지 않음"이라고 명시하시오. 수치·날짜·금액·고유명사는 원문 그대로 보존하시오.`, `4. 한국어로 작성하시오. 관련 개념·고유명사는 [[대괄호 두 개]]로 감싸 위키 링크로 만드시오. 위키 링크는 반드시 \`[[\` 로 열고 \`]]\` 로 닫으시오.`, ]; const extra = (src.extraRules || []).map((r, i) => `${5 + i}. ${r}`); const trustRule = `${5 + extra.length}. 이 문서의 출처는 ${src.kind} 소스 1건이므로 '## 📚 출처'의 [S1]은 ${src.sourceRef} 를 가리키며, source_trust_level 은 ${src.trustHint || '소스 성격에 따라 정직하게'} 평가하시오.`; const metaLines = Object.entries(src.meta || {}).map(([k, v]) => `- ${k}: ${v || '(없음)'}`); return `임무: 아래 ${src.kind} 소스를 근거로 P-Reinforce v${fmt.version.replace('-embedded', '')} 규격에 맞춰 고밀도 지식 문서를 작성하시오. 주제: '${topic}' [필수 규칙] ${[...baseRules, ...extra, trustRule].join('\n')} ${fill(fmt.commonRules, vars)} [소스 메타] ${metaLines.join('\n') || '- (메타 없음)'} [소스 본문] \`\`\` ${String(src.body || '').slice(0, 30000)} \`\`\` [출력 템플릿 - 이 형식을 정확히 따르시오] ${fill(fmt.frontmatter, vars)} ${fill(fmt.sections, vars)}`; }