Compare commits

...
Author SHA1 Message Date
MS-01-Codexandmultica-agent 228e08a5e2 BAI-15: group mentor answer bubbles
Co-authored-by: multica-agent <github@multica.ai>
2026-08-07 16:40:00 +08:00
MS-01-Codexandmultica-agent 777af0cb8b BAI-3: harden live readiness diagnostics
Co-authored-by: multica-agent <github@multica.ai>
2026-08-07 12:29:01 +08:00
MS-01-Codexandmultica-agent a326df2a8d BAI-15: refine dark mentor answer bubble
Co-authored-by: multica-agent <github@multica.ai>
2026-08-07 10:14:02 +08:00
15 changed files with 421 additions and 129 deletions
+2
View File
@@ -43,6 +43,7 @@ from backend.features.screener.service import (
from backend.features.sentiment import SentimentServiceMixin from backend.features.sentiment import SentimentServiceMixin
from backend.features.sentiment.routes import SentimentRoutesMixin from backend.features.sentiment.routes import SentimentRoutesMixin
from backend.features.system import SystemHttpMixin from backend.features.system import SystemHttpMixin
from backend.features.system.health import HealthServiceMixin
from backend.features.system.routes import SystemRoutesMixin from backend.features.system.routes import SystemRoutesMixin
from backend.features.system.service import SystemServiceMixin from backend.features.system.service import SystemServiceMixin
from backend.features.themes import ThemeServiceMixin from backend.features.themes import ThemeServiceMixin
@@ -77,6 +78,7 @@ LEGACY_SECRET_KEYS = {
class DashboardService( class DashboardService(
HealthServiceMixin,
SystemServiceMixin, SystemServiceMixin,
AccountApplicationMixin, AccountApplicationMixin,
JobServiceMixin, JobServiceMixin,
+94
View File
@@ -0,0 +1,94 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
class HealthServiceMixin:
def health_status(self) -> dict[str, Any]:
"""Return a cheap public readiness summary without provider secrets."""
database_ready = False
database_status: dict[str, Any] = {}
try:
with self.database.connect() as connection:
database_ready = connection.execute("SELECT 1").fetchone() is not None
database_status = self.database.status()
except Exception:
database_ready = False
try:
recent_jobs = self.jobs.repository.recent(12)
except Exception:
recent_jobs = []
last_sync = database_status.get("last_sync") or {}
snapshot_dates = int(database_status.get("snapshot_dates") or 0)
data_configured = bool(self.configured)
last_sync_success = last_sync.get("status") == "success"
if data_configured and snapshot_dates and last_sync_success:
data_status = "ready"
elif snapshot_dates:
data_status = "degraded"
else:
data_status = "unavailable"
latest_job = recent_jobs[0] if recent_jobs else {}
failed_jobs = sum(1 for job in recent_jobs if job.get("status") == "failed")
if not database_ready:
jobs_status = "unavailable"
elif latest_job.get("status") == "running":
jobs_status = "running"
elif latest_job.get("status") == "failed":
jobs_status = "degraded"
else:
jobs_status = "ready"
platform = self._platform_llm_profile()
primary_ready = self._profile_configured(platform["primary"])
fallback_ready = self._profile_configured(platform["fallback"])
models_status = "ready" if primary_ready else "unavailable"
component_states = (
"ready",
"ready" if database_ready else "unavailable",
data_status,
jobs_status,
models_status,
)
return {
"ok": database_ready,
"status": (
"ready"
if all(state in {"ready", "running"} for state in component_states)
else "degraded"
),
"account_required": True,
"time": datetime.now().astimezone().isoformat(timespec="seconds"),
"components": {
"process": {"status": "ready"},
"database": {
"status": "ready" if database_ready else "unavailable",
"snapshot_dates": snapshot_dates,
"last_snapshot_at": database_status.get("updated_at") or "",
},
"data_sources": {
"status": data_status,
"configured": data_configured,
"snapshot_available": snapshot_dates > 0,
"latest_trade_date": str(last_sync.get("trade_date") or ""),
"last_success_at": (
str(last_sync.get("finished_at") or "") if last_sync_success else ""
),
},
"jobs": {
"status": jobs_status,
"recent_failures": failed_jobs,
"latest_finished_at": str(latest_job.get("finished_at") or ""),
},
"models": {
"status": models_status,
"primary": "ready" if primary_ready else "unavailable",
"fallback": "ready" if fallback_ready else "disabled",
},
},
}
+1 -9
View File
@@ -1,20 +1,12 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from http import HTTPStatus from http import HTTPStatus
class SystemRoutesMixin: class SystemRoutesMixin:
def _handle_system_public_get(self, parsed) -> bool: def _handle_system_public_get(self, parsed) -> bool:
if parsed.path == "/api/health": if parsed.path == "/api/health":
self.send_json( self.send_json(self.application_service.health_status())
{
"ok": True,
"storage": "sqlite",
"account_required": True,
"time": datetime.now().astimezone().isoformat(timespec="seconds"),
}
)
return True return True
return False return False
+31 -3
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import json import json
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime, timedelta
from typing import Any from typing import Any
from database import ReviewDatabase from database import ReviewDatabase
@@ -27,9 +27,37 @@ class SQLiteJobRunRepository:
def start( def start(
self, job_id: str, idempotency_key: str, output_version: str, self, job_id: str, idempotency_key: str, output_version: str,
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
) -> int: stale_after_seconds: int = 0,
now = datetime.now().astimezone().isoformat(timespec="seconds") ) -> int | None:
current = datetime.now().astimezone()
now = current.isoformat(timespec="seconds")
stale_before = (
current - timedelta(seconds=max(1, int(stale_after_seconds or 1)))
).isoformat(timespec="seconds")
with self.database.connect() as connection: with self.database.connect() as connection:
# The process-local runner lock cannot protect two server processes.
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"""
UPDATE job_runs
SET status = 'failed', finished_at = ?, error_code = 'StaleRun',
message = 'Previous run exceeded its execution window.'
WHERE job_id = ? AND idempotency_key = ? AND status = 'running'
AND started_at < ?
""",
(now, job_id, idempotency_key, stale_before),
)
claimed = connection.execute(
"""
SELECT 1 FROM job_runs
WHERE job_id = ? AND idempotency_key = ?
AND status IN ('running', 'success')
LIMIT 1
""",
(job_id, idempotency_key),
).fetchone()
if claimed:
return None
row = connection.execute( row = connection.execute(
""" """
SELECT COALESCE(MAX(attempt), 0) + 1 AS attempt FROM job_runs SELECT COALESCE(MAX(attempt), 0) + 1 AS attempt FROM job_runs
+12 -6
View File
@@ -51,8 +51,7 @@ class InProcessJobRunner:
lock = self._lock(definition.lock_key) lock = self._lock(definition.lock_key)
if not lock.acquire(blocking=False): if not lock.acquire(blocking=False):
return False return False
self._execute_locked(job_id, idempotency_key, action, metadata, lock) return self._execute_locked(job_id, idempotency_key, action, metadata, lock)
return True
def start_scheduler( def start_scheduler(
self, callback: Callable[[], None], interval_seconds: float, self, callback: Callable[[], None], interval_seconds: float,
@@ -105,13 +104,19 @@ class InProcessJobRunner:
def _execute_locked( def _execute_locked(
self, job_id: str, idempotency_key: str, action: JobAction, self, job_id: str, idempotency_key: str, action: JobAction,
metadata: dict[str, Any] | None, lock: threading.Lock, metadata: dict[str, Any] | None, lock: threading.Lock,
) -> None: ) -> bool:
definition = self.registry.get(job_id) definition = self.registry.get(job_id)
try: try:
for attempt in range(1, definition.max_attempts + 1): for attempt in range(1, definition.max_attempts + 1):
run_id = self.repository.start( run_id = self.repository.start(
job_id, idempotency_key, definition.output_version, metadata job_id,
idempotency_key,
definition.output_version,
metadata,
definition.timeout_seconds,
) )
if run_id is None:
return False
started = time.perf_counter() started = time.perf_counter()
try: try:
result = action() result = action()
@@ -119,7 +124,7 @@ class InProcessJobRunner:
raise RuntimeError(str(result.get("error") or "Job reported failure")) raise RuntimeError(str(result.get("error") or "Job reported failure"))
elapsed_ms = round((time.perf_counter() - started) * 1000) elapsed_ms = round((time.perf_counter() - started) * 1000)
self.repository.finish(run_id, "success", elapsed_ms) self.repository.finish(run_id, "success", elapsed_ms)
return return True
except Exception as exc: except Exception as exc:
elapsed_ms = round((time.perf_counter() - started) * 1000) elapsed_ms = round((time.perf_counter() - started) * 1000)
self.repository.finish( self.repository.finish(
@@ -127,7 +132,8 @@ class InProcessJobRunner:
type(exc).__name__, str(exc), type(exc).__name__, str(exc),
) )
if attempt >= definition.max_attempts: if attempt >= definition.max_attempts:
return return True
return True
finally: finally:
lock.release() lock.release()
+11 -11
View File
@@ -390,7 +390,7 @@
"/pages/popularity/foundation.css?v=20260806-1", "/pages/popularity/foundation.css?v=20260806-1",
"/pages/dragon-tiger/foundation.css?v=20260806-1", "/pages/dragon-tiger/foundation.css?v=20260806-1",
"/pages/screener/foundation.css?v=20260806-2", "/pages/screener/foundation.css?v=20260806-2",
"/pages/mentor/foundation.css?v=20260806-2", "/pages/mentor/foundation.css?v=20260807-1",
"/pages/heaven/foundation.css?v=20260806-2", "/pages/heaven/foundation.css?v=20260806-2",
"/pages/review/foundation.css?v=20260806-1" "/pages/review/foundation.css?v=20260806-1"
], ],
@@ -609,16 +609,16 @@
"bytes": 6983, "bytes": 6983,
"lines": 146 "lines": 146
}, },
{
"path": "backend/application.py",
"bytes": 6837,
"lines": 180
},
{ {
"path": "backend/data/providers/tushare_daily.py", "path": "backend/data/providers/tushare_daily.py",
"bytes": 6837, "bytes": 6837,
"lines": 160 "lines": 160
}, },
{
"path": "backend/application.py",
"bytes": 6751,
"lines": 178
},
{ {
"path": "backend/features/market/insights_popularity.py", "path": "backend/features/market/insights_popularity.py",
"bytes": 6739, "bytes": 6739,
@@ -829,11 +829,6 @@
"bytes": 1455, "bytes": 1455,
"lines": 48 "lines": 48
}, },
{
"path": "backend/features/system/routes.py",
"bytes": 1423,
"lines": 40
},
{ {
"path": "backend/features/themes/routes.py", "path": "backend/features/themes/routes.py",
"bytes": 1337, "bytes": 1337,
@@ -844,6 +839,11 @@
"bytes": 1195, "bytes": 1195,
"lines": 30 "lines": 30
}, },
{
"path": "backend/features/system/routes.py",
"bytes": 1178,
"lines": 32
},
{ {
"path": "frontend/pages/ladder/page.html", "path": "frontend/pages/ladder/page.html",
"bytes": 1143, "bytes": 1143,
+1 -1
View File
@@ -37,7 +37,7 @@
<link rel="stylesheet" href="/pages/popularity/foundation.css?v=20260806-1"> <link rel="stylesheet" href="/pages/popularity/foundation.css?v=20260806-1">
<link rel="stylesheet" href="/pages/dragon-tiger/foundation.css?v=20260806-1"> <link rel="stylesheet" href="/pages/dragon-tiger/foundation.css?v=20260806-1">
<link rel="stylesheet" href="/pages/screener/foundation.css?v=20260806-2"> <link rel="stylesheet" href="/pages/screener/foundation.css?v=20260806-2">
<link rel="stylesheet" href="/pages/mentor/foundation.css?v=20260806-2"> <link rel="stylesheet" href="/pages/mentor/foundation.css?v=20260807-1">
<link rel="stylesheet" href="/pages/heaven/foundation.css?v=20260806-2"> <link rel="stylesheet" href="/pages/heaven/foundation.css?v=20260806-2">
<link rel="stylesheet" href="/pages/review/foundation.css?v=20260806-1"> <link rel="stylesheet" href="/pages/review/foundation.css?v=20260806-1">
</head> </head>
+32 -61
View File
@@ -250,20 +250,20 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 1px solid var(--mentor-blue-line); border: 1px solid var(--mentor-blue-line);
border-radius: 50%; border-radius: var(--radius-md);
background: var(--mentor-blue-soft); background: var(--mentor-blue-soft);
color: var(--mentor-blue-dark); color: var(--mentor-blue-dark);
font-size: var(--font-size-card-title); font-size: var(--font-size-card-title);
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
} }
#mentorView .mentor-avatar[data-grade="a"] { #mentorView :is(.mentor-avatar, .mentor-message-avatar, .mentor-profile-avatar)[data-grade="a"] {
border-color: var(--market-up); border-color: var(--market-up);
background: var(--market-up-soft); background: var(--market-up-soft);
color: var(--market-up); color: var(--market-up);
} }
#mentorView .mentor-avatar[data-grade="c"] { #mentorView :is(.mentor-avatar, .mentor-message-avatar, .mentor-profile-avatar)[data-grade="c"] {
border-color: var(--market-down); border-color: var(--market-down);
background: var(--market-down-soft); background: var(--market-down-soft);
color: var(--market-down); color: var(--market-down);
@@ -427,61 +427,10 @@
background: var(--surface-subtle); background: var(--surface-subtle);
} }
#mentorView .mentor-chat-header {
height: var(--mentor-pane-header-height);
flex: 0 0 var(--mentor-pane-header-height);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-12);
padding: 0 var(--space-16);
border-bottom: 1px solid var(--mentor-line);
background: var(--surface);
}
#mentorView .mentor-chat-identity {
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-8);
}
#mentorView .mentor-chat-identity > div {
min-width: 0;
}
#mentorView .mentor-active-title {
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-8);
}
#mentorView .mentor-active-title h3 {
min-width: 0;
margin: 0;
overflow: hidden;
color: var(--mentor-ink);
font-size: var(--font-size-card-title);
font-weight: var(--font-weight-semibold);
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
#mentorView .mentor-active-badges {
display: inline-flex;
gap: var(--space-4);
}
#mentorView #activeMentorStatus {
margin: var(--space-4) 0 0;
color: var(--mentor-faint);
font-size: var(--font-size-caption);
}
#mentorView .mentor-clear-button { #mentorView .mentor-clear-button {
flex: 0 0 auto; width: 40px;
height: 40px;
flex: 0 0 40px;
color: var(--mentor-sub); color: var(--mentor-sub);
} }
@@ -554,7 +503,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 1px solid var(--mentor-line); border: 1px solid var(--mentor-line);
border-radius: 50%; border-radius: var(--radius-md);
background: var(--surface); background: var(--surface);
color: var(--mentor-sub); color: var(--mentor-sub);
font-size: var(--font-size-label); font-size: var(--font-size-label);
@@ -568,6 +517,7 @@
} }
#mentorView .mentor-message-body { #mentorView .mentor-message-body {
min-width: 0;
max-width: var(--mentor-message-max-width); max-width: var(--mentor-message-max-width);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -579,6 +529,20 @@
align-items: flex-end; align-items: flex-end;
} }
#mentorView .mentor-message-stack {
min-width: 0;
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-8);
}
#mentorView .mentor-message-stack > .mentor-message-content {
width: fit-content;
max-width: 100%;
}
#mentorView .mentor-message-label { #mentorView .mentor-message-label {
color: var(--mentor-faint); color: var(--mentor-faint);
font-size: var(--font-size-caption); font-size: var(--font-size-caption);
@@ -618,6 +582,7 @@
#mentorView .mentor-answer-paragraph { #mentorView .mentor-answer-paragraph {
margin: 0 0 var(--space-8); margin: 0 0 var(--space-8);
text-wrap: pretty;
} }
#mentorView .mentor-answer-paragraph:last-child { #mentorView .mentor-answer-paragraph:last-child {
@@ -630,6 +595,7 @@
color: var(--mentor-ink); color: var(--mentor-ink);
font-size: var(--font-size-body); font-size: var(--font-size-body);
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
text-wrap: balance;
} }
#mentorView .mentor-answer-heading:first-child { #mentorView .mentor-answer-heading:first-child {
@@ -844,7 +810,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 1px solid var(--mentor-blue-line); border: 1px solid var(--mentor-blue-line);
border-radius: 50%; border-radius: var(--radius-md);
background: var(--mentor-blue-soft); background: var(--mentor-blue-soft);
color: var(--mentor-blue-dark); color: var(--mentor-blue-dark);
font-size: var(--font-size-metric); font-size: var(--font-size-metric);
@@ -966,11 +932,16 @@
} }
:root[data-theme="dark"] #mentorView .mentor-message { :root[data-theme="dark"] #mentorView .mentor-message {
border-color: var(--line-soft); background: transparent;
background: var(--surface-subtle);
box-shadow: none; box-shadow: none;
} }
:root[data-theme="dark"] #mentorView .mentor-message.assistant .mentor-message-content {
border-color: var(--border);
background: var(--surface-muted);
color: var(--text-primary);
}
:root[data-theme="dark"] #mentorView .mentor-message.user .mentor-message-content { :root[data-theme="dark"] #mentorView .mentor-message.user .mentor-message-content {
border-color: var(--blue-line); border-color: var(--blue-line);
background: var(--action-soft); background: var(--action-soft);
+2 -10
View File
@@ -33,16 +33,7 @@
</aside> </aside>
<section class="mentor-chat-panel" aria-label="问师对话"> <section class="mentor-chat-panel" aria-label="问师对话">
<header class="mentor-chat-header"> <p id="activeMentorStatus" class="visually-hidden" aria-live="polite">思维模型已就绪</p>
<div class="mentor-chat-identity">
<span id="activeMentorAvatar" class="mentor-avatar" aria-hidden="true"></span>
<div>
<div class="mentor-active-title"><h3 id="activeMentorName">--</h3><span id="activeMentorBadges" class="mentor-active-badges"></span></div>
<p id="activeMentorStatus">思维模型已就绪</p>
</div>
</div>
<button id="clearMentorChatButton" class="icon-button mentor-clear-button" type="button" disabled aria-label="清空当前对话" title="清空对话"><i data-lucide="trash-2"></i></button>
</header>
<div id="mentorMessages" class="mentor-messages" aria-live="polite"></div> <div id="mentorMessages" class="mentor-messages" aria-live="polite"></div>
<div id="mentorQuickPrompts" class="mentor-quick-prompts"> <div id="mentorQuickPrompts" class="mentor-quick-prompts">
<span class="mentor-prompt-label">开始一个话题</span> <span class="mentor-prompt-label">开始一个话题</span>
@@ -56,6 +47,7 @@
<textarea id="mentorQuestion" maxlength="2000" placeholder="输入市场、板块、个股代码或交易问题"></textarea> <textarea id="mentorQuestion" maxlength="2000" placeholder="输入市场、板块、个股代码或交易问题"></textarea>
<div class="mentor-composer-actions"> <div class="mentor-composer-actions">
<span>Enter 发送 · Shift + Enter 换行</span> <span>Enter 发送 · Shift + Enter 换行</span>
<button id="clearMentorChatButton" class="icon-button mentor-clear-button" type="button" disabled aria-label="清空当前对话" title="清空对话"><i data-lucide="trash-2"></i></button>
<button id="stopMentorQuestion" class="button ghost mentor-stop-button" type="button" hidden><i data-lucide="square"></i><span>停止</span></button> <button id="stopMentorQuestion" class="button ghost mentor-stop-button" type="button" hidden><i data-lucide="square"></i><span>停止</span></button>
<button id="sendMentorQuestion" class="button primary mentor-send-button" type="submit"><i data-lucide="send-horizontal"></i><span>发送</span></button> <button id="sendMentorQuestion" class="button primary mentor-send-button" type="submit"><i data-lucide="send-horizontal"></i><span>发送</span></button>
</div> </div>
+38 -15
View File
@@ -36,14 +36,12 @@ function renderMentorWorkspace() {
if (!setup) return; if (!setup) return;
const selected = setup.mentors.find((item) => item.id === state.selectedMentorId) || null; const selected = setup.mentors.find((item) => item.id === state.selectedMentorId) || null;
setText("mentorDataDate", `数据日期 ${displayCompactDate(setup.trade_date)}`); setText("mentorDataDate", `数据日期 ${displayCompactDate(setup.trade_date)}`);
setText("activeMentorName", selected?.name || "--");
setText("mentorProfileName", selected?.name || "--"); setText("mentorProfileName", selected?.name || "--");
setText("mentorProfileTagline", selected?.tagline || selected?.description || "--"); setText("mentorProfileTagline", selected?.tagline || selected?.description || "--");
setText("mentorProfileSource", selected?.evidence?.label || "公开资料整理"); setText("mentorProfileSource", selected?.evidence?.label || "公开资料整理");
setText("mentorProfileDataDate", `行情数据 ${displayCompactDate(setup.trade_date)}`); setText("mentorProfileDataDate", `行情数据 ${displayCompactDate(setup.trade_date)}`);
setText("activeMentorAvatar", mentorAvatarText(selected));
setText("mentorProfileAvatar", mentorAvatarText(selected)); setText("mentorProfileAvatar", mentorAvatarText(selected));
document.querySelector("#activeMentorBadges").innerHTML = selected ? renderMentorBadges(selected, true) : ""; document.querySelector("#mentorProfileAvatar").dataset.grade = String(selected?.evidence?.grade || "").toLowerCase();
document.querySelector("#mentorProfileBadges").innerHTML = selected ? renderMentorBadges(selected, true) : ""; document.querySelector("#mentorProfileBadges").innerHTML = selected ? renderMentorBadges(selected, true) : "";
setText("activeMentorEvidence", selected?.evidence?.note || selected?.description || "--"); setText("activeMentorEvidence", selected?.evidence?.note || selected?.description || "--");
document.querySelector("#activeMentorFocus").innerHTML = (selected?.focus || []).slice(0, 4) document.querySelector("#activeMentorFocus").innerHTML = (selected?.focus || []).slice(0, 4)
@@ -287,12 +285,14 @@ function renderMentorMessages() {
} else { } else {
container.innerHTML = state.mentorMessages.map((message) => ` container.innerHTML = state.mentorMessages.map((message) => `
<article class="mentor-message ${message.role} ${message.error ? "is-error" : ""}"> <article class="mentor-message ${message.role} ${message.error ? "is-error" : ""}">
<span class="mentor-message-avatar" aria-hidden="true">${message.role === "user" ? "我" : escapeHtml(mentorAvatarText(selected))}</span> <span class="mentor-message-avatar" data-grade="${message.role === "assistant" ? escapeHtml(String(selected?.evidence?.grade || "").toLowerCase()) : ""}" aria-hidden="true">${message.role === "user" ? "我" : escapeHtml(mentorAvatarText(selected))}</span>
<div class="mentor-message-body"> <div class="mentor-message-body">
<div class="mentor-message-label">${message.role === "user" ? "我" : escapeHtml(selected?.name || "问师")}</div> <div class="mentor-message-label">${message.role === "user" ? "我" : escapeHtml(selected?.name || "问师")}</div>
<div class="mentor-message-content">${message.role === "assistant" ${message.role === "assistant"
? (message.content ? formatMentorAnswer(message.content) : '<p class="mentor-loading-copy">正在读取复盘数据并推演...</p>') ? `<div class="mentor-message-stack">${message.content
: escapeHtml(message.content)}</div> ? formatMentorAnswer(message.content)
: '<div class="mentor-message-content"><p class="mentor-loading-copy">正在读取复盘数据并推演...</p></div>'}</div>`
: `<div class="mentor-message-content">${escapeHtml(message.content)}</div>`}
${message.streaming ? '<span class="assistant-stream-caret" aria-hidden="true"></span>' : ""} ${message.streaming ? '<span class="assistant-stream-caret" aria-hidden="true"></span>' : ""}
${renderMentorFollowUps(message)} ${renderMentorFollowUps(message)}
${message.meta && !message.streaming ? `<small>${escapeHtml(message.meta)}</small>` : ""} ${message.meta && !message.streaming ? `<small>${escapeHtml(message.meta)}</small>` : ""}
@@ -478,12 +478,14 @@ function hideMentorNotice() {
} }
function formatMentorAnswer(content) { function formatMentorAnswer(content) {
const blocks = []; const sections = [[]];
let currentSection = sections[0];
let headingCount = 0;
let listType = ""; let listType = "";
let listItems = []; let listItems = [];
const flushList = () => { const flushList = () => {
if (!listItems.length) return; if (!listItems.length) return;
blocks.push(`<${listType} class="mentor-answer-list">${listItems.map((item) => `<li>${item}</li>`).join("")}</${listType}>`); currentSection.push(`<${listType} class="mentor-answer-list">${listItems.map((item) => `<li>${item}</li>`).join("")}</${listType}>`);
listItems = []; listItems = [];
listType = ""; listType = "";
}; };
@@ -493,18 +495,23 @@ function formatMentorAnswer(content) {
flushList(); flushList();
return; return;
} }
const heading = line.match(/^#{1,3}\s+(.+)$/); const heading = mentorAnswerHeading(line);
const bullet = line.match(/^[-*]\s+(.+)$/); const bullet = line.match(/^[-*]\s+(.+)$/);
const ordered = line.match(/^\d+[.、]\s*(.+)$/); const ordered = line.match(/^\d+[.、]\s*(.+)$/);
if (heading) { if (heading) {
flushList(); flushList();
blocks.push(`<strong class="mentor-answer-heading">${formatMentorInline(escapeHtml(heading[1]))}</strong>`); if (currentSection.length) {
currentSection = [];
sections.push(currentSection);
}
headingCount += 1;
currentSection.push(`<strong class="mentor-answer-heading">${formatMentorInline(escapeHtml(heading))}</strong>`);
} else if (/^-{3,}$/.test(line)) { } else if (/^-{3,}$/.test(line)) {
flushList(); flushList();
blocks.push('<span class="mentor-answer-rule"></span>'); currentSection.push('<span class="mentor-answer-rule"></span>');
} else if (line.startsWith("> ")) { } else if (line.startsWith("> ")) {
flushList(); flushList();
blocks.push(`<span class="mentor-answer-quote">${formatMentorInline(escapeHtml(line.slice(2)))}</span>`); currentSection.push(`<span class="mentor-answer-quote">${formatMentorInline(escapeHtml(line.slice(2)))}</span>`);
} else if (bullet || ordered) { } else if (bullet || ordered) {
const nextType = bullet ? "ul" : "ol"; const nextType = bullet ? "ul" : "ol";
if (listType && listType !== nextType) flushList(); if (listType && listType !== nextType) flushList();
@@ -512,11 +519,27 @@ function formatMentorAnswer(content) {
listItems.push(formatMentorInline(escapeHtml((bullet || ordered)[1]))); listItems.push(formatMentorInline(escapeHtml((bullet || ordered)[1])));
} else { } else {
flushList(); flushList();
blocks.push(`<p class="mentor-answer-paragraph">${formatMentorInline(escapeHtml(line))}</p>`); currentSection.push(`<p class="mentor-answer-paragraph">${formatMentorInline(escapeHtml(line))}</p>`);
} }
}); });
flushList(); flushList();
return blocks.join(""); const populatedSections = sections.filter((section) => section.length);
if (headingCount < 2) {
return `<div class="mentor-message-content">${populatedSections.flat().join("")}</div>`;
}
return populatedSections.map((section) => (
`<section class="mentor-message-content mentor-answer-bubble">${section.join("")}</section>`
)).join("");
}
function mentorAnswerHeading(line) {
const markdownHeading = line.match(/^#{1,3}\s+(.+)$/);
if (markdownHeading) return markdownHeading[1].trim();
const boldHeading = line.match(/^\*\*([^*]+)\*\*$/);
if (!boldHeading) return "";
const title = boldHeading[1].trim();
if (!title || title.length > 32 || /[。!?!?;:]$/.test(title)) return "";
return title;
} }
function formatMentorInline(content) { function formatMentorInline(content) {
+2 -2
View File
@@ -222,8 +222,8 @@
--mentor-directory-width: 280px; --mentor-directory-width: 280px;
--mentor-profile-width: 272px; --mentor-profile-width: 272px;
--mentor-pane-header-height: 58px; --mentor-pane-header-height: 58px;
--mentor-avatar-size: 38px; --mentor-avatar-size: 46px;
--mentor-profile-avatar-size: 68px; --mentor-profile-avatar-size: 80px;
--mentor-composer-min-height: 94px; --mentor-composer-min-height: 94px;
--mentor-message-max-width: 82%; --mentor-message-max-width: 82%;
+29 -8
View File
@@ -489,8 +489,8 @@ async function mockApplication(page, authSession = session(), options = {}) {
status: 200, status: 200,
contentType: "application/x-ndjson; charset=utf-8", contentType: "application/x-ndjson; charset=utf-8",
body: [ body: [
JSON.stringify({ type: "delta", content: "## 判断\n先看市场结构。\n\n" }), JSON.stringify({ type: "delta", content: "**判断**\n先看市场结构。\n\n" }),
JSON.stringify({ type: "delta", content: "- 等待确认\n- 控制仓位" }), JSON.stringify({ type: "delta", content: "**操作**\n- 等待确认\n- 控制仓位" }),
JSON.stringify({ JSON.stringify({
type: "meta", type: "meta",
data_trade_date: "20260722", data_trade_date: "20260722",
@@ -2818,9 +2818,24 @@ test("mentor directory exposes evidence filters and private owner metadata", asy
await page.locator('[data-mentor-grade="B"]').click(); await page.locator('[data-mentor-grade="B"]').click();
await expect(page.locator("#mentorList .mentor-option")).toHaveCount(7); await expect(page.locator("#mentorList .mentor-option")).toHaveCount(7);
await page.locator('#mentorList [data-mentor-id="source-b"]').click(); await page.locator('#mentorList [data-mentor-id="source-b"]').click();
await expect(page.locator("#activeMentorName")).toHaveText("多源老师"); await expect(page.locator("#mentorProfileName")).toHaveText("多源老师");
await expect(page.locator("#activeMentorBadges")).toHaveText("B"); await expect(page.locator("#mentorProfileBadges")).toHaveText("B");
await expect(page.locator("#activeMentorEvidence")).toHaveText("公开访谈与多源材料"); await expect(page.locator("#activeMentorEvidence")).toHaveText("公开访谈与多源材料");
await expect(page.locator("#mentorView .mentor-chat-header")).toHaveCount(0);
await expect(page.locator("#mentorChatForm #clearMentorChatButton")).toBeVisible();
const avatarGeometry = await page.evaluate(() => {
const directoryAvatar = document.querySelector("#mentorList .mentor-option.active .mentor-avatar");
const profileAvatar = document.querySelector("#mentorProfileAvatar");
return {
directoryWidth: directoryAvatar.getBoundingClientRect().width,
directoryRadius: getComputedStyle(directoryAvatar).borderRadius,
profileWidth: profileAvatar.getBoundingClientRect().width,
profileRadius: getComputedStyle(profileAvatar).borderRadius,
};
});
expect(avatarGeometry.directoryWidth).toBeGreaterThanOrEqual(46);
expect(avatarGeometry.profileWidth).toBeGreaterThan(avatarGeometry.directoryWidth);
expect(avatarGeometry.profileRadius).toBe(avatarGeometry.directoryRadius);
const mentorLibrary = await page.locator("#mentorView .mentor-sidebar").boundingBox(); const mentorLibrary = await page.locator("#mentorView .mentor-sidebar").boundingBox();
const mentorChat = await page.locator("#mentorView .mentor-chat-panel").boundingBox(); const mentorChat = await page.locator("#mentorView .mentor-chat-panel").boundingBox();
const mentorProfile = await page.locator("#mentorView .mentor-profile-panel").boundingBox(); const mentorProfile = await page.locator("#mentorView .mentor-profile-panel").boundingBox();
@@ -2864,6 +2879,8 @@ test("mentor pins, custom order and streamed replies work together", async ({ pa
await page.locator("#sendMentorQuestion").click(); await page.locator("#sendMentorQuestion").click();
const answer = page.locator("#mentorMessages .mentor-message.assistant").last(); const answer = page.locator("#mentorMessages .mentor-message.assistant").last();
await expect(answer).toContainText("先看市场结构。"); await expect(answer).toContainText("先看市场结构。");
await expect(answer.locator(".mentor-message-content")).toHaveCount(2);
await expect(answer.locator(".mentor-answer-heading")).toHaveCount(2);
await expect(answer.locator(".mentor-answer-list li")).toHaveCount(2); await expect(answer.locator(".mentor-answer-list li")).toHaveCount(2);
await expect(answer.locator("br")).toHaveCount(0); await expect(answer.locator("br")).toHaveCount(0);
await expect(page.locator("#mentorMessages .assistant-stream-caret")).toHaveCount(0); await expect(page.locator("#mentorMessages .assistant-stream-caret")).toHaveCount(0);
@@ -2874,6 +2891,7 @@ test("mentor pins, custom order and streamed replies work together", async ({ pa
await expect(page.locator("#mentorQuestion")).toHaveValue("哪些信号代表确认?"); await expect(page.locator("#mentorQuestion")).toHaveValue("哪些信号代表确认?");
await expect(page.locator("#mentorMessages .mentor-message")).toHaveCount(messageCount); await expect(page.locator("#mentorMessages .mentor-message")).toHaveCount(messageCount);
await page.locator("#themeToggle").click(); await page.locator("#themeToggle").click();
await expect(page.locator("html")).not.toHaveClass(/theme-switching/);
const userMessage = page.locator("#mentorMessages .mentor-message.user"); const userMessage = page.locator("#mentorMessages .mentor-message.user");
const darkUserMessageStyle = await userMessage.evaluate((element) => ({ const darkUserMessageStyle = await userMessage.evaluate((element) => ({
background: getComputedStyle(element).backgroundColor, background: getComputedStyle(element).backgroundColor,
@@ -2888,22 +2906,25 @@ test("mentor pins, custom order and streamed replies work together", async ({ pa
const darkMessageStyle = await answer.evaluate((element) => { const darkMessageStyle = await answer.evaluate((element) => {
const style = getComputedStyle(element); const style = getComputedStyle(element);
const content = element.querySelector(".mentor-message-content"); const content = element.querySelector(".mentor-message-content");
const contentStyle = getComputedStyle(content);
const heading = element.querySelector(".mentor-answer-heading"); const heading = element.querySelector(".mentor-answer-heading");
const label = element.querySelector(".mentor-message-label"); const label = element.querySelector(".mentor-message-label");
const meta = element.querySelector("small"); const meta = element.querySelector("small");
return { return {
background: style.backgroundColor, background: style.backgroundColor,
border: style.borderTopColor,
shadow: style.boxShadow, shadow: style.boxShadow,
contentColor: getComputedStyle(content).color, contentBackground: contentStyle.backgroundColor,
contentBorder: contentStyle.borderTopColor,
contentColor: contentStyle.color,
headingColor: getComputedStyle(heading).color, headingColor: getComputedStyle(heading).color,
labelColor: getComputedStyle(label).color, labelColor: getComputedStyle(label).color,
metaColor: getComputedStyle(meta).color, metaColor: getComputedStyle(meta).color,
}; };
}); });
expect(darkMessageStyle.background).not.toBe("rgb(255, 255, 255)"); expect(darkMessageStyle.background).toBe("rgba(0, 0, 0, 0)");
expect(darkMessageStyle.border).not.toBe("rgb(255, 255, 255)");
expect(darkMessageStyle.shadow).toBe("none"); expect(darkMessageStyle.shadow).toBe("none");
expect(darkMessageStyle.contentBackground).toBe("rgb(32, 36, 40)");
expect(darkMessageStyle.contentBorder).toBe("rgb(52, 58, 64)");
expect(darkMessageStyle.contentColor).toBe("rgb(232, 234, 237)"); expect(darkMessageStyle.contentColor).toBe("rgb(232, 234, 237)");
expect(darkMessageStyle.headingColor).toBe("rgb(232, 234, 237)"); expect(darkMessageStyle.headingColor).toBe("rgb(232, 234, 237)");
expect(darkMessageStyle.labelColor).toBe("rgb(127, 137, 147)"); expect(darkMessageStyle.labelColor).toBe("rgb(127, 137, 147)");
+8 -3
View File
@@ -279,14 +279,19 @@ class FrontendContractTests(unittest.TestCase):
self.assertIn("context.fillStyle = palette.axis;", chart) self.assertIn("context.fillStyle = palette.axis;", chart)
self.assertNotIn('context.fillStyle = "#6c7983";', chart) self.assertNotIn('context.fillStyle = "#6c7983";', chart)
def test_dark_mentor_tokens_and_sentiment_bottom_clearance_are_defined(self): def test_dark_mentor_answer_bubble_and_sentiment_bottom_clearance_are_defined(self):
self.assertIn(":root[data-theme=\"dark\"] #mentorView {", self.mentor_styles) self.assertIn(":root[data-theme=\"dark\"] #mentorView {", self.mentor_styles)
self.assertIn("--mentor-ink: var(--text-primary);", self.mentor_styles) self.assertIn("--mentor-ink: var(--text-primary);", self.mentor_styles)
self.assertIn("--mentor-sub: var(--text-secondary);", self.mentor_styles) self.assertIn("--mentor-sub: var(--text-secondary);", self.mentor_styles)
self.assertIn(":root[data-theme=\"dark\"] #mentorView .mentor-message {", self.mentor_styles) self.assertIn(":root[data-theme=\"dark\"] #mentorView .mentor-message {", self.mentor_styles)
self.assertIn("border-color: var(--line-soft);", self.mentor_styles) self.assertIn("background: transparent;", self.mentor_styles)
self.assertIn("background: var(--surface-subtle);", self.mentor_styles)
self.assertIn("box-shadow: none;", self.mentor_styles) self.assertIn("box-shadow: none;", self.mentor_styles)
self.assertIn(
':root[data-theme="dark"] #mentorView .mentor-message.assistant .mentor-message-content {',
self.mentor_styles,
)
self.assertIn("border-color: var(--border);", self.mentor_styles)
self.assertIn("background: var(--surface-muted);", self.mentor_styles)
self.assertIn("#sentimentCycleView .sentiment-history-frame {", self.sentiment_styles) self.assertIn("#sentimentCycleView .sentiment-history-frame {", self.sentiment_styles)
self.assertIn("margin-bottom: var(--card-gap);", self.sentiment_styles) self.assertIn("margin-bottom: var(--card-gap);", self.sentiment_styles)
self.assertIn("padding-bottom: var(--card-gap);", self.sentiment_styles) self.assertIn("padding-bottom: var(--card-gap);", self.sentiment_styles)
+22
View File
@@ -59,6 +59,28 @@ class JobRunnerTests(unittest.TestCase):
release.set() release.set()
self.assertTrue(self.runner.wait_for_idle()) self.assertTrue(self.runner.wait_for_idle())
def test_database_claim_rejects_the_same_job_from_another_runner(self) -> None:
other_runner = InProcessJobRunner(JobRegistry.load(), self.repository)
entered = threading.Event()
release = threading.Event()
calls = []
def wait() -> None:
calls.append("first")
entered.set()
release.wait(2)
self.assertTrue(self.runner.submit("screener.automatic", "shared-key", wait))
self.assertTrue(entered.wait(1))
self.assertFalse(
other_runner.run_inline(
"screener.automatic", "shared-key", lambda: calls.append("second")
)
)
release.set()
self.assertTrue(self.runner.wait_for_idle())
self.assertEqual(calls, ["first"])
def test_failed_status_payload_is_recorded_as_failure(self) -> None: def test_failed_status_payload_is_recorded_as_failure(self) -> None:
self.assertTrue( self.assertTrue(
self.runner.run_inline( self.runner.run_inline(
+136
View File
@@ -0,0 +1,136 @@
from __future__ import annotations
import json
import unittest
from types import SimpleNamespace
from backend.features.system.health import HealthServiceMixin
from backend.features.system.routes import SystemRoutesMixin
class _Connection:
def __enter__(self):
return self
def __exit__(self, *_args):
return None
def execute(self, _query: str):
return self
def fetchone(self):
return (1,)
class _Database:
def connect(self):
return _Connection()
def status(self):
return {
"database": "secret-review.db",
"snapshot_dates": 31,
"updated_at": "2026-08-07T11:34:55+08:00",
"last_sync": {
"id": 991,
"trade_date": "20260807",
"source": "tushare",
"status": "success",
"finished_at": "2026-08-07T11:34:55+08:00",
"message": "https://provider.invalid?token=secret",
},
}
class _Service(HealthServiceMixin):
def __init__(self):
self.database = _Database()
self.jobs = SimpleNamespace(
repository=SimpleNamespace(
recent=lambda _limit: [
{
"id": 77,
"status": "success",
"finished_at": "2026-08-07T11:34:55+08:00",
"message": "secret upstream response",
}
]
)
)
self._system_credentials = {
"tushare_token": "secret-token",
"primary_model_id": "primary-id",
"fallback_model_id": "fallback-id",
}
@property
def configured(self):
return bool(self._system_credentials.get("tushare_token"))
def _platform_llm_profile(self):
return {
"primary": {
"api_key": "secret-primary-key",
"base_url": "https://models.invalid/v1",
"model": "secret-primary-model",
},
"fallback": {
"api_key": "secret-fallback-key",
"base_url": "https://models.invalid/v1",
"model": "secret-fallback-model",
},
}
@staticmethod
def _profile_configured(profile):
return all(profile.get(key) for key in ("api_key", "base_url", "model"))
class SystemHealthTests(unittest.TestCase):
def test_public_route_uses_the_service_health_contract(self):
handler = SystemRoutesMixin()
expected = {"ok": True, "components": {"process": {"status": "ready"}}}
handler.application_service = SimpleNamespace(health_status=lambda: expected)
responses = []
handler.send_json = responses.append
handled = handler._handle_system_public_get(SimpleNamespace(path="/api/health"))
self.assertTrue(handled)
self.assertEqual(responses, [expected])
def test_health_distinguishes_runtime_components(self):
result = _Service().health_status()
self.assertTrue(result["ok"])
self.assertEqual(result["status"], "ready")
self.assertEqual(
set(result["components"]),
{"process", "database", "data_sources", "jobs", "models"},
)
self.assertEqual(result["components"]["data_sources"]["latest_trade_date"], "20260807")
self.assertEqual(result["components"]["models"]["primary"], "ready")
self.assertEqual(result["components"]["models"]["fallback"], "ready")
def test_public_health_never_exposes_engineering_or_secret_fields(self):
payload = json.dumps(_Service().health_status(), ensure_ascii=False).lower()
for forbidden in (
"tushare",
"ifind",
"secret",
"token",
"https://",
".db",
"primary-id",
"fallback-id",
"model",
):
if forbidden == "model":
self.assertNotIn("secret-primary-model", payload)
else:
self.assertNotIn(forbidden, payload)
if __name__ == "__main__":
unittest.main()