From 82859d8d08c62105abb7871ec2bb591dbe2d5bb3 Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Tue, 7 Jul 2026 09:37:59 +0000 Subject: [PATCH] Wire CareerSprint context into talk conversations --- src/routes/conversations.ts | 238 ++++++++++++++++++++++++++++++------ 1 file changed, 198 insertions(+), 40 deletions(-) diff --git a/src/routes/conversations.ts b/src/routes/conversations.ts index 873185f..8d044c0 100644 --- a/src/routes/conversations.ts +++ b/src/routes/conversations.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { z } from "zod"; import { createClient, type Client } from "rivetkit/client"; -import { convertToModelMessages, generateText, stepCountIs, streamText, tool, type UIMessage } from "ai"; +import { convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse, generateText, stepCountIs, streamText, tool, type UIMessage } from "ai"; import { config } from "../config.js"; import { requireUser, type AuthContext } from "../auth/clerk.js"; import type { Registry } from "../actors/registry.js"; @@ -111,6 +111,7 @@ const createMissionConversationSchema = z.object({ const streamSchema = z.object({ messages: z.array(z.custom()), conversationId: z.string().optional(), + growContext: z.any().optional(), }); const createMissionInstanceId = (missionId: string) => @@ -154,15 +155,25 @@ function textFromMessage(message: UIMessage | undefined) { .trim(); } -function forcedToolForPrompt(text: string) { +function forcedToolForPrompt(text: string, hasGrowContext = false) { const lower = text.toLowerCase(); + const asksForTodayTasks = + /\b(show|list|give|get|what|which|today|daily|streak|task|tasks|mission|missions|report|progress)\b/.test(lower) && + /\b(today|daily|streak|task|tasks|mission|missions|day|report|progress)\b/.test(lower); - // Keep high-value dashboard UI predictable: when the user explicitly asks to - // see/discover/find workflows, always produce workflow cards instead of a - // text-only answer. + if (hasGrowContext && /\b(report|result|outcome|kpi|summary)\b/.test(lower)) { + return "showCareerSprintReport" as const; + } + + if (hasGrowContext && asksForTodayTasks) { + return "showCareerSprintTasks" as const; + } + + // The legacy workflow catalog should only show when the user explicitly asks + // for broader programs/workflows, not when they ask for streak missions. if ( /\b(discover|find|show|list|recommend|suggest|compare)\b/.test(lower) && - /\b(workflow|workflows|mission|missions|plan|plans|program|programs)\b/.test(lower) + /\b(workflow|workflows|program|programs)\b/.test(lower) ) { return "discoverWorkflows" as const; } @@ -170,6 +181,27 @@ function forcedToolForPrompt(text: string) { return undefined; } +function buildGrowContextPrompt(growContext: any) { + if (!growContext || typeof growContext !== "object") return ""; + const activeDay = growContext.activeDay; + const tasks = Array.isArray(activeDay?.tasks) ? activeDay.tasks : []; + const taskLines = tasks + .map((task: any, index: number) => ` ${index + 1}. ${task.title ?? "Task"} — ${task.serviceName ?? task.serviceId ?? "service"} — status: ${task.status ?? "pending"} — route: ${task.route ?? "none"}`) + .join("\n"); + return `\n\nCurrent CareerSprint context from the dashboard/streak UI: +- localDate: ${growContext.localDate ?? "unknown"} +- activeDayIndex: ${growContext.activeDayIndex ?? "unknown"} +- streak: ${growContext.streak ?? 0} day(s) +- progress: ${growContext.completedCount ?? 0}/${growContext.totalCount ?? 0} tasks (${growContext.overallProgressPercent ?? 0}%) +- reportEligible: ${growContext.reportEligible ? "yes" : "no"} +- activeDayTitle: ${activeDay?.title ?? "today"} +- activeDayCompleted: ${activeDay?.completedCount ?? 0}/${activeDay?.totalCount ?? tasks.length} +- activeDayTasks: +${taskLines || " none"} + +When the user asks for missions, tasks, today, streak status, missed work, or report readiness, use this CareerSprint context instead of the old workflow catalog. If reportEligible is true and the user asks about a report, ask whether they want to open the report. If activeDayIndex is 8 or higher, explain recovery/missed tasks only when there are incomplete tasks.`; +} + async function buildMissionContextPrompt(userId: string, conversationId: string) { const metadata = await getConversationMetadataPg(userId, conversationId); const missionInstanceId = typeof metadata.missionInstanceId === "string" ? metadata.missionInstanceId : undefined; @@ -207,7 +239,8 @@ How to help: - Don't call tools for general chitchat, emotional support, simple explanations, or when a normal text answer would do. - If you don't know something, say so. If a tool fails, just say it failed and move on. - When the user asks about interview prep, roleplay, resume, or Q Score — just answer normally. Only route to a specialist tool if they explicitly say something like "connect me to the interview specialist" or "let me talk to the roleplay agent". -- When the user asks to see missions or plans, call discoverWorkflows or showMissions. When they ask about memory, use the memory tools. Otherwise, just chat. +- When the user asks to see today’s missions, streak tasks, daily tasks, missed tasks, task progress, or report readiness, call showCareerSprintTasks if CareerSprint context is available. +- Use discoverWorkflows only for broad workflow/program discovery. When they ask about memory, use the memory tools. Otherwise, just chat. - Only start a mission if the user clearly says yes to starting one. Don't push. - When you write memory, a quick "Saved." is enough. No need to over-confirm.${missionContext}` } @@ -264,8 +297,107 @@ When helping: Return compact, bullet-heavy markdown.`, }; -function buildConversationTools(userId: string) { +function careerSprintTasksPayload(growContext: any, dayIndex?: number) { + const days = Array.isArray(growContext?.days) ? growContext.days : []; + const requestedDay = typeof dayIndex === "number" ? dayIndex : Number(growContext?.activeDayIndex ?? 1); + const clampedDay = Math.max(1, Math.min(7, requestedDay || 1)); + const activeDay = days.find((day: any) => Number(day?.dayIndex) === clampedDay) ?? growContext?.activeDay ?? days[0] ?? null; + const tasks = Array.isArray(activeDay?.tasks) ? activeDay.tasks : []; + const missedTasks = days.flatMap((day: any) => (Array.isArray(day?.tasks) ? day.tasks : []).filter((task: any) => task?.status !== "completed")); return { + kind: "career-sprint-tasks", + localDate: growContext?.localDate, + activeDayIndex: growContext?.activeDayIndex, + requestedDayIndex: clampedDay, + streak: growContext?.streak ?? 0, + completedCount: growContext?.completedCount ?? 0, + totalCount: growContext?.totalCount ?? 0, + overallProgressPercent: growContext?.overallProgressPercent ?? 0, + reportEligible: Boolean(growContext?.reportEligible), + isRecoveryDay: Number(growContext?.activeDayIndex ?? 1) > 7, + missedCount: missedTasks.length, + missedTasks, + day: activeDay, + tasks, + }; +} + +function careerSprintReportPayload(growContext: any) { + const days = Array.isArray(growContext?.days) ? growContext.days : []; + const completedCount = Number(growContext?.completedCount ?? 0); + const totalCount = Number(growContext?.totalCount ?? 0); + const reportEligible = Boolean(growContext?.reportEligible); + const missedTasks = days.flatMap((day: any) => (Array.isArray(day?.tasks) ? day.tasks : []).filter((task: any) => task?.status !== "completed")); + const params = new URLSearchParams(); + if (typeof growContext?.demoIcpId === "string" && growContext.demoIcpId) params.set("icpId", growContext.demoIcpId); + if (Number.isFinite(Number(growContext?.activeDayIndex))) params.set("dayIndex", String(growContext.activeDayIndex)); + const href = `/agents/careersprint-report${params.size ? `?${params.toString()}` : ""}`; + return { + kind: "career-sprint-report-status", + localDate: growContext?.localDate, + activeDayIndex: growContext?.activeDayIndex, + streak: growContext?.streak ?? 0, + completedCount, + totalCount, + overallProgressPercent: growContext?.overallProgressPercent ?? 0, + reportEligible, + href, + missedCount: missedTasks.length, + missedTasks: missedTasks.slice(0, 8), + }; +} + +function deterministicCareerSprintResponse(options: { + originalMessages: UIMessage[]; + toolName: "showCareerSprintTasks" | "showCareerSprintReport"; + input: Record; + output: unknown; + onFinish: (responseMessage: UIMessage) => Promise; +}) { + const messageId = `assistant-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const toolCallId = `call-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const stream = createUIMessageStream({ + originalMessages: options.originalMessages, + execute: ({ writer }) => { + writer.write({ type: "start", messageId } as any); + writer.write({ type: "start-step" } as any); + writer.write({ + type: "tool-input-available", + toolCallId, + toolName: options.toolName, + input: options.input, + } as any); + writer.write({ + type: "tool-output-available", + toolCallId, + output: options.output, + } as any); + writer.write({ type: "finish-step" } as any); + writer.write({ type: "finish", finishReason: "stop" } as any); + }, + onFinish: async ({ responseMessage }) => { + await options.onFinish(responseMessage as UIMessage); + }, + }); + return createUIMessageStreamResponse({ stream }); +} + +function buildConversationTools(userId: string, growContext?: any) { + return { + showCareerSprintTasks: tool({ + description: "Return the current CareerSprint streak tasks from the dashboard context. Use for today missions, daily tasks, streak status, missed tasks, completed tasks, Day 8/recovery, and report readiness.", + inputSchema: z.object({ + dayIndex: z.number().optional().describe("Optional requested CareerSprint day number. Defaults to active day."), + }), + execute: async ({ dayIndex }) => careerSprintTasksPayload(growContext, dayIndex), + }), + + showCareerSprintReport: tool({ + description: "Return CareerSprint report readiness, KPI completion status, and the report link. Use when the user asks for report, result, outcome, KPI, or 7-day summary.", + inputSchema: z.object({}), + execute: async () => careerSprintReportPayload(growContext), + }), + discoverWorkflows: tool({ description: "Return sellable GrowQR missions as rich UI-ready discovery cards. Use whenever the user asks to see, find, discover, compare, choose, recommend, or understand missions/workflows.", inputSchema: z.object({ @@ -364,33 +496,36 @@ function buildConversationTools(userId: string) { inputSchema: z.object({ focus: z.string().optional(), }), - execute: async ({ focus }) => ({ - kind: "missions", - focus, - missions: [ - { - id: "mission-share-goal", - title: "Tell Grow what you are aiming for", - description: "Share target role, company, deadline, and biggest blocker so the agent can set up your first mission.", - status: "suggested", - rewardLabel: "+10 readiness", - }, - { - id: "mission-pick-workflow", - title: "Pick a mission to activate", - description: "Start Interview-to-Offer if you have an interview coming up, or Career Transition if you are exploring a pivot.", - status: "suggested", - rewardLabel: "Unlock plan", - }, - { - id: "mission-save-memory", - title: "Save one durable career fact", - description: "Let Grow remember a role target, resume link, deadline, or interview date.", - status: "suggested", - rewardLabel: "Better recommendations", - }, - ], - }), + execute: async ({ focus }) => { + if (growContext && typeof growContext === "object") return careerSprintTasksPayload(growContext); + return { + kind: "missions", + focus, + missions: [ + { + id: "mission-share-goal", + title: "Tell Grow what you are aiming for", + description: "Share target role, company, deadline, and biggest blocker so the agent can set up your first mission.", + status: "suggested", + rewardLabel: "+10 readiness", + }, + { + id: "mission-pick-workflow", + title: "Pick a mission to activate", + description: "Start Interview-to-Offer if you have an interview coming up, or Career Transition if you are exploring a pivot.", + status: "suggested", + rewardLabel: "Unlock plan", + }, + { + id: "mission-save-memory", + title: "Save one durable career fact", + description: "Let Grow remember a role target, resume link, deadline, or interview date.", + status: "suggested", + rewardLabel: "Better recommendations", + }, + ], + }; + }, }), startWorkflow: tool({ @@ -657,13 +792,37 @@ export function conversationRoutes() { withActorRecovery("conversationActor", () => conversation.addMessage({ role: "user", content: latestUserText, sender: "User" })).catch((err) => console.warn("conversationActor user-message mirror failed", err)); } - const visualTool = forcedToolForPrompt(latestUserText); - const missionContext = await buildMissionContextPrompt(userId, conversationId); + const visualTool = forcedToolForPrompt(latestUserText, Boolean(body.growContext)); + if (visualTool === "showCareerSprintTasks" || visualTool === "showCareerSprintReport") { + const output = visualTool === "showCareerSprintTasks" + ? careerSprintTasksPayload(body.growContext) + : careerSprintReportPayload(body.growContext); + return deterministicCareerSprintResponse({ + originalMessages: body.messages, + toolName: visualTool, + input: {}, + output, + onFinish: async (responseMessage) => { + const assistantText = textFromMessage(responseMessage); + await addMessagePg(userId, { + id: responseMessage.id, + conversationId, + role: "assistant", + content: assistantText || JSON.stringify(responseMessage.parts ?? []), + sender: "GrowQR", + }); + await touchConversationPg(userId, conversationId, latestUserText.slice(0, 64) || undefined); + await growPromise; + }, + }); + } + + const missionContext = `${await buildMissionContextPrompt(userId, conversationId)}${buildGrowContextPrompt(body.growContext)}`; const result = streamText({ model: getConversationModel(), system: buildSystemPrompt(missionContext), messages: await convertToModelMessages(body.messages), - tools: buildConversationTools(userId), + tools: buildConversationTools(userId, body.growContext), toolChoice: visualTool ? { type: "tool", toolName: visualTool } : "auto", stopWhen: stepCountIs(5), }); @@ -686,8 +845,7 @@ export function conversationRoutes() { sender: "GrowQR", })).catch((err) => console.warn("conversationActor assistant-message mirror failed", err)); await touchConversationPg(userId, conversationId, latestUserText.slice(0, 64) || undefined); - const grow = await growPromise; - if (grow) await grow.touchConversation({ conversationId, title: latestUserText.slice(0, 64) || undefined }).catch((err) => console.warn("growActor touch mirror failed", err)); + await growPromise; }, }); });