Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
name: CI

# 기본 브랜치는 dev 다(origin/HEAD -> origin/dev). 이전 lint.yml 은 트리거가
# main/develop 이라 한 번도 실행되지 않았고, 그 사이 dev 에 eslint 에러와
# 의존성 취약점이 그대로 쌓였다. 트리거를 실제 브랜치에 맞추고 레이어별
# 테스트까지 함께 돌린다.
on:
pull_request:
branches: [dev, main]
push:
branches: [dev, main]

# 같은 브랜치에 연속 push 하면 이전 실행은 취소한다(러너 점유 방지).
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
frontend:
name: Frontend (lint · types · test · build)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
cache: npm
cache-dependency-path: frontend/package-lock.json

- name: Install
working-directory: ./frontend
run: npm ci

- name: Lint
working-directory: ./frontend
run: npm run lint

- name: Type check
working-directory: ./frontend
run: npm run type-check

- name: Test
working-directory: ./frontend
run: npm run test

# 타입체크가 통과해도 번들 단계에서 깨지는 경우(정적 자산·플러그인)를 잡는다.
- name: Build
working-directory: ./frontend
run: npm run build

backend:
name: Backend (Core, Spring)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
cache: gradle

# checkstyle 은 build.gradle 에 플러그인이 없어 태스크 자체가 없다.
# 도입하는 PR 에서 이 잡에 단계를 추가한다.
- name: Test
working-directory: ./backend
run: ./gradlew test --no-daemon

- name: Upload test report
if: failure()
uses: actions/upload-artifact@v4
with:
name: backend-test-report
path: backend/build/reports/tests/test

ai:
name: AI (FastAPI/LangChain)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: '3.13'

- uses: astral-sh/setup-uv@v5
with:
enable-cache: true

- name: Install
working-directory: ./ai
run: uv sync --extra dev

- name: Lint (flake8)
working-directory: ./ai
run: uv run flake8 .

- name: Format check (black)
working-directory: ./ai
run: uv run black --check .

- name: Test
working-directory: ./ai
run: uv run pytest -q

realtime:
name: RealTime (Go)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

# Go 코드는 realtime/ 에 있다. 이전 워크플로는 ./backend 에서 실행해
# go.mod 가 없어 조용히 통과했고, realtime 은 검증된 적이 없다.
- uses: actions/setup-go@v5
with:
go-version-file: realtime/go.mod
cache-dependency-path: realtime/go.sum

- name: Build
working-directory: ./realtime
run: go build ./...

- name: Vet
working-directory: ./realtime
run: go vet ./...

- name: Test
working-directory: ./realtime
run: go test ./...
92 changes: 0 additions & 92 deletions .github/workflows/lint.yml

This file was deleted.

6 changes: 5 additions & 1 deletion ai/src/ai_server/api/voice_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,11 @@ async def pump_transcripts() -> None:
await asyncio.wait_for(pump, timeout=10.0)
except asyncio.TimeoutError:
pump.cancel()
log.warn("voice_stream.pump.timeout", session_id=session_id, message_id=message_id)
log.warn(
"voice_stream.pump.timeout",
session_id=session_id,
message_id=message_id,
)
result = await session.result()
await session.close()

Expand Down
4 changes: 1 addition & 3 deletions ai/src/ai_server/messaging/consumers/tts_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,9 +225,7 @@ async def _synth(sentence: str) -> TtsResult:
ext = self._EXT_BY_CONTENT_TYPE.get(
res.content_type.split(";")[0].strip(), "mp3"
)
seg_key = (
f"interview/tts/{req.session_id}/{req.message_id}/seg-{seq}.{ext}"
)
seg_key = f"interview/tts/{req.session_id}/{req.message_id}/seg-{seq}.{ext}"
try:
await self._storage.put_bytes(
seg_key, res.audio_bytes, content_type=res.content_type
Expand Down
6 changes: 4 additions & 2 deletions ai/src/ai_server/model/messages/tts.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,24 @@

class GenerateTtsRequest(BaseModel):
"""Core 가 질문 메시지 commit 후 발행. AI 가 text 를 TTS 합성."""

model_config = camel_config()

session_id: int
message_id: int # interview_messages.id (INTERVIEWER 질문)
message_id: int # interview_messages.id (INTERVIEWER 질문)
text: str
mode: Literal["PERSONALITY", "TECHNICAL", "INTEGRATED"]
job_category: Literal["FRONTEND", "BACKEND", "INFRA", "DBA"]


class TtsCallbackPayload(BaseModel):
"""AI → Core. 합성 결과(S3 키) 또는 실패. Core TtsCallbackPayload 와 호환."""

model_config = camel_config()

session_id: int
message_id: int
status: Literal["SUCCEEDED", "FAILED"]
audio_key: str | None = None # camelCase 직렬화 → audioKey
audio_key: str | None = None # camelCase 직렬화 → audioKey
duration_sec: float | None = None
error_code: str | None = None
6 changes: 4 additions & 2 deletions ai/src/ai_server/model/messages/voice.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@

class AnalyzeVoiceRequest(BaseModel):
"""Core 가 음성 답변 업로드 commit 후 발행."""

model_config = camel_config()

session_id: int
message_id: int # interview_messages.id (placeholder, STT 후 content 채움)
message_id: int # interview_messages.id (placeholder, STT 후 content 채움)
parent_question_message_id: int | None = None
audio_s3_key: str
content_type: str
Expand All @@ -23,6 +24,7 @@ class AnalyzeVoiceRequest(BaseModel):

class VoiceCallbackPayload(BaseModel):
"""AI → Core. STT 결과 + 음성 지표."""

model_config = camel_config()

session_id: int
Expand All @@ -32,4 +34,4 @@ class VoiceCallbackPayload(BaseModel):
silence_duration_sec: float | None = None
filler_word_counts: dict[str, int] = Field(default_factory=dict)
pronunciation_accuracy: float | None = None
error_code: str | None = None # 실패 시 코드 (Core 가 message FAILED 마킹)
error_code: str | None = None # 실패 시 코드 (Core 가 message FAILED 마킹)
33 changes: 26 additions & 7 deletions ai/src/ai_server/observability/llm_logging_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,22 @@ class CoreAiLogCallback(AsyncCallbackHandler):
metadata 에서 토큰 수를 추출한다.
"""

def __init__(self, *, core_client: CoreClient, request_type: str, default_model: str) -> None:
def __init__(
self, *, core_client: CoreClient, request_type: str, default_model: str
) -> None:
self._core = core_client
self._request_type = request_type
self._default_model = default_model
self._start_at: dict[UUID, float] = {}

async def on_llm_start(self, serialized, prompts, *, run_id: UUID, **kwargs: Any) -> None:
async def on_llm_start(
self, serialized, prompts, *, run_id: UUID, **kwargs: Any
) -> None:
self._start_at[run_id] = time.perf_counter()

async def on_llm_end(self, response: LLMResult, *, run_id: UUID, **kwargs: Any) -> None:
async def on_llm_end(
self, response: LLMResult, *, run_id: UUID, **kwargs: Any
) -> None:
latency_ms = self._latency_ms(run_id)
in_tok, out_tok, model = _extract_usage(response, self._default_model)
await self._fire(
Expand All @@ -44,7 +50,9 @@ async def on_llm_end(self, response: LLMResult, *, run_id: UUID, **kwargs: Any)
error_message=None,
)

async def on_llm_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
async def on_llm_error(
self, error: BaseException, *, run_id: UUID, **kwargs: Any
) -> None:
latency_ms = self._latency_ms(run_id)
await self._fire(
request_type=self._request_type,
Expand All @@ -69,10 +77,13 @@ async def _do() -> None:
await self._core.record_ai_log(**kwargs)
except Exception as exc:
log.warn("core.ai_log.background_failed", error=str(exc), **kwargs)

asyncio.create_task(_do())


def _extract_usage(response: LLMResult, default_model: str) -> tuple[int | None, int | None, str]:
def _extract_usage(
response: LLMResult, default_model: str
) -> tuple[int | None, int | None, str]:
in_tok = out_tok = None
model = default_model
try:
Expand All @@ -90,8 +101,16 @@ def _extract_usage(response: LLMResult, default_model: str) -> tuple[int | None,
info = getattr(gen, "generation_info", None) or {}
usage = info.get("usage_metadata") or info.get("token_usage") or {}
if isinstance(usage, dict):
in_tok = in_tok or usage.get("input_tokens") or usage.get("prompt_tokens")
out_tok = out_tok or usage.get("output_tokens") or usage.get("completion_tokens")
in_tok = (
in_tok
or usage.get("input_tokens")
or usage.get("prompt_tokens")
)
out_tok = (
out_tok
or usage.get("output_tokens")
or usage.get("completion_tokens")
)
except Exception as exc:
log.debug("ai_log.usage_extract_failed", error=str(exc))
return in_tok, out_tok, model
Loading
Loading