Skip to content

Commit

Permalink
FS-123 Add report download (#51)
Browse files Browse the repository at this point in the history
  • Loading branch information
mic-smith authored Dec 12, 2024
1 parent 27a853f commit a8d76c8
Show file tree
Hide file tree
Showing 13 changed files with 144 additions and 31 deletions.
15 changes: 14 additions & 1 deletion backend/src/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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):
Expand Down
3 changes: 0 additions & 3 deletions backend/src/chat_storage_service.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@

import json
import logging
from typing import TypedDict
import redis

Expand All @@ -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)
Expand Down
16 changes: 6 additions & 10 deletions backend/src/directors/report_director.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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
37 changes: 28 additions & 9 deletions backend/src/session/file_uploads.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
UPLOADS_SESSION_KEY = "file_uploads"

UPLOADS_KEY_PREFIX = "file_upload_"
REPORT_KEY_PREFIX = "report_"


class FileUploadMeta(TypedDict):
Expand All @@ -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:
Expand All @@ -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)
19 changes: 19 additions & 0 deletions backend/tests/api/app_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 7 additions & 1 deletion backend/tests/directors/report_director_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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"}
28 changes: 24 additions & 4 deletions backend/tests/session/test_file_uploads.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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)
Expand All @@ -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")
15 changes: 15 additions & 0 deletions frontend/src/components/button.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
background-color: transparent;
}

.button_container.download {
background-color: var(--grey-900);
}

.button_container:not(:has(.withText)) {
border-radius: 24px;
}
Expand Down Expand Up @@ -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;
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/components/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ interface ButtonProps {
disabled?: boolean;
isOutline?: boolean;
isPressed?: boolean;
isDownload?: boolean;
href?: string;
onClick?: () => void;
}

Expand All @@ -17,14 +19,17 @@ export const Button = ({
disabled,
isOutline,
isPressed,
isDownload,
href,
onClick,
}: ButtonProps) => {
const isIconOnly = !text && icon;

return (
const content = (
<div
className={classNames(styles.button_container, {
[styles.outline]: isOutline,
[styles.download]: isDownload,
})}
>
<button
Expand All @@ -48,4 +53,6 @@ export const Button = ({
</button>
</div>
);

return href ? <a href={href}>{content}</a> : content;
};
7 changes: 5 additions & 2 deletions frontend/src/components/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/components/message.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,8 @@
position: relative;
top: -4px;
}

.button_spacer {
display: inline-block;
width: 16px;
}
12 changes: 12 additions & 0 deletions frontend/src/components/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -74,6 +75,17 @@ export const MessageComponent = ({
selectMessage(message === selectedMessage ? null : message)
}
/>
{report && (
<>
<span className={styles.button_spacer} />
<Button
isDownload
text="Download report"
icon={DownloadIcon}
href={`${process.env.BACKEND_URL}/report/${message.id}`}
/>
</>
)}
</div>
)}
{role == Role.Bot && reasoning && (
Expand Down
1 change: 1 addition & 0 deletions frontend/src/icons/download.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit a8d76c8

Please sign in to comment.