Skip to content

feat(offers): compare multiple job offers, then plan the negotiation - #222

Merged
rish106-hub merged 10 commits into
mainfrom
feat/offers/multi-offer-compare
Aug 18, 2026
Merged

feat(offers): compare multiple job offers, then plan the negotiation#222
rish106-hub merged 10 commits into
mainfrom
feat/offers/multi-offer-compare

Conversation

@rish106-hub

@rish106-hub rish106-hub commented Aug 18, 2026

Copy link
Copy Markdown
Owner

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 extracted
employer, role, CTC, the fixed/variable split, joining bonus and a per-component
breakdown; tax_documents is keyed on the file fingerprint, so several offer
letters per user already coexisted; offerLetter was already an accepted document
type. 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:

  • An amount with no stated frequency is not annualized. Assuming an unlabelled
    figure is monthly would turn one number into twelve.
  • A letter nothing can be classified from reports null guaranteed pay, not
    zero. Zero would rank a silent letter last as though the job paid nothing, and
    renders as "Not stated" on screen.
  • Reimbursements and deductions are never counted as pay. One returns money
    already spent, the other takes money away.
  • Offers in different currencies are not ranked at all — that needs an exchange
    rate this product does not hold.
  • Advice naming an offer id outside the comparison is rejected rather than
    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.

  1. interpretOfferLetter() called Gemini directly and never consulted
    aiSpendLedger — unmetered and uncapped, while every other paid path was
    metered.
  2. The ledger priced no Gemini model, and FALLBACK_PRICE is deliberately the
    most 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 SpendGuard rather than imported, and that
looks like ceremony but is load-bearing: geminiInterpreter and
sarvamDocumentService read process.env directly and import no config, which is
exactly 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, which
requires 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 the
call 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 comparison
column would have quietly undone that decision for the same numbers. Unlike
ai_spend_ledger, these tables do get row-level security — the ledger opts out
because 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 beside
Workday 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

  • Backend: 147/147 tests, npm run check, npm run build, npm run scan:secrets all clean.
  • App: 424/424 tests, dart format clean, flutter analyze --no-fatal-infos clean.

34 files, +4802 −185.

Before this can run in production

  • GEMINI_API_KEY set in the deployed backend. Without it the comparison and the
    questions still work; only the verdict is missing, and the UI says so.
  • npm run migrate from backend/ to apply 020_offer_comparisons.sql and its
    Cockroach 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-flash at 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

    • Run database migrations for the new session tables.
    • Set GEMINI_API_KEY in the backend.
    • Ensure spend cap and per‑user daily quota env vars are set (defaults: $3 and 200).

Written for commit f4c949f. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added a multi-offer comparison tool accessible from Money tools.
    • Compare up to 10 uploaded offer letters with ranked compensation, take-home estimates, warnings, and currency notices.
    • Answer tailored follow-up questions to receive a verdict and negotiation guidance.
    • Added support for persisted comparison sessions and restarting comparisons.
  • Improvements

    • Added spending controls and usage tracking for AI-powered document analysis.
    • Improved compensation calculations, validation, and display formatting.
    • Added support for Gemini pricing and expanded the default AI spending limit.

rish106-hub and others added 10 commits August 17, 2026 22:54
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>
@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 94f7f1cd-e610-4cca-a1cf-b7c41eca3fea

📥 Commits

Reviewing files that changed from the base of the PR and between 3729054 and f4c949f.

📒 Files selected for processing (34)
  • backend/.env.example
  • backend/sql/020_offer_comparisons.sql
  • backend/sql/cockroach/005_offer_comparisons.sql
  • backend/src/aiSpendLedger.ts
  • backend/src/config.ts
  • backend/src/documentParser.ts
  • backend/src/documentSpendGuard.ts
  • backend/src/geminiInterpreter.ts
  • backend/src/geminiStructuredCall.ts
  • backend/src/offerAdvisor.ts
  • backend/src/offerComparisonEngine.ts
  • backend/src/offerComparisonRoutes.ts
  • backend/src/offerMoneyFormat.ts
  • backend/src/offerQuestionSelector.ts
  • backend/src/routes.ts
  • backend/src/spendCategorizer.ts
  • backend/src/tokenEstimate.ts
  • backend/test/aiSpendLedger.test.ts
  • backend/test/cockroachSchema.test.ts
  • backend/test/config.test.ts
  • backend/test/documentParser.test.ts
  • backend/test/offerAdvisor.test.ts
  • backend/test/offerComparisonEngine.test.ts
  • backend/test/offerQuestionSelector.test.ts
  • backend/test/security.test.ts
  • docs/offer-comparison-plan.md
  • lib/app.dart
  • lib/features/offer_compare/engine/offer_take_home_engine.dart
  • lib/features/offer_compare/models/offer_comparison_models.dart
  • lib/features/offer_compare/providers/offer_comparison_provider.dart
  • lib/features/offer_compare/screens/offer_compare_screen.dart
  • lib/features/offer_compare/services/offer_comparison_service.dart
  • lib/screens/s31_profile_screens.dart
  • test/offer_compare_test.dart

📝 Walkthrough

Walkthrough

Adds 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.

Changes

Offer comparison feature

Layer / File(s) Summary
Metered Gemini interpretation
backend/src/geminiStructuredCall.ts, backend/src/geminiInterpreter.ts, backend/src/documentSpendGuard.ts, backend/src/documentParser.ts, backend/src/config.ts, backend/src/aiSpendLedger.ts, backend/src/tokenEstimate.ts, backend/src/spendCategorizer.ts, backend/test/documentParser.test.ts, backend/test/offerAdvisor.test.ts, backend/test/config.test.ts, backend/test/aiSpendLedger.test.ts, backend/.env.example
Gemini structured calls now use shared token estimation, spend checks, bounded output, timeout handling, schema validation, and usage recording. Document interpretation receives a required spend guard.
Offer normalization and question selection
backend/src/offerComparisonEngine.ts, backend/src/offerQuestionSelector.ts, backend/src/offerMoneyFormat.ts, backend/test/offerComparisonEngine.test.ts, backend/test/offerQuestionSelector.test.ts
Offer data is normalized into compensation categories, ranked when comparable, and used to select up to five deterministic questions.
Comparison sessions and API routes
backend/sql/020_offer_comparisons.sql, backend/sql/cockroach/005_offer_comparisons.sql, backend/src/offerComparisonRoutes.ts, backend/src/routes.ts, backend/test/cockroachSchema.test.ts, backend/test/security.test.ts
Encrypted comparison sessions and linked documents are stored with tenant isolation, cascading relationships, validation constraints, and authenticated create, answer, and retrieval routes.
Guarded offer advice
backend/src/offerAdvisor.ts, backend/src/offerComparisonRoutes.ts, backend/test/offerAdvisor.test.ts
Advice generation validates structured verdict and negotiation output, checks target offer IDs, fingerprints answers and comparison inputs, and preserves answers when advice is unavailable.
Flutter comparison experience
lib/features/offer_compare/*, lib/app.dart, lib/screens/s31_profile_screens.dart, test/offer_compare_test.dart
The app adds comparison models, service calls, Riverpod flow state, take-home estimates, navigation, profile access, and three UI stages for offer selection, questions, and advice.

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
Loading
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/offers/multi-offer-compare

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@rish106-hub
rish106-hub merged commit eec2e8e into main Aug 18, 2026
6 of 7 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