| 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", "<br/>") |
|
|
|
|
| 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}" |
| ) |
|
|