diff --git a/tests/test_web_app.py b/tests/test_web_app.py index 0ff46d6..9318472 100644 --- a/tests/test_web_app.py +++ b/tests/test_web_app.py @@ -22,20 +22,28 @@ class WebAppTests(unittest.TestCase): def setUp(self): self.client = web_app.app.test_client() - def test_api_events_serializes_datetime_payloads(self): + def test_api_events_returns_short_polling_json_and_serializes_datetimes(self): with mock.patch.object( web_app, "get_events", - side_effect=[ - [{"seq": 1, "created_at": datetime(2026, 4, 21, 12, 0, 0)}], - [{"seq": 1, "created_at": datetime(2026, 4, 21, 12, 0, 0)}], - ], - ): - response = self.client.get("/api/events") - first_chunk = next(response.response).decode("utf-8") + return_value=[{"seq": 6, "created_at": datetime(2026, 4, 21, 12, 0, 0)}], + ) as get_events: + response = self.client.get("/api/events?since=5") + payload = response.get_json() + + get_events.assert_called_once_with(5) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.mimetype, "application/json") + self.assertEqual(payload["events"][0]["created_at"], "2026-04-21 12:00:00") + self.assertEqual(payload["events"][0]["seq"], 6) + self.assertEqual(payload["next_seq"], 7) - self.assertIn("2026-04-21 12:00:00", first_chunk) - self.assertIn('"seq": 1', first_chunk) + def test_read_requests_roll_back_open_transaction_on_teardown(self): + with mock.patch.object(web_app.db, "rollback") as rollback: + response = self.client.get("/api/meta") + + self.assertEqual(response.status_code, 200) + rollback.assert_called() def test_api_meta_includes_database_backend_summary(self): response = self.client.get("/api/meta") @@ -127,6 +135,63 @@ def test_dashboard_i18n_keys_match_and_static_labels_are_marked(self): ): self.assertIn(f'"{key}"', i18n) + def test_dashboard_stat_cards_have_i18n_tooltips(self): + expected_tips = { + "sourcePapers": ( + "Papers extracted/parsed (excludes fetched-but-unprocessed)", + "已抽取/解析完成的文献数(不含仅入库未处理的)", + ), + "results": ( + "Benchmark result records produced by experiments", + "基准实验产出的结果记录条数", + ), + "researchAreas": ( + "Nodes in the research-area taxonomy tree", + "研究领域分类树的节点数", + ), + "contradictions": ( + "Pairs of conflicting claims found across papers", + "从文献中发现的互相矛盾的论断对数", + ), + "researchInsights": ( + "Research insights (insights table; distinct from Discoveries)", + "研究洞见条数(insights 表;与\"深度发现\"是不同的表)", + ), + "tokens": ( + "Total LLM tokens consumed processing papers", + "处理文献累计消耗的 LLM token 总量", + ), + "experimentRuns": ( + "Total experiment runs", + "实验运行的总次数", + ), + "discoveries": ( + "Discoveries that can grow into papers (deep_insights table; distinct from Research Insights)", + "可发展成论文的深度发现条数(deep_insights 表;与\"研究洞见\"是不同的表)", + ), + "submissionBundles": ( + "Completed paper bundles ready for submission", + "打包完成、可投稿的完整论文数", + ), + } + html = _read("web/templates/index.html") + i18n = _read("web/static/js/i18n.js") + + self.assertEqual(html.count('class="stat-card'), 9) + self.assertEqual(html.count("data-i18n-title=\"overview."), 9) + for key, (en_tip, zh_tip) in expected_tips.items(): + i18n_key = f"overview.{key}.tip" + self.assertIn(f'data-i18n-title="{i18n_key}"', html) + self.assertIn(f'"{i18n_key}": {json.dumps(en_tip, ensure_ascii=False)}', i18n) + self.assertIn(f'"{i18n_key}": {json.dumps(zh_tip, ensure_ascii=False)}', i18n) + + def test_dashboard_events_use_short_polling_not_eventsource(self): + app_js = _read("web/static/js/app.js") + self.assertNotIn("EventSource", app_js) + self.assertIn("function fetchEvents()", app_js) + self.assertIn("/api/events?since=", app_js) + self.assertIn("setInterval(fetchEvents, 2000)", app_js) + def test_dashboard_legacy_visible_labels_are_gone(self): frontend = "\n".join( _read(path) diff --git a/web/app.py b/web/app.py index b5349f0..dea80e0 100644 --- a/web/app.py +++ b/web/app.py @@ -7,7 +7,7 @@ import traceback from pathlib import Path from typing import Any -from flask import Flask, Response, abort, jsonify, render_template, request, send_file, url_for +from flask import Flask, abort, jsonify, render_template, request, send_file, url_for from agents.workspace_layout import get_idea_workspace, list_paper_assets, plan_file_path, resolve_paper_asset from config import APP_NAME, APP_SUBTITLE, PROFILE, ROOT_NODE_ID from db import database as db @@ -83,6 +83,14 @@ def block_manual_experiment_post_apis(): return _manual_api_removed_response() +@app.teardown_request +def rollback_request_transaction(_exc): + try: + db.rollback() + except Exception: + pass + + def _pick_canonical_run(runs: list[dict], canonical_run_id: int | None = None) -> dict | None: if not runs: return None @@ -1144,24 +1152,23 @@ def api_matrix_gaps(): return jsonify(rows) -# ── Events (SSE) ────────────────────────────────────────────────── +# ── Events ──────────────────────────────────────────────────────── @app.route("/api/events") def api_events(): - """SSE endpoint for real-time updates.""" - def generate(): - # Start from near the end - only send last 20 events on connect - all_events = get_events(0) - last_seq = max(0, all_events[-1]["seq"] - 20) if all_events else 0 - while True: - events = get_events(last_seq) - for e in events: - yield f"data: {json.dumps(e, ensure_ascii=False, default=str)}\n\n" - last_seq = e["seq"] + 1 - time.sleep(2) # slower polling = less browser load - - return Response(generate(), mimetype="text/event-stream", - headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + """Short-poll pipeline events without holding a web worker thread.""" + since = max(0, request.args.get("since", 0, type=int) or 0) + events = get_events(since) + payload_events = json.loads(json.dumps(events, ensure_ascii=False, default=str)) + next_seq = since + for event in payload_events: + try: + next_seq = max(next_seq, int(event.get("seq", since)) + 1) + except (TypeError, ValueError): + pass + response = jsonify({"events": payload_events, "next_seq": next_seq}) + response.headers["Cache-Control"] = "no-cache" + return response # ── Pipeline Control ────────────────────────────────────────────── diff --git a/web/static/js/app.js b/web/static/js/app.js index 9cf95dd..ff9b369 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -13,7 +13,7 @@ const ROOT_NODE = document.body.dataset.rootNode || 'ml'; let activeTab = 'overview'; let exploreNodeId = ROOT_NODE; let exploreData = null; // cached /api/taxonomy/ response -let eventSource = null; +let eventsSince = 0; let events = []; // max 50 let activePapers = {}; // paper_id -> {title, step, startTime} let statsCache = null; @@ -176,41 +176,23 @@ async function refreshStats() { } } -// ── SSE Event Stream ───────────────────────────────────────────────── +// ── Event Polling ──────────────────────────────────────────────────── -let sseRetryDelay = 2000; - -function startSSE() { - if (eventSource) { - try { eventSource.close(); } catch(e) {} - eventSource = null; - } - eventSource = new EventSource('/api/events'); - - eventSource.onopen = () => { - sseRetryDelay = 2000; - }; - - eventSource.onmessage = (msg) => { - try { - const ev = JSON.parse(msg.data); +async function fetchEvents() { + try { + const payload = await api(`/api/events?since=${eventsSince}`); + eventsSince = payload.next_seq || eventsSince; + for (const ev of payload.events || []) { events.push(ev); if (events.length > 50) events.shift(); trackPaperEvent(ev); updateLiveBadge(ev); appendFeedEvent(ev); - } catch (e) { - console.error('SSE parse error:', e); } - }; - - eventSource.onerror = () => { - eventSource.close(); - eventSource = null; - setTimeout(startSSE, sseRetryDelay); - sseRetryDelay = Math.min(sseRetryDelay * 1.5, 15000); - }; + } catch (e) { + console.error('Event polling error:', e); + } } let pipelineRunning = false; @@ -2608,7 +2590,8 @@ function init() { // Initial data loads refreshStats(); loadRecentlyDiscovered(); - startSSE(); + fetchEvents(); + setInterval(fetchEvents, 2000); // Stats refresh every 15s statsTimer = setInterval(refreshStats, 15000); diff --git a/web/static/js/i18n.js b/web/static/js/i18n.js index 8f86ebe..7f44e96 100644 --- a/web/static/js/i18n.js +++ b/web/static/js/i18n.js @@ -32,6 +32,15 @@ "overview.experimentRuns": "Experiment Run", "overview.discoveries": "Discovery", "overview.submissionBundles": "Submission Bundle", + "overview.sourcePapers.tip": "Papers extracted/parsed (excludes fetched-but-unprocessed)", + "overview.results.tip": "Benchmark result records produced by experiments", + "overview.researchAreas.tip": "Nodes in the research-area taxonomy tree", + "overview.contradictions.tip": "Pairs of conflicting claims found across papers", + "overview.researchInsights.tip": "Research insights (insights table; distinct from Discoveries)", + "overview.tokens.tip": "Total LLM tokens consumed processing papers", + "overview.experimentRuns.tip": "Total experiment runs", + "overview.discoveries.tip": "Discoveries that can grow into papers (deep_insights table; distinct from Research Insights)", + "overview.submissionBundles.tip": "Completed paper bundles ready for submission", "overview.latest": "Latest Activity", "overview.latestEmpty": "Run the pipeline to discover gaps, contradictions, opportunities, and discoveries.", "overview.processing": "Current Processing", @@ -436,6 +445,15 @@ "overview.experimentRuns": "实验运行", "overview.discoveries": "深度发现", "overview.submissionBundles": "投稿包", + "overview.sourcePapers.tip": "已抽取/解析完成的文献数(不含仅入库未处理的)", + "overview.results.tip": "基准实验产出的结果记录条数", + "overview.researchAreas.tip": "研究领域分类树的节点数", + "overview.contradictions.tip": "从文献中发现的互相矛盾的论断对数", + "overview.researchInsights.tip": "研究洞见条数(insights 表;与\"深度发现\"是不同的表)", + "overview.tokens.tip": "处理文献累计消耗的 LLM token 总量", + "overview.experimentRuns.tip": "实验运行的总次数", + "overview.discoveries.tip": "可发展成论文的深度发现条数(deep_insights 表;与\"研究洞见\"是不同的表)", + "overview.submissionBundles.tip": "打包完成、可投稿的完整论文数", "overview.latest": "最新动态", "overview.latestEmpty": "运行流水线以发现空白、矛盾、机会点和深度发现。", "overview.processing": "当前处理", diff --git a/web/templates/index.html b/web/templates/index.html index d2d7f9d..03cab21 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -91,63 +91,63 @@
-
+
0
Source Paper
-
+
0
Result
-
+
0
Research Area
-
+
0
Contradiction
-
+
0
Research Insight
-
+
0
Token
-
+
0
Experiment Run
-
+
0
Discovery
-
+