🧒

ChatGPTのAPI + Flutter で子供の質問に答えるアプリを作ってみた

2023/04/18に公開

作ったもの

アプリの構成

MVVM + Repository で実装しています。

Service

Singleton な Service として、Chat Completion APIModerations APIにアクセスする関数を提供します。サードパーティ製のOpenAI SDKである dart_openai を使わせてもらいました。
https://pub.dev/packages/dart_openai

openai_service.dart
class OpenAIService {
  static const defaultChatModel = 'gpt-3.5-turbo-0301';

  static final OpenAIService _instance = OpenAIService._internal();

  factory OpenAIService() => _instance;

  OpenAIService._internal();

  static initialize(String apiKey) {
    OpenAI.apiKey = apiKey;
  }
  
  Stream<OpenAIStreamChatCompletionModel> postMessageStream(List<OpenAIChatCompletionChoiceMessageModel> messages,
      {String model = defaultChatModel}) {
    final stream = OpenAI.instance.chat.createStream(
      model: model,
      messages: messages,
    );
    return stream;
  }

  Future<OpenAIModerationModel> moderate(String input) {
    return OpenAI.instance.moderation.create(
      input: input,
    );
  }
}

Repository

Repository は、ViewModel から送られてくる質問文を String 型で受け取って、回答をStream<String> 型で返します。
こども向けのアプリということで、質問をModerations APIに投げてから、問題ない場合にだけ回答を返すような getAnswerStreamWithModeration 関数を提供します。

qa_repository.dart
class QaRepository {

  final openAiService = OpenAIService();

  Future<bool> _moderate(String input) {
    return openAiService.moderate(input).then((value) {
      final result = value.results.firstOrNull;
      if (result == null) {
        throw Exception('Moderation failed');
      }
      return result.flagged;
    });
  }

  Stream<String> getAnswerStream(String query) {
    final messages = [
      OpenAIChatCompletionChoiceMessageModel(
        role: OpenAIChatMessageRole.system,
        content: 'あなたは小学生向けのアシスタントです。この後の質問には、漢字を一切使わずに子供向けにわかりやすく答えてください。',
      ),
      OpenAIChatCompletionChoiceMessageModel(role: OpenAIChatMessageRole.user, content: query),
    ];

    final answerStream = openAiService.postMessageStream(messages).scan('', (answerPrev, response) {
      final deltaContent = response.choices.firstOrNull?.delta.content ?? '';
      final answer = answerPrev + deltaContent;
      return answer;
    });

    return answerStream;
  }

  Stream<String> getAnswerStreamWithModeration(String query) {
    final moderationStream = _moderate(query).asStream();

    return moderationStream.switchMap((flagged) {
      if (flagged) {
        return Stream.fromIterable(['この質問は自分や他人を傷つける危険があるため回答できません。']);
      }
      return getAnswerStream(query);
    });
  }
}

ViewModel

ViewModel は画面から送られてきた質問文を受け取って Repository に渡し、回答を状態として保持します。

answer_viewmodel.dart
import 'dart:async';

import 'package:flutter_kids_qa/model/answer_viewmodel_model.dart';
import 'package:flutter_kids_qa/repository/qa_repository.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

class AnswerViewModelNotifier extends StateNotifier<AnswerViewModel> {
  AnswerViewModelNotifier({required qaRepository, required query})
      : _qaReporitory = qaRepository,
        _query = query,
        super(AnswerViewModel.empty()) {
    _subscription = _qaReporitory.getAnswerStreamWithModeration(query).listen((answer) {
      state = state.copyWith(answer: answer);
    });
  }

  final QaRepository _qaReporitory;
  final String _query;
  late final StreamSubscription _subscription;

  onDispose() {
    _subscription.cancel();
  }
}

final answerViewModelProvider =
    StateNotifierProvider.family.autoDispose<AnswerViewModelNotifier, AnswerViewModel, String>((ref, query) {
  final notifier = AnswerViewModelNotifier(qaRepository: ref.read(qaRepositoryProvider), query: query);
  ref.onDispose(notifier.onDispose);
  return notifier;
});
answer_viewmodel_model.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'answer_viewmodel_model.freezed.dart';


class AnswerViewModel with _$AnswerViewModel {
  const AnswerViewModel._();

  const factory AnswerViewModel({
    required String answer,
  }) = _AnswerViewModel;

  factory AnswerViewModel.empty() => const AnswerViewModel(answer: '');
}

View

ViewModel に質問文を送って、ViewModel に保持されている回答を Widget に反映します。

answer_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_kids_qa/screen/answer_viewmodel.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

class AnswerScreen extends StatelessWidget {
  const AnswerScreen({super.key, required this.query});

  static const String name = 'Answer';

  final String query;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('こたえ'),
        centerTitle: true,
      ),
      body: SingleChildScrollView(
        child: Padding(
          padding: const EdgeInsets.all(32.0),
          child: SizedBox(
            width: double.infinity,
            child: Column(
              mainAxisAlignment: MainAxisAlignment.start,
              children: [
                Text(
                  query,
                  style: Theme.of(context).textTheme.headlineSmall,
                ),
                const SizedBox(height: 32),
                Align(
                  alignment: Alignment.topLeft,
                  child: AnswerWidget(query: query),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

class AnswerWidget extends ConsumerWidget {
  const AnswerWidget({super.key, required this.query});
  final String query;

  
  Widget build(BuildContext context, WidgetRef ref) {
    final viewModel = ref.watch(answerViewModelProvider(query));
    return Text(
      viewModel.answer,
      style: Theme.of(context).textTheme.headlineMedium?.copyWith(height: 1.5),
    );
  }
}

免責

「作ってみた」のレベルなので、以下は実装していません。

  • トークン制限への対処:GPT系のAPIはトークン数の制限(gpt-3.5-turbo-0301 なら4096tokens)があるため、長文の質問は受け付けない、回答が途中で切れた場合は続きを返してもらう。などの処理が必要です。
  • API Keyの隠蔽:モバイルアプリにAPI Keyを埋め込むことは漏洩リスクがあるため、本来はバックエンドサービスを用意するべきです。
  • 例外処理:インターネットがつながらない状況やOpenAIのAPIがダウンしている状況などは考慮していません。
  • 音声入力:小学生くらいを想定したアプリなので、キーボードではなく音声で質問を入力させたいところです。
  • プロンプトの調整:プロンプトで 漢字を一切使わずに と指示しているものの回答に漢字が含まれているため調整が必要です。

完全なコード

https://github.com/intoffset/flutter_kids_qa

Discussion