408 lines
12 KiB
TypeScript
408 lines
12 KiB
TypeScript
import { config } from "../config.js";
|
|
import { createHash } from "node:crypto";
|
|
|
|
// Lightweight agent reference (works with both old AgentProfile and new SubAgentModule).
|
|
export type ServiceAgentRef = {
|
|
id: string;
|
|
name: string;
|
|
role: string;
|
|
kind: string;
|
|
description: string;
|
|
service?: string;
|
|
};
|
|
|
|
export type ServiceAgentResult = {
|
|
status: "ok" | "unavailable" | "local";
|
|
summary: string;
|
|
detail?: unknown;
|
|
};
|
|
|
|
export type ServiceAgentContext = {
|
|
userId: string;
|
|
orgId?: string;
|
|
goal: string;
|
|
};
|
|
|
|
export function buildServiceSessionUrl(
|
|
service: string | undefined,
|
|
detail: Record<string, unknown> | undefined,
|
|
goal?: string,
|
|
): string | undefined {
|
|
const sessionId = detail?.session_id ?? detail?.sessionId;
|
|
if (!sessionId || typeof sessionId !== "string") return undefined;
|
|
|
|
const base = config.growqrAppFrontendUrl.replace(/\/$/, "");
|
|
const params = new URLSearchParams({ session_id: sessionId });
|
|
if (goal) params.set("goal", goal);
|
|
|
|
if (service === "interview-service") {
|
|
params.set("role", String(detail?.target_role ?? goal ?? "Interview practice"));
|
|
params.set("type", String(detail?.interview_type ?? "behavioral"));
|
|
return `${base}/service-sessions/interview?${params.toString()}`;
|
|
}
|
|
|
|
if (service === "roleplay-service") {
|
|
params.set("role", String(detail?.target_role ?? goal ?? "Roleplay practice"));
|
|
params.set("type", String(detail?.roleplay_type ?? "custom"));
|
|
return `${base}/service-sessions/roleplay?${params.toString()}`;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function stableUuid(input: string): string {
|
|
const hex = createHash("sha256").update(input).digest("hex").slice(0, 32);
|
|
return [
|
|
hex.slice(0, 8),
|
|
hex.slice(8, 12),
|
|
`4${hex.slice(13, 16)}`,
|
|
`8${hex.slice(17, 20)}`,
|
|
hex.slice(20, 32),
|
|
].join("-");
|
|
}
|
|
|
|
async function serviceJson<T>(
|
|
baseUrl: string,
|
|
path: string,
|
|
init?: RequestInit,
|
|
): Promise<T> {
|
|
const res = await fetch(`${baseUrl.replace(/\/$/, "")}${path}`, {
|
|
...init,
|
|
headers: {
|
|
"content-type": "application/json",
|
|
...(config.a2aAllowedKey
|
|
? { authorization: `Bearer ${config.a2aAllowedKey}` }
|
|
: {}),
|
|
...(init?.headers ?? {}),
|
|
},
|
|
});
|
|
const body = await res.text();
|
|
if (!res.ok) {
|
|
throw new Error(`${path} returned HTTP ${res.status}: ${body}`);
|
|
}
|
|
return (body ? JSON.parse(body) : {}) as T;
|
|
}
|
|
|
|
async function healthCheck(baseUrl: string, label: string): Promise<ServiceAgentResult> {
|
|
try {
|
|
const detail = await serviceJson<unknown>(baseUrl, "/health", { method: "GET" });
|
|
return {
|
|
status: "ok",
|
|
summary: `${label} is reachable and ready for workflow handoff.`,
|
|
detail,
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
status: "unavailable",
|
|
summary: `${label} is not reachable yet: ${err instanceof Error ? err.message : String(err)}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function runSaraInterview(ctx: ServiceAgentContext): Promise<ServiceAgentResult> {
|
|
const payload = {
|
|
user_id: ctx.userId,
|
|
org_id: ctx.orgId ?? "growqr",
|
|
persona_id: "emma",
|
|
interview_type: "behavioral",
|
|
duration_minutes: 15,
|
|
context: {
|
|
target_role: ctx.goal,
|
|
company_name: "Target company",
|
|
difficulty: "medium",
|
|
source: "growqr-workflow",
|
|
},
|
|
};
|
|
const detail = await serviceJson<Record<string, unknown>>(
|
|
config.interviewServiceUrl,
|
|
"/api/v1/configure",
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
},
|
|
);
|
|
return {
|
|
status: "ok",
|
|
summary: `Sara created interview session ${detail.session_id ?? "(pending id)"} for ${ctx.goal}.`,
|
|
detail: {
|
|
...detail,
|
|
target_role: payload.context.target_role,
|
|
interview_type: payload.interview_type,
|
|
ui_session_url: buildServiceSessionUrl("interview-service", detail, ctx.goal),
|
|
},
|
|
};
|
|
}
|
|
|
|
async function runEmilyRoleplay(ctx: ServiceAgentContext): Promise<ServiceAgentResult> {
|
|
const payload = {
|
|
user_id: ctx.userId,
|
|
org_id: ctx.orgId ?? "growqr",
|
|
persona_id: "emma",
|
|
duration_minutes: 15,
|
|
roleplay_type: "custom",
|
|
brief: `Practice a realistic job-application conversation for: ${ctx.goal}. Include objection handling, concise self-pitching, and a closing next step.`,
|
|
metadata: {
|
|
target_role: ctx.goal,
|
|
difficulty: "medium",
|
|
source: "growqr-workflow",
|
|
},
|
|
qscore: {
|
|
q_score: 78,
|
|
profession: "student",
|
|
formula_version: "workflow-demo",
|
|
quotients: {
|
|
CQm: { score: 72, active: true },
|
|
XQ: { score: 80, active: true },
|
|
VQ: { score: 76, active: true },
|
|
},
|
|
},
|
|
};
|
|
const detail = await serviceJson<Record<string, unknown>>(
|
|
config.roleplayServiceUrl,
|
|
"/api/v1/roleplays/configure",
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
},
|
|
);
|
|
return {
|
|
status: "ok",
|
|
summary: `Emily created roleplay session ${detail.session_id ?? "(pending id)"} for ${ctx.goal}.`,
|
|
detail: {
|
|
...detail,
|
|
target_role: payload.metadata.target_role,
|
|
roleplay_type: payload.roleplay_type,
|
|
ui_session_url: buildServiceSessionUrl("roleplay-service", detail, ctx.goal),
|
|
},
|
|
};
|
|
}
|
|
|
|
async function runQuinnQScore(ctx: ServiceAgentContext): Promise<ServiceAgentResult> {
|
|
const orgId = ctx.orgId ?? "growqr";
|
|
const qscoreUserId = stableUuid(ctx.userId);
|
|
const signals = [
|
|
{
|
|
signal_id: "resume.uploaded",
|
|
present: true,
|
|
score: 82,
|
|
raw: { source: "resume-agent", workflow_goal: ctx.goal },
|
|
},
|
|
{
|
|
signal_id: "resume.ats_compatibility",
|
|
present: true,
|
|
score: 76,
|
|
raw: { source: "resume-agent", workflow_goal: ctx.goal },
|
|
},
|
|
{
|
|
signal_id: "engagement.features_used",
|
|
present: true,
|
|
score: 88,
|
|
raw: { source: "grow-agent", workflow_goal: ctx.goal },
|
|
},
|
|
{
|
|
signal_id: "goals.goal_clarity",
|
|
present: true,
|
|
score: 90,
|
|
raw: { source: "grow-agent", workflow_goal: ctx.goal },
|
|
},
|
|
];
|
|
|
|
// Try to ingest signals (non-critical — may fail if QScore worker is down)
|
|
let ingest: Record<string, unknown> | undefined;
|
|
try {
|
|
ingest = await serviceJson<Record<string, unknown>>(
|
|
config.qscoreServiceUrl,
|
|
"/v1/signals/ingest",
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
org_id: orgId,
|
|
user_id: qscoreUserId,
|
|
profession: "student",
|
|
source: "growqr-workflow",
|
|
signals,
|
|
}),
|
|
},
|
|
);
|
|
} catch (err) {
|
|
// Signal ingestion is optional — compute may still work with cached signals
|
|
ingest = { status: "skipped", reason: err instanceof Error ? err.message : String(err) };
|
|
}
|
|
|
|
// Try to compute Q-Score
|
|
let compute: Record<string, unknown> | undefined;
|
|
try {
|
|
compute = await serviceJson<Record<string, unknown>>(
|
|
config.qscoreServiceUrl,
|
|
"/v1/qscore/compute",
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
org_id: orgId,
|
|
user_id: qscoreUserId,
|
|
}),
|
|
},
|
|
);
|
|
} catch (err) {
|
|
// Graceful fallback: formula store unavailable → use static estimate
|
|
const avgSignalScore = Math.round(
|
|
signals.reduce((sum, s) => sum + s.score, 0) / signals.length,
|
|
);
|
|
return {
|
|
status: "ok",
|
|
summary: `Quinn estimated Q-Score ~${avgSignalScore} (service compute unavailable: formula store may not be seeded). Based on ${signals.length} signals.`,
|
|
detail: {
|
|
ingest,
|
|
estimated_q_score: avgSignalScore,
|
|
signal_scores: signals.map(s => ({ id: s.signal_id, score: s.score })),
|
|
compute_fallback: true,
|
|
compute_error: err instanceof Error ? err.message : String(err),
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
status: "ok",
|
|
summary: `Quinn computed Q-Score ${compute.q_score ?? "(unknown)"} for ${ctx.goal}.`,
|
|
detail: { ingest, compute, qscore_user_id: qscoreUserId },
|
|
};
|
|
}
|
|
|
|
// ── Resume Agent (resume-builder service from growqr-app) ──
|
|
|
|
async function runResumeAnalyze(ctx: ServiceAgentContext): Promise<ServiceAgentResult> {
|
|
// Probe resume state for the user
|
|
try {
|
|
const detail = await serviceJson<Record<string, unknown>>(
|
|
config.resumeServiceUrl,
|
|
`/api/state/${encodeURIComponent(ctx.userId)}`,
|
|
{ method: "GET" },
|
|
);
|
|
const completeness = detail.resume_completeness ?? 0;
|
|
const hasResume = (detail.resume_count as number) > 0;
|
|
return {
|
|
status: "ok",
|
|
summary: hasResume
|
|
? `Resume Agent found ${detail.resume_count} resume(s) at ${completeness}% completeness. Current role: ${detail.current_role ?? "unknown"}.`
|
|
: "No existing resume found. Resume Agent is ready to build one from scratch.",
|
|
detail: {
|
|
resume_count: detail.resume_count,
|
|
completeness,
|
|
current_role: detail.current_role,
|
|
current_company: detail.current_company,
|
|
skills: detail.technical_skills ?? detail.skills ?? [],
|
|
},
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
status: "unavailable",
|
|
summary: `Resume Agent unavailable: ${err instanceof Error ? err.message : String(err)}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function runResumeTailor(ctx: ServiceAgentContext): Promise<ServiceAgentResult> {
|
|
// For now, return analysis-based tailoring
|
|
// The resume-builder's AI capabilities will handle actual tailoring
|
|
try {
|
|
const stateResult = await runResumeAnalyze(ctx);
|
|
if (stateResult.status !== "ok") return stateResult;
|
|
|
|
// Return summary with optimization guidance
|
|
return {
|
|
status: "ok",
|
|
summary: `Resume Agent analyzed your profile for the role "${ctx.goal}". Skills detected: ${(stateResult.detail as any)?.skills?.slice(0, 5).join(", ") ?? "none"}. Resume ready for optimization.`,
|
|
detail: {
|
|
...(stateResult.detail as Record<string, unknown> ?? {}),
|
|
goal: ctx.goal,
|
|
recommendation: "Use the AI analysis and copilot tools to tailor bullet points, add missing keywords, and optimize for ATS.",
|
|
},
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
status: "unavailable",
|
|
summary: `Resume tailoring failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function runMatchmaking(ctx: ServiceAgentContext): Promise<ServiceAgentResult> {
|
|
const matchmakingUserId = stableUuid(ctx.userId);
|
|
const query = new URLSearchParams({
|
|
user_id: matchmakingUserId,
|
|
top_n: "6",
|
|
threshold: "60",
|
|
recompute: "true",
|
|
});
|
|
|
|
try {
|
|
const detail = await serviceJson<Record<string, unknown>>(
|
|
config.matchmakingServiceUrl,
|
|
`/api/v1/feed?${query.toString()}`,
|
|
{ method: "GET" },
|
|
);
|
|
const items = Array.isArray(detail.items)
|
|
? detail.items
|
|
: Array.isArray(detail.feed_items)
|
|
? detail.feed_items
|
|
: [];
|
|
|
|
return {
|
|
status: "ok",
|
|
summary: items.length > 0
|
|
? `Scout pulled ${items.length} ranked opportunities from matchmaking for ${ctx.goal}.`
|
|
: `Scout asked matchmaking to refresh opportunities for ${ctx.goal}. Add preferences if the feed is empty.`,
|
|
detail: {
|
|
...detail,
|
|
matchmaking_user_id: matchmakingUserId,
|
|
goal: ctx.goal,
|
|
},
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
status: "unavailable",
|
|
summary: `Matchmaking service unavailable: ${err instanceof Error ? err.message : String(err)}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function runServiceAgentProbe(
|
|
agent: ServiceAgentRef,
|
|
ctx?: ServiceAgentContext,
|
|
): Promise<ServiceAgentResult> {
|
|
try {
|
|
switch (agent.service) {
|
|
case "interview-service":
|
|
return ctx
|
|
? await runSaraInterview(ctx)
|
|
: healthCheck(config.interviewServiceUrl, "Sara / interview-service");
|
|
case "roleplay-service":
|
|
return ctx
|
|
? await runEmilyRoleplay(ctx)
|
|
: healthCheck(config.roleplayServiceUrl, "Emily / roleplay-service");
|
|
case "qscore-service":
|
|
return ctx
|
|
? await runQuinnQScore(ctx)
|
|
: healthCheck(config.qscoreServiceUrl, "Quinn / qscore-service");
|
|
case "resume-service":
|
|
return ctx
|
|
? await runResumeTailor(ctx)
|
|
: healthCheck(config.resumeServiceUrl, "Resume Agent / resume-service");
|
|
case "matchmaking-service":
|
|
return ctx
|
|
? await runMatchmaking(ctx)
|
|
: healthCheck(config.matchmakingServiceUrl, "Scout / matchmaking-service");
|
|
default:
|
|
return {
|
|
status: "local",
|
|
summary: `${agent.name} is a local workflow agent managed by Rivet.`,
|
|
};
|
|
}
|
|
} catch (err) {
|
|
return {
|
|
status: "unavailable",
|
|
summary: `${agent.name} could not complete its service handoff: ${err instanceof Error ? err.message : String(err)}`,
|
|
};
|
|
}
|
|
}
|