Files

1 line
71 KiB
JSON

{"repo": "cameronlhwa/lingoisland", "files": {"lib/stories/getOrCreateDailyStory.ts": {"fd32bfbfa5d5021d0757631d1df59818b060adcc": {"t": 1785745579.859877, "n": 12461, "h": "f24b320cab21f864", "c": "import type { SupabaseClient } from \"@supabase/supabase-js\";\nimport { getLocalDateKey } from \"@/lib/utils/date\";\n\ntype StoryRow = {\n id: string;\n user_id: string;\n kind: \"daily\" | \"custom\";\n date: string | null;\n title: string;\n title_en: string | null;\n level: string;\n length_chars: number;\n topic: string | null;\n story_zh: string;\n story_en: string | null;\n story_pinyin: string | null;\n source_island_ids: string[];\n target_word_ids: string[];\n created_at: string;\n};\n\ntype IslandRow = { id: string; topic: string };\ntype WordRow = { id: string; hanzi: string; pinyin: string; english: string };\n\nfunction getTodayDate() {\n return getLocalDateKey();\n}\n\nfunction sample<T>(items: T[], count: number) {\n const copy = [...items];\n for (let i = copy.length - 1; i > 0; i -= 1) {\n const j = Math.floor(Math.random() * (i + 1));\n [copy[i], copy[j]] = [copy[j], copy[i]];\n }\n return copy.slice(0, count);\n}\n\nfunction countStoryChars(text: string) {\n return text.replace(/\\s/g, \"\").length;\n}\n\nfunction stripCodeFence(content: string) {\n let jsonContent = content.trim();\n if (jsonContent.startsWith(\"```\")) {\n jsonContent = jsonContent\n .replace(/^```(?:json)?\\n/, \"\")\n .replace(/\\n```$/, \"\");\n }\n return jsonContent;\n}\n\nfunction extractJsonObject(content: string) {\n const trimmed = stripCodeFence(content);\n const start = trimmed.indexOf(\"{\");\n const end = trimmed.lastIndexOf(\"}\");\n if (start === -1 || end === -1 || end <= start) {\n return trimmed;\n }\n let extracted = trimmed.slice(start, end + 1);\n \n // Fix common JSON issues from LLM responses\n // 1. Replace smart quotes with regular quotes\n extracted = extracted.replace(/[\\u201C\\u201D]/g, '\"').replace(/[\\u2018\\u2019]/g, \"'\");\n \n // 2. Fix unescaped newlines in strings (common in Chinese text)\n try {\n // Try to parse first\n JSON.parse(extracted);\n return extracted;\n } catch (e) {\n // If it fails, try to fix common issues\n // Replace literal newlines inside strings with \\n\n extracted = extracted.replace(/(\"(?:[^\"\\\\]|\\\\.)*?\")/g, (match) => {\n return match.replace(/\\n/g, '\\\\n').replace(/\\r/g, '\\\\r');\n });\n }\n \n return extracted;\n}\n\nasync function generateDailyStory({\n topic,\n level,\n lengthChars,\n targetWords,\n sourceIslandIds,\n maxAttempts = 4,\n}: {\n topic: string;\n level: string;\n lengthChars: number;\n targetWords: WordRow[];\n sourceIslandIds: string[];\n maxAttempts?: number;\n}) {\n const minLength = Math.max(80, Math.round(lengthChars * 0.6));\n const maxLength = Math.min(260, Math.round(lengthChars * 1.4));\n const apiKey = process.env.DEEPSEEK_API_KEY;\n if (!apiKey) {\n throw new Error(\"DEEPSEEK_API_KEY not configured\");\n }\n\n const targetWordList = targetWords.map((w) => w.hanzi).filter(Boolean);\n \n // Level-specific style guidance\n const levelGuidance: Record<string, string> = {\n A1: `\\nSTYLE FOR A1: Very simple sentences (5-10 chars), present tense, basic patterns (我 + verb), concrete survival vocabulary.`,\n A2: `\\nSTYLE FOR A2: Simple clear sentences (8-15 chars), everyday vocabulary, basic connectors (然后、但是、因为).`,\n B1: `\\nSTYLE FOR B1: Natural conversational flow, mix of simple and compound sentences, casual connectors (其实、不过、虽然).`,\n B2: `\\nSTYLE FOR B2: Sophisticated narrative, varied structures, personal voice, modern expressions (limited).`,\n C1: `\\nSTYLE FOR C1: Complex nuanced narrative, idioms and chengyu, sophisticated discourse, cultural references OK.`,\n };\n \n // Normalize level (handle variations like A2-, A2, A2+)\n let baseLevel = level;\n if (level.match(/^(A1|A2|B1|B2|C1)[+-]?$/)) {\n baseLevel = level.replace(/[+-]/g, '');\n }\n const styleGuide = levelGuidance[baseLevel] || levelGuidance['B1'];\n \n const basePrompt = `You are a Mandarin Chinese storyteller for language learners.\n\nTopic: ${topic}\nLearner level: ${level}${styleGuide}\nTarget length: ${lengthChars} Chinese characters (count characters, not tokens)\nTarget words (optional to use): ${targetWordList.join(\", \")}\n\nStory requirements:\n- 5-8 sentences (adjust complexity and length based on ${baseLevel} level)\n- Match the style guidance for ${baseLevel} level\n- Use Simplified Chinese\n- Not all target words need to be used; include only what fits naturally\n- story_zh must be between ${minLength}-${maxLength} characters\n- If story_zh is too short, add a sentence or detail to reach the minimum\n\nOutput STRICT JSON only with this shape:\n{\n \"title\": \"...\",\n \"title_en\": \"...\",\n \"level\": \"${level}\",\n \"length_chars\": ${lengthChars},\n \"story_zh\": \"...\",\n \"story_en\": null,\n \"story_pinyin\": null,\n \"source_island_ids\": ${JSON.stringify(sourceIslandIds)},\n \"target_word_ids\": ${JSON.stringify(targetWords.map((w) => w.id))}\n}\n\nRules for title and title_en:\n- \"title\" must be in Simplified Chinese (e.g. \"人工智能备案记\")\n- \"title_en\" must be the English translation of the title (e.g. \"AI Registration Record\")`;\n\n let lastError: Error | null = null;\n\n let extraNote = \"\";\n for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {\n const prompt = `${basePrompt}${extraNote ? `\\n\\n${extraNote}` : \"\"}`;\n const response = await fetch(\n \"https://api.deepseek.com/v1/chat/completions\",\n {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model: \"deepseek-chat\",\n messages: [\n {\n role: \"system\",\n content:\n \"You are a helpful assistant that generates structured JSON data for Chinese language learning. Always respond with valid JSON only, no markdown formatting.\",\n },\n {\n role: \"user\",\n content: prompt,\n },\n ],\n temperature: 0.9,\n frequency_penalty: 0.6,\n presence_penalty: 0.2,\n max_tokens: 3000,\n }),\n }\n );\n\n if (!response.ok) {\n const errorText = await response.text();\n throw new Error(\n `DeepSeek API error: ${response.status} ${response.statusText} - ${errorText}`\n );\n }\n\n const data = await response.json();\n const content = data.choices?.[0]?.message?.content;\n if (!content) {\n lastError = new Error(\"No content in DeepSeek response\");\n continue;\n }\n\n let parsed: {\n title?: string;\n title_en?: string | null;\n story_zh?: string;\n story_en?: string | null;\n story_pinyin?: string | null;\n };\n try {\n const extracted = extractJsonObject(content);\n parsed = JSON.parse(extracted);\n } catch (error) {\n if (process.env.NODE_ENV === \"development\") {\n console.error(\"[Daily Story] Failed to parse DeepSeek response:\", {\n error: error instanceof Error ? error.message : error,\n attempt,\n contentPreview: content.substring(0, 200),\n });\n }\n lastError = new Error(`Failed to parse DeepSeek JSON: ${error}`);\n extraNote =\n \"Return STRICT JSON only. Do not include extra text or markdown.\";\n continue;\n }\n\n if (!parsed.title || !parsed.story_zh) {\n lastError = new Error(\"Missing title or story_zh in DeepSeek output\");\n continue;\n }\n\n const charCount = countStoryChars(parsed.story_zh);\n if (charCount < minLength || charCount > maxLength) {\n lastError = new Error(\n `story_zh length out of bounds: ${charCount} characters`\n );\n extraNote =\n `Adjust story_zh length to ${minLength}-${maxLength} Chinese characters. Count characters carefully.`;\n continue;\n }\n\n return {\n title: parsed.title.trim(),\n title_en: parsed.title_en?.trim() || null,\n story_zh: parsed.story_zh.trim(),\n story_en: parsed.story_en?.trim() || null,\n story_pinyin: parsed.story_pinyin?.trim() || null,\n };\n }\n\n throw lastError || new Error(\"Failed to generate daily story\");\n}\n\nasync function selectDailyStoryContext({\n supabaseServerClient,\n userId,\n date,\n}: {\n supabaseServerClient: SupabaseClient;\n userId: string;\n date: string;\n}) {\n const { data: profile } = await supabaseServerClient\n .from(\"user_profiles\")\n .select(\"cefr_level\")\n .eq(\"user_id\", userId)\n .maybeSingle();\n\n const level = profile?.cefr_level || \"B1\";\n\n const { data: islands, error: islandsError } = await supabaseServerClient\n .from(\"topic_islands\")\n .select(\"id, topic\")\n .eq(\"user_id\", userId)\n .order(\"created_at\", { ascending: false });\n\n if (islandsError || !islands || islands.length === 0) {\n return null;\n }\n\n const { data: words, error: wordsError } = await supabaseServerClient\n .from(\"island_words\")\n .select(\"id, hanzi, pinyin, english, island_id\")\n .in(\"island_id\", (islands as IslandRow[]).map((island) => island.id))\n .eq(\"user_id\", userId);\n\n if (wordsError || !words || words.length === 0) {\n return null;\n }\n\n const wordsByIsland = new Map<string, WordRow[]>();\n for (const word of words as (WordRow & { island_id: string })[]) {\n if (!wordsByIsland.has(word.island_id)) {\n wordsByIsland.set(word.island_id, []);\n }\n wordsByIsland.get(word.island_id)!.push(word);\n }\n\n const eligibleIslands = (islands as IslandRow[]).filter((island) =>\n wordsByIsland.has(island.id)\n );\n\n if (eligibleIslands.length === 0) {\n return null;\n }\n\n const islandCount = eligibleIslands.length >= 2 ? 2 : 1;\n const selectedIslands = sample(eligibleIslands, islandCount);\n const islandIds = selectedIslands.map((island) => island.id);\n\n const selectedIslandWords = selectedIslands.flatMap(\n (island) => wordsByIsland.get(island.id) || []\n );\n\n const targetCount = Math.min(14, Math.max(10, 12));\n const selectedWords =\n selectedIslandWords.length <= targetCount\n ? selectedIslandWords\n : sample(selectedIslandWords, targetCount);\n\n const lengthChars = 140 + Math.floor(Math.random() * 40);\n const storyTopic =\n selectedIslands.map((island) => island.topic).join(\", \") || \"Daily life\";\n\n return {\n level,\n islandIds,\n selectedWords,\n storyTopic,\n lengthChars,\n };\n}\n\nexport async function getOrCreateDailyStory({\n supabaseServerClient,\n date,\n}: {\n supabaseServerClient: SupabaseClient;\n date?: string;\n}): Promise<StoryRow | null> {\n const {\n data: { user },\n } = await supabaseServerClient.auth.getUser();\n\n if (!user) {\n return null;\n }\n\n const targetDate = date || getTodayDate();\n\n await supabaseServerClient\n .from(\"stories\")\n .delete()\n .eq(\"user_id\", user.id)\n .eq(\"kind\", \"daily\")\n .eq(\"saved\", false)\n .lt(\"date\", targetDate);\n\n const { data: existing } = await supabaseServerClient\n .from(\"stories\")\n .select(\"*\")\n .eq(\"user_id\", user.id)\n .eq(\"kind\", \"daily\")\n .eq(\"date\", targetDate)\n .maybeSingle();\n\n if (existing) {\n return existing as StoryRow;\n }\n const context = await selectDailyStoryContext({\n supabaseServerClient,\n userId: user.id,\n date: targetDate,\n });\n\n if (!context) {\n return null;\n }\n\n try {\n const generated = await generateDailyStory({\n topic: context.storyTopic,\n level: context.level,\n lengthChars: context.lengthChars,\n targetWords: context.selectedWords,\n sourceIslandIds: context.islandIds,\n });\n\n const { data: inserted, error: insertError } = await supabaseServerClient\n .from(\"stories\")\n .insert({\n user_id: user.id,\n kind: \"daily\",\n date: targetDate,\n title: generated.title,\n title_en: generated.title_en,\n level: context.level,\n length_chars: context.lengthChars,\n topic: context.storyTopic,\n story_zh: generated.story_zh,\n story_en: generated.story_en,\n story_pinyin: generated.story_pinyin,\n source_island_ids: context.islandIds,\n target_word_ids: context.selectedWords.map((word) => word.id),\n requested_words: [],\n saved: false,\n })\n .select()\n .single();\n\n if (insertError) {\n const { data: fallback } = await supabaseServerClient\n .from(\"stories\")\n .select(\"*\")\n .eq(\"user_id\", user.id)\n .eq(\"kind\", \"daily\")\n .eq(\"date\", targetDate)\n .maybeSingle();\n\n return (fallback as StoryRow) || null;\n }\n\n return inserted as StoryRow;\n } catch (error) {\n console.error(\"Daily story generation failed:\", error);\n return null;\n }\n}\n\n"}, "11f01b777d6f84e48dbfdb3bbc702a509cb77aa7": {"t": 1785754446.7221262, "n": 12461, "h": "f24b320cab21f864", "c": "import type { SupabaseClient } from \"@supabase/supabase-js\";\nimport { getLocalDateKey } from \"@/lib/utils/date\";\n\ntype StoryRow = {\n id: string;\n user_id: string;\n kind: \"daily\" | \"custom\";\n date: string | null;\n title: string;\n title_en: string | null;\n level: string;\n length_chars: number;\n topic: string | null;\n story_zh: string;\n story_en: string | null;\n story_pinyin: string | null;\n source_island_ids: string[];\n target_word_ids: string[];\n created_at: string;\n};\n\ntype IslandRow = { id: string; topic: string };\ntype WordRow = { id: string; hanzi: string; pinyin: string; english: string };\n\nfunction getTodayDate() {\n return getLocalDateKey();\n}\n\nfunction sample<T>(items: T[], count: number) {\n const copy = [...items];\n for (let i = copy.length - 1; i > 0; i -= 1) {\n const j = Math.floor(Math.random() * (i + 1));\n [copy[i], copy[j]] = [copy[j], copy[i]];\n }\n return copy.slice(0, count);\n}\n\nfunction countStoryChars(text: string) {\n return text.replace(/\\s/g, \"\").length;\n}\n\nfunction stripCodeFence(content: string) {\n let jsonContent = content.trim();\n if (jsonContent.startsWith(\"```\")) {\n jsonContent = jsonContent\n .replace(/^```(?:json)?\\n/, \"\")\n .replace(/\\n```$/, \"\");\n }\n return jsonContent;\n}\n\nfunction extractJsonObject(content: string) {\n const trimmed = stripCodeFence(content);\n const start = trimmed.indexOf(\"{\");\n const end = trimmed.lastIndexOf(\"}\");\n if (start === -1 || end === -1 || end <= start) {\n return trimmed;\n }\n let extracted = trimmed.slice(start, end + 1);\n \n // Fix common JSON issues from LLM responses\n // 1. Replace smart quotes with regular quotes\n extracted = extracted.replace(/[\\u201C\\u201D]/g, '\"').replace(/[\\u2018\\u2019]/g, \"'\");\n \n // 2. Fix unescaped newlines in strings (common in Chinese text)\n try {\n // Try to parse first\n JSON.parse(extracted);\n return extracted;\n } catch (e) {\n // If it fails, try to fix common issues\n // Replace literal newlines inside strings with \\n\n extracted = extracted.replace(/(\"(?:[^\"\\\\]|\\\\.)*?\")/g, (match) => {\n return match.replace(/\\n/g, '\\\\n').replace(/\\r/g, '\\\\r');\n });\n }\n \n return extracted;\n}\n\nasync function generateDailyStory({\n topic,\n level,\n lengthChars,\n targetWords,\n sourceIslandIds,\n maxAttempts = 4,\n}: {\n topic: string;\n level: string;\n lengthChars: number;\n targetWords: WordRow[];\n sourceIslandIds: string[];\n maxAttempts?: number;\n}) {\n const minLength = Math.max(80, Math.round(lengthChars * 0.6));\n const maxLength = Math.min(260, Math.round(lengthChars * 1.4));\n const apiKey = process.env.DEEPSEEK_API_KEY;\n if (!apiKey) {\n throw new Error(\"DEEPSEEK_API_KEY not configured\");\n }\n\n const targetWordList = targetWords.map((w) => w.hanzi).filter(Boolean);\n \n // Level-specific style guidance\n const levelGuidance: Record<string, string> = {\n A1: `\\nSTYLE FOR A1: Very simple sentences (5-10 chars), present tense, basic patterns (我 + verb), concrete survival vocabulary.`,\n A2: `\\nSTYLE FOR A2: Simple clear sentences (8-15 chars), everyday vocabulary, basic connectors (然后、但是、因为).`,\n B1: `\\nSTYLE FOR B1: Natural conversational flow, mix of simple and compound sentences, casual connectors (其实、不过、虽然).`,\n B2: `\\nSTYLE FOR B2: Sophisticated narrative, varied structures, personal voice, modern expressions (limited).`,\n C1: `\\nSTYLE FOR C1: Complex nuanced narrative, idioms and chengyu, sophisticated discourse, cultural references OK.`,\n };\n \n // Normalize level (handle variations like A2-, A2, A2+)\n let baseLevel = level;\n if (level.match(/^(A1|A2|B1|B2|C1)[+-]?$/)) {\n baseLevel = level.replace(/[+-]/g, '');\n }\n const styleGuide = levelGuidance[baseLevel] || levelGuidance['B1'];\n \n const basePrompt = `You are a Mandarin Chinese storyteller for language learners.\n\nTopic: ${topic}\nLearner level: ${level}${styleGuide}\nTarget length: ${lengthChars} Chinese characters (count characters, not tokens)\nTarget words (optional to use): ${targetWordList.join(\", \")}\n\nStory requirements:\n- 5-8 sentences (adjust complexity and length based on ${baseLevel} level)\n- Match the style guidance for ${baseLevel} level\n- Use Simplified Chinese\n- Not all target words need to be used; include only what fits naturally\n- story_zh must be between ${minLength}-${maxLength} characters\n- If story_zh is too short, add a sentence or detail to reach the minimum\n\nOutput STRICT JSON only with this shape:\n{\n \"title\": \"...\",\n \"title_en\": \"...\",\n \"level\": \"${level}\",\n \"length_chars\": ${lengthChars},\n \"story_zh\": \"...\",\n \"story_en\": null,\n \"story_pinyin\": null,\n \"source_island_ids\": ${JSON.stringify(sourceIslandIds)},\n \"target_word_ids\": ${JSON.stringify(targetWords.map((w) => w.id))}\n}\n\nRules for title and title_en:\n- \"title\" must be in Simplified Chinese (e.g. \"人工智能备案记\")\n- \"title_en\" must be the English translation of the title (e.g. \"AI Registration Record\")`;\n\n let lastError: Error | null = null;\n\n let extraNote = \"\";\n for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {\n const prompt = `${basePrompt}${extraNote ? `\\n\\n${extraNote}` : \"\"}`;\n const response = await fetch(\n \"https://api.deepseek.com/v1/chat/completions\",\n {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model: \"deepseek-chat\",\n messages: [\n {\n role: \"system\",\n content:\n \"You are a helpful assistant that generates structured JSON data for Chinese language learning. Always respond with valid JSON only, no markdown formatting.\",\n },\n {\n role: \"user\",\n content: prompt,\n },\n ],\n temperature: 0.9,\n frequency_penalty: 0.6,\n presence_penalty: 0.2,\n max_tokens: 3000,\n }),\n }\n );\n\n if (!response.ok) {\n const errorText = await response.text();\n throw new Error(\n `DeepSeek API error: ${response.status} ${response.statusText} - ${errorText}`\n );\n }\n\n const data = await response.json();\n const content = data.choices?.[0]?.message?.content;\n if (!content) {\n lastError = new Error(\"No content in DeepSeek response\");\n continue;\n }\n\n let parsed: {\n title?: string;\n title_en?: string | null;\n story_zh?: string;\n story_en?: string | null;\n story_pinyin?: string | null;\n };\n try {\n const extracted = extractJsonObject(content);\n parsed = JSON.parse(extracted);\n } catch (error) {\n if (process.env.NODE_ENV === \"development\") {\n console.error(\"[Daily Story] Failed to parse DeepSeek response:\", {\n error: error instanceof Error ? error.message : error,\n attempt,\n contentPreview: content.substring(0, 200),\n });\n }\n lastError = new Error(`Failed to parse DeepSeek JSON: ${error}`);\n extraNote =\n \"Return STRICT JSON only. Do not include extra text or markdown.\";\n continue;\n }\n\n if (!parsed.title || !parsed.story_zh) {\n lastError = new Error(\"Missing title or story_zh in DeepSeek output\");\n continue;\n }\n\n const charCount = countStoryChars(parsed.story_zh);\n if (charCount < minLength || charCount > maxLength) {\n lastError = new Error(\n `story_zh length out of bounds: ${charCount} characters`\n );\n extraNote =\n `Adjust story_zh length to ${minLength}-${maxLength} Chinese characters. Count characters carefully.`;\n continue;\n }\n\n return {\n title: parsed.title.trim(),\n title_en: parsed.title_en?.trim() || null,\n story_zh: parsed.story_zh.trim(),\n story_en: parsed.story_en?.trim() || null,\n story_pinyin: parsed.story_pinyin?.trim() || null,\n };\n }\n\n throw lastError || new Error(\"Failed to generate daily story\");\n}\n\nasync function selectDailyStoryContext({\n supabaseServerClient,\n userId,\n date,\n}: {\n supabaseServerClient: SupabaseClient;\n userId: string;\n date: string;\n}) {\n const { data: profile } = await supabaseServerClient\n .from(\"user_profiles\")\n .select(\"cefr_level\")\n .eq(\"user_id\", userId)\n .maybeSingle();\n\n const level = profile?.cefr_level || \"B1\";\n\n const { data: islands, error: islandsError } = await supabaseServerClient\n .from(\"topic_islands\")\n .select(\"id, topic\")\n .eq(\"user_id\", userId)\n .order(\"created_at\", { ascending: false });\n\n if (islandsError || !islands || islands.length === 0) {\n return null;\n }\n\n const { data: words, error: wordsError } = await supabaseServerClient\n .from(\"island_words\")\n .select(\"id, hanzi, pinyin, english, island_id\")\n .in(\"island_id\", (islands as IslandRow[]).map((island) => island.id))\n .eq(\"user_id\", userId);\n\n if (wordsError || !words || words.length === 0) {\n return null;\n }\n\n const wordsByIsland = new Map<string, WordRow[]>();\n for (const word of words as (WordRow & { island_id: string })[]) {\n if (!wordsByIsland.has(word.island_id)) {\n wordsByIsland.set(word.island_id, []);\n }\n wordsByIsland.get(word.island_id)!.push(word);\n }\n\n const eligibleIslands = (islands as IslandRow[]).filter((island) =>\n wordsByIsland.has(island.id)\n );\n\n if (eligibleIslands.length === 0) {\n return null;\n }\n\n const islandCount = eligibleIslands.length >= 2 ? 2 : 1;\n const selectedIslands = sample(eligibleIslands, islandCount);\n const islandIds = selectedIslands.map((island) => island.id);\n\n const selectedIslandWords = selectedIslands.flatMap(\n (island) => wordsByIsland.get(island.id) || []\n );\n\n const targetCount = Math.min(14, Math.max(10, 12));\n const selectedWords =\n selectedIslandWords.length <= targetCount\n ? selectedIslandWords\n : sample(selectedIslandWords, targetCount);\n\n const lengthChars = 140 + Math.floor(Math.random() * 40);\n const storyTopic =\n selectedIslands.map((island) => island.topic).join(\", \") || \"Daily life\";\n\n return {\n level,\n islandIds,\n selectedWords,\n storyTopic,\n lengthChars,\n };\n}\n\nexport async function getOrCreateDailyStory({\n supabaseServerClient,\n date,\n}: {\n supabaseServerClient: SupabaseClient;\n date?: string;\n}): Promise<StoryRow | null> {\n const {\n data: { user },\n } = await supabaseServerClient.auth.getUser();\n\n if (!user) {\n return null;\n }\n\n const targetDate = date || getTodayDate();\n\n await supabaseServerClient\n .from(\"stories\")\n .delete()\n .eq(\"user_id\", user.id)\n .eq(\"kind\", \"daily\")\n .eq(\"saved\", false)\n .lt(\"date\", targetDate);\n\n const { data: existing } = await supabaseServerClient\n .from(\"stories\")\n .select(\"*\")\n .eq(\"user_id\", user.id)\n .eq(\"kind\", \"daily\")\n .eq(\"date\", targetDate)\n .maybeSingle();\n\n if (existing) {\n return existing as StoryRow;\n }\n const context = await selectDailyStoryContext({\n supabaseServerClient,\n userId: user.id,\n date: targetDate,\n });\n\n if (!context) {\n return null;\n }\n\n try {\n const generated = await generateDailyStory({\n topic: context.storyTopic,\n level: context.level,\n lengthChars: context.lengthChars,\n targetWords: context.selectedWords,\n sourceIslandIds: context.islandIds,\n });\n\n const { data: inserted, error: insertError } = await supabaseServerClient\n .from(\"stories\")\n .insert({\n user_id: user.id,\n kind: \"daily\",\n date: targetDate,\n title: generated.title,\n title_en: generated.title_en,\n level: context.level,\n length_chars: context.lengthChars,\n topic: context.storyTopic,\n story_zh: generated.story_zh,\n story_en: generated.story_en,\n story_pinyin: generated.story_pinyin,\n source_island_ids: context.islandIds,\n target_word_ids: context.selectedWords.map((word) => word.id),\n requested_words: [],\n saved: false,\n })\n .select()\n .single();\n\n if (insertError) {\n const { data: fallback } = await supabaseServerClient\n .from(\"stories\")\n .select(\"*\")\n .eq(\"user_id\", user.id)\n .eq(\"kind\", \"daily\")\n .eq(\"date\", targetDate)\n .maybeSingle();\n\n return (fallback as StoryRow) || null;\n }\n\n return inserted as StoryRow;\n } catch (error) {\n console.error(\"Daily story generation failed:\", error);\n return null;\n }\n}\n\n"}, "d3878b3274864a10cc95dafcfbeb38d2b9434c04": {"t": 1785754447.9879572, "n": 12116, "h": "2234a836ada088da", "c": "import type { SupabaseClient } from \"@supabase/supabase-js\";\nimport { getLocalDateKey } from \"@/lib/utils/date\";\n\ntype StoryRow = {\n id: string;\n user_id: string;\n kind: \"daily\" | \"custom\";\n date: string | null;\n title: string;\n level: string;\n length_chars: number;\n topic: string | null;\n story_zh: string;\n story_en: string | null;\n story_pinyin: string | null;\n source_island_ids: string[];\n target_word_ids: string[];\n created_at: string;\n};\n\ntype IslandRow = { id: string; topic: string };\ntype WordRow = { id: string; hanzi: string; pinyin: string; english: string };\n\nfunction getTodayDate() {\n return getLocalDateKey();\n}\n\nfunction sample<T>(items: T[], count: number) {\n const copy = [...items];\n for (let i = copy.length - 1; i > 0; i -= 1) {\n const j = Math.floor(Math.random() * (i + 1));\n [copy[i], copy[j]] = [copy[j], copy[i]];\n }\n return copy.slice(0, count);\n}\n\nfunction countStoryChars(text: string) {\n return text.replace(/\\s/g, \"\").length;\n}\n\nfunction stripCodeFence(content: string) {\n let jsonContent = content.trim();\n if (jsonContent.startsWith(\"```\")) {\n jsonContent = jsonContent\n .replace(/^```(?:json)?\\n/, \"\")\n .replace(/\\n```$/, \"\");\n }\n return jsonContent;\n}\n\nfunction extractJsonObject(content: string) {\n const trimmed = stripCodeFence(content);\n const start = trimmed.indexOf(\"{\");\n const end = trimmed.lastIndexOf(\"}\");\n if (start === -1 || end === -1 || end <= start) {\n return trimmed;\n }\n let extracted = trimmed.slice(start, end + 1);\n \n // Fix common JSON issues from LLM responses\n // 1. Replace smart quotes with regular quotes\n extracted = extracted.replace(/[\\u201C\\u201D]/g, '\"').replace(/[\\u2018\\u2019]/g, \"'\");\n \n // 2. Fix unescaped newlines in strings (common in Chinese text)\n try {\n // Try to parse first\n JSON.parse(extracted);\n return extracted;\n } catch (e) {\n // If it fails, try to fix common issues\n // Replace literal newlines inside strings with \\n\n extracted = extracted.replace(/(\"(?:[^\"\\\\]|\\\\.)*?\")/g, (match) => {\n return match.replace(/\\n/g, '\\\\n').replace(/\\r/g, '\\\\r');\n });\n }\n \n return extracted;\n}\n\nasync function generateDailyStory({\n topic,\n level,\n lengthChars,\n targetWords,\n sourceIslandIds,\n maxAttempts = 4,\n}: {\n topic: string;\n level: string;\n lengthChars: number;\n targetWords: WordRow[];\n sourceIslandIds: string[];\n maxAttempts?: number;\n}) {\n const minLength = Math.max(80, Math.round(lengthChars * 0.6));\n const maxLength = Math.min(260, Math.round(lengthChars * 1.4));\n const apiKey = process.env.DEEPSEEK_API_KEY;\n if (!apiKey) {\n throw new Error(\"DEEPSEEK_API_KEY not configured\");\n }\n\n const targetWordList = targetWords.map((w) => w.hanzi).filter(Boolean);\n \n // Level-specific style guidance\n const levelGuidance: Record<string, string> = {\n A1: `\\nSTYLE FOR A1: Very simple sentences (5-10 chars), present tense, basic patterns (我 + verb), concrete survival vocabulary.`,\n A2: `\\nSTYLE FOR A2: Simple clear sentences (8-15 chars), everyday vocabulary, basic connectors (然后、但是、因为).`,\n B1: `\\nSTYLE FOR B1: Natural conversational flow, mix of simple and compound sentences, casual connectors (其实、不过、虽然).`,\n B2: `\\nSTYLE FOR B2: Sophisticated narrative, varied structures, personal voice, modern expressions (limited).`,\n C1: `\\nSTYLE FOR C1: Complex nuanced narrative, idioms and chengyu, sophisticated discourse, cultural references OK.`,\n };\n \n // Normalize level (handle variations like A2-, A2, A2+)\n let baseLevel = level;\n if (level.match(/^(A1|A2|B1|B2|C1)[+-]?$/)) {\n baseLevel = level.replace(/[+-]/g, '');\n }\n const styleGuide = levelGuidance[baseLevel] || levelGuidance['B1'];\n \n const basePrompt = `You are a Mandarin Chinese storyteller for language learners.\n\nTopic: ${topic}\nLearner level: ${level}${styleGuide}\nTarget length: ${lengthChars} Chinese characters (count characters, not tokens)\nTarget words (optional to use): ${targetWordList.join(\", \")}\n\nStory requirements:\n- 5-8 sentences (adjust complexity and length based on ${baseLevel} level)\n- Match the style guidance for ${baseLevel} level\n- Use Simplified Chinese\n- Not all target words need to be used; include only what fits naturally\n- story_zh must be between ${minLength}-${maxLength} characters\n- If story_zh is too short, add a sentence or detail to reach the minimum\n\nOutput STRICT JSON only with this shape:\n{\n \"title\": \"...\",\n \"level\": \"${level}\",\n \"length_chars\": ${lengthChars},\n \"story_zh\": \"...\",\n \"story_en\": null,\n \"story_pinyin\": null,\n \"source_island_ids\": ${JSON.stringify(sourceIslandIds)},\n \"target_word_ids\": ${JSON.stringify(targetWords.map((w) => w.id))}\n}`;\n\n let lastError: Error | null = null;\n\n let extraNote = \"\";\n for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {\n const prompt = `${basePrompt}${extraNote ? `\\n\\n${extraNote}` : \"\"}`;\n const response = await fetch(\n \"https://api.deepseek.com/v1/chat/completions\",\n {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model: \"deepseek-chat\",\n messages: [\n {\n role: \"system\",\n content:\n \"You are a helpful assistant that generates structured JSON data for Chinese language learning. Always respond with valid JSON only, no markdown formatting.\",\n },\n {\n role: \"user\",\n content: prompt,\n },\n ],\n temperature: 0.9,\n frequency_penalty: 0.6,\n presence_penalty: 0.2,\n max_tokens: 3000,\n }),\n }\n );\n\n if (!response.ok) {\n const errorText = await response.text();\n throw new Error(\n `DeepSeek API error: ${response.status} ${response.statusText} - ${errorText}`\n );\n }\n\n const data = await response.json();\n const content = data.choices?.[0]?.message?.content;\n if (!content) {\n lastError = new Error(\"No content in DeepSeek response\");\n continue;\n }\n\n let parsed: {\n title?: string;\n story_zh?: string;\n story_en?: string | null;\n story_pinyin?: string | null;\n };\n try {\n const extracted = extractJsonObject(content);\n parsed = JSON.parse(extracted);\n } catch (error) {\n if (process.env.NODE_ENV === \"development\") {\n console.error(\"[Daily Story] Failed to parse DeepSeek response:\", {\n error: error instanceof Error ? error.message : error,\n attempt,\n contentPreview: content.substring(0, 200),\n });\n }\n lastError = new Error(`Failed to parse DeepSeek JSON: ${error}`);\n extraNote =\n \"Return STRICT JSON only. Do not include extra text or markdown.\";\n continue;\n }\n\n if (!parsed.title || !parsed.story_zh) {\n lastError = new Error(\"Missing title or story_zh in DeepSeek output\");\n continue;\n }\n\n const charCount = countStoryChars(parsed.story_zh);\n if (charCount < minLength || charCount > maxLength) {\n lastError = new Error(\n `story_zh length out of bounds: ${charCount} characters`\n );\n extraNote =\n `Adjust story_zh length to ${minLength}-${maxLength} Chinese characters. Count characters carefully.`;\n continue;\n }\n\n return {\n title: parsed.title.trim(),\n story_zh: parsed.story_zh.trim(),\n story_en: parsed.story_en?.trim() || null,\n story_pinyin: parsed.story_pinyin?.trim() || null,\n };\n }\n\n throw lastError || new Error(\"Failed to generate daily story\");\n}\n\nasync function selectDailyStoryContext({\n supabaseServerClient,\n userId,\n date,\n}: {\n supabaseServerClient: SupabaseClient;\n userId: string;\n date: string;\n}) {\n const { data: profile } = await supabaseServerClient\n .from(\"user_profiles\")\n .select(\"cefr_level\")\n .eq(\"user_id\", userId)\n .maybeSingle();\n\n const level = profile?.cefr_level || \"B1\";\n\n const { data: islands, error: islandsError } = await supabaseServerClient\n .from(\"topic_islands\")\n .select(\"id, topic\")\n .eq(\"user_id\", userId)\n .order(\"created_at\", { ascending: false });\n\n if (islandsError || !islands || islands.length === 0) {\n return null;\n }\n\n const { data: words, error: wordsError } = await supabaseServerClient\n .from(\"island_words\")\n .select(\"id, hanzi, pinyin, english, island_id\")\n .in(\"island_id\", (islands as IslandRow[]).map((island) => island.id))\n .eq(\"user_id\", userId);\n\n if (wordsError || !words || words.length === 0) {\n return null;\n }\n\n const wordsByIsland = new Map<string, WordRow[]>();\n for (const word of words as (WordRow & { island_id: string })[]) {\n if (!wordsByIsland.has(word.island_id)) {\n wordsByIsland.set(word.island_id, []);\n }\n wordsByIsland.get(word.island_id)!.push(word);\n }\n\n const eligibleIslands = (islands as IslandRow[]).filter((island) =>\n wordsByIsland.has(island.id)\n );\n\n if (eligibleIslands.length === 0) {\n return null;\n }\n\n const islandCount = eligibleIslands.length >= 2 ? 2 : 1;\n const selectedIslands = sample(eligibleIslands, islandCount);\n const islandIds = selectedIslands.map((island) => island.id);\n\n const selectedIslandWords = selectedIslands.flatMap(\n (island) => wordsByIsland.get(island.id) || []\n );\n\n const targetCount = Math.min(14, Math.max(10, 12));\n const selectedWords =\n selectedIslandWords.length <= targetCount\n ? selectedIslandWords\n : sample(selectedIslandWords, targetCount);\n\n const lengthChars = 140 + Math.floor(Math.random() * 40);\n const storyTopic =\n selectedIslands.map((island) => island.topic).join(\", \") || \"Daily life\";\n\n return {\n level,\n islandIds,\n selectedWords,\n storyTopic,\n lengthChars,\n };\n}\n\nexport async function getOrCreateDailyStory({\n supabaseServerClient,\n date,\n}: {\n supabaseServerClient: SupabaseClient;\n date?: string;\n}): Promise<StoryRow | null> {\n const {\n data: { user },\n } = await supabaseServerClient.auth.getUser();\n\n if (!user) {\n return null;\n }\n\n const targetDate = date || getTodayDate();\n\n await supabaseServerClient\n .from(\"stories\")\n .delete()\n .eq(\"user_id\", user.id)\n .eq(\"kind\", \"daily\")\n .eq(\"saved\", false)\n .lt(\"date\", targetDate);\n\n const { data: existing } = await supabaseServerClient\n .from(\"stories\")\n .select(\"*\")\n .eq(\"user_id\", user.id)\n .eq(\"kind\", \"daily\")\n .eq(\"date\", targetDate)\n .maybeSingle();\n\n if (existing) {\n return existing as StoryRow;\n }\n const context = await selectDailyStoryContext({\n supabaseServerClient,\n userId: user.id,\n date: targetDate,\n });\n\n if (!context) {\n return null;\n }\n\n try {\n const generated = await generateDailyStory({\n topic: context.storyTopic,\n level: context.level,\n lengthChars: context.lengthChars,\n targetWords: context.selectedWords,\n sourceIslandIds: context.islandIds,\n });\n\n const { data: inserted, error: insertError } = await supabaseServerClient\n .from(\"stories\")\n .insert({\n user_id: user.id,\n kind: \"daily\",\n date: targetDate,\n title: generated.title,\n level: context.level,\n length_chars: context.lengthChars,\n topic: context.storyTopic,\n story_zh: generated.story_zh,\n story_en: generated.story_en,\n story_pinyin: generated.story_pinyin,\n source_island_ids: context.islandIds,\n target_word_ids: context.selectedWords.map((word) => word.id),\n requested_words: [],\n saved: false,\n })\n .select()\n .single();\n\n if (insertError) {\n const { data: fallback } = await supabaseServerClient\n .from(\"stories\")\n .select(\"*\")\n .eq(\"user_id\", user.id)\n .eq(\"kind\", \"daily\")\n .eq(\"date\", targetDate)\n .maybeSingle();\n\n return (fallback as StoryRow) || null;\n }\n\n return inserted as StoryRow;\n } catch (error) {\n console.error(\"Daily story generation failed:\", error);\n return null;\n }\n}\n\n"}, "2bec30700ae1567b835ff582640c2b8a4ef8e8b5": {"t": 1785754449.3517978, "n": 11159, "h": "405068dc8b809ec9", "c": "import type { SupabaseClient } from \"@supabase/supabase-js\";\nimport { getLocalDateKey } from \"@/lib/utils/date\";\n\ntype StoryRow = {\n id: string;\n user_id: string;\n kind: \"daily\" | \"custom\";\n date: string | null;\n title: string;\n level: string;\n length_chars: number;\n topic: string | null;\n story_zh: string;\n story_en: string | null;\n story_pinyin: string | null;\n source_island_ids: string[];\n target_word_ids: string[];\n created_at: string;\n};\n\ntype IslandRow = { id: string; topic: string };\ntype WordRow = { id: string; hanzi: string; pinyin: string; english: string };\n\nfunction getTodayDate() {\n return getLocalDateKey();\n}\n\nfunction sample<T>(items: T[], count: number) {\n const copy = [...items];\n for (let i = copy.length - 1; i > 0; i -= 1) {\n const j = Math.floor(Math.random() * (i + 1));\n [copy[i], copy[j]] = [copy[j], copy[i]];\n }\n return copy.slice(0, count);\n}\n\nfunction countStoryChars(text: string) {\n return text.replace(/\\s/g, \"\").length;\n}\n\nfunction stripCodeFence(content: string) {\n let jsonContent = content.trim();\n if (jsonContent.startsWith(\"```\")) {\n jsonContent = jsonContent\n .replace(/^```(?:json)?\\n/, \"\")\n .replace(/\\n```$/, \"\");\n }\n return jsonContent;\n}\n\nfunction extractJsonObject(content: string) {\n const trimmed = stripCodeFence(content);\n const start = trimmed.indexOf(\"{\");\n const end = trimmed.lastIndexOf(\"}\");\n if (start === -1 || end === -1 || end <= start) {\n return trimmed;\n }\n return trimmed.slice(start, end + 1);\n}\n\nasync function generateDailyStory({\n topic,\n level,\n lengthChars,\n targetWords,\n sourceIslandIds,\n maxAttempts = 4,\n}: {\n topic: string;\n level: string;\n lengthChars: number;\n targetWords: WordRow[];\n sourceIslandIds: string[];\n maxAttempts?: number;\n}) {\n const minLength = Math.max(80, Math.round(lengthChars * 0.6));\n const maxLength = Math.min(260, Math.round(lengthChars * 1.4));\n const apiKey = process.env.DEEPSEEK_API_KEY;\n if (!apiKey) {\n throw new Error(\"DEEPSEEK_API_KEY not configured\");\n }\n\n const targetWordList = targetWords.map((w) => w.hanzi).filter(Boolean);\n \n // Level-specific style guidance\n const levelGuidance: Record<string, string> = {\n A1: `\\nSTYLE FOR A1: Very simple sentences (5-10 chars), present tense, basic patterns (我 + verb), concrete survival vocabulary.`,\n A2: `\\nSTYLE FOR A2: Simple clear sentences (8-15 chars), everyday vocabulary, basic connectors (然后、但是、因为).`,\n B1: `\\nSTYLE FOR B1: Natural conversational flow, mix of simple and compound sentences, casual connectors (其实、不过、虽然).`,\n B2: `\\nSTYLE FOR B2: Sophisticated narrative, varied structures, personal voice, modern expressions (limited).`,\n C1: `\\nSTYLE FOR C1: Complex nuanced narrative, idioms and chengyu, sophisticated discourse, cultural references OK.`,\n };\n \n // Normalize level (handle variations like A2-, A2, A2+)\n let baseLevel = level;\n if (level.match(/^(A1|A2|B1|B2|C1)[+-]?$/)) {\n baseLevel = level.replace(/[+-]/g, '');\n }\n const styleGuide = levelGuidance[baseLevel] || levelGuidance['B1'];\n \n const basePrompt = `You are a Mandarin Chinese storyteller for language learners.\n\nTopic: ${topic}\nLearner level: ${level}${styleGuide}\nTarget length: ${lengthChars} Chinese characters (count characters, not tokens)\nTarget words (optional to use): ${targetWordList.join(\", \")}\n\nStory requirements:\n- 5-8 sentences (adjust complexity and length based on ${baseLevel} level)\n- Match the style guidance for ${baseLevel} level\n- Use Simplified Chinese\n- Not all target words need to be used; include only what fits naturally\n- story_zh must be between ${minLength}-${maxLength} characters\n- If story_zh is too short, add a sentence or detail to reach the minimum\n\nOutput STRICT JSON only with this shape:\n{\n \"title\": \"...\",\n \"level\": \"${level}\",\n \"length_chars\": ${lengthChars},\n \"story_zh\": \"...\",\n \"story_en\": null,\n \"story_pinyin\": null,\n \"source_island_ids\": ${JSON.stringify(sourceIslandIds)},\n \"target_word_ids\": ${JSON.stringify(targetWords.map((w) => w.id))}\n}`;\n\n let lastError: Error | null = null;\n\n let extraNote = \"\";\n for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {\n const prompt = `${basePrompt}${extraNote ? `\\n\\n${extraNote}` : \"\"}`;\n const response = await fetch(\n \"https://api.deepseek.com/v1/chat/completions\",\n {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model: \"deepseek-chat\",\n messages: [\n {\n role: \"system\",\n content:\n \"You are a helpful assistant that generates structured JSON data for Chinese language learning. Always respond with valid JSON only, no markdown formatting.\",\n },\n {\n role: \"user\",\n content: prompt,\n },\n ],\n temperature: 0.9,\n frequency_penalty: 0.6,\n presence_penalty: 0.2,\n max_tokens: 3000,\n }),\n }\n );\n\n if (!response.ok) {\n const errorText = await response.text();\n throw new Error(\n `DeepSeek API error: ${response.status} ${response.statusText} - ${errorText}`\n );\n }\n\n const data = await response.json();\n const content = data.choices?.[0]?.message?.content;\n if (!content) {\n lastError = new Error(\"No content in DeepSeek response\");\n continue;\n }\n\n let parsed: {\n title?: string;\n story_zh?: string;\n story_en?: string | null;\n story_pinyin?: string | null;\n };\n try {\n parsed = JSON.parse(extractJsonObject(content));\n } catch (error) {\n lastError = new Error(`Failed to parse DeepSeek JSON: ${error}`);\n extraNote =\n \"Return STRICT JSON only. Do not include extra text or markdown.\";\n continue;\n }\n\n if (!parsed.title || !parsed.story_zh) {\n lastError = new Error(\"Missing title or story_zh in DeepSeek output\");\n continue;\n }\n\n const charCount = countStoryChars(parsed.story_zh);\n if (charCount < minLength || charCount > maxLength) {\n lastError = new Error(\n `story_zh length out of bounds: ${charCount} characters`\n );\n extraNote =\n `Adjust story_zh length to ${minLength}-${maxLength} Chinese characters. Count characters carefully.`;\n continue;\n }\n\n return {\n title: parsed.title.trim(),\n story_zh: parsed.story_zh.trim(),\n story_en: parsed.story_en?.trim() || null,\n story_pinyin: parsed.story_pinyin?.trim() || null,\n };\n }\n\n throw lastError || new Error(\"Failed to generate daily story\");\n}\n\nasync function selectDailyStoryContext({\n supabaseServerClient,\n userId,\n date,\n}: {\n supabaseServerClient: SupabaseClient;\n userId: string;\n date: string;\n}) {\n const { data: profile } = await supabaseServerClient\n .from(\"user_profiles\")\n .select(\"cefr_level\")\n .eq(\"user_id\", userId)\n .maybeSingle();\n\n const level = profile?.cefr_level || \"B1\";\n\n const { data: islands, error: islandsError } = await supabaseServerClient\n .from(\"topic_islands\")\n .select(\"id, topic\")\n .eq(\"user_id\", userId)\n .order(\"created_at\", { ascending: false });\n\n if (islandsError || !islands || islands.length === 0) {\n return null;\n }\n\n const { data: words, error: wordsError } = await supabaseServerClient\n .from(\"island_words\")\n .select(\"id, hanzi, pinyin, english, island_id\")\n .in(\"island_id\", (islands as IslandRow[]).map((island) => island.id))\n .eq(\"user_id\", userId);\n\n if (wordsError || !words || words.length === 0) {\n return null;\n }\n\n const wordsByIsland = new Map<string, WordRow[]>();\n for (const word of words as (WordRow & { island_id: string })[]) {\n if (!wordsByIsland.has(word.island_id)) {\n wordsByIsland.set(word.island_id, []);\n }\n wordsByIsland.get(word.island_id)!.push(word);\n }\n\n const eligibleIslands = (islands as IslandRow[]).filter((island) =>\n wordsByIsland.has(island.id)\n );\n\n if (eligibleIslands.length === 0) {\n return null;\n }\n\n const islandCount = eligibleIslands.length >= 2 ? 2 : 1;\n const selectedIslands = sample(eligibleIslands, islandCount);\n const islandIds = selectedIslands.map((island) => island.id);\n\n const selectedIslandWords = selectedIslands.flatMap(\n (island) => wordsByIsland.get(island.id) || []\n );\n\n const targetCount = Math.min(14, Math.max(10, 12));\n const selectedWords =\n selectedIslandWords.length <= targetCount\n ? selectedIslandWords\n : sample(selectedIslandWords, targetCount);\n\n const lengthChars = 140 + Math.floor(Math.random() * 40);\n const storyTopic =\n selectedIslands.map((island) => island.topic).join(\", \") || \"Daily life\";\n\n return {\n level,\n islandIds,\n selectedWords,\n storyTopic,\n lengthChars,\n };\n}\n\nexport async function getOrCreateDailyStory({\n supabaseServerClient,\n date,\n}: {\n supabaseServerClient: SupabaseClient;\n date?: string;\n}): Promise<StoryRow | null> {\n const {\n data: { user },\n } = await supabaseServerClient.auth.getUser();\n\n if (!user) {\n return null;\n }\n\n const targetDate = date || getTodayDate();\n\n await supabaseServerClient\n .from(\"stories\")\n .delete()\n .eq(\"user_id\", user.id)\n .eq(\"kind\", \"daily\")\n .eq(\"saved\", false)\n .lt(\"date\", targetDate);\n\n const { data: existing } = await supabaseServerClient\n .from(\"stories\")\n .select(\"*\")\n .eq(\"user_id\", user.id)\n .eq(\"kind\", \"daily\")\n .eq(\"date\", targetDate)\n .maybeSingle();\n\n if (existing) {\n return existing as StoryRow;\n }\n const context = await selectDailyStoryContext({\n supabaseServerClient,\n userId: user.id,\n date: targetDate,\n });\n\n if (!context) {\n return null;\n }\n\n try {\n const generated = await generateDailyStory({\n topic: context.storyTopic,\n level: context.level,\n lengthChars: context.lengthChars,\n targetWords: context.selectedWords,\n sourceIslandIds: context.islandIds,\n });\n\n const { data: inserted, error: insertError } = await supabaseServerClient\n .from(\"stories\")\n .insert({\n user_id: user.id,\n kind: \"daily\",\n date: targetDate,\n title: generated.title,\n level: context.level,\n length_chars: context.lengthChars,\n topic: context.storyTopic,\n story_zh: generated.story_zh,\n story_en: generated.story_en,\n story_pinyin: generated.story_pinyin,\n source_island_ids: context.islandIds,\n target_word_ids: context.selectedWords.map((word) => word.id),\n requested_words: [],\n saved: false,\n })\n .select()\n .single();\n\n if (insertError) {\n const { data: fallback } = await supabaseServerClient\n .from(\"stories\")\n .select(\"*\")\n .eq(\"user_id\", user.id)\n .eq(\"kind\", \"daily\")\n .eq(\"date\", targetDate)\n .maybeSingle();\n\n return (fallback as StoryRow) || null;\n }\n\n return inserted as StoryRow;\n } catch (error) {\n console.error(\"Daily story generation failed:\", error);\n return null;\n }\n}\n\n"}, "b0f78c484a5fc4ea8a927851de86a8441c7638ed": {"t": 1785754450.7284179, "n": 10172, "h": "227a6907e8c84810", "c": "import type { SupabaseClient } from \"@supabase/supabase-js\";\nimport { getLocalDateKey } from \"@/lib/utils/date\";\n\ntype StoryRow = {\n id: string;\n user_id: string;\n kind: \"daily\" | \"custom\";\n date: string | null;\n title: string;\n level: string;\n length_chars: number;\n topic: string | null;\n story_zh: string;\n story_en: string | null;\n story_pinyin: string | null;\n source_island_ids: string[];\n target_word_ids: string[];\n created_at: string;\n};\n\ntype IslandRow = { id: string; topic: string };\ntype WordRow = { id: string; hanzi: string; pinyin: string; english: string };\n\nfunction getTodayDate() {\n return getLocalDateKey();\n}\n\nfunction sample<T>(items: T[], count: number) {\n const copy = [...items];\n for (let i = copy.length - 1; i > 0; i -= 1) {\n const j = Math.floor(Math.random() * (i + 1));\n [copy[i], copy[j]] = [copy[j], copy[i]];\n }\n return copy.slice(0, count);\n}\n\nfunction countStoryChars(text: string) {\n return text.replace(/\\s/g, \"\").length;\n}\n\nfunction stripCodeFence(content: string) {\n let jsonContent = content.trim();\n if (jsonContent.startsWith(\"```\")) {\n jsonContent = jsonContent\n .replace(/^```(?:json)?\\n/, \"\")\n .replace(/\\n```$/, \"\");\n }\n return jsonContent;\n}\n\nfunction extractJsonObject(content: string) {\n const trimmed = stripCodeFence(content);\n const start = trimmed.indexOf(\"{\");\n const end = trimmed.lastIndexOf(\"}\");\n if (start === -1 || end === -1 || end <= start) {\n return trimmed;\n }\n return trimmed.slice(start, end + 1);\n}\n\nasync function generateDailyStory({\n topic,\n level,\n lengthChars,\n targetWords,\n sourceIslandIds,\n maxAttempts = 4,\n}: {\n topic: string;\n level: string;\n lengthChars: number;\n targetWords: WordRow[];\n sourceIslandIds: string[];\n maxAttempts?: number;\n}) {\n const minLength = Math.max(80, Math.round(lengthChars * 0.6));\n const maxLength = Math.min(260, Math.round(lengthChars * 1.4));\n const apiKey = process.env.DEEPSEEK_API_KEY;\n if (!apiKey) {\n throw new Error(\"DEEPSEEK_API_KEY not configured\");\n }\n\n const targetWordList = targetWords.map((w) => w.hanzi).filter(Boolean);\n const basePrompt = `You are a Mandarin Chinese storyteller for language learners.\n\nTopic: ${topic}\nLearner level: ${level}\nTarget length: ${lengthChars} Chinese characters (count characters, not tokens)\nTarget words (optional to use): ${targetWordList.join(\", \")}\n\nStory requirements:\n- 5-8 sentences\n- Natural Chinese a native in their 20s would say\n- Conversational, not textbook, not repetitive\n- Use Simplified Chinese\n- Not all target words need to be used; include only what fits naturally\n- story_zh must be between ${minLength}-${maxLength} characters\n- If story_zh is too short, add a sentence or detail to reach the minimum\n\nOutput STRICT JSON only with this shape:\n{\n \"title\": \"...\",\n \"level\": \"${level}\",\n \"length_chars\": ${lengthChars},\n \"story_zh\": \"...\",\n \"story_en\": null,\n \"story_pinyin\": null,\n \"source_island_ids\": ${JSON.stringify(sourceIslandIds)},\n \"target_word_ids\": ${JSON.stringify(targetWords.map((w) => w.id))}\n}`;\n\n let lastError: Error | null = null;\n\n let extraNote = \"\";\n for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {\n const prompt = `${basePrompt}${extraNote ? `\\n\\n${extraNote}` : \"\"}`;\n const response = await fetch(\n \"https://api.deepseek.com/v1/chat/completions\",\n {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model: \"deepseek-chat\",\n messages: [\n {\n role: \"system\",\n content:\n \"You are a helpful assistant that generates structured JSON data for Chinese language learning. Always respond with valid JSON only, no markdown formatting.\",\n },\n {\n role: \"user\",\n content: prompt,\n },\n ],\n temperature: 0.9,\n frequency_penalty: 0.6,\n presence_penalty: 0.2,\n max_tokens: 3000,\n }),\n }\n );\n\n if (!response.ok) {\n const errorText = await response.text();\n throw new Error(\n `DeepSeek API error: ${response.status} ${response.statusText} - ${errorText}`\n );\n }\n\n const data = await response.json();\n const content = data.choices?.[0]?.message?.content;\n if (!content) {\n lastError = new Error(\"No content in DeepSeek response\");\n continue;\n }\n\n let parsed: {\n title?: string;\n story_zh?: string;\n story_en?: string | null;\n story_pinyin?: string | null;\n };\n try {\n parsed = JSON.parse(extractJsonObject(content));\n } catch (error) {\n lastError = new Error(`Failed to parse DeepSeek JSON: ${error}`);\n extraNote =\n \"Return STRICT JSON only. Do not include extra text or markdown.\";\n continue;\n }\n\n if (!parsed.title || !parsed.story_zh) {\n lastError = new Error(\"Missing title or story_zh in DeepSeek output\");\n continue;\n }\n\n const charCount = countStoryChars(parsed.story_zh);\n if (charCount < minLength || charCount > maxLength) {\n lastError = new Error(\n `story_zh length out of bounds: ${charCount} characters`\n );\n extraNote =\n `Adjust story_zh length to ${minLength}-${maxLength} Chinese characters. Count characters carefully.`;\n continue;\n }\n\n return {\n title: parsed.title.trim(),\n story_zh: parsed.story_zh.trim(),\n story_en: parsed.story_en?.trim() || null,\n story_pinyin: parsed.story_pinyin?.trim() || null,\n };\n }\n\n throw lastError || new Error(\"Failed to generate daily story\");\n}\n\nasync function selectDailyStoryContext({\n supabaseServerClient,\n userId,\n date,\n}: {\n supabaseServerClient: SupabaseClient;\n userId: string;\n date: string;\n}) {\n const { data: profile } = await supabaseServerClient\n .from(\"user_profiles\")\n .select(\"cefr_level\")\n .eq(\"user_id\", userId)\n .maybeSingle();\n\n const level = profile?.cefr_level || \"B1\";\n\n const { data: islands, error: islandsError } = await supabaseServerClient\n .from(\"topic_islands\")\n .select(\"id, topic\")\n .eq(\"user_id\", userId)\n .order(\"created_at\", { ascending: false });\n\n if (islandsError || !islands || islands.length === 0) {\n return null;\n }\n\n const { data: words, error: wordsError } = await supabaseServerClient\n .from(\"island_words\")\n .select(\"id, hanzi, pinyin, english, island_id\")\n .in(\"island_id\", (islands as IslandRow[]).map((island) => island.id))\n .eq(\"user_id\", userId);\n\n if (wordsError || !words || words.length === 0) {\n return null;\n }\n\n const wordsByIsland = new Map<string, WordRow[]>();\n for (const word of words as (WordRow & { island_id: string })[]) {\n if (!wordsByIsland.has(word.island_id)) {\n wordsByIsland.set(word.island_id, []);\n }\n wordsByIsland.get(word.island_id)!.push(word);\n }\n\n const eligibleIslands = (islands as IslandRow[]).filter((island) =>\n wordsByIsland.has(island.id)\n );\n\n if (eligibleIslands.length === 0) {\n return null;\n }\n\n const islandCount = eligibleIslands.length >= 2 ? 2 : 1;\n const selectedIslands = sample(eligibleIslands, islandCount);\n const islandIds = selectedIslands.map((island) => island.id);\n\n const selectedIslandWords = selectedIslands.flatMap(\n (island) => wordsByIsland.get(island.id) || []\n );\n\n const targetCount = Math.min(14, Math.max(10, 12));\n const selectedWords =\n selectedIslandWords.length <= targetCount\n ? selectedIslandWords\n : sample(selectedIslandWords, targetCount);\n\n const lengthChars = 140 + Math.floor(Math.random() * 40);\n const storyTopic =\n selectedIslands.map((island) => island.topic).join(\", \") || \"Daily life\";\n\n return {\n level,\n islandIds,\n selectedWords,\n storyTopic,\n lengthChars,\n };\n}\n\nexport async function getOrCreateDailyStory({\n supabaseServerClient,\n date,\n}: {\n supabaseServerClient: SupabaseClient;\n date?: string;\n}): Promise<StoryRow | null> {\n const {\n data: { user },\n } = await supabaseServerClient.auth.getUser();\n\n if (!user) {\n return null;\n }\n\n const targetDate = date || getTodayDate();\n\n await supabaseServerClient\n .from(\"stories\")\n .delete()\n .eq(\"user_id\", user.id)\n .eq(\"kind\", \"daily\")\n .eq(\"saved\", false)\n .lt(\"date\", targetDate);\n\n const { data: existing } = await supabaseServerClient\n .from(\"stories\")\n .select(\"*\")\n .eq(\"user_id\", user.id)\n .eq(\"kind\", \"daily\")\n .eq(\"date\", targetDate)\n .maybeSingle();\n\n if (existing) {\n return existing as StoryRow;\n }\n const context = await selectDailyStoryContext({\n supabaseServerClient,\n userId: user.id,\n date: targetDate,\n });\n\n if (!context) {\n return null;\n }\n\n try {\n const generated = await generateDailyStory({\n topic: context.storyTopic,\n level: context.level,\n lengthChars: context.lengthChars,\n targetWords: context.selectedWords,\n sourceIslandIds: context.islandIds,\n });\n\n const { data: inserted, error: insertError } = await supabaseServerClient\n .from(\"stories\")\n .insert({\n user_id: user.id,\n kind: \"daily\",\n date: targetDate,\n title: generated.title,\n level: context.level,\n length_chars: context.lengthChars,\n topic: context.storyTopic,\n story_zh: generated.story_zh,\n story_en: generated.story_en,\n story_pinyin: generated.story_pinyin,\n source_island_ids: context.islandIds,\n target_word_ids: context.selectedWords.map((word) => word.id),\n requested_words: [],\n saved: false,\n })\n .select()\n .single();\n\n if (insertError) {\n const { data: fallback } = await supabaseServerClient\n .from(\"stories\")\n .select(\"*\")\n .eq(\"user_id\", user.id)\n .eq(\"kind\", \"daily\")\n .eq(\"date\", targetDate)\n .maybeSingle();\n\n return (fallback as StoryRow) || null;\n }\n\n return inserted as StoryRow;\n } catch (error) {\n console.error(\"Daily story generation failed:\", error);\n return null;\n }\n}\n\n"}, "aa4174f206bc5c0b780d9f27b2854d3a6f749585": {"t": 1785754452.3618696, "n": 9988, "h": "b0f3b47c0f7217b2", "c": "import type { SupabaseClient } from \"@supabase/supabase-js\";\n\ntype StoryRow = {\n id: string;\n user_id: string;\n kind: \"daily\" | \"custom\";\n date: string | null;\n title: string;\n level: string;\n length_chars: number;\n topic: string | null;\n story_zh: string;\n story_en: string | null;\n story_pinyin: string | null;\n source_island_ids: string[];\n target_word_ids: string[];\n created_at: string;\n};\n\ntype IslandRow = { id: string; topic: string };\ntype WordRow = { id: string; hanzi: string; pinyin: string; english: string };\n\nfunction getTodayDate() {\n return new Date().toISOString().slice(0, 10);\n}\n\nfunction sample<T>(items: T[], count: number) {\n const copy = [...items];\n for (let i = copy.length - 1; i > 0; i -= 1) {\n const j = Math.floor(Math.random() * (i + 1));\n [copy[i], copy[j]] = [copy[j], copy[i]];\n }\n return copy.slice(0, count);\n}\n\nfunction countStoryChars(text: string) {\n return text.replace(/\\s/g, \"\").length;\n}\n\nfunction stripCodeFence(content: string) {\n let jsonContent = content.trim();\n if (jsonContent.startsWith(\"```\")) {\n jsonContent = jsonContent\n .replace(/^```(?:json)?\\n/, \"\")\n .replace(/\\n```$/, \"\");\n }\n return jsonContent;\n}\n\nfunction extractJsonObject(content: string) {\n const trimmed = stripCodeFence(content);\n const start = trimmed.indexOf(\"{\");\n const end = trimmed.lastIndexOf(\"}\");\n if (start === -1 || end === -1 || end <= start) {\n return trimmed;\n }\n return trimmed.slice(start, end + 1);\n}\n\nasync function generateDailyStory({\n topic,\n level,\n lengthChars,\n targetWords,\n sourceIslandIds,\n maxAttempts = 4,\n}: {\n topic: string;\n level: string;\n lengthChars: number;\n targetWords: WordRow[];\n sourceIslandIds: string[];\n maxAttempts?: number;\n}) {\n const apiKey = process.env.DEEPSEEK_API_KEY;\n if (!apiKey) {\n throw new Error(\"DEEPSEEK_API_KEY not configured\");\n }\n\n const targetWordList = targetWords.map((w) => w.hanzi).filter(Boolean);\n const basePrompt = `You are a Mandarin Chinese storyteller for language learners.\n\nTopic: ${topic}\nLearner level: ${level}\nTarget length: ${lengthChars} Chinese characters (count characters, not tokens)\nTarget words (optional to use): ${targetWordList.join(\", \")}\n\nStory requirements:\n- 5-8 sentences\n- Natural Chinese a native in their 20s would say\n- Conversational, not textbook, not repetitive\n- Use Simplified Chinese\n- Not all target words need to be used; include only what fits naturally\n- story_zh must be 100-200 characters and must not exceed 220 characters\n- If story_zh is too short, add a sentence or detail\n\nOutput STRICT JSON only with this shape:\n{\n \"title\": \"...\",\n \"level\": \"${level}\",\n \"length_chars\": ${lengthChars},\n \"story_zh\": \"...\",\n \"story_en\": null,\n \"story_pinyin\": null,\n \"source_island_ids\": ${JSON.stringify(sourceIslandIds)},\n \"target_word_ids\": ${JSON.stringify(targetWords.map((w) => w.id))}\n}`;\n\n let lastError: Error | null = null;\n\n let extraNote = \"\";\n for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {\n const prompt = `${basePrompt}${extraNote ? `\\n\\n${extraNote}` : \"\"}`;\n const response = await fetch(\n \"https://api.deepseek.com/v1/chat/completions\",\n {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model: \"deepseek-chat\",\n messages: [\n {\n role: \"system\",\n content:\n \"You are a helpful assistant that generates structured JSON data for Chinese language learning. Always respond with valid JSON only, no markdown formatting.\",\n },\n {\n role: \"user\",\n content: prompt,\n },\n ],\n temperature: 0.9,\n frequency_penalty: 0.6,\n presence_penalty: 0.2,\n max_tokens: 3000,\n }),\n }\n );\n\n if (!response.ok) {\n const errorText = await response.text();\n throw new Error(\n `DeepSeek API error: ${response.status} ${response.statusText} - ${errorText}`\n );\n }\n\n const data = await response.json();\n const content = data.choices?.[0]?.message?.content;\n if (!content) {\n lastError = new Error(\"No content in DeepSeek response\");\n continue;\n }\n\n let parsed: {\n title?: string;\n story_zh?: string;\n story_en?: string | null;\n story_pinyin?: string | null;\n };\n try {\n parsed = JSON.parse(extractJsonObject(content));\n } catch (error) {\n lastError = new Error(`Failed to parse DeepSeek JSON: ${error}`);\n extraNote =\n \"Return STRICT JSON only. Do not include extra text or markdown.\";\n continue;\n }\n\n if (!parsed.title || !parsed.story_zh) {\n lastError = new Error(\"Missing title or story_zh in DeepSeek output\");\n continue;\n }\n\n const charCount = countStoryChars(parsed.story_zh);\n if (charCount < 100 || charCount > 220) {\n lastError = new Error(\n `story_zh length out of bounds: ${charCount} characters`\n );\n extraNote =\n \"Adjust story_zh length to 100-200 Chinese characters and never exceed 220. Count characters carefully.\";\n continue;\n }\n\n return {\n title: parsed.title.trim(),\n story_zh: parsed.story_zh.trim(),\n story_en: parsed.story_en?.trim() || null,\n story_pinyin: parsed.story_pinyin?.trim() || null,\n };\n }\n\n throw lastError || new Error(\"Failed to generate daily story\");\n}\n\nasync function selectDailyStoryContext({\n supabaseServerClient,\n userId,\n date,\n}: {\n supabaseServerClient: SupabaseClient;\n userId: string;\n date: string;\n}) {\n const { data: profile } = await supabaseServerClient\n .from(\"user_profiles\")\n .select(\"cefr_level\")\n .eq(\"user_id\", userId)\n .maybeSingle();\n\n const level = profile?.cefr_level || \"B1\";\n\n const { data: islands, error: islandsError } = await supabaseServerClient\n .from(\"topic_islands\")\n .select(\"id, topic\")\n .eq(\"user_id\", userId)\n .order(\"created_at\", { ascending: false });\n\n if (islandsError || !islands || islands.length === 0) {\n return null;\n }\n\n const { data: words, error: wordsError } = await supabaseServerClient\n .from(\"island_words\")\n .select(\"id, hanzi, pinyin, english, island_id\")\n .in(\"island_id\", (islands as IslandRow[]).map((island) => island.id))\n .eq(\"user_id\", userId);\n\n if (wordsError || !words || words.length === 0) {\n return null;\n }\n\n const wordsByIsland = new Map<string, WordRow[]>();\n for (const word of words as (WordRow & { island_id: string })[]) {\n if (!wordsByIsland.has(word.island_id)) {\n wordsByIsland.set(word.island_id, []);\n }\n wordsByIsland.get(word.island_id)!.push(word);\n }\n\n const eligibleIslands = (islands as IslandRow[]).filter((island) =>\n wordsByIsland.has(island.id)\n );\n\n if (eligibleIslands.length === 0) {\n return null;\n }\n\n const islandCount = eligibleIslands.length >= 2 ? 2 : 1;\n const selectedIslands = sample(eligibleIslands, islandCount);\n const islandIds = selectedIslands.map((island) => island.id);\n\n const selectedIslandWords = selectedIslands.flatMap(\n (island) => wordsByIsland.get(island.id) || []\n );\n\n const targetCount = Math.min(14, Math.max(10, 12));\n const selectedWords =\n selectedIslandWords.length <= targetCount\n ? selectedIslandWords\n : sample(selectedIslandWords, targetCount);\n\n const lengthChars = 140 + Math.floor(Math.random() * 40);\n const storyTopic =\n selectedIslands.map((island) => island.topic).join(\", \") || \"Daily life\";\n\n return {\n level,\n islandIds,\n selectedWords,\n storyTopic,\n lengthChars,\n };\n}\n\nexport async function getOrCreateDailyStory({\n supabaseServerClient,\n date,\n}: {\n supabaseServerClient: SupabaseClient;\n date?: string;\n}): Promise<StoryRow | null> {\n const {\n data: { user },\n } = await supabaseServerClient.auth.getUser();\n\n if (!user) {\n return null;\n }\n\n const targetDate = date || getTodayDate();\n\n await supabaseServerClient\n .from(\"stories\")\n .delete()\n .eq(\"user_id\", user.id)\n .eq(\"kind\", \"daily\")\n .eq(\"saved\", false)\n .lt(\"date\", targetDate);\n\n const { data: existing } = await supabaseServerClient\n .from(\"stories\")\n .select(\"*\")\n .eq(\"user_id\", user.id)\n .eq(\"kind\", \"daily\")\n .eq(\"date\", targetDate)\n .maybeSingle();\n\n if (existing) {\n return existing as StoryRow;\n }\n const context = await selectDailyStoryContext({\n supabaseServerClient,\n userId: user.id,\n date: targetDate,\n });\n\n if (!context) {\n return null;\n }\n\n try {\n const generated = await generateDailyStory({\n topic: context.storyTopic,\n level: context.level,\n lengthChars: context.lengthChars,\n targetWords: context.selectedWords,\n sourceIslandIds: context.islandIds,\n });\n\n const { data: inserted, error: insertError } = await supabaseServerClient\n .from(\"stories\")\n .insert({\n user_id: user.id,\n kind: \"daily\",\n date: targetDate,\n title: generated.title,\n level: context.level,\n length_chars: context.lengthChars,\n topic: context.storyTopic,\n story_zh: generated.story_zh,\n story_en: generated.story_en,\n story_pinyin: generated.story_pinyin,\n source_island_ids: context.islandIds,\n target_word_ids: context.selectedWords.map((word) => word.id),\n requested_words: [],\n saved: false,\n })\n .select()\n .single();\n\n if (insertError) {\n const { data: fallback } = await supabaseServerClient\n .from(\"stories\")\n .select(\"*\")\n .eq(\"user_id\", user.id)\n .eq(\"kind\", \"daily\")\n .eq(\"date\", targetDate)\n .maybeSingle();\n\n return (fallback as StoryRow) || null;\n }\n\n return inserted as StoryRow;\n } catch (error) {\n console.error(\"Daily story generation failed:\", error);\n return null;\n }\n}\n\n"}}}}