from __future__ import annotations import json import time import urllib.error import urllib.request from typing import Any class HeavenAgentError(RuntimeError): pass def interpret_heaven( mode: str, context: dict[str, Any], api_key: str, base_url: str, model: str, timeout: int = 90, ) -> dict[str, Any]: if mode not in {"trend", "fortune", "heart"}: raise HeavenAgentError("不支持的问天解读模式。") if not api_key or not model: raise HeavenAgentError("LLM API Key 或模型尚未配置。") system_prompt = _system_prompt(mode) payload = json.dumps( { "model": model, "messages": [ {"role": "system", "content": system_prompt}, { "role": "user", "content": json.dumps(context, ensure_ascii=False, separators=(",", ":")), }, ], "stream": False, }, ensure_ascii=False, ).encode("utf-8") request = urllib.request.Request( f"{base_url.rstrip('/')}/chat/completions", data=payload, headers={ "Content-Type": "application/json", "Authorization": f"Bearer {api_key}", "User-Agent": "XiaobaiReviewWeb/0.7", }, method="POST", ) started = time.perf_counter() try: with urllib.request.urlopen(request, timeout=timeout) as response: result = json.loads(response.read().decode("utf-8")) answer = str(result["choices"][0]["message"]["content"]).strip() if not answer: raise KeyError("empty response") except urllib.error.HTTPError as exc: raise HeavenAgentError(_http_error_message(exc)) from exc except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc: raise HeavenAgentError(f"问天模型调用失败:{exc}") from exc return { "answer": answer, "model": model, "latency_ms": round((time.perf_counter() - started) * 1000), } def _system_prompt(mode: str) -> str: common = """ 你是“小白复盘”的问天解读器。所有历法、卦象、爻位和市场指标已经由确定性程序计算,你只能解释提供的数据,不得改卦、改爻、改干支或编造行情。 问天属于传统文化与娱乐化观察,不是预测模型,不承诺应验,不输出无条件买卖指令,不用神秘话术制造确定性。 使用中文,先给核心判断,再解释结构。引用市场数字时标明数据日期。输出纯文本,可使用简短标题。 """.strip() if mode == "trend": return common + """ 当前任务是“观势·解势”。六爻从初爻到上爻依次是个股内核、个股外显、板块内核、板块外显、指数内核、指数外显;初二为地、三四为人、五上为天。 行情数据只负责生成六爻,本次解势必须以卦象本身为主,不得根据指数涨跌、板块强弱、涨停家数、成交量或个股表现直接推演方向。context中不会提供这些数字,也不会提供爻位对应的市场角色。 先解释本卦卦名的核心义、上下卦组合及大象;再只解释实际动爻所代表的转折,并说明本卦如何走向之卦;最后可把这一组卦势翻译成克制的市场语言。 重点是“本卦为当下之势,动爻为变化关节,之卦为所趋之势”。不要说明某一动爻对应指数、板块或个股,也不要输出“一看指数、二看涨停家数”一类行情观察条件。 全文控制在300至450个中文字符,最多四小段。卦理约占九成,市场翻译最多一句,只能落到节制、等待、守信、辨伪等行为态度,不得据此预测市场下一阶段、涨跌方向或动能变化。不直接荐股,不使用Markdown表格。 不要使用“必然、确定、必涨、必跌、后续将、进入某阶段”等断语;天机只点出势的性质与变化关系,不替用户宣布结果。 """.strip() if mode == "fortune": return common + """ 当前任务是“观气·解运”。严格区分五运、六气、节气、月令和日干,不把丙午简单解释为火年。 严格服从five_phase_field.framework提供的确定性结构,不自行重新计算五行:年纲由中运与司天在泉构成;岁半以前司天为主、在泉为辅,岁半以后在泉为主、司天为辅;当前六气层以客气加临主气为核心;日辰只负责触发。节气只用于定位当前六气阶段,不得再次叠加为独立力量。 重点解释framework.relations中的客主同气、客生主、主生客、客克主或主克客,以及客胜为从、主胜为逆、司天在泉同位、天符岁会等已经判定的关系。不得把司天、在泉、主气、客气视为彼此独立的证据重复计权,也不得自行增删传统格局。 首要解释当日气场容易放大参与者的哪些情绪、判断偏差和操作冲动,例如急躁、恐惧、迟疑、追涨、过早止损或路径依赖;再给出一至两个调节动作。 如有personal_profile,结合其日主、十神、五行平衡倾向说明当日对该用户主观状态的影响,但不得把简化平衡倾向说成唯一喜用神,也不得复述或猜测出生日期。 不得引用市场上涨下跌家数、涨跌停数量、成交额、板块强度或个股表现来证明气场。industry_affinity只是五行行业取象示例,不是行情旁证;行业契合度最多在末尾用一句话说明,不得写“当日共振”或暗示相关行业必然涨跌。 全文控制在420至600个中文字符,按“三层气机、人的状态、操作偏向、个人影响(如有)、制衡动作”组织,标题必须写“三层气机”。明确这些是传统历法框架下的观察语言,不宣称气候或五行直接导致股价。 """.strip() return common + """ 当前任务是“观心·解卦”。用户的问题始终只在心中,没有输入给你,因此你不能猜测问题内容,也不能替用户作具体决定。 全文控制在180至350个中文字符。只写一句卦意;一小段动爻与之卦;最后三句极短的问心句。 不要重述六条爻辞,不猜用户未说出口的问题,不以吉凶二字替代思考,不给出股票涨跌预测。语气安静、克制,越短越有余味。 """.strip() def _http_error_message(exc: urllib.error.HTTPError) -> str: detail = "" try: payload = json.loads(exc.read().decode("utf-8", errors="replace")) error = payload.get("error") if isinstance(error, dict): detail = str(error.get("message") or error.get("code") or "") elif error: detail = str(error) elif payload.get("message"): detail = str(payload["message"]) except (json.JSONDecodeError, OSError): detail = "" suffix = f":{detail[:300]}" if detail else "" return f"问天模型调用失败(HTTP {exc.code}){suffix}"