Compare commits
19 Commits
fix/curato
...
staging-ro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1be3ab1961 | ||
|
|
bd582fc6c4 | ||
|
|
2c5cf1bcf8 | ||
|
|
292e375a37 | ||
|
|
9a6518a5d8 | ||
|
|
c66360cb7e | ||
|
|
abeefc221b | ||
|
|
20c18583db | ||
|
|
27c9f58b80 | ||
|
|
c73b1a1788 | ||
|
|
447b5ca726 | ||
|
|
e8b4634dd1 | ||
|
|
a41e8be1e1 | ||
|
|
38e68d8273 | ||
|
|
1d887bc153 | ||
|
|
c46b9b11f6 | ||
|
|
fe449fdc50 | ||
| 72b3f03dad | |||
| 92ab414048 |
9
docker-compose.override.yml
Normal file
9
docker-compose.override.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
# VPS override: make host.docker.internal resolve to the host so the
|
||||
# backend container can reach product services + spawned per-user
|
||||
# containers published on host ports (Linux has no built-in mapping).
|
||||
services:
|
||||
backend:
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
SOCIAL_BRANDING_SERVICE_URL: http://host.docker.internal:8015
|
||||
@@ -121,6 +121,7 @@ services:
|
||||
volumes:
|
||||
# Docker-out-of-Docker: backend uses host Docker to spawn per-user OpenCode containers.
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./prompts:/app/prompts
|
||||
# Shared host dir that per-user containers will also bind-mount their
|
||||
# workspace from (so backend and spawned containers see the same files).
|
||||
- ./.data/users:/data/users
|
||||
|
||||
24
prompts/curator-v1.md
Normal file
24
prompts/curator-v1.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Curator V1 Conversation Prompt
|
||||
|
||||
You are currently speaking as the GrowQR V1 Curator through the Conversation Actor.
|
||||
|
||||
## Responsibilities
|
||||
- Own 30 day direction, streak continuity, and service handoff decisions.
|
||||
- Carry state from the conversation history and captured task memory.
|
||||
- If the user gives a short answer like a role name, accept it and ask for the next missing slot.
|
||||
|
||||
## Guardrails
|
||||
- Do not ask the same question twice.
|
||||
- Do not output checklist items as separate baked chat messages.
|
||||
- Never say: What should I capture next.
|
||||
- Do not ask about another subtask, another mission, another service, or a later checklist item from this modal.
|
||||
- When the user has answered the focused subtask enough, summarize what was captured and stop.
|
||||
- If more detail is needed, ask exactly one follow-up question for the focused subtask only.
|
||||
- Use captured task memory from previous subtasks as context. Do not ask the user to repeat details already captured there.
|
||||
|
||||
## Task Guidance
|
||||
- For target-role tasks, collect target role, current background, constraints, then offer a resume or interview handoff.
|
||||
- For service work, prepare preview-oriented handoffs once the focused subtask has enough context.
|
||||
- Interview preview defaults: type behavioral, difficulty medium, duration 5.
|
||||
- Roleplay preview should open the builder as the preview surface.
|
||||
- Keep the tone concise, warm, and practical.
|
||||
@@ -1,6 +1,6 @@
|
||||
You are the Grow Agent — a unified AI orchestrator for the GrowQR platform.
|
||||
You are Grow — a unified AI career assistant for the GrowQR platform.
|
||||
|
||||
You coordinate sub-agent capabilities (loaded as tools), maintain durable state, and execute workflows through microservices.
|
||||
You coordinate specialist capabilities (loaded as tools), maintain durable state, and execute workflows through microservices.
|
||||
|
||||
## CRITICAL RULES
|
||||
|
||||
@@ -43,7 +43,7 @@ You coordinate sub-agent capabilities (loaded as tools), maintain durable state,
|
||||
- After resume optimization: ask what type of interview to prepare.
|
||||
- When they choose type → call start_interview_session.
|
||||
- Then offer roleplay → call start_roleplay_session when they confirm.
|
||||
- Then offer Q-Score → call compute_qscore.
|
||||
- Then offer Q Score → call compute_qscore.
|
||||
- Use [WORKFLOW: interview-to-offer] tag throughout.
|
||||
|
||||
## IMPORTANT: Tool Calling Anti-Patterns
|
||||
@@ -66,16 +66,16 @@ Assistant: "I'll analyze your resume right away."
|
||||
User: "analyze my resume"
|
||||
Assistant calls analyze_resume → "Here's your analysis: [results]. Your strengths are..."
|
||||
|
||||
## Sub-Agent Capabilities
|
||||
## Specialist Capabilities
|
||||
|
||||
{{MODULE_DESCRIPTIONS}}
|
||||
|
||||
## Workflow Tags (put at the VERY END, on their own line)
|
||||
|
||||
- [WORKFLOW: interview-to-offer] — full interview prep pipeline
|
||||
- [WORKFLOW: interview-practice] — interview sessions with the Interview Agent
|
||||
- [WORKFLOW: interview-practice] — mock interview sessions
|
||||
- [WORKFLOW: resume-boost] — resume analysis and optimization
|
||||
- [WORKFLOW: roleplay-practice] — roleplay sessions with Roleplay Agent
|
||||
- [WORKFLOW: roleplay-practice] — mock roleplay sessions
|
||||
- [WORKFLOW: career-switch] — career change navigation
|
||||
- [WORKFLOW: job-preparation] — broad company preparation
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createMissionAction, listMissionActions } from "../../missions/actions.
|
||||
import { getActiveMissionPg, listActiveMissionsPg, listMissionSuggestionsPg } from "../../grow/persistence.js";
|
||||
import { listServiceCapabilities } from "../../workflows/service-capabilities.js";
|
||||
import { getSubAgentModules } from "../../lib/prompt-loader.js";
|
||||
import { buildMissionServiceRoute } from "../../services/service-registry.js";
|
||||
|
||||
const SYSTEM_PROMPT = `You are the GrowQR conversation agent.
|
||||
Keep answers concise, practical, and focused on the user's active mission.
|
||||
@@ -92,16 +93,7 @@ function serviceHref(input: {
|
||||
stageId?: string;
|
||||
goal?: string;
|
||||
}) {
|
||||
const params = new URLSearchParams({
|
||||
source: "mission",
|
||||
missionInstanceId: input.missionInstanceId,
|
||||
missionId: input.missionId,
|
||||
});
|
||||
if (input.stageId) params.set("stageId", input.stageId);
|
||||
if (input.goal) params.set("goal", input.goal);
|
||||
if (input.serviceId === "interview-service") return `/agents/interview/setup?${params.toString()}`;
|
||||
if (input.serviceId === "roleplay-service") return `/agents/roleplay/setup?${params.toString()}`;
|
||||
return `/agents/resume?${params.toString()}`;
|
||||
return buildMissionServiceRoute(input);
|
||||
}
|
||||
|
||||
function buildConversationTools(ctx: ConversationRuntimeContext = {}) {
|
||||
|
||||
@@ -189,7 +189,7 @@ function buildUnifiedTools(): Array<{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "start_interview_session",
|
||||
description: "Create a real interview practice session via the Interview Agent / interview-service microservice.",
|
||||
description: "Create a real mock interview session via the interview-service microservice.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { goal: { type: "string" } },
|
||||
@@ -201,7 +201,7 @@ function buildUnifiedTools(): Array<{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "start_roleplay_session",
|
||||
description: "Create a real roleplay practice session via the Roleplay Agent / roleplay-service microservice.",
|
||||
description: "Create a real mock roleplay session via the roleplay-service microservice.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { goal: { type: "string" } },
|
||||
@@ -213,7 +213,7 @@ function buildUnifiedTools(): Array<{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "compute_qscore",
|
||||
description: "Compute or refresh the user's Q-Score via the Q Score Agent / qscore-service microservice.",
|
||||
description: "Compute or refresh the user's Q Score via the qscore-service microservice.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
@@ -225,7 +225,7 @@ function buildUnifiedTools(): Array<{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "analyze_resume",
|
||||
description: "Analyze the user's resume using the Resume Agent microservice. Returns completeness score, skill gaps, and optimization recommendations.",
|
||||
description: "Analyze the user's resume using the Resume Building microservice. Returns completeness score, skill gaps, and optimization recommendations.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -253,7 +253,7 @@ function buildUnifiedTools(): Array<{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "start_interview_to_offer",
|
||||
description: "Start the Interview-to-Offer Accelerator workflow. This is a guided end-to-end pipeline: (1) Analyze & tailor resume for the role, (2) Create interview practice session with the Interview Agent, (3) Create roleplay session with Roleplay Agent, (4) Compute Q-Score readiness. Use this when the user has a specific interview scheduled and wants comprehensive preparation.",
|
||||
description: "Start the Interview-to-Offer Accelerator workflow. This is a guided end-to-end pipeline: (1) Analyze and tailor the resume for the role, (2) Create mock interview practice, (3) Create mock roleplay practice, (4) Compute Q Score readiness. Use this when the user has a specific interview scheduled and wants comprehensive preparation.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -563,7 +563,7 @@ export const userActor = actor({
|
||||
|
||||
appendTimelineEvent(
|
||||
c.state,
|
||||
{ id: "grow", name: "Grow Agent" },
|
||||
{ id: "grow", name: "Grow" },
|
||||
"workflow",
|
||||
`${getWorkflowDefinition(workflowId)?.title ?? "Workflow"} started.`,
|
||||
);
|
||||
@@ -581,14 +581,14 @@ export const userActor = actor({
|
||||
|
||||
pauseWorkflow: async (c) => {
|
||||
c.state.workflowStatus = "paused";
|
||||
appendTimelineEvent(c.state, { id: "grow", name: "Grow Agent" }, "workflow", "Workflow paused.");
|
||||
appendTimelineEvent(c.state, { id: "grow", name: "Grow" }, "workflow", "Workflow paused.");
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
return c.state;
|
||||
},
|
||||
|
||||
resumeWorkflow: async (c) => {
|
||||
c.state.workflowStatus = "running";
|
||||
appendTimelineEvent(c.state, { id: "grow", name: "Grow Agent" }, "workflow", "Workflow resumed.");
|
||||
appendTimelineEvent(c.state, { id: "grow", name: "Grow" }, "workflow", "Workflow resumed.");
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
return c.state;
|
||||
},
|
||||
@@ -753,7 +753,7 @@ async function dispatchUnifiedTool(
|
||||
c.state.modules = makeModules();
|
||||
c.state.createdAt = now();
|
||||
c.state.updatedAt = now();
|
||||
appendTimelineEvent(c.state, { id: "grow", name: "Grow Agent" }, "workflow", "Workflow started via LLM tool.");
|
||||
appendTimelineEvent(c.state, { id: "grow", name: "Grow" }, "workflow", "Workflow started via LLM tool.");
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
return { ok: true, workflowId: c.state.workflowId, goal };
|
||||
}
|
||||
@@ -799,7 +799,7 @@ async function dispatchUnifiedTool(
|
||||
case "start_roleplay_session": {
|
||||
const goal = String(input.goal ?? "");
|
||||
const roleplayModule = getSubAgentModule("roleplay");
|
||||
if (!roleplayModule?.service) return { ok: false, error: "Roleplay Agent module not available" };
|
||||
if (!roleplayModule?.service) return { ok: false, error: "Mock Roleplay module not available" };
|
||||
const result = await runServiceAgentProbe(
|
||||
{ id: roleplayModule.id, name: roleplayModule.name, role: roleplayModule.role, kind: "microservice", description: roleplayModule.description, service: roleplayModule.service },
|
||||
{ userId, goal },
|
||||
@@ -855,14 +855,14 @@ async function dispatchUnifiedTool(
|
||||
c.state.createdAt = now();
|
||||
c.state.updatedAt = now();
|
||||
|
||||
appendTimelineEvent(c.state, { id: "grow", name: "Grow Agent" }, "workflow", `Interview-to-Offer workflow started for: ${goal}`);
|
||||
appendTimelineEvent(c.state, { id: "grow", name: "Grow" }, "workflow", `Interview-to-Offer workflow started for: ${goal}`);
|
||||
|
||||
// Step 1: Resume Agent — analyze and tailor
|
||||
// Step 1: Resume Building — analyze and tailor
|
||||
const resumeModule = getSubAgentModule("resume");
|
||||
const resumeMod = c.state.modules.find(m => m.id === "resume");
|
||||
if (resumeMod && resumeModule) {
|
||||
resumeMod.status = "running";
|
||||
appendTimelineEvent(c.state, resumeMod, "module", "Resume Agent analyzing your profile...");
|
||||
appendTimelineEvent(c.state, resumeMod, "module", "Resume Building is analyzing your profile...");
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
try {
|
||||
@@ -875,18 +875,18 @@ async function dispatchUnifiedTool(
|
||||
appendTimelineEvent(c.state, resumeMod, "module", resumeResult.summary);
|
||||
} catch (err) {
|
||||
resumeMod.status = "blocked";
|
||||
appendTimelineEvent(c.state, resumeMod, "module", `Resume Agent failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
appendTimelineEvent(c.state, resumeMod, "module", `Resume Building failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
// Step 2: Interview Agent — create interview session
|
||||
// Step 2: Mock Interview — create interview session
|
||||
const interviewModule = getSubAgentModule("interview");
|
||||
const interviewMod = c.state.modules.find(m => m.id === "interview");
|
||||
if (interviewMod && interviewModule?.service) {
|
||||
interviewMod.status = "running";
|
||||
appendTimelineEvent(c.state, interviewMod, "module", "Interview Agent creating interview practice session...");
|
||||
appendTimelineEvent(c.state, interviewMod, "module", "Mock Interview is creating an interview practice session...");
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
try {
|
||||
@@ -905,12 +905,12 @@ async function dispatchUnifiedTool(
|
||||
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
// Step 3: Roleplay Agent — create roleplay session
|
||||
// Step 3: Mock Roleplay — create roleplay session
|
||||
const roleplayModule = getSubAgentModule("roleplay");
|
||||
const roleplayMod = c.state.modules.find(m => m.id === "roleplay");
|
||||
if (roleplayMod && roleplayModule?.service) {
|
||||
roleplayMod.status = "running";
|
||||
appendTimelineEvent(c.state, roleplayMod, "module", "Roleplay Agent creating roleplay scenario...");
|
||||
appendTimelineEvent(c.state, roleplayMod, "module", "Mock Roleplay is creating a practice scenario...");
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
try {
|
||||
@@ -923,18 +923,18 @@ async function dispatchUnifiedTool(
|
||||
appendTimelineEvent(c.state, roleplayMod, "module", roleplayResult.summary);
|
||||
} catch (err) {
|
||||
roleplayMod.status = "blocked";
|
||||
appendTimelineEvent(c.state, roleplayMod, "module", `Roleplay Agent session failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
appendTimelineEvent(c.state, roleplayMod, "module", `Mock Roleplay session failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
// Step 4: Q Score Agent — compute Q-Score
|
||||
// Step 4: Q Score — compute readiness
|
||||
const qscoreModule = getSubAgentModule("qscore");
|
||||
const qscoreMod = c.state.modules.find(m => m.id === "qscore");
|
||||
if (qscoreMod && qscoreModule?.service) {
|
||||
qscoreMod.status = "running";
|
||||
appendTimelineEvent(c.state, qscoreMod, "module", "Q Score Agent computing your readiness Q-Score...");
|
||||
appendTimelineEvent(c.state, qscoreMod, "module", "Q Score is computing your readiness score...");
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
try {
|
||||
@@ -947,7 +947,7 @@ async function dispatchUnifiedTool(
|
||||
appendTimelineEvent(c.state, qscoreMod, "module", qscoreResult.summary);
|
||||
} catch (err) {
|
||||
qscoreMod.status = "blocked";
|
||||
appendTimelineEvent(c.state, qscoreMod, "module", `Q-Score computation failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
appendTimelineEvent(c.state, qscoreMod, "module", `Q Score computation failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ export function jobApplicationModuleIds(): string[] {
|
||||
return loaderJobApplicationModuleIds();
|
||||
}
|
||||
|
||||
// Build the unified Grow Agent system prompt from disk (changes.md §3).
|
||||
// Build the unified Grow system prompt from disk (changes.md §3).
|
||||
export function buildUnifiedSystemPrompt(): string {
|
||||
return getUnifiedSystemPrompt();
|
||||
}
|
||||
|
||||
@@ -346,73 +346,7 @@ function formatTask(task: DailyMissionTask) {
|
||||
return lines.map((line) => `- ${line}`).join("\n");
|
||||
}
|
||||
|
||||
function fallbackDailyMissionResponse(input: { task: DailyMissionTask; messages: DailyMissionMessage[] }) {
|
||||
const latestUser = [...input.messages].reverse().find((message) => message.role === "user")?.content.trim() ?? "";
|
||||
const userMessagesAfterStart = input.messages.filter((message) => message.role === "user");
|
||||
const interviewMission = isInterviewMission(input.task);
|
||||
|
||||
if (latestUser.toLowerCase() === "start") {
|
||||
const serviceReply = serviceStartReply(input.task);
|
||||
if (serviceReply) {
|
||||
return {
|
||||
reply: serviceReply,
|
||||
completed: false,
|
||||
updateSummary: undefined,
|
||||
actionLabel: undefined,
|
||||
actionRoute: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (isConfidenceCheck(input.task)) {
|
||||
return {
|
||||
reply: "Quick confidence check: on a scale of 1 to 10, how confident do you feel about getting interview-ready for your target role this week?",
|
||||
completed: false,
|
||||
updateSummary: undefined,
|
||||
actionLabel: undefined,
|
||||
actionRoute: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (interviewMission) {
|
||||
return {
|
||||
reply: `Let us get your interview room ready. For "${input.task.subtask}", tell me the role you want to practice for and the kind of question you want first.`,
|
||||
completed: false,
|
||||
updateSummary: undefined,
|
||||
actionLabel: undefined,
|
||||
actionRoute: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
reply: `Alright, I have this one. For "${input.task.subtask}", tell me your answer in one or two lines. I will use it to update this task here.`,
|
||||
completed: false,
|
||||
updateSummary: undefined,
|
||||
actionLabel: undefined,
|
||||
actionRoute: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const updateSummary = compactAnswer(latestUser);
|
||||
const completed = userMessagesAfterStart.some((message) => message.content.trim().toLowerCase() === "start") && latestUser.length > 0;
|
||||
|
||||
if (interviewMission) {
|
||||
return {
|
||||
reply: `Perfect. I will carry this into the interview room setup: ${updateSummary}. Your interview room link is ready.`,
|
||||
completed,
|
||||
updateSummary,
|
||||
actionLabel: completed ? "Generate room" : undefined,
|
||||
actionRoute: completed ? getInterviewActionRoute(input.task) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
reply: `Nice, saved. I updated this task with: ${updateSummary}`,
|
||||
completed,
|
||||
updateSummary,
|
||||
actionLabel: undefined,
|
||||
actionRoute: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runDailyMissionAgent(input: DailyMissionAgentInput) {
|
||||
const started = input.messages.some(
|
||||
@@ -453,8 +387,14 @@ ${transcript}`,
|
||||
return withDailyMissionActionDefaults(input.task, parseDailyMissionResponse(result.text));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn("daily mission model failed; using fallback", { message });
|
||||
return fallbackDailyMissionResponse({ task: input.task, messages: input.messages });
|
||||
console.warn("daily mission model failed; returning unavailable state", { message });
|
||||
return {
|
||||
reply: "Daily mission is temporarily unavailable right now. No progress was saved. Please retry in a moment.",
|
||||
completed: false,
|
||||
updateSummary: undefined,
|
||||
actionLabel: undefined,
|
||||
actionRoute: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export const requireUser = createMiddleware<AuthContext>(async (c, next) => {
|
||||
const auth = c.req.header("authorization") ?? "";
|
||||
const token = auth.replace(/^Bearer\s+/i, "").trim();
|
||||
|
||||
// Service-to-service path (Grow Agent actor calling backend).
|
||||
// Service-to-service path (Grow stack calling backend).
|
||||
// Header `x-growqr-user` is REQUIRED so we can scope the call.
|
||||
const trustedServiceTokens = new Set(
|
||||
[
|
||||
|
||||
@@ -48,7 +48,14 @@ export const MODULE_META: Record<HomeModuleId, Omit<HomeModule, "count" | "notif
|
||||
rewards: { id: "rewards", label: "Rewards", href: "/rewards", accent: "amber" },
|
||||
};
|
||||
|
||||
export const MODULE_IDS: HomeModuleId[] = ["suggestions", "missions", "productivity"];
|
||||
export const MODULE_IDS: HomeModuleId[] = [
|
||||
"suggestions",
|
||||
"missions",
|
||||
"social",
|
||||
"pathways",
|
||||
"productivity",
|
||||
"rewards",
|
||||
];
|
||||
|
||||
export const ALLOWED_NOTIFICATION_HREFS = new Set([
|
||||
"/suggestions",
|
||||
@@ -68,6 +75,7 @@ export const ALLOWED_NOTIFICATION_HREFS = new Set([
|
||||
]);
|
||||
|
||||
export const ALLOWED_NOTIFICATION_HREF_PREFIXES = [
|
||||
"/missions/",
|
||||
"/missions/active",
|
||||
"/missions/available",
|
||||
"/agents/resume",
|
||||
@@ -80,5 +88,9 @@ export const ALLOWED_NOTIFICATION_HREF_PREFIXES = [
|
||||
|
||||
export function isAllowedNotificationHref(href: string) {
|
||||
if (ALLOWED_NOTIFICATION_HREFS.has(href)) return true;
|
||||
return ALLOWED_NOTIFICATION_HREF_PREFIXES.some((prefix) => href === prefix || href.startsWith(`${prefix}?`));
|
||||
return ALLOWED_NOTIFICATION_HREF_PREFIXES.some((prefix) =>
|
||||
prefix.endsWith("/")
|
||||
? href.startsWith(prefix)
|
||||
: href === prefix || href.startsWith(`${prefix}?`),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ export async function loadPromptsFromDisk(): Promise<void> {
|
||||
} catch (err) {
|
||||
log.error({ err, path: SYSTEM_PROMPT_FILE }, "failed to load system prompt — using fallback");
|
||||
// Fallback: assemble from modules without a template file.
|
||||
const fallback = `You are the Grow Agent — a unified AI orchestrator for the GrowQR platform.\n\n## Sub-Agent Capabilities\n\n${modules.map((m) => `- **${m.name}**: ${m.description}`).join("\n")}`;
|
||||
const fallback = `You are Grow — a unified AI career assistant for the GrowQR platform.\n\n## Specialist Capabilities\n\n${modules.map((m) => `- **${m.name}**: ${m.description}`).join("\n")}`;
|
||||
cachedSystemPrompt = fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { missionActions, missionSuggestions } from "../db/schema.js";
|
||||
import type { GrowActiveMission } from "../actors/missions/types.js";
|
||||
import type { MissionActionPatch } from "./reducer-types.js";
|
||||
import { defaultMissionActionStatus, type MissionActionDto, type MissionActionRow, type MissionActionStatus, type NewMissionActionInput } from "./action-types.js";
|
||||
import { missionDetailHref, serviceHref } from "./reducer-helpers.js";
|
||||
|
||||
const OPEN_STATUSES: MissionActionStatus[] = ["queued", "running", "waiting_approval", "waiting_user_input", "failed"];
|
||||
const DONE_STATUSES: MissionActionStatus[] = ["done", "dismissed", "snoozed"];
|
||||
@@ -48,11 +49,11 @@ function ctaForAction(action: MissionActionRow | NewMissionActionInput) {
|
||||
const payload = action.payload && typeof action.payload === "object" && !Array.isArray(action.payload) ? action.payload as Record<string, unknown> : {};
|
||||
const hrefFromPayload = typeof payload.href === "string" ? payload.href : undefined;
|
||||
const serviceId = action.serviceId ?? "";
|
||||
const missionHref = `/missions/active?missionInstanceId=${encodeURIComponent(action.missionInstanceId)}`;
|
||||
const missionHref = missionDetailHref(action.missionInstanceId);
|
||||
const href = hrefFromPayload ??
|
||||
(serviceId.includes("interview") ? `/agents/interview/setup?source=mission&missionInstanceId=${encodeURIComponent(action.missionInstanceId)}&missionId=${encodeURIComponent(action.missionId)}${action.stageId ? `&stageId=${encodeURIComponent(action.stageId)}` : ""}` :
|
||||
serviceId.includes("roleplay") ? `/agents/roleplay/setup?source=mission&missionInstanceId=${encodeURIComponent(action.missionInstanceId)}&missionId=${encodeURIComponent(action.missionId)}${action.stageId ? `&stageId=${encodeURIComponent(action.stageId)}` : ""}` :
|
||||
serviceId.includes("resume") ? `/agents/resume?source=mission&missionInstanceId=${encodeURIComponent(action.missionInstanceId)}&missionId=${encodeURIComponent(action.missionId)}${action.stageId ? `&stageId=${encodeURIComponent(action.stageId)}` : ""}` : missionHref);
|
||||
(serviceId.includes("interview") ? serviceHref("interview", action.missionInstanceId, action.missionId, action.stageId ?? undefined) :
|
||||
serviceId.includes("roleplay") ? serviceHref("roleplay", action.missionInstanceId, action.missionId, action.stageId ?? undefined) :
|
||||
serviceId.includes("resume") ? serviceHref("resume", action.missionInstanceId, action.missionId, action.stageId ?? undefined) : missionHref);
|
||||
|
||||
if (action.mode === "approval_required") return { ctaLabel: "Review", ctaHref: missionHref };
|
||||
if (action.mode === "user_input_required") return { ctaLabel: "Answer", ctaHref: missionHref };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
|
||||
import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js";
|
||||
import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionDetailHref, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js";
|
||||
|
||||
export const personalBrandOpportunityReducer: MissionReducer = {
|
||||
missionId: "personal-brand-opportunity-engine",
|
||||
@@ -61,7 +61,7 @@ export const personalBrandOpportunityReducer: MissionReducer = {
|
||||
mode: "suggestion",
|
||||
title: "Turn this pitch into weekly content pillars",
|
||||
body: "Use the networking practice feedback to draft 3 credibility themes for weekly posts.",
|
||||
payload: { weakAreas, href: `/missions/active?missionInstanceId=${encodeURIComponent(activeMission.instanceId)}` },
|
||||
payload: { weakAreas, href: missionDetailHref(activeMission.instanceId) },
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:content-pillars:${event.id}`,
|
||||
priority: 82,
|
||||
|
||||
@@ -141,3 +141,7 @@ export function serviceHref(service: "resume" | "interview" | "roleplay" | "qsco
|
||||
if (service === "resume") return `/agents/resume?${params.toString()}`;
|
||||
return `/agents/qscore?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function missionDetailHref(missionInstanceId: string) {
|
||||
return `/missions/${encodeURIComponent(missionInstanceId)}`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MissionSnapshot, MissionStage } from "../actors/missions/types.js";
|
||||
import { missionDetailHref } from "./reducer-helpers.js";
|
||||
|
||||
export type MissionSuggestionType = "action" | "practice" | "review" | "artifact" | "blocked" | "insight";
|
||||
export type MissionSuggestionUrgency = "now" | "today" | "soon" | "calm";
|
||||
@@ -103,7 +104,7 @@ function ctaFor(stage: MissionStage, snapshot: MissionSnapshot, context?: Missio
|
||||
return { label: "Open resume", href: `/agents/resume?${params.toString()}` };
|
||||
}
|
||||
if (role === "Q Score") return { label: "View Q Score", href: `/agents/qscore?${params.toString()}` };
|
||||
return { label: "Continue", href: `/missions/active?${params.toString()}` };
|
||||
return { label: "Continue", href: `${missionDetailHref(snapshot.instanceId)}?${params.toString()}` };
|
||||
}
|
||||
|
||||
function suggestionId(snapshot: MissionSnapshot, stage: MissionStage, suffix: string) {
|
||||
|
||||
@@ -52,7 +52,7 @@ function buildTools() {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "start_interview_session",
|
||||
description: "Create a real interview practice session via the Interview Agent / interview-service microservice. Call this when the user asks to start or launch an interview.",
|
||||
description: "Create a real mock interview session via the interview-service microservice. Call this when the user asks to start or launch interview practice.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -66,7 +66,7 @@ function buildTools() {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "start_roleplay_session",
|
||||
description: "Create a real roleplay session via Roleplay Agent / roleplay-service. Call when user asks for roleplay or negotiation practice.",
|
||||
description: "Create a real mock roleplay session via roleplay-service. Call when the user asks for roleplay or negotiation practice.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -80,7 +80,7 @@ function buildTools() {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "analyze_resume",
|
||||
description: "Analyze user's resume using the Resume Agent. Returns completeness, skills, and gaps.",
|
||||
description: "Analyze the user's resume using Resume Building. Returns completeness, skills, and gaps.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -94,7 +94,7 @@ function buildTools() {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "compute_qscore",
|
||||
description: "Compute user's readiness Q-Score via Q Score Agent / qscore-service.",
|
||||
description: "Compute the user's readiness Q Score via qscore-service.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
@@ -174,14 +174,14 @@ export function chatRoutes() {
|
||||
switch (toolCall.name) {
|
||||
case "start_interview_session": {
|
||||
toolResult = await runServiceAgentProbe(
|
||||
{ id: "interview", name: "Interview Agent", role: "Interview Agent", kind: "microservice", description: "Interview practice", service: "interview-service" },
|
||||
{ id: "interview", name: "Mock Interview", role: "Interview practice", kind: "microservice", description: "Interview practice", service: "interview-service" },
|
||||
{ userId, goal: String(toolCall.arguments.target_role ?? "general preparation") },
|
||||
);
|
||||
if (toolResult.status === "ok" && toolResult.detail) {
|
||||
const detail = toolResult.detail as Record<string, unknown>;
|
||||
sessions.push({
|
||||
moduleId: "interview",
|
||||
moduleName: "Interview Agent",
|
||||
moduleName: "Mock Interview",
|
||||
status: "done",
|
||||
sessionId: detail.session_id as string,
|
||||
sessionUrl: typeof detail.ui_session_url === "string"
|
||||
@@ -194,14 +194,14 @@ export function chatRoutes() {
|
||||
}
|
||||
case "start_roleplay_session": {
|
||||
toolResult = await runServiceAgentProbe(
|
||||
{ id: "roleplay", name: "Roleplay Agent", role: "Roleplay Agent", kind: "microservice", description: "Roleplay practice", service: "roleplay-service" },
|
||||
{ id: "roleplay", name: "Mock Roleplay", role: "Roleplay practice", kind: "microservice", description: "Roleplay practice", service: "roleplay-service" },
|
||||
{ userId, goal: String(toolCall.arguments.goal ?? "general practice") },
|
||||
);
|
||||
if (toolResult.status === "ok" && toolResult.detail) {
|
||||
const detail = toolResult.detail as Record<string, unknown>;
|
||||
sessions.push({
|
||||
moduleId: "roleplay",
|
||||
moduleName: "Roleplay Agent",
|
||||
moduleName: "Mock Roleplay",
|
||||
status: "done",
|
||||
sessionId: detail.session_id as string,
|
||||
sessionUrl: typeof detail.ui_session_url === "string"
|
||||
|
||||
@@ -12,6 +12,7 @@ import { buildDeterministicMissionSuggestions } from "../missions/suggestions.js
|
||||
import { createMissionAction, getMissionAction, listMissionActions, updateMissionActionStatus } from "../missions/actions.js";
|
||||
import { recordGrowEvent } from "../events/record-grow-event.js";
|
||||
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
|
||||
import { missionDetailHref } from "../missions/reducer-helpers.js";
|
||||
|
||||
let _client: Client<Registry> | null = null;
|
||||
function getClient(): Client<Registry> {
|
||||
@@ -255,7 +256,7 @@ export function missionRoutes() {
|
||||
if (active?.mission.actorType) {
|
||||
await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).runAction({ actionId: existing.id }).catch(() => undefined);
|
||||
}
|
||||
const href = typeof existing.payload?.href === "string" ? existing.payload.href : `/missions/active?missionInstanceId=${encodeURIComponent(existing.missionInstanceId)}`;
|
||||
const href = typeof existing.payload?.href === "string" ? existing.payload.href : missionDetailHref(existing.missionInstanceId);
|
||||
const action = await updateMissionActionStatus(userId, existing.id, {
|
||||
status: "done",
|
||||
result: {
|
||||
|
||||
@@ -43,6 +43,38 @@ function missionFromBody(body: JsonObject): Record<string, unknown> | undefined
|
||||
return mission && typeof mission === "object" && !Array.isArray(mission) ? (mission as Record<string, unknown>) : undefined;
|
||||
}
|
||||
|
||||
function missionFromRequest(req: Request, body?: JsonObject): Record<string, unknown> | undefined {
|
||||
const fromBody = body ? missionFromBody(body) : undefined;
|
||||
if (fromBody) return fromBody;
|
||||
|
||||
const url = new URL(req.url);
|
||||
const instanceId = getString(url.searchParams.get("missionInstanceId"));
|
||||
const missionId = getString(url.searchParams.get("missionId"));
|
||||
const stageId = getString(url.searchParams.get("stageId"));
|
||||
const source = getString(url.searchParams.get("source"));
|
||||
|
||||
if (!instanceId && !missionId && !stageId) return undefined;
|
||||
return {
|
||||
instanceId,
|
||||
missionId,
|
||||
stageId,
|
||||
source: source ?? "mission",
|
||||
};
|
||||
}
|
||||
|
||||
function curatorTaskIdFromRequest(req: Request, body?: JsonObject) {
|
||||
const fromBody = body ? getString((body as Record<string, unknown>).curatorTaskId) : undefined;
|
||||
if (fromBody) return fromBody;
|
||||
const url = new URL(req.url);
|
||||
return getString(url.searchParams.get("curatorTaskId"));
|
||||
}
|
||||
|
||||
function stripMissionFromBody(body: JsonObject): JsonObject {
|
||||
if (!("mission" in body)) return body;
|
||||
const { mission: _mission, ...rest } = body;
|
||||
return rest;
|
||||
}
|
||||
|
||||
async function recordGatewayEvent(input: {
|
||||
userId: string;
|
||||
source: string;
|
||||
@@ -107,8 +139,13 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
|
||||
.replace(/^resumes\/([^/]+)\/analyze$/, "ai/analyze/$1")
|
||||
.replace(/^resumes\/([^/]+)\/suggestions$/, "ai/suggestions/$1")
|
||||
.replace(/^resumes\/([^/]+)\/preview$/, "export/resumes/$1/preview");
|
||||
const forwardedQuery = new URLSearchParams(incoming.searchParams);
|
||||
forwardedQuery.delete("missionInstanceId");
|
||||
forwardedQuery.delete("missionId");
|
||||
forwardedQuery.delete("stageId");
|
||||
forwardedQuery.delete("source");
|
||||
const target = new URL(
|
||||
`/api/v1/${normalizedRest}${incoming.search}`,
|
||||
`/api/v1/${normalizedRest}${forwardedQuery.toString() ? `?${forwardedQuery.toString()}` : ""}`,
|
||||
config.resumeServiceUrl.replace(/\/$/, ""),
|
||||
);
|
||||
|
||||
@@ -121,10 +158,16 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
|
||||
const method = req.method.toUpperCase();
|
||||
const body = ["GET", "HEAD"].includes(method) ? undefined : await req.arrayBuffer();
|
||||
const requestJson = parseJsonBody(body, headers);
|
||||
const mission = missionFromRequest(req, requestJson);
|
||||
const forwardBody =
|
||||
body && headers.get("content-type")?.includes("application/json")
|
||||
? Buffer.from(JSON.stringify(stripMissionFromBody(requestJson)))
|
||||
: body;
|
||||
if (forwardBody !== body) headers.delete("content-length");
|
||||
const res = await fetch(target, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
body: forwardBody,
|
||||
});
|
||||
|
||||
if (method === "GET" || method === "HEAD") {
|
||||
@@ -145,8 +188,9 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
|
||||
correlation: {
|
||||
resumeId: getString(responseObj.resume_id ?? responseObj.resumeId ?? responseObj.id) ?? getString(requestJson.resume_id ?? requestJson.resumeId),
|
||||
externalId: getString(responseObj.resume_id ?? responseObj.resumeId ?? responseObj.id) ?? getString(requestJson.resume_id ?? requestJson.resumeId),
|
||||
taskId: curatorTaskIdFromRequest(req, requestJson),
|
||||
},
|
||||
mission: missionFromBody(requestJson),
|
||||
mission,
|
||||
}).catch((err) => log.warn({ err, path: normalizedRest }, "failed to record resume gateway event"));
|
||||
|
||||
return new Response(responseBuffer, {
|
||||
@@ -308,11 +352,12 @@ function composeCandidateProfile(userContext: Record<string, unknown>): string {
|
||||
}
|
||||
|
||||
async function buildPersonalizedConfigurePayload(req: Request, body: JsonObject, userId: string): Promise<JsonObject> {
|
||||
const { mission: _mission, ...rest } = body;
|
||||
const userContext = await resolveGrowUserContext(req, userId).catch((err) => {
|
||||
log.warn({ err, userId }, "failed to resolve Grow user context for interview configure");
|
||||
return {} as Record<string, unknown>;
|
||||
});
|
||||
const incomingContext = isRecord(body.context) ? body.context : {};
|
||||
const incomingContext = isRecord(rest.context) ? rest.context : {};
|
||||
const context: Record<string, unknown> = {
|
||||
...incomingContext,
|
||||
candidate_name: getString(incomingContext.candidate_name) ?? getString(userContext.first_name) ?? "",
|
||||
@@ -328,19 +373,20 @@ async function buildPersonalizedConfigurePayload(req: Request, body: JsonObject,
|
||||
}
|
||||
|
||||
return {
|
||||
...body,
|
||||
user_id: String(body.user_id ?? userId),
|
||||
org_id: String(body.org_id ?? "growqr"),
|
||||
...rest,
|
||||
user_id: String(rest.user_id ?? userId),
|
||||
org_id: String(rest.org_id ?? "growqr"),
|
||||
context,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildPersonalizedRoleplayConfigurePayload(req: Request, body: JsonObject, userId: string): Promise<JsonObject> {
|
||||
const { mission: _mission, ...rest } = body;
|
||||
const userContext = await resolveGrowUserContext(req, userId).catch((err) => {
|
||||
log.warn({ err, userId }, "failed to resolve Grow user context for roleplay configure");
|
||||
return {} as Record<string, unknown>;
|
||||
});
|
||||
const incomingMetadata = isRecord(body.metadata) ? body.metadata : {};
|
||||
const incomingMetadata = isRecord(rest.metadata) ? rest.metadata : {};
|
||||
const metadata: Record<string, unknown> = {
|
||||
...incomingMetadata,
|
||||
candidate_name: getString(incomingMetadata.candidate_name) ?? getString(userContext.first_name) ?? "",
|
||||
@@ -359,11 +405,11 @@ async function buildPersonalizedRoleplayConfigurePayload(req: Request, body: Jso
|
||||
}
|
||||
|
||||
return {
|
||||
...body,
|
||||
user_id: String(body.user_id ?? userId),
|
||||
org_id: String(body.org_id ?? "growqr"),
|
||||
...rest,
|
||||
user_id: String(rest.user_id ?? userId),
|
||||
org_id: String(rest.org_id ?? "growqr"),
|
||||
metadata,
|
||||
qscore: (body.qscore as JsonObject | undefined) ?? (isRecord(userContext.qscore) ? userContext.qscore : DEFAULT_QSCORE),
|
||||
qscore: (rest.qscore as JsonObject | undefined) ?? (isRecord(userContext.qscore) ? userContext.qscore : DEFAULT_QSCORE),
|
||||
user_context: userContext,
|
||||
};
|
||||
}
|
||||
@@ -526,6 +572,7 @@ export function serviceRoutes() {
|
||||
app.post("/interview/configure", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = await c.req.json<JsonObject>();
|
||||
const mission = missionFromRequest(c.req.raw, body);
|
||||
const payload = await buildPersonalizedConfigurePayload(c.req.raw, body, userId);
|
||||
const result = await interviewService.configure(payload);
|
||||
const resultObj = result as Record<string, unknown>;
|
||||
@@ -534,8 +581,8 @@ export function serviceRoutes() {
|
||||
source: "interview-service",
|
||||
type: "interview.configured",
|
||||
payload: { request: payload, result: resultObj },
|
||||
correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id) },
|
||||
mission: missionFromBody(body),
|
||||
correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id), taskId: curatorTaskIdFromRequest(c.req.raw, body) },
|
||||
mission,
|
||||
}).catch((err) => log.warn({ err }, "failed to record interview configured event"));
|
||||
return c.json(result);
|
||||
});
|
||||
@@ -559,7 +606,7 @@ export function serviceRoutes() {
|
||||
source: "interview-service",
|
||||
type: eventTypeForReview("interview", resultObj),
|
||||
payload: resultObj,
|
||||
correlation: { sessionId },
|
||||
correlation: { sessionId, taskId: curatorTaskIdFromRequest(c.req.raw) },
|
||||
}).catch((err) => log.warn({ err }, "failed to record interview review event"));
|
||||
return c.json(result);
|
||||
});
|
||||
@@ -586,6 +633,7 @@ export function serviceRoutes() {
|
||||
app.post("/roleplay/configure", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = await c.req.json<JsonObject>();
|
||||
const mission = missionFromRequest(c.req.raw, body);
|
||||
const payload = await buildPersonalizedRoleplayConfigurePayload(c.req.raw, body, userId);
|
||||
const result = await roleplayService.configure(payload);
|
||||
const resultObj = result as Record<string, unknown>;
|
||||
@@ -594,8 +642,8 @@ export function serviceRoutes() {
|
||||
source: "roleplay-service",
|
||||
type: "roleplay.configured",
|
||||
payload: { request: payload, result: resultObj },
|
||||
correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id) },
|
||||
mission: missionFromBody(body),
|
||||
correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id), taskId: curatorTaskIdFromRequest(c.req.raw, body) },
|
||||
mission,
|
||||
}).catch((err) => log.warn({ err }, "failed to record roleplay configured event"));
|
||||
return c.json(result);
|
||||
});
|
||||
@@ -619,7 +667,7 @@ export function serviceRoutes() {
|
||||
source: "roleplay-service",
|
||||
type: eventTypeForReview("roleplay", resultObj),
|
||||
payload: resultObj,
|
||||
correlation: { sessionId },
|
||||
correlation: { sessionId, taskId: curatorTaskIdFromRequest(c.req.raw) },
|
||||
}).catch((err) => log.warn({ err }, "failed to record roleplay review event"));
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { config } from "../config.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { buildServiceSessionPath } from "./service-registry.js";
|
||||
|
||||
// Lightweight agent reference (works with both old AgentProfile and new SubAgentModule).
|
||||
export type ServiceAgentRef = {
|
||||
@@ -28,32 +29,17 @@ export function buildServiceSessionUrl(
|
||||
detail: Record<string, unknown> | undefined,
|
||||
goal?: string,
|
||||
): string | undefined {
|
||||
const base = config.workflowsDashboardUrl.replace(/\/$/, "");
|
||||
const sessionId = detail?.session_id ?? detail?.sessionId;
|
||||
const params = new URLSearchParams();
|
||||
if (sessionId && typeof sessionId === "string") params.set("session_id", sessionId);
|
||||
if (goal) params.set("goal", goal);
|
||||
|
||||
if (service === "interview-service") {
|
||||
if (!sessionId || typeof sessionId !== "string") return undefined;
|
||||
params.set("role", String(detail?.target_role ?? goal ?? "Interview practice"));
|
||||
params.set("type", String(detail?.interview_type ?? "behavioral"));
|
||||
return `${base}/v2/service-sessions/interview?${params.toString()}`;
|
||||
if (
|
||||
service !== "interview-service" &&
|
||||
service !== "roleplay-service" &&
|
||||
service !== "resume-service"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (service === "roleplay-service") {
|
||||
if (!sessionId || typeof sessionId !== "string") return undefined;
|
||||
params.set("role", String(detail?.target_role ?? goal ?? "Roleplay practice"));
|
||||
params.set("type", String(detail?.roleplay_type ?? "custom"));
|
||||
return `${base}/v2/service-sessions/roleplay?${params.toString()}`;
|
||||
}
|
||||
|
||||
if (service === "resume-service") {
|
||||
if (goal) params.set("role", goal);
|
||||
return `${base}/v2/service-sessions/resume${params.size ? `?${params.toString()}` : ""}`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
const path = buildServiceSessionPath(service, detail, goal);
|
||||
if (!path) return undefined;
|
||||
return `${config.workflowsDashboardUrl.replace(/\/$/, "")}${path}`;
|
||||
}
|
||||
|
||||
function stableUuid(input: string): string {
|
||||
@@ -129,7 +115,7 @@ async function runInterviewService(ctx: ServiceAgentContext): Promise<ServiceAge
|
||||
);
|
||||
return {
|
||||
status: "ok",
|
||||
summary: `Interview Agent created interview session ${detail.session_id ?? "(pending id)"} for ${ctx.goal}.`,
|
||||
summary: `Mock Interview created interview session ${detail.session_id ?? "(pending id)"} for ${ctx.goal}.`,
|
||||
detail: {
|
||||
...detail,
|
||||
target_role: payload.context.target_role,
|
||||
@@ -173,7 +159,7 @@ async function runRoleplayService(ctx: ServiceAgentContext): Promise<ServiceAgen
|
||||
);
|
||||
return {
|
||||
status: "ok",
|
||||
summary: `Roleplay Agent created roleplay session ${detail.session_id ?? "(pending id)"} for ${ctx.goal}.`,
|
||||
summary: `Mock Roleplay created roleplay session ${detail.session_id ?? "(pending id)"} for ${ctx.goal}.`,
|
||||
detail: {
|
||||
...detail,
|
||||
target_role: payload.metadata.target_role,
|
||||
@@ -256,7 +242,7 @@ async function runQScoreService(ctx: ServiceAgentContext): Promise<ServiceAgentR
|
||||
);
|
||||
return {
|
||||
status: "ok",
|
||||
summary: `Q Score Agent estimated Q-Score ~${avgSignalScore} (service compute unavailable: formula store may not be seeded). Based on ${signals.length} signals.`,
|
||||
summary: `Q Score estimated Q Score ~${avgSignalScore} (service compute unavailable: formula store may not be seeded). Based on ${signals.length} signals.`,
|
||||
detail: {
|
||||
ingest,
|
||||
estimated_q_score: avgSignalScore,
|
||||
@@ -269,12 +255,12 @@ async function runQScoreService(ctx: ServiceAgentContext): Promise<ServiceAgentR
|
||||
|
||||
return {
|
||||
status: "ok",
|
||||
summary: `Q Score Agent computed Q-Score ${compute.q_score ?? "(unknown)"} for ${ctx.goal}.`,
|
||||
summary: `Q Score computed Q Score ${compute.q_score ?? "(unknown)"} for ${ctx.goal}.`,
|
||||
detail: { ingest, compute, qscore_user_id: qscoreUserId },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Resume Agent (resume-builder service from growqr-app) ──
|
||||
// ── Resume Building (resume-builder service from growqr-app) ──
|
||||
|
||||
async function runResumeAnalyze(ctx: ServiceAgentContext): Promise<ServiceAgentResult> {
|
||||
// Probe resume state for the user
|
||||
@@ -289,8 +275,8 @@ async function runResumeAnalyze(ctx: ServiceAgentContext): Promise<ServiceAgentR
|
||||
return {
|
||||
status: "ok",
|
||||
summary: hasResume
|
||||
? `Resume Agent found ${detail.resume_count} resume(s) at ${completeness}% completeness. Current role: ${detail.current_role ?? "unknown"}.`
|
||||
: "No existing resume found. Resume Agent is ready to build one from scratch.",
|
||||
? `Resume Building found ${detail.resume_count} resume(s) at ${completeness}% completeness. Current role: ${detail.current_role ?? "unknown"}.`
|
||||
: "No existing resume found. Resume Building is ready to build one from scratch.",
|
||||
detail: {
|
||||
resume_count: detail.resume_count,
|
||||
completeness,
|
||||
@@ -302,7 +288,7 @@ async function runResumeAnalyze(ctx: ServiceAgentContext): Promise<ServiceAgentR
|
||||
} catch (err) {
|
||||
return {
|
||||
status: "unavailable",
|
||||
summary: `Resume Agent unavailable: ${err instanceof Error ? err.message : String(err)}`,
|
||||
summary: `Resume Building unavailable: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -317,7 +303,7 @@ async function runResumeTailor(ctx: ServiceAgentContext): Promise<ServiceAgentRe
|
||||
// Return summary with optimization guidance
|
||||
return {
|
||||
status: "ok",
|
||||
summary: `Resume Agent analyzed your profile for the role "${ctx.goal}". Skills detected: ${(stateResult.detail as any)?.skills?.slice(0, 5).join(", ") ?? "none"}. Resume ready for optimization.`,
|
||||
summary: `Resume Building analyzed your profile for the role "${ctx.goal}". Skills detected: ${(stateResult.detail as any)?.skills?.slice(0, 5).join(", ") ?? "none"}. Resume ready for optimization.`,
|
||||
detail: {
|
||||
...(stateResult.detail as Record<string, unknown> ?? {}),
|
||||
goal: ctx.goal,
|
||||
@@ -382,11 +368,11 @@ export async function runServiceAgentProbe(
|
||||
case "interview-service":
|
||||
return ctx
|
||||
? await runInterviewService(ctx)
|
||||
: healthCheck(config.interviewServiceUrl, "Interview Agent / interview-service");
|
||||
: healthCheck(config.interviewServiceUrl, "Mock Interview / interview-service");
|
||||
case "roleplay-service":
|
||||
return ctx
|
||||
? await runRoleplayService(ctx)
|
||||
: healthCheck(config.roleplayServiceUrl, "Roleplay Agent / roleplay-service");
|
||||
: healthCheck(config.roleplayServiceUrl, "Mock Roleplay / roleplay-service");
|
||||
case "qscore-service":
|
||||
return ctx
|
||||
? await runQScoreService(ctx)
|
||||
@@ -394,7 +380,7 @@ export async function runServiceAgentProbe(
|
||||
case "resume-service":
|
||||
return ctx
|
||||
? await runResumeTailor(ctx)
|
||||
: healthCheck(config.resumeServiceUrl, "Resume Agent / resume-service");
|
||||
: healthCheck(config.resumeServiceUrl, "Resume Building / resume-service");
|
||||
case "matchmaking-service":
|
||||
return ctx
|
||||
? await runMatchmaking(ctx)
|
||||
|
||||
195
src/services/service-registry.ts
Normal file
195
src/services/service-registry.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import type { CuratorServiceId, CuratorTask } from "../v1/curator/curator-types.js";
|
||||
|
||||
type QueryValue = string | number | undefined | null;
|
||||
|
||||
type MissionServiceId = Extract<CuratorServiceId, "interview-service" | "roleplay-service" | "resume-service">;
|
||||
|
||||
type MissionRouteInput = {
|
||||
serviceId: MissionServiceId;
|
||||
missionInstanceId: string;
|
||||
missionId: string;
|
||||
stageId?: string;
|
||||
goal?: string;
|
||||
};
|
||||
|
||||
type CuratorRouteInput = {
|
||||
serviceId?: CuratorServiceId;
|
||||
missionInstanceId?: string;
|
||||
missionId?: string;
|
||||
stageId?: string;
|
||||
taskId?: string;
|
||||
targetRole?: string;
|
||||
durationMinutes?: number;
|
||||
difficulty?: string;
|
||||
personaId?: string;
|
||||
requestedMode?: string;
|
||||
roleplayBrief?: string;
|
||||
};
|
||||
|
||||
function appendQuery(
|
||||
pathname: string,
|
||||
params: Record<string, QueryValue>,
|
||||
) {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
search.set(key, String(value));
|
||||
}
|
||||
const query = search.toString();
|
||||
return query ? `${pathname}?${query}` : pathname;
|
||||
}
|
||||
|
||||
function getString(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function getSessionId(detail?: Record<string, unknown>) {
|
||||
return getString(detail?.session_id ?? detail?.sessionId ?? detail?.id);
|
||||
}
|
||||
|
||||
export function buildServiceSessionPath(
|
||||
serviceId: MissionServiceId,
|
||||
detail?: Record<string, unknown>,
|
||||
goal?: string,
|
||||
) {
|
||||
const sessionId = getSessionId(detail);
|
||||
|
||||
if (serviceId === "interview-service") {
|
||||
if (!sessionId) return undefined;
|
||||
return appendQuery("/v2/service-sessions/interview", {
|
||||
session_id: sessionId,
|
||||
goal,
|
||||
role: getString(detail?.target_role) ?? goal ?? "Interview practice",
|
||||
type: getString(detail?.interview_type) ?? "behavioral",
|
||||
});
|
||||
}
|
||||
|
||||
if (serviceId === "roleplay-service") {
|
||||
if (!sessionId) return undefined;
|
||||
return appendQuery("/v2/service-sessions/roleplay", {
|
||||
session_id: sessionId,
|
||||
goal,
|
||||
role: getString(detail?.target_role) ?? goal ?? "Roleplay practice",
|
||||
type: getString(detail?.roleplay_type) ?? "custom",
|
||||
});
|
||||
}
|
||||
|
||||
return appendQuery("/v2/service-sessions/resume", {
|
||||
goal,
|
||||
role: goal,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildMissionServiceRoute(input: MissionRouteInput) {
|
||||
const baseParams = {
|
||||
source: "mission",
|
||||
missionInstanceId: input.missionInstanceId,
|
||||
missionId: input.missionId,
|
||||
stageId: input.stageId,
|
||||
goal: input.goal,
|
||||
};
|
||||
|
||||
if (input.serviceId === "interview-service") {
|
||||
return appendQuery("/agents/interview/setup", baseParams);
|
||||
}
|
||||
|
||||
if (input.serviceId === "roleplay-service") {
|
||||
return appendQuery("/agents/roleplay/setup", baseParams);
|
||||
}
|
||||
|
||||
return appendQuery("/agents/resume", baseParams);
|
||||
}
|
||||
|
||||
function curatorBaseParams(input: CuratorRouteInput) {
|
||||
return {
|
||||
source: "curator-v1",
|
||||
missionInstanceId: input.missionInstanceId,
|
||||
missionId: input.missionId,
|
||||
stageId: input.stageId,
|
||||
curatorTaskId: input.taskId,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCuratorServiceRoute(input: CuratorRouteInput) {
|
||||
if (input.serviceId === "interview-service") {
|
||||
return appendQuery("/agents/interview/preview", {
|
||||
...curatorBaseParams(input),
|
||||
role: input.targetRole?.trim() || "Product Manager",
|
||||
type: "behavioral",
|
||||
persona: input.personaId ?? "payal",
|
||||
duration: input.durationMinutes ?? 5,
|
||||
difficulty: input.difficulty ?? "medium",
|
||||
media: input.requestedMode ?? "video",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.serviceId === "roleplay-service") {
|
||||
return appendQuery("/agents/roleplay/builder", {
|
||||
...curatorBaseParams(input),
|
||||
role: input.targetRole?.trim() || "Professional",
|
||||
persona: input.personaId ?? "emma",
|
||||
duration: input.durationMinutes ?? 5,
|
||||
mode: input.requestedMode ?? "video",
|
||||
brief: input.roleplayBrief,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.serviceId === "resume-service") {
|
||||
return appendQuery("/agents/resume", curatorBaseParams(input));
|
||||
}
|
||||
if (input.serviceId === "qscore-service") {
|
||||
return appendQuery("/analytics", curatorBaseParams(input));
|
||||
}
|
||||
if (input.serviceId === "social-branding-service") {
|
||||
return appendQuery("/social", curatorBaseParams(input));
|
||||
}
|
||||
if (input.serviceId === "matchmaking-service") {
|
||||
return appendQuery("/pathways", curatorBaseParams(input));
|
||||
}
|
||||
|
||||
return input.missionInstanceId
|
||||
? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId })
|
||||
: "/missions/active";
|
||||
}
|
||||
|
||||
export function getServiceDisplayName(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 getServiceToolName(serviceId?: CuratorServiceId) {
|
||||
if (serviceId === "interview-service") return "prepare_interview_preview";
|
||||
if (serviceId === "roleplay-service") return "prepare_roleplay_preview";
|
||||
if (serviceId === "resume-service") return "prepare_resume_upload";
|
||||
if (serviceId === "qscore-service") return "prepare_qscore_review";
|
||||
return "prepare_mission_step";
|
||||
}
|
||||
|
||||
export function getServiceCompletionEvents(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 getServiceActionLabel(task: CuratorTask) {
|
||||
if (task.serviceId === "interview-service") return "Open interview preview";
|
||||
if (task.serviceId === "roleplay-service") return "Open roleplay preview";
|
||||
if (task.serviceId === "resume-service") return "Open resume workspace";
|
||||
if (task.serviceId === "qscore-service") return "Review Q Score";
|
||||
return task.cta || "Open";
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { generateText, tool } from "ai";
|
||||
import { z } from "zod";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { desc, eq, gte } 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 { growConversationMessages, growEvents } from "../../db/schema.js";
|
||||
import { curatorActor } from "../curator/curator-actor.js";
|
||||
import { curatorImprovementSignalSchema } from "../curator/curator-types.js";
|
||||
|
||||
@@ -109,9 +109,14 @@ export const v1AnalyticsActor = {
|
||||
async runNightly(input: { date: string; userId?: string }) {
|
||||
const userRows = input.userId
|
||||
? [{ id: input.userId }]
|
||||
: await db.select({ id: users.id }).from(users).limit(500);
|
||||
: await db
|
||||
.selectDistinct({ id: growEvents.userId })
|
||||
.from(growEvents)
|
||||
.where(gte(growEvents.occurredAt, new Date(Date.now() - 7 * 86400000)))
|
||||
.limit(200);
|
||||
let improvementSignalsCreated = 0;
|
||||
for (const user of userRows) {
|
||||
if (!user.id) continue;
|
||||
const signals = await this.generateImprovementSignals({ userId: user.id, date: input.date });
|
||||
improvementSignalsCreated += signals.length;
|
||||
await this.applyImprovementSignals({ userId: user.id, date: input.date, signals });
|
||||
|
||||
@@ -21,8 +21,15 @@ export function v1AnalyticsRoutes() {
|
||||
|
||||
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 }));
|
||||
const body = z.object({
|
||||
date: z.string().optional(),
|
||||
userId: z.string().optional(),
|
||||
runForAll: z.boolean().optional(),
|
||||
}).parse(await c.req.json().catch(() => ({})));
|
||||
return c.json(await v1AnalyticsActor.runNightly({
|
||||
date: body.date ?? new Date().toISOString().slice(0, 10),
|
||||
userId: body.runForAll ? undefined : (body.userId ?? userId),
|
||||
}));
|
||||
});
|
||||
|
||||
return app;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { buildCuratorPlan, buildCuratorStreak, buildCuratorTasks, todayIsoDate } from "./curator-store.js";
|
||||
import { curatorPlanSchema, type CuratorImprovementSignal } from "./curator-types.js";
|
||||
import { buildCuratorPlan, buildCuratorSprint, buildCuratorStreak, buildCuratorTasks, todayIsoDate } from "./curator-store.js";
|
||||
import { curatorPlanSchema, curatorSprintResponseSchema, type CuratorImprovementSignal } from "./curator-types.js";
|
||||
import { emitCuratorEvent } from "./curator-events.js";
|
||||
import { runCuratorChat } from "./curator-agent.js";
|
||||
import { prepareHandoffForTask } from "./curator-tools.js";
|
||||
@@ -19,20 +19,27 @@ export const curatorActor = {
|
||||
|
||||
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);
|
||||
const sprint = curatorSprintResponseSchema.parse(await buildCuratorSprint(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,
|
||||
plan: sprint.plan,
|
||||
tasks: sprint.todayTasks,
|
||||
streak: sprint.streak,
|
||||
completedCount: sprint.completedCount,
|
||||
totalCount: sprint.totalCount,
|
||||
sprint,
|
||||
source: "curator-v1" as const,
|
||||
};
|
||||
},
|
||||
|
||||
async getSprint(input: { userId: string; date?: string }) {
|
||||
const date = input.date ?? todayIsoDate();
|
||||
const sprint = curatorSprintResponseSchema.parse(await buildCuratorSprint(input.userId, date));
|
||||
await emitCuratorEvent({ userId: input.userId, type: "curator.day.opened", payload: { date, sprintId: sprint.sprintId } });
|
||||
return sprint;
|
||||
},
|
||||
|
||||
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);
|
||||
},
|
||||
@@ -62,32 +69,36 @@ export const curatorActor = {
|
||||
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) {
|
||||
const reason = input.reason ?? "subtasks_completed";
|
||||
const allowDirectServiceCompletion = task.serviceId === "qscore-service" && reason === "qscore_review_opened";
|
||||
if (task.serviceId && !allowDirectServiceCompletion) {
|
||||
throw new Error("curator_service_task_requires_service_event");
|
||||
}
|
||||
const event = await emitCuratorEvent({
|
||||
userId: input.userId,
|
||||
type: "curator.task.completed",
|
||||
mission: { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId },
|
||||
payload: { taskId: task.id, date, reason: input.reason ?? "subtasks_completed" },
|
||||
payload: { taskId: task.id, date, reason },
|
||||
});
|
||||
return { task: { ...task, status: "completed" as const }, eventId: event.id };
|
||||
},
|
||||
|
||||
async recordServiceImpact(input: { userId: string; eventId: string }) {
|
||||
const streak = await buildCuratorStreak(input.userId);
|
||||
return { matched: true, completedTasks: await buildCuratorTasks(input.userId, todayIsoDate()), streak };
|
||||
const sprint = await buildCuratorSprint(input.userId, todayIsoDate());
|
||||
return { matched: true, completedTasks: sprint.todayTasks, streak, sprint };
|
||||
},
|
||||
|
||||
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 };
|
||||
const sprint = await buildCuratorSprint(input.userId, input.date);
|
||||
return { applied: input.signals.length, plan: sprint.plan, sprint };
|
||||
},
|
||||
|
||||
async getState(input: { userId: string }) {
|
||||
return { tasks: await buildCuratorTasks(input.userId, todayIsoDate()), streak: await buildCuratorStreak(input.userId) };
|
||||
const sprint = await buildCuratorSprint(input.userId, todayIsoDate());
|
||||
return { tasks: sprint.todayTasks, streak: sprint.streak, sprint };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -176,7 +176,25 @@ function targetRoleState(messages: CuratorMessage[], latest: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function curatorSystemAddendum(input: {
|
||||
const CURATOR_PROMPT_FILE = path.resolve(process.cwd(), "prompts", "curator-v1.md");
|
||||
|
||||
const DEFAULT_CURATOR_PROMPT = `You are currently speaking as the GrowQR V1 Curator through the Conversation Actor.
|
||||
Own 30 day direction, streak continuity, and service handoff decisions.
|
||||
Do not ask the same question twice.
|
||||
Use captured task memory and keep the user on the focused subtask.
|
||||
When the user has answered enough, summarize what was captured and stop.
|
||||
If more detail is needed, ask exactly one follow-up question.
|
||||
For service work, prepare preview-oriented handoffs once enough context exists.`;
|
||||
|
||||
async function loadCuratorPromptTemplate() {
|
||||
try {
|
||||
return await readFile(CURATOR_PROMPT_FILE, "utf8");
|
||||
} catch {
|
||||
return DEFAULT_CURATOR_PROMPT;
|
||||
}
|
||||
}
|
||||
|
||||
async function curatorSystemAddendum(input: {
|
||||
date: string;
|
||||
taskId?: string;
|
||||
subtaskIndex?: number;
|
||||
@@ -186,21 +204,14 @@ function curatorSystemAddendum(input: {
|
||||
promptText: string;
|
||||
targetRole?: string;
|
||||
}) {
|
||||
const template = await loadCuratorPromptTemplate();
|
||||
const lines = [
|
||||
input.promptText,
|
||||
"",
|
||||
"You are currently speaking as the GrowQR V1 Curator through the Conversation Actor.",
|
||||
"The V1 Curator owns 30 day direction, streak continuity, and service handoff decisions.",
|
||||
"Carry state from the conversation history. If the user gives a short answer like a role name, accept it and ask for the next missing slot.",
|
||||
"Do not ask the same question twice. Do not output checklist items as separate baked chat messages.",
|
||||
"For target-role tasks, collect target role, current background, constraints, then offer a resume or interview handoff.",
|
||||
"For service work, use Conversation Actor tools to prepare handoffs only after the focused subtask has enough context.",
|
||||
"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.",
|
||||
"Do not ask about another subtask, another mission, another service, or a later checklist item from this modal.",
|
||||
"When the user has answered the focused subtask enough, summarize what was captured and stop. Do not ask the next subtask question.",
|
||||
"If more detail is needed, ask exactly one follow-up question for the focused subtask only.",
|
||||
"Use captured task memory from previous subtasks as context. Do not ask the user to repeat details already captured there.",
|
||||
...template
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trimEnd())
|
||||
.filter(Boolean),
|
||||
];
|
||||
pushField(lines, "Known target role", input.targetRole);
|
||||
pushField(lines, "Date", input.date);
|
||||
@@ -268,6 +279,77 @@ function servicePreviewSummary(task: Awaited<ReturnType<typeof buildCuratorTasks
|
||||
return `${task.serviceName} handoff is ready.`;
|
||||
}
|
||||
|
||||
function fallbackCuratorReply(input: {
|
||||
latest: string;
|
||||
task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number];
|
||||
subtask?: string;
|
||||
targetRole?: string;
|
||||
}) {
|
||||
const latest = input.latest.trim();
|
||||
const lowerTitle = input.task?.title.toLowerCase() ?? "";
|
||||
const lowerSubtask = input.subtask?.toLowerCase() ?? "";
|
||||
const role = fallbackCuratorRole(input.targetRole);
|
||||
|
||||
if ((input.task?.serviceId === "interview-service" || input.task?.serviceId === "roleplay-service") && !input.targetRole) {
|
||||
return "What role are you targeting?";
|
||||
}
|
||||
|
||||
if (/^start$/i.test(latest)) {
|
||||
if (input.task?.serviceId === "qscore-service") {
|
||||
return "Open your current Q Score and tell me which readiness signal looks weakest today.";
|
||||
}
|
||||
if (input.task?.serviceId === "resume-service") {
|
||||
return "Upload your current resume or paste three recent wins so I can anchor this proof task.";
|
||||
}
|
||||
if (input.task?.serviceId === "interview-service" || input.task?.serviceId === "roleplay-service") {
|
||||
return `I have your target role as ${role}. Say start when you want the preview opened.`;
|
||||
}
|
||||
if (lowerTitle.includes("role direction") || lowerSubtask.includes("role direction")) {
|
||||
return "Which role family do you want this sprint to optimize toward?";
|
||||
}
|
||||
if (input.task?.taskType === "measurement") {
|
||||
return "Open the current view and tell me the one gap or signal that stands out most.";
|
||||
}
|
||||
if (input.task?.taskType === "proof") {
|
||||
return "Share the strongest proof you already have so we can build from something real.";
|
||||
}
|
||||
return "What is the single outcome you want from this task today?";
|
||||
}
|
||||
|
||||
if (input.task?.serviceId === "interview-service" || input.task?.serviceId === "roleplay-service") {
|
||||
if (isExplicitHandoffRequest(latest)) {
|
||||
return servicePreviewSummary(input.task, input.targetRole);
|
||||
}
|
||||
return `Captured ${role} as the target role. Say start when you want the preview opened.`;
|
||||
}
|
||||
|
||||
if (lowerTitle.includes("role direction") || lowerSubtask.includes("role direction")) {
|
||||
return `Captured ${latest}. I will use that as the role direction for this sprint.`;
|
||||
}
|
||||
|
||||
if (input.task?.serviceId === "resume-service") {
|
||||
return "Captured. Open the resume flow when you are ready to turn this into proof.";
|
||||
}
|
||||
|
||||
if (input.task?.serviceId === "qscore-service") {
|
||||
return "Captured. Open the Q Score view and save the main readiness gap you want to work on.";
|
||||
}
|
||||
|
||||
if (input.task?.taskType === "measurement") {
|
||||
return "Captured the baseline signal for today.";
|
||||
}
|
||||
|
||||
if (input.task?.taskType === "proof") {
|
||||
return "Captured the proof point for today.";
|
||||
}
|
||||
|
||||
if (input.task?.taskType === "practice") {
|
||||
return "Captured the practice focus for today.";
|
||||
}
|
||||
|
||||
return "Captured. We can use this to move the task forward.";
|
||||
}
|
||||
|
||||
async function evaluateSubtaskStatus(input: {
|
||||
task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number];
|
||||
subtask?: string;
|
||||
@@ -451,6 +533,7 @@ export async function runCuratorChat(input: {
|
||||
}
|
||||
|
||||
let reply = "";
|
||||
let usedFallbackReply = false;
|
||||
try {
|
||||
try {
|
||||
const extract = await generateText({
|
||||
@@ -499,7 +582,7 @@ export async function runCuratorChat(input: {
|
||||
missionId: task?.missionId,
|
||||
stageId: task?.stageId,
|
||||
source: "curator-v1",
|
||||
systemAddendum: curatorSystemAddendum({ date, taskId: input.taskId, subtaskIndex: input.subtaskIndex, subtask: input.subtask, task, taskMemory, promptText, targetRole }),
|
||||
systemAddendum: await curatorSystemAddendum({ date, taskId: input.taskId, subtaskIndex: input.subtaskIndex, subtask: input.subtask, task, taskMemory, promptText, targetRole }),
|
||||
});
|
||||
reply = sanitize(result.text);
|
||||
if (/what should i capture next/i.test(reply) || !reply) {
|
||||
@@ -512,7 +595,23 @@ export async function runCuratorChat(input: {
|
||||
subtask: input.subtask,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
reply = sanitize(fallbackCuratorReply({
|
||||
latest,
|
||||
task,
|
||||
subtask: input.subtask,
|
||||
targetRole,
|
||||
}));
|
||||
usedFallbackReply = true;
|
||||
}
|
||||
|
||||
if (!reply) {
|
||||
reply = sanitize(fallbackCuratorReply({
|
||||
latest,
|
||||
task,
|
||||
subtask: input.subtask,
|
||||
targetRole,
|
||||
}));
|
||||
usedFallbackReply = true;
|
||||
}
|
||||
|
||||
let statusUpdate = await evaluateSubtaskStatus({
|
||||
@@ -530,6 +629,12 @@ export async function runCuratorChat(input: {
|
||||
confidence: Math.max(statusUpdate.confidence, 0.9),
|
||||
};
|
||||
}
|
||||
if (usedFallbackReply && statusUpdate.status === "needs_more_context" && !statusUpdate.nextMissingInfo) {
|
||||
statusUpdate = {
|
||||
...statusUpdate,
|
||||
summary: reply,
|
||||
};
|
||||
}
|
||||
if (isPreviewHandoffService(task) && !isInitialOpen && usefulUserMessages(conversationHistory).length >= 1) {
|
||||
statusUpdate = {
|
||||
status: "handoff_ready",
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import { recordGrowEvent } from "../../events/record-grow-event.js";
|
||||
|
||||
function curatorDedupeKey(input: {
|
||||
userId: string;
|
||||
type: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}) {
|
||||
const payload = input.payload ?? {};
|
||||
const stableId =
|
||||
payload.taskId ??
|
||||
payload.sprintId ??
|
||||
payload.startDate ??
|
||||
payload.sourceEventId ??
|
||||
payload.eventId ??
|
||||
payload.date;
|
||||
|
||||
return `${input.userId}:${input.type}:${stableId ?? Date.now()}`;
|
||||
}
|
||||
|
||||
export async function emitCuratorEvent(input: {
|
||||
userId: string;
|
||||
type: string;
|
||||
@@ -14,6 +31,6 @@ export async function emitCuratorEvent(input: {
|
||||
occurredAt: new Date().toISOString(),
|
||||
mission: input.mission,
|
||||
payload: input.payload ?? {},
|
||||
dedupeKey: `${input.userId}:${input.type}:${input.payload?.taskId ?? input.payload?.date ?? Date.now()}`,
|
||||
dedupeKey: curatorDedupeKey(input),
|
||||
}, { userId: input.userId, source: "curator-v1" });
|
||||
}
|
||||
|
||||
@@ -41,6 +41,11 @@ export function v1CuratorRoutes() {
|
||||
return c.json(await curatorActor.getToday({ userId, date: c.req.query("date") }));
|
||||
});
|
||||
|
||||
app.get("/sprint", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
return c.json(await curatorActor.getSprint({ userId, date: c.req.query("date") }));
|
||||
});
|
||||
|
||||
app.post("/chat", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = chatSchema.parse(await c.req.json());
|
||||
|
||||
@@ -1,74 +1,42 @@
|
||||
import {
|
||||
buildCuratorServiceRoute,
|
||||
getServiceActionLabel,
|
||||
getServiceCompletionEvents,
|
||||
getServiceDisplayName,
|
||||
getServiceToolName,
|
||||
} from "../../services/service-registry.js";
|
||||
import type { CuratorServiceId, CuratorTask } from "./curator-types.js";
|
||||
|
||||
export function serviceRoute(input: {
|
||||
type ServiceRouteInput = {
|
||||
serviceId?: CuratorServiceId;
|
||||
missionInstanceId?: string;
|
||||
missionId?: string;
|
||||
stageId?: string;
|
||||
taskId?: string;
|
||||
targetRole?: string;
|
||||
roleplayScenario?: 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);
|
||||
durationMinutes?: number;
|
||||
difficulty?: string;
|
||||
personaId?: string;
|
||||
requestedMode?: string;
|
||||
roleplayBrief?: string;
|
||||
};
|
||||
|
||||
if (input.serviceId === "interview-service") {
|
||||
params.set("role", input.targetRole?.trim() || "Product Manager");
|
||||
params.set("type", "behavioral");
|
||||
params.set("difficulty", "medium");
|
||||
params.set("duration", "5");
|
||||
return `/agents/interview/preview?${params.toString()}`;
|
||||
}
|
||||
if (input.serviceId === "roleplay-service") {
|
||||
params.set("role", input.targetRole?.trim() || "Product Manager");
|
||||
params.set("type", "custom");
|
||||
params.set("difficulty", "medium");
|
||||
params.set("duration", "5");
|
||||
if (input.roleplayScenario?.trim()) params.set("scenario_name", input.roleplayScenario.trim());
|
||||
return `/agents/roleplay/preview?${params.toString()}`;
|
||||
}
|
||||
|
||||
const suffix = params.toString();
|
||||
if (input.serviceId === "resume-service") return `/agents/resume?${suffix}`;
|
||||
if (input.serviceId === "qscore-service") return `/agents/qscore?${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 serviceRoute(input: ServiceRouteInput) {
|
||||
return buildCuratorServiceRoute(input);
|
||||
}
|
||||
|
||||
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;
|
||||
return getServiceDisplayName(serviceId, fallback);
|
||||
}
|
||||
|
||||
export function serviceToolName(serviceId?: CuratorServiceId) {
|
||||
if (serviceId === "interview-service") return "prepare_interview_preview";
|
||||
if (serviceId === "roleplay-service") return "prepare_roleplay_preview";
|
||||
if (serviceId === "resume-service") return "prepare_resume_upload";
|
||||
if (serviceId === "qscore-service") return "prepare_qscore_review";
|
||||
return "prepare_mission_step";
|
||||
return getServiceToolName(serviceId);
|
||||
}
|
||||
|
||||
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"];
|
||||
return getServiceCompletionEvents(serviceId);
|
||||
}
|
||||
|
||||
export function actionLabel(task: CuratorTask) {
|
||||
if (task.serviceId === "interview-service") return "Open interview preview";
|
||||
if (task.serviceId === "roleplay-service") return "Open roleplay preview";
|
||||
if (task.serviceId === "resume-service") return "Open resume workspace";
|
||||
if (task.serviceId === "qscore-service") return "Review Q Score";
|
||||
return task.cta || "Open";
|
||||
return getServiceActionLabel(task);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,9 +17,124 @@ async function findTask(userId: string, taskId: string, date: string) {
|
||||
return tasks.find((task) => task.id === taskId) ?? null;
|
||||
}
|
||||
|
||||
export async function prepareHandoffForTask(userId: string, task: CuratorTask, serviceId = task.serviceId, targetRoleOverride?: string): Promise<CuratorServiceHandoff> {
|
||||
function conciseRoleHint(value: string | undefined) {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.length > 80 ? `${trimmed.slice(0, 77).trimEnd()}...` : trimmed;
|
||||
}
|
||||
|
||||
function buildRoleplayBrief(task: CuratorTask, targetRole: string) {
|
||||
return `Practice a realistic ${task.title.toLowerCase()} conversation for ${targetRole}. Include one pushback moment, concise answers, and a clear next step.`;
|
||||
}
|
||||
|
||||
async function missionGoalHint(userId: string, task: CuratorTask) {
|
||||
if (!task.missionInstanceId) return undefined;
|
||||
const active = await listActiveMissionsPg(userId);
|
||||
const match = active.find((item) => item.mission.instanceId === task.missionInstanceId);
|
||||
const goal = typeof match?.mission.goal === "string" ? match.mission.goal : undefined;
|
||||
return conciseRoleHint(goal);
|
||||
}
|
||||
|
||||
function asText(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function reviewField(payload: Record<string, unknown>, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
const direct = asText(payload[key]);
|
||||
if (direct) return direct;
|
||||
}
|
||||
const review = payload.review && typeof payload.review === "object" ? payload.review as Record<string, unknown> : undefined;
|
||||
if (!review) return undefined;
|
||||
for (const key of keys) {
|
||||
const nested = asText(review[key]);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function latestInterviewResumeEvidence(userId: string) {
|
||||
const rows = await db.select({
|
||||
id: growEvents.id,
|
||||
type: growEvents.type,
|
||||
source: growEvents.source,
|
||||
payload: growEvents.payload,
|
||||
occurredAt: growEvents.occurredAt,
|
||||
}).from(growEvents)
|
||||
.where(and(
|
||||
eq(growEvents.userId, userId),
|
||||
inArray(growEvents.type as any, [
|
||||
"interview.review_completed",
|
||||
"interview.completed",
|
||||
"roleplay.review_completed",
|
||||
"roleplay.completed",
|
||||
]),
|
||||
))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(5);
|
||||
|
||||
const latest = rows[0];
|
||||
if (!latest) return null;
|
||||
|
||||
const payload = latest.payload ?? {};
|
||||
const strongestAnswer = reviewField(payload, [
|
||||
"strongest_answer",
|
||||
"strongestAnswer",
|
||||
"best_answer",
|
||||
"bestAnswer",
|
||||
"top_answer",
|
||||
"topAnswer",
|
||||
]);
|
||||
const improvementArea = reviewField(payload, [
|
||||
"improvement_area",
|
||||
"improvementArea",
|
||||
"biggest_gap",
|
||||
"biggestGap",
|
||||
"coaching_note",
|
||||
"coachingNote",
|
||||
]);
|
||||
const summary = reviewField(payload, [
|
||||
"summary",
|
||||
"feedback_summary",
|
||||
"feedbackSummary",
|
||||
"overall_feedback",
|
||||
"overallFeedback",
|
||||
]);
|
||||
|
||||
const carryForward = [
|
||||
summary ? `Review summary: ${summary}` : undefined,
|
||||
strongestAnswer ? `Strongest answer to convert into proof: ${strongestAnswer}` : undefined,
|
||||
improvementArea ? `Weakest area to repair in resume positioning: ${improvementArea}` : undefined,
|
||||
].filter((item): item is string => Boolean(item));
|
||||
|
||||
return {
|
||||
eventId: latest.id,
|
||||
source: latest.source,
|
||||
type: latest.type,
|
||||
occurredAt: latest.occurredAt,
|
||||
carryForward,
|
||||
};
|
||||
}
|
||||
|
||||
export async function prepareHandoffForTask(
|
||||
userId: string,
|
||||
task: CuratorTask,
|
||||
serviceId = task.serviceId,
|
||||
targetRoleOverride?: string,
|
||||
): Promise<CuratorServiceHandoff> {
|
||||
if (!serviceId) throw new Error("Task has no service handoff.");
|
||||
const targetRole = fallbackCuratorRole(targetRoleOverride ?? await resolveCuratorTargetRole({ userId, task }));
|
||||
const resolvedTargetRole =
|
||||
targetRoleOverride ??
|
||||
(await missionGoalHint(userId, task)) ??
|
||||
(await resolveCuratorTargetRole({ userId, task }));
|
||||
const targetRole = fallbackCuratorRole(resolvedTargetRole);
|
||||
const route = serviceRoute({
|
||||
serviceId,
|
||||
missionId: task.missionId,
|
||||
@@ -27,11 +142,15 @@ export async function prepareHandoffForTask(userId: string, task: CuratorTask, s
|
||||
stageId: task.stageId,
|
||||
taskId: task.id,
|
||||
targetRole,
|
||||
roleplayScenario: task.title,
|
||||
durationMinutes: 5,
|
||||
difficulty: "medium",
|
||||
personaId: serviceId === "roleplay-service" ? "emma" : "payal",
|
||||
requestedMode: "video",
|
||||
roleplayBrief: serviceId === "roleplay-service" ? buildRoleplayBrief(task, targetRole) : undefined,
|
||||
});
|
||||
|
||||
let actionId: string | undefined;
|
||||
if (task.missionInstanceId) {
|
||||
if (task.missionInstanceId && task.missionId !== "curator-sprint") {
|
||||
const action = await createMissionAction({
|
||||
userId,
|
||||
missionInstanceId: task.missionInstanceId,
|
||||
@@ -68,7 +187,7 @@ export async function prepareHandoffForTask(userId: string, task: CuratorTask, s
|
||||
route,
|
||||
actionId,
|
||||
actionRoute: route,
|
||||
actionLabel: actionLabel(task),
|
||||
actionLabel: actionLabel({ ...task, serviceId }),
|
||||
status: "prepared",
|
||||
};
|
||||
}
|
||||
@@ -272,6 +391,35 @@ export function buildCuratorTools(ctx: { userId: string; date: string; conversat
|
||||
},
|
||||
}),
|
||||
|
||||
prepare_resume_from_interview_evidence: tool({
|
||||
description: "Prepare a resume handoff that carries forward recent interview or roleplay review evidence into the proof task.",
|
||||
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" };
|
||||
const handoff = await prepareHandoffForTask(ctx.userId, task, "resume-service");
|
||||
const evidence = await latestInterviewResumeEvidence(ctx.userId);
|
||||
if (!evidence?.carryForward?.length) {
|
||||
const interviewFallback = await prepareHandoffForTask(ctx.userId, task, "interview-service");
|
||||
return {
|
||||
handoff,
|
||||
carryForward: [],
|
||||
requiresInterviewEvidence: true,
|
||||
recommendedNextAction: "No recent interview or roleplay review evidence is available yet. Run an interview rep first so the resume proof can be generated from real conversation evidence.",
|
||||
fallbackHandoff: interviewFallback,
|
||||
};
|
||||
}
|
||||
return {
|
||||
handoff,
|
||||
carryForward: evidence?.carryForward ?? [],
|
||||
sourceEventId: evidence?.eventId,
|
||||
sourceEventType: evidence?.type,
|
||||
sourceService: evidence?.source,
|
||||
sourceOccurredAt: evidence?.occurredAt,
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
prepare_roleplay_setup: tool({
|
||||
description: "Prepare roleplay setup handoff.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
|
||||
@@ -11,6 +11,9 @@ export const curatorServiceIdSchema = z.enum([
|
||||
|
||||
export type CuratorServiceId = z.infer<typeof curatorServiceIdSchema>;
|
||||
|
||||
export const curatorTaskTypeSchema = z.enum(["measurement", "proof", "practice"]);
|
||||
export type CuratorTaskType = z.infer<typeof curatorTaskTypeSchema>;
|
||||
|
||||
export const curatorTaskStatusSchema = z.enum([
|
||||
"ready",
|
||||
"started",
|
||||
@@ -19,9 +22,16 @@ export const curatorTaskStatusSchema = z.enum([
|
||||
"blocked",
|
||||
]);
|
||||
|
||||
export const curatorWeekLifecycleSchema = z.enum(["done", "active", "upcoming"]);
|
||||
export const curatorWeekPerformanceSchema = z.enum(["Missed", "Okayish", "Avg", "Excelling"]);
|
||||
|
||||
export const curatorTaskSchema = z.object({
|
||||
id: z.string(),
|
||||
date: z.string(),
|
||||
dayIndex: z.number().int().min(1).max(30),
|
||||
dayIndexInWeek: z.number().int().min(1).max(7),
|
||||
weekIndex: z.number().int().min(1).max(5),
|
||||
taskType: curatorTaskTypeSchema,
|
||||
title: z.string(),
|
||||
subtitle: z.string(),
|
||||
missionId: z.string(),
|
||||
@@ -39,7 +49,7 @@ export const curatorTaskSchema = z.object({
|
||||
cta: z.string(),
|
||||
context: z.array(z.object({ label: z.string(), value: z.string() })),
|
||||
contextNarrative: z.string(),
|
||||
subtasks: z.array(z.string()).min(1),
|
||||
subtasks: z.array(z.string()).length(3),
|
||||
signals: z.array(z.string()),
|
||||
completionEvents: z.array(z.string()),
|
||||
source: z.enum(["curator-v1", "mission-registry", "service-registry"]),
|
||||
@@ -51,6 +61,38 @@ export const curatorStreakSchema = z.object({
|
||||
lastCompletedDate: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const curatorPlanDaySchema = z.object({
|
||||
date: z.string(),
|
||||
dayIndex: z.number().int().min(1).max(30),
|
||||
dayIndexInWeek: z.number().int().min(1).max(7),
|
||||
weekIndex: z.number().int().min(1).max(5),
|
||||
weekTheme: z.string(),
|
||||
weekSummary: z.string(),
|
||||
focus: z.string().optional(),
|
||||
plannedServices: z.array(curatorServiceIdSchema).max(3).default([]),
|
||||
generationStatus: z.enum(["seeded", "generated", "adapted"]).default("seeded"),
|
||||
adaptationReason: z.string().optional(),
|
||||
completedCount: z.number().int().min(0),
|
||||
totalCount: z.number().int().min(0),
|
||||
unlockState: z.enum(["completed", "active", "upcoming"]),
|
||||
tasks: z.array(curatorTaskSchema),
|
||||
});
|
||||
|
||||
export const curatorWeekSchema = z.object({
|
||||
weekIndex: z.number().int().min(1).max(5),
|
||||
title: z.string(),
|
||||
theme: z.string(),
|
||||
summary: z.string(),
|
||||
startDayIndex: z.number().int().min(1).max(30),
|
||||
endDayIndex: z.number().int().min(1).max(30),
|
||||
lifecycle: curatorWeekLifecycleSchema,
|
||||
performance: curatorWeekPerformanceSchema,
|
||||
completedTaskCount: z.number().int().min(0),
|
||||
totalTaskCount: z.number().int().min(0),
|
||||
completionPercent: z.number().min(0).max(100),
|
||||
days: z.array(curatorPlanDaySchema).min(2).max(7),
|
||||
});
|
||||
|
||||
export const curatorPlanSchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
@@ -58,12 +100,9 @@ export const curatorPlanSchema = z.object({
|
||||
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),
|
||||
})),
|
||||
durationDays: z.literal(30),
|
||||
weeks: z.array(curatorWeekSchema).length(5),
|
||||
days: z.array(curatorPlanDaySchema).length(30),
|
||||
streak: curatorStreakSchema,
|
||||
source: z.literal("curator-v1"),
|
||||
});
|
||||
@@ -81,10 +120,29 @@ export const curatorImprovementSignalSchema = z.object({
|
||||
status: z.enum(["created", "applied", "skipped"]).default("created"),
|
||||
});
|
||||
|
||||
export const curatorSprintResponseSchema = z.object({
|
||||
date: z.string(),
|
||||
sprintId: z.string(),
|
||||
plan: curatorPlanSchema,
|
||||
activeWeek: curatorWeekSchema,
|
||||
activeWeekIndex: z.number().int().min(1).max(5),
|
||||
activeDay: curatorPlanDaySchema,
|
||||
activeDayIndex: z.number().int().min(1).max(30),
|
||||
todayTasks: z.array(curatorTaskSchema).length(3),
|
||||
streak: curatorStreakSchema,
|
||||
completedCount: z.number().int().min(0),
|
||||
totalCount: z.number().int().min(0),
|
||||
overallProgressPercent: z.number().min(0).max(100),
|
||||
source: z.literal("curator-v1"),
|
||||
});
|
||||
|
||||
export type CuratorTask = z.infer<typeof curatorTaskSchema>;
|
||||
export type CuratorPlanDay = z.infer<typeof curatorPlanDaySchema>;
|
||||
export type CuratorWeek = z.infer<typeof curatorWeekSchema>;
|
||||
export type CuratorPlan = z.infer<typeof curatorPlanSchema>;
|
||||
export type CuratorStreak = z.infer<typeof curatorStreakSchema>;
|
||||
export type CuratorImprovementSignal = z.infer<typeof curatorImprovementSignalSchema>;
|
||||
export type CuratorSprintResponse = z.infer<typeof curatorSprintResponseSchema>;
|
||||
|
||||
export type CuratorTodayResponse = {
|
||||
date: string;
|
||||
|
||||
@@ -71,9 +71,27 @@ function roleFromTask(task?: CuratorTask) {
|
||||
export function inferRoleFromText(text?: string) {
|
||||
const value = text?.trim();
|
||||
if (!value) return undefined;
|
||||
if (value.length <= 80 && ROLE_PATTERN.test(value)) return value.replace(/[.?!]+$/, "").trim();
|
||||
const explicit = value.match(/(?:targeting|for|as|role is|role:)\s+([A-Za-z][A-Za-z0-9 +/&.-]{2,60})/i)?.[1];
|
||||
return explicit?.replace(/[.?!]+$/, "").trim();
|
||||
const cleanRole = (raw?: string) => {
|
||||
if (!raw) return undefined;
|
||||
const normalized = raw
|
||||
.replace(/^(?:i am |i'm |im )/i, "")
|
||||
.replace(/^(?:targeting|aiming for|looking for)\s+/i, "")
|
||||
.replace(/[.?!,]+$/, "")
|
||||
.replace(/\broles?\b/gi, "")
|
||||
.replace(/\b(this|next)\s+(week|month|quarter|year)\b/gi, "")
|
||||
.replace(/\b(right now|currently)\b/gi, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (!normalized) return undefined;
|
||||
if (/^pm$/i.test(normalized)) return "Product Manager";
|
||||
if (/^swe$/i.test(normalized)) return "Software Engineer";
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const explicit = value.match(/(?:targeting|for|as|toward|towards|role is|role:)\s+([A-Za-z][A-Za-z0-9 +/&.-]{2,60}?)(?=\s+roles?\b|\s+(?:this|next)\s+(?:week|month|quarter|year)\b|[.?!,]|$)/i)?.[1];
|
||||
if (explicit) return cleanRole(explicit);
|
||||
if (value.length <= 80 && ROLE_PATTERN.test(value)) return cleanRole(value);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function resolveCuratorTargetRole(input: {
|
||||
|
||||
Reference in New Issue
Block a user