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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ REDIS_URL=redis://localhost:6379/0
OPENAI_API_KEY=
OPENAI_MODEL=gpt-4o-mini
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
GROQ_API_KEY=
HF_API_KEY=
HF_MODEL=Qwen/Qwen2.5-7B-Instruct
GOOGLE_API_KEY=
GEMINI_MODEL=gemini-3.6-flash

Expand Down
143 changes: 128 additions & 15 deletions src/backend/app/agents/nodes/recruiter_decision.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,94 @@
import asyncio

from src.backend.app.agents.nodes.base import BaseNode
from src.backend.app.agents.state import ATSState, RecruiterDecisionOutput, RecruiterDecisionInput
from src.backend.app.agents.state import (
ATSState,
CandidateContext,
CandidateRecommendation,
RecruiterDecisionInput,
RecruiterDecisionOutput,
)
from src.backend.app.services.llm_provider import LLMProvider


class RecruiterDecisionNode(BaseNode):
batch_size = 2
history_limit = 3

@staticmethod
def _candidate_code(candidate_id: str) -> str:
return candidate_id[-4:].upper()

@classmethod
def _normalize_recommendation(
cls,
recommendation: CandidateRecommendation,
candidates_by_id: dict[str, CandidateContext],
) -> CandidateRecommendation:
candidate = candidates_by_id.get(recommendation.candidate_id)
full_name = (candidate.full_name if candidate else None) or "Ứng viên"
code = cls._candidate_code(recommendation.candidate_id)
return recommendation.model_copy(
update={
"candidate_code": code,
"full_name": full_name,
"display_name": f"{full_name} (#{code})",
}
)

@staticmethod
def _compact_candidate(candidate: CandidateContext) -> CandidateContext:
return candidate.model_copy(
update={
"summary": candidate.summary[:400],
"skills": candidate.skills[:8],
"strengths": candidate.strengths[:8],
"weaknesses": candidate.weaknesses[:6],
"experiences": [
experience.model_copy(
update={
"company": experience.company[:100],
"position": experience.position[:100],
"duration": experience.duration[:50],
"highlights": [highlight[:180] for highlight in experience.highlights[:2]],
}
)
for experience in candidate.experiences[:3]
],
"github_summary": (
candidate.github_summary[:400]
if candidate.github_summary
else None
),
"linkedin_summary": (
candidate.linkedin_summary[:400]
if candidate.linkedin_summary
else None
),
}
)

@staticmethod
def _compact_mission(mission):
return mission.model_copy(
update={
"objective": mission.objective[:1000],
"plan": [step[:160] for step in mission.plan[:5]],
}
)

@staticmethod
def _compact_history(history):
return [
action.model_copy(
update={
"action": action.action[:160],
"decision": action.decision[:300] if action.decision else None,
}
)
for action in history
]

def __init__(self, llm_provider: LLMProvider):
super().__init__()
self.llm_provider = llm_provider
Expand All @@ -14,8 +98,8 @@ async def execute(self, state: ATSState) -> ATSState:

if not state.candidate_search.candidates:
state.candidate_search.final_decision = RecruiterDecisionOutput(
recommendations=[],
final_summary="No candidates were returned by the retrieval pipeline.",
candidates=[],
summary="No candidates were returned by the retrieval pipeline.",
)
self.record_action(
state=state,
Expand All @@ -24,19 +108,48 @@ async def execute(self, state: ATSState) -> ATSState:
)
return state

# 1. Read state (Fix các biến bị trỏ sai)
decision_input = RecruiterDecisionInput(
mission=state.candidate_search.mission, # Đã fix
candidates=state.candidate_search.candidates,
history=state.candidate_search.action_history # Đã fix naming
# Evaluate small batches so one large candidate set cannot exceed the
# provider's per-request or tokens-per-minute budget.
candidates = [
self._compact_candidate(candidate)
for candidate in state.candidate_search.candidates
]
mission = self._compact_mission(state.candidate_search.mission)
history = self._compact_history(
state.candidate_search.action_history[-self.history_limit :]
)

# 2. Call LLM
decision_output = await asyncio.to_thread(
self.llm_provider.invoke,
recruiter_prompt,
decision_input,
RecruiterDecisionOutput,
recommendations: list[CandidateRecommendation] = []

for start in range(0, len(candidates), self.batch_size):
decision_input = RecruiterDecisionInput(
mission=mission,
candidates=candidates[start : start + self.batch_size],
history=history,
)
batch_output = await asyncio.to_thread(
self.llm_provider.invoke,
recruiter_prompt,
decision_input,
RecruiterDecisionOutput,
)
recommendations.extend(
self._normalize_recommendation(item, {candidate.candidate_id: candidate for candidate in candidates})
for item in batch_output.candidates
)

recommendations.sort(key=lambda item: item.confidence, reverse=True)
top_recommendations = recommendations[:5]
recommendation_summary = "; ".join(
f"{item.display_name}: {item.recommendation} ({item.confidence:.0%})"
for item in top_recommendations
)
decision_output = RecruiterDecisionOutput(
candidates=recommendations,
summary=(
f"Evaluated {len(candidates)} candidates in "
f"{(len(candidates) + self.batch_size - 1) // self.batch_size} batches. "
f"Top matches: {recommendation_summary or 'No recommendations returned.'}"
),
)

# 3. Update state
Expand Down
1 change: 1 addition & 0 deletions src/backend/app/agents/nodes/retrieval.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ def get_val(key: str, default: str | list | None = None) -> str | list | None:
mapped_candidates.append(
CandidateContext(
candidate_id=str(get_val("candidate_id", "")),
full_name=get_val("full_name"),
semantic_score=float(raw_score),
summary=str(get_val("summary", "")),
skills=list(get_val("skills", []) or []),
Expand Down
6 changes: 4 additions & 2 deletions src/backend/app/agents/prompts/planner_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ You will receive a structured input containing:
# Instructions
1. **Analyze Query Clarity**:
- Determine if the request has enough detail to construct a meaningful search query.
- If information is missing or ambiguous, set `query_assessment.clarification_detail.status` to `"needed"` or `"not_enough"` and provide ONE concise question in `suggestion`.
- If information is missing or ambiguous, set `query_assessment.clarification.status` to `"not_enough"` and provide ONE concise question in `query_assessment.clarification.question`.

2. **Formulate Search Requirements**:
- If the query is clear OR if you are revising search terms based on `reflection` feedback, generate a structured `search_requirement`.
Expand All @@ -25,4 +25,6 @@ You will receive a structured input containing:
# Constraints
- NEVER search candidates yourself.
- NEVER invent information not present in user query or context.
- Output MUST be valid JSON conforming strictly to the `PlannerOutput` schema.
- Output MUST be valid JSON conforming strictly to the `PlannerOutput` schema.
- Use `query_assessment.clarification.status` with exactly `"enough"` or `"not_enough"`.
- Do not use fields named `clarification_detail`, `needed`, or `suggestion` inside `query_assessment`.
9 changes: 7 additions & 2 deletions src/backend/app/agents/prompts/recruiter_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Your task is to analyze the retrieved top candidates against the hiring requirem
# Context Provided
You will receive:
- `mission`: The hiring requirement and target profile.
- `candidates`: List of retrieved candidate profiles (including skills, experience, resume summary, GitHub/LinkedIn insights, and match scores).
- `candidates`: A small batch of retrieved candidate profiles (including skills, experience, resume summary, GitHub/LinkedIn insights, and match scores).
- `history`: Actions taken to arrive at these candidates.

# Instructions
Expand All @@ -14,8 +14,9 @@ You will receive:
- Focus on overall **hiring quality**, practical skill alignment, and experience depth—NOT just the raw semantic matching score.

2. **Generate Final Recommendations**:
- Select and rank the best candidates.
- Evaluate every candidate in this batch and rank them relative to the other candidates in this batch.
- For each recommended candidate, provide:
- The candidate's full name when available, plus a candidate code made from the last four characters of the candidate ID.
- Clear rationale on WHY they fit.
- Any missing requirements or potential risks/gaps (e.g., lower experience in one specific skill, job-hopping history).

Expand All @@ -25,4 +26,8 @@ You will receive:
# Constraints
- NEVER invent or assume candidate qualifications not provided in the data.
- NEVER compare candidates using unprovided external knowledge.
- Return recommendations only for candidates present in this batch.
- Return `candidate_id` exactly as provided. Do not include CV URLs or UUIDs in `reasoning`.
- Return `key_strengths`, `missing_requirements`, and `risks` as short list items.
- The output fields are `summary` and `candidates`; each candidate includes `candidate_id`, `candidate_code`, `display_name`, `full_name`, `recommendation`, `confidence`, `reasoning`, `key_strengths`, `missing_requirements`, and `risks`.
- Output MUST be valid JSON conforming strictly to the `RecruiterDecisionOutput` schema.
13 changes: 11 additions & 2 deletions src/backend/app/agents/prompts/reflection_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,18 @@ You will receive:
- Set `retry` to `true` if search results are unsatisfactory and another search attempt is needed.
- Set `retry` to `false` if results are good enough to proceed to final candidate evaluation.
- Provide a clear, analytical `reason` explaining your decision.
- If `retry` is `true`, provide concrete `suggested_modifications` (e.g., "Drop location hard filter", "Remove rare framework from mandatory skills").
- If `retry` is `true`, put concrete search modifications in `suggestion` (e.g., "Drop location hard filter", "Remove rare framework from mandatory skills").
- If `retry` is `false`, set `suggestion` to `null` or a short next-step suggestion.

# Constraints
- Do NOT request or evaluate individual candidate resumes or full candidate profiles. Rely ONLY on `observation` metrics.
- Do NOT perform retrieval or ranking.
- Output MUST be valid JSON conforming strictly to the `ReflectionOutput` schema.
- Output MUST be one non-empty JSON object and nothing else. Use exactly this shape:
{
"reflection": {
"retry": false,
"reason": "The search result is sufficient for final evaluation.",
"suggestion": null
}
}
- Do not add fields such as `suggested_modifications`.
Loading
Loading