From c48c28fdb3c63aa939477a97169f3bce174638b2 Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Mon, 22 Jun 2026 21:25:38 +0000 Subject: [PATCH] Implement canonical service registry --- Dockerfile | 7 +- docker-compose.yml | 4 + src/config.ts | 18 + src/features/registry.ts | 102 +-- src/home/home-feed.ts | 43 +- src/missions/actions.ts | 29 +- src/missions/reducer-helpers.ts | 11 +- src/routes/services.ts | 2 +- src/services/service-registry.ts | 930 +++++++++++++++++++++++--- src/v1/curator/curator-types.ts | 4 + src/workflows/service-capabilities.ts | 45 +- 11 files changed, 980 insertions(+), 215 deletions(-) diff --git a/Dockerfile b/Dockerfile index a459bd8..f19d3bf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,14 +2,15 @@ FROM node:22-alpine AS base WORKDIR /app FROM base AS deps -COPY package.json package-lock.json* ./ -RUN npm install +RUN corepack enable && corepack prepare pnpm@10.24.0 --activate +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile FROM base AS build COPY --from=deps /app/node_modules ./node_modules COPY tsconfig.json ./ COPY src ./src -RUN npx tsc -p tsconfig.json +RUN ./node_modules/.bin/tsc -p tsconfig.json FROM base AS runtime ARG RIVET_RUNNER_VERSION=dev diff --git a/docker-compose.yml b/docker-compose.yml index 548dd05..794cac7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -116,6 +116,10 @@ services: ROLEPLAY_SERVICE_URL: ${ROLEPLAY_SERVICE_URL:-http://host.docker.internal:8008} QSCORE_SERVICE_URL: ${QSCORE_SERVICE_URL:-http://host.docker.internal:8000} RESUME_SERVICE_URL: ${RESUME_SERVICE_URL:-http://host.docker.internal:8002} + COURSES_SERVICE_URL: ${COURSES_SERVICE_URL:-http://host.docker.internal:8060} + ASSESSMENT_SERVICE_URL: ${ASSESSMENT_SERVICE_URL:-http://host.docker.internal:8070} + MATCHMAKING_SERVICE_URL: ${MATCHMAKING_SERVICE_URL:-http://host.docker.internal:8006} + PATHWAYS_SERVICE_URL: ${PATHWAYS_SERVICE_URL:-http://host.docker.internal:8009} # Frontend FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000} volumes: diff --git a/src/config.ts b/src/config.ts index b8a1eb6..573d20e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -77,10 +77,28 @@ export const config = { process.env.USER_SERVICE_URL ?? "http://localhost:8003", resumePublicUrl: process.env.RESUME_PUBLIC_URL ?? process.env.RESUME_SERVICE_URL ?? "http://localhost:8002", + coursesServiceUrl: + process.env.COURSES_SERVICE_URL ?? "http://localhost:8060", + coursesPublicUrl: + process.env.COURSES_PUBLIC_URL ?? process.env.COURSES_SERVICE_URL ?? "http://localhost:8060", + assessmentServiceUrl: + process.env.ASSESSMENT_SERVICE_URL ?? "http://localhost:8070", + assessmentPublicUrl: + process.env.ASSESSMENT_PUBLIC_URL ?? process.env.ASSESSMENT_SERVICE_URL ?? "http://localhost:8070", matchmakingServiceUrl: process.env.MATCHMAKING_SERVICE_URL ?? "http://localhost:8006", + matchmakingPublicUrl: + process.env.MATCHMAKING_PUBLIC_URL ?? process.env.MATCHMAKING_SERVICE_URL ?? "http://localhost:8006", + pathwaysServiceUrl: + process.env.PATHWAYS_SERVICE_URL ?? "http://localhost:8009", + pathwaysPublicUrl: + process.env.PATHWAYS_PUBLIC_URL ?? process.env.PATHWAYS_SERVICE_URL ?? "http://localhost:8009", socialBrandingServiceUrl: process.env.SOCIAL_BRANDING_SERVICE_URL ?? "http://localhost:8005", + socialBrandingPublicUrl: + process.env.SOCIAL_BRANDING_PUBLIC_URL ?? process.env.SOCIAL_BRANDING_SERVICE_URL ?? "http://localhost:8005", + qscorePublicUrl: + process.env.QSCORE_PUBLIC_URL ?? process.env.QSCORE_SERVICE_URL ?? "http://localhost:8000", workflowsDashboardUrl: process.env.WORKFLOWS_DASHBOARD_URL ?? process.env.FRONTEND_ORIGIN ?? diff --git a/src/features/registry.ts b/src/features/registry.ts index 7cae275..3a9958b 100644 --- a/src/features/registry.ts +++ b/src/features/registry.ts @@ -1,7 +1,17 @@ -import { config } from "../config.js"; +import { getService, listServices, type ServiceId } from "../services/service-registry.js"; -export type GrowServiceId = "resume-service" | "interview-service" | "roleplay-service" | "qscore-service" | "social-branding-service" | "matchmaking-service"; -export type GrowFeatureId = "resume-building" | "mock-interview" | "mock-roleplay" | "q-score" | "social-branding" | "matchmaking"; +export type GrowServiceId = ServiceId; +export type GrowFeatureId = + | "resume-building" + | "cover-letter" + | "mock-interview" + | "mock-roleplay" + | "q-score" + | "social-branding" + | "matchmaking" + | "pathways" + | "courses" + | "assessment"; export type GrowFeatureDefinition = { id: GrowFeatureId; @@ -16,77 +26,18 @@ export type GrowFeatureDefinition = { operations: string[]; }; -export const featureDefinitions: GrowFeatureDefinition[] = [ - { - id: "resume-building", - serviceId: "resume-service", - title: "Resume Building", - label: "Resume", - description: "Build, tailor, analyze, and improve resumes for role fit and ATS readiness.", - promptModulePath: "agents/resume.md", - enabled: Boolean(config.resumeServiceUrl), - internalUrl: config.resumeServiceUrl, - publicUrl: config.resumePublicUrl, - operations: ["resume.state", "resume.templates", "resume.a2aTask", "resume.create", "resume.update", "resume.analyze", "resume.suggestions", "resume.copilot", "resume.optimizeSummary", "resume.optimizeExperience", "resume.suggestSkills", "resume.generateSummary", "resume.versions", "resume.preview"], - }, - { - id: "mock-interview", - serviceId: "interview-service", - title: "Mock Interview", - label: "Interview", - description: "Configure, practice, review, and score interview sessions.", - promptModulePath: "agents/interview.md", - enabled: Boolean(config.interviewServiceUrl), - internalUrl: config.interviewServiceUrl, - publicUrl: config.interviewPublicUrl, - operations: ["interview.configure", "interview.preview", "interview.questions", "interview.approve", "interview.assignments", "interview.unassign", "interview.resultsBulk", "interview.review", "interview.leaderboard", "interview.artifacts", "interview.videoUpload", "interview.practice"], - }, - { - id: "mock-roleplay", - serviceId: "roleplay-service", - title: "Mock Roleplay", - label: "Roleplay", - description: "Practice negotiations, recruiter calls, manager conversations, and stakeholder roleplays.", - promptModulePath: "agents/roleplay.md", - enabled: Boolean(config.roleplayServiceUrl), - internalUrl: config.roleplayServiceUrl, - publicUrl: config.roleplayPublicUrl, - operations: ["roleplay.configure", "roleplay.preview", "roleplay.questions", "roleplay.approve", "roleplay.assignments", "roleplay.unassign", "roleplay.resultsBulk", "roleplay.review", "roleplay.leaderboard", "roleplay.artifacts", "roleplay.videoUpload", "roleplay.practice"], - }, - { - id: "q-score", - serviceId: "qscore-service", - title: "Q Score", - label: "Q Score", - description: "Analyze overall job-market readiness and convert signals into improvement priorities.", - promptModulePath: "agents/qscore.md", - enabled: Boolean(config.qscoreServiceUrl), - internalUrl: config.qscoreServiceUrl, - operations: ["qscore.ingest", "qscore.compute"], - }, - { - id: "social-branding", - serviceId: "social-branding-service", - title: "Social Branding", - label: "Branding", - description: "Build and optimize your professional profile, LinkedIn presence, and personal brand.", - promptModulePath: "agents/social-branding.md", - enabled: Boolean(config.socialBrandingServiceUrl), - internalUrl: config.socialBrandingServiceUrl, - operations: ["branding.profile", "branding.linkedin", "branding.content", "branding.analyze"], - }, - { - id: "matchmaking", - serviceId: "matchmaking-service", - title: "Matchmaking", - label: "Matchmaking", - description: "Connect with relevant professionals, mentors, and opportunities through curated matching.", - promptModulePath: "agents/matchmaking.md", - enabled: Boolean(config.matchmakingServiceUrl), - internalUrl: config.matchmakingServiceUrl, - operations: ["matchmaking.find", "matchmaking.connect", "matchmaking.schedule", "matchmaking.review"], - }, -]; +export const featureDefinitions: GrowFeatureDefinition[] = listServices().map((service) => ({ + id: service.featureId as GrowFeatureId, + serviceId: service.id, + title: service.label, + label: service.label, + description: service.description, + promptModulePath: service.promptModulePath, + enabled: service.enabled, + internalUrl: service.backend.baseUrl, + publicUrl: service.backend.publicUrl, + operations: Object.keys(service.backend.endpoints), +})); export const internalWorkflowModules = [ { @@ -103,7 +54,8 @@ export function listFeatureDefinitions() { } export function getFeatureByServiceId(serviceId: string) { - return featureDefinitions.find((feature) => feature.serviceId === serviceId); + const service = getService(serviceId); + return service ? featureDefinitions.find((feature) => feature.serviceId === service.id) : undefined; } export function displayLabelForService(serviceId: string | undefined) { diff --git a/src/home/home-feed.ts b/src/home/home-feed.ts index 6b2ab8b..fce5315 100644 --- a/src/home/home-feed.ts +++ b/src/home/home-feed.ts @@ -15,6 +15,7 @@ import { type NewGrowHomeNotification, } from "../db/schema.js"; import { interviewService, resumeService, roleplayService } from "../services/product-service-clients.js"; +import { buildServiceLink } from "../services/service-registry.js"; import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js"; import { refineHomeNotificationsWithAgent } from "./home-feed-agent.js"; import { listAvailableMissionDefinitions } from "../missions/registry.js"; @@ -35,13 +36,13 @@ const FRESH_MS = 10 * 60 * 1000; const EXPIRY_MS = 24 * 60 * 60 * 1000; const SERVICE_HREFS = { - resume: "/agents/resume", - interview: "/agents/interview", - roleplay: "/agents/roleplay", - qscore: "/agents/qscore", + resume: buildServiceLink("resume-service", "workspace") ?? "/opportunities/resume", + interview: buildServiceLink("interview-service", "discovery") ?? "/upskilling/interview", + roleplay: buildServiceLink("roleplay-service", "discovery") ?? "/upskilling/roleplay", + qscore: buildServiceLink("qscore-service", "dashboard") ?? "/home", mission: "/missions/active", - social: "/social", - pathways: "/pathways", + social: buildServiceLink("social-branding-service", "profile") ?? "/opportunities/social-media", + pathways: buildServiceLink("pathways-service", "dashboard") ?? "/career-pathways", rewards: "/rewards", suggestions: "/suggestions", productivity: "/productivity", @@ -101,20 +102,22 @@ function profileFromPreferences(preferences: Record) { function serviceHref(service: "resume" | "interview" | "roleplay" | "qscore", ctx: HomeContext, mission?: { instanceId?: string; missionId?: string; stageId?: string | null }) { const profile = profileFromPreferences(ctx.preferences); - const params = new URLSearchParams({ source: "home" }); - if (mission?.instanceId) params.set("missionInstanceId", mission.instanceId); - if (mission?.missionId) params.set("missionId", mission.missionId); - if (mission?.stageId) params.set("stageId", mission.stageId); - params.set("targetRole", profile.targetRole); - if (profile.targetCompany !== "target company") params.set("targetCompany", profile.targetCompany); - if (profile.industry) params.set("industry", profile.industry); - if (profile.focusAreas.length) params.set("focusAreas", profile.focusAreas.slice(0, 4).join(",")); - if (profile.weakSpots.length) params.set("weakSpots", profile.weakSpots.slice(0, 3).join(",")); - if (profile.jobDescription) params.set("jobDescription", profile.jobDescription.slice(0, 900)); - if (service === "interview") return `/agents/interview/setup?${params.toString()}`; - if (service === "roleplay") return `/agents/roleplay/setup?${params.toString()}`; - if (service === "resume") return `/agents/resume?${params.toString()}`; - return `/agents/qscore?${params.toString()}`; + const serviceId = service === "qscore" ? "qscore-service" : `${service}-service`; + const pageId = service === "resume" ? "workspace" : service === "qscore" ? "dashboard" : "setup"; + return buildServiceLink(serviceId, pageId, { + source: "home", + missionInstanceId: mission?.instanceId, + missionId: mission?.missionId, + stageId: mission?.stageId ?? undefined, + targetRole: profile.targetRole, + role: profile.targetRole, + targetCompany: profile.targetCompany !== "target company" ? profile.targetCompany : undefined, + industry: profile.industry, + focusAreas: profile.focusAreas.length ? profile.focusAreas.slice(0, 4).join(",") : undefined, + weakSpots: profile.weakSpots.length ? profile.weakSpots.slice(0, 3).join(",") : undefined, + jobDescription: profile.jobDescription?.slice(0, 900), + type: service === "interview" ? "behavioral" : undefined, + }) ?? SERVICE_HREFS[service]; } function sourceFromSuggestionRole(role: string): HomeSource { diff --git a/src/missions/actions.ts b/src/missions/actions.ts index 1e5a02b..d159921 100644 --- a/src/missions/actions.ts +++ b/src/missions/actions.ts @@ -4,7 +4,8 @@ import { missionActions, missionSuggestions } from "../db/schema.js"; import type { GrowActiveMission } from "../actors/missions/types.js"; import type { MissionActionPatch } from "./reducer-types.js"; import { defaultMissionActionStatus, type MissionActionDto, type MissionActionRow, type MissionActionStatus, type NewMissionActionInput } from "./action-types.js"; -import { missionDetailHref, serviceHref } from "./reducer-helpers.js"; +import { missionDetailHref } from "./reducer-helpers.js"; +import { buildServiceLink, getService, getServiceActionLabel } from "../services/service-registry.js"; const OPEN_STATUSES: MissionActionStatus[] = ["queued", "running", "waiting_approval", "waiting_user_input", "failed"]; const DONE_STATUSES: MissionActionStatus[] = ["done", "dismissed", "snoozed"]; @@ -48,26 +49,30 @@ export function actionToDto(row: MissionActionRow): MissionActionDto { function ctaForAction(action: MissionActionRow | NewMissionActionInput) { const payload = action.payload && typeof action.payload === "object" && !Array.isArray(action.payload) ? action.payload as Record : {}; const hrefFromPayload = typeof payload.href === "string" ? payload.href : undefined; - const serviceId = action.serviceId ?? ""; const missionHref = missionDetailHref(action.missionInstanceId); - const href = hrefFromPayload ?? - (serviceId.includes("interview") ? serviceHref("interview", action.missionInstanceId, action.missionId, action.stageId ?? undefined) : - serviceId.includes("roleplay") ? serviceHref("roleplay", action.missionInstanceId, action.missionId, action.stageId ?? undefined) : - serviceId.includes("resume") ? serviceHref("resume", action.missionInstanceId, action.missionId, action.stageId ?? undefined) : missionHref); + const service = getService(action.serviceId); + const href = hrefFromPayload ?? ( + service + ? buildServiceLink(service.id, service.curator.defaultPage, { + source: "mission", + missionInstanceId: action.missionInstanceId, + missionId: action.missionId, + stageId: action.stageId ?? undefined, + }) ?? missionHref + : missionHref + ); if (action.mode === "approval_required") return { ctaLabel: "Review", ctaHref: missionHref }; if (action.mode === "user_input_required") return { ctaLabel: "Answer", ctaHref: missionHref }; - if (serviceId.includes("interview")) return { ctaLabel: "Start mock", ctaHref: href }; - if (serviceId.includes("roleplay")) return { ctaLabel: "Run drill", ctaHref: href }; - if (serviceId.includes("resume")) return { ctaLabel: "Open resume", ctaHref: href }; - return { ctaLabel: "Open", ctaHref: href }; + return { ctaLabel: service ? getServiceActionLabel(service.id, "start") : "Open", ctaHref: href }; } function suggestionTypeForAction(action: MissionActionRow | NewMissionActionInput) { if (action.mode === "user_input_required") return "blocked" as const; if (action.mode === "approval_required") return "review" as const; - if ((action.serviceId ?? "").includes("interview") || (action.serviceId ?? "").includes("roleplay")) return "practice" as const; - if ((action.serviceId ?? "").includes("resume")) return "artifact" as const; + const category = getService(action.serviceId)?.category; + if (category === "practice") return "practice" as const; + if (category === "document") return "artifact" as const; return "action" as const; } diff --git a/src/missions/reducer-helpers.ts b/src/missions/reducer-helpers.ts index 313c3e5..4bc1548 100644 --- a/src/missions/reducer-helpers.ts +++ b/src/missions/reducer-helpers.ts @@ -1,4 +1,5 @@ import { asRecord, getNumber, getString } from "../events/envelope.js"; +import { buildServiceLink } from "../services/service-registry.js"; import type { MissionActionPatch } from "./reducer-types.js"; export function isResumeEvent(source: string, type: string) { @@ -134,12 +135,10 @@ export function actionForAgent(missionId: string, agent: "planner" | "resume" | } export function serviceHref(service: "resume" | "interview" | "roleplay" | "qscore", missionInstanceId: string, missionId: string, stageId?: string) { - const params = new URLSearchParams({ source: "mission", missionInstanceId, missionId }); - if (stageId) params.set("stageId", stageId); - if (service === "interview") return `/agents/interview/setup?${params.toString()}`; - if (service === "roleplay") return `/agents/roleplay/setup?${params.toString()}`; - if (service === "resume") return `/agents/resume?${params.toString()}`; - return `/agents/qscore?${params.toString()}`; + const serviceId = service === "qscore" ? "qscore-service" : `${service}-service`; + const pageId = service === "resume" ? "workspace" : service === "qscore" ? "dashboard" : "setup"; + return buildServiceLink(serviceId, pageId, { source: "mission", missionInstanceId, missionId, stageId }) + ?? missionDetailHref(missionInstanceId); } export function missionDetailHref(missionInstanceId: string) { diff --git a/src/routes/services.ts b/src/routes/services.ts index 9b5fbf3..24040cf 100644 --- a/src/routes/services.ts +++ b/src/routes/services.ts @@ -442,7 +442,7 @@ export function serviceRoutes() { const app = new Hono(); app.use("*", requireUser); - app.get("/catalog", (c) => c.json({ services: listServiceCapabilities() })); + app.get("/catalog", (c) => c.json({ services: listServiceCapabilities({ public: true }) })); app.get("/agents", async (c) => { const userId = c.get("userId"); diff --git a/src/services/service-registry.ts b/src/services/service-registry.ts index 518296a..3c0486d 100644 --- a/src/services/service-registry.ts +++ b/src/services/service-registry.ts @@ -1,6 +1,70 @@ +import { config } from "../config.js"; import type { CuratorServiceId, CuratorTask } from "../v1/curator/curator-types.js"; -type QueryValue = string | number | undefined | null; +export type QueryValue = string | number | boolean | undefined | null; +export type QueryState = Record; + +export type ServiceId = + | "interview-service" + | "roleplay-service" + | "courses-service" + | "assessment-service" + | "matchmaking-service" + | "pathways-service" + | "resume-service" + | "cover-letter-service" + | "qscore-service" + | "social-branding-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; + usage: string; + }; + frontend: { + baseUrl: string; + pages: Record; + usage: string; + }; + curator: { + defaultPage: string; + defaultActionLabel: string; + defaultQueryState?: QueryState; + actionLabels?: Record; + toolName: string; + completionEvents: string[]; + qscoreSignals?: string[]; + usage: string; + }; + usageDocs: string[]; +}; type MissionServiceId = Extract; @@ -26,10 +90,16 @@ type CuratorRouteInput = { roleplayBrief?: string; }; -function appendQuery( - pathname: string, - params: Record, -) { +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; @@ -47,6 +117,720 @@ function getSessionId(detail?: Record) { return getString(detail?.session_id ?? detail?.sessionId ?? detail?.id); } +const frontendBaseUrl = config.workflowsDashboardUrl.replace(/\/$/, ""); + +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: { + discovery: { + path: "/upskilling/interview", + aliases: ["/agents/interview"], + queryParams: ["fresh"], + usage: "Entry screen for role selection and resume of in-progress interview work.", + }, + setup: { + path: "/upskilling/interview/setup", + aliases: ["/agents/interview/setup"], + queryParams: ["role", "type", "from_assignment"], + usage: "Collects interview role, type, duration, persona, media mode, and personalization consent.", + }, + preview: { + path: "/upskilling/interview/preview", + aliases: ["/agents/interview/preview"], + queryParams: ["role", "type", "persona", "duration", "difficulty", "media", "vip", "from_assignment", "personalize", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"], + usage: "Curator default handoff. The page configures the session and opens the launch overlay.", + }, + feedback: { + path: "/upskilling/interview/feedback", + queryParams: ["sessionId"], + usage: "Opens feedback/review for a completed or processing session.", + }, + session: { + path: "/v2/service-sessions/interview", + queryParams: ["session_id", "goal", "role", "type"], + usage: "Legacy service-session launcher used by service agent results.", + }, + }, + usage: "Prefer preview for curator links and setup for mission CTAs that still need user choices.", + }, + curator: { + defaultPage: "preview", + defaultActionLabel: "Open interview preview", + actionLabels: { + start: "Start mock", + review: "Review interview", + }, + defaultQueryState: { + type: "behavioral", + persona: "payal", + duration: 5, + difficulty: "medium", + media: "video", + }, + toolName: "prepare_interview_preview", + completionEvents: ["interview.configured", "interview.review_completed", "interview.completed"], + 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', 'preview', state) for curator handoffs.", + "Call getServiceEndpoint('interview-service', 'configure') for backend contract metadata.", + ], + }, + { + 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: { + discovery: { + path: "/upskilling/roleplay", + aliases: ["/agents/roleplay"], + queryParams: ["fresh"], + usage: "Entry screen for scenario discovery and resume of in-progress roleplay work.", + }, + setup: { + path: "/upskilling/roleplay/setup", + aliases: ["/agents/roleplay/setup"], + queryParams: ["scenario", "scenario_text", "scenario_name", "from_assignment"], + usage: "Collects roleplay scenario details and stores configure parameters for the builder.", + }, + builder: { + path: "/upskilling/roleplay/builder", + aliases: ["/agents/roleplay/builder"], + queryParams: ["sessionId", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId", "role", "persona", "duration", "mode", "brief"], + usage: "Curator default handoff for generating or resuming a roleplay plan.", + }, + feedback: { + path: "/upskilling/roleplay/feedback", + queryParams: ["sessionId"], + usage: "Opens feedback/review for a completed or processing session.", + }, + session: { + path: "/v2/service-sessions/roleplay", + queryParams: ["session_id", "goal", "role", "type"], + usage: "Legacy service-session launcher used by service agent results.", + }, + }, + usage: "Prefer builder for curator links and setup for mission CTAs that still need user choices.", + }, + curator: { + defaultPage: "builder", + defaultActionLabel: "Open roleplay preview", + actionLabels: { + start: "Run drill", + review: "Review roleplay", + }, + defaultQueryState: { + role: "Professional", + persona: "emma", + duration: 5, + mode: "video", + }, + toolName: "prepare_roleplay_preview", + completionEvents: ["roleplay.configured", "roleplay.review_completed", "roleplay.completed"], + qscoreSignals: ["communication.roleplay", "networking.conversation", "readiness.practice"], + usage: "Include role, brief, mission state, and curatorTaskId when building stateful handoffs.", + }, + usageDocs: [ + "Call buildServiceLink('roleplay-service', 'builder', state) for curator handoffs.", + "Call getServiceEndpoint('roleplay-service', 'configure') for backend contract metadata.", + ], + }, + { + 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: "/opportunities/resume", + aliases: ["/agents/resume"], + queryParams: ["tab", "section", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"], + usage: "Resume hub. Use tab=resumes by default and section to deep-link editor panels.", + }, + editor: { + path: "/opportunities/resume/:resumeId", + queryParams: ["section"], + usage: "Resume editor for a known resume.", + }, + templates: { + path: "/opportunities/resume/templates", + queryParams: [], + usage: "Template gallery.", + }, + session: { + path: "/v2/service-sessions/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"], + 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.", + ], + }, + { + 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: "/opportunities/resume", + queryParams: ["tab", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"], + usage: "Open with tab=cover-letters to land in the cover-letter list.", + }, + generator: { + path: "/opportunities/cover-letter", + queryParams: [], + usage: "Standalone generation entry point.", + }, + editor: { + path: "/opportunities/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.updated", "cover_letter.analysis_completed"], + 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.", + ], + }, + { + id: "courses-service", + label: "Courses", + description: "Create, list, and open upskilling 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: "/upskilling/course", + queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"], + usage: "Course catalog and upskilling entry point.", + }, + }, + usage: "Open catalog for general learning handoffs.", + }, + curator: { + defaultPage: "catalog", + defaultActionLabel: "Open courses", + actionLabels: { + start: "Start course", + }, + toolName: "prepare_course_handoff", + completionEvents: ["course.started", "course.completed"], + qscoreSignals: ["skills.learning", "readiness.upskilling"], + usage: "Use for skill-gap tasks that should become learning work.", + }, + usageDocs: ["Call buildServiceLink('courses-service', 'catalog', state) for course handoffs."], + }, + { + 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: "/upskilling/assessment", + 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.submitted", "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."], + }, + { + 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: "/opportunities/job-matching", + aliases: ["/pathways"], + queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId", "query", "role", "location"], + usage: "Job/opportunity matching dashboard.", + }, + pathways: { + path: "/career-pathways", + queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"], + usage: "Career pathways dashboard and pathway list.", + }, + }, + usage: "Use jobs for immediate matching and pathways for career-plan handoffs.", + }, + curator: { + defaultPage: "jobs", + defaultActionLabel: "Open matches", + actionLabels: { + start: "View matches", + review: "Review matches", + }, + toolName: "prepare_matchmaking_handoff", + completionEvents: ["matchmaking.feed_viewed", "matchmaking.match_saved", "matchmaking.preference_updated"], + qscoreSignals: ["market.matches", "networking.opportunities"], + usage: "Use for immediate opportunity matching or mentor/network suggestions.", + }, + usageDocs: [ + "Call buildServiceLink('matchmaking-service', 'jobs', state) for job matching.", + "Use pathways-service for generated career pathway plans.", + ], + }, + { + id: "pathways-service", + label: "Pathways", + description: "Generate, activate, and report on personalized career pathways.", + category: "opportunity", + enabled: Boolean(config.pathwaysServiceUrl), + featureId: "pathways", + promptModulePath: "agents/pathways.md", + aliases: ["career-pathways-service"], + backend: { + baseUrl: config.pathwaysServiceUrl, + publicUrl: config.pathwaysPublicUrl, + healthPath: "/api/v1/health", + endpoints: { + health: endpoint("GET", "/api/v1/health", "Readiness probe.", "Check service availability."), + state: endpoint("GET", "/api/state/:clerkId", "Reads pathway state.", "Hydrate pathway dashboard."), + profileIngest: endpoint("POST", "/api/v1/profiles/ingest", "Ingests a profile for pathway generation.", "Profile setup."), + questionnaire: endpoint("GET", "/api/v1/questionnaires/:userId", "Reads pathway questionnaire.", "Questionnaire resume."), + createQuestionnaire: endpoint("POST", "/api/v1/questionnaires", "Creates questionnaire answers.", "Pathway onboarding."), + generatePathway: endpoint("POST", "/api/v1/pathways/generate", "Generates a pathway.", "Career-plan generation."), + activatePathway: endpoint("POST", "/api/v1/pathways/:pathwayId/activate", "Activates a pathway.", "Commit chosen pathway."), + getPathway: endpoint("GET", "/api/v1/pathways/:pathwayId", "Reads pathway details.", "Pathway detail."), + weeklyPlan: endpoint("GET", "/api/v1/pathways/:pathwayId/weekly-plan", "Reads weekly plan.", "Planner UI."), + report: endpoint("GET", "/api/v1/pathways/:pathwayId/report", "Reads pathway report.", "Progress report."), + opportunities: endpoint("GET", "/api/v1/pathways/:pathwayId/opportunities", "Reads pathway opportunities.", "Pathway opportunity recommendations."), + a2aTask: endpoint("POST", "/a2a/tasks", "Runs pathway agent tasks.", "Agent/curator orchestrated work."), + }, + usage: "Use for generated pathway plans and recommendation context. The live container is currently healthchecked separately from matchmaking.", + }, + frontend: { + baseUrl: frontendBaseUrl, + pages: { + dashboard: { + path: "/career-pathways", + aliases: ["/pathways"], + queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"], + usage: "Pathway list/dashboard.", + }, + generate: { + path: "/career-pathways/generate", + queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"], + usage: "Pathway generation flow.", + }, + questionnaire: { + path: "/career-pathways/questionnaire", + queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"], + usage: "Pathway questionnaire flow.", + }, + detail: { + path: "/career-pathways/dashboard/:pathwayId", + queryParams: [], + usage: "Pathway detail dashboard.", + }, + }, + usage: "Use dashboard for general pathway handoffs and generate/questionnaire for guided setup.", + }, + curator: { + defaultPage: "dashboard", + defaultActionLabel: "Open pathways", + actionLabels: { + start: "Build pathway", + review: "Review pathway", + }, + toolName: "prepare_pathways_handoff", + completionEvents: ["pathway.generated", "pathway.activated", "pathway.report_viewed"], + qscoreSignals: ["readiness.pathway", "market.plan"], + usage: "Use for strategic career pathway planning rather than immediate job matching.", + }, + usageDocs: ["Call buildServiceLink('pathways-service', 'dashboard', state) for pathway handoffs."], + }, + { + 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: "/home", + aliases: ["/analytics"], + queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"], + usage: "Home dashboard with QScore panel.", + }, + }, + usage: "Open home dashboard for QScore review until a dedicated analytics route exists.", + }, + curator: { + defaultPage: "dashboard", + defaultActionLabel: "Review QScore", + actionLabels: { + review: "Review QScore", + }, + toolName: "prepare_qscore_review", + completionEvents: ["qscore.updated", "qscore.signal_projected"], + 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."], + }, + { + 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: "/health", + endpoints: { + health: endpoint("GET", "/health", "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: "/opportunities/social-media", + aliases: ["/social"], + queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"], + 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: ["social.profile_updated", "social.linkedin_connected", "social.branding_analyzed"], + 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."], + }, +]; + +const serviceAliases = new Map(); +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; +} + +export function getService(serviceId?: string | null) { + const normalized = normalizeServiceId(serviceId); + return normalized ? serviceRegistry.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 serviceRegistry.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, + usageDocs: service.usageDocs, + })); +} + export function buildServiceSessionPath( serviceId: MissionServiceId, detail?: Record, @@ -56,7 +840,7 @@ export function buildServiceSessionPath( if (serviceId === "interview-service") { if (!sessionId) return undefined; - return appendQuery("/v2/service-sessions/interview", { + return buildServiceLink(serviceId, "session", { session_id: sessionId, goal, role: getString(detail?.target_role) ?? goal ?? "Interview practice", @@ -66,7 +850,7 @@ export function buildServiceSessionPath( if (serviceId === "roleplay-service") { if (!sessionId) return undefined; - return appendQuery("/v2/service-sessions/roleplay", { + return buildServiceLink(serviceId, "session", { session_id: sessionId, goal, role: getString(detail?.target_role) ?? goal ?? "Roleplay practice", @@ -74,30 +858,23 @@ export function buildServiceSessionPath( }); } - return appendQuery("/v2/service-sessions/resume", { + return buildServiceLink(serviceId, "session", { goal, role: goal, }); } export function buildMissionServiceRoute(input: MissionRouteInput) { - const baseParams = { + 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, - }; - - if (input.serviceId === "interview-service") { - return appendQuery("/agents/interview/setup", baseParams); - } - - if (input.serviceId === "roleplay-service") { - return appendQuery("/agents/roleplay/setup", baseParams); - } - - return appendQuery("/agents/resume", baseParams); + role: input.goal, + type: input.serviceId === "interview-service" ? "behavioral" : undefined, + }) ?? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId }); } function curatorBaseParams(input: CuratorRouteInput) { @@ -111,85 +888,64 @@ function curatorBaseParams(input: CuratorRouteInput) { } export function buildCuratorServiceRoute(input: CuratorRouteInput) { - if (input.serviceId === "interview-service") { - return appendQuery("/agents/interview/preview", { - ...curatorBaseParams(input), - role: input.targetRole?.trim() || "Product Manager", - type: "behavioral", - persona: input.personaId ?? "payal", - duration: input.durationMinutes ?? 5, - difficulty: input.difficulty ?? "medium", - media: input.requestedMode ?? "video", - }); + const service = getService(input.serviceId); + if (!service) { + return input.missionInstanceId + ? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId }) + : "/missions/active"; } - if (input.serviceId === "roleplay-service") { - return appendQuery("/agents/roleplay/builder", { - ...curatorBaseParams(input), - role: input.targetRole?.trim() || "Professional", - persona: input.personaId ?? "emma", - duration: input.durationMinutes ?? 5, - mode: input.requestedMode ?? "video", - brief: input.roleplayBrief, - }); + const state: QueryState = { + ...curatorBaseParams(input), + }; + + 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 (input.serviceId === "resume-service") { - return appendQuery("/agents/resume", curatorBaseParams(input)); - } - if (input.serviceId === "qscore-service") { - return appendQuery("/analytics", curatorBaseParams(input)); - } - if (input.serviceId === "social-branding-service") { - return appendQuery("/social", curatorBaseParams(input)); - } - if (input.serviceId === "matchmaking-service") { - return appendQuery("/pathways", curatorBaseParams(input)); + 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; } - return input.missionInstanceId - ? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId }) - : "/missions/active"; + return buildServiceLink(service.id, service.curator.defaultPage, state) + ?? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId }); } -export function getServiceDisplayName(serviceId?: CuratorServiceId, fallback = "Mission planner") { - if (serviceId === "interview-service") return "Interview service"; - if (serviceId === "roleplay-service") return "Roleplay service"; - if (serviceId === "resume-service") return "Resume service"; - if (serviceId === "qscore-service") return "Q Score service"; - if (serviceId === "social-branding-service") return "Social branding service"; - if (serviceId === "matchmaking-service") return "Pathways service"; - return fallback; +export function getServiceDisplayName(serviceId?: string, fallback = "Mission planner") { + return getService(serviceId)?.label ?? fallback; } -export function getServiceToolName(serviceId?: CuratorServiceId) { - if (serviceId === "interview-service") return "prepare_interview_preview"; - if (serviceId === "roleplay-service") return "prepare_roleplay_preview"; - if (serviceId === "resume-service") return "prepare_resume_upload"; - if (serviceId === "qscore-service") return "prepare_qscore_review"; - return "prepare_mission_step"; +export function getServiceToolName(serviceId?: string) { + return getService(serviceId)?.curator.toolName ?? "prepare_mission_step"; } -export function getServiceCompletionEvents(serviceId?: CuratorServiceId) { - if (serviceId === "interview-service") { - return ["interview.configured", "interview.review_completed", "interview.completed"]; - } - if (serviceId === "roleplay-service") { - return ["roleplay.configured", "roleplay.review_completed", "roleplay.completed"]; - } - if (serviceId === "resume-service") { - return ["resume.analysis_completed", "resume.parsed", "resume.updated"]; - } - if (serviceId === "qscore-service") { - return ["qscore.updated", "qscore.signal_projected"]; - } - return ["curator.task.completed"]; +export function getCompletionEvents(serviceId?: string) { + return getService(serviceId)?.curator.completionEvents ?? ["curator.task.completed"]; } -export function getServiceActionLabel(task: CuratorTask) { - if (task.serviceId === "interview-service") return "Open interview preview"; - if (task.serviceId === "roleplay-service") return "Open roleplay preview"; - if (task.serviceId === "resume-service") return "Open resume workspace"; - if (task.serviceId === "qscore-service") return "Review Q Score"; - return task.cta || "Open"; +export const getServiceCompletionEvents = getCompletionEvents; + +export function getServiceActionLabel(serviceId?: string, actionId?: string): string; +export function getServiceActionLabel(task: Pick): string; +export function getServiceActionLabel( + input?: string | Pick, + 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"; } diff --git a/src/v1/curator/curator-types.ts b/src/v1/curator/curator-types.ts index 58ebcbe..c051b4b 100644 --- a/src/v1/curator/curator-types.ts +++ b/src/v1/curator/curator-types.ts @@ -3,10 +3,14 @@ import { z } from "zod"; export const curatorServiceIdSchema = z.enum([ "interview-service", "resume-service", + "cover-letter-service", "roleplay-service", + "courses-service", + "assessment-service", "qscore-service", "social-branding-service", "matchmaking-service", + "pathways-service", ]); export type CuratorServiceId = z.infer; diff --git a/src/workflows/service-capabilities.ts b/src/workflows/service-capabilities.ts index 47ba439..da0b440 100644 --- a/src/workflows/service-capabilities.ts +++ b/src/workflows/service-capabilities.ts @@ -1,27 +1,50 @@ -import { listFeatureDefinitions, internalWorkflowModules } from "../features/registry.js"; +import { internalWorkflowModules } from "../features/registry.js"; +import { listServices } from "../services/service-registry.js"; export type ServiceCapability = { id: string; name: string; + label?: string; + description?: string; + category?: string; enabled: boolean; internalUrl?: string; publicUrl?: string; operations: string[]; featureId?: string; promptModulePath?: string; + healthPath?: string; + backend?: unknown; + frontend?: unknown; + curator?: unknown; + usageDocs?: string[]; }; -export function listServiceCapabilities(): ServiceCapability[] { +export function listServiceCapabilities(opts: { public?: boolean } = {}): ServiceCapability[] { return [ - ...listFeatureDefinitions().map((feature) => ({ - id: feature.serviceId, - name: feature.title, - enabled: feature.enabled, - internalUrl: feature.internalUrl, - publicUrl: feature.publicUrl, - operations: feature.operations, - featureId: feature.id, - promptModulePath: feature.promptModulePath, + ...listServices().map((service) => ({ + id: service.id, + name: service.label, + label: service.label, + description: service.description, + category: service.category, + enabled: service.enabled, + ...(opts.public ? {} : { internalUrl: service.backend.baseUrl }), + publicUrl: service.backend.publicUrl, + operations: Object.keys(service.backend.endpoints), + featureId: service.featureId, + promptModulePath: service.promptModulePath, + healthPath: service.backend.healthPath, + backend: { + ...(opts.public ? {} : { baseUrl: service.backend.baseUrl }), + publicUrl: service.backend.publicUrl, + healthPath: service.backend.healthPath, + endpoints: service.backend.endpoints, + usage: service.backend.usage, + }, + frontend: service.frontend, + curator: service.curator, + usageDocs: service.usageDocs, })), ...internalWorkflowModules.map((module) => ({ id: module.id,