음성 재생과 운영 배포 정리

This commit is contained in:
Yun Chan 2026-06-28 12:18:20 +09:00
parent 8ed185ce6c
commit ac7db95542
1020 changed files with 46863 additions and 2175 deletions

View file

@ -0,0 +1,336 @@
#!/usr/bin/env python3
"""Deployment preflight checks for portable Vignette installs.
This script is intentionally read-only. Owner-run DB migrations/init scripts
must happen before the API starts; this preflight only proves that the runtime
app role and packaged files are ready enough for startup.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[1]
REQUIRED_ENV_KEYS = (
"ENVIRONMENT",
"POSTGRES_PASSWORD",
"APP_DB_PASSWORD",
"OPENAI_API_KEY",
"SESSION_SECRET",
"OAUTH_GOOGLE_CLIENT_ID",
"OAUTH_GOOGLE_CLIENT_SECRET",
)
PLACEHOLDER_VALUES = {
"",
"change-me",
"change-me-app",
"change-me-random",
"dummy",
"dummy-openai-key",
"dummy-session-secret-not-real-12345",
"dummy-client",
"dummy-secret",
}
FORBIDDEN_PROD_TRUE_FLAGS = (
"AUTH_DEV_LOGIN_ENABLED",
"AUTO_SEED_PERSONAS",
"ALLOW_SEED_PERSONA_FALLBACK",
"VIGNETTE_VOICE_POC_SAMPLE_TTS",
)
VALID_ENGINE_MODES = {"claude_api", "claude_cli", "openai", "solar"}
REQUIREMENT_RE = re.compile(r"^[A-Za-z0-9_.-]+(?:\[[A-Za-z0-9_,.-]+\])?==[^\s#]+$")
@dataclass(frozen=True)
class Check:
name: str
ok: bool
detail: str
def _rel(path: Path) -> str:
try:
return path.relative_to(REPO_ROOT).as_posix()
except ValueError:
return str(path)
def _iter_requirement_lines(path: Path) -> list[tuple[int, str]]:
rows: list[tuple[int, str]] = []
for lineno, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
line = raw.strip()
if not line or line.startswith("#"):
continue
rows.append((lineno, line))
return rows
def check_pinned_requirements() -> Check:
paths = [
REPO_ROOT / "apps" / "api" / "requirements.txt",
REPO_ROOT / "apps" / "api" / "requirements-rag.txt",
]
offenders: list[str] = []
for path in paths:
if not path.exists():
offenders.append(f"{_rel(path)} missing")
continue
for lineno, line in _iter_requirement_lines(path):
if not REQUIREMENT_RE.match(line):
offenders.append(f"{_rel(path)}:{lineno} {line}")
if offenders:
return Check("requirements_pinned", False, "; ".join(offenders))
return Check("requirements_pinned", True, "all active API requirement lines use exact ==")
def _load_json(path: Path, errors: list[str]) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception as exc: # pragma: no cover - detail is reported in CLI output
errors.append(f"{_rel(path)} JSON load failed: {exc}")
return None
def check_live_coach_kb() -> Check:
root = REPO_ROOT / "data" / "kb"
workbook = root / "live_coaching_workbook_0615.json"
source_dir = root / "live_coaching_sources"
errors: list[str] = []
if not workbook.exists():
errors.append(f"{_rel(workbook)} missing")
if not source_dir.is_dir():
errors.append(f"{_rel(source_dir)} missing")
if errors:
return Check("live_coach_kb", False, "; ".join(errors))
payload = _load_json(workbook, errors)
if isinstance(payload, dict):
_validate_source_pack(path=workbook, payload=payload, errors=errors)
source_paths = sorted(source_dir.glob("*.json"))
if not source_paths:
errors.append(f"{_rel(source_dir)} has no json source files")
for path in source_paths:
data = _load_json(path, errors)
if isinstance(data, dict):
_validate_source_pack(path=path, payload=data, errors=errors)
if errors:
return Check("live_coach_kb", False, "; ".join(errors))
return Check(
"live_coach_kb",
True,
f"workbook present and {len(source_paths)} source json files parse",
)
def _validate_source_pack(path: Path, payload: dict[str, Any], errors: list[str]) -> None:
source = payload.get("source")
if not isinstance(source, dict):
errors.append(f"{_rel(path)} missing object source")
source = {}
for key in ("source_id", "title"):
if not str(source.get(key) or "").strip():
errors.append(f"{_rel(path)} source.{key} missing")
chunks = payload.get("chunks")
if not isinstance(chunks, list) or not chunks:
errors.append(f"{_rel(path)} chunks missing or empty")
return
for index, chunk in enumerate(chunks):
if not isinstance(chunk, dict):
errors.append(f"{_rel(path)} chunks[{index}] is not an object")
continue
if not str(chunk.get("summary") or "").strip():
errors.append(f"{_rel(path)} chunks[{index}].summary missing")
def _parse_env_file(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
values[key.strip()] = value.strip().strip('"').strip("'")
return values
def check_env_file(path: Path, *, allow_placeholder_secrets: bool) -> Check:
if not path.exists():
return Check("env_file", False, f"{_rel(path)} missing")
values = _parse_env_file(path)
errors: list[str] = []
for key in REQUIRED_ENV_KEYS:
value = values.get(key, "")
if key not in values:
errors.append(f"{key} missing")
elif not allow_placeholder_secrets and value in PLACEHOLDER_VALUES:
errors.append(f"{key} is placeholder or empty")
engine_mode = values.get("ENGINE_MODE", "claude_cli")
if engine_mode not in VALID_ENGINE_MODES:
errors.append(f"ENGINE_MODE invalid: {engine_mode}")
if values.get("ENVIRONMENT") in {"prod", "staging"}:
for key in FORBIDDEN_PROD_TRUE_FLAGS:
if values.get(key, "").lower() == "true":
errors.append(f"{key}=true is forbidden outside dev")
if errors:
return Check("env_file", False, "; ".join(errors))
return Check("env_file", True, f"{_rel(path)} has required deployment keys")
async def check_database(database_url: str, *, require_app_role: bool) -> Check:
try:
import asyncpg # type: ignore
except Exception as exc:
return Check("database", False, f"asyncpg unavailable: {exc}")
try:
conn = await asyncpg.connect(database_url)
except Exception as exc:
return Check("database", False, f"connect failed: {exc}")
try:
row = await conn.fetchrow(
"""
SELECT
current_user AS current_user,
(SELECT rolbypassrls FROM pg_roles WHERE rolname = current_user) AS bypassrls,
to_regclass('app.app_user') IS NOT NULL AS has_app_user,
to_regclass('app.auth_session') IS NOT NULL AS has_auth_session,
to_regclass('app.admin_engine_config') IS NOT NULL AS has_engine_config,
to_regclass('app.session_evaluation') IS NOT NULL AS has_session_evaluation,
to_regclass('app.live_coach_events') IS NOT NULL AS has_live_coach_events,
to_regclass('app.session_share_link') IS NOT NULL AS has_session_share_link,
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'persona_card'
AND column_name = 'triggers'
) AS has_persona_triggers,
EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname = 'app'
AND tablename = 'sessions'
AND policyname IN ('p_sessions_insert','p_sessions_update','p_sessions_delete')
GROUP BY schemaname, tablename
HAVING count(*) = 3
) AS has_session_write_policies,
EXISTS (
SELECT 1
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'app'
AND c.relname = 'persona_card'
AND pg_has_role(current_user, c.relowner, 'MEMBER')
) AS owns_persona_card
"""
)
except Exception as exc:
return Check("database", False, f"readiness query failed: {exc}")
finally:
await conn.close()
missing = [
key
for key in (
"has_app_user",
"has_auth_session",
"has_engine_config",
"has_session_evaluation",
"has_live_coach_events",
"has_session_share_link",
"has_persona_triggers",
"has_session_write_policies",
)
if not row[key]
]
errors: list[str] = []
if missing:
errors.append("missing DB readiness items: " + ", ".join(missing))
if require_app_role:
if row["bypassrls"]:
errors.append("runtime role has BYPASSRLS")
if row["owns_persona_card"]:
errors.append("runtime role owns app.persona_card; use app role DSN for API")
if errors:
return Check("database", False, "; ".join(errors))
role_note = f"current_user={row['current_user']}"
if require_app_role:
role_note += ", non-owner app role"
return Check("database", True, role_note)
def print_results(results: list[Check]) -> int:
failed = False
for result in results:
status = "OK" if result.ok else "FAIL"
print(f"{status} {result.name}: {result.detail}")
failed = failed or not result.ok
return 1 if failed else 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--env-file",
default=None,
help="Optional deployment env file to validate, for example infra/.env.",
)
parser.add_argument(
"--allow-placeholder-secrets",
action="store_true",
help="Allow example placeholder secrets when validating .env.example.",
)
parser.add_argument(
"--database-url",
default=os.getenv("DATABASE_URL", ""),
help="Optional runtime app-role DATABASE_URL for read-only DB readiness checks.",
)
parser.add_argument(
"--skip-db",
action="store_true",
help="Skip DB readiness checks even when DATABASE_URL is set.",
)
parser.add_argument(
"--require-app-role",
action="store_true",
help="Require the supplied DATABASE_URL to be a non-owner, NOBYPASSRLS runtime app role.",
)
return parser
async def main_async(argv: list[str]) -> int:
args = build_parser().parse_args(argv)
results = [
check_pinned_requirements(),
check_live_coach_kb(),
]
if args.env_file:
results.append(
check_env_file(
Path(args.env_file),
allow_placeholder_secrets=args.allow_placeholder_secrets,
)
)
if args.database_url and not args.skip_db:
results.append(
await check_database(
args.database_url,
require_app_role=args.require_app_role,
)
)
return print_results(results)
def main() -> int:
return asyncio.run(main_async(sys.argv[1:]))
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,267 @@
"""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()