diff --git a/app/services/chat_assistant.py b/app/services/chat_assistant.py index ee4431b..4d69a20 100644 --- a/app/services/chat_assistant.py +++ b/app/services/chat_assistant.py @@ -21,7 +21,7 @@ from app.db import chat_assistant_db from app.db.analytics import get_pipeline_runs from app.db.llm_insights import compute_input_hash, repair_mojibake_json, repair_mojibake_text from app.db.schema import get_conn -from app.services.llm_insights import get_llm_params +from app.services.llm_insights import _message_content_text, _parse_json_object_text, get_llm_params from app.services.market_overview import get_crypto_market_overview @@ -616,11 +616,9 @@ def _call_chat_llm(message: str, context: dict, history=None) -> dict: if resp.status_code >= 400: return {"status": "failed", "error": f"http_{resp.status_code}:{resp.text[:300]}", "model": model} data = resp.json() - content = (((data.get("choices") or [{}])[0]).get("message") or {}).get("content") or "{}" + content = _message_content_text(((data.get("choices") or [{}])[0]).get("message") or {}) content = repair_mojibake_text(content) - parsed = repair_mojibake_json(json.loads(content)) - if not isinstance(parsed, dict): - raise ValueError("llm_output_not_object") + parsed = repair_mojibake_json(_parse_json_object_text(content)) parsed.setdefault("summary", parsed.get("answer", "")[:80]) parsed.setdefault("answer_style", _answer_style_for_intent(context.get("intent"))) parsed.setdefault("evidence", []) diff --git a/app/services/llm_insights.py b/app/services/llm_insights.py index 35f3b55..968f852 100644 --- a/app/services/llm_insights.py +++ b/app/services/llm_insights.py @@ -76,6 +76,44 @@ def _parse_insight_payload(content): return content +def _message_content_text(message: dict) -> str: + content = (message or {}).get("content") + if isinstance(content, str): + return content.strip() + if isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict): + parts.append(str(item.get("text") or item.get("content") or "")) + return "\n".join(x for x in parts if x).strip() + return "" + + +def _parse_json_object_text(content: str) -> dict: + text = str(content or "").strip() + if not text: + raise ValueError("llm_empty_content") + if text.startswith("```"): + text = text.strip("`").strip() + if text.lower().startswith("json"): + text = text[4:].strip() + try: + parsed = json.loads(text) + except json.JSONDecodeError: + start = text.find("{") + end = text.rfind("}") + if start < 0 or end <= start: + raise + parsed = json.loads(text[start:end + 1]) + if not isinstance(parsed, dict): + raise ValueError("llm_output_not_object") + if not parsed: + raise ValueError("llm_empty_json_object") + return parsed + + 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() @@ -113,10 +151,9 @@ def _call_llm_json(prompt_version, payload): 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") + message = (((data.get("choices") or [{}])[0]).get("message") or {}) + content = _message_content_text(message) + parsed = _parse_json_object_text(content) return {"status": "success", "content": parsed, "model": model} except json.JSONDecodeError as exc: return {"status": "failed", "error": f"invalid_json:{exc}", "model": model} diff --git a/tests/test_llm_insights.py b/tests/test_llm_insights.py index 6376fdb..fa4b69e 100644 --- a/tests/test_llm_insights.py +++ b/tests/test_llm_insights.py @@ -125,6 +125,60 @@ def test_invalid_json_is_marked_failed(monkeypatch, temp_db): assert row["status"] == "failed" +def test_llm_empty_content_is_not_marked_success(monkeypatch): + monkeypatch.setattr(llm_insights, "get_llm_params", lambda: { + "enabled": True, + "base_url": "https://llm.example/v1", + "api_key_env": "TEST_LLM_KEY", + "model": "deepseek-v4-pro", + "timeout": 5, + "max_tokens": 100, + "modules": {}, + }) + monkeypatch.setenv("TEST_LLM_KEY", "test-key") + + class Resp: + status_code = 200 + text = "{}" + + def json(self): + return {"choices": [{"message": {"content": ""}}]} + + monkeypatch.setattr(llm_insights.requests, "post", lambda *args, **kwargs: Resp()) + + result = llm_insights._call_llm_json("test_prompt", {"symbol": "AAA/USDT"}) + + assert result["status"] == "failed" + assert "llm_empty_content" in result["error"] + + +def test_llm_json_code_fence_is_parsed(monkeypatch): + monkeypatch.setattr(llm_insights, "get_llm_params", lambda: { + "enabled": True, + "base_url": "https://llm.example/v1", + "api_key_env": "TEST_LLM_KEY", + "model": "deepseek-v4-pro", + "timeout": 5, + "max_tokens": 100, + "modules": {}, + }) + monkeypatch.setenv("TEST_LLM_KEY", "test-key") + + class Resp: + status_code = 200 + text = "{}" + + def json(self): + return {"choices": [{"message": {"content": "```json\n{\"summary\":\"ok\"}\n```"}}]} + + monkeypatch.setattr(llm_insights.requests, "post", lambda *args, **kwargs: Resp()) + + result = llm_insights._call_llm_json("test_prompt", {"symbol": "AAA/USDT"}) + + assert result["status"] == "success" + assert result["content"]["summary"] == "ok" + + 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")