Route curator chat through conversation actor

This commit is contained in:
Sai-karthik
2026-06-14 19:00:28 +00:00
parent 4a20816ba0
commit ed7233d6e2
3 changed files with 170 additions and 39 deletions

View File

@@ -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<ConversationMessage, "role" | "content">;
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),

View File

@@ -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<ReturnType<typeof buildCuratorTasks>>[number] | undefined, latest: string) {
type CuratorMessage = Awaited<ReturnType<typeof listMessagesPg>>[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<ReturnType<typeof buildCuratorTasks>>[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<ReturnType<typeof buildCuratorTasks>>[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<ReturnType<typeof buildCuratorTasks>>[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)),
};
}

View File

@@ -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.`,
};
}