guia-idem-api / app.py
RafelSV's picture
Update app.py
34e440a verified
Raw
History Blame Contribute Delete
26.5 kB
import os
import re
import sys
import threading
import time
import unicodedata
from typing import Any
import torch
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from transformers import AutoModelForCausalLM, AutoTokenizer
sys.path.append("/app/iDEM_writing_assistant")
sys.path.append("/app")
try:
from core.correctifier import Correctifier
except Exception:
Correctifier = None
app = FastAPI()
_model_lock = threading.Lock()
_generation_lock = threading.Lock()
_model = None
_model_error = None
MODEL_ALIASES = {
"QWEN05B": "Qwen/Qwen2.5-0.5B-Instruct",
"LLAMA1B": "meta-llama/Llama-3.2-1B-Instruct",
"GEMMA2B": "google/gemma-2-2b-it",
"SALAMANDRA2B": "BSC-LT/salamandra-2b-instruct",
"LLAMA3B": "meta-llama/Llama-3.2-3B-Instruct",
"GEMMA4B": "google/gemma-3-4b-it",
"OLMO7B": "allenai/OLMo-2-1124-7B-Instruct",
"SALAMANDRA7B": "BSC-LT/salamandra-7b-instruct",
}
OFFICIAL_IDEM_MODELS = {
"LLAMA1B",
"GEMMA2B",
"SALAMANDRA2B",
"LLAMA3B",
"GEMMA4B",
"OLMO7B",
"SALAMANDRA7B",
}
CPU_THREADS = max(1, min(int(os.getenv("IDEM_CPU_THREADS", "2")), os.cpu_count() or 1))
torch.set_num_threads(CPU_THREADS)
try:
torch.set_num_interop_threads(1)
except RuntimeError:
pass
LANGUAGE_NAMES = {
"en": "English",
"es": "Spanish",
"ca": "Catalan",
}
FALLBACKS = {
"en": "I do not have enough information in the context to answer.",
"es": "No tengo informacion suficiente en el contexto para responder.",
"ca": "No tinc prou informacio en el context per respondre.",
}
IDEM_SIMPLIFICATION_PROMPTS = {
"en": (
"Please rewrite the following complex sentence in order to make it easier to understand by non-native "
"speakers of English. You can do so by replacing complex words with simpler synonyms (i.e. paraphrasing), "
"deleting unimportant information (i.e. compression), and/or splitting a long complex sentence into "
"several simpler ones. The final simplified sentence needs to be grammatical, fluent, and retain the "
"main ideas of its original counterpart without altering its meaning.\n"
"Return one reformulation. Do not generate any text except the reformulation."
),
"es": (
"Por favor, reescriba la siguiente oración compleja para que sea más fácil de entender para quienes no "
"hablan español como lengua materna. Puede hacerlo reemplazando palabras complejas con sinónimos más "
"simples, eliminando información irrelevante o dividiendo una oración compleja larga en varias más "
"simples. La oración simplificada final debe ser gramaticalmente correcta, fluida y conservar las ideas "
"principales de su contraparte original sin alterar su significado.\n"
"Da una reformulación. No genere ningún texto excepto la reformulación."
),
"ca": (
"Reescriviu la següent frase complexa per tal que sigui més fàcil d'entendre per a parlants "
"no nadius del català. Podeu fer-ho substituint paraules complexes per sinònims més simples, eliminant "
"informació no important o dividint una frase llarga i complexa en diverses de més simples. La frase "
"simplificada final ha de ser gramatical, fluida i conservar les idees principals de la seva contrapart "
"original sense alterar-ne el significat.\n"
"Dona una reformulació. No genereu cap text excepte la reformulació."
),
}
class AnswerRequest(BaseModel):
question: str = ""
prompt: str | None = None
text: str | None = None
context: list[dict[str, Any]] | None = None
rows: list[dict[str, Any]] | None = None
graph_context: dict[str, Any] | None = None
language: str = "es"
room: str | None = None
artwork: str | None = None
visitor_profile: str | None = None
personality: str | None = None
instructions: str | None = None
max_new_tokens: int | None = None
class IdemGenerator:
def __init__(self, model_name: str, token: str | None, device: torch.device):
self.device = device
self.model_name = model_name.upper()
self.use_official_correctifier = (
os.getenv("IDEM_USE_OFFICIAL_CORRECTIFIER", "true").strip().lower() != "false"
and Correctifier is not None
and self.model_name in OFFICIAL_IDEM_MODELS
)
if self.use_official_correctifier:
print("Loading official iDEM Correctifier", self.model_name, "device", device, flush=True)
self.correctifier = Correctifier(self.model_name, token, device, lang="en-multi")
self.model = self.correctifier.model
self.tokenizer = self.correctifier.tokenizer
self.model_id = self.correctifier.pretrained_model
print("Official iDEM Correctifier loaded.", flush=True)
return
self.correctifier = None
self.model_id = MODEL_ALIASES.get(self.model_name, model_name)
self.tokenizer = AutoTokenizer.from_pretrained(self.model_id, token=token, use_fast=True)
if self.tokenizer.pad_token_id is None:
self.tokenizer.pad_token = self.tokenizer.eos_token
dtype = torch.bfloat16 if device.type == "cuda" else torch.float32
kwargs: dict[str, Any] = {"token": token, "torch_dtype": dtype}
if device.type == "cuda":
kwargs["device_map"] = "auto"
print(
"Loading model",
self.model_id,
"device",
device,
"cuda",
torch.cuda.is_available(),
"gpus",
torch.cuda.device_count(),
"dtype",
dtype,
"cpu_threads",
CPU_THREADS,
flush=True,
)
self.model = AutoModelForCausalLM.from_pretrained(self.model_id, **kwargs).eval()
if device.type != "cuda":
self.model.to(device)
self.model.generation_config.pad_token_id = self.tokenizer.pad_token_id
self.model.generation_config.do_sample = False
self.model.generation_config.temperature = None
self.model.generation_config.top_p = None
self.model.generation_config.top_k = None
print("Model loaded.", flush=True)
print(
"Model on GPU: "
+ str(round(100 * sum(param.is_cuda for param in self.model.parameters()) / len(list(self.model.parameters()))))
+ "%",
flush=True,
)
def render_prompt(self, prompt: str) -> str:
messages = [{"role": "user", "content": prompt}]
try:
return self.tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
)
except Exception:
return prompt
def generate(self, prompt: str, max_new_tokens: int) -> str:
max_input_tokens = int(os.getenv("IDEM_MAX_INPUT_TOKENS", "1024"))
rendered_prompt = self.render_prompt(prompt)
inputs = self.tokenizer(
rendered_prompt,
return_tensors="pt",
return_token_type_ids=False,
truncation=True,
max_length=max_input_tokens,
)
inputs = {key: value.to(self.device) for key, value in inputs.items()}
input_len = inputs["input_ids"].shape[-1]
print("Input tokens:", input_len, "Max new tokens:", max_new_tokens, flush=True)
start = time.perf_counter()
with torch.inference_mode():
generation = self.model.generate(
**inputs,
max_new_tokens=max_new_tokens,
do_sample=False,
num_beams=1,
use_cache=True,
eos_token_id=self.tokenizer.eos_token_id,
pad_token_id=self.tokenizer.pad_token_id,
)
elapsed = time.perf_counter() - start
print("Response time:", round(elapsed, 2), flush=True)
output_ids = generation[0][input_len:]
return self.tokenizer.decode(output_ids, skip_special_tokens=True)
def simplify_with_official_idem(self, text: str, lang: str) -> str:
if not self.use_official_correctifier:
return ""
self.correctifier.lang = lang
prompt = IDEM_SIMPLIFICATION_PROMPTS.get(lang, IDEM_SIMPLIFICATION_PROMPTS["en"])
try:
return self.correctifier.correct(text, force_prompt=prompt)
finally:
self.correctifier.lang = "en-multi"
def language_code(value: str | None) -> str:
key = (value or "es").strip().lower()
if key in {"english", "eng", "en"}:
return "en"
if key in {"spanish", "espanol", "es"}:
return "es"
if key in {"catalan", "catala", "ca"}:
return "ca"
return "es"
def load_model():
global _model, _model_error
with _model_lock:
if _model is not None:
return _model
if _model_error is not None:
raise _model_error
try:
default_model = "SALAMANDRA2B" if torch.cuda.is_available() else "QWEN05B"
model_name = os.getenv("IDEM_MODEL", default_model)
token = os.getenv("HF_TOKEN") or os.getenv("IDEM_HF_TOKEN")
device = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")
_model = IdemGenerator(model_name, token, device)
return _model
except Exception as exc:
_model_error = exc
raise
@app.on_event("startup")
def warmup_model():
thread = threading.Thread(target=load_model, daemon=True)
thread.start()
def rows_to_context(rows: list[dict[str, Any]] | None) -> str:
if not rows:
return "No context is available."
parts = []
max_rows = int(os.getenv("IDEM_MAX_CONTEXT_ROWS", "3"))
max_value_chars = int(os.getenv("IDEM_MAX_CONTEXT_VALUE_CHARS", "700"))
max_total_chars = int(os.getenv("IDEM_MAX_CONTEXT_CHARS", "2500"))
for index, row in enumerate(rows[:max_rows], start=1):
values = []
for key, value in row.items():
if value is None:
continue
if isinstance(value, str) and not value.strip():
continue
text = str(value).strip()
if len(text) > max_value_chars:
text = text[:max_value_chars].rsplit(" ", 1)[0].strip() + "..."
values.append(f"{key}: {text}")
if values:
parts.append(f"{index}. " + "\n".join(values))
context = "\n\n".join(parts) if parts else "No context is available."
if len(context) > max_total_chars:
context = context[:max_total_chars].rsplit(" ", 1)[0].strip() + "..."
return context
def normalize_key(value: str) -> str:
normalized = unicodedata.normalize("NFKD", str(value))
ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
return re.sub(r"[^a-z0-9]+", "_", ascii_text.lower()).strip("_")
def truncate_text(text: str, max_chars: int) -> str:
if len(text) <= max_chars:
return text
cut = text[:max_chars].strip()
sentence_end = max(cut.rfind("."), cut.rfind("!"), cut.rfind("?"))
if sentence_end >= max_chars * 0.55:
return cut[:sentence_end + 1].strip()
word_end = cut.rfind(" ")
if word_end >= max_chars * 0.55:
return cut[:word_end].strip() + "..."
return cut + "..."
def clean_value(value: Any, max_chars: int = 220) -> str:
if value is None:
return ""
if isinstance(value, (list, tuple, set)):
text = ", ".join(clean_value(item, max_chars=max_chars) for item in value)
else:
text = str(value)
text = text.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\t", " ")
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text).strip()
if not text or text.lower() in {"none", "null", "nan", "not provided"}:
return ""
if text.startswith(("http://", "https://")):
return ""
return truncate_text(text, max_chars)
FIELD_ALIASES = {
"title": ("title", "titol", "titulo", "obra", "artwork", "nom_obra", "name"),
"artist": ("artist", "artista", "autor", "autoria", "creator", "created_by"),
"date": ("date", "data", "any", "year", "cronologia", "chronology", "datacio"),
"room": ("room", "sala", "ubicacio", "location", "palau"),
"material": ("material", "materials", "technique", "tecnica", "medium"),
"description": (
"description",
"descripcio",
"descripcion",
"text",
"resum",
"summary",
"explicacio",
"comentari",
"context",
),
}
def row_value(row: dict[str, Any], field: str) -> str:
aliases = FIELD_ALIASES[field]
normalized_items = [(normalize_key(key), value) for key, value in row.items()]
for key, value in normalized_items:
if key in aliases or key.split("_")[-1] in aliases:
cleaned = clean_value(value)
if cleaned:
return cleaned
for key, value in normalized_items:
if any(alias in key for alias in aliases):
cleaned = clean_value(value)
if cleaned:
return cleaned
return ""
def best_context_row(rows: list[dict[str, Any]], req: AnswerRequest) -> dict[str, Any]:
if not rows:
return {}
wanted = " ".join(
clean_value(value, max_chars=120).lower()
for value in (req.artwork, req.room, req.question)
if clean_value(value, max_chars=120)
)
if not wanted:
return rows[0]
def score(row: dict[str, Any]) -> int:
text = " ".join(clean_value(value, max_chars=500).lower() for value in row.values())
return sum(1 for word in re.findall(r"\w+", wanted) if len(word) > 3 and word in text)
return max(rows, key=score)
def text_looks_english(text: str) -> bool:
words = set(re.findall(r"[a-zA-Z]+", text.lower()))
english_markers = {
"the", "with", "and", "of", "from", "that", "this", "it", "is", "are",
"was", "were", "had", "has", "still", "also", "very", "likely", "because",
}
return len(words & english_markers) >= 3
def text_matches_language(text: str, lang: str) -> bool:
if not text:
return True
if lang in {"ca", "es"} and text_looks_english(text):
return False
return True
def is_meta_reformulation(text: str) -> bool:
lower = text.lower().strip()
meta_prefixes = (
"here are",
"here is",
"rephrasing",
"rewrite",
"si us plau",
"aquí tenen",
"aqui tenen",
"aquí tens",
"aqui tens",
"te proporciono",
"a continuación",
"a continuacion",
)
return (
lower.startswith(meta_prefixes)
or lower == "si us plau"
or "reformulació" in lower
or "reformulación" in lower
or "reformulation" in lower
)
def extractive_answer(req: AnswerRequest, lang: str, description_override: str | None = None) -> str:
rows = req.context or req.rows or []
if not rows:
return ""
row = best_context_row(rows, req)
title = clean_value(req.artwork) or row_value(row, "title")
artist = row_value(row, "artist")
date = row_value(row, "date")
room = clean_value(req.room) or row_value(row, "room")
material = row_value(row, "material")
description = row_value(row, "description") if description_override is None else description_override
if lang == "ca":
lines = []
if title:
lines.append(f"L'obra es diu {title}.")
if artist:
lines.append(f"La va fer {artist}.")
if date:
lines.append(f"És de {date}.")
if material:
lines.append(f"Està feta amb {material}.")
if description:
lines.append(description)
if room:
lines.append(f"La pots veure a {room}.")
elif lang == "es":
lines = []
if title:
lines.append(f"La obra se llama {title}.")
if artist:
lines.append(f"La hizo {artist}.")
if date:
lines.append(f"Es de {date}.")
if material:
lines.append(f"Está hecha con {material}.")
if description:
lines.append(description)
if room:
lines.append(f"La puedes ver en {room}.")
else:
lines = []
if title:
lines.append(f"The artwork is called {title}.")
if artist:
lines.append(f"It was made by {artist}.")
if date:
lines.append(f"It is from {date}.")
if material:
lines.append(f"It is made with {material}.")
if description:
lines.append(description)
if room:
lines.append(f"You can see it in {room}.")
cleaned_lines = []
for line in lines:
line = clean_value(line, max_chars=260)
if line and line not in cleaned_lines:
cleaned_lines.append(line)
return "\n".join(cleaned_lines[:5])
def build_factual_draft(req: AnswerRequest, lang: str) -> str:
rows = req.context or req.rows or []
if not rows:
return ""
row = best_context_row(rows, req)
title = clean_value(req.artwork) or row_value(row, "title")
artist = row_value(row, "artist")
date = row_value(row, "date")
room = clean_value(req.room) or row_value(row, "room")
material = row_value(row, "material")
description = row_value(row, "description")
facts = []
if title:
facts.append(f"Title: {title}")
if artist:
facts.append(f"Artist: {artist}")
if date:
facts.append(f"Date: {date}")
if material:
facts.append(f"Material: {material}")
if room:
facts.append(f"Room: {room}")
if description:
facts.append(f"Description: {clean_value(description, max_chars=1400)}")
return "\n".join(facts)
def question_asks_artist(question: str) -> bool:
normalized = normalize_key(question)
terms = {"artista", "autor", "autoria", "artist", "author", "creator", "creador"}
return any(term in normalized for term in terms)
def sentence_join(lines: list[str]) -> str:
return "\n".join(line for line in lines if line)
def normalize_answer_text(text: str) -> str:
text = str(text or "").replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\t", " ")
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r" *\n *", "\n", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def build_grounded_answer(req: AnswerRequest, lang: str) -> str:
rows = req.context or req.rows or []
if not rows:
return ""
row = best_context_row(rows, req)
title = clean_value(req.artwork) or row_value(row, "title")
artist = row_value(row, "artist")
date = row_value(row, "date")
room = clean_value(req.room) or row_value(row, "room")
material = row_value(row, "material")
description = row_value(row, "description")
if question_asks_artist(req.question):
if not artist:
return ""
if lang == "ca":
return f"L'artista de {title or 'aquesta obra'} és {artist}."
if lang == "es":
return f"El artista de {title or 'esta obra'} es {artist}."
return f"The artist of {title or 'this artwork'} is {artist}."
if lang == "ca":
return sentence_join([
f"L'obra es diu {title}." if title else "",
f"La va fer {artist}." if artist else "",
f"És de {date}." if date else "",
f"Està feta amb {material}." if material else "",
clean_value(description, max_chars=1400) if description else "",
f"La pots veure a {room}." if room else "",
])
if lang == "es":
return sentence_join([
f"La obra se llama {title}." if title else "",
f"La hizo {artist}." if artist else "",
f"Es de {date}." if date else "",
f"Está hecha con {material}." if material else "",
clean_value(description, max_chars=1400) if description else "",
f"La puedes ver en {room}." if room else "",
])
return sentence_join([
f"The artwork is called {title}." if title else "",
f"It was made by {artist}." if artist else "",
f"It is from {date}." if date else "",
f"It is made with {material}." if material else "",
clean_value(description, max_chars=1400) if description else "",
f"You can see it in {room}." if room else "",
])
def introduces_unsupported_ownership(original: str, simplified: str) -> bool:
original_key = normalize_key(original)
simplified_key = normalize_key(simplified)
ownership_terms = ("propietari", "propietaria", "dueno", "duena", "owner", "owns", "owned")
return not any(term in original_key for term in ownership_terms) and any(
term in simplified_key for term in ownership_terms
)
def has_enough_context(req: AnswerRequest) -> bool:
rows = req.context or req.rows or []
if not rows:
return False
row = best_context_row(rows, req)
return any(row_value(row, field) for field in ("description", "artist", "date", "material"))
def simplify_grounded_answer(generator: IdemGenerator, req: AnswerRequest, lang: str, max_new_tokens: int) -> str:
grounded_answer = build_grounded_answer(req, lang)
if not grounded_answer or not has_enough_context(req):
return ""
simplification_prompt = (
f"{IDEM_SIMPLIFICATION_PROMPTS.get(lang, IDEM_SIMPLIFICATION_PROMPTS['en'])}\n"
f"INPUT:\n{grounded_answer}"
)
simplified = generator.simplify_with_official_idem(grounded_answer, lang)
if not simplified:
raw_simplified = generator.generate(simplification_prompt, max_new_tokens=max_new_tokens)
simplified = first_reformulation(raw_simplified, grounded_answer, lang)
if not simplified:
simplified = grounded_answer
simplified = normalize_answer_text(clean_value(simplified, max_chars=3000))
if introduces_unsupported_ownership(grounded_answer, simplified):
simplified = grounded_answer
if not simplified or not text_matches_language(simplified, lang):
return ""
return simplified
def clean_idem_output(raw: str, original_input: str) -> str:
if not raw:
return ""
lines = []
for line in raw.splitlines():
cleaned = line.strip()
if not cleaned:
continue
if cleaned in {"INPUT:", "OUTPUT:"}:
continue
if cleaned == original_input.strip():
continue
if is_meta_reformulation(cleaned):
continue
cleaned = cleaned.lstrip("1234567890-").lstrip(".").strip()
if cleaned:
lines.append(cleaned)
return normalize_answer_text("\n".join(lines).strip() or raw.strip())
def first_reformulation(output_text: str, original_text: str, lang: str) -> str:
responses = []
for line in output_text.splitlines():
cleaned = clean_value(line, max_chars=3000)
cleaned = re.sub(r"^(OUTPUT|INPUT|ANSWER|RESPOSTA|RESPUESTA)\s*:?\s*", "", cleaned, flags=re.I)
cleaned = re.sub(r"^si us plau,?\s*", "", cleaned, flags=re.I)
cleaned = cleaned.lstrip("1234567890-").lstrip(".").strip()
if not cleaned or cleaned == original_text.strip():
continue
if is_meta_reformulation(cleaned):
continue
if text_matches_language(cleaned, lang):
responses.append(cleaned)
return responses[0] if responses else ""
@app.get("/")
def health():
return {"status": "ok", "service": "guia-idem-api"}
@app.post("/answer")
def answer(req: AnswerRequest):
generator = load_model()
lang = language_code(req.language)
language_name = LANGUAGE_NAMES[lang]
direct_prompt = (req.prompt or req.text or "").strip()
if direct_prompt and not req.question.strip():
input_text = direct_prompt[:2000]
else:
rows = req.context or req.rows
if not rows:
return {"answer": FALLBACKS[lang], "source": "no-context"}
context_text = rows_to_context(rows)
force_prompt = (
f"Answer in {language_name} only.\n"
"Use only the museum context.\n"
"Use Easy Read language: short sentences and common words.\n"
"Answer with the detail needed by the question and available context.\n"
"Use complete sentences. Do not cut the answer in the middle of a sentence.\n"
"Do not invent facts. Return only the answer.\n"
)
if req.instructions:
force_prompt += f"\nExtra instructions:\n{str(req.instructions)[:700]}\n"
input_text = (
f"{force_prompt}\n"
f"Context:\n{context_text}\n\n"
f"Room: {req.room or 'Not provided'}\n"
f"Artwork: {req.artwork or 'Not provided'}\n"
f"Question: {req.question[:320]}"
)
default_tokens = 180 if generator.device.type == "cpu" else 220
requested_tokens = req.max_new_tokens or int(os.getenv("IDEM_MAX_NEW_TOKENS", str(default_tokens)))
max_new_tokens = max(64, min(requested_tokens, 240 if generator.device.type == "cpu" else 320))
if not _generation_lock.acquire(blocking=False):
raise HTTPException(
status_code=503,
detail="iDEM is already generating an answer. Please use the fallback or try again later.",
)
try:
use_cpu_simplifier = os.getenv("IDEM_CPU_SIMPLIFIER", "true").strip().lower() != "false"
if (
generator.device.type == "cpu"
and use_cpu_simplifier
and not (direct_prompt and not req.question.strip())
and (req.context or req.rows)
):
result = simplify_grounded_answer(generator, req, lang, max_new_tokens)
source = "idem-easy-read"
else:
raw_result = generator.generate(input_text, max_new_tokens=max_new_tokens)
result = clean_idem_output(raw_result, input_text)
source = "idem-generate"
finally:
_generation_lock.release()
if not result:
result = FALLBACKS[lang]
return {"answer": normalize_answer_text(result), "source": source}