Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 13 additions & 0 deletions designer/client/src/javascripts/preview/lib/llm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export async function postShortDescription(title) {
const payload = JSON.stringify({ title })

const response = await window.fetch(
'http://localhost:3000/api/ai/short_description',
{
body: payload,
method: 'POST',
headers: { 'Content-Type': 'application/json' }
}
)
return response.json()
}
32 changes: 32 additions & 0 deletions designer/client/src/javascripts/preview/question.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DomElements } from '~/src/javascripts/preview/dom-elements.js'
import { postShortDescription } from '~/src/javascripts/preview/lib/llm.js'

/**
* @class QuestionDomElements
Expand All @@ -25,6 +26,9 @@ export class QuestionDomElements extends DomElements {
const shortDescEl = /** @type {HTMLInputElement | null} */ (
document.getElementById('shortDescription')
)
const generateEl = /** @type {HTMLInputElement | null} */ (
document.getElementById('generate')
)

/**
* @type {HTMLInputElement|null}
Expand All @@ -42,6 +46,7 @@ export class QuestionDomElements extends DomElements {
* @type {HTMLInputElement|null}
*/
this.shortDesc = shortDescEl
this.generate = generateEl
}

/**
Expand Down Expand Up @@ -160,6 +165,32 @@ export class EventListeners {
'input'
])

const shortDescriptionText = /** @type {ListenerRow} */ ([
this.baseElements.generate,
/**
* @param {HTMLInputElement} target
* @param {Event} e
*/
(target, e) => {
e.preventDefault()
const question = this.baseElements.question?.value ?? ''
postShortDescription(question).then(
({ shortDescription }) => {
const shortDesc = this.baseElements.shortDesc
if (shortDesc) {
shortDesc.value = shortDescription
const event = new InputEvent('input', { bubbles: true })
shortDesc.dispatchEvent(event)
}
}
).catch(e => {
// eslint-disable-next-line no-console -- POC
console.error('Generate short description failed', e)
})
},
'click'
])

const hintText = /** @type {ListenerRow} */ ([
this.baseElements.hintText,
/**
Expand All @@ -185,6 +216,7 @@ export class EventListeners {
questionText,
hintText,
optionalCheckbox,
shortDescriptionText,
...this.highlightListeners,
...this._customListeners
]
Expand Down
27 changes: 27 additions & 0 deletions designer/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ export interface Config {
tracing: {
header: string
}
ai: {
llmEndpoint: string
anthropic: {
endpoint: string
key: string
version: string
maxTokens: number
}
}
}

// Define config schema
Expand Down Expand Up @@ -156,6 +165,16 @@ const schema = joi.object<Config>({
roleEditorGroupId: joi.string().required(),
tracing: joi.object({
header: joi.string().default('x-cdp-request-id')
}),
ai: joi.object({
llmEndpoint: joi.string().required(),
anthropic: joi.object({
maxTokens: joi.number().default(1024),
model: joi.string().default(''),
endpoint: joi.string().default('https://api.anthropic.com'),
key: joi.string().required(),
version: joi.string().default('2023-06-01')
})
})
})

Expand Down Expand Up @@ -195,6 +214,14 @@ const result = schema.validate(
roleEditorGroupId: process.env.ROLE_EDITOR_GROUP_ID,
tracing: {
header: process.env.TRACING_HEADER
},
ai: {
llmEndpoint: process.env.AI_LLM_ENDPOINT,
anthropic: {
endpoint: process.env.AI_ANTHROPIC_ENDPOINT,
key: process.env.AI_ANTHROPIC_KEY,
version: process.env.AI_ANTHROPIC_VERSION
}
}
},
{ abortEarly: false }
Expand Down
17 changes: 17 additions & 0 deletions designer/server/src/lib/llm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import config from '~/src/config.js'
import { postJson } from '~/src/lib/fetch.js'

const llmEndpoint = new URL('/api/short_description', config.ai.llmEndpoint)

export async function getShortDescription(title) {
const postJsonByType =
/** @type { typeof postJson<{ short_description: string }>} */ (postJson)

const { body } = await postJsonByType(llmEndpoint, {
payload: {
title
}
})

return body
}
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@ export const allBaseSettingsFields = {
},
hint: {
text: "Enter a short description for this question like 'Licence period'. Short descriptions are used in error messages and on the check your answers page."
}
},
customTemplate: 'short-description'
},
fileTypes: {
id: 'fileTypes',
Expand Down
43 changes: 43 additions & 0 deletions designer/server/src/routes/ai/api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { StatusCodes } from 'http-status-codes'

import * as scopes from '~/src/common/constants/scopes.js'
import { anthropicGetShortDescription } from '~/src/service/langchain.js'
export default [
/**
* @satisfies {ServerRoute<{ Params: FormByIdInput }>}
*/
({
method: 'POST',
path: '/api/ai/short_description',
options: {
async handler(request, h) {
const { title } = request.payload

try {
// const { short_description: shortDescription } =
// await getShortDescription(title)
// const shortDescription = await langchainJS(title)
const shortDescription = await anthropicGetShortDescription(title)

return h.response({ shortDescription }).code(StatusCodes.OK)
} catch (e) {
console.error(e)
throw e
}
},
auth: {
mode: 'required',
access: {
entity: 'user',
scope: [`+${scopes.SCOPE_WRITE}`]
}
}
}
})
]

/**
* @import { FormByIdInput, FormDefinition } from '@defra/forms-model'
* @import { ServerRoute } from '@hapi/hapi'
* @import { Level, SerializedError } from 'pino'
*/
3 changes: 3 additions & 0 deletions designer/server/src/routes/ai/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import ai from '~/src/routes/ai/api.js'

export default [ai]
3 changes: 2 additions & 1 deletion designer/server/src/routes/index.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import account from '~/src/routes/account/index.js'
import ai from '~/src/routes/ai/api.js'
import assets from '~/src/routes/assets.js'
import file from '~/src/routes/file/file.js'
import forms from '~/src/routes/forms/index.js'
import health from '~/src/routes/health.js'
import help from '~/src/routes/help.js'
import home from '~/src/routes/home.js'

export default [account, assets, forms, health, home, help, file].flat()
export default [account, assets, forms, health, home, help, file, ai].flat()
75 changes: 75 additions & 0 deletions designer/server/src/service/langchain.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import Anthropic from '@anthropic-ai/sdk'
import { ChatAnthropic } from '@langchain/anthropic'
import { StringOutputParser } from '@langchain/core/output_parsers'
import { ChatPromptTemplate } from '@langchain/core/prompts'
import { RunnableSequence } from '@langchain/core/runnables'

import config from '~/src/config.js'

const MODEL = 'claude-haiku-4-5-20251001'

const SYSTEM_TEMPLATE = `Answer the user as if you were an expert GDS copywriter.
Reply with a concise declarative phrase describing the form field heading they have entered.
If the user enters a declarative phrase or word, return the phrase or word verbatim.
Do not wrap the answer in quotations or apostrophes.
Do not end the answer in a full stop.
Replace 'Do you...' with 'Whether you...'.
Questions should be reorganised into a declarative phrase, for example ‘What is your name?’ -> ‘Your name’, ‘Age’ -> ‘Age’.
Declarative phrases should be left untouched, for example ‘Deadline date’ -> ‘Deadline date’.
If the user includes ‘your’ in the input, include ‘your’ in the output.
If the user doesn't include 'your' in the input, don't include 'your' in the output.
Remove any acronyms or brackets.
`

const promptTemplate = ChatPromptTemplate.fromMessages([
['system', SYSTEM_TEMPLATE],
['user', '{text}']
])

const model = new ChatAnthropic({
apiKey: config.ai.anthropic.key,
model: MODEL,
maxTokens: config.ai.anthropic.maxTokens,
temperature: 0.2
})

const chain = RunnableSequence.from([
promptTemplate,
model,
new StringOutputParser()
])

/**
* @param {string} title
* @returns {Promise<string>}
*/
export async function langchainJS(title) {
return await chain.invoke({ text: title })
}

const client = new Anthropic({
apiKey: config.ai.anthropic.key
})

const cachedAnthropicCall = {
model: MODEL,
max_tokens: config.ai.anthropic.maxTokens,
system: [
{
type: 'text',
text: SYSTEM_TEMPLATE,
cache_control: { type: 'ephemeral' }
}
],
messages: []
}

export async function anthropicGetShortDescription(title) {
const response = await client.messages.create({
...cachedAnthropicCall,
messages: [{ role: 'user', content: title }]
})
const [{ text }] = response.content

return text
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<div class="govuk-form-group">
<label class="govuk-label govuk-label--m" for="shortDescription">
Short description
</label>
<div id="shortDescription-hint" class="govuk-hint">
Enter a short description for this question like 'Licence period'. Short descriptions are used in error messages and on the check your answers page.
</div>
<input id="shortDescription" class="govuk-input" style="width:calc(100% - 135px)" type="text" name="shortDescription" aria-describedby="shortDescription-hint">
<button id="generate" style="height: 38px;" class="govuk-button govuk-button--inverse" data-module="govuk-button">
<svg width="24" height="24" viewBox="0 0 20 24" fill="none" aria-hidden="true" focusable="false" style="vertical-align: middle; margin-right: 2px; margin-top: -4px;">
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.333 13.144a12.884 12.884 0 0 1 .927-.235l.209-.044a.5.5 0 0 0 0-1 14.663 14.663 0 0 1-1.138-.276c-2.8-.815-4.275-2.382-5.082-5.113a13.224 13.224 0 0 1-.103-.37l-.084-.331a16.311 16.311 0 0 1-.12-.539.493.493 0 0 0-.473-.371.493.493 0 0 0-.475.376 14.724 14.724 0 0 1-.366 1.45l-.002.006c-.832 2.648-2.348 4.104-5.021 4.888a12.601 12.601 0 0 1-.927.235l-.21.045a.5.5 0 0 0 0 1 14.723 14.723 0 0 1 1.14.275c2.809.813 4.278 2.362 5.082 5.105l.083.299.02.077c.019-.016.06.332.08.317.023.098.046.198.068.3l.009.04.036.174a.493.493 0 0 0 .483.413.493.493 0 0 0 .477-.387 16.114 16.114 0 0 1 .196-.859 12.797 12.797 0 0 1 .17-.593l.002-.005c.832-2.645 2.346-4.094 5.019-4.877Zm-5.864 2.934a7.982 7.982 0 0 1 1.51-2.162 7.922 7.922 0 0 1 2.193-1.55 7.877 7.877 0 0 1-2.246-1.612 8.07 8.07 0 0 1-1.454-2.115 8.007 8.007 0 0 1-1.511 2.168A7.927 7.927 0 0 1 8.77 12.36a7.841 7.841 0 0 1 2.233 1.592 7.99 7.99 0 0 1 1.466 2.127Zm4.293-10.43a.153.153 0 0 0-.153.152h.004c0 .084.062.14.145.156l.023.006.032.007.008.003a3.2 3.2 0 0 1 1.104.508 2.487 2.487 0 0 1 .284.246l.003.002A2.521 2.521 0 0 1 18.872 8a4.606 4.606 0 0 1 .015.076.153.153 0 0 0 .152.154v-.004c.084 0 .14-.062.156-.145l.006-.023.008-.032c0-.002 0-.005.002-.008.113-.432.284-.801.508-1.104a2.479 2.479 0 0 1 .425-.444 2.521 2.521 0 0 1 1.172-.517.15.15 0 0 0 .15-.153c0-.084-.063-.14-.145-.156l-.024-.006-.032-.007-.008-.003a3.197 3.197 0 0 1-1.105-.509 2.5 2.5 0 0 1-.283-.245l-.002-.002a2.36 2.36 0 0 1-.158-.177 2.521 2.521 0 0 1-.517-1.172.153.153 0 0 0-.153-.153v.004c-.084 0-.14.062-.156.144l-.006.024-.007.032-.003.008a3.197 3.197 0 0 1-.508 1.104 2.48 2.48 0 0 1-.246.284l-.002.002a2.362 2.362 0 0 1-.177.158 2.52 2.52 0 0 1-1.115.506 3.248 3.248 0 0 1-.057.011ZM6.848 19.66a2.074 2.074 0 0 1 .93-.478 3.17 3.17 0 0 1 .14-.032.218.218 0 0 0 0-.436 5.028 5.028 0 0 1-.092-.028c-.386-.124-.706-.277-.963-.482a1.9 1.9 0 0 1-.225-.21 2.073 2.073 0 0 1-.47-.921 3.1 3.1 0 0 1-.031-.14.218.218 0 1 0-.436 0 5.43 5.43 0 0 1-.029.092c-.123.386-.277.706-.481.963a1.983 1.983 0 0 1-.21.225c-.242.22-.542.376-.922.47a3.036 3.036 0 0 1-.14.031.218.218 0 0 0 0 .436l.093.03c.385.122.705.276.962.48a2.105 2.105 0 0 1 .217.202l.008.009c.22.241.376.541.47.921a3.1 3.1 0 0 1 .032.14.218.218 0 0 0 .436 0l.028-.093c.124-.385.277-.705.482-.962a1.9 1.9 0 0 1 .201-.217Z" fill="currentColor"></path><path d="M9.6 5.613C7.91 5.466 6.98 4.874 6.484 3.7c-.179-.423-.304-.917-.384-1.5 0-.1-.1-.2-.2-.2s-.2.1-.2.2c-.08.583-.205 1.077-.384 1.5C4.821 4.874 3.891 5.466 2.2 5.613c-.1 0-.2.1-.2.2s.1.2.2.2c2.1.4 3.2 1.187 3.5 3.387 0 .1.1.2.2.2s.2-.1.2-.2c.3-2.2 1.4-2.987 3.5-3.387.1 0 .2-.1.2-.2s-.1-.2-.2-.2Z" fill="currentColor"></path>
</svg>
Generate
</button>
</div>
9 changes: 9 additions & 0 deletions model/src/form/form-editor/preview/question.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@ export class Question extends PreviewComponent {
this._hintText = value
this.render()
}

set shortDescription(value) {
this._shortDescription = value
this.render()
}

get shortDescription() {
return this._shortDescription
}
}

/**
Expand Down
Loading