Files
hyungi_document_server/app/services/study/weakness_compute.py
hyungi 6a85087b83 feat(eid): 이드 persona substrate W2~W4 — DS compose·약점진단·egress 코드층 박탈
전 로컬 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>
2026-06-07 15:13:20 +09:00

84 lines
3.6 KiB
Python

"""eid 학습 약점 판정/포맷 — 순수 함수 (DB·LLM 무관, 단위테스트 대상). W3-2.
worker(workers/study_weakness.py)가 decide_tier/topic_trend/overall_trend 로 판정,
surface(api/study_topics.py study_diagnosis)가 format_*_block 으로 스냅샷 JSONB → 프롬프트 블록.
임계는 worker 가 주입(여기선 받기만) — 튜닝값은 한 곳(worker)에서 관리.
"""
from __future__ import annotations
# 표면 약점 토픽 상한 (포맷)
TOP_WEAKNESS = 5
def decide_tier(
*, chronic: int, relapsed: int, overdue: int, unsure: int, attempted: int,
min_attempts: int, chronic_focus: int, relapse_focus: int, review_overdue: int,
) -> str | None:
"""bounded 권고 tier(watch/review/focus). None = 약점 아님(스냅샷 미포함).
conservative: 표본 미달(attempted < min_attempts)이면 focus/review 단정 안 하고 watch 상한.
"""
shallow = attempted < min_attempts
if not shallow and (chronic >= chronic_focus or relapsed >= relapse_focus):
return "focus"
if not shallow and (chronic >= 1 or relapsed >= 1 or overdue >= review_overdue):
return "review"
if chronic >= 1 or relapsed >= 1 or unsure >= 2 or overdue >= 1:
return "watch"
return None
def topic_trend(sessions: list[dict]) -> str:
"""recent 세션 finalize 카운트 → 개선|정체|악화. conservative: 명확하지 않으면 정체."""
if not sessions:
return "정체"
gained = sum(s.get("newly_correct", 0) for s in sessions)
lost = sum(s.get("relapsed", 0) + s.get("chronic_remaining", 0) for s in sessions)
if gained > lost * 1.5:
return "개선"
if lost > gained * 1.5:
return "악화"
return "정체"
def overall_trend(topic_trends: list[str]) -> str:
"""토픽별 추세 다수결 → 전체 추세. conservative: 동률/공백이면 정체."""
if not topic_trends:
return "정체"
worse = topic_trends.count("악화")
better = topic_trends.count("개선")
if worse > better:
return "악화"
if better > worse:
return "개선"
return "정체"
def format_weakness_block(weaknesses: list[dict], *, shallow_overall: bool) -> str:
"""약점 스냅샷 list → study overlay {weakness_snapshot_block} 텍스트. 워커 값만(추측 없음)."""
if not weaknesses:
return "(약점으로 판정된 토픽 없음. 스냅샷에 없는 토픽을 약점으로 추정하지 마라.)"
lines = []
for w in weaknesses[:TOP_WEAKNESS]:
lines.append(
f"- {w['topic']}: chronic 반복오답 {w['chronic']}건 / relapsed(회복후재오답) {w['relapsed']}건 / "
f"모르겠음 {w['unsure']}건 / 미답(커버리지공백) {w['coverage_gap']}건 / 묵힌 due {w['overdue']}건 / "
f"추세 {w['trend']} / 권고 tier={w['tier']}"
)
if shallow_overall:
lines.append("- (전체 표본 적음 — 약점 단정 대신 '지켜볼 토픽'으로만 해석)")
return "\n".join(lines)
def format_habit_block(habits: dict) -> str:
"""태도 신호 dict → study overlay {habit_signal_block} 텍스트."""
parts = []
if habits.get("avoidance_topics"):
parts.append(f"- 재시도 회피 신호(모르겠음 누적) 토픽: {', '.join(habits['avoidance_topics'])}")
parts.append(f"- 세션 중단율: {round(habits.get('session_abandon_rate', 0.0) * 100)}%")
parts.append(f"- 오래 묵힌 due(복습 밀림): {habits.get('stale_due_count', 0)}")
if habits.get("skew_topic"):
parts.append(f"- 편중: '{habits['skew_topic']}' 에 풀이 집중")
return "\n".join(parts)