Fix curator task chat scoping
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user