build_course.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. """保留原 MkDocs Material 壳;增量生成图增强教材,所有源和产物只在副本中。"""
  2. from pathlib import Path
  3. from bs4 import BeautifulSoup
  4. from urllib.parse import urlsplit, urlunsplit, unquote, quote
  5. import os,re,json,ast,hashlib,copy,csv,shutil,html,sys
  6. import markdown
  7. from pygments import highlight
  8. from pygments.lexers import PythonLexer
  9. from pygments.formatters import HtmlFormatter
  10. from draw_diagrams import main as draw_diagrams
  11. ROOT=Path(__file__).resolve().parents[1]
  12. SRC=ROOT/'course-source';SITE=ROOT/'课件'/'EnterpriseKB_RAG_V1.0讲义';OLD=SRC/'legacy-html';APP=ROOT/'代码'/'EnterpriseKB_qa_system';RESEARCH=ROOT/'research'
  13. RESEARCH.mkdir(exist_ok=True)
  14. D={'⑤ RAG系统评估':'⑥ RAG系统评估','⑥ 融合Mysql的RAG系统':'⑦ 融合MySQL、Milvus与Neo4j','⑦ 企业级生产环境部署':'⑧ 企业级生产环境部署'}
  15. G5='⑤ 基于Neo4j的知识图谱增强问答系统'
  16. EXTRA={'6.4':'⑥ RAG系统评估','7.5':'⑦ 融合MySQL、Milvus与Neo4j','8.3':'⑧ 企业级生产环境部署'}
  17. NUM={'5':'6','6':'7','7':'8'}
  18. LEDGER=[]
  19. SNIPPETS={
  20. 'schema-load':('graph_qa/schema.py','load_dataset',' for fact in facts:', ' if any(e["type"]', '5.3'),
  21. 'settings':('graph_qa/config.py','GraphSettings', ' milvus_collection:', ' @classmethod','5.4'),
  22. 'graph-constraints':('graph_qa/db.py','GraphStore.ensure_schema',None,None,'5.4'),
  23. 'graph-import':('graph_qa/db.py','GraphStore._replace_document',' for fact in facts:',None,'5.4'),
  24. 'validate-extraction':('graph_qa/extractor.py','validate_extraction',None,None,'5.5'),
  25. 'llm-extract':('graph_qa/extractor.py','LLMExtractor.extract',None,None,'5.5'),
  26. 'normalize':('graph_qa/entity_resolver.py','normalize',None,None,'5.6'),
  27. 'entity-resolve':('graph_qa/entity_resolver.py','EntityResolver.resolve',None,None,'5.6'),
  28. 'dependency-paths':('graph_qa/retriever.py','GraphRetriever._dependency_paths',' for selected in directions:',None,'5.7'),
  29. 'attach-responsibility':('graph_qa/retriever.py','GraphRetriever._attach_responsibility',None,None,'5.7'),
  30. 'evidence-bundle':('graph_qa/schema.py','EvidenceBundle',None,None,'5.8'),
  31. 'graph-evidence':('graph_qa/retriever.py','GraphRetriever.retrieve',' chunk_ids =',' bundles.append','5.8'),
  32. 'build-context':('graph_qa/evidence_builder.py','build_context',' for bundle in bundles:',' paths = []','5.8'),
  33. 'rrf':('graph_qa/fusion.py','reciprocal_rank_fusion',None,None,'5.9'),
  34. 'rerank':('graph_qa/fusion.py','rerank_candidates',None,None,'5.9'),
  35. 'parallel-retrieve':('graph_qa/pipeline.py','GraphPipeline.retrieve',' executor =',' channels, warnings','5.9'),
  36. 'select-context':('graph_qa/fusion.py','select_context',None,None,'5.9'),
  37. 'make-prompt':('graph_qa/evidence_builder.py','make_prompt',' if scope in',' else:','5.10'),
  38. 'prompt-context':('graph_qa/evidence_builder.py','make_prompt',' return f',None,'5.10'),
  39. 'answer':('graph_qa/pipeline.py','GraphPipeline.answer',None,None,'5.10'),
  40. 'validate-citations':('graph_qa/evidence_builder.py','validate_citations',None,None,'5.10'),
  41. 'remove-document':('graph_qa/db.py','GraphStore._remove_document',None,None,'5.11'),
  42. 'ingest-journal':('graph_qa/pipeline.py','GraphPipeline.ingest',' journal =',' return {','5.11'),
  43. 'channel-errors':('graph_qa/pipeline.py','GraphPipeline.retrieve',' for future in done:',' executor.shutdown','5.11'),
  44. 'evaluation-metrics':('evaluation_graph/run_evaluation.py','score_result',None,None,'5.12,6.4'),
  45. 'integration-hook':('rag_qa/core/new_rag_system.py','RAGSystem.retrieve_and_merge',' if self.graph_pipeline is not None:',' # 如果未指定','7.5'),
  46. 'api-response':('graph_qa/api.py','query',None,None,'7.5'),
  47. 'model-paths':('graph_qa/model_runtime.py','ModelRuntime.__init__',None,None,'8.3'),
  48. }
  49. def mapped(path):
  50. p=Path(path);parts=list(p.parts)
  51. if parts and parts[0] in D:
  52. old=parts[0];parts[0]=D[old]
  53. if len(parts)>1 and re.match(r'[567]\.\d',parts[-1]):parts[-1]=NUM[parts[-1][0]]+parts[-1][1:]
  54. return '/'.join(parts)
  55. def renumber_text(text):
  56. for old,new in D.items():text=text.replace(old,new)
  57. text=re.sub(r'(?<![\d.])([567])\.(?=\d)',lambda m:NUM[m[1]]+'.',text)
  58. text=re.sub(r'第([五六七])章',lambda m:'第'+{'五':'六','六':'七','七':'八'}[m[1]]+'章',text)
  59. return text
  60. def encode(path):return quote(path,safe='/#?=&:()')
  61. def relpath(target,current):return encode(os.path.relpath(SITE/target,(SITE/current).parent).replace(os.sep,'/'))
  62. def rewrite_url(url,old_path,new_path):
  63. if not url or url.startswith(('#','mailto:','data:','javascript:')):return url
  64. q=urlsplit(url)
  65. if q.scheme or q.netloc:return url
  66. decoded=unquote(q.path)
  67. target=os.path.normpath(str(Path(old_path).parent/decoded)).replace(os.sep,'/')
  68. target=mapped(target)
  69. relative=os.path.relpath(SITE/target,(SITE/new_path).parent).replace(os.sep,'/')
  70. return urlunsplit(('', '',encode(relative),q.query,q.fragment))
  71. def snippet(key):
  72. path,symbol,start,end,lessons=SNIPPETS[key];p=APP/path
  73. if not p.exists():raise ValueError(f'Missing source {p}')
  74. source=p.read_text();tree=ast.parse(source);node=None
  75. def find(nodes,parts):
  76. for n in nodes:
  77. if isinstance(n,(ast.FunctionDef,ast.AsyncFunctionDef,ast.ClassDef)) and n.name==parts[0]:
  78. if len(parts)==1:return n
  79. return find(n.body,parts[1:])
  80. node=find(tree.body,symbol.split('.'))
  81. if not node:raise ValueError(f'Symbol not found: {path}:{symbol}')
  82. lines=source.splitlines();a=node.lineno-1;b=node.end_lineno
  83. if node.decorator_list:a=min(d.lineno-1 for d in node.decorator_list)
  84. if start:
  85. a=next((i for i in range(a,b) if start in lines[i]),None)
  86. if a is None:raise ValueError(f'Start not found: {key}: {start}')
  87. if end:
  88. found=next((i for i in range(a+1,b) if end in lines[i]),None)
  89. if found is not None:b=found
  90. text='\n'.join(lines[a:b])
  91. # Long mechanisms are split into consecutive coherent excerpts, never silently truncated.
  92. import textwrap
  93. text=textwrap.dedent(text)
  94. LEDGER.append({'lesson_id':lessons,'capability':key,'concept':symbol,'source_path':path,'symbols':symbol,'line_spans':f'{a+1}-{b}','source_sha256':hashlib.sha256(source.encode()).hexdigest(),'context_symbols':'相关类型与上下游在正文解释','upstream_downstream':'CLI / API → GraphPipeline → 检索或存储模块','excerpt_reason':'保留本知识点对应的真实机制与接口','omissions':'文件中的其他职责与初始化代码','adaptation':'verbatim','verification_evidence':'验证结果/graphrag-evaluation.json; tests_graph/test_graph_integration.py; tests_graph/test_api_live.py'})
  95. chunks=[text]
  96. if len(text.splitlines())>36:
  97. # Whole function may include setup and validation sections. Keep source in order but split at an empty line.
  98. ls=text.splitlines();cut=min(range(14,min(len(ls),30)),key=lambda i: 0 if not ls[i].strip() else 1)
  99. if not ls[cut].strip():chunks=['\n'.join(ls[:cut]),'\n'.join(ls[cut+1:])]
  100. label=f'<p class="graph-code-location"><code>{html.escape(path)}</code> · <code>{html.escape(symbol)}</code>(本知识点节选)</p>'
  101. parts=[]
  102. for i,c in enumerate(chunks):
  103. block=highlight(c,PythonLexer(),HtmlFormatter(cssclass='highlight',wrapcode=True))
  104. if i:parts.append('<p>继续沿用上面已经准备的变量,下面完成这个方法余下的校验与返回:</p>')
  105. parts.append(block)
  106. return label+'\n'.join(parts)
  107. def fixture(key):
  108. data=APP/'graph_qa/data/dessert-shop'
  109. if key=='tea-fact':
  110. rows=[json.loads(line) for line in (data/'facts.jsonl').read_text().splitlines() if line.strip()]
  111. row=next(r for r in rows if r['id']=='f-tea-cake')
  112. entities=json.loads((data/'entities.json').read_text())
  113. names={e['id']:e['name'] for e in entities}
  114. relation_names={'DEPENDS_ON':'需要','CONTAINS':'包含','OWNED_BY':'由谁负责','SOLD_AT':'在哪里售卖','USED_BY':'供谁使用'}
  115. display={'左边节点':names[row['subject']], '关系':relation_names.get(row['relation'],row['relation']), '右边节点':names[row['object']], '知识域':'甜品店' if row['scope']=='dessert-shop' else '另一门店', '原文编号':row['chunk_id'], '引用原句':row['evidence_quote']}
  116. return '<div class="highlight"><pre><code>'+html.escape(json.dumps(display,ensure_ascii=False,indent=2))+'</code></pre></div>'
  117. raise ValueError('Unknown fixture '+key)
  118. def figure(match,current):
  119. name,caption=match.group(1),match.group(2)
  120. url=relpath('graph_assets/'+name+'.svg',current)
  121. return f'<figure><a href="{url}" target="_blank" rel="noopener" title="打开原尺寸图解"><img src="{url}" alt="{html.escape(caption)}" loading="lazy"/></a><figcaption>{html.escape(caption)}</figcaption></figure>'
  122. def runtime(key):
  123. p=SRC/'runtime-sections.json'
  124. if p.exists():
  125. content=json.loads(p.read_text()).get(key)
  126. if content:return content
  127. return '<div class="admonition note"><p class="admonition-title">课堂运行与核对</p><p>按本节命令执行后,以增强版“验证结果”中的记录核对证据和回答。建图基线使用配套人工核对资料;不要将核对答案当作运行指标。</p></div>'
  128. def nav_html(pages,current):
  129. groups=[]
  130. for p,title in pages:
  131. if p=='index.html':continue
  132. g=p.split('/')[0]
  133. if not groups or groups[-1][0]!=g:groups.append((g,[]))
  134. groups[-1][1].append((p,title))
  135. out=['<nav class="md-nav md-nav--primary" aria-label="导航栏" data-md-level="0"><label class="md-nav__title" for="__drawer">企业知识库 RAG · 图增强版</label><ul class="md-nav__list" data-md-scrollfix>']
  136. out.append(f'<li class="md-nav__item"><a class="md-nav__link {"md-nav__link--active" if current=="index.html" else ""}" href="{relpath("index.html",current)}"><span class="md-ellipsis">知识库内容</span></a></li>')
  137. for i,(g,items) in enumerate(groups,2):
  138. active=any(p==current for p,_ in items);n=f'__nav_{i}'
  139. out.append(f'<li class="md-nav__item md-nav__item--nested {"md-nav__item--active" if active else ""}"><input class="md-nav__toggle md-toggle" type="checkbox" id="{n}" {"checked" if active else ""}><label class="md-nav__link" for="{n}" id="{n}_label" tabindex="0"><span class="md-ellipsis">{html.escape(g)}</span><span class="md-nav__icon md-icon"></span></label><nav class="md-nav" data-md-level="1" aria-labelledby="{n}_label" aria-expanded="{str(active).lower()}"><label class="md-nav__title" for="{n}"><span class="md-nav__icon md-icon"></span>{html.escape(g)}</label><ul class="md-nav__list" data-md-scrollfix>')
  140. for p,title in items:
  141. out.append(f'<li class="md-nav__item {"md-nav__item--active" if p==current else ""}"><a class="md-nav__link {"md-nav__link--active" if p==current else ""}" href="{relpath(p,current)}"><span class="md-ellipsis">{html.escape(title)}</span></a></li>')
  142. out.append('</ul></nav></li>')
  143. out.append('</ul></nav>');return ''.join(out)
  144. def html_fragment(s):return BeautifulSoup(s,'html.parser')
  145. def make_toc(soup,article):
  146. for i,h in enumerate(article.select('h2,h3,h4')):
  147. if not h.get('id'):h['id']='section-'+str(i+1)
  148. if not h.select_one('.headerlink'):
  149. a=soup.new_tag('a',attrs={'class':'headerlink','href':'#'+h['id'],'title':'本节链接'});a.string='¶';h.append(a)
  150. links=[]
  151. for h in article.select('h2,h3'):
  152. title=h.get_text(' ',strip=True).rstrip('¶').strip();links.append(f'<li class="md-nav__item"><a class="md-nav__link" href="#{h["id"]}"><span class="md-ellipsis">{html.escape(title)}</span></a></li>')
  153. toc='<nav class="md-nav md-nav--secondary" aria-label="目录"><label class="md-nav__title" for="__toc"><span class="md-nav__icon md-icon"></span>目录</label><ul class="md-nav__list" data-md-component="toc" data-md-scrollfix>'+''.join(links)+'</ul></nav>'
  154. sidebar=soup.select_one('.md-sidebar--secondary .md-sidebar__inner')
  155. if sidebar:sidebar.clear();sidebar.append(html_fragment(toc))
  156. else:
  157. primary=soup.select_one('.md-sidebar--primary')
  158. if primary:primary.insert_after(html_fragment('<div class="md-sidebar md-sidebar--secondary" data-md-component="sidebar" data-md-type="toc"><div class="md-sidebar__scrollwrap"><div class="md-sidebar__inner">'+toc+'</div></div></div>'))
  159. def standardize(soup,current,pages,title):
  160. nav=soup.select_one('nav.md-nav--primary')
  161. if nav:nav.replace_with(html_fragment(nav_html(pages,current)))
  162. article=soup.select_one('article')
  163. h1=article.select_one('h1')
  164. if h1:
  165. h1.clear();h1.string=title
  166. make_toc(soup,article)
  167. if current.startswith(G5) or current.split('/')[-1].startswith(('6.4 ','7.5 ','8.3 ')):article['class']=article.get('class',[])+['graph-lesson']
  168. soup.title.string=title+' - 企业知识库RAG智能问答项目·图增强版'
  169. topic=soup.select_one('[data-md-component="header-topic"] .md-ellipsis')
  170. if topic:topic.string=title
  171. for t in soup.find_all(['script','link']):
  172. src=t.get('src') or t.get('href') or ''
  173. if t.name=='link' and (t.get('rel')==['preconnect'] or 'fonts.googleapis' in src):t.decompose();continue
  174. if t.name=='script' and ('cdn.jsdelivr' in src or 'polyfill.io' in src or src.endswith(('javascripts/extra.js','javascripts/mathjax.js'))):t.decompose();continue
  175. for l in soup.select('link[rel="prev"],link[rel="next"],link[rel="canonical"]'):l.decompose()
  176. # Legacy pages may reference local MathJax config that did not exist. Add a complete local replacement.
  177. for s in soup.select('script[data-course-local],link[data-course-local]'):s.decompose()
  178. style=soup.new_tag('link',attrs={'rel':'stylesheet','href':relpath('graph_assets/course.css',current),'data-course-local':'true'});soup.head.append(style)
  179. cfg=soup.new_tag('script');cfg.string="window.MathJax={tex:{inlineMath:[['$','$'],['\\\\(','\\\\)']]},options:{skipHtmlTags:['script','noscript','style','textarea','pre','code']}};";soup.head.append(cfg)
  180. if (SITE/'assets/mathjax/tex-mml-chtml.js').exists():
  181. s=soup.new_tag('script',attrs={'src':relpath('assets/mathjax/tex-mml-chtml.js',current),'defer':'','data-course-local':'true'});soup.body.append(s)
  182. for asset in ['graph_assets/search-data.js','graph_assets/course.js']:
  183. s=soup.new_tag('script',attrs={'src':relpath(asset,current),'defer':'','data-course-local':'true'});soup.body.append(s)
  184. conf=soup.select_one('#__config')
  185. if conf:
  186. data=json.loads(conf.string);data['features']=[];conf.string=json.dumps(data,ensure_ascii=False)
  187. for prev in article.select('.graph-page-turn'):prev.decompose()
  188. seq=[p for p,_ in pages]
  189. if current in seq:
  190. idx=seq.index(current);links=[]
  191. for j,rel,label in [(idx-1,'prev','上一节'),(idx+1,'next','下一节')]:
  192. if 0<=j<len(pages):
  193. p,t=pages[j];u=relpath(p,current);links.append(f'<a href="{u}" rel="{rel}"><small>{label}</small><span>{html.escape(t)}</span></a>')
  194. soup.head.append(soup.new_tag('link',attrs={'rel':rel,'href':u}))
  195. article.append(html_fragment('<nav class="graph-page-turn" aria-label="前后章节">'+''.join(links)+'</nav>'))
  196. return soup
  197. def build():
  198. draw_diagrams()
  199. for filename in ('course.css','course.js'):
  200. shutil.copy2(SRC/filename,SITE/'graph_assets'/filename)
  201. for old,new in D.items():
  202. if (SITE/old).exists() and not (SITE/new).exists():shutil.move(str(SITE/old),str(SITE/new))
  203. index=BeautifulSoup((OLD/'index.html').read_text(),'html.parser')
  204. pages=[]
  205. for a in index.select('nav.md-nav--primary a.md-nav__link'):
  206. p=unquote(a['href']);t=renumber_text(a.get_text(' ',strip=True)) if p.split('/')[0] in D else a.get_text(' ',strip=True)
  207. pages.append((mapped(p),t))
  208. lessons={}
  209. for p in (SRC/'lessons').glob('*.md'):
  210. content=p.read_text();title=content.splitlines()[0].removeprefix('# ');folder=G5 if p.stem.startswith('5.') else EXTRA[p.stem]
  211. output=folder+'/'+title+'.html';lessons[output]=(p,content,title)
  212. pages.append((output,title))
  213. def order(item):
  214. p,t=item
  215. if p=='index.html':return (0,0,0)
  216. m=re.match(r'(\d+)\.(\d+)',t)
  217. if m:return (int(m[1]),int(m[2]),0)
  218. if p.startswith('④'):return (4,99,0)
  219. return (99,0,0)
  220. pages.sort(key=order)
  221. (SRC/'pages.json').write_text(json.dumps(pages,ensure_ascii=False,indent=2))
  222. template=(OLD/'④ 基于Milvus库的问答系统/4.4 向量存储.html').read_text()
  223. for old in OLD.rglob('*.html'):
  224. op=old.relative_to(OLD).as_posix();np=mapped(op);soup=BeautifulSoup(old.read_text(),'html.parser')
  225. for tag in soup.find_all(True):
  226. for att in ['href','src','poster']:
  227. if tag.get(att):tag[att]=rewrite_url(tag[att],op,np)
  228. for text_node in soup.find_all(string=True):
  229. if not text_node.parent or text_node.parent.name in ['script','style','code','pre']:continue
  230. if text_node.find_parent(['pre','code']):continue
  231. updated=renumber_text(str(text_node))
  232. if updated!=str(text_node):text_node.replace_with(updated)
  233. art=soup.select_one('article')
  234. if not art:continue
  235. title=next((t for p,t in pages if p==np),art.select_one('h1').get_text(' ',strip=True) if art.select_one('h1') else '课程资料')
  236. if np=='index.html':
  237. intro=f'<section class="graph-home"><h2 id="graph-update">新增第五章:用关系补齐跨文档证据</h2><p>从“草莓缺货,为什么双人下午茶也受影响”出发,复用已有 Milvus 混合检索,增加 Neo4j 图召回。先看图和真实案例,再分知识点学习短代码,最后在同一数据上对照实验。</p><p><a href="{relpath(next(p for p,t in pages if t.startswith("5.1 ")),np)}">进入 5.1:先看图检索的价值 →</a></p><p>原评估、融合与部署内容依次顺延到第六、七、八章,原正文与配图保留。增强版全部修改发生在独立副本。</p></section>'
  238. art.insert(1,html_fragment(intro))
  239. soup=standardize(soup,np,pages,title)
  240. out=SITE/np;out.parent.mkdir(parents=True,exist_ok=True);out.write_text(str(soup))
  241. # Remove old-number filenames inside the renamed directories; source snapshots retain every version.
  242. if op.split('/')[0] in D:
  243. stale=SITE/D[op.split('/')[0]]/Path(op).name
  244. if stale!=out and stale.exists():stale.unlink()
  245. for np,(p,content,title) in lessons.items():
  246. content=re.sub(r'\{\{figure:([^|}]+)\|([^}]+)\}\}',lambda m:figure(m,np),content)
  247. content=re.sub(r'\{\{fixture:([^}]+)\}\}',lambda m:fixture(m[1]),content)
  248. content=re.sub(r'\{\{snippet:([^}]+)\}\}',lambda m:snippet(m[1]),content)
  249. content=re.sub(r'\{\{runtime:([^}]+)\}\}',lambda m:runtime(m[1]),content)
  250. body=markdown.markdown(content,extensions=['tables','fenced_code','codehilite','admonition','sane_lists'],extension_configs={'codehilite':{'css_class':'highlight','guess_lang':False,'wrapcode':True}})
  251. soup=BeautifulSoup(template,'html.parser');art=soup.select_one('article');art.clear();art.append(html_fragment(body))
  252. # Only new lessons: keep diagram labels readable on a phone, with contained scrolling.
  253. for diagram in art.select('figure'):
  254. picture=diagram.find('a',recursive=False)
  255. if not picture or not picture.find('img'):continue
  256. viewport=soup.new_tag('div',attrs={'class':'graph-diagram-scroll','tabindex':'0','role':'region','aria-label':'图解,可左右滑动或点图放大'})
  257. picture.wrap(viewport)
  258. hint=soup.new_tag('p',attrs={'class':'graph-diagram-hint'});hint.string='可左右滑动或点图放大'
  259. diagram.insert(0,hint)
  260. for tag in soup.find_all(True):
  261. for att in ['href','src']:
  262. if tag.get(att) and not tag.find_parent('article'):tag[att]=rewrite_url(tag[att],'④ 基于Milvus库的问答系统/4.4 向量存储.html',np)
  263. soup=standardize(soup,np,pages,title);out=SITE/np;out.parent.mkdir(parents=True,exist_ok=True);out.write_text(str(soup))
  264. docs=[]
  265. for p,title in pages:
  266. s=BeautifulSoup((SITE/p).read_text(),'html.parser');article=s.select_one('article')
  267. for nav in article.select('.graph-page-turn'):nav.decompose()
  268. docs.append({'location':p,'title':title,'text':article.get_text(' ',strip=True)})
  269. search={'config':{'lang':['zh'],'separator':'[\\s\\-]+','pipeline':['stopWordFilter'],'fields':{'title':{'boost':1000},'text':{'boost':1},'tags':{'boost':1000000}}},'docs':docs}
  270. (SITE/'search/search_index.json').write_text(json.dumps(search,ensure_ascii=False))
  271. (SITE/'graph_assets/search-data.js').write_text('window.COURSE_SEARCH_DOCS='+json.dumps(docs,ensure_ascii=False)+';')
  272. (SITE/'sitemap.xml').write_text('<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'+''.join('<url><loc>'+html.escape(encode(p))+'</loc></url>' for p,_ in pages)+'</urlset>')
  273. gz=SITE/'sitemap.xml.gz'
  274. if gz.exists():
  275. import gzip;gz.write_bytes(gzip.compress((SITE/'sitemap.xml').read_bytes()))
  276. if LEDGER:
  277. with (RESEARCH/'course-source-to-lesson-ledger.csv').open('w',newline='') as f:
  278. w=csv.DictWriter(f,fieldnames=LEDGER[0].keys());w.writeheader();w.writerows(LEDGER)
  279. manifest={'site_root':str(SITE.relative_to(ROOT)),'formal_pages':len(pages),'added_pages':len(lessons),'preserved_html_sources':len(list(OLD.rglob('*.html'))),'diagrams':len(list((SITE/'graph_assets').glob('*.svg'))),'source_snippets':len(LEDGER),'build_command':'python course-source/build_course.py','layout':'原版 MkDocs Material HTML shell,原 CSS、代码块语法与组件保留'}
  280. (RESEARCH/'course-site-manifest.json').write_text(json.dumps(manifest,ensure_ascii=False,indent=2));print(json.dumps(manifest,ensure_ascii=False))
  281. if __name__=='__main__':build()