Files
growqr-backend/src/services/service-registry.ts
T

1034 lines
46 KiB
TypeScript

import { config } from "../config.js";
import type { CuratorServiceId, CuratorTask } from "../v1/curator/curator-types.js";
export type QueryValue = string | number | boolean | undefined | null;
export type QueryState = Record<string, QueryValue>;
export type ServiceId =
| "interview-service"
| "roleplay-service"
| "courses-service"
| "assessment-service"
| "matchmaking-service"
| "resume-service"
| "cover-letter-service"
| "qscore-service"
| "social-branding-service"
| "expert-service"
| "events-service";
export type ServiceCategory = "practice" | "learning" | "opportunity" | "document" | "measurement" | "profile";
export type ServiceEndpoint = {
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
path: string;
contract: string;
usage: string;
};
export type ServiceFrontendPage = {
path: string;
aliases?: string[];
queryParams: string[];
usage: string;
};
export type ServiceRecord = {
id: ServiceId;
label: string;
description: string;
category: ServiceCategory;
enabled: boolean;
featureId: string;
promptModulePath: string;
aliases?: string[];
backend: {
baseUrl?: string;
publicUrl?: string;
healthPath: string;
endpoints: Record<string, ServiceEndpoint>;
usage: string;
};
frontend: {
baseUrl: string;
pages: Record<string, ServiceFrontendPage>;
usage: string;
};
curator: {
defaultPage: string;
defaultActionLabel: string;
defaultQueryState?: QueryState;
actionLabels?: Record<string, string>;
toolName: string;
completionEvents: string[];
qscoreSignals?: string[];
usage: string;
};
eventContract: {
canonicalServiceId: string;
openEvents: string[];
startedEvents: string[];
configuredEvents: string[];
completionEvents: string[];
abandonmentEvents: string[];
qscoreSignalIds: readonly string[];
analyticsDimensions: readonly string[];
requiredCorrelationFields: readonly string[];
handoffParams: readonly string[];
actionKind: "active_user_action" | "passive_background_action" | "measurement_review";
};
usageDocs: string[];
};
type MissionServiceId = Extract<CuratorServiceId, "interview-service" | "roleplay-service" | "resume-service">;
type MissionRouteInput = {
serviceId: MissionServiceId;
missionInstanceId: string;
missionId: string;
stageId?: string;
goal?: string;
};
type CuratorRouteInput = {
serviceId?: CuratorServiceId;
missionInstanceId?: string;
missionId?: string;
stageId?: string;
taskId?: string;
taskTitle?: string;
taskSubtitle?: string;
taskType?: string;
targetRole?: string;
durationMinutes?: number;
difficulty?: string;
personaId?: string;
requestedMode?: string;
roleplayBrief?: string;
};
function endpoint(
method: ServiceEndpoint["method"],
path: string,
contract: string,
usage: string,
): ServiceEndpoint {
return { method, path, contract, usage };
}
function appendQuery(pathname: string, params: QueryState = {}) {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === "") continue;
search.set(key, String(value));
}
const query = search.toString();
return query ? `${pathname}?${query}` : pathname;
}
function getString(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function getSessionId(detail?: Record<string, unknown>) {
return getString(detail?.session_id ?? detail?.sessionId ?? detail?.id);
}
const frontendBaseUrl = config.workflowsDashboardUrl.replace(/\/$/, "");
const allowedServiceIds = new Set<ServiceId>([
"interview-service",
"matchmaking-service",
"roleplay-service",
"courses-service",
"resume-service",
"qscore-service",
"social-branding-service",
]);
const serviceRegistry: ServiceRecord[] = [
{
id: "interview-service",
label: "Interview",
description: "Configure, practice, review, and score mock interview sessions.",
category: "practice",
enabled: Boolean(config.interviewServiceUrl),
featureId: "mock-interview",
promptModulePath: "agents/interview.md",
backend: {
baseUrl: config.interviewServiceUrl,
publicUrl: config.interviewPublicUrl,
healthPath: "/health",
endpoints: {
health: endpoint("GET", "/health", "Readiness probe.", "Check service availability before a handoff."),
pageState: endpoint("GET", "/api/v1/interviews/page-state?user_id=:userId", "Returns usage and personalization state.", "Hydrate interview landing/setup pages."),
configure: endpoint("POST", "/api/v1/configure", "Creates an interview plan from user, org, persona, type, duration, and context.", "Use for committed setup requests."),
preview: endpoint("POST", "/api/v1/configure/preview", "Creates a preview plan without starting live practice.", "Use for curator/dashboard previews."),
questions: endpoint("POST", "/api/v1/configure/questions", "Edits generated questions for a session.", "Use after preview edits."),
approve: endpoint("POST", "/api/v1/configure/approve", "Approves a generated session by session_id.", "Use when a user accepts a preview."),
assignments: endpoint("GET", "/api/v1/interviews/assignments", "Lists interview assignments by email/status/limit.", "Render assigned practice work."),
createAssignments: endpoint("POST", "/api/v1/interviews/assignments", "Creates interview assignments for assignee emails.", "Admin or organization handoffs."),
unassign: endpoint("POST", "/api/v1/interviews/assignments/unassign", "Removes interview assignments.", "Admin cleanup."),
resultsBulk: endpoint("POST", "/api/v1/interviews/results:bulk", "Fetches result summaries for multiple sessions.", "Dashboard history and summaries."),
review: endpoint("GET", "/api/v1/review/:sessionId", "Returns review/status for a session.", "Poll or open feedback."),
leaderboard: endpoint("GET", "/api/v1/leaderboard", "Returns interview leaderboard.", "Leaderboard widgets."),
artifact: endpoint("GET", "/api/v1/artifacts/:sessionId/:artifactType", "Returns session artifacts.", "Fetch transcript, report, or media artifacts."),
videoUploadUrl: endpoint("POST", "/api/v1/sessions/:sessionId/video/upload-url", "Returns signed upload instructions.", "Browser upload setup."),
markVideoUploaded: endpoint("POST", "/api/v1/sessions/:sessionId/video/uploaded", "Marks uploaded video as available.", "Complete upload flow."),
},
usage: "Backend callers should use the gateway /services/interview/* routes when user auth, mission correlation, and event recording are required.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
service: {
path: "/agents/interview",
aliases: ["discovery", "setup", "preview", "feedback", "session"],
queryParams: ["view", "phase", "sessionId", "role", "type", "persona", "duration", "difficulty", "media", "vip", "personalize", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId", "taskTitle", "taskSubtitle", "taskType"],
usage: "Single nuqs-driven interview surface for configuration, live practice, plan review, and feedback.",
},
},
usage: "All interview UI states use /agents/interview with typed query state; no child UI routes exist.",
},
curator: {
defaultPage: "service",
defaultActionLabel: "Open interview",
actionLabels: {
start: "Start mock",
review: "Review interview",
},
defaultQueryState: {
type: "behavioral",
persona: "payal",
duration: 5,
difficulty: "medium",
media: "video",
},
toolName: "prepare_interview_preview",
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.",
},
usageDocs: [
"Call buildServiceLink('interview-service', 'service', state) for curator handoffs.",
"Call getServiceEndpoint('interview-service', 'configure') for backend contract metadata.",
],
eventContract: {
canonicalServiceId: "interview",
openEvents: ["task.opened", "interview.preview.opened"],
startedEvents: ["service.started", "interview.session.started"],
configuredEvents: ["service.configured", "interview.session.configured"],
completionEvents: ["service.completed", "interview.session.completed", "interview.feedback.generated"],
abandonmentEvents: ["service.abandoned", "interview.session.abandoned"],
qscoreSignalIds: ["interview.communication_score", "interview.confidence_score", "readiness.practice"],
analyticsDimensions: ["interview_type", "target_role", "difficulty", "duration_minutes", "overall_score"],
requiredCorrelationFields: ["taskId"],
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
actionKind: "active_user_action",
},
},
{
id: "roleplay-service",
label: "Roleplay",
description: "Practice negotiations, recruiter calls, stakeholder conversations, and workplace scenarios.",
category: "practice",
enabled: Boolean(config.roleplayServiceUrl),
featureId: "mock-roleplay",
promptModulePath: "agents/roleplay.md",
backend: {
baseUrl: config.roleplayServiceUrl,
publicUrl: config.roleplayPublicUrl,
healthPath: "/health",
endpoints: {
health: endpoint("GET", "/health", "Readiness probe.", "Check service availability before a handoff."),
pageState: endpoint("GET", "/api/v1/roleplays/page-state?user_id=:userId", "Returns usage and personalization state.", "Hydrate roleplay landing/setup pages."),
configure: endpoint("POST", "/api/v1/roleplays/configure", "Creates a roleplay scenario from user, org, persona, duration, brief, metadata, qscore, and user_context.", "Use for committed scenario generation."),
preview: endpoint("POST", "/api/v1/roleplays/configure/preview", "Creates a roleplay preview.", "Use for curator/dashboard previews."),
questions: endpoint("POST", "/api/v1/roleplays/configure/questions", "Edits generated roleplay questions or beats.", "Use after preview edits."),
approve: endpoint("POST", "/api/v1/roleplays/configure/approve", "Approves a generated roleplay by session_id.", "Use when a user accepts a preview."),
assignments: endpoint("GET", "/api/v1/roleplays/assignments", "Lists roleplay assignments by email/status/limit.", "Render assigned drills."),
createAssignments: endpoint("POST", "/api/v1/roleplays/assignments", "Creates roleplay assignments.", "Admin or organization handoffs."),
unassign: endpoint("POST", "/api/v1/roleplays/assignments/unassign", "Removes roleplay assignments.", "Admin cleanup."),
resultsBulk: endpoint("POST", "/api/v1/roleplays/results:bulk", "Fetches result summaries for multiple sessions.", "Dashboard history and summaries."),
review: endpoint("GET", "/api/v1/roleplays/review/:sessionId", "Returns review/status for a roleplay session.", "Poll or open feedback."),
leaderboard: endpoint("GET", "/api/v1/roleplays/leaderboard", "Returns roleplay leaderboard.", "Leaderboard widgets."),
artifact: endpoint("GET", "/api/v1/artifacts/:sessionId/:artifactType", "Returns session artifacts.", "Fetch transcript, report, or media artifacts."),
videoUploadUrl: endpoint("POST", "/api/v1/sessions/:sessionId/video/upload-url", "Returns signed upload instructions.", "Browser upload setup."),
markVideoUploaded: endpoint("POST", "/api/v1/sessions/:sessionId/video/uploaded", "Marks uploaded video as available.", "Complete upload flow."),
},
usage: "Backend callers should use the gateway /services/roleplay/* routes when user auth, mission correlation, and event recording are required.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
service: {
path: "/agents/roleplay",
aliases: ["discovery", "setup", "builder", "feedback", "session"],
queryParams: ["view", "phase", "sessionId", "scenario", "scenarioName", "brief", "role", "persona", "duration", "difficulty", "mode", "vip", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId", "taskTitle", "taskSubtitle", "taskType"],
usage: "Single nuqs-driven roleplay surface for configuration, scenario review, live practice, and feedback.",
},
},
usage: "All roleplay UI states use /agents/roleplay with typed query state; no child UI routes exist.",
},
curator: {
defaultPage: "service",
defaultActionLabel: "Open roleplay",
actionLabels: {
start: "Run drill",
review: "Review roleplay",
},
defaultQueryState: {
role: "Professional",
persona: "emma",
duration: 5,
mode: "video",
},
toolName: "prepare_roleplay_preview",
completionEvents: ["roleplay.scenario.configured", "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.",
},
usageDocs: [
"Call buildServiceLink('roleplay-service', 'service', state) for curator handoffs.",
"Call getServiceEndpoint('roleplay-service', 'configure') for backend contract metadata.",
],
eventContract: {
canonicalServiceId: "roleplay",
openEvents: ["task.opened", "roleplay.preview.opened"],
startedEvents: ["service.started", "roleplay.scenario.started"],
configuredEvents: ["service.configured", "roleplay.scenario.configured"],
completionEvents: ["service.completed", "roleplay.scenario.completed", "roleplay.feedback.generated"],
abandonmentEvents: ["service.abandoned", "roleplay.scenario.abandoned"],
qscoreSignalIds: ["roleplay.negotiation_confidence", "communication.roleplay", "readiness.practice"],
analyticsDimensions: ["roleplay_type", "target_role", "difficulty", "duration_minutes", "overall_score"],
requiredCorrelationFields: ["taskId"],
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
actionKind: "active_user_action",
},
},
{
id: "resume-service",
label: "Resume",
description: "Build, tailor, analyze, version, and preview resumes.",
category: "document",
enabled: Boolean(config.resumeServiceUrl),
featureId: "resume-building",
promptModulePath: "agents/resume.md",
backend: {
baseUrl: config.resumeServiceUrl,
publicUrl: config.resumePublicUrl,
healthPath: "/health",
endpoints: {
health: endpoint("GET", "/health", "Readiness probe.", "Check service availability before a handoff."),
state: endpoint("GET", "/api/state/:clerkId", "Returns user resume-builder state.", "Hydrate profile and personalization context."),
templates: endpoint("GET", "/api/v1/templates", "Lists resume templates.", "Render template gallery."),
a2aTask: endpoint("POST", "/a2a/tasks", "Runs resume-builder agent actions for a user_id.", "Agent/curator orchestrated work."),
listResumes: endpoint("GET", "/api/v1/resumes?clerk_id=:clerkId", "Lists resumes for a Clerk user.", "Resume hub."),
createResume: endpoint("POST", "/api/v1/resumes", "Creates a resume.", "Resume creation."),
getResume: endpoint("GET", "/api/v1/resumes/:resumeId", "Reads a resume.", "Resume editor."),
updateResume: endpoint("PUT", "/api/v1/resumes/:resumeId", "Updates a resume.", "Resume editor saves."),
analyzeResume: endpoint("POST", "/api/v1/ai/analyze/:resumeId", "Runs AI analysis for a resume.", "Resume score and improvement plan."),
suggestions: endpoint("GET", "/api/v1/ai/suggestions/:resumeId", "Returns AI suggestions.", "Editor improvement rail."),
copilot: endpoint("POST", "/api/v1/ai/copilot", "Runs resume copilot.", "Inline editing assistant."),
versions: endpoint("GET", "/api/v1/resumes/:resumeId/versions", "Lists resume versions.", "Version history."),
preview: endpoint("GET", "/api/v1/export/resumes/:resumeId/preview", "Returns resume preview.", "PDF/preview surface."),
},
usage: "Use gateway /services/resume/* for browser-authenticated requests so Clerk bearer tokens are preserved.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
workspace: {
path: "/agents/resume",
queryParams: ["view", "phase", "tab", "section", "role", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId", "taskTitle", "taskSubtitle", "taskType"],
usage: "Resume hub. Use tab=resumes by default and section to deep-link editor panels.",
},
editor: {
path: "/agents/resume/:resumeId",
queryParams: ["section"],
usage: "Resume editor for a known resume.",
},
templates: {
path: "/agents/resume/templates",
queryParams: [],
usage: "Template gallery.",
},
session: {
path: "/agents/resume",
queryParams: ["goal", "role"],
usage: "Legacy service-session launcher used by service agent results.",
},
},
usage: "Curator links should open the workspace unless a concrete resumeId is known.",
},
curator: {
defaultPage: "workspace",
defaultActionLabel: "Open resume workspace",
actionLabels: {
start: "Open resume",
review: "Review resume",
},
defaultQueryState: {
tab: "resumes",
},
toolName: "prepare_resume_upload",
completionEvents: ["resume.analysis.completed", "resume.parsed", "resume.updated", "resume.exported"],
qscoreSignals: ["proof.resume", "readiness.ats", "profile.skills"],
usage: "Include mission state and optional section when linking into resume work.",
},
usageDocs: [
"Call buildServiceLink('resume-service', 'workspace', { tab: 'resumes' }) for curator handoffs.",
"Use the resume gateway proxy for browser calls that need Clerk auth.",
],
eventContract: {
canonicalServiceId: "resume",
openEvents: ["task.opened"],
startedEvents: ["service.started", "resume.uploaded"],
configuredEvents: ["service.configured", "resume.parsed"],
completionEvents: ["service.completed", "resume.analysis.completed", "resume.updated", "resume.exported"],
abandonmentEvents: ["service.abandoned"],
qscoreSignalIds: ["resume.ats_score", "resume.profile_completeness", "proof.resume"],
analyticsDimensions: ["resume_id", "version_id", "ats_score", "role_fit_score", "updated_sections"],
requiredCorrelationFields: ["taskId"],
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
actionKind: "active_user_action",
},
},
{
id: "cover-letter-service",
label: "Cover Letter",
description: "Generate, tailor, analyze, version, and preview cover letters.",
category: "document",
enabled: Boolean(config.resumeServiceUrl),
featureId: "cover-letter",
promptModulePath: "agents/cover-letter.md",
aliases: ["coverletter-service"],
backend: {
baseUrl: config.resumeServiceUrl,
publicUrl: config.resumePublicUrl,
healthPath: "/health",
endpoints: {
health: endpoint("GET", "/health", "Readiness probe inherited from resume-builder.", "Check resume-builder availability."),
listCoverLetters: endpoint("GET", "/api/v1/cover-letters", "Lists cover letters.", "Cover-letter hub."),
createCoverLetter: endpoint("POST", "/api/v1/cover-letters", "Creates a cover letter.", "Manual creation."),
getCoverLetter: endpoint("GET", "/api/v1/cover-letters/:coverLetterId", "Reads a cover letter.", "Cover-letter editor."),
updateCoverLetter: endpoint("PUT", "/api/v1/cover-letters/:coverLetterId", "Updates a cover letter.", "Editor saves."),
generate: endpoint("POST", "/api/v1/cover-letters/generate", "Generates a tailored cover letter.", "Job application handoff."),
tailor: endpoint("POST", "/api/v1/cover-letters/:coverLetterId/tailor", "Tailors an existing cover letter.", "Application-specific rewrite."),
analyze: endpoint("POST", "/api/v1/cover-letters/:coverLetterId/analyze", "Analyzes a cover letter.", "Strength and fit scoring."),
copilot: endpoint("POST", "/api/v1/cover-letters/copilot", "Runs cover-letter copilot.", "Inline editing assistant."),
preview: endpoint("GET", "/api/v1/export/cover-letters/:coverLetterId/preview", "Returns cover-letter preview.", "Preview/PDF surface."),
},
usage: "Cover letters currently live behind resume-builder and the /services/resume/* proxy.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
workspace: {
path: "/agents/resume",
queryParams: ["tab", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Open with tab=cover-letters to land in the cover-letter list.",
},
generator: {
path: "/agents/resume",
queryParams: ["tab", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Cover-letter generation currently lands in the shared resume workspace with tab=cover-letters.",
},
editor: {
path: "/agents/resume/cover-letters/:coverLetterId",
queryParams: [],
usage: "Cover-letter editor for a known coverLetterId.",
},
},
usage: "Use workspace with tab=cover-letters for general handoffs.",
},
curator: {
defaultPage: "workspace",
defaultActionLabel: "Open cover letters",
actionLabels: {
start: "Write cover letter",
review: "Review cover letter",
},
defaultQueryState: {
tab: "cover-letters",
},
toolName: "prepare_cover_letter_handoff",
completionEvents: ["cover_letter.generated", "cover_letter.edited", "cover_letter.exported"],
qscoreSignals: ["proof.cover_letter", "readiness.application"],
usage: "Use for application-specific artifact tasks; share mission/job context in query or payload.",
},
usageDocs: [
"Call buildServiceLink('cover-letter-service', 'workspace', { tab: 'cover-letters' }) for curator handoffs.",
"Backend endpoint metadata maps to resume-builder cover-letter APIs.",
],
eventContract: {
canonicalServiceId: "cover_letter",
openEvents: ["task.opened"],
startedEvents: ["service.started"],
configuredEvents: ["service.configured"],
completionEvents: ["service.completed", "cover_letter.generated", "cover_letter.edited", "cover_letter.exported"],
abandonmentEvents: ["service.abandoned"],
qscoreSignalIds: ["proof.cover_letter", "readiness.application"],
analyticsDimensions: ["cover_letter_id", "role_fit_score", "export_format"],
requiredCorrelationFields: ["taskId"],
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
actionKind: "active_user_action",
},
},
{
id: "courses-service",
label: "Courses",
description: "Create, list, and open learning courses.",
category: "learning",
enabled: Boolean(config.coursesServiceUrl),
featureId: "courses",
promptModulePath: "agents/courses.md",
aliases: ["course-service"],
backend: {
baseUrl: config.coursesServiceUrl,
publicUrl: config.coursesPublicUrl,
healthPath: "/api/v1/health",
endpoints: {
health: endpoint("GET", "/api/v1/health", "Readiness probe.", "Check service availability."),
createCourse: endpoint("POST", "/api/v1/courses", "Creates a course.", "Admin or generated course creation."),
listCourses: endpoint("GET", "/api/v1/courses", "Lists courses with pagination/query filters.", "Course catalog."),
getCourse: endpoint("GET", "/api/v1/courses/:courseId", "Reads course details.", "Course detail page."),
},
usage: "Use for learning plan/course catalog handoffs; course generation stays in the service.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
catalog: {
path: "/agents/courses",
queryParams: ["view", "phase", "role", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId", "taskTitle", "taskSubtitle", "taskType"],
usage: "Course catalog and learning entry point.",
},
assigned: {
path: "/agents/courses/assigned",
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Assigned course rail.",
},
lesson: {
path: "/agents/courses/assigned/:assignmentCode/lesson/:lessonId",
queryParams: [],
usage: "Assigned course lesson player.",
},
},
usage: "Open catalog for general learning handoffs.",
},
curator: {
defaultPage: "catalog",
defaultActionLabel: "Open courses",
actionLabels: {
start: "Start course",
},
toolName: "prepare_course_handoff",
completionEvents: ["course.generated", "course.started", "course.progress_recorded", "course.lesson.completed", "course.completed", "course.weakness.applied"],
qscoreSignals: ["courses.started", "courses.completed", "courses.completion_rate"],
usage: "Use for skill-gap tasks that should become learning work.",
},
usageDocs: ["Call buildServiceLink('courses-service', 'catalog', state) for course handoffs."],
eventContract: {
canonicalServiceId: "course",
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"],
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"],
requiredCorrelationFields: ["taskId"],
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
actionKind: "active_user_action",
},
},
{
id: "assessment-service",
label: "Assessment",
description: "Create, list, read, and submit assessments.",
category: "measurement",
enabled: Boolean(config.assessmentServiceUrl),
featureId: "assessment",
promptModulePath: "agents/assessment.md",
backend: {
baseUrl: config.assessmentServiceUrl,
publicUrl: config.assessmentPublicUrl,
healthPath: "/api/v1/health",
endpoints: {
health: endpoint("GET", "/api/v1/health", "Readiness probe.", "Check service availability."),
createAssessment: endpoint("POST", "/api/v1/assessments", "Creates an assessment.", "Admin or generated assessment creation."),
listAssessments: endpoint("GET", "/api/v1/assessments", "Lists assessments with pagination/query filters.", "Assessment catalog."),
getAssessment: endpoint("GET", "/api/v1/assessments/:assessmentId", "Reads assessment details.", "Assessment page."),
submitAssessment: endpoint("POST", "/api/v1/assessments/:assessmentId/submit", "Submits answers and returns assessment state.", "Completion flow."),
},
usage: "Use for measurement tasks and proof of skill checks.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
assessment: {
path: "/agents/assessments",
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Assessment landing and active assessment surface.",
},
},
usage: "Open assessment for skill/readiness measurement handoffs.",
},
curator: {
defaultPage: "assessment",
defaultActionLabel: "Open assessment",
actionLabels: {
start: "Start assessment",
review: "Review assessment",
},
toolName: "prepare_assessment_handoff",
completionEvents: ["assessment.started", "assessment.completed"],
qscoreSignals: ["skills.assessment", "readiness.measurement"],
usage: "Use when a task needs a scoreable assessment rather than practice.",
},
usageDocs: ["Call buildServiceLink('assessment-service', 'assessment', state) for assessment handoffs."],
eventContract: {
canonicalServiceId: "assessment",
openEvents: ["task.opened"],
startedEvents: ["service.started", "assessment.started"],
configuredEvents: [],
completionEvents: ["service.completed", "assessment.completed"],
abandonmentEvents: ["service.abandoned"],
qscoreSignalIds: ["skills.assessment", "readiness.measurement"],
analyticsDimensions: ["assessment_id", "skill_area", "score", "weakness_tags"],
requiredCorrelationFields: ["taskId"],
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
actionKind: "measurement_review",
},
},
{
id: "matchmaking-service",
label: "Matchmaking",
description: "Match users to opportunities, employers, mentors, and networking targets.",
category: "opportunity",
enabled: Boolean(config.matchmakingServiceUrl),
featureId: "matchmaking",
promptModulePath: "agents/matchmaking.md",
aliases: ["jobs-service"],
backend: {
baseUrl: config.matchmakingServiceUrl,
publicUrl: config.matchmakingPublicUrl,
healthPath: "/api/v1/health",
endpoints: {
health: endpoint("GET", "/api/v1/health", "Readiness probe.", "Check service availability."),
preferences: endpoint("GET", "/api/v1/preferences/:userId", "Reads matching preferences.", "Hydrate feed filters and personalization."),
writePreferences: endpoint("POST", "/api/v1/preferences", "Writes matching preferences.", "Preference onboarding."),
feed: endpoint("GET", "/api/v1/feed", "Returns matched opportunity feed.", "Job/opportunity feed."),
feedAction: endpoint("POST", "/api/v1/feed/actions", "Records feed actions.", "Save/apply/dismiss tracking."),
opportunity: endpoint("GET", "/api/v1/opportunities/:opportunityId", "Reads opportunity details.", "Opportunity detail panel."),
a2aTask: endpoint("POST", "/a2a/tasks", "Runs matching agent tasks.", "Agent/curator orchestrated work."),
},
usage: "Use for opportunity matching and feed intelligence; keep user-specific actions through authenticated gateway routes when added.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
jobs: {
path: "/agents/matchmaking",
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId", "query", "role", "location"],
usage: "Job/opportunity matching dashboard.",
},
},
usage: "Use matchmaking for immediate opportunity matching and Scout-style recommendations.",
},
curator: {
defaultPage: "jobs",
defaultActionLabel: "Open matches",
actionLabels: {
start: "View matches",
review: "Review matches",
},
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"],
usage: "Use for immediate opportunity matching or mentor/network suggestions.",
},
usageDocs: [
"Call buildServiceLink('matchmaking-service', 'jobs', state) for job matching.",
"Use the canonical /agents/matchmaking page for Scout-style opportunity review.",
],
eventContract: {
canonicalServiceId: "matchmaking",
openEvents: ["task.opened", "matchmaking.feed.viewed"],
startedEvents: ["service.started", "matchmaking.application.started"],
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",
],
abandonmentEvents: ["service.abandoned"],
qscoreSignalIds: ["matching.jobs_viewed", "matching.applications_submitted", "matching.application_quality", "matching.match_rate"],
analyticsDimensions: ["match_count", "role_ids", "role_fit", "saved_count", "dismissed_reason"],
requiredCorrelationFields: ["taskId"],
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
actionKind: "active_user_action",
},
},
{
id: "qscore-service",
label: "QScore",
description: "Analyze readiness signals and expose score projections.",
category: "measurement",
enabled: Boolean(config.qscoreServiceUrl),
featureId: "q-score",
promptModulePath: "agents/qscore.md",
aliases: ["q-score-service"],
backend: {
baseUrl: config.qscoreServiceUrl,
publicUrl: config.qscorePublicUrl,
healthPath: "/health",
endpoints: {
health: endpoint("GET", "/health", "Readiness probe.", "Check service availability."),
currentGateway: endpoint("GET", "/services/qscore/current", "Backend-projected current score and latest signals.", "Dashboard QScore panel."),
ingest: endpoint("POST", "/api/v1/signals", "Ingests score signals when available.", "Service-to-service signal updates."),
compute: endpoint("POST", "/api/v1/score/compute", "Computes or refreshes score when available.", "Score recalculation."),
},
usage: "Use backend gateway /services/qscore/current for dashboard-safe reads; direct service APIs vary by QScore deployment.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
dashboard: {
path: "/agents/qscore",
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Dedicated Q Score service page with live readiness signals.",
},
},
usage: "Open the dedicated Q Score service page for score review.",
},
curator: {
defaultPage: "dashboard",
defaultActionLabel: "Review QScore",
actionLabels: {
review: "Review QScore",
},
toolName: "prepare_qscore_review",
completionEvents: ["qscore.baseline.created", "qscore.review.opened", "qscore.weak_driver.reviewed", "qscore.signal.projected", "qscore.updated"],
qscoreSignals: ["qscore.updated", "qscore.signal.projected"],
usage: "Use for measurement and projected readiness review tasks.",
},
usageDocs: ["Call buildServiceLink('qscore-service', 'dashboard', state) for QScore handoffs."],
eventContract: {
canonicalServiceId: "qscore",
openEvents: ["qscore.review.opened"],
startedEvents: ["service.started"],
configuredEvents: ["qscore.baseline.created"],
completionEvents: ["qscore.signal.projected", "qscore.updated", "qscore.weak_driver.reviewed"],
abandonmentEvents: [],
qscoreSignalIds: ["qscore.updated", "qscore.signal.projected"],
analyticsDimensions: ["score_before", "score_after", "signal_id", "delta", "trend_label"],
requiredCorrelationFields: [],
handoffParams: [],
actionKind: "measurement_review",
},
},
{
id: "social-branding-service",
label: "Social Branding",
description: "Build and optimize professional profile, LinkedIn, content, and brand signals.",
category: "profile",
enabled: Boolean(config.socialBrandingServiceUrl),
featureId: "social-branding",
promptModulePath: "agents/social-branding.md",
aliases: ["social-service"],
backend: {
baseUrl: config.socialBrandingServiceUrl,
publicUrl: config.socialBrandingPublicUrl,
healthPath: "/healthz",
endpoints: {
health: endpoint("GET", "/healthz", "Readiness probe.", "Check service availability."),
state: endpoint("GET", "/api/state/:clerkId", "Reads social/profile state.", "Hydrate personalization context."),
profile: endpoint("GET", "/api/v1/profile", "Reads profile data when available.", "Social profile page."),
linkedin: endpoint("POST", "/api/v1/linkedin", "Connects or imports LinkedIn data when available.", "LinkedIn onboarding."),
analyze: endpoint("POST", "/api/v1/analyze", "Analyzes profile/social brand when available.", "Brand improvement tasks."),
},
usage: "Use /services/social/* gateway proxy for browser-authenticated profile calls.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
profile: {
path: "/agents/social-branding",
queryParams: ["view", "phase", "role", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId", "taskTitle", "taskSubtitle", "taskType"],
usage: "Social/profile improvement page.",
},
},
usage: "Open profile for branding, LinkedIn, and social proof handoffs.",
},
curator: {
defaultPage: "profile",
defaultActionLabel: "Open social profile flow",
actionLabels: {
start: "Improve profile",
review: "Review profile",
},
toolName: "prepare_social_branding_handoff",
completionEvents: ["brand.post.drafted", "brand.post.edited", "brand.post.exported", "brand.profile.updated", "brand.content.published"],
qscoreSignals: ["profile.linkedin", "proof.visibility", "networking.brand"],
usage: "Use for profile visibility, LinkedIn cleanup, and social proof tasks.",
},
usageDocs: ["Call buildServiceLink('social-branding-service', 'profile', state) for social branding handoffs."],
eventContract: {
canonicalServiceId: "brand",
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"],
abandonmentEvents: ["service.abandoned"],
qscoreSignalIds: ["brand.visibility_signal", "profile.linkedin", "proof.visibility"],
analyticsDimensions: ["content_type", "channel", "draft_id", "topic", "profile_field_updated"],
requiredCorrelationFields: ["taskId"],
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
actionKind: "active_user_action",
},
},
];
const serviceAliases = new Map<string, ServiceId>();
for (const service of serviceRegistry) {
serviceAliases.set(service.id, service.id);
for (const alias of service.aliases ?? []) serviceAliases.set(alias, service.id);
}
export function normalizeServiceId(serviceId?: string | null): ServiceId | undefined {
if (!serviceId) return undefined;
return serviceAliases.get(serviceId);
}
export function listServices() {
return serviceRegistry.filter((service) => allowedServiceIds.has(service.id));
}
function getServiceRecord(serviceId?: string | null) {
const normalized = normalizeServiceId(serviceId);
return normalized ? serviceRegistry.find((service) => service.id === normalized) : undefined;
}
export function getService(serviceId?: string | null) {
const normalized = normalizeServiceId(serviceId);
return normalized ? listServices().find((service) => service.id === normalized) : undefined;
}
export function getServiceBackend(serviceId?: string | null) {
return getService(serviceId)?.backend;
}
export function getServiceFrontend(serviceId?: string | null) {
return getService(serviceId)?.frontend;
}
export function getServiceEndpoint(serviceId: string | undefined, endpointId: string) {
return getService(serviceId)?.backend.endpoints[endpointId];
}
export function getServiceUsageDocs(serviceId?: string | null) {
return getService(serviceId)?.usageDocs ?? [];
}
function resolvePage(service: ServiceRecord, pageId?: string) {
const selectedPageId = pageId || service.curator.defaultPage;
const direct = service.frontend.pages[selectedPageId];
if (direct) return direct;
return Object.values(service.frontend.pages).find((page) => page.aliases?.includes(selectedPageId));
}
export function buildServiceLink(serviceId: string | undefined, pageId?: string, state: QueryState = {}) {
const service = getService(serviceId);
if (!service) return undefined;
const page = resolvePage(service, pageId);
if (!page) return undefined;
const includeDefaultState = !pageId || pageId === service.curator.defaultPage;
return appendQuery(page.path, {
...(includeDefaultState ? service.curator.defaultQueryState : {}),
...state,
});
}
export function listServicesForCatalog() {
return listServices().map((service) => ({
id: service.id,
label: service.label,
description: service.description,
category: service.category,
enabled: service.enabled,
featureId: service.featureId,
backend: {
publicUrl: service.backend.publicUrl,
healthPath: service.backend.healthPath,
endpoints: service.backend.endpoints,
usage: service.backend.usage,
},
frontend: service.frontend,
curator: service.curator,
eventContract: service.eventContract,
usageDocs: service.usageDocs,
}));
}
export function buildServiceSessionPath(
serviceId: MissionServiceId,
detail?: Record<string, unknown>,
goal?: string,
) {
const sessionId = getSessionId(detail);
if (serviceId === "interview-service") {
if (!sessionId) return undefined;
return buildServiceLink(serviceId, "session", {
session_id: sessionId,
goal,
role: getString(detail?.target_role) ?? goal ?? "Interview practice",
type: getString(detail?.interview_type) ?? "behavioral",
});
}
if (serviceId === "roleplay-service") {
if (!sessionId) return undefined;
return buildServiceLink(serviceId, "session", {
session_id: sessionId,
goal,
role: getString(detail?.target_role) ?? goal ?? "Roleplay practice",
type: getString(detail?.roleplay_type) ?? "custom",
});
}
return buildServiceLink(serviceId, "session", {
goal,
role: goal,
});
}
export function buildMissionServiceRoute(input: MissionRouteInput) {
const pageId = input.serviceId === "resume-service" ? "workspace" : "setup";
return buildServiceLink(input.serviceId, pageId, {
source: "mission",
missionInstanceId: input.missionInstanceId,
missionId: input.missionId,
stageId: input.stageId,
goal: input.goal,
role: input.goal,
type: input.serviceId === "interview-service" ? "behavioral" : undefined,
}) ?? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId });
}
function curatorBaseParams(input: CuratorRouteInput) {
return {
source: "curator-v1",
missionInstanceId: input.missionInstanceId,
missionId: input.missionId,
stageId: input.stageId,
curatorTaskId: input.taskId,
};
}
function unavailableServiceRoute(input: CuratorRouteInput) {
return appendQuery("/agents/service-unavailable", {
...curatorBaseParams(input),
serviceId: input.serviceId,
});
}
export function buildCuratorServiceRoute(input: CuratorRouteInput) {
const service = getService(input.serviceId);
if (!service) return unavailableServiceRoute(input);
const state: QueryState = {
...curatorBaseParams(input),
};
if (input.taskTitle) state.taskTitle = input.taskTitle;
if (input.taskSubtitle) state.taskSubtitle = input.taskSubtitle;
if (input.taskType) state.taskType = input.taskType;
if (service.id === "interview-service") {
state.role = input.targetRole?.trim() || "Product Manager";
state.type = "behavioral";
state.persona = input.personaId ?? "payal";
state.duration = input.durationMinutes ?? 5;
state.difficulty = input.difficulty ?? "medium";
state.media = input.requestedMode ?? "video";
}
if (service.id === "roleplay-service") {
state.role = input.targetRole?.trim() || "Professional";
state.persona = input.personaId ?? "emma";
state.duration = input.durationMinutes ?? 5;
state.mode = input.requestedMode ?? "video";
state.brief = input.roleplayBrief;
}
if (service.id === "social-branding-service") {
state.agent = "nova";
}
if (
service.id === "resume-service" ||
service.id === "courses-service" ||
service.id === "social-branding-service"
) {
state.role = input.targetRole?.trim();
}
return buildServiceLink(service.id, service.curator.defaultPage, state)
?? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId });
}
const unavailableServiceLabels: Record<string, string> = {
"social-branding-service": "Social Branding",
"qscore-service": "Q Score",
"assessment-service": "Assessment",
"expert-service": "Expert",
"events-service": "Events",
};
export function getServiceDisplayName(serviceId?: string, fallback = "Mission planner") {
return getService(serviceId)?.label ?? (serviceId ? unavailableServiceLabels[serviceId] : undefined) ?? fallback;
}
export function getServiceToolName(serviceId?: string) {
return getService(serviceId)?.curator.toolName ?? "prepare_mission_step";
}
export function getCompletionEvents(serviceId?: string) {
return getServiceRecord(serviceId)?.curator.completionEvents ?? ["curator.task.completed"];
}
export const getServiceCompletionEvents = getCompletionEvents;
export function getServiceActionLabel(serviceId?: string, actionId?: string): string;
export function getServiceActionLabel(task: Pick<CuratorTask, "serviceId" | "cta">): string;
export function getServiceActionLabel(
input?: string | Pick<CuratorTask, "serviceId" | "cta">,
actionId?: string,
) {
if (typeof input === "object") {
const service = getService(input.serviceId);
if (actionId && service?.curator.actionLabels?.[actionId]) return service.curator.actionLabels[actionId];
return service?.curator.defaultActionLabel ?? input.cta ?? "Open";
}
const service = getService(input);
if (actionId && service?.curator.actionLabels?.[actionId]) return service.curator.actionLabels[actionId];
return service?.curator.defaultActionLabel ?? "Open";
}