Implement V1 curator flow
This commit is contained in:
@@ -21,6 +21,7 @@ import { homeRoutes } from "./routes/home.js";
|
||||
import { dailyMissionRoutes } from "./routes/daily-mission.js";
|
||||
import { analyticsRoutes } from "./routes/analytics.js";
|
||||
import { logRoutes } from "./routes/logs.js";
|
||||
import { v1Routes } from "./v1/index.js";
|
||||
import { startGrowEventsRedisConsumer } from "./events/redis-consumer.js";
|
||||
import { db } from "./db/client.js";
|
||||
import { hydratePortAllocator, reconcileOnBoot, ensureCentralGiteaReady } from "./docker/manager.js";
|
||||
@@ -90,6 +91,7 @@ async function main() {
|
||||
app.route("/missions", missionRoutes());
|
||||
app.route("/events", eventRoutes());
|
||||
app.route("/analytics", analyticsRoutes());
|
||||
app.route("/v1", v1Routes());
|
||||
app.route("/logs", logRoutes());
|
||||
app.route("/home", homeRoutes());
|
||||
app.route("/daily-mission", dailyMissionRoutes());
|
||||
|
||||
10
src/v1/analytics/README.md
Normal file
10
src/v1/analytics/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# V1 Analytics
|
||||
|
||||
V1 Analytics reuses the existing Analytics Actor for platform, Q-score, and activity reads.
|
||||
|
||||
The added responsibility here is the nightly improvement loop:
|
||||
|
||||
1. Read Grow events, service events, Q-score signals, and conversation summaries.
|
||||
2. Generate validated improvement signal objects with the Vercel AI SDK.
|
||||
3. Apply those signals to the V1 Curator as events.
|
||||
4. The Curator uses them on the next day when shaping tasks and nudges.
|
||||
115
src/v1/analytics/analytics-actor.ts
Normal file
115
src/v1/analytics/analytics-actor.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { generateObject, generateText, tool } from "ai";
|
||||
import { z } from "zod";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { createClient, type Client } from "rivetkit/client";
|
||||
import { config } from "../../config.js";
|
||||
import type { Registry } from "../../actors/registry.js";
|
||||
import { getConversationModel } from "../../actors/conversation/agent.js";
|
||||
import { db } from "../../db/client.js";
|
||||
import { growConversationMessages, growEvents, users } from "../../db/schema.js";
|
||||
import { curatorActor } from "../curator/curator-actor.js";
|
||||
import { curatorImprovementSignalSchema } from "../curator/curator-types.js";
|
||||
|
||||
let _client: Client<Registry> | null = null;
|
||||
function getClient(): Client<Registry> {
|
||||
return (_client ??= createClient<Registry>(config.rivetClientEndpoint));
|
||||
}
|
||||
|
||||
const signalsSchema = z.object({
|
||||
signals: z.array(curatorImprovementSignalSchema.omit({ userId: true, date: true }).extend({
|
||||
id: z.string(),
|
||||
})).max(5),
|
||||
});
|
||||
|
||||
export const analyticsTools = {
|
||||
read_platform_events: tool({
|
||||
description: "Read latest platform events.",
|
||||
inputSchema: z.object({ limit: z.number().int().min(1).max(100).default(50) }),
|
||||
execute: async ({ limit }) => db.select().from(growEvents).orderBy(desc(growEvents.occurredAt)).limit(limit),
|
||||
}),
|
||||
read_user_service_events: tool({
|
||||
description: "Read latest service events for a user.",
|
||||
inputSchema: z.object({ userId: z.string(), limit: z.number().int().min(1).max(100).default(50) }),
|
||||
execute: async ({ userId, limit }) => db.select().from(growEvents).where(eq(growEvents.userId, userId)).orderBy(desc(growEvents.occurredAt)).limit(limit),
|
||||
}),
|
||||
read_conversation_summaries: tool({
|
||||
description: "Read latest conversation messages for a user.",
|
||||
inputSchema: z.object({ userId: z.string(), limit: z.number().int().min(1).max(100).default(30) }),
|
||||
execute: async ({ userId, limit }) => db.select().from(growConversationMessages).where(eq(growConversationMessages.userId, userId)).orderBy(desc(growConversationMessages.createdAt)).limit(limit),
|
||||
}),
|
||||
generate_improvement_signals: tool({
|
||||
description: "Generate curator improvement signals for a user.",
|
||||
inputSchema: z.object({ userId: z.string(), date: z.string() }),
|
||||
execute: async ({ userId, date }) => v1AnalyticsActor.generateImprovementSignals({ userId, date }),
|
||||
}),
|
||||
apply_improvement_to_curator: tool({
|
||||
description: "Apply generated improvement signals to the curator.",
|
||||
inputSchema: z.object({ userId: z.string(), date: z.string(), signals: z.array(curatorImprovementSignalSchema) }),
|
||||
execute: async ({ userId, date, signals }) => curatorActor.applyImprovementSignals({ userId, date, signals }),
|
||||
}),
|
||||
};
|
||||
|
||||
export const v1AnalyticsActor = {
|
||||
async getPlatform() {
|
||||
return getClient().analyticsActor.getOrCreate(["platform"]).getPlatform();
|
||||
},
|
||||
|
||||
async getUserQscore(input: { userId: string }) {
|
||||
return getClient().analyticsActor.getOrCreate(["user", input.userId]).getUserQscore(input);
|
||||
},
|
||||
|
||||
async getUserActivity(input: { userId: string }) {
|
||||
return getClient().analyticsActor.getOrCreate(["user", input.userId]).getUserActivity(input);
|
||||
},
|
||||
|
||||
async generateImprovementSignals(input: { userId: string; date: string }) {
|
||||
const events = await db.select().from(growEvents).where(eq(growEvents.userId, input.userId)).orderBy(desc(growEvents.occurredAt)).limit(80);
|
||||
const messages = await db.select().from(growConversationMessages).where(eq(growConversationMessages.userId, input.userId)).orderBy(desc(growConversationMessages.createdAt)).limit(40);
|
||||
try {
|
||||
const result = await generateObject({
|
||||
model: getConversationModel(),
|
||||
schema: signalsSchema,
|
||||
system: "You are the GrowQR V1 Analytics Actor. Generate small overnight improvement signals for the Curator. Use ASCII punctuation.",
|
||||
prompt: JSON.stringify({ date: input.date, events, messages }).slice(0, 20000),
|
||||
});
|
||||
return result.object.signals.map((signal) => curatorImprovementSignalSchema.parse({ ...signal, userId: input.userId, date: input.date }));
|
||||
} catch {
|
||||
return [curatorImprovementSignalSchema.parse({
|
||||
id: `improvement:${input.userId}:${input.date}:streak`,
|
||||
userId: input.userId,
|
||||
date: input.date,
|
||||
priority: 50,
|
||||
reason: "Keep service usage meaningful and preserve streak momentum.",
|
||||
nudgeText: "Pick one task that opens a real service today.",
|
||||
status: "created",
|
||||
})];
|
||||
}
|
||||
},
|
||||
|
||||
async applyImprovementSignals(input: { userId: string; date: string; signals: z.infer<typeof curatorImprovementSignalSchema>[] }) {
|
||||
return curatorActor.applyImprovementSignals(input);
|
||||
},
|
||||
|
||||
async runNightly(input: { date: string; userId?: string }) {
|
||||
const userRows = input.userId
|
||||
? [{ id: input.userId }]
|
||||
: await db.select({ id: users.id }).from(users).limit(500);
|
||||
let improvementSignalsCreated = 0;
|
||||
for (const user of userRows) {
|
||||
const signals = await this.generateImprovementSignals({ userId: user.id, date: input.date });
|
||||
improvementSignalsCreated += signals.length;
|
||||
await this.applyImprovementSignals({ userId: user.id, date: input.date, signals });
|
||||
}
|
||||
return { date: input.date, usersProcessed: userRows.length, improvementSignalsCreated };
|
||||
},
|
||||
|
||||
async explain(input: { userId: string; question: string }) {
|
||||
const answer = await generateText({
|
||||
model: getConversationModel(),
|
||||
system: "You are the GrowQR V1 Analytics Actor. Explain analytics and Q-score movement concisely.",
|
||||
prompt: input.question,
|
||||
tools: analyticsTools,
|
||||
});
|
||||
return { answer: answer.text };
|
||||
},
|
||||
};
|
||||
29
src/v1/analytics/analytics-routes.ts
Normal file
29
src/v1/analytics/analytics-routes.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Hono } from "hono";
|
||||
import { z } from "zod";
|
||||
import { requireUser, type AuthContext } from "../../auth/clerk.js";
|
||||
import { v1AnalyticsActor } from "./analytics-actor.js";
|
||||
|
||||
export function v1AnalyticsRoutes() {
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
|
||||
app.get("/platform", async (c) => c.json(await v1AnalyticsActor.getPlatform()));
|
||||
|
||||
app.get("/user/qscore", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
return c.json(await v1AnalyticsActor.getUserQscore({ userId }));
|
||||
});
|
||||
|
||||
app.get("/user/activity", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
return c.json(await v1AnalyticsActor.getUserActivity({ userId }));
|
||||
});
|
||||
|
||||
app.post("/nightly/run", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = z.object({ date: z.string().optional(), userId: z.string().optional() }).parse(await c.req.json().catch(() => ({})));
|
||||
return c.json(await v1AnalyticsActor.runNightly({ date: body.date ?? new Date().toISOString().slice(0, 10), userId: body.userId ?? userId }));
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
10
src/v1/curator/README.md
Normal file
10
src/v1/curator/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# V1 Curator
|
||||
|
||||
V1 replaces the old Daily Mission path with a single Curator layer.
|
||||
|
||||
- Curator owns the 30 day plan JSON, today's tasks, streak state, service direction, and task status.
|
||||
- Conversation Actor still owns chat persistence and long lived conversations.
|
||||
- Analytics Actor owns the nightly loop and writes improvement signals back into Curator events.
|
||||
- Services still own their workflows. Curator tools prepare handoffs and routes.
|
||||
|
||||
Completion is event gated. A checkbox or chat message cannot complete a task unless a matching service or platform event exists.
|
||||
77
src/v1/curator/curator-actor.ts
Normal file
77
src/v1/curator/curator-actor.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { buildCuratorPlan, buildCuratorStreak, buildCuratorTasks, todayIsoDate } from "./curator-store.js";
|
||||
import { curatorPlanSchema, type CuratorImprovementSignal } from "./curator-types.js";
|
||||
import { emitCuratorEvent } from "./curator-events.js";
|
||||
import { runCuratorChat } from "./curator-agent.js";
|
||||
import { prepareHandoffForTask } from "./curator-tools.js";
|
||||
|
||||
export const curatorActor = {
|
||||
async generatePlanRange(input: { userId: string; startDate?: string; endDate?: string; goals?: string[]; forceRegenerate?: boolean }) {
|
||||
const startDate = input.startDate ?? todayIsoDate();
|
||||
const endDate = input.endDate ?? startDate;
|
||||
const plan = curatorPlanSchema.parse(await buildCuratorPlan(input.userId, { startDate, endDate, goals: input.goals }));
|
||||
await emitCuratorEvent({ userId: input.userId, type: "curator.plan.generated", payload: { startDate, endDate, goals: input.goals ?? [] } });
|
||||
return { plan };
|
||||
},
|
||||
|
||||
async getPlan(input: { userId: string; startDate?: string; endDate?: string }) {
|
||||
return this.generatePlanRange(input);
|
||||
},
|
||||
|
||||
async getToday(input: { userId: string; date?: string }) {
|
||||
const date = input.date ?? todayIsoDate();
|
||||
const plan = curatorPlanSchema.parse(await buildCuratorPlan(input.userId, { startDate: date, endDate: date }));
|
||||
const tasks = plan.days[0]?.tasks ?? await buildCuratorTasks(input.userId, date);
|
||||
await emitCuratorEvent({ userId: input.userId, type: "curator.day.opened", payload: { date } });
|
||||
return {
|
||||
date,
|
||||
plan,
|
||||
tasks,
|
||||
streak: plan.streak,
|
||||
completedCount: tasks.filter((task) => task.status === "completed").length,
|
||||
totalCount: tasks.length,
|
||||
source: "curator-v1" as const,
|
||||
};
|
||||
},
|
||||
|
||||
async chat(input: { userId: string; conversationId?: string; date?: string; taskId?: string; messages: Array<{ role: "user" | "assistant"; content: string }> }) {
|
||||
return runCuratorChat(input);
|
||||
},
|
||||
|
||||
async startTask(input: { userId: string; taskId: string; date?: string }) {
|
||||
const date = input.date ?? todayIsoDate();
|
||||
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
|
||||
if (!task) throw new Error("curator_task_not_found");
|
||||
const event = await emitCuratorEvent({
|
||||
userId: input.userId,
|
||||
type: "curator.task.started",
|
||||
mission: { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId },
|
||||
payload: { taskId: task.id, date },
|
||||
});
|
||||
return { task: { ...task, status: "started" as const }, eventId: event.id };
|
||||
},
|
||||
|
||||
async prepareTaskHandoff(input: { userId: string; taskId: string; date?: string }) {
|
||||
const date = input.date ?? todayIsoDate();
|
||||
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
|
||||
if (!task) throw new Error("curator_task_not_found");
|
||||
if (task.serviceId) return prepareHandoffForTask(input.userId, task, task.serviceId);
|
||||
throw new Error("curator_task_has_no_handoff");
|
||||
},
|
||||
|
||||
async recordServiceImpact(input: { userId: string; eventId: string }) {
|
||||
const streak = await buildCuratorStreak(input.userId);
|
||||
return { matched: true, completedTasks: await buildCuratorTasks(input.userId, todayIsoDate()), streak };
|
||||
},
|
||||
|
||||
async applyImprovementSignals(input: { userId: string; date: string; signals: CuratorImprovementSignal[] }) {
|
||||
for (const signal of input.signals) {
|
||||
await emitCuratorEvent({ userId: input.userId, type: "curator.improvement_signal.applied", payload: { signal } });
|
||||
}
|
||||
const plan = await buildCuratorPlan(input.userId, { startDate: input.date, endDate: input.date });
|
||||
return { applied: input.signals.length, plan };
|
||||
},
|
||||
|
||||
async getState(input: { userId: string }) {
|
||||
return { tasks: await buildCuratorTasks(input.userId, todayIsoDate()), streak: await buildCuratorStreak(input.userId) };
|
||||
},
|
||||
};
|
||||
131
src/v1/curator/curator-agent.ts
Normal file
131
src/v1/curator/curator-agent.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { generateObject, generateText, stepCountIs } 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 { buildCuratorTasks, todayIsoDate } from "./curator-store.js";
|
||||
import { emitCuratorEvent } from "./curator-events.js";
|
||||
import type { CuratorChatResponse } from "./curator-types.js";
|
||||
|
||||
const chatExtractSchema = z.object({
|
||||
summary: z.string(),
|
||||
userGoal: z.string().optional(),
|
||||
serviceIntent: z.string().optional(),
|
||||
shouldPrepareHandoff: z.boolean().default(false),
|
||||
});
|
||||
|
||||
function buildId(prefix: string) {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function sanitize(text: string) {
|
||||
return text
|
||||
.replace(/[\u2013\u2014]/g, "-")
|
||||
.replace(/[\u2018\u2019]/g, "'")
|
||||
.replace(/[\u201C\u201D]/g, '"')
|
||||
.replace(/\u2026/g, "...")
|
||||
.replace(/^\s*(Perfect|Great|Absolutely|Sure)[.!,:;-]*\s*/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function ensureCuratorConversation(input: { userId: string; taskId?: string; date: 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({
|
||||
userId: input.userId,
|
||||
missionInstanceId: task.missionInstanceId,
|
||||
missionId: task.missionId,
|
||||
stageId: task.stageId,
|
||||
title: task.title,
|
||||
source: "curator-v1",
|
||||
});
|
||||
}
|
||||
return createConversationPg(input.userId, task?.title ?? "V1 Curator chat");
|
||||
}
|
||||
|
||||
export async function runCuratorChat(input: {
|
||||
userId: string;
|
||||
conversationId?: string;
|
||||
taskId?: 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 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;
|
||||
|
||||
await addMessagePg(input.userId, {
|
||||
id: buildId("user"),
|
||||
conversationId: conversation.id,
|
||||
role: "user",
|
||||
sender: "User",
|
||||
content: latest,
|
||||
});
|
||||
|
||||
let reply = "";
|
||||
try {
|
||||
const extract = await generateObject({
|
||||
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}`,
|
||||
});
|
||||
await emitCuratorEvent({
|
||||
userId: input.userId,
|
||||
type: "curator.chat.context_extracted",
|
||||
mission: task ? { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId } : undefined,
|
||||
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.",
|
||||
"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"}`,
|
||||
`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),
|
||||
});
|
||||
reply = sanitize(result.text);
|
||||
} catch (error) {
|
||||
reply = task?.serviceId === "resume-service"
|
||||
? "Share the resume text or upload the resume file, plus the target role. I will prepare the resume handoff and keep this task tied to service events."
|
||||
: task?.serviceId === "interview-service"
|
||||
? "Tell me the role, round type, and one thing you want to improve. I will prepare the interview setup handoff."
|
||||
: task?.serviceId === "roleplay-service"
|
||||
? "Tell me the scenario, who you are speaking with, and the outcome you want. I will prepare the roleplay handoff."
|
||||
: `What should I capture for ${task?.title ?? "this curator task"}?`;
|
||||
}
|
||||
|
||||
await addMessagePg(input.userId, {
|
||||
id: buildId("assistant"),
|
||||
conversationId: conversation.id,
|
||||
role: "assistant",
|
||||
sender: "V1 Curator",
|
||||
content: reply,
|
||||
});
|
||||
|
||||
return {
|
||||
conversationId: conversation.id,
|
||||
taskId: input.taskId,
|
||||
reply,
|
||||
messages: await listMessagesPg(input.userId, conversation.id),
|
||||
};
|
||||
}
|
||||
19
src/v1/curator/curator-events.ts
Normal file
19
src/v1/curator/curator-events.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { recordGrowEvent } from "../../events/record-grow-event.js";
|
||||
|
||||
export async function emitCuratorEvent(input: {
|
||||
userId: string;
|
||||
type: string;
|
||||
payload?: Record<string, unknown>;
|
||||
mission?: Record<string, unknown>;
|
||||
}) {
|
||||
return recordGrowEvent({
|
||||
source: "curator-v1",
|
||||
type: input.type,
|
||||
category: "mission",
|
||||
userId: input.userId,
|
||||
occurredAt: new Date().toISOString(),
|
||||
mission: input.mission,
|
||||
payload: input.payload ?? {},
|
||||
dedupeKey: `${input.userId}:${input.type}:${input.payload?.taskId ?? input.payload?.date ?? Date.now()}`,
|
||||
}, { userId: input.userId, source: "curator-v1" });
|
||||
}
|
||||
70
src/v1/curator/curator-routes.ts
Normal file
70
src/v1/curator/curator-routes.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Hono } from "hono";
|
||||
import { z } from "zod";
|
||||
import { requireUser, type AuthContext } from "../../auth/clerk.js";
|
||||
import { curatorActor } from "./curator-actor.js";
|
||||
|
||||
const chatSchema = z.object({
|
||||
conversationId: z.string().optional(),
|
||||
taskId: z.string().optional(),
|
||||
date: z.string().optional(),
|
||||
messages: z.array(z.object({ role: z.enum(["user", "assistant"]), content: z.string() })).min(1).max(50),
|
||||
});
|
||||
|
||||
export function v1CuratorRoutes() {
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
|
||||
app.post("/plan/generate", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = z.object({
|
||||
startDate: z.string().optional(),
|
||||
endDate: z.string().optional(),
|
||||
goals: z.array(z.string()).optional(),
|
||||
forceRegenerate: z.boolean().optional(),
|
||||
}).parse(await c.req.json().catch(() => ({})));
|
||||
return c.json(await curatorActor.generatePlanRange({ userId, ...body }));
|
||||
});
|
||||
|
||||
app.get("/plan", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
return c.json(await curatorActor.getPlan({
|
||||
userId,
|
||||
startDate: c.req.query("startDate"),
|
||||
endDate: c.req.query("endDate"),
|
||||
}));
|
||||
});
|
||||
|
||||
app.get("/today", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
return c.json(await curatorActor.getToday({ userId, date: c.req.query("date") }));
|
||||
});
|
||||
|
||||
app.post("/chat", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = chatSchema.parse(await c.req.json());
|
||||
return c.json(await curatorActor.chat({ userId, ...body }));
|
||||
});
|
||||
|
||||
app.post("/tasks/:taskId/start", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
return c.json(await curatorActor.startTask({ userId, taskId: c.req.param("taskId"), date: c.req.query("date") }));
|
||||
});
|
||||
|
||||
app.post("/tasks/:taskId/handoff", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
return c.json(await curatorActor.prepareTaskHandoff({ userId, taskId: c.req.param("taskId"), date: c.req.query("date") }));
|
||||
});
|
||||
|
||||
app.post("/events/service-impact", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = z.object({ eventId: z.string() }).parse(await c.req.json());
|
||||
return c.json(await curatorActor.recordServiceImpact({ userId, eventId: body.eventId }));
|
||||
});
|
||||
|
||||
app.get("/state", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
return c.json(await curatorActor.getState({ userId }));
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
58
src/v1/curator/curator-service-links.ts
Normal file
58
src/v1/curator/curator-service-links.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { CuratorServiceId, CuratorTask } from "./curator-types.js";
|
||||
|
||||
export function serviceRoute(input: {
|
||||
serviceId?: CuratorServiceId;
|
||||
missionInstanceId?: string;
|
||||
missionId?: string;
|
||||
stageId?: string;
|
||||
taskId?: string;
|
||||
}) {
|
||||
const params = new URLSearchParams({ source: "curator-v1" });
|
||||
if (input.missionInstanceId) params.set("missionInstanceId", input.missionInstanceId);
|
||||
if (input.missionId) params.set("missionId", input.missionId);
|
||||
if (input.stageId) params.set("stageId", input.stageId);
|
||||
if (input.taskId) params.set("curatorTaskId", input.taskId);
|
||||
const suffix = params.toString();
|
||||
|
||||
if (input.serviceId === "interview-service") return `/agents/interview/setup?${suffix}`;
|
||||
if (input.serviceId === "roleplay-service") return `/agents/roleplay/setup?${suffix}`;
|
||||
if (input.serviceId === "resume-service") return `/agents/resume?${suffix}`;
|
||||
if (input.serviceId === "qscore-service") return `/analytics?${suffix}`;
|
||||
if (input.serviceId === "social-branding-service") return `/social?${suffix}`;
|
||||
if (input.serviceId === "matchmaking-service") return `/pathways?${suffix}`;
|
||||
return `/missions/active${input.missionInstanceId ? `?missionInstanceId=${encodeURIComponent(input.missionInstanceId)}` : ""}`;
|
||||
}
|
||||
|
||||
export function serviceName(serviceId?: CuratorServiceId, fallback = "Mission planner") {
|
||||
if (serviceId === "interview-service") return "Interview service";
|
||||
if (serviceId === "roleplay-service") return "Roleplay service";
|
||||
if (serviceId === "resume-service") return "Resume service";
|
||||
if (serviceId === "qscore-service") return "Q Score service";
|
||||
if (serviceId === "social-branding-service") return "Social branding service";
|
||||
if (serviceId === "matchmaking-service") return "Pathways service";
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function serviceToolName(serviceId?: CuratorServiceId) {
|
||||
if (serviceId === "interview-service") return "prepare_interview_setup";
|
||||
if (serviceId === "roleplay-service") return "prepare_roleplay_setup";
|
||||
if (serviceId === "resume-service") return "prepare_resume_upload";
|
||||
if (serviceId === "qscore-service") return "prepare_qscore_review";
|
||||
return "prepare_mission_step";
|
||||
}
|
||||
|
||||
export function completionEventsForService(serviceId?: CuratorServiceId) {
|
||||
if (serviceId === "interview-service") return ["interview.configured", "interview.review_completed", "interview.completed"];
|
||||
if (serviceId === "roleplay-service") return ["roleplay.configured", "roleplay.review_completed", "roleplay.completed"];
|
||||
if (serviceId === "resume-service") return ["resume.analysis_completed", "resume.parsed", "resume.updated"];
|
||||
if (serviceId === "qscore-service") return ["qscore.updated", "qscore.signal_projected"];
|
||||
return ["curator.task.completed"];
|
||||
}
|
||||
|
||||
export function actionLabel(task: CuratorTask) {
|
||||
if (task.serviceId === "interview-service") return "Set up interview";
|
||||
if (task.serviceId === "roleplay-service") return "Set up roleplay";
|
||||
if (task.serviceId === "resume-service") return "Open resume";
|
||||
if (task.serviceId === "qscore-service") return "Review Q Score";
|
||||
return task.cta || "Open";
|
||||
}
|
||||
286
src/v1/curator/curator-store.ts
Normal file
286
src/v1/curator/curator-store.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { and, desc, eq, gte, inArray, sql } from "drizzle-orm";
|
||||
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 type { CuratorPlan, CuratorServiceId, CuratorStreak, CuratorTask } from "./curator-types.js";
|
||||
import { completionEventsForService, serviceName, serviceRoute, serviceToolName } from "./curator-service-links.js";
|
||||
|
||||
const VALID_COMPLETION_TYPES = [
|
||||
"resume.analysis_completed",
|
||||
"resume.parsed",
|
||||
"resume.updated",
|
||||
"interview.configured",
|
||||
"interview.review_completed",
|
||||
"interview.completed",
|
||||
"roleplay.configured",
|
||||
"roleplay.review_completed",
|
||||
"roleplay.completed",
|
||||
"qscore.updated",
|
||||
"qscore.signal_projected",
|
||||
"curator.task.completed",
|
||||
];
|
||||
|
||||
function todayIso(date = new Date()) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function addDaysIso(startDate: string, days: number) {
|
||||
const date = new Date(`${startDate}T00:00:00.000Z`);
|
||||
date.setUTCDate(date.getUTCDate() + days);
|
||||
return todayIso(date);
|
||||
}
|
||||
|
||||
function coerceServiceId(value?: string | null): CuratorServiceId | undefined {
|
||||
if (!value) return undefined;
|
||||
if (value === "interview-service" || value === "resume-service" || value === "roleplay-service" || value === "qscore-service" || value === "social-branding-service" || value === "matchmaking-service") {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function serviceFromRole(role?: string, service?: string): CuratorServiceId | undefined {
|
||||
const raw = `${role ?? ""} ${service ?? ""}`.toLowerCase();
|
||||
if (raw.includes("interview")) return "interview-service";
|
||||
if (raw.includes("resume")) return "resume-service";
|
||||
if (raw.includes("roleplay")) return "roleplay-service";
|
||||
if (raw.includes("qscore") || raw.includes("q score")) return "qscore-service";
|
||||
if (raw.includes("social") || raw.includes("brand")) return "social-branding-service";
|
||||
if (raw.includes("pathway") || raw.includes("match")) return "matchmaking-service";
|
||||
return coerceServiceId(service);
|
||||
}
|
||||
|
||||
function taskFromStage(input: {
|
||||
userId: string;
|
||||
date: string;
|
||||
index: number;
|
||||
missionId: string;
|
||||
missionTitle: string;
|
||||
missionInstanceId?: string;
|
||||
stageId?: string;
|
||||
stageTitle: string;
|
||||
stageDescription: string;
|
||||
role?: string;
|
||||
serviceId?: CuratorServiceId;
|
||||
completed?: boolean;
|
||||
}): 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 });
|
||||
return {
|
||||
id,
|
||||
date: input.date,
|
||||
title: input.stageTitle,
|
||||
subtitle: input.stageDescription || `Continue ${input.missionTitle}`,
|
||||
missionId: input.missionId,
|
||||
missionInstanceId: input.missionInstanceId,
|
||||
stageId: input.stageId,
|
||||
serviceId,
|
||||
serviceName: serviceName(serviceId, input.role),
|
||||
actorName: `${input.missionTitle} curator actor`,
|
||||
toolName: serviceToolName(serviceId),
|
||||
status: input.completed ? "completed" : "ready",
|
||||
rewardCoins: 15 + input.index * 5,
|
||||
qxImpact: serviceId === "interview-service" ? "+10 projected" : serviceId === "resume-service" ? "+7 projected" : serviceId === "roleplay-service" ? "+9 projected" : "+5 projected",
|
||||
effort: serviceId ? "5-15 min" : "2 min",
|
||||
route,
|
||||
cta: serviceId ? `Open ${serviceName(serviceId).replace(" service", "")}` : "Open mission",
|
||||
context: [
|
||||
{ label: "Mission", value: input.missionTitle },
|
||||
{ label: "Stage", value: input.stageTitle },
|
||||
{ label: "Source", value: input.missionInstanceId ? "Active mission" : "Mission registry" },
|
||||
],
|
||||
signals: [input.missionTitle, input.stageTitle, serviceName(serviceId, input.role)],
|
||||
completionEvents: completionEventsForService(serviceId),
|
||||
source: input.missionInstanceId ? "curator-v1" : "mission-registry",
|
||||
};
|
||||
}
|
||||
|
||||
async function completionRows(userId: string, sinceDate: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(growEvents)
|
||||
.where(and(
|
||||
eq(growEvents.userId, userId),
|
||||
inArray(growEvents.type, VALID_COMPLETION_TYPES),
|
||||
gte(growEvents.occurredAt, new Date(`${sinceDate}T00:00:00.000Z`)),
|
||||
))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(200);
|
||||
}
|
||||
|
||||
async function taskCompletedByEvents(userId: string, task: CuratorTask) {
|
||||
const rows = await completionRows(userId, task.date);
|
||||
return rows.some((row) => {
|
||||
const payload = row.payload ?? {};
|
||||
const mission = row.mission ?? {};
|
||||
const correlation = row.correlation ?? {};
|
||||
const taskId = payload.taskId ?? correlation.taskId;
|
||||
if (taskId === task.id) return true;
|
||||
if (!task.completionEvents.includes(row.type)) return false;
|
||||
if (task.missionInstanceId && mission.missionInstanceId === task.missionInstanceId) return true;
|
||||
if (task.stageId && mission.stageId === task.stageId) return true;
|
||||
return !task.missionInstanceId && !task.stageId;
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildCuratorTasks(userId: string, date = todayIso()): Promise<CuratorTask[]> {
|
||||
const active = await listActiveMissionsPg(userId);
|
||||
const tasks: CuratorTask[] = [];
|
||||
|
||||
for (const item of active) {
|
||||
const snapshot = item.snapshot;
|
||||
const stages = (snapshot?.stages ?? [])
|
||||
.filter((stage) => stage.status !== "locked" && stage.status !== "done")
|
||||
.sort((a, b) => {
|
||||
if (a.id === snapshot?.currentStageId) return -1;
|
||||
if (b.id === snapshot?.currentStageId) return 1;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
for (const stage of stages) {
|
||||
tasks.push(taskFromStage({
|
||||
userId,
|
||||
date,
|
||||
index: tasks.length,
|
||||
missionId: item.mission.missionId,
|
||||
missionTitle: item.mission.shortTitle || item.mission.title,
|
||||
missionInstanceId: item.mission.instanceId,
|
||||
stageId: stage.id,
|
||||
stageTitle: stage.title,
|
||||
stageDescription: stage.description,
|
||||
role: stage.role,
|
||||
completed: stage.status === "done",
|
||||
}));
|
||||
if (tasks.length >= 3) break;
|
||||
}
|
||||
if (tasks.length >= 3) break;
|
||||
}
|
||||
|
||||
if (tasks.length < 3) {
|
||||
const fallbackModules = listMissionDefinitions().flatMap((mission) =>
|
||||
mission.modules.map((module) => ({ mission, module, serviceId: serviceFromRole(module.role, module.service) })),
|
||||
);
|
||||
const seenServices = new Set(tasks.map((task) => task.serviceId).filter(Boolean));
|
||||
|
||||
for (const item of fallbackModules) {
|
||||
if (tasks.length >= 3) break;
|
||||
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({
|
||||
userId,
|
||||
date,
|
||||
index: tasks.length,
|
||||
missionId: item.mission.missionId,
|
||||
missionTitle: item.mission.shortTitle || item.mission.title,
|
||||
stageId: item.module.id,
|
||||
stageTitle: item.module.title,
|
||||
stageDescription: item.module.description ?? item.mission.promise,
|
||||
role: item.module.role,
|
||||
serviceId: item.serviceId,
|
||||
}));
|
||||
if (item.serviceId) seenServices.add(item.serviceId);
|
||||
}
|
||||
|
||||
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({
|
||||
userId,
|
||||
date,
|
||||
index: tasks.length,
|
||||
missionId: item.mission.missionId,
|
||||
missionTitle: item.mission.shortTitle || item.mission.title,
|
||||
stageId: item.module.id,
|
||||
stageTitle: item.module.title,
|
||||
stageDescription: item.module.description ?? item.mission.promise,
|
||||
role: item.module.role,
|
||||
serviceId: item.serviceId,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const enriched: CuratorTask[] = [];
|
||||
for (const task of tasks.slice(0, 3)) {
|
||||
const completed = task.status === "completed" || await taskCompletedByEvents(userId, task);
|
||||
enriched.push({ ...task, status: completed ? "completed" : task.status });
|
||||
}
|
||||
return enriched;
|
||||
}
|
||||
|
||||
export async function buildCuratorStreak(userId: string): Promise<CuratorStreak> {
|
||||
const rows = await db
|
||||
.select({
|
||||
day: sql<string>`to_char(${growEvents.occurredAt}, 'YYYY-MM-DD')`,
|
||||
})
|
||||
.from(growEvents)
|
||||
.where(and(eq(growEvents.userId, userId), inArray(growEvents.type, VALID_COMPLETION_TYPES)))
|
||||
.groupBy(sql`to_char(${growEvents.occurredAt}, 'YYYY-MM-DD')`)
|
||||
.orderBy(sql`to_char(${growEvents.occurredAt}, 'YYYY-MM-DD') desc`)
|
||||
.limit(90);
|
||||
|
||||
const days = rows.map((row) => row.day);
|
||||
let current = 0;
|
||||
let cursor = todayIso();
|
||||
while (days.includes(cursor)) {
|
||||
current += 1;
|
||||
cursor = addDaysIso(cursor, -1);
|
||||
}
|
||||
|
||||
let longest = 0;
|
||||
let run = 0;
|
||||
let previous: string | null = null;
|
||||
for (const day of [...days].reverse()) {
|
||||
if (previous && addDaysIso(previous, 1) !== day) run = 0;
|
||||
run += 1;
|
||||
longest = Math.max(longest, run);
|
||||
previous = day;
|
||||
}
|
||||
|
||||
return { current, longest: Math.max(longest, current), lastCompletedDate: days[0] ?? null };
|
||||
}
|
||||
|
||||
export async function buildCuratorPlan(userId: string, input: { startDate: string; endDate: string; goals?: string[] }): Promise<CuratorPlan> {
|
||||
const days: CuratorPlan["days"] = [];
|
||||
const start = new Date(`${input.startDate}T00:00:00.000Z`);
|
||||
const end = new Date(`${input.endDate}T00:00:00.000Z`);
|
||||
const totalDays = Math.max(1, Math.min(30, Math.round((end.getTime() - start.getTime()) / 86400000) + 1));
|
||||
for (let index = 0; index < totalDays; index += 1) {
|
||||
const date = addDaysIso(input.startDate, index);
|
||||
days.push({
|
||||
date,
|
||||
dayIndex: index + 1,
|
||||
theme: index === 0 ? "Start with the highest-leverage service action" : "Keep streak and service momentum",
|
||||
tasks: await buildCuratorTasks(userId, date),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: `curator-plan:${userId}:${input.startDate}:${input.endDate}`,
|
||||
userId,
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
goals: input.goals ?? [],
|
||||
generatedAt: new Date().toISOString(),
|
||||
days,
|
||||
streak: await buildCuratorStreak(userId),
|
||||
source: "curator-v1",
|
||||
};
|
||||
}
|
||||
|
||||
export async function listCuratorRegistryCapabilities() {
|
||||
return {
|
||||
missions: listMissionDefinitions().map((mission) => ({
|
||||
id: mission.missionId,
|
||||
title: mission.title,
|
||||
modules: mission.modules.map((module) => ({ id: module.id, title: module.title, service: module.service, role: module.role })),
|
||||
})),
|
||||
services: listServiceCapabilities(),
|
||||
};
|
||||
}
|
||||
|
||||
export function todayIsoDate() {
|
||||
return todayIso();
|
||||
}
|
||||
396
src/v1/curator/curator-tools.ts
Normal file
396
src/v1/curator/curator-tools.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
import { eq, desc, and, inArray } from "drizzle-orm";
|
||||
import { db } from "../../db/client.js";
|
||||
import { growEvents, growQscoreLatest, growQscoreProjectionState } from "../../db/schema.js";
|
||||
import { interviewService, resumeService, roleplayService } from "../../services/product-service-clients.js";
|
||||
import { createMissionAction, listMissionActions } from "../../missions/actions.js";
|
||||
import { listActiveMissionsPg, listMessagesPg } from "../../grow/persistence.js";
|
||||
import { buildCuratorStreak, buildCuratorTasks, listCuratorRegistryCapabilities } from "./curator-store.js";
|
||||
import { actionLabel, serviceRoute } from "./curator-service-links.js";
|
||||
import { curatorServiceIdSchema, type CuratorServiceHandoff, type CuratorTask } from "./curator-types.js";
|
||||
import { emitCuratorEvent } from "./curator-events.js";
|
||||
|
||||
async function findTask(userId: string, taskId: string, date: string) {
|
||||
const tasks = await buildCuratorTasks(userId, date);
|
||||
return tasks.find((task) => task.id === taskId) ?? null;
|
||||
}
|
||||
|
||||
export async function prepareHandoffForTask(userId: string, task: CuratorTask, serviceId = task.serviceId): Promise<CuratorServiceHandoff> {
|
||||
if (!serviceId) throw new Error("Task has no service handoff.");
|
||||
const route = serviceRoute({
|
||||
serviceId,
|
||||
missionId: task.missionId,
|
||||
missionInstanceId: task.missionInstanceId,
|
||||
stageId: task.stageId,
|
||||
taskId: task.id,
|
||||
});
|
||||
|
||||
let actionId: string | undefined;
|
||||
if (task.missionInstanceId) {
|
||||
const action = await createMissionAction({
|
||||
userId,
|
||||
missionInstanceId: task.missionInstanceId,
|
||||
missionId: task.missionId,
|
||||
stageId: task.stageId,
|
||||
agentId: "curator-v1",
|
||||
agentName: "V1 Curator Actor",
|
||||
baseAgent: "Curator Agent",
|
||||
serviceId,
|
||||
toolName: task.toolName,
|
||||
mode: "suggestion",
|
||||
status: "queued",
|
||||
title: task.title,
|
||||
body: task.subtitle,
|
||||
prompt: `Prepare ${task.serviceName} handoff for ${task.title}.`,
|
||||
payload: { href: route, route, taskId: task.id, source: "curator-v1" },
|
||||
idempotencyKey: `curator-v1:${task.id}:${serviceId}`,
|
||||
priority: 50,
|
||||
urgency: "today",
|
||||
});
|
||||
actionId = action?.id;
|
||||
}
|
||||
|
||||
await emitCuratorEvent({
|
||||
userId,
|
||||
type: "curator.service_handoff.opened",
|
||||
mission: { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId },
|
||||
payload: { taskId: task.id, serviceId, route, actionId },
|
||||
});
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
serviceId,
|
||||
route,
|
||||
actionId,
|
||||
actionRoute: route,
|
||||
actionLabel: actionLabel(task),
|
||||
status: "prepared",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCuratorTools(ctx: { userId: string; date: string; conversationId?: string; taskId?: string }) {
|
||||
return {
|
||||
get_onboarding_context: tool({
|
||||
description: "Read available onboarding and profile context from recent platform events.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
const events = await db.select().from(growEvents)
|
||||
.where(and(eq(growEvents.userId, ctx.userId), eq(growEvents.category, "usage")))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(20);
|
||||
return { events };
|
||||
},
|
||||
}),
|
||||
|
||||
get_user_goals: tool({
|
||||
description: "Infer currently known goals from active missions and mission goals.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
const active = await listActiveMissionsPg(ctx.userId);
|
||||
return { goals: active.map((item) => ({ missionId: item.mission.missionId, title: item.mission.title, goal: item.mission.goal })) };
|
||||
},
|
||||
}),
|
||||
|
||||
get_curator_plan: tool({
|
||||
description: "Read today's curator tasks and streak state.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => ({ date: ctx.date, tasks: await buildCuratorTasks(ctx.userId, ctx.date), streak: await buildCuratorStreak(ctx.userId) }),
|
||||
}),
|
||||
|
||||
get_today_tasks: tool({
|
||||
description: "List today's V1 curator tasks.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => ({ tasks: await buildCuratorTasks(ctx.userId, ctx.date) }),
|
||||
}),
|
||||
|
||||
get_curator_streak: tool({
|
||||
description: "Read the user's curator streak from allowed completion events.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => ({ streak: await buildCuratorStreak(ctx.userId) }),
|
||||
}),
|
||||
|
||||
read_curator_memory: tool({
|
||||
description: "Read recent curator memory from existing grow events.",
|
||||
inputSchema: z.object({ limit: z.number().int().min(1).max(50).default(10) }),
|
||||
execute: async ({ limit }) => {
|
||||
const events = await db.select().from(growEvents)
|
||||
.where(and(eq(growEvents.userId, ctx.userId), eq(growEvents.source, "curator-v1")))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(limit);
|
||||
return { events };
|
||||
},
|
||||
}),
|
||||
|
||||
write_curator_memory: tool({
|
||||
description: "Write a durable curator memory event. Use this for chat extracts and status updates.",
|
||||
inputSchema: z.object({ summary: z.string(), tags: z.array(z.string()).default([]) }),
|
||||
execute: async ({ summary, tags }) => emitCuratorEvent({ userId: ctx.userId, type: "curator.memory.updated", payload: { summary, tags } }),
|
||||
}),
|
||||
|
||||
read_conversation_context: tool({
|
||||
description: "Read the current conversation history from existing conversation storage.",
|
||||
inputSchema: z.object({ conversationId: z.string().optional() }),
|
||||
execute: async ({ conversationId }) => ({ messages: conversationId || ctx.conversationId ? await listMessagesPg(ctx.userId, conversationId ?? ctx.conversationId!) : [] }),
|
||||
}),
|
||||
|
||||
list_service_capabilities: tool({
|
||||
description: "List deterministic service capabilities from existing service registries.",
|
||||
inputSchema: z.object({}),
|
||||
execute: listCuratorRegistryCapabilities,
|
||||
}),
|
||||
|
||||
list_available_service_routes: tool({
|
||||
description: "Return known handoff routes for registered services.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = taskId ? await findTask(ctx.userId, taskId, ctx.date) : null;
|
||||
return {
|
||||
routes: ["interview-service", "resume-service", "roleplay-service", "qscore-service"].map((serviceId) => ({
|
||||
serviceId,
|
||||
route: serviceRoute({ serviceId: serviceId as any, missionId: task?.missionId, missionInstanceId: task?.missionInstanceId, stageId: task?.stageId, taskId: task?.id }),
|
||||
})),
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
validate_service_handoff: tool({
|
||||
description: "Validate whether a requested service handoff exists in the registry.",
|
||||
inputSchema: z.object({ serviceId: curatorServiceIdSchema }),
|
||||
execute: async ({ serviceId }) => {
|
||||
const capabilities = await listCuratorRegistryCapabilities();
|
||||
return { valid: capabilities.services.some((service) => service.id === serviceId), serviceId };
|
||||
},
|
||||
}),
|
||||
|
||||
map_task_to_service: tool({
|
||||
description: "Map a curator task to its service capability and handoff route.",
|
||||
inputSchema: z.object({ taskId: z.string() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId, ctx.date);
|
||||
return { task, route: task ? serviceRoute({ serviceId: task.serviceId, missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId, taskId }) : null };
|
||||
},
|
||||
}),
|
||||
|
||||
prepare_interview_setup: tool({
|
||||
description: "Prepare an interview setup handoff. This creates a mission action and route, not the full interview workflow.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
return prepareHandoffForTask(ctx.userId, task, "interview-service");
|
||||
},
|
||||
}),
|
||||
|
||||
prepare_interview_preview: tool({
|
||||
description: "Prepare the interview preview route after setup context exists.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
return prepareHandoffForTask(ctx.userId, task, "interview-service");
|
||||
},
|
||||
}),
|
||||
|
||||
read_interview_report: tool({
|
||||
description: "Read available interview page/report state from the existing interview service.",
|
||||
inputSchema: z.object({ sessionId: z.string().optional() }),
|
||||
execute: async ({ sessionId }) => sessionId ? interviewService.review(sessionId) : interviewService.pageState(ctx.userId),
|
||||
}),
|
||||
|
||||
list_interview_sessions: tool({
|
||||
description: "List interview service page state for the user.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => interviewService.pageState(ctx.userId),
|
||||
}),
|
||||
|
||||
get_interview_latest_status: tool({
|
||||
description: "Read the latest interview-related events.",
|
||||
inputSchema: z.object({ limit: z.number().int().min(1).max(20).default(5) }),
|
||||
execute: async ({ limit }) => db.select().from(growEvents).where(and(eq(growEvents.userId, ctx.userId), eq(growEvents.source, "interview"))).orderBy(desc(growEvents.occurredAt)).limit(limit),
|
||||
}),
|
||||
|
||||
prepare_resume_upload: tool({
|
||||
description: "Prepare resume upload or resume builder handoff.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
return prepareHandoffForTask(ctx.userId, task, "resume-service");
|
||||
},
|
||||
}),
|
||||
|
||||
extract_resume_context: tool({
|
||||
description: "Extract basic context from pasted resume text for curator reasoning.",
|
||||
inputSchema: z.object({ text: z.string().min(1) }),
|
||||
execute: async ({ text }) => ({
|
||||
length: text.length,
|
||||
hasExperience: /experience|work|employment/i.test(text),
|
||||
hasEducation: /education|degree|university|college/i.test(text),
|
||||
hasSkills: /skills|tools|technologies/i.test(text),
|
||||
preview: text.slice(0, 500),
|
||||
}),
|
||||
}),
|
||||
|
||||
read_resume_report: tool({
|
||||
description: "Read existing resume service state.",
|
||||
inputSchema: z.object({ resumeId: z.string().optional() }),
|
||||
execute: async ({ resumeId }) => resumeId ? resumeService.getResume(resumeId) : resumeService.state(ctx.userId),
|
||||
}),
|
||||
|
||||
prepare_resume_rewrite: tool({
|
||||
description: "Prepare a resume rewrite handoff.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
return prepareHandoffForTask(ctx.userId, task, "resume-service");
|
||||
},
|
||||
}),
|
||||
|
||||
prepare_resume_talking_points: tool({
|
||||
description: "Prepare resume talking point handoff.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
return prepareHandoffForTask(ctx.userId, task, "resume-service");
|
||||
},
|
||||
}),
|
||||
|
||||
prepare_resume_gap_scan: tool({
|
||||
description: "Prepare resume gap scan handoff.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
return prepareHandoffForTask(ctx.userId, task, "resume-service");
|
||||
},
|
||||
}),
|
||||
|
||||
prepare_roleplay_setup: tool({
|
||||
description: "Prepare roleplay setup handoff.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
return prepareHandoffForTask(ctx.userId, task, "roleplay-service");
|
||||
},
|
||||
}),
|
||||
|
||||
prepare_roleplay_preview: tool({
|
||||
description: "Prepare roleplay preview handoff.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
return prepareHandoffForTask(ctx.userId, task, "roleplay-service");
|
||||
},
|
||||
}),
|
||||
|
||||
suggest_roleplay_scenario: tool({
|
||||
description: "Suggest a roleplay scenario from current task context.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
return { scenario: task?.title ?? "Practice a high-stakes workplace conversation", outcome: "Clear next step and confident response" };
|
||||
},
|
||||
}),
|
||||
|
||||
read_roleplay_report: tool({
|
||||
description: "Read roleplay service report or page state.",
|
||||
inputSchema: z.object({ sessionId: z.string().optional() }),
|
||||
execute: async ({ sessionId }) => sessionId ? roleplayService.review(sessionId) : roleplayService.pageState(ctx.userId),
|
||||
}),
|
||||
|
||||
list_roleplay_sessions: tool({
|
||||
description: "List roleplay service page state for the user.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => roleplayService.pageState(ctx.userId),
|
||||
}),
|
||||
|
||||
read_qscore_state: tool({
|
||||
description: "Read current Q-score projection state.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => db.select().from(growQscoreProjectionState).where(eq(growQscoreProjectionState.userId, ctx.userId)).limit(1),
|
||||
}),
|
||||
|
||||
read_qscore_signals: tool({
|
||||
description: "Read latest Q-score signals.",
|
||||
inputSchema: z.object({ limit: z.number().int().min(1).max(50).default(20) }),
|
||||
execute: async ({ limit }) => db.select().from(growQscoreLatest).where(eq(growQscoreLatest.userId, ctx.userId)).orderBy(desc(growQscoreLatest.updatedAt)).limit(limit),
|
||||
}),
|
||||
|
||||
explain_qscore_movement: tool({
|
||||
description: "Explain recent Q-score movement from available signals.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => ({ state: await db.select().from(growQscoreProjectionState).where(eq(growQscoreProjectionState.userId, ctx.userId)).limit(1), signals: await db.select().from(growQscoreLatest).where(eq(growQscoreLatest.userId, ctx.userId)).orderBy(desc(growQscoreLatest.updatedAt)).limit(10) }),
|
||||
}),
|
||||
|
||||
map_task_to_qscore_signals: tool({
|
||||
description: "Map a curator task to the Q-score signals it can affect.",
|
||||
inputSchema: z.object({ taskId: z.string() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId, ctx.date);
|
||||
return { taskId, signals: task?.signals ?? [] };
|
||||
},
|
||||
}),
|
||||
|
||||
prepare_qscore_review: tool({
|
||||
description: "Prepare a Q-score review handoff.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
return prepareHandoffForTask(ctx.userId, task, "qscore-service");
|
||||
},
|
||||
}),
|
||||
|
||||
emit_curator_event: tool({
|
||||
description: "Emit a curator event through existing Grow event ingestion.",
|
||||
inputSchema: z.object({ type: z.string(), payload: z.record(z.string(), z.unknown()).default({}) }),
|
||||
execute: async ({ type, payload }) => emitCuratorEvent({ userId: ctx.userId, type, payload }),
|
||||
}),
|
||||
|
||||
read_recent_grow_events: tool({
|
||||
description: "Read recent Grow events for this user.",
|
||||
inputSchema: z.object({ limit: z.number().int().min(1).max(50).default(20) }),
|
||||
execute: async ({ limit }) => db.select().from(growEvents).where(eq(growEvents.userId, ctx.userId)).orderBy(desc(growEvents.occurredAt)).limit(limit),
|
||||
}),
|
||||
|
||||
find_matching_service_event: tool({
|
||||
description: "Find service events that can complete a task.",
|
||||
inputSchema: z.object({ taskId: z.string() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId, ctx.date);
|
||||
if (!task) return { task: null, events: [] };
|
||||
const events = await db.select().from(growEvents).where(and(eq(growEvents.userId, ctx.userId), inArray(growEvents.type as any, task.completionEvents))).orderBy(desc(growEvents.occurredAt)).limit(20);
|
||||
return { task, events };
|
||||
},
|
||||
}),
|
||||
|
||||
complete_task_from_event: tool({
|
||||
description: "Complete a task only when a valid service or platform event exists.",
|
||||
inputSchema: z.object({ taskId: z.string(), eventId: z.string() }),
|
||||
execute: async ({ taskId, eventId }) => {
|
||||
const task = await findTask(ctx.userId, taskId, ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
const [event] = await db.select().from(growEvents).where(and(eq(growEvents.userId, ctx.userId), eq(growEvents.id, eventId))).limit(1);
|
||||
if (!event || !task.completionEvents.includes(event.type)) return { completed: false, reason: "event_not_allowed" };
|
||||
return emitCuratorEvent({ userId: ctx.userId, type: "curator.task.completed", mission: { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId }, payload: { taskId, sourceEventId: eventId } });
|
||||
},
|
||||
}),
|
||||
|
||||
update_streak_from_completion: tool({
|
||||
description: "Read streak after completion events.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => ({ streak: await buildCuratorStreak(ctx.userId) }),
|
||||
}),
|
||||
|
||||
list_mission_actions: tool({
|
||||
description: "List existing mission actions so the curator does not duplicate handoffs.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => listMissionActions(ctx.userId, { openOnly: false }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
113
src/v1/curator/curator-types.ts
Normal file
113
src/v1/curator/curator-types.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const curatorServiceIdSchema = z.enum([
|
||||
"interview-service",
|
||||
"resume-service",
|
||||
"roleplay-service",
|
||||
"qscore-service",
|
||||
"social-branding-service",
|
||||
"matchmaking-service",
|
||||
]);
|
||||
|
||||
export type CuratorServiceId = z.infer<typeof curatorServiceIdSchema>;
|
||||
|
||||
export const curatorTaskStatusSchema = z.enum([
|
||||
"ready",
|
||||
"started",
|
||||
"handoff_prepared",
|
||||
"completed",
|
||||
"blocked",
|
||||
]);
|
||||
|
||||
export const curatorTaskSchema = z.object({
|
||||
id: z.string(),
|
||||
date: z.string(),
|
||||
title: z.string(),
|
||||
subtitle: z.string(),
|
||||
missionId: z.string(),
|
||||
missionInstanceId: z.string().optional(),
|
||||
stageId: z.string().optional(),
|
||||
serviceId: curatorServiceIdSchema.optional(),
|
||||
serviceName: z.string(),
|
||||
actorName: z.string(),
|
||||
toolName: z.string(),
|
||||
status: curatorTaskStatusSchema,
|
||||
rewardCoins: z.number().int().min(0),
|
||||
qxImpact: z.string(),
|
||||
effort: z.string(),
|
||||
route: z.string(),
|
||||
cta: z.string(),
|
||||
context: z.array(z.object({ label: z.string(), value: z.string() })),
|
||||
signals: z.array(z.string()),
|
||||
completionEvents: z.array(z.string()),
|
||||
source: z.enum(["curator-v1", "mission-registry", "service-registry"]),
|
||||
});
|
||||
|
||||
export const curatorStreakSchema = z.object({
|
||||
current: z.number().int().min(0),
|
||||
longest: z.number().int().min(0),
|
||||
lastCompletedDate: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const curatorPlanSchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
goals: z.array(z.string()),
|
||||
generatedAt: z.string(),
|
||||
days: z.array(z.object({
|
||||
date: z.string(),
|
||||
dayIndex: z.number().int().min(1),
|
||||
theme: z.string(),
|
||||
tasks: z.array(curatorTaskSchema),
|
||||
})),
|
||||
streak: curatorStreakSchema,
|
||||
source: z.literal("curator-v1"),
|
||||
});
|
||||
|
||||
export const curatorImprovementSignalSchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
date: z.string(),
|
||||
priority: z.number().int().min(0).max(100),
|
||||
reason: z.string(),
|
||||
recommendedServiceId: curatorServiceIdSchema.optional(),
|
||||
recommendedMissionId: z.string().optional(),
|
||||
memoryPatch: z.string().optional(),
|
||||
nudgeText: z.string().optional(),
|
||||
status: z.enum(["created", "applied", "skipped"]).default("created"),
|
||||
});
|
||||
|
||||
export type CuratorTask = z.infer<typeof curatorTaskSchema>;
|
||||
export type CuratorPlan = z.infer<typeof curatorPlanSchema>;
|
||||
export type CuratorStreak = z.infer<typeof curatorStreakSchema>;
|
||||
export type CuratorImprovementSignal = z.infer<typeof curatorImprovementSignalSchema>;
|
||||
|
||||
export type CuratorTodayResponse = {
|
||||
date: string;
|
||||
plan: CuratorPlan;
|
||||
tasks: CuratorTask[];
|
||||
streak: CuratorStreak;
|
||||
completedCount: number;
|
||||
totalCount: number;
|
||||
source: "curator-v1";
|
||||
};
|
||||
|
||||
export type CuratorChatResponse = {
|
||||
conversationId: string;
|
||||
taskId?: string;
|
||||
reply: string;
|
||||
messages: Array<{ id: string; role: "user" | "assistant"; sender: string; content: string; createdAt: number }>;
|
||||
handoff?: CuratorServiceHandoff;
|
||||
};
|
||||
|
||||
export type CuratorServiceHandoff = {
|
||||
taskId: string;
|
||||
serviceId: CuratorServiceId;
|
||||
route: string;
|
||||
actionId?: string;
|
||||
actionRoute: string;
|
||||
actionLabel: string;
|
||||
status: "prepared";
|
||||
};
|
||||
10
src/v1/index.ts
Normal file
10
src/v1/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Hono } from "hono";
|
||||
import { v1CuratorRoutes } from "./curator/curator-routes.js";
|
||||
import { v1AnalyticsRoutes } from "./analytics/analytics-routes.js";
|
||||
|
||||
export function v1Routes() {
|
||||
const app = new Hono();
|
||||
app.route("/curator", v1CuratorRoutes());
|
||||
app.route("/analytics", v1AnalyticsRoutes());
|
||||
return app;
|
||||
}
|
||||
Reference in New Issue
Block a user