Files
growqr-backend/src/services/service-agents.ts

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)}`,
};
}
}