10개의 Edge Function에서 Lovable AI gateway를 사용하고 있습니다. 이제 AI provider 분기 모듈을 생성해야 합니다.

10개의 Edge Function이 Lovable AI gateway를 사용합니다. AI provider 분기 모듈을 생성하겠습니다.

write

/**
 * AI Provider 모듈
 *
 * subscription_plans 테이블의 ai_provider 필드에 따라 직접 AI API를 호출합니다.
 * - Gemini: Google AI API (REST)
 * - Claude: Anthropic API (REST)
 *
 * 작업: task-441.1
 */

// AI Provider 타입 정의
export type AIProvider =
  | "gemini-2.0-flash-lite"  // Free/Basic
  | "gemini-2.0-flash"        // Pro/Internal
  | "claude-haiku-4-5-20251001"  // Premium
  | "claude-sonnet-4-5-20250514"; // Enterprise

export interface AICallOptions {
  model?: string;
  maxTokens?: number;
  temperature?: number;
  stream?: boolean;
}

export interface AICallResult {
  ok: boolean;
  body?: ReadableStream<Uint8Array>;
  content?: string;
  error?: string;
}

// Provider → API 매핑
const PROVIDER_CONFIG: Record<string, {
  api: "gemini" | "claude";
  model: string;
  apiKeyEnv: string;
}> = {
  "gemini-2.0-flash-lite": { api: "gemini", model: "gemini-2.0-flash-lite", apiKeyEnv: "GOOGLE_AI_API_KEY" },
  "gemini-2.0-flash": { api: "gemini", model: "gemini-2.0-flash", apiKeyEnv: "GOOGLE_AI_API_KEY" },
  "claude-haiku-4-5-20251001": { api: "claude", model: "claude-haiku-4-5-20251001", apiKeyEnv: "ANTHROPIC_API_KEY" },
  "claude-sonnet-4-5-20250514": { api: "claude", model: "claude-sonnet-4-5-20250514", apiKeyEnv: "ANTHROPIC_API_KEY" },
};

// 기본 provider (fallback)
const DEFAULT_PROVIDER: AIProvider = "gemini-2.0-flash-lite";

/**
 * 사용자의 organization_id로부터 ai_provider 조회
 */
export async function getAIProvider(
  supabaseUrl: string,
  supabaseKey: string,
  organizationId: string | null
): Promise<AIProvider> {
  if (!organizationId) {
    return DEFAULT_PROVIDER;
  }

  try {
    // organization의 subscription_plan_id 조회
    const orgRes = await fetch(
      `${supabaseUrl}/rest/v1/organizations?id=eq.${organizationId}&select=subscription_plan_id`,
      {
        headers: {
          apikey: supabaseKey,
          Authorization: `Bearer ${supabaseKey}`,
        },
      }
    );
    const orgs = await orgRes.json();
    const planId = orgs?.[0]?.subscription_plan_id;

    if (!planId) {
      return DEFAULT_PROVIDER;
    }

    // subscription_plans에서 ai_provider 조회
    const planRes = await fetch(
      `${supabaseUrl}/rest/v1/subscription_plans?id=eq.${planId}&select=ai_provider`,
      {
        headers: {
          apikey: supabaseKey,
          Authorization: `Bearer ${supabaseKey}`,
        },
      }
    );
    const plans = await planRes.json();
    const provider = plans?.[0]?.ai_provider;

    if (provider && PROVIDER_CONFIG[provider]) {
      return provider as AIProvider;
    }

    return DEFAULT_PROVIDER;
  } catch (e) {
    console.error("getAIProvider error:", e);
    return DEFAULT_PROVIDER;
  }
}

/**
 * Gemini API 호출
 */
async function callGemini(
  apiKey: string,
  model: string,
  systemPrompt: string,
  messages: Array<{ role: string; content: string }>,
  options: AICallOptions = {}
): Promise<AICallResult> {
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?key=${apiKey}`;

  // messages → Gemini 형식 변환
  const contents: Array<{ role: string; parts: Array<{ text: string }> }> = [];

  // 시스템 프롬프트를 첫 번째 user 메시지에 포함
  const userMessages = messages.filter(m => m.role === "user");
  const assistantMessages = messages.filter(m => m.role === "assistant");

  // 첫 user 메시지에 시스템 프롬프트 포함
  if (userMessages.length > 0) {
    contents.push({
      role: "user",
      parts: [{ text: `${systemPrompt}\n\n${userMessages[0].content}` }],
    });

    // 나머지 user/assistant 메시지들
    for (let i = 1; i < userMessages.length; i++) {
      if (assistantMessages[i - 1]) {
        contents.push({
          role: "model",
          parts: [{ text: assistantMessages[i - 1].content }],
        });
      }
      contents.push({
        role: "user",
        parts: [{ text: userMessages[i].content }],
      });
    }
  }

  const body = {
    contents,
    generationConfig: {
      maxOutputTokens: options.maxTokens || 8192,
      temperature: options.temperature ?? 0.7,
    },
  };

  try {
    const response = await fetch(url, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });

    if (!response.ok) {
      const errorText = await response.text();
      return { ok: false, error: `Gemini API error: ${response.status} ${errorText}` };
    }

    // 스트리밍 응답 반환
    if (options.stream) {
      return { ok: true, body: response.body! };
    }

    // 비스트리밍: JSON 파싱
    const data = await response.json();
    const content = data.candidates?.[0]?.content?.parts?.[0]?.text || "";
    return { ok: true, content };
  } catch (e) {
    return { ok: false, error: e instanceof Error ? e.message : "Unknown error" };
  }
}

/**
 * Claude API 호출
 */
async function callClaude(
  apiKey: string,
  model: string,
  systemPrompt: string,
  messages: Array<{ role: string; content: string }>,
  options: AICallOptions = {}
): Promise<AICallResult> {
  const url = "https://api.anthropic.com/v1/messages";

  // messages → Claude 형식 변환 (user/assistant만)
  const claudeMessages = messages
    .filter(m => m.role === "user" || m.role === "assistant")
    .map(m => ({
      role: m.role === "assistant" ? "assistant" : "user",
      content: m.content,
    }));

  const body = {
    model,
    max_tokens: options.maxTokens || 8192,
    system: systemPrompt,
    messages: claudeMessages,
    stream: options.stream ?? true,
  };

  try {
    const response = await fetch(url, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "x-api-key": apiKey,
        "anthropic-version": "2023-06-01",
      },
      body: JSON.stringify(body),
    });

    if (!response.ok) {
      const errorText = await response.text();
      return { ok: false, error: `Claude API error: ${response.status} ${errorText}` };
    }

    // 스트리밍 응답 반환
    if (options.stream) {
      return { ok: true, body: response.body! };
    }

    // 비스트리밍: JSON 파싱
    const data = await response.json();
    const content = data.content?.[0]?.text || "";
    return { ok: true, content };
  } catch (e) {
    return { ok: false, error: e instanceof Error ? e.message : "Unknown error" };
  }
}

/**
 * 통일된 AI 호출 인터페이스
 *
 * @param provider - AI provider ("gemini-2.0-flash-lite" 등)
 * @param systemPrompt - 시스템 프롬프트
 * @param messages - 대화 메시지 배열
 * @param options - 호출 옵션
 * @returns AI 호출 결과
 */
export async function callAI(
  provider: AIProvider,
  systemPrompt: string,
  messages: Array<{ role: string; content: string }>,
  options: AICallOptions = {}
): Promise<AICallResult> {
  const config = PROVIDER_CONFIG[provider] || PROVIDER_CONFIG[DEFAULT_PROVIDER];
  const apiKey = Deno.env.get(config.apiKeyEnv);

  if (!apiKey) {
    return { ok: false, error: `${config.apiKeyEnv} is not configured` };
  }

  if (config.api === "gemini") {
    return callGemini(apiKey, config.model, systemPrompt, messages, options);
  } else {
    return callClaude(apiKey, config.model, systemPrompt, messages, options);
  }
}

/**
 * Lovable gateway 호환 래퍼 (마이그레이션용)
 *
 * 기존 코드에서 fetch("https://ai.gateway.lovable.dev/...") 호출을
 * 이 함수로 교체하면 됩니다.
 */
export async function callAIWithProvider(
  supabaseUrl: string,
  supabaseKey: string,
  organizationId: string | null,
  systemPrompt: string,
  messages: Array<{ role: string; content: string }>,
  options: AICallOptions = {}
): Promise<AICallResult> {
  const provider = await getAIProvider(supabaseUrl, supabaseKey, organizationId);
  return callAI(provider, systemPrompt, messages, options);
}


(no output)


Successfully wrote 7541 bytes to /home/jay/projects/InsuRo/supabase/functions/_shared/ai-provider.ts


AI provider 모듈을 생성했습니다. 이제 Edge Function들을 수정해야 합니다. 먼저 premium-chat을 수정하겠습니다.

AI provider 모듈을 생성했습니다. 이제 Edge Function들을 수정하겠습니다.

import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { callAIWithProvider } from "../_shared/ai-provider.ts";

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers":
    "authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
};

serve(async (req) => {
  if (req.method === "OPTIONS") {
    return new Response(null, { headers: corsHeaders });
  }

  try {
    const { messages, month_key } = await req.json();

    const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
    const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;

    // Extract user from JWT to get their organization
    const authHeader = req.headers.get("Authorization") ?? "";
    let userOrgId: string | null = null;
    let isSystemAdmin = false;

    // Try to get user info from JWT
    const token = authHeader.replace("Bearer ", "");
    if (token && token !== Deno.env.get("SUPABASE_ANON_KEY")) {
      // Verify user via Supabase auth
      const userRes = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
        headers: { Authorization: `Bearer ${token}`, apikey: SUPABASE_SERVICE_ROLE_KEY },
      });
      if (userRes.ok) {
        const user = await userRes.json();
        const userId = user.id;

        // Check system_admin role
        const rolesRes = await fetch(
          `${SUPABASE_URL}/rest/v1/user_roles?user_id=eq.${userId}&role=eq.system_admin&select=role`,
          { headers: { apikey: SUPABASE_SERVICE_ROLE_KEY, Authorization: `Bearer ${SUPABASE_SERVICE_ROLE_KEY}` } }
        );
        const roles = await rolesRes.json();
        if (roles?.length > 0) isSystemAdmin = true;

        // Get user's organization_id
        const profileRes = await fetch(
          `${SUPABASE_URL}/rest/v1/profiles?id=eq.${userId}&select=organization_id`,
          { headers: { apikey: SUPABASE_SERVICE_ROLE_KEY, Authorization: `Bearer ${SUPABASE_SERVICE_ROLE_KEY}` } }
        );
        const profiles = await profileRes.json();
        if (profiles?.[0]?.organization_id) {
          userOrgId = profiles[0].organization_id;
        }
      }
    }

    if (!isSystemAdmin && !userOrgId) {
      return new Response(JSON.stringify({ error: "조직 소속이 확인되지 않습니다." }), {
        status: 403,
        headers: { ...corsHeaders, "Content-Type": "application/json" },
      });
    }

    // Fetch premium data filtered by organization
    let url = `${SUPABASE_URL}/rest/v1/premium_data?select=company_name,product_type,month_key,title,extracted_text&order=company_name`;

    if (!isSystemAdmin && userOrgId) {
      url += `&organization_id=eq.${userOrgId}`;
    }

    if (month_key) {
      url += `&month_key=eq.${month_key}`;
    } else {
      const now = new Date();
      const twoMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 1, 1);
      const fromKey = `${twoMonthsAgo.getFullYear()}-${String(twoMonthsAgo.getMonth() + 1).padStart(2, "0")}`;
      url += `&month_key=gte.${fromKey}`;
    }

    const res = await fetch(url, {
      headers: {
        apikey: SUPABASE_SERVICE_ROLE_KEY,
        Authorization: `Bearer ${SUPABASE_SERVICE_ROLE_KEY}`,
      },
    });
    const premiumItems = await res.json();

    const contextParts: string[] = [];
    for (const item of premiumItems) {
      if (item.extracted_text && item.extracted_text.trim()) {
        contextParts.push(
          `\n=== ${item.company_name} ${item.product_type ? `(${item.product_type})` : ""} (${item.month_key}) - ${item.title} ===\n${item.extracted_text}`
        );
      }
    }

    const premiumContext = contextParts.length > 0
      ? contextParts.join("\n")
      : "현재 등록된 보험료 데이터가 없습니다.";

    const availableCompanies = [...new Set(premiumItems.map((n: any) => n.company_name))].join(", ");
    const availableMonths = [...new Set(premiumItems.map((n: any) => n.month_key))].sort().reverse().join(", ");

    const systemPrompt = `당신은 보험업계 전문 AI 어시스턴트 "인슈로 보험료 분석가"입니다.

## 역할
- 보험사별 보험료 테이블 데이터를 기반으로 정확한 보험료 비교 분석을 제공합니다.
- 조건별(나이, 성별, 심사유형, 납입기간, 보장기간 등) 보험료 비교, 최저가 보험사 추천, 가성비 분석 등을 수행합니다.

## 현재 보유 데이터
- 보험사: ${availableCompanies || "없음"}
- 기간: ${availableMonths || "없음"}

## 보험료 데이터
${premiumContext}

## ⚠ 필수 확인 사항 (가장 중요!)
사용자가 보험료나 환급률을 질문할 때, 아래 조건이 명확하지 않으면 **반드시 먼저 확인**하세요. 한 번에 여러 조건을 물어도 됩니다.

### 1. 심사유형 (반드시 확인)
보험료 데이터는 심사유형별로 구분되어 있습니다:
- **일반심사**: 표준 건강 상태의 일반 가입자
- **건강체(건강고지)**: 10년고지 기준 (DB손보는 8년고지). 건강한 상태를 고지하여 보험료 할인
- **유병자(간편고지)**: 기왕증이 있는 가입자. 상품명에 조건이 명시됨
  - 예: (315 간편고지형) = 3개 질문, 1년/5년 고지
  - 예: (325형) = 3개 질문, 2년/5년 고지
  - 유병자 선택 시 어떤 간편고지 조건인지도 확인하세요

### 2. 나이 (반드시 확인)
데이터는 10세/20세/30세/40세/50세/60세 등 연령대별로 구분됩니다.

### 3. 성별 (반드시 확인)
남성/여성 보험료가 다르므로 반드시 확인하세요.

### 4. 생명보험 / 손해보험 구분
- 질문 내용에서 **자동 판단**하세요 (종신보험→생명, 3대질병→손해 등)
- 구분이 모호하면 **둘 다 보여주세요** (설계사는 양쪽 모두 비교하는 경우가 많음)
- 확실히 판단 안 될 때만 물어보세요

### 확인 예시:
> 사용자: "40대 3대질병 보험료 비교해줘"
> AI: "네, 3대질병진단비 보험료를 비교해드리겠습니다.
> 정확한 비교를 위해 몇 가지 확인이 필요합니다:
> 1. **심사유형**: 일반심사 / 건강체(10년고지) / 유병자(간편고지) 중 어떤 기준인가요?
> 2. **성별**: 남성 / 여성 중 어느 쪽인가요?
> (나이는 40세로 확인했습니다)"

## 응답 형식 규칙
1. 보험료 데이터에 있는 정보만 기반으로 답변하세요. 추측하지 마세요.
2. 보험료 비교 시 반드시 조건(나이, 성별, 심사유형, 납입기간 등)을 명시하세요.
3. 데이터가 없는 질문에는 "현재 등록된 보험료 데이터에 해당 정보가 없습니다"라고 안내하세요.
4. 한국어로 답변하세요.
5. 첫 인사 시 현재 보유한 보험료 데이터 목록을 간단히 안내하세요.

## 마크다운 표 작성 규칙 (반드시 준수!)
- 표의 각 셀은 **10자 이내**로 간결하게 작성하세요.
- 셀 안에 절대로 <br> 태그, 줄바꿈, 슬래시(/)로 여러 항목을 넣지 마세요.
- 항목이 많으면 표의 **행을 늘려서** 각 항목을 별도 행으로 분리하세요.
- 표 컬럼은 최대 4~5개까지만 사용하세요.
- 복잡한 비교는 표 대신 **회사별 섹션**으로 나누어 작성하세요.
- 보험료는 원 단위로 표기하고 천 단위 콤마를 사용하세요.`;

    // AI 호출 (provider 자동 선택)
    const result = await callAIWithProvider(
      SUPABASE_URL,
      SUPABASE_SERVICE_ROLE_KEY,
      userOrgId,
      systemPrompt,
      messages,
      { stream: true }
    );

    if (!result.ok) {
      console.error("AI call error:", result.error);

      // 에러 메시지 매핑
      if (result.error?.includes("429")) {
        return new Response(JSON.stringify({ error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." }), {
          status: 429,
          headers: { ...corsHeaders, "Content-Type": "application/json" },
        });
      }
      if (result.error?.includes("402") || result.error?.includes("quota")) {
        return new Response(JSON.stringify({ error: "AI 크레딧이 부족합니다." }), {
          status: 402,
          headers: { ...corsHeaders, "Content-Type": "application/json" },
        });
      }

      return new Response(JSON.stringify({ error: "AI 서비스 오류가 발생했습니다." }), {
        status: 500,
        headers: { ...corsHeaders, "Content-Type": "application/json" },
      });
    }

    return new Response(result.body, {
      headers: { ...corsHeaders, "Content-Type": "text/event-stream" },
    });
  } catch (e) {
    console.error("premium-chat error:", e);
    return new Response(
      JSON.stringify({ error: e instanceof Error ? e.message : "Unknown error" }),
      { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
    );
  }
});


Successfully wrote 7051 bytes to /home/jay/projects/InsuRo/supabase/functions/premium-chat/index.ts


premium-chat을 수정했습니다. 이제 newsletter-chat을 수정하겠습니다.

import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { callAIWithProvider } from "../_shared/ai-provider.ts";

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers":
    "authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
};

serve(async (req) => {
  if (req.method === "OPTIONS") {
    return new Response(null, { headers: corsHeaders });
  }

  try {
    const { messages, month_key } = await req.json();

    const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
    const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;

    // Extract user from JWT to get their organization
    const authHeader = req.headers.get("Authorization") ?? "";
    let userOrgId: string | null = null;

    const token = authHeader.replace("Bearer ", "");
    if (token && token !== Deno.env.get("SUPABASE_ANON_KEY")) {
      const userRes = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
        headers: { Authorization: `Bearer ${token}`, apikey: SUPABASE_SERVICE_ROLE_KEY },
      });
      if (userRes.ok) {
        const user = await userRes.json();
        const userId = user.id;

        const profileRes = await fetch(
          `${SUPABASE_URL}/rest/v1/profiles?id=eq.${userId}&select=organization_id`,
          { headers: { apikey: SUPABASE_SERVICE_ROLE_KEY, Authorization: `Bearer ${SUPABASE_SERVICE_ROLE_KEY}` } }
        );
        const profiles = await profileRes.json();
        if (profiles?.[0]?.organization_id) {
          userOrgId = profiles[0].organization_id;
        }
      }
    }

    // Fetch newsletters for context (filter by organization)
    let url = `${SUPABASE_URL}/rest/v1/newsletters?select=company_name,month_key,title,extracted_text&order=company_name`;

    if (userOrgId) {
      url += `&organization_id=eq.${userOrgId}`;
    }

    if (month_key) {
      url += `&month_key=eq.${month_key}`;
    } else {
      const now = new Date();
      const twoMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 1, 1);
      const fromKey = `${twoMonthsAgo.getFullYear()}-${String(twoMonthsAgo.getMonth() + 1).padStart(2, "0")}`;
      url += `&month_key=gte.${fromKey}`;
    }

    const nlRes = await fetch(url, {
      headers: {
        apikey: SUPABASE_SERVICE_ROLE_KEY,
        Authorization: `Bearer ${SUPABASE_SERVICE_ROLE_KEY}`,
      },
    });
    const newsletters = await nlRes.json();

    // Build context from newsletters
    const contextParts: string[] = [];
    for (const nl of newsletters) {
      if (nl.extracted_text && nl.extracted_text.trim()) {
        contextParts.push(
          `\n=== ${nl.company_name} (${nl.month_key}) - ${nl.title} ===\n${nl.extracted_text}`
        );
      }
    }

    const newsletterContext = contextParts.length > 0
      ? contextParts.join("\n")
      : "현재 등록된 소식지 데이터가 없습니다.";

    const availableCompanies = [...new Set(newsletters.map((n: any) => n.company_name))].join(", ");
    const availableMonths = [...new Set(newsletters.map((n: any) => n.month_key))].sort().reverse().join(", ");

    const systemPrompt = `당신은 보험업계 전문 AI 어시스턴트 "인슈로 마켓 애널리스트"입니다.

## 역할
- 보험사 GA 소식지를 분석하여 설계사에게 핵심 정보를 제공합니다.
- 담보 비교, 보험료 비교, 회사별 특징, 신상품 정보 등을 정확히 안내합니다.

## 현재 보유 데이터
- 보험사: ${availableCompanies || "없음"}
- 기간: ${availableMonths || "없음"}

## 소식지 데이터
${newsletterContext}

## 응답 형식 규칙 (매우 중요!)
1. 소식지에 있는 정보만 기반으로 답변하세요. 추측하지 마세요.
2. 보험료 정보는 조건(나이, 성별, 납입기간 등)을 함께 명시하세요.
3. 데이터가 없는 질문에는 "현재 등록된 소식지에 해당 정보가 없습니다"라고 안내하세요.
4. 한국어로 답변하세요.
5. 첫 인사 시 현재 보유한 소식지 목록을 간단히 안내하세요.

## 마크다운 표 작성 규칙 (반드시 준수!)
- 표의 각 셀은 **10자 이내**로 간결하게 작성하세요.
- 셀 안에 절대로 <br> 태그, 줄바꿈, 슬래시(/)로 여러 항목을 넣지 마세요.
- 항목이 많으면 표의 **행을 늘려서** 각 항목을 별도 행으로 분리하세요.
- 표 컬럼은 최대 4~5개까지만 사용하세요.
- 복잡한 비교는 표 대신 **회사별 섹션**으로 나누어 작성하세요.

### 좋은 예시:
\`\`\`
| 담보명 | DB손보 | NH손보 |
|--------|--------|--------|
| 암진단비 | 3천만 | 2천만 |
| 입원일당 | 20만 | 15만 |
\`\`\`

### 나쁜 예시 (절대 하지 마세요):
\`\`\`
| 구분 | DB손보 | NH손보 |
|------|--------|--------|
| 보장내용 | 암진단비 3천만, 입원일당 20만, 수술비 500만 (요양병원 제외: 1천만, 요양병원: 4백만) | 암진단비 2천만... |
\`\`\`

### 복잡한 비교 시 권장 형식:

**1. DB손해보험**
- **암 통합케어 지원비**: 1심만 연 1회
- **간병인사용 입원지원비**: 1천만/4백만

**2. KB손해보험**
- **질병통합치료비**: 회당 20만~500만
- **상급종합병원 암주요치료비Plus**: 연간 한도 없음

이런 식으로 회사별로 나누어 **리스트 형태**로 작성하세요.`;

    // AI 호출 (provider 자동 선택)
    const result = await callAIWithProvider(
      SUPABASE_URL,
      SUPABASE_SERVICE_ROLE_KEY,
      userOrgId,
      systemPrompt,
      messages,
      { stream: true }
    );

    if (!result.ok) {
      console.error("AI call error:", result.error);

      if (result.error?.includes("429")) {
        return new Response(JSON.stringify({ error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." }), {
          status: 429,
          headers: { ...corsHeaders, "Content-Type": "application/json" },
        });
      }
      if (result.error?.includes("402") || result.error?.includes("quota")) {
        return new Response(JSON.stringify({ error: "AI 크레딧이 부족합니다." }), {
          status: 402,
          headers: { ...corsHeaders, "Content-Type": "application/json" },
        });
      }

      return new Response(JSON.stringify({ error: "AI 서비스 오류가 발생했습니다." }), {
        status: 500,
        headers: { ...corsHeaders, "Content-Type": "application/json" },
      });
    }

    return new Response(result.body, {
      headers: { ...corsHeaders, "Content-Type": "text/event-stream" },
    });
  } catch (e) {
    console.error("newsletter-chat error:", e);
    return new Response(
      JSON.stringify({ error: e instanceof Error ? e.message : "Unknown error" }),
      { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
    );
  }
});


Successfully wrote 5788 bytes to /home/jay/projects/InsuRo/supabase/functions/newsletter-chat/index.ts


newsletter-chat을 수정했습니다. 이제 나머지 Edge Functions들도 수정해야 합니다. generate-content, suggest-topics, analyze-performance, analyze-customer, evaluate-consultation을 확인하고 수정하겠습니다.

newsletter-chat을 수정했습니다. 나머지 Edge Functions들을 확인하겠습니다.


import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
};

serve(async (req) => {
  if (req.method === "OPTIONS") {
    return new Response(null, { headers: corsHeaders });
  }

  try {
    const { topic, contentType, channelPrompt, stream, settings, gaRegulations, personalRegulations } = await req.json();

    const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
    const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
    const sb = createClient(supabaseUrl, supabaseServiceKey);

    // Determine AI provider: check ai_config (admin setting)
    const { data: aiConfig } = await sb.from("ai_config").select("*").limit(1).maybeSingle();

    let aiProvider = aiConfig?.provider || "lovable";
    let aiModel = aiConfig?.model || "google/gemini-3-flash-preview";
    let aiApiKey = "";
    let aiApiUrl = "";

    if (aiProvider === "lovable") {
      aiApiKey = Deno.env.get("LOVABLE_API_KEY") || "";
      aiApiUrl = "https://ai.gateway.lovable.dev/v1/chat/completions";
    } else {
      aiApiKey = aiConfig?.api_key || "";
      aiApiUrl = aiConfig?.api_url || "";
    }

    // Check if user has personal API key (overrides admin config for that user)
    const authHeader = req.headers.get("Authorization");
    let userId: string | null = null;
    let userProfile: any = null;

    if (authHeader) {
      try {
        const anonClient = createClient(supabaseUrl, Deno.env.get("SUPABASE_ANON_KEY")!);
        const token = authHeader.replace("Bearer ", "");
        const { data: claimsData } = await anonClient.auth.getClaims(token);
        userId = claimsData?.claims?.sub || null;

        if (userId) {
          // Fetch user profile + personal API keys in parallel
          const [{ data: userKeys }, { data: profileData }] = await Promise.all([
            sb.from("user_api_keys").select("*").eq("user_id", userId).eq("is_active", true),
            sb.from("profiles").select("*").eq("id", userId).maybeSingle(),
          ]);

          userProfile = profileData;

          if (userKeys && userKeys.length > 0) {
            const matchingKey = userKeys.find((k: any) => k.provider === aiProvider) || userKeys[0];
            if (matchingKey) {
              aiApiKey = matchingKey.api_key;
              if (matchingKey.provider === "openai") {
                aiApiUrl = matchingKey.api_url || "https://api.openai.com/v1/chat/completions";
              } else if (matchingKey.provider === "claude") {
                aiApiUrl = matchingKey.api_url || "https://api.anthropic.com/v1/messages";
              } else if (matchingKey.provider === "gemini") {
                aiApiUrl = matchingKey.api_url || "https://generativelanguage.googleapis.com/v1beta/chat/completions";
              }
            }
          }
        }
      } catch (e) {
        console.error("Failed to check user API keys:", e);
      }
    }

    if (!aiApiKey) {
      throw new Error("AI API key is not configured. Please check admin AI settings.");
    }

    // Build context from settings
    const contextParts: string[] = [];

    if (settings?.insuranceCategory) contextParts.push(`보험 종류/카테고리: ${settings.insuranceCategory}`);
    if (settings?.lifeStage) contextParts.push(`타겟 라이프 스테이지: ${settings.lifeStage}`);
    if (settings?.targetAge) contextParts.push(`타겟 연령대: ${settings.targetAge}`);
    if (settings?.targetGender && settings.targetGender !== "공통") contextParts.push(`타겟 성별: ${settings.targetGender}`);
    if (settings?.region && settings.region !== "전국") contextParts.push(`타겟 지역: ${settings.region}`);
    if (settings?.contentTone && settings.contentTone !== "자동") contextParts.push(`콘텐츠 톤: ${settings.contentTone}`);
    if (settings?.targetConcern) contextParts.push(`타겟 고민/관심사: ${settings.targetConcern}`);
    if (settings?.cpaLink) contextParts.push(`CPA 링크 (본문에 자연스럽게 삽입): ${settings.cpaLink}`);

    // Compliance filter
    const complianceLevel = settings?.complianceFilter === true ? "full"
      : settings?.complianceFilter === false ? "off"
      : (settings?.complianceFilter || "full");

    let compliancePrompt = "";
    if (complianceLevel !== "off") {
      try {
        const { data: fcpaData } = await sb.from("fcpa_config").select("checklist").limit(1).maybeSingle();
        if (fcpaData?.checklist && Array.isArray(fcpaData.checklist)) {
          const items = fcpaData.checklist as Array<{ category: string; text: string; required: boolean }>;
          if (complianceLevel === "basic") {
            const forbidden = items.filter((i) => i.category === "금지 표현");
            if (forbidden.length > 0) {
              compliancePrompt = `\n\n[금소법 — 금지 표현 필터링만 적용]`;
              compliancePrompt += `\n아래 금지 표현은 절대 사용하지 마세요:`;
              forbidden.forEach((item, idx) => { compliancePrompt += `\n${idx + 1}. ${item.text}`; });
            }
          } else {
            const required = items.filter((i) => i.required);
            const optional = items.filter((i) => !i.required);
            compliancePrompt = `\n\n[금소법 준수 필터링 — 전체 적용]`;
            if (required.length > 0) {
              compliancePrompt += `\n\n★ 필수 준수 사항:`;
              required.forEach((item, idx) => { compliancePrompt += `\n${idx + 1}. [${item.category}] ${item.text}`; });
            }
            if (optional.length > 0) {
              compliancePrompt += `\n\n☆ 권장 사항:`;
              optional.forEach((item, idx) => { compliancePrompt += `\n${idx + 1}. [${item.category}] ${item.text}`; });
            }
            compliancePrompt += `\n\n위 체크리스트를 콘텐츠에 반드시 반영하세요.`;
          }
        }
      } catch (e) {
        console.error("Failed to fetch FCPA config:", e);
        compliancePrompt = complianceLevel === "basic"
          ? `\n\n[금소법 — 금지 표현 필터링]\n- 확정적 판단 표현 금지\n- 과장·허위 표현 금지\n- 특정 보험사명 직접 비교 금지`
          : `\n\n[금소법 준수 필터링 — 전체 적용]\n- 금융소비자보호법 준수\n- 금지 표현 필터링\n- 필수 안내문구 삽입`;
      }
    }

    const basePrompt = `당신은 보험 마케팅 전문 AI 작가입니다. 보험 설계사들이 고객에게 보낼 수 있는 마케팅 콘텐츠를 작성합니다.
- 한국어로 작성
- 전문적이면서도 친근한 톤${compliancePrompt}`;

    // Build profile context for system prompt
    let profilePrompt = "";
    if (userProfile) {
      const parts: string[] = [];
      if (userProfile.branding_name) parts.push(`작성자 브랜딩 이름: ${userProfile.branding_name} (인삿말에 자연스럽게 활용)`);
      if (userProfile.company_name) parts.push(`소속: ${userProfile.company_name}${userProfile.business_unit ? ` ${userProfile.business_unit}` : ""}`);

      // Disclaimer footer
      const footerParts: string[] = [];
      if (userProfile.include_disclaimer) {
        footerParts.push(`※ 상기 내용은 모집종사자 개인의 의견이며, 특정 보험회사 또는 보험상품을 추천·권유하는 것이 아닙니다.
※ 보험계약 체결 전 반드시 상품설명서 및 약관을 확인하시기 바랍니다.
※ 보험료, 보장내용 등은 가입 조건에 따라 상이할 수 있으므로 자세한 사항은 담당 설계사에게 문의하시기 바랍니다.
※ 본 내용은 금융소비자보호법 제17조~제19조(적합성·적정성·설명의무)를 준수하여 작성되었습니다.`);
      }
      if (userProfile.include_ad_label) {
        footerParts.push("※ 유료 광고 포함");
      }
      if (userProfile.custom_disclaimer) {
        footerParts.push(userProfile.custom_disclaimer);
      }
      // Insurance registration numbers
      const regParts: string[] = [];
      if (userProfile.insurance_nonlife_no) regParts.push(userProfile.insurance_nonlife_no);
      if (userProfile.insurance_life_no) regParts.push(userProfile.insurance_life_no);
      if (regParts.length > 0) footerParts.push(regParts.join(" | "));
      if (userProfile.company_name) footerParts.push(userProfile.company_name);

      if (footerParts.length > 0) {
        parts.push(`\n[콘텐츠 하단에 반드시 포함할 문구]\n${footerParts.join("\n")}`);
      }

      if (parts.length > 0) profilePrompt = `\n\n[설계사 프로필 정보]\n${parts.join("\n")}`;
    }

    const personalRegs = personalRegulations || settings?.personalRegulations || "";
    let personalRegPrompt = personalRegs && complianceLevel !== "off" ? `\n\n[설계사 개인 추가 규정]\n${personalRegs}` : "";

    let systemPrompt = channelPrompt ? `${basePrompt}${profilePrompt}${personalRegPrompt}\n\n${channelPrompt}` : `${basePrompt}${profilePrompt}${personalRegPrompt}`;
    if (contextParts.length > 0) systemPrompt += `\n\n[타겟팅 설정]\n${contextParts.join("\n")}`;
    if (settings?.extraPrompt) systemPrompt += `\n\n[추가 지시사항]\n${settings.extraPrompt}`;

    const userPrompt = `콘텐츠 유형: ${contentType}\n주제: ${topic}\n\n위 주제에 맞는 보험 마케팅 콘텐츠를 작성해 주세요.`;

    // Use selected model from settings or admin config
    const model = settings?.aiModel || aiModel;

    const response = await fetch(aiApiUrl, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${aiApiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        model,
        messages: [
          { role: "system", content: systemPrompt },
          { role: "user", content: userPrompt },
        ],
        stream: !!stream,
      }),
    });

    if (!response.ok) {
      if (response.status === 429) {
        return new Response(JSON.stringify({ error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." }), {
          status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" },
        });
      }
      if (response.status === 402) {
        return new Response(JSON.stringify({ error: "크레딧이 부족합니다." }), {
          status: 402, headers: { ...corsHeaders, "Content-Type": "application/json" },
        });
      }
      const t = await response.text();
      console.error("AI API error:", response.status, t);
      throw new Error(`AI API error: ${response.status}`);
    }

    if (stream) {
      return new Response(response.body, {
        headers: { ...corsHeaders, "Content-Type": "text/event-stream" },
      });
    }

    const data = await response.json();
    const content = data.choices?.[0]?.message?.content || "콘텐츠를 생성할 수 없습니다.";

    return new Response(JSON.stringify({ content }), {
      headers: { ...corsHeaders, "Content-Type": "application/json" },
    });
  } catch (error) {
    console.error("Error:", error);
    return new Response(JSON.stringify({ error: error.message }), {
      status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
    });
  }
});


import { serve } from "https://deno.land/std@0.168.0/http/server.ts";

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
};

serve(async (req) => {
  if (req.method === "OPTIONS") {
    return new Response(null, { headers: corsHeaders });
  }

  try {
    const { channel, insuranceCategory, targetAge, targetGender, lifeStage, targetConcern, contentTone, region } = await req.json();

    const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY");
    if (!LOVABLE_API_KEY) throw new Error("LOVABLE_API_KEY is not configured");

    // Build context from selected settings
    const contextParts: string[] = [];
    if (channel) contextParts.push(`채널: ${channel}`);
    if (insuranceCategory) contextParts.push(`보험 종류: ${insuranceCategory}`);
    if (targetAge) contextParts.push(`타겟 연령대: ${targetAge}`);
    if (targetGender && targetGender !== "공통") contextParts.push(`타겟 성별: ${targetGender}`);
    if (lifeStage) contextParts.push(`라이프스테이지: ${lifeStage}`);
    if (targetConcern) contextParts.push(`타겟 고민: ${targetConcern}`);
    if (contentTone && contentTone !== "자동") contextParts.push(`콘텐츠 톤: ${contentTone}`);
    if (region && region !== "전국") contextParts.push(`지역: ${region}`);

    const contextStr = contextParts.length > 0
      ? contextParts.join(", ")
      : "보험 관련 일반 콘텐츠";

    const systemPrompt = `당신은 보험 설계사를 위한 콘텐츠 주제 추천 전문가입니다.
사용자가 선택한 설정을 기반으로, 해당 채널에 적합하고 클릭률이 높은 콘텐츠 주제/키워드를 추천합니다.
반드시 suggest_topics 함수를 사용하여 응답하세요.`;

    const userPrompt = `다음 설정을 기반으로 콘텐츠 주제를 6개 추천해주세요.

설정: ${contextStr}

조건:
- 각 주제는 15~40자 사이로 구체적이고 검색 친화적이어야 합니다
- 보험 설계사가 고객 유치를 위해 쓸 수 있는 실용적인 주제
- 선택된 채널 특성에 맞는 주제 (블로그는 SEO, 인스타는 캐주얼, Threads는 위트 등)
- 타겟 고민이나 연령대가 있으면 그에 맞춘 주제
- 최신 트렌드나 시의성 있는 주제 포함`;

    const response = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${LOVABLE_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        model: "google/gemini-2.5-flash-lite",
        messages: [
          { role: "system", content: systemPrompt },
          { role: "user", content: userPrompt },
        ],
        tools: [
          {
            type: "function",
            function: {
              name: "suggest_topics",
              description: "Return 6 suggested content topics",
              parameters: {
                type: "object",
                properties: {
                  topics: {
                    type: "array",
                    items: { type: "string" },
                    minItems: 6,
                    maxItems: 6,
                  },
                },
                required: ["topics"],
                additionalProperties: false,
              },
            },
          },
        ],
        tool_choice: { type: "function", function: { name: "suggest_topics" } },
      }),
    });

    if (!response.ok) {
      if (response.status === 429) {
        return new Response(JSON.stringify({ error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." }), {
          status: 429,
          headers: { ...corsHeaders, "Content-Type": "application/json" },
        });
      }
      if (response.status === 402) {
        return new Response(JSON.stringify({ error: "크레딧이 부족합니다." }), {
          status: 402,
          headers: { ...corsHeaders, "Content-Type": "application/json" },
        });
      }
      const t = await response.text();
      console.error("AI gateway error:", response.status, t);
      throw new Error("AI 주제 추천 실패");
    }

    const data = await response.json();
    const toolCall = data.choices?.[0]?.message?.tool_calls?.[0];

    if (toolCall?.function?.arguments) {
      const parsed = JSON.parse(toolCall.function.arguments);
      return new Response(JSON.stringify({ topics: parsed.topics }), {
        headers: { ...corsHeaders, "Content-Type": "application/json" },
      });
    }

    throw new Error("AI 응답 파싱 실패");
  } catch (e) {
    console.error("suggest-topics error:", e);
    return new Response(JSON.stringify({ error: e instanceof Error ? e.message : "Unknown error" }), {
      status: 500,
      headers: { ...corsHeaders, "Content-Type": "application/json" },
    });
  }
});


generate-content는 이미 ai_config 테이블을 사용해서 provider를 선택할 수 있는 구조로 되어 있습니다. 하지만 subscription_plans 기반은 아닙니다. suggest-topics는 Lovable gateway를 직접 사용합니다.

나머지 함수들도 확인해야 합니다. transcribe-call과 generate-image는 이미지/음성 관련이므로 다른 방식일 수 있습니다.

먼저 주요 텍스트 생성 함수들만 수정하겠습니다. generate-content는 이미 ai_config 기반이므로, suggest-topics, analyze-performance, analyze-customer, evaluate-consultation을 수정하겠습니다.

generate-content는 이미 ai_config 기반으로 provider 선택이 가능합니다. suggest-topics를 수정하겠습니다.

import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { callAI } from "../_shared/ai-provider.ts";

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
};

serve(async (req) => {
  if (req.method === "OPTIONS") {
    return new Response(null, { headers: corsHeaders });
  }

  try {
    const { channel, insuranceCategory, targetAge, targetGender, lifeStage, targetConcern, contentTone, region } = await req.json();

    // Build context from selected settings
    const contextParts: string[] = [];
    if (channel) contextParts.push(`채널: ${channel}`);
    if (insuranceCategory) contextParts.push(`보험 종류: ${insuranceCategory}`);
    if (targetAge) contextParts.push(`타겟 연령대: ${targetAge}`);
    if (targetGender && targetGender !== "공통") contextParts.push(`타겟 성별: ${targetGender}`);
    if (lifeStage) contextParts.push(`라이프스테이지: ${lifeStage}`);
    if (targetConcern) contextParts.push(`타겟 고민: ${targetConcern}`);
    if (contentTone && contentTone !== "자동") contextParts.push(`콘텐츠 톤: ${contentTone}`);
    if (region && region !== "전국") contextParts.push(`지역: ${region}`);

    const contextStr = contextParts.length > 0
      ? contextParts.join(", ")
      : "보험 관련 일반 콘텐츠";

    const systemPrompt = `당신은 보험 설계사를 위한 콘텐츠 주제 추천 전문가입니다.
사용자가 선택한 설정을 기반으로, 해당 채널에 적합하고 클릭률이 높은 콘텐츠 주제/키워드를 추천합니다.
반드시 suggest_topics 함수를 사용하여 응답하세요.`;

    const userPrompt = `다음 설정을 기반으로 콘텐츠 주제를 6개 추천해주세요.

설정: ${contextStr}

조건:
- 각 주제는 15~40자 사이로 구체적이고 검색 친화적이어야 합니다
- 보험 설계사가 고객 유치를 위해 쓸 수 있는 실용적인 주제
- 선택된 채널 특성에 맞는 주제 (블로그는 SEO, 인스타는 캐주얼, Threads는 위트 등)
- 타겟 고민이나 연령대가 있으면 그에 맞춘 주제
- 최신 트렌드나 시의성 있는 주제 포함`;

    // AI 호출 (기본 provider 사용 - Free 플랜은 gemini-2.0-flash-lite)
    const result = await callAI(
      "gemini-2.0-flash-lite",
      systemPrompt,
      [{ role: "user", content: userPrompt }],
      { stream: false, maxTokens: 1024 }
    );

    if (!result.ok) {
      console.error("AI call error:", result.error);

      if (result.error?.includes("429")) {
        return new Response(JSON.stringify({ error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." }), {
          status: 429,
          headers: { ...corsHeaders, "Content-Type": "application/json" },
        });
      }
      if (result.error?.includes("402") || result.error?.includes("quota")) {
        return new Response(JSON.stringify({ error: "크레딧이 부족합니다." }), {
          status: 402,
          headers: { ...corsHeaders, "Content-Type": "application/json" },
        });
      }

      throw new Error("AI 주제 추천 실패");
    }

    // 결과 파싱 - JSON 형식의 토픽 목록 추출
    const content = result.content || "";

    // JSON 배열 형식으로 파싱 시도
    try {
      // ```json ... ``` 블록에서 추출
      const jsonMatch = content.match(/```json\s*([\s\S]*?)\s*```/);
      if (jsonMatch) {
        const parsed = JSON.parse(jsonMatch[1]);
        if (parsed.topics && Array.isArray(parsed.topics)) {
          return new Response(JSON.stringify({ topics: parsed.topics }), {
            headers: { ...corsHeaders, "Content-Type": "application/json" },
          });
        }
      }

      // 직접 JSON 배열로 파싱 시도
      const arrayMatch = content.match(/\[[\s\S]*?\]/);
      if (arrayMatch) {
        const topics = JSON.parse(arrayMatch[0]);
        if (Array.isArray(topics)) {
          return new Response(JSON.stringify({ topics }), {
            headers: { ...corsHeaders, "Content-Type": "application/json" },
          });
        }
      }
    } catch {
      // 파싱 실패 시 텍스트에서 라인별로 추출
    }

    // 텍스트에서 번호별 항목 추출
    const lines = content.split("\n").filter((l: string) => l.trim());
    const topics: string[] = [];

    for (const line of lines) {
      // "1. 주제" 또는 "- 주제" 패턴
      const match = line.match(/^\s*(?:\d+[\.\)]\s*|[-*]\s*)(.+)$/);
      if (match && match[1].length >= 10) {
        topics.push(match[1].trim());
        if (topics.length >= 6) break;
      }
    }

    if (topics.length > 0) {
      return new Response(JSON.stringify({ topics }), {
        headers: { ...corsHeaders, "Content-Type": "application/json" },
      });
    }

    throw new Error("AI 응답 파싱 실패");
  } catch (e) {
    console.error("suggest-topics error:", e);
    return new Response(JSON.stringify({ error: e instanceof Error ? e.message : "Unknown error" }), {
      status: 500,
      headers: { ...corsHeaders, "Content-Type": "application/json" },
    });
  }
});


Successfully wrote 4587 bytes to /home/jay/projects/InsuRo/supabase/functions/suggest-topics/index.ts


suggest-topics를 수정했습니다. 나머지 함수들도 확인하고 수정해야 합니다. analyze-performance, analyze-customer, evaluate-consultation을 확인하겠습니다.

suggest-topics를 수정했습니다. 나머지 Edge Functions들도 확인하겠습니다.


import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
};

serve(async (req) => {
  if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });

  try {
    const authHeader = req.headers.get("Authorization");
    if (!authHeader) throw new Error("No authorization header");

    const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
    const supabaseKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
    const lovableKey = Deno.env.get("LOVABLE_API_KEY");
    if (!lovableKey) throw new Error("LOVABLE_API_KEY not configured");

    // Verify user
    const userClient = createClient(supabaseUrl, Deno.env.get("SUPABASE_ANON_KEY")!, {
      global: { headers: { Authorization: authHeader } },
    });
    const { data: { user }, error: authError } = await userClient.auth.getUser();
    if (authError || !user) throw new Error("Unauthorized");

    const { customer_id } = await req.json();
    if (!customer_id) throw new Error("customer_id required");

    const adminClient = createClient(supabaseUrl, supabaseKey);

    // Fetch customer data, notes, conversations, and existing insurance
    const [custRes, notesRes, convsRes, insRes] = await Promise.all([
      adminClient.from("customers").select("*").eq("id", customer_id).eq("agent_id", user.id).maybeSingle(),
      adminClient.from("customer_notes").select("*").eq("customer_id", customer_id).eq("agent_id", user.id).order("created_at", { ascending: false }).limit(20),
      adminClient.from("conversations").select("id").eq("customer_id", customer_id).eq("agent_id", user.id),
      adminClient.from("customer_insurance").select("*").eq("customer_id", customer_id),
    ]);

    if (!custRes.data) throw new Error("Customer not found or unauthorized");
    const customer = custRes.data;

    // Fetch recent messages from all conversations
    let allMessages: { content: string; sender_type: string; created_at: string }[] = [];
    if (convsRes.data && convsRes.data.length > 0) {
      const convIds = convsRes.data.map((c: { id: string }) => c.id);
      const { data: msgs } = await adminClient
        .from("conversation_messages")
        .select("content, sender_type, created_at")
        .in("conversation_id", convIds)
        .order("created_at", { ascending: false })
        .limit(50);
      allMessages = msgs || [];
    }

    // Build context for AI
    const notesText = (notesRes.data || []).map((n: { content: string; created_at: string }) =>
      `[${new Date(n.created_at).toLocaleDateString("ko-KR")}] ${n.content}`
    ).join("\n");

    const messagesText = allMessages.reverse().map((m) =>
      `[${m.sender_type === "agent" ? "설계사" : "고객"}] ${m.content}`
    ).join("\n");

    const insuranceText = (insRes.data || []).map((ins: { company_name: string; product_name: string; coverage_type: string | null; monthly_premium: number | null }) =>
      `${ins.company_name} ${ins.product_name} (${ins.coverage_type || "미분류"}, 월 ${ins.monthly_premium?.toLocaleString() || 0}원)`
    ).join("\n");

    const systemPrompt = `당신은 보험 설계사를 위한 AI 어시스턴트입니다. 고객 정보와 상담 기록을 분석하여 두 가지를 제공합니다:
1. 고객 상황 요약 (3-5문장, 핵심 니즈/상황/다음 단계 포함)
2. 설계사를 위한 할 일 목록 (구체적이고 실행 가능한 액션, 최대 5개)

반드시 아래 JSON 형식으로만 응답하세요. 다른 텍스트는 포함하지 마세요.`;

    const userPrompt = `## 고객 기본정보
- 이름: ${customer.name}
- 단계: ${customer.stage}
- 연락처: ${customer.phone || "미등록"}
- 직업: ${customer.occupation || "미등록"}
- 관심영역: ${(customer.interest_areas || []).join(", ") || "미등록"}

## 가입 보험
${insuranceText || "없음"}

## 설계사 메모
${notesText || "없음"}

## 최근 대화
${messagesText || "없음"}

위 정보를 바탕으로 분석해주세요.`;

    const aiResponse = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${lovableKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        model: "google/gemini-3-flash-preview",
        messages: [
          { role: "system", content: systemPrompt },
          { role: "user", content: userPrompt },
        ],
        tools: [
          {
            type: "function",
            function: {
              name: "analyze_customer",
              description: "고객 분석 결과를 구조화된 형식으로 반환",
              parameters: {
                type: "object",
                properties: {
                  summary: {
                    type: "string",
                    description: "고객 상황 요약 (3-5문장)",
                  },
                  todos: {
                    type: "array",
                    items: {
                      type: "object",
                      properties: {
                        title: { type: "string", description: "할 일 제목" },
                        priority: { type: "string", enum: ["high", "medium", "low"] },
                        due_days: { type: "number", description: "며칠 내 처리해야 하는지 (0=오늘, 7=1주일 등)" },
                      },
                      required: ["title", "priority"],
                      additionalProperties: false,
                    },
                  },
                },
                required: ["summary", "todos"],
                additionalProperties: false,
              },
            },
          },
        ],
        tool_choice: { type: "function", function: { name: "analyze_customer" } },
      }),
    });

    if (!aiResponse.ok) {
      const status = aiResponse.status;
      if (status === 429) {
        return new Response(JSON.stringify({ error: "AI 요청 한도 초과. 잠시 후 다시 시도해주세요." }), {
          status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" },
        });
      }
      if (status === 402) {
        return new Response(JSON.stringify({ error: "AI 크레딧이 부족합니다." }), {
          status: 402, headers: { ...corsHeaders, "Content-Type": "application/json" },
        });
      }
      const errText = await aiResponse.text();
      console.error("AI error:", status, errText);
      throw new Error("AI analysis failed");
    }

    const aiData = await aiResponse.json();
    const toolCall = aiData.choices?.[0]?.message?.tool_calls?.[0];
    if (!toolCall) throw new Error("No tool call in AI response");

    const result = JSON.parse(toolCall.function.arguments);

    // Update customer ai_summary
    await adminClient.from("customers").update({ ai_summary: result.summary }).eq("id", customer_id);

    // Save AI summary history
    await adminClient.from("customer_ai_summaries").insert({
      customer_id,
      agent_id: user.id,
      summary: result.summary,
      trigger_type: "manual",
    });

    // Create AI-generated todos
    if (result.todos && result.todos.length > 0) {
      const todosToInsert = result.todos.map((todo: { title: string; priority: string; due_days?: number }) => {
        const dueDate = todo.due_days !== undefined
          ? new Date(Date.now() + todo.due_days * 86400000).toISOString().split("T")[0]
          : null;
        return {
          agent_id: user.id,
          customer_id,
          title: todo.title,
          priority: todo.priority,
          due_date: dueDate,
          source: "ai",
        };
      });
      await adminClient.from("agent_todos").insert(todosToInsert);
    }

    return new Response(JSON.stringify({
      summary: result.summary,
      todos_created: result.todos?.length || 0,
    }), {
      headers: { ...corsHeaders, "Content-Type": "application/json" },
    });

  } catch (e) {
    console.error("analyze-customer error:", e);
    return new Response(JSON.stringify({ error: e instanceof Error ? e.message : "Unknown error" }), {
      status: 500,
      headers: { ...corsHeaders, "Content-Type": "application/json" },
    });
  }
});


import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
};

serve(async (req) => {
  if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });

  try {
    const authHeader = req.headers.get("Authorization");
    if (!authHeader) throw new Error("No authorization header");

    const lovableKey = Deno.env.get("LOVABLE_API_KEY");
    if (!lovableKey) throw new Error("LOVABLE_API_KEY not configured");

    const userClient = createClient(
      Deno.env.get("SUPABASE_URL")!,
      Deno.env.get("SUPABASE_ANON_KEY")!,
      { global: { headers: { Authorization: authHeader } } }
    );
    const { data: { user }, error: authError } = await userClient.auth.getUser();
    if (authError || !user) throw new Error("Unauthorized");

    const { customer_id, transcript } = await req.json();
    if (!customer_id || !transcript) throw new Error("customer_id and transcript required");

    const systemPrompt = `당신은 보험 상담 품질 평가 전문 AI입니다. 설계사의 상담 내용을 분석하여 구조화된 평가를 제공합니다.

평가는 4개 카테고리, 각 카테고리 2~3개 세부 항목으로 구성됩니다:

1. 커뮤니케이션 (30점)
   - 어투/톤 적절성 (10점): 공손함, 전문성, 친근함 균형
   - 경청 비율 (10점): 고객 발언 기회 제공, 중간 확인 질문
   - 공감 표현 (10점): 고객 상황에 대한 이해와 공감 표현

2. 상담 구조 (25점)
   - 니즈 파악 질문 (10점): 고객 상황/니즈 파악을 위한 질문 구조
   - 대화 주도권 (8점): 상담 흐름 컨트롤, 방향 유지
   - 클로징 시도 (7점): 다음 단계 제안, 명확한 행동 유도

3. 전문성 (25점)
   - 상품 설명 정확도 (10점): 보험 상품 설명의 정확성과 이해도
   - 비교 설명 (8점): 다른 상품과의 차이점 설명 능력
   - 반론 처리 (7점): 고객 우려/반론에 대한 적절한 대응

4. 컴플라이언스 (20점)
   - 금소법 준수 (10점): 금융소비자보호법 관련 필수 설명 이행
   - 중요사항 설명 (10점): 보장 범위, 면책, 갱신 등 핵심 사항 안내

각 항목을 채점하고 구체적인 코멘트를 달아주세요. 또한 3개 이상의 강점과 3개 이상의 개선 제안을 제공하세요.
설계사마다 상담 스타일이 다를 수 있으므로 절대적 기준보다는 상담 효과성 관점에서 평가하세요.`;

    const aiResponse = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${lovableKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        model: "google/gemini-3-flash-preview",
        messages: [
          { role: "system", content: systemPrompt },
          { role: "user", content: `다음 상담 내용을 평가해주세요:\n\n${transcript}` },
        ],
        tools: [
          {
            type: "function",
            function: {
              name: "evaluate_consultation",
              description: "상담 품질 평가 결과 반환",
              parameters: {
                type: "object",
                properties: {
                  overall_score: { type: "number", description: "총점 (0-100)" },
                  categories: {
                    type: "array",
                    items: {
                      type: "object",
                      properties: {
                        name: { type: "string" },
                        score: { type: "number" },
                        max_score: { type: "number" },
                        items: {
                          type: "array",
                          items: {
                            type: "object",
                            properties: {
                              name: { type: "string" },
                              score: { type: "number" },
                              max: { type: "number" },
                              comment: { type: "string" },
                            },
                            required: ["name", "score", "max", "comment"],
                            additionalProperties: false,
                          },
                        },
                      },
                      required: ["name", "score", "max_score", "items"],
                      additionalProperties: false,
                    },
                  },
                  strengths: { type: "array", items: { type: "string" } },
                  improvements: { type: "array", items: { type: "string" } },
                },
                required: ["overall_score", "categories", "strengths", "improvements"],
                additionalProperties: false,
              },
            },
          },
        ],
        tool_choice: { type: "function", function: { name: "evaluate_consultation" } },
      }),
    });

    if (!aiResponse.ok) {
      if (aiResponse.status === 429) {
        return new Response(JSON.stringify({ error: "AI 요청 한도 초과" }), {
          status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" },
        });
      }
      if (aiResponse.status === 402) {
        return new Response(JSON.stringify({ error: "AI 크레딧 부족" }), {
          status: 402, headers: { ...corsHeaders, "Content-Type": "application/json" },
        });
      }
      throw new Error("AI evaluation failed");
    }

    const aiData = await aiResponse.json();
    const toolCall = aiData.choices?.[0]?.message?.tool_calls?.[0];
    if (!toolCall) throw new Error("No tool call response");

    const result = JSON.parse(toolCall.function.arguments);

    return new Response(JSON.stringify(result), {
      headers: { ...corsHeaders, "Content-Type": "application/json" },
    });

  } catch (e) {
    console.error("evaluate-consultation error:", e);
    return new Response(JSON.stringify({ error: e instanceof Error ? e.message : "Unknown error" }), {
      status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
    });
  }
});


analyze-customer와 evaluate-consultation도 Lovable gateway를 사용합니다. 이들을 ai-provider로 교체하겠습니다.

import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import { callAI } from "../_shared/ai-provider.ts";

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
};

serve(async (req) => {
  if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });

  try {
    const authHeader = req.headers.get("Authorization");
    if (!authHeader) throw new Error("No authorization header");

    const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
    const supabaseKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;

    // Verify user
    const userClient = createClient(supabaseUrl, Deno.env.get("SUPABASE_ANON_KEY")!, {
      global: { headers: { Authorization: authHeader } },
    });
    const { data: { user }, error: authError } = await userClient.auth.getUser();
    if (authError || !user) throw new Error("Unauthorized");

    const { customer_id } = await req.json();
    if (!customer_id) throw new Error("customer_id required");

    const adminClient = createClient(supabaseUrl, supabaseKey);

    // Fetch customer data, notes, conversations, and existing insurance
    const [custRes, notesRes, convsRes, insRes] = await Promise.all([
      adminClient.from("customers").select("*").eq("id", customer_id).eq("agent_id", user.id).maybeSingle(),
      adminClient.from("customer_notes").select("*").eq("customer_id", customer_id).eq("agent_id", user.id).order("created_at", { ascending: false }).limit(20),
      adminClient.from("conversations").select("id").eq("customer_id", customer_id).eq("agent_id", user.id),
      adminClient.from("customer_insurance").select("*").eq("customer_id", customer_id),
    ]);

    if (!custRes.data) throw new Error("Customer not found or unauthorized");
    const customer = custRes.data;

    // Fetch recent messages from all conversations
    let allMessages: { content: string; sender_type: string; created_at: string }[] = [];
    if (convsRes.data && convsRes.data.length > 0) {
      const convIds = convsRes.data.map((c: { id: string }) => c.id);
      const { data: msgs } = await adminClient
        .from("conversation_messages")
        .select("content, sender_type, created_at")
        .in("conversation_id", convIds)
        .order("created_at", { ascending: false })
        .limit(50);
      allMessages = msgs || [];
    }

    // Build context for AI
    const notesText = (notesRes.data || []).map((n: { content: string; created_at: string }) =>
      `[${new Date(n.created_at).toLocaleDateString("ko-KR")}] ${n.content}`
    ).join("\n");

    const messagesText = allMessages.reverse().map((m) =>
      `[${m.sender_type === "agent" ? "설계사" : "고객"}] ${m.content}`
    ).join("\n");

    const insuranceText = (insRes.data || []).map((ins: { company_name: string; product_name: string; coverage_type: string | null; monthly_premium: number | null }) =>
      `${ins.company_name} ${ins.product_name} (${ins.coverage_type || "미분류"}, 월 ${ins.monthly_premium?.toLocaleString() || 0}원)`
    ).join("\n");

    const systemPrompt = `당신은 보험 설계사를 위한 AI 어시스턴트입니다. 고객 정보와 상담 기록을 분석하여 두 가지를 제공합니다:
1. 고객 상황 요약 (3-5문장, 핵심 니즈/상황/다음 단계 포함)
2. 설계사를 위한 할 일 목록 (구체적이고 실행 가능한 액션, 최대 5개)

반드시 아래 JSON 형식으로만 응답하세요. 다른 텍스트는 포함하지 마세요.
{"summary": "요약 내용", "todos": [{"title": "할 일", "priority": "high|medium|low", "due_days": 7}]}`;

    const userPrompt = `## 고객 기본정보
- 이름: ${customer.name}
- 단계: ${customer.stage}
- 연락처: ${customer.phone || "미등록"}
- 직업: ${customer.occupation || "미등록"}
- 관심영역: ${(customer.interest_areas || []).join(", ") || "미등록"}

## 가입 보험
${insuranceText || "없음"}

## 설계사 메모
${notesText || "없음"}

## 최근 대화
${messagesText || "없음"}

위 정보를 바탕으로 분석해주세요. JSON 형식으로만 응답하세요.`;

    // AI 호출 (기본 provider 사용)
    const result = await callAI(
      "gemini-2.0-flash-lite",
      systemPrompt,
      [{ role: "user", content: userPrompt }],
      { stream: false, maxTokens: 1024 }
    );

    if (!result.ok) {
      console.error("AI call error:", result.error);

      if (result.error?.includes("429")) {
        return new Response(JSON.stringify({ error: "AI 요청 한도 초과. 잠시 후 다시 시도해주세요." }), {
          status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" },
        });
      }
      if (result.error?.includes("402") || result.error?.includes("quota")) {
        return new Response(JSON.stringify({ error: "AI 크레딧이 부족합니다." }), {
          status: 402, headers: { ...corsHeaders, "Content-Type": "application/json" },
        });
      }

      throw new Error("AI analysis failed");
    }

    // JSON 파싱
    let parsedResult;
    try {
      // ```json 블록에서 추출 시도
      const jsonMatch = result.content?.match(/```json\s*([\s\S]*?)\s*```/);
      if (jsonMatch) {
        parsedResult = JSON.parse(jsonMatch[1]);
      } else {
        parsedResult = JSON.parse(result.content || "{}");
      }
    } catch {
      // 파싱 실패 시 기본값
      parsedResult = {
        summary: result.content || "분석 실패",
        todos: [],
      };
    }

    // Update customer ai_summary
    await adminClient.from("customers").update({ ai_summary: parsedResult.summary }).eq("id", customer_id);

    // Save AI summary history
    await adminClient.from("customer_ai_summaries").insert({
      customer_id,
      agent_id: user.id,
      summary: parsedResult.summary,
      trigger_type: "manual",
    });

    // Create AI-generated todos
    if (parsedResult.todos && parsedResult.todos.length > 0) {
      const todosToInsert = parsedResult.todos.map((todo: { title: string; priority: string; due_days?: number }) => {
        const dueDate = todo.due_days !== undefined
          ? new Date(Date.now() + todo.due_days * 86400000).toISOString().split("T")[0]
          : null;
        return {
          agent_id: user.id,
          customer_id,
          title: todo.title,
          priority: todo.priority,
          due_date: dueDate,
          source: "ai",
        };
      });
      await adminClient.from("agent_todos").insert(todosToInsert);
    }

    return new Response(JSON.stringify({
      summary: parsedResult.summary,
      todos_created: parsedResult.todos?.length || 0,
    }), {
      headers: { ...corsHeaders, "Content-Type": "application/json" },
    });

  } catch (e) {
    console.error("analyze-customer error:", e);
    return new Response(JSON.stringify({ error: e instanceof Error ? e.message : "Unknown error" }), {
      status: 500,
      headers: { ...corsHeaders, "Content-Type": "application/json" },
    });
  }
});


Successfully wrote 6766 bytes to /home/jay/projects/InsuRo/supabase/functions/analyze-customer/index.ts


analyze-customer를 수정했습니다. 이제 evaluate-consultation을 수정하겠습니다.

import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import { callAI } from "../_shared/ai-provider.ts";

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
};

serve(async (req) => {
  if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });

  try {
    const authHeader = req.headers.get("Authorization");
    if (!authHeader) throw new Error("No authorization header");

    const userClient = createClient(
      Deno.env.get("SUPABASE_URL")!,
      Deno.env.get("SUPABASE_ANON_KEY")!,
      { global: { headers: { Authorization: authHeader } } }
    );
    const { data: { user }, error: authError } = await userClient.auth.getUser();
    if (authError || !user) throw new Error("Unauthorized");

    const { customer_id, transcript } = await req.json();
    if (!customer_id || !transcript) throw new Error("customer_id and transcript required");

    const systemPrompt = `당신은 보험 상담 품질 평가 전문 AI입니다. 설계사의 상담 내용을 분석하여 구조화된 평가를 제공합니다.

평가는 4개 카테고리, 각 카테고리 2~3개 세부 항목으로 구성됩니다:

1. 커뮤니케이션 (30점)
   - 어투/톤 적절성 (10점): 공손함, 전문성, 친근함 균형
   - 경청 비율 (10점): 고객 발언 기회 제공, 중간 확인 질문
   - 공감 표현 (10점): 고객 상황에 대한 이해와 공감 표현

2. 상담 구조 (25점)
   - 니즈 파악 질문 (10점): 고객 상황/니즈 파악을 위한 질문 구조
   - 대화 주도권 (8점): 상담 흐름 컨트롤, 방향 유지
   - 클로징 시도 (7점): 다음 단계 제안, 명확한 행동 유도

3. 전문성 (25점)
   - 상품 설명 정확도 (10점): 보험 상품 설명의 정확성과 이해도
   - 비교 설명 (8점): 다른 상품과의 차이점 설명 능력
   - 반론 처리 (7점): 고객 우려/반론에 대한 적절한 대응

4. 컴플라이언스 (20점)
   - 금소법 준수 (10점): 금융소비자보호법 관련 필수 설명 이행
   - 중요사항 설명 (10점): 보장 범위, 면책, 갱신 등 핵심 사항 안내

각 항목을 채점하고 구체적인 코멘트를 달아주세요. 또한 3개 이상의 강점과 3개 이상의 개선 제안을 제공하세요.
설계사마다 상담 스타일이 다를 수 있으므로 절대적 기준보다는 상담 효과성 관점에서 평가하세요.

반드시 아래 JSON 형식으로만 응답하세요:
{
  "overall_score": 0-100,
  "categories": [
    {
      "name": "카테고리명",
      "score": 0,
      "max_score": 30,
      "items": [
        {"name": "항목명", "score": 0, "max": 10, "comment": "코멘트"}
      ]
    }
  ],
  "strengths": ["강점1", "강점2", "강점3"],
  "improvements": ["개선1", "개선2", "개선3"]
}`;

    // AI 호출 (기본 provider 사용)
    const result = await callAI(
      "gemini-2.0-flash-lite",
      systemPrompt,
      [{ role: "user", content: `다음 상담 내용을 평가해주세요:\n\n${transcript}` }],
      { stream: false, maxTokens: 2048 }
    );

    if (!result.ok) {
      console.error("AI call error:", result.error);

      if (result.error?.includes("429")) {
        return new Response(JSON.stringify({ error: "AI 요청 한도 초과" }), {
          status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" },
        });
      }
      if (result.error?.includes("402") || result.error?.includes("quota")) {
        return new Response(JSON.stringify({ error: "AI 크레딧 부족" }), {
          status: 402, headers: { ...corsHeaders, "Content-Type": "application/json" },
        });
      }

      throw new Error("AI evaluation failed");
    }

    // JSON 파싱
    let parsedResult;
    try {
      const jsonMatch = result.content?.match(/```json\s*([\s\S]*?)\s*```/);
      if (jsonMatch) {
        parsedResult = JSON.parse(jsonMatch[1]);
      } else {
        parsedResult = JSON.parse(result.content || "{}");
      }
    } catch {
      throw new Error("AI response parsing failed");
    }

    return new Response(JSON.stringify(parsedResult), {
      headers: { ...corsHeaders, "Content-Type": "application/json" },
    });

  } catch (e) {
    console.error("evaluate-consultation error:", e);
    return new Response(JSON.stringify({ error: e instanceof Error ? e.message : "Unknown error" }), {
      status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
    });
  }
});


Successfully wrote 3874 bytes to /home/jay/projects/InsuRo/supabase/functions/evaluate-consultation/index.ts


analyze-customer와 evaluate-consultation을 수정했습니다. 이제 analyze-performance를 확인하고 수정하겠습니다.

이제 analyze-performance를 확인하겠습니다.


import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
};

serve(async (req) => {
  if (req.method === "OPTIONS") {
    return new Response(null, { headers: corsHeaders });
  }

  try {
    const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
    const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
    const sb = createClient(supabaseUrl, supabaseServiceKey);

    // Auth check
    const authHeader = req.headers.get("Authorization");
    if (!authHeader) throw new Error("인증이 필요합니다");

    const anonClient = createClient(supabaseUrl, Deno.env.get("SUPABASE_ANON_KEY")!);
    const token = authHeader.replace("Bearer ", "");
    const { data: claimsData } = await anonClient.auth.getClaims(token);
    const userId = claimsData?.claims?.sub;
    if (!userId) throw new Error("사용자 인증 실패");

    // Fetch user's contents and metrics
    const [{ data: contents }, { data: metrics }] = await Promise.all([
      sb.from("contents").select("*").eq("user_id", userId).order("created_at", { ascending: false }),
      sb.from("content_metrics").select("*").eq("user_id", userId),
    ]);

    if (!contents || contents.length === 0) {
      return new Response(JSON.stringify({
        report: "아직 생성된 콘텐츠가 없습니다. 콘텐츠를 생성한 후 분석을 시도해주세요.",
        stats: null,
      }), {
        headers: { ...corsHeaders, "Content-Type": "application/json" },
      });
    }

    // Build stats summary
    const totalContents = contents.length;
    const typeDistribution: Record<string, number> = {};
    contents.forEach((c: any) => {
      typeDistribution[c.content_type] = (typeDistribution[c.content_type] || 0) + 1;
    });

    const totalViews = (metrics || []).reduce((s: number, m: any) => s + (m.views || 0), 0);
    const totalLikes = (metrics || []).reduce((s: number, m: any) => s + (m.likes || 0), 0);
    const avgViews = metrics && metrics.length > 0 ? Math.round(totalViews / metrics.length) : 0;

    // Top performing contents
    const topContents = (metrics || [])
      .sort((a: any, b: any) => (b.views || 0) - (a.views || 0))
      .slice(0, 5)
      .map((m: any) => {
        const content = contents.find((c: any) => c.id === m.content_id);
        return { title: content?.title || "Unknown", views: m.views, likes: m.likes };
      });

    // Recent 30 days trend
    const thirtyDaysAgo = new Date();
    thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
    const recentContents = contents.filter((c: any) => new Date(c.created_at) >= thirtyDaysAgo).length;

    const statsText = `
[사용자 콘텐츠 성과 데이터]
- 총 콘텐츠: ${totalContents}개
- 최근 30일 생성: ${recentContents}개
- 유형별: ${Object.entries(typeDistribution).map(([k, v]) => `${k}(${v}개)`).join(", ")}
- 총 조회수: ${totalViews.toLocaleString()}
- 총 좋아요: ${totalLikes.toLocaleString()}
- 평균 조회수: ${avgViews.toLocaleString()}
- 상위 콘텐츠: ${topContents.map((t: any) => `${t.title}(조회수 ${t.views})`).join(", ") || "데이터 없음"}
`;

    // Call AI for analysis
    const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY");
    if (!LOVABLE_API_KEY) throw new Error("AI API key not configured");

    const aiResp = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${LOVABLE_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        model: "google/gemini-3-flash-preview",
        messages: [
          {
            role: "system",
            content: `당신은 보험 마케팅 콘텐츠 성과 분석 전문가입니다. 아래 데이터를 분석하여 한국어로 전략 리포트를 작성하세요.

리포트 구조:
1. 📊 성과 요약 (2-3줄)
2. 🏆 잘한 점 (2-3개)
3. ⚠ 개선 포인트 (2-3개)
4. 💡 다음 주 실행 전략 (3-4개 구체적 액션 아이템)
5. 🎯 추천 콘텐츠 주제 (3개, 이유 포함)

보험 설계사 관점에서 실무에 바로 적용 가능한 조언을 해주세요. 과장하지 말고 데이터에 기반해서 솔직하게 분석하세요.`,
          },
          { role: "user", content: statsText },
        ],
      }),
    });

    if (!aiResp.ok) {
      if (aiResp.status === 429) {
        return new Response(JSON.stringify({ error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." }), {
          status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" },
        });
      }
      if (aiResp.status === 402) {
        return new Response(JSON.stringify({ error: "크레딧이 부족합니다." }), {
          status: 402, headers: { ...corsHeaders, "Content-Type": "application/json" },
        });
      }
      const errText = await aiResp.text();
      console.error("AI error:", aiResp.status, errText);
      throw new Error("AI 분석 실패");
    }

    const aiData = await aiResp.json();
    const report = aiData.choices?.[0]?.message?.content || "분석 결과를 생성할 수 없습니다.";

    return new Response(JSON.stringify({
      report,
      stats: {
        totalContents,
        recentContents,
        totalViews,
        totalLikes,
        avgViews,
        typeDistribution,
        topContents,
      },
    }), {
      headers: { ...corsHeaders, "Content-Type": "application/json" },
    });
  } catch (error) {
    console.error("Analyze error:", error);
    return new Response(JSON.stringify({ error: error.message }), {
      status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
    });
  }
});


analyze-performance는 Lovable의 ai.gateway 대신 LOVABLE_API_KEY를 사용하여 직접 Lovable의 ai.gateway.lovable.dev를 호출합니다. 이것도 ai-provider로 교체해야 합니다.

analyze-performance도 Lovable gateway를 사용합니다. 수정하겠습니다.

import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import { callAI } from "../_shared/ai-provider.ts";

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
};

serve(async (req) => {
  if (req.method === "OPTIONS") {
    return new Response(null, { headers: corsHeaders });
  }

  try {
    const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
    const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
    const sb = createClient(supabaseUrl, supabaseServiceKey);

    // Auth check
    const authHeader = req.headers.get("Authorization");
    if (!authHeader) throw new Error("인증이 필요합니다");

    const anonClient = createClient(supabaseUrl, Deno.env.get("SUPABASE_ANON_KEY")!);
    const token = authHeader.replace("Bearer ", "");
    const { data: claimsData } = await anonClient.auth.getClaims(token);
    const userId = claimsData?.claims?.sub;
    if (!userId) throw new Error("사용자 인증 실패");

    // Fetch user's contents and metrics
    const [{ data: contents }, { data: metrics }] = await Promise.all([
      sb.from("contents").select("*").eq("user_id", userId).order("created_at", { ascending: false }),
      sb.from("content_metrics").select("*").eq("user_id", userId),
    ]);

    if (!contents || contents.length === 0) {
      return new Response(JSON.stringify({
        report: "아직 생성된 콘텐츠가 없습니다. 콘텐츠를 생성한 후 분석을 시도해주세요.",
        stats: null,
      }), {
        headers: { ...corsHeaders, "Content-Type": "application/json" },
      });
    }

    // Build stats summary
    const totalContents = contents.length;
    const typeDistribution: Record<string, number> = {};
    contents.forEach((c: any) => {
      typeDistribution[c.content_type] = (typeDistribution[c.content_type] || 0) + 1;
    });

    const totalViews = (metrics || []).reduce((s: number, m: any) => s + (m.views || 0), 0);
    const totalLikes = (metrics || []).reduce((s: number, m: any) => s + (m.likes || 0), 0);
    const avgViews = metrics && metrics.length > 0 ? Math.round(totalViews / metrics.length) : 0;

    // Top performing contents
    const topContents = (metrics || [])
      .sort((a: any, b: any) => (b.views || 0) - (a.views || 0))
      .slice(0, 5)
      .map((m: any) => {
        const content = contents.find((c: any) => c.id === m.content_id);
        return { title: content?.title || "Unknown", views: m.views, likes: m.likes };
      });

    // Recent 30 days trend
    const thirtyDaysAgo = new Date();
    thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
    const recentContents = contents.filter((c: any) => new Date(c.created_at) >= thirtyDaysAgo).length;

    const statsText = `
[사용자 콘텐츠 성과 데이터]
- 총 콘텐츠: ${totalContents}개
- 최근 30일 생성: ${recentContents}개
- 유형별: ${Object.entries(typeDistribution).map(([k, v]) => `${k}(${v}개)`).join(", ")}
- 총 조회수: ${totalViews.toLocaleString()}
- 총 좋아요: ${totalLikes.toLocaleString()}
- 평균 조회수: ${avgViews.toLocaleString()}
- 상위 콘텐츠: ${topContents.map((t: any) => `${t.title}(조회수 ${t.views})`).join(", ") || "데이터 없음"}
`;

    const systemPrompt = `당신은 보험 마케팅 콘텐츠 성과 분석 전문가입니다. 아래 데이터를 분석하여 한국어로 전략 리포트를 작성하세요.

리포트 구조:
1. 📊 성과 요약 (2-3줄)
2. 🏆 잘한 점 (2-3개)
3. ⚠ 개선 포인트 (2-3개)
4. 💡 다음 주 실행 전략 (3-4개 구체적 액션 아이템)
5. 🎯 추천 콘텐츠 주제 (3개, 이유 포함)

보험 설계사 관점에서 실무에 바로 적용 가능한 조언을 해주세요. 과장하지 말고 데이터에 기반해서 솔직하게 분석하세요.`;

    // AI 호출 (기본 provider 사용)
    const result = await callAI(
      "gemini-2.0-flash-lite",
      systemPrompt,
      [{ role: "user", content: statsText }],
      { stream: false, maxTokens: 2048 }
    );

    if (!result.ok) {
      console.error("AI call error:", result.error);

      if (result.error?.includes("429")) {
        return new Response(JSON.stringify({ error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." }), {
          status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" },
        });
      }
      if (result.error?.includes("402") || result.error?.includes("quota")) {
        return new Response(JSON.stringify({ error: "크레딧이 부족합니다." }), {
          status: 402, headers: { ...corsHeaders, "Content-Type": "application/json" },
        });
      }

      throw new Error("AI 분석 실패");
    }

    const report = result.content || "분석 결과를 생성할 수 없습니다.";

    return new Response(JSON.stringify({
      report,
      stats: {
        totalContents,
        recentContents,
        totalViews,
        totalLikes,
        avgViews,
        typeDistribution,
        topContents,
      },
    }), {
      headers: { ...corsHeaders, "Content-Type": "application/json" },
    });
  } catch (error) {
    console.error("Analyze error:", error);
    return new Response(JSON.stringify({ error: error instanceof Error ? error.message : "Unknown error" }), {
      status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
    });
  }
});


Successfully wrote 5092 bytes to /home/jay/projects/InsuRo/supabase/functions/analyze-performance/index.ts


주요 Edge Functions들을 수정했습니다. 이제 나머지 3개(transcribe-call, parse-premium-file, generate-image)를 확인해야 합니다.

주요 Edge Functions들을 수정했습니다. 나머지를 확인하겠습니다.

exec

grep -l "ai.gateway.lovable" /home/jay/projects/InsuRo/supabase/functions/*/index.ts 2>/dev/null


4개 더 있습니다. generate-content, generate-image, parse-premium-file, transcribe-call입니다. 이들을 확인하고 수정해야 합니다.

4개의 함수가 더 있습니다. 이들을 확인하겠습니다.


import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
};

serve(async (req) => {
  if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });

  try {
    const authHeader = req.headers.get("Authorization");
    if (!authHeader) throw new Error("No authorization header");

    const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
    const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
    const lovableKey = Deno.env.get("LOVABLE_API_KEY");
    if (!lovableKey) throw new Error("LOVABLE_API_KEY not configured");

    const userClient = createClient(supabaseUrl, Deno.env.get("SUPABASE_ANON_KEY")!, {
      global: { headers: { Authorization: authHeader } },
    });
    const { data: { user }, error: authError } = await userClient.auth.getUser();
    if (authError || !user) throw new Error("Unauthorized");

    const formData = await req.formData();
    const audioFile = formData.get("audio") as File | null;
    const customerId = formData.get("customer_id") as string;
    const manualTranscript = formData.get("transcript") as string | null;

    if (!customerId) throw new Error("customer_id required");

    const adminClient = createClient(supabaseUrl, serviceKey);

    // Verify customer ownership
    const { data: customer } = await adminClient.from("customers")
      .select("id, name, stage, occupation, interest_areas")
      .eq("id", customerId).eq("agent_id", user.id).maybeSingle();
    if (!customer) throw new Error("Customer not found");

    let transcript = manualTranscript || "";
    let audioUrl: string | null = null;

    // If audio file provided, upload to storage
    if (audioFile) {
      const fileName = `${user.id}/${customerId}_${Date.now()}.${audioFile.name.split(".").pop()}`;
      const { error: uploadErr } = await adminClient.storage
        .from("call-recordings")
        .upload(fileName, audioFile, { contentType: audioFile.type });
      if (uploadErr) throw new Error("Audio upload failed: " + uploadErr.message);

      const { data: urlData } = adminClient.storage.from("call-recordings").getPublicUrl(fileName);
      audioUrl = urlData.publicUrl;

      // If no manual transcript, use AI to describe that we have an audio file
      // (Real STT would use ElevenLabs or similar - for now we note audio was uploaded)
      if (!transcript) {
        transcript = "[음성 녹음 파일 업로드됨 - 텍스트 변환 대기중]";
      }
    }

    if (!transcript && !audioFile) throw new Error("녹음 파일이나 통화 내용 텍스트를 입력해주세요.");

    // AI analysis of the transcript
    const aiResponse = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${lovableKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        model: "google/gemini-3-flash-preview",
        messages: [
          {
            role: "system",
            content: `당신은 보험 설계사를 위한 통화 분석 AI입니다. 통화 내용을 분석하여 구조화된 요약과 후속 할 일을 생성합니다.`,
          },
          {
            role: "user",
            content: `## 고객 정보
- 이름: ${customer.name}
- 단계: ${customer.stage}
- 직업: ${customer.occupation || "미등록"}
- 관심영역: ${(customer.interest_areas || []).join(", ") || "미등록"}

## 통화 내용
${transcript}

위 통화 내용을 분석해주세요.`,
          },
        ],
        tools: [
          {
            type: "function",
            function: {
              name: "analyze_call",
              description: "통화 내용 분석 결과 반환",
              parameters: {
                type: "object",
                properties: {
                  summary: { type: "string", description: "통화 요약 (3-5문장, 핵심 내용/고객 반응/합의사항)" },
                  key_points: {
                    type: "array",
                    items: { type: "string" },
                    description: "핵심 포인트 목록",
                  },
                  todos: {
                    type: "array",
                    items: {
                      type: "object",
                      properties: {
                        title: { type: "string" },
                        priority: { type: "string", enum: ["high", "medium", "low"] },
                        due_days: { type: "number" },
                      },
                      required: ["title", "priority"],
                      additionalProperties: false,
                    },
                  },
                  customer_mood: { type: "string", enum: ["positive", "neutral", "negative", "concerned"], description: "고객 감정/분위기" },
                },
                required: ["summary", "key_points", "todos", "customer_mood"],
                additionalProperties: false,
              },
            },
          },
        ],
        tool_choice: { type: "function", function: { name: "analyze_call" } },
      }),
    });

    if (!aiResponse.ok) {
      if (aiResponse.status === 429) {
        return new Response(JSON.stringify({ error: "AI 요청 한도 초과. 잠시 후 다시 시도해주세요." }), {
          status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" },
        });
      }
      if (aiResponse.status === 402) {
        return new Response(JSON.stringify({ error: "AI 크레딧이 부족합니다." }), {
          status: 402, headers: { ...corsHeaders, "Content-Type": "application/json" },
        });
      }
      throw new Error("AI analysis failed");
    }

    const aiData = await aiResponse.json();
    const toolCall = aiData.choices?.[0]?.message?.tool_calls?.[0];
    if (!toolCall) throw new Error("No tool call response");
    const result = JSON.parse(toolCall.function.arguments);

    // Save call log
    await adminClient.from("customer_call_logs").insert({
      customer_id: customerId,
      agent_id: user.id,
      audio_url: audioUrl,
      transcript,
      ai_summary: result.summary,
    });

    // Create AI todos
    if (result.todos?.length > 0) {
      const todosToInsert = result.todos.map((t: { title: string; priority: string; due_days?: number }) => ({
        agent_id: user.id,
        customer_id: customerId,
        title: t.title,
        priority: t.priority,
        due_date: t.due_days !== undefined
          ? new Date(Date.now() + t.due_days * 86400000).toISOString().split("T")[0]
          : null,
        source: "ai",
      }));
      await adminClient.from("agent_todos").insert(todosToInsert);
    }

    // Update customer AI summary with call context
    await adminClient.from("customers").update({ ai_summary: result.summary }).eq("id", customerId);

    return new Response(JSON.stringify({
      summary: result.summary,
      key_points: result.key_points,
      customer_mood: result.customer_mood,
      todos_created: result.todos?.length || 0,
    }), {
      headers: { ...corsHeaders, "Content-Type": "application/json" },
    });

  } catch (e) {
    console.error("transcribe-call error:", e);
    return new Response(JSON.stringify({ error: e instanceof Error ? e.message : "Unknown error" }), {
      status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
    });
  }
});


import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { unzipSync } from "npm:fflate@0.8.2";

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers":
    "authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
};

// Extract text from PPTX (ZIP of XMLs) using fflate
function extractTextFromPptx(fileBytes: Uint8Array): string {
  try {
    const unzipped = unzipSync(fileBytes);
    const slideTexts: string[] = [];
    const slideFiles: string[] = [];

    // Collect slide file names
    for (const filename of Object.keys(unzipped)) {
      if (filename.match(/^ppt\/slides\/slide\d+\.xml$/)) {
        slideFiles.push(filename);
      }
    }

    console.log("Found slide files:", slideFiles.length, slideFiles);

    // Sort by slide number
    slideFiles.sort((a, b) => {
      const numA = parseInt(a.match(/slide(\d+)/)?.[1] || "0");
      const numB = parseInt(b.match(/slide(\d+)/)?.[1] || "0");
      return numA - numB;
    });

    const decoder = new TextDecoder("utf-8");

    for (const filename of slideFiles) {
      const content = decoder.decode(unzipped[filename]);
      if (!content) continue;

      const slideNum = filename.match(/slide(\d+)/)?.[1] || "?";
      // Extract text between <a:t> tags
      const texts: string[] = [];
      const regex = /<a:t>([^<]*)<\/a:t>/g;
      let match;
      while ((match = regex.exec(content)) !== null) {
        if (match[1].trim()) texts.push(match[1]);
      }

      if (texts.length > 0) {
        slideTexts.push(`--- 슬라이드 ${slideNum} ---\n${texts.join(" ")}`);
      }
    }

    return slideTexts.join("\n\n");
  } catch (e) {
    console.error("PPTX extraction error:", e);
    return "";
  }
}

serve(async (req) => {
  if (req.method === "OPTIONS") {
    return new Response(null, { headers: corsHeaders });
  }

  try {
    const { file_url, record_id, file_type, original_filename } = await req.json();

    if (!file_url || !record_id) {
      return new Response(
        JSON.stringify({ error: "file_url and record_id are required" }),
        { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
      );
    }

    const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY");
    if (!LOVABLE_API_KEY) throw new Error("LOVABLE_API_KEY is not configured");

    const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
    const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;

    // Download the file
    console.log("Downloading file:", file_url);
    const fileResp = await fetch(file_url);
    if (!fileResp.ok) throw new Error(`Failed to download file: ${fileResp.status}`);
    const fileBuffer = await fileResp.arrayBuffer();
    const fileBytes = new Uint8Array(fileBuffer);

    const ext = file_url.split(".").pop()?.toLowerCase() || "";
    const isPdf = ext === "pdf";
    const isPptx = ext === "pptx" || ext === "ppt";
    const isNewsletter = file_type === "newsletter";

    console.log(`File type: ${ext}, size: ${fileBytes.length}, isPdf: ${isPdf}, isPptx: ${isPptx}`);

    // Build AI messages based on file type
    let userContent: any[];
    let fullExtractedText = "";

    if (isPdf) {
      // PDF: send as base64 image_url (Gemini supports application/pdf)

[242 more lines in file. Use offset=101 to continue.]


transcribe-call과 parse-premium-file도 Lovable gateway를 사용합니다. 이들을 ai-provider로 교체하겠습니다.

transcribe-call을 수정하겠습니다.

write

import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import { callAI } from "../_shared/ai-provider.ts";

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
};

serve(async (req) => {
  if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });

  try {
    const authHeader = req.headers.get("Authorization");
    if (!authHeader) throw new Error("No authorization header");

    const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
    const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;

    const userClient = createClient(supabaseUrl, Deno.env.get("SUPABASE_ANON_KEY")!, {
      global: { headers: { Authorization: authHeader } },
    });
    const { data: { user }, error: authError } = await userClient.auth.getUser();
    if (authError || !user) throw new Error("Unauthorized");

    const formData = await req.formData();
    const audioFile = formData.get("audio") as File | null;
    const customerId = formData.get("customer_id") as string;
    const manualTranscript = formData.get("transcript") as string | null;

    if (!customerId) throw new Error("customer_id required");

    const adminClient = createClient(supabaseUrl, serviceKey);

    // Verify customer ownership
    const { data: customer } = await adminClient.from("customers")
      .select("id, name, stage, occupation, interest_areas")
      .eq("id", customerId).eq("agent_id", user.id).maybeSingle();
    if (!customer) throw new Error("Customer not found");

    let transcript = manualTranscript || "";
    let audioUrl: string | null = null;

    // If audio file provided, upload to storage
    if (audioFile) {
      const fileName = `${user.id}/${customerId}_${Date.now()}.${audioFile.name.split(".").pop()}`;
      const { error: uploadErr } = await adminClient.storage
        .from("call-recordings")
        .upload(fileName, audioFile, { contentType: audioFile.type });
      if (uploadErr) throw new Error("Audio upload failed: " + uploadErr.message);

      const { data: urlData } = adminClient.storage.from("call-recordings").getPublicUrl(fileName);
      audioUrl = urlData.publicUrl;

      // If no manual transcript, note audio was uploaded
      if (!transcript) {
        transcript = "[음성 녹음 파일 업로드됨 - 텍스트 변환 대기중]";
      }
    }

    if (!transcript && !audioFile) throw new Error("녹음 파일이나 통화 내용 텍스트를 입력해주세요.");

    // AI analysis of the transcript
    const systemPrompt = `당신은 보험 설계사를 위한 통화 분석 AI입니다. 통화 내용을 분석하여 다음 JSON 형식으로 결과를 반환하세요:
{
  "summary": "통화 요약 (3-5문장, 핵심 내용/고객 반응/합의사항)",
  "key_points": ["핵심 포인트 1", "핵심 포인트 2"],
  "todos": [
    {"title": "할 일", "priority": "high|medium|low", "due_days": 7}
  ],
  "customer_mood": "positive|neutral|negative|concerned"
}

JSON 형식으로만 응답하세요.`;

    const userPrompt = `## 고객 정보
- 이름: ${customer.name}
- 단계: ${customer.stage}
- 직업: ${customer.occupation || "미등록"}
- 관심영역: ${(customer.interest_areas || []).join(", ") || "미등록"}

## 통화 내용
${transcript}

위 통화 내용을 분석해주세요. JSON 형식으로만 응답하세요.`;

    // AI 호출 (기본 provider 사용)
    const result = await callAI(
      "gemini-2.0-flash-lite",
      systemPrompt,
      [{ role: "user", content: userPrompt }],
      { stream: false, maxTokens: 2048 }
    );

    if (!result.ok) {
      console.error("AI call error:", result.error);

      if (result.error?.includes("429")) {
        return new Response(JSON.stringify({ error: "AI 요청 한도 초과. 잠시 후 다시 시도해주세요." }), {
          status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" },
        });
      }
      if (result.error?.includes("402") || result.error?.includes("quota")) {
        return new Response(JSON.stringify({ error: "AI 크레딧이 부족합니다." }), {
          status: 402, headers: { ...corsHeaders, "Content-Type": "application/json" },
        });
      }

      throw new Error("AI analysis failed");
    }

    // JSON 파싱
    let analysisResult;
    try {
      const jsonMatch = result.content?.match(/```json\s*([\s\S]*?)\s*```/);
      if (jsonMatch) {
        analysisResult = JSON.parse(jsonMatch[1]);
      } else {
        analysisResult = JSON.parse(result.content || "{}");
      }
    } catch {
      analysisResult = {
        summary: result.content || "분석 실패",
        key_points: [],
        todos: [],
        customer_mood: "neutral",
      };
    }

    // Save call log
    await adminClient.from("customer_call_logs").insert({
      customer_id: customerId,
      agent_id: user.id,
      audio_url: audioUrl,
      transcript,
      ai_summary: analysisResult.summary,
    });

    // Create AI todos
    if (analysisResult.todos?.length > 0) {
      const todosToInsert = analysisResult.todos.map((t: { title: string; priority: string; due_days?: number }) => ({
        agent_id: user.id,
        customer_id: customerId,
        title: t.title,
        priority: t.priority,
        due_date: t.due_days !== undefined
          ? new Date(Date.now() + t.due_days * 86400000).toISOString().split("T")[0]
          : null,
        source: "ai",
      }));
      await adminClient.from("agent_todos").insert(todosToInsert);
    }

    // Update customer AI summary with call context
    await adminClient.from("customers").update({ ai_summary: analysisResult.summary }).eq("id", customerId);

    return new Response(JSON.stringify({
      summary: analysisResult.summary,
      key_points: analysisResult.key_points,
      customer_mood: analysisResult.customer_mood,
      todos_created: analysisResult.todos?.length || 0,
    }), {
      headers: { ...corsHeaders, "Content-Type": "application/json" },
    });

  } catch (e) {
    console.error("transcribe-call error:", e);
    return new Response(JSON.stringify({ error: e instanceof Error ? e.message : "Unknown error" }), {
      status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
    });
  }
});


Successfully wrote 6138 bytes to /home/jay/projects/InsuRo/supabase/functions/transcribe-call/index.ts


transcribe-call을 수정했습니다. parse-premium-file과 generate-image, generate-content도 수정해야 합니다. generate-content는 이미 ai_config 기반이므로 넘어가고, parse-premium-file과 generate-image를 확인하겠습니다.

이제 parse-premium-file을 수정하겠습니다.