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>
157 lines
5.8 KiB
Python
157 lines
5.8 KiB
Python
"""eid.compose 단위 테스트 — persona→rules→overlay→task 합성 (stdlib only, venv 불필요).
|
|
|
|
실행: python3 tests/eid/test_compose.py (또는 pytest tests/eid/test_compose.py)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# app/ 를 import 루트로 (repo_root/tests/eid/ → repo_root/app)
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "app"))
|
|
|
|
from eid.compose import ( # noqa: E402
|
|
SEP,
|
|
SubstrateOverflow,
|
|
_persona,
|
|
compose,
|
|
is_composed_surface,
|
|
rules_present,
|
|
)
|
|
|
|
_TASK = "<<<TASK_SENTINEL>>>"
|
|
|
|
|
|
def test_order_persona_rules_task():
|
|
out = compose("react_ask", _TASK)
|
|
# persona(이드 정체성) · rules(생성 가드, '보수적'=conservative 룰) · task 모두 존재
|
|
assert "이드" in out, "persona 미주입"
|
|
assert "보수적" in out, "rules(생성 서브셋) 미주입"
|
|
assert _TASK in out, "task 미포함"
|
|
# 순서: persona < rules < task
|
|
assert out.index("이드") < out.index("보수적") < out.index(_TASK), "persona→rules→task 순서 위반"
|
|
|
|
|
|
def test_base_surface_has_no_overlay():
|
|
out = compose("study_subject_note", _TASK)
|
|
assert "학습 진단 코치" not in out, "base 표면에 기능 overlay 누출"
|
|
assert "뉴스 큐레이터" not in out
|
|
|
|
|
|
def test_overlay_surface_includes_overlay_between_rules_and_task():
|
|
out = compose("study_diagnosis", _TASK)
|
|
assert "학습 진단 코치" in out, "study overlay 미주입"
|
|
# overlay 는 rules 뒤, task 앞
|
|
assert out.index("보수적") < out.index("학습 진단 코치") < out.index(_TASK)
|
|
|
|
|
|
def test_unknown_surface_falls_back_to_base():
|
|
out = compose("totally_unknown_surface", _TASK)
|
|
assert "이드" in out and _TASK in out # persona+rules+task 유지
|
|
assert "학습 진단 코치" not in out # overlay 없음
|
|
|
|
|
|
def test_is_composed_surface():
|
|
assert is_composed_surface("react_ask")
|
|
assert is_composed_surface("study_diagnosis")
|
|
assert not is_composed_surface("classify") # 기계류 9종 = 미등록
|
|
assert not is_composed_surface("briefing_comparative") # JSON 기계류 = persona ZERO
|
|
|
|
|
|
def test_persona_quiet_on_unknown_variant():
|
|
assert _persona("bogus_variant") == "" # quiet fail-open
|
|
|
|
|
|
def test_sep_join_present():
|
|
out = compose("react_ask", _TASK)
|
|
assert SEP in out, "합본 구분자 SEP 누락"
|
|
|
|
|
|
def test_overflow_failloud_never_silent_drop():
|
|
# 아주 작은 budget → non-droppable floor 초과 → SubstrateOverflow(절대 silent drop 안 함)
|
|
raised = False
|
|
try:
|
|
compose("study_diagnosis", _TASK, budget_chars=50)
|
|
except SubstrateOverflow:
|
|
raised = True
|
|
assert raised, "budget 초과인데 silent 통과 — fail-loud 위반"
|
|
|
|
|
|
def test_generous_budget_passes():
|
|
out = compose("react_ask", _TASK, budget_chars=100_000)
|
|
assert _TASK in out # 넉넉한 예산 = 통과
|
|
|
|
|
|
def test_study_diagnosis_overlay_placeholders_survive_compose():
|
|
# study_diagnosis = study overlay 경로. {weakness_snapshot_block}/{habit_signal_block} 가
|
|
# compose 출력(system)에 리터럴로 남아야 surface 가 .replace 로 실데이터 치환 가능.
|
|
out = compose("study_diagnosis", task="")
|
|
assert "{weakness_snapshot_block}" in out, "약점 placeholder 누락(overlay degrade)"
|
|
assert "{habit_signal_block}" in out, "태도 placeholder 누락"
|
|
filled = out.replace("{weakness_snapshot_block}", "WB").replace("{habit_signal_block}", "HB")
|
|
assert "{weakness_snapshot_block}" not in filled and "WB" in filled and "HB" in filled
|
|
|
|
|
|
def test_eid_chat_surface_registered():
|
|
# eid-chat D-1: 채팅 표면 = 자유-prose(base), persona ON, 기능 overlay 없음 (불변식 #3)
|
|
assert is_composed_surface("eid_chat"), "eid_chat ROUTE_MAP 미등록"
|
|
out = compose("eid_chat", "")
|
|
assert "이드" in out, "persona 미주입"
|
|
assert "보수적" in out, "rules 미주입"
|
|
assert out.index("이드") < out.index("보수적"), "persona→rules 순서 위반"
|
|
assert "학습 진단 코치" not in out, "채팅 base 표면에 기능 overlay 누출"
|
|
|
|
|
|
def test_rules_present_true_then_false():
|
|
# D-6 fail-closed 판정 재료 — vendored rules.md 존재 시 True, 부재 시 False.
|
|
# _rules() 의 degraded 배너 동작(다른 표면)은 본 헬퍼와 무관하게 유지된다.
|
|
import eid.compose as c
|
|
|
|
assert rules_present() is True, "vendored rules.md 가 있는데 False"
|
|
orig = c._SUBSTRATE_DIR
|
|
try:
|
|
c._SUBSTRATE_DIR = Path("/nonexistent-substrate-dir-for-test")
|
|
assert c.rules_present() is False, "rules.md 부재인데 True — fail-closed 판정 불가"
|
|
finally:
|
|
c._SUBSTRATE_DIR = orig
|
|
|
|
|
|
def test_rules_present_live_judgment():
|
|
# D-6 게이트 = 살아있는 판정 — lru_cache(_read) 동결 회귀 방지.
|
|
# 같은 경로에서 생성→True, 삭제→False 가 즉시 반영돼야 한다.
|
|
import tempfile
|
|
|
|
import eid.compose as c
|
|
|
|
orig = c._SUBSTRATE_DIR
|
|
try:
|
|
with tempfile.TemporaryDirectory() as td:
|
|
c._SUBSTRATE_DIR = Path(td)
|
|
rules = Path(td) / "rules.md"
|
|
assert c.rules_present() is False
|
|
rules.write_text("rule", encoding="utf-8")
|
|
assert c.rules_present() is True, "생성이 반영 안 됨 — 캐시 동결"
|
|
rules.unlink()
|
|
assert c.rules_present() is False, "삭제가 반영 안 됨 — 캐시 동결"
|
|
finally:
|
|
c._SUBSTRATE_DIR = orig
|
|
|
|
|
|
def _run():
|
|
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
|
fails = 0
|
|
for fn in fns:
|
|
try:
|
|
fn()
|
|
print(f" PASS {fn.__name__}")
|
|
except Exception as exc: # noqa: BLE001
|
|
fails += 1
|
|
print(f" FAIL {fn.__name__}: {type(exc).__name__}: {exc}")
|
|
print(f"\n{len(fns) - fails}/{len(fns)} passed")
|
|
return 1 if fails else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(_run())
|