| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580 |
- #!/usr/bin/env python3
- """
- Wiki Full Compilation: Read domain docs → LLM understand → Write wiki pages
- Uses vLLM on NPU for fast inference
- For each document:
- 1. Read extracted text
- 2. LLM summarizes → write sources/<filename>.md
- 3. LLM extracts entities → create/update entities/<entity>.md
- 4. LLM identifies topics → create/update topics/<topic>.md
- 5. Update index.md and log.md
- Supports resume from interruption.
- """
- import json
- import os
- import re
- import sys
- import time
- import urllib.request
- from datetime import datetime
- from pathlib import Path
- from config import cfg
- PROCESSED_DIR = cfg.processed_dir
- WIKI_DIR = cfg.wiki_dir
- PROGRESS_FILE = WIKI_DIR / '.compile_progress.json'
- # LLM API endpoints — read from env vars so the job runner can override them.
- # Priority: VLLM_URL env (set by knowledge.py job runner) → Ollama default → vLLM fallback
- # NOTE: never default to port 8000 — that's the AgentPaaS FastAPI server,
- # which returns index.html (SPA catch-all) with HTTP 200, fooling detect_api().
- VLLM_URL = os.environ.get('VLLM_URL', 'http://127.0.0.1:8001/v1/chat/completions')
- OLLAMA_URL = os.environ.get('OLLAMA_URL', 'http://127.0.0.1:11434/v1/chat/completions')
- API_URL = None # auto-detect
- def _is_llm_endpoint(url: str, timeout: int = 10) -> bool:
- """Return True only if `url` responds like an OpenAI-compatible LLM.
- We send a minimal chat request and verify the response contains 'choices'
- or a known Ollama/vLLM error (model not found etc.) — NOT HTML.
- """
- try:
- body = json.dumps({
- "model": os.environ.get('LLM_MODEL', 'qwen2.5:7b'),
- "messages": [{"role": "user", "content": "hi"}],
- "max_tokens": 3,
- }).encode('utf-8')
- req = urllib.request.Request(
- url, data=body,
- headers={"Content-Type": "application/json"}, method="POST"
- )
- with urllib.request.urlopen(req, timeout=timeout) as resp:
- raw = resp.read()
- # Must be valid JSON and look like an LLM response (not HTML)
- if not raw or raw[:1] in (b'<', b'!'):
- return False
- data = json.loads(raw)
- # OpenAI format: {"choices": [...]} or Ollama error: {"error": "..."}
- return "choices" in data or "error" in data or "message" in data
- except urllib.error.HTTPError as e:
- # 4xx from a real LLM (model not loaded, bad request) — server IS there
- if e.code in (400, 404, 422, 503):
- return True
- return False
- except Exception:
- return False
- def detect_api():
- global API_URL
- candidates = list(dict.fromkeys([VLLM_URL, OLLAMA_URL])) # dedup, preserve order
- for url in candidates:
- if _is_llm_endpoint(url):
- API_URL = url
- label = "Ollama" if "11434" in url or "11435" in url else "vLLM/API"
- print(f"Using: {label} ({url})")
- return
- print("ERROR: No LLM API available!")
- print(f" Tried: {candidates}")
- print(" → Please start Ollama: brew install ollama && ollama serve && ollama pull qwen2.5:7b")
- sys.exit(1)
- def llm_chat(prompt, max_tokens=2048, temperature=0.2):
- model = os.environ.get('LLM_MODEL', 'qwen2.5:7b')
- body = json.dumps({
- "model": model,
- "messages": [{"role": "user", "content": prompt}],
- "temperature": temperature,
- "max_tokens": max_tokens,
- }, ensure_ascii=False).encode('utf-8')
- req = urllib.request.Request(API_URL, data=body,
- headers={"Content-Type": "application/json"}, method="POST")
- with urllib.request.urlopen(req, timeout=300) as resp:
- data = json.loads(resp.read())
- return data["choices"][0]["message"]["content"].strip()
- def safe_filename(name, max_len=80):
- """Convert to safe filename"""
- name = re.sub(r'[\\/:*?"<>|]', '_', name)
- name = re.sub(r'\s+', '_', name)
- return name[:max_len]
- def load_progress():
- if PROGRESS_FILE.exists():
- with open(PROGRESS_FILE) as f:
- return json.load(f)
- return {'done': [], 'entities': {}, 'topics': {}, 'relations': []}
- def save_progress(progress):
- with open(PROGRESS_FILE, 'w', encoding='utf-8') as f:
- json.dump(progress, f, ensure_ascii=False, indent=2)
- def _extract_date_from_name(doc_name):
- """从文件名中提取日期信息"""
- # 匹配 "2017年8月31日起施行" 或 "2022年4月1日"
- m = re.search(r'(\d{4})年(\d{1,2})月(\d{1,2})日', doc_name)
- if m:
- return f"{m.group(1)}年{m.group(2)}月{m.group(3)}日"
- # 匹配 "2017年"
- m = re.search(r'(\d{4})年', doc_name)
- if m:
- return f"{m.group(1)}年"
- return "未知"
- def compile_source(doc_name, text, progress):
- """Step 1: Create source summary page"""
- # Truncate long documents
- text_sample = text[:6000] if len(text) > 6000 else text
- # Extract date from filename for temporal normalization
- doc_date = _extract_date_from_name(doc_name)
- prompt = f"""请阅读以下海事领域文档,输出结构化摘要。
- 文档名: {doc_name}
- 文档日期: {doc_date}
- 文档内容:
- {text_sample}
- 请按以下格式输出(不要输出其他内容):
- ## 摘要
- [2-4句话概括文档核心内容,必须包含具体的施行日期和文号]
- ## 关键信息
- - [要点1]
- - [要点2]
- - [要点3]
- (最多8个要点,每个要点尽量包含具体条款号、数字、日期)
- ## 时间线
- [列出文档中提到的所有时间节点,格式: 绝对日期|事件]
- {doc_date}|本文件施行
- (**重要**: 文档中的所有相对时间必须转换为绝对时间:
- - "自发布之日起施行" → "{doc_date}|施行"
- - "近年来" → 以{doc_date}为基准,推算为具体年份范围
- - "去年" → 以{doc_date}为基准,推算具体年份
- - "本条例修订前" → 写明具体的修订前版本年份
- - "三年内" → 从{doc_date}起算,写明截止的具体年份
- 禁止使用任何相对时间表述,全部转为绝对年月日)
- ## 实体列表
- [列出文档中涉及的重要实体,每行一个,格式: 类型|名称|说明]
- 法规|XXX法|(含施行日期和文号)
- 机构|XXX局|...
- 概念|XXX制度|...
- ## 实体关系
- [列出文档中实体之间的关系,每行一个,格式: 主体|关系|客体|说明]
- 交通运输部|制定|船员管理规定|交通运输部负责制定船员管理相关规定
- 海事局|隶属|交通运输部|海事局是交通运输部下属机构
- 船员适任证书|依据|STCW公约|船员适任证书制度依据STCW国际公约
- (列出文档中所有重要的实体间关系,包括:上下级/隶属、制定/发布、依据/引用、适用/管辖、修订/废止、时间先后等)
- ## 相关主题
- [列出文档涉及的主题领域,每行一个]
- 船员管理
- 船舶安全
- ...
- ## 元数据
- - 发布机构: [如有]
- - 施行日期: {doc_date}
- - 文号: [如有]
- - 法律层级: [全国人大/国务院/部委/海事局/其他]
- - 是否现行有效: [是/否/已被修订]
- - 修订历史: [如有,列出历次修订的绝对日期]"""
- result = llm_chat(prompt)
- # Write source page
- source_path = WIKI_DIR / 'sources' / f'{safe_filename(doc_name)}.md'
- source_path.parent.mkdir(parents=True, exist_ok=True)
- with open(source_path, 'w', encoding='utf-8') as f:
- f.write(f'# {doc_name}\n\n')
- f.write(f'[来源: {doc_name}]\n\n')
- f.write(result)
- f.write('\n')
- return result
- def extract_entities(llm_result, doc_name, progress):
- """Step 2: Extract and update entity pages"""
- entities_found = []
- lines = llm_result.split('\n')
- in_entity_section = False
- for line in lines:
- if '## 实体列表' in line:
- in_entity_section = True
- continue
- if line.startswith('## ') and in_entity_section:
- in_entity_section = False
- continue
- if in_entity_section and '|' in line:
- parts = [p.strip() for p in line.strip('- ').split('|')]
- if len(parts) >= 2:
- etype = parts[0]
- ename = parts[1]
- edesc = parts[2] if len(parts) > 2 else ''
- if ename and len(ename) > 1:
- entities_found.append((etype, ename, edesc))
- # Update entity pages
- for etype, ename, edesc in entities_found[:10]: # Max 10 per doc
- entity_file = safe_filename(ename)
- entity_path = WIKI_DIR / 'entities' / f'{entity_file}.md'
- entity_path.parent.mkdir(parents=True, exist_ok=True)
- if entity_path.exists():
- # Append reference
- existing = entity_path.read_text(encoding='utf-8')
- if doc_name not in existing:
- with open(entity_path, 'a', encoding='utf-8') as f:
- f.write(f'\n- {edesc} [来源: {doc_name}]\n')
- else:
- # Create new entity page
- with open(entity_path, 'w', encoding='utf-8') as f:
- f.write(f'# {ename}\n\n')
- f.write(f'**类型**: {etype}\n\n')
- f.write(f'## 描述\n\n{edesc}\n\n')
- f.write(f'## 相关文档\n\n')
- f.write(f'- {doc_name}\n')
- # Track in progress
- if ename not in progress['entities']:
- progress['entities'][ename] = {'type': etype, 'refs': []}
- if doc_name not in progress['entities'][ename]['refs']:
- progress['entities'][ename]['refs'].append(doc_name)
- return entities_found
- def extract_topics(llm_result, doc_name, progress):
- """Step 3: Extract and update topic pages"""
- topics_found = []
- lines = llm_result.split('\n')
- in_topic_section = False
- for line in lines:
- if '## 相关主题' in line:
- in_topic_section = True
- continue
- if line.startswith('## ') and in_topic_section:
- in_topic_section = False
- continue
- if in_topic_section:
- topic = line.strip('- ').strip()
- if topic and len(topic) > 1 and not topic.startswith('#'):
- topics_found.append(topic)
- # Update topic pages
- for topic in topics_found[:5]: # Max 5 per doc
- topic_file = safe_filename(topic)
- topic_path = WIKI_DIR / 'topics' / f'{topic_file}.md'
- topic_path.parent.mkdir(parents=True, exist_ok=True)
- if topic_path.exists():
- existing = topic_path.read_text(encoding='utf-8')
- if doc_name not in existing:
- with open(topic_path, 'a', encoding='utf-8') as f:
- f.write(f'- [[{safe_filename(doc_name)}]] - {doc_name}\n')
- else:
- with open(topic_path, 'w', encoding='utf-8') as f:
- f.write(f'# {topic}\n\n')
- f.write(f'## 相关文档\n\n')
- f.write(f'- [[{safe_filename(doc_name)}]] - {doc_name}\n')
- if topic not in progress['topics']:
- progress['topics'][topic] = []
- if doc_name not in progress['topics'][topic]:
- progress['topics'][topic].append(doc_name)
- return topics_found
- def extract_relations(llm_result, doc_name, progress):
- """Step 4: Extract entity relations and build knowledge graph"""
- relations_found = []
- lines = llm_result.split('\n')
- in_relation_section = False
- for line in lines:
- if '## 实体关系' in line:
- in_relation_section = True
- continue
- if line.startswith('## ') and in_relation_section:
- in_relation_section = False
- continue
- if in_relation_section and '|' in line:
- parts = [p.strip() for p in line.strip('- (').split('|')]
- if len(parts) >= 3:
- subject = parts[0]
- relation = parts[1]
- obj = parts[2]
- desc = parts[3] if len(parts) > 3 else ''
- if subject and relation and obj and len(subject) > 1:
- relations_found.append({
- 'subject': subject,
- 'relation': relation,
- 'object': obj,
- 'description': desc,
- 'source': doc_name,
- })
- # Store relations in progress
- if 'relations' not in progress:
- progress['relations'] = []
- for rel in relations_found[:15]: # Max 15 per doc
- # Deduplicate
- existing = any(
- r['subject'] == rel['subject'] and
- r['relation'] == rel['relation'] and
- r['object'] == rel['object']
- for r in progress['relations']
- )
- if not existing:
- progress['relations'].append(rel)
- # Update entity pages with relations
- for rel in relations_found[:15]:
- for ename in [rel['subject'], rel['object']]:
- entity_file = safe_filename(ename)
- entity_path = WIKI_DIR / 'entities' / f'{entity_file}.md'
- if entity_path.exists():
- existing = entity_path.read_text(encoding='utf-8')
- if '## 关系' not in existing:
- with open(entity_path, 'a', encoding='utf-8') as f:
- f.write(f'\n## 关系\n\n')
- rel_line = f'- {rel["subject"]} **{rel["relation"]}** {rel["object"]}'
- if rel['description']:
- rel_line += f' — {rel["description"]}'
- rel_line += f' [来源: {doc_name}]\n'
- existing = entity_path.read_text(encoding='utf-8')
- if rel_line.strip() not in existing:
- with open(entity_path, 'a', encoding='utf-8') as f:
- f.write(rel_line)
- # Write/update relations graph file
- relations_path = WIKI_DIR / 'relations.json'
- with open(relations_path, 'w', encoding='utf-8') as f:
- json.dump(progress['relations'], f, ensure_ascii=False, indent=2)
- return relations_found
- def update_index(progress):
- """Update wiki index.md"""
- sources = sorted(WIKI_DIR.glob('sources/*.md'))
- entities = sorted(WIKI_DIR.glob('entities/*.md'))
- topics = sorted(WIKI_DIR.glob('topics/*.md'))
- analyses = sorted(WIKI_DIR.glob('analyses/*.md'))
- index = f"""# Maritime Wiki Index
- > Auto-compiled from domain documents by wikiagent
- > Last updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
- ## Sources ({len(sources)} files)
- """
- for s in sources[:50]: # Show first 50
- index += f'- [[{s.stem}]]\n'
- if len(sources) > 50:
- index += f'\n... and {len(sources) - 50} more\n'
- index += f'\n## Entities ({len(entities)})\n\n'
- # Group by type
- entity_types = {}
- for ename, einfo in progress.get('entities', {}).items():
- etype = einfo.get('type', 'other')
- if etype not in entity_types:
- entity_types[etype] = []
- entity_types[etype].append((ename, len(einfo.get('refs', []))))
- for etype, elist in sorted(entity_types.items()):
- index += f'### {etype} ({len(elist)})\n'
- for ename, ref_count in sorted(elist, key=lambda x: -x[1])[:20]:
- index += f'- [[{safe_filename(ename)}]] ({ref_count} refs)\n'
- if len(elist) > 20:
- index += f'- ... and {len(elist) - 20} more\n'
- index += '\n'
- index += f'## Topics ({len(topics)})\n\n'
- for tname, trefs in sorted(progress.get('topics', {}).items(), key=lambda x: -len(x[1]))[:30]:
- index += f'- [[{safe_filename(tname)}]] ({len(trefs)} docs)\n'
- if len(topics) > 30:
- index += f'\n... and {len(topics) - 30} more\n'
- index += f'\n## Analyses ({len(analyses)})\n\n'
- for a in analyses[:20]:
- index += f'- [[{a.stem}]]\n'
- # Relations summary
- relations = progress.get('relations', [])
- if relations:
- index += f'\n## Relations ({len(relations)})\n\n'
- # Group by relation type
- rel_types = {}
- for r in relations:
- rt = r['relation']
- if rt not in rel_types:
- rel_types[rt] = []
- rel_types[rt].append(r)
- for rt, rels in sorted(rel_types.items(), key=lambda x: -len(x[1]))[:15]:
- index += f'### {rt} ({len(rels)})\n'
- for r in rels[:5]:
- index += f'- {r["subject"]} → {r["object"]}\n'
- if len(rels) > 5:
- index += f'- ... and {len(rels) - 5} more\n'
- index += '\n'
- index += f'\n---\nStats: {len(sources)} sources, {len(entities)} entities, {len(topics)} topics, {len(relations)} relations, {len(analyses)} analyses\n'
- with open(WIKI_DIR / 'index.md', 'w', encoding='utf-8') as f:
- f.write(index)
- def update_log(doc_name, entities_count, topics_count, relations_count=0):
- """Append to log.md"""
- log_path = WIKI_DIR / 'log.md'
- timestamp = datetime.now().strftime('%Y-%m-%d %H:%M')
- entry = f'## [{timestamp}] Ingest | {doc_name}\n- Entities: {entities_count}, Topics: {topics_count}, Relations: {relations_count}\n\n'
- if log_path.exists():
- existing = log_path.read_text(encoding='utf-8')
- # Insert after header
- if '_no records_' in existing or '_no operations_' in existing:
- existing = '# Operation Log\n\n'
- with open(log_path, 'w', encoding='utf-8') as f:
- f.write(existing + entry)
- else:
- with open(log_path, 'w', encoding='utf-8') as f:
- f.write('# Operation Log\n\n' + entry)
- def main():
- detect_api()
- # Ensure wiki dirs exist
- for d in ['sources', 'entities', 'topics', 'analyses']:
- (WIKI_DIR / d).mkdir(parents=True, exist_ok=True)
- # WIKI_REBUILD=1 → clear progress file and all generated pages for a clean rebuild
- if os.environ.get('WIKI_REBUILD') == '1':
- if PROGRESS_FILE.exists():
- PROGRESS_FILE.unlink()
- print("[Rebuild] 已清除进度文件")
- for d in ['sources', 'entities', 'topics']:
- for f in (WIKI_DIR / d).glob('*.md'):
- f.unlink()
- print("[Rebuild] 已清除旧 wiki 页面,开始全量重建")
- # Load progress
- progress = load_progress()
- # Separate truly-done from errored: only skip docs with generated source pages
- compiled_pages = {f.stem for f in (WIKI_DIR / 'sources').glob('*.md')}
- done_set = compiled_pages # use actual output files as ground truth, not the progress file
- progress['done'] = [d for d in progress['done'] if d in compiled_pages]
- # Get all text files
- all_docs = sorted(PROCESSED_DIR.glob('*.txt'))
- remaining = [d for d in all_docs if d.stem not in done_set]
- total = len(all_docs)
- print(f"Total documents: {total}")
- print(f"Already compiled: {len(done_set)}")
- print(f"Remaining: {len(remaining)}")
- print("=" * 60)
- start_time = time.time()
- errors = 0
- for idx, doc_path in enumerate(remaining):
- doc_name = doc_path.stem
- qnum = len(done_set) + idx + 1
- text = doc_path.read_text(encoding='utf-8')
- if len(text) < 50:
- print(f"[{qnum}/{total}] SKIP (too short): {doc_name[:60]}")
- progress['done'].append(doc_name)
- continue
- print(f"\n[{qnum}/{total}] {doc_name[:70]}...")
- try:
- # Step 1: Compile source summary
- t0 = time.time()
- llm_result = compile_source(doc_name, text, progress)
- t1 = time.time()
- print(f" Source page: {t1-t0:.1f}s")
- # Step 2: Extract entities
- entities = extract_entities(llm_result, doc_name, progress)
- print(f" Entities: {len(entities)} found")
- # Step 3: Extract topics
- topics = extract_topics(llm_result, doc_name, progress)
- print(f" Topics: {len(topics)} found")
- # Step 4: Extract relations
- relations = extract_relations(llm_result, doc_name, progress)
- print(f" Relations: {len(relations)} found")
- # Step 5: Update log
- update_log(doc_name, len(entities), len(topics), len(relations))
- progress['done'].append(doc_name)
- except Exception as e:
- errors += 1
- print(f" ERROR: {e}")
- # Do NOT add to done — failed docs will be retried on next incremental run
- # (only successfully compiled docs with a source page count as done)
- # Save progress and update index every 20 docs
- if (idx + 1) % 20 == 0 or idx == len(remaining) - 1:
- save_progress(progress)
- update_index(progress)
- elapsed = time.time() - start_time
- avg = elapsed / (idx + 1)
- eta = avg * (len(remaining) - idx - 1)
- print(f"\n [Progress] {qnum}/{total} done | "
- f"Elapsed: {elapsed/60:.1f}min | ETA: {eta/60:.1f}min | "
- f"Errors: {errors} | "
- f"Entities: {len(progress['entities'])} | "
- f"Topics: {len(progress['topics'])} | "
- f"Relations: {len(progress.get('relations', []))}")
- # Final update
- save_progress(progress)
- update_index(progress)
- total_time = time.time() - start_time
- sources = len(list(WIKI_DIR.glob('sources/*.md')))
- entities = len(list(WIKI_DIR.glob('entities/*.md')))
- topics = len(list(WIKI_DIR.glob('topics/*.md')))
- print(f"\n{'=' * 60}")
- print(f"Wiki Compilation Complete!")
- print(f" Time: {total_time/60:.1f} minutes")
- print(f" Sources: {sources}")
- print(f" Entities: {entities}")
- print(f" Topics: {topics}")
- print(f" Errors: {errors}")
- print(f" Wiki dir: {WIKI_DIR}")
- if __name__ == '__main__':
- main()
|