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
3 changes: 3 additions & 0 deletions ai/src/ai_server/chain/prompts/question_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@
" - TECHNICAL: CS_FUNDAMENTAL + TECH_CHOICE + PROJECT_DEEP_DIVE 중심.\n"
" - PERSONALITY: BEHAVIORAL 중심.\n"
" - INTEGRATED: 모두 균형.\n"
" - JOB_TAILORED: JD 요구를 검증하는 TECH_CHOICE·PROJECT_DEEP_DIVE 와 지원동기·"
"직무적합(컬처핏)을 묻는 BEHAVIORAL 을 균형 있게 섞습니다. JD 요구 대비 갭·지원동기 질문은 "
"지원자 자료에 근거가 없을 수 있으니 그 경우 target_evidence 를 비워도 됩니다(JD 인용은 가능).\n"
" 직군이 여러 개면 각 직군에 질문이 고르게 분배되도록 합니다.\n"
" 자료가 받쳐주는 만큼만 분배하고, 근거 없는 카테고리를 억지로 채우지 마세요.\n"
"4. 각 질문에 target_evidence 와 expected_signal 을 채웁니다:\n"
Expand Down
41 changes: 29 additions & 12 deletions ai/src/ai_server/messaging/consumers/feedback_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def __init__(
job_fit_evaluator: JobFitEvaluator | None = None,
answer_coach: AnswerCoach | None = None,
coaching_max_answers: int = 30,
coaching_concurrency: int = 5,
) -> None:
self._generator = generator
self._publisher = publisher
Expand All @@ -75,6 +76,7 @@ def __init__(
self._job_fit_evaluator = job_fit_evaluator
self._answer_coach = answer_coach
self._coaching_max_answers = coaching_max_answers
self._coaching_concurrency = max(1, coaching_concurrency)

async def handle(self, message: AbstractIncomingMessage) -> None:
async with message.process(requeue=False):
Expand Down Expand Up @@ -135,9 +137,11 @@ async def handle(self, message: AbstractIncomingMessage) -> None:
self._coach_answers(req, rag_context),
)
)
if self_intro_item is not None:
result.panel_breakdown.append(self_intro_item)
result.panel_breakdown.extend(job_fit_items)
# 빈 평가위원 항목(점수·내용 모두 없음)은 표시하지 않는다 — LLM 부분 응답이 빈 패널로 새는 것 방지.
extras = [self_intro_item, *job_fit_items]
result.panel_breakdown.extend(
e for e in extras if e is not None and _panel_has_content(e)
)

payload = FeedbackCallbackPayload(
session_id=req.session_id,
Expand Down Expand Up @@ -255,20 +259,23 @@ async def _coach_answers(
)
pairs = pairs[: self._coaching_max_answers]
target_role = _coaching_target_role(req)
# 게이트웨이 429/과부하 방지 — 답변별 코칭 호출 동시성을 제한한다.
sem = asyncio.Semaphore(self._coaching_concurrency)

async def _one(
question: FeedbackMessageItem, answer: FeedbackMessageItem
) -> AnswerCoachingItem | None:
try:
res = await self._answer_coach.coach(
job_category=req.job_category,
mode=req.mode,
target_role=target_role,
question=question.content,
expected_signal=question.expected_signal or "",
answer=answer.content,
rag_context=rag_context,
)
async with sem:
res = await self._answer_coach.coach(
job_category=req.job_category,
mode=req.mode,
target_role=target_role,
question=question.content,
expected_signal=question.expected_signal or "",
answer=answer.content,
rag_context=rag_context,
)
except Exception as exc: # noqa: BLE001
log.warning(
"feedback.coaching.failed",
Expand Down Expand Up @@ -346,6 +353,16 @@ def _coaching_target_role(req: GenerateFeedbackRequest) -> str:
return f"{head}채용공고(JD) 발췌: {jd[:800]}"


def _panel_has_content(item: PanelBreakdownItem) -> bool:
"""점수도 서술도 없는 빈 평가위원 항목인지. 빈 항목은 표시에서 제외한다."""
return (
item.score is not None
or bool((item.detail or "").strip())
or bool((item.strength or "").strip())
or bool((item.weakness or "").strip())
)


def _to_panel_item(
label: str, dimension: str, ev: EvaluatorResult
) -> PanelBreakdownItem:
Expand Down
9 changes: 9 additions & 0 deletions ai/src/ai_server/messaging/consumers/questions_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,15 @@ def _build_initial_rag_query(req: GenerateQuestionsRequest) -> str:
f"mode: {req.mode}",
f"job categories: {', '.join(req.job_categories)}",
]
# 자기소개·JD 를 쿼리에 포함해 검색이 지원자 발화/직무 요구로 steering 되게 한다.
intro = (req.self_intro_answer or "").strip()
if intro:
parts.append(f"self-introduction: {intro[:600]}")
jd = (req.target_job_description or "").strip()
if jd:
company = (req.target_company_name or "").strip()
head = f"target role at {company}" if company else "target role (JD)"
parts.append(f"{head}: {jd[:800]}")
for d in req.documents:
doc_parts = [f"document #{d.document_id} {d.source_type}"]
if d.summary:
Expand Down
31 changes: 31 additions & 0 deletions ai/tests/test_feedback_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,37 @@ async def test_consumer_appends_job_fit_panel_item():
assert und_items[0].score == 60.0


@pytest.mark.asyncio
async def test_consumer_drops_empty_job_fit_axis():
# understanding 축이 빈(점수·내용 없음) 경우 그 패널 항목은 표시에서 제외된다.
generator = _generator()
publisher = MagicMock()
publisher.publish = AsyncMock()
evaluator = MagicMock()
evaluator.evaluate = AsyncMock(
return_value=JobFitResult(
fit=EvaluatorResult(score=72.0, detail="결제 경험 부합"),
understanding=EvaluatorResult(), # 빈 축
)
)

consumer = FeedbackConsumer(
generator=generator,
publisher=publisher,
idempotency=LruIdempotencyStore(max_size=10),
callback_routing_key="callback.feedback",
core_client=MagicMock(),
embedder=None,
job_fit_evaluator=evaluator,
)
await consumer.handle(_StubMessage(_job_tailored_envelope()))

payload: FeedbackCallbackPayload = publisher.publish.await_args.kwargs["payload"]
labels = [b.evaluator for b in payload.panel_breakdown]
assert "직무 적합도" in labels
assert "직무 이해도" not in labels # 빈 축은 제외


@pytest.mark.asyncio
async def test_consumer_skips_job_fit_when_not_job_tailored():
generator = _generator()
Expand Down
25 changes: 24 additions & 1 deletion ai/tests/test_questions_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,9 @@ async def test_consumer_forwards_self_intro_answer_to_generator():
await consumer.handle(_StubMessage(body))

call = generator.generate.await_args
assert call.kwargs["self_introduction"] == "결제 시스템을 설계한 백엔드 3년차입니다."
assert (
call.kwargs["self_introduction"] == "결제 시스템을 설계한 백엔드 3년차입니다."
)


@pytest.mark.asyncio
Expand Down Expand Up @@ -386,6 +388,27 @@ async def test_consumer_falls_back_to_document_context_when_rag_fails():
assert "Retrieved document chunks" not in context


def test_build_initial_rag_query_includes_self_intro_and_jd():
from ai_server.messaging.consumers.questions_consumer import (
_build_initial_rag_query,
)
from ai_server.model.messages.questions import GenerateQuestionsRequest

req = GenerateQuestionsRequest(
session_id=1,
mode="JOB_TAILORED",
job_categories=["BACKEND"],
documents=[],
self_intro_answer="결제 시스템을 만든 백엔드 3년차",
target_company_name="토스",
target_job_description="대용량 결제 백엔드, Kotlin/Spring",
)
q = _build_initial_rag_query(req)
assert "결제 시스템을 만든 백엔드" in q # 자기소개가 쿼리에 포함
assert "대용량 결제 백엔드" in q # JD 가 쿼리에 포함
assert "토스" in q


def test_build_context_handles_empty_documents():
assert _build_context([]) == "(no documents)"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import com.stackup.stackup.session.application.dto.FeedbackCallbackPayload;
import com.stackup.stackup.session.domain.InterviewMessage;
import com.stackup.stackup.session.domain.InterviewMessageRepository;
import com.stackup.stackup.session.domain.MessageRole;
import com.stackup.stackup.session.domain.InterviewSession;
import com.stackup.stackup.session.domain.InterviewSessionRepository;
import com.stackup.stackup.session.domain.SessionFeedback;
Expand Down Expand Up @@ -112,7 +113,10 @@ private void applyAnswerCoaching(Long sessionId, java.util.List<AnswerCoachingIt
continue;
}
InterviewMessage message = messageRepository.findById(item.messageId()).orElse(null);
if (message == null || !sessionId.equals(message.getSession().getId())) {
// 답변(INTERVIEWEE) 메시지에만 기록 — 잘못된 messageId 로 질문에 복기가 달리는 것 방지.
if (message == null
|| !sessionId.equals(message.getSession().getId())
|| message.getRole() != MessageRole.INTERVIEWEE) {
continue;
}
message.recordCoaching(item.modelAnswer(), item.answerRewrite(), item.coachingComment());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import com.stackup.stackup.session.domain.InterviewMessageRepository;
import com.stackup.stackup.session.domain.InterviewSession;
import com.stackup.stackup.session.domain.InterviewSessionRepository;
import com.stackup.stackup.session.domain.MessageRole;
import com.stackup.stackup.session.domain.SessionQuestionPool;
import com.stackup.stackup.session.domain.SessionQuestionPoolRepository;
import com.stackup.stackup.session.domain.SessionStatus;
Expand Down Expand Up @@ -236,11 +237,14 @@ private void recordAnswerEvaluation(QuestionsCallbackPayload payload) {
if (eval == null || payload.answerMessageId() == null) {
return;
}
messageRepository.findById(payload.answerMessageId()).ifPresent(answer -> {
answer.recordAnswerEvaluation(
eval.specificity(), eval.logic(), eval.structure(), eval.correctness());
messageRepository.save(answer);
});
messageRepository.findById(payload.answerMessageId())
// 답변(INTERVIEWEE) 메시지에만 평가 기록 — 잘못된 messageId 로 질문에 점수가 달리는 것 방지.
.filter(answer -> answer.getRole() == MessageRole.INTERVIEWEE)
.ifPresent(answer -> {
answer.recordAnswerEvaluation(
eval.specificity(), eval.logic(), eval.structure(), eval.correctness());
messageRepository.save(answer);
});
}

private boolean isProcessed(String messageId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
public record SessionCreateRequest(
@Size(max = 200) String title,
@Size(max = 4000) String memo,
// mode: TECHNICAL/PERSONALITY/INTEGRATED 중 하나를 선택한다.
// mode: TECHNICAL/PERSONALITY/INTEGRATED/JOB_TAILORED하나. JOB_TAILORED 는 아래 회사명·JD 필수.
@NotNull SessionMode mode,
// 직군 다중 선택. 최소 1개. 첫 항목이 대표 직군이 된다.
@NotEmpty List<JobCategory> jobCategories,
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/domain/session/lib/turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ export function canSubmitAnswer(session: Session, messages: Message[]): boolean
}

export function sessionProgress(session: Session): { current: number; max: number; ratio: number } {
const max = session.maxQuestions ?? 0
// 실제 면접은 일반질문 수(자기소개 포함 = generalQuestionCount)에서 끝나거나 maxQuestions 상한에서
// 끝난다 — 둘 중 작은 값이 진짜 목표. maxQuestions(상한)로만 나누면 진행률이 과소 표시된다.
const cap = session.maxQuestions ?? 0
const general = session.generalQuestionCount ?? cap
const max = cap > 0 ? Math.min(general || cap, cap) : general
const current = session.totalQuestionCount ?? 0
const ratio = max > 0 ? Math.min(current / max, 1) : 0
return { current, max, ratio }
Expand Down
17 changes: 12 additions & 5 deletions frontend/src/features/history/ui/ScoreTrend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,19 @@ export function ScoreTrend({ stats }: { stats: UserStats }) {
: null,
)
.filter((p): p is { x: number; y: number } => p !== null)
const vals = sessions
.map((s) => s[m.key])
.filter((v): v is number => typeof v === 'number')
const latest = vals.length ? Math.round(vals[vals.length - 1]) : null
// 최신 점수 + 지난번(바로 직전 세션) 대비 델타. sparse 지표에서 비인접 세션끼리 비교하지 않는다.
const seq = sessions.map((s) => (typeof s[m.key] === 'number' ? (s[m.key] as number) : null))
let latestIdx = -1
for (let i = seq.length - 1; i >= 0; i--) {
if (seq[i] !== null) {
latestIdx = i
break
}
}
const latest = latestIdx >= 0 ? Math.round(seq[latestIdx] as number) : null
const prev = latestIdx > 0 ? seq[latestIdx - 1] : null
const delta =
vals.length >= 2 ? Math.round(vals[vals.length - 1] - vals[vals.length - 2]) : null
latest !== null && prev !== null ? Math.round((seq[latestIdx] as number) - prev) : null
return { ...m, pts, latest, delta }
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,18 @@ export function AnswerCoachingAccordion({ message }: { message: Message }) {
</div>
)}
{evals.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{evals.map((e) => (
<span
key={e}
className="rounded-pill bg-surface-raised px-2 py-0.5 text-caption text-fg-muted"
>
{e}
</span>
))}
<div className="flex flex-col gap-1">
<span className="text-caption text-fg-subtle">답변 평가</span>
<div className="flex flex-wrap gap-1.5">
{evals.map((e) => (
<span
key={e}
className="rounded-pill bg-surface-raised px-2 py-0.5 text-caption text-fg-muted"
>
{e}
</span>
))}
</div>
</div>
)}
{message.coachingComment && (
Expand Down
25 changes: 21 additions & 4 deletions frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,17 @@ export function InterviewSetupForm({
</section>
<section className="flex flex-col gap-2">
<h2 className="text-h6 text-fg">면접 모드</h2>
<ModeSelector value={mode} onChange={setMode} />
<ModeSelector
value={mode}
onChange={(m) => {
setMode(m)
// 직무 맞춤이 아니면 회사명·JD 입력값을 비운다(전송엔 이미 미포함이지만 상태도 정리).
if (m !== 'JOB_TAILORED') {
setCompanyName('')
setJobDescription('')
}
}}
/>
</section>
{isJobTailored && (
<section className="flex flex-col gap-3">
Expand Down Expand Up @@ -176,9 +186,16 @@ export function InterviewSetupForm({
<h2 className="text-h6 text-fg">참고 문서 (선택)</h2>
<ContextDocumentPicker documents={documents} selected={selected} onToggle={toggle} />
</section>
<Button type="submit" size="lg" loading={isSubmitting} disabled={!valid}>
면접 생성
</Button>
<div className="flex flex-col gap-2">
{isJobTailored && jobDescription.trim().length === 0 && (
<p className="text-caption text-warning-700">
채용공고(JD)를 입력해야 직무 맞춤 면접을 생성할 수 있어요.
</p>
)}
<Button type="submit" size="lg" loading={isSubmitting} disabled={!valid}>
면접 생성
</Button>
</div>
</form>
)
}