Fix curator task chat scoping

This commit is contained in:
Sai-karthik
2026-06-14 19:33:24 +00:00
parent ed7233d6e2
commit 60b1df6892
5 changed files with 250 additions and 26 deletions

View File

@@ -42,6 +42,61 @@ export async function createConversationPg(userId: string, title = "Talk to Me")
return toConversation(row);
}
export async function ensureCuratorTaskConversationPg(input: {
userId: string;
curatorTaskId: string;
subtaskIndex?: number;
subtask?: string;
missionInstanceId?: string;
missionId?: string;
stageId?: string;
title?: string;
}): Promise<GrowConversation> {
const curatorTaskKey = `${input.curatorTaskId}:${input.subtaskIndex ?? "task"}`;
const [existing] = await db
.select()
.from(growConversations)
.where(and(
eq(growConversations.userId, input.userId),
sql`${growConversations.metadata}->>'curatorTaskKey' = ${curatorTaskKey}`,
))
.orderBy(desc(growConversations.updatedAt))
.limit(1);
const metadata = {
source: "curator-v1",
curatorTaskId: input.curatorTaskId,
curatorTaskKey,
...(Number.isInteger(input.subtaskIndex) ? { subtaskIndex: input.subtaskIndex } : {}),
...(input.subtask ? { subtask: input.subtask } : {}),
...(input.missionInstanceId ? { missionInstanceId: input.missionInstanceId } : {}),
...(input.missionId ? { missionId: input.missionId } : {}),
...(input.stageId ? { stageId: input.stageId } : {}),
};
if (existing) {
const [row] = await db.update(growConversations).set({
title: input.title?.trim() || existing.title,
metadata,
updatedAt: new Date(),
}).where(and(eq(growConversations.userId, input.userId), eq(growConversations.id, existing.id))).returning();
if (!row) throw new Error("Failed to update curator task conversation");
return toConversation(row);
}
const now = new Date();
const [row] = await db.insert(growConversations).values({
id: buildId("conversation"),
userId: input.userId,
title: input.title?.trim() || input.subtask?.trim() || "V1 Curator chat",
metadata,
createdAt: now,
updatedAt: now,
}).returning();
if (!row) throw new Error("Failed to create curator task conversation");
return toConversation(row);
}
export async function ensureMissionConversationPg(input: {
userId: string;
missionInstanceId: string;

View File

@@ -33,7 +33,7 @@ export const curatorActor = {
};
},
async chat(input: { userId: string; conversationId?: string; date?: string; taskId?: string; messages: Array<{ role: "user" | "assistant"; content: string }> }) {
async chat(input: { userId: string; conversationId?: string; date?: string; taskId?: string; subtaskIndex?: number; subtask?: string; messages: Array<{ role: "user" | "assistant"; content: string }> }) {
return runCuratorChat(input);
},

View File

@@ -1,6 +1,6 @@
import { generateObject } from "ai";
import { z } from "zod";
import { addMessagePg, createConversationPg, ensureMissionConversationPg, listMessagesPg } from "../../grow/persistence.js";
import { addMessagePg, createConversationPg, ensureCuratorTaskConversationPg, getConversationMetadataPg, listMessagesPg } from "../../grow/persistence.js";
import { generateConversationResponse, getConversationModel } from "../../actors/conversation/agent.js";
import { buildCuratorTasks, todayIsoDate } from "./curator-store.js";
import { emitCuratorEvent } from "./curator-events.js";
@@ -30,9 +30,18 @@ function sanitize(text: 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);
const filtered = messages.filter((message) => {
const content = message.content.trim();
if (message.role === "user") {
if (/^start$/i.test(content)) return false;
if (/^i opened /i.test(content)) return false;
return true;
}
return !/what should i capture/i.test(content);
});
return filtered.filter((message, index) => {
const previous = filtered[index - 1];
return !previous || previous.role !== message.role || previous.content.trim() !== message.content.trim();
});
}
@@ -72,6 +81,8 @@ function targetRoleState(messages: CuratorMessage[], latest: string) {
function curatorSystemAddendum(input: {
date: string;
taskId?: string;
subtaskIndex?: number;
subtask?: string;
task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number];
}) {
return [
@@ -82,8 +93,12 @@ function curatorSystemAddendum(input: {
"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.",
"If a curator subtask is provided, focus on that subtask only. Do not answer as if another subtask was clicked.",
"When the user answers the focused subtask, acknowledge the captured detail and point them to the next missing slot instead of asking the same subtask again.",
`Date: ${input.date}`,
`Curator task id: ${input.taskId ?? "none"}`,
`Focused subtask index: ${Number.isInteger(input.subtaskIndex) ? input.subtaskIndex : "none"}`,
`Focused subtask title: ${input.subtask ?? "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"}`,
@@ -92,7 +107,55 @@ function curatorSystemAddendum(input: {
].join("\n");
}
function fallbackReply(task: Awaited<ReturnType<typeof buildCuratorTasks>>[number] | undefined, latest: string, history: CuratorMessage[] = []) {
function subtaskFallbackReply(input: {
subtask?: string;
subtaskIndex?: number;
task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number];
latest: string;
history?: CuratorMessage[];
}) {
const subtask = input.subtask?.toLowerCase() ?? "";
const latest = input.latest.trim();
const hasUserAnswer = Boolean(latest && !/^start$/i.test(latest) && !/^i opened /i.test(latest));
const taskTitle = input.task?.title ?? "this mission";
const targetRole = targetRoleState(input.history ?? [], latest).targetRole;
if (subtask.includes("current role") || subtask.includes("background")) {
return hasUserAnswer
? "Got it. I will use that as your starting point. Next, open the target role task so I can lock the direction."
: "What are you doing now? Share your current role, years of experience, and the background I should factor in.";
}
if (subtask.includes("target role") || subtask.includes("role you want")) {
return hasUserAnswer
? `Got it, ${targetRole ?? latest} is the target direction. Next, add the constraints I should plan around.`
: "Which role are you aiming for next?";
}
if (subtask.includes("constraint") || subtask.includes("timeline") || subtask.includes("salary") || subtask.includes("location")) {
return hasUserAnswer
? "Saved. I now have enough direction to shape the next resume or interview handoff for this mission."
: "What constraints should I plan around, like timeline, location, salary, visa, remote preference, or industry?";
}
if (subtask.includes("resume")) {
return hasUserAnswer
? "Got it. I will use that resume context for the handoff instead of asking the broad setup again."
: "Paste the resume text or upload the file here, and I will read it against the mission goal.";
}
if (subtask.includes("interview")) {
return hasUserAnswer
? "Got it. I will use that to prepare the interview setup."
: "What role, interview round, and one weak spot should the interview setup focus on?";
}
if (Number.isInteger(input.subtaskIndex)) {
return hasUserAnswer
? `Got it. I saved that for ${taskTitle}.`
: `For ${input.subtask}, what detail should I use?`;
}
return undefined;
}
function fallbackReply(task: Awaited<ReturnType<typeof buildCuratorTasks>>[number] | undefined, latest: string, history: CuratorMessage[] = [], subtask?: string, subtaskIndex?: number) {
const subtaskReply = subtaskFallbackReply({ task, latest, history, subtask, subtaskIndex });
if (subtaskReply) return subtaskReply;
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");
@@ -141,33 +204,54 @@ function fallbackReply(task: Awaited<ReturnType<typeof buildCuratorTasks>>[numbe
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 }) {
function curatorTaskKey(taskId?: string, subtaskIndex?: number) {
if (!taskId) return undefined;
return `${taskId}:${subtaskIndex ?? "task"}`;
}
async function ensureCuratorConversation(input: { userId: string; taskId?: string; date: string; subtaskIndex?: number; subtask?: string }) {
if (!input.taskId) return createConversationPg(input.userId, "V1 Curator chat");
const task = (await buildCuratorTasks(input.userId, input.date)).find((item) => item.id === input.taskId);
if (task?.missionInstanceId) {
return ensureMissionConversationPg({
if (task) {
return ensureCuratorTaskConversationPg({
userId: input.userId,
curatorTaskId: task.id,
subtaskIndex: input.subtaskIndex,
subtask: input.subtask,
missionInstanceId: task.missionInstanceId,
missionId: task.missionId,
stageId: task.stageId,
title: task.title,
source: "curator-v1",
title: input.subtask ?? task.title,
});
}
return createConversationPg(input.userId, task?.title ?? "V1 Curator chat");
return createConversationPg(input.userId, "V1 Curator chat");
}
export async function runCuratorChat(input: {
userId: string;
conversationId?: string;
taskId?: string;
subtaskIndex?: number;
subtask?: string;
date?: string;
messages: Array<{ role: "user" | "assistant"; content: string }>;
}): Promise<CuratorChatResponse> {
const date = input.date ?? todayIsoDate();
const conversation = input.conversationId
? { id: input.conversationId }
: await ensureCuratorConversation({ userId: input.userId, taskId: input.taskId, date });
const expectedTaskKey = curatorTaskKey(input.taskId, input.subtaskIndex);
let conversation = input.conversationId ? { id: input.conversationId } : undefined;
if (conversation?.id && expectedTaskKey) {
const metadata = await getConversationMetadataPg(input.userId, conversation.id);
if (metadata?.curatorTaskKey !== expectedTaskKey) {
conversation = undefined;
}
}
conversation ??= await ensureCuratorConversation({
userId: input.userId,
taskId: input.taskId,
date,
subtaskIndex: input.subtaskIndex,
subtask: input.subtask,
});
const latest = [...input.messages].reverse().find((message) => message.role === "user")?.content?.trim() ?? "start";
const tasks = await buildCuratorTasks(input.userId, date);
const task = input.taskId ? tasks.find((item) => item.id === input.taskId) : undefined;
@@ -187,7 +271,7 @@ export async function runCuratorChat(input: {
model: getConversationModel(),
schema: chatExtractSchema,
system: "Extract compact curator memory from the user's latest message. Use ASCII punctuation only.",
prompt: `Task: ${task?.title ?? "General curator chat"}\nService: ${task?.serviceName ?? "none"}\nMessage: ${latest}`,
prompt: `Task: ${task?.title ?? "General curator chat"}\nSubtask: ${input.subtask ?? "none"}\nService: ${task?.serviceName ?? "none"}\nMessage: ${latest}`,
});
await emitCuratorEvent({
userId: input.userId,
@@ -206,14 +290,14 @@ export async function runCuratorChat(input: {
missionId: task?.missionId,
stageId: task?.stageId,
source: "curator-v1",
systemAddendum: curatorSystemAddendum({ date, taskId: input.taskId, task }),
systemAddendum: curatorSystemAddendum({ date, taskId: input.taskId, subtaskIndex: input.subtaskIndex, subtask: input.subtask, task }),
});
reply = sanitize(result.text);
if (/what should i capture next/i.test(reply) || !reply) {
reply = fallbackReply(task, latest, conversationHistory);
reply = fallbackReply(task, latest, conversationHistory, input.subtask, input.subtaskIndex);
}
} catch (error) {
reply = fallbackReply(task, latest, conversationHistory);
reply = fallbackReply(task, latest, conversationHistory, input.subtask, input.subtaskIndex);
}
await addMessagePg(input.userId, {

View File

@@ -6,6 +6,8 @@ import { curatorActor } from "./curator-actor.js";
const chatSchema = z.object({
conversationId: z.string().optional(),
taskId: z.string().optional(),
subtaskIndex: z.number().int().min(0).optional(),
subtask: z.string().optional(),
date: z.string().optional(),
messages: z.array(z.object({ role: z.enum(["user", "assistant"]), content: z.string() })).min(1).max(50),
});

View File

@@ -1,9 +1,12 @@
import { and, desc, eq, gte, inArray, sql } from "drizzle-orm";
import { generateText } from "ai";
import { z } from "zod";
import { db } from "../../db/client.js";
import { growEvents } from "../../db/schema.js";
import { listActiveMissionsPg } from "../../grow/persistence.js";
import { listMissionDefinitions } from "../../missions/registry.js";
import { listServiceCapabilities } from "../../workflows/service-capabilities.js";
import { getConversationModel } from "../../actors/conversation/agent.js";
import type { CuratorPlan, CuratorServiceId, CuratorStreak, CuratorTask } from "./curator-types.js";
import { completionEventsForService, serviceName, serviceRoute, serviceToolName } from "./curator-service-links.js";
@@ -77,7 +80,29 @@ function stageIntent(input: {
return "general";
}
function taskCopy(input: {
const generatedTaskCopySchema = z.object({
title: z.string().min(8).max(70),
subtitle: z.string().min(20).max(160),
subtasks: z.array(z.string().min(8).max(120)).length(3),
contextNarrative: z.string().min(80).max(700),
});
type GeneratedTaskCopy = z.infer<typeof generatedTaskCopySchema>;
const taskCopyCache = new Map<string, GeneratedTaskCopy>();
function parseJsonObject(text: string) {
const trimmed = text.trim();
try {
return JSON.parse(trimmed);
} catch {
const match = trimmed.match(/\{[\s\S]*\}/);
if (!match) throw new Error("model_did_not_return_json");
return JSON.parse(match[0]);
}
}
function fallbackTaskCopy(input: {
missionTitle: string;
stageTitle: string;
stageDescription: string;
@@ -170,7 +195,64 @@ function taskCopy(input: {
};
}
function taskFromStage(input: {
async function taskCopy(input: {
missionTitle: string;
stageTitle: string;
stageDescription: string;
role?: string;
serviceId?: CuratorServiceId;
}) {
const fallback = fallbackTaskCopy(input);
const cacheKey = JSON.stringify({
missionTitle: input.missionTitle,
stageTitle: input.stageTitle,
stageDescription: input.stageDescription,
role: input.role,
serviceId: input.serviceId,
});
const cached = taskCopyCache.get(cacheKey);
if (cached) return cached;
try {
const result = await generateText({
model: getConversationModel(),
system: [
"You generate GrowQR V1 curator task copy.",
"Return JSON only. Do not wrap it in markdown.",
"The JSON shape must be: {\"title\": string, \"subtitle\": string, \"subtasks\": [string, string, string], \"contextNarrative\": string}.",
"Do not invent new missions or services. Use the provided mission, stage, role, and service only.",
"Generate exactly three sequential subtasks that make sense for a real user.",
"Keep each subtask label short, direct, and under 80 characters when possible.",
"Each subtask must ask for a different missing piece of context. Never repeat the same question.",
"Do not include rewards, coins, scores, or generic readiness-score language.",
"Use ASCII punctuation only.",
"For early users, prefer practical context capture: goal, current background, resume/input, constraints, or service setup.",
].join("\n"),
prompt: [
`Mission: ${input.missionTitle}`,
`Stage: ${input.stageTitle}`,
`Stage description: ${input.stageDescription || "none"}`,
`Stage role: ${input.role ?? "none"}`,
`Service: ${input.serviceId ? serviceName(input.serviceId) : "Mission Planner"}`,
`Fallback intent: ${stageIntent(input)}`,
`Fallback copy to improve, not copy blindly: ${JSON.stringify(fallback)}`,
].join("\n"),
});
const copy = generatedTaskCopySchema.parse(parseJsonObject(result.text));
taskCopyCache.set(cacheKey, copy);
return copy;
} catch (error) {
console.warn("curator task copy generation failed; using fallback", {
missionTitle: input.missionTitle,
stageTitle: input.stageTitle,
serviceId: input.serviceId,
error: error instanceof Error ? error.message : String(error),
});
return fallback;
}
}
async function taskFromStage(input: {
userId: string;
date: string;
index: number;
@@ -183,14 +265,15 @@ function taskFromStage(input: {
role?: string;
serviceId?: CuratorServiceId;
completed?: boolean;
}): CuratorTask {
}): Promise<CuratorTask> {
const serviceId = input.serviceId ?? serviceFromRole(input.role);
const id = `curator:${input.date}:${input.missionInstanceId ?? input.missionId}:${input.stageId ?? input.index}`;
const route = serviceRoute({ serviceId, missionId: input.missionId, missionInstanceId: input.missionInstanceId, stageId: input.stageId, taskId: id });
const copy = taskCopy({
const copy = await taskCopy({
missionTitle: input.missionTitle,
stageTitle: input.stageTitle,
stageDescription: input.stageDescription,
role: input.role,
serviceId,
});
return {
@@ -269,7 +352,7 @@ export async function buildCuratorTasks(userId: string, date = todayIso()): Prom
});
for (const stage of stages) {
tasks.push(taskFromStage({
tasks.push(await taskFromStage({
userId,
date,
index: tasks.length,
@@ -298,7 +381,7 @@ export async function buildCuratorTasks(userId: string, date = todayIso()): Prom
if (!item.serviceId) continue;
if (item.serviceId && seenServices.has(item.serviceId)) continue;
if (tasks.some((task) => task.missionId === item.mission.missionId && task.stageId === item.module.id)) continue;
tasks.push(taskFromStage({
tasks.push(await taskFromStage({
userId,
date,
index: tasks.length,
@@ -316,7 +399,7 @@ export async function buildCuratorTasks(userId: string, date = todayIso()): Prom
for (const item of fallbackModules) {
if (tasks.length >= 3) break;
if (tasks.some((task) => task.missionId === item.mission.missionId && task.stageId === item.module.id)) continue;
tasks.push(taskFromStage({
tasks.push(await taskFromStage({
userId,
date,
index: tasks.length,