migration: preserve heaven trend fortune and heart slice
This commit is contained in:
+4
-1313
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
from .agent import HeavenAgentError, interpret_heaven
|
||||||
|
from .engine import (
|
||||||
|
build_five_phase_field,
|
||||||
|
build_market_hexagram,
|
||||||
|
build_manual_market_hexagram,
|
||||||
|
build_personal_field,
|
||||||
|
hexagram_from_lines,
|
||||||
|
)
|
||||||
|
from .http import HeavenHttpMixin
|
||||||
|
from .repository import HeavenRepositoryMixin
|
||||||
|
from .service import HeavenServiceMixin
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"HeavenAgentError",
|
||||||
|
"HeavenHttpMixin",
|
||||||
|
"HeavenRepositoryMixin",
|
||||||
|
"HeavenServiceMixin",
|
||||||
|
"build_five_phase_field",
|
||||||
|
"build_manual_market_hexagram",
|
||||||
|
"build_market_hexagram",
|
||||||
|
"build_personal_field",
|
||||||
|
"hexagram_from_lines",
|
||||||
|
"interpret_heaven",
|
||||||
|
]
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class HeavenAgentError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def interpret_heaven(
|
||||||
|
mode: str,
|
||||||
|
context: dict[str, Any],
|
||||||
|
api_key: str,
|
||||||
|
base_url: str,
|
||||||
|
model: str,
|
||||||
|
timeout: int = 90,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if mode not in {"trend", "fortune", "heart"}:
|
||||||
|
raise HeavenAgentError("不支持的问天解读模式。")
|
||||||
|
if not api_key or not model:
|
||||||
|
raise HeavenAgentError("LLM API Key 或模型尚未配置。")
|
||||||
|
system_prompt = _system_prompt(mode)
|
||||||
|
payload = json.dumps(
|
||||||
|
{
|
||||||
|
"model": model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": json.dumps(context, ensure_ascii=False, separators=(",", ":")),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"stream": False,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{base_url.rstrip('/')}/chat/completions",
|
||||||
|
data=payload,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"User-Agent": "XiaobaiReviewWeb/0.7",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
|
result = json.loads(response.read().decode("utf-8"))
|
||||||
|
answer = str(result["choices"][0]["message"]["content"]).strip()
|
||||||
|
if not answer:
|
||||||
|
raise KeyError("empty response")
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raise HeavenAgentError(_http_error_message(exc)) from exc
|
||||||
|
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc:
|
||||||
|
raise HeavenAgentError(f"问天模型调用失败:{exc}") from exc
|
||||||
|
return {
|
||||||
|
"answer": answer,
|
||||||
|
"model": model,
|
||||||
|
"latency_ms": round((time.perf_counter() - started) * 1000),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _system_prompt(mode: str) -> str:
|
||||||
|
common = """
|
||||||
|
你是“小白复盘”的问天解读器。所有历法、卦象、爻位和市场指标已经由确定性程序计算,你只能解释提供的数据,不得改卦、改爻、改干支或编造行情。
|
||||||
|
问天属于传统文化与娱乐化观察,不是预测模型,不承诺应验,不输出无条件买卖指令,不用神秘话术制造确定性。
|
||||||
|
使用中文,先给核心判断,再解释结构。引用市场数字时标明数据日期。输出纯文本,可使用简短标题。
|
||||||
|
""".strip()
|
||||||
|
if mode == "trend":
|
||||||
|
return common + """
|
||||||
|
|
||||||
|
当前任务是“观势·解势”。六爻从初爻到上爻依次是个股内核、个股外显、板块内核、板块外显、指数内核、指数外显;初二为地、三四为人、五上为天。
|
||||||
|
行情数据只负责生成六爻,本次解势必须以卦象本身为主,不得根据指数涨跌、板块强弱、涨停家数、成交量或个股表现直接推演方向。context中不会提供这些数字,也不会提供爻位对应的市场角色。
|
||||||
|
先解释本卦卦名的核心义、上下卦组合及大象;再只解释实际动爻所代表的转折,并说明本卦如何走向之卦;最后可把这一组卦势翻译成克制的市场语言。
|
||||||
|
重点是“本卦为当下之势,动爻为变化关节,之卦为所趋之势”。不要说明某一动爻对应指数、板块或个股,也不要输出“一看指数、二看涨停家数”一类行情观察条件。
|
||||||
|
全文控制在300至450个中文字符,最多四小段。卦理约占九成,市场翻译最多一句,只能落到节制、等待、守信、辨伪等行为态度,不得据此预测市场下一阶段、涨跌方向或动能变化。不直接荐股,不使用Markdown表格。
|
||||||
|
不要使用“必然、确定、必涨、必跌、后续将、进入某阶段”等断语;天机只点出势的性质与变化关系,不替用户宣布结果。
|
||||||
|
""".strip()
|
||||||
|
if mode == "fortune":
|
||||||
|
return common + """
|
||||||
|
|
||||||
|
当前任务是“观气·解运”。严格区分五运、六气、节气、月令和日干,不把丙午简单解释为火年。
|
||||||
|
严格服从five_phase_field.framework提供的确定性结构,不自行重新计算五行:年纲由中运与司天在泉构成;岁半以前司天为主、在泉为辅,岁半以后在泉为主、司天为辅;当前六气层以客气加临主气为核心;日辰只负责触发。节气只用于定位当前六气阶段,不得再次叠加为独立力量。
|
||||||
|
重点解释framework.relations中的客主同气、客生主、主生客、客克主或主克客,以及客胜为从、主胜为逆、司天在泉同位、天符岁会等已经判定的关系。不得把司天、在泉、主气、客气视为彼此独立的证据重复计权,也不得自行增删传统格局。
|
||||||
|
首要解释当日气场容易放大参与者的哪些情绪、判断偏差和操作冲动,例如急躁、恐惧、迟疑、追涨、过早止损或路径依赖;再给出一至两个调节动作。
|
||||||
|
如有personal_profile,结合其日主、十神、五行平衡倾向说明当日对该用户主观状态的影响,但不得把简化平衡倾向说成唯一喜用神,也不得复述或猜测出生日期。
|
||||||
|
不得引用市场上涨下跌家数、涨跌停数量、成交额、板块强度或个股表现来证明气场。industry_affinity只是五行行业取象示例,不是行情旁证;行业契合度最多在末尾用一句话说明,不得写“当日共振”或暗示相关行业必然涨跌。
|
||||||
|
全文控制在420至600个中文字符,按“三层气机、人的状态、操作偏向、个人影响(如有)、制衡动作”组织,标题必须写“三层气机”。明确这些是传统历法框架下的观察语言,不宣称气候或五行直接导致股价。
|
||||||
|
""".strip()
|
||||||
|
return common + """
|
||||||
|
|
||||||
|
当前任务是“观心·解卦”。用户的问题始终只在心中,没有输入给你,因此你不能猜测问题内容,也不能替用户作具体决定。
|
||||||
|
全文控制在180至350个中文字符。只写一句卦意;一小段动爻与之卦;最后三句极短的问心句。
|
||||||
|
不要重述六条爻辞,不猜用户未说出口的问题,不以吉凶二字替代思考,不给出股票涨跌预测。语气安静、克制,越短越有余味。
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _http_error_message(exc: urllib.error.HTTPError) -> str:
|
||||||
|
detail = ""
|
||||||
|
try:
|
||||||
|
payload = json.loads(exc.read().decode("utf-8", errors="replace"))
|
||||||
|
error = payload.get("error")
|
||||||
|
if isinstance(error, dict):
|
||||||
|
detail = str(error.get("message") or error.get("code") or "")
|
||||||
|
elif error:
|
||||||
|
detail = str(error)
|
||||||
|
elif payload.get("message"):
|
||||||
|
detail = str(payload["message"])
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
detail = ""
|
||||||
|
suffix = f":{detail[:300]}" if detail else ""
|
||||||
|
return f"问天模型调用失败(HTTP {exc.code}){suffix}"
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
|
||||||
|
class HeavenHttpMixin:
|
||||||
|
def heaven_hexagram(self) -> None:
|
||||||
|
try:
|
||||||
|
body = self.read_json_body()
|
||||||
|
result = self.application_service.heaven_hexagram(body.get("lines"))
|
||||||
|
self.send_json({"ok": True, "hexagram": result})
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
def heaven_personal(self) -> None:
|
||||||
|
try:
|
||||||
|
body = self.read_json_body()
|
||||||
|
result = self.application_service.heaven_personal(body)
|
||||||
|
self.send_json({"ok": True, "personal": result})
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
def heaven_interpret(self) -> None:
|
||||||
|
try:
|
||||||
|
body = self.read_json_body()
|
||||||
|
result = self.application_service.heaven_interpret(body)
|
||||||
|
self.send_json({"ok": True, **result})
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class HeavenRepositoryMixin:
|
||||||
|
@staticmethod
|
||||||
|
def _heaven_reading_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": int(row["id"]),
|
||||||
|
"mode": str(row["mode"]),
|
||||||
|
"context_date": str(row["context_date"]),
|
||||||
|
"subject": str(row["subject"]),
|
||||||
|
"subject_detail": str(row["subject_detail"]),
|
||||||
|
"answer": str(row["answer"]),
|
||||||
|
"created_at": str(row["created_at"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
def save_heaven_reading(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
mode: str,
|
||||||
|
context_date: str,
|
||||||
|
subject: str,
|
||||||
|
subject_detail: str,
|
||||||
|
answer: str,
|
||||||
|
context_snapshot: dict[str, Any],
|
||||||
|
dedupe_key: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
|
snapshot_json = json.dumps(
|
||||||
|
context_snapshot, ensure_ascii=False, separators=(",", ":")
|
||||||
|
)
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO heaven_readings
|
||||||
|
(user_id, mode, context_date, subject, subject_detail, answer,
|
||||||
|
context_snapshot, dedupe_key, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(user_id, dedupe_key) DO NOTHING
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
int(user_id), mode, context_date, subject, subject_detail,
|
||||||
|
answer, snapshot_json, dedupe_key, now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, mode, context_date, subject, subject_detail, answer, created_at
|
||||||
|
FROM heaven_readings WHERE user_id = ? AND dedupe_key = ?
|
||||||
|
""",
|
||||||
|
(int(user_id), dedupe_key),
|
||||||
|
).fetchone()
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM heaven_readings
|
||||||
|
WHERE user_id = ? AND mode = ? AND id NOT IN (
|
||||||
|
SELECT id FROM heaven_readings
|
||||||
|
WHERE user_id = ? AND mode = ? ORDER BY id DESC LIMIT 100
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(int(user_id), mode, int(user_id), mode),
|
||||||
|
)
|
||||||
|
result = self._heaven_reading_dict(row)
|
||||||
|
if not result:
|
||||||
|
raise ValueError("解读记录保存失败。")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def list_heaven_readings(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
mode: str,
|
||||||
|
context_date: str = "",
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
clauses = ["user_id = ?", "mode = ?"]
|
||||||
|
parameters: list[Any] = [int(user_id), mode]
|
||||||
|
if context_date:
|
||||||
|
clauses.append("context_date = ?")
|
||||||
|
parameters.append(context_date)
|
||||||
|
parameters.append(max(1, min(100, int(limit))))
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, mode, context_date, subject, subject_detail, answer, created_at
|
||||||
|
FROM heaven_readings WHERE {' AND '.join(clauses)}
|
||||||
|
ORDER BY context_date DESC, id DESC LIMIT ?
|
||||||
|
""",
|
||||||
|
parameters,
|
||||||
|
).fetchall()
|
||||||
|
return [self._heaven_reading_dict(row) for row in rows if row]
|
||||||
|
|
||||||
|
def latest_heaven_reading(
|
||||||
|
self, user_id: int, mode: str, context_date: str = ""
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
items = self.list_heaven_readings(user_id, mode, context_date, 1)
|
||||||
|
return items[0] if items else None
|
||||||
|
|
||||||
|
def delete_heaven_reading(self, user_id: int, reading_id: int) -> bool:
|
||||||
|
with self.connect() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"DELETE FROM heaven_readings WHERE id = ? AND user_id = ?",
|
||||||
|
(int(reading_id), int(user_id)),
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
File diff suppressed because it is too large
Load Diff
+2
-103
@@ -10,6 +10,7 @@ from backend.database import MIGRATIONS, MigrationRunner, SQLiteConnectionFactor
|
|||||||
from backend.features.accounts.repository import AccountRepositoryMixin
|
from backend.features.accounts.repository import AccountRepositoryMixin
|
||||||
from backend.features.auction.repository import AuctionRepositoryMixin
|
from backend.features.auction.repository import AuctionRepositoryMixin
|
||||||
from backend.features.dragon_tiger.repository import DragonTigerRepositoryMixin
|
from backend.features.dragon_tiger.repository import DragonTigerRepositoryMixin
|
||||||
|
from backend.features.heaven.repository import HeavenRepositoryMixin
|
||||||
from backend.features.market.repository import MarketRepositoryMixin
|
from backend.features.market.repository import MarketRepositoryMixin
|
||||||
from backend.features.mentor.repository import MentorRepositoryMixin
|
from backend.features.mentor.repository import MentorRepositoryMixin
|
||||||
from backend.features.pools.repository import PoolRepositoryMixin
|
from backend.features.pools.repository import PoolRepositoryMixin
|
||||||
@@ -23,6 +24,7 @@ class ReviewDatabase(
|
|||||||
AccountRepositoryMixin,
|
AccountRepositoryMixin,
|
||||||
AuctionRepositoryMixin,
|
AuctionRepositoryMixin,
|
||||||
DragonTigerRepositoryMixin,
|
DragonTigerRepositoryMixin,
|
||||||
|
HeavenRepositoryMixin,
|
||||||
MarketRepositoryMixin,
|
MarketRepositoryMixin,
|
||||||
MentorRepositoryMixin,
|
MentorRepositoryMixin,
|
||||||
PoolRepositoryMixin,
|
PoolRepositoryMixin,
|
||||||
@@ -1121,106 +1123,3 @@ class ReviewDatabase(
|
|||||||
"DELETE FROM assistant_messages WHERE user_id = ?", (int(user_id),)
|
"DELETE FROM assistant_messages WHERE user_id = ?", (int(user_id),)
|
||||||
)
|
)
|
||||||
return int(cursor.rowcount)
|
return int(cursor.rowcount)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _heaven_reading_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
return {
|
|
||||||
"id": int(row["id"]),
|
|
||||||
"mode": str(row["mode"]),
|
|
||||||
"context_date": str(row["context_date"]),
|
|
||||||
"subject": str(row["subject"]),
|
|
||||||
"subject_detail": str(row["subject_detail"]),
|
|
||||||
"answer": str(row["answer"]),
|
|
||||||
"created_at": str(row["created_at"]),
|
|
||||||
}
|
|
||||||
|
|
||||||
def save_heaven_reading(
|
|
||||||
self,
|
|
||||||
user_id: int,
|
|
||||||
mode: str,
|
|
||||||
context_date: str,
|
|
||||||
subject: str,
|
|
||||||
subject_detail: str,
|
|
||||||
answer: str,
|
|
||||||
context_snapshot: dict[str, Any],
|
|
||||||
dedupe_key: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
||||||
snapshot_json = json.dumps(
|
|
||||||
context_snapshot, ensure_ascii=False, separators=(",", ":")
|
|
||||||
)
|
|
||||||
with self.connect() as connection:
|
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO heaven_readings
|
|
||||||
(user_id, mode, context_date, subject, subject_detail, answer,
|
|
||||||
context_snapshot, dedupe_key, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(user_id, dedupe_key) DO NOTHING
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
int(user_id), mode, context_date, subject, subject_detail,
|
|
||||||
answer, snapshot_json, dedupe_key, now,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
row = connection.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, mode, context_date, subject, subject_detail, answer, created_at
|
|
||||||
FROM heaven_readings WHERE user_id = ? AND dedupe_key = ?
|
|
||||||
""",
|
|
||||||
(int(user_id), dedupe_key),
|
|
||||||
).fetchone()
|
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
DELETE FROM heaven_readings
|
|
||||||
WHERE user_id = ? AND mode = ? AND id NOT IN (
|
|
||||||
SELECT id FROM heaven_readings
|
|
||||||
WHERE user_id = ? AND mode = ? ORDER BY id DESC LIMIT 100
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
(int(user_id), mode, int(user_id), mode),
|
|
||||||
)
|
|
||||||
result = self._heaven_reading_dict(row)
|
|
||||||
if not result:
|
|
||||||
raise ValueError("解读记录保存失败。")
|
|
||||||
return result
|
|
||||||
|
|
||||||
def list_heaven_readings(
|
|
||||||
self,
|
|
||||||
user_id: int,
|
|
||||||
mode: str,
|
|
||||||
context_date: str = "",
|
|
||||||
limit: int = 100,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
clauses = ["user_id = ?", "mode = ?"]
|
|
||||||
parameters: list[Any] = [int(user_id), mode]
|
|
||||||
if context_date:
|
|
||||||
clauses.append("context_date = ?")
|
|
||||||
parameters.append(context_date)
|
|
||||||
parameters.append(max(1, min(100, int(limit))))
|
|
||||||
with self.connect() as connection:
|
|
||||||
rows = connection.execute(
|
|
||||||
f"""
|
|
||||||
SELECT id, mode, context_date, subject, subject_detail, answer, created_at
|
|
||||||
FROM heaven_readings WHERE {' AND '.join(clauses)}
|
|
||||||
ORDER BY context_date DESC, id DESC LIMIT ?
|
|
||||||
""",
|
|
||||||
parameters,
|
|
||||||
).fetchall()
|
|
||||||
return [self._heaven_reading_dict(row) for row in rows if row]
|
|
||||||
|
|
||||||
def latest_heaven_reading(
|
|
||||||
self, user_id: int, mode: str, context_date: str = ""
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
items = self.list_heaven_readings(user_id, mode, context_date, 1)
|
|
||||||
return items[0] if items else None
|
|
||||||
|
|
||||||
def delete_heaven_reading(self, user_id: int, reading_id: int) -> bool:
|
|
||||||
with self.connect() as connection:
|
|
||||||
cursor = connection.execute(
|
|
||||||
"DELETE FROM heaven_readings WHERE id = ? AND user_id = ?",
|
|
||||||
(int(reading_id), int(user_id)),
|
|
||||||
)
|
|
||||||
return cursor.rowcount > 0
|
|
||||||
|
|||||||
+4
-115
@@ -1,118 +1,7 @@
|
|||||||
from __future__ import annotations
|
"""Compatibility alias for the canonical heaven agent implementation."""
|
||||||
|
|
||||||
import json
|
import sys
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
from backend.features.heaven import agent as _implementation
|
||||||
|
|
||||||
class HeavenAgentError(RuntimeError):
|
sys.modules[__name__] = _implementation
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def interpret_heaven(
|
|
||||||
mode: str,
|
|
||||||
context: dict[str, Any],
|
|
||||||
api_key: str,
|
|
||||||
base_url: str,
|
|
||||||
model: str,
|
|
||||||
timeout: int = 90,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if mode not in {"trend", "fortune", "heart"}:
|
|
||||||
raise HeavenAgentError("不支持的问天解读模式。")
|
|
||||||
if not api_key or not model:
|
|
||||||
raise HeavenAgentError("LLM API Key 或模型尚未配置。")
|
|
||||||
system_prompt = _system_prompt(mode)
|
|
||||||
payload = json.dumps(
|
|
||||||
{
|
|
||||||
"model": model,
|
|
||||||
"messages": [
|
|
||||||
{"role": "system", "content": system_prompt},
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": json.dumps(context, ensure_ascii=False, separators=(",", ":")),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"stream": False,
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
|
||||||
).encode("utf-8")
|
|
||||||
request = urllib.request.Request(
|
|
||||||
f"{base_url.rstrip('/')}/chat/completions",
|
|
||||||
data=payload,
|
|
||||||
headers={
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": f"Bearer {api_key}",
|
|
||||||
"User-Agent": "XiaobaiReviewWeb/0.7",
|
|
||||||
},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
started = time.perf_counter()
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
||||||
result = json.loads(response.read().decode("utf-8"))
|
|
||||||
answer = str(result["choices"][0]["message"]["content"]).strip()
|
|
||||||
if not answer:
|
|
||||||
raise KeyError("empty response")
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
raise HeavenAgentError(_http_error_message(exc)) from exc
|
|
||||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc:
|
|
||||||
raise HeavenAgentError(f"问天模型调用失败:{exc}") from exc
|
|
||||||
return {
|
|
||||||
"answer": answer,
|
|
||||||
"model": model,
|
|
||||||
"latency_ms": round((time.perf_counter() - started) * 1000),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _system_prompt(mode: str) -> str:
|
|
||||||
common = """
|
|
||||||
你是“小白复盘”的问天解读器。所有历法、卦象、爻位和市场指标已经由确定性程序计算,你只能解释提供的数据,不得改卦、改爻、改干支或编造行情。
|
|
||||||
问天属于传统文化与娱乐化观察,不是预测模型,不承诺应验,不输出无条件买卖指令,不用神秘话术制造确定性。
|
|
||||||
使用中文,先给核心判断,再解释结构。引用市场数字时标明数据日期。输出纯文本,可使用简短标题。
|
|
||||||
""".strip()
|
|
||||||
if mode == "trend":
|
|
||||||
return common + """
|
|
||||||
|
|
||||||
当前任务是“观势·解势”。六爻从初爻到上爻依次是个股内核、个股外显、板块内核、板块外显、指数内核、指数外显;初二为地、三四为人、五上为天。
|
|
||||||
行情数据只负责生成六爻,本次解势必须以卦象本身为主,不得根据指数涨跌、板块强弱、涨停家数、成交量或个股表现直接推演方向。context中不会提供这些数字,也不会提供爻位对应的市场角色。
|
|
||||||
先解释本卦卦名的核心义、上下卦组合及大象;再只解释实际动爻所代表的转折,并说明本卦如何走向之卦;最后可把这一组卦势翻译成克制的市场语言。
|
|
||||||
重点是“本卦为当下之势,动爻为变化关节,之卦为所趋之势”。不要说明某一动爻对应指数、板块或个股,也不要输出“一看指数、二看涨停家数”一类行情观察条件。
|
|
||||||
全文控制在300至450个中文字符,最多四小段。卦理约占九成,市场翻译最多一句,只能落到节制、等待、守信、辨伪等行为态度,不得据此预测市场下一阶段、涨跌方向或动能变化。不直接荐股,不使用Markdown表格。
|
|
||||||
不要使用“必然、确定、必涨、必跌、后续将、进入某阶段”等断语;天机只点出势的性质与变化关系,不替用户宣布结果。
|
|
||||||
""".strip()
|
|
||||||
if mode == "fortune":
|
|
||||||
return common + """
|
|
||||||
|
|
||||||
当前任务是“观气·解运”。严格区分五运、六气、节气、月令和日干,不把丙午简单解释为火年。
|
|
||||||
严格服从five_phase_field.framework提供的确定性结构,不自行重新计算五行:年纲由中运与司天在泉构成;岁半以前司天为主、在泉为辅,岁半以后在泉为主、司天为辅;当前六气层以客气加临主气为核心;日辰只负责触发。节气只用于定位当前六气阶段,不得再次叠加为独立力量。
|
|
||||||
重点解释framework.relations中的客主同气、客生主、主生客、客克主或主克客,以及客胜为从、主胜为逆、司天在泉同位、天符岁会等已经判定的关系。不得把司天、在泉、主气、客气视为彼此独立的证据重复计权,也不得自行增删传统格局。
|
|
||||||
首要解释当日气场容易放大参与者的哪些情绪、判断偏差和操作冲动,例如急躁、恐惧、迟疑、追涨、过早止损或路径依赖;再给出一至两个调节动作。
|
|
||||||
如有personal_profile,结合其日主、十神、五行平衡倾向说明当日对该用户主观状态的影响,但不得把简化平衡倾向说成唯一喜用神,也不得复述或猜测出生日期。
|
|
||||||
不得引用市场上涨下跌家数、涨跌停数量、成交额、板块强度或个股表现来证明气场。industry_affinity只是五行行业取象示例,不是行情旁证;行业契合度最多在末尾用一句话说明,不得写“当日共振”或暗示相关行业必然涨跌。
|
|
||||||
全文控制在420至600个中文字符,按“三层气机、人的状态、操作偏向、个人影响(如有)、制衡动作”组织,标题必须写“三层气机”。明确这些是传统历法框架下的观察语言,不宣称气候或五行直接导致股价。
|
|
||||||
""".strip()
|
|
||||||
return common + """
|
|
||||||
|
|
||||||
当前任务是“观心·解卦”。用户的问题始终只在心中,没有输入给你,因此你不能猜测问题内容,也不能替用户作具体决定。
|
|
||||||
全文控制在180至350个中文字符。只写一句卦意;一小段动爻与之卦;最后三句极短的问心句。
|
|
||||||
不要重述六条爻辞,不猜用户未说出口的问题,不以吉凶二字替代思考,不给出股票涨跌预测。语气安静、克制,越短越有余味。
|
|
||||||
""".strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _http_error_message(exc: urllib.error.HTTPError) -> str:
|
|
||||||
detail = ""
|
|
||||||
try:
|
|
||||||
payload = json.loads(exc.read().decode("utf-8", errors="replace"))
|
|
||||||
error = payload.get("error")
|
|
||||||
if isinstance(error, dict):
|
|
||||||
detail = str(error.get("message") or error.get("code") or "")
|
|
||||||
elif error:
|
|
||||||
detail = str(error)
|
|
||||||
elif payload.get("message"):
|
|
||||||
detail = str(payload["message"])
|
|
||||||
except (json.JSONDecodeError, OSError):
|
|
||||||
detail = ""
|
|
||||||
suffix = f":{detail[:300]}" if detail else ""
|
|
||||||
return f"问天模型调用失败(HTTP {exc.code}){suffix}"
|
|
||||||
|
|||||||
+3
-1178
File diff suppressed because it is too large
Load Diff
@@ -9,11 +9,11 @@ from tushare_client import _sector_coverage_issue
|
|||||||
|
|
||||||
|
|
||||||
def load_method(name: str):
|
def load_method(name: str):
|
||||||
source = Path("backend/application.py").read_text(encoding="utf-8")
|
source = Path("backend/features/heaven/service.py").read_text(encoding="utf-8")
|
||||||
tree = ast.parse(source)
|
tree = ast.parse(source)
|
||||||
dashboard_service = next(
|
dashboard_service = next(
|
||||||
node for node in tree.body
|
node for node in tree.body
|
||||||
if isinstance(node, ast.ClassDef) and node.name == "DashboardService"
|
if isinstance(node, ast.ClassDef) and node.name == "HeavenServiceMixin"
|
||||||
)
|
)
|
||||||
method = next(
|
method = next(
|
||||||
node for node in dashboard_service.body
|
node for node in dashboard_service.body
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import hashlib
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import heaven_agent
|
||||||
|
import heaven_engine
|
||||||
|
from backend.features.heaven import agent as canonical_agent
|
||||||
|
from backend.features.heaven import engine as canonical_engine
|
||||||
|
|
||||||
|
|
||||||
|
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
ORIGINAL_ROOT = APP_ROOT.parent
|
||||||
|
|
||||||
|
HEAVEN_SERVICE_METHODS = {
|
||||||
|
"_heaven_manual_schema",
|
||||||
|
"_validate_heaven_manual_data",
|
||||||
|
"_apply_heaven_manual_data",
|
||||||
|
"_heaven_line_checks",
|
||||||
|
"_resolve_heaven_stock_code",
|
||||||
|
"heaven_setup",
|
||||||
|
"_heaven_stock_context",
|
||||||
|
"_heaven_market_mode",
|
||||||
|
"_heaven_trend_sources",
|
||||||
|
"_heaven_trend_quality_issues",
|
||||||
|
"heaven_personal",
|
||||||
|
"heaven_hexagram",
|
||||||
|
"heaven_readings",
|
||||||
|
"_heaven_reading_identity",
|
||||||
|
"heaven_interpret",
|
||||||
|
"_legacy_truncated_heaven_reading",
|
||||||
|
"_call_heaven_agent",
|
||||||
|
"_heaven_index_context",
|
||||||
|
"_aggregate_index_context",
|
||||||
|
"_heaven_sector_context",
|
||||||
|
}
|
||||||
|
|
||||||
|
HEAVEN_REPOSITORY_METHODS = {
|
||||||
|
"_heaven_reading_dict",
|
||||||
|
"save_heaven_reading",
|
||||||
|
"list_heaven_readings",
|
||||||
|
"latest_heaven_reading",
|
||||||
|
"delete_heaven_reading",
|
||||||
|
}
|
||||||
|
|
||||||
|
HEAVEN_HTTP_METHODS = {"heaven_hexagram", "heaven_personal", "heaven_interpret"}
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(path: Path) -> str:
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def class_methods(path: Path, class_name: str) -> dict[str, str]:
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
owner = next(
|
||||||
|
node
|
||||||
|
for node in tree.body
|
||||||
|
if isinstance(node, ast.ClassDef) and node.name == class_name
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
node.name: ast.dump(node, include_attributes=False)
|
||||||
|
for node in owner.body
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def top_level_definitions(path: Path) -> dict[str, str]:
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
return {
|
||||||
|
node.name: ast.dump(node, include_attributes=False)
|
||||||
|
for node in tree.body
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HeavenSliceSourceEquivalenceTests(unittest.TestCase):
|
||||||
|
def assert_methods_equal(
|
||||||
|
self,
|
||||||
|
original_path: Path,
|
||||||
|
original_class: str,
|
||||||
|
migrated_path: Path,
|
||||||
|
migrated_class: str,
|
||||||
|
expected: set[str],
|
||||||
|
adapted: set[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
original = class_methods(original_path, original_class)
|
||||||
|
migrated = class_methods(migrated_path, migrated_class)
|
||||||
|
self.assertEqual(set(migrated), expected)
|
||||||
|
for name in sorted(expected - (adapted or set())):
|
||||||
|
self.assertEqual(migrated[name], original[name], name)
|
||||||
|
|
||||||
|
def test_heaven_agent_is_an_exact_file(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
sha256(ORIGINAL_ROOT / "heaven_agent.py"),
|
||||||
|
sha256(APP_ROOT / "backend" / "features" / "heaven" / "agent.py"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_heaven_engine_definitions_are_exact_original_ast(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
top_level_definitions(ORIGINAL_ROOT / "heaven_engine.py"),
|
||||||
|
top_level_definitions(
|
||||||
|
APP_ROOT / "backend" / "features" / "heaven" / "engine.py"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_compatibility_modules_are_canonical_module_objects(self) -> None:
|
||||||
|
self.assertIs(heaven_agent, canonical_agent)
|
||||||
|
self.assertIs(heaven_engine, canonical_engine)
|
||||||
|
|
||||||
|
def test_heaven_service_methods_are_exact_original_ast(self) -> None:
|
||||||
|
self.assert_methods_equal(
|
||||||
|
ORIGINAL_ROOT / "server.py",
|
||||||
|
"DashboardService",
|
||||||
|
APP_ROOT / "backend" / "features" / "heaven" / "service.py",
|
||||||
|
"HeavenServiceMixin",
|
||||||
|
HEAVEN_SERVICE_METHODS,
|
||||||
|
{"_heaven_reading_identity"},
|
||||||
|
)
|
||||||
|
source = (
|
||||||
|
APP_ROOT / "backend" / "features" / "heaven" / "service.py"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
self.assertIn(
|
||||||
|
"MarketServiceMixin._display_compact_date(context_date)", source
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_heaven_repository_methods_are_exact_original_ast(self) -> None:
|
||||||
|
self.assert_methods_equal(
|
||||||
|
ORIGINAL_ROOT / "database.py",
|
||||||
|
"ReviewDatabase",
|
||||||
|
APP_ROOT / "backend" / "features" / "heaven" / "repository.py",
|
||||||
|
"HeavenRepositoryMixin",
|
||||||
|
HEAVEN_REPOSITORY_METHODS,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_original_classes_no_longer_duplicate_moved_methods(self) -> None:
|
||||||
|
remaining_service = class_methods(
|
||||||
|
APP_ROOT / "backend" / "application.py", "DashboardService"
|
||||||
|
)
|
||||||
|
remaining_database = class_methods(APP_ROOT / "database.py", "ReviewDatabase")
|
||||||
|
remaining_http = class_methods(
|
||||||
|
APP_ROOT / "backend" / "application.py", "RequestHandler"
|
||||||
|
)
|
||||||
|
self.assertTrue(HEAVEN_SERVICE_METHODS.isdisjoint(remaining_service))
|
||||||
|
self.assertTrue(HEAVEN_REPOSITORY_METHODS.isdisjoint(remaining_database))
|
||||||
|
self.assertTrue(HEAVEN_HTTP_METHODS.isdisjoint(remaining_http))
|
||||||
|
|
||||||
|
def test_http_mixin_preserves_all_heaven_endpoints(self) -> None:
|
||||||
|
methods = class_methods(
|
||||||
|
APP_ROOT / "backend" / "features" / "heaven" / "http.py",
|
||||||
|
"HeavenHttpMixin",
|
||||||
|
)
|
||||||
|
self.assertEqual(set(methods), HEAVEN_HTTP_METHODS)
|
||||||
|
source = (
|
||||||
|
APP_ROOT / "backend" / "features" / "heaven" / "http.py"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
self.assertNotIn("SERVICE.", source)
|
||||||
|
self.assertEqual(source.count("self.application_service.heaven_"), 3)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user