Compare commits
5 Commits
backend-ca
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d74f81913 | ||
|
|
3bba99e874 | ||
|
|
697040e9d3 | ||
|
|
39ba59ab25 | ||
|
|
10478fb035 |
@@ -8,7 +8,6 @@
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test:onboarding": "tsx scripts/onboarding-ledger.test.ts",
|
||||
"test:missions": "tsx scripts/mission-lifecycle.test.ts",
|
||||
"test:passive-actions": "tsx scripts/passive-actions.test.ts",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"workflows:smoke": "tsx src/workflows/smoke-test.ts",
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { careerTransitionReducer } from "../src/missions/career-transition/reducer.js";
|
||||
import { interviewToOfferReducer } from "../src/missions/interview-to-offer/reducer.js";
|
||||
import { personalBrandOpportunityReducer } from "../src/missions/personal-brand-opportunity-engine/reducer.js";
|
||||
import { promotionReadinessReducer } from "../src/missions/promotion-readiness/reducer.js";
|
||||
import { salaryNegotiationReducer } from "../src/missions/salary-negotiation-war-room/reducer.js";
|
||||
import type { GrowActiveMission } from "../src/actors/missions/types.js";
|
||||
import type { MissionReducer } from "../src/missions/reducer-types.js";
|
||||
import type { MissionReducerContext } from "../src/missions/reducer-types.js";
|
||||
|
||||
function missionFor(missionId: GrowActiveMission["missionId"], actorType: GrowActiveMission["actorType"]): GrowActiveMission {
|
||||
return {
|
||||
instanceId: `mission-${missionId}-test`,
|
||||
missionId,
|
||||
workflowId: missionId,
|
||||
title: missionId,
|
||||
shortTitle: missionId,
|
||||
status: "active",
|
||||
progressPercent: 0,
|
||||
currentStageId: "resume",
|
||||
actorType,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
const mission = missionFor("interview-to-offer", "interviewToOfferMissionActor");
|
||||
|
||||
function ctxWithMission(activeMission: GrowActiveMission, event: Partial<MissionReducerContext["event"]> & { source: string; type: string; payload?: Record<string, unknown> }): MissionReducerContext {
|
||||
return {
|
||||
userId: "user_test",
|
||||
activeMission,
|
||||
event: {
|
||||
id: `event-${activeMission.missionId}-${event.type}`,
|
||||
userId: "user_test",
|
||||
orgId: null,
|
||||
source: event.source,
|
||||
type: event.type,
|
||||
category: "service",
|
||||
occurredAt: new Date(),
|
||||
receivedAt: new Date(),
|
||||
mission: event.mission,
|
||||
subject: null,
|
||||
correlation: null,
|
||||
payload: event.payload ?? {},
|
||||
raw: {},
|
||||
dedupeKey: null,
|
||||
processingStatus: "pending",
|
||||
processingError: null,
|
||||
processedAt: null,
|
||||
},
|
||||
qscoreSignals: [],
|
||||
insight: {
|
||||
summary: "test insight",
|
||||
confidence: "low",
|
||||
recommendedActions: [],
|
||||
missionStageHints: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function ctx(event: Partial<MissionReducerContext["event"]> & { source: string; type: string; payload?: Record<string, unknown> }): MissionReducerContext {
|
||||
return ctxWithMission(mission, event);
|
||||
}
|
||||
|
||||
const interviewFeedbackPayload = {
|
||||
review: {
|
||||
status: "completed",
|
||||
overall_score: 72,
|
||||
weak_areas: ["impact metrics", "ownership clarity"],
|
||||
proof_gaps: ["no scale numbers"],
|
||||
story_issues: ["STAR structure is loose"],
|
||||
summary: "Good direction, but missing measurable proof.",
|
||||
},
|
||||
};
|
||||
|
||||
const roleplayFeedbackPayload = {
|
||||
review: {
|
||||
status: "completed",
|
||||
weak_areas: ["concision", "objection handling"],
|
||||
story_gaps: ["needs clearer tradeoff story"],
|
||||
summary: "Good empathy, but answers need tighter story framing.",
|
||||
},
|
||||
};
|
||||
|
||||
const reducerCases: Array<{
|
||||
name: string;
|
||||
reducer: MissionReducer;
|
||||
mission: GrowActiveMission;
|
||||
}> = [
|
||||
{
|
||||
name: "interview to offer",
|
||||
reducer: interviewToOfferReducer,
|
||||
mission,
|
||||
},
|
||||
{
|
||||
name: "career transition",
|
||||
reducer: careerTransitionReducer,
|
||||
mission: missionFor("career-transition", "careerTransitionMissionActor"),
|
||||
},
|
||||
{
|
||||
name: "promotion readiness",
|
||||
reducer: promotionReadinessReducer,
|
||||
mission: missionFor("promotion-readiness", "promotionReadinessMissionActor"),
|
||||
},
|
||||
{
|
||||
name: "salary negotiation",
|
||||
reducer: salaryNegotiationReducer,
|
||||
mission: missionFor("salary-negotiation-war-room", "salaryNegotiationWarRoomMissionActor"),
|
||||
},
|
||||
{
|
||||
name: "personal brand",
|
||||
reducer: personalBrandOpportunityReducer,
|
||||
mission: missionFor("personal-brand-opportunity-engine", "personalBrandOpportunityMissionActor"),
|
||||
},
|
||||
];
|
||||
|
||||
const resumeResult = interviewToOfferReducer.reduce(ctx({
|
||||
source: "resume-builder",
|
||||
type: "resume.analysis.completed",
|
||||
payload: {
|
||||
analysis: {
|
||||
summary: "Strong backend platform project.",
|
||||
strengths: ["Built an event-driven backend"],
|
||||
gaps: ["Add impact metrics"],
|
||||
missing_keywords: ["Kafka", "AWS"],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const proofPractice = resumeResult.actions.find((action) => action.payload?.passiveAction === "resume_analysis_to_interview_practice");
|
||||
assert.ok(proofPractice, "resume analysis should create an interview practice passive action");
|
||||
assert.equal(proofPractice?.serviceId, "interview-service");
|
||||
assert.equal(proofPractice?.toolName, "interview.configure_practice");
|
||||
assert.match(String(proofPractice?.payload?.prompt), /event-driven backend/i);
|
||||
|
||||
const interviewResult = interviewToOfferReducer.reduce(ctx({
|
||||
source: "interview-service",
|
||||
type: "interview.feedback.generated",
|
||||
payload: interviewFeedbackPayload,
|
||||
}));
|
||||
|
||||
const resumeUpgrade = interviewResult.actions.find((action) => action.payload?.passiveAction === "interview_feedback_to_resume_upgrade");
|
||||
assert.ok(resumeUpgrade, "interview feedback should create a resume upgrade passive action");
|
||||
assert.equal(resumeUpgrade?.mode, "approval_required");
|
||||
assert.equal(resumeUpgrade?.serviceId, "resume-service");
|
||||
assert.deepEqual(resumeUpgrade?.payload?.missingProof, ["no scale numbers"]);
|
||||
assert.deepEqual(resumeUpgrade?.payload?.storyIssues, ["STAR structure is loose", "add measurable impact proof"]);
|
||||
|
||||
const roleplayResult = interviewToOfferReducer.reduce(ctx({
|
||||
source: "roleplay-service",
|
||||
type: "roleplay.feedback.generated",
|
||||
payload: roleplayFeedbackPayload,
|
||||
}));
|
||||
|
||||
const storyArtifact = roleplayResult.artifacts.find((artifact) => artifact.type === "story_bank_update");
|
||||
const communicationDrill = roleplayResult.actions.find((action) => action.payload?.passiveAction === "roleplay_feedback_to_communication_drill");
|
||||
assert.ok(storyArtifact, "roleplay feedback should create a story bank artifact");
|
||||
assert.ok(communicationDrill, "roleplay feedback should create a communication drill passive action");
|
||||
assert.equal(communicationDrill?.serviceId, "interview-service");
|
||||
assert.equal(communicationDrill?.toolName, "interview.configure_practice");
|
||||
assert.deepEqual(communicationDrill?.payload?.storyIssues, ["needs clearer tradeoff story", "tighten STAR story structure"]);
|
||||
|
||||
for (const testCase of reducerCases) {
|
||||
const reducerResumeResult = testCase.reducer.reduce(ctxWithMission(testCase.mission, {
|
||||
source: "resume-builder",
|
||||
type: "resume.analysis.completed",
|
||||
payload: {
|
||||
analysis: {
|
||||
summary: "Strong backend platform project.",
|
||||
strengths: ["Built an event-driven backend"],
|
||||
gaps: ["Add impact metrics"],
|
||||
missing_keywords: ["Kafka", "AWS"],
|
||||
},
|
||||
},
|
||||
}));
|
||||
assert.ok(
|
||||
reducerResumeResult.actions.some((action) => action.payload?.passiveAction === "resume_analysis_to_interview_practice"),
|
||||
`${testCase.name} resume analysis should create an interview practice passive action`,
|
||||
);
|
||||
|
||||
const reducerInterviewResult = testCase.reducer.reduce(ctxWithMission(testCase.mission, {
|
||||
source: "interview-service",
|
||||
type: "interview.feedback.generated",
|
||||
payload: interviewFeedbackPayload,
|
||||
}));
|
||||
assert.ok(
|
||||
reducerInterviewResult.actions.some((action) => action.payload?.passiveAction === "interview_feedback_to_resume_upgrade"),
|
||||
`${testCase.name} interview feedback should create a resume upgrade passive action`,
|
||||
);
|
||||
|
||||
const reducerRoleplayResult = testCase.reducer.reduce(ctxWithMission(testCase.mission, {
|
||||
source: "roleplay-service",
|
||||
type: "roleplay.feedback.generated",
|
||||
payload: roleplayFeedbackPayload,
|
||||
}));
|
||||
assert.ok(
|
||||
reducerRoleplayResult.actions.some((action) => action.payload?.passiveAction === "roleplay_feedback_to_communication_drill"),
|
||||
`${testCase.name} roleplay feedback should create a communication drill passive action`,
|
||||
);
|
||||
assert.ok(
|
||||
reducerRoleplayResult.artifacts.some((artifact) => artifact.type === "story_bank_update"),
|
||||
`${testCase.name} roleplay feedback should create a story bank update artifact`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("passive-actions tests passed");
|
||||
process.exit(0);
|
||||
@@ -31,8 +31,9 @@ const rawFeedSchema = z.object({
|
||||
notifications: z.array(rawNotificationSchema).min(1).max(24),
|
||||
});
|
||||
|
||||
const HOME_FEED_AGENT_TIMEOUT_MS = Number(process.env.HOME_FEED_AGENT_TIMEOUT_MS ?? 90000);
|
||||
const HOME_FEED_AGENT_ATTEMPTS = Math.max(1, Number(process.env.HOME_FEED_AGENT_ATTEMPTS ?? 2));
|
||||
const HOME_FEED_AGENT_ENABLED = process.env.HOME_FEED_AGENT_ENABLED === "true";
|
||||
const HOME_FEED_AGENT_TIMEOUT_MS = Number(process.env.HOME_FEED_AGENT_TIMEOUT_MS ?? 5000);
|
||||
const HOME_FEED_AGENT_ATTEMPTS = Math.max(1, Number(process.env.HOME_FEED_AGENT_ATTEMPTS ?? 1));
|
||||
|
||||
export type AgentHomeNotification = z.infer<typeof notificationSchema>;
|
||||
|
||||
@@ -50,7 +51,6 @@ Every notification must point to one of these real dashboard routes:
|
||||
- /agents/resume for resume building, resume analysis, ATS, resume suggestions
|
||||
- /agents/interview for mock interview setup, interview session, interview review
|
||||
- /agents/roleplay for recruiter/manager/salary/stakeholder roleplay
|
||||
- /agents/qscore for Q Score/readiness explanations
|
||||
- /missions for mission progress, approvals, artifacts, next stages
|
||||
- /agents/social-branding for LinkedIn/social branding
|
||||
- /agents/matchmaking for Scout/opportunity matching
|
||||
@@ -58,7 +58,7 @@ Every notification must point to one of these real dashboard routes:
|
||||
- /suggestions for broad onboarding/profile suggestions
|
||||
Every notification object must include:
|
||||
- moduleId: one of ${MODULE_IDS.join(", ")}
|
||||
- source: one of resume, interview, roleplay, qscore, mission, social, pathways, rewards, system
|
||||
- source: one of resume, interview, roleplay, mission, social, pathways, rewards, system
|
||||
Use minimal iPhone-notification copy: title <= 72 chars, subtitle <= 110 chars, short tag <= 14 chars.
|
||||
Use urgency truthfully: now = needs immediate user action, today = useful today, soon = next few days, calm = informational.`;
|
||||
|
||||
@@ -79,8 +79,7 @@ function sourceFromHref(href: string) {
|
||||
if (href.startsWith("/agents/resume")) return "resume";
|
||||
if (href.startsWith("/agents/interview")) return "interview";
|
||||
if (href.startsWith("/agents/roleplay")) return "roleplay";
|
||||
if (href.startsWith("/agents/qscore")) return "qscore";
|
||||
if (href.startsWith("/missions")) return "mission";
|
||||
if (href.startsWith("/missions")) return "mission";
|
||||
if (href.startsWith("/agents/social-branding")) return "social";
|
||||
if (href.startsWith("/agents/matchmaking")) return "pathways";
|
||||
if (href.startsWith("/rewards")) return "rewards";
|
||||
@@ -97,7 +96,6 @@ function moduleFromSource(source: NonNullable<AgentHomeNotification["source"]>):
|
||||
}
|
||||
|
||||
function tagFromSource(source: NonNullable<AgentHomeNotification["source"]>) {
|
||||
if (source === "qscore") return "Q Score";
|
||||
if (source === "mission") return "Mission";
|
||||
if (source === "roleplay") return "Roleplay";
|
||||
if (source === "interview") return "Interview";
|
||||
@@ -112,7 +110,6 @@ function defaultHrefForSource(source: NonNullable<AgentHomeNotification["source"
|
||||
if (source === "resume") return "/agents/resume";
|
||||
if (source === "interview") return "/agents/interview";
|
||||
if (source === "roleplay") return "/agents/roleplay";
|
||||
if (source === "qscore") return "/agents/qscore";
|
||||
if (source === "mission") return "/missions";
|
||||
if (source === "social") return "/agents/social-branding";
|
||||
if (source === "pathways") return "/agents/matchmaking";
|
||||
@@ -127,10 +124,12 @@ function normalizeAgentNotification(
|
||||
const seed = (raw.href ? seeds.find((item) => item.href === raw.href) : undefined)
|
||||
?? seeds.find((item) => item.title.toLowerCase() === raw.title.toLowerCase());
|
||||
const inferredSource = raw.source ?? seed?.source;
|
||||
const moduleId = raw.moduleId ?? seed?.moduleId ?? (inferredSource ? moduleFromSource(inferredSource) : "suggestions");
|
||||
const rawHref = raw.href ?? seed?.href ?? (inferredSource ? defaultHrefForSource(inferredSource, moduleId) : `/${moduleId}`);
|
||||
const normalizedSource = inferredSource === "qscore" ? "pathways" : inferredSource;
|
||||
const moduleId = raw.moduleId ?? seed?.moduleId ?? (normalizedSource ? moduleFromSource(normalizedSource) : "suggestions");
|
||||
const rawHref = raw.href ?? seed?.href ?? (normalizedSource ? defaultHrefForSource(normalizedSource, moduleId) : `/${moduleId}`);
|
||||
const href = sanitizeHref(rawHref, moduleId);
|
||||
const source = raw.source ?? seed?.source ?? sourceFromHref(href);
|
||||
const seedSource = seed?.source === "qscore" ? "pathways" : seed?.source;
|
||||
const source = raw.source ?? seedSource ?? sourceFromHref(href);
|
||||
return notificationSchema.parse({
|
||||
...raw,
|
||||
tag: raw.tag ?? seed?.tag ?? tagFromSource(source),
|
||||
@@ -157,7 +156,8 @@ function completeNotificationsWithSeeds(
|
||||
|
||||
for (const seed of seeds) {
|
||||
if (completed.length >= 6) break;
|
||||
const candidate = normalizeAgentNotification(seed, seeds);
|
||||
const candidateSeed = seed.source === "qscore" ? { ...seed, source: "pathways" as const, href: seed.href?.startsWith("/agents/qscore") ? "/agents/matchmaking" : seed.href } : seed;
|
||||
const candidate = normalizeAgentNotification(candidateSeed, seeds);
|
||||
const key = notificationKey(candidate);
|
||||
if (seen.has(key)) continue;
|
||||
completed.push(candidate);
|
||||
@@ -184,6 +184,7 @@ export async function refineHomeNotificationsWithAgent(input: {
|
||||
context: Record<string, unknown>;
|
||||
seeds: Array<Omit<HomeNotification, "id" | "createdAt"> & { moduleId: HomeModuleId }>;
|
||||
}): Promise<Array<AgentHomeNotification & { id: string; createdAt: string }>> {
|
||||
if (!HOME_FEED_AGENT_ENABLED) return [];
|
||||
if (!config.llmApiKey && config.nodeEnv === "production") {
|
||||
throw new HomeFeedAgentError("home_feed_agent_missing_llm_api_key");
|
||||
}
|
||||
|
||||
@@ -35,14 +35,13 @@ import {
|
||||
|
||||
const FRESH_MS = 10 * 60 * 1000;
|
||||
const EXPIRY_MS = 24 * 60 * 60 * 1000;
|
||||
const MISSION_MODULE_LIMIT = 3;
|
||||
|
||||
const SERVICE_HREFS = {
|
||||
resume: buildServiceLink("resume-service", "workspace") ?? "/agents/resume",
|
||||
interview: buildServiceLink("interview-service", "discovery") ?? "/agents/interview",
|
||||
roleplay: buildServiceLink("roleplay-service", "discovery") ?? "/agents/roleplay",
|
||||
qscore: buildServiceLink("qscore-service", "dashboard") ?? "/agents/qscore",
|
||||
mission: "/missions/active",
|
||||
social: buildServiceLink("social-branding-service", "profile") ?? "/agents/social-branding",
|
||||
pathways: buildServiceLink("matchmaking-service", "jobs") ?? "/agents/matchmaking",
|
||||
rewards: "/rewards",
|
||||
suggestions: "/suggestions",
|
||||
@@ -101,10 +100,10 @@ function profileFromPreferences(preferences: Record<string, unknown>) {
|
||||
};
|
||||
}
|
||||
|
||||
function serviceHref(service: "resume" | "interview" | "roleplay" | "qscore", ctx: HomeContext, mission?: { instanceId?: string; missionId?: string; stageId?: string | null }) {
|
||||
function serviceHref(service: "resume" | "interview" | "roleplay", ctx: HomeContext, mission?: { instanceId?: string; missionId?: string; stageId?: string | null }) {
|
||||
const profile = profileFromPreferences(ctx.preferences);
|
||||
const serviceId = service === "qscore" ? "qscore-service" : `${service}-service`;
|
||||
const pageId = service === "resume" ? "workspace" : service === "qscore" ? "dashboard" : "setup";
|
||||
const serviceId = `${service}-service`;
|
||||
const pageId = service === "resume" ? "workspace" : "setup";
|
||||
return buildServiceLink(serviceId, pageId, {
|
||||
source: "home",
|
||||
missionInstanceId: mission?.instanceId,
|
||||
@@ -126,7 +125,7 @@ function sourceFromSuggestionRole(role: string): HomeSource {
|
||||
if (value.includes("resume")) return "resume";
|
||||
if (value.includes("roleplay")) return "roleplay";
|
||||
if (value.includes("interview")) return "interview";
|
||||
if (value.includes("q")) return "qscore";
|
||||
if (value.includes("match") || value.includes("pathway") || value.includes("job")) return "pathways";
|
||||
return "mission";
|
||||
}
|
||||
|
||||
@@ -193,9 +192,8 @@ function buildDayOneSeeds(): SeedNotification[] {
|
||||
{ id: "resume-service", moduleId: "productivity" as const, href: SERVICE_HREFS.resume, source: "resume" as const, urgency: "today" as const },
|
||||
{ id: "interview-service", moduleId: "productivity" as const, href: SERVICE_HREFS.interview, source: "interview" as const, urgency: "today" as const },
|
||||
{ id: "roleplay-service", moduleId: "productivity" as const, href: SERVICE_HREFS.roleplay, source: "roleplay" as const, urgency: "soon" as const },
|
||||
{ id: "qscore-service", moduleId: "suggestions" as const, href: SERVICE_HREFS.qscore, source: "qscore" as const, urgency: "now" as const },
|
||||
{ id: "social-branding-service", moduleId: "social" as const, href: SERVICE_HREFS.social, source: "social" as const, urgency: "soon" as const },
|
||||
{ id: "matchmaking-service", moduleId: "pathways" as const, href: SERVICE_HREFS.pathways, source: "pathways" as const, urgency: "calm" as const },
|
||||
{ id: "courses-service", moduleId: "productivity" as const, href: SERVICE_HREFS.productivity, source: "system" as const, urgency: "soon" as const },
|
||||
{ id: "matchmaking-service", moduleId: "pathways" as const, href: SERVICE_HREFS.pathways, source: "pathways" as const, urgency: "today" as const },
|
||||
];
|
||||
|
||||
for (const [index, card] of serviceCards.entries()) {
|
||||
@@ -233,7 +231,14 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
const roleplayReview = serviceEvent(ctx, "roleplay.", "review");
|
||||
const resumeAnalysis = serviceEvent(ctx, "resume.", "analysis");
|
||||
|
||||
for (const suggestion of ctx.missionSuggestions.slice(0, 5)) {
|
||||
const visibleMissionSuggestions = ctx.missionSuggestions
|
||||
.filter((suggestion) => {
|
||||
const haystack = `${suggestion.role} ${suggestion.title} ${suggestion.body} ${suggestion.ctaLabel} ${suggestion.ctaHref}`.toLowerCase();
|
||||
return !haystack.includes("qscore") && !haystack.includes("q score") && !haystack.includes("assessment") && !haystack.includes("/agents/qscore");
|
||||
})
|
||||
.slice(0, 5);
|
||||
|
||||
for (const suggestion of visibleMissionSuggestions) {
|
||||
const mission = ctx.activeMissions.find((item) => item.instanceId === suggestion.missionInstanceId);
|
||||
const source = sourceFromSuggestionRole(suggestion.role);
|
||||
const href = sanitizeHref(suggestion.ctaHref, mission ? `/missions/active?missionInstanceId=${encodeURIComponent(mission.instanceId)}` : SERVICE_HREFS.mission);
|
||||
@@ -276,13 +281,13 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
|
||||
if (ctx.qscore || ctx.qscoreSignals.length) {
|
||||
pushSeed(seeds, {
|
||||
moduleId: "suggestions",
|
||||
title: qscore >= 80 ? "Protect your Q Score momentum" : "Raise your Q Score next",
|
||||
subtitle: qscore >= 80 ? `Readiness is trending at ${qscore}. Keep one proof action moving for ${profile.targetRole}.` : `Current estimate is ${qscore || 64}. Resume + mock practice are fastest for ${profile.targetRole}.`,
|
||||
tag: "Q Score",
|
||||
moduleId: "pathways",
|
||||
title: qscore >= 80 ? "Review your best job matches" : "Find better-fit job matches",
|
||||
subtitle: qscore >= 80 ? `Your profile signals are strong enough to compare matched roles for ${profile.targetRole}.` : `Use resume and interview signals to surface roles that fit ${profile.targetRole}.`,
|
||||
tag: "Matches",
|
||||
urgency: qscore >= 80 ? "today" : "now",
|
||||
href: serviceHref("qscore", ctx),
|
||||
source: "qscore",
|
||||
href: SERVICE_HREFS.pathways,
|
||||
source: "pathways",
|
||||
priority: 95,
|
||||
});
|
||||
}
|
||||
@@ -300,7 +305,7 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
});
|
||||
}
|
||||
|
||||
for (const mission of ctx.activeMissions.slice(0, 3)) {
|
||||
for (const mission of ctx.activeMissions.slice(0, MISSION_MODULE_LIMIT)) {
|
||||
pushSeed(seeds, {
|
||||
moduleId: "missions",
|
||||
title: `${mission.title} — ${mission.progressPercent}%`,
|
||||
@@ -327,17 +332,6 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
});
|
||||
}
|
||||
|
||||
pushSeed(seeds, {
|
||||
moduleId: "social",
|
||||
title: "Turn proof into LinkedIn updates",
|
||||
subtitle: ctx.artifacts.length ? `${ctx.artifacts.length} artifact${ctx.artifacts.length === 1 ? "" : "s"} can feed headline, featured, or post ideas.` : `Connect LinkedIn and use ${profile.targetRole} proof to improve your profile.`,
|
||||
tag: ctx.artifacts.length ? "Proof" : "Setup",
|
||||
urgency: ctx.artifacts.length ? "today" : "soon",
|
||||
href: SERVICE_HREFS.social,
|
||||
source: "social",
|
||||
priority: 70,
|
||||
});
|
||||
|
||||
if (resumeAnalysis || resumeSession || ats !== undefined) {
|
||||
pushSeed(seeds, {
|
||||
moduleId: "productivity",
|
||||
@@ -381,7 +375,7 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
}
|
||||
|
||||
if (!ctx.activeMissions.length) {
|
||||
pushSeed(seeds, { moduleId: "missions", title: "Start Interview-to-Offer", subtitle: `Bundle resume fit, mock practice, and Q Score deltas for ${profile.targetRole}.`, tag: "Begin", urgency: "today", href: "/missions/available", source: "mission", priority: 80 });
|
||||
pushSeed(seeds, { moduleId: "missions", title: "Start Interview-to-Offer", subtitle: `Bundle resume fit, mock practice, and matched roles for ${profile.targetRole}.`, tag: "Begin", urgency: "today", href: "/missions/available", source: "mission", priority: 80 });
|
||||
}
|
||||
|
||||
return seeds;
|
||||
@@ -399,7 +393,7 @@ async function collectContext(userId: string, input: { userProfile?: Record<stri
|
||||
const activeMissions = await db
|
||||
.select({ instanceId: growActiveMissions.instanceId, missionId: growActiveMissions.missionId, title: growActiveMissions.title, status: growActiveMissions.status, progressPercent: growActiveMissions.progressPercent, currentStageId: growActiveMissions.currentStageId, updatedAt: growActiveMissions.updatedAt })
|
||||
.from(growActiveMissions)
|
||||
.where(eq(growActiveMissions.userId, userId))
|
||||
.where(and(eq(growActiveMissions.userId, userId), eq(growActiveMissions.status, "active")))
|
||||
.orderBy(desc(growActiveMissions.updatedAt))
|
||||
.limit(6);
|
||||
const suggestions = await db
|
||||
@@ -501,7 +495,6 @@ function hasLegacyMockSeed(rows: GrowHomeNotificationRow[]) {
|
||||
"Complete your QX self-check",
|
||||
"Create your interview room",
|
||||
"Browse 1 career pathway",
|
||||
"Start with your Q Score",
|
||||
"Explore Interview-to-Offer",
|
||||
"Pathways are warming up",
|
||||
"Open Resume Builder",
|
||||
@@ -557,8 +550,7 @@ function moduleCount(moduleId: HomeModuleId, notifications: HomeNotification[],
|
||||
return String(notifications.length);
|
||||
}
|
||||
if (moduleId === "missions") {
|
||||
if (ctx.activeMissions.length) return `${ctx.activeMissions.length} active`;
|
||||
return mode === "day1" ? "0" : String(notifications.length);
|
||||
return mode === "day1" && notifications.length === 0 ? "0" : String(Math.min(notifications.length, MISSION_MODULE_LIMIT));
|
||||
}
|
||||
if (moduleId === "productivity") {
|
||||
const active = ctx.sessions.filter((s) => s.status === "active" || s.status === "configured" || s.status === "processing").length;
|
||||
@@ -580,10 +572,11 @@ function buildModules(rows: GrowHomeNotificationRow[], ctx: HomeContext, mode: H
|
||||
|
||||
return MODULE_IDS.map((moduleId) => {
|
||||
const notifications = byModule.get(moduleId) ?? [];
|
||||
const visibleNotifications = moduleId === "missions" ? notifications.slice(0, MISSION_MODULE_LIMIT) : notifications;
|
||||
return {
|
||||
...MODULE_META[moduleId],
|
||||
count: moduleCount(moduleId, notifications, ctx, mode),
|
||||
notifications,
|
||||
count: moduleCount(moduleId, visibleNotifications, ctx, mode),
|
||||
notifications: visibleNotifications,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
|
||||
import {
|
||||
actionForAgent,
|
||||
extractResumeSignals,
|
||||
extractWeakAreas,
|
||||
isFeedbackEvent,
|
||||
isInterviewEvent,
|
||||
isRelevantServiceEvent,
|
||||
isResumeEvent,
|
||||
isRoleplayEvent,
|
||||
missionExplicitlyMatches,
|
||||
passiveInterviewFeedbackResumeUpgrade,
|
||||
passiveResumeAnalysisInterviewPractice,
|
||||
passiveRoleplayFeedbackStoryBank,
|
||||
serviceHref,
|
||||
} from "../reducer-helpers.js";
|
||||
import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js";
|
||||
|
||||
export const careerTransitionReducer: MissionReducer = {
|
||||
missionId: "career-transition",
|
||||
@@ -62,14 +48,6 @@ export const careerTransitionReducer: MissionReducer = {
|
||||
priority: 95,
|
||||
urgency: "today",
|
||||
}));
|
||||
actions.push(passiveResumeAnalysisInterviewPractice({
|
||||
missionId: "career-transition",
|
||||
activeMission,
|
||||
eventId: event.id,
|
||||
payload,
|
||||
stageId: "interview",
|
||||
priority: 98,
|
||||
}));
|
||||
eventMessage = "Transferable skills map created; repositioned resume action is ready.";
|
||||
}
|
||||
|
||||
@@ -78,7 +56,7 @@ export const careerTransitionReducer: MissionReducer = {
|
||||
eventMessage = "Adjacent-role interview practice started.";
|
||||
}
|
||||
|
||||
if (isInterviewEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
if (isInterviewEvent(event.source, type) && type.includes("review_completed")) {
|
||||
const weakAreas = extractWeakAreas(payload);
|
||||
stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Adjacent-role credibility checked." });
|
||||
stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Practice the 'why I am switching' narrative next." });
|
||||
@@ -96,30 +74,12 @@ export const careerTransitionReducer: MissionReducer = {
|
||||
priority: 92,
|
||||
urgency: "today",
|
||||
}));
|
||||
actions.push(passiveInterviewFeedbackResumeUpgrade({
|
||||
missionId: "career-transition",
|
||||
activeMission,
|
||||
eventId: event.id,
|
||||
payload,
|
||||
stageId: "resume",
|
||||
priority: 104,
|
||||
}));
|
||||
eventMessage = "Career transition interview feedback produced the next pitch-practice action.";
|
||||
}
|
||||
|
||||
if (isRoleplayEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
const passive = passiveRoleplayFeedbackStoryBank({
|
||||
missionId: "career-transition",
|
||||
activeMission,
|
||||
eventId: event.id,
|
||||
payload,
|
||||
stageId: "roleplay",
|
||||
priority: 94,
|
||||
});
|
||||
if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) {
|
||||
stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Transition pitch practice reviewed." });
|
||||
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 70, outputSummary: "Transition confidence signals updated." });
|
||||
artifacts.push(passive.artifact);
|
||||
actions.push(passive.action);
|
||||
eventMessage = "Transition narrative practice completed.";
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,8 @@ import { getString } from "../../events/envelope.js";
|
||||
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
|
||||
import {
|
||||
actionForAgent,
|
||||
extractMissingProof,
|
||||
extractOverallScore,
|
||||
extractResumeProofPoints,
|
||||
extractResumeSignals,
|
||||
extractStoryIssues,
|
||||
extractWeakAreas,
|
||||
isInterviewEvent,
|
||||
isQscoreEvent,
|
||||
@@ -30,10 +27,6 @@ function reviewSummary(payload: Record<string, unknown>) {
|
||||
return summary ?? "Mock interview review completed.";
|
||||
}
|
||||
|
||||
function isFeedbackEvent(type: string) {
|
||||
return type.includes("review_completed") || type.includes("review.completed") || type.includes("feedback.generated");
|
||||
}
|
||||
|
||||
export const interviewToOfferReducer: MissionReducer = {
|
||||
missionId: "interview-to-offer",
|
||||
accepts: acceptsMission,
|
||||
@@ -54,7 +47,6 @@ export const interviewToOfferReducer: MissionReducer = {
|
||||
|
||||
if (isResumeEvent(event.source, type) && (type.includes("analysis_completed") || type.includes("analysis.complete") || type.includes("analyzed"))) {
|
||||
const signals = extractResumeSignals(payload);
|
||||
const proofPoints = extractResumeProofPoints(payload);
|
||||
stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Resume talking points and fit scan are ready." });
|
||||
stagePatches.push({ stageId: "interview", status: "ready", progressPercent: 0 });
|
||||
artifacts.push({
|
||||
@@ -77,29 +69,6 @@ export const interviewToOfferReducer: MissionReducer = {
|
||||
priority: 92,
|
||||
urgency: "today",
|
||||
}));
|
||||
actions.push(actionForAgent("interview-to-offer", "interview", {
|
||||
stageId: "interview",
|
||||
serviceId: "interview-service",
|
||||
toolName: "interview.configure_practice",
|
||||
mode: "suggestion",
|
||||
title: "Practice explaining your strongest resume proof",
|
||||
body: proofPoints.strengths.length
|
||||
? `Run a mock focused on ${proofPoints.strengths.slice(0, 2).join(" and ")} so your resume turns into interview-ready stories.`
|
||||
: "Run a resume-led mock interview so your strongest proof turns into interview-ready stories.",
|
||||
payload: {
|
||||
passiveAction: "resume_analysis_to_interview_practice",
|
||||
resumeSignals: signals,
|
||||
proofPoints,
|
||||
prompt: proofPoints.strengths[0]
|
||||
? `Practice explaining ${proofPoints.strengths[0]} with clear ownership, impact, and tradeoffs.`
|
||||
: "Practice explaining your strongest resume project with clear ownership, impact, and tradeoffs.",
|
||||
href: serviceHref("interview", activeMission.instanceId, activeMission.missionId, "interview"),
|
||||
},
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:resume-analysis:proof-interview:${event.id}`,
|
||||
priority: 98,
|
||||
urgency: "today",
|
||||
}));
|
||||
eventMessage = "Resume fit scan completed; mock interview is ready to run.";
|
||||
}
|
||||
|
||||
@@ -113,10 +82,8 @@ export const interviewToOfferReducer: MissionReducer = {
|
||||
eventMessage = "Mock interview completed; waiting for review.";
|
||||
}
|
||||
|
||||
if (isInterviewEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
if (isInterviewEvent(event.source, type) && (type.includes("review_completed") || type.includes("review.completed"))) {
|
||||
const weakAreas = extractWeakAreas(payload);
|
||||
const missingProof = extractMissingProof(payload);
|
||||
const storyIssues = extractStoryIssues(payload);
|
||||
stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: reviewSummary(payload) });
|
||||
stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Practice the communication gaps surfaced by interview feedback." });
|
||||
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 60, outputSummary: "Readiness signals updated from interview review." });
|
||||
@@ -132,27 +99,14 @@ export const interviewToOfferReducer: MissionReducer = {
|
||||
serviceId: "resume-service",
|
||||
toolName: "resume.create_version_prompt_draft",
|
||||
mode: "approval_required",
|
||||
title: "Draft stronger resume bullets from interview feedback?",
|
||||
body: [...weakAreas, ...missingProof, ...storyIssues].length
|
||||
? `Approve a Resume Agent draft that fixes ${[...weakAreas, ...missingProof, ...storyIssues].slice(0, 3).join(", ")} with stronger bullets and proof.`
|
||||
: "Approve a Resume Agent draft that turns the interview feedback into stronger bullets and talking points.",
|
||||
prompt: "Create a resume upgrade draft from this interview feedback. Focus on measurable impact, ownership, missing proof, and reusable talking points.",
|
||||
payload: {
|
||||
passiveAction: "interview_feedback_to_resume_upgrade",
|
||||
weakAreas,
|
||||
missingProof,
|
||||
storyIssues,
|
||||
sourceReviewEventId: event.id,
|
||||
draftInstructions: [
|
||||
"Rewrite weak bullets with action, scope, metric, and result.",
|
||||
"Add proof for any interview gaps that lacked evidence.",
|
||||
"Create talking points that match the feedback themes.",
|
||||
],
|
||||
href: serviceHref("resume", activeMission.instanceId, activeMission.missionId, "resume"),
|
||||
},
|
||||
title: "Create a tailored resume version from this interview feedback?",
|
||||
body: weakAreas.length
|
||||
? `The interview exposed ${weakAreas.slice(0, 3).join(", ")}. Approve a Resume Agent draft that turns this feedback into targeted bullets and talking points.`
|
||||
: "Approve a Resume Agent draft that turns the interview feedback into targeted bullets and talking points.",
|
||||
payload: { weakAreas, sourceReviewEventId: event.id, href: serviceHref("resume", activeMission.instanceId, activeMission.missionId, "resume") },
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:interview-review:tailor-resume:${event.id}`,
|
||||
priority: 108,
|
||||
priority: 100,
|
||||
urgency: "now",
|
||||
}));
|
||||
if (weakAreas.some((area) => /communication|story|clarity|confidence|concise/i.test(area))) {
|
||||
@@ -173,45 +127,9 @@ export const interviewToOfferReducer: MissionReducer = {
|
||||
eventMessage = "Interview review completed; resume and roleplay next actions were created.";
|
||||
}
|
||||
|
||||
if (isRoleplayEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
const weakAreas = extractWeakAreas(payload);
|
||||
const storyIssues = extractStoryIssues(payload);
|
||||
if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) {
|
||||
stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Communication drill reviewed." });
|
||||
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 75, outputSummary: "Communication readiness updated." });
|
||||
artifacts.push({
|
||||
type: "story_bank_update",
|
||||
title: "Story bank updates from roleplay feedback",
|
||||
stageId: "roleplay",
|
||||
summary: [...weakAreas, ...storyIssues].length
|
||||
? `Turn these into reusable STAR stories: ${[...weakAreas, ...storyIssues].slice(0, 5).join(", ")}`
|
||||
: "Roleplay feedback captured story bank improvements for future interviews.",
|
||||
metadata: { sourceEventId: event.id, weakAreas, storyIssues, payload },
|
||||
});
|
||||
actions.push(actionForAgent("interview-to-offer", "interview", {
|
||||
stageId: "interview",
|
||||
serviceId: "interview-service",
|
||||
toolName: "interview.configure_practice",
|
||||
mode: "suggestion",
|
||||
title: "Run a story-bank recovery mock",
|
||||
body: [...weakAreas, ...storyIssues].length
|
||||
? `Practice the communication gaps from roleplay: ${[...weakAreas, ...storyIssues].slice(0, 3).join(", ")}.`
|
||||
: "Run a targeted mock interview that converts roleplay feedback into reusable STAR stories.",
|
||||
payload: {
|
||||
passiveAction: "roleplay_feedback_to_communication_drill",
|
||||
weakAreas,
|
||||
storyIssues,
|
||||
storyBankInstructions: [
|
||||
"Convert each weak communication moment into a STAR story prompt.",
|
||||
"Practice the answer in an interview setting.",
|
||||
"Save the strongest version as reusable story-bank material.",
|
||||
],
|
||||
href: serviceHref("interview", activeMission.instanceId, activeMission.missionId, "interview"),
|
||||
},
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:roleplay-review:story-bank-interview:${event.id}`,
|
||||
priority: 96,
|
||||
urgency: "today",
|
||||
}));
|
||||
eventMessage = "Roleplay review improved interview communication readiness.";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
|
||||
import {
|
||||
actionForAgent,
|
||||
extractResumeSignals,
|
||||
extractWeakAreas,
|
||||
isFeedbackEvent,
|
||||
isInterviewEvent,
|
||||
isRelevantServiceEvent,
|
||||
isResumeEvent,
|
||||
isRoleplayEvent,
|
||||
missionDetailHref,
|
||||
missionExplicitlyMatches,
|
||||
passiveInterviewFeedbackResumeUpgrade,
|
||||
passiveResumeAnalysisInterviewPractice,
|
||||
passiveRoleplayFeedbackStoryBank,
|
||||
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",
|
||||
@@ -63,31 +48,14 @@ export const personalBrandOpportunityReducer: MissionReducer = {
|
||||
priority: 92,
|
||||
urgency: "today",
|
||||
}));
|
||||
actions.push(passiveResumeAnalysisInterviewPractice({
|
||||
missionId: "personal-brand-opportunity-engine",
|
||||
activeMission,
|
||||
eventId: event.id,
|
||||
payload,
|
||||
stageId: "interview",
|
||||
priority: 90,
|
||||
}));
|
||||
eventMessage = "Resume proof points created a profile positioning action.";
|
||||
}
|
||||
|
||||
if (isRoleplayEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) {
|
||||
const weakAreas = extractWeakAreas(payload);
|
||||
const passive = passiveRoleplayFeedbackStoryBank({
|
||||
missionId: "personal-brand-opportunity-engine",
|
||||
activeMission,
|
||||
eventId: event.id,
|
||||
payload,
|
||||
stageId: "roleplay",
|
||||
priority: 92,
|
||||
});
|
||||
stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Networking pitch reviewed." });
|
||||
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 70, outputSummary: "Brand voice/readiness signals updated." });
|
||||
artifacts.push({ type: "networking_scripts", title: "Networking script improvements", stageId: "roleplay", summary: weakAreas.length ? `Improve: ${weakAreas.join(", ")}` : "Networking pitch practice completed.", metadata: { sourceEventId: event.id, weakAreas } });
|
||||
artifacts.push(passive.artifact);
|
||||
actions.push(actionForAgent("personal-brand-opportunity-engine", "planner", {
|
||||
stageId: "positioning",
|
||||
mode: "suggestion",
|
||||
@@ -99,22 +67,13 @@ export const personalBrandOpportunityReducer: MissionReducer = {
|
||||
priority: 82,
|
||||
urgency: "soon",
|
||||
}));
|
||||
actions.push(passive.action);
|
||||
eventMessage = "Networking pitch review created brand content next steps.";
|
||||
}
|
||||
|
||||
if (isInterviewEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
if (isInterviewEvent(event.source, type) && type.includes("review_completed")) {
|
||||
const weakAreas = extractWeakAreas(payload);
|
||||
stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Credibility signals mined from interview review." });
|
||||
artifacts.push({ type: "credibility_signal_map", title: "Credibility signal map", stageId: "interview", summary: weakAreas.length ? `Recurring gaps/themes: ${weakAreas.join(", ")}` : "Interview review mined for positioning signals.", metadata: { sourceEventId: event.id, weakAreas } });
|
||||
actions.push(passiveInterviewFeedbackResumeUpgrade({
|
||||
missionId: "personal-brand-opportunity-engine",
|
||||
activeMission,
|
||||
eventId: event.id,
|
||||
payload,
|
||||
stageId: "resume",
|
||||
priority: 98,
|
||||
}));
|
||||
eventMessage = "Interview feedback was mined for brand positioning signals.";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
|
||||
import {
|
||||
actionForAgent,
|
||||
extractResumeSignals,
|
||||
extractWeakAreas,
|
||||
isFeedbackEvent,
|
||||
isInterviewEvent,
|
||||
isRelevantServiceEvent,
|
||||
isResumeEvent,
|
||||
isRoleplayEvent,
|
||||
missionExplicitlyMatches,
|
||||
passiveInterviewFeedbackResumeUpgrade,
|
||||
passiveResumeAnalysisInterviewPractice,
|
||||
passiveRoleplayFeedbackStoryBank,
|
||||
serviceHref,
|
||||
} from "../reducer-helpers.js";
|
||||
import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js";
|
||||
|
||||
export const promotionReadinessReducer: MissionReducer = {
|
||||
missionId: "promotion-readiness",
|
||||
@@ -62,31 +48,14 @@ export const promotionReadinessReducer: MissionReducer = {
|
||||
priority: 94,
|
||||
urgency: "today",
|
||||
}));
|
||||
actions.push(passiveResumeAnalysisInterviewPractice({
|
||||
missionId: "promotion-readiness",
|
||||
activeMission,
|
||||
eventId: event.id,
|
||||
payload,
|
||||
stageId: "interview",
|
||||
priority: 91,
|
||||
}));
|
||||
eventMessage = "Promotion evidence packet is ready; manager conversation practice is next.";
|
||||
}
|
||||
|
||||
if (isRoleplayEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) {
|
||||
const weakAreas = extractWeakAreas(payload);
|
||||
const passive = passiveRoleplayFeedbackStoryBank({
|
||||
missionId: "promotion-readiness",
|
||||
activeMission,
|
||||
eventId: event.id,
|
||||
payload,
|
||||
stageId: "roleplay",
|
||||
priority: 95,
|
||||
});
|
||||
stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Manager conversation drill reviewed." });
|
||||
stagePatches.push({ stageId: "interview", status: "ready", progressPercent: 0, outputSummary: "Practice leadership narratives next if gaps remain." });
|
||||
artifacts.push({ type: "manager_conversation_script", title: "Manager conversation script", stageId: "roleplay", summary: weakAreas.length ? `Follow-up focus: ${weakAreas.join(", ")}` : "Manager conversation review completed.", metadata: { sourceEventId: event.id, weakAreas } });
|
||||
artifacts.push(passive.artifact);
|
||||
actions.push(actionForAgent("promotion-readiness", "interview", {
|
||||
stageId: "interview",
|
||||
serviceId: "interview-service",
|
||||
@@ -100,23 +69,14 @@ export const promotionReadinessReducer: MissionReducer = {
|
||||
priority: 86,
|
||||
urgency: "soon",
|
||||
}));
|
||||
actions.push(passive.action);
|
||||
eventMessage = "Manager conversation review updated promotion readiness.";
|
||||
}
|
||||
|
||||
if (isInterviewEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
if (isInterviewEvent(event.source, type) && type.includes("review_completed")) {
|
||||
const weakAreas = extractWeakAreas(payload);
|
||||
stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Leadership communication gap check completed." });
|
||||
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 75, outputSummary: "Leadership readiness signals updated." });
|
||||
artifacts.push({ type: "leadership_gap_map", title: "Leadership gap map", stageId: "interview", summary: weakAreas.length ? weakAreas.join(", ") : "Leadership practice review completed.", metadata: { sourceEventId: event.id, weakAreas } });
|
||||
actions.push(passiveInterviewFeedbackResumeUpgrade({
|
||||
missionId: "promotion-readiness",
|
||||
activeMission,
|
||||
eventId: event.id,
|
||||
payload,
|
||||
stageId: "resume",
|
||||
priority: 102,
|
||||
}));
|
||||
eventMessage = "Leadership practice review updated the promotion gap map.";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { asRecord, getNumber, getString } from "../events/envelope.js";
|
||||
import { buildServiceLink } from "../services/service-registry.js";
|
||||
import type { GrowActiveMission } from "../actors/missions/types.js";
|
||||
import type { MissionActionPatch, MissionArtifactPatch } from "./reducer-types.js";
|
||||
import type { MissionActionPatch } from "./reducer-types.js";
|
||||
|
||||
export function isResumeEvent(source: string, type: string) {
|
||||
const value = source.toLowerCase();
|
||||
@@ -23,10 +22,6 @@ export function isQscoreEvent(source: string, type: string) {
|
||||
return value.includes("qscore") || type.startsWith("qscore.");
|
||||
}
|
||||
|
||||
export function isFeedbackEvent(type: string) {
|
||||
return type.includes("review_completed") || type.includes("review.completed") || type.includes("feedback.generated");
|
||||
}
|
||||
|
||||
export function reviewRecord(payload: Record<string, unknown>) {
|
||||
return asRecord(payload.review ?? payload.result ?? payload.data ?? payload);
|
||||
}
|
||||
@@ -71,64 +66,6 @@ export function extractWeakAreas(payload: Record<string, unknown>): string[] {
|
||||
return Array.from(new Set(areas)).slice(0, 5);
|
||||
}
|
||||
|
||||
function extractStringListFromKeys(record: Record<string, unknown>, keys: string[]) {
|
||||
const values: string[] = [];
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (typeof item === "string" && item.trim()) values.push(item.trim());
|
||||
else if (item && typeof item === "object" && !Array.isArray(item)) {
|
||||
const row = item as Record<string, unknown>;
|
||||
const text = getString(row.title ?? row.name ?? row.label ?? row.summary ?? row.description ?? row.text);
|
||||
if (text) values.push(text);
|
||||
}
|
||||
}
|
||||
} else if (typeof value === "string" && value.trim()) {
|
||||
values.push(...value.split(/[;\n]/).map((part) => part.trim()).filter(Boolean));
|
||||
}
|
||||
}
|
||||
return Array.from(new Set(values)).slice(0, 8);
|
||||
}
|
||||
|
||||
export function extractMissingProof(payload: Record<string, unknown>): string[] {
|
||||
const review = reviewRecord(payload);
|
||||
return extractStringListFromKeys(review, [
|
||||
"missing_proof",
|
||||
"missingProof",
|
||||
"proof_gaps",
|
||||
"proofGaps",
|
||||
"evidence_gaps",
|
||||
"evidenceGaps",
|
||||
"missing_evidence",
|
||||
"missingEvidence",
|
||||
"gaps",
|
||||
]);
|
||||
}
|
||||
|
||||
export function extractStoryIssues(payload: Record<string, unknown>): string[] {
|
||||
const review = reviewRecord(payload);
|
||||
const values = extractStringListFromKeys(review, [
|
||||
"story_issues",
|
||||
"storyIssues",
|
||||
"story_gaps",
|
||||
"storyGaps",
|
||||
"star_gaps",
|
||||
"starGaps",
|
||||
"communication_gaps",
|
||||
"communicationGaps",
|
||||
"recommendations",
|
||||
]);
|
||||
const summary = getString(review.summary ?? review.feedback_summary ?? review.overall_feedback);
|
||||
if (summary) {
|
||||
const lower = summary.toLowerCase();
|
||||
if (lower.includes("star") || lower.includes("story")) values.push("tighten STAR story structure");
|
||||
if (lower.includes("metric") || lower.includes("impact") || lower.includes("measurable")) values.push("add measurable impact proof");
|
||||
if (lower.includes("ownership")) values.push("clarify ownership and scope");
|
||||
}
|
||||
return Array.from(new Set(values)).slice(0, 8);
|
||||
}
|
||||
|
||||
export function extractResumeSignals(payload: Record<string, unknown>): string[] {
|
||||
const analysis = asRecord(payload.analysis ?? payload.result ?? payload.data ?? payload);
|
||||
const signals: string[] = [];
|
||||
@@ -143,14 +80,6 @@ export function extractResumeSignals(payload: Record<string, unknown>): string[]
|
||||
return signals.slice(0, 8);
|
||||
}
|
||||
|
||||
export function extractResumeProofPoints(payload: Record<string, unknown>) {
|
||||
const analysis = asRecord(payload.analysis ?? payload.result ?? payload.data ?? payload);
|
||||
const strengths = extractStringListFromKeys(analysis, ["strengths", "top_strengths", "strong_projects", "projects", "achievements"]);
|
||||
const gaps = extractStringListFromKeys(analysis, ["gaps", "recommendations", "missing_keywords", "keyword_gaps", "weak_bullets"]);
|
||||
const keywords = extractStringListFromKeys(analysis, ["keywords", "matched_keywords", "missing_keywords", "keyword_gaps"]);
|
||||
return { strengths, gaps, keywords };
|
||||
}
|
||||
|
||||
export function missionExplicitlyMatches(eventMission: unknown, missionId: string) {
|
||||
const mission = asRecord(eventMission);
|
||||
const explicit = getString(mission.missionId ?? mission.mission_id);
|
||||
@@ -215,127 +144,3 @@ export function serviceHref(service: "resume" | "interview" | "roleplay" | "qsco
|
||||
export function missionDetailHref(missionInstanceId: string) {
|
||||
return `/missions/${encodeURIComponent(missionInstanceId)}`;
|
||||
}
|
||||
|
||||
export function passiveResumeAnalysisInterviewPractice(input: {
|
||||
missionId: string;
|
||||
activeMission: GrowActiveMission;
|
||||
eventId: string;
|
||||
payload: Record<string, unknown>;
|
||||
stageId?: string;
|
||||
priority?: number;
|
||||
}): MissionActionPatch {
|
||||
const signals = extractResumeSignals(input.payload);
|
||||
const proofPoints = extractResumeProofPoints(input.payload);
|
||||
return actionForAgent(input.missionId, "interview", {
|
||||
stageId: input.stageId ?? "interview",
|
||||
serviceId: "interview-service",
|
||||
toolName: "interview.configure_practice",
|
||||
mode: "suggestion",
|
||||
title: "Practice explaining your strongest resume proof",
|
||||
body: proofPoints.strengths.length
|
||||
? `Run a mock focused on ${proofPoints.strengths.slice(0, 2).join(" and ")} so your resume turns into interview-ready stories.`
|
||||
: "Run a resume-led mock interview so your strongest proof turns into interview-ready stories.",
|
||||
payload: {
|
||||
passiveAction: "resume_analysis_to_interview_practice",
|
||||
resumeSignals: signals,
|
||||
proofPoints,
|
||||
prompt: proofPoints.strengths[0]
|
||||
? `Practice explaining ${proofPoints.strengths[0]} with clear ownership, impact, and tradeoffs.`
|
||||
: "Practice explaining your strongest resume project with clear ownership, impact, and tradeoffs.",
|
||||
href: serviceHref("interview", input.activeMission.instanceId, input.activeMission.missionId, input.stageId ?? "interview"),
|
||||
},
|
||||
sourceEventId: input.eventId,
|
||||
idempotencyKey: `${input.activeMission.instanceId}:resume-analysis:proof-interview:${input.eventId}`,
|
||||
priority: input.priority ?? 98,
|
||||
urgency: "today",
|
||||
});
|
||||
}
|
||||
|
||||
export function passiveInterviewFeedbackResumeUpgrade(input: {
|
||||
missionId: string;
|
||||
activeMission: GrowActiveMission;
|
||||
eventId: string;
|
||||
payload: Record<string, unknown>;
|
||||
stageId?: string;
|
||||
priority?: number;
|
||||
}): MissionActionPatch {
|
||||
const weakAreas = extractWeakAreas(input.payload);
|
||||
const missingProof = extractMissingProof(input.payload);
|
||||
const storyIssues = extractStoryIssues(input.payload);
|
||||
return actionForAgent(input.missionId, "resume", {
|
||||
stageId: input.stageId ?? "resume",
|
||||
serviceId: "resume-service",
|
||||
toolName: "resume.create_version_prompt_draft",
|
||||
mode: "approval_required",
|
||||
title: "Draft stronger resume bullets from interview feedback?",
|
||||
body: [...weakAreas, ...missingProof, ...storyIssues].length
|
||||
? `Approve a Resume Agent draft that fixes ${[...weakAreas, ...missingProof, ...storyIssues].slice(0, 3).join(", ")} with stronger bullets and proof.`
|
||||
: "Approve a Resume Agent draft that turns the interview feedback into stronger bullets and talking points.",
|
||||
prompt: "Create a resume upgrade draft from this interview feedback. Focus on measurable impact, ownership, missing proof, and reusable talking points.",
|
||||
payload: {
|
||||
passiveAction: "interview_feedback_to_resume_upgrade",
|
||||
weakAreas,
|
||||
missingProof,
|
||||
storyIssues,
|
||||
sourceReviewEventId: input.eventId,
|
||||
draftInstructions: [
|
||||
"Rewrite weak bullets with action, scope, metric, and result.",
|
||||
"Add proof for any interview gaps that lacked evidence.",
|
||||
"Create talking points that match the feedback themes.",
|
||||
],
|
||||
href: serviceHref("resume", input.activeMission.instanceId, input.activeMission.missionId, input.stageId ?? "resume"),
|
||||
},
|
||||
sourceEventId: input.eventId,
|
||||
idempotencyKey: `${input.activeMission.instanceId}:interview-review:tailor-resume:${input.eventId}`,
|
||||
priority: input.priority ?? 108,
|
||||
urgency: "now",
|
||||
});
|
||||
}
|
||||
|
||||
export function passiveRoleplayFeedbackStoryBank(input: {
|
||||
missionId: string;
|
||||
activeMission: GrowActiveMission;
|
||||
eventId: string;
|
||||
payload: Record<string, unknown>;
|
||||
stageId?: string;
|
||||
priority?: number;
|
||||
}): { artifact: MissionArtifactPatch; action: MissionActionPatch } {
|
||||
const weakAreas = extractWeakAreas(input.payload);
|
||||
const storyIssues = extractStoryIssues(input.payload);
|
||||
return {
|
||||
artifact: {
|
||||
type: "story_bank_update",
|
||||
title: "Story bank updates from roleplay feedback",
|
||||
stageId: input.stageId ?? "roleplay",
|
||||
summary: [...weakAreas, ...storyIssues].length
|
||||
? `Turn these into reusable STAR stories: ${[...weakAreas, ...storyIssues].slice(0, 5).join(", ")}`
|
||||
: "Roleplay feedback captured story bank improvements for future interviews.",
|
||||
metadata: { sourceEventId: input.eventId, weakAreas, storyIssues, payload: input.payload },
|
||||
},
|
||||
action: actionForAgent(input.missionId, "interview", {
|
||||
stageId: "interview",
|
||||
serviceId: "interview-service",
|
||||
toolName: "interview.configure_practice",
|
||||
mode: "suggestion",
|
||||
title: "Run a story-bank recovery mock",
|
||||
body: [...weakAreas, ...storyIssues].length
|
||||
? `Practice the communication gaps from roleplay: ${[...weakAreas, ...storyIssues].slice(0, 3).join(", ")}.`
|
||||
: "Run a targeted mock interview that converts roleplay feedback into reusable STAR stories.",
|
||||
payload: {
|
||||
passiveAction: "roleplay_feedback_to_communication_drill",
|
||||
weakAreas,
|
||||
storyIssues,
|
||||
storyBankInstructions: [
|
||||
"Convert each weak communication moment into a STAR story prompt.",
|
||||
"Practice the answer in an interview setting.",
|
||||
"Save the strongest version as reusable story-bank material.",
|
||||
],
|
||||
href: serviceHref("interview", input.activeMission.instanceId, input.activeMission.missionId, "interview"),
|
||||
},
|
||||
sourceEventId: input.eventId,
|
||||
idempotencyKey: `${input.activeMission.instanceId}:roleplay-review:story-bank-interview:${input.eventId}`,
|
||||
priority: input.priority ?? 96,
|
||||
urgency: "today",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
|
||||
import {
|
||||
actionForAgent,
|
||||
extractResumeSignals,
|
||||
extractWeakAreas,
|
||||
isFeedbackEvent,
|
||||
isInterviewEvent,
|
||||
isRelevantServiceEvent,
|
||||
isResumeEvent,
|
||||
isRoleplayEvent,
|
||||
missionExplicitlyMatches,
|
||||
passiveInterviewFeedbackResumeUpgrade,
|
||||
passiveResumeAnalysisInterviewPractice,
|
||||
passiveRoleplayFeedbackStoryBank,
|
||||
serviceHref,
|
||||
} from "../reducer-helpers.js";
|
||||
import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js";
|
||||
|
||||
export const salaryNegotiationReducer: MissionReducer = {
|
||||
missionId: "salary-negotiation-war-room",
|
||||
@@ -62,14 +48,6 @@ export const salaryNegotiationReducer: MissionReducer = {
|
||||
priority: 96,
|
||||
urgency: "today",
|
||||
}));
|
||||
actions.push(passiveResumeAnalysisInterviewPractice({
|
||||
missionId: "salary-negotiation-war-room",
|
||||
activeMission,
|
||||
eventId: event.id,
|
||||
payload,
|
||||
stageId: "interview",
|
||||
priority: 88,
|
||||
}));
|
||||
eventMessage = "Value evidence is ready for negotiation practice.";
|
||||
}
|
||||
|
||||
@@ -78,20 +56,11 @@ export const salaryNegotiationReducer: MissionReducer = {
|
||||
eventMessage = "Negotiation drill started.";
|
||||
}
|
||||
|
||||
if (isRoleplayEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) {
|
||||
const weakAreas = extractWeakAreas(payload);
|
||||
const passive = passiveRoleplayFeedbackStoryBank({
|
||||
missionId: "salary-negotiation-war-room",
|
||||
activeMission,
|
||||
eventId: event.id,
|
||||
payload,
|
||||
stageId: "roleplay",
|
||||
priority: 93,
|
||||
});
|
||||
stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Negotiation drill reviewed." });
|
||||
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 70, outputSummary: "Confidence signals updated." });
|
||||
artifacts.push({ type: "negotiation_objection_map", title: "Objection handling map", stageId: "roleplay", summary: weakAreas.length ? `Practice objections: ${weakAreas.join(", ")}` : "Negotiation practice review completed.", metadata: { sourceEventId: event.id, weakAreas } });
|
||||
artifacts.push(passive.artifact);
|
||||
actions.push(actionForAgent("salary-negotiation-war-room", "roleplay", {
|
||||
stageId: "roleplay",
|
||||
serviceId: "roleplay-service",
|
||||
@@ -105,20 +74,11 @@ export const salaryNegotiationReducer: MissionReducer = {
|
||||
priority: 94,
|
||||
urgency: "today",
|
||||
}));
|
||||
actions.push(passive.action);
|
||||
eventMessage = "Negotiation drill review created the next objection-handling action.";
|
||||
}
|
||||
|
||||
if (isInterviewEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
if (isInterviewEvent(event.source, type) && type.includes("review_completed")) {
|
||||
stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Communication confidence signal captured from interview review." });
|
||||
actions.push(passiveInterviewFeedbackResumeUpgrade({
|
||||
missionId: "salary-negotiation-war-room",
|
||||
activeMission,
|
||||
eventId: event.id,
|
||||
payload,
|
||||
stageId: "resume",
|
||||
priority: 99,
|
||||
}));
|
||||
eventMessage = "Interview feedback updated negotiation confidence signals.";
|
||||
}
|
||||
|
||||
|
||||
@@ -909,6 +909,7 @@ export function serviceRoutes() {
|
||||
taskId: curatorTaskIdFromRequest(c.req.raw, body),
|
||||
externalId: getString(result.task_id ?? result.taskId),
|
||||
},
|
||||
mission: missionFromRequest(c.req.raw, body),
|
||||
}).catch((err) => log.warn({ err, userId, action }, "failed to record matchmaking workflow event"));
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
@@ -131,6 +131,14 @@ function getSessionId(detail?: Record<string, unknown>) {
|
||||
|
||||
const frontendBaseUrl = config.workflowsDashboardUrl.replace(/\/$/, "");
|
||||
|
||||
const allowedServiceIds = new Set<ServiceId>([
|
||||
"interview-service",
|
||||
"matchmaking-service",
|
||||
"roleplay-service",
|
||||
"courses-service",
|
||||
"resume-service",
|
||||
]);
|
||||
|
||||
const serviceRegistry: ServiceRecord[] = [
|
||||
{
|
||||
id: "interview-service",
|
||||
@@ -837,12 +845,12 @@ export function normalizeServiceId(serviceId?: string | null): ServiceId | undef
|
||||
}
|
||||
|
||||
export function listServices() {
|
||||
return serviceRegistry;
|
||||
return serviceRegistry.filter((service) => allowedServiceIds.has(service.id));
|
||||
}
|
||||
|
||||
export function getService(serviceId?: string | null) {
|
||||
const normalized = normalizeServiceId(serviceId);
|
||||
return normalized ? serviceRegistry.find((service) => service.id === normalized) : undefined;
|
||||
return normalized ? listServices().find((service) => service.id === normalized) : undefined;
|
||||
}
|
||||
|
||||
export function getServiceBackend(serviceId?: string | null) {
|
||||
@@ -881,7 +889,7 @@ export function buildServiceLink(serviceId: string | undefined, pageId?: string,
|
||||
}
|
||||
|
||||
export function listServicesForCatalog() {
|
||||
return serviceRegistry.map((service) => ({
|
||||
return listServices().map((service) => ({
|
||||
id: service.id,
|
||||
label: service.label,
|
||||
description: service.description,
|
||||
|
||||
@@ -16,6 +16,29 @@ type CuratorActorState = {
|
||||
lastEventId?: string;
|
||||
};
|
||||
|
||||
|
||||
function addDaysIsoLocal(startDate: string, days: number) {
|
||||
const date = new Date(`${startDate}T00:00:00.000Z`);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
date.setUTCDate(date.getUTCDate() + days);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function dateFromTaskId(taskId: string) {
|
||||
const match = /:icp-v\d+:(\d{4}-\d{2}-\d{2}):day-(\d+):/.exec(taskId);
|
||||
if (!match) return undefined;
|
||||
const startDate = match[1];
|
||||
const daySegment = match[2];
|
||||
if (!startDate || !daySegment) return undefined;
|
||||
const dayIndex = Number(daySegment);
|
||||
if (!Number.isFinite(dayIndex) || dayIndex < 1) return undefined;
|
||||
return addDaysIsoLocal(startDate, dayIndex - 1);
|
||||
}
|
||||
|
||||
function taskActionDate(input: { taskId: string; date?: string }) {
|
||||
return input.date ?? dateFromTaskId(input.taskId) ?? todayIsoDate();
|
||||
}
|
||||
|
||||
function touch(c: { state: CuratorActorState }, input: { userId: string }) {
|
||||
if (c.state.userId && c.state.userId !== input.userId) throw new Error("curatorActor initialized for a different user");
|
||||
c.state.userId = input.userId;
|
||||
@@ -88,9 +111,16 @@ export const curatorService = {
|
||||
},
|
||||
|
||||
async startTask(input: { userId: string; taskId: string; date?: string }) {
|
||||
const date = input.date ?? todayIsoDate();
|
||||
const date = taskActionDate(input);
|
||||
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
|
||||
if (!task) throw new Error("curator_task_not_found");
|
||||
if (!task) {
|
||||
const event = await emitCuratorEvent({
|
||||
userId: input.userId,
|
||||
type: "curator.task.started",
|
||||
payload: { taskId: input.taskId, date, recoveredDeepLink: true },
|
||||
});
|
||||
return { task: { id: input.taskId, date, status: "started" as const }, eventId: event.id };
|
||||
}
|
||||
const event = await emitCuratorEvent({
|
||||
userId: input.userId,
|
||||
type: "curator.task.started",
|
||||
@@ -101,7 +131,7 @@ export const curatorService = {
|
||||
},
|
||||
|
||||
async prepareTaskHandoff(input: { userId: string; taskId: string; date?: string }) {
|
||||
const date = input.date ?? todayIsoDate();
|
||||
const date = taskActionDate(input);
|
||||
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
|
||||
if (!task) throw new Error("curator_task_not_found");
|
||||
if (task.serviceId) return prepareHandoffForTask(input.userId, task, task.serviceId);
|
||||
@@ -109,11 +139,21 @@ export const curatorService = {
|
||||
},
|
||||
|
||||
async completeTask(input: { userId: string; taskId: string; date?: string; reason?: string }) {
|
||||
const date = input.date ?? todayIsoDate();
|
||||
const date = taskActionDate(input);
|
||||
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
|
||||
if (!task) throw new Error("curator_task_not_found");
|
||||
const reason = input.reason ?? "subtasks_completed";
|
||||
const allowDirectServiceCompletion = task.serviceId === "qscore-service" && reason === "qscore_review_opened";
|
||||
if (!task && reason === "matchmaking_matches_reviewed") {
|
||||
const event = await emitCuratorEvent({
|
||||
userId: input.userId,
|
||||
type: "curator.task.completed",
|
||||
payload: { taskId: input.taskId, date, reason, recoveredDeepLink: true },
|
||||
});
|
||||
return { task: { id: input.taskId, date, status: "completed" as const }, eventId: event.id };
|
||||
}
|
||||
if (!task) throw new Error("curator_task_not_found");
|
||||
const allowDirectServiceCompletion =
|
||||
(task.serviceId === "qscore-service" && reason === "qscore_review_opened") ||
|
||||
(task.serviceId === "matchmaking-service" && reason === "matchmaking_matches_reviewed");
|
||||
if (task.serviceId && !allowDirectServiceCompletion) {
|
||||
throw new Error("curator_service_task_requires_service_event");
|
||||
}
|
||||
|
||||
@@ -113,14 +113,17 @@ type ServiceCurationPreviewInput = {
|
||||
};
|
||||
|
||||
const QSCORE_TASK_SERVICE_REPLACEMENTS: Record<CuratorTaskType, CuratorServiceId> = {
|
||||
measurement: "assessment-service",
|
||||
measurement: "matchmaking-service",
|
||||
proof: "resume-service",
|
||||
practice: "interview-service",
|
||||
recovery: "roleplay-service",
|
||||
};
|
||||
|
||||
function curatorAssignableServiceId(serviceId: CuratorServiceId, taskType: CuratorTaskType): CuratorServiceId {
|
||||
return serviceId === "qscore-service" ? QSCORE_TASK_SERVICE_REPLACEMENTS[taskType] : serviceId;
|
||||
if (serviceId === "qscore-service") return QSCORE_TASK_SERVICE_REPLACEMENTS[taskType];
|
||||
if (serviceId === "social-branding-service") return taskType === "practice" ? "roleplay-service" : "resume-service";
|
||||
if (serviceId === "assessment-service") return "matchmaking-service";
|
||||
return serviceId;
|
||||
}
|
||||
|
||||
function qscoreFreeTaskCopy(input: {
|
||||
@@ -159,10 +162,12 @@ function qscoreFreeTaskCopy(input: {
|
||||
}
|
||||
|
||||
return {
|
||||
title: input.title.replace(/Q Score|QScore/gi, "readiness assessment"),
|
||||
subtitle: input.subtitle.replace(/Q Score|QScore/gi, "the assessment service"),
|
||||
cta: "Open assessment",
|
||||
signals: input.signals.map((signal) => signal.replace(/q\s?score/gi, "assessment")),
|
||||
title: input.title.replace(/Q Score|QScore/gi, "job matches"),
|
||||
subtitle: input.subtitle
|
||||
.replace(/Q Score|QScore/gi, "job matching")
|
||||
.replace(/assessment service|assessment/gi, "job matching"),
|
||||
cta: "View matches",
|
||||
signals: input.signals.map((signal) => signal.replace(/q\s?score|assessment/gi, "job matching")),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -877,6 +882,18 @@ function seed(
|
||||
): TaskSeed {
|
||||
const assignedServiceId = curatorAssignableServiceId(serviceId, taskType);
|
||||
const copy = qscoreFreeTaskCopy({ serviceId, taskType, title, subtitle, cta, signals });
|
||||
if (serviceId === "social-branding-service") {
|
||||
return {
|
||||
taskType,
|
||||
serviceId: assignedServiceId,
|
||||
title: copy.title.replace(/social proof|social profile|public credibility|visibility/gi, "resume proof"),
|
||||
subtitle: copy.subtitle.replace(/social proof|social profile|public credibility|visibility|public positioning/gi, "resume proof"),
|
||||
effort,
|
||||
qxImpact,
|
||||
cta: "Open resume workspace",
|
||||
signals: copy.signals.map((signal) => signal.replace(/social|public|visibility/gi, "resume proof")),
|
||||
};
|
||||
}
|
||||
return { taskType, serviceId: assignedServiceId, title: copy.title, subtitle: copy.subtitle, effort, qxImpact, cta: copy.cta, signals: copy.signals };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user