feat(events): project course and matchmaking activity
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { extractQscoreSignals } from "../src/events/projectors/qscore-projector.js";
|
||||
import { buildMatchmakingGatewayEvent } from "../src/services/matchmaking-events.js";
|
||||
import type { GrowEventRow } from "../src/db/schema.js";
|
||||
|
||||
function event(overrides: Partial<GrowEventRow> & { type: string; payload: Record<string, unknown> }): GrowEventRow {
|
||||
return {
|
||||
id: `event-${overrides.type}`,
|
||||
userId: "user_test",
|
||||
orgId: null,
|
||||
source: "matchmaking-v2",
|
||||
type: overrides.type,
|
||||
category: "service",
|
||||
occurredAt: new Date("2026-07-09T00:00:00.000Z"),
|
||||
receivedAt: new Date("2026-07-09T00:00:01.000Z"),
|
||||
mission: overrides.mission ?? null,
|
||||
subject: overrides.subject ?? null,
|
||||
correlation: overrides.correlation ?? null,
|
||||
payload: overrides.payload,
|
||||
raw: {},
|
||||
dedupeKey: null,
|
||||
processingStatus: "pending",
|
||||
processingError: null,
|
||||
processedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
const saved = buildMatchmakingGatewayEvent({
|
||||
userId: "user_test",
|
||||
action: "mark_saved",
|
||||
body: {
|
||||
action: "mark_saved",
|
||||
params: {
|
||||
opportunity_id: "job_123",
|
||||
curatorTaskId: "task_123",
|
||||
missionId: "curator-sprint",
|
||||
missionInstanceId: "sprint_123",
|
||||
stageId: "stage_123",
|
||||
source: "curator-v1",
|
||||
},
|
||||
},
|
||||
result: { task_id: "a2a_123", status: "completed", messages: [] },
|
||||
});
|
||||
|
||||
assert.equal(saved.type, "matchmaking.match.saved");
|
||||
assert.deepEqual(saved.mission, {
|
||||
missionId: "curator-sprint",
|
||||
instanceId: "sprint_123",
|
||||
stageId: "stage_123",
|
||||
source: "curator-v1",
|
||||
});
|
||||
assert.deepEqual(saved.subject, {
|
||||
serviceId: "matchmaking",
|
||||
kind: "opportunity",
|
||||
id: "job_123",
|
||||
externalId: "job_123",
|
||||
});
|
||||
assert.equal(saved.correlation?.taskId, "task_123");
|
||||
assert.equal(saved.correlation?.curatorTaskId, "task_123");
|
||||
assert.equal(saved.correlation?.opportunityId, "job_123");
|
||||
assert.equal(saved.payload.curatorTaskId, "task_123");
|
||||
assert.equal(saved.payload.opportunityId, "job_123");
|
||||
assert.equal(saved.payload.action, "mark_saved");
|
||||
|
||||
const generatedSignals = extractQscoreSignals(event({
|
||||
type: "matchmaking.matches.generated",
|
||||
payload: {
|
||||
action: "run_search",
|
||||
matchCount: 6,
|
||||
scanned: 120,
|
||||
result: { status: "completed" },
|
||||
},
|
||||
}));
|
||||
assert.ok(
|
||||
generatedSignals.some((signal) => signal.signalId === "market.matches" && signal.score > 0),
|
||||
"generated matches should produce a market opportunity signal from match counts",
|
||||
);
|
||||
|
||||
const savedSignals = extractQscoreSignals(event({
|
||||
type: "matchmaking.match.saved",
|
||||
payload: {
|
||||
action: "mark_saved",
|
||||
opportunityId: "job_123",
|
||||
curatorTaskId: "task_123",
|
||||
result: { status: "completed" },
|
||||
},
|
||||
}));
|
||||
assert.ok(
|
||||
savedSignals.some((signal) => signal.signalId === "networking.opportunities" && signal.score > 0),
|
||||
"saved opportunities should produce an opportunity engagement signal without numeric match score",
|
||||
);
|
||||
|
||||
const reviewed = buildMatchmakingGatewayEvent({
|
||||
userId: "user_test",
|
||||
action: "record_feedback",
|
||||
body: {
|
||||
action: "record_feedback",
|
||||
params: { opportunity_id: "job_123", curatorTaskId: "task_123" },
|
||||
},
|
||||
result: { task_id: "a2a_456", status: "completed", messages: [] },
|
||||
});
|
||||
assert.equal(reviewed.type, "matchmaking.matches.reviewed");
|
||||
|
||||
const appliedSignals = extractQscoreSignals(event({
|
||||
type: "matchmaking.match.applied",
|
||||
payload: {
|
||||
action: "mark_applied",
|
||||
opportunityId: "job_123",
|
||||
curatorTaskId: "task_123",
|
||||
result: { status: "completed" },
|
||||
},
|
||||
}));
|
||||
assert.ok(
|
||||
appliedSignals.some((signal) => signal.signalId === "networking.opportunities" && signal.score >= 80),
|
||||
"applied opportunities should produce a stronger opportunity signal without numeric match score",
|
||||
);
|
||||
|
||||
console.log("matchmaking-events tests passed");
|
||||
process.exit(0);
|
||||
@@ -36,6 +36,7 @@ export const config = {
|
||||
interviewRedisUrl: process.env.INTERVIEW_REDIS_URL ?? process.env.GROW_EVENTS_REDIS_URL ?? process.env.REDIS_URL ?? "",
|
||||
roleplayRedisUrl: process.env.ROLEPLAY_REDIS_URL ?? process.env.GROW_EVENTS_REDIS_URL ?? process.env.REDIS_URL ?? "",
|
||||
resumeRedisUrl: process.env.RESUME_REDIS_URL ?? process.env.GROW_EVENTS_REDIS_URL ?? process.env.REDIS_URL ?? "",
|
||||
coursesRedisUrl: process.env.COURSES_REDIS_URL ?? process.env.COURSE_REDIS_URL ?? process.env.GROW_EVENTS_REDIS_URL ?? process.env.REDIS_URL ?? "",
|
||||
legacyServiceTaskObserverGroup: process.env.LEGACY_SERVICE_TASK_OBSERVER_GROUP ?? "growqr-backend-observer",
|
||||
|
||||
// LLM gateway for the unified user agent.
|
||||
|
||||
@@ -20,7 +20,7 @@ function normalizeSubject(value: unknown) {
|
||||
|
||||
function normalizeMission(value: unknown) {
|
||||
const record = asRecord(value);
|
||||
const instanceId = getString(record.instanceId ?? record.instance_id ?? record.mission_instance_id);
|
||||
const instanceId = getString(record.instanceId ?? record.instance_id ?? record.missionInstanceId ?? record.mission_instance_id);
|
||||
const missionId = getString(record.missionId ?? record.mission_id);
|
||||
const stageId = getString(record.stageId ?? record.stage_id);
|
||||
if (!instanceId && !missionId && !stageId) return undefined;
|
||||
|
||||
@@ -113,6 +113,28 @@ function extractRoleplaySignals(event: GrowEventRow): QscoreSignal[] {
|
||||
return signals;
|
||||
}
|
||||
|
||||
function extractCourseSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
const payload = event.payload ?? {};
|
||||
if (!event.type.includes("progress_recorded") && !event.type.includes("completed")) return [];
|
||||
|
||||
const watchPct = getNumber(payload.watchPct ?? payload.watch_pct);
|
||||
const progressPercentage = getNumber(payload.progressPercentage ?? payload.progress_percentage);
|
||||
const progressScore = progressPercentage ?? (watchPct === undefined ? undefined : watchPct * 100);
|
||||
const score = clampScore(Math.max(35, Math.min(100, progressScore ?? 50)));
|
||||
const raw = {
|
||||
eventId: event.id,
|
||||
taskId: payload.taskId ?? payload.curatorTaskId ?? event.correlation?.taskId,
|
||||
courseId: payload.courseId ?? payload.course_id ?? event.correlation?.courseId,
|
||||
lessonId: payload.lessonId ?? payload.lesson_id ?? event.correlation?.lessonId,
|
||||
watchPct,
|
||||
};
|
||||
|
||||
return [
|
||||
signal("skills.learning", score, raw),
|
||||
signal("readiness.course_progress", score, { ...raw, source: event.source, type: event.type }),
|
||||
];
|
||||
}
|
||||
|
||||
function sourceSignalPrefix(source: string) {
|
||||
return source
|
||||
.toLowerCase()
|
||||
@@ -275,11 +297,58 @@ function extractScoredServiceSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
];
|
||||
}
|
||||
|
||||
function countFromPayload(payload: Record<string, unknown>): number | undefined {
|
||||
const result = asRecord(payload.result);
|
||||
const request = asRecord(payload.request);
|
||||
const params = asRecord(request.params);
|
||||
const direct = getNumber(payload.matchCount ?? payload.matches ?? payload.shortlisted ?? result.matchCount ?? result.matches ?? result.shortlisted);
|
||||
if (direct !== undefined) return direct;
|
||||
const opportunities = Array.isArray(result.opportunities) ? result.opportunities : undefined;
|
||||
if (opportunities) return opportunities.length;
|
||||
const requestOpportunities = Array.isArray(params.opportunities) ? params.opportunities : undefined;
|
||||
return requestOpportunities?.length;
|
||||
}
|
||||
|
||||
function opportunityActionScore(event: GrowEventRow, count?: number): number | undefined {
|
||||
if (event.type === "matchmaking.matches.generated") return clampScore(58 + Math.min(count ?? 0, 20));
|
||||
if (event.type === "matchmaking.feed.viewed") return count && count > 0 ? 58 : 48;
|
||||
if (event.type === "matchmaking.match.viewed") return 62;
|
||||
if (event.type === "matchmaking.match.saved") return 72;
|
||||
if (event.type === "matchmaking.matches.reviewed") return 76;
|
||||
if (event.type === "matchmaking.match.applied") return 86;
|
||||
if (event.type === "matchmaking.application.completed") return 90;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractMatchmakingSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
if (!event.type.startsWith("matchmaking.")) return [];
|
||||
const payload = event.payload ?? {};
|
||||
const count = countFromPayload(payload);
|
||||
const score = opportunityActionScore(event, count);
|
||||
if (score === undefined) return [];
|
||||
|
||||
const raw = {
|
||||
eventId: event.id,
|
||||
type: event.type,
|
||||
action: payload.action,
|
||||
opportunityId: payload.opportunityId ?? payload.matchId ?? payload.jobId ?? event.subject?.externalId,
|
||||
taskId: payload.taskId ?? payload.curatorTaskId ?? event.correlation?.taskId,
|
||||
matchCount: count,
|
||||
status: payload.status,
|
||||
};
|
||||
|
||||
if (event.type === "matchmaking.matches.generated") return [signal("market.matches", score, raw)];
|
||||
if (event.type === "matchmaking.feed.viewed") return [signal("market.opportunity_feed_viewed", score, raw)];
|
||||
return [signal("networking.opportunities", score, raw)];
|
||||
}
|
||||
|
||||
export function extractQscoreSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
const source = event.source.toLowerCase();
|
||||
if (source.includes("resume") || event.type.startsWith("resume.")) return extractResumeSignals(event);
|
||||
if (source.includes("interview") || event.type.startsWith("interview.")) return extractInterviewSignals(event);
|
||||
if (source.includes("roleplay") || event.type.startsWith("roleplay.")) return extractRoleplaySignals(event);
|
||||
if (source.includes("course") || event.type.startsWith("course.")) return extractCourseSignals(event);
|
||||
if (source.includes("matchmaking") || event.type.startsWith("matchmaking.")) return extractMatchmakingSignals(event);
|
||||
if (source.includes("qscore") || event.type.startsWith("qscore.")) return extractScoredServiceSignals(event);
|
||||
const scoredServiceSignals = extractScoredServiceSignals(event);
|
||||
if (scoredServiceSignals.length) return scoredServiceSignals;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "../../db/client.js";
|
||||
import { missionServiceSessions, type GrowEventRow } from "../../db/schema.js";
|
||||
import { growActiveMissions, missionServiceSessions, type GrowEventRow } from "../../db/schema.js";
|
||||
import { asRecord, getString } from "../envelope.js";
|
||||
import { normalizeServiceId } from "../record-grow-event.js";
|
||||
|
||||
@@ -12,8 +12,16 @@ function extractExternalId(event: GrowEventRow): string | undefined {
|
||||
correlation.session_id ??
|
||||
correlation.externalId ??
|
||||
correlation.external_id ??
|
||||
correlation.lessonId ??
|
||||
correlation.courseId ??
|
||||
payload.session_id ??
|
||||
payload.sessionId ??
|
||||
payload.lessonId ??
|
||||
payload.lesson_id ??
|
||||
payload.courseId ??
|
||||
payload.course_id ??
|
||||
payload.videoId ??
|
||||
payload.video_id ??
|
||||
payload.id,
|
||||
);
|
||||
}
|
||||
@@ -21,6 +29,7 @@ function extractExternalId(event: GrowEventRow): string | undefined {
|
||||
function statusFor(event: GrowEventRow): string {
|
||||
const payload = event.payload ?? {};
|
||||
const explicit = getString(payload.status);
|
||||
if (explicit === "configured" || explicit === "draft" || explicit === "ready") return "active";
|
||||
if (explicit) return explicit;
|
||||
if (event.type.includes("review_completed") || event.type.includes("feedback.generated") || event.type.includes("completed")) return "completed";
|
||||
if (event.type.includes("failed")) return "failed";
|
||||
@@ -33,22 +42,58 @@ export async function applyServiceSessionProjection(event: GrowEventRow) {
|
||||
const externalId = extractExternalId(event);
|
||||
if (!externalId) return null;
|
||||
const serviceId = normalizeServiceId(event.source);
|
||||
if (!["interview", "roleplay", "resume"].includes(serviceId)) return null;
|
||||
if (!["interview", "roleplay", "resume", "course"].includes(serviceId)) return null;
|
||||
|
||||
const mission = asRecord(event.mission);
|
||||
const correlation = asRecord(event.correlation);
|
||||
const payload = event.payload ?? {};
|
||||
const missionInstanceId = getString(mission.instanceId ?? mission.instance_id ?? payload.missionInstanceId ?? payload.mission_instance_id);
|
||||
const missionId = getString(mission.missionId ?? mission.mission_id ?? payload.missionId ?? payload.mission_id);
|
||||
const stageId = getString(mission.stageId ?? mission.stage_id ?? payload.stageId ?? payload.stage_id);
|
||||
const [activeMission] = missionInstanceId
|
||||
? await db
|
||||
.select({
|
||||
instanceId: growActiveMissions.instanceId,
|
||||
missionId: growActiveMissions.missionId,
|
||||
currentStageId: growActiveMissions.currentStageId,
|
||||
})
|
||||
.from(growActiveMissions)
|
||||
.where(and(eq(growActiveMissions.userId, event.userId), eq(growActiveMissions.instanceId, missionInstanceId)))
|
||||
.limit(1)
|
||||
: [];
|
||||
const linkedMissionInstanceId = activeMission?.instanceId ?? missionInstanceId;
|
||||
const linkedMissionId = linkedMissionInstanceId ? missionId ?? activeMission?.missionId : undefined;
|
||||
const linkedStageId = linkedMissionInstanceId ? stageId ?? activeMission?.currentStageId ?? undefined : undefined;
|
||||
const metadata = {
|
||||
lastType: event.type,
|
||||
subject: event.subject,
|
||||
payloadStatus: event.payload?.status,
|
||||
missionInstanceId,
|
||||
missionId,
|
||||
stageId,
|
||||
taskId: getString(correlation.taskId ?? correlation.curatorTaskId ?? correlation.curator_task_id ?? payload.taskId ?? payload.curatorTaskId ?? payload.curator_task_id),
|
||||
curatorTaskId: getString(correlation.curatorTaskId ?? correlation.curator_task_id ?? correlation.taskId ?? payload.curatorTaskId ?? payload.curator_task_id ?? payload.taskId),
|
||||
courseId: getString(correlation.courseId ?? payload.courseId ?? payload.course_id),
|
||||
lessonId: getString(correlation.lessonId ?? payload.lessonId ?? payload.lesson_id),
|
||||
};
|
||||
const updateValues = {
|
||||
...(linkedMissionInstanceId ? { missionInstanceId: linkedMissionInstanceId } : {}),
|
||||
...(linkedMissionId ? { missionId: linkedMissionId } : {}),
|
||||
...(linkedStageId ? { stageId: linkedStageId } : {}),
|
||||
status: statusFor(event),
|
||||
metadata,
|
||||
lastEventId: event.id,
|
||||
lastCheckedAt: event.type.includes("review") ? new Date() : undefined,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const [row] = await db
|
||||
.insert(missionServiceSessions)
|
||||
.values({
|
||||
userId: event.userId,
|
||||
missionInstanceId: getString(mission.instanceId ?? mission.instance_id),
|
||||
missionId: getString(mission.missionId ?? mission.mission_id),
|
||||
stageId: getString(mission.stageId ?? mission.stage_id),
|
||||
missionInstanceId: linkedMissionInstanceId,
|
||||
missionId: linkedMissionId,
|
||||
stageId: linkedStageId,
|
||||
serviceId,
|
||||
externalId,
|
||||
status: statusFor(event),
|
||||
@@ -58,13 +103,7 @@ export async function applyServiceSessionProjection(event: GrowEventRow) {
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [missionServiceSessions.serviceId, missionServiceSessions.externalId],
|
||||
set: {
|
||||
status: statusFor(event),
|
||||
metadata,
|
||||
lastEventId: event.id,
|
||||
lastCheckedAt: event.type.includes("review") ? new Date() : undefined,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
set: updateValues,
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ 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;
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ type RedisClientLike = {
|
||||
};
|
||||
|
||||
type ServiceRedisSpec = {
|
||||
serviceId: "interview" | "roleplay" | "resume";
|
||||
agentName: "interview-service" | "roleplay-service" | "resume-builder";
|
||||
serviceId: "interview" | "roleplay" | "resume" | "course";
|
||||
agentName: "interview-service" | "roleplay-service" | "resume-builder" | "course-service";
|
||||
redisUrl: string;
|
||||
};
|
||||
|
||||
@@ -71,6 +71,15 @@ function getString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function getNumber(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function fieldsToEvent(fields: Record<string, string>, stream: string) {
|
||||
const payload = fields.payload ? parseFieldValue(fields.payload) : parseFieldValue(fields.data ?? "{}");
|
||||
const correlation = fields.correlation ? parseFieldValue(fields.correlation) : undefined;
|
||||
@@ -98,6 +107,7 @@ function serviceSpecs(): ServiceRedisSpec[] {
|
||||
{ serviceId: "interview", agentName: "interview-service", redisUrl: config.interviewRedisUrl },
|
||||
{ serviceId: "roleplay", agentName: "roleplay-service", redisUrl: config.roleplayRedisUrl },
|
||||
{ serviceId: "resume", agentName: "resume-builder", redisUrl: config.resumeRedisUrl },
|
||||
{ serviceId: "course", agentName: "course-service", redisUrl: config.coursesRedisUrl },
|
||||
];
|
||||
return specs.filter((spec) => Boolean(spec.redisUrl));
|
||||
}
|
||||
@@ -126,6 +136,14 @@ function actionToEventType(serviceId: ServiceRedisSpec["serviceId"], action: str
|
||||
return `roleplay.${effective.replaceAll("_", ".")}`;
|
||||
}
|
||||
|
||||
if (serviceId === "course") {
|
||||
if (effective === "swipe_recorded" || action === "record_swipe") return "course.progress_recorded";
|
||||
if (effective === "profile_created" || effective === "profile_recalibrated") return "course.started";
|
||||
if (effective === "course_state_loaded") return "course.page_state_loaded";
|
||||
if (effective === "feed_loaded") return "course.feed_loaded";
|
||||
return `course.${effective.replaceAll("_", ".")}`;
|
||||
}
|
||||
|
||||
if (effective === "ai_analysis_complete" || action === "ai_analyze") return "resume.analysis.completed";
|
||||
if (effective === "resume_loaded") return "resume.loaded";
|
||||
if (effective === "resume_parsed") return "resume.parsed";
|
||||
@@ -152,6 +170,22 @@ function extractResumeId(message: Record<string, unknown>, ctx?: LegacyTaskConte
|
||||
);
|
||||
}
|
||||
|
||||
function extractCourseIds(ctx?: LegacyTaskContext) {
|
||||
const params = ctx?.params ?? {};
|
||||
const courseId = getString(params.courseId ?? params.course_id ?? params.anchor_id ?? params.anchorId);
|
||||
const lessonId = getString(params.lessonId ?? params.lesson_id ?? params.video_id ?? params.videoId);
|
||||
return { courseId, lessonId };
|
||||
}
|
||||
|
||||
function meaningfulCourseProgress(data: Record<string, unknown>, ctx?: LegacyTaskContext) {
|
||||
if (data.error) return false;
|
||||
const params = ctx?.params ?? {};
|
||||
const watchS = getNumber(params.watch_s ?? params.watchS) ?? 0;
|
||||
const watchPct = getNumber(data.watch_pct ?? data.watchPct) ?? 0;
|
||||
const label = getString(data.label);
|
||||
return watchS >= 3 || watchPct >= 0.5 || label === "pos" || label === "strong_pos";
|
||||
}
|
||||
|
||||
async function recordAndRoute(input: unknown) {
|
||||
const event = await recordGrowEvent(input);
|
||||
await routeGrowEventToUserActor(event).catch((err) => {
|
||||
@@ -189,6 +223,12 @@ async function handleLegacyResponseMessage(spec: ServiceRedisSpec, channel: stri
|
||||
const eventType = actionToEventType(spec.serviceId, ctx?.action, message);
|
||||
const sessionId = extractSessionId(message, ctx);
|
||||
const resumeId = extractResumeId(message, ctx);
|
||||
const { courseId, lessonId } = extractCourseIds(ctx);
|
||||
if (eventType === "course.progress_recorded" && !meaningfulCourseProgress(data, ctx)) return;
|
||||
const curatorTaskId = getString(ctx?.params?.curatorTaskId ?? ctx?.params?.curator_task_id ?? ctx?.params?.taskId);
|
||||
const missionInstanceId = getString(ctx?.params?.missionInstanceId ?? ctx?.params?.mission_instance_id);
|
||||
const missionId = getString(ctx?.params?.missionId ?? ctx?.params?.mission_id);
|
||||
const stageId = getString(ctx?.params?.stageId ?? ctx?.params?.stage_id);
|
||||
|
||||
await recordAndRoute({
|
||||
id: randomUUID(),
|
||||
@@ -197,18 +237,36 @@ async function handleLegacyResponseMessage(spec: ServiceRedisSpec, channel: stri
|
||||
category: type === "agent_error" ? "system" : "service",
|
||||
userId: ctx?.userId,
|
||||
occurredAt: new Date().toISOString(),
|
||||
mission: {
|
||||
instanceId: missionInstanceId,
|
||||
missionId,
|
||||
stageId,
|
||||
},
|
||||
correlation: {
|
||||
taskId,
|
||||
taskId: curatorTaskId ?? taskId,
|
||||
curatorTaskId,
|
||||
serviceTaskId: taskId,
|
||||
action: ctx?.action,
|
||||
sessionId,
|
||||
resumeId,
|
||||
externalId: sessionId ?? resumeId,
|
||||
courseId,
|
||||
lessonId,
|
||||
externalId: sessionId ?? resumeId ?? lessonId ?? courseId,
|
||||
},
|
||||
payload: {
|
||||
action: ctx?.action,
|
||||
params: ctx?.params,
|
||||
message,
|
||||
data,
|
||||
taskId: curatorTaskId,
|
||||
curatorTaskId,
|
||||
serviceId: spec.agentName,
|
||||
sessionId,
|
||||
courseId,
|
||||
lessonId,
|
||||
missionInstanceId,
|
||||
missionId,
|
||||
stageId,
|
||||
},
|
||||
raw: { channel, message },
|
||||
dedupeKey: `legacy-a2a:${spec.agentName}:${taskId}:${eventType}:${JSON.stringify(message).slice(0, 512)}`,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "../events/record-grow-event.js";
|
||||
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
|
||||
import { applyQscoreProjection } from "../events/projectors/qscore-projector.js";
|
||||
import { applyServiceSessionProjection } from "../events/projectors/service-session-projector.js";
|
||||
import { ensureOnboardingSideEffectsForEvent } from "../events/onboarding-ledger.js";
|
||||
|
||||
function serviceAuthorized(auth: string | undefined) {
|
||||
@@ -48,6 +49,7 @@ async function ingest(body: unknown, userId?: string, source?: string) {
|
||||
|
||||
await markGrowEventProcessing(event.id);
|
||||
try {
|
||||
await applyServiceSessionProjection(event);
|
||||
const qscore = await applyQscoreProjection(event);
|
||||
const onboarding = await ensureOnboardingSideEffectsForEvent(event);
|
||||
if (
|
||||
|
||||
+105
-31
@@ -1,17 +1,18 @@
|
||||
import { Hono } from "hono";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { createHash } from "node:crypto";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { requireUser, type AuthContext } from "../auth/clerk.js";
|
||||
import { config } from "../config.js";
|
||||
import { listServiceCapabilities } from "../workflows/service-capabilities.js";
|
||||
import { interviewService, ProductServiceError, resumeService, roleplayService, type JsonObject } from "../services/product-service-clients.js";
|
||||
import { db } from "../db/client.js";
|
||||
import { events, growQscoreLatest, growQscoreProjectionState } from "../db/schema.js";
|
||||
import { events, growEvents, growQscoreLatest, growQscoreProjectionState, missionServiceSessions } from "../db/schema.js";
|
||||
import { recordGrowEvent } from "../events/record-grow-event.js";
|
||||
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
|
||||
import { ensureOnboardingBaselineQscoreFromLedger } from "../events/onboarding-ledger.js";
|
||||
import { getRequestUserPreferences, getRequestUserProfile } from "../services/user-context.js";
|
||||
import { buildMatchmakingGatewayEvent } from "../services/matchmaking-events.js";
|
||||
import { log } from "../log.js";
|
||||
|
||||
const LANDING_AGENTS = [
|
||||
@@ -75,6 +76,80 @@ function curatorTaskIdFromRequest(req: Request, body?: JsonObject) {
|
||||
return getString(url.searchParams.get("curatorTaskId"));
|
||||
}
|
||||
|
||||
function orchestrationFromMission(mission?: Record<string, unknown>, taskId?: string) {
|
||||
return {
|
||||
...(taskId ? { taskId, curatorTaskId: taskId } : {}),
|
||||
...(getString(mission?.missionId ?? mission?.mission_id) ? { missionId: getString(mission?.missionId ?? mission?.mission_id) } : {}),
|
||||
...(getString(mission?.instanceId ?? mission?.instance_id ?? mission?.missionInstanceId ?? mission?.mission_instance_id)
|
||||
? { missionInstanceId: getString(mission?.instanceId ?? mission?.instance_id ?? mission?.missionInstanceId ?? mission?.mission_instance_id) }
|
||||
: {}),
|
||||
...(getString(mission?.stageId ?? mission?.stage_id) ? { stageId: getString(mission?.stageId ?? mission?.stage_id) } : {}),
|
||||
...(getString(mission?.source) ? { source: getString(mission?.source) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
type SessionMissionContext = {
|
||||
mission?: Record<string, unknown>;
|
||||
taskId?: string;
|
||||
};
|
||||
|
||||
async function sessionMissionContext(serviceId: "interview" | "roleplay", sessionId: string): Promise<SessionMissionContext> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
missionInstanceId: missionServiceSessions.missionInstanceId,
|
||||
missionId: missionServiceSessions.missionId,
|
||||
stageId: missionServiceSessions.stageId,
|
||||
metadata: missionServiceSessions.metadata,
|
||||
})
|
||||
.from(missionServiceSessions)
|
||||
.where(and(eq(missionServiceSessions.serviceId, serviceId), eq(missionServiceSessions.externalId, sessionId)))
|
||||
.limit(1);
|
||||
const metadata = row && isRecord(row.metadata) ? row.metadata : {};
|
||||
const fromSession: SessionMissionContext = {
|
||||
mission: row && (row.missionInstanceId || row.missionId || row.stageId)
|
||||
? {
|
||||
instanceId: row.missionInstanceId ?? undefined,
|
||||
missionId: row.missionId ?? undefined,
|
||||
stageId: row.stageId ?? undefined,
|
||||
source: "curator-v1",
|
||||
}
|
||||
: undefined,
|
||||
taskId: getString(metadata.taskId ?? metadata.curatorTaskId ?? metadata.curator_task_id),
|
||||
};
|
||||
if (fromSession.taskId || fromSession.mission) return fromSession;
|
||||
|
||||
const eventRows = await db
|
||||
.select({
|
||||
mission: growEvents.mission,
|
||||
correlation: growEvents.correlation,
|
||||
payload: growEvents.payload,
|
||||
})
|
||||
.from(growEvents)
|
||||
.where(and(eq(growEvents.source, `${serviceId}-service`), eq(growEvents.type, serviceId === "roleplay" ? "roleplay.scenario.configured" : "interview.session.configured")))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(50);
|
||||
const eventRow = eventRows.find((item) => {
|
||||
const correlation = isRecord(item.correlation) ? item.correlation : {};
|
||||
return getString(correlation.sessionId ?? correlation.session_id ?? correlation.externalId) === sessionId;
|
||||
});
|
||||
|
||||
if (!eventRow) return fromSession;
|
||||
const correlation = isRecord(eventRow.correlation) ? eventRow.correlation : {};
|
||||
const payload = isRecord(eventRow.payload) ? eventRow.payload : {};
|
||||
const request = isRecord(payload.request) ? payload.request : {};
|
||||
return {
|
||||
mission: isRecord(eventRow.mission) ? eventRow.mission : fromSession.mission,
|
||||
taskId: getString(
|
||||
correlation.taskId ??
|
||||
correlation.curatorTaskId ??
|
||||
correlation.curator_task_id ??
|
||||
request.taskId ??
|
||||
request.curatorTaskId ??
|
||||
request.curator_task_id,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function stripMissionFromBody(body: JsonObject): JsonObject {
|
||||
if (!("mission" in body)) return body;
|
||||
const { mission: _mission, ...rest } = body;
|
||||
@@ -288,18 +363,6 @@ function serviceErrorResponse(err: unknown): never {
|
||||
throw err;
|
||||
}
|
||||
|
||||
function matchmakingEventType(action: string, response: Record<string, unknown>) {
|
||||
if (response.status === "error") return "matchmaking.request.failed";
|
||||
if (action === "run_search" || action === "generate_matches") return "matchmaking.matches.generated";
|
||||
if (action === "get_scout_feed" || action === "get_feed" || action === "session_start") return "matchmaking.feed.viewed";
|
||||
if (action === "get_opportunity_detail" || action === "mark_viewed") return "matchmaking.match.viewed";
|
||||
if (action === "mark_saved" || action === "record_feedback") return "matchmaking.match.saved";
|
||||
if (action === "dismiss_opportunity") return "matchmaking.match.dismissed";
|
||||
if (action === "tailor_resume") return "matchmaking.application.started";
|
||||
if (action === "submit_application") return "matchmaking.application.completed";
|
||||
return "matchmaking.workflow.completed";
|
||||
}
|
||||
|
||||
async function callMatchmakingA2a(body: Record<string, unknown>, userId: string) {
|
||||
const target = new URL("/a2a/tasks", config.matchmakingServiceUrl.replace(/\/$/, ""));
|
||||
const action = getString(body.action) ?? (body.session_start ? "session_start" : "");
|
||||
@@ -814,12 +877,14 @@ export function serviceRoutes() {
|
||||
const payload = await buildPersonalizedConfigurePayload(c.req.raw, body, userId);
|
||||
const result = await interviewService.configure(payload).catch(serviceErrorResponse);
|
||||
const resultObj = result as Record<string, unknown>;
|
||||
const taskId = curatorTaskIdFromRequest(c.req.raw, body);
|
||||
const orchestration = orchestrationFromMission(mission, taskId);
|
||||
await recordGatewayEvent({
|
||||
userId,
|
||||
source: "interview-service",
|
||||
type: "interview.session.configured",
|
||||
payload: { request: payload, result: resultObj },
|
||||
correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id), taskId: curatorTaskIdFromRequest(c.req.raw, body) },
|
||||
payload: { ...orchestration, request: payload, result: resultObj },
|
||||
correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id), ...orchestration },
|
||||
mission,
|
||||
}).catch((err) => log.warn({ err }, "failed to record interview configured event"));
|
||||
return c.json(result);
|
||||
@@ -840,15 +905,19 @@ export function serviceRoutes() {
|
||||
app.post("/interview/results:bulk", async (c) => c.json(await interviewService.resultsBulk(await c.req.json<JsonObject>())));
|
||||
app.get("/interview/review/:sessionId", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const sessionId = c.req.param("sessionId");
|
||||
const sessionId = c.req.param("sessionId");
|
||||
const result = await interviewService.review(sessionId, userId).catch(serviceErrorResponse);
|
||||
const resultObj = result as Record<string, unknown>;
|
||||
const sessionContext = await sessionMissionContext("interview", sessionId).catch((): SessionMissionContext => ({}));
|
||||
const taskId = curatorTaskIdFromRequest(c.req.raw) ?? sessionContext.taskId;
|
||||
const orchestration = orchestrationFromMission(sessionContext.mission, taskId);
|
||||
await recordGatewayEvent({
|
||||
userId,
|
||||
source: "interview-service",
|
||||
type: eventTypeForReview("interview", resultObj),
|
||||
payload: resultObj,
|
||||
correlation: { sessionId, taskId: curatorTaskIdFromRequest(c.req.raw) },
|
||||
payload: { ...orchestration, ...resultObj },
|
||||
correlation: { sessionId, ...orchestration },
|
||||
mission: sessionContext.mission,
|
||||
}).catch((err) => log.warn({ err }, "failed to record interview review event"));
|
||||
return c.json(result);
|
||||
});
|
||||
@@ -879,12 +948,14 @@ export function serviceRoutes() {
|
||||
const payload = await buildPersonalizedRoleplayConfigurePayload(c.req.raw, body, userId);
|
||||
const result = await roleplayService.configure(payload).catch(serviceErrorResponse);
|
||||
const resultObj = result as Record<string, unknown>;
|
||||
const taskId = curatorTaskIdFromRequest(c.req.raw, body);
|
||||
const orchestration = orchestrationFromMission(mission, taskId);
|
||||
await recordGatewayEvent({
|
||||
userId,
|
||||
source: "roleplay-service",
|
||||
type: "roleplay.scenario.configured",
|
||||
payload: { request: payload, result: resultObj },
|
||||
correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id), taskId: curatorTaskIdFromRequest(c.req.raw, body) },
|
||||
payload: { ...orchestration, request: payload, result: resultObj },
|
||||
correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id), ...orchestration },
|
||||
mission,
|
||||
}).catch((err) => log.warn({ err }, "failed to record roleplay configured event"));
|
||||
return c.json(result);
|
||||
@@ -908,12 +979,16 @@ export function serviceRoutes() {
|
||||
const sessionId = c.req.param("sessionId");
|
||||
const result = await roleplayService.review(sessionId, userId).catch(serviceErrorResponse);
|
||||
const resultObj = result as Record<string, unknown>;
|
||||
const sessionContext = await sessionMissionContext("roleplay", sessionId).catch((): SessionMissionContext => ({}));
|
||||
const taskId = curatorTaskIdFromRequest(c.req.raw) ?? sessionContext.taskId;
|
||||
const orchestration = orchestrationFromMission(sessionContext.mission, taskId);
|
||||
await recordGatewayEvent({
|
||||
userId,
|
||||
source: "roleplay-service",
|
||||
type: eventTypeForReview("roleplay", resultObj),
|
||||
payload: resultObj,
|
||||
correlation: { sessionId, taskId: curatorTaskIdFromRequest(c.req.raw) },
|
||||
payload: { ...orchestration, ...resultObj },
|
||||
correlation: { sessionId, ...orchestration },
|
||||
mission: sessionContext.mission,
|
||||
}).catch((err) => log.warn({ err }, "failed to record roleplay review event"));
|
||||
return c.json(result);
|
||||
});
|
||||
@@ -964,16 +1039,15 @@ export function serviceRoutes() {
|
||||
const userId = c.get("userId");
|
||||
const body = await c.req.json<JsonObject>().catch(() => ({}));
|
||||
const { action, result } = await callMatchmakingA2a(body, userId);
|
||||
const event = buildMatchmakingGatewayEvent({ userId, action, body, result });
|
||||
await recordGatewayEvent({
|
||||
userId,
|
||||
source: "matchmaking-v2",
|
||||
type: matchmakingEventType(action, result),
|
||||
payload: { request: body, result },
|
||||
correlation: {
|
||||
taskId: curatorTaskIdFromRequest(c.req.raw, body),
|
||||
externalId: getString(result.task_id ?? result.taskId),
|
||||
},
|
||||
mission: missionFromRequest(c.req.raw, body),
|
||||
source: event.source,
|
||||
type: event.type,
|
||||
payload: event.payload,
|
||||
correlation: event.correlation,
|
||||
mission: event.mission ?? missionFromRequest(c.req.raw, body),
|
||||
subject: event.subject,
|
||||
}).catch((err) => log.warn({ err, userId, action }, "failed to record matchmaking workflow event"));
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function getNumber(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const n = Number(value);
|
||||
if (Number.isFinite(n)) return n;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function paramsFromBody(body: JsonRecord): JsonRecord {
|
||||
return isRecord(body.params) ? body.params : {};
|
||||
}
|
||||
|
||||
function agentData(result: JsonRecord, action: string): JsonRecord | undefined {
|
||||
const messages = Array.isArray(result.messages) ? result.messages : [];
|
||||
for (const item of messages) {
|
||||
const message = isRecord(item) ? item : {};
|
||||
if (message.type !== "agent_data") continue;
|
||||
if (message.action !== action) continue;
|
||||
const data = isRecord(message.data) ? message.data : {};
|
||||
return data;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function firstAgentData(result: JsonRecord, actions: string[]): JsonRecord {
|
||||
for (const action of actions) {
|
||||
const data = agentData(result, action);
|
||||
if (data) return data;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function matchmakingEventType(action: string, response: JsonRecord) {
|
||||
if (response.status === "error") return "matchmaking.request.failed";
|
||||
if (action === "run_search" || action === "generate_matches") return "matchmaking.matches.generated";
|
||||
if (action === "get_scout_feed" || action === "get_feed" || action === "session_start") return "matchmaking.feed.viewed";
|
||||
if (action === "get_opportunity_detail" || action === "mark_viewed") return "matchmaking.match.viewed";
|
||||
if (action === "mark_saved") return "matchmaking.match.saved";
|
||||
if (action === "record_feedback") return "matchmaking.matches.reviewed";
|
||||
if (action === "dismiss_opportunity") return "matchmaking.match.dismissed";
|
||||
if (action === "tailor_resume") return "matchmaking.application.started";
|
||||
if (action === "mark_applied") return "matchmaking.match.applied";
|
||||
if (action === "submit_application") return "matchmaking.application.completed";
|
||||
return "matchmaking.workflow.completed";
|
||||
}
|
||||
|
||||
function opportunityIdFrom(input: { params: JsonRecord; body: JsonRecord; data: JsonRecord; result: JsonRecord }) {
|
||||
return getString(
|
||||
input.params.opportunity_id ??
|
||||
input.params.opportunityId ??
|
||||
input.params.matchId ??
|
||||
input.params.jobId ??
|
||||
input.body.opportunity_id ??
|
||||
input.body.opportunityId ??
|
||||
input.data.opportunity_id ??
|
||||
input.data.opportunityId ??
|
||||
input.data.id ??
|
||||
input.result.opportunity_id ??
|
||||
input.result.opportunityId,
|
||||
);
|
||||
}
|
||||
|
||||
function missionFromParams(params: JsonRecord) {
|
||||
const instanceId = getString(params.missionInstanceId ?? params.instanceId);
|
||||
const missionId = getString(params.missionId);
|
||||
const stageId = getString(params.stageId);
|
||||
const source = getString(params.source);
|
||||
if (!instanceId && !missionId && !stageId) return undefined;
|
||||
return {
|
||||
missionId,
|
||||
instanceId,
|
||||
stageId,
|
||||
source: source ?? "curator-v1",
|
||||
};
|
||||
}
|
||||
|
||||
function countFromData(data: JsonRecord) {
|
||||
const opportunities = Array.isArray(data.opportunities) ? data.opportunities : undefined;
|
||||
return getNumber(data.matchCount ?? data.matches ?? data.shortlisted) ?? opportunities?.length;
|
||||
}
|
||||
|
||||
export function buildMatchmakingGatewayEvent(input: {
|
||||
userId: string;
|
||||
action: string;
|
||||
body: JsonRecord;
|
||||
result: JsonRecord;
|
||||
}) {
|
||||
const params = paramsFromBody(input.body);
|
||||
const type = matchmakingEventType(input.action, input.result);
|
||||
const data = firstAgentData(input.result, ["search_complete", "feed_loaded", "opportunity_state_saved", "opportunity_detail"]);
|
||||
const opportunityId = opportunityIdFrom({ params, body: input.body, data, result: input.result });
|
||||
const taskId = getString(params.curatorTaskId ?? params.taskId ?? input.body.curatorTaskId ?? input.body.taskId);
|
||||
const resultTaskId = getString(input.result.task_id ?? input.result.taskId);
|
||||
const matchCount = countFromData(data);
|
||||
const scanned = getNumber(data.scanned);
|
||||
|
||||
const payload: JsonRecord = {
|
||||
request: input.body,
|
||||
result: input.result,
|
||||
action: input.action,
|
||||
status: getString(input.result.status),
|
||||
...(taskId ? { taskId, curatorTaskId: taskId } : {}),
|
||||
...(opportunityId ? { opportunityId, matchId: opportunityId, jobId: opportunityId } : {}),
|
||||
...(matchCount !== undefined ? { matchCount } : {}),
|
||||
...(scanned !== undefined ? { scanned } : {}),
|
||||
...(getString(params.source) ? { source: getString(params.source) } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
userId: input.userId,
|
||||
source: "matchmaking-v2",
|
||||
type,
|
||||
payload,
|
||||
correlation: {
|
||||
...(taskId ? { taskId, curatorTaskId: taskId } : {}),
|
||||
...(opportunityId ? { opportunityId, matchId: opportunityId, jobId: opportunityId } : {}),
|
||||
externalId: opportunityId ?? resultTaskId,
|
||||
...(resultTaskId ? { a2aTaskId: resultTaskId } : {}),
|
||||
},
|
||||
mission: missionFromParams(params),
|
||||
subject: {
|
||||
serviceId: "matchmaking",
|
||||
kind: opportunityId ? "opportunity" : "opportunity_feed",
|
||||
id: opportunityId,
|
||||
externalId: opportunityId ?? resultTaskId ?? `matchmaking:${type}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -224,7 +224,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
media: "video",
|
||||
},
|
||||
toolName: "prepare_interview_preview",
|
||||
completionEvents: ["interview.session.configured", "interview.session.completed", "interview.feedback.generated"],
|
||||
completionEvents: ["interview.session.completed", "interview.feedback.generated"],
|
||||
qscoreSignals: ["communication.interview", "proof.story_bank", "readiness.practice"],
|
||||
usage: "Include missionInstanceId, missionId, stageId, curatorTaskId, role, and media when building stateful handoffs.",
|
||||
},
|
||||
@@ -562,7 +562,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
start: "Start course",
|
||||
},
|
||||
toolName: "prepare_course_handoff",
|
||||
completionEvents: ["course.generated", "course.started", "course.lesson.completed", "course.completed", "course.weakness.applied"],
|
||||
completionEvents: ["course.generated", "course.started", "course.progress_recorded", "course.lesson.completed", "course.completed", "course.weakness.applied"],
|
||||
qscoreSignals: ["skills.learning", "readiness.course_progress"],
|
||||
usage: "Use for skill-gap tasks that should become learning work.",
|
||||
},
|
||||
@@ -572,7 +572,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
openEvents: ["task.opened"],
|
||||
startedEvents: ["service.started", "course.started"],
|
||||
configuredEvents: ["course.generated"],
|
||||
completionEvents: ["service.completed", "course.lesson.completed", "course.completed", "course.weakness.applied"],
|
||||
completionEvents: ["service.completed", "course.progress_recorded", "course.lesson.completed", "course.completed", "course.weakness.applied"],
|
||||
abandonmentEvents: ["service.abandoned"],
|
||||
qscoreSignalIds: ["course.skill_gap_closed", "skills.learning", "readiness.course_progress"],
|
||||
analyticsDimensions: ["course_id", "lesson_id", "progress_percentage", "weakness_tags"],
|
||||
@@ -689,6 +689,8 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
"matchmaking.match.viewed",
|
||||
"matchmaking.match.saved",
|
||||
"matchmaking.match.dismissed",
|
||||
"matchmaking.matches.reviewed",
|
||||
"matchmaking.match.applied",
|
||||
"matchmaking.application.started",
|
||||
"matchmaking.application.completed",
|
||||
],
|
||||
@@ -710,6 +712,8 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
"matchmaking.match.viewed",
|
||||
"matchmaking.match.saved",
|
||||
"matchmaking.match.dismissed",
|
||||
"matchmaking.matches.reviewed",
|
||||
"matchmaking.match.applied",
|
||||
"matchmaking.application.completed",
|
||||
],
|
||||
abandonmentEvents: ["service.abandoned"],
|
||||
|
||||
@@ -41,13 +41,13 @@ const VALID_COMPLETION_TYPES = [
|
||||
"resume.parsed",
|
||||
"resume.updated",
|
||||
"resume.exported",
|
||||
"interview.session.configured",
|
||||
"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",
|
||||
@@ -55,6 +55,8 @@ const VALID_COMPLETION_TYPES = [
|
||||
"matchmaking.matches.generated",
|
||||
"matchmaking.feed.viewed",
|
||||
"matchmaking.match.saved",
|
||||
"matchmaking.matches.reviewed",
|
||||
"matchmaking.match.applied",
|
||||
"matchmaking.application.completed",
|
||||
"qscore.updated",
|
||||
"qscore.signal.projected",
|
||||
|
||||
Reference in New Issue
Block a user