관리 메뉴

Bull

[Dev] LLM을 활용 단어장 앱 개발일지 007: 핵심 - 파인튜닝, 응답 데이터 전처리, 단어 추가 UI 구성 본문

일상/개발일지

[Dev] LLM을 활용 단어장 앱 개발일지 007: 핵심 - 파인튜닝, 응답 데이터 전처리, 단어 추가 UI 구성

Bull_ 2024. 8. 27. 09:14

서론

이번에는 이 앱의 핵심 부분인 "단어"를 질문하고 그 응답을 딕셔너리 형태로 받아서 문자열 전처리를 진행한다. 실제 채팅에 나타날 문자와 단어 추가할 데이터를 담는다. 담아진 단어 데이터는 UI를 통해 구성한다.

전처리 문자열

이 부분을 어떻게 해야할지 이전부터 각은 잡아놨었다. 하지만 정말 그 방향이 나중에 유지보수할 때 정확한 방향인지 판단하기 어려워 시작을 망설였었다. 이 걱정이 너무 길어져 행동으로 옮겼고 생각해낸 답변은 다음과 같다.

{
   "answer":"네, 이해했습니다.",
   "word":{
      "name":"apple",
      "meaning":"사과",
      "example":[
         "I like apple.",
         "apple is red."
      ],
      "tag":[
         "명사",
         "과일"
      ]
   }
}

단순하다. 채팅으로 답변할 부분은 answer 에 저장하고 단어 부분은 word에 딕셔너리 형태로 담아달라고 하였다.

 

name : 단어명
meaning : 뜻
example : 예문
tag : 태그

 

이 부분은 사람마다 취향이 다르고 여러 뜻을 가진 단어도 많기 때문에 형태에 대해서 고민이 깊었는데 결정한 형태는 위와 같다. 단어명은 어차피 1개밖에 존재할 수 밖에 없기 때문에 String으로 하였다. 그에 반해 뜻은 여러 개가 존재할 수 있는데 리스트로 만들기에는 뭔가 애매한 부분이 많았다. 대부분의 영어사전에 리스트형태로 나열해놓는 경우가 없었기 때문이다. 그래서 String 으로 하되, 콤마(,)로 답변받도록 파인튜닝을 진행한다. 예문과 태그는 String 리스트로 받도록 했다.

파인튜닝

{"role": "system", "content": "너는 영어 단어를 설명해주는 도우미야."},
{"role": "system", "content": "한글로 대답해줘."},            
{"role": "system", "content": 'json형태로 대답해줘. 예를 들어 {"answer": "네, 이해했습니다.", "word": {"name": "apple", "meaning": "사과", "example": ["I like apple.","apple is red."],"tag": ["명사", "과일"]}}'},
{"role": "system", "content": 'json은 무조건 큰따옴표로 묶어줘야 돼. 내가 파싱해야 되거든'},
{"role": "system", "content": "질문형식으로 물어봐도 json형태로 대답해야 돼."},
{"role": "system", "content": "만약 잘못된 단어가 나오면 유추해서 answer 부분에 잘못된 단어를 유추했다고 알리고, 그 단어에 대한 설명을 적어줘."},
{"role": "system", "content": "answer 부분에는 그냥 그 단어에 대한 니 생각을 적어주면 돼."},
{"role": "system", "content": "2줄정도의 예문을 만들어서 대답해줘."},
{"role": "system", "content": "meaning은 최소 2개 이상의 의미를 ,로 구분해서 문자열로 적어줘."},
{"role": "system", "content": "example은 최소 2개 이상의 의미를 문자열 리스트로 적어줘."},
{"role": "system", "content": "tag는 최소 3개 이상의 키워드를 문자열 리스트로 적어줘. 한글로 적어줘야돼."},
{"role": "system", "content": "만약 단어에 대한 질문이 아니라면 단어에 대한 설명을 해달라고 요청해주고, word 부분에는 아무것도 안적어도 돼."},

3번 줄을 통해 json 형태로 받아준다.
4번 줄에 큰 따옴표로 설정해달라고 했는데, 왜냐하면 dart의 jsonDecode 메소드에서 작은 따옴표는 파싱을 못하기 때문이다.
5번을 통해 확고하게 json으로 받도록했다. 왜냐하면 답변이 json이 아니면 파싱을 못하기 때문이다. 예외처리를 flutter 에서 해준다고 하더라도 번거롭다.
6번은 내가 하지 않아도 gpt 가 알아서 처리해주는 경우가 많은데 확실하게 하고싶어서 넣었다. banena 라고 적게 되면 banana로 고쳐알아서 답변하도록 지시하는 부분이다.


나머지는 따로 설명하지 않겠다. 랭체인부분은 이정도로 마무리 짓고 더 추가할 부분은 없다.

프롬프트 응답 결과

{
   "answer":"잘못된 단어를 유추했습니다. 아마""gradient""를 의미하시는 것 같습니다.",
   "word":{
      "name":"gradient",
      "meaning":"기울기",
      "변화의 정도",
      "example":[
         "The gradient of the hill is steep.",
         "We need to calculate the gradient of the function."
      ],
      "tag":[
         "명사",
         "수학",
         "물리"
      ]
   }
}

dart word 모델 추가

// word.dart
class Word {
  final String word;
  final String meaning;
  final List<String> example;
  final List<String> tag;

  Word({
    required this.word,
    required this.meaning,
    required this.example,
    required this.tag,
  });

  factory Word.fromJson(Map<String, dynamic> json) {
    return Word(
      word: json['name'],
      meaning: json['meaning'],
      example: List<String>.from(json['example']),
      tag: List<String>.from(json['tag']),
    );
  }
}

factory 키워드를 통해 객체의 인스턴스를 네임드 생성자 내에서 반환하도록 한다.

// message.dart
import 'word.dart';

class Message {
  final String text;
  final bool isUserMessage;
  final Word? word; 

  Message({required this.text, required this.isUserMessage, this.word});
}

chat bloc에서 word 처리

원래 word bloc을 따로 만들려다가 기존의 chat bloc의 프로퍼티로 만들기로 했다. 아직 전체적인 흐름은 잘 안보이지만 채팅창 부분에서 단어를 추가할 것이기 때문에 chat bloc에 구현하는 것이다.

 

그러면 응답데이터가 json 데이터로 바뀌었기 때문에 ChatBloc 에서 채팅을 보내는 부분을 바꿔야한다.

// chat_bloc.dart
Future<void> _onSendMessage(
    SendMessageEvent event, Emitter<ChatState> emit) async {
  List<Message> updatedMessages = [];

  if (state is ChatLoaded) {
    updatedMessages = List.from((state as ChatLoaded).messages);
  }

  emit(ChatFetching(updatedMessages));
  updatedMessages.add(Message(text: event.message, isUserMessage: true));

  try {
    final responseString = await chatRepository.getResponse(event.message);
    final Map<String, dynamic> response = jsonDecode(responseString);
    print("Response: $response");
    final String answer =
        response['answer'] ?? "Sorry, No answer. Please try again.";
    final Word? word = response.containsKey('word') &&
            response['word'] is Map<String, dynamic> &&
            (response['word'] as Map<String, dynamic>).isNotEmpty
        ? Word.fromJson(response['word'] as Map<String, dynamic>)
        : null;

    updatedMessages
        .add(Message(text: answer, isUserMessage: false, word: word));
    emit(ChatLoaded(updatedMessages));
  } catch (error) {
    print("Error in _onSendMessage: $error");
    emit(ChatError(error.toString()));
  }
}

jsonDecode를 통해 json을 Map 형태로 바꿔준다. 여기서 json이 큰따옴표로 되어있지 않으면 포맷익셉션이 발생하므로 반드시 큰따옴표로 받아야한다. 만약 단어에 대한 질문을 받으면 word는 비어있기 때문에 (response['word'] as Map<String, dynamic>).isNotEmpty 을 통해 비어있는 지 검사해야 한다. 이부분이 없으면 에러가 난다.

이제 리스트에 add를 통해 word도 함께 추가하면 된다.

단어 추가 UI

단어를 추가하는 UI는 두가지 있다.

 

  1. 단어를 추가하는 페이지로 들어가는 Button
  2. 단어를 추가하는 page

단어를 추가하는 페이지로 들어가는 Button

1번을 따로 둔게 의아할 수 있지만 이 추가페이지로 들어가는 부분에 대해서 생각을 진짜 많이 했다. 예를들어 여러 가지 방법이 존재한다. 처음 생각했던건 채팅을 보내고 응답을 받으면 showDialog 를 통해서 추가하시겠습니까? 하는 위젯을 강제적으로 띄우는 것이다.두번째는 초기화된 상태에서 응답받은 단어들을 모아놓는다. 이후 단어장 페이지로 뷰 전환을 할 때 추가페이지 위젯을 띄어서 여러 개의 단어를 추가할 리스트를 나열해서 체크 버튼을 통해 골라서 단어를 추가할 수 있도록하는 것이다.


1번째 방식은 사용자 입장에서 불편할 수 있고 2번째 방식은 내가 조금 귀찮아 질 거 같은 방식이라고 생각했다. 그래서 설정창에서 1,2번째 방식을 골라서 할 수 있도록 옵션 변경 창을 만들려했는데 만들면서 계속 고민하던 도중 좋은 방법이 생각났다.

 

바로 응답받은 데이터에서 상대 채팅 아래에 추가버튼을 만드는 것이다. 그러면 채팅 관리가 매우 쉬워질 것이라 생각했다. 사실 지금 위에서 짜놓은 코드는 이 방식이 먼저 채택된 이후 구상된 방식이다. 왜냐하면 word bloc을 따로 만들거나 할 필요 없이 chat bloc에 바로 넣어서 인덱스로 관리할 수 있기 때문이다. word 프로퍼티에 null인지 아닌지를 넣은 이유도 이 때문이다. null 유무를 통해 상대의 채팅 UI 아래에 단순히 렌더링될때 word의 null값만 확인하면 되기 때문이다.

 

그렇게 생각한 방식은 아래의 결과와 같다.

단어 추가 페이지로 들어가는 button 방식

처음 부분처럼 단어에 대한 질문을 받지 않으면 json의 word 부분이 비어있기 때문에 "단어 추가" 버튼을 렌더링시키지 않는다. 반면 단어에 대한 질문은 word에 원하는 방식대로 전처리되어 있기 때문에 단어에 대한 정보가 들어가 있다. 이제 그 데이터를 추가하는 페이지에 자동으로 채워지게 하면 된다. 그 전에 코드가 처리된 부분은 다음과 같다.

Column(
  crossAxisAlignment: CrossAxisAlignment.start,
  children: [
    ListTile(),
    if (!isUser && message.word != null)
      Padding(
        padding: EdgeInsets.fromLTRB(20, 0, 0, 0),
        child: TextButton(
          onPressed: () {
            showWordDialog(context,
                updateWord: message.word);
          },
          child: Text("단어 추가",
              style: TextStyle(
                  color: Colors.white, fontSize: 12)),
          style: TextButton.styleFrom(
            minimumSize: Size(60, 30),
            backgroundColor: Colors.lightGreen,
            overlayColor: Colors.lightGreen.shade100,
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(5),
            ),
          ),
        ),
      ),
  ],
);

기존에는 ListTile로 채팅 내역을 반환하지만 부모위젯을 Column 으로 감싸고 if문을 통해 ListTile 아래에 추가버튼을 조건을 통해 렌더링시켰다.

단어를 추가하는 page

단어를 추가하는 page는 이전부터 dialog로 구상하고 있었다. 그래서 dialog도 여러 종류가 있는 지 찾아보았다. 그 중 아랫부분에서 쑤욱 튀어나오는 위젯이 마음에 들어서 showModalBottomSheet로 채택했다. 다이얼로그는 아니지만 일반적인 다이얼로그의 느낌이 있어서 같은 취급을 하였다. 이제 showWordDialog 라는 커스텀 메소드를 만드는데 이 때 bloc으로 상태관리하고 있는 word를 파라미터로 받으면 된다.

void showWordDialog(BuildContext context, {Word? updateWord}) {
  final wordController = TextEditingController(text: updateWord?.word ?? '');
  final meaningController =
      TextEditingController(text: updateWord?.meaning ?? '');
  final exampleControllers = (updateWord?.example ?? [''])
      .map((e) => TextEditingController(text: e))
      .toList();
  final tagControllers = (updateWord?.tag ?? [''])
      .map((e) => TextEditingController(text: e))
      .toList();

  showModalBottomSheet(
    context: context,
    isScrollControlled: true,
    builder: (BuildContext context) {
      return StatefulBuilder(
        builder: (BuildContext context, StateSetter setState) {
          return Container(
            color: Colors.white,
            child: Padding(
              padding: const EdgeInsets.all(14.0),
              child: SingleChildScrollView(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  mainAxisSize: MainAxisSize.min,
                  children: <Widget>[
                    Text('단어',
                        style: TextStyle(
                            fontSize: 13, fontWeight: FontWeight.bold)),
                    Padding(
                      padding: const EdgeInsets.fromLTRB(5, 0, 5, 5),
                      child: InputTextfield(
                        hintText: '',
                        controller: wordController,
                      ),
                    ),
                    Text('뜻',
                        style: TextStyle(
                            fontSize: 13, fontWeight: FontWeight.bold)),
                    Padding(
                      padding: const EdgeInsets.fromLTRB(5, 0, 5, 5),
                      child: InputTextfield(
                        hintText: '',
                        controller: meaningController,
                      ),
                    ),
                    Text('예문',
                        style: TextStyle(
                            fontSize: 13, fontWeight: FontWeight.bold)),
                    ...exampleControllers.map((controller) {
                      return Padding(
                        padding: const EdgeInsets.fromLTRB(5, 0, 5, 5),
                        child: Row(
                          children: [
                            Expanded(
                              child: InputTextfield(
                                hintText: '',
                                controller: controller,
                              ),
                            ),
                            IconButton(
                              icon: Icon(Icons.remove_circle),
                              onPressed: () {
                                setState(() {
                                  exampleControllers.remove(controller);
                                });
                              },
                            ),
                          ],
                        ),
                      );
                    }).toList(),
                    TextButton(
                      onPressed: () {
                        setState(() {
                          exampleControllers.add(TextEditingController());
                        });
                      },
                      child: Text(
                        '예문 추가',
                        style: TextStyle(color: Colors.lightGreen),
                      ),
                      style: ButtonStyle(
                        overlayColor: WidgetStateProperty.all(
                            Colors.lightGreen.shade100),
                      ),
                    ),
                    Text('태그',
                        style: TextStyle(
                            fontSize: 13, fontWeight: FontWeight.bold)),
                    ...tagControllers.map((controller) {
                      return Padding(
                        padding: const EdgeInsets.fromLTRB(5, 0, 5, 5),
                        child: Row(
                          children: [
                            Expanded(
                              child: InputTextfield(
                                hintText: '',
                                controller: controller,
                              ),
                            ),
                            IconButton(
                              icon: Icon(Icons.remove_circle),
                              onPressed: () {
                                setState(() {
                                  tagControllers.remove(controller);
                                });
                              },
                            ),
                          ],
                        ),
                      );
                    }).toList(),
                    TextButton(
                      onPressed: () {
                        setState(() {
                          tagControllers.add(TextEditingController());
                        });
                      },
                      child: Text(
                        '태그 추가',
                        style: TextStyle(color: Colors.lightGreen),
                      ),
                      style: ButtonStyle(
                        overlayColor: WidgetStateProperty.all(
                            Colors.lightGreen.shade100),
                      ),
                    ),
                    SizedBox(height: 20),
                    Center(
                      child: ElevatedButton(
                        onPressed: () {
                          final updatedWord = Word(
                            word: wordController.text,
                            meaning: meaningController.text,
                            example: exampleControllers
                                .map((controller) => controller.text)
                                .toList(),
                            tag: tagControllers
                                .map((controller) => controller.text)
                                .toList(),
                          );

                          Navigator.pop(context, updatedWord);
                        },
                        style: ElevatedButton.styleFrom(
                          backgroundColor: Colors.lightGreen,
                        ),
                        child: const Text('추가',
                            style: TextStyle(color: Colors.white)),
                      ),
                    ),
                  ],
                ),
              ),
            ),
          );
        },
      );
    },
  );
}

하나씩 살펴보겠다.

final wordController = TextEditingController(text: updateWord?.word ?? '');
final meaningController =
    TextEditingController(text: updateWord?.meaning ?? '');
final exampleControllers = (updateWord?.example ?? [''])
    .map((e) => TextEditingController(text: e))
    .toList();
final tagControllers = (updateWord?.tag ?? [''])
    .map((e) => TextEditingController(text: e))
    .toList();

wordController와 meaningController는 하나의 String만 저장해도 되지만 예문과 태그는 여러 개 존재할 수 있기 때문에 Type: List<TextEditingController> 로 정해주었다. map 메소드를 통해 TextEditingController를 간단하게 list로 만들었다.
파라미터로 받은 updateWord를 통해서 TextEditingController에 추가해주면 된다.

showModalBottomSheet

아래에서 모달이 튀어나오는 바텀시트이다.

 

isScrollControlled: true : 무한히 커질 수 있기 때문에 true로 등록한다.

 

StatefulBuilder : 모달, 다이얼로그, 바텀시트 등에서 상태를 변경할 때 사용해주어야 한다.

 

SingleChildScrollView : isScrollControlled를 true로 허용을 했고 실제로 구현해야하기 때문에 사용한다.

 

InputTextfield : 내가 커스텀한 텍스트 필드이다.

import 'package:flutter/material.dart';

class InputTextfield extends StatefulWidget {
  final String hintText;
  final TextEditingController controller;

  const InputTextfield({
    Key? super.key,
    required this.hintText,
    required this.controller,
  });

  @override
  _InputTextfieldState createState() => _InputTextfieldState();
}

class _InputTextfieldState extends State<InputTextfield> {
  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
        color: Colors.white,
      ),
      child: TextField(
        controller: widget.controller,
        decoration: InputDecoration(
          contentPadding: const EdgeInsets.symmetric(horizontal: 10),
          focusedBorder: UnderlineInputBorder(
            borderSide: BorderSide(
              color: Colors.lightGreen.shade200,
            ),
          ),
          enabledBorder: UnderlineInputBorder(
            borderSide: BorderSide(
              color: Colors.lightGreen.shade200,
            ),
          ),
          hintText: widget.hintText,
          hintStyle: const TextStyle(
            color: Colors.grey,
          ),
        ),
      ),
    );
  }
}

 

...exampleControllers.map((controller) {
  return Padding(
    padding: const EdgeInsets.fromLTRB(5, 0, 5, 5),
    child: Row(
      children: [
        Expanded(
          child: InputTextfield(
            hintText: '',
            controller: controller,
          ),
        ),
        IconButton(
          icon: Icon(Icons.remove_circle),
          onPressed: () {
            setState(() {
              exampleControllers.remove(controller);
            });
          },
        ),
      ],
    ),
  );
}).toList(),

exampleControllers는 리스트기 때문에 map을 통해서 인자로 전달하고 위젯을 반환해야 한다. 이 부분은 특별하게 아이콘을 통해서 remove 를 구현해주었다. 페이지에서 바로 예문을 어떻게 추가하고 삭제할 지 편하게 컨트롤할 수 있다. 추가부분은 다음에 나온다.

TextButton(
  onPressed: () {
    setState(() {
      exampleControllers.add(TextEditingController());
    });
  },
  child: Text(
    '예문 추가',
    style: TextStyle(color: Colors.lightGreen),
  ),
  style: ButtonStyle(
    overlayColor: WidgetStateProperty.all(
        Colors.lightGreen.shade100),
  ),
),

예문을 추가해주는 버튼을 바로 아래에 추가해주었다. 태그도 마찬가지이니 생략하겠다.

ElevatedButton(
  onPressed: () {
    final updatedWord = Word(
      word: wordController.text,
      meaning: meaningController.text,
      example: exampleControllers
          .map((controller) => controller.text)
          .toList(),
      tag: tagControllers
          .map((controller) => controller.text)
          .toList(),
    );

    Navigator.pop(context, updatedWord);
  },
  style: ElevatedButton.styleFrom(
    backgroundColor: Colors.lightGreen,
  ),
  child: const Text('추가',
      style: TextStyle(color: Colors.white)),
),

마지막으로 최종적인 Word 의 프로퍼티의 인스턴스를 만들어서 반환한다. 아직 word에 대한 상태관리와 DB는 구현하지 않았으니 이 정도만 해주었다.

단어 추가 페이지

마무리

뒤늦게 발견한건데 예문에 한글 해석이 추가되지 않았다. 이 부분은 다음에 고칠 예정이다.

 

이제 word에 대한 상태관리와 DB를 만들 예정이다. 간단하게 express와 MySQL로 서버에서 데이터를 가져오는 것을 구현했지만 아직 멀고도 먼 일이다. word에 대한 DB는 우선적으로 SQLite를 통해서 기기의 내부 데이터 저장할 예정이다. 아직 DB를 어떻게 해야할 지 모르기 때문이다. 예전에 파이어베이스로 데이터를 저장할 때는 CRUD를 서버에 바로 요청하는 식으로 진행했지만 그게 맞는 지 확신하지 않았다. 왜냐하면 매번 서버와 통신하면 비용이 많이 드는 것을 직감적으로 알고 있었기 때문이다. 예를들어 내부에 DB를 저장하고 앱을 종료할 때 한번만 서버의 DB를 갱신하거나 주기적인 시간마다 (예를들어 10분마다) 데이터를 갱신하는 방식이 있을 수 있다. 이것에 대한 솔루션이 있는 지 찾아보고 진행해볼 예정이다.


TMI지만 앱을 종료하는 방식에 강제종료도 있기 때문에 제대로 갱신되지 않을 수 있다. 지금 생각난거지만 특정 앱에서 종료할 때 앱을 종료하시겠습니까 모달창이 뜨는 이유가 서버에 데이터를 갱신하기 위함인건가 추측된다. 원래는 광고 1초라도 더 보이려고 하는 거라고만 생각했었는데.