telegram_bot_ai / app.py
tyagiAdarsh's picture
Update app.py
20572e9 verified
Raw
History Blame Contribute Delete
10.2 kB
import json
import logging
import re
from typing import Dict, Optional, Tuple
import torch
from fastapi import FastAPI
from pydantic import BaseModel
from transformers import AutoTokenizer, AutoModelForCausalLM
# =========================================================
# CONFIG
# =========================================================
MODEL_NAME = "Qwen/Qwen2.5-1.5B-Instruct"
MAX_NEW_TOKENS = 512
RETRY_ATTEMPTS = 2 # first deterministic, then sampled attempt if validation fails
app = FastAPI(title="Japanese Corrector API (Qwen Edition)")
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("japanese-corrector")
# =========================================================
# LOAD MODEL (CPU)
# =========================================================
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
# Ensure pad token exists
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME,
torch_dtype=torch.float32,
device_map="cpu"
)
model.eval()
class InferenceRequest(BaseModel):
input: str
# =========================================================
# SYSTEM PROMPT (enhanced)
# =========================================================
SYSTEM_PROMPT = """
You are a certified Japanese language instructor (JLPT-focused).
Your task is to correct the learner's Japanese sentence into natural, grammatically correct Japanese,
while strictly preserving the original meaning and polarity.
You must follow JLPT N5–N3 pedagogy.
CRITICAL CONSISTENCY RULE:
- You may ONLY explain particle changes that actually occur in the final corrected sentence.
- If a particle is not changed, explicitly state that it remains unchanged and explain why it is correct as-is.
- NEVER assert a substitution (e.g., 'は β†’ が') unless that substitution appears in the final 'kanji' output.
PARTICLE EXPLANATION REQUIREMENTS:
- When correcting particles (γ―γƒ»γŒγƒ»γ‚’γƒ»γ«), explicitly explain the grammatical reason using JLPT-friendly terminology.
- Use concise "Particle Logic: ... | Grammar: ..." format in the 'explanation' field.
OUTPUT FORMAT (STRICT):
Return ONLY valid JSON with this schema:
{
"kanji": "...",
"hiragana": "...",
"explanation": "Particle Logic: ... | Grammar: ..."
}
IMPORTANT RULES:
- Do NOT change meaning or polarity.
- Do NOT invent particles or verb forms.
- Explanations must be precise, neutral, and instructional (teacher tone).
"""
# =========================================================
# JSON EXTRACTION (robust)
# =========================================================
def find_balanced_json(text: str) -> Optional[str]:
"""Find the first balanced { ... } JSON object in text (handles nested braces)."""
start = None
depth = 0
for i, ch in enumerate(text):
if ch == '{':
if start is None:
start = i
depth += 1
elif ch == '}':
if depth > 0:
depth -= 1
if depth == 0 and start is not None:
return text[start:i + 1]
return None
def extract_json(text: str) -> Optional[Dict]:
"""Clean and parse JSON from model output robustly."""
# remove common markdown fences first
text = re.sub(r"```(?:json)?\s*", "", text)
text = re.sub(r"\s*```", "", text)
candidate = find_balanced_json(text)
if not candidate:
return None
try:
return json.loads(candidate)
except Exception as e:
logger.error("JSON parse failed for candidate: %s; error: %s", candidate, e)
return None
# =========================================================
# VALIDATION
# =========================================================
SUBSTITUTION_PATTERNS = [
re.compile(r"は\s*(?:β†’|->)\s*が"),
re.compile(r"が\s*(?:β†’|->)\s*γ‚’"),
re.compile(r"γ‚’\s*(?:β†’|->)\s*が"),
# optionally add Japanese wording patterns if you commonly see them
re.compile(r"は\s*β†’\s*が"),
re.compile(r"が\s*β†’\s*γ‚’"),
re.compile(r"γ‚’\s*β†’\s*が"),
]
def mentions_explicit_substitution(explanation: str) -> bool:
"""Detect explicit substitution statements like 'は β†’ が' in explanation."""
for p in SUBSTITUTION_PATTERNS:
if p.search(explanation):
return True
return False
def validate_explanation_consistency(kanji: str, explanation: str) -> Tuple[bool, str]:
"""
Ensure model does not claim replacements that didn't occur.
If explanation contains explicit substitution arrows, verify that the target particle exists
in the final 'kanji' output.
"""
if not explanation:
return False, "Empty explanation"
if mentions_explicit_substitution(explanation):
# If explanation mentions γ―β†’γŒ, verify 'が' present in kanji
if re.search(r"は\s*(?:β†’|->)\s*が", explanation) and "が" not in kanji:
return False, "Explanation claims γ―β†’γŒ but final sentence lacks 'が'"
if re.search(r"が\s*(?:β†’|->)\s*γ‚’", explanation) and "γ‚’" not in kanji:
return False, "Explanation claims γŒβ†’γ‚’ but final sentence lacks 'γ‚’'"
if re.search(r"γ‚’\s*(?:β†’|->)\s*が", explanation) and "が" not in kanji:
return False, "Explanation claims γ‚’β†’γŒ but final sentence lacks 'が'"
# Passed basic checks
return True, ""
# =========================================================
# GENERATION (with retry and validation)
# =========================================================
def build_prompt(user_input: str) -> str:
"""Concatenate system + user into a single string prompt for non-chat tokenizers."""
# Keep it explicit so LLM knows to return ONLY JSON
return SYSTEM_PROMPT.strip() + "\n\nUser: Correct this Japanese: " + user_input.strip()
def generate_once(prompt_text: str, do_sample: bool = False, temperature: float = 0.0, top_p: float = 1.0) -> str:
model_inputs = tokenizer(prompt_text, return_tensors="pt", padding=True)
input_ids = model_inputs["input_ids"]
attention_mask = model_inputs.get("attention_mask", None)
with torch.no_grad():
outputs = model.generate(
input_ids=input_ids.to("cpu"),
attention_mask=attention_mask.to("cpu") if attention_mask is not None else None,
max_new_tokens=MAX_NEW_TOKENS,
do_sample=do_sample,
temperature=temperature,
top_p=top_p,
repetition_penalty=1.1,
pad_token_id=tokenizer.pad_token_id,
eos_token_id=tokenizer.eos_token_id,
)
# outputs is [batch, seq_len]; remove the prompt portion
prompt_len = input_ids.shape[1]
generated = outputs[:, prompt_len:]
decoded = tokenizer.batch_decode(generated, skip_special_tokens=True)
return decoded[0].strip()
def generate_response(user_input: str) -> Tuple[Optional[Dict], str]:
"""
Try deterministic generation first. If explanation validation fails, retry once with light sampling.
Returns (parsed_json_or_none, raw_output).
"""
prompt = build_prompt(user_input)
attempts = [
{"do_sample": False, "temperature": 0.0, "top_p": 1.0},
{"do_sample": True, "temperature": 0.25, "top_p": 0.9},
][:RETRY_ATTEMPTS]
last_raw = ""
for attempt_idx, attempt_cfg in enumerate(attempts, start=1):
try:
raw = generate_once(prompt, **attempt_cfg)
except Exception as e:
logger.exception("Model generation error on attempt %d: %s", attempt_idx, e)
last_raw = f"GENERATION_ERROR: {e}"
continue
last_raw = raw
logger.info("Generation attempt %d raw output: %s", attempt_idx, raw[:1000])
parsed = extract_json(raw)
if not parsed:
logger.info("Attempt %d: could not parse JSON, will retry if attempts remain.", attempt_idx)
continue
# Validate schema minimally
kanji = parsed.get("kanji", "")
explanation = parsed.get("explanation", "")
ok, reason = validate_explanation_consistency(kanji, explanation)
if ok:
return parsed, raw
else:
logger.warning("Attempt %d: Explanation consistency failed: %s", attempt_idx, reason)
# continue to next attempt (if any) to try regenerate
continue
# If all attempts fail, return last parsed (if any) or None
parsed_fallback = extract_json(last_raw) if last_raw else None
return parsed_fallback, last_raw
# =========================================================
# ENDPOINT
# =========================================================
@app.post("/infer")
def infer(req: InferenceRequest):
user_text = req.input.strip()
if not user_text:
return {"status": "error", "result": {"explanation": "Empty input"}}
parsed, raw_output = generate_response(user_text)
logger.info("Final raw output (truncated): %s", (raw_output or "")[:1200])
if parsed:
# final safety validate again and return helpful error if still inconsistent
kanji = parsed.get("kanji", "")
explanation = parsed.get("explanation", "")
ok, reason = validate_explanation_consistency(kanji, explanation)
if not ok:
return {
"status": "error",
"result": {
"kanji": kanji,
"hiragana": parsed.get("hiragana", ""),
"explanation": f"Rejected by server-side consistency check: {reason}",
"raw_debug": raw_output,
},
}
return {
"status": "success",
"result": {
"kanji": kanji,
"hiragana": parsed.get("hiragana", ""),
"explanation": explanation,
},
}
# no parsed JSON available
return {
"status": "error",
"result": {
"kanji": "",
"hiragana": "",
"explanation": "Could not parse model response as JSON after retries.",
"raw_debug": raw_output,
},
}