diff --git a/.gitignore b/.gitignore index 19ba966..bcfc6ca 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ coverage .env .env.local .env.*.local +.infisical.json .trigger diff --git a/apps/web/app/_components/search-form.tsx b/apps/web/app/_components/search-form.tsx index 5b4826b..3fd0767 100644 --- a/apps/web/app/_components/search-form.tsx +++ b/apps/web/app/_components/search-form.tsx @@ -9,7 +9,7 @@ type SearchFormProps = { export function SearchForm({ defaultValue = "", action = "/", - placeholder = "Search ministers, portfolios, and departments", + placeholder = "Search ministers, offices, and departments", }: SearchFormProps) { return (
diff --git a/apps/web/app/_lib/presenters.ts b/apps/web/app/_lib/presenters.ts index 6820129..601c18a 100644 --- a/apps/web/app/_lib/presenters.ts +++ b/apps/web/app/_lib/presenters.ts @@ -20,8 +20,8 @@ export function humanizeSourceFamily(sourceFamily: string): string { export function describeRelationship(entry: RelatedNode): string { switch (entry.edgeType) { - case "HOLDS_PORTFOLIO": - return entry.direction === "outgoing" ? "Holds portfolio" : "Held by"; + case "HOLDS_OFFICE": + return entry.direction === "outgoing" ? "Holds office" : "Held by"; case "MEMBER_OF_MINISTRY": return entry.direction === "outgoing" ? "Member of" : "Includes"; case "SUPPORTED_BY_DEPARTMENT": diff --git a/apps/web/app/general-order/[officeSlug]/page.tsx b/apps/web/app/general-order/[officeSlug]/page.tsx new file mode 100644 index 0000000..431b9ac --- /dev/null +++ b/apps/web/app/general-order/[officeSlug]/page.tsx @@ -0,0 +1,218 @@ +import type { Metadata } from "next"; +import type { Route } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +import { Badge, Card } from "@govgraph/ui"; + +import { loadGeneralOrderOffice } from "../_lib/data"; +import { + humanizeAdministrationMode, + humanizeAdministrationScope, + humanizeParseStatus, + humanizeRuleKind, + summarizeProvisionReferences, +} from "../_lib/presenters"; + +type GeneralOrderOfficePageProps = { + params: Promise<{ officeSlug: string }>; +}; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata({ + params, +}: GeneralOrderOfficePageProps): Promise { + const { officeSlug } = await params; + const state = await loadGeneralOrderOffice(officeSlug); + + if (state.status !== "ready") { + return { + title: "General Order office | GovGraph", + }; + } + + return { + title: `${state.detail.officeName} | General Order | GovGraph`, + description: `Act allocations recorded for ${state.detail.officeName} in the Victorian General Order.`, + }; +} + +function EmptyState({ + title, + body, +}: { + title: string; + body: string; +}) { + return ( + +

+ {title} +

+

+ {body} +

+
+ ); +} + +export default async function GeneralOrderOfficePage({ + params, +}: GeneralOrderOfficePageProps) { + const { officeSlug } = await params; + const state = await loadGeneralOrderOffice(officeSlug); + + if (state.status === "missing") { + notFound(); + } + + return ( +
+
+
+
+ + Atlas + + / + + General Order + +
+ + {state.status === "ready" ? ( + <> +
+ {state.detail.actEntryCount} Acts + {state.detail.partialRuleCount > 0 ? ( + {state.detail.partialRuleCount} partial rules + ) : null} + {state.detail.source.effectiveDate ? ( + effective {state.detail.source.effectiveDate} + ) : null} +
+
+

+ {state.detail.officeName} +

+

+ Browse the Acts, shared provisions, and carve-outs recorded + for this office in the latest imported General Order. +

+
+ + ) : null} +
+ + {state.status === "ready" ? ( + +

+ Source document +

+

+ {state.detail.source.title} +

+

+ Retrieved {state.detail.source.retrievedAt.slice(0, 10)} +

+ + Open official page + +
+ ) : null} +
+ + {state.status === "empty" ? ( + + ) : null} + + {state.status === "error" ? ( + + ) : null} + + {state.status === "ready" ? ( +
+
+

+ Act allocations +

+

+ Imported records for this office +

+
+
+ {state.detail.acts.map((act) => ( + +
+ {act.headingStyle} + {act.rules.length} rules +
+

+ {act.actName} +

+

+ {act.headingText} +

+ +
+ {act.rules.map((rule, index) => ( +
+
+ {humanizeRuleKind(rule.ruleKind)} + {humanizeAdministrationScope(rule.scope)} + + {humanizeAdministrationMode(rule.administrationMode)} + + {rule.parseStatus !== "parsed" ? ( + {humanizeParseStatus(rule.parseStatus)} + ) : null} + {rule.kanonAssistance?.status === "enriched" ? ( + Kanon-assisted + ) : null} +
+

+ {rule.rawText} +

+ {rule.administeringOfficeNames.length > 1 ? ( +

+ Also involving: {rule.administeringOfficeNames.join(", ")} +

+ ) : null} + {rule.provisionReferences.length > 0 ? ( +

+ Provisions:{" "} + {summarizeProvisionReferences(rule.provisionReferences)} +

+ ) : null} + {rule.unparsedTail ? ( +

+ Unparsed tail preserved: {rule.unparsedTail} +

+ ) : null} +
+ ))} +
+
+ ))} +
+
+ ) : null} +
+ ); +} diff --git a/apps/web/app/general-order/_lib/data.ts b/apps/web/app/general-order/_lib/data.ts new file mode 100644 index 0000000..ea645f9 --- /dev/null +++ b/apps/web/app/general-order/_lib/data.ts @@ -0,0 +1,158 @@ +import { cache } from "react"; + +import { + closeDbClient, + createDbClient, + getGeneralOrderOfficeDetail, + getLatestGeneralOrderSourceSummary, + listGeneralOrderOfficeSummaries, + searchGeneralOrder, + type GeneralOrderSearchResult, + type GeneralOrderOfficeDetail, + type GeneralOrderOfficeSummary, + type GeneralOrderSourceSummary, +} from "@govgraph/db"; + +type GeneralOrderOverviewState = + | { + status: "ready"; + source: GeneralOrderSourceSummary; + offices: GeneralOrderOfficeSummary[]; + } + | { status: "empty" } + | { status: "error"; message: string }; + +type GeneralOrderOfficeState = + | { + status: "ready"; + detail: GeneralOrderOfficeDetail; + } + | { status: "empty" } + | { status: "missing" } + | { status: "error"; message: string }; + +type GeneralOrderSearchState = + | { + status: "ready"; + source: GeneralOrderSourceSummary; + results: GeneralOrderSearchResult[]; + } + | { status: "empty" } + | { status: "error"; message: string }; + +function describeLoadFailure(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return "Unknown database error"; +} + +export const loadGeneralOrderOverview = cache( + async (): Promise => { + const client = createDbClient(); + + try { + const source = await getLatestGeneralOrderSourceSummary(client.db); + + if (!source) { + return { status: "empty" }; + } + + const offices = await listGeneralOrderOfficeSummaries( + client.db, + source.sourceDocumentId, + ); + + return { + status: "ready", + source, + offices, + }; + } catch (error) { + console.error("Unable to load General Order overview.", error); + + return { + status: "error", + message: describeLoadFailure(error), + }; + } finally { + await closeDbClient(client); + } + }, +); + +export const loadGeneralOrderOffice = cache( + async (officeSlug: string): Promise => { + const client = createDbClient(); + + try { + const source = await getLatestGeneralOrderSourceSummary(client.db); + + if (!source) { + return { status: "empty" }; + } + + const detail = await getGeneralOrderOfficeDetail( + client.db, + officeSlug, + source.sourceDocumentId, + ); + + if (!detail) { + return { status: "missing" }; + } + + return { + status: "ready", + detail, + }; + } catch (error) { + console.error(`Unable to load General Order office ${officeSlug}.`, error); + + return { + status: "error", + message: describeLoadFailure(error), + }; + } finally { + await closeDbClient(client); + } + }, +); + +export const loadGeneralOrderSearch = cache( + async (query: string): Promise => { + const trimmedQuery = query.trim(); + + if (!trimmedQuery) { + return { status: "empty" }; + } + + const client = createDbClient(); + + try { + const source = await getLatestGeneralOrderSourceSummary(client.db); + + if (!source) { + return { status: "empty" }; + } + + const results = await searchGeneralOrder(client.db, trimmedQuery, 8); + + return { + status: "ready", + source, + results, + }; + } catch (error) { + console.error(`Unable to search General Order records for ${trimmedQuery}.`, error); + + return { + status: "error", + message: describeLoadFailure(error), + }; + } finally { + await closeDbClient(client); + } + }, +); diff --git a/apps/web/app/general-order/_lib/presenters.ts b/apps/web/app/general-order/_lib/presenters.ts new file mode 100644 index 0000000..d6797e3 --- /dev/null +++ b/apps/web/app/general-order/_lib/presenters.ts @@ -0,0 +1,65 @@ +import type { GeneralOrderRule, ProvisionReference } from "@govgraph/domain"; + +export function humanizeAdministrationMode( + administrationMode: GeneralOrderRule["administrationMode"], +): string { + switch (administrationMode) { + case "joint_and_several": + return "Joint and several"; + case "joint": + return "Joint"; + case "sole": + return "Sole"; + case "unknown": + return "Unknown"; + default: + return administrationMode; + } +} + +export function humanizeAdministrationScope( + scope: GeneralOrderRule["scope"], +): string { + switch (scope) { + case "whole_act": + return "Whole Act"; + case "provision_list": + return "Specific provisions"; + case "residual": + return "Residual"; + default: + return scope; + } +} + +export function humanizeRuleKind(ruleKind: GeneralOrderRule["ruleKind"]): string { + switch (ruleKind) { + case "default": + return "Default rule"; + case "listed_scope": + return "Listed carve-out"; + case "residual": + return "Residual rule"; + default: + return ruleKind; + } +} + +export function humanizeParseStatus( + parseStatus: GeneralOrderRule["parseStatus"], +): string { + switch (parseStatus) { + case "parsed": + return "Parsed"; + case "partial": + return "Partially parsed"; + default: + return parseStatus; + } +} + +export function summarizeProvisionReferences( + provisionReferences: ProvisionReference[], +): string { + return provisionReferences.map((reference) => reference.rawText).join(", "); +} diff --git a/apps/web/app/general-order/page.tsx b/apps/web/app/general-order/page.tsx new file mode 100644 index 0000000..9219112 --- /dev/null +++ b/apps/web/app/general-order/page.tsx @@ -0,0 +1,209 @@ +import type { Metadata } from "next"; +import type { Route } from "next"; +import Link from "next/link"; + +import { Badge, Card } from "@govgraph/ui"; + +import { loadGeneralOrderOverview } from "./_lib/data"; + +export const metadata: Metadata = { + title: "General Order | GovGraph", + description: + "Browse the Victorian General Order allocations of Acts to ministerial offices.", +}; + +export const dynamic = "force-dynamic"; + +function EmptyState({ + title, + body, +}: { + title: string; + body: string; +}) { + return ( + +

+ {title} +

+

+ {body} +

+
+ ); +} + +export default async function GeneralOrderPage() { + const state = await loadGeneralOrderOverview(); + + return ( +
+
+
+ + Back to atlas + +
+ General Order + Database-backed +
+
+

+ Browse Act allocations by ministerial office +

+

+ This view shows the latest imported Victorian General Order as a + browsable office index, so you can move from an office to the + Acts and carve-outs it administers. +

+
+
+ + {state.status === "ready" ? ( +
+ +

+ Offices +

+

+ {state.source.officeCount} +

+

+ ministerial offices in the imported order. +

+
+ +

+ Rules +

+

+ {state.source.ruleCount} +

+

+ parsed administration rules, including shared and partial ones. +

+
+
+ ) : ( + +

+ The General Order browser reads from Postgres, so this page comes + alive once the import has been loaded into the database. +

+
+ )} +
+ + {state.status === "empty" ? ( + + ) : null} + + {state.status === "error" ? ( + + ) : null} + + {state.status === "ready" ? ( + <> +
+ +

+ Act entries +

+

+ {state.source.actEntryCount} +

+

+ office-by-Act allocations captured from the order. +

+
+ +

+ Partial rules +

+

+ {state.source.partialRuleCount} +

+

+ rules that still preserve original wording alongside the parse. +

+
+ +

+ Effective date +

+

+ {state.source.effectiveDate ?? "Not recorded"} +

+

+ date attached to the imported source document. +

+
+ +

+ Source +

+

+ {state.source.title} +

+ + Open official page + +
+
+ +
+
+

+ Offices +

+

+ Current office index +

+
+
+ {state.offices.map((office) => ( + +
+ {office.actEntryCount} Acts + {office.ruleCount} rules + {office.sharedRuleCount > 0 ? ( + {office.sharedRuleCount} shared + ) : null} + {office.partialRuleCount > 0 ? ( + {office.partialRuleCount} partial + ) : null} +
+ + {office.officeName} + +

+ Browse the Acts, carve-outs, and shared arrangements recorded + for this office in the imported order. +

+
+ ))} +
+
+ + ) : null} +
+ ); +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 8cd532c..49c9b48 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -21,7 +21,7 @@ const mono = IBM_Plex_Mono({ export const metadata: Metadata = { title: "GovGraph", description: - "An official-source-first map of Victoria's current ministers, portfolios, and supporting departments.", + "An official-source-first map of Victoria's current ministers, offices, and supporting departments.", }; export default function RootLayout({ diff --git a/apps/web/app/not-found.tsx b/apps/web/app/not-found.tsx index c669103..c705ccd 100644 --- a/apps/web/app/not-found.tsx +++ b/apps/web/app/not-found.tsx @@ -14,7 +14,7 @@ export default function NotFound() {

The sprint 1 prototype only includes the current ministry, current - portfolio mapping, and department-backed portfolio relationships. + office mapping, and department-backed office relationships.

; }; +function SearchSectionHeading({ + eyebrow, + title, +}: { + eyebrow: string; + title: string; +}) { + return ( +
+

+ {eyebrow} +

+

+ {title} +

+
+ ); +} + +function EmptySearchState({ body }: { body: string }) { + return ( + +

+ No matches yet +

+

+ {body} +

+
+ ); +} + +function GeneralOrderSearchCard({ + result, +}: { + result: GeneralOrderSearchResult; +}) { + if (result.kind === "office") { + return ( + +
+ General Order office + {result.actEntryCount} Acts + {result.ruleCount} rules + {result.sharedRuleCount > 0 ? ( + {result.sharedRuleCount} shared + ) : null} + {result.partialRuleCount > 0 ? ( + {result.partialRuleCount} partial + ) : null} +
+ + {result.officeName} + +

+ Browse the Acts and carve-outs recorded for this office in the latest + imported General Order. +

+

+ Match: {result.matchReason} +

+
+ ); + } + + return ( + +
+ General Order Act + {result.ruleCount} rules + {result.sharedRuleCount > 0 ? {result.sharedRuleCount} shared : null} + {result.partialRuleCount > 0 ? ( + {result.partialRuleCount} partial + ) : null} +
+ + {result.actName} + +

+ Under {result.officeName} +

+

+ {result.previewText} +

+

+ Match: {result.matchReason} +

+
+ ); +} + export default async function HomePage({ searchParams }: HomePageProps) { const { q } = await searchParams; const query = firstQueryValue(q)?.trim(); - const catalog = await loadGovernmentCatalog(); + const [catalog, generalOrderSearch] = await Promise.all([ + loadGovernmentCatalog(), + query ? loadGeneralOrderSearch(query) : Promise.resolve({ status: "empty" as const }), + ]); const results = query ? searchGovernmentCatalog(catalog, query, 12) : []; + const generalOrderResults = + generalOrderSearch.status === "ready" ? generalOrderSearch.results : []; + const totalSearchMatches = results.length + generalOrderResults.length; const featuredNodes = [ ...catalog.nodes.filter((node) => node.nodeType === "budget_document").slice(0, 2), ...catalog.nodes.filter((node) => node.nodeType === "public_entity").slice(0, 2), ...catalog.nodes.filter((node) => node.nodeType === "department").slice(0, 2), - ...catalog.nodes.filter((node) => node.nodeType === "person").slice(0, 2), + ...catalog.nodes.filter((node) => node.nodeType === "ministerial_office").slice(0, 1), + ...catalog.nodes.filter((node) => node.nodeType === "person").slice(0, 1), ].slice(0, 8); const implementedConnectors = sourceRegistry.filter( (entry) => entry.implementationStatus === "implemented", @@ -46,10 +153,10 @@ export default async function HomePage({ searchParams }: HomePageProps) { Victorian Government Map

- Search the Victorian Government across structure, entities, and budget papers. + Search the Victorian Government across offices, entities, and budget papers.

- The graph now combines the current ministry and portfolio map + The graph now combines the current ministry and office map with the VPSC employers directory and the Victorian Budget paper index, plus structured budget outputs and performance measures, so public entities and budget records sit beside the core @@ -57,6 +164,14 @@ export default async function HomePage({ searchParams }: HomePageProps) {

+
+ + Browse General Order Act allocations + +
@@ -68,7 +183,7 @@ export default async function HomePage({ searchParams }: HomePageProps) { {catalog.summary.totalNodes}

- records across ministers, departments, public entities, outputs, + records across ministers, offices, departments, public entities, outputs, measures, and budget documents.

@@ -122,14 +237,101 @@ export default async function HomePage({ searchParams }: HomePageProps) {
{query ? (

- {results.length} matches + {totalSearchMatches} matches

) : null}
- {(query ? results.map((result) => result.node) : featuredNodes).map( - (node) => ( + {query ? ( + <> + {results.length > 0 ? ( + <> + + {results.map((result) => { + const node = result.node; + + return ( + +
+ {humanizeNodeType(node.nodeType)} +
+ + {node.canonicalName} + +

+ {node.description ?? + "Current record in the live government graph snapshot."} +

+
+ ); + })} + + ) : null} + + {generalOrderSearch.status === "ready" && + generalOrderResults.length > 0 ? ( + <> + + {generalOrderResults.map((result) => ( + + ))} + + ) : null} + + {generalOrderSearch.status === "empty" ? ( + +

+ General Order records are not loaded yet +

+

+ The main atlas search is working, but Act allocation matches + will only appear after the General Order import has been + loaded into Postgres. +

+
+ ) : null} + + {generalOrderSearch.status === "error" ? ( + +

+ General Order search is unavailable right now +

+

+ The rest of the atlas search still works, but the app could + not reach the imported General Order records. Error:{" "} + {generalOrderSearch.message} +

+
+ ) : null} + + {results.length === 0 && + generalOrderResults.length === 0 && + generalOrderSearch.status === "ready" ? ( + + ) : null} + + ) : ( + featuredNodes.map((node) => ( - ), + )) )}
diff --git a/apps/web/package.json b/apps/web/package.json index 437b028..9ca3270 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,6 +10,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@govgraph/db": "workspace:*", "@govgraph/domain": "workspace:*", "@govgraph/parsers": "workspace:*", "@govgraph/ui": "workspace:*", diff --git a/apps/worker/package.json b/apps/worker/package.json index 7254a8a..2da9d47 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -9,6 +9,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@govgraph/db": "workspace:*", "@govgraph/parsers": "workspace:*", "@trigger.dev/sdk": "^4.0.0" } diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 0694eb7..95d94b9 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -1,5 +1,6 @@ export * from "./tasks/crawl-budget-index"; export * from "./tasks/sync-budget-performance-measures"; +export * from "./tasks/sync-general-order"; export * from "./tasks/sync-ministry"; export * from "./tasks/sync-vpsc-employers"; export * from "./tasks/sync-vpsc-portfolios"; diff --git a/apps/worker/src/tasks/sync-general-order.ts b/apps/worker/src/tasks/sync-general-order.ts new file mode 100644 index 0000000..838966e --- /dev/null +++ b/apps/worker/src/tasks/sync-general-order.ts @@ -0,0 +1,22 @@ +import { task } from "@trigger.dev/sdk"; + +import { + closeDbClient, + createDbClient, + persistGeneralOrderDataset, +} from "@govgraph/db"; +import { fetchVicGovGeneralOrder } from "@govgraph/parsers"; + +export const syncGeneralOrder = task({ + id: "sync-general-order", + run: async () => { + const snapshot = await fetchVicGovGeneralOrder({ kanonAssistance: true }); + const client = createDbClient(); + + try { + return await persistGeneralOrderDataset(client.db, snapshot); + } finally { + await closeDbClient(client); + } + }, +}); diff --git a/apps/worker/src/tasks/sync-ministry.ts b/apps/worker/src/tasks/sync-ministry.ts index 23c2c17..f303700 100644 --- a/apps/worker/src/tasks/sync-ministry.ts +++ b/apps/worker/src/tasks/sync-ministry.ts @@ -9,8 +9,8 @@ export const syncMinistry = task({ return { members: snapshot.members.length, - portfolioAssignments: snapshot.members.reduce( - (total, member) => total + member.titles.length, + officeAssignments: snapshot.members.reduce( + (total, member) => total + member.officeTitles.length, 0, ), }; diff --git a/apps/worker/src/tasks/sync-vpsc-portfolios.ts b/apps/worker/src/tasks/sync-vpsc-portfolios.ts index 8767063..af60188 100644 --- a/apps/worker/src/tasks/sync-vpsc-portfolios.ts +++ b/apps/worker/src/tasks/sync-vpsc-portfolios.ts @@ -10,8 +10,8 @@ export const syncVpscPortfolios = task({ return { effectiveDate: snapshot.asOfDate, departments: snapshot.sections.length, - portfolios: snapshot.sections.reduce( - (total, section) => total + section.portfolioTitles.length, + ministerialOffices: snapshot.sections.reduce( + (total, section) => total + section.officeTitles.length, 0, ), }; diff --git a/data_model.md b/data_model.md index 826665f..6e77c86 100644 --- a/data_model.md +++ b/data_model.md @@ -8,7 +8,7 @@ The key design decision is to separate **identity**, **relationships**, **metric type NodeType = | 'person' | 'ministry' - | 'portfolio' + | 'ministerial_office' | 'department' | 'administrative_office' | 'public_entity' @@ -22,7 +22,7 @@ type NodeType = ```ts type EdgeType = - | 'HOLDS_PORTFOLIO' + | 'HOLDS_OFFICE' | 'MEMBER_OF_MINISTRY' | 'SUPPORTED_BY_DEPARTMENT' | 'IN_PORTFOLIO' diff --git a/packages/db/drizzle/0000_brown_serpent_society.sql b/packages/db/drizzle/0000_brown_serpent_society.sql new file mode 100644 index 0000000..39791a4 --- /dev/null +++ b/packages/db/drizzle/0000_brown_serpent_society.sql @@ -0,0 +1,191 @@ +CREATE TYPE "public"."administration_mode" AS ENUM('sole', 'joint', 'joint_and_several', 'unknown');--> statement-breakpoint +CREATE TYPE "public"."administration_scope" AS ENUM('whole_act', 'provision_list', 'residual');--> statement-breakpoint +CREATE TYPE "public"."alias_type" AS ENUM('acronym', 'former_name', 'source_name', 'short_name');--> statement-breakpoint +CREATE TYPE "public"."assertion_confidence" AS ENUM('high', 'medium', 'low');--> statement-breakpoint +CREATE TYPE "public"."budget_phase" AS ENUM('budget', 'revised', 'actual');--> statement-breakpoint +CREATE TYPE "public"."change_event_type" AS ENUM('created', 'abolished', 'renamed', 'transferred', 'portfolio_changed');--> statement-breakpoint +CREATE TYPE "public"."edge_type" AS ENUM('HOLDS_OFFICE', 'MEMBER_OF_MINISTRY', 'SUPPORTED_BY_DEPARTMENT', 'IN_PORTFOLIO', 'REPORTS_TO', 'RESPONSIBLE_FOR', 'DELIVERS_OUTPUT', 'HAS_PERFORMANCE_MEASURE', 'HAS_CAPITAL_PROJECT', 'FUNDED_BY', 'SUCCEEDED_BY', 'RENAMED_TO', 'MOVED_TO_PORTFOLIO', 'HAS_SOURCE');--> statement-breakpoint +CREATE TYPE "public"."general_order_heading_style" AS ENUM('plain', 'except', 'scoped_list');--> statement-breakpoint +CREATE TYPE "public"."general_order_parse_status" AS ENUM('parsed', 'partial');--> statement-breakpoint +CREATE TYPE "public"."general_order_rule_kind" AS ENUM('default', 'listed_scope', 'residual');--> statement-breakpoint +CREATE TYPE "public"."node_type" AS ENUM('person', 'ministry', 'ministerial_office', 'department', 'administrative_office', 'public_entity', 'program_output', 'capital_project', 'performance_measure', 'budget_document', 'source_document', 'organisation_group');--> statement-breakpoint +CREATE TYPE "public"."record_status" AS ENUM('active', 'ceased', 'superseded', 'draft');--> statement-breakpoint +CREATE TYPE "public"."source_confidence" AS ENUM('high', 'medium', 'low');--> statement-breakpoint +CREATE TYPE "public"."source_family" AS ENUM('budget', 'gazette', 'vpsc', 'vic_gov', 'parliament');--> statement-breakpoint +CREATE TYPE "public"."source_type" AS ENUM('html', 'pdf', 'xlsx', 'csv', 'api');--> statement-breakpoint +CREATE TABLE "act_administration_rules" ( + "id" uuid PRIMARY KEY NOT NULL, + "source_document_id" uuid NOT NULL, + "effective_date" date, + "office_name" text NOT NULL, + "act_name" text NOT NULL, + "heading_text" text NOT NULL, + "heading_style" "general_order_heading_style" NOT NULL, + "rule_kind" "general_order_rule_kind" NOT NULL, + "scope" "administration_scope" NOT NULL, + "raw_text" text NOT NULL, + "scope_text" text, + "administration_mode" "administration_mode" NOT NULL, + "administering_office_names" jsonb NOT NULL, + "provision_references" jsonb NOT NULL, + "nested_raw_texts" jsonb NOT NULL, + "parse_status" "general_order_parse_status" NOT NULL, + "unparsed_tail" text, + "kanon_assistance" jsonb, + "created_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE "change_events" ( + "id" uuid PRIMARY KEY NOT NULL, + "event_type" "change_event_type" NOT NULL, + "affected_node_id" uuid, + "from_node_id" uuid, + "to_node_id" uuid, + "effective_date" date NOT NULL, + "source_document_id" uuid NOT NULL, + "summary" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "edge_source_documents" ( + "edge_id" uuid NOT NULL, + "source_document_id" uuid NOT NULL, + CONSTRAINT "edge_source_documents_edge_id_source_document_id_pk" PRIMARY KEY("edge_id","source_document_id") +); +--> statement-breakpoint +CREATE TABLE "edges" ( + "id" uuid PRIMARY KEY NOT NULL, + "edge_type" "edge_type" NOT NULL, + "from_node_id" uuid NOT NULL, + "to_node_id" uuid NOT NULL, + "valid_from" date, + "valid_to" date, + "status" "record_status" DEFAULT 'active' NOT NULL, + "notes" text +); +--> statement-breakpoint +CREATE TABLE "budget_metrics" ( + "id" uuid PRIMARY KEY NOT NULL, + "node_id" uuid NOT NULL, + "metric_family" text NOT NULL, + "metric_name" text NOT NULL, + "amount" numeric, + "currency" text DEFAULT 'AUD' NOT NULL, + "fiscal_year" text NOT NULL, + "budget_phase" "budget_phase" DEFAULT 'budget' NOT NULL, + "source_document_id" uuid NOT NULL, + "notes" text +); +--> statement-breakpoint +CREATE TABLE "capital_projects" ( + "id" uuid PRIMARY KEY NOT NULL, + "node_id" uuid, + "project_name" text NOT NULL, + "category" text, + "project_type" text, + "location_text" text, + "investment_amount" numeric, + "fiscal_year" text, + "source_document_id" uuid NOT NULL +); +--> statement-breakpoint +CREATE TABLE "performance_measures" ( + "id" uuid PRIMARY KEY NOT NULL, + "node_id" uuid NOT NULL, + "measure_name" text NOT NULL, + "measure_unit" text, + "target_value" text, + "actual_value" text, + "period_label" text NOT NULL, + "source_document_id" uuid NOT NULL +); +--> statement-breakpoint +CREATE TABLE "workforce_metrics" ( + "id" uuid PRIMARY KEY NOT NULL, + "node_id" uuid NOT NULL, + "metric_name" text NOT NULL, + "metric_value" numeric NOT NULL, + "as_at_date" date NOT NULL, + "source_document_id" uuid NOT NULL +); +--> statement-breakpoint +CREATE TABLE "node_aliases" ( + "id" uuid PRIMARY KEY NOT NULL, + "node_id" uuid NOT NULL, + "alias" text NOT NULL, + "alias_type" "alias_type" NOT NULL, + "source_document_id" uuid, + "valid_from" date, + "valid_to" date +); +--> statement-breakpoint +CREATE TABLE "nodes" ( + "id" uuid PRIMARY KEY NOT NULL, + "node_type" "node_type" NOT NULL, + "canonical_name" text NOT NULL, + "slug" text NOT NULL, + "status" "record_status" DEFAULT 'active' NOT NULL, + "description" text, + "website_url" text, + "source_confidence" "source_confidence" DEFAULT 'high' NOT NULL, + "created_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE "source_assertions" ( + "id" uuid PRIMARY KEY NOT NULL, + "source_document_id" uuid NOT NULL, + "subject_text" text NOT NULL, + "predicate" text NOT NULL, + "object_text" text, + "value_json" jsonb, + "page_ref" text, + "row_ref" text, + "extracted_at" timestamp with time zone NOT NULL, + "confidence" "assertion_confidence" NOT NULL +); +--> statement-breakpoint +CREATE TABLE "source_documents" ( + "id" uuid PRIMARY KEY NOT NULL, + "source_type" "source_type" NOT NULL, + "source_family" "source_family" NOT NULL, + "title" text NOT NULL, + "source_url" text NOT NULL, + "publisher" text NOT NULL, + "publication_date" date, + "effective_date" date, + "retrieved_at" timestamp with time zone NOT NULL, + "checksum" text, + "parser_version" text NOT NULL, + "licence" text, + "raw_storage_path" text NOT NULL +); +--> statement-breakpoint +ALTER TABLE "act_administration_rules" ADD CONSTRAINT "act_administration_rules_source_document_id_source_documents_id_fk" FOREIGN KEY ("source_document_id") REFERENCES "public"."source_documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "change_events" ADD CONSTRAINT "change_events_affected_node_id_nodes_id_fk" FOREIGN KEY ("affected_node_id") REFERENCES "public"."nodes"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "change_events" ADD CONSTRAINT "change_events_from_node_id_nodes_id_fk" FOREIGN KEY ("from_node_id") REFERENCES "public"."nodes"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "change_events" ADD CONSTRAINT "change_events_to_node_id_nodes_id_fk" FOREIGN KEY ("to_node_id") REFERENCES "public"."nodes"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "change_events" ADD CONSTRAINT "change_events_source_document_id_source_documents_id_fk" FOREIGN KEY ("source_document_id") REFERENCES "public"."source_documents"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "edge_source_documents" ADD CONSTRAINT "edge_source_documents_edge_id_edges_id_fk" FOREIGN KEY ("edge_id") REFERENCES "public"."edges"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "edge_source_documents" ADD CONSTRAINT "edge_source_documents_source_document_id_source_documents_id_fk" FOREIGN KEY ("source_document_id") REFERENCES "public"."source_documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "edges" ADD CONSTRAINT "edges_from_node_id_nodes_id_fk" FOREIGN KEY ("from_node_id") REFERENCES "public"."nodes"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "edges" ADD CONSTRAINT "edges_to_node_id_nodes_id_fk" FOREIGN KEY ("to_node_id") REFERENCES "public"."nodes"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "budget_metrics" ADD CONSTRAINT "budget_metrics_node_id_nodes_id_fk" FOREIGN KEY ("node_id") REFERENCES "public"."nodes"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "budget_metrics" ADD CONSTRAINT "budget_metrics_source_document_id_source_documents_id_fk" FOREIGN KEY ("source_document_id") REFERENCES "public"."source_documents"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "capital_projects" ADD CONSTRAINT "capital_projects_node_id_nodes_id_fk" FOREIGN KEY ("node_id") REFERENCES "public"."nodes"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "capital_projects" ADD CONSTRAINT "capital_projects_source_document_id_source_documents_id_fk" FOREIGN KEY ("source_document_id") REFERENCES "public"."source_documents"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "performance_measures" ADD CONSTRAINT "performance_measures_node_id_nodes_id_fk" FOREIGN KEY ("node_id") REFERENCES "public"."nodes"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "performance_measures" ADD CONSTRAINT "performance_measures_source_document_id_source_documents_id_fk" FOREIGN KEY ("source_document_id") REFERENCES "public"."source_documents"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workforce_metrics" ADD CONSTRAINT "workforce_metrics_node_id_nodes_id_fk" FOREIGN KEY ("node_id") REFERENCES "public"."nodes"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workforce_metrics" ADD CONSTRAINT "workforce_metrics_source_document_id_source_documents_id_fk" FOREIGN KEY ("source_document_id") REFERENCES "public"."source_documents"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "node_aliases" ADD CONSTRAINT "node_aliases_node_id_nodes_id_fk" FOREIGN KEY ("node_id") REFERENCES "public"."nodes"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "node_aliases" ADD CONSTRAINT "node_aliases_source_document_id_source_documents_id_fk" FOREIGN KEY ("source_document_id") REFERENCES "public"."source_documents"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "source_assertions" ADD CONSTRAINT "source_assertions_source_document_id_source_documents_id_fk" FOREIGN KEY ("source_document_id") REFERENCES "public"."source_documents"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "act_administration_rules_source_document_idx" ON "act_administration_rules" USING btree ("source_document_id");--> statement-breakpoint +CREATE INDEX "act_administration_rules_office_lookup_idx" ON "act_administration_rules" USING btree ("office_name","act_name");--> statement-breakpoint +CREATE INDEX "edge_source_documents_source_document_idx" ON "edge_source_documents" USING btree ("source_document_id");--> statement-breakpoint +CREATE INDEX "edges_lookup_idx" ON "edges" USING btree ("edge_type","from_node_id","to_node_id");--> statement-breakpoint +CREATE INDEX "node_aliases_node_alias_idx" ON "node_aliases" USING btree ("node_id","alias");--> statement-breakpoint +CREATE UNIQUE INDEX "nodes_slug_idx" ON "nodes" USING btree ("slug");--> statement-breakpoint +CREATE INDEX "nodes_type_name_idx" ON "nodes" USING btree ("node_type","canonical_name");--> statement-breakpoint +CREATE UNIQUE INDEX "source_documents_source_url_idx" ON "source_documents" USING btree ("source_url"); diff --git a/packages/db/drizzle/meta/0000_snapshot.json b/packages/db/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..f022c6d --- /dev/null +++ b/packages/db/drizzle/meta/0000_snapshot.json @@ -0,0 +1,1460 @@ +{ + "id": "665ea40a-a5ca-46c2-a52d-0bece0c15f63", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.act_administration_rules": { + "name": "act_administration_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "source_document_id": { + "name": "source_document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "effective_date": { + "name": "effective_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "office_name": { + "name": "office_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "act_name": { + "name": "act_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "heading_text": { + "name": "heading_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "heading_style": { + "name": "heading_style", + "type": "general_order_heading_style", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "rule_kind": { + "name": "rule_kind", + "type": "general_order_rule_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "administration_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_text": { + "name": "scope_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "administration_mode": { + "name": "administration_mode", + "type": "administration_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "administering_office_names": { + "name": "administering_office_names", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "provision_references": { + "name": "provision_references", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "nested_raw_texts": { + "name": "nested_raw_texts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "parse_status": { + "name": "parse_status", + "type": "general_order_parse_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "unparsed_tail": { + "name": "unparsed_tail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kanon_assistance": { + "name": "kanon_assistance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "act_administration_rules_source_document_idx": { + "name": "act_administration_rules_source_document_idx", + "columns": [ + { + "expression": "source_document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "act_administration_rules_office_lookup_idx": { + "name": "act_administration_rules_office_lookup_idx", + "columns": [ + { + "expression": "office_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "act_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "act_administration_rules_source_document_id_source_documents_id_fk": { + "name": "act_administration_rules_source_document_id_source_documents_id_fk", + "tableFrom": "act_administration_rules", + "tableTo": "source_documents", + "columnsFrom": [ + "source_document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.change_events": { + "name": "change_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "change_event_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "affected_node_id": { + "name": "affected_node_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "from_node_id": { + "name": "from_node_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "to_node_id": { + "name": "to_node_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "effective_date": { + "name": "effective_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "source_document_id": { + "name": "source_document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "change_events_affected_node_id_nodes_id_fk": { + "name": "change_events_affected_node_id_nodes_id_fk", + "tableFrom": "change_events", + "tableTo": "nodes", + "columnsFrom": [ + "affected_node_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "change_events_from_node_id_nodes_id_fk": { + "name": "change_events_from_node_id_nodes_id_fk", + "tableFrom": "change_events", + "tableTo": "nodes", + "columnsFrom": [ + "from_node_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "change_events_to_node_id_nodes_id_fk": { + "name": "change_events_to_node_id_nodes_id_fk", + "tableFrom": "change_events", + "tableTo": "nodes", + "columnsFrom": [ + "to_node_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "change_events_source_document_id_source_documents_id_fk": { + "name": "change_events_source_document_id_source_documents_id_fk", + "tableFrom": "change_events", + "tableTo": "source_documents", + "columnsFrom": [ + "source_document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.edge_source_documents": { + "name": "edge_source_documents", + "schema": "", + "columns": { + "edge_id": { + "name": "edge_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_document_id": { + "name": "source_document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "edge_source_documents_source_document_idx": { + "name": "edge_source_documents_source_document_idx", + "columns": [ + { + "expression": "source_document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "edge_source_documents_edge_id_edges_id_fk": { + "name": "edge_source_documents_edge_id_edges_id_fk", + "tableFrom": "edge_source_documents", + "tableTo": "edges", + "columnsFrom": [ + "edge_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "edge_source_documents_source_document_id_source_documents_id_fk": { + "name": "edge_source_documents_source_document_id_source_documents_id_fk", + "tableFrom": "edge_source_documents", + "tableTo": "source_documents", + "columnsFrom": [ + "source_document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "edge_source_documents_edge_id_source_document_id_pk": { + "name": "edge_source_documents_edge_id_source_document_id_pk", + "columns": [ + "edge_id", + "source_document_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.edges": { + "name": "edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "edge_type": { + "name": "edge_type", + "type": "edge_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "from_node_id": { + "name": "from_node_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "to_node_id": { + "name": "to_node_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "valid_from": { + "name": "valid_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "valid_to": { + "name": "valid_to", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "record_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "edges_lookup_idx": { + "name": "edges_lookup_idx", + "columns": [ + { + "expression": "edge_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "from_node_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "to_node_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "edges_from_node_id_nodes_id_fk": { + "name": "edges_from_node_id_nodes_id_fk", + "tableFrom": "edges", + "tableTo": "nodes", + "columnsFrom": [ + "from_node_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "edges_to_node_id_nodes_id_fk": { + "name": "edges_to_node_id_nodes_id_fk", + "tableFrom": "edges", + "tableTo": "nodes", + "columnsFrom": [ + "to_node_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.budget_metrics": { + "name": "budget_metrics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "metric_family": { + "name": "metric_family", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric_name": { + "name": "metric_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'AUD'" + }, + "fiscal_year": { + "name": "fiscal_year", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "budget_phase": { + "name": "budget_phase", + "type": "budget_phase", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'budget'" + }, + "source_document_id": { + "name": "source_document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "budget_metrics_node_id_nodes_id_fk": { + "name": "budget_metrics_node_id_nodes_id_fk", + "tableFrom": "budget_metrics", + "tableTo": "nodes", + "columnsFrom": [ + "node_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "budget_metrics_source_document_id_source_documents_id_fk": { + "name": "budget_metrics_source_document_id_source_documents_id_fk", + "tableFrom": "budget_metrics", + "tableTo": "source_documents", + "columnsFrom": [ + "source_document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.capital_projects": { + "name": "capital_projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_type": { + "name": "project_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location_text": { + "name": "location_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "investment_amount": { + "name": "investment_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "fiscal_year": { + "name": "fiscal_year", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_document_id": { + "name": "source_document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "capital_projects_node_id_nodes_id_fk": { + "name": "capital_projects_node_id_nodes_id_fk", + "tableFrom": "capital_projects", + "tableTo": "nodes", + "columnsFrom": [ + "node_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "capital_projects_source_document_id_source_documents_id_fk": { + "name": "capital_projects_source_document_id_source_documents_id_fk", + "tableFrom": "capital_projects", + "tableTo": "source_documents", + "columnsFrom": [ + "source_document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.performance_measures": { + "name": "performance_measures", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "measure_name": { + "name": "measure_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "measure_unit": { + "name": "measure_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_value": { + "name": "target_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actual_value": { + "name": "actual_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_label": { + "name": "period_label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document_id": { + "name": "source_document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "performance_measures_node_id_nodes_id_fk": { + "name": "performance_measures_node_id_nodes_id_fk", + "tableFrom": "performance_measures", + "tableTo": "nodes", + "columnsFrom": [ + "node_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "performance_measures_source_document_id_source_documents_id_fk": { + "name": "performance_measures_source_document_id_source_documents_id_fk", + "tableFrom": "performance_measures", + "tableTo": "source_documents", + "columnsFrom": [ + "source_document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workforce_metrics": { + "name": "workforce_metrics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "metric_name": { + "name": "metric_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric_value": { + "name": "metric_value", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "as_at_date": { + "name": "as_at_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "source_document_id": { + "name": "source_document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "workforce_metrics_node_id_nodes_id_fk": { + "name": "workforce_metrics_node_id_nodes_id_fk", + "tableFrom": "workforce_metrics", + "tableTo": "nodes", + "columnsFrom": [ + "node_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workforce_metrics_source_document_id_source_documents_id_fk": { + "name": "workforce_metrics_source_document_id_source_documents_id_fk", + "tableFrom": "workforce_metrics", + "tableTo": "source_documents", + "columnsFrom": [ + "source_document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.node_aliases": { + "name": "node_aliases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "alias": { + "name": "alias", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "alias_type": { + "name": "alias_type", + "type": "alias_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source_document_id": { + "name": "source_document_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "valid_from": { + "name": "valid_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "valid_to": { + "name": "valid_to", + "type": "date", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "node_aliases_node_alias_idx": { + "name": "node_aliases_node_alias_idx", + "columns": [ + { + "expression": "node_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "alias", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "node_aliases_node_id_nodes_id_fk": { + "name": "node_aliases_node_id_nodes_id_fk", + "tableFrom": "node_aliases", + "tableTo": "nodes", + "columnsFrom": [ + "node_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "node_aliases_source_document_id_source_documents_id_fk": { + "name": "node_aliases_source_document_id_source_documents_id_fk", + "tableFrom": "node_aliases", + "tableTo": "source_documents", + "columnsFrom": [ + "source_document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.nodes": { + "name": "nodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "node_type": { + "name": "node_type", + "type": "node_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "canonical_name": { + "name": "canonical_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "record_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_confidence": { + "name": "source_confidence", + "type": "source_confidence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'high'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "nodes_slug_idx": { + "name": "nodes_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "nodes_type_name_idx": { + "name": "nodes_type_name_idx", + "columns": [ + { + "expression": "node_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "canonical_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.source_assertions": { + "name": "source_assertions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "source_document_id": { + "name": "source_document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subject_text": { + "name": "subject_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "predicate": { + "name": "predicate", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_text": { + "name": "object_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "value_json": { + "name": "value_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "page_ref": { + "name": "page_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "row_ref": { + "name": "row_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "extracted_at": { + "name": "extracted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "assertion_confidence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "source_assertions_source_document_id_source_documents_id_fk": { + "name": "source_assertions_source_document_id_source_documents_id_fk", + "tableFrom": "source_assertions", + "tableTo": "source_documents", + "columnsFrom": [ + "source_document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.source_documents": { + "name": "source_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "source_type": { + "name": "source_type", + "type": "source_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source_family": { + "name": "source_family", + "type": "source_family", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publisher": { + "name": "publisher", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publication_date": { + "name": "publication_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "effective_date": { + "name": "effective_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "retrieved_at": { + "name": "retrieved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_version": { + "name": "parser_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "licence": { + "name": "licence", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_storage_path": { + "name": "raw_storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "source_documents_source_url_idx": { + "name": "source_documents_source_url_idx", + "columns": [ + { + "expression": "source_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.administration_mode": { + "name": "administration_mode", + "schema": "public", + "values": [ + "sole", + "joint", + "joint_and_several", + "unknown" + ] + }, + "public.administration_scope": { + "name": "administration_scope", + "schema": "public", + "values": [ + "whole_act", + "provision_list", + "residual" + ] + }, + "public.alias_type": { + "name": "alias_type", + "schema": "public", + "values": [ + "acronym", + "former_name", + "source_name", + "short_name" + ] + }, + "public.assertion_confidence": { + "name": "assertion_confidence", + "schema": "public", + "values": [ + "high", + "medium", + "low" + ] + }, + "public.budget_phase": { + "name": "budget_phase", + "schema": "public", + "values": [ + "budget", + "revised", + "actual" + ] + }, + "public.change_event_type": { + "name": "change_event_type", + "schema": "public", + "values": [ + "created", + "abolished", + "renamed", + "transferred", + "portfolio_changed" + ] + }, + "public.edge_type": { + "name": "edge_type", + "schema": "public", + "values": [ + "HOLDS_OFFICE", + "MEMBER_OF_MINISTRY", + "SUPPORTED_BY_DEPARTMENT", + "IN_PORTFOLIO", + "REPORTS_TO", + "RESPONSIBLE_FOR", + "DELIVERS_OUTPUT", + "HAS_PERFORMANCE_MEASURE", + "HAS_CAPITAL_PROJECT", + "FUNDED_BY", + "SUCCEEDED_BY", + "RENAMED_TO", + "MOVED_TO_PORTFOLIO", + "HAS_SOURCE" + ] + }, + "public.general_order_heading_style": { + "name": "general_order_heading_style", + "schema": "public", + "values": [ + "plain", + "except", + "scoped_list" + ] + }, + "public.general_order_parse_status": { + "name": "general_order_parse_status", + "schema": "public", + "values": [ + "parsed", + "partial" + ] + }, + "public.general_order_rule_kind": { + "name": "general_order_rule_kind", + "schema": "public", + "values": [ + "default", + "listed_scope", + "residual" + ] + }, + "public.node_type": { + "name": "node_type", + "schema": "public", + "values": [ + "person", + "ministry", + "ministerial_office", + "department", + "administrative_office", + "public_entity", + "program_output", + "capital_project", + "performance_measure", + "budget_document", + "source_document", + "organisation_group" + ] + }, + "public.record_status": { + "name": "record_status", + "schema": "public", + "values": [ + "active", + "ceased", + "superseded", + "draft" + ] + }, + "public.source_confidence": { + "name": "source_confidence", + "schema": "public", + "values": [ + "high", + "medium", + "low" + ] + }, + "public.source_family": { + "name": "source_family", + "schema": "public", + "values": [ + "budget", + "gazette", + "vpsc", + "vic_gov", + "parliament" + ] + }, + "public.source_type": { + "name": "source_type", + "schema": "public", + "values": [ + "html", + "pdf", + "xlsx", + "csv", + "api" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json new file mode 100644 index 0000000..5d1c1ec --- /dev/null +++ b/packages/db/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1775551509841, + "tag": "0000_brown_serpent_society", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/packages/db/package.json b/packages/db/package.json index 97e8b85..7d478b5 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -9,9 +9,15 @@ "types": "./src/index.ts", "scripts": { "build": "tsc --noEmit", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "dependencies": { - "drizzle-orm": "^0.44.6" + "@govgraph/domain": "workspace:*", + "drizzle-orm": "^0.44.6", + "pg": "^8.16.3" + }, + "devDependencies": { + "@types/pg": "^8.20.0" } } diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts new file mode 100644 index 0000000..09e8928 --- /dev/null +++ b/packages/db/src/client.ts @@ -0,0 +1,28 @@ +import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; + +import * as schema from "./schema"; + +const DEFAULT_DATABASE_URL = "postgresql://postgres:postgres@localhost:5432/govgraph"; + +export type GovgraphDb = NodePgDatabase; + +export type GovgraphDbClient = { + db: GovgraphDb; + pool: Pool; +}; + +export function createDbClient( + connectionString = process.env.DATABASE_URL ?? DEFAULT_DATABASE_URL, +): GovgraphDbClient { + const pool = new Pool({ connectionString }); + + return { + db: drizzle(pool, { schema }), + pool, + }; +} + +export async function closeDbClient(client: GovgraphDbClient): Promise { + await client.pool.end(); +} diff --git a/packages/db/src/general-order-browse.test.ts b/packages/db/src/general-order-browse.test.ts new file mode 100644 index 0000000..14050a9 --- /dev/null +++ b/packages/db/src/general-order-browse.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; + +import { + buildGeneralOrderActEntries, + buildGeneralOrderOfficeSummary, +} from "./general-order-browse"; + +describe("buildGeneralOrderActEntries", () => { + it("groups rules under each act entry and sorts default rules first", () => { + const entries = buildGeneralOrderActEntries([ + { + officeName: "Attorney-General", + actName: "Bail Act 1977", + headingText: "Bail Act 1977 - Except:", + headingStyle: "except", + ruleKind: "listed_scope", + scope: "provision_list", + rawText: + "Section 3B and Part 2A (these provisions are jointly and severally administered with the Minister for Youth Justice)", + scopeText: "Section 3B and Part 2A", + administrationMode: "joint_and_several", + administeringOfficeNames: [ + "Attorney-General", + "Minister for Youth Justice", + ], + provisionReferences: [ + { + rawText: "Section 3B", + unit: "section", + label: "3B", + }, + ], + nestedRawTexts: [], + parseStatus: "partial", + unparsedTail: "except to the extent that ...", + kanonAssistance: { + model: "kanon-2-enricher", + status: "enriched", + externalDocumentNames: ["Bail Act 1977"], + externalDocumentTypes: ["statute"], + pinpointTexts: ["Section 3B"], + pinpointReferences: [ + { + rawText: "Section 3B", + unit: "section", + label: "3B", + }, + ], + }, + }, + { + officeName: "Attorney-General", + actName: "Bail Act 1977", + headingText: "Bail Act 1977 - Except:", + headingStyle: "except", + ruleKind: "default", + scope: "whole_act", + rawText: "The Act is administered by Attorney-General", + scopeText: null, + administrationMode: "sole", + administeringOfficeNames: ["Attorney-General"], + provisionReferences: [], + nestedRawTexts: [], + parseStatus: "parsed", + unparsedTail: null, + kanonAssistance: null, + }, + ]); + + expect(entries).toHaveLength(1); + expect(entries[0]?.actName).toBe("Bail Act 1977"); + expect(entries[0]?.rules).toHaveLength(2); + expect(entries[0]?.rules[0]?.ruleKind).toBe("default"); + expect(entries[0]?.rules[1]?.kanonAssistance?.status).toBe("enriched"); + }); +}); + +describe("buildGeneralOrderOfficeSummary", () => { + it("normalizes the office summary fields for browse links", () => { + const summary = buildGeneralOrderOfficeSummary({ + officeName: "Minister for Youth Justice", + actEntryCount: 27, + ruleCount: 41, + partialRuleCount: 3, + sharedRuleCount: 9, + }); + + expect(summary).toEqual({ + officeName: "Minister for Youth Justice", + officeSlug: "minister-for-youth-justice", + actEntryCount: 27, + ruleCount: 41, + partialRuleCount: 3, + sharedRuleCount: 9, + }); + }); +}); diff --git a/packages/db/src/general-order-browse.ts b/packages/db/src/general-order-browse.ts new file mode 100644 index 0000000..f316013 --- /dev/null +++ b/packages/db/src/general-order-browse.ts @@ -0,0 +1,373 @@ +import { and, asc, desc, eq, sql } from "drizzle-orm"; + +import { + slugify, + type GeneralOrderActEntry, + type GeneralOrderKanonAssistance, + type GeneralOrderRule, + type ProvisionReference, +} from "@govgraph/domain"; + +import type { GovgraphDb } from "./client"; +import { actAdministrationRules, sourceDocuments } from "./schema"; + +type ActAdministrationRuleRow = typeof actAdministrationRules.$inferSelect; + +type GeneralOrderSourceSummaryRow = { + sourceDocumentId: string; + title: string; + sourceUrl: string; + effectiveDate: string | null; + retrievedAt: Date; + officeCount: number; + actEntryCount: number; + ruleCount: number; + partialRuleCount: number; +}; + +export type GeneralOrderSourceSummary = { + sourceDocumentId: string; + title: string; + sourceUrl: string; + effectiveDate?: string; + retrievedAt: string; + officeCount: number; + actEntryCount: number; + ruleCount: number; + partialRuleCount: number; +}; + +export type GeneralOrderOfficeSummary = { + officeName: string; + officeSlug: string; + actEntryCount: number; + ruleCount: number; + partialRuleCount: number; + sharedRuleCount: number; +}; + +export type GeneralOrderOfficeDetail = { + source: GeneralOrderSourceSummary; + officeName: string; + officeSlug: string; + actEntryCount: number; + partialRuleCount: number; + acts: GeneralOrderActEntry[]; +}; + +type GeneralOrderOfficeSummaryRow = { + officeName: string; + actEntryCount: number; + ruleCount: number; + partialRuleCount: number; + sharedRuleCount: number; +}; + +type GeneralOrderActEntryRow = Pick< + ActAdministrationRuleRow, + | "officeName" + | "actName" + | "headingText" + | "headingStyle" + | "ruleKind" + | "scope" + | "rawText" + | "scopeText" + | "administrationMode" + | "administeringOfficeNames" + | "provisionReferences" + | "nestedRawTexts" + | "parseStatus" + | "unparsedTail" + | "kanonAssistance" +>; + +function normalizeCount(value: number | string): number { + return typeof value === "number" ? value : Number(value); +} + +function toSourceSummary( + row: GeneralOrderSourceSummaryRow, +): GeneralOrderSourceSummary { + return { + sourceDocumentId: row.sourceDocumentId, + title: row.title, + sourceUrl: row.sourceUrl, + retrievedAt: row.retrievedAt.toISOString(), + officeCount: normalizeCount(row.officeCount), + actEntryCount: normalizeCount(row.actEntryCount), + ruleCount: normalizeCount(row.ruleCount), + partialRuleCount: normalizeCount(row.partialRuleCount), + ...(row.effectiveDate ? { effectiveDate: row.effectiveDate } : {}), + }; +} + +export function buildGeneralOrderOfficeSummary( + row: GeneralOrderOfficeSummaryRow, +): GeneralOrderOfficeSummary { + return { + officeName: row.officeName, + officeSlug: slugify(row.officeName), + actEntryCount: normalizeCount(row.actEntryCount), + ruleCount: normalizeCount(row.ruleCount), + partialRuleCount: normalizeCount(row.partialRuleCount), + sharedRuleCount: normalizeCount(row.sharedRuleCount), + }; +} + +function getRuleSortWeight(ruleKind: GeneralOrderRule["ruleKind"]): number { + switch (ruleKind) { + case "default": + return 0; + case "listed_scope": + return 1; + case "residual": + return 2; + default: + return 3; + } +} + +function compareRules(left: GeneralOrderRule, right: GeneralOrderRule): number { + const weightDifference = + getRuleSortWeight(left.ruleKind) - getRuleSortWeight(right.ruleKind); + + if (weightDifference !== 0) { + return weightDifference; + } + + return left.rawText.localeCompare(right.rawText); +} + +function toGeneralOrderRule(row: GeneralOrderActEntryRow): GeneralOrderRule { + return { + ruleKind: row.ruleKind, + scope: row.scope, + rawText: row.rawText, + scopeText: row.scopeText ?? undefined, + administeringOfficeNames: row.administeringOfficeNames as string[], + administrationMode: row.administrationMode, + provisionReferences: row.provisionReferences as ProvisionReference[], + nestedRawTexts: row.nestedRawTexts as string[], + parseStatus: row.parseStatus, + unparsedTail: row.unparsedTail ?? undefined, + kanonAssistance: + (row.kanonAssistance as GeneralOrderKanonAssistance | null) ?? undefined, + }; +} + +export function buildGeneralOrderActEntries( + rows: GeneralOrderActEntryRow[], +): GeneralOrderActEntry[] { + const entries = new Map(); + + for (const row of rows) { + const key = `${row.actName}::${row.headingText}::${row.headingStyle}`; + const existing = entries.get(key); + + if (existing) { + existing.rules.push(toGeneralOrderRule(row)); + continue; + } + + entries.set(key, { + officeName: row.officeName, + actName: row.actName, + headingText: row.headingText, + headingStyle: row.headingStyle, + rules: [toGeneralOrderRule(row)], + }); + } + + return Array.from(entries.values()) + .map((entry) => ({ + ...entry, + rules: [...entry.rules].sort(compareRules), + })) + .sort((left, right) => left.actName.localeCompare(right.actName)); +} + +async function getLatestGeneralOrderSourceRow( + db: GovgraphDb, +): Promise { + const [row] = await db + .select({ + sourceDocumentId: actAdministrationRules.sourceDocumentId, + title: sourceDocuments.title, + sourceUrl: sourceDocuments.sourceUrl, + effectiveDate: sourceDocuments.effectiveDate, + retrievedAt: sourceDocuments.retrievedAt, + officeCount: sql`count(distinct ${actAdministrationRules.officeName})`, + actEntryCount: + sql`count(distinct ${actAdministrationRules.officeName} || '::' || ${actAdministrationRules.actName} || '::' || ${actAdministrationRules.headingText} || '::' || ${actAdministrationRules.headingStyle})`, + ruleCount: sql`count(*)`, + partialRuleCount: + sql`sum(case when ${actAdministrationRules.parseStatus} <> 'parsed' then 1 else 0 end)`, + }) + .from(actAdministrationRules) + .innerJoin( + sourceDocuments, + eq(actAdministrationRules.sourceDocumentId, sourceDocuments.id), + ) + .groupBy( + actAdministrationRules.sourceDocumentId, + sourceDocuments.title, + sourceDocuments.sourceUrl, + sourceDocuments.effectiveDate, + sourceDocuments.retrievedAt, + ) + .orderBy(desc(sourceDocuments.effectiveDate), desc(sourceDocuments.retrievedAt)) + .limit(1); + + return row ?? null; +} + +export async function getLatestGeneralOrderSourceSummary( + db: GovgraphDb, +): Promise { + const row = await getLatestGeneralOrderSourceRow(db); + + return row ? toSourceSummary(row) : null; +} + +export async function listGeneralOrderOfficeSummaries( + db: GovgraphDb, + sourceDocumentId?: string, +): Promise { + const latestSource = + sourceDocumentId === undefined + ? await getLatestGeneralOrderSourceRow(db) + : null; + const resolvedSourceDocumentId = + sourceDocumentId ?? latestSource?.sourceDocumentId ?? null; + + if (!resolvedSourceDocumentId) { + return []; + } + + const rows = await db + .select({ + officeName: actAdministrationRules.officeName, + actEntryCount: + sql`count(distinct ${actAdministrationRules.actName} || '::' || ${actAdministrationRules.headingText} || '::' || ${actAdministrationRules.headingStyle})`, + ruleCount: sql`count(*)`, + partialRuleCount: + sql`sum(case when ${actAdministrationRules.parseStatus} <> 'parsed' then 1 else 0 end)`, + sharedRuleCount: + sql`sum(case when ${actAdministrationRules.administrationMode} <> 'sole' then 1 else 0 end)`, + }) + .from(actAdministrationRules) + .where(eq(actAdministrationRules.sourceDocumentId, resolvedSourceDocumentId)) + .groupBy(actAdministrationRules.officeName) + .orderBy(asc(actAdministrationRules.officeName)); + + return rows.map(buildGeneralOrderOfficeSummary); +} + +export async function getGeneralOrderOfficeDetail( + db: GovgraphDb, + officeSlug: string, + sourceDocumentId?: string, +): Promise { + const sourceSummary = + sourceDocumentId === undefined + ? await getLatestGeneralOrderSourceSummary(db) + : await (async () => { + const latestRow = await getLatestGeneralOrderSourceRow(db); + if (!latestRow || latestRow.sourceDocumentId !== sourceDocumentId) { + const [sourceRow] = await db + .select({ + sourceDocumentId: actAdministrationRules.sourceDocumentId, + title: sourceDocuments.title, + sourceUrl: sourceDocuments.sourceUrl, + effectiveDate: sourceDocuments.effectiveDate, + retrievedAt: sourceDocuments.retrievedAt, + officeCount: + sql`count(distinct ${actAdministrationRules.officeName})`, + actEntryCount: + sql`count(distinct ${actAdministrationRules.officeName} || '::' || ${actAdministrationRules.actName} || '::' || ${actAdministrationRules.headingText} || '::' || ${actAdministrationRules.headingStyle})`, + ruleCount: sql`count(*)`, + partialRuleCount: + sql`sum(case when ${actAdministrationRules.parseStatus} <> 'parsed' then 1 else 0 end)`, + }) + .from(actAdministrationRules) + .innerJoin( + sourceDocuments, + eq(actAdministrationRules.sourceDocumentId, sourceDocuments.id), + ) + .where(eq(actAdministrationRules.sourceDocumentId, sourceDocumentId)) + .groupBy( + actAdministrationRules.sourceDocumentId, + sourceDocuments.title, + sourceDocuments.sourceUrl, + sourceDocuments.effectiveDate, + sourceDocuments.retrievedAt, + ) + .limit(1); + + return sourceRow ? toSourceSummary(sourceRow) : null; + } + + return toSourceSummary(latestRow); + })(); + + if (!sourceSummary) { + return null; + } + + const officeRows = await db + .select({ + officeName: actAdministrationRules.officeName, + }) + .from(actAdministrationRules) + .where(eq(actAdministrationRules.sourceDocumentId, sourceSummary.sourceDocumentId)) + .groupBy(actAdministrationRules.officeName) + .orderBy(asc(actAdministrationRules.officeName)); + + const officeName = officeRows.find( + (row) => slugify(row.officeName) === officeSlug, + )?.officeName; + + if (!officeName) { + return null; + } + + const rows = await db + .select({ + officeName: actAdministrationRules.officeName, + actName: actAdministrationRules.actName, + headingText: actAdministrationRules.headingText, + headingStyle: actAdministrationRules.headingStyle, + ruleKind: actAdministrationRules.ruleKind, + scope: actAdministrationRules.scope, + rawText: actAdministrationRules.rawText, + scopeText: actAdministrationRules.scopeText, + administrationMode: actAdministrationRules.administrationMode, + administeringOfficeNames: actAdministrationRules.administeringOfficeNames, + provisionReferences: actAdministrationRules.provisionReferences, + nestedRawTexts: actAdministrationRules.nestedRawTexts, + parseStatus: actAdministrationRules.parseStatus, + unparsedTail: actAdministrationRules.unparsedTail, + kanonAssistance: actAdministrationRules.kanonAssistance, + }) + .from(actAdministrationRules) + .where( + and( + eq(actAdministrationRules.sourceDocumentId, sourceSummary.sourceDocumentId), + eq(actAdministrationRules.officeName, officeName), + ), + ) + .orderBy(asc(actAdministrationRules.actName), asc(actAdministrationRules.rawText)); + + const acts = buildGeneralOrderActEntries(rows); + const partialRuleCount = rows.filter((row) => row.parseStatus !== "parsed").length; + + return { + source: sourceSummary, + officeName, + officeSlug, + actEntryCount: acts.length, + partialRuleCount, + acts, + }; +} diff --git a/packages/db/src/general-order-search.test.ts b/packages/db/src/general-order-search.test.ts new file mode 100644 index 0000000..04ecbb7 --- /dev/null +++ b/packages/db/src/general-order-search.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; + +import type { GeneralOrderOfficeSummary } from "./general-order-browse"; +import { + buildGeneralOrderSearchResults, + type GeneralOrderSearchRuleRow, +} from "./general-order-search"; + +const officeSummaries: GeneralOrderOfficeSummary[] = [ + { + officeName: "Attorney-General", + officeSlug: "attorney-general", + actEntryCount: 1, + ruleCount: 2, + partialRuleCount: 1, + sharedRuleCount: 1, + }, + { + officeName: "Minister for Youth Justice", + officeSlug: "minister-for-youth-justice", + actEntryCount: 1, + ruleCount: 1, + partialRuleCount: 0, + sharedRuleCount: 1, + }, +]; + +const rows: GeneralOrderSearchRuleRow[] = [ + { + officeName: "Attorney-General", + actName: "Bail Act 1977", + headingText: "Bail Act 1977 - Except:", + headingStyle: "except", + ruleKind: "default", + scope: "whole_act", + rawText: "The Act is administered by Attorney-General", + scopeText: null, + administrationMode: "sole", + administeringOfficeNames: ["Attorney-General"], + provisionReferences: [], + nestedRawTexts: [], + parseStatus: "parsed", + unparsedTail: null, + kanonAssistance: null, + }, + { + officeName: "Attorney-General", + actName: "Bail Act 1977", + headingText: "Bail Act 1977 - Except:", + headingStyle: "except", + ruleKind: "listed_scope", + scope: "provision_list", + rawText: + "Section 3B and Part 2A (these provisions are jointly and severally administered with the Minister for Youth Justice)", + scopeText: "Section 3B and Part 2A", + administrationMode: "joint_and_several", + administeringOfficeNames: [ + "Attorney-General", + "Minister for Youth Justice", + ], + provisionReferences: [ + { + rawText: "Section 3B", + unit: "section", + label: "3B", + }, + { + rawText: "Part 2A", + unit: "part", + label: "2A", + }, + ], + nestedRawTexts: [], + parseStatus: "partial", + unparsedTail: "except to the extent that ...", + kanonAssistance: { + model: "kanon-2-enricher", + status: "enriched", + externalDocumentNames: ["Bail Act 1977"], + externalDocumentTypes: ["statute"], + pinpointTexts: ["Section 3B", "Part 2A"], + pinpointReferences: [ + { + rawText: "Section 3B", + unit: "section", + label: "3B", + }, + ], + }, + }, + { + officeName: "Minister for Youth Justice", + actName: "Bail Act 1977", + headingText: "Bail Act 1977 - Except:", + headingStyle: "except", + ruleKind: "listed_scope", + scope: "provision_list", + rawText: + "Section 3B and Part 2A (these provisions are jointly and severally administered with the Attorney-General)", + scopeText: "Section 3B and Part 2A", + administrationMode: "joint_and_several", + administeringOfficeNames: [ + "Minister for Youth Justice", + "Attorney-General", + ], + provisionReferences: [ + { + rawText: "Section 3B", + unit: "section", + label: "3B", + }, + { + rawText: "Part 2A", + unit: "part", + label: "2A", + }, + ], + nestedRawTexts: [], + parseStatus: "parsed", + unparsedTail: null, + kanonAssistance: null, + }, +]; + +describe("buildGeneralOrderSearchResults", () => { + it("returns office matches first for an office-name query", () => { + const results = buildGeneralOrderSearchResults( + officeSummaries, + rows, + "Attorney-General", + 5, + ); + + expect(results[0]).toMatchObject({ + kind: "office", + officeName: "Attorney-General", + matchReason: "office name match", + }); + }); + + it("returns the matching Act for an Act-name query", () => { + const results = buildGeneralOrderSearchResults( + officeSummaries, + rows, + "Bail Act 1977", + 5, + ); + + expect(results[0]).toMatchObject({ + kind: "act", + actName: "Bail Act 1977", + officeName: "Attorney-General", + matchReason: "Act name match", + }); + }); + + it("matches provision references for scoped carve-outs", () => { + const results = buildGeneralOrderSearchResults( + officeSummaries, + rows, + "Section 3B", + 5, + ); + + const actResult = results.find((result) => result.kind === "act"); + + expect(actResult).toMatchObject({ + kind: "act", + actName: "Bail Act 1977", + matchReason: "provision reference match", + previewText: "Section 3B", + }); + }); +}); diff --git a/packages/db/src/general-order-search.ts b/packages/db/src/general-order-search.ts new file mode 100644 index 0000000..18a3b83 --- /dev/null +++ b/packages/db/src/general-order-search.ts @@ -0,0 +1,403 @@ +import { asc, eq } from "drizzle-orm"; + +import { + normalizeForMatch, + slugify, + type GeneralOrderActEntry, + type GeneralOrderKanonAssistance, + type GeneralOrderRule, + type ProvisionReference, +} from "@govgraph/domain"; + +import type { GovgraphDb } from "./client"; +import { + buildGeneralOrderActEntries, + getLatestGeneralOrderSourceSummary, + listGeneralOrderOfficeSummaries, + type GeneralOrderOfficeSummary, +} from "./general-order-browse"; +import { actAdministrationRules } from "./schema"; + +export type GeneralOrderOfficeSearchResult = { + kind: "office"; + officeName: string; + officeSlug: string; + actEntryCount: number; + ruleCount: number; + partialRuleCount: number; + sharedRuleCount: number; + score: number; + matchReason: string; +}; + +export type GeneralOrderActSearchResult = { + kind: "act"; + officeName: string; + officeSlug: string; + actName: string; + headingText: string; + ruleCount: number; + partialRuleCount: number; + sharedRuleCount: number; + score: number; + matchReason: string; + previewText: string; +}; + +export type GeneralOrderSearchResult = + | GeneralOrderOfficeSearchResult + | GeneralOrderActSearchResult; + +type ScoredMatch = { + score: number; + matchReason: string; + previewText?: string; +}; + +export type GeneralOrderSearchRuleRow = { + officeName: string; + actName: string; + headingText: string; + headingStyle: GeneralOrderActEntry["headingStyle"]; + ruleKind: GeneralOrderRule["ruleKind"]; + scope: GeneralOrderRule["scope"]; + rawText: string; + scopeText: string | null; + administrationMode: GeneralOrderRule["administrationMode"]; + administeringOfficeNames: string[]; + provisionReferences: ProvisionReference[]; + nestedRawTexts: string[]; + parseStatus: GeneralOrderRule["parseStatus"]; + unparsedTail: string | null; + kanonAssistance: GeneralOrderKanonAssistance | null; +}; + +type GeneralOrderActEntrySearchIndex = { + officeName: string; + officeSlug: string; + actEntry: GeneralOrderActEntry; + partialRuleCount: number; + sharedRuleCount: number; +}; + +function scoreTextField( + rawValue: string, + normalizedQuery: string, + labels: { + exact: string; + startsWith: string; + contains: string; + }, + scores: { + exact: number; + startsWith: number; + contains: number; + }, +): ScoredMatch | null { + const normalizedValue = normalizeForMatch(rawValue); + + if (!normalizedValue) { + return null; + } + + if (normalizedValue === normalizedQuery) { + return { + score: scores.exact, + matchReason: labels.exact, + previewText: rawValue, + }; + } + + if (normalizedValue.startsWith(normalizedQuery)) { + return { + score: scores.startsWith, + matchReason: labels.startsWith, + previewText: rawValue, + }; + } + + if (normalizedValue.includes(normalizedQuery)) { + return { + score: scores.contains, + matchReason: labels.contains, + previewText: rawValue, + }; + } + + return null; +} + +function scoreOfficeSummary( + office: GeneralOrderOfficeSummary, + normalizedQuery: string, +): GeneralOrderOfficeSearchResult | null { + const match = scoreTextField( + office.officeName, + normalizedQuery, + { + exact: "office name match", + startsWith: "office name starts with query", + contains: "office name contains query", + }, + { + exact: 100, + startsWith: 90, + contains: 80, + }, + ); + + if (!match) { + return null; + } + + return { + kind: "office", + officeName: office.officeName, + officeSlug: office.officeSlug, + actEntryCount: office.actEntryCount, + ruleCount: office.ruleCount, + partialRuleCount: office.partialRuleCount, + sharedRuleCount: office.sharedRuleCount, + score: match.score, + matchReason: match.matchReason, + }; +} + +function scoreActEntry( + entry: GeneralOrderActEntrySearchIndex, + normalizedQuery: string, +): GeneralOrderActSearchResult | null { + const candidates: ScoredMatch[] = []; + + const actNameMatch = scoreTextField( + entry.actEntry.actName, + normalizedQuery, + { + exact: "Act name match", + startsWith: "Act name starts with query", + contains: "Act name contains query", + }, + { + exact: 98, + startsWith: 89, + contains: 78, + }, + ); + + if (actNameMatch) { + candidates.push(actNameMatch); + } + + const officeMatch = scoreTextField( + entry.officeName, + normalizedQuery, + { + exact: "administering office match", + startsWith: "administering office starts with query", + contains: "administering office contains query", + }, + { + exact: 74, + startsWith: 68, + contains: 62, + }, + ); + + if (officeMatch) { + candidates.push(officeMatch); + } + + const headingMatch = scoreTextField( + entry.actEntry.headingText, + normalizedQuery, + { + exact: "heading match", + startsWith: "heading starts with query", + contains: "heading contains query", + }, + { + exact: 70, + startsWith: 64, + contains: 58, + }, + ); + + if (headingMatch) { + candidates.push(headingMatch); + } + + for (const rule of entry.actEntry.rules) { + const ruleMatch = scoreTextField( + rule.rawText, + normalizedQuery, + { + exact: "rule text match", + startsWith: "rule text starts with query", + contains: "rule text contains query", + }, + { + exact: 67, + startsWith: 60, + contains: 54, + }, + ); + + if (ruleMatch) { + candidates.push(ruleMatch); + } + + for (const reference of rule.provisionReferences) { + const referenceMatch = scoreTextField( + reference.rawText, + normalizedQuery, + { + exact: "provision reference match", + startsWith: "provision reference starts with query", + contains: "provision reference contains query", + }, + { + exact: 72, + startsWith: 66, + contains: 59, + }, + ); + + if (referenceMatch) { + candidates.push(referenceMatch); + } + } + } + + const bestMatch = candidates.sort((left, right) => right.score - left.score)[0]; + + if (!bestMatch) { + return null; + } + + return { + kind: "act", + officeName: entry.officeName, + officeSlug: entry.officeSlug, + actName: entry.actEntry.actName, + headingText: entry.actEntry.headingText, + ruleCount: entry.actEntry.rules.length, + partialRuleCount: entry.partialRuleCount, + sharedRuleCount: entry.sharedRuleCount, + score: bestMatch.score, + matchReason: bestMatch.matchReason, + previewText: bestMatch.previewText ?? entry.actEntry.rules[0]?.rawText ?? "", + }; +} + +function compareSearchResults( + left: GeneralOrderSearchResult, + right: GeneralOrderSearchResult, +): number { + if (right.score !== left.score) { + return right.score - left.score; + } + + const leftLabel = + left.kind === "office" + ? left.officeName + : `${left.actName} ${left.officeName}`; + const rightLabel = + right.kind === "office" + ? right.officeName + : `${right.actName} ${right.officeName}`; + + return leftLabel.localeCompare(rightLabel); +} + +export function buildGeneralOrderSearchResults( + officeSummaries: GeneralOrderOfficeSummary[], + rows: GeneralOrderSearchRuleRow[], + rawQuery: string, + limit = 8, +): GeneralOrderSearchResult[] { + const normalizedQuery = normalizeForMatch(rawQuery); + + if (!normalizedQuery) { + return []; + } + + const actEntries = buildGeneralOrderActEntries(rows); + + const actResults = actEntries + .map((actEntry) => { + return scoreActEntry( + { + officeName: actEntry.officeName, + officeSlug: slugify(actEntry.officeName), + actEntry, + partialRuleCount: actEntry.rules.filter( + (rule) => rule.parseStatus !== "parsed", + ).length, + sharedRuleCount: actEntry.rules.filter( + (rule) => rule.administrationMode !== "sole", + ).length, + }, + normalizedQuery, + ); + }) + .filter((result): result is GeneralOrderActSearchResult => result !== null); + + const officeResults = officeSummaries + .map((office) => scoreOfficeSummary(office, normalizedQuery)) + .filter( + (result): result is GeneralOrderOfficeSearchResult => result !== null, + ); + + return [...officeResults, ...actResults] + .sort(compareSearchResults) + .slice(0, limit); +} + +export async function searchGeneralOrder( + db: GovgraphDb, + rawQuery: string, + limit = 8, +): Promise { + const normalizedQuery = normalizeForMatch(rawQuery); + + if (!normalizedQuery) { + return []; + } + + const source = await getLatestGeneralOrderSourceSummary(db); + + if (!source) { + return []; + } + + const [officeSummaries, rows] = await Promise.all([ + listGeneralOrderOfficeSummaries(db, source.sourceDocumentId), + db + .select({ + officeName: actAdministrationRules.officeName, + actName: actAdministrationRules.actName, + headingText: actAdministrationRules.headingText, + headingStyle: actAdministrationRules.headingStyle, + ruleKind: actAdministrationRules.ruleKind, + scope: actAdministrationRules.scope, + rawText: actAdministrationRules.rawText, + scopeText: actAdministrationRules.scopeText, + administrationMode: actAdministrationRules.administrationMode, + administeringOfficeNames: actAdministrationRules.administeringOfficeNames, + provisionReferences: actAdministrationRules.provisionReferences, + nestedRawTexts: actAdministrationRules.nestedRawTexts, + parseStatus: actAdministrationRules.parseStatus, + unparsedTail: actAdministrationRules.unparsedTail, + kanonAssistance: actAdministrationRules.kanonAssistance, + }) + .from(actAdministrationRules) + .where(eq(actAdministrationRules.sourceDocumentId, source.sourceDocumentId)) + .orderBy( + asc(actAdministrationRules.officeName), + asc(actAdministrationRules.actName), + asc(actAdministrationRules.rawText), + ), + ]); + + return buildGeneralOrderSearchResults(officeSummaries, rows, rawQuery, limit); +} diff --git a/packages/db/src/general-order.test.ts b/packages/db/src/general-order.test.ts new file mode 100644 index 0000000..8287fe3 --- /dev/null +++ b/packages/db/src/general-order.test.ts @@ -0,0 +1,106 @@ +import { generalOrderDatasetSchema } from "@govgraph/domain"; + +import { buildActAdministrationRuleRows } from "./general-order"; + +describe("buildActAdministrationRuleRows", () => { + it("flattens General Order rules into database rows", () => { + const dataset = generalOrderDatasetSchema.parse({ + source: { + id: "11111111-1111-5111-8111-111111111111", + sourceType: "html", + sourceFamily: "vic_gov", + title: "General Order effective 30 October 2025", + sourceUrl: "https://www.vic.gov.au/general-order-effective-30-october-2025", + publisher: "Victorian Government", + effectiveDate: "2025-10-30", + retrievedAt: "2026-04-07T00:00:00.000Z", + parserVersion: "test", + rawStoragePath: "fixtures/general-order.html", + }, + effectiveDate: "2025-10-30", + offices: [ + { + officeName: "Attorney-General", + acts: [ + { + officeName: "Attorney-General", + actName: "Bail Act 1977", + headingText: "Bail Act 1977 - Except:", + headingStyle: "except", + rules: [ + { + ruleKind: "default", + scope: "whole_act", + rawText: "The Act is administered by Attorney-General", + administeringOfficeNames: ["Attorney-General"], + administrationMode: "sole", + provisionReferences: [], + nestedRawTexts: [], + parseStatus: "parsed", + }, + { + ruleKind: "listed_scope", + scope: "provision_list", + rawText: + "Section 3B and Part 2A (these provisions are jointly and severally administered with the Minister for Youth Justice)", + scopeText: "Section 3B and Part 2A", + administeringOfficeNames: [ + "Attorney-General", + "Minister for Youth Justice", + ], + administrationMode: "joint_and_several", + provisionReferences: [ + { + rawText: "Section 3B", + unit: "section", + label: "3B", + }, + { + rawText: "Part 2A", + unit: "part", + label: "2A", + }, + ], + nestedRawTexts: [], + parseStatus: "partial", + unparsedTail: "except to the extent that ...", + kanonAssistance: { + model: "kanon-2-enricher", + status: "enriched", + externalDocumentNames: ["Bail Act 1977"], + externalDocumentTypes: ["statute"], + pinpointTexts: ["Section 3B", "Part 2A"], + pinpointReferences: [ + { + rawText: "Section 3B", + unit: "section", + label: "3B", + }, + ], + }, + }, + ], + }, + ], + }, + ], + }); + + const rows = buildActAdministrationRuleRows(dataset); + + expect(rows).toHaveLength(2); + expect(rows[0]?.officeName).toBe("Attorney-General"); + expect(rows[0]?.actName).toBe("Bail Act 1977"); + expect(rows[0]?.effectiveDate).toBe("2025-10-30"); + expect(rows[1]?.parseStatus).toBe("partial"); + expect(rows[1]?.administeringOfficeNames).toEqual([ + "Attorney-General", + "Minister for Youth Justice", + ]); + expect(rows[1]?.kanonAssistance?.pinpointTexts).toEqual([ + "Section 3B", + "Part 2A", + ]); + expect(rows[1]?.createdAt).toEqual(new Date("2026-04-07T00:00:00.000Z")); + }); +}); diff --git a/packages/db/src/general-order.ts b/packages/db/src/general-order.ts new file mode 100644 index 0000000..83c5167 --- /dev/null +++ b/packages/db/src/general-order.ts @@ -0,0 +1,128 @@ +import { eq } from "drizzle-orm"; + +import { + deterministicId, + type GeneralOrderDataset, + type SourceDocument, +} from "@govgraph/domain"; + +import type { GovgraphDb } from "./client"; +import { actAdministrationRules, sourceDocuments } from "./schema"; + +type SourceDocumentInsert = typeof sourceDocuments.$inferInsert; +type ActAdministrationRuleInsert = typeof actAdministrationRules.$inferInsert; + +function toSourceDocumentInsert(source: SourceDocument): SourceDocumentInsert { + return { + id: source.id, + sourceType: source.sourceType, + sourceFamily: source.sourceFamily, + title: source.title, + sourceUrl: source.sourceUrl, + publisher: source.publisher, + publicationDate: source.publicationDate, + effectiveDate: source.effectiveDate, + retrievedAt: new Date(source.retrievedAt), + parserVersion: source.parserVersion, + rawStoragePath: source.rawStoragePath, + }; +} + +export function buildActAdministrationRuleRows( + dataset: GeneralOrderDataset, +): ActAdministrationRuleInsert[] { + const timestamp = new Date(dataset.source.retrievedAt); + + return dataset.offices.flatMap((office, officeIndex) => + office.acts.flatMap((act, actIndex) => + act.rules.map((rule, ruleIndex) => ({ + id: deterministicId( + "act-administration-rule", + [ + dataset.source.id, + officeIndex, + actIndex, + ruleIndex, + office.officeName, + act.actName, + rule.ruleKind, + ].join(":"), + ), + sourceDocumentId: dataset.source.id, + effectiveDate: dataset.effectiveDate ?? dataset.source.effectiveDate, + officeName: office.officeName, + actName: act.actName, + headingText: act.headingText, + headingStyle: act.headingStyle, + ruleKind: rule.ruleKind, + scope: rule.scope, + rawText: rule.rawText, + scopeText: rule.scopeText, + administrationMode: rule.administrationMode, + administeringOfficeNames: rule.administeringOfficeNames, + provisionReferences: rule.provisionReferences, + nestedRawTexts: rule.nestedRawTexts, + parseStatus: rule.parseStatus, + unparsedTail: rule.unparsedTail, + kanonAssistance: rule.kanonAssistance, + createdAt: timestamp, + updatedAt: timestamp, + })), + ), + ); +} + +export async function persistGeneralOrderDataset( + db: GovgraphDb, + dataset: GeneralOrderDataset, +): Promise<{ + sourceDocumentId: string; + officeCount: number; + actCount: number; + ruleCount: number; + partialRuleCount: number; +}> { + const sourceDocument = toSourceDocumentInsert(dataset.source); + const ruleRows = buildActAdministrationRuleRows(dataset); + const actCount = dataset.offices.reduce((count, office) => count + office.acts.length, 0); + const partialRuleCount = ruleRows.filter( + (row) => row.parseStatus !== "parsed", + ).length; + + await db.transaction(async (tx) => { + await tx + .insert(sourceDocuments) + .values(sourceDocument) + .onConflictDoUpdate({ + target: sourceDocuments.id, + set: { + sourceType: sourceDocument.sourceType, + sourceFamily: sourceDocument.sourceFamily, + title: sourceDocument.title, + sourceUrl: sourceDocument.sourceUrl, + publisher: sourceDocument.publisher, + publicationDate: sourceDocument.publicationDate, + effectiveDate: sourceDocument.effectiveDate, + retrievedAt: sourceDocument.retrievedAt, + parserVersion: sourceDocument.parserVersion, + rawStoragePath: sourceDocument.rawStoragePath, + }, + }); + + await tx + .delete(actAdministrationRules) + .where(eq(actAdministrationRules.sourceDocumentId, dataset.source.id)); + + if (ruleRows.length > 0) { + await tx.insert(actAdministrationRules).values(ruleRows); + } + }); + + return { + sourceDocumentId: dataset.source.id, + officeCount: dataset.offices.length, + actCount, + ruleCount: ruleRows.length, + partialRuleCount, + }; +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 686fbd9..3dc8fe7 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1 +1,5 @@ +export * from "./client"; +export * from "./general-order-browse"; +export * from "./general-order"; +export * from "./general-order-search"; export * from "./schema"; diff --git a/packages/db/src/schema/act-administration.ts b/packages/db/src/schema/act-administration.ts new file mode 100644 index 0000000..6a448fb --- /dev/null +++ b/packages/db/src/schema/act-administration.ts @@ -0,0 +1,64 @@ +import { + date, + index, + jsonb, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; + +import type { + GeneralOrderKanonAssistance, + ProvisionReference, +} from "@govgraph/domain"; + +import { + administrationModeEnum, + administrationScopeEnum, + generalOrderHeadingStyleEnum, + generalOrderParseStatusEnum, + generalOrderRuleKindEnum, +} from "./enums"; +import { sourceDocuments } from "./sources"; + +export const actAdministrationRules = pgTable( + "act_administration_rules", + { + id: uuid("id").primaryKey(), + sourceDocumentId: uuid("source_document_id") + .notNull() + .references(() => sourceDocuments.id, { onDelete: "cascade" }), + effectiveDate: date("effective_date"), + officeName: text("office_name").notNull(), + actName: text("act_name").notNull(), + headingText: text("heading_text").notNull(), + headingStyle: generalOrderHeadingStyleEnum("heading_style").notNull(), + ruleKind: generalOrderRuleKindEnum("rule_kind").notNull(), + scope: administrationScopeEnum("scope").notNull(), + rawText: text("raw_text").notNull(), + scopeText: text("scope_text"), + administrationMode: administrationModeEnum("administration_mode").notNull(), + administeringOfficeNames: jsonb("administering_office_names") + .$type() + .notNull(), + provisionReferences: jsonb("provision_references") + .$type() + .notNull(), + nestedRawTexts: jsonb("nested_raw_texts").$type().notNull(), + parseStatus: generalOrderParseStatusEnum("parse_status").notNull(), + unparsedTail: text("unparsed_tail"), + kanonAssistance: jsonb("kanon_assistance").$type(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), + }, + (table) => ({ + sourceDocumentIdx: index("act_administration_rules_source_document_idx").on( + table.sourceDocumentId, + ), + officeLookupIdx: index("act_administration_rules_office_lookup_idx").on( + table.officeName, + table.actName, + ), + }), +); diff --git a/packages/db/src/schema/enums.ts b/packages/db/src/schema/enums.ts index 2e16492..b6496f3 100644 --- a/packages/db/src/schema/enums.ts +++ b/packages/db/src/schema/enums.ts @@ -3,7 +3,7 @@ import { pgEnum } from "drizzle-orm/pg-core"; export const nodeTypeEnum = pgEnum("node_type", [ "person", "ministry", - "portfolio", + "ministerial_office", "department", "administrative_office", "public_entity", @@ -16,7 +16,7 @@ export const nodeTypeEnum = pgEnum("node_type", [ ]); export const edgeTypeEnum = pgEnum("edge_type", [ - "HOLDS_PORTFOLIO", + "HOLDS_OFFICE", "MEMBER_OF_MINISTRY", "SUPPORTED_BY_DEPARTMENT", "IN_PORTFOLIO", @@ -88,3 +88,33 @@ export const changeEventTypeEnum = pgEnum("change_event_type", [ "transferred", "portfolio_changed", ]); + +export const generalOrderHeadingStyleEnum = pgEnum("general_order_heading_style", [ + "plain", + "except", + "scoped_list", +]); + +export const administrationModeEnum = pgEnum("administration_mode", [ + "sole", + "joint", + "joint_and_several", + "unknown", +]); + +export const administrationScopeEnum = pgEnum("administration_scope", [ + "whole_act", + "provision_list", + "residual", +]); + +export const generalOrderRuleKindEnum = pgEnum("general_order_rule_kind", [ + "default", + "listed_scope", + "residual", +]); + +export const generalOrderParseStatusEnum = pgEnum("general_order_parse_status", [ + "parsed", + "partial", +]); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 88c0bf8..5de743b 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -1,3 +1,4 @@ +export * from "./act-administration"; export * from "./change-events"; export * from "./edges"; export * from "./enums"; diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json index f2cf293..ccacd08 100644 --- a/packages/db/tsconfig.json +++ b/packages/db/tsconfig.json @@ -1,4 +1,7 @@ { "extends": "../config/ts/node.json", + "compilerOptions": { + "types": ["node", "vitest/globals"] + }, "include": ["src/**/*.ts", "drizzle.config.ts"] } diff --git a/packages/domain/src/government/catalog.test.ts b/packages/domain/src/government/catalog.test.ts index 2eef2d6..5bd04b2 100644 --- a/packages/domain/src/government/catalog.test.ts +++ b/packages/domain/src/government/catalog.test.ts @@ -33,7 +33,7 @@ const portfolios: VpscPortfolioDataset = { portfolioGroupName: "Education", departmentName: "Department of Education", departmentDescription: "Supports education in Victoria.", - portfolioTitles: ["Minister for Education", "Minister for Children"], + officeTitles: ["Minister for Education", "Minister for Children"], }, ], }; @@ -50,7 +50,7 @@ const ministry: MinistryDataset = { members: [ { personName: "Ben Carroll", - titles: ["Minister for Education", "Deputy Premier"], + officeTitles: ["Minister for Education", "Deputy Premier"], }, ], }; @@ -171,7 +171,7 @@ const performanceMeasures: BudgetPerformanceMeasuresDataset = { }; describe("buildGovernmentCatalog", () => { - it("creates departments, portfolios, people, employers, budget documents, and budget measures", () => { + it("creates departments, ministerial offices, people, employers, budget documents, and budget measures", () => { const catalog = buildGovernmentCatalog({ portfolios, ministry, @@ -184,9 +184,9 @@ describe("buildGovernmentCatalog", () => { expect( catalog.edges.some((edge) => edge.edgeType === "SUPPORTED_BY_DEPARTMENT"), ).toBe(true); - expect( - catalog.edges.some((edge) => edge.edgeType === "HOLDS_PORTFOLIO"), - ).toBe(true); + expect(catalog.edges.some((edge) => edge.edgeType === "HOLDS_OFFICE")).toBe( + true, + ); expect( catalog.edges.some((edge) => edge.edgeType === "IN_PORTFOLIO"), ).toBe(true); @@ -244,7 +244,7 @@ describe("buildGovernmentCatalog", () => { const profile = getNodeProfile(catalog, benCarroll.slug); expect( - profile?.relatedNodes.some((entry) => entry.edgeType === "HOLDS_PORTFOLIO"), + profile?.relatedNodes.some((entry) => entry.edgeType === "HOLDS_OFFICE"), ).toBe(true); const education = catalog.nodes.find( diff --git a/packages/domain/src/government/catalog.ts b/packages/domain/src/government/catalog.ts index a440753..bb2782c 100644 --- a/packages/domain/src/government/catalog.ts +++ b/packages/domain/src/government/catalog.ts @@ -59,7 +59,7 @@ function createCounts(nodes: GovernmentNode[]) { const counts = { person: 0, ministry: 0, - portfolio: 0, + ministerial_office: 0, department: 0, administrative_office: 0, public_entity: 0, @@ -207,12 +207,12 @@ function describeEmployer(employer: VpscEmployerInput): string { return parts.filter((value): value is string => value !== undefined).join(" "); } -function buildPortfolioNodes( +function buildOfficeNodes( sections: PortfolioSectionInput[], source: SourceDocument, nodeStore: Map, edgeStore: Map, - departmentByPortfolioTitle: Map, + departmentByOfficeTitle: Map, ) { for (const section of sections) { const department = upsertNode(nodeStore, { @@ -224,21 +224,21 @@ function buildPortfolioNodes( sourceDocumentIds: [source.id], }); - for (const title of section.portfolioTitles) { - const portfolio = upsertNode(nodeStore, { - nodeType: "portfolio", - canonicalName: title, - description: `${title} is supported by ${section.departmentName}.`, + for (const officeTitle of section.officeTitles) { + const office = upsertNode(nodeStore, { + nodeType: "ministerial_office", + canonicalName: officeTitle, + description: `${officeTitle} is supported by ${section.departmentName}.`, status: "active", sourceConfidence: "high", sourceDocumentIds: [source.id], }); - departmentByPortfolioTitle.set(normalizeForMatch(title), department); + departmentByOfficeTitle.set(normalizeForMatch(officeTitle), department); upsertEdge(edgeStore, { edgeType: "SUPPORTED_BY_DEPARTMENT", - fromNodeId: portfolio.id, + fromNodeId: office.id, toNodeId: department.id, validFrom: todayIsoDate(), status: "active", @@ -253,7 +253,7 @@ function buildMinistryNodes( ministry: MinistryDataset, nodeStore: Map, edgeStore: Map, - departmentByPortfolioTitle: Map, + departmentByOfficeTitle: Map, ) { const ministryNode = upsertNode(nodeStore, { nodeType: "ministry", @@ -286,26 +286,28 @@ function buildMinistryNodes( const responsibleDepartments = new Set(); - for (const title of member.titles) { - const portfolio = upsertNode(nodeStore, { - nodeType: "portfolio", - canonicalName: title, - description: "Current ministerial portfolio.", + for (const officeTitle of member.officeTitles) { + const office = upsertNode(nodeStore, { + nodeType: "ministerial_office", + canonicalName: officeTitle, + description: "Current ministerial office.", status: "active", sourceConfidence: "high", sourceDocumentIds: [ministry.source.id], }); upsertEdge(edgeStore, { - edgeType: "HOLDS_PORTFOLIO", + edgeType: "HOLDS_OFFICE", fromNodeId: person.id, - toNodeId: portfolio.id, + toNodeId: office.id, validFrom: todayIsoDate(), status: "active", sourceDocumentIds: [ministry.source.id], }); - const department = departmentByPortfolioTitle.get(normalizeForMatch(title)); + const department = departmentByOfficeTitle.get( + normalizeForMatch(officeTitle), + ); if (department) { responsibleDepartments.add(department.id); } @@ -512,20 +514,20 @@ export function buildGovernmentCatalog(input: { }): GovernmentCatalog { const nodeStore = new Map(); const edgeStore = new Map(); - const departmentByPortfolioTitle = new Map(); + const departmentByOfficeTitle = new Map(); - buildPortfolioNodes( + buildOfficeNodes( input.portfolios.sections, input.portfolios.source, nodeStore, edgeStore, - departmentByPortfolioTitle, + departmentByOfficeTitle, ); buildMinistryNodes( input.ministry, nodeStore, edgeStore, - departmentByPortfolioTitle, + departmentByOfficeTitle, ); if (input.employers) { diff --git a/packages/domain/src/government/edge-types.ts b/packages/domain/src/government/edge-types.ts index c039ca4..8260108 100644 --- a/packages/domain/src/government/edge-types.ts +++ b/packages/domain/src/government/edge-types.ts @@ -1,5 +1,5 @@ export const edgeTypes = [ - "HOLDS_PORTFOLIO", + "HOLDS_OFFICE", "MEMBER_OF_MINISTRY", "SUPPORTED_BY_DEPARTMENT", "IN_PORTFOLIO", diff --git a/packages/domain/src/government/node-types.ts b/packages/domain/src/government/node-types.ts index 3f31dbc..6b9281e 100644 --- a/packages/domain/src/government/node-types.ts +++ b/packages/domain/src/government/node-types.ts @@ -1,7 +1,7 @@ export const nodeTypes = [ "person", "ministry", - "portfolio", + "ministerial_office", "department", "administrative_office", "public_entity", diff --git a/packages/domain/src/government/schemas.ts b/packages/domain/src/government/schemas.ts index c68b5d2..1e430d7 100644 --- a/packages/domain/src/government/schemas.ts +++ b/packages/domain/src/government/schemas.ts @@ -80,7 +80,7 @@ export type PortfolioSectionInput = { portfolioGroupName: string; departmentName: string; departmentDescription: string; - portfolioTitles: string[]; + officeTitles: string[]; }; export type VpscPortfolioDataset = { @@ -91,7 +91,7 @@ export type VpscPortfolioDataset = { export type MinistryMemberInput = { personName: string; - titles: string[]; + officeTitles: string[]; detailUrl?: string; }; @@ -164,3 +164,95 @@ export type BudgetPerformanceMeasuresDataset = { budgetYear?: string; owners: BudgetPerformanceMeasureOwnerInput[]; }; + +export const administrationModeSchema = z.enum([ + "sole", + "joint", + "joint_and_several", + "unknown", +]); + +export const administrationScopeSchema = z.enum([ + "whole_act", + "provision_list", + "residual", +]); + +export const generalOrderParseStatusSchema = z.enum(["parsed", "partial"]); + +export const kanonExternalDocumentTypeSchema = z.enum([ + "statute", + "regulation", + "decision", + "contract", + "other", +]); + +export const provisionReferenceSchema = z.object({ + rawText: z.string(), + unit: z.enum([ + "section", + "part", + "division", + "subdivision", + "chapter", + "schedule", + "act", + "other", + ]), + label: z.string(), +}); + +export const generalOrderKanonAssistanceSchema = z.object({ + model: z.literal("kanon-2-enricher"), + status: z.enum(["enriched", "no_signal", "failed"]), + externalDocumentNames: z.array(z.string()).default([]), + externalDocumentTypes: z.array(kanonExternalDocumentTypeSchema).default([]), + pinpointTexts: z.array(z.string()).default([]), + pinpointReferences: z.array(provisionReferenceSchema).default([]), + error: z.string().optional(), +}); + +export const generalOrderRuleSchema = z.object({ + ruleKind: z.enum(["default", "listed_scope", "residual"]), + scope: administrationScopeSchema, + rawText: z.string(), + scopeText: z.string().optional(), + administeringOfficeNames: z.array(z.string()).default([]), + administrationMode: administrationModeSchema, + provisionReferences: z.array(provisionReferenceSchema).default([]), + nestedRawTexts: z.array(z.string()).default([]), + parseStatus: generalOrderParseStatusSchema, + unparsedTail: z.string().optional(), + kanonAssistance: generalOrderKanonAssistanceSchema.optional(), +}); + +export const generalOrderActEntrySchema = z.object({ + officeName: z.string(), + actName: z.string(), + headingText: z.string(), + headingStyle: z.enum(["plain", "except", "scoped_list"]), + rules: z.array(generalOrderRuleSchema), +}); + +export const generalOrderOfficeSectionSchema = z.object({ + officeName: z.string(), + acts: z.array(generalOrderActEntrySchema), +}); + +export const generalOrderDatasetSchema = z.object({ + source: sourceDocumentSchema, + effectiveDate: z.string().optional(), + offices: z.array(generalOrderOfficeSectionSchema), +}); + +export type ProvisionReference = z.infer; +export type GeneralOrderKanonAssistance = z.infer< + typeof generalOrderKanonAssistanceSchema +>; +export type GeneralOrderRule = z.infer; +export type GeneralOrderActEntry = z.infer; +export type GeneralOrderOfficeSection = z.infer< + typeof generalOrderOfficeSectionSchema +>; +export type GeneralOrderDataset = z.infer; diff --git a/packages/parsers/package.json b/packages/parsers/package.json index 7544261..16ba116 100644 --- a/packages/parsers/package.json +++ b/packages/parsers/package.json @@ -16,6 +16,7 @@ "@govgraph/domain": "workspace:*", "cheerio": "^1.1.2", "fast-xml-parser": "^5.5.10", + "isaacus": "^0.13.0", "jszip": "^3.10.1", "xlsx": "^0.18.5", "zod": "^4.1.12" diff --git a/packages/parsers/src/fixtures/general-order.fixture.ts b/packages/parsers/src/fixtures/general-order.fixture.ts new file mode 100644 index 0000000..5a3f1e2 --- /dev/null +++ b/packages/parsers/src/fixtures/general-order.fixture.ts @@ -0,0 +1,77 @@ +export const generalOrderFixture = ` +
+
+

Effective date: 30 October 2025

+
+
+
    +
  • + +
    +
    +

    Acts Enumeration and Revision Act 1958

    +

    Bail Act 1977 - Except:

    +
      +
    • + Section 3B and Part 2A (these provisions are jointly and severally + administered with the Minister for Youth Justice) +
    • +
    +

    Constitution Act 1975 -

    +
      +
    • Part III
    • +
    • + Division 1 of Part IIIAA (this Division is jointly administered with + the Premier) +
    • +
    +

    (The Act is otherwise administered by the Minister for Finance and the Premier)

    +
    +
    +
  • +
  • + +
    +
    +

    Bail Act 1977 -

    +
      +
    • + Section 3B and Part 2A (these provisions are jointly and severally + administered with the Attorney-General) +
    • +
    +

    (The Act is otherwise administered by the Attorney-General)

    +

    Children, Youth and Families Act 2005 - Except:

    +
      +
    • + Chapters 3 and 4 (these Chapters are jointly and severally administered by the + Attorney-General and the Minister for Children) +
    • +
    +

    (The Act is otherwise jointly and severally administered with the Attorney-General and the Minister for Children)

    +
    +
    +
  • +
  • + +
    +
    +

    Project Development and Construction Management Act 1994 - Except:

    +
      +
    • + Part 5A (this Part is jointly and severally administered by the Minister for Development Victoria and Precincts and the Minister for Planning, except to the extent that it relates to the exercise of powers and functions under Part 9A of the Planning and Environment Act 1987, in so far as it relates to those powers and functions this Part is administered by the Minister for Planning) +
    • +
    +
    +
    +
  • +
+
+
+`; diff --git a/packages/parsers/src/general-order/kanon.test.ts b/packages/parsers/src/general-order/kanon.test.ts new file mode 100644 index 0000000..9b25038 --- /dev/null +++ b/packages/parsers/src/general-order/kanon.test.ts @@ -0,0 +1,156 @@ +import { Isaacus } from "isaacus"; + +import { + assistPartialGeneralOrderRulesWithKanon, + sanitizeKanonSpanText, +} from "./kanon"; +import { loadFixtureVicGovGeneralOrder } from "./vicgov"; + +type EnrichmentCreateParams = Isaacus.EnrichmentCreateParams; +type EnrichmentResponse = Isaacus.EnrichmentResponse; + +describe("assistPartialGeneralOrderRulesWithKanon", () => { + it("cleans trailing punctuation from Kanon pinpoint spans", () => { + expect(sanitizeKanonSpanText("section 51B(5)),")).toBe("section 51B(5)"); + expect(sanitizeKanonSpanText("Part 7;")).toBe("Part 7"); + }); + + it("returns the original dataset when no client or API key is available", async () => { + const snapshot = loadFixtureVicGovGeneralOrder(); + + const result = await assistPartialGeneralOrderRulesWithKanon(snapshot); + + expect(result).toBe(snapshot); + }); + + it("adds Kanon assistance only to rules that still need review", async () => { + const snapshot = loadFixtureVicGovGeneralOrder(); + const create = vi.fn(async ({ texts }: EnrichmentCreateParams) => { + const inputTexts = Array.isArray(texts) ? texts : [texts]; + + const response: EnrichmentResponse = { + results: inputTexts.map((text, index) => { + const actName = "Project Development and Construction Management Act 1994"; + const actStart = text.indexOf(actName); + const pinpointText = "Part 5A"; + const pinpointStart = text.indexOf(pinpointText); + + return { + index, + document: { + crossreferences: [], + dates: [], + emails: [], + external_documents: [ + { + id: `exd:${index}`, + jurisdiction: "AU-VIC", + mentions: [], + name: { + start: actStart, + end: actStart + actName.length, + }, + pinpoints: [ + { + start: pinpointStart, + end: pinpointStart + pinpointText.length, + }, + ], + reception: "neutral", + type: "statute", + }, + ], + headings: [], + id_numbers: [], + junk: [], + jurisdiction: "AU-VIC", + locations: [], + persons: [], + phone_numbers: [], + quotes: [], + segments: [], + subtitle: null, + terms: [], + title: null, + type: "other", + version: "ilgs@1", + websites: [], + }, + }; + }), + usage: { + input_tokens: 1, + }, + }; + + return response; + }); + const result = await assistPartialGeneralOrderRulesWithKanon(snapshot, { + client: { enrichments: { create } }, + }); + + const attorneyGeneral = result.offices.find( + (section) => section.officeName === "Attorney-General", + ); + const bailAct = attorneyGeneral?.acts.find( + (entry) => entry.actName === "Bail Act 1977", + ); + expect(bailAct?.rules.every((rule) => rule.kanonAssistance === undefined)).toBe( + true, + ); + + const premier = result.offices.find((section) => section.officeName === "Premier"); + const projectAct = premier?.acts.find( + (entry) => + entry.actName === "Project Development and Construction Management Act 1994", + ); + expect(projectAct?.rules[1]?.kanonAssistance).toEqual({ + model: "kanon-2-enricher", + status: "enriched", + externalDocumentNames: [ + "Project Development and Construction Management Act 1994", + ], + externalDocumentTypes: ["statute"], + pinpointTexts: ["Part 5A"], + pinpointReferences: [ + { + rawText: "Part 5A", + unit: "part", + label: "5A", + }, + ], + }); + expect(create).toHaveBeenCalledTimes(1); + }); + + it("records a failed Kanon attempt without changing the underlying rule parse", async () => { + const snapshot = loadFixtureVicGovGeneralOrder(); + + const result = await assistPartialGeneralOrderRulesWithKanon(snapshot, { + client: { + enrichments: { + create: vi.fn(async () => { + throw new Error("network unavailable"); + }), + }, + }, + }); + + const premier = result.offices.find((section) => section.officeName === "Premier"); + const projectAct = premier?.acts.find( + (entry) => + entry.actName === "Project Development and Construction Management Act 1994", + ); + + expect(projectAct?.rules[1]?.parseStatus).toBe("partial"); + expect(projectAct?.rules[1]?.kanonAssistance).toEqual({ + model: "kanon-2-enricher", + status: "failed", + externalDocumentNames: [], + externalDocumentTypes: [], + pinpointTexts: [], + pinpointReferences: [], + error: "network unavailable", + }); + }); +}); diff --git a/packages/parsers/src/general-order/kanon.ts b/packages/parsers/src/general-order/kanon.ts new file mode 100644 index 0000000..4dd5fb6 --- /dev/null +++ b/packages/parsers/src/general-order/kanon.ts @@ -0,0 +1,275 @@ +import { Isaacus } from "isaacus"; + +import { + generalOrderDatasetSchema, + type GeneralOrderDataset, + type GeneralOrderKanonAssistance, + type GeneralOrderRule, + type ProvisionReference, +} from "@govgraph/domain"; + +import { + extractProvisionReferencesFromText, + normalizeGeneralOrderText, +} from "./shared"; + +const KANON_MODEL = "kanon-2-enricher" as const; +const DEFAULT_BATCH_SIZE = 8; +type EnrichmentCreateParams = Isaacus.EnrichmentCreateParams; +type EnrichmentResponse = Isaacus.EnrichmentResponse; + +export type GeneralOrderKanonClient = { + enrichments: { + create(params: EnrichmentCreateParams): Promise; + }; +}; + +export type AssistGeneralOrderWithKanonOptions = { + client?: GeneralOrderKanonClient; + apiKey?: string; + // Isaacus enrichment requests accept at most 8 texts per call. + batchSize?: number; + overflowStrategy?: NonNullable; +}; + +type PartialRuleTarget = { + officeIndex: number; + actIndex: number; + ruleIndex: number; + officeName: string; + actName: string; + rule: GeneralOrderRule; +}; + +type EnrichedDocument = EnrichmentResponse["results"][number]["document"]; +type ExternalDocumentType = + GeneralOrderKanonAssistance["externalDocumentTypes"][number]; + +function buildBatches(items: readonly T[], batchSize: number): T[][] { + const batches: T[][] = []; + + for (let index = 0; index < items.length; index += batchSize) { + batches.push(items.slice(index, index + batchSize)); + } + + return batches; +} + +function codePointSlice(text: string, start: number, end: number): string { + return Array.from(text).slice(start, end).join(""); +} + +export function sanitizeKanonSpanText(value: string): string { + let normalized = normalizeGeneralOrderText(value).replace(/[,:;.-]+$/g, ""); + + const openParentheses = (normalized.match(/\(/g) ?? []).length; + let closeParentheses = (normalized.match(/\)/g) ?? []).length; + + while (closeParentheses > openParentheses && normalized.endsWith(")")) { + normalized = normalized.slice(0, -1).trimEnd(); + closeParentheses -= 1; + } + + return normalized.trim(); +} + +function uniqueStrings(values: readonly string[]): string[] { + return Array.from(new Set(values.filter(Boolean))); +} + +function uniqueExternalDocumentTypes( + values: readonly ExternalDocumentType[], +): ExternalDocumentType[] { + return Array.from(new Set(values)); +} + +function uniqueProvisionReferences( + values: readonly ProvisionReference[], +): ProvisionReference[] { + const referencesByKey = new Map(); + + for (const value of values) { + referencesByKey.set(`${value.unit}:${value.rawText}`, value); + } + + return [...referencesByKey.values()]; +} + +function getPartialRuleTargets(dataset: GeneralOrderDataset): PartialRuleTarget[] { + return dataset.offices.flatMap((office, officeIndex) => + office.acts.flatMap((act, actIndex) => + act.rules.flatMap((rule, ruleIndex) => + rule.parseStatus === "parsed" + ? [] + : [ + { + officeIndex, + actIndex, + ruleIndex, + officeName: office.officeName, + actName: act.actName, + rule, + }, + ], + ), + ), + ); +} + +function createIsaacusClient( + options: AssistGeneralOrderWithKanonOptions, +): GeneralOrderKanonClient | null { + if (options.client) { + return options.client; + } + + const apiKey = options.apiKey ?? process.env.ISAACUS_API_KEY; + if (!apiKey) { + return null; + } + + return new Isaacus({ apiKey }); +} + +export function buildGeneralOrderKanonInput(target: { + officeName: string; + actName: string; + rule: Pick; +}): string { + const nestedText = + target.rule.nestedRawTexts.length === 0 + ? "" + : ` Nested exceptions: ${target.rule.nestedRawTexts.join(" ")}`; + + return normalizeGeneralOrderText( + `Under the ministerial office ${target.officeName}, the ${target.actName} includes this administration rule: ${target.rule.rawText}.${nestedText}`, + ); +} + +function createFailedKanonAssistance(error: unknown): GeneralOrderKanonAssistance { + const message = error instanceof Error ? error.message : "Unknown Kanon error"; + + return { + model: KANON_MODEL, + status: "failed", + externalDocumentNames: [], + externalDocumentTypes: [], + pinpointTexts: [], + pinpointReferences: [], + error: normalizeGeneralOrderText(message), + }; +} + +function createKanonAssistanceFromDocument( + inputText: string, + document: EnrichedDocument, +): GeneralOrderKanonAssistance { + const externalDocumentNames = uniqueStrings( + document.external_documents.map((externalDocument) => + normalizeGeneralOrderText( + codePointSlice( + inputText, + externalDocument.name.start, + externalDocument.name.end, + ), + ), + ), + ); + const externalDocumentTypes = uniqueExternalDocumentTypes( + document.external_documents.map((externalDocument) => externalDocument.type), + ); + const pinpointTexts = uniqueStrings( + document.external_documents.flatMap((externalDocument) => + externalDocument.pinpoints.map((pinpoint) => + sanitizeKanonSpanText( + codePointSlice(inputText, pinpoint.start, pinpoint.end), + ), + ), + ), + ); + const pinpointReferences = uniqueProvisionReferences( + pinpointTexts.flatMap((pinpointText) => + extractProvisionReferencesFromText(pinpointText), + ), + ); + + return { + model: KANON_MODEL, + status: + externalDocumentNames.length > 0 || pinpointTexts.length > 0 + ? "enriched" + : "no_signal", + externalDocumentNames, + externalDocumentTypes, + pinpointTexts, + pinpointReferences, + }; +} + +export async function assistPartialGeneralOrderRulesWithKanon( + dataset: GeneralOrderDataset, + options: AssistGeneralOrderWithKanonOptions = {}, +): Promise { + const partialRuleTargets = getPartialRuleTargets(dataset); + if (partialRuleTargets.length === 0) { + return dataset; + } + + const client = createIsaacusClient(options); + if (!client) { + return dataset; + } + + const batchSize = Math.max(1, Math.min(options.batchSize ?? DEFAULT_BATCH_SIZE, 8)); + const nextDataset = structuredClone(dataset); + + for (const batch of buildBatches(partialRuleTargets, batchSize)) { + const texts = batch.map((target) => buildGeneralOrderKanonInput(target)); + + try { + const response = await client.enrichments.create({ + model: KANON_MODEL, + texts, + ...(options.overflowStrategy + ? { overflow_strategy: options.overflowStrategy } + : {}), + }); + const resultsByIndex = new Map( + response.results.map((result) => [result.index, result]), + ); + + for (const [batchIndex, target] of batch.entries()) { + const rule = + nextDataset.offices[target.officeIndex]?.acts[target.actIndex]?.rules[ + target.ruleIndex + ]; + + if (!rule) { + continue; + } + + const result = resultsByIndex.get(batchIndex); + rule.kanonAssistance = result + ? createKanonAssistanceFromDocument(texts[batchIndex] ?? "", result.document) + : createFailedKanonAssistance( + new Error("No enrichment result returned for rule"), + ); + } + } catch (error) { + for (const target of batch) { + const rule = + nextDataset.offices[target.officeIndex]?.acts[target.actIndex]?.rules[ + target.ruleIndex + ]; + + if (!rule) { + continue; + } + + rule.kanonAssistance = createFailedKanonAssistance(error); + } + } + } + + return generalOrderDatasetSchema.parse(nextDataset); +} diff --git a/packages/parsers/src/general-order/shared.ts b/packages/parsers/src/general-order/shared.ts new file mode 100644 index 0000000..a341d82 --- /dev/null +++ b/packages/parsers/src/general-order/shared.ts @@ -0,0 +1,51 @@ +import type { ProvisionReference } from "@govgraph/domain"; + +export function normalizeGeneralOrderText(value: string): string { + return value + .replace(/\u00a0/g, " ") + .replace(/[\u2010\u2011\u2012\u2013\u2014]/g, "-") + .replace(/Attorney-\s+General/g, "Attorney-General") + .replace(/\s+/g, " ") + .trim(); +} + +export function extractProvisionReferencesFromText( + scopeText: string, +): ProvisionReference[] { + const normalized = normalizeGeneralOrderText(scopeText); + const references: ProvisionReference[] = []; + const referencePattern = + /\b(Sections?|Parts?|Divisions?|Subdivisions?|Chapters?|Schedules?)\s+([^;]+?)(?=(?:,\s*(?:Sections?|Parts?|Divisions?|Subdivisions?|Chapters?|Schedules?)\b)|(?:\s+(?:and|or)\s+(?:Sections?|Parts?|Divisions?|Subdivisions?|Chapters?|Schedules?)\b)|$)/gi; + + for (const match of normalized.matchAll(referencePattern)) { + const unitText = match[1]?.toLowerCase() ?? ""; + const label = normalizeGeneralOrderText(match[2] ?? ""); + + if (!label) { + continue; + } + + let unit: ProvisionReference["unit"] = "other"; + if (unitText.startsWith("section")) { + unit = "section"; + } else if (unitText.startsWith("part")) { + unit = "part"; + } else if (unitText.startsWith("division")) { + unit = "division"; + } else if (unitText.startsWith("subdivision")) { + unit = "subdivision"; + } else if (unitText.startsWith("chapter")) { + unit = "chapter"; + } else if (unitText.startsWith("schedule")) { + unit = "schedule"; + } + + references.push({ + rawText: normalizeGeneralOrderText(`${match[1]} ${label}`), + unit, + label, + }); + } + + return references; +} diff --git a/packages/parsers/src/general-order/vicgov.test.ts b/packages/parsers/src/general-order/vicgov.test.ts new file mode 100644 index 0000000..01b6d36 --- /dev/null +++ b/packages/parsers/src/general-order/vicgov.test.ts @@ -0,0 +1,71 @@ +import { loadFixtureVicGovGeneralOrder } from "./vicgov"; + +describe("parseVicGovGeneralOrder", () => { + it("extracts offices, Acts, and shared responsibility rules", () => { + const snapshot = loadFixtureVicGovGeneralOrder(); + + expect(snapshot.effectiveDate).toBe("2025-10-30"); + expect(snapshot.offices).toHaveLength(3); + + const attorneyGeneral = snapshot.offices[0]; + expect(attorneyGeneral?.officeName).toBe("Attorney-General"); + + const bailAct = attorneyGeneral?.acts.find( + (entry) => entry.actName === "Bail Act 1977", + ); + expect(bailAct?.headingStyle).toBe("except"); + expect(bailAct?.rules[0]?.ruleKind).toBe("default"); + expect(bailAct?.rules[0]?.administeringOfficeNames).toEqual([ + "Attorney-General", + ]); + expect(bailAct?.rules[1]?.administeringOfficeNames).toEqual([ + "Attorney-General", + "Minister for Youth Justice", + ]); + expect(bailAct?.rules[1]?.administrationMode).toBe("joint_and_several"); + expect(bailAct?.rules[1]?.provisionReferences.map((entry) => entry.rawText)).toEqual([ + "Section 3B", + "Part 2A", + ]); + }); + + it("keeps scoped lists and residual rules distinct", () => { + const snapshot = loadFixtureVicGovGeneralOrder(); + const attorneyGeneral = snapshot.offices.find( + (section) => section.officeName === "Attorney-General", + ); + const constitutionAct = attorneyGeneral?.acts.find( + (entry) => entry.actName === "Constitution Act 1975", + ); + + expect(constitutionAct?.headingStyle).toBe("scoped_list"); + expect(constitutionAct?.rules[0]?.scopeText).toBe("Part III"); + expect(constitutionAct?.rules[0]?.administeringOfficeNames).toEqual([ + "Attorney-General", + ]); + expect(constitutionAct?.rules[1]?.administeringOfficeNames).toEqual([ + "Attorney-General", + "Premier", + ]); + expect(constitutionAct?.rules[2]?.ruleKind).toBe("residual"); + expect(constitutionAct?.rules[2]?.administeringOfficeNames).toEqual([ + "Minister for Finance", + "Premier", + ]); + expect(constitutionAct?.rules[2]?.administrationMode).toBe("unknown"); + }); + + it("marks complex carve-outs as partial instead of overstating confidence", () => { + const snapshot = loadFixtureVicGovGeneralOrder(); + const premier = snapshot.offices.find((section) => section.officeName === "Premier"); + const projectAct = premier?.acts.find( + (entry) => + entry.actName === "Project Development and Construction Management Act 1994", + ); + + expect(projectAct?.rules[1]?.parseStatus).toBe("partial"); + expect(projectAct?.rules[1]?.unparsedTail).toContain( + "except to the extent", + ); + }); +}); diff --git a/packages/parsers/src/general-order/vicgov.ts b/packages/parsers/src/general-order/vicgov.ts new file mode 100644 index 0000000..4921f19 --- /dev/null +++ b/packages/parsers/src/general-order/vicgov.ts @@ -0,0 +1,487 @@ +import { type CheerioAPI, load } from "cheerio"; + +import { + generalOrderDatasetSchema, + type GeneralOrderActEntry, + type GeneralOrderDataset, + type GeneralOrderRule, +} from "@govgraph/domain"; + +import { generalOrderFixture } from "../fixtures/general-order.fixture"; +import { parseHumanDate } from "../shared/date"; +import { createSourceDocument } from "../shared/source-document"; +import { + assistPartialGeneralOrderRulesWithKanon, + type AssistGeneralOrderWithKanonOptions, +} from "./kanon"; +import { + extractProvisionReferencesFromText, + normalizeGeneralOrderText as normalizeText, +} from "./shared"; + +export const VIC_GOV_GENERAL_ORDER_URL = + "https://www.vic.gov.au/general-order-effective-30-october-2025"; + +type HeadingStyle = GeneralOrderActEntry["headingStyle"]; +type AdministrationMode = GeneralOrderRule["administrationMode"]; +type ParseStatus = GeneralOrderRule["parseStatus"]; +type CheerioElement = Parameters[0]; + +type ParentheticalChunk = { + start: number; + end: number; + content: string; +}; + +type AdministrationDetails = { + administeringOfficeNames: string[]; + administrationMode: AdministrationMode; + parseStatus: ParseStatus; + unparsedTail?: string; +}; + +function stripOuterParentheses(value: string): string { + const trimmed = value.trim(); + if (!trimmed.startsWith("(") || !trimmed.endsWith(")")) { + return trimmed; + } + + let depth = 0; + for (let index = 0; index < trimmed.length; index += 1) { + const character = trimmed[index]; + if (character === "(") { + depth += 1; + } else if (character === ")") { + depth -= 1; + if (depth === 0 && index !== trimmed.length - 1) { + return trimmed; + } + } + } + + return trimmed.slice(1, -1).trim(); +} + +function getTopLevelParentheticalChunks(value: string): ParentheticalChunk[] { + const chunks: ParentheticalChunk[] = []; + let depth = 0; + let start = -1; + + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (character === "(") { + if (depth === 0) { + start = index; + } + depth += 1; + } else if (character === ")") { + depth -= 1; + if (depth === 0 && start >= 0) { + chunks.push({ + start, + end: index, + content: value.slice(start + 1, index).trim(), + }); + start = -1; + } + } + } + + return chunks; +} + +function extractEffectiveDate(html: string): string | undefined { + const match = normalizeText(load(html).text()).match( + /Effective date:\s*(\d{1,2}\s+[A-Za-z]+\s+\d{4})/i, + ); + + return parseHumanDate(match?.[1] ?? ""); +} + +function parseHeadingText(headingText: string): { + actName: string; + headingStyle: HeadingStyle; +} { + const normalized = normalizeText(headingText); + + if (/\s-\s*Except:$/i.test(normalized)) { + return { + actName: normalized.replace(/\s-\s*Except:$/i, "").trim(), + headingStyle: "except", + }; + } + + if (/\s-\s*$/i.test(normalized)) { + return { + actName: normalized.replace(/\s-\s*$/i, "").trim(), + headingStyle: "scoped_list", + }; + } + + return { + actName: normalized, + headingStyle: "plain", + }; +} + +function normalizeOfficeName(value: string): string { + return normalizeText(value) + .replace(/^the\s+/i, "") + .replace(/,\s*$/g, "") + .replace(/\s+and$/i, "") + .trim(); +} + +function extractOfficeNames(value: string): string[] { + const normalized = normalizeText(value); + // TODO: extend this pattern if future sources introduce office titles such as + // Deputy Premier or Minister without Portfolio in these administration notes. + const officeStartPattern = + /\b(?:the\s+)?(?:Attorney-General|Premier|Treasurer|Minister for\b)/gi; + const starts = Array.from(normalized.matchAll(officeStartPattern)) + .map((match) => match.index) + .filter((index): index is number => index !== undefined); + + if (starts.length === 0) { + const fallback = normalizeOfficeName(normalized); + return fallback ? [fallback] : []; + } + + const offices = starts + .map((start, index) => { + const end = starts[index + 1] ?? normalized.length; + return normalizeOfficeName(normalized.slice(start, end)); + }) + .filter(Boolean); + + return Array.from(new Set(offices)); +} + +function splitOfficeTail(rawTail: string): { + officeText: string; + unparsedTail?: string; +} { + const normalized = normalizeText(rawTail); + const lower = normalized.toLowerCase(); + const markers = [ + ", except to the extent", + ", in so far as", + ", this ", + ", these ", + ]; + + const index = markers + .map((marker) => lower.indexOf(marker)) + .filter((candidate) => candidate >= 0) + .sort((left, right) => left - right)[0]; + + if (index === undefined) { + return { officeText: normalized }; + } + + return { + officeText: normalized.slice(0, index).trim(), + unparsedTail: normalized.slice(index + 2).trim(), + }; +} + +function parseAdministrationDetails( + adminText: string, + currentOfficeName: string, +): AdministrationDetails { + const normalized = normalizeText(adminText); + const administrationMatch = normalized.match( + /\b(jointly and severally administered|jointly administered|administered)\s+(with|by)\s+(.+)$/i, + ); + + if (!administrationMatch) { + return { + administeringOfficeNames: [currentOfficeName], + administrationMode: "sole", + parseStatus: "parsed", + }; + } + + const descriptor = administrationMatch[1]?.toLowerCase() ?? "administered"; + const connector = administrationMatch[2]?.toLowerCase() ?? "by"; + const rawTail = administrationMatch[3] ?? ""; + const { officeText, unparsedTail } = splitOfficeTail(rawTail); + const parsedOfficeNames = extractOfficeNames(officeText); + const administeringOfficeNames = + connector === "with" + ? Array.from(new Set([currentOfficeName, ...parsedOfficeNames])) + : parsedOfficeNames; + + let administrationMode: AdministrationMode = "unknown"; + if (descriptor === "jointly and severally administered") { + administrationMode = "joint_and_several"; + } else if (descriptor === "jointly administered") { + administrationMode = "joint"; + } else if (descriptor === "administered" && administeringOfficeNames.length <= 1) { + administrationMode = "sole"; + } + + return { + administeringOfficeNames, + administrationMode, + parseStatus: + administeringOfficeNames.length === 0 || unparsedTail ? "partial" : "parsed", + ...(unparsedTail ? { unparsedTail } : {}), + }; +} + +function createDefaultWholeActRule(officeName: string): GeneralOrderRule { + return { + ruleKind: "default", + scope: "whole_act", + rawText: `The Act is administered by ${officeName}`, + administeringOfficeNames: [officeName], + administrationMode: "sole", + provisionReferences: [], + nestedRawTexts: [], + parseStatus: "parsed", + }; +} + +function buildRuleFromText(input: { + rawText: string; + currentOfficeName: string; + ruleKind: GeneralOrderRule["ruleKind"]; +}): GeneralOrderRule { + const normalized = normalizeText(stripOuterParentheses(input.rawText)); + + if (/^The Act is /i.test(normalized)) { + const administration = parseAdministrationDetails( + normalized, + input.currentOfficeName, + ); + + return { + ruleKind: input.ruleKind, + scope: input.ruleKind === "residual" ? "residual" : "whole_act", + rawText: normalized, + administeringOfficeNames: administration.administeringOfficeNames, + administrationMode: administration.administrationMode, + provisionReferences: [], + nestedRawTexts: [], + parseStatus: administration.parseStatus, + ...(administration.unparsedTail + ? { unparsedTail: administration.unparsedTail } + : {}), + }; + } + + const parentheticalChunks = getTopLevelParentheticalChunks(normalized); + const administrationChunk = [...parentheticalChunks] + .reverse() + .find((chunk) => /administered/i.test(chunk.content)); + const scopeText = administrationChunk + ? normalizeText(normalized.slice(0, administrationChunk.start)) + : normalized; + const administration = administrationChunk + ? parseAdministrationDetails( + administrationChunk.content, + input.currentOfficeName, + ) + : { + administeringOfficeNames: [input.currentOfficeName], + administrationMode: "sole" as const, + parseStatus: "parsed" as const, + }; + const trailingText = administrationChunk + ? normalizeText(normalized.slice(administrationChunk.end + 1)) + : ""; + const nestedStatus: ParseStatus = + administration.parseStatus === "partial" || trailingText ? "partial" : "parsed"; + + return { + ruleKind: input.ruleKind, + scope: "provision_list", + rawText: normalized, + scopeText, + administeringOfficeNames: administration.administeringOfficeNames, + administrationMode: administration.administrationMode, + provisionReferences: extractProvisionReferencesFromText(scopeText), + nestedRawTexts: [], + parseStatus: nestedStatus, + ...((administration.unparsedTail || trailingText) && { + unparsedTail: normalizeText( + [administration.unparsedTail, trailingText].filter(Boolean).join(" "), + ), + }), + }; +} + +function extractListItemContent($: CheerioAPI, element: CheerioElement): { + rawText: string; + nestedRawTexts: string[]; +} { + const listItem = $(element).clone(); + const nestedRawTexts = listItem + .children("ul") + .children("li") + .map((_, nestedElement) => normalizeText($(nestedElement).text())) + .get() + .filter(Boolean); + + listItem.children("ul").remove(); + + return { + rawText: normalizeText(listItem.text()), + nestedRawTexts, + }; +} + +function finalizeActEntry( + currentAct: GeneralOrderActEntry | null, +): GeneralOrderActEntry | null { + if (!currentAct) { + return null; + } + + if (currentAct.headingStyle === "plain" && currentAct.rules.length === 0) { + currentAct.rules.push(createDefaultWholeActRule(currentAct.officeName)); + } + + if (currentAct.headingStyle === "except") { + currentAct.rules.unshift(createDefaultWholeActRule(currentAct.officeName)); + } + + return currentAct; +} + +function parseOfficeSection($: CheerioAPI, element: CheerioElement) { + const item = $(element); + const officeName = normalizeText( + item.find(".rpl-accordion__item-heading").first().text(), + ); + const content = item.find(".rpl-accordion__item-content-inner").first(); + const acts: GeneralOrderActEntry[] = []; + let currentAct: GeneralOrderActEntry | null = null; + + for (const child of content.children().toArray()) { + const node = $(child); + const text = normalizeText(node.text()); + + if (!text) { + continue; + } + + if (node.is("p")) { + if (/^\(The Act is otherwise /i.test(text)) { + if (!currentAct) { + continue; + } + + currentAct.rules.push( + buildRuleFromText({ + rawText: text, + currentOfficeName: officeName, + ruleKind: "residual", + }), + ); + continue; + } + + const completeAct = finalizeActEntry(currentAct); + if (completeAct) { + acts.push(completeAct); + } + + const heading = parseHeadingText(text); + currentAct = { + officeName, + actName: heading.actName, + headingText: text, + headingStyle: heading.headingStyle, + rules: [], + }; + continue; + } + + if (node.is("ul") && currentAct) { + for (const listItem of node.children("li").toArray()) { + const { rawText, nestedRawTexts } = extractListItemContent($, listItem); + if (!rawText) { + continue; + } + + const rule = buildRuleFromText({ + rawText, + currentOfficeName: officeName, + ruleKind: "listed_scope", + }); + + currentAct.rules.push({ + ...rule, + nestedRawTexts, + parseStatus: + nestedRawTexts.length > 0 && rule.parseStatus === "parsed" + ? "partial" + : rule.parseStatus, + }); + } + } + } + + const completeAct = finalizeActEntry(currentAct); + if (completeAct) { + acts.push(completeAct); + } + + return { + officeName, + acts, + }; +} + +export function parseVicGovGeneralOrder(html: string): GeneralOrderDataset { + const $ = load(html); + const effectiveDate = extractEffectiveDate(html); + const offices = $(".rpl-accordion__item") + .map((_, element) => parseOfficeSection($, element)) + .get() + .filter((section) => section.acts.length > 0); + + return generalOrderDatasetSchema.parse({ + source: createSourceDocument({ + sourceType: "html", + sourceFamily: "vic_gov", + title: "General Order effective 30 October 2025", + sourceUrl: VIC_GOV_GENERAL_ORDER_URL, + publisher: "Victorian Government", + effectiveDate, + parserVersion: "sprint-1-general-order", + rawStoragePath: "live/vic-gov-general-order-2025-10-30.html", + }), + ...(effectiveDate ? { effectiveDate } : {}), + offices, + }); +} + +export type FetchVicGovGeneralOrderOptions = { + kanonAssistance?: boolean | AssistGeneralOrderWithKanonOptions; +}; + +export async function fetchVicGovGeneralOrder( + options: FetchVicGovGeneralOrderOptions = {}, +): Promise { + const response = await fetch(VIC_GOV_GENERAL_ORDER_URL); + if (!response.ok) { + throw new Error(`Vic Gov general order request failed with ${response.status}`); + } + + const dataset = parseVicGovGeneralOrder(await response.text()); + if (!options.kanonAssistance) { + return dataset; + } + + return assistPartialGeneralOrderRulesWithKanon( + dataset, + options.kanonAssistance === true ? {} : options.kanonAssistance, + ); +} + +export function loadFixtureVicGovGeneralOrder(): GeneralOrderDataset { + return parseVicGovGeneralOrder(generalOrderFixture); +} diff --git a/packages/parsers/src/index.ts b/packages/parsers/src/index.ts index 33f672d..d5a7173 100644 --- a/packages/parsers/src/index.ts +++ b/packages/parsers/src/index.ts @@ -2,6 +2,13 @@ export * from "./budget/index"; export * from "./budget/bp3"; export * from "./budget/performance-measures"; export * from "./catalog-loader"; +export * from "./general-order/kanon"; +export { + fetchVicGovGeneralOrder, + parseVicGovGeneralOrder, + VIC_GOV_GENERAL_ORDER_URL, +} from "./general-order/vicgov"; +export type { FetchVicGovGeneralOrderOptions } from "./general-order/vicgov"; export * from "./ministry/parliament"; export * from "./source-registry"; export * from "./vpsc/employers"; diff --git a/packages/parsers/src/ministry/parliament.test.ts b/packages/parsers/src/ministry/parliament.test.ts index aa49dd2..05e4487 100644 --- a/packages/parsers/src/ministry/parliament.test.ts +++ b/packages/parsers/src/ministry/parliament.test.ts @@ -12,12 +12,12 @@ describe("parseParliamentGovernmentMinistry", () => { vi.restoreAllMocks(); }); - it("extracts people, their portfolios, and detail links", () => { + it("extracts people, their offices, and detail links", () => { const snapshot = loadFixtureParliamentGovernmentMinistry(); expect(snapshot.members).toHaveLength(2); expect(snapshot.members[1]?.personName).toBe("Ben Carroll"); - expect(snapshot.members[1]?.titles).toContain("Deputy Premier"); + expect(snapshot.members[1]?.officeTitles).toContain("Deputy Premier"); expect(snapshot.members[1]?.detailUrl).toBe( "https://www.parliament.vic.gov.au/members/ben-carroll/", ); diff --git a/packages/parsers/src/ministry/parliament.ts b/packages/parsers/src/ministry/parliament.ts index 40d2870..62f790c 100644 --- a/packages/parsers/src/ministry/parliament.ts +++ b/packages/parsers/src/ministry/parliament.ts @@ -26,7 +26,7 @@ export function parseParliamentGovernmentMinistry(html: string): MinistryDataset .text() .replace(/\s+/g, " ") .trim(); - const titles = $(element) + const officeTitles = $(element) .find("table p") .map((__, title) => $(title).text().replace(/\s+/g, " ").trim()) .get() @@ -40,7 +40,7 @@ export function parseParliamentGovernmentMinistry(html: string): MinistryDataset return { personName, - titles, + officeTitles, ...(href ? { detailUrl: absolutize(href) } : {}), }; }) diff --git a/packages/parsers/src/source-registry.ts b/packages/parsers/src/source-registry.ts index 16cd643..5ed2803 100644 --- a/packages/parsers/src/source-registry.ts +++ b/packages/parsers/src/source-registry.ts @@ -35,7 +35,7 @@ export const sourceRegistry: SourceRegistryEntry[] = [ id: "vpsc-portfolios", name: "VPSC portfolios", description: - "Maps current ministerial portfolios to their supporting departments.", + "Maps current ministerial offices to their supporting departments.", cadence: "weekly", sourceFamily: "vpsc", urls: [ @@ -58,7 +58,7 @@ export const sourceRegistry: SourceRegistryEntry[] = [ { id: "ministry-parliament", name: "Parliament government ministry", - description: "Lists current ministers and the portfolios they hold.", + description: "Lists current ministers and the offices they hold.", cadence: "daily", sourceFamily: "parliament", urls: ["https://www.parliament.vic.gov.au/portfolios/"], diff --git a/packages/parsers/src/vpsc/portfolios.test.ts b/packages/parsers/src/vpsc/portfolios.test.ts index 696b408..919d382 100644 --- a/packages/parsers/src/vpsc/portfolios.test.ts +++ b/packages/parsers/src/vpsc/portfolios.test.ts @@ -13,11 +13,11 @@ describe("parseVpscPortfolios", () => { vi.restoreAllMocks(); }); - it("extracts the effective date and department-backed portfolio titles", () => { + it("extracts the effective date and department-backed office titles", () => { const snapshot = loadFixtureVpscPortfolios(); expect(snapshot.asOfDate).toBe("2025-06-05"); - expect(snapshot.sections[0]?.portfolioTitles).toContain("Premier"); + expect(snapshot.sections[0]?.officeTitles).toContain("Premier"); expect(snapshot.sections[1]?.departmentName).toBe("Department of Education"); }); diff --git a/packages/parsers/src/vpsc/portfolios.ts b/packages/parsers/src/vpsc/portfolios.ts index 4ca8bf5..3086b33 100644 --- a/packages/parsers/src/vpsc/portfolios.ts +++ b/packages/parsers/src/vpsc/portfolios.ts @@ -41,13 +41,13 @@ export function parseVpscPortfolios(html: string): VpscPortfolioDataset { content.find("h2").each((_, element) => { const portfolioGroupName = $(element).text().trim(); let cursor = $(element).next(); - let portfolioTitles: string[] = []; + let officeTitles: string[] = []; let departmentDescription = ""; while (cursor.length > 0 && cursor[0]?.tagName !== "h2") { if (cursor.is("h3") && /Ministers/i.test(cursor.text())) { const list = cursor.next("ul"); - portfolioTitles = list + officeTitles = list .find("li") .map((__, item) => $(item).text().trim()) .get() @@ -69,7 +69,7 @@ export function parseVpscPortfolios(html: string): VpscPortfolioDataset { departmentDescription, ), departmentDescription, - portfolioTitles, + officeTitles, }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8d4beb..1684f29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: apps/web: dependencies: + '@govgraph/db': + specifier: workspace:* + version: link:../../packages/db '@govgraph/domain': specifier: workspace:* version: link:../../packages/domain @@ -96,6 +99,9 @@ importers: apps/worker: dependencies: + '@govgraph/db': + specifier: workspace:* + version: link:../../packages/db '@govgraph/parsers': specifier: workspace:* version: link:../../packages/parsers @@ -107,9 +113,19 @@ importers: packages/db: dependencies: + '@govgraph/domain': + specifier: workspace:* + version: link:../domain drizzle-orm: specifier: ^0.44.6 - version: 0.44.7(@opentelemetry/api@1.9.0) + version: 0.44.7(@opentelemetry/api@1.9.0)(@types/pg@8.20.0)(pg@8.20.0) + pg: + specifier: ^8.16.3 + version: 8.20.0 + devDependencies: + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 packages/domain: dependencies: @@ -128,6 +144,9 @@ importers: fast-xml-parser: specifier: ^5.5.10 version: 5.5.10 + isaacus: + specifier: ^0.13.0 + version: 0.13.0 jszip: specifier: ^3.10.1 version: 3.10.1 @@ -1433,6 +1452,9 @@ packages: '@types/node@24.12.2': resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -2600,6 +2622,9 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + isaacus@0.13.0: + resolution: {integrity: sha512-pxfF3WRRSYKhrZUb+3HV5G1aeU5QYVzQDjp8WSlW7mMu2blV2hOS6d4X7/UiUlmfZGlGFRTMUoGhg5rkGjLPFQ==} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -2967,6 +2992,40 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.12.0: + resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.13.0: + resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.20.0: + resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2990,6 +3049,22 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -3183,6 +3258,10 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + ssf@0.11.2: resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} engines: {node: '>=0.8'} @@ -3575,6 +3654,10 @@ packages: resolution: {integrity: sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==} engines: {node: '>=0.4.0'} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -4608,6 +4691,12 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/pg@8.20.0': + dependencies: + '@types/node': 24.12.2 + pg-protocol: 1.13.0 + pg-types: 2.2.0 + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -5154,9 +5243,11 @@ snapshots: esbuild: 0.25.12 tsx: 4.21.0 - drizzle-orm@0.44.7(@opentelemetry/api@1.9.0): + drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/pg@8.20.0)(pg@8.20.0): optionalDependencies: '@opentelemetry/api': 1.9.0 + '@types/pg': 8.20.0 + pg: 8.20.0 dunder-proto@1.0.1: dependencies: @@ -5412,7 +5503,7 @@ snapshots: eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1)) @@ -5445,7 +5536,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -5460,7 +5551,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -5957,6 +6048,8 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + isaacus@0.13.0: {} + isarray@1.0.0: {} isarray@2.0.5: {} @@ -6291,6 +6384,41 @@ snapshots: pathval@2.0.1: {} + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.12.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.13.0(pg@8.20.0): + dependencies: + pg: 8.20.0 + + pg-protocol@1.13.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.20.0: + dependencies: + pg-connection-string: 2.12.0 + pg-pool: 3.13.0(pg@8.20.0) + pg-protocol: 1.13.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -6311,6 +6439,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prelude-ls@1.2.1: {} process-nextick-args@2.0.1: {} @@ -6627,6 +6765,8 @@ snapshots: source-map@0.6.1: {} + split2@4.2.0: {} + ssf@0.11.2: dependencies: frac: 1.1.2 @@ -7047,6 +7187,8 @@ snapshots: xmlhttprequest-ssl@2.0.0: {} + xtend@4.0.2: {} + yallist@3.1.1: {} yocto-queue@0.1.0: {}