- {concerns.map((item) =>
- {item} )}
diff --git a/.env.example b/.env.example
index cebe38c..4293731 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/src/backend/app/agents/nodes/recruiter_decision.py b/src/backend/app/agents/nodes/recruiter_decision.py
index b0cd49b..4205aa4 100644
--- a/src/backend/app/agents/nodes/recruiter_decision.py
+++ b/src/backend/app/agents/nodes/recruiter_decision.py
@@ -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
@@ -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,
@@ -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
diff --git a/src/backend/app/agents/nodes/retrieval.py b/src/backend/app/agents/nodes/retrieval.py
index 19833cb..4440c45 100644
--- a/src/backend/app/agents/nodes/retrieval.py
+++ b/src/backend/app/agents/nodes/retrieval.py
@@ -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 []),
diff --git a/src/backend/app/agents/prompts/planner_prompt.md b/src/backend/app/agents/prompts/planner_prompt.md
index 77bfe6b..2152f76 100644
--- a/src/backend/app/agents/prompts/planner_prompt.md
+++ b/src/backend/app/agents/prompts/planner_prompt.md
@@ -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`.
@@ -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.
\ No newline at end of file
+- 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`.
\ No newline at end of file
diff --git a/src/backend/app/agents/prompts/recruiter_prompt.md b/src/backend/app/agents/prompts/recruiter_prompt.md
index e7e1b13..727f706 100644
--- a/src/backend/app/agents/prompts/recruiter_prompt.md
+++ b/src/backend/app/agents/prompts/recruiter_prompt.md
@@ -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
@@ -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).
@@ -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.
\ No newline at end of file
diff --git a/src/backend/app/agents/prompts/reflection_prompt.md b/src/backend/app/agents/prompts/reflection_prompt.md
index edc2629..3df73a9 100644
--- a/src/backend/app/agents/prompts/reflection_prompt.md
+++ b/src/backend/app/agents/prompts/reflection_prompt.md
@@ -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.
\ No newline at end of file
+- 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`.
\ No newline at end of file
diff --git a/src/backend/app/agents/router.py b/src/backend/app/agents/router.py
index 2482ae2..846958c 100644
--- a/src/backend/app/agents/router.py
+++ b/src/backend/app/agents/router.py
@@ -1,6 +1,189 @@
from __future__ import annotations
-from src.backend.app.agents.state import ATSState
+import asyncio
+import json
+import logging
+from collections.abc import AsyncIterator
+from typing import Annotated
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi.responses import StreamingResponse
+from groq import (
+ APIConnectionError,
+ APIError,
+ APITimeoutError,
+ AuthenticationError,
+ BadRequestError,
+ GroqError,
+ RateLimitError,
+)
+from modules.auth.domain.models import AuthUser
+from modules.search.application.search_service import get_embedding_service
+from modules.search.infra.legacy_bridge import (
+ CandidateSearchRepository,
+ CandidateSearchService,
+ EnrichmentRepository,
+ RankingService,
+)
+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 modules.shared.infrastructure.supabase_client import get_supabase_admin_client
+from pydantic import BaseModel, Field
+
+from src.backend.app.agents.nodes.interaction import CLIInteractionGateway
+from src.backend.app.agents.state import (
+ ATSState,
+ CandidateSearchState,
+ RecruiterDecisionOutput,
+ Mission,
+ MissionStatus,
+)
+from src.backend.app.services.llm_provider import (
+ FallbackLLMProvider,
+ GroqProvider,
+ HFProvider,
+)
+
+router = APIRouter(prefix="/agents", tags=["agents"])
+logger = logging.getLogger(__name__)
+
+
+class AgentContext(BaseModel):
+ current_page: str = Field(default="candidates_dashboard", max_length=100)
+ user_id: str = Field(min_length=1, max_length=200)
+
+
+class AgentChatRequest(BaseModel):
+ message: str = Field(min_length=1, max_length=12000)
+ conversation_id: UUID
+ context: AgentContext
+
+
+def _agent_graph(settings: Settings):
+ # Import lazily because graph.py imports the route decision functions from
+ # this module while the application is importing the router.
+ from src.backend.app.agents.graph import build_graph
+
+ client = get_supabase_admin_client(settings)
+ if client is None:
+ raise HTTPException(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ detail="Agent search is unavailable: the database is not configured.",
+ )
+
+ search_service = CandidateSearchService(
+ search_repository=CandidateSearchRepository(client),
+ enrichment_repository=EnrichmentRepository(client),
+ embedding_service=get_embedding_service(),
+ ranking_service=RankingService(),
+ )
+ return build_graph(
+ llm_provider=FallbackLLMProvider(
+ primary=GroqProvider(),
+ fallback=HFProvider(),
+ ),
+ search_service=search_service,
+ interaction_gateway=CLIInteractionGateway(),
+ )
+
+
+def _sse(event: str, payload: dict) -> str:
+ return f"event: {event}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
+
+
+def _agent_error_message(error: Exception) -> str:
+ if isinstance(error, HTTPException):
+ return str(error.detail)
+ if isinstance(error, RateLimitError):
+ return "The AI service is rate-limited. Please try again in a moment."
+ if isinstance(error, (AuthenticationError, BadRequestError)):
+ return "The AI service is not configured correctly. Please contact an administrator."
+ if isinstance(error, (APITimeoutError, APIConnectionError, TimeoutError, asyncio.TimeoutError)):
+ return "The AI service did not respond in time. Please try again."
+ if isinstance(error, APIError):
+ return "The AI service is temporarily unavailable. Please try again."
+ if isinstance(error, GroqError):
+ return "The AI service is not configured correctly. Please contact an administrator."
+ return "The agent could not complete this request."
+
+
+def _extract_final_decision(value: object):
+ if isinstance(value, dict):
+ if "final_decision" in value:
+ return value["final_decision"]
+ for nested in value.values():
+ decision = _extract_final_decision(nested)
+ if decision is not None:
+ return decision
+ if isinstance(value, (list, tuple)):
+ for nested in value:
+ decision = _extract_final_decision(nested)
+ if decision is not None:
+ return decision
+ decision = getattr(value, "final_decision", None)
+ if decision is not None:
+ return decision
+ candidate_search = getattr(value, "candidate_search", None)
+ if candidate_search is not None:
+ return _extract_final_decision(candidate_search)
+ return None
+
+
+async def _stream_agent(request: AgentChatRequest, settings: Settings) -> AsyncIterator[str]:
+ try:
+ graph = _agent_graph(settings)
+ initial_state = ATSState(
+ messages=[request.message],
+ candidate_search=CandidateSearchState(
+ mission=Mission(
+ objective=request.message,
+ current_step="Planner Assessment",
+ status=MissionStatus.PENDING,
+ )
+ ),
+ )
+
+ yield _sse("status", {"message": "Agent is thinking..."})
+ final_state = None
+ async for update in graph.astream(initial_state, stream_mode="updates"):
+ final_state = update
+ for node_name in update:
+ yield _sse("status", {"message": f"Completed {node_name}"})
+
+ if not final_state:
+ raise RuntimeError("Agent completed without a result")
+
+ decision = _extract_final_decision(final_state)
+ if decision is None:
+ result = {"summary": "The agent completed without a recommendation.", "candidates": []}
+ elif isinstance(decision, dict):
+ result = RecruiterDecisionOutput.model_validate(decision).model_dump(mode="json")
+ else:
+ result = decision.model_dump(mode="json")
+
+ yield _sse(
+ "done",
+ {"conversation_id": str(request.conversation_id), "result": result},
+ )
+ except Exception as exc:
+ logger.exception("Agent stream failed", extra={"error_type": type(exc).__name__})
+ yield _sse("error", {"message": _agent_error_message(exc)})
+
+
+@router.post("", response_class=StreamingResponse)
+async def chat_with_agent(
+ 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_agent(request, settings),
+ media_type="text/event-stream",
+ headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
+ )
def route_after_planner(state: ATSState) -> str:
diff --git a/src/backend/app/agents/state.py b/src/backend/app/agents/state.py
index 20054d2..fed0459 100644
--- a/src/backend/app/agents/state.py
+++ b/src/backend/app/agents/state.py
@@ -5,7 +5,7 @@
from typing import Any
from typing_extensions import Literal
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, model_validator
"""
==========================================================
@@ -311,6 +311,8 @@ class CandidateContext(BaseModel):
candidate_id: str
+ full_name: str | None = None
+
semantic_score: float
summary: str
@@ -346,6 +348,12 @@ class CandidateRecommendation(BaseModel):
candidate_id: str
+ candidate_code: str = ""
+
+ display_name: str = ""
+
+ full_name: str = ""
+
recommendation: Literal[
"Strong Hire",
"Hire",
@@ -353,7 +361,7 @@ class CandidateRecommendation(BaseModel):
"Reject",
]
- confidence: float
+ confidence: float = Field(ge=0.0, le=1.0)
reasoning: str
@@ -363,12 +371,40 @@ class CandidateRecommendation(BaseModel):
risks: list[str]
+ @model_validator(mode="after")
+ def normalize_identity(self) -> "CandidateRecommendation":
+ code = self.candidate_id[-4:].upper()
+ self.candidate_code = code
+ if not self.full_name:
+ self.full_name = "Ứng viên"
+ self.display_name = f"{self.full_name} (#{code})"
+ return self
+
class RecruiterDecisionOutput(BaseModel):
- recommendations: list[CandidateRecommendation]
+ summary: str = ""
+
+ candidates: list[CandidateRecommendation] = Field(default_factory=list)
+
+ @model_validator(mode="before")
+ @classmethod
+ def accept_legacy_field_names(cls, values: Any) -> Any:
+ if isinstance(values, dict):
+ values = dict(values)
+ if "candidates" not in values and "recommendations" in values:
+ values["candidates"] = values.pop("recommendations")
+ if "summary" not in values and "final_summary" in values:
+ values["summary"] = values.pop("final_summary")
+ return values
+
+ @property
+ def recommendations(self) -> list[CandidateRecommendation]:
+ return self.candidates
- final_summary: str
+ @property
+ def final_summary(self) -> str:
+ return self.summary
# ==========================================================
diff --git a/src/backend/app/services/llm_provider.py b/src/backend/app/services/llm_provider.py
index a4c9d9b..1962f1a 100644
--- a/src/backend/app/services/llm_provider.py
+++ b/src/backend/app/services/llm_provider.py
@@ -1,16 +1,19 @@
from __future__ import annotations
-from abc import ABC, abstractmethod
-from typing import Any
-
import json
+import logging
import os
+from abc import ABC, abstractmethod
+from typing import Any
from dotenv import load_dotenv
from groq import Groq
+from groq import RateLimitError
+from openai import OpenAI
from pydantic import BaseModel
load_dotenv()
+logger = logging.getLogger(__name__)
class LLMProvider(ABC):
@@ -34,12 +37,27 @@ def invoke(
) -> Any:
pass
+ def generate_text(
+ self,
+ prompt: str,
+ temperature: float = 0.1,
+ ) -> str:
+ """Compatibility helper for services that only need plain text."""
+ result = self.invoke(
+ system_prompt="You are a helpful assistant.",
+ user_input=prompt,
+ temperature=temperature,
+ )
+ if not isinstance(result, str):
+ raise TypeError("LLM provider returned a non-text response")
+ return result
+
class GroqProvider(LLMProvider):
def __init__(
self,
- model: str = "llama-3.1-8b-instant",
+ model: str = "openai/gpt-oss-20b",
):
self.client = Groq(
api_key=os.getenv("GROQ_API_KEY")
@@ -89,11 +107,8 @@ def invoke(
# ----------------------------
if response_model is not None:
- schema = json.dumps(
- response_model.model_json_schema(),
- ensure_ascii=False,
- indent=2,
- )
+ schema = response_model.model_json_schema()
+ schema_text = json.dumps(schema, ensure_ascii=False, indent=2)
system_prompt = (
f"{system_prompt}\n\n"
@@ -101,7 +116,7 @@ def invoke(
"Do NOT explain.\n"
"Do NOT wrap in markdown.\n"
"The JSON MUST follow this schema:\n"
- f"{schema}"
+ f"{schema_text}"
)
# ----------------------------
@@ -111,7 +126,11 @@ def invoke(
if response_model is not None:
request_kwargs["response_format"] = {
- "type": "json_object"
+ "type": "json_schema",
+ "json_schema": {
+ "name": response_model.__name__,
+ "schema": schema,
+ },
}
response = self.client.chat.completions.create(
@@ -142,3 +161,114 @@ def invoke(
# Structured Output
# ----------------------------
return response_model.model_validate_json(content)
+
+
+class HFProvider(LLMProvider):
+ """Hugging Face Router provider using its OpenAI-compatible endpoint."""
+
+ def __init__(
+ self,
+ model: str | None = None,
+ api_key: str | None = None,
+ ):
+ self._api_key = api_key or os.getenv("HF_API_KEY")
+ self._client: OpenAI | None = None
+ self.model = model or os.getenv(
+ "HF_MODEL", "Qwen/Qwen2.5-7B-Instruct"
+ )
+
+ @property
+ def client(self) -> OpenAI:
+ if self._client is None:
+ self._client = OpenAI(
+ api_key=self._api_key,
+ base_url="https://router.huggingface.co/v1",
+ )
+ return self._client
+
+ def invoke(
+ self,
+ system_prompt: str,
+ user_input: Any,
+ response_model: type[BaseModel] | None = None,
+ temperature: float = 0.1,
+ ) -> Any:
+ if isinstance(user_input, BaseModel):
+ user_content = user_input.model_dump_json(indent=2)
+ else:
+ user_content = str(user_input)
+
+ request_prompt = system_prompt
+ request_kwargs: dict[str, Any] = {}
+ if response_model is not None:
+ schema = response_model.model_json_schema()
+ request_prompt = (
+ f"{system_prompt}\n\n"
+ "Return ONLY a valid JSON object. Do NOT use markdown. "
+ "The JSON MUST follow this schema:\n"
+ f"{json.dumps(schema, ensure_ascii=False, indent=2)}"
+ )
+ request_kwargs["response_format"] = {"type": "json_object"}
+
+ response = self.client.chat.completions.create(
+ model=self.model,
+ messages=[
+ {"role": "system", "content": request_prompt},
+ {"role": "user", "content": user_content},
+ ],
+ temperature=temperature,
+ **request_kwargs,
+ )
+ content = response.choices[0].message.content or ""
+ if response_model is None:
+ return content
+ return response_model.model_validate_json(content)
+
+
+HF_Provider = HFProvider
+
+
+def _is_groq_rate_limit(error: Exception) -> bool:
+ if isinstance(error, RateLimitError):
+ return True
+ if getattr(error, "status_code", None) == 429:
+ return True
+ if getattr(error, "code", None) == "rate_limit_exceeded":
+ return True
+ response = getattr(error, "response", None)
+ return getattr(response, "status_code", None) == 429
+
+
+class FallbackLLMProvider(LLMProvider):
+ """Use the fallback provider only for an upstream Groq rate limit."""
+
+ def __init__(self, primary: LLMProvider, fallback: LLMProvider):
+ self.primary = primary
+ self.fallback = fallback
+
+ def invoke(
+ self,
+ system_prompt: str,
+ user_input: Any,
+ response_model: type[BaseModel] | None = None,
+ temperature: float = 0.1,
+ ) -> Any:
+ try:
+ return self.primary.invoke(
+ system_prompt=system_prompt,
+ user_input=user_input,
+ response_model=response_model,
+ temperature=temperature,
+ )
+ except Exception as error:
+ if not _is_groq_rate_limit(error):
+ raise
+ logger.warning(
+ "Groq Rate Limit exceeded. Switching to Hugging Face Provider (Qwen 7B)..."
+ )
+ return self.fallback.invoke(
+ system_prompt=system_prompt,
+ user_input=user_input,
+ response_model=response_model,
+ temperature=temperature,
+ )
diff --git a/src/backend/apps/main.py b/src/backend/apps/main.py
index 55f2580..f472f52 100644
--- a/src/backend/apps/main.py
+++ b/src/backend/apps/main.py
@@ -17,6 +17,7 @@
from modules.review.adapters.routes import router as review_router
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 modules.shared.infrastructure.config import get_settings
structlog.configure(
@@ -60,6 +61,7 @@
app.include_router(review_router)
app.include_router(search_router)
app.include_router(scoring_router)
+app.include_router(agents_router)
@app.get("/health")
diff --git a/src/backend/modules/shared/infrastructure/rate_limit.py b/src/backend/modules/shared/infrastructure/rate_limit.py
index b9e7098..ccfdf63 100644
--- a/src/backend/modules/shared/infrastructure/rate_limit.py
+++ b/src/backend/modules/shared/infrastructure/rate_limit.py
@@ -105,3 +105,5 @@ def reset(self) -> None:
#: Nộp CV công khai — mỗi lượt là một file tới 10MB đi vào Azure và một lượt
#: gọi LLM. Không giới hạn thì một script biến nó thành hoá đơn.
ingest_rate_limit = RateLimit("ingestion.ingest", limit=20, seconds=3600)
+
+agent_rate_limit = RateLimit("agents.chat", limit=20, seconds=600)
diff --git a/src/frontend/app/providers.tsx b/src/frontend/app/providers.tsx
index e5000c0..7baea6d 100644
--- a/src/frontend/app/providers.tsx
+++ b/src/frontend/app/providers.tsx
@@ -5,6 +5,8 @@ import { AuthProvider } from "@/contexts/AuthContext";
import { AuthGuard } from "@/components/AuthGuard";
import { WorkspaceProvider } from "@/contexts/WorkspaceContext";
import { RequireCalendarModal } from "@/components/RequireCalendarModal";
+import { AgentChatProvider } from "@/hooks/useAgentChat";
+import { AgentChatDrawer } from "@/components/AgentChatDrawer";
export function Providers({ children }: { children: React.ReactNode }) {
const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
@@ -14,8 +16,11 @@ export function Providers({ children }: { children: React.ReactNode }) {
{part.slice(1, -1)};
+ return {part};
+ });
+
+ const renderText = (text: string) => {
+ const blocks: React.ReactNode[] = [];
+ let listItems: string[] = [];
+ const flushList = () => {
+ if (!listItems.length) return;
+ blocks.push(
{part.replace(/^```\w*\n?/, "").replace(/```$/, "")}
+ ) : {renderText(part)})}
+