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")