"""
export_report.py — BIRAS Report Export System
==============================================
Generates publication-ready reports from analysis_results in:
- DOCX (python-docx) ← primary
- TXT ← always available fallback
- PDF via DOCX→PDF ← optional (requires LibreOffice or reportlab)
Usage:
from export_report import export_report
pdf_bytes = export_report(analysis_results, fmt='docx')
"""
from __future__ import annotations
import io
import os
import datetime
import logging
logger = logging.getLogger('biras.export')
# ─────────────────────────────────────────────────────────────────────────────
# HELPERS
# ─────────────────────────────────────────────────────────────────────────────
def _get(ar: dict, *keys, default=''):
"""Nested safe-get from analysis_results."""
for key_path in keys:
parts = key_path.split('.')
val = ar
for p in parts:
if isinstance(val, dict):
val = val.get(p, '')
else:
val = ''
break
if val:
return val
return default
def _flatten(ar: dict) -> dict:
"""
Build a flat context dict from the nested analysis_results structure,
with backward-compat fallback to flat keys.
"""
query = (ar.get('meta') or {}).get('query') or ar.get('query', 'Research Topic')
n = ((ar.get('meta') or {}).get('article_count') or
ar.get('total_articles', ar.get('total_results', 0)))
writing = ar.get('writing', ar) # nested or flat fallback
research = ar.get('research', ar)
ai_out = ar.get('ai_outputs', ar)
rqs = research.get('questions', ar.get('research_questions', []))
rqs_text = '\n'.join(f" {i+1}. {q}" for i, q in enumerate(rqs)) if rqs else ' (Not available)'
return {
'query': query,
'n': n,
'timestamp': datetime.datetime.now().strftime('%Y-%m-%d %H:%M UTC'),
'llm_source': (ar.get('meta') or {}).get('llm_source', ar.get('llm_source', 'rule-based')),
# Writing sections (nested → flat fallback)
'abstract': writing.get('abstract', ar.get('abstract', '')),
'global_summary': writing.get('global_summary', ar.get('global_summary', '')),
'conclusion': writing.get('conclusion', ar.get('conclusion', '')),
'future_work': writing.get('future_work', ar.get('future_work', '')),
'validation': writing.get('validation_summary', ar.get('validation_summary', '')),
'system_position': writing.get('system_positioning', ar.get('system_positioning', '')),
'limitations': writing.get('limitations', ar.get('limitations', '')),
# AI outputs
'insights_text': ai_out.get('insights_text', ar.get('insights_text', '')),
'recommendations': ai_out.get('recommendations_text', ar.get('recommendations_text', '')),
'narrative': ai_out.get('narrative_text', ar.get('narrative_text', '')),
# Research
'rqs_text': rqs_text,
'related_work': research.get('related_work', ar.get('related_work', '')),
# Evaluation
'evaluation': ar.get('evaluation', {}),
'metrics': ar.get('metrics', {}),
}
# ─────────────────────────────────────────────────────────────────────────────
# TXT EXPORT (always-available fallback)
# ─────────────────────────────────────────────────────────────────────────────
def _build_txt(ctx: dict) -> bytes:
sep = '=' * 70
lines = [
sep,
f" BIRAS — AI Research Report",
f" Query : {ctx['query']}",
f" Papers: {ctx['n']}",
f" Date : {ctx['timestamp']}",
f" Source: {ctx['llm_source']}",
sep, '',
'[ ABSTRACT ]',
ctx['abstract'] or ctx['global_summary'] or '(not available)', '',
'[ KEY INSIGHTS ]',
ctx['insights_text'] or '(not available)', '',
'[ RESEARCH QUESTIONS ]',
ctx['rqs_text'], '',
'[ RELATED WORK ]',
ctx['related_work'] or '(not available)', '',
'[ CONCLUSION ]',
ctx['conclusion'] or '(not available)', '',
'[ FUTURE WORK ]',
ctx['future_work'] or '(not available)', '',
]
if ctx.get('limitations'):
lines += ['[ LIMITATIONS ]', ctx['limitations'], '']
ev = ctx.get('evaluation', {})
if ev:
lines += ['[ EVALUATION ]']
for k, v in ev.items():
lines.append(f" {k}: {v}")
lines.append('')
lines += [sep, 'Generated by BIRAS — AI Research Operating System', sep]
return '\n'.join(lines).encode('utf-8')
# ─────────────────────────────────────────────────────────────────────────────
# DOCX EXPORT
# ─────────────────────────────────────────────────────────────────────────────
def _build_docx(ctx: dict) -> bytes:
try:
from docx import Document
from docx.shared import Pt, RGBColor, Inches
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml.ns import qn
except ImportError as e:
logger.warning("python-docx not available: %s — falling back to TXT", e)
raise
doc = Document()
# ── Page margins ──────────────────────────────────────────────────────────
for section in doc.sections:
section.top_margin = Inches(1)
section.bottom_margin = Inches(1)
section.left_margin = Inches(1.25)
section.right_margin = Inches(1.25)
def h(text: str, level: int = 1):
doc.add_heading(text, level=level)
def body(text: str, italic: bool = False):
if not text:
return
p = doc.add_paragraph(text)
run = p.runs[0] if p.runs else p.add_run(text)
run.font.size = Pt(11)
if italic:
run.italic = True
def muted(text: str):
p = doc.add_paragraph()
run = p.add_run(text)
run.font.size = Pt(9)
run.font.color.rgb = RGBColor(0x88, 0x88, 0x88)
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
# ── Cover ─────────────────────────────────────────────────────────────────
title = doc.add_heading(
f"AI-Assisted Systematic Literature Review\non: {ctx['query'].title()}", level=0
)
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
muted(f"Generated by BIRAS | {ctx['timestamp']} | {ctx['n']} articles")
doc.add_paragraph()
# ── Sections ──────────────────────────────────────────────────────────────
_SECTIONS = [
('Abstract', 'abstract'),
('1. Global Research Summary', 'global_summary'),
('2. Key Insights', 'insights_text'),
('3. Research Questions', 'rqs_text'),
('4. Related Work', 'related_work'),
('5. Conclusion', 'conclusion'),
('6. Future Work', 'future_work'),
]
for heading_txt, key in _SECTIONS:
content = ctx.get(key, '').strip()
if not content:
continue
h(heading_txt)
if key == 'rqs_text':
for line in content.split('\n'):
if line.strip():
doc.add_paragraph(line.strip(), style='List Number')
else:
body(content)
doc.add_paragraph()
if ctx.get('limitations'):
h('7. Limitations')
body(ctx['limitations'])
doc.add_paragraph()
ev = ctx.get('evaluation', {})
if ev:
h('8. Evaluation Metrics')
for k, v in ev.items():
doc.add_paragraph(f"{k.replace('_', ' ').title()}: {v}", style='List Bullet')
doc.add_paragraph()
m = ctx.get('metrics', {})
if m:
h('Appendix — Performance Metrics')
for k, v in m.items():
doc.add_paragraph(f"{k}: {v}", style='List Bullet')
# Footer note
body(
f"\nThis report was auto-generated by BIRAS (Bibliometric Intelligent Research "
f"Assistant System). AI source: {ctx['llm_source']}.", italic=True
)
buf = io.BytesIO()
doc.save(buf)
buf.seek(0)
return buf.read()
# ─────────────────────────────────────────────────────────────────────────────
# PDF EXPORT (via reportlab — graceful fallback)
# ─────────────────────────────────────────────────────────────────────────────
def _build_pdf(ctx: dict) -> bytes:
try:
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import cm
from reportlab.lib import colors
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, HRFlowable
)
buf = io.BytesIO()
doc = SimpleDocTemplate(
buf, pagesize=A4,
leftMargin=3*cm, rightMargin=3*cm,
topMargin=2.5*cm, bottomMargin=2.5*cm,
)
styles = getSampleStyleSheet()
story = []
title_style = ParagraphStyle(
'BIRASTitle', parent=styles['Title'],
fontSize=16, leading=22, spaceAfter=6,
)
h1_style = ParagraphStyle(
'BIRASH1', parent=styles['Heading1'],
fontSize=13, textColor=colors.HexColor('#1e3a5f'), spaceAfter=4,
)
body_style = ParagraphStyle(
'BIRASBody', parent=styles['Normal'],
fontSize=10.5, leading=15, spaceAfter=8,
)
meta_style = ParagraphStyle(
'BIRASmeta', parent=styles['Normal'],
fontSize=9, textColor=colors.grey, alignment=1,
)
story.append(Paragraph(
f"AI-Assisted Systematic Literature Review
{ctx['query'].title()}",
title_style
))
story.append(Paragraph(
f"Generated by BIRAS | {ctx['timestamp']} | {ctx['n']} articles | {ctx['llm_source']}",
meta_style
))
story.append(HRFlowable(width='100%', thickness=1, color=colors.lightgrey))
story.append(Spacer(1, 0.4*cm))
_SECTIONS = [
('Abstract', 'abstract'),
('Global Research Summary','global_summary'),
('Key Insights', 'insights_text'),
('Research Questions', 'rqs_text'),
('Related Work', 'related_work'),
('Conclusion', 'conclusion'),
('Future Work', 'future_work'),
]
for heading_txt, key in _SECTIONS:
content = ctx.get(key, '').strip()
if not content:
continue
story.append(Paragraph(heading_txt, h1_style))
# Escape & for reportlab
safe = content.replace('&', '&').replace('<', '<').replace('>', '>')
for para in safe.split('\n'):
if para.strip():
story.append(Paragraph(para, body_style))
story.append(Spacer(1, 0.3*cm))
doc.build(story)
buf.seek(0)
return buf.read()
except ImportError:
logger.warning("reportlab not installed — falling back to DOCX bytes")
try:
return _build_docx(ctx)
except Exception:
return _build_txt(ctx)
# ─────────────────────────────────────────────────────────────────────────────
# PUBLIC API
# ─────────────────────────────────────────────────────────────────────────────
def export_report(analysis_results: dict, fmt: str = 'docx') -> tuple[bytes, str, str]:
"""
Export analysis_results to a report file.
Args:
analysis_results: The full pipeline output dict.
fmt: 'docx' | 'pdf' | 'txt'
Returns:
(file_bytes, mimetype, filename)
"""
fmt = fmt.lower().strip()
ctx = _flatten(analysis_results)
query_slug = ctx['query'][:30].replace(' ', '_').replace('/', '-')
date_slug = datetime.datetime.now().strftime('%Y%m%d')
try:
if fmt == 'pdf':
data = _build_pdf(ctx)
mime = 'application/pdf'
filename = f"BIRAS_Report_{query_slug}_{date_slug}.pdf"
elif fmt == 'docx':
data = _build_docx(ctx)
mime = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
filename = f"BIRAS_Report_{query_slug}_{date_slug}.docx"
else: # txt
data = _build_txt(ctx)
mime = 'text/plain; charset=utf-8'
filename = f"BIRAS_Report_{query_slug}_{date_slug}.txt"
logger.info("Report exported: fmt=%s size=%d bytes", fmt, len(data))
return data, mime, filename
except Exception as exc:
logger.error("Export failed (%s): %s — falling back to TXT", fmt, exc)
data = _build_txt(ctx)
filename = f"BIRAS_Report_{query_slug}_{date_slug}.txt"
return data, 'text/plain; charset=utf-8', filename