"""Prismarkedsføring: finds price and discount claims in Norwegian marketing text. The model is ltg/norbert3-base, fine-tuned on sentences labelled against Forbrukertilsynet's guidance on price marketing. It reads an advert one sentence at a time, and each pair of neighbouring sentences, and shows which piece decided the answer. """ import io import json import os import re import tempfile from pathlib import Path import gradio as gr import pandas as pd import spaces import torch from transformers import AutoModelForSequenceClassification, AutoTokenizer MODEL_ID = os.environ.get("MODEL_ID", "") MODEL_DIR = Path(__file__).parent / "model" # CPU on purpose: the free tier on Hugging Face Spaces has no GPU, and this model # classifies a few hundred short texts a second on a CPU anyway. # On ZeroGPU a GPU is lent to each request; locally this falls back to the CPU. DEVICE = os.environ.get("DEVICE", "cuda" if torch.cuda.is_available() else "cpu") torch.set_num_threads(max(1, (os.cpu_count() or 4) // 2)) # Between these the model is guessing, and a person should read the text. UNCERTAIN = (0.35, 0.65) DEFINITION = """ ### Hva teller som en prispåstand Dette er ikke vår definisjon. Den er hentet fra Forbrukertilsynets egen veiledning *Salg og bruk av førpriser*, og fra vedtakene. Det holder at teksten gir inntrykk av at prisen er satt ned eller er gunstig. Det trengs ikke noe tall. Veiledningen nevner selv «-50 %», «Få 200 kr i avslag på alle bukser», «Black Friday-priser» og «SALG» som eksempler på salgsmarkedsføring. Det samme gjelder påstander om å være billigst, og tilbud om gratis vare, prøve eller rentefri periode. «Rekordbillig» ga overtredelsesgebyr i saken mot Stayclassy (FOV-2022-1369). Navnet på et produkt teller ikke, selv om det inneholder ordet pris. «Medlemspris» og «Spotpris med Elkjøprabatt» er avtalenavn. I de sakene var problemet manglende opplysninger, ikke navnet. Det samme gjelder nøytrale prisopplysninger, avtalevilkår og juridisk tekst om prismarkedsføring. """ # Written by hand; which answer each one gets is the model's call. EXAMPLES_FILE = MODEL_DIR / "examples.json" EXAMPLES = ( json.loads(EXAMPLES_FILE.read_text()) if EXAMPLES_FILE.exists() else [ "Black Friday-priser på alle jakker denne helgen", "Norges billigste strømavtale, garantert", "Prøv gratis i 30 dager, betal kun frakt 79,-", "Strøm til en grei pris", "Medlemspris", "Spotpris med Elkjøprabatt", "Vi sier opp den gamle avtalen din for deg, helt uten styr", "Avtalen har ingen bindingstid og faktureres etterskuddsvis hver måned", ] ) SPLIT = re.compile(r"(?<=[.!?:])\s+|\n+") def load_model(): source = MODEL_ID or (str(MODEL_DIR) if MODEL_DIR.exists() else "") if not source: return None, None # NorBERT ships its own model code, so it has to be allowed to run it. tok = AutoTokenizer.from_pretrained(source, trust_remote_code=True) model = AutoModelForSequenceClassification.from_pretrained(source, trust_remote_code=True) return tok, model.to(DEVICE).eval() def load_temperature() -> float: # How much to soften the scores so that "70% sure" is right about 70% of the time. path = MODEL_DIR / "calibration.json" return json.loads(path.read_text())["temperature"] if path.exists() else 1.0 TOKENIZER, MODEL = load_model() TEMPERATURE = load_temperature() def gpu_seconds(texts: list[str], batch: int = 32) -> int: # Shorter requests get better queue priority, so ask only for what the batch needs. return min(120, 10 + len(texts) // 50) @spaces.GPU(duration=gpu_seconds) def score_sentences(texts: list[str], batch: int = 32) -> list[float]: """Calibrated probability that each sentence contains a price or discount claim.""" out: list[float] = [] with torch.no_grad(): for i in range(0, len(texts), batch): enc = TOKENIZER( texts[i : i + batch], truncation=True, max_length=256, padding=True, return_tensors="pt", ).to(DEVICE) probs = torch.softmax(MODEL(**enc).logits / TEMPERATURE, dim=1)[:, 1] out.extend(probs.cpu().tolist()) return out def sentences(text: str) -> list[str]: return [s.strip() for s in SPLIT.split(text) if len(s.strip()) >= 3] or [text.strip()] def pieces(text: str) -> list[str]: """Each sentence, and each pair of neighbouring sentences. Scoring sentences alone read «169,90 kr per kilo. Før 229,90.» as two harmless fragments and gave the advert 44%. Together they are a textbook before-price claim. """ s = sentences(text) return s + [f"{a} {b}" for a, b in zip(s, s[1:], strict=False)] def predict(texts: list[str]) -> tuple[list[float], list[str]]: """For each advert: the highest score of any piece, and the piece that got it.""" flat = [(i, s) for i, t in enumerate(texts) for s in pieces(t)] scores = score_sentences([s for _, s in flat]) best = [(0.0, "")] * len(texts) for (i, s), p in zip(flat, scores, strict=True): if p >= best[i][0]: best[i] = (p, s) return [p for p, _ in best], [s for _, s in best] CUES = { "prosent": re.compile(r"\d+\s*%|prosent", re.I), "beløp": re.compile(r"\bkr\b|kroner|,-|øre\b", re.I), "sammenligning": re.compile(r"billigst|rimeligst|lavest|best\w* pris|mest for pengene", re.I), "gratis": re.compile(r"gratis|kostnadsfri\w*|uten ekstra kostnad|på kjøpet", re.I), "salgsord": re.compile(r"salg|tilbud|rabatt|black \w+|kampanje|kupp|spar\b", re.I), "førpris": re.compile(r"førpris|før kr|veil\.? pris|ordinær pris", re.I), } def cues_in(text: str) -> str: hits = [name for name, pat in CUES.items() if pat.search(text)] return ", ".join(hits) if hits else "ingen tydelige signalord" def verdict(p: float) -> str: if p >= UNCERTAIN[1]: return "Prispåstand" if p <= UNCERTAIN[0]: return "Ingen prispåstand" return "Usikker, les den selv" COLOURS = {"Prispåstand": "#e8590c", "Ingen prispåstand": "#2f9e44"} def meter(p: float, call: str) -> str: """One bar that never moves: 0 to 100% chance of a price claim, usikker zone shaded. gr.Label sorted its bars by score, so the winner jumped to the top with a full bar every time. This keeps the same scale in the same place, so 72% looks like 72%. """ colour = COLOURS.get(call, "#f59f00") lo, hi = UNCERTAIN[0] * 100, UNCERTAIN[1] * 100 return f"""