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