Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e8e386a
feat: suggest dataservice description
bolinocroustibat Feb 5, 2026
f4c512e
docs: add help text
bolinocroustibat Feb 16, 2026
5ec0ff7
refactor(markdown-editor): remove unnecessary ref import
bolinocroustibat Feb 17, 2026
4066749
refactor(albert): use shared helpers and remove duplicate config check
bolinocroustibat Feb 17, 2026
93c5c19
refactor(config): rename generateShortDescriptionFeedbackUrl to gener…
bolinocroustibat Feb 17, 2026
92d279c
feat: fetch documentation URLs to build prompt to suggest description
bolinocroustibat Feb 17, 2026
86cfec4
docs: add comments
bolinocroustibat Feb 17, 2026
512e430
feat: infer content type from the URL extension when fetching dataser…
bolinocroustibat Apr 14, 2026
de43c07
feat(dataservice): show description feedback link after AI suggestion
bolinocroustibat Apr 14, 2026
9dd21ca
fix(albert): decode HTML entities in fetched documentation
bolinocroustibat Apr 14, 2026
4ca3fb9
fix(albert): secure documentation URL fetch (SSRF, size cap, redirects)
bolinocroustibat Apr 14, 2026
7e5f0a8
Merge branch 'main' into feat/suggest-dataservice-description
bolinocroustibat Apr 21, 2026
80bfe62
Merge branch 'main' into feat/suggest-dataservice-description
bolinocroustibat May 4, 2026
e0a50c0
feat(dataservice): show description suggestion errors in form banner
bolinocroustibat May 4, 2026
8727cbf
refactor: use promise.all()) in server/routes/nuxt-api/albert/generat…
bolinocroustibat May 5, 2026
0ae0f6b
Merge branch 'main' into feat/suggest-dataservice-description
bolinocroustibat May 5, 2026
a9c6f5a
style: fix linting/indentation issue
bolinocroustibat May 5, 2026
a6a805f
Merge branch 'main' into feat/suggest-dataservice-description
bolinocroustibat May 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 156 additions & 4 deletions components/Dataservices/DescribeDataservice.vue
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@
<p class="fr-m-0">
{{ t("Rédigez une description claire et précise de l'API. Les usagers ont besoin de comprendre l'objectif de l'API, les données délivrées, le périmètre couvert (la donnée est-elle exhaustive, y-a t'il des manques ?), la fréquence d'actualisation de la donnée, ainsi que les paramètres avec lesquels ils peuvent effectuer un appel.") }}
</p>
<p class="fr-mt-3v font-bold">
{{ $t("Suggestions automatiques") }}
</p>
<p class="fr-m-0">
{{ $t(`Une première version peut être générée automatiquement si vous avez rempli le nom de l'API et au moins un lien fonctionnel vers la documentation (technique ou machine), puis adaptée selon vos besoins.`) }}
</p>
</Accordion>
<Accordion
:id="addBaseUrlAccordionId"
Expand Down Expand Up @@ -251,6 +257,7 @@
:accordion="addDescriptionAccordionId"
>
<InputGroup
:key="descriptionEditorRefreshKey"
v-model="form.description"
class="mb-3"
:label="$t('Description')"
Expand All @@ -268,6 +275,56 @@
>
{{ getFirstWarning("description") }}
</SimpleBanner>
<div class="flex items-center gap-4 mt-2 mb-3">
<Tooltip v-if="!canGenerateDescription">
<BrandedButton
type="button"
color="primary"
:disabled="true"
>
<div class="flex items-center space-x-2">
<RiSparklingLine
class="size-4"
aria-hidden="true"
/>
<span>{{ $t('Suggérer une description') }}</span>
</div>
</BrandedButton>
<template #tooltip>
{{ $t('Remplissez le nom de l\'API et au moins l\'un des liens (documentation technique ou documentation machine) pour utiliser cette fonctionnalité.') }}
</template>
</Tooltip>
<BrandedButton
v-else
type="button"
color="primary"
:icon="RiSparklingLine"
:loading="isGeneratingDescription"
@click="handleAutoCompleteDescription"
>
<template v-if="isGeneratingDescription">
{{ $t('Suggestion en cours...') }}
</template>
<template v-else>
{{ $t('Suggérer une description') }}
</template>
</BrandedButton>
<CdataLink
Comment thread
bolinocroustibat marked this conversation as resolved.
v-if="config.public.generateDescriptionFeedbackUrl && hasReceivedAiDescriptionSuggestion"
:to="config.public.generateDescriptionFeedbackUrl"
target="_blank"
class="text-sm text-gray-medium"
>
{{ $t('Comment avez-vous trouvé cette suggestion ?') }}
</CdataLink>
</div>
<SimpleBanner
v-if="descriptionGenerationErrorMessage"
type="danger"
class="mb-3"
>
{{ descriptionGenerationErrorMessage }}
</SimpleBanner>
</LinkedToAccordion>
<LinkedToAccordion
class="fr-fieldset__element"
Expand Down Expand Up @@ -550,14 +607,16 @@
</template>

<script setup lang="ts">
import { BrandedButton, SimpleBanner, TranslationT } from '@datagouv/components-next'
import { RiAddLine } from '@remixicon/vue'
import ModalClient from '../Modal/Modal.client.vue'
import { BrandedButton, SimpleBanner, Tooltip, TranslationT } from '@datagouv/components-next'
import { RiAddLine, RiSparklingLine } from '@remixicon/vue'
import { computed, nextTick } from 'vue'
import Accordion from '~/components/Accordion/Accordion.global.vue'
import AccordionGroup from '~/components/Accordion/AccordionGroup.global.vue'
import ToggleSwitch from '~/components/Form/ToggleSwitch.vue'
import CdataLink from '~/components/CdataLink.vue'
import ContactPointSelect from '~/components/ContactPointSelect.vue'
import ProducerSelect from '~/components/ProducerSelect.vue'
import ToggleSwitch from '~/components/Form/ToggleSwitch.vue'
import ModalClient from '../Modal/Modal.client.vue'
import type { DataserviceForm } from '~/types/types'

const props = defineProps<{
Expand All @@ -572,6 +631,31 @@ const emit = defineEmits<{

const { t } = useTranslation()

const DATASERVICE_DESCRIPTION_ERROR_GENERIC
= 'Une erreur s’est produite lors de la suggestion de description. Vérifiez que les liens de documentation sont valides, accessibles et adaptés, puis réessayez.'

function formatIntFrLocale(n: string): string {
return Number.parseInt(n, 10).toLocaleString('fr-FR')
}

function userFacingDataserviceDescriptionError(raw: string): string {
const bytesInParens = /\((\d+) bytes\)/
const charsInPrompt = /process \((\d+) characters, maximum (\d+)\)/
if (raw.includes('Documentation response exceeds maximum size')) {
const m = raw.match(bytesInParens)
if (m) {
return `La suggestion de description a échoué : la documentation téléchargée dépasse la taille maximale autorisée (${formatIntFrLocale(m[1])} octets). Réduisez le fichier ou utilisez une autre URL.`
}
}
if (raw.includes('The documentation is too long to process')) {
const m = raw.match(charsInPrompt)
if (m) {
return `La suggestion de description a échoué : la documentation est trop longue pour être traitée (${formatIntFrLocale(m[1])} caractères, maximum ${formatIntFrLocale(m[2])}). Utilisez une documentation plus courte ou une seule URL.`
}
}
return DATASERVICE_DESCRIPTION_ERROR_GENERIC
}

const formId = useId()

const config = useRuntimeConfig()
Expand All @@ -592,6 +676,18 @@ const contactPointAccordionId = useId()
const machineDocumentationUrlWarningMessage = t(`Il est fortement recommandé d'ajouter une documentation OpenAPI ou Swagger à votre API.`)
const openConfirmModal = ref(false)
const showRateLimitingUrl = ref(false)
const descriptionEditorRefreshKey = ref(0)
const isGeneratingDescription = ref(false)
const hasReceivedAiDescriptionSuggestion = ref(false)
const descriptionGenerationErrorMessage = ref<string | null>(null)

const hasTechnicalDocumentationUrl = computed(() => form.value.technical_documentation_url && form.value.technical_documentation_url.trim().length > 0)
const hasMachineDocumentationUrl = computed(() => form.value.machine_documentation_url && form.value.machine_documentation_url.trim().length > 0)
const hasTitle = computed(() => form.value.title && form.value.title.trim().length > 0)

const canGenerateDescription = computed(() => {
return hasTitle.value && (hasTechnicalDocumentationUrl.value || hasMachineDocumentationUrl.value)
})

const { form, touch, getFirstError, getFirstWarning, validate } = useForm(dataserviceForm, {
featured: [],
Expand Down Expand Up @@ -623,6 +719,62 @@ const accordionState = (key: keyof typeof form.value) => {
return 'default'
}

async function handleAutoCompleteDescription() {
const title = form.value.title?.trim()
const technicalUrl = form.value.technical_documentation_url?.trim()
const machineUrl = form.value.machine_documentation_url?.trim()

if (!title || (!technicalUrl && !machineUrl)) {
return
}

try {
isGeneratingDescription.value = true
descriptionGenerationErrorMessage.value = null

const requestBody: {
title: string
technicalDocumentationUrl?: string
machineDocumentationUrl?: string
} = {
title,
...(technicalUrl && { technicalDocumentationUrl: technicalUrl }),
...(machineUrl && { machineDocumentationUrl: machineUrl }),
}

// 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<{ description?: string }>('/nuxt-api/albert/generate-dataservice-description', {
method: 'POST',
body: requestBody,
})

if (response.description) {
form.value.description = response.description
if (form.value.description.trim().length > 0) {
hasReceivedAiDescriptionSuggestion.value = true
}
descriptionEditorRefreshKey.value += 1
await nextTick()
}
}
catch (error) {
console.error('Failed to generate description:', error)
const raw = error && typeof error === 'object' && 'data' in error && error.data && typeof error.data === 'object' && 'statusMessage' in error.data
? String((error.data as { statusMessage: string }).statusMessage)
: error instanceof Error
? error.message
: ''
descriptionGenerationErrorMessage.value = raw
? userFacingDataserviceDescriptionError(raw)
: DATASERVICE_DESCRIPTION_ERROR_GENERIC
}
finally {
isGeneratingDescription.value = false
}
}

async function submit() {
if (await validate()) {
if (dataserviceForm.value.machine_documentation_url || openConfirmModal.value) {
Expand Down
2 changes: 1 addition & 1 deletion nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export default defineNuxtConfig({
datasetRestrictedGuideUrl: 'https://guides.data.gouv.fr/guides/guide-juridique/producteurs-de-donnees/quelles-sont-les-obligations',
dataSearchFeedbackFormUrl: 'https://tally.so/r/mDKv1N',
forumUrl: 'https://forum.data.gouv.fr/',
generateShortDescriptionFeedbackUrl: 'https://tally.so/r/wbbRxo',
generateDescriptionFeedbackUrl: 'https://tally.so/r/wbbRxo',
generateTagsFeedbackUrl: 'https://tally.so/r/w80JNP',
publishingDatasetFeedbackUrl: 'https://tally.so/r/nGo0yO',
publishingDataserviceFeedbackUrl: 'https://tally.so/r/w2J7lL',
Expand Down
127 changes: 127 additions & 0 deletions server/routes/nuxt-api/albert/generate-dataservice-description.post.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { DESCRIPTION_MIN_LENGTH } from '~/datagouv-components/src/functions/description'
import { callAlbertAPI } from './utils/albert-helpers'
import { fetchDocumentationContent } from './utils/fetch-documentation'

/**
* Maximum total prompt length in characters (not tokens).
* Covers the full payload sent to the model: system message + user message, where the user
* message includes the prompt template and the formatted documentation content (technical
* and/or machine) fetched from the URLs. openweight-small/medium: 128k tokens,
* openweight-large: 256k tokens; 120k chars ≈ 30k tokens leaves ample headroom. Large API
* specs may still hit this limit and trigger a "documentation too long" error.
*/
const MAXIMUM_PROMPT_LENGTH = 120_000

export default defineEventHandler(async (event) => {
const body = await readBody(event)
const { title, technicalDocumentationUrl, machineDocumentationUrl } = body

if (!title?.trim()) {
throw createError({
statusCode: 400,
statusMessage: 'Title is required',
})
}

const technicalUrl = technicalDocumentationUrl?.trim() || ''
const machineUrl = machineDocumentationUrl?.trim() || ''
if (!technicalUrl && !machineUrl) {
throw createError({
statusCode: 400,
statusMessage: 'Either technical documentation URL or machine documentation URL is required',
})
}

const systemContent = `You are an assistant integrated into data.gouv.fr, the French open data platform.
Your purpose is to help API producers write clear, comprehensive, and factual descriptions of APIs.

Guidelines:
- Always respond in French.
- Your tone is factual, neutral, and accessible to non-experts.
- Use plain language and clear sentences, avoiding unnecessary technical jargon.
- Do not make assumptions or add information that is not present in the input.
- Focus on what the API does, what data it provides, and how it can be used.
- Always start with a capital letter and end with a period.
- The goal is to produce informative descriptions that help users understand the API's purpose and capabilities.
- IMPORTANT: Return ONLY the description text, without quotes or additional punctuation.`

async function fetchOrFail(url: string, label: string): Promise<string> {
if (!url) return ''
try {
return await fetchDocumentationContent(url)
}
catch (err) {
throw createError({
statusCode: 422,
statusMessage: err instanceof Error ? err.message : `Failed to load ${label} documentation URL.`,
})
}
}

const [technicalContent, machineContent] = await Promise.all([
fetchOrFail(technicalUrl, 'technical'),
fetchOrFail(machineUrl, 'machine'),
])

if (!technicalContent && !machineContent) {
throw createError({
statusCode: 422,
statusMessage: 'The documentation URLs returned no usable content. Please check that the links point to readable API documentation (e.g. OpenAPI/Swagger or a technical doc page).',
})
}

const documentationParts: string[] = []
if (technicalContent) {
documentationParts.push(`Technical documentation content:\n\n${technicalContent}`)
}
if (machineContent) {
documentationParts.push(`Machine documentation content (OpenAPI/Swagger):\n\n${machineContent}`)
}

const userContent = `You are asked to generate a description for an API on data.gouv.fr.

Goal:
→ Write a comprehensive and accessible description of the API.
→ Focus on what the API does, what data it provides, and its main capabilities.
→ Mention key endpoints, data types, and use cases if available.
→ Explain the API's purpose and how it can be used.

Here is the API information:

Title: ${title.trim()}

${documentationParts.join('\n\n---\n\n')}

Output:
→ A comprehensive description in French (no markdown, no introduction, no labels, no emojis).
→ The description should be detailed enough to help users understand the API's purpose and capabilities.
→ Minimum length: at least ${DESCRIPTION_MIN_LENGTH} characters.`

const totalLength = systemContent.length + userContent.length
if (totalLength > MAXIMUM_PROMPT_LENGTH) {
throw createError({
statusCode: 422,
statusMessage: `The documentation is too long to process (${totalLength} characters, maximum ${MAXIMUM_PROMPT_LENGTH}). Please use shorter documentation or provide a single, smaller documentation URL.`,
})
}

const messages = [
{ role: 'system', content: systemContent },
{ role: 'user', content: userContent },
]

// Models available for text generation:
// - openweight-small (replaces albert-small): 128k tokens
// - openweight-medium (replaces albert-large): 128k tokens
// - openweight-large: 256k tokens
const generatedDescription = (await callAlbertAPI(messages, 'openweight-small')).trim()

if (generatedDescription.length < DESCRIPTION_MIN_LENGTH) {
throw createError({
statusCode: 422,
statusMessage: 'The model could not generate a sufficient description. Please try again or provide more detailed documentation.',
})
}

return { description: generatedDescription }
})
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DESCRIPTION_SHORT_MAX_LENGTH } from '~/datagouv-components/src/functions/description'
import { validateAlbertConfig, callAlbertAPI } from './utils/albert-helpers'
import { callAlbertAPI } from './utils/albert-helpers'

export default defineEventHandler(async (event) => {
const body = await readBody(event)
Expand All @@ -12,8 +12,6 @@ export default defineEventHandler(async (event) => {
})
}

validateAlbertConfig()

const messages = [
{
role: 'system',
Expand Down
4 changes: 1 addition & 3 deletions server/routes/nuxt-api/albert/generate-dataset-tags.post.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { validateAlbertConfig, callAlbertAPI, parseTags } from './utils/albert-helpers'
import { callAlbertAPI, parseTags } from './utils/albert-helpers'

export default defineEventHandler(async (event) => {
const body = await readBody(event)
Expand All @@ -11,8 +11,6 @@ export default defineEventHandler(async (event) => {
})
}

validateAlbertConfig()

const messages = [
{
role: 'system',
Expand Down
4 changes: 1 addition & 3 deletions server/routes/nuxt-api/albert/generate-reuse-tags.post.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { validateAlbertConfig, callAlbertAPI, parseTags } from './utils/albert-helpers'
import { callAlbertAPI, parseTags } from './utils/albert-helpers'

export default defineEventHandler(async (event) => {
const body = await readBody(event)
Expand All @@ -11,8 +11,6 @@ export default defineEventHandler(async (event) => {
})
}

validateAlbertConfig()

const messages = [
{
role: 'system',
Expand Down
Loading
Loading