diff --git a/app/chat/routes.py b/app/chat/routes.py index 7f4b711..5cb4d7a 100644 --- a/app/chat/routes.py +++ b/app/chat/routes.py @@ -32,7 +32,7 @@ ) from app.models.message_attachment import ALLOWED_IMAGE_TYPES from app.models.project import STATUS_READY -from app.services import audit, token_usage +from app.services import audit, chat_export, token_usage from app.services.github import ( GitHubError, GitHubNotConnectedError, @@ -413,6 +413,20 @@ def export_conversation(conversation_id: int): ) +@bp.route("/conversations//export.md") +@login_required +def export_conversation_markdown(conversation_id: int): + """Export a conversation as a downloadable Markdown document (issue #46).""" + conversation = _get_conversation(conversation_id) + body = chat_export.render_conversation_markdown(conversation) + filename = f"conversation-{conversation.id}.md" + return Response( + body, + mimetype="text/markdown", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + @bp.route("/conversations//shares", methods=["GET"]) @login_required def list_shares(conversation_id: int): diff --git a/app/services/chat_export.py b/app/services/chat_export.py new file mode 100644 index 0000000..ed5cb79 --- /dev/null +++ b/app/services/chat_export.py @@ -0,0 +1,84 @@ +"""Render chat conversations as portable Markdown documents (issue #46). + +Message bodies are stored verbatim as the text the user typed or the provider +streamed back — the client renders and sanitises them for display only. An +export must therefore forward the stored text untouched: escaping it again here +would double-escape entities (``&`` becoming ``&amp;``) and pull the fences +out of code blocks. + +This module deliberately only adds document scaffolding — a title, metadata and +per-message headings — around the original content, in strict conversation +order, so fenced code blocks (including their language info strings) survive the +round trip unchanged. +""" + +from __future__ import annotations + +from app.models import Conversation, Message + +#: Human-readable headings for the roles the app stores. Unknown roles fall back +#: to a capitalised version of the stored name. +_ROLE_LABELS = {"user": "User", "assistant": "Assistant", "system": "System"} + + +def _role_label(role: str) -> str: + """Return a readable ``##`` heading label for a message ``role``.""" + known = _ROLE_LABELS.get((role or "").lower()) + if known is not None: + return known + return (role or "Message").capitalize() + + +def ordered_messages(conversation: Conversation) -> list[Message]: + """Return a conversation's messages in deterministic conversation order. + + The relationship is already ordered by ``created_at`` in SQL, but two + messages written in the same instant can share a timestamp and would then + come back in an unspecified order. ``id`` is monotonically increasing, so it + breaks those ties and preserves the real exchange order. + """ + return sorted( + conversation.messages, + key=lambda message: ( + message.created_at.timestamp() if message.created_at else 0.0, + message.id or 0, + ), + ) + + +def render_conversation_markdown(conversation: Conversation) -> str: + """Render ``conversation`` as a Markdown document. + + User messages and assistant replies are emitted in order under ``##`` + headings. An empty conversation still produces a valid document. Content is + never escaped or rewritten, so fenced code blocks in the stored text are + preserved exactly as written. + """ + lines: list[str] = [f"# {conversation.title or 'Conversation'}", ""] + + meta = [ + ("Conversation ID", str(conversation.id)), + ("Created", conversation.created_at.isoformat() if conversation.created_at else None), + ("Updated", conversation.updated_at.isoformat() if conversation.updated_at else None), + ("Messages", str(len(conversation.messages))), + ] + for label, value in meta: + if value: + lines.append(f"- **{label}:** {value}") + lines.append("") + + messages = ordered_messages(conversation) + if not messages: + lines += ["*This conversation has no messages.*", ""] + return "\n".join(lines) + + for message in messages: + lines.append(f"## {_role_label(message.role)}") + lines.append("") + # Emit the stored content verbatim; only trailing newlines are trimmed so + # exactly one blank line separates messages. Leading whitespace and code + # fences are left untouched. + lines.append((message.content or "").rstrip("\n")) + lines.append("") + + return "\n".join(lines) diff --git a/tests/test_chat_export_markdown.py b/tests/test_chat_export_markdown.py new file mode 100644 index 0000000..04a5c56 --- /dev/null +++ b/tests/test_chat_export_markdown.py @@ -0,0 +1,166 @@ +"""Tests for the Markdown conversation export endpoint (issue #46).""" + +from datetime import UTC, datetime + +from app.extensions import db as database +from app.models import Conversation, Message + + +def _register(client, username="mduser", email="mduser@example.com"): + client.post( + "/auth/register", + data={ + "username": username, + "email": email, + "password": "supersecret123", + "password_confirm": "supersecret123", + }, + ) + + +def _logout(client): + client.post("/auth/logout") + + +def _create_conversation(client, title=None): + payload = {"title": title} if title is not None else {} + response = client.post( + "/chat/conversations", + json=payload, + headers={"X-CSRFToken": "ignored"}, + ) + assert response.status_code == 201 + return response.get_json() + + +def _export(client, conversation_id): + return client.get(f"/chat/conversations/{conversation_id}/export.md") + + +def _add_messages(conversation_id, *messages): + """Append ``(role, content)`` pairs to a conversation and commit.""" + conversation = database.session.get(Conversation, conversation_id) + for role, content in messages: + conversation.messages.append(Message(role=role, content=content)) + database.session.commit() + return conversation + + +class TestMarkdownExportAuth: + def test_requires_login(self, client): + response = _export(client, 1) + assert response.status_code == 302 + + def test_other_users_conversation_is_not_found(self, client, db): + _register(client) + conversation = _create_conversation(client, title="Private") + _add_messages(conversation["id"], ("user", "top secret")) + + _logout(client) + _register(client, username="intruder", email="intruder@example.com") + + response = _export(client, conversation["id"]) + # A 404 (not 403) avoids leaking that the conversation exists. + assert response.status_code == 404 + + +class TestMarkdownExportHeaders: + def test_content_type_is_markdown_utf8(self, client, db): + _register(client) + conversation = _create_conversation(client, title="Headers") + + response = _export(client, conversation["id"]) + assert response.status_code == 200 + assert response.mimetype == "text/markdown" + assert response.headers["Content-Type"] == "text/markdown; charset=utf-8" + + def test_download_filename_is_sensible(self, client, db): + _register(client) + conversation = _create_conversation(client, title="Headers") + + response = _export(client, conversation["id"]) + assert ( + response.headers["Content-Disposition"] + == f'attachment; filename="conversation-{conversation["id"]}.md"' + ) + + +class TestMarkdownExportStructure: + def test_messages_are_ordered_with_role_headings(self, client, db): + _register(client) + conversation = _create_conversation(client, title="Ordering") + _add_messages( + conversation["id"], + ("user", "first question"), + ("assistant", "first answer"), + ("user", "second question"), + ("assistant", "second answer"), + ) + + body = _export(client, conversation["id"]).get_data(as_text=True) + + assert body.startswith("# Ordering") + assert "## User" in body + assert "## Assistant" in body + # Every message appears strictly after the previous one. + markers = [ + body.index("first question"), + body.index("first answer"), + body.index("second question"), + body.index("second answer"), + ] + assert markers == sorted(markers) + + def test_order_falls_back_to_id_on_tied_timestamps(self, client, db): + _register(client) + conversation = _create_conversation(client, title="Ties") + conversation_row = database.session.get(Conversation, conversation["id"]) + same_instant = datetime.now(UTC) + conversation_row.messages.append( + Message(role="user", content="earlier", created_at=same_instant) + ) + conversation_row.messages.append( + Message(role="assistant", content="later", created_at=same_instant) + ) + database.session.commit() + + body = _export(client, conversation["id"]).get_data(as_text=True) + assert body.index("earlier") < body.index("later") + + def test_code_blocks_are_preserved_as_fences(self, client, db): + _register(client) + conversation = _create_conversation(client, title="Code") + code = "Try this:\n\n```python\nprint('hi')\n```\n\nThat is all." + _add_messages(conversation["id"], ("assistant", code)) + + body = _export(client, conversation["id"]).get_data(as_text=True) + + assert "```python" in body + assert "print('hi')" in body + assert body.count("```") == 2 + # The fence is on its own line, not glued to the surrounding text. + assert "\n```python\n" in body + + def test_content_is_not_double_escaped(self, client, db): + _register(client) + conversation = _create_conversation(client, title="Verbatim") + content = "Use `**literal**`, keep & and
tag
" + _add_messages(conversation["id"], ("user", content)) + + body = _export(client, conversation["id"]).get_data(as_text=True) + + assert "**literal**" in body + assert "&" in body + assert "&amp;" not in body + assert "
tag
" in body + + def test_empty_conversation_is_still_valid_markdown(self, client, db): + _register(client) + conversation = _create_conversation(client, title="Empty") + + response = _export(client, conversation["id"]) + body = response.get_data(as_text=True) + + assert response.status_code == 200 + assert body.startswith("# Empty") + assert "no messages" in body.lower()