267 lines
9.7 KiB
Python
267 lines
9.7 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")
|
|
],
|
|
}
|
|
|
|
|
|
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()
|