"""Rows to API shapes. A conversation has no title column: the first message is the title, derived here so the list and the detail can never disagree about what a conversation is called. """ from app.api.conversations.schemas import MessageOut, MessageSource from app.models import Message from app.modes.query import excerpt as clean_excerpt TITLE_LENGTH = 80 def title(first_message: str | None) -> str | None: if not first_message: return None flattened = " ".join(first_message.split()) if len(flattened) <= TITLE_LENGTH: return flattened return flattened[: TITLE_LENGTH - 1] + "…" def message_out(message: Message) -> MessageOut: """Message + its citation snapshot from `meta` (assistant turns only). The excerpt is re-cleaned on the way out, not just on the way in. It is a presentation detail frozen at answer time, so an improvement to the cleaning would otherwise only reach conversations created afterwards, and every existing citation would keep showing raw Markdown forever. Cleaning is idempotent, so text stored by a newer backend passes through untouched. """ meta = message.meta or {} sources = [ MessageSource.model_validate(item).model_copy( update={"excerpt": clean_excerpt(item.get("excerpt", ""))} ) for item in meta.get("sources", []) ] return MessageOut.model_validate(message).model_copy( update={"sources": sources, "fallback": meta.get("fallback")} )