fix: harden ontology JSON generation
This commit is contained in:
parent
d64175943e
commit
f0d487f310
|
|
@ -4,6 +4,7 @@
|
|||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import traceback
|
||||
import threading
|
||||
from contextlib import ExitStack, nullcontext
|
||||
|
|
@ -24,6 +25,7 @@ from ..models.project import ProjectManager, ProjectStatus
|
|||
from ..services.simulation_manager import SimulationManager
|
||||
from ..services.simulation_runner import SimulationRunner, RunnerStatus
|
||||
from ..services.zep_graph_memory_updater import ZepGraphMemoryManager
|
||||
from ..utils.llm_client import LLMResponseError
|
||||
|
||||
# 获取日志器
|
||||
logger = get_logger('mirofish.api')
|
||||
|
|
@ -288,6 +290,7 @@ def generate_ontology():
|
|||
}
|
||||
}
|
||||
"""
|
||||
project = None
|
||||
try:
|
||||
logger.info("=== 开始生成本体定义 ===")
|
||||
|
||||
|
|
@ -388,12 +391,56 @@ def generate_ontology():
|
|||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
except Exception as error:
|
||||
provider_status = getattr(error, "status_code", None)
|
||||
request_id = getattr(error, "request_id", None)
|
||||
|
||||
if isinstance(error, LLMResponseError):
|
||||
public_error = str(error)
|
||||
response_status = 502
|
||||
logger.exception("LLM returned an unusable ontology response")
|
||||
elif isinstance(provider_status, int):
|
||||
public_error = f"LLM provider request failed (HTTP {provider_status})"
|
||||
if request_id:
|
||||
safe_request_id = re.sub(
|
||||
r"[^a-zA-Z0-9._:-]", "", str(request_id)
|
||||
)[:128]
|
||||
if safe_request_id:
|
||||
public_error += f" (request_id: {safe_request_id})"
|
||||
response_status = 502
|
||||
# Provider exception bodies may echo request content. Keep the
|
||||
# server log useful without serializing the exception body.
|
||||
logger.error(
|
||||
"Ontology provider request failed: type=%s status=%s request_id=%s",
|
||||
type(error).__name__,
|
||||
provider_status,
|
||||
request_id or "unknown",
|
||||
)
|
||||
else:
|
||||
public_error = "Ontology generation failed; check the server logs"
|
||||
response_status = 500
|
||||
logger.exception("Unexpected ontology generation failure")
|
||||
|
||||
response_data = None
|
||||
if project is not None:
|
||||
project.status = ProjectStatus.FAILED
|
||||
project.error = public_error
|
||||
try:
|
||||
ProjectManager.save_project(project)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to persist ontology failure for project %s",
|
||||
project.project_id,
|
||||
)
|
||||
response_data = {"project_id": project.project_id}
|
||||
|
||||
payload = {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
}), 500
|
||||
"error": public_error,
|
||||
}
|
||||
if response_data is not None:
|
||||
payload["data"] = response_data
|
||||
return jsonify(payload), response_status
|
||||
|
||||
|
||||
# ============== 接口2:构建图谱 ==============
|
||||
|
|
|
|||
|
|
@ -235,7 +235,11 @@ class OntologyGenerator:
|
|||
result = self.llm_client.chat_json(
|
||||
messages=messages,
|
||||
temperature=0.3,
|
||||
max_tokens=4096
|
||||
# Structured ontology responses can exceed 4096 completion tokens,
|
||||
# especially when a compatible provider counts hidden reasoning in
|
||||
# the same budget. Let the provider use its model-specific limit.
|
||||
max_tokens=None,
|
||||
max_attempts=2,
|
||||
)
|
||||
|
||||
# 验证和后处理
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ LLM客户端封装
|
|||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional, Dict, Any, List
|
||||
from openai import OpenAI
|
||||
|
|
@ -12,6 +13,81 @@ from ..config import Config
|
|||
from .openai_chat_compat import create_chat_completion, extract_chat_completion_text
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMResponseError(ValueError):
|
||||
"""A safe, structured error for unusable model responses."""
|
||||
|
||||
def __init__(self, message: str, *, finish_reason: Optional[str] = None):
|
||||
super().__init__(message)
|
||||
self.finish_reason = finish_reason
|
||||
|
||||
|
||||
def _is_response_format_unsupported(error: Exception) -> bool:
|
||||
"""Detect an explicit provider rejection of JSON response_format."""
|
||||
|
||||
if getattr(error, "status_code", None) not in {400, 422}:
|
||||
return False
|
||||
|
||||
body = getattr(error, "body", None)
|
||||
if not isinstance(body, dict):
|
||||
return False
|
||||
|
||||
details = body.get("error", body)
|
||||
if not isinstance(details, dict):
|
||||
return False
|
||||
|
||||
param = str(details.get("param") or "").strip().lower()
|
||||
if param == "response_format" or param.startswith("response_format."):
|
||||
return True
|
||||
|
||||
message = str(details.get("message") or "").lower()
|
||||
if "response_format" not in message:
|
||||
return False
|
||||
|
||||
code = str(details.get("code") or "").lower()
|
||||
unsupported_codes = {
|
||||
"unsupported_parameter",
|
||||
"unsupported_value",
|
||||
"unknown_parameter",
|
||||
"invalid_parameter",
|
||||
}
|
||||
unsupported_phrases = (
|
||||
"not support",
|
||||
"unsupported",
|
||||
"unknown parameter",
|
||||
"unrecognized parameter",
|
||||
)
|
||||
return code in unsupported_codes or any(
|
||||
phrase in message for phrase in unsupported_phrases
|
||||
)
|
||||
|
||||
|
||||
def _clean_chat_text(content: str) -> str:
|
||||
"""Remove common reasoning wrappers and an outer Markdown JSON fence."""
|
||||
|
||||
cleaned = re.sub(r'<think>[\s\S]*?</think>', '', content).strip()
|
||||
cleaned = cleaned.lstrip("\ufeff")
|
||||
cleaned = re.sub(r'^```(?:json)?\s*\n?', '', cleaned, flags=re.IGNORECASE)
|
||||
cleaned = re.sub(r'\n?```\s*$', '', cleaned)
|
||||
return cleaned.strip()
|
||||
|
||||
|
||||
def _contains_additional_json_container(content: str) -> bool:
|
||||
"""Return True when trailing text embeds another JSON object or array."""
|
||||
|
||||
decoder = json.JSONDecoder()
|
||||
for match in re.finditer(r"[\[{]", content):
|
||||
try:
|
||||
value, _ = decoder.raw_decode(content[match.start():])
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(value, (dict, list)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class LLMClient:
|
||||
"""LLM客户端"""
|
||||
|
||||
|
|
@ -32,12 +108,31 @@ class LLMClient:
|
|||
api_key=self.api_key,
|
||||
base_url=self.base_url
|
||||
)
|
||||
|
||||
def _create_completion(
|
||||
self,
|
||||
*,
|
||||
messages: List[Dict[str, str]],
|
||||
temperature: Optional[float],
|
||||
max_tokens: Optional[int],
|
||||
response_format: Optional[Dict[str, Any]],
|
||||
) -> Any:
|
||||
"""Send one raw Chat Completions request through the compatibility layer."""
|
||||
|
||||
return create_chat_completion(
|
||||
self.client,
|
||||
model=self.model,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
response_format=response_format,
|
||||
)
|
||||
|
||||
def chat(
|
||||
self,
|
||||
messages: List[Dict[str, str]],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 4096,
|
||||
max_tokens: Optional[int] = 4096,
|
||||
response_format: Optional[Dict] = None
|
||||
) -> str:
|
||||
"""
|
||||
|
|
@ -52,24 +147,21 @@ class LLMClient:
|
|||
Returns:
|
||||
模型响应文本
|
||||
"""
|
||||
response = create_chat_completion(
|
||||
self.client,
|
||||
model=self.model,
|
||||
response = self._create_completion(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
response_format=response_format,
|
||||
)
|
||||
content = extract_chat_completion_text(response)
|
||||
# 部分模型(如MiniMax M2.5)会在content中包含<think>思考内容,需要移除
|
||||
content = re.sub(r'<think>[\s\S]*?</think>', '', content).strip()
|
||||
return content
|
||||
return _clean_chat_text(content)
|
||||
|
||||
def chat_json(
|
||||
self,
|
||||
messages: List[Dict[str, str]],
|
||||
temperature: float = 0.3,
|
||||
max_tokens: int = 4096
|
||||
max_tokens: Optional[int] = 4096,
|
||||
max_attempts: int = 1,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
发送聊天请求并返回JSON
|
||||
|
|
@ -78,24 +170,121 @@ class LLMClient:
|
|||
messages: 消息列表
|
||||
temperature: 温度参数
|
||||
max_tokens: 最大token数
|
||||
max_attempts: 内容生成尝试次数(不含一次明确的JSON模式能力降级)
|
||||
|
||||
Returns:
|
||||
解析后的JSON对象
|
||||
"""
|
||||
response = self.chat(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
# 清理markdown代码块标记
|
||||
cleaned_response = response.strip()
|
||||
cleaned_response = re.sub(r'^```(?:json)?\s*\n?', '', cleaned_response, flags=re.IGNORECASE)
|
||||
cleaned_response = re.sub(r'\n?```\s*$', '', cleaned_response)
|
||||
cleaned_response = cleaned_response.strip()
|
||||
if max_attempts < 1:
|
||||
raise ValueError("max_attempts must be at least 1")
|
||||
|
||||
response_format: Optional[Dict[str, str]] = {"type": "json_object"}
|
||||
request_max_tokens = max_tokens
|
||||
last_error: Optional[LLMResponseError] = None
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
# JSON-mode capability negotiation is separate from content
|
||||
# regeneration. An explicit response_format rejection may add one
|
||||
# request, but it must not consume a content attempt.
|
||||
while True:
|
||||
try:
|
||||
response = self._create_completion(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=request_max_tokens,
|
||||
response_format=response_format,
|
||||
)
|
||||
except Exception as error:
|
||||
if (
|
||||
response_format is not None
|
||||
and _is_response_format_unsupported(error)
|
||||
):
|
||||
logger.warning(
|
||||
"LLM provider explicitly rejected response_format; "
|
||||
"retrying once with prompt-only JSON guidance"
|
||||
)
|
||||
response_format = None
|
||||
continue
|
||||
raise
|
||||
break
|
||||
|
||||
try:
|
||||
return self._parse_json_response(response)
|
||||
except LLMResponseError as error:
|
||||
last_error = error
|
||||
if attempt >= max_attempts:
|
||||
raise
|
||||
|
||||
# A caller-supplied cap is the common cause of a partial JSON
|
||||
# object. Omit it for the one bounded retry so the provider can
|
||||
# use its model-specific output limit.
|
||||
had_token_cap = request_max_tokens is not None
|
||||
request_max_tokens = None
|
||||
logger.warning(
|
||||
"LLM returned unusable JSON (finish_reason=%s); "
|
||||
"retrying content generation%s",
|
||||
error.finish_reason or "unknown",
|
||||
" without an output token cap" if had_token_cap else "",
|
||||
)
|
||||
|
||||
if last_error is not None: # pragma: no cover - defensive loop guard
|
||||
raise last_error
|
||||
raise LLMResponseError("LLM did not produce a JSON response")
|
||||
|
||||
@staticmethod
|
||||
def _parse_json_response(response: Any) -> Dict[str, Any]:
|
||||
choices = getattr(response, "choices", None) or []
|
||||
if not choices:
|
||||
raise LLMResponseError("LLM returned no choices")
|
||||
|
||||
choice = choices[0]
|
||||
finish_reason = getattr(choice, "finish_reason", None)
|
||||
if finish_reason == "length":
|
||||
raise LLMResponseError(
|
||||
"LLM JSON output was truncated at the token limit",
|
||||
finish_reason=finish_reason,
|
||||
)
|
||||
if finish_reason not in {None, "stop"}:
|
||||
raise LLMResponseError(
|
||||
f"LLM JSON generation stopped unexpectedly ({finish_reason})",
|
||||
finish_reason=finish_reason,
|
||||
)
|
||||
|
||||
content = _clean_chat_text(extract_chat_completion_text(response))
|
||||
if not content:
|
||||
raise LLMResponseError(
|
||||
"LLM returned empty JSON content",
|
||||
finish_reason=finish_reason,
|
||||
)
|
||||
|
||||
try:
|
||||
return json.loads(cleaned_response)
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError(f"LLM返回的JSON格式无效: {cleaned_response}")
|
||||
value = json.loads(content)
|
||||
except json.JSONDecodeError as strict_error:
|
||||
# Some compatible providers append a short explanation after an
|
||||
# otherwise complete JSON object. Accept only an object decoded
|
||||
# from the beginning; never repair or invent truncated JSON.
|
||||
try:
|
||||
value, end = json.JSONDecoder().raw_decode(content)
|
||||
except json.JSONDecodeError:
|
||||
raise LLMResponseError(
|
||||
"LLM returned invalid JSON "
|
||||
f"(line {strict_error.lineno}, column {strict_error.colno})",
|
||||
finish_reason=finish_reason,
|
||||
) from strict_error
|
||||
|
||||
trailing = content[end:].strip()
|
||||
if trailing:
|
||||
if _contains_additional_json_container(trailing):
|
||||
raise LLMResponseError(
|
||||
"LLM returned multiple JSON values",
|
||||
finish_reason=finish_reason,
|
||||
)
|
||||
logger.warning("Ignoring text after a complete LLM JSON object")
|
||||
|
||||
if not isinstance(value, dict):
|
||||
raise LLMResponseError(
|
||||
"LLM JSON response must be a top-level JSON object",
|
||||
finish_reason=finish_reason,
|
||||
)
|
||||
|
||||
return value
|
||||
|
|
|
|||
|
|
@ -0,0 +1,259 @@
|
|||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.utils.llm_client import LLMClient, LLMResponseError
|
||||
|
||||
|
||||
class CompletionSequence:
|
||||
def __init__(self, *results):
|
||||
self.results = list(results)
|
||||
self.calls = []
|
||||
|
||||
def create(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
result = self.results.pop(0)
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
return result
|
||||
|
||||
|
||||
class ProviderError(RuntimeError):
|
||||
def __init__(self, *, status_code, body):
|
||||
super().__init__(body.get("error", {}).get("message", "provider error"))
|
||||
self.status_code = status_code
|
||||
self.body = body
|
||||
|
||||
|
||||
def _response(content, *, finish_reason="stop", include_choice=True):
|
||||
choices = []
|
||||
if include_choice:
|
||||
choices.append(
|
||||
SimpleNamespace(
|
||||
finish_reason=finish_reason,
|
||||
message=SimpleNamespace(content=content),
|
||||
)
|
||||
)
|
||||
return SimpleNamespace(choices=choices)
|
||||
|
||||
|
||||
def _client_for(sequence):
|
||||
client = object.__new__(LLMClient)
|
||||
client.model = "compatible-model"
|
||||
client.client = SimpleNamespace(
|
||||
chat=SimpleNamespace(completions=sequence)
|
||||
)
|
||||
return client
|
||||
|
||||
|
||||
def test_chat_json_retries_truncated_completion_without_token_cap():
|
||||
sequence = CompletionSequence(
|
||||
_response('{"items": [', finish_reason="length"),
|
||||
_response('{"items": [1, 2]}'),
|
||||
)
|
||||
client = _client_for(sequence)
|
||||
|
||||
result = client.chat_json(
|
||||
messages=[{"role": "user", "content": "Return JSON"}],
|
||||
max_tokens=4096,
|
||||
max_attempts=2,
|
||||
)
|
||||
|
||||
assert result == {"items": [1, 2]}
|
||||
assert sequence.calls[0]["max_tokens"] == 4096
|
||||
assert "max_tokens" not in sequence.calls[1]
|
||||
|
||||
|
||||
def test_chat_json_omits_token_cap_when_requested():
|
||||
sequence = CompletionSequence(_response('{"ok": true}'))
|
||||
client = _client_for(sequence)
|
||||
|
||||
assert client.chat_json(
|
||||
messages=[{"role": "user", "content": "Return JSON"}],
|
||||
max_tokens=None,
|
||||
) == {"ok": True}
|
||||
assert "max_tokens" not in sequence.calls[0]
|
||||
|
||||
|
||||
def test_chat_json_retries_empty_content_once():
|
||||
sequence = CompletionSequence(
|
||||
_response(None),
|
||||
_response('{"ok": true}'),
|
||||
)
|
||||
client = _client_for(sequence)
|
||||
|
||||
result = client.chat_json(
|
||||
messages=[{"role": "user", "content": "Return JSON"}],
|
||||
max_attempts=2,
|
||||
)
|
||||
|
||||
assert result == {"ok": True}
|
||||
assert len(sequence.calls) == 2
|
||||
|
||||
|
||||
def test_chat_json_accepts_complete_object_before_trailing_text():
|
||||
sequence = CompletionSequence(
|
||||
_response('{"ok": true}\nThis object is ready to use.')
|
||||
)
|
||||
client = _client_for(sequence)
|
||||
|
||||
assert client.chat_json(
|
||||
messages=[{"role": "user", "content": "Return JSON"}],
|
||||
) == {"ok": True}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"content",
|
||||
[
|
||||
'{"status": "draft"}\n{"status": "complete"}',
|
||||
'{"status": "draft"}\n```json\n{"status": "complete"}\n```',
|
||||
'{"status": "draft"}\nExplanation first.\n{"status": "complete"}',
|
||||
],
|
||||
)
|
||||
def test_chat_json_rejects_a_second_json_document(content):
|
||||
sequence = CompletionSequence(_response(content))
|
||||
client = _client_for(sequence)
|
||||
|
||||
with pytest.raises(LLMResponseError, match="multiple JSON"):
|
||||
client.chat_json(
|
||||
messages=[{"role": "user", "content": "Return JSON"}],
|
||||
)
|
||||
|
||||
|
||||
def test_chat_json_rejects_top_level_array():
|
||||
sequence = CompletionSequence(_response('[{"ok": true}]'))
|
||||
client = _client_for(sequence)
|
||||
|
||||
with pytest.raises(LLMResponseError, match="JSON object"):
|
||||
client.chat_json(
|
||||
messages=[{"role": "user", "content": "Return JSON"}],
|
||||
max_attempts=1,
|
||||
)
|
||||
|
||||
|
||||
def test_chat_json_error_does_not_echo_partial_model_output():
|
||||
partial = '{"private_source_text": "SENTINEL-SHOULD-NOT-LEAK"'
|
||||
sequence = CompletionSequence(
|
||||
_response(partial, finish_reason="length"),
|
||||
_response(partial, finish_reason="length"),
|
||||
)
|
||||
client = _client_for(sequence)
|
||||
|
||||
with pytest.raises(LLMResponseError) as captured:
|
||||
client.chat_json(
|
||||
messages=[{"role": "user", "content": "Return JSON"}],
|
||||
max_attempts=2,
|
||||
)
|
||||
|
||||
assert "SENTINEL-SHOULD-NOT-LEAK" not in str(captured.value)
|
||||
assert captured.value.finish_reason == "length"
|
||||
|
||||
|
||||
def test_chat_json_falls_back_only_for_explicit_response_format_rejection():
|
||||
unsupported = ProviderError(
|
||||
status_code=400,
|
||||
body={
|
||||
"error": {
|
||||
"param": "response_format",
|
||||
"code": "unsupported_parameter",
|
||||
"message": "response_format is not supported by this model",
|
||||
}
|
||||
},
|
||||
)
|
||||
sequence = CompletionSequence(unsupported, _response('{"ok": true}'))
|
||||
client = _client_for(sequence)
|
||||
|
||||
result = client.chat_json(
|
||||
messages=[{"role": "user", "content": "Return JSON"}],
|
||||
max_attempts=1,
|
||||
)
|
||||
|
||||
assert result == {"ok": True}
|
||||
assert sequence.calls[0]["response_format"] == {"type": "json_object"}
|
||||
assert "response_format" not in sequence.calls[1]
|
||||
|
||||
|
||||
def test_response_format_fallback_keeps_content_retry_available():
|
||||
unsupported = ProviderError(
|
||||
status_code=400,
|
||||
body={
|
||||
"error": {
|
||||
"param": "response_format",
|
||||
"code": "unsupported_parameter",
|
||||
"message": "response_format is not supported by this model",
|
||||
}
|
||||
},
|
||||
)
|
||||
sequence = CompletionSequence(
|
||||
unsupported,
|
||||
_response('{"items": [', finish_reason="length"),
|
||||
_response('{"items": [1]}'),
|
||||
)
|
||||
client = _client_for(sequence)
|
||||
|
||||
result = client.chat_json(
|
||||
messages=[{"role": "user", "content": "Return JSON"}],
|
||||
max_attempts=2,
|
||||
)
|
||||
|
||||
assert result == {"items": [1]}
|
||||
assert sequence.calls[0]["response_format"] == {"type": "json_object"}
|
||||
assert "response_format" not in sequence.calls[1]
|
||||
assert "response_format" not in sequence.calls[2]
|
||||
assert "max_tokens" not in sequence.calls[2]
|
||||
|
||||
|
||||
def test_chat_json_defaults_to_one_content_attempt():
|
||||
sequence = CompletionSequence(
|
||||
_response(None),
|
||||
_response('{"ok": true}'),
|
||||
)
|
||||
client = _client_for(sequence)
|
||||
|
||||
with pytest.raises(LLMResponseError, match="empty JSON"):
|
||||
client.chat_json(
|
||||
messages=[{"role": "user", "content": "Return JSON"}],
|
||||
)
|
||||
|
||||
assert len(sequence.calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status_code", [400, 401, 429, 500])
|
||||
def test_chat_json_does_not_retry_unrelated_provider_errors(status_code):
|
||||
provider_error = ProviderError(
|
||||
status_code=status_code,
|
||||
body={
|
||||
"error": {
|
||||
"param": "messages",
|
||||
"code": "invalid_request",
|
||||
"message": "request failed for an unrelated reason",
|
||||
}
|
||||
},
|
||||
)
|
||||
sequence = CompletionSequence(provider_error, _response('{"ok": true}'))
|
||||
client = _client_for(sequence)
|
||||
|
||||
with pytest.raises(ProviderError) as captured:
|
||||
client.chat_json(
|
||||
messages=[{"role": "user", "content": "Return JSON"}],
|
||||
max_attempts=2,
|
||||
)
|
||||
|
||||
assert captured.value is provider_error
|
||||
assert len(sequence.calls) == 1
|
||||
|
||||
|
||||
def test_chat_json_reports_missing_choices_without_retrying_forever():
|
||||
sequence = CompletionSequence(
|
||||
_response(None, include_choice=False),
|
||||
_response(None, include_choice=False),
|
||||
)
|
||||
client = _client_for(sequence)
|
||||
|
||||
with pytest.raises(LLMResponseError, match="no choices"):
|
||||
client.chat_json(
|
||||
messages=[{"role": "user", "content": "Return JSON"}],
|
||||
max_attempts=2,
|
||||
)
|
||||
|
||||
assert len(sequence.calls) == 2
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import io
|
||||
|
||||
from app import create_app
|
||||
from app.api import graph as graph_api
|
||||
from app.models.project import ProjectManager, ProjectStatus
|
||||
from app.utils.llm_client import LLMResponseError
|
||||
|
||||
|
||||
def _post_ontology(client):
|
||||
return client.post(
|
||||
"/api/graph/ontology/generate",
|
||||
data={
|
||||
"simulation_requirement": "Simulate the discussion.",
|
||||
"files": (io.BytesIO(b"A short source document."), "source.md"),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
|
||||
|
||||
def test_ontology_api_returns_safe_truncation_error_and_failed_project(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
class FailingGenerator:
|
||||
def generate(self, **kwargs):
|
||||
raise LLMResponseError(
|
||||
"LLM JSON output was truncated at the token limit",
|
||||
finish_reason="length",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(ProjectManager, "PROJECTS_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(graph_api, "OntologyGenerator", FailingGenerator)
|
||||
|
||||
app = create_app()
|
||||
app.config.update(TESTING=True)
|
||||
response = _post_ontology(app.test_client())
|
||||
|
||||
assert response.status_code == 502
|
||||
assert response.json["success"] is False
|
||||
assert "token limit" in response.json["error"]
|
||||
assert "traceback" not in response.json
|
||||
|
||||
project_id = response.json["data"]["project_id"]
|
||||
project = ProjectManager.get_project(project_id)
|
||||
assert project.status == ProjectStatus.FAILED
|
||||
assert project.error == response.json["error"]
|
||||
|
||||
|
||||
def test_ontology_api_does_not_expose_provider_error_body(tmp_path, monkeypatch):
|
||||
class ProviderError(RuntimeError):
|
||||
status_code = 401
|
||||
request_id = "request-safe-id"
|
||||
body = {"error": {"message": "SECRET-PROVIDER-BODY"}}
|
||||
|
||||
class FailingGenerator:
|
||||
def generate(self, **kwargs):
|
||||
raise ProviderError("SECRET-PROVIDER-BODY")
|
||||
|
||||
monkeypatch.setattr(ProjectManager, "PROJECTS_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(graph_api, "OntologyGenerator", FailingGenerator)
|
||||
|
||||
app = create_app()
|
||||
app.config.update(TESTING=True)
|
||||
response = _post_ontology(app.test_client())
|
||||
|
||||
assert response.status_code == 502
|
||||
assert "HTTP 401" in response.json["error"]
|
||||
assert "request-safe-id" in response.json["error"]
|
||||
assert "SECRET-PROVIDER-BODY" not in response.get_data(as_text=True)
|
||||
assert "traceback" not in response.json
|
||||
|
|
@ -1,6 +1,19 @@
|
|||
from app.services.ontology_generator import OntologyGenerator
|
||||
|
||||
|
||||
class RecordingLLMClient:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def chat_json(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
return {
|
||||
"entity_types": [],
|
||||
"edge_types": [],
|
||||
"analysis_summary": "ok",
|
||||
}
|
||||
|
||||
|
||||
def _generator_for_test() -> OntologyGenerator:
|
||||
generator = OntologyGenerator(llm_client=object())
|
||||
generator.MAX_TEXT_LENGTH_FOR_LLM = 2000
|
||||
|
|
@ -50,3 +63,17 @@ def test_very_long_ontology_context_selects_representative_chunks():
|
|||
assert "BEGIN" in context
|
||||
assert "FINALEND" in context
|
||||
assert context.count("--- 文档 1 / 分块") == generator.MAX_LONG_TEXT_CHUNKS
|
||||
|
||||
|
||||
def test_ontology_generation_does_not_cap_structured_output_tokens():
|
||||
llm = RecordingLLMClient()
|
||||
generator = OntologyGenerator(llm_client=llm)
|
||||
|
||||
result = generator.generate(
|
||||
document_texts=["A short source document."],
|
||||
simulation_requirement="Simulate the public discussion.",
|
||||
)
|
||||
|
||||
assert result["analysis_summary"] == "ok"
|
||||
assert llm.calls[0]["max_tokens"] is None
|
||||
assert llm.calls[0]["max_attempts"] == 2
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ service.interceptors.response.use(
|
|||
},
|
||||
error => {
|
||||
console.error('Response error:', error)
|
||||
const apiError = error.response?.data?.error || error.response?.data?.message
|
||||
|
||||
// 处理超时
|
||||
if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
|
||||
|
|
@ -47,6 +48,12 @@ service.interceptors.response.use(
|
|||
if (error.message === 'Network Error') {
|
||||
console.error('Network error - please check your connection')
|
||||
}
|
||||
|
||||
// Axios rejects non-2xx responses before the success interceptor can
|
||||
// surface the backend's safe, actionable error message.
|
||||
if (typeof apiError === 'string' && apiError) {
|
||||
error.message = apiError
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue