migration: preserve mentor and llm streaming slice
This commit is contained in:
+7
-726
@@ -18,6 +18,8 @@ from backend.bootstrap.container import build_application_container
|
||||
from backend.bootstrap.settings import load_runtime_settings
|
||||
from backend.http import HttpTransportMixin
|
||||
from backend.llm import LLMGateway, LLMGatewayError
|
||||
from backend.llm.http import LLMHttpMixin
|
||||
from backend.llm.service import LLMServiceMixin
|
||||
from backend.features.market import ChartDataError, MarketServiceMixin
|
||||
from backend.bootstrap.config import (
|
||||
DATA_DIR,
|
||||
@@ -39,14 +41,12 @@ from heaven_engine import (
|
||||
build_personal_field,
|
||||
hexagram_from_lines,
|
||||
)
|
||||
from backend.data.providers.ifind_client import IfindError
|
||||
from llm_strategy import LLMCompilerError, test_llm_connection
|
||||
from mentor_agent import MentorAgentError, stream_with_mentor
|
||||
from backend.features.accounts.http import AccountHttpMixin
|
||||
from backend.features.accounts.security import SecretVault
|
||||
from backend.features.accounts.service import AccountService
|
||||
from backend.features.auction import AuctionServiceMixin
|
||||
from backend.features.dragon_tiger import DragonTigerServiceMixin
|
||||
from backend.features.mentor import MentorHttpMixin, MentorServiceMixin
|
||||
from backend.features.pools import PoolServiceMixin
|
||||
from backend.features.popularity import PopularityServiceMixin
|
||||
from backend.features.rotation import RotationServiceMixin
|
||||
@@ -76,44 +76,6 @@ LEGACY_SECRET_KEYS = {
|
||||
"LLM_FALLBACK_MODEL",
|
||||
}
|
||||
|
||||
MENTOR_DATA_PROFILES = {
|
||||
"emotion": {
|
||||
"kobe92-perspective", "niepanchongsheng-perspective",
|
||||
"chaojiyangjia-perspective", "tuixuechaogu-perspective",
|
||||
"chenxiaoqun-perspective", "zhiyechaoshou-perspective",
|
||||
},
|
||||
"first_board": {
|
||||
"beijingchaojia-perspective", "chuangshiji-perspective",
|
||||
"xuxiang-perspective", "foshanwuyingjiao-perspective",
|
||||
},
|
||||
"leader": {
|
||||
"zhaolaoge-perspective", "fangxinxia-perspective",
|
||||
"xiaoe-perspective", "sunge-perspective", "liuyizhonglu-perspective",
|
||||
},
|
||||
"trend": {
|
||||
"zhangdetao-perspective", "zhangmengzhu-perspective",
|
||||
"zuoshouxinyi-perspective",
|
||||
},
|
||||
"low_absorption": {
|
||||
"qiaobangzhu-perspective", "asking-perspective",
|
||||
"longfeihu-perspective", "ruihexian-perspective",
|
||||
},
|
||||
"macro": {"shuipi-perspective"},
|
||||
}
|
||||
|
||||
MENTOR_INDEX_UNIVERSE = (
|
||||
("000001.SH", "上证指数"), ("399001.SZ", "深证成指"),
|
||||
("399006.SZ", "创业板指"), ("000016.SH", "上证50"),
|
||||
("000300.SH", "沪深300"), ("000905.SH", "中证500"),
|
||||
("000852.SH", "中证1000"), ("932000.CSI", "中证2000"),
|
||||
)
|
||||
|
||||
MENTOR_ETF_UNIVERSE = (
|
||||
("510050.SH", "上证50ETF"), ("510300.SH", "沪深300ETF"),
|
||||
("510500.SH", "中证500ETF"), ("512100.SH", "中证1000ETF"),
|
||||
)
|
||||
|
||||
|
||||
class DashboardService(
|
||||
MarketServiceMixin,
|
||||
SentimentServiceMixin,
|
||||
@@ -124,6 +86,8 @@ class DashboardService(
|
||||
PopularityServiceMixin,
|
||||
DragonTigerServiceMixin,
|
||||
ScreenerServiceMixin,
|
||||
MentorServiceMixin,
|
||||
LLMServiceMixin,
|
||||
):
|
||||
def __init__(self) -> None:
|
||||
runtime = load_runtime_settings()
|
||||
@@ -286,207 +250,10 @@ class DashboardService(
|
||||
def token(self) -> str:
|
||||
return str(self._system_credentials.get("tushare_token") or "")
|
||||
|
||||
def _personal_llm_profile(self) -> dict[str, Any]:
|
||||
credentials = self._credentials()
|
||||
return {
|
||||
"source": "personal",
|
||||
"primary": {
|
||||
"api_key": credentials["llm_primary_api_key"],
|
||||
"base_url": credentials["llm_primary_base_url"],
|
||||
"model": credentials["llm_primary_model"],
|
||||
},
|
||||
"fallback": {
|
||||
"api_key": credentials["llm_fallback_api_key"],
|
||||
"base_url": credentials["llm_fallback_base_url"],
|
||||
"model": credentials["llm_fallback_model"],
|
||||
},
|
||||
}
|
||||
|
||||
def _platform_llm_profile(self) -> dict[str, Any]:
|
||||
models = {
|
||||
str(item.get("id") or ""): item
|
||||
for item in self._system_credentials.get("llm_models") or []
|
||||
if isinstance(item, dict) and item.get("id")
|
||||
}
|
||||
|
||||
def selected(role: str) -> dict[str, str]:
|
||||
item = models.get(str(self._system_credentials.get(f"{role}_model_id") or ""), {})
|
||||
return {
|
||||
"id": str(item.get("id") or ""),
|
||||
"name": str(item.get("name") or ""),
|
||||
"api_key": str(item.get("api_key") or ""),
|
||||
"base_url": str(item.get("base_url") or ""),
|
||||
"model": str(item.get("model") or ""),
|
||||
}
|
||||
|
||||
return {
|
||||
"source": "platform",
|
||||
"primary": selected("primary"),
|
||||
"fallback": selected("fallback"),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _profile_configured(profile: dict[str, str]) -> bool:
|
||||
return bool(profile.get("api_key") and profile.get("base_url") and profile.get("model"))
|
||||
|
||||
def membership(self) -> dict[str, Any]:
|
||||
return self.accounts.membership()
|
||||
|
||||
def _resolved_llm_profile(self) -> dict[str, Any]:
|
||||
platform = self._platform_llm_profile()
|
||||
platform_ready = self.membership()["active"] and self._profile_configured(platform["primary"])
|
||||
if platform_ready:
|
||||
return platform
|
||||
return {"source": "none", "primary": {}, "fallback": {}}
|
||||
|
||||
@property
|
||||
def llm_primary_api_key(self) -> str:
|
||||
return str(self._resolved_llm_profile()["primary"].get("api_key") or "")
|
||||
|
||||
@property
|
||||
def llm_primary_base_url(self) -> str:
|
||||
return str(self._resolved_llm_profile()["primary"].get("base_url") or "")
|
||||
|
||||
@property
|
||||
def llm_primary_model(self) -> str:
|
||||
return str(self._resolved_llm_profile()["primary"].get("model") or "")
|
||||
|
||||
@property
|
||||
def llm_fallback_api_key(self) -> str:
|
||||
return str(self._resolved_llm_profile()["fallback"].get("api_key") or "")
|
||||
|
||||
@property
|
||||
def llm_fallback_base_url(self) -> str:
|
||||
return str(self._resolved_llm_profile()["fallback"].get("base_url") or "")
|
||||
|
||||
@property
|
||||
def llm_fallback_model(self) -> str:
|
||||
return str(self._resolved_llm_profile()["fallback"].get("model") or "")
|
||||
|
||||
@property
|
||||
def llm_source(self) -> str:
|
||||
return str(self._resolved_llm_profile().get("source") or "none")
|
||||
|
||||
@property
|
||||
def llm_configured(self) -> bool:
|
||||
return bool(self.llm_primary_api_key and self.llm_primary_model)
|
||||
|
||||
@property
|
||||
def llm_fallback_configured(self) -> bool:
|
||||
return bool(
|
||||
self.llm_fallback_api_key
|
||||
and self.llm_fallback_base_url
|
||||
and self.llm_fallback_model
|
||||
)
|
||||
|
||||
def save_llm_settings(
|
||||
self,
|
||||
primary: dict[str, Any],
|
||||
fallback: dict[str, Any],
|
||||
fallback_enabled: bool,
|
||||
) -> None:
|
||||
personal = self._personal_llm_profile()
|
||||
primary_profile = self._validate_llm_profile(
|
||||
primary,
|
||||
personal["primary"],
|
||||
required=True,
|
||||
label="主模型",
|
||||
)
|
||||
if fallback_enabled:
|
||||
fallback_profile = self._validate_llm_profile(
|
||||
fallback,
|
||||
personal["fallback"],
|
||||
required=True,
|
||||
label="辅助模型",
|
||||
)
|
||||
else:
|
||||
fallback_profile = {"api_key": "", "base_url": "", "model": ""}
|
||||
credentials = self._credentials()
|
||||
credentials.update(
|
||||
{
|
||||
"llm_primary_api_key": primary_profile["api_key"],
|
||||
"llm_primary_base_url": primary_profile["base_url"],
|
||||
"llm_primary_model": primary_profile["model"],
|
||||
"llm_fallback_api_key": fallback_profile["api_key"],
|
||||
"llm_fallback_base_url": fallback_profile["base_url"],
|
||||
"llm_fallback_model": fallback_profile["model"],
|
||||
}
|
||||
)
|
||||
self._save_credentials(credentials)
|
||||
|
||||
def save_llm_mode(self, mode: str) -> None:
|
||||
raise ValueError("LLM 算力由管理员统一配置,会员账号自动使用平台模型。")
|
||||
|
||||
def test_llm_profile(self, role: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
personal = self._personal_llm_profile()
|
||||
if role == "primary":
|
||||
current = personal["primary"]
|
||||
label = "主模型"
|
||||
elif role == "fallback":
|
||||
current = personal["fallback"]
|
||||
label = "辅助模型"
|
||||
else:
|
||||
raise ValueError("模型角色不支持。")
|
||||
profile = self._validate_llm_profile(payload, current, required=True, label=label)
|
||||
try:
|
||||
return self.llm_gateway.probe(
|
||||
profile,
|
||||
lambda model: test_llm_connection(
|
||||
model.api_key, model.base_url, model.model
|
||||
),
|
||||
)
|
||||
except LLMCompilerError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
|
||||
@staticmethod
|
||||
def _validate_llm_profile(
|
||||
payload: dict[str, Any],
|
||||
current: dict[str, str],
|
||||
required: bool,
|
||||
label: str,
|
||||
) -> dict[str, str]:
|
||||
api_key = str(payload.get("api_key") or current.get("api_key") or "").strip()
|
||||
base_url = str(payload.get("base_url") or current.get("base_url") or "").strip().rstrip("/")
|
||||
model = str(payload.get("model") or current.get("model") or "").strip()
|
||||
if not required and not any((api_key, base_url, model)):
|
||||
return {"api_key": "", "base_url": "", "model": ""}
|
||||
parsed = urlparse(base_url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise ValueError(f"{label} Base URL 格式不正确。")
|
||||
if not api_key or len(api_key) > 300:
|
||||
raise ValueError(f"{label} API Key 不能为空或过长。")
|
||||
if not model or len(model) > 100:
|
||||
raise ValueError(f"{label}模型名称不能为空或过长。")
|
||||
return {"api_key": api_key, "base_url": base_url, "model": model}
|
||||
|
||||
def llm_access_status(self) -> dict[str, Any]:
|
||||
platform = self._platform_llm_profile()
|
||||
membership = self.membership()
|
||||
limit = max(1, int(self._system_credentials.get("member_daily_limit") or 50))
|
||||
used = self._platform_usage_today() if membership["active"] else 0
|
||||
resolved = self._resolved_llm_profile()
|
||||
return {
|
||||
"mode": "platform" if membership["active"] else "locked",
|
||||
"resolved_source": resolved.get("source") or "none",
|
||||
"resolved_model": str(resolved.get("primary", {}).get("model") or ""),
|
||||
"platform_configured": self._profile_configured(platform["primary"]),
|
||||
"membership": membership,
|
||||
"daily_limit": limit,
|
||||
"used_today": used,
|
||||
"remaining_calls": None if membership["is_admin"] else max(0, limit - used),
|
||||
}
|
||||
|
||||
def _platform_usage_today(self) -> int:
|
||||
return self._platform_usage_today_for_user(self.current_user_id)
|
||||
|
||||
def _platform_usage_today_for_user(self, user_id: int) -> int:
|
||||
now = datetime.now().astimezone()
|
||||
start = now.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(timezone.utc)
|
||||
return self.database.count_llm_usage_since(
|
||||
user_id,
|
||||
"platform",
|
||||
start.isoformat(timespec="seconds"),
|
||||
)
|
||||
|
||||
def system_status(self) -> dict[str, Any]:
|
||||
platform = self._platform_llm_profile()
|
||||
@@ -625,28 +392,6 @@ class DashboardService(
|
||||
self._save_system_credentials(current)
|
||||
return self.system_status()
|
||||
|
||||
def test_system_llm_profile(self, model_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
current = next(
|
||||
(
|
||||
item
|
||||
for item in self._system_credentials.get("llm_models") or []
|
||||
if str(item.get("id") or "") == model_id
|
||||
),
|
||||
{},
|
||||
)
|
||||
label = validate_text(payload.get("name") or current.get("name"), "模型名称", 50, required=True)
|
||||
profile = self._validate_llm_profile(
|
||||
payload, current, required=True, label=label
|
||||
)
|
||||
try:
|
||||
return self.llm_gateway.probe(
|
||||
profile,
|
||||
lambda model: test_llm_connection(
|
||||
model.api_key, model.base_url, model.model
|
||||
),
|
||||
)
|
||||
except LLMCompilerError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
|
||||
def admin_users(self) -> list[dict[str, Any]]:
|
||||
return self.accounts.admin_users(self._platform_usage_today_for_user)
|
||||
@@ -967,144 +712,6 @@ class DashboardService(
|
||||
}
|
||||
|
||||
|
||||
def mentor_setup(self, trade_date: str) -> dict[str, Any]:
|
||||
normalized_date = normalize_date(trade_date)
|
||||
mentors = [
|
||||
skill.public()
|
||||
for skill in self.mentor_skills.list_skills(
|
||||
include_private=self.membership()["is_admin"]
|
||||
)
|
||||
]
|
||||
if not mentors:
|
||||
raise ValueError("游资skills 目录中没有可用的 SKILL.md。")
|
||||
stored_preferences = self.database.list_mentor_preferences(self.current_user_id)
|
||||
preferences = {item["mentor_id"]: item for item in stored_preferences}
|
||||
for default_order, mentor in enumerate(mentors):
|
||||
preference = preferences.get(str(mentor.get("id") or ""), {})
|
||||
mentor["pinned"] = bool(preference.get("pinned"))
|
||||
mentor["sort_order"] = int(preference.get("sort_order", 10000 + default_order))
|
||||
mentors.sort(
|
||||
key=lambda item: (
|
||||
not bool(item.get("pinned")),
|
||||
int(item.get("sort_order") or 0),
|
||||
)
|
||||
)
|
||||
for sort_order, mentor in enumerate(mentors):
|
||||
mentor["sort_order"] = sort_order
|
||||
snapshot = self.database.get_snapshot(normalized_date)
|
||||
actual_date = str((snapshot or {}).get("meta", {}).get("trade_date") or normalized_date)
|
||||
return {
|
||||
"trade_date": actual_date,
|
||||
"mentors": mentors,
|
||||
"preferences_configured": bool(stored_preferences),
|
||||
"llm": {
|
||||
"configured": self.llm_configured,
|
||||
"model": self.llm_primary_model if self.llm_configured else "",
|
||||
"fallback_configured": self.llm_fallback_configured,
|
||||
"fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "",
|
||||
},
|
||||
}
|
||||
|
||||
def save_mentor_preferences(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
available_ids = [
|
||||
skill.skill_id
|
||||
for skill in self.mentor_skills.list_skills(
|
||||
include_private=self.membership()["is_admin"]
|
||||
)
|
||||
]
|
||||
available = set(available_ids)
|
||||
raw_order = payload.get("order")
|
||||
raw_pinned = payload.get("pinned")
|
||||
if not isinstance(raw_order, list) or not isinstance(raw_pinned, list):
|
||||
raise ValueError("问师排序格式不正确。")
|
||||
ordered_ids: list[str] = []
|
||||
for raw_id in raw_order:
|
||||
mentor_id = validate_text(raw_id, "问师角色", 100, required=True)
|
||||
if mentor_id not in available:
|
||||
raise ValueError("问师排序中包含不可用的思维模型。")
|
||||
if mentor_id not in ordered_ids:
|
||||
ordered_ids.append(mentor_id)
|
||||
ordered_ids.extend(mentor_id for mentor_id in available_ids if mentor_id not in ordered_ids)
|
||||
pinned_ids = {
|
||||
validate_text(raw_id, "问师角色", 100, required=True)
|
||||
for raw_id in raw_pinned
|
||||
}
|
||||
if not pinned_ids.issubset(available):
|
||||
raise ValueError("问师置顶中包含不可用的思维模型。")
|
||||
self.database.save_mentor_preferences(
|
||||
self.current_user_id, ordered_ids, pinned_ids
|
||||
)
|
||||
return {"saved": True}
|
||||
|
||||
def mentor_stream(self, payload: dict[str, Any]):
|
||||
mentor_id = validate_text(payload.get("mentor_id"), "问师角色", 100, required=True)
|
||||
question = validate_text(payload.get("question"), "问题", 2000, required=True)
|
||||
trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
|
||||
history = self._validate_mentor_history(payload.get("history") or [])
|
||||
skill = self.mentor_skills.get_skill(
|
||||
mentor_id, include_private=self.membership()["is_admin"]
|
||||
)
|
||||
context = self._build_mentor_context(trade_date, question, skill)
|
||||
|
||||
def generate():
|
||||
answer_parts: list[str] = []
|
||||
events = self.llm_gateway.stream(
|
||||
"mentor",
|
||||
f"mentor-skill-v1:{skill.skill_id}",
|
||||
lambda profile: stream_with_mentor(
|
||||
skill,
|
||||
context,
|
||||
question,
|
||||
history,
|
||||
profile.api_key,
|
||||
profile.base_url,
|
||||
profile.model,
|
||||
),
|
||||
(MentorAgentError,),
|
||||
)
|
||||
for event in events:
|
||||
if event.kind == "delta":
|
||||
chunk = str(event.value or "")
|
||||
answer_parts.append(chunk)
|
||||
yield {"type": "delta", "content": chunk}
|
||||
elif event.kind == "complete":
|
||||
self.database.save_mentor_exchange(
|
||||
self.current_user_id,
|
||||
mentor_id,
|
||||
trade_date,
|
||||
question,
|
||||
"".join(answer_parts).strip(),
|
||||
context["data_trade_date"],
|
||||
)
|
||||
yield {
|
||||
"type": "meta",
|
||||
"data_trade_date": context["data_trade_date"],
|
||||
"notice": "智能解读已自动切换可用服务。"
|
||||
if event.role == "fallback"
|
||||
else "",
|
||||
}
|
||||
|
||||
return generate()
|
||||
|
||||
def mentor_messages(self, mentor_id: str, trade_date: str) -> list[dict[str, Any]]:
|
||||
mentor_id = validate_text(mentor_id, "问师角色", 100, required=True)
|
||||
trade_date = normalize_date(trade_date)
|
||||
self.mentor_skills.get_skill(
|
||||
mentor_id, include_private=self.membership()["is_admin"]
|
||||
)
|
||||
return self.database.list_mentor_messages(
|
||||
self.current_user_id, mentor_id, trade_date
|
||||
)
|
||||
|
||||
def clear_mentor_messages(self, mentor_id: str, trade_date: str) -> int:
|
||||
mentor_id = validate_text(mentor_id, "问师角色", 100, required=True)
|
||||
trade_date = normalize_date(trade_date)
|
||||
self.mentor_skills.get_skill(
|
||||
mentor_id, include_private=self.membership()["is_admin"]
|
||||
)
|
||||
return self.database.delete_mentor_messages(
|
||||
self.current_user_id, mentor_id, trade_date
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _heaven_manual_schema(market_mode: str) -> dict[str, dict[str, Any]]:
|
||||
@@ -2383,274 +1990,6 @@ class DashboardService(
|
||||
)
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _validate_mentor_history(raw_history: Any) -> list[dict[str, str]]:
|
||||
if not isinstance(raw_history, list):
|
||||
raise ValueError("问师对话历史格式不正确。")
|
||||
history = []
|
||||
total_length = 0
|
||||
for item in raw_history[-12:]:
|
||||
if not isinstance(item, dict) or item.get("role") not in {"user", "assistant"}:
|
||||
raise ValueError("问师对话历史包含无效消息。")
|
||||
content = str(item.get("content") or "").strip()
|
||||
if not content or len(content) > 5000:
|
||||
raise ValueError("问师对话历史消息为空或过长。")
|
||||
total_length += len(content)
|
||||
if total_length > 24_000:
|
||||
raise ValueError("问师对话历史过长,请清空后重新提问。")
|
||||
history.append({"role": item["role"], "content": content})
|
||||
return history
|
||||
|
||||
def _build_mentor_context(
|
||||
self, trade_date: str, question: str, skill: Any | None = None
|
||||
) -> dict[str, Any]:
|
||||
dashboard = self.get_dashboard(trade_date)
|
||||
data_trade_date = normalize_date(
|
||||
str(dashboard.get("meta", {}).get("trade_date") or trade_date)
|
||||
)
|
||||
regime = self.screener.detect_regime(data_trade_date)
|
||||
limits = list(dashboard.get("limits") or [])
|
||||
broken = list(dashboard.get("broken") or [])
|
||||
down_limits = list(dashboard.get("down_limits") or [])
|
||||
yesterday_limits = list(dashboard.get("yesterday_limits") or [])
|
||||
all_stocks = limits + broken + down_limits + yesterday_limits
|
||||
matched_rows = []
|
||||
codes = re.findall(r"(?<!\d)\d{6}(?!\d)", question)[:3]
|
||||
for row in all_stocks:
|
||||
code = str(row.get("code") or "")
|
||||
name = str(row.get("name") or "")
|
||||
if code in codes or (len(name) >= 2 and name in question):
|
||||
if not any(item.get("code") == code for item in matched_rows):
|
||||
matched_rows.append(row)
|
||||
for row in matched_rows:
|
||||
code = str(row.get("code") or "")
|
||||
if code and code not in codes:
|
||||
codes.append(code)
|
||||
stock_details = []
|
||||
for code in codes[:2]:
|
||||
try:
|
||||
detail = self.get_stock_detail(code, data_trade_date)
|
||||
stock_details.append(
|
||||
{
|
||||
"stock": detail.get("stock") or {},
|
||||
"moneyflow": detail.get("moneyflow") or {},
|
||||
"recent_prices": (detail.get("prices") or [])[-20:],
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
stock_details.append({"code": code, "error": str(exc)})
|
||||
|
||||
skill_id = str(getattr(skill, "skill_id", "") or "")
|
||||
profile = next(
|
||||
(
|
||||
profile_name
|
||||
for profile_name, skill_ids in MENTOR_DATA_PROFILES.items()
|
||||
if skill_id in skill_ids
|
||||
),
|
||||
"balanced",
|
||||
)
|
||||
dragon_tiger = None
|
||||
if any(keyword in question for keyword in ("龙虎榜", "席位", "机构", "游资")):
|
||||
try:
|
||||
dragon_payload = self.get_dragon_tiger(data_trade_date)
|
||||
rows = list(dragon_payload.get("rows") or [])
|
||||
matched_dragon = [row for row in rows if str(row.get("code") or "") in codes]
|
||||
leading_dragon = sorted(
|
||||
rows,
|
||||
key=lambda row: abs(float(row.get("net_buy_million") or 0)),
|
||||
reverse=True,
|
||||
)[:12]
|
||||
dragon_tiger = {
|
||||
"summary": dragon_payload.get("summary") or {},
|
||||
"matched": matched_dragon,
|
||||
"largest_net_flows": leading_dragon,
|
||||
}
|
||||
except Exception as exc:
|
||||
dragon_tiger = {"error": str(exc)}
|
||||
|
||||
context: dict[str, Any] = {
|
||||
"data_trade_date": data_trade_date,
|
||||
"data_profile": profile,
|
||||
"overview": dashboard.get("overview") or {},
|
||||
"market_regime": regime,
|
||||
"recent_market_history": self.database.snapshot_summaries(data_trade_date, 10),
|
||||
"question_matched_stocks": matched_rows[:10],
|
||||
"stock_details": stock_details,
|
||||
}
|
||||
|
||||
ordered_limits = sorted(
|
||||
limits,
|
||||
key=lambda row: (
|
||||
float(row.get("streak") or 0),
|
||||
float(row.get("amount_billion") or 0),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
if profile in {"emotion", "balanced"}:
|
||||
context.update(
|
||||
{
|
||||
"limit_ladder": dashboard.get("ladders") or [],
|
||||
"limit_performance": dashboard.get("limit_performance") or [],
|
||||
"hot_sectors": (dashboard.get("sectors") or [])[:15],
|
||||
"sector_rotation": (dashboard.get("sector_rotation") or [])[:15],
|
||||
"limit_up_stocks": ordered_limits[:30],
|
||||
"broken_stocks": sorted(
|
||||
broken,
|
||||
key=lambda row: float(row.get("amount_billion") or 0),
|
||||
reverse=True,
|
||||
)[:20],
|
||||
"limit_down_stocks": down_limits[:20],
|
||||
"yesterday_limit_performance": sorted(
|
||||
yesterday_limits,
|
||||
key=lambda row: float(row.get("change") or 0),
|
||||
reverse=True,
|
||||
)[:20],
|
||||
}
|
||||
)
|
||||
elif profile == "first_board":
|
||||
context.update(
|
||||
{
|
||||
"first_board_environment": {
|
||||
"seal_rate": (dashboard.get("overview") or {}).get("seal_rate"),
|
||||
"broken_count": len(broken),
|
||||
"first_boards": [row for row in ordered_limits if int(row.get("streak") or 1) == 1][:35],
|
||||
"broken_stocks": sorted(
|
||||
broken,
|
||||
key=lambda row: float(row.get("amount_billion") or 0),
|
||||
reverse=True,
|
||||
)[:30],
|
||||
},
|
||||
"hot_sectors": (dashboard.get("sectors") or [])[:12],
|
||||
}
|
||||
)
|
||||
elif profile == "leader":
|
||||
context.update(
|
||||
{
|
||||
"limit_ladder": dashboard.get("ladders") or [],
|
||||
"multi_board_leaders": [
|
||||
row for row in ordered_limits if int(row.get("streak") or 0) >= 2
|
||||
][:25],
|
||||
"hot_sectors": (dashboard.get("sectors") or [])[:12],
|
||||
"sector_rotation": (dashboard.get("sector_rotation") or [])[:12],
|
||||
}
|
||||
)
|
||||
try:
|
||||
popularity = self.popularity(data_trade_date)
|
||||
context["popularity_core"] = {
|
||||
"consensus": [
|
||||
row for row in (popularity.get("combined") or [])
|
||||
if row.get("dual_source")
|
||||
][:10],
|
||||
"ths": (popularity.get("ths") or [])[:10],
|
||||
"eastmoney": (popularity.get("dc") or [])[:10],
|
||||
}
|
||||
except Exception:
|
||||
context["popularity_core"] = {"unavailable": True}
|
||||
elif profile == "trend":
|
||||
context.update(
|
||||
{
|
||||
"index_momentum": self._mentor_market_matrix(
|
||||
data_trade_date, MENTOR_INDEX_UNIVERSE
|
||||
),
|
||||
"sector_rotation": (dashboard.get("sector_rotation") or [])[:20],
|
||||
"hot_sectors": (dashboard.get("sectors") or [])[:20],
|
||||
"market_breadth": {
|
||||
key: (dashboard.get("overview") or {}).get(key)
|
||||
for key in ("up_count", "down_count", "flat_count", "amount_billion")
|
||||
},
|
||||
}
|
||||
)
|
||||
elif profile == "low_absorption":
|
||||
context.update(
|
||||
{
|
||||
"yesterday_limit_performance": sorted(
|
||||
yesterday_limits,
|
||||
key=lambda row: float(row.get("change") or 0),
|
||||
reverse=True,
|
||||
)[:35],
|
||||
"broken_stocks": broken[:20],
|
||||
"hot_sectors": (dashboard.get("sectors") or [])[:12],
|
||||
}
|
||||
)
|
||||
elif profile == "macro":
|
||||
context.update(
|
||||
{
|
||||
"broad_indexes": self._mentor_market_matrix(
|
||||
data_trade_date, MENTOR_INDEX_UNIVERSE
|
||||
),
|
||||
"core_etfs": self._mentor_market_matrix(
|
||||
data_trade_date, MENTOR_ETF_UNIVERSE
|
||||
),
|
||||
"market_style": {
|
||||
"amount_billion": (dashboard.get("overview") or {}).get("amount_billion"),
|
||||
"breadth": {
|
||||
"up": (dashboard.get("overview") or {}).get("up_count"),
|
||||
"down": (dashboard.get("overview") or {}).get("down_count"),
|
||||
},
|
||||
"top_sectors": (dashboard.get("sectors") or [])[:15],
|
||||
},
|
||||
"unavailable_data": [
|
||||
"政策原文与隔夜资讯尚未接入",
|
||||
"汇率、利率和商品宏观序列当前不可用",
|
||||
],
|
||||
}
|
||||
)
|
||||
if dragon_tiger is not None:
|
||||
context["dragon_tiger"] = dragon_tiger
|
||||
return context
|
||||
|
||||
def _mentor_market_matrix(
|
||||
self, trade_date: str, universe: tuple[tuple[str, str], ...]
|
||||
) -> list[dict[str, Any]]:
|
||||
ifind = getattr(self, "ifind", None)
|
||||
if not ifind or not ifind.configured:
|
||||
return []
|
||||
end = datetime.strptime(trade_date, "%Y%m%d")
|
||||
start = (end - timedelta(days=45)).strftime("%Y%m%d")
|
||||
names = {code: name for code, name in universe}
|
||||
try:
|
||||
rows = ifind.history(
|
||||
list(names), ["close", "volume", "amount"], start, trade_date, cache_ttl=600
|
||||
)
|
||||
except IfindError:
|
||||
return []
|
||||
grouped: dict[str, list[dict[str, Any]]] = {}
|
||||
for row in rows:
|
||||
code = str(row.get("thscode") or "").upper()
|
||||
if code in names:
|
||||
grouped.setdefault(code, []).append(row)
|
||||
result = []
|
||||
for code, name in universe:
|
||||
series = sorted(grouped.get(code, []), key=lambda row: str(row.get("time") or ""))
|
||||
closes = []
|
||||
for row in series:
|
||||
try:
|
||||
close = float(row.get("close") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if close > 0:
|
||||
closes.append(close)
|
||||
if not closes:
|
||||
continue
|
||||
def period_return(days: int) -> float | None:
|
||||
if len(closes) <= days or closes[-days - 1] <= 0:
|
||||
return None
|
||||
return round((closes[-1] / closes[-days - 1] - 1) * 100, 2)
|
||||
previous = closes[-2] if len(closes) > 1 else 0
|
||||
result.append(
|
||||
{
|
||||
"code": code,
|
||||
"name": name,
|
||||
"close": round(closes[-1], 3),
|
||||
"change": round((closes[-1] / previous - 1) * 100, 2) if previous else None,
|
||||
"return_5d": period_return(5),
|
||||
"return_10d": period_return(10),
|
||||
"return_20d": period_return(20),
|
||||
"latest_amount": series[-1].get("amount") if series else None,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
SERVICE = DashboardService()
|
||||
|
||||
@@ -2658,6 +1997,8 @@ SERVICE = DashboardService()
|
||||
class RequestHandler(
|
||||
AccountHttpMixin,
|
||||
SystemHttpMixin,
|
||||
MentorHttpMixin,
|
||||
LLMHttpMixin,
|
||||
HttpTransportMixin,
|
||||
BaseHTTPRequestHandler,
|
||||
):
|
||||
@@ -3243,43 +2584,6 @@ class RequestHandler(
|
||||
)
|
||||
self.wfile.flush()
|
||||
|
||||
def save_llm_settings(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
SERVICE.save_llm_settings(
|
||||
body.get("primary") or {},
|
||||
body.get("fallback") or {},
|
||||
bool(body.get("fallback_enabled")),
|
||||
)
|
||||
self.send_json(
|
||||
{
|
||||
"ok": True,
|
||||
"configured": SERVICE.llm_configured,
|
||||
"model": SERVICE.llm_primary_model,
|
||||
"fallback_configured": SERVICE.llm_fallback_configured,
|
||||
"fallback_model": SERVICE.llm_fallback_model,
|
||||
}
|
||||
)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
|
||||
def save_llm_mode(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
SERVICE.save_llm_mode(str(body.get("mode") or "auto"))
|
||||
self.send_json({"ok": True, "llm_access": SERVICE.llm_access_status()})
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
|
||||
def test_llm_settings(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
role = str(body.get("role") or "")
|
||||
profile = body.get("profile") or {}
|
||||
result = SERVICE.test_llm_profile(role, profile)
|
||||
self.send_json({"ok": True, "result": result})
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
|
||||
def save_watchlist(self) -> None:
|
||||
try:
|
||||
@@ -3430,29 +2734,6 @@ class RequestHandler(
|
||||
except Exception as exc:
|
||||
self.send_json({"error": f"跟踪刷新失败:{exc}"}, HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||
|
||||
def stream_mentor_chat(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
stream = SERVICE.mentor_stream(body)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-cache, no-transform")
|
||||
self.send_header("X-Accel-Buffering", "no")
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
try:
|
||||
for event in stream:
|
||||
self._write_stream_event(event)
|
||||
self._write_stream_event({"type": "done"})
|
||||
except (ValueError, MentorAgentError) as exc:
|
||||
self._write_stream_event({"type": "error", "error": str(exc)})
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
finally:
|
||||
self.close_connection = True
|
||||
|
||||
def heaven_hexagram(self) -> None:
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user