File size: 6,165 Bytes
16e1aa7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 | """
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
|