Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/deploy-private-ross.yml
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,17 @@ jobs:
echo "- Region: Toronto (yyz)"
echo "- New sign-ups: disabled"
} >> "$GITHUB_STEP_SUMMARY"

- name: Verify required Ontario legal sources
run: >-
node scripts/observe-legal-sources.mjs
--output artifacts/legal-source-health-live.json

- name: Upload sanitized legal-source observation
if: always()
uses: actions/upload-artifact@v4
with:
name: deployed-legal-source-health-${{ github.run_id }}
path: artifacts/legal-source-health-live.json
if-no-files-found: warn
retention-days: 14
37 changes: 37 additions & 0 deletions .github/workflows/verify-ontario-sources.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Verify Ontario legal sources

on:
workflow_dispatch:
schedule:
- cron: "17 11 * * *"

permissions:
contents: read

concurrency:
group: verify-ontario-legal-sources
cancel-in-progress: true

jobs:
verify:
name: Observe required public legal sources
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22.13.0

- name: Run sanitized live observations
run: node scripts/observe-legal-sources.mjs --output artifacts/legal-source-health-live.json

- name: Upload sanitized observation
if: always()
uses: actions/upload-artifact@v4
with:
name: legal-source-health-${{ github.run_id }}
path: artifacts/legal-source-health-live.json
if-no-files-found: error
retention-days: 14
1 change: 1 addition & 0 deletions backend/src/lib/chat/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ function researchInstructions(settings: ResearchPromptSettings) {
- Prefer binding primary authority. Distinguish binding, persuasive, and secondary authority.
- For every legal proposition researched in this turn, retrieve the exact supporting passage. Do not cite an authority merely because its title or citation appeared in search results.
- Use search_legal_sources for discovery, fetch_legal_source for source metadata, find_in_legal_source for the exact passage, and verify_legal_citations for citation checks. Do not skip the passage step for a researched proposition.
- A legal-source search may return results from several enabled providers. Prefer Ontario e-Laws or Justice Laws Canada for current legislation when available. Treat A2AJ legislation as unofficial discovery and passage text that still requires the linked official source to be checked.
- Prefer neutral citations and paragraph-level pinpoints. Preserve authoritative English and French source text without silently translating official titles.
- Official, current source metadata controls over model memory. Do not substitute current law for historical law without disclosure.
- Track citation verification, passage verification, currency, and treatment separately. The absence of negative treatment does not prove good law when comprehensive current treatment data is unavailable.
Expand Down
2 changes: 1 addition & 1 deletion backend/src/lib/chat/tools/legalSourceTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const LEGAL_SOURCE_TOOLS = [
function: {
name: LEGAL_SOURCE_TOOL_NAMES.search,
description:
"Search an enabled legal-source provider. Returns metadata only; fetch and find an exact passage before citing an authority.",
"Search enabled legal-source providers. Returns metadata only; choose a result, then fetch and find an exact passage before citing an authority.",
parameters: {
type: "object",
properties: {
Expand Down
111 changes: 75 additions & 36 deletions backend/src/lib/chat/tools/toolDispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,40 +564,70 @@ async function executeLegalSourceTool(args: {
if (name === LEGAL_SOURCE_TOOL_NAMES.search) {
const query = typeof input.query === "string" ? input.query.trim() : "";
const limit = Math.min(20, Math.max(1, Number(input.limit) || 10));
const results =
materialType === "decision" && provider.searchDecisions
? await provider.searchDecisions(
{
query,
jurisdiction: jurisdiction ?? undefined,
court:
typeof input.court === "string" ? input.court : undefined,
language: input.language === "fr" ? "fr" : "en",
from: typeof input.from === "string" ? input.from : undefined,
to: typeof input.to === "string" ? input.to : undefined,
limit,
},
context,
)
: provider.searchLegislation
? await provider.searchLegislation(
{
query,
jurisdiction: jurisdiction ?? undefined,
language: input.language === "fr" ? "fr" : "en",
kind:
materialType === "legislation" ||
materialType === "regulation" ||
materialType === "rule"
? materialType
: undefined,
limit,
},
context,
)
: [];
const searchTargets = requestedProvider ? [provider] : candidates;
const searched = await Promise.all(
searchTargets.map(async (target) => {
try {
const targetContext = legalSourceProviderContext(
target.descriptor.id,
db,
apiKeys,
);
const results =
materialType === "decision" && target.searchDecisions
? await target.searchDecisions(
{
query,
jurisdiction: jurisdiction ?? undefined,
court:
typeof input.court === "string"
? input.court
: undefined,
language: input.language === "fr" ? "fr" : "en",
from:
typeof input.from === "string" ? input.from : undefined,
to: typeof input.to === "string" ? input.to : undefined,
limit,
},
targetContext,
)
: target.searchLegislation
? await target.searchLegislation(
{
query,
jurisdiction: jurisdiction ?? undefined,
language: input.language === "fr" ? "fr" : "en",
kind:
materialType === "legislation" ||
materialType === "regulation" ||
materialType === "rule"
? materialType
: undefined,
limit,
},
targetContext,
)
: [];
return { provider: target.descriptor, results, available: true };
} catch {
return {
provider: target.descriptor,
results: [],
available: false,
};
}
}),
);
const results = searched
.reduce<unknown[]>((all, entry) => {
all.push(...entry.results);
return all;
}, [])
.slice(0, limit);
const coverageWarning =
provider.descriptor.id === "a2aj-canada" &&
searchTargets.some(
(target) => target.descriptor.id === "a2aj-canada",
) &&
typeof input.court === "string" &&
["ONSC", "ONCJ", "SMALL CLAIMS", "HRTO", "ONLTB"].includes(
input.court.toUpperCase(),
Expand All @@ -606,16 +636,25 @@ async function executeLegalSourceTool(args: {
: undefined;
return {
content: JSON.stringify({
provider: provider.descriptor,
providers: searched.map((entry) => ({
...entry.provider,
available: entry.available,
})),
results,
...(coverageWarning ? { coverage_warning: coverageWarning } : {}),
next_required_action:
"Fetch a selected source and find the exact supporting passage before citing it.",
}),
event: {
type: "legal_source_search",
provider_id: provider.descriptor.id,
provider_name: provider.descriptor.name,
provider_id:
searchTargets.length === 1
? searchTargets[0].descriptor.id
: null,
provider_name:
searchTargets.length === 1
? searchTargets[0].descriptor.name
: "Multiple legal sources",
query,
result_count: results.length,
...(coverageWarning ? { coverage_warning: coverageWarning } : {}),
Expand Down
10 changes: 7 additions & 3 deletions backend/src/lib/legalSources/a2ajClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export class A2ajClient {

async search(input: {
query: string;
docType?: "cases" | "laws";
dataset?: string;
language?: "en" | "fr";
from?: string;
Expand All @@ -115,7 +116,7 @@ export class A2ajClient {
}) {
const params = new URLSearchParams({
query: input.query,
doc_type: "cases",
doc_type: input.docType ?? "cases",
size: String(Math.min(100, Math.max(1, input.size ?? 10))),
});
if (input.dataset) params.set("dataset", input.dataset);
Expand All @@ -128,8 +129,11 @@ export class A2ajClient {
);
}

async fetchByCitation(citation: string) {
const params = new URLSearchParams({ citation, doc_type: "cases" });
async fetchByCitation(
citation: string,
docType: "cases" | "laws" = "cases",
) {
const params = new URLSearchParams({ citation, doc_type: docType });
const raw = await this.request(`/fetch?${params}`);
const direct = a2ajDocumentSchema.safeParse(raw);
if (direct.success) return direct.data;
Expand Down
60 changes: 60 additions & 0 deletions backend/src/lib/legalSources/a2ajProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,20 @@ const syntheticDecision = {
upstream_license: "SYNTHETIC TEST LICENCE",
};

const syntheticLegislation = {
...syntheticDecision,
dataset: "LEGISLATION-ON",
citation_en: "S.O. 2025, c. 1",
name_en: "Synthetic Ontario Act, 2025",
document_date_en: "2025-01-01T00:00:00",
url_en: "https://example.invalid/official/synthetic-act",
unofficial_text_en:
"1 Short title\nThis Act may be cited as the Synthetic Ontario Act.\n2 Application\nThis section applies only to synthetic tests.",
cases_cited_en: null,
cases_citing_en: null,
citing_cases_count: null,
};

test("A2AJ client sends bounded documented search parameters", async () => {
const urls: string[] = [];
const client = new A2ajClient({
Expand All @@ -50,6 +64,15 @@ test("A2AJ client sends bounded documented search parameters", async () => {
assert.equal(url.searchParams.get("dataset"), "ONCA");
assert.equal(url.searchParams.get("size"), "5");
assert.equal(url.searchParams.get("from"), "10");

await client.search({
query: "synthetic act",
docType: "laws",
dataset: "LEGISLATION-ON",
});
const lawUrl = new URL(urls[1]);
assert.equal(lawUrl.searchParams.get("doc_type"), "laws");
assert.equal(lawUrl.searchParams.get("dataset"), "LEGISLATION-ON");
});

test("A2AJ client retries transient failures and opens its circuit", async () => {
Expand Down Expand Up @@ -122,3 +145,40 @@ test("A2AJ provider maps Ontario metadata and grounded passages", async () => {
assert.equal(coverage[0].dataset, "ONCA");
assert.equal(coverage[0].documentCount, 1);
});

test("A2AJ provider discovers Ontario legislation while retaining unofficial-source limits", async () => {
const searches: Array<Record<string, unknown>> = [];
const fetches: Array<[string, string | undefined]> = [];
const client = {
search: async (input: Record<string, unknown>) => {
searches.push(input);
return { results: [syntheticLegislation], total: 1 };
},
fetchByCitation: async (citation: string, docType?: string) => {
fetches.push([citation, docType]);
return syntheticLegislation;
},
coverage: async () => [],
} as unknown as A2ajClient;
const provider = new A2ajProvider(client);
const [summary] = await provider.searchLegislation({
query: "synthetic act",
jurisdiction: "CA-ON",
kind: "legislation",
limit: 5,
});
assert.equal(searches.length, 1);
assert.equal(searches[0].docType, "laws");
assert.equal(searches[0].dataset, "LEGISLATION-ON");
assert.equal(summary.jurisdiction, "CA-ON");
assert.equal(summary.kind, "legislation");
assert.equal(summary.verification, "partial");

const document = await provider.fetchLegislation(summary.sourceId, {
section: "2",
});
assert.deepEqual(fetches, [["S.O. 2025, c. 1", "laws"]]);
assert.equal(document.reproductionIsOfficial, false);
assert.equal(document.sections.length, 1);
assert.match(document.sections[0].text, /synthetic tests/);
});
Loading
Loading