From 9c1d578d70d322bfc79e535b499d416cd1fa0935 Mon Sep 17 00:00:00 2001 From: Psalmuel Date: Wed, 7 Oct 2026 10:45:23 +0100 Subject: [PATCH] feat(chat): add Markdown export for conversations (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What & why Adds a Markdown export for conversations, requested in #46: > `GET /chat/conversations//export.md` returns the conversation as a Markdown document with the user's messages and assistant replies in order. This is the `.md` sibling of the existing JSON export at `GET /chat/conversations//export`. It follows the same auth/ownership pattern (`@login_required` + `_get_conversation`, which 404s for another user's conversation rather than leaking a 403), and keeps the route thin by delegating rendering to a focused service. ### Changes - **`app/services/chat_export.py`** (new) — `render_conversation_markdown(conversation)` and `ordered_messages(conversation)`. Emits a `# ` document with metadata and one `## <User|Assistant|System>` heading per message, in strict conversation order (`created_at`, with `id` breaking timestamp ties so the order is deterministic even for messages written in the same instant). Message bodies are forwarded **verbatim** — the stored text is already the Markdown the client renders, so re-escaping it here would double-escape entities and corrupt code fences. Fenced code blocks therefore survive unchanged, including their language info strings (e.g. ```` ```python ````). An empty conversation still produces valid Markdown (`*This conversation has no messages.*`). - **`app/chat/routes.py`** — new `export_conversation_markdown` route `GET /chat/conversations/<int:conversation_id>/export.md`, returning `Content-Type: text/markdown; charset=utf-8` and `Content-Disposition: attachment; filename="conversation-<id>.md"`. - **`tests/test_chat_export_markdown.py`** (new) — focused tests. ### Acceptance criteria coverage | Criterion | Test | | --- | --- | | `.md` route returns user + assistant messages in order | `test_messages_are_ordered_with_role_headings`, `test_order_falls_back_to_id_on_tied_timestamps` | | Code blocks preserved as fenced blocks | `test_code_blocks_are_preserved_as_fences` | | Sensible filename + correct `Content-Type` | `test_content_type_is_markdown_utf8`, `test_download_filename_is_sensible` | | Tests verify structure and ordering | the `TestMarkdownExportStructure` class | | Auth: unauthenticated rejected / other user's conversation 404 | `TestMarkdownExportAuth` | | Empty conversation | `test_empty_conversation_is_still_valid_markdown` | ### Testing ``` ../venv/bin/python -m pytest ``` Result: **1899 passed, 16 failed** (0 skipped) in 724.75s. The 16 failures are all in `tests/test_chat_markdown_sanitize.py`, which drives the pre-existing `app/static/js/chat_markdown.js` renderer under Node; they fail because that module loads no exports under this environment's Node (`require(...)` returns `{}`, so `renderMarkdown` is not a function). They are unrelated to this change (no JS was touched) and reproduce independently of it. The new file alone: `../venv/bin/python -m pytest tests/test_chat_export_markdown.py` → 9 passed. Baseline trio (`tests/test_providers.py tests/test_llm_retry.py tests/test_chat.py`) → 80 passed. ### Notes - `ruff` and `black` are not installed in the project venv (`No module named ruff` / `No module named black`), so those checks were not run — nothing new was installed. - Only the three files above are touched; `app/services/exporting.py` (project ZIP export, #107) is untouched. Closes #46 --- app/chat/routes.py | 16 ++- app/services/chat_export.py | 84 +++++++++++++++ tests/test_chat_export_markdown.py | 166 +++++++++++++++++++++++++++++ 3 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 app/services/chat_export.py create mode 100644 tests/test_chat_export_markdown.py 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/<int:conversation_id>/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/<int:conversation_id>/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 <div>tag</div>" + _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 "<div>tag</div>" 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()