"""이드 substrate compose — persona → rules → overlay → task 단일 system 문자열. 설계 정본 : PKM plans/2026-06-05-eid-persona-substrate-plan.html (eid-persona-substrate, r1~r3 수렴) 구현 plan : plans/2026-06-07-eid-persona-impl-plan.html (W2-1) 불변식 : memory project_eid_persona_substrate (load-bearing 9건) 핵심 불변식 (바꾸지 말 것 — 위반 = 설계 회귀): #3 "강력하게" = 출력계약 경계(균질주입 아님). 자유-prose 표면 = persona ON, STRICT JSON 기계류 = persona ZERO. 판정 = 정적 ROUTE_MAP(런타임 sniffing 아님). #4 합본 = persona → rules → overlay → task. rules 는 합본의 *명시 항*(compose 가 반드시 끼움) → 'rules 부재 = fail-loud' 성립. 충돌 시 rules > persona, overlay ≤ rules. persona 부재 = quiet fail-open / rules 부재 = fail-loud(degraded 배너 + 로그). #2 overlay 는 delta-only. injection 방어는 공통 rules(rules.md)에 있음(overlay 아님, never-dropped). 스코프: 사용자대면 자유-prose 표면만. STRICT JSON 기계류 9종은 ROUTE_MAP 부재 → compose 우회(task-only). 의존성: stdlib only (DB·yaml·LLM 불필요). 입력 = app/prompts/substrate/ 의 vendored 아티팩트. """ from __future__ import annotations import logging from functools import lru_cache from pathlib import Path logger = logging.getLogger("eid.compose") # vendored 아티팩트 (sync = app/prompts/substrate/README.md) _SUBSTRATE_DIR = Path(__file__).resolve().parent.parent / "prompts" / "substrate" _OVERLAY_DIR = _SUBSTRATE_DIR / "overlays" # 합본 구분자 — MLX 다중 system role 위험 회피용 단일 문자열 join (설계 0-3) SEP = "\n\n---\n\n" # variant → persona 아티팩트 파일명. 26B/27B = full, 4B = compact. _PERSONA_FILES = {"full": "persona.full.md", "compact": "persona.compact.md"} # rules 미주입 시 degraded 배너 (fail-loud — silent 빈문자열 금지, 불변식 #4) _RULES_DEGRADED = ( "[substrate-degraded: 운영 규칙(rules) 미주입 — 안전·정책 가드 없이 동작 중. " "app/prompts/substrate/rules.md 부재. 관리자 확인 필요.]" ) # ── 정적 ROUTE_MAP (surface → overlay + variant). 런타임 출력 sniffing 아님(불변식 #3). ── # overlay=None → 자유-prose 표면(persona + rules + task, 기능 overlay 없음). # overlay name → 미래 active eid 표면(W3+ 배선). variant = persona 변형(현재 전부 26B/27B = full). # 미등록 surface(.get None) → base(persona + rules + task) + 가시 로그. _ROUTE: dict[str, dict] = { # W2-2 wire 대상 — 자유-prose, 기능 overlay 없음(base) "react_ask": {"overlay": None, "variant": "full"}, "study_subject_note": {"overlay": None, "variant": "full"}, "study_question_explanation": {"overlay": None, "variant": "full"}, # 이드 채팅 표면 (D-1 /api/eid/chat) — 자유-prose(base), persona ON (불변식 #3) "eid_chat": {"overlay": None, "variant": "full"}, # 미래 active eid 표면 — 기능 overlay (W3+ 에서 호출 배선) "study_diagnosis": {"overlay": "study", "variant": "full"}, "document_brief": {"overlay": "document", "variant": "full"}, "news_brief": {"overlay": "news", "variant": "full"}, "recap_brief": {"overlay": "recap", "variant": "full"}, "schedule_brief": {"overlay": "schedule", "variant": "full"}, } class SubstrateOverflow(RuntimeError): """non-droppable floor 가 모델 budget 초과 — fail-loud(26B 에스컬레이트), 절대 silent drop 안 함.""" @lru_cache(maxsize=8) def _read(path_str: str) -> str | None: """파일 읽기(캐시). 부재 = None (호출부가 quiet/loud 결정).""" p = Path(path_str) if not p.is_file(): return None return p.read_text(encoding="utf-8").strip() def _persona(variant: str) -> str: """persona 변형 로드. 부재 = quiet fail-open(빈 문자열) — voice 는 cosmetic(불변식 #4).""" fname = _PERSONA_FILES.get(variant) if fname is None: logger.debug("eid.compose: unknown persona variant %r → quiet skip", variant) return "" text = _read(str(_SUBSTRATE_DIR / fname)) if text is None: logger.debug("eid.compose: persona %r absent → quiet fail-open", fname) return "" return text def _rules() -> str: """rules 로드. 부재 = fail-loud(degraded 배너 + error 로그) — 정책은 silent 누락 금지(불변식 #4).""" text = _read(str(_SUBSTRATE_DIR / "rules.md")) if text is None: logger.error( "eid.compose: rules.md ABSENT — substrate degraded (안전·정책 가드 없이 동작). " "app/prompts/substrate/rules.md 확인 필요." ) return _RULES_DEGRADED return text def _overlay(name: str | None) -> str: """기능 overlay 로드. name=None → 빈 문자열(base). 미존재 파일 = fail-loud(error 로그 + 빈).""" if name is None: return "" text = _read(str(_OVERLAY_DIR / f"{name}.txt")) if text is None: logger.error("eid.compose: overlay %r 파일 부재 → base 로 degrade", name) return "" return text def is_composed_surface(surface: str) -> bool: """이 surface 가 ROUTE_MAP 에 등록된 compose 대상인가(= persona 주입 표면인가).""" return surface in _ROUTE def rules_present() -> bool: """rules.md 존재 여부 — 채팅 표면(D-6)의 fail-closed 판정 재료. 기존 _rules() 의 degraded 배너 컨벤션(다른 표면, fail-loud 진행)은 그대로 둔다 — 여긴 '진행 거부' 판정만 제공하고 강제는 호출부(/api/eid/chat) 책임. lru_cache 된 _read 를 쓰지 않고 매 호출 직접 stat — D-6 게이트는 살아있는 판정 이어야 한다(캐시 동결 시 rules.md 부재/복구가 영원히 반영 안 됨). """ return (_SUBSTRATE_DIR / "rules.md").is_file() def compose(surface: str, task: str, *, variant: str | None = None, budget_chars: int | None = None) -> str: """persona → rules → overlay → task 단일 system 문자열 합성. surface : 정적 ROUTE_MAP 키. 미등록이면 base(persona+rules+task) + 가시 로그. task : 표면 고유 지시(기존 prompt txt 본문). 합본의 마지막 항. variant : persona 변형 override. None = ROUTE_MAP 의 variant(기본 full). budget_chars: 모델 system 예산(char). None = 무제한(26B/27B 경로). 설정 시 non-droppable floor(persona+rules+overlay) 초과면 SubstrateOverflow(fail-loud, 절대 silent drop X). 반환: SEP 로 join 된 system 문자열. 빈 항(persona 부재 등)은 join 에서 제외. """ route = _ROUTE.get(surface) if route is None: logger.info( "eid.compose: surface %r ROUTE_MAP 미등록 → base(persona+rules+task)", surface ) v = variant or "full" overlay_name = None else: v = variant or route["variant"] overlay_name = route["overlay"] persona = _persona(v) rules = _rules() # 항상 비-빈(degraded 배너라도) → 합본의 명시 항 보장 overlay = _overlay(overlay_name) # non-droppable floor = persona + rules + overlay (task 제외). budget 초과 = fail-loud. if budget_chars is not None: floor = len(SEP.join(p for p in (persona, rules, overlay) if p)) if floor > budget_chars: logger.error( "eid.compose: non-droppable floor %d char > budget %d (surface=%r, variant=%r) " "→ fail-loud, 26B 에스컬레이트 필요(silent drop 안 함)", floor, budget_chars, surface, v, ) raise SubstrateOverflow( f"floor {floor} > budget {budget_chars} for surface={surface!r} variant={v!r}" ) parts = [persona, rules, overlay, task] return SEP.join(p for p in parts if p) def clear_cache() -> None: """vendored 아티팩트 sync 후 재로드용(1회 캐시 불변식). 프로세스 재시작 대안.""" _read.cache_clear()