331 lines
13 KiB
Python
331 lines
13 KiB
Python
"""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")
|
|
],
|
|
}
|
|
|
|
|
|
def _openness_curve(api_results: list[dict[str, Any]]) -> list[float]:
|
|
return [float(item.get("effective_openness") or 0.0) for item in api_results]
|
|
|
|
|
|
def _stage_curve(api_results: list[dict[str, Any]]) -> list[str]:
|
|
return [str(item.get("stage") or "") for item in api_results]
|
|
|
|
|
|
def _assert_turn_sequence(label: str, api_results: list[dict[str, Any]], expected_count: int) -> None:
|
|
if len(api_results) != expected_count:
|
|
raise SmokeError(f"{label} API returned {len(api_results)} turns, expected {expected_count}")
|
|
expected_turns = list(range(1, expected_count + 1))
|
|
actual_turns = [int(item.get("turn_seq") or 0) for item in api_results]
|
|
if actual_turns != expected_turns:
|
|
raise SmokeError(f"{label} API turn sequence mismatch: {actual_turns} != {expected_turns}")
|
|
flagged_turns = [item.get("turn_seq") for item in api_results if item.get("safety_flagged")]
|
|
if flagged_turns:
|
|
raise SmokeError(f"{label} API unexpectedly triggered safety flags on turns {flagged_turns}")
|
|
|
|
|
|
def _assert_db_storage(label: str, db_result: dict[str, Any], expected_state_turn_seq: int) -> None:
|
|
state = db_result.get("session_state") or {}
|
|
if int(state.get("turn_seq") or 0) != expected_state_turn_seq:
|
|
raise SmokeError(f"{label} DB session_state turn_seq mismatch: {state}")
|
|
|
|
turns = db_result.get("turns") or []
|
|
expected_turn_rows = expected_state_turn_seq * 2
|
|
if len(turns) != expected_turn_rows:
|
|
raise SmokeError(f"{label} DB stored {len(turns)} turns, expected {expected_turn_rows}")
|
|
|
|
state_rows = [row for row in turns if row.get("client_states")]
|
|
if len(state_rows) < expected_state_turn_seq:
|
|
raise SmokeError(
|
|
f"{label} DB stored {len(state_rows)} turn_client_state rows, expected at least {expected_state_turn_seq}"
|
|
)
|
|
|
|
|
|
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)
|
|
|
|
expected_turns = len(EMPATHIC_UTTERANCES)
|
|
if len(ADVICE_JUMP_UTTERANCES) != expected_turns:
|
|
raise SmokeError("empathy/advice utterance sets must have the same turn count")
|
|
|
|
_assert_turn_sequence("empathy", empathy_results, expected_turns)
|
|
_assert_turn_sequence("advice_jump", advice_results, expected_turns)
|
|
_assert_db_storage("empathy", empathy_db, expected_turns)
|
|
_assert_db_storage("advice_jump", advice_db, expected_turns)
|
|
|
|
empathy_curve = _openness_curve(empathy_results)
|
|
advice_curve = _openness_curve(advice_results)
|
|
empathy_stages = _stage_curve(empathy_results)
|
|
advice_stages = _stage_curve(advice_results)
|
|
|
|
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_stages[2:] != ["탐색"] * (expected_turns - 2):
|
|
raise SmokeError(f"expected empathy stage 탐색 from turn 3, got {empathy_stages}")
|
|
if empathy_curve[2] <= 0 or empathy_curve[-1] <= empathy_curve[2]:
|
|
raise SmokeError(f"expected empathy openness to open from turn 3 and keep rising, got {empathy_curve}")
|
|
if advice_stages != ["라포"] * expected_turns:
|
|
raise SmokeError(f"expected advice_jump stage 라포 throughout, got {advice_stages}")
|
|
if any(value != 0.0 for value in advice_curve):
|
|
raise SmokeError(f"expected advice_jump openness to stay at 0.0, got {advice_curve}")
|
|
if empathy_final <= advice_final:
|
|
raise SmokeError(f"expected empathy openness > advice openness, got {empathy_final} <= {advice_final}")
|
|
if empathy_stage != "탐색" or empathy_final < 0.1:
|
|
raise SmokeError(f"empathy DB curve did not open enough: stage={empathy_stage}, openness={empathy_final}")
|
|
if advice_stage != "라포" or advice_final != 0.0:
|
|
raise SmokeError(f"advice_jump DB curve did not stay closed: stage={advice_stage}, openness={advice_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,
|
|
"empathy_api_curve": empathy_curve,
|
|
"advice_jump_api_curve": advice_curve,
|
|
"db_turn_rows_per_session": expected_turns * 2,
|
|
},
|
|
}
|
|
|
|
|
|
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()
|