Skip to content
Closed
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
1 change: 1 addition & 0 deletions src/backend/app/agents/nodes/planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 13 additions & 2 deletions src/backend/app/agents/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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__})
Expand All @@ -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"},
)
Expand Down
6 changes: 5 additions & 1 deletion src/backend/app/agents/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,8 @@ class PlannerInput(BaseModel):

history: list[ActionRecord]

initial_search_criteria: dict[str, Any] | None = None


class PlannerOutput(BaseModel):
"""
Expand Down Expand Up @@ -456,4 +458,6 @@ class ATSState(BaseModel):

max_steps: int = 20

messages: list[str] = Field(default_factory=list)
messages: list[str] = Field(default_factory=list)

initial_search_criteria: dict[str, Any] | None = None
84 changes: 84 additions & 0 deletions src/backend/app/chat/router.py
Original file line number Diff line number Diff line change
@@ -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"},
)
31 changes: 31 additions & 0 deletions src/backend/app/dtos/find_candidate.py
Original file line number Diff line number Diff line change
@@ -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
38 changes: 37 additions & 1 deletion src/backend/app/repositories/candidate_search_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,40 @@ async def search_similar_embeddings(
if not response.data:
return []

return [SemanticSearchResult.model_validate(row) for row in response.data]
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
31 changes: 31 additions & 0 deletions src/backend/app/schemas/orchestrator.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading