269 lines
9.9 KiB
Python
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")
|