Skip to content

Commit 8b98c75

Browse files
authored
[codex] Add sourced job lead review workflow (#373)
* Add sourced job lead review workflow * Harden sourced lead Discord publishing
1 parent 7968cbd commit 8b98c75

9 files changed

Lines changed: 1442 additions & 0 deletions

File tree

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

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@
6464
register_job_post_channel,
6565
unregister_job_post_channel,
6666
)
67+
from five08.job_leads import (
68+
JobLead,
69+
JobLeadStatus,
70+
list_job_leads,
71+
mark_job_lead_posted,
72+
review_job_lead,
73+
)
6774
from five08.job_match import (
6875
DISCORD_ROLES_EXCLUDE_FROM_SYNC,
6976
JobRequirements,
@@ -3405,6 +3412,271 @@ async def on_message(self, message: discord.Message) -> None:
34053412
if not recorded_interest:
34063413
await self._persist_thread_reply_activity(message)
34073414

3415+
@staticmethod
3416+
def _truncate_job_lead_text(value: str, limit: int) -> str:
3417+
"""Trim lead text for Discord command responses."""
3418+
normalized = value.replace("\r", " ").strip()
3419+
if limit <= 0:
3420+
return ""
3421+
if limit <= 3:
3422+
return normalized[:limit]
3423+
if len(normalized) <= limit:
3424+
return normalized
3425+
return f"{normalized[: limit - 3]}..."
3426+
3427+
@classmethod
3428+
def _format_job_lead_review_line(cls, index: int, lead: JobLead) -> str:
3429+
tags = ", ".join(lead.tags[:5]) if lead.tags else "untagged"
3430+
title = cls._truncate_job_lead_text(lead.title, 120)
3431+
return (
3432+
f"{index}. `{lead.id[:8]}` **{title}** "
3433+
f"({lead.confidence:.0%}; {tags})\n{lead.source_url}"
3434+
)
3435+
3436+
@classmethod
3437+
def _format_job_lead_thread_content(cls, lead: JobLead) -> str:
3438+
lines = [
3439+
f"Source: {lead.source_url}",
3440+
f"Status: {lead.status.value}",
3441+
]
3442+
if lead.organization:
3443+
lines.append(f"Organization: {lead.organization}")
3444+
if lead.location:
3445+
lines.append(f"Location: {lead.location}")
3446+
if lead.apply_url:
3447+
lines.append(f"Apply/contact: {lead.apply_url}")
3448+
if lead.tags:
3449+
lines.append(f"Lead tags: {', '.join(lead.tags)}")
3450+
metadata = "\n".join(lines)
3451+
separator = "\n\n"
3452+
body_limit = (
3453+
settings.discord_sendmsg_character_limit - len(metadata) - len(separator)
3454+
)
3455+
if body_limit <= 0:
3456+
return cls._truncate_job_lead_text(
3457+
metadata,
3458+
settings.discord_sendmsg_character_limit,
3459+
)
3460+
body = cls._truncate_job_lead_text(lead.body_normalized, body_limit)
3461+
return f"{metadata}{separator}{body}"
3462+
3463+
@staticmethod
3464+
def _job_lead_allowed_mentions() -> discord.AllowedMentions:
3465+
"""Disable mention parsing for untrusted external lead content."""
3466+
return discord.AllowedMentions.none()
3467+
3468+
@staticmethod
3469+
def _resolve_job_lead_forum_tags(
3470+
channel: discord.ForumChannel,
3471+
lead: JobLead,
3472+
extra_tag_names: str | None,
3473+
) -> list[discord.ForumTag]:
3474+
requested = {tag.casefold() for tag in lead.tags}
3475+
if lead.remote:
3476+
requested.add("remote")
3477+
for raw_name in (extra_tag_names or "").split(","):
3478+
normalized = raw_name.strip().casefold()
3479+
if normalized:
3480+
requested.add(normalized)
3481+
if not requested:
3482+
return []
3483+
selected: list[discord.ForumTag] = []
3484+
for tag in channel.available_tags:
3485+
normalized_name = tag.name.strip().casefold()
3486+
if normalized_name in requested:
3487+
selected.append(tag)
3488+
return selected[:5]
3489+
3490+
@app_commands.command(
3491+
name="list-job-leads",
3492+
description="List pending externally sourced job leads awaiting review.",
3493+
)
3494+
@app_commands.describe(limit="Number of pending leads to show, up to 10.")
3495+
@require_role("Steering Committee")
3496+
async def list_sourced_job_leads(
3497+
self,
3498+
interaction: discord.Interaction,
3499+
limit: int = 5,
3500+
) -> None:
3501+
"""Show pending scraped leads without publishing anything to Discord."""
3502+
await interaction.response.defer(ephemeral=True)
3503+
safe_limit = max(1, min(limit, 10))
3504+
try:
3505+
leads = await asyncio.to_thread(
3506+
list_job_leads,
3507+
settings,
3508+
status=JobLeadStatus.PENDING,
3509+
limit=safe_limit,
3510+
)
3511+
except Exception as exc:
3512+
logger.warning("Failed listing job leads: %s", exc)
3513+
await interaction.followup.send(
3514+
"❌ Failed to load pending job leads.",
3515+
ephemeral=True,
3516+
)
3517+
return
3518+
3519+
if not leads:
3520+
await interaction.followup.send(
3521+
"No pending job leads found.",
3522+
ephemeral=True,
3523+
)
3524+
return
3525+
3526+
lines = [
3527+
self._format_job_lead_review_line(index, lead)
3528+
for index, lead in enumerate(leads, start=1)
3529+
]
3530+
await interaction.followup.send(
3531+
"Pending job leads:\n\n" + "\n\n".join(lines),
3532+
ephemeral=True,
3533+
)
3534+
3535+
@app_commands.command(
3536+
name="reject-job-lead",
3537+
description="Reject a sourced job lead so it will not be posted.",
3538+
)
3539+
@app_commands.describe(lead_id="Lead UUID or unambiguous UUID prefix.")
3540+
@require_role("Steering Committee")
3541+
async def reject_sourced_job_lead(
3542+
self,
3543+
interaction: discord.Interaction,
3544+
lead_id: str,
3545+
) -> None:
3546+
"""Reject a scraped lead without publishing it."""
3547+
await interaction.response.defer(ephemeral=True)
3548+
try:
3549+
lead = await asyncio.to_thread(
3550+
review_job_lead,
3551+
settings,
3552+
lead_id=lead_id,
3553+
status=JobLeadStatus.REJECTED,
3554+
reviewer_discord_user_id=str(interaction.user.id),
3555+
)
3556+
except Exception as exc:
3557+
logger.warning("Failed rejecting job lead %s: %s", lead_id, exc)
3558+
await interaction.followup.send(
3559+
"❌ Failed to reject this job lead.",
3560+
ephemeral=True,
3561+
)
3562+
return
3563+
3564+
if lead is None:
3565+
await interaction.followup.send(
3566+
"⚠️ Could not find a pending/approved lead with that ID.",
3567+
ephemeral=True,
3568+
)
3569+
return
3570+
3571+
await interaction.followup.send(
3572+
f"✅ Rejected job lead `{lead.id[:8]}`.",
3573+
ephemeral=True,
3574+
)
3575+
3576+
@app_commands.command(
3577+
name="approve-job-lead",
3578+
description="Approve a sourced job lead and create a Discord forum thread.",
3579+
)
3580+
@app_commands.describe(
3581+
lead_id="Lead UUID or unambiguous UUID prefix.",
3582+
channel="Forum channel where the approved lead should be posted.",
3583+
tags="Optional comma-separated Discord forum tag names to apply.",
3584+
)
3585+
@require_role("Steering Committee")
3586+
async def approve_sourced_job_lead(
3587+
self,
3588+
interaction: discord.Interaction,
3589+
lead_id: str,
3590+
channel: discord.ForumChannel,
3591+
tags: str | None = None,
3592+
) -> None:
3593+
"""Approve a scraped lead and publish it to Discord."""
3594+
guild = interaction.guild
3595+
if guild is None:
3596+
await interaction.response.send_message(
3597+
"⚠️ This command must be used inside a server.",
3598+
ephemeral=True,
3599+
)
3600+
return
3601+
3602+
await interaction.response.defer(ephemeral=True)
3603+
try:
3604+
lead = await asyncio.to_thread(
3605+
review_job_lead,
3606+
settings,
3607+
lead_id=lead_id,
3608+
status=JobLeadStatus.APPROVED,
3609+
reviewer_discord_user_id=str(interaction.user.id),
3610+
)
3611+
except Exception as exc:
3612+
logger.warning("Failed approving job lead %s: %s", lead_id, exc)
3613+
await interaction.followup.send(
3614+
"❌ Failed to approve this job lead.",
3615+
ephemeral=True,
3616+
)
3617+
return
3618+
3619+
if lead is None:
3620+
await interaction.followup.send(
3621+
"⚠️ Could not find a pending/approved lead with that ID.",
3622+
ephemeral=True,
3623+
)
3624+
return
3625+
3626+
applied_tags = self._resolve_job_lead_forum_tags(channel, lead, tags)
3627+
content = self._format_job_lead_thread_content(lead)
3628+
try:
3629+
created = await channel.create_thread(
3630+
name=self._truncate_job_lead_text(lead.title, 100),
3631+
content=content,
3632+
applied_tags=applied_tags,
3633+
allowed_mentions=self._job_lead_allowed_mentions(),
3634+
reason=f"Approved sourced job lead by {interaction.user}",
3635+
)
3636+
except discord.Forbidden:
3637+
await interaction.followup.send(
3638+
"❌ Lead approved, but I do not have permission to create a thread in that forum.",
3639+
ephemeral=True,
3640+
)
3641+
return
3642+
except discord.HTTPException as exc:
3643+
logger.warning("Failed posting approved job lead %s: %s", lead.id, exc)
3644+
await interaction.followup.send(
3645+
"❌ Lead approved, but Discord rejected the thread creation.",
3646+
ephemeral=True,
3647+
)
3648+
return
3649+
3650+
thread = getattr(created, "thread", created)
3651+
thread_id = str(getattr(thread, "id", ""))
3652+
if not thread_id:
3653+
await interaction.followup.send(
3654+
"⚠️ Lead approved and Discord returned success, but I could not record the thread ID.",
3655+
ephemeral=True,
3656+
)
3657+
return
3658+
3659+
posted = await asyncio.to_thread(
3660+
mark_job_lead_posted,
3661+
settings,
3662+
lead_id=lead.id,
3663+
reviewer_discord_user_id=str(interaction.user.id),
3664+
guild_id=str(guild.id),
3665+
channel_id=str(channel.id),
3666+
thread_id=thread_id,
3667+
)
3668+
if posted is None:
3669+
await interaction.followup.send(
3670+
f"⚠️ Created <#{thread_id}>, but could not mark lead `{lead.id[:8]}` as posted.",
3671+
ephemeral=True,
3672+
)
3673+
return
3674+
3675+
await interaction.followup.send(
3676+
f"✅ Posted approved lead `{lead.id[:8]}` to <#{thread_id}>.",
3677+
ephemeral=True,
3678+
)
3679+
34083680
@app_commands.command(
34093681
name="register-jobs-channel",
34103682
description="Register a forum channel for automatic job-post matching.",

apps/worker/src/five08/worker/jobs.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from five08.worker.mailbox_resume_ingest import ResumeMailboxProcessor
2323
from five08.worker.masking import mask_email
2424
from five08.newsletter_sync import NewsletterSyncProcessor
25+
from five08.job_lead_sources import scrape_job_leads
2526

2627
logger = logging.getLogger(__name__)
2728

@@ -216,6 +217,19 @@ def sync_508_members_newsletters_job() -> dict[str, Any]:
216217
return _mask_newsletter_sync_result(processor.sync_508_members())
217218

218219

220+
def scrape_job_leads_job(
221+
source: str = "hackernews_who_is_hiring",
222+
story_id: int | None = None,
223+
) -> dict[str, Any]:
224+
"""Scrape external job lead sources into the review queue.
225+
226+
This job intentionally does not publish to Discord. Publishing requires a
227+
separate approval action in the bot/dashboard layer.
228+
"""
229+
logger.info("Scraping job leads source=%s story_id=%s", source, story_id)
230+
return scrape_job_leads(settings, source=source, story_id=story_id)
231+
232+
219233
JOB_FUNCTIONS: dict[str, Callable[..., dict[str, Any]]] = {
220234
process_webhook_event.__name__: process_webhook_event,
221235
process_contact_skills_job.__name__: process_contact_skills_job,
@@ -228,4 +242,5 @@ def sync_508_members_newsletters_job() -> dict[str, Any]:
228242
sync_projects_from_erpnext_job.__name__: sync_projects_from_erpnext_job,
229243
sync_508_members_newsletters_job.__name__: sync_508_members_newsletters_job,
230244
process_docuseal_agreement_job.__name__: process_docuseal_agreement_job,
245+
scrape_job_leads_job.__name__: scrape_job_leads_job,
231246
}

0 commit comments

Comments
 (0)