일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 파이토치 트랜스포머를 활용한 자연어 처리와 컴퓨터비전 심층학습
- fastapi를 사용한 파이썬 웹 개발
- PCA
- FastAPI
- Widget
- Algorithm
- ARM
- BAEKJOON
- pytorch
- study book
- C++
- 영상처리
- Got
- Flutter
- BFS
- rao
- Dreamhack
- system hacking
- BOF
- Kaggle
- 백준
- MATLAB
- llm을 활용 단어장 앱 개발일지
- Image Processing
- Computer Architecture
- bloc
- Stream
- DART
- ML
- MDP
- Today
- Total
Bull
[Dev] LLM을 활용 단어장 앱 개발일지 004: Bloc 상태 관리(대화 내용) 본문
https://codcost.tistory.com/238
[Dev] LLM을 활용 단어장 앱 개발일지 003: Fast API로 Langchain 설정하기
LLM을 통해서 채팅을 하는 것이 주목적이기 때문에 간단하게 Fast API로 gpt api 요청을 하는 서버를 구축해보겠다. OpenAI의 라이브러리만 써도 gpt 사용하는데 무방하지만 나중에 여러 모델을 쓰게 된
codcost.tistory.com
세 번째 일지에서 Fast API를 통해서 GPT api에게 쿼리를 보내고 응답을 json 형태로 받을 수 있도록 구현하였다. 이제 실제 대화를 받고 그 내용을 채팅 페이지에 출력할 것이다. 그리고 채팅 내용은 Bloc 패턴을 통해 페이지를 이동해도 남아 있을 수 있도록 관리하겠다.
Bloc
우선 첫 번째로 선택한 데이터 모델은 다음과 같다.
class Message {
final String text;
final bool isUserMessage;
Message({required this.text, required this.isUserMessage});
}
메시지를 문자열 포맷으로 저장하고 isUserMessage
프로퍼티를 통해 메시지를 누구인지의 여부를 저장한다.
Bloc Repository
bloc 패턴에서 repository는 api를 호출하여 외부에서 데이터 통신을 할 때 사용하는 것을 원칙으로 한다. Repository를 ChatRepository
로 저장하고 api 를 호출하고 json 형태를 받아올 것이다.
# filename: chat_repository.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
class ChatRepository {
final String apiUrl = "http://OOO.OOO.OOO.OOO:12345/ask";
Future<String> getResponse(String userMessage) async {
final response = await http.post(
Uri.parse(apiUrl),
headers: {"Content-Type": "application/json"},
body: jsonEncode({"question": userMessage}),
);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
return data['answer'];
} else {
throw Exception("Failed to get response");
}
}
}
ChatRepository 클래스에서 getResponse
메소드를 통해서 fast api로 켜놓은 서버에 api을 요청해서 응답을 가져온다. 여기서 utf-8
로 디코딩을 해서 가져와야 한글도 응답 받을 수 있다. 응답 받은 json의 프로퍼티는 answer
로 모델링 했기 때문에 data['answer'] 를 리턴해준다.
chat Bloc
// filename: chat_event.dart
abstract class ChatEvent {}
class SendMessageEvent extends ChatEvent {
final String message;
SendMessageEvent(this.message);
}
class ReceiveMessageEvent extends ChatEvent {
final String message;
ReceiveMessageEvent(this.message);
}
이벤트는 위처럼 메시지는 보내는 이벤트와 가져오는 이벤트를 생성했다.
// filename: chat_state.dart
import '../../models/message.dart';
abstract class ChatState {}
class ChatInitial extends ChatState {}
class ChatLoaded extends ChatState {
final List<Message> messages;
ChatLoaded(this.messages);
}
class ChatFetching extends ChatLoaded {
ChatFetching(List<Message> messages) : super(messages);
}
class ChatError extends ChatState {
final String error;
ChatError(this.error);
}
ChatInitial
: 기능은 없지만 초기 상태를 등록하기 위해 정의했다.ChatLoaded
: send 를 했을 때 api 요청을 완료하면 채팅이 표시된다. 채팅을 가져오는 상태는 아니고 가져오기를 완료된 상태이다.ChatFetching
: 채팅을 가져오는 상태이다. 이 상태를 통해 채팅을 가져오고 위젯에는 ...
표시를 할 것이다.ChatError
: 에러 상태이다.
// filename: chat_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import 'chat_event.dart';
import 'chat_state.dart';
import '../../models/message.dart';
import '../../repositories/chat_repository.dart';
class ChatBloc extends Bloc<ChatEvent, ChatState> {
final ChatRepository chatRepository;
ChatBloc({required this.chatRepository}) : super(ChatInitial()) {
on<SendMessageEvent>(_onSendMessage);
}
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));
try {
updatedMessages.add(Message(text: event.message, isUserMessage: true));
final response = await chatRepository.getResponse(event.message);
updatedMessages.add(Message(text: response, isUserMessage: false));
emit(ChatLoaded(updatedMessages));
} catch (error) {
emit(ChatError(error.toString()));
}
}
}
final ChatRepository chatRepository
: 의존성을 통해 레파지토리 클래스를 저장한다.on<SendMessageEvent>(_onSendMessage)
: 스트림을 등록해준다.
if (state is ChatLoaded) {
updatedMessages = List.from((state as ChatLoaded).messages);
}
emit(ChatFetching(updatedMessages));
ChatLoaded 상태는 기존 메시지가 표시된 상태라고 봐도 된다. 그런데 이 상태에서 send 를 하게 되면 기존에 있던 메시지를 updatedMessages로 갱신한다.
이때 ChatFetching
상태에도 대화가 저장이 되는데 왜 그렇냐하면 send를 요청한 상태에서 위젯이 빌드되더라도 기존에 있던 대화가 표시되어 있어야한다. ChatLoaded 에는 messages 프로퍼티가 있기 때문에 ChatFetching 을 ChatLoaded 로 상속 받는 이유이다.
try {
updatedMessages.add(Message(text: event.message, isUserMessage: true));
final response = await chatRepository.getResponse(event.message);
updatedMessages.add(Message(text: response, isUserMessage: false));
emit(ChatLoaded(updatedMessages));
}
updatedMessages 에 나의 메시지를 추가하고 chatRepository 를 초기화했기 때문에 요청을 보낸 후 받은 답변에 대한 텍스트도 updatedMessages 에 isUserMessage;false로 저장해준다.
Bloc Widget
// filename: chatting_page.dart
class ChattingPage extends StatefulWidget {
@override
_ChattingPageState createState() => _ChattingPageState();
}
class _ChattingPageState extends State<ChattingPage> {
final TextEditingController _controller = TextEditingController();
final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
void _sendMessage() {
if (_controller.text.isNotEmpty) {
context.read<ChatBloc>().add(SendMessageEvent(_controller.text));
_controller.clear();
_scrollToBottom();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Column(
children: [
Expanded(
child: BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
if (state is ChatLoaded || state is ChatFetching) {
final messages = (state as ChatLoaded).messages;
_scrollToBottom();
return ListView.builder(
controller: _scrollController,
itemCount:
messages.length + (state is ChatFetching ? 1 : 0),
itemBuilder: (context, index) {
if (index == messages.length && state is ChatFetching) {
return ListTile(
title: Align(
alignment: Alignment.centerLeft,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 15, vertical: 7.5),
decoration: BoxDecoration(
color: Colors.green[50],
borderRadius: BorderRadius.circular(40),
),
child: Text(
"...",
style: TextStyle(color: Colors.black),
),
),
),
);
}
final message = messages[index];
final isUser = message.isUserMessage;
return ListTile(
title: Align(
alignment: isUser
? Alignment.centerRight
: Alignment.centerLeft,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 15, vertical: 7.5),
decoration: BoxDecoration(
color: isUser ? Colors.green : Colors.green[50],
borderRadius: BorderRadius.circular(40),
),
child: MarkdownBody(),
),
);
},
);
} else if (state is ChatError) {
return Center(child: Text("Error: ${state.error}"));
} else {
return Center(child: Text("Send a message"));
}
},
),
),
Padding(
basicIconButton(
icon: Icons.send,
onPressed: () => _sendMessage(),
),
],
),
),
],
),
),
);
}
}
핵심만 표시하기 위해 많이 줄였다. 그래도 여전히 많지만 하나하나 설명해보겠다.
그전에 BlocProvider
를 통해 상태를 정의해주어야 한다. 이것은 main에 작성해주었다.
BlocProvider(
create: (context) => ChatBloc(chatRepository: ChatRepository()),
child: MaterialApp.router(
routerConfig: router,
)
위젯 빌더는 BlocBuilder
를 사용하였다.
state is ChatLoaded || state is ChatFetching
: 상태 분기문을 통해 채팅을 나타내는 부분을 표시헀다.
_scrollToBottom()
: 메시지를 보내면 채팅 내역이 추가되기 때문에 스크롤을 내려준다.
itemCount: messages.length + (state is ChatFetching ? 1 : 0)
: 만약 채팅을 가져오는 상태가 되면 기존의 로드상태와 채팅 내역은 동등하지만 ...
이라는 텍스트 박스를 통해 메시지를 가져오는 상태를 보여주고 싶었다.
ListView.builder 의 item 개수를 상태에 따라 1개 더 추가했다.
index == messages.length && state is ChatFetching
: 메시지 크기는 size + 1이 되기 때문에 ListTile로 아이템이 빌드될 때 리스트에 실제로 크기가 더 들어가지 않지만 인덱스가 증가한다. 예를 들어 실제 메시지 크기는 10인데 itemCount는 11이 된다. 그래서 11번 반복해서 item으로 ListTile을 빌드하게 된다. 즉 index는 0부터니까 index==10 (크기가 11) 이고 state가 fetch 상태면 이부분을 리턴하게 된다.
message = messages[index]
: 이전 예시를 계속 이어가자면, fetch 상태에서 size 10인데 index는 10이 되어서(원래는 9까지) messages[10]
으로 배열 초과 예외가 발생할 일이 없다.
왜냐하면 위에 리스트타일이 조건문에 의해 이미 반환되었기 때문이다.
isUser = message.isUserMessage
: 로 반환되는 ListTile을 통해서 텍스트 컨테이너를 각 위치에 배정하게 된다.
대략적인 설명은 모두 마쳤다. 텍스트 박스에 텍스트 표시는 마크다운을 나타낼 수 있도록 했지만 설명은 하지 않았다. 간단하게 여기에 정리했으니 구경할 수 있다.
이후 방향성
개발일지가 너무 코드 설명식으로 되어서 지루했지만 Bloc을 아직 더 공부하고 싶어서 대략적인 설명만 했다. 이제 실제로 채팅하는 건 되지만 앞으로도 구현해야 할 부분이 너무 많다. 아, 코드도 리팩터링 해놓고 다음 단계로 넘어가야할 것 같다.
내가 지금 해야할 부분
- 서랍에 채팅 기록이 저장되도록 한다.
- 핵심적인 단어와 태그 CRUD 구현
- 채팅 전환 혹은 채팅 답변 완료시 다이얼 로그 추가: 단어와 태그를 등록할 내용들을 보여준다.
개선하거나 바꿀 부분
지금은 api를 보내는 방식이 아니라 서버에 env 파일로 저장해 놨다. 왜냐하면 key 보낼 때 이용자의 키를 보내면 보안에도 취약하고 일반 이용자가 직접 키를 사용하기에는 접근성이 너무 힘들지 않을까 생각했다.
그래서 key는 내껄로 쓰고 인 앱 결제 방식을 채택하는 게 어떨까?
그렇다면 BM은 인 앱 결제, 근데 무료로 사용할 수 있는 플랫폼도 있을텐데 내가 어떤 경쟁력을 갖출 수 있는 거지?
일정 무료를 풀게 된다면 나의 수익은 어떻게 되는 지 가늠이 잘 안간다. 예를들어 일정 토큰량을 무료로 사용하게 해준다면 계정을 여러 개 만들 수 있잖아. 그러면 무료로 돌리는 사람들은 어떻게 하지? 근데 그럴 사람이 있나? 나의 핵심은 단어를 좀 더 편안하게 검색하고 저장해주는 서비스인건데. 계정을 여러 개 만들면 사용하는 의미가 없어지지. 그러면 일정 무료는 괜찮을 거 같다.
이제 계정을 통해 gpt를 요청하고 api 키를 개인이 보내는 걸 하지 않을 거기 때문에 계정 로그인 방식으로 바꿔야 한다. 개인의 데이터도 그러면 DB를 따로 만들어야겠네? 하하. 복잡해지는 게 많은 것 같다. 이부분은 생각보다 기능적인 복잡함이 있지만 개인정보처리방침에도 여러 룰이 추가되기 때문에 마지막 구현으로 고려해보는 게 좋을 거 같다.
그러면 prefs 로 일단 모바일 내장 저장으로 어떤 인증도 없이 누구나 api에 접근할 수 있도록 먼저 구현해보도록 하자.
'일상 > 개발일지' 카테고리의 다른 글
[Dev] LLM을 활용 단어장 앱 개발일지 006: 단어장 UI 변경 (0) | 2024.08.27 |
---|---|
[Dev] LLM을 활용 단어장 앱 개발일지 005: CRUD - 단어 DB 읽어서 표시하기 (0) | 2024.08.20 |
[Dev] LLM을 활용 단어장 앱 개발일지 003: Fast API로 Langchain 설정하기 (0) | 2024.08.17 |
[Dev] LLM을 활용 단어장 앱 개발일지 002: key 기기 보관 (0) | 2024.08.16 |
[Dev] LLM을 활용 단어장 앱 개발 일지 001: UI 구성 (0) | 2024.08.14 |