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
162 lines
5.1 KiB
Python
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,
|
|
}
|