Skip to content

Commit b019d19

Browse files
authored
Merge pull request #53 from Team-StackUp/feature/ai-voice-stt-log-flow-v2
feat: AI 서버 STT 로그 연동
2 parents e66c362 + 91da13c commit b019d19

7 files changed

Lines changed: 231 additions & 22 deletions

File tree

ai/src/ai_server/messaging/consumers/voice_consumer.py

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
from __future__ import annotations
22

3+
import asyncio
4+
import time
5+
36
import structlog
47
from aio_pika.abc import AbstractIncomingMessage
58

9+
from ai_server.core.client import CoreClient
610
from ai_server.messaging.idempotency import LruIdempotencyStore
711
from ai_server.messaging.publisher import CallbackPublisher
812
from ai_server.model.envelope import Envelope
@@ -37,20 +41,28 @@ def __init__(
3741
idempotency: LruIdempotencyStore,
3842
callback_routing_key: str,
3943
filler_pattern: str,
44+
core_client: CoreClient | None = None,
4045
) -> None:
4146
self._stt = stt
4247
self._storage = storage
4348
self._publisher = publisher
4449
self._idempotency = idempotency
4550
self._callback_routing_key = callback_routing_key
4651
self._filler_pattern = filler_pattern
52+
self._core_client = core_client
4753

4854
async def handle(self, message: AbstractIncomingMessage) -> None:
4955
async with message.process(requeue=False):
5056
try:
51-
envelope = Envelope[AnalyzeVoiceRequest].model_validate_json(message.body)
57+
envelope = Envelope[AnalyzeVoiceRequest].model_validate_json(
58+
message.body
59+
)
5260
except Exception as exc:
53-
log.error("voice.parse.failed", error=str(exc), delivery_tag=message.delivery_tag)
61+
log.error(
62+
"voice.parse.failed",
63+
error=str(exc),
64+
delivery_tag=message.delivery_tag,
65+
)
5466
raise
5567

5668
if self._idempotency.is_seen_then_mark(envelope.message_id):
@@ -74,13 +86,29 @@ async def handle(self, message: AbstractIncomingMessage) -> None:
7486
await self._publish_failed(envelope, req, code="AUDIO_FETCH_FAILED")
7587
return
7688

89+
started = None
7790
try:
91+
started = time.perf_counter()
7892
result = await self._stt.transcribe(
7993
audio_bytes=audio_bytes,
8094
content_type=req.content_type,
8195
hint=req.previous_question_text,
8296
)
97+
self._record_stt_log(
98+
req,
99+
envelope,
100+
latency_ms=_elapsed_ms(started),
101+
status="SUCCESS",
102+
error_message=None,
103+
)
83104
except SttError as exc:
105+
self._record_stt_log(
106+
req,
107+
envelope,
108+
latency_ms=_elapsed_ms(started),
109+
status="FAILED",
110+
error_message=exc.message,
111+
)
84112
log.error(
85113
"voice.stt.failed",
86114
error=str(exc),
@@ -90,7 +118,16 @@ async def handle(self, message: AbstractIncomingMessage) -> None:
90118
await self._publish_failed(envelope, req, code=exc.code)
91119
return
92120
except Exception as exc:
93-
log.error("voice.stt.unexpected", error=str(exc), session_id=req.session_id)
121+
self._record_stt_log(
122+
req,
123+
envelope,
124+
latency_ms=_elapsed_ms(started),
125+
status="FAILED",
126+
error_message=str(exc),
127+
)
128+
log.error(
129+
"voice.stt.unexpected", error=str(exc), session_id=req.session_id
130+
)
94131
await self._publish_failed(envelope, req, code="TRANSCRIPTION_FAILED")
95132
return
96133

@@ -123,7 +160,9 @@ async def handle(self, message: AbstractIncomingMessage) -> None:
123160
trace_id=envelope.trace_id,
124161
)
125162

126-
async def _publish_failed(self, envelope, req: AnalyzeVoiceRequest, *, code: str) -> None:
163+
async def _publish_failed(
164+
self, envelope, req: AnalyzeVoiceRequest, *, code: str
165+
) -> None:
127166
payload = VoiceCallbackPayload(
128167
session_id=req.session_id,
129168
interview_message_id=req.message_id,
@@ -142,3 +181,48 @@ async def _publish_failed(self, envelope, req: AnalyzeVoiceRequest, *, code: str
142181
correlation_id=envelope.message_id,
143182
context=envelope.context,
144183
)
184+
185+
def _record_stt_log(
186+
self,
187+
req: AnalyzeVoiceRequest,
188+
envelope,
189+
*,
190+
latency_ms: int | None,
191+
status: str,
192+
error_message: str | None,
193+
) -> None:
194+
if self._core_client is None:
195+
return
196+
197+
async def _do() -> None:
198+
try:
199+
await self._core_client.record_ai_log(
200+
request_type="stt.transcribe",
201+
model_name=_provider_model_name(self._stt),
202+
input_tokens=None,
203+
output_tokens=None,
204+
latency_ms=latency_ms,
205+
status=status,
206+
user_id=envelope.context.user_id,
207+
session_id=req.session_id,
208+
error_message=(error_message[:1000] if error_message else None),
209+
)
210+
except Exception as exc:
211+
log.warn(
212+
"voice.ai_log.failed", error=str(exc), session_id=req.session_id
213+
)
214+
215+
asyncio.create_task(_do())
216+
217+
218+
def _elapsed_ms(started: float | None) -> int | None:
219+
if started is None:
220+
return None
221+
return int((time.perf_counter() - started) * 1000)
222+
223+
224+
def _provider_model_name(provider) -> str | None:
225+
value = getattr(provider, "model_name", None)
226+
if isinstance(value, str):
227+
return value
228+
return None

ai/src/ai_server/messaging/runner.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ def __init__(self, settings: Settings) -> None:
194194
idempotency=self._idempotency,
195195
callback_routing_key=settings.ai_callback_routing_voice,
196196
filler_pattern=settings.voice_filler_pattern,
197+
core_client=core_client,
197198
)
198199

199200
self._consumers: list[tuple[AbstractRobustQueue, str]] = []

ai/src/ai_server/voice/stt/base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ class TranscriptionResult:
2121

2222

2323
class SttProvider(Protocol):
24+
@property
25+
def model_name(self) -> str | None: ...
26+
2427
async def transcribe(
2528
self,
2629
*,

ai/src/ai_server/voice/stt/deepgram.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ def __init__(
4141
self._timeout_sec = timeout_sec
4242
self._client = client
4343

44+
@property
45+
def model_name(self) -> str | None:
46+
return self._model
47+
4448
async def transcribe(
4549
self,
4650
*,
@@ -69,17 +73,33 @@ async def transcribe(
6973

7074
try:
7175
if self._client is not None:
72-
resp = await self._client.post(url, params=params, headers=headers, content=audio_bytes)
76+
resp = await self._client.post(
77+
url, params=params, headers=headers, content=audio_bytes
78+
)
7379
else:
7480
async with httpx.AsyncClient(timeout=self._timeout_sec) as client:
75-
resp = await client.post(url, params=params, headers=headers, content=audio_bytes)
81+
resp = await client.post(
82+
url, params=params, headers=headers, content=audio_bytes
83+
)
7684
except httpx.HTTPError as exc:
77-
raise SttError(code="STT_UNAVAILABLE", message=f"Deepgram 호출 실패: {exc}", retriable=True) from exc
85+
raise SttError(
86+
code="STT_UNAVAILABLE",
87+
message=f"Deepgram 호출 실패: {exc}",
88+
retriable=True,
89+
) from exc
7890

7991
if resp.status_code in (401, 403):
80-
raise SttError(code="STT_AUTH_FAILED", message=f"Deepgram 인증 실패: {resp.status_code}", retriable=False)
92+
raise SttError(
93+
code="STT_AUTH_FAILED",
94+
message=f"Deepgram 인증 실패: {resp.status_code}",
95+
retriable=False,
96+
)
8197
if resp.status_code >= 500:
82-
raise SttError(code="STT_UNAVAILABLE", message=f"Deepgram 5xx: {resp.status_code}", retriable=True)
98+
raise SttError(
99+
code="STT_UNAVAILABLE",
100+
message=f"Deepgram 5xx: {resp.status_code}",
101+
retriable=True,
102+
)
83103
if resp.status_code >= 400:
84104
raise SttError(
85105
code="STT_BAD_REQUEST",
@@ -90,13 +110,19 @@ async def transcribe(
90110
try:
91111
data = resp.json()
92112
except ValueError as exc:
93-
raise SttError(code="STT_BAD_RESPONSE", message=f"JSON 파싱 실패: {exc}", retriable=True) from exc
113+
raise SttError(
114+
code="STT_BAD_RESPONSE",
115+
message=f"JSON 파싱 실패: {exc}",
116+
retriable=True,
117+
) from exc
94118

95119
metadata = data.get("metadata") or {}
96120
results = data.get("results") or {}
97121
channels = results.get("channels") or []
98122
if not channels:
99-
return TranscriptionResult(text="", language=self._language, duration_sec=None, segments=[])
123+
return TranscriptionResult(
124+
text="", language=self._language, duration_sec=None, segments=[]
125+
)
100126

101127
alt = (channels[0].get("alternatives") or [{}])[0]
102128
text = str(alt.get("transcript") or "")

ai/src/ai_server/voice/stt/mock.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ class MockSttProvider:
1313
def __init__(self, *, default_text: str = "(mock transcript)") -> None:
1414
self._default_text = default_text
1515

16+
@property
17+
def model_name(self) -> str | None:
18+
return "mock-stt"
19+
1620
async def transcribe(
1721
self,
1822
*,

ai/src/ai_server/voice/stt/openai_whisper.py

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ def __init__(
3838
self._timeout_sec = timeout_sec
3939
self._client = client
4040

41+
@property
42+
def model_name(self) -> str | None:
43+
return self._model
44+
4145
async def transcribe(
4246
self,
4347
*,
@@ -60,17 +64,33 @@ async def transcribe(
6064

6165
try:
6266
if self._client is not None:
63-
resp = await self._client.post(url, headers=headers, files=files, data=data)
67+
resp = await self._client.post(
68+
url, headers=headers, files=files, data=data
69+
)
6470
else:
6571
async with httpx.AsyncClient(timeout=self._timeout_sec) as client:
66-
resp = await client.post(url, headers=headers, files=files, data=data)
72+
resp = await client.post(
73+
url, headers=headers, files=files, data=data
74+
)
6775
except httpx.HTTPError as exc:
68-
raise SttError(code="STT_UNAVAILABLE", message=f"OpenAI 호출 실패: {exc}", retriable=True) from exc
76+
raise SttError(
77+
code="STT_UNAVAILABLE",
78+
message=f"OpenAI 호출 실패: {exc}",
79+
retriable=True,
80+
) from exc
6981

7082
if resp.status_code in (401, 403):
71-
raise SttError(code="STT_AUTH_FAILED", message=f"OpenAI 인증 실패: {resp.status_code}", retriable=False)
83+
raise SttError(
84+
code="STT_AUTH_FAILED",
85+
message=f"OpenAI 인증 실패: {resp.status_code}",
86+
retriable=False,
87+
)
7288
if resp.status_code >= 500:
73-
raise SttError(code="STT_UNAVAILABLE", message=f"OpenAI 5xx: {resp.status_code}", retriable=True)
89+
raise SttError(
90+
code="STT_UNAVAILABLE",
91+
message=f"OpenAI 5xx: {resp.status_code}",
92+
retriable=True,
93+
)
7494
if resp.status_code >= 400:
7595
raise SttError(
7696
code="STT_BAD_REQUEST",
@@ -81,7 +101,11 @@ async def transcribe(
81101
try:
82102
data_resp = resp.json()
83103
except ValueError as exc:
84-
raise SttError(code="STT_BAD_RESPONSE", message=f"JSON 파싱 실패: {exc}", retriable=True) from exc
104+
raise SttError(
105+
code="STT_BAD_RESPONSE",
106+
message=f"JSON 파싱 실패: {exc}",
107+
retriable=True,
108+
) from exc
85109

86110
segments = []
87111
for seg in data_resp.get("segments") or []:
@@ -90,13 +114,17 @@ async def transcribe(
90114
start_sec=float(seg.get("start", 0.0)),
91115
end_sec=float(seg.get("end", 0.0)),
92116
text=str(seg.get("text", "")),
93-
avg_logprob=(float(seg["avg_logprob"]) if "avg_logprob" in seg else None),
117+
avg_logprob=(
118+
float(seg["avg_logprob"]) if "avg_logprob" in seg else None
119+
),
94120
)
95121
)
96122
return TranscriptionResult(
97123
text=str(data_resp.get("text", "")),
98124
language=data_resp.get("language"),
99-
duration_sec=(float(data_resp["duration"]) if "duration" in data_resp else None),
125+
duration_sec=(
126+
float(data_resp["duration"]) if "duration" in data_resp else None
127+
),
100128
segments=segments,
101129
)
102130

0 commit comments

Comments
 (0)