Download app.py from cadazar/han2han: direct link, hf CLI and curl.
- Browser
- Download file 12.5 kB
-
https://huggingface.co/spaces/cadazar/han2han/resolve/main/app.py
- Command line
-
hf download hf://spaces/cadazar/han2han/app.py
-
curl -L -o app.py https://huggingface.co/spaces/cadazar/han2han/resolve/main/app.py
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() | |