| """ |
| 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') |
|
|
|
|
| |
| |
| |
|
|
| 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) |
| 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')), |
|
|
| |
| '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', '')), |
|
|
| |
| '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', '')), |
|
|
| |
| 'rqs_text': rqs_text, |
| 'related_work': research.get('related_work', ar.get('related_work', '')), |
|
|
| |
| 'evaluation': ar.get('evaluation', {}), |
| 'metrics': ar.get('metrics', {}), |
| } |
|
|
|
|
| |
| |
| |
|
|
| 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') |
|
|
|
|
| |
| |
| |
|
|
| 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() |
|
|
| |
| 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 |
|
|
| |
| 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 = [ |
| ('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') |
|
|
| |
| 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() |
|
|
|
|
| |
| |
| |
|
|
| 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<br/><i>{ctx['query'].title()}</i>", |
| 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)) |
| |
| 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) |
|
|
|
|
| |
| |
| |
|
|
| 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: |
| 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 |
|
|