Skip to content

U06: Add job is one paste box, a review, then Save and run fit - #438

Merged
Taleef7 merged 14 commits into
mainfrom
restart/355-add-job
Oct 21, 2026
Merged

Taleef7 merged 14 commits into
mainfrom
restart/355-add-job

Conversation

@Taleef7

@Taleef7 Taleef7 commented Oct 15, 2026

Copy link
Copy Markdown
Owner

Refs #355 (U06, Stage A step 14, part of #333). Based on main after #433 (A7) and #434 (A4). ?run=fit goes through A7's one-at-a-time Score fit guard.

Why

The Add job form showed the URL path and the manual path as one long form, and it didn't do what it said:

  • pasting a posting filled nothing; Company and Title had to be typed;
  • "Save job" only saved, so a fit read needed a second step;
  • Location and Workplace started as Remote, so "London, United Kingdom" was saved as Remote;
  • every job got a Medium priority, and the placeholders ("Pebble", "AI Software Engineer") looked like values.

The stores did the same behind the form: a job created without a location, workplace or employment type was saved as "Remote", "remote" and "Full-time", and a NULL column was read back that way.

What changed

  • Step 1: one box, "Paste a job link or the full posting", and Get details.
    • A single line starting with http goes to POST /api/jobs/extract. Anything else goes to parse-job, without a job id.
    • Enter details yourself opens an empty review, keeping what was pasted. It's hidden while a posting is being read.
    • A failure shows under the box: the API's reason, Try again and Enter details yourself. It stays up during the retry, so focus stays on Try again.
    • An answer that comes back after Enter details yourself or Start over is dropped.
    • A posting the agent refuses as instructions to the AI says so, instead of looking like a posting that states nothing.
  • Step 2, the review:
    • Fields: Title and Company (required), Location, Workplace (Remote / Hybrid / On-site / Flexible / Not stated), the salary (read-only), Posting URL, and the description folded to four lines with its length and an Edit toggle.
    • Three states for Title, Company, Location and Workplace:
      • from posting: a field the reading filled, until it's changed;
      • Not stated: a field the reading said wasn't there;
      • unknown: blank, with no hint and no workplace chosen. This covers what the reading couldn't tell: an older agent, or a workplace word the app doesn't know ("remote or hybrid").
    • Nothing starts with a default.
  • Salary: the review shows what will be saved, not what the posting said.
    • The store reads the salary from the saved description with parseSalaryFromText, as it always has, and the form sends none.
    • parse-job and /api/jobs/extract return that same reading.
    • The row is "Salary, as it will be saved". It shows either "£80,000 – £95,000 a year (GBP)", with the store's yearly basis and currency, or "No salary found in the description."
    • Once the description is edited, or for details entered by hand, it says "Read from the description when you save."
    • It makes no claim about the posting, so it can't contradict the saved job.
  • Save and run fit saves and opens /jobs/<id>?run=fit. Save only saves and opens the job. Nothing is saved before one of them is pressed. On a phone the buttons stack, primary first.
  • Save errors: a 409 or 400 on a field the form shows puts the message there and moves focus to it. Anything else goes in an alert.
  • Job page: with ?run=fit, Score fit runs once on open through A7's guard.
    • The button says "Scoring…", and a status line says "Scoring this job. This can take a moment." Then the toast, or the reason inline with Try again. A refused posting says "…so it wasn’t scored."
    • The parameter is dropped once the run starts. The job is also remembered for the page load, so Back, Forward and reload don't start (or pay for) another run.
  • What's saved: no priority, no employment type, and no location or workplace the review didn't show.
  • Stores: Postgres and the file store save NULL for a location, workplace or employment type a job wasn't given, and read it back as empty (workplaceType is nullable now). The job header leaves those badges out, and the Pipeline row and dashboard show the company alone. Rows already saved keep what they have.
  • API:
    • parse-job answers 400 for a missing or non-string description_text, and 413 above 100,000 characters, before the AI.
    • It returns location, workplace_type and salary, and leaves out the location or workplace it can't tell.
    • A refused posting is a 422 with a reason. Score fit answers its own 422 for the same posting. The n8n intake keeps the job, skips enrichment and still answers 201, so n8n doesn't retry it into a duplicate.
    • Any other 422 from the agent is the usual retryable 503. It used to throw on a locked stream and answer 500 on every agent route.
    • The salary parser's regexes are bounded: 40k digits took 16 s and now take a few ms. What it saves is otherwise unchanged.
    • A workplace in other words ("On-site", "in-office", "in-person", "fully remote", "100% remote", "Remote (US)", "flexible") is read. One the app doesn't know is left out rather than failing the parse, which had also failed Score fit and the n8n intake.
  • Agent:
    • The job parser reads location and workplace_type, null only when the posting says nothing.
    • Known workplace words map to remote, hybrid, onsite or flexible (listed in the field's description for the structured output); any other word goes through as written for the API to call unknown.
    • A location list keeps its text items, joined with "; ".
    • A refused posting raises PostingRefused, and /parse-job answers 422 with code: "input_refused". The assistant graph still records it as before.
    • The prompt says never to guess or default to remote.
  • Copy: "Paste a job link or the posting. Check the details, then save." No sparkles, no "CRM", "e.g." placeholders only.

Not in this PR:

  • PageContainer form / PageHeader (U03).
  • The saved salary's accuracy is Saved salaries: hourly, monthly and other currencies are saved as yearly USD #437. The review now shows the store's reading as it is. The known limits:
    • an hourly rate is saved as yearly (×2080);
    • a monthly or weekly figure is saved as if it were yearly;
    • "$… CAD" or "$… AUD" is saved as USD, and so are other dollars (NT$, MX$, R$, A$);
    • a figure that isn't pay ("50,000 customers") can be read as a salary when salary words are near it.
  • The job header still shows "medium priority" on every new job: the form no longer sets one, and the column's default is medium. C2 removes priority from the job page; until then the badge stays.
  • Discovery still guesses onsite and "Full-time" for Adzuna jobs in normalize.ts; that's discovery's, not the form's.

Evidence

  • New behaviour came with tests that failed first, then passed. A few tests only guard display that already worked, such as the ?run=fit inline error for a refusal.
  • Gates: npm run check passes. npm test: API 699/699; web 385/385 in 58 files. npm run test:pg on a fresh database: 56/56. Agent: pytest 459 passed, 13 skipped; ruff check clean.
  • Acceptance grep: git grep -n "placeholder=\"Pebble\"\|Saved to the CRM\|AI extracts\|Save & analyze" -- apps/web/src returns nothing.
  • Hostile input: 40k digits, a 40k-digit decimal, a 10k-group comma number and 40k spaces each go through the salary parser in under 100 ms (16 s, 16 s, 1 s and 3.6 s before).
  • Agent 422s: a FastAPI validation 422, a non-JSON 422 and an unknown code each give a retryable 503. A refusal gives the parse-job 422, the Score fit 422, or the n8n 201 with enrichment skipped.
  • One fit run: under StrictMode, opening with ?run=fit sends one score-fit. A re-render, or an unmount and a fresh render of the same job, sends no second.
  • Reading races: a late answer after Enter details yourself keeps what the user typed; a late failure after Start over shows no stale error.
  • Layout: the real page, driven into each state (paste, error, review from a posting, "Not stated", manual with errors), rendered with the built CSS: no sideways scroll at 390px or 1280px, and at 390px the buttons stack with Save and run fit first.
  • Keyboard: a test goes from the paste box to Get details to the review heading to Title, and Enter saves with Save and run fit.

Deploy

Agent image, then API, then web; no migration and no env change.

  1. Agent: activate the image CI builds from the merge commit.
    • Until the API follows, a refused posting reaches users as the generic "The AI couldn't answer" (503), not as an empty parse.
    • The live API ignores the new keys: it doesn't check workplace_type, and the live web doesn't call parse-job.
  2. API. With the new agent, pasted text gets location and workplace, and a refused posting gets its own message.
  3. Web. Against an older API or agent, the review shows what it couldn't tell as blank, and the salary as "Read from the description when you save", never as "Not stated".

After deploy, on live:

  • /jobs/new at 1280px and 390px: one box and Get details; no console errors; no sideways scroll.
  • Paste a Greenhouse link: Title, Company and Location "from posting"; the job count is unchanged until a save.
  • Paste the same posting's text: Title, Company and Location filled.
  • Save and run fit: one POST /api/ai/score-fit in the network log, the fit or an inline error, and the address without ?run=fit. Reload, then Back and Forward: no second request.
  • A posting with no location: "Not stated" in the review; no location or workplace badge on the saved job.
  • Salary checks:
    • A posting with a yearly range: the review's "Salary, as it will be saved" equals salaryMin, salaryMax and salaryCurrency in GET /api/jobs/<id> after saving.
    • A posting without one: "No salary found in the description."
  • A link with nothing to read: the inline error, with Try again and Enter details yourself.
  • Enter details yourself, then Save only; the whole flow by keyboard.
  • Paste 40,000 digits: the answer comes back quickly, and the API stays responsive.

Taleef added 12 commits October 15, 2026 14:27
The Add job review fills Title, Company and Location from a pasted
posting and shows the salary as posted. parse-job returned only company
and title, so pasted text could never fill Location.

- The agent's job parser reads `location` and `workplace_type` (remote,
  hybrid or onsite), null when the posting doesn't say. The prompt says
  never to guess or default to remote.
- parse-job returns both, null when the agent leaves them out, so the API
  works with the agent image that's live now. A workplace the API doesn't
  know is an unusable answer (503), like any other bad field.
- parse-job and /api/jobs/extract return `salary`, read from the
  description by parseSalaryFromText, the function the store uses when it
  saves the job.

Nothing is saved by either call. Tests: the API ones were red first
(salary missing, the unknown workplace answered 200), and so were the
agent's schema and endpoint tests.

Refs #355
The stores filled what a new job didn't say: location "Remote",
workplace "remote", employment type "Full-time", on insert and again when
a NULL column was read back. The Add job review is going to say "Not
stated" for these, and the job page then said Remote.

- Postgres and the file store save NULL (or nothing) for a location,
  workplace or employment type the job wasn't given, and read it back as
  an empty location and employment type and a null workplace.
  `workplaceType` is `JobWorkplaceType | null` in the API and the web.
- The job page header leaves those badges out instead of showing an empty
  one. The Pipeline row and the dashboard show the company alone when
  there's no location, not "Acme · ".
- The assistant's job context says "not stated" instead of "null".

Rows already saved keep the values they have. Tests were red first: the
file store and the Postgres store (raw columns and the read), the job
header's empty badge, the list line, and the assistant context.

Refs #355
"Save and run fit" on the Add job form will save the job and open
/jobs/<id>?run=fit. The page reads `run=fit` and JobAnalysisActions
starts Score fit once on open, through the same one-at-a-time guard as
the button (#353), and reports it like a click: "Scoring…" on the button
and a status line while it runs, the "Fit score saved" toast after, or
the reason inline with Try again.

Once the run starts, `?run=fit` is dropped from the address, so a reload
or Back to the page doesn't start (and pay for) another. The once-per-job
ref that holds the estimate upgrade holds this too, including React's
second effect run in development.

Tests were red first: the page passes runFit only for `run=fit`; in
StrictMode, one request, the status line, the toast and the address
without the parameter; a failure shows inline.

Refs #355
The form showed the URL path and the manual path as one long form. It
promised that the AI extracts skills and scores the fit, but pasting a
posting filled nothing, Company and Title had to be typed, and "Save &
analyze" only saved. Location and Workplace started as Remote, and every
job got a Medium priority.

Step 1 is one box, "Paste a job link or the full posting". A single line
starting with http goes to the URL extractor; anything else goes to
parse-job, without a job id. "Enter details yourself" skips to an empty
review, keeping what was pasted. A failure shows under the box with the
API's reason, Try again and Enter details yourself.

Step 2 is the review: Title and Company (required), Location, Workplace
as Remote / Hybrid / On-site / Not stated, Posting URL, and the
description collapsed with its length and an Edit toggle. When the
fields came from a posting, the salary it states is shown read-only
("£80,000 – £95,000"), or "Not stated"; entered by hand, there's no
salary row, since the server reads it from the description on save.
A field the posting filled says "from posting" until it's changed; an
empty one says "Not stated". Nothing starts with a default, and nothing
is saved before a button is pressed.

"Save and run fit" saves the job and opens /jobs/<id>?run=fit, where
the job page runs Score fit once. "Save only" saves and opens the job.
What the review showed is what's saved: no priority, no employment type,
no location or workplace the posting didn't state, and the salary it
showed. On a phone the buttons stack, primary first, and focus moves to
the review's heading, then back to the box on Start over.

The page says "Paste a job link or the posting. Check the details, then
save." ErrorState can show another action beside Try again. The tests
were red first (18 for the form and ErrorState, 1 for the page); the
acceptance grep for Pebble, "Saved to the CRM", "AI extracts" and "Save &
analyze" in apps/web/src returns nothing.

Refs #355
…ry drops two hints

From the layout check at 390px and 1280px with the built CSS:
- The collapsed description clamped at four lines, but the box's padding
  sat on the same element, and overflow is cut at the padding edge, so
  part of a fifth line showed. The clamp is on an inner line box now.
- Entered by hand, Title and Company said "Not stated" next to "Add the
  job title." They're required, so the hint only says something when a
  posting left them out. An empty Location still says "Not stated".
- "Done editing" showed over an empty description, which would fold to
  an empty box. The toggle shows once there's text.

The manual-entry test's new checks were red against the last commit.

Refs #355
…ot an error

From review:
- parse-job had no size limit, and it ran the salary regexes on the
  whole body. Their digit runs were unbounded, so they were quadratic:
  40k digits stalled the API for 16-21 s. parse-job now refuses more than
  100,000 characters with 413, before the AI, and reads the salary from
  the first 20,000, the part the agent reads. parseSalaryFromText folds
  whitespace runs, drops digit runs longer than 12, and bounds every
  repeat; 40k digits, a long decimal or comma tail, or 40k spaces now
  take a few milliseconds.
- The validator rejected a workplace the API didn't know ("Remote",
  "on-site", "flexible"), and Score fit and the n8n intake parse through
  the same call, so an odd word failed them. The API now lowercases the
  workplace, reads "on-site" and "in-office" as onsite, and makes
  anything else null. The agent does the same in a before-validator.
- An agent from before #355 doesn't read location or workplace. parse-job
  left them as null, which the form shows as "Not stated"; it now leaves
  the keys out, so they're unknown.

Tests were red first: the timing tests (16 s on digits, 3.6 s on
spaces), the 413 and the 20k salary slice, the omitted keys, the
workplace words, and the agent's schema test.

Refs #355
…lace

From review:
- Back or Forward to /jobs/<id>?run=fit can serve the router's cached
  page, still with runFit, after the address was cleaned. After a failed
  run that started another. A job id is now remembered for the page load
  once it has run from the link. Test: render with runFit, unmount,
  render again; one score-fit, not two.
- The status line had `empty:hidden`, which takes an empty live region
  out of the accessibility tree, so the first message could be missed.
  It stays in place now, and says "Scoring this job. This can take a
  moment."

Both tests were red first.

Refs #355
…g changes nothing

From review:
- Reading races. "Enter details yourself" stayed live while a posting
  was read, and the late answer replaced what the user had typed. A
  failure that came back after Start over showed as a stale error, and
  Start over mid-read later jumped to the review. Each reading now has a
  number that Enter details yourself and Start over move on, and an
  answer for an older number is dropped. The link under the box is gone
  while reading.
- The failure stays up while Try again reads again, so focus stays on
  Try again instead of falling to the page. Try again is type="button".
- Unknown is not "not stated". With an agent or API from before this
  change, location, workplace and salary are left out of the answer. The
  review now shows those as a blank field with no hint, no workplace
  chosen, and no salary row. "Not stated" is only for what the reading
  said wasn't there. /api/jobs/extract now returns `salary: null` when
  the description states none, and leaves it out when there's no
  description.
- Server errors on save. A 409 or 400 on a field the form shows puts the
  message there and moves focus to it. A field the form doesn't show
  (priority, say) goes in the alert, and with no field at all the
  server's message does.
- The Workplace legend is the fieldset's first child, so the group is
  named "Workplace".
- No router.refresh() after the push: the job page is rendered on
  request.

Tests were red first (13 in the form, actions and error-state files,
and the extractor's salary test).

Refs #355
…t be told is unknown

From the round-3 review:
- The salary was read from the first 20,000 characters, but the store
  reads the whole description on save, so a salary further down showed
  "Not stated" and was saved anyway. The parser is linear now (100k
  characters in tens of ms), so parse-job reads the whole description.
- The review showed derived figures as "from posting": an hourly rate
  made annual (x2080), "$120,000 CAD" read as USD, and "$40 - $60 /
  hour", which the parser can't read, as "Not stated". The new
  readSalaryForReview gives the figure only for an annual salary in a
  currency the parser is sure of. An hourly rate, another dollar, or pay
  it can't read is unknown and left out of the answer. Null ("not
  stated") is only for a posting with no sign of a salary.
  parseSalaryFromText, and so what the store saves, is unchanged.
- /api/jobs/extract does the same, and leaves the salary unknown when
  the description is only the page's meta description.
- A workplace word the app doesn't know became null, which the form
  shows as "Not stated". It's left out now (unknown). A leading "remote"
  ("remote-first", "Remote (US)") is remote unless it also names an
  office or hybrid; "flexible" stays unknown. The agent maps the same
  way.
- A refused posting (INJECTION_ACTION=refuse) came back as an empty
  parse, so it looked like a posting stating nothing. The agent's
  parse_job raises PostingRefused, and /parse-job answers 422 with code
  "input_refused". The API turns that into its own 422 with a reason the
  form shows. The assistant graph still records the refusal as before.
- The agent joins a list of locations with "; " instead of rejecting it.
- parse-job answers 400, not 500, when description_text isn't a string.

Tests were red first: 14 in the API (salary reading, workplace words,
400, 422) and the agent's refusal and list-location tests.

Refs #355
…ing says why

From the round-3 review:
- The form sent the reviewed salary on save, but the store reads it from
  the description, and an edited description could say something else.
  The form now sends no salary, and the store reads it from the saved
  description as before. The review shows the API's reading (an amount,
  or "Not stated") only while the description is the one it was read
  from; edited, entered by hand, or unknown, the row says "Read from the
  description when you save."
- A posting the agent refuses shows its reason under the box, with Try
  again and Enter details yourself.
- ExtractedJobResponse.salary is `PostedSalary | null`, with the unknown
  cases in its comment. The JSDoc for ranFromLink and forgetRunParam is
  back above the code it describes.

Tests were red first: four in the form (the salary row by hand, the
payload without a salary, the unknown row, the edited description).

Refs #355
…and the salary is judged at the figure

From the final review:
- Any 422 from the agent that wasn't a refusal (FastAPI's validation
  error, a body that isn't JSON) was read, then cancelled on a locked
  stream. That threw, and every agent route answered 500, the n8n intake
  after it had created the job. The body is now read once as text and
  parsed in a try. A refusal is AiRefusedInputError; anything else is the
  usual "The AI couldn't answer" (503, retryable).
- The n8n intake answered 422 for a refused posting after creating the
  job, so n8n would retry and add it again. It now skips enrichment, as
  for an AI that can't answer, and answers 201 with a note.
- Score fit on a refused posting showed the Add job form's "Enter the
  details yourself". It answers 422 "This posting has text that reads
  like instructions to the AI, so it wasn't scored.", saves nothing, and
  the ?run=fit inline error shows it.
- The agent turned a workplace word it didn't know into None, so "remote
  or hybrid" or "flexible" showed as "Not stated". It now maps the known
  words (adding "fully remote", "100% remote", "in-person" and any
  spacing of "on site") and passes any other word through as written.
  The API maps the same words and leaves anything else out (unknown).
- The review's salary is judged at the figure, not across the posting.
  The figure is shown only when it's yearly, with its currency stated
  next to it, in a currency the parser reads correctly. A monthly,
  weekly, daily or hourly figure, a figure with no currency next to it
  ("Salary 60,000 - 70,000"), or a sign of another currency right at it
  ("A$", "NZ$", "HK$", "S$", "$... AUD") is unknown. "AutoCAD" elsewhere
  no longer counts. "US$" is read as US dollars, for the store too.
- More signs of pay make a posting unknown rather than "Not stated": ¥,
  ₹, kr, a currency code next to digits, "40k", and base, CTC, lakh,
  remuneration, wage and stipend.
- The agent keeps only the text items of a location list (["NYC", None]
  is "NYC"); anything else that isn't text is None.
- The timing test for the review's salary uses hostile inputs; each
  takes under 200 ms.

Tests were red first: 9 in the API (other 422s, workplace words, Score
fit and n8n refusals, the salary cases) and 2 in the agent.

Refs #355
…tore's own parser

The review tried to show the salary "as the posting stated it", with
windows and signs to decide when a figure could be trusted. Each fix
opened another case ("Salary range (CAD): $120k-150k" read as USD,
"customer base" counting as a sign of pay, "+ 25 days PTO" making a
yearly figure unknown). That heuristic is gone.

- parse-job and /api/jobs/extract return `salary` as parseSalaryFromText
  reads the description, the function the store calls on save, or null.
  Extract still leaves it out for a meta description.
- The review's row is "Salary, as it will be saved": "£80,000 – £95,000
  a year (GBP)", with the store's yearly basis and currency, or "No
  salary found in the description." Edited or entered by hand, it's
  "Read from the description when you save." It says nothing about what
  the posting said, so it can't contradict the saved job. The store's
  own mistakes now show in the review; the saved salary's accuracy is
  #437.
- "US$" is reverted in the store's parser: what the store saves belongs
  to #437. The bounded regexes stay.
- "flexible" is a workplace the app stores. The API and the agent map
  it, and the review offers Flexible next to Remote, Hybrid and On-site.
  The agent's free-text field lists the four values in its description,
  for the structured output. The unused WorkplaceType literal is gone.
- A cancelled agent body can't throw: `.catch(() => undefined)`.

Tests were red first: 4 in the API, 9 in the web (the salary row, the
formatter, Flexible), 2 in the agent.

Refs #355
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-15T19:19:35.963226Z bbb9230 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@Taleef7 Taleef7 self-assigned this Oct 21, 2026
@Taleef7
Taleef7 merged commit 32bb815 into main Oct 21, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant