G7 증명과 G8 clean-head 승격 준비

This commit is contained in:
Yun Chan 2026-08-09 18:22:03 +09:00
parent 94c681d450
commit 5221f79e3f
52 changed files with 6876 additions and 506 deletions

View file

@ -153,7 +153,7 @@ class Settings(BaseSettings):
validation_alias="VIGNETTE_LOCAL_WHISPER_STT_URL",
)
local_whisper_stt_model: str = Field(
default="large-v3",
default="small",
validation_alias="VIGNETTE_LOCAL_WHISPER_STT_MODEL",
)
local_whisper_stt_language: str = Field(

View file

@ -206,9 +206,13 @@ async def voice_health() -> JSONResponse:
"stt_model": voice_service.stt_model(),
"stt_batch_fallback_available": voice_service.batch_stt_available(),
"tts_model": (
voice_svc.HIGGS_TTS_MODEL
if tts_provider == "higgs"
else voice_svc.TTS_MODEL
voice_svc.MELOTTS_TTS_MODEL
if tts_provider == "melotts"
else (
voice_svc.HIGGS_TTS_MODEL
if tts_provider == "higgs"
else voice_svc.TTS_MODEL
)
),
"tts_provider": tts_provider,
"limits": {

View file

@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from datetime import datetime, timezone
from typing import Any, Callable
from ..store import InProcSession
@ -44,7 +44,7 @@ class LearnerGrowthMetrics:
def iso_datetime(ts: float | None) -> str | None:
if ts is None:
return None
return datetime.fromtimestamp(ts).isoformat(timespec="seconds")
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(timespec="seconds")
def session_activity_time(sess: InProcSession) -> float:

View file

@ -998,7 +998,7 @@ class VoiceService:
local_whisper_stt_model
if local_whisper_stt_model is not None
else settings.local_whisper_stt_model
).strip() or "large-v3"
).strip() or "small"
self._local_whisper_stt_language = (
local_whisper_stt_language
if local_whisper_stt_language is not None

View file

@ -10,7 +10,7 @@ from __future__ import annotations
import re
from collections import Counter
from dataclasses import dataclass
from datetime import datetime
from datetime import datetime, timedelta, timezone
from typing import Any, Literal, Optional, cast
from pydantic import BaseModel, Field
@ -31,6 +31,7 @@ WorksheetItemSpec = tuple[str, str, list[str], WorksheetSpeaker | None]
WorksheetSectionSpec = tuple[str, str, list[WorksheetItemSpec]]
LEARNER_VISIBLE_AI_ROLE = "counselor"
KOREA_STANDARD_TIME = timezone(timedelta(hours=9), name="KST")
MISSING_SESSION_EVALUATION_GRACE_SECONDS = 30.0
MISSING_SESSION_EVALUATION_ERROR = (
"회기말 평가가 제한 시간 이후에도 저장되지 않았습니다. AI 평가 재시도가 필요합니다."
@ -478,7 +479,11 @@ def stage_label(stage: object) -> StageLabel:
def iso(ts: float | None) -> str | None:
if ts is None:
return None
return datetime.fromtimestamp(ts).isoformat(timespec="seconds")
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(timespec="seconds")
def session_calendar_date(ts: float) -> str:
return datetime.fromtimestamp(ts, tz=KOREA_STANDARD_TIME).strftime("%Y-%m-%d")
def learner_visible_turns(sess: InProcSession) -> list[TurnRecord]:
@ -1757,7 +1762,7 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
initial=client_initial,
persona=_persona_label(sess.persona_code, str(sess.persona.difficulty)),
),
date=datetime.fromtimestamp(sess.created_at).strftime("%Y-%m-%d"),
date=session_calendar_date(sess.created_at),
durationLabel=_duration_label(duration_seconds),
durationSeconds=duration_seconds,
reachedPhase=reached_phase,

View file

@ -0,0 +1,49 @@
"""회기 읽기 모델의 시간 직렬화 계약 테스트."""
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from .session_read_model import iso, session_calendar_date
from .services.session_metrics import iso_datetime
class SessionReadModelIsoTest(unittest.TestCase):
def test_none_remains_none(self) -> None:
self.assertIsNone(iso(None))
def test_epoch_is_serialized_as_utc_with_offset(self) -> None:
self.assertEqual(iso(0), "1970-01-01T00:00:00+00:00")
def test_timestamp_round_trips_without_local_timezone_drift(self) -> None:
timestamp = 1_800_000_120.0
rendered = iso(timestamp)
self.assertIsNotNone(rendered)
assert rendered is not None
parsed = datetime.fromisoformat(rendered)
self.assertEqual(parsed.tzinfo, timezone.utc)
self.assertEqual(parsed.timestamp(), timestamp)
def test_serialized_timestamp_has_explicit_utc_suffix(self) -> None:
rendered = iso(1_800_000_120.0)
self.assertIsNotNone(rendered)
assert rendered is not None
self.assertTrue(rendered.endswith("+00:00"))
def test_dashboard_metrics_use_the_same_utc_contract(self) -> None:
timestamp = 1_800_000_120.0
self.assertEqual(iso_datetime(timestamp), iso(timestamp))
def test_review_calendar_date_uses_korea_standard_time(self) -> None:
utc_late_afternoon = datetime(2026, 8, 8, 15, 30, tzinfo=timezone.utc)
self.assertEqual(session_calendar_date(utc_late_afternoon.timestamp()), "2026-08-09")
if __name__ == "__main__":
unittest.main()

View file

@ -693,7 +693,7 @@ class LocalWhisperStreamingTest(unittest.IsolatedAsyncioTestCase):
query = parse_qs(urlsplit(url).query)
self.assertEqual(urlsplit(url).scheme, "ws")
self.assertEqual(urlsplit(url).hostname, "127.0.0.1")
self.assertEqual(query["model"], ["large-v3"])
self.assertEqual(query["model"], ["small"])
self.assertEqual(query["language"], ["ko"])
self.assertEqual(query["sample_rate"], ["16000"])
self.assertEqual(query["channels"], ["1"])
@ -701,7 +701,7 @@ class LocalWhisperStreamingTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(query["utterance_end_ms"], ["1200"])
self.assertEqual(result.text, "안녕하세요")
self.assertEqual(result.model, "large-v3")
self.assertEqual(result.model, "small")
self.assertEqual(result.language, "ko")
self.assertEqual([word.word for word in result.words], ["안녕하세요"])
self.assertAlmostEqual(result.words[0].start, 0.05, places=3)
@ -927,7 +927,7 @@ class LocalWhisperProviderSelectionTest(unittest.TestCase):
service = VoiceService(api_key="", stt_provider="local_whisper")
self.assertTrue(service.streaming_stt_enabled())
self.assertEqual(service.stt_provider(), "local_whisper")
self.assertEqual(service.stt_model(), "large-v3")
self.assertEqual(service.stt_model(), "small")
def test_missing_sidecar_url_falls_back_to_batch(self) -> None:
service = VoiceService(

View file

@ -16,6 +16,7 @@ from .persona_repository import PersonaVoiceMap
from .routes import voice as voice_routes
from .services import multimodal_alliance_store
from .services.voice import (
MELOTTS_TTS_MODEL,
StreamingTranscriptEvent,
TranscriptResult,
TranscriptWord,
@ -150,6 +151,27 @@ class _FakeRouteStreamingSession:
self.aborted = True
class VoiceHealthContractTest(unittest.IsolatedAsyncioTestCase):
async def test_melotts_health_reports_the_operated_model(self) -> None:
service = SimpleNamespace(
is_available=lambda: True,
stt_available=lambda: True,
tts_available=lambda: True,
tts_provider=lambda: "melotts",
stt_provider=lambda: "local_whisper",
stt_model=lambda: "small",
batch_stt_available=lambda: False,
)
with patch.object(voice_routes, "voice_service", service):
response = await voice_routes.voice_health()
body = json.loads(bytes(response.body))
self.assertEqual(body["status"], "ok")
self.assertEqual(body["tts_provider"], "melotts")
self.assertEqual(body["tts_model"], MELOTTS_TTS_MODEL)
class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
def _bind_result(self) -> tuple[str, VoicePreset, None, dict[str, object]]:
return (