Files
PocketVeto/backend/app/api/alignment.py
Jack Levy 4c86a5b9ca feat: PocketVeto v1.0.0 — initial public release
Self-hosted US Congress monitoring platform with AI policy briefs,
bill/member/topic follows, ntfy + RSS + email notifications,
alignment scoring, collections, and draft-letter generator.

Authored by: Jack Levy
2026-03-15 01:35:01 -04:00

162 lines
5.1 KiB
Python

"""
Representation Alignment API.
Returns how well each followed member's voting record aligns with the
current user's bill stances (pocket_veto / pocket_boost).
"""
from collections import defaultdict
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_current_user
from app.database import get_db
from app.models import Follow, Member
from app.models.user import User
from app.models.vote import BillVote, MemberVotePosition
router = APIRouter()
@router.get("")
async def get_alignment(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Cross-reference the user's stanced bill follows with how their
followed members voted on those same bills.
pocket_boost + Yea → aligned
pocket_veto + Nay → aligned
All other combinations with an actual Yea/Nay vote → opposed
Not Voting / Present → excluded from tally
"""
# 1. Bill follows with a stance
bill_follows_result = await db.execute(
select(Follow).where(
Follow.user_id == current_user.id,
Follow.follow_type == "bill",
Follow.follow_mode.in_(["pocket_veto", "pocket_boost"]),
)
)
bill_follows = bill_follows_result.scalars().all()
if not bill_follows:
return {
"members": [],
"total_bills_with_stance": 0,
"total_bills_with_votes": 0,
}
stance_map = {f.follow_value: f.follow_mode for f in bill_follows}
# 2. Followed members
member_follows_result = await db.execute(
select(Follow).where(
Follow.user_id == current_user.id,
Follow.follow_type == "member",
)
)
member_follows = member_follows_result.scalars().all()
followed_member_ids = {f.follow_value for f in member_follows}
if not followed_member_ids:
return {
"members": [],
"total_bills_with_stance": len(stance_map),
"total_bills_with_votes": 0,
}
# 3. Bulk fetch votes for all stanced bills
bill_ids = list(stance_map.keys())
votes_result = await db.execute(
select(BillVote).where(BillVote.bill_id.in_(bill_ids))
)
votes = votes_result.scalars().all()
if not votes:
return {
"members": [],
"total_bills_with_stance": len(stance_map),
"total_bills_with_votes": 0,
}
vote_ids = [v.id for v in votes]
bill_id_by_vote = {v.id: v.bill_id for v in votes}
bills_with_votes = len({v.bill_id for v in votes})
# 4. Bulk fetch positions for followed members on those votes
positions_result = await db.execute(
select(MemberVotePosition).where(
MemberVotePosition.vote_id.in_(vote_ids),
MemberVotePosition.bioguide_id.in_(followed_member_ids),
)
)
positions = positions_result.scalars().all()
# 5. Aggregate per member
tally: dict[str, dict] = defaultdict(lambda: {"aligned": 0, "opposed": 0})
for pos in positions:
if pos.position not in ("Yea", "Nay"):
# Skip Not Voting / Present — not a real position signal
continue
bill_id = bill_id_by_vote.get(pos.vote_id)
if not bill_id:
continue
stance = stance_map.get(bill_id)
is_aligned = (
(stance == "pocket_boost" and pos.position == "Yea") or
(stance == "pocket_veto" and pos.position == "Nay")
)
if is_aligned:
tally[pos.bioguide_id]["aligned"] += 1
else:
tally[pos.bioguide_id]["opposed"] += 1
if not tally:
return {
"members": [],
"total_bills_with_stance": len(stance_map),
"total_bills_with_votes": bills_with_votes,
}
# 6. Load member details
member_ids = list(tally.keys())
members_result = await db.execute(
select(Member).where(Member.bioguide_id.in_(member_ids))
)
members = members_result.scalars().all()
member_map = {m.bioguide_id: m for m in members}
# 7. Build response
result = []
for bioguide_id, counts in tally.items():
m = member_map.get(bioguide_id)
aligned = counts["aligned"]
opposed = counts["opposed"]
total = aligned + opposed
result.append({
"bioguide_id": bioguide_id,
"name": m.name if m else bioguide_id,
"party": m.party if m else None,
"state": m.state if m else None,
"chamber": m.chamber if m else None,
"photo_url": m.photo_url if m else None,
"effectiveness_percentile": m.effectiveness_percentile if m else None,
"aligned": aligned,
"opposed": opposed,
"total": total,
"alignment_pct": round(aligned / total * 100, 1) if total > 0 else None,
})
result.sort(key=lambda x: (x["alignment_pct"] is None, -(x["alignment_pct"] or 0)))
return {
"members": result,
"total_bills_with_stance": len(stance_map),
"total_bills_with_votes": bills_with_votes,
}