6a85087b83
전 로컬 LLM 관통 '이드' persona substrate 의 Document Server 측 빌드(W2~W4). 설계 = PKM eid-persona-substrate(r1~r3 수렴) / impl = eid-persona-impl. W2 — compose + 표면 배선: - app/eid/compose.py: persona→rules→overlay→task 단일 system 문자열 + 정적 ROUTE_MAP (런타임 sniffing 아님) + rules 부재 fail-loud · persona 부재 quiet · overflow fail-loud. - 자유-prose 3 표면(react_ask·study_subject_note·study_question_explanation) 중복 정체성· generic 정책 trim + compose 배선(AIClient 에 additive system 파라미터). 도메인 calibration 보존. - STRICT JSON 기계류(briefing_comparative·digest_topic)는 persona-ZERO 동결(불변식 #3). - app/prompts/substrate/: persona(외부 컴파일 산출물 vendor) + rules(생성 가드 서브셋) + overlay 5. W3 — migration + 워커 + study_diagnosis: - migration 301~305: eid_* append-only 원장(약점/복습초안/회고) + approval_requests(가변 큐) + 일정 파생뷰 2. - app/workers/study_weakness.py: study_question_progress.pattern_state 집계로 약점 derived 산출 (LLM 0) + bounded tier(watch/review/focus). nightly cron. - study_diagnosis 표면: 최신 스냅샷을 코치 언어로 번역(약점 판정은 코드, LLM 은 블록 값만 인용). W4-1 — egress 코드층 박탈: - app/eid/ai.py EidAIClient: 이드 표면 = call_primary(내부 MLX) only. 외부 LLM fallback 경로 구조적 봉쇄(call_fallback raise · 자동 fallback 제거 · 외부 endpoint 차단). egress 워커는 분리 유지. load-bearing 정정 3(환경 grounding 강제, 설계 회귀 아님): - rules = 운영 ruleset 전체 → 생성 가드 서브셋(HTML 산출물 룰이 study task 와 충돌). - append-only = REVOKE → CREATE RULE DO INSTEAD NOTHING(단일 owner role 은 REVOKE 무효 + migration 검증기가 plpgsql BEGIN 거부) + actor/source_* NOT NULL 스탬프. - 이드 LLM 봉쇄 = path discipline → EidAIClient 구조화. 검증: eid 순수 단위테스트 30 통과 + py_compile + migration 검증기 모사 + egress 적대감사 COMPLETE. DB/LLM/httpx 의존 테스트(append-only RULE·EidAIClient·E2E)는 staging(Docker) 가동. W4-2 네트워크 belt 은 조건부 보류(코드층 1차 충분, P0-3② 원격 실측 후 hard-gate 시 승격). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
104 lines
3.7 KiB
Python
104 lines
3.7 KiB
Python
"""eid 약점 판정/포맷 순수 함수 테스트 (stdlib only, venv 불필요). W3-2.
|
|
|
|
실행: python3 tests/eid/test_weakness_compute.py (또는 pytest)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "app"))
|
|
|
|
from services.study.weakness_compute import ( # noqa: E402
|
|
decide_tier,
|
|
format_habit_block,
|
|
format_weakness_block,
|
|
overall_trend,
|
|
topic_trend,
|
|
)
|
|
|
|
# worker 임계 미러 (테스트 고정값)
|
|
TH = dict(min_attempts=5, chronic_focus=3, relapse_focus=2, review_overdue=5)
|
|
|
|
|
|
def test_decide_tier_focus_on_chronic():
|
|
assert decide_tier(chronic=3, relapsed=0, overdue=0, unsure=0, attempted=20, **TH) == "focus"
|
|
|
|
|
|
def test_decide_tier_focus_on_relapse():
|
|
assert decide_tier(chronic=0, relapsed=2, overdue=0, unsure=0, attempted=20, **TH) == "focus"
|
|
|
|
|
|
def test_decide_tier_review_on_single_chronic():
|
|
assert decide_tier(chronic=1, relapsed=0, overdue=0, unsure=0, attempted=20, **TH) == "review"
|
|
|
|
|
|
def test_decide_tier_review_on_overdue():
|
|
assert decide_tier(chronic=0, relapsed=0, overdue=5, unsure=0, attempted=20, **TH) == "review"
|
|
|
|
|
|
def test_decide_tier_shallow_caps_to_watch():
|
|
# 표본 미달(attempted<5) → chronic 많아도 focus/review 단정 안 함, watch 상한 (conservative)
|
|
assert decide_tier(chronic=4, relapsed=3, overdue=9, unsure=0, attempted=3, **TH) == "watch"
|
|
|
|
|
|
def test_decide_tier_watch_on_unsure():
|
|
assert decide_tier(chronic=0, relapsed=0, overdue=0, unsure=2, attempted=10, **TH) == "watch"
|
|
|
|
|
|
def test_decide_tier_none_when_clean():
|
|
assert decide_tier(chronic=0, relapsed=0, overdue=0, unsure=0, attempted=20, **TH) is None
|
|
|
|
|
|
def test_topic_trend():
|
|
assert topic_trend([]) == "정체"
|
|
assert topic_trend([{"newly_correct": 10, "relapsed": 1, "chronic_remaining": 1}]) == "개선"
|
|
assert topic_trend([{"newly_correct": 1, "relapsed": 5, "chronic_remaining": 4}]) == "악화"
|
|
assert topic_trend([{"newly_correct": 3, "relapsed": 2, "chronic_remaining": 1}]) == "정체"
|
|
|
|
|
|
def test_overall_trend_majority():
|
|
assert overall_trend([]) == "정체"
|
|
assert overall_trend(["악화", "악화", "개선"]) == "악화"
|
|
assert overall_trend(["개선", "개선", "악화"]) == "개선"
|
|
assert overall_trend(["개선", "악화"]) == "정체" # 동률
|
|
|
|
|
|
def test_format_weakness_block_empty_guards():
|
|
out = format_weakness_block([], shallow_overall=False)
|
|
assert "약점으로 판정된 토픽 없음" in out
|
|
assert "추정하지 마라" in out # 환각 약점 차단 문구
|
|
|
|
|
|
def test_format_weakness_block_content_and_shallow():
|
|
ws = [{"topic": "가스설비", "chronic": 4, "relapsed": 1, "unsure": 2,
|
|
"coverage_gap": 7, "overdue": 3, "trend": "악화", "tier": "focus"}]
|
|
out = format_weakness_block(ws, shallow_overall=True)
|
|
assert "가스설비" in out and "tier=focus" in out and "추세 악화" in out
|
|
assert "표본 적음" in out # shallow 주석
|
|
|
|
|
|
def test_format_habit_block():
|
|
out = format_habit_block({
|
|
"avoidance_topics": ["배관", "연소"], "session_abandon_rate": 0.25,
|
|
"stale_due_count": 12, "skew_topic": "배관",
|
|
})
|
|
assert "배관" in out and "25%" in out and "12건" in out and "편중" in out
|
|
|
|
|
|
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())
|