update
This commit is contained in:
parent
7523f2a48f
commit
188eb6015a
12
.env.example
12
.env.example
@ -20,6 +20,18 @@ ALPHAX_DEFAULT_ADMIN_PASSWORD=AlphaXAdmin123
|
|||||||
# ALTCOIN_FEISHU_WEBHOOK=https://open.feishu.cn/open-apis/bot/v2/hook/REDACTED
|
# ALTCOIN_FEISHU_WEBHOOK=https://open.feishu.cn/open-apis/bot/v2/hook/REDACTED
|
||||||
ALTCOIN_FEISHU_WEBHOOK=
|
ALTCOIN_FEISHU_WEBHOOK=
|
||||||
|
|
||||||
|
# LLM 解释层运行时配置。默认关闭;只异步生成缓存解释,不参与策略决策。
|
||||||
|
ALPHAX_LLM_ENABLED=0
|
||||||
|
ALPHAX_LLM_BASE_URL=https://api.openai.com/v1
|
||||||
|
ALPHAX_LLM_API_KEY=
|
||||||
|
ALPHAX_LLM_API_KEY_ENV=ALPHAX_LLM_API_KEY
|
||||||
|
ALPHAX_LLM_MODEL=gpt-4o-mini
|
||||||
|
ALPHAX_LLM_TIMEOUT=20
|
||||||
|
ALPHAX_LLM_MAX_TOKENS=900
|
||||||
|
ALPHAX_LLM_RECOMMENDATIONS_ENABLED=1
|
||||||
|
ALPHAX_LLM_SENTIMENT_ENABLED=1
|
||||||
|
ALPHAX_LLM_REVIEW_ENABLED=1
|
||||||
|
|
||||||
# 邮箱验证码 SMTP 配置。没有配置时,注册验证码只会生成,不会发邮件。
|
# 邮箱验证码 SMTP 配置。没有配置时,注册验证码只会生成,不会发邮件。
|
||||||
ASTOCK_SMTP_HOST=
|
ASTOCK_SMTP_HOST=
|
||||||
ASTOCK_SMTP_PORT=465
|
ASTOCK_SMTP_PORT=465
|
||||||
|
|||||||
@ -96,6 +96,25 @@ docker compose config
|
|||||||
|
|
||||||
> 当前机器如果没有 Docker,只能做离线文件/语法/DB 校验;到有 Docker 的机器上再执行 build/up。
|
> 当前机器如果没有 Docker,只能做离线文件/语法/DB 校验;到有 Docker 的机器上再执行 build/up。
|
||||||
|
|
||||||
|
## LLM 解释层配置
|
||||||
|
|
||||||
|
LLM 是运行时系统能力,不属于策略参数,不写入 `rules.yaml`。在 `.env` 中配置即可:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ALPHAX_LLM_ENABLED=1
|
||||||
|
ALPHAX_LLM_BASE_URL=https://api.openai.com/v1
|
||||||
|
ALPHAX_LLM_API_KEY=your-key
|
||||||
|
ALPHAX_LLM_MODEL=gpt-4o-mini
|
||||||
|
```
|
||||||
|
|
||||||
|
生成缓存解释:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec alphax-web python -m app.cli llm-insights --scope recommendations --limit 30
|
||||||
|
docker compose exec alphax-web python -m app.cli llm-insights --scope sentiment --limit 30
|
||||||
|
docker compose exec alphax-web python -m app.cli llm-insights --scope review --limit 10
|
||||||
|
```
|
||||||
|
|
||||||
## 数据迁移
|
## 数据迁移
|
||||||
|
|
||||||
当前副本是从线上目录复制来的,包含复制时刻的 `altcoin_monitor.db`。为了避免误影响线上,容器读写的是副本目录下的:
|
当前副本是从线上目录复制来的,包含复制时刻的 `altcoin_monitor.db`。为了避免误影响线上,容器读写的是副本目录下的:
|
||||||
|
|||||||
10
app/cli.py
10
app/cli.py
@ -29,6 +29,10 @@ def build_parser():
|
|||||||
sentiment.add_argument("--check", action="store_true", help="输出舆情异动")
|
sentiment.add_argument("--check", action="store_true", help="输出舆情异动")
|
||||||
sentiment.add_argument("--scores", action="store_true", help="输出评分")
|
sentiment.add_argument("--scores", action="store_true", help="输出评分")
|
||||||
|
|
||||||
|
llm = subparsers.add_parser("llm-insights", help="异步生成 LLM 缓存解释")
|
||||||
|
llm.add_argument("--scope", choices=["recommendations", "sentiment", "sentiment-events", "review"], default="recommendations")
|
||||||
|
llm.add_argument("--limit", type=int, default=30)
|
||||||
|
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@ -70,6 +74,12 @@ def main():
|
|||||||
}
|
}
|
||||||
print(sentiment_monitor.json.dumps(result, ensure_ascii=False, indent=2))
|
print(sentiment_monitor.json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
return result
|
return result
|
||||||
|
if args.command == "llm-insights":
|
||||||
|
from app.services import llm_insights
|
||||||
|
|
||||||
|
result = llm_insights.run(scope=args.scope, limit=args.limit)
|
||||||
|
print(sentiment_monitor.json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
return result
|
||||||
|
|
||||||
parser.error(f"unknown command: {args.command}")
|
parser.error(f"unknown command: {args.command}")
|
||||||
|
|
||||||
|
|||||||
@ -381,8 +381,39 @@ def init_db():
|
|||||||
""")
|
""")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_sentiment_lookup ON sentiment_events(symbol, source, detected_at)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_sentiment_lookup ON sentiment_events(symbol, source, detected_at)")
|
||||||
|
|
||||||
|
# 10. LLM解释层缓存:只保存异步生成的解释/研究备忘,不参与交易决策。
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS llm_insights (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
target_type TEXT NOT NULL,
|
||||||
|
target_id TEXT NOT NULL,
|
||||||
|
insight_type TEXT NOT NULL,
|
||||||
|
prompt_version TEXT NOT NULL,
|
||||||
|
input_hash TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'success',
|
||||||
|
input_json TEXT DEFAULT '{}',
|
||||||
|
content_json TEXT DEFAULT '{}',
|
||||||
|
error TEXT DEFAULT '',
|
||||||
|
model TEXT DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE llm_insights ADD COLUMN input_json TEXT DEFAULT '{}'")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
conn.execute("""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_llm_insights_unique
|
||||||
|
ON llm_insights(target_type, target_id, insight_type, input_hash)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_insights_lookup
|
||||||
|
ON llm_insights(target_type, target_id, insight_type, status, updated_at)
|
||||||
|
""")
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
print("DB初始化完成(9+1表)")
|
print("DB初始化完成(9+2表)")
|
||||||
|
|
||||||
|
|
||||||
# === 推送去重 ===
|
# === 推送去重 ===
|
||||||
@ -1482,8 +1513,17 @@ def get_active_recommendations_deduped(actionable_only=True, version="", hours=0
|
|||||||
summary["expired_filtered"] = summary.pop("expired", 0)
|
summary["expired_filtered"] = summary.pop("expired", 0)
|
||||||
|
|
||||||
if not with_meta:
|
if not with_meta:
|
||||||
return all_items
|
try:
|
||||||
|
from app.services.llm_insights import attach_recommendation_insights
|
||||||
|
return attach_recommendation_insights(all_items)
|
||||||
|
except Exception:
|
||||||
|
return all_items
|
||||||
page_items = all_items[offset: offset + limit] if limit else all_items[offset:]
|
page_items = all_items[offset: offset + limit] if limit else all_items[offset:]
|
||||||
|
try:
|
||||||
|
from app.services.llm_insights import attach_recommendation_insights
|
||||||
|
attach_recommendation_insights(page_items)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return {
|
return {
|
||||||
"items": page_items,
|
"items": page_items,
|
||||||
"total": len(all_items),
|
"total": len(all_items),
|
||||||
|
|||||||
@ -1060,26 +1060,40 @@ def get_pipeline_runs(limit=30, hours=24, offset=0):
|
|||||||
""",
|
""",
|
||||||
(datetime.now().isoformat(), hours / 24.0, limit, offset),
|
(datetime.now().isoformat(), hours / 24.0, limit, offset),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
all_run_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM cron_run_log
|
||||||
|
WHERE job_name = '粗筛'
|
||||||
|
AND julianday(?) - julianday(started_at) <= ?
|
||||||
|
ORDER BY started_at DESC, id DESC
|
||||||
|
""",
|
||||||
|
(datetime.now().isoformat(), hours / 24.0),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
runs = []
|
runs = []
|
||||||
for row in run_rows:
|
for row in run_rows:
|
||||||
run = _cron_item(row)
|
run = _cron_item(row)
|
||||||
related = _select_pipeline_rows(conn, run)
|
related = _select_pipeline_rows(conn, run)
|
||||||
runs.append(_pipeline_summary_for_run(run, related))
|
runs.append(_pipeline_summary_for_run(run, related))
|
||||||
|
all_summaries = []
|
||||||
|
for row in all_run_rows:
|
||||||
|
run = _cron_item(row)
|
||||||
|
related = _select_pipeline_rows(conn, run)
|
||||||
|
all_summaries.append(_pipeline_summary_for_run(run, related))
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
kpi = {
|
kpi = {
|
||||||
"hours": hours,
|
"hours": hours,
|
||||||
"run_count": len(runs),
|
"run_count": len(all_summaries),
|
||||||
"rough_candidates": sum(item["rough_candidates"] for item in runs),
|
"rough_candidates": sum(item["rough_candidates"] for item in all_summaries),
|
||||||
"fine_qualified": sum(item["fine_qualified"] for item in runs),
|
"fine_qualified": sum(item["fine_qualified"] for item in all_summaries),
|
||||||
"confirm_processed": sum(item["confirm_processed"] for item in runs),
|
"confirm_processed": sum(item["confirm_processed"] for item in all_summaries),
|
||||||
"confirm_hits": sum(item["confirm_hits"] for item in runs),
|
"confirm_hits": sum(item["confirm_hits"] for item in all_summaries),
|
||||||
"recommendations": sum(item["recommendations"] for item in runs),
|
"recommendations": sum(item["recommendations"] for item in all_summaries),
|
||||||
"perf_success": sum(item["perf_success"] for item in runs),
|
"perf_success": sum(item["perf_success"] for item in all_summaries),
|
||||||
"perf_failed": sum(item["perf_failed"] for item in runs),
|
"perf_failed": sum(item["perf_failed"] for item in all_summaries),
|
||||||
"perf_pending": sum(item["perf_pending"] for item in runs),
|
"perf_pending": sum(item["perf_pending"] for item in all_summaries),
|
||||||
"missed_count": sum(item["missed_count"] for item in runs),
|
"missed_count": sum(item["missed_count"] for item in all_summaries),
|
||||||
}
|
}
|
||||||
kpi["recommendation_rate"] = round(kpi["recommendations"] / kpi["fine_qualified"] * 100, 1) if kpi["fine_qualified"] else 0
|
kpi["recommendation_rate"] = round(kpi["recommendations"] / kpi["fine_qualified"] * 100, 1) if kpi["fine_qualified"] else 0
|
||||||
kpi["performance_hit_rate"] = round(kpi["perf_success"] / (kpi["perf_success"] + kpi["perf_failed"]) * 100, 1) if (kpi["perf_success"] + kpi["perf_failed"]) else 0
|
kpi["performance_hit_rate"] = round(kpi["perf_success"] / (kpi["perf_success"] + kpi["perf_failed"]) * 100, 1) if (kpi["perf_success"] + kpi["perf_failed"]) else 0
|
||||||
|
|||||||
264
app/db/llm_insights.py
Normal file
264
app/db/llm_insights.py
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
"""Cached LLM insight storage helpers."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.db.schema import get_conn
|
||||||
|
|
||||||
|
|
||||||
|
_MOJIBAKE_MARKERS = ("Ã", "Â", "ç", "è", "é", "å", "æ", "ä", "ï¼", "ã")
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_mojibake(value):
|
||||||
|
text = str(value or "")
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
return any(marker in text for marker in _MOJIBAKE_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
def repair_mojibake_text(value):
|
||||||
|
"""Repair common UTF-8-as-latin1 mojibake from model/provider responses."""
|
||||||
|
if not isinstance(value, str) or not _looks_mojibake(value):
|
||||||
|
return value
|
||||||
|
try:
|
||||||
|
repaired = value.encode("latin1").decode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
repaired = _repair_mixed_mojibake_text(value)
|
||||||
|
return repaired if repaired != value else value
|
||||||
|
# Only accept the repair when it produces visible CJK text or common CJK punctuation.
|
||||||
|
if any("\u4e00" <= ch <= "\u9fff" for ch in repaired) or any(ch in repaired for ch in ",。;:!?()《》"):
|
||||||
|
return repaired
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _repair_mixed_mojibake_text(value):
|
||||||
|
"""Repair strings that contain normal CJK text plus mojibake fragments."""
|
||||||
|
text = str(value or "")
|
||||||
|
separators = (": ", ":", " - ", ",", "。")
|
||||||
|
for sep in separators:
|
||||||
|
if sep not in text:
|
||||||
|
continue
|
||||||
|
left, right = text.split(sep, 1)
|
||||||
|
fixed_right = repair_mojibake_text(right)
|
||||||
|
if fixed_right != right:
|
||||||
|
return left + sep + fixed_right
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def repair_mojibake_json(value):
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
repair_mojibake_text(k) if isinstance(k, str) else k: repair_mojibake_json(v)
|
||||||
|
for k, v in value.items()
|
||||||
|
}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [repair_mojibake_json(v) for v in value]
|
||||||
|
if isinstance(value, str):
|
||||||
|
return repair_mojibake_text(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def compute_input_hash(payload):
|
||||||
|
"""Stable hash for structured LLM input payloads."""
|
||||||
|
raw = json.dumps(payload or {}, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
|
||||||
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_content(row):
|
||||||
|
item = dict(row)
|
||||||
|
try:
|
||||||
|
item["content"] = repair_mojibake_json(json.loads(item.get("content_json") or "{}"))
|
||||||
|
except Exception:
|
||||||
|
item["content"] = {}
|
||||||
|
try:
|
||||||
|
item["input"] = repair_mojibake_json(json.loads(item.get("input_json") or "{}"))
|
||||||
|
except Exception:
|
||||||
|
item["input"] = {}
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def get_cached_insight(target_type, target_id, insight_type, input_hash=None, success_only=True):
|
||||||
|
conn = get_conn()
|
||||||
|
where = "target_type=? AND target_id=? AND insight_type=?"
|
||||||
|
params = [str(target_type), str(target_id), str(insight_type)]
|
||||||
|
if input_hash:
|
||||||
|
where += " AND input_hash=?"
|
||||||
|
params.append(str(input_hash))
|
||||||
|
if success_only:
|
||||||
|
where += " AND status='success'"
|
||||||
|
row = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT * FROM llm_insights
|
||||||
|
WHERE {where}
|
||||||
|
ORDER BY updated_at DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
tuple(params),
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
return _load_content(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_any_insight(target_type, target_id, insight_type, input_hash):
|
||||||
|
return get_cached_insight(target_type, target_id, insight_type, input_hash=input_hash, success_only=False)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_insight(
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
insight_type,
|
||||||
|
prompt_version,
|
||||||
|
input_hash,
|
||||||
|
status,
|
||||||
|
input_payload=None,
|
||||||
|
content=None,
|
||||||
|
error="",
|
||||||
|
model="",
|
||||||
|
):
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
input_json = json.dumps(repair_mojibake_json(input_payload or {}), ensure_ascii=False, default=str)
|
||||||
|
content_json = json.dumps(repair_mojibake_json(content or {}), ensure_ascii=False, default=str)
|
||||||
|
conn = get_conn()
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO llm_insights (
|
||||||
|
target_type, target_id, insight_type, prompt_version, input_hash,
|
||||||
|
status, input_json, content_json, error, model, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(target_type, target_id, insight_type, input_hash) DO UPDATE SET
|
||||||
|
prompt_version=excluded.prompt_version,
|
||||||
|
status=excluded.status,
|
||||||
|
input_json=excluded.input_json,
|
||||||
|
content_json=excluded.content_json,
|
||||||
|
error=excluded.error,
|
||||||
|
model=excluded.model,
|
||||||
|
updated_at=excluded.updated_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
str(target_type),
|
||||||
|
str(target_id),
|
||||||
|
str(insight_type),
|
||||||
|
str(prompt_version),
|
||||||
|
str(input_hash),
|
||||||
|
str(status),
|
||||||
|
input_json,
|
||||||
|
content_json,
|
||||||
|
str(error or "")[:2000],
|
||||||
|
str(model or ""),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM llm_insights
|
||||||
|
WHERE target_type=? AND target_id=? AND insight_type=? AND input_hash=?
|
||||||
|
""",
|
||||||
|
(str(target_type), str(target_id), str(insight_type), str(input_hash)),
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
return _load_content(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_insights_for_targets(target_type, target_ids, insight_type):
|
||||||
|
ids = [str(x) for x in (target_ids or []) if str(x or "").strip()]
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
placeholders = ",".join(["?"] * len(ids))
|
||||||
|
conn = get_conn()
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT * FROM llm_insights
|
||||||
|
WHERE target_type=? AND insight_type=? AND status='success'
|
||||||
|
AND target_id IN ({placeholders})
|
||||||
|
ORDER BY updated_at DESC, id DESC
|
||||||
|
""",
|
||||||
|
tuple([str(target_type), str(insight_type)] + ids),
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
result = {}
|
||||||
|
for row in rows:
|
||||||
|
item = _load_content(row)
|
||||||
|
result.setdefault(str(item.get("target_id")), item)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_insight_by_type(target_type, insight_type, success_only=True):
|
||||||
|
conn = get_conn()
|
||||||
|
status_clause = "AND status='success'" if success_only else ""
|
||||||
|
row = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT * FROM llm_insights
|
||||||
|
WHERE target_type=? AND insight_type=? {status_clause}
|
||||||
|
ORDER BY updated_at DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(str(target_type), str(insight_type)),
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
return _load_content(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def list_llm_insights(limit=50, offset=0, target_type="", status="", insight_type=""):
|
||||||
|
try:
|
||||||
|
limit = min(100, max(1, int(limit or 50)))
|
||||||
|
except Exception:
|
||||||
|
limit = 50
|
||||||
|
try:
|
||||||
|
offset = max(0, int(offset or 0))
|
||||||
|
except Exception:
|
||||||
|
offset = 0
|
||||||
|
where = []
|
||||||
|
params = []
|
||||||
|
if target_type:
|
||||||
|
where.append("target_type=?")
|
||||||
|
params.append(str(target_type))
|
||||||
|
if status:
|
||||||
|
where.append("status=?")
|
||||||
|
params.append(str(status))
|
||||||
|
if insight_type:
|
||||||
|
where.append("insight_type=?")
|
||||||
|
params.append(str(insight_type))
|
||||||
|
clause = ("WHERE " + " AND ".join(where)) if where else ""
|
||||||
|
conn = get_conn()
|
||||||
|
total = conn.execute(f"SELECT COUNT(*) FROM llm_insights {clause}", tuple(params)).fetchone()[0]
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT * FROM llm_insights
|
||||||
|
{clause}
|
||||||
|
ORDER BY updated_at DESC, id DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
""",
|
||||||
|
tuple(params + [limit, offset]),
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
return {
|
||||||
|
"items": [_load_content(row) for row in rows],
|
||||||
|
"total": int(total or 0),
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
"has_more": offset + len(rows) < int(total or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_llm_insight_by_id(insight_id):
|
||||||
|
conn = get_conn()
|
||||||
|
row = conn.execute("SELECT * FROM llm_insights WHERE id=?", (int(insight_id or 0),)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
return _load_content(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"compute_input_hash",
|
||||||
|
"get_any_insight",
|
||||||
|
"get_cached_insight",
|
||||||
|
"get_insights_for_targets",
|
||||||
|
"get_latest_insight_by_type",
|
||||||
|
"get_llm_insight_by_id",
|
||||||
|
"list_llm_insights",
|
||||||
|
"repair_mojibake_json",
|
||||||
|
"repair_mojibake_text",
|
||||||
|
"upsert_insight",
|
||||||
|
]
|
||||||
@ -11,6 +11,7 @@ from app.db.altcoin_db import (
|
|||||||
update_recommendation_tracking,
|
update_recommendation_tracking,
|
||||||
)
|
)
|
||||||
from app.db.schema import get_conn
|
from app.db.schema import get_conn
|
||||||
|
from app.services.llm_insights import attach_recommendation_insights
|
||||||
|
|
||||||
|
|
||||||
def should_push(symbol: str, push_type: str, action_status: str = "") -> bool:
|
def should_push(symbol: str, push_type: str, action_status: str = "") -> bool:
|
||||||
@ -208,8 +209,9 @@ def get_active_recommendations_deduped(
|
|||||||
summary["expired_filtered"] = summary.pop("expired", 0)
|
summary["expired_filtered"] = summary.pop("expired", 0)
|
||||||
|
|
||||||
if not with_meta:
|
if not with_meta:
|
||||||
return all_items
|
return attach_recommendation_insights(all_items)
|
||||||
page_items = all_items[offset : offset + limit] if limit else all_items[offset:]
|
page_items = all_items[offset : offset + limit] if limit else all_items[offset:]
|
||||||
|
attach_recommendation_insights(page_items)
|
||||||
return {
|
return {
|
||||||
"items": page_items,
|
"items": page_items,
|
||||||
"total": len(all_items),
|
"total": len(all_items),
|
||||||
|
|||||||
491
app/db/scheduler_db.py
Normal file
491
app/db/scheduler_db.py
Normal file
@ -0,0 +1,491 @@
|
|||||||
|
"""SQLite-backed scheduler configuration and runtime state."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.db.schema import get_conn
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_JOBS = [
|
||||||
|
{
|
||||||
|
"job_name": "event",
|
||||||
|
"command": "event",
|
||||||
|
"args": [],
|
||||||
|
"every_seconds": 60,
|
||||||
|
"initial_delay": 5,
|
||||||
|
"lock_group": "recommendation_write",
|
||||||
|
"description": "事件/舆情驱动技术检查",
|
||||||
|
"sort_order": 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_name": "tracker",
|
||||||
|
"command": "tracker",
|
||||||
|
"args": [],
|
||||||
|
"every_seconds": 180,
|
||||||
|
"initial_delay": 20,
|
||||||
|
"lock_group": "tracking_write",
|
||||||
|
"description": "推荐价格跟踪",
|
||||||
|
"sort_order": 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_name": "confirm",
|
||||||
|
"command": "confirm",
|
||||||
|
"args": [],
|
||||||
|
"every_seconds": 600,
|
||||||
|
"initial_delay": 40,
|
||||||
|
"lock_group": "recommendation_write",
|
||||||
|
"description": "确认层",
|
||||||
|
"sort_order": 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_name": "screener",
|
||||||
|
"command": "screener",
|
||||||
|
"args": [],
|
||||||
|
"every_seconds": 900,
|
||||||
|
"initial_delay": 80,
|
||||||
|
"lock_group": "screening_write",
|
||||||
|
"description": "粗筛/细筛",
|
||||||
|
"sort_order": 40,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_name": "sentiment",
|
||||||
|
"command": "sentiment",
|
||||||
|
"args": ["--collect"],
|
||||||
|
"every_seconds": 1800,
|
||||||
|
"initial_delay": 120,
|
||||||
|
"lock_group": "sentiment_write",
|
||||||
|
"description": "舆情采集",
|
||||||
|
"sort_order": 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_name": "llm-sentiment",
|
||||||
|
"command": "llm-insights",
|
||||||
|
"args": ["--scope", "sentiment", "--limit", "40"],
|
||||||
|
"every_seconds": 1800,
|
||||||
|
"initial_delay": 180,
|
||||||
|
"lock_group": "llm_write",
|
||||||
|
"description": "LLM 批量舆情分析",
|
||||||
|
"sort_order": 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_name": "review",
|
||||||
|
"command": "review",
|
||||||
|
"args": [],
|
||||||
|
"every_seconds": 86400,
|
||||||
|
"initial_delay": 300,
|
||||||
|
"lock_group": "review_write",
|
||||||
|
"description": "复盘",
|
||||||
|
"sort_order": 70,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _now():
|
||||||
|
return datetime.now().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _dump(value):
|
||||||
|
return json.dumps(value or [], ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def _load(value, fallback=None):
|
||||||
|
try:
|
||||||
|
return json.loads(value) if isinstance(value, str) else (value if value is not None else fallback)
|
||||||
|
except Exception:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _create_runtime_table(conn):
|
||||||
|
conn.execute("DROP TABLE IF EXISTS scheduler_runtime_status")
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE scheduler_runtime_status (
|
||||||
|
job_name TEXT PRIMARY KEY,
|
||||||
|
status TEXT DEFAULT 'idle',
|
||||||
|
pid INTEGER DEFAULT 0,
|
||||||
|
run_kind TEXT DEFAULT '',
|
||||||
|
trigger_id INTEGER DEFAULT 0,
|
||||||
|
locked_by TEXT DEFAULT '',
|
||||||
|
next_run_at TEXT DEFAULT '',
|
||||||
|
last_started_at TEXT DEFAULT '',
|
||||||
|
last_finished_at TEXT DEFAULT '',
|
||||||
|
last_exit_code INTEGER DEFAULT 0,
|
||||||
|
last_duration_ms INTEGER DEFAULT 0,
|
||||||
|
last_error TEXT DEFAULT '',
|
||||||
|
output_tail TEXT DEFAULT '',
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_config_table(conn):
|
||||||
|
conn.execute("DROP TABLE IF EXISTS scheduler_job_config")
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE scheduler_job_config (
|
||||||
|
job_name TEXT PRIMARY KEY,
|
||||||
|
command TEXT NOT NULL,
|
||||||
|
args_json TEXT DEFAULT '[]',
|
||||||
|
enabled INTEGER DEFAULT 1,
|
||||||
|
every_seconds INTEGER NOT NULL,
|
||||||
|
initial_delay INTEGER DEFAULT 0,
|
||||||
|
lock_group TEXT DEFAULT '',
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_manual_trigger_table(conn):
|
||||||
|
conn.execute("DROP INDEX IF EXISTS idx_scheduler_trigger_status")
|
||||||
|
conn.execute("DROP TABLE IF EXISTS scheduler_manual_trigger")
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE scheduler_manual_trigger (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
job_name TEXT NOT NULL,
|
||||||
|
force INTEGER DEFAULT 0,
|
||||||
|
status TEXT DEFAULT 'queued',
|
||||||
|
requested_by TEXT DEFAULT '',
|
||||||
|
requested_at TEXT NOT NULL,
|
||||||
|
started_at TEXT DEFAULT '',
|
||||||
|
finished_at TEXT DEFAULT '',
|
||||||
|
exit_code INTEGER DEFAULT 0,
|
||||||
|
duration_ms INTEGER DEFAULT 0,
|
||||||
|
output_tail TEXT DEFAULT '',
|
||||||
|
error_message TEXT DEFAULT ''
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_scheduler_trigger_status ON scheduler_manual_trigger(status, requested_at)")
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_scheduler_tables(conn):
|
||||||
|
_create_manual_trigger_table(conn)
|
||||||
|
_create_runtime_table(conn)
|
||||||
|
_create_config_table(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_scheduler_tables(conn):
|
||||||
|
now = _now()
|
||||||
|
for job in DEFAULT_JOBS:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO scheduler_job_config (
|
||||||
|
job_name, command, args_json, enabled, every_seconds, initial_delay,
|
||||||
|
lock_group, description, sort_order, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(job_name) DO UPDATE SET
|
||||||
|
command=excluded.command,
|
||||||
|
args_json=excluded.args_json,
|
||||||
|
initial_delay=excluded.initial_delay,
|
||||||
|
lock_group=excluded.lock_group,
|
||||||
|
description=excluded.description,
|
||||||
|
sort_order=excluded.sort_order,
|
||||||
|
updated_at=scheduler_job_config.updated_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
job["job_name"],
|
||||||
|
job["command"],
|
||||||
|
_dump(job.get("args")),
|
||||||
|
int(job["every_seconds"]),
|
||||||
|
int(job.get("initial_delay") or 0),
|
||||||
|
job.get("lock_group") or "",
|
||||||
|
job.get("description") or "",
|
||||||
|
int(job.get("sort_order") or 0),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO scheduler_runtime_status (job_name, status, updated_at)
|
||||||
|
VALUES (?, 'idle', ?)
|
||||||
|
ON CONFLICT(job_name) DO NOTHING
|
||||||
|
""",
|
||||||
|
(job["job_name"], now),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def init_scheduler_tables():
|
||||||
|
conn = get_conn()
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS scheduler_job_config (
|
||||||
|
job_name TEXT PRIMARY KEY,
|
||||||
|
command TEXT NOT NULL,
|
||||||
|
args_json TEXT DEFAULT '[]',
|
||||||
|
enabled INTEGER DEFAULT 1,
|
||||||
|
every_seconds INTEGER NOT NULL,
|
||||||
|
initial_delay INTEGER DEFAULT 0,
|
||||||
|
lock_group TEXT DEFAULT '',
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS scheduler_runtime_status (
|
||||||
|
job_name TEXT PRIMARY KEY,
|
||||||
|
status TEXT DEFAULT 'idle',
|
||||||
|
pid INTEGER DEFAULT 0,
|
||||||
|
run_kind TEXT DEFAULT '',
|
||||||
|
trigger_id INTEGER DEFAULT 0,
|
||||||
|
locked_by TEXT DEFAULT '',
|
||||||
|
next_run_at TEXT DEFAULT '',
|
||||||
|
last_started_at TEXT DEFAULT '',
|
||||||
|
last_finished_at TEXT DEFAULT '',
|
||||||
|
last_exit_code INTEGER DEFAULT 0,
|
||||||
|
last_duration_ms INTEGER DEFAULT 0,
|
||||||
|
last_error TEXT DEFAULT '',
|
||||||
|
output_tail TEXT DEFAULT '',
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute("SELECT COUNT(*) FROM scheduler_runtime_status").fetchone()
|
||||||
|
except Exception:
|
||||||
|
_create_runtime_table(conn)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS scheduler_manual_trigger (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
job_name TEXT NOT NULL,
|
||||||
|
force INTEGER DEFAULT 0,
|
||||||
|
status TEXT DEFAULT 'queued',
|
||||||
|
requested_by TEXT DEFAULT '',
|
||||||
|
requested_at TEXT NOT NULL,
|
||||||
|
started_at TEXT DEFAULT '',
|
||||||
|
finished_at TEXT DEFAULT '',
|
||||||
|
exit_code INTEGER DEFAULT 0,
|
||||||
|
duration_ms INTEGER DEFAULT 0,
|
||||||
|
output_tail TEXT DEFAULT '',
|
||||||
|
error_message TEXT DEFAULT ''
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_scheduler_trigger_status ON scheduler_manual_trigger(status, requested_at)")
|
||||||
|
try:
|
||||||
|
_seed_scheduler_tables(conn)
|
||||||
|
except Exception:
|
||||||
|
_reset_scheduler_tables(conn)
|
||||||
|
_seed_scheduler_tables(conn)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_job_configs():
|
||||||
|
init_scheduler_tables()
|
||||||
|
conn = get_conn()
|
||||||
|
rows = conn.execute("SELECT * FROM scheduler_job_config ORDER BY sort_order ASC, job_name ASC").fetchall()
|
||||||
|
conn.close()
|
||||||
|
jobs = []
|
||||||
|
for row in rows:
|
||||||
|
item = dict(row)
|
||||||
|
item["args"] = _load(item.pop("args_json", "[]"), [])
|
||||||
|
item["enabled"] = bool(item.get("enabled"))
|
||||||
|
jobs.append(item)
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
|
||||||
|
def get_job_config(job_name):
|
||||||
|
init_scheduler_tables()
|
||||||
|
conn = get_conn()
|
||||||
|
row = conn.execute("SELECT * FROM scheduler_job_config WHERE job_name=?", (job_name,)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
item = dict(row)
|
||||||
|
item["args"] = _load(item.pop("args_json", "[]"), [])
|
||||||
|
item["enabled"] = bool(item.get("enabled"))
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def set_job_enabled(job_name, enabled):
|
||||||
|
init_scheduler_tables()
|
||||||
|
now = _now()
|
||||||
|
conn = get_conn()
|
||||||
|
cur = conn.execute(
|
||||||
|
"UPDATE scheduler_job_config SET enabled=?, updated_at=? WHERE job_name=?",
|
||||||
|
(1 if enabled else 0, now, job_name),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def set_job_interval(job_name, every_seconds):
|
||||||
|
seconds = max(30, int(every_seconds or 0))
|
||||||
|
init_scheduler_tables()
|
||||||
|
now = _now()
|
||||||
|
conn = get_conn()
|
||||||
|
cur = conn.execute(
|
||||||
|
"UPDATE scheduler_job_config SET every_seconds=?, updated_at=? WHERE job_name=?",
|
||||||
|
(seconds, now, job_name),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def update_runtime(job_name, **fields):
|
||||||
|
init_scheduler_tables()
|
||||||
|
allowed = {
|
||||||
|
"status", "pid", "run_kind", "trigger_id", "locked_by", "next_run_at",
|
||||||
|
"last_started_at", "last_finished_at", "last_exit_code", "last_duration_ms",
|
||||||
|
"last_error", "output_tail",
|
||||||
|
}
|
||||||
|
values = {k: v for k, v in fields.items() if k in allowed}
|
||||||
|
values["updated_at"] = _now()
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO scheduler_runtime_status (job_name, updated_at) VALUES (?, ?) ON CONFLICT(job_name) DO NOTHING",
|
||||||
|
(job_name, values["updated_at"]),
|
||||||
|
)
|
||||||
|
assignments = ", ".join([f"{k}=?" for k in values])
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE scheduler_runtime_status SET {assignments} WHERE job_name=?",
|
||||||
|
(*values.values(), job_name),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
_create_runtime_table(conn)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO scheduler_runtime_status (job_name, updated_at) VALUES (?, ?)",
|
||||||
|
(job_name, values["updated_at"]),
|
||||||
|
)
|
||||||
|
assignments = ", ".join([f"{k}=?" for k in values])
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE scheduler_runtime_status SET {assignments} WHERE job_name=?",
|
||||||
|
(*values.values(), job_name),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_manual_trigger(job_name, force=False, requested_by=""):
|
||||||
|
init_scheduler_tables()
|
||||||
|
if not get_job_config(job_name):
|
||||||
|
return None
|
||||||
|
conn = get_conn()
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO scheduler_manual_trigger (job_name, force, status, requested_by, requested_at)
|
||||||
|
VALUES (?, ?, 'queued', ?, ?)
|
||||||
|
""",
|
||||||
|
(job_name, 1 if force else 0, requested_by or "", _now()),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
trigger_id = cur.lastrowid
|
||||||
|
conn.close()
|
||||||
|
return trigger_id
|
||||||
|
|
||||||
|
|
||||||
|
def claim_manual_triggers(limit=10):
|
||||||
|
init_scheduler_tables()
|
||||||
|
conn = get_conn()
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM scheduler_manual_trigger
|
||||||
|
WHERE status IN ('queued', 'pending')
|
||||||
|
ORDER BY requested_at ASC, id ASC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(int(limit or 10),),
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def update_manual_trigger(trigger_id, **fields):
|
||||||
|
init_scheduler_tables()
|
||||||
|
allowed = {"status", "started_at", "finished_at", "exit_code", "duration_ms", "output_tail", "error_message"}
|
||||||
|
values = {k: v for k, v in fields.items() if k in allowed}
|
||||||
|
if not values:
|
||||||
|
return
|
||||||
|
conn = get_conn()
|
||||||
|
assignments = ", ".join([f"{k}=?" for k in values])
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE scheduler_manual_trigger SET {assignments} WHERE id=?",
|
||||||
|
(*values.values(), int(trigger_id)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def list_manual_triggers(limit=30):
|
||||||
|
init_scheduler_tables()
|
||||||
|
limit = max(1, min(int(limit or 30), 100))
|
||||||
|
conn = get_conn()
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM scheduler_manual_trigger ORDER BY requested_at DESC, id DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_scheduler_overview():
|
||||||
|
init_scheduler_tables()
|
||||||
|
conn = get_conn()
|
||||||
|
configs = conn.execute("SELECT * FROM scheduler_job_config ORDER BY sort_order ASC, job_name ASC").fetchall()
|
||||||
|
runtime_rows = conn.execute("SELECT * FROM scheduler_runtime_status").fetchall()
|
||||||
|
latest_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT c.*
|
||||||
|
FROM cron_run_log c
|
||||||
|
JOIN (
|
||||||
|
SELECT job_name, MAX(id) AS max_id
|
||||||
|
FROM cron_run_log
|
||||||
|
GROUP BY job_name
|
||||||
|
) x ON x.max_id = c.id
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
runtime = {row["job_name"]: dict(row) for row in runtime_rows}
|
||||||
|
latest = {row["job_name"]: dict(row) for row in latest_rows}
|
||||||
|
jobs = []
|
||||||
|
for row in configs:
|
||||||
|
item = dict(row)
|
||||||
|
item["args"] = _load(item.pop("args_json", "[]"), [])
|
||||||
|
item["enabled"] = bool(item.get("enabled"))
|
||||||
|
item["runtime"] = runtime.get(item["job_name"], {})
|
||||||
|
item["latest_cron"] = latest.get(_display_job_name(item["job_name"]), latest.get(item["job_name"], {}))
|
||||||
|
jobs.append(item)
|
||||||
|
return {"jobs": jobs, "updated_at": _now()}
|
||||||
|
|
||||||
|
|
||||||
|
def _display_job_name(job_name):
|
||||||
|
return {
|
||||||
|
"event": "事件舆情",
|
||||||
|
"tracker": "跟踪",
|
||||||
|
"confirm": "确认",
|
||||||
|
"screener": "粗筛",
|
||||||
|
"sentiment": "舆情",
|
||||||
|
"llm-sentiment": "AI舆情",
|
||||||
|
"review": "复盘",
|
||||||
|
}.get(job_name, job_name)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DEFAULT_JOBS",
|
||||||
|
"claim_manual_triggers",
|
||||||
|
"enqueue_manual_trigger",
|
||||||
|
"get_job_config",
|
||||||
|
"get_job_configs",
|
||||||
|
"get_scheduler_overview",
|
||||||
|
"init_scheduler_tables",
|
||||||
|
"list_manual_triggers",
|
||||||
|
"set_job_enabled",
|
||||||
|
"set_job_interval",
|
||||||
|
"update_manual_trigger",
|
||||||
|
"update_runtime",
|
||||||
|
]
|
||||||
@ -26,6 +26,7 @@ sys.path.insert(0, os.path.dirname(__file__))
|
|||||||
|
|
||||||
from app.config.config_loader import load_rules, get_meta, get_strategy_direction
|
from app.config.config_loader import load_rules, get_meta, get_strategy_direction
|
||||||
from app.db.altcoin_db import init_db, get_conn, create_recommendation, log_screening, log_cron_run, get_recommendation_for_push
|
from app.db.altcoin_db import init_db, get_conn, create_recommendation, log_screening, log_cron_run, get_recommendation_for_push
|
||||||
|
from app.db.llm_insights import repair_mojibake_json, repair_mojibake_text
|
||||||
from app.services.altcoin_screener import (
|
from app.services.altcoin_screener import (
|
||||||
fetch_all_tickers,
|
fetch_all_tickers,
|
||||||
detect_volume_price_fly,
|
detect_volume_price_fly,
|
||||||
@ -378,6 +379,129 @@ def store_events(events):
|
|||||||
return stored
|
return stored
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_llm_symbol(value):
|
||||||
|
text = str(value or "").strip().upper()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
text = text.replace("-", "/").replace("_", "/")
|
||||||
|
if "/" in text:
|
||||||
|
base = text.split("/")[0]
|
||||||
|
else:
|
||||||
|
base = re.sub(r"USDT$", "", text)
|
||||||
|
base = re.sub(r"[^A-Z0-9]", "", base)
|
||||||
|
if not base:
|
||||||
|
return ""
|
||||||
|
symbol = f"{base}/USDT"
|
||||||
|
return symbol if _tradable_symbol(symbol) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _llm_confidence_score(value):
|
||||||
|
try:
|
||||||
|
score = float(value or 0)
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
return score * 100 if 0 < score <= 1 else score
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_llm_sentiment_candidates(analysis, source_insight_id="", min_confidence=70, max_candidates=10, cooldown_hours=6):
|
||||||
|
"""Turn LLM sentiment analysis into event candidates for the existing technical gate.
|
||||||
|
|
||||||
|
LLM output is allowed to request a technical check, but it never creates a
|
||||||
|
recommendation or changes scores directly.
|
||||||
|
"""
|
||||||
|
analysis = repair_mojibake_json(analysis)
|
||||||
|
if not isinstance(analysis, dict):
|
||||||
|
return {"queued": 0, "skipped": 0, "symbols": []}
|
||||||
|
|
||||||
|
candidates = []
|
||||||
|
seen = set()
|
||||||
|
for item in analysis.get("coin_impacts") or []:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
if item.get("need_technical_check") is not True:
|
||||||
|
continue
|
||||||
|
direction = str(item.get("direction") or "").lower()
|
||||||
|
if direction not in ("positive", "neutral", "bullish", "利好", "正面", "中性"):
|
||||||
|
continue
|
||||||
|
confidence = _llm_confidence_score(item.get("confidence"))
|
||||||
|
if confidence < float(min_confidence or 0):
|
||||||
|
continue
|
||||||
|
symbol = _normalize_llm_symbol(item.get("symbol"))
|
||||||
|
if not symbol or symbol in seen:
|
||||||
|
continue
|
||||||
|
seen.add(symbol)
|
||||||
|
reason = str(repair_mojibake_text(item.get("reason") or "AI 舆情分析认为需要技术检查")).strip()
|
||||||
|
candidates.append({
|
||||||
|
"source": "llm_sentiment",
|
||||||
|
"symbol": symbol,
|
||||||
|
"title": f"AI舆情候选 {symbol}: {reason[:160]}",
|
||||||
|
"url": "",
|
||||||
|
"published_at": _now(),
|
||||||
|
"importance": "A",
|
||||||
|
"event_type": "llm_sentiment_candidate",
|
||||||
|
"raw": {
|
||||||
|
"source_insight_id": source_insight_id,
|
||||||
|
"direction": direction,
|
||||||
|
"confidence": confidence,
|
||||||
|
"reason": reason,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if len(candidates) >= int(max_candidates or 10):
|
||||||
|
break
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return {"queued": 0, "skipped": 0, "symbols": []}
|
||||||
|
|
||||||
|
init_event_tables()
|
||||||
|
conn = get_conn()
|
||||||
|
queued = []
|
||||||
|
skipped = 0
|
||||||
|
cooldown_cutoff = (_now() - timedelta(hours=float(cooldown_hours or 6))).isoformat()
|
||||||
|
now = _now().isoformat()
|
||||||
|
for event in candidates:
|
||||||
|
recent = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM event_news
|
||||||
|
WHERE source='llm_sentiment' AND symbol=? AND detected_at >= ?
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(event["symbol"], cooldown_cutoff),
|
||||||
|
).fetchone()
|
||||||
|
if recent:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
h = _event_hash(event["source"], event["title"], event["symbol"])
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO event_news
|
||||||
|
(event_hash, source, symbol, title, url, published_at, detected_at, importance, event_type, raw_json, processed)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
h,
|
||||||
|
event["source"],
|
||||||
|
event["symbol"],
|
||||||
|
event["title"],
|
||||||
|
event.get("url", ""),
|
||||||
|
event["published_at"].isoformat(),
|
||||||
|
now,
|
||||||
|
event["importance"],
|
||||||
|
event["event_type"],
|
||||||
|
json.dumps(event.get("raw", {}), ensure_ascii=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
queued.append(event["symbol"])
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
skipped += 1
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[event] llm candidate enqueue error {event.get('symbol')}: {exc}")
|
||||||
|
skipped += 1
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {"queued": len(queued), "skipped": skipped, "symbols": queued}
|
||||||
|
|
||||||
|
|
||||||
def fetch_klines(symbol, timeframe, limit=120):
|
def fetch_klines(symbol, timeframe, limit=120):
|
||||||
try:
|
try:
|
||||||
ohlcv = exchange.fetch_ohlcv(symbol, timeframe, limit=limit)
|
ohlcv = exchange.fetch_ohlcv(symbol, timeframe, limit=limit)
|
||||||
|
|||||||
635
app/services/llm_insights.py
Normal file
635
app/services/llm_insights.py
Normal file
@ -0,0 +1,635 @@
|
|||||||
|
"""Async cached LLM explanation layer."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from app.core.opportunity_lifecycle import normalize_action_status
|
||||||
|
from app.db.altcoin_db import get_conn, _derive_execution_fields
|
||||||
|
from app.db.llm_insights import compute_input_hash, get_any_insight, get_insights_for_targets, get_latest_insight_by_type, upsert_insight
|
||||||
|
|
||||||
|
PROMPTS = {
|
||||||
|
"recommendation_explain_v1": "recommendation_explain_v1",
|
||||||
|
"sentiment_explain_v1": "sentiment_explain_v1",
|
||||||
|
"sentiment_batch_analyze_v1": "sentiment_batch_analyze_v1",
|
||||||
|
"review_memo_v1": "review_memo_v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _env_bool(name, default=False):
|
||||||
|
value = os.getenv(name)
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
return str(value).strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
def get_llm_params():
|
||||||
|
"""Runtime LLM config. This is system config, not strategy config."""
|
||||||
|
return {
|
||||||
|
"enabled": _env_bool("ALPHAX_LLM_ENABLED", False),
|
||||||
|
"base_url": os.getenv("ALPHAX_LLM_BASE_URL", "https://api.openai.com/v1").strip(),
|
||||||
|
"api_key_env": os.getenv("ALPHAX_LLM_API_KEY_ENV", "ALPHAX_LLM_API_KEY").strip(),
|
||||||
|
"model": os.getenv("ALPHAX_LLM_MODEL", "gpt-4o-mini").strip(),
|
||||||
|
"timeout": int(os.getenv("ALPHAX_LLM_TIMEOUT", "20") or "20"),
|
||||||
|
"max_tokens": int(os.getenv("ALPHAX_LLM_MAX_TOKENS", "900") or "900"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_llm_module_enabled(module_name):
|
||||||
|
if not _env_bool("ALPHAX_LLM_ENABLED", False):
|
||||||
|
return False
|
||||||
|
env_name = f"ALPHAX_LLM_{str(module_name or '').upper()}_ENABLED"
|
||||||
|
return _env_bool(env_name, True)
|
||||||
|
|
||||||
|
|
||||||
|
def _dump_json(value):
|
||||||
|
return json.dumps(value or {}, ensure_ascii=False, sort_keys=True, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_target_key(value):
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_fallback(value, fallback=None):
|
||||||
|
try:
|
||||||
|
return json.loads(value) if isinstance(value, str) else (value if value is not None else fallback)
|
||||||
|
except Exception:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_insight_payload(content):
|
||||||
|
if not isinstance(content, dict):
|
||||||
|
return {}
|
||||||
|
if isinstance(content.get("content"), dict):
|
||||||
|
return content["content"]
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
def _call_llm_json(prompt_version, payload):
|
||||||
|
params = get_llm_params()
|
||||||
|
api_key = os.getenv(str(params.get("api_key_env") or "OPENAI_API_KEY"), "").strip()
|
||||||
|
if not params.get("enabled", False) or not api_key:
|
||||||
|
return {"status": "skipped", "error": "llm_disabled_or_missing_key"}
|
||||||
|
base_url = str(params.get("base_url") or "").rstrip("/")
|
||||||
|
model = str(params.get("model") or "").strip()
|
||||||
|
timeout = int(params.get("timeout") or 20)
|
||||||
|
max_tokens = int(params.get("max_tokens") or 900)
|
||||||
|
system_prompt = (
|
||||||
|
"You are a crypto research assistant. Return strict JSON only. "
|
||||||
|
"Do not change trading decisions, scores, or strategy state."
|
||||||
|
)
|
||||||
|
user_prompt = _dump_json({
|
||||||
|
"prompt_version": prompt_version,
|
||||||
|
"input": payload,
|
||||||
|
"output_schema_hint": "JSON object with concise Chinese fields only",
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
f"{base_url}/chat/completions",
|
||||||
|
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||||
|
json={
|
||||||
|
"model": model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_prompt},
|
||||||
|
],
|
||||||
|
"temperature": 0.2,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"response_format": {"type": "json_object"},
|
||||||
|
},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
return {"status": "failed", "error": f"http_{resp.status_code}", "raw": resp.text[:1000], "model": model}
|
||||||
|
data = resp.json()
|
||||||
|
content = (((data.get("choices") or [{}])[0]).get("message") or {}).get("content") or "{}"
|
||||||
|
parsed = json.loads(content)
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise ValueError("llm_output_not_object")
|
||||||
|
return {"status": "success", "content": parsed, "model": model}
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
return {"status": "failed", "error": f"invalid_json:{exc}", "model": model}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"status": "failed", "error": str(exc)[:1000], "model": model}
|
||||||
|
|
||||||
|
|
||||||
|
def _should_generate_recommendation(row):
|
||||||
|
action_status = normalize_action_status(row.get("action_status") or row.get("entry_plan", {}).get("entry_action") or "持有", row.get("status") or "active")
|
||||||
|
execution_status = str(row.get("execution_status") or "")
|
||||||
|
observe_tier = str(row.get("observe_tier") or "")
|
||||||
|
state_reason = str(row.get("state_reason") or row.get("execution_reason") or "")
|
||||||
|
entry_window = row.get("entry_window") or {}
|
||||||
|
if execution_status in ("buy_now", "wait_pullback", "invalid") or action_status in ("可即刻买入", "等回踩", "衰减") or row.get("display_bucket") == "realtime":
|
||||||
|
return True
|
||||||
|
if observe_tier == "strong" and ("回踩" in state_reason or "入场" in state_reason or "失效" in state_reason):
|
||||||
|
return True
|
||||||
|
if isinstance(entry_window, dict) and entry_window.get("status") == "active":
|
||||||
|
return True
|
||||||
|
if "重点观察" in state_reason:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _should_generate_sentiment(row):
|
||||||
|
importance = str(row.get("importance") or "").upper()
|
||||||
|
source = str(row.get("source") or "").lower()
|
||||||
|
title = str(row.get("title") or "")
|
||||||
|
if importance in ("A", "S", "RISK"):
|
||||||
|
return True
|
||||||
|
if "binance" in source:
|
||||||
|
return True
|
||||||
|
if any(k in title.lower() for k in ("listing", "launch", "mainnet", "upgrade", "partnership", "hack", "exploit", "burn", "合约", "上币", "主网", "升级", "合作", "黑客", "漏洞")):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_internal_sentiment_event(row):
|
||||||
|
event_type = str(row.get("event_type") or "")
|
||||||
|
title = str(row.get("title") or "")
|
||||||
|
source = str(row.get("source") or "")
|
||||||
|
return (
|
||||||
|
event_type in ("market_heat", "theme_expansion", "theme_direct", "llm_sentiment_candidate")
|
||||||
|
or source == "llm_sentiment"
|
||||||
|
or title.startswith("[主题扩散:")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _should_generate_review(item):
|
||||||
|
metrics = item.get("metrics") or {}
|
||||||
|
release_decision = str(item.get("release_decision") or "")
|
||||||
|
failure_count = int(metrics.get("fail_count") or 0)
|
||||||
|
hit_count = int(metrics.get("hit_count") or 0)
|
||||||
|
pollution = item.get("pollution_summary") or {}
|
||||||
|
if release_decision in ("gray", "release", "hold"):
|
||||||
|
return True
|
||||||
|
if failure_count > 0 or hit_count > 0:
|
||||||
|
return True
|
||||||
|
if int(pollution.get("contaminated_symbol_count") or 0) > 0:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _build_recommendation_payload(row):
|
||||||
|
entry_plan = row.get("entry_plan") or _json_fallback(row.get("entry_plan_json"), {}) or {}
|
||||||
|
signals = row.get("signals") or _json_fallback(row.get("signals"), []) or []
|
||||||
|
if isinstance(signals, str):
|
||||||
|
signals = _json_fallback(signals, []) or []
|
||||||
|
return {
|
||||||
|
"target_type": "recommendation",
|
||||||
|
"target_id": row.get("id"),
|
||||||
|
"symbol": row.get("symbol"),
|
||||||
|
"rec_time": row.get("rec_time"),
|
||||||
|
"status": row.get("status"),
|
||||||
|
"action_status": row.get("action_status"),
|
||||||
|
"execution_status": row.get("execution_status"),
|
||||||
|
"execution_label": row.get("execution_label"),
|
||||||
|
"execution_reason": row.get("execution_reason"),
|
||||||
|
"rec_score": row.get("rec_score"),
|
||||||
|
"entry_price": row.get("entry_price"),
|
||||||
|
"current_price": row.get("current_price"),
|
||||||
|
"stop_loss": row.get("stop_loss"),
|
||||||
|
"tp1": row.get("tp1"),
|
||||||
|
"tp2": row.get("tp2"),
|
||||||
|
"observe_tier": row.get("observe_tier"),
|
||||||
|
"observe_reason": row.get("observe_reason"),
|
||||||
|
"state_reason": row.get("state_reason"),
|
||||||
|
"entry_window": row.get("entry_window"),
|
||||||
|
"market_context": row.get("market_context"),
|
||||||
|
"derivatives_context": row.get("derivatives_context"),
|
||||||
|
"sector_context": row.get("sector_context"),
|
||||||
|
"entry_plan": entry_plan,
|
||||||
|
"signals": signals,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_sentiment_payload(row):
|
||||||
|
return {
|
||||||
|
"target_type": "sentiment",
|
||||||
|
"target_id": row.get("event_id") or row.get("id"),
|
||||||
|
"source": row.get("source"),
|
||||||
|
"source_label": row.get("source_label"),
|
||||||
|
"event_type": row.get("event_type"),
|
||||||
|
"importance": row.get("importance"),
|
||||||
|
"title": row.get("title"),
|
||||||
|
"related_symbol": row.get("related_symbol"),
|
||||||
|
"related_base": row.get("related_base"),
|
||||||
|
"decision": row.get("decision"),
|
||||||
|
"tech_score": row.get("tech_score"),
|
||||||
|
"published_at": row.get("published_at"),
|
||||||
|
"detected_at": row.get("detected_at"),
|
||||||
|
"relation_tag": row.get("relation_tag"),
|
||||||
|
"in_active": row.get("in_active"),
|
||||||
|
"in_screened": row.get("in_screened"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_sentiment_batch_payload(hours=24, limit=40):
|
||||||
|
conn = get_conn()
|
||||||
|
conn.row_factory = None
|
||||||
|
events = []
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, source, symbol, title, url, published_at, detected_at, importance,
|
||||||
|
event_type, decision, tech_score, rec_id, pushed
|
||||||
|
FROM event_news
|
||||||
|
WHERE detected_at >= datetime('now', '-' || ? || ' hours')
|
||||||
|
ORDER BY datetime(published_at) DESC, id DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(int(hours or 24), int(limit or 40)),
|
||||||
|
).fetchall()
|
||||||
|
except Exception:
|
||||||
|
rows = []
|
||||||
|
for raw in rows:
|
||||||
|
row = {
|
||||||
|
"event_id": f"event_news:{raw[0]}",
|
||||||
|
"source": raw[1],
|
||||||
|
"related_symbol": raw[2],
|
||||||
|
"related_base": (str(raw[2] or "").split("/")[0] or "").upper(),
|
||||||
|
"title": raw[3],
|
||||||
|
"url": raw[4],
|
||||||
|
"published_at": raw[5],
|
||||||
|
"detected_at": raw[6],
|
||||||
|
"importance": raw[7],
|
||||||
|
"event_type": raw[8],
|
||||||
|
"decision": raw[9],
|
||||||
|
"tech_score": raw[10],
|
||||||
|
"rec_id": raw[11],
|
||||||
|
"pushed": bool(raw[12]),
|
||||||
|
}
|
||||||
|
if _is_internal_sentiment_event(row):
|
||||||
|
continue
|
||||||
|
events.append(row)
|
||||||
|
|
||||||
|
try:
|
||||||
|
trend_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, symbol, name, trend_rank, trend_score, market_cap_rank, detected_at, extra_json
|
||||||
|
FROM sentiment_events
|
||||||
|
WHERE detected_at = (SELECT MAX(detected_at) FROM sentiment_events WHERE source='coingecko')
|
||||||
|
ORDER BY trend_rank
|
||||||
|
LIMIT 20
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
except Exception:
|
||||||
|
trend_rows = []
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
trend_news = []
|
||||||
|
for raw in trend_rows:
|
||||||
|
extra = _json_fallback(raw[7], {}) or {}
|
||||||
|
for n in (extra.get("news") or [])[:3]:
|
||||||
|
title = n.get("title") or ""
|
||||||
|
if not title:
|
||||||
|
continue
|
||||||
|
trend_news.append({
|
||||||
|
"event_id": f"sentiment_event:{raw[0]}:{n.get('url') or title}",
|
||||||
|
"source": n.get("source") or "news",
|
||||||
|
"related_symbol": f"{str(raw[1] or '').upper()}/USDT",
|
||||||
|
"related_base": str(raw[1] or "").upper(),
|
||||||
|
"related_name": raw[2] or raw[1],
|
||||||
|
"title": title[:180],
|
||||||
|
"url": n.get("url") or "",
|
||||||
|
"published_at": n.get("published") or "",
|
||||||
|
"detected_at": raw[6],
|
||||||
|
"importance": "B",
|
||||||
|
"event_type": "news",
|
||||||
|
"trend_rank": raw[3],
|
||||||
|
"trend_score": raw[4],
|
||||||
|
"market_cap_rank": raw[5],
|
||||||
|
"price_usd": extra.get("price_usd", 0),
|
||||||
|
"change_24h_pct": extra.get("change_24h_pct", 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
combined = events + trend_news
|
||||||
|
seen = set()
|
||||||
|
deduped = []
|
||||||
|
for item in combined:
|
||||||
|
key = ((item.get("title") or "").strip().lower(), item.get("related_base"), item.get("source"))
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
deduped.append(item)
|
||||||
|
deduped = deduped[: int(limit or 40)]
|
||||||
|
return {
|
||||||
|
"target_type": "sentiment_batch",
|
||||||
|
"target_id": f"sentiment_batch:{int(hours or 24)}h",
|
||||||
|
"hours": int(hours or 24),
|
||||||
|
"generated_at": datetime.now().isoformat(),
|
||||||
|
"event_count": len(deduped),
|
||||||
|
"events": deduped,
|
||||||
|
"instructions": {
|
||||||
|
"role": "作为加密市场舆情分析师,判断这些新闻对山寨币行情的影响。",
|
||||||
|
"focus": [
|
||||||
|
"归纳主线叙事和受影响币种",
|
||||||
|
"区分利好、利空、风险和噪音",
|
||||||
|
"给出可信度和短线影响窗口",
|
||||||
|
"指出哪些币种需要触发技术检查",
|
||||||
|
"不要给买卖指令,只做舆情影响分析",
|
||||||
|
],
|
||||||
|
"expected_schema": {
|
||||||
|
"market_mood": "risk_on|neutral|risk_off",
|
||||||
|
"summary": "中文摘要",
|
||||||
|
"hot_themes": [{"theme": "", "impact": "", "symbols": [], "confidence": 0}],
|
||||||
|
"coin_impacts": [{"symbol": "", "direction": "positive|negative|risk|neutral", "reason": "", "confidence": 0, "need_technical_check": False}],
|
||||||
|
"risk_events": [{"title": "", "symbols": [], "risk_type": "", "severity": "low|medium|high"}],
|
||||||
|
"watchlist": [{"symbol": "", "why": "", "trigger": ""}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_review_payload(item):
|
||||||
|
return {
|
||||||
|
"target_type": "review",
|
||||||
|
"target_id": item.get("id") or item.get("created_at") or item.get("run_date"),
|
||||||
|
"run_date": item.get("run_date"),
|
||||||
|
"created_at": item.get("created_at"),
|
||||||
|
"title": item.get("title"),
|
||||||
|
"summary": item.get("summary"),
|
||||||
|
"metrics": item.get("metrics") or {},
|
||||||
|
"findings": item.get("findings") or [],
|
||||||
|
"problems": item.get("problems") or [],
|
||||||
|
"actions": item.get("actions") or [],
|
||||||
|
"candidate_rules": item.get("candidate_rules") or [],
|
||||||
|
"success_analysis": item.get("success_analysis") or {},
|
||||||
|
"failure_analysis": item.get("failure_analysis") or {},
|
||||||
|
"pollution_summary": item.get("pollution_summary") or {},
|
||||||
|
"version_change_summary": item.get("version_change_summary") or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_recommendation_insights(limit=30):
|
||||||
|
if not get_llm_module_enabled("recommendations"):
|
||||||
|
return {"status": "skipped", "reason": "module_disabled", "processed": 0}
|
||||||
|
conn = get_conn()
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT r.*,
|
||||||
|
lpc.price AS latest_cache_price,
|
||||||
|
lpc.updated_at AS latest_cache_updated_at
|
||||||
|
FROM recommendation r
|
||||||
|
LEFT JOIN latest_price_cache lpc ON lpc.symbol = r.symbol
|
||||||
|
WHERE r.status='active' AND COALESCE(r.display_bucket,'watch_pool') != 'history'
|
||||||
|
ORDER BY r.rec_time DESC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
items = []
|
||||||
|
seen = set()
|
||||||
|
for row in rows:
|
||||||
|
item = _derive_execution_fields(dict(row))
|
||||||
|
if not _should_generate_recommendation(item):
|
||||||
|
continue
|
||||||
|
if str(item.get("id")) in seen:
|
||||||
|
continue
|
||||||
|
seen.add(str(item.get("id")))
|
||||||
|
items.append(item)
|
||||||
|
if limit and len(items) >= int(limit):
|
||||||
|
break
|
||||||
|
|
||||||
|
processed = 0
|
||||||
|
for row in items:
|
||||||
|
payload = _build_recommendation_payload(row)
|
||||||
|
input_hash = compute_input_hash(payload)
|
||||||
|
cached = get_any_insight("recommendation", payload["target_id"], PROMPTS["recommendation_explain_v1"], input_hash)
|
||||||
|
if cached:
|
||||||
|
continue
|
||||||
|
result = _call_llm_json(PROMPTS["recommendation_explain_v1"], payload)
|
||||||
|
upsert_insight(
|
||||||
|
"recommendation",
|
||||||
|
payload["target_id"],
|
||||||
|
PROMPTS["recommendation_explain_v1"],
|
||||||
|
PROMPTS["recommendation_explain_v1"],
|
||||||
|
input_hash,
|
||||||
|
result.get("status") or "failed",
|
||||||
|
input_payload=payload,
|
||||||
|
content=result.get("content") if result.get("status") == "success" else {"raw": result.get("raw", "")},
|
||||||
|
error=result.get("error", ""),
|
||||||
|
model=result.get("model", ""),
|
||||||
|
)
|
||||||
|
processed += 1
|
||||||
|
return {"status": "success", "processed": processed, "scanned": len(items)}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_sentiment_insights(limit=30):
|
||||||
|
if not get_llm_module_enabled("sentiment"):
|
||||||
|
return {"status": "skipped", "reason": "module_disabled", "processed": 0}
|
||||||
|
conn = get_conn()
|
||||||
|
conn.row_factory = None
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id AS event_id, source, symbol, title, url, published_at, detected_at, importance,
|
||||||
|
event_type, decision, tech_score, rec_id, pushed
|
||||||
|
FROM event_news
|
||||||
|
ORDER BY datetime(published_at) DESC, id DESC
|
||||||
|
LIMIT 120
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
except Exception:
|
||||||
|
rows = []
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
processed = 0
|
||||||
|
for raw in rows:
|
||||||
|
row = {
|
||||||
|
"event_id": f"event_news:{raw[0]}",
|
||||||
|
"source": raw[1],
|
||||||
|
"symbol": raw[2],
|
||||||
|
"title": raw[3],
|
||||||
|
"published_at": raw[5],
|
||||||
|
"detected_at": raw[6],
|
||||||
|
"importance": raw[7],
|
||||||
|
"event_type": raw[8],
|
||||||
|
"decision": raw[9],
|
||||||
|
"tech_score": raw[10],
|
||||||
|
"rec_id": raw[11],
|
||||||
|
"pushed": raw[12],
|
||||||
|
"source_label": "Binance公告" if "binance" in str(raw[1]).lower() else str(raw[1] or ""),
|
||||||
|
"related_symbol": raw[2],
|
||||||
|
"related_base": (str(raw[2] or "").split("/")[0] or "").upper(),
|
||||||
|
"in_active": False,
|
||||||
|
"in_screened": False,
|
||||||
|
"relation_tag": "",
|
||||||
|
}
|
||||||
|
if not _should_generate_sentiment(row):
|
||||||
|
continue
|
||||||
|
payload = _build_sentiment_payload(row)
|
||||||
|
input_hash = compute_input_hash(payload)
|
||||||
|
cached = get_any_insight("sentiment", payload["target_id"], PROMPTS["sentiment_explain_v1"], input_hash)
|
||||||
|
if cached:
|
||||||
|
continue
|
||||||
|
result = _call_llm_json(PROMPTS["sentiment_explain_v1"], payload)
|
||||||
|
upsert_insight(
|
||||||
|
"sentiment",
|
||||||
|
payload["target_id"],
|
||||||
|
PROMPTS["sentiment_explain_v1"],
|
||||||
|
PROMPTS["sentiment_explain_v1"],
|
||||||
|
input_hash,
|
||||||
|
result.get("status") or "failed",
|
||||||
|
input_payload=payload,
|
||||||
|
content=result.get("content") if result.get("status") == "success" else {"raw": result.get("raw", "")},
|
||||||
|
error=result.get("error", ""),
|
||||||
|
model=result.get("model", ""),
|
||||||
|
)
|
||||||
|
processed += 1
|
||||||
|
if limit and processed >= int(limit):
|
||||||
|
break
|
||||||
|
return {"status": "success", "processed": processed}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_sentiment_batch_analysis(limit=40, hours=24):
|
||||||
|
if not get_llm_module_enabled("sentiment"):
|
||||||
|
return {"status": "skipped", "reason": "module_disabled", "processed": 0}
|
||||||
|
payload = _build_sentiment_batch_payload(hours=hours, limit=limit)
|
||||||
|
if not payload.get("events"):
|
||||||
|
return {"status": "skipped", "reason": "no_sentiment_events", "processed": 0}
|
||||||
|
input_hash = compute_input_hash(payload)
|
||||||
|
cached = get_any_insight("sentiment_batch", payload["target_id"], PROMPTS["sentiment_batch_analyze_v1"], input_hash)
|
||||||
|
if cached:
|
||||||
|
candidate_result = {"queued": 0, "skipped": 0, "symbols": []}
|
||||||
|
if cached.get("status") == "success":
|
||||||
|
try:
|
||||||
|
from app.services.event_driven_screener import enqueue_llm_sentiment_candidates
|
||||||
|
|
||||||
|
candidate_result = enqueue_llm_sentiment_candidates(
|
||||||
|
cached.get("content") or {},
|
||||||
|
source_insight_id=str(cached.get("id") or input_hash),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
candidate_result = {"queued": 0, "skipped": 0, "symbols": [], "error": str(exc)[:300]}
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"processed": 0,
|
||||||
|
"cached": True,
|
||||||
|
"event_count": payload.get("event_count", 0),
|
||||||
|
"candidate_events": candidate_result,
|
||||||
|
}
|
||||||
|
result = _call_llm_json(PROMPTS["sentiment_batch_analyze_v1"], payload)
|
||||||
|
candidate_result = {"queued": 0, "skipped": 0, "symbols": []}
|
||||||
|
if result.get("status") == "success":
|
||||||
|
try:
|
||||||
|
from app.services.event_driven_screener import enqueue_llm_sentiment_candidates
|
||||||
|
|
||||||
|
candidate_result = enqueue_llm_sentiment_candidates(
|
||||||
|
result.get("content") or {},
|
||||||
|
source_insight_id=input_hash,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
candidate_result = {"queued": 0, "skipped": 0, "symbols": [], "error": str(exc)[:300]}
|
||||||
|
upsert_insight(
|
||||||
|
"sentiment_batch",
|
||||||
|
payload["target_id"],
|
||||||
|
PROMPTS["sentiment_batch_analyze_v1"],
|
||||||
|
PROMPTS["sentiment_batch_analyze_v1"],
|
||||||
|
input_hash,
|
||||||
|
result.get("status") or "failed",
|
||||||
|
input_payload=payload,
|
||||||
|
content=result.get("content") if result.get("status") == "success" else {"raw": result.get("raw", "")},
|
||||||
|
error=result.get("error", ""),
|
||||||
|
model=result.get("model", ""),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"processed": 1,
|
||||||
|
"event_count": payload.get("event_count", 0),
|
||||||
|
"candidate_events": candidate_result,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_review_memos(limit=10):
|
||||||
|
if not get_llm_module_enabled("review"):
|
||||||
|
return {"status": "skipped", "reason": "module_disabled", "processed": 0}
|
||||||
|
from app.db.review_queries import get_strategy_iteration_logs
|
||||||
|
|
||||||
|
logs = get_strategy_iteration_logs(limit=max(limit or 10, 1))
|
||||||
|
processed = 0
|
||||||
|
for item in logs:
|
||||||
|
if not _should_generate_review(item):
|
||||||
|
continue
|
||||||
|
payload = _build_review_payload(item)
|
||||||
|
input_hash = compute_input_hash(payload)
|
||||||
|
cached = get_any_insight("review", payload["target_id"], PROMPTS["review_memo_v1"], input_hash)
|
||||||
|
if cached:
|
||||||
|
continue
|
||||||
|
result = _call_llm_json(PROMPTS["review_memo_v1"], payload)
|
||||||
|
upsert_insight(
|
||||||
|
"review",
|
||||||
|
payload["target_id"],
|
||||||
|
PROMPTS["review_memo_v1"],
|
||||||
|
PROMPTS["review_memo_v1"],
|
||||||
|
input_hash,
|
||||||
|
result.get("status") or "failed",
|
||||||
|
input_payload=payload,
|
||||||
|
content=result.get("content") if result.get("status") == "success" else {"raw": result.get("raw", "")},
|
||||||
|
error=result.get("error", ""),
|
||||||
|
model=result.get("model", ""),
|
||||||
|
)
|
||||||
|
processed += 1
|
||||||
|
return {"status": "success", "processed": processed}
|
||||||
|
|
||||||
|
|
||||||
|
def run(scope="recommendations", limit=30):
|
||||||
|
scope = str(scope or "").strip()
|
||||||
|
if scope == "recommendations":
|
||||||
|
return generate_recommendation_insights(limit=limit)
|
||||||
|
if scope == "sentiment":
|
||||||
|
return generate_sentiment_batch_analysis(limit=limit)
|
||||||
|
if scope == "sentiment-events":
|
||||||
|
return generate_sentiment_insights(limit=limit)
|
||||||
|
if scope == "review":
|
||||||
|
return generate_review_memos(limit=limit)
|
||||||
|
raise ValueError(f"unknown llm scope: {scope}")
|
||||||
|
|
||||||
|
|
||||||
|
def attach_recommendation_insights(items):
|
||||||
|
ids = [str(item.get("id")) for item in items or [] if item.get("id") is not None]
|
||||||
|
insights = get_insights_for_targets("recommendation", ids, PROMPTS["recommendation_explain_v1"])
|
||||||
|
for item in items or []:
|
||||||
|
insight = insights.get(str(item.get("id")))
|
||||||
|
if insight:
|
||||||
|
item["llm_insight"] = insight
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def attach_sentiment_insights(items):
|
||||||
|
ids = [str(item.get("event_id") or item.get("id")) for item in items or [] if (item.get("event_id") or item.get("id")) is not None]
|
||||||
|
insights = get_insights_for_targets("sentiment", ids, PROMPTS["sentiment_explain_v1"])
|
||||||
|
for item in items or []:
|
||||||
|
insight = insights.get(str(item.get("event_id") or item.get("id")))
|
||||||
|
if insight:
|
||||||
|
item["llm_insight"] = insight
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_review_memo():
|
||||||
|
return get_latest_insight_by_type("review", PROMPTS["review_memo_v1"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_sentiment_batch_analysis():
|
||||||
|
return get_latest_insight_by_type("sentiment_batch", PROMPTS["sentiment_batch_analyze_v1"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_sentiment_batch_attempt():
|
||||||
|
return get_latest_insight_by_type("sentiment_batch", PROMPTS["sentiment_batch_analyze_v1"], success_only=False)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PROMPTS",
|
||||||
|
"attach_recommendation_insights",
|
||||||
|
"attach_sentiment_insights",
|
||||||
|
"generate_recommendation_insights",
|
||||||
|
"generate_review_memos",
|
||||||
|
"generate_sentiment_batch_analysis",
|
||||||
|
"generate_sentiment_insights",
|
||||||
|
"get_latest_sentiment_batch_analysis",
|
||||||
|
"get_latest_sentiment_batch_attempt",
|
||||||
|
"get_latest_review_memo",
|
||||||
|
"run",
|
||||||
|
]
|
||||||
@ -2,7 +2,21 @@ from fastapi import APIRouter, Cookie, HTTPException, Request
|
|||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
from app.db import auth_db
|
from app.db import auth_db
|
||||||
from app.web.shared import login_redirect, require_admin
|
from app.db.scheduler_db import (
|
||||||
|
enqueue_manual_trigger,
|
||||||
|
get_job_config,
|
||||||
|
get_scheduler_overview,
|
||||||
|
list_manual_triggers,
|
||||||
|
set_job_enabled,
|
||||||
|
set_job_interval,
|
||||||
|
)
|
||||||
|
from app.web.shared import (
|
||||||
|
SchedulerIntervalRequest,
|
||||||
|
SchedulerToggleRequest,
|
||||||
|
SchedulerTriggerRequest,
|
||||||
|
login_redirect,
|
||||||
|
require_admin,
|
||||||
|
)
|
||||||
|
|
||||||
def build_router(templates):
|
def build_router(templates):
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -40,4 +54,41 @@ def build_router(templates):
|
|||||||
require_admin(altcoin_session)
|
require_admin(altcoin_session)
|
||||||
return auth_db.get_admin_orders(search=search, offset=offset, limit=limit, status=status)
|
return auth_db.get_admin_orders(search=search, offset=offset, limit=limit, status=status)
|
||||||
|
|
||||||
|
@router.get("/api/scheduler/jobs")
|
||||||
|
async def api_scheduler_jobs(altcoin_session: str = Cookie(default="")):
|
||||||
|
require_admin(altcoin_session)
|
||||||
|
return get_scheduler_overview()
|
||||||
|
|
||||||
|
@router.post("/api/scheduler/jobs/{job_name}/toggle")
|
||||||
|
async def api_scheduler_toggle(job_name: str, payload: SchedulerToggleRequest, altcoin_session: str = Cookie(default="")):
|
||||||
|
require_admin(altcoin_session)
|
||||||
|
if not set_job_enabled(job_name, payload.enabled):
|
||||||
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
|
return {"ok": True, "job_name": job_name, "enabled": payload.enabled}
|
||||||
|
|
||||||
|
@router.post("/api/scheduler/jobs/{job_name}/interval")
|
||||||
|
async def api_scheduler_interval(job_name: str, payload: SchedulerIntervalRequest, altcoin_session: str = Cookie(default="")):
|
||||||
|
require_admin(altcoin_session)
|
||||||
|
if payload.every_seconds < 30:
|
||||||
|
raise HTTPException(status_code=400, detail="周期不能低于 30 秒")
|
||||||
|
if not set_job_interval(job_name, payload.every_seconds):
|
||||||
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
|
return {"ok": True, "job_name": job_name, "every_seconds": payload.every_seconds}
|
||||||
|
|
||||||
|
@router.post("/api/scheduler/jobs/{job_name}/trigger")
|
||||||
|
async def api_scheduler_trigger(job_name: str, payload: SchedulerTriggerRequest, altcoin_session: str = Cookie(default="")):
|
||||||
|
user = require_admin(altcoin_session)
|
||||||
|
job = get_job_config(job_name)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
|
if not job.get("enabled") and not payload.force:
|
||||||
|
raise HTTPException(status_code=409, detail="任务已关闭,需要确认后 force=true 才能单次运行")
|
||||||
|
trigger_id = enqueue_manual_trigger(job_name, force=payload.force, requested_by=user.get("email", ""))
|
||||||
|
return {"ok": True, "job_name": job_name, "trigger_id": trigger_id, "force": payload.force}
|
||||||
|
|
||||||
|
@router.get("/api/scheduler/triggers")
|
||||||
|
async def api_scheduler_triggers(limit: int = 30, altcoin_session: str = Cookie(default="")):
|
||||||
|
require_admin(altcoin_session)
|
||||||
|
return {"items": list_manual_triggers(limit=limit)}
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|||||||
@ -8,6 +8,7 @@ from fastapi import APIRouter, Cookie
|
|||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from app.web.shared import require_api_user_with_subscription
|
from app.web.shared import require_api_user_with_subscription
|
||||||
|
from app.services.llm_insights import attach_sentiment_insights, get_latest_sentiment_batch_analysis, get_latest_sentiment_batch_attempt
|
||||||
|
|
||||||
|
|
||||||
def build_router(repo_root: Path):
|
def build_router(repo_root: Path):
|
||||||
@ -83,7 +84,7 @@ def build_router(repo_root: Path):
|
|||||||
try:
|
try:
|
||||||
event_rows = conn.execute(
|
event_rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT source, symbol, title, url, published_at, detected_at, importance,
|
SELECT id, source, symbol, title, url, published_at, detected_at, importance,
|
||||||
event_type, decision, tech_score, rec_id, pushed
|
event_type, decision, tech_score, rec_id, pushed
|
||||||
FROM event_news
|
FROM event_news
|
||||||
WHERE detected_at >= datetime('now', '-' || ? || ' hours')
|
WHERE detected_at >= datetime('now', '-' || ? || ' hours')
|
||||||
@ -96,14 +97,16 @@ def build_router(repo_root: Path):
|
|||||||
base = (r["symbol"] or "").split("/")[0].upper()
|
base = (r["symbol"] or "").split("/")[0].upper()
|
||||||
source = r["source"] or "event"
|
source = r["source"] or "event"
|
||||||
event_type = r["event_type"] or "event"
|
event_type = r["event_type"] or "event"
|
||||||
if event_type == "market_heat":
|
title = r["title"] or ""
|
||||||
|
if event_type in ("market_heat", "theme_expansion", "theme_direct", "llm_sentiment_candidate") or source == "llm_sentiment" or title.startswith("[主题扩散:"):
|
||||||
continue
|
continue
|
||||||
events.append({
|
events.append({
|
||||||
|
"event_id": f"event_news:{r['id']}",
|
||||||
"source": source,
|
"source": source,
|
||||||
"source_label": "Binance公告" if "binance" in source else "CoinGecko热度" if "coingecko" in source else source,
|
"source_label": "Binance公告" if "binance" in source else "CoinGecko热度" if "coingecko" in source else source,
|
||||||
"event_type": event_type,
|
"event_type": event_type,
|
||||||
"importance": r["importance"] or "B",
|
"importance": r["importance"] or "B",
|
||||||
"title": r["title"] or "",
|
"title": title,
|
||||||
"url": r["url"] or "",
|
"url": r["url"] or "",
|
||||||
"published_at": r["published_at"],
|
"published_at": r["published_at"],
|
||||||
"detected_at": r["detected_at"],
|
"detected_at": r["detected_at"],
|
||||||
@ -155,6 +158,7 @@ def build_router(repo_root: Path):
|
|||||||
if not _is_valuable_news_title(title):
|
if not _is_valuable_news_title(title):
|
||||||
continue
|
continue
|
||||||
events.append({
|
events.append({
|
||||||
|
"event_id": f"sentiment_event:{r['id']}:{n.get('url') or title}",
|
||||||
"source": n.get("source") or "news",
|
"source": n.get("source") or "news",
|
||||||
"source_label": n.get("source") or "新闻",
|
"source_label": n.get("source") or "新闻",
|
||||||
"event_type": "news",
|
"event_type": "news",
|
||||||
@ -196,6 +200,7 @@ def build_router(repo_root: Path):
|
|||||||
deduped.append(e)
|
deduped.append(e)
|
||||||
|
|
||||||
deduped.sort(key=lambda item: (item.get("published_at") or item.get("detected_at") or "", {"RISK": 5, "S": 4, "A": 3, "B": 2, "C": 1}.get(item.get("importance"), 0)), reverse=True)
|
deduped.sort(key=lambda item: (item.get("published_at") or item.get("detected_at") or "", {"RISK": 5, "S": 4, "A": 3, "B": 2, "C": 1}.get(item.get("importance"), 0)), reverse=True)
|
||||||
|
attach_sentiment_insights(deduped)
|
||||||
check_time = deduped[0]["detected_at"] if deduped else None
|
check_time = deduped[0]["detected_at"] if deduped else None
|
||||||
return {
|
return {
|
||||||
"check_time": check_time,
|
"check_time": check_time,
|
||||||
@ -207,6 +212,34 @@ def build_router(repo_root: Path):
|
|||||||
"total_trending": 0,
|
"total_trending": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@router.get("/api/sentiment/analysis")
|
||||||
|
async def api_sentiment_analysis(altcoin_session: str = Cookie(default="")):
|
||||||
|
require_api_user_with_subscription(altcoin_session)
|
||||||
|
insight = get_latest_sentiment_batch_analysis()
|
||||||
|
if not insight:
|
||||||
|
attempt = get_latest_sentiment_batch_attempt()
|
||||||
|
return {
|
||||||
|
"analysis": None,
|
||||||
|
"status": "empty" if not attempt else attempt.get("status"),
|
||||||
|
"updated_at": attempt.get("updated_at") if attempt else None,
|
||||||
|
"model": attempt.get("model") if attempt else "",
|
||||||
|
"error": attempt.get("error") if attempt else "",
|
||||||
|
"event_count": (attempt.get("input") or {}).get("event_count", 0) if attempt else 0,
|
||||||
|
"source_events": (attempt.get("input") or {}).get("events", []) if attempt else [],
|
||||||
|
}
|
||||||
|
content = insight.get("content") or {}
|
||||||
|
payload = insight.get("input") or {}
|
||||||
|
return {
|
||||||
|
"status": insight.get("status"),
|
||||||
|
"updated_at": insight.get("updated_at"),
|
||||||
|
"model": insight.get("model"),
|
||||||
|
"prompt_version": insight.get("prompt_version"),
|
||||||
|
"analysis": content,
|
||||||
|
"source_events": payload.get("events") or [],
|
||||||
|
"event_count": payload.get("event_count") or len(payload.get("events") or []),
|
||||||
|
"hours": payload.get("hours") or 24,
|
||||||
|
}
|
||||||
|
|
||||||
@router.get("/api/kline")
|
@router.get("/api/kline")
|
||||||
async def api_kline(symbol: str, interval: str = "1d", limit: int = 60, altcoin_session: str = Cookie(default="")):
|
async def api_kline(symbol: str, interval: str = "1d", limit: int = 60, altcoin_session: str = Cookie(default="")):
|
||||||
require_api_user_with_subscription(altcoin_session)
|
require_api_user_with_subscription(altcoin_session)
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Cookie, Request
|
from fastapi import APIRouter, Cookie, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
from app.db import auth_db
|
from app.db import auth_db
|
||||||
from app.web.shared import require_page_user
|
from app.web.shared import require_admin, require_page_user
|
||||||
|
|
||||||
|
|
||||||
def build_router(templates, repo_root: Path, stock_report_template: str):
|
def build_router(templates, repo_root: Path, stock_report_template: str):
|
||||||
@ -48,6 +48,24 @@ def build_router(templates, repo_root: Path, stock_report_template: str):
|
|||||||
return redirect
|
return redirect
|
||||||
return render_page("pipeline.html", request)
|
return render_page("pipeline.html", request)
|
||||||
|
|
||||||
|
@router.get("/llm-insights", response_class=HTMLResponse)
|
||||||
|
async def llm_insights_page(request: Request):
|
||||||
|
user, redirect = require_page_user(request)
|
||||||
|
if redirect:
|
||||||
|
return redirect
|
||||||
|
return render_page("llm_insights.html", request)
|
||||||
|
|
||||||
|
@router.get("/cron", response_class=HTMLResponse)
|
||||||
|
async def cron_page(request: Request):
|
||||||
|
user, redirect = require_page_user(request)
|
||||||
|
if redirect:
|
||||||
|
return redirect
|
||||||
|
try:
|
||||||
|
require_admin(request.cookies.get("altcoin_session", ""))
|
||||||
|
except HTTPException as exc:
|
||||||
|
return HTMLResponse(content=f"<meta charset=utf-8><h2>需要管理员权限</h2><p>{exc.detail}</p><a href=/app>返回看板</a>", status_code=exc.status_code)
|
||||||
|
return render_page("cron.html", request)
|
||||||
|
|
||||||
@router.get("/strategy", response_class=HTMLResponse)
|
@router.get("/strategy", response_class=HTMLResponse)
|
||||||
async def strategy_page(request: Request):
|
async def strategy_page(request: Request):
|
||||||
user, redirect = require_page_user(request)
|
user, redirect = require_page_user(request)
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from app.db.analytics import (
|
|||||||
get_screening_history,
|
get_screening_history,
|
||||||
get_stats,
|
get_stats,
|
||||||
)
|
)
|
||||||
|
from app.db.llm_insights import get_llm_insight_by_id, list_llm_insights
|
||||||
from app.db.recommendation_queries import get_active_recommendations, get_active_recommendations_deduped
|
from app.db.recommendation_queries import get_active_recommendations, get_active_recommendations_deduped
|
||||||
from app.config.config_loader import get_signal_weights
|
from app.config.config_loader import get_signal_weights
|
||||||
from app.web.shared import (
|
from app.web.shared import (
|
||||||
@ -24,6 +25,41 @@ from app.web.shared import (
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _friendly_llm_item(item):
|
||||||
|
content = item.get("content") or {}
|
||||||
|
payload = item.get("input") or {}
|
||||||
|
target_type = item.get("target_type") or ""
|
||||||
|
status = item.get("status") or ""
|
||||||
|
type_label = {
|
||||||
|
"recommendation": "推荐解释",
|
||||||
|
"sentiment": "舆情解读",
|
||||||
|
"review": "复盘 memo",
|
||||||
|
}.get(target_type, target_type or "未知任务")
|
||||||
|
status_label = {
|
||||||
|
"success": "成功",
|
||||||
|
"failed": "失败",
|
||||||
|
"skipped": "跳过",
|
||||||
|
}.get(status, status or "未知")
|
||||||
|
subject = payload.get("symbol") or payload.get("related_symbol") or payload.get("title") or payload.get("run_date") or item.get("target_id")
|
||||||
|
summary = content.get("summary") or content.get("memo") or content.get("why_now_or_not") or content.get("raw") or item.get("error") or ""
|
||||||
|
return {
|
||||||
|
"id": item.get("id"),
|
||||||
|
"type_label": type_label,
|
||||||
|
"status_label": status_label,
|
||||||
|
"status": status,
|
||||||
|
"subject": subject,
|
||||||
|
"summary": summary,
|
||||||
|
"model": item.get("model") or "",
|
||||||
|
"prompt_version": item.get("prompt_version") or "",
|
||||||
|
"target_type": target_type,
|
||||||
|
"target_id": item.get("target_id"),
|
||||||
|
"updated_at": item.get("updated_at"),
|
||||||
|
"error": item.get("error") or "",
|
||||||
|
"content": content,
|
||||||
|
"input": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/stats")
|
@router.get("/api/stats")
|
||||||
async def api_stats(altcoin_session: str = Cookie(default="")):
|
async def api_stats(altcoin_session: str = Cookie(default="")):
|
||||||
require_api_user_with_subscription(altcoin_session)
|
require_api_user_with_subscription(altcoin_session)
|
||||||
@ -166,3 +202,33 @@ async def api_pipeline_run_detail(run_id: int, altcoin_session: str = Cookie(def
|
|||||||
if not detail:
|
if not detail:
|
||||||
return {"error": "pipeline run not found", "run_id": run_id}
|
return {"error": "pipeline run not found", "run_id": run_id}
|
||||||
return detail
|
return detail
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/llm/insights")
|
||||||
|
async def api_llm_insights(
|
||||||
|
limit: int = 30,
|
||||||
|
offset: int = 0,
|
||||||
|
target_type: str = "",
|
||||||
|
status: str = "",
|
||||||
|
insight_type: str = "",
|
||||||
|
altcoin_session: str = Cookie(default=""),
|
||||||
|
):
|
||||||
|
require_api_user_with_subscription(altcoin_session)
|
||||||
|
data = list_llm_insights(
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
target_type=target_type or "",
|
||||||
|
status=status or "",
|
||||||
|
insight_type=insight_type or "",
|
||||||
|
)
|
||||||
|
data["items"] = [_friendly_llm_item(item) for item in data.get("items", [])]
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/llm/insights/{insight_id}")
|
||||||
|
async def api_llm_insight_detail(insight_id: int, altcoin_session: str = Cookie(default="")):
|
||||||
|
require_api_user_with_subscription(altcoin_session)
|
||||||
|
item = get_llm_insight_by_id(insight_id)
|
||||||
|
if not item:
|
||||||
|
return {"error": "llm insight not found", "id": insight_id}
|
||||||
|
return _friendly_llm_item(item)
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from app.db.review_queries import (
|
|||||||
get_strategy_rule_candidates,
|
get_strategy_rule_candidates,
|
||||||
refresh_strategy_candidate_performance,
|
refresh_strategy_candidate_performance,
|
||||||
)
|
)
|
||||||
|
from app.services.llm_insights import get_latest_review_memo
|
||||||
from app.db.schema import get_conn
|
from app.db.schema import get_conn
|
||||||
from app.db.altcoin_db import _derive_execution_fields
|
from app.db.altcoin_db import _derive_execution_fields
|
||||||
from app.web.shared import require_api_user_with_subscription
|
from app.web.shared import require_api_user_with_subscription
|
||||||
@ -74,7 +75,9 @@ async def api_strategy_insights(altcoin_session: str = Cookie(default="")):
|
|||||||
@router.get("/api/strategy/lifecycle")
|
@router.get("/api/strategy/lifecycle")
|
||||||
async def api_strategy_lifecycle(days: int = 30, altcoin_session: str = Cookie(default="")):
|
async def api_strategy_lifecycle(days: int = 30, altcoin_session: str = Cookie(default="")):
|
||||||
require_api_user_with_subscription(altcoin_session)
|
require_api_user_with_subscription(altcoin_session)
|
||||||
return get_strategy_iteration_dashboard(days=days)
|
data = get_strategy_iteration_dashboard(days=days)
|
||||||
|
data["llm_review_memo"] = get_latest_review_memo()
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/iterations")
|
@router.get("/api/iterations")
|
||||||
|
|||||||
@ -67,6 +67,18 @@ class PushRulesRequest(BaseModel):
|
|||||||
quiet_end: str = ""
|
quiet_end: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerToggleRequest(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerIntervalRequest(BaseModel):
|
||||||
|
every_seconds: int
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerTriggerRequest(BaseModel):
|
||||||
|
force: bool = False
|
||||||
|
|
||||||
|
|
||||||
def auth_error(exc: Exception, status_code: int = 400):
|
def auth_error(exc: Exception, status_code: int = 400):
|
||||||
raise HTTPException(status_code=status_code, detail=str(exc))
|
raise HTTPException(status_code=status_code, detail=str(exc))
|
||||||
|
|
||||||
|
|||||||
@ -1,25 +1,35 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""AlphaX 容器内轻量调度器。
|
"""AlphaX Docker scheduler with lightweight process concurrency."""
|
||||||
|
|
||||||
设计目标:
|
|
||||||
- 替代宿主机 crontab;
|
|
||||||
- 单进程串行执行,避免 SQLite 并发写锁;
|
|
||||||
- 默认 DRY_RUN=1,不影响线上,也不会真的跑任务;
|
|
||||||
- 部署验证通过后再把 ALPHAX_SCHEDULER_DRY_RUN=0 打开。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from app.db.schema import init_db
|
||||||
|
from app.db.scheduler_db import (
|
||||||
|
claim_manual_triggers,
|
||||||
|
get_job_configs,
|
||||||
|
init_scheduler_tables,
|
||||||
|
update_manual_trigger,
|
||||||
|
update_runtime,
|
||||||
|
)
|
||||||
|
|
||||||
PYTHON = sys.executable
|
PYTHON = sys.executable
|
||||||
DRY_RUN = os.getenv("ALPHAX_SCHEDULER_DRY_RUN", "1").strip() not in {"0", "false", "False", "no", "NO"}
|
DRY_RUN = os.getenv("ALPHAX_SCHEDULER_DRY_RUN", "1").strip() not in {"0", "false", "False", "no", "NO"}
|
||||||
|
POLL_SECONDS = 1.0
|
||||||
|
CONFIG_RELOAD_SECONDS = 5.0
|
||||||
|
PENDING_WARN_SECONDS = 30.0
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -29,77 +39,262 @@ class Job:
|
|||||||
every_seconds: int
|
every_seconds: int
|
||||||
args: tuple[str, ...] = ()
|
args: tuple[str, ...] = ()
|
||||||
initial_delay: int = 0
|
initial_delay: int = 0
|
||||||
|
lock_group: str = ""
|
||||||
|
enabled: bool = True
|
||||||
|
description: str = ""
|
||||||
next_run: float = 0.0
|
next_run: float = 0.0
|
||||||
|
pending: bool = False
|
||||||
|
pending_since: float = 0.0
|
||||||
|
last_pending_log: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RunningJob:
|
||||||
|
job: Job
|
||||||
|
proc: subprocess.Popen
|
||||||
|
started_at: float
|
||||||
|
started_iso: str
|
||||||
|
run_kind: str = "auto"
|
||||||
|
trigger_id: int = 0
|
||||||
|
output_file: object | None = None
|
||||||
|
|
||||||
|
|
||||||
def now_str() -> str:
|
def now_str() -> str:
|
||||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
def iso_now() -> str:
|
||||||
|
return datetime.now().isoformat()
|
||||||
|
|
||||||
|
|
||||||
def env_for_child() -> dict[str, str]:
|
def env_for_child() -> dict[str, str]:
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env.setdefault("PYTHONUNBUFFERED", "1")
|
env.setdefault("PYTHONUNBUFFERED", "1")
|
||||||
return env
|
return env
|
||||||
|
|
||||||
|
|
||||||
def run_job(job: Job) -> None:
|
def _tail(text: str, limit: int = 8000) -> str:
|
||||||
|
value = text or ""
|
||||||
|
return value[-limit:] if len(value) > limit else value
|
||||||
|
|
||||||
|
|
||||||
|
def _next_run_iso(ts: float) -> str:
|
||||||
|
return datetime.fromtimestamp(ts).isoformat() if ts else ""
|
||||||
|
|
||||||
|
|
||||||
|
def load_jobs(existing: dict[str, Job], base: float, running: dict[str, RunningJob] | None = None) -> dict[str, Job]:
|
||||||
|
running = running or {}
|
||||||
|
loaded = {}
|
||||||
|
for cfg in get_job_configs():
|
||||||
|
name = cfg["job_name"]
|
||||||
|
old = existing.get(name)
|
||||||
|
every = int(cfg.get("every_seconds") or 60)
|
||||||
|
job = Job(
|
||||||
|
name=name,
|
||||||
|
command=cfg.get("command") or name,
|
||||||
|
every_seconds=every,
|
||||||
|
args=tuple(cfg.get("args") or ()),
|
||||||
|
initial_delay=int(cfg.get("initial_delay") or 0),
|
||||||
|
lock_group=cfg.get("lock_group") or name,
|
||||||
|
enabled=bool(cfg.get("enabled")),
|
||||||
|
description=cfg.get("description") or "",
|
||||||
|
next_run=old.next_run if old else base + int(cfg.get("initial_delay") or 0),
|
||||||
|
pending=old.pending if old else False,
|
||||||
|
pending_since=old.pending_since if old else 0.0,
|
||||||
|
last_pending_log=old.last_pending_log if old else 0.0,
|
||||||
|
)
|
||||||
|
loaded[name] = job
|
||||||
|
if name not in running:
|
||||||
|
update_runtime(name, next_run_at=_next_run_iso(job.next_run), status=("disabled" if not job.enabled else ("pending" if job.pending else "idle")))
|
||||||
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_busy(job: Job, running: dict[str, RunningJob]) -> bool:
|
||||||
|
if job.name in running:
|
||||||
|
return True
|
||||||
|
return any(item.job.lock_group == job.lock_group for item in running.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_pending(job: Job, reason: str) -> None:
|
||||||
|
now = time.time()
|
||||||
|
if not job.pending:
|
||||||
|
job.pending = True
|
||||||
|
job.pending_since = now
|
||||||
|
if now - job.last_pending_log >= PENDING_WARN_SECONDS:
|
||||||
|
print(f"[{now_str()}] [scheduler] pending {job.name}: {reason}", flush=True)
|
||||||
|
job.last_pending_log = now
|
||||||
|
update_runtime(job.name, status="pending", locked_by=reason, next_run_at=_next_run_iso(job.next_run))
|
||||||
|
|
||||||
|
|
||||||
|
def start_job(job: Job, running: dict[str, RunningJob], run_kind: str = "auto", trigger_id: int = 0) -> bool:
|
||||||
|
if _lock_busy(job, running):
|
||||||
|
blocker = "same_job" if job.name in running else f"lock:{job.lock_group}"
|
||||||
|
_mark_pending(job, blocker)
|
||||||
|
return False
|
||||||
|
|
||||||
cmd = [PYTHON, "-m", "app.cli", job.command, *job.args]
|
cmd = [PYTHON, "-m", "app.cli", job.command, *job.args]
|
||||||
print(f"[{now_str()}] [scheduler] start {job.name}: {' '.join(cmd)}", flush=True)
|
print(f"[{now_str()}] [scheduler] start {job.name} kind={run_kind}: {' '.join(cmd)}", flush=True)
|
||||||
|
job.pending = False
|
||||||
|
job.pending_since = 0.0
|
||||||
|
job.last_pending_log = 0.0
|
||||||
|
|
||||||
if DRY_RUN:
|
if DRY_RUN:
|
||||||
print(f"[{now_str()}] [scheduler] DRY_RUN=1 skip {job.name}", flush=True)
|
print(f"[{now_str()}] [scheduler] DRY_RUN=1 skip {job.name}", flush=True)
|
||||||
return
|
update_runtime(
|
||||||
started = time.time()
|
job.name,
|
||||||
try:
|
status="idle" if job.enabled else "disabled",
|
||||||
proc = subprocess.run(
|
pid=0,
|
||||||
cmd,
|
run_kind=run_kind,
|
||||||
cwd=ROOT,
|
trigger_id=trigger_id,
|
||||||
env=env_for_child(),
|
locked_by="",
|
||||||
text=True,
|
last_started_at=iso_now(),
|
||||||
stdout=subprocess.PIPE,
|
last_finished_at=iso_now(),
|
||||||
stderr=subprocess.STDOUT,
|
last_exit_code=0,
|
||||||
timeout=max(job.every_seconds * 2, 600),
|
last_duration_ms=0,
|
||||||
|
last_error="dry_run",
|
||||||
|
output_tail="DRY_RUN=1",
|
||||||
)
|
)
|
||||||
duration = time.time() - started
|
if trigger_id:
|
||||||
out = (proc.stdout or "").strip()
|
update_manual_trigger(trigger_id, status="skipped", finished_at=iso_now(), output_tail="DRY_RUN=1")
|
||||||
if len(out) > 8000:
|
return True
|
||||||
out = out[-8000:]
|
|
||||||
print(f"[{now_str()}] [scheduler] done {job.name} exit={proc.returncode} duration={duration:.1f}s", flush=True)
|
started_iso = iso_now()
|
||||||
if out:
|
output_file = tempfile.TemporaryFile(mode="w+t", encoding="utf-8", errors="replace")
|
||||||
print(out, flush=True)
|
proc = subprocess.Popen(
|
||||||
except subprocess.TimeoutExpired as e:
|
cmd,
|
||||||
print(f"[{now_str()}] [scheduler] timeout {job.name}: {e}", flush=True)
|
cwd=ROOT,
|
||||||
except Exception as e:
|
env=env_for_child(),
|
||||||
print(f"[{now_str()}] [scheduler] error {job.name}: {e}", flush=True)
|
text=True,
|
||||||
|
stdout=output_file,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
running[job.name] = RunningJob(
|
||||||
|
job=job,
|
||||||
|
proc=proc,
|
||||||
|
started_at=time.time(),
|
||||||
|
started_iso=started_iso,
|
||||||
|
run_kind=run_kind,
|
||||||
|
trigger_id=trigger_id,
|
||||||
|
output_file=output_file,
|
||||||
|
)
|
||||||
|
update_runtime(
|
||||||
|
job.name,
|
||||||
|
status="running",
|
||||||
|
pid=proc.pid,
|
||||||
|
run_kind=run_kind,
|
||||||
|
trigger_id=trigger_id,
|
||||||
|
locked_by=job.lock_group,
|
||||||
|
last_started_at=started_iso,
|
||||||
|
last_error="",
|
||||||
|
output_tail="",
|
||||||
|
)
|
||||||
|
if trigger_id:
|
||||||
|
update_manual_trigger(trigger_id, status="running", started_at=started_iso)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def build_jobs() -> list[Job]:
|
def finish_running_jobs(running: dict[str, RunningJob]) -> None:
|
||||||
# 与当前宿主机 crontab 对齐,但串行执行。
|
for name, item in list(running.items()):
|
||||||
return [
|
proc = item.proc
|
||||||
Job("event", "event", 60, initial_delay=5),
|
timeout = max(item.job.every_seconds * 2, 600)
|
||||||
Job("tracker", "tracker", 180, initial_delay=20),
|
elapsed = time.time() - item.started_at
|
||||||
Job("confirm", "confirm", 600, initial_delay=40),
|
if proc.poll() is None and elapsed > timeout:
|
||||||
Job("screener", "screener", 900, initial_delay=80),
|
proc.kill()
|
||||||
Job("sentiment", "sentiment", 1800, ("--collect",), initial_delay=120),
|
print(f"[{now_str()}] [scheduler] timeout {name} after {elapsed:.1f}s", flush=True)
|
||||||
Job("review", "review", 24 * 3600, initial_delay=300),
|
if proc.poll() is None:
|
||||||
]
|
continue
|
||||||
|
out = ""
|
||||||
|
try:
|
||||||
|
if item.output_file:
|
||||||
|
item.output_file.seek(0)
|
||||||
|
out = item.output_file.read()
|
||||||
|
except Exception:
|
||||||
|
out = ""
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if item.output_file:
|
||||||
|
item.output_file.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
duration_ms = int((time.time() - item.started_at) * 1000)
|
||||||
|
exit_code = int(proc.returncode or 0)
|
||||||
|
output_tail = _tail(out.strip())
|
||||||
|
status = "idle" if item.job.enabled else "disabled"
|
||||||
|
err = "" if exit_code == 0 else f"exit={exit_code}"
|
||||||
|
print(f"[{now_str()}] [scheduler] done {name} exit={exit_code} duration={duration_ms/1000:.1f}s", flush=True)
|
||||||
|
if output_tail:
|
||||||
|
print(output_tail, flush=True)
|
||||||
|
update_runtime(
|
||||||
|
name,
|
||||||
|
status=status,
|
||||||
|
pid=0,
|
||||||
|
run_kind=item.run_kind,
|
||||||
|
trigger_id=item.trigger_id,
|
||||||
|
locked_by="",
|
||||||
|
next_run_at=_next_run_iso(item.job.next_run),
|
||||||
|
last_finished_at=iso_now(),
|
||||||
|
last_exit_code=exit_code,
|
||||||
|
last_duration_ms=duration_ms,
|
||||||
|
last_error=err,
|
||||||
|
output_tail=output_tail,
|
||||||
|
)
|
||||||
|
if item.trigger_id:
|
||||||
|
update_manual_trigger(
|
||||||
|
item.trigger_id,
|
||||||
|
status="success" if exit_code == 0 else "error",
|
||||||
|
finished_at=iso_now(),
|
||||||
|
exit_code=exit_code,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
output_tail=output_tail,
|
||||||
|
error_message=err,
|
||||||
|
)
|
||||||
|
running.pop(name, None)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_manual_triggers(jobs: dict[str, Job], running: dict[str, RunningJob]) -> None:
|
||||||
|
for trigger in claim_manual_triggers(limit=10):
|
||||||
|
job = jobs.get(trigger.get("job_name"))
|
||||||
|
trigger_id = int(trigger.get("id") or 0)
|
||||||
|
if not job:
|
||||||
|
update_manual_trigger(trigger_id, status="error", error_message="unknown job", finished_at=iso_now())
|
||||||
|
continue
|
||||||
|
if not job.enabled and not trigger.get("force"):
|
||||||
|
update_manual_trigger(trigger_id, status="rejected", error_message="job disabled", finished_at=iso_now())
|
||||||
|
continue
|
||||||
|
if start_job(job, running, run_kind="manual", trigger_id=trigger_id):
|
||||||
|
continue
|
||||||
|
update_manual_trigger(trigger_id, status="pending")
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_due_jobs(jobs: dict[str, Job], running: dict[str, RunningJob]) -> None:
|
||||||
|
now = time.time()
|
||||||
|
for job in sorted(jobs.values(), key=lambda item: item.next_run):
|
||||||
|
if not job.enabled:
|
||||||
|
update_runtime(job.name, status="disabled", next_run_at="")
|
||||||
|
continue
|
||||||
|
if job.pending or now >= job.next_run:
|
||||||
|
if start_job(job, running, run_kind="auto"):
|
||||||
|
job.next_run = time.time() + job.every_seconds
|
||||||
|
update_runtime(job.name, next_run_at=_next_run_iso(job.next_run))
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
jobs = build_jobs()
|
init_db()
|
||||||
|
init_scheduler_tables()
|
||||||
base = time.time()
|
base = time.time()
|
||||||
for job in jobs:
|
jobs: dict[str, Job] = {}
|
||||||
job.next_run = base + job.initial_delay
|
running: dict[str, RunningJob] = {}
|
||||||
print(f"[{now_str()}] [scheduler] started jobs={len(jobs)} dry_run={DRY_RUN}", flush=True)
|
jobs = load_jobs(jobs, base, running)
|
||||||
|
last_reload = time.time()
|
||||||
|
print(f"[{now_str()}] [scheduler] started jobs={len(jobs)} dry_run={DRY_RUN} mode=concurrent", flush=True)
|
||||||
while True:
|
while True:
|
||||||
now = time.time()
|
finish_running_jobs(running)
|
||||||
due = [j for j in jobs if now >= j.next_run]
|
if time.time() - last_reload >= CONFIG_RELOAD_SECONDS:
|
||||||
if not due:
|
jobs = load_jobs(jobs, time.time(), running)
|
||||||
time.sleep(1)
|
last_reload = time.time()
|
||||||
continue
|
handle_manual_triggers(jobs, running)
|
||||||
# 串行执行;一个 job 跑完才跑下一个,避免 SQLite 写锁。
|
schedule_due_jobs(jobs, running)
|
||||||
for job in sorted(due, key=lambda j: j.next_run):
|
time.sleep(POLL_SECONDS)
|
||||||
run_job(job)
|
|
||||||
job.next_run = time.time() + job.every_seconds
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@ -405,11 +405,11 @@ event_driven:
|
|||||||
note: Solana meme主题扩散
|
note: Solana meme主题扩散
|
||||||
meta:
|
meta:
|
||||||
version: 1
|
version: 1
|
||||||
last_review: '2026-05-14T10:26:01.120951'
|
last_review: '2026-05-14T17:09:45.630655'
|
||||||
last_reverse_analysis: '2026-05-14T10:26:36.940507'
|
last_reverse_analysis: '2026-05-14T17:10:41.080069'
|
||||||
total_reviews: 28
|
total_reviews: 36
|
||||||
total_rules_learned: 37
|
total_rules_learned: 37
|
||||||
iteration_count: 33
|
iteration_count: 41
|
||||||
strategy_version: v1.7.11
|
strategy_version: v1.7.11
|
||||||
strategy_revision_started_at: '2026-05-09T01:20:00'
|
strategy_revision_started_at: '2026-05-09T01:20:00'
|
||||||
strategy_revision_note: 'v1.7.11: 触发时效治理,旧形态只作背景,消息触发显式标记'
|
strategy_revision_note: 'v1.7.11: 触发时效治理,旧形态只作背景,消息触发显式标记'
|
||||||
|
|||||||
@ -8,6 +8,8 @@
|
|||||||
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||||
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
||||||
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/cron" style="display:none"><svg class="link-icon"><use href="#svg-cron"/></svg>调度中心</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/llm-insights" style="display:none"><svg class="link-icon"><use href="#svg-ai"/></svg>AI 记录</a>
|
||||||
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||||
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||||
<a class="sidebar-link active admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
<a class="sidebar-link active admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||||
|
|||||||
@ -155,6 +155,19 @@
|
|||||||
.decision-strip.observe .decision-title { color: var(--blue); }
|
.decision-strip.observe .decision-title { color: var(--blue); }
|
||||||
.decision-strip.weak .decision-title { color: var(--muted); }
|
.decision-strip.weak .decision-title { color: var(--muted); }
|
||||||
|
|
||||||
|
.ai-insight { margin: 0 18px 8px; border: 1px solid var(--hairline-soft); border-radius: var(--radius-lg); background: var(--surface); overflow: hidden; }
|
||||||
|
.ai-insight summary { list-style: none; cursor: pointer; padding: 8px 10px; display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 11px; font-weight: 900; color: var(--ink); }
|
||||||
|
.ai-insight summary::-webkit-details-marker { display: none; }
|
||||||
|
.ai-insight .ai-tag { font-size: 10px; color: var(--blue); background: rgba(66,98,255,.08); border-radius: 999px; padding: 2px 8px; white-space: nowrap; }
|
||||||
|
.ai-insight .ai-body { border-top: 1px solid var(--hairline-soft); padding: 8px 10px 10px; display: grid; gap: 8px; }
|
||||||
|
.ai-insight .ai-summary { color: var(--slate); font-size: 12px; line-height: 1.5; }
|
||||||
|
.ai-insight .ai-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; }
|
||||||
|
.ai-insight .ai-item { border: 1px solid var(--hairline-soft); border-radius: 10px; background: var(--canvas); padding: 6px 8px; min-width: 0; }
|
||||||
|
.ai-insight .ai-label { color: var(--stone); font-size: 10px; font-weight: 800; }
|
||||||
|
.ai-insight .ai-text { color: var(--ink); font-size: 12px; line-height: 1.45; margin-top: 3px; word-break: break-word; }
|
||||||
|
.ai-insight .ai-list { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||||
|
.ai-insight .ai-pill { display: inline-flex; padding: 4px 7px; border-radius: 999px; font-size: 11px; color: var(--slate); background: var(--canvas); border: 1px solid var(--hairline-soft); }
|
||||||
|
|
||||||
/* ===== K-LINE ===== */
|
/* ===== K-LINE ===== */
|
||||||
.kline-wrap { padding: 0 8px 4px; }
|
.kline-wrap { padding: 0 8px 4px; }
|
||||||
.kline-int-bar { display: flex; gap: 2px; padding: 0 10px 6px; }
|
.kline-int-bar { display: flex; gap: 2px; padding: 0 10px 6px; }
|
||||||
@ -728,6 +741,39 @@ function renderRecCard(r) {
|
|||||||
var decisionFocus = isBuy ? ('现价 '+fmtP(price)) : (isWait ? ('等 '+fmtP(entryRef)) : (isWeakObserve ? '低优先级观察' : '等待确认'));
|
var decisionFocus = isBuy ? ('现价 '+fmtP(price)) : (isWait ? ('等 '+fmtP(entryRef)) : (isWeakObserve ? '低优先级观察' : '等待确认'));
|
||||||
var decisionReason = cleanDisplayText(isBuy ? (entryWindowSummary() || '入场窗口有效') : (isWait ? '现价不追,等回踩价附近再评估' : (r.observe_reason || r.state_reason || '未形成入场窗口')));
|
var decisionReason = cleanDisplayText(isBuy ? (entryWindowSummary() || '入场窗口有效') : (isWait ? '现价不追,等回踩价附近再评估' : (r.observe_reason || r.state_reason || '未形成入场窗口')));
|
||||||
var decisionHtml = '<div class="decision-strip '+decisionCls+'"><div class="decision-head"><span class="decision-label">最终建议</span><span class="decision-title">'+decisionTitle+'</span></div><div class="decision-body"><span class="decision-focus">'+decisionFocus+'</span><span class="decision-reason">'+decisionReason+'</span></div></div>';
|
var decisionHtml = '<div class="decision-strip '+decisionCls+'"><div class="decision-head"><span class="decision-label">最终建议</span><span class="decision-title">'+decisionTitle+'</span></div><div class="decision-body"><span class="decision-focus">'+decisionFocus+'</span><span class="decision-reason">'+decisionReason+'</span></div></div>';
|
||||||
|
var aiInsightHtml = '';
|
||||||
|
var aiInsight = r.llm_insight && r.llm_insight.content ? r.llm_insight.content : null;
|
||||||
|
function hasAiText(v) {
|
||||||
|
if (Array.isArray(v)) return v.some(function(x){ return cleanDisplayText(x).replace(/^-+$/,'').trim(); });
|
||||||
|
return !!cleanDisplayText(v).replace(/^-+$/,'').trim();
|
||||||
|
}
|
||||||
|
if (aiInsight && (
|
||||||
|
hasAiText(aiInsight.summary) ||
|
||||||
|
hasAiText(aiInsight.why_now_or_not) ||
|
||||||
|
hasAiText(aiInsight.key_evidence) ||
|
||||||
|
hasAiText(aiInsight.risk_flags) ||
|
||||||
|
hasAiText(aiInsight.watch_points) ||
|
||||||
|
hasAiText(aiInsight.invalid_if)
|
||||||
|
)) {
|
||||||
|
var evidenceHtml = (aiInsight.key_evidence || []).slice(0, 4).map(function(x){ return '<span class="ai-pill">'+cleanDisplayText(x)+'</span>'; }).join('');
|
||||||
|
var riskHtml = (aiInsight.risk_flags || []).slice(0, 4).map(function(x){ return '<span class="ai-pill">'+cleanDisplayText(x)+'</span>'; }).join('');
|
||||||
|
var watchHtml = (aiInsight.watch_points || []).slice(0, 4).map(function(x){ return '<span class="ai-pill">'+cleanDisplayText(x)+'</span>'; }).join('');
|
||||||
|
var invalidHtml = (aiInsight.invalid_if || []).slice(0, 4).map(function(x){ return '<span class="ai-pill">'+cleanDisplayText(x)+'</span>'; }).join('');
|
||||||
|
aiInsightHtml =
|
||||||
|
'<details class="ai-insight">'+
|
||||||
|
'<summary><span>AI 解读</span><span class="ai-tag">缓存</span></summary>'+
|
||||||
|
'<div class="ai-body">'+
|
||||||
|
'<div class="ai-summary">'+cleanDisplayText(aiInsight.summary || aiInsight.why_now_or_not || '暂无摘要')+'</div>'+
|
||||||
|
'<div class="ai-grid">'+
|
||||||
|
'<div class="ai-item"><div class="ai-label">为什么现在 / 为什么不现在</div><div class="ai-text">'+cleanDisplayText(aiInsight.why_now_or_not || '--')+'</div></div>'+
|
||||||
|
'<div class="ai-item"><div class="ai-label">关键证据</div><div class="ai-list">'+(evidenceHtml || '<span class="ai-pill">--</span>')+'</div></div>'+
|
||||||
|
'<div class="ai-item"><div class="ai-label">风险提示</div><div class="ai-list">'+(riskHtml || '<span class="ai-pill">--</span>')+'</div></div>'+
|
||||||
|
'<div class="ai-item"><div class="ai-label">观察点</div><div class="ai-list">'+(watchHtml || '<span class="ai-pill">--</span>')+'</div></div>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div class="ai-item"><div class="ai-label">失效条件</div><div class="ai-list">'+(invalidHtml || '<span class="ai-pill">--</span>')+'</div></div>'+
|
||||||
|
'</div>'+
|
||||||
|
'</details>';
|
||||||
|
}
|
||||||
var entryPlanHtml = '';
|
var entryPlanHtml = '';
|
||||||
if (isTradePlan) {
|
if (isTradePlan) {
|
||||||
entryPlanHtml = '<div class="entry-plan">' +
|
entryPlanHtml = '<div class="entry-plan">' +
|
||||||
@ -747,6 +793,7 @@ function renderRecCard(r) {
|
|||||||
return '<div class="card '+(isWeakObserve?'weak-observe':'')+'"><div class="card-bar"><div class="coin-left"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+base+'</span></div></div><div class="badge-group">'+actionBadge+'<span class="score-badge '+st.cls+'"><span class="score-num">'+score+'</span><span class="score-label">'+st.label+'</span></span></div></div>'+
|
return '<div class="card '+(isWeakObserve?'weak-observe':'')+'"><div class="card-bar"><div class="coin-left"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+base+'</span></div></div><div class="badge-group">'+actionBadge+'<span class="score-badge '+st.cls+'"><span class="score-num">'+score+'</span><span class="score-label">'+st.label+'</span></span></div></div>'+
|
||||||
'<div class="price-bar"><span class="price">$'+priceFmt+'</span>'+changeHtml+'</div>'+
|
'<div class="price-bar"><span class="price">$'+priceFmt+'</span>'+changeHtml+'</div>'+
|
||||||
decisionHtml+
|
decisionHtml+
|
||||||
|
aiInsightHtml+
|
||||||
'<div class="kline-wrap"><div class="kline-int-bar"><button class="kline-int-btn" data-int="15m" onclick="switchKlineInterval(this);event.stopPropagation()">15m</button><button class="kline-int-btn active" data-int="1h" onclick="switchKlineInterval(this);event.stopPropagation()">1H</button><button class="kline-int-btn" data-int="4h" onclick="switchKlineInterval(this);event.stopPropagation()">4H</button><button class="kline-int-btn" data-int="1d" onclick="switchKlineInterval(this);event.stopPropagation()">1D</button></div><div class="kline-container loading" data-symbol="'+r.symbol+'" data-entry-price="'+klineEntryPrice+'" data-stop-loss="'+klineStopLoss+'" data-tp1="'+klineTp1+'" data-rec-time="'+entryTime+'" data-tp1-time="'+tp1EventTime+'" data-sl-time="'+slEventTime+'" data-ref-price="'+price+'" data-action-status="'+(r.action_status||'')+'"><div class="chart-loading"><svg class="spin" width="16" height="16" color="#8e91a0"><use href="#svg-spinner"/></svg></div></div></div>'+
|
'<div class="kline-wrap"><div class="kline-int-bar"><button class="kline-int-btn" data-int="15m" onclick="switchKlineInterval(this);event.stopPropagation()">15m</button><button class="kline-int-btn active" data-int="1h" onclick="switchKlineInterval(this);event.stopPropagation()">1H</button><button class="kline-int-btn" data-int="4h" onclick="switchKlineInterval(this);event.stopPropagation()">4H</button><button class="kline-int-btn" data-int="1d" onclick="switchKlineInterval(this);event.stopPropagation()">1D</button></div><div class="kline-container loading" data-symbol="'+r.symbol+'" data-entry-price="'+klineEntryPrice+'" data-stop-loss="'+klineStopLoss+'" data-tp1="'+klineTp1+'" data-rec-time="'+entryTime+'" data-tp1-time="'+tp1EventTime+'" data-sl-time="'+slEventTime+'" data-ref-price="'+price+'" data-action-status="'+(r.action_status||'')+'"><div class="chart-loading"><svg class="spin" width="16" height="16" color="#8e91a0"><use href="#svg-spinner"/></svg></div></div></div>'+
|
||||||
(isWeakObserve ? weakNoteHtml : entryPlanHtml)+
|
(isWeakObserve ? weakNoteHtml : entryPlanHtml)+
|
||||||
(sigHtml?'<div class="signals-row">'+sigHtml+'</div>':'')+
|
(sigHtml?'<div class="signals-row">'+sigHtml+'</div>':'')+
|
||||||
|
|||||||
@ -153,6 +153,8 @@ a { color: inherit; text-decoration: none; }
|
|||||||
<symbol id="svg-spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" stroke-dasharray="31.4 31.4" stroke-linecap="round"/></symbol>
|
<symbol id="svg-spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" stroke-dasharray="31.4 31.4" stroke-linecap="round"/></symbol>
|
||||||
<symbol id="svg-dashboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></symbol>
|
<symbol id="svg-dashboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></symbol>
|
||||||
<symbol id="svg-pipeline" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="5" cy="6" r="2"/><circle cx="19" cy="6" r="2"/><circle cx="12" cy="18" r="2"/><path d="M7 6h10"/><path d="M6.5 7.7 11 16"/><path d="M17.5 7.7 13 16"/></symbol>
|
<symbol id="svg-pipeline" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="5" cy="6" r="2"/><circle cx="19" cy="6" r="2"/><circle cx="12" cy="18" r="2"/><path d="M7 6h10"/><path d="M6.5 7.7 11 16"/><path d="M17.5 7.7 13 16"/></symbol>
|
||||||
|
<symbol id="svg-cron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/><path d="M4 4l3 3"/><path d="M20 4l-3 3"/></symbol>
|
||||||
|
<symbol id="svg-ai" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="5" width="14" height="14" rx="3"/><path d="M9 1v4"/><path d="M15 1v4"/><path d="M9 19v4"/><path d="M15 19v4"/><path d="M1 9h4"/><path d="M1 15h4"/><path d="M19 9h4"/><path d="M19 15h4"/><path d="M9 15l1.5-6h3L15 15"/><path d="M10 13h4"/></symbol>
|
||||||
<symbol id="svg-iterate" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></symbol>
|
<symbol id="svg-iterate" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></symbol>
|
||||||
<symbol id="svg-sentiment" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14z"/><path d="M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></symbol>
|
<symbol id="svg-sentiment" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14z"/><path d="M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></symbol>
|
||||||
<symbol id="svg-subscribe" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></symbol>
|
<symbol id="svg-subscribe" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></symbol>
|
||||||
@ -175,6 +177,8 @@ a { color: inherit; text-decoration: none; }
|
|||||||
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||||
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
||||||
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/cron" style="display:none"><svg class="link-icon"><use href="#svg-cron"/></svg>调度中心</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/llm-insights" style="display:none"><svg class="link-icon"><use href="#svg-ai"/></svg>AI 记录</a>
|
||||||
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||||
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||||
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||||
|
|||||||
61
static/cron.html
Normal file
61
static/cron.html
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}AlphaX Agent | Crypto — 调度中心{% endblock %}
|
||||||
|
{% block nav_links %}
|
||||||
|
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||||
|
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||||
|
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||||
|
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||||
|
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
||||||
|
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
||||||
|
<a class="sidebar-link active admin-link" href="/cron" style="display:none"><svg class="link-icon"><use href="#svg-cron"/></svg>调度中心</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/llm-insights" style="display:none"><svg class="link-icon"><use href="#svg-ai"/></svg>AI 记录</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||||
|
{% endblock %}
|
||||||
|
{% block extra_head_css %}
|
||||||
|
<style>
|
||||||
|
.shell{width:min(100% - 40px,1280px);margin:0 auto;padding:24px 0 44px}.page-head{display:flex;justify-content:space-between;align-items:flex-end;gap:14px;flex-wrap:wrap;margin-bottom:16px}.page-head h1{font-size:26px;font-weight:900;color:var(--ink)}.page-head p{font-size:13px;color:var(--stone);margin-top:4px}.actions{display:flex;gap:8px;align-items:center}.btn{height:36px;border:1px solid var(--hairline-strong);background:var(--canvas);border-radius:var(--radius-md);padding:0 12px;font-size:12px;font-weight:900;color:var(--ink);cursor:pointer}.btn.primary{background:var(--primary);border-color:var(--primary);color:var(--on-primary)}.btn.warn{color:var(--red)}.btn:disabled{opacity:.45;cursor:default}.grid{display:grid;grid-template-columns:1fr;gap:14px}.panel{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-md);overflow:hidden}.panel-head{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:13px 14px;border-bottom:1px solid var(--hairline-soft)}.panel-title{font-size:14px;font-weight:900;color:var(--ink)}.panel-note{font-size:11px;font-weight:800;color:var(--stone)}.job-table{width:100%;border-collapse:collapse;min-width:1040px}.job-table th,.job-table td{padding:11px 10px;border-bottom:1px solid var(--hairline-soft);text-align:left;font-size:12px;vertical-align:middle}.job-table th{font-size:11px;color:var(--stone);font-weight:900;background:var(--surface)}.job-table tr:last-child td{border-bottom:0}.table-wrap{overflow:auto}.job-name{font-weight:900;color:var(--ink);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.desc{color:var(--stone);font-size:11px;margin-top:3px}.badge{display:inline-flex;align-items:center;height:24px;border-radius:999px;padding:0 8px;font-size:11px;font-weight:900;border:1px solid var(--hairline-soft);background:var(--surface);color:var(--slate);white-space:nowrap}.badge.ok{background:var(--green-light);border-color:rgba(0,180,115,.18);color:var(--green)}.badge.err{background:var(--red-light);border-color:rgba(229,62,62,.18);color:var(--red)}.badge.run{background:rgba(66,98,255,.08);border-color:rgba(66,98,255,.18);color:var(--blue)}.badge.wait{background:rgba(245,158,11,.1);border-color:rgba(245,158,11,.22);color:#b7791f}.interval{width:82px;height:34px;border:1px solid var(--hairline-strong);border-radius:var(--radius-md);padding:0 8px;font-size:12px;font-weight:800;background:var(--canvas);color:var(--ink)}.mini{font-size:11px;color:var(--stone);line-height:1.5}.switch{display:inline-flex;align-items:center;gap:7px}.switch input{width:34px;height:20px;appearance:none;border-radius:999px;background:var(--hairline);position:relative;cursor:pointer}.switch input:checked{background:var(--green)}.switch input:before{content:"";position:absolute;top:3px;left:3px;width:14px;height:14px;border-radius:50%;background:white;transition:.15s}.switch input:checked:before{left:17px}.trigger-list{display:grid;gap:8px;padding:12px}.trigger{display:grid;grid-template-columns:140px 90px 1fr auto;gap:10px;align-items:center;border:1px solid var(--hairline-soft);background:var(--surface);border-radius:var(--radius-md);padding:10px}.out{color:var(--slate);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.empty,.loading{padding:28px 16px;text-align:center;color:var(--stone);font-size:13px}.toast{position:fixed;right:18px;bottom:18px;background:var(--ink);color:white;border-radius:var(--radius-md);padding:10px 12px;font-size:12px;font-weight:800;opacity:0;transform:translateY(8px);transition:.18s;z-index:20}.toast.show{opacity:1;transform:translateY(0)}@media(max-width:760px){.shell{width:min(100% - 24px,1280px)}.trigger{grid-template-columns:1fr}.actions{width:100%;justify-content:flex-start}}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="shell">
|
||||||
|
<div class="page-head">
|
||||||
|
<div><h1>调度中心</h1><p>查看 Docker scheduler 当前状态,控制任务启停、周期与手动触发。</p></div>
|
||||||
|
<div class="actions"><button class="btn" onclick="loadAll()">刷新</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head"><div class="panel-title">任务</div><div class="panel-note" id="updatedAt">--</div></div>
|
||||||
|
<div class="table-wrap"><table class="job-table"><thead><tr><th>任务</th><th>状态</th><th>启用</th><th>周期</th><th>下次运行</th><th>最近结果</th><th>运行信息</th><th>操作</th></tr></thead><tbody id="jobRows"><tr><td colspan="8" class="loading">加载中...</td></tr></tbody></table></div>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head"><div class="panel-title">手动触发记录</div><div class="panel-note">最近 30 条</div></div>
|
||||||
|
<div id="triggerRows" class="trigger-list"><div class="loading">加载中...</div></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block extra_script %}
|
||||||
|
<script>
|
||||||
|
function $(id){return document.getElementById(id)}
|
||||||
|
function esc(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];});}
|
||||||
|
function fmtTime(t){if(!t)return'--';var d=new Date(t);if(isNaN(d.getTime()))return t;return (d.getMonth()+1)+'/'+d.getDate()+' '+String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0')+':'+String(d.getSeconds()).padStart(2,'0');}
|
||||||
|
function fmtDur(ms){ms=Number(ms||0);if(ms>=60000)return Math.round(ms/60000)+'m';if(ms>=1000)return (ms/1000).toFixed(1)+'s';return ms?ms+'ms':'--';}
|
||||||
|
function statusBadge(s){var cls=s==='running'?'run':s==='pending'?'wait':s==='disabled'?'err':'ok';var map={running:'运行中',pending:'等待锁',disabled:'已关闭',idle:'空闲',success:'成功',error:'失败',queued:'排队'};return '<span class="badge '+cls+'">'+esc(map[s]||s||'未知')+'</span>'}
|
||||||
|
function showToast(msg){var t=$('toast');t.textContent=msg;t.classList.add('show');setTimeout(function(){t.classList.remove('show')},1800)}
|
||||||
|
async function api(url,opts){var r=await fetch(url,opts||{});var d=await r.json().catch(function(){return {}});if(!r.ok)throw new Error(d.detail||d.error||'请求失败');return d}
|
||||||
|
async function loadAll(){await Promise.all([loadJobs(),loadTriggers()])}
|
||||||
|
async function loadJobs(){try{var d=await api('/api/scheduler/jobs');$('updatedAt').textContent='更新 '+fmtTime(d.updated_at);var rows=d.jobs||[];$('jobRows').innerHTML=rows.map(renderJob).join('')||'<tr><td colspan="8" class="empty">暂无任务</td></tr>'}catch(e){$('jobRows').innerHTML='<tr><td colspan="8" class="empty">'+esc(e.message)+'</td></tr>'}}
|
||||||
|
function renderJob(j){var rt=j.runtime||{},last=j.latest_cron||{};var st=rt.status||(j.enabled?'idle':'disabled');var result=last.run_status?statusBadge(last.run_status)+ '<div class="mini">'+esc(last.result_status||'')+' · '+fmtDur(last.duration_ms)+'</div>':'<span class="mini">暂无 cron 结果</span>';var running='<div class="mini">PID '+esc(rt.pid||'--')+' · '+esc(rt.locked_by||j.lock_group||'--')+'</div><div class="mini">'+esc(rt.last_error||'')+'</div>';var name=esc(j.job_name);return '<tr data-job="'+name+'"><td><div class="job-name">'+name+'</div><div class="desc">'+esc(j.description||j.command)+'</div></td><td>'+statusBadge(st)+'</td><td><label class="switch"><input class="job-enabled" data-job="'+name+'" type="checkbox" '+(j.enabled?'checked':'')+'><span>'+(j.enabled?'开':'关')+'</span></label></td><td><input class="interval job-interval" data-job="'+name+'" type="number" min="30" value="'+esc(j.every_seconds)+'"> 秒<br><button class="btn" data-action="save-interval" data-job="'+name+'">保存</button></td><td>'+fmtTime(rt.next_run_at)+'</td><td>'+result+'</td><td>'+running+'</td><td><button class="btn primary" data-action="trigger" data-job="'+name+'" data-enabled="'+(j.enabled?'1':'0')+'">立即运行</button></td></tr>'}
|
||||||
|
async function toggleJob(name,en){try{await api('/api/scheduler/jobs/'+encodeURIComponent(name)+'/toggle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:en})});showToast('已更新 '+name);loadJobs()}catch(e){showToast(e.message);loadJobs()}}
|
||||||
|
function getIntervalInput(name){var inputs=document.querySelectorAll('.job-interval');for(var i=0;i<inputs.length;i++){if(inputs[i].dataset.job===name)return inputs[i]}return null}
|
||||||
|
async function saveInterval(name){var input=getIntervalInput(name);var v=Number((input&&input.value)||0);try{await api('/api/scheduler/jobs/'+encodeURIComponent(name)+'/interval',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({every_seconds:v})});showToast('周期已保存');loadJobs()}catch(e){showToast(e.message)}}
|
||||||
|
async function triggerJob(name,enabled){var force=false;if(!enabled){force=confirm('任务当前已关闭,仍要单次运行 '+name+' 吗?');if(!force)return}try{var d=await api('/api/scheduler/jobs/'+encodeURIComponent(name)+'/trigger',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({force:force})});showToast('已入队 #'+d.trigger_id);loadAll()}catch(e){showToast(e.message)}}
|
||||||
|
async function loadTriggers(){try{var d=await api('/api/scheduler/triggers?limit=30');var rows=d.items||[];$('triggerRows').innerHTML=rows.map(function(x){return '<div class="trigger"><div><b class="job-name">'+esc(x.job_name)+'</b><div class="mini">'+fmtTime(x.requested_at)+'</div></div><div>'+statusBadge(x.status)+'</div><div class="out">'+esc(x.error_message||x.output_tail||'--')+'</div><div class="mini">'+fmtDur(x.duration_ms)+'</div></div>'}).join('')||'<div class="empty">暂无手动触发</div>'}catch(e){$('triggerRows').innerHTML='<div class="empty">'+esc(e.message)+'</div>'}}
|
||||||
|
$('jobRows').addEventListener('change',function(e){if(e.target&&e.target.classList.contains('job-enabled'))toggleJob(e.target.dataset.job,e.target.checked)})
|
||||||
|
$('jobRows').addEventListener('click',function(e){var btn=e.target.closest('[data-action]');if(!btn)return;if(btn.dataset.action==='save-interval')saveInterval(btn.dataset.job);if(btn.dataset.action==='trigger')triggerJob(btn.dataset.job,btn.dataset.enabled==='1')})
|
||||||
|
loadAll();setInterval(loadAll,5000);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -7,6 +7,8 @@
|
|||||||
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||||
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
||||||
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/cron" style="display:none"><svg class="link-icon"><use href="#svg-cron"/></svg>调度中心</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/llm-insights" style="display:none"><svg class="link-icon"><use href="#svg-ai"/></svg>AI 记录</a>
|
||||||
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||||
<a class="sidebar-link active admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
<a class="sidebar-link active admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||||
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||||
@ -96,6 +98,9 @@ h2 { font-size:26px; font-weight:900; margin:0 0 8px; color:var(--ink); }
|
|||||||
.rule-quality.good { background:var(--green-light); color:var(--green); }
|
.rule-quality.good { background:var(--green-light); color:var(--green); }
|
||||||
.rule-quality.wait { background:var(--yellow-light); color:var(--yellow-dark); }
|
.rule-quality.wait { background:var(--yellow-light); color:var(--yellow-dark); }
|
||||||
.rule-quality.bad { background:var(--red-light); color:var(--red); }
|
.rule-quality.bad { background:var(--red-light); color:var(--red); }
|
||||||
|
.ai-memo { margin-top:12px; border:1px solid var(--hairline-soft); border-radius:18px; background:var(--surface); padding:14px; }
|
||||||
|
.ai-memo .label { color:var(--stone); font-size:11px; font-weight:900; margin-bottom:6px; }
|
||||||
|
.ai-memo .text { color:var(--slate); font-size:13px; line-height:1.7; }
|
||||||
@media(max-width:860px){ .summary-report{grid-template-columns:1fr;} }
|
@media(max-width:860px){ .summary-report{grid-template-columns:1fr;} }
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@ -160,6 +165,7 @@ function renderUserReport(d){
|
|||||||
var ov=d.overview||{}, dry=d.dry_run||{}, ds=ov.dry_run_summary||{};
|
var ov=d.overview||{}, dry=d.dry_run||{}, ds=ov.dry_run_summary||{};
|
||||||
var decision=ov.latest_release_decision || (dry.would_bump_version?'release':'hold');
|
var decision=ov.latest_release_decision || (dry.would_bump_version?'release':'hold');
|
||||||
var reason=ov.latest_release_reason || dry.release_reason || '样本仍在积累,暂不改变线上策略。';
|
var reason=ov.latest_release_reason || dry.release_reason || '样本仍在积累,暂不改变线上策略。';
|
||||||
|
var aiMemo = d.llm_review_memo && d.llm_review_memo.content ? d.llm_review_memo.content : null;
|
||||||
var candidates=(d.candidates||[]).slice(0,3);
|
var candidates=(d.candidates||[]).slice(0,3);
|
||||||
var failures=((ov.failure_type_counts)||[]).slice(0,3);
|
var failures=((ov.failure_type_counts)||[]).slice(0,3);
|
||||||
var candHtml=candidates.length?candidates.map(function(c){
|
var candHtml=candidates.length?candidates.map(function(c){
|
||||||
@ -170,7 +176,8 @@ function renderUserReport(d){
|
|||||||
return '<div class="report-item"><b>'+esc(name)+'</b><span><span class="rule-quality '+q+'">'+qtxt+'</span> 样本 '+esc(c.sample_size||0)+' · 置信 '+esc(c.confidence_score||0)+' · 平均表现 '+esc(c.avg_pnl||0)+'</span></div>';
|
return '<div class="report-item"><b>'+esc(name)+'</b><span><span class="rule-quality '+q+'">'+qtxt+'</span> 样本 '+esc(c.sample_size||0)+' · 置信 '+esc(c.confidence_score||0)+' · 平均表现 '+esc(c.avg_pnl||0)+'</span></div>';
|
||||||
}).join(''):'<div class="report-item"><b>暂无新规律</b><span>当前没有足够证据支持策略改动。</span></div>';
|
}).join(''):'<div class="report-item"><b>暂无新规律</b><span>当前没有足够证据支持策略改动。</span></div>';
|
||||||
var failHtml=failures.length?failures.map(function(f){return '<div class="report-item"><b>'+esc(f.type||'失败模式')+'</b><span>出现 '+esc(f.count||0)+' 次,后续复盘会重点观察是否重复发生。</span></div>';}).join(''):'<div class="report-item"><b>暂无集中失败模式</b><span>当前失败样本不足,先继续观察。</span></div>';
|
var failHtml=failures.length?failures.map(function(f){return '<div class="report-item"><b>'+esc(f.type||'失败模式')+'</b><span>出现 '+esc(f.count||0)+' 次,后续复盘会重点观察是否重复发生。</span></div>';}).join(''):'<div class="report-item"><b>暂无集中失败模式</b><span>当前失败样本不足,先继续观察。</span></div>';
|
||||||
$('userReport').innerHTML = '<div class="report-card"><div class="report-title">本轮策略结论 '+badge(decision)+'</div><div class="report-answer '+decisionClass(decision)+'">'+decisionText(decision)+'</div><div class="report-text">'+esc(reason)+'</div><div class="report-list">'+candHtml+'</div></div>' +
|
var aiHtml = aiMemo ? '<div class="ai-memo"><div class="label">AI 复盘摘要</div><div class="text">'+esc(aiMemo.summary || aiMemo.memo || aiMemo.why_it_matters || '暂无摘要')+'</div></div>' : '';
|
||||||
|
$('userReport').innerHTML = '<div class="report-card"><div class="report-title">本轮策略结论 '+badge(decision)+'</div><div class="report-answer '+decisionClass(decision)+'">'+decisionText(decision)+'</div><div class="report-text">'+esc(reason)+'</div>'+aiHtml+'<div class="report-list">'+candHtml+'</div></div>' +
|
||||||
'<div class="report-card"><div class="report-title">最近最该关注的错误</div><div class="report-text">系统不只看成功因子,也会记录反复导致失败的原因,避免下一轮继续犯同样的错。</div><div class="report-list">'+failHtml+'</div></div>';
|
'<div class="report-card"><div class="report-title">最近最该关注的错误</div><div class="report-text">系统不只看成功因子,也会记录反复导致失败的原因,避免下一轮继续犯同样的错。</div><div class="report-list">'+failHtml+'</div></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
77
static/llm_insights.html
Normal file
77
static/llm_insights.html
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}AlphaX Agent | Crypto — AI 记录{% endblock %}
|
||||||
|
{% block nav_links %}
|
||||||
|
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||||
|
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||||
|
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||||
|
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||||
|
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
||||||
|
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/cron" style="display:none"><svg class="link-icon"><use href="#svg-cron"/></svg>调度中心</a>
|
||||||
|
<a class="sidebar-link active admin-link" href="/llm-insights" style="display:none"><svg class="link-icon"><use href="#svg-ai"/></svg>AI 记录</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||||
|
{% endblock %}
|
||||||
|
{% block extra_head_css %}
|
||||||
|
<style>
|
||||||
|
.shell{width:min(100% - 40px,1280px);margin:0 auto;padding:24px 0 44px}.page-head{display:flex;justify-content:space-between;align-items:flex-end;gap:14px;margin-bottom:16px;flex-wrap:wrap}.page-head h1{font-size:26px;font-weight:900;letter-spacing:-.6px;color:var(--ink)}.page-head p{margin-top:4px;color:var(--stone);font-size:13px;line-height:1.6}.controls{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.select,.btn{height:38px;border:1px solid var(--hairline-strong);background:var(--canvas);border-radius:var(--radius-md);padding:0 12px;font-size:13px;font-weight:800;color:var(--ink)}.btn{cursor:pointer}.layout{display:grid;grid-template-columns:430px minmax(0,1fr);gap:14px;align-items:start}.panel{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-md);min-width:0;overflow:hidden}.panel-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:13px 14px;border-bottom:1px solid var(--hairline-soft)}.panel-title{font-size:14px;font-weight:900;color:var(--ink)}.panel-note{font-size:11px;color:var(--stone);font-weight:800}.list{max-height:calc(100vh - 178px);overflow:auto}.row{padding:13px 14px;border-bottom:1px solid var(--hairline-soft);cursor:pointer;transition:.12s;background:var(--canvas)}.row:hover{background:var(--surface)}.row.active{background:rgba(66,98,255,.06);box-shadow:inset 3px 0 0 var(--blue)}.row-top{display:flex;align-items:center;justify-content:space-between;gap:8px}.type{font-size:12px;font-weight:900;color:var(--blue);background:rgba(66,98,255,.08);border-radius:999px;padding:4px 8px;white-space:nowrap}.type.failed{color:var(--red);background:var(--red-light)}.type.skipped{color:var(--stone);background:var(--surface);border:1px solid var(--hairline-soft)}.subject{margin-top:8px;font-size:14px;font-weight:900;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.summary{margin-top:5px;color:var(--slate);font-size:12px;line-height:1.55;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.meta{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:8px;color:var(--stone);font-size:11px;font-weight:800}.detail{min-height:560px}.detail-body{padding:16px}.hero-card{border:1px solid var(--hairline-soft);border-radius:var(--radius-md);background:var(--surface);padding:15px;margin-bottom:12px}.hero-title{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.hero-title b{font-size:18px;color:var(--ink)}.badge{display:inline-flex;border-radius:999px;padding:4px 9px;font-size:11px;font-weight:900;background:var(--canvas);border:1px solid var(--hairline-soft);color:var(--slate)}.badge.ok{color:var(--green);background:var(--green-light);border-color:rgba(0,180,115,.18)}.badge.fail{color:var(--red);background:var(--red-light);border-color:rgba(229,62,62,.18)}.plain{margin-top:10px;color:var(--slate);font-size:13px;line-height:1.7}.cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-bottom:12px}.mini{border:1px solid var(--hairline-soft);border-radius:var(--radius-md);background:var(--surface);padding:11px;min-width:0}.mini span{display:block;color:var(--stone);font-size:10px;font-weight:900}.mini b{display:block;margin-top:4px;color:var(--ink);font-size:13px;word-break:break-word}.section{border:1px solid var(--hairline-soft);border-radius:var(--radius-md);background:var(--canvas);margin-bottom:12px;overflow:hidden}.section h3{font-size:13px;font-weight:900;color:var(--ink);padding:12px 13px;border-bottom:1px solid var(--hairline-soft);background:var(--surface)}.kv{display:grid;gap:8px;padding:12px}.kv-row{display:grid;grid-template-columns:140px minmax(0,1fr);gap:10px;font-size:12px}.kv-row label{color:var(--stone);font-weight:900}.kv-row div{color:var(--slate);line-height:1.55;word-break:break-word}.chips{display:flex;gap:5px;flex-wrap:wrap}.chip{display:inline-flex;border:1px solid var(--hairline-soft);background:var(--surface);border-radius:999px;padding:4px 8px;font-size:11px;font-weight:800;color:var(--slate)}.json-box{max-height:320px;overflow:auto;background:#101423;color:#dce6ff;border-radius:var(--radius-md);padding:12px;font-size:12px;line-height:1.55;white-space:pre-wrap;word-break:break-word}.empty,.loading{padding:34px 20px;text-align:center;color:var(--stone);font-size:13px}.pager{display:flex;gap:8px;align-items:center}.page-btn{height:34px;border:1px solid var(--hairline-strong);border-radius:var(--radius-md);background:var(--canvas);font-size:12px;font-weight:900;color:var(--ink);padding:0 10px;cursor:pointer}.page-btn:disabled{opacity:.45;cursor:default}@media(max-width:980px){.layout{grid-template-columns:1fr}.list{max-height:none}.cards{grid-template-columns:1fr}}@media(max-width:560px){.shell{width:min(100% - 24px,1280px);padding-top:18px}.kv-row{grid-template-columns:1fr}.page-head h1{font-size:22px}}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="shell">
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>AI 记录</h1>
|
||||||
|
<p>查看每一次 LLM 解释任务:它看了什么结构化输入、调用了哪个模型、产出了什么解释,以及是否失败。</p>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<select class="select" id="typeSel" onchange="reloadFirst()">
|
||||||
|
<option value="">全部类型</option>
|
||||||
|
<option value="recommendation">推荐解释</option>
|
||||||
|
<option value="sentiment">舆情解读</option>
|
||||||
|
<option value="review">复盘 memo</option>
|
||||||
|
</select>
|
||||||
|
<select class="select" id="statusSel" onchange="reloadFirst()">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="success">成功</option>
|
||||||
|
<option value="failed">失败</option>
|
||||||
|
<option value="skipped">跳过</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn" onclick="reloadFirst()">刷新</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layout">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div class="panel-title">调用记录</div>
|
||||||
|
<div class="pager"><button class="page-btn" id="prevBtn" onclick="page(-1)">上一页</button><button class="page-btn" id="nextBtn" onclick="page(1)">下一页</button><span class="panel-note" id="countText">--</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="list" id="list"><div class="loading">加载中...</div></div>
|
||||||
|
</section>
|
||||||
|
<section class="panel detail">
|
||||||
|
<div class="panel-head"><div class="panel-title">这次 AI 做了什么</div><div class="panel-note" id="detailTime">--</div></div>
|
||||||
|
<div class="detail-body" id="detail"><div class="empty">选择左侧一条记录查看详情</div></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block extra_script %}
|
||||||
|
<script>
|
||||||
|
var state={items:[],selected:null,offset:0,limit:20,total:0,hasMore:false};
|
||||||
|
function $(id){return document.getElementById(id);}
|
||||||
|
function esc(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];});}
|
||||||
|
function fmtTime(t){if(!t)return'--';var d=new Date(t);if(isNaN(d.getTime()))return t;return (d.getMonth()+1)+'/'+d.getDate()+' '+String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0');}
|
||||||
|
function statusClass(s){return s==='success'?'ok':s==='failed'?'fail':'';}
|
||||||
|
function chipList(items){items=Array.isArray(items)?items:[];return items.length?'<div class="chips">'+items.slice(0,8).map(function(x){return '<span class="chip">'+esc(x)+'</span>';}).join('')+'</div>':'--';}
|
||||||
|
function short(v,n){v=String(v||'');return v.length>n?v.slice(0,n)+'...':v;}
|
||||||
|
async function load(){try{var url='/api/llm/insights?limit='+state.limit+'&offset='+state.offset+'&target_type='+encodeURIComponent($('typeSel').value||'')+'&status='+encodeURIComponent($('statusSel').value||'');var d=await (await fetch(url)).json();state.items=d.items||[];state.total=d.total||0;state.hasMore=!!d.has_more;if(state.selected&&!state.items.some(function(x){return x.id===state.selected;}))state.selected=null;if(!state.selected&&state.items[0])state.selected=state.items[0].id;renderList();if(state.selected)selectItem(state.selected,true);else $('detail').innerHTML='<div class="empty">暂无 LLM 调用记录</div>';}catch(e){$('list').innerHTML='<div class="empty">加载失败</div>';}}
|
||||||
|
function renderList(){var start=state.offset+1,end=state.offset+state.items.length;$('countText').textContent=state.total?start+'-'+end+' / '+state.total:'0 条';$('prevBtn').disabled=state.offset<=0;$('nextBtn').disabled=!state.hasMore;if(!state.items.length){$('list').innerHTML='<div class="empty">暂无记录</div>';return;}$('list').innerHTML=state.items.map(function(x){var active=x.id===state.selected?' active':'';var cls=x.status==='failed'?' failed':x.status==='skipped'?' skipped':'';return '<div class="row'+active+'" onclick="selectItem('+x.id+')"><div class="row-top"><span class="type'+cls+'">'+esc(x.type_label)+'</span><span class="badge '+statusClass(x.status)+'">'+esc(x.status_label)+'</span></div><div class="subject">'+esc(x.subject||'未命名对象')+'</div><div class="summary">'+esc(short(x.summary||x.error||'暂无输出摘要',150))+'</div><div class="meta"><span>'+fmtTime(x.updated_at)+'</span><span>'+esc(x.model||'未记录模型')+'</span><span>'+esc(x.prompt_version||'--')+'</span></div></div>';}).join('');}
|
||||||
|
async function selectItem(id,quiet){state.selected=id;renderList();$('detail').innerHTML='<div class="loading">读取详情...</div>';try{var d=await (await fetch('/api/llm/insights/'+id)).json();renderDetail(d);}catch(e){$('detail').innerHTML='<div class="empty">详情加载失败</div>';}}
|
||||||
|
function page(step){var next=state.offset+step*state.limit;if(next<0)return;state.offset=next;state.selected=null;load();}
|
||||||
|
function reloadFirst(){state.offset=0;state.selected=null;load();}
|
||||||
|
function kv(label,val){return '<div class="kv-row"><label>'+esc(label)+'</label><div>'+val+'</div></div>';}
|
||||||
|
function renderDetail(d){$('detailTime').textContent=fmtTime(d.updated_at);var input=d.input||{},out=d.content||{};var rawIn=JSON.stringify(input,null,2);var rawOut=JSON.stringify(out,null,2);var evidence=out.key_evidence||out.key_tags||out.theme_tags||[];var risks=out.risk_flags||out.risk_types||[];var watch=out.watch_points||[];var invalid=out.invalid_if||[];var title=d.subject||input.symbol||input.title||input.run_date||'未命名对象';$('detail').innerHTML='<div class="hero-card"><div class="hero-title"><b>'+esc(title)+'</b><span class="badge '+statusClass(d.status)+'">'+esc(d.status_label)+'</span><span class="badge">'+esc(d.type_label)+'</span></div><div class="plain">'+esc(d.summary||d.error||'暂无摘要')+'</div></div><div class="cards"><div class="mini"><span>模型</span><b>'+esc(d.model||'未记录')+'</b></div><div class="mini"><span>提示词版本</span><b>'+esc(d.prompt_version||'--')+'</b></div><div class="mini"><span>对象类型</span><b>'+esc(d.target_type||'--')+'</b></div><div class="mini"><span>对象 ID</span><b>'+esc(d.target_id||'--')+'</b></div></div><div class="section"><h3>AI 输出</h3><div class="kv">'+kv('摘要',esc(out.summary||out.memo||out.why_now_or_not||'--'))+kv('为什么',esc(out.why_now_or_not||out.why_it_matters||'--'))+kv('关键证据',chipList(evidence))+kv('风险提示',chipList(risks))+kv('观察点',chipList(watch))+kv('失效条件',chipList(invalid))+kv('错误信息',esc(d.error||'--'))+'</div></div><div class="section"><h3>这次喂给 AI 的结构化输入</h3><div class="kv">'+kv('标的/主题',esc(input.symbol||input.related_symbol||input.title||input.run_date||'--'))+kv('状态/建议',esc(input.execution_label||input.action_status||input.decision||'--'))+kv('信号',chipList(input.signals||[]))+kv('价格上下文',esc([input.current_price?'现价 '+input.current_price:'',input.entry_price?'参考 '+input.entry_price:'',input.stop_loss?'止损 '+input.stop_loss:'',input.tp1?'TP1 '+input.tp1:''].filter(Boolean).join(' · ')||'--'))+'</div></div><div class="section"><h3>原始输入 JSON</h3><pre class="json-box">'+esc(rawIn)+'</pre></div><div class="section"><h3>原始输出 JSON</h3><pre class="json-box">'+esc(rawOut)+'</pre></div>';}
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -7,6 +7,8 @@
|
|||||||
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||||
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
||||||
<a class="sidebar-link active admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
<a class="sidebar-link active admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/cron" style="display:none"><svg class="link-icon"><use href="#svg-cron"/></svg>调度中心</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/llm-insights" style="display:none"><svg class="link-icon"><use href="#svg-ai"/></svg>AI 记录</a>
|
||||||
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||||
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||||
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||||
@ -23,17 +25,22 @@
|
|||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
<select class="select" id="hoursSel" onchange="setHoursAndReload()"><option value="24">近 24h</option><option value="72">近 3 天</option><option value="168">近 7 天</option></select>
|
<select class="select" id="hoursSel" onchange="setHoursAndReload()"><option value="24">近 24h</option><option value="72">近 3 天</option><option value="168">近 7 天</option></select>
|
||||||
<button class="btn-lite" onclick="reloadRuns()">刷新</button>
|
<button class="btn-lite" onclick="reloadRuns()">刷新</button>
|
||||||
<div class="pager">
|
|
||||||
<button class="page-btn" id="prevPageBtn" onclick="changePage(-1)">上一页</button>
|
|
||||||
<button class="page-btn" id="nextPageBtn" onclick="changePage(1)">下一页</button>
|
|
||||||
<span class="pager-info" id="pageInfo">--</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="kpis" id="kpis"><div class="loading">加载中...</div></div>
|
<div class="kpis" id="kpis"><div class="loading">加载中...</div></div>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panel-head"><div class="panel-title">批次</div><div class="panel-note" id="runCount">--</div></div>
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<div class="panel-title">批次</div>
|
||||||
|
<div class="panel-note" id="runCount">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="pager">
|
||||||
|
<button class="page-btn" id="prevPageBtn" onclick="changePage(-1)">上一页</button>
|
||||||
|
<button class="page-btn" id="nextPageBtn" onclick="changePage(1)">下一页</button>
|
||||||
|
<span class="pager-info" id="pageInfo">--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="run-list" id="runList"><div class="loading">加载批次...</div></div>
|
<div class="run-list" id="runList"><div class="loading">加载批次...</div></div>
|
||||||
</section>
|
</section>
|
||||||
<section class="panel detail">
|
<section class="panel detail">
|
||||||
@ -55,7 +62,7 @@ function statusCls(s){return s==='success'?'ok':'err';}
|
|||||||
function label(text,cls){return '<span class="label '+(cls||'')+'">'+esc(text)+'</span>';}
|
function label(text,cls){return '<span class="label '+(cls||'')+'">'+esc(text)+'</span>';}
|
||||||
function updatePager(){var info=$('pageInfo');var prev=$('prevPageBtn');var next=$('nextPageBtn');if(!info||!prev||!next)return;var totalPages=state.totalPages||0;info.textContent=totalPages?('第 '+state.page+' / '+totalPages+' 页,共 '+state.totalCount+' 批'):'暂无数据';prev.disabled=state.page<=1;next.disabled=!totalPages||state.page>=totalPages;}
|
function updatePager(){var info=$('pageInfo');var prev=$('prevPageBtn');var next=$('nextPageBtn');if(!info||!prev||!next)return;var totalPages=state.totalPages||0;info.textContent=totalPages?('第 '+state.page+' / '+totalPages+' 页,共 '+state.totalCount+' 批'):'暂无数据';prev.disabled=state.page<=1;next.disabled=!totalPages||state.page>=totalPages;}
|
||||||
function renderKpis(k){var items=[['批次数',k.run_count||0,''],['粗筛命中',k.rough_candidates||0,''],['细筛通过',k.fine_qualified||0,'blue'],['确认命中',k.confirm_hits||0,'blue'],['推荐生成',k.recommendations||0,'green'],['复盘成功',k.perf_success||0,'green'],['失败/漏选',(k.perf_failed||0)+' / '+(k.missed_count||0),'red']];$('kpis').innerHTML=items.map(function(x){return '<div class="kpi"><span>'+x[0]+'</span><b class="'+x[2]+'">'+x[1]+'</b></div>';}).join('');}
|
function renderKpis(k){var items=[['批次数',k.run_count||0,''],['粗筛命中',k.rough_candidates||0,''],['细筛通过',k.fine_qualified||0,'blue'],['确认命中',k.confirm_hits||0,'blue'],['推荐生成',k.recommendations||0,'green'],['复盘成功',k.perf_success||0,'green'],['失败/漏选',(k.perf_failed||0)+' / '+(k.missed_count||0),'red']];$('kpis').innerHTML=items.map(function(x){return '<div class="kpi"><span>'+x[0]+'</span><b class="'+x[2]+'">'+x[1]+'</b></div>';}).join('');}
|
||||||
function renderRuns(){var list=state.runs||[];$('runCount').textContent=list.length+' 批';updatePager();if(!list.length){$('runList').innerHTML='<div class="empty">暂无粗筛批次</div>';return;}$('runList').innerHTML=list.map(function(r){var active=state.selected===r.run_id?' active':'';var notes=(r.issue_notes||[]).join(' / ')||'链路正常';return '<div class="run-row'+active+'" onclick="selectRun('+r.run_id+')"><div class="run-main"><div class="run-time">'+fmtTime(r.started_at)+'</div><div class="run-sub '+(r.issue_notes&&r.issue_notes.length?'issue':'')+'">'+esc(notes)+'</div></div><span class="status '+statusCls(r.run_status)+'">'+esc(r.result_status||r.run_status)+'</span><div class="funnel"><div><span>粗筛</span><b>'+r.rough_candidates+'</b></div><div><span>细筛</span><b>'+r.fine_qualified+'</b></div><div><span>确认</span><b>'+r.confirm_hits+'/'+r.confirm_processed+'</b></div><div><span>推荐</span><b>'+r.recommendations+'</b></div><div><span>绩效</span><b>'+r.perf_success+'/'+r.perf_failed+'</b></div></div></div>';}).join('');}
|
function renderRuns(){var list=state.runs||[];$('runCount').textContent='本页 '+list.length+' / 全部 '+state.totalCount+' 批';updatePager();if(!list.length){$('runList').innerHTML='<div class="empty">暂无粗筛批次</div>';return;}$('runList').innerHTML=list.map(function(r){var active=state.selected===r.run_id?' active':'';var notes=(r.issue_notes||[]).join(' / ')||'链路正常';return '<div class="run-row'+active+'" onclick="selectRun('+r.run_id+')"><div class="run-main"><div class="run-time">'+fmtTime(r.started_at)+'</div><div class="run-sub '+(r.issue_notes&&r.issue_notes.length?'issue':'')+'">'+esc(notes)+'</div></div><span class="status '+statusCls(r.run_status)+'">'+esc(r.result_status||r.run_status)+'</span><div class="funnel"><div><span>粗筛</span><b>'+r.rough_candidates+'</b></div><div><span>细筛</span><b>'+r.fine_qualified+'</b></div><div><span>确认</span><b>'+r.confirm_hits+'/'+r.confirm_processed+'</b></div><div><span>推荐</span><b>'+r.recommendations+'</b></div><div><span>绩效</span><b>'+r.perf_success+'/'+r.perf_failed+'</b></div></div></div>';}).join('');}
|
||||||
function resetSelection(){state.selected=null;state.detail=null;}
|
function resetSelection(){state.selected=null;state.detail=null;}
|
||||||
async function loadRuns(){try{$('runList').innerHTML='<div class="loading">加载批次...</div>';state.hours=Number($('hoursSel').value||24);var d=await (await fetch('/api/pipeline/runs?hours='+state.hours+'&limit='+state.limit+'&offset='+state.offset)).json();state.runs=d.runs||[];state.totalPages=(d.pagination&&d.pagination.total_pages)||0;state.totalCount=(d.pagination&&d.pagination.total_count)||0;state.page=(d.pagination&&d.pagination.page)||1;renderKpis(d.kpi||{});if(state.selected&&!state.runs.some(function(r){return r.run_id===state.selected;}))resetSelection();if(!state.selected&&state.runs[0])state.selected=state.runs[0].run_id;renderRuns();if(state.selected)selectRun(state.selected,true);else $('detailBody').innerHTML='<div class="empty">暂无可展示批次</div>';}catch(e){$('runList').innerHTML='<div class="empty">加载失败</div>';updatePager();}}
|
async function loadRuns(){try{$('runList').innerHTML='<div class="loading">加载批次...</div>';state.hours=Number($('hoursSel').value||24);var d=await (await fetch('/api/pipeline/runs?hours='+state.hours+'&limit='+state.limit+'&offset='+state.offset)).json();state.runs=d.runs||[];state.totalPages=(d.pagination&&d.pagination.total_pages)||0;state.totalCount=(d.pagination&&d.pagination.total_count)||0;state.page=(d.pagination&&d.pagination.page)||1;renderKpis(d.kpi||{});if(state.selected&&!state.runs.some(function(r){return r.run_id===state.selected;}))resetSelection();if(!state.selected&&state.runs[0])state.selected=state.runs[0].run_id;renderRuns();if(state.selected)selectRun(state.selected,true);else $('detailBody').innerHTML='<div class="empty">暂无可展示批次</div>';}catch(e){$('runList').innerHTML='<div class="empty">加载失败</div>';updatePager();}}
|
||||||
async function selectRun(id,quiet){state.selected=id;renderRuns();$('detailBody').innerHTML='<div class="loading">加载详情...</div>';try{var d=await (await fetch('/api/pipeline/runs/'+id)).json();state.detail=d;state.filter='all';renderDetail();}catch(e){$('detailBody').innerHTML='<div class="empty">详情加载失败</div>';}}
|
async function selectRun(id,quiet){state.selected=id;renderRuns();$('detailBody').innerHTML='<div class="loading">加载详情...</div>';try{var d=await (await fetch('/api/pipeline/runs/'+id)).json();state.detail=d;state.filter='all';renderDetail();}catch(e){$('detailBody').innerHTML='<div class="empty">详情加载失败</div>';}}
|
||||||
|
|||||||
@ -8,6 +8,8 @@
|
|||||||
<a class="sidebar-link active" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
<a class="sidebar-link active" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||||
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
||||||
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/cron" style="display:none"><svg class="link-icon"><use href="#svg-cron"/></svg>调度中心</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/llm-insights" style="display:none"><svg class="link-icon"><use href="#svg-ai"/></svg>AI 记录</a>
|
||||||
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||||
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||||
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||||
|
|||||||
@ -7,6 +7,8 @@
|
|||||||
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||||
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
||||||
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/cron" style="display:none"><svg class="link-icon"><use href="#svg-cron"/></svg>调度中心</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/llm-insights" style="display:none"><svg class="link-icon"><use href="#svg-ai"/></svg>AI 记录</a>
|
||||||
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||||
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||||
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||||
@ -19,46 +21,44 @@
|
|||||||
|
|
||||||
/* Page title */
|
/* Page title */
|
||||||
.page-title { font-size: 24px; font-weight: 800; color: var(--ink); margin-bottom: 4px; }
|
.page-title { font-size: 24px; font-weight: 800; color: var(--ink); margin-bottom: 4px; }
|
||||||
.page-sub { font-size: 13px; color: var(--stone); margin-bottom: 24px; }
|
.page-sub { font-size: 13px; color: var(--stone); margin-bottom: 18px; }
|
||||||
|
|
||||||
/* === SECTION: DASHBOARD === */
|
/* === SECTION: DASHBOARD === */
|
||||||
.dashboard-grid {
|
.market-context {
|
||||||
display: grid; grid-template-columns: 1fr 1fr;
|
display: grid; grid-template-columns: minmax(210px, 260px) 1fr;
|
||||||
gap: 14px; margin-bottom: 28px;
|
gap: 10px; margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
@media(max-width:640px) { .dashboard-grid { grid-template-columns: 1fr; } }
|
@media(max-width:720px) { .market-context { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
/* Fear & Greed */
|
/* Fear & Greed */
|
||||||
.fg-card {
|
.fg-card {
|
||||||
background: var(--canvas); border: 1px solid var(--hairline-soft);
|
background: var(--canvas); border: 1px solid var(--hairline-soft);
|
||||||
border-radius: var(--radius-xl); padding: 24px;
|
border-radius: var(--radius-lg); padding: 10px 12px;
|
||||||
display: flex; flex-direction: column; align-items: center; gap: 12px;
|
display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 8px 10px;
|
||||||
}
|
}
|
||||||
.fg-card .fg-label { font-size: 12px; color: var(--stone); font-weight: 600; text-transform: uppercase; letter-spacing: .5px; }
|
.fg-card .fg-label { grid-column: 1 / -1; font-size: 11px; color: var(--stone); font-weight: 800; }
|
||||||
.fg-card .fg-value { font-size: 56px; font-weight: 900; line-height: 1; transition: color .3s; }
|
.fg-card .fg-value { font-size: 28px; font-weight: 900; line-height: 1; transition: color .3s; }
|
||||||
.fg-card .fg-class { font-size: 15px; font-weight: 700; padding: 4px 16px; border-radius: var(--radius-full); }
|
.fg-card .fg-class { justify-self: start; font-size: 12px; font-weight: 800; padding: 3px 9px; border-radius: var(--radius-full); }
|
||||||
.fg-gauge { width: 100%; height: 8px; border-radius: 4px; background: linear-gradient(to right, #e53e3e, #f59e0b, #84cc16); position: relative; }
|
.fg-gauge { grid-column: 1 / -1; width: 100%; height: 5px; border-radius: 999px; background: linear-gradient(to right, #e53e3e, #f59e0b, #84cc16); position: relative; }
|
||||||
.fg-gauge::after {
|
.fg-gauge::after {
|
||||||
content: ""; position: absolute; top: -4px;
|
content: ""; position: absolute; top: -3px;
|
||||||
width: 16px; height: 16px; border-radius: 50%; background: var(--canvas);
|
width: 11px; height: 11px; border-radius: 50%; background: var(--canvas);
|
||||||
border: 3px solid var(--ink); transition: left .5s;
|
border: 2px solid var(--ink); transition: left .5s;
|
||||||
left: 0%;
|
left: calc(var(--pos, 0%) - 5px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Trending card */
|
/* Trending card */
|
||||||
.trend-card {
|
.trend-card {
|
||||||
background: var(--canvas); border: 1px solid var(--hairline-soft);
|
background: var(--canvas); border: 1px solid var(--hairline-soft);
|
||||||
border-radius: var(--radius-xl); padding: 18px 20px;
|
border-radius: var(--radius-lg); padding: 10px 12px; min-width: 0;
|
||||||
}
|
}
|
||||||
.trend-card .section-label { font-size: 12px; color: var(--stone); font-weight: 600; margin-bottom: 14px; }
|
.trend-card .section-label { font-size: 11px; color: var(--stone); font-weight: 800; margin-bottom: 8px; }
|
||||||
.trend-list { display: flex; flex-direction: column; gap: 10px; }
|
.trend-list { display: flex; flex-wrap: wrap; gap: 6px; min-height: 29px; align-items: center; }
|
||||||
.trend-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--hairline-soft); }
|
.trend-pill { display: inline-flex; align-items: center; gap: 6px; max-width: 160px; padding: 4px 8px; border: 1px solid var(--hairline-soft); border-radius: 999px; background: var(--surface); color: var(--slate); font-size: 12px; font-weight: 800; }
|
||||||
.trend-row:last-child { border-bottom: 0; }
|
.trend-pill img { width: 16px; height: 16px; border-radius: 50%; flex-shrink: 0; }
|
||||||
.trend-icon { width: 28px; height: 28px; border-radius: 50%; background: var(--surface); display: grid; place-items: center; font-weight: 800; font-size: 10px; color: var(--steel); flex-shrink: 0; }
|
.trend-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); }
|
||||||
.trend-icon img { width: 28px; height: 28px; border-radius: 50%; }
|
|
||||||
.trend-name { font-weight: 700; font-size: 14px; color: var(--ink); }
|
|
||||||
.trend-symbol { font-size: 11px; color: var(--stone); margin-left: 4px; }
|
.trend-symbol { font-size: 11px; color: var(--stone); margin-left: 4px; }
|
||||||
.trend-rank { margin-left: auto; font-size: 11px; color: var(--muted); }
|
.trend-rank { font-size: 10px; color: var(--muted); }
|
||||||
|
|
||||||
/* === SECTION: NEWS FEED === */
|
/* === SECTION: NEWS FEED === */
|
||||||
.feed-header { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
|
.feed-header { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
|
||||||
@ -87,6 +87,30 @@
|
|||||||
.news-meta { display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--muted); }
|
.news-meta { display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--muted); }
|
||||||
.news-meta .dot { width: 3px; height: 3px; border-radius: 50%; background: var(--hairline); }
|
.news-meta .dot { width: 3px; height: 3px; border-radius: 50%; background: var(--hairline); }
|
||||||
|
|
||||||
|
.ai-brief { margin-top: 8px; border: 1px solid var(--hairline-soft); border-radius: var(--radius-lg); background: var(--surface); padding: 10px 12px; }
|
||||||
|
.ai-brief .label { font-size: 10px; color: var(--stone); font-weight: 900; margin-bottom: 4px; }
|
||||||
|
.ai-brief .text { font-size: 12px; color: var(--slate); line-height: 1.55; }
|
||||||
|
.ai-brief .chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
|
||||||
|
.ai-brief .chip { display: inline-flex; padding: 3px 7px; border-radius: 999px; border: 1px solid var(--hairline-soft); background: var(--canvas); color: var(--slate); font-size: 10px; }
|
||||||
|
.analysis-card { background: var(--canvas); border: 1px solid var(--hairline-soft); border-radius: var(--radius-xl); padding: 18px; margin-bottom: 18px; }
|
||||||
|
.analysis-head { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:12px; }
|
||||||
|
.analysis-title { font-size: 16px; font-weight: 900; color: var(--ink); }
|
||||||
|
.analysis-meta { color: var(--stone); font-size: 11px; font-weight: 800; text-align:right; line-height:1.5; }
|
||||||
|
.mood { display:inline-flex; border-radius:999px; padding:4px 9px; font-size:11px; font-weight:900; background:var(--surface); color:var(--slate); border:1px solid var(--hairline-soft); }
|
||||||
|
.mood.risk_on { color: var(--green); background: var(--green-light); border-color: rgba(0,180,115,.18); }
|
||||||
|
.mood.risk_off { color: var(--red); background: var(--red-light); border-color: rgba(229,62,62,.18); }
|
||||||
|
.analysis-summary { color: var(--slate); font-size: 14px; line-height: 1.75; margin-bottom: 14px; }
|
||||||
|
.analysis-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.analysis-section { border:1px solid var(--hairline-soft); border-radius: var(--radius-lg); background: var(--surface); padding: 12px; min-width:0; }
|
||||||
|
.analysis-section h3 { font-size: 12px; font-weight: 900; color: var(--ink); margin-bottom: 9px; }
|
||||||
|
.analysis-item { border-top:1px solid var(--hairline-soft); padding:8px 0; color:var(--slate); font-size:12px; line-height:1.55; }
|
||||||
|
.analysis-item:first-of-type { border-top:0; padding-top:0; }
|
||||||
|
.analysis-item b { color:var(--ink); font-size:13px; }
|
||||||
|
.analysis-item .sub { display:block; margin-top:3px; color:var(--stone); }
|
||||||
|
.symbol-tags { display:flex; flex-wrap:wrap; gap:4px; margin-top:5px; }
|
||||||
|
.symbol-tag { display:inline-flex; padding:3px 7px; border-radius:999px; background:var(--canvas); border:1px solid var(--hairline-soft); color:var(--blue); font-size:10px; font-weight:900; }
|
||||||
|
@media(max-width:760px){ .analysis-grid{grid-template-columns:1fr;} .analysis-head{display:block;} .analysis-meta{text-align:left;margin-top:8px;} }
|
||||||
|
|
||||||
/* Empty */
|
/* Empty */
|
||||||
.empty-state { text-align:center; padding:48px 20px; color:var(--stone); }
|
.empty-state { text-align:center; padding:48px 20px; color:var(--stone); }
|
||||||
.empty-state p { font-size:14px; }
|
.empty-state p { font-size:14px; }
|
||||||
@ -97,8 +121,8 @@
|
|||||||
.spin { animation: spin 1s linear infinite; }
|
.spin { animation: spin 1s linear infinite; }
|
||||||
@keyframes spin { to{ transform:rotate(360deg) } }
|
@keyframes spin { to{ transform:rotate(360deg) } }
|
||||||
|
|
||||||
|
@media(max-width:640px) {
|
||||||
.shell { width: min(100% - 24px, 960px); }
|
.shell { width: min(100% - 24px, 960px); }
|
||||||
.fg-card .fg-value { font-size: 42px; }
|
|
||||||
.news-card { padding: 14px 14px; gap: 10px; }
|
.news-card { padding: 14px 14px; gap: 10px; }
|
||||||
.news-source { min-width: 48px; font-size: 9px; padding: 3px 6px; }
|
.news-source { min-width: 48px; font-size: 9px; padding: 3px 6px; }
|
||||||
}
|
}
|
||||||
@ -107,10 +131,14 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<h1 class="page-title">实时舆情</h1>
|
<h1 class="page-title">实时舆情</h1>
|
||||||
<p class="page-sub">市场情绪 + 热门币种 + 最新加密新闻</p>
|
<p class="page-sub">AI 舆情研判 + 本轮分析来源</p>
|
||||||
|
|
||||||
|
<div id="aiAnalysis" class="analysis-card">
|
||||||
|
<div class="empty-state"><p>等待 AI 舆情分析结果...</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Dashboard -->
|
<!-- Dashboard -->
|
||||||
<div class="dashboard-grid">
|
<div class="market-context">
|
||||||
<div class="fg-card" id="fgCard">
|
<div class="fg-card" id="fgCard">
|
||||||
<div class="fg-label">恐惧 & 贪婪指数</div>
|
<div class="fg-label">恐惧 & 贪婪指数</div>
|
||||||
<div class="fg-value loading-pulse" id="fgValue">--</div>
|
<div class="fg-value loading-pulse" id="fgValue">--</div>
|
||||||
@ -121,14 +149,14 @@
|
|||||||
<div class="trend-card">
|
<div class="trend-card">
|
||||||
<div class="section-label">CoinGecko 热门币种</div>
|
<div class="section-label">CoinGecko 热门币种</div>
|
||||||
<div class="trend-list loading-pulse" id="trendList">
|
<div class="trend-list loading-pulse" id="trendList">
|
||||||
<div class="trend-row"><span class="trend-icon">?</span><span class="trend-name">加载中...</span></div>
|
<span class="trend-pill">加载中...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- News Feed -->
|
<!-- Source Feed -->
|
||||||
<div class="feed-header">
|
<div class="feed-header">
|
||||||
<h2>新闻信息流</h2>
|
<h2>本轮分析来源</h2>
|
||||||
<span class="feed-count" id="feedCount">--</span>
|
<span class="feed-count" id="feedCount">--</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="news-feed" id="newsFeed">
|
<div class="news-feed" id="newsFeed">
|
||||||
@ -202,6 +230,8 @@ function ageStr(h) {
|
|||||||
return Math.floor(h / 24) + '天前';
|
return Math.floor(h / 24) + '天前';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function esc(v){ return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];}); }
|
||||||
|
|
||||||
function fgColor(v) {
|
function fgColor(v) {
|
||||||
if (v <= 25) return 'var(--red)';
|
if (v <= 25) return 'var(--red)';
|
||||||
if (v <= 45) return 'var(--orange)';
|
if (v <= 45) return 'var(--orange)';
|
||||||
@ -210,10 +240,59 @@ function fgColor(v) {
|
|||||||
return 'var(--green)';
|
return 'var(--green)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtTime(t) {
|
||||||
|
if (!t) return '--';
|
||||||
|
var d = new Date(t);
|
||||||
|
if (isNaN(d.getTime())) return t;
|
||||||
|
return (d.getMonth()+1)+'/'+d.getDate()+' '+String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAnalysis(resp) {
|
||||||
|
var box = document.getElementById('aiAnalysis');
|
||||||
|
var a = resp && resp.analysis ? resp.analysis : null;
|
||||||
|
if (!a) {
|
||||||
|
var msg = resp && resp.error ? ('AI 舆情分析未完成:' + resp.error) : '暂无 AI 舆情分析。调度器会定时生成,或运行 llm-insights --scope sentiment。';
|
||||||
|
box.innerHTML = '<div class="empty-state"><p>'+esc(msg)+'</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var mood = a.market_mood || 'neutral';
|
||||||
|
function symbolsHtml(items) {
|
||||||
|
return (items || []).slice(0, 8).map(function(s){ return '<span class="symbol-tag">'+esc(s)+'</span>'; }).join('');
|
||||||
|
}
|
||||||
|
var themes = (a.hot_themes || []).slice(0, 6).map(function(x){
|
||||||
|
return '<div class="analysis-item"><b>'+esc(x.theme || '主题')+'</b><span class="sub">'+esc(x.impact || x.reason || '--')+'</span><div class="symbol-tags">'+symbolsHtml(x.symbols || [])+'</div></div>';
|
||||||
|
}).join('') || '<div class="analysis-item">暂无明确主题</div>';
|
||||||
|
var impacts = (a.coin_impacts || []).slice(0, 8).map(function(x){
|
||||||
|
var check = x.need_technical_check ? ' · 需要技术检查' : '';
|
||||||
|
return '<div class="analysis-item"><b>'+esc(x.symbol || '--')+'</b><span class="sub">'+esc((x.direction || 'neutral') + check)+'</span><span class="sub">'+esc(x.reason || '--')+'</span></div>';
|
||||||
|
}).join('') || '<div class="analysis-item">暂无币种影响</div>';
|
||||||
|
var risks = (a.risk_events || []).slice(0, 6).map(function(x){
|
||||||
|
return '<div class="analysis-item"><b>'+esc(x.risk_type || x.title || '风险事件')+'</b><span class="sub">'+esc(x.title || x.reason || '--')+'</span><div class="symbol-tags">'+symbolsHtml(x.symbols || [])+'</div></div>';
|
||||||
|
}).join('') || '<div class="analysis-item">暂无集中风险</div>';
|
||||||
|
var watch = (a.watchlist || []).slice(0, 8).map(function(x){
|
||||||
|
return '<div class="analysis-item"><b>'+esc(x.symbol || '--')+'</b><span class="sub">'+esc(x.why || '--')+'</span><span class="sub">触发条件:'+esc(x.trigger || '--')+'</span></div>';
|
||||||
|
}).join('') || '<div class="analysis-item">暂无重点观察</div>';
|
||||||
|
box.innerHTML =
|
||||||
|
'<div class="analysis-head"><div><div class="analysis-title">AI 舆情研判 <span class="mood '+esc(mood)+'">'+esc(mood)+'</span></div></div><div class="analysis-meta">模型 '+esc(resp.model || '--')+'<br>更新 '+fmtTime(resp.updated_at)+' · 来源 '+esc(resp.event_count || 0)+' 条</div></div>'+
|
||||||
|
'<div class="analysis-summary">'+esc(a.summary || '暂无摘要')+'</div>'+
|
||||||
|
'<div class="analysis-grid">'+
|
||||||
|
'<div class="analysis-section"><h3>主线主题</h3>'+themes+'</div>'+
|
||||||
|
'<div class="analysis-section"><h3>币种影响</h3>'+impacts+'</div>'+
|
||||||
|
'<div class="analysis-section"><h3>风险事件</h3>'+risks+'</div>'+
|
||||||
|
'<div class="analysis-section"><h3>重点观察</h3>'+watch+'</div>'+
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
async function loadFeed() {
|
async function loadFeed() {
|
||||||
try {
|
try {
|
||||||
var resp = await fetch(API + '/api/newsfeed');
|
var resp = await fetch(API + '/api/newsfeed');
|
||||||
var data = await resp.json();
|
var data = await resp.json();
|
||||||
|
var analysisResp = {};
|
||||||
|
try {
|
||||||
|
var ar = await fetch(API + '/api/sentiment/analysis');
|
||||||
|
if (ar.ok) analysisResp = await ar.json();
|
||||||
|
} catch(e) {}
|
||||||
|
renderAnalysis(analysisResp);
|
||||||
|
|
||||||
// Fear & Greed
|
// Fear & Greed
|
||||||
var fg = data.fear_greed;
|
var fg = data.fear_greed;
|
||||||
@ -226,48 +305,64 @@ async function loadFeed() {
|
|||||||
document.getElementById('fgClass').style.color = clr;
|
document.getElementById('fgClass').style.color = clr;
|
||||||
document.getElementById('fgClass').style.background = clr + '15';
|
document.getElementById('fgClass').style.background = clr + '15';
|
||||||
document.getElementById('fgGauge').style.setProperty('--pos', v + '%');
|
document.getElementById('fgGauge').style.setProperty('--pos', v + '%');
|
||||||
// Update gauge pointer position
|
|
||||||
var g = document.getElementById('fgGauge');
|
|
||||||
g.style.setProperty('--pos', v + '%');
|
|
||||||
// Re-apply ::after style with JS since CSS custom properties in pseudo-elements can be tricky
|
|
||||||
var sheet = document.createElement('style');
|
|
||||||
sheet.textContent = '#fgGauge::after { left: calc(' + v + '% - 8px); }';
|
|
||||||
document.head.appendChild(sheet);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trending
|
// Trending
|
||||||
var trends = data.trending || [];
|
var trends = data.trending || [];
|
||||||
document.getElementById('trendList').classList.remove('loading-pulse');
|
document.getElementById('trendList').classList.remove('loading-pulse');
|
||||||
if (trends.length) {
|
if (trends.length) {
|
||||||
document.getElementById('trendList').innerHTML = trends.map(function(t, i) {
|
document.getElementById('trendList').innerHTML = trends.slice(0, 8).map(function(t) {
|
||||||
var icon = t.thumb
|
var symbol = String(t.symbol || '').toUpperCase();
|
||||||
? '<img src="' + t.thumb + '" alt="' + t.symbol + '" onerror="this.style.display=\'none\';this.nextSibling.style.display=\'block\'"><span style="display:none">' + t.symbol.charAt(0) + '</span>'
|
var icon = t.thumb ? '<img src="' + esc(t.thumb) + '" alt="' + esc(symbol) + '" onerror="this.remove()">' : '';
|
||||||
: t.symbol.slice(0, 2).toUpperCase();
|
return '<span class="trend-pill">' +
|
||||||
return '<div class="trend-row">' +
|
icon +
|
||||||
'<div class="trend-icon">' + icon + '</div>' +
|
'<span class="trend-name">' + esc(t.name || symbol || '--') + '</span>' +
|
||||||
'<div><span class="trend-name">' + t.name + '</span><span class="trend-symbol">' + t.symbol + '</span></div>' +
|
'<span class="trend-symbol">' + esc(symbol) + '</span>' +
|
||||||
'<span class="trend-rank">#' + (t.market_cap_rank || '--') + '</span>' +
|
'<span class="trend-rank">#' + esc(t.market_cap_rank || '--') + '</span>' +
|
||||||
'</div>';
|
'</span>';
|
||||||
}).join('');
|
}).join('');
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('trendList').innerHTML = '<div style="color:var(--muted);font-size:13px;text-align:center;padding:20px">暂无数据</div>';
|
document.getElementById('trendList').innerHTML = '<span style="color:var(--muted);font-size:12px">暂无数据</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// News feed
|
// Source feed: only show the events/news that fed the latest AI analysis.
|
||||||
var news = data.news || [];
|
var events = ((analysisResp && analysisResp.source_events) || []).map(function(e){
|
||||||
|
return {
|
||||||
|
title: e.title,
|
||||||
|
url: e.url,
|
||||||
|
source: e.source_label || e.source || '事件',
|
||||||
|
age_hours: null,
|
||||||
|
lang: e.related_base || '事件',
|
||||||
|
llm_insight: null,
|
||||||
|
relation_tag: e.related_symbol || e.related_base || '',
|
||||||
|
importance: e.importance
|
||||||
|
};
|
||||||
|
});
|
||||||
|
var news = events.slice(0, 50);
|
||||||
document.getElementById('feedCount').textContent = news.length + ' 条';
|
document.getElementById('feedCount').textContent = news.length + ' 条';
|
||||||
if (news.length) {
|
if (news.length) {
|
||||||
document.getElementById('newsFeed').innerHTML = news.map(function(n) {
|
document.getElementById('newsFeed').innerHTML = news.map(function(n) {
|
||||||
var isCn = n.lang === 'cn';
|
var isCn = n.lang === 'cn';
|
||||||
return '<a class="news-card" href="' + n.url + '" target="_blank" rel="noopener">' +
|
var langLabel = n.relation_tag || (isCn ? '中文' : (n.importance ? ('重要性 ' + n.importance) : 'EN'));
|
||||||
'<span class="news-source' + (isCn ? ' cn' : '') + '">' + n.source + '</span>' +
|
var ai = n.llm_insight && n.llm_insight.content ? n.llm_insight.content : null;
|
||||||
|
var tags = ai ? (ai.key_tags || ai.theme_tags || ai.risk_types || []) : [];
|
||||||
|
var aiHtml = ai ? (
|
||||||
|
'<div class="ai-brief">' +
|
||||||
|
'<div class="label">AI 解读</div>' +
|
||||||
|
'<div class="text">' + esc(ai.summary || ai.why_now_or_not || '暂无摘要') + '</div>' +
|
||||||
|
'<div class="chips">' + (tags.slice(0,4).map(function(x){ return '<span class="chip">'+esc(x)+'</span>'; }).join('') || '') + '</div>' +
|
||||||
|
'</div>'
|
||||||
|
) : '';
|
||||||
|
return '<a class="news-card" href="' + esc(n.url || '#') + '" target="_blank" rel="noopener">' +
|
||||||
|
'<span class="news-source' + (isCn ? ' cn' : '') + '">' + esc(n.source) + '</span>' +
|
||||||
'<div class="news-body">' +
|
'<div class="news-body">' +
|
||||||
'<div class="news-title">' + n.title + '</div>' +
|
'<div class="news-title">' + esc(n.title) + '</div>' +
|
||||||
'<div class="news-meta">' +
|
'<div class="news-meta">' +
|
||||||
'<span>' + (isCn ? '中文' : 'EN') + '</span>' +
|
'<span>' + esc(langLabel) + '</span>' +
|
||||||
'<span class="dot"></span>' +
|
'<span class="dot"></span>' +
|
||||||
'<span>' + ageStr(n.age_hours) + '</span>' +
|
'<span>' + ageStr(n.age_hours) + '</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
aiHtml +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</a>';
|
'</a>';
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|||||||
@ -7,6 +7,8 @@
|
|||||||
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||||
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
||||||
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/cron" style="display:none"><svg class="link-icon"><use href="#svg-cron"/></svg>调度中心</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/llm-insights" style="display:none"><svg class="link-icon"><use href="#svg-ai"/></svg>AI 记录</a>
|
||||||
<a class="sidebar-link active admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
<a class="sidebar-link active admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||||
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||||
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||||
|
|||||||
@ -7,6 +7,8 @@
|
|||||||
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||||
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
||||||
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/cron" style="display:none"><svg class="link-icon"><use href="#svg-cron"/></svg>调度中心</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/llm-insights" style="display:none"><svg class="link-icon"><use href="#svg-ai"/></svg>AI 记录</a>
|
||||||
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||||
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||||
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||||
|
|||||||
@ -7,6 +7,8 @@
|
|||||||
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||||
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
|
||||||
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/cron" style="display:none"><svg class="link-icon"><use href="#svg-cron"/></svg>调度中心</a>
|
||||||
|
<a class="sidebar-link admin-link" href="/llm-insights" style="display:none"><svg class="link-icon"><use href="#svg-ai"/></svg>AI 记录</a>
|
||||||
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||||
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||||
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||||
|
|||||||
306
tests/test_llm_insights.py
Normal file
306
tests/test_llm_insights.py
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
if PROJECT_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, PROJECT_DIR)
|
||||||
|
|
||||||
|
from app.db import altcoin_db
|
||||||
|
from app.db.llm_insights import compute_input_hash, get_cached_insight, repair_mojibake_json, upsert_insight
|
||||||
|
from app.services import event_driven_screener, llm_insights
|
||||||
|
from app.web import web_server
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_db(monkeypatch, tmp_path):
|
||||||
|
db_path = tmp_path / "altcoin_monitor.db"
|
||||||
|
monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path))
|
||||||
|
monkeypatch.setattr(web_server, "init_db", altcoin_db.init_db)
|
||||||
|
altcoin_db.init_db()
|
||||||
|
return db_path
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_recommendation(db_path, **kwargs):
|
||||||
|
defaults = dict(
|
||||||
|
symbol="AAA/USDT",
|
||||||
|
rec_time="2026-05-01T10:00:00",
|
||||||
|
rec_state="加速",
|
||||||
|
rec_score=80,
|
||||||
|
entry_price=100.0,
|
||||||
|
stop_loss=95.0,
|
||||||
|
tp1=110.0,
|
||||||
|
tp2=120.0,
|
||||||
|
sector="AI",
|
||||||
|
signals=json.dumps(["15min 即刻入场信号"], ensure_ascii=False),
|
||||||
|
signal_codes_json=json.dumps(["vp_fly_1h_current"], ensure_ascii=False),
|
||||||
|
signal_labels_json=json.dumps(["15min 即刻入场信号"], ensure_ascii=False),
|
||||||
|
is_meme=0,
|
||||||
|
status="active",
|
||||||
|
current_price=100.0,
|
||||||
|
max_price=104.0,
|
||||||
|
min_price=98.0,
|
||||||
|
pnl_pct=0.0,
|
||||||
|
max_pnl_pct=4.0,
|
||||||
|
max_drawdown_pct=-1.0,
|
||||||
|
hit_tp1_time="",
|
||||||
|
hit_tp2_time="",
|
||||||
|
stopped_out_time="",
|
||||||
|
expired_time="",
|
||||||
|
last_track_time="2026-05-01T10:10:00",
|
||||||
|
entry_plan_json=json.dumps({"entry_price": 100.0, "entry_action": "可即刻买入"}, ensure_ascii=False),
|
||||||
|
action_status="可即刻买入",
|
||||||
|
direction="多头启动",
|
||||||
|
execution_status="buy_now",
|
||||||
|
display_bucket="realtime",
|
||||||
|
lifecycle_state="position",
|
||||||
|
entry_triggered=1,
|
||||||
|
state_reason="推荐时就是可即刻买入",
|
||||||
|
strategy_version="v1.0",
|
||||||
|
)
|
||||||
|
defaults.update(kwargs)
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cols = ",".join(defaults.keys())
|
||||||
|
qs = ",".join(["?"] * len(defaults))
|
||||||
|
conn.execute(f"INSERT INTO recommendation ({cols}) VALUES ({qs})", tuple(defaults.values()))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_llm_row(db_path):
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
row = conn.execute("SELECT * FROM llm_insights ORDER BY id DESC LIMIT 1").fetchone()
|
||||||
|
conn.close()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_llm_skips_and_does_not_change_mainline(monkeypatch, temp_db):
|
||||||
|
_insert_recommendation(temp_db)
|
||||||
|
monkeypatch.setattr(llm_insights, "get_llm_module_enabled", lambda module: False)
|
||||||
|
result = llm_insights.run(scope="recommendations", limit=10)
|
||||||
|
assert result["status"] == "skipped"
|
||||||
|
|
||||||
|
rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False)
|
||||||
|
target = next(r for r in rows if r["symbol"] == "AAA/USDT")
|
||||||
|
assert target["execution_status"] == "buy_now"
|
||||||
|
assert "llm_insight" not in target
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_input_hash_is_cached_without_repeated_call(monkeypatch, temp_db):
|
||||||
|
_insert_recommendation(temp_db)
|
||||||
|
monkeypatch.setattr(llm_insights, "get_llm_module_enabled", lambda module: True)
|
||||||
|
monkeypatch.setattr(llm_insights, "_call_llm_json", lambda prompt, payload: {"status": "success", "content": {"summary": "ok"}, "model": "m"})
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
original_upsert = llm_insights.upsert_insight
|
||||||
|
|
||||||
|
def wrapped_upsert(*args, **kwargs):
|
||||||
|
calls.append((args, kwargs))
|
||||||
|
return original_upsert(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(llm_insights, "upsert_insight", wrapped_upsert)
|
||||||
|
|
||||||
|
first = llm_insights.run(scope="recommendations", limit=10)
|
||||||
|
second = llm_insights.run(scope="recommendations", limit=10)
|
||||||
|
assert first["processed"] == 1
|
||||||
|
assert second["processed"] == 0
|
||||||
|
assert len(calls) == 1
|
||||||
|
|
||||||
|
rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False)
|
||||||
|
target = next(r for r in rows if r["symbol"] == "AAA/USDT")
|
||||||
|
assert target["llm_insight"]["content"]["summary"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_json_is_marked_failed(monkeypatch, temp_db):
|
||||||
|
_insert_recommendation(temp_db, symbol="BBB/USDT", rec_time="2026-05-01T11:00:00")
|
||||||
|
monkeypatch.setattr(llm_insights, "get_llm_module_enabled", lambda module: True)
|
||||||
|
monkeypatch.setattr(llm_insights, "_call_llm_json", lambda prompt, payload: {"status": "failed", "error": "invalid_json", "model": "m"})
|
||||||
|
llm_insights.run(scope="recommendations", limit=10)
|
||||||
|
row = _fetch_llm_row(temp_db)
|
||||||
|
assert row["status"] == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_key_samples_generate_insights(monkeypatch, temp_db):
|
||||||
|
_insert_recommendation(temp_db, symbol="CCC/USDT", action_status="观察", execution_status="observe", display_bucket="watch_pool", state_reason="普通观察")
|
||||||
|
_insert_recommendation(temp_db, symbol="DDD/USDT", action_status="等回踩", execution_status="wait_pullback", display_bucket="realtime", rec_time="2026-05-01T12:00:00")
|
||||||
|
monkeypatch.setattr(llm_insights, "get_llm_module_enabled", lambda module: True)
|
||||||
|
seen = []
|
||||||
|
monkeypatch.setattr(llm_insights, "_call_llm_json", lambda prompt, payload: seen.append(payload["symbol"]) or {"status": "success", "content": {"summary": "ok"}, "model": "m"})
|
||||||
|
llm_insights.run(scope="recommendations", limit=10)
|
||||||
|
assert "CCC/USDT" not in seen
|
||||||
|
assert "DDD/USDT" in seen
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_exposes_cached_ai_fields(monkeypatch, temp_db):
|
||||||
|
_insert_recommendation(temp_db)
|
||||||
|
monkeypatch.setattr(llm_insights, "get_llm_module_enabled", lambda module: True)
|
||||||
|
monkeypatch.setattr(llm_insights, "_call_llm_json", lambda prompt, payload: {"status": "success", "content": {"summary": "AI 摘要", "key_evidence": ["量能增强"]}, "model": "m"})
|
||||||
|
llm_insights.run(scope="recommendations", limit=10)
|
||||||
|
|
||||||
|
client = TestClient(web_server.app)
|
||||||
|
resp = client.get("/api/recommendations/active?actionable_only=false")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
target = next(r for r in data if r["symbol"] == "AAA/USDT")
|
||||||
|
assert target["llm_insight"]["content"]["summary"] == "AI 摘要"
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_json_gets_cached_as_failed(monkeypatch, temp_db):
|
||||||
|
_insert_recommendation(temp_db)
|
||||||
|
monkeypatch.setattr(llm_insights, "get_llm_module_enabled", lambda module: True)
|
||||||
|
monkeypatch.setattr(llm_insights, "_call_llm_json", lambda prompt, payload: {"status": "failed", "error": "invalid_json:bad", "model": "m"})
|
||||||
|
llm_insights.run(scope="recommendations", limit=10)
|
||||||
|
row = _fetch_llm_row(temp_db)
|
||||||
|
assert row["status"] == "failed"
|
||||||
|
assert "invalid_json" in row["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_insights_api_exposes_input_and_output(temp_db):
|
||||||
|
payload = {"symbol": "AAA/USDT", "action_status": "可即刻买入", "signals": ["15min 即刻入场信号"]}
|
||||||
|
upsert_insight(
|
||||||
|
"recommendation",
|
||||||
|
"7",
|
||||||
|
llm_insights.PROMPTS["recommendation_explain_v1"],
|
||||||
|
llm_insights.PROMPTS["recommendation_explain_v1"],
|
||||||
|
compute_input_hash(payload),
|
||||||
|
"success",
|
||||||
|
input_payload=payload,
|
||||||
|
content={"summary": "AI 判断现在处于入场窗口", "key_evidence": ["量能增强"]},
|
||||||
|
model="test-model",
|
||||||
|
)
|
||||||
|
client = TestClient(web_server.app)
|
||||||
|
resp = client.get("/api/llm/insights")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["items"][0]["model"] == "test-model"
|
||||||
|
assert data["items"][0]["input"]["symbol"] == "AAA/USDT"
|
||||||
|
assert data["items"][0]["content"]["summary"] == "AI 判断现在处于入场窗口"
|
||||||
|
|
||||||
|
detail = client.get(f"/api/llm/insights/{data['items'][0]['id']}").json()
|
||||||
|
assert detail["input"]["signals"] == ["15min 即刻入场信号"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sentiment_batch_analysis_api_returns_cached_result(temp_db):
|
||||||
|
payload = {
|
||||||
|
"target_type": "sentiment_batch",
|
||||||
|
"target_id": "sentiment_batch:24h",
|
||||||
|
"hours": 24,
|
||||||
|
"event_count": 1,
|
||||||
|
"events": [{"title": "Binance Will List ABCUSDT", "related_symbol": "ABC/USDT"}],
|
||||||
|
}
|
||||||
|
upsert_insight(
|
||||||
|
"sentiment_batch",
|
||||||
|
"sentiment_batch:24h",
|
||||||
|
llm_insights.PROMPTS["sentiment_batch_analyze_v1"],
|
||||||
|
llm_insights.PROMPTS["sentiment_batch_analyze_v1"],
|
||||||
|
compute_input_hash(payload),
|
||||||
|
"success",
|
||||||
|
input_payload=payload,
|
||||||
|
content={
|
||||||
|
"market_mood": "risk_on",
|
||||||
|
"summary": "上币事件带动短线风险偏好",
|
||||||
|
"coin_impacts": [{"symbol": "ABC/USDT", "direction": "positive", "reason": "Binance listing", "need_technical_check": True}],
|
||||||
|
},
|
||||||
|
model="test-model",
|
||||||
|
)
|
||||||
|
client = TestClient(web_server.app)
|
||||||
|
resp = client.get("/api/sentiment/analysis")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["analysis"]["summary"] == "上币事件带动短线风险偏好"
|
||||||
|
assert data["source_events"][0]["related_symbol"] == "ABC/USDT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sentiment_batch_enqueues_technical_check_candidates(monkeypatch, temp_db):
|
||||||
|
event_driven_screener.init_event_tables()
|
||||||
|
conn = sqlite3.connect(temp_db)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO event_news (
|
||||||
|
event_hash, source, symbol, title, url, published_at, detected_at,
|
||||||
|
importance, event_type, processed
|
||||||
|
) VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'), ?, ?, 1)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
"seed-llm-candidate",
|
||||||
|
"binance_latest",
|
||||||
|
"AAA/USDT",
|
||||||
|
"Binance announces AAA ecosystem update",
|
||||||
|
"",
|
||||||
|
"A",
|
||||||
|
"important_catalyst",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
monkeypatch.setattr(llm_insights, "get_llm_module_enabled", lambda module: True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
llm_insights,
|
||||||
|
"_call_llm_json",
|
||||||
|
lambda prompt, payload: {
|
||||||
|
"status": "success",
|
||||||
|
"model": "m",
|
||||||
|
"content": {
|
||||||
|
"summary": "AAA 需要进入技术检查",
|
||||||
|
"coin_impacts": [
|
||||||
|
{
|
||||||
|
"symbol": "AAA/USDT",
|
||||||
|
"direction": "positive",
|
||||||
|
"reason": "公告催化且主题关注升温",
|
||||||
|
"confidence": 88,
|
||||||
|
"need_technical_check": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "BBB/USDT",
|
||||||
|
"direction": "positive",
|
||||||
|
"reason": "置信度不足",
|
||||||
|
"confidence": 40,
|
||||||
|
"need_technical_check": True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = llm_insights.generate_sentiment_batch_analysis(limit=10)
|
||||||
|
assert result["candidate_events"]["queued"] == 1
|
||||||
|
assert result["candidate_events"]["symbols"] == ["AAA/USDT"]
|
||||||
|
|
||||||
|
conn = sqlite3.connect(temp_db)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT source, symbol, event_type, processed, decision, rec_id FROM event_news WHERE source='llm_sentiment'"
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["symbol"] == "AAA/USDT"
|
||||||
|
assert rows[0]["event_type"] == "llm_sentiment_candidate"
|
||||||
|
assert rows[0]["processed"] == 0
|
||||||
|
assert rows[0]["decision"] in ("", None)
|
||||||
|
assert rows[0]["rec_id"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_insights_page_route(temp_db):
|
||||||
|
client = TestClient(web_server.app)
|
||||||
|
resp = client.get("/llm-insights")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "AI 记录" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_mojibake_json_is_repaired_for_display():
|
||||||
|
raw = {"ç»è®º": "ç\x89å\x9b\x9e踩ï¼\x8cä¸\x8d追é«\x98", "list": ["æ\xa0¸å¿\x83å\x8e\x9få\x9b\xa0"]}
|
||||||
|
fixed = repair_mojibake_json(raw)
|
||||||
|
assert fixed["结论"] == "等回踩,不追高"
|
||||||
|
assert fixed["list"][0] == "核心原因"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mixed_mojibake_text_is_repaired():
|
||||||
|
raw = "AI舆情候选 ABC/USDT: Binance Futuresæ\x96°ä¸\x8a线永ç»\xadå\x90\x88约"
|
||||||
|
fixed = repair_mojibake_json(raw)
|
||||||
|
assert fixed == "AI舆情候选 ABC/USDT: Binance Futures新上线永续合约"
|
||||||
@ -235,6 +235,10 @@ def test_pipeline_runs_supports_pagination(temp_db):
|
|||||||
assert data_page2["pagination"]["page"] == 2
|
assert data_page2["pagination"]["page"] == 2
|
||||||
assert len(data_page2["runs"]) == 1
|
assert len(data_page2["runs"]) == 1
|
||||||
assert data_page1["runs"][0]["run_id"] != data_page2["runs"][0]["run_id"]
|
assert data_page1["runs"][0]["run_id"] != data_page2["runs"][0]["run_id"]
|
||||||
|
assert data_page1["kpi"]["run_count"] == 3
|
||||||
|
assert data_page2["kpi"]["run_count"] == 3
|
||||||
|
assert data_page1["kpi"]["rough_candidates"] == 6
|
||||||
|
assert data_page2["kpi"]["rough_candidates"] == 6
|
||||||
|
|
||||||
|
|
||||||
def test_pipeline_api_keeps_observation_batch_without_recommendations(temp_db):
|
def test_pipeline_api_keeps_observation_batch_without_recommendations(temp_db):
|
||||||
|
|||||||
154
tests/test_scheduler_control.py
Normal file
154
tests/test_scheduler_control.py
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
if PROJECT_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, PROJECT_DIR)
|
||||||
|
|
||||||
|
from app.db import altcoin_db
|
||||||
|
from app.db import scheduler_db
|
||||||
|
from app.web import web_server
|
||||||
|
import docker.scheduler as scheduler
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduler_tables_seed_defaults(monkeypatch, tmp_path):
|
||||||
|
db_path = tmp_path / "altcoin_monitor.db"
|
||||||
|
monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path))
|
||||||
|
altcoin_db.init_db()
|
||||||
|
scheduler_db.init_scheduler_tables()
|
||||||
|
|
||||||
|
jobs = {item["job_name"]: item for item in scheduler_db.get_job_configs()}
|
||||||
|
assert jobs["event"]["lock_group"] == "recommendation_write"
|
||||||
|
assert jobs["confirm"]["lock_group"] == "recommendation_write"
|
||||||
|
assert jobs["tracker"]["every_seconds"] == 180
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduler_control_api_and_page(monkeypatch, tmp_path):
|
||||||
|
db_path = tmp_path / "altcoin_monitor.db"
|
||||||
|
monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path))
|
||||||
|
monkeypatch.setattr(web_server, "init_db", altcoin_db.init_db)
|
||||||
|
altcoin_db.init_db()
|
||||||
|
scheduler_db.init_scheduler_tables()
|
||||||
|
|
||||||
|
client = TestClient(web_server.app)
|
||||||
|
page = client.get("/cron")
|
||||||
|
assert page.status_code == 200
|
||||||
|
assert "调度中心" in page.text
|
||||||
|
scripts = re.findall(r"<script>([\s\S]*?)</script>", page.text)
|
||||||
|
scheduler_scripts = [s for s in scripts if "/api/scheduler/jobs" in s]
|
||||||
|
assert scheduler_scripts
|
||||||
|
subprocess.run(
|
||||||
|
["node", "-e", f"new Function({scheduler_scripts[-1]!r})"],
|
||||||
|
check=True,
|
||||||
|
cwd=PROJECT_DIR,
|
||||||
|
)
|
||||||
|
assert "data-action=\"trigger\"" in page.text
|
||||||
|
assert "onchange=\"toggleJob" not in page.text
|
||||||
|
assert "onclick=\"triggerJob" not in page.text
|
||||||
|
|
||||||
|
resp = client.get("/api/scheduler/jobs")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert any(item["job_name"] == "event" for item in resp.json()["jobs"])
|
||||||
|
|
||||||
|
toggle = client.post("/api/scheduler/jobs/event/toggle", json={"enabled": False})
|
||||||
|
assert toggle.status_code == 200
|
||||||
|
assert scheduler_db.get_job_config("event")["enabled"] is False
|
||||||
|
|
||||||
|
blocked = client.post("/api/scheduler/jobs/event/trigger", json={"force": False})
|
||||||
|
assert blocked.status_code == 409
|
||||||
|
|
||||||
|
forced = client.post("/api/scheduler/jobs/event/trigger", json={"force": True})
|
||||||
|
assert forced.status_code == 200
|
||||||
|
triggers = scheduler_db.list_manual_triggers()
|
||||||
|
assert triggers[0]["job_name"] == "event"
|
||||||
|
assert triggers[0]["force"] == 1
|
||||||
|
|
||||||
|
interval = client.post("/api/scheduler/jobs/tracker/interval", json={"every_seconds": 240})
|
||||||
|
assert interval.status_code == 200
|
||||||
|
assert scheduler_db.get_job_config("tracker")["every_seconds"] == 240
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeProc:
|
||||||
|
_next_pid = 100
|
||||||
|
|
||||||
|
def __init__(self, cmd, **kwargs):
|
||||||
|
self.cmd = cmd
|
||||||
|
self.pid = _FakeProc._next_pid
|
||||||
|
_FakeProc._next_pid += 1
|
||||||
|
self.returncode = None
|
||||||
|
self.stdout = self
|
||||||
|
|
||||||
|
def poll(self):
|
||||||
|
return self.returncode
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
def kill(self):
|
||||||
|
self.returncode = -9
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduler_starts_different_lock_groups_concurrently(monkeypatch, tmp_path):
|
||||||
|
db_path = tmp_path / "altcoin_monitor.db"
|
||||||
|
monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path))
|
||||||
|
altcoin_db.init_db()
|
||||||
|
scheduler_db.init_scheduler_tables()
|
||||||
|
monkeypatch.setattr(scheduler.subprocess, "Popen", _FakeProc)
|
||||||
|
monkeypatch.setattr(scheduler, "DRY_RUN", False)
|
||||||
|
|
||||||
|
jobs = {
|
||||||
|
"tracker": scheduler.Job("tracker", "tracker", 180, lock_group="tracking_write", enabled=True, next_run=0),
|
||||||
|
"screener": scheduler.Job("screener", "screener", 900, lock_group="screening_write", enabled=True, next_run=0),
|
||||||
|
}
|
||||||
|
running = {}
|
||||||
|
|
||||||
|
scheduler.schedule_due_jobs(jobs, running)
|
||||||
|
|
||||||
|
assert set(running) == {"tracker", "screener"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduler_blocks_shared_lock_and_prevents_reentry(monkeypatch, tmp_path):
|
||||||
|
db_path = tmp_path / "altcoin_monitor.db"
|
||||||
|
monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path))
|
||||||
|
altcoin_db.init_db()
|
||||||
|
scheduler_db.init_scheduler_tables()
|
||||||
|
monkeypatch.setattr(scheduler.subprocess, "Popen", _FakeProc)
|
||||||
|
monkeypatch.setattr(scheduler, "DRY_RUN", False)
|
||||||
|
|
||||||
|
jobs = {
|
||||||
|
"event": scheduler.Job("event", "event", 60, lock_group="recommendation_write", enabled=True, next_run=0),
|
||||||
|
"confirm": scheduler.Job("confirm", "confirm", 600, lock_group="recommendation_write", enabled=True, next_run=0),
|
||||||
|
}
|
||||||
|
running = {}
|
||||||
|
|
||||||
|
scheduler.schedule_due_jobs(jobs, running)
|
||||||
|
|
||||||
|
assert "event" in running
|
||||||
|
assert "confirm" not in running
|
||||||
|
assert jobs["confirm"].pending is True
|
||||||
|
|
||||||
|
again = scheduler.start_job(jobs["event"], running)
|
||||||
|
assert again is False
|
||||||
|
assert len(running) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_job_does_not_auto_run_but_manual_force_can_start(monkeypatch, tmp_path):
|
||||||
|
db_path = tmp_path / "altcoin_monitor.db"
|
||||||
|
monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path))
|
||||||
|
altcoin_db.init_db()
|
||||||
|
scheduler_db.init_scheduler_tables()
|
||||||
|
monkeypatch.setattr(scheduler.subprocess, "Popen", _FakeProc)
|
||||||
|
monkeypatch.setattr(scheduler, "DRY_RUN", False)
|
||||||
|
|
||||||
|
job = scheduler.Job("event", "event", 60, lock_group="recommendation_write", enabled=False, next_run=0)
|
||||||
|
running = {}
|
||||||
|
|
||||||
|
scheduler.schedule_due_jobs({"event": job}, running)
|
||||||
|
assert running == {}
|
||||||
|
|
||||||
|
assert scheduler.start_job(job, running, run_kind="manual", trigger_id=1) is True
|
||||||
|
assert "event" in running
|
||||||
52
tests/test_sentiment_internal_events.py
Normal file
52
tests/test_sentiment_internal_events.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
if PROJECT_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, PROJECT_DIR)
|
||||||
|
|
||||||
|
from app.db import altcoin_db
|
||||||
|
from app.services.event_driven_screener import init_event_tables
|
||||||
|
from app.web import web_server
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_db(monkeypatch, tmp_path):
|
||||||
|
db_path = tmp_path / "altcoin_monitor.db"
|
||||||
|
monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path))
|
||||||
|
monkeypatch.setattr(web_server, "init_db", altcoin_db.init_db)
|
||||||
|
monkeypatch.setenv("ALPHAX_DB_PATH", str(db_path))
|
||||||
|
altcoin_db.init_db()
|
||||||
|
init_event_tables()
|
||||||
|
return db_path
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_event(db_path, title, event_type, symbol="SOL/USDT"):
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO event_news (
|
||||||
|
event_hash, source, symbol, title, url, published_at, detected_at,
|
||||||
|
importance, event_type, raw_json, processed, decision, tech_score, rec_id, pushed
|
||||||
|
) VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'), ?, ?, '{}', 0, '', 0, 0, 0)
|
||||||
|
""",
|
||||||
|
(f"hash-{event_type}-{title}", "coingecko", symbol, title, "https://example.com", "A", event_type),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sentiment_api_hides_internal_theme_expansion_events(temp_db):
|
||||||
|
_insert_event(temp_db, "[主题扩散:solana_meme] SOL(Solana) enters CoinGecko Trending #4", "theme_expansion")
|
||||||
|
_insert_event(temp_db, "Binance Will List ABCUSDT Perpetual Contracts", "major_listing_or_contract", "ABC/USDT")
|
||||||
|
|
||||||
|
client = TestClient(web_server.app)
|
||||||
|
resp = client.get("/api/sentiment?hours=24")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
titles = [item["title"] for item in resp.json()["events"]]
|
||||||
|
assert "Binance Will List ABCUSDT Perpetual Contracts" in titles
|
||||||
|
assert not any("主题扩散" in title for title in titles)
|
||||||
Loading…
Reference in New Issue
Block a user