- A1: fire the hard filter (Apify is precise); embedding vibe-ranker (embed.py) blends with the floor-free white-box (rank.py) to sift ~90 -> top 18 - A2: Opus curator (curate.py) reads the 18 -> honest <=count + Gemini-voiced report cards; graceful fallback to the white-box + templated cards - LLM via opencode.ai/zen gateway (llm.py): Opus chat + direct-OpenAI embeddings - Run LLM work off the event loop (asyncio.to_thread) so the Redis response publishes - C1: dedicated Postgres (app/db/) persists the per-user feed; get_scout_feed replays it so matches survive navigation/refresh - match contract + report-card fields (schema.py); skills.py; tests/
60 lines
2.5 KiB
Python
60 lines
2.5 KiB
Python
"""The `match` block — the ONE contract between the engine and the card.
|
||
|
||
Defined once here (Pydantic) and mirrored as a `JobMatch` TS interface on the frontend
|
||
`ScoutJob`. Every engine phase writes this exact shape; `scoutJobToMatchRole` reads it.
|
||
Keeping the breakdown/growth shapes identical to the card's existing `MatchDim`/`growth`
|
||
means the UI renders real numbers with **no shape change**.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from typing import Literal
|
||
|
||
from pydantic import BaseModel, ConfigDict, Field
|
||
|
||
Level = Literal["Strong", "Solid", "Light"]
|
||
Fit = Literal["fit", "stretch"]
|
||
|
||
|
||
class MatchDim(BaseModel):
|
||
"""One row of the "how you match" breakdown — mirrors the frontend `MatchDim`.
|
||
`note` is a one-line, Gemini-toned observation for this dimension (the report-card voice)."""
|
||
name: str
|
||
score: int # 0–100
|
||
level: Level
|
||
note: str | None = None
|
||
|
||
|
||
class Growth(BaseModel):
|
||
"""The weakest *reachable* dim + the lift closing it buys — mirrors `MatchRole.growth`.
|
||
`from` is a Python keyword, so it's aliased (JSON stays `{text, from, to}`)."""
|
||
model_config = ConfigDict(populate_by_name=True)
|
||
text: str
|
||
from_: int = Field(alias="from")
|
||
to: int
|
||
|
||
|
||
class MatchResult(BaseModel):
|
||
"""Attached to each surviving ScoutJob as `job["match"]` — the engine's report card.
|
||
Voice mirrors interview-service's Gemini video-analysis: a characterful archetype, an honest
|
||
balanced one-liner, per-dimension notes, and a warm coach note. Templated now → Opus later."""
|
||
score: int # 0–100 — the real utility (NOT fetch order)
|
||
fit: Fit
|
||
archetype: str | None = None # characterful label, e.g. "The Stretch Worth Taking"
|
||
one_line: str | None = None # balanced headline verdict (strength AND gap)
|
||
reason: str # short "why picked"
|
||
breakdown: list[MatchDim] = [] # per-dimension dims, each with a `note`
|
||
growth: Growth | None = None
|
||
coach_note: str | None = None # warm, actionable closing line
|
||
proofReady: bool = False # user is Strong on the job's key skill
|
||
factors: dict[str, float | None] = {} # debug/observability (per-factor φ; None = absent)
|
||
|
||
def as_dict(self) -> dict:
|
||
"""Serialize for the ScoutJob payload — by alias so growth emits `from`/`to`."""
|
||
return self.model_dump(by_alias=True)
|
||
|
||
|
||
def attach_match(job: dict, result: MatchResult) -> dict:
|
||
"""Set `job['match']` from a MatchResult (in place) and return the job."""
|
||
job["match"] = result.as_dict()
|
||
return job
|