diff --git a/ai_client.py b/ai_client.py index a699b32..5c2cd06 100644 --- a/ai_client.py +++ b/ai_client.py @@ -55,10 +55,24 @@ def _use_openai() -> bool: return _ai_provider() in ("openai", "openai_compatible", "gateway") -def _read_image_base64(image_path: str) -> Optional[str]: +def _image_mime_for_path(path: str) -> str: + ext = os.path.splitext(str(path or ""))[1].lower() + if ext == ".png": + return "image/png" + if ext in (".jpg", ".jpeg"): + return "image/jpeg" + if ext == ".webp": + return "image/webp" + if ext == ".gif": + return "image/gif" + return "image/jpeg" + + +def _read_image_base64(image_path: str) -> Optional[tuple]: try: with open(image_path, "rb") as f: - return base64.b64encode(f.read()).decode("utf-8") + b64 = base64.b64encode(f.read()).decode("utf-8") + return b64, _image_mime_for_path(image_path) except Exception: return None @@ -66,15 +80,15 @@ def _read_image_base64(image_path: str) -> Optional[str]: def _collect_images( image_paths: Optional[Sequence[str]] = None, images_b64: Optional[Sequence[str]] = None, -) -> List[str]: - out: List[str] = [] +) -> List[tuple]: + out: List[tuple] = [] for p in image_paths or []: - b = _read_image_base64(p) - if b: - out.append(b) + item = _read_image_base64(p) + if item: + out.append(item) for b in images_b64 or []: if b: - out.append(str(b)) + out.append((str(b), "image/jpeg")) return out @@ -85,7 +99,7 @@ def _openai_chat_url() -> str: return f"{base}/chat/completions" -def _generate_openai(prompt: str, images: List[str], temperature: float) -> str: +def _generate_openai(prompt: str, images: List[tuple], temperature: float) -> str: api_key = _openai_api_key() if not api_key: return "AI 调用失败:未配置 OPENAI_API_KEY(请在当前实例目录 .env 中设置,修改后需重启服务)" @@ -95,11 +109,11 @@ def _generate_openai(prompt: str, images: List[str], temperature: float) -> str: } if images: content: List[dict] = [{"type": "text", "text": prompt}] - for b64 in images: + for b64, mime in images: content.append( { "type": "image_url", - "image_url": {"url": f"data:image/jpeg;base64,{b64}"}, + "image_url": {"url": f"data:{mime};base64,{b64}"}, } ) messages = [{"role": "user", "content": content}] @@ -126,7 +140,7 @@ def _generate_openai(prompt: str, images: List[str], temperature: float) -> str: return (msg.get("content") or "").strip() or "AI 生成失败:空内容" -def _generate_ollama(prompt: str, images: List[str], temperature: float) -> str: +def _generate_ollama(prompt: str, images: List[tuple], temperature: float) -> str: payload = { "model": _ollama_model(), "prompt": prompt, @@ -134,7 +148,7 @@ def _generate_ollama(prompt: str, images: List[str], temperature: float) -> str: "options": {"temperature": temperature}, } if images: - payload["images"] = images + payload["images"] = [b64 for b64, _mime in images] r = requests.post(_ollama_api(), json=payload, timeout=_ai_timeout_seconds()) r.raise_for_status() return (r.json().get("response") or "").strip() or "AI 生成失败" @@ -167,6 +181,12 @@ def ai_generate( def ai_review(trades_text: str, period_title: str, image_paths=None) -> str: + n_img = len(image_paths or []) + attach_note = ( + f"【系统说明:已向模型附带 {n_img} 张复盘附图(自动K线或上传截图),请结合附图分析第5节。】\n\n" + if n_img + else "【系统说明:本次未附带复盘附图,第5节请写明「无附图,无法看图」;保存复盘记录时可勾选「自动生成K线图」。】\n\n" + ) prompt = f""" 你是一位专业交易教练。下面是用户的{period_title}交易记录,请做简洁、可执行的复盘(中文)。 @@ -188,7 +208,7 @@ def ai_review(trades_text: str, period_title: str, image_paths=None) -> str: 交易记录: {trades_text} """.strip() - return ai_generate(prompt, image_paths=image_paths, temperature=0.2) + return attach_note + ai_generate(prompt, image_paths=image_paths, temperature=0.2) def ai_short_advice(prompt_text: str) -> str: diff --git a/ai_review_lib.py b/ai_review_lib.py new file mode 100644 index 0000000..1b4cb0d --- /dev/null +++ b/ai_review_lib.py @@ -0,0 +1,110 @@ +"""AI 日复盘 / 周复盘:附图收集(各实例共用)。""" +from __future__ import annotations + +import os +import uuid +from typing import Callable, List, Optional, Sequence + +from journal_chart_lib import ( + JOURNAL_CHART_ANCHOR_CLOSE, + JOURNAL_CHART_DEFAULT_LIMIT, + JOURNAL_CHART_DEFAULT_TF1, + JOURNAL_CHART_DEFAULT_TF2, + normalize_chart_timeframe, +) + + +def collect_images_for_ai_review( + rows: Sequence, + upload_folder: str, + *, + build_chart_if_missing: Optional[Callable] = None, +) -> List[str]: + """ + 收集传给视觉模型的本地图片路径。 + - 优先 journal_entries.image 已存附图; + - 若无附图且提供 build_chart_if_missing,则临时生成 K 线图。 + """ + paths: List[str] = [] + seen = set() + upload_folder = os.path.abspath(upload_folder or "") + for row in rows or []: + candidate = None + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + img = row["image"] if "image" in keys else None + if img: + candidate = os.path.join(upload_folder, str(img).strip()) + elif build_chart_if_missing: + try: + candidate = build_chart_if_missing(row) + except Exception: + candidate = None + if not candidate: + continue + candidate = os.path.abspath(candidate) + if os.path.isfile(candidate) and candidate not in seen: + seen.add(candidate) + paths.append(candidate) + return paths + + +def build_journal_ai_chart_path( + row, + upload_folder: str, + *, + order_chart_enabled: bool, + normalize_exchange_symbol_fn: Callable[[str], str], + generate_chart_fn: Callable, + local_datetime_to_ms_fn: Callable[[str], Optional[int]], + now_ts_ms_fn: Callable[[], int], +) -> Optional[str]: + """无已存附图时,按复盘记录开平仓时间临时生成 K 线图路径。""" + if not order_chart_enabled: + return None + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + return None + coin = (row["coin"] if "coin" in keys else "") or "" + coin = str(coin).strip() + if not coin: + return None + try: + symbol = normalize_exchange_symbol_fn(coin) + except Exception: + return None + open_dt = row["open_datetime"] if "open_datetime" in keys else "" + close_dt = row["close_datetime"] if "close_datetime" in keys else "" + entry_ms = local_datetime_to_ms_fn(open_dt) + exit_ms = local_datetime_to_ms_fn(close_dt) + if not entry_ms: + return None + row_tf = row["tf"] if "tf" in keys else "" + tf1 = normalize_chart_timeframe(row_tf) or JOURNAL_CHART_DEFAULT_TF1 + tf2 = JOURNAL_CHART_DEFAULT_TF2 if tf1 != JOURNAL_CHART_DEFAULT_TF2 else "1h" + row_id = str(row["id"] if "id" in keys else "")[:8] or uuid.uuid4().hex[:8] + marker = { + "entry_ts_ms": entry_ms, + "exit_ts_ms": exit_ms, + "chart_anchor": JOURNAL_CHART_ANCHOR_CLOSE, + "now_ts_ms": int(now_ts_ms_fn()), + } + fname = f"ai_rev_{row_id}_{uuid.uuid4().hex[:6]}.png" + saved = generate_chart_fn( + symbol, + f"AI复盘 {coin}", + timeframes=[tf1, tf2], + limit=JOURNAL_CHART_DEFAULT_LIMIT, + out_dir=upload_folder, + filename=fname, + marker_payload=marker, + marker_timeframes={tf1, tf2}, + layout="vertical", + ) + if not saved: + return None + path = os.path.join(upload_folder, saved) + return path if os.path.isfile(path) else None diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index e1a28bf..6145ea8 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -35,6 +35,7 @@ import sys if _REPO_ROOT not in sys.path: sys.path.insert(0, _REPO_ROOT) from ai_client import ai_generate, ai_review, ai_short_advice +from ai_review_lib import build_journal_ai_chart_path, collect_images_for_ai_review from fib_key_monitor_lib import ( FIB_KEY_MONITOR_TYPES, calc_fib_plan, @@ -7743,6 +7744,18 @@ def manual_transfer(): return redirect("/") +def _journal_ai_chart_builder(row): + return build_journal_ai_chart_path( + row, + app.config["UPLOAD_FOLDER"], + order_chart_enabled=ORDER_CHART_ENABLED, + normalize_exchange_symbol_fn=lambda c: normalize_exchange_symbol(normalize_symbol_input(c)), + generate_chart_fn=generate_multi_timeframe_chart_png, + local_datetime_to_ms_fn=_local_input_datetime_to_ms, + now_ts_ms_fn=lambda: int(app_now().timestamp() * 1000), + ) + + @app.route("/ai_daily_review", methods=["POST"]) @login_required def ai_daily_review(): @@ -7761,14 +7774,11 @@ def ai_daily_review(): text += _journal_row_lines_for_ai(idx, row) text += "\n" - image_paths = [] - for row in rows: - img = row["image"] - if not img: - continue - img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) - if os.path.exists(img_path): - image_paths.append(img_path) + image_paths = collect_images_for_ai_review( + rows, + app.config["UPLOAD_FOLDER"], + build_chart_if_missing=_journal_ai_chart_builder, + ) ai_result = ai_review(text, "每日", image_paths=image_paths) full = f"【AI日复盘 {date}】\n{ai_result}\n\n原始记录:\n{text}" conn = get_db() @@ -7800,14 +7810,11 @@ def ai_weekly_review(): text += _journal_row_lines_for_ai(idx, row) text += "\n" - image_paths = [] - for row in rows: - img = row["image"] - if not img: - continue - img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) - if os.path.exists(img_path): - image_paths.append(img_path) + image_paths = collect_images_for_ai_review( + rows, + app.config["UPLOAD_FOLDER"], + build_chart_if_missing=_journal_ai_chart_builder, + ) ai_result = ai_review(text, "周度", image_paths=image_paths) full = f"【AI周复盘 {start_date}~{end_date}】\n{ai_result}\n\n原始记录:\n{text}" conn = get_db() diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index 24761a6..813ccf8 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -35,6 +35,7 @@ import sys if _REPO_ROOT not in sys.path: sys.path.insert(0, _REPO_ROOT) from ai_client import ai_generate, ai_review, ai_short_advice +from ai_review_lib import build_journal_ai_chart_path, collect_images_for_ai_review from fib_key_monitor_lib import ( FIB_KEY_MONITOR_TYPES, KEY_ENTRY_REASON_BY_SIGNAL, @@ -7832,6 +7833,18 @@ def manual_transfer(): return redirect("/") +def _journal_ai_chart_builder(row): + return build_journal_ai_chart_path( + row, + app.config["UPLOAD_FOLDER"], + order_chart_enabled=ORDER_CHART_ENABLED, + normalize_exchange_symbol_fn=lambda c: normalize_exchange_symbol(normalize_symbol_input(c)), + generate_chart_fn=generate_multi_timeframe_chart_png, + local_datetime_to_ms_fn=_local_input_datetime_to_ms, + now_ts_ms_fn=lambda: int(app_now().timestamp() * 1000), + ) + + @app.route("/ai_daily_review", methods=["POST"]) @login_required def ai_daily_review(): @@ -7850,14 +7863,11 @@ def ai_daily_review(): text += _journal_row_lines_for_ai(idx, row) text += "\n" - image_paths = [] - for row in rows: - img = row["image"] - if not img: - continue - img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) - if os.path.exists(img_path): - image_paths.append(img_path) + image_paths = collect_images_for_ai_review( + rows, + app.config["UPLOAD_FOLDER"], + build_chart_if_missing=_journal_ai_chart_builder, + ) ai_result = ai_review(text, "每日", image_paths=image_paths) full = f"【AI日复盘 {date}】\n{ai_result}\n\n原始记录:\n{text}" conn = get_db() @@ -7889,14 +7899,11 @@ def ai_weekly_review(): text += _journal_row_lines_for_ai(idx, row) text += "\n" - image_paths = [] - for row in rows: - img = row["image"] - if not img: - continue - img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) - if os.path.exists(img_path): - image_paths.append(img_path) + image_paths = collect_images_for_ai_review( + rows, + app.config["UPLOAD_FOLDER"], + build_chart_if_missing=_journal_ai_chart_builder, + ) ai_result = ai_review(text, "周度", image_paths=image_paths) full = f"【AI周复盘 {start_date}~{end_date}】\n{ai_result}\n\n原始记录:\n{text}" conn = get_db() diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py index 83a9942..32833a6 100644 --- a/crypto_monitor_gate_bot/app.py +++ b/crypto_monitor_gate_bot/app.py @@ -35,6 +35,7 @@ import sys if _REPO_ROOT not in sys.path: sys.path.insert(0, _REPO_ROOT) from ai_client import ai_generate, ai_review, ai_short_advice +from ai_review_lib import build_journal_ai_chart_path, collect_images_for_ai_review from journal_chart_lib import ( JOURNAL_CHART_DEFAULT_LIMIT, JOURNAL_CHART_DEFAULT_TF1, @@ -7230,6 +7231,18 @@ def manual_transfer(): return redirect("/") +def _journal_ai_chart_builder(row): + return build_journal_ai_chart_path( + row, + app.config["UPLOAD_FOLDER"], + order_chart_enabled=ORDER_CHART_ENABLED, + normalize_exchange_symbol_fn=lambda c: normalize_exchange_symbol(normalize_symbol_input(c)), + generate_chart_fn=generate_multi_timeframe_chart_png, + local_datetime_to_ms_fn=_local_input_datetime_to_ms, + now_ts_ms_fn=lambda: int(app_now().timestamp() * 1000), + ) + + @app.route("/ai_daily_review", methods=["POST"]) @login_required def ai_daily_review(): @@ -7248,14 +7261,11 @@ def ai_daily_review(): text += _journal_row_lines_for_ai(idx, row) text += "\n" - image_paths = [] - for row in rows: - img = row["image"] - if not img: - continue - img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) - if os.path.exists(img_path): - image_paths.append(img_path) + image_paths = collect_images_for_ai_review( + rows, + app.config["UPLOAD_FOLDER"], + build_chart_if_missing=_journal_ai_chart_builder, + ) ai_result = ai_review(text, "每日", image_paths=image_paths) full = f"【AI日复盘 {date}】\n{ai_result}\n\n原始记录:\n{text}" conn = get_db() @@ -7287,14 +7297,11 @@ def ai_weekly_review(): text += _journal_row_lines_for_ai(idx, row) text += "\n" - image_paths = [] - for row in rows: - img = row["image"] - if not img: - continue - img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) - if os.path.exists(img_path): - image_paths.append(img_path) + image_paths = collect_images_for_ai_review( + rows, + app.config["UPLOAD_FOLDER"], + build_chart_if_missing=_journal_ai_chart_builder, + ) ai_result = ai_review(text, "周度", image_paths=image_paths) full = f"【AI周复盘 {start_date}~{end_date}】\n{ai_result}\n\n原始记录:\n{text}" conn = get_db() diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 4add1be..f9437ec 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -35,6 +35,7 @@ import sys if _REPO_ROOT not in sys.path: sys.path.insert(0, _REPO_ROOT) from ai_client import ai_generate, ai_review, ai_short_advice +from ai_review_lib import build_journal_ai_chart_path, collect_images_for_ai_review from fib_key_monitor_lib import ( FIB_KEY_MONITOR_TYPES, calc_fib_plan, @@ -7138,6 +7139,18 @@ def manual_transfer(): return redirect("/") +def _journal_ai_chart_builder(row): + return build_journal_ai_chart_path( + row, + app.config["UPLOAD_FOLDER"], + order_chart_enabled=ORDER_CHART_ENABLED, + normalize_exchange_symbol_fn=lambda c: normalize_exchange_symbol(normalize_symbol_input(c)), + generate_chart_fn=generate_multi_timeframe_chart_png, + local_datetime_to_ms_fn=_local_input_datetime_to_ms, + now_ts_ms_fn=lambda: int(app_now().timestamp() * 1000), + ) + + @app.route("/ai_daily_review", methods=["POST"]) @login_required def ai_daily_review(): @@ -7163,14 +7176,11 @@ def ai_daily_review(): f" 问题:{issues}\n\n" ) - image_paths = [] - for row in rows: - img = row["image"] - if not img: - continue - img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) - if os.path.exists(img_path): - image_paths.append(img_path) + image_paths = collect_images_for_ai_review( + rows, + app.config["UPLOAD_FOLDER"], + build_chart_if_missing=_journal_ai_chart_builder, + ) ai_result = ai_review(text, "每日", image_paths=image_paths) full = f"【AI日复盘 {date}】\n{ai_result}\n\n原始记录:\n{text}" conn = get_db() @@ -7208,14 +7218,11 @@ def ai_weekly_review(): f" 心态标签:{issues} | 持仓:{row['hold_duration']}\n\n" ) - image_paths = [] - for row in rows: - img = row["image"] - if not img: - continue - img_path = os.path.join(app.config["UPLOAD_FOLDER"], img) - if os.path.exists(img_path): - image_paths.append(img_path) + image_paths = collect_images_for_ai_review( + rows, + app.config["UPLOAD_FOLDER"], + build_chart_if_missing=_journal_ai_chart_builder, + ) ai_result = ai_review(text, "周度", image_paths=image_paths) full = f"【AI周复盘 {start_date}~{end_date}】\n{ai_result}\n\n原始记录:\n{text}" conn = get_db()