1 Commits

Author SHA1 Message Date
Richie 7f2b388e7a setup workos 2026-05-29 19:00:06 -04:00
29 changed files with 1311 additions and 625 deletions
+16
View File
@@ -0,0 +1,16 @@
.git
.pytest_cache
.ruff_cache
__pycache__
*.pyc
*.pyo
*.pyd
.venv
venv
env
ENV
.env
dist
build
htmlcov
coverage.xml
+21
View File
@@ -0,0 +1,21 @@
# Postgres used by the FastAPI app
DATA_SCIENCE_DEV_DB=your_existing_database
DATA_SCIENCE_DEV_HOST=your_existing_postgres_host
DATA_SCIENCE_DEV_PORT=5432
DATA_SCIENCE_DEV_USER=your_existing_postgres_user
DATA_SCIENCE_DEV_PASSWORD=your_existing_postgres_password
# WorkOS AuthKit
WORKOS_API_KEY=sk_test_your_workos_api_key
WORKOS_CLIENT_ID=client_your_workos_client_id
WORKOS_COOKIE_PASSWORD=replace_with_a_long_random_secret_at_least_32_chars
WORKOS_ORGANIZATION_ID=org_your_workspace_org_id
WORKOS_REDIRECT_URI=http://localhost:8000/callback
WORKOS_LOGOUT_REDIRECT_URI=http://localhost:8000/
WORKOS_SESSION_COOKIE_NAME=workos_session
# Optional local port overrides for Docker Compose
WEB_PUBLISHED_PORT=8000
# Only used if you explicitly start the optional local Postgres profile
POSTGRES_PUBLISHED_PORT=5432
+25
View File
@@ -0,0 +1,25 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml /app/pyproject.toml
COPY __init__.py /app/__init__.py
COPY alembic /app/alembic
COPY database_cli.py /app/database_cli.py
COPY pipelines /app/pipelines
COPY docker /app/docker
RUN pip install --no-cache-dir .
RUN chmod +x /app/docker/web-entrypoint.sh
EXPOSE 8000
CMD ["/app/docker/web-entrypoint.sh"]
@@ -1,211 +0,0 @@
"""move bill text summaries into a child table.
Revision ID: 4b2e1c9d8f70
Revises: b9360b0b0c22
Create Date: 2026-05-03 00:00:00.000000
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import sqlalchemy as sa
from alembic import op
from pipelines.orm import DataScienceDevBase
if TYPE_CHECKING:
from collections.abc import Sequence
# revision identifiers, used by Alembic.
revision: str = "4b2e1c9d8f70"
down_revision: str | None = "b9360b0b0c22"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
schema = DataScienceDevBase.schema_name
def upgrade() -> None:
"""Upgrade."""
op.create_table(
"bill_text_summary",
sa.Column("bill_text_id", sa.Integer(), nullable=False),
sa.Column("summary", sa.String(), nullable=False),
sa.Column("summarization_model", sa.String(), nullable=True),
sa.Column("summarization_user_prompt_version", sa.String(), nullable=True),
sa.Column("summarization_system_prompt_version", sa.String(), nullable=True),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column(
"created",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["bill_text_id"],
[f"{schema}.bill_text.id"],
name=op.f("fk_bill_text_summary_bill_text_id_bill_text"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_bill_text_summary")),
schema=schema,
)
op.create_index(
"ix_bill_text_summary_bill_text_id",
"bill_text_summary",
["bill_text_id"],
unique=False,
schema=schema,
)
op.create_index(
"ix_bill_text_summary_bill_text_id_created",
"bill_text_summary",
["bill_text_id", "created"],
unique=False,
schema=schema,
)
op.add_column(
"bill_text",
sa.Column("primary_summary_id", sa.Integer(), nullable=True),
schema=schema,
)
op.create_foreign_key(
op.f("fk_bill_text_primary_summary_id_bill_text_summary"),
"bill_text",
"bill_text_summary",
["primary_summary_id"],
["id"],
source_schema=schema,
referent_schema=schema,
ondelete="SET NULL",
)
op.execute(
sa.text(
f"""
INSERT INTO {schema}.bill_text_summary (
bill_text_id,
summary,
summarization_model,
summarization_user_prompt_version,
summarization_system_prompt_version,
created,
updated
)
SELECT
bill_text.id,
bill_text.summary,
bill_text.summarization_model,
bill_text.summarization_user_prompt_version,
bill_text.summarization_system_prompt_version,
COALESCE(bill_text.updated, bill_text.created, now()),
COALESCE(bill_text.updated, bill_text.created, now())
FROM {schema}.bill_text
WHERE bill_text.summary IS NOT NULL
AND btrim(bill_text.summary) <> ''
"""
)
)
op.drop_column("bill_text", "summary", schema=schema)
op.drop_column("bill_text", "summarization_model", schema=schema)
op.drop_column("bill_text", "summarization_user_prompt_version", schema=schema)
op.drop_column("bill_text", "summarization_system_prompt_version", schema=schema)
def downgrade() -> None:
"""Downgrade."""
op.add_column(
"bill_text",
sa.Column("summarization_system_prompt_version", sa.String(), nullable=True),
schema=schema,
)
op.add_column(
"bill_text",
sa.Column("summarization_user_prompt_version", sa.String(), nullable=True),
schema=schema,
)
op.add_column(
"bill_text",
sa.Column("summarization_model", sa.String(), nullable=True),
schema=schema,
)
op.add_column(
"bill_text",
sa.Column("summary", sa.String(), nullable=True),
schema=schema,
)
op.execute(
sa.text(
f"""
WITH ranked AS (
SELECT
bts.*,
row_number() OVER (
PARTITION BY bts.bill_text_id
ORDER BY bts.created DESC, bts.id DESC
) AS rn
FROM {schema}.bill_text_summary AS bts
),
chosen AS (
SELECT
bill_text.id AS bill_text_id,
COALESCE(ps.summary, ls.summary) AS summary,
COALESCE(
ps.summarization_model,
ls.summarization_model
) AS summarization_model,
COALESCE(
ps.summarization_user_prompt_version,
ls.summarization_user_prompt_version
) AS summarization_user_prompt_version,
COALESCE(
ps.summarization_system_prompt_version,
ls.summarization_system_prompt_version
) AS summarization_system_prompt_version
FROM {schema}.bill_text
LEFT JOIN {schema}.bill_text_summary AS ps
ON ps.id = bill_text.primary_summary_id
LEFT JOIN ranked AS ls
ON ls.bill_text_id = bill_text.id
AND ls.rn = 1
)
UPDATE {schema}.bill_text
SET
summary = chosen.summary,
summarization_model = chosen.summarization_model,
summarization_user_prompt_version = chosen.summarization_user_prompt_version,
summarization_system_prompt_version = chosen.summarization_system_prompt_version
FROM chosen
WHERE chosen.bill_text_id = bill_text.id
"""
)
)
op.drop_constraint(
op.f("fk_bill_text_primary_summary_id_bill_text_summary"),
"bill_text",
schema=schema,
type_="foreignkey",
)
op.drop_column("bill_text", "primary_summary_id", schema=schema)
op.drop_index(
"ix_bill_text_summary_bill_text_id_created",
table_name="bill_text_summary",
schema=schema,
)
op.drop_index(
"ix_bill_text_summary_bill_text_id",
table_name="bill_text_summary",
schema=schema,
)
op.drop_table("bill_text_summary", schema=schema)
+52
View File
@@ -0,0 +1,52 @@
services:
db:
image: postgres:16
profiles: ["localdb"]
restart: unless-stopped
environment:
POSTGRES_DB: ${DATA_SCIENCE_DEV_DB:-nornsight}
POSTGRES_USER: ${DATA_SCIENCE_DEV_USER:-nornsight}
POSTGRES_PASSWORD: ${DATA_SCIENCE_DEV_PASSWORD:-nornsight}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${DATA_SCIENCE_DEV_USER:-nornsight} -d ${DATA_SCIENCE_DEV_DB:-nornsight}",
]
interval: 5s
timeout: 5s
retries: 20
start_period: 5s
ports:
- "${POSTGRES_PUBLISHED_PORT:-5432}:5432"
web:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
dns:
- ${WEB_DNS_1:-1.1.1.1}
- ${WEB_DNS_2:-8.8.8.8}
environment:
DATA_SCIENCE_DEV_DB: ${DATA_SCIENCE_DEV_DB}
DATA_SCIENCE_DEV_HOST: ${DATA_SCIENCE_DEV_HOST}
DATA_SCIENCE_DEV_PORT: ${DATA_SCIENCE_DEV_PORT}
DATA_SCIENCE_DEV_USER: ${DATA_SCIENCE_DEV_USER}
DATA_SCIENCE_DEV_PASSWORD: ${DATA_SCIENCE_DEV_PASSWORD}
WORKOS_API_KEY: ${WORKOS_API_KEY}
WORKOS_CLIENT_ID: ${WORKOS_CLIENT_ID}
WORKOS_COOKIE_PASSWORD: ${WORKOS_COOKIE_PASSWORD}
WORKOS_ORGANIZATION_ID: ${WORKOS_ORGANIZATION_ID}
WORKOS_REDIRECT_URI: ${WORKOS_REDIRECT_URI:-http://localhost:8000/callback}
WORKOS_LOGOUT_REDIRECT_URI: ${WORKOS_LOGOUT_REDIRECT_URI:-http://localhost:8000/}
WORKOS_SESSION_COOKIE_NAME: ${WORKOS_SESSION_COOKIE_NAME:-workos_session}
UVICORN_HOST: 0.0.0.0
UVICORN_PORT: 8000
ports:
- "${WEB_PUBLISHED_PORT:-8000}:8000"
volumes:
postgres_data:
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env sh
set -eu
python - <<'PY'
import os
import time
import psycopg
db = os.environ["DATA_SCIENCE_DEV_DB"]
host = os.environ["DATA_SCIENCE_DEV_HOST"]
port = os.environ["DATA_SCIENCE_DEV_PORT"]
user = os.environ["DATA_SCIENCE_DEV_USER"]
password = os.environ.get("DATA_SCIENCE_DEV_PASSWORD", "")
dsn = f"dbname={db} host={host} port={port} user={user} password={password}"
for attempt in range(60):
try:
with psycopg.connect(dsn) as conn:
with conn.cursor() as cur:
cur.execute("CREATE SCHEMA IF NOT EXISTS main")
conn.commit()
break
except psycopg.OperationalError:
if attempt == 59:
raise
time.sleep(1)
PY
python /app/database_cli.py data_science_dev upgrade head
exec uvicorn pipelines.web.main:app --host "${UVICORN_HOST:-0.0.0.0}" --port "${UVICORN_PORT:-8000}"
-21
View File
@@ -5,10 +5,8 @@ from __future__ import annotations
import logging
import sys
from datetime import UTC, datetime
from os import getenv
from subprocess import PIPE, Popen
from apprise import Apprise
logger = logging.getLogger(__name__)
@@ -47,25 +45,6 @@ def bash_wrapper(command: str) -> tuple[str, int]:
return output.decode(), process.returncode
def signal_alert(body: str, title: str = "") -> None:
"""Send a signal alert.
Args:
body (str): The body of the alert.
title (str, optional): The title of the alert. Defaults to "".
"""
apprise_client = Apprise()
from_phone = getenv("SIGNAL_ALERT_FROM_PHONE")
to_phone = getenv("SIGNAL_ALERT_TO_PHONE")
if not from_phone or not to_phone:
logger.info("SIGNAL_ALERT_FROM_PHONE or SIGNAL_ALERT_TO_PHONE not set")
return
apprise_client.add(f"signal://localhost:8989/{from_phone}/{to_phone}")
apprise_client.notify(title=title, body=body)
def utcnow() -> datetime:
"""Get the current UTC time."""
+197
View File
@@ -0,0 +1,197 @@
"""Docker container lifecycle management for the web app stack."""
from __future__ import annotations
import logging
import os
import subprocess
from pathlib import Path
from typing import Annotated, Literal
import typer
logger = logging.getLogger(__name__)
REPO_DIR = Path(__file__).resolve().parents[2]
COMPOSE_FILE = REPO_DIR / "docker-compose.yml"
EnvTarget = Literal["all", "web", "db"]
REQUIRED_WORKOS_ENV_VARS = (
"WORKOS_API_KEY",
"WORKOS_CLIENT_ID",
"WORKOS_COOKIE_PASSWORD",
"WORKOS_ORGANIZATION_ID",
)
app = typer.Typer(help="Web stack container management.")
def _compose_command(*args: str) -> list[str]:
"""Build a docker compose command for the repo-local stack."""
return ["docker", "compose", "-f", str(COMPOSE_FILE), *args]
def _run_compose(
*args: str,
capture_output: bool = False,
check: bool = True,
) -> subprocess.CompletedProcess[str]:
"""Run docker compose in the repository root."""
result = subprocess.run(
_compose_command(*args),
cwd=REPO_DIR,
text=True,
capture_output=capture_output,
check=False,
)
if check and result.returncode != 0:
detail = result.stderr.strip() if result.stderr else f"exit code {result.returncode}"
raise RuntimeError(f"docker compose {' '.join(args)} failed: {detail}")
return result
def _validate_workos_env() -> None:
"""Ensure the web app has the WorkOS env vars it needs before startup."""
missing = [name for name in REQUIRED_WORKOS_ENV_VARS if not os.getenv(name)]
if missing:
message = (
"Missing required WorkOS environment variables: "
+ ", ".join(missing)
+ ". Populate .env before running the web stack."
)
raise RuntimeError(message)
cookie_password = os.getenv("WORKOS_COOKIE_PASSWORD", "")
if len(cookie_password) < 32:
raise RuntimeError("WORKOS_COOKIE_PASSWORD must be at least 32 characters long.")
def build_stack() -> None:
"""Build the web app image."""
logger.info("Building web image from %s", COMPOSE_FILE)
_run_compose("build", "web", capture_output=False)
logger.info("Web image built")
def _validate_database_env() -> None:
"""Ensure the web app has the database env vars it needs before startup."""
required = (
"DATA_SCIENCE_DEV_DB",
"DATA_SCIENCE_DEV_HOST",
"DATA_SCIENCE_DEV_PORT",
"DATA_SCIENCE_DEV_USER",
)
missing = [name for name in required if not os.getenv(name)]
if missing:
message = (
"Missing required database environment variables: "
+ ", ".join(missing)
+ ". Populate .env before running the web stack."
)
raise RuntimeError(message)
def start_stack(
*, build: bool = False, detach: bool = False, with_local_db: bool = False
) -> None:
"""Start the web stack, using the existing DB by default."""
_validate_workos_env()
_validate_database_env()
command = ["up"]
if build:
command.append("--build")
if detach:
command.append("-d")
if with_local_db:
command.extend(["--profile", "localdb", "db", "web"])
else:
command.append("web")
logger.info(
"Starting web stack%s",
" with local Postgres" if with_local_db else " against existing Postgres",
)
_run_compose(*command, capture_output=False)
def stop_stack(*, drop_volumes: bool = False) -> None:
"""Stop and remove the web stack."""
logger.info("Stopping web stack")
command = ["down"]
if drop_volumes:
command.append("--volumes")
_run_compose(*command, capture_output=False)
def logs_stack(*, target: EnvTarget = "all", follow: bool = False, tail: int = 100) -> None:
"""Show docker compose logs for the web stack."""
command = ["logs", "--tail", str(tail)]
if follow:
command.append("--follow")
if target != "all":
command.append(target)
_run_compose(*command, capture_output=False)
@app.command()
def build(
log_level: Annotated[str, typer.Option(help="Log level")] = "INFO",
) -> None:
"""Build the web Docker image."""
logging.basicConfig(
level=log_level,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
build_stack()
@app.command()
def run(
build: Annotated[
bool, typer.Option(help="Rebuild the web image before starting the stack")
] = False,
detach: Annotated[
bool, typer.Option(help="Start the stack in the background")
] = False,
with_local_db: Annotated[
bool, typer.Option(help="Also start the optional local Postgres container")
] = False,
log_level: Annotated[str, typer.Option(help="Log level")] = "INFO",
) -> None:
"""Run the web + Postgres stack."""
logging.basicConfig(
level=log_level,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
start_stack(build=build, detach=detach, with_local_db=with_local_db)
@app.command()
def stop(
drop_volumes: Annotated[
bool, typer.Option(help="Also delete the Postgres volume")
] = False,
) -> None:
"""Stop and remove the web stack."""
stop_stack(drop_volumes=drop_volumes)
@app.command()
def logs(
target: Annotated[
EnvTarget, typer.Option(help="Which service logs to show")
] = "all",
follow: Annotated[
bool, typer.Option(help="Follow logs until interrupted")
] = False,
tail: Annotated[int, typer.Option(help="How many recent lines to show")] = 100,
) -> None:
"""Show recent logs from the web stack."""
logs_stack(target=target, follow=follow, tail=tail)
def cli() -> None:
"""Typer entry point."""
app()
if __name__ == "__main__":
cli()
+12 -26
View File
@@ -19,7 +19,6 @@ from pipelines.orm.common import get_postgres_engine
from pipelines.orm.data_science_dev.congress import (
Bill,
BillText,
BillTextSummary,
BillTopic,
BillTopicPosition,
SubjectType,
@@ -73,19 +72,11 @@ class ExtractedBillTopic:
def _select_bill_text_for_topic_extraction(bill: Bill) -> BillText | None:
"""Pick one summarized bill_text row from the already-loaded relationship."""
for bill_text in bill.bill_texts:
summary_row = bill_text.default_summary()
if summary_row and summary_row.summary.strip():
if bill_text.summary and bill_text.summary.strip():
return bill_text
return None
def _bill_text_has_summary_clause() -> ColumnElement[bool]:
"""Return a correlated EXISTS clause for bill texts with at least one summary."""
return exists(
select(BillTextSummary.id).where(BillTextSummary.bill_text_id == BillText.id)
)
def normalize_topic_label(value: str) -> str:
"""Normalize a topic label for storage, comparison, and de-duping."""
normalized = value.strip().strip("\"'")
@@ -332,7 +323,11 @@ def create_select_bills_for_topic_extraction(
limit: int | None = None,
) -> Select[tuple[Bill]]:
"""Select bill rows that have summarized bill_text rows for topic extraction."""
summarized_text_filters: list[ColumnElement[bool]] = [_bill_text_has_summary_clause()]
has_summary = (BillText.summary.is_not(None), BillText.summary != "")
summarized_text_filters: list[ColumnElement[bool]] = [
BillText.bill_id == Bill.id,
*has_summary,
]
if with_votes_only:
summarized_text_filters.append(
exists(
@@ -352,17 +347,11 @@ def create_select_bills_for_topic_extraction(
)
)
)
summarized_text_exists = exists(
select(BillText.id).where(BillText.bill_id == Bill.id, *summarized_text_filters)
)
bill_text_loader = selectinload(Bill.bill_texts.and_(*summarized_text_filters))
summarized_text_exists = exists(select(BillText.id).where(*summarized_text_filters))
stmt = (
select(Bill)
.where(summarized_text_exists)
.options(
bill_text_loader.selectinload(BillText.summaries),
bill_text_loader.selectinload(BillText.primary_summary),
)
.options(selectinload(Bill.bill_texts.and_(*summarized_text_filters[1:])))
.order_by(Bill.id)
)
if congress is not None:
@@ -374,7 +363,7 @@ def create_select_bills_for_topic_extraction(
select(BillText.id).where(
BillText.bill_id == Bill.id,
BillText.id.in_(bill_text_ids),
*summarized_text_filters,
*summarized_text_filters[1:],
)
)
stmt = stmt.where(selected_text_exists)
@@ -427,7 +416,8 @@ def collect_topic_extraction_diagnostics(
)
)
summary_filters = [*bill_text_filters, _bill_text_has_summary_clause()]
has_summary = (BillText.summary.is_not(None), BillText.summary != "")
summary_filters = [*bill_text_filters, *has_summary]
bills_with_summaries = session.scalar(
select(func.count(func.distinct(Bill.id)))
@@ -617,11 +607,7 @@ def main(
if bill_text is None:
logger.warning("Skipping bill id=%s: no usable summary", bill.id)
continue
summary_row = bill_text.default_summary()
if summary_row is None:
logger.warning("Skipping bill id=%s: no default summary", bill.id)
continue
summary = summary_row.summary.strip()
summary = bill_text.summary.strip()
try:
extracted_topics = extract_topics_for_bill_text(
+9 -23
View File
@@ -9,7 +9,7 @@ from typing import Annotated, Any
import httpx
import typer
from sqlalchemy import Select, exists, select
from sqlalchemy import Select, exists, or_, select
from sqlalchemy.orm import Session, selectinload
from tiktoken import get_encoding
@@ -20,7 +20,6 @@ from pipelines.orm.common import get_postgres_engine
from pipelines.orm.data_science_dev.congress import (
Bill,
BillText,
BillTextSummary,
SubjectType,
VoteClassification,
VoteRelationship,
@@ -113,7 +112,7 @@ def summarize_bill_text(
model: str,
bill_text: BillText,
summarization_prompts: dict[str, str],
) -> str | None:
) -> str:
"""Generate and return a summary for one bill_text row."""
messages, user_prompt_tokens = build_bill_summary_messages(
bill_text=bill_text,
@@ -137,21 +136,15 @@ def summarize_bill_text(
def store_bill_summary_result(
*,
session: Session,
bill_text: BillText,
summary: str,
model: str,
) -> BillTextSummary:
) -> None:
"""Store a generated summary and the prompt/model metadata that produced it."""
summary_row = BillTextSummary(
bill_text=bill_text,
summary=summary,
summarization_model=model,
summarization_system_prompt_version="v1.2",
summarization_user_prompt_version="v1",
)
session.add(summary_row)
return summary_row
bill_text.summary = summary
bill_text.summarization_model = model
bill_text.summarization_system_prompt_version = "v1.2"
bill_text.summarization_user_prompt_version = "v1"
def create_select_bill_texts_for_summarization(
@@ -161,7 +154,7 @@ def create_select_bill_texts_for_summarization(
with_votes_only: bool = False,
force: bool = False,
limit: int | None = None,
) -> Select[tuple[BillText]]:
) -> Select:
"""Select bill_text rows that have source text and need summaries."""
stmt = (
select(BillText)
@@ -196,13 +189,7 @@ def create_select_bill_texts_for_summarization(
)
)
if not force:
stmt = stmt.where(
~exists(
select(BillTextSummary.id).where(
BillTextSummary.bill_text_id == BillText.id
)
)
)
stmt = stmt.where(or_(BillText.summary.is_(None), BillText.summary == ""))
if limit is not None:
stmt = stmt.limit(limit)
return stmt
@@ -300,7 +287,6 @@ def main(
logger.warning("Skipping bill_text id=%s", bill_text.id)
continue
store_bill_summary_result(
session=session,
bill_text=bill_text,
summary=summary,
model=model,
@@ -6,7 +6,6 @@ from pipelines.orm.data_science_dev.congress.bill import (
BillActionRecordedVote,
BillRelation,
BillText,
BillTextSummary,
BillTopic,
BillTopicPosition,
)
@@ -55,7 +54,6 @@ __all__ = [
"BillActionRecordedVote",
"BillRelation",
"BillText",
"BillTextSummary",
"BillTopic",
"BillTopicPosition",
"ClassificationMethod",
@@ -105,12 +105,13 @@ class BillText(DataScienceDevTableBase):
)
bill_id: Mapped[int] = mapped_column(ForeignKey("main.bill.id", ondelete="CASCADE"))
primary_summary_id: Mapped[int | None] = mapped_column(
ForeignKey("main.bill_text_summary.id", ondelete="SET NULL")
)
version_code: Mapped[str]
version_name: Mapped[str | None]
text_content: Mapped[str | None]
summary: Mapped[str | None]
summarization_model: Mapped[str | None]
summarization_user_prompt_version: Mapped[str | None]
summarization_system_prompt_version: Mapped[str | None]
date: Mapped[date | None]
source_datetime_raw: Mapped[str | None]
text_url_xml: Mapped[str | None]
@@ -121,57 +122,6 @@ class BillText(DataScienceDevTableBase):
)
bill: Mapped[Bill] = relationship("Bill", back_populates="bill_texts")
summaries: Mapped[list[BillTextSummary]] = relationship(
"BillTextSummary",
back_populates="bill_text",
cascade="all, delete-orphan",
foreign_keys="BillTextSummary.bill_text_id",
order_by=lambda: (
BillTextSummary.created.desc(),
BillTextSummary.id.desc(),
),
)
primary_summary: Mapped[BillTextSummary | None] = relationship(
"BillTextSummary",
foreign_keys=[primary_summary_id],
post_update=True,
)
def latest_summary(self) -> BillTextSummary | None:
"""Return the newest summary row for this bill text."""
return self.summaries[0] if self.summaries else None
def default_summary(self) -> BillTextSummary | None:
"""Return the primary summary when set, otherwise the newest summary."""
return self.primary_summary or self.latest_summary()
class BillTextSummary(DataScienceDevTableBase):
"""Stores one generated summary for a bill text version."""
__tablename__ = "bill_text_summary"
__table_args__ = (
Index("ix_bill_text_summary_bill_text_id", "bill_text_id"),
Index(
"ix_bill_text_summary_bill_text_id_created",
"bill_text_id",
"created",
),
)
bill_text_id: Mapped[int] = mapped_column(
ForeignKey("main.bill_text.id", ondelete="CASCADE")
)
summary: Mapped[str]
summarization_model: Mapped[str | None]
summarization_user_prompt_version: Mapped[str | None]
summarization_system_prompt_version: Mapped[str | None]
bill_text: Mapped[BillText] = relationship(
"BillText",
back_populates="summaries",
foreign_keys=[bill_text_id],
)
class BillAction(DataScienceDevTableBase):
-2
View File
@@ -11,7 +11,6 @@ from pipelines.orm.data_science_dev.congress import (
BillActionRecordedVote,
BillRelation,
BillText,
BillTextSummary,
BillTopic,
BillTopicPosition,
ClassificationMethod,
@@ -52,7 +51,6 @@ __all__ = [
"BillActionRecordedVote",
"BillRelation",
"BillText",
"BillTextSummary",
"BillTopic",
"BillTopicPosition",
"ClassificationMethod",
+202
View File
@@ -0,0 +1,202 @@
"""WorkOS AuthKit helpers for the FastAPI web app."""
from __future__ import annotations
from dataclasses import dataclass
from functools import lru_cache
from os import getenv
from typing import Any
from fastapi import Request
from workos import WorkOSClient
from workos.session import seal_session_from_auth_response
@dataclass(frozen=True)
class AuthConfig:
"""Runtime configuration for WorkOS AuthKit."""
api_key: str
client_id: str
cookie_password: str
redirect_uri: str
logout_redirect_uri: str
session_cookie_name: str
organization_id: str
@property
def secure_cookies(self) -> bool:
return self.redirect_uri.startswith("https://")
@dataclass(frozen=True)
class AuthSession:
"""Normalized auth session passed through the app."""
user_id: str
email: str
first_name: str | None
last_name: str | None
role_slugs: set[str]
organization_id: str | None
raw_user: Any
raw_session: Any
@property
def display_name(self) -> str:
parts = [part for part in (self.first_name, self.last_name) if part]
return " ".join(parts) if parts else self.email
@property
def is_admin(self) -> bool:
return "admin" in self.role_slugs
@dataclass(frozen=True)
class CallbackResult:
"""Result of exchanging a WorkOS callback code."""
sealed_session: str
next_path: str
def safe_next_path(value: str | None, default: str = "/dashboard") -> str:
"""Allow only local relative redirect targets."""
if value and value.startswith("/") and not value.startswith("//"):
return value
return default
def build_authorization_url(next_path: str) -> str:
"""Build the WorkOS hosted login URL."""
config = get_auth_config()
return get_workos_client().user_management.get_authorization_url(
provider="authkit",
redirect_uri=config.redirect_uri,
state=safe_next_path(next_path),
organization_id=config.organization_id,
)
def exchange_code(request: Request) -> CallbackResult:
"""Exchange a WorkOS callback code for a sealed session cookie value."""
code = request.query_params.get("code")
if not code:
raise ValueError("Missing authentication code.")
config = get_auth_config()
auth_response = get_workos_client().user_management.authenticate_with_code(
code=code,
ip_address=_request_ip(request),
user_agent=request.headers.get("user-agent"),
)
sealed_session = seal_session_from_auth_response(
access_token=auth_response.access_token,
refresh_token=auth_response.refresh_token,
user=auth_response.user.to_dict(),
impersonator=auth_response.impersonator.to_dict()
if auth_response.impersonator is not None
else None,
cookie_password=config.cookie_password,
)
return CallbackResult(
sealed_session=sealed_session,
next_path=safe_next_path(request.query_params.get("state")),
)
def get_current_session(request: Request) -> AuthSession | None:
"""Load the current signed-in WorkOS session from the sealed cookie."""
cookie_name = getenv("WORKOS_SESSION_COOKIE_NAME", "workos_session")
sealed_session = request.cookies.get(cookie_name)
if not sealed_session:
return None
config = get_auth_config()
session = get_workos_client().user_management.load_sealed_session(
session_data=sealed_session,
cookie_password=config.cookie_password,
)
auth_response = session.authenticate()
if not getattr(auth_response, "authenticated", False):
return None
user = auth_response.user or {}
organization_id = getattr(auth_response, "organization_id", None)
if config.organization_id and organization_id != config.organization_id:
return None
role_slugs = set(getattr(auth_response, "roles", None) or [])
role = getattr(auth_response, "role", None)
if role:
role_slugs.add(role)
return AuthSession(
user_id=_user_field(user, "id") or "",
email=_user_field(user, "email") or "",
first_name=_user_field(user, "first_name"),
last_name=_user_field(user, "last_name"),
role_slugs=role_slugs,
organization_id=organization_id,
raw_user=user,
raw_session=auth_response,
)
def get_logout_url(request: Request) -> str:
"""Return the WorkOS logout URL for the current sealed session."""
config = get_auth_config()
sealed_session = request.cookies.get(config.session_cookie_name)
if not sealed_session:
return config.logout_redirect_uri
session = get_workos_client().user_management.load_sealed_session(
session_data=sealed_session,
cookie_password=config.cookie_password,
)
return session.get_logout_url(return_to=config.logout_redirect_uri)
@lru_cache(maxsize=1)
def get_auth_config() -> AuthConfig:
"""Load and validate WorkOS environment configuration."""
values = {
"WORKOS_API_KEY": getenv("WORKOS_API_KEY"),
"WORKOS_CLIENT_ID": getenv("WORKOS_CLIENT_ID"),
"WORKOS_COOKIE_PASSWORD": getenv("WORKOS_COOKIE_PASSWORD"),
"WORKOS_ORGANIZATION_ID": getenv("WORKOS_ORGANIZATION_ID"),
}
missing = [name for name, value in values.items() if not value]
if missing:
raise RuntimeError(
"Missing WorkOS configuration: " + ", ".join(sorted(missing))
)
return AuthConfig(
api_key=values["WORKOS_API_KEY"] or "",
client_id=values["WORKOS_CLIENT_ID"] or "",
cookie_password=values["WORKOS_COOKIE_PASSWORD"] or "",
redirect_uri=getenv("WORKOS_REDIRECT_URI", "http://localhost:8000/callback"),
logout_redirect_uri=getenv("WORKOS_LOGOUT_REDIRECT_URI", "http://localhost:8000/"),
session_cookie_name=getenv("WORKOS_SESSION_COOKIE_NAME", "workos_session"),
organization_id=values["WORKOS_ORGANIZATION_ID"] or "",
)
@lru_cache(maxsize=1)
def get_workos_client():
"""Create and cache the WorkOS SDK client."""
config = get_auth_config()
return WorkOSClient(api_key=config.api_key, client_id=config.client_id)
def _request_ip(request: Request) -> str | None:
if request.client is None:
return None
return request.client.host
def _user_field(user: Any, key: str) -> Any:
if isinstance(user, dict):
return user.get(key)
return getattr(user, key, None)
+122 -102
View File
@@ -4,20 +4,17 @@ from __future__ import annotations
from contextlib import asynccontextmanager
from dataclasses import dataclass
import hashlib
import hmac
import logging
from os import getenv
from pathlib import Path
import secrets
from typing import Any
from urllib.parse import parse_qs
from fastapi import Depends, FastAPI, HTTPException, Request, Response, status
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pipelines.web import repository
from pipelines.web import auth, repository
from pipelines.web.db import session_scope, validate_database_connection
from pipelines.web.repository import Chamber, RankingResult
from pipelines.web.scoring import normalize_issues
@@ -28,10 +25,7 @@ TEMPLATES_DIR = BASE_DIR / "templates"
STATIC_DIR = BASE_DIR / "static"
templates = Jinja2Templates(directory=TEMPLATES_DIR)
ADMIN_USERNAME = "admin"
ADMIN_PASSWORD = "admin"
SESSION_COOKIE = "nornsight_admin"
SESSION_SECRET = "nornsight-local-dev-session-secret"
logger = logging.getLogger(__name__)
@asynccontextmanager
@@ -62,72 +56,69 @@ def healthz() -> str:
return "ok"
@app.get("/login", response_class=HTMLResponse)
def login(request: Request) -> Response:
"""Render the integrated login page."""
next_path = _safe_next_path(request.query_params.get("next"))
if _authenticated_user(request) is not None:
return RedirectResponse(next_path, status_code=status.HTTP_303_SEE_OTHER)
@app.get("/", response_class=HTMLResponse)
def home(request: Request) -> Response:
"""Render the public home page."""
current_user = auth.get_current_session(request)
return templates.TemplateResponse(
request,
"login.html",
"home.html",
{
"error": "",
"is_authenticated": False,
"show_primary_nav": False,
"next_path": next_path,
"username": "",
**_auth_context(current_user),
"auth_error": request.query_params.get("auth_error") == "1",
},
)
@app.post("/login", response_class=HTMLResponse)
async def login_submit(request: Request) -> Response:
"""Authenticate the hard-coded admin user and set a session cookie."""
form = parse_qs((await request.body()).decode())
username = form.get("username", [""])[0]
password = form.get("password", [""])[0]
next_path = _safe_next_path(form.get("next", [request.query_params.get("next")])[0])
@app.get("/login")
def login(request: Request) -> Response:
"""Start the WorkOS hosted login flow."""
next_path = auth.safe_next_path(request.query_params.get("next"))
current_user = auth.get_current_session(request)
if current_user is not None:
return RedirectResponse(next_path, status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(
auth.build_authorization_url(next_path),
status_code=status.HTTP_303_SEE_OTHER,
)
username_ok = secrets.compare_digest(username, ADMIN_USERNAME)
password_ok = secrets.compare_digest(password, ADMIN_PASSWORD)
if not (username_ok and password_ok):
return templates.TemplateResponse(
request,
"login.html",
{
"error": "Invalid username or password.",
"is_authenticated": False,
"show_primary_nav": False,
"next_path": next_path,
"username": username,
},
status_code=status.HTTP_401_UNAUTHORIZED,
)
response = RedirectResponse(next_path, status_code=status.HTTP_303_SEE_OTHER)
@app.get("/callback")
def callback(request: Request) -> Response:
"""Exchange the WorkOS code for a sealed session cookie."""
try:
result = auth.exchange_code(request)
except Exception:
logger.exception("WorkOS callback exchange failed.")
response = RedirectResponse("/?auth_error=1", status_code=status.HTTP_303_SEE_OTHER)
_delete_auth_cookie(response)
return response
config = auth.get_auth_config()
response = RedirectResponse(result.next_path, status_code=status.HTTP_303_SEE_OTHER)
response.set_cookie(
SESSION_COOKIE,
_sign_session(username),
config.session_cookie_name,
result.sealed_session,
httponly=True,
samesite="lax",
secure=config.secure_cookies,
)
return response
@app.get("/logout")
def logout() -> Response:
"""Clear the local admin session."""
response = RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(SESSION_COOKIE)
@app.post("/logout")
def logout(request: Request) -> Response:
"""End the WorkOS session and clear the local sealed session cookie."""
response = RedirectResponse(auth.get_logout_url(request), status_code=status.HTTP_303_SEE_OTHER)
_delete_auth_cookie(response)
return response
def require_admin(request: Request) -> str:
"""Redirect unauthenticated users to the in-site login page."""
username = _authenticated_user(request)
if username is not None:
return username
def require_user(request: Request) -> auth.AuthSession:
"""Redirect unauthenticated users to the WorkOS sign-in flow."""
current_user = auth.get_current_session(request)
if current_user is not None:
return current_user
next_path = request.url.path
if request.url.query:
next_path = f"{next_path}?{request.url.query}"
@@ -138,87 +129,64 @@ def require_admin(request: Request) -> str:
)
def _authenticated_user(request: Request) -> str | None:
token = request.cookies.get(SESSION_COOKIE)
if token is None:
return None
try:
username, signature = token.split(":", 1)
except ValueError:
return None
if username != ADMIN_USERNAME:
return None
expected = _session_signature(username)
if secrets.compare_digest(signature, expected):
return username
return None
def require_admin(current_user: auth.AuthSession = Depends(require_user)) -> auth.AuthSession:
"""Restrict a route to WorkOS users with the admin role."""
if current_user.is_admin:
return current_user
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required.")
def _sign_session(username: str) -> str:
return f"{username}:{_session_signature(username)}"
def _session_signature(username: str) -> str:
return hmac.new(
SESSION_SECRET.encode(),
username.encode(),
hashlib.sha256,
).hexdigest()
def _safe_next_path(value: str | None) -> str:
if value and value.startswith("/") and not value.startswith("//"):
return value
return "/"
@app.get("/", response_class=HTMLResponse)
def dashboard(request: Request, _: str = Depends(require_admin)) -> Response:
@app.get("/dashboard", response_class=HTMLResponse)
def dashboard(
request: Request, current_user: auth.AuthSession = Depends(require_user)
) -> Response:
"""Render the full dashboard page."""
context = _dashboard_context(request)
context = {**_auth_context(current_user), **_dashboard_context(request)}
if request.headers.get("hx-request") == "true":
return templates.TemplateResponse(request, "partials/_dashboard.html", context)
return templates.TemplateResponse(request, "dashboard.html", context)
@app.get("/partials/dashboard", response_class=HTMLResponse)
def dashboard_partial(request: Request, _: str = Depends(require_admin)) -> Response:
def dashboard_partial(request: Request, _: auth.AuthSession = Depends(require_user)) -> Response:
"""Render the filter-dependent dashboard body."""
context = _dashboard_context(request)
return templates.TemplateResponse(request, "partials/_dashboard.html", context)
@app.get("/partials/issues", response_class=HTMLResponse)
def issues_partial(request: Request, _: str = Depends(require_admin)) -> Response:
def issues_partial(request: Request, _: auth.AuthSession = Depends(require_user)) -> Response:
"""Render only issue filters."""
context = _dashboard_context(request)
return templates.TemplateResponse(request, "partials/_issue_filters.html", context)
@app.get("/partials/rankings", response_class=HTMLResponse)
def rankings_partial(request: Request, _: str = Depends(require_admin)) -> Response:
def rankings_partial(request: Request, _: auth.AuthSession = Depends(require_user)) -> Response:
"""Render only ranking panels."""
context = _dashboard_context(request)
return templates.TemplateResponse(request, "partials/_rankings.html", context)
@app.get("/partials/chart", response_class=HTMLResponse)
def chart_partial(request: Request, _: str = Depends(require_admin)) -> Response:
def chart_partial(request: Request, _: auth.AuthSession = Depends(require_user)) -> Response:
"""Render only the SVG chart panel."""
context = _dashboard_context(request)
return templates.TemplateResponse(request, "partials/_chart.html", context)
@app.get("/legislators", response_class=HTMLResponse)
def legislators(request: Request, _: str = Depends(require_admin)) -> Response:
def legislators(
request: Request, current_user: auth.AuthSession = Depends(require_user)
) -> Response:
"""Render the legislator profile/search page."""
context = _legislators_context(request)
context = {**_auth_context(current_user), **_legislators_context(request)}
return templates.TemplateResponse(request, "legislators.html", context)
@app.get("/partials/legislator-suggestions", response_class=HTMLResponse)
def legislator_suggestions_partial(
request: Request, _: str = Depends(require_admin)
request: Request, _: auth.AuthSession = Depends(require_user)
) -> Response:
"""Render legislator search suggestions for the HTMX typeahead."""
query = request.query_params.get("q", "").strip()
@@ -238,12 +206,29 @@ def legislator_suggestions_partial(
@app.get("/compare", response_class=HTMLResponse)
def compare(request: Request, _: str = Depends(require_admin)) -> Response:
def compare(
request: Request, current_user: auth.AuthSession = Depends(require_user)
) -> Response:
"""Render the legislator radar comparison page."""
context = _compare_context(request)
context = {**_auth_context(current_user), **_compare_context(request)}
return templates.TemplateResponse(request, "compare.html", context)
@app.get("/admin", response_class=HTMLResponse)
def admin_page(
request: Request, current_user: auth.AuthSession = Depends(require_admin)
) -> Response:
"""Render the admin-only placeholder page."""
return templates.TemplateResponse(
request,
"admin.html",
{
**_auth_context(current_user),
"organization_id": auth.get_auth_config().organization_id,
},
)
def _dashboard_context(request: Request) -> dict[str, Any]:
state = _parse_state(request)
base_context: dict[str, Any] = {
@@ -263,6 +248,7 @@ def _dashboard_context(request: Request) -> dict[str, Any]:
"has_scores": False,
"empty_message": "",
"build_url": _build_url,
"build_dashboard_partial_url": _build_dashboard_partial_url,
"toggle_compare": _toggle_compare,
}
with session_scope() as session:
@@ -520,10 +506,29 @@ def _build_url(
for legislator_id in chosen_compare:
params.append(("compare", str(legislator_id)))
if not params:
return "/"
return "/dashboard"
from urllib.parse import urlencode
return f"/?{urlencode(params, doseq=True)}"
return f"/dashboard?{urlencode(params, doseq=True)}"
def _build_dashboard_partial_url(
request: Request,
*,
issues: list[str] | None = None,
chamber: str | None = None,
congress: int | None = None,
compare: list[int] | None = None,
) -> str:
"""Return the HTMX endpoint matching the current dashboard query state."""
dashboard_url = _build_url(
request,
issues=issues,
chamber=chamber,
congress=congress,
compare=compare,
)
return dashboard_url.replace("/dashboard", "/partials/dashboard", 1)
def _toggle_compare(compare: list[int], legislator_id: int) -> list[int]:
@@ -587,3 +592,18 @@ def _build_compare_url(
if q:
params.append(("q", q))
return f"/compare?{urlencode(params, doseq=True)}" if params else "/compare"
def _auth_context(current_user: auth.AuthSession | None) -> dict[str, Any]:
"""Shared template context for auth-aware navigation."""
return {
"is_authenticated": current_user is not None,
"is_admin": current_user.is_admin if current_user is not None else False,
"current_user_name": current_user.display_name if current_user is not None else "",
"current_user_email": current_user.email if current_user is not None else "",
}
def _delete_auth_cookie(response: Response) -> None:
"""Delete the sealed WorkOS session cookie."""
response.delete_cookie(getenv("WORKOS_SESSION_COOKIE_NAME", "workos_session"))
+161 -1
View File
@@ -152,16 +152,35 @@ a {
text-align: left;
}
.account-menu-panel form {
margin: 0.2rem 0 0;
}
.account-email {
color: var(--muted);
display: block;
font-size: 0.84rem;
padding: 0.3rem 0.1rem 0.55rem;
}
.account-menu-panel .sign-out {
background: #0d5f53;
border-color: #16806f;
color: white;
}
.account-menu-panel .sign-out,
.account-nav .sign-in {
background: #0d5f53;
border-color: #16806f;
border-radius: 7px;
color: white;
cursor: pointer;
display: block;
font: inherit;
min-width: 100%;
padding: 0.55rem 0.8rem;
text-align: left;
}
.shell {
@@ -170,6 +189,140 @@ a {
padding: 1.25rem 1.5rem 2rem;
}
.home-shell {
padding-top: 2rem;
}
.hero-panel,
.home-grid,
.admin-meta,
.admin-actions {
display: grid;
}
.hero-panel {
gap: 1.4rem;
grid-template-columns: minmax(0, 1.5fr) minmax(18rem, 0.85fr);
}
.hero-panel,
.home-card,
.admin-card {
background: color-mix(in srgb, var(--panel) 88%, transparent);
border: 1px solid var(--line);
border-radius: 18px;
}
.hero-copy,
.hero-card,
.home-card,
.admin-card {
padding: 1.5rem 1.6rem;
}
.hero-copy h1 {
font-size: clamp(2.3rem, 4vw, 4.15rem);
letter-spacing: -0.04em;
line-height: 0.96;
margin: 0.2rem 0 0.9rem;
max-width: 12ch;
}
.hero-text {
color: #c0d3cd;
font-size: 1.02rem;
line-height: 1.65;
margin: 0;
max-width: 60ch;
}
.hero-actions,
.admin-actions {
gap: 0.75rem;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
margin-top: 1.35rem;
}
.hero-primary,
.hero-secondary,
.admin-actions a {
border-radius: 999px;
font-weight: 760;
min-height: 2.8rem;
padding: 0.78rem 1.15rem;
}
.hero-primary {
background: linear-gradient(120deg, #0d5f53, #2fbd9f);
color: white;
}
.hero-secondary,
.admin-actions a {
border: 1px solid #1d554c;
color: #d0dfdb;
}
.hero-card h2,
.home-card h2,
.admin-card h2 {
margin-bottom: 0.65rem;
}
.hero-card ul {
color: #c0d3cd;
line-height: 1.6;
margin: 0;
padding-left: 1.1rem;
}
.home-grid {
gap: 1rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 1.25rem;
}
.home-card p,
.admin-card p,
.admin-meta dt {
color: var(--muted);
}
.auth-notice {
margin-bottom: 1rem;
}
.admin-card {
margin-top: 1.25rem;
}
.admin-meta {
gap: 0.85rem;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
margin: 1.2rem 0 1.1rem;
}
.admin-meta div {
background: rgba(12, 38, 33, 0.78);
border: 1px solid rgba(44, 123, 109, 0.28);
border-radius: 10px;
padding: 0.9rem 1rem;
}
.admin-meta dt {
font-size: 0.84rem;
margin-bottom: 0.35rem;
text-transform: uppercase;
}
.admin-meta dd {
margin: 0;
}
.login-shell {
align-items: center;
display: flex;
@@ -1122,7 +1275,9 @@ h2 {
.rankings-grid,
.topic-panels,
.compare-card,
.login-panel {
.login-panel,
.hero-panel,
.home-grid {
display: block;
}
@@ -1212,4 +1367,9 @@ h2 {
margin-bottom: 1rem;
padding: 0 0 1rem;
}
.hero-card,
.home-card + .home-card {
margin-top: 1rem;
}
}
+36
View File
@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title %}Admin Settings{% endblock %}
{% block body %}
<main class="shell">
<section class="page-heading stacked-heading">
<div>
<h1>Admin settings</h1>
<p>Admin-only operational controls for the Nornsight workspace.</p>
</div>
</section>
<section class="admin-card">
<h2>WorkOS-managed access</h2>
<p>
Invitations, Google access, and role assignments are managed in the WorkOS dashboard.
This page confirms that app-level admin gating is active.
</p>
<dl class="admin-meta">
<div>
<dt>Workspace organization</dt>
<dd><code>{{ organization_id }}</code></dd>
</div>
<div>
<dt>Current administrator</dt>
<dd>{{ current_user_email }}</dd>
</div>
</dl>
<div class="admin-actions">
<a href="/dashboard">Return to dashboard</a>
<a href="https://dashboard.workos.com/" rel="noreferrer" target="_blank">Open WorkOS dashboard</a>
</div>
</section>
</main>
{% endblock %}
+15 -5
View File
@@ -15,23 +15,33 @@
</a>
{% if show_primary_nav|default(true) %}
<nav class="primary-nav" aria-label="Primary">
<a href="/">Issues</a>
{% if is_authenticated|default(false) %}
<a href="/dashboard">Dashboard</a>
<a href="/legislators">Legislators</a>
<a href="/compare">Compare</a>
{% if is_admin|default(false) %}
<a href="/admin">Admin</a>
{% endif %}
{% else %}
<a href="/">Overview</a>
{% endif %}
</nav>
{% endif %}
<nav class="account-nav" aria-label="Account">
<a href="#" aria-disabled="true">Help</a>
{% if is_authenticated|default(true) %}
{% if is_authenticated|default(false) %}
<details class="account-menu">
<summary>My account</summary>
<summary>{{ current_user_name or "My account" }}</summary>
<div class="account-menu-panel">
<span class="account-email">{{ current_user_email }}</span>
<a href="#" aria-disabled="true">Account settings</a>
<a class="sign-out" href="/logout">Sign out</a>
<form action="/logout" method="post">
<button class="sign-out" type="submit">Sign out</button>
</form>
</div>
</details>
{% else %}
<a class="sign-in" href="/login">Sign in</a>
<a class="sign-in" href="/login?next=/dashboard">Sign in</a>
{% endif %}
</nav>
</header>
+1
View File
@@ -10,6 +10,7 @@
<p>US legislative accountability · precomputed legislator topic scores{% if latest_score_year %} through {{ latest_score_year }}{% endif %}</p>
</div>
<div class="heading-actions">
<span>{{ current_user_email }}</span>
<a href="#" aria-disabled="true">Methodology</a>
<a href="#" aria-disabled="true">Data sources</a>
<span>Last updated: {{ last_updated.strftime("%b %Y") if last_updated else "Unavailable" }}</span>
+59
View File
@@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block title %}Nornsight | Legislative Accountability{% endblock %}
{% block body %}
<main class="shell home-shell">
{% if auth_error %}
<div class="notice auth-notice">Authentication failed. Try signing in again.</div>
{% endif %}
<section class="hero-panel">
<div class="hero-copy">
<p class="eyebrow">Invite-only access</p>
<h1>Track legislative behavior with role-aware access and shared WorkOS sign-in.</h1>
<p class="hero-text">
Nornsight turns roll-call data into issue-level accountability views for your invited team.
Use the public home page as the front door, then move signed-in users into the dashboard,
legislator search, and comparison tools.
</p>
<div class="hero-actions">
{% if is_authenticated %}
<a class="hero-primary" href="/dashboard">Open dashboard</a>
{% if is_admin %}
<a class="hero-secondary" href="/admin">Admin settings</a>
{% endif %}
{% else %}
<a class="hero-primary" href="/login?next=/dashboard">Sign in</a>
<a class="hero-secondary" href="#access-model">How access works</a>
{% endif %}
</div>
</div>
<aside class="hero-card">
<h2>Launch access model</h2>
<ul>
<li>Public landing page at <code>/</code></li>
<li>Invite-only AuthKit login with Email + Password and Google</li>
<li><code>viewer</code> role for dashboard, legislators, and compare</li>
<li><code>admin</code> role for settings and account administration</li>
</ul>
</aside>
</section>
<section id="access-model" class="home-grid">
<article class="home-card">
<h2>For invited users</h2>
<p>View the dashboard, inspect legislator profiles, and compare issue scoring without sharing a local password.</p>
</article>
<article class="home-card">
<h2>For admins</h2>
<p>Manage invitations and role assignments in WorkOS while the app enforces role-based route access.</p>
</article>
<article class="home-card">
<h2>For rollout</h2>
<p>Authentication is centralized, sessions are sealed, and the old hard-coded admin login is removed.</p>
</article>
</section>
</main>
{% endblock %}
+1 -1
View File
@@ -2,7 +2,7 @@
<header>
<h2>Score history{% if selected_issue_label %} — {{ selected_issue_label }}{% endif %}</h2>
<a href="{{ build_url(request, compare=[]) }}"
hx-get="/partials/dashboard{{ build_url(request, compare=[])|replace('/', '', 1) }}"
hx-get="{{ build_dashboard_partial_url(request, compare=[]) }}"
hx-target="#dashboard-body"
hx-push-url="{{ build_url(request, compare=[]) }}">Clear comparison</a>
</header>
@@ -3,17 +3,17 @@
<div class="chamber-card">
<a class="segment {{ 'active' if chamber == 'house' else '' }}"
href="{{ build_url(request, chamber='house') }}"
hx-get="/partials/dashboard{{ build_url(request, chamber='house')|replace('/', '', 1) }}"
hx-get="{{ build_dashboard_partial_url(request, chamber='house') }}"
hx-target="#dashboard-body"
hx-push-url="{{ build_url(request, chamber='house') }}">House</a>
<a class="segment {{ 'active' if chamber == 'senate' else '' }}"
href="{{ build_url(request, chamber='senate') }}"
hx-get="/partials/dashboard{{ build_url(request, chamber='senate')|replace('/', '', 1) }}"
hx-get="{{ build_dashboard_partial_url(request, chamber='senate') }}"
hx-target="#dashboard-body"
hx-push-url="{{ build_url(request, chamber='senate') }}">Senate</a>
<a class="segment {{ 'active' if chamber == 'all' else '' }}"
href="{{ build_url(request, chamber='all') }}"
hx-get="/partials/dashboard{{ build_url(request, chamber='all')|replace('/', '', 1) }}"
hx-get="{{ build_dashboard_partial_url(request, chamber='all') }}"
hx-target="#dashboard-body"
hx-push-url="{{ build_url(request, chamber='all') }}">All</a>
</div>
@@ -2,10 +2,10 @@
<h2>Issue filters</h2>
<form class="issue-form"
method="get"
action="/"
hx-get="/"
action="/dashboard"
hx-get="/partials/dashboard"
hx-target="#dashboard-body"
hx-push-url="true">
hx-push-url="/dashboard">
<input type="hidden" name="chamber" value="{{ chamber }}">
{% if congress %}
<input type="hidden" name="congress" value="{{ congress }}">
@@ -17,7 +17,7 @@
<span class="chip">
{{ issue }}
<a href="{{ build_url(request, issues=issues[:loop.index0] + issues[loop.index:]) }}"
hx-get="/partials/dashboard{{ build_url(request, issues=issues[:loop.index0] + issues[loop.index:])|replace('/', '', 1) }}"
hx-get="{{ build_dashboard_partial_url(request, issues=issues[:loop.index0] + issues[loop.index:]) }}"
hx-target="#dashboard-body"
hx-push-url="{{ build_url(request, issues=issues[:loop.index0] + issues[loop.index:]) }}"
aria-label="Remove {{ issue }}">×</a>
@@ -36,7 +36,7 @@
{% for suggestion in suggestions %}
{% if suggestion not in issues %}
<a href="{{ build_url(request, issues=issues + [suggestion]) }}"
hx-get="/partials/dashboard{{ build_url(request, issues=issues + [suggestion])|replace('/', '', 1) }}"
hx-get="{{ build_dashboard_partial_url(request, issues=issues + [suggestion]) }}"
hx-target="#dashboard-body"
hx-push-url="{{ build_url(request, issues=issues + [suggestion]) }}">{{ suggestion }}</a>
{% endif %}
@@ -10,7 +10,7 @@
{% set next_compare = toggle_compare(compare, row.legislator_id) %}
<li class="{{ 'selected' if row.legislator_id in compare else '' }}">
<a href="{{ build_url(request, compare=next_compare) }}"
hx-get="/partials/dashboard{{ build_url(request, compare=next_compare)|replace('/', '', 1) }}"
hx-get="{{ build_dashboard_partial_url(request, compare=next_compare) }}"
hx-target="#dashboard-body"
hx-push-url="{{ build_url(request, compare=next_compare) }}">
<span class="rank">{{ loop.index }}</span>
@@ -40,7 +40,7 @@
{% set next_compare = toggle_compare(compare, row.legislator_id) %}
<li class="{{ 'selected' if row.legislator_id in compare else '' }}">
<a href="{{ build_url(request, compare=next_compare) }}"
hx-get="/partials/dashboard{{ build_url(request, compare=next_compare)|replace('/', '', 1) }}"
hx-get="{{ build_dashboard_partial_url(request, compare=next_compare) }}"
hx-target="#dashboard-body"
hx-push-url="{{ build_url(request, compare=next_compare) }}">
<span class="rank">{{ loop.index }}</span>
+8 -2
View File
@@ -4,12 +4,15 @@ version = "0.1.0"
description = "Data science pipeline tools and legislative dashboard."
requires-python = ">=3.12"
dependencies = [
"alembic",
"fastapi",
"httpx",
"uvicorn[standard]",
"jinja2",
"sqlalchemy",
"psycopg",
"sqlalchemy",
"typer",
"uvicorn[standard]",
"workos",
]
[project.optional-dependencies]
@@ -20,3 +23,6 @@ test = [
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
[tool.setuptools.packages.find]
include = ["pipelines*"]
-36
View File
@@ -1,36 +0,0 @@
from pipelines.orm.data_science_dev.congress import BillText, BillTextSummary
def test_default_summary_prefers_primary_summary() -> None:
primary_summary = BillTextSummary(id=1, bill_text_id=10, summary="primary")
latest_summary = BillTextSummary(id=2, bill_text_id=10, summary="latest")
bill_text = BillText(
id=10,
bill_id=5,
version_code="ih",
summaries=[latest_summary],
primary_summary=primary_summary,
)
assert bill_text.default_summary() is primary_summary
def test_default_summary_falls_back_to_latest_summary() -> None:
latest_summary = BillTextSummary(id=2, bill_text_id=10, summary="latest")
older_summary = BillTextSummary(id=1, bill_text_id=10, summary="older")
bill_text = BillText(
id=10,
bill_id=5,
version_code="ih",
summaries=[latest_summary, older_summary],
)
assert bill_text.latest_summary() is latest_summary
assert bill_text.default_summary() is latest_summary
def test_default_summary_is_none_without_summaries() -> None:
bill_text = BillText(id=10, bill_id=5, version_code="ih")
assert bill_text.latest_summary() is None
assert bill_text.default_summary() is None
-71
View File
@@ -1,71 +0,0 @@
from sqlalchemy.dialects import postgresql
from pipelines.jobs.extract_bill_topics import (
_select_bill_text_for_topic_extraction,
create_select_bills_for_topic_extraction,
)
from pipelines.orm.data_science_dev.congress import Bill, BillText, BillTextSummary
def _compile_sql(statement: object) -> str:
return str(
statement.compile(
dialect=postgresql.dialect(),
compile_kwargs={"literal_binds": True},
)
)
def test_select_bill_text_for_topic_extraction_uses_primary_summary() -> None:
primary_summary = BillTextSummary(id=1, bill_text_id=10, summary="primary")
newest_summary = BillTextSummary(id=2, bill_text_id=10, summary="newest")
bill_text = BillText(
id=10,
bill_id=5,
version_code="ih",
summaries=[newest_summary],
primary_summary=primary_summary,
)
bill = Bill(
id=5,
congress=119,
bill_type="hr",
number=1,
bill_texts=[bill_text],
)
selected = _select_bill_text_for_topic_extraction(bill)
assert selected is bill_text
assert selected.default_summary() is primary_summary
def test_select_bill_text_for_topic_extraction_uses_latest_summary_without_primary() -> None:
newest_summary = BillTextSummary(id=2, bill_text_id=10, summary="newest")
older_summary = BillTextSummary(id=1, bill_text_id=10, summary="older")
bill_text = BillText(
id=10,
bill_id=5,
version_code="ih",
summaries=[newest_summary, older_summary],
)
bill = Bill(
id=5,
congress=119,
bill_type="hr",
number=1,
bill_texts=[bill_text],
)
selected = _select_bill_text_for_topic_extraction(bill)
assert selected is bill_text
assert selected.default_summary() is newest_summary
def test_create_select_bills_for_topic_extraction_uses_summary_exists_subquery() -> None:
sql = _compile_sql(create_select_bills_for_topic_extraction())
assert "bill_text_summary" in sql
assert "EXISTS" in sql
assert "bill_text.summary" not in sql
-58
View File
@@ -1,58 +0,0 @@
from sqlalchemy.dialects import postgresql
from pipelines.jobs.summarize_bills import (
create_select_bill_texts_for_summarization,
store_bill_summary_result,
)
from pipelines.orm.data_science_dev.congress import BillText, BillTextSummary
class FakeSession:
def __init__(self) -> None:
self.added: list[object] = []
def add(self, value: object) -> None:
self.added.append(value)
def _compile_sql(statement: object) -> str:
return str(
statement.compile(
dialect=postgresql.dialect(),
compile_kwargs={"literal_binds": True},
)
)
def test_store_bill_summary_result_creates_summary_row() -> None:
session = FakeSession()
bill_text = BillText(id=10, bill_id=5, version_code="ih")
summary_row = store_bill_summary_result(
session=session,
bill_text=bill_text,
summary="A summary",
model="gpt-5.4-mini",
)
assert session.added == [summary_row]
assert isinstance(summary_row, BillTextSummary)
assert summary_row.bill_text is bill_text
assert summary_row.summary == "A summary"
assert summary_row.summarization_model == "gpt-5.4-mini"
assert summary_row.summarization_system_prompt_version == "v1.2"
assert summary_row.summarization_user_prompt_version == "v1"
def test_create_select_bill_texts_for_summarization_excludes_existing_summaries() -> None:
sql = _compile_sql(create_select_bill_texts_for_summarization(force=False))
assert "bill_text_summary" in sql
assert "NOT (EXISTS" in sql or "NOT EXISTS" in sql
assert "bill_text.summary" not in sql
def test_create_select_bill_texts_for_summarization_force_skips_summary_filter() -> None:
sql = _compile_sql(create_select_bill_texts_for_summarization(force=True))
assert "bill_text_summary" not in sql
+327
View File
@@ -0,0 +1,327 @@
from __future__ import annotations
from datetime import date
import pytest
from fastapi.testclient import TestClient
from pipelines.web import auth, main
from pipelines.web.repository import (
ChartSeries,
LegislatorOption,
RadarSeries,
RankingResult,
RankingRow,
TimePoint,
)
def test_healthz() -> None:
client = TestClient(main.app)
response = client.get("/healthz")
assert response.status_code == 200
assert response.text == "ok"
def test_public_home_page_renders() -> None:
client = TestClient(main.app)
response = client.get("/")
assert response.status_code == 200
assert "Invite-only access" in response.text
assert "Sign in" in response.text
def test_dashboard_redirects_to_login() -> None:
client = TestClient(main.app)
response = client.get("/dashboard?issues=Health", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"].endswith(
"/login?next=%2Fdashboard%3Fissues%3DHealth"
)
def test_other_protected_routes_redirect_when_unauthenticated() -> None:
client = TestClient(main.app)
for path in ["/legislators", "/compare", "/admin"]:
response = client.get(path, follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"].endswith(f"/login?next={path.replace('/', '%2F', 1)}")
def test_login_redirects_to_workos(monkeypatch) -> None:
monkeypatch.setattr(main.auth, "get_current_session", lambda request: None)
monkeypatch.setattr(
main.auth,
"build_authorization_url",
lambda next_path: f"https://auth.example/login?state={next_path}",
)
client = TestClient(main.app)
response = client.get("/login?next=/compare", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "https://auth.example/login?state=/compare"
def test_login_redirects_authenticated_user(monkeypatch) -> None:
monkeypatch.setattr(main.auth, "get_current_session", lambda request: _viewer_session())
client = TestClient(main.app)
response = client.get("/login?next=/compare", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/compare"
def test_callback_sets_session_cookie(monkeypatch) -> None:
monkeypatch.setattr(
main.auth,
"exchange_code",
lambda request: auth.CallbackResult(
sealed_session="sealed-session-value", next_path="/dashboard"
),
)
monkeypatch.setattr(main.auth, "get_auth_config", _fake_auth_config)
client = TestClient(main.app)
response = client.get("/callback?code=abc&state=/dashboard", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/dashboard"
assert "workos_session=sealed-session-value" in response.headers["set-cookie"]
def test_callback_failure_redirects_home_and_clears_cookie(monkeypatch) -> None:
def raise_exchange_error(request):
raise RuntimeError("bad code")
monkeypatch.setattr(main.auth, "exchange_code", raise_exchange_error)
client = TestClient(main.app)
response = client.get("/callback?code=bad", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/?auth_error=1"
assert "workos_session=" in response.headers["set-cookie"]
def test_logout_redirects_to_workos_and_clears_cookie(monkeypatch) -> None:
monkeypatch.setattr(
main.auth,
"get_logout_url",
lambda request: "https://auth.example/logout",
)
client = TestClient(main.app)
response = client.post("/logout", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "https://auth.example/logout"
assert "workos_session=" in response.headers["set-cookie"]
def test_dashboard_route_renders_with_stubbed_repository(monkeypatch) -> None:
_patch_authenticated_dashboard(monkeypatch, current_user=_viewer_session())
client = TestClient(main.app)
response = client.get("/dashboard?issues=Health&chamber=senate")
assert response.status_code == 200
assert "Legislative accountability" in response.text
assert "Most supportive" in response.text
assert "viewer@nornsight.test" in response.text
assert "/admin" not in response.text
assert '/partials/dashboard?issues=Health&amp;chamber=house' in response.text
assert "/partials/dashboarddashboard?" not in response.text
def test_admin_route_forbids_viewer(monkeypatch) -> None:
monkeypatch.setattr(main.auth, "get_current_session", lambda request: _viewer_session())
client = TestClient(main.app)
response = client.get("/admin")
assert response.status_code == 403
assert response.json()["detail"] == "Admin access required."
def test_admin_route_renders_for_admin(monkeypatch) -> None:
monkeypatch.setattr(main.auth, "get_current_session", lambda request: _admin_session())
monkeypatch.setattr(main.auth, "get_auth_config", _fake_auth_config)
client = TestClient(main.app)
response = client.get("/admin")
assert response.status_code == 200
assert "Admin settings" in response.text
assert "admin@nornsight.test" in response.text
assert "org_test_123" in response.text
def test_compare_page_renders_for_authenticated_user(monkeypatch) -> None:
monkeypatch.setattr(main.auth, "get_current_session", lambda request: _viewer_session())
_patch_compare_page_data(monkeypatch)
client = TestClient(main.app)
response = client.get("/compare")
assert response.status_code == 200
assert "Compare legislators" in response.text
assert "Sanders, B." in response.text
def _viewer_session() -> auth.AuthSession:
return auth.AuthSession(
user_id="user_viewer",
email="viewer@nornsight.test",
first_name="Viewer",
last_name="User",
role_slugs={"viewer"},
organization_id="org_test_123",
raw_user=None,
raw_session=None,
)
def _admin_session() -> auth.AuthSession:
return auth.AuthSession(
user_id="user_admin",
email="admin@nornsight.test",
first_name="Admin",
last_name="User",
role_slugs={"admin", "viewer"},
organization_id="org_test_123",
raw_user=None,
raw_session=None,
)
def _fake_auth_config() -> auth.AuthConfig:
return auth.AuthConfig(
api_key="sk_test",
client_id="client_test",
cookie_password="x" * 32,
redirect_uri="http://localhost:8000/callback",
logout_redirect_uri="http://localhost:8000/",
session_cookie_name="workos_session",
organization_id="org_test_123",
)
def _patch_authenticated_dashboard(monkeypatch, *, current_user: auth.AuthSession) -> None:
monkeypatch.setattr(main.auth, "get_current_session", lambda request: current_user)
class DummySession:
pass
class DummyScope:
def __enter__(self):
return DummySession()
def __exit__(self, exc_type, exc, tb):
return False
rankings = RankingResult(
supportive=[
RankingRow(
legislator_id=1,
display_name="Sanders, B.",
party="I",
state="VT",
chamber="senate",
score=78.0,
supportive=7,
opposed=2,
)
],
opposed=[
RankingRow(
legislator_id=2,
display_name="Cruz, T.",
party="R",
state="TX",
chamber="senate",
score=22.0,
supportive=2,
opposed=7,
)
],
)
history = [
ChartSeries(
legislator_id=1,
label="Sanders, B.",
party="I",
state="VT",
points=[TimePoint(year=2024, score=74.0), TimePoint(year=2025, score=78.0)],
)
]
monkeypatch.setattr(main, "session_scope", lambda: DummyScope())
monkeypatch.setattr(main.repository, "latest_congress", lambda session: 119)
monkeypatch.setattr(main.repository, "has_scores", lambda session: True)
monkeypatch.setattr(main.repository, "latest_score_year", lambda session: 2026)
monkeypatch.setattr(
main.repository, "latest_vote_date", lambda session, congress: date(2026, 1, 15)
)
monkeypatch.setattr(
main.repository,
"issue_suggestions",
lambda session, congress=None, limit=12: ["Health", "Taxation"],
)
monkeypatch.setattr(
main.repository,
"get_rankings",
lambda session, *, issues, chamber, congress: rankings,
)
monkeypatch.setattr(
main.repository,
"get_score_history",
lambda session, *, issues, chamber, congress, legislator_ids: history,
)
def _patch_compare_page_data(monkeypatch) -> None:
class DummySession:
pass
class DummyScope:
def __enter__(self):
return DummySession()
def __exit__(self, exc_type, exc, tb):
return False
legislator = LegislatorOption(
legislator_id=1,
display_name="Sanders, B.",
party="I",
state="VT",
chamber="senate",
)
topics = ["Health", "Taxation", "Energy"]
series = [
RadarSeries(
legislator=legislator,
average_score=77.0,
scores_by_topic={"Health": 82.0, "Taxation": 71.0, "Energy": 78.0},
)
]
monkeypatch.setattr(main, "session_scope", lambda: DummyScope())
monkeypatch.setattr(
main.repository,
"get_compare_defaults",
lambda session: ([1], topics),
)
monkeypatch.setattr(
main.repository,
"get_legislator_options",
lambda session, selected_legislators: [legislator],
)
monkeypatch.setattr(
main.repository,
"get_compare_radar_series",
lambda session, *, legislator_ids, topics: series,
)
monkeypatch.setattr(
main.repository,
"search_legislators",
lambda session, query=None, limit=12: [legislator],
)
monkeypatch.setattr(
main.repository,
"issue_suggestions",
lambda session, congress=None, limit=12: topics,
)