From 737adfb0058c9373f3b7c194261b30fd6285ba07 Mon Sep 17 00:00:00 2001 From: aryansoni-dev Date: Wed, 1 Jul 2026 00:25:57 +0530 Subject: [PATCH 1/3] [+] Bug fixes and improvements. [+] Implemented code-rabbit's suggested patches. --- AGENTS.md | 2 +- app/(onboarding)/onboarding-screen-2.tsx | 4 +- app/insights/report/[periodType].tsx | 27 +- components/ai-chat/ai-chat-screen.tsx | 90 +++- components/ai/ai-response-renderer.tsx | 422 ++++++++++++++++++ components/auth/auth-screen.tsx | 4 +- components/insights/RecurringThemesCard.tsx | 8 +- components/insights/insights-screen.tsx | 17 +- .../insights/report/MoodJourneyChart.tsx | 15 +- .../insights/report/RecurringThemesChart.tsx | 7 +- .../insights/report/ReportNarrativeBlocks.tsx | 69 ++- .../insights/report/ReportScreenStates.tsx | 1 + .../entry-ai-reflection-card.tsx | 29 +- docs/dynamic-ai-text-rendering-audit.md | 106 +++++ docs/dynamic-ai-text-test-matrix.md | 26 ++ lib/ai/entryReflectionService.ts | 8 + lib/ai/get-ai-text-length.ts | 40 ++ lib/ai/log-ai-text-integrity.ts | 26 ++ lib/ai/remoteJournalAssistant.ts | 9 + lib/insights/aiInsightReportService.ts | 8 + lib/text/add-safe-break-opportunities.ts | 46 ++ lib/text/parse-ai-markdown.ts | 177 ++++++++ lib/validation/persistedDataValidators.ts | 5 + store/useAIInsightReportStore.ts | 8 + store/useChatStore.ts | 12 +- store/useEntryReflectionStore.ts | 8 + .../functions/_shared/parseReportNarrative.ts | 185 ++++++++ .../generate-insight-report/index.ts | 249 +++-------- supabase/functions/journal-ai-chat/index.ts | 53 +++ supabase/functions/reflect-on-entry/index.ts | 68 +-- tests/ai-text-rendering.test.ts | 93 ++++ tests/fixtures/ai-responses.ts | 82 ++++ tests/report-narrative-parser.test.ts | 78 ++++ types/chat.ts | 1 + 34 files changed, 1637 insertions(+), 346 deletions(-) create mode 100644 components/ai/ai-response-renderer.tsx create mode 100644 docs/dynamic-ai-text-rendering-audit.md create mode 100644 docs/dynamic-ai-text-test-matrix.md create mode 100644 lib/ai/get-ai-text-length.ts create mode 100644 lib/ai/log-ai-text-integrity.ts create mode 100644 lib/text/add-safe-break-opportunities.ts create mode 100644 lib/text/parse-ai-markdown.ts create mode 100644 supabase/functions/_shared/parseReportNarrative.ts create mode 100644 tests/ai-text-rendering.test.ts create mode 100644 tests/fixtures/ai-responses.ts create mode 100644 tests/report-narrative-parser.test.ts diff --git a/AGENTS.md b/AGENTS.md index 8496a72..c5c7daa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -281,6 +281,6 @@ Before every feature: - Keep 'leading-6' in all components, and fix if you find any un-matching ones. -- If you see any svgs in the attached design look for it in the '/assets' folder. If we don't have it, try to create them yourself like you created the google icon in the login screens. +- If you see any svgs in the attached design look for them in the '/assets' folder. If we don't have it, try to create them yourself like you created the google icon in the login screens. - Keep the text sizes like : headings, subheadings, body text, etc consistent on each screen. diff --git a/app/(onboarding)/onboarding-screen-2.tsx b/app/(onboarding)/onboarding-screen-2.tsx index 614c9c6..c7f4b3e 100644 --- a/app/(onboarding)/onboarding-screen-2.tsx +++ b/app/(onboarding)/onboarding-screen-2.tsx @@ -238,10 +238,10 @@ export default function OnboardingScreenTwo() { }} > diff --git a/app/insights/report/[periodType].tsx b/app/insights/report/[periodType].tsx index 9efc162..6410a4d 100644 --- a/app/insights/report/[periodType].tsx +++ b/app/insights/report/[periodType].tsx @@ -10,6 +10,7 @@ import { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AnimatedIconButton } from "@/components/ui/animated-icon-button"; +import { AIResponseRenderer } from "@/components/ai/ai-response-renderer"; import { BottomTabBar, bottomTabBarBaseHeight } from "@/components/navigation/bottom-tab-bar"; import { EntryTypeDistributionChart } from "@/components/insights/report/EntryTypeDistributionChart"; import { @@ -196,21 +197,19 @@ export default function AIInsightReportScreen() { onPress={handleRegenerate} /> - - {reportState.report.narrative.overview} - + {reportState.report.narrative.dataQualityNote ? ( - - {reportState.report.narrative.dataQualityNote} - + + + ) : null} diff --git a/components/ai-chat/ai-chat-screen.tsx b/components/ai-chat/ai-chat-screen.tsx index 0d979f2..cc20214 100644 --- a/components/ai-chat/ai-chat-screen.tsx +++ b/components/ai-chat/ai-chat-screen.tsx @@ -9,6 +9,8 @@ import { Animated, Keyboard, KeyboardAvoidingView, + type NativeScrollEvent, + type NativeSyntheticEvent, Pressable, ScrollView, Text, @@ -21,6 +23,7 @@ import { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AnimatedIconButton } from "@/components/ui/animated-icon-button"; +import { AIResponseRenderer } from "@/components/ai/ai-response-renderer"; import { ScreenErrorState } from "@/components/states/ScreenErrorState"; import { CONNECTION_STATE_COLORS } from "@/constants/theme"; import { useAppDialog } from "@/hooks/useAppDialog"; @@ -28,6 +31,7 @@ import { useConnectivity } from "@/hooks/useConnectivity"; import { useDelayedVisibility } from "@/hooks/useDelayedVisibility"; import { generateLocalJournalResponse } from "@/lib/ai/localJournalAssistant"; import { generateRemoteJournalResponse } from "@/lib/ai/remoteJournalAssistant"; +import { addSafeBreakOpportunities } from "@/lib/text/add-safe-break-opportunities"; import { useJournalStore } from "@/store/journal-store"; import { useChatStore } from "@/store/useChatStore"; import type { ChatMessage } from "@/types/chat"; @@ -52,15 +56,7 @@ const chatMessageTextStyle = { paddingBottom: 8, paddingTop: 3, } as const; -const assistantMessageTextStyle = { - flexShrink: 1, - flexWrap: "wrap", - includeFontPadding: true, - overflow: "visible", - paddingBottom: 8, - paddingRight: 3, - paddingTop: 3, -} as const; +const nearBottomThreshold = 80; export function AiChatScreen({ avatarUrl, @@ -73,6 +69,7 @@ export function AiChatScreen({ const connectivity = useConnectivity(); const requestIdRef = useRef(0); const scrollViewRef = useRef(null); + const isNearBottomRef = useRef(true); const [message, setMessage] = useState(""); const [composerTextHeight, setComposerTextHeight] = useState( minComposerTextHeight, @@ -80,6 +77,7 @@ export function AiChatScreen({ const [isThinking, setIsThinking] = useState(false); const [isEmojiPickerVisible, setIsEmojiPickerVisible] = useState(false); const [keyboardOffset, setKeyboardOffset] = useState(0); + const [showJumpToLatest, setShowJumpToLatest] = useState(false); const journalEntries = useJournalStore((state) => state.entries); const chatMessages = useChatStore((state) => state.messages); const chatHasHydrated = useChatStore((state) => state.hasHydrated); @@ -151,7 +149,9 @@ export function AiChatScreen({ useEffect(() => { requestIdRef.current += 1; + isNearBottomRef.current = true; setIsThinking(false); + setShowJumpToLatest(false); }, [userId]); useEffect(() => { @@ -236,6 +236,36 @@ export function AiChatScreen({ ); } + function handleChatScroll( + event: NativeSyntheticEvent, + ) { + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + const isNearBottom = + contentOffset.y + layoutMeasurement.height >= + contentSize.height - nearBottomThreshold; + + isNearBottomRef.current = isNearBottom; + + if (isNearBottom) { + setShowJumpToLatest(false); + } + } + + function handleChatContentSizeChange() { + if (isNearBottomRef.current) { + scrollViewRef.current?.scrollToEnd({ animated: true }); + return; + } + + setShowJumpToLatest(true); + } + + function handleJumpToLatest() { + isNearBottomRef.current = true; + setShowJumpToLatest(false); + scrollViewRef.current?.scrollToEnd({ animated: true }); + } + async function handleSendMessage(nextMessage = message) { const trimmedMessage = nextMessage.trim(); @@ -299,6 +329,7 @@ export function AiChatScreen({ content: remoteResponse.message, createdAt: new Date().toISOString(), id: createChatMessageId(), + isPartial: remoteResponse.isPartial, relatedEntryIds: remoteResponse.relatedEntryIds, role: "assistant", source: remoteResponse.source, @@ -414,8 +445,10 @@ export function AiChatScreen({ paddingTop: 16, }} keyboardShouldPersistTaps="handled" - onContentSizeChange={() => scrollViewRef.current?.scrollToEnd()} + onContentSizeChange={handleChatContentSizeChange} + onScroll={handleChatScroll} ref={scrollViewRef} + scrollEventThrottle={16} showsVerticalScrollIndicator={false} > @@ -473,6 +506,21 @@ export function AiChatScreen({ + {showJumpToLatest ? ( + + + + Jump to latest + + + + ) : null} + - + {message.isPartial ? ( + + This response stopped before it was complete. Ask DearDiary to + continue, or try your question again. + + ) : null} {messageTime} @@ -739,7 +797,7 @@ function BubbleMessageText({ selectable={selectable} style={style} > - {text} + {addSafeBreakOpportunities(text)} ); } diff --git a/components/ai/ai-response-renderer.tsx b/components/ai/ai-response-renderer.tsx new file mode 100644 index 0000000..826f110 --- /dev/null +++ b/components/ai/ai-response-renderer.tsx @@ -0,0 +1,422 @@ +import { + Component, + memo, + useEffect, + useMemo, + type ErrorInfo, + type ReactNode, +} from "react"; +import { + Linking, + ScrollView, + Text, + View, + type TextStyle, +} from "react-native"; + +import { logAITextIntegrity } from "@/lib/ai/log-ai-text-integrity"; +import { addSafeBreakOpportunities } from "@/lib/text/add-safe-break-opportunities"; +import { + parseAITextBlocks, + type AITextBlock, +} from "@/lib/text/parse-ai-markdown"; + +export type AIResponseVariant = "chat" | "reflection" | "report" | "insight"; + +type AIResponseRendererProps = { + accessibilityLabel?: string; + content: string; + diagnosticLabel?: string; + isStreaming?: boolean; + selectable?: boolean; + testID?: string; + variant?: AIResponseVariant; +}; + +const variantClasses: Record< + AIResponseVariant, + { body: string; heading: string; muted: string } +> = { + chat: { + body: "text-[16px] leading-6 text-[#51515B]", + heading: "text-[18px] font-bold leading-6 text-[#27272A]", + muted: "text-[15px] leading-6 text-[#71717B]", + }, + insight: { + body: "text-[17px] leading-6 text-[#52525B]", + heading: "text-[18px] font-bold leading-6 text-[#18181B]", + muted: "text-[16px] leading-6 text-[#71717B]", + }, + reflection: { + body: "text-[16px] leading-6 text-[#3F3F46]", + heading: "text-[18px] font-bold leading-6 text-[#27272A]", + muted: "text-[15px] leading-6 text-[#71717B]", + }, + report: { + body: "text-[16px] leading-6 text-[#52525B]", + heading: "text-[18px] font-bold leading-6 text-[#18181B]", + muted: "text-[15px] leading-6 text-[#71717B]", + }, +}; + +const safeTextStyle = { + flexShrink: 1, + includeFontPadding: true, + overflow: "visible", +} as const; + +export const AIResponseRenderer = memo(function AIResponseRenderer({ + accessibilityLabel, + content, + diagnosticLabel, + isStreaming = false, + selectable = true, + testID, + variant = "chat", +}: AIResponseRendererProps) { + useEffect(() => { + if (!diagnosticLabel) { + return; + } + + logAITextIntegrity({ + length: content.length, + stage: "render_source", + surface: diagnosticLabel, + }); + }, [content, diagnosticLabel]); + + return ( + + + + ); +}); + +function AIResponseContent({ + accessibilityLabel, + content, + isStreaming, + selectable, + testID, + variant, +}: Required< + Pick< + AIResponseRendererProps, + "content" | "isStreaming" | "selectable" | "variant" + > +> & + Pick) { + const blocks = useMemo( + () => + isStreaming + ? [{ content, type: "paragraph" } satisfies AITextBlock] + : parseAITextBlocks(content), + [content, isStreaming], + ); + + return ( + + {blocks.map((block, index) => ( + + ))} + + ); +} + +function AIResponseBlock({ + block, + index, + selectable, + variant, +}: { + block: AITextBlock; + index: number; + selectable: boolean; + variant: AIResponseVariant; +}) { + const classes = variantClasses[variant]; + + if (block.type === "code" || block.type === "table") { + const label = + block.type === "code" + ? block.language + ? `${block.language} code block` + : "Code block" + : "Table"; + + return ( + + + {label} + + + + {block.content} + + + + ); + } + + if (block.type === "heading") { + const headingClassName = + block.level === 1 + ? classes.heading + : block.level === 2 + ? `${classes.heading} text-[17px]` + : `${classes.heading} text-[16px]`; + + return ( + + ); + } + + if (block.type === "blockquote") { + return ( + + + + ); + } + + if (block.type === "list") { + return ( + + {block.items.map((item, itemIndex) => ( + + + {item.marker} + + + + ))} + + ); + } + + if (block.type === "horizontal-rule") { + return ; + } + + return ( + + ); +} + +function InlineMarkdownText({ + accessibilityRole, + className, + selectable, + style, + text, +}: { + accessibilityRole?: "header"; + className: string; + selectable: boolean; + style: TextStyle; + text: string; +}) { + const parts = useMemo(() => parseInlineMarkdown(text), [text]); + + return ( + + {parts.map((part, index) => { + if (part.type === "bold") { + return ( + + {addSafeBreakOpportunities(part.content)} + + ); + } + + if (part.type === "italic") { + return ( + + {addSafeBreakOpportunities(part.content)} + + ); + } + + if (part.type === "code") { + return ( + + {addSafeBreakOpportunities(part.content)} + + ); + } + + if (part.type === "link") { + return ( + openSafeLink(part.url)} + > + {addSafeBreakOpportunities(part.content)} + + ); + } + + return addSafeBreakOpportunities(part.content); + })} + + ); +} + +type InlinePart = + | { content: string; type: "bold" | "code" | "italic" | "text" } + | { content: string; type: "link"; url: string }; + +function parseInlineMarkdown(value: string): InlinePart[] { + const pattern = /(\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|`([^`\n]+)`|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*\n]+)\*|_([^_\n]+)_)/gi; + const parts: InlinePart[] = []; + let lastIndex = 0; + + for (const match of value.matchAll(pattern)) { + const matchIndex = match.index ?? 0; + + if (matchIndex > lastIndex) { + parts.push({ content: value.slice(lastIndex, matchIndex), type: "text" }); + } + + if (match[2] && match[3]) { + parts.push({ content: match[2], type: "link", url: match[3] }); + } else if (match[4]) { + parts.push({ content: match[4], type: "code" }); + } else if (match[5] || match[6]) { + parts.push({ content: match[5] ?? match[6], type: "bold" }); + } else { + parts.push({ content: match[7] ?? match[8] ?? match[0], type: "italic" }); + } + + lastIndex = matchIndex + match[0].length; + } + + if (lastIndex < value.length) { + parts.push({ content: value.slice(lastIndex), type: "text" }); + } + + return parts.length > 0 ? parts : [{ content: value, type: "text" }]; +} + +function openSafeLink(url: string) { + if (!/^https?:\/\//i.test(url)) { + return; + } + + void Linking.openURL(url).catch(() => undefined); +} + +class AIResponseErrorBoundary extends Component< + { + children: ReactNode; + content: string; + selectable: boolean; + variant: AIResponseVariant; + }, + { failed: boolean } +> { + state = { failed: false }; + + static getDerivedStateFromError() { + return { failed: true }; + } + + componentDidCatch(_error: Error, _info: ErrorInfo) { + if (__DEV__) { + console.warn("AI response rendering failed; using complete plain-text fallback."); + } + } + + componentDidUpdate(previousProps: Readonly<{ content: string }>) { + if (this.state.failed && previousProps.content !== this.props.content) { + this.setState({ failed: false }); + } + } + + render() { + if (this.state.failed) { + return ( + + {addSafeBreakOpportunities(this.props.content)} + + ); + } + + return this.props.children; + } +} diff --git a/components/auth/auth-screen.tsx b/components/auth/auth-screen.tsx index 2d8b129..8d15254 100644 --- a/components/auth/auth-screen.tsx +++ b/components/auth/auth-screen.tsx @@ -611,11 +611,11 @@ function SocialButtons({ source={images.googleLogo} contentFit="contain" accessibilityLabel="Google logo" - style={{ height: 16, width: 16 }} + className="size-4" /> )} - + Continue with Google diff --git a/components/insights/RecurringThemesCard.tsx b/components/insights/RecurringThemesCard.tsx index 8bb86d2..1b51d68 100644 --- a/components/insights/RecurringThemesCard.tsx +++ b/components/insights/RecurringThemesCard.tsx @@ -1,5 +1,6 @@ import { Text, View } from "react-native"; +import { addSafeBreakOpportunities } from "@/lib/text/add-safe-break-opportunities"; import type { ThemeFrequency } from "@/types/insights"; type RecurringThemesCardProps = { @@ -29,8 +30,11 @@ export function RecurringThemesCard({ themes }: RecurringThemesCardProps) { {themes.map((theme) => ( - - {theme.label} + + {addSafeBreakOpportunities(theme.label)} {theme.count} {theme.count === 1 ? "entry" : "entries"} diff --git a/components/insights/insights-screen.tsx b/components/insights/insights-screen.tsx index 26aff7b..acd3bb9 100644 --- a/components/insights/insights-screen.tsx +++ b/components/insights/insights-screen.tsx @@ -29,6 +29,7 @@ import Svg, { LinearGradient as SvgLinearGradient, } from "react-native-svg"; +import { AIResponseRenderer } from "@/components/ai/ai-response-renderer"; import { BottomTabBar, bottomTabBarBaseHeight, @@ -714,11 +715,7 @@ function getCompactInsightText(value: string) { } const sentenceMatch = normalizedText.match(/^.*?[.!?](?:\s|$)/); - const firstSentence = sentenceMatch?.[0].trim() ?? normalizedText; - - return firstSentence.length > 180 - ? `${firstSentence.slice(0, 177).trim()}...` - : firstSentence; + return sentenceMatch?.[0].trim() ?? normalizedText; } function getRecurringThemes( @@ -1164,9 +1161,13 @@ function InsightMessageCard({ card }: { card: InsightCard }) { {card.title} - - {card.body} - + + + ); } diff --git a/components/insights/report/MoodJourneyChart.tsx b/components/insights/report/MoodJourneyChart.tsx index 364321d..d5de2c7 100644 --- a/components/insights/report/MoodJourneyChart.tsx +++ b/components/insights/report/MoodJourneyChart.tsx @@ -1,5 +1,6 @@ import { ScrollView, Text, View } from "react-native"; +import { AIResponseRenderer } from "@/components/ai/ai-response-renderer"; import { formatMoodLabel, formatReportDate, @@ -95,13 +96,13 @@ export function MoodJourneyChart({ data, explanation }: MoodJourneyChartProps) { ))} - - {explanation} - + + + ); } diff --git a/components/insights/report/RecurringThemesChart.tsx b/components/insights/report/RecurringThemesChart.tsx index e95f7a4..ca622a6 100644 --- a/components/insights/report/RecurringThemesChart.tsx +++ b/components/insights/report/RecurringThemesChart.tsx @@ -1,6 +1,7 @@ import { Text, View } from "react-native"; import { reportColors } from "@/constants/report-theme"; +import { addSafeBreakOpportunities } from "@/lib/text/add-safe-break-opportunities"; import type { ThemeFrequencyItem } from "@/types/aiInsightReport"; type RecurringThemesChartProps = { @@ -28,10 +29,10 @@ export function RecurringThemesChart({ data }: RecurringThemesChartProps) { - {item.name} + {addSafeBreakOpportunities(item.name)} )} - - {item} - + + + ))} @@ -102,12 +103,11 @@ export function EmotionalFlowCard({ stages }: { stages: string[] }) { style={{ backgroundColor: reportColors.lavender }} > - {stage} + {addSafeBreakOpportunities(stage)} {index < stages.length - 1 ? ( @@ -144,14 +144,11 @@ export function PatternCards({ items }: { items: string[] }) { boxShadow: reportCardShadow, }} > - - {item} - + ))} @@ -186,17 +183,11 @@ export function NextFocusCard({ focus }: { focus: string }) { - {focus} + {addSafeBreakOpportunities(focus)} ); @@ -225,19 +216,13 @@ export function ReflectionPromptCard({ prompt }: { prompt: string | null }) { > Continue Reflecting - - “{prompt}” - + + + {message} diff --git a/components/journal-editor/entry-ai-reflection-card.tsx b/components/journal-editor/entry-ai-reflection-card.tsx index c348836..c154975 100644 --- a/components/journal-editor/entry-ai-reflection-card.tsx +++ b/components/journal-editor/entry-ai-reflection-card.tsx @@ -2,6 +2,8 @@ import { RefreshCw, Sparkles } from "lucide-react-native"; import type { ReactNode } from "react"; import { ActivityIndicator, Pressable, Text, View } from "react-native"; +import { AIResponseRenderer } from "@/components/ai/ai-response-renderer"; +import { addSafeBreakOpportunities } from "@/lib/text/add-safe-break-opportunities"; import type { EntryAIReflection } from "@/types/entryReflection"; const colors = { @@ -9,14 +11,6 @@ const colors = { chipBackground: "#FFE8F0", mutedText: "#71717B", }; -const bodyTextStyle = { - flexShrink: 1, - flexWrap: "wrap", - includeFontPadding: true, - overflow: "visible", - paddingBottom: 8, - paddingTop: 3, -} as const; const smallTextStyle = { flexShrink: 1, flexWrap: "wrap", @@ -102,6 +96,7 @@ export function EntryAIReflectionCard({ {error ? ( Could not generate this reflection. {error} @@ -180,6 +175,7 @@ export function EntryAIReflectionCard({ {error ? ( Could not update this reflection. Your previous reflection is still @@ -220,13 +216,13 @@ function ReflectionTextSection({ {title} - - {value} - + + + ); } @@ -256,9 +252,10 @@ function ReflectionChipSection({ > - {item} + {addSafeBreakOpportunities(item)} ))} diff --git a/docs/dynamic-ai-text-rendering-audit.md b/docs/dynamic-ai-text-rendering-audit.md new file mode 100644 index 0000000..be2d3fd --- /dev/null +++ b/docs/dynamic-ai-text-rendering-audit.md @@ -0,0 +1,106 @@ +# Dynamic AI Text Rendering Audit + +## Affected surfaces + +| Surface | Component | Data source | Renderer | Primary vertical scroll | Clipping or truncation risk | +|---|---|---|---|---|---| +| AI Chat assistant messages | `components/ai-chat/ai-chat-screen.tsx` | `journal-ai-chat` or local fallback → Zustand/AsyncStorage | Plain React Native `Text` | Chat `ScrollView` | No Markdown structure, assistant selection disabled, long tokens unsafe, unconditional scroll-to-end | +| AI Chat user messages | `components/ai-chat/ai-chat-screen.tsx` | Local input → Zustand/AsyncStorage | Plain React Native `Text` | Chat `ScrollView` | Long tokens unsafe | +| Entry AI reflection | `components/journal-editor/entry-ai-reflection-card.tsx` | `reflect-on-entry` → Supabase `text` columns → Zustand/AsyncStorage | Separate plain `Text` fields | Journal editor `ScrollView` | Backend prose fields truncated to 220 characters and line breaks collapsed | +| Weekly report | `app/insights/report/[periodType].tsx` and report components | `generate-insight-report` → Supabase `jsonb` → Zustand/AsyncStorage | Separate plain `Text` fields | Report `ScrollView` | Backend narrative strings and arrays truncated; dynamic prose disables font scaling and selection | +| Monthly report | `app/insights/report/[periodType].tsx` and report components | `generate-insight-report` → Supabase `jsonb` → Zustand/AsyncStorage | Separate plain `Text` fields | Report `ScrollView` | Same report risks as weekly reports | +| Insights summary cards | `components/insights/insights-screen.tsx` | Cached weekly/monthly report | Plain React Native `Text` | Insights `ScrollView` | Overview reduced with an arbitrary 180-character slice; no structured rendering | +| Recurring AI themes | `components/insights/RecurringThemesCard.tsx` and report charts | Report analytics | Plain React Native `Text` | Owning screen `ScrollView` | Font scaling disabled in report variants; long labels need flexible wrapping | +| AI errors and retained-result notices | Reflection/report/chat state components | Service errors and cached state | Plain React Native `Text` | Owning screen scroll | Natural height already used, but important error text is not consistently selectable | + +## Data truncation risks + +- `reflect-on-entry` truncated every generated prose field to 220 characters and collapsed all whitespace. +- `generate-insight-report` truncated overview, emotional journey, focus, prompt, and every narrative array item; it also sliced generated arrays to fixed item counts. +- `journal-ai-chat` did not inspect `finish_reason`, so a provider response ending because of its output limit could be presented as complete. +- Chat history intentionally limits context sent back to the model. This does not mutate or truncate the stored/rendered message. +- Entry and report source material is intentionally bounded for provider context. Those context limits are documented by the functions and are distinct from truncating generated output. + +## Visual clipping risks + +- The chat calls `scrollToEnd()` after every content-size change, forcing a reader away from older content as the document changes height. +- Assistant chat text explicitly disables selection. +- AI surfaces use separate one-off text styles and have no safe handling for long URLs or unbroken tokens. +- Report narrative text opts out of system font scaling with `allowFontScaling={false}` and `maxFontSizeMultiplier={1}`. +- No full-response surface currently has a fixed prose height, `numberOfLines`, or `ellipsizeMode`. Fixed chart and composer heights are intentional and are not prose containers. + +## Markdown rendering + +No Markdown library is installed. AI output is currently displayed as raw plain text, so headings, lists, blockquotes, links, inline code, code fences, and tables have no dedicated layout behavior. + +## Chat streaming + +The current chat implementation is non-streaming. It performs one Supabase Edge Function request and stores one complete assistant message. Streaming chunk loss, stale chunk assembly, and streaming memoization defects are therefore not applicable to the current code. The chat still needs variable-height-safe scrolling behavior for long non-streamed messages and future growth. + +## Variable-height lists + +Chat uses a single `ScrollView`, not `FlatList`; there is no `getItemLayout`, `removeClippedSubviews`, or fixed message measurement. Message keys use stable stored IDs. The primary defect is forced scrolling on every content-size change. + +## Font metrics + +Chat and reflection body styles use `includeFontPadding: true`, which is safe for Android accents, emoji, and mixed scripts. Report narrative styles also include font padding but disable font scaling. Dynamic prose should retain safe padding and allow system scaling with line heights that can grow naturally. + +## Long-token handling + +No presentation-only safe-break helper exists. Long raw URLs and unusually long unbroken tokens can overflow narrow Android layouts. Stored source strings must remain unchanged while display text receives safe break opportunities. + +## Accessibility + +- Assistant chat text is currently not selectable. +- Report narrative and AI insight text is not selectable. +- Report narrative content opts out of user font scaling. +- The existing screen-level scroll owners and App Lock boundary remain appropriate and must not be changed. + +## Confirmed root causes + +| ID | Surface | Symptom | Root cause | Data complete? | Fix | Status | +|---|---|---|---|---:|---|---| +| CHAT-01 | AI Chat | Reader is forced to the latest message after remeasurement | Unconditional `scrollToEnd` in `onContentSizeChange` | Yes | Follow only while already near bottom | Retest required | +| CHAT-02 | AI Chat | Markdown is raw and long tokens can overflow | One plain `Text` renderer with no block handling | Yes | Shared natural-height AI renderer | Retest required | +| CHAT-03 | AI Chat | Provider-limit completion can look complete | `finish_reason` ignored | No | Preserve and label partial content when output limit is reached | Fixed | +| REF-01 | Entry reflection | Generated prose ends early | 220-character output sanitizer and prompt restriction | No | Preserve complete validated strings | Fixed | +| REF-02 | Entry reflection | Paragraph and Markdown structure disappears | Whitespace collapsed with `replace(/\\s+/g, " ")` | No | Trim boundaries only; preserve internal whitespace | Fixed | +| REPORT-01 | Weekly/monthly reports | Narrative fields and lists end early | Output string truncation and fixed array slicing | No | Preserve every valid generated field and item | Fixed | +| REPORT-02 | Weekly/monthly reports | Large system text is unavailable | Font scaling disabled on dynamic narrative | Yes | Shared scaling, selectable renderer | Retest required | +| REPORT-03 | Weekly/monthly reports | Generation returns `invalid_ai_response` with HTTP 502 | Deployed parser truncated required fields and then rejected cuts ending in connector words or punctuation | No report saved | Parse without output truncation, normalize empty optional fields, recover wrapped JSON, retry malformed JSON once | Fixed | +| INSIGHT-01 | AI summary card | Overview is shortened mid-thought | 180-character compact-card slice | No in card | Keep a complete first-sentence summary without character slicing; full report remains linked | Fixed | +| DB-01 | Reflection/report persistence | Possible database clipping | Audited schemas use PostgreSQL `text` and `jsonb`, with no `varchar` cap | Yes | No migration required | Fixed | +| STREAM-01 | AI Chat | Chunk loss or stale assembly | No streaming path exists | N/A | Document as not applicable | Fixed | + +## Fixes implemented + +- Added a shared native AI response renderer for chat, reflection, report, and insight variants. +- Added natural-height paragraphs, headings, lists, blockquotes, inline emphasis/code, safe links, code fences, and table fallback rendering. +- Code and table blocks own horizontal scrolling only; the screen remains the sole vertical scroll owner. +- Added display-only safe break opportunities for long URLs and unbroken tokens without mutating stored source strings. +- Added selectable text, safe Android font padding, system font scaling, and a complete plain-text rendering fallback. +- Replaced unconditional chat auto-scroll with near-bottom tracking and a `Jump to latest` action. +- Added explicit partial chat-response persistence and labeling when the provider reports `finish_reason: length`. +- Removed reflection prose character truncation, output array slicing, and internal whitespace collapse. +- Removed report narrative character truncation and fixed output-array slicing; invalid generated arrays now reject the report instead of silently dropping items. +- Replaced the report parser that could reject its own truncated output. Long required fields and complete arrays are preserved, empty optional strings normalize to `null`, fenced/wrapped JSON is recovered, and malformed responses receive one schema-correction retry. +- Added safe parser diagnostics containing only the attempt, character count, and invalid field reason—never report or journal text. +- Removed the insight-card 180-character slice. The dashboard intentionally uses the first complete sentence while the existing report route exposes the complete report. +- Added development-only character-count diagnostics for validated, stored, and rendered source values. Server functions log counts and finish reasons without logging private text. +- Confirmed reflection persistence uses PostgreSQL `text` and reports use `jsonb`; no database migration is needed. +- Added long prose, very-long prose, Markdown, code, table, Unicode/Hindi, long-token, report, and irregular-chunk fixtures plus pure rendering-helper assertions. +- Deployed `generate-insight-report` version 7 with `verify_jwt: false`, matching the project’s Clerk third-party authentication architecture. + +## Intentional remaining summaries and limits + +- The Insights dashboard shows one complete overview sentence and links to the existing full report. It no longer cuts that sentence by character count. +- Compact analytics cards show top themes or first report items by design; full narrative arrays remain available on the report detail screen. +- Chat history, journal entries, and report source entries are bounded only when constructing provider input context. Stored journal data and generated response output are not modified by those context limits. +- Local fallback search results use deliberate journal-entry previews. They do not truncate a stored assistant response after generation. + +## Remaining limitations + +- Physical Android, TalkBack, lifecycle, orientation, and system-font matrix testing require a device and cannot be claimed from this repository-only environment. +- Live provider, Supabase persistence, backgrounding, process-kill, and account-switch tests were not run in this repository-only pass. +- Runtime scroll and parse performance on 25,000-character documents still needs device profiling. Pure parsing and integrity assertions passed with a 15,733-character fixture. +- Provider context limits remain necessary to fit model input windows. They affect analysis source breadth, not storage or rendering of generated output, and are surfaced through existing data-quality metadata where applicable. diff --git a/docs/dynamic-ai-text-test-matrix.md b/docs/dynamic-ai-text-test-matrix.md new file mode 100644 index 0000000..33355be --- /dev/null +++ b/docs/dynamic-ai-text-test-matrix.md @@ -0,0 +1,26 @@ +# Dynamic AI Text Test Matrix + +| ID | Surface | Content type | Length | Device | Font size | Expected | Actual | Status | +|---|---|---|---:|---|---|---|---|---| +| CHAT-01 | AI Chat | Short prose | 1 paragraph | Physical Android | Default | Full selectable text | Device required | Blocked | +| CHAT-02 | AI Chat | Long prose | 2,000+ | Physical Android | Default | Final line reachable | Device required | Blocked | +| CHAT-03 | AI Chat | Very long prose | 10,000+ | Physical Android | Large | Full response grows naturally | Device required | Blocked | +| CHAT-04 | AI Chat | Markdown and lists | Mixed | Physical Android | Large | Structure wraps without clipping | Device required | Blocked | +| CHAT-05 | AI Chat | Code and table | Wide | Physical Android | Default | Only wide blocks scroll horizontally | Device required | Blocked | +| CHAT-06 | AI Chat | Unicode and Hindi | Mixed | Physical Android | Maximum practical | Glyphs and emoji remain visible | Device required | Blocked | +| CHAT-07 | AI Chat | Long URL/token | 100+ token | Narrow Android | Large | Page width remains stable | Device required | Blocked | +| CHAT-08 | AI Chat | Scroll behavior | Multiple long messages | Physical Android | Default | Older reading position is not forced down | Device required | Blocked | +| CHAT-09 | AI Chat | Restart/offline restore | 10,000+ | Physical Android | Default | Restored source matches saved source | Device required | Blocked | +| REF-01 | Entry reflection | Long structured reflection | 2,000+ total | Physical Android | Large | Parent editor scroll reaches final line | Device required | Blocked | +| REF-02 | Entry reflection | Markdown/Unicode | Mixed | Narrow Android | Maximum practical | Complete readable sections | Device required | Blocked | +| REF-03 | Entry reflection | Failed regeneration | Existing long result | Physical Android | Default | Previous result remains visible | Device required | Blocked | +| REPORT-01 | Weekly report | Every narrative section | 10,000+ total | Physical Android | Large | Final section and line reachable | Device required | Blocked | +| REPORT-02 | Monthly report | Charts plus long narrative | 10,000+ total | Narrow Android | Maximum practical | Chart height does not constrain prose | Device required | Blocked | +| REPORT-03 | Report | Cached/offline restore | 10,000+ total | Physical Android | Default | Restored report matches saved source | Device required | Blocked | +| A11Y-01 | All AI surfaces | TalkBack and selection | Mixed | Physical Android | Large | Full content exposed and selectable | Device required | Blocked | +| STATIC-01 | Shared parser/helpers | Plain, Markdown, code, table, Unicode, long tokens, chunks | 15,733 | Repository checks | N/A | No content loss in pure assertions | Assertions passed | Passed | +| STATIC-02 | Application | TypeScript strict check | N/A | Repository checks | N/A | No type errors | `npx tsc --noEmit` passed | Passed | +| STATIC-03 | Application | Expo lint | N/A | Repository checks | N/A | No lint errors | `npm run lint` passed | Passed | +| STATIC-04 | Edge Functions | ESLint syntax/static pass | N/A | Repository checks | N/A | No lint errors | Three changed functions passed | Passed | +| STATIC-05 | Report parser | Long fields, 10-item array, empty optionals, fenced/wrapped JSON | 1,000+ | Repository checks | N/A | Preserve all content and return safe invalid-field reasons | Assertions passed | Passed | +| HOSTED-01 | Report Edge Function | Invalid authentication probe | N/A | Supabase version 7 | N/A | Reachable and reject malformed JWT | HTTP 401 `invalid_jwt` | Passed | diff --git a/lib/ai/entryReflectionService.ts b/lib/ai/entryReflectionService.ts index f2e18c6..48c42b2 100644 --- a/lib/ai/entryReflectionService.ts +++ b/lib/ai/entryReflectionService.ts @@ -8,6 +8,8 @@ import { isFaultEnabled, throwIfFaultEnabled, } from "@/lib/dev/faultInjection"; +import { getEntryReflectionTextLength } from "@/lib/ai/get-ai-text-length"; +import { logAITextIntegrity } from "@/lib/ai/log-ai-text-integrity"; import { getAuthenticatedSupabaseClient, SupabaseConfigurationError, @@ -93,6 +95,12 @@ export const generateEntryReflection = async (params: { ); } + logAITextIntegrity({ + length: getEntryReflectionTextLength(data.reflection), + stage: "validated", + surface: "entry_reflection", + }); + return data.reflection; }; diff --git a/lib/ai/get-ai-text-length.ts b/lib/ai/get-ai-text-length.ts new file mode 100644 index 0000000..ae4e959 --- /dev/null +++ b/lib/ai/get-ai-text-length.ts @@ -0,0 +1,40 @@ +import type { AIInsightReport } from "@/types/aiInsightReport"; +import type { EntryAIReflection } from "@/types/entryReflection"; + +export function getEntryReflectionTextLength(reflection: EntryAIReflection) { + return getCombinedTextLength([ + reflection.summary, + ...reflection.emotions, + ...reflection.themes, + reflection.observation, + reflection.followUpQuestion, + reflection.suggestion, + ]); +} + +export function getAIInsightReportTextLength(report: AIInsightReport) { + const narrative = report.narrative; + + return getCombinedTextLength([ + narrative.overview, + ...narrative.activities, + narrative.emotionalJourney, + ...narrative.emotionalFlow, + ...narrative.enjoyed, + ...narrative.challenges, + ...narrative.wins, + ...narrative.patterns, + ...narrative.improvements, + narrative.nextFocus, + narrative.reflectionPrompt, + narrative.dataQualityNote, + ]); +} + +function getCombinedTextLength(values: (string | null)[]) { + return values.reduce( + (total, value) => total + (typeof value === "string" ? value.length : 0), + 0, + ); +} + diff --git a/lib/ai/log-ai-text-integrity.ts b/lib/ai/log-ai-text-integrity.ts new file mode 100644 index 0000000..6095cb4 --- /dev/null +++ b/lib/ai/log-ai-text-integrity.ts @@ -0,0 +1,26 @@ +type AITextIntegrityStage = + | "received" + | "validated" + | "stored" + | "render_source"; + +export function logAITextIntegrity({ + length, + stage, + surface, +}: { + length: number; + stage: AITextIntegrityStage; + surface: string; +}) { + if (!__DEV__) { + return; + } + + console.info("AI text integrity", { + characterCount: length, + stage, + surface, + }); +} + diff --git a/lib/ai/remoteJournalAssistant.ts b/lib/ai/remoteJournalAssistant.ts index ba995ca..aae3a28 100644 --- a/lib/ai/remoteJournalAssistant.ts +++ b/lib/ai/remoteJournalAssistant.ts @@ -8,6 +8,7 @@ import { isFaultEnabled, throwIfFaultEnabled, } from "@/lib/dev/faultInjection"; +import { logAITextIntegrity } from "@/lib/ai/log-ai-text-integrity"; import { getAuthenticatedSupabaseClient } from "@/lib/supabase"; import type { ClientContext } from "@/lib/ai/chatIntent"; @@ -18,6 +19,7 @@ export type RemoteJournalMessage = { }; export type RemoteJournalResponse = { + isPartial?: boolean; message: string; relatedEntryIds: string[]; source: "remote_ai"; @@ -84,6 +86,12 @@ export const generateRemoteJournalResponse = async (params: { ); } + logAITextIntegrity({ + length: data.message.length, + stage: "validated", + surface: "ai_chat_message", + }); + return data; }; @@ -97,6 +105,7 @@ function isRemoteJournalResponse( return ( typeof value.message === "string" && value.message.trim().length > 0 && + (value.isPartial === undefined || typeof value.isPartial === "boolean") && Array.isArray(value.relatedEntryIds) && value.relatedEntryIds.every((entryId) => typeof entryId === "string") && value.source === "remote_ai" diff --git a/lib/insights/aiInsightReportService.ts b/lib/insights/aiInsightReportService.ts index c90a926..f3c5279 100644 --- a/lib/insights/aiInsightReportService.ts +++ b/lib/insights/aiInsightReportService.ts @@ -8,6 +8,8 @@ import { isFaultEnabled, throwIfFaultEnabled, } from "@/lib/dev/faultInjection"; +import { getAIInsightReportTextLength } from "@/lib/ai/get-ai-text-length"; +import { logAITextIntegrity } from "@/lib/ai/log-ai-text-integrity"; import { isAIInsightReport, mapAIInsightReportRow, @@ -128,6 +130,12 @@ export async function generateAIInsightReport(params: { ); } + logAITextIntegrity({ + length: getAIInsightReportTextLength(data.report), + stage: "validated", + surface: `${data.report.periodType}_insight_report`, + }); + return data.report; } diff --git a/lib/text/add-safe-break-opportunities.ts b/lib/text/add-safe-break-opportunities.ts new file mode 100644 index 0000000..1da47c3 --- /dev/null +++ b/lib/text/add-safe-break-opportunities.ts @@ -0,0 +1,46 @@ +const zeroWidthSpace = "\u200B"; +const longTokenThreshold = 32; +const fallbackChunkLength = 24; +const preferredBreakCharacters = new Set([ + "/", + "?", + "&", + "=", + "#", + ".", + "-", + "_", + ":", +]); + +export function addSafeBreakOpportunities(value: string) { + return value.replace(/\S{32,}/gu, (token) => addBreaksToToken(token)); +} + +function addBreaksToToken(token: string) { + const characters = Array.from(token); + let charactersSinceBreak = 0; + + return characters + .map((character) => { + charactersSinceBreak += 1; + + if (preferredBreakCharacters.has(character)) { + charactersSinceBreak = 0; + return `${character}${zeroWidthSpace}`; + } + + if (charactersSinceBreak >= fallbackChunkLength) { + charactersSinceBreak = 0; + return `${character}${zeroWidthSpace}`; + } + + return character; + }) + .join(""); +} + +export function needsSafeBreakOpportunities(value: string) { + return new RegExp(`\\S{${longTokenThreshold},}`, "u").test(value); +} + diff --git a/lib/text/parse-ai-markdown.ts b/lib/text/parse-ai-markdown.ts new file mode 100644 index 0000000..6a615bc --- /dev/null +++ b/lib/text/parse-ai-markdown.ts @@ -0,0 +1,177 @@ +export type AITextBlock = + | { content: string; type: "blockquote" } + | { content: string; language: string | null; type: "code" } + | { content: string; level: 1 | 2 | 3; type: "heading" } + | { type: "horizontal-rule" } + | { items: AITextListItem[]; type: "list" } + | { content: string; type: "paragraph" } + | { content: string; type: "table" }; + +export type AITextListItem = { + content: string; + depth: number; + marker: string; +}; + +export function parseAITextBlocks(content: string): AITextBlock[] { + const normalizedContent = content.replace(/\r\n?/g, "\n"); + const lines = normalizedContent.split("\n"); + const blocks: AITextBlock[] = []; + let index = 0; + + while (index < lines.length) { + const line = lines[index]; + + if (!line.trim()) { + index += 1; + continue; + } + + const codeFence = line.match(/^\s*```([^`]*)$/); + + if (codeFence) { + const codeLines: string[] = []; + index += 1; + + while (index < lines.length && !/^\s*```\s*$/.test(lines[index])) { + codeLines.push(lines[index]); + index += 1; + } + + if (index < lines.length) { + index += 1; + } + + blocks.push({ + content: codeLines.join("\n"), + language: codeFence[1]?.trim() || null, + type: "code", + }); + continue; + } + + const heading = line.match(/^\s*(#{1,3})\s+(.+)$/); + + if (heading) { + blocks.push({ + content: heading[2].trim(), + level: heading[1].length as 1 | 2 | 3, + type: "heading", + }); + index += 1; + continue; + } + + if (/^\s*((\*\s*){3,}|(-\s*){3,}|(_\s*){3,})\s*$/.test(line)) { + blocks.push({ type: "horizontal-rule" }); + index += 1; + continue; + } + + if (isTableStart(lines, index)) { + const tableLines = [line, lines[index + 1]]; + index += 2; + + while (index < lines.length && lines[index].includes("|")) { + tableLines.push(lines[index]); + index += 1; + } + + blocks.push({ content: tableLines.join("\n"), type: "table" }); + continue; + } + + if (/^\s*>/.test(line)) { + const quoteLines: string[] = []; + + while (index < lines.length && /^\s*>/.test(lines[index])) { + quoteLines.push(lines[index].replace(/^\s*>\s?/, "")); + index += 1; + } + + blocks.push({ content: quoteLines.join("\n"), type: "blockquote" }); + continue; + } + + if (getListItem(line)) { + const items: AITextListItem[] = []; + + while (index < lines.length) { + const item = getListItem(lines[index]); + + if (!item) { + break; + } + + items.push(item); + index += 1; + } + + blocks.push({ items, type: "list" }); + continue; + } + + const paragraphLines = [line]; + index += 1; + + while ( + index < lines.length && + lines[index].trim() && + !isBlockStart(lines, index) + ) { + paragraphLines.push(lines[index]); + index += 1; + } + + blocks.push({ content: paragraphLines.join("\n"), type: "paragraph" }); + } + + return blocks.length > 0 + ? blocks + : [{ content: normalizedContent, type: "paragraph" }]; +} + +function getListItem(line: string): AITextListItem | null { + const match = line.match(/^(\s*)([-+*]|\d+[.)])\s+(.+)$/); + + if (!match) { + return null; + } + + return { + content: match[3], + depth: Math.min(3, Math.floor(match[1].replace(/\t/g, " ").length / 2)), + marker: /^\d/.test(match[2]) ? match[2] : "•", + }; +} + +function isBlockStart(lines: string[], index: number) { + const line = lines[index]; + + return ( + /^\s*```/.test(line) || + /^\s*#{1,3}\s+/.test(line) || + /^\s*>/.test(line) || + /^\s*((\*\s*){3,}|(-\s*){3,}|(_\s*){3,})\s*$/.test(line) || + Boolean(getListItem(line)) || + isTableStart(lines, index) + ); +} + +function isTableStart(lines: string[], index: number) { + const header = lines[index]; + const separator = lines[index + 1]; + + if (!header?.includes("|") || !separator?.includes("|")) { + return false; + } + + const cells = separator + .trim() + .replace(/^\||\|$/g, "") + .split("|") + .map((cell) => cell.trim()); + + return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell)); +} + diff --git a/lib/validation/persistedDataValidators.ts b/lib/validation/persistedDataValidators.ts index fea7fd1..2de885f 100644 --- a/lib/validation/persistedDataValidators.ts +++ b/lib/validation/persistedDataValidators.ts @@ -192,6 +192,10 @@ function normalizePersistedChatMessage(value: unknown): ChatMessage | null { return null; } + if (value.isPartial !== undefined && typeof value.isPartial !== "boolean") { + return null; + } + if ( value.source !== undefined && (typeof value.source !== "string" || @@ -204,6 +208,7 @@ function normalizePersistedChatMessage(value: unknown): ChatMessage | null { content: value.content, createdAt: value.createdAt, id: value.id, + isPartial: value.isPartial as boolean | undefined, relatedEntryIds: value.relatedEntryIds as string[] | undefined, role: value.role as ChatMessageRole, source: value.source as ChatMessageSource | undefined, diff --git a/store/useAIInsightReportStore.ts b/store/useAIInsightReportStore.ts index 75ad405..6646b32 100644 --- a/store/useAIInsightReportStore.ts +++ b/store/useAIInsightReportStore.ts @@ -2,6 +2,8 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { normalizeAppError } from "@/lib/errors/normalizeAppError"; +import { getAIInsightReportTextLength } from "@/lib/ai/get-ai-text-length"; +import { logAITextIntegrity } from "@/lib/ai/log-ai-text-integrity"; import { isAIInsightReport } from "@/lib/insights/aiInsightReportMapper"; import { createPersistStorage } from "@/lib/storage/createPersistStorage"; import type { AppError } from "@/types/appError"; @@ -73,6 +75,12 @@ export const useAIInsightReportStore = create()( return state; } + logAITextIntegrity({ + length: getAIInsightReportTextLength(report), + stage: "stored", + surface: `${report.periodType}_insight_report`, + }); + return { reportsByUser: { ...state.reportsByUser, diff --git a/store/useChatStore.ts b/store/useChatStore.ts index 1fc501a..8036add 100644 --- a/store/useChatStore.ts +++ b/store/useChatStore.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { normalizeAppError } from "@/lib/errors/normalizeAppError"; +import { logAITextIntegrity } from "@/lib/ai/log-ai-text-integrity"; import { createPersistStorage } from "@/lib/storage/createPersistStorage"; import { normalizePersistedChatMessages } from "@/lib/validation/persistedDataValidators"; import type { AppError } from "@/types/appError"; @@ -51,10 +52,17 @@ function sortMessagesByDate(messages: ChatMessage[]) { export const useChatStore = create()( persist( (set, get) => ({ - addMessage: (message) => + addMessage: (message) => { + logAITextIntegrity({ + length: message.content.length, + stage: "stored", + surface: `ai_chat_${message.role}`, + }); + set((state) => ({ messages: [...state.messages, message], - })), + })); + }, clearMessagesForUser: (userId) => set((state) => ({ hydrationError: null, diff --git a/store/useEntryReflectionStore.ts b/store/useEntryReflectionStore.ts index 5d82585..8694591 100644 --- a/store/useEntryReflectionStore.ts +++ b/store/useEntryReflectionStore.ts @@ -2,6 +2,8 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { normalizeAppError } from "@/lib/errors/normalizeAppError"; +import { getEntryReflectionTextLength } from "@/lib/ai/get-ai-text-length"; +import { logAITextIntegrity } from "@/lib/ai/log-ai-text-integrity"; import { createPersistStorage } from "@/lib/storage/createPersistStorage"; import { isNonEmptyString, @@ -72,6 +74,12 @@ export const useEntryReflectionStore = create()( })), upsertReflection: (reflection) => set((state) => { + logAITextIntegrity({ + length: getEntryReflectionTextLength(reflection), + stage: "stored", + surface: "entry_reflection", + }); + const nextReflections = state.reflections.filter( (currentReflection) => currentReflection.userId !== reflection.userId || diff --git a/supabase/functions/_shared/parseReportNarrative.ts b/supabase/functions/_shared/parseReportNarrative.ts new file mode 100644 index 0000000..317618a --- /dev/null +++ b/supabase/functions/_shared/parseReportNarrative.ts @@ -0,0 +1,185 @@ +export type ReportNarrative = { + overview: string; + activities: string[]; + emotionalJourney: string; + emotionalFlow: string[]; + enjoyed: string[]; + challenges: string[]; + wins: string[]; + patterns: string[]; + improvements: string[]; + nextFocus: string; + reflectionPrompt: string | null; + dataQualityNote: string | null; +}; + +export type ReportNarrativeParseResult = + | { narrative: ReportNarrative; ok: true } + | { ok: false; reason: string }; + +export function parseReportNarrative( + value: string, + dataWasCapped: boolean, +): ReportNarrativeParseResult { + const parsedValue = parseJsonObjectFromText(value); + + if (!parsedValue) { + return { ok: false, reason: "invalid_json" }; + } + + const overview = getRequiredString(parsedValue.overview); + const emotionalJourney = getRequiredString(parsedValue.emotionalJourney); + const nextFocus = getRequiredString(parsedValue.nextFocus); + const activities = getStringArray(parsedValue.activities); + const challenges = getStringArray(parsedValue.challenges); + const emotionalFlow = getStringArray(parsedValue.emotionalFlow); + const enjoyed = getStringArray(parsedValue.enjoyed); + const improvements = getStringArray(parsedValue.improvements); + const patterns = getStringArray(parsedValue.patterns); + const wins = getStringArray(parsedValue.wins); + const reflectionPrompt = getNullableString(parsedValue.reflectionPrompt); + const providerDataQualityNote = getNullableString( + parsedValue.dataQualityNote, + ); + + if (!overview) { + return { ok: false, reason: "invalid_overview" }; + } + + if (!emotionalJourney) { + return { ok: false, reason: "invalid_emotional_journey" }; + } + + if (!nextFocus) { + return { ok: false, reason: "invalid_next_focus" }; + } + + if (!activities) { + return { ok: false, reason: "invalid_activities" }; + } + + if (!challenges) { + return { ok: false, reason: "invalid_challenges" }; + } + + if (!emotionalFlow) { + return { ok: false, reason: "invalid_emotional_flow" }; + } + + if (!enjoyed) { + return { ok: false, reason: "invalid_enjoyed" }; + } + + if (!improvements) { + return { ok: false, reason: "invalid_improvements" }; + } + + if (!patterns) { + return { ok: false, reason: "invalid_patterns" }; + } + + if (!wins) { + return { ok: false, reason: "invalid_wins" }; + } + + if (!reflectionPrompt.ok) { + return { ok: false, reason: "invalid_reflection_prompt" }; + } + + if (!providerDataQualityNote.ok) { + return { ok: false, reason: "invalid_data_quality_note" }; + } + + return { + narrative: { + activities, + challenges, + dataQualityNote: dataWasCapped + ? "This report was generated from the first 250 entries in the selected period." + : providerDataQualityNote.value, + emotionalFlow, + emotionalJourney, + enjoyed, + improvements, + nextFocus, + overview, + patterns, + reflectionPrompt: reflectionPrompt.value, + wins, + }, + ok: true, + }; +} + +function parseJsonObjectFromText(value: string) { + const cleanedValue = stripJsonCodeFence(value); + + try { + const parsedValue: unknown = JSON.parse(cleanedValue); + + return isRecord(parsedValue) ? parsedValue : null; + } catch { + const objectStartIndex = cleanedValue.indexOf("{"); + const objectEndIndex = cleanedValue.lastIndexOf("}"); + + if (objectStartIndex < 0 || objectEndIndex <= objectStartIndex) { + return null; + } + + try { + const parsedValue: unknown = JSON.parse( + cleanedValue.slice(objectStartIndex, objectEndIndex + 1), + ); + + return isRecord(parsedValue) ? parsedValue : null; + } catch { + return null; + } + } +} + +function stripJsonCodeFence(value: string) { + const trimmedValue = value.trim(); + const fenceMatch = trimmedValue.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i); + + return fenceMatch?.[1]?.trim() ?? trimmedValue; +} + +function getRequiredString(value: unknown) { + if (typeof value !== "string") { + return null; + } + + const trimmedValue = value.trim(); + + return trimmedValue || null; +} + +function getNullableString(value: unknown) { + if (value === null || value === undefined) { + return { ok: true as const, value: null }; + } + + if (typeof value !== "string") { + return { ok: false as const }; + } + + return { ok: true as const, value: value.trim() || null }; +} + +function getStringArray(value: unknown) { + if (!Array.isArray(value)) { + return null; + } + + if (!value.every((item) => typeof item === "string")) { + return null; + } + + return value.map((item) => item.trim()).filter(Boolean); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + diff --git a/supabase/functions/generate-insight-report/index.ts b/supabase/functions/generate-insight-report/index.ts index 507f6a8..60b4e6d 100644 --- a/supabase/functions/generate-insight-report/index.ts +++ b/supabase/functions/generate-insight-report/index.ts @@ -5,6 +5,10 @@ import { type JournalEntryRow, type ReportAnalytics, } from "../_shared/buildReportAnalytics.ts"; +import { + parseReportNarrative, + type ReportNarrative, +} from "../_shared/parseReportNarrative.ts"; const corsHeaders = { "Access-Control-Allow-Headers": @@ -75,33 +79,8 @@ const maxEntryContentLength = 1200; const maxTitleLength = 200; const maxPromptLength = 300; const maxTagsPerEntry = 10; +const maxNarrativeAttempts = 2; const reportFormatVersion = 2; -const danglingEndingWords = new Set([ - "a", - "about", - "an", - "and", - "as", - "at", - "because", - "being", - "but", - "by", - "for", - "from", - "if", - "in", - "into", - "of", - "on", - "or", - "that", - "the", - "to", - "which", - "while", - "with", -]); type AIInsightPeriodType = "weekly" | "monthly"; @@ -113,21 +92,6 @@ type GenerateInsightReportRequest = { timezone?: string; }; -type ReportNarrative = { - overview: string; - activities: string[]; - emotionalJourney: string; - emotionalFlow: string[]; - enjoyed: string[]; - challenges: string[]; - wins: string[]; - patterns: string[]; - improvements: string[]; - nextFocus: string; - reflectionPrompt: string | null; - dataQualityNote: string | null; -}; - type AIInsightReportRow = { created_at: string; format_version: number | null; @@ -185,6 +149,15 @@ Deno.serve(async (request) => { ); } + const claims = parseJwtClaims(bearerToken); + + if (!claims?.sub) { + return jsonResponse( + { code: "invalid_jwt", error: "Authentication is invalid.", requestId }, + 401, + ); + } + const parsedRequest = await parseRequest(request); if (!parsedRequest.ok) { @@ -225,16 +198,6 @@ Deno.serve(async (request) => { }, }, }); - const authResult = await supabase.auth.getUser(bearerToken); - const userId = authResult.data.user?.id; - - if (authResult.error || !userId) { - return jsonResponse( - { code: "invalid_jwt", error: "Authentication is invalid.", requestId }, - 401, - ); - } - const reportRequest = parsedRequest.data; const periodStart = new Date(reportRequest.periodStart); const periodEnd = new Date(reportRequest.periodEnd); @@ -423,7 +386,7 @@ Deno.serve(async (request) => { ? "Weekly Reflection" : "Monthly Reflection", updated_at: now, - user_id: userId, + user_id: claims.sub, }, { onConflict: "user_id,insight_type,period_start,period_end" }, ) @@ -705,17 +668,39 @@ async function generateValidNarrative( prompt: string, analytics: ReportAnalytics, ) { - const rawNarrative = await callAIProvider(prompt); - const parsed = parseNarrativeResult(rawNarrative, analytics); - - if (!parsed.ok) { - throw new AIProviderError( - "The AI provider returned invalid narrative JSON.", - "invalid_narrative_response", + for (let attempt = 1; attempt <= maxNarrativeAttempts; attempt += 1) { + const rawNarrative = await callAIProvider( + attempt === 1 ? prompt : buildNarrativeRetryPrompt(prompt), + ); + const parsed = parseReportNarrative( + rawNarrative, + analytics.dataWasCapped, ); + + if (parsed.ok) { + return parsed.narrative; + } + + console.warn("generate-insight-report invalid_ai_response", { + attempt, + characterCount: rawNarrative.length, + reason: parsed.reason, + }); } - return parsed.narrative; + throw new AIProviderError( + "The AI provider returned invalid narrative JSON.", + "invalid_narrative_response", + ); +} + +function buildNarrativeRetryPrompt(prompt: string) { + return `${prompt} + +Your previous response could not be parsed. +Return only one valid JSON object with every requested key and the correct value types. +Keep every narrative field complete and preserve its full detail. +Use null instead of an empty string for optional fields.`; } async function callAIProvider(finalPrompt: string) { @@ -784,60 +769,16 @@ async function callAIProvider(finalPrompt: string) { ); } + console.info("generate-insight-report provider_response_received", { + characterCount: content.length, + }); + return content; } finally { clearTimeout(timeout); } } -function parseNarrativeResult( - value: string, - analytics: ReportAnalytics, -): { narrative: ReportNarrative; ok: true } | { ok: false } { - let parsedValue: unknown; - - try { - parsedValue = JSON.parse(stripJsonCodeFence(value)); - } catch { - return { ok: false }; - } - - if (!isRecord(parsedValue)) { - return { ok: false }; - } - - const overview = getRequiredString(parsedValue.overview, 700); - const emotionalJourney = getRequiredString(parsedValue.emotionalJourney, 700); - const nextFocus = getRequiredString(parsedValue.nextFocus, 320); - - if (!overview || !emotionalJourney || !nextFocus) { - return { ok: false }; - } - - const dataQualityNote = - analytics.dataWasCapped - ? "This report was generated from the first 250 entries in the selected period." - : getNullableString(parsedValue.dataQualityNote, 280); - - return { - narrative: { - activities: getStringArray(parsedValue.activities, 8, 180), - challenges: getStringArray(parsedValue.challenges, 6, 220), - dataQualityNote, - emotionalFlow: getStringArray(parsedValue.emotionalFlow, 6, 80), - emotionalJourney, - enjoyed: getStringArray(parsedValue.enjoyed, 6, 180), - improvements: getStringArray(parsedValue.improvements, 5, 220), - nextFocus, - overview, - patterns: getStringArray(parsedValue.patterns, 6, 240), - reflectionPrompt: getNullableString(parsedValue.reflectionPrompt, 260), - wins: getStringArray(parsedValue.wins, 6, 180), - }, - ok: true, - }; -} - function mapReportRow(row: AIInsightReportRow) { if (!isRecord(row.report_data)) { return null; @@ -970,6 +911,31 @@ function getBearerToken(authorization: string) { return token || null; } +function parseJwtClaims(token: string) { + const [, payload] = token.split("."); + + if (!payload) { + return null; + } + + try { + const normalizedPayload = payload.replace(/-/g, "+").replace(/_/g, "/"); + const paddedPayload = normalizedPayload.padEnd( + normalizedPayload.length + ((4 - (normalizedPayload.length % 4)) % 4), + "=", + ); + const claims: unknown = JSON.parse(atob(paddedPayload)); + + if (!isRecord(claims) || typeof claims.sub !== "string") { + return null; + } + + return { sub: claims.sub }; + } catch { + return null; + } +} + function jsonResponse(body: unknown, status = 200) { return new Response(JSON.stringify(body), { headers: { @@ -999,73 +965,6 @@ function truncate(value: string, maxLength: number) { return `${truncated}…`; } -function stripJsonCodeFence(value: string) { - return value - .trim() - .replace(/^```(?:json)?\s*/i, "") - .replace(/\s*```$/i, "") - .trim(); -} - -function getRequiredString(value: unknown, maxLength: number) { - if (typeof value !== "string") { - return null; - } - - const trimmedValue = sanitizeNarrativeText(value, maxLength); - - return isCompleteNarrativeText(trimmedValue) ? trimmedValue : null; -} - -function getNullableString(value: unknown, maxLength: number) { - if (value === null || value === undefined) { - return null; - } - - if (typeof value !== "string") { - return null; - } - - const trimmedValue = sanitizeNarrativeText(value, maxLength); - - return isCompleteNarrativeText(trimmedValue) ? trimmedValue : null; -} - -function getStringArray(value: unknown, maxItems: number, maxLength: number) { - if (!Array.isArray(value)) { - return []; - } - - return value - .filter((item): item is string => typeof item === "string") - .map((item) => sanitizeNarrativeText(item, maxLength)) - .filter(isCompleteNarrativeText) - .filter(Boolean) - .slice(0, maxItems); -} - -function sanitizeNarrativeText(value: string, maxLength: number) { - return truncate(value.replace(/\s+/g, " ").trim(), maxLength); -} - -function isCompleteNarrativeText(value: string) { - const trimmedValue = value.trim(); - - if (!trimmedValue) { - return false; - } - - if (/[,;:]$/.test(trimmedValue) || /\.{3}$/.test(trimmedValue)) { - return false; - } - - const lastWord = trimmedValue.match(/[A-Za-z]+[.!?"]?$/)?.[0] - .replace(/[.!?"]+$/g, "") - .toLowerCase(); - - return !lastWord || !danglingEndingWords.has(lastWord); -} - function createReportId() { return `insight_report_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; } diff --git a/supabase/functions/journal-ai-chat/index.ts b/supabase/functions/journal-ai-chat/index.ts index 6c33bf6..dc24f7a 100644 --- a/supabase/functions/journal-ai-chat/index.ts +++ b/supabase/functions/journal-ai-chat/index.ts @@ -144,6 +144,7 @@ type JournalEntryRow = { type ProviderMessage = { content: string; + finishReason: string | null; role: "assistant"; }; @@ -164,6 +165,7 @@ class AIProviderError extends Error { message: string, readonly code: string, readonly status?: number, + readonly partialContent?: string, ) { super(message); this.name = "AIProviderError"; @@ -496,6 +498,18 @@ Deno.serve(async (request) => { source: "remote_ai", }); } catch (error) { + if (isTruncatedProviderError(error)) { + return jsonResponse({ + isPartial: true, + message: error.partialContent, + relatedEntryIds: summaryEntries + .slice(-maxRelatedEntryCount) + .map((entry) => entry.id), + requestId, + source: "remote_ai", + }); + } + const providerError = error instanceof AIProviderError ? { @@ -565,6 +579,16 @@ Deno.serve(async (request) => { source: "remote_ai", }); } catch (error) { + if (isTruncatedProviderError(error)) { + return jsonResponse({ + isPartial: true, + message: error.partialContent, + relatedEntryIds, + requestId, + source: "remote_ai", + }); + } + const providerError = error instanceof AIProviderError ? { @@ -1510,6 +1534,20 @@ async function callAIProvider( ); } + console.info("journal-ai-chat provider_response_received", { + characterCount: content.length, + finishReason: providerMessage?.finishReason, + }); + + if (providerMessage?.finishReason === "length") { + throw new AIProviderError( + "The AI provider response was cut off.", + "provider_response_truncated", + undefined, + content, + ); + } + return content; } finally { clearTimeout(timeout); @@ -1567,10 +1605,25 @@ function getProviderMessage(body: unknown): ProviderMessage | null { return { content: message.content, + finishReason: + typeof firstChoice.finish_reason === "string" + ? firstChoice.finish_reason + : null, role: "assistant", }; } +function isTruncatedProviderError( + error: unknown, +): error is AIProviderError & { partialContent: string } { + return ( + error instanceof AIProviderError && + error.code === "provider_response_truncated" && + typeof error.partialContent === "string" && + error.partialContent.length > 0 + ); +} + function detectChatIntent( message: string, recentMessages: RecentMessage[], diff --git a/supabase/functions/reflect-on-entry/index.ts b/supabase/functions/reflect-on-entry/index.ts index 2552be6..2862815 100644 --- a/supabase/functions/reflect-on-entry/index.ts +++ b/supabase/functions/reflect-on-entry/index.ts @@ -44,10 +44,7 @@ const journalEntrySelectWithoutTags = "id,title,content,mood,type,prompt,created_at,updated_at"; const maxEntryContentLength = 8000; const maxTagCount = 10; -const maxEmotionsCount = 6; -const maxThemesCount = 6; const maxReflectionAttempts = 3; -const maxReflectionTextLength = 220; type ReflectOnEntryRequest = { entryId: string; @@ -99,10 +96,10 @@ type ValidReflectionResult = { type ChatCompletionRequestBody = { max_tokens: number; - messages: Array<{ + messages: { content: string; role: "system" | "user"; - }>; + }[]; model: string; response_format?: { type: "json_object" }; temperature: number; @@ -531,7 +528,6 @@ Return JSON with exactly these keys: Rules for the JSON values: - summary, observation, followUpQuestion, and suggestion must be complete. -- summary, observation, followUpQuestion, and suggestion must each be 220 characters or fewer. - followUpQuestion must end with a question mark. - summary, observation, and suggestion must end with punctuation. - Do not end any field with an unfinished phrase like "in your", "with", "to", "for", or "...".`; @@ -640,6 +636,10 @@ async function callAIProvider(finalPrompt: string) { ); } + console.info("reflect-on-entry provider_response_received", { + characterCount: content.length, + }); + return content; } finally { clearTimeout(timeout); @@ -687,14 +687,8 @@ function parseReflectionResult( "question", ); const suggestion = getReflectionText(parsedValue.suggestion, "punctuation"); - const emotions = getTrimmedStringArray(parsedValue.emotions).slice( - 0, - maxEmotionsCount, - ); - const themes = getTrimmedStringArray(parsedValue.themes).slice( - 0, - maxThemesCount, - ); + const emotions = getTrimmedStringArray(parsedValue.emotions); + const themes = getTrimmedStringArray(parsedValue.themes); if (!summary) { return { ok: false, reason: "invalid_summary" }; @@ -939,16 +933,13 @@ function getReflectionText( return null; } - const trimmedValue = cleanSingleLine(value); + const trimmedValue = value.trim(); if (!trimmedValue) { return null; } - const sanitizedValue = ensureReflectionTerminal( - truncateReflectionText(trimmedValue, maxReflectionTextLength), - terminal, - ); + const sanitizedValue = ensureReflectionTerminal(trimmedValue, terminal); if (!sanitizedValue || isIncompleteReflectionText(sanitizedValue)) { return null; @@ -957,29 +948,6 @@ function getReflectionText( return sanitizedValue; } -function truncateReflectionText(value: string, maxLength: number) { - if (value.length <= maxLength) { - return value; - } - - const sentenceEndMatch = value - .slice(0, maxLength + 1) - .match(/^([\s\S]*[.!?।])(?:\s|$)/); - - if (sentenceEndMatch?.[1]) { - return sentenceEndMatch[1].trim(); - } - - const truncatedValue = value.slice(0, maxLength).trimEnd(); - const lastSpaceIndex = truncatedValue.lastIndexOf(" "); - - if (lastSpaceIndex > Math.floor(maxLength * 0.7)) { - return truncatedValue.slice(0, lastSpaceIndex).trimEnd(); - } - - return truncatedValue; -} - function ensureReflectionTerminal( value: string, terminal: "punctuation" | "question", @@ -993,21 +961,10 @@ function ensureReflectionTerminal( return null; } - return looksLikeQuestion(value) ? appendTerminal(value, "?") : null; + return looksLikeQuestion(value) ? `${value}?` : null; } - return hasTerminalPunctuation(value) ? value : appendTerminal(value, "."); -} - -function appendTerminal(value: string, terminal: "." | "?") { - if (value.length < maxReflectionTextLength) { - return `${value}${terminal}`; - } - - return `${truncateReflectionText( - value, - maxReflectionTextLength - terminal.length, - )}${terminal}`; + return hasTerminalPunctuation(value) ? value : `${value}.`; } function looksLikeQuestion(value: string) { @@ -1024,7 +981,6 @@ function isIncompleteReflectionText(value: string) { const trimmedValue = value.trim(); if ( - trimmedValue.length > maxReflectionTextLength || /(\.\.\.|…)$/.test(trimmedValue) || /[,;:]$/.test(trimmedValue) ) { diff --git a/tests/ai-text-rendering.test.ts b/tests/ai-text-rendering.test.ts new file mode 100644 index 0000000..876d368 --- /dev/null +++ b/tests/ai-text-rendering.test.ts @@ -0,0 +1,93 @@ +import { addSafeBreakOpportunities } from "../lib/text/add-safe-break-opportunities"; +import { parseAITextBlocks } from "../lib/text/parse-ai-markdown"; + +import { + assembledStreamingAIResponse, + codeAIResponse, + longAIResponse, + longTokenAIResponse, + markdownAIResponse, + reportAIResponse, + streamingAIResponseChunks, + unicodeAIResponse, + veryLongAIResponse, +} from "./fixtures/ai-responses"; + +function assert(condition: boolean, message: string) { + if (!condition) { + throw new Error(message); + } +} + +function getBlockContent(content: string) { + return parseAITextBlocks(content) + .flatMap((block) => { + if (block.type === "list") { + return block.items.map((item) => item.content); + } + + return "content" in block ? [block.content] : []; + }) + .join("\n\n"); +} + +assert(longAIResponse.length >= 2_000, "Long fixture must exceed 2,000 characters."); +assert( + veryLongAIResponse.length >= 10_000, + "Very-long fixture must exceed 10,000 characters.", +); +assert( + getBlockContent(veryLongAIResponse) === veryLongAIResponse, + "Plain prose parsing must preserve every character except structural separators.", +); + +const markdownBlocks = parseAITextBlocks(markdownAIResponse); +assert( + markdownBlocks.some((block) => block.type === "heading"), + "Markdown headings must be identified.", +); +assert( + markdownBlocks.some((block) => block.type === "list"), + "Markdown lists must be identified.", +); +assert( + markdownBlocks.some((block) => block.type === "blockquote"), + "Markdown blockquotes must be identified.", +); + +const codeBlocks = parseAITextBlocks(codeAIResponse); +assert( + codeBlocks.some( + (block) => + block.type === "code" && block.content.includes("intentionallyLongLine"), + ), + "Code blocks must retain wide lines.", +); +assert( + codeBlocks.some((block) => block.type === "table"), + "Markdown tables must use the wide-content path.", +); + +const breakableText = addSafeBreakOpportunities(longTokenAIResponse); +assert( + breakableText.includes("\u200B"), + "Long tokens must receive display-only break opportunities.", +); +assert( + breakableText.replace(/\u200B/g, "") === longTokenAIResponse, + "Removing display-only breaks must restore the exact source.", +); +assert( + addSafeBreakOpportunities(unicodeAIResponse).replace(/\u200B/g, "") === + unicodeAIResponse, + "Unicode source must remain unchanged.", +); +assert( + streamingAIResponseChunks.join("") === assembledStreamingAIResponse, + "Irregular streaming chunks must assemble without loss.", +); +assert( + getBlockContent(reportAIResponse).includes("Next focus"), + "The final report section must remain in parsed output.", +); + diff --git a/tests/fixtures/ai-responses.ts b/tests/fixtures/ai-responses.ts new file mode 100644 index 0000000..c66d3d4 --- /dev/null +++ b/tests/fixtures/ai-responses.ts @@ -0,0 +1,82 @@ +const calmParagraph = + "A steady reflection can hold both uncertainty and progress without rushing either one. The details remain available so the reader can return to them at their own pace."; + +export const shortAIResponse = "A short one-paragraph response."; + +export const longAIResponse = Array.from( + { length: 18 }, + (_, index) => `Paragraph ${index + 1}. ${calmParagraph}`, +).join("\n\n"); + +export const veryLongAIResponse = Array.from( + { length: 82 }, + (_, index) => `Reflection section ${index + 1}. ${calmParagraph}`, +).join("\n\n"); + +export const markdownAIResponse = `# A complete reflection + +This paragraph includes **bold text**, *italic text*, and \`inline code\`. + +## What stood out + +- A first observation that wraps naturally on narrow screens. + - A nested observation with enough detail to span more than one line. +- A second observation with a [helpful link](https://example.com/reflection/resources). + +1. Pause and notice what feels important. +2. Write down one gentle next step. + +> Progress can be quiet and still be meaningful.`; + +export const codeAIResponse = `A short example: + +\`\`\`ts +function preserveCompleteResponse(chunks: string[]) { + return chunks.join(""); +} + +const intentionallyLongLine = "${"complete-response-".repeat(18)}"; +\`\`\` + +| Surface | Expected behavior | +| --- | --- | +| Chat | Natural vertical height | +| Code | Horizontal scrolling only |`; + +export const unicodeAIResponse = + "आज की सोच को धीरे-धीरे पढ़ें। 😊 यहाँ हिंदी, café, naïve, résumé, العربية, 日本語, and emoji 🫶🏽 remain visible together."; + +export const longTokenAIResponse = [ + "https://example.com/a/very/very/very/long/path/without/spaces?with=query&and=more-values", + "averyveryveryveryveryveryveryveryveryveryverylongunbrokentoken", + "A".repeat(120), +].join("\n\n"); + +export const reportAIResponse = `# Period overview + +${longAIResponse} + +## Patterns + +${markdownAIResponse} + +## Next focus + +${calmParagraph}`; + +export const streamingAIResponseChunks = [ + "# A growing", + " response\n\nThe first para", + "graph arrives in irregular pieces.\n\n- One complete", + " item\n- Another complete item\n\n", + unicodeAIResponse, +]; + +export const assembledStreamingAIResponse = streamingAIResponseChunks.join(""); + +export const aiResponseFixtureCharacterCounts = { + long: longAIResponse.length, + report: reportAIResponse.length, + veryLong: veryLongAIResponse.length, +}; + diff --git a/tests/report-narrative-parser.test.ts b/tests/report-narrative-parser.test.ts new file mode 100644 index 0000000..233ebb7 --- /dev/null +++ b/tests/report-narrative-parser.test.ts @@ -0,0 +1,78 @@ +import { parseReportNarrative } from "../supabase/functions/_shared/parseReportNarrative"; + +function assert(condition: boolean, message: string) { + if (!condition) { + throw new Error(message); + } +} + +const longOverview = `${"A complete reflection keeps every detail visible. ".repeat(24)}to`; +const patterns = Array.from( + { length: 10 }, + (_, index) => `Complete pattern ${index + 1}.`, +); +const validNarrative = { + activities: ["Walking", "Reading"], + challenges: ["A busy schedule"], + dataQualityNote: "", + emotionalFlow: ["Uncertain", "Calm"], + emotionalJourney: "The period moved from uncertainty toward calm.", + enjoyed: ["A quiet morning"], + improvements: ["Protect more rest"], + nextFocus: "Keep one small promise each day.", + overview: longOverview, + patterns, + reflectionPrompt: "", + wins: ["Made time to reflect"], +}; + +const parsed = parseReportNarrative(JSON.stringify(validNarrative), false); + +assert(parsed.ok, "A complete long report should parse successfully."); + +if (parsed.ok) { + assert( + parsed.narrative.overview === longOverview, + "Long narrative fields must not be truncated or rewritten.", + ); + assert( + parsed.narrative.patterns.length === patterns.length, + "Narrative arrays must not be sliced.", + ); + assert( + parsed.narrative.reflectionPrompt === null && + parsed.narrative.dataQualityNote === null, + "Empty optional fields should normalize to null.", + ); +} + +const fenced = parseReportNarrative( + `\`\`\`json\n${JSON.stringify(validNarrative)}\n\`\`\``, + false, +); +assert(fenced.ok, "A fenced JSON response should parse."); + +const wrapped = parseReportNarrative( + `Here is the report:\n${JSON.stringify(validNarrative)}\nDone.`, + false, +); +assert(wrapped.ok, "A valid JSON object should be recovered from wrapper text."); + +const capped = parseReportNarrative(JSON.stringify(validNarrative), true); +assert( + capped.ok && capped.narrative.dataQualityNote?.includes("first 250 entries") === true, + "Capped reports should receive the truthful server data-quality note.", +); + +const invalid = parseReportNarrative( + JSON.stringify({ ...validNarrative, patterns: "not-an-array" }), + false, +); +assert(!invalid.ok, "Invalid field types should fail parsing."); + +if ("reason" in invalid) { + assert( + invalid.reason === "invalid_patterns", + "Invalid field types should return a safe diagnostic reason.", + ); +} diff --git a/types/chat.ts b/types/chat.ts index 341ad22..fc0a292 100644 --- a/types/chat.ts +++ b/types/chat.ts @@ -7,6 +7,7 @@ export type ChatMessage = { role: ChatMessageRole; content: string; createdAt: string; + isPartial?: boolean; relatedEntryIds?: string[]; source?: ChatMessageSource; }; From 58d8bd2f797cf5f503357c299c0a20d9597dd1d7 Mon Sep 17 00:00:00 2001 From: aryansoni-dev Date: Wed, 1 Jul 2026 15:14:02 +0530 Subject: [PATCH 2/3] [+] Bug fixes and improvements. [+] Implemented code-rabbit's suggested patches. --- components/ai/ai-response-renderer.tsx | 2 +- components/insights/insights-screen.tsx | 134 ++++++----- .../insights/report/MoodJourneyChart.tsx | 18 +- .../insights/report/ReportNarrativeBlocks.tsx | 29 +-- hooks/useAIInsightReport.ts | 107 +++++++-- lib/insights/aiInsightReportMapper.ts | 4 +- lib/insights/getPreferredMoodForRange.ts | 113 +++++++++ .../functions/_shared/buildReportAnalytics.ts | 225 +++++++++++++++--- .../functions/_shared/parseReportNarrative.ts | 57 ++++- .../generate-insight-report/index.ts | 114 +++++++-- tests/preferred-mood.test.ts | 100 ++++++++ tests/report-analytics.test.ts | 136 +++++++++++ tests/report-narrative-parser.test.ts | 26 +- 13 files changed, 911 insertions(+), 154 deletions(-) create mode 100644 lib/insights/getPreferredMoodForRange.ts create mode 100644 tests/preferred-mood.test.ts create mode 100644 tests/report-analytics.test.ts diff --git a/components/ai/ai-response-renderer.tsx b/components/ai/ai-response-renderer.tsx index 826f110..77c52e7 100644 --- a/components/ai/ai-response-renderer.tsx +++ b/components/ai/ai-response-renderer.tsx @@ -60,8 +60,8 @@ const variantClasses: Record< }; const safeTextStyle = { - flexShrink: 1, includeFontPadding: true, + minWidth: 0, overflow: "visible", } as const; diff --git a/components/insights/insights-screen.tsx b/components/insights/insights-screen.tsx index acd3bb9..1635158 100644 --- a/components/insights/insights-screen.tsx +++ b/components/insights/insights-screen.tsx @@ -62,14 +62,17 @@ import { getCurrentReportPeriod, type ReportPeriod, } from "@/lib/insights/reportPeriods"; +import { getPreferredMoodForRange } from "@/lib/insights/getPreferredMoodForRange"; import { retryJournalStoreHydration, useJournalHydrationStore, useJournalStore, } from "@/store/journal-store"; +import { useMoodLogStore } from "@/store/useMoodLogStore"; import type { AIInsightReport } from "@/types/aiInsightReport"; import type { InsightsPeriod, ThemeFrequency } from "@/types/insights"; import type { JournalEntry, MoodId } from "@/types/journal"; +import type { MoodLog } from "@/types/moodLog"; const primaryColor = "#FF2056"; @@ -119,13 +122,16 @@ export function InsightsScreen() { const insets = useSafeAreaInsets(); const router = useRouter(); const entries = useJournalStore((state) => state.entries); + const moodLogs = useMoodLogStore((state) => state.allMoodLogs); + const moodLogHasHydrated = useMoodLogStore((state) => state.hasHydrated); const hasHydrated = useJournalHydrationStore( (state) => state.hasHydrated, ); const hydrationError = useJournalHydrationStore( (state) => state.hydrationError, ); - const showHydrationState = useDelayedVisibility(!hasHydrated); + const localDataHasHydrated = hasHydrated && moodLogHasHydrated; + const showHydrationState = useDelayedVisibility(!localDataHasHydrated); const [selectedPeriod, setSelectedPeriod] = useState("week"); const [selectedReferenceDate, setSelectedReferenceDate] = useState( () => new Date(), @@ -151,12 +157,20 @@ export function InsightsScreen() { () => getLocalInsights({ entries, - hasHydrated, + hasHydrated: localDataHasHydrated, + moodLogs, period: selectedPeriod, referenceDate: selectedReferenceDate, userId: userId ?? null, }), - [entries, hasHydrated, selectedPeriod, selectedReferenceDate, userId], + [ + entries, + localDataHasHydrated, + moodLogs, + selectedPeriod, + selectedReferenceDate, + userId, + ], ); const derivedInsights = useMemo( () => @@ -218,7 +232,9 @@ export function InsightsScreen() { () => getRecurringThemes(derivedInsights.themes, matchingPeriodReport), [derivedInsights.themes, matchingPeriodReport], ); - const hasNoEntries = hasHydrated && entries.length === 0; + const hasNoInsightData = + localDataHasHydrated && + !hasActiveUserInsightData(entries, moodLogs, userId ?? null); const newJournalEntryHref = { pathname: "/journal/new", params: { source: "insights" }, @@ -256,7 +272,7 @@ export function InsightsScreen() { }} > @@ -267,7 +283,7 @@ export function InsightsScreen() { onRetry={retryJournalHydration} /> - ) : !hasHydrated ? ( + ) : !localDataHasHydrated ? ( showHydrationState ? ( ) : null - ) : hasNoEntries ? ( + ) : hasNoInsightData ? ( - !entry.deletedAt && - (!userId || entry.userId === userId) && - isEntryInRange(entry, dateRange.start, dateRange.end), - ); if (period === "week") { return Array.from({ length: 7 }, (_, index) => { const date = addDays(dateRange.start, index); - const mood = getTopMoodForRange( - periodEntries, - startOfLocalDay(date), - endOfLocalDay(date), - ); + const mood = getPreferredMoodForRange({ + entries, + moodLogs, + rangeEnd: endOfLocalDay(date), + rangeStart: startOfLocalDay(date), + userId, + }); return toMoodJourneyPoint( mood, @@ -824,13 +841,16 @@ function getPeriodMoodJourney({ while (cursor.getTime() <= dateRange.end.getTime()) { const bucketStart = new Date(cursor); const bucketEnd = endOfLocalDay(addDays(bucketStart, 6)); - const mood = getTopMoodForRange( - periodEntries, - bucketStart, - bucketEnd.getTime() > dateRange.end.getTime() - ? dateRange.end - : bucketEnd, - ); + const mood = getPreferredMoodForRange({ + entries, + moodLogs, + rangeEnd: + bucketEnd.getTime() > dateRange.end.getTime() + ? dateRange.end + : bucketEnd, + rangeStart: bucketStart, + userId, + }); points.push( toMoodJourneyPoint( @@ -851,7 +871,13 @@ function getPeriodMoodJourney({ const bucketEnd = endOfLocalDay( new Date(referenceDate.getFullYear(), monthIndex + 1, 0), ); - const mood = getTopMoodForRange(periodEntries, bucketStart, bucketEnd); + const mood = getPreferredMoodForRange({ + entries, + moodLogs, + rangeEnd: bucketEnd, + rangeStart: bucketStart, + userId, + }); return toMoodJourneyPoint( mood, @@ -874,14 +900,6 @@ function getMoodJourneyPillLabel(period: InsightsPeriod) { return "This Year"; } -function getTopMoodForRange(entries: JournalEntry[], start: Date, end: Date) { - return getTopMood( - entries.filter( - (entry) => entry.mood && isEntryInRange(entry, start, end), - ), - ); -} - function toMoodJourneyPoint( mood: MoodId | null, day: string, @@ -893,40 +911,18 @@ function toMoodJourneyPoint( }; } -function isEntryInRange(entry: JournalEntry, start: Date, end: Date) { - const createdAt = Date.parse(entry.createdAt); +function hasActiveUserInsightData( + entries: JournalEntry[], + moodLogs: MoodLog[], + userId: string | null, +) { + if (!userId) { + return false; + } return ( - Number.isFinite(createdAt) && - createdAt >= start.getTime() && - createdAt <= end.getTime() - ); -} - -function getTopMood(entries: JournalEntry[]) { - const moodCounts = entries.reduce>>( - (counts, entry) => { - if (!entry.mood) { - return counts; - } - - counts[entry.mood] = (counts[entry.mood] ?? 0) + 1; - return counts; - }, - {}, - ); - - return Object.entries(moodCounts).reduce( - (currentMood, [mood, count]) => { - if (!currentMood) { - return mood as MoodId; - } - - return count > (moodCounts[currentMood] ?? 0) - ? (mood as MoodId) - : currentMood; - }, - null, + entries.some((entry) => entry.userId === userId && !entry.deletedAt) || + moodLogs.some((moodLog) => moodLog.userId === userId && !moodLog.deletedAt) ); } @@ -1101,6 +1097,12 @@ function getSmoothLinePath(points: ChartPoint[]) { const previous = points[Math.max(0, index - 1)]; const current = points[index]; const next = points[index + 1]; + + if (current.y === next.y) { + commands.push(`L ${next.x} ${next.y}`); + continue; + } + const afterNext = points[Math.min(points.length - 1, index + 2)]; const controlPointOne = { x: current.x + (next.x - previous.x) / 6, diff --git a/components/insights/report/MoodJourneyChart.tsx b/components/insights/report/MoodJourneyChart.tsx index d5de2c7..0a0169f 100644 --- a/components/insights/report/MoodJourneyChart.tsx +++ b/components/insights/report/MoodJourneyChart.tsx @@ -19,7 +19,9 @@ type MoodJourneyChartProps = { }; export function MoodJourneyChart({ data, explanation }: MoodJourneyChartProps) { - const activeDays = data.filter((item) => item.entryCount > 0); + const activeDays = data.filter( + (item) => item.entryCount > 0 || item.dominantMood !== null, + ); if (activeDays.length === 0) { return ( @@ -28,7 +30,7 @@ export function MoodJourneyChart({ data, explanation }: MoodJourneyChartProps) { className="text-[15px] leading-6" style={{ color: moodJourneyColors.emptyText }} > - Mood journey will appear after entries are added in this period. + Mood journey will appear after a mood check-in or an entry with a mood. ); } @@ -36,7 +38,7 @@ export function MoodJourneyChart({ data, explanation }: MoodJourneyChartProps) { const accessibilityLabel = `Mood journey. ${activeDays .map( (item) => - `${formatReportDate(item.date)}: ${formatMoodLabel(item.dominantMood)}, ${item.entryCount} entries`, + `${formatReportDate(item.date)}: ${formatMoodLabel(item.dominantMood)}, ${getActivityLabel(item.entryCount)}`, ) .join(". ")}.`; @@ -91,7 +93,7 @@ export function MoodJourneyChart({ data, explanation }: MoodJourneyChartProps) { className="mt-1 text-[12px] leading-5" style={{ color: moodJourneyColors.entryCountText }} > - {item.entryCount} {item.entryCount === 1 ? "entry" : "entries"} + {getActivityLabel(item.entryCount)} ))} @@ -106,3 +108,11 @@ export function MoodJourneyChart({ data, explanation }: MoodJourneyChartProps) { ); } + +function getActivityLabel(entryCount: number) { + if (entryCount === 0) { + return "Mood check-in"; + } + + return `${entryCount} ${entryCount === 1 ? "entry" : "entries"}`; +} diff --git a/components/insights/report/ReportNarrativeBlocks.tsx b/components/insights/report/ReportNarrativeBlocks.tsx index 5c7477d..13b36fa 100644 --- a/components/insights/report/ReportNarrativeBlocks.tsx +++ b/components/insights/report/ReportNarrativeBlocks.tsx @@ -134,21 +134,22 @@ export function PatternCards({ items }: { items: string[] }) { } return ( - + {items.map((item) => ( - - + + + + + + + ))} diff --git a/hooks/useAIInsightReport.ts b/hooks/useAIInsightReport.ts index 73455f8..36b7b93 100644 --- a/hooks/useAIInsightReport.ts +++ b/hooks/useAIInsightReport.ts @@ -10,6 +10,7 @@ import { getReportCacheKey, type ReportPeriod, } from "@/lib/insights/reportPeriods"; +import { isAIInsightReport } from "@/lib/insights/aiInsightReportMapper"; import { useAutoSync } from "@/hooks/useAutoSync"; import { useConnectivity } from "@/hooks/useConnectivity"; import { useAIInsightReportStore } from "@/store/useAIInsightReportStore"; @@ -17,8 +18,10 @@ import { useJournalHydrationStore, useJournalStore, } from "@/store/journal-store"; +import { useMoodLogStore } from "@/store/useMoodLogStore"; import type { AIInsightReport } from "@/types/aiInsightReport"; import type { JournalEntry } from "@/types/journal"; +import type { MoodLog } from "@/types/moodLog"; export type UseAIInsightReportResult = { report: AIInsightReport | null; @@ -49,10 +52,16 @@ export function useAIInsightReport( const hasHydrated = useJournalHydrationStore( (state) => state.hasHydrated, ); + const moodLogs = useMoodLogStore((state) => state.allMoodLogs); + const moodLogsHaveHydrated = useMoodLogStore((state) => state.hasHydrated); const cacheKey = useMemo(() => getReportCacheKey(period), [period]); - const cachedReport = useAIInsightReportStore((state) => - userId ? state.reportsByUser[userId]?.[cacheKey] ?? null : null, - ); + const cachedReport = useAIInsightReportStore((state) => { + const candidate = userId + ? state.reportsByUser[userId]?.[cacheKey] ?? null + : null; + + return candidate && isAIInsightReport(candidate) ? candidate : null; + }); const cacheHasHydrated = useAIInsightReportStore( (state) => state.hasHydrated, ); @@ -78,9 +87,13 @@ export function useAIInsightReport( () => getEntriesInPeriod(entries, period), [entries, period], ); + const periodMoodLogs = useMemo( + () => getMoodLogsInPeriod(moodLogs, period, userId ?? null), + [moodLogs, period, userId], + ); const localSource = useMemo( - () => getLocalSource(periodEntries), - [periodEntries], + () => getLocalSource(periodEntries, periodMoodLogs), + [periodEntries, periodMoodLogs], ); const isStale = Boolean( report && isReportStale(report, localSource), @@ -199,10 +212,21 @@ export function useAIInsightReport( ]); useEffect(() => { - if (enabled && hasHydrated && cacheHasHydrated) { + if ( + enabled && + hasHydrated && + moodLogsHaveHydrated && + cacheHasHydrated + ) { void refresh(); } - }, [cacheHasHydrated, enabled, hasHydrated, refresh]); + }, [ + cacheHasHydrated, + enabled, + hasHydrated, + moodLogsHaveHydrated, + refresh, + ]); const requestGeneration = useCallback( async (regenerate: boolean) => { @@ -215,7 +239,12 @@ export function useAIInsightReport( return; } - if (!cacheHasHydrated || requestInFlightRef.current) { + if ( + !cacheHasHydrated || + !hasHydrated || + !moodLogsHaveHydrated || + requestInFlightRef.current + ) { return; } @@ -235,7 +264,7 @@ export function useAIInsightReport( setError(null); try { - const entriesAreSynced = await syncPeriodEntriesBeforeGeneration({ + const sourcesAreSynced = await syncPeriodSourcesBeforeGeneration({ period, runAutoSync, userId, @@ -245,9 +274,9 @@ export function useAIInsightReport( return; } - if (!entriesAreSynced) { + if (!sourcesAreSynced) { setError( - "Please try again after your latest journal entries finish syncing.", + "Please try again after your latest entries and mood check-ins finish syncing.", ); return; } @@ -288,7 +317,9 @@ export function useAIInsightReport( cacheHydrationError, connectivity.status, enabled, + hasHydrated, isCurrentRequestContext, + moodLogsHaveHydrated, period, runAutoSync, setCachedReport, @@ -301,7 +332,9 @@ export function useAIInsightReport( error, generate: () => requestGeneration(false), isGenerating, - isLoading: isLoading || (enabled && !cacheHasHydrated), + isLoading: + isLoading || + (enabled && (!cacheHasHydrated || !hasHydrated || !moodLogsHaveHydrated)), isStale, legacyReportAvailable, refresh, @@ -310,7 +343,7 @@ export function useAIInsightReport( }; } -async function syncPeriodEntriesBeforeGeneration({ +async function syncPeriodSourcesBeforeGeneration({ period, runAutoSync, userId, @@ -319,28 +352,36 @@ async function syncPeriodEntriesBeforeGeneration({ runAutoSync: (reason?: "journal_change") => Promise; userId: string; }) { - if (!hasUnsyncedPeriodEntries({ period, userId })) { + if (!hasUnsyncedPeriodSources({ period, userId })) { return true; } await runAutoSync("journal_change"); - return !hasUnsyncedPeriodEntries({ period, userId }); + return !hasUnsyncedPeriodSources({ period, userId }); } -function hasUnsyncedPeriodEntries({ +function hasUnsyncedPeriodSources({ period, userId, }: { period: ReportPeriod; userId: string; }) { - return getEntriesInPeriod( + const hasUnsyncedEntries = getEntriesInPeriod( useJournalStore .getState() .allEntries.filter((entry) => entry.userId === userId), period, ).some((entry) => entry.syncStatus !== "synced"); + const hasUnsyncedMoodLogs = getMoodLogsInPeriod( + useMoodLogStore.getState().allMoodLogs, + period, + userId, + true, + ).some((moodLog) => moodLog.syncStatus !== "synced"); + + return hasUnsyncedEntries || hasUnsyncedMoodLogs; } function getEntriesInPeriod(entries: JournalEntry[], period: ReportPeriod) { @@ -359,11 +400,37 @@ function getEntriesInPeriod(entries: JournalEntry[], period: ReportPeriod) { }); } -function getLocalSource(entries: JournalEntry[]) { +function getMoodLogsInPeriod( + moodLogs: MoodLog[], + period: ReportPeriod, + userId: string | null, + includeDeleted = false, +) { + if (!userId) { + return []; + } + + const periodStart = period.start.getTime(); + const periodEnd = period.end.getTime(); + + return moodLogs.filter((moodLog) => { + const createdAt = Date.parse(moodLog.createdAt); + + return ( + moodLog.userId === userId && + (includeDeleted || !moodLog.deletedAt) && + Number.isFinite(createdAt) && + createdAt >= periodStart && + createdAt <= periodEnd + ); + }); +} + +function getLocalSource(entries: JournalEntry[], moodLogs: MoodLog[]) { const entryIds = entries.map((entry) => entry.id).sort(); const latestUpdatedAt = - entries - .map((entry) => entry.updatedAt) + [...entries, ...moodLogs] + .map((source) => source.updatedAt) .filter(Boolean) .sort() .at(-1) ?? null; diff --git a/lib/insights/aiInsightReportMapper.ts b/lib/insights/aiInsightReportMapper.ts index 9b0589c..94a9dd0 100644 --- a/lib/insights/aiInsightReportMapper.ts +++ b/lib/insights/aiInsightReportMapper.ts @@ -46,7 +46,7 @@ export function mapAIInsightReportRow( const formatVersion = reportData.formatVersion; - if (formatVersion !== 2) { + if (formatVersion !== 4) { return { status: "legacy" }; } @@ -95,7 +95,7 @@ export function isAIInsightReport(value: unknown): value is AIInsightReport { (value.sourceLatestUpdatedAt === null || typeof value.sourceLatestUpdatedAt === "string") && typeof value.sourceSnapshotHash === "string" && - value.formatVersion === 2 && + value.formatVersion === 4 && (value.model === null || typeof value.model === "string") && typeof value.createdAt === "string" && typeof value.updatedAt === "string" diff --git a/lib/insights/getPreferredMoodForRange.ts b/lib/insights/getPreferredMoodForRange.ts new file mode 100644 index 0000000..4019b65 --- /dev/null +++ b/lib/insights/getPreferredMoodForRange.ts @@ -0,0 +1,113 @@ +import { moodList } from "@/constants/moods"; +import { createLocalDateKey } from "@/lib/calendar/dateUtils"; +import type { JournalEntry, MoodId } from "@/types/journal"; +import type { MoodLog } from "@/types/moodLog"; + +const moodOrder = moodList.map((mood) => mood.id); + +export function getPreferredMoodForRange({ + entries, + moodLogs, + rangeEnd, + rangeStart, + userId, +}: { + entries: JournalEntry[]; + moodLogs: MoodLog[]; + rangeEnd: Date; + rangeStart: Date; + userId: string | null; +}): MoodId | null { + if (!userId) { + return null; + } + + const entryMoodsByDate = new Map(); + const latestMoodLogByDate = new Map(); + + entries.forEach((entry) => { + if ( + entry.userId !== userId || + entry.deletedAt || + !entry.mood || + !isTimestampInRange(entry.createdAt, rangeStart, rangeEnd) + ) { + return; + } + + const dateKey = createLocalDateKey(new Date(entry.createdAt)); + const moods = entryMoodsByDate.get(dateKey) ?? []; + + moods.push(entry.mood); + entryMoodsByDate.set(dateKey, moods); + }); + + moodLogs.forEach((moodLog) => { + if ( + moodLog.userId !== userId || + moodLog.deletedAt || + !isTimestampInRange(moodLog.createdAt, rangeStart, rangeEnd) + ) { + return; + } + + const dateKey = createLocalDateKey(new Date(moodLog.createdAt)); + const currentMoodLog = latestMoodLogByDate.get(dateKey); + + if (!currentMoodLog || isMoodLogNewer(moodLog, currentMoodLog)) { + latestMoodLogByDate.set(dateKey, moodLog); + } + }); + + const dateKeys = new Set([ + ...entryMoodsByDate.keys(), + ...latestMoodLogByDate.keys(), + ]); + const preferredDailyMoods = [...dateKeys].sort().flatMap((dateKey) => { + const homeMood = latestMoodLogByDate.get(dateKey)?.mood; + + if (homeMood) { + return [homeMood]; + } + + const entryMood = getTopMood(entryMoodsByDate.get(dateKey) ?? []); + + return entryMood ? [entryMood] : []; + }); + + return getTopMood(preferredDailyMoods); +} + +function getTopMood(moods: MoodId[]) { + const counts = moods.reduce>>((result, mood) => { + result[mood] = (result[mood] ?? 0) + 1; + return result; + }, {}); + let topMood: MoodId | null = null; + let topCount = 0; + + moodOrder.forEach((mood) => { + const count = counts[mood] ?? 0; + + if (count > topCount) { + topMood = mood; + topCount = count; + } + }); + + return topMood; +} + +function isMoodLogNewer(candidate: MoodLog, current: MoodLog) { + return Date.parse(candidate.updatedAt) > Date.parse(current.updatedAt); +} + +function isTimestampInRange(value: string, start: Date, end: Date) { + const timestamp = Date.parse(value); + + return ( + Number.isFinite(timestamp) && + timestamp >= start.getTime() && + timestamp <= end.getTime() + ); +} diff --git a/supabase/functions/_shared/buildReportAnalytics.ts b/supabase/functions/_shared/buildReportAnalytics.ts index 133e08b..9a8800b 100644 --- a/supabase/functions/_shared/buildReportAnalytics.ts +++ b/supabase/functions/_shared/buildReportAnalytics.ts @@ -57,56 +57,176 @@ export type JournalEntryRow = { updated_at: string; }; -const stopWords = new Set([ +export type MoodLogRow = { + created_at: string; + id: string; + mood: string; + updated_at: string; +}; + +const contentStopWords = new Set([ + "above", "about", "after", "again", + "against", + "all", + "almost", + "already", "also", + "although", + "always", + "among", "and", + "another", + "any", + "anyone", + "anything", + "around", "because", "been", "before", "being", + "between", + "both", + "but", + "cannot", + "challenge", + "challenged", + "challenges", "could", "daily", + "day", + "days", "dear", "diary", + "does", + "doing", + "done", + "down", + "during", + "each", + "either", + "else", + "enough", + "even", + "ever", + "every", + "everyone", + "everything", + "evening", + "entries", + "entry", "feel", + "feeling", + "feelings", "felt", "from", + "getting", + "going", + "had", + "has", "have", + "having", + "here", + "herself", + "himself", "into", + "itself", + "i'd", + "i'll", + "i'm", + "i've", "journal", "just", + "know", + "later", + "least", + "less", "like", + "many", + "maybe", + "might", "more", + "morning", + "most", "much", + "must", + "myself", + "never", + "night", + "nobody", + "none", + "nothing", + "often", + "once", + "only", + "other", + "ourselves", + "quite", + "really", + "reflection", + "reflections", + "same", + "should", + "since", + "someone", + "something", + "still", + "such", + "than", "that", "the", "their", + "theirs", "them", + "themselves", "then", "there", + "these", + "thing", + "things", + "think", "this", + "those", + "through", + "together", + "toward", "today", + "under", + "until", + "usually", + "very", "want", + "whatever", "were", "what", "when", + "where", + "whether", + "which", + "while", + "without", "with", "would", + "write", + "writing", + "wrote", + "yourself", "your", ]); export function buildReportAnalytics({ dataWasCapped, entries, + moodLogs = [], periodEnd, periodStart, timezone, }: { dataWasCapped: boolean; entries: JournalEntryRow[]; + moodLogs?: MoodLogRow[]; periodEnd: Date; periodStart: Date; timezone?: string; @@ -138,7 +258,12 @@ export function buildReportAnalytics({ entryTypeDistribution: getEntryTypeDistribution(entries), longestStreak: getLongestStreak(activeDates), moodDistribution, - moodTimeline: getMoodTimeline(entries, entryCountByDate, timezone), + moodTimeline: getMoodTimeline( + entries, + entryCountByDate, + moodLogs, + timezone, + ), mostActiveDate: mostActive.date, mostActiveDateEntryCount: mostActive.count, recurringThemes: getRecurringThemes(entries), @@ -214,6 +339,7 @@ function getMoodDistribution(entries: JournalEntryRow[]) { function getMoodTimeline( entries: JournalEntryRow[], entryCountByDate: Record, + moodLogs: MoodLogRow[], timezone?: string, ) { const moodsByDate = entries.reduce>((groups, entry) => { @@ -229,15 +355,40 @@ function getMoodTimeline( return groups; }, {}); + const latestMoodLogByDate = moodLogs.reduce>( + (groups, moodLog) => { + const date = getDateKey(new Date(moodLog.created_at), timezone); + const currentMoodLog = groups[date]; + + if ( + !currentMoodLog || + Date.parse(moodLog.updated_at) > Date.parse(currentMoodLog.updated_at) + ) { + groups[date] = moodLog; + } - return Object.keys(entryCountByDate) + return groups; + }, + {}, + ); + const timelineDates = new Set([ + ...Object.keys(entryCountByDate), + ...Object.keys(latestMoodLogByDate), + ]); + + return [...timelineDates] .sort() - .map((date) => ({ - date, - dominantMood: getDominantMood(moodsByDate[date] ?? []), - entryCount: entryCountByDate[date], - moods: [...new Set(moodsByDate[date] ?? [])], - })); + .map((date) => { + const homeMood = latestMoodLogByDate[date]?.mood; + const entryMoods = moodsByDate[date] ?? []; + + return { + date, + dominantMood: homeMood ?? getDominantMood(entryMoods), + entryCount: entryCountByDate[date] ?? 0, + moods: [...new Set(homeMood ? [homeMood, ...entryMoods] : entryMoods)], + }; + }); } function getDominantMood(moods: string[]) { @@ -250,7 +401,15 @@ function getDominantMood(moods: string[]) { return moodCounts; }, {}); - return Object.entries(counts).sort(sortByCountThenName)[0]?.[0] ?? null; + return ( + Object.entries(counts).sort((first, second) => { + const countDifference = second[1] - first[1]; + + return countDifference !== 0 + ? countDifference + : first[0].localeCompare(second[0]); + })[0]?.[0] ?? null + ); } function getEntryTypeDistribution(entries: JournalEntryRow[]) { @@ -274,26 +433,26 @@ function getRecurringThemes(entries: JournalEntryRow[]) { const contentCounts = new Map(); entries.forEach((entry) => { - (entry.tags ?? []).forEach((tag) => { - const normalizedTag = normalizeTheme(tag); - - if (normalizedTag) { - tagCounts.set(normalizedTag, (tagCounts.get(normalizedTag) ?? 0) + 1); - } + const entryTags = new Set( + (entry.tags ?? []) + .map(normalizeTag) + .filter((tag): tag is string => tag !== null), + ); + + entryTags.forEach((tag) => { + tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1); }); const searchableText = `${entry.title} ${entry.content}`; const words = searchableText.toLowerCase().match(/[a-z][a-z']+/g) ?? []; - - words.forEach((word) => { - const normalizedWord = normalizeTheme(word); - - if (normalizedWord) { - contentCounts.set( - normalizedWord, - (contentCounts.get(normalizedWord) ?? 0) + 1, - ); - } + const entryContentThemes = new Set( + words + .map(normalizeContentWord) + .filter((word): word is string => word !== null), + ); + + entryContentThemes.forEach((theme) => { + contentCounts.set(theme, (contentCounts.get(theme) ?? 0) + 1); }); }); @@ -388,16 +547,26 @@ function isValidTimezone(timezone: string) { } } -function normalizeTheme(value: string) { +function normalizeTag(value: string) { const normalized = value.trim().toLowerCase().replace(/[_-]+/g, " "); - if (normalized.length < 4 || stopWords.has(normalized)) { + if (normalized.length < 2) { return null; } return normalized.replace(/\s+/g, " "); } +function normalizeContentWord(value: string) { + const normalized = value.trim().toLowerCase(); + + if (normalized.length < 4 || contentStopWords.has(normalized)) { + return null; + } + + return normalized; +} + function roundToTwo(value: number) { return Math.round(value * 100) / 100; } diff --git a/supabase/functions/_shared/parseReportNarrative.ts b/supabase/functions/_shared/parseReportNarrative.ts index 317618a..bfed5f0 100644 --- a/supabase/functions/_shared/parseReportNarrative.ts +++ b/supabase/functions/_shared/parseReportNarrative.ts @@ -17,6 +17,33 @@ export type ReportNarrativeParseResult = | { narrative: ReportNarrative; ok: true } | { ok: false; reason: string }; +const danglingEndingWords = new Set([ + "a", + "about", + "an", + "and", + "as", + "at", + "because", + "being", + "but", + "by", + "for", + "from", + "if", + "in", + "into", + "of", + "on", + "or", + "that", + "the", + "to", + "which", + "while", + "with", +]); + export function parseReportNarrative( value: string, dataWasCapped: boolean, @@ -152,7 +179,7 @@ function getRequiredString(value: unknown) { const trimmedValue = value.trim(); - return trimmedValue || null; + return isCompleteNarrativeText(trimmedValue) ? trimmedValue : null; } function getNullableString(value: unknown) { @@ -164,7 +191,15 @@ function getNullableString(value: unknown) { return { ok: false as const }; } - return { ok: true as const, value: value.trim() || null }; + const trimmedValue = value.trim(); + + if (!trimmedValue) { + return { ok: true as const, value: null }; + } + + return isCompleteNarrativeText(trimmedValue) + ? { ok: true as const, value: trimmedValue } + : { ok: false as const }; } function getStringArray(value: unknown) { @@ -176,10 +211,24 @@ function getStringArray(value: unknown) { return null; } - return value.map((item) => item.trim()).filter(Boolean); + const items = value.map((item) => item.trim()).filter(Boolean); + + return items.every(isCompleteNarrativeText) ? items : null; +} + +function isCompleteNarrativeText(value: string) { + if (!value || /[,;:]$/.test(value) || /(?:\.{3}|…)$/.test(value)) { + return false; + } + + const lastWord = value + .match(/[A-Za-z]+[.!?\"']?$/)?.[0] + .replace(/[.!?\"']+$/g, "") + .toLowerCase(); + + return !lastWord || !danglingEndingWords.has(lastWord); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } - diff --git a/supabase/functions/generate-insight-report/index.ts b/supabase/functions/generate-insight-report/index.ts index 60b4e6d..e1e4fc5 100644 --- a/supabase/functions/generate-insight-report/index.ts +++ b/supabase/functions/generate-insight-report/index.ts @@ -3,6 +3,7 @@ import { createClient, type SupabaseClient } from "@supabase/supabase-js"; import { buildReportAnalytics, type JournalEntryRow, + type MoodLogRow, type ReportAnalytics, } from "../_shared/buildReportAnalytics.ts"; import { @@ -64,7 +65,8 @@ Keep the tone warm, grounded, concise and practical. Every list item must be a complete sentence or a concise complete phrase. Never end a sentence or list item with connector words such as "and", "or", -"which", "that", "with", "for", "to", "of", "a", "an", or "the". +"which", "that", "with", "for", "to", "of", "into", "as", "a", "an", or +"the". Return only valid JSON with the exact requested keys.`; @@ -72,6 +74,7 @@ const journalEntrySelectWithTags = "id,title,content,mood,type,prompt,tags,created_at,updated_at"; const journalEntrySelectWithoutTags = "id,title,content,mood,type,prompt,created_at,updated_at"; +const moodLogSelect = "id,mood,created_at,updated_at"; const maxFetchedEntries = 251; const maxAnalyticsEntries = 250; const maxDetailedEntries = 150; @@ -80,7 +83,15 @@ const maxTitleLength = 200; const maxPromptLength = 300; const maxTagsPerEntry = 10; const maxNarrativeAttempts = 2; -const reportFormatVersion = 2; +const reportFormatVersion = 4; +const validMoodIds = new Set([ + "anxious", + "calm", + "grateful", + "happy", + "motivated", + "sad", +]); type AIInsightPeriodType = "weekly" | "monthly"; @@ -201,11 +212,19 @@ Deno.serve(async (request) => { const reportRequest = parsedRequest.data; const periodStart = new Date(reportRequest.periodStart); const periodEnd = new Date(reportRequest.periodEnd); - const entriesResult = await fetchJournalEntries({ - periodEnd: reportRequest.periodEnd, - periodStart: reportRequest.periodStart, - supabase, - }); + const [entriesResult, moodLogsResult] = await Promise.all([ + fetchJournalEntries({ + periodEnd: reportRequest.periodEnd, + periodStart: reportRequest.periodStart, + supabase, + }), + fetchMoodLogs({ + periodEnd: reportRequest.periodEnd, + periodStart: reportRequest.periodStart, + supabase, + userId: claims.sub, + }), + ]); if (entriesResult.error) { console.error("generate-insight-report entries_query_failed", { @@ -223,16 +242,34 @@ Deno.serve(async (request) => { ); } + if (moodLogsResult.error) { + console.error("generate-insight-report mood_logs_query_failed", { + code: moodLogsResult.error.code, + requestId, + }); + + return jsonResponse( + { + code: "mood_logs_query_failed", + error: "Mood check-ins could not be loaded.", + requestId, + }, + 500, + ); + } + const dataWasCapped = entriesResult.entries.length > maxAnalyticsEntries; const entries = entriesResult.entries.slice(0, maxAnalyticsEntries); + const moodLogs = moodLogsResult.moodLogs; const analytics = buildReportAnalytics({ dataWasCapped, entries, + moodLogs, periodEnd, periodStart, timezone: reportRequest.timezone, }); - const source = await buildSourceSnapshot(entries); + const source = await buildSourceSnapshot(entries, moodLogs); const existingReportResult = await fetchExistingReport({ periodEnd: reportRequest.periodEnd, periodStart: reportRequest.periodStart, @@ -559,6 +596,38 @@ async function fetchJournalEntries({ }; } +async function fetchMoodLogs({ + periodEnd, + periodStart, + supabase, + userId, +}: { + periodEnd: string; + periodStart: string; + supabase: SupabaseClient; + userId: string; +}) { + const result = await supabase + .from("mood_logs") + .select(moodLogSelect) + .eq("user_id", userId) + .is("deleted_at", null) + .gte("created_at", periodStart) + .lte("created_at", periodEnd) + .order("created_at", { ascending: true }); + + if (result.error) { + return { error: result.error, moodLogs: [] }; + } + + const rows: unknown[] = Array.isArray(result.data) ? result.data : []; + + return { + error: null, + moodLogs: rows.filter(isMoodLogRow), + }; +} + async function fetchExistingReport({ periodEnd, periodStart, @@ -588,13 +657,17 @@ async function fetchExistingReport({ }; } -async function buildSourceSnapshot(entries: JournalEntryRow[]) { - const sortedParts = entries - .map((entry) => `${entry.id}:${entry.updated_at}`) - .sort(); +async function buildSourceSnapshot( + entries: JournalEntryRow[], + moodLogs: MoodLogRow[], +) { + const sortedParts = [ + ...entries.map((entry) => `entry:${entry.id}:${entry.updated_at}`), + ...moodLogs.map((moodLog) => `mood:${moodLog.id}:${moodLog.updated_at}`), + ].sort(); const latestUpdatedAt = - entries - .map((entry) => entry.updated_at) + [...entries, ...moodLogs] + .map((source) => source.updated_at) .filter(Boolean) .sort() .at(-1) ?? null; @@ -880,6 +953,19 @@ function isJournalEntryRow(value: unknown): value is JournalEntryRow { ); } +function isMoodLogRow(value: unknown): value is MoodLogRow { + return ( + isRecord(value) && + typeof value.id === "string" && + typeof value.mood === "string" && + validMoodIds.has(value.mood) && + typeof value.created_at === "string" && + Number.isFinite(Date.parse(value.created_at)) && + typeof value.updated_at === "string" && + Number.isFinite(Date.parse(value.updated_at)) + ); +} + function isReportRow(value: unknown): value is AIInsightReportRow { return ( isRecord(value) && diff --git a/tests/preferred-mood.test.ts b/tests/preferred-mood.test.ts new file mode 100644 index 0000000..564608f --- /dev/null +++ b/tests/preferred-mood.test.ts @@ -0,0 +1,100 @@ +import { getPreferredMoodForRange } from "../lib/insights/getPreferredMoodForRange"; +import type { JournalEntry, MoodId } from "../types/journal"; +import type { MoodLog } from "../types/moodLog"; + +function assert(condition: boolean, message: string) { + if (!condition) { + throw new Error(message); + } +} + +const userId = "user_1"; +const day = new Date(2026, 6, 1, 12); +const rangeStart = new Date(2026, 6, 1); +const rangeEnd = new Date(2026, 6, 1, 23, 59, 59, 999); + +function createEntry(id: string, mood: MoodId): JournalEntry { + return { + content: "Entry", + createdAt: day.toISOString(), + id, + mood, + tags: [], + title: "Entry", + type: "free_write", + updatedAt: day.toISOString(), + userId, + }; +} + +function createMoodLog( + id: string, + mood: MoodId, + updatedAt = day.toISOString(), +): MoodLog { + return { + createdAt: day.toISOString(), + deletedAt: null, + id, + intensity: null, + mood, + note: null, + updatedAt, + userId, + }; +} + +const homeMoodWins = getPreferredMoodForRange({ + entries: [createEntry("entry_1", "sad"), createEntry("entry_2", "sad")], + moodLogs: [createMoodLog("mood_1", "happy")], + rangeEnd, + rangeStart, + userId, +}); +assert( + homeMoodWins === "happy", + "The Home mood check-in must override entry moods for the same day.", +); + +const entryFallback = getPreferredMoodForRange({ + entries: [ + createEntry("entry_1", "anxious"), + createEntry("entry_2", "anxious"), + createEntry("entry_3", "calm"), + ], + moodLogs: [], + rangeEnd, + rangeStart, + userId, +}); +assert( + entryFallback === "anxious", + "Entry moods must provide the daily fallback when no Home mood exists.", +); + +const latestHomeMoodWins = getPreferredMoodForRange({ + entries: [], + moodLogs: [ + createMoodLog("mood_1", "sad", new Date(2026, 6, 1, 12).toISOString()), + createMoodLog("mood_2", "grateful", new Date(2026, 6, 1, 13).toISOString()), + ], + rangeEnd, + rangeStart, + userId, +}); +assert( + latestHomeMoodWins === "grateful", + "The latest Home mood must be used if duplicate logs exist for a day.", +); + +const deletedHomeMoodFallsBack = getPreferredMoodForRange({ + entries: [createEntry("entry_1", "calm")], + moodLogs: [{ ...createMoodLog("mood_1", "sad"), deletedAt: day.toISOString() }], + rangeEnd, + rangeStart, + userId, +}); +assert( + deletedHomeMoodFallsBack === "calm", + "Deleted Home mood logs must not override entry moods.", +); diff --git a/tests/report-analytics.test.ts b/tests/report-analytics.test.ts new file mode 100644 index 0000000..6580e33 --- /dev/null +++ b/tests/report-analytics.test.ts @@ -0,0 +1,136 @@ +import { + buildReportAnalytics, + type JournalEntryRow, + type MoodLogRow, +} from "../supabase/functions/_shared/buildReportAnalytics"; + +function assert(condition: boolean, message: string) { + if (!condition) { + throw new Error(message); + } +} + +function createEntry( + id: string, + content: string, + tags: string[] = [], +): JournalEntryRow { + return { + content, + created_at: `2026-06-${id.padStart(2, "0")}T10:00:00.000Z`, + id, + mood: null, + prompt: null, + tags, + title: "Daily reflection", + type: "free_write", + updated_at: `2026-06-${id.padStart(2, "0")}T10:00:00.000Z`, + }; +} + +const entries = [ + createEntry( + "1", + "I'll focus on the app. Every challenge affects everything, everything.", + ["AI", "breakup", "breakup"], + ), + createEntry( + "2", + "I'll keep my focus while every night feels different and everything changes.", + ["AI", "breakup"], + ), + createEntry("3", "Productivity productivity productivity helped today."), +]; + +const report = buildReportAnalytics({ + dataWasCapped: false, + entries, + periodEnd: new Date("2026-06-30T23:59:59.999Z"), + periodStart: new Date("2026-06-01T00:00:00.000Z"), + timezone: "UTC", +}); +const themes = report.recurringThemes; + +assert( + themes.some((theme) => theme.name === "focus" && theme.count === 2), + "A meaningful word repeated across separate entries should be a theme.", +); +assert( + themes.some( + (theme) => + theme.name === "ai" && theme.count === 2 && theme.source === "tag", + ), + "Short, intentional tags should remain valid themes.", +); +assert( + themes.some((theme) => theme.name === "breakup" && theme.count === 2), + "Duplicate tags in one entry should count only once per entry.", +); +assert( + !themes.some((theme) => + ["every", "everything", "i'll", "night"].includes(theme.name), + ), + "Generic filler and contraction fragments must not become themes.", +); +assert( + !themes.some((theme) => theme.name === "productivity"), + "Repeating a word inside one entry must not make it recurring.", +); + +function createMoodEntry(id: string, mood: string, date: string): JournalEntryRow { + return { + content: "Mood entry", + created_at: date, + id, + mood, + prompt: null, + tags: [], + title: "Mood entry", + type: "free_write", + updated_at: date, + }; +} + +function createMoodLog( + id: string, + mood: string, + createdAt: string, + updatedAt = createdAt, +): MoodLogRow { + return { + created_at: createdAt, + id, + mood, + updated_at: updatedAt, + }; +} + +const moodAnalytics = buildReportAnalytics({ + dataWasCapped: false, + entries: [ + createMoodEntry("mood_entry_1", "sad", "2026-06-10T10:00:00.000Z"), + createMoodEntry("mood_entry_2", "sad", "2026-06-10T12:00:00.000Z"), + ], + moodLogs: [ + createMoodLog("mood_log_1", "happy", "2026-06-10T08:00:00.000Z"), + createMoodLog("mood_log_2", "calm", "2026-06-11T08:00:00.000Z"), + ], + periodEnd: new Date("2026-06-30T23:59:59.999Z"), + periodStart: new Date("2026-06-01T00:00:00.000Z"), + timezone: "UTC", +}); +const entryDay = moodAnalytics.moodTimeline.find( + (item) => item.date === "2026-06-10", +); +const moodOnlyDay = moodAnalytics.moodTimeline.find( + (item) => item.date === "2026-06-11", +); + +assert( + entryDay?.dominantMood === "happy", + "A Home mood check-in must override the day's dominant entry mood.", +); +assert( + moodOnlyDay?.dominantMood === "calm" && moodOnlyDay.entryCount === 0, + "A Home mood check-in must create a mood timeline day without an entry.", +); diff --git a/tests/report-narrative-parser.test.ts b/tests/report-narrative-parser.test.ts index 233ebb7..a19bba1 100644 --- a/tests/report-narrative-parser.test.ts +++ b/tests/report-narrative-parser.test.ts @@ -6,7 +6,7 @@ function assert(condition: boolean, message: string) { } } -const longOverview = `${"A complete reflection keeps every detail visible. ".repeat(24)}to`; +const longOverview = `${"A complete reflection keeps every detail visible. ".repeat(24)}Nothing is lost.`; const patterns = Array.from( { length: 10 }, (_, index) => `Complete pattern ${index + 1}.`, @@ -76,3 +76,27 @@ if ("reason" in invalid) { "Invalid field types should return a safe diagnostic reason.", ); } + +const incompleteChallenge = parseReportNarrative( + JSON.stringify({ + ...validNarrative, + challenges: ["Missing someone and struggling with"], + }), + false, +); +assert( + !incompleteChallenge.ok, + "Narrative items ending in connector words must be rejected as incomplete.", +); + +const ellipsizedPattern = parseReportNarrative( + JSON.stringify({ + ...validNarrative, + patterns: ["The user often focuses on…"], + }), + false, +); +assert( + !ellipsizedPattern.ok, + "Ellipsized narrative items must be rejected as incomplete.", +); From 48c3c5bdff02c1e69133c2b54bb3aee9e02b6432 Mon Sep 17 00:00:00 2001 From: aryansoni-dev Date: Thu, 2 Jul 2026 00:21:55 +0530 Subject: [PATCH 3/3] [+] Implemented code-rabbit's suggested patches. --- AGENTS.md | 2 +- components/ai-chat/ai-chat-screen.tsx | 24 ++--- components/ai/ai-response-renderer.tsx | 36 +++++--- components/auth/auth-screen.tsx | 2 +- components/insights/insights-screen.tsx | 31 +++++-- .../insights/report/ReportNarrativeBlocks.tsx | 29 +++--- hooks/useAIInsightReport.ts | 80 ++++++++++++++++- lib/text/parse-ai-markdown.ts | 89 +++++++++++++++++-- store/useChatStore.ts | 5 +- supabase/functions/journal-ai-chat/index.ts | 7 +- supabase/functions/reflect-on-entry/index.ts | 8 +- tests/ai-text-rendering.test.ts | 20 ++++- tests/report-analytics.test.ts | 10 ++- 13 files changed, 279 insertions(+), 64 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c5c7daa..6823992 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -281,6 +281,6 @@ Before every feature: - Keep 'leading-6' in all components, and fix if you find any un-matching ones. -- If you see any svgs in the attached design look for them in the '/assets' folder. If we don't have it, try to create them yourself like you created the google icon in the login screens. +- If you see any svgs in the attached design look for them in the '/assets' folder. If we don't have it, try to create them yourself like you created the Google icon in the login screens. - Keep the text sizes like : headings, subheadings, body text, etc consistent on each screen. diff --git a/components/ai-chat/ai-chat-screen.tsx b/components/ai-chat/ai-chat-screen.tsx index cc20214..3bfcd61 100644 --- a/components/ai-chat/ai-chat-screen.tsx +++ b/components/ai-chat/ai-chat-screen.tsx @@ -245,10 +245,7 @@ export function AiChatScreen({ contentSize.height - nearBottomThreshold; isNearBottomRef.current = isNearBottom; - - if (isNearBottom) { - setShowJumpToLatest(false); - } + setShowJumpToLatest(!isNearBottom); } function handleChatContentSizeChange() { @@ -677,10 +674,9 @@ function ChatBubble({ userBubbleMaxWidth: number; }) { const isUser = message.role === "user"; - const messageText = - isFirstAssistant && message.role === "assistant" - ? `Hi ${displayName} 🌸 ${message.content}` - : message.content; + const firstAssistantGreeting = isFirstAssistant + ? `Hi ${displayName} 🌸` + : null; const messageTime = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", @@ -719,7 +715,7 @@ function ChatBubble({ > @@ -755,8 +751,16 @@ function ChatBubble({ overflow: "visible", }} > + {firstAssistantGreeting ? ( + + {firstAssistantGreeting} + + ) : null} { @@ -94,6 +96,7 @@ export const AIResponseRenderer = memo(function AIResponseRenderer({ isStreaming={isStreaming} selectable={selectable} testID={testID} + textClassName={textClassName} variant={variant} /> @@ -106,6 +109,7 @@ function AIResponseContent({ isStreaming, selectable, testID, + textClassName, variant, }: Required< Pick< @@ -113,7 +117,10 @@ function AIResponseContent({ "content" | "isStreaming" | "selectable" | "variant" > > & - Pick) { + Pick< + AIResponseRendererProps, + "accessibilityLabel" | "testID" | "textClassName" + >) { const blocks = useMemo( () => isStreaming @@ -134,6 +141,7 @@ function AIResponseContent({ index={index} key={`${block.type}-${index}`} selectable={selectable} + textClassName={textClassName} variant={variant} /> ))} @@ -145,11 +153,13 @@ function AIResponseBlock({ block, index, selectable, + textClassName, variant, }: { block: AITextBlock; index: number; selectable: boolean; + textClassName?: string; variant: AIResponseVariant; }) { const classes = variantClasses[variant]; @@ -197,7 +207,7 @@ function AIResponseBlock({ return ( parseInlineMarkdown(text), [text]); + const getRenderedContent = (content: string) => + selectable ? content : addSafeBreakOpportunities(content); return ( - {addSafeBreakOpportunities(part.content)} + {getRenderedContent(part.content)} ); } @@ -295,7 +307,7 @@ function InlineMarkdownText({ if (part.type === "italic") { return ( - {addSafeBreakOpportunities(part.content)} + {getRenderedContent(part.content)} ); } @@ -307,7 +319,7 @@ function InlineMarkdownText({ key={index} style={{ fontFamily: "monospace" }} > - {addSafeBreakOpportunities(part.content)} + {getRenderedContent(part.content)} ); } @@ -320,12 +332,12 @@ function InlineMarkdownText({ key={index} onPress={() => openSafeLink(part.url)} > - {addSafeBreakOpportunities(part.content)} + {getRenderedContent(part.content)} ); } - return addSafeBreakOpportunities(part.content); + return getRenderedContent(part.content); })} ); @@ -412,7 +424,9 @@ class AIResponseErrorBoundary extends Component< style={safeTextStyle} textBreakStrategy="highQuality" > - {addSafeBreakOpportunities(this.props.content)} + {this.props.selectable + ? this.props.content + : addSafeBreakOpportunities(this.props.content)} ); } diff --git a/components/auth/auth-screen.tsx b/components/auth/auth-screen.tsx index 8d15254..305d606 100644 --- a/components/auth/auth-screen.tsx +++ b/components/auth/auth-screen.tsx @@ -634,7 +634,7 @@ function SocialButtons({ )} - + Continue with Apple diff --git a/components/insights/insights-screen.tsx b/components/insights/insights-screen.tsx index 1635158..0902756 100644 --- a/components/insights/insights-screen.tsx +++ b/components/insights/insights-screen.tsx @@ -124,14 +124,21 @@ export function InsightsScreen() { const entries = useJournalStore((state) => state.entries); const moodLogs = useMoodLogStore((state) => state.allMoodLogs); const moodLogHasHydrated = useMoodLogStore((state) => state.hasHydrated); + const moodLogHydrationError = useMoodLogStore( + (state) => state.hydrationError, + ); const hasHydrated = useJournalHydrationStore( (state) => state.hasHydrated, ); const hydrationError = useJournalHydrationStore( (state) => state.hydrationError, ); - const localDataHasHydrated = hasHydrated && moodLogHasHydrated; - const showHydrationState = useDelayedVisibility(!localDataHasHydrated); + const localHydrationError = hydrationError ?? moodLogHydrationError; + const localDataHasHydrated = + hasHydrated && moodLogHasHydrated && !localHydrationError; + const showHydrationState = useDelayedVisibility( + !localDataHasHydrated && !localHydrationError, + ); const [selectedPeriod, setSelectedPeriod] = useState("week"); const [selectedReferenceDate, setSelectedReferenceDate] = useState( () => new Date(), @@ -148,10 +155,10 @@ export function InsightsScreen() { [reportDate], ); const weeklyReportState = useAIInsightReport(weeklyPeriod, { - enabled: hasHydrated, + enabled: localDataHasHydrated, }); const monthlyReportState = useAIInsightReport(monthlyPeriod, { - enabled: hasHydrated, + enabled: localDataHasHydrated, }); const insights = useMemo( () => @@ -240,8 +247,14 @@ export function InsightsScreen() { params: { source: "insights" }, } as Href; - function retryJournalHydration() { - retryJournalStoreHydration(); + function retryLocalHydration() { + if (hydrationError) { + retryJournalStoreHydration(); + } + + if (moodLogHydrationError) { + void useMoodLogStore.persist.rehydrate(); + } } return ( @@ -276,11 +289,11 @@ export function InsightsScreen() { title="Your Insights ✨" /> - {hydrationError ? ( + {localHydrationError ? ( ) : !localDataHasHydrated ? ( diff --git a/components/insights/report/ReportNarrativeBlocks.tsx b/components/insights/report/ReportNarrativeBlocks.tsx index 13b36fa..5435cbe 100644 --- a/components/insights/report/ReportNarrativeBlocks.tsx +++ b/components/insights/report/ReportNarrativeBlocks.tsx @@ -10,7 +10,6 @@ import { Pressable, Text, View } from "react-native"; import { AIResponseRenderer } from "@/components/ai/ai-response-renderer"; import { reportCardShadow, reportColors } from "@/constants/report-theme"; -import { addSafeBreakOpportunities } from "@/lib/text/add-safe-break-opportunities"; const reportBodyTextStyle = { fontSize: 15, @@ -102,13 +101,12 @@ export function EmotionalFlowCard({ stages }: { stages: string[] }) { className="rounded-full px-5 py-3" style={{ backgroundColor: reportColors.lavender }} > - - {addSafeBreakOpportunities(stage)} - + {index < stages.length - 1 ? ( @@ -183,13 +181,14 @@ export function NextFocusCard({ focus }: { focus: string }) { Focus for Next Period - - {addSafeBreakOpportunities(focus)} - + + + ); } diff --git a/hooks/useAIInsightReport.ts b/hooks/useAIInsightReport.ts index 36b7b93..d45c6e9 100644 --- a/hooks/useAIInsightReport.ts +++ b/hooks/useAIInsightReport.ts @@ -1,4 +1,5 @@ import { useAuth } from "@clerk/expo"; +import * as Crypto from "expo-crypto"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { @@ -54,6 +55,9 @@ export function useAIInsightReport( ); const moodLogs = useMoodLogStore((state) => state.allMoodLogs); const moodLogsHaveHydrated = useMoodLogStore((state) => state.hasHydrated); + const moodLogsHydrationError = useMoodLogStore( + (state) => state.hydrationError, + ); const cacheKey = useMemo(() => getReportCacheKey(period), [period]); const cachedReport = useAIInsightReportStore((state) => { const candidate = userId @@ -79,6 +83,10 @@ export function useAIInsightReport( const [isGenerating, setIsGenerating] = useState(false); const [legacyReportAvailable, setLegacyReportAvailable] = useState(false); const [error, setError] = useState(null); + const [localSnapshot, setLocalSnapshot] = useState<{ + hash: string; + input: string; + } | null>(null); const requestContextVersionRef = useRef(0); const requestInFlightRef = useRef(false); const reportRef = useRef(report); @@ -95,10 +103,39 @@ export function useAIInsightReport( () => getLocalSource(periodEntries, periodMoodLogs), [periodEntries, periodMoodLogs], ); + const localSnapshotHash = + localSnapshot?.input === localSource.snapshotInput + ? localSnapshot.hash + : null; const isStale = Boolean( - report && isReportStale(report, localSource), + report && + isReportStale(report, { + ...localSource, + snapshotHash: localSnapshotHash, + }), ); + useEffect(() => { + let isActive = true; + const snapshotInput = localSource.snapshotInput; + + void Crypto.digestStringAsync( + Crypto.CryptoDigestAlgorithm.SHA256, + snapshotInput, + { encoding: Crypto.CryptoEncoding.HEX }, + ) + .then((hash) => { + if (isActive) { + setLocalSnapshot({ hash, input: snapshotInput }); + } + }) + .catch(() => undefined); + + return () => { + isActive = false; + }; + }, [localSource.snapshotInput]); + useEffect(() => { reportRef.current = report; }, [report]); @@ -145,7 +182,12 @@ export function useAIInsightReport( return; } - if (!cacheHasHydrated) { + if (moodLogsHydrationError) { + setError(moodLogsHydrationError.userMessage); + return; + } + + if (!cacheHasHydrated || !hasHydrated || !moodLogsHaveHydrated) { return; } @@ -204,6 +246,9 @@ export function useAIInsightReport( cacheHydrationError, enabled, isCurrentRequestContext, + hasHydrated, + moodLogsHaveHydrated, + moodLogsHydrationError, period, removeCachedReport, setCachedReport, @@ -225,6 +270,7 @@ export function useAIInsightReport( enabled, hasHydrated, moodLogsHaveHydrated, + moodLogsHydrationError, refresh, ]); @@ -239,6 +285,11 @@ export function useAIInsightReport( return; } + if (moodLogsHydrationError) { + setError(moodLogsHydrationError.userMessage); + return; + } + if ( !cacheHasHydrated || !hasHydrated || @@ -320,6 +371,7 @@ export function useAIInsightReport( hasHydrated, isCurrentRequestContext, moodLogsHaveHydrated, + moodLogsHydrationError, period, runAutoSync, setCachedReport, @@ -428,6 +480,15 @@ function getMoodLogsInPeriod( function getLocalSource(entries: JournalEntry[], moodLogs: MoodLog[]) { const entryIds = entries.map((entry) => entry.id).sort(); + const moodLogIds = moodLogs.map((moodLog) => moodLog.id).sort(); + const snapshotInput = [ + ...entries.map((entry) => `entry:${entry.id}:${entry.updatedAt}`), + ...moodLogs.map( + (moodLog) => `mood:${moodLog.id}:${moodLog.updatedAt}`, + ), + ] + .sort() + .join("|"); const latestUpdatedAt = [...entries, ...moodLogs] .map((source) => source.updatedAt) @@ -439,6 +500,9 @@ function getLocalSource(entries: JournalEntry[], moodLogs: MoodLog[]) { count: entries.length, entryIds, latestUpdatedAt, + moodLogCount: moodLogIds.length, + moodLogIds, + snapshotInput, }; } @@ -458,6 +522,10 @@ function isReportStale( count: number; entryIds: string[]; latestUpdatedAt: string | null; + moodLogCount: number; + moodLogIds: string[]; + snapshotHash: string | null; + snapshotInput: string; }, ) { if (!areSameStringSet(report.relatedEntryIds, localSource.entryIds)) { @@ -468,6 +536,14 @@ function isReportStale( return true; } + if ( + report.sourceSnapshotHash.trim() && + localSource.snapshotHash && + report.sourceSnapshotHash !== localSource.snapshotHash + ) { + return true; + } + if (!report.sourceLatestUpdatedAt) { return isTimestampAfter(localSource.latestUpdatedAt, report.updatedAt); } diff --git a/lib/text/parse-ai-markdown.ts b/lib/text/parse-ai-markdown.ts index 6a615bc..cbd79fd 100644 --- a/lib/text/parse-ai-markdown.ts +++ b/lib/text/parse-ai-markdown.ts @@ -69,10 +69,15 @@ export function parseAITextBlocks(content: string): AITextBlock[] { } if (isTableStart(lines, index)) { + const tableShape = getTableShape(line, lines[index + 1]); const tableLines = [line, lines[index + 1]]; index += 2; - while (index < lines.length && lines[index].includes("|")) { + while ( + index < lines.length && + tableShape && + matchesTableShape(lines[index], tableShape) + ) { tableLines.push(lines[index]); index += 1; } @@ -161,17 +166,85 @@ function isBlockStart(lines: string[], index: number) { function isTableStart(lines: string[], index: number) { const header = lines[index]; const separator = lines[index + 1]; + const separatorRow = getTableRow(separator); + const tableShape = getTableShape(header, separator); - if (!header?.includes("|") || !separator?.includes("|")) { + if (!separatorRow || !tableShape) { return false; } - const cells = separator - .trim() - .replace(/^\||\|$/g, "") - .split("|") - .map((cell) => cell.trim()); + return separatorRow.cells.every((cell) => /^:?-{3,}:?$/.test(cell)); +} + +type TableRow = { + cells: string[]; + hasLeadingPipe: boolean; + hasTrailingPipe: boolean; +}; + +type TableShape = { + columnCount: number; + hasLeadingPipe: boolean | null; + hasTrailingPipe: boolean | null; +}; + +function getTableRow(line: string | undefined): TableRow | null { + const trimmedLine = line?.trim(); - return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell)); + if (!trimmedLine?.includes("|")) { + return null; + } + + const hasLeadingPipe = trimmedLine.startsWith("|"); + const hasTrailingPipe = trimmedLine.endsWith("|"); + const rowContent = trimmedLine + .slice(hasLeadingPipe ? 1 : 0, hasTrailingPipe ? -1 : undefined); + const cells = rowContent.split("|").map((cell) => cell.trim()); + + if (cells.length < 2) { + return null; + } + + return { cells, hasLeadingPipe, hasTrailingPipe }; } +function getTableShape( + headerLine: string | undefined, + separatorLine: string | undefined, +): TableShape | null { + const headerRow = getTableRow(headerLine); + const separatorRow = getTableRow(separatorLine); + + if ( + !headerRow || + !separatorRow || + headerRow.cells.length !== separatorRow.cells.length + ) { + return null; + } + + return { + columnCount: headerRow.cells.length, + hasLeadingPipe: + headerRow.hasLeadingPipe === separatorRow.hasLeadingPipe + ? headerRow.hasLeadingPipe + : null, + hasTrailingPipe: + headerRow.hasTrailingPipe === separatorRow.hasTrailingPipe + ? headerRow.hasTrailingPipe + : null, + }; +} + +function matchesTableShape(line: string, expectedShape: TableShape) { + const candidateRow = getTableRow(line); + + return Boolean( + candidateRow && + candidateRow.cells.length === expectedShape.columnCount && + (expectedShape.hasLeadingPipe === null || + candidateRow.hasLeadingPipe === expectedShape.hasLeadingPipe) && + (expectedShape.hasTrailingPipe === null || + candidateRow.hasTrailingPipe === expectedShape.hasTrailingPipe), + ); +} diff --git a/store/useChatStore.ts b/store/useChatStore.ts index 8036add..1d9885d 100644 --- a/store/useChatStore.ts +++ b/store/useChatStore.ts @@ -56,7 +56,10 @@ export const useChatStore = create()( logAITextIntegrity({ length: message.content.length, stage: "stored", - surface: `ai_chat_${message.role}`, + surface: + message.role === "assistant" + ? "ai_chat_message" + : `ai_chat_${message.role}`, }); set((state) => ({ diff --git a/supabase/functions/journal-ai-chat/index.ts b/supabase/functions/journal-ai-chat/index.ts index dc24f7a..d44d0ae 100644 --- a/supabase/functions/journal-ai-chat/index.ts +++ b/supabase/functions/journal-ai-chat/index.ts @@ -483,6 +483,7 @@ Deno.serve(async (request) => { try { const assistantMessage = await callAIProvider(summaryPrompt, { maxTokens: 950, + requestId, systemPromptText: summarySystemPrompt, temperature: 0.55, }); @@ -568,7 +569,7 @@ Deno.serve(async (request) => { }); try { - const assistantMessage = await callAIProvider(finalPrompt); + const assistantMessage = await callAIProvider(finalPrompt, { requestId }); console.info("journal-ai-chat provider_succeeded", { requestId }); @@ -1470,8 +1471,9 @@ Instructions: async function callAIProvider( finalPrompt: string, - options?: { + options: { maxTokens?: number; + requestId: string; systemPromptText?: string; temperature?: number; }, @@ -1537,6 +1539,7 @@ async function callAIProvider( console.info("journal-ai-chat provider_response_received", { characterCount: content.length, finishReason: providerMessage?.finishReason, + requestId: options.requestId, }); if (providerMessage?.finishReason === "length") { diff --git a/supabase/functions/reflect-on-entry/index.ts b/supabase/functions/reflect-on-entry/index.ts index 2862815..68e666f 100644 --- a/supabase/functions/reflect-on-entry/index.ts +++ b/supabase/functions/reflect-on-entry/index.ts @@ -45,6 +45,7 @@ const journalEntrySelectWithoutTags = const maxEntryContentLength = 8000; const maxTagCount = 10; const maxReflectionAttempts = 3; +const maxReflectionListItems = 6; type ReflectOnEntryRequest = { entryId: string; @@ -922,7 +923,8 @@ function getTrimmedStringArray(value: unknown) { return sourceValues .filter((item): item is string => typeof item === "string") .map((item) => cleanSingleLine(item)) - .filter(Boolean); + .filter(Boolean) + .slice(0, maxReflectionListItems); } function getReflectionText( @@ -939,6 +941,10 @@ function getReflectionText( return null; } + if (isIncompleteReflectionText(trimmedValue)) { + return null; + } + const sanitizedValue = ensureReflectionTerminal(trimmedValue, terminal); if (!sanitizedValue || isIncompleteReflectionText(sanitizedValue)) { diff --git a/tests/ai-text-rendering.test.ts b/tests/ai-text-rendering.test.ts index 876d368..be38869 100644 --- a/tests/ai-text-rendering.test.ts +++ b/tests/ai-text-rendering.test.ts @@ -68,6 +68,25 @@ assert( "Markdown tables must use the wide-content path.", ); +const tableWithTrailingProse = parseAITextBlocks( + "| Mood | Count |\n| --- | --- |\n| Calm | 2 |\nThis prose contains | a pipe but is not a table row.", +); +const parsedTable = tableWithTrailingProse.find( + (block) => block.type === "table", +); +assert( + parsedTable?.content === + "| Mood | Count |\n| --- | --- |\n| Calm | 2 |", + "Table parsing must stop when a pipe-containing line does not match the table structure.", +); +assert( + tableWithTrailingProse.some( + (block) => + block.type === "paragraph" && block.content.includes("not a table row"), + ), + "Pipe-containing prose after a table must remain a paragraph.", +); + const breakableText = addSafeBreakOpportunities(longTokenAIResponse); assert( breakableText.includes("\u200B"), @@ -90,4 +109,3 @@ assert( getBlockContent(reportAIResponse).includes("Next focus"), "The final report section must remain in parsed output.", ); - diff --git a/tests/report-analytics.test.ts b/tests/report-analytics.test.ts index 6580e33..6bcd288 100644 --- a/tests/report-analytics.test.ts +++ b/tests/report-analytics.test.ts @@ -113,6 +113,12 @@ const moodAnalytics = buildReportAnalytics({ ], moodLogs: [ createMoodLog("mood_log_1", "happy", "2026-06-10T08:00:00.000Z"), + createMoodLog( + "mood_log_1_newer", + "grateful", + "2026-06-10T09:00:00.000Z", + "2026-06-10T13:00:00.000Z", + ), createMoodLog("mood_log_2", "calm", "2026-06-11T08:00:00.000Z"), ], periodEnd: new Date("2026-06-30T23:59:59.999Z"), @@ -127,8 +133,8 @@ const moodOnlyDay = moodAnalytics.moodTimeline.find( ); assert( - entryDay?.dominantMood === "happy", - "A Home mood check-in must override the day's dominant entry mood.", + entryDay?.dominantMood === "grateful", + "The latest-updated Home mood check-in must override the day's dominant entry mood.", ); assert( moodOnlyDay?.dominantMood === "calm" && moodOnlyDay.entryCount === 0,