Files
hyungi_document_server/tests/eid/test_eid_append_only_pg.py
T
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

106 lines
4.3 KiB
Python

"""eid_* append-only 구조강제 + 파생뷰 PG 통합 테스트 (W3, review #1).
설계 불변식 #8 의 load-bearing 부분 = DB 강제. 단일 owner role pkm 이라 REVOKE 무효 +
migration 검증기가 plpgsql BEGIN 거부 → RULE(DO INSTEAD NOTHING) + NOT NULL 스탬프로 강제.
순수함수 테스트(test_compose/test_weakness_compute)로는 검증 불가한 'DB 가 실제로 막는가'를 본다.
★ 실행 환경: Postgres(Docker 스택, migrations 301-305 적용 후) 필요 — MacBook 로컬엔 PG 없어
hard-fail(skip 아님). test_worker_jobs_smoke.py 와 동일 idiom(_worker_pool_helpers).
staging(devsbx/개발서버 배포 후)에서 가동. 트랜잭션 rollback 으로 테스트 행 오염 0.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
import pytest
import pytest_asyncio
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "app"))
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # tests/ (helpers)
from sqlalchemy import text # noqa: E402
from sqlalchemy.exc import IntegrityError # noqa: E402
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine # noqa: E402
from _worker_pool_helpers import ensure_user, get_database_url # noqa: E402
_VALID_INSERT = (
"INSERT INTO eid_study_weakness "
"(user_id, weaknesses, habit_signals, trend_label, actor, source_generated_at) "
"VALUES (:u, '[]'::jsonb, '{}'::jsonb, '악화', 'eid', now()) RETURNING id"
)
@pytest_asyncio.fixture
async def uid():
return await ensure_user("test-eid-append-only")
@pytest.mark.asyncio
async def test_unstamped_insert_rejected(uid):
"""actor 스탬프 누락 INSERT → NOT NULL 위반 (owner 도 적용 — 스탬프 없는 행 거부)."""
engine = create_async_engine(get_database_url())
sm = async_sessionmaker(engine, expire_on_commit=False)
try:
with pytest.raises(IntegrityError):
async with sm() as s:
await s.execute(
text(
"INSERT INTO eid_study_weakness "
"(user_id, weaknesses, habit_signals, trend_label, source_generated_at) "
"VALUES (:u, '[]'::jsonb, '{}'::jsonb, '정체', now())" # actor 누락
),
{"u": uid},
)
await s.commit()
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_update_and_delete_are_no_op(uid):
"""RULE DO INSTEAD NOTHING — owner pkm 의 UPDATE/DELETE 도 행을 못 바꾼다(append-only)."""
engine = create_async_engine(get_database_url())
sm = async_sessionmaker(engine, expire_on_commit=False)
try:
async with sm() as s:
wid = (await s.execute(text(_VALID_INSERT), {"u": uid})).scalar_one()
await s.flush() # 같은 트랜잭션 내 가시 (commit 안 함 → 끝에 rollback 으로 오염 0)
await s.execute(
text("UPDATE eid_study_weakness SET trend_label='개선' WHERE id=:i"), {"i": wid}
)
tl = (
await s.execute(text("SELECT trend_label FROM eid_study_weakness WHERE id=:i"), {"i": wid})
).scalar_one()
assert tl == "악화", "UPDATE 가 값을 바꿈 — RULE 미적용(append-only 깨짐)"
await s.execute(text("DELETE FROM eid_study_weakness WHERE id=:i"), {"i": wid})
cnt = (
await s.execute(text("SELECT count(*) FROM eid_study_weakness WHERE id=:i"), {"i": wid})
).scalar_one()
assert cnt == 1, "DELETE 가 행을 지움 — RULE 미적용(append-only 깨짐)"
await s.rollback() # 테스트 행 폐기
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_schedule_views_queryable(uid):
"""v_schedule_today / v_schedule_defer_pattern 정의 유효성 smoke (enum 리터럴·LATERAL·date_trunc).
뷰가 invalid 면 CREATE 시점 또는 SELECT 시점에 에러 → 쿼리 성공 = DDL 유효.
"""
engine = create_async_engine(get_database_url())
sm = async_sessionmaker(engine, expire_on_commit=False)
try:
async with sm() as s:
await s.execute(text("SELECT * FROM v_schedule_today LIMIT 1"))
await s.execute(text("SELECT * FROM v_schedule_defer_pattern LIMIT 1"))
finally:
await engine.dispose()