mirror of
https://github.com/Ladebeze66/devsite.git
synced 2026-05-11 16:56:26 +02:00
Compare commits
No commits in common. "a0e59442f4dbbba07ed3e068c077e3ea8d655b20" and "1267d60cf2adf4811d9b4dfe4f6d21c70cd88c97" have entirely different histories.
a0e59442f4
...
1267d60cf2
19
.env.example
19
.env.example
@ -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
5
.gitignore
vendored
@ -32,11 +32,6 @@ yarn-error.log*
|
|||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.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
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@ -14,16 +14,11 @@ Ce site utilise une architecture full-stack moderne avec :
|
|||||||
my-next-site/
|
my-next-site/
|
||||||
├── app/ # Application Next.js
|
├── app/ # Application Next.js
|
||||||
├── cmsbackend/ # Backend Strapi
|
├── cmsbackend/ # Backend Strapi
|
||||||
├── llm-api/ # API FastAPI pour IA (+ instrumentation Langfuse)
|
├── llm-api/ # API FastAPI pour IA
|
||||||
│ ├── .env # Secrets Python (Langfuse, etc.) — non committé
|
|
||||||
│ └── observability.py # Init client Langfuse (no-op safe)
|
|
||||||
├── start-my-site.ps1 # Script de démarrage
|
├── start-my-site.ps1 # Script de démarrage
|
||||||
├── stop-my-site.ps1 # Script d'arrêt propre
|
|
||||||
└── package.json # Dépendances frontend
|
└── 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
|
## 🚀 Démarrage Rapide
|
||||||
|
|
||||||
### Script Automatique (Recommandé)
|
### 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.
|
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
|
## 🔧 Commandes Manuelles
|
||||||
|
|
||||||
### 1. Frontend Next.js
|
### 1. Frontend Next.js
|
||||||
@ -79,10 +57,6 @@ uvicorn api:app --host 0.0.0.0 --port 8000 --reload
|
|||||||
- **API** : http://localhost:8000
|
- **API** : http://localhost:8000
|
||||||
- **Endpoint IA** : http://localhost:8000/ask?q=votre_question
|
- **Endpoint IA** : http://localhost:8000/ask?q=votre_question
|
||||||
- **Documentation** : http://localhost:8000/docs
|
- **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
|
## 📊 Ports Utilisés
|
||||||
|
|
||||||
|
|||||||
27
app/admin/messages/page.tsx
Normal file
27
app/admin/messages/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 ré-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, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
@ -1,63 +1,31 @@
|
|||||||
/**
|
|
||||||
* 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) {
|
export async function GET(req) {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const question = searchParams.get("q");
|
const question = searchParams.get("q");
|
||||||
|
|
||||||
if (!question) {
|
if (!question) {
|
||||||
return new Response(JSON.stringify({ error: "Question manquante" }), {
|
return new Response(JSON.stringify({ error: "Question manquante" }), { status: 400 });
|
||||||
status: 400,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
try {
|
||||||
const response = await fetch(apiUrl, {
|
const response = await fetch(apiUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return new Response(JSON.stringify(data), {
|
return new Response(JSON.stringify(data), {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
headers: {
|
headers: {
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return new Response(
|
return new Response(JSON.stringify({ error: "Erreur de communication avec l'API" }), {
|
||||||
JSON.stringify({ error: "Erreur de communication avec l'API" }),
|
status: 500,
|
||||||
{
|
});
|
||||||
status: 500,
|
}
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,33 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
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 :
|
* - Plus de `bg-white shadow-lg rounded-lg` sur le form : il est désormais
|
||||||
* Form → POST /api/contact (Next.js server route, voir app/api/contact/route.ts)
|
* 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`.
|
||||||
* Brevo API HTTP → Gmail
|
* - 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`.
|
||||||
* Plus de passage par Strapi pour les messages : la route serveur valide,
|
* - Bandeau status Stitch : succès en `primary-fixed`, erreur en `error-container`,
|
||||||
* filtre (honeypot + rate-limit), puis envoie une notification email. Voir
|
* chargement en `surface-container`. Chaque état porte une Material Symbol.
|
||||||
* `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.
|
|
||||||
*/
|
*/
|
||||||
export default function ContactForm() {
|
export default function ContactForm() {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [message, setMessage] = 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 [status, setStatus] = useState("");
|
||||||
const [statusKind, setStatusKind] = useState<
|
const [statusKind, setStatusKind] = useState<
|
||||||
"idle" | "loading" | "success" | "error"
|
"idle" | "loading" | "success" | "error"
|
||||||
@ -54,55 +44,24 @@ export default function ContactForm() {
|
|||||||
setStatusKind("loading");
|
setStatusKind("loading");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/contact", {
|
await sendMessage(name, email, message);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus("Message envoyé. Merci, je reviens vers vous rapidement.");
|
setStatus("Message envoyé. Merci, je reviens vers vous rapidement.");
|
||||||
setStatusKind("success");
|
setStatusKind("success");
|
||||||
setName("");
|
setName("");
|
||||||
setEmail("");
|
setEmail("");
|
||||||
setMessage("");
|
setMessage("");
|
||||||
setWebsite("");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[ContactForm] submit failed:", error);
|
setStatus("Erreur lors de l'envoi du message.");
|
||||||
setStatus("Erreur réseau. Vérifiez votre connexion et réessayez.");
|
|
||||||
setStatusKind("error");
|
setStatusKind("error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusStyles: Record<typeof statusKind, string> = {
|
const statusStyles: Record<typeof statusKind, string> = {
|
||||||
idle: "",
|
idle: "",
|
||||||
loading: "bg-surface-container text-on-surface-variant",
|
loading:
|
||||||
success: "bg-primary-fixed/70 text-on-primary-fixed",
|
"bg-surface-container text-on-surface-variant",
|
||||||
|
success:
|
||||||
|
"bg-primary-fixed/70 text-on-primary-fixed",
|
||||||
error: "bg-error-container text-on-error-container",
|
error: "bg-error-container text-on-error-container",
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -118,32 +77,6 @@ export default function ContactForm() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3" noValidate>
|
<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">
|
<label className="flex flex-col gap-1">
|
||||||
<span className="font-headline text-[11px] font-bold uppercase tracking-[0.3em] text-secondary">
|
<span className="font-headline text-[11px] font-bold uppercase tracking-[0.3em] text-secondary">
|
||||||
Votre nom
|
Votre nom
|
||||||
@ -155,7 +88,6 @@ export default function ContactForm() {
|
|||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
className={fieldClass}
|
className={fieldClass}
|
||||||
required
|
required
|
||||||
maxLength={120}
|
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@ -171,7 +103,6 @@ export default function ContactForm() {
|
|||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
className={fieldClass}
|
className={fieldClass}
|
||||||
required
|
required
|
||||||
maxLength={160}
|
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@ -185,7 +116,6 @@ export default function ContactForm() {
|
|||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
rows={5}
|
rows={5}
|
||||||
maxLength={5000}
|
|
||||||
className={`${fieldClass} min-h-[9rem] resize-y`}
|
className={`${fieldClass} min-h-[9rem] resize-y`}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import { getGrasbotSessionId, getGrasbotUserId } from "./grasbotIds";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Appelle l'API GrasBot via le proxy Next (/api/proxy).
|
* 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`
|
* - 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
|
* puisse afficher les sources citées, le badge `grounded`, etc. Ajoute un
|
||||||
* timeout (45 s) via `AbortController` pour éviter les spinners infinis.
|
* 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
|
* @param {string} question
|
||||||
* @returns {Promise<{
|
* @returns {Promise<{
|
||||||
@ -26,14 +21,8 @@ export async function askAI(question) {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 45_000);
|
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 {
|
try {
|
||||||
const response = await fetch(`/api/proxy?${params.toString()}`, {
|
const response = await fetch(`/api/proxy?q=${encodeURIComponent(question)}`, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
|
|||||||
@ -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
35
app/utils/sendMessage.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -17,8 +17,8 @@
|
|||||||
| `/portfolio/[slug]` | `app/portfolio/[slug]/page.tsx` | Détail projet (`fetchData('projects', slug)`) |
|
| `/portfolio/[slug]` | `app/portfolio/[slug]/page.tsx` | Détail projet (`fetchData('projects', slug)`) |
|
||||||
| `/competences` | `app/competences/page.jsx` | Liste compétences |
|
| `/competences` | `app/competences/page.jsx` | Liste compétences |
|
||||||
| `/competences/[slug]` | `app/competences/[slug]/page.tsx` | Détail (`fetchData('competences', slug)`) |
|
| `/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`) |
|
| `/contact` | `app/contact/page.js` | Formulaire → Strapi `messages` |
|
||||||
| `/api/contact` | `app/api/contact/route.ts` | Endpoint serveur : envoie un email via Brevo, honeypot + rate-limit |
|
| `/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 |
|
| `/api/proxy` | `app/api/proxy/route.js` | Proxy GET vers API LLM distante |
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
@ -42,8 +42,7 @@
|
|||||||
|
|
||||||
- Carrousels : `Carousel.tsx`, `CarouselCompetences.tsx` (swiper / react-responsive-carousel).
|
- Carrousels : `Carousel.tsx`, `CarouselCompetences.tsx` (swiper / react-responsive-carousel).
|
||||||
- Sections : `ContentSection.tsx`, `ContentSectionCompetences*.tsx`.
|
- Sections : `ContentSection.tsx`, `ContentSectionCompetences*.tsx`.
|
||||||
- `ContactForm.tsx` → `POST /api/contact` → Brevo API (voir `docs-site-interne/contact-flow.md`).
|
- `ContactForm.tsx` → `sendMessage.ts`.
|
||||||
- `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`).
|
|
||||||
- `ChatBot.js` → `askAI.js` → `/api/proxy`.
|
- `ChatBot.js` → `askAI.js` → `/api/proxy`.
|
||||||
- `ModalGlossaire.tsx` — glossaire (données Strapi selon usage dans les pages).
|
- `ModalGlossaire.tsx` — glossaire (données Strapi selon usage dans les pages).
|
||||||
|
|
||||||
@ -54,6 +53,6 @@ app/layout.tsx
|
|||||||
app/page.tsx
|
app/page.tsx
|
||||||
app/utils/getApiUrl.ts
|
app/utils/getApiUrl.ts
|
||||||
app/utils/fetchData.ts
|
app/utils/fetchData.ts
|
||||||
app/api/contact/route.ts
|
app/utils/sendMessage.ts
|
||||||
next.config.ts
|
next.config.ts
|
||||||
```
|
```
|
||||||
|
|||||||
@ -43,9 +43,15 @@ Utilisation front : `app/page.tsx` — premier enregistrement `populate=*`, imag
|
|||||||
| `slug` | uid ← `name` | requis |
|
| `slug` | uid ← `name` | requis |
|
||||||
| `order` | integer | optionnel |
|
| `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`)
|
### `glossaire` (collection `glossaires`)
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
- **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).
|
- **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)
|
## 5. Checklist relecture (à passer à la fin de chaque étape)
|
||||||
|
|
||||||
- [ ] Aucune colonne unique globale `max-w-xl` (c'est le format newsletter).
|
- [ ] Aucune colonne unique globale `max-w-xl` (c'est le format newsletter).
|
||||||
|
|||||||
@ -104,13 +104,12 @@ Les captures suivantes **n’ont 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) |
|
| 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) |
|
| 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` |
|
| 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
|
## 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`).
|
- **É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/`).
|
||||||
- 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 pour une future passe : fusion `Carousel.tsx` / `CarouselCompetences.tsx` (doublons), persistance serveur du compteur de visites, validation anti-spam du formulaire.
|
||||||
- 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).
|
|
||||||
|
|||||||
@ -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".
|
|
||||||
@ -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.
|
|
||||||
@ -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
|
|
||||||
Binary file not shown.
@ -9,29 +9,14 @@ Historique :
|
|||||||
* Expansion par graphe (linked / related / wikilinks du body).
|
* Expansion par graphe (linked / related / wikilinks du body).
|
||||||
* Plus de dépendance ChromaDB ni hnswlib ni nomic-embed-text.
|
* Plus de dépendance ChromaDB ni hnswlib ni nomic-embed-text.
|
||||||
* Module `rag.py` / `index_vault.py` supprimés.
|
* 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.
|
Voir `docs-site-interne/08-vault-obsidian-retrieval.md` pour l'architecture.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException
|
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 (
|
from search import (
|
||||||
LLM_MODEL,
|
LLM_MODEL,
|
||||||
MIN_SCORE,
|
MIN_SCORE,
|
||||||
@ -43,39 +28,22 @@ from search import (
|
|||||||
reload_vault,
|
reload_vault,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app = FastAPI(title="GrasBot LLM API", version="3.0.0")
|
||||||
@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.get("/ask")
|
@app.get("/ask")
|
||||||
async def ask_question(
|
async def ask_question(q: str):
|
||||||
q: str,
|
|
||||||
session_id: Optional[str] = None,
|
|
||||||
user_id: Optional[str] = None,
|
|
||||||
):
|
|
||||||
"""Endpoint historique consommé par `app/utils/askAI.js`.
|
"""Endpoint historique consommé par `app/utils/askAI.js`.
|
||||||
|
|
||||||
Le front lit `data.response` : on conserve cette clé pour la compatibilité.
|
Le front lit `data.response` : on conserve cette clé pour la compatibilité.
|
||||||
Les champs `sources` / `grounded` / `model` sont des ajouts non destructifs
|
Les champs `sources` / `grounded` / `model` sont des ajouts non destructifs
|
||||||
utilisés par `ChatBot.js` pour afficher les sources cliquables.
|
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():
|
if not q or not q.strip():
|
||||||
raise HTTPException(status_code=400, detail="Paramètre `q` manquant ou vide.")
|
raise HTTPException(status_code=400, detail="Paramètre `q` manquant ou vide.")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return answer(q, session_id=session_id, user_id=user_id)
|
return answer(q)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"❌ /ask failed ({exc})")
|
print(f"❌ /ask failed ({exc})")
|
||||||
raise HTTPException(status_code=502, detail=str(exc))
|
raise HTTPException(status_code=502, detail=str(exc))
|
||||||
@ -85,6 +53,7 @@ async def ask_question(
|
|||||||
async def health():
|
async def health():
|
||||||
"""Configuration active + stats du vault — utile pour debug / monitoring."""
|
"""Configuration active + stats du vault — utile pour debug / monitoring."""
|
||||||
vault = load_vault()
|
vault = load_vault()
|
||||||
|
# Stats rapides pour vérifier que le vault est bien chargé
|
||||||
by_type: dict[str, int] = {}
|
by_type: dict[str, int] = {}
|
||||||
for n in vault.values():
|
for n in vault.values():
|
||||||
by_type[n.type] = by_type.get(n.type, 0) + 1
|
by_type[n.type] = by_type.get(n.type, 0) + 1
|
||||||
@ -102,9 +71,6 @@ async def health():
|
|||||||
"top_k": TOP_K,
|
"top_k": TOP_K,
|
||||||
"min_score": MIN_SCORE,
|
"min_score": MIN_SCORE,
|
||||||
},
|
},
|
||||||
"observability": {
|
|
||||||
"langfuse_enabled": langfuse_enabled(),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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}")
|
|
||||||
@ -6,20 +6,8 @@
|
|||||||
# - v2 : ajout chromadb + pyyaml (RAG vectoriel avec nomic-embed-text).
|
# - v2 : ajout chromadb + pyyaml (RAG vectoriel avec nomic-embed-text).
|
||||||
# - v3 : retour à un pipeline graph + BM25, 100 % pure Python, pas de
|
# - v3 : retour à un pipeline graph + BM25, 100 % pure Python, pas de
|
||||||
# compilation C++, pas d'embeddings (lecture directe de vault-grasbot/).
|
# 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
|
fastapi>=0.110
|
||||||
uvicorn[standard]>=0.27
|
uvicorn[standard]>=0.27
|
||||||
requests>=2.31
|
requests>=2.31
|
||||||
pyyaml>=6.0
|
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
|
|
||||||
|
|||||||
@ -22,28 +22,12 @@ Pipeline :
|
|||||||
|
|
||||||
Variables d'environnement (toutes optionnelles) :
|
Variables d'environnement (toutes optionnelles) :
|
||||||
|
|
||||||
- `OLLAMA_URL` (default: http://localhost:11434)
|
- `OLLAMA_URL` (default: http://localhost:11434)
|
||||||
- `LLM_MODEL` (default: qwen3:8b)
|
- `LLM_MODEL` (default: qwen3:8b)
|
||||||
- `VAULT_DIR` (default: <repo_root>/vault-grasbot)
|
- `VAULT_DIR` (default: <repo_root>/vault-grasbot)
|
||||||
- `SEARCH_TOP_K` (default: 5)
|
- `SEARCH_TOP_K` (default: 5)
|
||||||
- `SEARCH_MIN_SCORE` (default: 1.0) — seuil en-dessous duquel on
|
- `SEARCH_MIN_SCORE` (default: 1.0) — seuil en-dessous duquel on considère
|
||||||
considère qu'aucune note pertinente n'a été trouvée.
|
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`.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@ -51,7 +35,6 @@ from __future__ import annotations
|
|||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -60,8 +43,6 @@ from typing import Any
|
|||||||
import requests
|
import requests
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from observability import langfuse
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Configuration
|
# 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"))
|
TOP_K = int(os.environ.get("SEARCH_TOP_K", "5"))
|
||||||
MIN_SCORE = float(os.environ.get("SEARCH_MIN_SCORE", "1.0"))
|
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)
|
# Tokenisation FR (stop-words minimalistes, suffisants pour 36 notes)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -516,74 +487,31 @@ def expand_by_graph(seed: list[ScoredNote], vault: dict[str, Note],
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Sérialisation pour Langfuse (évite de loguer des objets Python opaques)
|
# API haut-niveau : search
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
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)
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
def search(query: str, top_k: int | None = None) -> list[ScoredNote]:
|
def search(query: str, top_k: int | None = None) -> list[ScoredNote]:
|
||||||
"""Retourne la liste des notes pertinentes pour `query`, triée par score.
|
"""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.
|
|
||||||
"""
|
|
||||||
top_k = top_k or TOP_K
|
top_k = top_k or TOP_K
|
||||||
vault = load_vault()
|
vault = load_vault()
|
||||||
if not vault:
|
if not vault:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
with langfuse.start_as_current_span(
|
stats = _corpus_stats()
|
||||||
name="retrieval",
|
query_tokens = tokenize_fr(query)
|
||||||
input={"query": query, "top_k": top_k},
|
|
||||||
) as span:
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
stats = _corpus_stats()
|
|
||||||
query_tokens = tokenize_fr(query)
|
|
||||||
|
|
||||||
scored = [score_note(note, query, query_tokens, stats) for note in vault.values()]
|
scored = [score_note(note, query, query_tokens, stats) for note in vault.values()]
|
||||||
scored = [s for s in scored if s.score > 0]
|
scored = [s for s in scored if s.score > 0]
|
||||||
scored.sort(key=lambda x: -x.score)
|
scored.sort(key=lambda x: -x.score)
|
||||||
|
|
||||||
# Top-N brut avant expansion (garde 3 seeds pour expansion graphe)
|
# Top-N brut avant expansion (garde 3 seeds pour expansion graphe)
|
||||||
seeds = scored[:3]
|
seeds = scored[:3]
|
||||||
expanded = expand_by_graph(seeds, vault, max_extra=top_k - len(seeds))
|
expanded = expand_by_graph(seeds, vault, max_extra=top_k - len(seeds))
|
||||||
expanded.sort(key=lambda x: -x.score)
|
expanded.sort(key=lambda x: -x.score)
|
||||||
result = expanded[:top_k]
|
return 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
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Prompt building (instrumenté)
|
# Prompt building
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
SYSTEM_PROMPT = """Tu es GrasBot, l'assistant IA du portfolio de Fernand Gras-Calvet, étudiant à l'École 42 Perpignan.
|
SYSTEM_PROMPT = """Tu es GrasBot, l'assistant IA du portfolio de Fernand Gras-Calvet, étudiant à l'École 42 Perpignan.
|
||||||
|
|
||||||
@ -591,221 +519,82 @@ Ton rôle :
|
|||||||
- Répondre aux visiteurs du site sur le parcours, les projets, les compétences de Fernand.
|
- 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.
|
- 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.
|
- 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]).
|
- 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.
|
- 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.
|
- 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
|
|
||||||
|
|
||||||
|
|
||||||
def build_prompt(query: str, scored_notes: list[ScoredNote]) -> tuple[str, str]:
|
def build_prompt(query: str, scored_notes: list[ScoredNote]) -> tuple[str, str]:
|
||||||
"""Assemble (system, user) pour Qwen3.
|
"""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"
|
||||||
- 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.
|
|
||||||
"""
|
|
||||||
relevant = [s for s in scored_notes if s.score >= MIN_SCORE]
|
relevant = [s for s in scored_notes if s.score >= MIN_SCORE]
|
||||||
|
|
||||||
with langfuse.start_as_current_span(
|
if relevant:
|
||||||
name="prompt_build",
|
context_blocks = []
|
||||||
input={"query": query, "scored_count": len(scored_notes)},
|
for i, s in enumerate(relevant, 1):
|
||||||
) as span:
|
n = s.note
|
||||||
truncated_log: list[dict[str, Any]] = []
|
header = f"[SOURCE {i} · slug={n.slug} · type={n.type} · score={s.score:.1f}] {n.title}"
|
||||||
|
context_blocks.append(f"{header}\n{n.body}")
|
||||||
if relevant:
|
context = "\n\n---\n\n".join(context_blocks)
|
||||||
top_score = relevant[0].score
|
user = (
|
||||||
keep_full_threshold = top_score * SECONDARY_KEEP_RATIO
|
"Voici les notes pertinentes du vault personnel de Fernand :\n\n"
|
||||||
context_blocks = []
|
f"{context}\n\n"
|
||||||
for i, s in enumerate(relevant, 1):
|
"---\n\n"
|
||||||
n = s.note
|
f"Question du visiteur : {query}\n\n"
|
||||||
body = n.body
|
"Réponds en t'appuyant sur ces notes. Si la question dépasse leur portée, dis-le."
|
||||||
original_chars = len(body)
|
)
|
||||||
should_truncate = (
|
else:
|
||||||
i > 1
|
user = (
|
||||||
and s.score < keep_full_threshold
|
f"Question du visiteur : {query}\n\n"
|
||||||
and original_chars > SECONDARY_MAX_CHARS
|
"Note : aucune fiche du vault ne correspond clairement à cette question. "
|
||||||
)
|
"Réponds sobrement à partir de tes connaissances générales, "
|
||||||
if should_truncate:
|
"sans inventer de faits spécifiques sur Fernand. "
|
||||||
body = _truncate_body(body, SECONDARY_MAX_CHARS)
|
"Invite le visiteur à explorer /portfolio, /competences, /contact."
|
||||||
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 = "\n\n---\n\n".join(context_blocks)
|
|
||||||
user = (
|
|
||||||
"Voici les notes pertinentes du vault personnel de Fernand :\n\n"
|
|
||||||
f"{context}\n\n"
|
|
||||||
"---\n\n"
|
|
||||||
f"Question du visiteur : {query}\n\n"
|
|
||||||
"Réponds en t'appuyant sur ces notes. Si la question dépasse leur portée, dis-le."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
user = (
|
|
||||||
f"Question du visiteur : {query}\n\n"
|
|
||||||
"Note : aucune fiche du vault ne correspond clairement à cette question. "
|
|
||||||
"Réponds sobrement à partir de tes connaissances générales, "
|
|
||||||
"sans inventer de faits spécifiques sur Fernand. "
|
|
||||||
"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
|
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:
|
def generate(system: str, user: str) -> str:
|
||||||
"""Appelle Ollama `/api/chat` et renvoie le texte de réponse.
|
"""Appelle Ollama `/api/chat` et renvoie le texte de réponse."""
|
||||||
|
response = requests.post(
|
||||||
Span Langfuse de type `generation` → expose latence, modèle, paramètres,
|
f"{OLLAMA_URL}/api/chat",
|
||||||
et tokens (si l'API Ollama les retourne dans `prompt_eval_count` /
|
json={
|
||||||
`eval_count`) comme un LLM-call standard dans le dashboard.
|
"model": LLM_MODEL,
|
||||||
|
"messages": [
|
||||||
Paramètres clefs (tunés 2026-04-23 après audit des traces Langfuse) :
|
{"role": "system", "content": system},
|
||||||
- `num_ctx=8192` : fenêtre de contexte explicite (le défaut Ollama
|
{"role": "user", "content": user},
|
||||||
à 2048/4096 tronquait silencieusement le début du prompt quand les
|
],
|
||||||
sources du RAG étaient volumineuses, d'où hallucinations sur l'identité).
|
"stream": False,
|
||||||
- `num_predict=1024` : budget de sortie doublé (512 coupait les réponses
|
"options": {
|
||||||
détaillées — p. ex. description du site ou d'un projet — en plein milieu).
|
"temperature": 0.4,
|
||||||
- `think=False` (top-level, hors `options`) : désactive le mode *thinking*
|
"num_predict": 512,
|
||||||
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:
|
|
||||||
response = requests.post(
|
|
||||||
f"{OLLAMA_URL}/api/chat",
|
|
||||||
json={
|
|
||||||
"model": LLM_MODEL,
|
|
||||||
"messages": messages,
|
|
||||||
"stream": False,
|
|
||||||
"think": False,
|
|
||||||
"options": model_params,
|
|
||||||
"keep_alive": "30m",
|
|
||||||
},
|
},
|
||||||
timeout=180,
|
"keep_alive": "30m",
|
||||||
|
},
|
||||||
|
timeout=180,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
message = data.get("message") or {}
|
||||||
|
content = message.get("content", "").strip()
|
||||||
|
if not content:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"generate: réponse vide du modèle '{LLM_MODEL}' — vérifier qu'il est pullé."
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
return content
|
||||||
data = response.json()
|
|
||||||
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(
|
def answer(query: str, top_k: int | None = None) -> dict[str, Any]:
|
||||||
query: str,
|
|
||||||
top_k: int | None = None,
|
|
||||||
session_id: str | None = None,
|
|
||||||
user_id: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Entrée principale consommée par `api.py`.
|
"""Entrée principale consommée par `api.py`.
|
||||||
|
|
||||||
Retourne :
|
Retourne :
|
||||||
@ -816,111 +605,33 @@ def answer(
|
|||||||
"grounded": bool, # True si au moins 1 note a dépassé MIN_SCORE
|
"grounded": bool, # True si au moins 1 note a dépassé MIN_SCORE
|
||||||
"vault_size": int,
|
"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(
|
scored = search(query, top_k=top_k)
|
||||||
name="ask",
|
system, user = build_prompt(query, scored)
|
||||||
input={"query": query},
|
text = generate(system, user)
|
||||||
) 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)
|
sources = []
|
||||||
|
for s in scored:
|
||||||
|
url = None
|
||||||
|
if s.note.type == "projet":
|
||||||
|
url = f"/portfolio/{s.note.slug}"
|
||||||
|
elif s.note.type == "competence":
|
||||||
|
url = f"/competences/{s.note.slug}"
|
||||||
|
sources.append({
|
||||||
|
"slug": s.note.slug,
|
||||||
|
"title": s.note.title,
|
||||||
|
"type": s.note.type,
|
||||||
|
"score": round(s.score, 2),
|
||||||
|
"reasons": s.reasons,
|
||||||
|
**({"url": url} if url else {}),
|
||||||
|
})
|
||||||
|
|
||||||
# --- Pipeline ---
|
grounded = any(s.score >= MIN_SCORE for s in scored)
|
||||||
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 ---
|
return {
|
||||||
sources = []
|
"response": text,
|
||||||
for s in scored:
|
"sources": sources,
|
||||||
url = None
|
"model": LLM_MODEL,
|
||||||
if s.note.type == "projet":
|
"grounded": grounded,
|
||||||
url = f"/portfolio/{s.note.slug}"
|
"vault_size": len(load_vault()),
|
||||||
elif s.note.type == "competence":
|
}
|
||||||
url = f"/competences/{s.note.slug}"
|
|
||||||
sources.append({
|
|
||||||
"slug": s.note.slug,
|
|
||||||
"title": s.note.title,
|
|
||||||
"type": s.note.type,
|
|
||||||
"score": round(s.score, 2),
|
|
||||||
"reasons": s.reasons,
|
|
||||||
**({"url": url} if url else {}),
|
|
||||||
})
|
|
||||||
|
|
||||||
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,
|
|
||||||
"sources": sources,
|
|
||||||
"model": LLM_MODEL,
|
|
||||||
"grounded": grounded,
|
|
||||||
"vault_size": len(load_vault()),
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,104 +1,24 @@
|
|||||||
# Script de démarrage du portfolio — lance les 3 services dans des fenêtres PowerShell séparées.
|
# Configuration des variables d'environnement
|
||||||
#
|
$env:NEXT_PUBLIC_API_URL = "https://api.fernandgrascalvet.com"
|
||||||
# Pendant inverse : stop-my-site.ps1 (arrête proprement les services par port).
|
$env:PUBLIC_URL = "https://api.fernandgrascalvet.com"
|
||||||
#
|
|
||||||
# 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
|
|
||||||
|
|
||||||
$root = $PSScriptRoot
|
Write-Host "🚀 Démarrage du site avec configuration HTTPS..." -ForegroundColor Green
|
||||||
$apiUrl = "https://api.fernandgrascalvet.com"
|
Write-Host "📡 API URL: $env:NEXT_PUBLIC_API_URL" -ForegroundColor Cyan
|
||||||
|
|
||||||
$services = @(
|
# Lancer Strapi avec configuration HTTPS
|
||||||
@{
|
Write-Host "🔧 Démarrage de Strapi..." -ForegroundColor Yellow
|
||||||
Nom = "Strapi (CMS)"
|
Start-Process powershell -ArgumentList "cd 'J:\my-next-site\cmsbackend'; `$env:PUBLIC_URL='https://api.fernandgrascalvet.com'; npm run develop" -WindowStyle Normal
|
||||||
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"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
Write-Host "🚀 Démarrage des services du portfolio..." -ForegroundColor Green
|
# Attendre un peu pour que Strapi démarre
|
||||||
Write-Host " API URL : $apiUrl" -ForegroundColor Cyan
|
Start-Sleep -Seconds 3
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
$lances = 0
|
# Lancer Next.js
|
||||||
$dejaActifs = 0
|
Write-Host "⚡ Démarrage de Next.js..." -ForegroundColor Yellow
|
||||||
$echecs = 0
|
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) {
|
# Lancer FastAPI
|
||||||
# Si le port est déjà écouté, le service tourne sans doute déjà — on saute.
|
Write-Host "🤖 Démarrage de FastAPI..." -ForegroundColor Yellow
|
||||||
$existing = Get-NetTCPConnection -LocalPort $s.Port -State Listen -ErrorAction SilentlyContinue
|
Start-Process powershell -ArgumentList "cd 'J:\my-next-site\llm-api'; uvicorn api:app --host 0.0.0.0 --port 8000" -WindowStyle Normal
|
||||||
if ($existing) {
|
|
||||||
Write-Host " ⚪ $($s.Nom) — port $($s.Port) déjà occupé, pas de relance" -ForegroundColor Gray
|
|
||||||
$dejaActifs++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
Write-Host "✅ Tous les services sont en cours de démarrage!" -ForegroundColor Green
|
||||||
Write-Host " 🔵 $($s.Nom) — port $($s.Port) → $($s.Cwd)" -ForegroundColor Cyan
|
Write-Host "🌐 Site accessible sur: http://localhost:3000" -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
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
@ -28,7 +28,6 @@ answers:
|
|||||||
- "A-t-il de l'expérience professionnelle ?"
|
- "A-t-il de l'expérience professionnelle ?"
|
||||||
priority: 10
|
priority: 10
|
||||||
linked:
|
linked:
|
||||||
- "[[bio-fernand]]"
|
|
||||||
- "[[MOC-Parcours]]"
|
- "[[MOC-Parcours]]"
|
||||||
- "[[MOC-Ecole-42]]"
|
- "[[MOC-Ecole-42]]"
|
||||||
- "[[MOC-Ia]]"
|
- "[[MOC-Ia]]"
|
||||||
@ -50,7 +49,7 @@ visibility: public
|
|||||||
## Identité
|
## Identité
|
||||||
|
|
||||||
- **Nom** : Gras-Calvet Fernand
|
- **Nom** : Gras-Calvet Fernand
|
||||||
- **Âge** : 47 ans
|
- **Âge** : 46 ans
|
||||||
- **Situation** : Étudiant en informatique, École 42 Perpignan
|
- **Situation** : Étudiant en informatique, École 42 Perpignan
|
||||||
- **Objectif** : Alternance **Data / IA** (2 ans)
|
- **Objectif** : Alternance **Data / IA** (2 ans)
|
||||||
- **RQTH** : reconversion professionnelle suite à problèmes de santé
|
- **RQTH** : reconversion professionnelle suite à problèmes de santé
|
||||||
@ -65,7 +64,7 @@ visibility: public
|
|||||||
|
|
||||||
## Présentation
|
## 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
|
Perpignan. Je recherche une alternance de 2 ans pour me spécialiser dans
|
||||||
**l'automatisation agentique au sein des entreprises**, en y apportant mon
|
**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.
|
expérience sur le traitement de Data et les nouveaux process basés sur les LLM.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user