feat(offers): compare multiple job offers, then plan the negotiation - #222
Merged
Conversation
Offer-letter extraction, encrypted document storage, and the offerLetter document type already ship. tax_documents is keyed on the file fingerprint, so several offer letters per user already coexist. The gap is comparison, questions, verdict, and negotiation — so that is all this plan scopes. Two decisions worth stating up front, because they shape the rest: Questions are selected deterministically, not invented by the model. The engine normalizes the offers first, finds where they tie or where the paper cannot answer, and asks only the questions whose answer changes the ranking. Real extracted numbers get slotted into the wording. A model asked to invent five questions produces a generic quiz; a selector driven by this candidate's actual numbers produces five that matter to them. The engine decides the winner and the model only phrases it. No number in the output originates from the model, which keeps the verdict auditable and stops a hallucinated figure from becoming financial advice. Also records two pre-existing defects the feature must fix, both of which get worse when one upload per user becomes five: interpretOfferLetter bypasses the AI spend ledger entirely, and the ledger prices no Gemini model, so metering it without adding prices would throttle on the deliberately-expensive fallback. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Offer-letter and payslip interpretation called Gemini directly and never consulted the AI spend ledger. Every other paid path in the backend is metered, and this one only survived unnoticed because the product uploads one document per user. Multi-offer comparison turns that into five, so the hole gets closed before the feature opens it. The ledger priced no Gemini model, which meant metering alone would not have worked: FALLBACK_PRICE is deliberately the most expensive model known, so an unpriced GEMINI_MODEL would refuse every upload rather than overspend. Gemini Flash is now priced at its standard tier, not the introductory rate that halves it until 31 Dec 2026 — a price that rises on a date nobody is watching would start under-counting the cap silently, and over-counting is the only safe direction to be wrong about a cap. The budget check is injected as a SpendGuard rather than imported. That looks like ceremony but is load-bearing: geminiInterpreter and sarvamDocumentService read process.env directly and never import config.ts, which is exactly why documentParser.test.ts can use static imports and run with no environment at all. The backend CI job sets no env vars, so importing the ledger — and through it config.ts, which requires DATABASE_URL — would have failed the whole parser suite at import time. Injection keeps the provider clients free of the database and lets the tests state their own budget. The ledger-backed implementation lives in documentSpendGuard.ts, which only routes.ts imports. spendGuard is required on parseUploadedDocument with no default, so no upload path can reach a paid model unmetered by omission. Two smaller fixes fell out of the same work. The two interpreters were near duplicates of one 90-line call; they now share one metered helper, which is what made a single place to check the budget possible. And neither sent maxOutputTokens, so the worst case the guard approves is now the worst case the call can actually reach, which a test pins. Gemini bills thinking tokens at the output rate, so thoughtsTokenCount is recorded as output. Usage is recorded even when the response is truncated or unparseable, because those responses were billed too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A comparison session holds the normalized comparison across several stored offer letters, the questions selected for that candidate, their answers, and the cached advice. The session payload is encrypted at rest. That is not extra caution: extracted salary fields are already encrypted inside tax_documents.parse_summary by storedParseSummary, so a plaintext comparison column would have quietly undone that decision for the same numbers. One envelope covers the whole session rather than one per stage, because every read wants all of it and a single decrypt is simpler than four. Only the columns the runtime filters or compares on — status and the advice fingerprint — stay outside. Unlike ai_spend_ledger, these tables do get row-level security. The ledger opts out because its cap is one global figure that has to be summed across users; every column here belongs to exactly one candidate. Which offer letters a session compares is a real foreign key rather than a list of ids inside the payload, so deleting an offer letter cascades the session away with it. A comparison that outlived one of its own inputs would keep answering with a figure the user believes they deleted. The advice fingerprint is a SHA-256 over the normalized offers plus the answers. Advice is the only paid step in the feature, so unchanged inputs reuse the cached result instead of paying again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…l sees them The engine decides which offer wins. The model that later writes the wording is given no number of its own to produce, which is what keeps a verdict about someone's salary auditable instead of plausible. Offers are decomposed onto four axes rather than compared on CTC: pay that arrives regardless of performance, pay that is conditional, money paid once, and employer contributions. Ranking is on guaranteed pay, and the comparison reports when the largest CTC is not the best offer on that axis — which is the single most useful thing this file can notice, because it is how a candidate ends up choosing the worse offer while believing the numbers backed them up. Four refusals are load-bearing, and each has a test: An amount with no stated frequency is not annualized. Assuming an unlabelled figure is monthly would turn one number into twelve, the most expensive mistake available here, so it becomes a question instead. A letter nothing can be classified from reports null guaranteed pay, not zero. Zero would rank a silent letter last as though it paid nothing. Reimbursements and deductions are never counted as pay. One returns money already spent, the other takes money away; counting either as income is how a CTC figure gets padded. Offers in different currencies are not ranked at all, because ranking them needs an exchange rate this product does not hold. Printed totals win over component sums — the letter is the promise — but a disagreement above 5% is reported rather than resolved. Differences under 2% in guaranteed pay are called a tie, since ranking noise as a winner would dress it up as a finding. Take-home is not computed here. The tax engine lives in the app (lib/engine/tax_engine.dart), and duplicating slab logic in the backend to save a hop would leave two tax engines to keep in step. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…didate's own numbers A model asked to invent five questions produces a competent generic quiz. What helps is asking only the questions whose answer would change the ranking the engine already computed, worded with the figures from these letters. So selection is code, not a prompt — which also means this step costs nothing to run. Seven questions are eligible, five get asked, ranked by how much the answer moves the verdict. Questions that cannot fire are not asked: no at-risk pay to lose, no money paid once to claw back, nothing left unread in the letters. The set differs between candidates by construction rather than by instruction. Two pieces of the ordering are worth naming. When guaranteed pay cannot separate the offers, the role question is promoted to first, because at that point it is not breaking a tie, it is the decision. And the at-risk question only appears when the offers' at-risk shares diverge by more than 8 percentage points — below that both sides carry the same risk and the answer is wasted. The location question is always eligible and never triggered by detection, because offer letters do not reliably state the posting and the interpretation carries no location field. Claiming to detect it would have been a lie in code; asking last is honest. Every question records why it earned its slot. That is not shown to the candidate — it makes the selection reviewable, and it is what the advice step will be told so the verdict is explained in the same terms it was decided on. Money is formatted the way the offers read, in lakh and crore, and a non-rupee amount is labelled with its currency code rather than dressed up with a ₹. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ed call The advisor is given the comparison the engine already decided and asked to explain it. It is never asked which offer wins, and the response schema has no field for a winner, so the model cannot name a different one even if it disagrees. That is the difference between advice that can be audited and advice that merely sounds right. Every figure about the offers is handed over pre-formatted, so restating one is quoting rather than calculating. The model may still propose a number to ask for in a negotiation — that is a recommendation, not a claim about the letters, and the distinction is stated in the prompt rather than left implied. Advice aimed at an offer id that is not in the comparison is rejected outright rather than retargeted. Repairing it would leave a script written for one employer addressed to another, which is worse than showing nothing. The call is still recorded as spend, because it was still billed. Answers are the candidate's own words and employer names came out of an uploaded document, so both go in through the untrusted-text wrapper. A test feeds an injection attempt through the answers to prove it stays data. The fingerprint that gates re-spending covers the offers and the answers, sorted so answer order cannot change it, and excludes the question wording because that is derived from the comparison — including it would only add a way for the fingerprint to move without the inputs moving. Two refactors made this possible without a third copy of the same code. The metered Gemini call is now shared (geminiStructuredCall.ts) rather than living inside the document interpreter, and money formatting moved beside it so the questions and the advice quote the same figure the same way. Both preserve the property that these modules read process.env directly and import no config, which is what keeps the parser suite runnable with no environment at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The offer routes live in their own module rather than in routes.ts, which is already 2,000 lines covering every other product area. One product area, one file, so it reviews and hands off on its own. Offers keep the order the request listed them in, so "Offer A" means the same thing on every screen no matter how the ranking comes out. Three failure modes are handled deliberately rather than incidentally. A document that is not an offer letter is refused outright instead of compared as if it were. A document whose extracted fields cannot be decrypted is left out and reported by id, rather than silently compared as blanks. And an answer to a question this session never asked is a 400, because feeding it forward would brief the advice call with a premise the comparison does not support — a single offer never gets the leverage question, and answering it anyway would invent a second offer. When advice is unavailable — no key, budget spent, model down — the answers are still saved and the response is a 503 carrying the session. The candidate never re-enters five answers because of an outage. Advice is reused when the fingerprint matches, so re-submitting identical answers does not buy the same paragraph twice. The question rationale is stripped from every response. It exists to make the selection reviewable and to brief the advice call, not to be read by the candidate. Session state goes in and out through the same document encryption the vault uses, so no normalized salary figure is written in readable form. The fake database in the security harness grew handlers for the two new tables, and its id generator now produces a UUID for comparison sessions — the routes validate the path parameter as one, so a placeholder id was being rejected before the route body ever ran. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The flow is one screen with three stages, because choosing between job offers is one decision made in one sitting: pick the letters, answer at most five questions, read the verdict and the negotiation play. Nothing here recomputes a figure the backend decided. The ranking and the pay decomposition arrive settled and this layer reads them, which is the same reason the advice call is never asked to pick a winner. Take-home is the one number the app adds, and it is added here because the tax engine lives here. Every offer is taxed as a full year of salary with no deductions claimed, on whichever regime is cheaper. Those assumptions are not hidden: they sit behind a disclosure next to the figure, and they live as constants beside the calculation so the caveat cannot drift from the maths. Only rupee offers are estimated — running a dollar salary through an Indian slab engine would produce a confident wrong number. Two display decisions carry weight. A missing guaranteed figure renders as "Not stated" rather than ₹0, because zero reads as "this job pays nothing". And the verdict's caveat sits inside the verdict card rather than below it — what the advice cannot know belongs beside what it claims, not where it reads as small print. Offers are picked in order and numbered as picked, so "Offer A" means the same thing here as it does server-side. An offer letter that was never interpreted says so on its tile, before the tap that would fail. A 503 on the answers call means the answers were saved and only the advice is missing, so the service reads the session back and the UI says exactly that. The candidate never retypes five answers because of an outage. Two repo-wide design gates caught this screen and both were right: seven spacing values were off the 4pt grid, and the take-home explanation was long enough that the copy budget wanted it behind a disclosure rather than always on screen. Both are fixed rather than excepted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the plan The route existed but nothing linked to it, so the feature was unreachable. It now sits in the profile list beside workday costs, which is where the other decision tools already are. The plan doc is updated to what was actually built rather than what was proposed: the shared metered Gemini call, the routes living in their own module, the take-home assumptions being app-side and disclosed, and the two environment values still needed before it can run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… spend maths The cap goes from $1.50 to $3, and both AI variables are now listed in .env.example so a deploy does not have to discover them from the config schema. The plan previously said $1.50 allowed "roughly ten document interpretations in total". That conflated two different numbers and made the cap look far tighter than it is. The ledger charges what a call actually cost — about $0.011 for a two-page offer letter — and separately refuses to start a call whose worst case would not fit in what remains, which for a byte-uploaded document is about $0.15 because a PDF is billed per page and its size is unknowable before it is sent. So the cap depletes at the actual rate and only stops issuing calls once the balance falls under one worst case. At $3 that is on the order of 250 interpretations, with the last $0.15 unusable by design. The plan now explains both numbers. A config test pins the default. A cap at or below one worst case would refuse every document interpretation starting from the first, so this is a value that must not drift down unnoticed. Several other claims in the plan predated the implementation and are corrected: at-risk share is of recurring pay rather than of CTC; take-home is computed in the app and explicitly not in the backend engine; the role question is promoted to first when guaranteed pay ties; the location question is never triggered by detection because offer letters do not reliably state the posting; the unverified-component question fires for anything that could not be counted, not only low confidence; and the advice schema has no field for naming a winner. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (34)
📝 WalkthroughWalkthroughAdds end-to-end multi-offer comparison. The change includes metered Gemini interpretation, encrypted comparison sessions, deterministic analysis and questions, guarded negotiation advice, authenticated APIs, and a Flutter flow for selection, answers, and results. ChangesOffer comparison feature
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant Candidate
participant OfferCompareScreen
participant OfferComparisonService
participant OfferComparisonRoutes
participant OfferAdvisor
Candidate->>OfferCompareScreen: select uploaded offer letters
OfferCompareScreen->>OfferComparisonService: startComparison(documentIds)
OfferComparisonService->>OfferComparisonRoutes: POST /offers/compare
OfferComparisonRoutes-->>OfferComparisonService: comparison and questions
OfferComparisonService-->>OfferCompareScreen: comparison session
Candidate->>OfferCompareScreen: submit answers
OfferCompareScreen->>OfferComparisonService: submitAnswers(id, answers)
OfferComparisonService->>OfferComparisonRoutes: POST answers
OfferComparisonRoutes->>OfferAdvisor: adviseOnOffers(comparison, answers)
OfferAdvisor-->>OfferComparisonRoutes: validated advice or unavailable result
OfferComparisonRoutes-->>OfferComparisonService: saved comparison
OfferComparisonService-->>OfferCompareScreen: verdict and negotiation guidance
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
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.
A candidate holding several job offers uploads each offer letter, answers at most
five questions, and gets back a verdict naming one offer with the figure that
decided it, plus a negotiation play.
Most of the machinery already existed.
interpretOfferLetter()already extractedemployer, role, CTC, the fixed/variable split, joining bonus and a per-component
breakdown;
tax_documentsis keyed on the file fingerprint, so several offerletters per user already coexisted;
offerLetterwas already an accepted documenttype. The gap was comparison, questions, verdict and negotiation, so that is what
this adds.
The two decisions that shape everything else
The engine decides which offer wins; the model only writes the words. Ranking
happens in pure code, and the advice response schema has no field for naming a
winner, so the model cannot substitute its own even if it disagrees. Every figure
is handed over pre-formatted, so restating one is quoting rather than calculating.
The one number the model may originate is the negotiation ask, because a target to
push for is a recommendation rather than a claim about the letters.
The five questions are selected deterministically, not invented. A model asked
to invent five questions produces a competent generic quiz. The engine normalizes
the offers first, finds where they tie or where the paper cannot answer, and asks
only the questions whose answer would change the ranking — worded with this
candidate's own numbers. Seven are eligible, five get asked. Questions that cannot
fire are not asked: no at-risk pay to lose, no money paid once to claw back,
nothing left unread. The set differs between candidates by construction. It also
costs nothing to run.
Refusals that are load-bearing
Each of these has a test, because each is a way to produce a confident wrong
number about someone's salary:
figure is monthly would turn one number into twelve.
zero. Zero would rank a silent letter last as though the job paid nothing, and
renders as "Not stated" on screen.
already spent, the other takes money away.
rate this product does not hold.
retargeted. A script written for one employer addressed to another is worse than
showing nothing.
Printed totals win over component sums, since the letter is the promise, but a
disagreement above 5% is reported rather than resolved. A gap under 2% in
guaranteed pay is called a tie, because ranking noise would dress it up as a
finding — and when pay ties, the role question is promoted to first, since at that
point it is not a tie-breaker, it is the decision.
The comparison also reports when the largest CTC is not the best offer on
guaranteed pay. That is the single most useful thing here: it is how a candidate
ends up choosing the worse offer while believing the numbers backed them up.
Two pre-existing defects fixed on the way in
Both got worse the moment the product invited five uploads instead of one.
interpretOfferLetter()called Gemini directly and never consultedaiSpendLedger— unmetered and uncapped, while every other paid path wasmetered.
FALLBACK_PRICEis deliberately themost expensive model known. Metering without adding prices would have refused
every upload rather than overspent. Gemini Flash is registered at its standard
tier, not the introductory rate that halves it until 31 Dec 2026 — a price that
rises on a date nobody is watching would start under-counting the cap silently.
The budget check is injected as a
SpendGuardrather than imported, and thatlooks like ceremony but is load-bearing:
geminiInterpreterandsarvamDocumentServicereadprocess.envdirectly and import no config, which isexactly why the document parser suite runs with no environment at all. The backend
CI job sets none, so importing the ledger — and through it
config.ts, whichrequires
DATABASE_URL— would have failed that suite at import time.Two smaller fixes fell out: the two interpreters were near-duplicate 90-line
copies of one call and now share a metered helper, and neither sent
maxOutputTokens, so the worst case the guard approved was not the worst case thecall could reach.
Spend
Two different numbers, and confusing them makes the cap look far tighter than it
is. The ledger charges actual usage — about $0.011 for a two-page offer letter
— and separately refuses to start a call whose worst case would not fit, which
for a byte-uploaded document is about $0.15 because a PDF is billed per page and
its size is unknowable before it is sent.
So the cap drains at the actual rate and only stops issuing calls once the balance
falls under one worst case. The default moves from $1.50 to $3, which is on the
order of 250 document interpretations, or ~70 three-offer sessions at roughly $0.04
each. A config test pins the default, since a cap at or below one worst case would
refuse every document starting from the first.
Storage
Session state is encrypted at rest. Not extra caution: extracted salary fields are
already encrypted inside
tax_documents.parse_summary, so a plaintext comparisoncolumn would have quietly undone that decision for the same numbers. Unlike
ai_spend_ledger, these tables do get row-level security — the ledger opts outbecause its cap is one global figure summed across users, whereas every column here
belongs to one candidate.
Which offer letters a session compares is a real foreign key, so deleting a letter
cascades the session away with it. A comparison that outlived one of its own inputs
would keep answering with a figure the user believes they deleted.
App
One screen at
/offers/compare, three stages, entry from the profile list besideWorkday costs. Take-home is the only figure the app computes, because the tax
engine lives here — duplicating slab logic in the backend would leave two engines
to keep in step. It is estimated on a deliberately bare basis (full year, no
deductions, cheaper regime, rupee offers only), with those assumptions behind a
disclosure beside the figure and held as constants next to the calculation so the
caveat cannot drift from the maths.
The verdict's caveat sits inside the verdict card rather than below it — what the
advice cannot know belongs beside what it claims, not where it reads as small
print.
A 503 on the answers call means the answers were saved and only advice is missing,
so the session is read back and the UI says exactly that. Nobody retypes five
answers because of an outage.
Two repo-wide design gates caught the new screen and both were right: seven spacing
values off the 4pt grid, and the take-home explanation past the copy budget. Both
fixed rather than excepted.
Verification
npm run check,npm run build,npm run scan:secretsall clean.dart formatclean,flutter analyze --no-fatal-infosclean.34 files, +4802 −185.
Before this can run in production
GEMINI_API_KEYset in the deployed backend. Without it the comparison and thequestions still work; only the verdict is missing, and the UI says so.
npm run migratefrombackend/to apply020_offer_comparisons.sqland itsCockroach counterpart.
Out of scope, deliberately
Company ratings, equity valuation, promotion or salary-growth forecasts, and any
claim about a company's culture or stability. ARTH compares what the letters say.
🤖 Generated with Claude Code
Summary by cubic
Compares multiple uploaded job offers, ranks them on guaranteed pay, then generates negotiation advice; document interpretation is now metered and budget‑gated to support multi‑upload sessions. Previously there was no comparison flow and parsing calls bypassed the spend ledger; now the engine decides the winner in code, selects up to five deterministic questions, and the advice step only writes the words.
Encrypted comparison sessions: new tables store normalized offers, selected questions, candidate answers, and cached advice with row‑level security; offer links are real foreign keys so deleting a letter deletes dependent sessions.
Backend routes: open a comparison, submit answers, and fetch advice; answers are saved even when advice is unavailable (returns 503 with session so users don’t retype).
Metered structured calls: shared Gemini JSON call enforces worst‑case budget checks, records usage (including truncated responses), and caps output via max tokens; prices registered for
gemini-3.6-flashat standard tier.Ranking and refusals that affect behavior: no annualization without stated frequency; reimbursements/deductions excluded; cross‑currency offers not ranked; unreadable letters yield null (not zero) guaranteed pay; printed totals beat component sums but >5% disagreements are surfaced; ≤2% guaranteed‑pay gaps are ties; flag when the highest CTC is not the best guaranteed pay.
App screen at /offers/compare: one flow with three stages; take‑home estimated client‑side under stated assumptions; preserves answers when advice is unavailable and explains why.
Rollout
Written for commit f4c949f. Summary will update on new commits.
Summary by CodeRabbit
New Features
Improvements