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