""" 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