Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d252077135 | ||
|
|
5606f987c5 | ||
|
|
4353e8fe33 | ||
|
|
f826b7b9a3 | ||
|
|
6432734f74 | ||
|
|
0394dfbca0 | ||
|
|
837a36276a | ||
|
|
a43b7dd9dd | ||
|
|
4ec3f57210 | ||
|
|
9df22e01e7 | ||
|
|
35052a0ced | ||
|
|
e1bb3b2b11 | ||
|
|
ac66abec28 | ||
|
|
7d74f81913 | ||
|
|
3bba99e874 | ||
|
|
697040e9d3 | ||
|
|
39ba59ab25 | ||
|
|
10478fb035 | ||
|
|
a6b0cf3a00 | ||
|
|
dfc45fbea2 | ||
|
|
210b577462 | ||
|
|
60332956a0 |
@@ -7,6 +7,9 @@
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"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",
|
||||
"test:curator-static": "tsx scripts/curator-static-registry.test.ts",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"workflows:smoke": "tsx src/workflows/smoke-test.ts",
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { buildServiceCurationPreview } from "../src/v1/curator/curator-store.js";
|
||||
import type { CuratorIcpId } from "../src/v1/curator/icp-registry.js";
|
||||
|
||||
const ICP_IDS: CuratorIcpId[] = [
|
||||
"student_recent_grad",
|
||||
"intern",
|
||||
"fresher_early_professional",
|
||||
"experienced_professional",
|
||||
"freelancer",
|
||||
"founder",
|
||||
"enterprise",
|
||||
];
|
||||
|
||||
const LIVE_ROUTE_PREFIX: Record<string, string> = {
|
||||
"courses-service": "/agents/courses",
|
||||
"resume-service": "/agents/resume",
|
||||
"interview-service": "/agents/interview/preview",
|
||||
"roleplay-service": "/agents/roleplay/builder",
|
||||
"matchmaking-service": "/agents/matchmaking",
|
||||
};
|
||||
|
||||
const EXPECTED_FOCUS: Record<CuratorIcpId, string[]> = {
|
||||
student_recent_grad: ["Start Your Story", "Discover Your Strengths", "Build Credibility", "Explore Opportunities", "Learn To Grow", "Grow Your Network", "Ready For Tomorrow"],
|
||||
intern: ["Own Your Journey", "Show Your Growth", "Build Credibility", "Find Better Opportunities", "Strengthen Your Skills", "Expand Your Network", "Step Into Opportunity"],
|
||||
fresher_early_professional: ["Start Strong", "Build Visibility", "Show Your Potential", "Find Better Opportunities", "Grow Your Skills", "Expand Your Network", "Launch Your Career"],
|
||||
experienced_professional: ["Lead With Purpose", "Build Executive Presence", "Show Strategic Value", "Expand Your Influence", "Future-Proof Leadership", "Build Strategic Relationships", "Lead The Future"],
|
||||
freelancer: ["Build Your Brand", "Package Your Value", "Earn Trust", "Find More Clients", "Grow Your Business", "Expand Your Network", "Scale Forward"],
|
||||
founder: ["Own Your Vision", "Validate Your Idea", "Build Credibility", "Find Your Market", "Grow Smarter", "Build Your Network", "Launch Forward"],
|
||||
enterprise: ["Know Your Workforce", "Measure Your Talent", "Strengthen Capability", "Build Better Hiring", "Grow Leaders", "Activate Growth", "Future Ready Enterprise"],
|
||||
};
|
||||
|
||||
const EXPECTED_DAY_ONE_TITLES: Record<CuratorIcpId, string[]> = {
|
||||
student_recent_grad: ["Claim Your Corner", "Know Your Number", "Resume Rescue", "Pitch Perfect"],
|
||||
intern: ["Claim Your Corner", "Know Your Number", "Resume Reloaded", "Pitch Perfect"],
|
||||
fresher_early_professional: ["Claim Your Corner", "Know Your Number", "Resume Rescue", "Pitch Perfect"],
|
||||
experienced_professional: ["Own Your Executive Brand", "Know Your Number", "Executive Resume", "Executive Introduction"],
|
||||
freelancer: ["Claim Your Corner", "Know Your Number", "Portfolio Rescue", "Client Pitch"],
|
||||
founder: ["Build In Public", "Know Your Number", "Founder Story", "Founder Pitch"],
|
||||
enterprise: ["Shape Your Story", "Know Your Workforce", "Capability Blueprint", "Leadership Kickoff"],
|
||||
};
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function routeFor(route: string) {
|
||||
return new URL(route, "https://app.sai-onchain.me");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const startDate = "2026-07-02";
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const icpId of ICP_IDS) {
|
||||
const preview = await buildServiceCurationPreview({
|
||||
userId: `curator-static-registry-test-${icpId}`,
|
||||
startDate,
|
||||
icpId,
|
||||
userContext: { targetRole: "Product Manager" },
|
||||
});
|
||||
|
||||
try {
|
||||
assert(preview.version === "icp-v7-static", `${icpId}: expected static plan version`);
|
||||
assert(preview.idempotency.promptHash === "static-task-registry", `${icpId}: prompt hash should prove static registry mode`);
|
||||
assert(preview.plan.days.length === 30, `${icpId}: expected 30 generated days`);
|
||||
assert(preview.plan.calendarWeeks.length === 5, `${icpId}: expected 5 sprint-relative calendar weeks`);
|
||||
|
||||
const firstSevenFocus = preview.plan.days.slice(0, 7).map((day) => day.focus);
|
||||
assert(JSON.stringify(firstSevenFocus) === JSON.stringify(EXPECTED_FOCUS[icpId]), `${icpId}: first 7 focus labels must match 7-day sprint doc`);
|
||||
assert(JSON.stringify(firstSevenFocus) === JSON.stringify(preview.plan.days.slice(7, 14).map((day) => day.focus)), `${icpId}: 7-day sprint should repeat after day 7 for now`);
|
||||
|
||||
const dayOneTitles = preview.plan.days[0]?.tasks.map((task) => task.title);
|
||||
assert(JSON.stringify(dayOneTitles) === JSON.stringify(EXPECTED_DAY_ONE_TITLES[icpId]), `${icpId}: day 1 task titles must match docs`);
|
||||
|
||||
for (const day of preview.plan.days) {
|
||||
assert(day.generationStatus === "seeded", `${icpId} day ${day.dayIndex}: generation status must stay seeded`);
|
||||
assert(!day.adaptationReason, `${icpId} day ${day.dayIndex}: adaptation reason should be absent`);
|
||||
assert(day.tasks.length === 4, `${icpId} day ${day.dayIndex}: expected exactly 4 task objects`);
|
||||
|
||||
const taskIds = new Set(day.tasks.map((task) => task.id));
|
||||
const stageIds = new Set(day.tasks.map((task) => task.stageId));
|
||||
assert(taskIds.size === 4, `${icpId} day ${day.dayIndex}: task IDs must be unique`);
|
||||
assert(stageIds.size === 4, `${icpId} day ${day.dayIndex}: stage IDs must be unique`);
|
||||
|
||||
for (const task of day.tasks) {
|
||||
assert(task.missionId === "curator-sprint", `${icpId} ${task.id}: missionId mismatch`);
|
||||
assert(task.missionInstanceId?.startsWith("curator-sprint:icp-v7-static:"), `${icpId} ${task.id}: missionInstanceId should use static version`);
|
||||
assert(task.stageId?.includes(`${task.serviceId?.replace("-service", "")}`), `${icpId} ${task.id}: stageId should include service segment`);
|
||||
assert(task.id.includes(`${task.serviceId?.replace("-service", "")}`), `${icpId} ${task.id}: taskId should include service segment`);
|
||||
assert(task.actorName !== "Vera" && task.actorName !== "Kai" && task.actorName !== "Mira" && task.actorName !== "Lyra", `${icpId} ${task.id}: agent persona leaked into actorName`);
|
||||
|
||||
const url = routeFor(task.route);
|
||||
const livePrefix = task.serviceId ? LIVE_ROUTE_PREFIX[task.serviceId] : undefined;
|
||||
if (livePrefix) {
|
||||
assert(url.pathname === livePrefix, `${icpId} ${task.id}: route ${url.pathname} does not match ${livePrefix}`);
|
||||
} else {
|
||||
assert(url.pathname === "/agents/service-unavailable", `${icpId} ${task.id}: unsupported service must use unavailable route`);
|
||||
assert(url.searchParams.get("serviceId") === task.serviceId, `${icpId} ${task.id}: unavailable route must preserve serviceId`);
|
||||
}
|
||||
assert(url.searchParams.get("source") === "curator-v1", `${icpId} ${task.id}: route missing curator source`);
|
||||
assert(url.searchParams.get("curatorTaskId") === task.id, `${icpId} ${task.id}: route curatorTaskId mismatch`);
|
||||
assert(url.searchParams.get("missionId") === task.missionId, `${icpId} ${task.id}: route missionId mismatch`);
|
||||
assert(url.searchParams.get("missionInstanceId") === task.missionInstanceId, `${icpId} ${task.id}: route missionInstanceId mismatch`);
|
||||
assert(url.searchParams.get("stageId") === task.stageId, `${icpId} ${task.id}: route stageId mismatch`);
|
||||
|
||||
if (task.serviceId === "interview-service" || task.serviceId === "roleplay-service") {
|
||||
assert(url.searchParams.get("role") === "Product Manager", `${icpId} ${task.id}: practice route should preserve target role`);
|
||||
assert(url.searchParams.get("duration") === "5", `${icpId} ${task.id}: practice route should carry duration`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
failures.push(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error(failures.join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`curator-static-registry tests passed for ${ICP_IDS.length} ICPs, 30 days each, 4 doc-backed tasks per day`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
onboardingMissionInstanceId,
|
||||
selectOnboardingMissionIds,
|
||||
} from "../src/missions/lifecycle.js";
|
||||
|
||||
const userA = "user_abc123";
|
||||
|
||||
assert.deepEqual(
|
||||
selectOnboardingMissionIds({ onboarding: { goal: "I need internship interview prep" } }),
|
||||
["interview-to-offer", "personal-brand-opportunity-engine"],
|
||||
"default onboarding should start interview-to-offer plus personal brand",
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
selectOnboardingMissionIds({ onboarding: { goal: "I want to negotiate my offer and compensation" } }),
|
||||
["salary-negotiation-war-room", "personal-brand-opportunity-engine"],
|
||||
"salary/offer context should prioritize the negotiation mission",
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
selectOnboardingMissionIds({ preferences: { onboarding: { goal: "I need a career transition into product" } } }),
|
||||
["career-transition", "personal-brand-opportunity-engine"],
|
||||
"preferences onboarding context should be read when selecting missions",
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
selectOnboardingMissionIds({ preferences: { onboarding: { goal: "Build LinkedIn visibility and network" } } }),
|
||||
["personal-brand-opportunity-engine", "interview-to-offer"],
|
||||
"brand/networking context should not duplicate the personal-brand mission",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
onboardingMissionInstanceId(userA, "interview-to-offer"),
|
||||
onboardingMissionInstanceId(userA, "interview-to-offer"),
|
||||
"onboarding mission instance ids must be deterministic for idempotent retries",
|
||||
);
|
||||
|
||||
assert.notEqual(
|
||||
onboardingMissionInstanceId(userA, "interview-to-offer"),
|
||||
onboardingMissionInstanceId("user_other", "interview-to-offer"),
|
||||
"onboarding mission instance ids must be scoped by user",
|
||||
);
|
||||
|
||||
console.log("mission-lifecycle tests passed");
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,208 @@
|
||||
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);
|
||||
@@ -144,6 +144,12 @@ export const config = {
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
|
||||
// Passive mission refresh loop. Dedupe keys make this safe across retries and
|
||||
// multiple staging replicas; set MISSION_PASSIVE_LOOP_ENABLED=false to disable.
|
||||
missionPassiveLoopEnabled: (process.env.MISSION_PASSIVE_LOOP_ENABLED ?? "true").toLowerCase() !== "false",
|
||||
missionPassiveLoopIntervalMs: Number(process.env.MISSION_PASSIVE_LOOP_INTERVAL_MS ?? 60 * 60 * 1000),
|
||||
missionPassiveLoopBatchSize: Number(process.env.MISSION_PASSIVE_LOOP_BATCH_SIZE ?? 100),
|
||||
|
||||
// Used by LLM requests.
|
||||
maxAgentTokens: Number(process.env.MAX_AGENT_TOKENS ?? 4096),
|
||||
|
||||
|
||||
@@ -130,6 +130,7 @@ export async function ensureOnboardingSideEffectsForEvent(event: GrowEventRow) {
|
||||
return {
|
||||
qscoreBaselineSeeded: false,
|
||||
curatorOnboarding: { status: "skipped" as const, reason: event.userId ? "not_onboarding_completion" : "missing_user_id" },
|
||||
missions: { status: "skipped" as const, reason: event.userId ? "not_onboarding_completion" : "missing_user_id", started: [], existing: [] },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -146,8 +147,14 @@ export async function ensureOnboardingSideEffectsForEvent(event: GrowEventRow) {
|
||||
source: event.source,
|
||||
context: onboardingContextFromInput(event.payload),
|
||||
});
|
||||
const missions = {
|
||||
status: "skipped" as const,
|
||||
reason: "static_curator_sprint_enabled" as const,
|
||||
started: [],
|
||||
existing: [],
|
||||
};
|
||||
|
||||
return { qscoreBaselineSeeded, curatorOnboarding };
|
||||
return { qscoreBaselineSeeded, curatorOnboarding, missions };
|
||||
}
|
||||
|
||||
export async function recordAndProcessOnboardingCompletion(input: {
|
||||
|
||||
@@ -274,6 +274,20 @@ export async function listActiveMissionsPg(userId: string) {
|
||||
return rows.map((row) => ({ mission: activeMissionFromRow(row), snapshot: missionSnapshotFromRow(row) }));
|
||||
}
|
||||
|
||||
export async function listActiveMissionsForPassiveReviewPg(opts: { userId?: string; limit?: number } = {}) {
|
||||
const conditions = [eq(growActiveMissions.status, "active")];
|
||||
if (opts.userId) conditions.push(eq(growActiveMissions.userId, opts.userId));
|
||||
const query = db
|
||||
.select()
|
||||
.from(growActiveMissions)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(growActiveMissions.updatedAt));
|
||||
const rows = typeof opts.limit === "number" && opts.limit > 0
|
||||
? await query.limit(opts.limit)
|
||||
: await query;
|
||||
return rows.map((row) => ({ userId: row.userId, mission: activeMissionFromRow(row), snapshot: missionSnapshotFromRow(row) }));
|
||||
}
|
||||
|
||||
export async function getActiveMissionPg(userId: string, instanceId: string) {
|
||||
const [row] = await db.select().from(growActiveMissions).where(and(eq(growActiveMissions.userId, userId), eq(growActiveMissions.instanceId, instanceId))).limit(1);
|
||||
return row ? { mission: activeMissionFromRow(row), snapshot: missionSnapshotFromRow(row) } : null;
|
||||
|
||||
+13
-12
@@ -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");
|
||||
}
|
||||
|
||||
+30
-35
@@ -21,6 +21,7 @@ import { ensureOnboardingBaselineQscoreFromLedger } from "../events/onboarding-l
|
||||
import { HomeFeedAgentError, refineHomeNotificationsWithAgent } from "./home-feed-agent.js";
|
||||
import { listAvailableMissionDefinitions } from "../missions/registry.js";
|
||||
import { listServiceCapabilities } from "../workflows/service-capabilities.js";
|
||||
import { buildCuratorSprint } from "../v1/curator/curator-store.js";
|
||||
import {
|
||||
isAllowedNotificationHref,
|
||||
MODULE_IDS,
|
||||
@@ -35,14 +36,13 @@ import {
|
||||
|
||||
const FRESH_MS = 10 * 60 * 1000;
|
||||
const EXPIRY_MS = 24 * 60 * 60 * 1000;
|
||||
const MISSION_MODULE_LIMIT = 4;
|
||||
|
||||
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 +101,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 +126,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 +193,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 +232,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 +282,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 +306,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 +333,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 +376,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 +394,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 +496,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 +551,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 +573,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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -616,6 +610,7 @@ async function buildIdentity(ctx: HomeContext) {
|
||||
|
||||
export async function getHomeFeed(userId: string, opts: { refresh?: boolean; userProfile?: Record<string, unknown>; preferences?: Record<string, unknown> } = {}): Promise<HomeFeedResponse> {
|
||||
await ensureOnboardingBaselineQscoreFromLedger(userId);
|
||||
await buildCuratorSprint(userId);
|
||||
const ctx = await collectContext(userId, { userProfile: opts.userProfile, preferences: opts.preferences });
|
||||
const persisted = await readPersistedNotifications(userId);
|
||||
const newest = persisted[0]?.createdAt?.getTime() ?? 0;
|
||||
|
||||
@@ -23,6 +23,7 @@ import { analyticsRoutes } from "./routes/analytics.js";
|
||||
import { logRoutes } from "./routes/logs.js";
|
||||
import { v1Routes } from "./v1/index.js";
|
||||
import { startGrowEventsRedisConsumer } from "./events/redis-consumer.js";
|
||||
import { startPassiveMissionReviewLoop } from "./missions/passive-runner.js";
|
||||
import { db } from "./db/client.js";
|
||||
import { ensureRuntimeSchema } from "./db/ensure-runtime-schema.js";
|
||||
import { hydratePortAllocator, reconcileOnBoot, ensureCentralGiteaReady } from "./docker/manager.js";
|
||||
@@ -47,6 +48,7 @@ async function main() {
|
||||
|
||||
await reconcileOnBoot();
|
||||
startGrowEventsRedisConsumer().catch((err) => log.error({ err }, "failed to start grow events redis consumer"));
|
||||
startPassiveMissionReviewLoop();
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
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,
|
||||
isFeedbackEvent,
|
||||
isInterviewEvent,
|
||||
isRelevantServiceEvent,
|
||||
isResumeEvent,
|
||||
isRoleplayEvent,
|
||||
missionExplicitlyMatches,
|
||||
passiveInterviewFeedbackResumeUpgrade,
|
||||
passiveResumeAnalysisInterviewPractice,
|
||||
passiveRoleplayFeedbackStoryBank,
|
||||
serviceHref,
|
||||
} from "../reducer-helpers.js";
|
||||
|
||||
export const careerTransitionReducer: MissionReducer = {
|
||||
missionId: "career-transition",
|
||||
@@ -48,6 +62,14 @@ 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.";
|
||||
}
|
||||
|
||||
@@ -56,7 +78,7 @@ export const careerTransitionReducer: MissionReducer = {
|
||||
eventMessage = "Adjacent-role interview practice started.";
|
||||
}
|
||||
|
||||
if (isInterviewEvent(event.source, type) && type.includes("review_completed")) {
|
||||
if (isInterviewEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
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." });
|
||||
@@ -74,12 +96,30 @@ 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) && type.includes("review_completed")) {
|
||||
if (isRoleplayEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
const passive = passiveRoleplayFeedbackStoryBank({
|
||||
missionId: "career-transition",
|
||||
activeMission,
|
||||
eventId: event.id,
|
||||
payload,
|
||||
stageId: "roleplay",
|
||||
priority: 94,
|
||||
});
|
||||
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,8 +2,11 @@ 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,
|
||||
@@ -27,6 +30,10 @@ 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,
|
||||
@@ -47,6 +54,7 @@ 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({
|
||||
@@ -69,6 +77,29 @@ 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.";
|
||||
}
|
||||
|
||||
@@ -82,8 +113,10 @@ export const interviewToOfferReducer: MissionReducer = {
|
||||
eventMessage = "Mock interview completed; waiting for review.";
|
||||
}
|
||||
|
||||
if (isInterviewEvent(event.source, type) && (type.includes("review_completed") || type.includes("review.completed"))) {
|
||||
if (isInterviewEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
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." });
|
||||
@@ -99,14 +132,27 @@ export const interviewToOfferReducer: MissionReducer = {
|
||||
serviceId: "resume-service",
|
||||
toolName: "resume.create_version_prompt_draft",
|
||||
mode: "approval_required",
|
||||
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") },
|
||||
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"),
|
||||
},
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:interview-review:tailor-resume:${event.id}`,
|
||||
priority: 100,
|
||||
priority: 108,
|
||||
urgency: "now",
|
||||
}));
|
||||
if (weakAreas.some((area) => /communication|story|clarity|confidence|concise/i.test(area))) {
|
||||
@@ -127,9 +173,45 @@ export const interviewToOfferReducer: MissionReducer = {
|
||||
eventMessage = "Interview review completed; resume and roleplay next actions were created.";
|
||||
}
|
||||
|
||||
if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) {
|
||||
if (isRoleplayEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
const weakAreas = extractWeakAreas(payload);
|
||||
const storyIssues = extractStoryIssues(payload);
|
||||
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.";
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import crypto from "node:crypto";
|
||||
import { createClient, type Client } from "rivetkit/client";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { config } from "../config.js";
|
||||
import { db } from "../db/client.js";
|
||||
import { growEvents, users } from "../db/schema.js";
|
||||
import {
|
||||
completeMissionCoachRunPg,
|
||||
createMissionCoachRunPg,
|
||||
getActiveMissionPg,
|
||||
listActiveMissionsForPassiveReviewPg,
|
||||
listActiveMissionsPg,
|
||||
replaceMissionSuggestionsPg,
|
||||
upsertActiveMissionPg,
|
||||
} from "../grow/persistence.js";
|
||||
import { recordGrowEvent, markGrowEventFailed, markGrowEventProcessed, markGrowEventProcessing } from "../events/record-grow-event.js";
|
||||
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
|
||||
import { getPersistedMissionDefinition } from "./postgres-registry.js";
|
||||
import { buildDeterministicMissionSuggestions } from "./suggestions.js";
|
||||
import type { Registry } from "../actors/registry.js";
|
||||
import type { GrowActiveMission, MissionActorType, MissionId, MissionSnapshot } from "../actors/missions/types.js";
|
||||
import { log } from "../log.js";
|
||||
|
||||
const ONBOARDING_MISSION_LIMIT = 2;
|
||||
const PASSIVE_REVIEW_SOURCE = "growqr-backend:mission-passive";
|
||||
|
||||
let _client: Client<Registry> | null = null;
|
||||
function getClient(): Client<Registry> {
|
||||
return (_client ??= createClient<Registry>(config.rivetClientEndpoint));
|
||||
}
|
||||
|
||||
function missionActorFor(userId: string, instanceId: string, actorType: MissionActorType) {
|
||||
const client = getClient();
|
||||
switch (actorType) {
|
||||
case "interviewToOfferMissionActor":
|
||||
return client.interviewToOfferMissionActor.getOrCreate([userId, instanceId]);
|
||||
case "careerTransitionMissionActor":
|
||||
return client.careerTransitionMissionActor.getOrCreate([userId, instanceId]);
|
||||
case "salaryNegotiationWarRoomMissionActor":
|
||||
return client.salaryNegotiationWarRoomMissionActor.getOrCreate([userId, instanceId]);
|
||||
case "promotionReadinessMissionActor":
|
||||
return client.promotionReadinessMissionActor.getOrCreate([userId, instanceId]);
|
||||
case "personalBrandOpportunityEngineMissionActor":
|
||||
return client.personalBrandOpportunityEngineMissionActor.getOrCreate([userId, instanceId]);
|
||||
}
|
||||
}
|
||||
|
||||
function activeMissionFromSnapshot(snapshot: MissionSnapshot): GrowActiveMission {
|
||||
return {
|
||||
instanceId: snapshot.instanceId,
|
||||
missionId: snapshot.missionId,
|
||||
workflowId: snapshot.workflowId,
|
||||
title: snapshot.title,
|
||||
shortTitle: snapshot.shortTitle,
|
||||
status: snapshot.status,
|
||||
progressPercent: snapshot.progressPercent,
|
||||
currentStageId: snapshot.currentStageId,
|
||||
goal: snapshot.goal,
|
||||
actorType: actorTypeFor(snapshot.missionId),
|
||||
createdAt: new Date(snapshot.createdAt).getTime(),
|
||||
updatedAt: new Date(snapshot.updatedAt).getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
function actorTypeFor(missionId: MissionId): MissionActorType | undefined {
|
||||
if (missionId === "interview-to-offer") return "interviewToOfferMissionActor";
|
||||
if (missionId === "career-transition") return "careerTransitionMissionActor";
|
||||
if (missionId === "salary-negotiation-war-room") return "salaryNegotiationWarRoomMissionActor";
|
||||
if (missionId === "promotion-readiness") return "promotionReadinessMissionActor";
|
||||
if (missionId === "personal-brand-opportunity-engine") return "personalBrandOpportunityEngineMissionActor";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hashUser(userId: string) {
|
||||
return crypto.createHash("sha256").update(userId).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
export function onboardingMissionInstanceId(userId: string, missionId: MissionId) {
|
||||
return `mission-${missionId}-${hashUser(userId)}`;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function stringValues(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string" && Boolean(item.trim())).map((item) => item.trim());
|
||||
if (typeof value === "string" && value.trim()) return [value.trim()];
|
||||
return [];
|
||||
}
|
||||
|
||||
function onboardingText(context?: Record<string, unknown>) {
|
||||
const source = asRecord(context);
|
||||
const preferences = asRecord(source.preferences);
|
||||
const onboarding = asRecord(source.onboarding ?? preferences.onboarding);
|
||||
const mission = asRecord(preferences.mission_preferences);
|
||||
const resume = asRecord(preferences.resume_preferences);
|
||||
const interview = asRecord(preferences.interview_preferences);
|
||||
const values = [
|
||||
...stringValues(onboarding.goal),
|
||||
...stringValues(onboarding.target_role ?? onboarding.targetRole ?? onboarding.role ?? onboarding.current_role),
|
||||
...stringValues(onboarding.timeline),
|
||||
...stringValues(mission.active_goal),
|
||||
...stringValues(resume.target_title),
|
||||
...stringValues(interview.job_description),
|
||||
...stringValues(preferences.target_roles),
|
||||
...stringValues(preferences.target_companies),
|
||||
];
|
||||
return values.join(" ").toLowerCase();
|
||||
}
|
||||
|
||||
export function selectOnboardingMissionIds(context?: Record<string, unknown>, limit = ONBOARDING_MISSION_LIMIT): MissionId[] {
|
||||
const text = onboardingText(context);
|
||||
const primary: MissionId =
|
||||
/salary|compensation|negotiat/.test(text) ? "salary-negotiation-war-room" :
|
||||
/promot|manager|leadership|level up|level-up/.test(text) ? "promotion-readiness" :
|
||||
/transition|switch|pivot|career change|new field/.test(text) ? "career-transition" :
|
||||
/brand|linkedin|network|visibility|opportunit/.test(text) ? "personal-brand-opportunity-engine" :
|
||||
"interview-to-offer";
|
||||
|
||||
const ordered: MissionId[] = [
|
||||
primary,
|
||||
"personal-brand-opportunity-engine",
|
||||
"interview-to-offer",
|
||||
"career-transition",
|
||||
"promotion-readiness",
|
||||
"salary-negotiation-war-room",
|
||||
];
|
||||
return Array.from(new Set(ordered)).slice(0, Math.max(1, limit));
|
||||
}
|
||||
|
||||
async function ensureUser(userId: string) {
|
||||
await db
|
||||
.insert(users)
|
||||
.values({ id: userId, email: `${userId}@service.local`, displayName: userId })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
export async function ensureOnboardingActiveMissions(input: {
|
||||
userId: string;
|
||||
context?: Record<string, unknown>;
|
||||
completedAt?: string | Date | null;
|
||||
sourceEventId?: string;
|
||||
source?: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
const userId = input.userId.trim();
|
||||
if (!userId) return { status: "skipped" as const, reason: "missing_user_id" as const, started: [], existing: [] };
|
||||
|
||||
await ensureUser(userId);
|
||||
const missionIds = selectOnboardingMissionIds(input.context, input.limit ?? ONBOARDING_MISSION_LIMIT);
|
||||
const activeRows = await listActiveMissionsPg(userId);
|
||||
const started: GrowActiveMission[] = [];
|
||||
const existing: GrowActiveMission[] = [];
|
||||
|
||||
for (const missionId of missionIds) {
|
||||
const alreadyActive = activeRows.find((item) => item.mission.missionId === missionId && ["active", "paused"].includes(item.mission.status));
|
||||
if (alreadyActive) {
|
||||
existing.push(alreadyActive.mission);
|
||||
continue;
|
||||
}
|
||||
|
||||
const mission = await getPersistedMissionDefinition(missionId);
|
||||
if (!mission?.actorType) continue;
|
||||
|
||||
const instanceId = onboardingMissionInstanceId(userId, missionId);
|
||||
const existingInstance = await getActiveMissionPg(userId, instanceId);
|
||||
if (existingInstance) {
|
||||
existing.push(existingInstance.mission);
|
||||
continue;
|
||||
}
|
||||
|
||||
const actor = missionActorFor(userId, instanceId, mission.actorType);
|
||||
const completedAt = input.completedAt instanceof Date ? input.completedAt.toISOString() : input.completedAt ?? new Date().toISOString();
|
||||
const snapshot = await actor.init({
|
||||
userId,
|
||||
instanceId,
|
||||
missionId,
|
||||
goal: onboardingText(input.context) || undefined,
|
||||
input: {
|
||||
source: "onboarding",
|
||||
sourceEventId: input.sourceEventId,
|
||||
completedAt,
|
||||
context: input.context ?? {},
|
||||
},
|
||||
});
|
||||
const activeMission = activeMissionFromSnapshot(snapshot);
|
||||
await upsertActiveMissionPg(userId, activeMission, snapshot);
|
||||
started.push(activeMission);
|
||||
|
||||
const event = await recordGrowEvent({
|
||||
source: input.source ?? "onboarding",
|
||||
type: "mission.started",
|
||||
category: "mission",
|
||||
userId,
|
||||
occurredAt: completedAt,
|
||||
mission: { instanceId, missionId, stageId: snapshot.currentStageId },
|
||||
correlation: { sourceEventId: input.sourceEventId },
|
||||
payload: {
|
||||
trigger: "onboarding",
|
||||
title: snapshot.title,
|
||||
goal: snapshot.goal,
|
||||
selectedMissionIds: missionIds,
|
||||
},
|
||||
dedupeKey: `mission-onboarding-start:${userId}:${missionId}`,
|
||||
}, { userId, source: input.source ?? "onboarding" });
|
||||
await routeGrowEventToUserActor(event).catch((err) => log.warn({ err, userId, missionId }, "failed to route onboarding mission start event"));
|
||||
}
|
||||
|
||||
return {
|
||||
status: started.length ? "started" as const : "already_ready" as const,
|
||||
selectedMissionIds: missionIds,
|
||||
started,
|
||||
existing,
|
||||
};
|
||||
}
|
||||
|
||||
function passiveReviewDate(input?: string | Date) {
|
||||
const date = input instanceof Date ? input : input ? new Date(input) : new Date();
|
||||
return Number.isNaN(date.getTime()) ? new Date().toISOString().slice(0, 10) : date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function passiveReviewAlreadyRan(instanceId: string, date: string) {
|
||||
const [existing] = await db
|
||||
.select({ id: growEvents.id, processingStatus: growEvents.processingStatus })
|
||||
.from(growEvents)
|
||||
.where(eq(growEvents.dedupeKey, `mission-passive-review:${instanceId}:${date}`))
|
||||
.limit(1);
|
||||
return existing ?? null;
|
||||
}
|
||||
|
||||
export async function runPassiveMissionReviewForMission(input: {
|
||||
userId: string;
|
||||
mission: GrowActiveMission;
|
||||
snapshot?: MissionSnapshot | null;
|
||||
date?: string | Date;
|
||||
force?: boolean;
|
||||
}) {
|
||||
const date = passiveReviewDate(input.date);
|
||||
const existing = input.force ? null : await passiveReviewAlreadyRan(input.mission.instanceId, date);
|
||||
if (existing) {
|
||||
return { status: "skipped" as const, reason: "already_ran" as const, eventId: existing.id, mission: input.mission };
|
||||
}
|
||||
if (!input.mission.actorType) {
|
||||
return { status: "skipped" as const, reason: "missing_actor" as const, mission: input.mission };
|
||||
}
|
||||
|
||||
const dedupeKey = input.force
|
||||
? `mission-passive-review:${input.mission.instanceId}:${date}:${Date.now()}`
|
||||
: `mission-passive-review:${input.mission.instanceId}:${date}`;
|
||||
const event = await recordGrowEvent({
|
||||
source: PASSIVE_REVIEW_SOURCE,
|
||||
type: "mission.passive_review.completed",
|
||||
category: "mission",
|
||||
userId: input.userId,
|
||||
occurredAt: new Date().toISOString(),
|
||||
mission: { instanceId: input.mission.instanceId, missionId: input.mission.missionId, stageId: input.mission.currentStageId },
|
||||
payload: {
|
||||
reviewDate: date,
|
||||
status: "started",
|
||||
},
|
||||
dedupeKey,
|
||||
}, { userId: input.userId, source: PASSIVE_REVIEW_SOURCE });
|
||||
await markGrowEventProcessing(event.id);
|
||||
|
||||
try {
|
||||
const actor = missionActorFor(input.userId, input.mission.instanceId, input.mission.actorType);
|
||||
const scrum = await actor.runDailyScrum({ trigger: "nightly" });
|
||||
const snapshot = scrum.snapshot ?? input.snapshot;
|
||||
if (!snapshot) {
|
||||
await markGrowEventFailed(event.id, new Error("mission_passive_review_missing_snapshot"));
|
||||
return { status: "skipped" as const, reason: "missing_snapshot" as const, eventId: event.id, mission: input.mission };
|
||||
}
|
||||
|
||||
const activeMission = activeMissionFromSnapshot(snapshot);
|
||||
await upsertActiveMissionPg(input.userId, activeMission, snapshot);
|
||||
const windowEnd = new Date(`${date}T23:59:59.999Z`);
|
||||
const windowStart = new Date(`${date}T00:00:00.000Z`);
|
||||
const run = await createMissionCoachRunPg({
|
||||
userId: input.userId,
|
||||
missionInstanceId: activeMission.instanceId,
|
||||
missionId: activeMission.missionId,
|
||||
windowStart,
|
||||
windowEnd,
|
||||
skillVersion: snapshot.skillVersion,
|
||||
inputDigest: {
|
||||
passive: true,
|
||||
reviewDate: date,
|
||||
trigger: "nightly",
|
||||
stageCount: snapshot.stages.length,
|
||||
currentStageId: snapshot.currentStageId,
|
||||
progressPercent: snapshot.progressPercent,
|
||||
artifactCount: snapshot.artifacts.length,
|
||||
eventCount: snapshot.events.length,
|
||||
},
|
||||
});
|
||||
|
||||
const snapshotContext = asRecord(snapshot.input?.context);
|
||||
const suggestions = await replaceMissionSuggestionsPg({
|
||||
userId: input.userId,
|
||||
missionInstanceId: activeMission.instanceId,
|
||||
missionId: activeMission.missionId,
|
||||
coachRunId: run.id,
|
||||
suggestions: buildDeterministicMissionSuggestions(snapshot, { preferences: asRecord(snapshotContext.preferences) }),
|
||||
});
|
||||
const summary = suggestions[0]
|
||||
? `Passive mission review refreshed ${suggestions.length} suggestion${suggestions.length === 1 ? "" : "s"}. Top action: ${suggestions[0].title}`
|
||||
: "Passive mission review found no open action.";
|
||||
await completeMissionCoachRunPg({ id: run.id, summary, output: { suggestions, passive: true, reviewDate: date } });
|
||||
|
||||
await db.update(growEvents).set({
|
||||
mission: { instanceId: activeMission.instanceId, missionId: activeMission.missionId, stageId: activeMission.currentStageId },
|
||||
payload: {
|
||||
reviewDate: date,
|
||||
status: "completed",
|
||||
coachRunId: run.id,
|
||||
suggestionIds: suggestions.map((item) => item.id),
|
||||
summary,
|
||||
},
|
||||
}).where(eq(growEvents.id, event.id));
|
||||
await markGrowEventProcessed(event.id);
|
||||
|
||||
return { status: "reviewed" as const, eventId: event.id, coachRunId: run.id, mission: activeMission, suggestionCount: suggestions.length, summary };
|
||||
} catch (err) {
|
||||
await markGrowEventFailed(event.id, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPassiveMissionReviews(input: { userId?: string; date?: string | Date; force?: boolean; limit?: number } = {}) {
|
||||
const rows = await listActiveMissionsForPassiveReviewPg({ userId: input.userId, limit: input.limit });
|
||||
const results = [];
|
||||
for (const row of rows) {
|
||||
try {
|
||||
results.push(await runPassiveMissionReviewForMission({
|
||||
userId: row.userId,
|
||||
mission: row.mission,
|
||||
snapshot: row.snapshot,
|
||||
date: input.date,
|
||||
force: input.force,
|
||||
}));
|
||||
} catch (err) {
|
||||
log.error({ err, userId: row.userId, missionInstanceId: row.mission.instanceId }, "passive mission review failed");
|
||||
results.push({ status: "failed" as const, mission: row.mission, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
return {
|
||||
date: passiveReviewDate(input.date),
|
||||
reviewed: results.filter((item) => item.status === "reviewed").length,
|
||||
skipped: results.filter((item) => item.status === "skipped").length,
|
||||
failed: results.filter((item) => item.status === "failed").length,
|
||||
results,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { config } from "../config.js";
|
||||
import { log } from "../log.js";
|
||||
import { runPassiveMissionReviews } from "./lifecycle.js";
|
||||
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
let running = false;
|
||||
|
||||
async function runOnce() {
|
||||
if (running) return;
|
||||
running = true;
|
||||
try {
|
||||
const result = await runPassiveMissionReviews({ limit: config.missionPassiveLoopBatchSize });
|
||||
if (result.reviewed || result.failed) {
|
||||
log.info({
|
||||
reviewed: result.reviewed,
|
||||
skipped: result.skipped,
|
||||
failed: result.failed,
|
||||
date: result.date,
|
||||
}, "passive mission review loop completed");
|
||||
}
|
||||
} catch (err) {
|
||||
log.error({ err }, "passive mission review loop failed");
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function startPassiveMissionReviewLoop() {
|
||||
if (!config.missionPassiveLoopEnabled) {
|
||||
log.info("passive mission review loop disabled");
|
||||
return;
|
||||
}
|
||||
if (timer) return;
|
||||
|
||||
const intervalMs = Math.max(5 * 60 * 1000, config.missionPassiveLoopIntervalMs);
|
||||
const firstDelayMs = Math.min(60_000, intervalMs);
|
||||
const first = setTimeout(() => void runOnce(), firstDelayMs);
|
||||
first.unref?.();
|
||||
timer = setInterval(() => void runOnce(), intervalMs);
|
||||
timer.unref?.();
|
||||
log.info({ intervalMs, batchSize: config.missionPassiveLoopBatchSize }, "passive mission review loop scheduled");
|
||||
}
|
||||
@@ -1,5 +1,20 @@
|
||||
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
|
||||
import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionDetailHref, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js";
|
||||
import {
|
||||
actionForAgent,
|
||||
extractResumeSignals,
|
||||
extractWeakAreas,
|
||||
isFeedbackEvent,
|
||||
isInterviewEvent,
|
||||
isRelevantServiceEvent,
|
||||
isResumeEvent,
|
||||
isRoleplayEvent,
|
||||
missionDetailHref,
|
||||
missionExplicitlyMatches,
|
||||
passiveInterviewFeedbackResumeUpgrade,
|
||||
passiveResumeAnalysisInterviewPractice,
|
||||
passiveRoleplayFeedbackStoryBank,
|
||||
serviceHref,
|
||||
} from "../reducer-helpers.js";
|
||||
|
||||
export const personalBrandOpportunityReducer: MissionReducer = {
|
||||
missionId: "personal-brand-opportunity-engine",
|
||||
@@ -48,14 +63,31 @@ 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) && type.includes("review_completed")) {
|
||||
if (isRoleplayEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
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",
|
||||
@@ -67,13 +99,22 @@ 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) && type.includes("review_completed")) {
|
||||
if (isInterviewEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
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,5 +1,19 @@
|
||||
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,
|
||||
isFeedbackEvent,
|
||||
isInterviewEvent,
|
||||
isRelevantServiceEvent,
|
||||
isResumeEvent,
|
||||
isRoleplayEvent,
|
||||
missionExplicitlyMatches,
|
||||
passiveInterviewFeedbackResumeUpgrade,
|
||||
passiveResumeAnalysisInterviewPractice,
|
||||
passiveRoleplayFeedbackStoryBank,
|
||||
serviceHref,
|
||||
} from "../reducer-helpers.js";
|
||||
|
||||
export const promotionReadinessReducer: MissionReducer = {
|
||||
missionId: "promotion-readiness",
|
||||
@@ -48,14 +62,31 @@ 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) && type.includes("review_completed")) {
|
||||
if (isRoleplayEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
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",
|
||||
@@ -69,14 +100,23 @@ export const promotionReadinessReducer: MissionReducer = {
|
||||
priority: 86,
|
||||
urgency: "soon",
|
||||
}));
|
||||
actions.push(passive.action);
|
||||
eventMessage = "Manager conversation review updated promotion readiness.";
|
||||
}
|
||||
|
||||
if (isInterviewEvent(event.source, type) && type.includes("review_completed")) {
|
||||
if (isInterviewEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
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,6 +1,7 @@
|
||||
import { asRecord, getNumber, getString } from "../events/envelope.js";
|
||||
import { buildServiceLink } from "../services/service-registry.js";
|
||||
import type { MissionActionPatch } from "./reducer-types.js";
|
||||
import type { GrowActiveMission } from "../actors/missions/types.js";
|
||||
import type { MissionActionPatch, MissionArtifactPatch } from "./reducer-types.js";
|
||||
|
||||
export function isResumeEvent(source: string, type: string) {
|
||||
const value = source.toLowerCase();
|
||||
@@ -22,6 +23,10 @@ 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);
|
||||
}
|
||||
@@ -66,6 +71,64 @@ 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[] = [];
|
||||
@@ -80,6 +143,14 @@ 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);
|
||||
@@ -144,3 +215,127 @@ 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,5 +1,19 @@
|
||||
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,
|
||||
isFeedbackEvent,
|
||||
isInterviewEvent,
|
||||
isRelevantServiceEvent,
|
||||
isResumeEvent,
|
||||
isRoleplayEvent,
|
||||
missionExplicitlyMatches,
|
||||
passiveInterviewFeedbackResumeUpgrade,
|
||||
passiveResumeAnalysisInterviewPractice,
|
||||
passiveRoleplayFeedbackStoryBank,
|
||||
serviceHref,
|
||||
} from "../reducer-helpers.js";
|
||||
|
||||
export const salaryNegotiationReducer: MissionReducer = {
|
||||
missionId: "salary-negotiation-war-room",
|
||||
@@ -48,6 +62,14 @@ 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.";
|
||||
}
|
||||
|
||||
@@ -56,11 +78,20 @@ export const salaryNegotiationReducer: MissionReducer = {
|
||||
eventMessage = "Negotiation drill started.";
|
||||
}
|
||||
|
||||
if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) {
|
||||
if (isRoleplayEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
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",
|
||||
@@ -74,11 +105,20 @@ 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) && type.includes("review_completed")) {
|
||||
if (isInterviewEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
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.";
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { recordGrowEvent } from "../events/record-grow-event.js";
|
||||
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
|
||||
import { getRequestUserPreferences } from "../services/user-context.js";
|
||||
import { missionDetailHref } from "../missions/reducer-helpers.js";
|
||||
import { runPassiveMissionReviews } from "../missions/lifecycle.js";
|
||||
|
||||
let _client: Client<Registry> | null = null;
|
||||
function getClient(): Client<Registry> {
|
||||
@@ -69,6 +70,12 @@ const snoozeActionSchema = z.object({
|
||||
until: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
const passiveReviewSchema = z.object({
|
||||
date: z.string().optional(),
|
||||
force: z.boolean().optional(),
|
||||
limit: z.number().int().min(1).max(50).optional(),
|
||||
});
|
||||
|
||||
const createInstanceId = (missionId: string) =>
|
||||
`${missionId}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
@@ -285,6 +292,17 @@ export function missionRoutes() {
|
||||
return c.json({ action });
|
||||
});
|
||||
|
||||
app.post("/passive/run", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = passiveReviewSchema.parse(await c.req.json().catch(() => ({})));
|
||||
return c.json(await runPassiveMissionReviews({
|
||||
userId,
|
||||
date: body.date,
|
||||
force: body.force,
|
||||
limit: body.limit,
|
||||
}));
|
||||
});
|
||||
|
||||
app.post("/:missionId/start", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const missionId = c.req.param("missionId");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,9 @@ export type ServiceId =
|
||||
| "resume-service"
|
||||
| "cover-letter-service"
|
||||
| "qscore-service"
|
||||
| "social-branding-service";
|
||||
| "social-branding-service"
|
||||
| "expert-service"
|
||||
| "events-service";
|
||||
|
||||
export type ServiceCategory = "practice" | "learning" | "opportunity" | "document" | "measurement" | "profile";
|
||||
|
||||
@@ -131,6 +133,15 @@ 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",
|
||||
"qscore-service",
|
||||
]);
|
||||
|
||||
const serviceRegistry: ServiceRecord[] = [
|
||||
{
|
||||
id: "interview-service",
|
||||
@@ -837,12 +848,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 +892,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,
|
||||
@@ -957,13 +968,16 @@ function curatorBaseParams(input: CuratorRouteInput) {
|
||||
};
|
||||
}
|
||||
|
||||
function unavailableServiceRoute(input: CuratorRouteInput) {
|
||||
return appendQuery("/agents/service-unavailable", {
|
||||
...curatorBaseParams(input),
|
||||
serviceId: input.serviceId,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCuratorServiceRoute(input: CuratorRouteInput) {
|
||||
const service = getService(input.serviceId);
|
||||
if (!service) {
|
||||
return input.missionInstanceId
|
||||
? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId })
|
||||
: "/missions/active";
|
||||
}
|
||||
if (!service) return unavailableServiceRoute(input);
|
||||
|
||||
const state: QueryState = {
|
||||
...curatorBaseParams(input),
|
||||
@@ -990,8 +1004,16 @@ export function buildCuratorServiceRoute(input: CuratorRouteInput) {
|
||||
?? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId });
|
||||
}
|
||||
|
||||
const unavailableServiceLabels: Record<string, string> = {
|
||||
"social-branding-service": "Social Branding",
|
||||
"qscore-service": "Q Score",
|
||||
"assessment-service": "Assessment",
|
||||
"expert-service": "Expert",
|
||||
"events-service": "Events",
|
||||
};
|
||||
|
||||
export function getServiceDisplayName(serviceId?: string, fallback = "Mission planner") {
|
||||
return getService(serviceId)?.label ?? fallback;
|
||||
return getService(serviceId)?.label ?? (serviceId ? unavailableServiceLabels[serviceId] : undefined) ?? fallback;
|
||||
}
|
||||
|
||||
export function getServiceToolName(serviceId?: string) {
|
||||
|
||||
@@ -11,7 +11,7 @@ Completion is event gated. A checkbox or chat message cannot complete a task unl
|
||||
|
||||
## Service Curation Layer
|
||||
|
||||
- `curator-icp-playbooks.ts` defines ICP playbooks and maps each persona goal to registry-backed service actions.
|
||||
- `icp-registry.ts` defines ICP playbooks and maps each persona goal to registry-backed service actions.
|
||||
- `curator-user-context.ts` assembles deterministic user context from Grow events and QScore projection state.
|
||||
- `curator-prompt-builder.ts` builds the LLM-ready curation prompt and stable prompt hash.
|
||||
- `curator-store.ts` keeps generation idempotent by storing sprint starts in `grow_events` with the plan version, ICP, user context, prompt hash, playbook, plan hash, and 30-day plan days.
|
||||
@@ -28,3 +28,5 @@ Completion is event gated. A checkbox or chat message cannot complete a task unl
|
||||
- Before generation, the loop snapshots onboarding context into `grow_events` so curation sees the user-service profile/preferences. Event-only triggers also attempt an internal user-service fetch via the service-token path.
|
||||
- Idempotency is based on the one-time `curator.onboarding_plan.ready` event. Retries do not duplicate the plan-ready analytics event or in-app notification.
|
||||
- The loop stores the sprint as `curator.sprint.started`, emits `curator.onboarding_plan.ready` with weekly themes and Day 1 task links, and creates a persistent home notification pointing users to their active plan.
|
||||
|
||||
- `task-registry.ts` defines the static day-wise task registry and maps those tasks to service handoffs.
|
||||
|
||||
@@ -4,7 +4,7 @@ import { curatorPlanSchema, curatorSprintResponseSchema, type CuratorImprovement
|
||||
import { emitCuratorEvent } from "./curator-events.js";
|
||||
import { runCuratorChat } from "./curator-agent.js";
|
||||
import { prepareHandoffForTask } from "./curator-tools.js";
|
||||
import type { CuratorIcpId } from "./curator-icp-playbooks.js";
|
||||
import type { CuratorIcpId } from "./icp-registry.js";
|
||||
import { runCuratorOnboardingLoop } from "./curator-onboarding-loop.js";
|
||||
|
||||
type CuratorActorState = {
|
||||
@@ -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{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");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { CuratorIcpPlaybook } from "./curator-icp-playbooks.js";
|
||||
import type { CuratorIcpPlaybook } from "./icp-registry.js";
|
||||
import type { CuratorUserContext } from "./curator-user-context.js";
|
||||
|
||||
export const CURATOR_PROMPT_VERSION = "service-curation-v1";
|
||||
|
||||
@@ -4,6 +4,8 @@ import { createClient, type Client } from "rivetkit/client";
|
||||
import { requireUser, type AuthContext } from "../../auth/clerk.js";
|
||||
import { config } from "../../config.js";
|
||||
import type { Registry } from "../../actors/registry.js";
|
||||
import { curatorService } from "./curator-actor.js";
|
||||
import { buildCuratorSprint, buildCuratorTasks } from "./curator-store.js";
|
||||
|
||||
let _client: Client<Registry> | null = null;
|
||||
function getClient(): Client<Registry> {
|
||||
@@ -25,7 +27,7 @@ const chatSchema = z.object({
|
||||
|
||||
const curationPreviewSchema = z.object({
|
||||
startDate: z.string().optional(),
|
||||
icpId: z.enum(["student_recent_grad", "intern", "fresher_early_professional", "experienced_professional"]).optional(),
|
||||
icpId: z.enum(["student_recent_grad", "intern", "fresher_early_professional", "experienced_professional", "freelancer", "founder", "enterprise"]).optional(),
|
||||
goals: z.array(z.string()).optional(),
|
||||
userContext: z.record(z.unknown()).optional(),
|
||||
});
|
||||
@@ -56,18 +58,25 @@ export function v1CuratorRoutes() {
|
||||
|
||||
app.get("/today", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
return c.json(await getCuratorActor(userId).getToday({ userId, date: c.req.query("date") }));
|
||||
return c.json({ tasks: await buildCuratorTasks(userId, c.req.query("date")) });
|
||||
});
|
||||
|
||||
app.get("/sprint", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
return c.json(await getCuratorActor(userId).getSprint({ userId, date: c.req.query("date") }));
|
||||
const icpQuery = c.req.query("icpId");
|
||||
const dayQuery = c.req.query("dayIndex");
|
||||
const icpId = curationPreviewSchema.shape.icpId.safeParse(icpQuery).success ? icpQuery as NonNullable<z.infer<typeof curationPreviewSchema>["icpId"]> : undefined;
|
||||
const dayIndex = dayQuery ? Number.parseInt(dayQuery, 10) : undefined;
|
||||
return c.json(await buildCuratorSprint(userId, c.req.query("date"), {
|
||||
icpId,
|
||||
dayIndex: Number.isFinite(dayIndex) ? dayIndex : undefined,
|
||||
}));
|
||||
});
|
||||
|
||||
app.post("/curation/preview", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = curationPreviewSchema.parse(await c.req.json().catch(() => ({})));
|
||||
return c.json(await getCuratorActor(userId).previewCuration({ userId, ...body }));
|
||||
return c.json(await curatorService.previewCuration({ userId, ...body }));
|
||||
});
|
||||
|
||||
app.post("/onboarding/run", async (c) => {
|
||||
|
||||
+108
-881
File diff suppressed because it is too large
Load Diff
@@ -499,7 +499,7 @@ export function buildCuratorTools(ctx: { userId: string; date: string; conversat
|
||||
prepare_qscore_review: tool({
|
||||
description: "Disabled for curator task handoffs; QScore is read-only for dashboard scoring and should not be assigned as a curator task.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async () => ({ error: "qscore_curator_handoff_disabled", replacementServices: ["assessment-service", "interview-service", "roleplay-service", "resume-service", "matchmaking-service", "courses-service", "social-branding-service"] }),
|
||||
execute: async () => ({ error: "qscore_curator_handoff_disabled", replacementServices: ["interview-service", "roleplay-service", "resume-service", "matchmaking-service", "courses-service"] }),
|
||||
}),
|
||||
|
||||
emit_curator_event: tool({
|
||||
|
||||
@@ -10,6 +10,8 @@ export const curatorServiceIdSchema = z.enum([
|
||||
"qscore-service",
|
||||
"social-branding-service",
|
||||
"matchmaking-service",
|
||||
"expert-service",
|
||||
"events-service",
|
||||
]);
|
||||
|
||||
export type CuratorServiceId = z.infer<typeof curatorServiceIdSchema>;
|
||||
@@ -75,6 +77,7 @@ export const curatorPlanDaySchema = z.object({
|
||||
weekTheme: z.string(),
|
||||
weekSummary: z.string(),
|
||||
focus: z.string().optional(),
|
||||
outcome: z.string().optional(),
|
||||
plannedServices: z.array(curatorServiceIdSchema).max(4).default([]),
|
||||
generationStatus: z.enum(["seeded", "generated", "adapted"]).default("seeded"),
|
||||
adaptationReason: z.string().optional(),
|
||||
|
||||
@@ -4,7 +4,10 @@ export type CuratorIcpId =
|
||||
| "student_recent_grad"
|
||||
| "intern"
|
||||
| "fresher_early_professional"
|
||||
| "experienced_professional";
|
||||
| "experienced_professional"
|
||||
| "freelancer"
|
||||
| "founder"
|
||||
| "enterprise";
|
||||
|
||||
export type CuratorPlaybookAction = {
|
||||
taskType: CuratorTaskType;
|
||||
@@ -81,6 +84,45 @@ export const CURATOR_ICP_PLAYBOOKS: Record<CuratorIcpId, CuratorIcpPlaybook> = {
|
||||
play("practice", "roleplay-service", "negotiation and pushback", "Practice compensation, scope, promotion, and objection conversations.", "roleplay builder", ["negotiation", "pushback"]),
|
||||
],
|
||||
},
|
||||
freelancer: {
|
||||
id: "freelancer",
|
||||
label: "Freelancer / Gig User",
|
||||
sprintTheme: "Freelancer Business Sprint",
|
||||
goal: "Build a stronger freelancer profile, portfolio proof, client conversation readiness, and business learning momentum.",
|
||||
stageLabels: ["Brand + Portfolio", "Package Value", "Trust + Cases", "Client Pipeline", "Scale Forward"],
|
||||
serviceActions: [
|
||||
play("proof", "resume-service", "portfolio proof", "Package portfolio, services, case studies, and proposal assets.", "resume workspace", ["portfolio", "proposal"]),
|
||||
play("practice", "roleplay-service", "client conversations", "Practice discovery, pricing, objections, and negotiation.", "roleplay builder", ["client discovery", "negotiation"]),
|
||||
play("practice", "interview-service", "client pitch", "Practice concise pitch and client-facing presentation.", "interview preview", ["client pitch", "presentation"]),
|
||||
play("measurement", "courses-service", "business learning", "Complete practical learning for sales, pricing, and marketing.", "courses", ["business sprint", "learning"]),
|
||||
],
|
||||
},
|
||||
founder: {
|
||||
id: "founder",
|
||||
label: "Founder / Solopreneur",
|
||||
sprintTheme: "Founder Acceleration Sprint",
|
||||
goal: "Build founder proof, pitch readiness, customer conversation practice, and focused learning momentum.",
|
||||
stageLabels: ["Vision + Proof", "Validate Idea", "Credibility", "Find Market", "Launch Forward"],
|
||||
serviceActions: [
|
||||
play("proof", "resume-service", "founder proof", "Create founder story, venture overview, pitch assets, and product proof.", "resume workspace", ["founder story", "pitch assets"]),
|
||||
play("practice", "interview-service", "founder pitch", "Practice investor, customer, and media-style pitch delivery.", "interview preview", ["founder pitch", "investor"]),
|
||||
play("practice", "roleplay-service", "customer discovery", "Simulate customer, sales, partnership, and negotiation conversations.", "roleplay builder", ["customer discovery", "sales"]),
|
||||
play("measurement", "courses-service", "founder learning", "Complete focused founder learning for validation, growth, or fundraising.", "courses", ["founder learning", "validation"]),
|
||||
],
|
||||
},
|
||||
enterprise: {
|
||||
id: "enterprise",
|
||||
label: "Enterprise",
|
||||
sprintTheme: "Enterprise Capability Sprint",
|
||||
goal: "Activate workforce capability proof, leadership practice, hiring calibration, and learning pathways.",
|
||||
stageLabels: ["Workforce Baseline", "Talent Measurement", "Capability Building", "Hiring Excellence", "Future Ready"],
|
||||
serviceActions: [
|
||||
play("proof", "resume-service", "capability documentation", "Generate competency frameworks, hiring profiles, and workforce documentation.", "resume workspace", ["competency", "hiring profiles"]),
|
||||
play("practice", "interview-service", "interview calibration", "Standardize interview questions, rubrics, and hiring simulations.", "interview preview", ["interview calibration", "rubrics"]),
|
||||
play("practice", "roleplay-service", "leadership simulation", "Practice leadership, coaching, conflict, and stakeholder conversations.", "roleplay builder", ["leadership", "coaching"]),
|
||||
play("measurement", "courses-service", "learning pathways", "Assign or complete focused capability learning pathways.", "courses", ["learning pathways", "capability"]),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function isCuratorIcpId(value: string): value is CuratorIcpId {
|
||||
@@ -0,0 +1,257 @@
|
||||
import type { CuratorIcpId } from "./icp-registry.js";
|
||||
import type { CuratorServiceId, CuratorTaskType } from "./curator-types.js";
|
||||
|
||||
export type TaskSeed = {
|
||||
taskType: CuratorTaskType;
|
||||
serviceId: CuratorServiceId;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
effort: string;
|
||||
qxImpact: string;
|
||||
cta: string;
|
||||
signals: string[];
|
||||
};
|
||||
|
||||
export type WeekTemplate = {
|
||||
theme: string;
|
||||
summary: string;
|
||||
days: Array<{
|
||||
focus?: string;
|
||||
outcome?: string;
|
||||
measurement: TaskSeed;
|
||||
proof: TaskSeed;
|
||||
practice: TaskSeed;
|
||||
roleplay?: TaskSeed;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type CuratorIcpTemplateSet = {
|
||||
id: CuratorIcpId;
|
||||
label: string;
|
||||
sprintTheme: string;
|
||||
goal: string;
|
||||
weeks: WeekTemplate[];
|
||||
};
|
||||
|
||||
function unavailableCta(serviceId: CuratorServiceId, fallback: string) {
|
||||
if (
|
||||
serviceId === "social-branding-service" ||
|
||||
serviceId === "qscore-service" ||
|
||||
serviceId === "assessment-service" ||
|
||||
serviceId === "expert-service" ||
|
||||
serviceId === "events-service"
|
||||
) {
|
||||
return "View service status";
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
|
||||
export const CURATOR_TASK_REGISTRY: Record<CuratorIcpId, CuratorIcpTemplateSet> = {
|
||||
student_recent_grad: staticIcpTemplate("student_recent_grad", "Student / Recent Grad", "7-Day Student CareerSprint", "Build a professional identity, career assets, interview confidence, and learning momentum.", [
|
||||
day("Start Your Story", "Career profile established with a measurable readiness baseline.", "Claim Your Corner", "Complete your GrowQR profile with education, interests, career goals and profile photo.", "social-branding-service", "Know Your Number", "Review your QX Score, Career Dashboard and complete your Career Readiness Assessment.", "qscore-service", "Resume Rescue", "Create your first AI-powered resume using your academic achievements.", "resume-service", "Pitch Perfect", "Record a 60-second self-introduction for recruiters and mentors.", "interview-service"),
|
||||
day("Discover Your Strengths", "Stronger professional identity and clearer career direction.", "Headline Hero", "Write a compelling professional headline and About section.", "social-branding-service", "Skill Gap Radar", "Review assessment insights and identify your top three improvement areas.", "assessment-service", "Project Spotlight", "Add one academic project, internship or extracurricular achievement.", "resume-service", "Manager Mode", "Practice introducing yourself in a professional workplace conversation.", "roleplay-service"),
|
||||
day("Build Credibility", "Career proof strengthened with interview feedback received.", "Proof Beats Promises", "Upload certificates, awards or extracurricular achievements.", "social-branding-service", "Career Snapshot", "Review ATS readiness, profile completion and Dashboard insights.", "qscore-service", "Portfolio Power", "Create a simple academic portfolio or project showcase.", "resume-service", "Interview Replay", "Complete a mock interview and review AI-generated feedback.", "interview-service"),
|
||||
day("Explore Opportunities", "Opportunity pipeline activated and application assets prepared.", "Go Visible", "Update your LinkedIn and GrowQR professional profile.", "social-branding-service", "Opportunity Radar", "Review AI-matched internships, campus placements and research opportunities.", "matchmaking-service", "Cover Letter Boost", "Generate a personalized cover letter for a selected opportunity.", "resume-service", "Group Discussion Lab", "Practice communication through an AI group discussion simulation.", "roleplay-service"),
|
||||
day("Learn To Grow", "Skills improved with measurable learning progress.", "Show Your Progress", "Publish your latest certification or learning achievement.", "social-branding-service", "Growth Snapshot", "Review Dashboard, updated QX Score and weekly readiness improvements.", "qscore-service", "Learning Sprint", "Complete one AI-recommended course to close a skill gap.", "courses-service", "Communication Lab", "Practice workplace communication and presentation skills.", "roleplay-service"),
|
||||
day("Grow Your Network", "Professional network expanded with expert guidance.", "Network Smart", "Connect with alumni, mentors, faculty members and professionals.", "social-branding-service", "Career Intelligence", "Review networking suggestions and recruiter recommendations.", "matchmaking-service", "Expert Connect", "Book a mentor session for resume review or career guidance.", "expert-service", "Career Connect Live", "Attend a GrowQR workshop, employer webinar or networking event.", "events-service"),
|
||||
day("Ready For Tomorrow", "Career-ready profile completed with a personalized action plan for internships and first-job opportunities.", "Ready To Shine", "Share your weekly achievements and future career aspirations.", "social-branding-service", "Career Scorecard", "Review Dashboard, updated QX Score and weekly progress report.", "qscore-service", "Career Blueprint", "Generate your AI-powered Career Growth Blueprint and apply for your best-matched opportunity.", "resume-service", "Mock Finale", "Complete a full AI interview simulation for your dream internship or first job.", "interview-service"),
|
||||
]),
|
||||
intern: staticIcpTemplate("intern", "Intern / Early Career", "7-Day Early CareerSprint", "Convert internship work into career proof, stronger interviews, workplace confidence, and learning momentum.", [
|
||||
day("Own Your Journey", "Professional profile established with career readiness baseline.", "Claim Your Corner", "Complete your GrowQR profile with internship experience, skills and career goals.", "social-branding-service", "Know Your Number", "Review your QX Score, Career Dashboard and complete your Career Readiness Assessment.", "qscore-service", "Resume Reloaded", "Build an AI-optimized resume tailored to your target role.", "resume-service", "Pitch Perfect", "Record a 60-second professional introduction highlighting your internship experience.", "interview-service"),
|
||||
day("Show Your Growth", "Internship experience converted into impactful career proof.", "Headline Hero", "Write a compelling professional headline and update your About section.", "social-branding-service", "Career Snapshot", "Review recruiter visibility, ATS readiness and internship-to-job readiness.", "qscore-service", "Achievement Spotlight", "Transform internship tasks into measurable achievements.", "resume-service", "Recruiter Connect", "Practice an HR screening interview.", "roleplay-service"),
|
||||
day("Build Credibility", "Professional credibility enhanced through measurable work examples.", "Proof Beats Promises", "Publish one internship project, certification or professional achievement.", "social-branding-service", "Skill Gap Radar", "Review competency assessment and identify improvement areas.", "assessment-service", "Project Showcase", "Document a project demonstrating business impact.", "resume-service", "Manager Mode", "Practice workplace communication with a reporting manager.", "roleplay-service"),
|
||||
day("Find Better Opportunities", "Career documents optimized and opportunity pipeline activated.", "Go Visible", "Strengthen your LinkedIn and GrowQR professional profile.", "social-branding-service", "Opportunity Radar", "Review AI-recommended early-career roles and opportunities.", "matchmaking-service", "Application Pack", "Generate a customized resume and cover letter for a selected role.", "resume-service", "Workplace Conversations", "Practice collaboration and workplace scenarios.", "roleplay-service"),
|
||||
day("Strengthen Your Skills", "Skill gaps addressed and interview confidence improved.", "Build Authority", "Share a certification, learning milestone or project update.", "social-branding-service", "Growth Snapshot", "Review Dashboard insights, QX Score movement and interview readiness.", "qscore-service", "Learning Sprint", "Complete an AI-recommended course aligned with your career goals.", "courses-service", "Interview Replay", "Complete a mock interview and implement AI feedback.", "interview-service"),
|
||||
day("Expand Your Network", "Professional network expanded with expert guidance and recruiter exposure.", "Network Smart", "Connect with recruiters, mentors, managers and industry professionals.", "social-branding-service", "Career Intelligence", "Review networking opportunities and recruiter engagement.", "matchmaking-service", "Expert Connect", "Book an expert session for career guidance or resume review.", "expert-service", "Career Connect Live", "Attend a GrowQR networking session, employer webinar or career event.", "events-service"),
|
||||
day("Step Into Opportunity", "Career-ready profile completed with active applications and a personalized growth roadmap.", "Ready To Rise", "Publish your weekly achievements and career aspirations.", "social-branding-service", "Career Scorecard", "Review Dashboard, updated QX Score and weekly employability report.", "qscore-service", "Career Blueprint", "Generate your AI-powered Career Advancement Blueprint and submit applications to your top-matched roles.", "resume-service", "Mock Finale", "Complete a full interview simulation for your preferred job role.", "interview-service"),
|
||||
]),
|
||||
fresher_early_professional: staticIcpTemplate("fresher_early_professional", "Fresher / Active Job Seeker", "7-Day Job Seeker CareerSprint", "Improve employability, recruiter visibility, interview readiness, and application quality.", [
|
||||
day("Start Strong", "Professional profile completed with a measurable employability baseline.", "Claim Your Corner", "Complete your GrowQR profile with education, skills, career goals and professional photo.", "social-branding-service", "Know Your Number", "Review your QX Score, Career Dashboard and complete your Career Readiness Assessment.", "qscore-service", "Resume Rescue", "Build your first AI-optimized resume for your preferred job role.", "resume-service", "Pitch Perfect", "Record a 60-second self-introduction for recruiters.", "interview-service"),
|
||||
day("Build Visibility", "Resume optimized for recruiter searches and stronger first impressions.", "Headline Hero", "Write an attention-grabbing professional headline and About section.", "social-branding-service", "Career Snapshot", "Review profile completion, ATS readiness and recruiter visibility.", "qscore-service", "Keyword Lift", "Optimize your resume with AI-recommended keywords.", "resume-service", "Recruiter Connect", "Practice a recruiter screening conversation.", "roleplay-service"),
|
||||
day("Show Your Potential", "Stronger proof of capability and improved interview awareness.", "Proof Beats Promises", "Publish an academic project, internship or achievement.", "social-branding-service", "Skill Gap Radar", "Review your capability assessment and identify priority skills.", "assessment-service", "Project Spotlight", "Add one detailed project with measurable outcomes.", "resume-service", "Interview Replay", "Complete a behavioural mock interview and review AI feedback.", "interview-service"),
|
||||
day("Find Better Opportunities", "Career documents prepared and opportunity pipeline activated.", "Go Visible", "Update your LinkedIn and GrowQR professional presence.", "social-branding-service", "Opportunity Radar", "Review AI-matched jobs, internships and career opportunities.", "matchmaking-service", "Cover Letter Boost", "Generate a personalized cover letter for a target role.", "resume-service", "Manager Mode", "Practice workplace communication with an AI manager simulation.", "roleplay-service"),
|
||||
day("Grow Your Skills", "Skill gaps reduced and communication confidence improved.", "Build Authority", "Share one learning milestone or certification.", "social-branding-service", "Growth Snapshot", "Review Dashboard insights, QX Score movement and profile strength.", "qscore-service", "Learning Sprint", "Complete one AI-recommended skill course.", "courses-service", "Communication Lab", "Practice workplace communication and confidence exercises.", "roleplay-service"),
|
||||
day("Expand Your Network", "Professional network expanded with expert guidance received.", "Network Smart", "Connect with recruiters, alumni and professionals in your field.", "social-branding-service", "Career Intelligence", "Review networking opportunities and recruiter recommendations.", "matchmaking-service", "Expert Connect", "Book a mentor session for resume or career guidance.", "expert-service", "Career Connect Live", "Attend a GrowQR hiring webinar or networking event.", "events-service"),
|
||||
day("Launch Your Career", "Job-ready profile completed with applications submitted and clear next steps.", "Ready To Shine", "Publish your career goals and professional achievements.", "social-branding-service", "Career Scorecard", "Review Dashboard, updated QX Score and weekly progress report.", "qscore-service", "Application Ready", "Generate your AI-powered Career Action Blueprint and apply to your best-matched role.", "resume-service", "Mock Finale", "Complete a full AI interview simulation for your target role.", "interview-service"),
|
||||
]),
|
||||
experienced_professional: staticIcpTemplate("experienced_professional", "Experienced Professional", "7-Day Leadership CareerSprint", "Strengthen leadership proof, senior interview readiness, conversation confidence, and learning momentum.", [
|
||||
day("Lead With Purpose", "Executive profile established with leadership readiness baseline.", "Own Your Executive Brand", "Complete your GrowQR executive profile, leadership bio and career vision.", "social-branding-service", "Know Your Number", "Review Executive QX Score, Dashboard and Leadership Assessment.", "qscore-service", "Executive Resume", "Create a leadership-focused resume highlighting measurable business impact.", "resume-service", "Executive Introduction", "Record a 90-second executive leadership introduction.", "interview-service"),
|
||||
day("Build Executive Presence", "Executive positioning strengthened with measurable business impact.", "Thought Leader Mode", "Publish your leadership philosophy or industry insight.", "social-branding-service", "Leadership Pulse", "Review leadership strengths, competency gaps and opportunity insights.", "matchmaking-service", "Impact Portfolio", "Document major achievements, transformation projects and business outcomes.", "resume-service", "Boardroom Briefing", "Practice executive stakeholder communication.", "roleplay-service"),
|
||||
day("Show Strategic Value", "Leadership capability validated with executive proof assets.", "Influence In Action", "Share a case study, article or strategic business insight.", "social-branding-service", "Capability Scan", "Review leadership capability assessment and organizational fit.", "assessment-service", "Promotion Portfolio", "Prepare evidence for promotions, executive interviews or board appointments.", "resume-service", "Executive Interview", "Complete a senior leadership mock interview.", "interview-service"),
|
||||
day("Expand Your Influence", "Executive opportunities identified and leadership brand elevated.", "Network With Purpose", "Strengthen your executive network and update your professional presence.", "social-branding-service", "Opportunity Scan", "Review senior leadership roles, advisory positions and board opportunities.", "matchmaking-service", "Executive Brand Kit", "Optimize executive profile, LinkedIn and professional portfolio.", "resume-service", "Negotiation Room", "Practice salary, board and leadership negotiations.", "roleplay-service"),
|
||||
day("Future-Proof Leadership", "Leadership capabilities strengthened through targeted executive learning.", "Executive Visibility", "Publish a leadership perspective or innovation story.", "social-branding-service", "Growth Snapshot", "Review Dashboard, QX trends and executive readiness analytics.", "qscore-service", "Leadership Learning", "Complete an executive leadership or AI strategy course.", "courses-service", "Crisis Leadership", "Practice managing high-pressure executive scenarios.", "roleplay-service"),
|
||||
day("Build Strategic Relationships", "Expanded strategic network and leadership guidance established.", "Executive Connector", "Engage with senior professionals, mentors and industry communities.", "social-branding-service", "Market Intelligence", "Review strategic collaborations, board roles and executive opportunities.", "matchmaking-service", "Expert Connect", "Book a mentoring session with an industry leader or executive coach.", "expert-service", "Leadership Forum", "Attend an executive webinar or GrowQR leadership event.", "events-service"),
|
||||
day("Lead The Future", "Executive CareerSprint completed with a measurable leadership roadmap and next-level career strategy.", "Executive Spotlight", "Publish your leadership roadmap and professional milestones.", "social-branding-service", "Leadership Scorecard", "Review Dashboard, Executive QX Score and weekly progress analytics.", "qscore-service", "Career Blueprint", "Generate an AI-powered Executive Growth & Leadership Blueprint.", "resume-service", "Boardroom Simulation", "Deliver a complete executive presentation and strategic decision simulation.", "roleplay-service"),
|
||||
]),
|
||||
freelancer: staticIcpTemplate("freelancer", "Freelancer / Gig User", "7-Day Freelancer CareerSprint", "Build trust, package freelance proof, practice client conversations, and improve business skills.", [
|
||||
day("Build Your Brand", "Professional freelance identity established with measurable business baseline.", "Claim Your Corner", "Complete your freelancer profile, niche and service offerings.", "social-branding-service", "Know Your Number", "Review Freelancer QX Score, Dashboard and Business Readiness Assessment.", "qscore-service", "Portfolio Rescue", "Upload your best work, portfolio and service profile.", "resume-service", "Client Pitch", "Record a 60-second introduction for prospective clients.", "interview-service"),
|
||||
day("Package Your Value", "Clear service positioning and improved client readiness.", "Show Your Expertise", "Publish your services and professional strengths.", "social-branding-service", "Opportunity Radar", "Review recommended freelance projects and client opportunities.", "matchmaking-service", "Service Showcase", "Create attractive service packages and pricing overview.", "resume-service", "Discovery Call", "Practice your first client consultation.", "roleplay-service"),
|
||||
day("Earn Trust", "Stronger credibility through real business proof.", "Proof Beats Promises", "Publish testimonials or client success stories.", "social-branding-service", "Trust Meter", "Track profile strength and credibility indicators.", "qscore-service", "Case Study Builder", "Document one successful project with measurable outcomes.", "resume-service", "Client Objection Lab", "Practice handling pricing and client objections.", "roleplay-service"),
|
||||
day("Find More Clients", "Qualified client pipeline established.", "Go Visible", "Share a professional insight or portfolio update.", "social-branding-service", "Lead Finder", "Review AI-matched projects and client opportunities.", "matchmaking-service", "Proposal Builder", "Generate a professional proposal template.", "resume-service", "Sales Conversation", "Simulate a client proposal presentation.", "roleplay-service"),
|
||||
day("Grow Your Business", "Business skills strengthened and pricing confidence improved.", "Build Authority", "Publish expertise through LinkedIn or GrowQR community.", "social-branding-service", "Business Snapshot", "Review Dashboard, earnings potential and pipeline analytics.", "qscore-service", "Learning Sprint", "Complete a course on sales, marketing or pricing.", "courses-service", "Negotiation Lab", "Practice pricing and contract negotiation.", "roleplay-service"),
|
||||
day("Expand Your Network", "Expanded professional network and collaboration opportunities.", "Community Builder", "Connect with clients, creators and professionals.", "social-branding-service", "Opportunity Pulse", "Review collaborations, referrals and strategic matches.", "matchmaking-service", "Expert Connect", "Book a mentor or business expert consultation.", "expert-service", "Freelancer Meetup", "Attend a GrowQR networking event or webinar.", "events-service"),
|
||||
day("Scale Forward", "Business growth roadmap completed with stronger client acquisition strategy.", "Trusted Professional", "Publish your weekly growth achievements and client success.", "social-branding-service", "Growth Scorecard", "Review Dashboard, Freelancer QX Score and weekly business insights.", "qscore-service", "Growth Blueprint", "Generate your AI-powered Client Growth & Business Roadmap.", "resume-service", "Premium Client Meeting", "Deliver a complete client pitch simulation.", "interview-service"),
|
||||
]),
|
||||
founder: staticIcpTemplate("founder", "Founder / Solopreneur", "7-Day Founder Acceleration Sprint", "Validate venture proof, strengthen founder credibility, and practice customer or investor conversations.", [
|
||||
day("Own Your Vision", "Founder identity established with a measurable business baseline.", "Build In Public", "Complete your Founder Profile, business description and company branding.", "social-branding-service", "Know Your Number", "Review Founder QX Score, Dashboard and Startup Readiness Assessment.", "qscore-service", "Founder Story", "Create your founder profile, mission and venture overview.", "resume-service", "Founder Pitch", "Record a 90-second startup introduction.", "interview-service"),
|
||||
day("Validate Your Idea", "Product positioning clarified through customer validation.", "Mission Matters", "Publish your vision and value proposition.", "social-branding-service", "Market Radar", "Review customer opportunities and industry trends.", "matchmaking-service", "Problem–Solution Canvas", "Build your customer problem statement and solution.", "resume-service", "Customer Discovery", "Practice customer discovery conversations.", "roleplay-service"),
|
||||
day("Build Credibility", "Investor-ready business story developed.", "Show Your Progress", "Share an update about your product or startup.", "social-branding-service", "Traction Pulse", "Track business readiness and founder score.", "qscore-service", "Pitch Deck Builder", "Create an investor-ready pitch deck.", "resume-service", "Investor Warm-Up", "Practice investor introduction and elevator pitch.", "interview-service"),
|
||||
day("Find Your Market", "Business proof strengthened with qualified opportunities identified.", "Become Discoverable", "Optimize founder profile and startup page.", "social-branding-service", "Opportunity Scan", "Explore customers, investors, grants and partnerships.", "matchmaking-service", "Business Portfolio", "Upload MVP screenshots, website or product demo.", "resume-service", "Sales Conversation", "Roleplay first customer sales meeting.", "roleplay-service"),
|
||||
day("Grow Smarter", "Founder capability expanded through learning and negotiation practice.", "Lead With Value", "Publish an industry insight or founder journey.", "social-branding-service", "Growth Snapshot", "Review Dashboard, customer pipeline and QX progress.", "qscore-service", "Learning Sprint", "Complete one founder-focused course.", "courses-service", "Negotiation Lab", "Practice pricing, partnership or investment negotiation.", "roleplay-service"),
|
||||
day("Build Your Network", "Strategic relationships established with founders, mentors and investors.", "Community Builder", "Engage with founders and startup communities.", "social-branding-service", "Network Intelligence", "Review strategic matches and collaboration opportunities.", "matchmaking-service", "Expert Connect", "Book a mentor session and refine your strategy.", "expert-service", "Founder Roundtable", "Attend a GrowQR startup event or networking session.", "events-service"),
|
||||
day("Launch Forward", "Founder completes a 7-day launch roadmap with measurable business readiness and a clear action plan.", "Founder Spotlight", "Publish your startup milestone and growth roadmap.", "social-branding-service", "Growth Scorecard", "Review Dashboard, Founder QX Score and capability improvements.", "qscore-service", "Startup Blueprint", "Generate your AI-powered Growth & Funding Blueprint.", "resume-service", "Vision Presentation", "Deliver your final investor/customer presentation.", "interview-service"),
|
||||
]),
|
||||
enterprise: staticIcpTemplate("enterprise", "Enterprise", "7-Day Enterprise Capability Sprint", "Activate workforce capability proof, structured hiring practice, leadership simulations, and learning pathways.", [
|
||||
day("Know Your Workforce", "Enterprise capability baseline established with organizational priorities identified.", "Shape Your Story", "Publish or refresh your Employer Value Proposition and company profile.", "social-branding-service", "Know Your Workforce", "Review Enterprise QX Score, Dashboard and workforce capability baseline.", "qscore-service", "Capability Blueprint", "Upload competency framework, hiring roles and workforce structure.", "resume-service", "Leadership Kickoff", "Complete an executive leadership simulation.", "roleplay-service"),
|
||||
day("Measure Your Talent", "Skill gaps identified and hiring standards aligned.", "Culture Showcase", "Highlight employee culture, achievements and workplace initiatives with LinkedIn Post.", "social-branding-service", "Skill Gap Radar", "Run organization-wide capability assessment.", "assessment-service", "Talent Profiles", "Create AI-ready hiring profiles and role descriptions.", "resume-service", "Interview Calibration", "Standardize interview questions with AI Interview Coach.", "roleplay-service"),
|
||||
day("Strengthen Capability", "Learning pathways activated and leadership coaching initiated.", "Lead With Purpose", "Publish a leadership or employer branding update.", "social-branding-service", "Capability Heatmap", "Review department-wise readiness dashboard.", "qscore-service", "Learning Pathways", "Assign personalized learning journeys to employees.", "courses-service", "Manager Coaching", "Practice performance review conversations.", "roleplay-service"),
|
||||
day("Build Better Hiring", "Hiring quality improved with standardized recruitment practices.", "Employer Spotlight", "Showcase hiring philosophy and employee success stories.", "social-branding-service", "Talent Match Scan", "Review AI talent recommendations and internal mobility insights.", "matchmaking-service", "Hiring Playbook", "Generate structured hiring toolkit and competency matrix.", "resume-service", "Hiring Lab", "Conduct structured interview simulations.", "roleplay-service"),
|
||||
day("Grow Leaders", "Leadership pipeline strengthened with succession planning.", "People First", "Share a postemployee development initiatives.", "social-branding-service", "Leadership Pulse", "Review leadership readiness dashboard and QX trends.", "qscore-service", "Leadership Portfolio", "Document succession plans and leadership competencies.", "resume-service", "Executive Roleplay", "Simulate stakeholder, boardroom and conflict scenarios.", "roleplay-service"),
|
||||
day("Activate Growth", "Organizational capability accelerated through expert intervention.", "Innovation Culture", "Promote innovation, awards and organizational achievements on social media.", "social-branding-service", "Growth Snapshot", "Track workforce learning completion and hiring analytics.", "qscore-service", "Expert Connect", "Book an expert consultation or capability workshop.", "expert-service", "Capability Workshop", "Attend GrowQR expert-led enterprise learning session.", "events-service"),
|
||||
day("Future Ready Enterprise", "Enterprise receives a complete workforce capability roadmap with measurable actions for hiring, learning, leadership and transformation.", "Future Forward", "Launch employer branding campaign across digital channels.", "social-branding-service", "Capability Scorecard", "Review Enterprise Dashboard, QX Score improvements and analytics.", "qscore-service", "Transformation Blueprint", "Generate AI-powered workforce transformation report.", "resume-service", "Leadership Summit", "Complete executive simulation and strategic planning workshop.", "events-service"),
|
||||
]),
|
||||
};
|
||||
|
||||
export function seed(
|
||||
taskType: CuratorTaskType,
|
||||
serviceId: CuratorServiceId,
|
||||
title: string,
|
||||
subtitle: string,
|
||||
effort: string,
|
||||
qxImpact: string,
|
||||
cta: string,
|
||||
signals: string[],
|
||||
): TaskSeed {
|
||||
return { taskType, serviceId, title, subtitle, effort, qxImpact, cta: unavailableCta(serviceId, cta), signals };
|
||||
}
|
||||
|
||||
|
||||
type StaticSprintDay = {
|
||||
focus: string;
|
||||
outcome: string;
|
||||
socialTitle: string;
|
||||
socialTask: string;
|
||||
socialServiceId: CuratorServiceId;
|
||||
measurementTitle: string;
|
||||
measurementTask: string;
|
||||
measurementServiceId: CuratorServiceId;
|
||||
proofTitle: string;
|
||||
proofTask: string;
|
||||
proofServiceId: CuratorServiceId;
|
||||
practiceTitle: string;
|
||||
practiceTask: string;
|
||||
practiceServiceId: CuratorServiceId;
|
||||
};
|
||||
|
||||
function day(
|
||||
focus: string,
|
||||
outcome: string,
|
||||
socialTitle: string,
|
||||
socialTask: string,
|
||||
socialServiceId: CuratorServiceId,
|
||||
measurementTitle: string,
|
||||
measurementTask: string,
|
||||
measurementServiceId: CuratorServiceId,
|
||||
proofTitle: string,
|
||||
proofTask: string,
|
||||
proofServiceId: CuratorServiceId,
|
||||
practiceTitle: string,
|
||||
practiceTask: string,
|
||||
practiceServiceId: CuratorServiceId,
|
||||
): StaticSprintDay {
|
||||
return {
|
||||
focus,
|
||||
outcome,
|
||||
socialTitle,
|
||||
socialTask,
|
||||
socialServiceId,
|
||||
measurementTitle,
|
||||
measurementTask,
|
||||
measurementServiceId,
|
||||
proofTitle,
|
||||
proofTask,
|
||||
proofServiceId,
|
||||
practiceTitle,
|
||||
practiceTask,
|
||||
practiceServiceId,
|
||||
};
|
||||
}
|
||||
|
||||
function staticWeekTemplate(days: StaticSprintDay[]): WeekTemplate {
|
||||
return {
|
||||
theme: "Static 7-Day CareerSprint",
|
||||
summary: "Static day-wise sprint tasks sourced from the GrowQR task library and 7-day sprint document.",
|
||||
days: days.map((item) => {
|
||||
return {
|
||||
focus: item.focus,
|
||||
outcome: item.outcome,
|
||||
measurement: seed(
|
||||
"proof",
|
||||
item.socialServiceId,
|
||||
item.socialTitle,
|
||||
item.socialTask,
|
||||
"10 min",
|
||||
"+6 projected",
|
||||
"Open social proof",
|
||||
["static social proof", item.socialTitle.toLowerCase()],
|
||||
),
|
||||
proof: seed(
|
||||
"measurement",
|
||||
item.measurementServiceId,
|
||||
item.measurementTitle,
|
||||
item.measurementTask,
|
||||
"10 min",
|
||||
"+6 projected",
|
||||
"Open measurement",
|
||||
["static measurement", item.measurementTitle.toLowerCase()],
|
||||
),
|
||||
practice: seed(
|
||||
"proof",
|
||||
item.proofServiceId,
|
||||
item.proofTitle,
|
||||
item.proofTask,
|
||||
"10 min",
|
||||
"+7 projected",
|
||||
"Open proof task",
|
||||
["static proof", item.proofTitle.toLowerCase()],
|
||||
),
|
||||
roleplay: seed(
|
||||
"practice",
|
||||
item.practiceServiceId,
|
||||
item.practiceTitle,
|
||||
item.practiceTask,
|
||||
"10 min",
|
||||
"+10 projected",
|
||||
"Open practice task",
|
||||
["static practice", item.practiceTitle.toLowerCase()],
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function staticIcpTemplate(
|
||||
id: CuratorIcpId,
|
||||
label: string,
|
||||
sprintTheme: string,
|
||||
goal: string,
|
||||
days: StaticSprintDay[],
|
||||
): CuratorIcpTemplateSet {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
sprintTheme,
|
||||
goal,
|
||||
weeks: [staticWeekTemplate(days)],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeActiveCuratorTaskSeed(task: TaskSeed): TaskSeed {
|
||||
return task;
|
||||
}
|
||||
|
||||
|
||||
export function templateSetFor(variantId: CuratorIcpId): CuratorIcpTemplateSet {
|
||||
return CURATOR_TASK_REGISTRY[variantId] ?? CURATOR_TASK_REGISTRY.fresher_early_professional;
|
||||
}
|
||||
Reference in New Issue
Block a user