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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ LLM_FLASH_MODEL=gemini-3.1-flash-lite
LLM_FLASH_TEMPERATURE=0.4
LLM_FLASH_MAX_TOKENS=512

# 음성 STT (Phase 2). auto=DEEPGRAM_API_KEY 있으면 deepgram, 없고 OPENAI_API_KEY 있으면 openai_whisper, 둘 다 없으면 mock.
STT_PROVIDER=auto
DEEPGRAM_API_KEY=
DEEPGRAM_MODEL=whisper-large
DEEPGRAM_LANGUAGE=ko
OPENAI_API_KEY=
WHISPER_MODEL=whisper-1
WHISPER_LANGUAGE=ko

# RealTime server
REALTIME_PORT=38020
REALTIME_LOG_LEVEL=info
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/deploy-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ jobs:
CORE_INTERNAL_API_KEY: ${{ secrets.CORE_INTERNAL_API_KEY }}
GITHUB_OAUTH_CLIENT_ID: ${{ secrets.GH_OAUTH_CLIENT_ID }}
GITHUB_OAUTH_CLIENT_SECRET: ${{ secrets.GH_OAUTH_CLIENT_SECRET }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
run: |
cd "$DEPLOY_DIR"
if [ ! -f .env ]; then
Expand Down Expand Up @@ -107,6 +109,13 @@ jobs:
upsert_env "CORE_INTERNAL_API_KEY" "$CORE_INTERNAL_API_KEY"
upsert_env "GITHUB_OAUTH_CLIENT_ID" "$GITHUB_OAUTH_CLIENT_ID"
upsert_env "GITHUB_OAUTH_CLIENT_SECRET" "$GITHUB_OAUTH_CLIENT_SECRET"
upsert_env "OPENAI_API_KEY" "$OPENAI_API_KEY"
upsert_env "DEEPGRAM_API_KEY" "$DEEPGRAM_API_KEY"
# STT_PROVIDER 자동 선택. auto 로 두면 AI factory 가 보유 키 기준으로 선택 (deepgram > openai_whisper > mock).
# 키가 1개라도 있으면 auto 로 설정해두면 충분 — 명시적 전환 불필요.
if [ -n "$DEEPGRAM_API_KEY" ] || [ -n "$OPENAI_API_KEY" ]; then
upsert_env "STT_PROVIDER" "auto"
fi

- name: Build and restart app services
run: |
Expand Down
15 changes: 15 additions & 0 deletions ai/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,18 @@ EMBEDDING_CHUNK_OVERLAP=200
EMBEDDING_BATCH_SIZE=32

GEMINI_API_KEY=

# 음성 STT (Phase 2). auto=키 보유에 따라 deepgram > openai_whisper > mock 자동 선택.
STT_PROVIDER=auto
# Deepgram (한국어 정확도 우선 권장. 신규 가입 시 $200 크레딧).
DEEPGRAM_API_KEY=
DEEPGRAM_BASE_URL=https://api.deepgram.com/v1
DEEPGRAM_MODEL=whisper-large
DEEPGRAM_LANGUAGE=ko
DEEPGRAM_TIMEOUT_SEC=60
# OpenAI Whisper (Mindlogic 미지원, 직접 호출).
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.openai.com/v1
WHISPER_MODEL=whisper-1
WHISPER_LANGUAGE=ko
WHISPER_TIMEOUT_SEC=60
100 changes: 100 additions & 0 deletions ai/src/ai_server/chain/feedback_generation_chain.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
from __future__ import annotations

from typing import Protocol

from langchain_core.output_parsers import PydanticOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import Runnable
from pydantic import BaseModel, Field

from ai_server.chain.prompts.feedback_generation import HUMAN_PROMPT, SYSTEM_PROMPT
from ai_server.config.settings import Settings
from ai_server.core.client import CoreClient
from ai_server.observability.llm_logging_callback import CoreAiLogCallback


class FeedbackResult(BaseModel):
overall_score: float | None = Field(None, description="0~100")
technical_accuracy: float | None = Field(None, description="0~100")
logic_score: float | None = Field(None, description="0~100")
communication_score: float | None = Field(None, description="0~100")
strengths_summary: str | None = Field(None)
weaknesses_summary: str | None = Field(None)
improvement_keywords: list[str] = Field(default_factory=list)


class FeedbackGenerator(Protocol):
async def generate(
self,
*,
job_category: str,
interview_type: str,
total_question_count: int | None,
end_reason: str | None,
transcript: str,
rag_context: str,
) -> FeedbackResult: ...


class LlmFeedbackGenerator:
def __init__(self, chain: Runnable) -> None:
self._chain = chain

async def generate(
self,
*,
job_category: str,
interview_type: str,
total_question_count: int | None,
end_reason: str | None,
transcript: str,
rag_context: str,
) -> FeedbackResult:
result = await self._chain.ainvoke(
{
"job_category": job_category,
"interview_type": interview_type,
"total_question_count": total_question_count or 0,
"end_reason": end_reason or "USER_REQUEST",
"transcript": transcript,
"rag_context": rag_context or "(none)",
}
)
if not isinstance(result, FeedbackResult):
raise TypeError(
f"chain returned {type(result).__name__}, expected FeedbackResult"
)
return result


def build_feedback_generation_chain(
settings: Settings, core_client: CoreClient | None = None
) -> Runnable:
from langchain_openai import ChatOpenAI

parser = PydanticOutputParser(pydantic_object=FeedbackResult)
prompt = ChatPromptTemplate.from_messages(
[
("system", SYSTEM_PROMPT),
("human", HUMAN_PROMPT),
]
).partial(format_instructions=parser.get_format_instructions())

callbacks = []
if core_client is not None:
callbacks.append(
CoreAiLogCallback(
core_client=core_client,
request_type="generate.feedback",
default_model=settings.llm_pro_model,
)
)

llm = ChatOpenAI(
model=settings.llm_pro_model,
temperature=settings.llm_pro_temperature,
api_key=settings.llm_api_key or None,
base_url=settings.llm_base_url,
callbacks=callbacks,
)
return prompt | llm | parser
33 changes: 33 additions & 0 deletions ai/src/ai_server/chain/prompts/feedback_generation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# 종합 피드백 생성 (US-24)
# Pro 모델 + 세션 전체 메시지 시퀀스 + (옵션) RAG 컨텍스트 청크.
# 출력: 0~100 점수 4개 + 강점·약점 요약 + 개선 키워드 리스트.

SYSTEM_PROMPT = (
"당신은 IT 직군 면접 평가관입니다. 지원자의 모든 답변을 종합해 객관적이고 건설적인 피드백을 한국어로 작성합니다.\n"
"- 점수 (0~100 정수형, 산정 불가 시 null):\n"
" - overall_score: 종합 점수\n"
" - technical_accuracy: 기술 정확도\n"
" - logic_score: 논리·인과관계 명확성\n"
" - communication_score: 답변의 명료성·구조화\n"
"- 요약:\n"
" - strengths_summary: 가장 잘한 점 3가지 이내 (각 1~2문장).\n"
" - weaknesses_summary: 가장 부족한 점 3가지 이내 (각 1~2문장).\n"
" - improvement_keywords: 다음 면접에서 채울 키워드 5~10개 (짧은 명사구).\n"
"- 평가 원칙:\n"
" - 단일 답변보다 시퀀스 흐름을 우선 고려 (꼬리질문 대응의 깊이가 중요).\n"
" - 답변이 짧거나 비어 있으면 해당 점수는 낮게 또는 null.\n"
" - 컨텍스트 청크(분석 문서 일부) 가 있다면 사실 검증에만 활용 (직접 인용 X).\n"
"- 응답은 반드시 지정된 JSON 스키마를 따릅니다."
)

HUMAN_PROMPT = (
"직군: {job_category}\n"
"면접 유형: {interview_type}\n"
"총 질문 수: {total_question_count}\n"
"종료 사유: {end_reason}\n\n"
"=== 메시지 시퀀스 ===\n"
"{transcript}\n\n"
"=== RAG 컨텍스트 청크 (참고용, 직접 인용 금지) ===\n"
"{rag_context}\n\n"
"{format_instructions}"
)
20 changes: 20 additions & 0 deletions ai/src/ai_server/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,30 @@ class Settings(BaseSettings):
ai_queue_web: str = "ai.analyze.web"
ai_queue_questions: str = "ai.generate.questions"
ai_queue_followup: str = "ai.generate.followup"
ai_queue_feedback: str = "ai.generate.feedback"
ai_queue_voice: str = "ai.analyze.voice"
ai_queue_prefetch: int = 10
ai_callback_exchange: str = "stackup.ai-to-core"
ai_callback_routing_analysis: str = "callback.analysis"
ai_callback_routing_questions: str = "callback.questions"
ai_callback_routing_feedback: str = "callback.feedback"
ai_callback_routing_voice: str = "callback.voice"
feedback_rag_top_k: int = 5

# STT (음성 답변). "auto" 면 deepgram > openai_whisper > mock 순으로 키 보유 여부에 따라 자동 선택.
stt_provider: Literal["auto", "mock", "openai_whisper", "deepgram"] = "auto"
openai_api_key: str = ""
openai_base_url: str = "https://api.openai.com/v1"
whisper_model: str = "whisper-1"
whisper_language: str = "ko"
whisper_timeout_sec: float = 60.0
deepgram_api_key: str = ""
deepgram_base_url: str = "https://api.deepgram.com/v1"
deepgram_model: str = "whisper-large" # 한국어 정확도 우선; 저비용 우선 시 nova-2.
deepgram_language: str = "ko"
deepgram_timeout_sec: float = 60.0
# 음성 분석
voice_filler_pattern: str = r"(?:음+|어+|그+|아+)"
ai_publisher_name: str = "ai-server"
ai_idempotency_lru_size: int = 1024

Expand Down
64 changes: 64 additions & 0 deletions ai/src/ai_server/core/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ class EmbeddingChunkPayload:
embedding: list[float]


@dataclass(frozen=True)
class EmbeddingSearchHit:
document_id: int
chunk_index: int
chunk_text: str
distance: float


# 코어 서버 API 호출용
class CoreClient(Protocol):
async def fetch_github_token(self, user_id: int) -> str: ...
Expand All @@ -45,6 +53,14 @@ async def upsert_embeddings(
chunks: list[EmbeddingChunkPayload],
) -> int: ...

async def search_embeddings(
self,
*,
query_embedding: list[float],
document_ids: list[int] | None = None,
top_k: int = 5,
) -> list[EmbeddingSearchHit]: ...

async def record_ai_log(
self,
*,
Expand Down Expand Up @@ -234,6 +250,54 @@ async def _do_upsert(
return len(body["chunks"])
return count

async def search_embeddings(
self,
*,
query_embedding: list[float],
document_ids: list[int] | None = None,
top_k: int = 5,
) -> list[EmbeddingSearchHit]:
"""pgvector cosine topK 검색. 실패 시 빈 리스트 반환 (RAG 보강용이므로 fatal 아님)."""
body = {
"queryEmbedding": query_embedding,
"documentIds": list(document_ids or []),
"topK": top_k,
}
path = "/api/internal/embeddings/search"
try:
if self._client is not None:
resp = await self._client.post(path, json=body)
else:
async with self._build_client() as client:
resp = await client.post(path, json=body)
except httpx.HTTPError as exc:
log.warn("core.embedding.search.failed", error=str(exc))
return []

if resp.status_code >= 400:
log.warn(
"core.embedding.search.non_2xx",
status=resp.status_code,
body=resp.text[:200],
)
return []
try:
data = resp.json()
except ValueError:
return []
hits = data.get("hits") if isinstance(data, dict) else None
if not isinstance(hits, list):
return []
return [
EmbeddingSearchHit(
document_id=int(h.get("documentId", 0)),
chunk_index=int(h.get("chunkIndex", 0)),
chunk_text=str(h.get("chunkText", "")),
distance=float(h.get("distance", 0.0)),
)
for h in hits
]

async def record_ai_log(
self,
*,
Expand Down
Loading