diff --git a/src/backend/app/agents/nodes/planner.py b/src/backend/app/agents/nodes/planner.py index baa722d..ff9cba5 100644 --- a/src/backend/app/agents/nodes/planner.py +++ b/src/backend/app/agents/nodes/planner.py @@ -24,6 +24,7 @@ async def execute(self, state: ATSState) -> ATSState: history=history, reflection=reflection, user_query=user_query, + initial_search_criteria=state.initial_search_criteria, ) # 3. Call LLM diff --git a/src/backend/app/agents/router.py b/src/backend/app/agents/router.py index 9cffd1b..3a317ee 100644 --- a/src/backend/app/agents/router.py +++ b/src/backend/app/agents/router.py @@ -51,6 +51,7 @@ GroqProvider, HFProvider, ) +from src.backend.app.schemas.orchestrator import ChatResponse, ChatResponseType router = APIRouter(prefix="/agents", tags=["agents"]) logger = logging.getLogger(__name__) @@ -75,6 +76,7 @@ class AgentChatRequest(BaseModel): #: viên: chỉ tin TRƯỚC của người dùng khi họ trả lời câu hỏi làm rõ (planner #: chỉ đọc tin cuối nên phải ghép lại). history: list[Union[str, HistoryTurn]] = Field(default_factory=list, max_length=12) + initial_search_criteria: dict[str, object] | None = None def user_history(self) -> list[str]: return [h if isinstance(h, str) else h.content for h in self.history @@ -245,6 +247,7 @@ async def _stream_agent( objective = "\n".join([*prior, request.message]) if prior else request.message initial_state = ATSState( messages=[objective], + initial_search_criteria=request.initial_search_criteria, candidate_search=CandidateSearchState( mission=Mission( objective=objective, @@ -287,7 +290,11 @@ async def _stream_agent( yield _sse( "done", - {"conversation_id": str(request.conversation_id), "result": result}, + ChatResponse( + type=ChatResponseType.AGENT_EXECUTION, + conversation_id=str(request.conversation_id), + result=result, + ).model_dump(mode="json"), ) except Exception as exc: logger.exception("Agent stream failed", extra={"error_type": type(exc).__name__}) @@ -301,8 +308,12 @@ async def chat_with_agent( settings: Annotated[Settings, Depends(get_settings)], _limit: Annotated[None, Depends(agent_rate_limit)] = None, ) -> StreamingResponse: + # Keep this legacy route behind the same intent router as /chat. Existing + # clients must not be able to activate an agent graph without orchestration. + from src.backend.app.chat.router import _stream_chat + return StreamingResponse( - _stream_agent(request, settings, _user), + _stream_chat(request, settings, _user), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, ) diff --git a/src/backend/app/agents/state.py b/src/backend/app/agents/state.py index fed0459..9fdf2df 100644 --- a/src/backend/app/agents/state.py +++ b/src/backend/app/agents/state.py @@ -242,6 +242,8 @@ class PlannerInput(BaseModel): history: list[ActionRecord] + initial_search_criteria: dict[str, Any] | None = None + class PlannerOutput(BaseModel): """ @@ -456,4 +458,6 @@ class ATSState(BaseModel): max_steps: int = 20 - messages: list[str] = Field(default_factory=list) \ No newline at end of file + messages: list[str] = Field(default_factory=list) + + initial_search_criteria: dict[str, Any] | None = None \ No newline at end of file diff --git a/src/backend/app/chat/router.py b/src/backend/app/chat/router.py new file mode 100644 index 0000000..fbd28aa --- /dev/null +++ b/src/backend/app/chat/router.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from typing import Annotated + +from fastapi import APIRouter, Depends +from fastapi.responses import StreamingResponse + +from modules.auth.domain.models import AuthUser +from modules.shared.infrastructure.auth_dependencies import require_operational_roles +from modules.shared.infrastructure.config import Settings, get_settings +from modules.shared.infrastructure.rate_limit import agent_rate_limit +from src.backend.app.agents.router import AgentChatRequest, _agent_error_message, _sse, _stream_agent +from src.backend.app.schemas.orchestrator import ( + ChatResponse, + ChatResponseType, + IntentType, + OrchestratorDecision, +) +from src.backend.app.services.llm_provider import FallbackLLMProvider, GroqProvider, HFProvider +from src.backend.app.services.orchestrator import OrchestratorService + +router = APIRouter(prefix="/chat", tags=["chat"]) + + +def _orchestrator(settings: Settings) -> OrchestratorService: + return OrchestratorService( + FallbackLLMProvider(primary=GroqProvider(), fallback=HFProvider()) + ) + + +async def _stream_chat( + request: AgentChatRequest, + settings: Settings, + user: AuthUser | None = None, +) -> AsyncIterator[str]: + try: + decision: OrchestratorDecision = await asyncio.to_thread( + _orchestrator(settings).classify, + request.message, + request.history, + ) + + if decision.intent == IntentType.GENERAL_CHAT: + yield _sse( + "direct", + ChatResponse( + type=ChatResponseType.DIRECT_CHAT, + conversation_id=str(request.conversation_id), + content=decision.direct_response + or "Tôi có thể giúp gì cho bạn về tuyển dụng?", + ).model_dump(mode="json"), + ) + return + + agent_request = request.model_copy( + update={"initial_search_criteria": decision.initial_search_criteria} + ) + yield _sse( + "route", + { + "type": ChatResponseType.AGENT_EXECUTION.value, + "message": "Agent is handling the candidate search.", + }, + ) + async for event in _stream_agent(agent_request, settings, user): + yield event + except Exception as exc: + yield _sse("error", {"message": _agent_error_message(exc)}) + + +@router.post("") +async def chat( + request: AgentChatRequest, + _user: Annotated[AuthUser, Depends(require_operational_roles())], + settings: Annotated[Settings, Depends(get_settings)], + _limit: Annotated[None, Depends(agent_rate_limit)] = None, +) -> StreamingResponse: + return StreamingResponse( + _stream_chat(request, settings, _user), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, + ) diff --git a/src/backend/app/dtos/find_candidate.py b/src/backend/app/dtos/find_candidate.py new file mode 100644 index 0000000..33cd15f --- /dev/null +++ b/src/backend/app/dtos/find_candidate.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class FindCandidateRequest(BaseModel): + role_description: str = Field( + ..., min_length=1, max_length=4000, description="Role requirements in plain text" + ) + experience_expectations: str | None = Field( + default=None, max_length=4000, description="Experience requirements text" + ) + must_have_skills: list[str] = Field( + default_factory=list, max_length=25, description="Hard filter skill list" + ) + top_k: int = Field(default=20, ge=1, le=100) + + +class FindCandidateResult(BaseModel): + candidate_uuid: str + overall_score: float + lexical_score: float + semantic_score: float + full_name: str | None = None + email: str | None = None + phone: str | None = None + summary: str = "" + skills: list[str] = Field(default_factory=list) + github_username: str | None = None + github_url: str | None = None + linkedin_url: str | None = None \ No newline at end of file diff --git a/src/backend/app/pipelines/candidate_ranking_service.py b/src/backend/app/pipelines/candidate_ranking_service_pipeline.py similarity index 100% rename from src/backend/app/pipelines/candidate_ranking_service.py rename to src/backend/app/pipelines/candidate_ranking_service_pipeline.py diff --git a/src/backend/app/repositories/candidate_search_repository.py b/src/backend/app/repositories/candidate_search_repository.py index 54fe362..4fa3389 100644 --- a/src/backend/app/repositories/candidate_search_repository.py +++ b/src/backend/app/repositories/candidate_search_repository.py @@ -99,4 +99,40 @@ async def search_similar_embeddings( if not response.data: return [] - return [SemanticSearchResult.model_validate(row) for row in response.data] \ No newline at end of file + return [SemanticSearchResult.model_validate(row) for row in response.data] + + async def get_candidate_details( + self, candidate_ids: Sequence[UUID | str] + ) -> list[dict]: + if not candidate_ids: + return [] + + response = await asyncio.to_thread( + lambda: self.client.table("candidates") + .select( + "uuid, full_name, email, phone, github_username, github_url, " + "linkedin_url, enrichment_profiles!left(summary, skills)" + ) + .in_("uuid", [str(candidate_id) for candidate_id in candidate_ids]) + .execute() + ) + + rows: list[dict] = [] + for row in response.data or []: + profiles = row.get("enrichment_profiles") or [] + profile = profiles[0] if isinstance(profiles, list) and profiles else profiles + profile = profile or {} + rows.append( + { + "candidate_uuid": row.get("uuid"), + "full_name": row.get("full_name"), + "email": row.get("email"), + "phone": row.get("phone"), + "github_username": row.get("github_username"), + "github_url": row.get("github_url"), + "linkedin_url": row.get("linkedin_url"), + "summary": profile.get("summary"), + "skills": profile.get("skills"), + } + ) + return rows \ No newline at end of file diff --git a/src/backend/app/schemas/orchestrator.py b/src/backend/app/schemas/orchestrator.py new file mode 100644 index 0000000..e742712 --- /dev/null +++ b/src/backend/app/schemas/orchestrator.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from enum import Enum +from typing import Any + +from pydantic import BaseModel, Field + + +class IntentType(str, Enum): + GENERAL_CHAT = "GENERAL_CHAT" + CANDIDATE_SEARCH = "CANDIDATE_SEARCH" + + +class OrchestratorDecision(BaseModel): + intent: IntentType = Field(description="The primary intent of the user request.") + confidence: float = Field(ge=0.0, le=1.0) + reasoning: str = Field(min_length=1, max_length=1000) + direct_response: str | None = Field(default=None, max_length=4000) + initial_search_criteria: dict[str, Any] | None = None + + +class ChatResponseType(str, Enum): + DIRECT_CHAT = "direct_chat" + AGENT_EXECUTION = "agent_execution" + + +class ChatResponse(BaseModel): + type: ChatResponseType + content: str | None = None + conversation_id: str + result: dict[str, Any] | None = None diff --git a/src/backend/app/services/find_candidate_service.py b/src/backend/app/services/find_candidate_service.py new file mode 100644 index 0000000..ab47a00 --- /dev/null +++ b/src/backend/app/services/find_candidate_service.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Sequence +from typing import Any + +from src.backend.app.dtos.find_candidate import ( + FindCandidateRequest, + FindCandidateResult, +) + + +class FindCandidateService: + """Search candidates without creating or changing an application.""" + + def __init__( + self, + search_repository: Any, + candidate_repository: Any, + embedding_service: Any, + lexical_weight: float = 0.35, + semantic_weight: float = 0.65, + ) -> None: + self.search_repository = search_repository + self.candidate_repository = candidate_repository + self.embedding_service = embedding_service + self.lexical_weight = lexical_weight + self.semantic_weight = semantic_weight + + async def find( + self, request: FindCandidateRequest + ) -> list[FindCandidateResult]: + candidate_ids: Sequence[str] | None = None + if request.must_have_skills: + skill_matches = await self.search_repository.get_candidate_ids_by_skills( + request.must_have_skills + ) + candidate_ids = [str(item.candidate_uuid) for item in skill_matches] + if not candidate_ids: + return [] + + lexical_results = await self.search_repository.search_profiles_lexically( + query=request.role_description, + top_k=request.top_k, + candidate_ids=candidate_ids, + ) + + semantic_query = request.role_description + if request.experience_expectations: + semantic_query = ( + f"{semantic_query}\nExperience expectations: " + f"{request.experience_expectations}" + ) + query_embedding = await asyncio.to_thread( + self.embedding_service.embed_text, + f"query: {semantic_query}", + ) + semantic_results = await self.search_repository.search_similar_embeddings( + embedding=query_embedding, + top_k=request.top_k, + candidate_ids=candidate_ids, + source_types=["summary", "experience"], + ) + + ranked_scores = self._fuse_scores(lexical_results, semantic_results) + if not ranked_scores: + return [] + + details = await self.candidate_repository.get_candidate_details( + [candidate_uuid for candidate_uuid, _ in ranked_scores] + ) + details_by_id = {str(row["candidate_uuid"]): row for row in details} + + results: list[FindCandidateResult] = [] + for candidate_uuid, scores in ranked_scores: + row = details_by_id.get(str(candidate_uuid)) + if row is None: + continue + results.append( + FindCandidateResult( + candidate_uuid=str(candidate_uuid), + overall_score=scores["overall_score"], + lexical_score=scores["lexical_score"], + semantic_score=scores["semantic_score"], + full_name=row.get("full_name"), + email=row.get("email"), + phone=row.get("phone"), + summary=row.get("summary") or "", + skills=row.get("skills") or [], + github_username=row.get("github_username"), + github_url=row.get("github_url"), + linkedin_url=row.get("linkedin_url"), + ) + ) + return results[: request.top_k] + + def _fuse_scores( + self, lexical_results: list[Any], semantic_results: list[Any] + ) -> list[tuple[str, dict[str, float]]]: + max_lexical = max( + (float(item.lexical_score) for item in lexical_results), default=0.0 + ) + lexical_scores = ( + { + str(item.candidate_uuid): float(item.lexical_score) / max_lexical + for item in lexical_results + } + if max_lexical > 0 + else {} + ) + semantic_scores: dict[str, float] = {} + for item in semantic_results: + candidate_uuid = str(item.candidate_uuid) + semantic_scores[candidate_uuid] = max( + semantic_scores.get(candidate_uuid, 0.0), + float(item.similarity_score), + ) + + ranked: list[tuple[str, dict[str, float]]] = [] + for candidate_uuid in set(lexical_scores) | set(semantic_scores): + lexical_score = lexical_scores.get(candidate_uuid, 0.0) + semantic_score = semantic_scores.get(candidate_uuid, 0.0) + overall_score = ( + self.lexical_weight * lexical_score + + self.semantic_weight * semantic_score + ) + ranked.append( + ( + candidate_uuid, + { + "lexical_score": round(lexical_score, 4), + "semantic_score": round(semantic_score, 4), + "overall_score": round(overall_score, 4), + }, + ) + ) + return sorted(ranked, key=lambda item: item[1]["overall_score"], reverse=True) \ No newline at end of file diff --git a/src/backend/app/services/orchestrator.py b/src/backend/app/services/orchestrator.py new file mode 100644 index 0000000..6fb4f56 --- /dev/null +++ b/src/backend/app/services/orchestrator.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from collections.abc import Sequence + +from src.backend.app.schemas.orchestrator import OrchestratorDecision +from src.backend.app.services.llm_provider import LLMProvider + + +class OrchestratorService: + """Classifies chat requests before they enter the candidate-search graph.""" + + _SYSTEM_PROMPT = """ +You are the intent router for an ATS recruitment assistant. + +Classify the latest user message: +- CANDIDATE_SEARCH: searching, filtering, recommending, comparing, or evaluating + candidates, resumes, skills, experience, or candidate data. +- GENERAL_CHAT: greetings, general questions, explanations about the ATS, or + conversation that does not require candidate retrieval. + +For GENERAL_CHAT, write a concise helpful answer in Vietnamese unless the user +clearly uses another language. For CANDIDATE_SEARCH, set direct_response to null +and extract only criteria explicitly present in the message into +initial_search_criteria. Never invent criteria. +Return only the requested structured object. +""".strip() + + def __init__(self, llm_provider: LLMProvider): + self.llm_provider = llm_provider + + def classify( + self, + user_message: str, + history: Sequence[str] = (), + ) -> OrchestratorDecision: + recent_history = list(history)[-6:] + user_input = "\n".join( + [ + "Recent conversation:", + *(f"- {item}" for item in recent_history), + "Latest user message:", + user_message, + ] + ) + return self.llm_provider.invoke( + system_prompt=self._SYSTEM_PROMPT, + user_input=user_input, + response_model=OrchestratorDecision, + ) diff --git a/src/backend/apps/main.py b/src/backend/apps/main.py index f472f52..8db086f 100644 --- a/src/backend/apps/main.py +++ b/src/backend/apps/main.py @@ -18,6 +18,7 @@ from modules.search.adapters.routes import router as search_router from modules.scoring.adapters.routes import router as scoring_router from src.backend.app.agents.router import router as agents_router +from src.backend.app.chat.router import router as chat_router from modules.shared.infrastructure.config import get_settings structlog.configure( @@ -62,6 +63,7 @@ app.include_router(search_router) app.include_router(scoring_router) app.include_router(agents_router) +app.include_router(chat_router) @app.get("/health") diff --git a/src/backend/modules/search/adapters/routes.py b/src/backend/modules/search/adapters/routes.py index bd02130..52ea0c3 100644 --- a/src/backend/modules/search/adapters/routes.py +++ b/src/backend/modules/search/adapters/routes.py @@ -10,7 +10,11 @@ SearchService, get_embedding_service, ) +from src.backend.app.dtos.find_candidate import FindCandidateRequest, FindCandidateResult +from src.backend.app.repositories.candidate_search_repository import CandidateSearchRepository +from src.backend.app.services.find_candidate_service import FindCandidateService from modules.shared.infrastructure.auth_dependencies import require_operational_roles +from modules.shared.infrastructure.abac import apply_abac from modules.shared.infrastructure.config import Settings, get_settings from modules.shared.infrastructure.supabase_client import get_supabase_client @@ -30,7 +34,27 @@ def get_search_service( return SearchService(client=client, embedding_service=get_embedding_service()) +def get_find_candidate_service( + settings: Annotated[Settings, Depends(get_settings)], +) -> FindCandidateService: + client = get_supabase_client(settings, use_admin=True) + if client is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Candidate search is unavailable: the database is not configured.", + ) + repository = CandidateSearchRepository(client) + return FindCandidateService( + search_repository=repository, + candidate_repository=repository, + embedding_service=get_embedding_service(), + ) + + ServiceDep = Annotated[SearchService, Depends(get_search_service)] +FindCandidateServiceDep = Annotated[ + FindCandidateService, Depends(get_find_candidate_service) +] class SearchRequest(BaseModel): @@ -87,3 +111,17 @@ async def search_candidates( return SearchResponse( results=results, total=len(results), min_score=body.min_score ) + + +@router.post("/find", response_model=list[FindCandidateResult]) +async def find_candidates( + body: FindCandidateRequest, + service: FindCandidateServiceDep, + user: Annotated[AuthUser, Depends(require_operational_roles())], +) -> list[FindCandidateResult]: + """Tìm kiếm ad-hoc, không tạo hoặc cập nhật application.""" + results = await service.find(body) + return [ + FindCandidateResult.model_validate(apply_abac(result.model_dump(), user.role)) + for result in results + ] diff --git a/src/backend/modules/shared/infrastructure/abac.py b/src/backend/modules/shared/infrastructure/abac.py index bfbd13a..0e63417 100644 --- a/src/backend/modules/shared/infrastructure/abac.py +++ b/src/backend/modules/shared/infrastructure/abac.py @@ -75,6 +75,9 @@ # đó là văn bản tự do do LLM viết và gần như chắc chắn nhắc tên ứng # viên, nên vẫn bị che. "score", + "overall_score", + "lexical_score", + "semantic_score", "skills", "strengths", "weaknesses", diff --git a/src/frontend/app/search/page.tsx b/src/frontend/app/search/page.tsx index b1c0372..20883ac 100644 --- a/src/frontend/app/search/page.tsx +++ b/src/frontend/app/search/page.tsx @@ -7,8 +7,8 @@ import { AppShell } from "../../components/AppShell"; import { D, tint } from "../../lib/shared"; import { MAX_TOP_K, - searchCandidates, - type SearchResult, + findCandidates, + type FindCandidateResult, } from "../../services/searchService"; import { anonymousCandidateLabel, isMasked } from "../../lib/candidateLabel"; import { useT } from "../../lib/i18n"; @@ -38,7 +38,7 @@ export default function SearchPage() { const [skills, setSkills] = useState([]); const [topK, setTopK] = useState(10); - const [results, setResults] = useState(null); + const [results, setResults] = useState(null); const [threshold, setThreshold] = useState(0); const [searching, setSearching] = useState(false); const [error, setError] = useState(null); @@ -54,13 +54,13 @@ export default function SearchPage() { setSearching(true); setError(null); try { - const body = await searchCandidates({ - summary: summary.trim(), - experience: experience.trim() || undefined, - required_skills: skills.length ? skills : undefined, + const body = await findCandidates({ + role_description: summary.trim(), + experience_expectations: experience.trim() || undefined, + must_have_skills: skills, top_k: topK, }); - setResults(body.results); + setResults(body); // Ngưỡng về 0 sau mỗi lần tìm mới: giữ lại mức của truy vấn trước có thể // ẩn sạch kết quả mới, và trông y như "không tìm thấy ai". setThreshold(0); @@ -71,7 +71,7 @@ export default function SearchPage() { } }; - const visible = (results ?? []).filter((r) => r.score >= threshold); + const visible = (results ?? []).filter((r) => r.overall_score >= threshold); const field: React.CSSProperties = { width: "100%", @@ -344,11 +344,14 @@ function ResultCard({ result, onOpen, }: { - result: SearchResult; + result: FindCandidateResult; onOpen: () => void; }) { const t = useT(); - const percent = Math.round(result.score * 100); + const percent = Math.round(result.overall_score * 100); + const contact = [result.email, result.phone].filter( + (value): value is string => Boolean(value) && !isMasked(value), + ); return (
- {anonymousCandidateLabel(result.candidate_uuid)} + {result.full_name && !isMasked(result.full_name) + ? result.full_name + : anonymousCandidateLabel(result.candidate_uuid)}
+ {contact.length > 0 && ( +
+ {contact.join(" · ")} +
+ )} + {/* Tóm tắt bị che với tech_lead — hiện lời giải thích thay vì ba dấu sao trần trụi, để người dùng biết đó là chính sách chứ không phải dữ liệu thiếu. */} @@ -456,16 +467,6 @@ function ResultCard({ )} - {(result.strengths.length > 0 || result.weaknesses.length > 0) && ( -
- {result.strengths.length > 0 && ( - - )} - {result.weaknesses.length > 0 && ( - - )} -
- )} ); diff --git a/src/frontend/hooks/useAgentChat.tsx b/src/frontend/hooks/useAgentChat.tsx index e6ab4e4..d1543e2 100644 --- a/src/frontend/hooks/useAgentChat.tsx +++ b/src/frontend/hooks/useAgentChat.tsx @@ -164,7 +164,7 @@ export function AgentChatProvider({ children }: { children: React.ReactNode }) { : clarificationHistory(current.messages); try { - const response = await streamClient("/agents", { + const response = await streamClient("/chat", { method: "POST", body: JSON.stringify({ message: trimmed, @@ -193,9 +193,12 @@ export function AgentChatProvider({ children }: { children: React.ReactNode }) { const dataLine = event.split("\n").find((line) => line.startsWith("data: ")); if (!dataLine) continue; const data = JSON.parse(dataLine.slice(6)) as { - text?: string; message?: string; clarification?: boolean; + type?: string; content?: string; text?: string; message?: string; clarification?: boolean; result?: { summary?: string; candidates?: unknown[]; suggestions?: string[] }; }; + if (event.includes("event: direct") && data.content) { + patch((m) => ({ ...m, content: data.content! })); + } if (event.includes("event: delta") && data.text) patch((m) => ({ ...m, content: m.content + data.text })); if (event.includes("event: error")) throw new Error(data.message ?? "Agent request failed"); if (event.includes("event: done") && data.result) { diff --git a/src/frontend/services/__tests__/searchService.test.ts b/src/frontend/services/__tests__/searchService.test.ts index 42a79d5..b60ad5a 100644 --- a/src/frontend/services/__tests__/searchService.test.ts +++ b/src/frontend/services/__tests__/searchService.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const post = vi.fn(); vi.mock("../httpClient", () => ({ api: { post: (...a: unknown[]) => post(...a) } })); -import { MASKED, isMasked, searchCandidates } from "../searchService"; +import { MASKED, findCandidates, isMasked, searchCandidates } from "../searchService"; describe("searchService", () => { beforeEach(() => { @@ -23,6 +23,22 @@ describe("searchService", () => { expect(post.mock.calls[0][1].required_skills).toEqual(["Python", "Docker"]); }); + it("sends ad-hoc find-candidate requests to the hybrid search route", async () => { + await findCandidates({ + role_description: "Senior backend engineer", + experience_expectations: "3+ years", + must_have_skills: ["Python"], + top_k: 5, + }); + + expect(post).toHaveBeenCalledWith("/api/search/find", { + role_description: "Senior backend engineer", + experience_expectations: "3+ years", + must_have_skills: ["Python"], + top_k: 5, + }); + }); + it("recognises a masked field", () => { // Backend trả đúng chuỗi này cho trường bị che; giao diện phải phân biệt // được "bị che vì chính sách" với "dữ liệu rỗng". diff --git a/src/frontend/services/searchService.ts b/src/frontend/services/searchService.ts index 9213c7f..a5de214 100644 --- a/src/frontend/services/searchService.ts +++ b/src/frontend/services/searchService.ts @@ -43,6 +43,28 @@ export interface SearchRequest { min_score?: number; } +export interface FindCandidateRequest { + role_description: string; + experience_expectations?: string; + must_have_skills?: string[]; + top_k?: number; +} + +export interface FindCandidateResult { + candidate_uuid: string; + overall_score: number; + lexical_score: number; + semantic_score: number; + full_name: string | null; + email: string | null; + phone: string | null; + summary: string; + skills: string[]; + github_username: string | null; + github_url: string | null; + linkedin_url: string | null; +} + /** Trần do backend đặt (`MAX_TOP_K`). Gửi quá sẽ bị từ chối 422. */ export const MAX_TOP_K = 50; @@ -50,5 +72,13 @@ export async function searchCandidates(req: SearchRequest): Promise("/api/search", { ...req } as Record); } +export async function findCandidates( + req: FindCandidateRequest, +): Promise { + return api.post("/api/search/find", { + ...req, + } as Record); +} + /** Định nghĩa duy nhất nằm ở lib/candidateLabel — re-export để import cũ không gãy. */ export { MASKED, isMasked } from "../lib/candidateLabel"; diff --git a/tests/services/test_find_candidate_service.py b/tests/services/test_find_candidate_service.py new file mode 100644 index 0000000..39b80a6 --- /dev/null +++ b/tests/services/test_find_candidate_service.py @@ -0,0 +1,93 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from src.backend.app.dtos.find_candidate import FindCandidateRequest +from src.backend.app.services.find_candidate_service import FindCandidateService + + +def _service(search_repo, candidate_repo, embedding_repo): + return FindCandidateService( + search_repository=search_repo, + candidate_repository=candidate_repo, + embedding_service=embedding_repo, + ) + + +@pytest.mark.asyncio +async def test_empty_skill_match_stops_before_search_or_embedding(): + search_repo = SimpleNamespace( + get_candidate_ids_by_skills=AsyncMock(return_value=[]), + search_profiles_lexically=AsyncMock(), + search_similar_embeddings=AsyncMock(), + ) + candidate_repo = SimpleNamespace(get_candidate_details=AsyncMock()) + embedding_service = SimpleNamespace(embed_text=AsyncMock()) + service = _service(search_repo, candidate_repo, embedding_service) + + result = await service.find( + FindCandidateRequest( + role_description="Backend engineer", + must_have_skills=["Rust"], + ) + ) + + assert result == [] + search_repo.search_profiles_lexically.assert_not_awaited() + search_repo.search_similar_embeddings.assert_not_awaited() + embedding_service.embed_text.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_fuses_scores_and_returns_descending_hydrated_results(): + first_id, second_id = uuid4(), uuid4() + search_repo = SimpleNamespace( + get_candidate_ids_by_skills=AsyncMock( + return_value=[SimpleNamespace(candidate_uuid=first_id), SimpleNamespace(candidate_uuid=second_id)] + ), + search_profiles_lexically=AsyncMock( + return_value=[ + SimpleNamespace(candidate_uuid=first_id, lexical_score=10.0), + SimpleNamespace(candidate_uuid=second_id, lexical_score=5.0), + ] + ), + search_similar_embeddings=AsyncMock( + return_value=[ + SimpleNamespace(candidate_uuid=first_id, similarity_score=0.5), + SimpleNamespace(candidate_uuid=second_id, similarity_score=0.9), + ] + ), + ) + candidate_repo = SimpleNamespace( + get_candidate_details=AsyncMock( + return_value=[ + {"candidate_uuid": first_id, "full_name": "First", "skills": ["Python"]}, + {"candidate_uuid": second_id, "full_name": "Second", "skills": ["Go"]}, + ] + ) + ) + embedding_service = SimpleNamespace(embed_text=lambda text: [1.0, 2.0]) + service = _service(search_repo, candidate_repo, embedding_service) + + result = await service.find( + FindCandidateRequest( + role_description="Backend engineer", + must_have_skills=["Python"], + top_k=2, + ) + ) + + assert [item.full_name for item in result] == ["Second", "First"] + assert result[0].overall_score == 0.76 + assert result[1].overall_score == 0.675 + search_repo.search_profiles_lexically.assert_awaited_once_with( + query="Backend engineer", top_k=2, candidate_ids=[str(first_id), str(second_id)] + ) + search_repo.search_similar_embeddings.assert_awaited_once_with( + embedding=[1.0, 2.0], + top_k=2, + candidate_ids=[str(first_id), str(second_id)], + source_types=["summary", "experience"], + ) \ No newline at end of file diff --git a/tests/services/test_orchestrator.py b/tests/services/test_orchestrator.py new file mode 100644 index 0000000..f69fe25 --- /dev/null +++ b/tests/services/test_orchestrator.py @@ -0,0 +1,63 @@ +from typing import Any + +from pydantic import BaseModel + +from src.backend.app.schemas.orchestrator import IntentType, OrchestratorDecision +from src.backend.app.services.llm_provider import LLMProvider +from src.backend.app.services.orchestrator import OrchestratorService + + +class StubLLMProvider(LLMProvider): + def __init__(self, decision: OrchestratorDecision): + self.decision = decision + self.last_input = "" + self.last_model: type[BaseModel] | None = None + + def invoke( + self, + system_prompt: str, + user_input: Any, + response_model: type[BaseModel] | None = None, + temperature: float = 0.1, + ) -> Any: + self.last_input = str(user_input) + self.last_model = response_model + return self.decision + + +def test_orchestrator_uses_structured_decision_and_recent_history(): + provider = StubLLMProvider( + OrchestratorDecision( + intent=IntentType.CANDIDATE_SEARCH, + confidence=0.95, + reasoning="The user asks to find candidates.", + initial_search_criteria={"skills": ["Python"]}, + ) + ) + + decision = OrchestratorService(provider).classify( + "Tìm ứng viên Python", + ["old message", "context 1", "context 2", "context 3", "context 4", "context 5", "context 6", "latest context"], + ) + + assert decision.intent == IntentType.CANDIDATE_SEARCH + assert decision.initial_search_criteria == {"skills": ["Python"]} + assert provider.last_model is OrchestratorDecision + assert "old message" not in provider.last_input + assert "latest context" in provider.last_input + + +def test_orchestrator_supports_direct_chat_contract(): + provider = StubLLMProvider( + OrchestratorDecision( + intent=IntentType.GENERAL_CHAT, + confidence=0.99, + reasoning="The user greeted the assistant.", + direct_response="Xin chào, tôi có thể giúp gì?", + ) + ) + + decision = OrchestratorService(provider).classify("Xin chào") + + assert decision.intent == IntentType.GENERAL_CHAT + assert decision.direct_response == "Xin chào, tôi có thể giúp gì?" \ No newline at end of file