diff --git a/backend/src/api/app.py b/backend/src/api/app.py index 71bcac78..f1855039 100644 --- a/backend/src/api/app.py +++ b/backend/src/api/app.py @@ -7,7 +7,7 @@ from fastapi.middleware.cors import CORSMiddleware from src.chat_storage_service import get_chat_message from src.directors.report_director import report_on_file_upload -from src.session.file_uploads import clear_session_file_uploads +from src.session.file_uploads import clear_session_file_uploads, get_report from src.session.redis_session_middleware import reset_session from src.utils import Config, test_connection from src.directors.chat_director import question, dataset_upload @@ -57,6 +57,7 @@ async def lifespan(app: FastAPI): suggestions_failed_response = "Unable to generate suggestions. Check the service by using the keyphrase 'healthcheck'" file_upload_failed_response = "Unable to upload file. Check the service by using the keyphrase 'healthcheck'" file_get_upload_failed_response = "Unable to get uploaded file. Check the service by using the keyphrase 'healthcheck'" +report_get_upload_failed_response = "Unable to download report. Check the service by using the keyphrase 'healthcheck'" @app.get("/health") @@ -132,6 +133,18 @@ async def report(file: UploadFile): logger.exception(e) return JSONResponse(status_code=500, content=file_upload_failed_response) +@app.get("/report/{id}") +def download_report(id: str): + logger.info(f"Get report download called for id: {id}") + try: + final_result = get_report(id) + if final_result is None: + return JSONResponse(status_code=404, content=f"Message with id {id} not found") + headers = {'Content-Disposition': 'attachment; filename="report.md"'} + return Response(final_result.get("report"), headers=headers, media_type='text/markdown') + except Exception as e: + logger.exception(e) + return JSONResponse(status_code=500, content=report_get_upload_failed_response) @app.get("/uploadfile") async def fetch_file(id: str): diff --git a/backend/src/chat_storage_service.py b/backend/src/chat_storage_service.py index de9cc17d..c048c06c 100644 --- a/backend/src/chat_storage_service.py +++ b/backend/src/chat_storage_service.py @@ -1,6 +1,5 @@ import json -import logging from typing import TypedDict import redis @@ -14,8 +13,6 @@ class ChatResponse(TypedDict): dataset: str | None reasoning: str | None -logger = logging.getLogger(__name__) - config = Config() redis_client = redis.Redis(host=config.redis_host, port=6379, decode_responses=True) diff --git a/backend/src/directors/report_director.py b/backend/src/directors/report_director.py index c064a57c..80b02141 100644 --- a/backend/src/directors/report_director.py +++ b/backend/src/directors/report_director.py @@ -1,18 +1,10 @@ - -from typing import TypedDict from fastapi import UploadFile +from src.session.file_uploads import FileUploadReport, store_report from src.utils.scratchpad import clear_scratchpad, update_scratchpad from src.utils.file_utils import handle_file_upload from src.agents import get_report_agent - -class FileUploadReport(TypedDict): - id: str - filename: str | None - report: str | None - - async def report_on_file_upload(upload: UploadFile) -> FileUploadReport: file = handle_file_upload(upload) @@ -23,4 +15,8 @@ async def report_on_file_upload(upload: UploadFile) -> FileUploadReport: clear_scratchpad() - return {"filename": file["filename"], "id": file["uploadId"], "report": report} + report_upload = FileUploadReport(filename=file["filename"], id=file["uploadId"], report=report) + + store_report(report_upload) + + return report_upload diff --git a/backend/src/session/file_uploads.py b/backend/src/session/file_uploads.py index 4419441b..9e6dcb4d 100644 --- a/backend/src/session/file_uploads.py +++ b/backend/src/session/file_uploads.py @@ -16,6 +16,7 @@ UPLOADS_SESSION_KEY = "file_uploads" UPLOADS_KEY_PREFIX = "file_upload_" +REPORT_KEY_PREFIX = "report_" class FileUploadMeta(TypedDict): @@ -30,20 +31,27 @@ class FileUpload(TypedDict): contentType: str | None size: int | None +class FileUploadReport(TypedDict): + id: str + filename: str | None + report: str | None def get_session_file_uploads_meta() -> list[FileUploadMeta] | None: return get_session(UPLOADS_META_SESSION_KEY, []) -def get_session_file_upload(upload_id) -> FileUpload | None: - value = redis_client.get(UPLOADS_KEY_PREFIX + upload_id) +def _get_key(key): + value = redis_client.get(key) if value and isinstance(value, str): - parsed_session_data = try_parse_to_json(value) - if parsed_session_data: + if parsed_session_data := try_parse_to_json(value): return parsed_session_data return None +def get_session_file_upload(upload_id) -> FileUpload | None: + return _get_key(UPLOADS_KEY_PREFIX + upload_id) + + def update_session_file_uploads(file_upload:FileUpload): file_uploads_meta_session = get_session(UPLOADS_META_SESSION_KEY, []) if not file_uploads_meta_session: @@ -55,15 +63,26 @@ def update_session_file_uploads(file_upload:FileUpload): def clear_session_file_uploads(): - logger.info("Clearing file uploads from session") + logger.info("Clearing file uploads and reports from session") meta_list = get_session(UPLOADS_META_SESSION_KEY, []) - keys = [ UPLOADS_KEY_PREFIX + meta["uploadId"] for meta in meta_list ] + keys = [] + for meta in meta_list: + keys.append(UPLOADS_KEY_PREFIX + meta["uploadId"]) + keys.append(REPORT_KEY_PREFIX + meta["uploadId"]) - keystr = " ".join(keys) - logger.info("Deleting keys " + keystr) - redis_client.delete(keystr) + if keys: + keystr = " ".join(keys) + logger.info("Deleting keys " + keystr) + redis_client.delete(keystr) set_session(UPLOADS_META_SESSION_KEY, []) + +def store_report(report:FileUploadReport): + redis_client.set(REPORT_KEY_PREFIX + report["id"], json.dumps(report)) + + +def get_report(id: str) -> FileUploadReport | None: + return _get_key(REPORT_KEY_PREFIX + id) diff --git a/backend/tests/api/app_test.py b/backend/tests/api/app_test.py index ae5d91d8..68420c3c 100644 --- a/backend/tests/api/app_test.py +++ b/backend/tests/api/app_test.py @@ -94,3 +94,22 @@ async def test_lifespan_populates_db(mocker) -> None: with client: mock_dataset_upload.assert_called_once_with() + +def test_get_report_success(mocker): + report = FileUploadReport(id="12", filename="test.pdf", report="test report") + mock_get_report = mocker.patch("src.api.app.get_report", return_value=report) + + response = client.get("/report/12") + + mock_get_report.assert_called_with("12") + assert response.status_code == 200 + assert response.headers.get('Content-Disposition') == 'attachment; filename="report.md"' + assert response.headers.get('Content-Type') == 'text/markdown; charset=utf-8' + +def test_get_report_not_found(mocker): + mock_get_report = mocker.patch("src.api.app.get_report", return_value=None) + + response = client.get("/report/12") + + mock_get_report.assert_called_with("12") + assert response.status_code == 404 diff --git a/backend/tests/directors/report_director_test.py b/backend/tests/directors/report_director_test.py index 5cd6583e..b52d6ed7 100644 --- a/backend/tests/directors/report_director_test.py +++ b/backend/tests/directors/report_director_test.py @@ -3,7 +3,7 @@ from fastapi.datastructures import Headers import pytest -from src.session.file_uploads import FileUpload +from src.session.file_uploads import FileUpload, FileUploadReport from src.directors.report_director import report_on_file_upload @@ -15,11 +15,17 @@ async def test_report_on_file_upload(mocker): mock_agent.invoke.return_value = "#Report on upload as markdown" mocker.patch("src.directors.report_director.get_report_agent", return_value=mock_agent) mock_handle_file_upload = mocker.patch("src.directors.report_director.handle_file_upload", return_value=file_upload) + mock_store_report = mocker.patch("src.directors.report_director.store_report", return_value=file_upload) headers = Headers({"content-type": "text/plain"}) file = BytesIO(b"test content") request_upload_file = UploadFile(file=file, size=12, headers=headers, filename="test.txt") response = await report_on_file_upload(request_upload_file) + report_upload = FileUploadReport(filename=file_upload["filename"], + id=file_upload["uploadId"], + report="#Report on upload as markdown") mock_handle_file_upload.assert_called_once_with(request_upload_file) + mock_store_report.assert_called_once_with(report_upload) + assert response == {"filename": "test.txt", "id": "1", "report": "#Report on upload as markdown"} diff --git a/backend/tests/session/test_file_uploads.py b/backend/tests/session/test_file_uploads.py index b853daeb..63df80d2 100644 --- a/backend/tests/session/test_file_uploads.py +++ b/backend/tests/session/test_file_uploads.py @@ -3,8 +3,10 @@ from unittest.mock import patch, MagicMock from starlette.requests import Request from starlette.responses import Response -from src.session.file_uploads import (FileUpload, clear_session_file_uploads, get_session_file_upload, - get_session_file_uploads_meta, update_session_file_uploads) +from src.session.file_uploads import (FileUpload, FileUploadReport, clear_session_file_uploads, + get_report, get_session_file_upload, + get_session_file_uploads_meta, store_report, + update_session_file_uploads) @pytest.fixture def mock_redis(): @@ -75,7 +77,7 @@ def test_clear_session_file_uploads_meta(mocker, mock_redis, mock_request_contex clear_session_file_uploads() assert get_session_file_uploads_meta() == [] - mock_redis.delete.assert_called_with("file_upload_1234") + mock_redis.delete.assert_called_with("file_upload_1234 report_1234") update_session_file_uploads(file_upload=file) update_session_file_uploads(file_upload=file2) @@ -84,6 +86,24 @@ def test_clear_session_file_uploads_meta(mocker, mock_redis, mock_request_contex clear_session_file_uploads() assert get_session_file_uploads_meta() == [] - mock_redis.delete.assert_called_with("file_upload_1234 file_upload_12345") + mock_redis.delete.assert_called_with("file_upload_1234 report_1234 file_upload_12345 report_12345") +def test_store_report(mocker, mock_redis): + mocker.patch("src.session.file_uploads.redis_client", mock_redis) + report = FileUploadReport(filename="test.txt", id="12", report="test report") + + store_report(report) + + mock_redis.set.assert_called_with("report_12", json.dumps(report)) + +def test_get_report(mocker, mock_redis): + mocker.patch("src.session.file_uploads.redis_client", mock_redis) + + report = FileUploadReport(filename="test.txt", id="12", report="test report") + mock_redis.get.return_value = json.dumps(report) + + value = get_report("12") + + assert value == report + mock_redis.get.assert_called_with("report_12") diff --git a/frontend/src/components/button.module.css b/frontend/src/components/button.module.css index bdbfad14..df928d2f 100644 --- a/frontend/src/components/button.module.css +++ b/frontend/src/components/button.module.css @@ -8,6 +8,10 @@ background-color: transparent; } +.button_container.download { + background-color: var(--grey-900); +} + .button_container:not(:has(.withText)) { border-radius: 24px; } @@ -66,6 +70,17 @@ } } +.download { + .button { + color: var(--grey-50); + } + + .button.pressed, + .button:active:enabled { + background-color: var(--black); + } +} + .button_container:has(.button:disabled) { background-color: var(--grey-400); opacity: 0.5; diff --git a/frontend/src/components/button.tsx b/frontend/src/components/button.tsx index ae6d767a..1995a8d5 100644 --- a/frontend/src/components/button.tsx +++ b/frontend/src/components/button.tsx @@ -8,6 +8,8 @@ interface ButtonProps { disabled?: boolean; isOutline?: boolean; isPressed?: boolean; + isDownload?: boolean; + href?: string; onClick?: () => void; } @@ -17,14 +19,17 @@ export const Button = ({ disabled, isOutline, isPressed, + isDownload, + href, onClick, }: ButtonProps) => { const isIconOnly = !text && icon; - return ( + const content = (
); + + return href ? {content} : content; }; diff --git a/frontend/src/components/input.tsx b/frontend/src/components/input.tsx index 8fb1153e..40510c33 100644 --- a/frontend/src/components/input.tsx +++ b/frontend/src/components/input.tsx @@ -72,10 +72,13 @@ export const Input = ({ setUploadInProgress(true); try { - const { filename, report } = await uploadFileToServer(file); + const { filename, report, id } = await uploadFileToServer(file); setUploadedFile(file); appendMessage( - { answer: `Your ESG report for ${filename} is ready to view.` }, + { + id, + answer: `Your ESG report for ${filename} is ready to view.`, + }, Role.Bot, report, `ESG Report - ${filename}`, diff --git a/frontend/src/components/message.module.css b/frontend/src/components/message.module.css index 6df39961..fa845c7f 100644 --- a/frontend/src/components/message.module.css +++ b/frontend/src/components/message.module.css @@ -78,3 +78,8 @@ position: relative; top: -4px; } + +.button_spacer { + display: inline-block; + width: 16px; +} diff --git a/frontend/src/components/message.tsx b/frontend/src/components/message.tsx index bb19afd6..d8596a2b 100644 --- a/frontend/src/components/message.tsx +++ b/frontend/src/components/message.tsx @@ -5,6 +5,7 @@ import UserIcon from '../icons/account-circle.svg'; import BotIcon from '../icons/logomark.svg'; import ChevronIcon from '../icons/chevron.svg'; import OpenGridIcon from '../icons/open-grid.svg'; +import DownloadIcon from '../icons/download.svg'; import { Button } from './button'; export enum Role { @@ -74,6 +75,17 @@ export const MessageComponent = ({ selectMessage(message === selectedMessage ? null : message) } /> + {report && ( + <> + +