rebuild(stage-11): deliver deterministic heaven workflows
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
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")
|
||||
Reference in New Issue
Block a user