在实现了前端的对话数据持久化(依靠后端存储,前端主要与后端进行对接)之后,我这一次实现了计划书中预想的租房推荐markdown的输出部分,直接根据房源list进行输出markdown,保证无乱码。

因此,本阶段我投入精力重构了报告生成模块,核心思路是“模板约束 + 结构化渲染”

1. 架构抉择

在开发初期,我尝试让大模型直接输出完整的 Markdown 报告。但在测试中发现,模型经常会输出未闭合的代码块、错乱的表格管道符(|),导致 Streamlit 渲染崩溃。

为了解决这个问题,我确立了新的架构原则:

  • 模型只负责“填空”:让模型仅输出纯文本的分析结论。
  • 代码负责“骨架”:使用 Jinja2 模板引擎拼接标题、表格和固定章节。

这样既保证了报告的格式永远工整,又确保了任务书要求的“房源摘要、评分理由、核心参数”三大要素齐全。

2. 核心实现:report_builder.py 的模板化设计

这是本阶段最核心的代码文件。我引入了 Jinja2 模板引擎,将报告拆解为固定的结构。

强约束结构:无论模型输出什么,报告必定包含“摘要”、“推荐列表”、“评分依据”等固定章节。

数据与展示分离:Python 负责将房源列表(Listings)和评分(Scores)组装成字典,传给模板渲染,彻底杜绝了 Markdown 表格错位的问题。

双轨展示策略:针对 Streamlit 对复杂 Markdown 支持有限的问题,我在代码中预留了逻辑——核心参数表既生成 Markdown 文本,也支持在渲染层转换为 st.dataframe 进行二次展示。def _sanitize_plain(text: str, *, max_len: int = 4000) -> str: """避免未闭合代码块破坏整页:弱化 ```;控制长度。""" s = str(text).replace("\r\n", "\n").strip() s = s.replace("```", "'''") if len(s) > max_len: s = s[: max_len - 3] + "..." return s

def _sanitize_plain(text: str, *, max_len: int = 4000) -> str:
    """避免未闭合代码块破坏整页:弱化 ```;控制长度。"""
    s = str(text).replace("\r\n", "\n").strip()
    s = s.replace("```", "'''")
    if len(s) > max_len:
        s = s[: max_len - 3] + "..."
    return s
def build_report_md(
    listings: list[dict[str, Any]],
    score_rows: list[dict[str, Any]],
    user_query_summary: str,
    *,
    risk_notes: str | None = None,
) -> str:
    """
    输出完整 Markdown:房源摘要、评分理由、核心参数(表格在渲染层辅之以 dataframe)。
    """
    items = [normalize_listing(x) for x in listings if isinstance(x, dict)]
    score_by_id = _index_scores(score_rows)

    rows: list[dict[str, Any]] = []
    for li in items:
        lid = str(li.get("listing_id", ""))
        sr = score_by_id.get(lid)
        title = (li.get("title") or "").strip() or "(标题待补充)"
        rows.append(
            {
                "listing_id": lid,
                "title": _sanitize_plain(title, max_len=200),
                "title_short": _sanitize_plain(title, max_len=60),
                "price_text": _fmt_price(li),
                "area_text": _fmt_area(li),
                "rooms": _sanitize_plain(str(li.get("rooms") or "户型待补充"), max_len=80),
                "district": _sanitize_plain(str(li.get("district") or "区域待补充"), max_len=80),
                "community": _sanitize_plain(str(li.get("community") or "小区待补充"), max_len=80),
                "score_text": _fmt_score(li, sr),
                "rationale": _rationale_for(li, sr),
            }
        )

    env = Environment(
        loader=FileSystemLoader(str(_TEMPLATE_DIR)),
        autoescape=False,
        trim_blocks=True,
        lstrip_blocks=True,
    )
    tpl = env.get_template(_TEMPLATE_NAME)

    uq = _sanitize_plain(user_query_summary or "(本轮未捕获用户需求摘要)", max_len=2000)
    risk_section = (
        _sanitize_plain(risk_notes, max_len=2000)
        if risk_notes and str(risk_notes).strip()
        else "(默认提示)实地看房、核验产权与户口、贷款与税费方案请咨询专业机构;本报告由系统自动生成,不构成投资建议。"
    )

    table_lines = [
        "",
        "### 核心参数表",
        "",
        "| 序号 | 标题 | 总价 | 面积 | 户型 | 区域 | 小区 | 综合分 |",
        "| --- | --- | --- | --- | --- | --- | --- | --- |",
    ]
    for i, li in enumerate(items, start=1):
        sr = score_by_id.get(str(li.get("listing_id", "")))
        table_lines.append(
            "| "
            + " | ".join(
                [
                    str(i),
                    _sanitize_cell(li.get("title") or "—"),
                    _sanitize_cell(_fmt_price(li)),
                    _sanitize_cell(_fmt_area(li)),
                    _sanitize_cell(li.get("rooms")),
                    _sanitize_cell(li.get("district")),
                    _sanitize_cell(li.get("community")),
                    _sanitize_cell(_fmt_score(li, sr)),
                ]
            )
            + " |"
        )
    core_table_md = "\n".join(table_lines) + "\n"

    md = tpl.render(
        user_query_summary=uq,
        rows=rows,
        core_table_md=core_table_md,
        risk_section=risk_section,
    )
    return md

3. 安全渲染与双轨展示策略

有了构建器还不够,还需要一个安全的渲染器 report_render.py。在 Streamlit 中直接渲染外部生成的 Markdown 存在风险,因此我增加了自动化检查机制。

实现逻辑:

章节完整性校验:在渲染前,通过 validate_report_sections 检查生成的 Markdown 是否包含“房源摘要”、“评分依据”等关键词。如果缺失,前端会弹出警告而不是渲染半截页面。

双轨展示(Double-Track):这是本阶段的一个体验优化。考虑到 Streamlit 对复杂 Markdown 表格的支持有限,我渲染了 Markdown 文本,还额外调用 st.dataframe 展示了一份交互式表格。用户既能看报告,又能对表格进行排序筛选。

UTF-8 导出:下载按钮显式指定了 charset=utf-8,解决了中文乱码问题。

def render_report_tab(
    markdown_text: str,
    *,
    listings: list[dict[str, Any]] | None = None,
    show_dataframe: bool = True,
) -> None:
    """
    安全渲染 Markdown(默认关闭 HTML);结构不完整时警告。
    核心参数表同时用 dataframe 展示,缓解 Streamlit 下复杂表格限制。
    """
    ok, missing = validate_report_sections(markdown_text)
    if not ok:
        st.warning("报告结构不完整,缺失:" + "、".join(missing))

    try:
        st.markdown(markdown_text, unsafe_allow_html=False)
    except Exception as e:  # noqa: BLE001 — 渲染兜底
        st.error(f"Markdown 渲染失败:{e}")
        st.text(markdown_text[:8000])

    if show_dataframe and listings:
        items = [x for x in listings if isinstance(x, dict)]
        if items:
            st.subheader("核心参数(表格补充)")
            try:
                st.dataframe(
                    _listings_to_rows(items),
                    use_container_width=True,
                    hide_index=True,
                )
            except Exception as e:  # noqa: BLE001
                st.caption(f"表格展示失败:{e}")

    col_copy, col_dl = st.columns(2)
    with col_dl:
        st.download_button(
            label="下载报告 (.md)",
            data=markdown_text.encode("utf-8"),
            file_name="选房分析报告.md",
            mime="text/markdown; charset=utf-8",
            use_container_width=True,
        )
    with col_copy:
        with st.expander("复制全文(选中复制)", expanded=False):
            st.caption("UTF-8 文本;亦可使用上方下载保存为文件。")
            st.text_area(
                "report_md_copy",
                value=markdown_text,
                height=280,
                label_visibility="collapsed",
                key="report_md_copy_area",
            )

4. 总结与展望

通过引入 Jinja2 模板和自动化校验,我们彻底解决了“大模型输出格式不可控”的顽疾。

稳定性提升:无论模型如何发挥,报告的结构永远符合任务书的要求。

体验优化:用户不仅能看到结构清晰的报告,还能通过双轨表格进行交互,并一键下载无乱码的文件。

至此,我们的智能体不仅能“记住”历史,还能“输出”专业的书面报告。

更多推荐