han2han / app.py
cadazar's picture
Clear chat button; clearing also resets the feedback records; intro says the model never sees earlier messages
e4be91a verified
Raw History Blame Contribute Delete
12.5 kB
"""Han2Han demo: Hangul and Hanja transcription with cadazar/han2han-rl.
Each message is converted on its own. Ratings and corrections of replies are
appended to a JSONL file that a CommitScheduler pushes to the dataset repo
named by the FEEDBACK_REPO variable; inputs nobody rates or corrects are not
stored.
"""
import json
import logging
import os
import re
import threading
import uuid
from datetime import datetime, timezone
from pathlib import Path
import gradio as gr
import torch
from huggingface_hub import CommitScheduler
from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
logging.basicConfig(level=logging.INFO)
logging.getLogger("httpx").setLevel(logging.WARNING)
logger = logging.getLogger("han2han-space")
MODEL_REPO = "cadazar/han2han-rl"
MODEL_REVISION = "49395d62d5f5da084b587d31d5bffdf3410004a3"
MAX_INPUT_CHARS = 2000
CHUNK_CHARS = 300
TO_HANJA = "ํ•œ๊ธ€์„ ํ•œ์ž๋กœ (Hangul to Hanja)"
TO_HANGUL = "ํ•œ์ž๋ฅผ ํ•œ๊ธ€๋กœ (Hanja to Hangul)"
TASK_PROMPTS = {
TO_HANJA: "ํ•œ๊ธ€์„ ํ•œ์ž๋กœ ์ „์‚ฌํ•˜์‹œ์˜ค:",
TO_HANGUL: "ํ•œ์ž๋ฅผ ํ•œ๊ธ€๋กœ ์ „์‚ฌํ•˜์‹œ์˜ค:",
}
EXAMPLE_HANGUL = (
"๋‹จ์ˆœํ•œ ์Šค์ผ€์น˜๋‚˜ ์นด๋ฉ”๋ผ์  ์ „์‚ฌ๊ฐ€ ์•„๋‹ˆ๋ผ ์ž‘๊ฐ€์˜ ์˜ˆ์ˆ ์  ์ •์„œ์˜ ์ „์ฒด์ ์ธ ์•ˆ๊ณ„๋กœ "
"์ด๋ฃจ์–ด์ง€๋Š” ๊ฐ€์žฅ ์‚ฌ์‹ค์ ์ธ ํ‘œํ˜„์„ ๋งํ•˜๋Š” ๊ฒƒ์ด๋‹ค."
)
EXAMPLE_MIXED = (
"ๅ–ฎ็ด”ํ•œ ์Šค์ผ€์น˜๋‚˜ ์นด๋ฉ”๋ผ็š„ ่ฝ‰ๅฏซ๊ฐ€ ์•„๋‹ˆ๋ผ ไฝœๅฎถ์˜ ่—่ก“็š„ ๆƒ…็ท–์˜ ๅ…จ้ซ”็š„์ธ ็œผ็•Œ๋กœ "
"์ด๋ฃจ์–ด์ง€๋Š” ๊ฐ€์žฅ ๅฏซๅฏฆ็š„์ธ ่กจ็พ์„ ๋งํ•˜๋Š” ๊ฒƒ์ด๋‹ค."
)
MODEL_LINK = f"[`{MODEL_REPO}`](https://huggingface.co/{MODEL_REPO})"
UI_TEXT = {
"en": {
"intro": (
"# Han2Han\n"
f"Convert Korean text between Hangul and mixed Hangul-Hanja script with {MODEL_LINK}. "
"Each message is converted on its own; the model never sees earlier messages. "
"Restoring Hanja is the hard direction: check the output against a source before "
"quoting it."
),
"direction": "Direction",
"clear": "Clear chat",
"chat_placeholder": "Type or paste Korean text below.",
"feedback": (
"Rate a reply with the thumbs, or edit it to submit a correction. Rated and "
"corrected replies are stored together with their input text in a private "
"dataset and may be used to improve the model; nothing else you enter is stored."
),
"empty_input": "Enter some text to convert.",
"too_long": "Please keep inputs under {limit} characters ({given} given).",
"user_edit": "Edit a reply to correct it; send a new message to convert other text.",
},
"ko": {
"intro": (
"# Han2Han\n"
f"{MODEL_LINK} ๋ชจ๋ธ๋กœ ํ•œ๊ตญ์–ด ํ…์ŠคํŠธ๋ฅผ ํ•œ๊ธ€ ์ „์šฉ๊ณผ ๊ตญํ•œ๋ฌธ ํ˜ผ์šฉ ์‚ฌ์ด์—์„œ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค. "
"๋ฉ”์‹œ์ง€๋Š” ํ•˜๋‚˜์”ฉ ๋”ฐ๋กœ ๋ณ€ํ™˜๋˜๋ฉฐ, ๋ชจ๋ธ์€ ์ด์ „ ๋ฉ”์‹œ์ง€๋ฅผ ๋ณด์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํ•œ์ž ๋ณต์›์€ ๋” "
"์–ด๋ ค์šด ๋ฐฉํ–ฅ์ด๋‹ˆ, ๊ฒฐ๊ณผ๋ฅผ ์ธ์šฉํ•˜๊ธฐ ์ „์— ์›๋ฌธ๊ณผ ๋Œ€์กฐํ•ด ํ™•์ธํ•˜์„ธ์š”."
),
"direction": "๋ณ€ํ™˜ ๋ฐฉํ–ฅ",
"clear": "๋Œ€ํ™” ์ง€์šฐ๊ธฐ",
"chat_placeholder": "์•„๋ž˜์— ํ•œ๊ตญ์–ด ํ…์ŠคํŠธ๋ฅผ ์ž…๋ ฅํ•˜๊ฑฐ๋‚˜ ๋ถ™์—ฌ ๋„ฃ์œผ์„ธ์š”.",
"feedback": (
"๋‹ต๋ณ€ ์•„๋ž˜์˜ ์—„์ง€ ๋ฒ„ํŠผ์œผ๋กœ ํ‰๊ฐ€ํ•˜๊ฑฐ๋‚˜, ๋‹ต๋ณ€์„ ์ง์ ‘ ๊ณ ์ณ ๊ต์ •๋ณธ์„ ๋ณด๋‚ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. "
"ํ‰๊ฐ€ํ•˜๊ฑฐ๋‚˜ ๊ต์ •ํ•œ ๋‹ต๋ณ€์€ ์ž…๋ ฅ ํ…์ŠคํŠธ์™€ ํ•จ๊ป˜ ๋น„๊ณต๊ฐœ ๋ฐ์ดํ„ฐ์…‹์— ์ €์žฅ๋˜๋ฉฐ, ๋ชจ๋ธ ๊ฐœ์„ ์— "
"์“ฐ์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ ๋ฐ–์— ์ž…๋ ฅํ•œ ๋‚ด์šฉ์€ ์ €์žฅ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."
),
"empty_input": "๋ณ€ํ™˜ํ•  ํ…์ŠคํŠธ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”.",
"too_long": "์ž…๋ ฅ์€ {limit}์ž ์ด๋‚ด๋กœ ํ•ด ์ฃผ์„ธ์š”. (ํ˜„์žฌ {given}์ž)",
"user_edit": "๊ต์ •ํ•˜๋ ค๋ฉด ๋‹ต๋ณ€์„ ๊ณ ์ณ ์ฃผ์„ธ์š”. ๋‹ค๋ฅธ ํ…์ŠคํŠธ๋ฅผ ๋ณ€ํ™˜ํ•˜๋ ค๋ฉด ์ƒˆ ๋ฉ”์‹œ์ง€๋ฅผ ๋ณด๋‚ด์„ธ์š”.",
},
}
# cpu-basic has 2 vCPUs; torch would otherwise size its pool to the host's cores
torch.set_num_threads(int(os.environ.get("TORCH_NUM_THREADS", "2")))
tokenizer = AutoTokenizer.from_pretrained(MODEL_REPO, revision=MODEL_REVISION, trust_remote_code=True)
model = AutoModelForCausalLM.from_pretrained(
MODEL_REPO, revision=MODEL_REVISION, trust_remote_code=True
).eval()
FEEDBACK_REPO = os.environ.get("FEEDBACK_REPO")
FEEDBACK_DIR = Path("feedback")
FEEDBACK_DIR.mkdir(exist_ok=True)
# one file per process, so a restart never rewrites rows already pushed
FEEDBACK_FILE = FEEDBACK_DIR / f"feedback-{uuid.uuid4().hex}.jsonl"
if FEEDBACK_REPO:
scheduler = CommitScheduler(
repo_id=FEEDBACK_REPO,
repo_type="dataset",
folder_path=FEEDBACK_DIR,
path_in_repo="data",
every=5,
private=True,
)
feedback_lock = scheduler.lock
elif os.environ.get("SPACE_ID"):
raise RuntimeError("FEEDBACK_REPO is not set: ratings and corrections would be lost on restart.")
else:
logger.warning("FEEDBACK_REPO is not set: feedback stays in %s and is not pushed.", FEEDBACK_FILE)
feedback_lock = threading.Lock()
def log_feedback(row: dict) -> None:
row = {
"time": datetime.now(timezone.utc).isoformat(),
"model": MODEL_REPO,
"revision": MODEL_REVISION,
**row,
}
with feedback_lock:
with FEEDBACK_FILE.open("a", encoding="utf-8") as f:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
logger.info("feedback logged: %s %s", row["event"], row["id"])
def message_text(content) -> str:
if isinstance(content, str):
return content
if isinstance(content, dict) and content.get("type") == "text":
return content["text"]
if isinstance(content, list):
return "".join(message_text(part) for part in content)
raise TypeError(f"unexpected chat message content: {content!r}")
def split_line(line: str) -> list[str]:
"""Pack the sentences of one line into pieces of about CHUNK_CHARS characters."""
pieces, current = [], ""
for sentence in re.split(r"(?<=[.?!ใ€‚])\s+", line.strip()):
if current and len(current) + 1 + len(sentence) > CHUNK_CHARS:
pieces.append(current)
current = sentence
else:
current = f"{current} {sentence}" if current else sentence
if current:
pieces.append(current)
return pieces
def stream_piece(task_prompt: str, text: str):
"""Yield the reply to one piece of text as it is generated (greedy decoding)."""
messages = [
{"role": "system", "content": task_prompt},
{"role": "user", "content": text},
]
inputs = tokenizer.apply_chat_template(
messages, add_generation_prompt=True, return_tensors="pt", return_dict=True
)
budget = 2 * len(tokenizer(text).input_ids) + 8
streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
errors = []
def generate():
try:
with torch.no_grad():
model.generate(**inputs, max_new_tokens=budget, do_sample=False, streamer=streamer)
except Exception as e:
logger.exception("generation failed")
errors.append(e)
streamer.end()
thread = threading.Thread(target=generate)
thread.start()
for new_text in streamer:
yield new_text
thread.join()
if errors:
raise gr.Error("Generation failed; see the Space logs.") from errors[0]
def stream_convert(direction: str, text: str):
"""Yield the converted text so far, line by line and piece by piece."""
task_prompt = TASK_PROMPTS[direction]
done_lines = []
for line in text.split("\n"):
line_out = ""
for piece in split_line(line):
prefix = f"{line_out} " if line_out else ""
piece_out = ""
for new_text in stream_piece(task_prompt, piece):
piece_out += new_text
yield "\n".join(done_lines + [prefix + piece_out])
line_out = prefix + piece_out.strip()
done_lines.append(line_out)
yield "\n".join(done_lines)
def respond(message: str, history: list, direction: str, records: dict, language: str):
"""Convert the message in the chosen direction and stream the reply into the chat."""
text = message.strip()
if not text:
raise gr.Error(UI_TEXT[language]["empty_input"])
if len(text) > MAX_INPUT_CHARS:
raise gr.Error(UI_TEXT[language]["too_long"].format(limit=MAX_INPUT_CHARS, given=len(text)))
history = history + [
{"role": "user", "content": text},
{"role": "assistant", "content": ""},
]
output = ""
for output in stream_convert(direction, text):
history[-1]["content"] = output
yield history, "", records
records = {
**records,
len(history) - 1: {"id": uuid.uuid4().hex, "direction": direction, "input": text, "output": output},
}
yield history, "", records
def message_index(index) -> int:
return index[0] if isinstance(index, (list, tuple)) else index
def on_like(data: gr.LikeData, records: dict) -> None:
record = records.get(message_index(data.index))
if record is None:
logger.warning("rating for message %s, which has no record", data.index)
return
log_feedback({"event": "rating", "liked": data.liked, **record})
def on_edit(data: gr.EditData, history: list, records: dict, language: str):
index = message_index(data.index)
record = records.get(index)
if record is None:
history[index]["content"] = message_text(data.previous_value)
gr.Info(UI_TEXT[language]["user_edit"])
return history, records
correction = message_text(data.value)
history[index]["content"] = correction
log_feedback({"event": "correction", "correction": correction, **record})
records = {**records, index: {**record, "corrected": True}}
return history, records
def detect_language(request: gr.Request) -> str:
accept = request.headers.get("accept-language") or ""
return "ko" if accept.lower().startswith("ko") else "en"
def apply_language(language: str):
text = UI_TEXT[language]
return (
gr.update(value=text["intro"]),
gr.update(label=text["direction"]),
gr.update(placeholder=text["chat_placeholder"]),
gr.update(value=text["clear"]),
gr.update(value=text["feedback"]),
)
def clear_chat():
return [], "", {}
def reset_records() -> dict:
return {}
with gr.Blocks(title="Han2Han") as demo:
with gr.Row(equal_height=False):
intro = gr.Markdown(UI_TEXT["en"]["intro"])
language = gr.Radio(
[("English", "en"), ("ํ•œ๊ตญ์–ด", "ko")],
value="en",
show_label=False,
container=False,
scale=0,
min_width=180,
)
direction = gr.Radio(list(TASK_PROMPTS), value=TO_HANJA, label=UI_TEXT["en"]["direction"])
chatbot = gr.Chatbot(
height=480,
editable="all",
placeholder=UI_TEXT["en"]["chat_placeholder"],
)
textbox = gr.Textbox(
placeholder="ํ•™๋ฌธ์„ ๋‹ฆ๊ณ  ๋•์„ ์Œ“๋‹ค",
show_label=False,
submit_btn=True,
lines=2,
max_lines=12,
)
clear_button = gr.Button(UI_TEXT["en"]["clear"], size="sm")
gr.Examples(
[[TO_HANJA, EXAMPLE_HANGUL], [TO_HANGUL, EXAMPLE_MIXED]],
inputs=[direction, textbox],
label="์˜ˆ์‹œ / Examples",
)
feedback_note = gr.Markdown(UI_TEXT["en"]["feedback"])
records = gr.State({})
demo.load(detect_language, None, language, api_visibility="private")
language.change(
apply_language,
language,
[intro, direction, chatbot, clear_button, feedback_note],
api_visibility="private",
)
submit_event = textbox.submit(
respond, [textbox, chatbot, direction, records, language], [chatbot, textbox, records]
)
clear_button.click(
clear_chat, None, [chatbot, textbox, records], cancels=[submit_event], api_visibility="private"
)
chatbot.clear(reset_records, None, records, cancels=[submit_event], api_visibility="private")
chatbot.like(on_like, [records], None, api_visibility="private")
chatbot.edit(on_edit, [chatbot, records, language], [chatbot, records], api_visibility="private")
if __name__ == "__main__":
demo.queue(default_concurrency_limit=1).launch()