Compare commits

...
Author SHA1 Message Date
Jules NenyandClaude Opus 5.5 ebfc744574 feat(proposer): chip secondaire en contour, chip actif plein et contrasté
Le second choix du double type se lit en contour (texte --nav-text), le
principal passe en fond plein --nav-primary-solid : 4,16:1 → 14,6:1 mesuré
(le fond actif translucide de Chip.vue était sous AA). Survol collant
neutralisé sur tactile, annonce sr-only « Principal · second choix ».
Scopé à FormLibre ; Chip.vue non modifié (défaut global signalé).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AzGo8bxhHp6M9RxuAFbyLU
2026-09-28 16:48:24 +02:00
Jules NenyandClaude Opus 5.5 8f94a1acda fix(bifrost): tiers sur gpt-oss (Groq a retiré les llama) + marge de raisonnement
RAPIDE groq/llama-3.1-8b-instant → groq/openai/gpt-oss-20b ; APPROFONDI
groq/llama-3.3-70b-versatile → groq/openai/gpt-oss-120b ; replis inchangés.
Les chatbots vivaient sur le repli Gemini depuis le retrait.

gpt-oss raisonne : ses tokens de raisonnement sont décomptés de max_tokens.
REASONING_MARGIN_TOKENS (800) ajouté au budget des trois routes, et
bifrostContent() signale au journal une réponse tronquée (finish_reason
length). Le champ message.reasoning est ignoré. Défaut WORKER_MODEL aligné
sur la prod. En-têtes « Mistral Small » corrigés, PIPE-IA-DOC §11.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AzGo8bxhHp6M9RxuAFbyLU
2026-09-28 16:48:24 +02:00
Jules NenyandClaude Opus 5.5 288a9c9126 fix(data): A Brief History of Neoliberalism relève de David Harvey, pas de Hal Harvey
Doublon hérité du mapping heuristique v4.1 : retiré chez hal-harvey
(ingere: false, poster Bon Pote non ingéré), conservé chez david-harvey
avec bio et thèses réelles. Écoles inchangées. auteurs_ingeres_count
recompté sur les flags (91, la valeur 80 était obsolète).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvxdoEGWjCTSEJDmraYW96
2026-09-28 12:14:54 +02:00
Jules NenyandClaude Opus 5.5 a1edcf3093 feat(b5): M2b — légende des cinq types et double type pour les indécis
Deux chips maximum : le premier alimente submission_type, le second
s'écrit en tête de description_user (« Type suggéré : X ou Y »), sans
ALTER NocoDB. Le marqueur « Type : non précisé » n'est posé que sans
aucun type (contrat worker/enrich.js inchangé). ?categorie= présélectionne
un seul chip. Tests : 16/16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvxdoEGWjCTSEJDmraYW96
2026-09-28 12:14:54 +02:00
JulesandClaude Fable 5.1 043e2e2348 fix(b5): la notification ntfy ne porte jamais d'extrait du texte libre ; lien de modération public ; trackingUrl mort retiré
Checkpoint Fable 27/09 : sans lien, nomSuggere() reprenait 60 caractères du texte du contributeur et les envoyait sur un topic ntfy (public par URL). Le nom n'est transmis que s'il vient d'un hostname. Click → sheets.trans-former.fr (localhost inutilisable depuis un téléphone). trackingUrl pointait nav.trans-former.fr/suivi/, page inexistante.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 20:20:22 +02:00
11 changed files with 344 additions and 52 deletions
+38 -1
View File
@@ -365,7 +365,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 +381,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`.
+102 -5
View File
@@ -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,65 @@ 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 + contraste chip actif ──
Mesuré avant modif (script de capture, af4-avant-*) : le chip actif de
Chip.vue (variant fonction, fond rgba(26,34,56,.6) sur fond de page
#f8f6f1) rend ≈ 4.16:1 au repos — sous le seuil AA 4.5:1. C'est un défaut
général de Chip.vue (touche aussi les filtres de / et /agences), signalé
hors mandat — voir rapport. On neutralise ce point pour CE formulaire
seulement, sans toucher Chip.vue : fond plein (--nav-primary-solid) au
lieu de la version translucide, pairing déjà utilisé ailleurs sur le site
(pages/trouver-du-taf.vue .taff-tab--active). Mesuré après : ≈14.6:1. */
.field-group :deep(.chip--fonction.chip--active) {
background: var(--nav-primary-solid);
color: var(--nav-text-on-primary);
}
/* 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;
}
.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;
}
/* Survol collant sur tactile (le :hover de Chip.vue n'est pas gardé par
@media (hover: hover)) : neutralisé ici pour les deux chips actifs de ce
formulaire, sans toucher Chip.vue. Mesuré : le survol n'aggravait pas le
contraste du principal (6.68:1 contre 4.16:1 au repos, avant modif) mais
figeait un fond et un transform incohérents après un tap tant que rien ne
les levait. */
@media (hover: none) {
.field-group :deep(.chip--fonction.chip--active) {
transform: none !important;
}
.field-group :deep(.chip--fonction.chip--active:hover) {
background: var(--nav-primary-solid) !important;
color: var(--nav-text-on-primary) !important;
transform: none !important;
}
.field-group :deep(.chip--secondaire.chip--active:hover) {
background: transparent !important;
border-color: var(--nav-primary-solid) !important;
color: var(--nav-text) !important;
transform: none !important;
}
}
</style> </style>
+13 -15
View File
@@ -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",
+76 -2
View File
@@ -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)
+4 -4
View File
@@ -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.' })
} }
+4 -4
View File
@@ -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.' })
} }
+5 -5
View File
@@ -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 =
+18 -6
View File
@@ -207,7 +207,7 @@ export default defineEventHandler(async (event) => {
nomSuggere: data.nom, nomSuggere: data.nom,
typeSuggere: data.submission_type, typeSuggere: data.submission_type,
nbLiens: data.url ? 1 : 0, nbLiens: data.url ? 1 : 0,
nocodbAdminUrl: 'http://localhost:8070', nocodbAdminUrl: NOCODB_ADMIN_URL,
}), }),
).catch((e) => console.warn('[submit] Notification ntfy échouée:', e?.message ?? e)) ).catch((e) => console.warn('[submit] Notification ntfy échouée:', e?.message ?? e))
@@ -274,10 +274,10 @@ async function handleLibreSubmit(body: unknown) {
process.env.NTFY_TOPIC, process.env.NTFY_TOPIC,
buildSubmissionNotif({ buildSubmissionNotif({
submissionId, submissionId,
nomSuggere: payload.nom, nomSuggere: nomPourNotif(payload.nom, data),
typeSuggere: payload.submission_type, typeSuggere: payload.submission_type,
nbLiens: data.liens.length, nbLiens: data.liens.length,
nocodbAdminUrl: 'http://localhost:8070', nocodbAdminUrl: NOCODB_ADMIN_URL,
}), }),
).catch((e) => console.warn('[submit] Notification ntfy échouée (libre):', e?.message ?? e)) ).catch((e) => console.warn('[submit] Notification ntfy échouée (libre):', e?.message ?? e))
@@ -285,10 +285,22 @@ async function handleLibreSubmit(body: unknown) {
status: 201, status: 201,
submissionId, submissionId,
message: 'Ta fiche est en cours de traitement.', message: 'Ta fiche est en cours de traitement.',
trackingUrl: submissionId ? `https://nav.trans-former.fr/suivi/${submissionId}` : null, trackingUrl: null,
} }
} }
/** URL de modération cliquable depuis la notification (téléphone) — jamais localhost. */
const NOCODB_ADMIN_URL = 'https://sheets.trans-former.fr'
/**
* Sans lien, `nomSuggere()` retombe sur les 60 premiers caractères du texte libre :
* ce texte ne doit jamais partir vers ntfy (topic public par URL). On ne transmet
* le nom que lorsqu'il est dérivé d'un hostname.
*/
function nomPourNotif(nom: string, data: LibreSubmitInput): string {
return data.liens.length > 0 ? nom : '[à qualifier] texte seul (voir NocoDB)'
}
async function insertReferenceLibre(config: ReturnType<typeof useRuntimeConfig>, data: LibreSubmitInput) { async function insertReferenceLibre(config: ReturnType<typeof useRuntimeConfig>, data: LibreSubmitInput) {
const payload = toReferencePayload(data) const payload = toReferencePayload(data)
const insertUrl = `${config.nocodbUrl}/api/v1/db/data/noco/${config.nocodbBase}/${config.referencesTableId}` const insertUrl = `${config.nocodbUrl}/api/v1/db/data/noco/${config.nocodbBase}/${config.referencesTableId}`
@@ -311,10 +323,10 @@ async function insertReferenceLibre(config: ReturnType<typeof useRuntimeConfig>,
process.env.NTFY_TOPIC, process.env.NTFY_TOPIC,
buildSubmissionNotif({ buildSubmissionNotif({
submissionId: referenceId, submissionId: referenceId,
nomSuggere: payload.titre, nomSuggere: nomPourNotif(payload.titre, data),
typeSuggere: 'references', typeSuggere: 'references',
nbLiens: data.liens.length, nbLiens: data.liens.length,
nocodbAdminUrl: 'http://localhost:8070', nocodbAdminUrl: NOCODB_ADMIN_URL,
}), }),
).catch((e) => console.warn('[submit] Notification ntfy échouée (référence libre):', e?.message ?? e)) ).catch((e) => console.warn('[submit] Notification ntfy échouée (référence libre):', e?.message ?? e))
+39 -4
View File
@@ -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 ?? '{}'
}
+44 -5
View File
@@ -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
View File
@@ -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;