feat: qualify native Hermes research and private conversation exports
This commit is contained in:
1 parent
fb3178d34b
commit
e048793b02
5 files changed
+206
-6
No files matched your search
@@ -0,0 +1,53 @@
|
||||
"""Private bounded conversation exports; no public sharing or provider execution."""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
|
||||
from .app import current_user
|
||||
|
||||
MAX_EXPORT_BYTES = 16 * 1024 * 1024
|
||||
MAX_EXPORT_MESSAGES = 10_000
|
||||
|
||||
|
||||
def export_conversation(store, owner, identifier, format='json', byte_limit=MAX_EXPORT_BYTES):
|
||||
if format not in ('json', 'markdown'):
|
||||
raise HTTPException(422, 'Export format must be json or markdown')
|
||||
with store.lock:
|
||||
try:
|
||||
conversation = store._conversation(store._row('chat_conversations', owner, identifier))
|
||||
except (KeyError, ValueError):
|
||||
raise HTTPException(404, 'Conversation not found') from None
|
||||
count, content_bytes = store.db.execute('SELECT COUNT(*),COALESCE(SUM(length(CAST(content AS BLOB))),0) FROM chat_messages WHERE owner=? AND conversation_id=?', (owner, identifier)).fetchone()
|
||||
if count > MAX_EXPORT_MESSAGES or content_bytes > byte_limit:
|
||||
raise HTTPException(413, 'Conversation export limit exceeded')
|
||||
messages = [store._message(row) for row in store.db.execute('SELECT * FROM chat_messages WHERE owner=? AND conversation_id=? ORDER BY position', (owner, identifier))]
|
||||
if format == 'json':
|
||||
# Only public projection; no Hermes IDs, API URLs, credentials or tool events.
|
||||
encoded = json.dumps({'version': 1, 'conversation': conversation, 'messages': messages}, ensure_ascii=False, indent=2).encode('utf-8')
|
||||
mime, suffix = 'application/json', '.json'
|
||||
else:
|
||||
# Preserve original message text/citations. Always attachment+plain text:
|
||||
# user markdown/HTML is never executed or rendered by this API.
|
||||
sections = ['# ' + conversation['title'], '']
|
||||
for message in messages:
|
||||
sections += ['## ' + message['role'].capitalize() + ' — ' + message['status'], '', message['content'], '']
|
||||
if message['attachments']:
|
||||
sections += ['Attachments (IDs; private bytes not embedded): ' + ', '.join(message['attachments']), '']
|
||||
encoded = '\n'.join(sections).encode('utf-8')
|
||||
mime, suffix = 'text/plain', '.md'
|
||||
if len(encoded) > byte_limit:
|
||||
raise HTTPException(413, 'Conversation export limit exceeded')
|
||||
return encoded, mime, 'conversation-' + conversation['id'] + suffix
|
||||
|
||||
|
||||
def create_export_router():
|
||||
router = APIRouter()
|
||||
|
||||
@router.get('/api/conversations/{identifier}/export')
|
||||
async def conversation_export(identifier: str, request: Request, format: str = 'json', user=Depends(current_user)):
|
||||
data, mime, filename = await asyncio.to_thread(export_conversation, request.app.state.chat_store, user['id'], identifier, format)
|
||||
await current_user(request) # Revocation during export denies final response.
|
||||
return Response(data, media_type=mime, headers={'Content-Disposition': 'attachment; filename="' + filename + '"',
|
||||
'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'})
|
||||
return router
|
||||
Reference in new issue
Block a user