사이드 프로젝트에 대한 간단 설명
토리 AI(https://tale.fit) 란? (https://tale.fit)
- 어린 아이들이
등장인물
과간단한 줄거리
를 작성하면 재미있는 스토리를 만들어주는 서비스
해커톤을 진행한 이유
토리 AI 사이드 프로젝트 구성원들 간의 친목 도모를 위해 약 12시간 동안 진행된 해커톤입니다.
해커톤의 주제는 무엇이었는가?
토리 AI 에 들어가면 좋을 것 같은 기능을 서로 자유롭게 이야기해보고, 나온 아이디어 중 하나를 선택하여 개발까지 진행하는 것이었습니다.
어떤 것을 만들었는가?
캐릭톡
- 스토리의 등장 인물과 채팅 형식으로 대화를 나누는 기능
해커톤의 특성 상 12시간이라는 제한된 시간 내에 완성을 해야 했기 때문에 아이디어 제시 정도만 이루어진 채 바로 개발 및 기획이 병렬적으로 진행되었습니다.
- “스토리의 등장 인물과 채팅을 하는 기능을 만들자!”
최종 결과물
당시 프론트 개발에 차질이 발생하여 기획 문서로 대체합니다.. 🙏
스토리에서 “채팅 시작” 시 보여지는 등장인물 선택 화면입니다.
스토리에서 “채팅 시작” 시 보여지는 등장인물 선택 화면입니다.
유저가 등장인물과 채팅을 진행하는 UI 입니다.
유저가 등장인물과 채팅을 진행하는 UI 입니다.
빠른 설계
아래는 한 페이지 정도의 간략한 기능 설계도입니다. 해커톤에서 중요한 것은 핵심 기능(MVP)를 빠르게 개발하는 것이라고 생각하여 개발 요구사항도 한 페이지로 만들어 진행하였습니다.
- 한 페이지 짜리 기능 설계
이게.. 설계.?
[ 개발 필요한 사항 리스트업 ]
스토리 등장인물과 채팅을 하기 위해서는 “스토리 내용”과 “등장인물 정보”를 LLM의 시스템 프롬프트에 넣어야 했습니다. 이를 위해서는 DB 에 저장되어 있는 스토리 내용과 등장인물 정보를 불러와야 했지만, 초기 MVP 를 빠르게 개발하고자 우선 하드코딩으로 개발하였습니다.
-
스토리 내용 가져오기 (가칭: getBookPageContents 함수)
-
등장인물 성격, 모습을 기반으로 프롬프트 구성
개발 단에서는 Front-end 개발자와 제가(Back-end 담당) 웹에서 채팅 기능을 구현하기 위해 WebSockets API 명세를 빠르게 공유하고 서로 개발을 진행하였습니다.
[ 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}`;
}