266 lines
18 KiB
Python
266 lines
18 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
from typing import Any
|
||
|
||
from backend.llm import transport as llm_transport
|
||
|
||
|
||
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],
|
||
api_key: str,
|
||
base_url: str,
|
||
model: str,
|
||
timeout: int = 90,
|
||
) -> dict[str, Any]:
|
||
if mode not in {"trend", "fortune", "heart"}:
|
||
raise HeavenAgentError("不支持的问天解读模式。")
|
||
if not api_key or not model:
|
||
raise HeavenAgentError("LLM API Key 或模型尚未配置。")
|
||
system_prompt = _system_prompt(mode)
|
||
messages = [
|
||
{"role": "system", "content": system_prompt},
|
||
{
|
||
"role": "user",
|
||
"content": json.dumps(context, ensure_ascii=False, separators=(",", ":")),
|
||
},
|
||
]
|
||
try:
|
||
result = llm_transport.chat_completion(
|
||
api_key=api_key,
|
||
base_url=base_url,
|
||
model=model,
|
||
messages=messages,
|
||
timeout=timeout,
|
||
user_agent="XiaobaiReviewWeb/0.7",
|
||
)
|
||
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:
|
||
raise HeavenAgentError(f"问天模型调用失败:{exc}") from exc
|
||
return {
|
||
"answer": answer,
|
||
"model": model,
|
||
"latency_ms": result.latency_ms,
|
||
}
|
||
|
||
|
||
def _system_prompt(mode: str) -> str:
|
||
common = """
|
||
你是“小白复盘”的问天解读器。输入由calculation、knowledge和interpretation_contract组成:calculation是确定性程序已经算出的事实;knowledge是本次按条件精确检索到的原典、传统规则和产品边界;interpretation_contract规定本次必须回答与禁止推断的内容。
|
||
只能综合输入中已经提供的事实和知识。不得改卦、改爻、改纳甲、改世应、改干支、重新计算五运六气,也不得凭模型记忆补造缺失字段。知识记录之间若存在张力,应说明条件与分歧,不要强行合成唯一结论。
|
||
问天属于传统文化与自我观察,不是可验证的行情预测模型。必须给出有内容的倾向和依据,但不得把象义宣布为必然发生的股价结果,不输出无条件买卖指令,不用神秘话术制造确定性。
|
||
使用中文和普通用户能够理解的表达。专业术语首次出现时紧接一句白话解释。先给核心判断,再说明证据和变化关系。每个主题必须使用独立一行的简短标题,格式为“## 标题”,标题后另起一段正文;不得把全部内容挤在一个长段落中。可以使用Markdown加粗,不使用Markdown表格。
|
||
""".strip()
|
||
if mode == "trend":
|
||
return common + """
|
||
|
||
当前任务是“观势·解势”。行情只负责在进入模型之前生成卦象,本次回答不得引用或反推指数涨跌、成交额、涨跌停、板块强弱或个股表现,也不得说明某一爻原先对应哪类市场指标。
|
||
calculation中有意不提供股票、行业和板块身份。不得猜测或讨论观察对象所属行业、政策、消费环境、基本面、资金面或任何现实市场变量;只解释已经生成的卦象。
|
||
必须明确给出卦义上的当下倾向、主要矛盾、实际动爻所示的转折,以及本卦走向之卦后的变化方向。允许使用偏进、偏守、先难后易、由盛转收、转机有限、内外相违或结论有条件等相对判断;不得只罗列卦辞,也不得用“谨慎、等待、守信、辨伪”一类泛化劝诫代替解势。
|
||
以knowledge中本卦、上下卦、卦辞、彖义、大象、实际动爻和之卦记录为依据。无动爻、一动爻和多动爻分别服从本次检索到的方法规则;多动爻有冲突时必须指出冲突,不得压成单一套话。
|
||
按“## 核心判断、## 卦势依据、## 动爻转折、## 之卦趋向、## 决策映射”组织答案;无动爻时仍保留“动爻转折”,明确说明本次无动爻并解释结构的延续条件。结尾可以把卦势翻译成克制的交易决策语言,但只能表达条件、节奏和需要验证的矛盾,不得预测具体涨跌、价格、日期或给出直接荐股结论。篇幅随动爻数量自然展开,不设置固定字数。
|
||
""".strip()
|
||
if mode == "fortune":
|
||
return common + """
|
||
|
||
当前任务是“观气·解运”。页面用于直观展示的五行权重、主导元素和预制复合断语已明确排除,不得自行恢复这些结果,也不得按百分比重新生成单一五行结论。
|
||
严格区分中运、司天在泉、当前主气客气、节气定位和日辰触发。先解释中运与司天在泉构成的年纲,再解释客气加临主气的当前关系,最后说明日辰如何触发;不得把同一项拆成多份证据重复计权。相生不直接等于吉,相克不直接等于凶。
|
||
必须使用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 + """
|
||
|
||
当前任务是“观心·解卦”。用户在起卦前确定的问题位于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)
|
||
)
|