Files
genesis-2/supabase/functions/_shared/openai-service.ts
T
Guillermo.Arrieta 288278ea6c feat: update AI subject generation and handling responses
- Refactor AIGenerateSubjectInput type to include optional fields for updates.
- Add handling for "asignaturas" responses in OpenAI webhook.
- Implement crear.ts for creating/updating subjects based on AI responses.
- Update tests to validate OpenAI file uploads instead of storage uploads.
2026-02-27 12:07:06 -06:00

194 lines
7.1 KiB
TypeScript

// supabase/functions/_shared/openai-service.ts
/// <reference lib="deno.window" />
import OpenAI from "npm:openai@6.16.0";
import type * as OpenAITypes from "npm:openai@6.16.0";
// Use non-streaming params to ensure `responses.create` returns a typed Response
export type StructuredResponseOptions =
OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming;
export type StructuredResponseSuccess<TOutput = unknown> = {
ok: true;
output?: TOutput; // parsed JSON when available
outputText?: string; // raw text when parsing is not possible
model: string;
usage?: OpenAITypes.OpenAI.Responses.Response["usage"] | null;
responseId: string;
conversationId?: string | null;
references: {
openaiFileIds: string[]; // file ids in OpenAI
};
openaiRaw: OpenAITypes.OpenAI.Responses.Response; // keep for advanced consumers
};
export type StructuredResponseFailure = {
ok: false;
code:
| "MissingEnv"
| "OpenAIFileUploadFailed"
| "OpenAIRequestFailed";
message: string;
cause?: unknown;
};
export type StructuredResponseResult<TOutput = unknown> =
| StructuredResponseSuccess<TOutput>
| StructuredResponseFailure;
export interface OpenAIServiceConfig {
openAIApiKey: string;
}
export class OpenAIService {
private readonly openai: OpenAI;
private constructor(openai: OpenAI) {
this.openai = openai;
}
static fromEnv(): StructuredResponseFailure | OpenAIService {
const openAIApiKey = Deno.env.get("OPENAI_API_KEY") ?? "";
if (!openAIApiKey) {
return {
ok: false,
code: "MissingEnv",
message: "Required env vars missing: OPENAI_API_KEY",
};
}
const openai = new OpenAI({ apiKey: openAIApiKey });
return new OpenAIService(openai);
}
async createConversation(metadata?: Record<string, string>) {
const conversation = await this.openai.conversations.create({
metadata,
});
return conversation;
}
async createStructuredResponse<TOutput = unknown>(
options: StructuredResponseOptions,
files?: File[],
): Promise<StructuredResponseResult<TOutput>> {
try {
const openaiFileIds = await this.uploadFilesToOpenAI(files ?? []);
const newOptions = { ...options };
// Attach file references to the request as an extra user message
if (openaiFileIds.length > 0) {
const fileParts:
OpenAITypes.OpenAI.Responses.ResponseInputFile[] =
openaiFileIds.map((id) => ({
type: "input_file",
file_id: id,
}));
const arr = Array.isArray(options.input) ? options.input : [];
arr.push({
role: "user",
content: [
...fileParts,
{
type: "input_text",
text: "Usa estos archivos como referencia",
},
],
});
newOptions.input = arr;
}
// Narrow to non-streaming response
const openaiRaw = (await this.openai.responses.create(
newOptions as OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming,
)) as OpenAITypes.OpenAI.Responses.Response;
const isBackground =
(newOptions as unknown as { background?: boolean })
.background ===
true;
const { model, id: responseId } = openaiRaw;
const usage = openaiRaw?.usage ?? null;
const conversationId = (
openaiRaw as OpenAITypes.OpenAI.Responses.Response & {
conversation_id?: string | null;
}
).conversation_id ?? null;
if (isBackground) {
return {
ok: true,
output: undefined,
outputText: undefined,
model: String(model),
usage,
responseId: String(responseId),
conversationId: conversationId
? String(conversationId)
: null,
references: { openaiFileIds },
openaiRaw,
};
}
// Try to read structured JSON output
let output: TOutput | undefined = undefined;
let outputText: string | undefined = undefined;
// Prefer `output_text` if present (SDK convenience)
const maybeOutputText = openaiRaw.output_text;
if (
typeof maybeOutputText === "string" &&
maybeOutputText.length > 0
) {
outputText = maybeOutputText;
try {
output = JSON.parse(maybeOutputText) as TOutput;
} catch {
/* non-JSON text, keep as text only */
}
} else {
// Fallback: attempt to serialize `openaiRaw.output` into text
const maybeOutput = openaiRaw.output as unknown;
if (typeof maybeOutput === "object" && maybeOutput != null) {
try {
outputText = JSON.stringify(maybeOutput);
output = maybeOutput as TOutput;
} catch {
/* ignore */
}
}
}
return {
ok: true,
output,
outputText,
model: String(model),
usage,
responseId: String(responseId),
conversationId: conversationId ? String(conversationId) : null,
references: { openaiFileIds },
openaiRaw,
};
} catch (err) {
const e = err as Error;
const message = e?.message ?? "Unknown error";
const code = message.includes("OpenAI file upload failed")
? "OpenAIFileUploadFailed"
: "OpenAIRequestFailed";
return { ok: false, code, message, cause: err };
}
}
private async uploadFilesToOpenAI(files: File[]): Promise<string[]> {
const ids: string[] = [];
for (const file of files) {
try {
const created = await this.openai.files.create({
file,
purpose: "user_data",
});
ids.push(created.id);
} catch (e) {
throw new Error(
`OpenAI file upload failed: ${
(e as Error)?.message ?? String(e)
}`,
);
}
}
return ids;
}
private sanitizeFilename(name: string): string {
return name
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zA-Z0-9.-]/g, "_");
}
}