cd06ef0403
- compose: eid_chat surface 등록(persona+rules, 자유-prose) + rules_present() 라이브 판정(D-6 fail-closed) - EidAIClient.call_stream: 닫힌 mode 매핑(daily→mac-mini-default/deep→qwen-macbook), router 경유, MLX gate(FOREGROUND)+wall-clock 300s deadline, SSE 라인 relay(model→mode 치환·usage 제거), router 400 fail-loud, error_reason allowlist sanitize - POST /api/eid/chat: JWT, role=system 422 거부, 8000자/40턴/총량 32000 cap, 503 error_reason(ask 컨벤션), 본문 무로깅 - frontend /chat: 이드 표면 문법(일상/심층, 모델·머신명 비노출), SSE 파서(경계 buf·flush·[DONE]), error_reason UX, 8000자 선차단+422 오염 차단, localStorage 이력(logout 시 제거), nav 등록 - Caddyfile: encode 명시 match로 text/event-stream gzip 버퍼링 제외 - tests: 신규 32+ (fixture: router 경유 26B/27B SSE 박제), tests/eid 61 + ask 회귀 9 = 70 passed - 적대 리뷰 3렌즈 18 finding 반영 13/13. 배포는 D26 게이트(fix/hwp 머지+Soft Lock) 대기 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
176 lines
8.1 KiB
Python
176 lines
8.1 KiB
Python
"""이드 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()
|