This commit is contained in:
aaron 2026-06-08 09:06:34 +08:00
parent ce6a87fd19
commit 40dc2e07fe
4 changed files with 103 additions and 23 deletions

View File

@ -114,6 +114,33 @@ def _parse_json_object_text(content: str) -> dict:
return parsed return parsed
def _chat_completion_request(base_url, api_key, model, messages, max_tokens, timeout, *, response_format=True):
body = {
"model": model,
"messages": messages,
"temperature": 0.2,
"max_tokens": max_tokens,
}
if response_format:
body["response_format"] = {"type": "json_object"}
return requests.post(
f"{base_url}/chat/completions",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json=body,
timeout=timeout,
)
def _parse_llm_response(resp, model):
if resp.status_code >= 400:
return {"status": "failed", "error": f"http_{resp.status_code}", "raw": resp.text[:1000], "model": model}
data = resp.json()
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}
def _call_llm_json(prompt_version, payload): def _call_llm_json(prompt_version, payload):
params = get_llm_params() params = get_llm_params()
api_key = os.getenv(str(params.get("api_key_env") or "OPENAI_API_KEY"), "").strip() api_key = os.getenv(str(params.get("api_key_env") or "OPENAI_API_KEY"), "").strip()
@ -132,29 +159,38 @@ def _call_llm_json(prompt_version, payload):
"input": payload, "input": payload,
"output_schema_hint": "JSON object with concise Chinese fields only", "output_schema_hint": "JSON object with concise Chinese fields only",
}) })
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
try: try:
resp = requests.post( resp = _chat_completion_request(base_url, api_key, model, messages, max_tokens, timeout, response_format=True)
f"{base_url}/chat/completions", first = _parse_llm_response(resp, model)
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, if first.get("status") == "success":
json={ return first
"model": model, return first
"messages": [ except ValueError as exc:
{"role": "system", "content": system_prompt}, # Some OpenAI-compatible providers occasionally return an empty message
{"role": "user", "content": user_prompt}, # when strict JSON response_format is enabled. Retry once with prompt-only
], # JSON enforcement so the job does not fail silently on provider quirks.
"temperature": 0.2, retryable = str(exc) in ("llm_empty_content", "llm_empty_json_object")
"max_tokens": max_tokens, if not retryable:
"response_format": {"type": "json_object"}, return {"status": "failed", "error": str(exc)[:1000], "model": model}
}, try:
timeout=timeout, retry_messages = [
) {
if resp.status_code >= 400: "role": "system",
return {"status": "failed", "error": f"http_{resp.status_code}", "raw": resp.text[:1000], "model": model} "content": system_prompt + " Return one valid JSON object. No markdown, no prose.",
data = resp.json() },
message = (((data.get("choices") or [{}])[0]).get("message") or {}) {"role": "user", "content": user_prompt},
content = _message_content_text(message) ]
parsed = _parse_json_object_text(content) resp = _chat_completion_request(base_url, api_key, model, retry_messages, max_tokens, timeout, response_format=False)
return {"status": "success", "content": parsed, "model": model} result = _parse_llm_response(resp, model)
if result.get("status") == "success":
result["retry"] = "without_response_format"
return result
except Exception as retry_exc:
return {"status": "failed", "error": f"{exc}; retry_failed:{str(retry_exc)[:800]}", "model": model}
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
return {"status": "failed", "error": f"invalid_json:{exc}", "model": model} return {"status": "failed", "error": f"invalid_json:{exc}", "model": model}
except Exception as exc: except Exception as exc:

View File

@ -34,6 +34,7 @@ def _friendly_llm_item(item):
type_label = { type_label = {
"recommendation": "推荐解释", "recommendation": "推荐解释",
"sentiment": "舆情解读", "sentiment": "舆情解读",
"sentiment_batch": "批量舆情分析",
"review": "复盘 memo", "review": "复盘 memo",
}.get(target_type, target_type or "未知任务") }.get(target_type, target_type or "未知任务")
status_label = { status_label = {
@ -41,7 +42,7 @@ def _friendly_llm_item(item):
"failed": "失败", "failed": "失败",
"skipped": "跳过", "skipped": "跳过",
}.get(status, status or "未知") }.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") subject = payload.get("symbol") or payload.get("related_symbol") or payload.get("title") or payload.get("run_date") or payload.get("target_id") 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 "" 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 { return {
"id": item.get("id"), "id": item.get("id"),

View File

@ -17,6 +17,7 @@
<option value="">全部类型</option> <option value="">全部类型</option>
<option value="recommendation">推荐解释</option> <option value="recommendation">推荐解释</option>
<option value="sentiment">舆情解读</option> <option value="sentiment">舆情解读</option>
<option value="sentiment_batch">批量舆情分析</option>
<option value="review">复盘 memo</option> <option value="review">复盘 memo</option>
</select> </select>
<select class="select" id="statusSel" onchange="reloadFirst()"> <select class="select" id="statusSel" onchange="reloadFirst()">

View File

@ -152,6 +152,48 @@ def test_llm_empty_content_is_not_marked_success(monkeypatch):
assert "llm_empty_content" in result["error"] assert "llm_empty_content" in result["error"]
def test_llm_empty_content_retries_without_response_format(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")
calls = []
class EmptyResp:
status_code = 200
text = "{}"
def json(self):
return {"choices": [{"message": {"content": ""}}]}
class JsonResp:
status_code = 200
text = "{}"
def json(self):
return {"choices": [{"message": {"content": "{\"summary\":\"retry ok\"}"}}]}
def fake_post(*args, **kwargs):
calls.append(kwargs.get("json") or {})
return EmptyResp() if len(calls) == 1 else JsonResp()
monkeypatch.setattr(llm_insights.requests, "post", fake_post)
result = llm_insights._call_llm_json("test_prompt", {"symbol": "AAA/USDT"})
assert result["status"] == "success"
assert result["content"]["summary"] == "retry ok"
assert result["retry"] == "without_response_format"
assert calls[0]["response_format"] == {"type": "json_object"}
assert "response_format" not in calls[1]
def test_llm_json_code_fence_is_parsed(monkeypatch): def test_llm_json_code_fence_is_parsed(monkeypatch):
monkeypatch.setattr(llm_insights, "get_llm_params", lambda: { monkeypatch.setattr(llm_insights, "get_llm_params", lambda: {
"enabled": True, "enabled": True,