feat: complete heaven readings and screener publication

This commit is contained in:
leefer
2026-08-05 23:44:17 +08:00
parent 6a058c2929
commit b3a21d05b7
34 changed files with 3538 additions and 225 deletions
+196 -19
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import re
from typing import Any
from backend.llm import transport as llm_transport
@@ -10,6 +11,13 @@ class HeavenAgentError(RuntimeError):
pass
HEAVEN_PROMPT_VERSIONS = {
"trend": "heaven-trend-v4",
"fortune": "heaven-fortune-v9",
"heart": "heaven-heart-v5",
}
def interpret_heaven(
mode: str,
context: dict[str, Any],
@@ -42,6 +50,34 @@ def interpret_heaven(
answer = str(result.content).strip()
if not answer:
raise KeyError("empty response")
try:
_validate_answer(mode, answer, context)
except HeavenAgentError as validation_error:
repair_messages = [
*messages,
{"role": "assistant", "content": answer},
{
"role": "user",
"content": (
f"上一版未通过本地一致性校验:{validation_error}"
"请依据最初输入完整重写最终答案,只修正违规推断并补齐必答项。"
"不得讨论校验、提示词或重写过程,只输出新的正式解读。"
),
},
]
repaired = llm_transport.chat_completion(
api_key=api_key,
base_url=base_url,
model=model,
messages=repair_messages,
timeout=timeout,
user_agent="XiaobaiReviewWeb/0.7",
)
answer = str(repaired.content).strip()
if not answer:
raise KeyError("empty repaired response")
_validate_answer(mode, answer, context)
result = repaired
except llm_transport.OpenAIHTTPError as exc:
raise HeavenAgentError(exc.describe("问天模型调用失败")) from exc
except (llm_transport.OpenAITransportError, KeyError) as exc:
@@ -55,34 +91,175 @@ def interpret_heaven(
def _system_prompt(mode: str) -> str:
common = """
你是“小白复盘”的问天解读器。所有历法、卦象、爻位和市场指标已经由确定性程序计算,你只能解释提供的数据,不得改卦、改爻、改干支或编造行情
问天属于传统文化与娱乐化观察,不是预测模型,不承诺应验,不输出无条件买卖指令,不用神秘话术制造确定性
使用中文,先给核心判断,再解释结构。引用市场数字时标明数据日期。输出纯文本,可使用简短标题
你是“小白复盘”的问天解读器。输入由calculation、knowledge和interpretation_contract组成:calculation是确定性程序已经算出的事实;knowledge是本次按条件精确检索到的原典、传统规则和产品边界;interpretation_contract规定本次必须回答与禁止推断的内容
只能综合输入中已经提供的事实和知识。不得改卦、改爻、改纳甲、改世应、改干支、重新计算五运六气,也不得凭模型记忆补造缺失字段。知识记录之间若存在张力,应说明条件与分歧,不要强行合成唯一结论
问天属于传统文化与自我观察,不是可验证的行情预测模型。必须给出有内容的倾向和依据,但不得把象义宣布为必然发生的股价结果,不输出无条件买卖指令,不用神秘话术制造确定性
使用中文和普通用户能够理解的表达。专业术语首次出现时紧接一句白话解释。先给核心判断,再说明证据和变化关系。每个主题必须使用独立一行的简短标题,格式为“## 标题”,标题后另起一段正文;不得把全部内容挤在一个长段落中。可以使用Markdown加粗,不使用Markdown表格。
""".strip()
if mode == "trend":
return common + """
当前任务是“观势·解势”。六爻从初爻到上爻依次是个股内核、个股外显、板块内核、板块外显、指数内核、指数外显;初二为地、三四为人、五上为天
行情数据只负责生成六爻,本次解势必须以卦象本身为主,不得根据指数涨跌、板块强弱、涨停家数、成交量或个股表现直接推演方向。context中不会提供这些数字,也不会提供爻位对应的市场角色
先解释本卦卦名的核心义、上下卦组合及大象;再只解释实际动爻所代表的转折,并说明本卦如何走向之卦;最后可把这一组卦势翻译成克制的市场语言
重点是“本卦为当下之势,动爻为变化关节,之卦为所趋之势”。不要说明某一动爻对应指数、板块或个股,也不要输出“一看指数、二看涨停家数”一类行情观察条件
全文控制在300至450个中文字符,最多四小段。卦理约占九成,市场翻译最多一句,只能落到节制、等待、守信、辨伪等行为态度,不得据此预测市场下一阶段、涨跌方向或动能变化。不直接荐股,不使用Markdown表格
不要使用“必然、确定、必涨、必跌、后续将、进入某阶段”等断语;天机只点出势的性质与变化关系,不替用户宣布结果。
当前任务是“观势·解势”。行情只负责在进入模型之前生成卦象,本次回答不得引用或反推指数涨跌、成交额、涨跌停、板块强弱或个股表现,也不得说明某一爻原先对应哪类市场指标
calculation中有意不提供股票、行业和板块身份。不得猜测或讨论观察对象所属行业、政策、消费环境、基本面、资金面或任何现实市场变量;只解释已经生成的卦象
必须明确给出卦义上的当下倾向、主要矛盾、实际动爻所的转折,以及本卦走向之卦后的变化方向。允许使用偏进、偏守、先难后易、由盛转收、转机有限、内外相违或结论有条件等相对判断;不得只罗列卦辞,也不得用“谨慎、等待、守信、辨伪”一类泛化劝诫代替解势
以knowledge中本卦、上下卦、卦辞、彖义、大象、实际动爻和之卦记录为依据。无动爻、一动爻和多动爻分别服从本次检索到的方法规则;多动爻有冲突时必须指出冲突,不得压成单一套话
按“## 核心判断、## 卦势依据、## 动爻转折、## 之卦趋向、## 决策映射”组织答案;无动爻时仍保留“动爻转折”,明确说明本次无动爻并解释结构的延续条件。结尾可以把卦势翻译成克制的交易决策语言,但只能表达条件、节奏和需要验证的矛盾,不得预测具体涨跌、价格、日期或给出直接荐股结论。篇幅随动爻数量自然展开,不设置固定字数
""".strip()
if mode == "fortune":
return common + """
当前任务是“观气·解运”。严格区分五运、六气、节气、月令和日干,不把丙午简单解释为火年
严格服从five_phase_field.framework提供的确定性结构,不自行重新计算五行:年纲由中运司天在泉构成;岁半以前司天为主、在泉为辅,岁半以后在泉为主、司天为辅;当前六气层以客气加临主气为核心;日辰只负责触发。节气只用于定位当前六气阶段,不得再次叠加为独立力量
重点解释framework.relations中的客主同气、客生主、主生客、客克主或主克客,以及客胜为从、主胜为逆、司天在泉同位、天符岁会等已经判定的关系。不得把司天、在泉、主气、客气视为彼此独立的证据重复计权,也不得自行增删传统格局
首要解释当日气场容易放大参与者的哪些情绪、判断偏差和操作冲动,例如急躁、恐惧、迟疑、追涨、过早止损或路径依赖;再给出一至两个调节动作
如有personal_profile,结合其日主、十神、五行平衡倾向说明当日对该用户主观状态的影响,但不得把简化平衡倾向说成唯一喜用神,也不得复述或猜测出生日期
不得引用市场上涨下跌家数、涨跌停数量、成交额、板块强度或个股表现来证明气场。industry_affinity只是五行行业取象示例,不是行情旁证;行业契合度最多在末尾用一句话说明,不得写“当日共振”或暗示相关行业必然涨跌
全文控制在420至600个中文字符,按“三层气机、人的状态、操作偏向、个人影响(如有)、制衡动作”组织,标题必须写“三层气机”。明确这些是传统历法框架下的观察语言,不宣称气候或五行直接导致股价
当前任务是“观气·解运”。页面用于直观展示的五行权重、主导元素和预制复合断语已明确排除,不得自行恢复这些结果,也不得按百分比重新生成单一五行结论
严格区分中运司天在泉、当前主气客气、节气定位和日辰触发。先解释中运与司天在泉构成的年纲,再解释客气加临主气的当前关系,最后说明日辰如何触发;不得把同一项拆成多份证据重复计权。相生不直接等于吉,相克不直接等于凶
必须使用knowledge中与本日中运、六气和客主关系精确匹配的记录。可形成“湿热交蒸、燥中夹滞”一类复合表达,但要从输入关系逐层说明,不能从页面权重结论倒推
如calculation.personal存在,只结合日主、十神和当日派生关系说明用户容易出现的主观感受与判断偏差;不得使用简化强弱、喜用神、出生日期或权重平衡结论
personal.natal_day_master才是用户本命日主;today_relative_to_natal_day_master.pillars是当日历法,不是用户出生四柱。stem_relations只是当日年、月、日三柱天干相对本命日主的程序结果,只能使用knowledge中本次命中的关系释义,不得自行重算十神或扩展五行生克过程。不得使用藏干、支中藏某干、某支为某库或燥湿属性等输入未提供的信息,也不得把当日日柱写成用户命局,或推断用户命局中某个十神“较重”、身强身弱或喜用神
日辰只按calculation.day_trigger.summary与knowledge中的日辰边界解释,不得从干支另行推导藏气、库气或五行生克链。个人合参不得宣称本命日主被当日某气生扶、泄耗或克制,只能说明已给关系标签可能对应的主观注意点
day_trigger.summary中的日干运势、地支五行和六气对应是三个并列的确定性事实。不得把日柱整体改写成某一种五行,也不得把地支与六气的“对应”改写成地支自身具有某种六气属性
calculation.industry_symbols只提供五行与行业的传统取象归类及其本次出现依据。必须说明这些气机对相关行业可能形成的象征性关注、节奏或约束,但不得引入行业实时行情,不得预测行业涨跌或把取象写成投资推荐;未列入industry_symbols的行业不得自行补造。
按“## 年纲、## 客主加临、## 日辰触发、## 行业影响、## 个人合参、## 制衡动作”组织答案;没有个人资料时可以省略“个人合参”。不得用行情上涨下跌、行业表现或个股结果证明运气关系。篇幅按实际关系自然展开,不设置固定字数。
""".strip()
return common + """
当前任务是“观心·解卦”。用户的问题始终只在心中,没有输入给你,因此你不能猜测问题内容,也不能替用户作具体决定
全文控制在180至350个中文字符。只写一句卦意;一小段动爻与之卦;最后三句极短的问心句
不要重述六条爻辞,不猜用户未说出口的问题,不以吉凶二字替代思考,不给出股票涨跌预测。语气安静、克制,越短越有余味
当前任务是“观心·解卦”。用户在起卦前确定的问题位于calculation.questionquestion_preset只说明问题来源。必须针对实际问题作答;无题观心时不得猜测用户没有说出的事项
question_scope是本次问题的产品边界。trade预设专指股票交易中的参与条件、机会、阻碍和风险,不是商业合作、融资、借贷或寻找资金方;除非用户问题明确写出这些背景,否则不得擅自补入
纳甲、卦宫、世应、六亲、六神、月建日辰、旬空、伏神、动变和冲合关系已经由确定性程序给出。只能解释这些结果,不得自行改排盘、补用神或用模型记忆重算。六神只作辅助,任何单项都不能独立决定结论
六亲是关系类别,不是现实人物或资金来源的一一映射。必须使用knowledge中的六亲、旬空、动变和六神边界;不得把妻财直接写成现金或融资,把子孙写成资金提供方,把兄弟写成合作方,也不得由某一六神直接推出紧迫、欺骗或吉凶。
除非calculation.question明确说明用户已经持仓、买入、卖出或正在管理仓位,否则不得假定用户已经入场,不得使用“持仓、仓位、建仓、入场、持有、买入、卖出、止损、止盈”等措辞描述用户现状。可以只写尚待核对的参与条件、风险边界和决策倾向。
除非问题明确涉及融资、借贷、合作或资源安排,否则不得制造外围资金、外围资源、资金进入、资源进入,也不得虚构资金或资源的来源、提供、注入、安排和路径。
按“## 所问之答、## 卦象依据、## 动变与之卦、## 可验证之处”组织答案。先直接回应所问,再用白话解释本卦所示处境、世应与相关六亲、关键动爻和变爻,最后说明之卦趋向及一项可以由用户验证的动作。若证据相互冲突,应明确说明结论成立的条件,不以“吉、凶”二字替代推理。
交易问题可以判断参与条件、内外阻碍、风险和决策倾向,但不得宣告具体股价、涨跌日期或替用户作无条件买卖决定。不得用旬空、填实、出空或干支日推算“未来几日”或某日应验;可验证动作必须是用户当下能核对的交易条件或自身判断,不能制造现实中不存在的合作方、承诺、资金或资源安排。心境问题聚焦念头、压力和盲点;无题观心只作一般卦象观照。篇幅随问题和动爻复杂度自然展开,不使用固定三句模板,也不得输出使用竖线分栏的Markdown表格。
""".strip()
def _validate_answer(
mode: str, answer: str, context: dict[str, Any] | None = None
) -> None:
compact = "".join(answer.split())
if len(compact) < 60:
raise HeavenAgentError("问天模型返回内容过短,未形成有效解读。")
forbidden = ("必涨", "必跌", "保证上涨", "保证下跌", "无条件买入", "无条件卖出")
if any(term in answer for term in forbidden):
raise HeavenAgentError("问天模型返回了禁止的确定性行情断语。")
if mode == "fortune" and "%" in answer:
raise HeavenAgentError("解运结果错误引用了已排除的权重百分比。")
if mode == "trend":
market_narratives = (
"行业", "板块", "个股", "指数", "成交额", "涨停", "跌停",
"政策", "消费环境", "基本面", "资金面",
)
if any(term in answer for term in market_narratives):
raise HeavenAgentError("解势结果错误引入了卦象之外的现实市场叙事。")
if mode == "fortune":
if re.search(
r"(?:命局|个人本身).{0,16}(?:偏重|较重|过旺|过弱|身强|身弱|喜用神)",
answer,
):
raise HeavenAgentError("解运结果错误推断了输入中不存在的命局强弱。")
if re.search(
r"藏干|[子丑寅卯辰巳午未申酉戌亥](?:中|内)|[子丑寅卯辰巳午未申酉戌亥].{0,4}(?:含|藏)|(?:中|内)藏|余气|[辰戌丑未].{0,4}(?:火库|水库|金库|木库|土库|燥土|湿土)",
answer,
):
raise HeavenAgentError("解运结果使用了输入中未提供的藏干推断。")
if re.search(
r"木生火|火生土|土生金|金生水|水生木|木克土|土克水|水克火|火克金|金克木",
answer,
):
raise HeavenAgentError("解运结果自行扩展了输入中未提供的五行生克链。")
if re.search(
r"(?:本命)?日主.{0,32}(?:生扶|泄耗|受克|被克|得生|被生|偏强|偏弱)",
answer,
):
raise HeavenAgentError("解运结果把当日关系错误扩展成了本命强弱推断。")
if re.search(
r"(?:日柱)?[甲乙丙丁戊己庚辛壬癸][子丑寅卯辰巳午未申酉戌亥]"
r".{0,8}(?:本身|自身)(?:就)?是[木火土金水]",
answer,
):
raise HeavenAgentError("解运结果错误地把整个日柱归成了单一五行。")
if re.search(
r"[子丑寅卯辰巳午未申酉戌亥](?:的|具有|带有).{0,8}"
r"(?:风木|君火|湿土|相火|燥金|寒水)(?:之)?(?:属性|性质)",
answer,
):
raise HeavenAgentError("解运结果把六气对应误写成了地支自身属性。")
calculation = (context or {}).get("calculation") or {}
if calculation.get("industry_symbols") and "行业影响" not in answer:
raise HeavenAgentError("解运结果遗漏了本次必答的行业影响。")
if re.search(
r"行业.{0,16}(?:必涨|必跌|必然上涨|必然下跌|确定领涨|确定领跌|投资推荐)",
answer,
):
raise HeavenAgentError("解运结果把行业取象错误写成了行情预测或投资推荐。")
personal = calculation.get("personal") or {}
personal_today = personal.get("today_relative_to_natal_day_master") or {}
pillar_values = {
str(value)
for group in (calculation.get("pillars") or {}, personal_today.get("pillars") or {})
for value in group.values()
if value
}
mentioned_pillars = set(
re.findall(r"[甲乙丙丁戊己庚辛壬癸][子丑寅卯辰巳午未申酉戌亥]", answer)
)
if mentioned_pillars - pillar_values:
raise HeavenAgentError("解运结果补入了确定性输入中不存在的干支。")
month_pillar = str((calculation.get("pillars") or {}).get("month") or "")
month_branch = month_pillar[1:2]
mentioned_month_branches = set(
re.findall(r"([子丑寅卯辰巳午未申酉戌亥])月", answer)
)
if mentioned_month_branches - ({month_branch} if month_branch else set()):
raise HeavenAgentError("解运结果补入了当前月份之外的地支月。")
if personal:
relations = {
str(value)
for value in (personal_today.get("stem_relations") or {}).values()
if value
}
if "个人合参" not in answer and "本命日主" not in answer:
raise HeavenAgentError("解运结果遗漏了本次必答的个人合参。")
if relations and not any(relation in answer for relation in relations):
raise HeavenAgentError("解运结果未使用程序提供的当日关系标签。")
if mode != "heart":
return
calculation = (context or {}).get("calculation") or {}
question = str(calculation.get("question") or "")
if _contains_markdown_table(answer):
raise HeavenAgentError("解卦结果错误输出了Markdown表格。")
position_terms = (
"持仓", "仓位", "建仓", "入场", "持有", "买入", "卖出", "止损", "止盈",
)
if not any(term in question for term in position_terms) and any(
term in answer for term in position_terms
):
raise HeavenAgentError("解卦结果擅自假定了用户的持仓或买卖状态。")
financing_terms = (
"融资", "借贷", "合作", "出资", "资金来源", "资金方", "投资人", "投资方",
"外部资金", "外围资金", "外部资源", "外围资源",
)
invented_scenarios = (
"融资", "借贷", "合作方", "资金提供方", "资金意向", "资金注入", "自有资金",
"外围资金", "外围资源", "资金进入", "资源进入",
)
invented_resource_path = re.search(
r"(?:资金|资源).{0,8}(?:来源|提供|注入|安排|路径)", answer
)
if not any(term in question for term in financing_terms) and (
any(term in answer for term in invented_scenarios) or invented_resource_path
):
raise HeavenAgentError("解卦结果擅自补入了用户没有提出的融资或合作场景。")
timing_patterns = (
r"未来\s*[一二三四五六七八九十\d]+\s*(?:个)?(?:交易)?日",
r"[子丑寅卯辰巳午未申酉戌亥]{1,2}日(?:到来|来临|之前|之后|前后)",
r"(?:等待|等到|待).{0,16}(?:旬空|空亡).{0,16}(?:填实|出空)",
)
if any(re.search(pattern, answer) for pattern in timing_patterns):
raise HeavenAgentError("解卦结果错误使用旬空或干支推算了具体应期。")
def _contains_markdown_table(answer: str) -> bool:
return bool(
re.search(r"(?m)^\s*\|", answer)
or re.search(r"(?m)^\s*:?-{3,}:?\s*\|", answer)
or re.search(r"(?m)\|\s*:?-{3,}:?\s*(?:\||$)", answer)
)
+2
View File
@@ -733,6 +733,7 @@ def hexagram_from_lines(values: list[int]) -> dict[str, Any]:
return {
"name": primary["name"],
"text": primary["text"],
"tuan": primary.get("tuan") or "",
"image": primary.get("image") or "",
"inner_trigram": inner,
"outer_trigram": outer,
@@ -741,6 +742,7 @@ def hexagram_from_lines(values: list[int]) -> dict[str, Any]:
"transformed": {
"name": transformed["name"],
"text": transformed["text"],
"tuan": transformed.get("tuan") or "",
"image": transformed.get("image") or "",
"inner_trigram": transformed_inner,
"outer_trigram": transformed_outer,
+387
View File
@@ -0,0 +1,387 @@
from __future__ import annotations
import json
from functools import lru_cache
from typing import Any
from backend.bootstrap.config import APP_DIR
KNOWLEDGE_FILE = APP_DIR / "data" / "heaven_knowledge.json"
def prepare_heaven_context(mode: str, calculation: dict[str, Any]) -> dict[str, Any]:
"""Build the only context shape that may cross the LLM boundary."""
if mode == "trend":
prepared = _prepare_trend(calculation)
elif mode == "fortune":
prepared = _prepare_fortune(calculation)
elif mode == "heart":
prepared = _prepare_heart(calculation)
else:
raise ValueError("不支持的问天知识模式。")
prepared["knowledge"] = retrieve_heaven_knowledge(mode, prepared)
return prepared
def retrieve_heaven_knowledge(mode: str, context: dict[str, Any]) -> dict[str, Any]:
catalog = _knowledge_catalog()
source_ids: list[str]
records: list[dict[str, Any]]
if mode == "trend":
source_ids = ["zhouyi"]
records = _trend_records(catalog, context)
elif mode == "fortune":
source_ids = ["neijing"]
records = _fortune_records(catalog, context)
elif mode == "heart":
source_ids = ["zhouyi", "jingfang", "huozhulin", "zengshan"]
records = _heart_records(catalog, context)
else:
raise ValueError("不支持的问天知识模式。")
return {
"version": str(catalog.get("version") or ""),
"retrieval": "deterministic-keyed",
"sources": [
{"id": source_id, **dict(catalog["sources"][source_id])}
for source_id in source_ids
],
"records": records,
}
def _prepare_trend(context: dict[str, Any]) -> dict[str, Any]:
return {
"mode": "trend",
"calculation": {
"data_trade_date": context.get("data_trade_date") or "",
"hexagram": context.get("hexagram") or {},
"movement": context.get("movement") or {},
},
"interpretation_contract": {
"required": ["明确卦势倾向", "主要矛盾", "实际动爻转折", "本卦到之卦的变化关系"],
"allowed": ["偏进或偏守", "先难后易或由盛转收", "结论有条件或存在分歧"],
"forbidden": ["原始行情旁证", "具体涨跌预测", "时间点预测", "无条件买卖指令", "泛化劝诫代替解卦"],
},
}
def _prepare_fortune(context: dict[str, Any]) -> dict[str, Any]:
field = context.get("five_phase_field") or {}
framework = field.get("framework") or {}
relations = framework.get("relations") or {}
layers = {
str(item.get("id") or ""): item
for item in framework.get("layers") or []
if isinstance(item, dict)
}
six_qi = field.get("six_qi") or {}
movement = field.get("movement") or {}
pillars = field.get("pillars") or {}
personal = context.get("personal_profile") or {}
sector_catalog = {
str(group.get("element") or ""): [
str(item.get("name") or "").strip()
for item in group.get("industries") or []
if str(item.get("name") or "").strip()
]
for group in field.get("sector_catalog") or []
if isinstance(group, dict)
}
industry_bases: dict[str, list[str]] = {}
def add_industry_basis(element: str, basis: str) -> None:
if element not in sector_catalog or not sector_catalog[element]:
return
industry_bases.setdefault(element, [])
if basis not in industry_bases[element]:
industry_bases[element].append(basis)
add_industry_basis(str(movement.get("phase") or ""), "中运")
for label, qi in (
("司天", six_qi.get("sitian")),
("在泉", six_qi.get("zaiquan")),
("主气", six_qi.get("host_qi")),
("客气", six_qi.get("guest_qi")),
):
qi_text = str(qi or "")
add_industry_basis(qi_text[-1:] if qi_text else "", label)
day_master = personal.get("day_master") or {}
current = personal.get("current") or {}
personal_context = {}
if day_master:
current_ten_gods = current.get("ten_gods") or {}
personal_context = {
"natal_day_master": {
"stem": day_master.get("stem") or "",
"element": day_master.get("element") or "",
},
"today_relative_to_natal_day_master": {
"pillars": current.get("pillars") or {},
"stem_relations": {
key: str((current_ten_gods.get(key) or {}).get("stem") or "")
for key in ("year", "month", "day")
},
},
}
return {
"mode": "fortune",
"calculation": {
"calendar_date": context.get("calendar_date") or field.get("date") or "",
"lunar_date": field.get("lunar_date") or "",
"pillars": {
"year": pillars.get("year") or "",
"month": pillars.get("month") or "",
"day": pillars.get("day") or "",
},
"solar_terms": field.get("solar_terms") or {},
"year_movement": {
"phase": movement.get("phase") or "",
"tendency": movement.get("tendency") or "",
"label": movement.get("label") or "",
},
"annual_qi": {
"sitian": six_qi.get("sitian") or "",
"zaiquan": six_qi.get("zaiquan") or "",
"ruling": six_qi.get("ruling") or "",
"ruling_qi": six_qi.get("ruling_qi") or "",
"annual_pattern": relations.get("annual_pattern") or {},
},
"current_qi": {
"step": six_qi.get("step"),
"step_name": six_qi.get("step_name") or "",
"host_qi": six_qi.get("host_qi") or "",
"guest_qi": six_qi.get("guest_qi") or "",
"guest_host_relation": relations.get("guest_host") or {},
"alignment": relations.get("alignment") or six_qi.get("alignment") or "",
},
"day_trigger": {
"day_pillar": pillars.get("day") or "",
"summary": (layers.get("day") or {}).get("summary") or "",
},
"industry_symbols": [
{
"element": element,
"basis": bases,
"industries": sector_catalog[element],
}
for element, bases in industry_bases.items()
],
"personal": personal_context,
},
"excluded_from_interpretation": [
"五行权重与百分比",
"主导元素排序",
"权重生成的复合断语",
"预制情绪与交易行为结论",
"行业实时行情旁证",
"简化喜用神与强弱结论",
],
"interpretation_contract": {
"required": ["年纲", "当前客主加临", "日辰触发", "行业影响", "个人合参(如有)", "制衡动作"],
"forbidden": [
"重新计算五行权重",
"把相生直接判吉",
"把相克直接判凶",
"用市场涨跌证明气场",
"把行业取象写成行业涨跌预测或投资推荐",
"把当日日柱误称为用户命局",
"推断未提供的命局强弱或喜用神",
],
},
}
def _prepare_heart(context: dict[str, Any]) -> dict[str, Any]:
preset = str(context.get("question_preset") or "custom")
if preset not in {"trade", "mind", "unthemed", "custom"}:
preset = "custom"
return {
"mode": "heart",
"calculation": {
"question": str(context.get("question") or "").strip(),
"question_preset": preset,
"question_scope": {
"trade": "股票交易中的参与条件、机会、阻碍与风险,不是融资或商业合作问题。",
"mind": "影响股票交易判断的情绪、执念或盲点。",
"unthemed": "不指定事项的一般观照。",
"custom": "只按用户实际写出的事项理解,不补写背景。",
}[preset],
"ritual": context.get("ritual") or {},
"hexagram": context.get("hexagram") or {},
"six_yao": context.get("six_yao") or {},
},
"interpretation_contract": {
"required": ["回应所问", "本卦处境", "世应与相关六亲", "关键动变", "之卦趋向", "可验证动作"],
"plain_language": "专业术语首次出现时立即用白话解释。",
"forbidden": [
"修改纳甲排盘",
"猜测未输入的问题",
"把股票交易改写成融资或合作问题",
"把六亲直接等同于现实人物或资金来源",
"单凭六神或空亡断吉凶",
"根据旬空填实或干支日期预测应期",
"具体股价和时间点预测",
"无条件买卖指令",
],
},
}
def _trend_records(catalog: dict[str, Any], context: dict[str, Any]) -> list[dict[str, Any]]:
hexagram = (context.get("calculation") or {}).get("hexagram") or {}
moving = [line for line in hexagram.get("lines") or [] if line.get("moving")]
method_key = "stable" if not moving else "single" if len(moving) == 1 else "multiple"
rules = catalog["trend"]["rules"]
records = [
{"id": "trend-method", "source": "product_method", "text": catalog["trend"]["method"]},
{"id": f"trend-moving-{method_key}", "source": "product_method", "text": rules[method_key]},
_hexagram_record("primary", hexagram),
]
records.extend(_line_record(line) for line in moving)
transformed = hexagram.get("transformed") or {}
if transformed:
records.append(_hexagram_record("transformed", transformed))
return records
def _fortune_records(catalog: dict[str, Any], context: dict[str, Any]) -> list[dict[str, Any]]:
calculation = context.get("calculation") or {}
movement = calculation.get("year_movement") or {}
annual_qi = calculation.get("annual_qi") or {}
current_qi = calculation.get("current_qi") or {}
knowledge = catalog["fortune"]
records = [
{"id": "fortune-principle", "source": "neijing", "text": knowledge["principle"]},
]
if calculation.get("industry_symbols"):
records.append(
{
"id": "fortune-industry-boundary",
"source": "product_method",
"text": knowledge["industry_boundary"],
}
)
personal = calculation.get("personal") or {}
if personal:
records.append(
{
"id": "fortune-personal-boundary",
"source": "product_method",
"text": knowledge["personal_boundary"],
}
)
today = personal.get("today_relative_to_natal_day_master") or {}
relation_semantics = knowledge.get("personal_relations") or {}
for relation in dict.fromkeys((today.get("stem_relations") or {}).values()):
if relation in relation_semantics:
records.append(
{
"id": f"fortune-personal-{relation}",
"source": "product_method",
"subject": relation,
"text": relation_semantics[relation],
}
)
tendency = str(movement.get("tendency") or "")
if tendency in knowledge["movement"]:
records.append({"id": f"movement-{tendency}", "source": "neijing", "text": knowledge["movement"][tendency]})
for key in ("sitian", "zaiquan"):
qi = str(annual_qi.get(key) or "")
if qi in knowledge["qi"]:
records.append({"id": f"annual-{key}", "source": "neijing", "subject": qi, "text": knowledge["qi"][qi]})
for key in ("host_qi", "guest_qi"):
qi = str(current_qi.get(key) or "")
if qi in knowledge["qi"]:
records.append({"id": f"current-{key}", "source": "neijing", "subject": qi, "text": knowledge["qi"][qi]})
relation = current_qi.get("guest_host_relation") or {}
relation_type = str(relation.get("type") or "")
if relation_type in knowledge["relations"]:
records.append({"id": f"relation-{relation_type}", "source": "neijing", "subject": relation.get("label") or "", "text": knowledge["relations"][relation_type]})
records.append({"id": "day-trigger", "source": "neijing", "text": knowledge["day_trigger"]})
return records
def _heart_records(catalog: dict[str, Any], context: dict[str, Any]) -> list[dict[str, Any]]:
calculation = context.get("calculation") or {}
hexagram = calculation.get("hexagram") or {}
six_yao = calculation.get("six_yao") or {}
preset = str(calculation.get("question_preset") or "custom")
heart = catalog["heart"]
records = [
{"id": "heart-focus", "source": "product_method", "text": heart["focus"].get(preset, heart["focus"]["custom"])},
{"id": "heart-evidence-order", "source": "product_method", "items": heart["evidence_order"]},
{"id": "heart-limits", "source": "product_method", "text": heart["limits"]},
{"id": "heart-self-response", "source": "jingfang", "text": heart["semantics"]["self_response"]},
{"id": "heart-calendar", "source": "zengshan", "text": heart["semantics"]["calendar"]},
{"id": "heart-movement", "source": "huozhulin", "text": heart["semantics"]["movement"]},
{"id": "heart-six-spirits", "source": "zengshan", "text": heart["semantics"]["six_spirits"]},
{"id": "heart-timing-boundary", "source": "product_method", "text": heart["semantics"]["timing_boundary"]},
_hexagram_record("primary", hexagram),
]
relatives = {
str(line.get("relative") or "")
for line in six_yao.get("lines") or []
if line.get("relative")
}
for relative in sorted(relatives):
text = (heart["semantics"].get("relatives") or {}).get(relative)
if text:
records.append(
{
"id": f"heart-relative-{relative}",
"source": "huozhulin",
"subject": relative,
"text": text,
}
)
records.extend(_line_record(line) for line in hexagram.get("lines") or [] if line.get("moving"))
transformed = hexagram.get("transformed") or {}
if transformed:
records.append(_hexagram_record("transformed", transformed))
palace = six_yao.get("palace") or {}
records.append(
{
"id": "heart-palace",
"source": "jingfang",
"text": (
f"本卦归{palace.get('name') or '--'}{palace.get('stage') or '--'}"
f"世在{palace.get('self_position') or '--'}爻,应在{palace.get('response_position') or '--'}爻。"
),
}
)
return records
def _hexagram_record(kind: str, hexagram: dict[str, Any]) -> dict[str, Any]:
return {
"id": f"zhouyi-{kind}",
"source": "zhouyi",
"kind": kind,
"name": hexagram.get("name") or "",
"inner_trigram": hexagram.get("inner_trigram") or "",
"outer_trigram": hexagram.get("outer_trigram") or "",
"text": hexagram.get("text") or "",
"tuan": hexagram.get("tuan") or "",
"image": hexagram.get("image") or "",
}
def _line_record(line: dict[str, Any]) -> dict[str, Any]:
return {
"id": f"zhouyi-line-{line.get('position') or ''}",
"source": "zhouyi",
"position": line.get("position"),
"position_name": line.get("position_name") or "",
"line_name": line.get("line_name") or "",
"text": line.get("text") or "",
"image": line.get("image") or "",
}
@lru_cache(maxsize=1)
def _knowledge_catalog() -> dict[str, Any]:
payload = json.loads(KNOWLEDGE_FILE.read_text(encoding="utf-8"))
if not payload.get("version") or not isinstance(payload.get("sources"), dict):
raise ValueError("问天知识库格式不完整。")
return payload
+51 -27
View File
@@ -6,11 +6,17 @@ from datetime import date
from typing import Any
from backend.bootstrap.config import normalize_date
from backend.features.heaven.agent import HeavenAgentError, interpret_heaven
from backend.features.heaven.agent import (
HEAVEN_PROMPT_VERSIONS,
HeavenAgentError,
interpret_heaven,
)
from backend.features.heaven.engine import (
build_five_phase_field,
hexagram_from_lines,
)
from backend.features.heaven.knowledge import prepare_heaven_context
from backend.features.heaven.six_yao import build_six_yao_chart
from backend.features.market import MarketServiceMixin
@@ -75,9 +81,11 @@ class HeavenReadingMixin:
return subject, detail
hexagram = context.get("hexagram") or {}
transformed = hexagram.get("transformed") or {}
question = str(context.get("question") or "").strip()
question_detail = f" · {question[:48]}" if question else ""
return (
f"{display_date} 观心",
f"{hexagram.get('name') or '--'}{transformed.get('name') or '--'}",
f"{hexagram.get('name') or '--'}{transformed.get('name') or '--'}{question_detail}",
)
def heaven_interpret(self, payload: dict[str, Any]) -> dict[str, Any]:
@@ -85,6 +93,8 @@ class HeavenReadingMixin:
if mode not in {"trend", "fortune", "heart"}:
raise ValueError("问天解读模式不正确。")
trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
prompt_version = HEAVEN_PROMPT_VERSIONS[mode]
stale_fortune: dict[str, Any] | None = None
if mode == "fortune":
existing = self.database.latest_heaven_reading(
self.current_user_id, "fortune", trade_date
@@ -94,7 +104,9 @@ class HeavenReadingMixin:
self.current_user_id, int(existing["id"])
)
existing = None
if existing:
if existing and self.database.heaven_reading_interpretation_version(
self.current_user_id, int(existing["id"])
) == prompt_version:
return {
"answer": existing["answer"],
"mode": mode,
@@ -103,6 +115,7 @@ class HeavenReadingMixin:
"reading": existing,
"reused": True,
}
stale_fortune = existing
if mode in {"trend", "fortune"}:
setup = self.heaven_setup(
trade_date,
@@ -141,38 +154,44 @@ class HeavenReadingMixin:
setup["field"],
public=False,
)
fortune_field = json.loads(json.dumps(setup["field"], ensure_ascii=False))
catalog = fortune_field.pop("sector_catalog", [])
dominant_elements = {
item.get("element") for item in fortune_field.get("balance", [])[:2]
}
fortune_field["industry_affinity"] = [
{
"element": group.get("element"),
"examples": [
item.get("name")
for item in group.get("industries", [])[:8]
if item.get("name")
],
}
for group in catalog
if group.get("element") in dominant_elements
]
context = {
"calendar_date": setup["calendar_date"],
"five_phase_field": fortune_field,
"five_phase_field": setup["field"],
"personal_profile": personal_profile,
}
context_date = setup["calendar_date"]
if mode == "trend":
context_date = setup["trade_date"]
else:
question = str(payload.get("question") or "").strip()
if len(question) > 300:
raise ValueError("观心问题不能超过300个字符。")
question_preset = str(payload.get("question_preset") or "unthemed").strip()
if question_preset not in {"trade", "mind", "unthemed", "custom"}:
question_preset = "custom"
if not question:
question = "不设具体问题,只观此刻一念。"
question_preset = "unthemed"
raw_lines = payload.get("lines")
hexagram = self.heaven_hexagram(raw_lines)
context = {
"hexagram": self.heaven_hexagram(payload.get("lines")),
"ritual": "用户已完成30秒静心、六次三枚铜钱起卦,并在心中察看第一念。问题未输入。",
"question": question,
"question_preset": question_preset,
"hexagram": hexagram,
"six_yao": build_six_yao_chart(
[int(value) for value in raw_lines],
str(payload.get("cast_at") or ""),
),
"ritual": {
"breathing": "用户已完成1秒准备与五轮吸3秒、顿2秒、呼4秒的静心呼吸。",
"casting": "用户以三枚铜钱自初爻至上爻投掷六次。",
"reflection": "用户已在看见卦象后察看第一念。",
},
}
context_date = trade_date
result, compiler = self._call_heaven_agent(mode, context)
agent_context = prepare_heaven_context(mode, context)
agent_context["interpretation_version"] = prompt_version
result, compiler = self._call_heaven_agent(mode, agent_context)
subject, subject_detail = self._heaven_reading_identity(
mode, context_date, context
)
@@ -181,6 +200,10 @@ class HeavenReadingMixin:
if mode == "fortune"
else f"{mode}:{context_date}:{secrets.token_urlsafe(12)}"
)
if stale_fortune:
self.database.delete_heaven_reading(
self.current_user_id, int(stale_fortune["id"])
)
reading = self.database.save_heaven_reading(
self.current_user_id,
mode,
@@ -188,14 +211,14 @@ class HeavenReadingMixin:
subject,
subject_detail,
str(result.get("answer") or ""),
context,
agent_context,
dedupe_key,
)
return {
**result,
"mode": mode,
"compiler": compiler,
"notice": "智能解读已自动切换用服务。" if compiler == "fallback" else "",
"notice": "当前智能服务繁忙,已自动切换用服务。" if compiler == "fallback" else "",
"reading": reading,
"reused": False,
}
@@ -205,9 +228,10 @@ class HeavenReadingMixin:
return bool(reading and str(reading.get("answer") or "").rstrip().endswith("……"))
def _call_heaven_agent(self, mode: str, context: dict[str, Any]) -> tuple[dict[str, Any], str]:
prompt_version = HEAVEN_PROMPT_VERSIONS[mode]
result = self.llm_gateway.call(
f"heaven_{mode}",
f"heaven-{mode}-v1",
prompt_version,
lambda profile: interpret_heaven(
mode,
context,
+19
View File
@@ -131,6 +131,25 @@ class HeavenRepositoryMixin:
items = self.list_heaven_readings(user_id, mode, context_date, 1)
return items[0] if items else None
def heaven_reading_interpretation_version(
self, user_id: int, reading_id: int
) -> str:
with self.connect() as connection:
row = connection.execute(
"""
SELECT context_snapshot FROM heaven_readings
WHERE id = ? AND user_id = ?
""",
(int(reading_id), int(user_id)),
).fetchone()
if not row:
return ""
try:
snapshot = json.loads(str(row["context_snapshot"] or "{}"))
except (TypeError, json.JSONDecodeError):
return ""
return str(snapshot.get("interpretation_version") or "")
def delete_heaven_reading(self, user_id: int, reading_id: int) -> bool:
with self.connect() as connection:
cursor = connection.execute(
+432
View File
@@ -0,0 +1,432 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from functools import lru_cache
from typing import Any
from backend.features.heaven.engine import (
BRANCH_ELEMENT,
ELEMENT_CONTROLS,
ELEMENT_GENERATES,
LINE_POSITIONS,
Solar,
)
SHANGHAI = timezone(timedelta(hours=8), "Asia/Shanghai")
TRIGRAM_BITS = {
"": (1, 1, 1),
"": (1, 1, 0),
"": (1, 0, 1),
"": (1, 0, 0),
"": (0, 1, 1),
"": (0, 1, 0),
"": (0, 0, 1),
"": (0, 0, 0),
}
BITS_TRIGRAM = {bits: name for name, bits in TRIGRAM_BITS.items()}
PALACE_ELEMENT = {
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
}
# 京房纳甲通行表。每组均按初爻至三爻、四爻至上爻排列。
NAJIA = {
"": {
"inner": (("", ""), ("", ""), ("", "")),
"outer": (("", ""), ("", ""), ("", "")),
},
"": {
"inner": (("", ""), ("", ""), ("", "")),
"outer": (("", ""), ("", ""), ("", "")),
},
"": {
"inner": (("", ""), ("", ""), ("", "")),
"outer": (("", ""), ("", ""), ("", "")),
},
"": {
"inner": (("", ""), ("", ""), ("", "")),
"outer": (("", ""), ("", ""), ("", "")),
},
"": {
"inner": (("", ""), ("", ""), ("", "")),
"outer": (("", ""), ("", ""), ("", "")),
},
"": {
"inner": (("", ""), ("", ""), ("", "")),
"outer": (("", ""), ("", ""), ("", "")),
},
"": {
"inner": (("", ""), ("", ""), ("", "")),
"outer": (("", ""), ("", ""), ("", "")),
},
"": {
"inner": (("", ""), ("", ""), ("", "")),
"outer": (("", ""), ("", ""), ("", "")),
},
}
PALACE_STAGES = (
("本宫", (), 6),
("一世", (0,), 1),
("二世", (0, 1), 2),
("三世", (0, 1, 2), 3),
("四世", (0, 1, 2, 3), 4),
("五世", (0, 1, 2, 3, 4), 5),
("游魂", (0, 1, 2, 4), 4),
("归魂", (4,), 3),
)
SIX_SPIRITS = ("青龙", "朱雀", "勾陈", "螣蛇", "白虎", "玄武")
SPIRIT_START = {
"": 0,
"": 0,
"": 1,
"": 1,
"": 2,
"": 3,
"": 4,
"": 4,
"": 5,
"": 5,
}
BRANCH_CLASH = {
"": "", "": "", "": "", "": "",
"": "", "": "", "": "", "": "",
"": "", "": "", "": "", "": "",
}
BRANCH_COMBINE = {
"": "", "": "", "": "", "": "",
"": "", "": "", "": "", "": "",
"": "", "": "", "": "", "": "",
}
BRANCH_HARM = {
"": "", "": "", "": "", "": "",
"": "", "": "", "": "", "": "",
"": "", "": "", "": "", "": "",
}
THREE_PUNISHMENTS = (frozenset("寅巳申"), frozenset("丑未戌"), frozenset("子卯"))
SELF_PUNISHMENT = set("辰午酉亥")
ADVANCE_PAIRS = {
("", ""), ("", ""), ("", ""), ("", ""),
("", ""), ("", ""), ("", ""), ("", ""),
}
RETREAT_PAIRS = {(target, source) for source, target in ADVANCE_PAIRS}
def build_six_yao_chart(values: list[int], cast_at: str = "") -> dict[str, Any]:
"""Return a deterministic Jing Fang Na Jia chart for a six-coin result."""
if len(values) != 6 or any(value not in {6, 7, 8, 9} for value in values):
raise ValueError("六爻必须由六、七、八、九组成,且从初爻到上爻排列。")
observed_at = _parse_cast_at(cast_at)
solar = Solar.fromYmdHms(
observed_at.year,
observed_at.month,
observed_at.day,
observed_at.hour,
observed_at.minute,
observed_at.second,
)
lunar = solar.getLunar()
month_gz = lunar.getMonthInGanZhiExact()
day_gz = lunar.getDayInGanZhiExact2()
time_gz = lunar.getTimeInGanZhi()
void_branches = tuple(lunar.getDayXunKongExact2())
month_branch = month_gz[1]
day_stem, day_branch = day_gz[0], day_gz[1]
bits = tuple(1 if value % 2 else 0 for value in values)
transformed_values = tuple(7 if value == 6 else 8 if value == 9 else value for value in values)
transformed_bits = tuple(1 if value % 2 else 0 for value in transformed_values)
palace = _palace_map()[bits]
palace_element = PALACE_ELEMENT[palace["trigram"]]
self_position = int(palace["self_position"])
response_position = self_position + 3 if self_position <= 3 else self_position - 3
najia = _najia_for_bits(bits)
transformed_najia = _najia_for_bits(transformed_bits)
spirits = tuple(
SIX_SPIRITS[(SPIRIT_START[day_stem] + index) % 6] for index in range(6)
)
lines: list[dict[str, Any]] = []
for index, ((stem, branch), value) in enumerate(zip(najia, values)):
position = index + 1
element = BRANCH_ELEMENT[branch]
transformed_stem, transformed_branch = transformed_najia[index]
transformed_element = BRANCH_ELEMENT[transformed_branch]
line = {
"position": position,
"position_name": LINE_POSITIONS[index],
"value": value,
"yin_yang": "" if value % 2 else "",
"moving": value in {6, 9},
"stem": stem,
"branch": branch,
"element": element,
"relative": _six_relative(palace_element, element),
"spirit": spirits[index],
"role": "" if position == self_position else "" if position == response_position else "",
"void": branch in void_branches,
"month": _calendar_relation("", month_branch, branch),
"day": _calendar_relation("", day_branch, branch),
}
if line["moving"]:
line["transformation"] = {
"value": transformed_values[index],
"yin_yang": "" if transformed_values[index] % 2 else "",
"stem": transformed_stem,
"branch": transformed_branch,
"element": transformed_element,
"relative": _six_relative(palace_element, transformed_element),
"relation_to_origin": _transformation_relation(
branch,
element,
transformed_branch,
transformed_element,
),
}
lines.append(line)
hidden = _hidden_spirits(palace["trigram"], palace_element, lines)
for item in hidden:
lines[item["position"] - 1].setdefault("hidden_spirits", []).append(item)
return {
"method": "京房纳甲·八宫世应",
"method_version": "xiaobai-six-yao-v1",
"sources": ["jingfang", "huozhulin", "zengshan"],
"cast_at": observed_at.isoformat(timespec="seconds"),
"timezone": "Asia/Shanghai",
"day_boundary": "晚子时仍按民用当日排日柱",
"calendar": {
"month": month_gz,
"month_branch": month_branch,
"day": day_gz,
"day_branch": day_branch,
"time": time_gz,
"day_void": "".join(void_branches),
},
"palace": {
"name": f"{palace['trigram']}",
"trigram": palace["trigram"],
"element": palace_element,
"stage": palace["stage"],
"self_position": self_position,
"response_position": response_position,
},
"lines": lines,
"hidden_spirits": hidden,
"branch_pattern": _hexagram_branch_pattern(lines),
"relationships": _significant_line_relationships(lines),
}
def _parse_cast_at(raw: str) -> datetime:
value = str(raw or "").strip()
if not value:
return datetime.now(SHANGHAI)
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise ValueError("起卦时间格式不正确。") from exc
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=SHANGHAI)
return parsed.astimezone(SHANGHAI)
@lru_cache(maxsize=1)
def _palace_map() -> dict[tuple[int, ...], dict[str, Any]]:
result: dict[tuple[int, ...], dict[str, Any]] = {}
for trigram, trigram_bits in TRIGRAM_BITS.items():
pure = trigram_bits + trigram_bits
for stage, flipped, self_position in PALACE_STAGES:
bits = list(pure)
for index in flipped:
bits[index] = 1 - bits[index]
key = tuple(bits)
if key in result:
raise RuntimeError("八宫映射出现重复卦象。")
result[key] = {
"trigram": trigram,
"stage": stage,
"self_position": self_position,
}
if len(result) != 64:
raise RuntimeError("八宫映射未覆盖六十四卦。")
return result
def _najia_for_bits(bits: tuple[int, ...]) -> tuple[tuple[str, str], ...]:
inner = BITS_TRIGRAM[bits[:3]]
outer = BITS_TRIGRAM[bits[3:]]
return tuple(NAJIA[inner]["inner"] + NAJIA[outer]["outer"])
def _six_relative(palace_element: str, line_element: str) -> str:
if line_element == palace_element:
return "兄弟"
if ELEMENT_GENERATES[line_element] == palace_element:
return "父母"
if ELEMENT_GENERATES[palace_element] == line_element:
return "子孙"
if ELEMENT_CONTROLS[palace_element] == line_element:
return "妻财"
return "官鬼"
def _calendar_relation(prefix: str, actor_branch: str, line_branch: str) -> dict[str, Any]:
actor_element = BRANCH_ELEMENT[actor_branch]
line_element = BRANCH_ELEMENT[line_branch]
labels = []
if actor_branch == line_branch:
labels.append(f"{prefix}{'' if prefix == '' else ''}")
if BRANCH_CLASH[actor_branch] == line_branch:
labels.append("月破" if prefix == "" else "日冲")
if BRANCH_COMBINE[actor_branch] == line_branch:
labels.append(f"{prefix}")
if BRANCH_HARM[actor_branch] == line_branch:
labels.append(f"{prefix}")
element_relation = _actor_element_relation(actor_element, line_element, prefix)
return {
"branch": actor_branch,
"element": actor_element,
"branch_relations": labels,
"element_relation": element_relation,
}
def _actor_element_relation(actor: str, target: str, prefix: str) -> str:
if actor == target:
return f"{prefix}与爻同气"
if ELEMENT_GENERATES[actor] == target:
return f"{prefix}生爻"
if ELEMENT_CONTROLS[actor] == target:
return f"{prefix}克爻"
if ELEMENT_GENERATES[target] == actor:
return f"爻生{prefix}"
return f"爻克{prefix}"
def _transformation_relation(
origin_branch: str,
origin_element: str,
target_branch: str,
target_element: str,
) -> list[str]:
labels = []
if (origin_branch, target_branch) in ADVANCE_PAIRS:
labels.append("化进神")
elif (origin_branch, target_branch) in RETREAT_PAIRS:
labels.append("化退神")
if BRANCH_COMBINE[origin_branch] == target_branch:
labels.append("化合")
if BRANCH_CLASH[origin_branch] == target_branch:
labels.append("化冲")
if target_element == origin_element:
labels.append("变爻同气")
elif ELEMENT_GENERATES[target_element] == origin_element:
labels.append("回头生")
elif ELEMENT_CONTROLS[target_element] == origin_element:
labels.append("回头克")
elif ELEMENT_GENERATES[origin_element] == target_element:
labels.append("原爻生变")
else:
labels.append("原爻克变")
return labels
def _hidden_spirits(
palace_trigram: str,
palace_element: str,
lines: list[dict[str, Any]],
) -> list[dict[str, Any]]:
present = {str(line["relative"]) for line in lines}
missing = {"父母", "兄弟", "子孙", "妻财", "官鬼"} - present
if not missing:
return []
pure_bits = TRIGRAM_BITS[palace_trigram] + TRIGRAM_BITS[palace_trigram]
result = []
for index, (stem, branch) in enumerate(_najia_for_bits(pure_bits)):
element = BRANCH_ELEMENT[branch]
relative = _six_relative(palace_element, element)
if relative not in missing:
continue
result.append(
{
"position": index + 1,
"position_name": LINE_POSITIONS[index],
"stem": stem,
"branch": branch,
"element": element,
"relative": relative,
"flying_relative": lines[index]["relative"],
}
)
return result
def _hexagram_branch_pattern(lines: list[dict[str, Any]]) -> str:
pairs = ((0, 3), (1, 4), (2, 5))
if all(BRANCH_CLASH[lines[left]["branch"]] == lines[right]["branch"] for left, right in pairs):
return "六冲"
if all(BRANCH_COMBINE[lines[left]["branch"]] == lines[right]["branch"] for left, right in pairs):
return "六合"
return ""
def _significant_line_relationships(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
result = []
for left_index in range(6):
for right_index in range(left_index + 1, 6):
left = lines[left_index]
right = lines[right_index]
if not (left["moving"] or right["moving"] or left["role"] or right["role"]):
continue
labels = _branch_pair_relations(left["branch"], right["branch"])
element_relation = _pair_element_relation(left["element"], right["element"])
if not labels and element_relation == "同气":
continue
result.append(
{
"positions": [left["position"], right["position"]],
"lines": [left["position_name"], right["position_name"]],
"branch_relations": labels,
"element_relation": element_relation,
}
)
return result
def _branch_pair_relations(left: str, right: str) -> list[str]:
labels = []
if BRANCH_COMBINE[left] == right:
labels.append("六合")
if BRANCH_CLASH[left] == right:
labels.append("六冲")
if BRANCH_HARM[left] == right:
labels.append("六害")
pair = frozenset((left, right))
if pair in THREE_PUNISHMENTS or (left == right and left in SELF_PUNISHMENT):
labels.append("相刑")
return labels
def _pair_element_relation(left: str, right: str) -> str:
if left == right:
return "同气"
if ELEMENT_GENERATES[left] == right:
return "前者生后者"
if ELEMENT_GENERATES[right] == left:
return "后者生前者"
if ELEMENT_CONTROLS[left] == right:
return "前者克后者"
return "后者克前者"
+17 -5
View File
@@ -5,6 +5,7 @@ from typing import Any
from backend.bootstrap.config import normalize_date
from backend.data.providers.tushare_client import _sector_coverage_issue
from backend.features.heaven.agent import HEAVEN_PROMPT_VERSIONS
from backend.features.heaven.engine import build_five_phase_field, build_market_hexagram
@@ -157,11 +158,7 @@ class HeavenTrendMixin:
field,
public=True,
)
daily_fortune_reading = self.database.latest_heaven_reading(
self.current_user_id, "fortune", normalized_date
)
if self._legacy_truncated_heaven_reading(daily_fortune_reading):
daily_fortune_reading = None
daily_fortune_reading = self._reusable_daily_fortune_reading(normalized_date)
return {
"trade_date": data_date,
"calendar_date": normalized_date,
@@ -182,6 +179,21 @@ class HeavenTrendMixin:
},
}
def _reusable_daily_fortune_reading(
self, context_date: str
) -> dict[str, Any] | None:
reading = self.database.latest_heaven_reading(
self.current_user_id, "fortune", context_date
)
if not reading or self._legacy_truncated_heaven_reading(reading):
return None
version = self.database.heaven_reading_interpretation_version(
self.current_user_id, int(reading["id"])
)
if version != HEAVEN_PROMPT_VERSIONS["fortune"]:
return None
return reading
@staticmethod
def _heaven_market_mode(
trade_date: str,