Files
growqr-backend/src/lib/prompt-loader.ts
2026-06-04 14:25:20 +05:30

171 lines
5.6 KiB
TypeScript

import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { log } from "../log.js";
// ── Types ──
export type SubAgentModule = {
id: string;
name: string;
role: string;
description: string;
service?: "interview-service" | "roleplay-service" | "qscore-service" | "resume-service" | "matchmaking-service";
toolNames: string[];
};
type AgentFrontmatter = {
id?: string;
name?: string;
role?: string;
service?: string;
tools?: string[];
};
// ── Paths ──
const PROMPTS_DIR = path.resolve(process.cwd(), "prompts");
const AGENTS_DIR = path.resolve(process.cwd(), "agents");
const SYSTEM_PROMPT_FILE = path.join(PROMPTS_DIR, "system.txt");
// ── Frontmatter parser (no external dependencies) ──
function parseFrontmatter(raw: string): { data: AgentFrontmatter; body: string } {
const trimmed = raw.trim();
if (!trimmed.startsWith("---")) return { data: {}, body: trimmed };
const secondDelim = trimmed.indexOf("---", 3);
if (secondDelim === -1) return { data: {}, body: trimmed };
const fmBlock = trimmed.slice(3, secondDelim).trim();
const body = trimmed.slice(secondDelim + 3).trim();
const data: AgentFrontmatter = {};
for (const line of fmBlock.split("\n")) {
const colonIdx = line.indexOf(":");
if (colonIdx === -1) continue;
const key = line.slice(0, colonIdx).trim();
let value: string | string[] = line.slice(colonIdx + 1).trim();
if (key === "tools" && value.startsWith("[") && value.endsWith("]")) {
// Parse inline array: ["tool1", "tool2"]
const inner = value.slice(1, -1);
value = inner
.split(",")
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
.filter(Boolean);
}
if (key === "id") data.id = value as string;
if (key === "name") data.name = value as string;
if (key === "role") data.role = value as string;
if (key === "service") data.service = value as string;
if (key === "tools") data.tools = value as string[];
}
return { data, body };
}
// ── Loader ──
let cachedModules: SubAgentModule[] | null = null;
let cachedSystemPrompt: string | null = null;
export function getSubAgentModules(): SubAgentModule[] {
if (!cachedModules) {
throw new Error("Prompts not loaded — call loadPromptsFromDisk() at startup");
}
return cachedModules;
}
export function getUnifiedSystemPrompt(): string {
if (!cachedSystemPrompt) {
throw new Error("Prompts not loaded — call loadPromptsFromDisk() at startup");
}
return cachedSystemPrompt;
}
export function getSubAgentModule(id: string): SubAgentModule | undefined {
return getSubAgentModules().find((m) => m.id === id);
}
export function jobApplicationModuleIds(): string[] {
return ["resume", "interview", "roleplay", "qscore"];
}
// Load all prompt and agent files from disk.
// Called once at startup. Rebuild the Docker image to pick up changes (§3).
export async function loadPromptsFromDisk(): Promise<void> {
// ── Load agent modules ──
let agentFiles: string[];
try {
agentFiles = (await readdir(AGENTS_DIR)).filter((f) => f.endsWith(".md"));
} catch (err) {
log.warn({ err, dir: AGENTS_DIR }, "agents directory not found — using empty catalog");
agentFiles = [];
}
const modules: SubAgentModule[] = [];
for (const filename of agentFiles) {
const filePath = path.join(AGENTS_DIR, filename);
try {
const raw = await readFile(filePath, "utf8");
const { data, body } = parseFrontmatter(raw);
if (!data.id || !data.name) {
log.warn({ file: filename }, "agent file missing required frontmatter fields (id, name)");
continue;
}
const service = data.service as SubAgentModule["service"] | undefined;
if (
service &&
service !== "interview-service" &&
service !== "roleplay-service" &&
service !== "qscore-service" &&
service !== "resume-service" &&
service !== "matchmaking-service"
) {
log.warn({ file: filename, service }, "unknown service value — treating as no service");
}
modules.push({
id: data.id,
name: data.name,
role: data.role ?? data.name,
description: body || `Agent module: ${data.name}`,
service: service &&
["interview-service", "roleplay-service", "qscore-service", "resume-service", "matchmaking-service"].includes(service)
? (service as SubAgentModule["service"])
: undefined,
toolNames: data.tools ?? [],
});
} catch (err) {
log.error({ err, file: filename }, "failed to load agent module");
}
}
cachedModules = modules;
log.info({ count: modules.length, dir: AGENTS_DIR }, "loaded sub-agent modules from disk");
// ── Load system prompt ──
try {
const template = await readFile(SYSTEM_PROMPT_FILE, "utf8");
const moduleDescriptions = modules
.map(
(m) =>
`- **${m.name}** (${m.id}): ${m.description} ${
m.service ? `[backed by ${m.service}]` : "[local workflow]"
}`,
)
.join("\n");
cachedSystemPrompt = template.replace("{{MODULE_DESCRIPTIONS}}", moduleDescriptions);
log.info({ path: SYSTEM_PROMPT_FILE }, "loaded system prompt from disk");
} catch (err) {
log.error({ err, path: SYSTEM_PROMPT_FILE }, "failed to load system prompt — using fallback");
// Fallback: assemble from modules without a template file.
const fallback = `You are the Grow Agent — a unified AI orchestrator for the GrowQR platform.\n\n## Sub-Agent Capabilities\n\n${modules.map((m) => `- **${m.name}**: ${m.description}`).join("\n")}`;
cachedSystemPrompt = fallback;
}
}