EtsyListingGenerator / app /llm /validation.py
simikkk's picture
Upload 36 files
16e1aa7 verified
Raw
History Blame Contribute Delete
6.17 kB
"""
Programmatic validation of LLM-generated listing content.
We never trust the model to have respected the constraints just because we
asked nicely. Every rule from the product brief is re-checked here in code.
"""
import re
from dataclasses import dataclass, field
from app.config import TITLE_MAX_CHARS, TAG_MAX_CHARS, TAG_COUNT
_WORD_RE = re.compile(r"[a-zA-Z]+")
# Very small, dependency-free "de-pluralizer" - good enough to catch the
# common case (gift/gifts, candle/candles) without pulling in nltk.
def _singularize(word: str) -> str:
w = word.lower()
if len(w) > 3 and w.endswith("ies"):
return w[:-3] + "y"
if len(w) > 3 and w.endswith("es") and w[-3] in "sxzo" or w.endswith("shes") or w.endswith("ches"):
return w[:-2]
if len(w) > 3 and w.endswith("s") and not w.endswith("ss"):
return w[:-1]
return w
def _words(text: str) -> set[str]:
return {_singularize(w) for w in _WORD_RE.findall(text)}
@dataclass
class ValidationResult:
ok: bool
errors: list[str] = field(default_factory=list)
def add(self, msg: str) -> None:
self.ok = False
self.errors.append(msg)
def validate_finish_reason(finish_reason: str | None) -> ValidationResult:
"""Reject output that the API itself says was cut off by the token limit."""
result = ValidationResult(ok=True)
if finish_reason and finish_reason.lower() in ("length", "max_tokens"):
result.add(
f"Generation was truncated by the token limit (finish_reason={finish_reason}); "
f"must regenerate with a higher max_tokens or retry."
)
return result
def validate_titles(titles: list[str], expected_count: int) -> ValidationResult:
result = ValidationResult(ok=True)
if len(titles) != expected_count:
result.add(f"Expected {expected_count} title variant(s), got {len(titles)}.")
for i, title in enumerate(titles):
if not title or not title.strip():
result.add(f"Title {i} is empty.")
continue
if len(title) > TITLE_MAX_CHARS:
result.add(f"Title {i} is {len(title)} chars, exceeds {TITLE_MAX_CHARS} char limit.")
if not _ends_cleanly(title, require_terminal_punctuation=False):
result.add(f"Title {i} looks truncated / does not end on a complete word.")
return result
def validate_tags(tags: list[str], title_for_dup_check: str | None = None) -> ValidationResult:
result = ValidationResult(ok=True)
if len(tags) != TAG_COUNT:
result.add(f"Expected exactly {TAG_COUNT} tags, got {len(tags)}.")
for i, tag in enumerate(tags):
if not tag or not tag.strip():
result.add(f"Tag {i} is empty.")
continue
if len(tag) > TAG_MAX_CHARS:
result.add(f"Tag {i} ('{tag}') is {len(tag)} chars, exceeds {TAG_MAX_CHARS} char limit.")
# Duplicate-word check across tags (case-insensitive, singularized).
seen_words: dict[str, int] = {}
for i, tag in enumerate(tags):
for w in _words(tag):
if w in seen_words and seen_words[w] != i:
result.add(f"Word '{w}' is duplicated between tag {seen_words[w]} and tag {i}.")
seen_words.setdefault(w, i)
# Duplicate-word check between tags and title.
if title_for_dup_check:
title_words = _words(title_for_dup_check)
for i, tag in enumerate(tags):
overlap = _words(tag) & title_words
if overlap:
result.add(f"Tag {i} ('{tag}') repeats word(s) already in the title: {sorted(overlap)}.")
return result
def validate_description(description: str) -> ValidationResult:
result = ValidationResult(ok=True)
if not description or not description.strip():
result.add("Description is empty.")
return result
if not _ends_cleanly(description, require_terminal_punctuation=True):
result.add("Description looks truncated / does not end on a complete sentence.")
return result
def _ends_cleanly(text: str, require_terminal_punctuation: bool) -> bool:
"""True if text ends on a complete word, i.e. was not cut off mid-word.
If require_terminal_punctuation, longer text (descriptions) must also
end in sensible terminal punctuation. Titles never require this - Etsy
titles routinely end on a plain word and that is correct, not truncated.
"""
text = text.strip()
if not text:
return False
if text.endswith("-"):
return False
if text.endswith("...") and len(text) < 20:
return False
if require_terminal_punctuation and len(text) > 60 and text[-1] not in ".!?\u201d\"'":
return False
return True
def trim_to_limit(text: str, limit: int, require_terminal_punctuation: bool = False) -> str:
"""Trim text to `limit` chars, always at a word boundary, never mid-word.
If require_terminal_punctuation is True (used for descriptions), ensure
the trimmed result ends with proper punctuation rather than a dangling
fragment; otherwise just ensure it ends on a whole word.
"""
if len(text) <= limit:
return text
truncated = text[:limit]
# Back up to the last whitespace so we never cut mid-word.
last_space = truncated.rfind(" ")
if last_space > 0:
truncated = truncated[:last_space]
truncated = truncated.rstrip(" ,;:-")
if require_terminal_punctuation and truncated and truncated[-1] not in ".!?":
truncated += "."
return truncated
def validate_all(
titles: list[str],
tags: list[str],
description: str,
expected_title_count: int,
finish_reason: str | None = None,
) -> ValidationResult:
"""Run every check and merge into one result."""
combined = ValidationResult(ok=True)
for sub in (
validate_finish_reason(finish_reason),
validate_titles(titles, expected_title_count),
validate_tags(tags, title_for_dup_check=titles[0] if titles else None),
validate_description(description),
):
if not sub.ok:
combined.ok = False
combined.errors.extend(sub.errors)
return combined