| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390 |
- """
- Step 4:投书内容撰写(V2 更新)
- 三套策略协同:
- 1. 表/函填充模式(V2 新增):逻辑+LLM 协同填充资格响应表、投标函等
- 2. 模板改写模式(保留):解析模板 → 逐章基于模板改写
- 3. 从零生成模式(降级):模板不可用时,LLM 从零撰写
- 流程:
- 解析模板 → 格式比对 → 提取公司信息 → 构建占位符映射 →
- 表/函填充 → 逐章撰写 → 占位符解析
- """
- from __future__ import annotations
- import logging
- import json
- import os
- import re
- import xml.etree.ElementTree as ET
- from copy import deepcopy
- from concurrent.futures import ThreadPoolExecutor, as_completed
- from typing import Any, Dict, List, Optional, Tuple
- from models import (
- BidOutline,
- Chapter,
- ChapterType,
- CompanyInfo,
- ProjectData,
- TenderAnalysis,
- TemplateStructure,
- )
- from config import get_config
- from step4_writing.chapter_writer import (
- _ChapterWriter,
- clean_ai_commentary,
- replace_bracket_placeholders,
- strip_markdown_headings,
- )
- from step4_writing.content_injector import _ContentInjector
- from step4_writing.company_info_extractor import extract_company_info
- from step4_writing.placeholder_engine import (
- build_placeholder_map,
- apply_placeholders,
- fill_company_static_text,
- resolve_all_placeholders,
- )
- from step4_writing.table_form_filler import fill_all_tables
- from step3_outlining.template_parser import (
- parse_template,
- get_template_text_for_chapter,
- get_template_text_for_section,
- )
- logger = logging.getLogger(__name__)
- _EVALUATION_INDEX_KEYWORDS = (
- "索引表", "主要内容索引", "评标有关的投标文件主要内容索引",
- )
- _TEMPLATE_PART_MARKERS = frozenset(
- {"商务部分", "技术部分", "附件", "附录"}
- )
- _BOUND_BLOCK_MAX_TOKENS_BASE = 65536
- # 初始调用后再按 max_tokens 翻倍重试 4 次,仍缺节点才进入要求兜底。
- _BOUND_BLOCK_RETRY_ATTEMPTS = 4
- _BOUND_BLOCK_PROMPT = """你是投标文件评分响应撰写专家。系统已经完成模板原文保留、
- 原生表格处理计划和项目信息填充。你只能为给出的 Step3 最终标题节点生成独立正文块。
- 硬性要求:
- 1. 每个 node_id 必须恰好返回一个 content;不得新增、改名或重复输出任何标题;
- 2. 不得输出 Markdown 表格、HTML、图片说明、签名、公章、日期或表/函结构;
- 3. 模板复用节点只写评分所需的针对性补充,不复述或重构模板原文;
- 4. 必须分析该节点已有模板正文(template_content);模板已覆盖的内容不得重复承诺,
- 只补充模板未覆盖的针对性分析、措施或项目信息,且不得与模板原文冲突;
- 5. 新增节点写完整响应正文,但只能依据提供的招标要求、项目信息和真实材料;
- 6. 不得编造证书、业绩、人员、数值或承诺;资料不足时明确写“按招标文件要求响应,
- 证明材料见对应附件/表单”,不得虚构细节;
- 7. content 使用普通段落,可分段,但不得使用“一、”“(一)”“1.”等标题型编号。
- 8. 章节交叉引用只能使用 final_chapter_index 中给出的真实章号;不得引用清单外的
- “第X章”,无法确定时写“详见本投标文件相应章节及附件”。
- 输出:{"blocks":[{"node_id":"3.1","content":"正文"}]}"""
- def _walk_nodes(chapter: Chapter):
- yield chapter
- for child in chapter.children or []:
- yield from _walk_nodes(child)
- def _is_evaluation_index_chapter(chapter: Chapter) -> bool:
- return any(
- keyword in (chapter.title or "")
- for keyword in _EVALUATION_INDEX_KEYWORDS
- )
- def _is_deferred_evaluation_index(chapter: Chapter) -> bool:
- return _is_evaluation_index_chapter(chapter) and any(
- block.get("block_type") == "deferred_evaluation_index"
- for block in (chapter.content_blocks or [])
- )
- def _defer_evaluation_index_chapter(
- chapter: Chapter,
- template_text: str,
- placeholder_map: Dict[str, str],
- company_info: CompanyInfo,
- analysis: TenderAnalysis,
- ) -> Chapter:
- """Step4 只保留索引模板;行内容和页码等待最终合并分页后构建。"""
- filled_template = _resolve_chapter_text_placeholders(
- template_text or "", placeholder_map
- )
- filled_template = replace_bracket_placeholders(
- filled_template, company_info=company_info, analysis=analysis
- )
- chapter.generated_content = filled_template
- chapter.preserve_template_layout = True
- chapter.template_fill_completed = True
- chapter.content_blocks = [
- {
- "order": 0,
- "block_type": "template_base",
- "template_original_id": chapter.template_original_id,
- "template_chars": len(template_text or ""),
- "project_fill_completed": True,
- },
- {
- "order": 1,
- "block_type": "deferred_evaluation_index",
- "action": "build_rows_and_pages_after_final_merge_and_pagination",
- "entry_count": 0,
- },
- ]
- return chapter
- def _sanitize_bound_block(text: str) -> str:
- """把 LLM 响应收敛为不会被识别为新标题/表格的普通正文块。"""
- lines = []
- for raw in clean_ai_commentary(text or "").splitlines():
- line = raw.strip()
- if not line:
- if lines and lines[-1] != "":
- lines.append("")
- continue
- if line.startswith("|") and line.endswith("|"):
- continue
- line = re.sub(r"^#{1,6}\s*", "", line)
- heading = re.match(
- r"^(?:[一二三四五六七八九十百]+[、,]|"
- r"[((][一二三四五六七八九十百\d]+[))]|"
- r"\d+[、..])\s*(.+)$",
- line,
- )
- if heading:
- line = f"{heading.group(1).strip()}:"
- lines.append(line)
- return "\n".join(lines).strip()
- def _chapter_title_key(text: str) -> str:
- value = re.sub(r"^第\s*[一二三四五六七八九十百\d]+\s*章\s*", "", text or "")
- value = re.sub(r"[《》“”「」()()\s]+", "", value)
- return value.strip()
- def _sanitize_chapter_references(content: str, outline: BidOutline) -> str:
- """把 LLM 输出中不在最终大纲里的“第X章”引用改写为正确章号或通用表述。"""
- if not content:
- return content
- from step3_outlining.outline_generator import _chinese_to_int
- valid_numbers = set()
- chapter_by_title = {}
- for chapter in outline.chapters or []:
- if str(chapter.id).isdigit():
- valid_numbers.add(str(int(chapter.id)))
- title_key = _chapter_title_key(chapter.title)
- if title_key:
- chapter_by_title.setdefault(title_key, chapter)
- def _is_valid(num_text: str) -> bool:
- if num_text.isdigit():
- return str(int(num_text)) in valid_numbers
- try:
- return str(_chinese_to_int(num_text)) in valid_numbers
- except Exception:
- return False
- quoted_pattern = re.compile(
- r"第\s*([一二三四五六七八九十百\d]+)\s*章\s*[《“「]"
- r"([^》”」]{1,40})[》”」]"
- )
- def _quoted_repl(match: re.Match) -> str:
- num_text = match.group(1)
- title_text = match.group(2).strip()
- if _is_valid(num_text):
- return match.group(0)
- target = chapter_by_title.get(_chapter_title_key(title_text))
- if target is not None and str(target.id).isdigit():
- return f"第{_native_chapter_number(target)}章《{target.title}》"
- return "本投标文件相应章节及附件"
- content = quoted_pattern.sub(_quoted_repl, content)
- plain_pattern = re.compile(r"(?:投标文件)?第\s*([一二三四五六七八九十百\d]+)\s*章")
- def _plain_repl(match: re.Match) -> str:
- if _is_valid(match.group(1)):
- return match.group(0)
- return "本投标文件相应章节及附件"
- return plain_pattern.sub(_plain_repl, content)
- _REJECTION_CONSEQUENCE_SUFFIXES = (
- r"(?:,|;|。)?(?:其)?投标(?:将)?(?:被)?(?:认定为|作为|按)?无效(?:投标)?",
- r"(?:,|;|。)?(?:其)?投标无效",
- r"(?:,|;|。)?(?:招标人|采购人|评标委员会)?(?:将|可|有权)?(?:予以)?拒绝"
- r"(?:其)?(?:投标|接收|参加本次采购活动)?",
- r"(?:,|;|。)?不能通过(?:资格性|符合性)?审查",
- )
- def _compact_rejection_requirement(text: str) -> str:
- """保留废标条件本体,去掉每条末尾重复出现的处理后果。"""
- value = re.sub(r"\s+", "", str(text or "")).strip("。;; ")
- previous = None
- while value and value != previous:
- previous = value
- for suffix in _REJECTION_CONSEQUENCE_SUFFIXES:
- value = re.sub(f"{suffix}[。;; ]*$", "", value).strip(",。;; ")
- return value
- def _compact_rejection_supplement(
- rejection_entries: List[dict],
- analysis: TenderAnalysis,
- ) -> str:
- """把要求承诺函的废标项按上游类别归并,避免逐项重复套话。"""
- items_by_id = {
- str(item.id): item for item in (analysis.rejection_items or [])
- }
- grouped: Dict[str, List[str]] = {}
- seen = set()
- for entry in rejection_entries:
- source_id = str(entry.get("source_id", "") or entry.get("entry_id", ""))
- item = items_by_id.get(source_id)
- category = str(getattr(item, "category", "") or "其他实质性要求").strip()
- requirement = _compact_rejection_requirement(
- getattr(item, "description", "")
- or entry.get("requirement", "")
- )
- fingerprint = re.sub(r"[\s,。;;::]", "", requirement)
- if not requirement or fingerprint in seen:
- continue
- seen.add(fingerprint)
- grouped.setdefault(category, []).append(requirement)
- if not grouped:
- return ""
- lines = [
- "我方郑重承诺:严格遵守招标文件、采购需求及相关澄清文件的全部实质性要求,"
- "确保投标文件真实、完整、有效,并重点做到如下事项:"
- ]
- for category, requirements in grouped.items():
- lines.append(f"{category}方面:{';'.join(requirements)}。")
- return "\n".join(lines)
- def _generate_direct_content_blocks(
- chapter: Chapter,
- outline: BidOutline,
- analysis: TenderAnalysis,
- injector: "_ContentInjector",
- writer: "_ChapterWriter",
- ) -> int:
- """只为 Step3 直接绑定节点生成独立补充块,不改模板基块。"""
- entries = list(getattr(outline, "evaluation_index_entries", []) or [])
- entries_by_heading: Dict[str, List[dict]] = {}
- for entry in entries:
- heading_id = str(entry.get("final_heading_id", "") or "")
- if heading_id:
- entries_by_heading.setdefault(heading_id, []).append(entry)
- nodes = [
- node for node in _walk_nodes(chapter)
- if entries_by_heading.get(str(node.id))
- ]
- if not nodes:
- return 0
- scoring_nodes = [
- node for node in nodes
- if any(
- entry.get("entry_type") == "scoring"
- for entry in entries_by_heading[str(node.id)]
- )
- ]
- generated_by_id: Dict[str, str] = {}
- if scoring_nodes:
- template_buckets = _split_artifact_content_by_outline(chapter)
- def _request_nodes_for(nodes_subset) -> List[dict]:
- request_nodes = []
- for node in nodes_subset:
- node_entries = [
- entry for entry in entries_by_heading[str(node.id)]
- if entry.get("entry_type") == "scoring"
- ]
- request_nodes.append({
- "node_id": node.id,
- "title": node.title,
- "mode": node.content_generation_mode or "project_scoring",
- "requirements": [
- {
- "id": entry.get("source_id", ""),
- "name": entry.get("display_name", ""),
- "requirement": entry.get("requirement", ""),
- }
- for entry in node_entries
- ],
- "step3_description": node.description,
- "template_content": "\n".join(
- template_buckets.get(str(node.id), [])
- ).strip()[:4000],
- })
- return request_nodes
- def _request_payload(nodes_subset) -> Dict[str, object]:
- return {
- "project": {
- "name": analysis.project_name,
- "buyer": analysis.buyer_name,
- "project_id": analysis.tender_project_id,
- },
- "final_chapter_index": [
- {
- "id": chapter.id,
- "title": chapter.title,
- "display": (
- f"第{_native_chapter_number(chapter)}章 {chapter.title}"
- if str(chapter.id).isdigit()
- else chapter.title
- ),
- }
- for chapter in outline.chapters or []
- ],
- "nodes": _request_nodes_for(nodes_subset),
- "tender_context": injector.get_tender_context(chapter)[:10000],
- "material_context": injector.get_material_context(chapter)[:8000],
- "reference_context": injector.get_reference_context(chapter)[:5000],
- }
- def _consume_blocks(result, target: Dict[str, str]) -> None:
- raw_blocks = result.get("blocks", []) if isinstance(result, dict) else []
- if not isinstance(raw_blocks, list):
- return
- allowed = {str(node.id) for node in scoring_nodes}
- for block in raw_blocks:
- if not isinstance(block, dict):
- continue
- node_id = str(block.get("node_id", "") or "")
- if node_id in allowed and node_id not in target:
- content = _sanitize_bound_block(str(block.get("content", "")))
- content = _sanitize_chapter_references(content, outline)
- if content:
- target[node_id] = content
- try:
- max_tokens = _BOUND_BLOCK_MAX_TOKENS_BASE
- missing = list(scoring_nodes)
- for attempt in range(_BOUND_BLOCK_RETRY_ATTEMPTS + 1):
- if not missing:
- break
- payload = _request_payload(missing)
- result = writer.llm.extract_json(
- system_prompt=_BOUND_BLOCK_PROMPT,
- user_prompt=json.dumps(payload, ensure_ascii=False),
- max_tokens=max_tokens,
- )
- _consume_blocks(result, generated_by_id)
- missing = [
- node for node in scoring_nodes
- if str(node.id) not in generated_by_id
- ]
- if not missing:
- break
- if attempt >= _BOUND_BLOCK_RETRY_ATTEMPTS:
- break
- logger.warning(
- f" [{chapter.id}] 评分块缺失 {len(missing)} 个节点,"
- f"第 {attempt + 1} 次重试 max_tokens={max_tokens} -> "
- f"{min(max_tokens * 2, 65536)}: "
- f"{[str(node.id) for node in missing]}"
- )
- max_tokens = min(max_tokens * 2, 65536)
- except Exception as exc:
- logger.warning(f" [{chapter.id}] 评分内容块批量生成失败,使用要求兜底: {exc}")
- completed = 0
- for node in nodes:
- node_entries = entries_by_heading[str(node.id)]
- parts = []
- scoring_entries = [e for e in node_entries if e.get("entry_type") == "scoring"]
- rejection_entries = [e for e in node_entries if e.get("entry_type") == "rejection"]
- if scoring_entries:
- parts.append(
- generated_by_id.get(str(node.id))
- or "\n".join(
- (
- f"针对{entry.get('display_name', '本评分要求')},我方已结合"
- f"{(analysis.project_name or '本项目')}的实际情况、服务范围及"
- f"{(analysis.buyer_name or '采购人')}的管理要求,对相关事项进行"
- f"分析并制定针对性响应:{entry.get('requirement', '')}。"
- f"具体措施及证明材料详见本投标文件相关章节及附件。"
- )
- for entry in scoring_entries
- )
- )
- if rejection_entries:
- parts.append(_compact_rejection_supplement(rejection_entries, analysis))
- supplement = _sanitize_bound_block("\n\n".join(part for part in parts if part))
- if not supplement:
- continue
- node.supplement_content = supplement
- if node is not chapter or not chapter.preserve_template_layout:
- node.generated_content = supplement
- supplement_blocks = ([{
- "order": 2,
- "block_type": "scoring_supplement",
- "source_ids": [e.get("source_id", "") for e in scoring_entries],
- "target_heading_id": node.id,
- }] if scoring_entries else []) + ([{
- "order": 3,
- "block_type": "rejection_supplement",
- "source_ids": [e.get("source_id", "") for e in rejection_entries],
- "target_heading_id": node.id,
- }] if rejection_entries else [])
- node.content_blocks = [
- block for block in (node.content_blocks or [])
- if block.get("block_type") not in {
- "scoring_supplement", "rejection_supplement"
- }
- ] + supplement_blocks
- if node is not chapter:
- target_id = str(node.id)
- chapter.content_blocks = [
- block for block in (chapter.content_blocks or [])
- if not (
- block.get("block_type") in {
- "scoring_supplement", "rejection_supplement"
- }
- and str(block.get("target_heading_id", "")) == target_id
- )
- ] + [dict(block) for block in supplement_blocks]
- completed += 1
- return completed
- def _get_template_text_for_chapter(
- template_structure,
- chapter,
- ) -> str:
- """获取模板章节原文;新增评分章无模板正文,模板章按来源编号兜底。
- 商务章(如第二章"投标报价")要求严格按模板格式书写。
- 若目录标题与模板标题不一致(如目录用默认"商务响应文件"而模板是
- "投标报价"),按模板来源编号回退,避免用顺延后的最终编号读取其他章。
- """
- if getattr(chapter, "is_scoring_inserted", False):
- return ""
- # Step 3 已匹配并持久化的模板区域名 → 精确取文本,避免再次标题匹配
- section_name = getattr(chapter, "template_section", "") or ""
- if section_name:
- text = get_template_text_for_section(template_structure, section_name)
- if text:
- return text
- text = get_template_text_for_chapter(template_structure, chapter.title)
- if text or not getattr(chapter, "id", "") or not chapter.id.isdigit():
- return text
- try:
- from step3_outlining.template_parser import get_template_chapter_titles
- titles = get_template_chapter_titles(template_structure.file_path)
- source_number = _template_chapter_number(chapter)
- tmpl_title = dict(titles).get(source_number, "")
- if tmpl_title:
- text = get_template_text_for_chapter(template_structure, tmpl_title)
- if text:
- logger.info(
- f" 章节 [{chapter.id}] 标题未直接匹配模板,"
- f"已按模板来源编号 [{source_number}] 回退到模板标题: {tmpl_title}"
- )
- except Exception as e:
- logger.debug(f"按章节编号取模板文本失败: {e}")
- return text
- def _partition_step4_chapters(outline: BidOutline) -> Tuple[List[Chapter], List[Chapter]]:
- """普通商务/技术章统一并发;依赖全书的索引章保持延迟。"""
- ordinary = []
- deferred = []
- for chapter in outline.chapters:
- if chapter.depends_on_all or _is_evaluation_index_chapter(chapter):
- deferred.append(chapter)
- else:
- ordinary.append(chapter)
- return ordinary, deferred
- def write_content(
- outline: BidOutline,
- analysis: TenderAnalysis,
- project_data: ProjectData,
- template_path: str = "",
- reference_bid_path: str = "",
- chapter_output_dir: Optional[str] = None,
- ) -> BidOutline:
- """逐章撰写投书内容(模板改写优先)
- Args:
- outline: Step 3 输出的章节目录
- analysis: Step 2 输出的招标分析
- project_data: Step 1 输出的项目数据
- template_path: 模板 DOCX 路径(空则使用默认模板)
- reference_bid_path: 参考投标文件路径(用于 diff 识别模板骨架)
- Returns:
- 填充了 generated_content 的 BidOutline
- """
- cfg = get_config()
- # ---- 0. 尝试加载模板 ----
- template_structure: Optional[TemplateStructure] = None
- if not template_path:
- template_path = os.path.join(
- os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
- "templates", "申勤投标模板.docx",
- )
- if os.path.exists(template_path):
- try:
- template_structure = parse_template(
- template_path=template_path,
- reference_bid_path=reference_bid_path,
- )
- logger.info(
- f"模板解析完成: {len(template_structure.sections)} 个区域, "
- f"{template_structure.total_tables} 个表格"
- )
- except Exception as e:
- logger.warning(f"模板解析失败,将使用从零生成模式: {e}")
- template_structure = None
- # ---- 1. 提取公司信息 ----
- company_info = extract_company_info(project_data.general_materials)
- # ---- 2. 构建占位符映射 ----
- placeholder_map = build_placeholder_map(analysis, company_info, project_data)
- # ---- 3. V2: 格式比对 + 表/函填充 ----
- format_reconciled = False
- if template_structure:
- # 尝试格式比对(如果招标 PDF 可用)
- tender_pdf = (
- project_data.tender_docs[0].file_path
- if project_data.tender_docs else ""
- )
- if tender_pdf and tender_pdf.lower().endswith(".pdf"):
- try:
- from step1_parsing.table_extractor import (
- extract_tables_from_template_docx,
- )
- from step2_analysis.format_reconciler import reconcile_formats
- logger.info("开始格式比对...")
- template_tables = extract_tables_from_template_docx(template_path)
- # 招标表格统一使用提示词+代码方式提取结果(Step 1/Step 2 产物),
- # 不再用纯结构提取重复生成,保证名称/分类与 LLM 识别一致。
- tender_tables = list(analysis.tender_tables or [])
- if not tender_tables and project_data.extracted_items:
- tender_tables = [
- it.source_table
- for it in project_data.extracted_items
- if it.source_table is not None
- ]
- if not tender_tables and not project_data.extracted_items:
- from step1_parsing import extract_tender_items_with_tables
- items, tender_tables = extract_tender_items_with_tables(
- tender_file=tender_pdf,
- )
- project_data.extracted_items = items
- analysis.tender_tables = tender_tables
- reconciliation_report = reconcile_formats(
- template_tables, tender_tables, use_llm=True,
- )
- logger.info(f"格式比对完成: {reconciliation_report.summary}")
- # 对模板 DOCX 执行表/函填充
- # 注意:此时的 doc 在 Step 6 才加载,这里先记录比对结果
- # 将 reconciliation 存入 project_data 的元数据中供 Step 6 使用
- project_data.metadata["reconciliation_report"] = reconciliation_report
- format_reconciled = True
- except Exception as e:
- logger.warning(f"格式比对失败,跳过表/函填充: {e}")
- else:
- # 非 PDF 招标文件(如 .docx/.md):无法做表格级格式比对。
- # 明确告警而不是静默跳过,避免占位符原样残留却无人知晓。
- logger.warning(
- f"招标文件不是 PDF({tender_pdf or '空'}),"
- f"跳过格式比对/表函替换;"
- f"模板表格仍由 Step 6 逻辑填充,招标表格式将不替换"
- )
- # ---- 4. 加载通用资料和参考 ----
- material_map: Dict[str, str] = {}
- for doc in project_data.general_materials:
- if doc.content:
- material_map[doc.file_name] = doc.content
- reference_texts: List[str] = []
- for doc in project_data.reference_bids:
- if doc.content:
- reference_texts.append(doc.content)
- # 招标相关文件全文(招标文件/采购需求/澄清公告),作为章节生成依据
- tender_texts: List[str] = []
- for doc in (
- list(project_data.tender_docs)
- + list(project_data.procurement_docs)
- + list(project_data.clarification_docs)
- ):
- if doc.content:
- tender_texts.append(doc.content)
- # ---- 4.1 项目信息净化(V2.6 新增)----
- # 通用资料/参考标书常来自历史项目,可能包含其他项目名称、场所、地址
- # (如"嘉定区行政服务中心、档案馆等物业管理服务项目")。若直接注入
- # LLM 提示词,生成内容会引用历史项目信息,造成内容污染。此处统一将
- # 其他项目标识替换为当前招标项目的名称/采购人/地址。
- from step4_writing.project_info_sanitizer import (
- discover_foreign_project_names,
- extract_current_project_address,
- is_achievement_material,
- sanitize_project_identity,
- )
- current_project_name = analysis.project_name or outline.project_name or ""
- current_venue = analysis.buyer_name or current_project_name
- current_address = extract_current_project_address(
- [d.content for d in project_data.tender_docs]
- + [d.content for d in project_data.procurement_docs]
- )
- foreign_names = discover_foreign_project_names(
- list(material_map.values()) + reference_texts,
- current_project_name,
- )
- if current_address:
- analysis.raw_text_snippets["project_address"] = current_address
- if foreign_names:
- logger.info(
- f" 参考资料中发现其他项目标识 {len(foreign_names)} 个: "
- f"{foreign_names[:5]}"
- )
- for fname, content in list(material_map.items()):
- preserve_achievements = is_achievement_material(fname)
- material_map[fname] = sanitize_project_identity(
- content,
- foreign_names,
- current_project_name,
- current_venue,
- current_address=current_address,
- replace_short_venue=not preserve_achievements,
- replace_address=not preserve_achievements,
- )
- injector = _ContentInjector(material_map, reference_texts, tender_texts)
- # ---- 5. 章节撰写(V2:先技术后商务 + 并发) ----
- # 本轮 Step 4 对每章重新建立内容来源。默认是生成/改写内容;只有明确把
- # 模板章节原文直接载入 generated_content 的分支才改为 True。旧 pickle
- # 没有该字段时由 Step 6 以有界比较兼容,不能再对整章做无界相似度计算。
- for chapter in outline.chapters:
- chapter.preserve_template_layout = False
- # 普通商务/技术章统一并发;依赖全书的非索引章延迟撰写;评标索引章
- # 只保留模板,直到 Step6 合并、检查并稳定分页后再构建行与页码。
- ordinary, deferred = _partition_step4_chapters(outline)
- index_chapters = [ch for ch in deferred if _is_evaluation_index_chapter(ch)]
- deferred = [ch for ch in deferred if not _is_evaluation_index_chapter(ch)]
- technical = [
- chapter for chapter in ordinary
- if chapter.chapter_type == ChapterType.TECHNICAL
- ]
- business = [
- chapter for chapter in ordinary
- if chapter.chapter_type == ChapterType.BUSINESS
- ]
- ordinary = technical + business
- logger.info(
- f"章节分组: 普通章节{len(ordinary)}章统一并发 "
- f"(技术{len(technical)} + 商务{len(ordinary) - len(technical)}) → "
- f"延迟依赖章节{len(deferred)}章;"
- f"评标索引章{len(index_chapters)}章仅保留模板"
- )
- for ch in index_chapters:
- template_text = (
- _get_template_text_for_chapter(template_structure, ch)
- if template_structure else ""
- )
- _defer_evaluation_index_chapter(
- ch, template_text, placeholder_map, company_info, analysis
- )
- ch.content_blocks[1]["entry_count"] = len(
- getattr(outline, "evaluation_index_entries", []) or []
- )
- logger.info(
- f" [{ch.id}] {ch.title}: Step4 跳过内容和页码生成,"
- f"保留 {len(outline.evaluation_index_entries)} 条最终索引映射"
- )
- # ---- 阶段 A: 普通技术章和商务章统一并发 ----
- failed_chapters: List[str] = []
- if ordinary:
- total_ordinary = len(ordinary)
- max_workers = min(cfg.max_concurrent_writers, total_ordinary)
- logger.info(
- f"阶段 A: 并发处理 {total_ordinary} 个普通章节 "
- f"(max_workers={max_workers})"
- )
- with ThreadPoolExecutor(max_workers=max_workers) as executor:
- future_to_chapter = {}
- for ch in ordinary:
- chapter_writer = _ChapterWriter()
- future = executor.submit(
- _write_single_chapter,
- chapter=ch,
- writer=chapter_writer,
- injector=injector,
- analysis=analysis,
- company_info=company_info,
- template_structure=template_structure,
- cfg=cfg,
- outline=outline,
- placeholder_map=placeholder_map,
- table_fill_planned=format_reconciled,
- )
- future_to_chapter[future] = ch
- completed = 0
- for future in as_completed(future_to_chapter):
- ch = future_to_chapter[future]
- completed += 1
- try:
- updated_chapter = future.result()
- _update_chapter_in_outline(outline, updated_chapter)
- words = len(updated_chapter.generated_content)
- logger.info(
- f" [{ch.id}] {ch.title[:30]}: {words:,} 字 "
- f"({completed}/{total_ordinary})"
- )
- except Exception as e:
- logger.exception(f" [{ch.id}] 撰写失败: {e}")
- failed_chapters.append(ch.id)
- ch.generated_content = f"【撰写失败: {e}】"
- # ---- 阶段 B: 延迟章节(完整上下文) ----
- if deferred:
- logger.info(f"阶段 B: 撰写 {len(deferred)} 个延迟章节(完整上下文)")
- deferred_writer = _ChapterWriter()
- for ch in deferred:
- template_text = ""
- if template_structure:
- template_text = _get_template_text_for_chapter(
- template_structure, ch
- )
- if template_text and ch.chapter_type == ChapterType.BUSINESS:
- ch.generated_content = template_text
- ch.preserve_template_layout = True
- logger.info(
- f" [{ch.id}] 延迟商务章,"
- f"已载入模板章节内容 {len(template_text):,} 字"
- )
- continue
- try:
- ch = deferred_writer.write_deferred_chapter(
- chapter=ch,
- analysis=analysis,
- outline=outline,
- template_text=template_text,
- company_info=company_info,
- tender_context=injector.get_tender_context(ch),
- reference_context=injector.get_reference_context(ch),
- )
- words = len(ch.generated_content)
- logger.info(f" [{ch.id}] {ch.title[:30]}: {words:,} 字")
- except Exception as e:
- logger.exception(f" [{ch.id}] 延迟章节撰写失败: {e}")
- ch.generated_content = f"【撰写失败: {e}】"
- if failed_chapters:
- raise RuntimeError(
- f" {len(failed_chapters)} 个章节撰写失败: {failed_chapters}"
- )
- # ---- 阶段 D: 统一解析章节文本中的已知占位符 ----
- chapter_placeholder_map = build_placeholder_map(
- analysis, company_info, project_data
- )
- for ch in outline.flatten():
- if _is_evaluation_index_chapter(ch):
- # 索引模板之外的任何内容均不得在 Step4 生成或改写。
- continue
- ch.generated_content = _resolve_chapter_text_placeholders(
- ch.generated_content,
- chapter_placeholder_map,
- )
- ch.generated_content = replace_bracket_placeholders(
- ch.generated_content,
- company_info=company_info,
- analysis=analysis,
- )
- # 不再用“正文前 12000 字”的 LLM 返回结果覆盖整章;该旧路径会截断
- # 长章节并覆盖前序章节策略。这里只做可重复执行的确定性事实修正。
- ch.generated_content = _override_rejection_conflicts(
- ch.generated_content, analysis, tender_texts
- )
- # 确保废标项内容至少在本章正文中出现一次,而不是只出现在最终清单
- ch.generated_content = _append_missing_rejection_responses(
- ch, analysis
- )
- # 去除章节内重复出现的超长段落(如同一段"物联网+设施设备监测子系统"
- # 文字被 LLM 在不同小节重复输出),并做最终字数保险裁剪
- ch.generated_content = _dedupe_repeated_blocks(ch.generated_content)
- is_template_derived = getattr(ch, "from_template", False) or any(
- getattr(c, "from_template", False)
- for c in (ch.children or [])
- )
- if ch.word_count_target > 0 and not is_template_derived:
- from step4_writing.chapter_writer import trim_to_word_budget
- ch.generated_content = trim_to_word_budget(
- ch.generated_content, ch.word_count_target, tolerance=1.25
- )
- # ---- 阶段 D0.5: 模板内嵌附件(制度/预案等)用通用材料全文回填 ----
- # 模板第四章区域夹着"(五)安全运行管理制度 第一章~第十章"等附件块,
- # 技术章正文替换时这些旧文本会被删除。此处检测各章模板区域内的内嵌
- # 附件 section,用匹配的通用材料(如 管理制度.md)全文替换到章末,
- # 不做摘要;找不到匹配材料才保留模板原文,避免附件内容消失。
- try:
- _chapter_ctx = {}
- for _ch in outline.chapters:
- if _ch.chapter_type == ChapterType.TECHNICAL:
- _chapter_ctx[_ch.id] = {
- "tender": injector.get_tender_context(_ch),
- "reference": injector.get_reference_context(_ch),
- }
- _append_template_attachments_with_materials(
- outline, template_structure, material_map, _chapter_ctx
- )
- except Exception as e:
- logger.warning(f"模板内嵌附件材料回填失败: {e}")
- # ---- 阶段 D1: 生成内容项目信息净化(兜底,V2.6)----
- # 即使材料已净化,LLM 仍可能从参考标书等处引入历史项目名称/场所,
- # 此处对最终章节内容做确定性替换(仅替换项目全名与场所全名,不替换
- # 场所短名/地址,避免误伤"类似业绩"中的历史客户名单)。
- if foreign_names:
- replaced_total = 0
- for ch in outline.flatten():
- cleaned = sanitize_project_identity(
- ch.generated_content,
- foreign_names,
- current_project_name,
- current_venue,
- replace_short_venue=False,
- replace_address=False,
- )
- if cleaned != ch.generated_content:
- replaced_total += 1
- ch.generated_content = cleaned
- if replaced_total:
- logger.info(
- f" 生成内容项目信息净化: {replaced_total} 个章节完成替换"
- )
- # 用户授权的唯一正文重写/子标题扩展特例,在通用裁剪之后执行。
- from step4_writing.difficulty_rewrite import rewrite_difficulty_sections
- rewrite_difficulty_sections(outline, analysis, project_data)
- # ---- 阶段 E: 全部章节统一落盘;完成后才允许返回给审核/导出 ----
- if cfg.save_chapter_docs:
- _persist_all_chapter_docs(
- outline,
- chapter_output_dir or cfg.output_dir,
- template_path=template_path,
- placeholder_map=chapter_placeholder_map,
- company_info=company_info,
- analysis=analysis,
- project_data=project_data,
- )
- # ---- 字数汇总 ----
- total_words = sum(len(c.generated_content) for c in outline.chapters)
- logger.info(f"撰写完成: 总字数 {total_words:,} / 目标 {cfg.min_word_count:,}")
- return outline
- def _write_single_chapter(
- chapter: Chapter,
- writer: "_ChapterWriter",
- injector: "_ContentInjector",
- analysis: TenderAnalysis,
- company_info: CompanyInfo,
- template_structure: Optional[TemplateStructure],
- cfg,
- outline: BidOutline,
- placeholder_map: Dict[str, str],
- table_fill_planned: bool = False,
- ) -> Chapter:
- """在独立线程中撰写单个章节(供 ThreadPoolExecutor 使用)
- 这是一个纯函数:接收 Chapter 输入,返回填充后的 Chapter。
- 不访问任何共享可变状态。
- Args:
- chapter: 待撰写的章节
- writer: 独立的 _ChapterWriter 实例(每线程一个)
- injector: _ContentInjector(只读,可共享)
- analysis: 招标分析结果(只读)
- company_info: 公司信息(只读)
- template_structure: 模板结构(只读)
- cfg: 配置(只读)
- Returns:
- 填充了 generated_content 的 Chapter
- """
- ch_id = chapter.id
- logger.debug(f" [{ch_id}] 线程开始...")
- # 获取上下文
- scoring_context = injector.get_scoring_context(chapter, analysis)
- # 需求理解章节完整保留模板原文,仅以相关评分项、招标要求和采购需求
- # 驱动授权评分小节的增补正文。
- if "需求理解" in (chapter.title or ""):
- understanding = [
- c for c in analysis.scoring_criteria
- if (
- ("需求理解" in (c.name or ""))
- or ("需求理解" in (c.category or ""))
- or (c.id in (chapter.related_criteria or []))
- )
- ]
- if understanding:
- criteria_lines = "\n".join(
- f"- 【{c.id}】{c.name}({c.max_score}分):{c.description}"
- for c in understanding
- )
- unique_req = (
- "\n\n【需求理解专项要求】本章必须围绕以下评分项内容,"
- "结合本项目实际背景、服务对象、采购需求与难点,"
- "展开独特、有针对性的需求理解与分析。模板原文不得删除或改写,"
- "只撰写 Step3 已授权的评分小节,且新增内容必须能追溯到评分项、"
- "与需求理解相关的废标项、招标要求或采购需求,"
- "不得照搬模板通用表述:\n"
- f"{criteria_lines}"
- )
- scoring_context = (scoring_context or "") + unique_req
- rejection_context = injector.get_rejection_context(chapter, analysis)
- response_context = injector.get_response_context(chapter, analysis)
- material_context = injector.get_material_context(chapter)
- reference_context = injector.get_reference_context(chapter)
- tender_context = injector.get_tender_context(chapter)
- # 模板原文
- template_text = ""
- if template_structure:
- template_text = _get_template_text_for_chapter(
- template_structure, chapter
- )
- if template_text:
- logger.debug(f" [{ch_id}] 找到模板原文 {len(template_text):,} 字符")
- else:
- logger.debug(f" [{ch_id}] 模板中未找到对应章节,将从零生成")
- # 模板章统一采用“模板基块 → 项目/表格填充计划 → 直接绑定补充块”。
- # 不把整章或全部模板子节交给 LLM 重写,因此原生表格、图片、签名及
- # 相邻说明的 XML 顺序仍由 Step6 模板装配路径保持。
- if template_text:
- filled_template = _resolve_chapter_text_placeholders(
- template_text, placeholder_map
- )
- filled_template = replace_bracket_placeholders(
- filled_template,
- company_info=company_info,
- analysis=analysis,
- )
- chapter.generated_content = filled_template
- chapter.preserve_template_layout = True
- chapter.template_fill_completed = True
- chapter.content_blocks = [
- {
- "order": 0,
- "block_type": "template_base",
- "template_original_id": chapter.template_original_id,
- "template_chars": len(template_text),
- "project_fill_completed": True,
- },
- {
- "order": 1,
- "block_type": "native_table_plan",
- "action": (
- "apply_reconciliation_then_authorized_fill"
- if table_fill_planned
- else "preserve_structure_and_fill_authorized_cells"
- ),
- },
- ]
- supplement_count = _generate_direct_content_blocks(
- chapter, outline, analysis, injector, writer
- )
- logger.info(
- f" [{ch_id}] 模板基块 {len(filled_template):,} 字,"
- f"直接补充块 {supplement_count} 个"
- )
- return chapter
- has_direct_entries = any(
- str(entry.get("final_heading_id", "")).split(".", 1)[0] == str(chapter.id)
- for entry in list(getattr(outline, "evaluation_index_entries", []) or [])
- )
- if has_direct_entries:
- supplement_count = _generate_direct_content_blocks(
- chapter, outline, analysis, injector, writer
- )
- parts = []
- for node in _walk_nodes(chapter):
- if node is chapter:
- continue
- if node.supplement_content:
- parts.append(f"{node.title}\n{node.supplement_content}")
- if chapter.supplement_content:
- parts.insert(0, chapter.supplement_content)
- chapter.generated_content = "\n\n".join(parts).strip()
- chapter.template_fill_completed = True
- chapter.content_blocks = [{
- "order": 0,
- "block_type": "generated_scoring_chapter",
- "supplement_nodes": supplement_count,
- }]
- return chapter
- # 撰写
- chapter = writer.write_chapter(
- chapter=chapter,
- analysis=analysis,
- scoring_context=scoring_context,
- material_context=material_context,
- reference_context=reference_context,
- template_text=template_text,
- company_info=company_info,
- tender_context=tender_context,
- rejection_context=rejection_context,
- response_context=response_context,
- )
- # 字数不足时扩展(最多补到目标字数,不超发);
- # 字数超出目标 120% 时在 write_chapter 内已裁剪,此处仅做最终保险
- words = len(chapter.generated_content)
- is_template_derived = any(
- getattr(c, "from_template", False)
- for c in (chapter.children or [])
- )
- if (
- chapter.word_count_target > 0
- and not is_template_derived
- and cfg.enforce_word_limit
- and words < chapter.word_count_target * 0.9
- ):
- deficit = chapter.word_count_target - words
- logger.debug(f" [{ch_id}] 字数不足,扩展 {deficit:,} 字...")
- try:
- chapter = writer.expand_chapter(chapter, deficit)
- except Exception as e:
- logger.warning(f" [{ch_id}] 扩展失败: {e}")
- words_after = len(chapter.generated_content)
- if (
- chapter.word_count_target > 0
- and not is_template_derived
- and words_after > chapter.word_count_target * 1.2
- ):
- from step4_writing.chapter_writer import trim_to_word_budget
- chapter.generated_content = trim_to_word_budget(
- chapter.generated_content, chapter.word_count_target
- )
- return chapter
- def _update_chapter_in_outline(outline: BidOutline, updated: Chapter) -> None:
- """按 chapter.id 在 outline 中定位并替换章节(线程安全)
- 避免依赖列表索引(并发时索引可能错乱)。
- 若 outline 中存在重复 id(step3 缺陷),只替换与传入对象同 id
- 的那一个槽位,防止并发完成的多个章节相互覆盖。
- """
- matched = None
- for i, ch in enumerate(outline.chapters):
- if ch.id == updated.id:
- if ch is updated:
- matched = i
- break
- if matched is None:
- matched = i
- if matched is not None:
- outline.chapters[matched] = updated
- return
- # 未找到(不应发生)
- logger.warning(f"章节 [{updated.id}] 未在 outline 中找到,无法更新")
- def _resolve_chapter_text_placeholders(
- content: str,
- placeholder_map: Dict[str, str],
- ) -> str:
- """在章节落盘前替换已知的 %%...%% 项目/公司占位符。"""
- result = content or ""
- for key, value in placeholder_map.items():
- if not value:
- continue
- if key in ("服务内容", "服务要求", "服务期限"):
- value = re.sub(r"[。;;]+$", "", value.strip()) + "。"
- pattern = re.compile(
- rf"%{{1,3}}\s*{re.escape(key)}\s*%{{1,3}}"
- )
- result = pattern.sub(lambda _m: value, result)
- # 模板常在占位符后自带句号;来源字段又以句号结尾时,直接替换会产生
- # “。。。”,这里统一收敛为单句号。
- return re.sub(r"。{2,}", "。", result)
- _NUM_VALUE_PATTERN = (
- r"\d+(?:\.\d+)?\s*(?:%|人|名|年|个月|月|天|日|小时|分钟|分|元|万元|台|套|次|个)?"
- )
- _DYNAMIC_METRIC_PATTERN = re.compile(
- r"([\u4e00-\u9fff]{2,16}(?:率|数量|人数|期限|时间|违约金|罚款|金额|比例|面积|岗位|设备|单价|费用|标准|人次|年限))"
- r"[^,。;\n]{0,25}?"
- + _NUM_VALUE_PATTERN
- )
- _FIXED_NUMERIC_RULES = [
- (
- "流动率",
- re.compile(r"(?:人员)?流动率[^,。;\n]{0,20}?\d+(?:\.\d+)?%"),
- re.compile(r"(?:人员)?流动率[^,。;\n]{0,30}?\d+(?:\.\d+)?%"),
- ),
- ]
- def _build_dynamic_numeric_rules(source_texts):
- """从废标项/采购需求/服务要求中动态提取数值型约束规则。"""
- rules = []
- seen_metrics = set()
- for text in source_texts:
- for match in _DYNAMIC_METRIC_PATTERN.finditer(text or ""):
- metric = match.group(1).strip()
- canonical = match.group(0).strip()
- metric = re.sub(
- r"^(本项目|本项目的|供应商|投标人|我公司)[的]?",
- "",
- metric,
- )
- if not metric or metric in seen_metrics:
- continue
- if len(metric) < 2:
- continue
- # canonical 必须从规范化后的指标名开始,否则替换目标中已有的
- # “本项目的/供应商”等前缀会与来源前缀叠加,重复执行后不断增长。
- metric_pos = canonical.find(metric)
- if metric_pos >= 0:
- canonical = canonical[metric_pos:]
- seen_metrics.add(metric)
- conflict_re = re.compile(
- re.escape(metric) + r"[^,。;\n]{0,30}?" + _NUM_VALUE_PATTERN
- )
- rules.append({
- "metric": metric,
- "canonical": canonical,
- "conflict_re": conflict_re,
- })
- return rules
- _REQUIREMENT_ALIGN_PROMPT = """你是投标文件一致性修正专家。下方提供一份章节正文和权威要求来源。
- 请仅修正正文中与权威要求冲突的事实性内容,例如数值、比例、期限、人数、金额、证书、资质等。
- 必须保留原文结构、标题、表格框架和其他无关内容。
- 输出完整修正后的正文,不要输出任何解释、前缀或后缀。"""
- def _llm_apply_requirement_alignment(
- content: str,
- analysis: TenderAnalysis,
- source_texts: Optional[List[str]] = None,
- ) -> str:
- """使用 LLM 修正正文与招标/采购/废标要求冲突的内容。"""
- if not content or analysis is None:
- return content
- sources = [
- item.description or ""
- for item in (analysis.rejection_items or [])
- ]
- if analysis.service_requirements:
- sources.append(analysis.service_requirements)
- if analysis.qualification_requirements:
- sources.append(analysis.qualification_requirements)
- if analysis.procurement_requirements:
- sources.append(analysis.procurement_requirements)
- for extra in (source_texts or []):
- if extra:
- sources.append(extra)
- sources = [x for x in sources if x.strip()]
- if not sources:
- return content
- try:
- from llm_client import LLMClient
- llm = LLMClient()
- user_prompt = (
- "## 章节正文\n"
- f"{content[:12000]}\n\n"
- "## 权威要求来源\n"
- + "\n\n".join(x[:2500] for x in sources[:8])
- )
- corrected = llm.generate(
- system_prompt=_REQUIREMENT_ALIGN_PROMPT,
- user_prompt=user_prompt,
- max_tokens=32768,
- temperature=0.2,
- )
- cleaned = clean_ai_commentary(corrected.strip())
- if cleaned and len(cleaned) >= len(content) * 0.4:
- return cleaned
- except Exception as e:
- logger.warning(f"LLM 要求一致性修正失败: {e}")
- return content
- def _override_rejection_conflicts(
- content: str,
- analysis: TenderAnalysis,
- source_texts: Optional[List[str]] = None,
- ) -> str:
- """用评分/废标/招标/采购需求中的数值约束覆盖正文或模板冲突。"""
- if not content or analysis is None:
- return content
- result = content
- sources = [
- item.description or ""
- for item in (analysis.rejection_items or [])
- ]
- sources.extend(
- item.description or ""
- for item in (analysis.scoring_criteria or [])
- )
- if analysis.service_requirements:
- sources.append(analysis.service_requirements)
- if analysis.qualification_requirements:
- sources.append(analysis.qualification_requirements)
- if analysis.procurement_requirements:
- sources.append(analysis.procurement_requirements)
- for extra in (source_texts or []):
- if extra:
- sources.append(extra)
- dynamic_rules = _build_dynamic_numeric_rules(sources)
- for rule in dynamic_rules:
- result = rule["conflict_re"].sub(rule["canonical"], result)
- for metric, canonical_re, conflict_re in _FIXED_NUMERIC_RULES:
- for desc in sources:
- if metric not in desc:
- continue
- canonical_match = canonical_re.search(desc)
- if not canonical_match:
- continue
- canonical = canonical_match.group(0).strip()
- result = conflict_re.sub(canonical, result)
- break
- return result
- def _append_missing_rejection_responses(
- chapter: Chapter,
- analysis: TenderAnalysis,
- ) -> str:
- """兼容入口:废标要求已在写作提示词中正向转化,不再机械追加原文。
- 旧实现会把“投标无效/废标”等负面条款逐字堆到章节末尾,既可能触发
- 审核误报,也无法保证落到正确的模板小节。缺失覆盖由 Step5 按既有
- 小节定向补充。
- """
- return chapter.generated_content or ""
- def _dedupe_repeated_blocks(content: str) -> str:
- """去除章节正文中重复出现的长文本块(LLM 常把同一材料写入多个小节)。
- 规则:
- - 单行正文(>=40 字)重复出现时,保留第一次,删除后续重复行;
- - 连续两行组合重复出现时同样去重;
- - 小节标题行(一、/(一)/1. 等)不去重(不同小节可能重名但结构不同)。
- """
- if not content:
- return content
- def norm(s: str) -> str:
- return re.sub(r"\s+", "", s)
- lines = content.split("\n")
- seen_single = set()
- seen_pair = set()
- out = []
- removed = 0
- for i, line in enumerate(lines):
- stripped = line.strip()
- key = norm(stripped)
- is_heading = bool(
- re.match(r"^[一二三四五六七八九十百]+、", key)
- or re.match(r"^([一二三四五六七八九十百]+)", key)
- or re.match(r"^[((]\d+[))]", key)
- or re.match(r"^\d+[\.、))]", key)
- )
- if (
- not is_heading
- and len(key) >= 40
- and key in seen_single
- ):
- removed += 1
- continue
- if len(key) >= 40:
- seen_single.add(key)
- # 连续两行组合去重(针对被换行拆开的重复段落)
- if i >= 1 and not is_heading:
- prev_key = norm(lines[i - 1].strip())
- if prev_key:
- pair = prev_key + "||" + key
- if pair in seen_pair and len(prev_key) + len(key) >= 60:
- removed += 1
- continue
- if prev_key:
- seen_pair.add(pair)
- out.append(line)
- if removed:
- logger.info(f" 章节正文重复块去重: 删除 {removed} 行")
- return "\n".join(out)
- def _clean_chapter_title(name: str) -> str:
- """去掉"第X章"前缀,返回干净的章节标题"""
- return re.sub(
- r"^第\s*[一二三四五六七八九十百\d]+\s*章[\s::、]*", "", name or ""
- ).strip()
- _ATTACHMENT_MATERIAL_MATCH_PROMPT = """你是投标文件材料匹配专家。模板正文中夹有一段附件内容
- (通常是"管理制度/预案/方案"等),需要从通用材料清单中选出与它内容最匹配的一份,
- 用其全文替换附件区。
- 输出严格 JSON:{"material": "材料文件名或null", "label": "附件标题", "reason": "一句话理由"}
- 规则:
- 1. 依据附件块章节标题与每份材料的内容开头判断,选择章节结构最吻合、覆盖最全的材料;
- 2. material 必须是清单中存在的文件名;若清单中没有匹配项,输出 null;
- 3. label 给出附件标题(简短,如"安全运行管理制度"/"职业健康管理制度"),
- 从附件块章节标题归纳,不含"附:"前缀;
- 4. 只输出 JSON。"""
- def _llm_pick_attachment_material(
- embedded_names: list,
- material_map: Dict[str, str],
- ) -> Tuple[str, str]:
- """用 LLM 选出与附件块最匹配的材料与附件标题,返回 (材料文件名, 附件标题)"""
- try:
- from llm_client import LLMClient
- lines = ["## 附件块章节标题"]
- for name in embedded_names:
- lines.append(f"- {name}")
- lines.append("\n## 通用材料清单")
- for fname, content in sorted(material_map.items()):
- preview = " ".join((content or "").split())[:150]
- lines.append(
- f"- {fname}(约 {len(content or ''):,} 字)开头:{preview}"
- )
- llm = LLMClient()
- result = llm.extract_json(
- system_prompt=_ATTACHMENT_MATERIAL_MATCH_PROMPT,
- user_prompt="\n".join(lines),
- max_tokens=8192,
- )
- if isinstance(result, dict):
- name = str(result.get("material") or "").strip()
- if name not in material_map:
- name = ""
- label = str(result.get("label") or "").strip()
- return name, label
- except Exception as e:
- logger.warning(f"附件材料 LLM 匹配失败(回退模板原文): {e}")
- return "", ""
- _ATTACHMENT_GENERATE_PROMPT = """你是投标文件附件撰写专家。请根据以下材料撰写一份完整的
- 附件正文(如管理制度/预案/方案等),用于投标文件附录。
- 要求:
- 1. 以"模板附件原文"为结构基准(章节顺序、编号体系),完整覆盖其中所有章节;
- 2. 招标文件相关要求必须体现;参考投书内容仅作风格与写法参考;
- 3. 篇幅控制在模板篇幅的 1~1.3 倍以内,按模板章节与条目完整覆盖;
- 在模板内容基础上仅做必要润色与本项目适配,不新增条目、不展开解释性
- 扩写;超过模板篇幅 1.3 倍视为不合格;也不要摘要化(须给出完整正文);
- 4. 直接输出附件正文,无前缀无后缀。"""
- def _llm_generate_attachment(
- chapter: Chapter,
- template_attachment_text: str,
- tender_context: str,
- reference_context: str,
- ) -> str:
- """基于模板附件原文 + 招标要求 + 参考投书片段(三源)生成附件正文。"""
- try:
- from llm_client import LLMClient
- target = len(template_attachment_text or "")
- segments = [
- f"附件所属章节:第{chapter.id}章 {chapter.title}",
- ]
- if template_attachment_text:
- segments.append(
- f"## 模板附件原文(结构基准,约 {target:,} 字)\n"
- f"模板篇幅约 {target:,} 字,附件正文请控制在 "
- f"{int(target * 1.0):,}~{int(target * 1.3):,} 字以内。\n"
- f"{template_attachment_text}"
- )
- if tender_context:
- segments.append(
- f"## 招标文件相关要求(必须体现)\n{tender_context[:5000]}"
- )
- if reference_context:
- segments.append(
- f"## 参考投书相关片段(风格参考)\n{reference_context[:3000]}"
- )
- llm = LLMClient()
- content = llm.generate(
- system_prompt=_ATTACHMENT_GENERATE_PROMPT,
- user_prompt="\n\n".join(segments),
- max_tokens=32768,
- temperature=0.3,
- )
- cleaned = clean_ai_commentary(content.strip())
- return cleaned
- except Exception as e:
- logger.warning(f"附件正文 LLM 生成失败: {e}")
- return ""
- def _append_template_attachments_with_materials(
- outline: "BidOutline",
- template_structure: Optional[TemplateStructure],
- material_map: Dict[str, str],
- chapter_contexts: Optional[Dict[str, Dict[str, str]]] = None,
- ) -> int:
- """把模板章节区域内夹着的内嵌附件(制度等)用通用材料全文回填到章末。
- 附件区检测为纯结构判断:当前真实章到下一真实章之间、且不属于大纲的
- "第X章" section;附件正文用 LLM 基于"模板附件原文 + 招标要求 + 参考投书片段"
- 生成(通用材料仅作补充参考),篇幅与模板附件原文相当,不全文照搬。
- 返回回填的章节数。
- """
- if not template_structure or not outline:
- return 0
- sections = list(template_structure.sections)
- if not sections:
- return 0
- # 大纲真实章标题(去"第X章"前缀)
- real_titles = set()
- for ch in outline.chapters:
- t = _clean_chapter_title(ch.title)
- if t:
- real_titles.add(t)
- if not real_titles:
- return 0
- chapter_re = re.compile(r"^第\s*[一二三四五六七八九十百\d]+\s*章")
- filled = 0
- for ch in outline.chapters:
- if ch.chapter_type != ChapterType.TECHNICAL:
- continue
- if ch.preserve_template_layout:
- # 模板原生附件、图片和签署上下文由 Step6 原样保留;Step4
- # 不再把附件文本重复追加到模板基块末尾。
- continue
- own_name = getattr(ch, "template_section", "") or ""
- own = next(
- (s for s in sections if s.name.strip() == own_name.strip()),
- None,
- )
- if own is None:
- continue
- # 下一真实章边界:own 之后第一个"第X章"且标题属于大纲真实章的 section
- later = [
- s for s in sections
- if chapter_re.match(s.name)
- and s.start_para > own.start_para
- and _clean_chapter_title(s.name) in real_titles
- ]
- nxt = min(later, key=lambda s: s.start_para) if later else None
- # 内嵌附件区:own 到下一真实章之间、标题不属于大纲真实章的"第X章" section
- embedded = []
- for s in sections:
- if s.start_para <= own.start_para:
- continue
- if nxt is not None and s.end_para >= nxt.start_para:
- continue
- if (
- chapter_re.match(s.name)
- and _clean_chapter_title(s.name) not in real_titles
- ):
- embedded.append(s)
- if not embedded:
- continue
- # 模板附件原文(结构基准)
- template_attachment_text = "\n\n".join(
- t
- for s in embedded
- for t in [
- get_template_text_for_section(template_structure, s.name) or ""
- ]
- if t
- )
- ctx = (chapter_contexts or {}).get(ch.id, {})
- tender_ctx = ctx.get("tender", "")
- ref_ctx = ctx.get("reference", "")
- # LLM 选出最匹配的通用材料,仅用于附件命名(内容生成只用三源)
- best_name, attach_label = _llm_pick_attachment_material(
- [s.name for s in embedded], material_map
- )
- generated = _llm_generate_attachment(
- ch,
- template_attachment_text,
- tender_ctx,
- ref_ctx,
- )
- if generated:
- # 篇幅保险:LLM 扩写超过模板 1.4 倍时,直接采用模板附件原文
- # (模板本身即完整依据,避免附件无限膨胀)
- if (
- template_attachment_text
- and len(generated) > len(template_attachment_text) * 1.4
- ):
- logger.warning(
- f" 附件生成 [{ch.id}] 篇幅 {len(generated):,} 字超过模板基准 "
- f"{len(template_attachment_text):,} 字的 1.4 倍,改用模板原文"
- )
- generated = template_attachment_text
- label = (
- attach_label
- or (
- best_name.replace(".md", "").replace(".txt", "")
- if best_name
- else ""
- )
- or "附件"
- )
- ch.generated_content += f"\n\n附:{label}\n{generated}"
- logger.info(
- f" 附件区回填 [{ch.id}] {ch.title}: LLM 基于模板/招标/参考生成 "
- f"{label} ({len(generated):,} 字,模板基准 {len(template_attachment_text):,} 字)"
- )
- filled += 1
- continue
- # 兜底:LLM 无匹配或失败时保留模板内嵌附件原文
- parts = []
- for s in embedded:
- txt = get_template_text_for_section(template_structure, s.name)
- if txt:
- parts.append(txt)
- if parts:
- ch.generated_content += "\n\n" + "\n\n".join(parts)
- logger.info(
- f" 附件区保留模板原文 [{ch.id}] {ch.title}: "
- f"{sum(len(p) for p in parts):,} 字(附件生成失败,保留模板原文)"
- )
- filled += 1
- return filled
- def _persist_all_chapter_docs(
- outline: BidOutline,
- output_dir: str,
- template_path: str = "",
- placeholder_map: Optional[Dict[str, str]] = None,
- company_info: Optional[CompanyInfo] = None,
- analysis: Optional[TenderAnalysis] = None,
- project_data: Optional[ProjectData] = None,
- ) -> List[str]:
- """统一保存所有顶层章节,并写 manifest 作为拼接前完整性屏障。
- 章节目录按项目隔离,避免上一次未完成运行留下的局部文件与本次产物
- 混在同一个目录中。
- """
- safe_project = re.sub(
- r'[<>:"/\\|?*]+',
- "_",
- outline.project_name or "投标项目",
- ).strip(" ._") or "投标项目"
- chapter_dir = os.path.abspath(
- os.path.join(output_dir, "chapters", safe_project)
- )
- os.makedirs(chapter_dir, exist_ok=True)
- incomplete = []
- for ch in outline.chapters:
- content = ch.generated_content.strip()
- if _is_deferred_evaluation_index(ch):
- logger.info(
- f" [{ch.id}] {ch.title}: 索引产物允许无正文,"
- "等待最终合并、检查和稳定分页后构建"
- )
- continue
- # 兜底:父章节内容为空但子章节有内容时,用子章节内容拼接
- if not content and ch.children:
- joined = "\n".join(
- f"{c.title}\n{c.generated_content}"
- for c in ch.children
- if c.generated_content.strip()
- ).strip()
- if joined:
- logger.warning(
- f" [{ch.id}] {ch.title}: 父章节内容为空,已用子章节内容兜底"
- )
- ch.generated_content = joined
- content = joined
- if (
- not content
- or "【撰写失败" in content
- or "【索引表生成失败" in content
- ):
- incomplete.append(f"{ch.id} {ch.title}")
- logger.warning(
- f" 章节不完整 [{ch.id}] {ch.title}: 长度={len(content)}, "
- f"空={not content}, 含撰写失败={'【撰写失败' in content}, "
- f"开头={content[:80]!r}"
- )
- if incomplete:
- raise RuntimeError(
- "章节产物不完整,停止审核与拼接: " + ", ".join(incomplete)
- )
- artifact_paths: List[str] = []
- manifest_chapters = []
- for order, chapter in enumerate(outline.chapters, start=1):
- path = _save_chapter_docx(
- chapter,
- chapter_dir,
- template_path=template_path,
- placeholder_map=placeholder_map,
- company_info=company_info,
- analysis=analysis,
- project_data=project_data,
- )
- chapter.artifact_path = path
- artifact_paths.append(path)
- manifest_chapters.append({
- "order": order,
- "id": chapter.id,
- "title": chapter.title,
- "chapter_type": chapter.chapter_type.value,
- "content_chars": len(chapter.generated_content),
- "artifact_path": path,
- "status": (
- "deferred_until_final_pagination"
- if _is_deferred_evaluation_index(chapter)
- else "complete"
- ),
- })
- missing = [path for path in artifact_paths if not os.path.isfile(path)]
- if missing:
- raise RuntimeError(
- "章节 DOCX 保存后校验失败: " + ", ".join(missing)
- )
- manifest_path = os.path.join(chapter_dir, "manifest.json")
- with open(manifest_path, "w", encoding="utf-8") as f:
- json.dump(
- {
- "project_name": outline.project_name,
- "expected_count": len(outline.chapters),
- "completed_count": len(artifact_paths),
- "ready_for_assembly": True,
- "final_outline": {
- "project": outline.project_name,
- "heading_mappings": outline.heading_mappings,
- "evaluation_index_entries": outline.evaluation_index_entries,
- } if outline.heading_mappings else None,
- "chapters": manifest_chapters,
- },
- f,
- ensure_ascii=False,
- indent=2,
- )
- logger.info(
- f"章节产物完整性校验通过: {len(artifact_paths)}/"
- f"{len(outline.chapters)},目录: {chapter_dir}"
- )
- return artifact_paths
- def _artifact_heading_key(text: str) -> str:
- """用于把模板旧标题正文归入 Step3 最终节点,不用于重新判断层级。"""
- value = strip_markdown_headings(text or "").strip()
- value = re.sub(r"^第\s*[一二三四五六七八九十百\d]+\s*章\s*[::]?\s*", "", value)
- value = re.sub(r"\s+", "", value)
- return value.rstrip("::。;;")
- def _split_artifact_content_by_outline(chapter: Chapter) -> Dict[str, List[str]]:
- """按模板原标题定位正文,但只由最终大纲决定产物中的标题和顺序。"""
- from step4_writing.difficulty_rewrite import MODE, split_sections
- if any(n.content_generation_mode == MODE for n in _walk_nodes(chapter)):
- return split_sections(chapter)
- nodes = list(_walk_nodes(chapter))
- buckets: Dict[str, List[str]] = {str(node.id): [] for node in nodes}
- node_by_id = {str(node.id): node for node in nodes}
- parent_by_id = {str(chapter.id): None}
- for node in nodes:
- for child in node.children or []:
- parent_by_id.setdefault(str(child.id), str(node.id))
- def _node_key(node) -> str:
- for candidate in (node.template_original_title, node.title):
- key = _artifact_heading_key(candidate)
- if key:
- return key
- return ""
- def _node_path(node) -> Tuple[str, ...]:
- path = []
- current = node
- while current is not None:
- key = _node_key(current)
- if key:
- path.append(key)
- parent_id = parent_by_id.get(str(current.id))
- current = node_by_id.get(parent_id) if parent_id else None
- return tuple(reversed(path))
- path_nodes: Dict[Tuple[str, ...], List[Chapter]] = {}
- for node in nodes:
- path_nodes.setdefault(_node_path(node), []).append(node)
- current = chapter
- heading_stack: List[Tuple[int, str, Chapter]] = [
- (int(chapter.level or 1), _node_key(chapter), chapter)
- ]
- content = clean_ai_commentary(
- strip_markdown_headings(chapter.generated_content or "")
- )
- for raw in content.splitlines():
- line = raw.strip()
- if not line:
- if buckets[str(current.id)] and buckets[str(current.id)][-1] != "":
- buckets[str(current.id)].append("")
- continue
- line_key = _artifact_heading_key(line)
- prefix = [item_key for _level, item_key, _node in heading_stack]
- matched = next(
- (
- candidate
- for candidate in path_nodes.get(tuple(prefix + [line_key]), [])
- if candidate is not current
- ),
- None,
- )
- if matched is not None:
- current = matched
- level = int(matched.level or 2)
- while heading_stack and heading_stack[-1][0] >= level:
- heading_stack.pop()
- heading_stack.append((level, line_key, matched))
- continue
- buckets[str(current.id)].append(line)
- return buckets
- def _render_artifact_lines(doc, lines: List[str]) -> None:
- """把某个最终标题节点的正文写入检查 DOCX,不从正文猜测新标题。"""
- from docx.shared import Pt
- i = 0
- while i < len(lines):
- line = lines[i].strip()
- if not line:
- i += 1
- continue
- if _is_markdown_table_row(line) and _has_separator_row(lines, i + 1):
- table_lines, table_end = _extract_table_block(lines, i)
- _render_table_block(doc, table_lines)
- i = table_end
- continue
- para = doc.add_paragraph()
- para.paragraph_format.line_spacing = 1.5
- para.add_run(line).font.size = Pt(12)
- i += 1
- def _render_artifact_node(doc, node: Chapter, buckets: Dict[str, List[str]]) -> None:
- """按 Step3 最终树递归写出 H2-H4、模板正文和直接补充块。"""
- from docx.shared import Pt
- level = max(2, min(int(node.level or 2), 4))
- heading = doc.add_paragraph(node.title, style=f"Heading {level}")
- for run in heading.runs:
- run.font.size = Pt(16 if level == 2 else 14)
- body_lines = list(buckets.get(str(node.id), []) or [])
- if not body_lines and node.generated_content and not node.supplement_content:
- body_lines = clean_ai_commentary(
- strip_markdown_headings(node.generated_content)
- ).splitlines()
- _render_artifact_lines(doc, body_lines)
- if node.supplement_content.strip():
- _render_artifact_lines(doc, node.supplement_content.splitlines())
- for child in node.children or []:
- _render_artifact_node(doc, child, buckets)
- _WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
- _XML_NS = "http://www.w3.org/XML/1998/namespace"
- def _w_tag(local: str) -> str:
- return f"{{{_WORD_NS}}}{local}"
- def _docx_xml_text(element) -> str:
- return "".join(
- node.text or "" for node in element.iter(_w_tag("t"))
- ).strip()
- def _docx_style_maps(styles_root) -> Tuple[Dict[str, int], Dict[int, str]]:
- by_id: Dict[str, int] = {}
- by_level: Dict[int, str] = {}
- for style in styles_root.iter(_w_tag("style")):
- style_id = style.get(_w_tag("styleId"), "")
- name_node = style.find(_w_tag("name"))
- name = name_node.get(_w_tag("val"), "") if name_node is not None else ""
- level = 0
- for value in (style_id, name):
- match = re.match(r"^(?:heading|标题)\s*([1-4])$", value or "", re.I)
- if match:
- level = int(match.group(1))
- break
- if level:
- by_id[style_id] = level
- by_level.setdefault(level, style_id)
- return by_id, by_level
- def _docx_paragraph_level(paragraph, style_levels: Dict[str, int]) -> int:
- p_pr = paragraph.find(_w_tag("pPr"))
- if p_pr is None:
- return 0
- p_style = p_pr.find(_w_tag("pStyle"))
- if p_style is None:
- return 0
- style_id = p_style.get(_w_tag("val"), "")
- if style_id in style_levels:
- return style_levels[style_id]
- match = re.match(r"^(?:heading|标题)\s*([1-4])$", style_id or "", re.I)
- return int(match.group(1)) if match else 0
- def _replace_docx_paragraph_text(paragraph, text: str) -> None:
- text_nodes = list(paragraph.iter(_w_tag("t")))
- if not text_nodes:
- run = ET.SubElement(paragraph, _w_tag("r"))
- text_node = ET.SubElement(run, _w_tag("t"))
- text_nodes = [text_node]
- text_nodes[0].text = text
- text_nodes[0].set(f"{{{_XML_NS}}}space", "preserve")
- for node in text_nodes[1:]:
- node.text = ""
- def _fill_docx_paragraph_text(paragraph, text: str) -> None:
- """只更新变化的文本范围,保留模板各 run 的格式和非文本节点。"""
- from difflib import SequenceMatcher
- nodes = list(paragraph.iter(_w_tag("t")))
- if not nodes:
- _replace_docx_paragraph_text(paragraph, text)
- return
- original = "".join(node.text or "" for node in nodes)
- # 占位符作为整体匹配,避免值中的相同字符被匹配到占位符内部。
- token_pattern = r"%%[^%\r\n]+%%|%[((][^%\r\n]+[))]%|[\s\S]"
- before = re.findall(token_pattern, original)
- after = re.findall(token_pattern, text)
- offsets = [0]
- for token in before:
- offsets.append(offsets[-1] + len(token))
- owners = [index for index, node in enumerate(nodes) for _ in (node.text or "")]
- values = ["" for _ in nodes]
- for kind, i, j, a, b in SequenceMatcher(None, before, after, autojunk=False).get_opcodes():
- start, end = offsets[i], offsets[j]
- if kind == "equal":
- for pos in range(start, end):
- values[owners[pos]] += original[pos]
- elif kind in {"replace", "insert"}:
- # 新值继承被替换范围起点的格式;末尾追加继承最后一个字符。
- owner = owners[min(start, len(owners) - 1)] if owners else 0
- values[owner] += "".join(after[a:b])
- for node, value in zip(nodes, values):
- node.text = value
- node.set(f"{{{_XML_NS}}}space", "preserve")
- def _new_docx_paragraph(text: str, style_id: str = ""):
- paragraph = ET.Element(_w_tag("p"))
- if style_id:
- p_pr = ET.SubElement(paragraph, _w_tag("pPr"))
- p_style = ET.SubElement(p_pr, _w_tag("pStyle"))
- p_style.set(_w_tag("val"), style_id)
- run = ET.SubElement(paragraph, _w_tag("r"))
- text_node = ET.SubElement(run, _w_tag("t"))
- text_node.set(f"{{{_XML_NS}}}space", "preserve")
- text_node.text = text
- return paragraph
- def _native_chapter_number(chapter: Chapter) -> str:
- if not chapter.id.isdigit():
- return chapter.id
- from step3_outlining.scoring_structure import _int_to_chinese
- return _int_to_chinese(int(chapter.id))
- def _template_chapter_number(chapter: Chapter) -> str:
- value = (
- chapter.template_chapter_id
- or chapter.template_original_id
- or chapter.id
- )
- return str(value).split(".", 1)[0]
- _TABLE_PLACEHOLDER_TYPES = {
- "资格条件响应表": "qualification_response",
- "实质性要求响应表": "substantive_response",
- "报价汇总表": "bid_summary_table",
- "报价明细表": "bid_detail_table",
- }
- def _table_placeholder_key(text: str) -> str:
- match = re.fullmatch(r"\s*%%\s*([^%\n]+?)\s*%%\s*", text or "")
- return match.group(1).strip() if match else ""
- def _normalized_table_name(value: str) -> str:
- return re.sub(r"[\s\d.、::()()《》]", "", value or "").replace("格式", "")
- def _table_type_for_placeholder(key: str) -> str:
- normalized = _normalized_table_name(key)
- for name, table_type in _TABLE_PLACEHOLDER_TYPES.items():
- known = _normalized_table_name(name)
- if known in normalized or normalized in known:
- return table_type
- return ""
- def _looks_like_table_placeholder(key: str) -> bool:
- return bool(
- _table_type_for_placeholder(key)
- or re.search(r"表|清单|一览|汇总|明细|台账|台帐", key or "")
- )
- def _table_identity_matches(key: str, table: Any) -> bool:
- """按表名身份匹配任意表占位符,标准类型只作为辅助证据。"""
- normalized_key = _normalized_table_name(key)
- if not normalized_key:
- return False
- for value in (
- getattr(table, "title_hint", ""),
- getattr(table, "caption", ""),
- ):
- normalized_value = _normalized_table_name(value)
- if not normalized_value:
- continue
- if normalized_key == normalized_value:
- return True
- if min(len(normalized_key), len(normalized_value)) >= 4 and (
- normalized_key in normalized_value or normalized_value in normalized_key
- ):
- return True
- expected_type = _table_type_for_placeholder(key)
- return bool(
- expected_type
- and getattr(table, "table_type", "") == expected_type
- )
- def _select_placeholder_table(
- key: str,
- analysis: Optional[TenderAnalysis],
- project_data: Optional[ProjectData],
- ) -> Any:
- """按招标文件 > 采购需求 > 模板 > 参考投书选择获授权表格。
- 格式协调结果只能在来源表类型仍与占位符精确一致时作为候选,禁止
- 仅因列数/表头相似就把评分表等其他表格替换到资格响应表位置。
- """
- expected_type = _table_type_for_placeholder(key)
- normalized_key = _normalized_table_name(key)
- tender_paths = {
- os.path.abspath(doc.file_path)
- for doc in list(getattr(project_data, "tender_docs", []) or [])
- if getattr(doc, "file_path", "")
- }
- procurement_paths = {
- os.path.abspath(doc.file_path)
- for doc in list(getattr(project_data, "procurement_docs", []) or [])
- if getattr(doc, "file_path", "")
- }
- reference_tables = list(getattr(project_data, "reference_tables", []) or [])
- def source_rank(table: Any) -> int:
- source_file = str(getattr(table, "source_file", "") or "")
- absolute_source = os.path.abspath(source_file) if source_file else ""
- source_type = str(getattr(table, "source_type", "") or "").lower()
- if any(table is reference for reference in reference_tables):
- return 3
- if absolute_source in tender_paths or "tender" in source_type:
- return 0
- if absolute_source in procurement_paths or "procurement" in source_type:
- return 1
- if "template" in source_type:
- return 2
- if "reference" in source_type:
- return 3
- # Step1 旧缓存的招标表可能缺少来源标记;它们仍属于 analysis.tender_tables,
- # 排在模板/参考投书之前,但不会越过路径明确的招标文件。
- return 1
- def name_rank(table: Any) -> int:
- return 0 if _table_identity_matches(key, table) else 1
- candidates = [
- table for table in list(getattr(analysis, "tender_tables", []) or [])
- if _table_identity_matches(key, table)
- ]
- report = (
- (project_data.metadata or {}).get("reconciliation_report")
- if project_data is not None else None
- )
- for rec in list(getattr(report, "reconciliations", []) or []):
- section_name = _normalized_table_name(getattr(rec, "section_name", ""))
- template_table = getattr(rec, "template_table", None)
- section_type = getattr(template_table, "table_type", "")
- if not (
- (normalized_key and (normalized_key in section_name or section_name in normalized_key))
- or (expected_type and section_type == expected_type)
- ):
- continue
- decision = getattr(getattr(rec, "decision", None), "value", "")
- if decision != "replace_with_tender":
- continue
- source = (
- getattr(rec, "replace_with_tender_table", None)
- or getattr(rec, "best_match", None)
- )
- if source is None:
- continue
- if not _table_identity_matches(key, source):
- logger.warning(
- f" %%{key}%% 忽略格式协调误配: "
- f"{getattr(source, 'title_hint', '') or getattr(source, 'table_type', '') or '未分类'}"
- )
- continue
- if all(source is not table for table in candidates):
- candidates.append(source)
- if candidates:
- candidates.sort(key=lambda table: (source_rank(table), name_rank(table)))
- return candidates[0]
- # 招标文件没有指定格式时,才允许使用参考投书的同类型表格结构。
- # 具体项目身份和历史报价在构建 XML 前另行净化。
- reference = [
- table for table in list(getattr(project_data, "reference_tables", []) or [])
- if _table_identity_matches(key, table)
- ]
- if reference:
- reference.sort(key=name_rank)
- return reference[0]
- return None
- def _source_item_for_table(project_data: Optional[ProjectData], source_table: Any):
- for item in list(getattr(project_data, "extracted_items", []) or []):
- if getattr(item, "source_table", None) is source_table:
- return item
- candidate = getattr(item, "source_table", None)
- if candidate is not None and (
- getattr(candidate, "table_id", "")
- and getattr(candidate, "table_id", "") == getattr(source_table, "table_id", "")
- ):
- return item
- return None
- def _response_table_columns(source_table: Any) -> Tuple[int, int]:
- cells = list(getattr(source_table, "cells", []) or [])
- if not cells:
- return -1, -1
- header = [str(getattr(cell, "text", "") or "") for cell in cells[0]]
- response_col = next((
- index for index, text_value in enumerate(header)
- if "是/否" in text_value.replace(" ", "")
- or "投标检查项" in text_value
- or "响应内容说明" in text_value
- or "是否响应" in text_value
- ), -1)
- evidence_col = next((
- index for index, text_value in enumerate(header)
- if "电子投标文件" in text_value
- or "证明材料" in text_value
- or ("对应" in text_value and "文件" in text_value)
- ), -1)
- return response_col, evidence_col
- def _fill_current_response_table(source_table: Any, key: str) -> Any:
- """只用当前上游表的要求填写响应列,不带入参考投书历史正文。"""
- table = deepcopy(source_table)
- cells = list(getattr(table, "cells", []) or [])
- if len(cells) < 2:
- return table
- response_col, evidence_col = _response_table_columns(table)
- if response_col < 0:
- return table
- for row in cells[1:]:
- if (
- response_col < len(row)
- and not getattr(row[response_col], "is_merged_continuation", False)
- and not str(row[response_col].text or "").strip()
- ):
- row[response_col].text = "是"
- if evidence_col < 0 or evidence_col >= len(row):
- continue
- if getattr(row[evidence_col], "is_merged_continuation", False):
- continue
- if str(row[evidence_col].text or "").strip():
- continue
- item_name = str(getattr(row[0], "text", "") or "") if row else ""
- if "授权" in item_name:
- evidence = "第一章 法定代表人授权书"
- elif "中小企业" in item_name:
- evidence = "第一章 中小企业声明函"
- elif "联合" in item_name:
- evidence = "第一章 要求承诺函"
- else:
- evidence = "投标文件对应章节"
- row[evidence_col].text = evidence
- return table
- def _overlay_table_xml_text(table_xml, source_table: Any):
- """按Word网格只填空白响应/证明格,其他原生XML保持不变。
- 横向合并省略物理tc,纵向合并续格却仍有tc;不能过滤元数据续格后zip。
- Step1原文不应再用扁平元数据覆盖,否则会压平段落、run并丢失格式。
- """
- rows = list(getattr(source_table, "cells", []) or [])
- fill_columns = {col for col in _response_table_columns(source_table) if col >= 0}
- xml_rows = table_xml.findall(_w_tag("tr"))
- for xml_row, row in zip(xml_rows[1:], rows[1:]):
- before = xml_row.find(_w_tag("trPr") + "/" + _w_tag("gridBefore"))
- col = int(before.get(_w_tag("val"), "0")) if before is not None else 0
- for xml_cell in xml_row.findall(_w_tag("tc")):
- span = xml_cell.find(_w_tag("tcPr") + "/" + _w_tag("gridSpan"))
- width = max(1, int(span.get(_w_tag("val"), "1"))) if span is not None else 1
- cell_col = col
- col += width
- merge = xml_cell.find(_w_tag("tcPr") + "/" + _w_tag("vMerge"))
- if merge is not None and merge.get(_w_tag("val")) != "restart":
- continue
- if cell_col not in fill_columns or cell_col >= len(row):
- continue
- cell = row[cell_col]
- if getattr(cell, "is_merged_continuation", False):
- continue
- value = str(getattr(cell, "text", "") or "")
- if not value.strip() or _docx_xml_text(xml_cell).strip():
- continue
- paragraph = xml_cell.find(_w_tag("p"))
- if paragraph is None:
- paragraph = ET.SubElement(xml_cell, _w_tag("p"))
- _fill_docx_paragraph_text(paragraph, value)
- return table_xml
- def _table_artifact_xml(source_table: Any):
- """优先读取 Step1 单表产物中的原生 w:tbl;不加载 python-docx。"""
- import zipfile
- artifact_path = getattr(source_table, "artifact_path", "")
- if not artifact_path or not os.path.isfile(artifact_path):
- return None
- try:
- with zipfile.ZipFile(artifact_path, "r") as package:
- artifact_root = ET.fromstring(package.read("word/document.xml"))
- return next(artifact_root.iter(_w_tag("tbl")), None)
- except Exception as exc:
- logger.warning(f"Step1 表格产物读取失败,将按元数据重建: {exc}")
- return None
- def _table_origin_covering(source_table: Any, row: int, col: int):
- cells = list(getattr(source_table, "cells", []) or [])
- for origin_row in range(row - 1, -1, -1):
- if origin_row >= len(cells):
- continue
- for origin_col, cell in enumerate(cells[origin_row]):
- if not getattr(cell, "is_merged_origin", False):
- continue
- rowspan = max(1, int(getattr(cell, "rowspan", 1) or 1))
- colspan = max(1, int(getattr(cell, "colspan", 1) or 1))
- if (
- origin_row + rowspan > row
- and origin_col <= col < origin_col + colspan
- ):
- return origin_col, cell
- return None
- def _metadata_table_xml(source_table: Any, placeholder_map: Dict[str, str]):
- """按 ExtractedTable 的行列、合并和单元格样式元数据构造 w:tbl。"""
- cells = list(getattr(source_table, "cells", []) or [])
- rows = max(int(getattr(source_table, "rows", 0) or 0), len(cells))
- cols = int(getattr(source_table, "cols", 0) or 0)
- if not cols:
- cols = max((len(row) for row in cells), default=0)
- if not rows or not cols:
- return None
- table = ET.Element(_w_tag("tbl"))
- table_pr = ET.SubElement(table, _w_tag("tblPr"))
- width = ET.SubElement(table_pr, _w_tag("tblW"))
- width.set(_w_tag("w"), "0")
- width.set(_w_tag("type"), "auto")
- borders = ET.SubElement(table_pr, _w_tag("tblBorders"))
- for side in ("top", "left", "bottom", "right", "insideH", "insideV"):
- border = ET.SubElement(borders, _w_tag(side))
- border.set(_w_tag("val"), "single")
- border.set(_w_tag("sz"), "4")
- border.set(_w_tag("space"), "0")
- border.set(_w_tag("color"), "000000")
- raw_widths = list(getattr(source_table, "col_widths", []) or [])
- if len(raw_widths) != cols or sum(raw_widths) <= 0:
- raw_widths = [1.0 / cols] * cols
- total_width = sum(raw_widths)
- twips = [max(240, round(9000 * value / total_width)) for value in raw_widths]
- grid = ET.SubElement(table, _w_tag("tblGrid"))
- for value in twips:
- col = ET.SubElement(grid, _w_tag("gridCol"))
- col.set(_w_tag("w"), str(value))
- for row_index in range(rows):
- row_element = ET.SubElement(table, _w_tag("tr"))
- row_cells = cells[row_index] if row_index < len(cells) else []
- col_index = 0
- while col_index < cols:
- cell = row_cells[col_index] if col_index < len(row_cells) else None
- covering = _table_origin_covering(source_table, row_index, col_index)
- if covering is not None:
- origin_col, origin = covering
- if col_index != origin_col:
- col_index += 1
- continue
- colspan = max(1, int(getattr(origin, "colspan", 1) or 1))
- text_value = ""
- vertical_continue = True
- style_cell = origin
- elif cell is not None and getattr(cell, "is_merged_continuation", False):
- col_index += 1
- continue
- else:
- colspan = max(1, int(getattr(cell, "colspan", 1) or 1)) if cell else 1
- text_value = str(getattr(cell, "text", "") or "") if cell else ""
- vertical_continue = False
- style_cell = cell
- tc = ET.SubElement(row_element, _w_tag("tc"))
- tc_pr = ET.SubElement(tc, _w_tag("tcPr"))
- tc_width = ET.SubElement(tc_pr, _w_tag("tcW"))
- tc_width.set(_w_tag("w"), str(sum(twips[col_index:col_index + colspan])))
- tc_width.set(_w_tag("type"), "dxa")
- if colspan > 1:
- span = ET.SubElement(tc_pr, _w_tag("gridSpan"))
- span.set(_w_tag("val"), str(colspan))
- rowspan = max(1, int(getattr(style_cell, "rowspan", 1) or 1)) if style_cell else 1
- if vertical_continue or rowspan > 1:
- merge = ET.SubElement(tc_pr, _w_tag("vMerge"))
- if not vertical_continue:
- merge.set(_w_tag("val"), "restart")
- paragraph = ET.SubElement(tc, _w_tag("p"))
- alignment = getattr(style_cell, "alignment", "") if style_cell else ""
- if alignment:
- p_pr = ET.SubElement(paragraph, _w_tag("pPr"))
- jc = ET.SubElement(p_pr, _w_tag("jc"))
- jc.set(_w_tag("val"), alignment)
- run = ET.SubElement(paragraph, _w_tag("r"))
- if style_cell and (getattr(style_cell, "font_size", None) or getattr(style_cell, "bold", False)):
- r_pr = ET.SubElement(run, _w_tag("rPr"))
- if getattr(style_cell, "bold", False):
- ET.SubElement(r_pr, _w_tag("b"))
- if getattr(style_cell, "font_size", None):
- size = ET.SubElement(r_pr, _w_tag("sz"))
- size.set(_w_tag("val"), str(round(float(style_cell.font_size) * 2)))
- text_node = ET.SubElement(run, _w_tag("t"))
- text_node.set(f"{{{_XML_NS}}}space", "preserve")
- text_node.text = _resolve_chapter_text_placeholders(
- text_value, placeholder_map
- )
- col_index += colspan
- return table
- def _sanitized_reference_table(
- source_table: Any,
- analysis: Optional[TenderAnalysis],
- placeholder_map: Dict[str, str],
- ):
- """复用参考表结构时移除历史项目身份和历史报价数值。"""
- import copy
- table = copy.deepcopy(source_table)
- header_fields = list(getattr(table, "header_fields", []) or [])
- reference_project = ""
- reference_id = ""
- for line in header_fields:
- match = re.match(r"\s*项目名称\s*[::]\s*(.+)", str(line or ""))
- if match:
- reference_project = match.group(1).strip()
- match = re.match(
- r"\s*(?:项目编号|招标编号|采购编号)\s*[::]\s*(.+)",
- str(line or ""),
- )
- if match:
- reference_id = match.group(1).strip()
- current_project = str(getattr(analysis, "project_name", "") or "")
- current_id = str(getattr(analysis, "tender_project_id", "") or "")
- monetary_columns = set()
- cells = list(getattr(table, "cells", []) or [])
- for row in cells[:5]:
- for col, cell in enumerate(row):
- text_value = str(getattr(cell, "text", "") or "")
- if any(token in text_value for token in (
- "金额", "单价", "总价", "月工资", "年费用", "小计", "合计",
- )):
- monetary_columns.add(col)
- for row_index, row in enumerate(cells):
- for col, cell in enumerate(row):
- value = str(getattr(cell, "text", "") or "")
- if reference_project and current_project:
- value = value.replace(reference_project, current_project)
- if reference_id and current_id:
- value = value.replace(reference_id, current_id)
- if (
- row_index > 0
- and col in monetary_columns
- and re.search(r"\d", value)
- ):
- value = "待投标报价确认"
- cell.text = _resolve_chapter_text_placeholders(value, placeholder_map)
- table.header_fields = [
- _resolve_chapter_text_placeholders(
- str(line or "")
- .replace(reference_project, current_project) if reference_project else str(line or ""),
- placeholder_map,
- )
- for line in header_fields
- ]
- return table
- def _replace_native_table_placeholders(
- body,
- analysis: Optional[TenderAnalysis],
- project_data: Optional[ProjectData],
- placeholder_map: Dict[str, str],
- ) -> int:
- """把独立段落中的 %%表名%% 替换为获授权的招标或参考表格块。"""
- replaced = 0
- for paragraph in list(body):
- if paragraph.tag != _w_tag("p"):
- continue
- key = _table_placeholder_key(_docx_xml_text(paragraph))
- if not key or not _looks_like_table_placeholder(key):
- continue
- source_table = _select_placeholder_table(key, analysis, project_data)
- if source_table is None:
- if _table_type_for_placeholder(key) or re.search(
- r"表|清单|一览|汇总|明细", key
- ):
- logger.warning(f" %%{key}%% 未找到获授权的表格来源")
- continue
- reference_tables = list(getattr(project_data, "reference_tables", []) or [])
- is_reference = any(source_table is table for table in reference_tables) or "reference" in str(
- getattr(source_table, "source_type", "") or ""
- ).lower()
- if is_reference:
- source_table = _sanitized_reference_table(
- source_table, analysis, placeholder_map
- )
- is_response_table = _response_table_columns(source_table)[0] >= 0
- if is_response_table:
- source_table = _fill_current_response_table(source_table, key)
- table = None if is_reference else _table_artifact_xml(source_table)
- if table is not None and is_response_table:
- table = _overlay_table_xml_text(table, source_table)
- if table is None:
- table = _metadata_table_xml(source_table, placeholder_map)
- if table is None:
- logger.warning(f" %%{key}%% 的招标表格元数据为空,保留占位符")
- continue
- source_item = _source_item_for_table(project_data, source_table)
- preamble = str(getattr(source_item, "preamble", "") or "")
- header_fields = list(getattr(source_table, "header_fields", []) or [])
- postamble = str(
- getattr(source_item, "postamble", "")
- or getattr(source_table, "postamble", "")
- or ""
- )
- insert_at = list(body).index(paragraph)
- for line in [*preamble.splitlines(), *header_fields]:
- filled = _resolve_chapter_text_placeholders(line.strip(), placeholder_map)
- if filled:
- body.insert(insert_at, _new_docx_paragraph(filled))
- insert_at += 1
- body.insert(insert_at, table)
- insert_at += 1
- for line in postamble.splitlines():
- filled = _resolve_chapter_text_placeholders(line.strip(), placeholder_map)
- if filled:
- body.insert(insert_at, _new_docx_paragraph(filled))
- insert_at += 1
- body.remove(paragraph)
- replaced += 1
- logger.info(
- f" %%{key}%% → {'参考投书表格' if is_reference else '招标表格'} "
- f"[{getattr(source_table, 'rows', 0)} 行 × {getattr(source_table, 'cols', 0)} 列]"
- )
- return replaced
- def _replace_native_commitment_placeholder(body, chapter: Chapter) -> set[str]:
- """把废标承诺块放入模板授权的 %%承诺函%% 位置。"""
- rejection_nodes = [
- node for node in _walk_nodes(chapter)
- if (getattr(node, "direct_rejection_bindings", []) or [])
- and (getattr(node, "supplement_content", "") or "").strip()
- ]
- if not rejection_nodes:
- return set()
- target = rejection_nodes[0]
- inserted_for = set()
- for paragraph in list(body):
- if paragraph.tag != _w_tag("p"):
- continue
- if "%%承诺函%%" not in _docx_xml_text(paragraph):
- continue
- insert_at = list(body).index(paragraph)
- for line in target.supplement_content.splitlines():
- if not line.strip():
- continue
- replacement = deepcopy(paragraph)
- _replace_docx_paragraph_text(replacement, line.strip())
- body.insert(insert_at, replacement)
- insert_at += 1
- body.remove(paragraph)
- inserted_for.add(str(target.id))
- logger.info(
- f" %%承诺函%% → "
- f"{len(getattr(target, 'direct_rejection_bindings', []) or [])} 条废标项,"
- f"归并为 {len(target.supplement_content.splitlines()) - 1} 类承诺"
- )
- return inserted_for
- def _register_namespaces_from_part(data: bytes) -> ET.Element:
- """从独立 OOXML 部件(header/footer 等)注册命名空间并解析根节点。"""
- import io
- namespace_events = ET.iterparse(io.BytesIO(data), events=("start-ns",))
- for _event, (prefix, uri) in namespace_events:
- try:
- ET.register_namespace(prefix or "", uri)
- except ValueError:
- pass
- return ET.fromstring(data)
- def _transform_header_footer_part(
- data: bytes,
- placeholder_map: Optional[Dict[str, str]],
- project_name: str,
- ) -> bytes:
- """替换页眉/页脚独立 XML 部件中的项目名称信息。
- 模板页眉使用 %%项目名称%% 占位符;部分模板还残留历史项目名
- “上海市建筑工程学校物业管理服务项目投标文件”。此函数在章节 DOCX
- 的 header/footer 部件中执行与最终 Step6 一致的信息替换,不改动
- 页眉中的图片、文本框或公司标识,也不触碰页脚页码域。
- """
- root = _register_namespaces_from_part(data)
- placeholder_map = placeholder_map or {}
- placeholder_re = re.compile(r"%%(.+?)%%")
- t_tag = _w_tag("t")
- def _placeholder_value(match: re.Match) -> str:
- key = match.group(1).strip()
- value = placeholder_map.get(key, "")
- if not value and key == "项目名称" and project_name:
- value = project_name
- return value if value else match.group(0)
- for text_node in root.iter(t_tag):
- original = text_node.text or ""
- if not original:
- continue
- new_text = placeholder_re.sub(_placeholder_value, original)
- if project_name:
- new_text = new_text.replace(
- "上海市建筑工程学校物业管理服务项目投标文件",
- f"{project_name}项目投标文件",
- )
- new_text = new_text.replace(
- "上海市建筑工程学校", project_name
- )
- new_text = new_text.replace(
- "物业管理服务项目投标文件", "项目投标文件"
- )
- if new_text != original:
- text_node.text = new_text
- return ET.tostring(root, encoding="utf-8", xml_declaration=True)
- def _chapter_header_display_text(project_name: str) -> str:
- """为兜底章节生成与模板页眉一致的项目投标文件名称。"""
- project_name = (project_name or "").strip()
- if not project_name:
- return ""
- if project_name.endswith("项目"):
- return f"{project_name}投标文件"
- return f"{project_name}项目投标文件"
- def _add_fallback_header_footer(
- doc,
- placeholder_map: Optional[Dict[str, str]],
- project_name: str,
- ) -> None:
- """给非原生章节 DOCX 增加基础页眉和页脚页码。"""
- from docx.enum.text import WD_ALIGN_PARAGRAPH
- from docx.oxml import OxmlElement
- from docx.oxml.ns import qn
- placeholder_map = placeholder_map or {}
- header_project_name = (
- placeholder_map.get("项目名称")
- or (project_name or "")
- )
- header_text = _chapter_header_display_text(header_project_name)
- section = doc.sections[0]
- if header_text:
- section.header.is_linked_to_previous = False
- header_para = section.header.paragraphs[0]
- if header_para.runs:
- for run in list(header_para.runs):
- run._element.getparent().remove(run._element)
- header_para.add_run(header_text)
- section.footer.is_linked_to_previous = False
- footer_para = section.footer.paragraphs[0]
- if footer_para.runs:
- for run in list(footer_para.runs):
- run._element.getparent().remove(run._element)
- footer_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
- run = footer_para.add_run()
- begin = OxmlElement("w:fldChar")
- begin.set(qn("w:fldCharType"), "begin")
- run._element.append(begin)
- instr = OxmlElement("w:instrText")
- instr.set(qn("xml:space"), "preserve")
- instr.text = " PAGE "
- run._element.append(instr)
- end = OxmlElement("w:fldChar")
- end.set(qn("w:fldCharType"), "end")
- run._element.append(end)
- def _section_break_records(children) -> List[Tuple[int, object]]:
- """收集 body 中所有分节符及其位置。
- OOXML 中分节符可能位于段落 `pPr/sectPr` 内,也可能作为 body 末尾的
- 直接 `sectPr` 元素。这里只返回 `sectPr` 元素本身,调用方负责深拷贝。
- """
- records: List[Tuple[int, object]] = []
- for index, element in enumerate(children):
- sect_pr = None
- if element.tag == _w_tag("sectPr"):
- sect_pr = element
- elif element.tag == _w_tag("p"):
- p_pr = element.find(_w_tag("pPr"))
- if p_pr is not None:
- sect_pr = p_pr.find(_w_tag("sectPr"))
- if sect_pr is not None:
- records.append((index, sect_pr))
- return records
- def _first_header_footer_refs(children) -> List[Tuple[str, str, str]]:
- """返回模板第一个带页眉页脚引用的分节引用(统一页眉页脚基准)。"""
- for _index, sect_pr in _section_break_records(children):
- refs = _header_footer_refs(sect_pr)
- if refs:
- return refs
- return []
- def _header_footer_refs(sect_pr) -> List[Tuple[str, str, str]]:
- """读取 sectPr 中 header/footer 引用,按 headerReference 在前排序。"""
- rel_ns = (
- "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
- )
- by_key: Dict[Tuple[str, str], Tuple[str, str, str]] = {}
- order_keys: List[Tuple[str, str]] = []
- for child in list(sect_pr):
- local = child.tag.rsplit("}", 1)[-1]
- if local not in ("headerReference", "footerReference"):
- continue
- rel_id = child.get(f"{{{rel_ns}}}id", "")
- ref_type = child.get(_w_tag("type"), "default")
- key = (local, ref_type)
- if key in by_key:
- continue
- by_key[key] = (local, rel_id, ref_type)
- order_keys.append(key)
- ordered = [by_key[key] for key in order_keys]
- ordered.sort(key=lambda item: (
- 0 if item[0] == "headerReference" else 1,
- order_keys.index((item[0], item[2])),
- ))
- return ordered
- def _merge_header_footer_refs(
- current: List[Tuple[str, str, str]],
- explicit: List[Tuple[str, str, str]],
- ) -> List[Tuple[str, str, str]]:
- """合并节间继承关系:显式引用覆盖同类型,未覆盖类型继续继承。"""
- by_key: Dict[Tuple[str, str], Tuple[str, str, str]] = {
- (item[0], item[2]): item for item in current
- }
- order_keys = list(by_key.keys())
- for item in explicit:
- key = (item[0], item[2])
- if key not in by_key:
- order_keys.append(key)
- by_key[key] = item
- return [by_key[key] for key in order_keys]
- def _apply_header_footer_refs(sect_pr, refs) -> None:
- """把有效 header/footer 引用写回 sectPr 顶部,删除旧引用。"""
- rel_ns = (
- "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
- )
- for child in list(sect_pr):
- local = child.tag.rsplit("}", 1)[-1]
- if local in ("headerReference", "footerReference"):
- sect_pr.remove(child)
- insert_at = 0
- for local, rel_id, ref_type in refs:
- element = ET.Element(_w_tag(local))
- if rel_id:
- element.set(f"{{{rel_ns}}}id", rel_id)
- if ref_type:
- element.set(_w_tag("type"), ref_type)
- sect_pr.insert(insert_at, element)
- insert_at += 1
- def _chapter_section_properties(
- children,
- start: int,
- ) -> Optional[object]:
- """为从 start 开始的一级章计算应写入独立 DOCX 的 sectPr。
- 返回深拷贝后的 sectPr,已把其 header/footer 引用解析为当前章节所在
- 分节实际生效的引用(含跨分节继承),避免所有章节共用模板末尾的页眉页脚。
- """
- records = _section_break_records(children)
- if not records:
- return None
- effective_after: Dict[int, List[Tuple[str, str, str]]] = {}
- current: List[Tuple[str, str, str]] = []
- for index, sect_pr in records:
- explicit = _header_footer_refs(sect_pr)
- current = _merge_header_footer_refs(current, explicit)
- effective_after[index] = list(current)
- previous_index = None
- for index, _sect_pr in records:
- if index < start:
- previous_index = index
- else:
- break
- effective_refs = (
- effective_after.get(previous_index, [])
- if previous_index is not None else []
- )
- ending_index = next(
- (index for index, _sect_pr in records if index >= start),
- None,
- )
- if ending_index is None:
- ending_index = records[-1][0]
- base_sect_pr = next(
- sect_pr for index, sect_pr in records if index == ending_index
- )
- base_explicit = _header_footer_refs(base_sect_pr)
- resolved_refs = _merge_header_footer_refs(effective_refs, base_explicit)
- chapter_sect_pr = deepcopy(base_sect_pr)
- _apply_header_footer_refs(chapter_sect_pr, resolved_refs)
- return chapter_sect_pr
- def _trim_trailing_part_marker(
- children,
- start: int,
- end: int,
- ) -> int:
- """去掉当前章区域末尾属于下一分区的“技术部分/商务部分”等标记。
- 模板正文中“技术部分”等分区文字位于真实一级章之间,且不属于其前面
- 商务章的内容。Step4 按 H1 边界裁章时会把它连同末尾空行一起带进前一个
- 章节产物,导致第二章产物在新增标题前出现无关的“技术部分”。
- """
- marker_index = None
- for index in range(end - 1, start, -1):
- element = children[index]
- if element.tag != _w_tag("p"):
- break
- text = _docx_xml_text(element).strip()
- if text in _TEMPLATE_PART_MARKERS:
- marker_index = index
- continue
- if text:
- break
- return marker_index if marker_index is not None else end
- def _save_native_template_chapter_docx(
- chapter: Chapter,
- template_path: str,
- output_path: str,
- placeholder_map: Optional[Dict[str, str]] = None,
- company_info: Optional[CompanyInfo] = None,
- analysis: Optional[TenderAnalysis] = None,
- project_data: Optional[ProjectData] = None,
- ) -> bool:
- """复制模板原生章节 OOXML,仅改授权标题并插入节点补充正文。"""
- import io
- import posixpath
- import zipfile
- import xml.etree.ElementTree as ET
- if not template_path or not os.path.isfile(template_path):
- return False
- try:
- with zipfile.ZipFile(template_path, "r") as source_zip:
- document_bytes = source_zip.read("word/document.xml")
- styles_root = ET.fromstring(source_zip.read("word/styles.xml"))
- namespace_events = ET.iterparse(
- io.BytesIO(document_bytes), events=("start-ns",)
- )
- for _event, (prefix, uri) in namespace_events:
- try:
- ET.register_namespace(prefix or "", uri)
- except ValueError:
- pass
- root = ET.fromstring(document_bytes)
- body = root.find(_w_tag("body"))
- if body is None:
- return False
- style_levels, style_ids = _docx_style_maps(styles_root)
- children = list(body)
- h1_positions = [
- index for index, element in enumerate(children)
- if element.tag == _w_tag("p")
- and _docx_paragraph_level(element, style_levels) == 1
- ]
- # 新增评分章没有模板正文来源,不能用最终章号裁取同号模板章。
- new_scoring_chapter = chapter.is_scoring_inserted
- target_number = "" if new_scoring_chapter else _template_chapter_number(chapter)
- start = None
- for position in h1_positions:
- text = _docx_xml_text(children[position])
- match = re.match(
- r"^第\s*([一二三四五六七八九十百\d]+)\s*章", text
- )
- if not match:
- continue
- raw = match.group(1)
- from step3_outlining.outline_generator import _chinese_to_int
- number = str(_chinese_to_int(raw) if not raw.isdigit() else int(raw))
- if number == target_number:
- start = position
- break
- if start is None:
- if not new_scoring_chapter and not _is_evaluation_index_chapter(chapter):
- from step4_writing.difficulty_rewrite import MODE
- if any(n.content_generation_mode == MODE for n in _walk_nodes(chapter)):
- raise ValueError("模板中未找到专项所属章节,无法保护原生结构")
- return False
- start = 0
- chapter_sect_pr = _chapter_section_properties(children, 0)
- selected = [
- _new_docx_paragraph(
- chapter.title,
- style_ids.get(1, "Heading1"),
- )
- ]
- else:
- end = next((pos for pos in h1_positions if pos > start), len(children))
- end = _trim_trailing_part_marker(children, start, end)
- chapter_sect_pr = _chapter_section_properties(children, start)
- selected = children[start:end]
- for element in children:
- body.remove(element)
- for element in selected:
- body.append(element)
- existing_sect_pr = next(
- (
- element
- for element in list(body)
- if element.tag == _w_tag("sectPr")
- ),
- None,
- )
- if existing_sect_pr is not None:
- body.remove(existing_sect_pr)
- if chapter_sect_pr is not None:
- body.append(chapter_sect_pr)
- # 模板章内部常包含无 header/footer 引用的分节符;独立章节 DOCX
- # 中所有分节统一使用模板第一个分节的页眉页脚引用,保证第 1-8 章
- # 与 index 的页眉格式一致。
- resolved_refs = _first_header_footer_refs(children)
- for sect_pr in body.iter(_w_tag("sectPr")):
- _apply_header_footer_refs(sect_pr, resolved_refs)
- # Step4 在原生段落/表格单元格中完成项目信息填充。只改含已知
- # 占位符的段落文本,不触碰图片、表格结构或其他原始内容块。
- effective_map = dict(placeholder_map or {})
- header_project_name = (
- effective_map.get("项目名称")
- or (analysis.project_name if analysis is not None else "")
- or ""
- )
- _replace_native_table_placeholders(
- body,
- analysis=analysis,
- project_data=project_data,
- placeholder_map=effective_map,
- )
- commitment_placeholder_nodes = _replace_native_commitment_placeholder(
- body, chapter
- )
- for paragraph in body.iter(_w_tag("p")):
- original = "".join(
- node.text or "" for node in paragraph.iter(_w_tag("t"))
- )
- if not original:
- continue
- filled = _resolve_chapter_text_placeholders(
- original, effective_map
- )
- if company_info is not None and analysis is not None:
- filled = replace_bracket_placeholders(
- filled,
- company_info=company_info,
- analysis=analysis,
- )
- if analysis is not None:
- # 原生裁章会完整保留模板旧正文;评分要求中的当前数值必须
- # 同样覆盖模板历史值,不能只修正 generated_content。
- filled = _override_rejection_conflicts(filled, analysis)
- if filled != original:
- _fill_docx_paragraph_text(paragraph, filled)
- # 最终大纲是唯一标题树;模板原标题只负责找到原生内容锚点。
- all_nodes = list(_walk_nodes(chapter))
- node_by_id = {str(node.id): node for node in all_nodes}
- parent_by_id = {str(chapter.id): None}
- for node in all_nodes:
- for child in node.children or []:
- parent_by_id.setdefault(str(child.id), str(node.id))
- def _node_key(node) -> str:
- for candidate in (node.template_original_title, node.title):
- key = _artifact_heading_key(candidate)
- if key:
- return key
- return ""
- def _node_path(node) -> Tuple[str, ...]:
- path = []
- current = node
- while current is not None:
- key = _node_key(current)
- if key:
- path.append(key)
- parent_id = parent_by_id.get(str(current.id))
- current = node_by_id.get(parent_id) if parent_id else None
- return tuple(reversed(path))
- # 同一组织名称会同时出现在“近三年类似项目业绩”和“类似业绩业主评价”
- # 等多个父节下,仅按标题文本建索引会把后出现的同父子标题误绑到先出现
- # 的节点,导致 Step4 把未绑定的节点再插入一遍。这里按“父路径+标题”
- # 精确匹配,避免跨父节串绑。
- path_nodes: Dict[Tuple[str, ...], List[object]] = {}
- for node in all_nodes:
- path_nodes.setdefault(_node_path(node), []).append(node)
- anchors: Dict[str, object] = {str(chapter.id): selected[0]}
- heading_stack: List[Tuple[int, str]] = [(1, _node_key(chapter))]
- for element in list(body):
- if element.tag != _w_tag("p"):
- continue
- level = _docx_paragraph_level(element, style_levels)
- if not level:
- continue
- text = _docx_xml_text(element)
- key = _artifact_heading_key(text)
- prefix = [item_key for item_level, item_key in heading_stack if item_level < level]
- node = next(
- (
- candidate
- for candidate in path_nodes.get(
- tuple(prefix + [key]), []
- )
- if str(candidate.id) not in anchors
- ),
- None,
- )
- if node is not None:
- anchors[str(node.id)] = element
- _replace_docx_paragraph_text(element, node.title)
- while heading_stack and heading_stack[-1][0] >= level:
- heading_stack.pop()
- heading_stack.append((level, key))
- h1_text = (
- f"第{_native_chapter_number(chapter)}章 {chapter.title}"
- if chapter.id.isdigit() else chapter.title
- )
- _replace_docx_paragraph_text(anchors[str(chapter.id)], h1_text)
- def element_level(element) -> int:
- return (
- _docx_paragraph_level(element, style_levels)
- if element.tag == _w_tag("p") else 0
- )
- def insert_children(parent: Chapter) -> None:
- parent_anchor = anchors[str(parent.id)]
- for child in parent.children or []:
- child_id = str(child.id)
- if child_id not in anchors:
- body_items = list(body)
- parent_pos = body_items.index(parent_anchor)
- final_sect_index = next(
- (
- index
- for index, element in enumerate(body_items)
- if element.tag == _w_tag("sectPr")
- ),
- len(body_items),
- )
- insert_at = final_sect_index
- for index in range(parent_pos + 1, final_sect_index):
- level = element_level(body_items[index])
- if level and level <= int(parent.level or 1):
- insert_at = index
- break
- heading = _new_docx_paragraph(
- child.title,
- style_ids.get(max(2, min(int(child.level or 2), 4)), "Heading2"),
- )
- body.insert(insert_at, heading)
- anchors[child_id] = heading
- insert_children(child)
- from step4_writing.difficulty_rewrite import MODE
- for node in all_nodes:
- if (node.content_generation_mode == MODE
- and (node.from_template or node.template_original_id)
- and str(node.id) not in anchors):
- raise ValueError(f"专项原生标题锚点缺失:{node.id}")
- insert_children(chapter)
- from step4_writing.difficulty_rewrite import remove_rewritten_prose
- remove_rewritten_prose(
- body, all_nodes, anchors, element_level, _docx_xml_text, _w_tag
- )
- # 每个补充块放在该节点原生区域末尾、下一标题之前;图片/表格不移动。
- for node in reversed(all_nodes):
- supplement = (node.supplement_content or "").strip()
- if (
- not supplement
- or str(node.id) not in anchors
- or str(node.id) in commitment_placeholder_nodes
- ):
- continue
- body_items = list(body)
- anchor_pos = body_items.index(anchors[str(node.id)])
- final_sect_index = next(
- (
- index
- for index, element in enumerate(body_items)
- if element.tag == _w_tag("sectPr")
- ),
- len(body_items),
- )
- insert_at = final_sect_index
- for index in range(anchor_pos + 1, final_sect_index):
- if element_level(body_items[index]):
- insert_at = index
- break
- paragraphs = [
- _new_docx_paragraph(line)
- for line in supplement.splitlines() if line.strip()
- ]
- for paragraph in reversed(paragraphs):
- body.insert(insert_at, paragraph)
- document_out = ET.tostring(
- root, encoding="utf-8", xml_declaration=True
- )
- # 章节包只携带本章 document.xml 实际引用的图片/嵌入对象。
- # 其余 300+ 个模板媒体属于其他章节,复制会让每章都接近模板全量体积。
- office_rel_ns = (
- "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
- )
- package_rel_ns = (
- "http://schemas.openxmlformats.org/package/2006/relationships"
- )
- used_rel_ids = {
- value
- for element in root.iter()
- for attr, value in element.attrib.items()
- if attr.startswith(f"{{{office_rel_ns}}}")
- }
- document_rels_name = "word/_rels/document.xml.rels"
- document_rels_root = ET.fromstring(
- source_zip.read(document_rels_name)
- )
- selected_package_targets = set()
- heavy_tokens = ("/image", "/chart", "/oleObject", "/package")
- for relationship in list(document_rels_root):
- rel_id = relationship.get("Id", "")
- rel_type = relationship.get("Type", "")
- target = relationship.get("Target", "")
- is_heavy = any(token in rel_type for token in heavy_tokens)
- if is_heavy and rel_id not in used_rel_ids:
- document_rels_root.remove(relationship)
- continue
- if is_heavy and target and not target.startswith(("http:", "https:")):
- selected_package_targets.add(posixpath.normpath(
- posixpath.join("word", target)
- ))
- # 页眉页脚可能通过自己的 rels 使用图片;这些关系与媒体一并保留。
- for rel_info in source_zip.infolist():
- name = rel_info.filename
- if not name.endswith(".rels") or name == document_rels_name:
- continue
- if not name.startswith("word/_rels/"):
- continue
- try:
- rel_root = ET.fromstring(source_zip.read(name))
- except Exception:
- continue
- source_part = "word/" + name.rsplit("/", 1)[-1][:-5]
- for relationship in rel_root.findall(
- f"{{{package_rel_ns}}}Relationship"
- ):
- target = relationship.get("Target", "")
- if target and not target.startswith(("http:", "https:")):
- resolved = posixpath.normpath(posixpath.join(
- posixpath.dirname(source_part), target
- ))
- if resolved.startswith("word/media/"):
- selected_package_targets.add(resolved)
- document_rels_out = ET.tostring(
- document_rels_root, encoding="utf-8", xml_declaration=True
- )
- os.makedirs(os.path.dirname(output_path), exist_ok=True)
- with zipfile.ZipFile(
- output_path, "w", compression=zipfile.ZIP_STORED
- ) as output_zip:
- for info in source_zip.infolist():
- if (
- info.filename.startswith("word/media/")
- and info.filename not in selected_package_targets
- ):
- continue
- if info.filename == "word/document.xml":
- data = document_out
- elif info.filename == document_rels_name:
- data = document_rels_out
- elif (
- info.filename.startswith("word/header")
- or info.filename.startswith("word/footer")
- ) and info.filename.endswith(".xml"):
- try:
- data = _transform_header_footer_part(
- source_zip.read(info.filename),
- effective_map,
- header_project_name,
- )
- except Exception as exc:
- logger.warning(
- f" [{chapter.id}] 页眉/页脚信息替换失败,"
- f"保留模板原文: {exc}"
- )
- data = source_zip.read(info.filename)
- else:
- data = source_zip.read(info.filename)
- output_zip.writestr(
- info.filename, data, compress_type=zipfile.ZIP_STORED
- )
- return True
- except Exception as exc:
- from step4_writing.difficulty_rewrite import MODE
- if any(n.content_generation_mode == MODE for n in _walk_nodes(chapter)):
- raise RuntimeError("专项章节原生装配失败,禁止降级丢失受保护结构") from exc
- logger.warning(
- f" [{chapter.id}] 原生模板章节产物复制失败,使用结构化文本兜底: {exc}"
- )
- return False
- def _save_chapter_docx(
- chapter: Chapter,
- chapter_dir: str,
- template_path: str = "",
- placeholder_map: Optional[Dict[str, str]] = None,
- company_info: Optional[CompanyInfo] = None,
- analysis: Optional[TenderAnalysis] = None,
- project_data: Optional[ProjectData] = None,
- ) -> str:
- """保存一个顶层章节 DOCX。
- 标题树严格采用 Step3 最终 Chapter 树;模板来源编号只用于找回原生
- 正文,新增评分章不裁取模板正文。该文件供 Step5 审核和 Step6 聚合。
- """
- import re
- from docx import Document as DocxDocument
- from docx.shared import Pt, Cm
- from docx.enum.text import WD_ALIGN_PARAGRAPH
- os.makedirs(chapter_dir, exist_ok=True)
- safe_title = re.sub(
- r'[<>:"/\\|?*]+', "_", chapter.title,
- ).strip(" ._")[:40] or "章节"
- path = os.path.abspath(
- os.path.join(chapter_dir, f"{chapter.id}_{safe_title}.docx")
- )
- if _save_native_template_chapter_docx(
- chapter,
- template_path,
- path,
- placeholder_map=placeholder_map,
- company_info=company_info,
- analysis=analysis,
- project_data=project_data,
- ):
- return path
- doc = DocxDocument()
- section = doc.sections[0]
- section.page_width = Cm(21.0)
- section.page_height = Cm(29.7)
- # 章标题是唯一 H1;样式用于让 Word 导航窗格与 Step3 目录一致。
- title_para = doc.add_paragraph(style="Heading 1")
- title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
- chapter_number = chapter.id
- if chapter.id.isdigit():
- from step3_outlining.scoring_structure import _int_to_chinese
- chapter_number = _int_to_chinese(int(chapter.id))
- display_title = (
- f"第{chapter_number}章 {chapter.title}"
- if chapter.id.isdigit()
- else chapter.title
- )
- title_run = title_para.add_run(display_title)
- title_run.bold = True
- title_run.font.size = Pt(18)
- if _is_deferred_evaluation_index(chapter):
- notice = doc.add_paragraph()
- notice.add_run(
- "本索引在全部章节合并、结构检查并稳定分页后,"
- "依据 Step3 最终标题映射构建并填写实际页码。"
- ).font.size = Pt(12)
- if not _is_deferred_evaluation_index(chapter):
- buckets = _split_artifact_content_by_outline(chapter)
- _render_artifact_lines(doc, buckets.get(str(chapter.id), []))
- if chapter.supplement_content.strip():
- _render_artifact_lines(doc, chapter.supplement_content.splitlines())
- for child in chapter.children or []:
- _render_artifact_node(doc, child, buckets)
- fallback_project_name = (
- (placeholder_map or {}).get("项目名称")
- or (analysis.project_name if analysis is not None else "")
- )
- _add_fallback_header_footer(
- doc,
- placeholder_map=placeholder_map,
- project_name=fallback_project_name,
- )
- doc.save(path)
- if not os.path.isfile(path) or os.path.getsize(path) == 0:
- raise RuntimeError(f"章节 DOCX 保存失败: {path}")
- return path
- def _is_markdown_table_row(line: str) -> bool:
- """判断一行是否是 Markdown 表格行"""
- return line.strip().startswith("|") and line.strip().endswith("|") and line.count("|") >= 2
- def _has_separator_row(lines: list, start_idx: int) -> bool:
- """检查下一行是否是表格分隔行(|---|---|)"""
- if start_idx >= len(lines):
- return False
- line = lines[start_idx].strip()
- return bool(re.match(r"^\|[\s\-:|]+\|$", line))
- def _extract_table_block(lines: list, start_idx: int) -> tuple:
- """提取 Markdown 表格块
- Returns:
- (table_lines, next_idx): 表格行列表和表格结束后的下一个行索引
- """
- table_lines = []
- i = start_idx
- while i < len(lines):
- line = lines[i].strip()
- if _is_markdown_table_row(line):
- table_lines.append(line)
- i += 1
- else:
- break
- return table_lines, i
- def _render_table_block(doc, table_lines: list) -> None:
- """将 Markdown 表格行列表渲染为 DOCX 表格"""
- from docx.shared import Pt
- if len(table_lines) < 2:
- return
- # 解析所有行
- rows_data = []
- for line in table_lines:
- cells = [c.strip() for c in line.strip().strip("|").split("|")]
- rows_data.append(cells)
- # 过滤分隔行(---|---)
- data_rows = [r for r in rows_data if not all(re.match(r"^[\-:\s]+$", c) for c in r)]
- if not data_rows:
- return
- num_cols = max(len(r) for r in data_rows)
- # 创建 DOCX 表格
- table = doc.add_table(rows=len(data_rows), cols=num_cols, style="Table Grid")
- for row_idx, row_data in enumerate(data_rows):
- for col_idx in range(num_cols):
- cell = table.cell(row_idx, col_idx)
- cell_text = row_data[col_idx] if col_idx < len(row_data) else ""
- # 清空默认段落
- for p in cell.paragraphs:
- p.clear()
- run = cell.paragraphs[0].add_run(cell_text)
- run.font.size = Pt(10)
- # 表头加粗
- if row_idx == 0:
- run.bold = True
- # 表格后加空行
- doc.add_paragraph()
- __all__ = [
- "write_content",
- # V2 导出
- "fill_all_tables",
- "resolve_all_placeholders",
- "clean_ai_commentary",
- "strip_markdown_headings",
- "_persist_all_chapter_docs",
- ]
|