EtsyListingGenerator / app /llm /generator.py
simikkk's picture
Upload 36 files
16e1aa7 verified
Raw
History Blame Contribute Delete
7.87 kB
"""
Orchestrates one listing-generation request end to end:
1. Sanitize input
2. Build prompt (with brand voice context)
3. Call LLM (with Groq -> Gemini fallback)
4. Parse + programmatically validate the JSON output
5. If invalid, re-prompt with the specific violation, up to MAX_VALIDATION_RETRIES
6. Run a cheap critic pass for semantic relevance; regenerate only flagged tags
7. Return a clean, validated result
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field
from app.config import MAX_VALIDATION_RETRIES, TAG_MAX_CHARS, TITLE_MAX_CHARS, GROQ_CONFIGURED, GEMINI_CONFIGURED
from app.llm import prompts
from app.llm.provider import complete_with_fallback, LLMProviderError, MockProvider, CriticMockProvider
from app.llm.validation import validate_all, trim_to_limit, ValidationResult
from app.security import sanitize_user_input
logger = logging.getLogger("etsy_optimizer.generator")
class GenerationFailedError(Exception):
"""Raised when we've exhausted retries and still can't produce valid output."""
@dataclass
class GenerationResult:
titles: list[str]
tags: list[str]
description: str
category_hints: list[str] = field(default_factory=list)
provider_used: str = ""
def _parse_llm_json(raw_text: str) -> dict:
"""LLMs sometimes wrap JSON in markdown fences despite instructions not to."""
text = raw_text.strip()
if text.startswith("```"):
text = text.strip("`")
if text.lower().startswith("json"):
text = text[4:]
try:
return json.loads(text)
except json.JSONDecodeError as e:
raise GenerationFailedError(f"LLM did not return valid JSON: {e}") from e
async def _run_critic_pass(product_description: str, title: str, tags: list[str]) -> list[int]:
"""Returns indices of tags flagged as irrelevant/hallucinated."""
critic_prompt = prompts.build_critic_prompt(product_description, title, tags)
try:
if not (GROQ_CONFIGURED or GEMINI_CONFIGURED):
resp = await CriticMockProvider().complete(prompts.CRITIC_SYSTEM_PROMPT, critic_prompt, max_tokens=300)
else:
resp = await complete_with_fallback(prompts.CRITIC_SYSTEM_PROMPT, critic_prompt, max_tokens=300)
data = _parse_llm_json(resp.text)
flagged = data.get("flagged_indices", [])
return [i for i in flagged if isinstance(i, int) and 0 <= i < len(tags)]
except Exception as e: # critic pass is best-effort; never block the whole request on it
logger.warning("Critic pass failed, skipping semantic check: %s", e)
return []
async def generate_listing(
product_description: str,
target_keywords: str | None,
brand_voice: dict | None,
title_variants: int,
include_category_hints: bool,
) -> GenerationResult:
clean_description = sanitize_user_input(product_description)
clean_keywords = sanitize_user_input(target_keywords)
if not clean_description:
raise GenerationFailedError("Product description is required.")
user_prompt = prompts.build_user_prompt(
clean_description, clean_keywords, brand_voice, title_variants, include_category_hints
)
violation_note = ""
last_errors: list[str] = []
last_provider = ""
for attempt in range(MAX_VALIDATION_RETRIES + 1):
prompt_this_attempt = user_prompt
if violation_note:
prompt_this_attempt += (
f"\n\nYour previous attempt violated these rules - fix them and "
f"return corrected JSON (still following the full output format):\n{violation_note}"
)
try:
response = await complete_with_fallback(prompts.SYSTEM_PROMPT, prompt_this_attempt, max_tokens=1400)
except LLMProviderError as e:
logger.error("LLM call failed on attempt %d: %s", attempt, e)
last_errors = [str(e)]
continue
last_provider = response.provider
try:
data = _parse_llm_json(response.text)
titles = list(data.get("titles", []))
tags = list(data.get("tags", []))
description = str(data.get("description", ""))
category_hints = list(data.get("category_hints", []) or [])
except GenerationFailedError as e:
last_errors = [str(e)]
violation_note = str(e)
continue
result = validate_all(titles, tags, description, title_variants, response.finish_reason)
if result.ok:
# Formal validation passed. Now run the semantic "critic" pass -
# only on the primary title, since tags are meant to match the
# product as a whole.
flagged = await _run_critic_pass(clean_description, titles[0], tags)
if flagged:
tags = await _regenerate_flagged_tags(
clean_description, clean_keywords, brand_voice, titles[0], tags, flagged
)
return GenerationResult(
titles=titles,
tags=tags,
description=description,
category_hints=category_hints if include_category_hints else [],
provider_used=response.provider,
)
last_errors = result.errors
violation_note = "; ".join(result.errors)
logger.info("Validation failed on attempt %d: %s", attempt, violation_note)
# Exhausted retries. As a last resort, try to salvage by trimming rather
# than showing nothing - but only if we at least got parseable content
# with roughly the right shape; otherwise fail loudly.
raise GenerationFailedError(
"Could not produce a valid listing after retries. Last errors: " + "; ".join(last_errors)
)
async def _regenerate_flagged_tags(
product_description: str,
target_keywords: str | None,
brand_voice: dict | None,
title: str,
tags: list[str],
flagged_indices: list[int],
) -> list[str]:
"""Regenerate only the flagged tags, not the whole listing."""
existing_ok_tags = [t for i, t in enumerate(tags) if i not in flagged_indices]
n_needed = len(flagged_indices)
fix_prompt = (
f"Product description: {product_description}\n"
f"Title already chosen (do not repeat its words): {title}\n"
f"These tags are already used and approved, do not repeat their words: {existing_ok_tags}\n"
f"Generate exactly {n_needed} NEW replacement tag(s), each at most {TAG_MAX_CHARS} characters, "
f"genuinely relevant to the product, with no word repeated from the title or the approved tags "
f"above, and no duplicate words between the new tags themselves. "
f'Respond with ONLY JSON: {{"tags": ["...", ...]}} containing exactly {n_needed} tag(s).'
)
try:
if not (GROQ_CONFIGURED or GEMINI_CONFIGURED):
resp = await MockProvider().complete(prompts.SYSTEM_PROMPT, fix_prompt, max_tokens=200)
# Mock deterministic replacement tags so the flow still completes offline.
replacement = [f"great gift find {i}"[:TAG_MAX_CHARS] for i in range(n_needed)]
else:
resp = await complete_with_fallback(prompts.SYSTEM_PROMPT, fix_prompt, max_tokens=200)
data = _parse_llm_json(resp.text)
replacement = list(data.get("tags", []))[:n_needed]
if len(replacement) < n_needed:
replacement += [f"handmade gift {i}"[:TAG_MAX_CHARS] for i in range(n_needed - len(replacement))]
new_tags = list(tags)
for idx, new_tag in zip(flagged_indices, replacement):
new_tags[idx] = trim_to_limit(new_tag, TAG_MAX_CHARS)
return new_tags
except Exception as e:
logger.warning("Tag regeneration failed, keeping original (flagged) tags: %s", e)
return tags