feat: complete heaven readings and screener publication
This commit is contained in:
@@ -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.question,question_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)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 "后者克前者"
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user