Skip to content

story/9.6: Challenger promises view - #184

Merged
developer-ainative merged 2 commits into
mainfrom
story/9.6-challenger-promises-view
Oct 20, 2026
Merged

developer-ainative merged 2 commits into
mainfrom
story/9.6-challenger-promises-view

Conversation

@developer-ainative

Copy link
Copy Markdown
Contributor

Summary

Closes #62 (story 9.6, Epic 9, 3 pts).

Acceptance criteria: "Promises are grouped by my interests; a prior record in another office is linked if one exists."

Backend — GET /v1/candidates/{id}/promises?profile_id=

  • backend/app/promises/models.py — ChallengerPromises, PromiseGroup, PromiseItem, PriorOffice. No score, confidence badge or ranking anywhere in the shape (extra="forbid" throughout, same posture as app.scoring.models).
  • backend/app/promises/service.py — ChallengerPromisesService.promises_for:
    • Reads a person's promises rows directly (not candidate_positions, which a challenger has none of by definition — see the module docstring for why this is a separate endpoint rather than a widened /alignment).
    • Groups by the voter's own chosen stance_id (the same key AlignmentReason already groups by), loaded through the existing VoterProfileService.
    • A promise with no stance_id yet, or one the voter didn't choose, is counted in unmatched_count rather than dropped silently.
    • Each promise resolves to its promise_sources row for a real citation (url + capture date); a promise whose source has gone missing is omitted rather than shown uncited.
    • Prior office: every term on people.terms[] whose district isn't this candidacy's own seat — not a new backend concept, reusing the same data app.ballot.service._officeholders_from already reads for "who currently holds a seat". Never fabricated; empty when none exists.
  • backend/app/promises/router.py — thin FastAPI router, copying app.scoring.router's dependency-injection and 404-mapping pattern line for line.
  • Wired into app.main; tests/test_health_unit.py's route inventory updated.

Frontend — ChallengerPromisesView

  • frontend/src/api/promises.ts — typed client mirroring the backend models.
  • frontend/src/candidate/ChallengerPromisesView.tsx — one group per matching interest (same structure CandidateDetailPage uses for AlignmentReason[]), a plain "N more promises don't match your priorities" line (never hidden), a testable/not-testable label per promise, the existing Drawer (story 7.5) reused for each promise's source citation, and a plain prior-office notice — not a clickable link: there's no vote-based detail view for a prior office to point at, so an <a> would go nowhere real (same "never fabricate a link" rule CitationLink already holds).
  • Wired into BallotPage: "View details" now routes a challenger to this view and an incumbent to the existing CandidateDetailPage. BallotView's onViewDetails callback was widened to carry isIncumbent alongside the candidacy id (backward-compatible — it already had the value per row).

Honest state of live data

The live promises table is empty. Stories 9.4/9.5 (PR #182, just merged) ran the real promise extractor against all 6 live promise_sources rows and found it currently produces zero genuine promises — every candidate output today, including this view, will show its honest empty state ("no promises on file yet"). This view is built and tested against realistic fixture data (standard for Gherkin/BDD), not verified against populated live data, because there is no populated live data to verify against right now. No live/integration script was run or fabricated for this story.

Test plan

  • Gherkin first: backend/tests/features/challenger_promises.feature (12 scenarios) and frontend/tests/features/challenger_promises.feature (7 scenarios), committed before any production code.
  • Backend: pytest -q — 4038 passed, 3 pre-existing xfailed, 0 failed. ruff check . / ruff format --check . clean. Coverage on app/promises: 96% (bar is 90%).
  • Frontend: tsc --noEmit clean; vitest run — 223 passed; npm run test:bdd (Playwright + Cucumber) — 77 passed, 5 pre-existing pending, 0 failed.
  • Red-phase sanity check: deliberately broke the empty-state copy, rebuilt, reran the full BDD suite — exactly 1 scenario failed (the one testing that copy) — then restored it and reran to confirm 0 failures. Confirms the new steps genuinely exercise rendered output rather than passing vacuously.
  • No real voter data anywhere in fixtures/logs (synthetic ids throughout, per CLAUDE.md).

Judgment calls

  1. Grouping key is stance_id, not an arbitrary category. Matches AlignmentReason's own grouping exactly, so a voter who has seen the incumbent view recognizes the same structure.
  2. "Linked" (backlog.md's word) rendered as a plain notice, not a hyperlink. There's no separate "what this person did in their prior office" view to link to (that would be the vote-based record, which only exists for a sitting incumbent) — see the frontend feature file's own comment on this.
  3. Kept the vote-based /alignment endpoint untouched rather than widening it to include promises. A promise is unscored evidence; a position is a scored one. Folding them into one response risked a voter reading a promise as if it carried the same weight as a measured vote — see app.promises.models's module docstring.
  4. Wired into BallotPage rather than leaving the component unreachable from the live app, since every other per-candidate detail screen in this codebase (CandidateDetailPage, CompareView) is driven end-to-end through the real app shell in its own BDD suite, not rendered standalone.

Open questions for human review

  • Should a challenger who is also a sitting officeholder in a different seat (e.g., a state legislator running for Congress) eventually get both this promises view and a vote-based view of their current seat? Today they get only this one, routed purely off is_incumbent for this race.
  • unmatched_count has no drill-down (a voter can't see what the unmatched promises actually say). That felt like real scope creep for 3 points; flagging in case product wants it sooner.

AINative Admin added 2 commits October 20, 2026 04:51
…ts, link prior office

Gherkin first: backend/tests/features/challenger_promises.feature and its
step defs (12 scenarios, all red before app/promises/ existed). Adds
GET /v1/candidates/{id}/promises?profile_id=, the promise-side counterpart
to the vote-based /alignment endpoint for a candidate with no voting record:

- ChallengerPromisesService groups a person's `promises` rows under the
  voter's own chosen `stance_id`s (the same key AlignmentReason groups by),
  counting anything else as unmatched rather than dropping it.
- A prior office is read off the person's own `people.terms[]` — any term
  whose district is not this candidacy's own seat — never fabricated.
- Each promise resolves to its `promise_sources` row for a real citation
  (url + capture date); a promise whose source has gone missing is omitted
  rather than shown uncited.
- Never recommends, endorses or ranks; computed per request, nothing stored.

96% coverage on app/promises (90%+ bar); full suite green (4038 passed).
…prior office surfaced

Gherkin first: frontend/tests/features/challenger_promises.feature (7
scenarios) and its Playwright/Cucumber steps, stubbing
GET /v1/candidates/{id}/promises at the network boundary, same posture as
candidate_detail.feature. Confirmed red-phase sanity by deliberately
breaking the empty-state copy and re-running the suite (1 scenario failed
as expected, 0 elsewhere) before restoring it.

Adds:
- src/api/promises.ts — the typed client for the new endpoint.
- src/candidate/ChallengerPromisesView.tsx — one group per voter interest
  (mirrors CandidateDetailPage's stance_id grouping), a plain unmatched-count
  line rather than hiding promises outside the voter's interests, a
  testable/not-testable label per promise, a reused Drawer for each
  promise's source citation, and a plain prior-office notice (never a
  fabricated link — there is no vote-based record to link a challenger's
  prior office to).
- Wires into BallotPage: "View details" now routes a challenger to this
  view and an incumbent to the existing CandidateDetailPage, via a small,
  backward-compatible widening of BallotView's onViewDetails callback to
  also carry is_incumbent (it already has the value per row).

Full frontend pipeline green: tsc --noEmit, vitest (223 passed), and
Playwright+Cucumber (77 passed, 5 pre-existing pending, 0 failed).
@developer-ainative
developer-ainative merged commit ca1c489 into main Oct 20, 2026
6 checks passed
@developer-ainative
developer-ainative deleted the story/9.6-challenger-promises-view branch October 20, 2026 12:14
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.

9.6 Challenger promises view (3 pts)

1 participant