__init__.py 134 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390
  1. """
  2. Step 4:投书内容撰写(V2 更新)
  3. 三套策略协同:
  4. 1. 表/函填充模式(V2 新增):逻辑+LLM 协同填充资格响应表、投标函等
  5. 2. 模板改写模式(保留):解析模板 → 逐章基于模板改写
  6. 3. 从零生成模式(降级):模板不可用时,LLM 从零撰写
  7. 流程:
  8. 解析模板 → 格式比对 → 提取公司信息 → 构建占位符映射 →
  9. 表/函填充 → 逐章撰写 → 占位符解析
  10. """
  11. from __future__ import annotations
  12. import logging
  13. import json
  14. import os
  15. import re
  16. import xml.etree.ElementTree as ET
  17. from copy import deepcopy
  18. from concurrent.futures import ThreadPoolExecutor, as_completed
  19. from typing import Any, Dict, List, Optional, Tuple
  20. from models import (
  21. BidOutline,
  22. Chapter,
  23. ChapterType,
  24. CompanyInfo,
  25. ProjectData,
  26. TenderAnalysis,
  27. TemplateStructure,
  28. )
  29. from config import get_config
  30. from step4_writing.chapter_writer import (
  31. _ChapterWriter,
  32. clean_ai_commentary,
  33. replace_bracket_placeholders,
  34. strip_markdown_headings,
  35. )
  36. from step4_writing.content_injector import _ContentInjector
  37. from step4_writing.company_info_extractor import extract_company_info
  38. from step4_writing.placeholder_engine import (
  39. build_placeholder_map,
  40. apply_placeholders,
  41. fill_company_static_text,
  42. resolve_all_placeholders,
  43. )
  44. from step4_writing.table_form_filler import fill_all_tables
  45. from step3_outlining.template_parser import (
  46. parse_template,
  47. get_template_text_for_chapter,
  48. get_template_text_for_section,
  49. )
  50. logger = logging.getLogger(__name__)
  51. _EVALUATION_INDEX_KEYWORDS = (
  52. "索引表", "主要内容索引", "评标有关的投标文件主要内容索引",
  53. )
  54. _TEMPLATE_PART_MARKERS = frozenset(
  55. {"商务部分", "技术部分", "附件", "附录"}
  56. )
  57. _BOUND_BLOCK_MAX_TOKENS_BASE = 65536
  58. # 初始调用后再按 max_tokens 翻倍重试 4 次,仍缺节点才进入要求兜底。
  59. _BOUND_BLOCK_RETRY_ATTEMPTS = 4
  60. _BOUND_BLOCK_PROMPT = """你是投标文件评分响应撰写专家。系统已经完成模板原文保留、
  61. 原生表格处理计划和项目信息填充。你只能为给出的 Step3 最终标题节点生成独立正文块。
  62. 硬性要求:
  63. 1. 每个 node_id 必须恰好返回一个 content;不得新增、改名或重复输出任何标题;
  64. 2. 不得输出 Markdown 表格、HTML、图片说明、签名、公章、日期或表/函结构;
  65. 3. 模板复用节点只写评分所需的针对性补充,不复述或重构模板原文;
  66. 4. 必须分析该节点已有模板正文(template_content);模板已覆盖的内容不得重复承诺,
  67. 只补充模板未覆盖的针对性分析、措施或项目信息,且不得与模板原文冲突;
  68. 5. 新增节点写完整响应正文,但只能依据提供的招标要求、项目信息和真实材料;
  69. 6. 不得编造证书、业绩、人员、数值或承诺;资料不足时明确写“按招标文件要求响应,
  70. 证明材料见对应附件/表单”,不得虚构细节;
  71. 7. content 使用普通段落,可分段,但不得使用“一、”“(一)”“1.”等标题型编号。
  72. 8. 章节交叉引用只能使用 final_chapter_index 中给出的真实章号;不得引用清单外的
  73. “第X章”,无法确定时写“详见本投标文件相应章节及附件”。
  74. 输出:{"blocks":[{"node_id":"3.1","content":"正文"}]}"""
  75. def _walk_nodes(chapter: Chapter):
  76. yield chapter
  77. for child in chapter.children or []:
  78. yield from _walk_nodes(child)
  79. def _is_evaluation_index_chapter(chapter: Chapter) -> bool:
  80. return any(
  81. keyword in (chapter.title or "")
  82. for keyword in _EVALUATION_INDEX_KEYWORDS
  83. )
  84. def _is_deferred_evaluation_index(chapter: Chapter) -> bool:
  85. return _is_evaluation_index_chapter(chapter) and any(
  86. block.get("block_type") == "deferred_evaluation_index"
  87. for block in (chapter.content_blocks or [])
  88. )
  89. def _defer_evaluation_index_chapter(
  90. chapter: Chapter,
  91. template_text: str,
  92. placeholder_map: Dict[str, str],
  93. company_info: CompanyInfo,
  94. analysis: TenderAnalysis,
  95. ) -> Chapter:
  96. """Step4 只保留索引模板;行内容和页码等待最终合并分页后构建。"""
  97. filled_template = _resolve_chapter_text_placeholders(
  98. template_text or "", placeholder_map
  99. )
  100. filled_template = replace_bracket_placeholders(
  101. filled_template, company_info=company_info, analysis=analysis
  102. )
  103. chapter.generated_content = filled_template
  104. chapter.preserve_template_layout = True
  105. chapter.template_fill_completed = True
  106. chapter.content_blocks = [
  107. {
  108. "order": 0,
  109. "block_type": "template_base",
  110. "template_original_id": chapter.template_original_id,
  111. "template_chars": len(template_text or ""),
  112. "project_fill_completed": True,
  113. },
  114. {
  115. "order": 1,
  116. "block_type": "deferred_evaluation_index",
  117. "action": "build_rows_and_pages_after_final_merge_and_pagination",
  118. "entry_count": 0,
  119. },
  120. ]
  121. return chapter
  122. def _sanitize_bound_block(text: str) -> str:
  123. """把 LLM 响应收敛为不会被识别为新标题/表格的普通正文块。"""
  124. lines = []
  125. for raw in clean_ai_commentary(text or "").splitlines():
  126. line = raw.strip()
  127. if not line:
  128. if lines and lines[-1] != "":
  129. lines.append("")
  130. continue
  131. if line.startswith("|") and line.endswith("|"):
  132. continue
  133. line = re.sub(r"^#{1,6}\s*", "", line)
  134. heading = re.match(
  135. r"^(?:[一二三四五六七八九十百]+[、,]|"
  136. r"[((][一二三四五六七八九十百\d]+[))]|"
  137. r"\d+[、..])\s*(.+)$",
  138. line,
  139. )
  140. if heading:
  141. line = f"{heading.group(1).strip()}:"
  142. lines.append(line)
  143. return "\n".join(lines).strip()
  144. def _chapter_title_key(text: str) -> str:
  145. value = re.sub(r"^第\s*[一二三四五六七八九十百\d]+\s*章\s*", "", text or "")
  146. value = re.sub(r"[《》“”「」()()\s]+", "", value)
  147. return value.strip()
  148. def _sanitize_chapter_references(content: str, outline: BidOutline) -> str:
  149. """把 LLM 输出中不在最终大纲里的“第X章”引用改写为正确章号或通用表述。"""
  150. if not content:
  151. return content
  152. from step3_outlining.outline_generator import _chinese_to_int
  153. valid_numbers = set()
  154. chapter_by_title = {}
  155. for chapter in outline.chapters or []:
  156. if str(chapter.id).isdigit():
  157. valid_numbers.add(str(int(chapter.id)))
  158. title_key = _chapter_title_key(chapter.title)
  159. if title_key:
  160. chapter_by_title.setdefault(title_key, chapter)
  161. def _is_valid(num_text: str) -> bool:
  162. if num_text.isdigit():
  163. return str(int(num_text)) in valid_numbers
  164. try:
  165. return str(_chinese_to_int(num_text)) in valid_numbers
  166. except Exception:
  167. return False
  168. quoted_pattern = re.compile(
  169. r"第\s*([一二三四五六七八九十百\d]+)\s*章\s*[《“「]"
  170. r"([^》”」]{1,40})[》”」]"
  171. )
  172. def _quoted_repl(match: re.Match) -> str:
  173. num_text = match.group(1)
  174. title_text = match.group(2).strip()
  175. if _is_valid(num_text):
  176. return match.group(0)
  177. target = chapter_by_title.get(_chapter_title_key(title_text))
  178. if target is not None and str(target.id).isdigit():
  179. return f"第{_native_chapter_number(target)}章《{target.title}》"
  180. return "本投标文件相应章节及附件"
  181. content = quoted_pattern.sub(_quoted_repl, content)
  182. plain_pattern = re.compile(r"(?:投标文件)?第\s*([一二三四五六七八九十百\d]+)\s*章")
  183. def _plain_repl(match: re.Match) -> str:
  184. if _is_valid(match.group(1)):
  185. return match.group(0)
  186. return "本投标文件相应章节及附件"
  187. return plain_pattern.sub(_plain_repl, content)
  188. _REJECTION_CONSEQUENCE_SUFFIXES = (
  189. r"(?:,|;|。)?(?:其)?投标(?:将)?(?:被)?(?:认定为|作为|按)?无效(?:投标)?",
  190. r"(?:,|;|。)?(?:其)?投标无效",
  191. r"(?:,|;|。)?(?:招标人|采购人|评标委员会)?(?:将|可|有权)?(?:予以)?拒绝"
  192. r"(?:其)?(?:投标|接收|参加本次采购活动)?",
  193. r"(?:,|;|。)?不能通过(?:资格性|符合性)?审查",
  194. )
  195. def _compact_rejection_requirement(text: str) -> str:
  196. """保留废标条件本体,去掉每条末尾重复出现的处理后果。"""
  197. value = re.sub(r"\s+", "", str(text or "")).strip("。;; ")
  198. previous = None
  199. while value and value != previous:
  200. previous = value
  201. for suffix in _REJECTION_CONSEQUENCE_SUFFIXES:
  202. value = re.sub(f"{suffix}[。;; ]*$", "", value).strip(",。;; ")
  203. return value
  204. def _compact_rejection_supplement(
  205. rejection_entries: List[dict],
  206. analysis: TenderAnalysis,
  207. ) -> str:
  208. """把要求承诺函的废标项按上游类别归并,避免逐项重复套话。"""
  209. items_by_id = {
  210. str(item.id): item for item in (analysis.rejection_items or [])
  211. }
  212. grouped: Dict[str, List[str]] = {}
  213. seen = set()
  214. for entry in rejection_entries:
  215. source_id = str(entry.get("source_id", "") or entry.get("entry_id", ""))
  216. item = items_by_id.get(source_id)
  217. category = str(getattr(item, "category", "") or "其他实质性要求").strip()
  218. requirement = _compact_rejection_requirement(
  219. getattr(item, "description", "")
  220. or entry.get("requirement", "")
  221. )
  222. fingerprint = re.sub(r"[\s,。;;::]", "", requirement)
  223. if not requirement or fingerprint in seen:
  224. continue
  225. seen.add(fingerprint)
  226. grouped.setdefault(category, []).append(requirement)
  227. if not grouped:
  228. return ""
  229. lines = [
  230. "我方郑重承诺:严格遵守招标文件、采购需求及相关澄清文件的全部实质性要求,"
  231. "确保投标文件真实、完整、有效,并重点做到如下事项:"
  232. ]
  233. for category, requirements in grouped.items():
  234. lines.append(f"{category}方面:{';'.join(requirements)}。")
  235. return "\n".join(lines)
  236. def _generate_direct_content_blocks(
  237. chapter: Chapter,
  238. outline: BidOutline,
  239. analysis: TenderAnalysis,
  240. injector: "_ContentInjector",
  241. writer: "_ChapterWriter",
  242. ) -> int:
  243. """只为 Step3 直接绑定节点生成独立补充块,不改模板基块。"""
  244. entries = list(getattr(outline, "evaluation_index_entries", []) or [])
  245. entries_by_heading: Dict[str, List[dict]] = {}
  246. for entry in entries:
  247. heading_id = str(entry.get("final_heading_id", "") or "")
  248. if heading_id:
  249. entries_by_heading.setdefault(heading_id, []).append(entry)
  250. nodes = [
  251. node for node in _walk_nodes(chapter)
  252. if entries_by_heading.get(str(node.id))
  253. ]
  254. if not nodes:
  255. return 0
  256. scoring_nodes = [
  257. node for node in nodes
  258. if any(
  259. entry.get("entry_type") == "scoring"
  260. for entry in entries_by_heading[str(node.id)]
  261. )
  262. ]
  263. generated_by_id: Dict[str, str] = {}
  264. if scoring_nodes:
  265. template_buckets = _split_artifact_content_by_outline(chapter)
  266. def _request_nodes_for(nodes_subset) -> List[dict]:
  267. request_nodes = []
  268. for node in nodes_subset:
  269. node_entries = [
  270. entry for entry in entries_by_heading[str(node.id)]
  271. if entry.get("entry_type") == "scoring"
  272. ]
  273. request_nodes.append({
  274. "node_id": node.id,
  275. "title": node.title,
  276. "mode": node.content_generation_mode or "project_scoring",
  277. "requirements": [
  278. {
  279. "id": entry.get("source_id", ""),
  280. "name": entry.get("display_name", ""),
  281. "requirement": entry.get("requirement", ""),
  282. }
  283. for entry in node_entries
  284. ],
  285. "step3_description": node.description,
  286. "template_content": "\n".join(
  287. template_buckets.get(str(node.id), [])
  288. ).strip()[:4000],
  289. })
  290. return request_nodes
  291. def _request_payload(nodes_subset) -> Dict[str, object]:
  292. return {
  293. "project": {
  294. "name": analysis.project_name,
  295. "buyer": analysis.buyer_name,
  296. "project_id": analysis.tender_project_id,
  297. },
  298. "final_chapter_index": [
  299. {
  300. "id": chapter.id,
  301. "title": chapter.title,
  302. "display": (
  303. f"第{_native_chapter_number(chapter)}章 {chapter.title}"
  304. if str(chapter.id).isdigit()
  305. else chapter.title
  306. ),
  307. }
  308. for chapter in outline.chapters or []
  309. ],
  310. "nodes": _request_nodes_for(nodes_subset),
  311. "tender_context": injector.get_tender_context(chapter)[:10000],
  312. "material_context": injector.get_material_context(chapter)[:8000],
  313. "reference_context": injector.get_reference_context(chapter)[:5000],
  314. }
  315. def _consume_blocks(result, target: Dict[str, str]) -> None:
  316. raw_blocks = result.get("blocks", []) if isinstance(result, dict) else []
  317. if not isinstance(raw_blocks, list):
  318. return
  319. allowed = {str(node.id) for node in scoring_nodes}
  320. for block in raw_blocks:
  321. if not isinstance(block, dict):
  322. continue
  323. node_id = str(block.get("node_id", "") or "")
  324. if node_id in allowed and node_id not in target:
  325. content = _sanitize_bound_block(str(block.get("content", "")))
  326. content = _sanitize_chapter_references(content, outline)
  327. if content:
  328. target[node_id] = content
  329. try:
  330. max_tokens = _BOUND_BLOCK_MAX_TOKENS_BASE
  331. missing = list(scoring_nodes)
  332. for attempt in range(_BOUND_BLOCK_RETRY_ATTEMPTS + 1):
  333. if not missing:
  334. break
  335. payload = _request_payload(missing)
  336. result = writer.llm.extract_json(
  337. system_prompt=_BOUND_BLOCK_PROMPT,
  338. user_prompt=json.dumps(payload, ensure_ascii=False),
  339. max_tokens=max_tokens,
  340. )
  341. _consume_blocks(result, generated_by_id)
  342. missing = [
  343. node for node in scoring_nodes
  344. if str(node.id) not in generated_by_id
  345. ]
  346. if not missing:
  347. break
  348. if attempt >= _BOUND_BLOCK_RETRY_ATTEMPTS:
  349. break
  350. logger.warning(
  351. f" [{chapter.id}] 评分块缺失 {len(missing)} 个节点,"
  352. f"第 {attempt + 1} 次重试 max_tokens={max_tokens} -> "
  353. f"{min(max_tokens * 2, 65536)}: "
  354. f"{[str(node.id) for node in missing]}"
  355. )
  356. max_tokens = min(max_tokens * 2, 65536)
  357. except Exception as exc:
  358. logger.warning(f" [{chapter.id}] 评分内容块批量生成失败,使用要求兜底: {exc}")
  359. completed = 0
  360. for node in nodes:
  361. node_entries = entries_by_heading[str(node.id)]
  362. parts = []
  363. scoring_entries = [e for e in node_entries if e.get("entry_type") == "scoring"]
  364. rejection_entries = [e for e in node_entries if e.get("entry_type") == "rejection"]
  365. if scoring_entries:
  366. parts.append(
  367. generated_by_id.get(str(node.id))
  368. or "\n".join(
  369. (
  370. f"针对{entry.get('display_name', '本评分要求')},我方已结合"
  371. f"{(analysis.project_name or '本项目')}的实际情况、服务范围及"
  372. f"{(analysis.buyer_name or '采购人')}的管理要求,对相关事项进行"
  373. f"分析并制定针对性响应:{entry.get('requirement', '')}。"
  374. f"具体措施及证明材料详见本投标文件相关章节及附件。"
  375. )
  376. for entry in scoring_entries
  377. )
  378. )
  379. if rejection_entries:
  380. parts.append(_compact_rejection_supplement(rejection_entries, analysis))
  381. supplement = _sanitize_bound_block("\n\n".join(part for part in parts if part))
  382. if not supplement:
  383. continue
  384. node.supplement_content = supplement
  385. if node is not chapter or not chapter.preserve_template_layout:
  386. node.generated_content = supplement
  387. supplement_blocks = ([{
  388. "order": 2,
  389. "block_type": "scoring_supplement",
  390. "source_ids": [e.get("source_id", "") for e in scoring_entries],
  391. "target_heading_id": node.id,
  392. }] if scoring_entries else []) + ([{
  393. "order": 3,
  394. "block_type": "rejection_supplement",
  395. "source_ids": [e.get("source_id", "") for e in rejection_entries],
  396. "target_heading_id": node.id,
  397. }] if rejection_entries else [])
  398. node.content_blocks = [
  399. block for block in (node.content_blocks or [])
  400. if block.get("block_type") not in {
  401. "scoring_supplement", "rejection_supplement"
  402. }
  403. ] + supplement_blocks
  404. if node is not chapter:
  405. target_id = str(node.id)
  406. chapter.content_blocks = [
  407. block for block in (chapter.content_blocks or [])
  408. if not (
  409. block.get("block_type") in {
  410. "scoring_supplement", "rejection_supplement"
  411. }
  412. and str(block.get("target_heading_id", "")) == target_id
  413. )
  414. ] + [dict(block) for block in supplement_blocks]
  415. completed += 1
  416. return completed
  417. def _get_template_text_for_chapter(
  418. template_structure,
  419. chapter,
  420. ) -> str:
  421. """获取模板章节原文;新增评分章无模板正文,模板章按来源编号兜底。
  422. 商务章(如第二章"投标报价")要求严格按模板格式书写。
  423. 若目录标题与模板标题不一致(如目录用默认"商务响应文件"而模板是
  424. "投标报价"),按模板来源编号回退,避免用顺延后的最终编号读取其他章。
  425. """
  426. if getattr(chapter, "is_scoring_inserted", False):
  427. return ""
  428. # Step 3 已匹配并持久化的模板区域名 → 精确取文本,避免再次标题匹配
  429. section_name = getattr(chapter, "template_section", "") or ""
  430. if section_name:
  431. text = get_template_text_for_section(template_structure, section_name)
  432. if text:
  433. return text
  434. text = get_template_text_for_chapter(template_structure, chapter.title)
  435. if text or not getattr(chapter, "id", "") or not chapter.id.isdigit():
  436. return text
  437. try:
  438. from step3_outlining.template_parser import get_template_chapter_titles
  439. titles = get_template_chapter_titles(template_structure.file_path)
  440. source_number = _template_chapter_number(chapter)
  441. tmpl_title = dict(titles).get(source_number, "")
  442. if tmpl_title:
  443. text = get_template_text_for_chapter(template_structure, tmpl_title)
  444. if text:
  445. logger.info(
  446. f" 章节 [{chapter.id}] 标题未直接匹配模板,"
  447. f"已按模板来源编号 [{source_number}] 回退到模板标题: {tmpl_title}"
  448. )
  449. except Exception as e:
  450. logger.debug(f"按章节编号取模板文本失败: {e}")
  451. return text
  452. def _partition_step4_chapters(outline: BidOutline) -> Tuple[List[Chapter], List[Chapter]]:
  453. """普通商务/技术章统一并发;依赖全书的索引章保持延迟。"""
  454. ordinary = []
  455. deferred = []
  456. for chapter in outline.chapters:
  457. if chapter.depends_on_all or _is_evaluation_index_chapter(chapter):
  458. deferred.append(chapter)
  459. else:
  460. ordinary.append(chapter)
  461. return ordinary, deferred
  462. def write_content(
  463. outline: BidOutline,
  464. analysis: TenderAnalysis,
  465. project_data: ProjectData,
  466. template_path: str = "",
  467. reference_bid_path: str = "",
  468. chapter_output_dir: Optional[str] = None,
  469. ) -> BidOutline:
  470. """逐章撰写投书内容(模板改写优先)
  471. Args:
  472. outline: Step 3 输出的章节目录
  473. analysis: Step 2 输出的招标分析
  474. project_data: Step 1 输出的项目数据
  475. template_path: 模板 DOCX 路径(空则使用默认模板)
  476. reference_bid_path: 参考投标文件路径(用于 diff 识别模板骨架)
  477. Returns:
  478. 填充了 generated_content 的 BidOutline
  479. """
  480. cfg = get_config()
  481. # ---- 0. 尝试加载模板 ----
  482. template_structure: Optional[TemplateStructure] = None
  483. if not template_path:
  484. template_path = os.path.join(
  485. os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
  486. "templates", "申勤投标模板.docx",
  487. )
  488. if os.path.exists(template_path):
  489. try:
  490. template_structure = parse_template(
  491. template_path=template_path,
  492. reference_bid_path=reference_bid_path,
  493. )
  494. logger.info(
  495. f"模板解析完成: {len(template_structure.sections)} 个区域, "
  496. f"{template_structure.total_tables} 个表格"
  497. )
  498. except Exception as e:
  499. logger.warning(f"模板解析失败,将使用从零生成模式: {e}")
  500. template_structure = None
  501. # ---- 1. 提取公司信息 ----
  502. company_info = extract_company_info(project_data.general_materials)
  503. # ---- 2. 构建占位符映射 ----
  504. placeholder_map = build_placeholder_map(analysis, company_info, project_data)
  505. # ---- 3. V2: 格式比对 + 表/函填充 ----
  506. format_reconciled = False
  507. if template_structure:
  508. # 尝试格式比对(如果招标 PDF 可用)
  509. tender_pdf = (
  510. project_data.tender_docs[0].file_path
  511. if project_data.tender_docs else ""
  512. )
  513. if tender_pdf and tender_pdf.lower().endswith(".pdf"):
  514. try:
  515. from step1_parsing.table_extractor import (
  516. extract_tables_from_template_docx,
  517. )
  518. from step2_analysis.format_reconciler import reconcile_formats
  519. logger.info("开始格式比对...")
  520. template_tables = extract_tables_from_template_docx(template_path)
  521. # 招标表格统一使用提示词+代码方式提取结果(Step 1/Step 2 产物),
  522. # 不再用纯结构提取重复生成,保证名称/分类与 LLM 识别一致。
  523. tender_tables = list(analysis.tender_tables or [])
  524. if not tender_tables and project_data.extracted_items:
  525. tender_tables = [
  526. it.source_table
  527. for it in project_data.extracted_items
  528. if it.source_table is not None
  529. ]
  530. if not tender_tables and not project_data.extracted_items:
  531. from step1_parsing import extract_tender_items_with_tables
  532. items, tender_tables = extract_tender_items_with_tables(
  533. tender_file=tender_pdf,
  534. )
  535. project_data.extracted_items = items
  536. analysis.tender_tables = tender_tables
  537. reconciliation_report = reconcile_formats(
  538. template_tables, tender_tables, use_llm=True,
  539. )
  540. logger.info(f"格式比对完成: {reconciliation_report.summary}")
  541. # 对模板 DOCX 执行表/函填充
  542. # 注意:此时的 doc 在 Step 6 才加载,这里先记录比对结果
  543. # 将 reconciliation 存入 project_data 的元数据中供 Step 6 使用
  544. project_data.metadata["reconciliation_report"] = reconciliation_report
  545. format_reconciled = True
  546. except Exception as e:
  547. logger.warning(f"格式比对失败,跳过表/函填充: {e}")
  548. else:
  549. # 非 PDF 招标文件(如 .docx/.md):无法做表格级格式比对。
  550. # 明确告警而不是静默跳过,避免占位符原样残留却无人知晓。
  551. logger.warning(
  552. f"招标文件不是 PDF({tender_pdf or '空'}),"
  553. f"跳过格式比对/表函替换;"
  554. f"模板表格仍由 Step 6 逻辑填充,招标表格式将不替换"
  555. )
  556. # ---- 4. 加载通用资料和参考 ----
  557. material_map: Dict[str, str] = {}
  558. for doc in project_data.general_materials:
  559. if doc.content:
  560. material_map[doc.file_name] = doc.content
  561. reference_texts: List[str] = []
  562. for doc in project_data.reference_bids:
  563. if doc.content:
  564. reference_texts.append(doc.content)
  565. # 招标相关文件全文(招标文件/采购需求/澄清公告),作为章节生成依据
  566. tender_texts: List[str] = []
  567. for doc in (
  568. list(project_data.tender_docs)
  569. + list(project_data.procurement_docs)
  570. + list(project_data.clarification_docs)
  571. ):
  572. if doc.content:
  573. tender_texts.append(doc.content)
  574. # ---- 4.1 项目信息净化(V2.6 新增)----
  575. # 通用资料/参考标书常来自历史项目,可能包含其他项目名称、场所、地址
  576. # (如"嘉定区行政服务中心、档案馆等物业管理服务项目")。若直接注入
  577. # LLM 提示词,生成内容会引用历史项目信息,造成内容污染。此处统一将
  578. # 其他项目标识替换为当前招标项目的名称/采购人/地址。
  579. from step4_writing.project_info_sanitizer import (
  580. discover_foreign_project_names,
  581. extract_current_project_address,
  582. is_achievement_material,
  583. sanitize_project_identity,
  584. )
  585. current_project_name = analysis.project_name or outline.project_name or ""
  586. current_venue = analysis.buyer_name or current_project_name
  587. current_address = extract_current_project_address(
  588. [d.content for d in project_data.tender_docs]
  589. + [d.content for d in project_data.procurement_docs]
  590. )
  591. foreign_names = discover_foreign_project_names(
  592. list(material_map.values()) + reference_texts,
  593. current_project_name,
  594. )
  595. if current_address:
  596. analysis.raw_text_snippets["project_address"] = current_address
  597. if foreign_names:
  598. logger.info(
  599. f" 参考资料中发现其他项目标识 {len(foreign_names)} 个: "
  600. f"{foreign_names[:5]}"
  601. )
  602. for fname, content in list(material_map.items()):
  603. preserve_achievements = is_achievement_material(fname)
  604. material_map[fname] = sanitize_project_identity(
  605. content,
  606. foreign_names,
  607. current_project_name,
  608. current_venue,
  609. current_address=current_address,
  610. replace_short_venue=not preserve_achievements,
  611. replace_address=not preserve_achievements,
  612. )
  613. injector = _ContentInjector(material_map, reference_texts, tender_texts)
  614. # ---- 5. 章节撰写(V2:先技术后商务 + 并发) ----
  615. # 本轮 Step 4 对每章重新建立内容来源。默认是生成/改写内容;只有明确把
  616. # 模板章节原文直接载入 generated_content 的分支才改为 True。旧 pickle
  617. # 没有该字段时由 Step 6 以有界比较兼容,不能再对整章做无界相似度计算。
  618. for chapter in outline.chapters:
  619. chapter.preserve_template_layout = False
  620. # 普通商务/技术章统一并发;依赖全书的非索引章延迟撰写;评标索引章
  621. # 只保留模板,直到 Step6 合并、检查并稳定分页后再构建行与页码。
  622. ordinary, deferred = _partition_step4_chapters(outline)
  623. index_chapters = [ch for ch in deferred if _is_evaluation_index_chapter(ch)]
  624. deferred = [ch for ch in deferred if not _is_evaluation_index_chapter(ch)]
  625. technical = [
  626. chapter for chapter in ordinary
  627. if chapter.chapter_type == ChapterType.TECHNICAL
  628. ]
  629. business = [
  630. chapter for chapter in ordinary
  631. if chapter.chapter_type == ChapterType.BUSINESS
  632. ]
  633. ordinary = technical + business
  634. logger.info(
  635. f"章节分组: 普通章节{len(ordinary)}章统一并发 "
  636. f"(技术{len(technical)} + 商务{len(ordinary) - len(technical)}) → "
  637. f"延迟依赖章节{len(deferred)}章;"
  638. f"评标索引章{len(index_chapters)}章仅保留模板"
  639. )
  640. for ch in index_chapters:
  641. template_text = (
  642. _get_template_text_for_chapter(template_structure, ch)
  643. if template_structure else ""
  644. )
  645. _defer_evaluation_index_chapter(
  646. ch, template_text, placeholder_map, company_info, analysis
  647. )
  648. ch.content_blocks[1]["entry_count"] = len(
  649. getattr(outline, "evaluation_index_entries", []) or []
  650. )
  651. logger.info(
  652. f" [{ch.id}] {ch.title}: Step4 跳过内容和页码生成,"
  653. f"保留 {len(outline.evaluation_index_entries)} 条最终索引映射"
  654. )
  655. # ---- 阶段 A: 普通技术章和商务章统一并发 ----
  656. failed_chapters: List[str] = []
  657. if ordinary:
  658. total_ordinary = len(ordinary)
  659. max_workers = min(cfg.max_concurrent_writers, total_ordinary)
  660. logger.info(
  661. f"阶段 A: 并发处理 {total_ordinary} 个普通章节 "
  662. f"(max_workers={max_workers})"
  663. )
  664. with ThreadPoolExecutor(max_workers=max_workers) as executor:
  665. future_to_chapter = {}
  666. for ch in ordinary:
  667. chapter_writer = _ChapterWriter()
  668. future = executor.submit(
  669. _write_single_chapter,
  670. chapter=ch,
  671. writer=chapter_writer,
  672. injector=injector,
  673. analysis=analysis,
  674. company_info=company_info,
  675. template_structure=template_structure,
  676. cfg=cfg,
  677. outline=outline,
  678. placeholder_map=placeholder_map,
  679. table_fill_planned=format_reconciled,
  680. )
  681. future_to_chapter[future] = ch
  682. completed = 0
  683. for future in as_completed(future_to_chapter):
  684. ch = future_to_chapter[future]
  685. completed += 1
  686. try:
  687. updated_chapter = future.result()
  688. _update_chapter_in_outline(outline, updated_chapter)
  689. words = len(updated_chapter.generated_content)
  690. logger.info(
  691. f" [{ch.id}] {ch.title[:30]}: {words:,} 字 "
  692. f"({completed}/{total_ordinary})"
  693. )
  694. except Exception as e:
  695. logger.exception(f" [{ch.id}] 撰写失败: {e}")
  696. failed_chapters.append(ch.id)
  697. ch.generated_content = f"【撰写失败: {e}】"
  698. # ---- 阶段 B: 延迟章节(完整上下文) ----
  699. if deferred:
  700. logger.info(f"阶段 B: 撰写 {len(deferred)} 个延迟章节(完整上下文)")
  701. deferred_writer = _ChapterWriter()
  702. for ch in deferred:
  703. template_text = ""
  704. if template_structure:
  705. template_text = _get_template_text_for_chapter(
  706. template_structure, ch
  707. )
  708. if template_text and ch.chapter_type == ChapterType.BUSINESS:
  709. ch.generated_content = template_text
  710. ch.preserve_template_layout = True
  711. logger.info(
  712. f" [{ch.id}] 延迟商务章,"
  713. f"已载入模板章节内容 {len(template_text):,} 字"
  714. )
  715. continue
  716. try:
  717. ch = deferred_writer.write_deferred_chapter(
  718. chapter=ch,
  719. analysis=analysis,
  720. outline=outline,
  721. template_text=template_text,
  722. company_info=company_info,
  723. tender_context=injector.get_tender_context(ch),
  724. reference_context=injector.get_reference_context(ch),
  725. )
  726. words = len(ch.generated_content)
  727. logger.info(f" [{ch.id}] {ch.title[:30]}: {words:,} 字")
  728. except Exception as e:
  729. logger.exception(f" [{ch.id}] 延迟章节撰写失败: {e}")
  730. ch.generated_content = f"【撰写失败: {e}】"
  731. if failed_chapters:
  732. raise RuntimeError(
  733. f" {len(failed_chapters)} 个章节撰写失败: {failed_chapters}"
  734. )
  735. # ---- 阶段 D: 统一解析章节文本中的已知占位符 ----
  736. chapter_placeholder_map = build_placeholder_map(
  737. analysis, company_info, project_data
  738. )
  739. for ch in outline.flatten():
  740. if _is_evaluation_index_chapter(ch):
  741. # 索引模板之外的任何内容均不得在 Step4 生成或改写。
  742. continue
  743. ch.generated_content = _resolve_chapter_text_placeholders(
  744. ch.generated_content,
  745. chapter_placeholder_map,
  746. )
  747. ch.generated_content = replace_bracket_placeholders(
  748. ch.generated_content,
  749. company_info=company_info,
  750. analysis=analysis,
  751. )
  752. # 不再用“正文前 12000 字”的 LLM 返回结果覆盖整章;该旧路径会截断
  753. # 长章节并覆盖前序章节策略。这里只做可重复执行的确定性事实修正。
  754. ch.generated_content = _override_rejection_conflicts(
  755. ch.generated_content, analysis, tender_texts
  756. )
  757. # 确保废标项内容至少在本章正文中出现一次,而不是只出现在最终清单
  758. ch.generated_content = _append_missing_rejection_responses(
  759. ch, analysis
  760. )
  761. # 去除章节内重复出现的超长段落(如同一段"物联网+设施设备监测子系统"
  762. # 文字被 LLM 在不同小节重复输出),并做最终字数保险裁剪
  763. ch.generated_content = _dedupe_repeated_blocks(ch.generated_content)
  764. is_template_derived = getattr(ch, "from_template", False) or any(
  765. getattr(c, "from_template", False)
  766. for c in (ch.children or [])
  767. )
  768. if ch.word_count_target > 0 and not is_template_derived:
  769. from step4_writing.chapter_writer import trim_to_word_budget
  770. ch.generated_content = trim_to_word_budget(
  771. ch.generated_content, ch.word_count_target, tolerance=1.25
  772. )
  773. # ---- 阶段 D0.5: 模板内嵌附件(制度/预案等)用通用材料全文回填 ----
  774. # 模板第四章区域夹着"(五)安全运行管理制度 第一章~第十章"等附件块,
  775. # 技术章正文替换时这些旧文本会被删除。此处检测各章模板区域内的内嵌
  776. # 附件 section,用匹配的通用材料(如 管理制度.md)全文替换到章末,
  777. # 不做摘要;找不到匹配材料才保留模板原文,避免附件内容消失。
  778. try:
  779. _chapter_ctx = {}
  780. for _ch in outline.chapters:
  781. if _ch.chapter_type == ChapterType.TECHNICAL:
  782. _chapter_ctx[_ch.id] = {
  783. "tender": injector.get_tender_context(_ch),
  784. "reference": injector.get_reference_context(_ch),
  785. }
  786. _append_template_attachments_with_materials(
  787. outline, template_structure, material_map, _chapter_ctx
  788. )
  789. except Exception as e:
  790. logger.warning(f"模板内嵌附件材料回填失败: {e}")
  791. # ---- 阶段 D1: 生成内容项目信息净化(兜底,V2.6)----
  792. # 即使材料已净化,LLM 仍可能从参考标书等处引入历史项目名称/场所,
  793. # 此处对最终章节内容做确定性替换(仅替换项目全名与场所全名,不替换
  794. # 场所短名/地址,避免误伤"类似业绩"中的历史客户名单)。
  795. if foreign_names:
  796. replaced_total = 0
  797. for ch in outline.flatten():
  798. cleaned = sanitize_project_identity(
  799. ch.generated_content,
  800. foreign_names,
  801. current_project_name,
  802. current_venue,
  803. replace_short_venue=False,
  804. replace_address=False,
  805. )
  806. if cleaned != ch.generated_content:
  807. replaced_total += 1
  808. ch.generated_content = cleaned
  809. if replaced_total:
  810. logger.info(
  811. f" 生成内容项目信息净化: {replaced_total} 个章节完成替换"
  812. )
  813. # 用户授权的唯一正文重写/子标题扩展特例,在通用裁剪之后执行。
  814. from step4_writing.difficulty_rewrite import rewrite_difficulty_sections
  815. rewrite_difficulty_sections(outline, analysis, project_data)
  816. # ---- 阶段 E: 全部章节统一落盘;完成后才允许返回给审核/导出 ----
  817. if cfg.save_chapter_docs:
  818. _persist_all_chapter_docs(
  819. outline,
  820. chapter_output_dir or cfg.output_dir,
  821. template_path=template_path,
  822. placeholder_map=chapter_placeholder_map,
  823. company_info=company_info,
  824. analysis=analysis,
  825. project_data=project_data,
  826. )
  827. # ---- 字数汇总 ----
  828. total_words = sum(len(c.generated_content) for c in outline.chapters)
  829. logger.info(f"撰写完成: 总字数 {total_words:,} / 目标 {cfg.min_word_count:,}")
  830. return outline
  831. def _write_single_chapter(
  832. chapter: Chapter,
  833. writer: "_ChapterWriter",
  834. injector: "_ContentInjector",
  835. analysis: TenderAnalysis,
  836. company_info: CompanyInfo,
  837. template_structure: Optional[TemplateStructure],
  838. cfg,
  839. outline: BidOutline,
  840. placeholder_map: Dict[str, str],
  841. table_fill_planned: bool = False,
  842. ) -> Chapter:
  843. """在独立线程中撰写单个章节(供 ThreadPoolExecutor 使用)
  844. 这是一个纯函数:接收 Chapter 输入,返回填充后的 Chapter。
  845. 不访问任何共享可变状态。
  846. Args:
  847. chapter: 待撰写的章节
  848. writer: 独立的 _ChapterWriter 实例(每线程一个)
  849. injector: _ContentInjector(只读,可共享)
  850. analysis: 招标分析结果(只读)
  851. company_info: 公司信息(只读)
  852. template_structure: 模板结构(只读)
  853. cfg: 配置(只读)
  854. Returns:
  855. 填充了 generated_content 的 Chapter
  856. """
  857. ch_id = chapter.id
  858. logger.debug(f" [{ch_id}] 线程开始...")
  859. # 获取上下文
  860. scoring_context = injector.get_scoring_context(chapter, analysis)
  861. # 需求理解章节完整保留模板原文,仅以相关评分项、招标要求和采购需求
  862. # 驱动授权评分小节的增补正文。
  863. if "需求理解" in (chapter.title or ""):
  864. understanding = [
  865. c for c in analysis.scoring_criteria
  866. if (
  867. ("需求理解" in (c.name or ""))
  868. or ("需求理解" in (c.category or ""))
  869. or (c.id in (chapter.related_criteria or []))
  870. )
  871. ]
  872. if understanding:
  873. criteria_lines = "\n".join(
  874. f"- 【{c.id}】{c.name}({c.max_score}分):{c.description}"
  875. for c in understanding
  876. )
  877. unique_req = (
  878. "\n\n【需求理解专项要求】本章必须围绕以下评分项内容,"
  879. "结合本项目实际背景、服务对象、采购需求与难点,"
  880. "展开独特、有针对性的需求理解与分析。模板原文不得删除或改写,"
  881. "只撰写 Step3 已授权的评分小节,且新增内容必须能追溯到评分项、"
  882. "与需求理解相关的废标项、招标要求或采购需求,"
  883. "不得照搬模板通用表述:\n"
  884. f"{criteria_lines}"
  885. )
  886. scoring_context = (scoring_context or "") + unique_req
  887. rejection_context = injector.get_rejection_context(chapter, analysis)
  888. response_context = injector.get_response_context(chapter, analysis)
  889. material_context = injector.get_material_context(chapter)
  890. reference_context = injector.get_reference_context(chapter)
  891. tender_context = injector.get_tender_context(chapter)
  892. # 模板原文
  893. template_text = ""
  894. if template_structure:
  895. template_text = _get_template_text_for_chapter(
  896. template_structure, chapter
  897. )
  898. if template_text:
  899. logger.debug(f" [{ch_id}] 找到模板原文 {len(template_text):,} 字符")
  900. else:
  901. logger.debug(f" [{ch_id}] 模板中未找到对应章节,将从零生成")
  902. # 模板章统一采用“模板基块 → 项目/表格填充计划 → 直接绑定补充块”。
  903. # 不把整章或全部模板子节交给 LLM 重写,因此原生表格、图片、签名及
  904. # 相邻说明的 XML 顺序仍由 Step6 模板装配路径保持。
  905. if template_text:
  906. filled_template = _resolve_chapter_text_placeholders(
  907. template_text, placeholder_map
  908. )
  909. filled_template = replace_bracket_placeholders(
  910. filled_template,
  911. company_info=company_info,
  912. analysis=analysis,
  913. )
  914. chapter.generated_content = filled_template
  915. chapter.preserve_template_layout = True
  916. chapter.template_fill_completed = True
  917. chapter.content_blocks = [
  918. {
  919. "order": 0,
  920. "block_type": "template_base",
  921. "template_original_id": chapter.template_original_id,
  922. "template_chars": len(template_text),
  923. "project_fill_completed": True,
  924. },
  925. {
  926. "order": 1,
  927. "block_type": "native_table_plan",
  928. "action": (
  929. "apply_reconciliation_then_authorized_fill"
  930. if table_fill_planned
  931. else "preserve_structure_and_fill_authorized_cells"
  932. ),
  933. },
  934. ]
  935. supplement_count = _generate_direct_content_blocks(
  936. chapter, outline, analysis, injector, writer
  937. )
  938. logger.info(
  939. f" [{ch_id}] 模板基块 {len(filled_template):,} 字,"
  940. f"直接补充块 {supplement_count} 个"
  941. )
  942. return chapter
  943. has_direct_entries = any(
  944. str(entry.get("final_heading_id", "")).split(".", 1)[0] == str(chapter.id)
  945. for entry in list(getattr(outline, "evaluation_index_entries", []) or [])
  946. )
  947. if has_direct_entries:
  948. supplement_count = _generate_direct_content_blocks(
  949. chapter, outline, analysis, injector, writer
  950. )
  951. parts = []
  952. for node in _walk_nodes(chapter):
  953. if node is chapter:
  954. continue
  955. if node.supplement_content:
  956. parts.append(f"{node.title}\n{node.supplement_content}")
  957. if chapter.supplement_content:
  958. parts.insert(0, chapter.supplement_content)
  959. chapter.generated_content = "\n\n".join(parts).strip()
  960. chapter.template_fill_completed = True
  961. chapter.content_blocks = [{
  962. "order": 0,
  963. "block_type": "generated_scoring_chapter",
  964. "supplement_nodes": supplement_count,
  965. }]
  966. return chapter
  967. # 撰写
  968. chapter = writer.write_chapter(
  969. chapter=chapter,
  970. analysis=analysis,
  971. scoring_context=scoring_context,
  972. material_context=material_context,
  973. reference_context=reference_context,
  974. template_text=template_text,
  975. company_info=company_info,
  976. tender_context=tender_context,
  977. rejection_context=rejection_context,
  978. response_context=response_context,
  979. )
  980. # 字数不足时扩展(最多补到目标字数,不超发);
  981. # 字数超出目标 120% 时在 write_chapter 内已裁剪,此处仅做最终保险
  982. words = len(chapter.generated_content)
  983. is_template_derived = any(
  984. getattr(c, "from_template", False)
  985. for c in (chapter.children or [])
  986. )
  987. if (
  988. chapter.word_count_target > 0
  989. and not is_template_derived
  990. and cfg.enforce_word_limit
  991. and words < chapter.word_count_target * 0.9
  992. ):
  993. deficit = chapter.word_count_target - words
  994. logger.debug(f" [{ch_id}] 字数不足,扩展 {deficit:,} 字...")
  995. try:
  996. chapter = writer.expand_chapter(chapter, deficit)
  997. except Exception as e:
  998. logger.warning(f" [{ch_id}] 扩展失败: {e}")
  999. words_after = len(chapter.generated_content)
  1000. if (
  1001. chapter.word_count_target > 0
  1002. and not is_template_derived
  1003. and words_after > chapter.word_count_target * 1.2
  1004. ):
  1005. from step4_writing.chapter_writer import trim_to_word_budget
  1006. chapter.generated_content = trim_to_word_budget(
  1007. chapter.generated_content, chapter.word_count_target
  1008. )
  1009. return chapter
  1010. def _update_chapter_in_outline(outline: BidOutline, updated: Chapter) -> None:
  1011. """按 chapter.id 在 outline 中定位并替换章节(线程安全)
  1012. 避免依赖列表索引(并发时索引可能错乱)。
  1013. 若 outline 中存在重复 id(step3 缺陷),只替换与传入对象同 id
  1014. 的那一个槽位,防止并发完成的多个章节相互覆盖。
  1015. """
  1016. matched = None
  1017. for i, ch in enumerate(outline.chapters):
  1018. if ch.id == updated.id:
  1019. if ch is updated:
  1020. matched = i
  1021. break
  1022. if matched is None:
  1023. matched = i
  1024. if matched is not None:
  1025. outline.chapters[matched] = updated
  1026. return
  1027. # 未找到(不应发生)
  1028. logger.warning(f"章节 [{updated.id}] 未在 outline 中找到,无法更新")
  1029. def _resolve_chapter_text_placeholders(
  1030. content: str,
  1031. placeholder_map: Dict[str, str],
  1032. ) -> str:
  1033. """在章节落盘前替换已知的 %%...%% 项目/公司占位符。"""
  1034. result = content or ""
  1035. for key, value in placeholder_map.items():
  1036. if not value:
  1037. continue
  1038. if key in ("服务内容", "服务要求", "服务期限"):
  1039. value = re.sub(r"[。;;]+$", "", value.strip()) + "。"
  1040. pattern = re.compile(
  1041. rf"%{{1,3}}\s*{re.escape(key)}\s*%{{1,3}}"
  1042. )
  1043. result = pattern.sub(lambda _m: value, result)
  1044. # 模板常在占位符后自带句号;来源字段又以句号结尾时,直接替换会产生
  1045. # “。。。”,这里统一收敛为单句号。
  1046. return re.sub(r"。{2,}", "。", result)
  1047. _NUM_VALUE_PATTERN = (
  1048. r"\d+(?:\.\d+)?\s*(?:%|人|名|年|个月|月|天|日|小时|分钟|分|元|万元|台|套|次|个)?"
  1049. )
  1050. _DYNAMIC_METRIC_PATTERN = re.compile(
  1051. r"([\u4e00-\u9fff]{2,16}(?:率|数量|人数|期限|时间|违约金|罚款|金额|比例|面积|岗位|设备|单价|费用|标准|人次|年限))"
  1052. r"[^,。;\n]{0,25}?"
  1053. + _NUM_VALUE_PATTERN
  1054. )
  1055. _FIXED_NUMERIC_RULES = [
  1056. (
  1057. "流动率",
  1058. re.compile(r"(?:人员)?流动率[^,。;\n]{0,20}?\d+(?:\.\d+)?%"),
  1059. re.compile(r"(?:人员)?流动率[^,。;\n]{0,30}?\d+(?:\.\d+)?%"),
  1060. ),
  1061. ]
  1062. def _build_dynamic_numeric_rules(source_texts):
  1063. """从废标项/采购需求/服务要求中动态提取数值型约束规则。"""
  1064. rules = []
  1065. seen_metrics = set()
  1066. for text in source_texts:
  1067. for match in _DYNAMIC_METRIC_PATTERN.finditer(text or ""):
  1068. metric = match.group(1).strip()
  1069. canonical = match.group(0).strip()
  1070. metric = re.sub(
  1071. r"^(本项目|本项目的|供应商|投标人|我公司)[的]?",
  1072. "",
  1073. metric,
  1074. )
  1075. if not metric or metric in seen_metrics:
  1076. continue
  1077. if len(metric) < 2:
  1078. continue
  1079. # canonical 必须从规范化后的指标名开始,否则替换目标中已有的
  1080. # “本项目的/供应商”等前缀会与来源前缀叠加,重复执行后不断增长。
  1081. metric_pos = canonical.find(metric)
  1082. if metric_pos >= 0:
  1083. canonical = canonical[metric_pos:]
  1084. seen_metrics.add(metric)
  1085. conflict_re = re.compile(
  1086. re.escape(metric) + r"[^,。;\n]{0,30}?" + _NUM_VALUE_PATTERN
  1087. )
  1088. rules.append({
  1089. "metric": metric,
  1090. "canonical": canonical,
  1091. "conflict_re": conflict_re,
  1092. })
  1093. return rules
  1094. _REQUIREMENT_ALIGN_PROMPT = """你是投标文件一致性修正专家。下方提供一份章节正文和权威要求来源。
  1095. 请仅修正正文中与权威要求冲突的事实性内容,例如数值、比例、期限、人数、金额、证书、资质等。
  1096. 必须保留原文结构、标题、表格框架和其他无关内容。
  1097. 输出完整修正后的正文,不要输出任何解释、前缀或后缀。"""
  1098. def _llm_apply_requirement_alignment(
  1099. content: str,
  1100. analysis: TenderAnalysis,
  1101. source_texts: Optional[List[str]] = None,
  1102. ) -> str:
  1103. """使用 LLM 修正正文与招标/采购/废标要求冲突的内容。"""
  1104. if not content or analysis is None:
  1105. return content
  1106. sources = [
  1107. item.description or ""
  1108. for item in (analysis.rejection_items or [])
  1109. ]
  1110. if analysis.service_requirements:
  1111. sources.append(analysis.service_requirements)
  1112. if analysis.qualification_requirements:
  1113. sources.append(analysis.qualification_requirements)
  1114. if analysis.procurement_requirements:
  1115. sources.append(analysis.procurement_requirements)
  1116. for extra in (source_texts or []):
  1117. if extra:
  1118. sources.append(extra)
  1119. sources = [x for x in sources if x.strip()]
  1120. if not sources:
  1121. return content
  1122. try:
  1123. from llm_client import LLMClient
  1124. llm = LLMClient()
  1125. user_prompt = (
  1126. "## 章节正文\n"
  1127. f"{content[:12000]}\n\n"
  1128. "## 权威要求来源\n"
  1129. + "\n\n".join(x[:2500] for x in sources[:8])
  1130. )
  1131. corrected = llm.generate(
  1132. system_prompt=_REQUIREMENT_ALIGN_PROMPT,
  1133. user_prompt=user_prompt,
  1134. max_tokens=32768,
  1135. temperature=0.2,
  1136. )
  1137. cleaned = clean_ai_commentary(corrected.strip())
  1138. if cleaned and len(cleaned) >= len(content) * 0.4:
  1139. return cleaned
  1140. except Exception as e:
  1141. logger.warning(f"LLM 要求一致性修正失败: {e}")
  1142. return content
  1143. def _override_rejection_conflicts(
  1144. content: str,
  1145. analysis: TenderAnalysis,
  1146. source_texts: Optional[List[str]] = None,
  1147. ) -> str:
  1148. """用评分/废标/招标/采购需求中的数值约束覆盖正文或模板冲突。"""
  1149. if not content or analysis is None:
  1150. return content
  1151. result = content
  1152. sources = [
  1153. item.description or ""
  1154. for item in (analysis.rejection_items or [])
  1155. ]
  1156. sources.extend(
  1157. item.description or ""
  1158. for item in (analysis.scoring_criteria or [])
  1159. )
  1160. if analysis.service_requirements:
  1161. sources.append(analysis.service_requirements)
  1162. if analysis.qualification_requirements:
  1163. sources.append(analysis.qualification_requirements)
  1164. if analysis.procurement_requirements:
  1165. sources.append(analysis.procurement_requirements)
  1166. for extra in (source_texts or []):
  1167. if extra:
  1168. sources.append(extra)
  1169. dynamic_rules = _build_dynamic_numeric_rules(sources)
  1170. for rule in dynamic_rules:
  1171. result = rule["conflict_re"].sub(rule["canonical"], result)
  1172. for metric, canonical_re, conflict_re in _FIXED_NUMERIC_RULES:
  1173. for desc in sources:
  1174. if metric not in desc:
  1175. continue
  1176. canonical_match = canonical_re.search(desc)
  1177. if not canonical_match:
  1178. continue
  1179. canonical = canonical_match.group(0).strip()
  1180. result = conflict_re.sub(canonical, result)
  1181. break
  1182. return result
  1183. def _append_missing_rejection_responses(
  1184. chapter: Chapter,
  1185. analysis: TenderAnalysis,
  1186. ) -> str:
  1187. """兼容入口:废标要求已在写作提示词中正向转化,不再机械追加原文。
  1188. 旧实现会把“投标无效/废标”等负面条款逐字堆到章节末尾,既可能触发
  1189. 审核误报,也无法保证落到正确的模板小节。缺失覆盖由 Step5 按既有
  1190. 小节定向补充。
  1191. """
  1192. return chapter.generated_content or ""
  1193. def _dedupe_repeated_blocks(content: str) -> str:
  1194. """去除章节正文中重复出现的长文本块(LLM 常把同一材料写入多个小节)。
  1195. 规则:
  1196. - 单行正文(>=40 字)重复出现时,保留第一次,删除后续重复行;
  1197. - 连续两行组合重复出现时同样去重;
  1198. - 小节标题行(一、/(一)/1. 等)不去重(不同小节可能重名但结构不同)。
  1199. """
  1200. if not content:
  1201. return content
  1202. def norm(s: str) -> str:
  1203. return re.sub(r"\s+", "", s)
  1204. lines = content.split("\n")
  1205. seen_single = set()
  1206. seen_pair = set()
  1207. out = []
  1208. removed = 0
  1209. for i, line in enumerate(lines):
  1210. stripped = line.strip()
  1211. key = norm(stripped)
  1212. is_heading = bool(
  1213. re.match(r"^[一二三四五六七八九十百]+、", key)
  1214. or re.match(r"^([一二三四五六七八九十百]+)", key)
  1215. or re.match(r"^[((]\d+[))]", key)
  1216. or re.match(r"^\d+[\.、))]", key)
  1217. )
  1218. if (
  1219. not is_heading
  1220. and len(key) >= 40
  1221. and key in seen_single
  1222. ):
  1223. removed += 1
  1224. continue
  1225. if len(key) >= 40:
  1226. seen_single.add(key)
  1227. # 连续两行组合去重(针对被换行拆开的重复段落)
  1228. if i >= 1 and not is_heading:
  1229. prev_key = norm(lines[i - 1].strip())
  1230. if prev_key:
  1231. pair = prev_key + "||" + key
  1232. if pair in seen_pair and len(prev_key) + len(key) >= 60:
  1233. removed += 1
  1234. continue
  1235. if prev_key:
  1236. seen_pair.add(pair)
  1237. out.append(line)
  1238. if removed:
  1239. logger.info(f" 章节正文重复块去重: 删除 {removed} 行")
  1240. return "\n".join(out)
  1241. def _clean_chapter_title(name: str) -> str:
  1242. """去掉"第X章"前缀,返回干净的章节标题"""
  1243. return re.sub(
  1244. r"^第\s*[一二三四五六七八九十百\d]+\s*章[\s::、]*", "", name or ""
  1245. ).strip()
  1246. _ATTACHMENT_MATERIAL_MATCH_PROMPT = """你是投标文件材料匹配专家。模板正文中夹有一段附件内容
  1247. (通常是"管理制度/预案/方案"等),需要从通用材料清单中选出与它内容最匹配的一份,
  1248. 用其全文替换附件区。
  1249. 输出严格 JSON:{"material": "材料文件名或null", "label": "附件标题", "reason": "一句话理由"}
  1250. 规则:
  1251. 1. 依据附件块章节标题与每份材料的内容开头判断,选择章节结构最吻合、覆盖最全的材料;
  1252. 2. material 必须是清单中存在的文件名;若清单中没有匹配项,输出 null;
  1253. 3. label 给出附件标题(简短,如"安全运行管理制度"/"职业健康管理制度"),
  1254. 从附件块章节标题归纳,不含"附:"前缀;
  1255. 4. 只输出 JSON。"""
  1256. def _llm_pick_attachment_material(
  1257. embedded_names: list,
  1258. material_map: Dict[str, str],
  1259. ) -> Tuple[str, str]:
  1260. """用 LLM 选出与附件块最匹配的材料与附件标题,返回 (材料文件名, 附件标题)"""
  1261. try:
  1262. from llm_client import LLMClient
  1263. lines = ["## 附件块章节标题"]
  1264. for name in embedded_names:
  1265. lines.append(f"- {name}")
  1266. lines.append("\n## 通用材料清单")
  1267. for fname, content in sorted(material_map.items()):
  1268. preview = " ".join((content or "").split())[:150]
  1269. lines.append(
  1270. f"- {fname}(约 {len(content or ''):,} 字)开头:{preview}"
  1271. )
  1272. llm = LLMClient()
  1273. result = llm.extract_json(
  1274. system_prompt=_ATTACHMENT_MATERIAL_MATCH_PROMPT,
  1275. user_prompt="\n".join(lines),
  1276. max_tokens=8192,
  1277. )
  1278. if isinstance(result, dict):
  1279. name = str(result.get("material") or "").strip()
  1280. if name not in material_map:
  1281. name = ""
  1282. label = str(result.get("label") or "").strip()
  1283. return name, label
  1284. except Exception as e:
  1285. logger.warning(f"附件材料 LLM 匹配失败(回退模板原文): {e}")
  1286. return "", ""
  1287. _ATTACHMENT_GENERATE_PROMPT = """你是投标文件附件撰写专家。请根据以下材料撰写一份完整的
  1288. 附件正文(如管理制度/预案/方案等),用于投标文件附录。
  1289. 要求:
  1290. 1. 以"模板附件原文"为结构基准(章节顺序、编号体系),完整覆盖其中所有章节;
  1291. 2. 招标文件相关要求必须体现;参考投书内容仅作风格与写法参考;
  1292. 3. 篇幅控制在模板篇幅的 1~1.3 倍以内,按模板章节与条目完整覆盖;
  1293. 在模板内容基础上仅做必要润色与本项目适配,不新增条目、不展开解释性
  1294. 扩写;超过模板篇幅 1.3 倍视为不合格;也不要摘要化(须给出完整正文);
  1295. 4. 直接输出附件正文,无前缀无后缀。"""
  1296. def _llm_generate_attachment(
  1297. chapter: Chapter,
  1298. template_attachment_text: str,
  1299. tender_context: str,
  1300. reference_context: str,
  1301. ) -> str:
  1302. """基于模板附件原文 + 招标要求 + 参考投书片段(三源)生成附件正文。"""
  1303. try:
  1304. from llm_client import LLMClient
  1305. target = len(template_attachment_text or "")
  1306. segments = [
  1307. f"附件所属章节:第{chapter.id}章 {chapter.title}",
  1308. ]
  1309. if template_attachment_text:
  1310. segments.append(
  1311. f"## 模板附件原文(结构基准,约 {target:,} 字)\n"
  1312. f"模板篇幅约 {target:,} 字,附件正文请控制在 "
  1313. f"{int(target * 1.0):,}~{int(target * 1.3):,} 字以内。\n"
  1314. f"{template_attachment_text}"
  1315. )
  1316. if tender_context:
  1317. segments.append(
  1318. f"## 招标文件相关要求(必须体现)\n{tender_context[:5000]}"
  1319. )
  1320. if reference_context:
  1321. segments.append(
  1322. f"## 参考投书相关片段(风格参考)\n{reference_context[:3000]}"
  1323. )
  1324. llm = LLMClient()
  1325. content = llm.generate(
  1326. system_prompt=_ATTACHMENT_GENERATE_PROMPT,
  1327. user_prompt="\n\n".join(segments),
  1328. max_tokens=32768,
  1329. temperature=0.3,
  1330. )
  1331. cleaned = clean_ai_commentary(content.strip())
  1332. return cleaned
  1333. except Exception as e:
  1334. logger.warning(f"附件正文 LLM 生成失败: {e}")
  1335. return ""
  1336. def _append_template_attachments_with_materials(
  1337. outline: "BidOutline",
  1338. template_structure: Optional[TemplateStructure],
  1339. material_map: Dict[str, str],
  1340. chapter_contexts: Optional[Dict[str, Dict[str, str]]] = None,
  1341. ) -> int:
  1342. """把模板章节区域内夹着的内嵌附件(制度等)用通用材料全文回填到章末。
  1343. 附件区检测为纯结构判断:当前真实章到下一真实章之间、且不属于大纲的
  1344. "第X章" section;附件正文用 LLM 基于"模板附件原文 + 招标要求 + 参考投书片段"
  1345. 生成(通用材料仅作补充参考),篇幅与模板附件原文相当,不全文照搬。
  1346. 返回回填的章节数。
  1347. """
  1348. if not template_structure or not outline:
  1349. return 0
  1350. sections = list(template_structure.sections)
  1351. if not sections:
  1352. return 0
  1353. # 大纲真实章标题(去"第X章"前缀)
  1354. real_titles = set()
  1355. for ch in outline.chapters:
  1356. t = _clean_chapter_title(ch.title)
  1357. if t:
  1358. real_titles.add(t)
  1359. if not real_titles:
  1360. return 0
  1361. chapter_re = re.compile(r"^第\s*[一二三四五六七八九十百\d]+\s*章")
  1362. filled = 0
  1363. for ch in outline.chapters:
  1364. if ch.chapter_type != ChapterType.TECHNICAL:
  1365. continue
  1366. if ch.preserve_template_layout:
  1367. # 模板原生附件、图片和签署上下文由 Step6 原样保留;Step4
  1368. # 不再把附件文本重复追加到模板基块末尾。
  1369. continue
  1370. own_name = getattr(ch, "template_section", "") or ""
  1371. own = next(
  1372. (s for s in sections if s.name.strip() == own_name.strip()),
  1373. None,
  1374. )
  1375. if own is None:
  1376. continue
  1377. # 下一真实章边界:own 之后第一个"第X章"且标题属于大纲真实章的 section
  1378. later = [
  1379. s for s in sections
  1380. if chapter_re.match(s.name)
  1381. and s.start_para > own.start_para
  1382. and _clean_chapter_title(s.name) in real_titles
  1383. ]
  1384. nxt = min(later, key=lambda s: s.start_para) if later else None
  1385. # 内嵌附件区:own 到下一真实章之间、标题不属于大纲真实章的"第X章" section
  1386. embedded = []
  1387. for s in sections:
  1388. if s.start_para <= own.start_para:
  1389. continue
  1390. if nxt is not None and s.end_para >= nxt.start_para:
  1391. continue
  1392. if (
  1393. chapter_re.match(s.name)
  1394. and _clean_chapter_title(s.name) not in real_titles
  1395. ):
  1396. embedded.append(s)
  1397. if not embedded:
  1398. continue
  1399. # 模板附件原文(结构基准)
  1400. template_attachment_text = "\n\n".join(
  1401. t
  1402. for s in embedded
  1403. for t in [
  1404. get_template_text_for_section(template_structure, s.name) or ""
  1405. ]
  1406. if t
  1407. )
  1408. ctx = (chapter_contexts or {}).get(ch.id, {})
  1409. tender_ctx = ctx.get("tender", "")
  1410. ref_ctx = ctx.get("reference", "")
  1411. # LLM 选出最匹配的通用材料,仅用于附件命名(内容生成只用三源)
  1412. best_name, attach_label = _llm_pick_attachment_material(
  1413. [s.name for s in embedded], material_map
  1414. )
  1415. generated = _llm_generate_attachment(
  1416. ch,
  1417. template_attachment_text,
  1418. tender_ctx,
  1419. ref_ctx,
  1420. )
  1421. if generated:
  1422. # 篇幅保险:LLM 扩写超过模板 1.4 倍时,直接采用模板附件原文
  1423. # (模板本身即完整依据,避免附件无限膨胀)
  1424. if (
  1425. template_attachment_text
  1426. and len(generated) > len(template_attachment_text) * 1.4
  1427. ):
  1428. logger.warning(
  1429. f" 附件生成 [{ch.id}] 篇幅 {len(generated):,} 字超过模板基准 "
  1430. f"{len(template_attachment_text):,} 字的 1.4 倍,改用模板原文"
  1431. )
  1432. generated = template_attachment_text
  1433. label = (
  1434. attach_label
  1435. or (
  1436. best_name.replace(".md", "").replace(".txt", "")
  1437. if best_name
  1438. else ""
  1439. )
  1440. or "附件"
  1441. )
  1442. ch.generated_content += f"\n\n附:{label}\n{generated}"
  1443. logger.info(
  1444. f" 附件区回填 [{ch.id}] {ch.title}: LLM 基于模板/招标/参考生成 "
  1445. f"{label} ({len(generated):,} 字,模板基准 {len(template_attachment_text):,} 字)"
  1446. )
  1447. filled += 1
  1448. continue
  1449. # 兜底:LLM 无匹配或失败时保留模板内嵌附件原文
  1450. parts = []
  1451. for s in embedded:
  1452. txt = get_template_text_for_section(template_structure, s.name)
  1453. if txt:
  1454. parts.append(txt)
  1455. if parts:
  1456. ch.generated_content += "\n\n" + "\n\n".join(parts)
  1457. logger.info(
  1458. f" 附件区保留模板原文 [{ch.id}] {ch.title}: "
  1459. f"{sum(len(p) for p in parts):,} 字(附件生成失败,保留模板原文)"
  1460. )
  1461. filled += 1
  1462. return filled
  1463. def _persist_all_chapter_docs(
  1464. outline: BidOutline,
  1465. output_dir: str,
  1466. template_path: str = "",
  1467. placeholder_map: Optional[Dict[str, str]] = None,
  1468. company_info: Optional[CompanyInfo] = None,
  1469. analysis: Optional[TenderAnalysis] = None,
  1470. project_data: Optional[ProjectData] = None,
  1471. ) -> List[str]:
  1472. """统一保存所有顶层章节,并写 manifest 作为拼接前完整性屏障。
  1473. 章节目录按项目隔离,避免上一次未完成运行留下的局部文件与本次产物
  1474. 混在同一个目录中。
  1475. """
  1476. safe_project = re.sub(
  1477. r'[<>:"/\\|?*]+',
  1478. "_",
  1479. outline.project_name or "投标项目",
  1480. ).strip(" ._") or "投标项目"
  1481. chapter_dir = os.path.abspath(
  1482. os.path.join(output_dir, "chapters", safe_project)
  1483. )
  1484. os.makedirs(chapter_dir, exist_ok=True)
  1485. incomplete = []
  1486. for ch in outline.chapters:
  1487. content = ch.generated_content.strip()
  1488. if _is_deferred_evaluation_index(ch):
  1489. logger.info(
  1490. f" [{ch.id}] {ch.title}: 索引产物允许无正文,"
  1491. "等待最终合并、检查和稳定分页后构建"
  1492. )
  1493. continue
  1494. # 兜底:父章节内容为空但子章节有内容时,用子章节内容拼接
  1495. if not content and ch.children:
  1496. joined = "\n".join(
  1497. f"{c.title}\n{c.generated_content}"
  1498. for c in ch.children
  1499. if c.generated_content.strip()
  1500. ).strip()
  1501. if joined:
  1502. logger.warning(
  1503. f" [{ch.id}] {ch.title}: 父章节内容为空,已用子章节内容兜底"
  1504. )
  1505. ch.generated_content = joined
  1506. content = joined
  1507. if (
  1508. not content
  1509. or "【撰写失败" in content
  1510. or "【索引表生成失败" in content
  1511. ):
  1512. incomplete.append(f"{ch.id} {ch.title}")
  1513. logger.warning(
  1514. f" 章节不完整 [{ch.id}] {ch.title}: 长度={len(content)}, "
  1515. f"空={not content}, 含撰写失败={'【撰写失败' in content}, "
  1516. f"开头={content[:80]!r}"
  1517. )
  1518. if incomplete:
  1519. raise RuntimeError(
  1520. "章节产物不完整,停止审核与拼接: " + ", ".join(incomplete)
  1521. )
  1522. artifact_paths: List[str] = []
  1523. manifest_chapters = []
  1524. for order, chapter in enumerate(outline.chapters, start=1):
  1525. path = _save_chapter_docx(
  1526. chapter,
  1527. chapter_dir,
  1528. template_path=template_path,
  1529. placeholder_map=placeholder_map,
  1530. company_info=company_info,
  1531. analysis=analysis,
  1532. project_data=project_data,
  1533. )
  1534. chapter.artifact_path = path
  1535. artifact_paths.append(path)
  1536. manifest_chapters.append({
  1537. "order": order,
  1538. "id": chapter.id,
  1539. "title": chapter.title,
  1540. "chapter_type": chapter.chapter_type.value,
  1541. "content_chars": len(chapter.generated_content),
  1542. "artifact_path": path,
  1543. "status": (
  1544. "deferred_until_final_pagination"
  1545. if _is_deferred_evaluation_index(chapter)
  1546. else "complete"
  1547. ),
  1548. })
  1549. missing = [path for path in artifact_paths if not os.path.isfile(path)]
  1550. if missing:
  1551. raise RuntimeError(
  1552. "章节 DOCX 保存后校验失败: " + ", ".join(missing)
  1553. )
  1554. manifest_path = os.path.join(chapter_dir, "manifest.json")
  1555. with open(manifest_path, "w", encoding="utf-8") as f:
  1556. json.dump(
  1557. {
  1558. "project_name": outline.project_name,
  1559. "expected_count": len(outline.chapters),
  1560. "completed_count": len(artifact_paths),
  1561. "ready_for_assembly": True,
  1562. "final_outline": {
  1563. "project": outline.project_name,
  1564. "heading_mappings": outline.heading_mappings,
  1565. "evaluation_index_entries": outline.evaluation_index_entries,
  1566. } if outline.heading_mappings else None,
  1567. "chapters": manifest_chapters,
  1568. },
  1569. f,
  1570. ensure_ascii=False,
  1571. indent=2,
  1572. )
  1573. logger.info(
  1574. f"章节产物完整性校验通过: {len(artifact_paths)}/"
  1575. f"{len(outline.chapters)},目录: {chapter_dir}"
  1576. )
  1577. return artifact_paths
  1578. def _artifact_heading_key(text: str) -> str:
  1579. """用于把模板旧标题正文归入 Step3 最终节点,不用于重新判断层级。"""
  1580. value = strip_markdown_headings(text or "").strip()
  1581. value = re.sub(r"^第\s*[一二三四五六七八九十百\d]+\s*章\s*[::]?\s*", "", value)
  1582. value = re.sub(r"\s+", "", value)
  1583. return value.rstrip("::。;;")
  1584. def _split_artifact_content_by_outline(chapter: Chapter) -> Dict[str, List[str]]:
  1585. """按模板原标题定位正文,但只由最终大纲决定产物中的标题和顺序。"""
  1586. from step4_writing.difficulty_rewrite import MODE, split_sections
  1587. if any(n.content_generation_mode == MODE for n in _walk_nodes(chapter)):
  1588. return split_sections(chapter)
  1589. nodes = list(_walk_nodes(chapter))
  1590. buckets: Dict[str, List[str]] = {str(node.id): [] for node in nodes}
  1591. node_by_id = {str(node.id): node for node in nodes}
  1592. parent_by_id = {str(chapter.id): None}
  1593. for node in nodes:
  1594. for child in node.children or []:
  1595. parent_by_id.setdefault(str(child.id), str(node.id))
  1596. def _node_key(node) -> str:
  1597. for candidate in (node.template_original_title, node.title):
  1598. key = _artifact_heading_key(candidate)
  1599. if key:
  1600. return key
  1601. return ""
  1602. def _node_path(node) -> Tuple[str, ...]:
  1603. path = []
  1604. current = node
  1605. while current is not None:
  1606. key = _node_key(current)
  1607. if key:
  1608. path.append(key)
  1609. parent_id = parent_by_id.get(str(current.id))
  1610. current = node_by_id.get(parent_id) if parent_id else None
  1611. return tuple(reversed(path))
  1612. path_nodes: Dict[Tuple[str, ...], List[Chapter]] = {}
  1613. for node in nodes:
  1614. path_nodes.setdefault(_node_path(node), []).append(node)
  1615. current = chapter
  1616. heading_stack: List[Tuple[int, str, Chapter]] = [
  1617. (int(chapter.level or 1), _node_key(chapter), chapter)
  1618. ]
  1619. content = clean_ai_commentary(
  1620. strip_markdown_headings(chapter.generated_content or "")
  1621. )
  1622. for raw in content.splitlines():
  1623. line = raw.strip()
  1624. if not line:
  1625. if buckets[str(current.id)] and buckets[str(current.id)][-1] != "":
  1626. buckets[str(current.id)].append("")
  1627. continue
  1628. line_key = _artifact_heading_key(line)
  1629. prefix = [item_key for _level, item_key, _node in heading_stack]
  1630. matched = next(
  1631. (
  1632. candidate
  1633. for candidate in path_nodes.get(tuple(prefix + [line_key]), [])
  1634. if candidate is not current
  1635. ),
  1636. None,
  1637. )
  1638. if matched is not None:
  1639. current = matched
  1640. level = int(matched.level or 2)
  1641. while heading_stack and heading_stack[-1][0] >= level:
  1642. heading_stack.pop()
  1643. heading_stack.append((level, line_key, matched))
  1644. continue
  1645. buckets[str(current.id)].append(line)
  1646. return buckets
  1647. def _render_artifact_lines(doc, lines: List[str]) -> None:
  1648. """把某个最终标题节点的正文写入检查 DOCX,不从正文猜测新标题。"""
  1649. from docx.shared import Pt
  1650. i = 0
  1651. while i < len(lines):
  1652. line = lines[i].strip()
  1653. if not line:
  1654. i += 1
  1655. continue
  1656. if _is_markdown_table_row(line) and _has_separator_row(lines, i + 1):
  1657. table_lines, table_end = _extract_table_block(lines, i)
  1658. _render_table_block(doc, table_lines)
  1659. i = table_end
  1660. continue
  1661. para = doc.add_paragraph()
  1662. para.paragraph_format.line_spacing = 1.5
  1663. para.add_run(line).font.size = Pt(12)
  1664. i += 1
  1665. def _render_artifact_node(doc, node: Chapter, buckets: Dict[str, List[str]]) -> None:
  1666. """按 Step3 最终树递归写出 H2-H4、模板正文和直接补充块。"""
  1667. from docx.shared import Pt
  1668. level = max(2, min(int(node.level or 2), 4))
  1669. heading = doc.add_paragraph(node.title, style=f"Heading {level}")
  1670. for run in heading.runs:
  1671. run.font.size = Pt(16 if level == 2 else 14)
  1672. body_lines = list(buckets.get(str(node.id), []) or [])
  1673. if not body_lines and node.generated_content and not node.supplement_content:
  1674. body_lines = clean_ai_commentary(
  1675. strip_markdown_headings(node.generated_content)
  1676. ).splitlines()
  1677. _render_artifact_lines(doc, body_lines)
  1678. if node.supplement_content.strip():
  1679. _render_artifact_lines(doc, node.supplement_content.splitlines())
  1680. for child in node.children or []:
  1681. _render_artifact_node(doc, child, buckets)
  1682. _WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
  1683. _XML_NS = "http://www.w3.org/XML/1998/namespace"
  1684. def _w_tag(local: str) -> str:
  1685. return f"{{{_WORD_NS}}}{local}"
  1686. def _docx_xml_text(element) -> str:
  1687. return "".join(
  1688. node.text or "" for node in element.iter(_w_tag("t"))
  1689. ).strip()
  1690. def _docx_style_maps(styles_root) -> Tuple[Dict[str, int], Dict[int, str]]:
  1691. by_id: Dict[str, int] = {}
  1692. by_level: Dict[int, str] = {}
  1693. for style in styles_root.iter(_w_tag("style")):
  1694. style_id = style.get(_w_tag("styleId"), "")
  1695. name_node = style.find(_w_tag("name"))
  1696. name = name_node.get(_w_tag("val"), "") if name_node is not None else ""
  1697. level = 0
  1698. for value in (style_id, name):
  1699. match = re.match(r"^(?:heading|标题)\s*([1-4])$", value or "", re.I)
  1700. if match:
  1701. level = int(match.group(1))
  1702. break
  1703. if level:
  1704. by_id[style_id] = level
  1705. by_level.setdefault(level, style_id)
  1706. return by_id, by_level
  1707. def _docx_paragraph_level(paragraph, style_levels: Dict[str, int]) -> int:
  1708. p_pr = paragraph.find(_w_tag("pPr"))
  1709. if p_pr is None:
  1710. return 0
  1711. p_style = p_pr.find(_w_tag("pStyle"))
  1712. if p_style is None:
  1713. return 0
  1714. style_id = p_style.get(_w_tag("val"), "")
  1715. if style_id in style_levels:
  1716. return style_levels[style_id]
  1717. match = re.match(r"^(?:heading|标题)\s*([1-4])$", style_id or "", re.I)
  1718. return int(match.group(1)) if match else 0
  1719. def _replace_docx_paragraph_text(paragraph, text: str) -> None:
  1720. text_nodes = list(paragraph.iter(_w_tag("t")))
  1721. if not text_nodes:
  1722. run = ET.SubElement(paragraph, _w_tag("r"))
  1723. text_node = ET.SubElement(run, _w_tag("t"))
  1724. text_nodes = [text_node]
  1725. text_nodes[0].text = text
  1726. text_nodes[0].set(f"{{{_XML_NS}}}space", "preserve")
  1727. for node in text_nodes[1:]:
  1728. node.text = ""
  1729. def _fill_docx_paragraph_text(paragraph, text: str) -> None:
  1730. """只更新变化的文本范围,保留模板各 run 的格式和非文本节点。"""
  1731. from difflib import SequenceMatcher
  1732. nodes = list(paragraph.iter(_w_tag("t")))
  1733. if not nodes:
  1734. _replace_docx_paragraph_text(paragraph, text)
  1735. return
  1736. original = "".join(node.text or "" for node in nodes)
  1737. # 占位符作为整体匹配,避免值中的相同字符被匹配到占位符内部。
  1738. token_pattern = r"%%[^%\r\n]+%%|%[((][^%\r\n]+[))]%|[\s\S]"
  1739. before = re.findall(token_pattern, original)
  1740. after = re.findall(token_pattern, text)
  1741. offsets = [0]
  1742. for token in before:
  1743. offsets.append(offsets[-1] + len(token))
  1744. owners = [index for index, node in enumerate(nodes) for _ in (node.text or "")]
  1745. values = ["" for _ in nodes]
  1746. for kind, i, j, a, b in SequenceMatcher(None, before, after, autojunk=False).get_opcodes():
  1747. start, end = offsets[i], offsets[j]
  1748. if kind == "equal":
  1749. for pos in range(start, end):
  1750. values[owners[pos]] += original[pos]
  1751. elif kind in {"replace", "insert"}:
  1752. # 新值继承被替换范围起点的格式;末尾追加继承最后一个字符。
  1753. owner = owners[min(start, len(owners) - 1)] if owners else 0
  1754. values[owner] += "".join(after[a:b])
  1755. for node, value in zip(nodes, values):
  1756. node.text = value
  1757. node.set(f"{{{_XML_NS}}}space", "preserve")
  1758. def _new_docx_paragraph(text: str, style_id: str = ""):
  1759. paragraph = ET.Element(_w_tag("p"))
  1760. if style_id:
  1761. p_pr = ET.SubElement(paragraph, _w_tag("pPr"))
  1762. p_style = ET.SubElement(p_pr, _w_tag("pStyle"))
  1763. p_style.set(_w_tag("val"), style_id)
  1764. run = ET.SubElement(paragraph, _w_tag("r"))
  1765. text_node = ET.SubElement(run, _w_tag("t"))
  1766. text_node.set(f"{{{_XML_NS}}}space", "preserve")
  1767. text_node.text = text
  1768. return paragraph
  1769. def _native_chapter_number(chapter: Chapter) -> str:
  1770. if not chapter.id.isdigit():
  1771. return chapter.id
  1772. from step3_outlining.scoring_structure import _int_to_chinese
  1773. return _int_to_chinese(int(chapter.id))
  1774. def _template_chapter_number(chapter: Chapter) -> str:
  1775. value = (
  1776. chapter.template_chapter_id
  1777. or chapter.template_original_id
  1778. or chapter.id
  1779. )
  1780. return str(value).split(".", 1)[0]
  1781. _TABLE_PLACEHOLDER_TYPES = {
  1782. "资格条件响应表": "qualification_response",
  1783. "实质性要求响应表": "substantive_response",
  1784. "报价汇总表": "bid_summary_table",
  1785. "报价明细表": "bid_detail_table",
  1786. }
  1787. def _table_placeholder_key(text: str) -> str:
  1788. match = re.fullmatch(r"\s*%%\s*([^%\n]+?)\s*%%\s*", text or "")
  1789. return match.group(1).strip() if match else ""
  1790. def _normalized_table_name(value: str) -> str:
  1791. return re.sub(r"[\s\d.、::()()《》]", "", value or "").replace("格式", "")
  1792. def _table_type_for_placeholder(key: str) -> str:
  1793. normalized = _normalized_table_name(key)
  1794. for name, table_type in _TABLE_PLACEHOLDER_TYPES.items():
  1795. known = _normalized_table_name(name)
  1796. if known in normalized or normalized in known:
  1797. return table_type
  1798. return ""
  1799. def _looks_like_table_placeholder(key: str) -> bool:
  1800. return bool(
  1801. _table_type_for_placeholder(key)
  1802. or re.search(r"表|清单|一览|汇总|明细|台账|台帐", key or "")
  1803. )
  1804. def _table_identity_matches(key: str, table: Any) -> bool:
  1805. """按表名身份匹配任意表占位符,标准类型只作为辅助证据。"""
  1806. normalized_key = _normalized_table_name(key)
  1807. if not normalized_key:
  1808. return False
  1809. for value in (
  1810. getattr(table, "title_hint", ""),
  1811. getattr(table, "caption", ""),
  1812. ):
  1813. normalized_value = _normalized_table_name(value)
  1814. if not normalized_value:
  1815. continue
  1816. if normalized_key == normalized_value:
  1817. return True
  1818. if min(len(normalized_key), len(normalized_value)) >= 4 and (
  1819. normalized_key in normalized_value or normalized_value in normalized_key
  1820. ):
  1821. return True
  1822. expected_type = _table_type_for_placeholder(key)
  1823. return bool(
  1824. expected_type
  1825. and getattr(table, "table_type", "") == expected_type
  1826. )
  1827. def _select_placeholder_table(
  1828. key: str,
  1829. analysis: Optional[TenderAnalysis],
  1830. project_data: Optional[ProjectData],
  1831. ) -> Any:
  1832. """按招标文件 > 采购需求 > 模板 > 参考投书选择获授权表格。
  1833. 格式协调结果只能在来源表类型仍与占位符精确一致时作为候选,禁止
  1834. 仅因列数/表头相似就把评分表等其他表格替换到资格响应表位置。
  1835. """
  1836. expected_type = _table_type_for_placeholder(key)
  1837. normalized_key = _normalized_table_name(key)
  1838. tender_paths = {
  1839. os.path.abspath(doc.file_path)
  1840. for doc in list(getattr(project_data, "tender_docs", []) or [])
  1841. if getattr(doc, "file_path", "")
  1842. }
  1843. procurement_paths = {
  1844. os.path.abspath(doc.file_path)
  1845. for doc in list(getattr(project_data, "procurement_docs", []) or [])
  1846. if getattr(doc, "file_path", "")
  1847. }
  1848. reference_tables = list(getattr(project_data, "reference_tables", []) or [])
  1849. def source_rank(table: Any) -> int:
  1850. source_file = str(getattr(table, "source_file", "") or "")
  1851. absolute_source = os.path.abspath(source_file) if source_file else ""
  1852. source_type = str(getattr(table, "source_type", "") or "").lower()
  1853. if any(table is reference for reference in reference_tables):
  1854. return 3
  1855. if absolute_source in tender_paths or "tender" in source_type:
  1856. return 0
  1857. if absolute_source in procurement_paths or "procurement" in source_type:
  1858. return 1
  1859. if "template" in source_type:
  1860. return 2
  1861. if "reference" in source_type:
  1862. return 3
  1863. # Step1 旧缓存的招标表可能缺少来源标记;它们仍属于 analysis.tender_tables,
  1864. # 排在模板/参考投书之前,但不会越过路径明确的招标文件。
  1865. return 1
  1866. def name_rank(table: Any) -> int:
  1867. return 0 if _table_identity_matches(key, table) else 1
  1868. candidates = [
  1869. table for table in list(getattr(analysis, "tender_tables", []) or [])
  1870. if _table_identity_matches(key, table)
  1871. ]
  1872. report = (
  1873. (project_data.metadata or {}).get("reconciliation_report")
  1874. if project_data is not None else None
  1875. )
  1876. for rec in list(getattr(report, "reconciliations", []) or []):
  1877. section_name = _normalized_table_name(getattr(rec, "section_name", ""))
  1878. template_table = getattr(rec, "template_table", None)
  1879. section_type = getattr(template_table, "table_type", "")
  1880. if not (
  1881. (normalized_key and (normalized_key in section_name or section_name in normalized_key))
  1882. or (expected_type and section_type == expected_type)
  1883. ):
  1884. continue
  1885. decision = getattr(getattr(rec, "decision", None), "value", "")
  1886. if decision != "replace_with_tender":
  1887. continue
  1888. source = (
  1889. getattr(rec, "replace_with_tender_table", None)
  1890. or getattr(rec, "best_match", None)
  1891. )
  1892. if source is None:
  1893. continue
  1894. if not _table_identity_matches(key, source):
  1895. logger.warning(
  1896. f" %%{key}%% 忽略格式协调误配: "
  1897. f"{getattr(source, 'title_hint', '') or getattr(source, 'table_type', '') or '未分类'}"
  1898. )
  1899. continue
  1900. if all(source is not table for table in candidates):
  1901. candidates.append(source)
  1902. if candidates:
  1903. candidates.sort(key=lambda table: (source_rank(table), name_rank(table)))
  1904. return candidates[0]
  1905. # 招标文件没有指定格式时,才允许使用参考投书的同类型表格结构。
  1906. # 具体项目身份和历史报价在构建 XML 前另行净化。
  1907. reference = [
  1908. table for table in list(getattr(project_data, "reference_tables", []) or [])
  1909. if _table_identity_matches(key, table)
  1910. ]
  1911. if reference:
  1912. reference.sort(key=name_rank)
  1913. return reference[0]
  1914. return None
  1915. def _source_item_for_table(project_data: Optional[ProjectData], source_table: Any):
  1916. for item in list(getattr(project_data, "extracted_items", []) or []):
  1917. if getattr(item, "source_table", None) is source_table:
  1918. return item
  1919. candidate = getattr(item, "source_table", None)
  1920. if candidate is not None and (
  1921. getattr(candidate, "table_id", "")
  1922. and getattr(candidate, "table_id", "") == getattr(source_table, "table_id", "")
  1923. ):
  1924. return item
  1925. return None
  1926. def _response_table_columns(source_table: Any) -> Tuple[int, int]:
  1927. cells = list(getattr(source_table, "cells", []) or [])
  1928. if not cells:
  1929. return -1, -1
  1930. header = [str(getattr(cell, "text", "") or "") for cell in cells[0]]
  1931. response_col = next((
  1932. index for index, text_value in enumerate(header)
  1933. if "是/否" in text_value.replace(" ", "")
  1934. or "投标检查项" in text_value
  1935. or "响应内容说明" in text_value
  1936. or "是否响应" in text_value
  1937. ), -1)
  1938. evidence_col = next((
  1939. index for index, text_value in enumerate(header)
  1940. if "电子投标文件" in text_value
  1941. or "证明材料" in text_value
  1942. or ("对应" in text_value and "文件" in text_value)
  1943. ), -1)
  1944. return response_col, evidence_col
  1945. def _fill_current_response_table(source_table: Any, key: str) -> Any:
  1946. """只用当前上游表的要求填写响应列,不带入参考投书历史正文。"""
  1947. table = deepcopy(source_table)
  1948. cells = list(getattr(table, "cells", []) or [])
  1949. if len(cells) < 2:
  1950. return table
  1951. response_col, evidence_col = _response_table_columns(table)
  1952. if response_col < 0:
  1953. return table
  1954. for row in cells[1:]:
  1955. if (
  1956. response_col < len(row)
  1957. and not getattr(row[response_col], "is_merged_continuation", False)
  1958. and not str(row[response_col].text or "").strip()
  1959. ):
  1960. row[response_col].text = "是"
  1961. if evidence_col < 0 or evidence_col >= len(row):
  1962. continue
  1963. if getattr(row[evidence_col], "is_merged_continuation", False):
  1964. continue
  1965. if str(row[evidence_col].text or "").strip():
  1966. continue
  1967. item_name = str(getattr(row[0], "text", "") or "") if row else ""
  1968. if "授权" in item_name:
  1969. evidence = "第一章 法定代表人授权书"
  1970. elif "中小企业" in item_name:
  1971. evidence = "第一章 中小企业声明函"
  1972. elif "联合" in item_name:
  1973. evidence = "第一章 要求承诺函"
  1974. else:
  1975. evidence = "投标文件对应章节"
  1976. row[evidence_col].text = evidence
  1977. return table
  1978. def _overlay_table_xml_text(table_xml, source_table: Any):
  1979. """按Word网格只填空白响应/证明格,其他原生XML保持不变。
  1980. 横向合并省略物理tc,纵向合并续格却仍有tc;不能过滤元数据续格后zip。
  1981. Step1原文不应再用扁平元数据覆盖,否则会压平段落、run并丢失格式。
  1982. """
  1983. rows = list(getattr(source_table, "cells", []) or [])
  1984. fill_columns = {col for col in _response_table_columns(source_table) if col >= 0}
  1985. xml_rows = table_xml.findall(_w_tag("tr"))
  1986. for xml_row, row in zip(xml_rows[1:], rows[1:]):
  1987. before = xml_row.find(_w_tag("trPr") + "/" + _w_tag("gridBefore"))
  1988. col = int(before.get(_w_tag("val"), "0")) if before is not None else 0
  1989. for xml_cell in xml_row.findall(_w_tag("tc")):
  1990. span = xml_cell.find(_w_tag("tcPr") + "/" + _w_tag("gridSpan"))
  1991. width = max(1, int(span.get(_w_tag("val"), "1"))) if span is not None else 1
  1992. cell_col = col
  1993. col += width
  1994. merge = xml_cell.find(_w_tag("tcPr") + "/" + _w_tag("vMerge"))
  1995. if merge is not None and merge.get(_w_tag("val")) != "restart":
  1996. continue
  1997. if cell_col not in fill_columns or cell_col >= len(row):
  1998. continue
  1999. cell = row[cell_col]
  2000. if getattr(cell, "is_merged_continuation", False):
  2001. continue
  2002. value = str(getattr(cell, "text", "") or "")
  2003. if not value.strip() or _docx_xml_text(xml_cell).strip():
  2004. continue
  2005. paragraph = xml_cell.find(_w_tag("p"))
  2006. if paragraph is None:
  2007. paragraph = ET.SubElement(xml_cell, _w_tag("p"))
  2008. _fill_docx_paragraph_text(paragraph, value)
  2009. return table_xml
  2010. def _table_artifact_xml(source_table: Any):
  2011. """优先读取 Step1 单表产物中的原生 w:tbl;不加载 python-docx。"""
  2012. import zipfile
  2013. artifact_path = getattr(source_table, "artifact_path", "")
  2014. if not artifact_path or not os.path.isfile(artifact_path):
  2015. return None
  2016. try:
  2017. with zipfile.ZipFile(artifact_path, "r") as package:
  2018. artifact_root = ET.fromstring(package.read("word/document.xml"))
  2019. return next(artifact_root.iter(_w_tag("tbl")), None)
  2020. except Exception as exc:
  2021. logger.warning(f"Step1 表格产物读取失败,将按元数据重建: {exc}")
  2022. return None
  2023. def _table_origin_covering(source_table: Any, row: int, col: int):
  2024. cells = list(getattr(source_table, "cells", []) or [])
  2025. for origin_row in range(row - 1, -1, -1):
  2026. if origin_row >= len(cells):
  2027. continue
  2028. for origin_col, cell in enumerate(cells[origin_row]):
  2029. if not getattr(cell, "is_merged_origin", False):
  2030. continue
  2031. rowspan = max(1, int(getattr(cell, "rowspan", 1) or 1))
  2032. colspan = max(1, int(getattr(cell, "colspan", 1) or 1))
  2033. if (
  2034. origin_row + rowspan > row
  2035. and origin_col <= col < origin_col + colspan
  2036. ):
  2037. return origin_col, cell
  2038. return None
  2039. def _metadata_table_xml(source_table: Any, placeholder_map: Dict[str, str]):
  2040. """按 ExtractedTable 的行列、合并和单元格样式元数据构造 w:tbl。"""
  2041. cells = list(getattr(source_table, "cells", []) or [])
  2042. rows = max(int(getattr(source_table, "rows", 0) or 0), len(cells))
  2043. cols = int(getattr(source_table, "cols", 0) or 0)
  2044. if not cols:
  2045. cols = max((len(row) for row in cells), default=0)
  2046. if not rows or not cols:
  2047. return None
  2048. table = ET.Element(_w_tag("tbl"))
  2049. table_pr = ET.SubElement(table, _w_tag("tblPr"))
  2050. width = ET.SubElement(table_pr, _w_tag("tblW"))
  2051. width.set(_w_tag("w"), "0")
  2052. width.set(_w_tag("type"), "auto")
  2053. borders = ET.SubElement(table_pr, _w_tag("tblBorders"))
  2054. for side in ("top", "left", "bottom", "right", "insideH", "insideV"):
  2055. border = ET.SubElement(borders, _w_tag(side))
  2056. border.set(_w_tag("val"), "single")
  2057. border.set(_w_tag("sz"), "4")
  2058. border.set(_w_tag("space"), "0")
  2059. border.set(_w_tag("color"), "000000")
  2060. raw_widths = list(getattr(source_table, "col_widths", []) or [])
  2061. if len(raw_widths) != cols or sum(raw_widths) <= 0:
  2062. raw_widths = [1.0 / cols] * cols
  2063. total_width = sum(raw_widths)
  2064. twips = [max(240, round(9000 * value / total_width)) for value in raw_widths]
  2065. grid = ET.SubElement(table, _w_tag("tblGrid"))
  2066. for value in twips:
  2067. col = ET.SubElement(grid, _w_tag("gridCol"))
  2068. col.set(_w_tag("w"), str(value))
  2069. for row_index in range(rows):
  2070. row_element = ET.SubElement(table, _w_tag("tr"))
  2071. row_cells = cells[row_index] if row_index < len(cells) else []
  2072. col_index = 0
  2073. while col_index < cols:
  2074. cell = row_cells[col_index] if col_index < len(row_cells) else None
  2075. covering = _table_origin_covering(source_table, row_index, col_index)
  2076. if covering is not None:
  2077. origin_col, origin = covering
  2078. if col_index != origin_col:
  2079. col_index += 1
  2080. continue
  2081. colspan = max(1, int(getattr(origin, "colspan", 1) or 1))
  2082. text_value = ""
  2083. vertical_continue = True
  2084. style_cell = origin
  2085. elif cell is not None and getattr(cell, "is_merged_continuation", False):
  2086. col_index += 1
  2087. continue
  2088. else:
  2089. colspan = max(1, int(getattr(cell, "colspan", 1) or 1)) if cell else 1
  2090. text_value = str(getattr(cell, "text", "") or "") if cell else ""
  2091. vertical_continue = False
  2092. style_cell = cell
  2093. tc = ET.SubElement(row_element, _w_tag("tc"))
  2094. tc_pr = ET.SubElement(tc, _w_tag("tcPr"))
  2095. tc_width = ET.SubElement(tc_pr, _w_tag("tcW"))
  2096. tc_width.set(_w_tag("w"), str(sum(twips[col_index:col_index + colspan])))
  2097. tc_width.set(_w_tag("type"), "dxa")
  2098. if colspan > 1:
  2099. span = ET.SubElement(tc_pr, _w_tag("gridSpan"))
  2100. span.set(_w_tag("val"), str(colspan))
  2101. rowspan = max(1, int(getattr(style_cell, "rowspan", 1) or 1)) if style_cell else 1
  2102. if vertical_continue or rowspan > 1:
  2103. merge = ET.SubElement(tc_pr, _w_tag("vMerge"))
  2104. if not vertical_continue:
  2105. merge.set(_w_tag("val"), "restart")
  2106. paragraph = ET.SubElement(tc, _w_tag("p"))
  2107. alignment = getattr(style_cell, "alignment", "") if style_cell else ""
  2108. if alignment:
  2109. p_pr = ET.SubElement(paragraph, _w_tag("pPr"))
  2110. jc = ET.SubElement(p_pr, _w_tag("jc"))
  2111. jc.set(_w_tag("val"), alignment)
  2112. run = ET.SubElement(paragraph, _w_tag("r"))
  2113. if style_cell and (getattr(style_cell, "font_size", None) or getattr(style_cell, "bold", False)):
  2114. r_pr = ET.SubElement(run, _w_tag("rPr"))
  2115. if getattr(style_cell, "bold", False):
  2116. ET.SubElement(r_pr, _w_tag("b"))
  2117. if getattr(style_cell, "font_size", None):
  2118. size = ET.SubElement(r_pr, _w_tag("sz"))
  2119. size.set(_w_tag("val"), str(round(float(style_cell.font_size) * 2)))
  2120. text_node = ET.SubElement(run, _w_tag("t"))
  2121. text_node.set(f"{{{_XML_NS}}}space", "preserve")
  2122. text_node.text = _resolve_chapter_text_placeholders(
  2123. text_value, placeholder_map
  2124. )
  2125. col_index += colspan
  2126. return table
  2127. def _sanitized_reference_table(
  2128. source_table: Any,
  2129. analysis: Optional[TenderAnalysis],
  2130. placeholder_map: Dict[str, str],
  2131. ):
  2132. """复用参考表结构时移除历史项目身份和历史报价数值。"""
  2133. import copy
  2134. table = copy.deepcopy(source_table)
  2135. header_fields = list(getattr(table, "header_fields", []) or [])
  2136. reference_project = ""
  2137. reference_id = ""
  2138. for line in header_fields:
  2139. match = re.match(r"\s*项目名称\s*[::]\s*(.+)", str(line or ""))
  2140. if match:
  2141. reference_project = match.group(1).strip()
  2142. match = re.match(
  2143. r"\s*(?:项目编号|招标编号|采购编号)\s*[::]\s*(.+)",
  2144. str(line or ""),
  2145. )
  2146. if match:
  2147. reference_id = match.group(1).strip()
  2148. current_project = str(getattr(analysis, "project_name", "") or "")
  2149. current_id = str(getattr(analysis, "tender_project_id", "") or "")
  2150. monetary_columns = set()
  2151. cells = list(getattr(table, "cells", []) or [])
  2152. for row in cells[:5]:
  2153. for col, cell in enumerate(row):
  2154. text_value = str(getattr(cell, "text", "") or "")
  2155. if any(token in text_value for token in (
  2156. "金额", "单价", "总价", "月工资", "年费用", "小计", "合计",
  2157. )):
  2158. monetary_columns.add(col)
  2159. for row_index, row in enumerate(cells):
  2160. for col, cell in enumerate(row):
  2161. value = str(getattr(cell, "text", "") or "")
  2162. if reference_project and current_project:
  2163. value = value.replace(reference_project, current_project)
  2164. if reference_id and current_id:
  2165. value = value.replace(reference_id, current_id)
  2166. if (
  2167. row_index > 0
  2168. and col in monetary_columns
  2169. and re.search(r"\d", value)
  2170. ):
  2171. value = "待投标报价确认"
  2172. cell.text = _resolve_chapter_text_placeholders(value, placeholder_map)
  2173. table.header_fields = [
  2174. _resolve_chapter_text_placeholders(
  2175. str(line or "")
  2176. .replace(reference_project, current_project) if reference_project else str(line or ""),
  2177. placeholder_map,
  2178. )
  2179. for line in header_fields
  2180. ]
  2181. return table
  2182. def _replace_native_table_placeholders(
  2183. body,
  2184. analysis: Optional[TenderAnalysis],
  2185. project_data: Optional[ProjectData],
  2186. placeholder_map: Dict[str, str],
  2187. ) -> int:
  2188. """把独立段落中的 %%表名%% 替换为获授权的招标或参考表格块。"""
  2189. replaced = 0
  2190. for paragraph in list(body):
  2191. if paragraph.tag != _w_tag("p"):
  2192. continue
  2193. key = _table_placeholder_key(_docx_xml_text(paragraph))
  2194. if not key or not _looks_like_table_placeholder(key):
  2195. continue
  2196. source_table = _select_placeholder_table(key, analysis, project_data)
  2197. if source_table is None:
  2198. if _table_type_for_placeholder(key) or re.search(
  2199. r"表|清单|一览|汇总|明细", key
  2200. ):
  2201. logger.warning(f" %%{key}%% 未找到获授权的表格来源")
  2202. continue
  2203. reference_tables = list(getattr(project_data, "reference_tables", []) or [])
  2204. is_reference = any(source_table is table for table in reference_tables) or "reference" in str(
  2205. getattr(source_table, "source_type", "") or ""
  2206. ).lower()
  2207. if is_reference:
  2208. source_table = _sanitized_reference_table(
  2209. source_table, analysis, placeholder_map
  2210. )
  2211. is_response_table = _response_table_columns(source_table)[0] >= 0
  2212. if is_response_table:
  2213. source_table = _fill_current_response_table(source_table, key)
  2214. table = None if is_reference else _table_artifact_xml(source_table)
  2215. if table is not None and is_response_table:
  2216. table = _overlay_table_xml_text(table, source_table)
  2217. if table is None:
  2218. table = _metadata_table_xml(source_table, placeholder_map)
  2219. if table is None:
  2220. logger.warning(f" %%{key}%% 的招标表格元数据为空,保留占位符")
  2221. continue
  2222. source_item = _source_item_for_table(project_data, source_table)
  2223. preamble = str(getattr(source_item, "preamble", "") or "")
  2224. header_fields = list(getattr(source_table, "header_fields", []) or [])
  2225. postamble = str(
  2226. getattr(source_item, "postamble", "")
  2227. or getattr(source_table, "postamble", "")
  2228. or ""
  2229. )
  2230. insert_at = list(body).index(paragraph)
  2231. for line in [*preamble.splitlines(), *header_fields]:
  2232. filled = _resolve_chapter_text_placeholders(line.strip(), placeholder_map)
  2233. if filled:
  2234. body.insert(insert_at, _new_docx_paragraph(filled))
  2235. insert_at += 1
  2236. body.insert(insert_at, table)
  2237. insert_at += 1
  2238. for line in postamble.splitlines():
  2239. filled = _resolve_chapter_text_placeholders(line.strip(), placeholder_map)
  2240. if filled:
  2241. body.insert(insert_at, _new_docx_paragraph(filled))
  2242. insert_at += 1
  2243. body.remove(paragraph)
  2244. replaced += 1
  2245. logger.info(
  2246. f" %%{key}%% → {'参考投书表格' if is_reference else '招标表格'} "
  2247. f"[{getattr(source_table, 'rows', 0)} 行 × {getattr(source_table, 'cols', 0)} 列]"
  2248. )
  2249. return replaced
  2250. def _replace_native_commitment_placeholder(body, chapter: Chapter) -> set[str]:
  2251. """把废标承诺块放入模板授权的 %%承诺函%% 位置。"""
  2252. rejection_nodes = [
  2253. node for node in _walk_nodes(chapter)
  2254. if (getattr(node, "direct_rejection_bindings", []) or [])
  2255. and (getattr(node, "supplement_content", "") or "").strip()
  2256. ]
  2257. if not rejection_nodes:
  2258. return set()
  2259. target = rejection_nodes[0]
  2260. inserted_for = set()
  2261. for paragraph in list(body):
  2262. if paragraph.tag != _w_tag("p"):
  2263. continue
  2264. if "%%承诺函%%" not in _docx_xml_text(paragraph):
  2265. continue
  2266. insert_at = list(body).index(paragraph)
  2267. for line in target.supplement_content.splitlines():
  2268. if not line.strip():
  2269. continue
  2270. replacement = deepcopy(paragraph)
  2271. _replace_docx_paragraph_text(replacement, line.strip())
  2272. body.insert(insert_at, replacement)
  2273. insert_at += 1
  2274. body.remove(paragraph)
  2275. inserted_for.add(str(target.id))
  2276. logger.info(
  2277. f" %%承诺函%% → "
  2278. f"{len(getattr(target, 'direct_rejection_bindings', []) or [])} 条废标项,"
  2279. f"归并为 {len(target.supplement_content.splitlines()) - 1} 类承诺"
  2280. )
  2281. return inserted_for
  2282. def _register_namespaces_from_part(data: bytes) -> ET.Element:
  2283. """从独立 OOXML 部件(header/footer 等)注册命名空间并解析根节点。"""
  2284. import io
  2285. namespace_events = ET.iterparse(io.BytesIO(data), events=("start-ns",))
  2286. for _event, (prefix, uri) in namespace_events:
  2287. try:
  2288. ET.register_namespace(prefix or "", uri)
  2289. except ValueError:
  2290. pass
  2291. return ET.fromstring(data)
  2292. def _transform_header_footer_part(
  2293. data: bytes,
  2294. placeholder_map: Optional[Dict[str, str]],
  2295. project_name: str,
  2296. ) -> bytes:
  2297. """替换页眉/页脚独立 XML 部件中的项目名称信息。
  2298. 模板页眉使用 %%项目名称%% 占位符;部分模板还残留历史项目名
  2299. “上海市建筑工程学校物业管理服务项目投标文件”。此函数在章节 DOCX
  2300. 的 header/footer 部件中执行与最终 Step6 一致的信息替换,不改动
  2301. 页眉中的图片、文本框或公司标识,也不触碰页脚页码域。
  2302. """
  2303. root = _register_namespaces_from_part(data)
  2304. placeholder_map = placeholder_map or {}
  2305. placeholder_re = re.compile(r"%%(.+?)%%")
  2306. t_tag = _w_tag("t")
  2307. def _placeholder_value(match: re.Match) -> str:
  2308. key = match.group(1).strip()
  2309. value = placeholder_map.get(key, "")
  2310. if not value and key == "项目名称" and project_name:
  2311. value = project_name
  2312. return value if value else match.group(0)
  2313. for text_node in root.iter(t_tag):
  2314. original = text_node.text or ""
  2315. if not original:
  2316. continue
  2317. new_text = placeholder_re.sub(_placeholder_value, original)
  2318. if project_name:
  2319. new_text = new_text.replace(
  2320. "上海市建筑工程学校物业管理服务项目投标文件",
  2321. f"{project_name}项目投标文件",
  2322. )
  2323. new_text = new_text.replace(
  2324. "上海市建筑工程学校", project_name
  2325. )
  2326. new_text = new_text.replace(
  2327. "物业管理服务项目投标文件", "项目投标文件"
  2328. )
  2329. if new_text != original:
  2330. text_node.text = new_text
  2331. return ET.tostring(root, encoding="utf-8", xml_declaration=True)
  2332. def _chapter_header_display_text(project_name: str) -> str:
  2333. """为兜底章节生成与模板页眉一致的项目投标文件名称。"""
  2334. project_name = (project_name or "").strip()
  2335. if not project_name:
  2336. return ""
  2337. if project_name.endswith("项目"):
  2338. return f"{project_name}投标文件"
  2339. return f"{project_name}项目投标文件"
  2340. def _add_fallback_header_footer(
  2341. doc,
  2342. placeholder_map: Optional[Dict[str, str]],
  2343. project_name: str,
  2344. ) -> None:
  2345. """给非原生章节 DOCX 增加基础页眉和页脚页码。"""
  2346. from docx.enum.text import WD_ALIGN_PARAGRAPH
  2347. from docx.oxml import OxmlElement
  2348. from docx.oxml.ns import qn
  2349. placeholder_map = placeholder_map or {}
  2350. header_project_name = (
  2351. placeholder_map.get("项目名称")
  2352. or (project_name or "")
  2353. )
  2354. header_text = _chapter_header_display_text(header_project_name)
  2355. section = doc.sections[0]
  2356. if header_text:
  2357. section.header.is_linked_to_previous = False
  2358. header_para = section.header.paragraphs[0]
  2359. if header_para.runs:
  2360. for run in list(header_para.runs):
  2361. run._element.getparent().remove(run._element)
  2362. header_para.add_run(header_text)
  2363. section.footer.is_linked_to_previous = False
  2364. footer_para = section.footer.paragraphs[0]
  2365. if footer_para.runs:
  2366. for run in list(footer_para.runs):
  2367. run._element.getparent().remove(run._element)
  2368. footer_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
  2369. run = footer_para.add_run()
  2370. begin = OxmlElement("w:fldChar")
  2371. begin.set(qn("w:fldCharType"), "begin")
  2372. run._element.append(begin)
  2373. instr = OxmlElement("w:instrText")
  2374. instr.set(qn("xml:space"), "preserve")
  2375. instr.text = " PAGE "
  2376. run._element.append(instr)
  2377. end = OxmlElement("w:fldChar")
  2378. end.set(qn("w:fldCharType"), "end")
  2379. run._element.append(end)
  2380. def _section_break_records(children) -> List[Tuple[int, object]]:
  2381. """收集 body 中所有分节符及其位置。
  2382. OOXML 中分节符可能位于段落 `pPr/sectPr` 内,也可能作为 body 末尾的
  2383. 直接 `sectPr` 元素。这里只返回 `sectPr` 元素本身,调用方负责深拷贝。
  2384. """
  2385. records: List[Tuple[int, object]] = []
  2386. for index, element in enumerate(children):
  2387. sect_pr = None
  2388. if element.tag == _w_tag("sectPr"):
  2389. sect_pr = element
  2390. elif element.tag == _w_tag("p"):
  2391. p_pr = element.find(_w_tag("pPr"))
  2392. if p_pr is not None:
  2393. sect_pr = p_pr.find(_w_tag("sectPr"))
  2394. if sect_pr is not None:
  2395. records.append((index, sect_pr))
  2396. return records
  2397. def _first_header_footer_refs(children) -> List[Tuple[str, str, str]]:
  2398. """返回模板第一个带页眉页脚引用的分节引用(统一页眉页脚基准)。"""
  2399. for _index, sect_pr in _section_break_records(children):
  2400. refs = _header_footer_refs(sect_pr)
  2401. if refs:
  2402. return refs
  2403. return []
  2404. def _header_footer_refs(sect_pr) -> List[Tuple[str, str, str]]:
  2405. """读取 sectPr 中 header/footer 引用,按 headerReference 在前排序。"""
  2406. rel_ns = (
  2407. "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
  2408. )
  2409. by_key: Dict[Tuple[str, str], Tuple[str, str, str]] = {}
  2410. order_keys: List[Tuple[str, str]] = []
  2411. for child in list(sect_pr):
  2412. local = child.tag.rsplit("}", 1)[-1]
  2413. if local not in ("headerReference", "footerReference"):
  2414. continue
  2415. rel_id = child.get(f"{{{rel_ns}}}id", "")
  2416. ref_type = child.get(_w_tag("type"), "default")
  2417. key = (local, ref_type)
  2418. if key in by_key:
  2419. continue
  2420. by_key[key] = (local, rel_id, ref_type)
  2421. order_keys.append(key)
  2422. ordered = [by_key[key] for key in order_keys]
  2423. ordered.sort(key=lambda item: (
  2424. 0 if item[0] == "headerReference" else 1,
  2425. order_keys.index((item[0], item[2])),
  2426. ))
  2427. return ordered
  2428. def _merge_header_footer_refs(
  2429. current: List[Tuple[str, str, str]],
  2430. explicit: List[Tuple[str, str, str]],
  2431. ) -> List[Tuple[str, str, str]]:
  2432. """合并节间继承关系:显式引用覆盖同类型,未覆盖类型继续继承。"""
  2433. by_key: Dict[Tuple[str, str], Tuple[str, str, str]] = {
  2434. (item[0], item[2]): item for item in current
  2435. }
  2436. order_keys = list(by_key.keys())
  2437. for item in explicit:
  2438. key = (item[0], item[2])
  2439. if key not in by_key:
  2440. order_keys.append(key)
  2441. by_key[key] = item
  2442. return [by_key[key] for key in order_keys]
  2443. def _apply_header_footer_refs(sect_pr, refs) -> None:
  2444. """把有效 header/footer 引用写回 sectPr 顶部,删除旧引用。"""
  2445. rel_ns = (
  2446. "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
  2447. )
  2448. for child in list(sect_pr):
  2449. local = child.tag.rsplit("}", 1)[-1]
  2450. if local in ("headerReference", "footerReference"):
  2451. sect_pr.remove(child)
  2452. insert_at = 0
  2453. for local, rel_id, ref_type in refs:
  2454. element = ET.Element(_w_tag(local))
  2455. if rel_id:
  2456. element.set(f"{{{rel_ns}}}id", rel_id)
  2457. if ref_type:
  2458. element.set(_w_tag("type"), ref_type)
  2459. sect_pr.insert(insert_at, element)
  2460. insert_at += 1
  2461. def _chapter_section_properties(
  2462. children,
  2463. start: int,
  2464. ) -> Optional[object]:
  2465. """为从 start 开始的一级章计算应写入独立 DOCX 的 sectPr。
  2466. 返回深拷贝后的 sectPr,已把其 header/footer 引用解析为当前章节所在
  2467. 分节实际生效的引用(含跨分节继承),避免所有章节共用模板末尾的页眉页脚。
  2468. """
  2469. records = _section_break_records(children)
  2470. if not records:
  2471. return None
  2472. effective_after: Dict[int, List[Tuple[str, str, str]]] = {}
  2473. current: List[Tuple[str, str, str]] = []
  2474. for index, sect_pr in records:
  2475. explicit = _header_footer_refs(sect_pr)
  2476. current = _merge_header_footer_refs(current, explicit)
  2477. effective_after[index] = list(current)
  2478. previous_index = None
  2479. for index, _sect_pr in records:
  2480. if index < start:
  2481. previous_index = index
  2482. else:
  2483. break
  2484. effective_refs = (
  2485. effective_after.get(previous_index, [])
  2486. if previous_index is not None else []
  2487. )
  2488. ending_index = next(
  2489. (index for index, _sect_pr in records if index >= start),
  2490. None,
  2491. )
  2492. if ending_index is None:
  2493. ending_index = records[-1][0]
  2494. base_sect_pr = next(
  2495. sect_pr for index, sect_pr in records if index == ending_index
  2496. )
  2497. base_explicit = _header_footer_refs(base_sect_pr)
  2498. resolved_refs = _merge_header_footer_refs(effective_refs, base_explicit)
  2499. chapter_sect_pr = deepcopy(base_sect_pr)
  2500. _apply_header_footer_refs(chapter_sect_pr, resolved_refs)
  2501. return chapter_sect_pr
  2502. def _trim_trailing_part_marker(
  2503. children,
  2504. start: int,
  2505. end: int,
  2506. ) -> int:
  2507. """去掉当前章区域末尾属于下一分区的“技术部分/商务部分”等标记。
  2508. 模板正文中“技术部分”等分区文字位于真实一级章之间,且不属于其前面
  2509. 商务章的内容。Step4 按 H1 边界裁章时会把它连同末尾空行一起带进前一个
  2510. 章节产物,导致第二章产物在新增标题前出现无关的“技术部分”。
  2511. """
  2512. marker_index = None
  2513. for index in range(end - 1, start, -1):
  2514. element = children[index]
  2515. if element.tag != _w_tag("p"):
  2516. break
  2517. text = _docx_xml_text(element).strip()
  2518. if text in _TEMPLATE_PART_MARKERS:
  2519. marker_index = index
  2520. continue
  2521. if text:
  2522. break
  2523. return marker_index if marker_index is not None else end
  2524. def _save_native_template_chapter_docx(
  2525. chapter: Chapter,
  2526. template_path: str,
  2527. output_path: str,
  2528. placeholder_map: Optional[Dict[str, str]] = None,
  2529. company_info: Optional[CompanyInfo] = None,
  2530. analysis: Optional[TenderAnalysis] = None,
  2531. project_data: Optional[ProjectData] = None,
  2532. ) -> bool:
  2533. """复制模板原生章节 OOXML,仅改授权标题并插入节点补充正文。"""
  2534. import io
  2535. import posixpath
  2536. import zipfile
  2537. import xml.etree.ElementTree as ET
  2538. if not template_path or not os.path.isfile(template_path):
  2539. return False
  2540. try:
  2541. with zipfile.ZipFile(template_path, "r") as source_zip:
  2542. document_bytes = source_zip.read("word/document.xml")
  2543. styles_root = ET.fromstring(source_zip.read("word/styles.xml"))
  2544. namespace_events = ET.iterparse(
  2545. io.BytesIO(document_bytes), events=("start-ns",)
  2546. )
  2547. for _event, (prefix, uri) in namespace_events:
  2548. try:
  2549. ET.register_namespace(prefix or "", uri)
  2550. except ValueError:
  2551. pass
  2552. root = ET.fromstring(document_bytes)
  2553. body = root.find(_w_tag("body"))
  2554. if body is None:
  2555. return False
  2556. style_levels, style_ids = _docx_style_maps(styles_root)
  2557. children = list(body)
  2558. h1_positions = [
  2559. index for index, element in enumerate(children)
  2560. if element.tag == _w_tag("p")
  2561. and _docx_paragraph_level(element, style_levels) == 1
  2562. ]
  2563. # 新增评分章没有模板正文来源,不能用最终章号裁取同号模板章。
  2564. new_scoring_chapter = chapter.is_scoring_inserted
  2565. target_number = "" if new_scoring_chapter else _template_chapter_number(chapter)
  2566. start = None
  2567. for position in h1_positions:
  2568. text = _docx_xml_text(children[position])
  2569. match = re.match(
  2570. r"^第\s*([一二三四五六七八九十百\d]+)\s*章", text
  2571. )
  2572. if not match:
  2573. continue
  2574. raw = match.group(1)
  2575. from step3_outlining.outline_generator import _chinese_to_int
  2576. number = str(_chinese_to_int(raw) if not raw.isdigit() else int(raw))
  2577. if number == target_number:
  2578. start = position
  2579. break
  2580. if start is None:
  2581. if not new_scoring_chapter and not _is_evaluation_index_chapter(chapter):
  2582. from step4_writing.difficulty_rewrite import MODE
  2583. if any(n.content_generation_mode == MODE for n in _walk_nodes(chapter)):
  2584. raise ValueError("模板中未找到专项所属章节,无法保护原生结构")
  2585. return False
  2586. start = 0
  2587. chapter_sect_pr = _chapter_section_properties(children, 0)
  2588. selected = [
  2589. _new_docx_paragraph(
  2590. chapter.title,
  2591. style_ids.get(1, "Heading1"),
  2592. )
  2593. ]
  2594. else:
  2595. end = next((pos for pos in h1_positions if pos > start), len(children))
  2596. end = _trim_trailing_part_marker(children, start, end)
  2597. chapter_sect_pr = _chapter_section_properties(children, start)
  2598. selected = children[start:end]
  2599. for element in children:
  2600. body.remove(element)
  2601. for element in selected:
  2602. body.append(element)
  2603. existing_sect_pr = next(
  2604. (
  2605. element
  2606. for element in list(body)
  2607. if element.tag == _w_tag("sectPr")
  2608. ),
  2609. None,
  2610. )
  2611. if existing_sect_pr is not None:
  2612. body.remove(existing_sect_pr)
  2613. if chapter_sect_pr is not None:
  2614. body.append(chapter_sect_pr)
  2615. # 模板章内部常包含无 header/footer 引用的分节符;独立章节 DOCX
  2616. # 中所有分节统一使用模板第一个分节的页眉页脚引用,保证第 1-8 章
  2617. # 与 index 的页眉格式一致。
  2618. resolved_refs = _first_header_footer_refs(children)
  2619. for sect_pr in body.iter(_w_tag("sectPr")):
  2620. _apply_header_footer_refs(sect_pr, resolved_refs)
  2621. # Step4 在原生段落/表格单元格中完成项目信息填充。只改含已知
  2622. # 占位符的段落文本,不触碰图片、表格结构或其他原始内容块。
  2623. effective_map = dict(placeholder_map or {})
  2624. header_project_name = (
  2625. effective_map.get("项目名称")
  2626. or (analysis.project_name if analysis is not None else "")
  2627. or ""
  2628. )
  2629. _replace_native_table_placeholders(
  2630. body,
  2631. analysis=analysis,
  2632. project_data=project_data,
  2633. placeholder_map=effective_map,
  2634. )
  2635. commitment_placeholder_nodes = _replace_native_commitment_placeholder(
  2636. body, chapter
  2637. )
  2638. for paragraph in body.iter(_w_tag("p")):
  2639. original = "".join(
  2640. node.text or "" for node in paragraph.iter(_w_tag("t"))
  2641. )
  2642. if not original:
  2643. continue
  2644. filled = _resolve_chapter_text_placeholders(
  2645. original, effective_map
  2646. )
  2647. if company_info is not None and analysis is not None:
  2648. filled = replace_bracket_placeholders(
  2649. filled,
  2650. company_info=company_info,
  2651. analysis=analysis,
  2652. )
  2653. if analysis is not None:
  2654. # 原生裁章会完整保留模板旧正文;评分要求中的当前数值必须
  2655. # 同样覆盖模板历史值,不能只修正 generated_content。
  2656. filled = _override_rejection_conflicts(filled, analysis)
  2657. if filled != original:
  2658. _fill_docx_paragraph_text(paragraph, filled)
  2659. # 最终大纲是唯一标题树;模板原标题只负责找到原生内容锚点。
  2660. all_nodes = list(_walk_nodes(chapter))
  2661. node_by_id = {str(node.id): node for node in all_nodes}
  2662. parent_by_id = {str(chapter.id): None}
  2663. for node in all_nodes:
  2664. for child in node.children or []:
  2665. parent_by_id.setdefault(str(child.id), str(node.id))
  2666. def _node_key(node) -> str:
  2667. for candidate in (node.template_original_title, node.title):
  2668. key = _artifact_heading_key(candidate)
  2669. if key:
  2670. return key
  2671. return ""
  2672. def _node_path(node) -> Tuple[str, ...]:
  2673. path = []
  2674. current = node
  2675. while current is not None:
  2676. key = _node_key(current)
  2677. if key:
  2678. path.append(key)
  2679. parent_id = parent_by_id.get(str(current.id))
  2680. current = node_by_id.get(parent_id) if parent_id else None
  2681. return tuple(reversed(path))
  2682. # 同一组织名称会同时出现在“近三年类似项目业绩”和“类似业绩业主评价”
  2683. # 等多个父节下,仅按标题文本建索引会把后出现的同父子标题误绑到先出现
  2684. # 的节点,导致 Step4 把未绑定的节点再插入一遍。这里按“父路径+标题”
  2685. # 精确匹配,避免跨父节串绑。
  2686. path_nodes: Dict[Tuple[str, ...], List[object]] = {}
  2687. for node in all_nodes:
  2688. path_nodes.setdefault(_node_path(node), []).append(node)
  2689. anchors: Dict[str, object] = {str(chapter.id): selected[0]}
  2690. heading_stack: List[Tuple[int, str]] = [(1, _node_key(chapter))]
  2691. for element in list(body):
  2692. if element.tag != _w_tag("p"):
  2693. continue
  2694. level = _docx_paragraph_level(element, style_levels)
  2695. if not level:
  2696. continue
  2697. text = _docx_xml_text(element)
  2698. key = _artifact_heading_key(text)
  2699. prefix = [item_key for item_level, item_key in heading_stack if item_level < level]
  2700. node = next(
  2701. (
  2702. candidate
  2703. for candidate in path_nodes.get(
  2704. tuple(prefix + [key]), []
  2705. )
  2706. if str(candidate.id) not in anchors
  2707. ),
  2708. None,
  2709. )
  2710. if node is not None:
  2711. anchors[str(node.id)] = element
  2712. _replace_docx_paragraph_text(element, node.title)
  2713. while heading_stack and heading_stack[-1][0] >= level:
  2714. heading_stack.pop()
  2715. heading_stack.append((level, key))
  2716. h1_text = (
  2717. f"第{_native_chapter_number(chapter)}章 {chapter.title}"
  2718. if chapter.id.isdigit() else chapter.title
  2719. )
  2720. _replace_docx_paragraph_text(anchors[str(chapter.id)], h1_text)
  2721. def element_level(element) -> int:
  2722. return (
  2723. _docx_paragraph_level(element, style_levels)
  2724. if element.tag == _w_tag("p") else 0
  2725. )
  2726. def insert_children(parent: Chapter) -> None:
  2727. parent_anchor = anchors[str(parent.id)]
  2728. for child in parent.children or []:
  2729. child_id = str(child.id)
  2730. if child_id not in anchors:
  2731. body_items = list(body)
  2732. parent_pos = body_items.index(parent_anchor)
  2733. final_sect_index = next(
  2734. (
  2735. index
  2736. for index, element in enumerate(body_items)
  2737. if element.tag == _w_tag("sectPr")
  2738. ),
  2739. len(body_items),
  2740. )
  2741. insert_at = final_sect_index
  2742. for index in range(parent_pos + 1, final_sect_index):
  2743. level = element_level(body_items[index])
  2744. if level and level <= int(parent.level or 1):
  2745. insert_at = index
  2746. break
  2747. heading = _new_docx_paragraph(
  2748. child.title,
  2749. style_ids.get(max(2, min(int(child.level or 2), 4)), "Heading2"),
  2750. )
  2751. body.insert(insert_at, heading)
  2752. anchors[child_id] = heading
  2753. insert_children(child)
  2754. from step4_writing.difficulty_rewrite import MODE
  2755. for node in all_nodes:
  2756. if (node.content_generation_mode == MODE
  2757. and (node.from_template or node.template_original_id)
  2758. and str(node.id) not in anchors):
  2759. raise ValueError(f"专项原生标题锚点缺失:{node.id}")
  2760. insert_children(chapter)
  2761. from step4_writing.difficulty_rewrite import remove_rewritten_prose
  2762. remove_rewritten_prose(
  2763. body, all_nodes, anchors, element_level, _docx_xml_text, _w_tag
  2764. )
  2765. # 每个补充块放在该节点原生区域末尾、下一标题之前;图片/表格不移动。
  2766. for node in reversed(all_nodes):
  2767. supplement = (node.supplement_content or "").strip()
  2768. if (
  2769. not supplement
  2770. or str(node.id) not in anchors
  2771. or str(node.id) in commitment_placeholder_nodes
  2772. ):
  2773. continue
  2774. body_items = list(body)
  2775. anchor_pos = body_items.index(anchors[str(node.id)])
  2776. final_sect_index = next(
  2777. (
  2778. index
  2779. for index, element in enumerate(body_items)
  2780. if element.tag == _w_tag("sectPr")
  2781. ),
  2782. len(body_items),
  2783. )
  2784. insert_at = final_sect_index
  2785. for index in range(anchor_pos + 1, final_sect_index):
  2786. if element_level(body_items[index]):
  2787. insert_at = index
  2788. break
  2789. paragraphs = [
  2790. _new_docx_paragraph(line)
  2791. for line in supplement.splitlines() if line.strip()
  2792. ]
  2793. for paragraph in reversed(paragraphs):
  2794. body.insert(insert_at, paragraph)
  2795. document_out = ET.tostring(
  2796. root, encoding="utf-8", xml_declaration=True
  2797. )
  2798. # 章节包只携带本章 document.xml 实际引用的图片/嵌入对象。
  2799. # 其余 300+ 个模板媒体属于其他章节,复制会让每章都接近模板全量体积。
  2800. office_rel_ns = (
  2801. "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
  2802. )
  2803. package_rel_ns = (
  2804. "http://schemas.openxmlformats.org/package/2006/relationships"
  2805. )
  2806. used_rel_ids = {
  2807. value
  2808. for element in root.iter()
  2809. for attr, value in element.attrib.items()
  2810. if attr.startswith(f"{{{office_rel_ns}}}")
  2811. }
  2812. document_rels_name = "word/_rels/document.xml.rels"
  2813. document_rels_root = ET.fromstring(
  2814. source_zip.read(document_rels_name)
  2815. )
  2816. selected_package_targets = set()
  2817. heavy_tokens = ("/image", "/chart", "/oleObject", "/package")
  2818. for relationship in list(document_rels_root):
  2819. rel_id = relationship.get("Id", "")
  2820. rel_type = relationship.get("Type", "")
  2821. target = relationship.get("Target", "")
  2822. is_heavy = any(token in rel_type for token in heavy_tokens)
  2823. if is_heavy and rel_id not in used_rel_ids:
  2824. document_rels_root.remove(relationship)
  2825. continue
  2826. if is_heavy and target and not target.startswith(("http:", "https:")):
  2827. selected_package_targets.add(posixpath.normpath(
  2828. posixpath.join("word", target)
  2829. ))
  2830. # 页眉页脚可能通过自己的 rels 使用图片;这些关系与媒体一并保留。
  2831. for rel_info in source_zip.infolist():
  2832. name = rel_info.filename
  2833. if not name.endswith(".rels") or name == document_rels_name:
  2834. continue
  2835. if not name.startswith("word/_rels/"):
  2836. continue
  2837. try:
  2838. rel_root = ET.fromstring(source_zip.read(name))
  2839. except Exception:
  2840. continue
  2841. source_part = "word/" + name.rsplit("/", 1)[-1][:-5]
  2842. for relationship in rel_root.findall(
  2843. f"{{{package_rel_ns}}}Relationship"
  2844. ):
  2845. target = relationship.get("Target", "")
  2846. if target and not target.startswith(("http:", "https:")):
  2847. resolved = posixpath.normpath(posixpath.join(
  2848. posixpath.dirname(source_part), target
  2849. ))
  2850. if resolved.startswith("word/media/"):
  2851. selected_package_targets.add(resolved)
  2852. document_rels_out = ET.tostring(
  2853. document_rels_root, encoding="utf-8", xml_declaration=True
  2854. )
  2855. os.makedirs(os.path.dirname(output_path), exist_ok=True)
  2856. with zipfile.ZipFile(
  2857. output_path, "w", compression=zipfile.ZIP_STORED
  2858. ) as output_zip:
  2859. for info in source_zip.infolist():
  2860. if (
  2861. info.filename.startswith("word/media/")
  2862. and info.filename not in selected_package_targets
  2863. ):
  2864. continue
  2865. if info.filename == "word/document.xml":
  2866. data = document_out
  2867. elif info.filename == document_rels_name:
  2868. data = document_rels_out
  2869. elif (
  2870. info.filename.startswith("word/header")
  2871. or info.filename.startswith("word/footer")
  2872. ) and info.filename.endswith(".xml"):
  2873. try:
  2874. data = _transform_header_footer_part(
  2875. source_zip.read(info.filename),
  2876. effective_map,
  2877. header_project_name,
  2878. )
  2879. except Exception as exc:
  2880. logger.warning(
  2881. f" [{chapter.id}] 页眉/页脚信息替换失败,"
  2882. f"保留模板原文: {exc}"
  2883. )
  2884. data = source_zip.read(info.filename)
  2885. else:
  2886. data = source_zip.read(info.filename)
  2887. output_zip.writestr(
  2888. info.filename, data, compress_type=zipfile.ZIP_STORED
  2889. )
  2890. return True
  2891. except Exception as exc:
  2892. from step4_writing.difficulty_rewrite import MODE
  2893. if any(n.content_generation_mode == MODE for n in _walk_nodes(chapter)):
  2894. raise RuntimeError("专项章节原生装配失败,禁止降级丢失受保护结构") from exc
  2895. logger.warning(
  2896. f" [{chapter.id}] 原生模板章节产物复制失败,使用结构化文本兜底: {exc}"
  2897. )
  2898. return False
  2899. def _save_chapter_docx(
  2900. chapter: Chapter,
  2901. chapter_dir: str,
  2902. template_path: str = "",
  2903. placeholder_map: Optional[Dict[str, str]] = None,
  2904. company_info: Optional[CompanyInfo] = None,
  2905. analysis: Optional[TenderAnalysis] = None,
  2906. project_data: Optional[ProjectData] = None,
  2907. ) -> str:
  2908. """保存一个顶层章节 DOCX。
  2909. 标题树严格采用 Step3 最终 Chapter 树;模板来源编号只用于找回原生
  2910. 正文,新增评分章不裁取模板正文。该文件供 Step5 审核和 Step6 聚合。
  2911. """
  2912. import re
  2913. from docx import Document as DocxDocument
  2914. from docx.shared import Pt, Cm
  2915. from docx.enum.text import WD_ALIGN_PARAGRAPH
  2916. os.makedirs(chapter_dir, exist_ok=True)
  2917. safe_title = re.sub(
  2918. r'[<>:"/\\|?*]+', "_", chapter.title,
  2919. ).strip(" ._")[:40] or "章节"
  2920. path = os.path.abspath(
  2921. os.path.join(chapter_dir, f"{chapter.id}_{safe_title}.docx")
  2922. )
  2923. if _save_native_template_chapter_docx(
  2924. chapter,
  2925. template_path,
  2926. path,
  2927. placeholder_map=placeholder_map,
  2928. company_info=company_info,
  2929. analysis=analysis,
  2930. project_data=project_data,
  2931. ):
  2932. return path
  2933. doc = DocxDocument()
  2934. section = doc.sections[0]
  2935. section.page_width = Cm(21.0)
  2936. section.page_height = Cm(29.7)
  2937. # 章标题是唯一 H1;样式用于让 Word 导航窗格与 Step3 目录一致。
  2938. title_para = doc.add_paragraph(style="Heading 1")
  2939. title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
  2940. chapter_number = chapter.id
  2941. if chapter.id.isdigit():
  2942. from step3_outlining.scoring_structure import _int_to_chinese
  2943. chapter_number = _int_to_chinese(int(chapter.id))
  2944. display_title = (
  2945. f"第{chapter_number}章 {chapter.title}"
  2946. if chapter.id.isdigit()
  2947. else chapter.title
  2948. )
  2949. title_run = title_para.add_run(display_title)
  2950. title_run.bold = True
  2951. title_run.font.size = Pt(18)
  2952. if _is_deferred_evaluation_index(chapter):
  2953. notice = doc.add_paragraph()
  2954. notice.add_run(
  2955. "本索引在全部章节合并、结构检查并稳定分页后,"
  2956. "依据 Step3 最终标题映射构建并填写实际页码。"
  2957. ).font.size = Pt(12)
  2958. if not _is_deferred_evaluation_index(chapter):
  2959. buckets = _split_artifact_content_by_outline(chapter)
  2960. _render_artifact_lines(doc, buckets.get(str(chapter.id), []))
  2961. if chapter.supplement_content.strip():
  2962. _render_artifact_lines(doc, chapter.supplement_content.splitlines())
  2963. for child in chapter.children or []:
  2964. _render_artifact_node(doc, child, buckets)
  2965. fallback_project_name = (
  2966. (placeholder_map or {}).get("项目名称")
  2967. or (analysis.project_name if analysis is not None else "")
  2968. )
  2969. _add_fallback_header_footer(
  2970. doc,
  2971. placeholder_map=placeholder_map,
  2972. project_name=fallback_project_name,
  2973. )
  2974. doc.save(path)
  2975. if not os.path.isfile(path) or os.path.getsize(path) == 0:
  2976. raise RuntimeError(f"章节 DOCX 保存失败: {path}")
  2977. return path
  2978. def _is_markdown_table_row(line: str) -> bool:
  2979. """判断一行是否是 Markdown 表格行"""
  2980. return line.strip().startswith("|") and line.strip().endswith("|") and line.count("|") >= 2
  2981. def _has_separator_row(lines: list, start_idx: int) -> bool:
  2982. """检查下一行是否是表格分隔行(|---|---|)"""
  2983. if start_idx >= len(lines):
  2984. return False
  2985. line = lines[start_idx].strip()
  2986. return bool(re.match(r"^\|[\s\-:|]+\|$", line))
  2987. def _extract_table_block(lines: list, start_idx: int) -> tuple:
  2988. """提取 Markdown 表格块
  2989. Returns:
  2990. (table_lines, next_idx): 表格行列表和表格结束后的下一个行索引
  2991. """
  2992. table_lines = []
  2993. i = start_idx
  2994. while i < len(lines):
  2995. line = lines[i].strip()
  2996. if _is_markdown_table_row(line):
  2997. table_lines.append(line)
  2998. i += 1
  2999. else:
  3000. break
  3001. return table_lines, i
  3002. def _render_table_block(doc, table_lines: list) -> None:
  3003. """将 Markdown 表格行列表渲染为 DOCX 表格"""
  3004. from docx.shared import Pt
  3005. if len(table_lines) < 2:
  3006. return
  3007. # 解析所有行
  3008. rows_data = []
  3009. for line in table_lines:
  3010. cells = [c.strip() for c in line.strip().strip("|").split("|")]
  3011. rows_data.append(cells)
  3012. # 过滤分隔行(---|---)
  3013. data_rows = [r for r in rows_data if not all(re.match(r"^[\-:\s]+$", c) for c in r)]
  3014. if not data_rows:
  3015. return
  3016. num_cols = max(len(r) for r in data_rows)
  3017. # 创建 DOCX 表格
  3018. table = doc.add_table(rows=len(data_rows), cols=num_cols, style="Table Grid")
  3019. for row_idx, row_data in enumerate(data_rows):
  3020. for col_idx in range(num_cols):
  3021. cell = table.cell(row_idx, col_idx)
  3022. cell_text = row_data[col_idx] if col_idx < len(row_data) else ""
  3023. # 清空默认段落
  3024. for p in cell.paragraphs:
  3025. p.clear()
  3026. run = cell.paragraphs[0].add_run(cell_text)
  3027. run.font.size = Pt(10)
  3028. # 表头加粗
  3029. if row_idx == 0:
  3030. run.bold = True
  3031. # 表格后加空行
  3032. doc.add_paragraph()
  3033. __all__ = [
  3034. "write_content",
  3035. # V2 导出
  3036. "fill_all_tables",
  3037. "resolve_all_placeholders",
  3038. "clean_ai_commentary",
  3039. "strip_markdown_headings",
  3040. "_persist_all_chapter_docs",
  3041. ]