Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
228e08a5e2 | ||
|
|
777af0cb8b | ||
|
|
a326df2a8d |
@@ -43,6 +43,7 @@ from backend.features.screener.service import (
|
||||
from backend.features.sentiment import SentimentServiceMixin
|
||||
from backend.features.sentiment.routes import SentimentRoutesMixin
|
||||
from backend.features.system import SystemHttpMixin
|
||||
from backend.features.system.health import HealthServiceMixin
|
||||
from backend.features.system.routes import SystemRoutesMixin
|
||||
from backend.features.system.service import SystemServiceMixin
|
||||
from backend.features.themes import ThemeServiceMixin
|
||||
@@ -77,6 +78,7 @@ LEGACY_SECRET_KEYS = {
|
||||
|
||||
|
||||
class DashboardService(
|
||||
HealthServiceMixin,
|
||||
SystemServiceMixin,
|
||||
AccountApplicationMixin,
|
||||
JobServiceMixin,
|
||||
|
||||
@@ -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,20 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from http import HTTPStatus
|
||||
|
||||
|
||||
class SystemRoutesMixin:
|
||||
def _handle_system_public_get(self, parsed) -> bool:
|
||||
if parsed.path == "/api/health":
|
||||
self.send_json(
|
||||
{
|
||||
"ok": True,
|
||||
"storage": "sqlite",
|
||||
"account_required": True,
|
||||
"time": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
}
|
||||
)
|
||||
self.send_json(self.application_service.health_status())
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from database import ReviewDatabase
|
||||
@@ -27,9 +27,37 @@ class SQLiteJobRunRepository:
|
||||
def start(
|
||||
self, job_id: str, idempotency_key: str, output_version: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> int:
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
stale_after_seconds: int = 0,
|
||||
) -> 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:
|
||||
# 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(
|
||||
"""
|
||||
SELECT COALESCE(MAX(attempt), 0) + 1 AS attempt FROM job_runs
|
||||
|
||||
@@ -51,8 +51,7 @@ class InProcessJobRunner:
|
||||
lock = self._lock(definition.lock_key)
|
||||
if not lock.acquire(blocking=False):
|
||||
return False
|
||||
self._execute_locked(job_id, idempotency_key, action, metadata, lock)
|
||||
return True
|
||||
return self._execute_locked(job_id, idempotency_key, action, metadata, lock)
|
||||
|
||||
def start_scheduler(
|
||||
self, callback: Callable[[], None], interval_seconds: float,
|
||||
@@ -105,13 +104,19 @@ class InProcessJobRunner:
|
||||
def _execute_locked(
|
||||
self, job_id: str, idempotency_key: str, action: JobAction,
|
||||
metadata: dict[str, Any] | None, lock: threading.Lock,
|
||||
) -> None:
|
||||
) -> bool:
|
||||
definition = self.registry.get(job_id)
|
||||
try:
|
||||
for attempt in range(1, definition.max_attempts + 1):
|
||||
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()
|
||||
try:
|
||||
result = action()
|
||||
@@ -119,7 +124,7 @@ class InProcessJobRunner:
|
||||
raise RuntimeError(str(result.get("error") or "Job reported failure"))
|
||||
elapsed_ms = round((time.perf_counter() - started) * 1000)
|
||||
self.repository.finish(run_id, "success", elapsed_ms)
|
||||
return
|
||||
return True
|
||||
except Exception as exc:
|
||||
elapsed_ms = round((time.perf_counter() - started) * 1000)
|
||||
self.repository.finish(
|
||||
@@ -127,7 +132,8 @@ class InProcessJobRunner:
|
||||
type(exc).__name__, str(exc),
|
||||
)
|
||||
if attempt >= definition.max_attempts:
|
||||
return
|
||||
return True
|
||||
return True
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
|
||||
@@ -390,7 +390,7 @@
|
||||
"/pages/popularity/foundation.css?v=20260806-1",
|
||||
"/pages/dragon-tiger/foundation.css?v=20260806-1",
|
||||
"/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/review/foundation.css?v=20260806-1"
|
||||
],
|
||||
@@ -609,16 +609,16 @@
|
||||
"bytes": 6983,
|
||||
"lines": 146
|
||||
},
|
||||
{
|
||||
"path": "backend/application.py",
|
||||
"bytes": 6837,
|
||||
"lines": 180
|
||||
},
|
||||
{
|
||||
"path": "backend/data/providers/tushare_daily.py",
|
||||
"bytes": 6837,
|
||||
"lines": 160
|
||||
},
|
||||
{
|
||||
"path": "backend/application.py",
|
||||
"bytes": 6751,
|
||||
"lines": 178
|
||||
},
|
||||
{
|
||||
"path": "backend/features/market/insights_popularity.py",
|
||||
"bytes": 6739,
|
||||
@@ -829,11 +829,6 @@
|
||||
"bytes": 1455,
|
||||
"lines": 48
|
||||
},
|
||||
{
|
||||
"path": "backend/features/system/routes.py",
|
||||
"bytes": 1423,
|
||||
"lines": 40
|
||||
},
|
||||
{
|
||||
"path": "backend/features/themes/routes.py",
|
||||
"bytes": 1337,
|
||||
@@ -844,6 +839,11 @@
|
||||
"bytes": 1195,
|
||||
"lines": 30
|
||||
},
|
||||
{
|
||||
"path": "backend/features/system/routes.py",
|
||||
"bytes": 1178,
|
||||
"lines": 32
|
||||
},
|
||||
{
|
||||
"path": "frontend/pages/ladder/page.html",
|
||||
"bytes": 1143,
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<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/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/review/foundation.css?v=20260806-1">
|
||||
</head>
|
||||
|
||||
+32
-61
@@ -250,20 +250,20 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--mentor-blue-line);
|
||||
border-radius: 50%;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--mentor-blue-soft);
|
||||
color: var(--mentor-blue-dark);
|
||||
font-size: var(--font-size-card-title);
|
||||
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);
|
||||
background: var(--market-up-soft);
|
||||
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);
|
||||
background: var(--market-down-soft);
|
||||
color: var(--market-down);
|
||||
@@ -427,61 +427,10 @@
|
||||
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 {
|
||||
flex: 0 0 auto;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: 0 0 40px;
|
||||
color: var(--mentor-sub);
|
||||
}
|
||||
|
||||
@@ -554,7 +503,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--mentor-line);
|
||||
border-radius: 50%;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
color: var(--mentor-sub);
|
||||
font-size: var(--font-size-label);
|
||||
@@ -568,6 +517,7 @@
|
||||
}
|
||||
|
||||
#mentorView .mentor-message-body {
|
||||
min-width: 0;
|
||||
max-width: var(--mentor-message-max-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -579,6 +529,20 @@
|
||||
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 {
|
||||
color: var(--mentor-faint);
|
||||
font-size: var(--font-size-caption);
|
||||
@@ -618,6 +582,7 @@
|
||||
|
||||
#mentorView .mentor-answer-paragraph {
|
||||
margin: 0 0 var(--space-8);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
#mentorView .mentor-answer-paragraph:last-child {
|
||||
@@ -630,6 +595,7 @@
|
||||
color: var(--mentor-ink);
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
#mentorView .mentor-answer-heading:first-child {
|
||||
@@ -844,7 +810,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--mentor-blue-line);
|
||||
border-radius: 50%;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--mentor-blue-soft);
|
||||
color: var(--mentor-blue-dark);
|
||||
font-size: var(--font-size-metric);
|
||||
@@ -966,11 +932,16 @@
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] #mentorView .mentor-message {
|
||||
border-color: var(--line-soft);
|
||||
background: var(--surface-subtle);
|
||||
background: transparent;
|
||||
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 {
|
||||
border-color: var(--blue-line);
|
||||
background: var(--action-soft);
|
||||
|
||||
@@ -33,16 +33,7 @@
|
||||
</aside>
|
||||
|
||||
<section class="mentor-chat-panel" aria-label="问师对话">
|
||||
<header class="mentor-chat-header">
|
||||
<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>
|
||||
<p id="activeMentorStatus" class="visually-hidden" aria-live="polite">思维模型已就绪</p>
|
||||
<div id="mentorMessages" class="mentor-messages" aria-live="polite"></div>
|
||||
<div id="mentorQuickPrompts" class="mentor-quick-prompts">
|
||||
<span class="mentor-prompt-label">开始一个话题</span>
|
||||
@@ -56,6 +47,7 @@
|
||||
<textarea id="mentorQuestion" maxlength="2000" placeholder="输入市场、板块、个股代码或交易问题"></textarea>
|
||||
<div class="mentor-composer-actions">
|
||||
<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="sendMentorQuestion" class="button primary mentor-send-button" type="submit"><i data-lucide="send-horizontal"></i><span>发送</span></button>
|
||||
</div>
|
||||
|
||||
@@ -36,14 +36,12 @@ function renderMentorWorkspace() {
|
||||
if (!setup) return;
|
||||
const selected = setup.mentors.find((item) => item.id === state.selectedMentorId) || null;
|
||||
setText("mentorDataDate", `数据日期 ${displayCompactDate(setup.trade_date)}`);
|
||||
setText("activeMentorName", selected?.name || "--");
|
||||
setText("mentorProfileName", selected?.name || "--");
|
||||
setText("mentorProfileTagline", selected?.tagline || selected?.description || "--");
|
||||
setText("mentorProfileSource", selected?.evidence?.label || "公开资料整理");
|
||||
setText("mentorProfileDataDate", `行情数据 ${displayCompactDate(setup.trade_date)}`);
|
||||
setText("activeMentorAvatar", 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) : "";
|
||||
setText("activeMentorEvidence", selected?.evidence?.note || selected?.description || "--");
|
||||
document.querySelector("#activeMentorFocus").innerHTML = (selected?.focus || []).slice(0, 4)
|
||||
@@ -287,12 +285,14 @@ function renderMentorMessages() {
|
||||
} else {
|
||||
container.innerHTML = state.mentorMessages.map((message) => `
|
||||
<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-label">${message.role === "user" ? "我" : escapeHtml(selected?.name || "问师")}</div>
|
||||
<div class="mentor-message-content">${message.role === "assistant"
|
||||
? (message.content ? formatMentorAnswer(message.content) : '<p class="mentor-loading-copy">正在读取复盘数据并推演...</p>')
|
||||
: escapeHtml(message.content)}</div>
|
||||
${message.role === "assistant"
|
||||
? `<div class="mentor-message-stack">${message.content
|
||||
? 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>' : ""}
|
||||
${renderMentorFollowUps(message)}
|
||||
${message.meta && !message.streaming ? `<small>${escapeHtml(message.meta)}</small>` : ""}
|
||||
@@ -478,12 +478,14 @@ function hideMentorNotice() {
|
||||
}
|
||||
|
||||
function formatMentorAnswer(content) {
|
||||
const blocks = [];
|
||||
const sections = [[]];
|
||||
let currentSection = sections[0];
|
||||
let headingCount = 0;
|
||||
let listType = "";
|
||||
let listItems = [];
|
||||
const flushList = () => {
|
||||
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 = [];
|
||||
listType = "";
|
||||
};
|
||||
@@ -493,18 +495,23 @@ function formatMentorAnswer(content) {
|
||||
flushList();
|
||||
return;
|
||||
}
|
||||
const heading = line.match(/^#{1,3}\s+(.+)$/);
|
||||
const heading = mentorAnswerHeading(line);
|
||||
const bullet = line.match(/^[-*]\s+(.+)$/);
|
||||
const ordered = line.match(/^\d+[.、]\s*(.+)$/);
|
||||
if (heading) {
|
||||
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)) {
|
||||
flushList();
|
||||
blocks.push('<span class="mentor-answer-rule"></span>');
|
||||
currentSection.push('<span class="mentor-answer-rule"></span>');
|
||||
} else if (line.startsWith("> ")) {
|
||||
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) {
|
||||
const nextType = bullet ? "ul" : "ol";
|
||||
if (listType && listType !== nextType) flushList();
|
||||
@@ -512,11 +519,27 @@ function formatMentorAnswer(content) {
|
||||
listItems.push(formatMentorInline(escapeHtml((bullet || ordered)[1])));
|
||||
} else {
|
||||
flushList();
|
||||
blocks.push(`<p class="mentor-answer-paragraph">${formatMentorInline(escapeHtml(line))}</p>`);
|
||||
currentSection.push(`<p class="mentor-answer-paragraph">${formatMentorInline(escapeHtml(line))}</p>`);
|
||||
}
|
||||
});
|
||||
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) {
|
||||
|
||||
@@ -222,8 +222,8 @@
|
||||
--mentor-directory-width: 280px;
|
||||
--mentor-profile-width: 272px;
|
||||
--mentor-pane-header-height: 58px;
|
||||
--mentor-avatar-size: 38px;
|
||||
--mentor-profile-avatar-size: 68px;
|
||||
--mentor-avatar-size: 46px;
|
||||
--mentor-profile-avatar-size: 80px;
|
||||
--mentor-composer-min-height: 94px;
|
||||
--mentor-message-max-width: 82%;
|
||||
|
||||
|
||||
@@ -489,8 +489,8 @@ async function mockApplication(page, authSession = session(), options = {}) {
|
||||
status: 200,
|
||||
contentType: "application/x-ndjson; charset=utf-8",
|
||||
body: [
|
||||
JSON.stringify({ type: "delta", content: "## 判断\n先看市场结构。\n\n" }),
|
||||
JSON.stringify({ type: "delta", content: "- 等待确认\n- 控制仓位" }),
|
||||
JSON.stringify({ type: "delta", content: "**判断**\n先看市场结构。\n\n" }),
|
||||
JSON.stringify({ type: "delta", content: "**操作**\n- 等待确认\n- 控制仓位" }),
|
||||
JSON.stringify({
|
||||
type: "meta",
|
||||
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 expect(page.locator("#mentorList .mentor-option")).toHaveCount(7);
|
||||
await page.locator('#mentorList [data-mentor-id="source-b"]').click();
|
||||
await expect(page.locator("#activeMentorName")).toHaveText("多源老师");
|
||||
await expect(page.locator("#activeMentorBadges")).toHaveText("B");
|
||||
await expect(page.locator("#mentorProfileName")).toHaveText("多源老师");
|
||||
await expect(page.locator("#mentorProfileBadges")).toHaveText("B");
|
||||
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 mentorChat = await page.locator("#mentorView .mentor-chat-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();
|
||||
const answer = page.locator("#mentorMessages .mentor-message.assistant").last();
|
||||
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("br")).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("#mentorMessages .mentor-message")).toHaveCount(messageCount);
|
||||
await page.locator("#themeToggle").click();
|
||||
await expect(page.locator("html")).not.toHaveClass(/theme-switching/);
|
||||
const userMessage = page.locator("#mentorMessages .mentor-message.user");
|
||||
const darkUserMessageStyle = await userMessage.evaluate((element) => ({
|
||||
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 style = getComputedStyle(element);
|
||||
const content = element.querySelector(".mentor-message-content");
|
||||
const contentStyle = getComputedStyle(content);
|
||||
const heading = element.querySelector(".mentor-answer-heading");
|
||||
const label = element.querySelector(".mentor-message-label");
|
||||
const meta = element.querySelector("small");
|
||||
return {
|
||||
background: style.backgroundColor,
|
||||
border: style.borderTopColor,
|
||||
shadow: style.boxShadow,
|
||||
contentColor: getComputedStyle(content).color,
|
||||
contentBackground: contentStyle.backgroundColor,
|
||||
contentBorder: contentStyle.borderTopColor,
|
||||
contentColor: contentStyle.color,
|
||||
headingColor: getComputedStyle(heading).color,
|
||||
labelColor: getComputedStyle(label).color,
|
||||
metaColor: getComputedStyle(meta).color,
|
||||
};
|
||||
});
|
||||
expect(darkMessageStyle.background).not.toBe("rgb(255, 255, 255)");
|
||||
expect(darkMessageStyle.border).not.toBe("rgb(255, 255, 255)");
|
||||
expect(darkMessageStyle.background).toBe("rgba(0, 0, 0, 0)");
|
||||
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.headingColor).toBe("rgb(232, 234, 237)");
|
||||
expect(darkMessageStyle.labelColor).toBe("rgb(127, 137, 147)");
|
||||
|
||||
@@ -279,14 +279,19 @@ class FrontendContractTests(unittest.TestCase):
|
||||
self.assertIn("context.fillStyle = palette.axis;", 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("--mentor-ink: var(--text-primary);", 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("border-color: var(--line-soft);", self.mentor_styles)
|
||||
self.assertIn("background: var(--surface-subtle);", self.mentor_styles)
|
||||
self.assertIn("background: transparent;", 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("margin-bottom: var(--card-gap);", self.sentiment_styles)
|
||||
self.assertIn("padding-bottom: var(--card-gap);", self.sentiment_styles)
|
||||
|
||||
@@ -59,6 +59,28 @@ class JobRunnerTests(unittest.TestCase):
|
||||
release.set()
|
||||
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:
|
||||
self.assertTrue(
|
||||
self.runner.run_inline(
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user