app.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884
  1. #!/usr/bin/env python3
  2. """
  3. Maritime QA Comparison Demo
  4. RAG vs Wiki side-by-side comparison
  5. With server-side conversation history persistence
  6. """
  7. import json
  8. import os
  9. import sys
  10. import time
  11. import threading
  12. from datetime import datetime
  13. from flask import Flask, request, jsonify, render_template_string
  14. import yaml
  15. # Load paths from agent config
  16. _agent_cfg_path = os.environ.get('AGENT_CONFIG', '/app/agentexample/qaagent67/agent-config.yml')
  17. _wiki_cfg_path = os.environ.get('WIKI_CONFIG', '/app/agentexample/qaagent67wiki/agent-config.yml')
  18. _cfg = {}
  19. _wiki_cfg = {}
  20. if os.path.exists(_agent_cfg_path):
  21. with open(_agent_cfg_path) as f:
  22. _cfg = yaml.safe_load(f)
  23. if os.path.exists(_wiki_cfg_path):
  24. with open(_wiki_cfg_path) as f:
  25. _wiki_cfg = yaml.safe_load(f)
  26. _knowledge = _cfg.get('knowledge', {})
  27. KNOWLEDGE_BASE = _knowledge.get('baseDir', os.environ.get('KNOWLEDGE_DIR', '/data/knowledge/maritime'))
  28. sys.path.insert(0, KNOWLEDGE_BASE)
  29. try:
  30. from search_unified import search
  31. except ImportError:
  32. from search_engine import search
  33. from lambdagent.providers import create_provider
  34. app = Flask(__name__)
  35. HISTORY_FILE = os.environ.get('HISTORY_FILE', '/app/qademo/conversation_history.json')
  36. WIKI_DIR = _wiki_cfg.get('wiki', {}).get('dir', '/app/agentexample/qaagent67wiki/wiki')
  37. # LLM provider (lazy init)
  38. _provider = None
  39. def get_provider():
  40. global _provider
  41. if _provider is None:
  42. _provider = create_provider('ollama', model='qwen2.5-32b', base_url='http://127.0.0.1:8000/v1', timeout=120)
  43. return _provider
  44. # ── Conversation history persistence ──
  45. _history_lock = threading.Lock()
  46. def load_history():
  47. if os.path.exists(HISTORY_FILE):
  48. with open(HISTORY_FILE, 'r', encoding='utf-8') as f:
  49. return json.load(f)
  50. return []
  51. def save_record(record):
  52. with _history_lock:
  53. history = load_history()
  54. history.append(record)
  55. with open(HISTORY_FILE, 'w', encoding='utf-8') as f:
  56. json.dump(history, f, ensure_ascii=False, indent=2)
  57. # ── RAG answer ──
  58. def rag_answer(question, top_k=5):
  59. t0 = time.time()
  60. provider = get_provider()
  61. results = search(question, top_k=top_k)
  62. # Build enriched context from fusion results
  63. enriched = results[0].get('_enriched_context', '') if results else ''
  64. if not enriched:
  65. parts = []
  66. for i, r in enumerate(results):
  67. parts.append('[doc' + str(i+1) + ': ' + r['source'] + ']\n' + r['text'])
  68. enriched = '\n\n'.join(parts)
  69. enriched = enriched[:3500] # fit within 8192 context window
  70. prompt = f"""你是一个海事领域问答助手。请严格基于以下参考资料回答问题。
  71. 资料来自多个来源(检索文档、知识图谱、Wiki知识),请综合利用。
  72. 如果资料中没有相关信息,请说明"资料中未找到相关信息"。
  73. 回答要具体,引用原文,标注来源 [来源: 文件名]。
  74. {enriched}
  75. ## 问题
  76. {question}
  77. ## 回答"""
  78. answer = provider.chat([{'role': 'user', 'content': prompt}])
  79. elapsed = time.time() - t0
  80. # Extract unified search metadata
  81. query_type = results[0].get('_query_type', 'unknown') if results else 'unknown'
  82. strategy = results[0].get('_strategy', 'unknown') if results else 'unknown'
  83. search_time = results[0].get('_total_time', 0) if results else 0
  84. return {
  85. 'answer': answer,
  86. 'sources': [r['source'] for r in results],
  87. 'elapsed': round(elapsed, 1),
  88. 'chunks_used': len(results),
  89. 'mode': 'LambdaRAG',
  90. 'query_type': query_type,
  91. 'strategy': strategy,
  92. 'search_time': search_time,
  93. }
  94. # ── Wiki answer ──
  95. def wiki_answer(question, top_k=5):
  96. t0 = time.time()
  97. provider = get_provider()
  98. from pathlib import Path
  99. wiki_path = Path(WIKI_DIR)
  100. wiki_pages = []
  101. for subdir in ['sources', 'entities', 'topics', 'analyses']:
  102. d = wiki_path / subdir
  103. if d.exists():
  104. for f in d.glob('*.md'):
  105. try:
  106. content = f.read_text(encoding='utf-8')
  107. q_chars = set(question)
  108. match_score = sum(1 for c in q_chars if c in content)
  109. if match_score > len(question) * 0.3:
  110. wiki_pages.append({
  111. 'name': f.stem,
  112. 'content': content[:1500],
  113. 'score': match_score,
  114. })
  115. except:
  116. pass
  117. wiki_pages.sort(key=lambda x: x['score'], reverse=True)
  118. wiki_pages = wiki_pages[:3]
  119. if wiki_pages:
  120. wiki_context = '\n\n'.join([
  121. f'[Wiki: {p["name"]}]\n{p["content"]}' for p in wiki_pages
  122. ])
  123. source_type = 'wiki'
  124. else:
  125. results = search(question, top_k=top_k)
  126. compile_context = '\n\n'.join([
  127. f'[doc: {r["source"]}]\n{r["text"]}' for r in results
  128. ])
  129. compile_prompt = f"""请阅读以下文档片段,提炼出与问题相关的核心知识点。
  130. 用结构化的方式组织,标注来源。
  131. 文档:
  132. {compile_context}
  133. 问题: {question}
  134. 请输出结构化的知识摘要:"""
  135. compiled = provider.chat([{'role': 'user', 'content': compile_prompt}])
  136. wiki_context = compiled
  137. source_type = 'compiled'
  138. safe_name = question[:30].replace('/', '_').replace(' ', '_')
  139. analysis_path = wiki_path / 'analyses' / f'{safe_name}.md'
  140. analysis_path.parent.mkdir(parents=True, exist_ok=True)
  141. with open(analysis_path, 'w', encoding='utf-8') as f:
  142. f.write(f'# {question}\n\n{compiled}\n')
  143. answer_prompt = f"""你是一个海事领域 wiki 知识库问答助手。
  144. 基于以下已编译的 wiki 知识回答问题。回答要具体,标注来源。
  145. ## Wiki 知识
  146. {wiki_context}
  147. ## 问题
  148. {question}
  149. ## 回答"""
  150. answer = provider.chat([{'role': 'user', 'content': answer_prompt}])
  151. elapsed = time.time() - t0
  152. return {
  153. 'answer': answer,
  154. 'source_type': source_type,
  155. 'wiki_pages_used': len(wiki_pages),
  156. 'elapsed': round(elapsed, 1),
  157. 'mode': 'Wiki',
  158. }
  159. # ── Async results store ──
  160. # -- BM25 Baseline answer (for comparison) --
  161. def bm25_baseline_answer(question, top_k=5):
  162. t0 = time.time()
  163. provider = get_provider()
  164. try:
  165. from search_engine import search as bm25_only
  166. results = bm25_only(question, top_k=top_k)
  167. except Exception:
  168. results = []
  169. context_parts = []
  170. for i, r in enumerate(results):
  171. context_parts.append('[doc' + str(i+1) + ': ' + r['source'] + ']' + chr(10) + r['text'])
  172. context = chr(10)*2 .join(context_parts)
  173. prompt = ('You are a maritime QA assistant. Answer based on docs only.' + chr(10)
  174. + '## Docs' + chr(10) + context + chr(10)
  175. + '## Question' + chr(10) + question + chr(10) + '## Answer')
  176. answer = provider.chat([{'role': 'user', 'content': prompt}])
  177. elapsed = time.time() - t0
  178. return {
  179. 'answer': answer,
  180. 'sources': [r['source'] for r in results],
  181. 'elapsed': round(elapsed, 1),
  182. 'chunks_used': len(results),
  183. 'mode': 'BM25 Baseline',
  184. }
  185. _results = {}
  186. # ── API routes ──
  187. @app.route('/api/ask', methods=['POST'])
  188. def api_ask():
  189. data = request.json
  190. question = data.get('question', '').strip()
  191. if not question:
  192. return jsonify({'error': 'question is required'}), 400
  193. req_id = str(int(time.time() * 1000))
  194. _results[req_id] = {'rag': None, 'wiki': None, 'question': question}
  195. def run_rag():
  196. try:
  197. _results[req_id]['rag'] = rag_answer(question)
  198. except Exception as e:
  199. _results[req_id]['rag'] = {'answer': f'Error: {e}', 'elapsed': 0, 'mode': 'RAG'}
  200. def run_wiki():
  201. try:
  202. _results[req_id]['wiki'] = wiki_answer(question)
  203. except Exception as e:
  204. _results[req_id]['wiki'] = {'answer': f'Error: {e}', 'elapsed': 0, 'mode': 'Wiki'}
  205. def run_baseline():
  206. try:
  207. _results[req_id]['baseline'] = bm25_baseline_answer(question)
  208. except Exception as e:
  209. _results[req_id]['baseline'] = {'answer': 'Error: ' + str(e), 'elapsed': 0, 'mode': 'BM25 Baseline'}
  210. t1 = threading.Thread(target=run_rag)
  211. t2 = threading.Thread(target=run_wiki)
  212. t3 = threading.Thread(target=run_baseline)
  213. t1.start()
  214. t2.start()
  215. t3.start()
  216. return jsonify({'req_id': req_id, 'status': 'processing'})
  217. @app.route('/api/result/<req_id>')
  218. def api_result(req_id):
  219. if req_id not in _results:
  220. return jsonify({'error': 'not found'}), 404
  221. r = _results[req_id]
  222. done = r['rag'] is not None and r['wiki'] is not None
  223. # Save to history when both are done
  224. if done and not r.get('_saved'):
  225. r['_saved'] = True
  226. record = {
  227. 'id': req_id,
  228. 'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
  229. 'question': r['question'],
  230. 'rag': r['rag'],
  231. 'wiki': r['wiki'],
  232. }
  233. threading.Thread(target=save_record, args=(record,)).start()
  234. return jsonify({
  235. 'status': 'done' if done else 'processing',
  236. 'rag': r['rag'],
  237. 'wiki': r['wiki'],
  238. 'baseline': r.get('baseline'),
  239. })
  240. @app.route('/api/history')
  241. def api_history():
  242. history = load_history()
  243. history.reverse() # newest first
  244. return jsonify(history[:50]) # last 50
  245. @app.route('/api/history/export')
  246. def api_history_export():
  247. history = load_history()
  248. return jsonify({
  249. 'exported_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
  250. 'total_records': len(history),
  251. 'records': history,
  252. })
  253. @app.route('/api/wiki/index')
  254. def api_wiki_index():
  255. """List all wiki pages grouped by category"""
  256. from pathlib import Path
  257. wiki_path = Path(WIKI_DIR)
  258. result = {}
  259. for subdir in ['sources', 'entities', 'topics', 'analyses']:
  260. d = wiki_path / subdir
  261. pages = []
  262. if d.exists():
  263. for f in sorted(d.glob('*.md')):
  264. stat = f.stat()
  265. pages.append({
  266. 'name': f.stem,
  267. 'path': f'{subdir}/{f.name}',
  268. 'size': stat.st_size,
  269. 'modified': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M'),
  270. })
  271. result[subdir] = pages
  272. # Also read index.md
  273. idx_path = wiki_path / 'index.md'
  274. result['index_content'] = idx_path.read_text(encoding='utf-8') if idx_path.exists() else ''
  275. return jsonify(result)
  276. @app.route('/api/wiki/page/<path:page_path>')
  277. def api_wiki_page(page_path):
  278. """Read a single wiki page"""
  279. from pathlib import Path
  280. wiki_path = Path(WIKI_DIR)
  281. full_path = wiki_path / page_path
  282. if not full_path.exists() or not str(full_path).startswith(str(wiki_path)):
  283. return jsonify({'error': 'not found'}), 404
  284. content = full_path.read_text(encoding='utf-8')
  285. return jsonify({'path': page_path, 'content': content, 'size': len(content)})
  286. @app.route('/api/rag/search')
  287. def api_rag_search():
  288. """Search RAG chunks"""
  289. query = request.args.get('q', '').strip()
  290. top_k = int(request.args.get('k', 10))
  291. if not query:
  292. return jsonify({'error': 'q parameter required'}), 400
  293. results = search(query, top_k=top_k)
  294. return jsonify({'query': query, 'total_results': len(results), 'results': results})
  295. @app.route('/api/rag/stats')
  296. def api_rag_stats():
  297. """RAG index statistics"""
  298. import json as json_mod
  299. from pathlib import Path
  300. idx_path = Path(_knowledge.get('indexFile', KNOWLEDGE_BASE + '/rag_index.json'))
  301. if not idx_path.exists():
  302. return jsonify({'error': 'index not built'}), 404
  303. with open(idx_path) as f:
  304. data = json_mod.load(f)
  305. # Sample chunks for preview
  306. sample_chunks = data['chunks'][:20]
  307. for c in sample_chunks:
  308. pass # show full text
  309. return jsonify({
  310. 'total_chunks': data['total_chunks'],
  311. 'total_files': data['total_files'],
  312. 'chunk_size': data['chunk_size'],
  313. 'keyword_index_size': data.get('keyword_index_size', 0),
  314. 'sample_chunks': sample_chunks,
  315. })
  316. @app.route('/wiki')
  317. def wiki_page():
  318. return render_template_string(WIKI_HTML)
  319. @app.route('/rag')
  320. def rag_page():
  321. return render_template_string(RAG_HTML)
  322. @app.route('/')
  323. def index():
  324. return render_template_string(HTML_TEMPLATE)
  325. HTML_TEMPLATE = r"""
  326. <!DOCTYPE html>
  327. <html lang="zh-CN">
  328. <head>
  329. <meta charset="UTF-8">
  330. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  331. <title>Maritime QA Demo</title>
  332. <style>
  333. * { margin: 0; padding: 0; box-sizing: border-box; }
  334. body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; background: #f0f2f5; color: #333; }
  335. .header { background: linear-gradient(135deg, #1a3a5c 0%, #2d6aa0 100%); color: white; padding: 20px 30px; }
  336. .header-inner { max-width: 1400px; margin: 0 auto; display: flex; justify-content: space-between; align-items: center; }
  337. .header h1 { font-size: 22px; }
  338. .header p { opacity: 0.8; font-size: 13px; margin-top: 4px; }
  339. .header-stats { text-align: right; font-size: 13px; opacity: 0.8; }
  340. .container { max-width: 1400px; margin: 0 auto; padding: 20px; }
  341. .input-area { background: white; border-radius: 12px; padding: 20px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
  342. .input-row { display: flex; gap: 12px; }
  343. .input-row input { flex: 1; padding: 12px 16px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 16px; outline: none; transition: border-color 0.2s; }
  344. .input-row input:focus { border-color: #2d6aa0; }
  345. .btn { padding: 12px 28px; background: #2d6aa0; color: white; border: none; border-radius: 8px; font-size: 16px; cursor: pointer; transition: background 0.2s; white-space: nowrap; }
  346. .btn:hover { background: #1a5a8e; }
  347. .btn:disabled { background: #aaa; cursor: not-allowed; }
  348. .btn-sm { padding: 6px 14px; font-size: 13px; border-radius: 6px; }
  349. .btn-outline { background: transparent; border: 1px solid #2d6aa0; color: #2d6aa0; }
  350. .btn-outline:hover { background: #e0eef8; }
  351. .quick-questions { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 8px; }
  352. .quick-questions span { padding: 6px 12px; background: #f5f5f5; border-radius: 16px; font-size: 13px; cursor: pointer; transition: background 0.2s; border: 1px solid transparent; }
  353. .quick-questions span:hover { background: #e0eef8; border-color: #c0d8f0; }
  354. .compare-area { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
  355. .panel { background: white; border-radius: 12px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); min-height: 200px; }
  356. .panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 2px solid #f0f0f0; }
  357. .panel-title { font-size: 18px; font-weight: 600; }
  358. .panel-title.rag { color: #2d6aa0; }
  359. .panel-title.wiki { color: #7b2d8e; }
  360. .panel-badge { padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 500; }
  361. .badge-rag { background: #e0eef8; color: #2d6aa0; }
  362. .badge-wiki { background: #f0e0f8; color: #7b2d8e; }
  363. .panel-content { font-size: 15px; line-height: 1.8; white-space: pre-wrap; word-wrap: break-word; max-height: 600px; overflow-y: auto; }
  364. .panel-meta { margin-top: 12px; padding-top: 12px; border-top: 1px solid #f0f0f0; font-size: 13px; color: #888; }
  365. .loading { text-align: center; padding: 40px; color: #888; }
  366. .spinner { display: inline-block; width: 20px; height: 20px; border: 3px solid #e0e0e0; border-top-color: #2d6aa0; border-radius: 50%; animation: spin 0.8s linear infinite; margin-right: 8px; vertical-align: middle; }
  367. @keyframes spin { to { transform: rotate(360deg); } }
  368. .tabs { display: flex; gap: 0; margin-top: 20px; margin-bottom: 0; }
  369. .tab { padding: 10px 20px; background: #e8e8e8; cursor: pointer; font-size: 14px; border-radius: 8px 8px 0 0; }
  370. .tab.active { background: white; font-weight: 600; }
  371. .tab-content { background: white; border-radius: 0 12px 12px 12px; padding: 16px 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); display: none; max-height: 400px; overflow-y: auto; }
  372. .tab-content.active { display: block; }
  373. .hist-item { padding: 12px 0; border-bottom: 1px solid #f5f5f5; cursor: pointer; }
  374. .hist-item:hover { background: #fafafa; }
  375. .hist-item:last-child { border-bottom: none; }
  376. .hist-q { font-weight: 600; font-size: 14px; margin-bottom: 4px; }
  377. .hist-meta { font-size: 12px; color: #999; }
  378. .empty-hint { text-align: center; color: #ccc; padding: 30px; }
  379. @media (max-width: 768px) { .compare-area { grid-template-columns: 1fr; } }
  380. </style>
  381. </head>
  382. <body>
  383. <div class="header">
  384. <div class="header-inner">
  385. <div>
  386. <h1>Maritime QA Demo</h1>
  387. <p>RAG vs Wiki | 1326 docs | Qwen2.5:32B local</p>
  388. </div>
  389. <div class="header-stats">
  390. <div style="margin-bottom:6px;">
  391. <a href="/" style="color:white;text-decoration:none;margin-right:16px;font-weight:600;">QA Demo</a>
  392. <a href="/wiki" style="color:white;text-decoration:none;margin-right:16px;opacity:0.85;">Wiki Browser</a>
  393. <a href="/rag" style="color:white;text-decoration:none;opacity:0.85;">RAG Chunks</a>
  394. </div>
  395. <div id="statsLine">-</div>
  396. </div>
  397. </div>
  398. </div>
  399. <div class="container">
  400. <div class="input-area">
  401. <div class="input-row">
  402. <input type="text" id="question" placeholder="Please enter a maritime question..." autofocus>
  403. <button class="btn" id="askBtn" onclick="askQuestion()">Ask</button>
  404. </div>
  405. <div class="quick-questions">
  406. <span onclick="askDirect('船舶进出港报告制度的主要内容是什么?')">Ship entry/exit reporting system</span>
  407. <span onclick="askDirect('船员适任证书的申请条件有哪些?')">Crew competency certificates</span>
  408. <span onclick="askDirect('琼州海峡船舶定线制是什么时候开始施行的?')">Qiongzhou Strait routing system</span>
  409. <span onclick="askDirect('海上交通安全法与水污染防治法在船舶管理方面的规定有何异同?')">Maritime law comparison</span>
  410. <span onclick="askDirect('中国船员发展报告从2019到2023年有什么变化趋势?')">Crew development trends</span>
  411. </div>
  412. </div>
  413. <div class="compare-area">
  414. <div class="panel" id="ragPanel">
  415. <div class="panel-header">
  416. <span class="panel-title rag">LambdaRAG (Unified)</span>
  417. <span class="panel-badge badge-rag" id="ragBadge">Classify + Route + Retrieve</span>
  418. </div>
  419. <div class="panel-content" id="ragContent" style="white-space:pre-wrap;">
  420. <div class="empty-hint">Ask to see RAG response</div>
  421. </div>
  422. <div class="panel-meta" id="ragMeta"></div>
  423. </div>
  424. <div class="panel" id="wikiPanel">
  425. <div class="panel-header">
  426. <span class="panel-title wiki">Wiki Mode</span>
  427. <span class="panel-badge badge-wiki">Compile + Query</span>
  428. </div>
  429. <div class="panel-content" id="wikiContent" style="white-space:pre-wrap;">
  430. <div class="empty-hint">Ask to see Wiki response</div>
  431. </div>
  432. <div class="panel-meta" id="wikiMeta"></div>
  433. </div>
  434. </div>
  435. <div class="tabs">
  436. <div class="tab active" onclick="switchTab('history')">History</div>
  437. <div class="tab" onclick="switchTab('export')">Export</div>
  438. </div>
  439. <div class="tab-content active" id="tab-history">
  440. <div class="empty-hint" id="historyEmpty">No history yet</div>
  441. <div id="historyList"></div>
  442. </div>
  443. <div class="tab-content" id="tab-export">
  444. <p style="margin-bottom:12px;color:#666;">All records are saved on the server. Click to export.</p>
  445. <button class="btn btn-sm btn-outline" onclick="exportHistory()">Export JSON</button>
  446. <pre id="exportPreview" style="margin-top:12px;font-size:12px;max-height:300px;overflow:auto;background:#f8f8f8;padding:12px;border-radius:8px;display:none;"></pre>
  447. </div>
  448. </div>
  449. <script>
  450. const qInput = document.getElementById('question');
  451. const askBtn = document.getElementById('askBtn');
  452. qInput.addEventListener('keydown', e => { if (e.key === 'Enter') askQuestion(); });
  453. function askDirect(q) { qInput.value = q; askQuestion(); }
  454. function askQuestion() {
  455. const q = qInput.value.trim();
  456. if (!q) return;
  457. askBtn.disabled = true;
  458. askBtn.textContent = 'Processing...';
  459. document.getElementById('ragContent').innerHTML = '<div class="loading"><span class="spinner"></span>RAG retrieving...</div>';
  460. document.getElementById('wikiContent').innerHTML = '<div class="loading"><span class="spinner"></span>Wiki compiling...</div>';
  461. document.getElementById('ragMeta').textContent = '';
  462. document.getElementById('wikiMeta').textContent = '';
  463. fetch('/api/ask', {
  464. method: 'POST',
  465. headers: {'Content-Type': 'application/json'},
  466. body: JSON.stringify({question: q})
  467. }).then(r => r.json()).then(data => pollResult(data.req_id, q));
  468. }
  469. function pollResult(reqId, question) {
  470. const poll = setInterval(() => {
  471. fetch('/api/result/' + reqId).then(r => r.json()).then(data => {
  472. if (data.rag) {
  473. document.getElementById("ragContent").textContent = data.rag.answer;
  474. const srcs = data.rag.sources ? data.rag.sources.slice(0,3).join(', ') : '';
  475. var qtype = data.rag.query_type || 'unknown';
  476. var strat = data.rag.strategy || 'unknown';
  477. var stime = data.rag.search_time || 0;
  478. document.getElementById('ragBadge').textContent = qtype + ' → ' + strat;
  479. document.getElementById('ragMeta').innerHTML =
  480. '<b>Type:</b> ' + qtype + ' | <b>Strategy:</b> ' + strat +
  481. '<br><b>Time:</b> ' + data.rag.elapsed + 's (search: ' + stime + 's) | <b>Chunks:</b> ' + (data.rag.chunks_used||0) +
  482. '<br><b>Sources:</b> ' + srcs;
  483. }
  484. if (data.wiki) {
  485. document.getElementById("wikiContent").textContent = data.wiki.answer;
  486. document.getElementById('wikiMeta').innerHTML =
  487. '<b>Time:</b> ' + data.wiki.elapsed + 's | <b>Source type:</b> ' + (data.wiki.source_type||'wiki') +
  488. ' | <b>Wiki pages:</b> ' + (data.wiki.wiki_pages_used||0);
  489. }
  490. if (data.status === 'done') {
  491. clearInterval(poll);
  492. askBtn.disabled = false;
  493. askBtn.textContent = 'Ask';
  494. loadHistory();
  495. }
  496. });
  497. }, 2000);
  498. }
  499. function loadHistory() {
  500. fetch('/api/history').then(r => r.json()).then(data => {
  501. const el = document.getElementById('historyList');
  502. const empty = document.getElementById('historyEmpty');
  503. if (!data.length) { empty.style.display = 'block'; el.innerHTML = ''; return; }
  504. empty.style.display = 'none';
  505. document.getElementById('statsLine').textContent = 'Total records: ' + data.length;
  506. el.innerHTML = data.map((h, i) =>
  507. '<div class="hist-item" onclick="showRecord(' + i + ')">' +
  508. '<div class="hist-q">' + escHtml(h.question) + '</div>' +
  509. '<div class="hist-meta">' + h.timestamp +
  510. ' | RAG: ' + (h.rag?h.rag.elapsed:'-') + 's' +
  511. ' | Wiki: ' + (h.wiki?h.wiki.elapsed:'-') + 's</div></div>'
  512. ).join('');
  513. window._histData = data;
  514. });
  515. }
  516. function showRecord(idx) {
  517. const h = window._histData[idx];
  518. if (!h) return;
  519. qInput.value = h.question;
  520. if (h.rag) {
  521. document.getElementById("ragContent").textContent = h.rag.answer;
  522. const srcs = h.rag.sources ? h.rag.sources.slice(0,3).join(', ') : '';
  523. document.getElementById('ragMeta').innerHTML = '<b>Time:</b> ' + h.rag.elapsed + 's | <b>Sources:</b> ' + srcs;
  524. }
  525. if (h.wiki) {
  526. document.getElementById("wikiContent").textContent = h.wiki.answer;
  527. document.getElementById('wikiMeta').innerHTML = '<b>Time:</b> ' + h.wiki.elapsed + 's | <b>Source:</b> ' + (h.wiki.source_type||'wiki');
  528. }
  529. window.scrollTo({top: 0, behavior: 'smooth'});
  530. }
  531. function switchTab(name) {
  532. document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
  533. document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
  534. event.target.classList.add('active');
  535. document.getElementById('tab-' + name).classList.add('active');
  536. }
  537. function exportHistory() {
  538. fetch('/api/history/export').then(r => r.json()).then(data => {
  539. const pre = document.getElementById('exportPreview');
  540. pre.style.display = 'block';
  541. pre.textContent = JSON.stringify(data, null, 2);
  542. });
  543. }
  544. function escHtml(s) {
  545. const d = document.createElement('div');
  546. d.textContent = s;
  547. return d.innerHTML;
  548. }
  549. function renderAnswer(text) {
  550. if (typeof marked === "undefined") return escHtml(text);
  551. var clean = text.replace(/([^\n])\n([^\n\-\#\*\|])/g, "$1\n\n$2");
  552. clean = clean.replace(/([^\n])\n(- )/g, "$1\n\n$2");
  553. clean = clean.replace(/([^\n])\n(#{1,3} )/g, "$1\n\n$2");
  554. try { return marked.parse(clean); } catch(e) { return "<pre>" + escHtml(text) + "</pre>"; }
  555. }
  556. // Load history on page load
  557. loadHistory();
  558. </script>
  559. </body>
  560. </html>
  561. """
  562. WIKI_HTML = r"""
  563. <!DOCTYPE html>
  564. <html lang="zh-CN">
  565. <head>
  566. <meta charset="UTF-8">
  567. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  568. <title>Wiki Browser</title>
  569. <style>
  570. * { margin:0; padding:0; box-sizing:border-box; }
  571. body { font-family: -apple-system,"PingFang SC","Microsoft YaHei",sans-serif; background:#f0f2f5; color:#333; }
  572. .header { background:linear-gradient(135deg,#5b2d8e 0%,#7b4dae 100%); color:white; padding:16px 30px; }
  573. .header-inner { max-width:1400px; margin:0 auto; display:flex; justify-content:space-between; align-items:center; }
  574. .header h1 { font-size:22px; }
  575. .header a { color:white; text-decoration:none; opacity:0.8; }
  576. .header a:hover { opacity:1; }
  577. .container { max-width:1400px; margin:0 auto; padding:20px; display:flex; gap:20px; }
  578. .sidebar { width:300px; flex-shrink:0; }
  579. .main { flex:1; min-width:0; }
  580. .card { background:white; border-radius:12px; padding:16px; margin-bottom:16px; box-shadow:0 2px 8px rgba(0,0,0,0.08); }
  581. .card h3 { font-size:15px; margin-bottom:10px; color:#5b2d8e; }
  582. .page-list { max-height:250px; overflow-y:auto; }
  583. .page-item { padding:6px 8px; cursor:pointer; font-size:13px; border-radius:6px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  584. .page-item:hover { background:#f0e0f8; }
  585. .page-item.active { background:#e0d0f0; font-weight:600; }
  586. .badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; background:#f0e0f8; color:#5b2d8e; margin-left:6px; }
  587. .content-area { font-size:15px; line-height:1.8; word-wrap:break-word; max-height:700px; overflow-y:auto; }
  588. .content-area h1 { font-size:20px; margin:16px 0 8px; color:#333; border-bottom:1px solid #eee; padding-bottom:6px; }
  589. .content-area h2 { font-size:17px; margin:14px 0 6px; color:#444; }
  590. .content-area h3 { font-size:15px; margin:10px 0 4px; color:#555; }
  591. .content-area ul, .content-area ol { padding-left:20px; margin:8px 0; }
  592. .content-area li { margin:4px 0; }
  593. .content-area p { margin:8px 0; }
  594. .content-area code { background:#f0f0f0; padding:1px 4px; border-radius:3px; font-size:13px; }
  595. .content-area blockquote { border-left:3px solid #ccc; padding-left:12px; color:#666; margin:8px 0; }
  596. .stats-row { display:flex; gap:12px; margin-bottom:16px; }
  597. .stat-box { flex:1; background:white; border-radius:10px; padding:14px; text-align:center; box-shadow:0 1px 4px rgba(0,0,0,0.06); }
  598. .stat-num { font-size:28px; font-weight:700; color:#5b2d8e; }
  599. .stat-label { font-size:12px; color:#888; margin-top:4px; }
  600. .search-box { width:100%; padding:8px 12px; border:1px solid #ddd; border-radius:6px; font-size:14px; margin-bottom:10px; }
  601. </style>
  602. </head>
  603. <body>
  604. <div class="header">
  605. <div class="header-inner">
  606. <h1>Wiki Browser</h1>
  607. <div><a href="/">QA Demo</a> | <a href="/rag">RAG Chunks</a></div>
  608. </div>
  609. </div>
  610. <div class="container">
  611. <div class="sidebar">
  612. <div class="stats-row">
  613. <div class="stat-box"><div class="stat-num" id="nSources">-</div><div class="stat-label">Sources</div></div>
  614. <div class="stat-box"><div class="stat-num" id="nEntities">-</div><div class="stat-label">Entities</div></div>
  615. <div class="stat-box"><div class="stat-num" id="nTopics">-</div><div class="stat-label">Topics</div></div>
  616. </div>
  617. <div class="card"><h3>Sources</h3><input class="search-box" placeholder="Filter..." oninput="filterList('sources',this.value)"><div class="page-list" id="list-sources"></div></div>
  618. <div class="card"><h3>Entities</h3><input class="search-box" placeholder="Filter..." oninput="filterList('entities',this.value)"><div class="page-list" id="list-entities"></div></div>
  619. <div class="card"><h3>Topics</h3><div class="page-list" id="list-topics"></div></div>
  620. <div class="card"><h3>Analyses</h3><div class="page-list" id="list-analyses"></div></div>
  621. </div>
  622. <div class="main">
  623. <div class="card">
  624. <h3 id="pageTitle">Wiki Index</h3>
  625. <div class="content-area" id="pageContent">Loading...</div>
  626. <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
  627. </div>
  628. </div>
  629. </div>
  630. <script>
  631. let wikiData = {};
  632. function loadWiki() {
  633. fetch('/api/wiki/index').then(r=>r.json()).then(data => {
  634. wikiData = data;
  635. document.getElementById('nSources').textContent = data.sources.length;
  636. document.getElementById('nEntities').textContent = data.entities.length;
  637. document.getElementById('nTopics').textContent = data.topics.length;
  638. renderList('sources', data.sources);
  639. renderList('entities', data.entities);
  640. renderList('topics', data.topics);
  641. renderList('analyses', data.analyses);
  642. const idxContent = data.index_content || 'Wiki is empty. Run wiki_compile.py to populate.';
  643. if (typeof marked !== 'undefined') {
  644. document.getElementById('pageContent').innerHTML = marked.parse(idxContent);
  645. } else {
  646. document.getElementById('pageContent').textContent = idxContent;
  647. }
  648. });
  649. }
  650. function renderList(cat, pages) {
  651. const el = document.getElementById('list-' + cat);
  652. el.innerHTML = pages.map(p =>
  653. '<div class="page-item" data-path="' + p.path + '" data-name="' + p.name + '" onclick="loadPage(\'' + p.path + '\',\'' + escAttr(p.name) + '\')">' +
  654. p.name.substring(0, 50) + '<span class="badge">' + (p.size/1024).toFixed(1) + 'K</span></div>'
  655. ).join('');
  656. }
  657. function loadPage(path, name) {
  658. document.querySelectorAll('.page-item').forEach(e => e.classList.remove('active'));
  659. document.querySelectorAll('[data-path="'+path+'"]').forEach(e => e.classList.add('active'));
  660. document.getElementById('pageTitle').textContent = name;
  661. document.getElementById('pageContent').innerHTML = '<div style="color:#aaa">Loading...</div>';
  662. fetch('/api/wiki/page/' + path).then(r=>r.json()).then(data => {
  663. if (typeof marked !== 'undefined') {
  664. document.getElementById('pageContent').innerHTML = marked.parse(data.content);
  665. } else {
  666. document.getElementById('pageContent').textContent = data.content;
  667. }
  668. });
  669. }
  670. function filterList(cat, q) {
  671. const items = document.querySelectorAll('#list-' + cat + ' .page-item');
  672. q = q.toLowerCase();
  673. items.forEach(el => { el.style.display = el.dataset.name.toLowerCase().includes(q) ? '' : 'none'; });
  674. }
  675. function escAttr(s) { return s.replace(/'/g, "\\'"); }
  676. loadWiki();
  677. </script>
  678. </body>
  679. </html>
  680. """
  681. RAG_HTML = r"""
  682. <!DOCTYPE html>
  683. <html lang="zh-CN">
  684. <head>
  685. <meta charset="UTF-8">
  686. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  687. <title>RAG Chunks Browser</title>
  688. <style>
  689. * { margin:0; padding:0; box-sizing:border-box; }
  690. body { font-family: -apple-system,"PingFang SC","Microsoft YaHei",sans-serif; background:#f0f2f5; color:#333; }
  691. .header { background:linear-gradient(135deg,#1a3a5c 0%,#2d6aa0 100%); color:white; padding:16px 30px; }
  692. .header-inner { max-width:1400px; margin:0 auto; display:flex; justify-content:space-between; align-items:center; }
  693. .header h1 { font-size:22px; }
  694. .header a { color:white; text-decoration:none; opacity:0.8; }
  695. .header a:hover { opacity:1; }
  696. .container { max-width:1400px; margin:0 auto; padding:20px; }
  697. .stats-row { display:flex; gap:12px; margin-bottom:20px; }
  698. .stat-box { flex:1; background:white; border-radius:10px; padding:16px; text-align:center; box-shadow:0 1px 4px rgba(0,0,0,0.06); }
  699. .stat-num { font-size:32px; font-weight:700; color:#2d6aa0; }
  700. .stat-label { font-size:12px; color:#888; margin-top:4px; }
  701. .search-area { background:white; border-radius:12px; padding:20px; margin-bottom:20px; box-shadow:0 2px 8px rgba(0,0,0,0.08); }
  702. .search-row { display:flex; gap:12px; }
  703. .search-row input { flex:1; padding:12px 16px; border:2px solid #e0e0e0; border-radius:8px; font-size:16px; outline:none; }
  704. .search-row input:focus { border-color:#2d6aa0; }
  705. .btn { padding:12px 24px; background:#2d6aa0; color:white; border:none; border-radius:8px; font-size:15px; cursor:pointer; }
  706. .btn:hover { background:#1a5a8e; }
  707. .chunk-card { background:white; border-radius:10px; padding:16px; margin-bottom:12px; box-shadow:0 1px 4px rgba(0,0,0,0.06); border-left:4px solid #2d6aa0; }
  708. .chunk-header { display:flex; justify-content:space-between; margin-bottom:8px; font-size:13px; color:#888; }
  709. .chunk-source { font-weight:600; color:#2d6aa0; }
  710. .chunk-score { background:#e0eef8; padding:2px 8px; border-radius:10px; font-size:12px; }
  711. .chunk-text { font-size:14px; line-height:1.7; white-space:pre-wrap; word-wrap:break-word; max-height:200px; overflow-y:auto; background:#fafafa; padding:10px; border-radius:6px; }
  712. .sample-title { font-size:16px; font-weight:600; margin:20px 0 12px; color:#555; }
  713. </style>
  714. </head>
  715. <body>
  716. <div class="header">
  717. <div class="header-inner">
  718. <h1>RAG Chunks Browser</h1>
  719. <div><a href="/">QA Demo</a> | <a href="/wiki">Wiki</a></div>
  720. </div>
  721. </div>
  722. <div class="container">
  723. <div class="stats-row">
  724. <div class="stat-box"><div class="stat-num" id="nChunks">-</div><div class="stat-label">Total Chunks</div></div>
  725. <div class="stat-box"><div class="stat-num" id="nFiles">-</div><div class="stat-label">Source Files</div></div>
  726. <div class="stat-box"><div class="stat-num" id="nChunkSize">-</div><div class="stat-label">Chunk Size</div></div>
  727. <div class="stat-box"><div class="stat-num" id="nKeywords">-</div><div class="stat-label">Keywords</div></div>
  728. </div>
  729. <div class="search-area">
  730. <div class="search-row">
  731. <input type="text" id="searchInput" placeholder="Search RAG chunks (e.g. ship entry/exit reporting)...">
  732. <select id="topK" style="padding:10px;border-radius:8px;border:1px solid #ddd;font-size:14px;">
  733. <option value="5">Top 5</option>
  734. <option value="10" selected>Top 10</option>
  735. <option value="20">Top 20</option>
  736. <option value="50">Top 50</option>
  737. </select>
  738. <button class="btn" onclick="doSearch()">Search</button>
  739. </div>
  740. </div>
  741. <div id="results"></div>
  742. <div class="sample-title" id="sampleTitle">Sample Chunks (from index)</div>
  743. <div id="sampleChunks"></div>
  744. </div>
  745. <script>
  746. document.getElementById('searchInput').addEventListener('keydown', e => { if(e.key==='Enter') doSearch(); });
  747. function loadStats() {
  748. fetch('/api/rag/stats').then(r=>r.json()).then(data => {
  749. document.getElementById('nChunks').textContent = data.total_chunks.toLocaleString();
  750. document.getElementById('nFiles').textContent = data.total_files.toLocaleString();
  751. document.getElementById('nChunkSize').textContent = data.chunk_size;
  752. document.getElementById('nKeywords').textContent = data.keyword_index_size.toLocaleString();
  753. const el = document.getElementById('sampleChunks');
  754. el.innerHTML = data.sample_chunks.map(c => renderChunk(c, null)).join('');
  755. });
  756. }
  757. function doSearch() {
  758. const q = document.getElementById('searchInput').value.trim();
  759. if (!q) return;
  760. const k = document.getElementById('topK').value;
  761. document.getElementById('results').innerHTML = '<div style="text-align:center;padding:20px;color:#888;">Searching...</div>';
  762. document.getElementById('sampleTitle').style.display = 'none';
  763. document.getElementById('sampleChunks').style.display = 'none';
  764. fetch('/api/rag/search?q=' + encodeURIComponent(q) + '&k=' + k)
  765. .then(r=>r.json()).then(data => {
  766. const el = document.getElementById('results');
  767. if (!data.results.length) {
  768. el.innerHTML = '<div style="text-align:center;padding:20px;color:#888;">No results found</div>';
  769. return;
  770. }
  771. el.innerHTML = '<div style="margin-bottom:12px;font-size:14px;color:#666;">Found ' + data.results.length + ' chunks for "<b>' + escHtml(q) + '</b>"</div>' +
  772. data.results.map(c => renderChunk(c, c.score)).join('');
  773. });
  774. }
  775. function renderChunk(c, score) {
  776. return '<div class="chunk-card">' +
  777. '<div class="chunk-header"><span class="chunk-source">' + escHtml(c.source) +
  778. ' #' + c.chunk_id + '</span>' +
  779. (score !== null ? '<span class="chunk-score">Score: ' + score + '</span>' : '') +
  780. '</div><div class="chunk-text">' + escHtml(c.text) + '</div></div>';
  781. }
  782. function escHtml(s) { const d=document.createElement('div'); d.textContent=s; return d.innerHTML; }
  783. loadStats();
  784. </script>
  785. </body>
  786. </html>
  787. """
  788. if __name__ == '__main__':
  789. print("Loading search index...")
  790. search("test", top_k=1) # warm up
  791. print("Index loaded. Starting server...")
  792. print("Access: http://0.0.0.0:8080")
  793. app.run(host='0.0.0.0', port=8080, debug=False, threaded=True)