diff --git a/src/actors/conversation/agent.ts b/src/actors/conversation/agent.ts index 9e8501d..657d5a9 100644 --- a/src/actors/conversation/agent.ts +++ b/src/actors/conversation/agent.ts @@ -27,6 +27,7 @@ export type ConversationRuntimeContext = { missionId?: string; stageId?: string; source?: string; + systemAddendum?: string; }; function normalizeModel(model: string): string { @@ -51,7 +52,9 @@ export function getConversationModel() { return conversationProvider.chat(normalizeModel(modelId)); } -export function buildModelMessages(messages: ConversationMessage[]) { +type ModelConversationMessage = Pick; + +export function buildModelMessages(messages: ModelConversationMessage[]) { return messages.map((message) => ({ role: message.role, content: message.content, @@ -232,18 +235,30 @@ function buildConversationTools(ctx: ConversationRuntimeContext = {}) { }; } -export function streamConversationResponse(messages: ConversationMessage[], context: ConversationRuntimeContext = {}) { +export function streamConversationResponse(messages: ModelConversationMessage[], context: ConversationRuntimeContext = {}) { + const system = [SYSTEM_PROMPT, context.systemAddendum].filter(Boolean).join("\n\n"); if (context.source === "daily-mission-start") { return streamText({ model: getConversationModel(), - system: SYSTEM_PROMPT, + system, messages: buildModelMessages(messages), }); } return streamText({ model: getConversationModel(), - system: SYSTEM_PROMPT, + system, + messages: buildModelMessages(messages), + tools: buildConversationTools(context), + stopWhen: stepCountIs(5), + }); +} + +export async function generateConversationResponse(messages: ModelConversationMessage[], context: ConversationRuntimeContext = {}) { + const system = [SYSTEM_PROMPT, context.systemAddendum].filter(Boolean).join("\n\n"); + return generateText({ + model: getConversationModel(), + system, messages: buildModelMessages(messages), tools: buildConversationTools(context), stopWhen: stepCountIs(5), diff --git a/src/v1/curator/curator-agent.ts b/src/v1/curator/curator-agent.ts index e3be31a..cc1bf10 100644 --- a/src/v1/curator/curator-agent.ts +++ b/src/v1/curator/curator-agent.ts @@ -1,8 +1,7 @@ -import { generateObject, generateText, stepCountIs } from "ai"; +import { generateObject } from "ai"; import { z } from "zod"; import { addMessagePg, createConversationPg, ensureMissionConversationPg, listMessagesPg } from "../../grow/persistence.js"; -import { getConversationModel } from "../../actors/conversation/agent.js"; -import { buildCuratorTools } from "./curator-tools.js"; +import { generateConversationResponse, getConversationModel } from "../../actors/conversation/agent.js"; import { buildCuratorTasks, todayIsoDate } from "./curator-store.js"; import { emitCuratorEvent } from "./curator-events.js"; import type { CuratorChatResponse } from "./curator-types.js"; @@ -28,8 +27,91 @@ function sanitize(text: string) { .trim(); } -function fallbackReply(task: Awaited>[number] | undefined, latest: string) { +type CuratorMessage = Awaited>[number]; + +function visibleCuratorMessages(messages: CuratorMessage[]) { + return messages.filter((message) => { + if (message.role !== "assistant") return true; + return !/what should i capture/i.test(message.content); + }); +} + +function usefulUserMessages(messages: CuratorMessage[]) { + return messages + .filter((message) => message.role === "user") + .map((message) => message.content.trim()) + .filter((content) => content && !/^start$/i.test(content) && !content.toLowerCase().includes("i opened ")); +} + +function targetRoleState(messages: CuratorMessage[], latest: string) { + const userMessages = usefulUserMessages(messages); + const all = [...userMessages, latest.trim()].filter(Boolean); + const lowerAll = all.join("\n").toLowerCase(); + const shortAnswers = all.filter((content) => content.length <= 80); + const targetRole = shortAnswers.find((content) => { + const lower = content.toLowerCase(); + return /manager|engineer|designer|analyst|developer|product|marketing|sales|founder|consultant|operator|lead|head|director/.test(lower); + }); + const currentBackground = all.find((content) => { + const lower = content.toLowerCase(); + return lower.includes("currently") || lower.includes("right now") || lower.includes("i am ") || lower.includes("i'm ") || lower.includes("my background") || lower.includes("experience"); + }); + const constraints = all.find((content) => { + const lower = content.toLowerCase(); + return lower.includes("month") || lower.includes("week") || lower.includes("salary") || lower.includes("remote") || lower.includes("location") || lower.includes("visa") || lower.includes("timeline"); + }); + return { + targetRole, + currentBackground, + constraints, + hasAskedCurrent: lowerAll.includes("current background") || lowerAll.includes("current role") || lowerAll.includes("where you are starting"), + hasAskedConstraints: lowerAll.includes("constraint") || lowerAll.includes("timeline"), + }; +} + +function curatorSystemAddendum(input: { + date: string; + taskId?: string; + task?: Awaited>[number]; +}) { + return [ + "You are currently speaking as the GrowQR V1 Curator through the Conversation Actor.", + "The V1 Curator owns 30 day direction, streak continuity, and service handoff decisions.", + "Carry state from the conversation history. If the user gives a short answer like a role name, accept it and ask for the next missing slot.", + "Do not ask the same question twice. Do not output checklist items as separate baked chat messages.", + "For target-role tasks, collect target role, current background, constraints, then offer a resume or interview handoff.", + "For service work, use Conversation Actor tools to prepare handoffs. Do not mark work complete unless a service or platform event exists.", + "Never say: What should I capture next. Ask a concrete conversational question tied to the task.", + `Date: ${input.date}`, + `Curator task id: ${input.taskId ?? "none"}`, + `Curator task title: ${input.task?.title ?? "General curator chat"}`, + `Curator task context: ${input.task?.contextNarrative ?? "none"}`, + `Curator task subtasks: ${input.task?.subtasks.join(" | ") ?? "none"}`, + `Curator service: ${input.task?.serviceName ?? "none"}`, + `Expected completion events: ${input.task?.completionEvents.join(", ") ?? "none"}`, + ].join("\n"); +} + +function fallbackReply(task: Awaited>[number] | undefined, latest: string, history: CuratorMessage[] = []) { const lower = latest.toLowerCase(); + const taskText = `${task?.title ?? ""} ${task?.subtitle ?? ""} ${task?.contextNarrative ?? ""} ${task?.subtasks.join(" ") ?? ""}`.toLowerCase(); + const isTargetRoleTask = taskText.includes("target role") || taskText.includes("career transition") || taskText.includes("transition thesis"); + if (isTargetRoleTask) { + const state = targetRoleState(history, latest); + if (!state.targetRole && (lower.includes("opened") || lower.includes("target role") || lower.includes("role you want"))) { + return "Which role are you aiming for next?"; + } + if (!state.targetRole) return "What target role are you considering next?"; + if (!state.currentBackground) return `Got it, ${state.targetRole} is the direction. What are you doing now, and what background should I factor in?`; + if (!state.constraints) return "Good, that gives me the direction and starting point. What constraints should I plan around, like timeline, location, salary, or industry?"; + return "I have the target role, current background, and constraints. Should I shape the resume plan first or set up interview practice for this direction?"; + } + if (taskText.includes("main goal") || taskText.includes("30 days") || taskText.includes("onboarding")) { + if (latest.trim().length > 1 && latest.trim().length < 120) { + return `Got it. What is blocking you from ${latest.trim()} right now, and which service should help first: resume, interview, or roleplay?`; + } + return "What outcome do you want in the next 30 days, and what is blocking you right now?"; + } if (task?.serviceId === "resume-service") { if (lower.includes("target role") || lower.includes("goal")) { return "What target role are you aiming for, and what kind of resume change would help most right now: stronger proof, clearer skills, or better role fit?"; @@ -56,7 +138,7 @@ function fallbackReply(task: Awaited>[numbe if (lower.includes("outcome") || lower.includes("tone")) return "What outcome do you want, and what tone should you practice?"; return "Tell me who you are speaking with and what makes this conversation difficult."; } - return `What should I capture next for ${task?.title ?? "this curator task"}?`; + return "Tell me where you are starting from and the outcome you want next. I will use that to choose the right service handoff."; } async function ensureCuratorConversation(input: { userId: string; taskId?: string; date: string }) { @@ -97,6 +179,7 @@ export async function runCuratorChat(input: { sender: "User", content: latest, }); + const conversationHistory = visibleCuratorMessages(await listMessagesPg(input.userId, conversation.id)); let reply = ""; try { @@ -113,34 +196,24 @@ export async function runCuratorChat(input: { payload: { taskId: input.taskId, extract: extract.object }, }); - const result = await generateText({ - model: getConversationModel(), - system: [ - "You are the GrowQR V1 Curator Agent.", - "You own 30 day task direction, streak continuity, and service handoffs.", - "Use the supplied tools to read tasks, Q-score, service capabilities, reports, and to prepare handoffs.", - "Do not claim a task is completed unless a valid service or platform event exists.", - "Ask a task-specific question, not the same generic question for every task.", - "If the user just opened a task, ask one warm next question based on the exact subtask and available context.", - "Do not list all three subtasks as baked messages. Keep the conversation natural.", - "Keep the answer under 80 words. Use ASCII punctuation only. Do not use em dash or en dash.", - ].join("\n"), - prompt: [ - `Date: ${date}`, - `Task id: ${input.taskId ?? "none"}`, - `Task title: ${task?.title ?? "General curator chat"}`, - `Task context: ${task?.contextNarrative ?? "none"}`, - `Task subtasks: ${task?.subtasks.join(" | ") ?? "none"}`, - `Service: ${task?.serviceName ?? "none"}`, - `Completion events: ${task?.completionEvents.join(", ") ?? "none"}`, - `User message: ${latest}`, - ].join("\n"), - tools: buildCuratorTools({ userId: input.userId, date, conversationId: conversation.id, taskId: input.taskId }), - stopWhen: stepCountIs(6), + const result = await generateConversationResponse(conversationHistory.map((message) => ({ + role: message.role, + content: message.content, + })), { + userId: input.userId, + conversationId: conversation.id, + missionInstanceId: task?.missionInstanceId, + missionId: task?.missionId, + stageId: task?.stageId, + source: "curator-v1", + systemAddendum: curatorSystemAddendum({ date, taskId: input.taskId, task }), }); reply = sanitize(result.text); + if (/what should i capture next/i.test(reply) || !reply) { + reply = fallbackReply(task, latest, conversationHistory); + } } catch (error) { - reply = fallbackReply(task, latest); + reply = fallbackReply(task, latest, conversationHistory); } await addMessagePg(input.userId, { @@ -155,6 +228,6 @@ export async function runCuratorChat(input: { conversationId: conversation.id, taskId: input.taskId, reply, - messages: await listMessagesPg(input.userId, conversation.id), + messages: visibleCuratorMessages(await listMessagesPg(input.userId, conversation.id)), }; } diff --git a/src/v1/curator/curator-store.ts b/src/v1/curator/curator-store.ts index b217bdc..01b349e 100644 --- a/src/v1/curator/curator-store.ts +++ b/src/v1/curator/curator-store.ts @@ -59,6 +59,24 @@ function cleanTitle(text: string) { .trim(); } +function stageIntent(input: { + missionTitle: string; + stageTitle: string; + stageDescription: string; +}) { + const raw = `${input.missionTitle} ${input.stageTitle} ${input.stageDescription}`.toLowerCase(); + if (raw.includes("target role") || raw.includes("role recommendation") || raw.includes("career transition") || raw.includes("transition thesis")) { + return "target-role"; + } + if (raw.includes("goal") || raw.includes("achieve") || raw.includes("onboarding")) { + return "goal-setup"; + } + if (raw.includes("resume")) return "resume"; + if (raw.includes("interview")) return "interview"; + if (raw.includes("roleplay")) return "roleplay"; + return "general"; +} + function taskCopy(input: { missionTitle: string; stageTitle: string; @@ -67,6 +85,7 @@ function taskCopy(input: { }) { const mission = input.missionTitle; const stage = cleanTitle(input.stageTitle); + const intent = stageIntent(input); if (input.serviceId === "resume-service") { return { title: stage.toLowerCase().includes("resume") ? stage : "Shape your resume around your goal", @@ -115,15 +134,39 @@ function taskCopy(input: { contextNarrative: `This step is about direction before execution. The curator is collecting role preferences and constraints for ${mission}, so future resume, interview, and pathway suggestions can be grounded in what the user actually wants.`, }; } + if (intent === "target-role") { + return { + title: "Clarify your target role", + subtitle: "Tell the curator where you are starting from, which role you want next, and what constraints matter.", + subtasks: [ + "Share your current role or background", + "Name the target role you want next", + "Add constraints like timeline, location, or salary", + ], + contextNarrative: `This is the direction-setting step for ${mission}. The curator should collect the user's current background, target role, reason for the transition, and constraints before opening resume, interview, or roleplay. The goal is to create a clear transition thesis, not to ask the same generic planning question repeatedly.`, + }; + } + if (intent === "goal-setup") { + return { + title: "Tell the curator your main goal", + subtitle: "Start with the outcome you want, why it matters, and what help you need from GrowQR.", + subtasks: [ + "Describe the outcome you want in 30 days", + "Add what is blocking you right now", + "Choose the first service that should help", + ], + contextNarrative: `This is the goal setup step for ${mission}. The curator should understand what the user wants to achieve, what is blocking progress, and which GrowQR service should help first. It should turn the onboarding context into a useful next action, not a generic checklist.`, + }; + } return { title: stage || "Clarify the next useful action", subtitle: input.stageDescription || `Continue ${mission} with the next useful action.`, subtasks: [ - "Tell the curator what you want to achieve", - "Add the missing context for this step", - "Confirm the next service action", + "Describe where you are starting from", + "Tell the outcome you want next", + "Confirm the best service to open", ], - contextNarrative: `This step belongs to ${mission}. The curator is collecting enough user context to decide the next useful action and avoid generic tasks. Completion should come from a real platform or service event, not from simply clicking through the checklist.`, + contextNarrative: `This step belongs to ${mission}. The curator should ask for the user's starting point, desired outcome, and missing context so it can route to a useful service. Completion should come from a real platform or service event, not from simply clicking through the checklist.`, }; }