본문 바로가기

코딩

SchedAI 개발기 ep 8. Google calendar와 task 연결하기 : Nextjs + AISDK

반응형

저번에 구글 캘린더와 Task API를 직접적인 방법으로 연동했었다. 대시보드를 만들고, 벡엔드에 API를 연동해서 폼에 알맞는 정보를 입력하고 제출하면 구글캘린더에서 CRUD시스템이 작동 될수 있도록 구현했었다. 궁금하면 아래 URL에서 확인해보면 된다. 

 

SchedAI 개발기 ep 5. Nextjs14) GoogleCalendar에 Event 추가하기

저번까지 google calendar api와 auth5를 연동하는 것을 했었다. 그리고 openai api와도 연동해서 가장 기본적인 챗봇을 만드는 과정까지 했었다. 이번엔 구글 캘린더와 AI를 연동할 차례다.먼저 코드 흐름

eliclosetshop.tistory.com

구글 캘린더와 Task를 연동해보는 것이 처음이라서, 그리고, API연동자체가 익숙하지 않기때문에 한단계한단계해보면서 진행했다. 바로 AI와 연동하면 좋았겠지만 api를 가장 쉬운 방법으로 먼저 연동해보고 테스트해본후 연결하기로 계획하고 진행했기 때문에 저런 쓸데없는 코드를 만들었다. (나중에 활용할 일이 있을 수도?)암튼 위에서 만들었던 코드들은 앞에의 이유료 주석처리되었다. ㅠㅠ

서론은 여기까지 하고 이제 코딩하자.

AISDK에서 API를 연동시킬수 있는 방법이라고 하면 크게 3가지 정도있다.(내가 생각해본거, 혹시 추가적인 방법이 있다면 알려주셈)

첫번째는, 프롬프팅을 통해서(특히 시스템 프롬프트) 항상 같은 방식으로 값이 나오게 하는 것이다. 가장 원초적인 방법. string으로 받아와서 파싱 ㅋㅋ

두번째는, useObject를 사용하는 것이다.

세번째는, 내가 사용한 방법인 Tool을 사용하는 것이다. 


1. 프롬프팅을 통해 값이 나오게 하기

generateText와 streamText에는 system:""이라고 해서 시스템 프롬프트를 입력하는 란이 있다. 

여기에 항상 아래형식으로 나오게 해줘 + json(api요청 및 응답) 이런식으로 시스템 프롬프트를 작성하면 된다.

const result = await generateText({
  model: yourModel,
  system:
    `You help planning travel itineraries. ` +
    `Respond to the users' request with a list ` +
    `of the best stops to make in their destination.`,
  prompt:
    `I am planning a trip to ${destination} for ${lengthOfStay} days. ` +
    `Please suggest the best tourist activities for me to do.`,
});

사용해본 결과는 json이 나름 괜찮게 잘 뽑혔다. 하지만 오브젝트를 파싱해서 사용하고 하는 것이 비효율적(

대화(ex. 내일오후 2시에 달리기 일정추가해줘.) -> AI가 json데이터 생성 -> 응답결과출력 -> AI에게 결과알려줌(generateText) -> AI가 결과응답. 한번의 대화는 x2가 된다. 

)이고 나름 AISDK가 가지고있는 기능들이 있으니 활용해보고 싶었다. 그래서 컷.

 

2. useObject & stramObject사용하기

AISDK에는 useObject라는게 있다. 원래 처음엔 이것을 사용해서 API연동을 구현하려고 했었다. 그러나 기억이 가물가물하긴한데 useObject가 experimental이라서 그런지 자잘한 오류들이 너무나 많이 떠서 실질적으로 구현하기가 어려웠다. 이 Hook이 조금 더 개발이 잘 된다면 여러 곳에 사용하기 좋을 것 같다. 한 번 참여해볼까?

Use Object

'use client';

import { experimental_useObject as useObject } from '@ai-sdk/react';

export default function Page() {
  const { object, submit } = useObject({
    api: '/api/use-object',
    schema: z.object({ content: z.string() }),
  });

  return (
    <div>
      <button onClick={() => submit('example input')}>Generate</button>
      {object?.content && <p>{object.content}</p>}
    </div>
  );
}

Stream Object

import { openai } from '@ai-sdk/openai';
import { streamObject } from 'ai';
import { z } from 'zod';

const { elementStream } = streamObject({
  model: openai('gpt-4-turbo'),
  output: 'array',
  schema: z.object({
    name: z.string(),
    class: z
      .string()
      .describe('Character class, e.g. warrior, mage, or thief.'),
    description: z.string(),
  }),
  prompt: 'Generate 3 hero descriptions for a fantasy role playing game.',
});

for await (const hero of elementStream) {
  console.log(hero);
}

여기서부터 이제 zod라는 개념이 나온다. zod는 타입스크립트 기반으로 object의 타입을 특정해주고 스키마를 검증하주는 라이브러리이다. AISDK에서는 AI와 효과적으로 소통하기 위해 zod를 사용한다. 각 타입마다 AI에게 설명을 해줄 수도 있다. (describe) 아직 zod에 대해서는 잘 모르고 이번에 처음사용해본거라서 아직 더 연구가 필요할 것 같다.

나는 google Calender API에서는 ISO 8601을 datetime format으로 사용하는데(YYYY-MM-DDTHH:mm:ssZ) 이것을 describe안에 일정한 형식으로 작성될 수 있도록 프롬프팅해주었다. 그리고 같은 ISO 8601형식이더라도, 날짜까지만 사용하는 것도 있고, 전체 다(예: '2013-02-14T13:15:03-08:00')사용하는 것도 있어서 조금만 값이 달라져도 요청을 못읽기때문에 API목록을 상세히 살펴보았다.

 

 

3. Tool사용하기

tool은 LLM에서 쉽게 호출할 수 있도록 만든 것이다. LLM이 현실세계와 효과적으로 소통하려면 다양한 정보들을 읽을 수 있는 형태로 호출하고 받아올 수 있는 기능이 필요한 경우가 많다. 예를 들면 날씨정보를 받아온다거나, 웹검색이 필요하다거나, 방대한 양의 문서에서 무언가를 찾아야한다거나 할 때 말이다.

나는 이 tool을 사용해 google calendar api와 task api를 연결했다. 기본 사용방법은 아래와 같다. 

import { z } from 'zod';
import { generateText, tool } from 'ai';

const result = await streamText({
  model: yourModel,
  tools: {
    weather: tool({
      description: 'Get the weather in a location',
      parameters: z.object({
        location: z.string().describe('The location to get the weather for'),
      }),
      execute: async ({ location }) => ({
        location,
        temperature: 72 + Math.floor(Math.random() * 21) - 10,
      }),
    }),
  },
  toolChoice: 'required', // force the model to call a tool
  prompt: 'What is the weather in San Francisco?',
});

위는 공홈에서 예시로 나온 코드인데 간단하게 날씨를 가져오는 코드이다. 실질적으로 구현하려면, 날씨 API를 연동시키면 되겠다.

이렇게 스키마를 지정하고 excute쪽에 데이터를 어떻게 활용할 것인지 구현하면 된다. 

나는 호출, 생성, 수정, 삭제를 각각의 tool로 구현했다. 

// app/api/chat/tools.ts
import { z } from "zod";
import { auth } from "@/auth";
import { formatToKoreanDateTime } from "./utils";
import {
  addEventToCalendar,
  addTaskToList,
  deleteEventFromCalendar,
  deleteTaskFromList,
  getCalendarEvents,
  getTasksFromList,
  updateEventInCalendar,
  updateTaskInList,
  // getCalendarList,
} from "@/lib/googleClient";

/**
 * 주의사항: zod스키마를 선언할때, object에 직접 optional을 붙이면 null Error 발생.
 * enum type넣으면 오류남. 아마 이것도
 */

// # CALENDAR TOOLS

/**
 * 1. 캘린더 목록 가져오기 : 캘린더 목록을 가져오면 너무 단계가 많아져서 캘린더ID를 시작전에 프롬프트로 줌으로써 일단 해결.(캘린더가 많은사람이 별로 없을거라는 가정.)
 */
// export const getCalendarsListTool = {
//   description: `Get the user calendars`,
//   parameters: z.object({}),
//   execute: async () => {
//     const session = await auth();
//     const userId = session?.user.id;
//     const calendars = await getCalendarList(userId);

//     if (!calendars?.length) {
//       return "등록된 캘린더가 없습니다.";
//     }

//     const calendarText = calendars.map((calendar) => calendar.id).join(", ");
//     return `현재 캘린더 목록: ${calendarText}.
// 원하시는 캘린더 ID를 알려주세요 (예: ${calendars[0].id}).`;
//   },
// };

/**
 * 2. 특정 캘린더의 이벤트 조회
 */
export const getCalendarEventsTool = {
  description: "Get user events",
  parameters: z.object({
    calendarId: z.string(),
    timeMin: z.string().describe("start date in ISO 8601 format"),
    timeMax: z.string().describe("end date in ISO 8601 format"),
    maxResults: z.number().describe("최대 이벤트 개수"),
  }),
  execute: async ({
    calendarId,
    timeMin,
    timeMax,
    maxResults,
  }: {
    calendarId: string;
    timeMin: string;
    timeMax: string;
    maxResults: number;
  }) => {
    const session = await auth();
    const userId = session?.user.id;

    const events = await getCalendarEvents(
      userId,
      calendarId,
      timeMin,
      timeMax,
      maxResults
    );
    if (!events.length) {
      return "일정이 없습니다.";
    }

    // 날짜별로 그룹화
    const groupedEvents = events.reduce<
      Record<
        string,
        { summary: string; start?: { dateTime?: string }; id: string }[]
      >
    >((acc, event) => {
      const dateTime = event.start?.dateTime;
      if (!dateTime) return acc;
      const date = new Date(dateTime).toISOString().split("T")[0];
      if (!acc[date]) acc[date] = [];
      acc[date].push({
        summary: event.summary || "",
        start: {
          dateTime: event.start?.dateTime || undefined,
        },
        id: event.id || "", // eventId 추가
      });
      return acc;
    }, {});
    return groupedEvents;
  },
};

/**
 * 3. 일정 추가
 */

parameter에서 스키마를 정의하면, excute에서 바로 사용할 수 있다. 세션정보를 받아와 userId, calendarId, 기간 (timemin, timemax), 얼마나 가져올건지 maxresult를 정의하고 미리 구현해놓은 서버함수인 getCalendarEvent함수를 호출했다.

받아온 이벤트는 가공해서 프론트엔드로 보낸다.

/**
 * ToolInvocationRenderer - 특정 ToolInvocation을 렌더링
 */
function ToolInvocationRenderer({
  toolInvocation,
}: {
  toolInvocation: ToolInvocation;
}) {
  const { toolName, toolCallId, args, state } = toolInvocation;

프론트에서는 ToolInvocationRenderer를 통해 데이터를 안전하게 받아온다. 받아오는 방법도 상당히 여러가지가 있는거 같은데 다른방법도 사용해봐야겠다.

반응형