migration: preserve heaven trend fortune and heart slice

This commit is contained in:
leefer
2026-07-31 08:57:26 +08:00
parent fb7e3cbf0b
commit 11eebbf6cb
12 changed files with 2945 additions and 2711 deletions
+4 -1313
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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",
]
+118
View File
@@ -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
+30
View File
@@ -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)
+111
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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
+163
View File
@@ -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()