Compare commits

..

No commits in common. "a0e59442f4dbbba07ed3e068c077e3ea8d655b20" and "1267d60cf2adf4811d9b4dfe4f6d21c70cd88c97" have entirely different histories.

26 changed files with 242 additions and 1878 deletions

View File

@ -1,19 +0,0 @@
# Copie ce fichier en .env.local et remplis chaque variable.
# Ne committe JAMAIS .env.local (il est déjà dans .gitignore).
# URL de l'API Strapi (CMS) utilisée par le front pour homepage / portfolio / competences.
NEXT_PUBLIC_API_URL=http://localhost:1337
# --- Brevo (formulaire de contact) ---------------------------------
# 1. Créer une clé API dédiée : https://app.brevo.com/settings/keys/api
BREVO_API_KEY=xkeysib-...
# 2. Email d'un expéditeur VÉRIFIÉ dans Brevo (pastille verte SPF/DKIM).
# Réutilisation de l'expéditeur newsletter OK.
CONTACT_FROM_EMAIL=contact@exemple.com
# Nom qui s'affichera comme expéditeur dans la boîte de réception.
CONTACT_FROM_NAME=Portfolio — nouveau message
# 3. Destinataire : ta vraie boîte mail, où tu veux recevoir les notifications.
CONTACT_TO_EMAIL=toi@exemple.com
CONTACT_TO_NAME=Ton Nom

5
.gitignore vendored
View File

@ -32,11 +32,6 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
llm-api/.env
llm-api/.env.local
# Templates documentaires : forcer l'inclusion (ils contiennent des placeholders).
!.env.example
!llm-api/.env.example
# vercel
.vercel

View File

@ -14,16 +14,11 @@ Ce site utilise une architecture full-stack moderne avec :
my-next-site/
├── app/ # Application Next.js
├── cmsbackend/ # Backend Strapi
├── llm-api/ # API FastAPI pour IA (+ instrumentation Langfuse)
│ ├── .env # Secrets Python (Langfuse, etc.) — non committé
│ └── observability.py # Init client Langfuse (no-op safe)
├── llm-api/ # API FastAPI pour IA
├── start-my-site.ps1 # Script de démarrage
├── stop-my-site.ps1 # Script d'arrêt propre
└── package.json # Dépendances frontend
```
**Observabilité** : le chatbot GrasBot est tracé dans une instance **Langfuse self-hosted** (`langfuse.fernandgrascalvet.com`). Chaque question déclenche une trace `ask` avec spans `retrieval` / `prompt_build` / `ollama-chat`, plus des scores auto (`grounded`, `retrieval_relevance`) et des tags. Voir `docs-site-interne/langfuse-observability.md` pour le détail.
## 🚀 Démarrage Rapide
### Script Automatique (Recommandé)
@ -34,23 +29,6 @@ cd J:\my-next-site
Ce script lance automatiquement les 3 services dans des fenêtres PowerShell séparées.
**Améliorations :**
- Configuration centralisée via un tableau `$services` (plus de duplication entre les 3 blocs).
- **Détection du port déjà occupé** : si un service tourne déjà, il n'est pas relancé (évite `EADDRINUSE`).
- **Portabilité** : chemins résolus via `$PSScriptRoot`, pas de `J:\my-next-site` codé en dur.
- **`-NoExit`** sur chaque fenêtre : le message d'erreur reste visible si un service crashe au démarrage.
- **Bilan final** : nombre de services lancés / déjà actifs / échecs.
### Arrêt des services
```powershell
cd J:\my-next-site
.\stop-my-site.ps1
```
Termine les processus qui écoutent les ports **1337** (Strapi), **3000** (Next.js) et **8000** (FastAPI). Ne touche pas aux autres processus Node de ta machine. Les fenêtres PowerShell lancées par `start-my-site.ps1` restent ouvertes (à fermer manuellement). En cas d'échec sur certains PIDs → relancer dans un PowerShell admin.
> Note encodage : les deux scripts PowerShell sont encodés en **UTF-8 avec BOM**. C'est nécessaire pour que Windows PowerShell 5.1 (version par défaut) les lise correctement avec les emojis et accents. PowerShell 7 n'a pas ce souci mais reste compatible avec le BOM.
## 🔧 Commandes Manuelles
### 1. Frontend Next.js
@ -79,10 +57,6 @@ uvicorn api:app --host 0.0.0.0 --port 8000 --reload
- **API** : http://localhost:8000
- **Endpoint IA** : http://localhost:8000/ask?q=votre_question
- **Documentation** : http://localhost:8000/docs
- **Santé** : http://localhost:8000/health — renvoie `status`, `ollama_url`, `llm_model`, métadonnées vault, et `observability.langfuse_enabled`.
- **Recharger le vault à chaud** : `POST http://localhost:8000/reload-vault` — à appeler après création/modification d'une note dans `vault-grasbot/` (sinon `load_vault()` reste en cache mémoïsé jusqu'au prochain redémarrage d'uvicorn).
> **Tuning du pipeline LLM** : les paramètres Ollama (`num_ctx`, `num_predict`, `think`), la troncature des sources RAG secondaires (`SEARCH_SECONDARY_MAX_CHARS`, `SEARCH_SECONDARY_KEEP_RATIO`), le system prompt anti-hallucination et la note `bio-fernand` sont documentés en détail dans `docs-site-interne/langfuse-observability.md` (section *Tuning du pipeline — 2026-04-23*).
## 📊 Ports Utilisés

View File

@ -0,0 +1,27 @@
import { getApiUrl } from "../../utils/getApiUrl";
export default async function MessagesPage() {
const apiUrl = getApiUrl();
const res = await fetch(`${apiUrl}/api/messages`);
const { data } = await res.json();
return (
<div className="bg-white/70 rounded-md max-w-3xl mx-auto p-6">
{/* Titre de la page */}
<h1 className="text-3xl font-bold text-center mb-6">📬 Messages reçus</h1>
{data.length === 0 ? (
<p className="text-center text-gray-600">Aucun message reçu.</p>
) : (
<ul className="space-y-4">
{data.map((msg: any) => (
<li key={msg.id} className="p-4 border rounded shadow">
<p><strong>👤 {msg.name}</strong> ({msg.email})</p>
<p>📅 {new Date(msg.createdAt).toLocaleString("fr-FR")}</p>
<p className="mt-2">{msg.message}</p>
</li>
))}
</ul>
)}
</div>
);
}

View File

@ -1,291 +0,0 @@
/**
* Endpoint POST /api/contact reçoit les messages du formulaire de contact
* et envoie une notification par email via l'API HTTP Brevo.
*
* Flux :
* Navigateur POST /api/contact (cette route, serveur)
* validation + honeypot + rate-limit
* POST https://api.brevo.com/v3/smtp/email
*
* Gmail : CONTACT_TO_EMAIL
*
* Aucun stockage côté Strapi (voir docs-site-interne/contact-flow.md pour la
* décision d'architecture). Si l'envoi Brevo échoue, on renvoie une erreur 502
* au client pour qu'il puisse -essayer.
*
* Variables d'environnement requises (voir .env.example) :
* - BREVO_API_KEY
* - CONTACT_FROM_EMAIL (doit être un expéditeur vérifié dans Brevo)
* - CONTACT_FROM_NAME
* - CONTACT_TO_EMAIL
* - CONTACT_TO_NAME
*/
import { NextResponse, type NextRequest } from "next/server";
// Force Node runtime : `fetch` est dispo sur l'edge aussi, mais on veut la
// stdlib Node pour les logs serveur et parce que Brevo peut mettre >1s à
// répondre (edge a une limite de timeout plus stricte sur Vercel free).
export const runtime = "nodejs";
// Map en mémoire pour le rate-limit. `Map<ip, number[]>` où number[] est la
// liste des timestamps d'envois récents. On nettoie à la volée. Suffisant pour
// un portfolio — pas besoin de Redis tant qu'on reste sur une instance.
// Note : en mode serverless (plusieurs lambdas), cette map n'est pas partagée,
// donc le rate-limit est "best effort". Pour du ship prod sous charge, passer
// sur Redis ou Upstash.
const rateLimitStore = new Map<string, number[]>();
const RATE_LIMIT_MAX = 3; // 3 envois max
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000; // par tranche de 10 minutes
const MAX_NAME_LENGTH = 120;
const MAX_EMAIL_LENGTH = 160;
const MAX_MESSAGE_LENGTH = 5000;
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
type ContactPayload = {
name?: string;
email?: string;
message?: string;
// Champ honeypot : rempli = bot. Ne jamais loguer son contenu.
website?: string;
};
function getClientIp(req: NextRequest): string {
// Ordre de priorité classique derrière un reverse-proxy (Vercel, Nginx).
const xff = req.headers.get("x-forwarded-for");
if (xff) return xff.split(",")[0].trim();
const xrip = req.headers.get("x-real-ip");
if (xrip) return xrip.trim();
// Fallback local : pas d'IP extractible (ex. `localhost`), on bucketise tout
// le trafic dans un même compartiment. Acceptable en dev.
return "unknown";
}
function checkRateLimit(ip: string): { ok: boolean; retryAfterMs: number } {
const now = Date.now();
const recent = (rateLimitStore.get(ip) ?? []).filter(
(ts) => now - ts < RATE_LIMIT_WINDOW_MS
);
if (recent.length >= RATE_LIMIT_MAX) {
const oldest = recent[0];
return { ok: false, retryAfterMs: RATE_LIMIT_WINDOW_MS - (now - oldest) };
}
recent.push(now);
rateLimitStore.set(ip, recent);
return { ok: true, retryAfterMs: 0 };
}
function escapeHtml(raw: string): string {
return raw
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function buildEmailContents(input: {
name: string;
email: string;
message: string;
ip: string;
}) {
const dateTime = new Date().toLocaleString("fr-FR", {
timeZone: "Europe/Paris",
});
const safeName = escapeHtml(input.name);
const safeEmail = escapeHtml(input.email);
const safeMessage = escapeHtml(input.message).replace(/\n/g, "<br>");
const subject = `Nouveau message portfolio — ${input.name}`;
const textContent = [
`Nouveau message reçu sur le formulaire de contact.`,
``,
`Nom : ${input.name}`,
`Email : ${input.email}`,
`Date : ${dateTime}`,
`IP : ${input.ip}`,
``,
`Message :`,
input.message,
``,
`---`,
`Répondre directement à ce mail renverra vers ${input.email}.`,
].join("\n");
const htmlContent = `
<!doctype html>
<html>
<body style="font-family: system-ui, -apple-system, Segoe UI, sans-serif; color: #191c1d; background: #f8fafa; margin: 0; padding: 24px;">
<div style="max-width: 560px; margin: 0 auto; background: #ffffff; border-radius: 16px; padding: 24px; box-shadow: 0 4px 12px rgba(25,28,29,0.06);">
<h1 style="font-size: 18px; font-weight: 700; color: #26445d; margin: 0 0 16px;">
Nouveau message portfolio
</h1>
<table role="presentation" style="width: 100%; font-size: 14px; line-height: 1.5;">
<tr>
<td style="color: #516169; padding: 4px 0; width: 80px;">Nom</td>
<td style="font-weight: 600;">${safeName}</td>
</tr>
<tr>
<td style="color: #516169; padding: 4px 0;">Email</td>
<td><a href="mailto:${safeEmail}" style="color: #26445d;">${safeEmail}</a></td>
</tr>
<tr>
<td style="color: #516169; padding: 4px 0;">Date</td>
<td>${dateTime}</td>
</tr>
<tr>
<td style="color: #516169; padding: 4px 0;">IP</td>
<td style="font-family: monospace; font-size: 12px; color: #42474d;">${escapeHtml(input.ip)}</td>
</tr>
</table>
<div style="margin-top: 20px; padding: 16px; background: #f2f4f4; border-radius: 12px;">
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 0.15em; color: #516169; margin-bottom: 8px;">Message</div>
<div style="font-size: 14px; line-height: 1.6; white-space: pre-wrap;">${safeMessage}</div>
</div>
<p style="margin-top: 20px; font-size: 12px; color: #73777d;">
Répondre directement à ce mail renverra vers <strong>${safeEmail}</strong>.
</p>
</div>
</body>
</html>
`.trim();
return { subject, textContent, htmlContent };
}
export async function POST(req: NextRequest) {
const apiKey = process.env.BREVO_API_KEY;
const fromEmail = process.env.CONTACT_FROM_EMAIL;
const fromName = process.env.CONTACT_FROM_NAME ?? "Portfolio — nouveau message";
const toEmail = process.env.CONTACT_TO_EMAIL;
const toName = process.env.CONTACT_TO_NAME ?? "Portfolio";
if (!apiKey || !fromEmail || !toEmail) {
console.error("[/api/contact] Configuration Brevo incomplète", {
hasKey: Boolean(apiKey),
hasFrom: Boolean(fromEmail),
hasTo: Boolean(toEmail),
});
return NextResponse.json(
{ ok: false, error: "SERVER_MISCONFIGURED" },
{ status: 500 }
);
}
let payload: ContactPayload;
try {
payload = (await req.json()) as ContactPayload;
} catch {
return NextResponse.json(
{ ok: false, error: "INVALID_JSON" },
{ status: 400 }
);
}
const name = (payload.name ?? "").trim();
const email = (payload.email ?? "").trim();
const message = (payload.message ?? "").trim();
const honeypot = (payload.website ?? "").trim();
// Honeypot : si un bot a rempli le champ caché, on simule un succès silencieux.
// Ne jamais renvoyer une erreur → ça donnerait aux bots un signal pour retry.
if (honeypot.length > 0) {
console.warn("[/api/contact] Honeypot déclenché, IP :", getClientIp(req));
return NextResponse.json({ ok: true }, { status: 200 });
}
if (!name || !email || !message) {
return NextResponse.json(
{ ok: false, error: "MISSING_FIELDS" },
{ status: 400 }
);
}
if (
name.length > MAX_NAME_LENGTH ||
email.length > MAX_EMAIL_LENGTH ||
message.length > MAX_MESSAGE_LENGTH
) {
return NextResponse.json(
{ ok: false, error: "TOO_LONG" },
{ status: 413 }
);
}
if (!EMAIL_REGEX.test(email)) {
return NextResponse.json(
{ ok: false, error: "INVALID_EMAIL" },
{ status: 400 }
);
}
const ip = getClientIp(req);
const rate = checkRateLimit(ip);
if (!rate.ok) {
return NextResponse.json(
{ ok: false, error: "RATE_LIMITED" },
{
status: 429,
headers: {
"Retry-After": String(Math.ceil(rate.retryAfterMs / 1000)),
},
}
);
}
const { subject, textContent, htmlContent } = buildEmailContents({
name,
email,
message,
ip,
});
try {
const brevoRes = await fetch("https://api.brevo.com/v3/smtp/email", {
method: "POST",
headers: {
"api-key": apiKey,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
sender: { name: fromName, email: fromEmail },
to: [{ email: toEmail, name: toName }],
// replyTo : clé de confort UX. Cliquer "Répondre" dans Gmail écrit
// directement au visiteur, sans aller-retour manuel pour recopier
// l'email depuis le corps du mail.
replyTo: { email, name },
subject,
htmlContent,
textContent,
tags: ["portfolio", "contact-form"],
}),
});
if (!brevoRes.ok) {
const text = await brevoRes.text();
console.error(
"[/api/contact] Brevo error",
brevoRes.status,
brevoRes.statusText,
text
);
return NextResponse.json(
{ ok: false, error: "BREVO_ERROR" },
{ status: 502 }
);
}
} catch (err) {
console.error("[/api/contact] Brevo fetch failed:", err);
return NextResponse.json(
{ ok: false, error: "BREVO_UNREACHABLE" },
{ status: 502 }
);
}
return NextResponse.json({ ok: true }, { status: 200 });
}

View File

@ -1,40 +1,12 @@
/**
* Proxy Next.js vers l'API Python GrasBot.
*
* Rôle : éviter l'exposition directe du domaine `llmapi.fernandgrascalvet.com`
* depuis le navigateur (CORS, rate limiting applicatif, logging Next côté server).
*
* v3.1 (2026-04-23) relais des IDs d'observabilité Langfuse :
* - Les paramètres `session_id` et `user_id` passés par le front (voir
* `app/utils/grasbotIds.js`) sont propagés tels quels vers l'API Python
* qui les injecte dans la trace Langfuse.
* - Whitelist stricte des query params relayés (q, session_id, user_id).
* Toute autre clé est ignorée pas de risque de SSRF via query injection.
*/
const UPSTREAM_BASE = "https://llmapi.fernandgrascalvet.com";
const ALLOWED_PARAMS = new Set(["q", "session_id", "user_id"]);
export async function GET(req) {
const { searchParams } = new URL(req.url);
const question = searchParams.get("q");
if (!question) {
return new Response(JSON.stringify({ error: "Question manquante" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
return new Response(JSON.stringify({ error: "Question manquante" }), { status: 400 });
}
// Construction des params upstream : whitelist only.
const upstream = new URLSearchParams();
for (const [key, value] of searchParams.entries()) {
if (ALLOWED_PARAMS.has(key) && value) {
upstream.set(key, value);
}
}
const apiUrl = `${UPSTREAM_BASE}/ask?${upstream.toString()}`;
const apiUrl = `https://llmapi.fernandgrascalvet.com/ask?q=${encodeURIComponent(question)}`;
try {
const response = await fetch(apiUrl, {
@ -52,12 +24,8 @@ export async function GET(req) {
},
});
} catch (error) {
return new Response(
JSON.stringify({ error: "Erreur de communication avec l'API" }),
{
return new Response(JSON.stringify({ error: "Erreur de communication avec l'API" }), {
status: 500,
headers: { "Content-Type": "application/json" },
}
);
});
}
}

View File

@ -1,33 +1,23 @@
"use client";
import { useState } from "react";
import { sendMessage } from "../utils/sendMessage";
/**
* Formulaire de contact refonte "Digital Atelier" (étape 8) + envoi via Brevo (étape 9).
* Formulaire de contact refonte "Digital Atelier" (étape 8).
*
* Architecture :
* Form POST /api/contact (Next.js server route, voir app/api/contact/route.ts)
*
* Brevo API HTTP Gmail
*
* Plus de passage par Strapi pour les messages : la route serveur valide,
* filtre (honeypot + rate-limit), puis envoie une notification email. Voir
* `docs-site-interne/contact-flow.md` pour l'architecture détaillée.
*
* Anti-spam :
* - Champ honeypot `website` caché (sr-only + tabindex=-1). Les bots le
* remplissent systématiquement, les humains non. Côté serveur, un champ
* rempli succès silencieux (aucun email envoyé).
* - Rate-limit côté serveur : 3 envois / 10 min / IP (voir route.ts).
* - Validation longueur + format email côté serveur en plus du client.
* - Plus de `bg-white shadow-lg rounded-lg` sur le form : il est désormais
* monté dans la carte vellum de `app/contact/page.js`.
* - Champs : `bg-surface-container-low`, radius `rounded-tile`, `focus-visible:ring-2 focus-visible:ring-primary`.
* - CTA jewel : `bg-primary text-on-primary shadow-jewel` avec Material Symbol
* `send` + effet `-translate-y-0.5` au hover, état disabled en `bg-outline-variant/60`.
* - Bandeau status Stitch : succès en `primary-fixed`, erreur en `error-container`,
* chargement en `surface-container`. Chaque état porte une Material Symbol.
*/
export default function ContactForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
// Honeypot : doit rester vide. S'il est rempli, on soumet quand même pour ne
// rien changer côté UX (le serveur ignore silencieusement ces payloads).
const [website, setWebsite] = useState("");
const [status, setStatus] = useState("");
const [statusKind, setStatusKind] = useState<
"idle" | "loading" | "success" | "error"
@ -54,55 +44,24 @@ export default function ContactForm() {
setStatusKind("loading");
try {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, message, website }),
});
if (res.status === 429) {
setStatus(
"Trop d'envois depuis votre IP. Réessayez dans quelques minutes."
);
setStatusKind("error");
return;
}
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as {
error?: string;
};
const errorCode = data.error ?? "UNKNOWN";
setStatus(
errorCode === "INVALID_EMAIL"
? "Email invalide."
: errorCode === "MISSING_FIELDS"
? "Tous les champs sont obligatoires."
: errorCode === "TOO_LONG"
? "Message trop long."
: "Erreur lors de l'envoi du message."
);
setStatusKind("error");
return;
}
await sendMessage(name, email, message);
setStatus("Message envoyé. Merci, je reviens vers vous rapidement.");
setStatusKind("success");
setName("");
setEmail("");
setMessage("");
setWebsite("");
} catch (error) {
console.error("[ContactForm] submit failed:", error);
setStatus("Erreur réseau. Vérifiez votre connexion et réessayez.");
setStatus("Erreur lors de l'envoi du message.");
setStatusKind("error");
}
};
const statusStyles: Record<typeof statusKind, string> = {
idle: "",
loading: "bg-surface-container text-on-surface-variant",
success: "bg-primary-fixed/70 text-on-primary-fixed",
loading:
"bg-surface-container text-on-surface-variant",
success:
"bg-primary-fixed/70 text-on-primary-fixed",
error: "bg-error-container text-on-error-container",
};
@ -118,32 +77,6 @@ export default function ContactForm() {
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3" noValidate>
{/* Honeypot : caché visuellement ET aux lecteurs d'écran. tabindex=-1 pour
qu'un utilisateur clavier ne tombe jamais dessus. Les bots qui
parsent le DOM le remplissent quasi-systématiquement. */}
<div
aria-hidden="true"
style={{
position: "absolute",
left: "-10000px",
width: "1px",
height: "1px",
overflow: "hidden",
}}
>
<label>
Ne pas remplir ce champ
<input
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
value={website}
onChange={(e) => setWebsite(e.target.value)}
/>
</label>
</div>
<label className="flex flex-col gap-1">
<span className="font-headline text-[11px] font-bold uppercase tracking-[0.3em] text-secondary">
Votre nom
@ -155,7 +88,6 @@ export default function ContactForm() {
onChange={(e) => setName(e.target.value)}
className={fieldClass}
required
maxLength={120}
autoComplete="name"
/>
</label>
@ -171,7 +103,6 @@ export default function ContactForm() {
onChange={(e) => setEmail(e.target.value)}
className={fieldClass}
required
maxLength={160}
autoComplete="email"
/>
</label>
@ -185,7 +116,6 @@ export default function ContactForm() {
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={5}
maxLength={5000}
className={`${fieldClass} min-h-[9rem] resize-y`}
required
/>

View File

@ -1,5 +1,3 @@
import { getGrasbotSessionId, getGrasbotUserId } from "./grasbotIds";
/**
* Appelle l'API GrasBot via le proxy Next (/api/proxy).
*
@ -9,9 +7,6 @@ import { getGrasbotSessionId, getGrasbotUserId } from "./grasbotIds";
* - v3 (2026-04-22) : retourne maintenant l'objet complet pour que `ChatBot.js`
* puisse afficher les sources citées, le badge `grounded`, etc. Ajoute un
* timeout (45 s) via `AbortController` pour éviter les spinners infinis.
* - v3.1 (2026-04-23) : transmet `session_id` (sessionStorage) et `user_id`
* (localStorage) à chaque requête pour l'observabilité Langfuse. Pas de PII,
* juste des UUID anonymes. Voir `docs-site-interne/langfuse-observability.md`.
*
* @param {string} question
* @returns {Promise<{
@ -26,14 +21,8 @@ export async function askAI(question) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 45_000);
const params = new URLSearchParams({ q: question });
const sessionId = getGrasbotSessionId();
const userId = getGrasbotUserId();
if (sessionId) params.set("session_id", sessionId);
if (userId) params.set("user_id", userId);
try {
const response = await fetch(`/api/proxy?${params.toString()}`, {
const response = await fetch(`/api/proxy?q=${encodeURIComponent(question)}`, {
signal: controller.signal,
});
clearTimeout(timeoutId);

View File

@ -1,56 +0,0 @@
/**
* IDs anonymes pour l'instrumentation Langfuse du chatbot GrasBot.
*
* Modèle (voir docs-site-interne/langfuse-observability.md §Session / User) :
*
* - `user_id` : UUID v4 persistant dans localStorage (`grasbot_user_id`).
* Identifie un même "device" au fil du temps. Pas de PII, pas d'auth.
* Permet d'estimer les utilisateurs uniques et de regrouper l'historique
* des conversations d'un visiteur récurrent.
*
* - `session_id` : UUID v4 dans sessionStorage (`grasbot_session_id`).
* Expire à la fermeture de l'onglet. Regroupe dans Langfuse toutes les
* questions d'une même "conversation" pour voir le flow complet.
*
* On utilise `crypto.randomUUID()` (disponible partout depuis 2021, couvert
* par tous les navigateurs modernes). Fallback en cas d'environnement exotique
* (SSR, navigateur très ancien) : UUID généré par `Math.random` c'est pour
* l'observabilité, on ne dépend pas de la cryptographie forte.
*/
function safeRandomUUID() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
// Fallback RFC 4122 v4 : suffisant pour de l'observabilité (pas de sécurité).
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
function readOrCreate(storage, key) {
if (typeof window === "undefined") return null;
try {
const existing = storage.getItem(key);
if (existing) return existing;
const fresh = safeRandomUUID();
storage.setItem(key, fresh);
return fresh;
} catch {
// localStorage/sessionStorage peut être bloqué (mode privé strict). On
// renvoie un ID éphémère pour que l'appel marche, sans persister.
return safeRandomUUID();
}
}
export function getGrasbotUserId() {
if (typeof window === "undefined") return null;
return readOrCreate(window.localStorage, "grasbot_user_id");
}
export function getGrasbotSessionId() {
if (typeof window === "undefined") return null;
return readOrCreate(window.sessionStorage, "grasbot_session_id");
}

35
app/utils/sendMessage.ts Normal file
View File

@ -0,0 +1,35 @@
import { getApiUrl } from "./getApiUrl";
export async function sendMessage(name: string, email: string, message: string) {
const dateTime = new Date().toLocaleString("fr-FR", { timeZone: "Europe/Paris" });
const messageWithDate = `${message}\n\n📅 Envoyé le : ${dateTime}`;
console.log("📨 Envoi du message...", { name, email, messageWithDate });
const apiUrl = getApiUrl();
const res = await fetch(`${apiUrl}/api/messages`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
data: {
name: name,
email: email,
message: messageWithDate,
},
}),
});
const responseData = await res.json();
if (!res.ok) {
console.error("❌ Erreur API Strapi :", responseData);
throw new Error(`Échec de l'envoi du message: ${responseData.error.message}`);
}
console.log("✅ Message envoyé avec succès !", responseData);
return responseData;
}

View File

@ -17,8 +17,8 @@
| `/portfolio/[slug]` | `app/portfolio/[slug]/page.tsx` | Détail projet (`fetchData('projects', slug)`) |
| `/competences` | `app/competences/page.jsx` | Liste compétences |
| `/competences/[slug]` | `app/competences/[slug]/page.tsx` | Détail (`fetchData('competences', slug)`) |
| `/contact` | `app/contact/page.js` | Formulaire → `/api/contact` (Brevo, voir `contact-flow.md`) |
| `/api/contact` | `app/api/contact/route.ts` | Endpoint serveur : envoie un email via Brevo, honeypot + rate-limit |
| `/contact` | `app/contact/page.js` | Formulaire → Strapi `messages` |
| `/admin/messages` | `app/admin/messages/page.tsx` | Consultation messages (côté Next) |
| `/api/proxy` | `app/api/proxy/route.js` | Proxy GET vers API LLM distante |
## Layout
@ -42,8 +42,7 @@
- Carrousels : `Carousel.tsx`, `CarouselCompetences.tsx` (swiper / react-responsive-carousel).
- Sections : `ContentSection.tsx`, `ContentSectionCompetences*.tsx`.
- `ContactForm.tsx``POST /api/contact` → Brevo API (voir `docs-site-interne/contact-flow.md`).
- `ChatBot.js``askAI.js``/api/proxy` → FastAPI `/ask` avec `session_id` + `user_id` (UUID anonymes via `app/utils/grasbotIds.js`, voir `docs-site-interne/langfuse-observability.md`).
- `ContactForm.tsx``sendMessage.ts`.
- `ChatBot.js``askAI.js``/api/proxy`.
- `ModalGlossaire.tsx` — glossaire (données Strapi selon usage dans les pages).
@ -54,6 +53,6 @@ app/layout.tsx
app/page.tsx
app/utils/getApiUrl.ts
app/utils/fetchData.ts
app/api/contact/route.ts
app/utils/sendMessage.ts
next.config.ts
```

View File

@ -43,9 +43,15 @@ Utilisation front : `app/page.tsx` — premier enregistrement `populate=*`, imag
| `slug` | uid ← `name` | requis |
| `order` | integer | optionnel |
### `message` (supprimé le 2026-04-23)
### `message` (collection `messages`)
Ancien content-type pour stocker les soumissions du formulaire de contact. Supprimé car le formulaire envoie désormais une notification email via **Brevo** (voir `docs-site-interne/contact-flow.md`) — plus besoin de stockage Strapi. Les 4 fichiers `cmsbackend/src/api/message/**` ont été supprimés ; la table SQLite `messages` reste orpheline (inoffensive, peut être droppée manuellement).
| Champ | Type | Notes |
|-------|------|--------|
| `name` | string | requis |
| `email` | email | requis |
| `message` | text | requis |
Création via **POST** `/api/messages` depuis `sendMessage.ts` (permissions Strapi **create** publique à valider en prod).
### `glossaire` (collection `glossaires`)

View File

@ -361,51 +361,6 @@ Après : **carte vellum légère** centrée, sans ombre ambient (le footer ne do
- **Validation serveur des champs du form** (anti-spam, honeypot, reCAPTCHA) : hors scope refonte visuelle. Strapi ne filtre pour l'instant que sur la structure JSON attendue.
- **Fusion Carousel.tsx / CarouselCompetences.tsx** : reste en dette technique (déjà noté §7).
## 9. Post-refonte — Contact effectif via Brevo (2026-04-23)
La refonte visuelle a laissé le formulaire de contact en état "joli mais non fonctionnel" : il stockait les messages dans Strapi (content-type `message`), à consulter via `/admin/messages` (page publique, non protégée). Aucune notification.
**Décision** (discutée avec l'utilisateur) : supprimer le passage par Strapi, passer à une **notification email directe** via l'**API HTTP Brevo** (compte existant pour la newsletter). Plus simple, moins de surface d'attaque, notification immédiate.
### Changements
- **Nouveau** : `app/api/contact/route.ts` (Next.js App Router, runtime Node) — reçoit le form, valide, applique honeypot + rate-limit, appelle `POST https://api.brevo.com/v3/smtp/email`, retourne `{ ok: true | false, error }`.
- **Modifié** : `app/components/ContactForm.tsx` — appel vers `/api/contact` au lieu de `sendMessage(...)`. Ajout d'un **champ honeypot** `website` caché (position absolue hors écran + `aria-hidden` + `tabindex=-1`). Codes d'erreur serveur mappés en messages FR.
- **Supprimé** : `app/utils/sendMessage.ts` (plus utilisé), `app/admin/messages/page.tsx` (plus de consultation Strapi nécessaire, et cette page était exposée publiquement sans auth).
- **Supprimé côté Strapi** : `cmsbackend/src/api/message/` (content-type + routes + services + controllers). La table SQLite `messages` est laissée en place (orpheline, inoffensive).
- **Nouveau** : `.env.example` en racine pour documenter les variables requises, `docs-site-interne/contact-flow.md` pour l'architecture complète.
### Variables d'env (.env.local)
```env
BREVO_API_KEY=xkeysib-...
CONTACT_FROM_EMAIL=<expéditeur vérifié Brevo>
CONTACT_FROM_NAME=Portfolio — nouveau message
CONTACT_TO_EMAIL=grascalvet.fernand@gmail.com
CONTACT_TO_NAME=Fernand Gras-Calvet
```
### Anti-abus
- **Honeypot** : champ caché `website`. Si rempli → 200 silencieux + log warning (pas d'erreur pour ne pas signaler aux bots).
- **Rate-limit** : 3 envois / IP / 10 min (Map en mémoire). Limité en serverless multi-instance — acceptable pour un portfolio.
- **Validation serveur** : longueurs (120 / 160 / 5000), regex email, codes d'erreur en `UPPER_SNAKE_CASE` stables.
### Email reçu
- Sujet : `Nouveau message portfolio — {Nom}`
- Expéditeur : `CONTACT_FROM_NAME <CONTACT_FROM_EMAIL>`
- Reply-To : email du visiteur (un clic "Répondre" dans Gmail et le mail part au visiteur).
- Corps HTML : carte Stitch simplifiée (primary/secondary/surface-container-low) avec table nom/email/date/IP + zone message `pre-wrap`.
### Leçons retenues
1. **Surveiller les pages admin "dev"** qui fuitent en prod : `/admin/messages` listait les emails de tous les visiteurs en clair, sans auth. Avec la page supprimée, plus d'exposition.
2. **Clé API fuitant en clair dans un chat** : considérer comme compromise et régénérer. Documenté dans `contact-flow.md` § "Sécurité — rappels".
3. **Pour un besoin "1 seul destinataire" avec faible volume**, une route Next + API transactionnelle bat clairement une solution SMTP + plugin Strapi : moins de pièces, moins de config, même garantie de délivrabilité.
Voir `docs-site-interne/contact-flow.md` pour la procédure de test, les codes d'erreur, et le rollback éventuel.
## 5. Checklist relecture (à passer à la fin de chaque étape)
- [ ] Aucune colonne unique globale `max-w-xl` (c'est le format newsletter).

View File

@ -104,13 +104,12 @@ Les captures suivantes **nont pas révélé de problème spécifique** après
| 18 | Contact formulaire mobile | `/contact` | `18-contact-formulaire-mobile.webp` | `fait` (étape 8, 2026-04-22) |
| 19 | Footer desktop | `/` | `19-layout-footer-desktop.webp` | `fait` (étape 8, 2026-04-22) |
| 20 | Compteur visites desktop | `/` | `20-layout-compteur-visites-desktop.webp` | `OK` |
| 21 | ~~Admin messages desktop~~ | ~~`/admin/messages`~~ | `21-admin-messages-desktop.webp` | `obsolète` (route supprimée le 2026-04-23, voir `contact-flow.md`) |
| 21 | Admin messages desktop | `/admin/messages` | `21-admin-messages-desktop.webp` | `OK` |
---
## Suite
- **Étape 8 Digital Atelier bouclée** (2026-04-22) : contact + formulaire + footer migrés à la charte Stitch (voir `docs-site-interne/REFONTE-VISUELLE.md §8`).
- **Formulaire de contact rendu effectif** (2026-04-23) : envoi via **Brevo** au lieu de Strapi. Content-type `message` supprimé, page `/admin/messages` supprimée, honeypot + rate-limit ajoutés. Voir `docs-site-interne/contact-flow.md` et `REFONTE-VISUELLE.md §9`.
- Prendre de **nouvelles captures** 17 / 18 / 19 pour figer le rendu post-refonte (remplacement des WebP existants dans `docs-site-interne/captures/`). La capture 21 est désormais **obsolète** (route supprimée).
- Dette technique identifiée : fusion `Carousel.tsx` / `CarouselCompetences.tsx` (doublons), persistance serveur du compteur de visites. Anti-spam formulaire : **fait** (honeypot + rate-limit, 2026-04-23).
- Prendre de **nouvelles captures** 17 / 18 / 19 pour figer le rendu post-refonte (remplacement des WebP existants dans `docs-site-interne/captures/`).
- Dette technique identifiée pour une future passe : fusion `Carousel.tsx` / `CarouselCompetences.tsx` (doublons), persistance serveur du compteur de visites, validation anti-spam du formulaire.

View File

@ -1,186 +0,0 @@
# Flux "Contact" — notification par email (Brevo)
**Créé :** 2026-04-23
**Statut :** en production
## Vue d'ensemble
Le formulaire de contact du site envoie un **email de notification** sur la boîte Gmail personnelle (`grascalvet.fernand@gmail.com`), via l'**API HTTP transactionnelle de Brevo**.
**Plus aucun passage par Strapi pour les messages.** Le content-type `message` a été supprimé (ainsi que la page admin associée) le 2026-04-23. La table SQLite `messages` reste présente en base pour l'historique, mais n'est plus écrite ni lue.
```
Visiteur → /contact (Next.js front)
POST /api/contact (Next.js App Router, server runtime)
Validation + honeypot + rate-limit
POST https://api.brevo.com/v3/smtp/email
Gmail : CONTACT_TO_EMAIL
```
## Pourquoi cette architecture
### Simplification par rapport à l'ancien flux
Avant : `Front → Strapi POST /api/messages → SQLite` + consultation via `/admin/messages` (page publique, non protégée).
Problèmes :
- **Pas de notification** : il fallait penser à consulter l'admin pour voir les nouveaux messages.
- **Page admin exposée** : `/admin/messages` accessible à n'importe qui en clair (pas d'authentification). Fuite potentielle d'emails privés des visiteurs.
- **Content-type dédié** pour un usage limité (3 champs, pas d'historique utile).
Après : notification directe sur Gmail, zéro stockage intermédiaire, zéro page à sécuriser.
### Pourquoi l'API HTTP Brevo plutôt que SMTP
Discussion menée le 2026-04-23. Résumé :
| Critère | SMTP | API HTTP |
|---------|------|----------|
| Simplicité plugin Strapi | ✓ | (custom) |
| Port potentiellement bloqué | oui | non (HTTPS 443) |
| Débogage JSON | non | ✓ |
| Notre besoin (1 endroit, 1 mail) | ok | ok |
Comme on ne passe plus par Strapi, l'avantage "plugin Strapi" disparaît. On a pris **l'API HTTP** pour rester 100 % en HTTPS (aucun souci de port) et avoir des erreurs JSON lisibles.
## Fichiers concernés
| Fichier | Rôle |
|---------|------|
| `app/api/contact/route.ts` | Endpoint serveur Next.js qui reçoit le form et appelle Brevo |
| `app/components/ContactForm.tsx` | UI côté client, honeypot, gestion du status |
| `.env.local` | Secrets + config (non committé) |
| `.env.example` | Template documentaire des variables à remplir |
## Variables d'environnement
À définir dans `.env.local` (voir `.env.example` pour la structure) :
| Variable | Description | Où l'obtenir |
|----------|-------------|--------------|
| `BREVO_API_KEY` | Clé API Brevo | [app.brevo.com → SMTP & API → API Keys](https://app.brevo.com/settings/keys/api) |
| `CONTACT_FROM_EMAIL` | Expéditeur **vérifié** | Senders, Domains & IPs → Senders (pastille SPF/DKIM verte) |
| `CONTACT_FROM_NAME` | Nom affiché dans Gmail | Texte libre, ex. "Portfolio — nouveau message" |
| `CONTACT_TO_EMAIL` | Destinataire final | L'adresse Gmail personnelle |
| `CONTACT_TO_NAME` | Nom du destinataire | Texte libre |
**L'expéditeur doit être vérifié dans Brevo** : sinon Brevo renvoie une erreur au `POST`. On peut réutiliser l'expéditeur newsletter (pas de cloisonnement côté Brevo) et surcharger le nom d'affichage via `CONTACT_FROM_NAME`.
## Anti-abus
### Honeypot
Un champ caché `website` est ajouté au formulaire :
- Invisible aux humains (`position: absolute; left: -10000px; width: 1px`).
- Masqué aux lecteurs d'écran (`aria-hidden="true"`).
- Ignoré par le clavier (`tabindex="-1"`, `autocomplete="off"`).
Les bots qui parsent le DOM remplissent systématiquement tous les champs texte. Côté serveur, si `website` est non vide :
- On **renvoie un 200 silencieux** (pas de signal d'erreur aux bots → ils ne retentent pas).
- On **n'envoie pas d'email**.
- On **logue** un warning avec l'IP pour monitoring.
### Rate-limit
`app/api/contact/route.ts` limite à **3 envois par IP par fenêtre de 10 minutes**, via une `Map` en mémoire :
```ts
const RATE_LIMIT_MAX = 3;
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
```
Au-delà : réponse `429 Too Many Requests` avec header `Retry-After`.
**Limite connue** : en déploiement serverless avec plusieurs instances (Vercel free, Cloudflare Workers), la `Map` n'est pas partagée — le rate-limit est "best effort". Pour un portfolio, c'est acceptable. Pour du vrai ship : passer à Redis ou [Upstash](https://upstash.com/) (wrapper compatible serverless).
### Validation
Côté serveur (défense en profondeur, jamais faire confiance au client) :
- Champs requis non vides : `name`, `email`, `message`.
- Longueurs max : `name ≤ 120`, `email ≤ 160`, `message ≤ 5000` caractères (retourne `413`).
- Format email : regex `^[^\s@]+@[^\s@]+\.[^\s@]+$` (pragmatique, pas RFC 5322 stricte).
Les codes d'erreur renvoyés au client sont en `UPPER_SNAKE_CASE` (stables pour l'i18n future) : `MISSING_FIELDS`, `INVALID_EMAIL`, `TOO_LONG`, `RATE_LIMITED`, `BREVO_ERROR`, `BREVO_UNREACHABLE`, `SERVER_MISCONFIGURED`, `INVALID_JSON`.
## Format de l'email reçu
- **Sujet** : `Nouveau message portfolio — {Nom du visiteur}`
- **Expéditeur** : `{CONTACT_FROM_NAME} <{CONTACT_FROM_EMAIL}>`
- **Reply-To** : email du visiteur → cliquer "Répondre" dans Gmail écrit directement au visiteur.
- **Corps HTML** : carte Stitch simplifiée (palette primary / secondary) avec table récap (nom, email, date, IP) + zone message `pre-wrap`.
- **Corps texte** (fallback) : version plain text équivalente.
- **Tags Brevo** : `["portfolio", "contact-form"]` pour filtrer dans les statistiques Brevo si besoin.
## Procédure de test
### Local (développement)
1. S'assurer que `.env.local` contient les 5 variables requises + une clé Brevo valide.
2. `npm run dev` dans le repo Next (port 3000).
3. `/contact` → remplir le formulaire avec une **adresse email qu'on contrôle** (pour vérifier le Reply-To).
4. Vérifier :
- Feedback visuel "Message envoyé" en succès Stitch vert.
- Réception dans Gmail avec expéditeur = `CONTACT_FROM_NAME`.
- Clic "Répondre" → destinataire = l'email saisi dans le formulaire.
### Honeypot
1. Ouvrir la console DevTools, onglet Elements.
2. Trouver l'input caché `<input name="website">` (dans un div `position: absolute; left: -10000px`).
3. Y saisir une valeur (ex. "bot").
4. Soumettre le formulaire normalement.
5. Résultat attendu : UI affiche "Message envoyé" (succès silencieux) MAIS **aucun email n'arrive** dans Gmail.
6. Vérifier le warning serveur dans les logs Next (`[/api/contact] Honeypot déclenché`).
### Rate-limit
1. Envoyer un formulaire valide 4 fois de suite (modifier juste le message à chaque fois).
2. Au 4ᵉ envoi : réponse `429`, UI affiche "Trop d'envois depuis votre IP. Réessayez dans quelques minutes."
3. Attendre 10 min puis réessayer → doit repasser.
## Nettoyage de l'ancienne table SQLite (optionnel)
La table `messages` existe toujours dans `cmsbackend/.tmp/data.db` (ou le fichier configuré). Strapi ne la touche plus. Pour la supprimer proprement :
```bash
# Arrêter Strapi d'abord !
cd cmsbackend
sqlite3 .tmp/data.db "DROP TABLE IF EXISTS messages;"
sqlite3 .tmp/data.db "DROP TABLE IF EXISTS messages_cmps;"
# Reprise normale
npm run develop
```
Non fait par défaut : la table reste orpheline mais inoffensive. Ça évite toute régression en cas de rollback éventuel.
## Quotas Brevo
Le **plan gratuit Brevo** inclut traditionnellement **300 emails transactionnels par jour**, partagés avec la newsletter. Pour un portfolio qui reçoit 0 à 5 messages par semaine, c'est largement suffisant.
Si le site reçoit un jour beaucoup plus de trafic (ou si Brevo change ses quotas gratuits), surveiller :
- **Brevo → Transactional → Statistics** : nombre d'envois, taux d'erreur.
- **Brevo → SMTP & API → Statistics** : quota quotidien consommé.
## Rollback
Si le flux Brevo tombe en panne (compte suspendu, quota dépassé, clé compromise) et qu'on veut **temporairement** retomber sur le flux Strapi ancien :
1. `git checkout <commit-pre-refonte-contact>` sur les fichiers : `app/api/contact/route.ts` (supprimer), `app/components/ContactForm.tsx` (restaurer `sendMessage`), `app/utils/sendMessage.ts` (restaurer), `cmsbackend/src/api/message/` (restaurer les 4 fichiers), et `app/admin/messages/page.tsx` si on veut la page de consultation.
2. Re-démarrer Strapi pour qu'il régénère le content-type en mémoire.
3. Vérifier les **permissions publiques** sur `POST /api/messages` dans l'admin Strapi (peut avoir besoin d'être recoché).
Préférable : **renouveler la clé Brevo** plutôt que rollback.
## Sécurité — rappels
- **Ne jamais committer `.env.local`**. Déjà dans `.gitignore` (`.env*`).
- **Si la clé Brevo fuit** (exposée dans un commit, un chat, un screenshot) : la **supprimer immédiatement** sur Brevo et en générer une nouvelle. Une clé compromise permet d'envoyer des emails frauduleux depuis l'expéditeur vérifié, ce qui peut faire blacklister le domaine.
- L'**IP du visiteur** est loguée dans l'email (pour abus). À supprimer des emails si on veut un template plus "commercial".

View File

@ -1,227 +0,0 @@
# Observabilité GrasBot via Langfuse
**Créé :** 2026-04-23
**Statut :** en production
**Pré-requis lecture :** `docs-site-interne/08-vault-obsidian-retrieval.md` (architecture du pipeline graph + BM25).
## Vue d'ensemble
Le chatbot GrasBot est instrumenté avec **Langfuse** (instance self-hosted : `langfuse.fernandgrascalvet.com`) pour tracer **chaque requête visiteur** bout en bout :
- **Retrieval** : quelles notes du vault ont été remontées, avec quels scores, pour quelles raisons.
- **Prompt** : le system + user effectivement envoyés à Qwen3.
- **Génération** : latence, tokens, paramètres du modèle.
- **Trace globale** : question, réponse, sources, scores dérivés (grounded, retrieval_relevance), tags.
But : **debug**, **monitoring** (qualité/latence dans le temps), et **itération** sur le pipeline retrieval en voyant directement l'effet d'un changement de règle de scoring.
## Architecture
```
┌─────────────────────────┐ ┌──────────────────────────┐
│ ChatBot.js (front) │ │ Langfuse self-hosted │
│ - grasbotIds.js │ │ langfuse.fernandgrasc… │
│ - user_id localStorage│───▶│ │
│ - session_id sessionSt │ │ (ingestion HTTPS) │
└──────┬──────────────────┘ │ │
│ │ │
▼ │ ▲ │
┌─────────────────────────┐ │ │ SDK Python │
│ app/api/proxy/route.js │ │ │ (observability │
│ whitelist + GET fwd │ │ │ .py) │
└──────┬──────────────────┘ └─────────┼────────────────┘
│ │
▼ │
┌─────────────────────────┐ │
│ FastAPI /ask │ │
│ (llm-api/api.py) │ │
│ → @observe via Langfuse─────────────┘
│ → search.answer() │
└──────┬──────────────────┘
├── retrieval (span)
├── prompt_build (span)
└── ollama-chat (generation)
```
L'instrumentation **vit côté Python** (couche où on a accès aux détails du retrieval et du prompt). Le proxy Next ne fait que relayer le `session_id` / `user_id` depuis le front jusqu'à l'API Python.
## Fichiers concernés
| Fichier | Rôle |
|---------|------|
| `llm-api/observability.py` | Init client Langfuse (no-op safe si clés absentes) + `flush()` au shutdown |
| `llm-api/api.py` | FastAPI `/ask` — query params `session_id`/`user_id` + `lifespan` pour flush |
| `llm-api/search.py` | Spans `retrieval` + `prompt_build` + `generation`, trace racine `ask`, scores auto |
| `llm-api/.env` | Secrets Langfuse (non committé) |
| `llm-api/.env.example` | Template documentaire |
| `app/utils/grasbotIds.js` | Génération UUID v4 anonymes (localStorage + sessionStorage) |
| `app/utils/askAI.js` | Passe `session_id`/`user_id` en query params |
| `app/api/proxy/route.js` | Whitelist `q`, `session_id`, `user_id` → forward vers API Python |
## Variables d'environnement (côté Python uniquement)
Dans **`llm-api/.env`** (chargé automatiquement par `observability.py` via `python-dotenv`) :
| Variable | Obligatoire | Notes |
|----------|-------------|-------|
| `LANGFUSE_PUBLIC_KEY` | oui | Format `pk-lf-…` |
| `LANGFUSE_SECRET_KEY` | oui | Format `sk-lf-…`**JAMAIS dans un log/commit/chat** |
| `LANGFUSE_BASE_URL` | oui | URL du self-hosted (ex. `https://langfuse.fernandgrascalvet.com`) |
| `LANGFUSE_HOST` | fallback | Alternative à `BASE_URL` si jamais on passe sur le cloud Langfuse |
Si **l'une des 3** est absente → `observability.py` instancie un **client no-op** : l'API fonctionne normalement, aucune trace n'est envoyée, aucune erreur. Pratique pour dev local / contributeurs externes.
Les variables Langfuse **ne sont pas** dans `.env.local` de Next.js — elles ne servent qu'au backend Python.
## Structure d'une trace
### Trace racine : `ask`
- **input** : `{ query: "..." }`
- **output** : `{ response, sources_count, grounded }`
- **metadata** : `{ top_k, min_score }`
- **session_id**, **user_id** (propagés depuis le front)
- **tags** : `grounded`|`ungrounded`, `model:qwen3:8b`, `vault-miss` (si aucune note scorée)
- **scores** auto :
- `grounded` (BOOLEAN, 0/1) : au moins 1 note ≥ `MIN_SCORE`
- `retrieval_relevance` (NUMERIC, 0-1) : `min(max_score / 15, 1)`
### Span `retrieval`
- **input** : `{ query, top_k }`
- **output** : `[{slug, title, type, score, reasons}, …]` — top-K final après expansion
- **metadata** :
- `query_tokens` : tokens extraits par `tokenize_fr`
- `vault_size` : nombre de notes publiques chargées
- `candidates_with_signal` : combien de notes ont eu un score > 0
- `seeds_before_graph` : top-3 avant expansion par graphe
- `bm25_stats` : `{N, avgdl, idf_terms}` (pour debug de régressions BM25)
- `elapsed_ms` : durée du retrieval seul
### Span `prompt_build`
- **input** : `{ query, scored_count }`
- **output** : `{ system, user }` — le **prompt complet** envoyé à Qwen
- **metadata** :
- `grounded` : bool (= au moins 1 note ≥ MIN_SCORE)
- `relevant_notes` : notes effectivement incluses dans le contexte
- `system_chars`, `user_chars` : tailles utiles pour debug de fenêtre de contexte
- `min_score_threshold` : valeur du `MIN_SCORE` au moment de l'appel
- `truncation` : `{ secondary_max_chars, secondary_keep_ratio, truncated_notes: [...] }`
liste des sources rank 2+ résumées automatiquement (avec leur slug, score,
taille d'origine et taille tronquée). Vide s'il n'y a eu aucune troncature.
### Span `ollama-chat` (type **generation**)
- **input** : `[{role: "system", content}, {role: "user", content}]`
- **output** : réponse brute du modèle
- **model** : `LLM_MODEL` (ex. `qwen3:8b`)
- **model_parameters** : `{temperature: 0.4, num_ctx: 8192, num_predict: 1024, think: false}`
(voir section "Tuning 2026-04-23" ci-dessous pour le rationnel).
- **usage** : `{input, output, total}` — extraits de `prompt_eval_count` / `eval_count` si Ollama les renvoie
- Si réponse vide → span `level: ERROR` avec le payload Ollama brut en metadata.
## Session / User IDs (côté front)
**Pas de PII**, **pas d'authentification**. Deux UUID v4 anonymes générés automatiquement à la première interaction :
- **`grasbot_user_id`** → `localStorage` → stable par device, sert à mesurer les utilisateurs uniques et à regrouper l'historique d'un visiteur récurrent.
- **`grasbot_session_id`** → `sessionStorage` → expire à la fermeture de l'onglet, regroupe une conversation.
Générés par `app/utils/grasbotIds.js`, propagés par `askAI.js``/api/proxy` (whitelist) → `/ask` (query params) → `search.answer()` (`update_current_trace(session_id=…, user_id=…)`).
**Impact RGPD** : aucun identifiant déductible de l'utilisateur, aucune donnée persistante côté serveur autre que ce que Langfuse stocke de lui-même. L'utilisateur peut vider son storage pour "réinitialiser" son identité côté observabilité.
## Procédure de test
### Local
1. `cd llm-api && pip install -r requirements.txt` (ajoute `langfuse` + `python-dotenv`).
2. Remplir `llm-api/.env` avec les 3 clés (ou laisser vide pour tester le mode no-op).
3. `.\start-my-site.ps1` (ou démarrer uvicorn manuellement).
4. Aller sur `http://localhost:3000` → ouvrir le chatbot (FAB en bas à droite) → poser une question.
5. Dans Langfuse → **Traces** → voir apparaître une trace `ask` en temps réel (quelques secondes après la réponse, le temps du flush).
### Vérifier le no-op silencieux
1. Commenter les 3 variables `LANGFUSE_*` dans `llm-api/.env`.
2. Redémarrer uvicorn → les logs affichent ` Langfuse désactivé — variables manquantes : …`.
3. Poser une question au chatbot → réponse normale, aucun crash.
4. `GET /health` renvoie `{"observability": {"langfuse_enabled": false}}`.
### Scénarios utiles à reproduire dans Langfuse
- **Question grounded classique** : "Parle-moi de push-swap" → tags `grounded`, retrieval_relevance ~0.7-0.9.
- **Question hors-sujet** : "Quel temps fait-il demain ?" → tags `ungrounded`, grounded=0, sources_count=0 ou voisins faibles.
- **Question sur mot-clé ambigu** : "C" (langage C vs lettre C) → voir dans le span `retrieval` comment `_keyword_matches` filtre ou non.
## Dashboards Langfuse utiles
### Qualité du retrieval dans le temps
Dashboard → filtrer `score: grounded` → voir le **taux de grounded** par jour. Une chute = problème de vault ou de scoring.
### Latence p95
Dashboard → `latency` sur trace `ask` ou span `ollama-chat`. La génération est **la source de latence majoritaire** (≥ 90%), le retrieval reste sous ~100ms.
### Questions sans contexte pertinent
Filtrer tags = `ungrounded` → voir les questions posées mais non couvertes par le vault → **source d'insights pour enrichir le vault** (nouveaux alias, nouvelles notes).
### Sessions longues
Filtrer par `session_id` → enchaînement des questions d'un visiteur → voir si GrasBot garde la cohérence (pas de mémoire entre requêtes, attendu).
## Conventions de nommage
- **Spans** : kebab-case en anglais (`retrieval`, `prompt-build`, `ollama-chat`). Ici `prompt_build` a été laissé en snake pour rappeler la fonction Python, à remplacer par `prompt-build` si on refait un coup de ménage.
- **Tags** : kebab-case, préfixés par concept (`model:qwen3:8b`, `vault-miss`).
- **Scores** : snake_case nom simple (`grounded`, `retrieval_relevance`), + ajoutera plus tard `user_feedback` si on branche un 👍/👎.
## Rollback
Si Langfuse tombe en panne ou si l'instrumentation pose un souci :
1. **Soft rollback** : vider / commenter les variables `LANGFUSE_*` dans `llm-api/.env` et redémarrer uvicorn. Le client passe en no-op, aucun autre changement nécessaire.
2. **Hard rollback** : `git revert` du commit d'intégration Langfuse. Les fichiers `observability.py` / `.env` / `grasbotIds.js` disparaîtront ; le pipeline revient exactement à la v3.0.
## Sécurité — rappels
- **`LANGFUSE_SECRET_KEY`** permet d'écrire dans toutes les traces du projet → équivaut à un droit d'admin partiel. Jamais en clair dans un chat, un log, un screenshot, un commit.
- **Rotation** : en cas de doute, **Project Settings → API Keys → Delete** puis recréer. Les traces déjà ingérées ne sont pas affectées.
- Le client Langfuse envoie les traces **en asynchrone** avec un buffer → bien appeler `flush()` au shutdown pour ne rien perdre (déjà fait via le `lifespan` FastAPI).
- **Contenu sensible** : les prompts complets passent dans Langfuse. Vérifier que **le vault ne contient pas d'infos privées** (`visibility: private` est filtré côté search, mais si tu ajoutais un jour un vault mixte public/privé, il faudrait un filtre supplémentaire avant l'envoi à Langfuse).
## Tuning du pipeline — 2026-04-23
Audit des premières traces après mise en production : les réponses sur les
questions biographiques ("qui est Fernand ?") étaient parfois **hallucinées**
(âge erroné, statut inventé) et les réponses longues **tronquées** en plein
milieu. Quatre ajustements ciblés ont stabilisé le comportement :
| # | Fichier | Changement | Effet attendu |
|---|---------|------------|---------------|
| 1 | `search.py` · `generate()` | `num_ctx` explicite à **8192** | Fin de la troncature silencieuse du prompt (le défaut Ollama à 2048/4096 coupait le début du contexte quand plusieurs notes entières étaient injectées). |
| 1 | `search.py` · `generate()` | `num_predict` **512 → 1024** | Réponses longues (descriptions de projet, explications) ne sont plus coupées en plein milieu. |
| 1 | `search.py` · `generate()` | `think: false` **top-level** | Désactive le mode *thinking* de qwen3. Le modèle n'utilise plus de budget de sortie pour du raisonnement interne. |
| 2 | `search.py` · `build_prompt()` | Troncature conditionnelle des sources **rank 2+** | Les notes secondaires (ex. `inception` sur une question bio) sont résumées à `SEARCH_SECONDARY_MAX_CHARS` chars quand leur score est < `SEARCH_SECONDARY_KEEP_RATIO` × score(#1). Réduit le bruit sans supprimer de source. |
| 3 | `vault-grasbot/30-Parcours/bio-fernand.md` | **Nouvelle note** dédiée à la présentation courte | Source canonique pour les questions du type *"qui est Fernand"*. Priorité 10, aliases biographiques courts. Renvoie vers le CV complet pour le détail. |
| 3 | CV (`cv-grascalvet-fernand.md`) | Incohérence d'âge corrigée (46 → 47 ans) | Supprime la contradiction interne qui alimentait les hallucinations sur l'âge. |
| 4 | `search.py` · `SYSTEM_PROMPT` | Section "Règles de fidélité aux sources" | Force le modèle à (a) s'appuyer en priorité sur `type=parcours` pour les questions bio, (b) ne jamais inventer un fait factuel, (c) écrire *« non précisé dans les notes »* si l'info manque, (d) gérer les contradictions, (e) signaler les notes tronquées. |
Observabilité : dans les spans Langfuse, `prompt_build.metadata.truncation`
liste chaque source tronquée automatiquement → sert de point de vigilance pour
vérifier que la troncature reste pertinente (et n'écrase pas une source qu'on
aurait dû garder entière).
Variables d'environnement associées (dans `llm-api/.env` ou shell) :
| Variable | Défaut | Effet |
|----------|--------|-------|
| `SEARCH_SECONDARY_MAX_CHARS` | `1500` | Taille max des sources secondaires dans le prompt |
| `SEARCH_SECONDARY_KEEP_RATIO` | `0.8` | Tant que score(rank≥2) ≥ ratio × score(#1) → source gardée entière |
Rappel : `load_vault()` est mémoïsé. Après création/modification d'une note du
vault, appeler `POST /reload-vault` pour recharger le cache sans redémarrer
uvicorn (voir `api.py`).
## Évolutions futures possibles
- **Feedback utilisateur** : ajouter un 👍/👎 sur chaque réponse bot dans `ChatBot.js`, relayé à `/api/feedback` qui appellerait `langfuse.score(trace_id, name="user_feedback", value=1|0)`. Le `trace_id` serait retourné par `/ask` (actuellement omis).
- **Prompt versioning** : stocker `SYSTEM_PROMPT` dans Langfuse Prompts pour versionner et A/B tester sans redéploiement.
- **Coût / token pricing** : si on branche un provider payant (OpenAI / Anthropic) à la place d'Ollama, Langfuse calcule automatiquement le coût à partir de l'`usage`.
- **Dataset d'évaluation** : capturer les meilleures traces comme dataset, puis relancer le pipeline sur ces mêmes questions après modif du scoring pour comparer les sorties.

View File

@ -1,21 +0,0 @@
# Copie ce fichier en llm-api/.env et remplis les valeurs.
# Ne committe JAMAIS llm-api/.env (il est dans .gitignore).
#
# Ce fichier est chargé par `observability.py` via python-dotenv au démarrage de FastAPI.
# Toutes les variables sont optionnelles — si elles sont absentes, l'API fonctionne
# normalement mais sans instrumentation Langfuse (no-op silencieux).
# --- Langfuse (observabilité du chatbot GrasBot) ---------------------------
# Projet → Settings → API Keys (instance self-hosted ou cloud).
LANGFUSE_PUBLIC_KEY=pk-lf-xxxxxxxx
LANGFUSE_SECRET_KEY=sk-lf-xxxxxxxx
# URL de l'instance Langfuse. Chez Fernand : self-hosted sous langfuse.fernandgrascalvet.com.
# Le SDK officiel supporte soit LANGFUSE_HOST soit LANGFUSE_BASE_URL — on lit les deux,
# BASE_URL en priorité (c'est ce que l'UI Langfuse recopie dans ses snippets).
LANGFUSE_BASE_URL=https://langfuse.exemple.com
# --- Runtime du pipeline (optionnels, mêmes defaults que search.py) -------
# OLLAMA_URL=http://localhost:11434
# LLM_MODEL=qwen3:8b
# SEARCH_TOP_K=5
# SEARCH_MIN_SCORE=1.0

View File

@ -9,29 +9,14 @@ Historique :
* Expansion par graphe (linked / related / wikilinks du body).
* Plus de dépendance ChromaDB ni hnswlib ni nomic-embed-text.
* Module `rag.py` / `index_vault.py` supprimés.
- 2026-04-23 : intégration **Langfuse** pour observabilité complète du pipeline.
* `/ask` accepte `session_id` et `user_id` optionnels (passés par le front
depuis ChatBot.js via localStorage/sessionStorage).
* L'instrumentation vit dans `search.py` (retrieval + build_prompt + generate).
* Voir `docs-site-interne/langfuse-observability.md` pour le détail.
Voir `docs-site-interne/08-vault-obsidian-retrieval.md` pour l'architecture.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from typing import Optional
from fastapi import FastAPI, HTTPException
# observability doit être importé AVANT search pour que load_dotenv() pose les
# variables d'environnement que search.py lit (OLLAMA_URL, LLM_MODEL, etc.)
# au moment de son import.
from observability import flush as langfuse_flush
from observability import is_enabled as langfuse_enabled
from observability import langfuse
from search import (
LLM_MODEL,
MIN_SCORE,
@ -43,39 +28,22 @@ from search import (
reload_vault,
)
@asynccontextmanager
async def lifespan(_: FastAPI):
"""Flush des traces Langfuse au shutdown pour ne rien perdre en buffer."""
yield
langfuse_flush()
app = FastAPI(title="GrasBot LLM API", version="3.1.0", lifespan=lifespan)
app = FastAPI(title="GrasBot LLM API", version="3.0.0")
@app.get("/ask")
async def ask_question(
q: str,
session_id: Optional[str] = None,
user_id: Optional[str] = None,
):
async def ask_question(q: str):
"""Endpoint historique consommé par `app/utils/askAI.js`.
Le front lit `data.response` : on conserve cette clé pour la compatibilité.
Les champs `sources` / `grounded` / `model` sont des ajouts non destructifs
utilisés par `ChatBot.js` pour afficher les sources cliquables.
`session_id` et `user_id` sont optionnels et transmis pour Langfuse :
- `session_id` : UUID sessionStorage côté front (même conversation = mêmes questions regroupées).
- `user_id` : UUID localStorage côté front (anonyme, stable par device).
Ils sont propagés au span root par `search.answer()` via `langfuse.update_current_trace`.
"""
if not q or not q.strip():
raise HTTPException(status_code=400, detail="Paramètre `q` manquant ou vide.")
try:
return answer(q, session_id=session_id, user_id=user_id)
return answer(q)
except Exception as exc:
print(f"❌ /ask failed ({exc})")
raise HTTPException(status_code=502, detail=str(exc))
@ -85,6 +53,7 @@ async def ask_question(
async def health():
"""Configuration active + stats du vault — utile pour debug / monitoring."""
vault = load_vault()
# Stats rapides pour vérifier que le vault est bien chargé
by_type: dict[str, int] = {}
for n in vault.values():
by_type[n.type] = by_type.get(n.type, 0) + 1
@ -102,9 +71,6 @@ async def health():
"top_k": TOP_K,
"min_score": MIN_SCORE,
},
"observability": {
"langfuse_enabled": langfuse_enabled(),
},
}

View File

@ -1,156 +0,0 @@
"""Observabilité GrasBot via Langfuse — init client + helpers de tracing.
Conçu pour être **optionnel** : si les variables d'environnement Langfuse ne sont pas
définies, le module expose un client *no-op* (dummy) qui ignore silencieusement tous
les appels. Ainsi l'API FastAPI reste fonctionnelle même sans instance Langfuse, et
les dev qui clonent le repo n'ont rien à configurer pour tester `/ask` localement.
Chargement des variables d'environnement :
1. On appelle `load_dotenv()` qui lit `llm-api/.env` s'il existe.
2. On lit `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`, et l'URL (priorité à
`LANGFUSE_BASE_URL` c'est ce que l'UI Langfuse recopie dans ses snippets
puis fallback sur `LANGFUSE_HOST` pour compatibilité avec le SDK standard).
3. Si les 3 sont présentes vrai client Langfuse. Sinon no-op.
Voir `docs-site-interne/langfuse-observability.md` pour l'architecture et ce qu'on trace.
"""
from __future__ import annotations
import os
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Iterator
from dotenv import load_dotenv
# --------------------------------------------------------------------------
# 1. Chargement du .env local (à côté de ce fichier, donc llm-api/.env)
# --------------------------------------------------------------------------
_ENV_PATH = Path(__file__).resolve().parent / ".env"
load_dotenv(_ENV_PATH)
# --------------------------------------------------------------------------
# 2. Client no-op (fallback si Langfuse n'est pas configuré)
# --------------------------------------------------------------------------
class _NullSpan:
"""Remplace un span Langfuse quand l'instrumentation est désactivée."""
def update(self, **kwargs: Any) -> None: # noqa: D401
"""No-op."""
def update_trace(self, **kwargs: Any) -> None: # noqa: D401
"""No-op."""
def score(self, **kwargs: Any) -> None: # noqa: D401
"""No-op."""
def end(self, **kwargs: Any) -> None: # noqa: D401
"""No-op."""
def __enter__(self) -> "_NullSpan":
return self
def __exit__(self, *exc: Any) -> None:
return None
class _NullLangfuse:
"""Client Langfuse factice : toutes les méthodes sont des no-op."""
enabled = False
@contextmanager
def start_as_current_span(self, *args: Any, **kwargs: Any) -> Iterator[_NullSpan]:
yield _NullSpan()
@contextmanager
def start_as_current_observation(self, *args: Any, **kwargs: Any) -> Iterator[_NullSpan]:
yield _NullSpan()
def update_current_trace(self, **kwargs: Any) -> None:
pass
def update_current_span(self, **kwargs: Any) -> None:
pass
def score_current_trace(self, **kwargs: Any) -> None:
pass
def score_current_observation(self, **kwargs: Any) -> None:
pass
def flush(self) -> None:
pass
# --------------------------------------------------------------------------
# 3. Résolution de l'URL + construction du client
# --------------------------------------------------------------------------
def _resolve_host() -> str | None:
"""Priorité LANGFUSE_BASE_URL → LANGFUSE_HOST (compat SDK)."""
return os.environ.get("LANGFUSE_BASE_URL") or os.environ.get("LANGFUSE_HOST")
def _build_client() -> Any:
"""Tente de construire le vrai client Langfuse. Retourne _NullLangfuse en cas d'échec."""
public_key = os.environ.get("LANGFUSE_PUBLIC_KEY")
secret_key = os.environ.get("LANGFUSE_SECRET_KEY")
host = _resolve_host()
if not (public_key and secret_key and host):
missing = [
name
for name, value in (
("LANGFUSE_PUBLIC_KEY", public_key),
("LANGFUSE_SECRET_KEY", secret_key),
("LANGFUSE_BASE_URL/HOST", host),
)
if not value
]
print(
f" Langfuse désactivé — variables manquantes : {', '.join(missing)}. "
"L'API fonctionne normalement, aucun trace ne sera envoyée."
)
return _NullLangfuse()
try:
from langfuse import Langfuse
client = Langfuse(
public_key=public_key,
secret_key=secret_key,
host=host,
)
print(f"✅ Langfuse initialisé (host={host})")
return client
except Exception as exc: # pragma: no cover — défensif
print(
f"⚠️ Langfuse init failed ({exc.__class__.__name__}: {exc}). "
"L'API continue de fonctionner sans observabilité."
)
return _NullLangfuse()
# Singleton : un seul client pour toute la durée du process.
langfuse = _build_client()
# --------------------------------------------------------------------------
# 4. Helper pour obtenir l'attribut `enabled` quel que soit le client
# --------------------------------------------------------------------------
def is_enabled() -> bool:
"""True si le vrai client Langfuse tourne (utile pour skip des calculs coûteux)."""
# Le vrai client n'expose pas `.enabled` ; on vérifie par type.
return not isinstance(langfuse, _NullLangfuse)
# --------------------------------------------------------------------------
# 5. Flush côté shutdown (évite de perdre les dernières traces)
# --------------------------------------------------------------------------
def flush() -> None:
"""À appeler au shutdown de l'API pour forcer l'envoi des traces en buffer."""
try:
langfuse.flush()
except Exception as exc:
print(f"⚠️ Langfuse flush error : {exc}")

View File

@ -6,20 +6,8 @@
# - v2 : ajout chromadb + pyyaml (RAG vectoriel avec nomic-embed-text).
# - v3 : retour à un pipeline graph + BM25, 100 % pure Python, pas de
# compilation C++, pas d'embeddings (lecture directe de vault-grasbot/).
# - v3.1 (2026-04-23) : ajout Langfuse pour observabilité complète du pipeline
# (retrieval + prompt + génération) + python-dotenv pour charger
# `llm-api/.env` automatiquement. Voir docs-site-interne/langfuse-observability.md.
fastapi>=0.110
uvicorn[standard]>=0.27
requests>=2.31
pyyaml>=6.0
# Observabilité (optionnelles en runtime : l'API fonctionne sans si les clés sont absentes).
# NB : on reste sur Langfuse 3.x tant que l'instrumentation dans `observability.py`
# et `search.py` utilise `start_as_current_span` / `start_as_current_observation`
# (API v3). La v4 du SDK a supprimé `start_as_current_span` et modifié la surface
# publique — si on veut migrer, il faudra réécrire ces deux fichiers puis relever
# le plafond ci-dessous.
langfuse>=3.0,<4
python-dotenv>=1.0

View File

@ -26,24 +26,8 @@ Variables d'environnement (toutes optionnelles) :
- `LLM_MODEL` (default: qwen3:8b)
- `VAULT_DIR` (default: <repo_root>/vault-grasbot)
- `SEARCH_TOP_K` (default: 5)
- `SEARCH_MIN_SCORE` (default: 1.0) seuil en-dessous duquel on
considère qu'aucune note pertinente n'a été trouvée.
- `SEARCH_SECONDARY_MAX_CHARS` (default: 1500) taille max (en chars) du body
des sources rank 2+ dans le prompt. Les sources
dépassant cette limite sont tronquées à la
frontière de paragraphe la plus proche.
- `SEARCH_SECONDARY_KEEP_RATIO` (default: 0.8) seuil relatif au score de la
source #1. Tant que score(rank>=2) est ≥
ratio × score(#1), la source est gardée
entière (considérée aussi pertinente).
Instrumentation Langfuse (2026-04-23) :
- `answer()` : trace racine. Metadata (session_id, user_id, tags grounded/model).
- `search()` : span `retrieval` avec scores, reasons, seeds, voisins du graphe.
- `build_prompt()` : span `prompt_build` avec system/user en output.
- `generate()` : span `generation` (type Langfuse spécial : tokens, latence, model).
Voir `docs-site-interne/langfuse-observability.md`.
- `SEARCH_MIN_SCORE` (default: 1.0) seuil en-dessous duquel on considère
qu'aucune note pertinente n'a été trouvée.
"""
from __future__ import annotations
@ -51,7 +35,6 @@ from __future__ import annotations
import math
import os
import re
import time
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
@ -60,8 +43,6 @@ from typing import Any
import requests
import yaml
from observability import langfuse
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
@ -74,16 +55,6 @@ VAULT_DIR = Path(os.environ.get("VAULT_DIR", _DEFAULT_VAULT))
TOP_K = int(os.environ.get("SEARCH_TOP_K", "5"))
MIN_SCORE = float(os.environ.get("SEARCH_MIN_SCORE", "1.0"))
# Troncature des sources secondaires dans le prompt (étape 2, 2026-04-23).
# Rationnel : BM25 peut remonter des projets entiers (ex. `inception`, `cpp-partie2`)
# avec un score respectable pour des questions biographiques — ils polluent le
# contexte sans apporter d'info pertinente. On garde la source #1 entière et on
# tronque uniquement les sources rank 2+ dont le score est < SECONDARY_KEEP_RATIO
# fois celui de la #1, ET dont le body dépasse SECONDARY_MAX_CHARS caractères.
# Aucune source n'est jamais supprimée : le modèle voit toujours le top-K complet.
SECONDARY_MAX_CHARS = int(os.environ.get("SEARCH_SECONDARY_MAX_CHARS", "1500"))
SECONDARY_KEEP_RATIO = float(os.environ.get("SEARCH_SECONDARY_KEEP_RATIO", "0.8"))
# ---------------------------------------------------------------------------
# Tokenisation FR (stop-words minimalistes, suffisants pour 36 notes)
# ---------------------------------------------------------------------------
@ -516,38 +487,15 @@ def expand_by_graph(seed: list[ScoredNote], vault: dict[str, Note],
# ---------------------------------------------------------------------------
# Sérialisation pour Langfuse (évite de loguer des objets Python opaques)
# ---------------------------------------------------------------------------
def _scored_note_to_dict(s: ScoredNote) -> dict[str, Any]:
"""Projection JSON-safe d'une `ScoredNote` pour l'UI Langfuse."""
return {
"slug": s.note.slug,
"title": s.note.title,
"type": s.note.type,
"score": round(s.score, 3),
"reasons": s.reasons,
}
# ---------------------------------------------------------------------------
# API haut-niveau : search (instrumenté Langfuse)
# API haut-niveau : search
# ---------------------------------------------------------------------------
def search(query: str, top_k: int | None = None) -> list[ScoredNote]:
"""Retourne la liste des notes pertinentes pour `query`, triée par score.
Tracé dans Langfuse comme un span `retrieval` on y log les tokens extraits,
les seeds avant expansion, les voisins ajoutés par le graphe, et le top-K final.
"""
"""Retourne la liste des notes pertinentes pour `query`, triée par score."""
top_k = top_k or TOP_K
vault = load_vault()
if not vault:
return []
with langfuse.start_as_current_span(
name="retrieval",
input={"query": query, "top_k": top_k},
) as span:
t0 = time.perf_counter()
stats = _corpus_stats()
query_tokens = tokenize_fr(query)
@ -559,31 +507,11 @@ def search(query: str, top_k: int | None = None) -> list[ScoredNote]:
seeds = scored[:3]
expanded = expand_by_graph(seeds, vault, max_extra=top_k - len(seeds))
expanded.sort(key=lambda x: -x.score)
result = expanded[:top_k]
elapsed_ms = (time.perf_counter() - t0) * 1000
span.update(
output=[_scored_note_to_dict(s) for s in result],
metadata={
"query_tokens": query_tokens,
"vault_size": len(vault),
"candidates_with_signal": len(scored),
"seeds_before_graph": [_scored_note_to_dict(s) for s in seeds],
"bm25_stats": {
"N": stats["N"],
"avgdl": round(stats["avgdl"], 2),
"idf_terms": len(stats["idf"]),
},
"elapsed_ms": round(elapsed_ms, 1),
},
)
return result
return expanded[:top_k]
# ---------------------------------------------------------------------------
# Prompt building (instrumenté)
# Prompt building
# ---------------------------------------------------------------------------
SYSTEM_PROMPT = """Tu es GrasBot, l'assistant IA du portfolio de Fernand Gras-Calvet, étudiant à l'École 42 Perpignan.
@ -591,93 +519,25 @@ Ton rôle :
- Répondre aux visiteurs du site sur le parcours, les projets, les compétences de Fernand.
- T'appuyer sur les notes du vault personnel fournies dans le contexte.
Règles de ton :
Règles :
- Réponds en français, ton sobre et précis, sans emojis.
- Cite tes sources entre crochets carrés en utilisant le slug (ex. [push-swap], [ia]).
- Si l'information n'apparaît pas dans les notes fournies, dis-le honnêtement et oriente vers le site (/portfolio, /competences, /contact) sans inventer.
- Reste concis (3 à 6 phrases en général), sauf demande explicite de détail.
- Si la question est hors sujet (ex. question généraliste sans rapport avec Fernand), indique poliment ton rôle et invite à poser une question sur son parcours.
Règles de fidélité aux sources (important) :
- Chaque source fournie est annotée `type=parcours | projet | moc | competence | glossaire`.
- Pour toute question biographique (qui est Fernand, âge, situation, école, objectif, contact, localisation), appuie-toi **en priorité** sur les sources de `type=parcours` (ex. [bio-fernand], [cv-grascalvet-fernand]). Ne **déduis jamais** d'informations biographiques depuis une source `type=projet` ou `type=moc`.
- Ne **jamais inventer** un fait factuel (âge, date, diplôme, école, entreprise, technologie utilisée) qui n'apparaît pas littéralement dans les sources. Si l'info n'est pas présente, écris « non précisé dans les notes » et oriente vers /portfolio, /competences ou /contact.
- En cas de contradiction entre deux sources, privilégie la source de plus haut score, mentionne brièvement la divergence, et ne choisis jamais une valeur absente des deux.
- Une note dont le body se termine par « note tronquée » a été résumée : signale-le si tu t'appuies dessus pour un point précis, ou invite à consulter la note complète."""
_TRUNCATION_MARKER = "\n\n… *(note tronquée — voir le vault pour le détail)*"
def _truncate_body(body: str, max_chars: int) -> str:
"""Coupe `body` à `max_chars` en essayant de finir sur une frontière propre.
Stratégie :
1. Si le body est déjà max_chars inchangé.
2. Sinon on garde `body[:max_chars]` puis on cherche la dernière coupure
"naturelle" (double saut de ligne = fin de paragraphe, sinon fin de
phrase). On ne recule que si la coupure trouvée est dans la moitié
haute de la fenêtre, pour éviter de perdre trop de contenu.
3. On ajoute un marqueur explicite pour signaler au modèle que la note
a été résumée (évite qu'il conclue "il n'y a pas d'info sur ...").
"""
if len(body) <= max_chars:
return body
truncated = body[:max_chars]
cutoff = -1
for sep in ("\n\n", ". ", "\n", " "):
idx = truncated.rfind(sep)
if idx >= max_chars * 0.6:
cutoff = idx + (len(sep) if sep.endswith(" ") else 0)
break
if cutoff > 0:
truncated = truncated[:cutoff]
return truncated.rstrip() + _TRUNCATION_MARKER
- Si la question est hors sujet (ex. question généraliste sans rapport avec Fernand), indique poliment ton rôle et invite à poser une question sur son parcours."""
def build_prompt(query: str, scored_notes: list[ScoredNote]) -> tuple[str, str]:
"""Assemble (system, user) pour Qwen3.
- Sources gardées : celles dont le score MIN_SCORE.
- Troncature : la source #1 (top score) reste **entière**. Les sources
rank 2+ dont le score est < SECONDARY_KEEP_RATIO × score(#1) et dont le
body dépasse SECONDARY_MAX_CHARS sont résumées par `_truncate_body`.
Aucune source n'est supprimée — le modèle voit toujours tout le top-K.
"""
"""Assemble (system, user) pour Qwen3. Notes **entières** dans le contexte."""
# Seuil : si toutes les notes sont en-dessous, on considère "pas de contexte pertinent"
relevant = [s for s in scored_notes if s.score >= MIN_SCORE]
with langfuse.start_as_current_span(
name="prompt_build",
input={"query": query, "scored_count": len(scored_notes)},
) as span:
truncated_log: list[dict[str, Any]] = []
if relevant:
top_score = relevant[0].score
keep_full_threshold = top_score * SECONDARY_KEEP_RATIO
context_blocks = []
for i, s in enumerate(relevant, 1):
n = s.note
body = n.body
original_chars = len(body)
should_truncate = (
i > 1
and s.score < keep_full_threshold
and original_chars > SECONDARY_MAX_CHARS
)
if should_truncate:
body = _truncate_body(body, SECONDARY_MAX_CHARS)
truncated_log.append(
{
"rank": i,
"slug": n.slug,
"score": round(s.score, 2),
"original_chars": original_chars,
"truncated_chars": len(body),
}
)
header = f"[SOURCE {i} · slug={n.slug} · type={n.type} · score={s.score:.1f}] {n.title}"
context_blocks.append(f"{header}\n{body}")
context_blocks.append(f"{header}\n{n.body}")
context = "\n\n---\n\n".join(context_blocks)
user = (
"Voici les notes pertinentes du vault personnel de Fernand :\n\n"
@ -695,71 +555,27 @@ def build_prompt(query: str, scored_notes: list[ScoredNote]) -> tuple[str, str]:
"Invite le visiteur à explorer /portfolio, /competences, /contact."
)
grounded = bool(relevant)
span.update(
output={"system": SYSTEM_PROMPT, "user": user},
metadata={
"grounded": grounded,
"relevant_notes": [_scored_note_to_dict(s) for s in relevant],
"system_chars": len(SYSTEM_PROMPT),
"user_chars": len(user),
"min_score_threshold": MIN_SCORE,
"truncation": {
"secondary_max_chars": SECONDARY_MAX_CHARS,
"secondary_keep_ratio": SECONDARY_KEEP_RATIO,
"truncated_notes": truncated_log,
},
},
)
return SYSTEM_PROMPT, user
# ---------------------------------------------------------------------------
# Génération via Ollama (instrumenté comme "generation" Langfuse)
# Génération via Ollama
# ---------------------------------------------------------------------------
def generate(system: str, user: str) -> str:
"""Appelle Ollama `/api/chat` et renvoie le texte de réponse.
Span Langfuse de type `generation` expose latence, modèle, paramètres,
et tokens (si l'API Ollama les retourne dans `prompt_eval_count` /
`eval_count`) comme un LLM-call standard dans le dashboard.
Paramètres clefs (tunés 2026-04-23 après audit des traces Langfuse) :
- `num_ctx=8192` : fenêtre de contexte explicite (le défaut Ollama
à 2048/4096 tronquait silencieusement le début du prompt quand les
sources du RAG étaient volumineuses, d'où hallucinations sur l'identité).
- `num_predict=1024` : budget de sortie doublé (512 coupait les réponses
détaillées p. ex. description du site ou d'un projet — en plein milieu).
- `think=False` (top-level, hors `options`) : désactive le mode *thinking*
de qwen3. Sinon le modèle consomme du budget de sortie en raisonnement
interne avant de générer la réponse visible.
"""
model_params = {
"temperature": 0.4,
"num_ctx": 8192,
"num_predict": 1024,
}
messages = [
{"role": "system", "content": system},
{"role": "user", "content": user},
]
with langfuse.start_as_current_observation(
as_type="generation",
name="ollama-chat",
model=LLM_MODEL,
input=messages,
model_parameters={**model_params, "think": False},
) as generation:
"""Appelle Ollama `/api/chat` et renvoie le texte de réponse."""
response = requests.post(
f"{OLLAMA_URL}/api/chat",
json={
"model": LLM_MODEL,
"messages": messages,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"stream": False,
"think": False,
"options": model_params,
"options": {
"temperature": 0.4,
"num_predict": 512,
},
"keep_alive": "30m",
},
timeout=180,
@ -769,43 +585,16 @@ def generate(system: str, user: str) -> str:
message = data.get("message") or {}
content = message.get("content", "").strip()
if not content:
generation.update(
output=None,
metadata={"ollama_raw": data},
level="ERROR",
status_message=f"Empty response from model '{LLM_MODEL}'",
)
raise RuntimeError(
f"generate: réponse vide du modèle '{LLM_MODEL}' — vérifier qu'il est pullé."
)
# Ollama renvoie parfois les comptes de tokens — on les propage si dispos
# (compatible avec le format Langfuse "usage").
usage: dict[str, int] = {}
if "prompt_eval_count" in data:
usage["input"] = int(data["prompt_eval_count"])
if "eval_count" in data:
usage["output"] = int(data["eval_count"])
if usage:
usage["total"] = usage.get("input", 0) + usage.get("output", 0)
update_kwargs: dict[str, Any] = {"output": content}
if usage:
update_kwargs["usage_details"] = usage
generation.update(**update_kwargs)
return content
# ---------------------------------------------------------------------------
# Façade haut-niveau — trace racine Langfuse
# Façade haut-niveau
# ---------------------------------------------------------------------------
def answer(
query: str,
top_k: int | None = None,
session_id: str | None = None,
user_id: str | None = None,
) -> dict[str, Any]:
def answer(query: str, top_k: int | None = None) -> dict[str, Any]:
"""Entrée principale consommée par `api.py`.
Retourne :
@ -816,42 +605,11 @@ def answer(
"grounded": bool, # True si au moins 1 note a dépassé MIN_SCORE
"vault_size": int,
}
Côté Langfuse, crée une trace racine `ask` qui englobe :
- span `retrieval`
- span `prompt_build`
- span `generation` (type generation : model, params, usage)
Avec session_id/user_id propagés au trace-level pour regroupement dans Langfuse.
"""
with langfuse.start_as_current_span(
name="ask",
input={"query": query},
) as root_span:
# Méta au niveau de la TRACE (pas du span), pour filtrer/grouper dans l'UI.
trace_metadata: dict[str, Any] = {
"top_k": top_k or TOP_K,
"min_score": MIN_SCORE,
}
trace_update: dict[str, Any] = {
"name": "ask",
"input": {"query": query},
"metadata": trace_metadata,
}
if session_id:
trace_update["session_id"] = session_id
if user_id:
trace_update["user_id"] = user_id
langfuse.update_current_trace(**trace_update)
# --- Pipeline ---
t0 = time.perf_counter()
scored = search(query, top_k=top_k)
system, user = build_prompt(query, scored)
text = generate(system, user)
elapsed_ms = (time.perf_counter() - t0) * 1000
# --- Construction de la réponse API ---
sources = []
for s in scored:
url = None
@ -869,53 +627,6 @@ def answer(
})
grounded = any(s.score >= MIN_SCORE for s in scored)
max_score = max((s.score for s in scored), default=0.0)
# Score normalisé pour Langfuse : 0 si pas de contexte, sinon
# min(max_score / 15, 1) — 15 ≈ score typique d'un match fort (title + alias).
retrieval_relevance = min(max_score / 15.0, 1.0)
# --- Finalisation : output + scores + tags sur la trace ---
tags = [
"grounded" if grounded else "ungrounded",
f"model:{LLM_MODEL}",
]
if not scored:
tags.append("vault-miss")
langfuse.update_current_trace(
output={
"response": text,
"sources_count": len(sources),
"grounded": grounded,
},
tags=tags,
)
# Scores Langfuse : permettent de filtrer le dashboard (ex. "toutes les
# traces non-grounded du mois") et de tracer des régressions.
try:
langfuse.score_current_trace(
name="grounded",
value=1.0 if grounded else 0.0,
data_type="BOOLEAN",
)
langfuse.score_current_trace(
name="retrieval_relevance",
value=round(retrieval_relevance, 3),
data_type="NUMERIC",
)
except Exception as exc: # pragma: no cover
print(f"⚠ score_current_trace failed: {exc}")
root_span.update(
output={"response_chars": len(text)},
metadata={
"elapsed_ms": round(elapsed_ms, 1),
"sources_count": len(sources),
"max_score": round(max_score, 2),
"grounded": grounded,
},
)
return {
"response": text,

View File

@ -1,104 +1,24 @@
# Script de démarrage du portfolio — lance les 3 services dans des fenêtres PowerShell séparées.
#
# Pendant inverse : stop-my-site.ps1 (arrête proprement les services par port).
#
# Services lancés :
# - Strapi (CMS) → port 1337 → cmsbackend/ (npm run develop)
# - Next.js (site) → port 3000 → racine (npm run dev)
# - FastAPI (GrasBot) → port 8000 → llm-api/ (uvicorn api:app)
#
# Variables d'environnement propagées aux sous-shells :
# - NEXT_PUBLIC_API_URL / PUBLIC_URL = https://api.fernandgrascalvet.com
#
# Vérifications automatiques :
# - Si un port est déjà occupé, on ne relance pas le service concerné
# (évite l'erreur "address already in use" et le doublon de fenêtre).
# - Si Start-Process échoue, on continue avec les autres services et on
# consigne l'échec dans le bilan final.
#
# Portabilité :
# - $PSScriptRoot résout la racine du repo à partir du script → pas de chemin
# codé en dur. Le script marche donc même si le repo est cloné ailleurs.
#
# Usage :
# PS > .\start-my-site.ps1
# Configuration des variables d'environnement
$env:NEXT_PUBLIC_API_URL = "https://api.fernandgrascalvet.com"
$env:PUBLIC_URL = "https://api.fernandgrascalvet.com"
$root = $PSScriptRoot
$apiUrl = "https://api.fernandgrascalvet.com"
Write-Host "🚀 Démarrage du site avec configuration HTTPS..." -ForegroundColor Green
Write-Host "📡 API URL: $env:NEXT_PUBLIC_API_URL" -ForegroundColor Cyan
$services = @(
@{
Nom = "Strapi (CMS)"
Port = 1337
Cwd = Join-Path $root "cmsbackend"
Commande = "`$env:PUBLIC_URL='$apiUrl'; npm run develop"
},
@{
Nom = "Next.js (site)"
Port = 3000
Cwd = $root
Commande = "`$env:NEXT_PUBLIC_API_URL='$apiUrl'; npm run dev"
},
@{
Nom = "FastAPI (GrasBot)"
Port = 8000
Cwd = Join-Path $root "llm-api"
Commande = "uvicorn api:app --host 0.0.0.0 --port 8000"
}
)
# Lancer Strapi avec configuration HTTPS
Write-Host "🔧 Démarrage de Strapi..." -ForegroundColor Yellow
Start-Process powershell -ArgumentList "cd 'J:\my-next-site\cmsbackend'; `$env:PUBLIC_URL='https://api.fernandgrascalvet.com'; npm run develop" -WindowStyle Normal
Write-Host "🚀 Démarrage des services du portfolio..." -ForegroundColor Green
Write-Host " API URL : $apiUrl" -ForegroundColor Cyan
Write-Host ""
# Attendre un peu pour que Strapi démarre
Start-Sleep -Seconds 3
$lances = 0
$dejaActifs = 0
$echecs = 0
# Lancer Next.js
Write-Host "⚡ Démarrage de Next.js..." -ForegroundColor Yellow
Start-Process powershell -ArgumentList "cd 'J:\my-next-site'; `$env:NEXT_PUBLIC_API_URL='https://api.fernandgrascalvet.com'; npm run dev" -WindowStyle Normal
foreach ($s in $services) {
# Si le port est déjà écouté, le service tourne sans doute déjà — on saute.
$existing = Get-NetTCPConnection -LocalPort $s.Port -State Listen -ErrorAction SilentlyContinue
if ($existing) {
Write-Host "$($s.Nom) — port $($s.Port) déjà occupé, pas de relance" -ForegroundColor Gray
$dejaActifs++
continue
}
# Lancer FastAPI
Write-Host "🤖 Démarrage de FastAPI..." -ForegroundColor Yellow
Start-Process powershell -ArgumentList "cd 'J:\my-next-site\llm-api'; uvicorn api:app --host 0.0.0.0 --port 8000" -WindowStyle Normal
try {
Write-Host " 🔵 $($s.Nom) — port $($s.Port)$($s.Cwd)" -ForegroundColor Cyan
# Construction de la commande passée au sous-shell : on se place dans
# le bon répertoire, puis on lance la commande du service. -NoExit
# laisse la fenêtre ouverte même si la commande se termine (utile pour
# voir le message d'erreur si un service crashe au démarrage).
$fullCommand = "Set-Location -LiteralPath '$($s.Cwd)'; $($s.Commande)"
Start-Process powershell `
-ArgumentList @('-NoExit', '-Command', $fullCommand) `
-WindowStyle Normal `
-ErrorAction Stop
Write-Host " ✅ lancé" -ForegroundColor Green
$lances++
# Petite respiration entre les services pour étaler la charge CPU au
# boot (Strapi et Next sont tous deux gourmands au premier démarrage).
Start-Sleep -Milliseconds 800
} catch {
Write-Host " ⚠️ impossible de lancer : $($_.Exception.Message)" -ForegroundColor Red
$echecs++
}
}
Write-Host ""
Write-Host "── Bilan ─────────────────────────────" -ForegroundColor Cyan
Write-Host " Services lancés : $lances" -ForegroundColor Green
Write-Host " Déjà actifs : $dejaActifs" -ForegroundColor Gray
if ($echecs -gt 0) {
Write-Host " Échecs : $echecs" -ForegroundColor Red
}
if ($lances -gt 0 -or $dejaActifs -ge $services.Count) {
Write-Host ""
Write-Host "🌐 Site accessible sur : http://localhost:3000" -ForegroundColor Cyan
Write-Host " (attendre ~15 s que Strapi et Next soient prêts au premier lancement)" -ForegroundColor Gray
}
Write-Host "✅ Tous les services sont en cours de démarrage!" -ForegroundColor Green
Write-Host "🌐 Site accessible sur: http://localhost:3000" -ForegroundColor Cyan

View File

@ -1,72 +0,0 @@
# Script inverse de start-my-site.ps1 — arrête proprement les 3 services du portfolio.
#
# Stratégie : on identifie le processus qui écoute chaque port attendu, puis on
# le termine. C'est plus sûr que `Stop-Process -Name node` qui tuerait TOUS les
# node.exe de la machine (y compris d'autres projets que tu aurais ouverts).
#
# Ports surveillés :
# - 1337 → Strapi (CMS)
# - 3000 → Next.js (site)
# - 8000 → FastAPI / uvicorn (GrasBot LLM)
#
# Ce script ne ferme PAS les fenêtres PowerShell elles-mêmes : elles restent
# ouvertes avec un prompt une fois le processus enfant terminé. Tu peux les
# fermer manuellement ou ajouter un alias "exit" dans ton profil PowerShell.
#
# Usage :
# PS > .\stop-my-site.ps1
$services = @(
@{ Nom = "Strapi (CMS)"; Port = 1337 },
@{ Nom = "Next.js (site)"; Port = 3000 },
@{ Nom = "FastAPI (GrasBot)"; Port = 8000 }
)
Write-Host "🛑 Arrêt des services du portfolio..." -ForegroundColor Yellow
Write-Host ""
$arretes = 0
$nonTrouves = 0
$echecs = 0
foreach ($s in $services) {
# -ErrorAction SilentlyContinue : Get-NetTCPConnection lève une erreur
# bruyante si aucun processus n'écoute le port. On préfère gérer nous-mêmes.
$conn = Get-NetTCPConnection -LocalPort $s.Port -State Listen -ErrorAction SilentlyContinue
if ($null -eq $conn) {
Write-Host "$($s.Nom) — port $($s.Port) libre, rien à arrêter" -ForegroundColor Gray
$nonTrouves++
continue
}
# Un même processus peut exposer le port en IPv4 et IPv6 (2 lignes, même PID).
$procIds = $conn | Select-Object -ExpandProperty OwningProcess -Unique
foreach ($procId in $procIds) {
try {
$proc = Get-Process -Id $procId -ErrorAction Stop
Write-Host " 🔴 $($s.Nom) — port $($s.Port) → PID $procId ($($proc.ProcessName))" -ForegroundColor Red
Stop-Process -Id $procId -Force -ErrorAction Stop
Write-Host " ✅ arrêté" -ForegroundColor Green
$arretes++
} catch {
Write-Host " ⚠️ impossible d'arrêter le PID $procId : $($_.Exception.Message)" -ForegroundColor Yellow
$echecs++
}
}
}
Write-Host ""
Write-Host "── Bilan ─────────────────────────────" -ForegroundColor Cyan
Write-Host " Services arrêtés : $arretes" -ForegroundColor Green
Write-Host " Déjà libres : $nonTrouves" -ForegroundColor Gray
if ($echecs -gt 0) {
Write-Host " Échecs : $echecs" -ForegroundColor Red
Write-Host ""
Write-Host "💡 Échecs possibles : droits administrateur requis pour tuer certains processus." -ForegroundColor Yellow
Write-Host " Relance le script dans un PowerShell admin si besoin." -ForegroundColor Yellow
} else {
Write-Host ""
Write-Host "✅ Tous les services ciblés ont été traités." -ForegroundColor Green
}

View File

@ -1,69 +0,0 @@
---
title: "Bio — Fernand Gras-Calvet (présentation courte)"
slug: bio-fernand
type: parcours
source: manual
domains: [parcours, ecole-42, ia]
tags: [bio, presentation, parcours, alternance]
aliases:
- bio
- biographie
- présentation
- présentation courte
- en bref
- fernand en bref
- qui est fernand
- qui est-il
- parle-moi de fernand
- que peux-tu me dire de fernand
answers:
- "Qui est Fernand ?"
- "Qui est Fernand Gras-Calvet ?"
- "Que peux-tu me dire de Fernand ?"
- "Parle-moi de Fernand."
- "Fernand en quelques mots ?"
- "Présente-toi."
priority: 10
linked:
- "[[cv-grascalvet-fernand]]"
- "[[MOC-Parcours]]"
related:
- "[[ia]]"
updated: 2026-04-23
visibility: public
---
# Bio — Fernand Gras-Calvet
> [!info] Rôle de cette note
> Version **courte et factuelle** de la présentation de Fernand, pensée
> comme premier résultat du chatbot sur les questions du type *« qui est
> Fernand ? »*. Pour le détail complet (compétences, expériences, passions),
> voir [[cv-grascalvet-fernand|le CV]].
## Identité
- **Nom** : Fernand Gras-Calvet
- **Âge** : 47 ans
- **Situation** : étudiant en informatique à l'**École 42 Perpignan**
- **Localisation** : Rivesaltes (Pyrénées-Orientales, 66)
- **Statut** : reconversion professionnelle, bénéficiaire d'une **RQTH**
## Objectif professionnel
Trouver une **alternance de 2 ans** en **Data / IA** pour se spécialiser dans
l'**automatisation agentique en entreprise** (LLM, agents, intégration
d'outils internes).
## Parcours en une phrase
Ancien **infirmier diplômé d'État** (10 ans en gériatrie, 2014-2023), après
plus de 12 ans en **ostréiculture familiale** à Leucate, aujourd'hui en
reconversion IT à l'École 42 (2023-2025) avec un stage réalisé autour d'un
**chatbot multi-agent** en entreprise.
## Pour aller plus loin
- [[cv-grascalvet-fernand|CV détaillé]] — expériences, compétences techniques, intérêts
- [[MOC-Projets]] — vue d'ensemble des projets École 42
- [[ia|IA]] — note thématique sur son domaine cible

View File

@ -28,7 +28,6 @@ answers:
- "A-t-il de l'expérience professionnelle ?"
priority: 10
linked:
- "[[bio-fernand]]"
- "[[MOC-Parcours]]"
- "[[MOC-Ecole-42]]"
- "[[MOC-Ia]]"
@ -50,7 +49,7 @@ visibility: public
## Identité
- **Nom** : Gras-Calvet Fernand
- **Âge** : 47 ans
- **Âge** : 46 ans
- **Situation** : Étudiant en informatique, École 42 Perpignan
- **Objectif** : Alternance **Data / IA** (2 ans)
- **RQTH** : reconversion professionnelle suite à problèmes de santé
@ -65,7 +64,7 @@ visibility: public
## Présentation
Ancien infirmier de 47 ans, actuellement étudiant en informatique à l'École 42
Ancien infirmier de 46 ans, actuellement étudiant en informatique à l'École 42
Perpignan. Je recherche une alternance de 2 ans pour me spécialiser dans
**l'automatisation agentique au sein des entreprises**, en y apportant mon
expérience sur le traitement de Data et les nouveaux process basés sur les LLM.