refactor(api): split resource mutation concerns
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import { createAiClient, isAiConfigured, loggedAiCall } from "../ai-client.js";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||
import { logger } from "../lib/logger.js";
|
||||
import { managerProcedure } from "../trpc.js";
|
||||
|
||||
export const DEFAULT_SUMMARY_PROMPT = `You are writing a short professional profile for an internal resource planning tool.
|
||||
|
||||
Artist profile:
|
||||
- Role: {role}
|
||||
- Chapter: {chapter}
|
||||
- Main skills: {mainSkills}
|
||||
- Top skills: {topSkills}
|
||||
|
||||
Write a 2–3 sentence professional bio. Be specific, use skill names. No fluff.`;
|
||||
|
||||
type SkillRow = {
|
||||
skill: string;
|
||||
category?: string;
|
||||
proficiency: number;
|
||||
isMainSkill?: boolean;
|
||||
};
|
||||
|
||||
export const resourceAiSummaryProcedures = {
|
||||
generateAiSummary: managerProcedure
|
||||
.input(z.object({ resourceId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [resource, settings] = await Promise.all([
|
||||
findUniqueOrThrow(
|
||||
ctx.db.resource.findUnique({
|
||||
where: { id: input.resourceId },
|
||||
include: { areaRole: { select: { name: true } } },
|
||||
}),
|
||||
"Resource",
|
||||
),
|
||||
ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }),
|
||||
]);
|
||||
|
||||
if (!isAiConfigured(settings)) {
|
||||
throw new TRPCError({
|
||||
code: "PRECONDITION_FAILED",
|
||||
message: "AI is not configured. Please set credentials in Admin → Settings.",
|
||||
});
|
||||
}
|
||||
|
||||
const skills = (resource.skills as unknown as SkillRow[]) ?? [];
|
||||
const mainSkills = skills.filter((skill) => skill.isMainSkill).map((skill) => skill.skill);
|
||||
const top10 = [...skills]
|
||||
.sort((left, right) => right.proficiency - left.proficiency)
|
||||
.slice(0, 10)
|
||||
.map((skill) => `${skill.skill} (${skill.proficiency}/5)`);
|
||||
|
||||
const vars = {
|
||||
role: resource.areaRole?.name ?? "Not specified",
|
||||
chapter: resource.chapter ?? "Not specified",
|
||||
mainSkills: mainSkills.length > 0 ? mainSkills.join(", ") : "Not specified",
|
||||
topSkills: top10.join(", "),
|
||||
};
|
||||
|
||||
const templateStr = settings!.aiSummaryPrompt ?? DEFAULT_SUMMARY_PROMPT;
|
||||
const prompt = templateStr
|
||||
.replace("{role}", vars.role)
|
||||
.replace("{chapter}", vars.chapter)
|
||||
.replace("{mainSkills}", vars.mainSkills)
|
||||
.replace("{topSkills}", vars.topSkills);
|
||||
|
||||
const client = createAiClient(settings!);
|
||||
const model = settings!.azureOpenAiDeployment!;
|
||||
const maxTokens = settings!.aiMaxCompletionTokens ?? 300;
|
||||
const temperature = settings!.aiTemperature ?? 1;
|
||||
const provider = settings!.aiProvider ?? "openai";
|
||||
|
||||
async function callChatCompletions(withTemperature: boolean) {
|
||||
return loggedAiCall(provider, model, prompt.length, () =>
|
||||
client.chat.completions.create({
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
max_completion_tokens: maxTokens,
|
||||
model,
|
||||
...(withTemperature && temperature !== 1 ? { temperature } : {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let summary = "";
|
||||
try {
|
||||
let completion;
|
||||
try {
|
||||
completion = await callChatCompletions(true);
|
||||
logger.debug(
|
||||
{
|
||||
provider,
|
||||
model,
|
||||
choiceCount: completion.choices?.length ?? 0,
|
||||
},
|
||||
"AI summary chat completion succeeded",
|
||||
);
|
||||
} catch (tempErr) {
|
||||
const status = (tempErr as { status?: number }).status;
|
||||
const msg = (tempErr as Error).message ?? "";
|
||||
if (status === 400 && msg.includes("temperature")) {
|
||||
logger.info(
|
||||
{ provider, model, status },
|
||||
"Retrying AI summary generation without temperature override",
|
||||
);
|
||||
completion = await callChatCompletions(false);
|
||||
} else if (status === 404) {
|
||||
logger.info(
|
||||
{ provider, model, status },
|
||||
"Falling back to AI responses API for summary generation",
|
||||
);
|
||||
const resp = await client.responses.create({ model, input: prompt, max_output_tokens: maxTokens });
|
||||
logger.debug(
|
||||
{
|
||||
provider,
|
||||
model,
|
||||
summaryLength: resp.output_text?.trim().length ?? 0,
|
||||
},
|
||||
"AI summary responses API call succeeded",
|
||||
);
|
||||
summary = resp.output_text?.trim() ?? "";
|
||||
completion = null;
|
||||
} else {
|
||||
throw tempErr;
|
||||
}
|
||||
}
|
||||
if (completion) {
|
||||
summary = completion.choices[0]?.message?.content?.trim() ?? "";
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await ctx.db.resource.update({
|
||||
where: { id: input.resourceId },
|
||||
data: { aiSummary: summary, aiSummaryUpdatedAt: new Date() },
|
||||
});
|
||||
|
||||
return { summary };
|
||||
}),
|
||||
};
|
||||
Reference in New Issue
Block a user