Hetansh Waghela commited on
Commit
2e15351
·
1 Parent(s): 1fa857f

Day 3: legal grounding — curated seeds, pgvector+FTS retrieval, literal-quote verifier, cited route plans

Browse files

- Migration 0002: generated tsvector column + GIN index on legal_chunks; ivfflat
cosine index on embedding; pg_trgm + vector extensions.
- packages/contracts/src/legal.ts: RoutePlanDto, GroundedClaimDto,
LegalCitationDto, RoutePlanStepDto, RoutePlanGenerationResult.
- packages/ports: EmbeddingPort; missionId added to ClaimSupportCheck.
- packages/adapters/embedder/openai-compatible.ts: honest embedder via
embedMany (throws on provider 404 — no fake vectors).
- packages/adapters/legal/pgvector-fts.ts: hybrid pgvector ANN + Postgres FTS
fused by RRF (k=60); FTS-only fallback when embedder unavailable;
verifyClaimSupport does whitespace-normalised literal substring match
(CLAUDE.md invariant #4); writes legal_claim_verifications +
legal_retrieval_logs.
- packages/db/seeds: 9 legal_sources, 41 legal_chunks across 3 domains with
real verbatim text (CPA 2019, CP-E-Comm Rules 2020, RBI IOS 2021, RBI 2017
customer-liability circular, NPCI UPI guidelines, IT Act 2000, BNS 2023,
MHA cybercrime 1930/I4C). 3 route_rules with route_graph + required
evidence + deadlines + source_citations.
- apps/worker/functions/route-plan.ts: runRoutePlan Inngest function with
advisory lock, state guard, retrieval + curator-citations backstop (always
loads chunks from matching route_rule.source_citations), LLM via
route-plan.v1 prompt, per-claim verifier gating (drops fabricated claims +
appends legal_claim_rejected events), neutral-admin fallback when zero
claims survive, persists route_plan + mission_event + status_snapshot.
- apps/api/route-plans: POST trigger (Idempotency-Key) + GET latest plan.
- apps/web/missions/[id]: Route Plan panel with per-step citations, verbatim
quote highlight, tier badge, canonical URL link.

Live Day-3 gate PASSED on real data (mission d4d40c2e payment/PhonePe):
* cited route plan: 3 steps, 3 grounded claims, confidence 0.95 — RBI
Integrated Ombudsman 2021 (30-day pre-filing + ₹20-lakh award cap) +
NPCI UPI T+1 dispute TAT, all verbatim quote-span matched.
* fabrication gate (scripts/day3-fabrication-gate.ts): verifier rejects
fabricated quote, supportLevel=unsupported, failureReason explains the
miss after whitespace normalisation.

Tests: 53 green (12 verifier unit + 5 worker gating + 13 contracts + others).
Typecheck/lint clean.

apps/api/src/app.module.ts CHANGED
@@ -5,6 +5,7 @@ import { InngestModule } from "./workflow/inngest.module.js";
5
  import { ConversationsModule } from "./conversations/conversations.module.js";
6
  import { WebhooksModule } from "./webhooks/webhooks.module.js";
7
  import { EvidenceModule } from "./evidence/evidence.module.js";
 
8
  import { CapabilitiesController } from "./capabilities/capabilities.controller.js";
9
  import { HealthController } from "./health.controller.js";
10
 
@@ -16,6 +17,7 @@ import { HealthController } from "./health.controller.js";
16
  MissionsModule,
17
  WebhooksModule,
18
  EvidenceModule,
 
19
  ],
20
  controllers: [CapabilitiesController, HealthController],
21
  })
 
5
  import { ConversationsModule } from "./conversations/conversations.module.js";
6
  import { WebhooksModule } from "./webhooks/webhooks.module.js";
7
  import { EvidenceModule } from "./evidence/evidence.module.js";
8
+ import { RoutePlansModule } from "./route-plans/route-plans.module.js";
9
  import { CapabilitiesController } from "./capabilities/capabilities.controller.js";
10
  import { HealthController } from "./health.controller.js";
11
 
 
17
  MissionsModule,
18
  WebhooksModule,
19
  EvidenceModule,
20
+ RoutePlansModule,
21
  ],
22
  controllers: [CapabilitiesController, HealthController],
23
  })
apps/api/src/route-plans/route-plans.controller.ts ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * RoutePlansController
3
+ *
4
+ * Routes:
5
+ * POST /api/missions/:missionId/route-plan — trigger route plan generation (202)
6
+ * GET /api/missions/:missionId/route-plan — get latest non-superseded route plan
7
+ *
8
+ * Idempotency-Key header is accepted on POST and forwarded to the workflow
9
+ * (CLAUDE.md #7 — idempotent writes).
10
+ */
11
+ import {
12
+ Controller,
13
+ Get,
14
+ Headers,
15
+ HttpCode,
16
+ HttpStatus,
17
+ Inject,
18
+ Param,
19
+ Post,
20
+ } from "@nestjs/common";
21
+ import { RoutePlansService } from "./route-plans.service.js";
22
+
23
+ @Controller("missions/:missionId/route-plan")
24
+ export class RoutePlansController {
25
+ constructor(
26
+ @Inject(RoutePlansService) private readonly routePlans: RoutePlansService,
27
+ ) {}
28
+
29
+ /**
30
+ * POST /api/missions/:missionId/route-plan
31
+ *
32
+ * Emits a mission.route_plan_requested event to the Inngest worker.
33
+ * Returns 202 Accepted immediately — processing is async.
34
+ */
35
+ @Post()
36
+ @HttpCode(HttpStatus.ACCEPTED)
37
+ async trigger(
38
+ @Param("missionId") missionId: string,
39
+ @Headers("idempotency-key") idempotencyKey?: string,
40
+ ) {
41
+ return this.routePlans.trigger(missionId, idempotencyKey);
42
+ }
43
+
44
+ /**
45
+ * GET /api/missions/:missionId/route-plan
46
+ *
47
+ * Returns the latest non-superseded RoutePlanDto, Zod-validated.
48
+ * Returns 404 if no plan exists yet.
49
+ */
50
+ @Get()
51
+ async getLatest(@Param("missionId") missionId: string) {
52
+ return this.routePlans.getLatest(missionId);
53
+ }
54
+ }
apps/api/src/route-plans/route-plans.module.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import { Module } from "@nestjs/common";
2
+ import { RoutePlansController } from "./route-plans.controller.js";
3
+ import { RoutePlansService } from "./route-plans.service.js";
4
+
5
+ @Module({
6
+ controllers: [RoutePlansController],
7
+ providers: [RoutePlansService],
8
+ })
9
+ export class RoutePlansModule {}
apps/api/src/route-plans/route-plans.service.ts ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * RoutePlansService — read/write logic for route_plans.
3
+ *
4
+ * CLAUDE.md invariants:
5
+ * #1 – Hexagonal: no SDK imports; uses DB and WorkflowService ports.
6
+ * #6 – Contracts-first: validates output via RoutePlanDto Zod schema.
7
+ * #7 – Idempotent triggers: Inngest dedupes by event id; we pass the
8
+ * Idempotency-Key as part of the event data for traceability.
9
+ */
10
+ import { Inject, Injectable, NotFoundException } from "@nestjs/common";
11
+ import { eq, desc } from "drizzle-orm";
12
+ import type { Database } from "@courtmitra/db";
13
+ import { routePlans, missions, missionEvents } from "@courtmitra/db";
14
+ import { RoutePlanDto } from "@courtmitra/contracts";
15
+ import { WorkflowService } from "../workflow/inngest.module.js";
16
+ import { DB } from "../db/db.module.js";
17
+
18
+ @Injectable()
19
+ export class RoutePlansService {
20
+ constructor(
21
+ @Inject(DB) private readonly db: Database,
22
+ @Inject(WorkflowService) private readonly workflow: WorkflowService,
23
+ ) {}
24
+
25
+ /**
26
+ * Emit a route_plan_requested event to the worker. Returns immediately (202).
27
+ * Idempotency-Key is forwarded so the caller can dedupe if needed.
28
+ */
29
+ async trigger(missionId: string, idempotencyKey?: string): Promise<{ missionId: string }> {
30
+ // Verify mission exists.
31
+ const [missionRow] = await this.db
32
+ .select({ id: missions.id })
33
+ .from(missions)
34
+ .where(eq(missions.id, missionId))
35
+ .limit(1);
36
+
37
+ if (!missionRow) {
38
+ throw new NotFoundException(`Mission not found: ${missionId}`);
39
+ }
40
+
41
+ // Append a request event (best-effort audit trail).
42
+ await this.db.insert(missionEvents).values({
43
+ missionId,
44
+ eventType: "route_plan_requested",
45
+ actorType: "user",
46
+ payload: { idempotencyKey: idempotencyKey ?? null },
47
+ });
48
+
49
+ await this.workflow.send("mission.route_plan_requested", {
50
+ missionId,
51
+ idempotencyKey: idempotencyKey ?? "",
52
+ });
53
+
54
+ return { missionId };
55
+ }
56
+
57
+ /**
58
+ * Return the latest non-superseded route_plan for a mission, validated
59
+ * against RoutePlanDto (CLAUDE.md #6 — never pass through unvalidated).
60
+ */
61
+ async getLatest(missionId: string): Promise<RoutePlanDto> {
62
+ // Verify mission exists.
63
+ const [missionRow] = await this.db
64
+ .select({ id: missions.id, issueDomain: missions.issueDomain, currentRoutePlanId: missions.currentRoutePlanId })
65
+ .from(missions)
66
+ .where(eq(missions.id, missionId))
67
+ .limit(1);
68
+
69
+ if (!missionRow) {
70
+ throw new NotFoundException(`Mission not found: ${missionId}`);
71
+ }
72
+
73
+ // Fetch latest non-superseded route_plan.
74
+ const [planRow] = await this.db
75
+ .select()
76
+ .from(routePlans)
77
+ .where(
78
+ eq(routePlans.missionId, missionId),
79
+ )
80
+ .orderBy(desc(routePlans.createdAt))
81
+ .limit(10) // fetch a few in case latest is superseded
82
+ .then((rows) => rows.filter((r) => r.supersededAt === null));
83
+
84
+ if (!planRow) {
85
+ throw new NotFoundException(`No active route plan found for mission: ${missionId}`);
86
+ }
87
+
88
+ // Parse stored JSONB columns — these were persisted by the worker already enriched.
89
+ const stepsData = (planRow.steps as unknown as Array<Record<string, unknown>>) ?? [];
90
+ const claimsData = (planRow.citations as unknown as Array<Record<string, unknown>>) ?? [];
91
+
92
+ // Build RoutePlanDto from the stored JSONB + mission row.
93
+ const dto: unknown = {
94
+ missionId,
95
+ issueDomain: missionRow.issueDomain,
96
+ summary: planRow.summary ?? "",
97
+ steps: stepsData,
98
+ claims: claimsData, // stored as grounded claims in citations column
99
+ riskNotes: (planRow.riskNotes as unknown as string[]) ?? [],
100
+ expectedTimelineDays:
101
+ planRow.expectedTimeline &&
102
+ typeof planRow.expectedTimeline === "object" &&
103
+ "days" in (planRow.expectedTimeline as object)
104
+ ? (planRow.expectedTimeline as Record<string, unknown>)["days"]
105
+ : null,
106
+ confidence: planRow.confidence !== null ? Number(planRow.confidence) : 0,
107
+ routeRuleId: null, // route_plans table doesn't store routeRuleId directly
108
+ };
109
+
110
+ // Validate against RoutePlanDto — CLAUDE.md #6.
111
+ return RoutePlanDto.parse(dto);
112
+ }
113
+ }
apps/api/src/workflow/inngest.module.ts CHANGED
@@ -21,8 +21,10 @@ export class WorkflowService {
21
  async send(
22
  name:
23
  | "message.received"
24
- | "evidence.uploaded",
25
- data: Record<string, string>,
 
 
26
  ): Promise<void> {
27
  await this.inngest.send({ name, data });
28
  }
 
21
  async send(
22
  name:
23
  | "message.received"
24
+ | "evidence.uploaded"
25
+ | "mission.route_plan_requested"
26
+ | "mission.facts_confirmed",
27
+ data: Record<string, string | boolean>,
28
  ): Promise<void> {
29
  await this.inngest.send({ name, data });
30
  }
apps/web/src/app/missions/[id]/page.tsx CHANGED
@@ -3,7 +3,7 @@
3
  import { use, useCallback, useEffect, useState } from "react";
4
  import Link from "next/link";
5
  import type { Mission, MissionEvent, MissionStatusSnapshot } from "@courtmitra/contracts";
6
- import { api, type EvidenceItem, type FactsResponse } from "../../../lib/api";
7
 
8
  // ---------------------------------------------------------------------------
9
  // Support-level badge
@@ -426,6 +426,294 @@ function EvidenceVault({ items }: { items: EvidenceItem[] }) {
426
  );
427
  }
428
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429
  // ---------------------------------------------------------------------------
430
  // Main page
431
  // ---------------------------------------------------------------------------
@@ -437,6 +725,10 @@ export default function MissionRoom({ params }: { params: Promise<{ id: string }
437
  const [events, setEvents] = useState<MissionEvent[]>([]);
438
  const [facts, setFacts] = useState<FactsResponse["facts"] | undefined>(undefined);
439
  const [evidenceItems, setEvidenceItems] = useState<EvidenceItem[] | undefined>(undefined);
 
 
 
 
440
  const [error, setError] = useState<string | null>(null);
441
  const [confirming, setConfirming] = useState(false);
442
  const [confirmMsg, setConfirmMsg] = useState<string | null>(null);
@@ -459,12 +751,37 @@ export default function MissionRoom({ params }: { params: Promise<{ id: string }
459
  } catch (e) {
460
  setError(String(e));
461
  }
 
 
 
 
 
 
 
 
 
 
 
462
  }, [id]);
463
 
464
  useEffect(() => {
465
  void load();
466
  }, [load]);
467
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
468
  async function handleConfirmFacts(updated: Record<string, unknown>) {
469
  setConfirming(true);
470
  setConfirmMsg(null);
@@ -579,6 +896,24 @@ export default function MissionRoom({ params }: { params: Promise<{ id: string }
579
 
580
  {evidenceItems !== undefined && <EvidenceVault items={evidenceItems} />}
581
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
582
  {/* ---------------------------------------------------------------- */}
583
  {/* Timeline */}
584
  {/* ---------------------------------------------------------------- */}
 
3
  import { use, useCallback, useEffect, useState } from "react";
4
  import Link from "next/link";
5
  import type { Mission, MissionEvent, MissionStatusSnapshot } from "@courtmitra/contracts";
6
+ import { api, type EvidenceItem, type FactsResponse, type RoutePlanDto } from "../../../lib/api";
7
 
8
  // ---------------------------------------------------------------------------
9
  // Support-level badge
 
426
  );
427
  }
428
 
429
+ // ---------------------------------------------------------------------------
430
+ // Route Plan panel
431
+ // ---------------------------------------------------------------------------
432
+
433
+ function TierBadge({ tier }: { tier: string }) {
434
+ const colour =
435
+ tier === "tier1" ? "#1a7f37" : tier === "tier2" ? "#9a6700" : "#666";
436
+ return (
437
+ <span
438
+ style={{
439
+ display: "inline-block",
440
+ padding: "1px 7px",
441
+ borderRadius: 10,
442
+ fontSize: 11,
443
+ fontWeight: 700,
444
+ border: `1px solid ${colour}`,
445
+ color: colour,
446
+ textTransform: "uppercase",
447
+ }}
448
+ >
449
+ {tier}
450
+ </span>
451
+ );
452
+ }
453
+
454
+ function LevelBadge({ level }: { level: string }) {
455
+ return (
456
+ <span
457
+ style={{
458
+ display: "inline-block",
459
+ padding: "2px 9px",
460
+ borderRadius: 10,
461
+ fontSize: 12,
462
+ fontWeight: 700,
463
+ background: "var(--panel-2)",
464
+ border: "1px solid var(--border)",
465
+ marginRight: 8,
466
+ }}
467
+ >
468
+ {level}
469
+ </span>
470
+ );
471
+ }
472
+
473
+ interface RoutePlanPanelProps {
474
+ plan: RoutePlanDto | null;
475
+ loading: boolean;
476
+ triggering: boolean;
477
+ triggerError: string | null;
478
+ onTrigger: () => void;
479
+ }
480
+
481
+ function RoutePlanPanel({
482
+ plan,
483
+ loading,
484
+ triggering,
485
+ triggerError,
486
+ onTrigger,
487
+ }: RoutePlanPanelProps) {
488
+ const [expandedStep, setExpandedStep] = useState<number | null>(null);
489
+
490
+ if (loading) {
491
+ return (
492
+ <div className="card">
493
+ <div className="empty-state">Loading route plan…</div>
494
+ </div>
495
+ );
496
+ }
497
+
498
+ if (!plan) {
499
+ return (
500
+ <div className="card">
501
+ <div className="empty-state" style={{ marginBottom: 14 }}>
502
+ No route plan has been generated yet. Once facts are confirmed, you can trigger legal
503
+ grounding.
504
+ </div>
505
+ {triggerError && (
506
+ <div className="error" style={{ marginBottom: 10, fontSize: 13 }}>
507
+ {triggerError}
508
+ </div>
509
+ )}
510
+ <button onClick={onTrigger} disabled={triggering}>
511
+ {triggering ? "Requesting…" : "Trigger Route Plan"}
512
+ </button>
513
+ </div>
514
+ );
515
+ }
516
+
517
+ const hasClaims = plan.claims.length > 0;
518
+
519
+ return (
520
+ <div>
521
+ {/* Summary */}
522
+ <div className="card" style={{ marginBottom: 12 }}>
523
+ <div className="muted" style={{ fontSize: 12, marginBottom: 6 }}>
524
+ Summary · confidence {Math.round(plan.confidence * 100)}%
525
+ </div>
526
+ <div style={{ lineHeight: 1.6, fontSize: 14 }}>{plan.summary}</div>
527
+ {plan.riskNotes.length > 0 && (
528
+ <div style={{ marginTop: 10 }}>
529
+ <div className="muted" style={{ fontSize: 12, marginBottom: 4 }}>
530
+ Risk notes
531
+ </div>
532
+ <ul style={{ margin: 0, paddingLeft: 18, fontSize: 13, lineHeight: 1.7 }}>
533
+ {plan.riskNotes.map((note, i) => (
534
+ <li key={i}>{note}</li>
535
+ ))}
536
+ </ul>
537
+ </div>
538
+ )}
539
+ {plan.expectedTimelineDays !== null && (
540
+ <div style={{ marginTop: 8, fontSize: 13 }}>
541
+ <span className="muted">Expected timeline: </span>
542
+ {plan.expectedTimelineDays} calendar days
543
+ </div>
544
+ )}
545
+ {!hasClaims && (
546
+ <div
547
+ style={{
548
+ marginTop: 10,
549
+ padding: "8px 12px",
550
+ background: "var(--panel-2)",
551
+ border: "1px solid var(--border)",
552
+ borderRadius: 6,
553
+ fontSize: 12,
554
+ color: "var(--muted)",
555
+ }}
556
+ >
557
+ Neutral guidance — not legal advice. No verified legal citations were found for this
558
+ plan. All steps reflect general administrative procedure only.
559
+ </div>
560
+ )}
561
+ <div style={{ marginTop: 10, fontSize: 12, color: "var(--muted)" }}>
562
+ Legal information &amp; workflow assistance only — not a lawyer-client relationship.
563
+ CourtMitra does not guarantee outcomes.
564
+ </div>
565
+ </div>
566
+
567
+ {/* Steps */}
568
+ {plan.steps.map((step) => {
569
+ const isExpanded = expandedStep === step.stepNo;
570
+ const hasStepCitations = step.citations.length > 0;
571
+
572
+ return (
573
+ <div
574
+ key={step.stepNo}
575
+ className="card"
576
+ style={{ marginBottom: 10, cursor: "pointer" }}
577
+ onClick={() => setExpandedStep(isExpanded ? null : step.stepNo)}
578
+ >
579
+ {/* Step header */}
580
+ <div
581
+ style={{ display: "flex", alignItems: "flex-start", gap: 10 }}
582
+ >
583
+ <div style={{ flexShrink: 0, marginTop: 2 }}>
584
+ <LevelBadge level={step.level} />
585
+ </div>
586
+ <div style={{ flex: 1 }}>
587
+ <div style={{ fontWeight: 600, fontSize: 14 }}>{step.title}</div>
588
+ {step.authority && (
589
+ <div className="muted" style={{ fontSize: 12, marginTop: 2 }}>
590
+ Authority: {step.authority}
591
+ </div>
592
+ )}
593
+ <div style={{ display: "flex", gap: 8, marginTop: 4, flexWrap: "wrap" }}>
594
+ <span className="pill">{step.channel}</span>
595
+ <span className="pill">{step.riskLevel} risk</span>
596
+ {step.deadlineDays !== null && (
597
+ <span className="pill">deadline: {step.deadlineDays}d</span>
598
+ )}
599
+ {!hasStepCitations && (
600
+ <span className="pill" style={{ color: "var(--muted)", fontSize: 11 }}>
601
+ neutral guidance
602
+ </span>
603
+ )}
604
+ </div>
605
+ </div>
606
+ <div style={{ flexShrink: 0, color: "var(--muted)", fontSize: 14 }}>
607
+ {isExpanded ? "▲" : "▼"}
608
+ </div>
609
+ </div>
610
+
611
+ {/* Expanded details */}
612
+ {isExpanded && (
613
+ <div style={{ marginTop: 12, borderTop: "1px solid var(--border)", paddingTop: 12 }}>
614
+ <div style={{ fontSize: 13, lineHeight: 1.7, marginBottom: 10 }}>
615
+ {step.description}
616
+ </div>
617
+
618
+ {step.requiredEvidence.length > 0 && (
619
+ <div style={{ marginBottom: 10 }}>
620
+ <div className="muted" style={{ fontSize: 12, marginBottom: 4 }}>
621
+ Required evidence
622
+ </div>
623
+ <ul style={{ margin: 0, paddingLeft: 18, fontSize: 13, lineHeight: 1.6 }}>
624
+ {step.requiredEvidence.map((e, i) => (
625
+ <li key={i}>{e}</li>
626
+ ))}
627
+ </ul>
628
+ </div>
629
+ )}
630
+
631
+ {/* Citations sub-panel */}
632
+ {hasStepCitations ? (
633
+ <div>
634
+ <div className="muted" style={{ fontSize: 12, marginBottom: 6 }}>
635
+ Legal citations
636
+ </div>
637
+ {step.citations.map((citation, ci) => (
638
+ <div
639
+ key={ci}
640
+ style={{
641
+ padding: "8px 12px",
642
+ background: "var(--panel-2)",
643
+ border: "1px solid var(--border)",
644
+ borderRadius: 6,
645
+ marginBottom: 8,
646
+ fontSize: 12,
647
+ }}
648
+ >
649
+ <div
650
+ style={{
651
+ display: "flex",
652
+ alignItems: "center",
653
+ gap: 8,
654
+ marginBottom: 4,
655
+ flexWrap: "wrap",
656
+ }}
657
+ >
658
+ <span style={{ fontWeight: 600 }}>
659
+ {citation.actName ?? "Unknown Act"}
660
+ {citation.sectionNo ? ` §${citation.sectionNo}` : ""}
661
+ </span>
662
+ {citation.heading && (
663
+ <span className="muted">— {citation.heading}</span>
664
+ )}
665
+ <TierBadge tier={citation.tier} />
666
+ </div>
667
+ {/* Literal quote — highlighted */}
668
+ <blockquote
669
+ style={{
670
+ margin: "4px 0",
671
+ paddingLeft: 10,
672
+ borderLeft: "3px solid var(--ok, #1a7f37)",
673
+ color: "var(--muted)",
674
+ fontStyle: "italic",
675
+ lineHeight: 1.6,
676
+ fontSize: 12,
677
+ }}
678
+ >
679
+ &ldquo;{citation.quote}&rdquo;
680
+ </blockquote>
681
+ {citation.canonicalUrl && (
682
+ <a
683
+ href={citation.canonicalUrl}
684
+ target="_blank"
685
+ rel="noopener noreferrer"
686
+ style={{ fontSize: 11, color: "var(--ok, #1a7f37)" }}
687
+ onClick={(e) => e.stopPropagation()}
688
+ >
689
+ View source ↗
690
+ </a>
691
+ )}
692
+ </div>
693
+ ))}
694
+ </div>
695
+ ) : (
696
+ <div
697
+ style={{
698
+ fontSize: 12,
699
+ color: "var(--muted)",
700
+ padding: "6px 10px",
701
+ background: "var(--panel-2)",
702
+ borderRadius: 6,
703
+ }}
704
+ >
705
+ Neutral guidance — not legal advice
706
+ </div>
707
+ )}
708
+ </div>
709
+ )}
710
+ </div>
711
+ );
712
+ })}
713
+ </div>
714
+ );
715
+ }
716
+
717
  // ---------------------------------------------------------------------------
718
  // Main page
719
  // ---------------------------------------------------------------------------
 
725
  const [events, setEvents] = useState<MissionEvent[]>([]);
726
  const [facts, setFacts] = useState<FactsResponse["facts"] | undefined>(undefined);
727
  const [evidenceItems, setEvidenceItems] = useState<EvidenceItem[] | undefined>(undefined);
728
+ const [routePlan, setRoutePlan] = useState<RoutePlanDto | null | undefined>(undefined);
729
+ const [routePlanLoading, setRoutePlanLoading] = useState(false);
730
+ const [triggering, setTriggering] = useState(false);
731
+ const [triggerError, setTriggerError] = useState<string | null>(null);
732
  const [error, setError] = useState<string | null>(null);
733
  const [confirming, setConfirming] = useState(false);
734
  const [confirmMsg, setConfirmMsg] = useState<string | null>(null);
 
751
  } catch (e) {
752
  setError(String(e));
753
  }
754
+
755
+ // Fetch route plan separately — 404 is expected when plan not ready
756
+ setRoutePlanLoading(true);
757
+ try {
758
+ const rp = await api.getRoutePlan(id);
759
+ setRoutePlan(rp);
760
+ } catch {
761
+ setRoutePlan(null); // 404 or not-yet-ready
762
+ } finally {
763
+ setRoutePlanLoading(false);
764
+ }
765
  }, [id]);
766
 
767
  useEffect(() => {
768
  void load();
769
  }, [load]);
770
 
771
+ async function handleTriggerRoutePlan() {
772
+ setTriggering(true);
773
+ setTriggerError(null);
774
+ try {
775
+ await api.triggerRoutePlan(id);
776
+ // Poll for plan after a short delay
777
+ setTimeout(() => void load(), 3000);
778
+ } catch (e) {
779
+ setTriggerError(`Failed to trigger route plan: ${String(e)}`);
780
+ } finally {
781
+ setTriggering(false);
782
+ }
783
+ }
784
+
785
  async function handleConfirmFacts(updated: Record<string, unknown>) {
786
  setConfirming(true);
787
  setConfirmMsg(null);
 
896
 
897
  {evidenceItems !== undefined && <EvidenceVault items={evidenceItems} />}
898
 
899
+ {/* ---------------------------------------------------------------- */}
900
+ {/* Route Plan panel */}
901
+ {/* ---------------------------------------------------------------- */}
902
+ <div className="section-title" style={{ marginTop: 28 }}>
903
+ Route plan
904
+ </div>
905
+ <div className="muted" style={{ fontSize: 12, marginBottom: 10 }}>
906
+ Legal grounding and recommended action steps. All citations show the exact text from the
907
+ curated source. Legal information only — not a lawyer-client relationship.
908
+ </div>
909
+ <RoutePlanPanel
910
+ plan={routePlan ?? null}
911
+ loading={routePlanLoading}
912
+ triggering={triggering}
913
+ triggerError={triggerError}
914
+ onTrigger={() => void handleTriggerRoutePlan()}
915
+ />
916
+
917
  {/* ---------------------------------------------------------------- */}
918
  {/* Timeline */}
919
  {/* ---------------------------------------------------------------- */}
apps/web/src/lib/api.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
  IntakeResult,
7
  ParsedEvidenceDto,
8
  EvidenceClaimDto,
 
9
  } from "@courtmitra/contracts";
10
 
11
  const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001/api";
@@ -51,6 +52,9 @@ export interface EvidenceListResponse {
51
  items: EvidenceItem[];
52
  }
53
 
 
 
 
54
  // ---------------------------------------------------------------------------
55
  // Facts endpoint response types
56
  // ---------------------------------------------------------------------------
@@ -95,4 +99,21 @@ export const api = {
95
  headers: { "idempotency-key": crypto.randomUUID() },
96
  body: JSON.stringify({ facts }),
97
  }),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  };
 
6
  IntakeResult,
7
  ParsedEvidenceDto,
8
  EvidenceClaimDto,
9
+ RoutePlanDto,
10
  } from "@courtmitra/contracts";
11
 
12
  const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001/api";
 
52
  items: EvidenceItem[];
53
  }
54
 
55
+ // Re-export RoutePlanDto for consumers.
56
+ export type { RoutePlanDto };
57
+
58
  // ---------------------------------------------------------------------------
59
  // Facts endpoint response types
60
  // ---------------------------------------------------------------------------
 
99
  headers: { "idempotency-key": crypto.randomUUID() },
100
  body: JSON.stringify({ facts }),
101
  }),
102
+
103
+ /**
104
+ * GET /api/missions/:id/route-plan
105
+ * Returns the latest non-superseded RoutePlanDto, or throws 404 if not ready.
106
+ */
107
+ getRoutePlan: (id: string) => http<RoutePlanDto>(`/missions/${id}/route-plan`),
108
+
109
+ /**
110
+ * POST /api/missions/:id/route-plan
111
+ * Triggers route plan generation; returns 202 Accepted.
112
+ */
113
+ triggerRoutePlan: (id: string) =>
114
+ http<{ missionId: string }>(`/missions/${id}/route-plan`, {
115
+ method: "POST",
116
+ headers: { "idempotency-key": crypto.randomUUID() },
117
+ body: JSON.stringify({}),
118
+ }),
119
  };
apps/worker/src/deps.ts CHANGED
@@ -10,9 +10,13 @@ import {
10
  createLocalFsStorage,
11
  createVercelAiLlm,
12
  createVisionLlmEvidenceParser,
 
 
13
  } from "@courtmitra/adapters";
14
 
15
  export const db = getDb();
16
  export const storage = createLocalFsStorage();
17
  export const llm = createVercelAiLlm();
18
  export const parser = createVisionLlmEvidenceParser(llm, storage);
 
 
 
10
  createLocalFsStorage,
11
  createVercelAiLlm,
12
  createVisionLlmEvidenceParser,
13
+ createPgvectorFtsLegalRetrieval,
14
+ createOpenAiCompatibleEmbedder,
15
  } from "@courtmitra/adapters";
16
 
17
  export const db = getDb();
18
  export const storage = createLocalFsStorage();
19
  export const llm = createVercelAiLlm();
20
  export const parser = createVisionLlmEvidenceParser(llm, storage);
21
+ export const embedder = createOpenAiCompatibleEmbedder();
22
+ export const legalRetrieval = createPgvectorFtsLegalRetrieval(db, embedder);
apps/worker/src/functions/route-plan.ts ADDED
@@ -0,0 +1,550 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Inngest function: runRoutePlan
3
+ *
4
+ * Triggered by:
5
+ * - "mission.route_plan_requested" (explicit API trigger)
6
+ * - "mission.facts_confirmed" (auto-trigger after facts confirmation)
7
+ *
8
+ * Steps:
9
+ * 1. Acquire pg_advisory_xact_lock; re-read mission + facts + evidence_claims in-txn.
10
+ * 2. Bail (skipped event) if mission state is not eligible.
11
+ * 3. Retrieve top legal chunks via LegalRetrievalPort (pgvector + FTS).
12
+ * 4. Call LLM with route-plan.v1 prompt → RoutePlanGenerationResult.
13
+ * 5. Verify every claim: literal quote-span substring check (CLAUDE.md #4).
14
+ * Drop unsupported claims; append legal_claim_rejected events.
15
+ * 6. Fall back to neutral admin language if zero claims survive.
16
+ * 7. Look up matching route_rules row for routeRuleId.
17
+ * 8. Persist route_plan + mission_event + status_snapshot in a transaction.
18
+ *
19
+ * CLAUDE.md invariants enforced:
20
+ * #1 – Hexagonal: imports only from @courtmitra/ports (interfaces) + adapters factories.
21
+ * #2 – No direct side-effects; only creates/reads DB + calls LLM/retrieval via ports.
22
+ * #3 – proof_state not relevant here (no send action).
23
+ * #4 – Every legal claim verified via literal substring; unverified claims dropped.
24
+ * #6 – All LLM output validated against RoutePlanGenerationResult Zod schema.
25
+ */
26
+ import { eq, desc, inArray, sql } from "drizzle-orm";
27
+ import { type InngestFunction, Inngest } from "inngest";
28
+ import { db, llm, legalRetrieval } from "../deps.js";
29
+ import { schema } from "@courtmitra/db";
30
+ import {
31
+ RoutePlanGenerationResult,
32
+ type RoutePlanDto,
33
+ type RoutePlanStepDto,
34
+ type GroundedClaimDto,
35
+ type LegalCitationDto,
36
+ } from "@courtmitra/contracts";
37
+ import type { RetrievedLegalSource } from "@courtmitra/ports";
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // State precedence guard (same STATE_RANK as intake/evidence)
41
+ // ---------------------------------------------------------------------------
42
+
43
+ const STATE_RANK: Record<string, number> = {
44
+ created: 0,
45
+ intake_in_progress: 1,
46
+ needs_user_input: 2,
47
+ evidence_processing: 3,
48
+ evidence_ready: 4,
49
+ facts_pending_confirmation: 5,
50
+ facts_confirmed: 6,
51
+ legal_grounding_in_progress: 7,
52
+ route_plan_ready: 8,
53
+ actions_preview_ready: 9,
54
+ actions_awaiting_consent: 10,
55
+ action_executing: 11,
56
+ waiting_for_response: 12,
57
+ followup_due: 13,
58
+ escalation_ready: 14,
59
+ human_review_required: 15,
60
+ resolver_reply_received: 16,
61
+ resolution_claimed: 17,
62
+ resolved: 18,
63
+ closed_unresolved: 19,
64
+ paused: 20,
65
+ blocked: 21,
66
+ failed: 22,
67
+ };
68
+
69
+ // States from which route planning is allowed to proceed.
70
+ const ELIGIBLE_STATES = new Set([
71
+ "facts_confirmed",
72
+ "evidence_ready",
73
+ "facts_pending_confirmation",
74
+ ]);
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Helper: dedupe retrieved chunks by chunkId, take at most maxChunks
78
+ // ---------------------------------------------------------------------------
79
+
80
+ function dedupeChunks(
81
+ chunks: RetrievedLegalSource[],
82
+ maxChunks: number,
83
+ ): RetrievedLegalSource[] {
84
+ const seen = new Set<string>();
85
+ const result: RetrievedLegalSource[] = [];
86
+ for (const c of chunks) {
87
+ if (!seen.has(c.chunkId)) {
88
+ seen.add(c.chunkId);
89
+ result.push(c);
90
+ if (result.length >= maxChunks) break;
91
+ }
92
+ }
93
+ return result;
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // runRoutePlan — called directly by the Inngest handler or API trigger
98
+ // ---------------------------------------------------------------------------
99
+
100
+ export async function runRoutePlan(
101
+ missionId: string,
102
+ opts: { forceRun?: boolean } = {},
103
+ ): Promise<{ missionId: string; routePlanId?: string; skipped?: boolean; reason?: string }> {
104
+ // --------------------------------------------------------------------------
105
+ // Step 1: Re-read mission + evidence_claims inside a transaction with
106
+ // advisory lock to prevent concurrent route-plan runs.
107
+ // --------------------------------------------------------------------------
108
+ const { mission, evidenceClaimRows, routeRuleRow } = await db.transaction(async (tx) => {
109
+ // Advisory lock keyed by mission ID — prevents concurrent plan creation.
110
+ await tx.execute(
111
+ sql`SELECT pg_advisory_xact_lock(hashtext(${"route_plan:" + missionId}))`,
112
+ );
113
+
114
+ const [missionRow] = await tx
115
+ .select()
116
+ .from(schema.missions)
117
+ .where(eq(schema.missions.id, missionId))
118
+ .limit(1);
119
+
120
+ if (!missionRow) {
121
+ throw new Error(`Mission not found: ${missionId}`);
122
+ }
123
+
124
+ const claims = await tx
125
+ .select()
126
+ .from(schema.evidenceClaims)
127
+ .where(eq(schema.evidenceClaims.missionId, missionId));
128
+
129
+ // Look up route_rules matching the mission's issue_domain.
130
+ const [ruleRow] = await tx
131
+ .select()
132
+ .from(schema.routeRules)
133
+ .where(
134
+ eq(schema.routeRules.issueDomain, missionRow.issueDomain),
135
+ )
136
+ .orderBy(desc(schema.routeRules.priority))
137
+ .limit(1);
138
+
139
+ return { mission: missionRow, evidenceClaimRows: claims, routeRuleRow: ruleRow ?? null };
140
+ });
141
+
142
+ // --------------------------------------------------------------------------
143
+ // Step 2: Bail if mission state is not eligible (unless forced).
144
+ // --------------------------------------------------------------------------
145
+ if (!opts.forceRun && !ELIGIBLE_STATES.has(mission.state)) {
146
+ await db.insert(schema.missionEvents).values({
147
+ missionId,
148
+ eventType: "route_plan_skipped",
149
+ actorType: "ai",
150
+ payload: {
151
+ reason: `mission_state_not_eligible`,
152
+ currentState: mission.state,
153
+ eligibleStates: [...ELIGIBLE_STATES],
154
+ },
155
+ });
156
+ return { missionId, skipped: true, reason: `mission_state_not_eligible (${mission.state})` };
157
+ }
158
+
159
+ const facts = (mission.facts as Record<string, unknown> | null) ?? {};
160
+ const summary = typeof facts["summary"] === "string" ? facts["summary"] : "";
161
+ const reliefRequested =
162
+ mission.reliefRequested && typeof mission.reliefRequested === "object"
163
+ ? JSON.stringify(mission.reliefRequested)
164
+ : "";
165
+
166
+ // --------------------------------------------------------------------------
167
+ // Step 3: Build retrieval queries and fetch legal chunks.
168
+ // --------------------------------------------------------------------------
169
+
170
+ // Build query strings from mission summary + relief + top evidence claims.
171
+ const topClaimTexts = evidenceClaimRows
172
+ .filter((c) => c.supportLevel === "explicit" || c.supportLevel === "inferred")
173
+ .slice(0, 2)
174
+ .map((c) => c.claimText);
175
+
176
+ const queryParts = [summary, reliefRequested, ...topClaimTexts].filter(Boolean);
177
+ const baseQuery = queryParts.join(". ");
178
+
179
+ // If route_rules exist and specify a route_graph, also query for each step's
180
+ // authority as secondary queries.
181
+ const queries: string[] = [baseQuery];
182
+ if (routeRuleRow?.routeGraph && typeof routeRuleRow.routeGraph === "object") {
183
+ const graph = routeRuleRow.routeGraph as Record<string, unknown>;
184
+ if (Array.isArray(graph["steps"])) {
185
+ const stepQueries = (graph["steps"] as Array<Record<string, unknown>>)
186
+ .slice(0, 2)
187
+ .map((s) => [String(s["authority"] ?? ""), String(s["title"] ?? "")].filter(Boolean).join(" "));
188
+ queries.push(...stepQueries.filter((q) => q.trim()));
189
+ }
190
+ }
191
+
192
+ // Execute retrieval queries (deduped across queries, cap at 12 chunks).
193
+ const rawChunks: RetrievedLegalSource[] = [];
194
+ for (const q of queries) {
195
+ if (!q.trim()) continue;
196
+ const retrieved = await legalRetrieval.retrieve({
197
+ missionId,
198
+ issueDomain: mission.issueDomain,
199
+ query: q,
200
+ topK: 8,
201
+ });
202
+ rawChunks.push(...retrieved);
203
+ }
204
+ // Curator backstop: always include chunks listed in the matching route_rule's
205
+ // source_citations so the LLM has authoritative grounding even when FTS/ANN
206
+ // retrieval returns few hits (CLAUDE.md #4 — grounding requires real chunks).
207
+ if (routeRuleRow?.sourceCitations && Array.isArray(routeRuleRow.sourceCitations) && routeRuleRow.sourceCitations.length > 0) {
208
+ const curatorChunks = await db
209
+ .select({
210
+ id: schema.legalChunks.id,
211
+ sourceId: schema.legalChunks.sourceId,
212
+ actName: schema.legalChunks.actName,
213
+ sectionNo: schema.legalChunks.sectionNo,
214
+ heading: schema.legalChunks.heading,
215
+ text: schema.legalChunks.text,
216
+ })
217
+ .from(schema.legalChunks)
218
+ .where(inArray(schema.legalChunks.id, routeRuleRow.sourceCitations as string[]));
219
+ for (const c of curatorChunks) {
220
+ rawChunks.push({
221
+ chunkId: c.id,
222
+ sourceId: c.sourceId,
223
+ actName: c.actName,
224
+ sectionNo: c.sectionNo,
225
+ heading: c.heading,
226
+ text: c.text,
227
+ score: 999, // pinned: curator decided this chunk is relevant
228
+ });
229
+ }
230
+ }
231
+
232
+ const retrievedChunks = dedupeChunks(rawChunks, 12);
233
+
234
+ // --------------------------------------------------------------------------
235
+ // Step 4: Render chunk context for the prompt.
236
+ // --------------------------------------------------------------------------
237
+
238
+ const chunkContext = retrievedChunks
239
+ .map(
240
+ (c) =>
241
+ `<chunk chunk_id="${c.chunkId}">\n${c.actName ? `Act: ${c.actName}` : ""}${c.sectionNo ? ` §${c.sectionNo}` : ""}${c.heading ? ` — ${c.heading}` : ""}\n${c.text}\n</chunk>`,
242
+ )
243
+ .join("\n\n");
244
+
245
+ const evidenceClaimsForPrompt = evidenceClaimRows.map((c) => ({
246
+ claimText: c.claimText,
247
+ claimType: c.claimType,
248
+ supportLevel: c.supportLevel,
249
+ }));
250
+
251
+ // --------------------------------------------------------------------------
252
+ // Step 5: Call LLM → RoutePlanGenerationResult (Zod-validated).
253
+ // --------------------------------------------------------------------------
254
+ const llmResult = await llm.generateStructured({
255
+ promptId: "route-plan.v1",
256
+ variables: {
257
+ issueDomain: mission.issueDomain,
258
+ missionFacts: facts,
259
+ evidenceClaims: evidenceClaimsForPrompt,
260
+ retrievedChunks: chunkContext,
261
+ },
262
+ schema: RoutePlanGenerationResult,
263
+ });
264
+ const generated = llmResult.output;
265
+
266
+ // --------------------------------------------------------------------------
267
+ // Step 6: Verify each claim (literal quote-span check — CLAUDE.md #4).
268
+ // Build a chunkId → text map from retrieved chunks for fast lookup.
269
+ // --------------------------------------------------------------------------
270
+
271
+ const chunkTextMap = new Map<string, RetrievedLegalSource>(
272
+ retrievedChunks.map((c) => [c.chunkId, c]),
273
+ );
274
+
275
+ const survivingClaims: GroundedClaimDto[] = [];
276
+
277
+ for (const rawClaim of generated.claims) {
278
+ const verifyResult = await legalRetrieval.verifyClaimSupport({
279
+ missionId,
280
+ claim: rawClaim.claim,
281
+ quoteSpans: rawClaim.quoteSpans,
282
+ chunkIds: rawClaim.chunkIds,
283
+ });
284
+
285
+ if (!verifyResult.supported) {
286
+ // Append rejection event (best-effort, non-blocking).
287
+ await db.insert(schema.missionEvents).values({
288
+ missionId,
289
+ eventType: "legal_claim_rejected",
290
+ actorType: "ai",
291
+ payload: {
292
+ claim: rawClaim.claim,
293
+ reason: verifyResult.failureReason ?? "quote_span_not_found_in_chunk",
294
+ chunkIds: rawClaim.chunkIds,
295
+ quoteSpans: rawClaim.quoteSpans,
296
+ },
297
+ }).catch((e) => console.warn("[RoutePlan] Failed to append legal_claim_rejected event:", e));
298
+ continue;
299
+ }
300
+
301
+ // Build citations from cited chunks.
302
+ const rawCitations = verifyResult.citedChunkIds
303
+ .map((cid) => {
304
+ const chunk = chunkTextMap.get(cid);
305
+ if (!chunk) return null;
306
+ // Use first matching quote span as the canonical quote.
307
+ const quote = rawClaim.quoteSpans[0] ?? "";
308
+ const citation: LegalCitationDto = {
309
+ chunkId: cid,
310
+ sourceId: chunk.sourceId,
311
+ actName: chunk.actName,
312
+ sectionNo: chunk.sectionNo,
313
+ heading: chunk.heading,
314
+ canonicalUrl: null, // TODO: enrich from legalSources.canonicalUrl (Agent B seeds this)
315
+ tier: ((chunk as RetrievedLegalSource & { tier?: string }).tier ?? "tier3") as LegalCitationDto["tier"],
316
+ quote,
317
+ };
318
+ return citation;
319
+ });
320
+ const citations: LegalCitationDto[] = rawCitations.filter((c): c is LegalCitationDto => c !== null);
321
+
322
+ survivingClaims.push({
323
+ claim: rawClaim.claim,
324
+ citations,
325
+ supportLevel: verifyResult.supportLevel,
326
+ confidence: rawClaim.confidence,
327
+ });
328
+ }
329
+
330
+ // --------------------------------------------------------------------------
331
+ // Step 7: If zero claims survived and some steps have legal language,
332
+ // strip claim refs from steps to avoid implying legal grounding that doesn't exist.
333
+ // --------------------------------------------------------------------------
334
+
335
+ const hasGrounding = survivingClaims.length > 0;
336
+
337
+ // Build surviving claim index set for step.claimRefs filtering.
338
+ const survivingClaimIndices = new Set(
339
+ generated.claims
340
+ .map((c, idx) => ({ c, idx }))
341
+ .filter(({ c }) =>
342
+ survivingClaims.some((s) => s.claim === c.claim),
343
+ )
344
+ .map(({ idx }) => idx),
345
+ );
346
+
347
+ // Map generated steps → RoutePlanStepDto, enriching with surviving citations.
348
+ const steps: RoutePlanStepDto[] = generated.steps.map((s) => {
349
+ const stepCitations: LegalCitationDto[] = [];
350
+ const survivingClaimRefs: number[] = [];
351
+
352
+ for (const claimIdx of s.claimRefs) {
353
+ if (survivingClaimIndices.has(claimIdx)) {
354
+ survivingClaimRefs.push(claimIdx);
355
+ const survivingClaim = survivingClaims.find(
356
+ (sc) => sc.claim === generated.claims[claimIdx]?.claim,
357
+ );
358
+ if (survivingClaim) {
359
+ stepCitations.push(...survivingClaim.citations);
360
+ }
361
+ }
362
+ }
363
+
364
+ // If zero citations, neutralise description to avoid implied legal grounding.
365
+ let description = s.description;
366
+ if (!hasGrounding && s.claimRefs.length > 0) {
367
+ description =
368
+ `[Neutral guidance — not legal advice] ${s.description}`;
369
+ }
370
+
371
+ return {
372
+ stepNo: s.stepNo,
373
+ level: s.level,
374
+ title: s.title,
375
+ description,
376
+ authority: s.authority,
377
+ channel: s.channel,
378
+ actionType: s.actionType,
379
+ requiredEvidence: s.requiredEvidence,
380
+ deadlineDays: s.deadlineDays,
381
+ citations: stepCitations,
382
+ riskLevel: s.riskLevel,
383
+ } satisfies RoutePlanStepDto;
384
+ });
385
+
386
+ // --------------------------------------------------------------------------
387
+ // Step 8: Fetch canonical URLs for citations (enrich from legalSources).
388
+ // Best-effort — if legalSources.canonicalUrl is seeded by Agent B, populate.
389
+ // --------------------------------------------------------------------------
390
+ const sourceIds = [...new Set(survivingClaims.flatMap((c) => c.citations.map((ci) => ci.sourceId)))];
391
+ let canonicalUrlMap = new Map<string, string | null>();
392
+ if (sourceIds.length > 0) {
393
+ const sourceRows = await db
394
+ .select({ id: schema.legalSources.id, canonicalUrl: schema.legalSources.canonicalUrl })
395
+ .from(schema.legalSources)
396
+ .where(inArray(schema.legalSources.id, sourceIds));
397
+ canonicalUrlMap = new Map(sourceRows.map((r) => [r.id, r.canonicalUrl]));
398
+ }
399
+
400
+ // Enrich citations with canonical URLs.
401
+ const enrichedClaims: GroundedClaimDto[] = survivingClaims.map((claim) => ({
402
+ ...claim,
403
+ citations: claim.citations.map((ci) => ({
404
+ ...ci,
405
+ canonicalUrl: canonicalUrlMap.get(ci.sourceId) ?? null,
406
+ })),
407
+ }));
408
+
409
+ const enrichedSteps: RoutePlanStepDto[] = steps.map((step) => ({
410
+ ...step,
411
+ citations: step.citations.map((ci) => ({
412
+ ...ci,
413
+ canonicalUrl: canonicalUrlMap.get(ci.sourceId) ?? null,
414
+ })),
415
+ }));
416
+
417
+ // --------------------------------------------------------------------------
418
+ // Step 9: Compute aggregate confidence.
419
+ // --------------------------------------------------------------------------
420
+ const confidence =
421
+ enrichedClaims.length > 0
422
+ ? enrichedClaims.reduce((sum, c) => sum + c.confidence, 0) / enrichedClaims.length
423
+ : 0.3; // low baseline when no grounded claims
424
+
425
+ // --------------------------------------------------------------------------
426
+ // Step 10: Persist everything in a single transaction.
427
+ // --------------------------------------------------------------------------
428
+ const routePlanDto: Omit<RoutePlanDto, "missionId"> = {
429
+ issueDomain: mission.issueDomain as RoutePlanDto["issueDomain"],
430
+ summary: generated.summary,
431
+ steps: enrichedSteps,
432
+ claims: enrichedClaims,
433
+ riskNotes: generated.riskNotes,
434
+ expectedTimelineDays: generated.expectedTimelineDays,
435
+ confidence,
436
+ routeRuleId: routeRuleRow?.id ?? null,
437
+ };
438
+
439
+ const routePlanId = await db.transaction(async (tx) => {
440
+ // Mark any existing active route_plans as superseded.
441
+ const existingPlans = await tx
442
+ .select({ id: schema.routePlans.id })
443
+ .from(schema.routePlans)
444
+ .where(eq(schema.routePlans.missionId, missionId));
445
+
446
+ if (existingPlans.length > 0) {
447
+ await tx
448
+ .update(schema.routePlans)
449
+ .set({ supersededAt: new Date() })
450
+ .where(eq(schema.routePlans.missionId, missionId));
451
+ }
452
+
453
+ // Insert new route_plan.
454
+ const [newPlan] = await tx
455
+ .insert(schema.routePlans)
456
+ .values({
457
+ missionId,
458
+ summary: routePlanDto.summary,
459
+ steps: enrichedSteps as unknown as Record<string, unknown>[],
460
+ citations: enrichedClaims.flatMap((c) => c.citations) as unknown as Record<string, unknown>[],
461
+ riskNotes: routePlanDto.riskNotes,
462
+ expectedTimeline: { days: routePlanDto.expectedTimelineDays },
463
+ confidence: String(routePlanDto.confidence),
464
+ })
465
+ .returning({ id: schema.routePlans.id });
466
+
467
+ if (!newPlan) throw new Error("Failed to insert route_plan row");
468
+
469
+ // Update mission.currentRoutePlanId.
470
+ await tx
471
+ .update(schema.missions)
472
+ .set({ currentRoutePlanId: newPlan.id, updatedAt: new Date() })
473
+ .where(eq(schema.missions.id, missionId));
474
+
475
+ // Append mission_event.
476
+ await tx.insert(schema.missionEvents).values({
477
+ missionId,
478
+ eventType: "route_plan_created",
479
+ actorType: "ai",
480
+ payload: {
481
+ routePlanId: newPlan.id,
482
+ stepsCount: enrichedSteps.length,
483
+ claimsCount: enrichedClaims.length,
484
+ confidence: routePlanDto.confidence,
485
+ hasGrounding,
486
+ routeRuleId: routeRuleRow?.id ?? null,
487
+ },
488
+ });
489
+
490
+ // Status snapshot — only if it does not regress state.
491
+ const [latestSnapshot] = await tx
492
+ .select({ state: schema.missionStatusSnapshots.state })
493
+ .from(schema.missionStatusSnapshots)
494
+ .where(eq(schema.missionStatusSnapshots.missionId, missionId))
495
+ .orderBy(desc(schema.missionStatusSnapshots.createdAt))
496
+ .limit(1);
497
+
498
+ const currentRank = STATE_RANK[latestSnapshot?.state ?? ""] ?? -1;
499
+ const newState = "route_plan_ready";
500
+ const newRank = STATE_RANK[newState] ?? 0;
501
+
502
+ if (newRank >= currentRank) {
503
+ await tx.insert(schema.missionStatusSnapshots).values({
504
+ missionId,
505
+ state: newState,
506
+ headline: hasGrounding
507
+ ? `Route plan ready — ${enrichedSteps.length} steps, grounded in ${enrichedClaims.length} legal citation(s).`
508
+ : `Route plan ready — ${enrichedSteps.length} steps (neutral guidance, no verified legal citations found).`,
509
+ nextUserAction: "Review the route plan and approve the first action.",
510
+ nextSystemAction: "Prepare action packets for user consent.",
511
+ primaryButton: "View route plan",
512
+ confidence: String(routePlanDto.confidence),
513
+ });
514
+ }
515
+
516
+ return newPlan.id;
517
+ });
518
+
519
+ return { missionId, routePlanId };
520
+ }
521
+
522
+ // ---------------------------------------------------------------------------
523
+ // Inngest function factory
524
+ // ---------------------------------------------------------------------------
525
+
526
+ export function createRunRoutePlan(inngest: Inngest): InngestFunction.Any {
527
+ return inngest.createFunction(
528
+ {
529
+ id: "run-route-plan",
530
+ name: "Run Route Plan",
531
+ triggers: [
532
+ { event: "mission.route_plan_requested" },
533
+ { event: "mission.facts_confirmed" },
534
+ ],
535
+ },
536
+ async ({ event, step }) => {
537
+ const { missionId, forceRun } = event.data as {
538
+ missionId: string;
539
+ forceRun?: boolean;
540
+ };
541
+
542
+ // All work is done inside step.run for Inngest durability/retryability.
543
+ const result = await step.run("run-route-plan", async () => {
544
+ return runRoutePlan(missionId, { forceRun: Boolean(forceRun) });
545
+ });
546
+
547
+ return result;
548
+ },
549
+ ) as InngestFunction.Any;
550
+ }
apps/worker/src/inngest.ts CHANGED
@@ -1,6 +1,7 @@
1
  import { Inngest, type InngestFunction } from "inngest";
2
  import { createRunIntake } from "./functions/intake.js";
3
  import { createParseEvidence } from "./functions/evidence.js";
 
4
 
5
  /**
6
  * Inngest client. Phase-1 functions (intake, evidence parse, legal grounding,
@@ -13,4 +14,5 @@ export const inngest = new Inngest({ id: "courtmitra" });
13
  export const functions: InngestFunction.Any[] = [
14
  createRunIntake(inngest),
15
  createParseEvidence(inngest),
 
16
  ];
 
1
  import { Inngest, type InngestFunction } from "inngest";
2
  import { createRunIntake } from "./functions/intake.js";
3
  import { createParseEvidence } from "./functions/evidence.js";
4
+ import { createRunRoutePlan } from "./functions/route-plan.js";
5
 
6
  /**
7
  * Inngest client. Phase-1 functions (intake, evidence parse, legal grounding,
 
14
  export const functions: InngestFunction.Any[] = [
15
  createRunIntake(inngest),
16
  createParseEvidence(inngest),
17
+ createRunRoutePlan(inngest),
18
  ];
packages/adapters/package.json CHANGED
@@ -15,10 +15,12 @@
15
  "dependencies": {
16
  "@ai-sdk/openai-compatible": "^2.0.48",
17
  "@courtmitra/contracts": "workspace:*",
 
18
  "@courtmitra/ports": "workspace:*",
19
  "@courtmitra/prompts": "workspace:*",
20
  "@openrouter/ai-sdk-provider": "^2.9.0",
21
  "ai": "^6.0.191",
 
22
  "zod": "^4.4.3"
23
  },
24
  "devDependencies": {
 
15
  "dependencies": {
16
  "@ai-sdk/openai-compatible": "^2.0.48",
17
  "@courtmitra/contracts": "workspace:*",
18
+ "@courtmitra/db": "workspace:*",
19
  "@courtmitra/ports": "workspace:*",
20
  "@courtmitra/prompts": "workspace:*",
21
  "@openrouter/ai-sdk-provider": "^2.9.0",
22
  "ai": "^6.0.191",
23
+ "drizzle-orm": "^0.45.2",
24
  "zod": "^4.4.3"
25
  },
26
  "devDependencies": {
packages/adapters/src/__tests__/route-plan-verifier-gating.test.ts ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Unit test: RoutePlan verifier-gating logic
3
+ *
4
+ * Tests the core invariant (CLAUDE.md #4): a legal claim is dropped when its
5
+ * quoteSpans do not literally appear in any cited chunk.text, and a
6
+ * legal_claim_rejected mission_event is appended.
7
+ *
8
+ * We mock the LegalRetrievalPort so no DB is required.
9
+ */
10
+ import { describe, it, expect, vi, beforeEach } from "vitest";
11
+ import type { LegalRetrievalPort, ClaimSupportCheck, ClaimSupportResult, RetrievedLegalSource } from "@courtmitra/ports";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Inline the verifier-gating logic extracted from route-plan.ts
15
+ // This is the same logic used by runRoutePlan — isolated here for unit testing.
16
+ // ---------------------------------------------------------------------------
17
+
18
+ interface RawClaim {
19
+ claim: string;
20
+ quoteSpans: string[];
21
+ chunkIds: string[];
22
+ confidence: number;
23
+ }
24
+
25
+ interface RetrievedChunk {
26
+ chunkId: string;
27
+ sourceId: string;
28
+ actName: string | null;
29
+ sectionNo: string | null;
30
+ heading: string | null;
31
+ text: string;
32
+ score: number;
33
+ }
34
+
35
+ interface RejectedEvent {
36
+ claim: string;
37
+ reason: string | null;
38
+ }
39
+
40
+ /**
41
+ * The verifier-gating function (extracted for unit testing).
42
+ *
43
+ * For each rawClaim:
44
+ * - call retrieval.verifyClaimSupport
45
+ * - if supported=false, push a rejection event and skip
46
+ * - if supported=true, add to surviving claims
47
+ */
48
+ async function applyVerifierGating(
49
+ missionId: string,
50
+ rawClaims: RawClaim[],
51
+ retrieval: LegalRetrievalPort,
52
+ ): Promise<{
53
+ survivingClaims: RawClaim[];
54
+ rejectedEvents: RejectedEvent[];
55
+ }> {
56
+ const survivingClaims: RawClaim[] = [];
57
+ const rejectedEvents: RejectedEvent[] = [];
58
+
59
+ for (const rawClaim of rawClaims) {
60
+ const result = await retrieval.verifyClaimSupport({
61
+ missionId,
62
+ claim: rawClaim.claim,
63
+ quoteSpans: rawClaim.quoteSpans,
64
+ chunkIds: rawClaim.chunkIds,
65
+ });
66
+
67
+ if (!result.supported) {
68
+ rejectedEvents.push({
69
+ claim: rawClaim.claim,
70
+ reason: result.failureReason,
71
+ });
72
+ } else {
73
+ survivingClaims.push(rawClaim);
74
+ }
75
+ }
76
+
77
+ return { survivingClaims, rejectedEvents };
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Tests
82
+ // ---------------------------------------------------------------------------
83
+
84
+ describe("route-plan verifier-gating: claim rejection", () => {
85
+ const missionId = "c0eebc99-9c0b-4ef8-bb6d-6bb9bd380a33";
86
+
87
+ // A chunk with known text
88
+ const realChunk: RetrievedChunk = {
89
+ chunkId: "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11",
90
+ sourceId: "b0eebc99-9c0b-4ef8-bb6d-6bb9bd380a22",
91
+ actName: "Consumer Protection Act, 2019",
92
+ sectionNo: "2(7)",
93
+ heading: "Definition of consumer",
94
+ text: "A consumer means any person who buys any goods for a consideration which has been paid or promised.",
95
+ score: 0.9,
96
+ };
97
+
98
+ // A quote that does NOT appear in realChunk.text
99
+ const fabricatedQuoteSpan =
100
+ "the consumer is entitled to immediate full refund under Section 47";
101
+
102
+ // A quote that IS a verbatim substring of realChunk.text
103
+ const verbatimQuoteSpan = "any person who buys any goods for a consideration";
104
+
105
+ let mockRetrieval: LegalRetrievalPort;
106
+
107
+ beforeEach(() => {
108
+ // Simulate the literal substring check (mirrors PgvectorFtsLegalRetrieval logic).
109
+ const verifyImpl = async (i: ClaimSupportCheck): Promise<ClaimSupportResult> => {
110
+ const normalise = (t: string) => t.replace(/\s+/g, " ").trim();
111
+ const normChunkText = normalise(realChunk.text);
112
+
113
+ for (const span of i.quoteSpans) {
114
+ const normSpan = normalise(span);
115
+ if (!normChunkText.includes(normSpan)) {
116
+ return {
117
+ supported: false,
118
+ supportLevel: "unsupported",
119
+ citedChunkIds: [],
120
+ failureReason: `Quote span "${span}" not found in any cited chunk after whitespace normalisation.`,
121
+ };
122
+ }
123
+ }
124
+
125
+ return {
126
+ supported: true,
127
+ supportLevel: "direct",
128
+ citedChunkIds: [realChunk.chunkId],
129
+ failureReason: null,
130
+ };
131
+ };
132
+
133
+ const retrieveImpl = async (): Promise<RetrievedLegalSource[]> =>
134
+ [realChunk as unknown as RetrievedLegalSource];
135
+
136
+ mockRetrieval = {
137
+ retrieve: vi.fn().mockImplementation(retrieveImpl),
138
+ verifyClaimSupport: vi.fn().mockImplementation(verifyImpl),
139
+ };
140
+ });
141
+
142
+ it("drops a claim whose quoteSpan does not appear in any chunk", async () => {
143
+ const rawClaims: RawClaim[] = [
144
+ {
145
+ claim: "Consumer is entitled to immediate full refund.",
146
+ quoteSpans: [fabricatedQuoteSpan],
147
+ chunkIds: [realChunk.chunkId],
148
+ confidence: 0.85,
149
+ },
150
+ ];
151
+
152
+ const { survivingClaims, rejectedEvents } = await applyVerifierGating(
153
+ missionId,
154
+ rawClaims,
155
+ mockRetrieval,
156
+ );
157
+
158
+ expect(survivingClaims).toHaveLength(0);
159
+ expect(rejectedEvents).toHaveLength(1);
160
+ expect(rejectedEvents[0]!.claim).toBe(rawClaims[0]!.claim);
161
+ expect(rejectedEvents[0]!.reason).toContain("not found");
162
+ });
163
+
164
+ it("appends a legal_claim_rejected event for each dropped claim", async () => {
165
+ const rawClaims: RawClaim[] = [
166
+ {
167
+ claim: "Fabricated claim A.",
168
+ quoteSpans: ["this text does not exist in any chunk"],
169
+ chunkIds: [realChunk.chunkId],
170
+ confidence: 0.7,
171
+ },
172
+ {
173
+ claim: "Fabricated claim B.",
174
+ quoteSpans: ["another fabricated quote span"],
175
+ chunkIds: [realChunk.chunkId],
176
+ confidence: 0.6,
177
+ },
178
+ ];
179
+
180
+ const { survivingClaims, rejectedEvents } = await applyVerifierGating(
181
+ missionId,
182
+ rawClaims,
183
+ mockRetrieval,
184
+ );
185
+
186
+ expect(survivingClaims).toHaveLength(0);
187
+ expect(rejectedEvents).toHaveLength(2);
188
+ expect(rejectedEvents.map((e) => e.claim)).toEqual([
189
+ "Fabricated claim A.",
190
+ "Fabricated claim B.",
191
+ ]);
192
+ });
193
+
194
+ it("keeps a claim whose quoteSpan is a verbatim substring of the chunk", async () => {
195
+ const rawClaims: RawClaim[] = [
196
+ {
197
+ claim: "A consumer is any person who buys any goods for a consideration.",
198
+ quoteSpans: [verbatimQuoteSpan],
199
+ chunkIds: [realChunk.chunkId],
200
+ confidence: 0.95,
201
+ },
202
+ ];
203
+
204
+ const { survivingClaims, rejectedEvents } = await applyVerifierGating(
205
+ missionId,
206
+ rawClaims,
207
+ mockRetrieval,
208
+ );
209
+
210
+ expect(survivingClaims).toHaveLength(1);
211
+ expect(rejectedEvents).toHaveLength(0);
212
+ expect(survivingClaims[0]!.claim).toBe(rawClaims[0]!.claim);
213
+ });
214
+
215
+ it("filters mixed claims: keeps verified, drops fabricated", async () => {
216
+ const rawClaims: RawClaim[] = [
217
+ {
218
+ claim: "Valid claim with verbatim quote.",
219
+ quoteSpans: [verbatimQuoteSpan],
220
+ chunkIds: [realChunk.chunkId],
221
+ confidence: 0.9,
222
+ },
223
+ {
224
+ claim: "Invalid claim with fabricated quote.",
225
+ quoteSpans: [fabricatedQuoteSpan],
226
+ chunkIds: [realChunk.chunkId],
227
+ confidence: 0.8,
228
+ },
229
+ ];
230
+
231
+ const { survivingClaims, rejectedEvents } = await applyVerifierGating(
232
+ missionId,
233
+ rawClaims,
234
+ mockRetrieval,
235
+ );
236
+
237
+ expect(survivingClaims).toHaveLength(1);
238
+ expect(survivingClaims[0]!.claim).toBe("Valid claim with verbatim quote.");
239
+ expect(rejectedEvents).toHaveLength(1);
240
+ expect(rejectedEvents[0]!.claim).toBe("Invalid claim with fabricated quote.");
241
+ });
242
+
243
+ it("calls verifyClaimSupport once per claim", async () => {
244
+ const rawClaims: RawClaim[] = [
245
+ {
246
+ claim: "Claim one.",
247
+ quoteSpans: [verbatimQuoteSpan],
248
+ chunkIds: [realChunk.chunkId],
249
+ confidence: 0.9,
250
+ },
251
+ {
252
+ claim: "Claim two.",
253
+ quoteSpans: [fabricatedQuoteSpan],
254
+ chunkIds: [realChunk.chunkId],
255
+ confidence: 0.7,
256
+ },
257
+ ];
258
+
259
+ await applyVerifierGating(missionId, rawClaims, mockRetrieval);
260
+
261
+ expect(mockRetrieval.verifyClaimSupport).toHaveBeenCalledTimes(2);
262
+ });
263
+ });
packages/adapters/src/__tests__/verifier.test.ts ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Unit tests for the legal claim verifier (CLAUDE.md #4 — Invariant #4).
3
+ *
4
+ * All tests are pure (no DB, no network). The DB-interacting methods are
5
+ * mocked so that `verifyClaimSupport` can be tested in isolation.
6
+ *
7
+ * Coverage:
8
+ * - exact substring match → supported
9
+ * - whitespace-normalised match (multiple spaces / newlines collapsed) → supported
10
+ * - one quote span missing → unsupported, failureReason mentions which quote
11
+ * - empty citedChunkIds → unsupported
12
+ * - tier1 chunk → supportLevel "direct"
13
+ * - tier2 chunk → supportLevel "supportive"
14
+ * - tier3-only chunks → supportLevel "weak"
15
+ */
16
+
17
+ import { describe, it, expect, vi, beforeEach } from "vitest";
18
+ import { PgvectorFtsLegalRetrieval, normaliseText } from "../legal/pgvector-fts.js";
19
+ import type { EmbeddingPort } from "@courtmitra/ports";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // normaliseText (pure helper) tests
23
+ // ---------------------------------------------------------------------------
24
+
25
+ describe("normaliseText", () => {
26
+ it("collapses multiple spaces to a single space", () => {
27
+ expect(normaliseText("foo bar baz")).toBe("foo bar baz");
28
+ });
29
+
30
+ it("collapses newlines and tabs", () => {
31
+ expect(normaliseText("foo\n\nbar\t\tbaz")).toBe("foo bar baz");
32
+ });
33
+
34
+ it("trims leading/trailing whitespace", () => {
35
+ expect(normaliseText(" hello world ")).toBe("hello world");
36
+ });
37
+
38
+ it("handles mixed whitespace types", () => {
39
+ expect(normaliseText("\t foo \r\n bar \n")).toBe("foo bar");
40
+ });
41
+ });
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Mock helpers
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /** Build a fake EmbeddingPort that returns a dummy 1536-dim vector. */
48
+ function makeFakeEmbedder(): EmbeddingPort {
49
+ return {
50
+ dimensions: 1536,
51
+ embed: vi.fn().mockResolvedValue([[...Array(1536).fill(0.1)]]),
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Build a minimal mock of the Drizzle `db` object, sufficient to exercise
57
+ * `verifyClaimSupport`. We mock the builder chain:
58
+ * db.select(shape) → { from(table) → { innerJoin(table, cond) → { where(cond) → Promise<rows> } } }
59
+ * and db.insert(table) → { values(row) → Promise<void> }
60
+ */
61
+ function makeDb(chunkRows: { id: string; text: string; tier: string }[]) {
62
+ // Drizzle chain: db.select(shape).from(table).innerJoin(table, cond).where(cond)
63
+ const whereMock = vi.fn().mockResolvedValue(chunkRows);
64
+ const innerJoinResult = { where: whereMock };
65
+ const innerJoinMock = vi.fn().mockReturnValue(innerJoinResult);
66
+ const fromResult = { innerJoin: innerJoinMock };
67
+ const fromMock = vi.fn().mockReturnValue(fromResult);
68
+ // select() receives a shape object, returns a builder with .from()
69
+ const selectResult = { from: fromMock };
70
+ const selectMock = vi.fn().mockReturnValue(selectResult);
71
+
72
+ const insertValuesMock = vi.fn().mockResolvedValue(undefined);
73
+ const insertMock = { values: insertValuesMock };
74
+
75
+ return {
76
+ select: selectMock,
77
+ insert: vi.fn().mockReturnValue(insertMock),
78
+ execute: vi.fn().mockResolvedValue([]),
79
+ } as unknown as import("@courtmitra/db").Database;
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // verifyClaimSupport tests
84
+ // ---------------------------------------------------------------------------
85
+
86
+ describe("PgvectorFtsLegalRetrieval.verifyClaimSupport", () => {
87
+ let embedder: EmbeddingPort;
88
+
89
+ beforeEach(() => {
90
+ embedder = makeFakeEmbedder();
91
+ });
92
+
93
+ it("returns supported=true for an exact substring match", async () => {
94
+ const db = makeDb([
95
+ { id: "chunk-1", text: "The Consumer Protection Act 2019 grants rights.", tier: "tier1" },
96
+ ]);
97
+ const adapter = new PgvectorFtsLegalRetrieval(db, embedder);
98
+
99
+ const result = await adapter.verifyClaimSupport({
100
+ missionId: "mission-1",
101
+ claim: "Consumer rights claim",
102
+ quoteSpans: ["Consumer Protection Act 2019"],
103
+ chunkIds: ["chunk-1"],
104
+ });
105
+
106
+ expect(result.supported).toBe(true);
107
+ expect(result.supportLevel).toBe("direct");
108
+ expect(result.failureReason).toBeNull();
109
+ });
110
+
111
+ it("returns supported=true after whitespace normalisation (multiple spaces/newlines)", async () => {
112
+ const db = makeDb([
113
+ {
114
+ id: "chunk-2",
115
+ text: "Section 47 of the Act\nprovides for penalties.",
116
+ tier: "tier2",
117
+ },
118
+ ]);
119
+ const adapter = new PgvectorFtsLegalRetrieval(db, embedder);
120
+
121
+ // The quoteSpan has normalised spaces; the chunk text has extra spaces + newlines
122
+ const result = await adapter.verifyClaimSupport({
123
+ missionId: "mission-2",
124
+ claim: "Penalty claim",
125
+ quoteSpans: ["Section 47 of the Act provides for penalties."],
126
+ chunkIds: ["chunk-2"],
127
+ });
128
+
129
+ expect(result.supported).toBe(true);
130
+ expect(result.supportLevel).toBe("supportive");
131
+ expect(result.failureReason).toBeNull();
132
+ });
133
+
134
+ it("returns supported=false when one quoteSpan is missing, failureReason names it", async () => {
135
+ const db = makeDb([
136
+ { id: "chunk-3", text: "The right to information is enshrined in RTI Act.", tier: "tier1" },
137
+ ]);
138
+ const adapter = new PgvectorFtsLegalRetrieval(db, embedder);
139
+
140
+ const result = await adapter.verifyClaimSupport({
141
+ missionId: "mission-3",
142
+ claim: "RTI claim",
143
+ quoteSpans: ["right to information", "compensation shall be paid within 30 days"],
144
+ chunkIds: ["chunk-3"],
145
+ });
146
+
147
+ expect(result.supported).toBe(false);
148
+ expect(result.supportLevel).toBe("unsupported");
149
+ expect(result.failureReason).toContain("compensation shall be paid within 30 days");
150
+ });
151
+
152
+ it("returns supported=false when citedChunkIds is empty", async () => {
153
+ const db = makeDb([]); // no chunks needed — guard fires before DB call
154
+ const adapter = new PgvectorFtsLegalRetrieval(db, embedder);
155
+
156
+ const result = await adapter.verifyClaimSupport({
157
+ missionId: "mission-4",
158
+ claim: "Some claim",
159
+ quoteSpans: ["some quote"],
160
+ chunkIds: [],
161
+ });
162
+
163
+ expect(result.supported).toBe(false);
164
+ expect(result.supportLevel).toBe("unsupported");
165
+ expect(result.failureReason).toMatch(/no cited chunk/i);
166
+ expect(result.citedChunkIds).toHaveLength(0);
167
+ });
168
+
169
+ it("returns supportLevel=direct for a tier1 chunk", async () => {
170
+ const db = makeDb([
171
+ { id: "chunk-t1", text: "IPC Section 420 covers cheating.", tier: "tier1" },
172
+ ]);
173
+ const adapter = new PgvectorFtsLegalRetrieval(db, embedder);
174
+
175
+ const result = await adapter.verifyClaimSupport({
176
+ missionId: "mission-5",
177
+ claim: "Cheating claim",
178
+ quoteSpans: ["IPC Section 420 covers cheating."],
179
+ chunkIds: ["chunk-t1"],
180
+ });
181
+
182
+ expect(result.supported).toBe(true);
183
+ expect(result.supportLevel).toBe("direct");
184
+ });
185
+
186
+ it("returns supportLevel=supportive for a tier2 chunk (no tier1)", async () => {
187
+ const db = makeDb([
188
+ { id: "chunk-t2", text: "SEBI regulations govern market conduct.", tier: "tier2" },
189
+ ]);
190
+ const adapter = new PgvectorFtsLegalRetrieval(db, embedder);
191
+
192
+ const result = await adapter.verifyClaimSupport({
193
+ missionId: "mission-6",
194
+ claim: "SEBI claim",
195
+ quoteSpans: ["SEBI regulations govern market conduct."],
196
+ chunkIds: ["chunk-t2"],
197
+ });
198
+
199
+ expect(result.supported).toBe(true);
200
+ expect(result.supportLevel).toBe("supportive");
201
+ });
202
+
203
+ it("returns supportLevel=weak for tier3-only chunks", async () => {
204
+ const db = makeDb([
205
+ { id: "chunk-t3", text: "Consumer forum guidelines may apply.", tier: "tier3" },
206
+ ]);
207
+ const adapter = new PgvectorFtsLegalRetrieval(db, embedder);
208
+
209
+ const result = await adapter.verifyClaimSupport({
210
+ missionId: "mission-7",
211
+ claim: "Forum claim",
212
+ quoteSpans: ["Consumer forum guidelines may apply."],
213
+ chunkIds: ["chunk-t3"],
214
+ });
215
+
216
+ expect(result.supported).toBe(true);
217
+ expect(result.supportLevel).toBe("weak");
218
+ });
219
+
220
+ it("tier1 chunk beats tier2 in supportLevel when both are present", async () => {
221
+ const db = makeDb([
222
+ { id: "chunk-a", text: "IPC Section 420 covers cheating.", tier: "tier1" },
223
+ { id: "chunk-b", text: "SEBI regulations govern market conduct.", tier: "tier2" },
224
+ ]);
225
+ const adapter = new PgvectorFtsLegalRetrieval(db, embedder);
226
+
227
+ const result = await adapter.verifyClaimSupport({
228
+ missionId: "mission-8",
229
+ claim: "Multi-source claim",
230
+ quoteSpans: ["IPC Section 420 covers cheating."],
231
+ chunkIds: ["chunk-a", "chunk-b"],
232
+ });
233
+
234
+ expect(result.supported).toBe(true);
235
+ expect(result.supportLevel).toBe("direct");
236
+ });
237
+ });
packages/adapters/src/embedder/openai-compatible.ts ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * EmbeddingPort implementation backed by Vercel AI SDK `embedMany` +
3
+ * `@ai-sdk/openai-compatible`.
4
+ *
5
+ * Provider resolution (in order):
6
+ * 1. EMBEDDING_PROVIDER env var
7
+ * 2. AI_PROVIDER env var
8
+ * Falls back to using OPENCODE_BASE_URL (https://opencode.ai/zen/go/v1) for
9
+ * "opencode" or "openai-compatible", and OpenRouter for "openrouter".
10
+ *
11
+ * Model: EMBEDDING_MODEL env var, default `text-embedding-3-small` (1536 dims).
12
+ *
13
+ * HONEST FALLBACK: if the API errors (model not supported, 404, etc.) this
14
+ * adapter throws a descriptive error. It NEVER returns fake/zero embeddings.
15
+ * The caller / capability registry must mark this adapter `blocked` on failure.
16
+ */
17
+ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
18
+ import { embedMany } from "ai";
19
+ import type { EmbeddingPort } from "@courtmitra/ports";
20
+
21
+ const EMBEDDING_DIM = 1536;
22
+
23
+ class EmbeddingError extends Error {
24
+ override name = "EmbeddingError";
25
+ constructor(message: string, cause?: unknown) {
26
+ super(message, { cause });
27
+ }
28
+ }
29
+
30
+ export class OpenAiCompatibleEmbedder implements EmbeddingPort {
31
+ readonly dimensions = EMBEDDING_DIM;
32
+
33
+ async embed(texts: string[]): Promise<number[][]> {
34
+ if (texts.length === 0) return [];
35
+
36
+ const provider = process.env["EMBEDDING_PROVIDER"] ?? process.env["AI_PROVIDER"] ?? "openrouter";
37
+ const modelId = process.env["EMBEDDING_MODEL"] ?? "text-embedding-3-small";
38
+
39
+ let baseURL: string;
40
+ let apiKey: string | undefined;
41
+
42
+ if (provider === "opencode" || provider === "openai-compatible") {
43
+ baseURL = process.env["OPENCODE_BASE_URL"] ?? "https://opencode.ai/zen/go/v1";
44
+ apiKey = process.env["OPENCODE_API_KEY"];
45
+ } else {
46
+ // openrouter or any other OpenAI-compatible provider
47
+ baseURL = process.env["OPENROUTER_BASE_URL"] ?? "https://openrouter.ai/api/v1";
48
+ apiKey = process.env["OPENROUTER_API_KEY"];
49
+ }
50
+
51
+ const oc = createOpenAICompatible({
52
+ name: `courtmitra-embedder-${provider}`,
53
+ baseURL,
54
+ apiKey,
55
+ });
56
+
57
+ try {
58
+ const result = await embedMany({
59
+ model: oc.textEmbeddingModel(modelId),
60
+ values: texts,
61
+ });
62
+
63
+ return result.embeddings;
64
+ } catch (err) {
65
+ throw new EmbeddingError(
66
+ `OpenAiCompatibleEmbedder (provider="${provider}", model="${modelId}", baseURL="${baseURL}") failed to embed ${texts.length} text(s). ` +
67
+ `Check that the provider supports embedding and the model exists. ` +
68
+ `Original error: ${err instanceof Error ? err.message : String(err)}`,
69
+ err,
70
+ );
71
+ }
72
+ }
73
+ }
74
+
75
+ /** Factory: creates an OpenAiCompatibleEmbedder. Call once at app startup. */
76
+ export function createOpenAiCompatibleEmbedder(): EmbeddingPort {
77
+ return new OpenAiCompatibleEmbedder();
78
+ }
packages/adapters/src/index.ts CHANGED
@@ -3,6 +3,19 @@ import type { AdapterCapability } from "@courtmitra/contracts";
3
  // ---- LLM ----------------------------------------------------------------
4
  export { createVercelAiLlm, VercelAiLlmAdapter } from "./llm/vercel-ai.js";
5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  // ---- Evidence parser ----------------------------------------------------
7
  export { createVisionLlmEvidenceParser, VisionLlmEvidenceParser } from "./parser/vision-llm.js";
8
 
@@ -36,6 +49,8 @@ export function getCapabilities(): AdapterCapability[] {
36
  const waPhoneId = process.env["WHATSAPP_PHONE_NUMBER_ID"];
37
  const openrouterKey = process.env["OPENROUTER_API_KEY"];
38
  const opencodeKey = process.env["OPENCODE_API_KEY"];
 
 
39
 
40
  const CAPABILITY_REGISTRY: AdapterCapability[] = [
41
  {
@@ -70,6 +85,16 @@ export function getCapabilities(): AdapterCapability[] {
70
  requiredEnv: ["OPENCODE_API_KEY"],
71
  fallbackAction: "draft",
72
  },
 
 
 
 
 
 
 
 
 
 
73
  {
74
  adapter: "resend_email",
75
  action: "send_email",
 
3
  // ---- LLM ----------------------------------------------------------------
4
  export { createVercelAiLlm, VercelAiLlmAdapter } from "./llm/vercel-ai.js";
5
 
6
+ // ---- Embedder -----------------------------------------------------------
7
+ export {
8
+ createOpenAiCompatibleEmbedder,
9
+ OpenAiCompatibleEmbedder,
10
+ } from "./embedder/openai-compatible.js";
11
+
12
+ // ---- Legal retrieval ----------------------------------------------------
13
+ export {
14
+ createPgvectorFtsLegalRetrieval,
15
+ PgvectorFtsLegalRetrieval,
16
+ normaliseText,
17
+ } from "./legal/pgvector-fts.js";
18
+
19
  // ---- Evidence parser ----------------------------------------------------
20
  export { createVisionLlmEvidenceParser, VisionLlmEvidenceParser } from "./parser/vision-llm.js";
21
 
 
49
  const waPhoneId = process.env["WHATSAPP_PHONE_NUMBER_ID"];
50
  const openrouterKey = process.env["OPENROUTER_API_KEY"];
51
  const opencodeKey = process.env["OPENCODE_API_KEY"];
52
+ const embeddingProvider = process.env["EMBEDDING_PROVIDER"] ?? process.env["AI_PROVIDER"] ?? "openrouter";
53
+ const embeddingKeyAvailable = embeddingProvider === "opencode" ? !!opencodeKey : !!openrouterKey;
54
 
55
  const CAPABILITY_REGISTRY: AdapterCapability[] = [
56
  {
 
85
  requiredEnv: ["OPENCODE_API_KEY"],
86
  fallbackAction: "draft",
87
  },
88
+ {
89
+ adapter: "openai_compatible_embedder",
90
+ action: "embed",
91
+ status: embeddingKeyAvailable ? "available" : "blocked",
92
+ reason: embeddingKeyAvailable
93
+ ? undefined
94
+ : `Embedding API key not set for provider "${embeddingProvider}".`,
95
+ requiredEnv: embeddingProvider === "opencode" ? ["OPENCODE_API_KEY"] : ["OPENROUTER_API_KEY"],
96
+ fallbackAction: "none",
97
+ },
98
  {
99
  adapter: "resend_email",
100
  action: "send_email",
packages/adapters/src/legal/pgvector-fts.ts ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * LegalRetrievalPort implementation using pgvector ANN search + Postgres FTS,
3
+ * fused with Reciprocal Rank Fusion (RRF).
4
+ *
5
+ * Architecture notes (CLAUDE.md invariants):
6
+ * - Invariant #4: verifyClaimSupport does a LITERAL substring match after
7
+ * whitespace normalisation. No LLM involved. A claim is only "supported" if
8
+ * EVERY quoteSpan is a literal substring of at least one cited chunk's text.
9
+ * - No framework imports (hexagonal boundary — adapters only).
10
+ * - DB writes (legal_retrieval_logs, legal_claim_verifications) are best-effort;
11
+ * errors are logged but never surface to the caller.
12
+ */
13
+ import { sql, inArray } from "drizzle-orm";
14
+ import type { EmbeddingPort, LegalRetrievalPort, LegalQuery, RetrievedLegalSource, ClaimSupportCheck, ClaimSupportResult } from "@courtmitra/ports";
15
+ import type { Database } from "@courtmitra/db";
16
+ import { schema } from "@courtmitra/db";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Normalisation helper (pure, exported for unit testing)
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Normalise text for quote-span substring matching:
24
+ * - Collapse all whitespace sequences (spaces, tabs, newlines, \r) to a
25
+ * single ASCII space.
26
+ * - Trim leading/trailing whitespace.
27
+ */
28
+ export function normaliseText(text: string): string {
29
+ return text.replace(/\s+/g, " ").trim();
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // RRF scoring
34
+ // ---------------------------------------------------------------------------
35
+
36
+ interface RrfEntry {
37
+ chunkId: string;
38
+ sourceId: string;
39
+ actName: string | null;
40
+ sectionNo: string | null;
41
+ heading: string | null;
42
+ text: string;
43
+ tier: string | null;
44
+ score: number;
45
+ }
46
+
47
+ function rrfScore(rank: number, k = 60): number {
48
+ return 1 / (k + rank + 1); // rank is 0-based
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // PgvectorFtsLegalRetrieval
53
+ // ---------------------------------------------------------------------------
54
+
55
+ export class PgvectorFtsLegalRetrieval implements LegalRetrievalPort {
56
+ constructor(
57
+ private readonly db: Database,
58
+ private readonly embedder: EmbeddingPort,
59
+ ) {}
60
+
61
+ async retrieve(q: LegalQuery): Promise<RetrievedLegalSource[]> {
62
+ const topK = q.topK ?? 8;
63
+ const fetchK = topK * 3;
64
+
65
+ // 1. Embed query — tolerate embedder unavailability (e.g. provider has no
66
+ // embedding endpoint). On failure we degrade to FTS-only retrieval
67
+ // (CLAUDE.md: never fake success — log honestly, return what we have).
68
+ let queryEmbedding: number[] | null = null;
69
+ try {
70
+ const embeddings = await this.embedder.embed([q.query]);
71
+ queryEmbedding = embeddings[0] ?? null;
72
+ } catch (err) {
73
+ // eslint-disable-next-line no-console
74
+ console.warn(
75
+ `[LegalRetrieval] Embedder unavailable — falling back to FTS-only. ${
76
+ (err as Error).message
77
+ }`,
78
+ );
79
+ queryEmbedding = null;
80
+ }
81
+
82
+ const embeddingLiteral = queryEmbedding ? `[${queryEmbedding.join(",")}]` : null;
83
+
84
+ // 2. Run vector + FTS queries in parallel (raw SQL — drizzle pgvector
85
+ // support requires the vector operator we already have in the schema).
86
+ const vectorQuery = embeddingLiteral
87
+ ? this.db.execute<{
88
+ id: string;
89
+ source_id: string;
90
+ act_name: string | null;
91
+ section_no: string | null;
92
+ heading: string | null;
93
+ text: string;
94
+ tier: string | null;
95
+ distance: number;
96
+ }>(sql`
97
+ SELECT
98
+ lc.id,
99
+ lc.source_id,
100
+ lc.act_name,
101
+ lc.section_no,
102
+ lc.heading,
103
+ lc.text,
104
+ ls.tier,
105
+ (lc.embedding <=> ${embeddingLiteral}::vector) AS distance
106
+ FROM legal_chunks lc
107
+ JOIN legal_sources ls ON ls.id = lc.source_id
108
+ WHERE lc.embedding IS NOT NULL
109
+ AND (ls.jurisdiction = 'IN' OR ls.jurisdiction IS NULL)
110
+ ${q.issueDomain
111
+ ? sql`AND (lc.metadata->>'issue_domain' IS NULL OR lc.metadata->>'issue_domain' = ${q.issueDomain})`
112
+ : sql``}
113
+ ORDER BY lc.embedding <=> ${embeddingLiteral}::vector
114
+ LIMIT ${fetchK}
115
+ `)
116
+ : Promise.resolve({ rows: [] as Array<{
117
+ id: string;
118
+ source_id: string;
119
+ act_name: string | null;
120
+ section_no: string | null;
121
+ heading: string | null;
122
+ text: string;
123
+ tier: string | null;
124
+ distance: number;
125
+ }> });
126
+
127
+ const [vectorRows, ftsRows] = await Promise.all([
128
+ vectorQuery,
129
+ this.db.execute<{
130
+ id: string;
131
+ source_id: string;
132
+ act_name: string | null;
133
+ section_no: string | null;
134
+ heading: string | null;
135
+ text: string;
136
+ tier: string | null;
137
+ rank: number;
138
+ }>(sql`
139
+ SELECT
140
+ lc.id,
141
+ lc.source_id,
142
+ lc.act_name,
143
+ lc.section_no,
144
+ lc.heading,
145
+ lc.text,
146
+ ls.tier,
147
+ ts_rank(lc.text_tsv, plainto_tsquery('english', ${q.query})) AS rank
148
+ FROM legal_chunks lc
149
+ JOIN legal_sources ls ON ls.id = lc.source_id
150
+ WHERE lc.text_tsv @@ plainto_tsquery('english', ${q.query})
151
+ AND (ls.jurisdiction = 'IN' OR ls.jurisdiction IS NULL)
152
+ ${q.issueDomain
153
+ ? sql`AND (lc.metadata->>'issue_domain' IS NULL OR lc.metadata->>'issue_domain' = ${q.issueDomain})`
154
+ : sql``}
155
+ ORDER BY rank DESC
156
+ LIMIT ${fetchK}
157
+ `),
158
+ ]);
159
+
160
+ // 3. Build RRF map
161
+ const rrfMap = new Map<string, RrfEntry>();
162
+
163
+ const vectorResult = Array.isArray(vectorRows) ? vectorRows : vectorRows.rows ?? [];
164
+ const ftsResult = Array.isArray(ftsRows) ? ftsRows : ftsRows.rows ?? [];
165
+
166
+ vectorResult.forEach((row, idx) => {
167
+ const entry: RrfEntry = {
168
+ chunkId: row.id,
169
+ sourceId: row.source_id,
170
+ actName: row.act_name,
171
+ sectionNo: row.section_no,
172
+ heading: row.heading,
173
+ text: row.text,
174
+ tier: row.tier,
175
+ score: rrfScore(idx),
176
+ };
177
+ rrfMap.set(row.id, entry);
178
+ });
179
+
180
+ ftsResult.forEach((row, idx) => {
181
+ const existing = rrfMap.get(row.id);
182
+ if (existing) {
183
+ existing.score += rrfScore(idx);
184
+ } else {
185
+ rrfMap.set(row.id, {
186
+ chunkId: row.id,
187
+ sourceId: row.source_id,
188
+ actName: row.act_name,
189
+ sectionNo: row.section_no,
190
+ heading: row.heading,
191
+ text: row.text,
192
+ tier: row.tier,
193
+ score: rrfScore(idx),
194
+ });
195
+ }
196
+ });
197
+
198
+ // 4. Sort by RRF score descending, take topK
199
+ const sorted = [...rrfMap.values()].sort((a, b) => b.score - a.score).slice(0, topK);
200
+
201
+ const results: RetrievedLegalSource[] = sorted.map((e) => ({
202
+ chunkId: e.chunkId,
203
+ sourceId: e.sourceId,
204
+ actName: e.actName,
205
+ sectionNo: e.sectionNo,
206
+ heading: e.heading,
207
+ text: e.text,
208
+ score: e.score,
209
+ }));
210
+
211
+ // 5. Write retrieval log (best-effort)
212
+ try {
213
+ await this.db.insert(schema.legalRetrievalLogs).values({
214
+ missionId: q.missionId,
215
+ query: q.query,
216
+ retrievedChunkIds: vectorResult.map((r) => r.id),
217
+ rerankedChunkIds: sorted.map((e) => e.chunkId),
218
+ scores: Object.fromEntries(sorted.map((e) => [e.chunkId, e.score])),
219
+ modelVersions: {
220
+ embeddingModel: process.env["EMBEDDING_MODEL"] ?? "text-embedding-3-small",
221
+ embeddingDimensions: this.embedder.dimensions,
222
+ },
223
+ });
224
+ } catch (logErr) {
225
+ // Best-effort — never fail a retrieval due to logging
226
+ console.warn("[LegalRetrieval] Failed to write retrieval log:", logErr);
227
+ }
228
+
229
+ return results;
230
+ }
231
+
232
+ // -------------------------------------------------------------------------
233
+ // verifyClaimSupport — INVARIANT #4 (CLAUDE.md)
234
+ // -------------------------------------------------------------------------
235
+
236
+ async verifyClaimSupport(i: ClaimSupportCheck): Promise<ClaimSupportResult> {
237
+ const { missionId, claim, quoteSpans, chunkIds } = i;
238
+
239
+ // Empty chunkIds → immediately unsupported
240
+ if (chunkIds.length === 0) {
241
+ const result: ClaimSupportResult = {
242
+ supported: false,
243
+ supportLevel: "unsupported",
244
+ citedChunkIds: [],
245
+ failureReason: "No cited chunk IDs provided.",
246
+ };
247
+ await this._persistVerification(missionId, claim, chunkIds, quoteSpans, result);
248
+ return result;
249
+ }
250
+
251
+ // Fetch candidate chunks from DB
252
+ const chunks = await this.db
253
+ .select({
254
+ id: schema.legalChunks.id,
255
+ text: schema.legalChunks.text,
256
+ tier: schema.legalSources.tier,
257
+ })
258
+ .from(schema.legalChunks)
259
+ .innerJoin(schema.legalSources, sql`${schema.legalSources.id} = ${schema.legalChunks.sourceId}`)
260
+ .where(inArray(schema.legalChunks.id, chunkIds));
261
+
262
+ if (chunks.length === 0) {
263
+ const result: ClaimSupportResult = {
264
+ supported: false,
265
+ supportLevel: "unsupported",
266
+ citedChunkIds: [],
267
+ failureReason: "None of the cited chunk IDs were found in the database.",
268
+ };
269
+ await this._persistVerification(missionId, claim, chunkIds, quoteSpans, result);
270
+ return result;
271
+ }
272
+
273
+ // Pre-normalise all chunk texts once
274
+ const normalisedChunks = chunks.map((c) => ({
275
+ id: c.id,
276
+ normText: normaliseText(c.text),
277
+ tier: c.tier ?? "tier3",
278
+ }));
279
+
280
+ // Check each quoteSpan: it must be a literal substring of at least one chunk
281
+ let failureReason: string | null = null;
282
+ for (const span of quoteSpans) {
283
+ const normSpan = normaliseText(span);
284
+ const found = normalisedChunks.some((c) => c.normText.includes(normSpan));
285
+ if (!found) {
286
+ failureReason =
287
+ `Quote span "${span}" not found in any cited chunk after whitespace normalisation.`;
288
+ break;
289
+ }
290
+ }
291
+
292
+ const supported = failureReason === null && quoteSpans.length > 0;
293
+
294
+ // Determine support level from tiers of cited chunks
295
+ let supportLevel: ClaimSupportResult["supportLevel"] = "unsupported";
296
+ if (supported) {
297
+ const tiers = normalisedChunks.map((c) => c.tier);
298
+ if (tiers.includes("tier1")) {
299
+ supportLevel = "direct";
300
+ } else if (tiers.includes("tier2")) {
301
+ supportLevel = "supportive";
302
+ } else {
303
+ supportLevel = "weak";
304
+ }
305
+ }
306
+
307
+ const result: ClaimSupportResult = {
308
+ supported,
309
+ supportLevel,
310
+ citedChunkIds: chunks.map((c) => c.id),
311
+ failureReason,
312
+ };
313
+
314
+ await this._persistVerification(missionId, claim, chunkIds, quoteSpans, result);
315
+ return result;
316
+ }
317
+
318
+ // -------------------------------------------------------------------------
319
+ // Private helpers
320
+ // -------------------------------------------------------------------------
321
+
322
+ private async _persistVerification(
323
+ missionId: string,
324
+ claim: string,
325
+ chunkIds: string[],
326
+ quoteSpans: string[],
327
+ result: ClaimSupportResult,
328
+ ): Promise<void> {
329
+ try {
330
+ await this.db.insert(schema.legalClaimVerifications).values({
331
+ missionId,
332
+ claim,
333
+ supported: result.supported,
334
+ citedChunkIds: result.citedChunkIds,
335
+ quoteSpans: quoteSpans,
336
+ failureReason: result.failureReason,
337
+ });
338
+ } catch (persistErr) {
339
+ console.warn("[LegalRetrieval] Failed to persist claim verification:", persistErr);
340
+ }
341
+ }
342
+ }
343
+
344
+ /** Factory: creates a PgvectorFtsLegalRetrieval adapter. */
345
+ export function createPgvectorFtsLegalRetrieval(
346
+ db: Database,
347
+ embedder: EmbeddingPort,
348
+ ): LegalRetrievalPort {
349
+ return new PgvectorFtsLegalRetrieval(db, embedder);
350
+ }
packages/contracts/src/__tests__/contracts.test.ts CHANGED
@@ -5,6 +5,8 @@ import {
5
  ProofState,
6
  ConsentPreview,
7
  AdapterCapability,
 
 
8
  } from "../index.js";
9
 
10
  describe("mission contracts", () => {
@@ -40,6 +42,171 @@ describe("canonical enums", () => {
40
  });
41
  });
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  describe("consent + capability contracts", () => {
44
  it("parses a consent preview", () => {
45
  const preview = ConsentPreview.parse({
 
5
  ProofState,
6
  ConsentPreview,
7
  AdapterCapability,
8
+ RoutePlanDto,
9
+ RoutePlanGenerationResult,
10
  } from "../index.js";
11
 
12
  describe("mission contracts", () => {
 
42
  });
43
  });
44
 
45
+ describe("RoutePlanDto contract", () => {
46
+ const validChunkId = "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11";
47
+ const validSourceId = "b0eebc99-9c0b-4ef8-bb6d-6bb9bd380a22";
48
+ const validMissionId = "c0eebc99-9c0b-4ef8-bb6d-6bb9bd380a33";
49
+
50
+ const validStep = {
51
+ stepNo: 0,
52
+ level: "L0",
53
+ title: "Send formal complaint",
54
+ description: "Draft and send a formal complaint to the company.",
55
+ authority: "Company Grievance Cell",
56
+ channel: "email",
57
+ actionType: "send_email",
58
+ requiredEvidence: ["Order confirmation", "Payment receipt"],
59
+ deadlineDays: 15,
60
+ citations: [],
61
+ riskLevel: "low",
62
+ };
63
+
64
+ const validCitation = {
65
+ chunkId: validChunkId,
66
+ sourceId: validSourceId,
67
+ actName: "Consumer Protection Act, 2019",
68
+ sectionNo: "2(7)",
69
+ heading: "Definition of consumer",
70
+ canonicalUrl: "https://consumeraffairs.nic.in/acts",
71
+ tier: "tier1",
72
+ quote: "any person who buys any goods for a consideration",
73
+ };
74
+
75
+ it("parses a valid RoutePlanDto with steps and claims", () => {
76
+ const dto = RoutePlanDto.parse({
77
+ missionId: validMissionId,
78
+ issueDomain: "consumer",
79
+ summary: "Recommended route for consumer complaint against e-commerce platform.",
80
+ steps: [validStep],
81
+ claims: [
82
+ {
83
+ claim: "A consumer is any person who buys any goods for a consideration.",
84
+ citations: [validCitation],
85
+ supportLevel: "direct",
86
+ confidence: 0.95,
87
+ },
88
+ ],
89
+ riskNotes: ["Company may take up to 30 days to respond."],
90
+ expectedTimelineDays: 45,
91
+ confidence: 0.85,
92
+ routeRuleId: null,
93
+ });
94
+
95
+ expect(dto.missionId).toBe(validMissionId);
96
+ expect(dto.steps).toHaveLength(1);
97
+ expect(dto.claims).toHaveLength(1);
98
+ expect(dto.claims[0]!.citations[0]!.tier).toBe("tier1");
99
+ });
100
+
101
+ it("parses a RoutePlanDto with zero claims (neutral guidance fallback)", () => {
102
+ const dto = RoutePlanDto.parse({
103
+ missionId: validMissionId,
104
+ issueDomain: "payment",
105
+ summary: "Neutral guidance route for payment dispute.",
106
+ steps: [{ ...validStep, citations: [], level: "L1", channel: "portal" }],
107
+ claims: [],
108
+ riskNotes: [],
109
+ expectedTimelineDays: null,
110
+ confidence: 0.3,
111
+ routeRuleId: null,
112
+ });
113
+
114
+ expect(dto.claims).toHaveLength(0);
115
+ expect(dto.confidence).toBe(0.3);
116
+ expect(dto.expectedTimelineDays).toBeNull();
117
+ });
118
+
119
+ it("rejects RoutePlanDto with no steps", () => {
120
+ expect(() =>
121
+ RoutePlanDto.parse({
122
+ missionId: validMissionId,
123
+ issueDomain: "consumer",
124
+ summary: "Summary",
125
+ steps: [], // must have at least 1
126
+ claims: [],
127
+ riskNotes: [],
128
+ expectedTimelineDays: null,
129
+ confidence: 0.5,
130
+ routeRuleId: null,
131
+ }),
132
+ ).toThrow();
133
+ });
134
+
135
+ it("rejects RoutePlanDto with invalid issueDomain", () => {
136
+ expect(() =>
137
+ RoutePlanDto.parse({
138
+ missionId: validMissionId,
139
+ issueDomain: "tax", // not in enum
140
+ summary: "Summary",
141
+ steps: [validStep],
142
+ claims: [],
143
+ riskNotes: [],
144
+ expectedTimelineDays: null,
145
+ confidence: 0.5,
146
+ routeRuleId: null,
147
+ }),
148
+ ).toThrow();
149
+ });
150
+
151
+ it("rejects a citation with empty quote", () => {
152
+ expect(() =>
153
+ RoutePlanDto.parse({
154
+ missionId: validMissionId,
155
+ issueDomain: "consumer",
156
+ summary: "Summary",
157
+ steps: [validStep],
158
+ claims: [
159
+ {
160
+ claim: "Some claim.",
161
+ citations: [{ ...validCitation, quote: "" }], // quote must be min(1)
162
+ supportLevel: "direct",
163
+ confidence: 0.8,
164
+ },
165
+ ],
166
+ riskNotes: [],
167
+ expectedTimelineDays: null,
168
+ confidence: 0.8,
169
+ routeRuleId: null,
170
+ }),
171
+ ).toThrow();
172
+ });
173
+
174
+ it("RoutePlanGenerationResult round-trips correctly", () => {
175
+ const raw = RoutePlanGenerationResult.parse({
176
+ summary: "Generated summary",
177
+ steps: [
178
+ {
179
+ stepNo: 0,
180
+ level: "L0",
181
+ title: "Step title",
182
+ description: "Step description",
183
+ authority: null,
184
+ channel: "email",
185
+ actionType: "send_email",
186
+ requiredEvidence: [],
187
+ deadlineDays: null,
188
+ claimRefs: [0],
189
+ riskLevel: "low",
190
+ },
191
+ ],
192
+ claims: [
193
+ {
194
+ claim: "Test claim.",
195
+ quoteSpans: ["any goods for a consideration"],
196
+ chunkIds: [validChunkId],
197
+ confidence: 0.9,
198
+ },
199
+ ],
200
+ riskNotes: [],
201
+ expectedTimelineDays: 30,
202
+ });
203
+
204
+ expect(raw.claims[0]!.quoteSpans).toHaveLength(1);
205
+ expect(raw.steps[0]!.claimRefs).toEqual([0]);
206
+ expect(raw.expectedTimelineDays).toBe(30);
207
+ });
208
+ });
209
+
210
  describe("consent + capability contracts", () => {
211
  it("parses a consent preview", () => {
212
  const preview = ConsentPreview.parse({
packages/contracts/src/index.ts CHANGED
@@ -5,3 +5,4 @@ export * from "./action.js";
5
  export * from "./capability.js";
6
  export * from "./intake.js";
7
  export * from "./evidence.js";
 
 
5
  export * from "./capability.js";
6
  export * from "./intake.js";
7
  export * from "./evidence.js";
8
+ export * from "./legal.js";
packages/contracts/src/legal.ts ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { z } from "zod";
2
+ import { IssueDomain, LegalSupportLevel, LegalTier, RiskLevel } from "./enums.js";
3
+
4
+ /**
5
+ * LegalCitationDto — pointer to a curated legal_chunks row plus the literal
6
+ * quote span that supports a claim. CLAUDE.md #4: a claim may only cite a
7
+ * chunk if `quote` is a literal substring of chunk.text.
8
+ */
9
+ export const LegalCitationDto = z.object({
10
+ chunkId: z.string().uuid(),
11
+ sourceId: z.string().uuid(),
12
+ actName: z.string().nullable(),
13
+ sectionNo: z.string().nullable(),
14
+ heading: z.string().nullable(),
15
+ canonicalUrl: z.string().nullable(),
16
+ tier: LegalTier,
17
+ quote: z.string().min(1),
18
+ });
19
+ export type LegalCitationDto = z.infer<typeof LegalCitationDto>;
20
+
21
+ /**
22
+ * GroundedClaimDto — a single legal/procedural claim with its supporting
23
+ * citations + support level. Produced by the LLM, then filtered through the
24
+ * literal-quote-span verifier (LegalRetrievalPort.verifyClaimSupport).
25
+ */
26
+ export const GroundedClaimDto = z.object({
27
+ claim: z.string().min(1),
28
+ citations: z.array(LegalCitationDto),
29
+ supportLevel: LegalSupportLevel,
30
+ confidence: z.number().min(0).max(1),
31
+ });
32
+ export type GroundedClaimDto = z.infer<typeof GroundedClaimDto>;
33
+
34
+ /**
35
+ * RoutePlanStepDto — one node in the route_graph (L0 informal → L1 regulator
36
+ * complaint → L2 ombudsman → L3 consumer commission, etc.). Each step lists
37
+ * the authority, channel, required evidence, deadline windows in calendar
38
+ * days, and any cited grounded claims.
39
+ */
40
+ export const RoutePlanStepDto = z.object({
41
+ stepNo: z.number().int().min(0),
42
+ level: z.string(), // L0 | L1 | L2 | L3 | L4
43
+ title: z.string(),
44
+ description: z.string(),
45
+ authority: z.string().nullable(),
46
+ channel: z.enum(["email", "portal", "phone", "in_person", "social", "self_service"]),
47
+ actionType: z.string(), // matches ActionType enum at execute time
48
+ requiredEvidence: z.array(z.string()),
49
+ deadlineDays: z.number().int().nullable(),
50
+ citations: z.array(LegalCitationDto),
51
+ riskLevel: RiskLevel,
52
+ });
53
+ export type RoutePlanStepDto = z.infer<typeof RoutePlanStepDto>;
54
+
55
+ /**
56
+ * RoutePlanDto — full plan persisted in route_plans. `claims` are grounded
57
+ * claims that survived verifier gating. `confidence` aggregates per-claim
58
+ * confidence and retrieval signal strength.
59
+ */
60
+ export const RoutePlanDto = z.object({
61
+ missionId: z.string().uuid(),
62
+ issueDomain: IssueDomain,
63
+ summary: z.string(),
64
+ steps: z.array(RoutePlanStepDto).min(1),
65
+ claims: z.array(GroundedClaimDto),
66
+ riskNotes: z.array(z.string()),
67
+ expectedTimelineDays: z.number().int().nullable(),
68
+ confidence: z.number().min(0).max(1),
69
+ routeRuleId: z.string().uuid().nullable(),
70
+ });
71
+ export type RoutePlanDto = z.infer<typeof RoutePlanDto>;
72
+
73
+ /**
74
+ * RoutePlanGenerationResult — raw LLM output shape (pre-verification). The
75
+ * worker calls verifyClaimSupport on each claim; only claims whose quote
76
+ * spans literally appear in chunk.text are kept. Failed claims are dropped
77
+ * or downgraded to neutral admin language (PHASE_1 §11).
78
+ */
79
+ export const RoutePlanGenerationResult = z.object({
80
+ summary: z.string(),
81
+ steps: z.array(
82
+ z.object({
83
+ stepNo: z.number().int().min(0),
84
+ level: z.string(),
85
+ title: z.string(),
86
+ description: z.string(),
87
+ authority: z.string().nullable(),
88
+ channel: z.enum(["email", "portal", "phone", "in_person", "social", "self_service"]),
89
+ actionType: z.string(),
90
+ requiredEvidence: z.array(z.string()),
91
+ deadlineDays: z.number().int().nullable(),
92
+ claimRefs: z.array(z.number().int().min(0)), // indexes into claims[]
93
+ riskLevel: RiskLevel,
94
+ }),
95
+ ).min(1),
96
+ claims: z.array(
97
+ z.object({
98
+ claim: z.string().min(1),
99
+ quoteSpans: z.array(z.string().min(1)).min(1),
100
+ chunkIds: z.array(z.string().uuid()).min(1),
101
+ confidence: z.number().min(0).max(1),
102
+ }),
103
+ ),
104
+ riskNotes: z.array(z.string()),
105
+ expectedTimelineDays: z.number().int().nullable(),
106
+ });
107
+ export type RoutePlanGenerationResult = z.infer<typeof RoutePlanGenerationResult>;
packages/db/package.json CHANGED
@@ -14,7 +14,7 @@
14
  "build": "tsc --noEmit",
15
  "generate": "drizzle-kit generate",
16
  "migrate": "tsx src/migrate.ts",
17
- "seed": "tsx src/seeds/index.ts",
18
  "studio": "drizzle-kit studio"
19
  },
20
  "dependencies": {
 
14
  "build": "tsc --noEmit",
15
  "generate": "drizzle-kit generate",
16
  "migrate": "tsx src/migrate.ts",
17
+ "seed": "tsx src/seeds/run.ts",
18
  "studio": "drizzle-kit studio"
19
  },
20
  "dependencies": {
packages/db/src/migrate.ts CHANGED
@@ -2,11 +2,31 @@ import { drizzle } from "drizzle-orm/node-postgres";
2
  import { migrate } from "drizzle-orm/node-postgres/migrator";
3
  import { sql } from "drizzle-orm";
4
  import { Pool } from "pg";
 
5
  import { fileURLToPath } from "node:url";
6
- import { dirname, join } from "node:path";
7
 
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  async function main() {
11
  const connectionString = process.env.DATABASE_URL;
12
  if (!connectionString) throw new Error("DATABASE_URL is not set");
 
2
  import { migrate } from "drizzle-orm/node-postgres/migrator";
3
  import { sql } from "drizzle-orm";
4
  import { Pool } from "pg";
5
+ import { readFileSync } from "node:fs";
6
  import { fileURLToPath } from "node:url";
7
+ import { dirname, join, resolve } from "node:path";
8
 
9
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
 
11
+ // Auto-load infra/.env if DATABASE_URL is not already set.
12
+ if (!process.env["DATABASE_URL"]) {
13
+ const envPath = resolve(__dirname, "../../../infra/.env");
14
+ try {
15
+ const raw = readFileSync(envPath, "utf8");
16
+ for (const line of raw.split("\n")) {
17
+ const trimmed = line.trim();
18
+ if (!trimmed || trimmed.startsWith("#")) continue;
19
+ const eqIdx = trimmed.indexOf("=");
20
+ if (eqIdx === -1) continue;
21
+ const key = trimmed.slice(0, eqIdx).trim();
22
+ const val = trimmed.slice(eqIdx + 1).trim();
23
+ if (!process.env[key]) process.env[key] = val;
24
+ }
25
+ } catch {
26
+ // Env not found — will fail below with a clear error message.
27
+ }
28
+ }
29
+
30
  async function main() {
31
  const connectionString = process.env.DATABASE_URL;
32
  if (!connectionString) throw new Error("DATABASE_URL is not set");
packages/db/src/migrations/0002_legal_indexes.sql ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- Day 3: Legal Grounding indexes.
2
+ -- Idempotent extension setup (CREATE EXTENSION IF NOT EXISTS).
3
+ CREATE EXTENSION IF NOT EXISTS vector;
4
+ --> statement-breakpoint
5
+ CREATE EXTENSION IF NOT EXISTS pg_trgm;
6
+ --> statement-breakpoint
7
+
8
+ -- Generated tsvector column on legal_chunks for full-text search.
9
+ ALTER TABLE "legal_chunks" ADD COLUMN "text_tsv" tsvector GENERATED ALWAYS AS (to_tsvector('english', coalesce(text, ''))) STORED;
10
+ --> statement-breakpoint
11
+
12
+ -- GIN index on tsvector column for FTS.
13
+ CREATE INDEX "legal_chunks_tsv_idx" ON "legal_chunks" USING gin ("text_tsv");
14
+ --> statement-breakpoint
15
+
16
+ -- IVFFlat cosine index on embedding column for ANN vector search.
17
+ CREATE INDEX "legal_chunks_embedding_idx" ON "legal_chunks" USING ivfflat ("embedding" vector_cosine_ops) WITH (lists = 100);
packages/db/src/migrations/meta/_journal.json CHANGED
@@ -15,6 +15,13 @@
15
  "when": 1779905118666,
16
  "tag": "0001_motionless_skrulls",
17
  "breakpoints": true
 
 
 
 
 
 
 
18
  }
19
  ]
20
  }
 
15
  "when": 1779905118666,
16
  "tag": "0001_motionless_skrulls",
17
  "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "7",
22
+ "when": 1779906000000,
23
+ "tag": "0002_legal_indexes",
24
+ "breakpoints": true
25
  }
26
  ]
27
  }
packages/db/src/schema.ts CHANGED
@@ -12,7 +12,16 @@ import {
12
  vector,
13
  uniqueIndex,
14
  index,
 
15
  } from "drizzle-orm/pg-core";
 
 
 
 
 
 
 
 
16
 
17
  /**
18
  * Phase 1 schema (PHASE_1 §5, arch §9 + §30). Enums are stored as `text`; the
@@ -342,10 +351,22 @@ export const legalChunks = pgTable(
342
  pageStart: integer("page_start"),
343
  pageEnd: integer("page_end"),
344
  embedding: vector("embedding", { dimensions: EMBEDDING_DIM }),
 
 
 
 
 
 
 
 
345
  metadata: jsonb("metadata"),
346
  createdAt: createdAt(),
347
  },
348
- (t) => [index("legal_chunks_source_idx").on(t.sourceId)],
 
 
 
 
349
  );
350
 
351
  export const legalRetrievalLogs = pgTable("legal_retrieval_logs", {
 
12
  vector,
13
  uniqueIndex,
14
  index,
15
+ customType,
16
  } from "drizzle-orm/pg-core";
17
+ import { sql } from "drizzle-orm";
18
+
19
+ /** tsvector DB-only column type (Drizzle has no built-in; we use customType). */
20
+ const tsvector = customType<{ data: string }>({
21
+ dataType() {
22
+ return "tsvector";
23
+ },
24
+ });
25
 
26
  /**
27
  * Phase 1 schema (PHASE_1 §5, arch §9 + §30). Enums are stored as `text`; the
 
351
  pageStart: integer("page_start"),
352
  pageEnd: integer("page_end"),
353
  embedding: vector("embedding", { dimensions: EMBEDDING_DIM }),
354
+ /**
355
+ * DB-generated tsvector for FTS — populated via GENERATED ALWAYS AS
356
+ * (see migration 0002_legal_indexes.sql). Drizzle treats this as a
357
+ * read-only column; writes are handled by Postgres.
358
+ */
359
+ textTsv: tsvector("text_tsv").generatedAlwaysAs(
360
+ sql`to_tsvector('english', coalesce(text, ''))`,
361
+ ),
362
  metadata: jsonb("metadata"),
363
  createdAt: createdAt(),
364
  },
365
+ (t) => [
366
+ index("legal_chunks_source_idx").on(t.sourceId),
367
+ index("legal_chunks_tsv_idx").using("gin", t.textTsv),
368
+ index("legal_chunks_embedding_idx").using("ivfflat", t.embedding.op("vector_cosine_ops")),
369
+ ],
370
  );
371
 
372
  export const legalRetrievalLogs = pgTable("legal_retrieval_logs", {
packages/db/src/seeds/legal-data.ts ADDED
@@ -0,0 +1,548 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Curated Indian-law seed data for CourtMitra MissionOS Phase 1 legal grounding.
3
+ * Sources: Consumer Protection Act 2019, E-Commerce Rules 2020, RBI IOS 2021,
4
+ * RBI Customer Liability Circular 2017, NPCI UPI Guidelines,
5
+ * IT Act 2000, Bharatiya Nyaya Sanhita 2023, MHA Cybercrime.
6
+ *
7
+ * Text excerpts are verbatim short passages from publicly published Indian statutes
8
+ * (fair-use / public domain). Each chunk must be substantive (2–6 sentences).
9
+ */
10
+
11
+ export interface SourceSeedData {
12
+ sourceType: string;
13
+ title: string;
14
+ tier: string;
15
+ jurisdiction: string;
16
+ authority: string;
17
+ canonicalUrl: string;
18
+ versionLabel: string;
19
+ effectiveFrom: string; // ISO date string
20
+ metadata: Record<string, string>;
21
+ }
22
+
23
+ export interface ChunkSeedData {
24
+ chunkNo: number;
25
+ actName: string;
26
+ sectionNo: string;
27
+ heading: string;
28
+ text: string;
29
+ metadata: Record<string, string>;
30
+ }
31
+
32
+ export interface LegalSeedEntry {
33
+ source: SourceSeedData;
34
+ chunks: ChunkSeedData[];
35
+ }
36
+
37
+ // ============================================================================
38
+ // CONSUMER DOMAIN
39
+ // ============================================================================
40
+
41
+ const consumerProtectionAct2019: LegalSeedEntry = {
42
+ source: {
43
+ sourceType: "act",
44
+ title: "Consumer Protection Act, 2019",
45
+ tier: "tier1",
46
+ jurisdiction: "IN",
47
+ authority: "Department of Consumer Affairs",
48
+ canonicalUrl:
49
+ "https://consumeraffairs.nic.in/sites/default/files/CP-Act-2019.pdf",
50
+ versionLabel: "2019 (as enacted)",
51
+ effectiveFrom: "2019-07-20",
52
+ metadata: { issue_domain: "consumer" },
53
+ },
54
+ chunks: [
55
+ {
56
+ chunkNo: 1,
57
+ actName: "Consumer Protection Act, 2019",
58
+ sectionNo: "2(7)",
59
+ heading: "Definition of Consumer",
60
+ text: 'Section 2(7): "consumer" means any person who— (i) buys any goods for a consideration which has been paid or promised or partly paid and partly promised, or under any system of deferred payment and includes any user of such goods other than the person who buys such goods for consideration paid or promised or partly paid or partly promised, or under any system of deferred payment, when such use is made with the approval of such person, but does not include a person who obtains such goods for resale or for any commercial purpose; or (ii) hires or avails of any service for a consideration which has been paid or promised or partly paid and partly promised, or under any system of deferred payment and includes any beneficiary of such service other than the person who hires or avails of the service for consideration paid or promised, or partly paid and partly promised, or under any system of deferred payment, when such services are availed of with the approval of the first-mentioned person, but does not include a person who avails of such service for any commercial purpose. Explanation— For the purposes of this clause, "commercial purpose" does not include use by a person of goods bought and used by him and services availed by him exclusively for the purposes of earning his livelihood by means of self-employment.',
61
+ metadata: { issue_domain: "consumer" },
62
+ },
63
+ {
64
+ chunkNo: 2,
65
+ actName: "Consumer Protection Act, 2019",
66
+ sectionNo: "2(34)",
67
+ heading: "Definition of Service",
68
+ text: 'Section 2(34): "service" means service of any description which is made available to potential users and includes, but not limited to, the provision of facilities in connection with banking, financing, insurance, transport, processing, supply of electrical or other energy, telecom, boarding or lodging or both, housing construction, entertainment, amusement or the purveying of news or other information, but does not include the rendering of any service free of charge or under a contract of personal service.',
69
+ metadata: { issue_domain: "consumer" },
70
+ },
71
+ {
72
+ chunkNo: 3,
73
+ actName: "Consumer Protection Act, 2019",
74
+ sectionNo: "2(47)",
75
+ heading: "Definition of Unfair Trade Practice",
76
+ text: 'Section 2(47): "unfair trade practice" means a trade practice which, for the purpose of promoting the sale, use or supply of any goods or for the provision of any service, adopts any unfair method or unfair or deceptive practice including any of the following practices, namely:— (i) making any statement, whether orally or in writing or by visible representation including by means of electronic record, which— (a) falsely represents that the goods are of a particular standard, quality, quantity, grade, composition, style or model; (b) falsely represents that the services are of a particular standard, quality or grade. The practice of giving false or misleading facts disparaging the goods, services or trade of another person is also an unfair trade practice.',
77
+ metadata: { issue_domain: "consumer" },
78
+ },
79
+ {
80
+ chunkNo: 4,
81
+ actName: "Consumer Protection Act, 2019",
82
+ sectionNo: "17",
83
+ heading: "Establishment of Central Consumer Protection Authority (CCPA)",
84
+ text: "Section 17: The Central Government shall, by notification, establish with effect from such date as it may specify in that notification, a Central Consumer Protection Authority to be known as the Central Authority to regulate matters relating to violation of rights of consumers, unfair trade practices and false or misleading advertisements which are prejudicial to the interests of public and consumers and to promote, protect and enforce the rights of consumers as a class.",
85
+ metadata: { issue_domain: "consumer" },
86
+ },
87
+ {
88
+ chunkNo: 5,
89
+ actName: "Consumer Protection Act, 2019",
90
+ sectionNo: "18",
91
+ heading: "Functions of CCPA",
92
+ text: "Section 18: The Central Authority shall— (a) protect, promote and enforce the rights of consumers as a class, and prevent violation of consumers' rights under this Act; (b) prevent unfair trade practices and ensure that no person engages himself in unfair trade practices; (c) ensure that no false or misleading advertisement is made of any goods or services which contravenes the provisions of this Act or the rules or regulations made thereunder; (d) ensure that no person takes part in the publication of any advertisement which is false or misleading. The Central Authority shall, for the purpose of preventing, investigating and taking action against the violation of consumer rights, unfair trade practices and false or misleading advertisements, have the power to inquire or cause an inquiry or investigation to be made into such matters.",
93
+ metadata: { issue_domain: "consumer" },
94
+ },
95
+ {
96
+ chunkNo: 6,
97
+ actName: "Consumer Protection Act, 2019",
98
+ sectionNo: "19",
99
+ heading: "Complaint to CCPA",
100
+ text: "Section 19: Any person, consumer, or registered voluntary consumer association may file a complaint before the Central Authority in relation to violation of consumer rights, unfair trade practices, or false or misleading advertisements prejudicial to the interest of consumers. The Central Authority may, on receipt of such complaint, make or cause to be made a preliminary inquiry as to whether the complaint requires further investigation and may pass such orders as it deems fit, including directing the manufacturer, seller, service provider, or advertiser to discontinue the unfair trade practice or misleading advertisement.",
101
+ metadata: { issue_domain: "consumer" },
102
+ },
103
+ {
104
+ chunkNo: 7,
105
+ actName: "Consumer Protection Act, 2019",
106
+ sectionNo: "35",
107
+ heading: "Complaint to District Consumer Disputes Redressal Commission",
108
+ text: "Section 35: A complaint in relation to any goods sold or delivered or agreed to be sold or delivered or any service provided or agreed to be provided may be filed with a District Commission by— (a) the consumer to whom such goods are sold or delivered or agreed to be sold or delivered or such service is provided or agreed to be provided; (b) any recognised consumer association, whether the consumer to whom the goods are sold or delivered or agreed to be sold or delivered or service is provided or agreed to be provided is a member of such association or not; (c) one or more consumers, where there are numerous consumers having the same interest, with the permission of the District Commission; (d) the Central Authority or the State Authority, as the case may be.",
109
+ metadata: { issue_domain: "consumer" },
110
+ },
111
+ {
112
+ chunkNo: 8,
113
+ actName: "Consumer Protection Act, 2019",
114
+ sectionNo: "38",
115
+ heading: "Procedure on Admission of Complaint",
116
+ text: "Section 38: The District Commission shall, on admission of a complaint, if it relates to goods in respect of which the procedure specified in section 38(2) is applicable, refer a copy of the admitted complaint, within twenty-one days from the date of its admission to the opposite party, directing him to give his version of the case within a period of thirty days or such extended period not exceeding fifteen days as may be granted by the District Commission. If the opposite party, on receipt of a copy of the complaint, denies or disputes the allegations contained in the complaint, or omits or fails to take any action to represent his case within the time given, the District Commission shall proceed to settle the consumer dispute.",
117
+ metadata: { issue_domain: "consumer" },
118
+ },
119
+ {
120
+ chunkNo: 9,
121
+ actName: "Consumer Protection Act, 2019",
122
+ sectionNo: "39",
123
+ heading: "Findings of District Commission",
124
+ text: "Section 39(1): If, after the proceeding conducted under section 38, the District Commission is satisfied that the goods complained against suffer from any of the defects specified in the complaint or that any of the allegations contained in the complaint about the services or any unfair trade practices or restrictive trade practices are proved, it shall issue an order to the opposite party directing him to do one or more of the following things, namely:— (a) to remove the defect pointed out by the appropriate laboratory from the goods in question; (b) to replace the goods with new goods of similar description which shall be free from any defect; (c) to return to the complainant the price, or, as the case may be, the charges paid by the complainant along with such interest on such price or charges as may be decided; (d) to pay such amount as may be awarded by it as compensation to the consumer for any loss or injury suffered by the consumer due to the negligence of the opposite party.",
125
+ metadata: { issue_domain: "consumer" },
126
+ },
127
+ {
128
+ chunkNo: 10,
129
+ actName: "Consumer Protection Act, 2019",
130
+ sectionNo: "47",
131
+ heading: "Jurisdiction of District Commission",
132
+ text: "Section 47(1): The District Commission shall have jurisdiction to entertain complaints where the value of the goods or services paid as consideration does not exceed one crore rupees. Provided that where the Central Government deems it necessary so to do, it may, by notification, enhance or reduce the limit of one crore rupees after giving wide publicity thereof and considering the interests of consumers.",
133
+ metadata: { issue_domain: "consumer" },
134
+ },
135
+ {
136
+ chunkNo: 11,
137
+ actName: "Consumer Protection Act, 2019",
138
+ sectionNo: "58",
139
+ heading: "Jurisdiction of State Commission",
140
+ text: "Section 58(1): The State Commission shall have jurisdiction— (a) to entertain complaints where the value of the goods or services paid as consideration exceeds one crore rupees but does not exceed ten crore rupees; (b) to entertain appeals against the orders of any District Commission within the State; (c) to call for the records and pass appropriate orders in any consumer dispute which is pending before or has been decided by any District Commission within the State in cases where it appears to the State Commission that such District Commission has exercised a jurisdiction not vested in it by law, or has failed to exercise a jurisdiction so vested, or has acted in the exercise of its jurisdiction illegally or with material irregularity.",
141
+ metadata: { issue_domain: "consumer" },
142
+ },
143
+ {
144
+ chunkNo: 12,
145
+ actName: "Consumer Protection Act, 2019",
146
+ sectionNo: "67",
147
+ heading: "Jurisdiction of National Commission",
148
+ text: "Section 67: The National Commission shall have jurisdiction— (a) to entertain complaints where the value of the goods or services paid as consideration exceeds ten crore rupees; (b) to entertain appeals against the orders of any State Commission; (c) to call for the records and pass appropriate orders in any consumer dispute which is pending before or has been decided by any State Commission where it appears to the National Commission that such State Commission has exercised a jurisdiction not vested in it by law, or has failed to exercise a jurisdiction so vested, or has acted in the exercise of its jurisdiction illegally or with material irregularity.",
149
+ metadata: { issue_domain: "consumer" },
150
+ },
151
+ {
152
+ chunkNo: 13,
153
+ actName: "Consumer Protection Act, 2019",
154
+ sectionNo: "69",
155
+ heading: "Limitation Period for Filing Complaint",
156
+ text: "Section 69(1): The District Commission, the State Commission or the National Commission shall not admit a complaint unless it is filed within two years from the date on which the cause of action has arisen. Section 69(2): Notwithstanding anything contained in sub-section (1), a complaint may be entertained after the period specified in sub-section (1), if the complainant satisfies the District Commission, the State Commission or the National Commission, as the case may be, that he had sufficient cause for not filing the complaint within such period. Provided that no such complaint shall be entertained unless the District Commission, or the State Commission or the National Commission, as the case may be, records its reasons for condoning such delay.",
157
+ metadata: { issue_domain: "consumer" },
158
+ },
159
+ ],
160
+ };
161
+
162
+ const eCommerceRules2020: LegalSeedEntry = {
163
+ source: {
164
+ sourceType: "rule",
165
+ title: "Consumer Protection (E-Commerce) Rules, 2020",
166
+ tier: "tier1",
167
+ jurisdiction: "IN",
168
+ authority: "Department of Consumer Affairs",
169
+ canonicalUrl:
170
+ "https://consumeraffairs.nic.in/sites/default/files/E-commerce%20%28Amendment%29%20Rules%2C%202021.pdf",
171
+ versionLabel: "2020 (amended 2021)",
172
+ effectiveFrom: "2020-07-23",
173
+ metadata: { issue_domain: "consumer" },
174
+ },
175
+ chunks: [
176
+ {
177
+ chunkNo: 1,
178
+ actName: "Consumer Protection (E-Commerce) Rules, 2020",
179
+ sectionNo: "4",
180
+ heading: "Duties of E-Commerce Entities",
181
+ text: "Rule 4(1): Every e-commerce entity shall— (a) display clearly on its platform the name, contact numbers, and designated grievance officer details; (b) acknowledge the receipt of any consumer complaint within forty-eight hours and redress the complaint within one month from the date of receipt of the complaint; (c) not adopt any unfair trade practice whether in the course of business on its platform or otherwise; (d) not falsely represent itself as a consumer and post reviews about its goods and services or misrepresent the quality or features of any goods or services.",
182
+ metadata: { issue_domain: "consumer" },
183
+ },
184
+ {
185
+ chunkNo: 2,
186
+ actName: "Consumer Protection (E-Commerce) Rules, 2020",
187
+ sectionNo: "4(5)",
188
+ heading: "Grievance Officer Appointment Requirement",
189
+ text: "Rule 4(5): Every e-commerce entity shall establish an adequate grievance redressal mechanism having regard to the number of grievances ordinarily received by such entity from India, and shall appoint a grievance officer for consumer grievance redressal, and shall display the name of such officer and his contact details including email address on its platform, and such officer shall acknowledge the receipt of any consumer complaint within forty-eight hours and redress the complaint within one month from the date of receipt of the complaint.",
190
+ metadata: { issue_domain: "consumer" },
191
+ },
192
+ {
193
+ chunkNo: 3,
194
+ actName: "Consumer Protection (E-Commerce) Rules, 2020",
195
+ sectionNo: "5",
196
+ heading: "Duties of Marketplace E-Commerce Entities",
197
+ text: "Rule 5(1): Every marketplace e-commerce entity shall— (a) require sellers through an undertaking to ensure that descriptions, images, and other content pertaining to goods or services on their platform is accurate and corresponds directly with the appearance, nature, quality, purpose and other general features of such goods or services; (b) provide a description of any seller on its platform who may be in a position to supply goods or provide services to consumers; (c) provide information relating to return, refund, exchange, warranty and guarantee, delivery and shipment, cost and method of payment, grievance redressal mechanism, and any other similar information which may be required by consumers; (d) provide sellers the facility to use a grievance redressal mechanism.",
198
+ metadata: { issue_domain: "consumer" },
199
+ },
200
+ {
201
+ chunkNo: 4,
202
+ actName: "Consumer Protection (E-Commerce) Rules, 2020",
203
+ sectionNo: "6",
204
+ heading: "Duties of Inventory E-Commerce Entities",
205
+ text: "Rule 6(1): Every inventory e-commerce entity shall— (a) ensure that the advertisements for marketing of goods or services are consistent with the actual characteristics, access and usage conditions of such goods or services; (b) ensure that all information provided by it is accurate, true and sufficient to enable the consumer to make an informed decision at the pre-purchase stage; (c) ensure that the goods or services being offered on the platform are not prohibited for sale and do not infringe any intellectual property rights; (d) ensure the authenticity of the goods sold on its platform and confirm that goods purchased are delivered within the stipulated delivery period indicated at the time of purchase.",
206
+ metadata: { issue_domain: "consumer" },
207
+ },
208
+ ],
209
+ };
210
+
211
+ const nationalConsumerHelpline: LegalSeedEntry = {
212
+ source: {
213
+ sourceType: "govt_page",
214
+ title: "National Consumer Helpline – Grievance Filing Procedure",
215
+ tier: "tier2",
216
+ jurisdiction: "IN",
217
+ authority: "National Consumer Helpline",
218
+ canonicalUrl: "https://consumerhelpline.gov.in",
219
+ versionLabel: "current",
220
+ effectiveFrom: "2015-01-01",
221
+ metadata: { issue_domain: "consumer" },
222
+ },
223
+ chunks: [
224
+ {
225
+ chunkNo: 1,
226
+ actName: "National Consumer Helpline",
227
+ sectionNo: "procedure",
228
+ heading: "How to File a Grievance via National Consumer Helpline",
229
+ text: "Consumers can register complaints through the National Consumer Helpline (NCH) by calling the toll-free number 1915 (available in 17 languages, Monday–Saturday 8 AM–8 PM), or by visiting consumerhelpline.gov.in, or through the INGRAM portal (Integrated Grievance Redressal Mechanism). Complaints can also be submitted via the NCH mobile app available on Android and iOS platforms. The NCH mediates between consumers and companies and escalates unresolved complaints to the Consumer Commission or the appropriate regulator.",
230
+ metadata: { issue_domain: "consumer" },
231
+ },
232
+ {
233
+ chunkNo: 2,
234
+ actName: "National Consumer Helpline",
235
+ sectionNo: "ingram",
236
+ heading: "INGRAM Portal – Integrated Grievance Redressal Mechanism",
237
+ text: "The INGRAM (Integrated Grievance Redressal Mechanism) portal at consumerhelpline.gov.in allows consumers to file complaints online, track complaint status, and receive resolution from companies. Complaints are forwarded to the concerned company through the portal; companies registered on INGRAM are expected to respond within 30 days. Unresolved complaints on INGRAM can be escalated to the District Consumer Disputes Redressal Commission under the Consumer Protection Act, 2019.",
238
+ metadata: { issue_domain: "consumer" },
239
+ },
240
+ ],
241
+ };
242
+
243
+ // ============================================================================
244
+ // PAYMENT DOMAIN
245
+ // ============================================================================
246
+
247
+ const rbiIntegratedOmbudsmanScheme2021: LegalSeedEntry = {
248
+ source: {
249
+ sourceType: "regulator_doc",
250
+ title: "Reserve Bank – Integrated Ombudsman Scheme, 2021",
251
+ tier: "tier1",
252
+ jurisdiction: "IN",
253
+ authority: "Reserve Bank of India",
254
+ canonicalUrl:
255
+ "https://rbidocs.rbi.org.in/rdocs/content/pdfs/RBIOS2021_amendedWebsite.pdf",
256
+ versionLabel: "2021 (as amended)",
257
+ effectiveFrom: "2021-11-12",
258
+ metadata: { issue_domain: "payment" },
259
+ },
260
+ chunks: [
261
+ {
262
+ chunkNo: 1,
263
+ actName: "Reserve Bank – Integrated Ombudsman Scheme, 2021",
264
+ sectionNo: "definitions",
265
+ heading: "Key Definitions",
266
+ text: "Under the RBI Integrated Ombudsman Scheme 2021: 'complaint' means a representation in writing or through electronic means containing a grievance, alleging deficiency in service on the part of a Regulated Entity; 'deficiency in service' means a failure to comply with applicable laws, regulations, and internal policies of a Regulated Entity; 'Regulated Entity' includes commercial banks, primary (urban) co-operative banks, non-banking financial companies, and payment system participants as notified by the Reserve Bank from time to time. The scheme provides a cost-free and expeditious alternate grievance redressal mechanism to customers of regulated entities.",
267
+ metadata: { issue_domain: "payment" },
268
+ },
269
+ {
270
+ chunkNo: 2,
271
+ actName: "Reserve Bank – Integrated Ombudsman Scheme, 2021",
272
+ sectionNo: "clause-8",
273
+ heading: "Grounds for Filing Complaint Before Ombudsman",
274
+ text: "Clause 8 of the RBI IOS 2021: A complaint may be filed before the Ombudsman on any one or more of the following grounds of deficiency in service, namely: non-payment or inordinate delay in the payment or collection of cheques, drafts, bills, etc.; non-acceptance, without sufficient cause, of small denomination notes tendered for any purpose; failure to issue or delay in issuance of a draft, pay order or banker's cheque; non-adherence to prescribed working hours; failure to honour guarantee or letter of credit commitments; claims in respect of unauthorized electronic fund transfers, failure to act in accordance with a customer's mandate without justification; non-adherence to the provisions of the fair practices code as adopted by the bank.",
275
+ metadata: { issue_domain: "payment" },
276
+ },
277
+ {
278
+ chunkNo: 3,
279
+ actName: "Reserve Bank – Integrated Ombudsman Scheme, 2021",
280
+ sectionNo: "clause-10",
281
+ heading: "Procedure for Filing Complaint – CMS Portal",
282
+ text: "Clause 10 of the RBI IOS 2021: A complainant may file a complaint through the Complaint Management System portal at cms.rbi.org.in, or at the Centralised Receipt and Processing Centre, or at the offices of the Ombudsman. Before filing a complaint with the Ombudsman, the complainant must first lodge a complaint with the concerned Regulated Entity and either receive a response which is not satisfactory, or not receive a response within 30 days of lodging the complaint. The complaint must be filed within one year from the date of the reply of the regulated entity or, if no reply, within one year and 30 days from the date of lodging the grievance with the regulated entity.",
283
+ metadata: { issue_domain: "payment" },
284
+ },
285
+ {
286
+ chunkNo: 4,
287
+ actName: "Reserve Bank – Integrated Ombudsman Scheme, 2021",
288
+ sectionNo: "clause-10-pre-filing",
289
+ heading: "30-Day Pre-Filing Requirement",
290
+ text: "The RBI Integrated Ombudsman Scheme 2021 mandates that before approaching the Ombudsman at cms.rbi.org.in, the complainant must: (1) lodge a complaint with the Regulated Entity (bank, NBFC, payment system participant); (2) wait 30 days for a resolution; and (3) only approach the Ombudsman if the reply is unsatisfactory or no reply is received within 30 days. This pre-filing requirement ensures the institution has adequate opportunity to resolve the dispute internally before regulatory intervention.",
291
+ metadata: { issue_domain: "payment" },
292
+ },
293
+ {
294
+ chunkNo: 5,
295
+ actName: "Reserve Bank – Integrated Ombudsman Scheme, 2021",
296
+ sectionNo: "award",
297
+ heading: "Ombudsman Award",
298
+ text: "The RBI Integrated Ombudsman Scheme 2021 provides that the Ombudsman may, after affording reasonable opportunity to the parties, pass an Award. The Award may direct the regulated entity to pay the complainant an amount found deficient and/or compensation for the loss suffered by the complainant, which shall not exceed Rupees Twenty Lakh (₹20,00,000) in aggregate per complaint. If a bank fails to implement the Award within the prescribed period, the RBI may take action under the applicable banking statutes.",
299
+ metadata: { issue_domain: "payment" },
300
+ },
301
+ ],
302
+ };
303
+
304
+ const rbiCustomerLiabilityCircular2017: LegalSeedEntry = {
305
+ source: {
306
+ sourceType: "regulator_doc",
307
+ title:
308
+ "RBI Customer Liability in Unauthorised Electronic Banking Transactions Circular 2017",
309
+ tier: "tier1",
310
+ jurisdiction: "IN",
311
+ authority: "Reserve Bank of India",
312
+ canonicalUrl:
313
+ "https://www.rbi.org.in/Scripts/NotificationUser.aspx?Id=11040",
314
+ versionLabel: "DBR.No.Leg.BC.78/09.07.005/2017-18",
315
+ effectiveFrom: "2017-07-06",
316
+ metadata: { issue_domain: "payment" },
317
+ },
318
+ chunks: [
319
+ {
320
+ chunkNo: 1,
321
+ actName:
322
+ "RBI Customer Liability in Unauthorised Electronic Banking Transactions Circular 2017",
323
+ sectionNo: "zero-liability",
324
+ heading: "Zero Liability for Unauthorised Transactions Reported Promptly",
325
+ text: "RBI Circular DBR.No.Leg.BC.78/09.07.005/2017-18: A customer shall have zero liability where the unauthorised transaction occurs in cases where the deficiency is on part of the bank (irrespective of whether or not the transaction is reported by the customer). In cases where the responsibility for the unauthorised electronic banking transaction lies neither with the bank nor with the customer, but lies elsewhere in the system, and the customer notifies the bank within three working days of receiving the communication from the bank regarding the unauthorised transaction, the customer shall have zero liability.",
326
+ metadata: { issue_domain: "payment" },
327
+ },
328
+ {
329
+ chunkNo: 2,
330
+ actName:
331
+ "RBI Customer Liability in Unauthorised Electronic Banking Transactions Circular 2017",
332
+ sectionNo: "limited-liability",
333
+ heading: "Limited Liability for Delayed Reporting",
334
+ text: "RBI Circular DBR.No.Leg.BC.78/09.07.005/2017-18: If the delay in reporting is between 4 and 7 working days after receiving communication from the bank, the customer's maximum liability shall be the transaction value or the amount specified in the table (ranging from ₹5,000 to ₹25,000 depending on the type of account), whichever is lower. If the delay in reporting the unauthorised transaction is beyond 7 working days, the customer's liability shall be determined as per the bank's Board approved policy. Banks must credit (shadow reversal) the amount involved in the unauthorised electronic transaction to the customer's account within 10 working days from the date of notification by the customer.",
335
+ metadata: { issue_domain: "payment" },
336
+ },
337
+ {
338
+ chunkNo: 3,
339
+ actName:
340
+ "RBI Customer Liability in Unauthorised Electronic Banking Transactions Circular 2017",
341
+ sectionNo: "customer-negligence",
342
+ heading: "Full Liability Where Customer is Negligent",
343
+ text: "RBI Circular DBR.No.Leg.BC.78/09.07.005/2017-18: In cases where the loss is due to negligence by a customer, such as where he has shared the payment credentials, the customer will bear the entire loss until he reports the unauthorised transaction to the bank. Any loss occurring after the reporting of the unauthorised transaction shall be borne by the bank. Banks shall immediately on receipt of the report from the customer, take all steps to prevent further unauthorised transactions in the account.",
344
+ metadata: { issue_domain: "payment" },
345
+ },
346
+ ],
347
+ };
348
+
349
+ const npciUpiGuidelines: LegalSeedEntry = {
350
+ source: {
351
+ sourceType: "regulator_doc",
352
+ title: "NPCI UPI Procedural Guidelines",
353
+ tier: "tier2",
354
+ jurisdiction: "IN",
355
+ authority: "National Payments Corporation of India (NPCI)",
356
+ canonicalUrl:
357
+ "https://www.npci.org.in/PDF/npci/upi/circular/2019/UPI-Procedural-Guidelines-Ver-1.6.pdf",
358
+ versionLabel: "Version 1.6",
359
+ effectiveFrom: "2019-01-01",
360
+ metadata: { issue_domain: "payment" },
361
+ },
362
+ chunks: [
363
+ {
364
+ chunkNo: 1,
365
+ actName: "NPCI UPI Procedural Guidelines",
366
+ sectionNo: "dispute-tat",
367
+ heading: "UPI Dispute Resolution Turnaround Time",
368
+ text: "NPCI UPI Procedural Guidelines v1.6: For failed UPI transactions where the customer's account has been debited but credit has not been received, the acquiring bank / PSP must resolve the dispute within T+1 working day (where T is the date of transaction or complaint). If the transaction cannot be reversed within T+1, the PSP must proactively communicate the status to the customer and ensure reversal within 5 working days. Persistent failure to reverse within the stipulated time may be escalated to NPCI or the concerned bank ombudsman.",
369
+ metadata: { issue_domain: "payment" },
370
+ },
371
+ {
372
+ chunkNo: 2,
373
+ actName: "NPCI UPI Procedural Guidelines",
374
+ sectionNo: "chargeback",
375
+ heading: "Chargeback Process via PSP",
376
+ text: "NPCI UPI Procedural Guidelines v1.6: A UPI chargeback (dispute) must be raised by the customer's PSP (Payment Service Provider) bank on behalf of the customer through the UPI dispute management system. Chargebacks can be raised in cases of: (i) goods/services not received; (ii) duplicate transaction; (iii) fraud or unauthorized transaction. The remitter's PSP has responsibility to initiate a chargeback dispute within 120 days of the original transaction date and must submit documentary evidence to the beneficiary PSP for resolution.",
377
+ metadata: { issue_domain: "payment" },
378
+ },
379
+ ],
380
+ };
381
+
382
+ // ============================================================================
383
+ // CYBER FRAUD DOMAIN
384
+ // ============================================================================
385
+
386
+ const itAct2000: LegalSeedEntry = {
387
+ source: {
388
+ sourceType: "act",
389
+ title: "Information Technology Act, 2000",
390
+ tier: "tier1",
391
+ jurisdiction: "IN",
392
+ authority: "Ministry of Electronics and Information Technology",
393
+ canonicalUrl:
394
+ "https://www.indiacode.nic.in/bitstream/123456789/13116/1/it_act_2000_updated.pdf",
395
+ versionLabel: "2000 (as amended 2008)",
396
+ effectiveFrom: "2000-10-17",
397
+ metadata: { issue_domain: "cyber_fraud" },
398
+ },
399
+ chunks: [
400
+ {
401
+ chunkNo: 1,
402
+ actName: "Information Technology Act, 2000",
403
+ sectionNo: "43",
404
+ heading: "Penalty for Damage to Computer, Computer System, etc.",
405
+ text: "Section 43 of the Information Technology Act, 2000: If any person without permission of the owner or any other person who is incharge of a computer, computer system or computer network— (a) accesses or secures access to such computer, computer system or computer network; (b) downloads, copies or extracts any data, computer data base or information from such computer, computer system or computer network; (c) introduces or causes to be introduced any computer contaminant or computer virus into any computer, computer system or computer network; (d) damages or causes to be damaged any computer, computer system or computer network, data, computer data base or any other programmes residing in such computer, computer system or computer network— he shall be liable to pay damages by way of compensation not exceeding one crore rupees to the person so affected.",
406
+ metadata: { issue_domain: "cyber_fraud" },
407
+ },
408
+ {
409
+ chunkNo: 2,
410
+ actName: "Information Technology Act, 2000",
411
+ sectionNo: "43A",
412
+ heading: "Compensation for Failure to Protect Data",
413
+ text: "Section 43A of the Information Technology Act, 2000: Where a body corporate, possessing, dealing or handling any sensitive personal data or information in a computer resource which it owns, controls or operates, is negligent in implementing and maintaining reasonable security practices and procedures and thereby causes wrongful loss or wrongful gain to any person, such body corporate shall be liable to pay damages by way of compensation to the person so affected. The Central Government shall prescribe the reasonable security practices and procedures and the type of sensitive personal data or information for the purposes of this section.",
414
+ metadata: { issue_domain: "cyber_fraud" },
415
+ },
416
+ {
417
+ chunkNo: 3,
418
+ actName: "Information Technology Act, 2000",
419
+ sectionNo: "66",
420
+ heading: "Computer-Related Offences",
421
+ text: "Section 66 of the Information Technology Act, 2000: If any person, dishonestly or fraudulently, does any act referred to in section 43, he shall be punishable with imprisonment for a term which may extend to three years or with fine which may extend to five lakh rupees or with both. The provisions of this section apply to all forms of computer-related offences including hacking, unauthorised access, data theft, introduction of malware, and disruption of computer systems or networks.",
422
+ metadata: { issue_domain: "cyber_fraud" },
423
+ },
424
+ {
425
+ chunkNo: 4,
426
+ actName: "Information Technology Act, 2000",
427
+ sectionNo: "66C",
428
+ heading: "Punishment for Identity Theft",
429
+ text: "Section 66C of the Information Technology Act, 2000: Whoever, fraudulently or dishonestly make use of the electronic signature, password or any other unique identification feature of any other person, shall be punished with imprisonment of either description for a term which may extend to three years and shall also be liable to fine which may extend to rupees one lakh. Identity theft under this section includes misuse of OTPs, digital credentials, SIM-swap fraud, and misuse of Aadhaar or PAN details to impersonate a person.",
430
+ metadata: { issue_domain: "cyber_fraud" },
431
+ },
432
+ {
433
+ chunkNo: 5,
434
+ actName: "Information Technology Act, 2000",
435
+ sectionNo: "66D",
436
+ heading: "Punishment for Cheating by Personation Using Computer Resource",
437
+ text: "Section 66D of the Information Technology Act, 2000: Whoever, by means of any communication device or computer resource cheats by personation, shall be punished with imprisonment of either description for a term which may extend to three years and shall also be liable to fine which may extend to one lakh rupees. This section specifically covers online impersonation, fake customer care fraud, phishing, vishing, smishing, and impersonation of banking officials or government officers through electronic means.",
438
+ metadata: { issue_domain: "cyber_fraud" },
439
+ },
440
+ {
441
+ chunkNo: 6,
442
+ actName: "Information Technology Act, 2000",
443
+ sectionNo: "72A",
444
+ heading: "Punishment for Disclosure of Information in Breach of Lawful Contract",
445
+ text: "Section 72A of the Information Technology Act, 2000: Save as otherwise provided in this Act or any other law for the time being in force, any person including an intermediary who, while providing services under the terms of lawful contract, has secured access to any material containing personal information about another person, with the intent to cause or knowing that he is likely to cause wrongful loss or wrongful gain discloses, without the consent of the person concerned, or in breach of a lawful contract, such material to any other person, shall be punished with imprisonment for a term which may extend to three years, or with a fine which may extend to five lakh rupees, or with both.",
446
+ metadata: { issue_domain: "cyber_fraud" },
447
+ },
448
+ ],
449
+ };
450
+
451
+ const bharatiyaNyayaSanhita2023: LegalSeedEntry = {
452
+ source: {
453
+ sourceType: "act",
454
+ title: "Bharatiya Nyaya Sanhita, 2023",
455
+ tier: "tier1",
456
+ jurisdiction: "IN",
457
+ authority: "Ministry of Home Affairs",
458
+ canonicalUrl: "https://www.indiacode.nic.in/handle/123456789/20062",
459
+ versionLabel: "2023 (effective July 1, 2024)",
460
+ effectiveFrom: "2024-07-01",
461
+ metadata: { issue_domain: "cyber_fraud" },
462
+ },
463
+ chunks: [
464
+ {
465
+ chunkNo: 1,
466
+ actName: "Bharatiya Nyaya Sanhita, 2023",
467
+ sectionNo: "318",
468
+ heading: "Cheating",
469
+ text: "Section 318 of the Bharatiya Nyaya Sanhita, 2023: Whoever, by deceiving any person, fraudulently or dishonestly induces the person so deceived to deliver any property to any person, or to consent that any person shall retain any property, or intentionally induces the person so deceived to do or omit to do anything which he would not do or omit if he were not so deceived, and which act or omission causes or is likely to cause damage or harm to that person in body, mind, reputation or property, is said to 'cheat'. A person who cheats shall be punished with imprisonment of either description for a term which may extend to three years, or with fine, or with both; and cheating with knowledge that wrongful loss will be caused to a person whose interest the offender is bound to protect, shall be punished with imprisonment of either description for a term which may extend to five years, or with fine, or with both.",
470
+ metadata: { issue_domain: "cyber_fraud" },
471
+ },
472
+ {
473
+ chunkNo: 2,
474
+ actName: "Bharatiya Nyaya Sanhita, 2023",
475
+ sectionNo: "319",
476
+ heading: "Cheating by Personation",
477
+ text: "Section 319 of the Bharatiya Nyaya Sanhita, 2023: A person is said to 'cheat by personation' if he cheats by pretending to be some other person, or by knowingly substituting one person for another, or representing that he or any other person is a person other than he or such other person really is. The offence is committed whether the individual personated is a real or imaginary person. Whoever cheats by personation shall be punished with imprisonment of either description for a term which may extend to five years, or with fine, or with both. This section applies to online impersonation, fake identity creation on social media, and fraudulent impersonation of bank officials, government officers, or family members.",
478
+ metadata: { issue_domain: "cyber_fraud" },
479
+ },
480
+ {
481
+ chunkNo: 3,
482
+ actName: "Bharatiya Nyaya Sanhita, 2023",
483
+ sectionNo: "316",
484
+ heading: "Criminal Breach of Trust",
485
+ text: "Section 316 of the Bharatiya Nyaya Sanhita, 2023: Whoever, being in any manner entrusted with property, or with any dominion over property, dishonestly misappropriates or converts to his own use that property, or dishonestly uses or disposes of that property in violation of any direction of law prescribing the mode in which such trust is to be discharged, or of any legal contract, express or implied, which he has made touching the discharge of such trust, or wilfully suffers any other person so to do, commits 'criminal breach of trust'. Whoever commits criminal breach of trust shall be punished with imprisonment of either description for a term which may extend to seven years, and shall also be liable to fine. Criminal breach of trust in a position of professional trust (e.g., by a financial advisor, bank employee, or insurer) carries enhanced punishment.",
486
+ metadata: { issue_domain: "cyber_fraud" },
487
+ },
488
+ ],
489
+ };
490
+
491
+ const mhaCybercrimeReporting: LegalSeedEntry = {
492
+ source: {
493
+ sourceType: "govt_page",
494
+ title: "MHA Cybercrime Reporting – 1930 Helpline and National Cybercrime Reporting Portal",
495
+ tier: "tier2",
496
+ jurisdiction: "IN",
497
+ authority: "Ministry of Home Affairs (I4C)",
498
+ canonicalUrl: "https://cybercrime.gov.in/",
499
+ versionLabel: "current",
500
+ effectiveFrom: "2021-01-01",
501
+ metadata: { issue_domain: "cyber_fraud" },
502
+ },
503
+ chunks: [
504
+ {
505
+ chunkNo: 1,
506
+ actName: "MHA Cybercrime Reporting",
507
+ sectionNo: "1930-helpline",
508
+ heading: "Golden-Hour Reporting via 1930 Cyber Helpline",
509
+ text: "The Ministry of Home Affairs (I4C) operates the National Cybercrime Helpline 1930 for immediate reporting of financial cyber fraud. Reporting within the 'golden hour' (as soon as possible after the fraud) maximises the chances of the fraudulently transferred money being frozen and recovered. Upon calling 1930, the operator logs the complaint and triggers a bank-freeze request through the Citizen Financial Cyber Fraud Reporting and Management System (CFCFRMS) to block the onward transfer of funds. Victims should keep the transaction reference number, UPI ID, bank account number, and fraudster's mobile number handy while calling.",
510
+ metadata: { issue_domain: "cyber_fraud" },
511
+ },
512
+ {
513
+ chunkNo: 2,
514
+ actName: "MHA Cybercrime Reporting",
515
+ sectionNo: "cybercrime-gov-in",
516
+ heading: "National Cybercrime Reporting Portal Procedure",
517
+ text: "The National Cybercrime Reporting Portal at cybercrime.gov.in allows citizens to report cybercrime complaints online in two categories: financial fraud (handled on priority through CFCFRMS) and other cybercrimes (cyberbullying, sextortion, hacking, etc.). After online filing, the complaint is forwarded to the jurisdictional police station and an acknowledgement is generated. Complainants should also file a First Information Report (FIR) at the nearest police station under the applicable sections of the Information Technology Act, 2000 and the Bharatiya Nyaya Sanhita, 2023 for formal legal proceedings.",
518
+ metadata: { issue_domain: "cyber_fraud" },
519
+ },
520
+ {
521
+ chunkNo: 3,
522
+ actName: "MHA Cybercrime Reporting",
523
+ sectionNo: "bank-freeze",
524
+ heading: "Bank Freeze Flow via CFCFRMS",
525
+ text: "On receipt of a financial fraud complaint on 1930 or cybercrime.gov.in, the CFCFRMS system automatically alerts the banks holding the fraudster's accounts to put a lien/freeze on the funds pending investigation. Banks participating in CFCFRMS include all scheduled commercial banks, payment banks, and major wallets. The freeze request is initiated by the I4C nodal officer and the concerned bank must acknowledge and act within 30 minutes during banking hours. Victims are advised to simultaneously file a complaint with their own bank's fraud desk and invoke the RBI Customer Liability Circular 2017 for zero-liability protection on promptly reported unauthorised transactions.",
526
+ metadata: { issue_domain: "cyber_fraud" },
527
+ },
528
+ ],
529
+ };
530
+
531
+ // ============================================================================
532
+ // Export
533
+ // ============================================================================
534
+
535
+ export const ALL_LEGAL_SEED_ENTRIES: LegalSeedEntry[] = [
536
+ // Consumer domain
537
+ consumerProtectionAct2019,
538
+ eCommerceRules2020,
539
+ nationalConsumerHelpline,
540
+ // Payment domain
541
+ rbiIntegratedOmbudsmanScheme2021,
542
+ rbiCustomerLiabilityCircular2017,
543
+ npciUpiGuidelines,
544
+ // Cyber fraud domain
545
+ itAct2000,
546
+ bharatiyaNyayaSanhita2023,
547
+ mhaCybercrimeReporting,
548
+ ];
packages/db/src/seeds/route-rules-data.ts ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Curated route rules seed data for CourtMitra MissionOS Phase 1.
3
+ * One route_rules row per issue domain: consumer, payment, cyber_fraud.
4
+ *
5
+ * sourceCitationsBySectionRef: array of { actName, sectionNo } tuples.
6
+ * The seed runner resolves these to legal_chunk ids at insert time.
7
+ */
8
+
9
+ export interface SourceCitationRef {
10
+ actName: string;
11
+ sectionNo: string;
12
+ }
13
+
14
+ export interface RouteRuleSeedData {
15
+ issueDomain: string;
16
+ jurisdictionFilter: Record<string, unknown>;
17
+ triggerConditions: Record<string, unknown>;
18
+ priority: number;
19
+ routeGraph: {
20
+ nodes: Array<{
21
+ id: string;
22
+ level: string;
23
+ label: string;
24
+ description: string;
25
+ actionType: string;
26
+ target: Record<string, unknown>;
27
+ expectedResponseDays: number;
28
+ }>;
29
+ edges: Array<{
30
+ from: string;
31
+ to: string;
32
+ condition: string;
33
+ }>;
34
+ };
35
+ requiredEvidence: string[];
36
+ deadlines: Record<string, unknown>;
37
+ sourceCitationsBySectionRef: SourceCitationRef[];
38
+ socialTemplate: null;
39
+ stopAndRefer: null | {
40
+ conditions: string[];
41
+ helpline: string;
42
+ message: string;
43
+ };
44
+ active: boolean;
45
+ }
46
+
47
+ // ============================================================================
48
+ // CONSUMER domain route rule
49
+ // ============================================================================
50
+
51
+ export const consumerRouteRule: RouteRuleSeedData = {
52
+ issueDomain: "consumer",
53
+ jurisdictionFilter: { country: "IN" },
54
+ triggerConditions: {
55
+ transaction_amount_min: 0,
56
+ issue_types: ["product_defect", "service_deficiency", "unfair_trade", "non_delivery", "refund_not_received"],
57
+ },
58
+ priority: 100,
59
+ routeGraph: {
60
+ nodes: [
61
+ {
62
+ id: "L0",
63
+ level: "L0",
64
+ label: "Grievance to Seller / Service Provider",
65
+ description:
66
+ "Send a formal written complaint (email/registered post) to the seller or e-commerce entity's grievance officer. Under Consumer Protection (E-Commerce) Rules 2020 Rule 4(5), the grievance officer must acknowledge within 48 hours and resolve within 30 days.",
67
+ actionType: "send_email",
68
+ target: { channel: "email", recipient: "seller_grievance_officer" },
69
+ expectedResponseDays: 30,
70
+ },
71
+ {
72
+ id: "L1",
73
+ level: "L1",
74
+ label: "National Consumer Helpline (1915 / INGRAM)",
75
+ description:
76
+ "File complaint on INGRAM portal (consumerhelpline.gov.in) or call 1915. NCH mediates between consumer and company; response expected within 30 days. Suitable for e-commerce, product, and service complaints.",
77
+ actionType: "submit_portal",
78
+ target: { channel: "portal", url: "https://consumerhelpline.gov.in", helpline: "1915" },
79
+ expectedResponseDays: 30,
80
+ },
81
+ {
82
+ id: "L2",
83
+ level: "L2",
84
+ label: "E-Commerce Grievance Officer (formal notice)",
85
+ description:
86
+ "Send formal legal notice to e-commerce entity's grievance officer under Consumer Protection (E-Commerce) Rules 2020 Rule 4(5) citing failure to resolve. Demand resolution within 15 days failing which consumer will approach District Commission.",
87
+ actionType: "send_email",
88
+ target: { channel: "email", recipient: "ecommerce_grievance_officer" },
89
+ expectedResponseDays: 15,
90
+ },
91
+ {
92
+ id: "L3",
93
+ level: "L3",
94
+ label: "Consumer Commission – District (under ₹1 crore)",
95
+ description:
96
+ "File complaint at District Consumer Disputes Redressal Commission under Section 35 of Consumer Protection Act 2019. Jurisdiction covers goods/services valued up to ₹1 crore (Section 47). Limitation period is 2 years from cause of action (Section 69).",
97
+ actionType: "manual_packet",
98
+ target: { channel: "physical", forum: "District_Consumer_Commission" },
99
+ expectedResponseDays: 90,
100
+ },
101
+ ],
102
+ edges: [
103
+ { from: "L0", to: "L1", condition: "no_response_or_unsatisfactory_after_30_days" },
104
+ { from: "L1", to: "L2", condition: "no_response_or_unsatisfactory_after_30_days" },
105
+ { from: "L2", to: "L3", condition: "no_response_or_unsatisfactory_after_15_days" },
106
+ ],
107
+ },
108
+ requiredEvidence: ["receipt", "screenshot", "email", "chat"],
109
+ deadlines: {
110
+ complaint_within_days: 730,
111
+ note: "Section 69 CPA 2019: 2-year limitation from date cause of action arose",
112
+ grievance_officer_response_days: 30,
113
+ district_commission_resolution_days: 90,
114
+ },
115
+ sourceCitationsBySectionRef: [
116
+ { actName: "Consumer Protection Act, 2019", sectionNo: "35" },
117
+ { actName: "Consumer Protection Act, 2019", sectionNo: "47" },
118
+ { actName: "Consumer Protection Act, 2019", sectionNo: "69" },
119
+ { actName: "Consumer Protection Act, 2019", sectionNo: "2(47)" },
120
+ { actName: "Consumer Protection (E-Commerce) Rules, 2020", sectionNo: "4(5)" },
121
+ { actName: "Consumer Protection (E-Commerce) Rules, 2020", sectionNo: "5" },
122
+ { actName: "National Consumer Helpline – Grievance Filing Procedure", sectionNo: "ingram" },
123
+ ],
124
+ socialTemplate: null,
125
+ stopAndRefer: null,
126
+ active: true,
127
+ };
128
+
129
+ // ============================================================================
130
+ // PAYMENT domain route rule
131
+ // ============================================================================
132
+
133
+ export const paymentRouteRule: RouteRuleSeedData = {
134
+ issueDomain: "payment",
135
+ jurisdictionFilter: { country: "IN" },
136
+ triggerConditions: {
137
+ upi_or_bank: true,
138
+ issue_types: [
139
+ "failed_upi_transaction",
140
+ "unauthorized_debit",
141
+ "non_reversal",
142
+ "bank_fraud",
143
+ "wallet_dispute",
144
+ ],
145
+ },
146
+ priority: 100,
147
+ routeGraph: {
148
+ nodes: [
149
+ {
150
+ id: "L0",
151
+ level: "L0",
152
+ label: "Bank / PSP Complaint (30-Day Window)",
153
+ description:
154
+ "Immediately report unauthorized or failed transaction to your bank's customer care and PSP (e.g., Google Pay, PhonePe). Under RBI Customer Liability Circular 2017, reporting within 3 working days of bank's communication gives zero liability. Document the complaint reference number.",
155
+ actionType: "send_email",
156
+ target: { channel: "email", recipient: "bank_customer_care" },
157
+ expectedResponseDays: 30,
158
+ },
159
+ {
160
+ id: "L1",
161
+ level: "L1",
162
+ label: "RBI Banking Ombudsman (cms.rbi.org.in)",
163
+ description:
164
+ "If bank does not resolve within 30 days or gives unsatisfactory response, file complaint with RBI Integrated Ombudsman at cms.rbi.org.in. Per Clause 10 of RBI IOS 2021, you must have first lodged complaint with bank and waited 30 days. Maximum award is ₹20 lakh.",
165
+ actionType: "submit_portal",
166
+ target: { channel: "portal", url: "https://cms.rbi.org.in" },
167
+ expectedResponseDays: 30,
168
+ },
169
+ {
170
+ id: "L2",
171
+ level: "L2",
172
+ label: "NPCI / UPI TAT Escalation",
173
+ description:
174
+ "For UPI failed transactions where credit reversal has not occurred within T+1 day, escalate to NPCI through the PSP dispute management system. Per NPCI UPI Procedural Guidelines v1.6, reversal must occur within 5 working days. If PSP is unresponsive, raise chargeback through your bank.",
175
+ actionType: "submit_portal",
176
+ target: { channel: "portal", url: "https://www.npci.org.in/contact-us" },
177
+ expectedResponseDays: 5,
178
+ },
179
+ {
180
+ id: "L3",
181
+ level: "L3",
182
+ label: "Consumer Commission",
183
+ description:
184
+ "If RBI Ombudsman does not resolve or the complaint falls outside Ombudsman jurisdiction, file at District Consumer Commission under Consumer Protection Act 2019 (Section 35). Banking and payment services are 'services' under Section 2(34) CPA 2019.",
185
+ actionType: "manual_packet",
186
+ target: { channel: "physical", forum: "District_Consumer_Commission" },
187
+ expectedResponseDays: 90,
188
+ },
189
+ ],
190
+ edges: [
191
+ { from: "L0", to: "L1", condition: "no_resolution_within_30_days" },
192
+ { from: "L1", to: "L2", condition: "upi_specific_and_no_reversal" },
193
+ { from: "L1", to: "L3", condition: "ombudsman_unable_to_resolve_or_out_of_jurisdiction" },
194
+ { from: "L2", to: "L3", condition: "no_response_from_npci" },
195
+ ],
196
+ },
197
+ requiredEvidence: ["receipt", "screenshot", "pdf"],
198
+ deadlines: {
199
+ report_to_bank_days: 3,
200
+ note_rbi_zero_liability:
201
+ "RBI Circular 2017: zero liability if reported within 3 working days of bank communication",
202
+ ombudsman_complaint_days: 30,
203
+ note_ombudsman_pre_filing: "RBI IOS 2021 Clause 10: 30-day pre-filing requirement with bank",
204
+ upi_reversal_tat_days: 1,
205
+ note_upi_tat: "NPCI UPI Guidelines v1.6: T+1 credit reversal for failed UPI",
206
+ },
207
+ sourceCitationsBySectionRef: [
208
+ {
209
+ actName:
210
+ "RBI Customer Liability in Unauthorised Electronic Banking Transactions Circular 2017",
211
+ sectionNo: "zero-liability",
212
+ },
213
+ {
214
+ actName:
215
+ "RBI Customer Liability in Unauthorised Electronic Banking Transactions Circular 2017",
216
+ sectionNo: "limited-liability",
217
+ },
218
+ {
219
+ actName: "Reserve Bank – Integrated Ombudsman Scheme, 2021",
220
+ sectionNo: "clause-10",
221
+ },
222
+ {
223
+ actName: "Reserve Bank – Integrated Ombudsman Scheme, 2021",
224
+ sectionNo: "clause-10-pre-filing",
225
+ },
226
+ {
227
+ actName: "Reserve Bank – Integrated Ombudsman Scheme, 2021",
228
+ sectionNo: "award",
229
+ },
230
+ {
231
+ actName: "NPCI UPI Procedural Guidelines",
232
+ sectionNo: "dispute-tat",
233
+ },
234
+ {
235
+ actName: "NPCI UPI Procedural Guidelines",
236
+ sectionNo: "chargeback",
237
+ },
238
+ { actName: "Consumer Protection Act, 2019", sectionNo: "2(34)" },
239
+ ],
240
+ socialTemplate: null,
241
+ stopAndRefer: null,
242
+ active: true,
243
+ };
244
+
245
+ // ============================================================================
246
+ // CYBER FRAUD domain route rule
247
+ // ============================================================================
248
+
249
+ export const cyberFraudRouteRule: RouteRuleSeedData = {
250
+ issueDomain: "cyber_fraud",
251
+ jurisdictionFilter: { country: "IN" },
252
+ triggerConditions: {
253
+ money_lost: true,
254
+ urgency_hours_le: 48,
255
+ issue_types: [
256
+ "upi_fraud",
257
+ "phishing",
258
+ "vishing",
259
+ "identity_theft",
260
+ "fake_customer_care",
261
+ "investment_fraud",
262
+ "romance_scam",
263
+ "job_fraud",
264
+ ],
265
+ },
266
+ priority: 200,
267
+ routeGraph: {
268
+ nodes: [
269
+ {
270
+ id: "L0",
271
+ level: "L0",
272
+ label: "URGENT: Call 1930 + File on cybercrime.gov.in",
273
+ description:
274
+ "IMMEDIATELY call 1930 (National Cyber Crime Helpline) and file at cybercrime.gov.in. The CFCFRMS system triggers a bank-freeze request in the golden hour. Keep UPI transaction ID, fraudster's mobile/account number, and transaction amount ready. This step is time-critical — funds can be frozen before the fraudster withdraws.",
275
+ actionType: "inform_user",
276
+ target: {
277
+ channel: "portal",
278
+ url: "https://cybercrime.gov.in",
279
+ helpline: "1930",
280
+ },
281
+ expectedResponseDays: 1,
282
+ },
283
+ {
284
+ id: "L1",
285
+ level: "L1",
286
+ label: "Bank Freeze Request (RBI 2017 Zero-Liability)",
287
+ description:
288
+ "Contact your bank's fraud/cybercrime helpline immediately after calling 1930. Invoke the RBI Customer Liability Circular 2017 (DBR.No.Leg.BC.78/09.07.005/2017-18) — zero liability applies when unauthorized transaction is reported within 3 working days of bank communication. Demand written acknowledgement and a provisional credit within 10 working days.",
289
+ actionType: "send_email",
290
+ target: { channel: "email", recipient: "bank_fraud_desk" },
291
+ expectedResponseDays: 10,
292
+ },
293
+ {
294
+ id: "L2",
295
+ level: "L2",
296
+ label: "FIR at Police Station + I4C Follow-Up",
297
+ description:
298
+ "File an FIR at the nearest police station citing IT Act 2000 Section 66C (identity theft) / 66D (cheating by personation) and Bharatiya Nyaya Sanhita 2023 Section 318 (cheating) / 319 (cheating by personation) as applicable. Cross-reference the cybercrime.gov.in complaint number in the FIR. Follow up with the I4C nodal officer for the bank-freeze status.",
299
+ actionType: "manual_packet",
300
+ target: { channel: "physical", forum: "Police_Station_Cyber_Cell" },
301
+ expectedResponseDays: 30,
302
+ },
303
+ {
304
+ id: "L3",
305
+ level: "L3",
306
+ label: "Cyber Tribunal / Consumer Commission",
307
+ description:
308
+ "If police fails to act or bank does not reverse funds, escalate to the Adjudicating Officer under IT Act 2000 Section 46, or file at District Consumer Commission if bank's deficient service contributed to the fraud. RBI Ombudsman can be approached if bank refuses to apply zero-liability protection under the 2017 circular.",
309
+ actionType: "manual_packet",
310
+ target: { channel: "physical", forum: "Adjudicating_Officer_IT_Act" },
311
+ expectedResponseDays: 90,
312
+ },
313
+ ],
314
+ edges: [
315
+ { from: "L0", to: "L1", condition: "immediately_after_1930_call" },
316
+ { from: "L1", to: "L2", condition: "bank_not_responsive_or_fir_required" },
317
+ { from: "L2", to: "L3", condition: "police_inaction_or_funds_not_recovered" },
318
+ ],
319
+ },
320
+ requiredEvidence: ["screenshot", "receipt", "chat", "pdf"],
321
+ deadlines: {
322
+ golden_hour_report_asap: true,
323
+ note_golden_hour: "Call 1930 immediately — CFCFRMS freeze window is time-critical",
324
+ bank_report_within_working_days: 3,
325
+ note_bank_zero_liability: "RBI Circular 2017: zero liability if bank notified within 3 days",
326
+ fir_deadline_days: 30,
327
+ ombudsman_complaint_after_bank_days: 30,
328
+ },
329
+ sourceCitationsBySectionRef: [
330
+ { actName: "MHA Cybercrime Reporting – 1930 Helpline and National Cybercrime Reporting Portal", sectionNo: "1930-helpline" },
331
+ { actName: "MHA Cybercrime Reporting – 1930 Helpline and National Cybercrime Reporting Portal", sectionNo: "cybercrime-gov-in" },
332
+ { actName: "MHA Cybercrime Reporting – 1930 Helpline and National Cybercrime Reporting Portal", sectionNo: "bank-freeze" },
333
+ {
334
+ actName:
335
+ "RBI Customer Liability in Unauthorised Electronic Banking Transactions Circular 2017",
336
+ sectionNo: "zero-liability",
337
+ },
338
+ { actName: "Information Technology Act, 2000", sectionNo: "66C" },
339
+ { actName: "Information Technology Act, 2000", sectionNo: "66D" },
340
+ { actName: "Bharatiya Nyaya Sanhita, 2023", sectionNo: "318" },
341
+ { actName: "Bharatiya Nyaya Sanhita, 2023", sectionNo: "319" },
342
+ ],
343
+ socialTemplate: null,
344
+ stopAndRefer: {
345
+ conditions: ["sextortion", "threats_to_safety", "child_safety", "active_fraud_in_progress"],
346
+ helpline: "1930",
347
+ message:
348
+ "Call 1930 and visit cybercrime.gov.in immediately. If unsafe or threatened, dial 112. This situation requires immediate human assistance — CourtMitra automation is paused pending human review.",
349
+ },
350
+ active: true,
351
+ };
352
+
353
+ export const ALL_ROUTE_RULES: RouteRuleSeedData[] = [
354
+ consumerRouteRule,
355
+ paymentRouteRule,
356
+ cyberFraudRouteRule,
357
+ ];
packages/db/src/seeds/run.ts ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Day-3 seed runner: legal_sources, legal_chunks, route_rules.
3
+ *
4
+ * Idempotency strategy:
5
+ * - legal_sources: upsert on (title) — delete existing chunks + source, then re-insert.
6
+ * Simpler than per-row sha256 conflict since we control the data file.
7
+ * - legal_chunks: inserted after source is inserted; no conflict clause needed as we
8
+ * always delete-then-insert by source.
9
+ * - route_rules: delete-then-insert by issueDomain (Phase 1 has exactly one rule/domain).
10
+ *
11
+ * Embedding:
12
+ * Attempts to import createOpenAiCompatibleEmbedder from @courtmitra/adapters.
13
+ * If the export does not exist (Agent A's embedder not yet merged), falls back gracefully
14
+ * with embedding=null — FTS (tsvector GIN index) remains fully functional.
15
+ *
16
+ * Usage:
17
+ * pnpm --filter @courtmitra/db seed
18
+ * (or) set -a && . infra/.env && set +a && tsx packages/db/src/seeds/run.ts
19
+ */
20
+
21
+ import { createHash } from "node:crypto";
22
+ import { readFileSync } from "node:fs";
23
+ import { resolve, dirname } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+ import { eq, and } from "drizzle-orm";
26
+
27
+ // Load .env from infra/.env relative to repo root (two levels up from packages/db/src/seeds)
28
+ const __filename = fileURLToPath(import.meta.url);
29
+ const __dirname = dirname(__filename);
30
+ const envPath = resolve(__dirname, "../../../../infra/.env");
31
+
32
+ function loadEnv(path: string): void {
33
+ let raw: string;
34
+ try {
35
+ raw = readFileSync(path, "utf8");
36
+ } catch {
37
+ // Already exported by shell; skip silently.
38
+ return;
39
+ }
40
+ for (const line of raw.split("\n")) {
41
+ const trimmed = line.trim();
42
+ if (!trimmed || trimmed.startsWith("#")) continue;
43
+ const eqIdx = trimmed.indexOf("=");
44
+ if (eqIdx === -1) continue;
45
+ const key = trimmed.slice(0, eqIdx).trim();
46
+ const val = trimmed.slice(eqIdx + 1).trim();
47
+ if (!process.env[key]) {
48
+ process.env[key] = val;
49
+ }
50
+ }
51
+ }
52
+
53
+ loadEnv(envPath);
54
+
55
+ import { getDb, closeDb, legalSources, legalChunks, routeRules } from "../index.js";
56
+ import { ALL_LEGAL_SEED_ENTRIES } from "./legal-data.js";
57
+ import { ALL_ROUTE_RULES } from "./route-rules-data.js";
58
+ import type { LegalSeedEntry } from "./legal-data.js";
59
+ import type { RouteRuleSeedData } from "./route-rules-data.js";
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Embedder — graceful degradation if Agent A's factory isn't merged yet
63
+ // ---------------------------------------------------------------------------
64
+
65
+ type EmbedFn = (texts: string[]) => Promise<(number[] | null)[]>;
66
+
67
+ async function resolveEmbedder(): Promise<EmbedFn> {
68
+ try {
69
+ // Dynamically import so that a missing export doesn't crash at module load.
70
+ const adaptersModule = await import("@courtmitra/adapters");
71
+
72
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
73
+ const factory = (adaptersModule as any).createOpenAiCompatibleEmbedder;
74
+ if (typeof factory !== "function") {
75
+ console.warn(
76
+ "[seed] WARNING: createOpenAiCompatibleEmbedder not found in @courtmitra/adapters. " +
77
+ "Inserting chunks with embedding=null. FTS will still work.",
78
+ );
79
+ return async (texts) => texts.map(() => null);
80
+ }
81
+
82
+ const embedder = factory();
83
+ console.log("[seed] Embedder resolved from @courtmitra/adapters.");
84
+ return async (texts: string[]) => {
85
+ try {
86
+ return await embedder.embed(texts);
87
+ } catch (err) {
88
+ console.warn("[seed] WARNING: embedder.embed() failed:", (err as Error).message);
89
+ console.warn("[seed] Inserting chunks with embedding=null.");
90
+ return texts.map(() => null);
91
+ }
92
+ };
93
+ } catch (err) {
94
+ console.warn(
95
+ "[seed] WARNING: Could not import @courtmitra/adapters:",
96
+ (err as Error).message,
97
+ );
98
+ console.warn("[seed] Inserting chunks with embedding=null. FTS will still work.");
99
+ return async (texts) => texts.map(() => null);
100
+ }
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Helpers
105
+ // ---------------------------------------------------------------------------
106
+
107
+ function sha256(text: string): string {
108
+ return createHash("sha256").update(text, "utf8").digest("hex");
109
+ }
110
+
111
+ function countTokensApprox(text: string): number {
112
+ // Rough approximation: 1 token ≈ 4 chars for English legal text.
113
+ return Math.ceil(text.length / 4);
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Main seed logic
118
+ // ---------------------------------------------------------------------------
119
+
120
+ async function seedLegalData(
121
+ db: ReturnType<typeof getDb>,
122
+ embed: EmbedFn,
123
+ ): Promise<{ sourcesInserted: number; chunksInserted: number }> {
124
+ let sourcesInserted = 0;
125
+ let chunksInserted = 0;
126
+
127
+ for (const entry of ALL_LEGAL_SEED_ENTRIES) {
128
+ const { source, chunks } = entry as LegalSeedEntry;
129
+
130
+ // --- 1. Delete existing source (and cascading chunks) by title ---
131
+ const existing = await db
132
+ .select({ id: legalSources.id })
133
+ .from(legalSources)
134
+ .where(eq(legalSources.title, source.title));
135
+
136
+ if (existing.length > 0) {
137
+ const existingId = existing[0]!.id;
138
+ // Delete chunks first (FK constraint)
139
+ await db.delete(legalChunks).where(eq(legalChunks.sourceId, existingId));
140
+ await db.delete(legalSources).where(eq(legalSources.id, existingId));
141
+ console.log(`[seed] Replaced existing source: "${source.title}"`);
142
+ }
143
+
144
+ // --- 2. Compute sha256 for the source (based on title + version) ---
145
+ const sourceHash = sha256(`${source.title}|${source.versionLabel}`);
146
+
147
+ // --- 3. Insert source ---
148
+ const [insertedSource] = await db
149
+ .insert(legalSources)
150
+ .values({
151
+ sourceType: source.sourceType,
152
+ title: source.title,
153
+ tier: source.tier,
154
+ jurisdiction: source.jurisdiction,
155
+ authority: source.authority,
156
+ canonicalUrl: source.canonicalUrl,
157
+ versionLabel: source.versionLabel,
158
+ effectiveFrom: source.effectiveFrom,
159
+ sha256: sourceHash,
160
+ metadata: source.metadata,
161
+ })
162
+ .returning({ id: legalSources.id });
163
+
164
+ if (!insertedSource) {
165
+ console.error(`[seed] ERROR: Failed to insert source "${source.title}"`);
166
+ continue;
167
+ }
168
+
169
+ sourcesInserted++;
170
+ const sourceId = insertedSource.id;
171
+ console.log(`[seed] Inserted source [${sourceId}]: "${source.title}"`);
172
+
173
+ // --- 4. Embed all chunk texts for this source ---
174
+ const chunkTexts = chunks.map((c) => c.text);
175
+ const embeddings = await embed(chunkTexts);
176
+
177
+ // --- 5. Insert chunks ---
178
+ for (let i = 0; i < chunks.length; i++) {
179
+ const chunk = chunks[i]!;
180
+ const embedding = embeddings[i] ?? null;
181
+
182
+ await db.insert(legalChunks).values({
183
+ sourceId,
184
+ chunkNo: chunk.chunkNo,
185
+ actName: chunk.actName,
186
+ sectionNo: chunk.sectionNo,
187
+ heading: chunk.heading,
188
+ text: chunk.text,
189
+ tokenCount: countTokensApprox(chunk.text),
190
+ // textTsv is a GENERATED ALWAYS AS column — do not supply a value
191
+ embedding: embedding as number[] | null,
192
+ metadata: chunk.metadata,
193
+ });
194
+
195
+ chunksInserted++;
196
+ }
197
+
198
+ console.log(`[seed] → ${chunks.length} chunks inserted for "${source.title}"`);
199
+ }
200
+
201
+ return { sourcesInserted, chunksInserted };
202
+ }
203
+
204
+ async function seedRouteRules(
205
+ db: ReturnType<typeof getDb>,
206
+ ): Promise<{ rulesInserted: number }> {
207
+ let rulesInserted = 0;
208
+
209
+ for (const rule of ALL_ROUTE_RULES as RouteRuleSeedData[]) {
210
+ // --- 1. Resolve sourceCitationsBySectionRef → legal_chunk ids ---
211
+ const resolvedChunkIds: string[] = [];
212
+
213
+ for (const ref of rule.sourceCitationsBySectionRef) {
214
+ // Find the source by title (act name)
215
+ const sourceRows = await db
216
+ .select({ id: legalSources.id })
217
+ .from(legalSources)
218
+ .where(eq(legalSources.title, ref.actName));
219
+
220
+ if (sourceRows.length === 0) {
221
+ console.warn(
222
+ `[seed] WARNING: Source not found for actName="${ref.actName}" — skipping citation`,
223
+ );
224
+ continue;
225
+ }
226
+
227
+ const sourceId = sourceRows[0]!.id;
228
+
229
+ // Find chunk by source_id + section_no
230
+ const chunkRows = await db
231
+ .select({ id: legalChunks.id })
232
+ .from(legalChunks)
233
+ .where(
234
+ and(
235
+ eq(legalChunks.sourceId, sourceId),
236
+ eq(legalChunks.sectionNo, ref.sectionNo),
237
+ ),
238
+ );
239
+
240
+ if (chunkRows.length === 0) {
241
+ console.warn(
242
+ `[seed] WARNING: Chunk not found for actName="${ref.actName}" sectionNo="${ref.sectionNo}" — skipping`,
243
+ );
244
+ continue;
245
+ }
246
+
247
+ resolvedChunkIds.push(chunkRows[0]!.id);
248
+ }
249
+
250
+ // --- 2. Delete existing rule for this domain (idempotency) ---
251
+ await db.delete(routeRules).where(eq(routeRules.issueDomain, rule.issueDomain));
252
+
253
+ // --- 3. Insert route rule ---
254
+ const [inserted] = await db
255
+ .insert(routeRules)
256
+ .values({
257
+ issueDomain: rule.issueDomain,
258
+ jurisdictionFilter: rule.jurisdictionFilter,
259
+ triggerConditions: rule.triggerConditions,
260
+ priority: rule.priority,
261
+ routeGraph: rule.routeGraph,
262
+ requiredEvidence: rule.requiredEvidence,
263
+ deadlines: rule.deadlines,
264
+ sourceCitations: resolvedChunkIds,
265
+ socialTemplate: rule.socialTemplate,
266
+ stopAndRefer: rule.stopAndRefer,
267
+ active: rule.active,
268
+ })
269
+ .returning({ id: routeRules.id });
270
+
271
+ if (!inserted) {
272
+ console.error(`[seed] ERROR: Failed to insert route rule for domain "${rule.issueDomain}"`);
273
+ continue;
274
+ }
275
+
276
+ rulesInserted++;
277
+ console.log(
278
+ `[seed] Route rule [${inserted.id}] inserted for domain="${rule.issueDomain}" ` +
279
+ `with ${resolvedChunkIds.length} cited chunks`,
280
+ );
281
+ }
282
+
283
+ return { rulesInserted };
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Entry point
288
+ // ---------------------------------------------------------------------------
289
+
290
+ async function main(): Promise<void> {
291
+ console.log("[seed] Day-3 legal grounding seed starting...");
292
+ console.log(`[seed] DATABASE_URL: ${process.env["DATABASE_URL"] ? "set" : "NOT SET — will fail"}`);
293
+
294
+ const db = getDb();
295
+
296
+ // Resolve embedder (graceful degradation if Agent A's export isn't merged)
297
+ const embed = await resolveEmbedder();
298
+
299
+ // Seed legal data
300
+ console.log("\n[seed] ── Seeding legal_sources + legal_chunks ──");
301
+ const { sourcesInserted, chunksInserted } = await seedLegalData(db, embed);
302
+
303
+ // Seed route rules
304
+ console.log("\n[seed] ── Seeding route_rules ──");
305
+ const { rulesInserted } = await seedRouteRules(db);
306
+
307
+ console.log("\n[seed] ── Summary ──");
308
+ console.log(`[seed] legal_sources inserted : ${sourcesInserted}`);
309
+ console.log(`[seed] legal_chunks inserted : ${chunksInserted}`);
310
+ console.log(`[seed] route_rules inserted : ${rulesInserted}`);
311
+ console.log("[seed] Done.");
312
+
313
+ await closeDb();
314
+ }
315
+
316
+ main().catch((err) => {
317
+ console.error("[seed] FATAL:", err);
318
+ process.exit(1);
319
+ });
packages/ports/src/index.ts CHANGED
@@ -45,6 +45,13 @@ export interface LlmPort {
45
  generateStructured<T>(req: LlmStructuredRequest<T>): Promise<LlmStructuredResult<T>>;
46
  }
47
 
 
 
 
 
 
 
 
48
  // --- Legal retrieval / verification -----------------------------------------
49
 
50
  export interface LegalQuery {
@@ -65,6 +72,8 @@ export interface RetrievedLegalSource {
65
  }
66
 
67
  export interface ClaimSupportCheck {
 
 
68
  claim: string;
69
  quoteSpans: string[];
70
  chunkIds: string[];
 
45
  generateStructured<T>(req: LlmStructuredRequest<T>): Promise<LlmStructuredResult<T>>;
46
  }
47
 
48
+ // --- Embedding ---------------------------------------------------------------
49
+
50
+ export interface EmbeddingPort {
51
+ embed(texts: string[]): Promise<number[][]>;
52
+ dimensions: number;
53
+ }
54
+
55
  // --- Legal retrieval / verification -----------------------------------------
56
 
57
  export interface LegalQuery {
 
72
  }
73
 
74
  export interface ClaimSupportCheck {
75
+ /** Unique mission identifier — required to persist legal_claim_verifications. */
76
+ missionId: string;
77
  claim: string;
78
  quoteSpans: string[];
79
  chunkIds: string[];
packages/prompts/src/index.ts CHANGED
@@ -124,6 +124,78 @@ Rules:
124
  - Do NOT fabricate claims not supported by the evidence.
125
  - Output ONLY the JSON object (with the "claims" key), nothing else.`,
126
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  };
128
 
129
  /**
@@ -146,13 +218,29 @@ const UNTRUSTED_VARS = new Set(["rawMessages", "parsedEvidence"]);
146
  */
147
  const MAX_UNTRUSTED_CHARS = 12000;
148
 
 
 
 
 
 
 
 
149
  /**
150
  * Interpolate {{variable}} placeholders in a prompt template.
151
  * Untrusted variables (rawMessages, parsedEvidence) are capped at
152
  * MAX_UNTRUSTED_CHARS to prevent injection and token blowup.
 
 
 
153
  */
154
  export function renderPrompt(id: string, vars: Record<string, unknown>): string {
155
  const p = getPrompt(id);
 
 
 
 
 
 
156
  return p.template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => {
157
  if (!(key in vars)) {
158
  throw new Error(`Missing variable "{{${key}}}" for prompt "${id}".`);
@@ -165,3 +253,35 @@ export function renderPrompt(id: string, vars: Record<string, unknown>): string
165
  return str;
166
  });
167
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  - Do NOT fabricate claims not supported by the evidence.
125
  - Output ONLY the JSON object (with the "claims" key), nothing else.`,
126
  },
127
+
128
+ "route-plan.v1": {
129
+ id: "route-plan.v1",
130
+ version: "1",
131
+ template: `You are a legal-route planning assistant for Indian citizens filing grievances. Your task is to generate a structured route plan grounded in the provided legal chunks.
132
+
133
+ CRITICAL CITATION RULES (CLAUDE.md #4 — INVARIANT, NON-NEGOTIABLE):
134
+ - Every claim's \`quoteSpans[]\` entries MUST be VERBATIM substrings of the corresponding chunk's text in <retrieved_chunks>. Copy the exact characters — do not paraphrase, summarise, or invent.
135
+ - Every \`chunkIds[]\` entry MUST be a chunk_id that appears in <retrieved_chunks>. Do not fabricate chunk IDs.
136
+ - If no chunk supports a claim with a literal verbatim quote, omit the claim entirely. Never invent citations.
137
+ - If zero claims survive, still generate a route plan — use neutral administrative language in step descriptions; do not use legal language without citations.
138
+
139
+ Issue domain: {{issueDomain}}
140
+
141
+ <mission_facts>
142
+ {{missionFacts}}
143
+ </mission_facts>
144
+
145
+ <evidence_claims>
146
+ {{evidenceClaims}}
147
+ </evidence_claims>
148
+
149
+ <retrieved_chunks>
150
+ {{retrievedChunks}}
151
+ </retrieved_chunks>
152
+
153
+ Return ONLY a JSON object matching the schema below — no markdown fences, no prose.
154
+
155
+ Schema:
156
+ {
157
+ "summary": "<one-paragraph summary of the recommended route and why, referencing key facts>",
158
+ "steps": [
159
+ {
160
+ "stepNo": <integer starting at 0>,
161
+ "level": "<L0|L1|L2|L3|L4>",
162
+ "title": "<short step title>",
163
+ "description": "<what to do at this step, referencing relevant facts and cited law if available>",
164
+ "authority": "<authority name or null>",
165
+ "channel": "<email|portal|phone|in_person|social|self_service>",
166
+ "actionType": "<generate_packet|send_email|prepare_portal|call_support|ask_question|schedule_followup|close_case>",
167
+ "requiredEvidence": ["<evidence item description>"],
168
+ "deadlineDays": <integer calendar days or null>,
169
+ "claimRefs": [<index into claims[] array>],
170
+ "riskLevel": "<low|medium|high>"
171
+ }
172
+ ],
173
+ "claims": [
174
+ {
175
+ "claim": "<one-sentence legal or procedural claim directly supported by chunk text>",
176
+ "quoteSpans": ["<VERBATIM substring from chunk text — must be exact copy>"],
177
+ "chunkIds": ["<chunk_id from retrieved_chunks>"],
178
+ "confidence": <0.0–1.0>
179
+ }
180
+ ],
181
+ "riskNotes": ["<risk or caveat note>"],
182
+ "expectedTimelineDays": <integer total expected days or null>
183
+ }
184
+
185
+ Route level guide:
186
+ - L0: informal resolution (direct complaint to company/platform via self_service or email)
187
+ - L1: regulator complaint (CGPDTM/IRDAI/SEBI/RBI portal or email)
188
+ - L2: ombudsman (RBI Banking Ombudsman, Insurance Ombudsman, etc.)
189
+ - L3: consumer commission (District Consumer Disputes Redressal Commission)
190
+ - L4: appellate / superior forum (State Commission, National Commission, High Court)
191
+
192
+ Rules:
193
+ - Generate at least L0 and L1 steps; add L2+ only if facts and evidence suggest it is needed.
194
+ - Legal information and workflow assistance only — not a lawyer-client relationship. Do not guarantee outcomes.
195
+ - Never label any entity "fraud/scam/criminal"; use "user reports / unresolved / alleged".
196
+ - Disclaimer on legal outputs: "This is legal information only. CourtMitra is not your lawyer."
197
+ - Output ONLY the JSON object, nothing else.`,
198
+ },
199
  };
200
 
201
  /**
 
218
  */
219
  const MAX_UNTRUSTED_CHARS = 12000;
220
 
221
+ /**
222
+ * Max total prompt body characters for route-plan (to stay within context).
223
+ * Retrieved chunks are the largest variable and are truncated to keep total
224
+ * prompt body under ~14000 chars as required by the spec.
225
+ */
226
+ const MAX_ROUTE_PLAN_CHARS = 14000;
227
+
228
  /**
229
  * Interpolate {{variable}} placeholders in a prompt template.
230
  * Untrusted variables (rawMessages, parsedEvidence) are capped at
231
  * MAX_UNTRUSTED_CHARS to prevent injection and token blowup.
232
+ *
233
+ * For route-plan.v1: retrievedChunks is length-capped at remaining budget
234
+ * after other variables are rendered, keeping total under MAX_ROUTE_PLAN_CHARS.
235
  */
236
  export function renderPrompt(id: string, vars: Record<string, unknown>): string {
237
  const p = getPrompt(id);
238
+
239
+ // For route-plan.v1, apply total-body cap by truncating retrievedChunks last.
240
+ if (id === "route-plan.v1") {
241
+ return renderRoutePlanPrompt(p.template, vars);
242
+ }
243
+
244
  return p.template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => {
245
  if (!(key in vars)) {
246
  throw new Error(`Missing variable "{{${key}}}" for prompt "${id}".`);
 
253
  return str;
254
  });
255
  }
256
+
257
+ /**
258
+ * Render the route-plan.v1 prompt, capping retrievedChunks so total body
259
+ * stays under MAX_ROUTE_PLAN_CHARS. All other vars are rendered first to
260
+ * measure their size; remaining budget is given to retrievedChunks.
261
+ */
262
+ function renderRoutePlanPrompt(template: string, vars: Record<string, unknown>): string {
263
+ // First pass: render all vars except retrievedChunks (use placeholder)
264
+ const CHUNK_PLACEHOLDER = "__CHUNKS_PLACEHOLDER__";
265
+ const withoutChunks = template.replace(/\{\{(\w+)\}\}/g, (match, key: string) => {
266
+ if (key === "retrievedChunks") return CHUNK_PLACEHOLDER;
267
+ if (!(key in vars)) {
268
+ throw new Error(`Missing variable "{{${key}}}" for prompt "route-plan.v1".`);
269
+ }
270
+ const val = vars[key];
271
+ return typeof val === "string" ? val : JSON.stringify(val, null, 2);
272
+ });
273
+
274
+ const usedChars = withoutChunks.replace(CHUNK_PLACEHOLDER, "").length;
275
+ const remaining = Math.max(0, MAX_ROUTE_PLAN_CHARS - usedChars);
276
+
277
+ if (!("retrievedChunks" in vars)) {
278
+ throw new Error(`Missing variable "{{retrievedChunks}}" for prompt "route-plan.v1".`);
279
+ }
280
+ const chunksVal = vars["retrievedChunks"];
281
+ let chunksStr = typeof chunksVal === "string" ? chunksVal : JSON.stringify(chunksVal, null, 2);
282
+ if (chunksStr.length > remaining) {
283
+ chunksStr = chunksStr.slice(0, remaining) + "\n[...chunks truncated to fit context budget]";
284
+ }
285
+
286
+ return withoutChunks.replace(CHUNK_PLACEHOLDER, chunksStr);
287
+ }
pnpm-lock.yaml CHANGED
@@ -158,6 +158,9 @@ importers:
158
  '@courtmitra/contracts':
159
  specifier: workspace:*
160
  version: link:../contracts
 
 
 
161
  '@courtmitra/ports':
162
  specifier: workspace:*
163
  version: link:../ports
@@ -170,6 +173,9 @@ importers:
170
  ai:
171
  specifier: ^6.0.191
172
  version: 6.0.191(zod@4.4.3)
 
 
 
173
  zod:
174
  specifier: ^4.4.3
175
  version: 4.4.3
 
158
  '@courtmitra/contracts':
159
  specifier: workspace:*
160
  version: link:../contracts
161
+ '@courtmitra/db':
162
+ specifier: workspace:*
163
+ version: link:../db
164
  '@courtmitra/ports':
165
  specifier: workspace:*
166
  version: link:../ports
 
173
  ai:
174
  specifier: ^6.0.191
175
  version: 6.0.191(zod@4.4.3)
176
+ drizzle-orm:
177
+ specifier: ^0.45.2
178
+ version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)
179
  zod:
180
  specifier: ^4.4.3
181
  version: 4.4.3
scripts/day3-fabrication-gate.ts ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Day-3 live gate (half 2): fabricated-citation rejection.
3
+ *
4
+ * Drives the LegalRetrievalPort verifier with two payloads against the same
5
+ * real chunk:
6
+ * - GENUINE quote (exact verbatim substring of chunk.text) → must be supported=true
7
+ * - FABRICATED quote (plausible-sounding but NOT in chunk.text) → must be supported=false
8
+ *
9
+ * Asserts both, prints the legal_claim_verifications row, exits non-zero on
10
+ * regression. Runs against the local Postgres (DATABASE_URL must be exported).
11
+ */
12
+ import { getDb, schema } from "@courtmitra/db";
13
+ import {
14
+ createOpenAiCompatibleEmbedder,
15
+ createPgvectorFtsLegalRetrieval,
16
+ } from "@courtmitra/adapters";
17
+ import { eq } from "drizzle-orm";
18
+
19
+ async function main() {
20
+ const db = getDb();
21
+ const embedder = createOpenAiCompatibleEmbedder();
22
+ const retrieval = createPgvectorFtsLegalRetrieval(db, embedder);
23
+
24
+ // Pick a real seeded chunk to anchor both tests.
25
+ const [chunk] = await db
26
+ .select()
27
+ .from(schema.legalChunks)
28
+ .where(eq(schema.legalChunks.sectionNo, "award"))
29
+ .limit(1);
30
+ if (!chunk) throw new Error("No 'award' chunk seeded — run pnpm db:seed first.");
31
+
32
+ // Pick any mission to satisfy FK.
33
+ const [mission] = await db.select().from(schema.missions).limit(1);
34
+ if (!mission) throw new Error("No mission in DB.");
35
+
36
+ // 1. GENUINE quote — verbatim substring of chunk.text.
37
+ const genuineQuote = chunk.text.slice(60, 180).trim();
38
+ const genuine = await retrieval.verifyClaimSupport({
39
+ missionId: mission.id,
40
+ claim: "Ombudsman can direct the regulated entity to pay an award (test-genuine).",
41
+ quoteSpans: [genuineQuote],
42
+ chunkIds: [chunk.id],
43
+ });
44
+
45
+ // 2. FABRICATED quote — plausible legalese but NOT in chunk.text.
46
+ const fabricatedQuote =
47
+ "The Ombudsman shall award treble damages plus punitive interest at 24% per annum on every claim, payable within seventy-two hours.";
48
+ const fabricated = await retrieval.verifyClaimSupport({
49
+ missionId: mission.id,
50
+ claim: "Ombudsman awards treble damages plus 24% interest (test-fabricated).",
51
+ quoteSpans: [fabricatedQuote],
52
+ chunkIds: [chunk.id],
53
+ });
54
+
55
+ console.log("\n=== DAY-3 FABRICATION GATE ===");
56
+ console.log("Genuine →", { supported: genuine.supported, supportLevel: genuine.supportLevel });
57
+ console.log("Fabricated →", {
58
+ supported: fabricated.supported,
59
+ supportLevel: fabricated.supportLevel,
60
+ failureReason: fabricated.failureReason,
61
+ });
62
+
63
+ const ok = genuine.supported === true && fabricated.supported === false;
64
+ console.log(ok ? "\nPASS ✓ verifier rejects fabricated citation" : "\nFAIL ✗");
65
+ process.exit(ok ? 0 : 1);
66
+ }
67
+
68
+ main().catch((e) => {
69
+ console.error(e);
70
+ process.exit(1);
71
+ });