Skip to content

Commit ea4cbc2

Browse files
authored
[codex] Use LLM classification for job leads (#378)
* Use LLM classification for job leads * Address job lead review feedback * Address follow-up job lead review feedback
1 parent 6fda8ef commit ea4cbc2

16 files changed

Lines changed: 813 additions & 30 deletions

File tree

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,11 @@ AGENT_STRONG_API_KEY=
179179
AGENT_REASONING_MODEL=
180180
AGENT_REASONING_BASE_URL=
181181
AGENT_REASONING_API_KEY=
182+
# HN job-lead scraping uses an LLM classifier when OpenAI-compatible credentials
183+
# are configured, then falls back to keyword matching if the model is unavailable.
184+
JOB_LEAD_CLASSIFIER_ENABLED=true
185+
JOB_LEAD_CLASSIFIER_MODEL=
186+
JOB_LEAD_CLASSIFIER_TIMEOUT_SECONDS=8.0
182187
# Optional deterministic agent tool integrations.
183188
# GitHub Issues are the canonical code-task backend.
184189
GITHUB_API_TOKEN=

apps/admin_dashboard/src/main.tsx

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,16 @@ type JobLead = {
247247
apply_url?: string
248248
tags?: string[]
249249
confidence?: number
250+
contractor_classification?: {
251+
is_contractor_friendly?: boolean
252+
posting_type?: string
253+
tags?: string[]
254+
confidence?: number
255+
confidence_label?: string
256+
rationale?: string
257+
method?: string
258+
}
259+
review_summary?: string
250260
reviewed_by_discord_user_id?: string
251261
reviewed_at?: string
252262
discord_guild_id?: string
@@ -5580,6 +5590,16 @@ function defaultJobPostTagNames(lead: JobLead, channel?: JobPostChannel) {
55805590
return bestTag ? [bestTag.name] : []
55815591
}
55825592

5593+
function jobLeadClassificationLabel(lead: JobLead) {
5594+
const method = lead.contractor_classification?.method
5595+
const confidenceLabel = lead.contractor_classification?.confidence_label
5596+
if (method && confidenceLabel) {
5597+
const methodLabel = method === "llm" ? "LLM" : "Keyword fallback"
5598+
return `${methodLabel}: ${confidenceLabel} fit`
5599+
}
5600+
return lead.review_summary || "Classification unavailable"
5601+
}
5602+
55835603
function JobLeadListItem({
55845604
lead,
55855605
loading,
@@ -5643,9 +5663,7 @@ function JobLeadListItem({
56435663
<strong className="text-base">{lead.title || "Untitled lead"}</strong>
56445664
<Badge variant={jobLeadStatusTone(lead.status)}>{titleCase(lead.status)}</Badge>
56455665
{lead.remote ? <Badge variant="queued">Remote</Badge> : null}
5646-
{lead.confidence !== undefined ? (
5647-
<Badge variant="neutral">{Math.round(Number(lead.confidence || 0) * 100)}%</Badge>
5648-
) : null}
5666+
<Badge variant="neutral">{jobLeadClassificationLabel(lead)}</Badge>
56495667
</div>
56505668
<div className="mt-2 flex flex-wrap gap-1.5">
56515669
{lead.organization ? <Badge variant="neutral">{lead.organization}</Badge> : null}
@@ -5662,6 +5680,9 @@ function JobLeadListItem({
56625680
<p className="mt-3 max-h-20 overflow-hidden text-sm text-muted-foreground">
56635681
{lead.body_normalized || "No lead text captured."}
56645682
</p>
5683+
{lead.review_summary ? (
5684+
<p className="mt-2 text-sm font-semibold text-foreground">{lead.review_summary}</p>
5685+
) : null}
56655686
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground">
56665687
<span>Posted {formatDate(lead.source_posted_at) || "unknown"}</span>
56675688
<span>Captured {formatDate(lead.created_at) || "unknown"}</span>

apps/api/src/five08/backend/api.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
from five08.clients.espo import EspoAPIError, EspoClient
5858
from five08.logging import configure_observability
5959
from five08.job_leads import (
60+
job_lead_display_payload,
6061
JobLeadStatus,
6162
list_job_leads,
6263
review_job_lead,
@@ -4373,7 +4374,9 @@ async def dashboard_job_leads_handler(
43734374
status=normalized_status,
43744375
limit=limit,
43754376
)
4376-
return JSONResponse(jsonable_encoder(leads))
4377+
return JSONResponse(
4378+
jsonable_encoder([job_lead_display_payload(lead) for lead in leads])
4379+
)
43774380

43784381

43794382
async def dashboard_review_job_lead_handler(
@@ -4435,7 +4438,7 @@ async def dashboard_review_job_lead_handler(
44354438
"status": payload.status,
44364439
},
44374440
)
4438-
return JSONResponse(jsonable_encoder(lead))
4441+
return JSONResponse(jsonable_encoder(job_lead_display_payload(lead)))
44394442

44404443

44414444
async def dashboard_post_job_lead_handler(

apps/api/src/five08/backend/static/dashboard/.vite/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"index.html": {
3-
"file": "assets/index-CV7QH8Ei.js",
3+
"file": "assets/index-Ce7YIjfO.js",
44
"name": "index",
55
"src": "index.html",
66
"isEntry": true,

apps/api/src/five08/backend/static/dashboard/assets/index-CV7QH8Ei.js renamed to apps/api/src/five08/backend/static/dashboard/assets/index-Ce7YIjfO.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/api/src/five08/backend/static/dashboard/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
66
<title>508 Operations Dashboard</title>
7-
<script type="module" crossorigin src="/dashboard/assets/index-CV7QH8Ei.js"></script>
7+
<script type="module" crossorigin src="/dashboard/assets/index-Ce7YIjfO.js"></script>
88
<link rel="stylesheet" crossorigin href="/dashboard/assets/index-2UypYUrJ.css">
99
</head>
1010
<body>

apps/discord_bot/src/five08/discord_bot/cogs/jobs.py

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
unregister_job_post_channel,
6666
)
6767
from five08.job_leads import (
68+
format_job_lead_review_summary,
6869
get_job_lead,
6970
JobLead,
7071
JobLeadStatus,
@@ -3454,12 +3455,43 @@ def _truncate_job_lead_text(value: str, limit: int) -> str:
34543455

34553456
@classmethod
34563457
def _format_job_lead_review_line(cls, index: int, lead: JobLead) -> str:
3457-
tags = ", ".join(lead.tags[:5]) if lead.tags else "untagged"
34583458
title = cls._truncate_job_lead_text(lead.title, 120)
3459-
return (
3460-
f"{index}. `{lead.id[:8]}` **{title}** "
3461-
f"({lead.confidence:.0%}; {tags})\n{lead.source_url}"
3462-
)
3459+
summary = cls._truncate_job_lead_text(format_job_lead_review_summary(lead), 180)
3460+
return f"{index}. `{lead.id[:8]}` **{title}**\n{summary}\n{lead.source_url}"
3461+
3462+
@classmethod
3463+
def _format_job_lead_review_message(cls, leads: list[JobLead]) -> str:
3464+
"""Format a bounded Discord response for pending lead review."""
3465+
if not leads:
3466+
return "Pending job leads:\n\nNo pending job leads found."
3467+
limit = settings.discord_sendmsg_character_limit
3468+
header = "Pending job leads:"
3469+
lines: list[str] = []
3470+
shown = 0
3471+
for index, lead in enumerate(leads, start=1):
3472+
line = cls._format_job_lead_review_line(index, lead)
3473+
candidate_lines = [*lines, line]
3474+
omitted = len(leads) - len(candidate_lines)
3475+
footer = f"\n\nShowing {len(candidate_lines)} of {len(leads)} leads."
3476+
if omitted > 0:
3477+
footer += " Use a lower limit to inspect omitted leads."
3478+
candidate = f"{header}\n\n" + "\n\n".join(candidate_lines) + footer
3479+
if len(candidate) > limit:
3480+
break
3481+
lines = candidate_lines
3482+
shown = len(candidate_lines)
3483+
3484+
if not lines:
3485+
first = cls._truncate_job_lead_text(
3486+
cls._format_job_lead_review_line(1, leads[0]),
3487+
max(0, limit - len(header) - 32),
3488+
)
3489+
return f"{header}\n\n{first}"
3490+
3491+
footer = f"\n\nShowing {shown} of {len(leads)} leads."
3492+
if shown < len(leads):
3493+
footer += " Use a lower limit to inspect omitted leads."
3494+
return f"{header}\n\n" + "\n\n".join(lines) + footer
34633495

34643496
@classmethod
34653497
def _format_job_lead_thread_content(cls, lead: JobLead) -> str:
@@ -4014,12 +4046,8 @@ async def list_sourced_job_leads(
40144046
)
40154047
return
40164048

4017-
lines = [
4018-
self._format_job_lead_review_line(index, lead)
4019-
for index, lead in enumerate(leads, start=1)
4020-
]
40214049
await interaction.followup.send(
4022-
"Pending job leads:\n\n" + "\n\n".join(lines),
4050+
self._format_job_lead_review_message(leads),
40234051
ephemeral=True,
40244052
)
40254053

apps/worker/src/five08/worker/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ class WorkerSettings(SharedSettings):
7272
agent_reasoning_api_key: str | None = None
7373
agent_reasoning_base_url: str | None = None
7474
agent_reasoning_model: str | None = None
75+
job_lead_classifier_enabled: bool = True
76+
job_lead_classifier_model: str | None = None
77+
job_lead_classifier_timeout_seconds: float = Field(default=8.0, gt=0)
7578
resume_ai_api_key: str | None = None
7679
resume_ai_base_url: str | None = None
7780
resume_ai_model: str = "gpt-4.1-mini"

docs/configuration.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,9 @@ intake-completed field unset, and matches resume filenames with
219219
- `RESUME_AI_API_KEY`
220220
- `RESUME_AI_BASE_URL`
221221
- `RESUME_AI_MODEL`
222+
- `JOB_LEAD_CLASSIFIER_ENABLED`
223+
- `JOB_LEAD_CLASSIFIER_MODEL`
224+
- `JOB_LEAD_CLASSIFIER_TIMEOUT_SECONDS`
222225

223226
Resume/profile LLM calls retry matching direct providers after Bifrost request
224227
failures when direct provider credentials are configured.

0 commit comments

Comments
 (0)