Files
xiaobaifupan/next/backend/features/heaven/service.py
T

269 lines
9.9 KiB
Python

from __future__ import annotations
import json
import secrets
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import date, datetime
from pathlib import Path
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.accounts.service import AccountService
from backend.features.heaven import fortune, trend
from backend.features.heaven.hexagram import from_lines
from backend.features.heaven.prompt import PROMPT_VERSION, messages
from backend.features.heaven.repository import HeavenRepository
from backend.llm.gateway import LLMCall, LLMGateway, LLMGatewayError
SHANGHAI = ZoneInfo("Asia/Shanghai")
class HeavenError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class PreparedInterpretation:
reading_id: int
user_id: int
mode: str
prompt: list[dict[str, str]]
call: LLMCall | None
cached: str = ""
class HeavenService:
def __init__(
self,
database: Database,
repository: HeavenRepository,
gateway: DataGateway,
accounts: AccountService,
llm: LLMGateway,
iching_path: Path,
) -> None:
self._database = database
self._repository = repository
self._gateway = gateway
self._accounts = accounts
self._llm = llm
self._iching_path = iching_path
def setup(self, principal: Principal, requested_date: str) -> dict[str, Any]:
reading_date = self._date(requested_date)
field = fortune.build(reading_date, self._accounts.get_profile(principal.user.id))
with self._database.read() as connection:
daily = self._repository.latest_fortune(connection, principal.user.id, reading_date)
rows = self._repository.list(connection, principal.user.id, None, None, 60)
return {
"date": reading_date,
"fortune": field,
"daily_fortune": _public(daily) if daily else None,
"history": [_public(row) for row in rows],
}
def trend(
self,
principal: Principal,
query: str,
requested_date: str,
manual: dict[str, Any],
) -> dict[str, Any]:
reading_date = self._date(requested_date)
payload = self._gateway.heaven_trend_inputs(query, reading_date)
try:
result = trend.calculate(payload, self._iching_path, manual)
except trend.TrendDataError as exc:
return {
"ready": False,
"message": str(exc),
"trade_date": payload.get("trade_date"),
"stock": payload.get("stock"),
"sector": payload.get("sector"),
"checks": exc.checks,
"automatic": exc.payload,
}
reading_id = self._save(
principal.user.id,
"trend",
result["trade_date"],
str(result["stock"]["identifier"]),
result,
)
return {"ready": True, "reading_id": reading_id, "result": result}
def fortune(self, principal: Principal, requested_date: str) -> dict[str, Any]:
reading_date = self._date(requested_date)
result = fortune.build(reading_date, self._accounts.get_profile(principal.user.id))
with self._database.transaction() as connection:
row, reused = self._repository.ensure_fortune(
connection,
user_id=principal.user.id,
reading_date=reading_date,
result=result,
created_at=_now(),
)
return {"reused": reused, "reading": _public(row)}
def heart_line(
self, principal: Principal, requested_date: str, values: list[int]
) -> dict[str, Any]:
self._date(requested_date)
if len(values) >= 6 or any(value not in {6, 7, 8, 9} for value in values):
raise HeavenError("当前起卦进度无效。")
faces = [2 + secrets.randbelow(2) for _ in range(3)]
value = sum(faces)
return {
"position": len(values) + 1,
"value": value,
"faces": ["front" if face == 3 else "back" for face in faces],
"values": [*values, value],
}
def complete_heart(
self,
principal: Principal,
requested_date: str,
values: list[int],
first_thought_confirmed: bool,
) -> dict[str, Any]:
reading_date = self._date(requested_date)
if not first_thought_confirmed:
raise HeavenError("请先确认第一念,再进入解卦。")
result = {
"date": reading_date,
"hexagram": from_lines(values, self._iching_path),
"notice": "卦象仅供传统文化与自我观察,不构成预测或投资建议。",
}
reading_id = self._save(principal.user.id, "heart", reading_date, "", result)
return {"reading_id": reading_id, "result": result}
def readings(
self, principal: Principal, mode: str | None, requested_date: str | None
) -> list[dict[str, Any]]:
reading_date = self._date(requested_date) if requested_date else None
if mode and mode not in {"trend", "fortune", "heart"}:
raise HeavenError("历史类型无效。")
with self._database.read() as connection:
rows = self._repository.list(connection, principal.user.id, mode, reading_date, 60)
return [_public(row) for row in rows]
def delete(self, principal: Principal, reading_id: int) -> int:
with self._database.transaction() as connection:
return self._repository.delete(connection, principal.user.id, reading_id)
def prepare_interpret(self, principal: Principal, reading_id: int) -> PreparedInterpretation:
with self._database.read() as connection:
row = self._repository.get(connection, principal.user.id, reading_id)
if row is None:
raise HeavenError("未找到该问天记录。")
if row["interpretation_status"] == "complete" and row["interpretation"]:
return PreparedInterpretation(
reading_id,
principal.user.id,
str(row["mode"]),
[],
None,
str(row["interpretation"]),
)
result = json.loads(str(row["result_json"]))
prompt = messages(str(row["mode"]), result)
call = self._llm.prepare(
principal,
feature=f"heaven_{row['mode']}",
prompt_version=PROMPT_VERSION,
business_id=f"heaven:{reading_id}",
input_chars=sum(len(item["content"]) for item in prompt),
)
return PreparedInterpretation(reading_id, principal.user.id, str(row["mode"]), prompt, call)
def stream_interpret(self, prepared: PreparedInterpretation) -> Iterator[dict[str, Any]]:
if prepared.cached:
yield {"type": "delta", "content": prepared.cached, "cached": True}
yield {"type": "done", "cached": True}
return
if prepared.call is None:
raise HeavenError("智能解读状态无效。")
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_interpretation(prepared, answer, "complete")
saved = True
yield {"type": "done", "request_id": event.request_id}
except GeneratorExit:
stream.close()
if answer and not saved:
self._save_interpretation(prepared, answer, "stopped")
raise
except LLMGatewayError as exc:
if answer:
self._save_interpretation(prepared, answer, "error")
yield {"type": "error", "code": exc.code, "message": str(exc), "partial": exc.partial}
def _save(
self, user_id: int, mode: str, reading_date: str, subject_key: str, result: dict[str, Any]
) -> int:
now = _now()
with self._database.transaction() as connection:
return self._repository.add(
connection,
user_id=user_id,
mode=mode,
reading_date=reading_date,
subject_key=subject_key,
result=result,
created_at=now,
)
def _save_interpretation(
self, prepared: PreparedInterpretation, answer: str, status: str
) -> None:
with self._database.transaction() as connection:
self._repository.update_interpretation(
connection,
prepared.reading_id,
prepared.user_id,
answer,
status,
prepared.call.request_id if prepared.call else None,
_now(),
)
@staticmethod
def _date(value: str) -> str:
try:
return date.fromisoformat(value).isoformat()
except ValueError as exc:
raise HeavenError("日期格式无效。") from exc
def _public(row: Any) -> dict[str, Any]:
return {
"id": int(row["id"]),
"mode": str(row["mode"]),
"date": str(row["reading_date"]),
"subject_key": str(row["subject_key"]),
"result": json.loads(str(row["result_json"])),
"interpretation": str(row["interpretation"]),
"status": str(row["interpretation_status"]),
"created_at": str(row["created_at"]),
}
def _now() -> str:
return datetime.now(SHANGHAI).isoformat(timespec="seconds")