src / llmAgent.ts
src / llmAgent.ts
// The agentic core. The plugin (not the host model) drives a loaded chat LLM
// through the AGENTS.md workflows: extract → compose (ingest), synthesise
// (query), review (lint). All LLM calls demand strict JSON we parse defensively.
import type { LMStudioClient, LLM } from "@lmstudio/sdk";
export interface PickedChat {
model: LLM;
identifier: string;
}
export async function pickChatModel(
client: LMStudioClient,
override: string,
): Promise<PickedChat | { error: string }> {
if (override && override.trim()) {
try {
const model = await client.llm.model(override.trim());
return { model, identifier: override.trim() };
} catch (e: unknown) {
return {
error:
`Failed to access the configured chat model "${override}". ` +
`Check the identifier or load it in LM Studio. ` +
`(${e instanceof Error ? e.message : String(e)})`,
};
}
}
const loaded = await client.llm.listLoaded();
if (loaded.length === 0) {
return { error: "No chat LLM is currently loaded in LM Studio. Load one and try again." };
}
// Prefer a non-vision text model, but fall back to whatever is loaded.
const text = loaded.find((m) => m.vision !== true) ?? loaded[0];
let identifier = "(loaded LLM)";
try {
const info = await text.getModelInfo();
identifier = info?.identifier ?? info?.modelKey ?? identifier;
} catch {
/* keep fallback */
}
return { model: text, identifier };
}
const JSON_RULES =
"Respond with ONE valid JSON object and NOTHING else — no prose, no markdown " +
"fences, no comments. Use double quotes. Do not invent facts or citations; if " +
"the source does not support a claim, omit it.";
function extractJson(raw: string): unknown {
let s = raw.trim();
// strip ```json ... ``` fences if present
const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fence) s = fence[1].trim();
// fall back to the outermost { ... }
if (!s.startsWith("{")) {
const first = s.indexOf("{");
const last = s.lastIndexOf("}");
if (first >= 0 && last > first) s = s.slice(first, last + 1);
}
return JSON.parse(s);
}
export interface RunJsonArgs {
model: LLM;
system: string;
user: string;
abortSignal: AbortSignal;
maxTokens?: number;
/** JSON schema constraining the output. Strongly recommended — it forces the
* model to emit valid, conformant JSON (grammar-constrained decoding). */
jsonSchema?: unknown;
}
/** Call the model and parse strict JSON, with one corrective retry. */
export async function runJson<T>(args: RunJsonArgs): Promise<T> {
const structured = args.jsonSchema
? { type: "json" as const, jsonSchema: args.jsonSchema }
: { type: "json" as const };
const call = async (extra: string): Promise<string> => {
const res = await args.model.respond(
[
{ role: "system", content: `${args.system}\n\n${JSON_RULES}${extra}` },
{ role: "user", content: args.user },
],
{ temperature: 0.2, maxTokens: args.maxTokens ?? 4096, signal: args.abortSignal, structured },
);
return res.content;
};
let last = "";
try {
last = await call("");
return extractJson(last) as T;
} catch (e1) {
// one retry, nudging harder
try {
last = await call("\nYour previous output was not valid JSON. Output ONLY the JSON object.");
return extractJson(last) as T;
} catch (e2) {
const snippet = last.slice(0, 400).replace(/\s+/g, " ").trim();
throw new Error(
`The chat model did not return valid JSON for this step. This usually means the loaded ` +
`model is too small or does not support structured output — try a larger, tool/JSON-capable ` +
`instruct model. Last output (truncated): "${snippet}"`,
);
}
}
}
// ---------------------------------------------------------------------------
// INGEST — step 1: extract the entities/concepts the source touches
// ---------------------------------------------------------------------------
export interface ExtractResult {
entities: Array<{ slug: string; name: string }>;
concepts: Array<{ slug: string; name: string }>;
}
const slugNameArray = {
type: "array",
items: {
type: "object",
properties: { slug: { type: "string" }, name: { type: "string" } },
required: ["slug", "name"],
},
} as const;
export const EXTRACT_SCHEMA = {
type: "object",
properties: { entities: slugNameArray, concepts: slugNameArray },
required: ["entities", "concepts"],
} as const;
export function buildExtractPrompt(
schema: string,
sourceTitle: string,
sourceText: string,
): { system: string; user: string; jsonSchema: unknown } {
const built = {
system:
"Tu es un mainteneur de wiki discipliné (voir le schéma ci-dessous). " +
"Lis la source et liste UNIQUEMENT les entités (personnes, lieux, orgs, produits) " +
"et les concepts/thèmes qui méritent une page de wiki. Donne un slug kebab-case " +
"sans accent pour chacun.\n\n# Schéma (extrait)\n" +
truncate(schema, 2500) +
'\n\nFormat JSON attendu : {"entities":[{"slug":"","name":""}],"concepts":[{"slug":"","name":""}]}',
user: `# Source : ${sourceTitle}\n\n${truncate(sourceText, 12000)}`,
};
return { ...built, jsonSchema: EXTRACT_SCHEMA };
}
// ---------------------------------------------------------------------------
// INGEST — step 2: compose the source page + create/merge entity/concept pages
// ---------------------------------------------------------------------------
export interface ComposedPage {
path: string; // relative to wiki/, e.g. "entities/marie-curie.md"
action: "create" | "update";
markdown: string; // FULL page content (frontmatter + body)
reason?: string;
}
export interface ComposeResult {
source_page: { path: string; markdown: string };
pages: ComposedPage[];
contradictions: string[];
log_bullets: string[];
notes: string[];
}
export interface ExistingPage {
path: string; // relative to wiki/
content: string;
}
const stringArray = { type: "array", items: { type: "string" } } as const;
export const COMPOSE_SCHEMA = {
type: "object",
properties: {
source_page: {
type: "object",
properties: { path: { type: "string" }, markdown: { type: "string" } },
required: ["path", "markdown"],
},
pages: {
type: "array",
items: {
type: "object",
properties: {
path: { type: "string" },
action: { type: "string" },
markdown: { type: "string" },
reason: { type: "string" },
},
required: ["path", "action", "markdown"],
},
},
contradictions: stringArray,
log_bullets: stringArray,
notes: stringArray,
},
required: ["source_page", "pages"],
} as const;
export function buildComposePrompt(
schema: string,
sourceFileRel: string,
sourceTitle: string,
sourceText: string,
indexHead: string,
existingPages: ExistingPage[],
today: string,
): { system: string; user: string; jsonSchema: unknown } {
const existingBlock = existingPages.length
? existingPages
.map((p) => `### Page existante : ${p.path}\n\n${truncate(p.content, 3000)}`)
.join("\n\n")
: "(aucune page existante Ă fusionner)";
const system =
"Tu es un mainteneur de wiki discipliné. Tu appliques STRICTEMENT le schéma ci-dessous " +
"(frontmatter YAML, wikilinks [[slug]], une page par entité/concept, citations, " +
"signaler les contradictions sans écraser). Tu produis le contenu COMPLET de chaque page.\n\n" +
"Règles de fusion : pour une page existante fournie, RENVOIE la page entière mise à jour " +
"(intègre les nouveaux faits, ajoute le slug de la source à `sources:`, mets à jour " +
"`last_updated`, ajoute les wikilinks utiles) — ne supprime pas l'existant. " +
"Pour une nouvelle page, crée-la au format du schéma.\n\n" +
"IMPÉRATIF — frontmatter YAML VALIDE : les listes s'écrivent en style flow sur UNE ligne, " +
"ex. `sources: [source-a, source-b]`, `tags: [x, y]`, `aliases: []` (JAMAIS `sources: - a`). " +
"Mets les valeurs texte entre guillemets si elles contiennent `:` ou des accents. " +
"Les dates au format AAAA-MM-JJ. Le bloc frontmatter est délimité par `---`.\n\n# Schéma\n" +
truncate(schema, 4000) +
"\n\n# Format JSON attendu\n" +
'{"source_page":{"path":"sources/<slug>.md","markdown":"..."},' +
'"pages":[{"path":"entities/<slug>.md","action":"create|update","markdown":"...","reason":""}],' +
'"contradictions":["..."],"log_bullets":["..."],"notes":["..."]}';
const user =
`Date du jour : ${today}\n` +
`Fichier source brut : raw/${sourceFileRel}\n` +
`Titre : ${sourceTitle}\n\n` +
`# Catalogue actuel (index.md, extrait)\n${truncate(indexHead, 2500)}\n\n` +
`# Pages existantes Ă fusionner\n${existingBlock}\n\n` +
`# Contenu de la source\n${truncate(sourceText, 14000)}`;
return { system, user, jsonSchema: COMPOSE_SCHEMA };
}
// ---------------------------------------------------------------------------
// QUERY — synthesise a cited answer from retrieved passages
// ---------------------------------------------------------------------------
export interface QueryResult {
answer_markdown: string;
file_as_analysis: boolean;
analysis?: { slug: string; title: string; markdown: string };
log_note: string;
}
export interface RetrievedPassage {
wikilink: string;
headingPath: string[];
snippet: string;
}
export const QUERY_SCHEMA = {
type: "object",
properties: {
answer_markdown: { type: "string" },
file_as_analysis: { type: "boolean" },
analysis: {
type: "object",
properties: { slug: { type: "string" }, title: { type: "string" }, markdown: { type: "string" } },
},
log_note: { type: "string" },
},
required: ["answer_markdown", "file_as_analysis"],
} as const;
export function buildQueryPrompt(
schema: string,
question: string,
passages: RetrievedPassage[],
today: string,
): { system: string; user: string; jsonSchema: unknown } {
const ctx = passages.length
? passages
.map(
(p, i) =>
`[${i + 1}] [[${p.wikilink}]]${p.headingPath.length ? " › " + p.headingPath.join(" › ") : ""}\n${p.snippet}`,
)
.join("\n\n")
: "(aucun passage trouvé dans le wiki)";
const system =
"Tu réponds à une question en t'appuyant UNIQUEMENT sur les passages du wiki fournis. " +
"Chaque affirmation doit être citée avec le wikilink [[...]] de sa source. Si les passages " +
"ne suffisent pas, dis-le explicitement (ne fabrique rien). Si la réponse a une valeur " +
"durable (comparaison, synthèse, connexion), propose de la classer comme page d'analyse " +
"(file_as_analysis=true) au format du schéma.\n\n# Schéma (extrait)\n" +
truncate(schema, 2000) +
"\n\n# Format JSON attendu\n" +
'{"answer_markdown":"...","file_as_analysis":false,' +
'"analysis":{"slug":"","title":"","markdown":""},"log_note":"..."}';
const user = `Date : ${today}\n\n# Question\n${question}\n\n# Passages du wiki (RAG)\n${ctx}`;
return { system, user, jsonSchema: QUERY_SCHEMA };
}
// ---------------------------------------------------------------------------
// LINT — find contradictions, stale claims, gaps, suggestions
// ---------------------------------------------------------------------------
export interface LintResult {
findings: Array<{ type: string; severity: "info" | "warn" | "high"; page?: string; detail: string }>;
suggestions: string[];
}
export const LINT_SCHEMA = {
type: "object",
properties: {
findings: {
type: "array",
items: {
type: "object",
properties: {
type: { type: "string" },
severity: { type: "string" },
page: { type: "string" },
detail: { type: "string" },
},
required: ["type", "detail"],
},
},
suggestions: { type: "array", items: { type: "string" } },
},
required: ["findings", "suggestions"],
} as const;
export function buildLintPrompt(
schema: string,
pageSummaries: Array<{ path: string; title: string; outbound: string[]; inbound: number }>,
structuralNotes: string[],
): { system: string; user: string; jsonSchema: unknown } {
const pages = pageSummaries
.map(
(p) =>
`- ${p.path} — « ${p.title} » | liens sortants: ${p.outbound.length} | liens entrants: ${p.inbound}`,
)
.join("\n");
const system =
"Tu fais un contrôle de santé du wiki. À partir du catalogue et des notes structurelles " +
"déjà calculées, identifie : contradictions entre pages, affirmations vraisemblablement " +
"périmées, concepts importants sans page dédiée, références croisées manquantes, lacunes " +
"de données. Propose aussi des questions à explorer et des sources à chercher. Sois concret " +
"et bref.\n\n# Schéma (extrait)\n" +
truncate(schema, 1500) +
"\n\n# Format JSON attendu\n" +
'{"findings":[{"type":"","severity":"info|warn|high","page":"","detail":""}],"suggestions":["..."]}';
const user =
`# Catalogue des pages\n${pages || "(wiki vide)"}\n\n` +
`# Notes structurelles déjà détectées\n${structuralNotes.length ? structuralNotes.map((n) => `- ${n}`).join("\n") : "(aucune)"}`;
return { system, user, jsonSchema: LINT_SCHEMA };
}
// ---------------------------------------------------------------------------
function truncate(s: string, max: number): string {
if (s.length <= max) return s;
return s.slice(0, max) + `\n…[tronqué ${s.length - max} caractères]`;
}
// The agentic core. The plugin (not the host model) drives a loaded chat LLM
// through the AGENTS.md workflows: extract → compose (ingest), synthesise
// (query), review (lint). All LLM calls demand strict JSON we parse defensively.
import type { LMStudioClient, LLM } from "@lmstudio/sdk";
export interface PickedChat {
model: LLM;
identifier: string;
}
export async function pickChatModel(
client: LMStudioClient,
override: string,
): Promise<PickedChat | { error: string }> {
if (override && override.trim()) {
try {
const model = await client.llm.model(override.trim());
return { model, identifier: override.trim() };
} catch (e: unknown) {
return {
error:
`Failed to access the configured chat model "${override}". ` +
`Check the identifier or load it in LM Studio. ` +
`(${e instanceof Error ? e.message : String(e)})`,
};
}
}
const loaded = await client.llm.listLoaded();
if (loaded.length === 0) {
return { error: "No chat LLM is currently loaded in LM Studio. Load one and try again." };
}
// Prefer a non-vision text model, but fall back to whatever is loaded.
const text = loaded.find((m) => m.vision !== true) ?? loaded[0];
let identifier = "(loaded LLM)";
try {
const info = await text.getModelInfo();
identifier = info?.identifier ?? info?.modelKey ?? identifier;
} catch {
/* keep fallback */
}
return { model: text, identifier };
}
const JSON_RULES =
"Respond with ONE valid JSON object and NOTHING else — no prose, no markdown " +
"fences, no comments. Use double quotes. Do not invent facts or citations; if " +
"the source does not support a claim, omit it.";
function extractJson(raw: string): unknown {
let s = raw.trim();
// strip ```json ... ``` fences if present
const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fence) s = fence[1].trim();
// fall back to the outermost { ... }
if (!s.startsWith("{")) {
const first = s.indexOf("{");
const last = s.lastIndexOf("}");
if (first >= 0 && last > first) s = s.slice(first, last + 1);
}
return JSON.parse(s);
}
export interface RunJsonArgs {
model: LLM;
system: string;
user: string;
abortSignal: AbortSignal;
maxTokens?: number;
/** JSON schema constraining the output. Strongly recommended — it forces the
* model to emit valid, conformant JSON (grammar-constrained decoding). */
jsonSchema?: unknown;
}
/** Call the model and parse strict JSON, with one corrective retry. */
export async function runJson<T>(args: RunJsonArgs): Promise<T> {
const structured = args.jsonSchema
? { type: "json" as const, jsonSchema: args.jsonSchema }
: { type: "json" as const };
const call = async (extra: string): Promise<string> => {
const res = await args.model.respond(
[
{ role: "system", content: `${args.system}\n\n${JSON_RULES}${extra}` },
{ role: "user", content: args.user },
],
{ temperature: 0.2, maxTokens: args.maxTokens ?? 4096, signal: args.abortSignal, structured },
);
return res.content;
};
let last = "";
try {
last = await call("");
return extractJson(last) as T;
} catch (e1) {
// one retry, nudging harder
try {
last = await call("\nYour previous output was not valid JSON. Output ONLY the JSON object.");
return extractJson(last) as T;
} catch (e2) {
const snippet = last.slice(0, 400).replace(/\s+/g, " ").trim();
throw new Error(
`The chat model did not return valid JSON for this step. This usually means the loaded ` +
`model is too small or does not support structured output — try a larger, tool/JSON-capable ` +
`instruct model. Last output (truncated): "${snippet}"`,
);
}
}
}
// ---------------------------------------------------------------------------
// INGEST — step 1: extract the entities/concepts the source touches
// ---------------------------------------------------------------------------
export interface ExtractResult {
entities: Array<{ slug: string; name: string }>;
concepts: Array<{ slug: string; name: string }>;
}
const slugNameArray = {
type: "array",
items: {
type: "object",
properties: { slug: { type: "string" }, name: { type: "string" } },
required: ["slug", "name"],
},
} as const;
export const EXTRACT_SCHEMA = {
type: "object",
properties: { entities: slugNameArray, concepts: slugNameArray },
required: ["entities", "concepts"],
} as const;
export function buildExtractPrompt(
schema: string,
sourceTitle: string,
sourceText: string,
): { system: string; user: string; jsonSchema: unknown } {
const built = {
system:
"Tu es un mainteneur de wiki discipliné (voir le schéma ci-dessous). " +
"Lis la source et liste UNIQUEMENT les entités (personnes, lieux, orgs, produits) " +
"et les concepts/thèmes qui méritent une page de wiki. Donne un slug kebab-case " +
"sans accent pour chacun.\n\n# Schéma (extrait)\n" +
truncate(schema, 2500) +
'\n\nFormat JSON attendu : {"entities":[{"slug":"","name":""}],"concepts":[{"slug":"","name":""}]}',
user: `# Source : ${sourceTitle}\n\n${truncate(sourceText, 12000)}`,
};
return { ...built, jsonSchema: EXTRACT_SCHEMA };
}
// ---------------------------------------------------------------------------
// INGEST — step 2: compose the source page + create/merge entity/concept pages
// ---------------------------------------------------------------------------
export interface ComposedPage {
path: string; // relative to wiki/, e.g. "entities/marie-curie.md"
action: "create" | "update";
markdown: string; // FULL page content (frontmatter + body)
reason?: string;
}
export interface ComposeResult {
source_page: { path: string; markdown: string };
pages: ComposedPage[];
contradictions: string[];
log_bullets: string[];
notes: string[];
}
export interface ExistingPage {
path: string; // relative to wiki/
content: string;
}
const stringArray = { type: "array", items: { type: "string" } } as const;
export const COMPOSE_SCHEMA = {
type: "object",
properties: {
source_page: {
type: "object",
properties: { path: { type: "string" }, markdown: { type: "string" } },
required: ["path", "markdown"],
},
pages: {
type: "array",
items: {
type: "object",
properties: {
path: { type: "string" },
action: { type: "string" },
markdown: { type: "string" },
reason: { type: "string" },
},
required: ["path", "action", "markdown"],
},
},
contradictions: stringArray,
log_bullets: stringArray,
notes: stringArray,
},
required: ["source_page", "pages"],
} as const;
export function buildComposePrompt(
schema: string,
sourceFileRel: string,
sourceTitle: string,
sourceText: string,
indexHead: string,
existingPages: ExistingPage[],
today: string,
): { system: string; user: string; jsonSchema: unknown } {
const existingBlock = existingPages.length
? existingPages
.map((p) => `### Page existante : ${p.path}\n\n${truncate(p.content, 3000)}`)
.join("\n\n")
: "(aucune page existante Ă fusionner)";
const system =
"Tu es un mainteneur de wiki discipliné. Tu appliques STRICTEMENT le schéma ci-dessous " +
"(frontmatter YAML, wikilinks [[slug]], une page par entité/concept, citations, " +
"signaler les contradictions sans écraser). Tu produis le contenu COMPLET de chaque page.\n\n" +
"Règles de fusion : pour une page existante fournie, RENVOIE la page entière mise à jour " +
"(intègre les nouveaux faits, ajoute le slug de la source à `sources:`, mets à jour " +
"`last_updated`, ajoute les wikilinks utiles) — ne supprime pas l'existant. " +
"Pour une nouvelle page, crée-la au format du schéma.\n\n" +
"IMPÉRATIF — frontmatter YAML VALIDE : les listes s'écrivent en style flow sur UNE ligne, " +
"ex. `sources: [source-a, source-b]`, `tags: [x, y]`, `aliases: []` (JAMAIS `sources: - a`). " +
"Mets les valeurs texte entre guillemets si elles contiennent `:` ou des accents. " +
"Les dates au format AAAA-MM-JJ. Le bloc frontmatter est délimité par `---`.\n\n# Schéma\n" +
truncate(schema, 4000) +
"\n\n# Format JSON attendu\n" +
'{"source_page":{"path":"sources/<slug>.md","markdown":"..."},' +
'"pages":[{"path":"entities/<slug>.md","action":"create|update","markdown":"...","reason":""}],' +
'"contradictions":["..."],"log_bullets":["..."],"notes":["..."]}';
const user =
`Date du jour : ${today}\n` +
`Fichier source brut : raw/${sourceFileRel}\n` +
`Titre : ${sourceTitle}\n\n` +
`# Catalogue actuel (index.md, extrait)\n${truncate(indexHead, 2500)}\n\n` +
`# Pages existantes Ă fusionner\n${existingBlock}\n\n` +
`# Contenu de la source\n${truncate(sourceText, 14000)}`;
return { system, user, jsonSchema: COMPOSE_SCHEMA };
}
// ---------------------------------------------------------------------------
// QUERY — synthesise a cited answer from retrieved passages
// ---------------------------------------------------------------------------
export interface QueryResult {
answer_markdown: string;
file_as_analysis: boolean;
analysis?: { slug: string; title: string; markdown: string };
log_note: string;
}
export interface RetrievedPassage {
wikilink: string;
headingPath: string[];
snippet: string;
}
export const QUERY_SCHEMA = {
type: "object",
properties: {
answer_markdown: { type: "string" },
file_as_analysis: { type: "boolean" },
analysis: {
type: "object",
properties: { slug: { type: "string" }, title: { type: "string" }, markdown: { type: "string" } },
},
log_note: { type: "string" },
},
required: ["answer_markdown", "file_as_analysis"],
} as const;
export function buildQueryPrompt(
schema: string,
question: string,
passages: RetrievedPassage[],
today: string,
): { system: string; user: string; jsonSchema: unknown } {
const ctx = passages.length
? passages
.map(
(p, i) =>
`[${i + 1}] [[${p.wikilink}]]${p.headingPath.length ? " › " + p.headingPath.join(" › ") : ""}\n${p.snippet}`,
)
.join("\n\n")
: "(aucun passage trouvé dans le wiki)";
const system =
"Tu réponds à une question en t'appuyant UNIQUEMENT sur les passages du wiki fournis. " +
"Chaque affirmation doit être citée avec le wikilink [[...]] de sa source. Si les passages " +
"ne suffisent pas, dis-le explicitement (ne fabrique rien). Si la réponse a une valeur " +
"durable (comparaison, synthèse, connexion), propose de la classer comme page d'analyse " +
"(file_as_analysis=true) au format du schéma.\n\n# Schéma (extrait)\n" +
truncate(schema, 2000) +
"\n\n# Format JSON attendu\n" +
'{"answer_markdown":"...","file_as_analysis":false,' +
'"analysis":{"slug":"","title":"","markdown":""},"log_note":"..."}';
const user = `Date : ${today}\n\n# Question\n${question}\n\n# Passages du wiki (RAG)\n${ctx}`;
return { system, user, jsonSchema: QUERY_SCHEMA };
}
// ---------------------------------------------------------------------------
// LINT — find contradictions, stale claims, gaps, suggestions
// ---------------------------------------------------------------------------
export interface LintResult {
findings: Array<{ type: string; severity: "info" | "warn" | "high"; page?: string; detail: string }>;
suggestions: string[];
}
export const LINT_SCHEMA = {
type: "object",
properties: {
findings: {
type: "array",
items: {
type: "object",
properties: {
type: { type: "string" },
severity: { type: "string" },
page: { type: "string" },
detail: { type: "string" },
},
required: ["type", "detail"],
},
},
suggestions: { type: "array", items: { type: "string" } },
},
required: ["findings", "suggestions"],
} as const;
export function buildLintPrompt(
schema: string,
pageSummaries: Array<{ path: string; title: string; outbound: string[]; inbound: number }>,
structuralNotes: string[],
): { system: string; user: string; jsonSchema: unknown } {
const pages = pageSummaries
.map(
(p) =>
`- ${p.path} — « ${p.title} » | liens sortants: ${p.outbound.length} | liens entrants: ${p.inbound}`,
)
.join("\n");
const system =
"Tu fais un contrôle de santé du wiki. À partir du catalogue et des notes structurelles " +
"déjà calculées, identifie : contradictions entre pages, affirmations vraisemblablement " +
"périmées, concepts importants sans page dédiée, références croisées manquantes, lacunes " +
"de données. Propose aussi des questions à explorer et des sources à chercher. Sois concret " +
"et bref.\n\n# Schéma (extrait)\n" +
truncate(schema, 1500) +
"\n\n# Format JSON attendu\n" +
'{"findings":[{"type":"","severity":"info|warn|high","page":"","detail":""}],"suggestions":["..."]}';
const user =
`# Catalogue des pages\n${pages || "(wiki vide)"}\n\n` +
`# Notes structurelles déjà détectées\n${structuralNotes.length ? structuralNotes.map((n) => `- ${n}`).join("\n") : "(aucune)"}`;
return { system, user, jsonSchema: LINT_SCHEMA };
}
// ---------------------------------------------------------------------------
function truncate(s: string, max: number): string {
if (s.length <= max) return s;
return s.slice(0, max) + `\n…[tronqué ${s.length - max} caractères]`;
}