Spaces:
Sleeping
Sleeping
Commit
·
53cf39f
1
Parent(s):
efbe684
Upd PDF downloader + code snippet copier
Browse files- app.py +38 -8
- requirements.txt +3 -1
- static/script.js +191 -2
- static/styles.css +99 -0
- utils/__init__.py +1 -0
- utils/pdf.py +171 -0
app.py
CHANGED
|
@@ -430,11 +430,11 @@ async def upload_files(
|
|
| 430 |
preloaded_files = []
|
| 431 |
for uf in files:
|
| 432 |
raw = await uf.read()
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
|
| 439 |
# Initialize job status
|
| 440 |
app.state.jobs[job_id] = {
|
|
@@ -473,7 +473,7 @@ async def upload_files(
|
|
| 473 |
cap = captioner.caption_image(im)
|
| 474 |
caps.append(cap)
|
| 475 |
except Exception as e:
|
| 476 |
-
|
| 477 |
captions.append(caps)
|
| 478 |
else:
|
| 479 |
captions = [[] for _ in pages]
|
|
@@ -713,6 +713,36 @@ async def generate_report(
|
|
| 713 |
return ReportResponse(filename=eff_name, report_markdown=report_md, sources=sources_meta)
|
| 714 |
|
| 715 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 716 |
@app.post("/chat", response_model=ChatAnswerResponse)
|
| 717 |
async def chat(
|
| 718 |
user_id: str = Form(...),
|
|
@@ -775,7 +805,7 @@ async def _chat_impl(
|
|
| 775 |
match = next((f["filename"] for f in files_ci if f.get("filename", "").lower() == fn.lower()), None)
|
| 776 |
if match:
|
| 777 |
doc = rag.get_file_summary(user_id=user_id, project_id=project_id, filename=match)
|
| 778 |
-
|
| 779 |
return ChatAnswerResponse(
|
| 780 |
answer=doc.get("summary", ""),
|
| 781 |
sources=[{"filename": match, "file_summary": True}]
|
|
@@ -935,7 +965,7 @@ async def _chat_impl(
|
|
| 935 |
fsum_map = {f["filename"]: f.get("summary","") for f in files_list}
|
| 936 |
lines = [f"[{fn}] {fsum_map.get(fn, '')}" for fn in relevant_files]
|
| 937 |
file_summary_block = "\n".join(lines)
|
| 938 |
-
|
| 939 |
# Guardrail instruction to avoid hallucination
|
| 940 |
system_prompt = (
|
| 941 |
"You are a careful study assistant. Answer strictly using the given CONTEXT.\n"
|
|
|
|
| 430 |
preloaded_files = []
|
| 431 |
for uf in files:
|
| 432 |
raw = await uf.read()
|
| 433 |
+
if len(raw) > max_mb * 1024 * 1024:
|
| 434 |
+
raise HTTPException(400, detail=f"{uf.filename} exceeds {max_mb} MB limit")
|
| 435 |
+
# Apply rename if present
|
| 436 |
+
eff_name = rename_dict.get(uf.filename, uf.filename)
|
| 437 |
+
preloaded_files.append((eff_name, raw))
|
| 438 |
|
| 439 |
# Initialize job status
|
| 440 |
app.state.jobs[job_id] = {
|
|
|
|
| 473 |
cap = captioner.caption_image(im)
|
| 474 |
caps.append(cap)
|
| 475 |
except Exception as e:
|
| 476 |
+
logger.warning(f"[{job_id}] Caption error in {fname}: {e}")
|
| 477 |
captions.append(caps)
|
| 478 |
else:
|
| 479 |
captions = [[] for _ in pages]
|
|
|
|
| 713 |
return ReportResponse(filename=eff_name, report_markdown=report_md, sources=sources_meta)
|
| 714 |
|
| 715 |
|
| 716 |
+
@app.post("/report/pdf")
|
| 717 |
+
async def generate_report_pdf(
|
| 718 |
+
user_id: str = Form(...),
|
| 719 |
+
project_id: str = Form(...),
|
| 720 |
+
report_content: str = Form(...)
|
| 721 |
+
):
|
| 722 |
+
"""
|
| 723 |
+
Generate a PDF from report content using the PDF utility module
|
| 724 |
+
"""
|
| 725 |
+
from utils.pdf import generate_report_pdf as generate_pdf
|
| 726 |
+
from fastapi.responses import Response
|
| 727 |
+
|
| 728 |
+
try:
|
| 729 |
+
pdf_content = await generate_pdf(report_content, user_id, project_id)
|
| 730 |
+
|
| 731 |
+
# Return PDF as response
|
| 732 |
+
return Response(
|
| 733 |
+
content=pdf_content,
|
| 734 |
+
media_type="application/pdf",
|
| 735 |
+
headers={"Content-Disposition": f"attachment; filename=report-{datetime.now().strftime('%Y-%m-%d')}.pdf"}
|
| 736 |
+
)
|
| 737 |
+
|
| 738 |
+
except HTTPException:
|
| 739 |
+
# Re-raise HTTP exceptions as-is
|
| 740 |
+
raise
|
| 741 |
+
except Exception as e:
|
| 742 |
+
logger.error(f"[PDF] Unexpected error in PDF endpoint: {e}")
|
| 743 |
+
raise HTTPException(500, detail=f"Failed to generate PDF: {str(e)}")
|
| 744 |
+
|
| 745 |
+
|
| 746 |
@app.post("/chat", response_model=ChatAnswerResponse)
|
| 747 |
async def chat(
|
| 748 |
user_id: str = Form(...),
|
|
|
|
| 805 |
match = next((f["filename"] for f in files_ci if f.get("filename", "").lower() == fn.lower()), None)
|
| 806 |
if match:
|
| 807 |
doc = rag.get_file_summary(user_id=user_id, project_id=project_id, filename=match)
|
| 808 |
+
if doc:
|
| 809 |
return ChatAnswerResponse(
|
| 810 |
answer=doc.get("summary", ""),
|
| 811 |
sources=[{"filename": match, "file_summary": True}]
|
|
|
|
| 965 |
fsum_map = {f["filename"]: f.get("summary","") for f in files_list}
|
| 966 |
lines = [f"[{fn}] {fsum_map.get(fn, '')}" for fn in relevant_files]
|
| 967 |
file_summary_block = "\n".join(lines)
|
| 968 |
+
|
| 969 |
# Guardrail instruction to avoid hallucination
|
| 970 |
system_prompt = (
|
| 971 |
"You are a careful study assistant. Answer strictly using the given CONTEXT.\n"
|
requirements.txt
CHANGED
|
@@ -10,4 +10,6 @@ transformers==4.44.2
|
|
| 10 |
torch==2.2.2
|
| 11 |
sentence-transformers==3.1.1
|
| 12 |
sumy==0.11.0
|
| 13 |
-
numpy==1.26.4
|
|
|
|
|
|
|
|
|
| 10 |
torch==2.2.2
|
| 11 |
sentence-transformers==3.1.1
|
| 12 |
sumy==0.11.0
|
| 13 |
+
numpy==1.26.4
|
| 14 |
+
weasyprint==62.3
|
| 15 |
+
markdown==3.6
|
static/script.js
CHANGED
|
@@ -514,7 +514,7 @@
|
|
| 514 |
const data = await response.json();
|
| 515 |
if (response.ok) {
|
| 516 |
thinkingMsg.remove();
|
| 517 |
-
appendMessage('assistant', data.report_markdown || 'No report');
|
| 518 |
if (data.sources && data.sources.length) appendSources(data.sources);
|
| 519 |
// Save assistant report to chat history for persistence
|
| 520 |
try { await saveChatMessage(user.user_id, currentProject.project_id, 'assistant', data.report_markdown || 'No report'); } catch {}
|
|
@@ -656,7 +656,7 @@
|
|
| 656 |
}
|
| 657 |
}
|
| 658 |
|
| 659 |
-
function appendMessage(role, text) {
|
| 660 |
const messageDiv = document.createElement('div');
|
| 661 |
messageDiv.className = `msg ${role}`;
|
| 662 |
|
|
@@ -666,6 +666,14 @@
|
|
| 666 |
// Use marked library to convert Markdown to HTML
|
| 667 |
const htmlContent = marked.parse(text);
|
| 668 |
messageDiv.innerHTML = htmlContent;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 669 |
} catch (e) {
|
| 670 |
// Fallback to plain text if Markdown parsing fails
|
| 671 |
messageDiv.textContent = text;
|
|
@@ -683,6 +691,187 @@
|
|
| 683 |
|
| 684 |
return messageDiv;
|
| 685 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 686 |
|
| 687 |
function appendSources(sources) {
|
| 688 |
const sourcesDiv = document.createElement('div');
|
|
|
|
| 514 |
const data = await response.json();
|
| 515 |
if (response.ok) {
|
| 516 |
thinkingMsg.remove();
|
| 517 |
+
appendMessage('assistant', data.report_markdown || 'No report', true); // isReport = true
|
| 518 |
if (data.sources && data.sources.length) appendSources(data.sources);
|
| 519 |
// Save assistant report to chat history for persistence
|
| 520 |
try { await saveChatMessage(user.user_id, currentProject.project_id, 'assistant', data.report_markdown || 'No report'); } catch {}
|
|
|
|
| 656 |
}
|
| 657 |
}
|
| 658 |
|
| 659 |
+
function appendMessage(role, text, isReport = false) {
|
| 660 |
const messageDiv = document.createElement('div');
|
| 661 |
messageDiv.className = `msg ${role}`;
|
| 662 |
|
|
|
|
| 666 |
// Use marked library to convert Markdown to HTML
|
| 667 |
const htmlContent = marked.parse(text);
|
| 668 |
messageDiv.innerHTML = htmlContent;
|
| 669 |
+
|
| 670 |
+
// Add copy buttons to code blocks
|
| 671 |
+
addCopyButtonsToCodeBlocks(messageDiv);
|
| 672 |
+
|
| 673 |
+
// Add download PDF button for reports
|
| 674 |
+
if (isReport) {
|
| 675 |
+
addDownloadPdfButton(messageDiv, text);
|
| 676 |
+
}
|
| 677 |
} catch (e) {
|
| 678 |
// Fallback to plain text if Markdown parsing fails
|
| 679 |
messageDiv.textContent = text;
|
|
|
|
| 691 |
|
| 692 |
return messageDiv;
|
| 693 |
}
|
| 694 |
+
|
| 695 |
+
function addCopyButtonsToCodeBlocks(messageDiv) {
|
| 696 |
+
const codeBlocks = messageDiv.querySelectorAll('pre code');
|
| 697 |
+
codeBlocks.forEach((codeBlock, index) => {
|
| 698 |
+
const pre = codeBlock.parentElement;
|
| 699 |
+
const language = codeBlock.className.match(/language-(\w+)/)?.[1] || 'code';
|
| 700 |
+
|
| 701 |
+
// Create wrapper
|
| 702 |
+
const wrapper = document.createElement('div');
|
| 703 |
+
wrapper.className = 'code-block-wrapper';
|
| 704 |
+
|
| 705 |
+
// Create header with language and copy button
|
| 706 |
+
const header = document.createElement('div');
|
| 707 |
+
header.className = 'code-block-header';
|
| 708 |
+
header.innerHTML = `
|
| 709 |
+
<span class="code-block-language">${language}</span>
|
| 710 |
+
<button class="copy-code-btn" data-code-index="${index}">
|
| 711 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 712 |
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
| 713 |
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
| 714 |
+
</svg>
|
| 715 |
+
Copy
|
| 716 |
+
</button>
|
| 717 |
+
`;
|
| 718 |
+
|
| 719 |
+
// Create content wrapper
|
| 720 |
+
const content = document.createElement('div');
|
| 721 |
+
content.className = 'code-block-content';
|
| 722 |
+
content.appendChild(codeBlock.cloneNode(true));
|
| 723 |
+
|
| 724 |
+
// Assemble wrapper
|
| 725 |
+
wrapper.appendChild(header);
|
| 726 |
+
wrapper.appendChild(content);
|
| 727 |
+
|
| 728 |
+
// Replace original pre with wrapper
|
| 729 |
+
pre.parentNode.replaceChild(wrapper, pre);
|
| 730 |
+
|
| 731 |
+
// Add click handler for copy button
|
| 732 |
+
const copyBtn = wrapper.querySelector('.copy-code-btn');
|
| 733 |
+
copyBtn.addEventListener('click', () => copyCodeToClipboard(codeBlock.textContent, copyBtn));
|
| 734 |
+
});
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
function copyCodeToClipboard(code, button) {
|
| 738 |
+
navigator.clipboard.writeText(code).then(() => {
|
| 739 |
+
const originalText = button.innerHTML;
|
| 740 |
+
button.innerHTML = `
|
| 741 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 742 |
+
<polyline points="20,6 9,17 4,12"></polyline>
|
| 743 |
+
</svg>
|
| 744 |
+
Copied!
|
| 745 |
+
`;
|
| 746 |
+
button.classList.add('copied');
|
| 747 |
+
|
| 748 |
+
setTimeout(() => {
|
| 749 |
+
button.innerHTML = originalText;
|
| 750 |
+
button.classList.remove('copied');
|
| 751 |
+
}, 2000);
|
| 752 |
+
}).catch(err => {
|
| 753 |
+
console.error('Failed to copy code:', err);
|
| 754 |
+
// Fallback for older browsers
|
| 755 |
+
const textArea = document.createElement('textarea');
|
| 756 |
+
textArea.value = code;
|
| 757 |
+
document.body.appendChild(textArea);
|
| 758 |
+
textArea.select();
|
| 759 |
+
try {
|
| 760 |
+
document.execCommand('copy');
|
| 761 |
+
button.innerHTML = `
|
| 762 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 763 |
+
<polyline points="20,6 9,17 4,12"></polyline>
|
| 764 |
+
</svg>
|
| 765 |
+
Copied!
|
| 766 |
+
`;
|
| 767 |
+
button.classList.add('copied');
|
| 768 |
+
setTimeout(() => {
|
| 769 |
+
button.innerHTML = `
|
| 770 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 771 |
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
| 772 |
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
| 773 |
+
</svg>
|
| 774 |
+
Copy
|
| 775 |
+
`;
|
| 776 |
+
button.classList.remove('copied');
|
| 777 |
+
}, 2000);
|
| 778 |
+
} catch (fallbackErr) {
|
| 779 |
+
console.error('Fallback copy failed:', fallbackErr);
|
| 780 |
+
}
|
| 781 |
+
document.body.removeChild(textArea);
|
| 782 |
+
});
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
function addDownloadPdfButton(messageDiv, reportContent) {
|
| 786 |
+
const downloadBtn = document.createElement('button');
|
| 787 |
+
downloadBtn.className = 'download-pdf-btn';
|
| 788 |
+
downloadBtn.innerHTML = `
|
| 789 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 790 |
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
| 791 |
+
<polyline points="7,10 12,15 17,10"></polyline>
|
| 792 |
+
<line x1="12" y1="15" x2="12" y2="3"></line>
|
| 793 |
+
</svg>
|
| 794 |
+
Download PDF
|
| 795 |
+
`;
|
| 796 |
+
|
| 797 |
+
downloadBtn.addEventListener('click', () => downloadReportAsPdf(reportContent, downloadBtn));
|
| 798 |
+
messageDiv.appendChild(downloadBtn);
|
| 799 |
+
}
|
| 800 |
+
|
| 801 |
+
async function downloadReportAsPdf(reportContent, button) {
|
| 802 |
+
const user = window.__sb_get_user();
|
| 803 |
+
const currentProject = window.__sb_get_current_project && window.__sb_get_current_project();
|
| 804 |
+
|
| 805 |
+
if (!user || !currentProject) {
|
| 806 |
+
alert('Please sign in and select a project');
|
| 807 |
+
return;
|
| 808 |
+
}
|
| 809 |
+
|
| 810 |
+
button.disabled = true;
|
| 811 |
+
button.innerHTML = `
|
| 812 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 813 |
+
<circle cx="12" cy="12" r="10"></circle>
|
| 814 |
+
<polyline points="12,6 12,12 16,14"></polyline>
|
| 815 |
+
</svg>
|
| 816 |
+
Generating PDF...
|
| 817 |
+
`;
|
| 818 |
+
|
| 819 |
+
try {
|
| 820 |
+
const formData = new FormData();
|
| 821 |
+
formData.append('user_id', user.user_id);
|
| 822 |
+
formData.append('project_id', currentProject.project_id);
|
| 823 |
+
formData.append('report_content', reportContent);
|
| 824 |
+
|
| 825 |
+
const response = await fetch('/report/pdf', {
|
| 826 |
+
method: 'POST',
|
| 827 |
+
body: formData
|
| 828 |
+
});
|
| 829 |
+
|
| 830 |
+
if (response.ok) {
|
| 831 |
+
const blob = await response.blob();
|
| 832 |
+
const url = window.URL.createObjectURL(blob);
|
| 833 |
+
const a = document.createElement('a');
|
| 834 |
+
a.href = url;
|
| 835 |
+
a.download = `report-${new Date().toISOString().split('T')[0]}.pdf`;
|
| 836 |
+
document.body.appendChild(a);
|
| 837 |
+
a.click();
|
| 838 |
+
window.URL.revokeObjectURL(url);
|
| 839 |
+
document.body.removeChild(a);
|
| 840 |
+
|
| 841 |
+
button.innerHTML = `
|
| 842 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 843 |
+
<polyline points="20,6 9,17 4,12"></polyline>
|
| 844 |
+
</svg>
|
| 845 |
+
Downloaded!
|
| 846 |
+
`;
|
| 847 |
+
setTimeout(() => {
|
| 848 |
+
button.innerHTML = `
|
| 849 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 850 |
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
| 851 |
+
<polyline points="7,10 12,15 17,10"></polyline>
|
| 852 |
+
<line x1="12" y1="15" x2="12" y2="3"></line>
|
| 853 |
+
</svg>
|
| 854 |
+
Download PDF
|
| 855 |
+
`;
|
| 856 |
+
button.disabled = false;
|
| 857 |
+
}, 2000);
|
| 858 |
+
} else {
|
| 859 |
+
throw new Error('Failed to generate PDF');
|
| 860 |
+
}
|
| 861 |
+
} catch (error) {
|
| 862 |
+
console.error('PDF generation failed:', error);
|
| 863 |
+
alert('Failed to generate PDF. Please try again.');
|
| 864 |
+
button.innerHTML = `
|
| 865 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 866 |
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
| 867 |
+
<polyline points="7,10 12,15 17,10"></polyline>
|
| 868 |
+
<line x1="12" y1="15" x2="12" y2="3"></line>
|
| 869 |
+
</svg>
|
| 870 |
+
Download PDF
|
| 871 |
+
`;
|
| 872 |
+
button.disabled = false;
|
| 873 |
+
}
|
| 874 |
+
}
|
| 875 |
|
| 876 |
function appendSources(sources) {
|
| 877 |
const sourcesDiv = document.createElement('div');
|
static/styles.css
CHANGED
|
@@ -1018,6 +1018,105 @@
|
|
| 1018 |
overflow: auto;
|
| 1019 |
}
|
| 1020 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1021 |
/* Modal */
|
| 1022 |
.modal {
|
| 1023 |
position: fixed;
|
|
|
|
| 1018 |
overflow: auto;
|
| 1019 |
}
|
| 1020 |
|
| 1021 |
+
/* Code blocks with copy button */
|
| 1022 |
+
.code-block-wrapper {
|
| 1023 |
+
position: relative;
|
| 1024 |
+
margin: 16px 0;
|
| 1025 |
+
border-radius: var(--radius);
|
| 1026 |
+
overflow: hidden;
|
| 1027 |
+
border: 1px solid var(--border);
|
| 1028 |
+
}
|
| 1029 |
+
|
| 1030 |
+
.code-block-header {
|
| 1031 |
+
display: flex;
|
| 1032 |
+
justify-content: space-between;
|
| 1033 |
+
align-items: center;
|
| 1034 |
+
padding: 8px 12px;
|
| 1035 |
+
background: var(--bg-secondary);
|
| 1036 |
+
border-bottom: 1px solid var(--border);
|
| 1037 |
+
font-size: 12px;
|
| 1038 |
+
color: var(--muted);
|
| 1039 |
+
}
|
| 1040 |
+
|
| 1041 |
+
.code-block-language {
|
| 1042 |
+
font-weight: 600;
|
| 1043 |
+
text-transform: uppercase;
|
| 1044 |
+
}
|
| 1045 |
+
|
| 1046 |
+
.copy-code-btn {
|
| 1047 |
+
display: flex;
|
| 1048 |
+
align-items: center;
|
| 1049 |
+
gap: 4px;
|
| 1050 |
+
padding: 4px 8px;
|
| 1051 |
+
background: var(--card);
|
| 1052 |
+
border: 1px solid var(--border);
|
| 1053 |
+
border-radius: var(--radius-sm);
|
| 1054 |
+
color: var(--text-secondary);
|
| 1055 |
+
font-size: 11px;
|
| 1056 |
+
cursor: pointer;
|
| 1057 |
+
transition: all 0.2s ease;
|
| 1058 |
+
}
|
| 1059 |
+
|
| 1060 |
+
.copy-code-btn:hover {
|
| 1061 |
+
background: var(--card-hover);
|
| 1062 |
+
border-color: var(--border-light);
|
| 1063 |
+
color: var(--text);
|
| 1064 |
+
}
|
| 1065 |
+
|
| 1066 |
+
.copy-code-btn.copied {
|
| 1067 |
+
background: var(--success);
|
| 1068 |
+
color: white;
|
| 1069 |
+
border-color: var(--success);
|
| 1070 |
+
}
|
| 1071 |
+
|
| 1072 |
+
.copy-code-btn svg {
|
| 1073 |
+
width: 12px;
|
| 1074 |
+
height: 12px;
|
| 1075 |
+
}
|
| 1076 |
+
|
| 1077 |
+
.code-block-content {
|
| 1078 |
+
background: var(--code-bg, #1e1e1e);
|
| 1079 |
+
color: var(--code-text, #d4d4d4);
|
| 1080 |
+
padding: 16px;
|
| 1081 |
+
overflow-x: auto;
|
| 1082 |
+
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
| 1083 |
+
font-size: 14px;
|
| 1084 |
+
line-height: 1.5;
|
| 1085 |
+
}
|
| 1086 |
+
|
| 1087 |
+
/* Download PDF button */
|
| 1088 |
+
.download-pdf-btn {
|
| 1089 |
+
display: inline-flex;
|
| 1090 |
+
align-items: center;
|
| 1091 |
+
gap: 8px;
|
| 1092 |
+
margin-top: 16px;
|
| 1093 |
+
padding: 12px 20px;
|
| 1094 |
+
background: var(--gradient-accent);
|
| 1095 |
+
color: white;
|
| 1096 |
+
border: none;
|
| 1097 |
+
border-radius: var(--radius);
|
| 1098 |
+
font-weight: 600;
|
| 1099 |
+
cursor: pointer;
|
| 1100 |
+
transition: all 0.2s ease;
|
| 1101 |
+
box-shadow: var(--shadow);
|
| 1102 |
+
}
|
| 1103 |
+
|
| 1104 |
+
.download-pdf-btn:hover {
|
| 1105 |
+
transform: translateY(-1px);
|
| 1106 |
+
box-shadow: var(--shadow-lg);
|
| 1107 |
+
}
|
| 1108 |
+
|
| 1109 |
+
.download-pdf-btn:disabled {
|
| 1110 |
+
opacity: 0.6;
|
| 1111 |
+
cursor: not-allowed;
|
| 1112 |
+
transform: none;
|
| 1113 |
+
}
|
| 1114 |
+
|
| 1115 |
+
.download-pdf-btn svg {
|
| 1116 |
+
width: 16px;
|
| 1117 |
+
height: 16px;
|
| 1118 |
+
}
|
| 1119 |
+
|
| 1120 |
/* Modal */
|
| 1121 |
.modal {
|
| 1122 |
position: fixed;
|
utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Utils package for StudyBuddy
|
utils/pdf.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PDF generation utilities for StudyBuddy
|
| 3 |
+
"""
|
| 4 |
+
import os
|
| 5 |
+
import tempfile
|
| 6 |
+
import markdown
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from fastapi import HTTPException
|
| 9 |
+
from utils.logger import get_logger
|
| 10 |
+
|
| 11 |
+
logger = get_logger("PDF", __name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
async def generate_report_pdf(report_content: str, user_id: str, project_id: str) -> bytes:
|
| 15 |
+
"""
|
| 16 |
+
Generate a PDF from report content using weasyprint
|
| 17 |
+
|
| 18 |
+
Args:
|
| 19 |
+
report_content: Markdown content of the report
|
| 20 |
+
user_id: User ID for logging
|
| 21 |
+
project_id: Project ID for logging
|
| 22 |
+
|
| 23 |
+
Returns:
|
| 24 |
+
PDF content as bytes
|
| 25 |
+
|
| 26 |
+
Raises:
|
| 27 |
+
HTTPException: If PDF generation fails
|
| 28 |
+
"""
|
| 29 |
+
try:
|
| 30 |
+
from weasyprint import HTML, CSS
|
| 31 |
+
from weasyprint.text.fonts import FontConfiguration
|
| 32 |
+
|
| 33 |
+
logger.info(f"[PDF] Generating PDF for user {user_id}, project {project_id}")
|
| 34 |
+
|
| 35 |
+
# Convert markdown to HTML
|
| 36 |
+
html_content = markdown.markdown(report_content, extensions=['codehilite', 'fenced_code'])
|
| 37 |
+
|
| 38 |
+
# Create a complete HTML document with styling
|
| 39 |
+
full_html = f"""
|
| 40 |
+
<!DOCTYPE html>
|
| 41 |
+
<html>
|
| 42 |
+
<head>
|
| 43 |
+
<meta charset="utf-8">
|
| 44 |
+
<title>Study Report</title>
|
| 45 |
+
<style>
|
| 46 |
+
body {{
|
| 47 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 48 |
+
line-height: 1.6;
|
| 49 |
+
color: #333;
|
| 50 |
+
max-width: 800px;
|
| 51 |
+
margin: 0 auto;
|
| 52 |
+
padding: 40px 20px;
|
| 53 |
+
}}
|
| 54 |
+
h1, h2, h3, h4, h5, h6 {{
|
| 55 |
+
color: #2c3e50;
|
| 56 |
+
margin-top: 2em;
|
| 57 |
+
margin-bottom: 1em;
|
| 58 |
+
}}
|
| 59 |
+
h1 {{
|
| 60 |
+
border-bottom: 2px solid #3498db;
|
| 61 |
+
padding-bottom: 10px;
|
| 62 |
+
}}
|
| 63 |
+
h2 {{
|
| 64 |
+
border-bottom: 1px solid #bdc3c7;
|
| 65 |
+
padding-bottom: 5px;
|
| 66 |
+
}}
|
| 67 |
+
code {{
|
| 68 |
+
background: #f8f9fa;
|
| 69 |
+
padding: 2px 6px;
|
| 70 |
+
border-radius: 3px;
|
| 71 |
+
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
| 72 |
+
font-size: 0.9em;
|
| 73 |
+
}}
|
| 74 |
+
pre {{
|
| 75 |
+
background: #2c3e50;
|
| 76 |
+
color: #ecf0f1;
|
| 77 |
+
padding: 20px;
|
| 78 |
+
border-radius: 8px;
|
| 79 |
+
overflow-x: auto;
|
| 80 |
+
margin: 1em 0;
|
| 81 |
+
}}
|
| 82 |
+
pre code {{
|
| 83 |
+
background: none;
|
| 84 |
+
padding: 0;
|
| 85 |
+
color: inherit;
|
| 86 |
+
}}
|
| 87 |
+
blockquote {{
|
| 88 |
+
border-left: 4px solid #3498db;
|
| 89 |
+
margin: 1em 0;
|
| 90 |
+
padding-left: 20px;
|
| 91 |
+
color: #7f8c8d;
|
| 92 |
+
}}
|
| 93 |
+
table {{
|
| 94 |
+
border-collapse: collapse;
|
| 95 |
+
width: 100%;
|
| 96 |
+
margin: 1em 0;
|
| 97 |
+
}}
|
| 98 |
+
th, td {{
|
| 99 |
+
border: 1px solid #bdc3c7;
|
| 100 |
+
padding: 12px;
|
| 101 |
+
text-align: left;
|
| 102 |
+
}}
|
| 103 |
+
th {{
|
| 104 |
+
background: #ecf0f1;
|
| 105 |
+
font-weight: 600;
|
| 106 |
+
}}
|
| 107 |
+
ul, ol {{
|
| 108 |
+
padding-left: 2em;
|
| 109 |
+
}}
|
| 110 |
+
li {{
|
| 111 |
+
margin: 0.5em 0;
|
| 112 |
+
}}
|
| 113 |
+
p {{
|
| 114 |
+
margin: 1em 0;
|
| 115 |
+
}}
|
| 116 |
+
.page-break {{
|
| 117 |
+
page-break-before: always;
|
| 118 |
+
}}
|
| 119 |
+
@page {{
|
| 120 |
+
margin: 2cm;
|
| 121 |
+
@bottom-center {{
|
| 122 |
+
content: "Page " counter(page);
|
| 123 |
+
font-size: 10px;
|
| 124 |
+
color: #7f8c8d;
|
| 125 |
+
}}
|
| 126 |
+
}}
|
| 127 |
+
</style>
|
| 128 |
+
</head>
|
| 129 |
+
<body>
|
| 130 |
+
<h1>Study Report</h1>
|
| 131 |
+
<p><em>Generated on {datetime.now().strftime('%B %d, %Y at %I:%M %p')}</em></p>
|
| 132 |
+
<hr>
|
| 133 |
+
{html_content}
|
| 134 |
+
</body>
|
| 135 |
+
</html>
|
| 136 |
+
"""
|
| 137 |
+
|
| 138 |
+
# Generate PDF
|
| 139 |
+
font_config = FontConfiguration()
|
| 140 |
+
html_doc = HTML(string=full_html)
|
| 141 |
+
css = CSS(string='', font_config=font_config)
|
| 142 |
+
|
| 143 |
+
# Create temporary file for PDF
|
| 144 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp_file:
|
| 145 |
+
pdf_path = tmp_file.name
|
| 146 |
+
|
| 147 |
+
try:
|
| 148 |
+
html_doc.write_pdf(pdf_path, stylesheets=[css], font_config=font_config)
|
| 149 |
+
|
| 150 |
+
# Read the generated PDF
|
| 151 |
+
with open(pdf_path, 'rb') as pdf_file:
|
| 152 |
+
pdf_content = pdf_file.read()
|
| 153 |
+
|
| 154 |
+
# Clean up temporary file
|
| 155 |
+
os.unlink(pdf_path)
|
| 156 |
+
|
| 157 |
+
logger.info(f"[PDF] Successfully generated PDF ({len(pdf_content)} bytes) for user {user_id}, project {project_id}")
|
| 158 |
+
return pdf_content
|
| 159 |
+
|
| 160 |
+
except Exception as e:
|
| 161 |
+
# Clean up temporary file on error
|
| 162 |
+
if os.path.exists(pdf_path):
|
| 163 |
+
os.unlink(pdf_path)
|
| 164 |
+
raise e
|
| 165 |
+
|
| 166 |
+
except ImportError:
|
| 167 |
+
logger.error("[PDF] weasyprint not installed. Install with: pip install weasyprint")
|
| 168 |
+
raise HTTPException(500, detail="PDF generation not available. Please install weasyprint.")
|
| 169 |
+
except Exception as e:
|
| 170 |
+
logger.error(f"[PDF] Failed to generate PDF: {e}")
|
| 171 |
+
raise HTTPException(500, detail=f"Failed to generate PDF: {str(e)}")
|