from __future__ import annotations from pathlib import Path from docx import Document from docx.enum.section import WD_SECTION_START from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT, WD_TABLE_ALIGNMENT from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.oxml import OxmlElement from docx.oxml.ns import qn from docx.shared import Inches, Pt, RGBColor ROOT = Path(__file__).resolve().parents[1] OUT = ROOT / "docs" / "市民请集合AI第一期方案汇总.docx" BLUE = "1E5B8C" DARK_BLUE = "123B5D" INK = "0B2545" MUTED = "667085" LIGHT_FILL = "EAF1F7" CALLOUT_FILL = "EEF5FA" CAUTION_FILL = "FFF8E8" BORDER = "D0D5DD" WHITE = "FFFFFF" def qn_attr(name: str) -> str: return qn(name) def set_cell_shading(cell, fill: str) -> None: tc_pr = cell._tc.get_or_add_tcPr() shd = tc_pr.find(qn_attr("w:shd")) if shd is None: shd = OxmlElement("w:shd") tc_pr.append(shd) shd.set(qn_attr("w:fill"), fill) def set_cell_margins(cell, top=100, bottom=100, start=140, end=140) -> None: tc_pr = cell._tc.get_or_add_tcPr() tc_mar = tc_pr.first_child_found_in("w:tcMar") if tc_mar is None: tc_mar = OxmlElement("w:tcMar") tc_pr.append(tc_mar) for edge, value in (("top", top), ("bottom", bottom), ("start", start), ("end", end)): node = tc_mar.find(qn_attr(f"w:{edge}")) if node is None: node = OxmlElement(f"w:{edge}") tc_mar.append(node) node.set(qn_attr("w:w"), str(value)) node.set(qn_attr("w:type"), "dxa") def set_table_borders(table, color=BORDER) -> None: tbl_pr = table._tbl.tblPr borders = tbl_pr.first_child_found_in("w:tblBorders") if borders is None: borders = OxmlElement("w:tblBorders") tbl_pr.append(borders) for edge in ("top", "left", "bottom", "right", "insideH", "insideV"): node = borders.find(qn_attr(f"w:{edge}")) if node is None: node = OxmlElement(f"w:{edge}") borders.append(node) node.set(qn_attr("w:val"), "single") node.set(qn_attr("w:sz"), "6") node.set(qn_attr("w:space"), "0") node.set(qn_attr("w:color"), color) def set_table_width(table, widths_dxa: list[int]) -> None: tbl_pr = table._tbl.tblPr tbl_w = tbl_pr.find(qn_attr("w:tblW")) if tbl_w is None: tbl_w = OxmlElement("w:tblW") tbl_pr.append(tbl_w) tbl_w.set(qn_attr("w:w"), str(sum(widths_dxa))) tbl_w.set(qn_attr("w:type"), "dxa") tbl_ind = tbl_pr.find(qn_attr("w:tblInd")) if tbl_ind is None: tbl_ind = OxmlElement("w:tblInd") tbl_pr.append(tbl_ind) tbl_ind.set(qn_attr("w:w"), "120") tbl_ind.set(qn_attr("w:type"), "dxa") grid = table._tbl.tblGrid if grid is None: grid = OxmlElement("w:tblGrid") table._tbl.insert(0, grid) for child in list(grid): grid.remove(child) for width in widths_dxa: col = OxmlElement("w:gridCol") col.set(qn_attr("w:w"), str(width)) grid.append(col) for row in table.rows: for cell, width in zip(row.cells, widths_dxa): tc_pr = cell._tc.get_or_add_tcPr() tc_w = tc_pr.find(qn_attr("w:tcW")) if tc_w is None: tc_w = OxmlElement("w:tcW") tc_pr.append(tc_w) tc_w.set(qn_attr("w:w"), str(width)) tc_w.set(qn_attr("w:type"), "dxa") cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER set_cell_margins(cell) def set_run_font(run, font_name="Calibri", east_asia="Microsoft YaHei") -> None: run.font.name = font_name run._element.rPr.rFonts.set(qn_attr("w:ascii"), font_name) run._element.rPr.rFonts.set(qn_attr("w:hAnsi"), font_name) run._element.rPr.rFonts.set(qn_attr("w:eastAsia"), east_asia) def set_paragraph_border_bottom(paragraph, color=BORDER, size="8") -> None: p_pr = paragraph._p.get_or_add_pPr() p_bdr = p_pr.find(qn_attr("w:pBdr")) if p_bdr is None: p_bdr = OxmlElement("w:pBdr") p_pr.append(p_bdr) bottom = p_bdr.find(qn_attr("w:bottom")) if bottom is None: bottom = OxmlElement("w:bottom") p_bdr.append(bottom) bottom.set(qn_attr("w:val"), "single") bottom.set(qn_attr("w:sz"), size) bottom.set(qn_attr("w:space"), "6") bottom.set(qn_attr("w:color"), color) def add_para( doc, text: str, style: str | None = None, bold_prefix: str | None = None, color: str | None = None, italic: bool = False, ): p = doc.add_paragraph(style=style) if bold_prefix and text.startswith(bold_prefix): r = p.add_run(bold_prefix) r.bold = True if color: r.font.color.rgb = RGBColor.from_string(color) set_run_font(r) tail = p.add_run(text[len(bold_prefix):]) tail.italic = italic if color: tail.font.color.rgb = RGBColor.from_string(color) set_run_font(tail) else: r = p.add_run(text) r.italic = italic if color: r.font.color.rgb = RGBColor.from_string(color) set_run_font(r) return p def add_bullet(doc, text: str): p = doc.add_paragraph(style="List Bullet") r = p.add_run(text) set_run_font(r) return p def add_number(doc, text: str): p = doc.add_paragraph(style="List Number") r = p.add_run(text) set_run_font(r) return p def add_heading(doc, text: str, level: int): p = doc.add_heading(text, level=level) for run in p.runs: set_run_font(run) return p def add_callout(doc, title: str, body: str, fill: str = CALLOUT_FILL) -> None: table = doc.add_table(rows=1, cols=1) table.alignment = WD_TABLE_ALIGNMENT.LEFT table.style = "Table Grid" set_table_width(table, [9360]) set_table_borders(table) cell = table.cell(0, 0) set_cell_shading(cell, fill) p = cell.paragraphs[0] p.paragraph_format.space_after = Pt(4) r = p.add_run(title) r.bold = True r.font.color.rgb = RGBColor.from_string(DARK_BLUE) set_run_font(r) p2 = cell.add_paragraph() p2.paragraph_format.space_after = Pt(0) r2 = p2.add_run(body) set_run_font(r2) doc.add_paragraph() def add_table(doc, headers: list[str], rows: list[list[str]], widths_dxa: list[int]): table = doc.add_table(rows=1, cols=len(headers)) table.alignment = WD_TABLE_ALIGNMENT.LEFT table.style = "Table Grid" set_table_width(table, widths_dxa) set_table_borders(table) for i, text in enumerate(headers): cell = table.rows[0].cells[i] set_cell_shading(cell, DARK_BLUE) p = cell.paragraphs[0] p.alignment = WD_ALIGN_PARAGRAPH.CENTER p.paragraph_format.space_after = Pt(0) r = p.add_run(text) r.bold = True r.font.color.rgb = RGBColor.from_string(WHITE) set_run_font(r) for row in rows: cells = table.add_row().cells for i, text in enumerate(row): p = cells[i].paragraphs[0] p.alignment = WD_ALIGN_PARAGRAPH.LEFT p.paragraph_format.space_after = Pt(0) r = p.add_run(text) set_run_font(r) set_table_width(table, widths_dxa) doc.add_paragraph() return table def add_metadata_rows(doc, rows: list[tuple[str, str]]) -> None: for label, value in rows: p = doc.add_paragraph() p.paragraph_format.space_before = Pt(0) p.paragraph_format.space_after = Pt(2) p.paragraph_format.line_spacing = 1.10 r1 = p.add_run(label) r1.bold = True set_run_font(r1) r2 = p.add_run(value) set_run_font(r2) def configure_styles(doc: Document) -> None: section = doc.sections[0] section.page_width = Inches(8.5) section.page_height = Inches(11) for margin in ("top_margin", "bottom_margin", "left_margin", "right_margin"): setattr(section, margin, Inches(1)) section.header_distance = Inches(0.492) section.footer_distance = Inches(0.492) styles = doc.styles normal = styles["Normal"] normal.font.name = "Calibri" normal._element.rPr.rFonts.set(qn_attr("w:eastAsia"), "Microsoft YaHei") normal.font.size = Pt(11) normal.paragraph_format.space_before = Pt(0) normal.paragraph_format.space_after = Pt(6) normal.paragraph_format.line_spacing = 1.10 title = styles["Title"] title.font.name = "Calibri" title._element.rPr.rFonts.set(qn_attr("w:eastAsia"), "Microsoft YaHei") title.font.size = Pt(24) title.font.bold = True title.font.color.rgb = RGBColor.from_string(INK) title.paragraph_format.space_before = Pt(0) title.paragraph_format.space_after = Pt(8) subtitle = styles["Subtitle"] subtitle.font.name = "Calibri" subtitle._element.rPr.rFonts.set(qn_attr("w:eastAsia"), "Microsoft YaHei") subtitle.font.size = Pt(11) subtitle.font.color.rgb = RGBColor.from_string(MUTED) subtitle.paragraph_format.space_after = Pt(14) for name, size, color, before, after in [ ("Heading 1", 16, BLUE, 16, 8), ("Heading 2", 13, BLUE, 12, 6), ("Heading 3", 12, DARK_BLUE, 8, 4), ]: style = styles[name] style.font.name = "Calibri" style._element.rPr.rFonts.set(qn_attr("w:eastAsia"), "Microsoft YaHei") style.font.size = Pt(size) style.font.bold = True style.font.color.rgb = RGBColor.from_string(color) style.paragraph_format.space_before = Pt(before) style.paragraph_format.space_after = Pt(after) for name in ("List Bullet", "List Number"): style = styles[name] style.font.name = "Calibri" style._element.rPr.rFonts.set(qn_attr("w:eastAsia"), "Microsoft YaHei") style.font.size = Pt(11) style.paragraph_format.space_after = Pt(8) style.paragraph_format.line_spacing = 1.167 footer = section.footer.paragraphs[0] footer.alignment = WD_ALIGN_PARAGRAPH.RIGHT r = footer.add_run("市民请集合 AI 第一期方案汇总") r.font.size = Pt(9) r.font.color.rgb = RGBColor.from_string(MUTED) set_run_font(r) def build_doc() -> None: doc = Document() configure_styles(doc) doc.add_paragraph() p = doc.add_paragraph(style="Title") r = p.add_run("市民请集合 AI 第一期方案汇总") set_run_font(r) p = doc.add_paragraph(style="Subtitle") r = p.add_run("基于当前 smqjh-agent 系统、第一期业务需求及 4 个 ChatGPT Pro 账号预算整理") set_run_font(r) add_metadata_rows( doc, [ ("版本日期:", "2026-06-30"), ("一期预算口径:", "4 个 ChatGPT Pro 账号,USD 200/账号/月,合计 USD 800/月;采购与发票口径同步确认"), ("资料依据:", "《市民请集合AI第一期需求.docx》、当前 smqjh-agent 代码与设计复盘文档、OpenAI 官方订阅说明(2026-06-30 查询)"), ], ) rule = doc.add_paragraph() set_paragraph_border_bottom(rule) add_callout( doc, "结论先行", "第一期建议定位为“业务运营 AI 助手试点”:先用当前已具备的 Agent/MCP/只读数据库能力,把查询、报价、对账、充值文件整理等高频人工工作做成可追踪、可导出的辅助流程;ChatGPT 账号用于人员日常方案、表格、文档和复杂问题处理,系统侧继续使用受控 MCP 工具承接内部数据能力。整体不建议一开始承诺全自动写入业务系统,所有涉及金额、上下架、充值、开票和结算确认的动作都保留人工复核。" ) add_heading(doc, "1. 解决什么问题", 1) add_para( doc, "第一期主要解决运营、商务和财务在报价、商品、充值、对账、查询中的重复性整理和跨系统核对问题。当前痛点不是单点功能缺失,而是信息分散在报价单、供应商资料、后台系统、订单/积分/商品表和人工沟通中,导致每次都要重新查、重新改表、重新核口径。", ) add_table( doc, ["问题类型", "当前表现", "AI 一期要解决的点"], [ ["报价单", "新企业签约时需要基于星闪豹商品、京东旗舰店原价和企业反馈反复调整报价。", "生成固定格式报价单,辅助补齐商品、规格、价格依据,支持对方新增商品后的快速报价和汇总下载。"], ["商品上下架", "最终报价单确定后,需要在海博和市民请集合系统做批量或零散上下架。", "解析报价单,生成待上架/下架清单、字段校验和执行建议;高风险写入动作人工确认后再执行。"], ["商品名称对应", "平台商品名、供应商开票名称和采购单名称不一致,建采购单耗时。", "建立名称/规格/编码匹配表,上传供应商开票图片或清单后给出候选匹配,人工确认后沉淀映射。"], ["积分充值", "企业给的积分导入名单格式不统一,导入后还要输出成功证明,错误反馈需要排查。", "自动清洗为系统导入格式,按规则检查风险,生成导入文件和成功反馈文件。"], ["对账结算", "积分过期后需要多方核对、生成对账单、开票明细、盖章和付款跟踪。", "打包对账单和核对结果,计算开票金额/税额关系,标记异常差异,形成可给企业确认的文件包。"], ["数据查询", "运营需要按企业、时间、状态查询积分充值/消耗/可用、正常订单和售后订单。", "管理员用自然语言查询,系统自动查只读数据库并输出表格、摘要和依据。"], ], [1500, 3900, 3960], ) add_para( doc, "从系统建设角度看,第一期还要解决两个底层问题:一是把业务查询从“人找字段、人写 SQL、人导表”改成“Agent 调受控工具”;二是把 AI 的输出从长文本变成表格、文件、待确认项和可审计日志。", ) add_heading(doc, "2. 能够解决到什么程度", 1) add_para( doc, "当前 smqjh-agent 已有可用底座:Python + LangGraph Web Agent、Java MCP Server、DeepSeek 工具规划、业务 schema、只读数据库查询、商品/订单/月结工具、表格渲染和 CSV 下载。基于这个基础,一期能做到“辅助闭环 + 人工确认”,不宜直接承诺无人值守的全自动业务变更。", ) add_table( doc, ["场景", "一期可交付程度", "预计自动化/减负", "需要人工确认的边界"], [ ["数据查询", "自然语言查询商品、订单、会员、积分、月结相关数据,并输出表格与依据。", "高,常规查询约 80%-90% 可自动完成。", "字段口径不明确、数据异常、权限不足时确认。"], ["报价单", "生成统一报价单模板,辅助商品匹配、价格引用、对方新增品报价和汇总下载。", "中高,格式整理和候选匹配约 60%-70% 可自动化。", "最终报价、利润率、外部平台最新价必须人工确认。"], ["对账结算", "按企业和月份生成导出计划、核对规则、差异清单,进一步可生成对账文件包。", "中高,模板确认后约 70%-80% 工作量可减少。", "差异、税率、开票金额、盖章和付款状态确认。"], ["积分充值", "清洗企业名单,校验字段、金额、手机号和重复项,生成导入格式和反馈文件。", "中,约 50%-70% 的整理排错可自动化。", "真实导入、批量发放、金额异常必须确认。"], ["商品上下架", "从报价单生成上下架清单和字段校验,后续对接接口后进入确认执行。", "中,前处理约 50%-60% 可自动化。", "自动写入海博/市民请集合系统前必须审批。"], ["名称/发票匹配", "建立供应商名称、平台商品、SKU、编码映射候选,支持图片/OCR 后续接入。", "中,样本积累前约 50%-60%;字典完善后可提升。", "同规格多候选、开票名称不规范、编码不唯一。"], ], [1300, 3450, 1850, 2760], ) add_callout( doc, "一期能力边界", "AI 可以先把“查、算、整、比、导、提示风险”做起来;凡是会改变业务系统状态的动作,例如上架、下架、充值导入、发券、结算确认、开票提交,都必须保留人工确认和日志记录。", fill=CAUTION_FILL, ) add_heading(doc, "3. 大致时间周期", 1) add_para( doc, "建议一期按 4-6 周推进:4 周形成可演示和小范围试用版本,6 周完成重点场景试运行。账号采购申请和发票确认不等待研发完成,采用同步推进。", ) add_table( doc, ["阶段", "周期", "主要工作", "交付结果"], [ ["启动与确认", "第 1 周", "确认报价单模板、对账模板、充值导入模板、上下架字段、企业样本和账号采购申请。", "一期范围确认表、样表清单、账号申请单。"], ["查询与报价 MVP", "第 2 周", "强化智能查询、商品/订单/积分表口径,完成报价单模板生成和新增品候选匹配。", "自然语言查询演示、报价单生成样例。"], ["对账与充值流程", "第 3-4 周", "打通月结企业、订单/积分/运费导出规则、充值文件清洗和风险提示。", "对账文件包样例、充值导入前检查表。"], ["上下架与名称匹配试点", "第 5 周", "根据报价单生成上下架清单,沉淀商品名、开票名、SKU 编码映射。", "上下架待确认清单、名称匹配表。"], ["试运行与培训", "第 6 周", "用真实业务样本试跑,修正提示词、口径、模板和人工确认流程。", "试运行报告、使用说明、下一期清单。"], ], [1450, 1200, 4300, 2410], ) add_para( doc, "如果只做“账号采购 + 文档/表格辅助”可以更快,约 1 周即可启用;如果要把报价、对账、充值、上下架真正沉淀到当前系统里,建议按 4-6 周评估。", bold_prefix="补充说明:", ) add_heading(doc, "4. 费用的具体数量", 1) add_para( doc, "本次费用按 ChatGPT Pro 账号采购口径测算:4 个账号,每个账号 USD 200/月,合计 USD 800/月。该口径对应一个月试点费用,不与其他订阅档位混合测算。", ) add_table( doc, ["费用项", "数量/单价", "金额", "说明"], [ ["ChatGPT Pro 账号", "4 个账号 x USD 200/月", "USD 800/月", "用于业务人员日常方案、表格、文档、复杂问题分析和高强度 AI 使用。"], ["采购申请金额", "按 1 个月试点", "USD 800", "先按一个月费用提交申请,后续是否续费根据试运行效果决定。"], ["OpenAI API 或其他第三方接口", "未纳入本次 USD 800/月", "另行测算", "ChatGPT Pro 订阅不等同于 API 用量;当前系统侧主要通过 MCP/DeepSeek 跑业务工具。"], ["内部系统开发/实施", "使用现有 smqjh-agent 底座推进", "如内部研发,不另列外采费用", "若后续外包 OCR、价格接口或专门爬取服务,需要单独报价。"], ], [2200, 2450, 1850, 2860], ) add_callout( doc, "采购与发票说明", "ChatGPT 自助订阅通常可在账户账单里下载 invoice/receipt,但这不等同于国内增值税专票或普票。当前建议先按 USD 800/月提交采购申请,同步让财务确认境外订阅 invoice、付款截图、信用卡流水等材料是否可作为报销/入账依据;如果必须取得国内发票,需要另查代理采购或企业合同路径,预算和周期都会变化。", fill=CAUTION_FILL, ) add_heading(doc, "5. 推荐推进方式", 1) add_number(doc, "先提交 USD 800/月 / 4 个 ChatGPT Pro 账号的试点申请。") add_number(doc, "账号采购和系统建设同步走,避免等采购流程导致研发试点停滞。") add_number(doc, "第一条端到端样例建议选择“报价单 + 对账查询”组合:展示价值直接,也最贴合当前需求文档。") add_number(doc, "所有涉及金额、上下架、充值、开票的结果都输出“待确认清单”,由业务人员确认后再执行。") add_number(doc, "试运行结束后,用真实样本统计节省时间、错误率、人工确认比例,再决定第二期是否做更深的自动写入和审批流。") add_heading(doc, "6. 可直接用于内部汇报的版本", 1) add_callout( doc, "汇报口径", "第一期主要解决报价、商品、充值、对账和查询这几类重复性工作。当前系统已经有 Agent、MCP、只读数据库和表格输出底座,所以不需要从零开始。账号侧先采购 4 个 ChatGPT Pro 账号做业务和文档辅助,预算按 USD 800/月申请;系统侧同步把查询、报价单、对账和充值整理做成可复用流程。预计 4 周能出可演示版本,6 周进入小范围试运行。ChatGPT 自助订阅暂时无法提供国内增值税发票,我们先提交申请,同时让财务确认境外 invoice/付款凭证是否可接受;如果必须走国内发票,再调整采购路径和预算。" ) add_heading(doc, "参考信息", 1) add_bullet(doc, "OpenAI Help Center:ChatGPT Pro 档位包含 USD 100/月和 USD 200/月两类,Pro USD 200 对应最高用量档。https://help.openai.com/en/articles/9793128-about-chatgpt-pro-tiers") add_bullet(doc, "OpenAI Help Center:自助 ChatGPT 客户可在账单入口查看或下载过往 invoices;如果组织要求 invoice billing,需要联系 Sales 讨论 Enterprise/Education 等合同方案。https://help.openai.com/en/articles/12356340-how-can-i-find-my-past-chatgpt-invoices") add_bullet(doc, "当前项目资料:smqjh-agent-runtime、smqjh-mcp-server、smqjh-admin-agent 以及《市民请集合AI第一期需求.docx》。") OUT.parent.mkdir(parents=True, exist_ok=True) doc.save(OUT) print(OUT) if __name__ == "__main__": build_doc()