Files
hyungi_document_server/tests/eid/test_compose.py
T
hyungi cd06ef0403 feat(eid): 이드 채팅 표면 — /api/eid/chat SSE 스트리밍 + /chat 페이지 (P1)
- 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>
2026-06-11 11:16:44 +09:00

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())