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
93 changes: 93 additions & 0 deletions supabase/functions/_shared/markdown-pages.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import test from "node:test";
import assert from "node:assert/strict";

// Vrais tests unitaires, pas des assertions sur la source : markdown-pages.ts est un module pur,
// sans I/O ni import npm:/https:, donc le type stripping natif de Node le charge directement (même
// parti pris que generate-atomic-knowledge/page-chunks.test.mjs, dont ces tests reprennent la
// couverture du découpage en pages, déplacé ici — voir markdown-pages.ts).
import { splitMarkdownIntoPages } from "./markdown-pages.ts";

function buildMarkdown(pageCount, startPage = 1) {
return Array.from({ length: pageCount }, (_, i) => {
const pageNumber = startPage + i;
return `<!-- source-page: ${pageNumber} -->\n\n# Titre ${pageNumber}\n\nContenu de la page ${pageNumber}.`;
}).join("\n\n");
}

test("un Markdown à N marqueurs produit N pages, dans l'ordre, avec les numéros absolus", () => {
const { pages, markersFound } = splitMarkdownIntoPages(buildMarkdown(7));

assert.equal(markersFound, true);
assert.equal(pages.length, 7);
assert.deepEqual(pages.map((page) => page.pageNumber), [1, 2, 3, 4, 5, 6, 7]);
assert.match(pages[0].markdown, /# Titre 1/);
assert.match(pages[6].markdown, /Contenu de la page 7\./);
});

test("les numéros absolus sont lus dans le marqueur, pas déduits de la position", () => {
const { pages } = splitMarkdownIntoPages(buildMarkdown(3, 12));
assert.deepEqual(pages.map((page) => page.pageNumber), [12, 13, 14]);
});

test("un Markdown sans marqueur est traité comme une page unique, et signalé comme tel", () => {
const { pages, markersFound } = splitMarkdownIntoPages("# Cours\n\nUn texte sans aucun marqueur.");

assert.equal(markersFound, false);
assert.equal(pages.length, 1);
assert.equal(pages[0].pageNumber, null);
assert.match(pages[0].markdown, /Un texte sans aucun marqueur\./);
});

test("un Markdown vide ne produit aucune page", () => {
assert.deepEqual(splitMarkdownIntoPages(" ").pages, []);
assert.equal(splitMarkdownIntoPages(" ").markersFound, false);
});

test("du contenu avant le premier marqueur est rattaché à la première page, jamais jeté", () => {
const { pages } = splitMarkdownIntoPages(`Préambule hors page\n\n${buildMarkdown(2)}`);

assert.equal(pages.length, 2);
assert.match(pages[0].markdown, /Préambule hors page/);
assert.match(pages[0].markdown, /# Titre 1/);
assert.doesNotMatch(pages[1].markdown, /Préambule hors page/);
});

test("le marqueur est reconnu quels que soient les espaces internes", () => {
const { pages, markersFound } = splitMarkdownIntoPages("<!--source-page:3-->\nA\n\n<!-- source-page: 4 -->\nB");

assert.equal(markersFound, true);
assert.deepEqual(pages.map((page) => page.pageNumber), [3, 4]);
});

// Offsets ajoutés pour professoral-document-structure.ts (D2) : le découpage lui-même n'a pas
// changé de comportement, il expose en plus la position de chaque page dans le texte complet.
test("chaque page porte des offsets caractère valides dans le texte complet", () => {
const markdown = buildMarkdown(3);
const { pages } = splitMarkdownIntoPages(markdown);

for (const page of pages) {
assert.ok(page.startChar >= 0);
assert.ok(page.endChar > page.startChar);
assert.ok(page.endChar <= markdown.length);
}
// Les pages sont contiguës et dans l'ordre : la fin de l'une est le début de la suivante.
assert.equal(pages[0].endChar, pages[1].startChar);
assert.equal(pages[1].endChar, pages[2].startChar);
});

test("les offsets restent cohérents quand un préambule est rattaché à la première page", () => {
const preamble = "Préambule hors page";
const markdown = `${preamble}\n\n${buildMarkdown(2)}`;
const { pages } = splitMarkdownIntoPages(markdown);

assert.equal(pages[0].startChar, 0);
assert.equal(markdown.slice(pages[1].startChar, pages[1].endChar).trim(), pages[1].markdown);
});

test("un document sans marqueur porte des offsets couvrant tout le texte", () => {
const markdown = " # Cours\n\nTexte sans marqueur. ";
const { pages } = splitMarkdownIntoPages(markdown);

assert.equal(pages[0].startChar, 0);
assert.equal(pages[0].endChar, markdown.length);
});
71 changes: 71 additions & 0 deletions supabase/functions/_shared/markdown-pages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Découpage de la transcription Markdown (document_markdown.markdown) en pages, à partir des
// marqueurs <!-- source-page: N --> écrits par extract-document-markdown (assembleMarkdownFromPages).
//
// Déplacé depuis generate-atomic-knowledge/page-chunks.ts pour être réutilisé par la construction
// de la structure documentaire des blocs d'architecture professorale (_shared/professoral-document-structure.ts),
// qui a besoin des mêmes pages mais aussi de leurs offsets caractère absolus — page-chunks.ts
// continue de fonctionner à l'identique en ré-exportant ce module (voir page-chunks.ts).
//
// Module volontairement pur : aucun import Deno/npm/https, aucune I/O. C'est ce qui permet à
// `node --test` de le charger directement via le type stripping natif et de le tester pour de vrai
// (même parti pris que _shared/markdown-reconciliation.ts).

// Marqueur écrit page par page côté serveur par extract-document-markdown
// (assembleMarkdownFromPages) : c'est la seule source de vérité sur la position absolue d'une page.
export const SOURCE_PAGE_MARKER_PATTERN = /<!--\s*source-page:\s*(\d+)\s*-->/g;

// Même motif, employé uniquement en remplacement : le marqueur est utile au modèle dans le texte de
// la page, mais bruite le contexte documentaire.
export const SOURCE_PAGE_MARKER_STRIP_PATTERN = /<!--\s*source-page:\s*\d+\s*-->/g;

export type MarkdownPage = {
// null uniquement dans le cas dégradé « aucun marqueur trouvé » (transcription produite avant
// l'introduction des marqueurs).
pageNumber: number | null;
markdown: string;
// Offsets absolus dans le Markdown complet passé à splitMarkdownIntoPages : c'est ce qui permet à
// un consommateur (professoral-document-structure.ts) de rattacher une position de section à sa
// page sans reparser les marqueurs lui-même.
startChar: number;
endChar: number;
};

export type MarkdownPageSplit = {
pages: MarkdownPage[];
markersFound: boolean;
};

export function splitMarkdownIntoPages(sourceMarkdown: string): MarkdownPageSplit {
const text = String(sourceMarkdown ?? "");
const matches = [...text.matchAll(SOURCE_PAGE_MARKER_PATTERN)];

if (matches.length === 0) {
const single = text.trim();
return {
pages: single ? [{ pageNumber: null, markdown: single, startChar: 0, endChar: text.length }] : [],
markersFound: false
};
}

const pages: MarkdownPage[] = matches.map((match, index) => {
const start = match.index ?? 0;
const end = index + 1 < matches.length ? (matches[index + 1].index ?? text.length) : text.length;
return {
pageNumber: Number(match[1]),
// Le marqueur est conservé dans le texte envoyé au modèle : c'est lui qui permet au champ
// source_page du schéma de sortie (inchangé) d'être renseigné.
markdown: text.slice(start, end).trim(),
startChar: start,
endChar: end
};
});

// Du contenu avant le premier marqueur ne devrait pas exister, mais s'il en existe il est rattaché
// à la première page plutôt que jeté : aucune portion du document ne doit disparaître.
const preamble = text.slice(0, matches[0].index ?? 0).trim();
if (preamble) {
pages[0] = { ...pages[0], markdown: `${preamble}\n\n${pages[0].markdown}`, startChar: 0 };
}

return { pages, markersFound: true };
}
29 changes: 25 additions & 4 deletions supabase/functions/_shared/professoral-architecture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,19 @@ export const PROFESSORAL_ARCHITECTURE_SOURCE_ROLES = new Set([
"lecture_transcript",
]);

export const PROFESSORAL_ARCHITECTURE_VERSION = "professoral-architecture-v1" as const;
// v2 : les offsets source_char_start/end désignent désormais des positions dans la transcription
// Markdown du document (document_markdown.markdown) et non plus dans le texte brut du PDF
// (analysis_runs.raw_text) — voir professoral-document-structure.ts. Le bump permet de distinguer
// immédiatement les blocs déjà en base (v1, offsets texte brut) des blocs reconstruits après cette
// migration (v2, offsets Markdown), le temps que les documents soient progressivement ré-analysés.
export const PROFESSORAL_ARCHITECTURE_VERSION = "professoral-architecture-v2" as const;

// Enregistré dans extraction_metadata.source_text_kind de chaque bloc : indique explicitement à quel
// texte ses offsets se rapportent, pour qu'un consommateur n'ait jamais à le deviner. Constante plutôt
// que littéral répété : ce module ne produit des blocs qu'à partir du Markdown (voir D3, aucun repli
// vers le texte brut n'est implémenté ici ni dans run-analysis/index.ts).
export const PROFESSORAL_ARCHITECTURE_SOURCE_TEXT_KIND = "document_markdown" as const;

const MAX_PREVIEW_BLOCKS = 12;
const MAX_EXCERPT_CHARS = 900;

Expand Down Expand Up @@ -76,6 +88,11 @@ export function buildProfessoralArchitectureBlocksFromDocumentStructure(input: {
documentId: string;
analysisRunId?: string;
sourceRole: string;
// Depuis D3, l'appelant (run-analysis/index.ts) passe ici la transcription Markdown
// (document_markdown.markdown), jamais le texte brut du PDF : c'est le texte contre lequel
// documentStructure.sections[].start_char/end_char ont été calculés (voir
// professoral-document-structure.ts). Le nom du champ est conservé pour ne pas élargir le diff :
// cette fonction ne fait que découper un texte générique par offsets, quel qu'il soit.
rawText: string;
documentStructure: any;
}): any[] {
Expand Down Expand Up @@ -121,6 +138,9 @@ export function buildProfessoralArchitectureBlocksFromDocumentStructure(input: {
source: input.documentStructure?.source || "document_structure",
document_structure_version: input.documentStructure?.version || null,
source_section_id: section?.id || null,
// De quel texte source_char_start/end relèvent : toujours le Markdown depuis D3, jamais le
// texte brut (voir le commentaire sur PROFESSORAL_ARCHITECTURE_SOURCE_TEXT_KIND ci-dessus).
source_text_kind: PROFESSORAL_ARCHITECTURE_SOURCE_TEXT_KIND,
},
};
}).sort((a, b) => a.block_index - b.block_index);
Expand Down Expand Up @@ -156,12 +176,13 @@ export async function replaceProfessoralArchitectureBlocksForDocument(supabase:
professoralArchitectureCourseScopeFilteredCount: number;
professoralArchitectureLogisticsOutOfScopeCount: number;
professoralArchitectureSourcePageCoverage: number;
professoralArchitectureVersion: "professoral-architecture-v1";
professoralArchitectureVersion: typeof PROFESSORAL_ARCHITECTURE_VERSION;
professoralArchitectureSourceTextKind: typeof PROFESSORAL_ARCHITECTURE_SOURCE_TEXT_KIND;
professoralArchitectureWarnings: string[];
professoralArchitecturePreview: any[];
}> {
if (!isProfessoralArchitectureSourceRole(input?.sourceRole)) {
return { professoralArchitectureApplied: false, professoralArchitectureStatus: "skipped_non_professoral_source", professoralArchitectureBlockCount: 0, professoralArchitectureInScopeBlockCount: 0, professoralArchitectureOutOfScopeBlockCount: 0, professoralArchitectureCourseScopeFilteredCount: 0, professoralArchitectureLogisticsOutOfScopeCount: 0, professoralArchitectureSourcePageCoverage: 0, professoralArchitectureVersion: PROFESSORAL_ARCHITECTURE_VERSION, professoralArchitectureWarnings: [], professoralArchitecturePreview: [] };
return { professoralArchitectureApplied: false, professoralArchitectureStatus: "skipped_non_professoral_source", professoralArchitectureBlockCount: 0, professoralArchitectureInScopeBlockCount: 0, professoralArchitectureOutOfScopeBlockCount: 0, professoralArchitectureCourseScopeFilteredCount: 0, professoralArchitectureLogisticsOutOfScopeCount: 0, professoralArchitectureSourcePageCoverage: 0, professoralArchitectureVersion: PROFESSORAL_ARCHITECTURE_VERSION, professoralArchitectureSourceTextKind: PROFESSORAL_ARCHITECTURE_SOURCE_TEXT_KIND, professoralArchitectureWarnings: [], professoralArchitecturePreview: [] };
}
const blocks = buildProfessoralArchitectureBlocksFromDocumentStructure(input);
const warnings = Array.isArray(input.documentStructure?.warnings) ? [...input.documentStructure.warnings] : [];
Expand All @@ -176,5 +197,5 @@ export async function replaceProfessoralArchitectureBlocksForDocument(supabase:
const out = blocks.filter((block) => block.is_out_of_scope).length;
const logisticsOut = blocks.filter((block) => block.out_of_scope_reason === "course_logistics_or_activity").length;
const pageTagged = blocks.filter((block) => Number.isInteger(block.source_page_start)).length;
return { professoralArchitectureApplied: true, professoralArchitectureStatus: "applied", professoralArchitectureBlockCount: blocks.length, professoralArchitectureInScopeBlockCount: blocks.length - out, professoralArchitectureOutOfScopeBlockCount: out, professoralArchitectureCourseScopeFilteredCount: out, professoralArchitectureLogisticsOutOfScopeCount: logisticsOut, professoralArchitectureSourcePageCoverage: blocks.length ? pageTagged / blocks.length : 0, professoralArchitectureVersion: PROFESSORAL_ARCHITECTURE_VERSION, professoralArchitectureWarnings: warnings, professoralArchitecturePreview: previewBlocks(blocks) };
return { professoralArchitectureApplied: true, professoralArchitectureStatus: "applied", professoralArchitectureBlockCount: blocks.length, professoralArchitectureInScopeBlockCount: blocks.length - out, professoralArchitectureOutOfScopeBlockCount: out, professoralArchitectureCourseScopeFilteredCount: out, professoralArchitectureLogisticsOutOfScopeCount: logisticsOut, professoralArchitectureSourcePageCoverage: blocks.length ? pageTagged / blocks.length : 0, professoralArchitectureVersion: PROFESSORAL_ARCHITECTURE_VERSION, professoralArchitectureSourceTextKind: PROFESSORAL_ARCHITECTURE_SOURCE_TEXT_KIND, professoralArchitectureWarnings: warnings, professoralArchitecturePreview: previewBlocks(blocks) };
}
Loading
Loading