from __future__ import annotations import html from datetime import datetime from pathlib import Path from reportlab.lib import colors from reportlab.lib.enums import TA_LEFT from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet from reportlab.lib.units import cm from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.cidfonts import UnicodeCIDFont from reportlab.platypus import ( PageBreak, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle, ) from .models import AgentResult, GeoPoint, RouteSolution, RouteTask, ToolEvent from .solver import format_km, format_minutes REPORT_DIR = Path("outputs") def generate_pdf_report( task: RouteTask, points: list[GeoPoint], solution: RouteSolution, trace: list[ToolEvent], summary_markdown: str, ) -> str: REPORT_DIR.mkdir(parents=True, exist_ok=True) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") path = REPORT_DIR / f"routeopt_report_{timestamp}.pdf" pdfmetrics.registerFont(UnicodeCIDFont("STSong-Light")) doc = SimpleDocTemplate( str(path), pagesize=A4, rightMargin=1.8 * cm, leftMargin=1.8 * cm, topMargin=1.6 * cm, bottomMargin=1.6 * cm, title="RouteOpt Agent 求解报告", ) styles = make_styles() story: list = [] story.append(Paragraph("RouteOpt Agent 求解报告", styles["TitleCJK"])) story.append(Paragraph(f"生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", styles["BodyCJK"])) story.append(Spacer(1, 0.3 * cm)) story.append(Paragraph("1. 问题定义", styles["HeadingCJK"])) story.extend(task_paragraphs(task, styles)) story.append(Spacer(1, 0.2 * cm)) story.append(Paragraph("2. 地点解析结果", styles["HeadingCJK"])) story.append(make_points_table(points)) story.append(Spacer(1, 0.2 * cm)) story.append(Paragraph("3. 优化结果", styles["HeadingCJK"])) story.extend(solution_paragraphs(solution, styles)) story.append(make_leg_table(solution)) story.append(Spacer(1, 0.2 * cm)) story.append(Paragraph("4. Agent 工具调用轨迹", styles["HeadingCJK"])) story.append(make_trace_table(trace)) story.append(PageBreak()) story.append(Paragraph("5. 结果解释", styles["HeadingCJK"])) for paragraph in markdown_to_plain_paragraphs(summary_markdown): story.append(Paragraph(escape_for_pdf(paragraph), styles["BodyCJK"])) story.append(Spacer(1, 0.12 * cm)) story.append(Paragraph("6. 局限性说明", styles["HeadingCJK"])) limitations = [ "公开 API 可能存在限速、网络延迟或临时不可用;系统内置了上海示例地点和直线距离兜底策略。", "OSRM 公共服务适合课程演示和小规模样例,不建议用于商业高并发生产环境。", "本系统把大模型用于自然语言理解、工具调用规划和解释生成,把路线最优化交给确定性算法完成。", ] for item in limitations: story.append(Paragraph("• " + escape_for_pdf(item), styles["BodyCJK"])) doc.build(story) return str(path) def make_styles() -> dict[str, ParagraphStyle]: base = getSampleStyleSheet() return { "TitleCJK": ParagraphStyle( "TitleCJK", parent=base["Title"], fontName="STSong-Light", fontSize=20, leading=26, alignment=TA_LEFT, ), "HeadingCJK": ParagraphStyle( "HeadingCJK", parent=base["Heading2"], fontName="STSong-Light", fontSize=14, leading=20, spaceBefore=8, spaceAfter=6, ), "BodyCJK": ParagraphStyle( "BodyCJK", parent=base["BodyText"], fontName="STSong-Light", fontSize=10.5, leading=16, wordWrap="CJK", ), } def task_paragraphs(task: RouteTask, styles: dict[str, ParagraphStyle]) -> list[Paragraph]: destination_text = "、".join(task.destination_places) objective_text = "最短时间" if task.objective == "time" else "最短距离" return_text = "需要回到起点" if task.return_to_start else "不需要回到起点" fixed_end = task.fixed_end_place or "无" raw_request = task.raw_request or "用户通过结构化表单输入任务。" return [ Paragraph(f"原始需求:{escape_for_pdf(raw_request)}", styles["BodyCJK"]), Paragraph(f"起点:{escape_for_pdf(task.start_place)}", styles["BodyCJK"]), Paragraph(f"目的地:{escape_for_pdf(destination_text)}", styles["BodyCJK"]), Paragraph(f"优化目标:{objective_text};回程设置:{return_text};固定终点:{escape_for_pdf(fixed_end)}", styles["BodyCJK"]), ] def solution_paragraphs(solution: RouteSolution, styles: dict[str, ParagraphStyle]) -> list[Paragraph]: return [ Paragraph(f"访问顺序:{escape_for_pdf(' → '.join(solution.route_names))}", styles["BodyCJK"]), Paragraph( f"总距离:{format_km(solution.total_distance_meters)};预计驾驶时间:{format_minutes(solution.total_duration_seconds)}", styles["BodyCJK"], ), Paragraph(f"求解算法:{solution.algorithm}", styles["BodyCJK"]), ] def make_points_table(points: list[GeoPoint]) -> Table: data = [["序号", "地点", "纬度", "经度", "数据源"]] for idx, point in enumerate(points): data.append([idx, point.name, f"{point.lat:.6f}", f"{point.lon:.6f}", point.source]) table = Table(data, colWidths=[1.0 * cm, 4.3 * cm, 3.0 * cm, 3.0 * cm, 4.1 * cm]) apply_table_style(table) return table def make_leg_table(solution: RouteSolution) -> Table: data = [["段", "从", "到", "距离", "时间"]] + solution.leg_rows table = Table(data, colWidths=[1.0 * cm, 4.3 * cm, 4.3 * cm, 2.5 * cm, 2.5 * cm]) apply_table_style(table) return table def make_trace_table(trace: list[ToolEvent]) -> Table: data = [["步骤", "工具", "状态", "结果摘要"]] for event in trace: data.append([event.step, event.tool, event.status, event.result[:120]]) table = Table(data, colWidths=[1.0 * cm, 4.0 * cm, 2.0 * cm, 8.6 * cm], repeatRows=1) apply_table_style(table) return table def apply_table_style(table: Table) -> None: table.setStyle( TableStyle( [ ("FONTNAME", (0, 0), (-1, -1), "STSong-Light"), ("FONTSIZE", (0, 0), (-1, -1), 8.5), ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#eef2f7")), ("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#111827")), ("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#d1d5db")), ("VALIGN", (0, 0), (-1, -1), "TOP"), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f8fafc")]), ] ) ) def markdown_to_plain_paragraphs(markdown: str) -> list[str]: lines = [] for line in markdown.splitlines(): clean = line.strip() if not clean: continue clean = clean.replace("**", "") clean = clean.lstrip("#").strip() clean = clean.lstrip("-").strip() lines.append(clean) return lines or ["Agent 已完成路线优化并生成结构化结果。"] def escape_for_pdf(text: str) -> str: return html.escape(str(text)).replace("\n", "
") def result_to_markdown(result: AgentResult) -> str: solution = result.solution return ( f"**访问顺序**:{' → '.join(solution.route_names)}\n\n" f"**总距离**:{format_km(solution.total_distance_meters)} \n" f"**预计时间**:{format_minutes(solution.total_duration_seconds)}\n\n" f"**算法**:{solution.algorithm}\n\n" f"{result.summary_markdown}" )