Skip to content

Commit 688b38e

Browse files
feat: suggest dataservice description
1 parent 911ffc5 commit 688b38e

File tree

3 files changed

+214
-5
lines changed

3 files changed

+214
-5
lines changed

components/Dataservices/DescribeDataservice.vue

Lines changed: 108 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,49 @@
270270
>
271271
{{ getFirstWarning("description") }}
272272
</SimpleBanner>
273+
<div class="flex items-center gap-4 mt-2 mb-3">
274+
<Tooltip v-if="!canGenerateDescription">
275+
<BrandedButton
276+
type="button"
277+
color="primary"
278+
:disabled="true"
279+
>
280+
<div class="flex items-center space-x-2">
281+
<RiSparklingLine
282+
class="size-4"
283+
aria-hidden="true"
284+
/>
285+
<span>{{ $t('Suggérer une description') }}</span>
286+
</div>
287+
</BrandedButton>
288+
<template #tooltip>
289+
{{ $t('Remplissez le champ "Lien vers la documentation technique de l\'API" pour utiliser cette fonctionnalité.') }}
290+
</template>
291+
</Tooltip>
292+
<BrandedButton
293+
v-else
294+
type="button"
295+
color="primary"
296+
:icon="RiSparklingLine"
297+
:loading="isGeneratingDescription"
298+
@click="handleAutoCompleteDescription"
299+
>
300+
<template v-if="isGeneratingDescription">
301+
{{ $t('Suggestion en cours...') }}
302+
</template>
303+
<template v-else>
304+
{{ $t('Suggérer une description') }}
305+
</template>
306+
</BrandedButton>
307+
<CdataLink
308+
v-if="config.public.generateDescriptionFeedbackUrl"
309+
:to="config.public.generateDescriptionFeedbackUrl"
310+
target="_blank"
311+
class="text-sm text-gray-medium"
312+
>
313+
{{ $t('Comment avez-vous trouvé cette suggestion ?') }}
314+
</CdataLink>
315+
</div>
273316
</LinkedToAccordion>
274317
<LinkedToAccordion
275318
class="fr-fieldset__element"
@@ -506,15 +549,16 @@
506549
</template>
507550

508551
<script setup lang="ts">
509-
import { BrandedButton, SimpleBanner, TranslationT, type Owned } from '@datagouv/components-next'
510-
import { RiAddLine } from '@remixicon/vue'
511-
import { computed } from 'vue'
512-
import ModalClient from '../Modal/Modal.client.vue'
552+
import { BrandedButton, SimpleBanner, Tooltip, TranslationT, type Owned } from '@datagouv/components-next'
553+
import { RiAddLine, RiSparklingLine } from '@remixicon/vue'
554+
import { computed, nextTick } from 'vue'
513555
import Accordion from '~/components/Accordion/Accordion.global.vue'
514556
import AccordionGroup from '~/components/Accordion/AccordionGroup.global.vue'
515-
import ToggleSwitch from '~/components/Form/ToggleSwitch.vue'
557+
import CdataLink from '~/components/CdataLink.vue'
516558
import ContactPointSelect from '~/components/ContactPointSelect.vue'
517559
import ProducerSelect from '~/components/ProducerSelect.vue'
560+
import ToggleSwitch from '~/components/Form/ToggleSwitch.vue'
561+
import ModalClient from '../Modal/Modal.client.vue'
518562
import type { DataserviceForm } from '~/types/types'
519563
520564
const props = defineProps<{
@@ -554,6 +598,15 @@ const ownedOptions = computed<Array<Owned>>(() => {
554598
const machineDocumentationUrlWarningMessage = t(`Il est fortement recommandé d'ajouter une documentation OpenAPI ou Swagger à votre API.`)
555599
const openConfirmModal = ref(false)
556600
601+
// Track description generation state
602+
const isGeneratingDescription = ref(false)
603+
604+
const hasTechnicalDocumentationUrl = computed(() => form.value.technical_documentation_url && form.value.technical_documentation_url.trim().length > 0)
605+
606+
const canGenerateDescription = computed(() => {
607+
return hasTechnicalDocumentationUrl.value
608+
})
609+
557610
const { form, touch, getFirstError, getFirstWarning, validate } = useForm(dataserviceForm, {
558611
featured: [],
559612
owned: [required()],
@@ -582,6 +635,56 @@ const accordionState = (key: keyof typeof form.value) => {
582635
return 'default'
583636
}
584637
638+
async function handleAutoCompleteDescription() {
639+
if (!form.value.technical_documentation_url) {
640+
return
641+
}
642+
643+
try {
644+
isGeneratingDescription.value = true
645+
646+
const requestBody: {
647+
technicalDocumentationUrl: string
648+
machineDocumentationUrl?: string
649+
title?: string
650+
} = {
651+
technicalDocumentationUrl: form.value.technical_documentation_url,
652+
}
653+
654+
// Include machine documentation URL only if it's provided
655+
if (form.value.machine_documentation_url && form.value.machine_documentation_url.trim().length > 0) {
656+
requestBody.machineDocumentationUrl = form.value.machine_documentation_url
657+
}
658+
659+
// Include title only if it's provided
660+
if (form.value.title && form.value.title.trim().length > 0) {
661+
requestBody.title = form.value.title
662+
}
663+
664+
// We call our server-side API route instead of Albert API directly to avoid CORS issues.
665+
// The Albert API doesn't allow direct requests from browser-side JavaScript.
666+
// Our server acts as a proxy, keeping the API key secure on the server side.
667+
const response = await $fetch<{ description?: string }>('/nuxt-api/albert/generate-dataservice-description', {
668+
method: 'POST',
669+
body: requestBody,
670+
})
671+
672+
if (response.description) {
673+
form.value.description = response.description
674+
await nextTick()
675+
}
676+
}
677+
catch (error) {
678+
console.error('[Albert API] Failed to generate description:', error)
679+
if (error && typeof error === 'object' && 'data' in error) {
680+
console.error('[Albert API] Error details:', error.data)
681+
}
682+
}
683+
finally {
684+
isGeneratingDescription.value = false
685+
}
686+
}
687+
585688
async function submit() {
586689
if (await validate()) {
587690
if (dataserviceForm.value.machine_documentation_url || openConfirmModal.value) {

components/MarkdownEditor/InternalEditor.vue

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ import {
123123
insertImageCommand,
124124
linkAttr,
125125
paragraphAttr,
126+
replaceAllCommand,
126127
toggleEmphasisCommand,
127128
toggleStrongCommand,
128129
wrapInBlockquoteCommand,
@@ -140,6 +141,7 @@ import { RiArrowGoBackLine, RiArrowGoForwardLine, RiBold, RiCodeSSlashLine, RiDo
140141
import { Milkdown, useEditor } from '@milkdown/vue'
141142
import { usePluginViewFactory, useWidgetViewFactory } from '@prosemirror-adapter/vue'
142143
import { useDebounceFn } from '@vueuse/core'
144+
import { watch } from 'vue'
143145
import { clipboard } from '@milkdown/kit/plugin/clipboard'
144146
import type { ImageModalForm } from '~/components/MarkdownEditor/ImageModal/ImageModalButton.vue'
145147
import ImageModalButton from '~/components/MarkdownEditor/ImageModal/ImageModalButton.vue'
@@ -249,4 +251,20 @@ const editor = useEditor(root =>
249251
function call<T>(command: CmdKey<T>, payload?: T) {
250252
return editor.get()?.action(callCommand(command, payload))
251253
}
254+
255+
// Watch for external value changes and update the editor
256+
watch(() => props.value, (newValue, oldValue) => {
257+
if (newValue !== oldValue && editor.get()) {
258+
const editorInstance = editor.get()
259+
if (editorInstance) {
260+
// Only update if the value actually changed and is different from current content
261+
try {
262+
editorInstance.action(callCommand(replaceAllCommand.key, newValue || ''))
263+
}
264+
catch (error) {
265+
console.warn('[MarkdownEditor] Failed to update editor content:', error)
266+
}
267+
}
268+
}
269+
}, { immediate: false })
252270
</script>
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { createChatCompletion, useAlbertConfig, type ChatResponse } from '~/server/utils/albert-api-client'
2+
3+
export default defineEventHandler(async (event) => {
4+
const body = await readBody(event)
5+
const { title, technicalDocumentationUrl, machineDocumentationUrl } = body
6+
7+
if (!technicalDocumentationUrl) {
8+
console.error('[Albert API] Missing required field: technicalDocumentationUrl')
9+
throw createError({
10+
statusCode: 400,
11+
statusMessage: 'Technical documentation URL is required',
12+
})
13+
}
14+
15+
const runtimeConfig = useRuntimeConfig()
16+
17+
if (!runtimeConfig.albertApiKey) {
18+
console.error('[Albert API] API key not configured')
19+
throw createError({
20+
statusCode: 400,
21+
statusMessage: 'Albert API is not configured',
22+
})
23+
}
24+
25+
try {
26+
const albertConfig = useAlbertConfig()
27+
28+
const messages = [
29+
{
30+
role: 'system',
31+
content: `You are an assistant integrated into data.gouv.fr, the French open data platform.\n`
32+
+ `Your purpose is to help API producers write clear, comprehensive, and factual descriptions of APIs.\n`
33+
+ `\n`
34+
+ `Guidelines:\n`
35+
+ `- Always respond in French.\n`
36+
+ `- Your tone is factual, neutral, and accessible to non-experts.\n`
37+
+ `- Use plain language and clear sentences, avoiding unnecessary technical jargon.\n`
38+
+ `- Do not make assumptions or add information that is not present in the input.\n`
39+
+ `- Focus on what the API does, what data it provides, and how it can be used.\n`
40+
+ `- Always start with a capital letter and end with a period.\n`
41+
+ `- The goal is to produce informative descriptions that help users understand the API's purpose and capabilities.\n`
42+
+ `- IMPORTANT: Return ONLY the description text, without quotes or additional punctuation.`,
43+
},
44+
{
45+
role: 'user',
46+
content: `You are asked to generate a description for an API on data.gouv.fr.\n`
47+
+ `\n`
48+
+ `Goal:\n`
49+
+ `→ Write a comprehensive and accessible description of the API.\n`
50+
+ `→ Focus on what the API does, what data it provides, and its main capabilities.\n`
51+
+ `→ Mention key endpoints, data types, and use cases if available.\n`
52+
+ `→ Explain the API's purpose and how it can be used.\n`
53+
+ `\n`
54+
+ `Here is the API information:\n`
55+
+ (title ? `Title: ${title}\n` : '')
56+
+ `Technical documentation URL: ${technicalDocumentationUrl}\n`
57+
+ (machineDocumentationUrl ? `Machine documentation URL (OpenAPI/Swagger): ${machineDocumentationUrl}\n` : '')
58+
+ `\n`
59+
+ `Output:\n`
60+
+ `→ A comprehensive description in French (no markdown, no introduction, no labels, no emojis).\n`
61+
+ `→ The description should be detailed enough to help users understand the API's purpose and capabilities.`,
62+
},
63+
]
64+
65+
// Models available for text generation:
66+
// - openweight-small (replaces albert-small)
67+
// - openweight-medium (replaces albert-large)
68+
// - openweight-large
69+
const response = await createChatCompletion(messages, 'openweight-small', albertConfig) as ChatResponse
70+
const generatedDescription = response.choices?.[0]?.message?.content || ''
71+
72+
return { description: generatedDescription }
73+
}
74+
catch (error) {
75+
console.error('[Albert API] Error calling Albert API:', error)
76+
if (error && typeof error === 'object') {
77+
console.error('[Albert API] Error details:', {
78+
message: (error as Error).message,
79+
stack: (error as Error).stack,
80+
...error,
81+
})
82+
}
83+
throw createError({
84+
statusCode: 500,
85+
statusMessage: (error as Error).message || 'Failed to call Albert API',
86+
})
87+
}
88+
})

0 commit comments

Comments
 (0)