wiki_compile.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539
  1. #!/usr/bin/env python3
  2. """
  3. Wiki Full Compilation: Read 1326 maritime docs → LLM understand → Write wiki pages
  4. Uses vLLM on NPU for fast inference
  5. For each document:
  6. 1. Read extracted text
  7. 2. LLM summarizes → write sources/<filename>.md
  8. 3. LLM extracts entities → create/update entities/<entity>.md
  9. 4. LLM identifies topics → create/update topics/<topic>.md
  10. 5. Update index.md and log.md
  11. Supports resume from interruption.
  12. """
  13. import json
  14. import os
  15. import re
  16. import sys
  17. import time
  18. import urllib.request
  19. from datetime import datetime
  20. from pathlib import Path
  21. PROCESSED_DIR = Path('/data/knowledge/maritime/processed')
  22. WIKI_DIR = Path('/app/agentexample/qaagent67wiki/wiki')
  23. PROGRESS_FILE = WIKI_DIR / '.compile_progress.json'
  24. # vLLM API
  25. VLLM_URL = "http://127.0.0.1:8000/v1/chat/completions"
  26. # Fallback to Ollama if vLLM not available
  27. OLLAMA_URL = "http://127.0.0.1:8000/v1/chat/completions"
  28. API_URL = None # auto-detect
  29. def detect_api():
  30. global API_URL
  31. for url in [VLLM_URL, OLLAMA_URL]:
  32. try:
  33. test_body = json.dumps({
  34. "model": "qwen2.5-32b" if "8000" in url else "qwen2.5-32b",
  35. "messages": [{"role": "user", "content": "hi"}],
  36. "max_tokens": 5,
  37. }).encode('utf-8')
  38. req = urllib.request.Request(url, data=test_body,
  39. headers={"Content-Type": "application/json"}, method="POST")
  40. with urllib.request.urlopen(req, timeout=30) as resp:
  41. API_URL = url
  42. engine = "vLLM NPU" if "8000" in url else "Ollama CPU"
  43. print(f"Using: {engine} ({url})")
  44. return
  45. except:
  46. continue
  47. print("ERROR: No LLM API available!")
  48. sys.exit(1)
  49. def llm_chat(prompt, max_tokens=2048, temperature=0.2):
  50. model = "qwen2.5-32b" if "8000" in API_URL else "qwen2.5-32b"
  51. body = json.dumps({
  52. "model": model,
  53. "messages": [{"role": "user", "content": prompt}],
  54. "temperature": temperature,
  55. "max_tokens": max_tokens,
  56. }, ensure_ascii=False).encode('utf-8')
  57. req = urllib.request.Request(API_URL, data=body,
  58. headers={"Content-Type": "application/json"}, method="POST")
  59. with urllib.request.urlopen(req, timeout=300) as resp:
  60. data = json.loads(resp.read())
  61. return data["choices"][0]["message"]["content"].strip()
  62. def safe_filename(name, max_len=80):
  63. """Convert to safe filename"""
  64. name = re.sub(r'[\\/:*?"<>|]', '_', name)
  65. name = re.sub(r'\s+', '_', name)
  66. return name[:max_len]
  67. def load_progress():
  68. if PROGRESS_FILE.exists():
  69. with open(PROGRESS_FILE) as f:
  70. return json.load(f)
  71. return {'done': [], 'entities': {}, 'topics': {}, 'relations': []}
  72. def save_progress(progress):
  73. with open(PROGRESS_FILE, 'w', encoding='utf-8') as f:
  74. json.dump(progress, f, ensure_ascii=False, indent=2)
  75. def _extract_date_from_name(doc_name):
  76. """从文件名中提取日期信息"""
  77. # 匹配 "2017年8月31日起施行" 或 "2022年4月1日"
  78. m = re.search(r'(\d{4})年(\d{1,2})月(\d{1,2})日', doc_name)
  79. if m:
  80. return f"{m.group(1)}年{m.group(2)}月{m.group(3)}日"
  81. # 匹配 "2017年"
  82. m = re.search(r'(\d{4})年', doc_name)
  83. if m:
  84. return f"{m.group(1)}年"
  85. return "未知"
  86. def compile_source(doc_name, text, progress):
  87. """Step 1: Create source summary page"""
  88. # Truncate long documents
  89. text_sample = text[:6000] if len(text) > 6000 else text
  90. # Extract date from filename for temporal normalization
  91. doc_date = _extract_date_from_name(doc_name)
  92. prompt = f"""请阅读以下海事领域文档,输出结构化摘要。
  93. 文档名: {doc_name}
  94. 文档日期: {doc_date}
  95. 文档内容:
  96. {text_sample}
  97. 请按以下格式输出(不要输出其他内容):
  98. ## 摘要
  99. [2-4句话概括文档核心内容,必须包含具体的施行日期和文号]
  100. ## 关键信息
  101. - [要点1]
  102. - [要点2]
  103. - [要点3]
  104. (最多8个要点,每个要点尽量包含具体条款号、数字、日期)
  105. ## 时间线
  106. [列出文档中提到的所有时间节点,格式: 绝对日期|事件]
  107. {doc_date}|本文件施行
  108. (**重要**: 文档中的所有相对时间必须转换为绝对时间:
  109. - "自发布之日起施行" → "{doc_date}|施行"
  110. - "近年来" → 以{doc_date}为基准,推算为具体年份范围
  111. - "去年" → 以{doc_date}为基准,推算具体年份
  112. - "本条例修订前" → 写明具体的修订前版本年份
  113. - "三年内" → 从{doc_date}起算,写明截止的具体年份
  114. 禁止使用任何相对时间表述,全部转为绝对年月日)
  115. ## 实体列表
  116. [列出文档中涉及的重要实体,每行一个,格式: 类型|名称|说明]
  117. 法规|XXX法|(含施行日期和文号)
  118. 机构|XXX局|...
  119. 概念|XXX制度|...
  120. ## 实体关系
  121. [列出文档中实体之间的关系,每行一个,格式: 主体|关系|客体|说明]
  122. 交通运输部|制定|船员管理规定|交通运输部负责制定船员管理相关规定
  123. 海事局|隶属|交通运输部|海事局是交通运输部下属机构
  124. 船员适任证书|依据|STCW公约|船员适任证书制度依据STCW国际公约
  125. (列出文档中所有重要的实体间关系,包括:上下级/隶属、制定/发布、依据/引用、适用/管辖、修订/废止、时间先后等)
  126. ## 相关主题
  127. [列出文档涉及的主题领域,每行一个]
  128. 船员管理
  129. 船舶安全
  130. ...
  131. ## 元数据
  132. - 发布机构: [如有]
  133. - 施行日期: {doc_date}
  134. - 文号: [如有]
  135. - 法律层级: [全国人大/国务院/部委/海事局/其他]
  136. - 是否现行有效: [是/否/已被修订]
  137. - 修订历史: [如有,列出历次修订的绝对日期]"""
  138. result = llm_chat(prompt)
  139. # Write source page
  140. source_path = WIKI_DIR / 'sources' / f'{safe_filename(doc_name)}.md'
  141. source_path.parent.mkdir(parents=True, exist_ok=True)
  142. with open(source_path, 'w', encoding='utf-8') as f:
  143. f.write(f'# {doc_name}\n\n')
  144. f.write(f'[来源: {doc_name}]\n\n')
  145. f.write(result)
  146. f.write('\n')
  147. return result
  148. def extract_entities(llm_result, doc_name, progress):
  149. """Step 2: Extract and update entity pages"""
  150. entities_found = []
  151. lines = llm_result.split('\n')
  152. in_entity_section = False
  153. for line in lines:
  154. if '## 实体列表' in line:
  155. in_entity_section = True
  156. continue
  157. if line.startswith('## ') and in_entity_section:
  158. in_entity_section = False
  159. continue
  160. if in_entity_section and '|' in line:
  161. parts = [p.strip() for p in line.strip('- ').split('|')]
  162. if len(parts) >= 2:
  163. etype = parts[0]
  164. ename = parts[1]
  165. edesc = parts[2] if len(parts) > 2 else ''
  166. if ename and len(ename) > 1:
  167. entities_found.append((etype, ename, edesc))
  168. # Update entity pages
  169. for etype, ename, edesc in entities_found[:10]: # Max 10 per doc
  170. entity_file = safe_filename(ename)
  171. entity_path = WIKI_DIR / 'entities' / f'{entity_file}.md'
  172. entity_path.parent.mkdir(parents=True, exist_ok=True)
  173. if entity_path.exists():
  174. # Append reference
  175. existing = entity_path.read_text(encoding='utf-8')
  176. if doc_name not in existing:
  177. with open(entity_path, 'a', encoding='utf-8') as f:
  178. f.write(f'\n- {edesc} [来源: {doc_name}]\n')
  179. else:
  180. # Create new entity page
  181. with open(entity_path, 'w', encoding='utf-8') as f:
  182. f.write(f'# {ename}\n\n')
  183. f.write(f'**类型**: {etype}\n\n')
  184. f.write(f'## 描述\n\n{edesc}\n\n')
  185. f.write(f'## 相关文档\n\n')
  186. f.write(f'- {doc_name}\n')
  187. # Track in progress
  188. if ename not in progress['entities']:
  189. progress['entities'][ename] = {'type': etype, 'refs': []}
  190. if doc_name not in progress['entities'][ename]['refs']:
  191. progress['entities'][ename]['refs'].append(doc_name)
  192. return entities_found
  193. def extract_topics(llm_result, doc_name, progress):
  194. """Step 3: Extract and update topic pages"""
  195. topics_found = []
  196. lines = llm_result.split('\n')
  197. in_topic_section = False
  198. for line in lines:
  199. if '## 相关主题' in line:
  200. in_topic_section = True
  201. continue
  202. if line.startswith('## ') and in_topic_section:
  203. in_topic_section = False
  204. continue
  205. if in_topic_section:
  206. topic = line.strip('- ').strip()
  207. if topic and len(topic) > 1 and not topic.startswith('#'):
  208. topics_found.append(topic)
  209. # Update topic pages
  210. for topic in topics_found[:5]: # Max 5 per doc
  211. topic_file = safe_filename(topic)
  212. topic_path = WIKI_DIR / 'topics' / f'{topic_file}.md'
  213. topic_path.parent.mkdir(parents=True, exist_ok=True)
  214. if topic_path.exists():
  215. existing = topic_path.read_text(encoding='utf-8')
  216. if doc_name not in existing:
  217. with open(topic_path, 'a', encoding='utf-8') as f:
  218. f.write(f'- [[{safe_filename(doc_name)}]] - {doc_name}\n')
  219. else:
  220. with open(topic_path, 'w', encoding='utf-8') as f:
  221. f.write(f'# {topic}\n\n')
  222. f.write(f'## 相关文档\n\n')
  223. f.write(f'- [[{safe_filename(doc_name)}]] - {doc_name}\n')
  224. if topic not in progress['topics']:
  225. progress['topics'][topic] = []
  226. if doc_name not in progress['topics'][topic]:
  227. progress['topics'][topic].append(doc_name)
  228. return topics_found
  229. def extract_relations(llm_result, doc_name, progress):
  230. """Step 4: Extract entity relations and build knowledge graph"""
  231. relations_found = []
  232. lines = llm_result.split('\n')
  233. in_relation_section = False
  234. for line in lines:
  235. if '## 实体关系' in line:
  236. in_relation_section = True
  237. continue
  238. if line.startswith('## ') and in_relation_section:
  239. in_relation_section = False
  240. continue
  241. if in_relation_section and '|' in line:
  242. parts = [p.strip() for p in line.strip('- (').split('|')]
  243. if len(parts) >= 3:
  244. subject = parts[0]
  245. relation = parts[1]
  246. obj = parts[2]
  247. desc = parts[3] if len(parts) > 3 else ''
  248. if subject and relation and obj and len(subject) > 1:
  249. relations_found.append({
  250. 'subject': subject,
  251. 'relation': relation,
  252. 'object': obj,
  253. 'description': desc,
  254. 'source': doc_name,
  255. })
  256. # Store relations in progress
  257. if 'relations' not in progress:
  258. progress['relations'] = []
  259. for rel in relations_found[:15]: # Max 15 per doc
  260. # Deduplicate
  261. existing = any(
  262. r['subject'] == rel['subject'] and
  263. r['relation'] == rel['relation'] and
  264. r['object'] == rel['object']
  265. for r in progress['relations']
  266. )
  267. if not existing:
  268. progress['relations'].append(rel)
  269. # Update entity pages with relations
  270. for rel in relations_found[:15]:
  271. for ename in [rel['subject'], rel['object']]:
  272. entity_file = safe_filename(ename)
  273. entity_path = WIKI_DIR / 'entities' / f'{entity_file}.md'
  274. if entity_path.exists():
  275. existing = entity_path.read_text(encoding='utf-8')
  276. if '## 关系' not in existing:
  277. with open(entity_path, 'a', encoding='utf-8') as f:
  278. f.write(f'\n## 关系\n\n')
  279. rel_line = f'- {rel["subject"]} **{rel["relation"]}** {rel["object"]}'
  280. if rel['description']:
  281. rel_line += f' — {rel["description"]}'
  282. rel_line += f' [来源: {doc_name}]\n'
  283. existing = entity_path.read_text(encoding='utf-8')
  284. if rel_line.strip() not in existing:
  285. with open(entity_path, 'a', encoding='utf-8') as f:
  286. f.write(rel_line)
  287. # Write/update relations graph file
  288. relations_path = WIKI_DIR / 'relations.json'
  289. with open(relations_path, 'w', encoding='utf-8') as f:
  290. json.dump(progress['relations'], f, ensure_ascii=False, indent=2)
  291. return relations_found
  292. def update_index(progress):
  293. """Update wiki index.md"""
  294. sources = sorted(WIKI_DIR.glob('sources/*.md'))
  295. entities = sorted(WIKI_DIR.glob('entities/*.md'))
  296. topics = sorted(WIKI_DIR.glob('topics/*.md'))
  297. analyses = sorted(WIKI_DIR.glob('analyses/*.md'))
  298. index = f"""# Maritime Wiki Index
  299. > Auto-compiled from 1326 maritime documents by wikiagent
  300. > Last updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
  301. ## Sources ({len(sources)} files)
  302. """
  303. for s in sources[:50]: # Show first 50
  304. index += f'- [[{s.stem}]]\n'
  305. if len(sources) > 50:
  306. index += f'\n... and {len(sources) - 50} more\n'
  307. index += f'\n## Entities ({len(entities)})\n\n'
  308. # Group by type
  309. entity_types = {}
  310. for ename, einfo in progress.get('entities', {}).items():
  311. etype = einfo.get('type', 'other')
  312. if etype not in entity_types:
  313. entity_types[etype] = []
  314. entity_types[etype].append((ename, len(einfo.get('refs', []))))
  315. for etype, elist in sorted(entity_types.items()):
  316. index += f'### {etype} ({len(elist)})\n'
  317. for ename, ref_count in sorted(elist, key=lambda x: -x[1])[:20]:
  318. index += f'- [[{safe_filename(ename)}]] ({ref_count} refs)\n'
  319. if len(elist) > 20:
  320. index += f'- ... and {len(elist) - 20} more\n'
  321. index += '\n'
  322. index += f'## Topics ({len(topics)})\n\n'
  323. for tname, trefs in sorted(progress.get('topics', {}).items(), key=lambda x: -len(x[1]))[:30]:
  324. index += f'- [[{safe_filename(tname)}]] ({len(trefs)} docs)\n'
  325. if len(topics) > 30:
  326. index += f'\n... and {len(topics) - 30} more\n'
  327. index += f'\n## Analyses ({len(analyses)})\n\n'
  328. for a in analyses[:20]:
  329. index += f'- [[{a.stem}]]\n'
  330. # Relations summary
  331. relations = progress.get('relations', [])
  332. if relations:
  333. index += f'\n## Relations ({len(relations)})\n\n'
  334. # Group by relation type
  335. rel_types = {}
  336. for r in relations:
  337. rt = r['relation']
  338. if rt not in rel_types:
  339. rel_types[rt] = []
  340. rel_types[rt].append(r)
  341. for rt, rels in sorted(rel_types.items(), key=lambda x: -len(x[1]))[:15]:
  342. index += f'### {rt} ({len(rels)})\n'
  343. for r in rels[:5]:
  344. index += f'- {r["subject"]} → {r["object"]}\n'
  345. if len(rels) > 5:
  346. index += f'- ... and {len(rels) - 5} more\n'
  347. index += '\n'
  348. index += f'\n---\nStats: {len(sources)} sources, {len(entities)} entities, {len(topics)} topics, {len(relations)} relations, {len(analyses)} analyses\n'
  349. with open(WIKI_DIR / 'index.md', 'w', encoding='utf-8') as f:
  350. f.write(index)
  351. def update_log(doc_name, entities_count, topics_count, relations_count=0):
  352. """Append to log.md"""
  353. log_path = WIKI_DIR / 'log.md'
  354. timestamp = datetime.now().strftime('%Y-%m-%d %H:%M')
  355. entry = f'## [{timestamp}] Ingest | {doc_name}\n- Entities: {entities_count}, Topics: {topics_count}, Relations: {relations_count}\n\n'
  356. if log_path.exists():
  357. existing = log_path.read_text(encoding='utf-8')
  358. # Insert after header
  359. if '_no records_' in existing or '_no operations_' in existing:
  360. existing = '# Operation Log\n\n'
  361. with open(log_path, 'w', encoding='utf-8') as f:
  362. f.write(existing + entry)
  363. else:
  364. with open(log_path, 'w', encoding='utf-8') as f:
  365. f.write('# Operation Log\n\n' + entry)
  366. def main():
  367. detect_api()
  368. # Ensure wiki dirs exist
  369. for d in ['sources', 'entities', 'topics', 'analyses']:
  370. (WIKI_DIR / d).mkdir(parents=True, exist_ok=True)
  371. # Load progress
  372. progress = load_progress()
  373. done_set = set(progress['done'])
  374. # Get all text files
  375. all_docs = sorted(PROCESSED_DIR.glob('*.txt'))
  376. remaining = [d for d in all_docs if d.stem not in done_set]
  377. total = len(all_docs)
  378. print(f"Total documents: {total}")
  379. print(f"Already compiled: {len(done_set)}")
  380. print(f"Remaining: {len(remaining)}")
  381. print("=" * 60)
  382. start_time = time.time()
  383. errors = 0
  384. for idx, doc_path in enumerate(remaining):
  385. doc_name = doc_path.stem
  386. qnum = len(done_set) + idx + 1
  387. text = doc_path.read_text(encoding='utf-8')
  388. if len(text) < 50:
  389. print(f"[{qnum}/{total}] SKIP (too short): {doc_name[:60]}")
  390. progress['done'].append(doc_name)
  391. continue
  392. print(f"\n[{qnum}/{total}] {doc_name[:70]}...")
  393. try:
  394. # Step 1: Compile source summary
  395. t0 = time.time()
  396. llm_result = compile_source(doc_name, text, progress)
  397. t1 = time.time()
  398. print(f" Source page: {t1-t0:.1f}s")
  399. # Step 2: Extract entities
  400. entities = extract_entities(llm_result, doc_name, progress)
  401. print(f" Entities: {len(entities)} found")
  402. # Step 3: Extract topics
  403. topics = extract_topics(llm_result, doc_name, progress)
  404. print(f" Topics: {len(topics)} found")
  405. # Step 4: Extract relations
  406. relations = extract_relations(llm_result, doc_name, progress)
  407. print(f" Relations: {len(relations)} found")
  408. # Step 5: Update log
  409. update_log(doc_name, len(entities), len(topics), len(relations))
  410. progress['done'].append(doc_name)
  411. except Exception as e:
  412. errors += 1
  413. print(f" ERROR: {e}")
  414. # Still mark as done to avoid infinite retry
  415. progress['done'].append(doc_name)
  416. # Save progress and update index every 20 docs
  417. if (idx + 1) % 20 == 0 or idx == len(remaining) - 1:
  418. save_progress(progress)
  419. update_index(progress)
  420. elapsed = time.time() - start_time
  421. avg = elapsed / (idx + 1)
  422. eta = avg * (len(remaining) - idx - 1)
  423. print(f"\n [Progress] {qnum}/{total} done | "
  424. f"Elapsed: {elapsed/60:.1f}min | ETA: {eta/60:.1f}min | "
  425. f"Errors: {errors} | "
  426. f"Entities: {len(progress['entities'])} | "
  427. f"Topics: {len(progress['topics'])} | "
  428. f"Relations: {len(progress.get('relations', []))}")
  429. # Final update
  430. save_progress(progress)
  431. update_index(progress)
  432. total_time = time.time() - start_time
  433. sources = len(list(WIKI_DIR.glob('sources/*.md')))
  434. entities = len(list(WIKI_DIR.glob('entities/*.md')))
  435. topics = len(list(WIKI_DIR.glob('topics/*.md')))
  436. print(f"\n{'=' * 60}")
  437. print(f"Wiki Compilation Complete!")
  438. print(f" Time: {total_time/60:.1f} minutes")
  439. print(f" Sources: {sources}")
  440. print(f" Entities: {entities}")
  441. print(f" Topics: {topics}")
  442. print(f" Errors: {errors}")
  443. print(f" Wiki dir: {WIKI_DIR}")
  444. if __name__ == '__main__':
  445. main()