Skip to content
Open
4 changes: 2 additions & 2 deletions components/Datasets/DescribeDataset.vue
Original file line number Diff line number Diff line change
Expand Up @@ -914,7 +914,7 @@ async function handleAutoCompleteDescriptionShort() {
// We call our server-side API route instead of Albert API directly to avoid CORS issues.
// The Albert API doesn't allow direct requests from browser-side JavaScript.
// Our server acts as a proxy, keeping the API key secure on the server side.
const response = await $fetch<{ descriptionShort?: string }>('/nuxt-api/albert/generate-short-description', {
const response = await $fetch<{ descriptionShort?: string }>('/nuxt-api/albert/generate-dataset-short-description', {
method: 'POST',
body: {
title: form.value.title,
Expand Down Expand Up @@ -946,7 +946,7 @@ async function handleAutoCompleteTags(nbTags: number) {
// We call our server-side API route instead of Albert API directly to avoid CORS issues.
// The Albert API doesn't allow direct requests from browser-side JavaScript.
// Our server acts as a proxy, keeping the API key secure on the server side.
const response = await $fetch<{ tags: string[] }>('/nuxt-api/albert/generate-tags', {
const response = await $fetch<{ tags: string[] }>('/nuxt-api/albert/generate-dataset-tags', {
method: 'POST',
body: {
title: form.value.title,
Expand Down
152 changes: 147 additions & 5 deletions components/Reuses/DescribeReuse.vue
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,25 @@
:title="t('Ajouter des mots-clés')"
:state="accordionState('tags')"
>
<p class="fr-m-0">
{{ t("Les mots clés apparaissent sur la page de présentation et apportent un meilleur référencement lors d’une recherche utilisateur.\nÀ partir de chaque mot clé, vous pouvez obtenir la liste des réutilisations pour lesquelles le mot clé a également été assigné.") }}
</p>
<div class="prose prose-neutral m-0">
<p class="m-0">
{{ t("Les mots clés apparaissent sur la page de présentation et apportent un meilleur référencement lors d'une recherche utilisateur.\nÀ partir de chaque mot clé, vous pouvez obtenir la liste des réutilisations pour lesquelles le mot clé a également été assigné.") }}
</p>
<p class="fr-mt-3v font-bold">
{{ t("Suggestions automatiques") }}
</p>
<p class="m-0">
{{ t("Des mots-clés peuvent vous être proposés automatiquement en fonction du contenu de votre réutilisation. Vous pouvez les accepter, les modifier ou les supprimer.") }}
</p>
<p class="m-0">
<CdataLink
to="https://guides.data.gouv.fr/autres-ressources-utiles/notre-approche-de-lintelligence-artificielle-sur-data.gouv.fr"
target="_blank"
>
{{ t(`L'IA se base uniquement sur les informations que vous avez fournies et peut parfois se tromper : relisez toujours la proposition avant de valider.`) }}
</CdataLink>
</p>
</div>
</Accordion>
<Accordion
:id="addImageAccordionId"
Expand Down Expand Up @@ -288,6 +304,46 @@
:error-text="getFirstError('tags')"
:warning-text="getFirstWarning('tags')"
/>
<div class="flex items-center gap-4 mt-2 mb-3">
<Tooltip v-if="tagsSuggestionDisabledMessage">
<BrandedButton
type="button"
color="primary"
:icon="RiSparklingLine"
:loading="isGeneratingTags"
:disabled="true"
>
{{ $t('Suggérer des mots clés') }}
</BrandedButton>
<template #tooltip>
{{ tagsSuggestionDisabledMessage }}
</template>
</Tooltip>
<BrandedButton
v-else
type="button"
color="primary"
:icon="RiSparklingLine"
:loading="isGeneratingTags"
:disabled="!!tagsSuggestionDisabledMessage"
@click="handleAutoCompleteTags(MAX_TAGS_NB)"
>
<template v-if="isGeneratingTags">
{{ $t('Suggestion en cours...') }}
</template>
<template v-else>
{{ $t('Suggérer des mots clés') }}
</template>
</BrandedButton>
<CdataLink
v-if="config.public.generateTagsFeedbackUrl"
:to="config.public.generateTagsFeedbackUrl"
target="_blank"
class="text-sm text-gray-medium"
>
{{ $t('Comment avez-vous trouvé cette suggestion ?') }}
</CdataLink>
</div>
<SimpleBanner
v-if="getFirstWarning('tags')"
type="warning"
Expand Down Expand Up @@ -342,14 +398,17 @@
</template>

<script setup lang="ts">
import { SearchableSelect, SimpleBanner, type ReuseTopic, type ReuseType, type Owned } from '@datagouv/components-next'
import { BrandedButton, Tooltip, SearchableSelect, SimpleBanner, type ReuseTopic, type ReuseType, type Owned } from '@datagouv/components-next'
import { RiSparklingLine } from '@remixicon/vue'
import { computed } from 'vue'
import Accordion from '~/components/Accordion/Accordion.global.vue'
import AccordionGroup from '~/components/Accordion/AccordionGroup.global.vue'
import CdataLink from '~/components/CdataLink.vue'
import ToggleSwitch from '~/components/Form/ToggleSwitch.vue'
import ProducerSelect from '~/components/ProducerSelect.vue'
import RequiredExplanation from '~/components/RequiredExplanation/RequiredExplanation.vue'
import type { ReuseForm } from '~/types/types'
import { humanJoin } from '~/utils/helpers'
import type { ReuseForm, Tag } from '~/types/types'

const reuseForm = defineModel<ReuseForm>({ required: true })
const props = defineProps<{
Expand Down Expand Up @@ -380,6 +439,12 @@ const ownedOptions = computed<Array<Owned>>(() => {
return [...user.value.organizations.map(organization => ({ organization, owner: null })), { owner: { ...user.value, class: 'User' }, organization: null }]
})

const MAX_TAGS_NB = 5

// Track tag sources
const isGeneratingTags = ref(false)
const lastSuggestedTags = ref<Array<Tag>>([])

const { form, touch, getFirstError, getFirstWarning, validate } = useForm(reuseForm, {
featured: [],
owned: [required()],
Expand All @@ -406,6 +471,31 @@ const accordionState = (key: keyof typeof form.value) => {
return 'default'
}

const hasTitle = computed(() => form.value.title && form.value.title.trim().length > 0)
const hasDescription = computed(() => form.value.description && form.value.description.trim().length > 0)
const hasType = computed(() => form.value.type?.label && form.value.type.label.trim().length > 0)
const hasLessThanMaxTags = computed(() => form.value.tags.length < MAX_TAGS_NB)

const tagsSuggestionDisabledMessage = computed(() => {
if (!hasLessThanMaxTags.value) {
return t('Vous avez déjà {count} mots-clés. Le maximum recommandé est de {max}.', { count: form.value.tags.length, max: MAX_TAGS_NB })
}
const missing = []
if (!hasTitle.value) {
missing.push(t('le titre'))
}
if (!hasDescription.value) {
missing.push(t('la description'))
}
if (!hasType.value) {
missing.push(t('le type'))
}
if (missing.length > 0) {
return t('Remplissez {fields} pour utiliser cette fonctionnalité.', { fields: humanJoin(missing) })
}
return ''
})

const setFiles = (files: Array<File>) => {
reuseForm.value.image = files[0]
}
Expand All @@ -420,4 +510,56 @@ async function submit() {
emit('submit')
}
}

async function handleAutoCompleteTags(nbTags: number) {
if (!form.value.type?.label) {
return
}

try {
isGeneratingTags.value = true

// We call our server-side API route instead of Albert API directly to avoid CORS issues.
// The Albert API doesn't allow direct requests from browser-side JavaScript.
// Our server acts as a proxy, keeping the API key secure on the server side.
const response = await $fetch<{ tags: string[] }>('/nuxt-api/albert/generate-reuse-tags', {
method: 'POST',
body: {
title: form.value.title,
description: form.value.description,
type: form.value.type.label.toLowerCase(),
nbTags: nbTags,
},
})

// Remove previously suggested tags and add new ones
if (response.tags && response.tags.length > 0) {
// Filter out tags that were the last suggested tags
let currentTags = form.value.tags
if (lastSuggestedTags.value.length > 0) {
currentTags = form.value.tags.filter(tag =>
!lastSuggestedTags.value.some(lastSuggested => lastSuggested.text === tag.text),
)
}

// Create new suggested tags, filtering out duplicates with existing tags
const existingTagTexts = currentTags.map(tag => tag.text)
const newSuggestedTags = response.tags
.filter(tag => !existingTagTexts.includes(tag))
.map(tag => ({ text: tag }))

// Update form with current tags + new suggested tags
form.value.tags = [...currentTags, ...newSuggestedTags]

// Update the suggested tags tracking
lastSuggestedTags.value = newSuggestedTags
}
}
catch (error) {
console.error('Failed to generate tags:', error)
}
finally {
isGeneratingTags.value = false
}
}
</script>
101 changes: 101 additions & 0 deletions server/routes/nuxt-api/albert/generate-reuse-tags.post.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { createChatCompletion, useAlbertConfig, type ChatResponse } from '~/server/utils/albert-api-client'

export default defineEventHandler(async (event) => {
const body = await readBody(event)
const { title, description, type, nbTags } = body

if (!title || !description || !type) {
throw createError({
statusCode: 400,
statusMessage: 'Title, description and type are required',
})
}

const runtimeConfig = useRuntimeConfig()

if (!runtimeConfig.albertApiKey) {
throw createError({
statusCode: 400,
statusMessage: 'Albert API is not configured',
})
}

try {
const albertConfig = useAlbertConfig()

const messages = [
{
role: 'system',
content: `You are an assistant integrated into data.gouv.fr, the French open data platform.\n`
+ `Your task is to identify and synthesize the main topics of a ${type} as five normalized keywords.\n`
+ `\n`
+ `Task goal:\n`
+ `Generate exactly ${nbTags} keywords that best represent the ${type}'s content.\n`
+ `Your goal is to improve search and discoverability through clear, consistent terms.\n`
+ `\n`
+ `Semantic guidance:\n`
+ `- Respond in French only.\n`
+ `- When possible, align the keywords with existing EuroVoc concepts in French (invisibly).\n`
+ `- Focus on the ${type}'s topics, not its context or technical structure.\n`
+ `\n`
+ `🧾Normalization rules:\n`
+ `1. Use simple, concrete words (1–3 words max).\n`
+ `2. Avoid repeating the reuse title.\n`
+ `3. Avoid generic words like "données" or "open-data".\n`
+ `4. Avoid technical jargon unless necessary.\n`
+ `5. Use lowercase, singular, no accents, words separated by hyphens, keywords separated by commas.\n`
+ `6. Remove duplicates or close synonyms.\n`
+ `\n`
+ `Output format:\n`
+ `- Exactly ${nbTags} keywords, separated by commas, and nothing else in the output.\n`
+ `- No explanations, no labels, no extra punctuation.\n`
+ `- Follow this example format: qualite-air, pollution, mesure, station-urbaine, environnement\n`
+ `\n`
+ `Your answer must strictly match this format.`,
},
{
role: 'user',
content: `You are asked to generate ${nbTags} keywords for the following ${type}.\n`
+ `\n`
+ `The title describes the main topic, the description explains the content.\n`
+ `\n`
+ `Goal:\n`
+ `→ Suggest ${nbTags} normalized French keywords representing the ${type}'s content.\n`
+ `→ When possible, use wording aligned with EuroVoc concepts in French.\n`
+ `→ Focus on what the ${type} is about, not on its context or structure.\n`
+ `\n`
+ `Input context:\n`
+ `- Title: ${title}\n`
+ `- Description: ${description}\n`
+ `- Type: ${type}\n`
+ `\n`
+ `Output:\n`
+ `→ A single line containing ${nbTags} normalized keywords, separated by commas.\n`
+ `→ Example: energie, consommation, electricite, region, environnement`,
},
]

// Models available for text generation:
// - openweight-small (replaces albert-small)
// - openweight-medium (replaces albert-large)
// - openweight-large
const response = await createChatCompletion(messages, 'openweight-small', albertConfig) as ChatResponse
const generatedTags = response.choices?.[0]?.message?.content || ''

// Parse the comma-separated tags and clean them
const tags = generatedTags
.split(',')
.map((tag: string) => tag.trim())
.filter((tag: string) => tag.length > 0)
.slice(0, nbTags)

return { tags }
}
catch (error) {
console.error('Albert API error:', error)
throw createError({
statusCode: 500,
statusMessage: (error as Error).message || 'Failed to call Albert API',
})
}
})
Loading