From 0c50c492a99c96075b5564c2b3fe42eb8d22ecf0 Mon Sep 17 00:00:00 2001 From: sigurdgroneng Date: Thu, 6 Mar 2025 12:43:05 +0100 Subject: [PATCH 1/7] Legg til --- src/api/tryggTekstAPI.ts | 156 ++++++++++++++++++ src/mocks/data/tryggtekst.ts | 25 +++ src/mocks/handlers.ts | 4 + .../InnerSamtalereferatForm.tsx | 2 + .../aktivitet-forms/tryggtekst/TryggTekst.tsx | 55 ++++++ .../tryggtekst/tryggtekst-selector.ts | 11 ++ .../tryggtekst/tryggtekst-slice.ts | 19 +++ .../visning/referat/OppdaterReferatForm.tsx | 4 +- src/reducer.ts | 2 + 9 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 src/api/tryggTekstAPI.ts create mode 100644 src/mocks/data/tryggtekst.ts create mode 100644 src/moduler/aktivitet/aktivitet-forms/tryggtekst/TryggTekst.tsx create mode 100644 src/moduler/aktivitet/aktivitet-forms/tryggtekst/tryggtekst-selector.ts create mode 100644 src/moduler/aktivitet/aktivitet-forms/tryggtekst/tryggtekst-slice.ts diff --git a/src/api/tryggTekstAPI.ts b/src/api/tryggTekstAPI.ts new file mode 100644 index 000000000..0548326f0 --- /dev/null +++ b/src/api/tryggTekstAPI.ts @@ -0,0 +1,156 @@ +import { Dispatch, SetStateAction } from 'react'; + +type UseSensitive = ( + verdi: string | null | undefined, + feilmelding?: string, +) => [string | undefined, Dispatch>, () => boolean]; + +export type LLMResponse = { + content: string; +}; + +interface LLMContent { + grunn: string; + kategori: string; +} + +interface IsSensitive { + kategori: string; + sensitiv: boolean; + feilmedling: string | null; +} + +const baseUrl = 'http://34.34.85.30:8007'; + +async function postRequest(inp: string): Promise { + const data = { + stream: false, + n_predict: 2048, + temperature: 0, + dry_allowed_length: 10, + dry_base: 3, + dry_multiplier: 5, + dry_penalty_last_n: 50, + stop: ['', 'user:', 'assistant:'], + penalize_nl: false, + repeat_last_n: 256, + repeat_penalty: 1, + top_k: 1, + top_p: 1, + min_p: 0, + tfs_z: 1, + min_keep: 0, + typical_p: 1, + presence_penalty: 0, + frequency_penalty: 0, + mirostat: 0, + mirostat_tau: 5, + mirostat_eta: 0.1, + grammar: + 'root ::= "{" space kategori-kv space "}" space nl\n' + + '\n' + + 'kategori-kv ::= "\\"kategorier\\"" space ":" space "[" space kategori-list space "]" space\n' + + '\n' + + 'kategori-list ::= kategori-multiple\n' + + '\n' + + 'kategori-ingen ::= "\\"ingen\\"" space\n' + + '\n' + + 'kategori-multiple ::= kategori-obj ("," space kategori-obj)*\n' + + '\n' + + 'kategori-obj ::= "{" space "\\"kategori\\"" space ":" space kategori-gyldig space "," space "\\"grunn\\"" space ":" space json-string space "}" space\n' + + '\n' + + 'kategori-gyldig ::= ("\\"etnisk opprinnelse\\""\n' + + ' | "\\"politisk oppfatning\\""\n' + + ' | "\\"religion\\""\n' + + ' | "\\"filosofisk overbevisning\\""\n' + + ' | "\\"fagforeningsmedlemskap\\""\n' + + ' | "\\"genetiske opplysninger\\""\n' + + ' | "\\"biometriske opplysninger\\""\n' + + ' | "\\"helseopplysninger\\""\n' + + ' | "\\"seksuelle forhold\\""\n' + + ' | "\\"seksuell legning\\"") space\n' + + '\n' + + 'json-string ::= "\\"" str-characters "\\"" space\n' + + 'str-characters ::= | str-character str-characters\n' + + 'str-character ::= [^"\\\\] | "\\\\" escape\n' + + 'escape ::= ["\\\\/bfnrt] | "u" hex hex hex hex\n' + + '\n' + + 'hex ::= [0-9A-Fa-f]\n' + + '\n' + + 'space ::= | " " {0,1}\n' + + 'nl ::= | "\\n" [ \\t]{0,1}\n', + n_probs: 9, + image_data: [], + cache_prompt: false, + api_key: '', + slot_id: 0, + prompt: `<|start_header_id|>system<|end_header_id|>\\n\\nDu er en klassifiseringsbot. Du skal vurdere om teksten inneholder særlige kategorier av personopplysninger, og hvis ja hvilke opplysningstype som best beskriver innholdet i teksten. Velg blant følgende 11 kategorier av opplysninger: +* politisk oppfatning +* religion +* etnisk opprinnelse +* filosofisk overbevisning +* fagforeningsmedlemskap +* genetiske opplysninger +* biometriske opplysninger +* helseopplysninger +* seksuelle forhold +* seksuell legning +* ingen særlige kategorier av personopplysninger +kan innholde flere kategorier eller ingen. Returnere med kategori og settningen som triggret kategorien uten å endre den eller sammenfatte den.<|start_header_id|>user<|end_header_id|> +${inp}\\n<|eot_id|>assistant`, + }; + return await fetch(`/tryggtekst/completion`, { + method: 'POST', + body: JSON.stringify(data), + headers: { Pragma: 'no-cache', 'Cache-Control': 'no-cache', 'Content-Type': 'application/json' }, + }) + .then((res) => { + if (res.ok) { + return res.json(); + } else { + throw new Error('Network response was not ok'); + } + }) + .then((r) => { + console.log(r.data); + return r.data; + }) + .catch((e) => { + console.log(e); + return e; + }); +} + +const postSjekkForPersonopplysninger = async (verdi: string) => { + let feil = ''; + let sensitiv = false; + let kategorier: { kategori: string; grunn: string }[] = []; + console.log('useSensitive', verdi); + + if (!verdi) { + console.log('ingen verdi', verdi); + return { kategorier: [], sensitiv: sensitiv, feilmedling: feil }; + } else { + console.log('verdi som ska til llm', verdi); + const b = await postRequest(verdi).then(async (c: LLMResponse) => { + const containsSensitive = JSON.parse(c.content); + console.log('containsSensitive', containsSensitive); + + if (containsSensitive.kategorier && containsSensitive.kategorier.length > 0) { + kategorier = containsSensitive.kategorier.map((item: LLMContent) => ({ + kategori: item.kategori, + grunn: item.grunn, + })); + feil = `⚠️ Det ser ut som du har skrevet inn personopplysninger om ${kategorier.map((k) => k.kategori).join(', ')} i skjemaet.`; + sensitiv = true; + } else { + feil = '👌✅'; + sensitiv = false; + } + return { kategorier: kategorier, sensitiv: sensitiv, feilmedling: feil }; + }); + } + return { kategorier: kategorier, sensitiv: sensitiv, feilmedling: feil }; +}; + +export default postSjekkForPersonopplysninger; diff --git a/src/mocks/data/tryggtekst.ts b/src/mocks/data/tryggtekst.ts new file mode 100644 index 000000000..98e05defc --- /dev/null +++ b/src/mocks/data/tryggtekst.ts @@ -0,0 +1,25 @@ +import { ResponseComposition, RestContext, RestRequest } from 'msw'; +import { LLMResponse } from '../../api/tryggTekstAPI'; + +const jsonContent = { + kategorier: [ + { + kategori: 'helseopplysninger', + grunn: 'Du er i gang med behandling hos fysioterapeut, men det er antatt at det vil ta noe tid å bli kvitt plagene. Trykkbølgebehandling og nåler prøves først.', + }, + { + kategori: 'religion', + grunn: 'Du ...', + }, + ], +}; + +const response = { + data: { + content: JSON.stringify(jsonContent), + } as LLMResponse, +}; + +export const sjekkTryggTekst = async (_: RestRequest, res: ResponseComposition, ctx: RestContext) => { + return res(ctx.delay(2000), ctx.json(response)); +}; diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index c58a89b80..ca9bd3d45 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -44,6 +44,7 @@ import { VeilarbAktivitet } from '../datatypes/internAktivitetTypes'; import { journalføring } from './data/journalføring'; import { subDays, subMinutes } from 'date-fns'; import { AktivitetsplanResponse } from '../api/aktivitetsplanGraphql'; +import { sjekkTryggTekst } from './data/tryggtekst'; const getOppfFeiler = () => oppfFeilet() && !oppdateringKunFeiler(); const getMaalFeiler = () => maalFeilet() && !oppdateringKunFeiler(); @@ -177,6 +178,9 @@ export const handlers = [ // veilarbmalverk rest.post('/veilarbmalverk/api/mal', jsonResponse(hentMalverk)), + + // tryggtekst + rest.post('/tryggtekst/completion', sjekkTryggTekst), ]; export const aktivitestplanResponse = ( diff --git a/src/moduler/aktivitet/aktivitet-forms/samtalereferat/InnerSamtalereferatForm.tsx b/src/moduler/aktivitet/aktivitet-forms/samtalereferat/InnerSamtalereferatForm.tsx index 993af2731..02b7d0a9d 100644 --- a/src/moduler/aktivitet/aktivitet-forms/samtalereferat/InnerSamtalereferatForm.tsx +++ b/src/moduler/aktivitet/aktivitet-forms/samtalereferat/InnerSamtalereferatForm.tsx @@ -13,6 +13,7 @@ import AktivitetFormHeader from '../AktivitetFormHeader'; import CustomErrorSummary from '../CustomErrorSummary'; import { dateOrUndefined } from '../ijobb/AktivitetIjobbForm'; import { useReferatStartTekst } from './useReferatStartTekst'; +import { TryggTekst } from '../tryggtekst/TryggTekst'; const schema = z.object({ tittel: z.string().min(1, 'Du må fylle ut tema for samtalen').max(100, 'Du må korte ned teksten til 100 tegn'), @@ -124,6 +125,7 @@ const InnerSamtalereferatForm = (props: Props) => { > Klarspråkhjelpen + )} diff --git a/src/moduler/aktivitet/aktivitet-forms/tryggtekst/TryggTekst.tsx b/src/moduler/aktivitet/aktivitet-forms/tryggtekst/TryggTekst.tsx new file mode 100644 index 000000000..c4829ee96 --- /dev/null +++ b/src/moduler/aktivitet/aktivitet-forms/tryggtekst/TryggTekst.tsx @@ -0,0 +1,55 @@ +import { selectPersonopplusningSjekk } from './tryggtekst-selector'; +import { useSelector } from 'react-redux'; +import { BodyLong, BodyShort, ExpansionCard, Heading, List, Loader } from '@navikt/ds-react'; +import { EyeIcon } from '@navikt/aksel-icons'; +import { Status } from '../../../../createGenericSlice'; +import { sjekkForPersonopplysninger } from './tryggtekst-slice'; +import useAppDispatch from '../../../../felles-komponenter/hooks/useAppDispatch'; + +export const TryggTekst = ({ value }: { value: string }) => { + const dispatch = useAppDispatch(); + const { status, data } = useSelector(selectPersonopplusningSjekk); + + const sjekkPersonopplysninger = (isOpen) => { + if (!isOpen) return; + dispatch(sjekkForPersonopplysninger(value)); + }; + + return ( + + +
+ + Sjekk for personopplysninger +
+
+ + {status === Status.PENDING || status === Status.RELOADING ? ( +
+ Teksten din sjekkes for sensitive personopplysninger + +
+ ) : status === Status.OK ? ( + + {data.kategorier.map((kategori) => { + return ( + + {capitalize(kategori.kategori)} + {kategori.grunn} + + ); + })} + + ) : null} +
+
+ ); +}; + +const capitalize = (tekst: string) => { + return tekst.charAt(0).toUpperCase() + tekst.slice(1); +}; diff --git a/src/moduler/aktivitet/aktivitet-forms/tryggtekst/tryggtekst-selector.ts b/src/moduler/aktivitet/aktivitet-forms/tryggtekst/tryggtekst-selector.ts new file mode 100644 index 000000000..cb0869bc6 --- /dev/null +++ b/src/moduler/aktivitet/aktivitet-forms/tryggtekst/tryggtekst-selector.ts @@ -0,0 +1,11 @@ +import { RootState } from '../../../../store'; +import { createSelector } from '@reduxjs/toolkit'; + +const selectTryggTekst = (state: RootState) => state.data.tryggTekst; + +export const selectPersonopplusningSjekk = createSelector(selectTryggTekst, (tryggTekstSlice) => { + return { + status: tryggTekstSlice.status, + data: tryggTekstSlice.data, + }; +}); diff --git a/src/moduler/aktivitet/aktivitet-forms/tryggtekst/tryggtekst-slice.ts b/src/moduler/aktivitet/aktivitet-forms/tryggtekst/tryggtekst-slice.ts new file mode 100644 index 000000000..e0772643e --- /dev/null +++ b/src/moduler/aktivitet/aktivitet-forms/tryggtekst/tryggtekst-slice.ts @@ -0,0 +1,19 @@ +import createGenericSlice from '../../../../createGenericSlice'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import postSjekkForPersonopplysninger from '../../../../api/tryggTekstAPI'; + +const tryggTekstSlice = createGenericSlice({ + name: 'trykkTekst', + reducers: {}, +}); + +export const sjekkForPersonopplysninger = createAsyncThunk( + `${tryggTekstSlice.name}/postSjekkForPersonopplysninger`, + async (tekst: string) => { + return await postSjekkForPersonopplysninger(tekst); + }, +); + +type PersonopplysningerSjekkResultat = Awaited>; + +export const tryggTekstReducer = tryggTekstSlice.reducer; diff --git a/src/moduler/aktivitet/visning/referat/OppdaterReferatForm.tsx b/src/moduler/aktivitet/visning/referat/OppdaterReferatForm.tsx index 81bdf6d10..53aca40ad 100644 --- a/src/moduler/aktivitet/visning/referat/OppdaterReferatForm.tsx +++ b/src/moduler/aktivitet/visning/referat/OppdaterReferatForm.tsx @@ -18,6 +18,7 @@ import Feilmelding from '../../../feilmelding/Feilmelding'; import { oppdaterReferat, utenHistorikk } from '../../aktivitet-actions'; import { useReferatStartTekst } from '../../aktivitet-forms/samtalereferat/useReferatStartTekst'; import { selectAktivitetStatus } from '../../aktivitet-selector'; +import { TryggTekst } from '../../aktivitet-forms/tryggtekst/TryggTekst'; const schema = z.object({ referat: z.string().min(0).max(5000), @@ -76,7 +77,7 @@ const OppdaterReferatForm = (props: Props) => { }; const updateAndPubliser = handleSubmit((values) => { - const oppdatertAktivitet = { ...utenHistorikk(aktivitet), erReferatPublisert: true, referat: values.referat } + const oppdatertAktivitet = { ...utenHistorikk(aktivitet), erReferatPublisert: true, referat: values.referat }; return dispatch(oppdaterReferat(oppdatertAktivitet)).then((action) => { const analysis = checkText(values.referat); logReferatFullfort(analysis, aktivitet.erReferatPublisert, open); @@ -114,6 +115,7 @@ const OppdaterReferatForm = (props: Props) => { > Klarspråkhjelpen + diff --git a/src/reducer.ts b/src/reducer.ts index e59d118fc..74be63bba 100644 --- a/src/reducer.ts +++ b/src/reducer.ts @@ -20,6 +20,7 @@ import veilederReducer from './moduler/veileder/veileder-slice'; import { arkivReducer } from './moduler/verktoylinje/arkivering/arkiv-slice'; import { innsynsrettReducer } from './moduler/aktivitet/innsynsrett/innsynsrett-slice'; import valgtPeriodeReducer from './moduler/filtrering/filter/valgt-periode-slice'; +import { tryggTekstReducer } from './moduler/aktivitet/aktivitet-forms/tryggtekst/tryggtekst-slice'; const reducer = { data: combineReducers({ @@ -41,6 +42,7 @@ const reducer = { errors: errorReducer, innsynsrett: innsynsrettReducer, valgtPeriode: valgtPeriodeReducer, + tryggTekst: tryggTekstReducer, }), view: combineReducers({ visteAktiviteterMedEndringer: aktivitetViewReducer, From 730f4b6ca784b3b33fc7f376959973e699917c36 Mon Sep 17 00:00:00 2001 From: sigurdgroneng Date: Thu, 6 Mar 2025 13:41:58 +0100 Subject: [PATCH 2/7] Putt TryggTekst bak featuretoggle --- src/mocks/data/feature.ts | 4 +++- .../samtalereferat/InnerSamtalereferatForm.tsx | 4 ++-- .../aktivitet-forms/tryggtekst/TryggTekst.tsx | 17 +++++++++++++++++ .../visning/referat/OppdaterReferatForm.tsx | 4 ++-- src/moduler/feature/feature-selector.ts | 2 +- src/moduler/feature/feature-slice.ts | 4 +++- src/moduler/feature/feature.ts | 2 +- 7 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/mocks/data/feature.ts b/src/mocks/data/feature.ts index 558fa601b..17a3447ef 100644 --- a/src/mocks/data/feature.ts +++ b/src/mocks/data/feature.ts @@ -1,3 +1,5 @@ import { Features } from '../../moduler/feature/feature'; -export const features: Features = {}; +export const features: Features = { + 'aktivitetsplan.tryggtekst': true, +}; diff --git a/src/moduler/aktivitet/aktivitet-forms/samtalereferat/InnerSamtalereferatForm.tsx b/src/moduler/aktivitet/aktivitet-forms/samtalereferat/InnerSamtalereferatForm.tsx index 02b7d0a9d..2299f3f69 100644 --- a/src/moduler/aktivitet/aktivitet-forms/samtalereferat/InnerSamtalereferatForm.tsx +++ b/src/moduler/aktivitet/aktivitet-forms/samtalereferat/InnerSamtalereferatForm.tsx @@ -13,7 +13,7 @@ import AktivitetFormHeader from '../AktivitetFormHeader'; import CustomErrorSummary from '../CustomErrorSummary'; import { dateOrUndefined } from '../ijobb/AktivitetIjobbForm'; import { useReferatStartTekst } from './useReferatStartTekst'; -import { TryggTekst } from '../tryggtekst/TryggTekst'; +import { TryggTekstBakFeatureToggle } from '../tryggtekst/TryggTekst'; const schema = z.object({ tittel: z.string().min(1, 'Du må fylle ut tema for samtalen').max(100, 'Du må korte ned teksten til 100 tegn'), @@ -125,7 +125,7 @@ const InnerSamtalereferatForm = (props: Props) => { > Klarspråkhjelpen - + )} diff --git a/src/moduler/aktivitet/aktivitet-forms/tryggtekst/TryggTekst.tsx b/src/moduler/aktivitet/aktivitet-forms/tryggtekst/TryggTekst.tsx index c4829ee96..d9964b713 100644 --- a/src/moduler/aktivitet/aktivitet-forms/tryggtekst/TryggTekst.tsx +++ b/src/moduler/aktivitet/aktivitet-forms/tryggtekst/TryggTekst.tsx @@ -5,6 +5,9 @@ import { EyeIcon } from '@navikt/aksel-icons'; import { Status } from '../../../../createGenericSlice'; import { sjekkForPersonopplysninger } from './tryggtekst-slice'; import useAppDispatch from '../../../../felles-komponenter/hooks/useAppDispatch'; +import { useEffect } from 'react'; +import { hentFeatures } from '../../../feature/feature-slice'; +import { selectFeature, selectFeatureSlice } from '../../../feature/feature-selector'; export const TryggTekst = ({ value }: { value: string }) => { const dispatch = useAppDispatch(); @@ -53,3 +56,17 @@ export const TryggTekst = ({ value }: { value: string }) => { const capitalize = (tekst: string) => { return tekst.charAt(0).toUpperCase() + tekst.slice(1); }; + +export const TryggTekstBakFeatureToggle = ({ value }: { value: string }) => { + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch(hentFeatures()); + }, []); + + const { status, data } = useSelector(selectFeatureSlice); + + return status === Status.OK && data && data['aktivitetsplan.tryggtekst'] === true ? ( + + ) : null; +}; diff --git a/src/moduler/aktivitet/visning/referat/OppdaterReferatForm.tsx b/src/moduler/aktivitet/visning/referat/OppdaterReferatForm.tsx index 53aca40ad..33c5b9f20 100644 --- a/src/moduler/aktivitet/visning/referat/OppdaterReferatForm.tsx +++ b/src/moduler/aktivitet/visning/referat/OppdaterReferatForm.tsx @@ -18,7 +18,7 @@ import Feilmelding from '../../../feilmelding/Feilmelding'; import { oppdaterReferat, utenHistorikk } from '../../aktivitet-actions'; import { useReferatStartTekst } from '../../aktivitet-forms/samtalereferat/useReferatStartTekst'; import { selectAktivitetStatus } from '../../aktivitet-selector'; -import { TryggTekst } from '../../aktivitet-forms/tryggtekst/TryggTekst'; +import { TryggTekstBakFeatureToggle } from '../../aktivitet-forms/tryggtekst/TryggTekst'; const schema = z.object({ referat: z.string().min(0).max(5000), @@ -115,7 +115,7 @@ const OppdaterReferatForm = (props: Props) => { > Klarspråkhjelpen - + diff --git a/src/moduler/feature/feature-selector.ts b/src/moduler/feature/feature-selector.ts index e1818bbc6..9681340a6 100644 --- a/src/moduler/feature/feature-selector.ts +++ b/src/moduler/feature/feature-selector.ts @@ -1,7 +1,7 @@ import { RootState } from '../../store'; import { Feature, Features } from './feature'; -const selectFeatureSlice = (state: RootState) => state.data.feature; +export const selectFeatureSlice = (state: RootState) => state.data.feature; const selectFeatureData = (state: RootState) => selectFeatureSlice(state).data as Features; diff --git a/src/moduler/feature/feature-slice.ts b/src/moduler/feature/feature-slice.ts index c7815aa93..4624b28e8 100644 --- a/src/moduler/feature/feature-slice.ts +++ b/src/moduler/feature/feature-slice.ts @@ -4,7 +4,9 @@ import * as Api from '../../api/featureAPI'; import createGenericSlice, { GenericState, Status } from '../../createGenericSlice'; import { Features } from './feature'; -const initialFeatures: Features = {}; +const initialFeatures: Features = { + 'aktivitetsplan.tryggtekst': false, +}; const featureSlice = createGenericSlice({ name: 'feature', diff --git a/src/moduler/feature/feature.ts b/src/moduler/feature/feature.ts index d93a8f19e..f097b93a3 100644 --- a/src/moduler/feature/feature.ts +++ b/src/moduler/feature/feature.ts @@ -1,4 +1,4 @@ -const ALL_TOGGLES = [] as const; +const ALL_TOGGLES = ['aktivitetsplan.tryggtekst'] as const; export type Feature = (typeof ALL_TOGGLES)[number]; export type Features = Record; From 4403ba41fceacc08d182669f9a848f78a2276f23 Mon Sep 17 00:00:00 2001 From: sigurdgroneng Date: Thu, 13 Mar 2025 12:08:43 +0100 Subject: [PATCH 3/7] new url --- src/api/tryggTekstAPI.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/tryggTekstAPI.ts b/src/api/tryggTekstAPI.ts index 0548326f0..88d848679 100644 --- a/src/api/tryggTekstAPI.ts +++ b/src/api/tryggTekstAPI.ts @@ -99,7 +99,7 @@ async function postRequest(inp: string): Promise { kan innholde flere kategorier eller ingen. Returnere med kategori og settningen som triggret kategorien uten å endre den eller sammenfatte den.<|start_header_id|>user<|end_header_id|> ${inp}\\n<|eot_id|>assistant`, }; - return await fetch(`/tryggtekst/completion`, { + return await fetch(`/tryggtekst/proxy`, { method: 'POST', body: JSON.stringify(data), headers: { Pragma: 'no-cache', 'Cache-Control': 'no-cache', 'Content-Type': 'application/json' }, From e1b876258ca605f505e51459e9d5fa275801d3f1 Mon Sep 17 00:00:00 2001 From: sigurdgroneng Date: Thu, 13 Mar 2025 12:09:03 +0100 Subject: [PATCH 4/7] Fix mock url --- src/mocks/handlers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index ca9bd3d45..d49b10f6e 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -180,7 +180,7 @@ export const handlers = [ rest.post('/veilarbmalverk/api/mal', jsonResponse(hentMalverk)), // tryggtekst - rest.post('/tryggtekst/completion', sjekkTryggTekst), + rest.post('/tryggtekst/proxy', sjekkTryggTekst), ]; export const aktivitestplanResponse = ( From a1272320bf46f99532edc740b2b2a8cac075c4e5 Mon Sep 17 00:00:00 2001 From: sigurdgroneng Date: Thu, 13 Mar 2025 14:01:42 +0100 Subject: [PATCH 5/7] Fix merge --- src/mocks/data/tryggtekst.ts | 7 ++++--- src/mocks/handlers.ts | 14 +++++--------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/mocks/data/tryggtekst.ts b/src/mocks/data/tryggtekst.ts index 98e05defc..51cc2570c 100644 --- a/src/mocks/data/tryggtekst.ts +++ b/src/mocks/data/tryggtekst.ts @@ -1,4 +1,4 @@ -import { ResponseComposition, RestContext, RestRequest } from 'msw'; +import { delay, HttpResponse, ResponseComposition, RestContext, RestRequest } from 'msw'; import { LLMResponse } from '../../api/tryggTekstAPI'; const jsonContent = { @@ -20,6 +20,7 @@ const response = { } as LLMResponse, }; -export const sjekkTryggTekst = async (_: RestRequest, res: ResponseComposition, ctx: RestContext) => { - return res(ctx.delay(2000), ctx.json(response)); +export const sjekkTryggTekst = async () => { + await delay(2000); + return HttpResponse.json(response); }; diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 020f93c03..b59c5af4f 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -60,15 +60,15 @@ export const handlers = [ http.post('/veilarboppfolging/api/v3/hent-maal', failOrGetResponse(getMaalFeiler, sisteMal)), http.post('/veilarboppfolging/api/v3/maal/hent-alle', failOrGetResponse(getMaalFeiler, malListe)), http.post('/veilarboppfolging/api/v3/maal', failOrGetResponse(maalFeilet, opprettMal)), - http.post('/veilarboppfolging/api/:fnr/lestaktivitetsplan', () => { + http.post('/veilarboppfolging/api/:fnr/lestaktivitetsplan', () => { return new HttpResponse(null, { status: 204, - }) + }); }), http.post('/veilarboppfolging/api/v3/veileder/lest-aktivitetsplan', () => { return new HttpResponse(null, { status: 204, - }) + }); }), http.post('/veilarboppfolging/api/v3/oppfolging/settDigital', failOrGetResponse(oppfFeilet, settDigital)), @@ -185,14 +185,10 @@ export const handlers = [ http.get('/veilarboppgave/api/oppgavehistorikk', jsonResponse([])), // veilarbmalverk -<<<<<<< HEAD - rest.post('/veilarbmalverk/api/mal', jsonResponse(hentMalverk)), + http.post('/veilarbmalverk/api/mal', jsonResponse(hentMalverk)), // tryggtekst - rest.post('/tryggtekst/proxy', sjekkTryggTekst), -======= - http.post('/veilarbmalverk/api/mal', jsonResponse(hentMalverk)), ->>>>>>> b18e8686327e4f2ef6e369471620bbf0696627a4 + http.post('/tryggtekst/proxy', sjekkTryggTekst), ]; export const aktivitestplanResponse = ( From 33441be50f91deda74aa6fe342ab1d0b9b4667ba Mon Sep 17 00:00:00 2001 From: sigurdgroneng Date: Thu, 13 Mar 2025 14:26:40 +0100 Subject: [PATCH 6/7] AI TAKEOVER --- src/api/tryggTekstAPI.ts | 237 ++++++++++++++++++++++++++++++++--- src/mocks/data/tryggtekst.ts | 47 ++++++- src/mocks/handlers.ts | 10 +- 3 files changed, 274 insertions(+), 20 deletions(-) diff --git a/src/api/tryggTekstAPI.ts b/src/api/tryggTekstAPI.ts index f3e31dd18..6d956d640 100644 --- a/src/api/tryggTekstAPI.ts +++ b/src/api/tryggTekstAPI.ts @@ -9,6 +9,11 @@ export type LLMResponse = { content: string; }; +export type LLMStreamChunk = { + content: string; + done: boolean; +}; + interface LLMContent { grunn: string; kategori: string; @@ -21,10 +26,134 @@ interface IsSensitive { } const baseUrl = 'http://34.34.85.30:8007'; +const wsUrl = 'ws://34.34.85.30:8007'; + +// WebSocket connection for streaming responses +class LlamaCppWebSocket { + private ws: WebSocket | null = null; + private messageCallback: ((chunk: LLMStreamChunk) => void) | null = null; + private errorCallback: ((error: Error) => void) | null = null; + private completeCallback: (() => void) | null = null; + private isConnected = false; + private reconnectAttempts = 0; + private maxReconnectAttempts = 3; + + constructor() { + this.connect(); + } + + private connect() { + if (this.ws) { + this.ws.close(); + } + + this.ws = new WebSocket(wsUrl + '/ws'); + + this.ws.onopen = () => { + console.log('WebSocket connection established'); + this.isConnected = true; + this.reconnectAttempts = 0; + }; + + this.ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (this.messageCallback) { + const isDone = data.done || false; + this.messageCallback({ + content: data.content || '', + done: isDone + }); + + if (isDone && this.completeCallback) { + this.completeCallback(); + } + } + } catch (error) { + console.error('Error parsing WebSocket message:', error); + if (this.errorCallback) { + this.errorCallback(new Error('Failed to parse WebSocket message')); + } + } + }; + + this.ws.onerror = (error) => { + console.error('WebSocket error:', error); + if (this.errorCallback) { + this.errorCallback(new Error('WebSocket connection error')); + } + }; + + this.ws.onclose = () => { + console.log('WebSocket connection closed'); + this.isConnected = false; + + // Attempt to reconnect + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++; + console.log(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`); + setTimeout(() => this.connect(), 1000 * this.reconnectAttempts); + } else { + console.error('Max reconnect attempts reached'); + if (this.errorCallback) { + this.errorCallback(new Error('Failed to establish WebSocket connection after multiple attempts')); + } + } + }; + } + + public send(data: any): Promise { + return new Promise((resolve, reject) => { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + reject(new Error('WebSocket is not connected')); + return; + } + + let fullResponse = ''; + + this.messageCallback = (chunk) => { + fullResponse += chunk.content; + }; + + this.errorCallback = (error) => { + reject(error); + }; + + this.completeCallback = () => { + resolve({ content: fullResponse }); + }; + + this.ws.send(JSON.stringify(data)); + }); + } + + public stream(data: any, onChunk: (chunk: LLMStreamChunk) => void, onError: (error: Error) => void, onComplete: () => void): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + onError(new Error('WebSocket is not connected')); + return; + } + + this.messageCallback = onChunk; + this.errorCallback = onError; + this.completeCallback = onComplete; + + this.ws.send(JSON.stringify(data)); + } + + public close(): void { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } +} + +// Singleton instance of WebSocket connection +const wsConnection = new LlamaCppWebSocket(); async function postRequest(inp: string): Promise { const data = { - stream: false, + stream: true, // Enable streaming n_predict: 2048, temperature: 0, dry_allowed_length: 10, @@ -99,24 +228,100 @@ async function postRequest(inp: string): Promise { kan innholde flere kategorier eller ingen. Returnere med kategori og settningen som triggret kategorien uten å endre den eller sammenfatte den.<|start_header_id|>user<|end_header_id|> ${inp}\\n<|eot_id|>assistant`, }; - return await fetch(`/tryggtekst/proxy`, { - method: 'POST', - body: JSON.stringify(data), - headers: { Pragma: 'no-cache', 'Cache-Control': 'no-cache', 'Content-Type': 'application/json' }, - }) - .then((res) => { - if (res.ok) { - return res.json(); - } else { - throw new Error('Network response was not ok'); - } + + try { + // Use WebSocket for streaming + return await wsConnection.send(data); + } catch (error) { + console.error('WebSocket error:', error); + + // Fallback to HTTP if WebSocket fails + return await fetch(`/tryggtekst/proxy`, { + method: 'POST', + body: JSON.stringify({ ...data, stream: false }), // Disable streaming for HTTP fallback + headers: { Pragma: 'no-cache', 'Cache-Control': 'no-cache', 'Content-Type': 'application/json' }, }) - .catch((e) => { - console.log(e); - return e; - }); + .then((res) => { + if (res.ok) { + return res.json(); + } else { + throw new Error('Network response was not ok'); + } + }) + .catch((e) => { + console.log(e); + return e; + }); + } } +// Stream version that provides chunks as they arrive +export const streamSjekkForPersonopplysninger = ( + verdi: string, + onChunk: (chunk: string) => void, + onComplete: (result: IsSensitive) => void, + onError: (error: Error) => void +) => { + if (!verdi) { + onComplete({ kategori: '', sensitiv: false, feilmedling: null }); + return; + } + + const data = { + stream: true, + // ... same parameters as in postRequest + n_predict: 2048, + temperature: 0, + // ... other parameters omitted for brevity + prompt: `<|start_header_id|>system<|end_header_id|>\\n\\nDu er en klassifiseringsbot. Du skal vurdere om teksten inneholder særlige kategorier av personopplysninger, og hvis ja hvilke opplysningstype som best beskriver innholdet i teksten. Velg blant følgende 11 kategorier av opplysninger: +* politisk oppfatning +* religion +* etnisk opprinnelse +* filosofisk overbevisning +* fagforeningsmedlemskap +* genetiske opplysninger +* biometriske opplysninger +* helseopplysninger +* seksuelle forhold +* seksuell legning +* ingen særlige kategorier av personopplysninger +kan innholde flere kategorier eller ingen. Returnere med kategori og settningen som triggret kategorien uten å endre den eller sammenfatte den.<|start_header_id|>user<|end_header_id|> +${verdi}\\n<|eot_id|>assistant`, + }; + + let fullResponse = ''; + + wsConnection.stream( + data, + (chunk) => { + fullResponse += chunk.content; + onChunk(chunk.content); + }, + onError, + () => { + try { + const containsSensitive = JSON.parse(fullResponse); + let feil = ''; + let sensitiv = false; + let kategori = ''; + + if (containsSensitive.kategorier && containsSensitive.kategorier.length > 0) { + kategori = containsSensitive.kategorier.map((item: LLMContent) => item.kategori).join(', '); + feil = `⚠️ Det ser ut som du har skrevet inn personopplysninger om ${kategori} i skjemaet.`; + sensitiv = true; + } else { + feil = '👌✅'; + sensitiv = false; + } + + onComplete({ kategori, sensitiv, feilmedling: feil }); + } catch (error) { + onError(new Error('Failed to parse response')); + } + } + ); +}; + const postSjekkForPersonopplysninger = async (verdi: string) => { let feil = ''; let sensitiv = false; diff --git a/src/mocks/data/tryggtekst.ts b/src/mocks/data/tryggtekst.ts index 51cc2570c..818bb5436 100644 --- a/src/mocks/data/tryggtekst.ts +++ b/src/mocks/data/tryggtekst.ts @@ -1,5 +1,5 @@ -import { delay, HttpResponse, ResponseComposition, RestContext, RestRequest } from 'msw'; -import { LLMResponse } from '../../api/tryggTekstAPI'; +import { delay, HttpResponse } from 'msw'; +import { LLMResponse, LLMStreamChunk } from '../../api/tryggTekstAPI'; const jsonContent = { kategorier: [ @@ -20,7 +20,50 @@ const response = { } as LLMResponse, }; +// HTTP response handler export const sjekkTryggTekst = async () => { await delay(2000); return HttpResponse.json(response); }; + +// WebSocket message handler for streaming +export const handleTryggTekstWebSocket = (socket: WebSocket) => { + // Simulate streaming chunks + const streamChunks = [ + { content: '{"kategorier":', done: false }, + { content: ' [{"kategori":', done: false }, + { content: ' "helseopplysninger", "grunn":', done: false }, + { content: ' "Du er i gang med behandling hos fysioterapeut, men det er antatt at det vil ta noe tid å bli kvitt plagene. Trykkbølgebehandling og nåler prøves først."}', done: false }, + { content: ', {"kategori": "religion", "grunn": "Du ..."}]}', done: true } + ]; + + // Send chunks with delays to simulate streaming + let chunkIndex = 0; + + const sendNextChunk = () => { + if (chunkIndex < streamChunks.length) { + const chunk = streamChunks[chunkIndex]; + socket.send(JSON.stringify(chunk)); + chunkIndex++; + + if (chunkIndex < streamChunks.length) { + setTimeout(sendNextChunk, 300); // Send next chunk after 300ms + } + } + }; + + // Start sending chunks after a small delay + setTimeout(sendNextChunk, 500); + + // Handle messages from client + socket.addEventListener('message', (event) => { + // Reset and start streaming again when receiving a new request + chunkIndex = 0; + setTimeout(sendNextChunk, 500); + }); + + // Handle socket closure + socket.addEventListener('close', () => { + console.log('WebSocket connection closed'); + }); +}; diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index b59c5af4f..6bf47ef1c 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -1,4 +1,4 @@ -import { http, HttpResponse } from 'msw'; +import { http, HttpResponse, WebSocket } from 'msw'; import { aktiviteterData, @@ -44,7 +44,7 @@ import { VeilarbAktivitet } from '../datatypes/internAktivitetTypes'; import { journalføring } from './data/journalføring'; import { subDays, subMinutes } from 'date-fns'; import { AktivitetsplanResponse } from '../api/aktivitetsplanGraphql'; -import { sjekkTryggTekst } from './data/tryggtekst'; +import { handleTryggTekstWebSocket, sjekkTryggTekst } from './data/tryggtekst'; const getOppfFeiler = () => oppfFeilet() && !oppdateringKunFeiler(); const getMaalFeiler = () => maalFeilet() && !oppdateringKunFeiler(); @@ -191,6 +191,12 @@ export const handlers = [ http.post('/tryggtekst/proxy', sjekkTryggTekst), ]; +// WebSocket handlers +export const wsHandlers = [ + // tryggtekst WebSocket handler + WebSocket.link('ws://34.34.85.30:8007/ws', handleTryggTekstWebSocket), +]; + export const aktivitestplanResponse = ( { aktiviteter }: { aktiviteter: VeilarbAktivitet[] } = { aktiviteter: aktiviteterData.aktiviteter }, ): AktivitetsplanResponse => { From 6327da42937cb420365ce449ea2b7811d9e962bd Mon Sep 17 00:00:00 2001 From: sigurdgroneng Date: Fri, 14 Mar 2025 08:44:57 +0100 Subject: [PATCH 7/7] Fix tests --- src/components/TryggTekstStreamDemo.tsx | 101 ++++++++++++++++++++++++ src/mocks/data/tryggtekst.ts | 20 +++-- src/mocks/handlers.ts | 4 +- src/mocks/index.ts | 4 +- src/setupTests.jsx | 8 +- 5 files changed, 124 insertions(+), 13 deletions(-) create mode 100644 src/components/TryggTekstStreamDemo.tsx diff --git a/src/components/TryggTekstStreamDemo.tsx b/src/components/TryggTekstStreamDemo.tsx new file mode 100644 index 000000000..2acf75946 --- /dev/null +++ b/src/components/TryggTekstStreamDemo.tsx @@ -0,0 +1,101 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { streamSjekkForPersonopplysninger } from '../api/tryggTekstAPI'; + +interface TryggTekstStreamDemoProps { + initialText?: string; +} + +const TryggTekstStreamDemo: React.FC = ({ initialText = '' }) => { + const [inputText, setInputText] = useState(initialText); + const [streamingResult, setStreamingResult] = useState(''); + const [isStreaming, setIsStreaming] = useState(false); + const [isSensitive, setIsSensitive] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const streamingRef = useRef(''); + + const handleInputChange = (e: React.ChangeEvent) => { + setInputText(e.target.value); + }; + + const handleStreamingCheck = () => { + if (!inputText.trim()) { + setErrorMessage('Vennligst skriv inn tekst for å sjekke'); + return; + } + + setIsStreaming(true); + setStreamingResult(''); + setErrorMessage(''); + streamingRef.current = ''; + + streamSjekkForPersonopplysninger( + inputText, + (chunk) => { + // Handle each chunk as it arrives + streamingRef.current += chunk; + setStreamingResult(streamingRef.current); + }, + (result) => { + // Handle completion + setIsStreaming(false); + setIsSensitive(result.sensitiv); + }, + (error) => { + // Handle errors + setIsStreaming(false); + setErrorMessage(`Feil ved streaming: ${error.message}`); + } + ); + }; + + return ( +
+

TryggTekst Streaming Demo

+ +
+ +