diff --git a/app.py b/app.py index abeef76..1f2fb51 100644 --- a/app.py +++ b/app.py @@ -61,6 +61,12 @@ def page_stock(code): service=SERVICE_NAME, is_mock=IS_MOCK) +@app.route("/analysis/") +def page_analysis(aid): + return render_template("analysis_detail.html", aid=aid, + service=SERVICE_NAME, is_mock=IS_MOCK) + + # ===================================================================== 公共 def _indicators(code): rows = query("SELECT date,open,high,low,close,volume FROM stock_daily " @@ -365,13 +371,32 @@ def api_analyze(code): def api_analyze_status(code): from engine import analyst st = analyst.report_status(code) - if not st: - cached = analyst.get_cached_report(code) - if cached: - return jsonify({"status": "done", "report": cached["report"], - "cached": True, "created_at": cached["created_at"]}) - return jsonify({"status": "idle"}) - return jsonify(st) + if st: + return jsonify(st) + # 无运行中任务:返回最近一次历史分析 + hist = analyst.list_history(code, limit=1) + if hist: + detail = analyst.get_history(hist[0]["id"]) + return jsonify({"status": "done", "report": detail["report"], + "history_id": detail["id"], "created_at": detail["created_at"], + "cached": True}) + return jsonify({"status": "idle"}) + + +@app.route("/api/stock//analyses") +def api_stock_analyses(code): + from engine import analyst + return jsonify({"items": analyst.list_history(code)}) + + +@app.route("/api/analyses/") +def api_analysis_detail(aid): + from engine import analyst + d = analyst.get_history(aid) + if not d: + return jsonify({"error": "记录不存在"}), 404 + stock = query_one("SELECT code, name, industry, board FROM stocks WHERE code=?", (d["code"],)) + return jsonify({"analysis": d, "stock": stock}) # ------------------------------------------------------------------ 自选 diff --git a/database.py b/database.py index a94c331..aa77ba9 100644 --- a/database.py +++ b/database.py @@ -100,6 +100,17 @@ CREATE TABLE IF NOT EXISTS analysis_cache ( created_at TEXT DEFAULT (datetime('now','localtime')) ); +CREATE TABLE IF NOT EXISTS analysis_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL, + stock_name TEXT DEFAULT '', + focus TEXT DEFAULT '', + report TEXT, + sources TEXT DEFAULT '{}', -- JSON:大模型参考的数据源(RAG新闻/概况/指标/评级/持仓/提示词) + created_at TEXT DEFAULT (datetime('now','localtime')) +); +CREATE INDEX IF NOT EXISTS idx_history_code ON analysis_history(code); + CREATE TABLE IF NOT EXISTS market_index ( date TEXT PRIMARY KEY, sh REAL DEFAULT 0, -- 上证指数(点) @@ -165,7 +176,8 @@ def table_count(name): def wipe_all(): """清空业务表(保留结构)+ 重置自增序列,用于重灌数据""" for t in ("stock_daily", "inst_ratings", "fund_holdings", "news", - "institutions", "stocks", "watchlist", "analysis_cache", "market_index"): + "institutions", "stocks", "watchlist", "analysis_cache", "analysis_history", + "market_index"): with db() as conn: conn.execute(f'DELETE FROM "{t}"') with db() as conn: diff --git a/engine/analyst.py b/engine/analyst.py index 422e8bf..9a75014 100644 --- a/engine/analyst.py +++ b/engine/analyst.py @@ -59,6 +59,7 @@ def _rag_news(code, stock_name, query_text, top_k=6): "date": m.get("date", ""), "sentiment": m.get("sentiment", 0), "text": h.get("document", "")[:400], + "distance": round(h.get("distance", 0), 3), }) return out except Exception as e: @@ -140,7 +141,8 @@ def _build_prompt(stock, ind, news_hits, profile, ratings, holdings, score, focu def generate_report_sync(code, focus=""): - """同步生成报告(后台线程调用)""" + """同步生成报告(后台线程调用),并记录历史 + 数据源""" + import json stock = query_one("SELECT * FROM stocks WHERE code=?", (code,)) if not stock: return {"error": "股票不存在"} @@ -150,6 +152,17 @@ def generate_report_sync(code, focus=""): profile = _rag_profile(code) ratings, holdings = _inst_summary(code) prompt = _build_prompt(stock, ind, hits, profile, ratings, holdings, score, focus) + # 记录大模型参考的数据源(供详情页展示) + sources = { + "focus": focus, + "score": score, + "indicators": _fmt_indicators(ind), + "profile": profile or stock.get("description", ""), + "news": hits, + "ratings": ratings, + "holdings": holdings, + "prompt": prompt, + } try: report = llm_chat([ {"role": "system", "content": "你是一名严谨专业的A股投资顾问,输出结构化、简洁、可执行的研报。"}, @@ -160,6 +173,10 @@ def generate_report_sync(code, focus=""): raise RuntimeError("LLM 返回为空") execute("INSERT OR REPLACE INTO analysis_cache(code, report, created_at) VALUES(?,?,datetime('now','localtime'))", (code, report)) + execute( + "INSERT INTO analysis_history(code, stock_name, focus, report, sources, created_at) " + "VALUES(?,?,?,?,?,datetime('now','localtime'))", + (code, stock["name"], focus, report, json.dumps(sources, ensure_ascii=False))) return {"report": report, "ts": time.time()} except Exception as e: log.exception("gen report fail") @@ -221,3 +238,43 @@ def report_status(code): def get_cached_report(code): return query_one("SELECT report, created_at FROM analysis_cache WHERE code=?", (code,)) + + +# ------------------------------------------------------------------ 历史记录 +def list_history(code, limit=20): + """某股票的历史 AI 分析记录(不含正文,只返回摘要)""" + rows = query( + "SELECT id, code, stock_name, focus, created_at, sources, LENGTH(report) AS len, " + "SUBSTR(report, 1, 60) AS excerpt FROM analysis_history " + "WHERE code=? ORDER BY id DESC LIMIT ?", (code, limit)) + out = [] + for r in rows: + try: + import json + src = json.loads(r.get("sources") or "{}") + except Exception: + src = {} + out.append({ + "id": r["id"], "code": r["code"], "stock_name": r["stock_name"], + "focus": r["focus"], "created_at": r["created_at"], + "chars": r["len"], "excerpt": (r["excerpt"] or "").strip(), + "news_count": len(src.get("news") or []), + }) + return out + + +def get_history(aid): + """单条分析详情:正文 + 数据源 JSON""" + import json + row = query_one("SELECT * FROM analysis_history WHERE id=?", (aid,)) + if not row: + return None + try: + src = json.loads(row.get("sources") or "{}") + except Exception: + src = {} + return { + "id": row["id"], "code": row["code"], "stock_name": row["stock_name"], + "focus": row["focus"], "report": row["report"], "created_at": row["created_at"], + "sources": src, + } diff --git a/static/css/style.css b/static/css/style.css index 901f8fe..370d282 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -208,3 +208,17 @@ tr:hover td { background: rgba(59,130,246,.05); } .score-bar .sb-track { flex: 1; height: 6px; background: #262d3a; border-radius: 3px; overflow: hidden; } .score-bar .sb-fill { height: 100%; background: linear-gradient(90deg, #3b82f6, #8b5cf6); border-radius: 3px; } .score-bar .sb-val { width: 28px; text-align: right; color: var(--text); } + +/* ===== 数据源区块(分析详情页) ===== */ +.src-block { border: 1px solid var(--border); border-radius: 10px; margin-bottom: 10px; background: var(--bg2); overflow: hidden; } +.src-block summary { cursor: pointer; padding: 11px 14px; font-weight: 600; font-size: 13px; user-select: none; list-style: none; } +.src-block summary::-webkit-details-marker { display: none; } +.src-block summary:hover { background: rgba(59,130,246,.06); } +.src-count { float: right; color: var(--accent); font-weight: 700; } +.src-body { padding: 0 14px 12px; border-top: 1px solid #1f2630; } +.src-news { padding: 10px 0; border-bottom: 1px dashed #1f2630; } +.src-news:last-child { border-bottom: none; } +.src-news-title { font-weight: 600; margin-bottom: 5px; font-size: 13px; } +.src-news-meta { display: flex; gap: 10px; align-items: center; font-size: 12px; color: var(--muted); margin-bottom: 5px; flex-wrap: wrap; } +.src-news-text { font-size: 12px; color: var(--text2); line-height: 1.7; } +.src-pre { white-space: pre-wrap; word-break: break-word; font-size: 12px; color: var(--text2); line-height: 1.7; background: #0d1117; border: 1px solid #1f2630; border-radius: 8px; padding: 10px; overflow-x: auto; } diff --git a/static/js/analysis_detail.js b/static/js/analysis_detail.js new file mode 100644 index 0000000..2b51395 --- /dev/null +++ b/static/js/analysis_detail.js @@ -0,0 +1,89 @@ +/* AI 分析详情页:研报正文 + 数据源 */ +const CODE_AID = parseInt(location.pathname.split('/').pop(), 10); + +async function load() { + try { + const d = await api('/api/analyses/' + CODE_AID); + const a = d.analysis, stock = d.stock || {}; + $('#aStock').textContent = a.stock_name || stock.name || '—'; + $('#aCode').textContent = a.code; + $('#aIndustry').textContent = stock ? (stock.name + ' · ' + (stock.industry || '') + ' · ' + (stock.board || '')) : ''; + $('#aTime').textContent = a.created_at; + $('#aFocus').textContent = a.focus || '整体投资价值'; + $('#reportBox').innerHTML = mdRender(a.report); + renderSources(a.sources || {}); + } catch (e) { + $('#reportBox').innerHTML = '
加载失败:' + escapeHtml(e.message) + '
'; + } +} + +function srcBlock(title, icon, inner, count) { + return `
+ ${icon} ${title} ${count !== undefined ? `${count}` : ''} +
${inner || '
'}
+
`; +} + +function renderSources(s) { + let html = ''; + + // 1. RAG 相关资讯 + const news = s.news || []; + html += srcBlock('RAG 相关资讯(向量检索命中)', '📰', news.map(n => ` +
+
${escapeHtml(n.title)}
+
+ ${n.date || '—'} + 情感 ${Number(n.sentiment).toFixed(2)} + 相似度 ${(1 - (n.distance || 0)).toFixed(3)} +
+
${escapeHtml(n.text)}
+
`).join(''), news.length); + + // 2. 公司概况 + html += srcBlock('公司概况(RAG 检索)', '🏢', s.profile ? `

${escapeHtml(s.profile)}

` : '', s.profile ? 1 : 0); + + // 3. 技术指标 + html += srcBlock('技术指标', '📈', s.indicators ? `
${escapeHtml(s.indicators)}
` : '', s.indicators ? 1 : 0); + + // 4. 综合评分 + if (s.score) { + const sc = s.score; + html += srcBlock('综合评分', '🎯', ` +
+
总分
${sc.total}
+
评级
${sc.rating}
+
趋势
${sc.trend}
+
动量
${sc.momentum}
+
技术
${sc.technical}
+
量能
${sc.volume}
+
消息
${sc.news}
+
机构
${sc.institutional}
+
`, 1); + } + + // 5. 机构评级 + const ratings = s.ratings || []; + html += srcBlock('机构评级', '🏦', ratings.length ? ` + + ${ratings.map(r => ` + + `).join('')} +
机构评级目标价日期
${escapeHtml(r.inst_name)}${r.rating}${r.target_price}${r.rating_date}
` : '', ratings.length); + + // 6. 基金持仓 + const holdings = s.holdings || []; + html += srcBlock('基金持仓', '💼', holdings.length ? ` + + ${holdings.map(h => ` + + `).join('')} +
机构季度持仓市值环比
${escapeHtml(h.inst_name)}${h.quarter}${fmtNum(h.hold_value)}${fmtPct(h.change_pct)}
` : '', holdings.length); + + // 7. 完整提示词 + html += srcBlock('完整提示词(Prompt)', '🧠', s.prompt ? `
${escapeHtml(s.prompt)}
` : '', s.prompt ? 1 : 0); + + $('#sourceBox').innerHTML = html; +} + +load(); diff --git a/static/js/stock_detail.js b/static/js/stock_detail.js index 3908d45..e90c4b1 100644 --- a/static/js/stock_detail.js +++ b/static/js/stock_detail.js @@ -232,7 +232,9 @@ async function pollReport() { clearInterval(timer); $('#analyzeBtn').disabled = false; $('#reportBox').innerHTML = `
${mdRender(d.report)}
-
${d.cached ? '(缓存报告 ' + d.created_at + ')' : '生成耗时 ' + Math.round((Date.now() - t0) / 1000) + ' 秒'}
`; +
${d.cached ? '(最近一次分析记录,' + d.created_at + ')' : '生成耗时 ' + Math.round((Date.now() - t0) / 1000) + ' 秒'} + ${d.history_id ? ` · 查看数据源详情 ↗` : ''}
`; + loadHistory(); } else if (d.status === 'error') { clearInterval(timer); $('#analyzeBtn').disabled = false; @@ -246,7 +248,32 @@ async function pollReport() { }, 4000); } +/* 历史分析记录 */ +async function loadHistory() { + try { + const d = await api(`/api/stock/${CODE}/analyses`); + const items = d.items || []; + if (!items.length) { + $('#historyBox').innerHTML = '
暂无历史分析,点击上方「生成研报」开始
'; + return; + } + $('#historyBox').innerHTML = ` + + ${items.map(h => ` + + + + + + `).join('')} +
时间关注点引用资讯报告摘要操作
${h.created_at}${escapeHtml(h.focus || '整体投资价值')}${h.news_count} 条${escapeHtml(h.excerpt)}…
`; + } catch (e) { + $('#historyBox').innerHTML = '
历史记录加载失败
'; + } +} + $('#analyzeBtn').onclick = generateReport; $('#focusInput').addEventListener('keydown', e => { if (e.key === 'Enter') generateReport(); }); load(); +loadHistory(); diff --git a/templates/analysis_detail.html b/templates/analysis_detail.html new file mode 100644 index 0000000..5a51c5d --- /dev/null +++ b/templates/analysis_detail.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block title %}AI 分析详情{% endblock %} +{% block page_title %}AI 深度分析详情{% endblock %} +{% block content %} +
+
+
+
+
+
+ + 生成时间 + 关注点 +
+
+
+
+ + +
+
+
+ +
+
+
研报正文
+
加载中…
+
+
+
数据源(大模型参考内容)
+
加载中…
+
+
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/templates/stock_detail.html b/templates/stock_detail.html index 66d5b48..62b3239 100644 --- a/templates/stock_detail.html +++ b/templates/stock_detail.html @@ -71,6 +71,8 @@
点击「生成研报」,系统将基于 新闻RAG检索 + 技术指标 + 机构数据 调用 DeepSeek 生成结构化研报(首次生成约需 30-90 秒)
+
历史分析记录
+
加载中…