Repository navigation
U06: Add job is one paste box, a review, then Save and run fit - #438
Merged
Merged
Conversation
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
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #355 (U06, Stage A step 14, part of #333). Based on main after #433 (A7) and #434 (A4).
?run=fitgoes 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:
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
POST /api/jobs/extract. Anything else goes toparse-job, without a job id.parseSalaryFromText, as it always has, and the form sends none.parse-joband/api/jobs/extractreturn that same reading./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.?run=fit, Score fit runs once on open through A7's guard.workplaceTypeis 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.parse-jobanswers 400 for a missing or non-stringdescription_text, and 413 above 100,000 characters, before the AI.location,workplace_typeandsalary, and leaves out the location or workplace it can't tell.locationandworkplace_type, null only when the posting says nothing.PostingRefused, and/parse-jobanswers 422 withcode: "input_refused". The assistant graph still records it as before.Not in this PR:
PageContainer form/PageHeader(U03).onsiteand "Full-time" for Adzuna jobs innormalize.ts; that's discovery's, not the form's.Evidence
?run=fitinline error for a refusal.npm run checkpasses.npm test: API 699/699; web 385/385 in 58 files.npm run test:pgon a fresh database: 56/56. Agent:pytest459 passed, 13 skipped;ruff checkclean.git grep -n "placeholder=\"Pebble\"\|Saved to the CRM\|AI extracts\|Save & analyze" -- apps/web/srcreturns nothing.?run=fitsends onescore-fit. A re-render, or an unmount and a fresh render of the same job, sends no second.Deploy
Agent image, then API, then web; no migration and no env change.
workplace_type, and the live web doesn't call parse-job.After deploy, on live:
/jobs/newat 1280px and 390px: one box and Get details; no console errors; no sideways scroll.POST /api/ai/score-fitin the network log, the fit or an inline error, and the address without?run=fit. Reload, then Back and Forward: no second request.salaryMin,salaryMaxandsalaryCurrencyinGET /api/jobs/<id>after saving.