| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466 |
- 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()
|