Route curator chat through conversation actor
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user