feat: personalize and stream mentor chat

This commit is contained in:
leefer
2026-07-23 19:24:57 +08:00
parent 771882ebf0
commit 95789c837c
11 changed files with 876 additions and 104 deletions
+132 -56
View File
@@ -50,7 +50,7 @@ from heaven_engine import (
hexagram_from_lines,
)
from llm_strategy import LLMCompilerError, compile_strategy_with_llm, test_llm_connection
from mentor_agent import MentorAgentError, MentorSkillRegistry, chat_with_mentor
from mentor_agent import MentorAgentError, MentorSkillRegistry, stream_with_mentor
from realtime_aggregator import WebRealtimeAggregator
from screener import (
FACTOR_FIELDS,
@@ -1353,11 +1353,26 @@ class DashboardService:
]
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 "",
@@ -1366,7 +1381,38 @@ class DashboardService:
},
}
def mentor_chat(self, payload: dict[str, Any]) -> dict[str, Any]:
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()))
@@ -1377,66 +1423,73 @@ class DashboardService:
context = self._build_mentor_context(trade_date, question)
source = self.ensure_llm_access("mentor")
primary_error = ""
result = None
compiler = "primary"
profiles = []
if self.llm_configured:
try:
result = chat_with_mentor(
skill,
context,
question,
history,
profiles.append(
(
"primary",
self.llm_primary_api_key,
self.llm_primary_base_url,
self.llm_primary_model,
)
except MentorAgentError as exc:
primary_error = str(exc)
if result is None and self.llm_fallback_configured:
try:
result = chat_with_mentor(
skill,
context,
question,
history,
)
if self.llm_fallback_configured:
profiles.append(
(
"fallback",
self.llm_fallback_api_key,
self.llm_fallback_base_url,
self.llm_fallback_model,
)
compiler = "fallback"
except MentorAgentError as exc:
fallback_error = str(exc)
self.record_llm_usage(
"mentor", source, self.llm_fallback_model, "failed"
)
def generate():
started = time.perf_counter()
last_error: Exception | None = None
for compiler, api_key, base_url, model in profiles:
try:
upstream = iter(
stream_with_mentor(
skill, context, question, history, api_key, base_url, model
)
)
first = next(upstream)
except (MentorAgentError, StopIteration) as exc:
last_error = exc
continue
answer_parts = [first]
yield {"type": "delta", "content": first}
try:
for chunk in upstream:
answer_parts.append(chunk)
yield {"type": "delta", "content": chunk}
except MentorAgentError as exc:
self.record_llm_usage("mentor", source, model, "failed")
raise ValueError("智能解读连接中断,请稍后重试。") from exc
answer = "".join(answer_parts).strip()
latency_ms = round((time.perf_counter() - started) * 1000)
self.database.save_mentor_exchange(
self.current_user_id,
mentor_id,
trade_date,
question,
answer,
context["data_trade_date"],
)
raise ValueError("智能解读服务暂不可用,请稍后重试。") from exc
if result is None:
self.record_llm_usage("mentor", source, self.llm_primary_model, "failed")
raise ValueError("智能解读服务暂不可用,请稍后重试。")
self.record_llm_usage(
"mentor",
source,
str(result.get("model") or ""),
"success",
int(result.get("latency_ms") or 0),
)
self.database.save_mentor_exchange(
self.current_user_id,
mentor_id,
trade_date,
question,
str(result.get("answer") or ""),
context["data_trade_date"],
)
return {
**result,
"mentor": skill.public(),
"compiler": compiler,
"requested_trade_date": trade_date,
"data_trade_date": context["data_trade_date"],
"notice": "智能解读已自动切换可用服务。" if compiler == "fallback" else "",
}
self.record_llm_usage("mentor", source, model, "success", latency_ms)
yield {
"type": "meta",
"data_trade_date": context["data_trade_date"],
"notice": "智能解读已自动切换可用服务。"
if compiler == "fallback"
else "",
}
return
failed_model = profiles[-1][3] if profiles else self.llm_primary_model
self.record_llm_usage("mentor", source, failed_model, "failed")
raise ValueError("智能解读服务暂不可用,请稍后重试。") from last_error
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)
@@ -3929,8 +3982,15 @@ class RequestHandler(BaseHTTPRequestHandler):
if parsed.path == "/api/screener/tracking/refresh":
self.refresh_screener_tracking()
return
if parsed.path == "/api/mentors/preferences":
try:
result = SERVICE.save_mentor_preferences(self.read_json_body())
self.send_json({"ok": True, **result})
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/mentors/chat":
self.mentor_chat()
self.stream_mentor_chat()
return
if parsed.path == "/api/heaven/hexagram":
self.heaven_hexagram()
@@ -4439,13 +4499,29 @@ class RequestHandler(BaseHTTPRequestHandler):
except Exception as exc:
self.send_json({"error": f"跟踪刷新失败:{exc}"}, HTTPStatus.INTERNAL_SERVER_ERROR)
def mentor_chat(self) -> None:
def stream_mentor_chat(self) -> None:
try:
body = self.read_json_body()
result = SERVICE.mentor_chat(body)
self.send_json({"ok": True, **result})
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: