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
39 changes: 29 additions & 10 deletions .idea/workspace.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

88 changes: 81 additions & 7 deletions api/app/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,18 @@
"required": ["onet_code"],
},
},
{
"name": "generate_tailored_cv",
"description": "Generate a tailored CV (Markdown) for a specific job, emphasising the most relevant experience and skills from the user persona.",
"input_schema": {
"type": "object",
"properties": {
"job": {"type": "object", "description": "Target job object with title, company, description"},
"persona": {"type": "object", "description": "Structured User Persona"},
},
"required": ["job", "persona"],
},
},
]


Expand Down Expand Up @@ -435,10 +447,12 @@ async def execute_tool(name: str, tool_input: dict) -> dict:
data = lookup_by_title(skill)
raw = data.get("overall_exposure", 0.0)
exposure = round(raw, 2) if raw and raw != 0.5 else 0.55
pct = round(exposure * 100)
risk_label = "High" if exposure >= 0.65 else ("Medium" if exposure >= 0.35 else "Low")
results.append({
"skill": skill,
"exposure": round(exposure, 2),
"risk": "high" if exposure >= 0.5 else "low",
"exposure_score": pct,
"risk_label": risk_label,
})
return {"skill_risks": results}

Expand Down Expand Up @@ -534,7 +548,8 @@ def _skill_gaps(desc: str) -> list[str]:
exposure = job.get("exposure_score", 0.5)
desc = job.get("description", "")
desc_lower = desc.lower()
skill_matches = sum(1 for s in cv_skills if s in desc_lower)
matched = [s for s in cv_skills if s in desc_lower]
skill_matches = len(matched)
fit_score = min(skill_matches / max(len(cv_skills), 1), 1.0)
level, level_bonus = _level_match(job.get("title", ""), desc)
gaps = _skill_gaps(desc)
Expand All @@ -547,9 +562,10 @@ def _skill_gaps(desc: str) -> list[str]:
"url": job.get("url"),
"description": desc,
"exposure_score": exposure,
"fit_score": round(fit_score, 2),
"fit_score": round(fit_score * 100),
"level_match": level,
"skill_gaps": gaps,
"matched_skills": sorted(matched)[:10],
"composite_score": round(composite, 2),
})

Expand Down Expand Up @@ -623,10 +639,10 @@ def _skill_gaps(desc: str) -> list[str]:
persona = tool_input.get("persona", {})
target_job_title = tool_input.get("target_job_title", "")

confirmed_high = [s for s in skill_risks if s.get("exposure", 0) > 0.65]
confirmed_low = [s for s in skill_risks if 0 < s.get("exposure", 0) <= 0.35]
confirmed_high = [s for s in skill_risks if s.get("exposure_score", s.get("exposure", 0) * 100) > 65]
confirmed_low = [s for s in skill_risks if 0 < s.get("exposure_score", s.get("exposure", 0) * 100) <= 35]
all_skills_str = ", ".join(
f"{s['skill']} ({int(s.get('exposure',0)*100)}%)" for s in skill_risks
f"{s['skill']} ({s.get('exposure_score', round(s.get('exposure', 0) * 100))}%)" for s in skill_risks
)

core = persona.get("core_info", {})
Expand Down Expand Up @@ -690,6 +706,64 @@ def _skill_gaps(desc: str) -> list[str]:
onet_code = tool_input.get("onet_code", "")
return lookup_onet(onet_code)

elif name == "generate_tailored_cv":
job = tool_input.get("job", {})
persona = tool_input.get("persona", {})

core = persona.get("core_info", {})
full_name = f"{core.get('first_name', '')} {core.get('last_name', '')}".strip()
contact_parts = filter(None, [
core.get("email"), core.get("phone"),
core.get("city"), core.get("linkedin"), core.get("github"),
])
contact_line = " | ".join(contact_parts)

skills_str = ""
for cat in persona.get("skills", []):
skills_str += f"**{cat.get('category','Skills')}:** {', '.join(cat.get('skills',[]))}\n"

history_str = ""
for exp in persona.get("history", [])[:5]:
end = "Present" if exp.get("is_current") else exp.get("end_date", "")
history_str += (
f"**{exp.get('title','')}** — {exp.get('employer','')} "
f"({exp.get('start_date','')} – {end})\n"
f"{exp.get('summary','')}\n"
f"{exp.get('metrics','')}\n\n"
)

edu_str = ""
for edu in persona.get("education", []):
edu_str += f"**{edu.get('degree','')}** — {edu.get('institution','')} ({edu.get('grade','')})\n"

proj_str = ""
for proj in persona.get("projects", [])[:3]:
proj_str += f"**{proj.get('name','')}**: {proj.get('problem_solved','')} → {proj.get('outcomes','')}\n"

cv_text = await _llm(
system=(
"You are a professional CV writer. Produce a tailored, ATS-optimised CV in clean Markdown. "
"Reorder and emphasise experience, skills, and projects that are most relevant to the target job. "
"Mirror keywords from the job description naturally. "
"Use this structure: Contact header, Professional Summary (3 sentences), Skills (grouped), "
"Experience (reverse-chronological, bullets using STAR), Education, Projects. "
"Do NOT add fictional information — only use what is provided. "
"Do NOT include placeholders. Output only the Markdown, no preamble."
),
user=(
f"# Target Job\n{job.get('title','')} at {job.get('company','')}\n"
f"Job Description:\n{job.get('description','')[:2000]}\n\n"
f"# Candidate\nName: {full_name}\nContact: {contact_line}\n\n"
f"## Skills\n{skills_str}\n"
f"## Experience\n{history_str}\n"
f"## Education\n{edu_str}\n"
f"## Projects\n{proj_str}\n"
f"Summary from persona: {persona.get('summary','')}"
),
max_tokens=2048,
)
return {"cv_text": cv_text.strip()}

return {"error": f"Unknown tool: {name}"}


Expand Down
96 changes: 92 additions & 4 deletions api/app/browser_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,100 @@
from browser_use import Agent, BrowserSession, BrowserProfile

if _BACKEND == "groq":
import json as _json
import re as _re
import typing as _typing
from langchain_groq import ChatGroq as _BaseChatGroq
from typing import ClassVar
from langchain_core.messages import (
SystemMessage as _SystemMessage,
HumanMessage as _HumanMessage,
AIMessage as _AIMessage,
)

def _to_lc_messages(messages):
"""Convert browser_use message types to standard LangChain messages."""
result = []
for msg in messages:
cls_name = type(msg).__name__
content = getattr(msg, "content", "")
if cls_name == "SystemMessage":
result.append(_SystemMessage(content=content))
elif cls_name == "HumanMessage":
result.append(_HumanMessage(content=content))
elif cls_name == "AIMessage":
result.append(_AIMessage(content=content))
else:
role = getattr(msg, "role", "user")
if role == "system":
result.append(_SystemMessage(content=content))
elif role == "assistant":
result.append(_AIMessage(content=content))
else:
result.append(_HumanMessage(content=content))
return result

def _parse_with_defaults(model_class, text: str):
"""
Extract JSON from LLM text and fill in defaults for any missing fields
before constructing the pydantic model. Handles the case where the
model omits required string fields (evaluation_previous_goal, etc.).
"""
m = _re.search(r'\{.*\}', text, _re.DOTALL)
data = _json.loads(m.group()) if m else {}

for name, fi in model_class.model_fields.items():
if name in data:
continue
ann = fi.annotation
origin = getattr(ann, '__origin__', None)
if origin is list or ann is list:
data[name] = []
elif ann is str or ann == type(""):
data[name] = ""
elif ann is int:
data[name] = 0
elif ann is bool:
data[name] = False
else:
data[name] = None

return model_class.model_validate(data)

class _BrowserLLM:
"""
Plain adapter around ChatGroq for browser-use 0.11.x.

browser-use calls:
response = await llm.ainvoke(messages, output_format=AgentOutput)
parsed = response.completion

We use Groq's json_object mode (no tool-call schema validation) and
fill in sensible defaults for any fields the model omits.
"""

provider = "groq"

def __init__(self, model: str, groq_api_key: str):
self.model = model
self._inner = _BaseChatGroq(model=model, groq_api_key=groq_api_key)

async def ainvoke(self, messages, output_format=None, **kwargs):
kwargs.pop("session_id", None)
lc_messages = _to_lc_messages(messages)

if output_format is not None:
# json_object mode: Groq validates JSON syntax only, not schema.
# The model already knows the expected structure from the system prompt.
llm = self._inner.bind(response_format={"type": "json_object"})
response = await llm.ainvoke(lc_messages)
content = getattr(response, "content", str(response))
completion = _parse_with_defaults(output_format, content)
return type("_Resp", (), {"completion": completion})()

return await self._inner.ainvoke(lc_messages, **kwargs)

class _BrowserLLM(_BaseChatGroq):
"""ChatGroq with provider attribute required by browser-use."""
provider: ClassVar[str] = "groq"
def __getattr__(self, name: str):
return getattr(self._inner, name)

def _make_browser_llm():
return _BrowserLLM(
Expand Down
Loading