Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion app/chat/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down
84 changes: 84 additions & 0 deletions app/services/chat_export.py
Original file line number Diff line number Diff line change
@@ -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;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)
166 changes: 166 additions & 0 deletions tests/test_chat_export_markdown.py
Original file line number Diff line number Diff line change
@@ -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 &amp; 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 "&amp;" in body
assert "&amp;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()
Loading