migration: preserve heaven trend fortune and heart slice

This commit is contained in:
leefer
2026-07-31 08:57:26 +08:00
parent 2919229c73
commit b3df070481
22 changed files with 3205 additions and 2717 deletions
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.auction.repository import AuctionRepositoryMixin
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.mentor.repository import MentorRepositoryMixin
from backend.features.pools.repository import PoolRepositoryMixin
@@ -23,6 +24,7 @@ class ReviewDatabase(
AccountRepositoryMixin,
AuctionRepositoryMixin,
DragonTigerRepositoryMixin,
HeavenRepositoryMixin,
MarketRepositoryMixin,
MentorRepositoryMixin,
PoolRepositoryMixin,
@@ -1121,106 +1123,3 @@ class ReviewDatabase(
"DELETE FROM assistant_messages WHERE user_id = ?", (int(user_id),)
)
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 time
import urllib.error
import urllib.request
from typing import Any
import sys
from backend.features.heaven import agent as _implementation
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}"
sys.modules[__name__] = _implementation
+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):
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)
dashboard_service = next(
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(
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()
@@ -0,0 +1,69 @@
# 切片 08:问天、观势、观气与观心
> 基线:`2919229`(切片 07
> 回档标签:`xiaobai-preservation-slice-08-20260731`
> 结论:源码、API、数据库、真实页面、动画与全量回归通过;最终视觉仍等待全站人工验收
## 1. 原实现归位
本切片只移动原版问天 Agent、历法/卦象引擎、服务、持久化与三个 HTTP 入口,没有从
`next/`取用代码,也没有改写六爻、安全门、五运六气、个人合参、铜钱起卦、历史去重或
LLM 解读逻辑。
| 原位置 | 新的唯一实现位置 | 兼容方式 |
|---|---|---|
| `app/heaven_agent.py` | `app/backend/features/heaven/agent.py` | 根级模块指向同一模块对象 |
| `app/heaven_engine.py` | `app/backend/features/heaven/engine.py` | 根级模块指向同一模块对象 |
| `DashboardService`问天方法 | `app/backend/features/heaven/service.py` | `HeavenServiceMixin` |
| 问天历史持久化 | `app/backend/features/heaven/repository.py` | `HeavenRepositoryMixin` |
| 三个问天 POST 入口 | `app/backend/features/heaven/http.py` | `HeavenHttpMixin` |
历法引擎搬迁后仍从`app/vendor``app/data/iching_zh.json`读取原资源;这是唯一资源路径适配,
顶层函数与类定义未变。旧测试依赖的根级模块继续有效。
## 2. 源码等价
- `heaven_agent.py`与原版文件 SHA-256 一致。
- `heaven_engine.py`所有顶层函数和类与原版无位置信息 AST 一致。
- 20个问天服务方法中19个与原版 AST 一致;`_heaven_reading_identity`仅将旧巨型类名
`DashboardService`替换为已归位的`MarketServiceMixin`静态日期格式化方法。
- 5个问天 Repository 方法与原版 AST 一致。
- 三个 HTTP 方法只把全局`SERVICE`改为 Mixin 的`self.application_service`,状态码、字段和异常语义不变。
- `DashboardService``ReviewDatabase``RequestHandler`不再重复保留已迁移实现。
## 3. API 与数据库差分
- 原版`8788`和迁移版`8789`使用同一正式数据库的两个临时副本。
- 固定日期的问天初始化、三类历史读取、六爻成卦和个人五行计算共6个接口,状态码与业务 JSON 完全一致。
- 未调用真实`/api/heaven/interpret`,避免外部模型波动及测试记录写入;提示词和调用链由源码等价与单元测试覆盖。
- 两版均为62个 schema 对象;`heaven_readings``users``system_settings``llm_usage`
`user_birth_profiles``sector_phase_overrides`逐行一致。
- 差分只在系统临时目录副本运行,正式数据库未写入问天记录或 LLM 用量。
## 4. 真实浏览器与动画检查
- 1920×1080视口逐页检查观势、观气、观心,无横向溢出,页面滚动边界可用。
- 观势保留星空、三才六爻与八卦待载入场景;观气生成100个星点,五运六气环动画名为`wt-spin`
五行行业默认折叠;观心呼吸波纹、香火燃烧和阶段文字均持续运行。
- 夜间模式问天背景为`rgb(11, 17, 32)`,全页无控制台错误。
- 没有触发真实解势、解运或解卦调用,加载动画的静态资产及脚本未改动。
- 截图 SHA-256
- `heaven-trend-1080.png``9e70d9cbfd741e74d3c4511c0e76ef8e625a6a31fd5d6bde014103f807bcc76e`
- `heaven-fortune-1080.png``e4cdf0e0fc1d9325724654a781febed1d509e49ddccecb3355803b5e9f1d2f5c`
- `heaven-heart-breathing-1080.png``3493a59b9032c1a8a1ba7da609b499c736170d2c7947b3aa0ac4c8b95d981ff5`
- `heaven-heart-dark-1080.png``3d346fea6b593fa4e43037f5fc68eda1a3f3446a223c2022d9084922f646a088`
## 5. 自动验证与保留边界
| 验证 | 结果 |
|---|---:|
| 原版`python -m unittest discover -s tests -q` | 231项通过 |
| 迁移版`python -m unittest discover -s tests -q` | 280项通过 |
| `python -m unittest tests.test_preservation_slice_heaven -q` | 7项通过 |
| `npx.cmd playwright test --reporter=dot` | 45项通过(2.2分钟) |
| `git diff --check` | 通过 |
- 复盘助手、交易日志、自选、笔记与提醒属于切片09,本切片不提前移动。
- 前端 DOM、页面 JS、CSS和移动端行为未改动,统一归档延至切片10。
- `static/heaven-loading.js`是否失效仍不确定,继续保留到切片11试删。
- 没有删除待定代码、没有修改正式数据库、没有测试或切换 Docker/NAS。
@@ -0,0 +1,71 @@
{
"all_equal": true,
"endpoints": [
{
"name": "heaven setup awaiting stock selection",
"method": "GET",
"endpoint": "/api/heaven/setup?trade_date=2026-07-29",
"original_status": 200,
"migrated_status": 200,
"original_sha256": "b72f2e972c3993717d639d1ccc68a9f1fb716a5846823a485a05458103465b92",
"migrated_sha256": "b72f2e972c3993717d639d1ccc68a9f1fb716a5846823a485a05458103465b92",
"equal": true,
"first_difference": null
},
{
"name": "trend interpretation history",
"method": "GET",
"endpoint": "/api/heaven/readings?mode=trend&limit=20",
"original_status": 200,
"migrated_status": 200,
"original_sha256": "2aa83ea177bd5cb2351ac4f3a60fdcd17e02cfc14b5753e157e515f03fc2c6c1",
"migrated_sha256": "2aa83ea177bd5cb2351ac4f3a60fdcd17e02cfc14b5753e157e515f03fc2c6c1",
"equal": true,
"first_difference": null
},
{
"name": "fortune interpretation history",
"method": "GET",
"endpoint": "/api/heaven/readings?mode=fortune&limit=20",
"original_status": 200,
"migrated_status": 200,
"original_sha256": "e60debaad54c58ad34fc7f025bfeaba7d2349aee2531b3f507c6e1968dbdcbf0",
"migrated_sha256": "e60debaad54c58ad34fc7f025bfeaba7d2349aee2531b3f507c6e1968dbdcbf0",
"equal": true,
"first_difference": null
},
{
"name": "heart interpretation history",
"method": "GET",
"endpoint": "/api/heaven/readings?mode=heart&limit=20",
"original_status": 200,
"migrated_status": 200,
"original_sha256": "1fe13531909c83c645b4fbb6740f46f2590adb346ab6e43414023967bb51b072",
"migrated_sha256": "1fe13531909c83c645b4fbb6740f46f2590adb346ab6e43414023967bb51b072",
"equal": true,
"first_difference": null
},
{
"name": "six-line hexagram calculation",
"method": "POST",
"endpoint": "/api/heaven/hexagram",
"original_status": 200,
"migrated_status": 200,
"original_sha256": "9773a9f288ea5fbe063b778fc47c613972fd7747f8fc6f80333316f33923c960",
"migrated_sha256": "9773a9f288ea5fbe063b778fc47c613972fd7747f8fc6f80333316f33923c960",
"equal": true,
"first_difference": null
},
{
"name": "personal five-phase calculation",
"method": "POST",
"endpoint": "/api/heaven/personal",
"original_status": 200,
"migrated_status": 200,
"original_sha256": "3b179ea4e3e0b43d12e929b4057a51264772fadcf41e5c87573dc5d18873bc9e",
"migrated_sha256": "3b179ea4e3e0b43d12e929b4057a51264772fadcf41e5c87573dc5d18873bc9e",
"equal": true,
"first_difference": null
}
]
}
@@ -0,0 +1,42 @@
[
{
"name": "heaven setup awaiting stock selection",
"method": "GET",
"endpoint": "/api/heaven/setup?trade_date=2026-07-29",
"payload": null
},
{
"name": "trend interpretation history",
"method": "GET",
"endpoint": "/api/heaven/readings?mode=trend&limit=20",
"payload": null
},
{
"name": "fortune interpretation history",
"method": "GET",
"endpoint": "/api/heaven/readings?mode=fortune&limit=20",
"payload": null
},
{
"name": "heart interpretation history",
"method": "GET",
"endpoint": "/api/heaven/readings?mode=heart&limit=20",
"payload": null
},
{
"name": "six-line hexagram calculation",
"method": "POST",
"endpoint": "/api/heaven/hexagram",
"payload": {
"lines": [7, 8, 9, 6, 7, 8]
}
},
{
"name": "personal five-phase calculation",
"method": "POST",
"endpoint": "/api/heaven/personal",
"payload": {
"trade_date": "2026-07-29"
}
}
]
@@ -0,0 +1,59 @@
{
"all_equal": true,
"schema": {
"object_count": 62,
"original_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1",
"migrated_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1",
"equal": true
},
"tables": [
{
"table": "heaven_readings",
"original_count": 31,
"migrated_count": 31,
"original_sha256": "e6cfc86f010fb182d7522bb99e5d268e826f93896035172120df51a011590951",
"migrated_sha256": "e6cfc86f010fb182d7522bb99e5d268e826f93896035172120df51a011590951",
"equal": true
},
{
"table": "users",
"original_count": 3,
"migrated_count": 3,
"original_sha256": "4a0f135bef8ebae454693d3f40e1157d84d814d18d849f33e2e760ed1d8a7f89",
"migrated_sha256": "4a0f135bef8ebae454693d3f40e1157d84d814d18d849f33e2e760ed1d8a7f89",
"equal": true
},
{
"table": "system_settings",
"original_count": 1,
"migrated_count": 1,
"original_sha256": "86d333a5c7feaf7111cd79b2db29513326a9b0d3781607f78bf633579e2ca8e9",
"migrated_sha256": "86d333a5c7feaf7111cd79b2db29513326a9b0d3781607f78bf633579e2ca8e9",
"equal": true
},
{
"table": "llm_usage",
"original_count": 72,
"migrated_count": 72,
"original_sha256": "03a070316a125cef904bbfb2bb06b06e792242d5713142e70354402c741393c3",
"migrated_sha256": "03a070316a125cef904bbfb2bb06b06e792242d5713142e70354402c741393c3",
"equal": true
},
{
"table": "user_birth_profiles",
"original_count": 1,
"migrated_count": 1,
"original_sha256": "2a74d4b2edd3a730a46f17fb6ab3926575c42662c1d968643b0d54586ab68b13",
"migrated_sha256": "2a74d4b2edd3a730a46f17fb6ab3926575c42662c1d968643b0d54586ab68b13",
"equal": true
},
{
"table": "sector_phase_overrides",
"original_count": 0,
"migrated_count": 0,
"original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
"migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
"equal": true
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

+5 -5
View File
@@ -1,6 +1,6 @@
{
"schema_version": 1,
"updated_at": "2026-07-31T04:17:23+08:00",
"updated_at": "2026-07-31T08:55:17+08:00",
"status": "active",
"migration_mode": "behavior_preserving_source_migration",
"source_of_truth": "current_original_webapp_runtime_and_source",
@@ -9,10 +9,10 @@
"failed_roots": [
"next"
],
"current_slice": "slice-08-heaven-trend-fortune-heart",
"last_completed_slice": "slice-07-mentor-skills-llm-streaming",
"last_checkpoint": "xiaobai-preservation-slice-07-20260731",
"next_action": "capture_slice-08_heaven_trend_fortune_heart_and_animation_contracts_then_move_original_implementations",
"current_slice": "slice-09-review-watchlist-notes-journal-alerts-assistant",
"last_completed_slice": "slice-08-heaven-trend-fortune-heart",
"last_checkpoint": "xiaobai-preservation-slice-08-20260731",
"next_action": "capture_slice-09_review_private_data_and_assistant_contracts_then_move_original_implementations",
"authoritative_documents": [
"AGENTS.md",
"docs/migration/原版保真迁移总纲.md",
+14 -1
View File
@@ -1,6 +1,6 @@
# 小白复盘保真迁移账本
> 当前状态:正式迁移,切片07“问师、模型 Skill 与 LLM 流式链路”已完成
> 当前状态:正式迁移,切片08“问天、观势、观气与观心”已完成
本账本是上下文恢复和人工审计的连续记录。任何迁移提交必须在同一提交中更新本文件及
`保真迁移状态.json`
@@ -27,6 +27,7 @@
| 2026-07-31 | `xiaobai-preservation-slice-05-20260731` | 集合竞价、题材库、人气热榜与龙虎榜原实现归位 | 自动、API、数据库与浏览器差分通过,进入切片06 |
| 2026-07-31 | `xiaobai-preservation-slice-06-20260731` | 智能选股、自定义选股与策略持续跟踪原实现归位 | 自动、API、数据库与浏览器差分通过,进入切片07 |
| 2026-07-31 | `xiaobai-preservation-slice-07-20260731` | 问师、模型Skill与LLM流式链路原实现归位 | 自动、API、数据库、Skill与浏览器差分通过,进入切片08 |
| 2026-07-31 | `xiaobai-preservation-slice-08-20260731` | 问天、观势、观气与观心原实现归位 | 自动、API、数据库、动画与浏览器差分通过,进入切片09 |
## 资产处置登记
@@ -58,6 +59,8 @@
| 问师消息与偏好方法 | 持久化 | 用户对话、置顶和排序 | 按职责机械移动并保持Mixin原接口 | `app/backend/features/mentor/repository.py` | 5个方法AST一致;相关表逐行一致 | 已移动 |
| `llm_stream.py`与模型访问方法 | 公共模型能力 | 问师、问天、复盘助手与策略编译 | 移入唯一模型边界并保留兼容别名 | `app/backend/llm/` | 流式文件哈希一致;21个服务方法与既有用户边界一致 | 已移动 |
| 公开`游资skills` | 运行资产 | 问师模型库 | 原样保留 | `app/游资skills/` | 190个文件逐路径和SHA-256一致 | 已复制 |
| `heaven_agent.py``heaven_engine.py`与问天服务 | 业务计算 | 观势、观气、观心 | 机械移动并保留兼容别名 | `app/backend/features/heaven/` | Agent文件哈希、引擎定义AST、20个服务方法、6个API及真实动画页面等价 | 已移动 |
| 问天历史方法 | 持久化 | 三类解读历史与当日解运复用 | 按职责机械移动 | `app/backend/features/heaven/repository.py` | 5个方法AST一致;62个schema对象及6张关键表逐行一致 | 已移动 |
处置只允许:`原样保留``移动``合并重复``待定``确认废弃`
@@ -145,6 +148,16 @@
- 回档:标签`xiaobai-preservation-slice-07-20260731`
- 完整证据:`docs/migration/evidence/slice-07/README.md`
已完成切片:`slice-08-heaven-trend-fortune-heart`
- 原版基线:提交`2919229`,即切片07回档点。
- 迁移范围:问天Agent、历法/卦象引擎、20个服务方法、5个持久化方法及3个HTTP入口。
- 兼容边界:根级`heaven_agent.py``heaven_engine.py`指向正式模块对象;原动画、DOM、JS与CSS未改动。
- API与数据库:6个固定输入真实API完全一致;62个schema对象及6张关键表逐行一致。
- 验收:原版231项、迁移版280项Python测试、7项切片源码等价测试、45项Playwright及1080P三模式动画页面通过。
- 回档:标签`xiaobai-preservation-slice-08-20260731`
- 完整证据:`docs/migration/evidence/slice-08/README.md`
## 决策记录
| 日期 | 决策 | 原因 |