oilverse-api / agent /chat.py
孙家明
deploy: OilVerse for HuggingFace (Node.js 18 fix)
fab9847
Raw
History Blame Contribute Delete
18.8 kB
"""
agent/chat.py — Oil Risk Analyst Agent (混合架构: 本地模板 + LLM 增强)
========================================================================
- 常见问题: 直接从平台数据生成专业回答 (即时响应)
- 复杂分析: 调用 SiliconFlow Qwen2.5-7B-Instruct (带重试)
"""
import json, os, re, time
import pandas as pd
import numpy as np
from config import (
OUTPUT_DIR, SILICONFLOW_API_KEY, SILICONFLOW_BASE_URL, SILICONFLOW_MODEL
)
# ═══════════════════════════════════════════════════════════
# 数据层
# ═══════════════════════════════════════════════════════════
_cache = {}
def _results():
if 'results' not in _cache:
fp = os.path.join(OUTPUT_DIR, 'v2_championship_results.csv')
_cache['results'] = pd.read_csv(fp) if os.path.exists(fp) else None
return _cache['results']
def _reports():
if 'reports' not in _cache:
fp = os.path.join(OUTPUT_DIR, 'v2_nlg_reports.json')
if os.path.exists(fp):
with open(fp, 'r', encoding='utf-8') as f:
_cache['reports'] = json.load(f)
else:
_cache['reports'] = {}
return _cache['reports']
def _hedge():
if 'hedge' not in _cache:
fp = os.path.join(OUTPUT_DIR, 'v2_hedge_backtest.json')
if os.path.exists(fp):
with open(fp, 'r', encoding='utf-8') as f:
_cache['hedge'] = json.load(f)
else:
_cache['hedge'] = {}
return _cache['hedge']
def _events():
if 'events' not in _cache:
fp = os.path.join(OUTPUT_DIR, 'event_timeline.json')
if os.path.exists(fp):
with open(fp, 'r', encoding='utf-8') as f:
evts = json.load(f)
evts.sort(key=lambda e: e.get('date', ''), reverse=True)
_cache['events'] = evts
else:
_cache['events'] = []
return _cache['events']
def _latest():
"""获取最新预测行。"""
r = _results()
if r is None:
return None
return r.iloc[-1]
def _latest_report(benchmark='WTI'):
"""获取最新NLG报告。"""
rp = _reports()
if not rp:
return None
# Try specific benchmark first, then any
keys = sorted(rp.keys())
bm_keys = [k for k in keys if benchmark in k]
key = bm_keys[-1] if bm_keys else keys[-1]
entry = rp[key]
return entry if isinstance(entry, str) else entry.get('report', str(entry)[:500])
# ═══════════════════════════════════════════════════════════
# 本地回答引擎 — 常见问题秒回
# ═══════════════════════════════════════════════════════════
IND_MAP = {
'航空': 'aviation', 'aviation': 'aviation',
'物流': 'logistics', 'logistics': 'logistics',
'化工': 'chemical', 'chemical': 'chemical', 'chemicals': 'chemical',
'制造': 'manufacturing', 'manufacturing': 'manufacturing',
'上游': 'upstream', '油气': 'upstream', 'upstream': 'upstream',
}
IND_ZH = {'aviation': '航空', 'logistics': '物流', 'chemical': '化工',
'manufacturing': '制造', 'upstream': '上游油气'}
IND_PROFILE = {
'aviation': '航空燃油占运营成本30-40%,油价波动10%影响利润5-8%,敏感度最高',
'logistics': '柴油占物流成本25-35%,可通过燃油附加费部分传导,但存在时滞',
'chemical': '原油作为石化原料占成本40-60%,裂解价差直接影响利润率',
'manufacturing': '能源成本占制造成本10-20%,主要通过电价和天然气间接传导',
'upstream': '油价上涨是收入利好,但需防范暴跌风险保护资本开支',
}
def _try_local_answer(msg):
"""尝试本地回答,返回 (reply, confidence)。"""
m = msg.lower()
last = _latest()
if last is None:
return None, 0
# ── 1. 风险等级/研判 ──
if any(w in m for w in ['风险等级', '风险研判', '当前风险', '油价风险']):
report = _latest_report()
if report:
return report, 0.95
# ── 2. 完整报告/月度报告 ──
if any(w in m for w in ['完整报告', '月度报告', '详细分析', '报告']):
report = _latest_report()
if report:
return report, 0.9
# ── 3. 预测区间 ──
if any(w in m for w in ['预测区间', '分位数', 'q10', 'q50', 'q90', '下个月']):
q10 = last['pred_q10_1m']
q50 = last['pred_q50_1m']
q90 = last['pred_q90_1m']
vol = last['pred_vol']
date = str(last['test_date'])[:7]
reply = (f"**{date} 油价预测区间:**\n\n"
f"- **Q10 (悲观):** {q10:.1%} ← 有10%概率跌幅超此\n"
f"- **Q50 (中枢):** {q50:.1%} ← 最可能的变动\n"
f"- **Q90 (乐观):** {q90:.1%} ← 有10%概率涨幅超此\n"
f"- **波动率:** {vol:.1%}\n\n"
f"**解读:** 区间跨度{q90-q10:.1%},"
f"{'偏上行' if q50 > 0 else '偏下行'},"
f"波动率{vol:.1%}{'较高,建议增加对冲' if vol > 0.05 else '可控'}。")
return reply, 0.9
# ── 4. 风险趋势 ──
if any(w in m for w in ['趋势', '走势', '变化', '最近', '历史', '几个月']):
r = _results()
tail = r.tail(6)
lines = ["**近6个月风险趋势:**\n"]
for _, row in tail.iterrows():
date = str(row['test_date'])[:7]
lvl = row['risk_level']
bias = row['risk_bias']
top = row['top_factor']
q50 = row['pred_q50_1m']
emoji = {'High': '🔴', 'Medium-High': '🟠', 'Medium': '🟡',
'Low-Medium': '🔵', 'Low': '🟢'}.get(lvl, '⚪')
lines.append(f" {emoji} **{date}**: {lvl} | {bias} | 中枢{q50:+.1%} | 主导: {top}")
# 趋势判断
levels = tail['risk_level'].tolist()
level_map = {'Low': 0, 'Low-Medium': 1, 'Medium': 2, 'Medium-High': 3, 'High': 4}
nums = [level_map.get(l, 2) for l in levels]
if nums[-1] > nums[0]:
trend = "📈 总体趋势:风险**上升**"
elif nums[-1] < nums[0]:
trend = "📉 总体趋势:风险**下降**"
else:
trend = "➡️ 总体趋势:风险**持平**"
lines.append(f"\n{trend}")
return '\n'.join(lines), 0.9
# ── 5. 行业分析/对冲建议 ──
detected_ind = None
for kw, ind in IND_MAP.items():
if kw in m:
detected_ind = ind
break
if detected_ind or any(w in m for w in ['对冲', '套保', 'cfo', '行业']):
ind = detected_ind or 'aviation'
hedge = _hedge()
h = hedge.get(ind, {})
zh = IND_ZH.get(ind, ind)
profile = IND_PROFILE.get(ind, '')
risk_level = last.get(f"risk_level", "Medium")
q50 = last['pred_q50_1m']
vol = last['pred_vol']
ratio = h.get('recommended_ratio_pct', '50%')
tool = {'futures': '期货锁价', 'put': '看跌期权', 'collar': '零成本领口'}.get(
h.get('recommended_tool', 'futures'), '期货锁价')
rationale = h.get('rationale', '')
saving = h.get('total_saving', 0)
vol_red = h.get('vol_reduction', 0)
reply = (f"**{zh}行业专项分析报告**\n\n"
f"**一、行业画像**\n{profile}\n\n"
f"**二、当前油价环境**\n"
f"- 风险等级: **{risk_level}**\n"
f"- 1M预测中枢: **{q50:+.1%}**,波动率: **{vol:.1%}**\n"
f"- 主导因子: **{last.get('top_factor', 'N/A')}**\n\n"
f"**三、对冲建议**\n"
f"- 推荐对冲比例: **{ratio}**\n"
f"- 推荐工具: **{tool}**\n"
f"- 理由: {rationale}\n\n"
f"**四、历史回测**\n"
f"- 按推荐比例累计节省: **${saving:.1f}M**\n"
f"- 波动率降低: **{vol_red}%**\n\n"
f"**五、银行行动建议**\n")
if risk_level in ('High', 'Medium-High'):
reply += (f"1. 立即联络{zh}客户,提示油价上行风险\n"
f"2. 推荐对冲方案: {ratio} {tool},锁定未来3-6个月成本\n"
f"3. 建议预留流动性缓冲以应对波动\n")
else:
reply += (f"1. 常规跟进{zh}客户,当前风险可控\n"
f"2. 建议维持基础对冲({ratio}),无需过度套保\n"
f"3. 关注下一轮OPEC+会议可能的政策变化\n")
return reply, 0.95
# ── 6. 压力测试 ──
if any(w in m for w in ['压力', '如果', '假设', '中东', '冲突', '崩塌', '减产', '战争']):
vol = last['pred_vol']
q50 = last['pred_q50_1m']
# 识别冲击场景
supply_shock = -15 if any(w in m for w in ['供给', '减产', '中断', '中东', '冲突']) else 0
demand_shock = -20 if any(w in m for w in ['需求', '崩塌', '衰退']) else 0
geo_spike = 3 if any(w in m for w in ['地缘', '冲突', '中东', '战争']) else 1
shock = abs(supply_shock)/100 + abs(demand_shock)/100
stressed_vol = vol * (1 + shock) * (max(1, geo_spike) ** 0.5)
stress_level = 'High' if stressed_vol > 0.12 else ('Medium' if stressed_vol > 0.06 else 'Low')
scenario_name = []
if supply_shock: scenario_name.append(f'供给冲击{supply_shock}%')
if demand_shock: scenario_name.append(f'需求冲击{demand_shock}%')
if geo_spike > 1: scenario_name.append(f'地缘风险×{geo_spike}')
scenario = '、'.join(scenario_name) or '基准情景'
reply = (f"**压力测试结果 — {scenario}**\n\n"
f"- 基准波动率: **{vol:.1%}**\n"
f"- 冲击后波动率: **{stressed_vol:.1%}** ({stressed_vol/vol:.0%})\n"
f"- 压力风险等级: **{stress_level}**\n\n")
if stress_level == 'High':
reply += ("**⚠️ 高风险预警:**\n"
"1. 立即提升对冲比例至 **50%以上**\n"
"2. 启动紧急风控预案,增加保证金缓冲\n"
"3. 重点关注航空、化工等高敏感行业客户\n")
elif stress_level == 'Medium':
reply += ("**⚡ 中等风险:**\n"
"1. 建议维持 **30%** 对冲并密切关注\n"
"2. 做好应急方案预案\n"
"3. 适度增加库存\n")
else:
reply += ("**✅ 风险可控:**\n"
"1. 当前策略无需调整\n"
"2. 维持常规对冲即可\n")
return reply, 0.9
# ── 7. 模型验证 ──
if any(w in m for w in ['准确', '验证', '可靠', '覆盖率', 'wis', '模型']):
r = _results()
# Drop rows with NaN
valid = r.dropna(subset=['actual_ret_1m', 'pred_q10_1m', 'pred_q90_1m', 'pred_vol', 'actual_vol'])
ar = valid['actual_ret_1m'].values
q10 = valid['pred_q10_1m'].values
q90 = valid['pred_q90_1m'].values
pv = valid['pred_vol'].values
av = valid['actual_vol'].values
n = len(valid)
cov = ((ar >= q10) & (ar <= q90)).mean()
wis_val = ((q90-q10)+(2/0.2)*np.maximum(q10-ar,0)+(2/0.2)*np.maximum(ar-q90,0)).mean()
nq10 = np.quantile(ar, 0.10); nq90 = np.quantile(ar, 0.90)
naive_wis = ((nq90-nq10)+(2/0.2)*np.maximum(nq10-ar,0)+(2/0.2)*np.maximum(ar-nq90,0)).mean()
corr = np.corrcoef(av, pv)[0,1] if len(av) > 1 else 0
wis_pct = (1-wis_val/naive_wis)*100 if naive_wis != 0 else 0
reply = (f"**模型验证报告 (共 {n} 个月)**\n\n"
f"**核心指标:**\n"
f"- 80%区间覆盖率: **{cov:.1%}** (目标≥80%)\n"
f"- WIS得分: **{wis_val:.4f}** (优于基准 {wis_pct:+.1f}%)\n"
f"- 波动率相关性: **{corr:.3f}**\n\n"
f"**评估:** "
f"{'✅ 模型表现优异' if cov >= 0.75 and wis_pct > 0 else '⚠️ 模型有改进空间'}。"
f"覆盖率{cov:.1%}{'达标' if cov >= 0.75 else '偏低'},"
f"WIS{'优于' if wis_pct > 0 else '劣于'}朴素基准{abs(wis_pct):.1f}%。")
return reply, 0.9
# ── 无法本地回答 ──
return None, 0
# ═══════════════════════════════════════════════════════════
# LLM 增强 — 仅用于复杂/自定义分析
# ═══════════════════════════════════════════════════════════
SYSTEM_PROMPT = """你是「油刃有余 OilVerse」平台的AI助手「Oil Risk Agent」。
你拥有实时的平台预测数据和事件时间线,你的回答必须:
1. 先给结论(一句话加粗),再给支撑(3-5条要点),最后给行动建议
2. 用 **加粗** 标记关键数字和结论
3. 每次回答控制在 200 字以内
4. 绝对不要输出工具名、函数名、JSON等技术内容
5. 如果是闲聊,简短回答身份即可
6. 引用最近事件作为分析支撑,说明「事件→因子异动→风险信号→对冲建议」的因果链"""
def _build_data_context(msg):
"""为LLM构建精炼的数据上下文。"""
last = _latest()
if last is None:
return ""
ctx = [f"分析日期: {str(last['test_date'])[:7]}",
f"风险等级: {last['risk_level']}",
f"方向偏置: {last['risk_bias']}",
f"1M区间: [{last['pred_q10_1m']:.1%}, {last['pred_q90_1m']:.1%}]",
f"波动率: {last['pred_vol']:.1%}",
f"主导因子: {last['top_factor']}",
f"Regime匹配: {last.get('regime_match', 'N/A')} ({last.get('regime_similarity', 0):.0%})"]
# Add recent events as causal context
evts = _events()
if evts:
ctx.append('\n[近期关键事件]')
for ev in evts[:3]:
impact_zh = {'bullish': '利多', 'bearish': '利空', 'neutral': '中性'}.get(ev.get('impact', ''), '')
ctx.append(f"- {ev['date']} {ev['title']} ({impact_zh}): {ev.get('risk_signal', '')}")
return '\n'.join(ctx)
def _call_llm_enhanced(user_message, history):
"""调用 LLM,带精炼上下文。"""
import requests
data_ctx = _build_data_context(user_message)
enriched = f"{user_message}\n\n[平台数据]\n{data_ctx}" if data_ctx else user_message
messages = [{'role': 'system', 'content': SYSTEM_PROMPT}]
for h in history[-4:]: # 只保留最近2轮对话
messages.append(h)
messages.append({'role': 'user', 'content': enriched})
headers = {
'Authorization': f'Bearer {SILICONFLOW_API_KEY}',
'Content-Type': 'application/json',
}
payload = {
'model': SILICONFLOW_MODEL,
'messages': messages,
'temperature': 0.3,
'max_tokens': 500,
'stream': False,
}
last_err = None
for attempt in range(2):
try:
resp = requests.post(
f'{SILICONFLOW_BASE_URL}/chat/completions',
headers=headers, json=payload, timeout=45
)
resp.raise_for_status()
data = resp.json()
reply = data['choices'][0]['message']['content']
# 清理残留
reply = re.sub(r'</?tool_call>', '', reply)
reply = re.sub(r'\b(query_\w+|run_\w+)\(.*?\)', '', reply)
return reply.strip()
except requests.exceptions.Timeout:
last_err = "LLM响应超时"
time.sleep(2)
except requests.exceptions.ConnectionError:
last_err = "无法连接LLM服务"
time.sleep(2)
except Exception as e:
return f"LLM调用失败: {e}"
return f"⚠️ {last_err},请稍后重试。\n\n💡 你可以尝试更具体的问题,如「航空行业对冲建议」「当前风险等级」等,这些可以即时响应。"
# ═══════════════════════════════════════════════════════════
# 主入口
# ═══════════════════════════════════════════════════════════
def chat_with_agent(user_message, history=None):
"""
混合架构对话入口:
1. 先尝试本地回答(即时)
2. 无法本地回答时调用 LLM
"""
if history is None:
history = []
# 闲聊快速回复
greets = ['你好', '你是谁', 'hello', 'hi', '嗨', '在吗']
if any(user_message.strip().lower() == g for g in greets):
reply = "👋 你好!我是油价风险分析 Agent,基于平台实时数据为你提供专业分析。\n\n你可以问我:\n• 当前风险等级和预测区间\n• 行业专项分析(航空/物流/化工/制造/上游)\n• 对冲策略和工具推荐\n• 压力测试模拟\n• 模型验证指标"
history.append({'role': 'user', 'content': user_message})
history.append({'role': 'assistant', 'content': reply})
return reply, history
# 尝试本地回答
local_reply, confidence = _try_local_answer(user_message)
if local_reply and confidence >= 0.85:
history.append({'role': 'user', 'content': user_message})
history.append({'role': 'assistant', 'content': local_reply})
return local_reply, history
# LLM 增强回答
reply = _call_llm_enhanced(user_message, history)
history.append({'role': 'user', 'content': user_message})
history.append({'role': 'assistant', 'content': reply})
return reply, history
if __name__ == '__main__':
print("油价风险分析 Agent(输入 quit 退出)")
print("=" * 50)
h = []
while True:
q = input("\n你: ").strip()
if q.lower() in ('quit', 'exit', 'q'):
break
reply, h = chat_with_agent(q, h)
print(f"\nAgent: {reply}")