1
This commit is contained in:
parent
ce6a87fd19
commit
40dc2e07fe
@ -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:
|
||||||
|
|||||||
@ -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"),
|
||||||
|
|||||||
@ -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()">
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user