tr]:last:border-b-0",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ );
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+ [role=checkbox]]:translate-y-[2px]",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+ | [role=checkbox]]:translate-y-[2px]",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+
+ );
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+};
diff --git a/src/bills/components/ui/tabs.tsx b/src/bills/components/ui/tabs.tsx
new file mode 100644
index 0000000..b511109
--- /dev/null
+++ b/src/bills/components/ui/tabs.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import * as React from "react";
+import * as TabsPrimitive from "@radix-ui/react-tabs";
+
+import { cn } from "./utils";
+
+function Tabs({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function TabsList({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function TabsTrigger({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function TabsContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent };
diff --git a/src/bills/components/ui/textarea.tsx b/src/bills/components/ui/textarea.tsx
new file mode 100644
index 0000000..8f1810e
--- /dev/null
+++ b/src/bills/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react";
+
+import { cn } from "./utils";
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ );
+}
+
+export { Textarea };
diff --git a/src/bills/components/ui/toggle-group.tsx b/src/bills/components/ui/toggle-group.tsx
new file mode 100644
index 0000000..8d4b5f1
--- /dev/null
+++ b/src/bills/components/ui/toggle-group.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import * as React from "react";
+import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
+import { type VariantProps } from "class-variance-authority";
+
+import { cn } from "./utils";
+import { toggleVariants } from "./toggle";
+
+const ToggleGroupContext = React.createContext<
+ VariantProps
+>({
+ size: "default",
+ variant: "default",
+});
+
+function ToggleGroup({
+ className,
+ variant,
+ size,
+ children,
+ ...props
+}: React.ComponentProps &
+ VariantProps) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+function ToggleGroupItem({
+ className,
+ children,
+ variant,
+ size,
+ ...props
+}: React.ComponentProps &
+ VariantProps) {
+ const context = React.useContext(ToggleGroupContext);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export { ToggleGroup, ToggleGroupItem };
diff --git a/src/bills/components/ui/toggle.tsx b/src/bills/components/ui/toggle.tsx
new file mode 100644
index 0000000..533c73f
--- /dev/null
+++ b/src/bills/components/ui/toggle.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import * as React from "react";
+import * as TogglePrimitive from "@radix-ui/react-toggle";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "./utils";
+
+const toggleVariants = cva(
+ "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ outline:
+ "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
+ },
+ size: {
+ default: "h-9 px-2 min-w-9",
+ sm: "h-8 px-1.5 min-w-8",
+ lg: "h-10 px-2.5 min-w-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+function Toggle({
+ className,
+ variant,
+ size,
+ ...props
+}: React.ComponentProps &
+ VariantProps) {
+ return (
+
+ );
+}
+
+export { Toggle, toggleVariants };
diff --git a/src/bills/components/ui/tooltip.tsx b/src/bills/components/ui/tooltip.tsx
new file mode 100644
index 0000000..94f84d0
--- /dev/null
+++ b/src/bills/components/ui/tooltip.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import * as React from "react";
+import * as TooltipPrimitive from "@radix-ui/react-tooltip";
+
+import { cn } from "./utils";
+
+function TooltipProvider({
+ delayDuration = 0,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function Tooltip({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function TooltipTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+ );
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
diff --git a/src/bills/components/ui/use-mobile.ts b/src/bills/components/ui/use-mobile.ts
new file mode 100644
index 0000000..a93d583
--- /dev/null
+++ b/src/bills/components/ui/use-mobile.ts
@@ -0,0 +1,21 @@
+import * as React from "react";
+
+const MOBILE_BREAKPOINT = 768;
+
+export function useIsMobile() {
+ const [isMobile, setIsMobile] = React.useState(
+ undefined,
+ );
+
+ React.useEffect(() => {
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
+ const onChange = () => {
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
+ };
+ mql.addEventListener("change", onChange);
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
+ return () => mql.removeEventListener("change", onChange);
+ }, []);
+
+ return !!isMobile;
+}
diff --git a/src/bills/components/ui/utils.ts b/src/bills/components/ui/utils.ts
new file mode 100644
index 0000000..a5ef193
--- /dev/null
+++ b/src/bills/components/ui/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/src/bills/consts/general.ts b/src/bills/consts/general.ts
new file mode 100644
index 0000000..77ed50f
--- /dev/null
+++ b/src/bills/consts/general.ts
@@ -0,0 +1,11 @@
+export const BUILD_CANADA_URL = "https://www.buildcanada.com";
+
+export const PROJECT_NAME = "Builder MP";
+
+export const GOOGLE_ANALYTICS_ID = "G-VFXPGBE1PR";
+export const BUILD_CANADA_TWITTER_HANDLE = "@buildcanada";
+
+// Revalidation intervals (in seconds)
+// Note: Route segment configs (export const revalidate in page.tsx files) must use literal values
+// due to Next.js static analysis requirements. Use these constants only for runtime fetch calls.
+export const BILL_API_REVALIDATE_INTERVAL = 600; // Bill API data cache (fetch revalidation)
diff --git a/src/bills/env.ts b/src/bills/env.ts
new file mode 100644
index 0000000..f37dadd
--- /dev/null
+++ b/src/bills/env.ts
@@ -0,0 +1,69 @@
+type NonEmpty = T & { __brand: "NonEmpty" };
+
+function required(name: string, value: string | undefined): NonEmpty {
+ if (!value || value.trim() === "") {
+ throw new Error(`Missing required env var: ${name}`);
+ }
+ return value as NonEmpty;
+}
+
+function optional(
+ _name: string,
+ value: string | undefined,
+): string | undefined {
+ return value && value.trim() !== "" ? value : undefined;
+}
+
+const ENDPOINT = "https://api.civicsproject.org";
+
+export const env = {
+ NODE_ENV: process.env.NODE_ENV || "development",
+ NEXTAUTH_URL: optional("NEXTAUTH_URL", process.env.NEXTAUTH_URL),
+ NEXTAUTH_SECRET: optional("NEXTAUTH_SECRET", process.env.NEXTAUTH_SECRET),
+ AUTH_SECRET: optional("AUTH_SECRET", process.env.AUTH_SECRET),
+ GOOGLE_CLIENT_ID:
+ optional("GOOGLE_CLIENT_ID", process.env.GOOGLE_CLIENT_ID) ||
+ optional("GOOGLE_ID", process.env.GOOGLE_ID),
+ GOOGLE_CLIENT_SECRET:
+ optional("GOOGLE_CLIENT_SECRET", process.env.GOOGLE_CLIENT_SECRET) ||
+ optional("GOOGLE_SECRET", process.env.GOOGLE_SECRET),
+ CIVICS_PROJECT_API_KEY: optional(
+ "CIVICS_PROJECT_API_KEY",
+ process.env.CIVICS_PROJECT_API_KEY,
+ ),
+ CIVICS_PROJECT_BASE_URL: optional("CIVICS_PROJECT_BASE_URL", ENDPOINT),
+ MONGO_URI: optional(
+ "MONGO_URI",
+ (process.env.MONGO_URI || process.env.MONGODB_URI)?.trim(),
+ ),
+ NEXT_PUBLIC_APP_URL: optional(
+ "NEXT_PUBLIC_APP_URL",
+ process.env.NEXT_PUBLIC_APP_URL,
+ ),
+ BILLS_DEV_OPEN_ACCESS: optional(
+ "BILLS_DEV_OPEN_ACCESS",
+ process.env.BILLS_DEV_OPEN_ACCESS,
+ ),
+};
+
+/**
+ * DEV ONLY: when true, admin/edit access is open to everyone — including users
+ * who are not signed in, and any Google account is auto-allowed at sign-in.
+ *
+ * Requires BOTH a non-production NODE_ENV and an explicit `BILLS_DEV_OPEN_ACCESS=true`
+ * opt-in, so it can never be enabled by accident in a misconfigured environment
+ * (e.g. a self-hosted/staging deploy that forgot to set NODE_ENV=production).
+ */
+export const DEV_OPEN_ACCESS =
+ env.NODE_ENV !== "production" && env.BILLS_DEV_OPEN_ACCESS === "true";
+
+export function assertServerEnv() {
+ // Required for auth
+ required("NEXTAUTH_URL", env.NEXTAUTH_URL);
+ required(
+ "NEXTAUTH_SECRET or AUTH_SECRET",
+ env.NEXTAUTH_SECRET || env.AUTH_SECRET,
+ );
+ required("GOOGLE_CLIENT_ID/GOOGLE_ID", env.GOOGLE_CLIENT_ID);
+ required("GOOGLE_CLIENT_SECRET/GOOGLE_SECRET", env.GOOGLE_CLIENT_SECRET);
+}
diff --git a/src/bills/lib/auth-guards.ts b/src/bills/lib/auth-guards.ts
new file mode 100644
index 0000000..666b51c
--- /dev/null
+++ b/src/bills/lib/auth-guards.ts
@@ -0,0 +1,46 @@
+import { redirect } from "next/navigation";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/bills/lib/auth";
+import { DEV_OPEN_ACCESS } from "@/bills/env";
+import { connectToDatabase } from "@/bills/lib/mongoose";
+import { User } from "@/bills/models/User";
+import { BASE_PATH } from "@/bills/utils/basePath";
+
+// Re-exported for the API routes that gate on it. Defined in env.ts so it can
+// be shared without creating an import cycle with auth.ts.
+export { DEV_OPEN_ACCESS };
+
+/**
+ * Server-side authentication guard that requires a valid authenticated user.
+ * Redirects to /unauthorized if:
+ * - No session exists
+ * - User email is not provided
+ * - User does not exist in the database
+ *
+ * @returns Object containing the session and database user
+ * @throws Redirects to /unauthorized if authentication fails
+ */
+export async function requireAuthenticatedUser() {
+ // DEV ONLY: open access — skip the session/allowlist checks entirely.
+ if (DEV_OPEN_ACCESS) {
+ return { session: null, dbUser: null };
+ }
+
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user?.email) {
+ redirect(`${BASE_PATH}/unauthorized`);
+ }
+
+ // Verify the signed-in user exists in DB; do not create
+ await connectToDatabase();
+ const dbUser = await User.findOne({
+ emailLower: session.user.email.toLowerCase(),
+ });
+
+ if (!dbUser) {
+ redirect(`${BASE_PATH}/unauthorized`);
+ }
+
+ return { session, dbUser };
+}
diff --git a/src/bills/lib/auth.ts b/src/bills/lib/auth.ts
new file mode 100644
index 0000000..2661a9e
--- /dev/null
+++ b/src/bills/lib/auth.ts
@@ -0,0 +1,96 @@
+import { NextAuthOptions } from "next-auth";
+import Google from "next-auth/providers/google";
+import { env, assertServerEnv, DEV_OPEN_ACCESS } from "@/bills/env";
+import { connectToDatabase } from "@/bills/lib/mongoose";
+import { User } from "@/bills/models/User";
+import { BASE_PATH } from "@/bills/utils/basePath";
+
+if (env.NODE_ENV !== "production") {
+ try {
+ assertServerEnv();
+ } catch (e) {
+ console.warn("[auth] env check:", e);
+ }
+ if (!env.NEXTAUTH_URL)
+ console.warn(
+ "[auth] Missing NEXTAUTH_URL (e.g. http://localhost:3000 in dev).",
+ );
+}
+
+export const authOptions: NextAuthOptions = {
+ providers: [
+ Google({
+ clientId: env.GOOGLE_CLIENT_ID || "",
+ clientSecret: env.GOOGLE_CLIENT_SECRET || "",
+ authorization: {
+ params: { scope: "openid email profile", prompt: "consent" },
+ },
+ }),
+ ],
+ debug: process.env.NODE_ENV !== "production",
+ session: { strategy: "jwt" },
+ callbacks: {
+ async signIn({ user }) {
+ const email = user?.email?.trim().toLowerCase();
+ if (!email) return false;
+
+ // Prefer DB-backed allowlist. Fall back to stub if DB not configured.
+ try {
+ await connectToDatabase();
+ const now = new Date();
+ const existing = await User.findOne({ emailLower: email });
+ if (!existing) {
+ // DEV ONLY: auto-create + allow any signed-in account so every user
+ // has admin access locally. Gated on explicit BILLS_DEV_OPEN_ACCESS
+ // opt-in; never runs in production.
+ if (DEV_OPEN_ACCESS) {
+ console.warn(
+ `[auth] DEV: auto-creating + allowing ${email} (admin access on for all users).`,
+ );
+ await User.create({
+ email: user?.email,
+ emailLower: email,
+ name: user?.name ?? null,
+ allowed: true,
+ lastLoginAt: now,
+ });
+ return true;
+ }
+ return false;
+ }
+ existing.name = user?.name ?? existing.name;
+ existing.image = user?.image ?? existing.image;
+ existing.lastLoginAt = now;
+ await existing.save();
+ // DEV ONLY: allow regardless of the allowlist flag.
+ if (DEV_OPEN_ACCESS) return true;
+ return !!existing.allowed;
+ } catch (err) {
+ if (env.NODE_ENV !== "production") {
+ console.warn("[auth] DB check failed, denying sign-in:", err);
+ }
+ return false;
+ }
+ },
+ async jwt({ token, user }) {
+ if (user?.email) {
+ token.email = user.email;
+ token.name = user.name;
+ token.picture = user?.image;
+ }
+ return token;
+ },
+ async session({ session, token }) {
+ if (session?.user) {
+ session.user.email = token.email;
+ session.user.name = token.name;
+ session.user.image = token.picture;
+ }
+ return session;
+ },
+ },
+ pages: {
+ signIn: `${BASE_PATH}/sign-in`,
+ },
+ secret: env.NEXTAUTH_SECRET || env.AUTH_SECRET,
+};
diff --git a/src/bills/lib/auth/allowed-users.ts b/src/bills/lib/auth/allowed-users.ts
new file mode 100644
index 0000000..db5a7ee
--- /dev/null
+++ b/src/bills/lib/auth/allowed-users.ts
@@ -0,0 +1,2 @@
+// Temporary stub. Replace with DB lookup later.
+
diff --git a/src/bills/lib/auth/config.ts b/src/bills/lib/auth/config.ts
new file mode 100644
index 0000000..e30cd8e
--- /dev/null
+++ b/src/bills/lib/auth/config.ts
@@ -0,0 +1,2 @@
+// Deprecated old config kept intentionally empty to avoid accidental imports
+export {};
diff --git a/src/bills/lib/auth/types.d.ts b/src/bills/lib/auth/types.d.ts
new file mode 100644
index 0000000..caf39db
--- /dev/null
+++ b/src/bills/lib/auth/types.d.ts
@@ -0,0 +1,21 @@
+/* eslint-disable */
+import "next-auth";
+import "next-auth/jwt";
+
+declare module "next-auth" {
+ interface Session {
+ user?: {
+ name?: string | null;
+ email?: string | null;
+ image?: string | null;
+ };
+ }
+}
+
+declare module "next-auth/jwt" {
+ interface JWT {
+ name?: string | null;
+ email?: string | null;
+ picture?: string | null;
+ }
+}
diff --git a/src/bills/lib/mongoose.ts b/src/bills/lib/mongoose.ts
new file mode 100644
index 0000000..f83d77e
--- /dev/null
+++ b/src/bills/lib/mongoose.ts
@@ -0,0 +1,45 @@
+import mongoose from "mongoose";
+import { env } from "@/bills/env";
+
+const MONGO_URI = env.MONGO_URI || "";
+
+export const DATABASE_NAME = "bills";
+
+if (!MONGO_URI) {
+ // In dev we don't throw to avoid crashing builds without env; callers can decide
+ console.warn(
+ "MONGO_URI is not set. Mongoose connections will fail at runtime.",
+ );
+}
+
+type MongooseCache = {
+ conn: typeof mongoose | null;
+ promise: Promise | null;
+};
+const globalAny = global as unknown as { mongoose?: MongooseCache } & Record<
+ string,
+ unknown
+>;
+const cached: MongooseCache = globalAny.mongoose ?? {
+ conn: null,
+ promise: null,
+};
+if (!globalAny.mongoose) {
+ globalAny.mongoose = cached;
+}
+
+export async function connectToDatabase(): Promise {
+ if (cached.conn) return cached.conn;
+ if (!cached.promise) {
+ cached.promise = mongoose.connect(MONGO_URI, {
+ dbName: DATABASE_NAME,
+ serverSelectionTimeoutMS: 3000,
+ socketTimeoutMS: 3000,
+ connectTimeoutMS: 3000,
+ maxPoolSize: 5,
+ bufferCommands: false,
+ });
+ }
+ cached.conn = await cached.promise;
+ return cached.conn;
+}
diff --git a/src/bills/lib/utils.ts b/src/bills/lib/utils.ts
new file mode 100644
index 0000000..a5ef193
--- /dev/null
+++ b/src/bills/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/src/bills/migrations/1.ts b/src/bills/migrations/1.ts
new file mode 100644
index 0000000..46ba33e
--- /dev/null
+++ b/src/bills/migrations/1.ts
@@ -0,0 +1,28 @@
+/**
+ * Migration 1: Update final_judgment from "neutral" to "abstain" for social issue bills
+ *
+ * This migration updates all bills where isSocialIssue is true and changes their
+ * final_judgment from "neutral" to "abstain".
+ */
+
+import { Bill } from "@/bills/models/Bill";
+
+export async function up() {
+ console.log(
+ "Starting migration: Update neutral to abstain for social issues",
+ );
+
+ const result = await Bill.updateMany(
+ {
+ isSocialIssue: true,
+ },
+ {
+ $set: {
+ final_judgment: "abstain",
+ },
+ },
+ );
+
+ console.log(`Updated ${result.modifiedCount} bills from neutral to abstain`);
+ console.log(`Matched ${result.matchedCount} bills with the criteria`);
+}
diff --git a/src/bills/models/Bill.ts b/src/bills/models/Bill.ts
new file mode 100644
index 0000000..38185ea
--- /dev/null
+++ b/src/bills/models/Bill.ts
@@ -0,0 +1,147 @@
+import mongoose, { Schema, model, models } from "mongoose";
+
+// Interface for each tenet evaluation object within the 'tenet_evaluations' array
+export interface TenetEvaluation {
+ id: number;
+ title: string;
+ alignment: "conflicts" | "aligns" | "neutral";
+ explanation: string;
+}
+
+// Existing interfaces for vote and stage records
+export interface VoteRecord {
+ chamber: string;
+ date: Date;
+ motion?: string;
+ result: string;
+ yeas?: number;
+ nays?: number;
+ abstentions?: number;
+ source?: string; // link to official vote record
+}
+
+export interface StageRecord {
+ stage: string; // e.g., "First Reading", "Committee", "Third Reading"
+ state: string; // e.g., "Completed", "In Committee"
+ house: string; // e.g., "House of Commons", "Senate"
+ date: Date;
+}
+
+// Updated main interface for the Bill document
+export interface BillDocument extends mongoose.Document {
+ billId: string; // e.g., C-18, S-5
+ parliamentNumber?: number; // e.g., 45
+ sessionNumber?: number; // e.g., 1
+ title: string;
+ short_title?: string;
+ summary: string; // human-friendly summary
+
+ // Replaced the old 'analysis' object with new, top-level fields
+ tenet_evaluations: TenetEvaluation[];
+ final_judgment: string; // "yes", "no", or "abstain"
+ rationale: string;
+ needs_more_info: boolean;
+ missing_details: string[];
+ steel_man: string;
+
+ status: string;
+
+ sponsorName?: string;
+ sponsorParty?: string;
+ chamber: string;
+ genres?: string[]; // categories/tags
+ supportedRegion?: string; // e.g., "Canada"
+ introducedOn?: Date;
+ lastUpdatedOn?: Date;
+ source?: string; // canonical source link
+ stages?: StageRecord[];
+ votes?: VoteRecord[];
+ billTextsCount?: number; // track number of bill texts to detect changes
+ isSocialIssue?: boolean;
+ question_period_questions?: Array<{ question: string }>;
+}
+
+// Schema for the Tenet Evaluation sub-document
+const TenetEvaluationSchema = new Schema(
+ {
+ id: { type: Number, required: true },
+ title: { type: String, required: true },
+ alignment: {
+ type: String,
+ enum: ["conflicts", "aligns", "neutral"],
+ required: true,
+ },
+ explanation: { type: String, required: true },
+ },
+ { _id: false },
+); // _id: false prevents Mongoose from adding an _id to each sub-document
+
+// Existing schemas for vote and stage records
+const VoteSchema = new Schema({
+ chamber: {
+ type: String,
+ enum: ["House of Commons", "Senate"],
+ required: true,
+ },
+ date: { type: Date, required: true },
+ motion: { type: String },
+ result: { type: String, required: true },
+ yeas: { type: Number },
+ nays: { type: Number },
+ abstentions: { type: Number },
+ source: { type: String },
+});
+
+const StageSchema = new Schema({
+ stage: { type: String, required: true },
+ state: { type: String },
+ house: { type: String },
+ date: { type: Date },
+});
+
+// Updated main Bill Schema
+const BillSchema = new Schema(
+ {
+ billId: { type: String, required: true, index: true },
+ parliamentNumber: { type: Number },
+ sessionNumber: { type: Number },
+ title: { type: String, required: true },
+ short_title: { type: String },
+ summary: { type: String, required: true },
+
+ // Replaced the old 'analysis' sub-schema with new fields
+ tenet_evaluations: { type: [TenetEvaluationSchema], default: [] },
+ final_judgment: { type: String, enum: ["yes", "no", "abstain"] },
+ rationale: { type: String },
+ needs_more_info: { type: Boolean, default: false },
+ missing_details: { type: [String], default: [] },
+ steel_man: { type: String },
+
+ status: { type: String, required: true },
+ sponsorName: { type: String },
+ sponsorParty: { type: String },
+ chamber: { type: String, required: true },
+ genres: [{ type: String }],
+ supportedRegion: { type: String },
+ introducedOn: { type: Date },
+ lastUpdatedOn: { type: Date },
+ source: { type: String },
+ stages: { type: [StageSchema], default: [] },
+ votes: { type: [VoteSchema], default: [] },
+ billTextsCount: { type: Number },
+ isSocialIssue: { type: Boolean, default: false },
+ question_period_questions: {
+ type: [{ question: { type: String, required: true } }],
+ default: [],
+ },
+ },
+ { timestamps: true },
+);
+
+BillSchema.index(
+ { billId: 1, parliamentNumber: 1 },
+ { unique: true, sparse: true },
+);
+BillSchema.index({ title: "text", short_title: "text", summary: "text" });
+
+export const Bill = models.Bill || model("Bill", BillSchema);
diff --git a/src/bills/models/User.ts b/src/bills/models/User.ts
new file mode 100644
index 0000000..e06aa0d
--- /dev/null
+++ b/src/bills/models/User.ts
@@ -0,0 +1,29 @@
+import mongoose, { Schema, model, models } from "mongoose";
+
+export interface UserDocument extends mongoose.Document {
+ email: string;
+ emailLower: string;
+ name?: string | null;
+ image?: string | null;
+ allowed: boolean;
+ /** Admins may manage the allowlist (create/update other users). */
+ isAdmin: boolean;
+ lastLoginAt?: Date;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+const UserSchema = new Schema(
+ {
+ email: { type: String, required: true },
+ emailLower: { type: String, required: true, unique: true, index: true },
+ name: { type: String },
+ image: { type: String },
+ allowed: { type: Boolean, default: false },
+ isAdmin: { type: Boolean, default: false },
+ lastLoginAt: { type: Date },
+ },
+ { timestamps: true },
+);
+
+export const User = models.User || model("User", UserSchema);
diff --git a/src/bills/prompt/summary-and-vote-prompt.ts b/src/bills/prompt/summary-and-vote-prompt.ts
new file mode 100644
index 0000000..7a3ad9a
--- /dev/null
+++ b/src/bills/prompt/summary-and-vote-prompt.ts
@@ -0,0 +1,156 @@
+export const TENETS = {
+ 1: "Canada should aim to be the world's most prosperous country.",
+ 2: "Promote economic freedom, ambition, and breaking from bureaucratic inertia (reduce red tape).",
+ 3: "Drive national productivity and global competitiveness.",
+ 4: "Grow exports of Canadian products and resources.",
+ 5: "Encourage investment, innovation, and resource development.",
+ 6: "Deliver better public services at lower cost (government efficiency).",
+ 7: "Reform taxes to incentivize work, risk-taking, and innovation.",
+ 8: "Focus on large-scale prosperity, not incrementalism.",
+};
+
+const SOCIAL_ISSUE_GRADING = `
+For social issue grading:
+ Positive signals (any one can qualify if it is the main focus):
+ - Recognition/commemoration: heritage months/days, awareness days, honorary observances, national symbols (e.g., national bird/anthem/flag changes).
+ - Rights & identity: assisted dying, abortion, marriage/family status, gender identity/expression, LGBTQ+ rights, indigenous rights, disability rights, hate speech/hate crimes, religious freedoms.
+ - Culture & language: multiculturalism, official languages, curriculum content on culture/history, media/broadcast standards on content/morality.
+ - Civil liberties & expression: protests/assembly, press/speech regulations primarily about expression or social values.
+
+ Negative/Non-social (unless rights/identity are the central focus):
+ - Core economics/fiscal: budgets, taxation, appropriations, trade, monetary policy.
+ - Infrastructure/operations: transportation, energy, housing supply mechanics, procurement, zoning mechanics.
+ - Technical/administrative: agency powers, forms, reporting, definitions not tied to values/identity.
+ - Environmental/health/safety mainly as regulation/operations (e.g., emissions standards, workplace safety), unless framed around rights/identity or moral controversy.
+
+ Tie-breakers:
+ - Classify based on primary purpose, not incidental mentions.
+ - If the bill materially creates or changes an observance/day/month or declares a national symbol, classify as social issue = yes.
+ - If mixed, choose "no".
+`;
+
+export const SUMMARY_AND_VOTE_PROMPT = `
+## Your Role
+
+You are analyzing Canadian legislation. You must assess whether the bill aligns with Build Canada's Core Tenets:
+ 1. ${TENETS[1]}
+ 2. ${TENETS[2]}
+ 3. ${TENETS[3]}
+ 4. ${TENETS[4]}
+ 5. ${TENETS[5]}
+ 6. ${TENETS[6]}
+ 7. ${TENETS[7]}
+ 8. ${TENETS[8]}
+
+## Social Issue Grading
+
+ ${SOCIAL_ISSUE_GRADING}
+
+## General Guidelines
+
+ For general guidelines:
+ - Be critical.
+ - Bias to the overall wellbeing of Canadians.
+ - Use markdown formatting.
+ - Use bullet points to summarize the highlights of the bill.
+ - Do not include any other text in the summary.
+ - Never self reference Build Canada.
+ - Never advocate for adding more red tape.
+ - Always advocate for safety and security for Canadians.
+ - Never self reference Build Canada, or use "We" or "Our", use the idea of "Builders" instead.
+ - Never self reference the tenents outside of the tenet evaluations.
+
+ ## Your Task
+
+ 1. Read the bill.
+ 2. Provide a concise summary of what the bill does in plain language (3-5 sentences).
+ 3. Evaluate the bill against the 8 tenets above:
+ 3.1 Does it clearly support one or more tenets?
+ 3.2 Does it conflict with one or more tenets?
+ 3.3 Is its impact neutral or unclear?
+ 4. Give a final judgment (choose exactly one; output in lowercase):
+ 4.1 Output "abstain" if the bill is primarily a social issue (per the social-issue criteria above).
+ 4.2 Output “yes” if the bill aligns overall with Build Canada's tenets.
+ 4.3 Output “no” if it conflicts overall with Build Canada's tenets.
+ 5. Generate 3 critical questions, pertaining to this and only about this bill, for Question Period in the House of Commons phrased in a way that a Member of Parliament might actually ask in Question Period. Omit any prefix like "Mr. Speaker" or "Madam Speaker".
+
+ Important: All enum values must be lowercase exactly as specified.
+ - tenet_evaluations.alignment: aligns|conflicts|neutral
+ - final_judgment: yes|no|abstain
+ - is_social_issue: yes|no
+ - Never mention the tenents in the summary, questions, or rationale.
+
+ Output format (return valid JSON only):
+
+ \`\`\`json
+ {
+ "summary": "Your 3-5 sentence summary here in plain language. Use bullet points to summarize the highlights of the bill. Do not include any other text in the summary. Use markdown formatting.",
+ "short_title": "A short title for the bill. Use 1-2 words to describe the bill.",
+ "tenet_evaluations": [
+ {
+ "id": 1,
+ "title": "${TENETS[1]}",
+ "alignment": "aligns|conflicts|neutral",
+ "explanation": "Short explanation of how this bill relates to this tenet"
+ },
+ {
+ "id": 2,
+ "title": "${TENETS[2]}",
+ "alignment": "aligns|conflicts|neutral",
+ "explanation": "Short explanation of how this bill relates to this tenet"
+ },
+ {
+ "id": 3,
+ "title": "${TENETS[3]}",
+ "alignment": "aligns|conflicts|neutral",
+ "explanation": "Short explanation of how this bill relates to this tenet"
+ },
+ {
+ "id": 4,
+ "title": "${TENETS[4]}",
+ "alignment": "aligns|conflicts|neutral",
+ "explanation": "Short explanation of how this bill relates to this tenet"
+ },
+ {
+ "id": 5,
+ "title": "${TENETS[5]}",
+ "alignment": "aligns|conflicts|neutral",
+ "explanation": "Short explanation of how this bill relates to this tenet"
+ },
+ {
+ "id": 6,
+ "title": "${TENETS[6]}",
+ "alignment": "aligns|conflicts|neutral",
+ "explanation": "Short explanation of how this bill relates to this tenet"
+ },
+ {
+ "id": 7,
+ "title": "${TENETS[7]}",
+ "alignment": "aligns|conflicts|neutral",
+ "explanation": "Short explanation of how this bill relates to this tenet"
+ },
+ {
+ "id": 8,
+ "title": "${TENETS[8]}",
+ "alignment": "aligns|conflicts|neutral",
+ "explanation": "Short explanation of how this bill relates to this tenet"
+ }
+ ],
+ "question_period_questions": [
+ {
+ "question": "A crticial question, pertaining to this and only about this bill, for Question Period in the House of Commons phrased in a way that a Member of Parliament might actually ask in Question Period. Omit any prefix like "Mr. Speaker" or "Madam Speaker""
+ },
+ {
+ "question": "A crticial question, pertaining to this and only about this bill, for Question Period in the House of Commons phrased in a way that a Member of Parliament might actually ask in Question Period. Omit any prefix like "Mr. Speaker" or "Madam Speaker""
+ },
+ {
+ "question": "A crticial question, pertaining to this and only about this bill, for Question Period in the House of Commons phrased in a way that a Member of Parliament might actually ask in Question Period. Omit any prefix like "Mr. Speaker" or "Madam Speaker""
+ },
+
+ ],
+ "final_judgment": "yes|no|abstain",
+ "rationale": "2 sentences explaining the overall judgment and then bullet points explaining the rationale for the judgment and suggestions for what we might change. Use markdown formatting.",
+ "is_social_issue": "yes|no"
+ }
+ \`\`\`
+`;
diff --git a/src/bills/server/get-all-bills-from-civics-project.ts b/src/bills/server/get-all-bills-from-civics-project.ts
new file mode 100644
index 0000000..46e3df6
--- /dev/null
+++ b/src/bills/server/get-all-bills-from-civics-project.ts
@@ -0,0 +1,20 @@
+import { BillSummary } from "@/app/bills/types";
+import { env } from "@/bills/env";
+
+export async function getBillsFromCivicsProject(): Promise {
+ const response = await fetch(
+ `${env.CIVICS_PROJECT_BASE_URL}/canada/bills/45`,
+ {
+ cache: "no-store",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${process.env.CIVICS_PROJECT_API_KEY}`,
+ },
+ },
+ );
+ if (!response.ok) {
+ throw new Error("Failed to fetch bills from API");
+ }
+ const { data } = await response.json();
+ return Array.isArray(data) ? (data as BillSummary[]) : (data?.bills ?? []);
+}
diff --git a/src/bills/server/get-all-bills-from-db.ts b/src/bills/server/get-all-bills-from-db.ts
new file mode 100644
index 0000000..ac4dd57
--- /dev/null
+++ b/src/bills/server/get-all-bills-from-db.ts
@@ -0,0 +1,34 @@
+import "server-only";
+import { connectToDatabase } from "@/bills/lib/mongoose";
+import { Bill } from "@/bills/models/Bill";
+import type { BillDocument } from "@/bills/models/Bill";
+import { env } from "@/bills/env";
+
+export const getAllBillsFromDB = async (): Promise => {
+ const uri = env.MONGO_URI || "";
+ const hasValidMongoUri =
+ uri.startsWith("mongodb://") || uri.startsWith("mongodb+srv://");
+ if (!hasValidMongoUri) {
+ console.warn("!!! No valid MongoDB URI found, returning empty bills array");
+ return [];
+ }
+
+ try {
+ await connectToDatabase();
+ // The bills list only renders/filters summary-level fields. Exclude the
+ // heavy analysis fields (tenet evaluations, question period questions,
+ // votes, steel man) — they are dropped during the page merge anyway, so
+ // fetching them only bloats the query and the client payload.
+ const bills = await Bill.find({})
+ .select("-tenet_evaluations -question_period_questions -votes -steel_man")
+ .lean()
+ .exec();
+ console.log(`Fetched ${bills.length} bills from MongoDB`);
+
+ // Return lean results directly so date fields stay as Date instances
+ return bills as unknown as BillDocument[];
+ } catch (error) {
+ console.error("Error fetching bills from MongoDB:", error);
+ return [];
+ }
+};
diff --git a/src/bills/server/get-bill-by-id-from-db.ts b/src/bills/server/get-bill-by-id-from-db.ts
new file mode 100644
index 0000000..840d806
--- /dev/null
+++ b/src/bills/server/get-bill-by-id-from-db.ts
@@ -0,0 +1,24 @@
+import "server-only";
+import { cache } from "react";
+import { connectToDatabase } from "@/bills/lib/mongoose";
+import { Bill } from "@/bills/models/Bill";
+import type { BillDocument } from "@/bills/models/Bill";
+import { env } from "@/bills/env";
+
+// Wrapped in React.cache so repeated lookups for the same bill within a single
+// server render (e.g. the page body and a helper both resolving the same id)
+// share one query instead of re-hitting Mongo.
+export const getBillByIdFromDB = cache(
+ async (billId: string): Promise => {
+ const uri = env.MONGO_URI || "";
+ const hasValidMongoUri =
+ uri.startsWith("mongodb://") || uri.startsWith("mongodb+srv://");
+ if (!hasValidMongoUri) return null;
+
+ await connectToDatabase();
+ const existing = (await Bill.findOne({ billId })
+ .lean()
+ .exec()) as BillDocument | null;
+ return existing;
+ },
+);
diff --git a/src/bills/server/get-unified-bill-by-id.ts b/src/bills/server/get-unified-bill-by-id.ts
new file mode 100644
index 0000000..07f1eff
--- /dev/null
+++ b/src/bills/server/get-unified-bill-by-id.ts
@@ -0,0 +1,16 @@
+import "server-only";
+import { getBillByIdFromDB } from "@/bills/server/get-bill-by-id-from-db";
+import {
+ fromBuildCanadaDbBill,
+ type UnifiedBill,
+} from "@/bills/utils/billConverters";
+
+export async function getUnifiedBillById(
+ id: string,
+): Promise {
+ const dbBill = await getBillByIdFromDB(id);
+ if (dbBill) {
+ return fromBuildCanadaDbBill(dbBill);
+ }
+ return null;
+}
diff --git a/src/bills/services/billApi.ts b/src/bills/services/billApi.ts
new file mode 100644
index 0000000..fcea601
--- /dev/null
+++ b/src/bills/services/billApi.ts
@@ -0,0 +1,536 @@
+import { xmlToMarkdown } from "@/bills/utils/xml-to-md/xml-to-md.util";
+import { SUMMARY_AND_VOTE_PROMPT } from "@/bills/prompt/summary-and-vote-prompt";
+import OpenAI from "openai";
+import { BILL_API_REVALIDATE_INTERVAL } from "@/bills/consts/general";
+import { env } from "@/bills/env";
+import type { BillDocument } from "@/bills/models/Bill";
+
+export type ApiStage = {
+ stage: string;
+ state: string;
+ house: string;
+ date: string;
+};
+
+export type ApiBillDetail = {
+ _id?: string;
+ internalID?: string;
+ billID: string;
+ title: string;
+ shortTitle?: string;
+ header: string;
+ summary?: string;
+ genres?: string[];
+ date: string;
+ updatedAt?: string;
+ status: string;
+ stage?: string;
+ stages?: ApiStage[];
+ sponsorParty?: string;
+ sponsorID?: string[];
+ sponsorName?: string[];
+ parliamentNumber: number;
+ sessionNumber: number;
+ supportedRegion?: string;
+ interestLevel?: number;
+ source?: string;
+ votes?: unknown[];
+ billTexts?: unknown[];
+};
+
+const CANADIAN_PARLIAMENT_NUMBER = 45;
+
+/** Types for AI analysis results */
+export interface BillAnalysis {
+ summary: string;
+ short_title?: string;
+ tenet_evaluations: Array<{
+ id: number;
+ title: string;
+ alignment: "aligns" | "conflicts" | "neutral";
+ explanation: string;
+ }>;
+ final_judgment: "yes" | "no" | "abstain";
+ rationale?: string;
+ needs_more_info: boolean;
+ missing_details: string[];
+ steel_man: string;
+ question_period_questions?: Array<{ question: string }>;
+}
+
+export async function getBillFromCivicsProjectApi(
+ billId: string,
+): Promise {
+ const URL = `${env.CIVICS_PROJECT_BASE_URL}/canada/bills/${CANADIAN_PARLIAMENT_NUMBER}/${billId}`;
+ console.log({URL});
+ const response = await fetch(URL, {
+ // Cache individual bills.
+ ...(process.env.NODE_ENV === "production"
+ ? { next: { revalidate: BILL_API_REVALIDATE_INTERVAL } }
+ : { cache: "no-store" }),
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${process.env.CIVICS_PROJECT_API_KEY}`,
+ },
+ });
+ if (!response.ok) {
+ const body = await response.text().catch(() => "");
+ console.error("Failed to fetch bill details from Civics Project API", {
+ billId,
+ url: URL,
+ status: response.status,
+ statusText: response.statusText,
+ body: body.slice(0, 1000),
+ });
+ throw new Error(
+ `Failed to fetch bill details for "${billId}": ${response.status} ${response.statusText} (${URL})`,
+ );
+ }
+ const json = await response.json();
+ const data = (json?.data?.bill ?? json?.data ?? json) as
+ | ApiBillDetail
+ | ApiBillDetail[]
+ | null;
+ if (!data) return null;
+ if (Array.isArray(data)) {
+ return (
+ data.find((b) => b.billID?.toLowerCase() === billId.toLowerCase()) ?? null
+ );
+ }
+ return data;
+}
+
+export async function summarizeBillText(input: string): Promise {
+ if (!process.env.OPENAI_API_KEY) {
+ console.log("No OPENAI API key, using fallback analysis");
+ // Fallback analysis
+ const text = input?.trim() || "";
+ const truncatedSummary =
+ text.length <= 500 ? text : `${text.slice(0, 500)}…`;
+
+ return {
+ summary: truncatedSummary || "No bill text available for analysis.",
+ short_title: undefined,
+ tenet_evaluations: [
+ {
+ id: 1,
+ title: "Canada should aim to be the world's most prosperous country",
+ alignment: "neutral",
+ explanation: "Unable to analyze without AI",
+ },
+ {
+ id: 2,
+ title:
+ "Promote economic freedom, ambition, and breaking from bureaucratic inertia",
+ alignment: "neutral",
+ explanation: "Unable to analyze without AI",
+ },
+ {
+ id: 3,
+ title: "Drive national productivity and global competitiveness",
+ alignment: "neutral",
+ explanation: "Unable to analyze without AI",
+ },
+ {
+ id: 4,
+ title: "Grow exports of Canadian products and resources",
+ alignment: "neutral",
+ explanation: "Unable to analyze without AI",
+ },
+ {
+ id: 5,
+ title: "Encourage investment, innovation, and resource development",
+ alignment: "neutral",
+ explanation: "Unable to analyze without AI",
+ },
+ {
+ id: 6,
+ title:
+ "Deliver better public services at lower cost (government efficiency)",
+ alignment: "neutral",
+ explanation: "Unable to analyze without AI",
+ },
+ {
+ id: 7,
+ title:
+ "Reform taxes to incentivize work, risk-taking, and innovation",
+ alignment: "neutral",
+ explanation: "Unable to analyze without AI",
+ },
+ {
+ id: 8,
+ title: "Focus on large-scale prosperity, not incrementalism",
+ alignment: "neutral",
+ explanation: "Unable to analyze without AI",
+ },
+ ],
+ final_judgment: "abstain",
+ rationale: undefined,
+ needs_more_info: true,
+ missing_details: ["AI analysis capabilities required"],
+ steel_man:
+ "The steel man for this bill is the bill that aligns with the tenets of Build Canada.",
+ question_period_questions: [],
+ };
+ }
+
+ try {
+ console.log("Analyzing bill text with AI");
+ const OpenAIClient = new OpenAI();
+
+ const prompt = `${SUMMARY_AND_VOTE_PROMPT}\n\nBill Text:\n${input}`;
+ const response = await OpenAIClient.responses.create({
+ model: "gpt-5",
+ input: prompt,
+ reasoning: {
+ effort: "high",
+ },
+ });
+ const responseText = response.output_text;
+
+ // Parse JSON response
+ try {
+ const parsed = JSON.parse(responseText);
+ const rawFj = String(parsed.final_judgment || "")
+ .trim()
+ .toLowerCase();
+ const normalizedFj: "yes" | "no" | "abstain" =
+ rawFj === "yes" || rawFj === "no" || rawFj === "abstain"
+ ? rawFj
+ : "abstain";
+ const analysis: BillAnalysis = {
+ summary: parsed.summary ?? "",
+ short_title: parsed.short_title ?? undefined,
+ tenet_evaluations: parsed.tenet_evaluations ?? [],
+ final_judgment: normalizedFj,
+ rationale: parsed.rationale ?? undefined,
+ needs_more_info: parsed.needs_more_info ?? false,
+ missing_details: parsed.missing_details ?? [],
+ steel_man: parsed.steel_man ?? "",
+ question_period_questions: Array.isArray(
+ parsed.question_period_questions,
+ )
+ ? parsed.question_period_questions
+ : [],
+ };
+ return analysis;
+ } catch (parseError) {
+ console.error("Failed to parse AI response as JSON:", parseError);
+ console.log("Raw response:", responseText);
+
+ // Fallback to extracting summary from text response
+ const summaryMatch = responseText.match(
+ /summary['":\s]*["']([^"']+)["']/i,
+ );
+ const summary = summaryMatch
+ ? summaryMatch[1]
+ : `${responseText.slice(0, 500)}…`;
+
+ return {
+ summary,
+ short_title: undefined,
+ tenet_evaluations: [
+ {
+ id: 1,
+ title:
+ "Canada should aim to be the world's most prosperous country",
+ alignment: "neutral",
+ explanation: "JSON parse failed",
+ },
+ {
+ id: 2,
+ title:
+ "Promote economic freedom, ambition, and breaking from bureaucratic inertia",
+ alignment: "neutral",
+ explanation: "JSON parse failed",
+ },
+ {
+ id: 3,
+ title: "Drive national productivity and global competitiveness",
+ alignment: "neutral",
+ explanation: "JSON parse failed",
+ },
+ {
+ id: 4,
+ title: "Grow exports of Canadian products and resources",
+ alignment: "neutral",
+ explanation: "JSON parse failed",
+ },
+ {
+ id: 5,
+ title: "Encourage investment, innovation, and resource development",
+ alignment: "neutral",
+ explanation: "JSON parse failed",
+ },
+ {
+ id: 6,
+ title:
+ "Deliver better public services at lower cost (government efficiency)",
+ alignment: "neutral",
+ explanation: "JSON parse failed",
+ },
+ {
+ id: 7,
+ title:
+ "Reform taxes to incentivize work, risk-taking, and innovation",
+ alignment: "neutral",
+ explanation: "JSON parse failed",
+ },
+ {
+ id: 8,
+ title: "Focus on large-scale prosperity, not incrementalism",
+ alignment: "neutral",
+ explanation: "JSON parse failed",
+ },
+ ],
+ final_judgment: "abstain",
+ rationale: undefined,
+ needs_more_info: true,
+ missing_details: ["Valid AI response format"],
+ steel_man: "Analysis parsing failed",
+ question_period_questions: [],
+ };
+ }
+ } catch (error) {
+ console.error("Error analyzing bill:", error);
+ // Fallback analysis
+ const text = input?.trim() || "";
+ const truncatedSummary =
+ text.length <= 500 ? text : `${text.slice(0, 500)}…`;
+
+ return {
+ summary: truncatedSummary || "Error occurred during analysis.",
+ short_title: undefined,
+ tenet_evaluations: [
+ {
+ id: 1,
+ title: "Canada should aim to be the world's most prosperous country",
+ alignment: "neutral",
+ explanation: "Analysis failed",
+ },
+ {
+ id: 2,
+ title:
+ "Promote economic freedom, ambition, and breaking from bureaucratic inertia",
+ alignment: "neutral",
+ explanation: "Analysis failed",
+ },
+ {
+ id: 3,
+ title: "Drive national productivity and global competitiveness",
+ alignment: "neutral",
+ explanation: "Analysis failed",
+ },
+ {
+ id: 4,
+ title: "Grow exports of Canadian products and resources",
+ alignment: "neutral",
+ explanation: "Analysis failed",
+ },
+ {
+ id: 5,
+ title: "Encourage investment, innovation, and resource development",
+ alignment: "neutral",
+ explanation: "Analysis failed",
+ },
+ {
+ id: 6,
+ title:
+ "Deliver better public services at lower cost (government efficiency)",
+ alignment: "neutral",
+ explanation: "Analysis failed",
+ },
+ {
+ id: 7,
+ title:
+ "Reform taxes to incentivize work, risk-taking, and innovation",
+ alignment: "neutral",
+ explanation: "Analysis failed",
+ },
+ {
+ id: 8,
+ title: "Focus on large-scale prosperity, not incrementalism",
+ alignment: "neutral",
+ explanation: "Analysis failed",
+ },
+ ],
+ final_judgment: "abstain",
+ rationale: "Technical error during analysis",
+ needs_more_info: true,
+ missing_details: ["Technical issue resolution"],
+ steel_man: "Technical error during analysis",
+ question_period_questions: [],
+ };
+ }
+}
+
+export async function fetchBillMarkdown(
+ sourceUrl: string,
+): Promise {
+ try {
+ const xmlResponse = await fetch(sourceUrl, {
+ // Cache bill text for 1 hour in production since it rarely changes
+ ...(process.env.NODE_ENV === "production"
+ ? { next: { revalidate: 3600 } }
+ : { cache: "no-store" }),
+ });
+ if (xmlResponse.ok) {
+ const xml = await xmlResponse.text();
+ return xmlToMarkdown(xml);
+ }
+ } catch (error) {
+ console.error("Error fetching bill markdown:", error);
+ }
+ return null;
+}
+
+export async function onBillNotInDatabase(params: {
+ billId: string;
+ source?: string;
+ markdown?: string | null;
+ bill: ApiBillDetail;
+ analysis: BillAnalysis;
+ billTextsCount: number;
+ isSocialIssue: boolean;
+}): Promise {
+ console.log("Saving bill to database:", params.billId);
+
+ // Import here to avoid circular dependencies
+ const { connectToDatabase } = await import("@/bills/lib/mongoose");
+ const { Bill } = await import("@/bills/models/Bill");
+
+ try {
+ const { env } = await import("@/bills/env");
+ const uri = env.MONGO_URI || "";
+ const hasValidMongoUri =
+ uri.startsWith("mongodb://") || uri.startsWith("mongodb+srv://");
+
+ if (!hasValidMongoUri) {
+ console.warn("No valid MongoDB URI, skipping bill save");
+ return;
+ }
+
+ await connectToDatabase();
+
+ // Check if bill already exists and if we need to update it
+ const existing = (await Bill.findOne({ billId: params.billId })
+ .lean()
+ .exec()) as BillDocument | null;
+ if (existing) {
+ const countChanged = existing.billTextsCount !== params.billTextsCount;
+ const sourceChanged =
+ (existing.source || null) !== (params.source || null);
+ const existingQP = Array.isArray(existing.question_period_questions)
+ ? existing.question_period_questions
+ : [];
+ const newQP = Array.isArray(params.analysis.question_period_questions)
+ ? params.analysis.question_period_questions
+ : [];
+ const qpMissingOrDifferent =
+ (existingQP.length === 0 && newQP.length > 0) ||
+ JSON.stringify(existingQP) !== JSON.stringify(newQP);
+ const shortTitleMissing =
+ !existing.short_title &&
+ (params.bill.shortTitle || params.analysis.short_title);
+
+ if (
+ sourceChanged ||
+ countChanged ||
+ qpMissingOrDifferent ||
+ shortTitleMissing
+ ) {
+ if (sourceChanged) {
+ console.log(
+ `Updating bill ${params.billId} - source changed from ${existing.source || ""} to ${params.source || ""}`,
+ );
+ } else if (countChanged) {
+ console.log(
+ `Updating bill ${params.billId} - billTexts count changed from ${existing.billTextsCount} to ${params.billTextsCount}`,
+ );
+ } else if (qpMissingOrDifferent) {
+ console.log(
+ `Updating bill ${params.billId} - adding/updating Question Period questions (${existingQP.length} -> ${newQP.length})`,
+ );
+ } else if (shortTitleMissing) {
+ console.log(
+ `Updating bill ${params.billId} - adding missing short_title`,
+ );
+ }
+
+ await Bill.updateOne(
+ { billId: params.billId },
+ {
+ title: params.bill.title,
+ short_title: params.bill.shortTitle || params.analysis.short_title,
+ summary: params.analysis.summary,
+ tenet_evaluations: params.analysis.tenet_evaluations,
+ final_judgment: params.analysis.final_judgment,
+ rationale: params.analysis.rationale,
+ needs_more_info: params.analysis.needs_more_info,
+ missing_details: params.analysis.missing_details,
+ steel_man: params.analysis.steel_man,
+ status: params.bill.status,
+ sponsorParty: params.bill.sponsorParty,
+ genres: params.bill.genres,
+ billTextsCount: params.billTextsCount,
+ lastUpdatedOn: new Date(),
+ isSocialIssue: params.isSocialIssue,
+ question_period_questions: newQP,
+ source: params.source,
+ },
+ );
+ }
+ return;
+ }
+
+ // Convert API bill to DB format
+ const latestStageDate =
+ params.bill.stages && params.bill.stages.length > 0
+ ? params.bill.stages[params.bill.stages.length - 1].date
+ : (params.bill.updatedAt ?? params.bill.date);
+
+ const classifiedIsSocialIssue = params.isSocialIssue;
+
+ const billData = {
+ billId: params.bill.billID,
+ parliamentNumber: params.bill.parliamentNumber,
+ sessionNumber: params.bill.sessionNumber,
+ title: params.bill.title,
+ short_title: params.bill.shortTitle || params.analysis.short_title,
+ summary: params.analysis.summary,
+ tenet_evaluations: params.analysis.tenet_evaluations,
+ final_judgment: params.analysis.final_judgment,
+ rationale: params.analysis.rationale,
+ needs_more_info: params.analysis.needs_more_info,
+ missing_details: params.analysis.missing_details,
+ steel_man: params.analysis.steel_man,
+ status: params.bill.status,
+ sponsorParty: params.bill.sponsorParty,
+ chamber: params.bill.billID.startsWith("S")
+ ? "Senate"
+ : "House of Commons",
+ genres: params.bill.genres,
+ supportedRegion: params.bill.supportedRegion,
+ introducedOn: new Date(params.bill.date),
+ lastUpdatedOn: new Date(latestStageDate),
+ source: params.source || params.bill.source,
+ stages: params.bill.stages?.map((stage) => ({
+ stage: stage.stage,
+ state: stage.state,
+ house: stage.house,
+ date: new Date(stage.date),
+ })),
+ votes: [], // API doesn't provide detailed vote records
+ billTextsCount: params.billTextsCount,
+ isSocialIssue: classifiedIsSocialIssue,
+ question_period_questions:
+ params.analysis.question_period_questions ?? [],
+ };
+
+ await Bill.create(billData);
+ console.log("Successfully saved bill to database:", params.billId);
+ } catch (error) {
+ console.error("Error saving bill to database:", error);
+ // Don't throw - this shouldn't break the page if DB save fails
+ }
+}
diff --git a/src/bills/services/social-issue-grader.ts b/src/bills/services/social-issue-grader.ts
new file mode 100644
index 0000000..f5a8805
--- /dev/null
+++ b/src/bills/services/social-issue-grader.ts
@@ -0,0 +1,56 @@
+import { OpenAI } from "openai";
+
+const SOCIAL_ISSUE_GRADER_PROMPT = `
+You are a policy classifier. Your sole task is to decide whether a bill is primarily a social issue.
+
+Definition — Social Issue (for this classifier):
+A bill is a social issue if its primary purpose centers on culture, identity, values, or rights in society — including recognition/commemoration, national symbols, moral/ethical questions, language and heritage, religion, family/sex/reproduction, education content, speech/censorship, discrimination/equality, civil liberties, and community identity.
+
+Positive signals (any one can qualify if it is the main focus):
+- Recognition/commemoration: heritage months/days, awareness days, honorary observances, national symbols (e.g., national bird/anthem/flag changes).
+- Rights & identity: assisted dying, abortion, marriage/family status, gender identity/expression, LGBTQ+ rights, indigenous rights, disability rights, hate speech/hate crimes, religious freedoms.
+- Culture & language: multiculturalism, official languages, curriculum content on culture/history, media/broadcast standards on content/morality.
+- Civil liberties & expression: protests/assembly, press/speech regulations primarily about expression or social values.
+
+Negative/Non-social (unless rights/identity are the central focus):
+- Core economics/fiscal: budgets, taxation, appropriations, trade, monetary policy.
+- Infrastructure/operations: transportation, energy, housing supply mechanics, procurement, zoning mechanics.
+- Technical/administrative: agency powers, forms, reporting, definitions not tied to values/identity.
+- Environmental/health/safety mainly as regulation/operations (e.g., emissions standards, workplace safety), unless framed around rights/identity or moral controversy.
+
+Tie-breakers:
+- Classify based on primary purpose, not incidental mentions.
+- If the bill materially creates or changes an observance/day/month or declares a national symbol, classify as social issue = yes.
+- If mixed, choose "no".
+
+Output exactly this JSON:
+{
+ "is_social_issue": "yes|no"
+}`;
+
+// This defaults to false and a verdict will present to the user
+export const socialIssueGrader = async (text: string): Promise => {
+ // Fast path when no API key
+ if (!process.env.OPENAI_API_KEY) {
+ return false;
+ }
+
+ try {
+ const client = new OpenAI();
+ const input = `${SOCIAL_ISSUE_GRADER_PROMPT}\n\nBill content:\n${text?.slice(0, 8000)}`;
+ const response = await client.responses.create({ model: "gpt-5", input });
+ const raw = response.output_text;
+ try {
+ const parsed = JSON.parse(raw || "{}") as { is_social_issue?: string };
+ return (parsed.is_social_issue || "no").toLowerCase() === "yes";
+ } catch {
+ // Try to detect yes/no in raw
+ const yes = /is[_\s-]?social[_\s-]?issue\s*["':\s]*yes/i.test(raw || "");
+ const no = /is[_\s-]?social[_\s-]?issue\s*["':\s]*no/i.test(raw || "");
+ if (yes || no) return yes;
+ return false;
+ }
+ } catch (_err) {
+ return false;
+ }
+};
diff --git a/src/bills/types/global.d.ts b/src/bills/types/global.d.ts
new file mode 100644
index 0000000..01e366f
--- /dev/null
+++ b/src/bills/types/global.d.ts
@@ -0,0 +1,8 @@
+export {};
+
+declare global {
+ interface Window {
+ // SimpleAnalytics custom-event hook used by the bills share/question UI.
+ sa_event?: (event: string) => void;
+ }
+}
diff --git a/src/bills/types/markdown.d.ts b/src/bills/types/markdown.d.ts
new file mode 100644
index 0000000..6b86f83
--- /dev/null
+++ b/src/bills/types/markdown.d.ts
@@ -0,0 +1,2 @@
+declare module "react-markdown";
+declare module "remark-gfm";
diff --git a/src/bills/utils/basePath.ts b/src/bills/utils/basePath.ts
new file mode 100644
index 0000000..fc20e09
--- /dev/null
+++ b/src/bills/utils/basePath.ts
@@ -0,0 +1,66 @@
+const RAW_BASE_PATH =
+ process.env.NEXT_PUBLIC_BASE_PATH || process.env.NEXT_BASE_PATH || "/bills";
+
+const normalizedBasePath = (() => {
+ let path = RAW_BASE_PATH || "";
+ if (!path || path === "/") {
+ return "";
+ }
+ if (!path.startsWith("/")) {
+ path = `/${path}`;
+ }
+ if (path.endsWith("/")) {
+ path = path.slice(0, -1);
+ }
+ return path;
+})();
+
+export const BASE_PATH = normalizedBasePath;
+
+export function stripBasePath(url: string | undefined): string {
+ if (!url) {
+ return "";
+ }
+ const trimmed = url.replace(/\/$/, "");
+ if (!normalizedBasePath) {
+ return trimmed;
+ }
+ if (trimmed.endsWith(normalizedBasePath)) {
+ return trimmed.slice(0, trimmed.length - normalizedBasePath.length) || "";
+ }
+ return trimmed;
+}
+
+function normalizeSegment(segment: string): string {
+ return segment.replace(/^\/+|\/+$/g, "");
+}
+
+export function buildRelativePath(
+ ...segments: Array
+): string {
+ const parts = [normalizedBasePath, ...segments]
+ .filter(
+ (segment): segment is string | number =>
+ segment !== undefined && segment !== null,
+ )
+ .map((segment) => normalizeSegment(String(segment)))
+ .filter(Boolean);
+
+ if (parts.length === 0) {
+ return normalizedBasePath || "/";
+ }
+
+ return `/${parts.join("/")}`;
+}
+
+export function buildAbsoluteUrl(
+ origin: string | undefined,
+ ...segments: Array
+): string {
+ const relative = buildRelativePath(...segments);
+ if (!origin) {
+ return relative;
+ }
+ const trimmedOrigin = origin.replace(/\/$/, "");
+ return `${trimmedOrigin}${relative}`;
+}
diff --git a/src/bills/utils/bill-category-to-icon/bill-category-to-icon.util.ts b/src/bills/utils/bill-category-to-icon/bill-category-to-icon.util.ts
new file mode 100644
index 0000000..1e575af
--- /dev/null
+++ b/src/bills/utils/bill-category-to-icon/bill-category-to-icon.util.ts
@@ -0,0 +1,68 @@
+export function getCategoryIcon(category: string): string {
+ const iconMap: Record = {
+ // 🌱 Environment & Climate
+ Environmental: "leaf",
+ "Environmental Policy": "sprout",
+ "Environmental Issues": "recycle",
+ "Climate and Environment": "cloud-sun",
+
+ // 🏗 Infrastructure & Housing
+ Infrastructure: "land-plot",
+ "Housing and Urban Development": "home",
+
+ // 🧑🤝🧑 Social & Human Rights
+ "Indigenous Affairs": "users",
+ "Social Welfare": "hand-heart",
+ "Human Rights": "handshake",
+ "Social Issues": "users",
+
+ // ⚖️ Law, Justice, Governance
+ "Legal and Regulatory": "scale",
+ "Legal and Corporate": "briefcase",
+ "Legal and Constitutional": "gavel",
+ "Criminal Justice": "shield-check",
+ "Law and Employment": "book-text",
+ Legal: "scale",
+ "Politics and Governance": "landmark",
+ "Political Issues": "megaphone",
+ Politics: "flag",
+ Government: "building",
+ "Government and Politics": "building-2",
+ Elections: "vote",
+ "National Security": "shield-alert",
+ "Military and Employment": "shield-plus",
+
+ // 💰 Economy & Work
+ Economics: "trending-up",
+ "Trade and Commerce": "shopping-bag",
+ Business: "factory",
+ "Labor and Employment": "hard-hat",
+
+ // 🩺 Health & Education
+ Healthcare: "stethoscope",
+ Education: "graduation-cap",
+
+ // 🌾 Agriculture & Food
+ "Food and Agriculture": "utensils-crossed",
+ Agriculture: "tractor",
+ "Animal Welfare": "paw-print",
+
+ // 🚗 Transportation & Land
+ Transportation: "truck",
+ "Public Lands": "map",
+
+ // 🌎 Foreign & Immigration
+ "Foreign Affairs": "globe",
+ Immigration: "plane",
+
+ // ⚡ Energy & Technology
+ "Energy and Utilities": "zap",
+ "Technology and Innovation": "cpu",
+
+ // 🎨 Culture & Heritage
+ "Culture and Heritage": "landmark",
+ "Cultural Awareness": "palette",
+ };
+
+ return iconMap[category] || "file-text";
+}
diff --git a/src/bills/utils/billConverters.ts b/src/bills/utils/billConverters.ts
new file mode 100644
index 0000000..0c1cb81
--- /dev/null
+++ b/src/bills/utils/billConverters.ts
@@ -0,0 +1,297 @@
+import type { BillDocument } from "@/bills/models/Bill";
+import type { ApiBillDetail } from "@/bills/services/billApi";
+import {
+ summarizeBillText,
+ fetchBillMarkdown,
+ onBillNotInDatabase,
+ type BillAnalysis,
+} from "@/bills/services/billApi";
+import { socialIssueGrader } from "@/bills/services/social-issue-grader";
+
+// Unified bill data structure
+export interface UnifiedBill {
+ billId: string;
+ title: string;
+ short_title?: string;
+ summary: string;
+ status: string;
+ sponsorParty?: string;
+ chamber?: string;
+ supportedRegion?: string;
+ introducedOn?: Date;
+ lastUpdatedOn?: Date;
+ stages: {
+ stage: string;
+ state: string;
+ house: string;
+ date: Date;
+ }[];
+ genres?: string[];
+ parliamentNumber?: number;
+ sessionNumber?: number;
+ votes?: Array<{ motion?: string; result: string }>;
+ fullTextMarkdown?: string | null;
+ isSocialIssue?: boolean;
+ question_period_questions?: Array<{ question: string }>;
+ // Analysis data from AI
+ tenet_evaluations?: Array<{
+ id: number;
+ title: string;
+ alignment: "aligns" | "conflicts" | "neutral";
+ explanation: string;
+ }>;
+ final_judgment: "yes" | "no" | "abstain";
+ rationale?: string;
+ needs_more_info?: boolean;
+ missing_details?: string[];
+ steel_man?: string;
+}
+
+// Convert Build Canada DB bill to unified format
+export function fromBuildCanadaDbBill(bill: BillDocument): UnifiedBill {
+ return {
+ billId: bill.billId,
+ title: bill.title,
+ short_title: bill.short_title,
+ summary: bill.summary,
+ status: bill.status,
+ sponsorParty: bill.sponsorParty,
+ chamber: bill.chamber,
+ supportedRegion: bill.supportedRegion,
+ introducedOn: bill.introducedOn,
+ lastUpdatedOn: bill.lastUpdatedOn,
+ stages: bill.stages
+ ? bill.stages.map((stage) => ({
+ stage: stage.stage,
+ state: stage.state,
+ house: stage.house,
+ date: stage.date,
+ }))
+ : [],
+ genres: bill.genres ? [...bill.genres] : undefined,
+ parliamentNumber: bill.parliamentNumber,
+ sessionNumber: bill.sessionNumber,
+ votes: bill.votes?.map((v) => ({
+ motion: v.motion,
+ result: v.result,
+ })),
+ isSocialIssue: bill.isSocialIssue,
+ // Properly serialize question_period_questions to remove MongoDB ObjectIds
+ question_period_questions: bill.question_period_questions?.map((q) => ({
+ question: q.question,
+ })),
+ // Include analysis data - ensure proper serialization
+ tenet_evaluations: bill.tenet_evaluations?.map((te) => ({
+ id: te.id,
+ title: te.title,
+ alignment: te.alignment,
+ explanation: te.explanation,
+ })),
+ final_judgment:
+ (bill.final_judgment ?? "").toString().trim().toLowerCase() === "yes" ||
+ (bill.final_judgment ?? "").toString().trim().toLowerCase() === "no"
+ ? (bill.final_judgment as "yes" | "no")
+ : "abstain",
+ rationale: bill.rationale,
+ needs_more_info: bill.needs_more_info,
+ missing_details: bill.missing_details
+ ? [...bill.missing_details]
+ : undefined,
+ steel_man: bill.steel_man,
+ };
+}
+
+// Convert Civics Project API bill to unified format
+export async function fromCivicsProjectApiBill(
+ bill: ApiBillDetail,
+): Promise {
+ const { env } = await import("@/bills/env");
+ const uri = env.MONGO_URI || "";
+ const hasValidMongoUri =
+ uri.startsWith("mongodb://") || uri.startsWith("mongodb+srv://");
+ let existingBill: BillDocument | null = null;
+ const latestStageDate =
+ bill.stages && bill.stages.length > 0
+ ? bill.stages[bill.stages.length - 1].date
+ : (bill.updatedAt ?? bill.date);
+ const house =
+ bill.stages && bill.stages.length > 0
+ ? bill.stages[bill.stages.length - 1].house
+ : undefined;
+
+ let billMarkdown: string | null = null;
+
+ const latestBillSource =
+ bill.source || (bill.billTexts?.[0] as { url?: string })?.url;
+
+ if (latestBillSource) {
+ billMarkdown = await fetchBillMarkdown(latestBillSource);
+ }
+
+ // Check if we need to regenerate summary based on source changes from Civics Project API
+ let analysis: BillAnalysis = {
+ summary: bill.header || "",
+ tenet_evaluations: [
+ {
+ id: 1,
+ title: "Canada should aim to be the world's most prosperous country",
+ alignment: "neutral",
+ explanation: "Not analyzed",
+ },
+ {
+ id: 2,
+ title:
+ "Promote economic freedom, ambition, and breaking from bureaucratic inertia",
+ alignment: "neutral",
+ explanation: "Not analyzed",
+ },
+ {
+ id: 3,
+ title: "Drive national productivity and global competitiveness",
+ alignment: "neutral",
+ explanation: "Not analyzed",
+ },
+ {
+ id: 4,
+ title: "Grow exports of Canadian products and resources",
+ alignment: "neutral",
+ explanation: "Not analyzed",
+ },
+ {
+ id: 5,
+ title: "Encourage investment, innovation, and resource development",
+ alignment: "neutral",
+ explanation: "Not analyzed",
+ },
+ {
+ id: 6,
+ title:
+ "Deliver better public services at lower cost (government efficiency)",
+ alignment: "neutral",
+ explanation: "Not analyzed",
+ },
+ {
+ id: 7,
+ title: "Reform taxes to incentivize work, risk-taking, and innovation",
+ alignment: "neutral",
+ explanation: "Not analyzed",
+ },
+ {
+ id: 8,
+ title: "Focus on large-scale prosperity, not incrementalism",
+ alignment: "neutral",
+ explanation: "Not analyzed",
+ },
+ ],
+ final_judgment: "no",
+ rationale: undefined,
+ needs_more_info: false,
+ missing_details: [],
+ steel_man: "Not analyzed",
+ };
+
+ // Check existing bill in database to see if source changed
+ try {
+ const { connectToDatabase } = await import("@/bills/lib/mongoose");
+ const { Bill } = await import("@/bills/models/Bill");
+
+ if (hasValidMongoUri) {
+ await connectToDatabase();
+ existingBill = (await Bill.findOne({ billId: bill.billID })
+ .lean()
+ .exec()) as BillDocument | null;
+
+ if (existingBill && !analysis.rationale) {
+ // Source hasn't changed, use existing analysis
+ analysis = {
+ summary: existingBill.summary,
+ tenet_evaluations:
+ existingBill.tenet_evaluations || analysis.tenet_evaluations,
+ final_judgment: (() => {
+ const raw = String(existingBill.final_judgment || "")
+ .trim()
+ .toLowerCase();
+ if (raw === "yes" || raw === "no") return raw as "yes" | "no";
+ // Treat legacy "neutral" and any unknown value as "abstain"
+ if (raw === "abstain" || raw === "neutral") return "abstain";
+ return analysis.final_judgment;
+ })(),
+ rationale: existingBill.rationale || analysis.rationale,
+ needs_more_info:
+ existingBill.needs_more_info || analysis.needs_more_info,
+ missing_details:
+ existingBill.missing_details || analysis.missing_details,
+ steel_man: existingBill.steel_man || analysis.steel_man,
+ };
+ console.log(
+ `Using existing analysis for ${bill.billID} (source unchanged)`,
+ );
+ }
+ }
+ } catch (error) {
+ console.error("Error checking existing bill:", error);
+ // Continue with regeneration if DB check fails
+ }
+
+ if (!analysis?.rationale && billMarkdown) {
+ console.log(`Regenerating analysis for ${bill.billID} (source changed)`);
+ analysis = await summarizeBillText(billMarkdown);
+ }
+
+ // Only classify if missing (new bill or classification absent). Avoid calling otherwise.
+ let isSocialIssueFinal: boolean =
+ typeof existingBill?.isSocialIssue === "boolean"
+ ? ((existingBill as BillDocument).isSocialIssue as boolean)
+ : false;
+ if (
+ hasValidMongoUri &&
+ (existingBill === null || typeof existingBill.isSocialIssue !== "boolean")
+ ) {
+ isSocialIssueFinal = await socialIssueGrader(
+ billMarkdown || analysis.summary || bill.header || bill.title,
+ );
+ }
+
+ await onBillNotInDatabase({
+ billId: bill.billID,
+ source: bill.source,
+ markdown: billMarkdown,
+ bill,
+ analysis,
+ billTextsCount: Array.isArray(bill.billTexts) ? bill.billTexts.length : 0,
+ isSocialIssue: isSocialIssueFinal,
+ });
+
+ return {
+ billId: bill.billID,
+ title: bill.title,
+ short_title: bill.shortTitle,
+ summary: analysis.summary,
+ status: bill.status,
+ stages: bill.stages
+ ? bill.stages.map((stage) => ({
+ stage: stage.stage,
+ state: stage.state,
+ house: stage.house,
+ date: new Date(stage.date),
+ }))
+ : [],
+ sponsorParty: bill.sponsorParty,
+ chamber: house,
+ supportedRegion: bill.supportedRegion,
+ introducedOn: new Date(bill.date),
+ lastUpdatedOn: new Date(latestStageDate),
+ genres: bill.genres,
+ parliamentNumber: bill.parliamentNumber,
+ sessionNumber: bill.sessionNumber,
+ fullTextMarkdown: billMarkdown,
+ question_period_questions: analysis.question_period_questions,
+ // Include analysis data
+ tenet_evaluations: analysis.tenet_evaluations,
+ final_judgment: analysis.final_judgment,
+ rationale: analysis.rationale,
+ needs_more_info: analysis.needs_more_info,
+ missing_details: analysis.missing_details,
+ steel_man: analysis.steel_man,
+ };
+}
diff --git a/src/bills/utils/get-party-colors/get-party-colors.util.ts b/src/bills/utils/get-party-colors/get-party-colors.util.ts
new file mode 100644
index 0000000..baa0dae
--- /dev/null
+++ b/src/bills/utils/get-party-colors/get-party-colors.util.ts
@@ -0,0 +1,77 @@
+const partyChipStyles = {
+ conservative: {
+ backgroundColor: "#6495ED",
+ color: "white",
+ },
+ liberal: {
+ backgroundColor: "#d41f27",
+ color: "white",
+ },
+ ndp: {
+ backgroundColor: "#F4A460",
+ color: "white",
+ },
+ bloc: {
+ backgroundColor: "#87CEFA",
+ color: "white",
+ },
+ green: {
+ backgroundColor: "#99C955",
+ color: "white",
+ },
+ independent: {
+ backgroundColor: "#A9A9A9",
+ color: "white",
+ },
+ default: {
+ backgroundColor: "grey",
+ color: "white",
+ },
+ all: {
+ backgroundColor: "white",
+ color: "black",
+ },
+ senate: {
+ backgroundColor: "#600000",
+ color: "white",
+ },
+};
+
+export const getPartyColor = (party: string) => {
+ if (!party) {
+ return partyChipStyles.default;
+ }
+ const normalizedParty = party
+ ?.toLowerCase()
+ .replace("québécois", "quebecois")
+ .replace("quebecois", "");
+
+ if (normalizedParty.includes("senate")) {
+ return partyChipStyles.senate;
+ }
+
+ if (party.includes("cois")) {
+ return partyChipStyles.bloc;
+ }
+
+ if (party.includes("Green Party")) {
+ return partyChipStyles.green;
+ }
+
+ if (normalizedParty.includes("new democratic party")) {
+ return partyChipStyles.ndp;
+ }
+
+ if (normalizedParty.includes("conservative")) {
+ return partyChipStyles.conservative;
+ }
+
+ if (normalizedParty.includes("liberal")) {
+ return partyChipStyles.liberal;
+ }
+
+ return (
+ partyChipStyles[normalizedParty as keyof typeof partyChipStyles] ||
+ partyChipStyles.default
+ );
+};
diff --git a/src/bills/utils/should-show-determination/should-show-determination.util.ts b/src/bills/utils/should-show-determination/should-show-determination.util.ts
new file mode 100644
index 0000000..617c12a
--- /dev/null
+++ b/src/bills/utils/should-show-determination/should-show-determination.util.ts
@@ -0,0 +1,7 @@
+export const shouldShowDetermination = (
+ vote: string | null | undefined,
+): boolean => {
+ if (vote === null) return true;
+ const normalized = (vote ?? "").toString().trim().toLowerCase();
+ return normalized !== "abstain";
+};
diff --git a/src/bills/utils/stage-summarizer/stage-summarizer.util.ts b/src/bills/utils/stage-summarizer/stage-summarizer.util.ts
new file mode 100644
index 0000000..6aa87da
--- /dev/null
+++ b/src/bills/utils/stage-summarizer/stage-summarizer.util.ts
@@ -0,0 +1,254 @@
+// Human-readable stage mappings with keywords for fuzzy matching
+const STAGE_MAPPINGS = [
+ {
+ displayName: "Royal Assent",
+ description: "Law complete - granted by Governor General",
+ keywords: ["royal assent", "assent", "completed", "enacted", "law"],
+ priority: 10,
+ category: "complete",
+ },
+ {
+ displayName: "Passed",
+ description: "Bill has been approved",
+ keywords: ["passed", "approved", "adopted", "carried"],
+ priority: 9,
+ category: "complete",
+ },
+ {
+ displayName: "Failed",
+ description: "Bill was defeated or withdrawn",
+ keywords: ["failed", "defeated", "rejected", "withdrawn", "defeat"],
+ priority: 9,
+ category: "failed",
+ },
+ {
+ displayName: "Third Reading",
+ description: "Final debate and vote in chamber",
+ keywords: ["third reading", "3rd reading", "final reading", "final vote"],
+ priority: 8,
+ category: "active",
+ },
+ {
+ displayName: "Report Stage",
+ description: "Reviewing committee amendments",
+ keywords: ["report stage", "report", "amendments review"],
+ priority: 7,
+ category: "active",
+ },
+ {
+ displayName: "Committee Review",
+ description: "Detailed study by parliamentary committee",
+ keywords: [
+ "committee",
+ "consideration",
+ "study",
+ "review",
+ "clause-by-clause",
+ ],
+ priority: 6,
+ category: "active",
+ },
+ {
+ displayName: "Second Reading",
+ description: "Principle debate and committee referral",
+ keywords: ["second reading", "2nd reading", "referral", "principle"],
+ priority: 5,
+ category: "active",
+ },
+ {
+ displayName: "First Reading",
+ description: "Bill introduced to Parliament",
+ keywords: ["first reading", "1st reading", "introduction", "introduced"],
+ priority: 4,
+ category: "introduced",
+ },
+ {
+ displayName: "Senate Review",
+ description: "Under consideration by the Senate",
+ keywords: ["senate", "upper chamber", "sober second thought"],
+ priority: 7,
+ category: "active",
+ },
+ {
+ displayName: "In Progress",
+ description: "Bill is moving through Parliament",
+ keywords: ["in progress", "proceeding", "advancing"],
+ priority: 3,
+ category: "active",
+ },
+ {
+ displayName: "Paused",
+ description: "Bill proceedings temporarily halted",
+ keywords: ["paused", "suspended", "delayed", "prorogation"],
+ priority: 2,
+ category: "paused",
+ },
+ {
+ displayName: "Notice Filed",
+ description: "Notice submitted before introduction",
+ keywords: ["notice", "filed", "48 hours"],
+ priority: 1,
+ category: "pre-introduction",
+ },
+ {
+ displayName: "Paused",
+ description: "Bill is outside the order of precedence",
+ keywords: ["outside", "precedence", "out of order"],
+ priority: 2,
+ category: "paused",
+ },
+];
+
+/**
+ * Simple string similarity calculation using Levenshtein distance
+ */
+function calculateSimilarity(str1: string, str2: string): number {
+ const longer = str1.length > str2.length ? str1 : str2;
+ const shorter = str1.length > str2.length ? str2 : str1;
+
+ if (longer.length === 0) return 1.0;
+
+ const distance = levenshteinDistance(longer, shorter);
+ return (longer.length - distance) / longer.length;
+}
+
+/**
+ * Levenshtein distance calculation
+ */
+function levenshteinDistance(str1: string, str2: string): number {
+ const matrix = Array(str2.length + 1)
+ .fill(null)
+ .map(() => Array(str1.length + 1).fill(null));
+
+ for (let i = 0; i <= str1.length; i++) matrix[0][i] = i;
+ for (let j = 0; j <= str2.length; j++) matrix[j][0] = j;
+
+ for (let j = 1; j <= str2.length; j++) {
+ for (let i = 1; i <= str1.length; i++) {
+ const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
+ matrix[j][i] = Math.min(
+ matrix[j][i - 1] + 1, // insertion
+ matrix[j - 1][i] + 1, // deletion
+ matrix[j - 1][i - 1] + cost, // substitution
+ );
+ }
+ }
+
+ return matrix[str2.length][str1.length];
+}
+
+/**
+ * Fuzzy match a stage string against known stages
+ */
+export const stageSummarizer = (
+ inputStage: string,
+ fallbackStatus?: string,
+): string => {
+ if (!inputStage && !fallbackStatus) {
+ return "Unknown Stage";
+ }
+
+ const searchText = (inputStage || fallbackStatus || "").toLowerCase().trim();
+
+ if (!searchText) {
+ return "Unknown Stage";
+ }
+
+ let bestMatch = null;
+ let bestScore = 0;
+
+ for (const stageMapping of STAGE_MAPPINGS) {
+ for (const keyword of stageMapping.keywords) {
+ // Direct substring match gets high score
+ if (searchText.includes(keyword)) {
+ const score = 0.9 + (keyword.length / searchText.length) * 0.1;
+ if (score > bestScore) {
+ bestScore = score;
+ bestMatch = stageMapping;
+ }
+ }
+
+ // Fuzzy similarity match
+ const similarity = calculateSimilarity(searchText, keyword);
+ if (similarity > 0.6 && similarity > bestScore) {
+ bestScore = similarity;
+ bestMatch = stageMapping;
+ }
+ }
+ }
+
+ // If we found a good match, return it
+ if (bestMatch && bestScore > 0.6) {
+ return bestMatch.displayName;
+ }
+
+ // Fallback to cleaned-up input
+ return inputStage || fallbackStatus || "Unknown Stage";
+};
+
+/**
+ * Get stage description for additional context
+ */
+export const getStageDescription = (
+ inputStage: string,
+ fallbackStatus?: string,
+): string => {
+ const searchText = (inputStage || fallbackStatus || "").toLowerCase().trim();
+
+ if (!searchText) {
+ return "Stage information not available";
+ }
+
+ for (const stageMapping of STAGE_MAPPINGS) {
+ for (const keyword of stageMapping.keywords) {
+ if (searchText.includes(keyword)) {
+ return stageMapping.description;
+ }
+
+ const similarity = calculateSimilarity(searchText, keyword);
+ if (similarity > 0.7) {
+ return stageMapping.description;
+ }
+ }
+ }
+
+ return "Stage information not available";
+};
+
+/**
+ * Get stage category for styling purposes
+ */
+type StageCategory =
+ | "complete"
+ | "failed"
+ | "active"
+ | "introduced"
+ | "paused"
+ | "pre-introduction"
+ | "unknown";
+
+export const getStageCategory = (
+ inputStage: string,
+ fallbackStatus?: string,
+): StageCategory => {
+ const searchText = (inputStage || fallbackStatus || "").toLowerCase().trim();
+
+ if (!searchText) {
+ return "unknown";
+ }
+
+ for (const stageMapping of STAGE_MAPPINGS) {
+ for (const keyword of stageMapping.keywords) {
+ if (searchText.includes(keyword)) {
+ return stageMapping.category as StageCategory;
+ }
+
+ const similarity = calculateSimilarity(searchText, keyword);
+ if (similarity > 0.7) {
+ return stageMapping.category as StageCategory;
+ }
+ }
+ }
+
+ return "unknown";
+};
diff --git a/src/bills/utils/stages-to-dates/stages-to-dates.ts b/src/bills/utils/stages-to-dates/stages-to-dates.ts
new file mode 100644
index 0000000..5effa09
--- /dev/null
+++ b/src/bills/utils/stages-to-dates/stages-to-dates.ts
@@ -0,0 +1,71 @@
+import { BillStage, BillSummary } from "@/app/bills/types";
+
+type BillStageDates = {
+ firstIntroduced: Date | null;
+ lastUpdated: Date | null;
+};
+
+export function getBillStageDates(stages?: BillStage[] | null): BillStageDates {
+ if (!stages || stages.length === 0) {
+ return { firstIntroduced: null, lastUpdated: null };
+ }
+
+ const stagesWithTimestamps = stages
+ .map((stage) => ({
+ ...stage,
+ timestamp: new Date(stage.date).getTime(),
+ }))
+ .filter((stage) => Number.isFinite(stage.timestamp));
+
+ if (stagesWithTimestamps.length === 0) {
+ return { firstIntroduced: null, lastUpdated: null };
+ }
+
+ stagesWithTimestamps.sort((a, b) => a.timestamp - b.timestamp);
+
+ const firstIntroduced = new Date(stagesWithTimestamps[0].timestamp);
+ const lastUpdated = new Date(
+ stagesWithTimestamps[stagesWithTimestamps.length - 1].timestamp,
+ );
+
+ return { firstIntroduced, lastUpdated };
+}
+
+export function getBillStages(bill?: {
+ billStages?: BillStage[];
+ stages?: BillStage[];
+}): BillStage[] {
+ if (!bill) return [];
+ return bill.billStages ?? bill.stages ?? [];
+}
+
+export function getBillMostRecentDate(bill?: {
+ billStages?: BillStage[];
+ stages?: BillStage[];
+ lastUpdatedOn?: string;
+ introducedOn?: string;
+}): Date | null {
+ if (!bill) return null;
+ const stages = getBillStages(bill);
+ const stageDate = getBillStageDates(stages).lastUpdated;
+ if (stageDate) return stageDate;
+
+ const parseDate = (value?: string) => {
+ if (!value) return null;
+ const parsed = new Date(value);
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
+ };
+
+ return parseDate(bill.lastUpdatedOn) ?? parseDate(bill.introducedOn);
+}
+
+/**
+ * Sort bills by their most recent known date (stages, lastUpdatedOn, introducedOn).
+ * Falls back to billID for deterministic ordering when dates are equal/missing.
+ */
+export function sortBillsByMostRecent(a: BillSummary, b: BillSummary): number {
+ const aDate = getBillMostRecentDate(a)?.getTime() ?? 0;
+ const bDate = getBillMostRecentDate(b)?.getTime() ?? 0;
+ if (aDate !== bDate) return bDate - aDate;
+ return a.billID.localeCompare(b.billID);
+}
diff --git a/src/bills/utils/vote-display.ts b/src/bills/utils/vote-display.ts
new file mode 100644
index 0000000..d975026
--- /dev/null
+++ b/src/bills/utils/vote-display.ts
@@ -0,0 +1,34 @@
+type Alignment = "aligns" | "conflicts" | "neutral" | null | undefined;
+
+interface TenetEvaluation {
+ alignment?: Alignment;
+}
+
+/**
+ * Checks whether the vote should be forced to "neutral"
+ * when there is only a single non-neutral evaluation
+ * (either one "aligns" or one "conflicts") and all others are neutral.
+ */
+export function isSingleTenet(evaluations: TenetEvaluation[]): boolean {
+ if (!evaluations || evaluations.length === 0) return false;
+
+ const counts = evaluations.reduce(
+ (acc, t) => {
+ switch (t?.alignment) {
+ case "aligns":
+ acc.aligns++;
+ break;
+ case "conflicts":
+ acc.conflicts++;
+ break;
+ default:
+ acc.neutral++;
+ }
+ return acc;
+ },
+ { aligns: 0, conflicts: 0, neutral: 0 },
+ );
+
+ const nonNeutralCount = counts.aligns + counts.conflicts;
+ return nonNeutralCount === 1 && counts.neutral === evaluations.length - 1;
+}
diff --git a/src/bills/utils/xml-to-md/xml-to-md.util.ts b/src/bills/utils/xml-to-md/xml-to-md.util.ts
new file mode 100644
index 0000000..970afcf
--- /dev/null
+++ b/src/bills/utils/xml-to-md/xml-to-md.util.ts
@@ -0,0 +1,344 @@
+// bill-xml-to-md.ts
+import { XMLParser } from "fast-xml-parser";
+
+/**
+ * With preserveOrder=true, each node is an object like:
+ * { TagName: NodeList, ":@": { "@attr": "value", ... } }
+ * or a text node: { "#text": "..." }
+ */
+export type XMLNode = Record;
+export type NodeList = XMLNode[];
+
+export function xmlToMarkdown(xmlString: string): string {
+ const parser = new XMLParser({
+ ignoreAttributes: false,
+ attributeNamePrefix: "@", // attributes look like "@level", "@href", etc (inside :@)
+ preserveOrder: true, // we need ordering and mixed content
+ trimValues: false,
+ });
+
+ // Strip BOM/newlines that often show up in downloaded XML
+ const cleaned = xmlString.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
+ const ast = parser.parse(cleaned) as NodeList;
+
+ const md = renderNodes(ast)
+ .replace(/\n{3,}/g, "\n\n")
+ .trim();
+ return md.length ? `${md}\n` : md;
+}
+
+/* ============================ Rendering Core ============================ */
+
+type Ctx = { olDepth: number; listMode: "none" | "ul" | "ol" };
+const defaultCtx = (): Ctx => ({ olDepth: 0, listMode: "none" });
+
+function renderNodes(nodes: NodeList, ctx: Ctx = defaultCtx()): string {
+ return nodes.map((n) => renderNode(n, ctx)).join("");
+}
+
+function renderNode(node: XMLNode, ctx: Ctx): string {
+ // Text node
+ if ("#text" in node) return String(node["#text"]);
+
+ const tag = getTagName(node);
+ if (!tag) return "";
+
+ const children = getChildren(node, tag);
+ const attrs = getAttributes(node);
+
+ switch (tag) {
+ /* ===== Top-level Bill shaping ===== */
+ case "bill": {
+ // Build a compact header from
+ const identification = findChild(children, "identification");
+ const billNo = identification
+ ? findText(identification, "billnumber")
+ : "";
+ const longTitle = identification
+ ? findText(identification, "longtitle")
+ : "";
+ const sponsor = identification
+ ? findText(identification, "billsponsor")
+ : "";
+ const dateStr = extractFirstReadingDate(identification);
+
+ const headerLines: string[] = [];
+ if (billNo || longTitle)
+ headerLines.push(
+ `# ${[billNo, longTitle].filter(Boolean).join(" — ")}`,
+ );
+ if (sponsor) headerLines.push(`**Sponsor:** ${sponsor}`);
+ if (dateStr) headerLines.push(`**Introduced:** ${dateStr}`);
+
+ // Render everything except (to avoid duplication)
+ const rest = children.filter((c) => getTagName(c) !== "identification");
+ return `${[headerLines.join("\n"), renderNodes(rest, ctx)]
+ .filter(Boolean)
+ .join("\n\n")}\n`;
+ }
+
+ /* ===== Structural containers ===== */
+ case "identification":
+ // We render Identification inside already; skip here.
+ return "";
+ case "introduction":
+ // Let Summary/Preamble render their own headings; otherwise just pass through.
+ return renderNodes(children, ctx);
+ case "summary": {
+ // Show a Summary section; skip TitleText="SUMMARY" heading duplicate
+ const body = renderNodes(
+ children.filter((c) => getTagName(c) !== "titletext"),
+ ctx,
+ );
+ return `\n## Summary\n\n${body}\n`;
+ }
+ case "preamble": {
+ return `\n## Preamble\n\n${renderNodes(children, ctx)}\n`;
+ }
+
+ /* ===== Headings / TitleText ===== */
+ case "heading": {
+ // ...
+ const level = Number(attrs.level ?? 1);
+ const title = findText(children, "titletext") || inline(children, ctx);
+ // Optional marginal/historical notes rendered inline after em-dash
+ const note = findText(children, "marginalnote");
+ const h = "#".repeat(Math.max(1, Math.min(6, level)));
+ return `\n${h} ${[title, note && `— ${note}`].filter(Boolean).join("")}\n\n`;
+ }
+ case "titletext":
+ // Usually consumed by , but elsewhere it's useful text (e.g., Summary label)
+ return inline(children, ctx);
+
+ /* ===== Sections / Subsections ===== */
+ case "section": {
+ const label = findText(children, "label");
+ const marginal = findText(children, "marginalnote");
+ const head =
+ label || marginal
+ ? `\n### ${[label, marginal].filter(Boolean).join(". ")}\n\n`
+ : "";
+
+ const textBlocks = collectAll(children, "text")
+ .map((t) => `${t}\n\n`)
+ .join("");
+
+ // Subsections
+ const subSecs = children
+ .filter((c) => getTagName(c) === "subsection")
+ .map((ss) => renderSubsection(getChildren(ss, "subsection"), ctx))
+ .join("");
+
+ // Amended text / nested content
+ const amended = children
+ .filter(
+ (c) =>
+ getTagName(c) === "amendedtext" || getTagName(c) === "sectionpiece",
+ )
+ .map((n) => renderNodes([n], ctx))
+ .join("");
+
+ // Other children (excluding the ones we handled) to avoid duplication
+ const leftovers = children.filter((c) => {
+ const t = getTagName(c);
+ return (
+ t !== "label" &&
+ t !== "marginalnote" &&
+ t !== "text" &&
+ t !== "subsection" &&
+ t !== "amendedtext" &&
+ t !== "sectionpiece"
+ );
+ });
+
+ return `${head}${textBlocks}${subSecs}${renderNodes(leftovers, ctx)}${amended}`;
+ }
+ case "subsection":
+ // Usually handled by parent , but render gracefully if alone
+ return renderSubsection(children, ctx);
+
+ /* ===== Paragraph/Provision (amendment pieces) ===== */
+ case "paragraph": {
+ const lbl = findText(children, "label");
+ const mn = findText(children, "marginalnote");
+ const t = findText(children, "text") || "";
+ const head = [lbl, mn].filter(Boolean).join(" ");
+
+ // Handle subparagraphs if present
+ const subParas = children
+ .filter((c) => getTagName(c) === "subparagraph")
+ .map((sp) => {
+ const spChildren = getChildren(sp, "subparagraph");
+ const spLbl = findText(spChildren, "label") || "";
+ const spText = findText(spChildren, "text") || "";
+ return spLbl ? ` - **${spLbl}** ${spText}` : ` - ${spText}`;
+ })
+ .join("\n");
+
+ const mainText = head ? `- **${head}** ${t}` : `- ${t}`;
+ return subParas ? `${mainText}\n${subParas}\n` : `${mainText}\n`;
+ }
+ case "subparagraph": {
+ // Handle standalone subparagraphs
+ const lbl = findText(children, "label") || "";
+ const t = findText(children, "text") || "";
+ return lbl ? ` - **${lbl}** ${t}\n` : ` - ${t}\n`;
+ }
+ case "provision": {
+ // In Summary/Preamble: prefer the child
+ const t = findText(children, "text");
+ if (t) return `${t}\n\n`;
+ return renderNodes(children, ctx);
+ }
+
+ /* ===== Inline formatting & references ===== */
+ case "marginalnote":
+ return `_${inline(children, ctx)}_`;
+ case "historicalnote":
+ return `(${inline(children, ctx)})`;
+ case "emphasis": {
+ const style = (attrs.style || "").toString().toLowerCase();
+ const content = inline(children, ctx);
+ if (style === "smallcaps") return content.toUpperCase();
+ return `*${content}*`;
+ }
+ case "sup":
+ // Markdown has no standard superscript; keep plain
+ return inline(children, ctx);
+ case "xrefexternal": {
+ // Act Name
+ return `_${inline(children, ctx)}_`;
+ }
+
+ /* ===== Generic HTML-ish bits (if they appear) ===== */
+ case "b":
+ case "strong":
+ return `**${inline(children, ctx)}**`;
+ case "i":
+ case "em":
+ return `*${inline(children, ctx)}*`;
+ case "code":
+ return `\`${inline(children, ctx)}\``;
+ case "a": {
+ const href = (attrs.href || attrs.url || "").toString();
+ const txt = inline(children, ctx) || href;
+ return href ? `[${txt}](${href})` : txt;
+ }
+
+ /* ===== Fallback ===== */
+ default:
+ return renderNodes(children, ctx);
+ }
+}
+
+/* ============================ Small Render Helpers ============================ */
+
+function renderSubsection(children: NodeList, ctx: Ctx): string {
+ const lbl = findText(children, "label");
+ const marginal = findText(children, "marginalnote");
+ const t = findText(children, "text") || "";
+
+ // Handle paragraphs within subsections
+ const paragraphs = children
+ .filter((c) => getTagName(c) === "paragraph")
+ .map((p) => renderNode(p, ctx))
+ .join("");
+
+ const header = [lbl, marginal].filter(Boolean).join(" - ");
+ const mainText = header ? `**${header}** ${t}` : t;
+
+ // If we have paragraphs, add them after the main text
+ if (paragraphs) {
+ return `${mainText}\n${paragraphs}\n`;
+ }
+
+ return `${mainText}\n\n`;
+}
+
+function inline(nodes: NodeList, ctx: Ctx): string {
+ return renderNodes(nodes, ctx)
+ .replace(/\n+/g, " ")
+ .replace(/\s{2,}/g, " ")
+ .trim();
+}
+
+function collectAll(nodes: NodeList, tag: string): string[] {
+ const out: string[] = [];
+ for (const n of nodes) {
+ const t = getTagName(n);
+ if (!t) continue;
+ if (t === tag) out.push(textContent(getChildren(n, tag)));
+ }
+ return out;
+}
+
+function textContent(nodes: NodeList): string {
+ // Keep line breaks inside blocks but normalize CRLF
+ return renderNodes(nodes).replace(/\r?\n/g, "\n").trim();
+}
+
+/* ============================ Tree Navigation ============================ */
+
+function getTagName(node: XMLNode): string | null {
+ for (const k of Object.keys(node)) {
+ if (k === "#text" || k === ":@") continue;
+ return k.toLowerCase();
+ }
+ return null;
+}
+
+function getChildren(node: XMLNode, tagName: string): NodeList {
+ const key = Object.keys(node).find((k) => k.toLowerCase() === tagName);
+ if (!key) return [];
+ const v = (node as Record)[key];
+ return Array.isArray(v) ? (v as NodeList) : [];
+}
+
+function getAttributes(node: XMLNode): Record {
+ const out: Record = {};
+ // Attributes live under the special ":@" key when preserveOrder=true
+ const attrBucket = (node as Record)[":@"] as Record | undefined;
+ if (attrBucket) {
+ for (const [k, v] of Object.entries(attrBucket)) {
+ if (k.startsWith("@")) out[k.slice(1)] = String(v);
+ else out[k] = String(v);
+ }
+ }
+ // Also accept attrs on the node itself (if preserveOrder wasn't used upstream)
+ for (const [k, v] of Object.entries(node)) {
+ if (k.startsWith("@")) out[k.slice(1)] = String(v);
+ }
+ return out;
+}
+
+function findChild(nodes: NodeList, tag: string): NodeList | null {
+ for (const n of nodes) {
+ const t = getTagName(n);
+ if (t === tag) return getChildren(n, t);
+ }
+ return null;
+}
+
+function findText(nodes: NodeList, tag: string): string {
+ const child = findChild(nodes, tag);
+ return child ? textContent(child) : "";
+}
+
+/* ===== Schema-specific helper ===== */
+
+function extractFirstReadingDate(ident: NodeList | null): string {
+ if (!ident) return "";
+ const hist = findChild(ident, "billhistory");
+ if (!hist) return "";
+ const stages = findChild(hist, "stages");
+ if (!stages) return "";
+ const date = findChild(stages, "date");
+ if (!date) return "";
+
+ const yyyy = findText(date, "yyyy");
+ const mm = findText(date, "mm");
+ const dd = findText(date, "dd");
+ if (!yyyy || !mm || !dd) return "";
+ const pad = (s: string) => s.padStart(2, "0");
+ return `${yyyy}-${pad(mm)}-${pad(dd)}`;
+}
|