Versión funcional de creación de plan de estudios con IA
This commit is contained in:
@@ -10,7 +10,8 @@ entrypoint = "./functions/ai-generate-plan/index.ts"
|
||||
# For example, if you want to serve static HTML pages in your function:
|
||||
# static_files = [ "./functions/ai-generate-plan/*.html" ]
|
||||
|
||||
[functions.ai-structured]
|
||||
enabled = true
|
||||
verify_jwt = true
|
||||
import_map = "./functions/ai-structured/deno.json"
|
||||
# [functions.ai-structured]
|
||||
# DEPRECATED: replaced by shared module `_shared/openai-service.ts`
|
||||
# enabled = false
|
||||
# verify_jwt = false
|
||||
# import_map = "./functions/ai-structured/deno.json"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,226 @@
|
||||
// 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";
|
||||
import { createClient, type SupabaseClient } from "npm:@supabase/supabase-js@2";
|
||||
|
||||
// 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: {
|
||||
uploadedToStorage: string[]; // supabase storage paths
|
||||
openaiFileIds: string[]; // file ids in OpenAI
|
||||
};
|
||||
openaiRaw: OpenAITypes.OpenAI.Responses.Response; // keep for advanced consumers
|
||||
};
|
||||
|
||||
export type StructuredResponseFailure = {
|
||||
ok: false;
|
||||
code:
|
||||
| "MissingEnv"
|
||||
| "StorageUploadFailed"
|
||||
| "OpenAIFileUploadFailed"
|
||||
| "OpenAIRequestFailed";
|
||||
message: string;
|
||||
cause?: unknown;
|
||||
};
|
||||
|
||||
export type StructuredResponseResult<TOutput = unknown> =
|
||||
| StructuredResponseSuccess<TOutput>
|
||||
| StructuredResponseFailure;
|
||||
|
||||
export interface OpenAIServiceConfig {
|
||||
supabaseUrl: string;
|
||||
serviceRoleKey: string;
|
||||
openAIApiKey: string;
|
||||
bucket?: string; // default: ai-storage
|
||||
}
|
||||
|
||||
export class OpenAIService {
|
||||
private readonly supabase: SupabaseClient;
|
||||
private readonly openai: OpenAI;
|
||||
private readonly bucket: string;
|
||||
|
||||
private constructor(
|
||||
client: SupabaseClient,
|
||||
openai: OpenAI,
|
||||
bucket: string,
|
||||
) {
|
||||
this.supabase = client;
|
||||
this.openai = openai;
|
||||
this.bucket = bucket;
|
||||
}
|
||||
|
||||
static fromEnv(): StructuredResponseFailure | OpenAIService {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? "";
|
||||
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
|
||||
const openAIApiKey = Deno.env.get("OPENAI_API_KEY") ?? "";
|
||||
const bucket = Deno.env.get("SUPABASE_BUCKET") ?? "ai-storage";
|
||||
|
||||
if (!supabaseUrl || !serviceRoleKey || !openAIApiKey) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "MissingEnv",
|
||||
message:
|
||||
"Required env vars missing: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, OPENAI_API_KEY",
|
||||
};
|
||||
}
|
||||
|
||||
const client = createClient(supabaseUrl, serviceRoleKey);
|
||||
const openai = new OpenAI({ apiKey: openAIApiKey });
|
||||
return new OpenAIService(client, openai, bucket);
|
||||
}
|
||||
|
||||
async createStructuredResponse<TOutput = unknown>(
|
||||
options: StructuredResponseOptions,
|
||||
files?: File[],
|
||||
): Promise<StructuredResponseResult<TOutput>> {
|
||||
try {
|
||||
const uploadedToStorage = await this.uploadFilesToStorage(
|
||||
files ?? [],
|
||||
);
|
||||
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 { model, id: responseId } = openaiRaw;
|
||||
const usage = openaiRaw?.usage ?? null;
|
||||
const conversationId = (
|
||||
openaiRaw as OpenAITypes.OpenAI.Responses.Response & {
|
||||
conversation_id?: string | null;
|
||||
}
|
||||
).conversation_id ?? null;
|
||||
|
||||
// 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: { uploadedToStorage, openaiFileIds },
|
||||
openaiRaw,
|
||||
};
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
const message = e?.message ?? "Unknown error";
|
||||
const code = message.includes("Supabase upload failed")
|
||||
? "StorageUploadFailed"
|
||||
: message.includes("OpenAI file upload failed")
|
||||
? "OpenAIFileUploadFailed"
|
||||
: "OpenAIRequestFailed";
|
||||
|
||||
return { ok: false, code, message, cause: err };
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadFilesToStorage(files: File[]): Promise<string[]> {
|
||||
const paths: string[] = [];
|
||||
for (const file of files) {
|
||||
const safeName = this.sanitizeFilename(file.name);
|
||||
const path = `${crypto.randomUUID()}-${safeName}`;
|
||||
const { data, error } = await this.supabase.storage
|
||||
.from(this.bucket)
|
||||
.upload(path, file, {
|
||||
contentType: file.type || "application/octet-stream",
|
||||
upsert: false,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Supabase upload failed: ${error.message}`);
|
||||
}
|
||||
paths.push(data.path);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
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, "_");
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,17 @@
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { corsHeaders } from "../_shared/cors.ts";
|
||||
import { createClient } from "npm:@supabase/supabase-js@2";
|
||||
import type { Database, Json } from "../_shared/database.types.ts";
|
||||
import type { AIGeneratePlanInput } from "./types.ts";
|
||||
import { z } from "zod";
|
||||
import { systemPrompt } from "./prompts.ts";
|
||||
import { strict } from "node:assert";
|
||||
import { OpenAIService } from "../_shared/openai-service.ts";
|
||||
import type { StructuredResponseOptions } from "../_shared/openai-service.ts";
|
||||
// Typed aliases for strict field unions
|
||||
type NivelType =
|
||||
Database["public"]["Tables"]["planes_estudio"]["Insert"]["nivel"];
|
||||
type TipoCicloType =
|
||||
Database["public"]["Tables"]["planes_estudio"]["Insert"]["tipo_ciclo"];
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
@@ -74,30 +81,22 @@ Deno.serve(async (req) => {
|
||||
throw new Error("Supabase environment variables are not set");
|
||||
}
|
||||
|
||||
const supabaseAnon = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||
global: {
|
||||
headers: {
|
||||
Authorization: authHeaderRaw,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: quitar hardcode de usuario
|
||||
const { data: user, error: userError } = await supabaseAnon.auth
|
||||
.signInWithPassword({
|
||||
email: "guillermo.arrieta@lasalle.mx",
|
||||
password: "admin",
|
||||
});
|
||||
if (userError) {
|
||||
throw new Error("Error authenticating user: " + userError.message);
|
||||
}
|
||||
// If needed for RLS-protected reads, create an anon client with user's JWT
|
||||
// Currently not used; kept here for future expansion.
|
||||
// const supabaseAnon = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||
// global: {
|
||||
// headers: {
|
||||
// Authorization: authHeaderRaw,
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
|
||||
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
|
||||
if (!SERVICE_ROLE_KEY) {
|
||||
throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set");
|
||||
}
|
||||
|
||||
const supabaseService = createClient(
|
||||
const supabaseService = createClient<Database>(
|
||||
SUPABASE_URL,
|
||||
SERVICE_ROLE_KEY,
|
||||
);
|
||||
@@ -136,7 +135,14 @@ Deno.serve(async (req) => {
|
||||
`Genera un borrador completo del PLAN DE ESTUDIOS con base en lo siguiente:
|
||||
- Descripción del enfoque: ${payload.iaConfig.descripcionEnfoque}
|
||||
- Notas adicionales: ${payload.iaConfig.notasAdicionales ?? "Ninguna"}`;
|
||||
const aiStructuredPayload = {
|
||||
// Ensure the JSON schema is an object as required by OpenAI types
|
||||
const schemaDef: Record<string, unknown> =
|
||||
typeof estructuraPlan?.definicion === "object" &&
|
||||
estructuraPlan?.definicion !== null
|
||||
? estructuraPlan.definicion as Record<string, unknown>
|
||||
: {};
|
||||
|
||||
const aiStructuredPayload: StructuredResponseOptions = {
|
||||
model: "gpt-5-mini",
|
||||
input: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
@@ -146,39 +152,34 @@ Deno.serve(async (req) => {
|
||||
format: {
|
||||
type: "json_schema",
|
||||
name: "plan_de_estudios_standard",
|
||||
schema: estructuraPlan?.definicion,
|
||||
schema: schemaDef,
|
||||
strict: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const aiStructuredFormData = new FormData();
|
||||
aiStructuredFormData.append("options", JSON.stringify(aiStructuredPayload));
|
||||
for (const file of payload.archivosAdjuntos ?? []) {
|
||||
aiStructuredFormData.append("files", file);
|
||||
// Use shared OpenAI service directly (no HTTP invoke)
|
||||
const svc = OpenAIService.fromEnv();
|
||||
if (!(svc instanceof OpenAIService)) {
|
||||
throw new Error(`OpenAI service misconfiguration: ${svc.message}`);
|
||||
}
|
||||
|
||||
const { data: aiJson, error } = await supabaseService.functions.invoke(
|
||||
"ai-structured",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${user.session?.access_token}`,
|
||||
},
|
||||
method: "POST",
|
||||
body: aiStructuredFormData,
|
||||
},
|
||||
const aiResult = await svc.createStructuredResponse(
|
||||
aiStructuredPayload,
|
||||
payload.archivosAdjuntos,
|
||||
);
|
||||
|
||||
if (error) {
|
||||
if (!aiResult.ok) {
|
||||
throw new Error(
|
||||
"ai-structured invocation failed: " + error.message,
|
||||
`OpenAI call failed [${aiResult.code}]: ${aiResult.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!aiJson || !aiJson.ok) {
|
||||
throw new Error(
|
||||
"ai-structured returned an error or no data",
|
||||
);
|
||||
// Prefer parsed output; fallback to parse outputText
|
||||
const aiOutput = aiResult.output ??
|
||||
(aiResult.outputText ? JSON.parse(aiResult.outputText) : null);
|
||||
const aiOutputJson: Json = aiOutput as unknown as Json;
|
||||
if (!aiOutput) {
|
||||
throw new Error("OpenAI response did not contain structured output");
|
||||
}
|
||||
|
||||
// TODO: Insertar interacciones con IA y quizas forzar a que la informacion de datosBasicos sea la misma que la recibida
|
||||
@@ -200,43 +201,41 @@ Deno.serve(async (req) => {
|
||||
throw new Error("Error fetching carrera: " + carreraError.message);
|
||||
}
|
||||
|
||||
const { data: plan, error: planError } = await supabaseService
|
||||
.from("planes_estudio")
|
||||
.insert({
|
||||
carrera_id: carrera?.id,
|
||||
estructura_id: estructuraPlan?.id,
|
||||
const planInsert: Database["public"]["Tables"]["planes_estudio"]["Insert"] =
|
||||
{
|
||||
carrera_id: carrera?.id as string,
|
||||
estructura_id: estructuraPlan?.id as string,
|
||||
nombre: payload.datosBasicos.nombrePlan,
|
||||
nivel: payload.datosBasicos.nivel,
|
||||
tipo_ciclo: payload.datosBasicos.tipoCiclo,
|
||||
nivel: payload.datosBasicos.nivel as NivelType,
|
||||
tipo_ciclo: payload.datosBasicos.tipoCiclo as TipoCicloType,
|
||||
numero_ciclos: payload.datosBasicos.numCiclos,
|
||||
datos: aiJson.output,
|
||||
estado_actual_id: estado?.id,
|
||||
datos: aiOutputJson,
|
||||
estado_actual_id: estado?.id ?? undefined,
|
||||
activo: true,
|
||||
tipo_origen: "IA",
|
||||
meta_origen: {
|
||||
generado_por: "ai_generate_plan",
|
||||
ai_structured: {
|
||||
cid: aiJson?.cid ?? null,
|
||||
responseId: aiJson?.responseId ?? null,
|
||||
conversationId: aiJson?.conversationId ?? null,
|
||||
model: aiJson?.model,
|
||||
usage: aiJson?.usage ?? null,
|
||||
responseId: aiResult.responseId ?? null,
|
||||
conversationId: aiResult.conversationId ?? null,
|
||||
model: aiResult.model,
|
||||
usage: aiResult.usage ?? null,
|
||||
},
|
||||
referencias: {
|
||||
archivosReferenciaIds: payload.iaConfig?.archivosReferencia ?? null,
|
||||
repositoriosIds: payload.iaConfig?.repositoriosIds ?? null,
|
||||
// openaiFileIds,
|
||||
// vectorStoreIds,
|
||||
},
|
||||
iaConfig: {
|
||||
descripcionEnfoque: payload.iaConfig?.descripcionEnfoque ?? null,
|
||||
notasAdicionales: payload.iaConfig?.notasAdicionales ?? null,
|
||||
usarMCP: Boolean(payload.iaConfig?.usarMCP),
|
||||
},
|
||||
},
|
||||
// creado_por: user.id,
|
||||
// actualizado_por: user.id,
|
||||
})
|
||||
} as unknown as Json,
|
||||
};
|
||||
|
||||
const { data: plan, error: planError } = await supabaseService
|
||||
.from("planes_estudio")
|
||||
.insert(planInsert)
|
||||
.select(
|
||||
"id,nombre,nivel,tipo_ciclo,numero_ciclos,carrera_id,estructura_id,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,datos",
|
||||
)
|
||||
@@ -246,7 +245,7 @@ Deno.serve(async (req) => {
|
||||
throw new Error("Error inserting plan: " + planError.message);
|
||||
}
|
||||
|
||||
// TODO: update a interaccion_ia y e insert a cambios_plancon id de plan generado
|
||||
// TODO: update a interaccion_ia y e insert a cambios_plan con id de plan generado
|
||||
const jsonResponse = {
|
||||
ok: true,
|
||||
plan,
|
||||
@@ -283,7 +282,7 @@ const jsonFromString = <T extends z.ZodTypeAny>(schema: T) =>
|
||||
z.string().transform((str, ctx) => {
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "El formato no es un JSON válido",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"imports": {
|
||||
"openai": "npm:openai"
|
||||
"openai": "npm:openai@6.16.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
// supabase/functions/ai-structured/index.ts
|
||||
/// <reference lib="deno.window" />
|
||||
import OpenAI from "openai";
|
||||
import type * as OpenAITypes from "openai";
|
||||
|
||||
// Function deprecated. This endpoint was replaced by shared module `_shared/openai-service.ts`.
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { createClient } from "npm:@supabase/supabase-js@2";
|
||||
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
@@ -16,151 +11,14 @@ const json = (body: unknown, status = 200) =>
|
||||
},
|
||||
});
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
Deno.serve((req) => {
|
||||
if (req.method === "OPTIONS") return json({ ok: true });
|
||||
if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
|
||||
|
||||
console.log("Received request");
|
||||
|
||||
try {
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? "";
|
||||
const SUPABASE_SERVICE_ROLE_KEY =
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
|
||||
const SUPABASE_BUCKET = Deno.env.get("SUPABASE_BUCKET") ?? "ai-storage";
|
||||
const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY") ?? "";
|
||||
|
||||
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY || !OPENAI_API_KEY) {
|
||||
return json({ error: "Missing env vars" }, 500);
|
||||
}
|
||||
|
||||
console.log("Env vars loaded");
|
||||
|
||||
type InputForm = {
|
||||
options: OpenAITypes.OpenAI.Responses.ResponseCreateParams;
|
||||
files: File[];
|
||||
};
|
||||
const fd = await req.formData();
|
||||
|
||||
// Asume siempre vienen: options, files[]
|
||||
const optionsRaw = fd.get("options");
|
||||
|
||||
const inputForm: InputForm = {
|
||||
options: typeof optionsRaw === "string"
|
||||
? JSON.parse(optionsRaw)
|
||||
: optionsRaw,
|
||||
|
||||
files: fd.getAll("files").filter((x): x is File => x instanceof File),
|
||||
};
|
||||
console.log(`Parsed form data: ${inputForm.files.length} files`);
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
|
||||
const openai = new OpenAI({ apiKey: OPENAI_API_KEY });
|
||||
|
||||
console.log("Parsed options");
|
||||
|
||||
// 1) Upload a Supabase Storage
|
||||
const supabase_paths: string[] = [];
|
||||
for (const file of inputForm.files ?? []) {
|
||||
const safeName = file.name
|
||||
.normalize("NFD") // 1. Descompone letras de tildes (í -> i + ´)
|
||||
.replace(/[\u0300-\u036f]/g, "") // 2. Borra las tildes
|
||||
.replace(/[^a-zA-Z0-9.-]/g, "_"); // 3. Reemplaza espacios y símbolos raros por "_"
|
||||
const path = `${crypto.randomUUID()}-${safeName}`;
|
||||
const { data, error } = await supabase.storage
|
||||
.from(SUPABASE_BUCKET)
|
||||
.upload(path, file, {
|
||||
contentType: file.type || "application/octet-stream",
|
||||
upsert: false,
|
||||
/* metadata */
|
||||
});
|
||||
|
||||
if (error) throw new Error(`Supabase upload failed: ${error.message}`);
|
||||
supabase_paths.push(data.path);
|
||||
}
|
||||
|
||||
console.log("Uploaded files to Supabase Storage");
|
||||
|
||||
// 2) Upload a OpenAI Files
|
||||
const openai_file_ids: string[] = [];
|
||||
for (const file of inputForm.files ?? []) {
|
||||
const created = await openai.files.create({
|
||||
file,
|
||||
purpose: "user_data",
|
||||
});
|
||||
openai_file_ids.push(created.id);
|
||||
}
|
||||
|
||||
console.log("Uploaded files to OpenAI Files");
|
||||
|
||||
// 3) Inject file_ids into options.input by appending a new user message
|
||||
const fileParts: OpenAITypes.OpenAI.Responses.ResponseInputFile[] =
|
||||
openai_file_ids.map((id) => ({
|
||||
type: "input_file",
|
||||
file_id: id,
|
||||
}));
|
||||
|
||||
// Asume que SIEMPRE hay input; de todas formas, cae bien si no.
|
||||
const inputArr = Array.isArray(inputForm.options?.input)
|
||||
? inputForm.options.input
|
||||
: [];
|
||||
|
||||
inputArr.push({
|
||||
role: "user",
|
||||
content: [
|
||||
...fileParts,
|
||||
{ type: "input_text", text: "usa estos archivos como referencia" },
|
||||
],
|
||||
});
|
||||
|
||||
inputForm.options.input = inputArr;
|
||||
|
||||
console.log("Prepared OpenAI options with file references");
|
||||
|
||||
// 4) Call Responses API
|
||||
const openai_raw = await openai.responses.create(inputForm.options);
|
||||
|
||||
console.log("Received response from OpenAI Responses API");
|
||||
|
||||
type SuccessResponse = {
|
||||
ok: true;
|
||||
openai_raw: OpenAITypes.OpenAI.Responses.Response;
|
||||
supabase_paths: string[];
|
||||
openai_file_ids: string[];
|
||||
options: unknown;
|
||||
};
|
||||
|
||||
const response: SuccessResponse = {
|
||||
ok: true,
|
||||
openai_raw, // cruda
|
||||
supabase_paths, // rutas en el bucket
|
||||
openai_file_ids, // IDs en OpenAI
|
||||
options: inputForm.options, // opciones usadas
|
||||
};
|
||||
response.openai_raw.output.
|
||||
|
||||
return json(response);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
console.error("Error occurred:", e.message);
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
|
||||
error: e?.message ?? String(e),
|
||||
// si fue Zod, normalmente trae detalles en `issues`
|
||||
issues: (e as any)?.issues,
|
||||
},
|
||||
400,
|
||||
);
|
||||
} else {
|
||||
console.error("Unknown error occurred:", e);
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: "An unknown error occurred",
|
||||
},
|
||||
500,
|
||||
);
|
||||
}
|
||||
}
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error:
|
||||
"This endpoint is deprecated. Use the shared module _shared/openai-service.ts from your functions.",
|
||||
},
|
||||
410,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -25,7 +25,9 @@ function mustEnv() {
|
||||
if (!SUPABASE_ANON_KEY) throw new Error("SUPABASE_ANON_KEY is required");
|
||||
}
|
||||
|
||||
async function getAuthedClient(): Promise<{ client: SupabaseClient; accessToken: string }> {
|
||||
async function getAuthedClient(): Promise<
|
||||
{ client: SupabaseClient; accessToken: string }
|
||||
> {
|
||||
mustEnv();
|
||||
const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, options);
|
||||
|
||||
@@ -36,69 +38,83 @@ async function getAuthedClient(): Promise<{ client: SupabaseClient; accessToken:
|
||||
if (error) throw new Error("Sign-in failed: " + error.message);
|
||||
|
||||
const accessToken = data.session?.access_token;
|
||||
if (!accessToken) throw new Error("No access_token returned from signInWithPassword");
|
||||
if (!accessToken) {
|
||||
throw new Error("No access_token returned from signInWithPassword");
|
||||
}
|
||||
|
||||
return { client, accessToken };
|
||||
}
|
||||
|
||||
Deno.test("ai-structured (JSON body)", async () => {
|
||||
const { client, accessToken } = await getAuthedClient();
|
||||
Deno.test(
|
||||
{ name: "ai-structured (JSON body) [DEPRECATED]", ignore: true },
|
||||
async () => {
|
||||
const { client, accessToken } = await getAuthedClient();
|
||||
|
||||
const { data, error } = await client.functions.invoke("ai-structured", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`, // 👈 clave para que tu función pase el authHeader check
|
||||
},
|
||||
body: {
|
||||
response: {
|
||||
model: "gpt-5",
|
||||
input: [
|
||||
{ role: "system", content: "Responde SIEMPRE en JSON válido." },
|
||||
{ role: "user", content: "Dame 3 ideas de proyecto de IA para educación." },
|
||||
],
|
||||
const { data, error } = await client.functions.invoke("ai-structured", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`, // 👈 clave para que tu función pase el authHeader check
|
||||
},
|
||||
structured: {
|
||||
type: "json_schema",
|
||||
name: "ideas",
|
||||
strict: true,
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
ideas: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
titulo: { type: "string" },
|
||||
descripcion: { type: "string" },
|
||||
body: {
|
||||
response: {
|
||||
model: "gpt-5",
|
||||
input: [
|
||||
{ role: "system", content: "Responde SIEMPRE en JSON válido." },
|
||||
{
|
||||
role: "user",
|
||||
content: "Dame 3 ideas de proyecto de IA para educación.",
|
||||
},
|
||||
],
|
||||
},
|
||||
structured: {
|
||||
type: "json_schema",
|
||||
name: "ideas",
|
||||
strict: true,
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
ideas: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
titulo: { type: "string" },
|
||||
descripcion: { type: "string" },
|
||||
},
|
||||
required: ["titulo", "descripcion"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
required: ["titulo", "descripcion"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
required: ["ideas"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
required: ["ideas"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
references: {},
|
||||
usarMCP: false,
|
||||
},
|
||||
references: {},
|
||||
usarMCP: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (error) throw new Error("Invoke failed: " + error.message);
|
||||
assert(data, "Expected data from function");
|
||||
if (error) throw new Error("Invoke failed: " + error.message);
|
||||
assert(data, "Expected data from function");
|
||||
|
||||
// tu función responde { ok, output, outputText, ... }
|
||||
assertEquals(data.ok, true);
|
||||
assert(Array.isArray(data.output?.ideas), "output.ideas should be an array");
|
||||
assertEquals(data.output.ideas.length, 3);
|
||||
for (const it of data.output.ideas) {
|
||||
assert(typeof it.titulo === "string");
|
||||
assert(typeof it.descripcion === "string");
|
||||
}
|
||||
});
|
||||
// tu función responde { ok, output, outputText, ... }
|
||||
assertEquals(data.ok, true);
|
||||
assert(
|
||||
Array.isArray(data.output?.ideas),
|
||||
"output.ideas should be an array",
|
||||
);
|
||||
assertEquals(data.output.ideas.length, 3);
|
||||
for (const it of data.output.ideas) {
|
||||
assert(typeof it.titulo === "string");
|
||||
assert(typeof it.descripcion === "string");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Deno.test("ai-structured (multipart + file)", async () => {
|
||||
Deno.test({
|
||||
name: "ai-structured (multipart + file) [DEPRECATED]",
|
||||
ignore: true,
|
||||
}, async () => {
|
||||
const { client, accessToken } = await getAuthedClient();
|
||||
|
||||
// Lee un PDF local (ajusta path)
|
||||
|
||||
Reference in New Issue
Block a user