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