fix: harden ontology JSON generation

This commit is contained in:
666ghj 2026-07-23 01:58:27 +08:00
parent d64175943e
commit f0d487f310
7 changed files with 631 additions and 28 deletions

View File

@ -4,6 +4,7 @@
""" """
import os import os
import re
import traceback import traceback
import threading import threading
from contextlib import ExitStack, nullcontext from contextlib import ExitStack, nullcontext
@ -24,6 +25,7 @@ from ..models.project import ProjectManager, ProjectStatus
from ..services.simulation_manager import SimulationManager from ..services.simulation_manager import SimulationManager
from ..services.simulation_runner import SimulationRunner, RunnerStatus from ..services.simulation_runner import SimulationRunner, RunnerStatus
from ..services.zep_graph_memory_updater import ZepGraphMemoryManager from ..services.zep_graph_memory_updater import ZepGraphMemoryManager
from ..utils.llm_client import LLMResponseError
# 获取日志器 # 获取日志器
logger = get_logger('mirofish.api') logger = get_logger('mirofish.api')
@ -288,6 +290,7 @@ def generate_ontology():
} }
} }
""" """
project = None
try: try:
logger.info("=== 开始生成本体定义 ===") logger.info("=== 开始生成本体定义 ===")
@ -388,12 +391,56 @@ def generate_ontology():
} }
}) })
except Exception as e: except Exception as error:
return jsonify({ 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, "success": False,
"error": str(e), "error": public_error,
"traceback": traceback.format_exc() }
}), 500 if response_data is not None:
payload["data"] = response_data
return jsonify(payload), response_status
# ============== 接口2构建图谱 ============== # ============== 接口2构建图谱 ==============

View File

@ -235,7 +235,11 @@ class OntologyGenerator:
result = self.llm_client.chat_json( result = self.llm_client.chat_json(
messages=messages, messages=messages,
temperature=0.3, 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,
) )
# 验证和后处理 # 验证和后处理

View File

@ -4,6 +4,7 @@ LLM客户端封装
""" """
import json import json
import logging
import re import re
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from openai import OpenAI from openai import OpenAI
@ -12,6 +13,81 @@ from ..config import Config
from .openai_chat_compat import create_chat_completion, extract_chat_completion_text 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: class LLMClient:
"""LLM客户端""" """LLM客户端"""
@ -33,11 +109,30 @@ class LLMClient:
base_url=self.base_url 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( def chat(
self, self,
messages: List[Dict[str, str]], messages: List[Dict[str, str]],
temperature: float = 0.7, temperature: float = 0.7,
max_tokens: int = 4096, max_tokens: Optional[int] = 4096,
response_format: Optional[Dict] = None response_format: Optional[Dict] = None
) -> str: ) -> str:
""" """
@ -52,24 +147,21 @@ class LLMClient:
Returns: Returns:
模型响应文本 模型响应文本
""" """
response = create_chat_completion( response = self._create_completion(
self.client,
model=self.model,
messages=messages, messages=messages,
temperature=temperature, temperature=temperature,
max_tokens=max_tokens, max_tokens=max_tokens,
response_format=response_format, response_format=response_format,
) )
content = extract_chat_completion_text(response) content = extract_chat_completion_text(response)
# 部分模型如MiniMax M2.5会在content中包含<think>思考内容,需要移除 return _clean_chat_text(content)
content = re.sub(r'<think>[\s\S]*?</think>', '', content).strip()
return content
def chat_json( def chat_json(
self, self,
messages: List[Dict[str, str]], messages: List[Dict[str, str]],
temperature: float = 0.3, temperature: float = 0.3,
max_tokens: int = 4096 max_tokens: Optional[int] = 4096,
max_attempts: int = 1,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
发送聊天请求并返回JSON 发送聊天请求并返回JSON
@ -78,24 +170,121 @@ class LLMClient:
messages: 消息列表 messages: 消息列表
temperature: 温度参数 temperature: 温度参数
max_tokens: 最大token数 max_tokens: 最大token数
max_attempts: 内容生成尝试次数不含一次明确的JSON模式能力降级
Returns: Returns:
解析后的JSON对象 解析后的JSON对象
""" """
response = self.chat( if max_attempts < 1:
messages=messages, raise ValueError("max_attempts must be at least 1")
temperature=temperature,
max_tokens=max_tokens, response_format: Optional[Dict[str, str]] = {"type": "json_object"}
response_format={"type": "json_object"} request_max_tokens = max_tokens
) last_error: Optional[LLMResponseError] = None
# 清理markdown代码块标记
cleaned_response = response.strip() for attempt in range(1, max_attempts + 1):
cleaned_response = re.sub(r'^```(?:json)?\s*\n?', '', cleaned_response, flags=re.IGNORECASE) # JSON-mode capability negotiation is separate from content
cleaned_response = re.sub(r'\n?```\s*$', '', cleaned_response) # regeneration. An explicit response_format rejection may add one
cleaned_response = cleaned_response.strip() # 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: try:
return json.loads(cleaned_response) value = json.loads(content)
except json.JSONDecodeError: except json.JSONDecodeError as strict_error:
raise ValueError(f"LLM返回的JSON格式无效: {cleaned_response}") # 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

View File

@ -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

View File

@ -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

View File

@ -1,6 +1,19 @@
from app.services.ontology_generator import OntologyGenerator 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: def _generator_for_test() -> OntologyGenerator:
generator = OntologyGenerator(llm_client=object()) generator = OntologyGenerator(llm_client=object())
generator.MAX_TEXT_LENGTH_FOR_LLM = 2000 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 "BEGIN" in context
assert "FINALEND" in context assert "FINALEND" in context
assert context.count("--- 文档 1 / 分块") == generator.MAX_LONG_TEXT_CHUNKS 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

View File

@ -37,6 +37,7 @@ service.interceptors.response.use(
}, },
error => { error => {
console.error('Response error:', error) console.error('Response error:', error)
const apiError = error.response?.data?.error || error.response?.data?.message
// 处理超时 // 处理超时
if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) { if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
@ -48,6 +49,12 @@ service.interceptors.response.use(
console.error('Network error - please check your connection') 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) return Promise.reject(error)
} }
) )