jobagent / agent /react_loop.py
karunaav
jobagent
9db9617
Raw
History Blame Contribute Delete
7.26 kB
"""
agent/react_loop.py
ReAct agent loop powered by Groq (llama-3.3-70b-versatile).
"""
import json
import time
from groq import Groq
from typing import Generator
from tools.definitions import TOOL_SCHEMAS, dispatch_tool
def _to_groq_tools(schemas: list[dict]) -> list[dict]:
return [
{
"type": "function",
"function": {
"name": s["name"],
"description": s["description"],
"parameters": s["input_schema"],
},
}
for s in schemas
]
SYSTEM_PROMPT = """You are JobAgent — an expert career intelligence analyst with access to real job market tools.
Your job: analyze a candidate's profile against real job postings and produce a grounded career intelligence report.
TOOLS AVAILABLE:
- search_jobs: semantic search over real HuggingFace job postings
- score_match: TF-IDF cosine similarity scoring of jobs vs candidate profile
- skill_demand: frequency analysis of skills across all postings
- salary_lookup: BLS OES 2024 salary data, location + seniority adjusted
- filter_by_location: filter results to a target city
- summarize_findings: compile all data — call this LAST when ready
RULES:
1. Always start with search_jobs AND skill_demand.
2. After search_jobs, always call score_match on the results.
3. Always call salary_lookup with the candidate's exact role, location, and years_exp.
4. If search results are weak (fewer than 10 results), retry search_jobs with a different query.
5. Only call summarize_findings after you have data from: search_jobs, score_match, skill_demand, salary_lookup.
6. After summarize_findings, write the final report immediately.
REPORT FORMAT (write this after summarize_findings):
## Market Position
## Top Job Matches
## Skill Gap Analysis
## Salary Intelligence
## 30-Day Action Plan
Every sentence must contain a specific number or data point. No filler."""
def run_agent(
profile: dict,
dataset_rows: list[dict],
api_key: str,
) -> Generator[dict, None, None]:
client = Groq(api_key=api_key)
groq_tools = _to_groq_tools(TOOL_SCHEMAS)
candidate_str = (
f"Candidate profile:\n"
f" Role target: {profile['role']}\n"
f" Location: {profile['location']}\n"
f" Years experience: {profile['years_exp']}\n"
f" Skills: {', '.join(profile['skills'])}\n\n"
f"Dataset: {len(dataset_rows)} real job postings loaded from HuggingFace.\n\n"
f"Analyze this candidate's market position and produce a career intelligence report. "
f"Use your tools to gather real data — do not guess or make up numbers."
)
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": candidate_str},
]
iteration = 0
max_iterations = 14
while iteration < max_iterations:
iteration += 1
yield {"type": "iteration", "n": iteration}
response = client.chat.completions.create(
model="llama-3.3-70b-versatile",
messages=messages,
tools=groq_tools,
tool_choice="auto",
max_tokens=4096,
temperature=0.1,
)
msg = response.choices[0].message
finish = response.choices[0].finish_reason
if msg.content and msg.content.strip():
yield {"type": "thought", "text": msg.content.strip()}
if finish == "stop" or not msg.tool_calls:
final_text = msg.content or "Analysis complete."
yield {"type": "final_report", "text": final_text}
break
messages.append({
"role": "assistant",
"content": msg.content or "",
"tool_calls": [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in msg.tool_calls
],
})
for tc in msg.tool_calls:
tool_name = tc.function.name
try:
tool_inputs = json.loads(tc.function.arguments)
except json.JSONDecodeError:
tool_inputs = {}
yield {"type": "tool_call", "name": tool_name, "inputs": tool_inputs}
start = time.time()
try:
result = dispatch_tool(tool_name, tool_inputs, dataset_rows)
elapsed = round(time.time() - start, 2)
summary = _summarize_result(tool_name, result)
yield {
"type": "tool_result",
"name": tool_name,
"summary": summary,
"elapsed": elapsed,
"full_result": result,
}
except Exception as e:
result = {"error": str(e)}
elapsed = round(time.time() - start, 2)
yield {
"type": "tool_result",
"name": tool_name,
"summary": f"Error: {e}",
"elapsed": elapsed,
"full_result": result,
}
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": json.dumps(result),
})
if tool_name == "summarize_findings" and result.get("ready_for_report"):
yield {"type": "summary_ready", "data": result}
yield {"type": "done", "iterations": iteration}
def _summarize_result(tool_name: str, result: dict) -> str:
if "error" in result:
return f"Error: {result['error']}"
if tool_name == "search_jobs":
n = result.get("results_returned", 0)
total = result.get("total_searched", 0)
return f"Found {n} relevant jobs from {total} postings · query: '{result.get('query', '')}'"
if tool_name == "score_match":
n = result.get("total_scored", 0)
avg = result.get("avg_match_top10", 0)
top = (result.get("top_jobs") or [{}])[0]
return (
f"Scored {n} jobs · top: '{top.get('title', '')}' "
f"@ {top.get('company', '')} ({top.get('match_score', 0)}%) · avg top-10: {avg}%"
)
if tool_name == "skill_demand":
return result.get("gap_summary", "Skill demand analyzed.")
if tool_name == "salary_lookup":
med = result.get("salary_median_k", "N/A")
band = result.get("band", "").title()
anchor = result.get("negotiation_anchor", "")
loc = result.get("location", "")
src = result.get("source", "")
return f"{band} median: ${med}k in {loc} · anchor: {anchor} · {src}"
if tool_name == "filter_by_location":
return f"Filtered to {result.get('filtered_count', 0)} jobs in {result.get('filter_applied', '')}"
if tool_name == "summarize_findings":
avg = result.get("avg_match_score", 0)
pos = result.get("market_position", "")
return f"Findings compiled · market position: {pos} · avg match: {avg}% · ready for report"
return str(result)[:120]