feat: new openai package

Signed-off-by: yihong0618 <zouzou0208@gmail.com>
This commit is contained in:
yihong0618 2023-12-26 17:50:01 +08:00
parent d85be65266
commit 7c79433159
8 changed files with 60 additions and 446 deletions

View File

@ -19,7 +19,7 @@ bilingual_book_maker 是一个 AI 翻译工具,使用 ChatGPT 帮助用户制
- 使用 `--openai_key` 指定 OpenAI API key如果有多个可以用英文逗号分隔(xxx,xxx,xxx),可以减少接口调用次数限制带来的错误。 - 使用 `--openai_key` 指定 OpenAI API key如果有多个可以用英文逗号分隔(xxx,xxx,xxx),可以减少接口调用次数限制带来的错误。
或者,指定环境变量 `BBM_OPENAI_API_KEY` 来略过这个选项。 或者,指定环境变量 `BBM_OPENAI_API_KEY` 来略过这个选项。
- 本地放了一个 `test_books/animal_farm.epub` 给大家测试 - 本地放了一个 `test_books/animal_farm.epub` 给大家测试
- 默认用了 [GPT-3.5-turbo](https://openai.com/blog/introducing-chatgpt-and-whisper-apis) 模型,也就是 ChatGPT 正在使用的模型,用 `--model gpt3` 来使用 gpt3 模型 - 默认用了 [GPT-3.5-turbo](https://openai.com/blog/introducing-chatgpt-and-whisper-apis) 模型,也就是 ChatGPT 正在使用的模型
- 可以使用 DeepL 封装的 api 进行翻译,需要付费,[DeepL Translator](https://rapidapi.com/splintPRO/api/dpl-translator) 来获得 token `--model deepl --deepl_key ${deepl_key}` - 可以使用 DeepL 封装的 api 进行翻译,需要付费,[DeepL Translator](https://rapidapi.com/splintPRO/api/dpl-translator) 来获得 token `--model deepl --deepl_key ${deepl_key}`
- 可以使用 DeepL free `--model deeplfree` - 可以使用 DeepL free `--model deeplfree`
- 可以使用 [Claude](https://console.anthropic.com/docs) 模型进行翻译 `--model claude --claude_key ${claude_key}` - 可以使用 [Claude](https://console.anthropic.com/docs) 模型进行翻译 `--model claude --claude_key ${claude_key}`
@ -60,9 +60,6 @@ python3 make_book.py --book_name test_books/animal_farm.epub --openai_key ${open
# 指定环境变量来略过 --openai_key # 指定环境变量来略过 --openai_key
export OPENAI_API_KEY=${your_api_key} export OPENAI_API_KEY=${your_api_key}
# 或使用 gpt3 模型
python3 make_book.py --book_name test_books/animal_farm.epub --model gpt3 --language ja
# Use the DeepL model with Japanese # Use the DeepL model with Japanese
python3 make_book.py --book_name test_books/animal_farm.epub --model deepl --deepl_key ${deepl_key} --language ja python3 make_book.py --book_name test_books/animal_farm.epub --model deepl --deepl_key ${deepl_key} --language ja

View File

@ -24,7 +24,7 @@ Find more info here for using liteLLM: https://github.com/BerriAI/litellm/blob/m
- Use `--openai_key` option to specify OpenAI API key. If you have multiple keys, separate them by commas (xxx,xxx,xxx) to reduce errors caused by API call limits. - Use `--openai_key` option to specify OpenAI API key. If you have multiple keys, separate them by commas (xxx,xxx,xxx) to reduce errors caused by API call limits.
Or, just set environment variable `BBM_OPENAI_API_KEY` instead. Or, just set environment variable `BBM_OPENAI_API_KEY` instead.
- A sample book, `test_books/animal_farm.epub`, is provided for testing purposes. - A sample book, `test_books/animal_farm.epub`, is provided for testing purposes.
- The default underlying model is [GPT-3.5-turbo](https://openai.com/blog/introducing-chatgpt-and-whisper-apis), which is used by ChatGPT currently. Use `--model gpt4` to change the underlying model to `GPT4` and use `--model gpt3` to change the model to `GPT3`. - The default underlying model is [GPT-3.5-turbo](https://openai.com/blog/introducing-chatgpt-and-whisper-apis), which is used by ChatGPT currently. Use `--model gpt4` to change the underlying model to `GPT4`.
If using `GPT4`, you can add `--use_context` to add a context paragraph to each passage sent to the model for translation (see below) If using `GPT4`, you can add `--use_context` to add a context paragraph to each passage sent to the model for translation (see below)
- support DeepL model [DeepL Translator](https://rapidapi.com/splintPRO/api/dpl-translator) need pay to get the token use `--model deepl --deepl_key ${deepl_key}` - support DeepL model [DeepL Translator](https://rapidapi.com/splintPRO/api/dpl-translator) need pay to get the token use `--model deepl --deepl_key ${deepl_key}`
- support DeepL free model `--model deeplfree` - support DeepL free model `--model deeplfree`
@ -78,9 +78,6 @@ export OPENAI_API_KEY=${your_api_key}
# Use the GPT-4 model with context to Japanese # Use the GPT-4 model with context to Japanese
python3 make_book.py --book_name test_books/animal_farm.epub --model gpt4 --use_context --language ja python3 make_book.py --book_name test_books/animal_farm.epub --model gpt4 --use_context --language ja
# Use the GPT-3 model with Japanese
python3 make_book.py --book_name test_books/animal_farm.epub --model gpt3 --language ja
# Use the DeepL model with Japanese # Use the DeepL model with Japanese
python3 make_book.py --book_name test_books/animal_farm.epub --model deepl --deepl_key ${deepl_key} --language ja python3 make_book.py --book_name test_books/animal_farm.epub --model deepl --deepl_key ${deepl_key} --language ja

View File

@ -259,7 +259,7 @@ So you are close to reaching the limit. You have to choose your own value, there
"--temperature", "--temperature",
type=float, type=float,
default=1.0, default=1.0,
help="temperature parameter for `gpt3`/`chatgptapi`/`gpt4`/`claude`", help="temperature parameter for `chatgptapi`/`gpt4`/`claude`",
) )
options = parser.parse_args() options = parser.parse_args()
@ -276,7 +276,7 @@ So you are close to reaching the limit. You have to choose your own value, there
translate_model = MODEL_DICT.get(options.model) translate_model = MODEL_DICT.get(options.model)
assert translate_model is not None, "unsupported model" assert translate_model is not None, "unsupported model"
API_KEY = "" API_KEY = ""
if options.model in ["gpt3", "chatgptapi", "gpt4"]: if options.model in ["chatgptapi", "gpt4"]:
if OPENAI_API_KEY := ( if OPENAI_API_KEY := (
options.openai_key options.openai_key
or env.get( or env.get(
@ -287,6 +287,7 @@ So you are close to reaching the limit. You have to choose your own value, there
) # suggest adding `BBM_` prefix for all the bilingual_book_maker ENVs. ) # suggest adding `BBM_` prefix for all the bilingual_book_maker ENVs.
): ):
API_KEY = OPENAI_API_KEY API_KEY = OPENAI_API_KEY
# patch
else: else:
raise Exception( raise Exception(
"OpenAI API key not provided, please google how to obtain it", "OpenAI API key not provided, please google how to obtain it",
@ -373,12 +374,16 @@ So you are close to reaching the limit. You have to choose your own value, there
if options.deployment_id: if options.deployment_id:
# only work for ChatGPT api for now # only work for ChatGPT api for now
# later maybe support others # later maybe support others
assert ( assert options.model in [
options.model == "chatgptapi" "chatgptapi",
), "only support chatgptapi for deployment_id" "gpt4",
], "only support chatgptapi for deployment_id"
if not options.api_base: if not options.api_base:
raise ValueError("`api_base` must be provided when using `deployment_id`") raise ValueError("`api_base` must be provided when using `deployment_id`")
e.translate_model.set_deployment_id(options.deployment_id) e.translate_model.set_deployment_id(options.deployment_id)
# TODO refactor, quick fix for gpt4 model
if options.model == "gpt4":
e.translate_model.set_gpt4_models("gpt4")
e.make_bilingual_book() e.make_bilingual_book()

View File

@ -3,19 +3,16 @@ from book_maker.translator.chatgptapi_translator import ChatGPTAPI
from book_maker.translator.deepl_translator import DeepL from book_maker.translator.deepl_translator import DeepL
from book_maker.translator.deepl_free_translator import DeepLFree from book_maker.translator.deepl_free_translator import DeepLFree
from book_maker.translator.google_translator import Google from book_maker.translator.google_translator import Google
from book_maker.translator.gpt3_translator import GPT3
from book_maker.translator.gpt4_translator import GPT4
from book_maker.translator.claude_translator import Claude from book_maker.translator.claude_translator import Claude
from book_maker.translator.custom_api_translator import CustomAPI from book_maker.translator.custom_api_translator import CustomAPI
MODEL_DICT = { MODEL_DICT = {
"chatgptapi": ChatGPTAPI, "chatgptapi": ChatGPTAPI,
"gpt3": GPT3,
"google": Google, "google": Google,
"caiyun": Caiyun, "caiyun": Caiyun,
"deepl": DeepL, "deepl": DeepL,
"deeplfree": DeepLFree, "deeplfree": DeepLFree,
"gpt4": GPT4, "gpt4": ChatGPTAPI,
"claude": Claude, "claude": Claude,
"customapi": CustomAPI "customapi": CustomAPI
# add more here # add more here

View File

@ -2,9 +2,10 @@ import re
import time import time
from copy import copy from copy import copy
from os import environ from os import environ
from rich import print from itertools import cycle
import openai from openai import AzureOpenAI, OpenAI
from rich import print
from .base_translator import Base from .base_translator import Base
@ -13,6 +14,21 @@ PROMPT_ENV_MAP = {
"system": "BBM_CHATGPTAPI_SYS_MSG", "system": "BBM_CHATGPTAPI_SYS_MSG",
} }
GPT35_MODEL_LIST = [
"gpt-3.5-turbo",
"gpt-3.5-turbo-1106",
"gpt-3.5-turbo-16k",
"gpt-3.5-turbo-0613",
"gpt-3.5-turbo-16k-0613",
"gpt-3.5-turbo-0301",
]
GPT4_MODEL_LIST = [
"gpt-4-1106-preview",
"gpt-4",
"gpt-4-0613",
"gpt-4-0314",
]
class ChatGPTAPI(Base): class ChatGPTAPI(Base):
DEFAULT_PROMPT = "Please help me to translate,`{text}` to {language}, please return only translated content not include the origin text" DEFAULT_PROMPT = "Please help me to translate,`{text}` to {language}, please return only translated content not include the origin text"
@ -29,9 +45,8 @@ class ChatGPTAPI(Base):
) -> None: ) -> None:
super().__init__(key, language) super().__init__(key, language)
self.key_len = len(key.split(",")) self.key_len = len(key.split(","))
self.openai_client = OpenAI(api_key=key, base_url=api_base)
if api_base:
openai.api_base = api_base
self.prompt_template = ( self.prompt_template = (
prompt_template prompt_template
or environ.get(PROMPT_ENV_MAP["user"]) or environ.get(PROMPT_ENV_MAP["user"])
@ -48,9 +63,15 @@ class ChatGPTAPI(Base):
self.system_content = environ.get("OPENAI_API_SYS_MSG") or "" self.system_content = environ.get("OPENAI_API_SYS_MSG") or ""
self.deployment_id = None self.deployment_id = None
self.temperature = temperature self.temperature = temperature
# gpt3 all models for save the limit
self.model_list = cycle(GPT35_MODEL_LIST)
def rotate_key(self): def rotate_key(self):
openai.api_key = next(self.keys) self.openai_client.api_key = next(self.keys)
def rotate_model(self):
# TODO
self.model = next(self.model_list)
def create_chat_completion(self, text): def create_chat_completion(self, text):
content = self.prompt_template.format( content = self.prompt_template.format(
@ -62,49 +83,25 @@ class ChatGPTAPI(Base):
{"role": "user", "content": content}, {"role": "user", "content": content},
] ]
if self.deployment_id: completion = self.openai_client.chat.completions.create(
return openai.ChatCompletion.create( model=self.model,
engine=self.deployment_id,
messages=messages,
temperature=self.temperature,
)
return openai.ChatCompletion.create(
model="gpt-3.5-turbo-16k",
messages=messages, messages=messages,
temperature=self.temperature, temperature=self.temperature,
) )
return completion
def get_translation(self, text): def get_translation(self, text):
self.rotate_key() self.rotate_key()
self.rotate_model() # rotate all the model to aviod the limit
completion = {}
try: try:
completion = self.create_chat_completion(text) completion = self.create_chat_completion(text)
except Exception: except Exception as e:
if ( print(e)
"choices" not in completion pass
or not isinstance(completion["choices"], list)
or len(completion["choices"]) == 0
):
raise
if completion["choices"][0]["finish_reason"] != "length":
raise
# work well or exception finish by length limit # TODO work well or exception finish by length limit
choice = completion["choices"][0] t_text = completion.choices[0].message.content.encode("utf8").decode() or ""
t_text = choice.get("message").get("content", "").encode("utf8").decode()
if choice["finish_reason"] == "length":
with open("log/long_text.txt", "a") as f:
print(
f"""==================================================
The total token is too long and cannot be completely translated\n
{text}
""",
file=f,
)
return t_text return t_text
@ -278,7 +275,7 @@ The total token is too long and cannot be completely translated\n
result_list, retry_count = self.get_best_result_list( result_list, retry_count = self.get_best_result_list(
plist_len, plist_len,
new_str, new_str,
6, 6, # WTF this magic number here?
result_list, result_list,
) )
@ -295,6 +292,13 @@ The total token is too long and cannot be completely translated\n
return result_list return result_list
def set_deployment_id(self, deployment_id): def set_deployment_id(self, deployment_id):
openai.api_type = "azure"
openai.api_version = "2023-03-15-preview"
self.deployment_id = deployment_id self.deployment_id = deployment_id
self.openai_client = AzureOpenAI(
api_key=next(self.keys),
azure_endpoint=self.api_base,
api_version="2023-07-01-preview",
azure_deployment=self.deployment_id,
)
def set_gpt4_models(self, model="gpt4"):
self.model_list = cycle(GPT4_MODEL_LIST)

View File

@ -1,56 +0,0 @@
import re
import requests
from rich import print
from .base_translator import Base
class GPT3(Base):
def __init__(
self,
key,
language,
api_base=None,
prompt_template=None,
temperature=1.0,
**kwargs,
) -> None:
super().__init__(key, language)
self.api_url = (
f"{api_base}v1/completions"
if api_base
else "https://api.openai.com/v1/completions"
)
self.headers = {
"Content-Type": "application/json",
}
# TODO support more models here
self.data = {
"prompt": "",
"model": "text-davinci-003",
"max_tokens": 1024,
"temperature": temperature,
"top_p": 1,
}
self.session = requests.session()
self.language = language
self.prompt_template = (
prompt_template or "Please help me to translate, `{text}` to {language}"
)
def rotate_key(self):
self.headers["Authorization"] = f"Bearer {next(self.keys)}"
def translate(self, text):
print(text)
self.rotate_key()
self.data["prompt"] = self.prompt_template.format(
text=text,
language=self.language,
)
r = self.session.post(self.api_url, headers=self.headers, json=self.data)
if not r.ok:
return text
t_text = r.json().get("choices")[0].get("text", "").strip()
print("[bold green]" + re.sub("\n{3,}", "\n\n", t_text) + "[/bold green]")
return t_text

View File

@ -1,330 +0,0 @@
import re
import time
from copy import copy
from os import environ, linesep
import openai
from rich import print
from .base_translator import Base
PROMPT_ENV_MAP = {
"user": "BBM_CHATGPTAPI_USER_MSG_TEMPLATE",
"system": "BBM_CHATGPTAPI_SYS_MSG",
}
class GPT4(Base):
DEFAULT_PROMPT = "Please help me to translate,`{text}` to {language}, please return only translated content not include the origin text"
def __init__(
self,
key,
language,
api_base=None,
prompt_template=None,
prompt_sys_msg=None,
context_flag=False,
temperature=1.0,
**kwargs,
) -> None:
super().__init__(key, language)
self.context_flag = context_flag
self.context = "<summary>The start of the story.</summary>"
self.key_len = len(key.split(","))
if api_base:
openai.api_base = api_base
self.prompt_template = (
prompt_template
or environ.get(PROMPT_ENV_MAP["user"])
or self.DEFAULT_PROMPT
)
self.prompt_sys_msg = (
prompt_sys_msg
or environ.get(
"OPENAI_API_SYS_MSG",
) # XXX: for backward compatibility, deprecate soon
or environ.get(PROMPT_ENV_MAP["system"])
or ""
)
self.system_content = environ.get("OPENAI_API_SYS_MSG") or ""
self.deployment_id = None
self.temperature = temperature
def rotate_key(self):
openai.api_key = next(self.keys)
def create_chat_completion(self, text):
# content = self.prompt_template.format(
# text=text, language=self.language, crlf="\n"
# )
content = f"{self.context if self.context_flag else ''} {self.prompt_template.format(text=text, language=self.language, crlf=linesep)}"
sys_content = self.system_content or self.prompt_sys_msg.format(crlf="\n")
context_sys_str = "For each passage given, you may be provided a summary of the story up until this point (wrapped in tags '<summary>' and '</summary>') for context within the query, to provide background context of the story up until this point. If it's provided, use the context summary to aid you in translation with deeper comprehension, and write a new summary above the returned translation, wrapped in '<summary>' HTML-like tags, including important details (if relevant) from the new passage, retaining the most important key details from the existing summary, and dropping out less important details. If the summary is blank, assume it is the start of the story and write a summary from scratch. Do not make the summary longer than a paragraph, and smaller details can be replaced based on the relative importance of new details. The summary should be formatted in straightforward, inornate text, briefly summarising the entire story (from the start, including information before the given passage, leading up to the given passage) to act as an instructional payload for a Large-Language AI Model to fully understand the context of the passage."
sys_content = f"{self.system_content or self.prompt_sys_msg.format(crlf=linesep)} {context_sys_str if self.context_flag else ''} "
messages = [
{"role": "system", "content": sys_content},
{"role": "user", "content": content},
]
if self.deployment_id:
return openai.ChatCompletion.create(
engine=self.deployment_id,
messages=messages,
temperature=self.temperature,
)
return openai.ChatCompletion.create(
model="gpt-4-1106-preview",
messages=messages,
temperature=self.temperature,
)
def get_translation(self, text):
self.rotate_key()
completion = {}
try:
completion = self.create_chat_completion(text)
except Exception:
if (
"choices" not in completion
or not isinstance(completion["choices"], list)
or len(completion["choices"]) == 0
):
raise
if completion["choices"][0]["finish_reason"] != "length":
raise
# work well or exception finish by length limit
choice = completion["choices"][0]
t_text = choice.get("message").get("content", "").encode("utf8").decode()
if choice["finish_reason"] == "length":
with open("log/long_text.txt", "a") as f:
print(
f"""==================================================
The total token is too long and cannot be completely translated\n
{text}
""",
file=f,
)
return t_text
def translate(self, text, needprint=True):
# print("=================================================")
start_time = time.time()
# todo: Determine whether to print according to the cli option
if needprint:
print(re.sub("\n{3,}", "\n\n", text))
attempt_count = 0
max_attempts = 3
t_text = ""
while attempt_count < max_attempts:
try:
t_text = self.get_translation(text)
# Extract the text between <summary> and </summary> tags (including the tags), save the next context text, then delete it from the text.
context_match = re.search(
r"(<summary>.*?</summary>)", t_text, re.DOTALL
)
if context_match:
self.context = context_match.group(0)
t_text = t_text.replace(self.context, "", 1)
else:
pass
# self.context = ""
break
except Exception as e:
# todo: better sleep time? why sleep alawys about key_len
# 1. openai server error or own network interruption, sleep for a fixed time
# 2. an apikey has no money or reach limit, don`t sleep, just replace it with another apikey
# 3. all apikey reach limit, then use current sleep
sleep_time = int(60 / self.key_len)
print(e, f"will sleep {sleep_time} seconds")
time.sleep(sleep_time)
attempt_count += 1
if attempt_count == max_attempts:
print(f"Get {attempt_count} consecutive exceptions")
raise
# todo: Determine whether to print according to the cli option
if needprint:
print("[bold green]" + re.sub("\n{3,}", "\n\n", t_text) + "[/bold green]")
time.time() - start_time
# print(f"translation time: {elapsed_time:.1f}s")
return t_text
def translate_and_split_lines(self, text):
result_str = self.translate(text, False)
lines = result_str.splitlines()
lines = [line.strip() for line in lines if line.strip() != ""]
return lines
def get_best_result_list(
self,
plist_len,
new_str,
sleep_dur,
result_list,
max_retries=15,
):
if len(result_list) == plist_len:
return result_list, 0
best_result_list = result_list
retry_count = 0
while retry_count < max_retries and len(result_list) != plist_len:
print(
f"bug: {plist_len} -> {len(result_list)} : Number of paragraphs before and after translation",
)
print(f"sleep for {sleep_dur}s and retry {retry_count+1} ...")
time.sleep(sleep_dur)
retry_count += 1
result_list = self.translate_and_split_lines(new_str)
if (
len(result_list) == plist_len
or len(best_result_list) < len(result_list) <= plist_len
or (
len(result_list) < len(best_result_list)
and len(best_result_list) > plist_len
)
):
best_result_list = result_list
return best_result_list, retry_count
def log_retry(self, state, retry_count, elapsed_time, log_path="log/buglog.txt"):
if retry_count == 0:
return
print(f"retry {state}")
with open(log_path, "a", encoding="utf-8") as f:
print(
f"retry {state}, count = {retry_count}, time = {elapsed_time:.1f}s",
file=f,
)
def log_translation_mismatch(
self,
plist_len,
result_list,
new_str,
sep,
log_path="log/buglog.txt",
):
if len(result_list) == plist_len:
return
newlist = new_str.split(sep)
with open(log_path, "a", encoding="utf-8") as f:
print(f"problem size: {plist_len - len(result_list)}", file=f)
for i in range(len(newlist)):
print(newlist[i], file=f)
print(file=f)
if i < len(result_list):
print("............................................", file=f)
print(result_list[i], file=f)
print(file=f)
print("=============================", file=f)
print(
f"bug: {plist_len} paragraphs of text translated into {len(result_list)} paragraphs",
)
print("continue")
def join_lines(self, text):
lines = text.splitlines()
new_lines = []
temp_line = []
# join
for line in lines:
if line.strip():
temp_line.append(line.strip())
else:
if temp_line:
new_lines.append(" ".join(temp_line))
temp_line = []
new_lines.append(line)
if temp_line:
new_lines.append(" ".join(temp_line))
text = "\n".join(new_lines)
# del ^M
text = text.replace("^M", "\r")
lines = text.splitlines()
filtered_lines = [line for line in lines if line.strip() != "\r"]
new_text = "\n".join(filtered_lines)
return new_text
def translate_list(self, plist, context_flag):
sep = "\n\n\n\n\n"
# new_str = sep.join([item.text for item in plist])
new_str = ""
i = 1
for p in plist:
temp_p = copy(p)
for sup in temp_p.find_all("sup"):
sup.extract()
new_str += f"({i}) {temp_p.get_text().strip()}{sep}"
i = i + 1
if new_str.endswith(sep):
new_str = new_str[: -len(sep)]
new_str = self.join_lines(new_str)
plist_len = len(plist)
print(f"plist len = {len(plist)}")
result_list = self.translate_and_split_lines(new_str)
start_time = time.time()
result_list, retry_count = self.get_best_result_list(
plist_len,
new_str,
6,
result_list,
)
end_time = time.time()
state = "fail" if len(result_list) != plist_len else "success"
log_path = "log/buglog.txt"
self.log_retry(state, retry_count, end_time - start_time, log_path)
self.log_translation_mismatch(plist_len, result_list, new_str, sep, log_path)
# del (num), num. sometime (num) will translated to num.
result_list = [re.sub(r"^(\(\d+\)|\d+\.|(\d+))\s*", "", s) for s in result_list]
# # Remove the context paragraph from the final output
# if self.context:
# context_len = len(self.context)
# result_list = [s[context_len:] if s.startswith(self.context) else s for s in result_list]
return result_list
def set_deployment_id(self, deployment_id):
openai.api_type = "azure"
openai.api_version = "2023-03-15-preview"
self.deployment_id = deployment_id

View File

@ -3,7 +3,7 @@ from setuptools import find_packages, setup
packages = [ packages = [
"bs4", "bs4",
"openai==0.27.2", "openai>=1.1.1",
"litellm", "litellm",
"requests", "requests",
"ebooklib", "ebooklib",
@ -17,7 +17,7 @@ packages = [
setup( setup(
name="bbook_maker", name="bbook_maker",
description="The bilingual_book_maker is an AI translation tool that uses ChatGPT to assist users in creating multi-language versions of epub/txt files and books.", description="The bilingual_book_maker is an AI translation tool that uses ChatGPT to assist users in creating multi-language versions of epub/txt files and books.",
version="0.5.1", version="0.6.0",
license="MIT", license="MIT",
author="yihong0618", author="yihong0618",
author_email="zouzou0208@gmail.com", author_email="zouzou0208@gmail.com",