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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
513 changes: 513 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions src/app/(app)/chat/tool-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import {
IconBuilding,
IconHistory,
IconTool,
IconGavel,
IconReceipt,
IconBookmark,
type Icon,
} from "@tabler/icons-react";

Expand All @@ -30,6 +33,10 @@ const META: Record<string, ToolMeta> = {
read_document: { icon: IconBook2, chip: "lecture", category: "lecture", primary: false },
list_documents: { icon: IconList, chip: "lecture", category: "lecture", primary: false },
legifrance_search: { icon: IconScale, chip: "Légifrance", category: "recherche", primary: false },
judilibre_search: { icon: IconGavel, chip: "Judilibre", category: "recherche", primary: false },
judilibre_decision: { icon: IconGavel, chip: "Judilibre", category: "recherche", primary: false },
bofip_search: { icon: IconBookmark, chip: "BOFIP", category: "recherche", primary: false },
bodacc_search: { icon: IconReceipt, chip: "BODACC", category: "recherche", primary: false },
pappers_search: { icon: IconBuilding, chip: "Pappers", category: "recherche", primary: false },
pappers_get: { icon: IconBuilding, chip: "Pappers", category: "recherche", primary: false },
search_conversation_history: { icon: IconHistory, chip: "historique", category: "recherche", primary: false },
Expand Down
60 changes: 60 additions & 0 deletions src/lib/connectors/bodacc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { odsSearch } from "./opendatasoft";
import { runTool, toolOk, type ToolResult } from "@/lib/tools/result";

const BODACC_BASE = "https://bodacc-datadila.opendatasoft.com";
const DATASET_ID = "annonces-commerciales";

export type BodaccHit = {
id: string;
type: string;
commercant: string;
date: string;
tribunal: string;
registre: string;
ville: string;
url: string;
};

type BodaccRecord = Record<string, string | undefined>;

function escapeOdsql(value: string): string {
return value.replace(/'/g, "''").replace(/\\/g, "\\\\");
}

export async function bodaccSearch(
query: string,
opts?: { departement?: string; famille?: string; date_start?: string; date_end?: string }
): Promise<ToolResult<{ query: string; hits: BodaccHit[]; total: number }>> {
return runTool(async () => {
const whereParts: string[] = [];
if (opts?.date_start) whereParts.push(`dateparution >= '${escapeOdsql(opts.date_start)}'`);
if (opts?.date_end) whereParts.push(`dateparution <= '${escapeOdsql(opts.date_end)}'`);
if (opts?.departement) whereParts.push(`numerodepartement = '${escapeOdsql(opts.departement)}'`);
if (opts?.famille) whereParts.push(`familleavis_lib like '${escapeOdsql(opts.famille)}'`);

const r = await odsSearch(BODACC_BASE, DATASET_ID, {
q: query,
where: whereParts.length > 0 ? whereParts.join(" AND ") : undefined,
orderBy: "dateparution desc",
limit: 5,
}, "BODACC");

if (!r.ok) return r;

const hits: BodaccHit[] = (r.data.results ?? []).slice(0, 5).map((raw) => {
const rec = raw as BodaccRecord;
return {
id: `BODACC-${rec.numeroannonce || ""}`,
type: rec.familleavis_lib || rec.typeavis_lib || "Annonce",
commercant: rec.commercant || "N/A",
date: rec.dateparution || "",
tribunal: rec.tribunal || "",
registre: rec.registre || "",
ville: `${rec.cp || ""} ${rec.ville || ""}`.trim(),
url: rec.url_complete || "https://www.bodacc.fr/pages/annonces-commerciales/",
};
});

return toolOk({ query, hits, total: r.data.total_count ?? 0 });
});
}
91 changes: 91 additions & 0 deletions src/lib/connectors/bofip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { pistePost } from "./piste";
import { runTool, toolOk, type ToolResult } from "@/lib/tools/result";

export type BofipHit = {
id: string;
title: string;
nature: string;
date: string;
nor: string;
url: string;
excerpt: string;
};

/**
* Recherche dans la doctrine fiscale (BOFIP) via la sous-API Légifrance/PISTE
* avec fond=CIRC (circulaires et instructions ministérielles).
*/
export async function bofipSearch(
userId: string,
query: string,
opts?: { limit?: number }
): Promise<ToolResult<{ query: string; hits: BofipHit[]; total: number }>> {
return runTool(async () => {
const pageSize = Math.min(opts?.limit ?? 5, 10);

type Raw = {
results?: Array<{
id?: string;
cid?: string;
titre?: string;
nature?: string;
dateDebut?: string;
dateTexte?: string;
nor?: string;
origin?: string;
texte?: string;
sections?: Array<{ extracts?: Array<{ values?: string[] }> }>;
}>;
totalResultNumber?: number;
};

const r = await pistePost<Raw>(
userId,
"/dila/legifrance/lf-engine-app/search",
{
fond: "CIRC",
recherche: {
champs: [
{
typeChamp: "ALL",
operateur: "ET",
criteres: [
{ valeur: query, operateur: "ET", typeRecherche: "UN_DES_MOTS" },
],
},
],
filtres: [],
pageNumber: 1,
pageSize,
sort: "PERTINENCE",
typePagination: "DEFAUT",
operateur: "ET",
},
},
"BOFIP"
);

if (!r.ok) return r;

const hits: BofipHit[] = (r.data.results ?? []).slice(0, pageSize).map((row) => {
const id = row.id ?? row.cid ?? "";
return {
id,
title: row.titre ?? "Document fiscal",
nature: row.nature ?? "",
date: row.dateDebut ?? row.dateTexte ?? "",
nor: row.nor ?? "",
url: id
? `https://www.legifrance.gouv.fr/circulaire/id/${id}`
: "https://bofip.impots.gouv.fr",
excerpt: (
row.texte ??
row.sections?.[0]?.extracts?.[0]?.values?.[0] ??
""
).slice(0, 280),
};
});

return toolOk({ query, hits, total: r.data.totalResultNumber ?? 0 });
});
}
4 changes: 2 additions & 2 deletions src/lib/connectors/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ export const CONNECTOR_CATALOG: Record<ConnectorType, ConnectorMeta> = {
// Seul Légifrance est réellement câblé (lib/connectors/tools.ts). Les
// autres sous-APIs PISTE sont annoncées « à venir » plutôt que prétendues
// débloquées.
unlocks: ["Légifrance"],
comingSoon: ["Judilibre", "JADE", "INPI", "BODACC"],
unlocks: ["Légifrance", "Judilibre", "BOFIP"],
comingSoon: ["JADE"],
credentialFields: [
{
name: "client_id",
Expand Down
121 changes: 121 additions & 0 deletions src/lib/connectors/judilibre.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);

vi.mock("./runtime", () => ({
loadConnectorCredentials: vi.fn().mockResolvedValue({
key: { id: "test-key" },
credentials: { client_id: "test-id", client_secret: "test-secret" },
}),
listActiveConnectorTypes: vi.fn().mockResolvedValue(["piste"]),
}));

describe("judilibre", () => {
beforeEach(() => {
mockFetch.mockReset();
vi.resetModules();
});

describe("judilibreSearch", () => {
it("construit la bonne URL GET avec les paramètres de recherche", async () => {
// OAuth token response
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("oauth.piste.gouv.fr")) {
return {
ok: true,
json: async () => ({ access_token: "tok-123", expires_in: 300 }),
};
}
if (url.includes("judilibre")) {
return {
ok: true,
json: async () => ({
results: [
{
id: "dec-001",
number: "21-12.345",
ecli: "ECLI:FR:CCASS:2024:CO00123",
formation: "FP",
solution: "Cassation",
decision_date: "2024-03-15",
jurisdiction: "Cour de cassation",
chamber: "Chambre commerciale",
themes: ["contrats", "responsabilité"],
summary: "Résumé de la décision...",
text: "",
},
],
total: 42,
next_page: null,
}),
};
}
return { ok: false, status: 404 };
});

const { judilibreSearch } = await import("./judilibre");
const result = await judilibreSearch("user-1", "rupture brutale", {
jurisdiction: "cc",
});

expect(result.ok).toBe(true);
if (!result.ok) return;

expect(result.data.hits).toHaveLength(1);
expect(result.data.hits[0].ecli).toBe("ECLI:FR:CCASS:2024:CO00123");
expect(result.data.hits[0].solution).toBe("Cassation");
expect(result.data.total).toBe(42);

// Vérifie que le 2e appel est un GET vers Judilibre
const judilibreCall = mockFetch.mock.calls.find((c) =>
(c[0] as string).includes("judilibre")
);
expect(judilibreCall).toBeDefined();
expect(judilibreCall![0]).toContain("/cassation/judilibre/v1.0/search");
expect(judilibreCall![0]).toContain("query=rupture+brutale");
expect(judilibreCall![0]).toContain("jurisdiction=cc");
expect(judilibreCall![1].method).toBe("GET");
});
});

describe("judilibreGetDecision", () => {
it("récupère une décision par ID", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("oauth.piste.gouv.fr")) {
return {
ok: true,
json: async () => ({ access_token: "tok-456", expires_in: 300 }),
};
}
if (url.includes("judilibre")) {
return {
ok: true,
json: async () => ({
id: "dec-001",
number: "21-12.345",
ecli: "ECLI:FR:CCASS:2024:CO00123",
formation: "FP",
solution: "Rejet",
decision_date: "2024-06-01",
jurisdiction: "Cour de cassation",
chamber: "Chambre sociale",
themes: ["licenciement"],
summary: "Attendu que...",
text: "LA COUR DE CASSATION...",
}),
};
}
return { ok: false, status: 404 };
});

const { judilibreGetDecision } = await import("./judilibre");
const result = await judilibreGetDecision("user-1", "dec-001");

expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.data.decision.text).toContain("LA COUR DE CASSATION");
expect(result.data.decision.url).toContain("dec-001");
});
});
});
Loading
Loading