title.py
Rama main · réplica de lectura del repositorio original.
"""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