"""Create two live P1 sessions and verify DB-backed openness curves. This is an evidence smoke for the backlog item "저항엔진 openness 곡선 DB 실증". It uses the public API surface for login/onboarding/session turns, then queries Postgres for the stored deterministic state and fast-loop client-state labels. """ from __future__ import annotations import argparse import asyncio import json import os import time import urllib.error import urllib.parse import urllib.request from http.cookiejar import CookieJar from pathlib import Path from typing import Any import asyncpg EMPATHIC_UTTERANCES = [ "얼마나 힘들었는지 마음이 느껴져요. 어떤 순간이 제일 버거웠나요?", "그런 마음을 꺼내는 것 자체가 쉽지 않았을 것 같아요. 더 말해줘도 괜찮아요.", "잠도 잘 못 자고 학교도 버거웠다면 하루가 길게 느껴졌겠어요.", "지금은 해결책보다 그 마음을 천천히 이해하는 게 먼저인 것 같아요.", "그 시간을 버텨온 마음을 함께 살펴보고 싶어요. 무엇부터 이야기해볼까요?", ] ADVICE_JUMP_UTTERANCES = [ "그냥 학교는 가야 해요. 노력하면 하면 돼요. 왜 안 하죠?", "그건 잘못 생각하는 거예요. 원래 다 힘들어요.", "당연히 엄마 말을 들어야죠. 하지 마세요.", "내 생각엔 그냥 계획표를 만들면 돼요.", "그러니까 더 노력해야 해요. 왜 안 바꾸나요?", ] class SmokeError(RuntimeError): pass class ApiClient: def __init__(self, base_url: str, timeout: float) -> None: self.base_url = base_url.rstrip("/") self.timeout = timeout self._opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(CookieJar())) def request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> Any: data = None headers = {"Accept": "application/json"} if payload is not None: data = json.dumps(payload, ensure_ascii=False).encode("utf-8") headers["Content-Type"] = "application/json" req = urllib.request.Request( f"{self.base_url}{path}", data=data, headers=headers, method=method, ) try: with self._opener.open(req, timeout=self.timeout) as resp: body = resp.read().decode("utf-8") return json.loads(body) if body else {} except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", errors="replace") raise SmokeError(f"{method} {path} failed with HTTP {exc.code}: {detail}") from exc except urllib.error.URLError as exc: raise SmokeError(f"{method} {path} transport failed: {exc}") from exc def _load_api_env() -> None: env_path = Path("apps/api/.env") if not env_path.exists(): return for raw_line in env_path.read_text(encoding="utf-8").splitlines(): line = raw_line.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) key = key.strip() value = value.strip().strip('"').strip("'") os.environ.setdefault(key, value) def _onboarding_payload(display_name: str) -> dict[str, Any]: return { "legal_name": display_name, "affiliation": "한신대학교", "department": "상담심리학과", "grade_level": "3학년", "phone": "010-2222-2222", "contact_address": "경기도 오산시 한신대학교", "nickname": display_name, "self_introduction": "저항엔진 DB 실증용 스모크 사용자입니다.", "avatar_url": "", "terms_accepted": True, "privacy_accepted": True, } def _start_session(client: ApiClient, email: str, display_name: str) -> str: client.request( "POST", "/auth/dev-login", {"email": email, "role": "learner", "display_name": display_name}, ) client.request("POST", "/users/me/onboarding", _onboarding_payload(display_name)) started = client.request( "POST", "/sessions", {"persona_code": "P1", "theory_mode": "humanistic"}, ) session_id = str(started.get("session_id") or "") if not session_id: raise SmokeError(f"session start returned no session_id: {started}") if started.get("degraded"): raise SmokeError(f"session start was degraded, refusing to use it as DB proof: {started}") return session_id def _run_turns(client: ApiClient, session_id: str, utterances: list[str]) -> list[dict[str, Any]]: results: list[dict[str, Any]] = [] for text in utterances: results.append(client.request("POST", f"/sessions/{session_id}/turn", {"text": text})) return results async def _fetch_curve(dsn: str, session_id: str) -> dict[str, Any]: conn = await asyncpg.connect(dsn) try: states = await conn.fetch( """ SELECT session_id::text AS session_id, stage, turn_seq, effective_openness, rapport_credit, resistance FROM app.session_state WHERE session_id = $1::uuid """, session_id, ) turns = await conn.fetch( """ SELECT t.id::text AS turn_id, t.seq AS turn_seq, t.actor_kind, t.text_masked, array_remove(array_agg(cs.code ORDER BY cs.code), NULL) AS client_states FROM app.turns t LEFT JOIN app.turn_client_state tcs ON tcs.turn_id = t.id LEFT JOIN app.client_state_def cs ON cs.label_id = tcs.label_id WHERE t.session_id = $1::uuid GROUP BY t.id, t.seq, t.actor_kind, t.text_masked ORDER BY t.seq, t.actor_kind """, session_id, ) state = dict(states[0]) if states else {} return { "session_state": state, "turns": [dict(row) for row in turns], } finally: await conn.close() def _summarize( label: str, session_id: str, api_results: list[dict[str, Any]], db_result: dict[str, Any], ) -> dict[str, Any]: return { "label": label, "session_id": session_id, "api_curve": [ { "turn_seq": item.get("turn_seq"), "stage": item.get("stage"), "effective_openness": item.get("effective_openness"), "safety_flagged": item.get("safety_flagged"), } for item in api_results ], "db_state": db_result.get("session_state"), "db_turn_count": len(db_result.get("turns") or []), "db_client_states": [ { "turn_seq": row.get("turn_seq"), "actor_kind": row.get("actor_kind"), "client_states": row.get("client_states") or [], } for row in db_result.get("turns") or [] if row.get("client_states") ], } async def run(args: argparse.Namespace) -> dict[str, Any]: _load_api_env() dsn = args.database_url or os.environ.get("DATABASE_URL") if not dsn: raise SmokeError("DATABASE_URL is required via --database-url or apps/api/.env") suffix = str(int(time.time())) empathy_client = ApiClient(args.api_base_url, args.timeout) advice_client = ApiClient(args.api_base_url, args.timeout) empathy_session = _start_session( empathy_client, f"resistance.empathy.{suffix}@hs.ac.kr", "Resistance Empathy", ) advice_session = _start_session( advice_client, f"resistance.advice.{suffix}@hs.ac.kr", "Resistance Advice", ) empathy_results = _run_turns(empathy_client, empathy_session, EMPATHIC_UTTERANCES) advice_results = _run_turns(advice_client, advice_session, ADVICE_JUMP_UTTERANCES) empathy_db = await _fetch_curve(dsn, empathy_session) advice_db = await _fetch_curve(dsn, advice_session) empathy_final = float((empathy_db.get("session_state") or {}).get("effective_openness") or 0.0) advice_final = float((advice_db.get("session_state") or {}).get("effective_openness") or 0.0) empathy_stage = str((empathy_db.get("session_state") or {}).get("stage") or "") advice_stage = str((advice_db.get("session_state") or {}).get("stage") or "") if empathy_final <= advice_final: raise SmokeError(f"expected empathy openness > advice openness, got {empathy_final} <= {advice_final}") if empathy_stage == advice_stage and empathy_final < 0.1: raise SmokeError(f"empathy curve did not open enough: stage={empathy_stage}, openness={empathy_final}") return { "ok": True, "api_base_url": args.api_base_url, "persona_code": "P1", "empathy": _summarize("empathy", empathy_session, empathy_results, empathy_db), "advice_jump": _summarize("advice_jump", advice_session, advice_results, advice_db), "assertion": { "empathy_final_openness": empathy_final, "advice_final_openness": advice_final, "empathy_stage": empathy_stage, "advice_stage": advice_stage, }, } def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--api-base-url", default="http://127.0.0.1:8000") parser.add_argument("--database-url", default="") parser.add_argument("--timeout", type=float, default=180.0) parser.add_argument("--out", default="") args = parser.parse_args() result = asyncio.run(run(args)) text = json.dumps(result, ensure_ascii=False, indent=2, default=str) if args.out: out_path = Path(args.out) out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text(text + "\n", encoding="utf-8") print(text) if __name__ == "__main__": main()