vimeml-tiny-ja-v1 / source /src /vimeml /tools /phrase_demo.py
Voltline's picture
Release frozen VimeML tiny-ja-v1 FP32 and Core ML INT8 (GPL-2.0)
2c87024 verified
Raw History Blame Contribute Delete
11 kB
"""Local short-continuation demo and frozen qualitative suite; no training/API."""
import argparse
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import torch
from vimeml.training.data import file_sha, write_json
from vimeml.training.evaluate_ime import beam_suggestions, generate_samples, unique_suggestions
from vimeml.training.infer import JapaneseLM, ROOT
ASSETS = Path(__file__).with_name("phrase_demo.html")
PROMPTS = ROOT / "configs/phrase-demo-prompts.json"
def validate_request(data):
if not isinstance(data, dict):
raise ValueError("请求必须是JSON对象。")
prompt = data.get("prompt")
if not isinstance(prompt, str) or not 1 <= len(prompt) <= 500 or any(ord(c) < 32 for c in prompt):
raise ValueError("请输入单行日语句内前缀(1~500字符);不要带换行。")
if any(c in "。!?!?" for c in prompt):
raise ValueError("当前演示只使用句内上下文。请删除已结束的句子,填写新一句的前缀。")
mode = data.get("mode", "beam")
count, tokens, seed = data.get("count", 5), data.get("max_tokens", 8), data.get("seed", 42)
if mode not in {"beam", "sample"}:
raise ValueError("未知的生成方式。")
if type(count) is not int or not 3 <= count <= 5:
raise ValueError("候选数应为3~5。")
if type(tokens) is not int or not 4 <= tokens <= 16:
raise ValueError("新token上限应为4~16。")
if type(seed) is not int or not 0 <= seed < 2**32:
raise ValueError("seed超出范围。")
return dict(prompt=prompt, mode=mode, count=count, max_tokens=tokens, seed=seed)
def present(item):
"""Preserve the exact decoded insertion text; do not hide incomplete endings."""
if set(item["flags"]) & {"prefix_changed", "replacement_character", "empty"}:
return None
text = item["text"]
# A vocabulary piece can contain text after punctuation. Keep the first
# sentence only, retaining its punctuation, and explicitly record the edit.
end = next((i + 1 for i, c in enumerate(text) if c in "。!?!?\n"), len(text))
text = text[:end]
if not text.strip(" 、,,。!?!?\n\t"):
return None
flags = list(item["flags"])
if end < len(item["text"]):
flags.append("display_after_sentence_removed")
incomplete = item["stop_reason"] in {"max_new_tokens", "context_limit"} and not text.endswith(tuple("。!?!?"))
return {**item, "text": text, "raw_text": item["text"], "flags": flags,
"may_be_incomplete": incomplete, "new_tokens": len(item["new_token_ids"])}
class PhraseDemo:
def __init__(self, lm, cases):
self.lm, self.cases, self.lock = lm, cases, threading.Lock()
def info(self):
return {"model": self.lm.metadata, "parameters": self.lm.model.parameter_count(),
"examples": self.cases,
"note": "本地开发机FP32推理;耗时不代表iPhone键盘。建议为短续写,可能尚未完成一个词。"}
@torch.inference_mode()
def suggest(self, data):
settings = validate_request(data)
started = time.perf_counter()
prompt = settings["prompt"]
context_tokens = len(self.lm.prefix_ids(prompt))
if settings["mode"] == "beam":
raw = beam_suggestions(self.lm, prompt, max_tokens=settings["max_tokens"],
width=8, count=8, alpha=.7)
else:
greedy, sampled = generate_samples(self.lm, prompt, settings["max_tokens"], settings["seed"], attempts=8)
raw = unique_suggestions([greedy, *sampled], 9)
suggestions, seen = [], set()
for item in raw:
candidate = present(item)
if candidate is None or candidate["text"] in seen:
continue
seen.add(candidate["text"])
suggestions.append(candidate)
if len(suggestions) == settings["count"]:
break
if self.lm.device.type == "cuda":
torch.cuda.synchronize(self.lm.device)
return {**settings, "context_tokens_including_bos": context_tokens,
"elapsed_ms": (time.perf_counter() - started) * 1000,
"suggestions": suggestions, "returned_count": len(suggestions),
"discarded_invalid_outputs": sum(present(item) is None for item in raw),
"raw_outputs": raw}
def handler_for(demo):
class Handler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
pass # Do not log users' Japanese text.
def reply(self, status, data, content_type="application/json; charset=utf-8"):
raw = data if isinstance(data, bytes) else json.dumps(data, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(raw)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.end_headers()
self.wfile.write(raw)
def same_origin(self):
expected = f"127.0.0.1:{self.server.server_port}"
return self.headers.get("Host") == expected and self.headers.get("Origin", f"http://{expected}") == f"http://{expected}"
def do_GET(self):
if not self.same_origin():
return self.reply(403, {"error": "仅接受本机同源请求。"})
if self.path == "/":
return self.reply(200, ASSETS.read_bytes(), "text/html; charset=utf-8")
if self.path == "/api/info":
return self.reply(200, demo.info())
return self.reply(404, {"error": "Not found"})
def do_POST(self):
if not self.same_origin():
return self.reply(403, {"error": "仅接受本机同源请求。"})
if self.path != "/api/suggest":
return self.reply(404, {"error": "Not found"})
try:
size = int(self.headers.get("Content-Length", "0"))
if not 1 <= size <= 16384 or self.headers.get("Content-Type", "").split(';')[0] != "application/json":
return self.reply(400, {"error": "请求格式或长度错误。"})
data = json.loads(self.rfile.read(size).decode("utf-8"))
if not demo.lock.acquire(blocking=False):
return self.reply(429, {"error": "模型正在生成,请稍后重试。"})
try:
result = demo.suggest(data)
finally:
demo.lock.release()
return self.reply(200, result)
except (ValueError, UnicodeError) as error:
return self.reply(400, {"error": str(error)})
return Handler
def run_suite(demo, path, output, max_tokens=8, mode="beam"):
if output.exists() and any(output.iterdir()):
raise ValueError("Suite output is not empty; use a new --output.")
cases = []
for i, case in enumerate(demo.cases):
result = demo.suggest({"prompt": case["prompt"], "max_tokens": max_tokens, "mode": mode, "seed": 42+i})
cases.append({**case, **result})
print(f"[{i+1}/{len(demo.cases)}] {case['prompt']} -> " + ' / '.join(s['text'] for s in result['suggestions']), flush=True)
output.mkdir(parents=True, exist_ok=True)
report = {"format": "vimeml_phrase_demo_suite_v1", "model": demo.lm.metadata,
"prompts_sha256": file_sha(path), "cases": cases,
"settings": {"mode": mode, "beam_width": 8, "alpha": .7, "max_tokens": max_tokens, "count": 5,
"sampling": {"temperature": .8, "top_k": 50, "top_p": .9, "attempts": 8}},
"note": "Qualitative developer prompts, not held-out accuracy. No training/API. Timings exclude model loading and warmup; not iOS timings."}
write_json(output / "samples.json", report)
lines = ["# 短语联想演示样本", "", "只读当前模型;短续写可能在token上限处未完成。此表不自动判定自然度。", "",
"| 前缀 | 建议 | 耗时 |", "| --- | --- | ---: |"]
for case in cases:
rendered = ' / '.join(s['text'] + ('〔未完〕' if s['may_be_incomplete'] else '') for s in case['suggestions'])
lines.append(f"| {case['prompt']} | {rendered} | {case['elapsed_ms']:.0f} ms |")
(output / "samples.md").write_text('\n'.join(lines)+'\n', encoding="utf-8")
return report
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--checkpoint", type=Path, default=ROOT / "artifacts/models/tiny-ja-v1/best.pt")
parser.add_argument("--tokenizer", type=Path, default=ROOT / "artifacts/tokenizers/ja-unigram-16k-v1")
parser.add_argument("--prompts", type=Path, default=PROMPTS)
parser.add_argument("--device", choices=("cpu", "cuda"), default="cpu")
parser.add_argument("--threads", type=int, default=4)
parser.add_argument("--port", type=int, default=8765)
parser.add_argument("--suite", action="store_true", help="Run the fixed qualitative suite and exit; no server.")
parser.add_argument("--max-tokens", type=int, default=8, help="Suite new-token limit (web UI sets its own).")
parser.add_argument("--suite-mode", choices=("beam", "sample"), default="beam")
parser.add_argument("--output", type=Path, default=ROOT / "outputs/phrase-demo/tiny-ja-v1")
args = parser.parse_args(argv)
if args.threads < 1 or not 1 <= args.port <= 65535 or not 4 <= args.max_tokens <= 16:
parser.error("Invalid threads, port or token limit.")
try:
torch.set_num_threads(args.threads)
cases = json.loads(args.prompts.read_text(encoding="utf-8"))
if not isinstance(cases, list) or not cases or len({c['id'] for c in cases}) != len(cases):
raise ValueError("Expected nonempty, uniquely identified prompts.")
lm = JapaneseLM(args.checkpoint, args.tokenizer, args.device)
for _ in range(2):
lm.next_logits(lm.prefix_ids(cases[0]['prompt']))
demo = PhraseDemo(lm, cases)
if args.suite:
run_suite(demo, args.prompts, args.output, args.max_tokens, args.suite_mode)
print(f"Samples: {args.output.resolve() / 'samples.json'}")
return
server = ThreadingHTTPServer(("127.0.0.1", args.port), handler_for(demo))
print(f"Ready: http://127.0.0.1:{args.port}/\nModel loaded once; no training or API. Ctrl+C to stop.", flush=True)
try:
server.serve_forever(poll_interval=.2)
finally:
server.server_close()
except KeyboardInterrupt:
return
except (OSError, ValueError, KeyError, TypeError) as error:
parser.exit(2, f"Error: {error}\n")
if __name__ == "__main__":
main()