walson.wang 3 周之前
父節點
當前提交
213c86b04a
共有 6 個文件被更改,包括 878 次插入0 次删除
  1. 22 0
      docs/需求.md
  2. 二進制
      output/temp_test.docx
  3. 二進制
      output/包件一 松江区办公中心物业管理服务.docx
  4. 2 0
      pyproject.toml
  5. 836 0
      scripts/generate_docx.py
  6. 18 0
      scripts/model_define.py

+ 22 - 0
docs/需求.md

@@ -0,0 +1,22 @@
+# 专业知识图谱聊天助手
+需要构建一个基于langgraph框架以及deepseek模型构建一个专业知识图谱聊天助手,需要根据用户问题进行针对行回答,目前我区分了三个方向的回答:
+针对用户的问题,进行判断,避免回答不相关的内容。以下是三个方向:
+- 闲聊。
+- 知识图谱总体介绍问题。
+- 知识图谱的具体问题。
+
+所以这里需要存在两个分类节点,第一个分类节点用于判断用户问题是闲聊类型的问题还是专业问题,第二个分类节点用于在专业知识图谱的具体问题中进一步细分问题类型,以便提供更精准的回答。第二个分类节点的区分规则可以依据回答是否需要具体的知识图谱数据支持来进行判断。因为知识图谱总体介绍不需要检索图谱中具体的图谱叙事链,而专业知识图谱的具体问题则需要检索图谱中具体的图谱叙事链来进行回答。
+
+对应的这里也需要有对应的三个回答节点,分别对应闲聊、知识图谱总体介绍以及专业知识图谱的具体问题回答。每个回答节点都需要根据用户的问题进行相应的处理和生成回答。
+
+整体流程大概就是:用户问题进来,首先做专业度判断,如果是闲聊问题,则直接进入闲聊回答节点进行处理;如果是专业问题,则进入第二个分类节点,进一步判断是否需要知识图谱数据支持。如果不需要,则进入知识图谱总体介绍回答节点进行处理;如果需要,则进入专业知识图谱的具体问题回答节点进行处理。
+
+现在说明一下其中流程的一些具体细节:
+
+(1)专业问题拆分。当判断用户问题需要使用到知识图谱具体叙事链信息(即经过第二个分类节点判断为知识图谱的具体问题)后,需要对用户问题进行拆分,以便更好地匹配知识图谱中的相关信息。拆分的方式可以是基于关键词的提取,也可以是基于语义的分析,确保能够准确识别出问题中的核心概念和关系。主要从以下几个方面进行拆分:
+- 属性1:从用户问题中提取出关键的实体、属性和关系词,以便在知识图谱中进行匹配。
+- 属性2:通过自然语言处理技术,分析用户问题的语义结构,识别出问题的意图和核心概念,从而更好地理解用户的需求。
+
+这一步主要是为了后续更好的在知识图谱中进行检索和匹配,确保能够提供准确和相关的回答。
+
+

二進制
output/temp_test.docx


二進制
output/包件一 松江区办公中心物业管理服务.docx


+ 2 - 0
pyproject.toml

@@ -4,6 +4,8 @@ version = "0.1.0"
 description = "Add your description here"
 requires-python = ">=3.13"
 dependencies = [
+    "langchain-deepseek>=1.1.0",
+    "langgraph>=1.2.9",
     "openai>=2.44.0",
     "pymupdf>=1.28.0",
 ]

+ 836 - 0
scripts/generate_docx.py

@@ -0,0 +1,836 @@
+"""
+根据 bid_fill_draft.json(正文真源)+ sq_bid_outline.json(结构真源)
+生成投标文档 docx。
+
+API 用法:
+    from scripts.generate_docx import generate_bid_docx
+
+    # 方式 1: 传文件路径,自动保存
+    generate_bid_docx(
+        bid_fill_draft="data/bid_fill_draft.json",
+        sq_bid_outline="data/sq_bid_outline.json",
+        project_name="包件一 松江区办公中心物业管理服务",
+        header_label="商务标",
+        output_path="output/标书.docx",
+    )
+
+    # 方式 2: 传已解析的 dict,返回 Document 对象
+    doc = generate_bid_docx(
+        bid_fill_draft=json.load(open("data/bid_fill_draft.json")),
+        sq_bid_outline=json.load(open("data/sq_bid_outline.json")),
+    )
+
+CLI 用法:
+    python scripts/generate_docx.py [--help]
+
+参考: data/真源解析说明.md
+样式规范: 阶段5 - 生成标书
+"""
+
+import json
+import re
+from pathlib import Path
+from typing import Union
+
+import docx
+from docx import Document
+from docx.shared import Pt, Cm, RGBColor
+from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
+from docx.enum.table import WD_TABLE_ALIGNMENT
+from docx.oxml.ns import qn
+from docx.oxml import OxmlElement
+
+
+# ── 字体常量 ──────────────────────────────────────────
+
+FONT_SIZE = {"二号": 22, "小三": 15, "四号": 14, "小四": 12, "五号": 10.5}
+
+FONT_SONG = "宋体"        # 正文中文字体
+FONT_HEI = "黑体"         # 标题中文字体
+FONT_TNR = "Times New Roman"  # 数字/英文字体
+
+
+# ════════════════════════════════════════════════════════
+# 字体工具 — 核心规则:西文 = TNR,东亚 = 宋体/黑体
+# ════════════════════════════════════════════════════════
+
+def _set_font(run, ea_font=FONT_SONG, size_pt=12, bold=False, color_rgb=(0, 0, 0)):
+    """
+    统一设置 run 字体:
+    - 西文(数字/英文)→ Times New Roman
+    - 东亚(中文)→ ea_font 参数(宋体/黑体)
+    - 文字颜色始终黑色
+    - 禁用倾斜
+    """
+    run.font.name = FONT_TNR
+    run.font.element.rPr.rFonts.set(qn('w:eastAsia'), ea_font)
+    run.font.size = Pt(size_pt)
+    run.font.bold = bold
+    if color_rgb:
+        run.font.color.rgb = RGBColor(*color_rgb)
+    run.font.italic = False
+    # 注意: underline 不由这里控制,由调用处按需设置
+
+
+def _apply_style_fonts(rPr, ea_font):
+    """在 rPr 上设置西文=TNR + 东亚=ea_font,并清除 theme 引用"""
+    rFonts = rPr.find(qn('w:rFonts'))
+    if rFonts is None:
+        rFonts = OxmlElement('w:rFonts')
+        rPr.insert(0, rFonts)
+    rFonts.set(qn('w:ascii'), FONT_TNR)
+    rFonts.set(qn('w:hAnsi'), FONT_TNR)
+    rFonts.set(qn('w:eastAsia'), ea_font)
+    # 清除 theme 引用(否则 theme 会覆盖显式字体名)
+    for attr in ['asciiTheme', 'hAnsiTheme', 'eastAsiaTheme', 'cstheme']:
+        key = qn(f'w:{attr}')
+        if key in rFonts.attrib:
+            del rFonts.attrib[key]
+
+
+def _apply_normal_style(style):
+    """设置 Normal 样式基准"""
+    rPr = style.element.get_or_add_rPr()
+    _apply_style_fonts(rPr, FONT_SONG)
+    style.font.size = Pt(FONT_SIZE["小四"])
+    style.font.color.rgb = RGBColor(0, 0, 0)
+    pf = style.paragraph_format
+    pf.space_before = Pt(0)
+    pf.space_after = Pt(0)
+    pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
+    pf.line_spacing = 1.5
+
+
+def _apply_heading_style(style, size_pt, ea_font, bold):
+    """统一设置标题样式"""
+    rPr = style.element.get_or_add_rPr()
+    _apply_style_fonts(rPr, ea_font)
+    # 字号
+    sz = rPr.find(qn('w:sz'))
+    if sz is None:
+        sz = OxmlElement('w:sz')
+        rPr.append(sz)
+    sz.set(qn('w:val'), str(int(size_pt * 2)))
+    szCs = rPr.find(qn('w:szCs'))
+    if szCs is None:
+        szCs = OxmlElement('w:szCs')
+        rPr.append(szCs)
+    szCs.set(qn('w:val'), str(int(size_pt * 2)))
+    # 加粗
+    b = rPr.find(qn('w:b'))
+    if b is None:
+        b = OxmlElement('w:b')
+        rPr.append(b)
+    bCs = rPr.find(qn('w:bCs'))
+    if bCs is None:
+        bCs = OxmlElement('w:bCs')
+        rPr.append(bCs)
+    # 颜色黑色
+    color = rPr.find(qn('w:color'))
+    if color is None:
+        color = OxmlElement('w:color')
+        rPr.append(color)
+    color.set(qn('w:val'), '000000')
+    # 清除颜色上的 themeColor 引用
+    for attr in ['themeColor', 'themeShade', 'themeTint']:
+        key = qn(f'w:{attr}')
+        if key in color.attrib:
+            del color.attrib[key]
+    # 禁止倾斜
+    i = rPr.find(qn('w:i'))
+    if i is None:
+        i = OxmlElement('w:i')
+        rPr.append(i)
+    i.set(qn('w:val'), '0')
+    # 段落格式
+    pf = style.paragraph_format
+    pf.space_before = Pt(0)
+    pf.space_after = Pt(0)
+    pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
+    pf.line_spacing = 1.5
+    pf.first_line_indent = Pt(0)
+
+
+def _create_toc_style(styles_element, style_id, name, based_on, ea_font, size_pt, bold):
+    """在文档 styles.xml 中创建 TOC 样式"""
+    # 查找是否已存在(通过迭代避免 XPath 命名空间问题)
+    existing = None
+    for child in styles_element:
+        if child.tag == qn('w:style') and child.get(qn('w:styleId')) == style_id:
+            existing = child
+            break
+    if existing is not None:
+        styles_element.remove(existing)
+
+    style_el = OxmlElement('w:style')
+    style_el.set(qn('w:type'), 'paragraph')
+    style_el.set(qn('w:styleId'), style_id)
+
+    name_el = OxmlElement('w:name')
+    name_el.set(qn('w:val'), name)
+    style_el.append(name_el)
+    if based_on:
+        based_el = OxmlElement('w:basedOn')
+        based_el.set(qn('w:val'), based_on)
+        style_el.append(based_el)
+
+    rPr = OxmlElement('w:rPr')
+    _apply_style_fonts(rPr, ea_font)
+    sz = OxmlElement('w:sz'); sz.set(qn('w:val'), str(int(size_pt * 2))); rPr.append(sz)
+    szCs = OxmlElement('w:szCs'); szCs.set(qn('w:val'), str(int(size_pt * 2))); rPr.append(szCs)
+    if bold:
+        rPr.append(OxmlElement('w:b'))
+        rPr.append(OxmlElement('w:bCs'))
+    color = OxmlElement('w:color'); color.set(qn('w:val'), '000000'); rPr.append(color)
+    style_el.append(rPr)
+    styles_element.append(style_el)
+
+
+# ── 段落布局工具 ──────────────────────────────────────
+
+def _add_empty_para(doc, pt_height=6):
+    p = doc.add_paragraph()
+    pf = p.paragraph_format
+    pf.space_before = Pt(0)
+    pf.space_after = Pt(0)
+    pf.line_spacing = Pt(pt_height)
+    return p
+
+
+def _set_para_spacing(pf, before=0, after=0, line_spacing=1.5):
+    pf.space_before = Pt(before)
+    pf.space_after = Pt(after)
+    pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
+    pf.line_spacing = line_spacing
+
+
+def _set_first_line_indent(pf, font_size_pt=12, chars=2):
+    pf.first_line_indent = Pt(font_size_pt * chars)
+
+
+# ── 表格边框工具 ──────────────────────────────────────
+
+def _add_table_borders(table):
+    """给表格加细实线边框"""
+    tbl = table._tbl
+    tblPr = tbl.tblPr if tbl.tblPr is not None else OxmlElement('w:tblPr')
+    borders = OxmlElement('w:tblBorders')
+    for edge in ['top', 'left', 'bottom', 'right', 'insideH', 'insideV']:
+        el = OxmlElement(f'w:{edge}')
+        el.set(qn('w:val'), 'single')
+        el.set(qn('w:sz'), '4')
+        el.set(qn('w:space'), '0')
+        el.set(qn('w:color'), '000000')
+        borders.append(el)
+    tblPr.append(borders)
+
+
+def _remove_table_borders(table):
+    """移除表格边框(用于页眉表格)"""
+    tbl = table._tbl
+    tblPr = tbl.tblPr if tbl.tblPr is not None else OxmlElement('w:tblPr')
+    borders = OxmlElement('w:tblBorders')
+    for edge in ['top', 'left', 'bottom', 'right', 'insideH', 'insideV']:
+        el = OxmlElement(f'w:{edge}')
+        el.set(qn('w:val'), 'none')
+        el.set(qn('w:sz'), '0')
+        el.set(qn('w:space'), '0')
+        el.set(qn('w:color'), 'auto')
+        borders.append(el)
+    tblPr.append(borders)
+
+
+# ════════════════════════════════════════════════════════
+# 正文渲染器 — 解析 export_markdown 写入 docx
+# ════════════════════════════════════════════════════════
+
+class _BodyRenderer:
+    """渲染 export_markdown 正文到 docx
+
+    规则:
+    - 正文:小四宋体(西文 TNR),1.5 倍行距,首行缩进 2 字符
+    - 表格:五号宋体
+    - **加粗** → 加粗
+    - #### 内部标题 → 黑体加粗段落
+    - && → 下划线空格
+    - ![](path) → 图片
+    """
+
+    def __init__(self, doc: Document):
+        self.doc = doc
+
+    def render(self, markdown_text: str):
+        if not markdown_text or not markdown_text.strip():
+            return
+        lines = markdown_text.split("\n")
+        i = 0
+        while i < len(lines):
+            line = lines[i]
+            stripped = line.strip()
+            if not stripped:
+                i += 1
+                continue
+            # 分隔线跳过
+            if re.match(r'^-{3,}$', stripped) or re.match(r'^\*{3,}$', stripped):
+                i += 1
+                continue
+            # Markdown 表格
+            if stripped.startswith("|") and stripped.endswith("|"):
+                tbl_lines = []
+                while i < len(lines) and lines[i].strip().startswith("|"):
+                    tbl_lines.append(lines[i].strip())
+                    i += 1
+                self._render_table(tbl_lines)
+                continue
+            # 行内图片
+            if "![" in stripped:
+                rest = stripped
+                while rest:
+                    m = re.search(r'!\[.*?\]\((.*?)\)', rest)
+                    if m:
+                        before = rest[:m.start()].strip()
+                        if before:
+                            self._render_line(before)
+                        self._render_image(m.group(1))
+                        rest = rest[m.end():].strip()
+                    else:
+                        if rest:
+                            self._render_line(rest)
+                        rest = ""
+                i += 1
+                continue
+            # 引用
+            if stripped.startswith(">"):
+                p = self.doc.add_paragraph()
+                _set_font(p.add_run(stripped.lstrip(">").strip()), size_pt=FONT_SIZE["小四"])
+                _set_para_spacing(p.paragraph_format)
+                p.paragraph_format.left_indent = Cm(1)
+                i += 1
+                continue
+            # 正文段落
+            self._render_line(stripped)
+            i += 1
+
+    def _render_line(self, text: str):
+        """一行正文:小四宋体,首行缩进2字符"""
+        p = self.doc.add_paragraph()
+        pf = p.paragraph_format
+        _set_para_spacing(pf)
+        _set_first_line_indent(pf, font_size_pt=FONT_SIZE["小四"])
+        for seg_type, seg_text in self._parse_inline(text):
+            if seg_type == "bold":
+                r = p.add_run(seg_text)
+                _set_font(r, ea_font=FONT_SONG, size_pt=FONT_SIZE["小四"], bold=True)
+            elif seg_type == "placeholder":
+                r = p.add_run(seg_text.replace("&&", "  "))
+                _set_font(r, ea_font=FONT_SONG, size_pt=FONT_SIZE["小四"])
+                r.underline = True
+            elif seg_type == "heading":
+                r = p.add_run(seg_text)
+                _set_font(r, ea_font=FONT_HEI, size_pt=FONT_SIZE["小四"], bold=True)
+                pf.first_line_indent = Pt(0)
+            else:
+                r = p.add_run(seg_text)
+                _set_font(r, ea_font=FONT_SONG, size_pt=FONT_SIZE["小四"])
+
+    @staticmethod
+    def _parse_inline(text: str):
+        """解析内联格式 → [(type, text), ...]"""
+        segments = []
+        m = re.match(r'^(#{1,6})\s+(.*)', text)
+        if m:
+            segments.append(("heading", m.group(2)))
+            return segments
+        for part in re.split(r'(\*\*.*?\*\*)', text):
+            if part.startswith("**") and part.endswith("**"):
+                inner = part[2:-2]
+                if inner:
+                    segments.append(("bold", inner))
+            else:
+                for sub in re.split(r'(&&+)', part):
+                    if sub.startswith("&&"):
+                        segments.append(("placeholder", sub))
+                    elif sub:
+                        segments.append(("text", sub))
+        return segments
+
+    def _render_table(self, table_lines: list):
+        """Markdown 表格 → docx 表格,五号宋体"""
+        rows = []
+        for line in table_lines:
+            if not re.match(r'^\|[-:\s|]+\|$', line):
+                cells = [c.strip() for c in line.split("|")[1:-1]]
+                rows.append(cells)
+
+        if not rows:
+            return
+        header = rows[0]
+        body = rows[1:] if len(rows) > 1 else []
+        cols = max(len(header), max((len(r) for r in body), default=0))
+        if cols == 0:
+            return
+        for r in body:
+            while len(r) < cols:
+                r.append("")
+
+        table = self.doc.add_table(rows=1 + len(body), cols=cols)
+        table.alignment = WD_TABLE_ALIGNMENT.CENTER
+        _add_table_borders(table)
+
+        for j, h in enumerate(header):
+            cell = table.rows[0].cells[j]
+            cell.text = ""
+            p = cell.paragraphs[0]
+            p.alignment = WD_ALIGN_PARAGRAPH.CENTER
+            _set_font(p.add_run(h), ea_font=FONT_SONG, size_pt=FONT_SIZE["五号"], bold=True)
+
+        for i, row in enumerate(body):
+            for j, text in enumerate(row):
+                if j >= len(table.rows[i + 1].cells):
+                    continue
+                cell = table.rows[i + 1].cells[j]
+                cell.text = ""
+                p = cell.paragraphs[0]
+                is_num = bool(re.match(r'^[\d,.\s\-+%()]*$', text))
+                p.alignment = WD_ALIGN_PARAGRAPH.RIGHT if is_num else WD_ALIGN_PARAGRAPH.LEFT
+                _set_font(p.add_run(text), ea_font=FONT_SONG, size_pt=FONT_SIZE["五号"])
+
+    def _render_image(self, img_path: str):
+        p = Path(img_path)
+        resolved = None
+        if p.exists():
+            resolved = p
+        else:
+            for drive in ["D:", "E:", "C:"]:
+                alt = Path(str(p).replace("E:", drive).replace("D:", drive))
+                if alt.exists():
+                    resolved = alt
+                    break
+        if resolved:
+            try:
+                para = self.doc.add_paragraph()
+                para.alignment = WD_ALIGN_PARAGRAPH.CENTER
+                para.paragraph_format.first_line_indent = Pt(0)
+                para.add_run().add_picture(str(resolved), width=Cm(14))
+            except Exception:
+                self._placeholder_image(img_path)
+        else:
+            self._placeholder_image(img_path)
+
+    def _placeholder_image(self, img_path):
+        p = self.doc.add_paragraph()
+        p.alignment = WD_ALIGN_PARAGRAPH.CENTER
+        _set_font(p.add_run(f"[图片: {Path(img_path).name}]"),
+                  ea_font=FONT_SONG, size_pt=FONT_SIZE["五号"])
+
+
+# ════════════════════════════════════════════════════════
+# 大纲遍历器(核心引擎)
+# ════════════════════════════════════════════════════════
+
+class _OutlineDocxBuilder:
+
+    def __init__(self, outline: dict, payload: dict, *,
+                 project_name: Union[str, None] = None,
+                 header_label: Union[str, None] = None):
+        self.outline = outline
+        self.payload = payload
+        self._project_name_override = project_name
+        self._header_label_override = header_label
+        self.doc = Document()
+
+        # path → section 索引
+        self.by_path = {sec["path"]: sec for sec in payload.get("sections", [])}
+
+        # 卷册角色 → 商务标/技术标
+        self.volume_roles: dict = {}
+        for node in outline.get("tree", []):
+            role = node.get("outline_role", "commercial")
+            self.volume_roles[node.get("path", "")] = "商务标" if role == "commercial" else "技术标"
+
+        self._header_right_cell = None
+
+        self._setup_page()
+        self._setup_styles()
+        self._setup_header_footer()
+
+    @property
+    def _project_name(self):
+        return self._project_name_override or self.outline.get("alias", "")
+
+    @property
+    def _task_id(self):
+        return self.outline.get("task_id", "")
+
+    @property
+    def _package_id(self):
+        return self.outline.get("package_id", "")
+
+    # ── 页面设置 ──
+    def _setup_page(self):
+        for sec in self.doc.sections:
+            sec.top_margin = Cm(2.54)
+            sec.bottom_margin = Cm(2.54)
+            sec.left_margin = Cm(2.5)
+            sec.right_margin = Cm(2.5)
+
+    # ── 样式 ──
+    def _setup_styles(self):
+        _apply_normal_style(self.doc.styles['Normal'])
+
+        # 标题层级映射(供目录使用 + 符合标书规范):
+        #   L1 卷册         → Heading 1 → 二号黑体加粗      (不在"一、二、三"编号体系中)
+        #   L2 章(第一章)  → Heading 2 → 小三黑体加粗      (一级标题 一、二、三...)
+        #   L3 节            → Heading 3 → 四号黑体加粗      (二级标题 (一)(二)...)
+        #   L4+ 细项         → 加粗段落   → 小四宋体加粗     (三级标题 1. 2. 3...,不入目录)
+        _apply_heading_style(self.doc.styles['Heading 1'],
+                             FONT_SIZE["二号"], FONT_HEI, bold=True)
+        _apply_heading_style(self.doc.styles['Heading 2'],
+                             FONT_SIZE["小三"], FONT_HEI, bold=True)
+        _apply_heading_style(self.doc.styles['Heading 3'],
+                             FONT_SIZE["四号"], FONT_HEI, bold=True)
+
+        # 目录样式: 一级目录四号黑体,二级目录小四宋体
+        styles_el = self.doc.styles.element
+        _create_toc_style(styles_el, 'TOC1', 'toc 1', 'Normal',
+                          FONT_HEI, FONT_SIZE["四号"], bold=False)
+        _create_toc_style(styles_el, 'TOC2', 'toc 2', 'Normal',
+                          FONT_SONG, FONT_SIZE["小四"], bold=False)
+
+    # ── 页眉(封面/目录节:无页码)──
+    def _setup_header_footer(self):
+        section = self.doc.sections[0]
+        header = section.header
+        header.is_linked_to_previous = False
+        for p in header.paragraphs:
+            p.clear()
+        self._build_header_table(header)
+
+        # 封面/目录节:页脚留空(无页码)
+
+    def _build_header_table(self, header):
+        """构造页眉表格:左项目名 + 右标签"""
+        htable = header.add_table(rows=1, cols=2, width=Cm(16))
+        htable.alignment = WD_TABLE_ALIGNMENT.CENTER
+        _set_font(htable.rows[0].cells[0].paragraphs[0].add_run(self._project_name),
+                  ea_font=FONT_SONG, size_pt=FONT_SIZE["五号"])
+
+        right_cell = htable.rows[0].cells[1]
+        right_cell.text = ""
+        self._header_right_cell = right_cell
+        _remove_table_borders(htable)
+
+    def _update_header_role(self, role_text):
+        if self._header_right_cell is not None:
+            p = self._header_right_cell.paragraphs[0]
+            p.clear()
+            _set_font(p.add_run(role_text), ea_font=FONT_SONG, size_pt=FONT_SIZE["五号"])
+
+    # ── 正文节(页码从 1 起始)──
+    def _add_body_section(self):
+        new_sec = self.doc.add_section()
+        new_sec.top_margin = Cm(2.54)
+        new_sec.bottom_margin = Cm(2.54)
+        new_sec.left_margin = Cm(2.5)
+        new_sec.right_margin = Cm(2.5)
+        new_sec.header.is_linked_to_previous = False
+        new_sec.footer.is_linked_to_previous = False
+
+        # 页眉
+        for p in new_sec.header.paragraphs:
+            p.clear()
+        self._build_header_table(new_sec.header)
+
+        # 页脚:居中页码
+        fp = new_sec.footer.paragraphs[0]
+        fp.alignment = WD_ALIGN_PARAGRAPH.CENTER
+
+        # 在段落默认属性上设置五号宋体,确保 PAGE 域渲染时继承正确字体
+        pPr = fp._p.get_or_add_pPr()
+        rPr_def = pPr.find(qn('w:rPr'))
+        if rPr_def is None:
+            rPr_def = OxmlElement('w:rPr')
+            pPr.insert(0, rPr_def)
+        _apply_style_fonts(rPr_def, FONT_SONG)
+        sz_def = rPr_def.find(qn('w:sz'))
+        if sz_def is None:
+            sz_def = OxmlElement('w:sz')
+            rPr_def.append(sz_def)
+        sz_def.set(qn('w:val'), str(int(FONT_SIZE["五号"] * 2)))
+        szCs_def = rPr_def.find(qn('w:szCs'))
+        if szCs_def is None:
+            szCs_def = OxmlElement('w:szCs')
+            rPr_def.append(szCs_def)
+        szCs_def.set(qn('w:val'), str(int(FONT_SIZE["五号"] * 2)))
+        color_def = rPr_def.find(qn('w:color'))
+        if color_def is None:
+            color_def = OxmlElement('w:color')
+            rPr_def.append(color_def)
+        color_def.set(qn('w:val'), '000000')
+
+        r = fp.add_run()
+        fc = OxmlElement('w:fldChar')
+        fc.set(qn('w:fldCharType'), 'begin')
+        r._r.append(fc)
+
+        r2 = fp.add_run()
+        it = OxmlElement('w:instrText')
+        it.text = ' PAGE '
+        r2._r.append(it)
+
+        r3 = fp.add_run()
+        fc2 = OxmlElement('w:fldChar')
+        fc2.set(qn('w:fldCharType'), 'end')
+        r3._r.append(fc2)
+
+        for rn in fp.runs:
+            _set_font(rn, ea_font=FONT_SONG, size_pt=FONT_SIZE["五号"])
+
+        # 从 1 开始
+        pnt = OxmlElement('w:pgNumType')
+        pnt.set(qn('w:start'), '1')
+        new_sec._sectPr.append(pnt)
+
+    # ── 封面 ──
+    def _add_cover(self):
+        for _ in range(8):
+            _add_empty_para(self.doc, 18)
+
+        # "投 标 文 件" —— 二号黑体加粗居中
+        p = self.doc.add_paragraph()
+        p.alignment = WD_ALIGN_PARAGRAPH.CENTER
+        _set_font(p.add_run("投 标 文 件"), ea_font=FONT_HEI,
+                  size_pt=FONT_SIZE["二号"], bold=True)
+        _add_empty_para(self.doc, 24)
+
+        # 项目名称(副标题)
+        p = self.doc.add_paragraph()
+        p.alignment = WD_ALIGN_PARAGRAPH.CENTER
+        _set_font(p.add_run(self._project_name), ea_font=FONT_HEI, size_pt=18)
+        _add_empty_para(self.doc, 12)
+
+        # 标识
+        p = self.doc.add_paragraph()
+        p.alignment = WD_ALIGN_PARAGRAPH.CENTER
+        _set_font(p.add_run(f"{self._task_id} / {self._package_id}"),
+                  ea_font=FONT_SONG, size_pt=FONT_SIZE["小四"])
+        self.doc.add_page_break()
+
+    # ── 目录 ──
+    def _add_toc(self):
+        p = self.doc.add_paragraph()
+        p.alignment = WD_ALIGN_PARAGRAPH.CENTER
+        _set_font(p.add_run("目  录"), ea_font=FONT_HEI,
+                  size_pt=FONT_SIZE["二号"], bold=False)
+        p.paragraph_format.space_after = Pt(12)
+
+        ptoc = self.doc.add_paragraph()
+        r = ptoc.add_run()
+        c = OxmlElement('w:fldChar')
+        c.set(qn('w:fldCharType'), 'begin')
+        r._r.append(c)
+
+        r2 = ptoc.add_run()
+        it = OxmlElement('w:instrText')
+        it.text = ' TOC \\o "1-3" \\h \\z \\u '
+        r2._r.append(it)
+
+        r3 = ptoc.add_run()
+        c2 = OxmlElement('w:fldChar')
+        c2.set(qn('w:fldCharType'), 'separate')
+        r3._r.append(c2)
+
+        r4 = ptoc.add_run()
+        r4.text = "(请在 Word 中右键此处 → 更新域 以生成目录)"
+        _set_font(r4, ea_font=FONT_SONG, size_pt=FONT_SIZE["五号"])
+
+        r5 = ptoc.add_run()
+        c3 = OxmlElement('w:fldChar')
+        c3.set(qn('w:fldCharType'), 'end')
+        r5._r.append(c3)
+
+        self.doc.add_page_break()
+
+    # ── 构建主流程 ──
+    def build(self) -> Document:
+        self._add_cover()
+        self._add_toc()
+        self._add_body_section()
+
+        for l1_node in self.outline.get("tree", []):
+            role = self.volume_roles.get(l1_node.get("path", ""), "")
+            self._update_header_role(self._header_label_override or role)
+            self._traverse_node(l1_node)
+        return self.doc
+
+    # ── 大纲递归遍历 ──
+    @staticmethod
+    def _strip_title_punct(title: str) -> str:
+        """去除标题末尾标点符号,确保标题独占一行、末尾不带标点"""
+        return title.rstrip("。,、;:?!.,;:!?·—…")
+
+    def _traverse_node(self, node, parent_role=""):
+        level = node["level"]
+        title = self._strip_title_punct(node["title"])
+        path = node["path"]
+        children = node.get("children", [])
+
+        em = ""
+        if path in self.by_path:
+            raw = (self.by_path[path].get("export_markdown", "") or "")
+            em = self._clean_export(raw, title)
+
+        if level == 1:
+            # L1 卷册 → Heading 1(二号黑体加粗,出现在目录)
+            self.doc.add_page_break()
+            self.doc.add_heading(title, level=1)
+            if em.strip():
+                _BodyRenderer(self.doc).render(em)
+            for child in children:
+                self._traverse_node(child, parent_role=path)
+
+        elif level == 2:
+            # L2 章(第一章...)→ Heading 2 → 一级标题,小三黑体加粗
+            self.doc.add_heading(title, level=2)
+            if em.strip():
+                _BodyRenderer(self.doc).render(em)
+            for child in children:
+                self._traverse_node(child, parent_role)
+
+        elif level == 3:
+            # L3 节 → Heading 3 → 二级标题,四号黑体加粗
+            self.doc.add_heading(title, level=3)
+            if em.strip():
+                _BodyRenderer(self.doc).render(em)
+            for child in children:
+                self._traverse_node(child, parent_role)
+
+        elif level >= 4:
+            # L4+ 细项 → 加粗段落(小四宋体加粗),不入目录
+            if em.strip():
+                p = self.doc.add_paragraph()
+                pf = p.paragraph_format
+                _set_para_spacing(pf, before=3, after=0)
+                pf.first_line_indent = Pt(0)
+                _set_font(p.add_run(title), ea_font=FONT_SONG,
+                          size_pt=FONT_SIZE["小四"], bold=True)
+                _BodyRenderer(self.doc).render(em)
+            for child in children:
+                self._traverse_node(child, parent_role)
+
+    @staticmethod
+    def _clean_export(markdown: str, title: str) -> str:
+        """去除 export_markdown 开头冗余内容"""
+        lines = markdown.split("\n")
+        cleaned = []
+        skipping = True
+        for line in lines:
+            s = line.strip()
+            if skipping:
+                if not s:
+                    continue
+                if s.startswith("#") and title in s:
+                    continue
+                if any(kw in s for kw in ["为您撰写", "根据您", "好的,"]):
+                    continue
+                skipping = False
+            cleaned.append(line)
+        return "\n".join(cleaned)
+
+
+# ════════════════════════════════════════════════════════
+# 公开 API
+# ════════════════════════════════════════════════════════
+
+def generate_bid_docx(
+    bid_fill_draft: Union[dict, str, Path],
+    sq_bid_outline: Union[dict, str, Path],
+    *,
+    project_name: Union[str, None] = None,
+    header_label: Union[str, None] = None,
+    output_path: Union[str, Path, None] = None,
+) -> Union[Document, Path]:
+    """生成投标文件 docx。
+
+    参数
+    ----
+    bid_fill_draft : dict | str | Path
+        正文真源。dict 或 JSON 文件路径。
+    sq_bid_outline : dict | str | Path
+        结构真源。dict 或 JSON 文件路径。
+    project_name : str | None
+        页眉左侧项目全称。不传则从 outline.alias 读取。
+    header_label : str | None
+        页眉右侧标签("商务标"/"技术标")。不传则按大纲角色判断。
+    output_path : str | Path | None
+        docx 保存路径。None 则返回 Document 对象。
+
+    返回
+    ----
+    Document | Path
+    """
+    outline = _load_json(sq_bid_outline)
+    payload = _load_json(bid_fill_draft)
+    builder = _OutlineDocxBuilder(outline, payload,
+                                  project_name=project_name,
+                                  header_label=header_label)
+    doc = builder.build()
+    if output_path:
+        output_path = Path(output_path)
+        output_path.parent.mkdir(parents=True, exist_ok=True)
+        doc.save(str(output_path))
+        return output_path
+    return doc
+
+
+def _load_json(data) -> dict:
+    if isinstance(data, dict):
+        return data
+    with open(Path(data), "r", encoding="utf-8") as f:
+        return json.load(f)
+
+
+# ════════════════════════════════════════════════════════
+# CLI 入口
+# ════════════════════════════════════════════════════════
+
+def main():
+    import argparse
+    parser = argparse.ArgumentParser(description="投标文件 DOCX 生成器")
+    parser.add_argument("--fill", default="data/bid_fill_draft.json")
+    parser.add_argument("--outline", default="data/sq_bid_outline.json")
+    parser.add_argument("--project-name", default=None)
+    parser.add_argument("--header-label", default=None)
+    parser.add_argument("--output", "-o", default=None)
+    args = parser.parse_args()
+
+    if args.output is None:
+        outline = _load_json(args.outline)
+        alias = outline.get("alias", "投标文件")
+        safe = re.sub(r'[\\/:*?"<>|]', '_', alias)
+        out_dir = Path(__file__).resolve().parent.parent / "output"
+        out_dir.mkdir(parents=True, exist_ok=True)
+        args.output = str(out_dir / f"{safe}.docx")
+
+    result = generate_bid_docx(
+        bid_fill_draft=args.fill,
+        sq_bid_outline=args.outline,
+        project_name=args.project_name,
+        header_label=args.header_label,
+        output_path=args.output,
+    )
+
+    payload = _load_json(args.fill)
+    total_chars = sum(
+        len(sec.get("export_markdown", "") or "")
+        for sec in payload.get("sections", [])
+    )
+    result_doc = docx.Document(str(result))
+    print(f"文档已生成: {result}")
+    print(f"  正文总字符数: {total_chars:,}")
+    print(f"  段 落 数: {len(result_doc.paragraphs)}")
+    print(f"  表 格 数: {len(result_doc.tables)}")
+
+
+if __name__ == "__main__":
+    main()

+ 18 - 0
scripts/model_define.py

@@ -0,0 +1,18 @@
+from langchain_deepseek import ChatDeepSeek
+
+# 初始化 DeepSeek 模型
+model = ChatDeepSeek(
+    model="deepseek-chat",      # 模型名称
+    api_key="sk-a329ecd8a64d4db8a8f1c00f89ad169d",  # API Key
+    temperature=0.3,            # 控制随机性
+    max_tokens=2048,            # 最大输出长度
+    max_retries=2,              # 重试次数
+)
+
+messages = [
+            ("system", "你是一个专业的聊天助手。"),
+            ("human", "你是谁"),
+        ]
+result = model.astream(messages)
+
+print(result)