feat(events): project course and matchmaking activity

This commit is contained in:
-Puter
2026-07-09 18:55:00 +05:30
parent 237c65f328
commit 64c245535f
12 changed files with 562 additions and 53 deletions
+119
View File
@@ -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);
+1
View File
@@ -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.
+1 -1
View File
@@ -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;
+69
View File
@@ -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();
+2
View File
@@ -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;
}
+62 -4
View File
@@ -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)}`,
+2
View File
@@ -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
View File
@@ -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);
});
+139
View File
@@ -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}`,
},
};
}
+7 -3
View File
@@ -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"],
+3 -1
View File
@@ -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",