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
116 changes: 85 additions & 31 deletions ai/src/ai_server/chain/feedback_generation_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ async def generate(
rag_context: str,
voice_analysis_summary: str,
score_basis: str = "(없음)",
domain_question_counts: dict[str, int] | None = None,
) -> FeedbackResult: ...


Expand All @@ -61,6 +62,7 @@ async def generate(
rag_context: str,
voice_analysis_summary: str = "",
score_basis: str = "(없음)",
domain_question_counts: dict[str, int] | None = None,
) -> FeedbackResult:
result = await self._chain.ainvoke(
{
Expand Down Expand Up @@ -136,6 +138,19 @@ class _EvaluatorSpec:
dimension_guide: str


_TECH_GUIDE = (
"- 기술 정확성, 깊이, trade-off, 근거를 봅니다. 질문의 '기대 신호'를 "
"답변이 얼마나 짚었는지를 핵심 근거로 삼습니다."
)

_DOMAIN_KO = {
"FRONTEND": "프론트엔드",
"BACKEND": "백엔드",
"INFRA": "인프라",
"DBA": "DBA",
}


def _domain_spec(job_category: str, mode: str) -> _EvaluatorSpec:
# PERSONALITY 모드는 기술 평가자를 인성·협업 평가자로 교체(사용자 결정).
if (mode or "").upper() == "PERSONALITY":
Expand All @@ -149,18 +164,39 @@ def _domain_spec(job_category: str, mode: str) -> _EvaluatorSpec:
"기술 정확도는 평가하지 않습니다."
),
)
ko = _DOMAIN_KO.get((job_category or "").upper(), job_category)
return _EvaluatorSpec(
key="technical",
label="기술",
persona=f"{job_category} 직군 시니어 기술 면접관",
persona=f"{ko} 직군 시니어 기술 면접관",
dimension_name="기술 정확도·깊이",
dimension_guide=(
"- 기술 정확성, 깊이, trade-off, 근거를 봅니다. 질문의 '기대 신호'를 "
"답변이 얼마나 짚었는지를 핵심 근거로 삼습니다."
),
dimension_guide=_TECH_GUIDE,
)


def _domain_specs_weighted(
job_category: str, mode: str, domain_question_counts: dict[str, int]
) -> list[tuple[_EvaluatorSpec, float]]:
"""직군별 기술 평가위원 + 가중치(질문 수). PERSONALITY/단일/레거시는 단일 평가위원."""
if (mode or "").upper() == "PERSONALITY" or not domain_question_counts:
return [(_domain_spec(job_category, mode), 1.0)]
specs: list[tuple[_EvaluatorSpec, float]] = []
for dom, cnt in domain_question_counts.items():
ko = _DOMAIN_KO.get((dom or "").upper(), dom)
weight = float(cnt) if cnt and cnt > 0 else 1.0
specs.append((
_EvaluatorSpec(
key=f"tech:{dom}",
label=ko,
persona=f"{ko} 직군 시니어 기술 면접관",
dimension_name="기술 정확도·깊이",
dimension_guide=_TECH_GUIDE,
),
weight,
))
return specs or [(_domain_spec(job_category, mode), 1.0)]


_LOGIC_SPEC = _EvaluatorSpec(
key="logic",
label="논리",
Expand Down Expand Up @@ -262,8 +298,11 @@ async def generate(
rag_context: str,
voice_analysis_summary: str = "",
score_basis: str = "(없음)",
domain_question_counts: dict[str, int] | None = None,
) -> FeedbackResult:
specs = [_domain_spec(job_category, mode), _LOGIC_SPEC, _COMM_SPEC]
domain_specs = _domain_specs_weighted(job_category, mode, domain_question_counts or {})
# 평가위원 순서: 직군 기술 평가위원(N) + 논리 + 전달.
specs = [s for s, _ in domain_specs] + [_LOGIC_SPEC, _COMM_SPEC]
shared = {
"job_category": job_category,
"mode": mode,
Expand All @@ -290,52 +329,67 @@ async def generate(
return_exceptions=True,
)

results: dict[str, EvaluatorResult] = {}
# 위치 기준 매핑(직군이 여러 개라 key 가 겹치지 않게).
results: list[EvaluatorResult] = []
for spec, r in zip(specs, raw):
if isinstance(r, EvaluatorResult):
results[spec.key] = r
results.append(r)
else:
log.warning(
"feedback.panel.evaluator_failed",
evaluator=spec.key,
error=str(r),
)
results[spec.key] = EvaluatorResult()
log.warning("feedback.panel.evaluator_failed", evaluator=spec.key, error=str(r))
results.append(EvaluatorResult())

tech = results["technical"]
logic = results["logic"]
comm = results["communication"]
domain_label = specs[0].label
n_domain = len(domain_specs)
domain_results = list(zip([s for s, _ in domain_specs], [w for _, w in domain_specs], results[:n_domain]))
logic = results[n_domain]
comm = results[n_domain + 1]

# technical_accuracy = 직군 평가위원 점수의 질문수 가중평균.
technical_accuracy = _weighted_overall([(r.score, w) for _, w, r in domain_results])
overall = _weighted_overall(
[
(tech.score, self._w_tech),
(technical_accuracy, self._w_tech),
(logic.score, self._w_logic),
(comm.score, self._w_comm),
]
)
strengths = _merge_notes(
[(domain_label, tech.strength), ("논리", logic.strength), ("전달", comm.strength)]
)
weaknesses = _merge_notes(
[(domain_label, tech.weakness), ("논리", logic.weakness), ("전달", comm.weakness)]
)
keywords = _dedup_keywords(tech.keywords + logic.keywords + comm.keywords)

note_items = [(spec.label, r.strength) for spec, _, r in domain_results]
note_items += [("논리", logic.strength), ("전달", comm.strength)]
strengths = _merge_notes(note_items)
weak_items = [(spec.label, r.weakness) for spec, _, r in domain_results]
weak_items += [("논리", logic.weakness), ("전달", comm.weakness)]
weaknesses = _merge_notes(weak_items)

all_keywords: list[str] = []
for _, _, r in domain_results:
all_keywords += r.keywords
all_keywords += logic.keywords + comm.keywords
keywords = _dedup_keywords(all_keywords)

breakdown = [
PanelBreakdownItem(
evaluator=spec.label,
dimension=spec.dimension_name,
score=res.score,
strength=res.strength,
weakness=res.weakness,
score=r.score,
strength=r.strength,
weakness=r.weakness,
)
for spec, res in zip(specs, (tech, logic, comm))
for spec, _, r in domain_results
]
for spec, r in ((_LOGIC_SPEC, logic), (_COMM_SPEC, comm)):
breakdown.append(
PanelBreakdownItem(
evaluator=spec.label,
dimension=spec.dimension_name,
score=r.score,
strength=r.strength,
weakness=r.weakness,
)
)

return FeedbackResult(
overall_score=overall,
technical_accuracy=tech.score,
technical_accuracy=technical_accuracy,
logic_score=logic.score,
communication_score=comm.score,
strengths_summary=strengths,
Expand Down
3 changes: 2 additions & 1 deletion ai/src/ai_server/chain/prompts/question_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"요구 사항:\n"
"1. 정확히 {max_questions}개의 질문을 생성하되, **서로 다른 주제/영역**을 다룹니다 "
"(각 질문이 일반질문 1개의 씨앗이 되고 뒤에 꼬리질문이 붙으므로 같은 주제 반복 금지).\n"
"2. 각 질문에 적절한 category 를 부여합니다.\n"
"2. 각 질문에 적절한 category 를 부여하고, job_category 를 직군({job_categories}) 중 "
"그 질문이 겨냥하는 **하나**로 지정합니다(직군이 하나면 모두 그 값).\n"
"3. 직군({job_categories})·면접 모드({mode}) 에 맞는 비중으로 카테고리 분배:\n"
" - TECHNICAL: CS_FUNDAMENTAL + TECH_CHOICE + PROJECT_DEEP_DIVE 중심.\n"
" - PERSONALITY: BEHAVIORAL 중심.\n"
Expand Down
1 change: 1 addition & 0 deletions ai/src/ai_server/messaging/consumers/feedback_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ async def handle(self, message: AbstractIncomingMessage) -> None:
score_basis=score_basis,
rag_context=rag_context,
voice_analysis_summary=voice_analysis_summary,
domain_question_counts=req.domain_question_counts,
)

payload = FeedbackCallbackPayload(
Expand Down
2 changes: 2 additions & 0 deletions ai/src/ai_server/model/messages/feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ class GenerateFeedbackRequest(BaseModel):
messages: list[FeedbackMessageItem] = Field(default_factory=list)
context_document_ids: list[int] = Field(default_factory=list)
voice_analysis_summary: VoiceAnalysisSummary | None = None
# 다직군 패널 가중: 사용된 일반질문의 직군별 개수. 비면 단일 직군 평가.
domain_question_counts: dict[str, int] = Field(default_factory=dict)


class PanelBreakdownItem(BaseModel):
Expand Down
3 changes: 3 additions & 0 deletions ai/src/ai_server/model/messages/questions.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ class GeneratedQuestion(BaseModel):

category: QuestionCategory
question: str
# 이 질문이 겨냥한 직군. 세션의 job_categories 중 하나. 다직군 패널 가중에 사용.
# LLM 이 비우면 Core 가 대표 직군으로 폴백.
job_category: JobCategory | None = None
# 질문이 근거한 자료 인용(PROJECT/TECH 는 필수). 라이브 화면에 힌트로 노출.
target_evidence: str = ""
# 좋은 답이 드러내야 할 것 — 내부 평가용. 라이브 비노출(정답 유출 방지).
Expand Down
36 changes: 36 additions & 0 deletions ai/tests/test_feedback_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,42 @@ async def test_personality_mode_swaps_domain_to_behavioral():
assert "[인성]" in r.strengths_summary


class _PersonaChain:
"""persona 내용으로 라우팅(다직군 기술 평가위원은 dimension 이 같아 persona 로 구분)."""

async def ainvoke(self, v):
p = v["persona"]
if "백엔드" in p:
return EvaluatorResult(score=80, strength="BE 강점")
if "프론트엔드" in p:
return EvaluatorResult(score=40, strength="FE 강점")
if "논리" in p:
return EvaluatorResult(score=60)
return EvaluatorResult(score=50) # 커뮤니케이션


@pytest.mark.asyncio
async def test_multi_domain_weighted_by_question_counts():
gen = PanelFeedbackGenerator(_PersonaChain())
r = await gen.generate(
job_category="BACKEND",
mode="TECHNICAL",
total_question_count=4,
end_reason="POOL_EXHAUSTED",
transcript="t",
rag_context="(none)",
domain_question_counts={"BACKEND": 3, "FRONTEND": 1},
)
# technical = (80*3 + 40*1)/4 = 70
assert r.technical_accuracy == 70
assert r.logic_score == 60
assert r.communication_score == 50
# 직군 평가위원 2명 + 논리 + 전달 = 4
assert [b.evaluator for b in r.panel_breakdown] == ["백엔드", "프론트엔드", "논리", "전달"]
# overall = 0.5*70 + 0.25*60 + 0.25*50 = 62.5 → 62 (은행가 반올림)
assert r.overall_score == 62


@pytest.mark.asyncio
async def test_keyword_dedup():
r = await _run(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,13 @@ private void applyInitialQuestion(InterviewSession session, QuestionsCallbackPay
return;
}
int idx = 0;
String fallbackJobCategory = session.getJobCategory().name();
for (GeneratedQuestion q : questions) {
String jobCategory = (q.jobCategory() != null && !q.jobCategory().isBlank())
? q.jobCategory() : fallbackJobCategory;
poolRepository.save(SessionQuestionPool.of(
session.getId(), idx++, q.question(), q.category(), q.targetEvidence(), q.expectedSignal()));
session.getId(), idx++, q.question(), q.category(), jobCategory,
q.targetEvidence(), q.expectedSignal()));
}
poolRepository.findFirstBySessionIdAndUsedFalseOrderByIdxAsc(session.getId())
.ifPresent(first -> insertGeneralFromPool(session, first, "INITIAL_QUESTION_READY"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public class SessionFeedbackRequester {
private final SessionContextRepository contextRepository;
private final SessionFeedbackRepository feedbackRepository;
private final MessageVoiceAnalysisRepository voiceAnalysisRepository;
private final com.stackup.stackup.session.domain.SessionQuestionPoolRepository poolRepository;

@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
Expand All @@ -68,6 +69,13 @@ public void onSessionEnded(SessionEndedEvent event) {
.map(c -> c.getDocument().getId())
.toList();

Map<String, Integer> domainQuestionCounts = new java.util.LinkedHashMap<>();
String fallbackJobCategory = session.getJobCategory().name();
for (var pool : poolRepository.findBySessionIdAndUsedTrue(event.sessionId())) {
String jc = pool.getJobCategory() != null ? pool.getJobCategory() : fallbackJobCategory;
domainQuestionCounts.merge(jc, 1, Integer::sum);
}

GenerateFeedbackPayload payload = new GenerateFeedbackPayload(
session.getId(),
session.getMode().name(),
Expand All @@ -76,7 +84,8 @@ public void onSessionEnded(SessionEndedEvent event) {
event.reason(),
messages,
contextDocumentIds,
summarizeVoiceAnalysis(event.sessionId())
summarizeVoiceAnalysis(event.sessionId()),
domainQuestionCounts
);

publisher.publishToAi(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ public record GenerateFeedbackPayload(
String endReason,
List<MessageItem> messages,
List<Long> contextDocumentIds,
VoiceAnalysisSummary voiceAnalysisSummary
VoiceAnalysisSummary voiceAnalysisSummary,
// 다직군 패널 가중: 사용된 일반질문의 직군별 개수(예: {"BACKEND":3,"FRONTEND":2}). 비면 단일.
Map<String, Integer> domainQuestionCounts
) {

public record MessageItem(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public boolean isFollowup() {
public record GeneratedQuestion(
String category,
String question,
String jobCategory, // 이 질문이 겨냥한 직군(다직군 가중용). null=대표 직군 폴백.
String targetEvidence, // 질문 근거(자료 인용). 라이브 노출.
String expectedSignal // 좋은 답이 드러낼 것. 내부 저장만(라이브 비노출).
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ public class SessionQuestionPool extends BaseTimeEntity {
@Column(length = 40)
private String category;

// 이 질문이 겨냥한 직군(다직군 패널 가중용). null = 대표 직군 폴백.
@Column(name = "job_category", length = 30)
private String jobCategory;

@Column(name = "target_evidence", columnDefinition = "text")
private String targetEvidence;

Expand All @@ -42,19 +46,22 @@ public class SessionQuestionPool extends BaseTimeEntity {
@Column(nullable = false)
private boolean used = false;

private SessionQuestionPool(Long sessionId, int idx, String question,
String category, String targetEvidence, String expectedSignal) {
private SessionQuestionPool(Long sessionId, int idx, String question, String category,
String jobCategory, String targetEvidence, String expectedSignal) {
this.sessionId = sessionId;
this.idx = idx;
this.question = question;
this.category = category;
this.jobCategory = jobCategory;
this.targetEvidence = targetEvidence;
this.expectedSignal = expectedSignal;
}

public static SessionQuestionPool of(Long sessionId, int idx, String question,
String category, String targetEvidence, String expectedSignal) {
return new SessionQuestionPool(sessionId, idx, question, category, targetEvidence, expectedSignal);
public static SessionQuestionPool of(Long sessionId, int idx, String question, String category,
String jobCategory, String targetEvidence,
String expectedSignal) {
return new SessionQuestionPool(sessionId, idx, question, category, jobCategory,
targetEvidence, expectedSignal);
}

public void markUsed() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ public interface SessionQuestionPoolRepository extends JpaRepository<SessionQues

long countBySessionId(Long sessionId);

// 다직군 패널 가중: 실제 출제된(used) 일반질문 — 직군별 집계용.
List<SessionQuestionPool> findBySessionIdAndUsedTrue(Long sessionId);

// 중복 질문 회피용: 주어진 세션들에서 출제된 질문 텍스트(최신순).
@Query("select p.question from SessionQuestionPool p "
+ "where p.sessionId in :sessionIds order by p.createdAt desc")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- 멀티 면접관 패널(다직군 가중): 풀의 각 일반질문이 겨냥한 직군 태그.
-- 피드백 시 사용된 질문을 직군별로 집계해 평가위원 가중에 쓴다. null = 대표 직군 폴백.
ALTER TABLE session_question_pool ADD COLUMN job_category varchar(30);
Loading