기능 개발

사이드 프로젝트 - 토리 AI 해커톤

동화책 만들어주는 AI 서비스 '토리 AI' 구성원들과 함께한 12시간 해커톤에서 '캐릭톡' 기능 개발

2024-07 ~ 2024-07
12 hours
Nest.jsSocket.ioWebSocketOpenAI APITypeScript

사이드 프로젝트에 대한 간단 설명

토리 AI(https://tale.fit) 란? (https://tale.fit)

  • 어린 아이들이 등장인물간단한 줄거리를 작성하면 재미있는 스토리를 만들어주는 서비스

해커톤을 진행한 이유

토리 AI 사이드 프로젝트 구성원들 간의 친목 도모를 위해 약 12시간 동안 진행된 해커톤입니다.

해커톤의 주제는 무엇이었는가?

토리 AI 에 들어가면 좋을 것 같은 기능을 서로 자유롭게 이야기해보고, 나온 아이디어 중 하나를 선택하여 개발까지 진행하는 것이었습니다.

어떤 것을 만들었는가?

캐릭톡

  • 스토리의 등장 인물과 채팅 형식으로 대화를 나누는 기능

해커톤의 특성 상 12시간이라는 제한된 시간 내에 완성을 해야 했기 때문에 아이디어 제시 정도만 이루어진 채 바로 개발 및 기획이 병렬적으로 진행되었습니다.

  • “스토리의 등장 인물과 채팅을 하는 기능을 만들자!”

최종 결과물

당시 프론트 개발에 차질이 발생하여 기획 문서로 대체합니다.. 🙏

스토리에서 “채팅 시작” 시 보여지는 등장인물 선택 화면입니다.스토리에서 “채팅 시작” 시 보여지는 등장인물 선택 화면입니다.

스토리에서 “채팅 시작” 시 보여지는 등장인물 선택 화면입니다.

유저가 등장인물과 채팅을 진행하는 UI 입니다.유저가 등장인물과 채팅을 진행하는 UI 입니다.

유저가 등장인물과 채팅을 진행하는 UI 입니다.

빠른 설계

아래는 한 페이지 정도의 간략한 기능 설계도입니다. 해커톤에서 중요한 것은 핵심 기능(MVP)를 빠르게 개발하는 것이라고 생각하여 개발 요구사항도 한 페이지로 만들어 진행하였습니다.

  • 한 페이지 짜리 기능 설계 이게.. 설계.?이게.. 설계.?

[ 개발 필요한 사항 리스트업 ]

스토리 등장인물과 채팅을 하기 위해서는 “스토리 내용”과 “등장인물 정보”를 LLM의 시스템 프롬프트에 넣어야 했습니다. 이를 위해서는 DB 에 저장되어 있는 스토리 내용과 등장인물 정보를 불러와야 했지만, 초기 MVP 를 빠르게 개발하고자 우선 하드코딩으로 개발하였습니다.

  1. 스토리 내용 가져오기 (가칭: getBookPageContents 함수)

  2. 등장인물 성격, 모습을 기반으로 프롬프트 구성

개발 단에서는 Front-end 개발자와 제가(Back-end 담당) 웹에서 채팅 기능을 구현하기 위해 WebSockets API 명세를 빠르게 공유하고 서로 개발을 진행하였습니다.

[ API 설명 ]

WebSockets API 설계WebSockets API 설계

최종 클라이언트 측 WebSockets Event

// Event Key: CHAT_INIT (새로운 채팅을 시작하는 데 사용함)
{
  bookId: string; // 스토리 ID 값
  characterId: number; // 스토리에 포함된 등장 인물의 ID 값
}

// Event Key: MESSAGE_REQUEST (CHAT_INIT 호출 이후 대화 내용을 전송할 때 사용함)
{
  "content": "string"; // 채팅 내용 (사용자가 웹에서 입력함)
}

최종 서버 측 WebSockets Event

// Event Key: MESSAGE_RESPONSE (MESSAGE_REQUEST 를 통해 요청한 대화에 응답하는 데 사용함)
{
  "content": "string"; // 채팅 응답 내용 (AI 가 생성함)
  "isFinish": "boolean"; // 채팅 말풍선 하나가 다 생성되었는지 여부
   // (Front-end 에서 다음 채팅을 입력할 수 있는 UI 를 그리는 데 필요함)
}

빠른 프로토타입 개발

초기에는 스토리를 선택하는 기능도, 등장인물을 선택하는 기능도 없이 코드 상에 프롬프트를 하드코딩하여 개발을 진행하여 팀 내 효율성을 극대화하였습니다.

  • Front-end 개발자에게는: 약속한 API 명세대로 동작하는 Mock 데이터를 넣어두어 UI 퍼블리싱 및 WebSockets 통신 로직 구현을 병렬적으로 할 수 있도록 하였습니다.
  • 기획자에게는: Front-end/Back-end 의 개발 진행상황을 지속적으로 확인할 수 있도록 하여 제한된 시간 내에 개발 가능한 기능을 확정하고 중간 개발 결과물을 확인하면서 새로운 기능을 제안하실 수 있도록 하였습니다.

등장인물 시스템 프롬프트 하드코딩

등장인물과 채팅을 하기 위해서는 시스템 프롬프트에 “스토리 내용”과 “등장인물 정보”가 필요합니다.

아래 코드는 CHAT_INIT 이벤트가 프론트로부터 들어왔을 때 채팅 세션을 초기화하는 로직입니다.

스토리 내용을 불러오는 로직에서 스토리 내용이 하드코딩 되어 있는 것을 확인할 수 있습니다.

private async getBookPageContents() {
    const pages = [
      {
        id: 14,
        length: 139,
        title: 'Page 1',
        content:
          '준석이는 오늘 아침에 일어나서 머리를 감으려고 욕실로 갔어요. 그런데 욕실 선반에 고양이 샴푸가 놓여 있는 걸 발견했어요. 준석이는 호기심이 많아서 이 샴푸로 머리를 감으면 어떻게 될지 궁금했어요. 기운이는 준석이의 옆에서 장난스럽게 웃고 있었어요.',
        imageUrl: null,
        bookId: '20d87ffd-1018-4a91-a405-71f4375fd6ca',
        pageIndex: 1,
        illustration:
          'A calm boy named Junseok and a playful cat named Giun in a bathroom. Junseok is holding a bottle of cat shampoo, looking curious. Giun is sitting on the sink, looking mischievous.',
      },
	  // ... 생략
	  ];
	  return pages.map((page) => ({
      title: page.title,
      content: page.content,
      illustration: page.illustration,
    }));
}

등장인물 정보 역시 마찬가지로 하드코딩 되어 있는 것을 확인할 수 있습니다.

 private async initializeChatMessages(): Promise<
    ChatCompletionMessageParam[]
  > {
    const bookPageContents = await this.getBookPageContents();

    return [
      {
        role: 'system',
        content:
          'You are a chatbot that responds to user input as a character in a book.\n' +
          '\n' +
          'Book content:\n' +
          `${bookPageContents.map((page) => this.makePagePrompt(page))}\n` +
          '\n' +
          'Character:\n' +
          '- Name: 기운(Giun)\n' +
          '- Appearance: Cat\n' +
          '- Traits: Cute, playful',
      },
      {
        role: 'assistant',
        content:
          'I can help you with general questions or provide information about our services.',
      },
    ];
  }

서서히 하드코딩 제거

앞서 “스토리 내용”과 “등장인물 정보”를 하드코딩 하여 개발했다고 했습니다.

이제는 해커톤 중반기에 접어들면서 기획 요구사항이 어느 정도 확정되고 있었고, 이에 따라 하드코딩을 해두었던 로직을 제거해도 될 것 같다는 판단을 하였습니다.

스토리 내용 동적으로 불러오도록 수정

기존에 토리 AI 프로젝트에서 사용하던 스토리 정보를 DB 에서 동적으로 불러올 수 있도록 하였습니다.

또한 등장인물 정보 역시 하드코딩 되어 있던 로직을 DB 에서 동적으로 불러올 수 있도록 하였습니다.

private async initializeChatMessages(
    socket: Socket,
  ): Promise<ChatCompletionMessageParam[]> {
    const chatSessionInfo = this.getChatSessionInfo(socket.id);
    const { bookId, characterId } = chatSessionInfo;

    const book = await this.bookService.getBook(bookId);
    const character = await this.characterService.getCharacter({ characterId });

    const { pages } = book;

    return [
      {
        role: 'system',
        content:
          "You must pretend to be a character from the book and respond to the user's question." +
          '\n' +
          'Book content:\n' +
          `${pages.map((page) => this.makePagePrompt(page))}\n` +
          '\n' +
          'Character:\n' +
          `${this.makeCharacterPrompt(character)}`,
      },
    ];
  }
  
  private makePagePrompt(page: Partial<BookPage>) {
    return `\nContent: ${page.content}\nIllustration: ${page.illustration}\n`;
  }

  private makeCharacterPrompt(character: Character) {
    return `\n- Name: ${character.name}\n- Appearance: ${character.external}\n- Traits: ${character.personality}`;
  }