diff --git a/docs/sprints/backlog.md b/docs/sprints/backlog.md index 4a2a9309..48afb12f 100644 --- a/docs/sprints/backlog.md +++ b/docs/sprints/backlog.md @@ -3,7 +3,7 @@ title: "Produktbacklog" description: "Alla kända stories och uppgifter, speglar roadmap.md. Plockas in i sprintar vid behov." category: sprint status: active -last_updated: 2026-05-08 +last_updated: 2026-05-13 tags: [backlog, roadmap, planning] sections: - Blockerare @@ -62,6 +62,7 @@ sections: | **Supabase Auth callback-route saknas** | 0.5 dag | Magic link och OAuth-callback från Supabase Auth landar bara på root-page utan att session etableras. Vi har egen email/password-flow så det är inte affärskritiskt idag, men blockerar framtida features (social login, magic link login, Supabase egna reset-mail). Bygg `/auth/callback`-route som anropar `supabase.auth.exchangeCodeForSession(code)` och redirectar till `/dashboard`. Hittad 2026-04-30 vid felsökning av password reset. | | **URL-konfigurationsmatris** | 30 min | Skapa `docs/operations/url-configuration.md` som listar alla URL-config-platser och vad varje styr: Vercel `APP_URL`, Supabase Site URL + Redirect URLs, Stripe webhook endpoint, Resend domän-verifiering, ev. iOS prod-URL i `AppConfig.swift`. Vi har precis brunnit oss på trippel-miss (APP_URL + Site URL + Redirect URLs alla satta fel) — dokumentationen ska göra det tydligt vad som måste uppdateras vid byte av domän. Inkluderar checklista och länkar till dashboard-vägar. | | **Städa Vercel env-variabler med literal `\\n` på slutet** | 15 min | `NEXT_PUBLIC_SUPABASE_URL` och `NEXT_PUBLIC_SUPABASE_ANON_KEY` i Vercel prod har literal backslash-n (`\\n`) på slutet av värdena. Next.js runtime kan strippa det, men `vercel env pull` skriver det som literal i .env-filen vilket bryter direktanrop mot Supabase API från lokala scripts. Hittad 2026-04-30. Fix: Vercel UI → respektive variabel → ta bort `\\n`-suffixet → spara → redeploy. | +| **Help-data drift protection** | 15-30 min | CI-validation som regenererar `articles-data.ts` och diffar mot committed version — failar PR om out-of-sync. Plus README-rad om `npm run generate:help`-workflow. Bevisat behov efter PR #333 (2026-05-13): staging hade tom hjälpsektion eftersom build-time-generator + `.vercelignore` (`*.md`) filtrerade bort markdown-källor. Detaljerad story med A/B/C/D-alternativ: [help-data-drift-protection.md](../stories/help-data-drift-protection.md). | ## Kodeffektivitet (tech debt) diff --git a/docs/stories/help-data-drift-protection.md b/docs/stories/help-data-drift-protection.md new file mode 100644 index 00000000..c63c3bd2 --- /dev/null +++ b/docs/stories/help-data-drift-protection.md @@ -0,0 +1,148 @@ +--- +title: "Help-data drift protection" +description: "Hindra att src/lib/help/articles-data.ts blir out-of-sync med markdown-källorna, eller att Vercel-build råkar generera tom help-data igen." +category: plan +status: draft +last_updated: 2026-05-13 +tags: [help, build, vercel, drift-prevention, ci] +related: + - ../../package.json + - ../../scripts/generate-help-data.ts + - ../../src/lib/help/articles-data.ts + - ../../.vercelignore +sections: + - Problem + - Risk + - Mål + - Förslag på lösningar + - Rekommendation + - Acceptanskriterier + - Risk och tradeoffs + - Verifieringsstrategi + - Prioritet +--- + +# Help-data drift protection + +## Problem + +`src/lib/help/articles-data.ts` är auto-genererad av `scripts/generate-help-data.ts` från markdown-filer i `src/lib/help/articles//*.md`. Filen committas och importeras synkront i `src/lib/help/index.ts`. Hjälpsektionen är beroende av att den committade versionen faktiskt matchar källorna. + +**Failure mode A — Vercel-byggets regenerering (fixad 2026-05-13, PR #333)**: Build-scriptet anropade `tsx scripts/generate-help-data.ts` före `next build`. Vercel kör `vercel build` som respekterar `.vercelignore` (`*.md`), så markdown-filerna filtrerades bort. Generatorn hittade noll filer och skrev `[]` till `articles-data.ts`, vilket överskrev den committade versionen. Build-loggen sa `Generated ... with 0 articles`, men bygget rapporterades som SUCCESS. Hjälpsidorna blev tomma i staging (och troligen prod) — lokalt fungerade allt eftersom markdown-filerna fanns på disk. + +**Failure mode B — Glömd regenerering**: En utvecklare redigerar en markdown-artikel men glömmer `npm run generate:help` innan commit. `articles-data.ts` är då out-of-sync med källorna. Hjälpsidorna visar gammal data tills någon märker det, ofta lång tid efter. + +**Varför det är svårt att upptäcka:** +- Build är "grön" — exit code 0, alla checks passerar, deploy READY. +- TypeScript-kompilering passerar (tom array är giltig). +- Inga tester fångar tom data eftersom unit-tester typiskt mockar artikelladdning. +- Lokalt fungerar allt, så bug:en syns bara i staging/prod efter deploy. +- Slutanvändaren ser "Inga artiklar matchade din sökning" — kan tolkas som UI-bug, inte data-bug. + +## Risk + +| Risk | Sannolikhet | Påverkan | +|------|-------------|----------| +| Staging/prod får tom hjälpsektion | Medel (failure mode A inträffat 1 gång; B kan ske vid varje markdown-edit) | Hög — användare får ingen hjälp, försämrad demo, supportbörda | +| Build blir "grön" trots fel innehåll | Hög | Hög — falsk trygghet, inga automatiska larm | +| Lokalt fungerar fortfarande | Hög | Medel — utvecklaren ser inte buggen, ingen incident-driver | +| Hjälpsökningar pekar på saknade slugs | Medel | Låg-medel — 404-spikar i loggar utan tydlig orsak | + +## Mål + +Skydda mot: +- **Stale generated data** — `articles-data.ts` är gammal jämfört med markdown-källor. +- **Missing regeneration efter markdown-edits** — utvecklaren commitar markdown utan att uppdatera den genererade filen. +- **Build/runtime drift mellan lokalt och Vercel** — bygg-pipelinen producerar annan output än utvecklaren ser. + +Icke-mål: +- Att flytta tillbaka regenereringen till build-time. Det är vad som orsakade originalbuggen. +- Att designa om help-systemet (synkron import-modell fungerar bra). + +## Förslag på lösningar + +### A. Pre-commit check + +Hook som varnar (eller blockerar) om någon `articles/**/*.md` har nyare mtime än `articles-data.ts`, eller om en diff mellan regenerering och committed version inte är tom. + +**Implementation:** Bash i `.husky/pre-commit` (eller motsvarande hook-katalog). Kör `tsx scripts/generate-help-data.ts` mot en tempfil, diffa mot committed `articles-data.ts`. Om olika → ge ett tydligt felmeddelande som pekar på `npm run generate:help`. + +**Pros:** Fångar buggen vid commit-tillfället. Snabb feedback. Påverkar bara utvecklaren som glömde regenerera. +**Cons:** Lokala hooks kan skippas med `--no-verify`. Kräver att alla utvecklare har hooks installerade. + +### B. CI validation + +GitHub Actions-step som regenererar `articles-data.ts` i CI-miljön och diffar mot committed version. Om olika → CI rött. + +**Implementation:** Ett `validate-help-data`-job i `.github/workflows/check.yml`. Kör `tsx scripts/generate-help-data.ts`, sedan `git diff --exit-code src/lib/help/articles-data.ts`. Failar med tydligt meddelande. + +**Pros:** Kan inte skippas. Fångar både A och B. Krävs som status-check på PR. +**Cons:** Lägger till några sekunder per CI-körning. Kräver att markdown-filer faktiskt finns i CI (vilket de gör — CI är inte Vercel-build-miljö). + +### C. Runtime sanity check + +Vid app-start eller per-request på `/api/feature-flags` (eller liknande low-frequency endpoint): logga `error` (eller skicka Sentry-larm) om `allArticles.length === 0` när `help_center`-flaggan är på. + +**Implementation:** En init-check i `src/lib/help/index.ts` eller i en `instrumentation.ts`-fil. `if (process.env.NODE_ENV === 'production' && allArticles.length === 0) logger.error("help_center: zero articles in bundle")`. + +**Pros:** Fångar buggen i produktion även om CI/pre-commit missar. Self-healing detection. +**Cons:** Reagerar först efter deploy, inte före. Sentry-brus om tom data är legitim någonstans (vilket den inte borde vara). + +### D. Developer UX + +- Tydlig README-rad i `src/lib/help/articles/`: "Efter ändring av .md-filer: kör `npm run generate:help` och commita både md + articles-data.ts". +- Felmeddelande i `npm run generate:help` om dir saknas — annars skriver det tyst tom fil. +- `npm run lint:help` eller liknande alias som diffar utan att skriva (read-only check). + +**Pros:** Hjälper utvecklare göra rätt från början. Komplement till A/B/C. +**Cons:** Hjälper inte om utvecklaren inte läser docs eller glömmer. + +## Rekommendation + +**MVP: B (CI validation), plus minimal D (README-rad).** + +Motivation: +- B är det enda alternativet som **inte kan skippas** av utvecklare och **inte beror på lokal miljö**. CI är auktoritativ. +- B fångar **både failure mode A och B** med samma mekanism — diffen mellan regenererad och committed version. +- Implementation är trivial (~10 rader yaml + 3 rader bash) och tar <5 sek per CI-körning. +- D är gratis tillägg som minskar friktion för utvecklare som faktiskt vill göra rätt. +- A (pre-commit) är värdefull men inte tillräcklig som primärt skydd (`--no-verify` finns; nya utvecklare kanske inte har hooks). Kan läggas till senare om CI-misstag blir vanliga. +- C (runtime sanity) är overkill när B fångar buggen pre-merge. Kan läggas till om vi vill ha defense-in-depth, men inte värt MVP-tiden. + +**Avgränsning:** Inte göra något åt `.vercelignore`-raden `*.md` — den är inte längre relevant eftersom vi inte kör generatorn under Vercel-build. Att ta bort den skulle bara öka deploy-storleken utan vinst. + +## Acceptanskriterier + +- [ ] CI failar (rött PR-check) om en PR ändrar `articles/**/*.md` utan att uppdatera `articles-data.ts`, eller om någon ändrar `articles-data.ts` på sätt som inte matchar markdown. +- [ ] CI failar med tydligt felmeddelande som nämner `npm run generate:help` (inte bara en stack trace). +- [ ] `npm run generate:help` är dokumenterad i README eller `src/lib/help/articles/README.md` (filen behöver kanske skapas) som det enda sättet att uppdatera help-data. +- [ ] Bevisad: PR som introducerar en orelaterad markdown-edit utan regenerering blockeras av CI-checken. +- [ ] Bevisad: PR som regenererar korrekt passerar utan friktion. + +## Risk och tradeoffs + +| Risk | Mitigation | +|------|------------| +| Generated file in git skapar merge-conflicts vid samtidiga markdown-edits | Acceptabel — konflikten är trivial att lösa (kör `npm run generate:help` post-merge). Alternativet (generera vid build) skapade värre bugg. | +| Längre CI-tid | Försumbart — generatorn tar <2 sek. CI är redan flera minuter. | +| False positives om `JSON.stringify`-output blir non-deterministisk | Generatorn är deterministisk per inspektion (samma input → samma output). Om problem uppstår: normalisera diff:en (whitespace-insensitive) eller lägg till sort-key. | +| CI-checken blockerar legitima docs-only PR:s med markdown-format-fixar | Inget problem — sådana PR:s ska faktiskt regenerera, det är hela poängen. | +| Pre-commit hook (om vi lägger till A senare) ger friktion | Kan göras opt-in via husky, eller bara varning utan blockering. | + +## Verifieringsstrategi + +1. **Negative test**: Skapa en test-PR som ändrar `src/lib/help/articles/customer/boka-en-tjanst.md` (t.ex. byter ett ord i summary) utan att köra generatorn. Förvänta: CI rött med tydligt felmeddelande. +2. **Positive test**: I samma PR, kör `npm run generate:help` och committa båda. Förvänta: CI grönt. +3. **Roundtrip test**: Verifiera att `npm run generate:help` är idempotent — kör två gånger, ingen diff andra gången. +4. **No-op test**: PR som inte rör help-systemet alls — CI-checken ska passera utan extra tid (alternativt: bara köra checken om relevanta paths ändrats, via `paths:` i workflow trigger). + +## Prioritet + +| Dimension | Bedömning | +|-----------|-----------| +| Severity | **Medel-Hög**. Hjälpsektionen är inte affärskritisk (ingen pengar-flöde), men är central för demo och onboarding. Tom help i staging undergräver demokänslan. | +| Likelihood | **Medel**. Failure mode A är fixad. Failure mode B (glömd regenerering) kan ske vid varje markdown-edit — kanske 1 gång per 5 edits utan skydd. | +| Effort | **Small (15-30 min)** för MVP (B + D). Kan göras som en separat slice utan beroenden. | +| Sprint-prio | **Nästa lediga sprint**, inte hotfix. Vi har redan fixat akut-buggen (PR #333). Detta är härdning. Inte värt att bryta pågående sprint-arbete för. | + +**Rekommenderad sprint-prio: Lägsta i nästa sprint.** Sätt på backlogen som "S68-X: Help-data drift protection" eller liknande. Plocka när någon har 30 min utrymme. diff --git a/package-lock.json b/package-lock.json index 0166c933..f90abb1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,9 +47,9 @@ "next": "16.1.7", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", - "react": "19.2.5", + "react": "19.2.6", "react-day-picker": "^9.11.1", - "react-dom": "19.2.5", + "react-dom": "19.2.6", "react-hook-form": "^7.66.0", "react-leaflet": "^4.2.1", "recharts": "^3.7.0", @@ -59,7 +59,7 @@ "swr": "^2.4.1", "tailwind-merge": "^3.4.1", "vaul": "^1.1.2", - "zod": "^4.1.12" + "zod": "^4.4.3" }, "devDependencies": { "@eslint/eslintrc": "^3.3.3", @@ -5628,6 +5628,15 @@ "webidl-conversions": "^4.0.2" } }, + "node_modules/@serwist/build/node_modules/zod": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.1.tgz", + "integrity": "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@serwist/next": { "version": "9.5.11", "resolved": "https://registry.npmjs.org/@serwist/next/-/next-9.5.11.tgz", @@ -5753,6 +5762,15 @@ "node": ">=10" } }, + "node_modules/@serwist/next/node_modules/zod": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.1.tgz", + "integrity": "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@serwist/utils": { "version": "9.5.11", "resolved": "https://registry.npmjs.org/@serwist/utils/-/utils-9.5.11.tgz", @@ -5794,6 +5812,15 @@ } } }, + "node_modules/@serwist/webpack-plugin/node_modules/zod": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.1.tgz", + "integrity": "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@serwist/window": { "version": "9.5.11", "resolved": "https://registry.npmjs.org/@serwist/window/-/window-9.5.11.tgz", @@ -6602,9 +6629,9 @@ } }, "node_modules/@types/node": { - "version": "20.19.39", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", - "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -14131,9 +14158,9 @@ } }, "node_modules/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", - "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -14161,15 +14188,15 @@ } }, "node_modules/react-dom": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", - "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.5" + "react": "^19.2.6" } }, "node_modules/react-hook-form": { @@ -17235,9 +17262,9 @@ } }, "node_modules/zod": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.1.tgz", - "integrity": "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 93e680ce..5f68a3a2 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev:offline": "FEATURE_OFFLINE_MODE=true next dev --webpack", "generate:help": "tsx scripts/generate-help-data.ts", "prebuild": "tsx scripts/check-prod-env.ts", - "build": "prisma generate && tsx scripts/generate-help-data.ts && next build --webpack", + "build": "prisma generate && next build --webpack", "postinstall": "prisma generate", "start": "next start", "lint": "eslint", @@ -113,9 +113,9 @@ "next": "16.1.7", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", - "react": "19.2.5", + "react": "19.2.6", "react-day-picker": "^9.11.1", - "react-dom": "19.2.5", + "react-dom": "19.2.6", "react-hook-form": "^7.66.0", "react-leaflet": "^4.2.1", "recharts": "^3.7.0", @@ -125,7 +125,7 @@ "swr": "^2.4.1", "tailwind-merge": "^3.4.1", "vaul": "^1.1.2", - "zod": "^4.1.12" + "zod": "^4.4.3" }, "devDependencies": { "@eslint/eslintrc": "^3.3.3", diff --git a/src/domain/customer-insight/CustomerInsightService.test.ts b/src/domain/customer-insight/CustomerInsightService.test.ts index 300d2460..32539c43 100644 --- a/src/domain/customer-insight/CustomerInsightService.test.ts +++ b/src/domain/customer-insight/CustomerInsightService.test.ts @@ -264,6 +264,52 @@ describe("CustomerInsightService", () => { expect(result.value.vipScore).toBe("low") }) + // Regression tests for Sonnet 4.6 returning prose around JSON + // (matches salvage-vision learning: tolerant JSON extraction needed) + it("handles prose before JSON object", async () => { + const json = JSON.stringify(VALID_LLM_RESPONSE) + + mockCreate.mockResolvedValue({ + content: [{ type: "text", text: "Här är analysen:\n" + json }], + }) + + const result = await service.generateInsight(SAMPLE_DATA, SAMPLE_METRICS) + expect(result.isSuccess).toBe(true) + expect(result.value.frequency).toBe("Regelbunden (var 8:e vecka)") + }) + + it("handles prose after JSON object", async () => { + const json = JSON.stringify(VALID_LLM_RESPONSE) + + mockCreate.mockResolvedValue({ + content: [{ type: "text", text: json + "\n\nHoppas detta hjälper!" }], + }) + + const result = await service.generateInsight(SAMPLE_DATA, SAMPLE_METRICS) + expect(result.isSuccess).toBe(true) + expect(result.value.frequency).toBe("Regelbunden (var 8:e vecka)") + }) + + it("handles markdown code block wrapped in prose", async () => { + const json = JSON.stringify(VALID_LLM_RESPONSE) + + mockCreate.mockResolvedValue({ + content: [ + { + type: "text", + text: + "Visst, här kommer analysen:\n```json\n" + + json + + "\n```\nHör av dig om något.", + }, + ], + }) + + const result = await service.generateInsight(SAMPLE_DATA, SAMPLE_METRICS) + expect(result.isSuccess).toBe(true) + expect(result.value.frequency).toBe("Regelbunden (var 8:e vecka)") + }) + describe("mapInsightErrorToStatus", () => { it("maps NO_DATA to 400", () => { expect(mapInsightErrorToStatus({ type: "NO_DATA", message: "" })).toBe(400) diff --git a/src/domain/customer-insight/CustomerInsightService.ts b/src/domain/customer-insight/CustomerInsightService.ts index 970e69fc..7fc2d94d 100644 --- a/src/domain/customer-insight/CustomerInsightService.ts +++ b/src/domain/customer-insight/CustomerInsightService.ts @@ -95,15 +95,17 @@ const customerInsightSchema = z.object({ // Helpers // ----------------------------------------------------------- -function stripMarkdownCodeBlock(text: string): string { - const trimmed = text.trim() - if (trimmed.startsWith("```")) { - return trimmed - .replace(/^```(?:json)?\s*\n?/, "") - .replace(/\n?```\s*$/, "") - .trim() +// Tolerant JSON extraction: finds the outermost JSON object in the response, +// even when the model wraps it in markdown code blocks or surrounding prose. +// Reason: Claude Sonnet 4.6 sometimes adds explanatory text despite system-prompt +// instructions. See salvage-vision/CLAUDE.md for the same learning. +function extractJsonObject(text: string): string { + const start = text.indexOf("{") + const end = text.lastIndexOf("}") + if (start === -1 || end === -1 || end <= start) { + return text.trim() } - return trimmed + return text.slice(start, end + 1) } // ----------------------------------------------------------- @@ -184,7 +186,7 @@ export class CustomerInsightService { }) } - const cleanedText = stripMarkdownCodeBlock(content.text) + const cleanedText = extractJsonObject(content.text) const rawParsed = JSON.parse(cleanedText) const validated = customerInsightSchema.safeParse(rawParsed)