"""Call an OpenAI-compatible chat completions endpoint to generate jokes.""" import json import re import httpx from .config import settings SYSTEM_PROMPT = ( "You are a comedian. You will receive the text of Wikipedia's featured article " "of the day. Write exactly 5 short, clean, family-friendly jokes inspired by " "facts from the article. Vary the style (one-liners, puns, observational). " "The jokes must be understandable on their own without reading the article. " "Respond with ONLY a JSON array of 5 strings and nothing else. " "Example: [\"joke one\", \"joke two\", \"joke three\", \"joke four\", \"joke five\"]" ) def _try_parse(candidate: str): try: return json.loads(candidate) except json.JSONDecodeError: return None def _normalize_quotes(text: str) -> str: """Rewrite LLM quote soup into valid JSON. Handles models that use curly quotes (“ ”) as string DELIMITERS and also as content inside straight-quoted strings. A scanner tracks which quote character opened the current string so content quotes are preserved (escaped) instead of breaking the structure. """ out: list[str] = [] in_str: str | None = None # '"' or '“' i = 0 n = len(text) while i < n: c = text[i] if in_str is None: if c == '"': in_str = '"' out.append(c) elif c == "\u201c": # “ opens a string in_str = "\u201c" out.append('"') else: out.append(c) else: if c == "\\" and i + 1 < n: # keep escape pairs intact out.append(c) out.append(text[i + 1]) i += 2 continue if in_str == '"': # Straight-delimited: curly quotes are just content. out.append(c) if c == '"': in_str = None else: # curly-delimited string if c == "\u201d": # ” closes it in_str = None out.append('"') elif c == '"': # raw straight quote inside -> escape out.append('\\"') else: out.append(c) i += 1 return "".join(out) def _repair_and_parse(text: str): """Parse JSON, repairing common LLM quote mistakes if strict parse fails.""" try: return json.loads(text) except json.JSONDecodeError: pass data = _try_parse(_normalize_quotes(text)) if data is not None: return data raise ValueError(f"Could not parse jokes JSON even after repair: {text[:200]!r}") def _extract_json_array(text: str) -> list[str]: """Pull a JSON array of strings out of a possibly messy LLM response.""" text = text.strip() # Strip markdown fences if present. fence = re.search(r"```(?:json)?\s*(.*?)```", text, re.DOTALL) if fence: text = fence.group(1).strip() # Find the outermost [...] span. start = text.find("[") end = text.rfind("]") if start == -1 or end == -1 or end <= start: raise ValueError(f"No JSON array found in LLM response: {text[:200]!r}") data = _repair_and_parse(text[start : end + 1]) if not isinstance(data, list): raise ValueError("Parsed JSON is not a list") jokes = [str(j).strip() for j in data if str(j).strip()] if len(jokes) < 5: raise ValueError(f"Only {len(jokes)} jokes returned, expected 5") return jokes[:5] def generate_jokes(article_title: str, article_extract: str) -> list[str]: url = f"{settings.openai_base_url}/chat/completions" headers = {"Content-Type": "application/json"} if settings.openai_api_key: headers["Authorization"] = f"Bearer {settings.openai_api_key}" user_prompt = ( f"Today's Wikipedia featured article: \"{article_title}\"\n\n" f"Article text:\n{article_extract}\n\n" "Now write exactly 5 jokes as a JSON array of 5 strings." ) body = { "model": settings.openai_model, "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_prompt}, ], "temperature": 0.9, "max_tokens": 800, } # 300s: the model may be shared (e.g. serving this agent too), so a # request can legitimately queue behind other traffic. resp = httpx.post(url, headers=headers, json=body, timeout=300) resp.raise_for_status() payload = resp.json() content = payload["choices"][0]["message"]["content"] return _extract_json_array(content)