""" 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