rebuild(stage-10): deliver mentor and unified llm streaming
This commit is contained in:
@@ -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")
|
||||
Reference in New Issue
Block a user