현재 작업 상태 저장

This commit is contained in:
Yun Chan 2026-06-27 11:20:24 +09:00
parent 07cc67761e
commit 6bd91b0d5e
674 changed files with 8726 additions and 298 deletions

View file

@ -40,6 +40,10 @@ def _is_allowed_local_dev_cors_origin(value: str) -> bool:
)
def _is_local_or_forbidden_non_dev_origin(value: str) -> bool:
return _is_local_url(value) and not _is_allowed_local_dev_cors_origin(value)
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
@ -84,6 +88,14 @@ class Settings(BaseSettings):
default="https://api.openai.com/v1",
validation_alias="OPENAI_BASE_URL",
)
voice_poc_sample_tts_enabled: bool = Field(
default=False,
validation_alias="VIGNETTE_VOICE_POC_SAMPLE_TTS",
)
voice_poc_sample_tts_dir: str = Field(
default="",
validation_alias="VIGNETTE_VOICE_POC_SAMPLE_TTS_DIR",
)
# ── 세션/인증 (BFF OAuth 2.1, 토큰 서버 보관) ────────
session_secret: str = Field(
@ -143,15 +155,27 @@ class Settings(BaseSettings):
default="http://localhost:5173",
validation_alias="FRONTEND_BASE_URL",
)
frontend_origin_map: dict[str, str] = Field(
default_factory=lambda: {
"api-vignette.chanpaca.net": "https://vignette.chanpaca.net",
"api-vnet.18ka.net": "https://vnet.18ka.net",
},
validation_alias="FRONTEND_ORIGIN_MAP",
)
# ── CORS (정적 프론트 + SSE 분리경로) ────────────────
cors_origins: list[str] = Field(
default=[
"https://vignette.chanpaca.net",
"https://vnet.18ka.net",
"https://vignette-b1q.pages.dev",
],
validation_alias="CORS_ORIGINS",
)
auth_dev_login_extra_origins: list[str] = Field(
default=[],
validation_alias="AUTH_DEV_LOGIN_EXTRA_ORIGINS",
)
# Built-in personas are developer/bootstrap fixtures, not runtime truth.
# Production should use approved rows from app.persona_card only.
@ -181,6 +205,8 @@ class Settings(BaseSettings):
forbidden.append("AUTO_SEED_PERSONAS")
if self.allow_seed_persona_fallback:
forbidden.append("ALLOW_SEED_PERSONA_FALLBACK")
if self.voice_poc_sample_tts_enabled:
forbidden.append("VIGNETTE_VOICE_POC_SAMPLE_TTS")
if not self.oauth_google_client_id.strip():
forbidden.append("OAUTH_GOOGLE_CLIENT_ID")
if not self.oauth_google_client_secret.strip():
@ -189,11 +215,13 @@ class Settings(BaseSettings):
forbidden.append("SESSION_SECRET")
if _is_local_url(self.frontend_base_url):
forbidden.append("FRONTEND_BASE_URL")
if any(
_is_local_url(origin) and not _is_allowed_local_dev_cors_origin(origin)
for origin in self.cors_origins
):
if any(_is_local_or_forbidden_non_dev_origin(origin) for origin in self.cors_origins):
forbidden.append("CORS_ORIGINS")
if any(
_is_local_or_forbidden_non_dev_origin(origin)
for origin in self.frontend_origin_map.values()
):
forbidden.append("FRONTEND_ORIGIN_MAP")
if forbidden:
joined = ", ".join(forbidden)
raise ValueError(f"{joined} must be production-safe when ENVIRONMENT={self.environment}")

View file

@ -221,9 +221,40 @@ def _is_local_origin(origin: str) -> bool:
return host in {"localhost", "127.0.0.1", "::1"}
def _frontend_origin_map() -> dict[str, str]:
mapped: dict[str, str] = {}
for api_host, frontend_origin in settings.frontend_origin_map.items():
host = (api_host or "").strip().lower().rstrip(".")
origin = _url_origin(frontend_origin)
if host and origin:
mapped[host] = origin
return mapped
def _dev_login_extra_origins() -> set[str]:
return {
origin
for value in settings.auth_dev_login_extra_origins
if (origin := _url_origin(value))
}
def _dev_login_extra_hosts() -> set[str]:
return {
host
for origin in _dev_login_extra_origins()
if (host := (urlsplit(origin).hostname or "").lower())
}
def _is_dev_login_allowed_origin(origin: str) -> bool:
host = (urlsplit(origin).hostname or "").lower()
return _is_local_origin(origin) or origin in _dev_login_extra_origins() or host in _dev_login_extra_hosts()
def _configured_frontend_origins() -> list[str]:
origins: list[str] = []
for value in [settings.frontend_base_url, *settings.cors_origins]:
for value in [settings.frontend_base_url, *settings.frontend_origin_map.values(), *settings.cors_origins]:
origin = _url_origin(value)
if origin and origin not in origins:
origins.append(origin)
@ -243,8 +274,8 @@ def _frontend_origin_for_request(request: Request | None = None) -> str:
forwarded_host = request.headers.get("x-forwarded-host")
host = (forwarded_host or request.headers.get("host") or "").split(",", 1)[0].strip()
hostname = host.rsplit(":", 1)[0].lower() if host else ""
if hostname == "api-vignette.chanpaca.net":
return "https://vignette.chanpaca.net"
if mapped_origin := _frontend_origin_map().get(hostname):
return mapped_origin
if hostname in {"localhost", "127.0.0.1", "::1"}:
return fallback
@ -348,15 +379,24 @@ def _dev_login_available(request: Request) -> bool:
if settings.environment != "dev" or not settings.auth_dev_login_enabled:
return False
if _dev_login_extra_origins():
return True
saw_browser_origin = False
for header_name in ("origin", "referer"):
origin = _url_origin(request.headers.get(header_name))
if origin and not _is_local_origin(origin):
return False
if origin:
saw_browser_origin = True
if not _is_dev_login_allowed_origin(origin):
return False
if not saw_browser_origin and _dev_login_extra_origins():
return True
forwarded_host = request.headers.get("x-forwarded-host")
host = (forwarded_host or request.headers.get("host") or "").split(",", 1)[0].strip()
origin = _url_origin(f"http://{host}") if host else None
return bool(origin and _is_local_origin(origin))
return bool(origin and _is_dev_login_allowed_origin(origin))
@router.get("/config", response_model=AuthConfigResponse)

View file

@ -381,8 +381,14 @@ async def _load_session_or_404(
principal: Principal,
*,
allow_ended: bool = False,
include_turn_evaluation: bool = False,
) -> InProcSession:
sess = await session_persistence.load_session(session_id, principal, allow_ended=True)
sess = await session_persistence.load_session(
session_id,
principal,
allow_ended=True,
include_turn_evaluation=include_turn_evaluation,
)
if sess is not None:
store.put(sess)
elif runtime_fallback_allowed():
@ -874,7 +880,11 @@ async def get_session_detail(
) -> SessionDetailResponse:
"""Return a learner-owned session with transcript for resume/history."""
_ensure_learner(principal)
sess = await _load_session_or_404(session_id, principal, allow_ended=True)
sess = await _load_session_or_404(
session_id,
principal,
allow_ended=True,
)
return _session_detail(sess, review_ready=await _review_ready(sess, principal))
@ -951,7 +961,12 @@ async def get_session_review(
) -> SessionReviewResponse:
"""Return a learner-safe review built only from the stored session transcript."""
_ensure_learner(principal)
sess = await _load_session_or_404(session_id, principal, allow_ended=True)
sess = await _load_session_or_404(
session_id,
principal,
allow_ended=True,
include_turn_evaluation=True,
)
visible_turns = _learner_visible_turns(sess)
hidden_turns = len(visible_turns) != len(sess.turns)

View file

@ -49,11 +49,13 @@ _MAX_AUDIO_BYTES = 10 * 1024 * 1024
async def voice_health() -> JSONResponse:
"""Return voice service readiness."""
available = voice_service.is_available()
tts_provider = voice_service.tts_provider()
body = {
"status": "ok" if available else "degraded",
"available": available,
"stt_model": voice_svc.STT_MODEL,
"tts_model": voice_svc.TTS_MODEL,
"tts_provider": tts_provider,
"reason": None if available else "OPENAI_API_KEY is not configured",
}
return JSONResponse(body, status_code=200 if available else 503)
@ -108,6 +110,7 @@ async def voice_ws(websocket: WebSocket) -> None:
"session_id": session_id,
"voice": voice_preset.openai_voice,
"preset": voice_preset.preset,
"tts_provider": voice_service.tts_provider(),
"state": "idle",
**bind_meta,
},
@ -374,7 +377,12 @@ async def _run_turn_and_speak(
# TTS speaking state comes before chunk metadata and binary audio.
await _safe_send_json(
websocket,
{"type": "state", "state": "speaking", "voice": voice_preset.openai_voice},
{
"type": "state",
"state": "speaking",
"voice": voice_preset.openai_voice,
"tts_provider": voice_service.tts_provider(),
},
)
try:
n = 0

View file

@ -20,6 +20,7 @@ from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from typing import AsyncIterator, Optional
import httpx
@ -49,6 +50,32 @@ TTS_RESPONSE_FORMAT = "mp3"
# End-of-turn readiness default for cascaded STT providers.
EOT_SILENCE_THRESHOLD_MS = 1200
_REPO_ROOT = Path(__file__).resolve().parents[4]
POC_SAMPLE_TTS_PRESET = "soft-young-fem"
POC_SAMPLE_TTS_DEFAULT_DIR = (
_REPO_ROOT / "docs" / "voice-art" / "p1-seoyeon-higgs-v3-20260627"
)
POC_SAMPLE_TTS_CHUNK_SIZE = 4096
_POC_SAMPLE_TTS_DEFAULT_SAMPLE = "p1_seoyeon_01_depressed_slow"
_POC_SAMPLE_TTS_KEYWORDS: tuple[tuple[str, tuple[str, ...]], ...] = (
(
"p1_seoyeon_03_anxious_guarded",
("엄마", "비밀", "말하지", "불안", "무서", "걱정", "들키", "", "갈래"),
),
(
"p1_seoyeon_02_tired_flat",
("", "피곤", "무거", "아무것도", "지쳐", "힘들", "에너지"),
),
(
"p1_seoyeon_05_recovered_lively",
("오늘은", "친구", "", "괜찮았", "좋았", "해냈"),
),
(
"p1_seoyeon_04_rapport_relief",
("괜찮", "들어", "고마", "선생님", "편해", "조금", "말해"),
),
)
# OpenAI 공식 voice 풀(2026 기준): alloy, ash, ballad, coral, echo, fable,
# nova, onyx, sage, shimmer, verse. 페르소나 톤별로 골라 매핑한다.
_OPENAI_VOICES = {
@ -240,9 +267,32 @@ def assess_end_of_turn(
class VoiceService:
"""OpenAI STT/TTS 어댑터. 앱 수명주기 동안 1 인스턴스 재사용(httpx 풀 공유)."""
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None) -> None:
def __init__(
self,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
*,
poc_sample_tts_enabled: Optional[bool] = None,
environment: Optional[str] = None,
poc_sample_tts_dir: Optional[str | Path] = None,
) -> None:
self._api_key = (api_key if api_key is not None else settings.openai_api_key) or ""
self._base_url = (base_url or settings.openai_base_url or OPENAI_BASE_URL).rstrip("/")
self._environment = environment if environment is not None else settings.environment
self._poc_sample_tts_enabled = (
bool(settings.voice_poc_sample_tts_enabled)
if poc_sample_tts_enabled is None
else bool(poc_sample_tts_enabled)
)
sample_dir_value: str | Path = (
poc_sample_tts_dir
if poc_sample_tts_dir is not None
else (settings.voice_poc_sample_tts_dir or POC_SAMPLE_TTS_DEFAULT_DIR)
)
sample_dir = Path(sample_dir_value)
if not sample_dir.is_absolute():
sample_dir = _REPO_ROOT / sample_dir
self._poc_sample_tts_dir = sample_dir
self._client: Optional[httpx.AsyncClient] = None
# ── 수명주기 ──────────────────────────────────────────
@ -264,6 +314,35 @@ class VoiceService:
"""음성 기능 가용 여부(키 설정됨). 라우트가 핸드셰이크에서 검사."""
return bool(self._api_key)
def tts_provider(self) -> str:
if self._poc_sample_tts_available():
return "p1-sample-poc"
if self._api_key:
return "openai"
if self._poc_sample_tts_enabled and self._environment != "dev":
return "disabled-non-dev"
return "unavailable"
def poc_sample_tts_available(self) -> bool:
return self._poc_sample_tts_available()
def _poc_sample_tts_available(self) -> bool:
return (
self._poc_sample_tts_enabled
and self._environment == "dev"
and self._poc_sample_path(_POC_SAMPLE_TTS_DEFAULT_SAMPLE).is_file()
)
def _should_use_poc_sample_tts(self, voice: VoicePreset) -> bool:
return (
self._poc_sample_tts_enabled
and self._environment == "dev"
and voice.preset == POC_SAMPLE_TTS_PRESET
)
def _poc_sample_path(self, sample_id: str) -> Path:
return self._poc_sample_tts_dir / f"{sample_id}.mp3"
@property
def _http(self) -> httpx.AsyncClient:
if not self._api_key:
@ -341,6 +420,10 @@ class VoiceService:
text = speakable_text(text)
if not text:
return
if self._should_use_poc_sample_tts(voice):
async for chunk in self._synthesize_poc_sample_tts(text):
yield chunk
return
payload = build_tts_payload(
text,
voice,
@ -375,6 +458,25 @@ class VoiceService:
except httpx.HTTPError as e:
raise RuntimeError(f"TTS transport error: {e}") from e
async def _synthesize_poc_sample_tts(self, text: str) -> AsyncIterator[TTSChunk]:
sample_id = self._select_poc_sample_id(text)
sample_path = self._poc_sample_path(sample_id)
try:
data = sample_path.read_bytes()
except OSError as e:
raise VoiceUnavailable(f"P1 sample TTS asset is missing: {sample_path}") from e
for i in range(0, len(data), POC_SAMPLE_TTS_CHUNK_SIZE):
chunk = data[i : i + POC_SAMPLE_TTS_CHUNK_SIZE]
if chunk:
yield TTSChunk(audio=chunk)
def _select_poc_sample_id(self, text: str) -> str:
normalized = text.casefold()
for sample_id, keywords in _POC_SAMPLE_TTS_KEYWORDS:
if any(keyword.casefold() in normalized for keyword in keywords):
return sample_id
return _POC_SAMPLE_TTS_DEFAULT_SAMPLE
async def _synthesize_fallback(self, payload: dict[str, object]) -> AsyncIterator[TTSChunk]:
"""tts-1 폴백(비스트림 POST → 전체 바이트를 청크로 분할)."""
try:

View file

@ -18,6 +18,11 @@ from .store import DEFAULT_TURN_VISIBLE_TO, InProcSession, TurnRecord
_EVALUATION_CACHE: dict[str, dict[str, Any]] = {}
_SESSION_AUDIT_ROLES = {"teacher", "admin"}
_APPROPRIATENESS_SCORE = {
"warn": 1.0,
"neutral": 3.0,
"pos": 5.0,
}
_JOINED_CARD_COLUMNS = (
@ -71,6 +76,248 @@ def _stage(stage: object) -> str:
return getattr(stage, "value", str(stage))
def _clean_text(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def _safe_float(value: Any) -> float | None:
if isinstance(value, (int, float)):
return float(value)
return None
def _evaluation_loop(evaluation: dict[str, Any]) -> str:
loop = _clean_text(evaluation.get("loop")) or "fast"
return loop if loop in {"fast", "deep"} else "fast"
def _dict_items(value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list):
return []
return [item for item in value if isinstance(item, dict)]
def _evaluation_feedback_rows(evaluation: dict[str, Any] | None) -> list[dict[str, Any]]:
"""Normalize scalar/rationale turn-evaluation fields into feedback_scores rows."""
if not isinstance(evaluation, dict):
return []
loop = _evaluation_loop(evaluation)
rows: list[dict[str, Any]] = []
def add(
dimension: str,
*,
score: float | None = None,
rationale: str | None = None,
top1_score: float | None = None,
) -> None:
dim = _clean_text(dimension)
if not dim:
return
rows.append(
{
"dimension": dim,
"score": score,
"rationale": rationale,
"top1_score": top1_score,
"loop": loop,
}
)
appropriateness = _clean_text(evaluation.get("appropriateness")) or "neutral"
if appropriateness not in _APPROPRIATENESS_SCORE:
appropriateness = "neutral"
add(
"appropriateness",
score=_APPROPRIATENESS_SCORE[appropriateness],
rationale=_clean_text(evaluation.get("appropriateness_note")),
)
rapport = _safe_float(evaluation.get("rapport_signal"))
if rapport is not None:
add("rapport_signal", score=max(-1.0, min(1.0, rapport)))
theory_mode = _clean_text(evaluation.get("theory_mode"))
if theory_mode:
add("theory_mode", rationale=theory_mode)
error = _clean_text(evaluation.get("error"))
if error:
add("error", rationale=error)
for tag in _dict_items(evaluation.get("techniques")):
code = _clean_text(tag.get("code"))
rationale = _clean_text(tag.get("rationale"))
if code and rationale:
add(f"technique:{code}", rationale=rationale)
for state in _dict_items(evaluation.get("client_state_read")):
code = _clean_text(state.get("code"))
rationale = _clean_text(state.get("rationale"))
if code and rationale:
add(f"client_state:{code}", rationale=rationale)
return rows
def _evaluation_technique_rows(evaluation: dict[str, Any] | None) -> list[dict[str, str]]:
if not isinstance(evaluation, dict):
return []
rows: list[dict[str, str]] = []
for tag in _dict_items(evaluation.get("techniques")):
code = _clean_text(tag.get("code"))
if not code:
continue
rows.append(
{
"code": code,
"label_ko": _clean_text(tag.get("label_ko")) or code,
"category": _clean_text(tag.get("category")) or "",
}
)
return rows
def _evaluation_client_state_rows(evaluation: dict[str, Any] | None) -> list[dict[str, str]]:
if not isinstance(evaluation, dict):
return []
rows: list[dict[str, str]] = []
for state in _dict_items(evaluation.get("client_state_read")):
code = _clean_text(state.get("code"))
if not code:
continue
rows.append(
{
"code": code,
"label_ko": _clean_text(state.get("label_ko")) or code,
}
)
return rows
def _evaluation_comment_rows(evaluation: dict[str, Any] | None) -> list[dict[str, Any]]:
if not isinstance(evaluation, dict):
return []
deviation = evaluation.get("intent_deviation")
if not isinstance(deviation, dict):
return []
note = _clean_text(evaluation.get("appropriateness_note")) or "의도와 다른 부분"
return [{"kind": "critique", "text": note, "intent_deviation": deviation}]
def _appropriateness_from_score(score: Any) -> str:
value = _safe_float(score)
if value is None:
return "neutral"
if value >= 4.0:
return "pos"
if value <= 2.0:
return "warn"
return "neutral"
def _base_turn_evaluation(turn_seq: int, stage: str) -> dict[str, Any]:
return {
"loop": "fast",
"turn_seq": turn_seq,
"stage": stage,
"techniques": [],
"client_state_read": [],
"appropriateness": "neutral",
}
def _rebuild_turn_evaluations(
turn_refs: list[tuple[str, int, str]],
*,
feedback_rows: Iterable[Any],
technique_rows: Iterable[Any],
client_state_rows: Iterable[Any],
comment_rows: Iterable[Any],
) -> dict[str, dict[str, Any]]:
"""Rehydrate normalized DB rows back into the TurnRecord.evaluation shape."""
refs = {turn_id: (turn_seq, stage) for turn_id, turn_seq, stage in turn_refs}
evaluations: dict[str, dict[str, Any]] = {}
rationale_by_dimension: dict[str, dict[str, str]] = {}
def ensure(turn_id: str) -> dict[str, Any]:
if turn_id not in evaluations:
turn_seq, stage = refs[turn_id]
evaluations[turn_id] = _base_turn_evaluation(turn_seq, stage)
return evaluations[turn_id]
for row in feedback_rows:
turn_id = str(row["turn_id"])
if turn_id not in refs:
continue
ev = ensure(turn_id)
loop = _clean_text(row["loop"])
if loop in {"fast", "deep"}:
ev["loop"] = loop
dimension = _clean_text(row["dimension"]) or ""
rationale = _clean_text(row["rationale"])
if rationale:
rationale_by_dimension.setdefault(turn_id, {})[dimension] = rationale
if dimension == "appropriateness":
ev["appropriateness"] = _appropriateness_from_score(row["score"])
if rationale:
ev["appropriateness_note"] = rationale
elif dimension == "rapport_signal":
score = _safe_float(row["score"])
if score is not None:
ev["rapport_signal"] = max(-1.0, min(1.0, score))
elif dimension == "theory_mode" and rationale:
ev["theory_mode"] = rationale
elif dimension == "error" and rationale:
ev["error"] = rationale
for row in technique_rows:
turn_id = str(row["turn_id"])
if turn_id not in refs:
continue
code = _clean_text(row["code"])
if not code:
continue
item = {
"code": code,
"label_ko": _clean_text(row["label_ko"]) or code,
"category": _clean_text(row["category"]) or "",
}
rationale = rationale_by_dimension.get(turn_id, {}).get(f"technique:{code}")
if rationale:
item["rationale"] = rationale
ensure(turn_id)["techniques"].append(item)
for row in client_state_rows:
turn_id = str(row["turn_id"])
if turn_id not in refs:
continue
code = _clean_text(row["code"])
if not code:
continue
item = {
"code": code,
"label_ko": _clean_text(row["label_ko"]) or code,
}
rationale = rationale_by_dimension.get(turn_id, {}).get(f"client_state:{code}")
if rationale:
item["rationale"] = rationale
ensure(turn_id)["client_state_read"].append(item)
for row in comment_rows:
turn_id = str(row["turn_id"])
if turn_id not in refs:
continue
deviation = row["intent_deviation"]
if isinstance(deviation, dict):
ensure(turn_id)["intent_deviation"] = deviation
return evaluations
async def _record_session_read_audit(
conn: Any,
principal: Principal,
@ -113,7 +360,7 @@ def _state_from_row(row, card: PersonaCard) -> state_machine.SessionState:
)
def _turn_from_row(row) -> TurnRecord:
def _turn_from_row(row, evaluation: dict[str, Any] | None = None) -> TurnRecord:
created_at = _ts(row["created_at"]) or time.time()
return TurnRecord(
turn_seq=int(row["seq"]),
@ -121,6 +368,7 @@ def _turn_from_row(row) -> TurnRecord:
stage=row["stage"],
text=row["text"] or row["text_masked"] or "",
text_masked=row["text_masked"] or row["text"] or "",
turn_id=str(_row_value(row, "id")) if _row_value(row, "id") is not None else None,
created_at=created_at,
llm_provider=_row_value(row, "llm_provider"),
model=_row_value(row, "model"),
@ -131,10 +379,179 @@ def _turn_from_row(row) -> TurnRecord:
silence_ms=_row_value(row, "silence_ms"),
speech_rate=_row_value(row, "speech_rate"),
barge_in=_row_value(row, "barge_in"),
evaluation=evaluation,
visible_to=tuple(_row_value(row, "visible_to") or DEFAULT_TURN_VISIBLE_TO),
)
async def _persist_turn_evaluation(conn: Any, turn_id: str, evaluation: dict[str, Any] | None) -> None:
if not isinstance(evaluation, dict):
return
await conn.execute("SELECT set_config('app.ai_context', '1', true)")
await conn.execute("SELECT set_config('app.current_ai_view', 'evaluator', true)")
await conn.execute("SELECT set_config('app.current_sens_max', '2', true)")
for row in _evaluation_feedback_rows(evaluation):
await conn.execute(
"""
INSERT INTO app.feedback_scores (
turn_id, dimension, score, rationale, top1_score, loop
)
VALUES ($1::uuid, $2, $3, $4, $5, $6)
ON CONFLICT (turn_id, dimension) DO UPDATE SET
score = EXCLUDED.score,
rationale = EXCLUDED.rationale,
top1_score = EXCLUDED.top1_score,
loop = EXCLUDED.loop
""",
turn_id,
row["dimension"],
row["score"],
row["rationale"],
row["top1_score"],
row["loop"],
)
for row in _evaluation_technique_rows(evaluation):
label_id = await conn.fetchval(
"""
INSERT INTO app.technique_label_def (code, display_name, category)
VALUES ($1, $2, $3)
ON CONFLICT (code, version) DO UPDATE SET
display_name = EXCLUDED.display_name,
category = EXCLUDED.category,
is_active = TRUE
RETURNING label_id
""",
row["code"],
row["label_ko"],
row["category"],
)
await conn.execute(
"""
INSERT INTO app.turn_technique (turn_id, label_id)
VALUES ($1::uuid, $2)
ON CONFLICT DO NOTHING
""",
turn_id,
label_id,
)
for row in _evaluation_client_state_rows(evaluation):
label_id = await conn.fetchval(
"""
INSERT INTO app.client_state_def (code, display_name)
VALUES ($1, $2)
ON CONFLICT (code, version) DO UPDATE SET
display_name = EXCLUDED.display_name,
is_active = TRUE
RETURNING label_id
""",
row["code"],
row["label_ko"],
)
await conn.execute(
"""
INSERT INTO app.turn_client_state (turn_id, label_id)
VALUES ($1::uuid, $2)
ON CONFLICT DO NOTHING
""",
turn_id,
label_id,
)
for row in _evaluation_comment_rows(evaluation):
await conn.execute(
"""
INSERT INTO app.supervisor_comment (
turn_id, kind, text, intent_deviation
)
VALUES ($1::uuid, $2, $3, $4::jsonb)
""",
turn_id,
row["kind"],
row["text"],
row["intent_deviation"],
)
async def _load_turn_evaluations(
conn: Any,
turn_refs: list[tuple[str, int, str]],
) -> dict[str, dict[str, Any]]:
turn_ids = [turn_id for turn_id, _, _ in turn_refs]
if not turn_ids:
return {}
feedback_rows = await conn.fetch(
"""
SELECT turn_id::text AS turn_id, dimension, score, rationale, top1_score, loop
FROM app.feedback_scores
WHERE turn_id = ANY($1::uuid[])
ORDER BY created_at, dimension
""",
turn_ids,
)
technique_rows = await conn.fetch(
"""
SELECT
tt.turn_id::text AS turn_id,
d.code,
d.display_name AS label_ko,
d.category
FROM app.turn_technique tt
JOIN app.technique_label_def d ON d.label_id = tt.label_id
WHERE tt.turn_id = ANY($1::uuid[])
ORDER BY tt.turn_id, d.code
""",
turn_ids,
)
client_state_rows = await conn.fetch(
"""
SELECT
ts.turn_id::text AS turn_id,
d.code,
d.display_name AS label_ko
FROM app.turn_client_state ts
JOIN app.client_state_def d ON d.label_id = ts.label_id
WHERE ts.turn_id = ANY($1::uuid[])
ORDER BY ts.turn_id, d.code
""",
turn_ids,
)
comment_rows = await conn.fetch(
"""
SELECT turn_id::text AS turn_id, intent_deviation
FROM app.supervisor_comment
WHERE turn_id = ANY($1::uuid[])
AND intent_deviation IS NOT NULL
ORDER BY created_at
""",
turn_ids,
)
return _rebuild_turn_evaluations(
turn_refs,
feedback_rows=feedback_rows,
technique_rows=technique_rows,
client_state_rows=client_state_rows,
comment_rows=comment_rows,
)
async def _hydrate_session_turn_evaluations(sess: InProcSession) -> None:
turn_refs = [
(turn.turn_id, turn.turn_seq, turn.stage)
for turn in sess.turns
if turn.turn_id is not None
]
if not turn_refs:
return
async with acquire(ai_view="evaluator") as conn:
evaluations = await _load_turn_evaluations(conn, turn_refs)
for turn in sess.turns:
if turn.turn_id and turn.turn_id in evaluations:
turn.evaluation = evaluations[turn.turn_id]
async def ensure_review_tables() -> None:
"""Create runtime review/evaluation storage when the DB role allows it."""
try:
@ -441,6 +858,7 @@ async def load_session(
principal: Principal,
*,
allow_ended: bool = False,
include_turn_evaluation: bool = False,
) -> InProcSession | None:
try:
get_pool()
@ -496,7 +914,7 @@ async def load_session(
)
turn_rows = await conn.fetch(
"""
SELECT seq, speaker, stage, text, text_masked, created_at,
SELECT id, seq, speaker, stage, text, text_masked, created_at,
llm_provider, model, tokens_in, tokens_out, cost_usd,
audio_ref, silence_ms, speech_rate, barge_in, visible_to
FROM app.turns
@ -518,7 +936,9 @@ async def load_session(
"learner_id": sess.learner_id,
},
)
return sess
if sess is not None and include_turn_evaluation:
await _hydrate_session_turn_evaluations(sess)
return sess
except Exception:
require_runtime_fallback_allowed("session load")
return None
@ -546,7 +966,7 @@ async def append_turn(
)
or 1
)
await conn.execute(
inserted_turn_id = await conn.fetchval(
"""
INSERT INTO app.turns (
session_id, seq, speaker, stage, text, text_masked, actor_kind,
@ -559,6 +979,7 @@ async def append_turn(
$13, $14, $15, $16, $17::text[]
)
ON CONFLICT (session_id, seq) DO NOTHING
RETURNING id
""",
session_id,
seq,
@ -578,6 +999,11 @@ async def append_turn(
turn.barge_in,
list(turn.visible_to or DEFAULT_TURN_VISIBLE_TO),
)
if inserted_turn_id is None:
return False
turn.turn_id = str(inserted_turn_id)
# 원시 평가 row는 학습자 축어록이 아니라 evaluator 전용 데이터다.
await _persist_turn_evaluation(conn, turn.turn_id, turn.evaluation)
return True
except Exception:
require_runtime_fallback_allowed("session turn append")
@ -703,7 +1129,7 @@ async def list_sessions(principal: Principal) -> tuple[list[InProcSession], bool
)
turn_rows = await conn.fetch(
"""
SELECT seq, speaker, stage, text, text_masked, created_at,
SELECT id, seq, speaker, stage, text, text_masked, created_at,
llm_provider, model, tokens_in, tokens_out, cost_usd,
audio_ref, silence_ms, speech_rate, barge_in, visible_to
FROM app.turns

View file

@ -31,6 +31,7 @@ class TurnRecord:
stage: str
text: str # 원문(개발용; 실제 저장은 마스킹본)
text_masked: str
turn_id: str | None = None # DB app.turns.id. 런타임 폴백 턴은 None.
created_at: float = field(default_factory=time.time)
llm_provider: str | None = None
model: str | None = None

View file

@ -29,13 +29,13 @@ def patched_settings(**values: Any):
setattr(settings, key, value)
def _request() -> Request:
def _request(headers: list[tuple[bytes, bytes]] | None = None) -> Request:
return Request(
{
"type": "http",
"method": "GET",
"path": "/auth/login",
"headers": [(b"host", b"localhost:8000")],
"headers": headers or [(b"host", b"localhost:8000")],
}
)
@ -111,6 +111,64 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
self.assertTrue(providers["saml"].enabled)
self.assertEqual(providers["saml"].login_path, "/auth/login?provider=saml")
async def test_auth_config_allows_dev_login_from_configured_tailnet_origin(self) -> None:
request = _request(
[
(b"host", b"127.0.0.1:8000"),
(b"origin", b"https://alpaca-home.taile93291.ts.net"),
]
)
with patched_settings(
environment="dev",
auth_dev_login_enabled=True,
auth_dev_login_extra_origins=["https://alpaca-home.taile93291.ts.net"],
):
config = await auth_routes.auth_config(request)
self.assertTrue(config.dev_login_enabled)
async def test_auth_config_allows_dev_login_from_configured_tailnet_forwarded_host(self) -> None:
request = _request(
[
(b"host", b"127.0.0.1:8000"),
(b"x-forwarded-host", b"alpaca-home.taile93291.ts.net"),
]
)
with patched_settings(
environment="dev",
auth_dev_login_enabled=True,
auth_dev_login_extra_origins=["https://alpaca-home.taile93291.ts.net"],
):
config = await auth_routes.auth_config(request)
self.assertTrue(config.dev_login_enabled)
async def test_auth_config_allows_dev_login_config_probe_without_origin_when_extra_origin_is_set(self) -> None:
request = _request([(b"host", b"127.0.0.1:8000")])
with patched_settings(
environment="dev",
auth_dev_login_enabled=True,
auth_dev_login_extra_origins=["https://alpaca-home.taile93291.ts.net"],
):
config = await auth_routes.auth_config(request)
self.assertTrue(config.dev_login_enabled)
async def test_frontend_origin_map_routes_vnet_api_callbacks_to_vnet_frontend(self) -> None:
request = _request([(b"host", b"api-vnet.18ka.net")])
with patched_settings(
frontend_base_url="https://vignette.chanpaca.net",
frontend_origin_map={"api-vnet.18ka.net": "https://vnet.18ka.net"},
cors_origins=["https://vignette.chanpaca.net", "https://vnet.18ka.net"],
):
origin = auth_routes._frontend_origin_for_request(request)
self.assertEqual(origin, "https://vnet.18ka.net")
async def test_saml_login_builds_redirect_authn_request_and_relay_state(self) -> None:
with patched_settings(
auth_saml_enabled=True,

View file

@ -0,0 +1,247 @@
"""Regression tests for turn-evaluation persistence mapping."""
from __future__ import annotations
from pathlib import Path
import unittest
from unittest.mock import patch
from .deps import Principal, Role
from . import session_persistence
from .routes import sessions
from .services import persona as persona_service
from .services import state_machine
from .store import InProcSession
class FakeEvaluationConn:
def __init__(self) -> None:
self.executed: list[tuple[str, tuple[object, ...]]] = []
self.fetchvals: list[tuple[str, tuple[object, ...]]] = []
async def execute(self, query: str, *args: object) -> str:
self.executed.append((query, args))
return "INSERT 0 1"
async def fetchval(self, query: str, *args: object) -> int:
self.fetchvals.append((query, args))
if "app.technique_label_def" in query:
return 101
if "app.client_state_def" in query:
return 202
raise AssertionError(f"unexpected fetchval query: {query}")
class EvaluationPersistenceMappingTest(unittest.TestCase):
def test_feedback_rows_preserve_review_scalar_contract(self) -> None:
evaluation = {
"loop": "fast",
"turn_seq": 2,
"stage": "탐색",
"appropriateness": "pos",
"appropriateness_note": "정서를 먼저 반영했다.",
"rapport_signal": 0.75,
"theory_mode": "humanistic",
"techniques": [
{
"code": "empathy",
"label_ko": "공감",
"category": "relational",
"rationale": "감정을 명시적으로 반영했다.",
}
],
"client_state_read": [
{
"code": "affect_contact",
"label_ko": "정서 접촉/표현",
"rationale": "내담자가 감정을 언급했다.",
}
],
}
rows = {
row["dimension"]: row
for row in session_persistence._evaluation_feedback_rows(evaluation)
}
self.assertEqual(rows["appropriateness"]["score"], 5.0)
self.assertEqual(rows["appropriateness"]["rationale"], "정서를 먼저 반영했다.")
self.assertEqual(rows["rapport_signal"]["score"], 0.75)
self.assertEqual(rows["theory_mode"]["rationale"], "humanistic")
self.assertEqual(rows["technique:empathy"]["rationale"], "감정을 명시적으로 반영했다.")
self.assertEqual(rows["client_state:affect_contact"]["rationale"], "내담자가 감정을 언급했다.")
def test_rebuild_turn_evaluation_restores_review_shape(self) -> None:
rebuilt = session_persistence._rebuild_turn_evaluations(
[("11111111-1111-1111-1111-111111111111", 2, "탐색")],
feedback_rows=[
{
"turn_id": "11111111-1111-1111-1111-111111111111",
"dimension": "appropriateness",
"score": 1.0,
"rationale": "조언이 너무 빨랐다.",
"top1_score": None,
"loop": "fast",
},
{
"turn_id": "11111111-1111-1111-1111-111111111111",
"dimension": "technique:empathy",
"score": None,
"rationale": "정서 반영이 포함됐다.",
"top1_score": None,
"loop": "fast",
},
{
"turn_id": "11111111-1111-1111-1111-111111111111",
"dimension": "rapport_signal",
"score": -0.4,
"rationale": None,
"top1_score": None,
"loop": "fast",
},
],
technique_rows=[
{
"turn_id": "11111111-1111-1111-1111-111111111111",
"code": "empathy",
"label_ko": "공감",
"category": "relational",
}
],
client_state_rows=[
{
"turn_id": "11111111-1111-1111-1111-111111111111",
"code": "defensive",
"label_ko": "방어",
}
],
comment_rows=[
{
"turn_id": "11111111-1111-1111-1111-111111111111",
"intent_deviation": {
"dimension": "pacing",
"expected": "감정 탐색",
"actual": "해결 조언",
"severity": "moderate",
},
}
],
)
ev = rebuilt["11111111-1111-1111-1111-111111111111"]
self.assertEqual(ev["turn_seq"], 2)
self.assertEqual(ev["stage"], "탐색")
self.assertEqual(ev["appropriateness"], "warn")
self.assertEqual(ev["appropriateness_note"], "조언이 너무 빨랐다.")
self.assertEqual(ev["rapport_signal"], -0.4)
self.assertEqual(ev["techniques"][0]["rationale"], "정서 반영이 포함됐다.")
self.assertEqual(ev["client_state_read"][0]["label_ko"], "방어")
self.assertEqual(ev["intent_deviation"]["dimension"], "pacing")
def test_evaluation_rls_blocks_raw_learner_writes(self) -> None:
root = Path(__file__).resolve().parents[3]
sql = (root / "infra/db/init/04_audit_eval_rls.sql").read_text(encoding="utf-8")
self.assertIn("ALTER TABLE app.feedback_scores ENABLE ROW LEVEL SECURITY", sql)
self.assertIn("ALTER TABLE app.turn_technique ENABLE ROW LEVEL SECURITY", sql)
self.assertIn("ALTER TABLE app.turn_client_state ENABLE ROW LEVEL SECURITY", sql)
self.assertIn("ALTER TABLE app.supervisor_comment ENABLE ROW LEVEL SECURITY", sql)
self.assertIn("ALTER TABLE app.alternative_utterance ENABLE ROW LEVEL SECURITY", sql)
feedback_insert = sql.split("CREATE POLICY p_feedback_insert", 1)[1].split(");", 1)[0]
self.assertNotIn("learner_id = app.current_uid()", feedback_insert)
def test_append_turn_requires_inserted_turn_id(self) -> None:
source = Path(session_persistence.__file__).read_text(encoding="utf-8")
self.assertIn("RETURNING id", source)
self.assertIn("if inserted_turn_id is None:", source)
class EvaluationPersistenceIOTest(unittest.IsolatedAsyncioTestCase):
async def test_persist_turn_evaluation_uses_evaluator_context_and_real_fast_tables(self) -> None:
conn = FakeEvaluationConn()
evaluation = {
"loop": "fast",
"turn_seq": 3,
"stage": "탐색",
"appropriateness": "warn",
"appropriateness_note": "해결 제안이 빨랐다.",
"techniques": [
{
"code": "empathy",
"label_ko": "공감",
"category": "relational",
"rationale": "정서 반영.",
}
],
"client_state_read": [
{
"code": "defensive",
"label_ko": "방어",
"rationale": "짧은 회피 반응.",
}
],
"intent_deviation": {
"dimension": "pacing",
"expected": "탐색",
"actual": "조언",
"severity": "minor",
},
}
await session_persistence._persist_turn_evaluation(
conn,
"11111111-1111-1111-1111-111111111111",
evaluation,
)
executed_sql = "\n".join(query for query, _ in conn.executed)
self.assertIn("set_config('app.ai_context', '1', true)", executed_sql)
self.assertIn("set_config('app.current_ai_view', 'evaluator', true)", executed_sql)
self.assertIn("INSERT INTO app.feedback_scores", executed_sql)
self.assertIn("INSERT INTO app.turn_technique", executed_sql)
self.assertIn("INSERT INTO app.turn_client_state", executed_sql)
self.assertIn("INSERT INTO app.supervisor_comment", executed_sql)
self.assertNotIn("app.alternative_utterance", executed_sql)
async def test_route_loader_only_hydrates_when_requested(self) -> None:
principal = Principal(
user_id="00000000-0000-0000-0000-000000000101",
role=Role.LEARNER,
cohort_ids=[],
email="eval-map@hs.ac.kr",
display_name="Eval Map",
)
card = persona_service.P1
sess = InProcSession(
session_id="eval-map-session",
case_id="eval-map-case",
learner_id=principal.user_id,
persona_code=card.code,
theory_mode="humanistic",
persona=card,
state=state_machine.SessionState(
resistance=card.base_resistance(),
ideation_stage=card.ideation_baseline(),
),
)
calls: list[bool] = []
async def fake_load_session(*args, **kwargs):
calls.append(bool(kwargs.get("include_turn_evaluation")))
return sess
with patch.object(sessions.session_persistence, "load_session", fake_load_session):
await sessions._load_session_or_404(sess.session_id, principal)
await sessions._load_session_or_404(
sess.session_id,
principal,
allow_ended=True,
include_turn_evaluation=True,
)
self.assertEqual(calls, [False, True])
if __name__ == "__main__":
unittest.main()

View file

@ -222,11 +222,13 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
auth_dev_login_enabled=True,
auto_seed_personas=True,
allow_seed_persona_fallback=True,
voice_poc_sample_tts_enabled=True,
)
self.assertIn("AUTH_DEV_LOGIN_ENABLED", str(caught.exception))
self.assertIn("AUTO_SEED_PERSONAS", str(caught.exception))
self.assertIn("ALLOW_SEED_PERSONA_FALLBACK", str(caught.exception))
self.assertIn("VIGNETTE_VOICE_POC_SAMPLE_TTS", str(caught.exception))
def test_non_dev_rejects_missing_public_runtime_config(self) -> None:
with self.assertRaises(ValueError) as caught:
@ -264,6 +266,43 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(cfg.environment, "staging")
self.assertEqual(cfg.cors_origins, ["https://vignette.chanpaca.net"])
def test_non_dev_accepts_vnet_frontend_origin_map(self) -> None:
cfg = Settings(
environment="prod",
auth_dev_login_enabled=False,
auto_seed_personas=False,
allow_seed_persona_fallback=False,
session_secret="prod-secret-change-me",
oauth_google_client_id="google-client-id",
oauth_google_client_secret="google-client-secret",
frontend_base_url="https://vignette.chanpaca.net",
frontend_origin_map={
"api-vignette.chanpaca.net": "https://vignette.chanpaca.net",
"api-vnet.18ka.net": "https://vnet.18ka.net",
},
cors_origins=["https://vignette.chanpaca.net", "https://vnet.18ka.net"],
)
self.assertEqual(cfg.frontend_origin_map["api-vnet.18ka.net"], "https://vnet.18ka.net")
self.assertIn("https://vnet.18ka.net", cfg.cors_origins)
def test_non_dev_rejects_local_frontend_origin_map(self) -> None:
with self.assertRaises(ValueError) as caught:
Settings(
environment="prod",
auth_dev_login_enabled=False,
auto_seed_personas=False,
allow_seed_persona_fallback=False,
session_secret="prod-secret-change-me",
oauth_google_client_id="google-client-id",
oauth_google_client_secret="google-client-secret",
frontend_base_url="https://vignette.chanpaca.net",
frontend_origin_map={"api.example.test": "http://localhost:9999"},
cors_origins=["https://vignette.chanpaca.net"],
)
self.assertIn("FRONTEND_ORIGIN_MAP", str(caught.exception))
def test_non_dev_accepts_explicit_local_vite_cors_ports(self) -> None:
cfg = Settings(
environment="prod",

View file

@ -3,6 +3,8 @@
from __future__ import annotations
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from .services.voice import (
DEFAULT_OPENAI_VOICE,
@ -173,6 +175,49 @@ class VoiceServiceStreamTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(payload["instructions"], "Keep the tone grounded.")
self.assertEqual([chunk.audio for chunk in chunks], [b"\x80\x80", b"\xff\x00"])
async def test_dev_p1_sample_tts_streams_local_mp3_without_openai_key(self) -> None:
with TemporaryDirectory() as tmp:
sample_dir = Path(tmp)
default_audio = b"default-mp3"
anxious_audio = (b"anxious-mp3-" * 500)
(sample_dir / "p1_seoyeon_01_depressed_slow.mp3").write_bytes(default_audio)
(sample_dir / "p1_seoyeon_03_anxious_guarded.mp3").write_bytes(anxious_audio)
service = VoiceService(
api_key="",
poc_sample_tts_enabled=True,
environment="dev",
poc_sample_tts_dir=sample_dir,
)
voice = VoicePreset(preset="soft-young-fem", openai_voice="coral")
chunks = [
chunk
async for chunk in service.synthesize_stream(
"엄마한테 말하지 않는 거죠? 진짜 불안해요.",
voice,
)
]
self.assertFalse(service.is_available())
self.assertTrue(service.poc_sample_tts_available())
self.assertEqual(service.tts_provider(), "p1-sample-poc")
self.assertEqual(b"".join(chunk.audio for chunk in chunks), anxious_audio)
async def test_p1_sample_tts_is_disabled_outside_dev(self) -> None:
with TemporaryDirectory() as tmp:
sample_dir = Path(tmp)
(sample_dir / "p1_seoyeon_01_depressed_slow.mp3").write_bytes(b"default-mp3")
service = VoiceService(
api_key="",
poc_sample_tts_enabled=True,
environment="prod",
poc_sample_tts_dir=sample_dir,
)
self.assertFalse(service.is_available())
self.assertFalse(service.poc_sample_tts_available())
self.assertEqual(service.tts_provider(), "disabled-non-dev")
class EndOfTurnDecisionTest(unittest.TestCase):
def test_end_of_turn_requires_silence_threshold(self) -> None:

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Some files were not shown because too many files have changed in this diff Show more