fenghantong
Deploy RouteOpt Agent
ffda755
Raw
History Blame Contribute Delete
7.98 kB
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}"
)