fix(curator): enforce correlated task completion

This commit is contained in:
-Puter
2026-07-14 10:20:29 +05:30
parent 41f9f0dfec
commit 7b35d1e0c5
6 changed files with 248 additions and 68 deletions
@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import { getCompletionEvents } from "../src/services/service-registry.js";
import { isTaskCompletionEvent, planSeedsForVariant, type CuratorCompletionEvent } from "../src/v1/curator/curator-store.js";
import { templateSetFor } from "../src/v1/curator/task-registry.js";
import type { CuratorIcpId } from "../src/v1/curator/icp-registry.js";
const taskId = "curator-task-1";
const event = (type: string, correlatedTaskId = taskId): CuratorCompletionEvent => ({
type,
payload: {},
correlation: { taskId: correlatedTaskId, curatorTaskId: correlatedTaskId },
});
const roleplayEvents = getCompletionEvents("roleplay-service");
assert.deepEqual(roleplayEvents, ["roleplay.scenario.completed", "roleplay.feedback.generated"]);
assert.equal(isTaskCompletionEvent(taskId, roleplayEvents, event("roleplay.scenario.configured")), false);
assert.equal(isTaskCompletionEvent(taskId, roleplayEvents, event("roleplay.scenario.completed")), true);
assert.equal(isTaskCompletionEvent(taskId, roleplayEvents, event("roleplay.feedback.generated")), true);
assert.equal(isTaskCompletionEvent(taskId, roleplayEvents, event("roleplay.scenario.completed", "other-task")), false);
const courseEvents = getCompletionEvents("courses-service");
assert.deepEqual(courseEvents, ["course.completed"]);
assert.equal(isTaskCompletionEvent(taskId, courseEvents, event("course.generated")), false);
assert.equal(isTaskCompletionEvent(taskId, courseEvents, event("course.progress.recorded")), false);
assert.equal(isTaskCompletionEvent(taskId, courseEvents, event("course.completed")), true);
const socialEvents = getCompletionEvents("social-branding-service");
assert.equal(isTaskCompletionEvent(taskId, socialEvents, event("social.post.scheduled")), true);
assert.equal(isTaskCompletionEvent(taskId, socialEvents, event("brand.content.published")), true);
assert.equal(isTaskCompletionEvent(taskId, socialEvents, event("brand.post.drafted")), false);
const resumeEvents = getCompletionEvents("resume-service");
assert.equal(isTaskCompletionEvent(taskId, resumeEvents, event("resume.parsed")), false);
assert.equal(isTaskCompletionEvent(taskId, resumeEvents, event("resume.analysis.completed")), true);
const assessmentEvents = getCompletionEvents("assessment-service");
assert.equal(isTaskCompletionEvent(taskId, assessmentEvents, event("assessment.started")), false);
assert.equal(isTaskCompletionEvent(taskId, assessmentEvents, event("assessment.completed")), true);
const matchmakingEvents = getCompletionEvents("matchmaking-service");
assert.equal(isTaskCompletionEvent(taskId, matchmakingEvents, event("matchmaking.matches.generated")), false);
assert.equal(isTaskCompletionEvent(taskId, matchmakingEvents, event("matchmaking.match.dismissed")), false);
assert.equal(isTaskCompletionEvent(taskId, matchmakingEvents, event("matchmaking.matches.reviewed")), true);
const qscoreEvents = getCompletionEvents("qscore-service");
assert.equal(isTaskCompletionEvent(taskId, qscoreEvents, event("qscore.review.opened")), false);
assert.equal(isTaskCompletionEvent(taskId, qscoreEvents, event("qscore.weak_driver.reviewed")), true);
const icpIds: CuratorIcpId[] = [
"student_recent_grad",
"intern",
"fresher_early_professional",
"experienced_professional",
"freelancer",
"founder",
"enterprise",
];
for (const icpId of icpIds) {
const trialDays = planSeedsForVariant(templateSetFor(icpId), "2026-07-14", 2, "trial");
assert.equal(trialDays.length, 2, `${icpId} must expose exactly two trial days`);
for (const day of trialDays) {
assert.equal(day.plannedTasks.length, 4, `${icpId} day ${day.dayIndex} must expose four tasks`);
for (const [index, task] of day.plannedTasks.entries()) {
const completionEvents = getCompletionEvents(task.serviceId);
const syntheticTaskId = `${icpId}:day-${day.dayIndex}:task-${index + 1}`;
assert.ok(completionEvents.length > 0, `${syntheticTaskId} must have completion evidence`);
assert.equal(
isTaskCompletionEvent(syntheticTaskId, completionEvents, event(completionEvents[0]!, syntheticTaskId)),
true,
`${syntheticTaskId} must complete from its exact correlated service event`,
);
}
}
}
for (const serviceId of [
"resume-service",
"interview-service",
"roleplay-service",
"courses-service",
"social-branding-service",
"matchmaking-service",
"qscore-service",
]) {
assert.ok(getCompletionEvents(serviceId).length > 0, `${serviceId} must define completion evidence`);
}
console.log("curator service completion contract tests passed");
+26 -1
View File
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import { correlateEventWithServiceSession } from "../src/events/record-grow-event.js";
import { correlateEventWithServiceSession, normalizeServiceId, serviceEventExternalIds } from "../src/events/record-grow-event.js";
import { canonicalGrowEventType } from "../src/events/normalize.js";
import type { GrowEventEnvelope } from "../src/events/envelope.js";
const event: GrowEventEnvelope = {
@@ -58,4 +59,28 @@ const link = {
assert.deepEqual(explicit.mission, { missionId: "explicit-mission", stageId: "explicit-stage" });
}
{
assert.deepEqual(serviceEventExternalIds({
...event,
correlation: { courseId: "course-1", lesson_id: "lesson-1", externalId: "course-1" },
subject: { serviceId: "courses-service", externalId: "lesson-1" },
payload: { resume_id: "resume-1", assessmentId: "assessment-1" },
}), ["course-1", "lesson-1", "resume-1", "assessment-1"]);
}
{
assert.equal(normalizeServiceId("social-branding-service"), "brand");
assert.equal(normalizeServiceId("courses-service"), "course");
assert.equal(normalizeServiceId("cover-letter-service"), "cover_letter");
assert.equal(normalizeServiceId("matchmaking-v2"), "matchmaking");
}
{
assert.equal(canonicalGrowEventType("roleplay.session.completed"), "roleplay.scenario.completed");
assert.equal(canonicalGrowEventType("roleplay.review.completed"), "roleplay.feedback.generated");
assert.equal(canonicalGrowEventType("course.video.completed"), "course.completed");
assert.equal(canonicalGrowEventType("social.profile.synced"), "brand.profile.updated");
assert.equal(canonicalGrowEventType("resume.analysis.completed"), "resume.analysis.completed");
}
console.log("service session correlation tests passed");
+18 -1
View File
@@ -41,6 +41,23 @@ function normalizeCorrelation(value: unknown) {
return Object.keys(correlation).length ? correlation : undefined;
}
const CANONICAL_EVENT_TYPE: Record<string, string> = {
"interview.review.completed": "interview.feedback.generated",
"roleplay.session.completed": "roleplay.scenario.completed",
"roleplay.review.completed": "roleplay.feedback.generated",
"course.video.completed": "course.completed",
"course.lesson.completed": "course.completed",
"course.progress_recorded": "course.progress.recorded",
"social.profile.synced": "brand.profile.updated",
"social.profile.updated": "brand.profile.updated",
"social.post.scheduled": "brand.post.exported",
"social.post.published": "brand.content.published",
};
export function canonicalGrowEventType(type: string): string {
return CANONICAL_EVENT_TYPE[type] ?? type;
}
export function normalizeGrowEvent(input: unknown, overrides: { userId?: string; source?: string } = {}): GrowEventEnvelope {
const raw = asRecord(input);
const payload = asRecord(raw.payload ?? raw.data ?? raw.event_payload);
@@ -60,7 +77,7 @@ export function normalizeGrowEvent(input: unknown, overrides: { userId?: string;
})();
const source = overrides.source ?? getString(raw.source) ?? "unknown-service";
const type = getString(raw.type ?? raw.event_type ?? raw.action) ?? "service.event";
const type = canonicalGrowEventType(getString(raw.type ?? raw.event_type ?? raw.action) ?? "service.event");
const categoryRaw = raw.category ?? payload.category;
const category = isGrowEventCategory(categoryRaw) ? categoryRaw : "service";
const userId = overrides.userId ?? getString(raw.userId ?? raw.user_id ?? payload.userId ?? payload.user_id);
+70 -20
View File
@@ -1,4 +1,4 @@
import { and, desc, eq, sql } from "drizzle-orm";
import { and, desc, eq, or, sql } from "drizzle-orm";
import { db } from "../db/client.js";
import { growEvents, missionServiceSessions, users, type GrowEventRow } from "../db/schema.js";
import { asRecord, getString, type GrowEventEnvelope } from "./envelope.js";
@@ -44,25 +44,68 @@ export function correlateEventWithServiceSession(
return { ...event, correlation, mission };
}
export function serviceEventExternalIds(event: GrowEventEnvelope): string[] {
const correlation = event.correlation ?? {};
const payload = event.payload ?? {};
const subject = event.subject ?? {};
return [...new Set([
correlation.sessionId,
correlation.session_id,
correlation.externalId,
correlation.external_id,
correlation.resumeId,
correlation.resume_id,
correlation.courseId,
correlation.course_id,
correlation.lessonId,
correlation.lesson_id,
correlation.assessmentId,
correlation.assessment_id,
subject.externalId,
payload.sessionId,
payload.session_id,
payload.externalId,
payload.external_id,
payload.resumeId,
payload.resume_id,
payload.courseId,
payload.course_id,
payload.lessonId,
payload.lesson_id,
payload.assessmentId,
payload.assessment_id,
].map(getString).filter((value): value is string => Boolean(value)))];
}
function eventHasExternalId(event: Pick<GrowEventRow, "correlation" | "payload" | "subject">, externalIds: Set<string>) {
return serviceEventExternalIds({ correlation: asRecord(event.correlation), payload: asRecord(event.payload), subject: asRecord(event.subject) } as GrowEventEnvelope)
.some((value) => externalIds.has(value));
}
async function resolveUserId(event: GrowEventEnvelope): Promise<string | undefined> {
if (event.userId) return event.userId;
const sessionId = getString(event.correlation?.sessionId ?? event.correlation?.session_id);
const externalIds = serviceEventExternalIds(event);
const serviceId = normalizeServiceId(event.source);
if (sessionId) {
if (!externalIds.length) return undefined;
const [row] = await db
.select({ userId: missionServiceSessions.userId })
.from(missionServiceSessions)
.where(and(eq(missionServiceSessions.serviceId, serviceId), eq(missionServiceSessions.externalId, sessionId)))
.where(and(
eq(missionServiceSessions.serviceId, serviceId),
inArrayValues(missionServiceSessions.externalId, externalIds),
))
.limit(1);
if (row?.userId) return row.userId;
return row?.userId ?? undefined;
}
return undefined;
function inArrayValues(column: typeof missionServiceSessions.externalId, values: string[]) {
return or(...values.map((value) => eq(column, value)))!;
}
async function resolveServiceSessionLink(event: GrowEventEnvelope, userId: string | undefined): Promise<ServiceSessionLink | undefined> {
if (!userId) return undefined;
const sessionId = getString(event.correlation?.sessionId ?? event.correlation?.session_id);
if (!sessionId) return undefined;
const externalIds = serviceEventExternalIds(event);
if (!externalIds.length) return undefined;
const serviceId = normalizeServiceId(event.source);
const [row] = await db
.select({
@@ -76,7 +119,7 @@ async function resolveServiceSessionLink(event: GrowEventEnvelope, userId: strin
.where(and(
eq(missionServiceSessions.userId, userId),
eq(missionServiceSessions.serviceId, serviceId),
eq(missionServiceSessions.externalId, sessionId),
inArrayValues(missionServiceSessions.externalId, externalIds),
))
.limit(1);
@@ -84,16 +127,19 @@ async function resolveServiceSessionLink(event: GrowEventEnvelope, userId: strin
const rowTaskId = getString(rowMetadata.taskId ?? rowMetadata.curatorTaskId ?? rowMetadata.curator_task_id);
if (rowTaskId) return row;
const [prior] = await db
.select({ correlation: growEvents.correlation, payload: growEvents.payload, mission: growEvents.mission })
const externalIdSet = new Set(externalIds);
const candidates = await db
.select({ source: growEvents.source, correlation: growEvents.correlation, payload: growEvents.payload, mission: growEvents.mission, subject: growEvents.subject })
.from(growEvents)
.where(and(
eq(growEvents.userId, userId),
sql`${growEvents.correlation}->>'sessionId' = ${sessionId}`,
sql`coalesce(${growEvents.correlation}->>'taskId', ${growEvents.correlation}->>'curatorTaskId', ${growEvents.payload}->>'taskId', ${growEvents.payload}->>'curatorTaskId') is not null`,
))
.orderBy(desc(growEvents.occurredAt))
.limit(1);
.limit(100);
const prior = candidates.find((candidate) => (
normalizeServiceId(candidate.source) === serviceId && eventHasExternalId(candidate, externalIdSet)
));
if (!prior) return row;
const priorCorrelation = asRecord(prior.correlation);
const priorPayload = asRecord(prior.payload);
@@ -112,13 +158,17 @@ async function resolveServiceSessionLink(event: GrowEventEnvelope, userId: strin
}
export function normalizeServiceId(source: string): string {
if (source.includes("interview")) return "interview";
if (source.includes("roleplay")) return "roleplay";
if (source.includes("resume")) return "resume";
if (source.includes("course")) return "course";
if (source.includes("courses")) return "course";
if (source.includes("qscore")) return "qscore";
return source;
const normalized = source.toLowerCase();
if (normalized.includes("interview")) return "interview";
if (normalized.includes("roleplay")) return "roleplay";
if (normalized.includes("resume")) return "resume";
if (normalized.includes("cover-letter") || normalized.includes("cover_letter")) return "cover_letter";
if (normalized.includes("course")) return "course";
if (normalized.includes("qscore") || normalized.includes("q-score")) return "qscore";
if (normalized.includes("assessment")) return "assessment";
if (normalized.includes("matchmaking")) return "matchmaking";
if (normalized.includes("social") || normalized.includes("brand")) return "brand";
return normalized;
}
export async function recordGrowEvent(input: unknown, overrides: { userId?: string; source?: string } = {}): Promise<GrowEventRow> {
+8 -16
View File
@@ -284,7 +284,7 @@ const serviceRegistry: ServiceRecord[] = [
mode: "video",
},
toolName: "prepare_roleplay_preview",
completionEvents: ["roleplay.scenario.configured", "roleplay.scenario.completed", "roleplay.feedback.generated"],
completionEvents: ["roleplay.scenario.completed", "roleplay.feedback.generated"],
qscoreSignals: ["communication.roleplay", "networking.conversation", "readiness.practice"],
usage: "Include role, brief, mission state, and curatorTaskId when building stateful handoffs.",
},
@@ -372,7 +372,7 @@ const serviceRegistry: ServiceRecord[] = [
tab: "resumes",
},
toolName: "prepare_resume_upload",
completionEvents: ["resume.analysis.completed", "resume.parsed", "resume.updated", "resume.exported"],
completionEvents: ["resume.analysis.completed", "resume.updated", "resume.exported"],
qscoreSignals: ["proof.resume", "readiness.ats", "profile.skills"],
usage: "Include mission state and optional section when linking into resume work.",
},
@@ -524,7 +524,7 @@ const serviceRegistry: ServiceRecord[] = [
start: "Start course",
},
toolName: "prepare_course_handoff",
completionEvents: ["course.generated", "course.started", "course.progress_recorded", "course.lesson.completed", "course.completed", "course.weakness.applied"],
completionEvents: ["course.completed"],
qscoreSignals: ["courses.started", "courses.completed", "courses.completion_rate"],
usage: "Use for skill-gap tasks that should become learning work.",
},
@@ -534,7 +534,7 @@ const serviceRegistry: ServiceRecord[] = [
openEvents: ["task.opened"],
startedEvents: ["service.started", "course.started"],
configuredEvents: ["course.generated"],
completionEvents: ["service.completed", "course.progress_recorded", "course.lesson.completed", "course.completed", "course.weakness.applied"],
completionEvents: ["service.completed", "course.completed"],
abandonmentEvents: ["service.abandoned"],
qscoreSignalIds: ["courses.started", "courses.completed", "courses.completion_rate", "courses.difficulty", "courses.pathway_relevance"],
analyticsDimensions: ["course_id", "lesson_id", "progress_percentage", "weakness_tags"],
@@ -583,7 +583,7 @@ const serviceRegistry: ServiceRecord[] = [
review: "Review assessment",
},
toolName: "prepare_assessment_handoff",
completionEvents: ["assessment.started", "assessment.completed"],
completionEvents: ["assessment.completed"],
qscoreSignals: ["skills.assessment", "readiness.measurement"],
usage: "Use when a task needs a scoreable assessment rather than practice.",
},
@@ -646,14 +646,9 @@ const serviceRegistry: ServiceRecord[] = [
},
toolName: "prepare_matchmaking_handoff",
completionEvents: [
"matchmaking.matches.generated",
"matchmaking.feed.viewed",
"matchmaking.match.viewed",
"matchmaking.match.saved",
"matchmaking.match.dismissed",
"matchmaking.matches.reviewed",
"matchmaking.match.applied",
"matchmaking.application.started",
"matchmaking.application.completed",
],
qscoreSignals: ["matching.jobs_viewed", "matching.applications_submitted", "matching.match_rate"],
@@ -670,10 +665,7 @@ const serviceRegistry: ServiceRecord[] = [
configuredEvents: [],
completionEvents: [
"service.completed",
"matchmaking.matches.generated",
"matchmaking.match.viewed",
"matchmaking.match.saved",
"matchmaking.match.dismissed",
"matchmaking.matches.reviewed",
"matchmaking.match.applied",
"matchmaking.application.completed",
@@ -725,7 +717,7 @@ const serviceRegistry: ServiceRecord[] = [
review: "Review QScore",
},
toolName: "prepare_qscore_review",
completionEvents: ["qscore.baseline.created", "qscore.review.opened", "qscore.weak_driver.reviewed", "qscore.signal.projected", "qscore.updated"],
completionEvents: ["qscore.weak_driver.reviewed", "qscore.signal.projected", "qscore.updated"],
qscoreSignals: ["qscore.updated", "qscore.signal.projected"],
usage: "Use for measurement and projected readiness review tasks.",
},
@@ -785,7 +777,7 @@ const serviceRegistry: ServiceRecord[] = [
review: "Review profile",
},
toolName: "prepare_social_branding_handoff",
completionEvents: ["brand.post.drafted", "brand.post.edited", "brand.post.exported", "brand.profile.updated", "brand.content.published"],
completionEvents: ["brand.post.exported", "brand.profile.updated", "brand.content.published", "social.profile.updated", "social.post.scheduled", "social.post.published"],
qscoreSignals: ["profile.linkedin", "proof.visibility", "networking.brand"],
usage: "Use for profile visibility, LinkedIn cleanup, and social proof tasks.",
},
@@ -795,7 +787,7 @@ const serviceRegistry: ServiceRecord[] = [
openEvents: ["task.opened"],
startedEvents: ["service.started"],
configuredEvents: ["brand.post.drafted"],
completionEvents: ["service.completed", "brand.post.edited", "brand.post.exported", "brand.profile.updated", "brand.content.published"],
completionEvents: ["service.completed", "brand.post.exported", "brand.profile.updated", "brand.content.published", "social.profile.updated", "social.post.scheduled", "social.post.published"],
abandonmentEvents: ["service.abandoned"],
qscoreSignalIds: ["brand.visibility_signal", "profile.linkedin", "proof.visibility"],
analyticsDimensions: ["content_type", "channel", "draft_id", "topic", "profile_field_updated"],
+29 -22
View File
@@ -1,6 +1,6 @@
import { and, desc, eq, gte, inArray, sql } from "drizzle-orm";
import { db } from "../../db/client.js";
import { growEvents, missionArtifacts, onboarding, users } from "../../db/schema.js";
import { growEvents, missionArtifacts, onboarding, users, type GrowEventRow } from "../../db/schema.js";
import { listMissionDefinitions } from "../../missions/registry.js";
import { listServiceCapabilities } from "../../workflows/service-capabilities.js";
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../../services/qscore-proxy.js";
@@ -39,26 +39,25 @@ import { addDaysIso, computeStreakFromDays, todayIso } from "./streak-utils.js";
const VALID_COMPLETION_TYPES = [
"service.completed",
"resume.analysis.completed",
"resume.parsed",
"resume.updated",
"resume.exported",
"interview.session.completed",
"interview.feedback.generated",
"roleplay.scenario.configured",
"roleplay.scenario.completed",
"roleplay.feedback.generated",
"assessment.completed",
"course.progress_recorded",
"course.completed",
"course.lesson.completed",
"brand.post.exported",
"brand.profile.updated",
"matchmaking.matches.generated",
"matchmaking.feed.viewed",
"brand.content.published",
"social.profile.updated",
"social.post.scheduled",
"social.post.published",
"matchmaking.match.saved",
"matchmaking.matches.reviewed",
"matchmaking.match.applied",
"matchmaking.application.completed",
"qscore.weak_driver.reviewed",
"qscore.updated",
"qscore.signal.projected",
"curator.task.completed",
@@ -859,20 +858,9 @@ async function loadCurrentQScore(userId: string) {
return result?.q_score ?? null;
}
async function loadCompletionRows(userId: string, sinceDate: string) {
return db
.select()
.from(growEvents)
.where(and(
eq(growEvents.userId, userId),
inArray(growEvents.type, [...VALID_COMPLETION_TYPES]),
gte(growEvents.occurredAt, new Date(`${sinceDate}T00:00:00.000Z`)),
))
.orderBy(desc(growEvents.occurredAt));
}
export type CuratorCompletionEvent = Pick<GrowEventRow, "type" | "payload" | "correlation">;
function taskCompletedByTaskId(taskId: string, completionEvents: string[], rows: Awaited<ReturnType<typeof loadCompletionRows>>) {
return rows.some((row) => {
export function isTaskCompletionEvent(taskId: string, completionEvents: string[], row: CuratorCompletionEvent) {
const payload = row.payload ?? {};
const correlation = row.correlation ?? {};
const eventTaskId =
@@ -887,7 +875,22 @@ function taskCompletedByTaskId(taskId: string, completionEvents: string[], rows:
row.type === "service.completed" ||
completionEvents.includes(row.type)
);
});
}
async function loadCompletionRows(userId: string, sinceDate: string) {
return db
.select()
.from(growEvents)
.where(and(
eq(growEvents.userId, userId),
inArray(growEvents.type, [...VALID_COMPLETION_TYPES]),
gte(growEvents.occurredAt, new Date(`${sinceDate}T00:00:00.000Z`)),
))
.orderBy(desc(growEvents.occurredAt));
}
function taskCompletedByTaskId(taskId: string, completionEvents: string[], rows: CuratorCompletionEvent[]) {
return rows.some((row) => isTaskCompletionEvent(taskId, completionEvents, row));
}
function taskCompletedByEvents(task: CuratorTask, rows: Awaited<ReturnType<typeof loadCompletionRows>>) {
@@ -1285,7 +1288,11 @@ export async function buildCuratorStreak(userId: string): Promise<CuratorStreak>
day: sql<string>`to_char(${growEvents.occurredAt} AT TIME ZONE 'Asia/Kolkata', 'YYYY-MM-DD')`,
})
.from(growEvents)
.where(and(eq(growEvents.userId, userId), inArray(growEvents.type, [...VALID_COMPLETION_TYPES])))
.where(and(
eq(growEvents.userId, userId),
inArray(growEvents.type, [...VALID_COMPLETION_TYPES]),
sql`coalesce(${growEvents.correlation}->>'taskId', ${growEvents.correlation}->>'curatorTaskId', ${growEvents.payload}->>'taskId', ${growEvents.payload}->>'curatorTaskId') is not null`,
))
.groupBy(sql`to_char(${growEvents.occurredAt} AT TIME ZONE 'Asia/Kolkata', 'YYYY-MM-DD')`)
.orderBy(sql`to_char(${growEvents.occurredAt} AT TIME ZONE 'Asia/Kolkata', 'YYYY-MM-DD') desc`)
.limit(90);