"""Session title generation: first words (instant) + LLM (enriched). Layer 1 — First words: instant sidebar title from the first user message (up to 4 words, capped by max_words). Layer 2 — LLM: 2-5 word enriched title from full conversation (background). """ import re def generate_instant_title(text: str, max_words: int = 5) -> str: # noqa: D401 """First words of user message as instant title. No language list. Emails/URLs are masked: sidebar titles must never carry PII verbatim. """ clean = text.strip() if not clean: return "Yeni Sohbet" # Mask before splitting so a masked token still occupies its position. clean = re.sub(r"[\w.+-]+@[\w-]+\.[\w.]+", "[e-posta]", clean) clean = re.sub(r"https?://\S+|www\.\S+", "[link]", clean) # Split on whitespace, take up to 4 words (capped by max_words), # strip trailing punctuation words = clean.split() first = " ".join(words[:min(4, max_words)]) # Truncate to 40 chars max to avoid overflow, add ellipsis if needed if len(first) > 40: first = first[:40].rsplit(" ", 1)[0] + "…" return first # ═══════════════════════════════════════════════════════════════ # LLM Title Generation (called as background task) # ═══════════════════════════════════════════════════════════════ _TITLE_PROMPT = """Based on this conversation, generate a short session title (2-5 words). Rules: - Only output the title text, nothing else - Be concise and descriptive - Use the same language as the user - Focus on the TOPIC, not the action verbs - Do not include email addresses, URLs, or personal info - Do not include quotes, punctuation, or "Title:" prefix User: {user_msg} Assistant: {assistant_msg} Title:""" async def generate_llm_title(user_msg: str, assistant_msg: str) -> str | None: """Generate an enriched session title via LLM. Returns the title string, or None on failure (caller keeps instant title). This function is designed to be called as a background task — it should never raise exceptions that crash the server. """ try: from config import get backend = get("LLM_BACKEND", "litert") prompt = _TITLE_PROMPT.format( user_msg=user_msg, assistant_msg=assistant_msg[:500], ) if backend == "litert": import httpx litert_url = get("LITERT_BASE_URL", "http://localhost:9379") model = get("LLM_MODEL", "gemma4-e2b").replace(":", "-") payload = { "model": model, "messages": [{"role": "user", "content": prompt}], "max_tokens": 15, "temperature": 0.1, } async with httpx.AsyncClient(timeout=15) as _client: r = await _client.post( f"{litert_url}/v1/chat/completions", json=payload, ) r.raise_for_status() raw = r.json()["choices"][0]["message"]["content"].strip() else: # Ollama path — reuse existing chat_with_ollama (no tools for title) from llm import chat_with_ollama raw = await chat_with_ollama( [{"role": "user", "content": prompt}], think=False, intent="question", tool_group=None, ) # chat_with_ollama returns dict, extract reply if isinstance(raw, dict): raw = raw.get("reply", "") or raw.get("content", "") or str(raw) # Clean output title = raw.strip('"\'') title = re.sub(r'^Title:\s*', '', title, flags=re.IGNORECASE) title = title.strip('.').strip() # Sanity: 2-5 words, 2-60 chars words = title.split() if title and 2 <= len(words) <= 5 and 2 <= len(title) <= 60: return title return None except Exception: return None