rebuild(stage-10): deliver mentor and unified llm streaming

This commit is contained in:
leefer
2026-07-30 05:52:12 +08:00
parent 532f0cfc11
commit f1fa104641
62 changed files with 6880 additions and 7 deletions
+244
View File
@@ -0,0 +1,244 @@
from __future__ import annotations
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import date, datetime
from typing import Any
from zoneinfo import ZoneInfo
from backend.data.gateway import DataGateway
from backend.database.connection import Database
from backend.features.accounts.models import Principal
from backend.features.mentor.context import MentorContextBuilder
from backend.features.mentor.prompt import PROMPT_VERSION
from backend.features.mentor.prompt import messages as prompt_messages
from backend.features.mentor.repository import MentorRepository
from backend.features.mentor.skills import MentorSkill, MentorSkillError, MentorSkillRegistry
from backend.llm.gateway import LLMCall, LLMGateway, LLMGatewayError
SHANGHAI = ZoneInfo("Asia/Shanghai")
class MentorError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class PreparedChat:
call: LLMCall
skill: MentorSkill
trade_date: str
prompt: list[dict[str, str]]
class MentorService:
def __init__(
self,
database: Database,
repository: MentorRepository,
registry: MentorSkillRegistry,
context: MentorContextBuilder,
gateway: DataGateway,
llm: LLMGateway,
) -> None:
self._database = database
self._repository = repository
self._registry = registry
self._context = context
self._gateway = gateway
self._llm = llm
def setup(self, principal: Principal, requested_date: str) -> dict[str, Any]:
trade_date = self._trade_date(requested_date)
skills = self._registry.list(principal.user.is_admin)
with self._database.read() as connection:
preferences = self._repository.preferences(connection, principal.user.id)
rows = []
for fallback_order, skill in enumerate(skills):
preference = preferences.get(skill.id, {})
rows.append(
{
**skill.public(),
"pinned": bool(preference.get("pinned")),
"sort_order": int(preference.get("sort_order", 10_000 + fallback_order)),
}
)
rows.sort(key=lambda item: (not item["pinned"], item["sort_order"], item["name"]))
return {"trade_date": trade_date, "mentors": rows}
def save_preferences(
self, principal: Principal, order: list[str], pinned: list[str]
) -> dict[str, Any]:
available = [item.id for item in self._registry.list(principal.user.is_admin)]
available_set = set(available)
normalized = []
for mentor_id in order:
if mentor_id not in available_set:
raise MentorError("排序中包含不可用的思维模型。")
if mentor_id not in normalized:
normalized.append(mentor_id)
normalized.extend(item for item in available if item not in normalized)
pinned_set = set(pinned)
if not pinned_set <= available_set:
raise MentorError("置顶列表中包含不可用的思维模型。")
with self._database.transaction() as connection:
self._repository.save_preferences(
connection,
principal.user.id,
normalized,
pinned_set,
_now(),
)
return {"order": normalized, "pinned": [item for item in normalized if item in pinned_set]}
def messages(
self, principal: Principal, mentor_id: str, requested_date: str
) -> list[dict[str, Any]]:
self._skill(principal, mentor_id)
trade_date = self._valid_date(requested_date)
with self._database.read() as connection:
rows = self._repository.messages(
connection, principal.user.id, mentor_id, trade_date
)
return [
{
"id": int(row["id"]),
"role": str(row["role"]),
"content": str(row["content"]),
"status": str(row["status"]),
"created_at": str(row["created_at"]),
}
for row in rows
]
def clear(self, principal: Principal, mentor_id: str, requested_date: str) -> int:
self._skill(principal, mentor_id)
trade_date = self._valid_date(requested_date)
with self._database.transaction() as connection:
return self._repository.clear_messages(
connection, principal.user.id, mentor_id, trade_date
)
def prepare_chat(
self,
principal: Principal,
mentor_id: str,
requested_date: str,
question: str,
) -> PreparedChat:
skill = self._skill(principal, mentor_id)
trade_date = self._trade_date(requested_date)
normalized = " ".join(question.split())
if not normalized or len(normalized) > 2000:
raise MentorError("问题应为1至2000个字符。")
history = self._history(principal.user.id, mentor_id, trade_date)
context = self._context.build(trade_date, normalized, skill)
prompt = prompt_messages(skill, context, history, normalized)
input_chars = sum(len(item["content"]) for item in prompt)
call = self._llm.prepare(
principal,
feature="mentor",
prompt_version=PROMPT_VERSION,
business_id=f"{mentor_id}:{trade_date}",
input_chars=input_chars,
)
with self._database.transaction() as connection:
self._repository.add_message(
connection,
user_id=principal.user.id,
mentor_id=mentor_id,
trade_date=trade_date,
role="user",
content=normalized,
request_id=call.request_id,
status="complete",
created_at=_now(),
)
return PreparedChat(call, skill, trade_date, prompt)
def stream_chat(self, prepared: PreparedChat) -> Iterator[dict[str, Any]]:
answer = ""
saved = False
stream = self._llm.stream(prepared.call, prepared.prompt)
try:
for event in stream:
if event.type == "delta":
answer += event.content
yield {
"type": "delta",
"content": event.content,
"request_id": event.request_id,
}
elif event.type == "done":
self._save_answer(prepared, answer, "complete")
saved = True
yield {"type": "done", "request_id": event.request_id}
except GeneratorExit:
stream.close()
if answer and not saved:
self._save_answer(prepared, answer, "stopped")
raise
except LLMGatewayError as exc:
if answer:
self._save_answer(prepared, answer, "error")
yield {
"type": "error",
"code": exc.code,
"message": str(exc),
"partial": exc.partial,
"request_id": prepared.call.request_id,
}
def _history(self, user_id: int, mentor_id: str, trade_date: str) -> list[dict[str, str]]:
with self._database.read() as connection:
rows = self._repository.messages(connection, user_id, mentor_id, trade_date, 40)
result = []
total = 0
for row in reversed(rows):
if str(row["status"]) != "complete":
continue
content = str(row["content"])
if total + len(content) > 24_000:
break
result.append({"role": str(row["role"]), "content": content})
total += len(content)
if len(result) == 10:
break
return list(reversed(result))
def _save_answer(self, prepared: PreparedChat, answer: str, status: str) -> None:
if not answer:
return
with self._database.transaction() as connection:
self._repository.add_message(
connection,
user_id=prepared.call.user_id,
mentor_id=prepared.skill.id,
trade_date=prepared.trade_date,
role="assistant",
content=answer,
request_id=prepared.call.request_id,
status=status,
created_at=_now(),
)
def _skill(self, principal: Principal, mentor_id: str) -> MentorSkill:
try:
return self._registry.get(mentor_id, principal.user.is_admin)
except MentorSkillError as exc:
raise MentorError(str(exc)) from exc
def _trade_date(self, requested_date: str) -> str:
requested = self._valid_date(requested_date)
return self._gateway.trade_context(requested).actual_date or requested
@staticmethod
def _valid_date(value: str) -> str:
try:
return date.fromisoformat(value).isoformat()
except ValueError as exc:
raise MentorError("日期格式无效。") from exc
def _now() -> str:
return datetime.now(SHANGHAI).isoformat(timespec="seconds")