Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74337c0bcb | ||
|
|
6ac5a2bdf7 | ||
|
|
ebfc744574 | ||
|
|
8f94a1acda | ||
|
|
288a9c9126 | ||
|
|
a1edcf3093 |
+40
-1
@@ -204,6 +204,8 @@ cout_eur = ((tokens_in × 0.02 + tokens_out × 0.04) / 1_000_000) × 0.93
|
|||||||
|
|
||||||
Le filtre NocoDB par date (`gte,YYYY-MM-DD`) n'est pas supporté en v0.301.5. Contournement : récupération de tous les records stats_usage (limit=1000) et filtre JavaScript par mois/année.
|
Le filtre NocoDB par date (`gte,YYYY-MM-DD`) n'est pas supporté en v0.301.5. Contournement : récupération de tous les records stats_usage (limit=1000) et filtre JavaScript par mois/année.
|
||||||
|
|
||||||
|
**Côté chatbot (AF5, 29/09)** : `server/utils/circuitBreaker.ts` n'appliquait pas ce contournement. Il envoyait `where=(timestamp,gte,…)&fields=cout_eur,timestamp` et recevait un 422 à chaque appel : fail-open silencieux, budget jamais vérifié depuis la V2. Il suit désormais le worker : **aucune colonne nommée dans la requête** (ni `where`, ni `fields`, ni `sort`), lecture paginée (`limit`/`offset` 1000), mois filtré en JS sur `timestamp`, sinon `CreatedAt`, sinon `created_at`. Chaque lecture écrit au journal `[circuitBreaker] budget lu : X € / 20 € (n lignes AAAA-MM sur N lues)` ; un échec écrit `budget NON vérifié`. Test : `node scripts/test-circuit-breaker.mjs` (faux NocoDB qui rejette en 422 toute colonne absente). Seule `/api/chatbot` vérifie le budget ; `-reseaux` et `-taff` ne le lisent ni ne l'écrivent (asymétrie pré-existante), et les appels servis par un tier gratuit s'inscrivent à 0 €.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Infrastructure
|
## 7. Infrastructure
|
||||||
@@ -365,7 +367,7 @@ fait ici pour ne pas tester un ALTER contre la prod sans l'avoir vérifié.
|
|||||||
|
|
||||||
| Sujet | NAV V2 (§1-10 ci-dessus) | AEP B5 |
|
| Sujet | NAV V2 (§1-10 ci-dessus) | AEP B5 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| LLM | Mistral Nemo, appel direct | **Bifrost** (`${BIFROST_URL}/v1/chat/completions`, header `x-bf-vk`), modèle `WORKER_MODEL` (défaut `groq/llama-3.1-8b-instant`) |
|
| LLM | Mistral Nemo, appel direct | **Bifrost** (`${BIFROST_URL}/v1/chat/completions`, header `x-bf-vk`), modèle `WORKER_MODEL` (défaut `groq/openai/gpt-oss-20b` depuis le 28/09, voir §11.3) |
|
||||||
| Scrape | crawl4ai (Python, `AsyncHTTPCrawlerStrategy`) | **fetch natif Node 22** — timeout 8 s, corps plafonné 500 Ko, extraction titre + meta description + `og:*` + texte visible tronqué à 4000 caractères, `User-Agent: AEP/2.0 contact@trans-former.fr` |
|
| Scrape | crawl4ai (Python, `AsyncHTTPCrawlerStrategy`) | **fetch natif Node 22** — timeout 8 s, corps plafonné 500 Ko, extraction titre + meta description + `og:*` + texte visible tronqué à 4000 caractères, `User-Agent: AEP/2.0 contact@trans-former.fr` |
|
||||||
| Notification | Resend (email Jules) | **ntfy** (`POST https://ntfy.sh/$NTFY_TOPIC`) — le message ne contient JAMAIS l'email ni le texte libre du contributeur, seulement id NocoDB / nom suggéré / type / confiance |
|
| Notification | Resend (email Jules) | **ntfy** (`POST https://ntfy.sh/$NTFY_TOPIC`) — le message ne contient JAMAIS l'email ni le texte libre du contributeur, seulement id NocoDB / nom suggéré / type / confiance |
|
||||||
| Seuil « 5 fiches pending » | Email si ≥ 5 en attente | **Retiré** (décision MOE : volume faible, une notif par fiche traitée suffit — à réactiver si le volume monte) |
|
| Seuil « 5 fiches pending » | Email si ≥ 5 en attente | **Retiré** (décision MOE : volume faible, une notif par fiche traitée suffit — à réactiver si le volume monte) |
|
||||||
@@ -381,3 +383,40 @@ Le mode `--dry-run` (`node enrich.js --dry-run`) lit
|
|||||||
`worker/fixtures/dry-run-rows.json` au lieu de NocoDB, n'écrit rien, et par
|
`worker/fixtures/dry-run-rows.json` au lieu de NocoDB, n'écrit rien, et par
|
||||||
défaut mock aussi le scrape et l'appel Bifrost (`DRY_RUN_LIVE=1` pour
|
défaut mock aussi le scrape et l'appel Bifrost (`DRY_RUN_LIVE=1` pour
|
||||||
forcer de vrais appels réseau sans jamais toucher NocoDB).
|
forcer de vrais appels réseau sans jamais toucher NocoDB).
|
||||||
|
|
||||||
|
### 11.3 Modèles Groq retirés — bascule sur gpt-oss (2026-09-28)
|
||||||
|
|
||||||
|
Groq ne sert plus `llama-3.1-8b-instant` ni `llama-3.3-70b-versatile`
|
||||||
|
(404 `model_not_found` via Bifrost, constaté au test M3 du 28/09).
|
||||||
|
|
||||||
|
| Consommateur | Avant | Après | Où |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Worker (`worker/enrich.js`) | `groq/llama-3.1-8b-instant` | `groq/openai/gpt-oss-20b` | prod : `WORKER_MODEL` dans `/opt/aep/.env` depuis le 28/09 ; défaut du code aligné |
|
||||||
|
| Chatbots, tier RAPIDE | `groq/llama-3.1-8b-instant` | `groq/openai/gpt-oss-20b` | `server/utils/bifrost.ts`, replis inchangés |
|
||||||
|
| Chatbots, tier APPROFONDI | `groq/llama-3.3-70b-versatile` | `groq/openai/gpt-oss-120b` | `server/utils/bifrost.ts`, replis inchangés |
|
||||||
|
| RAG Pensées (LightRAG) | `gemini-oai/models/gemini-2.5-flash-lite` | inchangé | `/opt/lightrag/.env`, hors de ce routage |
|
||||||
|
|
||||||
|
Entre le retrait et ce correctif, chaque appel chatbot partait sur le
|
||||||
|
premier repli Gemini (`stats_usage` depuis le 23/09 environ) : service
|
||||||
|
rendu, modèle primaire jamais servi.
|
||||||
|
|
||||||
|
**gpt-oss est un modèle à raisonnement.** Deux conséquences :
|
||||||
|
- la réponse porte `choices[0].message.reasoning` à côté de `content`. Les
|
||||||
|
trois routes (`chatbot`, `chatbot-reseaux`, `chatbot-taff`) et le worker ne
|
||||||
|
lisent que `content` (JSON), `usage` et `extra_fields` : le champ est
|
||||||
|
ignoré, rien n'en dépend. `chatbot-pensees` lit la réponse de LightRAG
|
||||||
|
(`response`, `references`), pas celle de Bifrost ;
|
||||||
|
- les tokens de raisonnement sont décomptés de `max_tokens`. Si le
|
||||||
|
fournisseur rend un JSON tronqué en HTTP 200, Bifrost ne bascule pas et
|
||||||
|
l'usager lit « Je n'ai pas pu analyser ta demande » (s'il le rejette en
|
||||||
|
400, le repli Gemini prend le relais : dégradé, pas cassé). Les routes chatbot ajoutent donc
|
||||||
|
`REASONING_MARGIN_TOKENS` (800) à leur budget de contenu (600 ou 700), et
|
||||||
|
`bifrostContent()` écrit un avertissement au journal quand
|
||||||
|
`finish_reason` vaut `length`. Le worker garde `max_tokens: 800` (sortie
|
||||||
|
courte, M3 passé en 1,6 s) ; à relever si `tokens_out` approche 800 dans
|
||||||
|
`stats_usage`.
|
||||||
|
|
||||||
|
**Non testé depuis Windows** (Bifrost écoute sur le loopback du VPS). Test
|
||||||
|
réel au checkpoint : un appel par route, relever `extra_fields` (modèle
|
||||||
|
réellement servi), `usage.completion_tokens`, `finish_reason`, et le
|
||||||
|
modèle inscrit dans `stats_usage` pour `/api/chatbot`.
|
||||||
|
|||||||
+31
-24
@@ -77,8 +77,12 @@ const emit = defineEmits<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip:hover {
|
/* Survol : uniquement sur pointeur capable de survol. Sur tactile, :hover
|
||||||
transform: translateY(-1px);
|
reste collé après un tap (chip « allumé » sans être actif). */
|
||||||
|
@media (hover: hover) {
|
||||||
|
.chip:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip:focus-visible {
|
.chip:focus-visible {
|
||||||
@@ -92,19 +96,21 @@ const emit = defineEmits<{
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
.chip--echelle:hover {
|
@media (hover: hover) {
|
||||||
background: var(--primary-hover);
|
.chip--echelle:hover {
|
||||||
border-color: var(--primary-hover);
|
background: var(--primary-hover);
|
||||||
color: var(--on-primary);
|
border-color: var(--primary-hover);
|
||||||
|
color: var(--on-primary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
/* Actif = fond PLEIN. --primary est translucide dans le thème AEP
|
||||||
|
(rgba(26,34,56,.6) : 4,16:1 sur le fond de page, sous AA) ; le couple
|
||||||
|
--nav-primary-solid / --nav-text-on-primary (≈14,6:1) reste lisible aussi
|
||||||
|
en sombre. Le survol d'un chip actif ne change pas de fond. */
|
||||||
.chip--echelle.chip--active {
|
.chip--echelle.chip--active {
|
||||||
background: var(--primary);
|
background: var(--nav-primary-solid);
|
||||||
border-color: var(--primary);
|
border-color: var(--nav-primary-solid);
|
||||||
color: var(--on-primary);
|
color: var(--nav-text-on-primary);
|
||||||
}
|
|
||||||
.chip--echelle.chip--active:hover {
|
|
||||||
background: var(--primary-hover);
|
|
||||||
border-color: var(--primary-hover);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Fonction/hashtag : pleine bleu, inactif = bg-alt ────────────────────── */
|
/* ── Fonction/hashtag : pleine bleu, inactif = bg-alt ────────────────────── */
|
||||||
@@ -113,16 +119,15 @@ const emit = defineEmits<{
|
|||||||
background: var(--bg-alt);
|
background: var(--bg-alt);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.chip--fonction:hover {
|
@media (hover: hover) {
|
||||||
background: var(--primary-hover);
|
.chip--fonction:hover {
|
||||||
color: var(--on-primary);
|
background: var(--primary-hover);
|
||||||
|
color: var(--on-primary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.chip--fonction.chip--active {
|
.chip--fonction.chip--active {
|
||||||
background: var(--primary);
|
background: var(--nav-primary-solid);
|
||||||
color: var(--on-primary);
|
color: var(--nav-text-on-primary);
|
||||||
}
|
|
||||||
.chip--fonction.chip--active:hover {
|
|
||||||
background: var(--primary-hover);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Posture (préparée pour plus tard) : outline safran ──────────────────── */
|
/* ── Posture (préparée pour plus tard) : outline safran ──────────────────── */
|
||||||
@@ -131,9 +136,11 @@ const emit = defineEmits<{
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
.chip--posture:hover {
|
@media (hover: hover) {
|
||||||
background: var(--accent);
|
.chip--posture:hover {
|
||||||
color: var(--on-accent);
|
background: var(--accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.chip--posture.chip--active {
|
.chip--posture.chip--active {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
|
|||||||
@@ -38,17 +38,29 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<label id="libre-type-label">Type suggéré <span class="label-hint">(optionnel — ça nous aide à trier)</span></label>
|
<label id="libre-type-label">Type suggéré <span class="label-hint">(optionnel — un ou deux si tu hésites)</span></label>
|
||||||
<ChipGroup aria-label="Type suggéré">
|
<ChipGroup aria-label="Type suggéré" aria-describedby="libre-type-hint">
|
||||||
<Chip
|
<Chip
|
||||||
v-for="t in TYPES_SUGGERES"
|
v-for="t in TYPES_SUGGERES"
|
||||||
:key="t"
|
:key="t"
|
||||||
:label="TYPES_SUGGERES_LABELS[t]"
|
:label="TYPES_SUGGERES_LABELS[t]"
|
||||||
variant="fonction"
|
variant="fonction"
|
||||||
:active="typeSuggere === t"
|
:active="typeSuggere === t || typeSecondaire === t"
|
||||||
|
:class="{ 'chip--secondaire': typeSecondaire === t }"
|
||||||
@toggle="toggleType(t)"
|
@toggle="toggleType(t)"
|
||||||
/>
|
/>
|
||||||
</ChipGroup>
|
</ChipGroup>
|
||||||
|
<span id="libre-type-hint" class="label-hint">
|
||||||
|
Écosystème : {{ TYPES_SUGGERES_HINTS.ecosysteme }} · Réseau : {{ TYPES_SUGGERES_HINTS.reseau }} · Jobs : {{ TYPES_SUGGERES_HINTS.jobs }} · Outils : {{ TYPES_SUGGERES_HINTS.outils }} · Références : {{ TYPES_SUGGERES_HINTS.references }}
|
||||||
|
</span>
|
||||||
|
<!-- Distinction non visuelle premier choix / second choix pour lecteurs d'écran :
|
||||||
|
les deux chips actifs partagent le même aria-pressed, sans quoi rien ne dit
|
||||||
|
lequel est principal. -->
|
||||||
|
<span class="sr-only" aria-live="polite">
|
||||||
|
<template v-if="typeSuggere && typeSecondaire">
|
||||||
|
Principal : {{ TYPES_SUGGERES_LABELS[typeSuggere] }} · second choix : {{ TYPES_SUGGERES_LABELS[typeSecondaire] }}
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
|
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
|
||||||
@@ -84,7 +96,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { LibreSubmitSchema, TYPES_SUGGERES, TYPES_SUGGERES_LABELS, type TypeSuggere } from '~/utils/submitLibre'
|
import { LibreSubmitSchema, TYPES_SUGGERES, TYPES_SUGGERES_LABELS, TYPES_SUGGERES_HINTS, type TypeSuggere } from '~/utils/submitLibre'
|
||||||
|
|
||||||
const props = defineProps<{ presetType?: TypeSuggere }>()
|
const props = defineProps<{ presetType?: TypeSuggere }>()
|
||||||
const emit = defineEmits<{ success: [] }>()
|
const emit = defineEmits<{ success: [] }>()
|
||||||
@@ -93,6 +105,8 @@ const liens = reactive<string[]>([''])
|
|||||||
const texte = ref('')
|
const texte = ref('')
|
||||||
const email = ref('')
|
const email = ref('')
|
||||||
const typeSuggere = ref<TypeSuggere | undefined>(props.presetType)
|
const typeSuggere = ref<TypeSuggere | undefined>(props.presetType)
|
||||||
|
// Deuxième chip optionnelle (B5-M2b) — pour les indécis entre deux types.
|
||||||
|
const typeSecondaire = ref<TypeSuggere | undefined>()
|
||||||
const siteWeb = ref('') // honeypot
|
const siteWeb = ref('') // honeypot
|
||||||
|
|
||||||
const errors = reactive<Record<string, string>>({})
|
const errors = reactive<Record<string, string>>({})
|
||||||
@@ -108,8 +122,29 @@ function removeLien(i: number) {
|
|||||||
if (liens.length === 0) liens.push('')
|
if (liens.length === 0) liens.push('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deux chips maximum. Premier clic = principal, deuxième = secondaire.
|
||||||
|
* Décocher le principal alors qu'un secondaire existe promeut ce dernier.
|
||||||
|
* Troisième clic sur un chip inactif quand deux sont déjà actifs : remplace
|
||||||
|
* le secondaire par le nouveau chip (choix le plus simple — pas de file d'attente).
|
||||||
|
*/
|
||||||
function toggleType(t: TypeSuggere) {
|
function toggleType(t: TypeSuggere) {
|
||||||
typeSuggere.value = typeSuggere.value === t ? undefined : t
|
if (typeSuggere.value === t) {
|
||||||
|
typeSuggere.value = typeSecondaire.value
|
||||||
|
typeSecondaire.value = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeSecondaire.value === t) {
|
||||||
|
typeSecondaire.value = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeSuggere.value === undefined) {
|
||||||
|
typeSuggere.value = t
|
||||||
|
} else if (typeSecondaire.value === undefined) {
|
||||||
|
typeSecondaire.value = t
|
||||||
|
} else {
|
||||||
|
typeSecondaire.value = t
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentPayload() {
|
function currentPayload() {
|
||||||
@@ -118,6 +153,7 @@ function currentPayload() {
|
|||||||
texte: texte.value,
|
texte: texte.value,
|
||||||
submitted_by_email: email.value || undefined,
|
submitted_by_email: email.value || undefined,
|
||||||
type_suggere: typeSuggere.value,
|
type_suggere: typeSuggere.value,
|
||||||
|
type_secondaire: typeSecondaire.value,
|
||||||
site_web: siteWeb.value,
|
site_web: siteWeb.value,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,4 +273,38 @@ async function submit() {
|
|||||||
clip: rect(0 0 0 0);
|
clip: rect(0 0 0 0);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── B5-M2c : distinction principal / second choix ──
|
||||||
|
Le principal actif (fond plein, ≈14,6:1) et le survol gardé par
|
||||||
|
@media (hover: hover) viennent de Chip.vue depuis AF5 : les surcharges
|
||||||
|
locales de AF4 (principal plein, survol collant neutralisé sur tactile)
|
||||||
|
ont été retirées. */
|
||||||
|
|
||||||
|
/* Chip secondaire (deuxième choix) : contour au lieu de plein, pour ne
|
||||||
|
jamais se confondre avec le principal — ni à l'œil ni au clic. Texte
|
||||||
|
--nav-text sur le fond de page : contraste très large (>10:1), stable
|
||||||
|
dans tous les états. */
|
||||||
|
.field-group :deep(.chip--secondaire.chip--active) {
|
||||||
|
background: transparent !important;
|
||||||
|
border-color: var(--nav-primary-solid) !important;
|
||||||
|
color: var(--nav-text) !important;
|
||||||
|
}
|
||||||
|
/* Survol du secondaire : pointeur capable de survol seulement, comme Chip.vue
|
||||||
|
(sur tactile, :hover reste collé après un tap). */
|
||||||
|
@media (hover: hover) {
|
||||||
|
.field-group :deep(.chip--secondaire.chip--active:hover) {
|
||||||
|
background: rgba(var(--nav-primary-raw), 0.08) !important;
|
||||||
|
border-color: var(--nav-primary-solid) !important;
|
||||||
|
color: var(--nav-text) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -9,11 +9,12 @@
|
|||||||
"note_doublons_en_fr": "3 livres avec version EN aussi indexee dans le RAG pour cross-language queries : carson-mer-autour-de-nous-fr/EN, graeber-wengrow-aurore-fr/EN, saito-capital-anthropocene/EN. JSON conserve la version FR.",
|
"note_doublons_en_fr": "3 livres avec version EN aussi indexee dans le RAG pour cross-language queries : carson-mer-autour-de-nous-fr/EN, graeber-wengrow-aurore-fr/EN, saito-capital-anthropocene/EN. JSON conserve la version FR.",
|
||||||
"note_v2_1": "Phase 6 refonte Bonpote-aligned : 11 ecoles (fusion Marxismes-ecologiques -> Ecosocialisme, Marx+Saito migres), palette pastel, positions x_hint/y_hint Bonpote-aligned, labels affiches renommes (eco-anarchisme -> Eco-anarchisme, technocritique, ethique-env singulier)",
|
"note_v2_1": "Phase 6 refonte Bonpote-aligned : 11 ecoles (fusion Marxismes-ecologiques -> Ecosocialisme, Marx+Saito migres), palette pastel, positions x_hint/y_hint Bonpote-aligned, labels affiches renommes (eco-anarchisme -> Eco-anarchisme, technocritique, ethique-env singulier)",
|
||||||
"updated": "2026-05-14",
|
"updated": "2026-05-14",
|
||||||
"auteurs_ingeres_count": 80,
|
"auteurs_ingeres_count": 91,
|
||||||
"note_v3_0": "Phase 8.A sync corpus unifie : ~140 auteurs Bonpote integres, flag ingere:true/false selon LightRAG VPS. Auteurs non-ingeres = entrees minimales (bio provisoire, livres_rag vide), a enrichir lors de PRG-4/PRG-5.",
|
"note_v3_0": "Phase 8.A sync corpus unifie : ~140 auteurs Bonpote integres, flag ingere:true/false selon LightRAG VPS. Auteurs non-ingeres = entrees minimales (bio provisoire, livres_rag vide), a enrichir lors de PRG-4/PRG-5.",
|
||||||
"note_v3_1": "Phase 8.D transposition 1:1 Bonpote V2 : positions+couleurs Bonpote-aligned via OCR vision Sonnet, blur Voronoi, grisage auteurs non-ingeres",
|
"note_v3_1": "Phase 8.D transposition 1:1 Bonpote V2 : positions+couleurs Bonpote-aligned via OCR vision Sonnet, blur Voronoi, grisage auteurs non-ingeres",
|
||||||
"note_v4_0": "Phase 8.G J+8 : corpus 226 docs / 62 auteurs ingeres. Noeuds-ecoles, popup info RAG, lien Bonpote, chatbots Nebius DeepSeek-V3.2.",
|
"note_v4_0": "Phase 8.G J+8 : corpus 226 docs / 62 auteurs ingeres. Noeuds-ecoles, popup info RAG, lien Bonpote, chatbots Nebius DeepSeek-V3.2.",
|
||||||
"note_v4_1": "Phase 8.G J+8 fin batch : corpus 277 docs / 99 auteurs LightRAG. Mise a jour livres_rag depuis mapping heuristique ID -> slug. Modele ingestion : Qwen3-235B."
|
"note_v4_1": "Phase 8.G J+8 fin batch : corpus 277 docs / 99 auteurs LightRAG. Mise a jour livres_rag depuis mapping heuristique ID -> slug. Modele ingestion : Qwen3-235B.",
|
||||||
|
"note_2026_09_28": "Correction attribution : harvey-brief-history-neoliberalism était dupliqué (heritage du mapping heuristique v4.1) entre hal-harvey (capitalisme-vert, non ingéré) et david-harvey (eco-anarchisme, poster Bonpote). Doublon retiré chez hal-harvey, livre conservé uniquement chez david-harvey (ecoles inchangées, alignées sur le poster Bonpote). auteurs_ingeres_count recalculé par script depuis le contenu JSON (flags ingere:true sur les 171 auteurs) : 91 - remplace la valeur 80 ci-dessus, déjà obsolète avant cette correction (note_v4_1 annonçait 99 côté VPS ; écart à réconcilier séparément, hors périmètre). corpus_ingere (277) et livres_count (64) non recalculés : ils référencent l'état LightRAG VPS, non reproductibles depuis ce JSON seul (mapping heuristique partiel vers livres_rag, cf note_v4_1)."
|
||||||
},
|
},
|
||||||
"ecoles": [
|
"ecoles": [
|
||||||
{
|
{
|
||||||
@@ -408,8 +409,13 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"theses_cles_attendues": [],
|
"theses_cles": [
|
||||||
"bio_courte_provisoire": "Théoricien·ne présent·e sur le poster Bonpote (eco-anarchisme), non ingéré·e dans le RAG ATIS.",
|
"Accumulation par dépossession",
|
||||||
|
"Le néolibéralisme comme restauration du pouvoir de classe",
|
||||||
|
"Droit à la ville et production capitaliste de l'espace"
|
||||||
|
],
|
||||||
|
"bio_courte": "Géographe marxiste britannique. A Brief History of Neoliberalism (2005) analyse le tournant néolibéral comme projet politique de restauration du pouvoir de la classe possédante, plus que comme doctrine économique neutre.",
|
||||||
|
"note_correction": "Attribution corrigée le 2026-09-28 : le livre était associé par erreur à Hal Harvey (capitalisme-vert, poster Bonpote non ingéré) au lieu de David Harvey.",
|
||||||
"ingere": true
|
"ingere": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2124,19 +2130,11 @@
|
|||||||
"capitalisme-vert"
|
"capitalisme-vert"
|
||||||
],
|
],
|
||||||
"ecole_principale": "capitalisme-vert",
|
"ecole_principale": "capitalisme-vert",
|
||||||
"livres_rag": [
|
"livres_rag": [],
|
||||||
{
|
|
||||||
"slug": "harvey-brief-history-neoliberalism",
|
|
||||||
"titre": "brief history neoliberalism",
|
|
||||||
"annee": 0,
|
|
||||||
"couches": [
|
|
||||||
"fond"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"theses_cles_attendues": [],
|
"theses_cles_attendues": [],
|
||||||
"bio_courte_provisoire": "Théoricien·ne présent·e sur le poster Bonpote (capitalisme-vert), non ingéré·e dans le RAG ATIS.",
|
"bio_courte_provisoire": "Théoricien·ne présent·e sur le poster Bonpote (capitalisme-vert), non ingéré·e dans le RAG ATIS.",
|
||||||
"ingere": true
|
"note_correction": "Le livre harvey-brief-history-neoliberalism, attribué par erreur, a été retiré le 2026-09-28 (voir entrée david-harvey).",
|
||||||
|
"ingere": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "laurence-tubiana",
|
"id": "laurence-tubiana",
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Test du circuit breaker budget (server/utils/circuitBreaker.ts), AF5.
|
||||||
|
* Charge le VRAI module TS via jiti (comme test-submit-libre.mjs).
|
||||||
|
*
|
||||||
|
* Le faux NocoDB ci-dessous se comporte comme le vrai sur le point qui a cassé :
|
||||||
|
* toute colonne nommée dans `where`, `fields` ou `sort` et absente du schéma
|
||||||
|
* renvoie un 422. Réintroduire un filtre sur un champ absent fait donc échouer
|
||||||
|
* le cas « schéma sans timestamp ».
|
||||||
|
* Usage : node scripts/test-circuit-breaker.mjs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createJiti } from 'jiti'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const jiti = createJiti(import.meta.url)
|
||||||
|
|
||||||
|
const { checkBudget, BUDGET_MAX_EUR } = await jiti.import(
|
||||||
|
join(__dirname, '..', 'server', 'utils', 'circuitBreaker.ts'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const NOW = new Date('2026-09-29T10:00:00Z')
|
||||||
|
|
||||||
|
/** Colonnes référencées par une requête NocoDB v2 (where, fields, sort). */
|
||||||
|
function colonnesReferencees(query) {
|
||||||
|
const cols = []
|
||||||
|
if (query.where) {
|
||||||
|
for (const m of String(query.where).matchAll(/\(([^,()]+),/g)) cols.push(m[1].trim())
|
||||||
|
}
|
||||||
|
if (query.fields) cols.push(...String(query.fields).split(',').map((s) => s.trim()))
|
||||||
|
if (query.sort) cols.push(...String(query.sort).split(',').map((s) => s.trim().replace(/^-/, '')))
|
||||||
|
return cols.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Faux NocoDB : schéma de colonnes + lignes ; 422 sur colonne inconnue ; pagination limit/offset. */
|
||||||
|
function fauxNocodb({ schema, rows, panne = false }) {
|
||||||
|
const appels = []
|
||||||
|
const fetchJson = async (url, { headers, query }) => {
|
||||||
|
appels.push({ url, query })
|
||||||
|
if (panne) throw Object.assign(new Error('[GET] 500 Internal Server Error'), { statusCode: 500 })
|
||||||
|
if (headers['xc-token'] !== 'tok') throw Object.assign(new Error('401'), { statusCode: 401 })
|
||||||
|
const inconnues = colonnesReferencees(query).filter((c) => !schema.includes(c))
|
||||||
|
if (inconnues.length) {
|
||||||
|
throw Object.assign(new Error(`[GET] 422 Field '${inconnues[0]}' not found`), { statusCode: 422 })
|
||||||
|
}
|
||||||
|
const limit = Number(query.limit ?? 25)
|
||||||
|
const offset = Number(query.offset ?? 0)
|
||||||
|
const list = rows.slice(offset, offset + limit)
|
||||||
|
return { list, pageInfo: { isLastPage: offset + limit >= rows.length } }
|
||||||
|
}
|
||||||
|
return { fetchJson, appels }
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = { nocodbUrl: 'http://noco', nocodbToken: 'tok', statsTableId: 'mbbq7n47ixy19mc', now: NOW }
|
||||||
|
|
||||||
|
// Schéma supposé de la prod : pas de colonne `timestamp`, date système `CreatedAt`.
|
||||||
|
const SCHEMA_SANS_TS = ['Id', 'model', 'endpoint', 'tokens_in', 'tokens_out', 'cout_eur', 'orga_id', 'CreatedAt', 'UpdatedAt']
|
||||||
|
const SCHEMA_AVEC_TS = [...SCHEMA_SANS_TS, 'timestamp']
|
||||||
|
|
||||||
|
const results = []
|
||||||
|
function check(name, cond, detail = '') {
|
||||||
|
results.push({ name, ok: !!cond, detail })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Journal capturé pour vérifier la ligne de budget
|
||||||
|
const logs = []
|
||||||
|
const origInfo = console.info
|
||||||
|
const origWarn = console.warn
|
||||||
|
console.info = (...a) => logs.push(['info', a.join(' ')])
|
||||||
|
console.warn = (...a) => logs.push(['warn', a.join(' ')])
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 0. Le faux NocoDB rejette bien l'ancienne requête (sinon le test ne prouverait rien)
|
||||||
|
{
|
||||||
|
const { fetchJson } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows: [] })
|
||||||
|
let status = null
|
||||||
|
try {
|
||||||
|
await fetchJson('x', {
|
||||||
|
headers: { 'xc-token': 'tok' },
|
||||||
|
query: { where: '(timestamp,gte,2026-09-01T00:00:00.000Z)', limit: 1000, fields: 'cout_eur,timestamp' },
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
status = e.statusCode
|
||||||
|
}
|
||||||
|
check('faux NocoDB : ancienne requête (where/fields sur timestamp) → 422', status === 422, `statut ${status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Schéma sans timestamp : lu, mois courant seul, aucune colonne nommée
|
||||||
|
{
|
||||||
|
const rows = [
|
||||||
|
{ Id: 1, cout_eur: 0.5, CreatedAt: '2026-09-03T08:00:00.000Z' },
|
||||||
|
{ Id: 2, cout_eur: '1.25', CreatedAt: '2026-09-28 22:10:00+00:00' },
|
||||||
|
{ Id: 3, cout_eur: 7, CreatedAt: '2026-08-31T23:59:00.000Z' }, // mois précédent
|
||||||
|
{ Id: 4, cout_eur: null, CreatedAt: '2026-09-10T00:00:00.000Z' },
|
||||||
|
]
|
||||||
|
const { fetchJson, appels } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows })
|
||||||
|
logs.length = 0
|
||||||
|
const s = await checkBudget({ ...base, fetchJson })
|
||||||
|
check('sans timestamp : budget vérifié (pas de 422)', s.verified === true, JSON.stringify(s))
|
||||||
|
check('sans timestamp : cumul = 1,75 € (août exclu)', Math.abs(s.cumulEur - 1.75) < 1e-9, `cumul ${s.cumulEur}`)
|
||||||
|
check('sans timestamp : 3 lignes du mois sur 4', s.lignesMois === 3 && s.lignesLues === 4)
|
||||||
|
check('aucune colonne nommée dans la requête', appels.every((a) => colonnesReferencees(a.query).length === 0),
|
||||||
|
JSON.stringify(appels.map((a) => a.query)))
|
||||||
|
const ligne = logs.find(([niv, t]) => niv === 'info' && t.includes('[circuitBreaker] budget lu'))
|
||||||
|
check('journal : ligne « budget lu » avec le cumul', ligne && ligne[1].includes('1.7500 €'), ligne?.[1] ?? 'absente')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Schéma avec timestamp : timestamp prioritaire sur CreatedAt (ordre du worker)
|
||||||
|
{
|
||||||
|
const rows = [
|
||||||
|
{ Id: 1, cout_eur: 2, timestamp: '2026-09-15T12:00:00.000Z', CreatedAt: '2026-08-15T12:00:00.000Z' },
|
||||||
|
{ Id: 2, cout_eur: 3, timestamp: '2026-08-15T12:00:00.000Z', CreatedAt: '2026-09-15T12:00:00.000Z' },
|
||||||
|
]
|
||||||
|
const { fetchJson } = fauxNocodb({ schema: SCHEMA_AVEC_TS, rows })
|
||||||
|
const s = await checkBudget({ ...base, fetchJson })
|
||||||
|
check('avec timestamp : timestamp prioritaire (cumul 2 €)', s.verified && s.cumulEur === 2, `cumul ${s.cumulEur}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Lignes sans aucune date : ignorées et comptées
|
||||||
|
{
|
||||||
|
const rows = [{ Id: 1, cout_eur: 9 }, { Id: 2, cout_eur: 1, CreatedAt: '2026-09-02T00:00:00Z' }]
|
||||||
|
const { fetchJson } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows })
|
||||||
|
logs.length = 0
|
||||||
|
const s = await checkBudget({ ...base, fetchJson })
|
||||||
|
check('sans date : ignorée, comptée', s.cumulEur === 1 && s.lignesSansDate === 1)
|
||||||
|
check('journal : lignes sans date signalées', logs.some(([, t]) => t.includes('1 sans date ignorées')))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Pagination : 2 500 lignes du mois à 0,01 € → 25 € → bloqué
|
||||||
|
{
|
||||||
|
const rows = Array.from({ length: 2500 }, (_, i) => ({ Id: i + 1, cout_eur: 0.01, CreatedAt: '2026-09-20T00:00:00Z' }))
|
||||||
|
const { fetchJson, appels } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows })
|
||||||
|
logs.length = 0
|
||||||
|
const s = await checkBudget({ ...base, fetchJson })
|
||||||
|
check('pagination : 2 500 lignes lues en 3 pages', s.lignesLues === 2500 && appels.length === 3, `${s.lignesLues} lignes, ${appels.length} appels`)
|
||||||
|
check(`seuil : 25 € ≥ ${BUDGET_MAX_EUR} € → blocked`, s.blocked === true && s.warning === true)
|
||||||
|
check('journal : BLOQUÉ', logs.some(([, t]) => t.includes('BLOQUÉ')))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Alerte entre 18 et 20 €
|
||||||
|
{
|
||||||
|
const rows = [{ Id: 1, cout_eur: 18.5, CreatedAt: '2026-09-20T00:00:00Z' }]
|
||||||
|
const { fetchJson } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows })
|
||||||
|
const s = await checkBudget({ ...base, fetchJson })
|
||||||
|
check('18,5 € → warning sans blocage', s.warning === true && s.blocked === false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Panne NocoDB : fail-open, mais verified=false et avertissement au journal
|
||||||
|
{
|
||||||
|
const { fetchJson } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows: [], panne: true })
|
||||||
|
logs.length = 0
|
||||||
|
const s = await checkBudget({ ...base, fetchJson })
|
||||||
|
check('panne : fail-open, verified=false', s.blocked === false && s.verified === false)
|
||||||
|
check('panne : avertissement « NON vérifié »', logs.some(([niv, t]) => niv === 'warn' && t.includes('NON vérifié')))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
console.info = origInfo
|
||||||
|
console.warn = origWarn
|
||||||
|
}
|
||||||
|
|
||||||
|
let failed = 0
|
||||||
|
for (const r of results) {
|
||||||
|
if (!r.ok) failed++
|
||||||
|
console.log(`${r.ok ? 'ok ' : 'FAIL'} ${r.name}${r.ok ? '' : ` → ${r.detail}`}`)
|
||||||
|
}
|
||||||
|
console.log(failed ? `\nÉCHEC — ${failed}/${results.length} tests en échec` : `\nPASS — ${results.length}/${results.length} tests passés`)
|
||||||
|
process.exit(failed ? 1 : 0)
|
||||||
@@ -13,7 +13,9 @@ import { dirname, join } from 'node:path'
|
|||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const jiti = createJiti(import.meta.url)
|
const jiti = createJiti(import.meta.url)
|
||||||
|
|
||||||
const { LibreSubmitSchema } = await jiti.import(join(__dirname, '..', 'utils', 'submitLibre.ts'))
|
const { LibreSubmitSchema, toOrgaPayload, toReferencePayload } = await jiti.import(
|
||||||
|
join(__dirname, '..', 'utils', 'submitLibre.ts'),
|
||||||
|
)
|
||||||
|
|
||||||
function baseInput(overrides) {
|
function baseInput(overrides) {
|
||||||
return { liens: [], texte: '', site_web: '', ...overrides }
|
return { liens: [], texte: '', site_web: '', ...overrides }
|
||||||
@@ -50,6 +52,26 @@ const cases = [
|
|||||||
input: baseInput({ liens: ['ftp://exemple.fr/fichier.pdf'] }),
|
input: baseInput({ liens: ['ftp://exemple.fr/fichier.pdf'] }),
|
||||||
expectOk: false,
|
expectOk: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'double type valide (jobs + outils) → ok',
|
||||||
|
input: baseInput({ liens: ['https://exemple.fr'], type_suggere: 'jobs', type_secondaire: 'outils' }),
|
||||||
|
expectOk: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'type_secondaire sans type_suggere → refus',
|
||||||
|
input: baseInput({ liens: ['https://exemple.fr'], type_secondaire: 'outils' }),
|
||||||
|
expectOk: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'type_secondaire = type_suggere → refus',
|
||||||
|
input: baseInput({ liens: ['https://exemple.fr'], type_suggere: 'jobs', type_secondaire: 'jobs' }),
|
||||||
|
expectOk: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'type inconnu → refus',
|
||||||
|
input: baseInput({ liens: ['https://exemple.fr'], type_suggere: 'inconnu' }),
|
||||||
|
expectOk: false,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
let failures = 0
|
let failures = 0
|
||||||
@@ -62,5 +84,57 @@ for (const c of cases) {
|
|||||||
if (!pass) failures++
|
if (!pass) failures++
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`\n${cases.length - failures}/${cases.length} tests passés`)
|
// ── Mappings (toOrgaPayload / toReferencePayload) ──────────────────────────
|
||||||
|
function checkMapping(name, ok) {
|
||||||
|
console.log(`${ok ? 'PASS' : 'FAIL'} — ${name}`)
|
||||||
|
if (!ok) failures++
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const data = LibreSubmitSchema.parse(baseInput({ liens: ['https://exemple.fr'] }))
|
||||||
|
const payload = toOrgaPayload(data)
|
||||||
|
checkMapping(
|
||||||
|
'aucun type → description_user commence par « Type : non précisé »',
|
||||||
|
payload.description_user.startsWith('Type : non précisé'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const data = LibreSubmitSchema.parse(baseInput({ liens: ['https://exemple.fr'], type_suggere: 'jobs' }))
|
||||||
|
const payload = toOrgaPayload(data)
|
||||||
|
checkMapping(
|
||||||
|
'un type → pas de marqueur ni de ligne « Type suggéré »',
|
||||||
|
!payload.description_user.startsWith('Type : non précisé') && !payload.description_user.includes('Type suggéré'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const data = LibreSubmitSchema.parse(
|
||||||
|
baseInput({ liens: ['https://exemple.fr'], type_suggere: 'jobs', type_secondaire: 'outils' }),
|
||||||
|
)
|
||||||
|
const payload = toOrgaPayload(data)
|
||||||
|
checkMapping(
|
||||||
|
'double type → description_user commence par « Type suggéré : Jobs ou Outils »',
|
||||||
|
payload.description_user.startsWith('Type suggéré : Jobs ou Outils'),
|
||||||
|
)
|
||||||
|
checkMapping('double type → submission_type = le premier (job)', payload.submission_type === 'job')
|
||||||
|
checkMapping(
|
||||||
|
'double type → ne commence PAS par « Type : non précisé »',
|
||||||
|
!payload.description_user.startsWith('Type : non précisé'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const data = LibreSubmitSchema.parse(
|
||||||
|
baseInput({ liens: ['https://exemple.fr'], type_suggere: 'references', type_secondaire: 'outils' }),
|
||||||
|
)
|
||||||
|
const payload = toReferencePayload(data)
|
||||||
|
checkMapping(
|
||||||
|
'premier = Références + second → description commence par « Type suggéré : Références ou Outils »',
|
||||||
|
payload.description.startsWith('Type suggéré : Références ou Outils'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMappingChecks = 6
|
||||||
|
console.log(`\n${failures === 0 ? 'PASS' : 'FAIL'} — ${cases.length + totalMappingChecks - failures}/${cases.length + totalMappingChecks} tests passés`)
|
||||||
process.exit(failures > 0 ? 1 : 0)
|
process.exit(failures > 0 ? 1 : 0)
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* POST /api/chatbot-reseaux
|
* POST /api/chatbot-reseaux
|
||||||
* Chatbot Réseaux AEP — Carte 2 "Réseaux de bifurcation"
|
* Chatbot Réseaux AEP — Carte 2 "Réseaux de bifurcation"
|
||||||
* Keyword search sur reseaux-bifurcation.json + Mistral Small.
|
* Keyword search sur reseaux-bifurcation.json + LLM via Bifrost (server/utils/bifrost.ts).
|
||||||
*/
|
*/
|
||||||
// @ts-ignore — JSON import résolu par Rollup
|
// @ts-ignore — JSON import résolu par Rollup
|
||||||
import reseauxData from '../../public/data/reseaux-bifurcation.json'
|
import reseauxData from '../../public/data/reseaux-bifurcation.json'
|
||||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost'
|
import { pickBifrostTier, bifrostContent, REASONING_MARGIN_TOKENS, type BifrostChatResponse } from '~/server/utils/bifrost'
|
||||||
|
|
||||||
interface Structure {
|
interface Structure {
|
||||||
id: string
|
id: string
|
||||||
@@ -99,7 +99,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
model: tier.model,
|
model: tier.model,
|
||||||
fallbacks: tier.fallbacks,
|
fallbacks: tier.fallbacks,
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
max_tokens: 700,
|
max_tokens: 700 + REASONING_MARGIN_TOKENS,
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
@@ -108,7 +108,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
mistralRaw = res.choices?.[0]?.message?.content ?? '{}'
|
mistralRaw = bifrostContent(res, 'chatbot-reseaux')
|
||||||
} catch {
|
} catch {
|
||||||
throw createError({ statusCode: 502, message: 'Erreur IA — réessaie dans quelques instants.' })
|
throw createError({ statusCode: 502, message: 'Erreur IA — réessaie dans quelques instants.' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* POST /api/chatbot-taff
|
* POST /api/chatbot-taff
|
||||||
* Chatbot d'aiguillage — Carte 3 "Trouver du taf"
|
* Chatbot d'aiguillage — Carte 3 "Trouver du taf"
|
||||||
* Lit plateformes-taff.json, appelle Mistral Small, retourne recommandations.
|
* Lit plateformes-taff.json, appelle un LLM via Bifrost (server/utils/bifrost.ts), retourne recommandations.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// @ts-ignore — JSON import résolu par Vite/Rollup
|
// @ts-ignore — JSON import résolu par Vite/Rollup
|
||||||
import taffData from '../../public/data/plateformes-taff.json'
|
import taffData from '../../public/data/plateformes-taff.json'
|
||||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost'
|
import { pickBifrostTier, bifrostContent, REASONING_MARGIN_TOKENS, type BifrostChatResponse } from '~/server/utils/bifrost'
|
||||||
|
|
||||||
interface PlateformeMinimal {
|
interface PlateformeMinimal {
|
||||||
id: string
|
id: string
|
||||||
@@ -110,7 +110,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
model: tier.model,
|
model: tier.model,
|
||||||
fallbacks: tier.fallbacks,
|
fallbacks: tier.fallbacks,
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
max_tokens: 700,
|
max_tokens: 700 + REASONING_MARGIN_TOKENS,
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
@@ -119,7 +119,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
mistralRaw = res.choices?.[0]?.message?.content ?? '{}'
|
mistralRaw = bifrostContent(res, 'chatbot-taff')
|
||||||
} catch {
|
} catch {
|
||||||
throw createError({ statusCode: 502, statusMessage: 'Erreur IA — réessaie dans quelques instants.' })
|
throw createError({ statusCode: 502, statusMessage: 'Erreur IA — réessaie dans quelques instants.' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* POST /api/chatbot
|
* POST /api/chatbot
|
||||||
*
|
*
|
||||||
* Chatbot recherche sémantique — Mistral Small
|
* Chatbot recherche sémantique — LLM via Bifrost (tiers et replis : server/utils/bifrost.ts)
|
||||||
* Spec : F §7 (endpoint), F §8 (rate limit), E-spec §6 (détails chatbot)
|
* Spec : F §7 (endpoint), F §8 (rate limit), E-spec §6 (détails chatbot)
|
||||||
*
|
*
|
||||||
* Flow :
|
* Flow :
|
||||||
* 1. Rate limit : 10 req/IP/jour (JSON fichier, SHA-256)
|
* 1. Rate limit : 10 req/IP/jour (JSON fichier, SHA-256)
|
||||||
* 2. Circuit breaker : budget 20€/mois
|
* 2. Circuit breaker : budget 20€/mois
|
||||||
* 3. Fetch top-N fiches (keyword match sur nom+description+fonctions)
|
* 3. Fetch top-N fiches (keyword match sur nom+description+fonctions)
|
||||||
* 4. Appel Mistral Small avec contexte JSON compact
|
* 4. Appel Bifrost (tier RAPIDE par défaut) avec contexte JSON compact
|
||||||
* 5. Parse JSON → { reponse_texte, fiches_recommandees }
|
* 5. Parse JSON → { reponse_texte, fiches_recommandees }
|
||||||
* 6. Log stats_usage
|
* 6. Log stats_usage
|
||||||
*
|
*
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
|
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
|
||||||
import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost'
|
import { pickBifrostTier, bifrostContent, REASONING_MARGIN_TOKENS, type BifrostChatResponse } from '~/server/utils/bifrost'
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -277,7 +277,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
model: tier.model,
|
model: tier.model,
|
||||||
fallbacks: tier.fallbacks,
|
fallbacks: tier.fallbacks,
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
max_tokens: 600,
|
max_tokens: 600 + REASONING_MARGIN_TOKENS,
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
@@ -286,7 +286,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
mistralRaw = bifrostRes.choices?.[0]?.message?.content ?? '{}'
|
mistralRaw = bifrostContent(bifrostRes, 'chatbot')
|
||||||
tokensIn = bifrostRes.usage?.prompt_tokens ?? 0
|
tokensIn = bifrostRes.usage?.prompt_tokens ?? 0
|
||||||
tokensOut = bifrostRes.usage?.completion_tokens ?? 0
|
tokensOut = bifrostRes.usage?.completion_tokens ?? 0
|
||||||
realModel =
|
realModel =
|
||||||
|
|||||||
+39
-4
@@ -3,10 +3,24 @@
|
|||||||
* Endpoint OpenAI-compatible : POST {bifrostUrl}/v1/chat/completions
|
* Endpoint OpenAI-compatible : POST {bifrostUrl}/v1/chat/completions
|
||||||
* Auth : header x-bf-vk
|
* Auth : header x-bf-vk
|
||||||
*
|
*
|
||||||
* 2 tiers validés (Mission M3, build Bifrost) :
|
* 2 tiers (Mission M3 du build Bifrost ; modèles primaires changés le 28/09, session AEP front 4) :
|
||||||
* RAPIDE — défaut, pas de toggle UI mode rapide/approfondi sur le site actuellement
|
* RAPIDE — défaut, pas de toggle UI mode rapide/approfondi sur le site actuellement
|
||||||
* APPROFONDI — activable via body.mode === 'approfondi' (prêt pour un futur toggle front)
|
* APPROFONDI — activable via body.mode === 'approfondi' (prêt pour un futur toggle front)
|
||||||
*
|
*
|
||||||
|
* ⚠ 28/09 : Groq a retiré groq/llama-3.1-8b-instant (RAPIDE) et groq/llama-3.3-70b-versatile
|
||||||
|
* (APPROFONDI) — 404 model_not_found via Bifrost. Depuis le 23/09 environ, chaque appel
|
||||||
|
* chatbot tombait sur le premier repli Gemini (stats_usage). Primaires passés sur
|
||||||
|
* groq/openai/gpt-oss-20b et groq/openai/gpt-oss-120b ; replis inchangés. Le worker
|
||||||
|
* /opt/aep-worker tourne sur gpt-oss-20b en prod depuis le 28/09 (WORKER_MODEL, json_object) :
|
||||||
|
* Bifrost accepte ce nom de modèle à slash côté Groq.
|
||||||
|
* ⚠ gpt-oss est un modèle à raisonnement :
|
||||||
|
* - la réponse porte un champ message.reasoning à côté de message.content. Les routes
|
||||||
|
* chatbot ne lisent que content (JSON), usage et extra_fields : le champ est ignoré ;
|
||||||
|
* - les tokens de raisonnement sont décomptés de max_tokens. Sans marge, le JSON de la
|
||||||
|
* réponse peut être tronqué ; rendu en HTTP 200, Bifrost ne bascule pas, JSON.parse
|
||||||
|
* échoue → « Je n'ai pas pu analyser ta demande ». Déjà vu le 15/07 : gpt-oss-120b (via Cerebras), 3 appels
|
||||||
|
* corrects sur 4, « tronque parfois à max_tokens 700 ». D'où REASONING_MARGIN_TOKENS, ajouté par
|
||||||
|
* chaque route à son budget de contenu.
|
||||||
* ⚠ openrouter-oai exclu (bug Bifrost confirmé — 404 HTML sur modèles avec slash)
|
* ⚠ openrouter-oai exclu (bug Bifrost confirmé — 404 HTML sur modèles avec slash)
|
||||||
* ⚠ gemini-oai exige le préfixe "models/" (sinon 403 silencieux)
|
* ⚠ gemini-oai exige le préfixe "models/" (sinon 403 silencieux)
|
||||||
* ⚠ cerebras/gemma-4-31b RETIRÉ du tier RAPIDE (M4, 15/07) : en JSON mode avec un contexte
|
* ⚠ cerebras/gemma-4-31b RETIRÉ du tier RAPIDE (M4, 15/07) : en JSON mode avec un contexte
|
||||||
@@ -19,7 +33,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const BIFROST_TIER_RAPIDE = {
|
export const BIFROST_TIER_RAPIDE = {
|
||||||
model: 'groq/llama-3.1-8b-instant',
|
model: 'groq/openai/gpt-oss-20b',
|
||||||
fallbacks: [
|
fallbacks: [
|
||||||
'gemini-oai/models/gemini-2.5-flash-lite',
|
'gemini-oai/models/gemini-2.5-flash-lite',
|
||||||
'cohere/command-r-08-2024',
|
'cohere/command-r-08-2024',
|
||||||
@@ -27,7 +41,7 @@ export const BIFROST_TIER_RAPIDE = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const BIFROST_TIER_APPROFONDI = {
|
export const BIFROST_TIER_APPROFONDI = {
|
||||||
model: 'groq/llama-3.3-70b-versatile',
|
model: 'groq/openai/gpt-oss-120b',
|
||||||
fallbacks: [
|
fallbacks: [
|
||||||
'gemini-oai/models/gemini-2.5-flash',
|
'gemini-oai/models/gemini-2.5-flash',
|
||||||
'mistral/mistral-large-latest',
|
'mistral/mistral-large-latest',
|
||||||
@@ -35,13 +49,34 @@ export const BIFROST_TIER_APPROFONDI = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokens ajoutés au budget de contenu de chaque route (max_tokens = contenu + marge) pour
|
||||||
|
* absorber le raisonnement de gpt-oss. Plafond seulement : les replis sans raisonnement
|
||||||
|
* n'écrivent pas plus long, les prompts bornent la réponse (200-250 mots). Rester modeste :
|
||||||
|
* la limite de tokens par minute d'une clé Groq peut compter max_tokens avec le prompt.
|
||||||
|
*/
|
||||||
|
export const REASONING_MARGIN_TOKENS = 800
|
||||||
|
|
||||||
/** Sélectionne le tier selon le param optionnel body.mode. */
|
/** Sélectionne le tier selon le param optionnel body.mode. */
|
||||||
export function pickBifrostTier(mode?: string) {
|
export function pickBifrostTier(mode?: string) {
|
||||||
return mode === 'approfondi' ? BIFROST_TIER_APPROFONDI : BIFROST_TIER_RAPIDE
|
return mode === 'approfondi' ? BIFROST_TIER_APPROFONDI : BIFROST_TIER_RAPIDE
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BifrostChatResponse {
|
export interface BifrostChatResponse {
|
||||||
choices: { message: { content: string } }[]
|
/** reasoning : présent avec gpt-oss, jamais lu (la réponse utile est dans content). */
|
||||||
|
choices: { message: { content: string; reasoning?: string }; finish_reason?: string }[]
|
||||||
usage?: { prompt_tokens: number; completion_tokens: number }
|
usage?: { prompt_tokens: number; completion_tokens: number }
|
||||||
extra_fields?: { provider?: string; resolved_model_used?: string }
|
extra_fields?: { provider?: string; resolved_model_used?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contenu JSON de la réponse. Une troncature (finish_reason 'length') ne lève aucune erreur
|
||||||
|
* et finit en message générique côté usager : on la signale au journal pour la voir.
|
||||||
|
*/
|
||||||
|
export function bifrostContent(res: BifrostChatResponse, route: string): string {
|
||||||
|
const choice = res.choices?.[0]
|
||||||
|
if (choice?.finish_reason === 'length') {
|
||||||
|
console.warn(`[${route}] réponse tronquée (finish_reason=length) : relever REASONING_MARGIN_TOKENS`)
|
||||||
|
}
|
||||||
|
return choice?.message?.content ?? '{}'
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,53 +19,117 @@ export interface BudgetStatus {
|
|||||||
cumulEur: number
|
cumulEur: number
|
||||||
blocked: boolean
|
blocked: boolean
|
||||||
warning: boolean
|
warning: boolean
|
||||||
|
/** false si stats_usage n'a pas pu être lu : le budget n'est alors PAS vérifié (fail-open). */
|
||||||
|
verified: boolean
|
||||||
|
/** Lignes lues dans stats_usage (toutes dates). */
|
||||||
|
lignesLues: number
|
||||||
|
/** Lignes retenues pour le mois courant. */
|
||||||
|
lignesMois: number
|
||||||
|
/** Lignes sans aucun champ date reconnu (ignorées, comme dans le worker). */
|
||||||
|
lignesSansDate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page NocoDB v2 : seuls les champs lus ici sont typés. */
|
||||||
|
interface StatsPage {
|
||||||
|
list?: Record<string, unknown>[]
|
||||||
|
pageInfo?: { isLastPage?: boolean }
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchJson = (
|
||||||
|
url: string,
|
||||||
|
opts: { headers: Record<string, string>; query: Record<string, string | number> },
|
||||||
|
) => Promise<StatsPage>
|
||||||
|
|
||||||
|
const PAGE_SIZE = 1000
|
||||||
|
const MAX_PAGES = 20 // 20 000 lignes : garde-fou contre une boucle, pas une limite métier
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date d'une ligne de stats_usage, dans l'ordre du worker (`worker/enrich.js`,
|
||||||
|
* getBudgetMoisCourant) : `timestamp` écrit par les routes et le worker, sinon
|
||||||
|
* la colonne système de NocoDB (`CreatedAt`, ou `created_at` selon la version).
|
||||||
|
*/
|
||||||
|
export function dateLigne(row: Record<string, unknown>): Date | null {
|
||||||
|
const brut = row.timestamp ?? row.CreatedAt ?? row.created_at
|
||||||
|
if (brut === null || brut === undefined || brut === '') return null
|
||||||
|
const d = new Date(brut as string)
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calcule le cumul de dépenses IA du mois courant depuis stats_usage NocoDB.
|
* Calcule le cumul de dépenses IA du mois courant depuis stats_usage NocoDB.
|
||||||
* Retourne blocked=true si le budget est atteint.
|
* Retourne blocked=true si le budget est atteint.
|
||||||
|
*
|
||||||
|
* AF5 (29/09) : la requête ne nomme plus AUCUNE colonne (ni `where`, ni `fields`,
|
||||||
|
* ni `sort`). L'ancienne version filtrait sur `timestamp` côté NocoDB et recevait
|
||||||
|
* un 422 à chaque appel : budget jamais vérifié côté chatbot, en silence
|
||||||
|
* (fail-open). On lit la table page par page et on filtre le mois en JS,
|
||||||
|
* comme le worker, qui lit la même table sans erreur.
|
||||||
*/
|
*/
|
||||||
export async function checkBudget(config: {
|
export async function checkBudget(config: {
|
||||||
nocodbUrl: string
|
nocodbUrl: string
|
||||||
nocodbToken: string
|
nocodbToken: string
|
||||||
statsTableId: string
|
statsTableId: string
|
||||||
|
/** Injecté par les tests ; `$fetch` de Nitro sinon. */
|
||||||
|
fetchJson?: FetchJson
|
||||||
|
/** Injecté par les tests ; maintenant sinon. */
|
||||||
|
now?: Date
|
||||||
}): Promise<BudgetStatus> {
|
}): Promise<BudgetStatus> {
|
||||||
const { nocodbUrl, nocodbToken, statsTableId } = config
|
const { nocodbUrl, nocodbToken, statsTableId } = config
|
||||||
|
const fetchJson: FetchJson = config.fetchJson ?? ((url, opts) => $fetch<StatsPage>(url, opts))
|
||||||
|
const now = config.now ?? new Date()
|
||||||
|
const annee = now.getUTCFullYear()
|
||||||
|
const mois = now.getUTCMonth()
|
||||||
|
|
||||||
// Premier du mois courant à minuit UTC
|
const url = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
|
||||||
const now = new Date()
|
let cumulEur = 0
|
||||||
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))
|
let lignesLues = 0
|
||||||
const monthStartIso = monthStart.toISOString()
|
let lignesMois = 0
|
||||||
|
let lignesSansDate = 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch toutes les entrées du mois courant (NocoDB v2)
|
for (let page = 0; page < MAX_PAGES; page++) {
|
||||||
const url = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
|
const res = await fetchJson(url, {
|
||||||
|
|
||||||
const res = await $fetch<{ list: { cout_eur: number | null; timestamp: string }[] }>(
|
|
||||||
url,
|
|
||||||
{
|
|
||||||
headers: { 'xc-token': nocodbToken },
|
headers: { 'xc-token': nocodbToken },
|
||||||
query: {
|
query: { limit: PAGE_SIZE, offset: page * PAGE_SIZE },
|
||||||
where: `(timestamp,gte,${monthStartIso})`,
|
})
|
||||||
limit: 1000,
|
const rows = res?.list ?? []
|
||||||
fields: 'cout_eur,timestamp',
|
for (const row of rows) {
|
||||||
},
|
lignesLues++
|
||||||
},
|
const d = dateLigne(row)
|
||||||
)
|
if (!d) {
|
||||||
|
lignesSansDate++
|
||||||
const rows = res?.list ?? []
|
continue
|
||||||
const cumulEur = rows.reduce((sum, row) => sum + (Number(row.cout_eur) || 0), 0)
|
}
|
||||||
|
if (d.getUTCFullYear() === annee && d.getUTCMonth() === mois) {
|
||||||
return {
|
lignesMois++
|
||||||
cumulEur,
|
cumulEur += Number(row.cout_eur) || 0
|
||||||
blocked: cumulEur >= BUDGET_MAX_EUR,
|
}
|
||||||
warning: cumulEur >= BUDGET_WARN_EUR,
|
}
|
||||||
|
if (rows.length < PAGE_SIZE || res?.pageInfo?.isLastPage) break
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// En cas d'erreur de lecture, on ne bloque PAS pour ne pas pénaliser les utilisateurs
|
// En cas d'erreur de lecture, on ne bloque PAS pour ne pas pénaliser les utilisateurs
|
||||||
console.warn('[circuitBreaker] Erreur lecture stats_usage — budget non vérifié:', (e as Error).message)
|
console.warn('[circuitBreaker] Erreur lecture stats_usage — budget NON vérifié:', (e as Error).message)
|
||||||
return { cumulEur: 0, blocked: false, warning: false }
|
return { cumulEur: 0, blocked: false, warning: false, verified: false, lignesLues, lignesMois, lignesSansDate }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const status: BudgetStatus = {
|
||||||
|
cumulEur,
|
||||||
|
blocked: cumulEur >= BUDGET_MAX_EUR,
|
||||||
|
warning: cumulEur >= BUDGET_WARN_EUR,
|
||||||
|
verified: true,
|
||||||
|
lignesLues,
|
||||||
|
lignesMois,
|
||||||
|
lignesSansDate,
|
||||||
|
}
|
||||||
|
const mm = String(mois + 1).padStart(2, '0')
|
||||||
|
const suffixe = status.blocked ? ' — BLOQUÉ' : status.warning ? ' — alerte' : ''
|
||||||
|
const sansDate = lignesSansDate ? `, ${lignesSansDate} sans date ignorées` : ''
|
||||||
|
console.info(
|
||||||
|
`[circuitBreaker] budget lu : ${cumulEur.toFixed(4)} € / ${BUDGET_MAX_EUR} € ` +
|
||||||
|
`(${lignesMois} lignes ${annee}-${mm} sur ${lignesLues} lues${sansDate})${suffixe}`,
|
||||||
|
)
|
||||||
|
return status
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+44
-5
@@ -25,6 +25,20 @@ export const TYPES_SUGGERES_LABELS: Record<TypeSuggere, string> = {
|
|||||||
references: 'Références',
|
references: 'Références',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Légende courte sous les chips — définitions tirées des textes existants du site :
|
||||||
|
* ecosysteme/jobs/outils/references reprennent la tête d'onglet (`TABS`, pages/proposer.vue)
|
||||||
|
* et/ou la phrase de l'échelle qui pointe vers ce type (utils/echelles.ts) ; reseau reprend
|
||||||
|
* la tête d'onglet « Réseau AEP » et les champs projet/lien de FormReseau.vue.
|
||||||
|
*/
|
||||||
|
export const TYPES_SUGGERES_HINTS: Record<TypeSuggere, string> = {
|
||||||
|
ecosysteme: 'entraide, mutualisation',
|
||||||
|
reseau: 'acteurs et projets AEP',
|
||||||
|
jobs: 'offres, plateformes d\'emploi',
|
||||||
|
outils: 'ressources, méthodes',
|
||||||
|
references: 'bibliothèque, ouvrages',
|
||||||
|
}
|
||||||
|
|
||||||
/** URL http(s) uniquement — un `ftp://` ou autre schéma est invalide. */
|
/** URL http(s) uniquement — un `ftp://` ou autre schéma est invalide. */
|
||||||
const httpUrl = z
|
const httpUrl = z
|
||||||
.string()
|
.string()
|
||||||
@@ -42,6 +56,8 @@ export const LibreSubmitSchema = z
|
|||||||
.or(z.literal(''))
|
.or(z.literal(''))
|
||||||
.transform((v) => v || undefined),
|
.transform((v) => v || undefined),
|
||||||
type_suggere: z.enum(TYPES_SUGGERES).optional().or(z.literal('')).transform((v) => (v ? v : undefined)),
|
type_suggere: z.enum(TYPES_SUGGERES).optional().or(z.literal('')).transform((v) => (v ? v : undefined)),
|
||||||
|
// Deuxième chip optionnelle (B5-M2b) — indécis entre deux types.
|
||||||
|
type_secondaire: z.enum(TYPES_SUGGERES).optional().or(z.literal('')).transform((v) => (v ? v : undefined)),
|
||||||
// Honeypot anti-spam : champ caché, doit rester vide pour un humain.
|
// Honeypot anti-spam : champ caché, doit rester vide pour un humain.
|
||||||
site_web: z.string().optional().or(z.literal('')),
|
site_web: z.string().optional().or(z.literal('')),
|
||||||
})
|
})
|
||||||
@@ -49,6 +65,14 @@ export const LibreSubmitSchema = z
|
|||||||
message: 'Ajoute au moins un lien ou quelques mots.',
|
message: 'Ajoute au moins un lien ou quelques mots.',
|
||||||
path: ['texte'],
|
path: ['texte'],
|
||||||
})
|
})
|
||||||
|
.refine((data) => !data.type_secondaire || !!data.type_suggere, {
|
||||||
|
message: 'Choisis d\'abord un type principal.',
|
||||||
|
path: ['type_secondaire'],
|
||||||
|
})
|
||||||
|
.refine((data) => !data.type_secondaire || data.type_secondaire !== data.type_suggere, {
|
||||||
|
message: 'Le second type doit être différent du premier.',
|
||||||
|
path: ['type_secondaire'],
|
||||||
|
})
|
||||||
|
|
||||||
export type LibreSubmitInput = z.infer<typeof LibreSubmitSchema>
|
export type LibreSubmitInput = z.infer<typeof LibreSubmitSchema>
|
||||||
|
|
||||||
@@ -75,26 +99,41 @@ function nomSuggere(data: LibreSubmitInput): string {
|
|||||||
return `[à qualifier] ${base || fallback || 'sans titre'}`
|
return `[à qualifier] ${base || fallback || 'sans titre'}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function descriptionAvecLiens(texte: string, liens: string[], typeNonPrecise: boolean): string {
|
/**
|
||||||
|
* `prefixLine` est soit le marqueur worker « Type : non précisé » (aucun type choisi),
|
||||||
|
* soit la ligne « Type suggéré : X ou Y » (double type) — jamais les deux à la fois,
|
||||||
|
* et toujours en toute première position quand présent (contrat worker/enrich.js:562).
|
||||||
|
*/
|
||||||
|
function descriptionAvecLiens(texte: string, liens: string[], prefixLine?: string): string {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
if (typeNonPrecise) parts.push('Type : non précisé')
|
if (prefixLine) parts.push(prefixLine)
|
||||||
if (texte.trim()) parts.push(texte.trim())
|
if (texte.trim()) parts.push(texte.trim())
|
||||||
if (liens.length) parts.push(`Liens :\n${liens.join('\n')}`)
|
if (liens.length) parts.push(`Liens :\n${liens.join('\n')}`)
|
||||||
return parts.join('\n\n')
|
return parts.join('\n\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ligneTypeSuggere(data: LibreSubmitInput): string | undefined {
|
||||||
|
if (!data.type_secondaire) return undefined
|
||||||
|
// Le schéma garantit type_suggere défini dès que type_secondaire l'est.
|
||||||
|
return `Type suggéré : ${TYPES_SUGGERES_LABELS[data.type_suggere!]} ou ${TYPES_SUGGERES_LABELS[data.type_secondaire]}`
|
||||||
|
}
|
||||||
|
|
||||||
/** Mapping vers la table orgas existante (submission_type ecosysteme/reseau/job/outil). */
|
/** Mapping vers la table orgas existante (submission_type ecosysteme/reseau/job/outil). */
|
||||||
export function toOrgaPayload(data: LibreSubmitInput) {
|
export function toOrgaPayload(data: LibreSubmitInput) {
|
||||||
const typeNonPrecise = !data.type_suggere || data.type_suggere === 'references'
|
const hasSecondaire = !!data.type_secondaire
|
||||||
|
// Marqueur worker posé uniquement quand aucun type exploitable n'a été choisi du tout —
|
||||||
|
// un double type ne le pose jamais (contrat worker/enrich.js:562).
|
||||||
|
const typeNonPrecise = !hasSecondaire && (!data.type_suggere || data.type_suggere === 'references')
|
||||||
const submissionType = data.type_suggere && data.type_suggere !== 'references'
|
const submissionType = data.type_suggere && data.type_suggere !== 'references'
|
||||||
? TYPE_TO_SUBMISSION_TYPE[data.type_suggere]
|
? TYPE_TO_SUBMISSION_TYPE[data.type_suggere]
|
||||||
: 'ecosysteme'
|
: 'ecosysteme'
|
||||||
|
const prefixLine = typeNonPrecise ? 'Type : non précisé' : ligneTypeSuggere(data)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
submission_type: submissionType,
|
submission_type: submissionType,
|
||||||
nom: nomSuggere(data),
|
nom: nomSuggere(data),
|
||||||
url: data.liens[0] || null,
|
url: data.liens[0] || null,
|
||||||
description_user: descriptionAvecLiens(data.texte, data.liens, typeNonPrecise),
|
description_user: descriptionAvecLiens(data.texte, data.liens, prefixLine),
|
||||||
submitted_by_email: data.submitted_by_email || null,
|
submitted_by_email: data.submitted_by_email || null,
|
||||||
moderation_status: 'pending' as const,
|
moderation_status: 'pending' as const,
|
||||||
ai_processed: false,
|
ai_processed: false,
|
||||||
@@ -109,7 +148,7 @@ export function toReferencePayload(data: LibreSubmitInput) {
|
|||||||
titre: nomSuggere(data),
|
titre: nomSuggere(data),
|
||||||
auteur: 'non précisé',
|
auteur: 'non précisé',
|
||||||
url: data.liens[0] || null,
|
url: data.liens[0] || null,
|
||||||
description: descriptionAvecLiens(data.texte, data.liens, false),
|
description: descriptionAvecLiens(data.texte, data.liens, ligneTypeSuggere(data)),
|
||||||
pertinence_rag: null,
|
pertinence_rag: null,
|
||||||
hashtags: null,
|
hashtags: null,
|
||||||
submitted_by_email: data.submitted_by_email || null,
|
submitted_by_email: data.submitted_by_email || null,
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@ const NOCODB_TABLE_STATS = process.env.NOCODB_TABLE_STATS;
|
|||||||
|
|
||||||
const BIFROST_URL = process.env.BIFROST_URL || 'http://127.0.0.1:8080';
|
const BIFROST_URL = process.env.BIFROST_URL || 'http://127.0.0.1:8080';
|
||||||
const BIFROST_VK = process.env.BIFROST_VK;
|
const BIFROST_VK = process.env.BIFROST_VK;
|
||||||
const WORKER_MODEL = process.env.WORKER_MODEL || 'groq/llama-3.1-8b-instant';
|
const WORKER_MODEL = process.env.WORKER_MODEL || 'groq/openai/gpt-oss-20b'; // llama-3.1-8b-instant retiré par Groq (28/09)
|
||||||
|
|
||||||
const NTFY_TOPIC = process.env.NTFY_TOPIC;
|
const NTFY_TOPIC = process.env.NTFY_TOPIC;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user