Files

1 line
73 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "carmen246/exam", "files": {"exam-api/src/main/java/com/yf/exam/modules/qu/support/QuestionAiModelFactory.java": {"b13f3b6fd3ef1a6c5ebce2d89d002c084e8b865b": {"t": 1785745699.799637, "n": 4207, "h": "2beb96b40f181d9c", "c": "package com.yf.exam.modules.qu.support;\n\nimport com.yf.exam.core.exception.ServiceException;\nimport com.yf.exam.modules.qu.config.QuestionAiProperties;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.stereotype.Component;\n\nimport java.time.Duration;\n\n/**\n * 统一创建 AI 对话模型,支持直连 OpenAI 兼容 API 与 RAGFlow。\n */\n@Component\npublic class QuestionAiModelFactory {\n\n public static final String PROVIDER_OPENAI = \"openai\";\n public static final String PROVIDER_RAGFLOW = \"ragflow\";\n\n public ChatLanguageModel create(QuestionAiProperties properties) {\n return create(properties, true);\n }\n\n /**\n * @param jsonResponse 是否要求模型以 JSON 对象格式回复(试题解析需要,连通性测试不需要)\n */\n public ChatLanguageModel create(QuestionAiProperties properties, boolean jsonResponse) {\n if (properties == null) {\n throw new ServiceException(\"AI 配置为空\");\n }\n if (StringUtils.isBlank(properties.getApiKey())) {\n throw new ServiceException(resolveMissingApiKeyMessage(properties));\n }\n if (StringUtils.isBlank(properties.getBaseUrl())) {\n throw new ServiceException(\"未配置 exam.ai.base-url\");\n }\n\n String provider = normalizeProvider(properties.getProvider());\n OpenAiChatModel.OpenAiChatModelBuilder builder = OpenAiChatModel.builder()\n .apiKey(properties.getApiKey())\n .temperature(0.1)\n .maxTokens(8000)\n .timeout(Duration.ofSeconds(properties.getTimeoutSeconds() == null\n ? 60\n : properties.getTimeoutSeconds()))\n .maxRetries(1);\n\n if (PROVIDER_RAGFLOW.equals(provider)) {\n if (StringUtils.isBlank(properties.getChatId())) {\n throw new ServiceException(\"RAGFlow 模式需配置 exam.ai.chat-id(聊天助手 ID)\");\n }\n return new RagflowChatLanguageModel(buildRagflowOpenAiBaseUrl(properties), properties.getApiKey(),\n StringUtils.defaultIfBlank(properties.getModelName(), \"model\"), properties.getTimeoutSeconds());\n } else {\n builder.baseUrl(trimTrailingSlash(properties.getBaseUrl()))\n .modelName(properties.getModelName());\n if (jsonResponse) {\n builder.responseFormat(\"json_object\");\n }\n }\n\n return builder.build();\n }\n\n public boolean isRagflow(QuestionAiProperties properties) {\n return properties != null && PROVIDER_RAGFLOW.equalsIgnoreCase(normalizeProvider(properties.getProvider()));\n }\n\n private String buildRagflowOpenAiBaseUrl(QuestionAiProperties properties) {\n String root = trimTrailingSlash(properties.getBaseUrl());\n String chatId = properties.getChatId().trim();\n if (isRagflowOpenAiBaseUrl(root)) {\n return root;\n }\n if (isRagflowOpenAiBasePath(root)) {\n return root + \"/\" + chatId;\n }\n\n return root + \"/api/v1/openai/\" + chatId;\n }\n\n private boolean isRagflowOpenAiBaseUrl(String url) {\n return StringUtils.contains(url, \"/api/v1/openai/\")\n || StringUtils.contains(url, \"/api/v1/chats_openai/\");\n }\n\n private boolean isRagflowOpenAiBasePath(String url) {\n return url.endsWith(\"/api/v1/openai\") || url.endsWith(\"/api/v1/chats_openai\");\n }\n\n private String normalizeProvider(String provider) {\n if (StringUtils.isBlank(provider)) {\n return PROVIDER_OPENAI;\n }\n return provider.trim().toLowerCase();\n }\n\n private String trimTrailingSlash(String url) {\n if (StringUtils.isBlank(url)) {\n return url;\n }\n String value = url.trim();\n while (value.endsWith(\"/\")) {\n value = value.substring(0, value.length() - 1);\n }\n return value;\n }\n\n private String resolveMissingApiKeyMessage(QuestionAiProperties properties) {\n if (isRagflow(properties)) {\n return \"未配置 RAGFLOW_API_KEY(exam.ai.api-key)\";\n }\n return \"未配置 DEEPSEEK_API_KEY(exam.ai.api-key)\";\n }\n}\n"}, "4cfc641a917fdcce4a6c7419c5a52364d8b8e9c7": {"t": 1785755755.8342679, "n": 4207, "h": "2beb96b40f181d9c", "c": "package com.yf.exam.modules.qu.support;\n\nimport com.yf.exam.core.exception.ServiceException;\nimport com.yf.exam.modules.qu.config.QuestionAiProperties;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.stereotype.Component;\n\nimport java.time.Duration;\n\n/**\n * 统一创建 AI 对话模型,支持直连 OpenAI 兼容 API 与 RAGFlow。\n */\n@Component\npublic class QuestionAiModelFactory {\n\n public static final String PROVIDER_OPENAI = \"openai\";\n public static final String PROVIDER_RAGFLOW = \"ragflow\";\n\n public ChatLanguageModel create(QuestionAiProperties properties) {\n return create(properties, true);\n }\n\n /**\n * @param jsonResponse 是否要求模型以 JSON 对象格式回复(试题解析需要,连通性测试不需要)\n */\n public ChatLanguageModel create(QuestionAiProperties properties, boolean jsonResponse) {\n if (properties == null) {\n throw new ServiceException(\"AI 配置为空\");\n }\n if (StringUtils.isBlank(properties.getApiKey())) {\n throw new ServiceException(resolveMissingApiKeyMessage(properties));\n }\n if (StringUtils.isBlank(properties.getBaseUrl())) {\n throw new ServiceException(\"未配置 exam.ai.base-url\");\n }\n\n String provider = normalizeProvider(properties.getProvider());\n OpenAiChatModel.OpenAiChatModelBuilder builder = OpenAiChatModel.builder()\n .apiKey(properties.getApiKey())\n .temperature(0.1)\n .maxTokens(8000)\n .timeout(Duration.ofSeconds(properties.getTimeoutSeconds() == null\n ? 60\n : properties.getTimeoutSeconds()))\n .maxRetries(1);\n\n if (PROVIDER_RAGFLOW.equals(provider)) {\n if (StringUtils.isBlank(properties.getChatId())) {\n throw new ServiceException(\"RAGFlow 模式需配置 exam.ai.chat-id(聊天助手 ID)\");\n }\n return new RagflowChatLanguageModel(buildRagflowOpenAiBaseUrl(properties), properties.getApiKey(),\n StringUtils.defaultIfBlank(properties.getModelName(), \"model\"), properties.getTimeoutSeconds());\n } else {\n builder.baseUrl(trimTrailingSlash(properties.getBaseUrl()))\n .modelName(properties.getModelName());\n if (jsonResponse) {\n builder.responseFormat(\"json_object\");\n }\n }\n\n return builder.build();\n }\n\n public boolean isRagflow(QuestionAiProperties properties) {\n return properties != null && PROVIDER_RAGFLOW.equalsIgnoreCase(normalizeProvider(properties.getProvider()));\n }\n\n private String buildRagflowOpenAiBaseUrl(QuestionAiProperties properties) {\n String root = trimTrailingSlash(properties.getBaseUrl());\n String chatId = properties.getChatId().trim();\n if (isRagflowOpenAiBaseUrl(root)) {\n return root;\n }\n if (isRagflowOpenAiBasePath(root)) {\n return root + \"/\" + chatId;\n }\n\n return root + \"/api/v1/openai/\" + chatId;\n }\n\n private boolean isRagflowOpenAiBaseUrl(String url) {\n return StringUtils.contains(url, \"/api/v1/openai/\")\n || StringUtils.contains(url, \"/api/v1/chats_openai/\");\n }\n\n private boolean isRagflowOpenAiBasePath(String url) {\n return url.endsWith(\"/api/v1/openai\") || url.endsWith(\"/api/v1/chats_openai\");\n }\n\n private String normalizeProvider(String provider) {\n if (StringUtils.isBlank(provider)) {\n return PROVIDER_OPENAI;\n }\n return provider.trim().toLowerCase();\n }\n\n private String trimTrailingSlash(String url) {\n if (StringUtils.isBlank(url)) {\n return url;\n }\n String value = url.trim();\n while (value.endsWith(\"/\")) {\n value = value.substring(0, value.length() - 1);\n }\n return value;\n }\n\n private String resolveMissingApiKeyMessage(QuestionAiProperties properties) {\n if (isRagflow(properties)) {\n return \"未配置 RAGFLOW_API_KEY(exam.ai.api-key)\";\n }\n return \"未配置 DEEPSEEK_API_KEY(exam.ai.api-key)\";\n }\n}\n"}, "d2a4383d5943cbe48b718655320fe03a04a356f2": {"t": 1785755757.3442018, "n": 3936, "h": "e9bc88a90dbf2f0e", "c": "package com.yf.exam.modules.qu.support;\n\nimport com.yf.exam.core.exception.ServiceException;\nimport com.yf.exam.modules.qu.config.QuestionAiProperties;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.stereotype.Component;\n\nimport java.time.Duration;\n\n/**\n * 统一创建 AI 对话模型,支持直连 OpenAI 兼容 API 与 RAGFlow。\n */\n@Component\npublic class QuestionAiModelFactory {\n\n public static final String PROVIDER_OPENAI = \"openai\";\n public static final String PROVIDER_RAGFLOW = \"ragflow\";\n\n public ChatLanguageModel create(QuestionAiProperties properties) {\n if (properties == null) {\n throw new ServiceException(\"AI 配置为空\");\n }\n if (StringUtils.isBlank(properties.getApiKey())) {\n throw new ServiceException(resolveMissingApiKeyMessage(properties));\n }\n if (StringUtils.isBlank(properties.getBaseUrl())) {\n throw new ServiceException(\"未配置 exam.ai.base-url\");\n }\n\n String provider = normalizeProvider(properties.getProvider());\n OpenAiChatModel.OpenAiChatModelBuilder builder = OpenAiChatModel.builder()\n .apiKey(properties.getApiKey())\n .temperature(0.1)\n .maxTokens(8000)\n .timeout(Duration.ofSeconds(properties.getTimeoutSeconds() == null\n ? 60\n : properties.getTimeoutSeconds()))\n .maxRetries(1);\n\n if (PROVIDER_RAGFLOW.equals(provider)) {\n if (StringUtils.isBlank(properties.getChatId())) {\n throw new ServiceException(\"RAGFlow 模式需配置 exam.ai.chat-id(聊天助手 ID)\");\n }\n return new RagflowChatLanguageModel(buildRagflowOpenAiBaseUrl(properties), properties.getApiKey(),\n StringUtils.defaultIfBlank(properties.getModelName(), \"model\"), properties.getTimeoutSeconds());\n } else {\n builder.baseUrl(trimTrailingSlash(properties.getBaseUrl()))\n .modelName(properties.getModelName())\n .responseFormat(\"json_object\");\n }\n\n return builder.build();\n }\n\n public boolean isRagflow(QuestionAiProperties properties) {\n return properties != null && PROVIDER_RAGFLOW.equalsIgnoreCase(normalizeProvider(properties.getProvider()));\n }\n\n private String buildRagflowOpenAiBaseUrl(QuestionAiProperties properties) {\n String root = trimTrailingSlash(properties.getBaseUrl());\n String chatId = properties.getChatId().trim();\n if (isRagflowOpenAiBaseUrl(root)) {\n return root;\n }\n if (isRagflowOpenAiBasePath(root)) {\n return root + \"/\" + chatId;\n }\n\n return root + \"/api/v1/openai/\" + chatId;\n }\n\n private boolean isRagflowOpenAiBaseUrl(String url) {\n return StringUtils.contains(url, \"/api/v1/openai/\")\n || StringUtils.contains(url, \"/api/v1/chats_openai/\");\n }\n\n private boolean isRagflowOpenAiBasePath(String url) {\n return url.endsWith(\"/api/v1/openai\") || url.endsWith(\"/api/v1/chats_openai\");\n }\n\n private String normalizeProvider(String provider) {\n if (StringUtils.isBlank(provider)) {\n return PROVIDER_OPENAI;\n }\n return provider.trim().toLowerCase();\n }\n\n private String trimTrailingSlash(String url) {\n if (StringUtils.isBlank(url)) {\n return url;\n }\n String value = url.trim();\n while (value.endsWith(\"/\")) {\n value = value.substring(0, value.length() - 1);\n }\n return value;\n }\n\n private String resolveMissingApiKeyMessage(QuestionAiProperties properties) {\n if (isRagflow(properties)) {\n return \"未配置 RAGFLOW_API_KEY(exam.ai.api-key)\";\n }\n return \"未配置 DEEPSEEK_API_KEY(exam.ai.api-key)\";\n }\n}\n"}}, "exam-api/src/main/java/com/yf/exam/modules/qu/support/AiCallErrorSupport.java": {"b13f3b6fd3ef1a6c5ebce2d89d002c084e8b865b": {"t": 1785746116.6484957, "n": 10962, "h": "e258a11b1a2a704f", "c": "package com.yf.exam.modules.qu.support;\n\nimport com.yf.exam.core.exception.ServiceException;\nimport org.apache.commons.lang3.StringUtils;\n\nimport java.util.regex.Matcher;\nimport java.util.regex.Pattern;\n\n/**\n * AI 调用异常识别与用户可读提示\n */\npublic final class AiCallErrorSupport {\n\n private static final Pattern API_ERROR_MESSAGE = Pattern.compile(\n \"\\\"message\\\"\\\\s*:\\\\s*\\\"((?:\\\\\\\\.|[^\\\"\\\\\\\\])*)\\\"\");\n private static final Pattern EXCEPTION_PREFIX = Pattern.compile(\n \"^[\\\\w.$]+(?:Exception|Error):\\\\s*\");\n\n private AiCallErrorSupport() {\n }\n\n public static boolean isTransientError(Throwable error) {\n if (error == null) {\n return false;\n }\n String message = collectMessage(error).toLowerCase();\n return message.contains(\"service is too busy\")\n || message.contains(\"service_unavailable\")\n || message.contains(\"too many requests\")\n || message.contains(\"rate limit\")\n || message.contains(\"ratelimit\")\n || message.contains(\"overloaded\")\n || message.contains(\"temporarily unavailable\")\n || message.contains(\" 429\")\n || message.contains(\"\\\"code\\\":\\\"service_unavailable\")\n || message.contains(\"ai服务繁忙\");\n }\n\n public static String toUserMessage(Throwable error) {\n if (error == null) {\n return \"AI 调用失败\";\n }\n if (isTransientError(error)) {\n return \"AI 服务繁忙,请稍后重试\";\n }\n String raw = collectMessage(error);\n String apiMessage = extractApiErrorMessage(raw);\n String text = StringUtils.defaultIfBlank(apiMessage, raw);\n text = stripExceptionPrefix(text);\n String localized = localizeMessage(text);\n if (StringUtils.isNotBlank(localized)) {\n return trimLength(localized);\n }\n if (StringUtils.isBlank(text)) {\n return \"AI 调用失败\";\n }\n if (looksLikeEnglishApiError(text)) {\n return trimLength(\"AI 接口返回错误:\" + text);\n }\n return trimLength(text);\n }\n\n public static void sleepBeforeRetry(int attempt) {\n long delayMs = Math.min(15000L, 2000L * Math.max(1, attempt));\n try {\n Thread.sleep(delayMs);\n } catch (InterruptedException e) {\n Thread.currentThread().interrupt();\n }\n }\n\n private static String collectMessage(Throwable error) {\n StringBuilder sb = new StringBuilder();\n Throwable current = error;\n while (current != null) {\n String message = current.getMessage();\n if (StringUtils.isBlank(message) && current instanceof ServiceException) {\n message = ((ServiceException) current).getMsg();\n }\n if (StringUtils.isNotBlank(message)) {\n if (sb.length() > 0) {\n sb.append(' ');\n }\n sb.append(message.trim());\n }\n current = current.getCause();\n }\n if (sb.length() == 0 && error != null) {\n sb.append(error.getClass().getSimpleName());\n }\n return sb.toString();\n }\n\n private static String extractApiErrorMessage(String message) {\n if (StringUtils.isBlank(message)) {\n return null;\n }\n Matcher matcher = API_ERROR_MESSAGE.matcher(message);\n String last = null;\n while (matcher.find()) {\n last = unescapeJson(matcher.group(1));\n }\n return last;\n }\n\n private static String stripExceptionPrefix(String message) {\n if (StringUtils.isBlank(message)) {\n return message;\n }\n int jsonStart = message.indexOf(\"{\\\"error\\\"\");\n if (jsonStart > 0) {\n return message.substring(jsonStart).trim();\n }\n return EXCEPTION_PREFIX.matcher(message).replaceFirst(\"\").trim();\n }\n\n private static String localizeMessage(String message) {\n if (StringUtils.isBlank(message)) {\n return null;\n }\n String htmlStatus = extractHtmlHttpStatus(message);\n if (StringUtils.isNotBlank(htmlStatus)) {\n return localizeHttpStatus(htmlStatus, message);\n }\n String lower = message.toLowerCase();\n\n if (lower.contains(\"prompt must contain the word 'json'\")\n || (lower.contains(\"response_format\") && lower.contains(\"json_object\"))) {\n return \"使用 JSON 输出格式时,提示词中需包含 json 字样\";\n }\n if (lower.contains(\"incorrect api key\")\n || lower.contains(\"invalid api key\")\n || lower.contains(\"invalid authentication\")\n || lower.contains(\"authentication failed\")\n || lower.contains(\"unauthorized\")\n || lower.contains(\"invalid_api_key\")\n || lower.contains(\"api key not valid\")) {\n return \"API Key 无效或已过期,请检查配置\";\n }\n if (lower.contains(\"exceeded your current quota\")\n || lower.contains(\"insufficient_quota\")\n || lower.contains(\"billing\")\n || lower.contains(\"余额不足\")\n || lower.contains(\"quota\")) {\n return \"API 调用额度不足或账户欠费,请检查服务商账户\";\n }\n if (lower.contains(\"rate limit\")\n || lower.contains(\"too many requests\")\n || lower.contains(\"ratelimit\")\n || lower.contains(\" 429\")) {\n return \"请求过于频繁,请稍后重试\";\n }\n if (lower.contains(\"model\") && (lower.contains(\"not found\")\n || lower.contains(\"does not exist\")\n || lower.contains(\"not exist\")\n || lower.contains(\"unknown model\"))) {\n return \"模型不存在或当前账户无权访问,请检查模型名称\";\n }\n if (lower.contains(\"connect timed out\")\n || lower.contains(\"read timed out\")\n || lower.contains(\"timeout\")\n || lower.contains(\"timed out\")) {\n return \"连接 AI 服务超时,请检查网络或增大超时时间\";\n }\n if (lower.contains(\"connection refused\")\n || lower.contains(\"failed to connect\")\n || lower.contains(\"unknown host\")\n || lower.contains(\"no route to host\")\n || lower.contains(\"network is unreachable\")) {\n return \"无法连接 AI 服务,请检查 API 地址与网络\";\n }\n if (lower.contains(\"ssl\")\n || lower.contains(\"certificate\")\n || lower.contains(\"handshake\")) {\n return \"连接 AI 服务时 SSL 证书校验失败,请检查 API 地址\";\n }\n if (lower.contains(\"context length\")\n || lower.contains(\"maximum context\")\n || lower.contains(\"token limit\")\n || lower.contains(\"max_tokens\")) {\n return \"输入内容过长,超出模型上下文限制\";\n }\n if (lower.contains(\"invalid_request_error\")\n || lower.contains(\"invalid request\")\n || lower.contains(\"bad request\")\n || lower.contains(\" 400\")) {\n return \"AI 接口请求参数无效,请检查配置\";\n }\n if (lower.contains(\"forbidden\")\n || lower.contains(\" 403\")) {\n return \"无权访问该 AI 接口,请检查 API Key 与权限\";\n }\n if (lower.contains(\"not allowed\")\n || lower.contains(\"method not allowed\")\n || lower.contains(\" 405\")) {\n return \"AI 接口地址不正确或网关不允许该请求方式(405),请检查 API 地址是否填写为服务商提供的接口根地址\";\n }\n if (lower.contains(\"not found\")\n || lower.contains(\" 404\")) {\n return \"AI 接口地址不存在,请检查 API 地址配置\";\n }\n if (lower.contains(\"internal server error\")\n || lower.contains(\"server error\")\n || lower.contains(\" 500\")\n || lower.contains(\" 502\")\n || lower.contains(\" 503\")\n || lower.contains(\" 504\")) {\n return \"AI 服务暂时不可用,请稍后重试\";\n }\n if (lower.contains(\"service is too busy\")\n || lower.contains(\"service_unavailable\")\n || lower.contains(\"overloaded\")) {\n return \"AI 服务繁忙,请稍后重试\";\n }\n if (containsChinese(message)) {\n return message;\n }\n return null;\n }\n\n private static boolean looksLikeEnglishApiError(String message) {\n if (StringUtils.isBlank(message)) {\n return false;\n }\n if (StringUtils.isNotBlank(extractHtmlHttpStatus(message))) {\n return true;\n }\n return !containsChinese(message)\n && (message.contains(\"error\")\n || message.contains(\"invalid\")\n || message.contains(\"failed\")\n || message.contains(\"exception\"));\n }\n\n private static String extractHtmlHttpStatus(String message) {\n if (StringUtils.isBlank(message) || !message.toLowerCase().contains(\"<html\")) {\n return null;\n }\n Matcher matcher = Pattern.compile(\"(?i)(\\\\d{3})\\\\s+([A-Za-z ]+)\").matcher(message);\n if (matcher.find()) {\n return matcher.group(1) + \" \" + matcher.group(2).trim();\n }\n return \"HTML\";\n }\n\n private static String localizeHttpStatus(String status, String rawMessage) {\n String lower = status.toLowerCase();\n if (lower.contains(\"405\")) {\n return \"AI 接口地址不正确或网关不允许该请求方式(405),请检查 API 地址是否填写为服务商提供的接口根地址,例如 https://api.deepseek.com\";\n }\n if (lower.contains(\"404\")) {\n return \"AI 接口地址不存在(404),请检查 API 地址配置\";\n }\n if (lower.contains(\"401\") || lower.contains(\"403\")) {\n return \"无权访问 AI 接口(\" + status.trim() + \"),请检查 API Key 与地址\";\n }\n if (lower.contains(\"502\") || lower.contains(\"503\") || lower.contains(\"504\")) {\n return \"AI 服务网关异常(\" + status.trim() + \"),请稍后重试或检查 API 地址\";\n }\n if (StringUtils.containsIgnoreCase(rawMessage, \"nginx\")) {\n return \"请求被 Web 网关拒绝(\" + status.trim() + \"),API 地址可能填错了,请填写 AI 服务商的接口地址而非网站首页\";\n }\n return \"AI 接口返回异常(\" + status.trim() + \"),请检查 API 地址与网络\";\n }\n\n private static boolean containsChinese(String text) {\n if (StringUtils.isBlank(text)) {\n return false;\n }\n for (int i = 0; i < text.length(); i++) {\n if (Character.UnicodeScript.of(text.charAt(i)) == Character.UnicodeScript.HAN) {\n return true;\n }\n }\n return false;\n }\n\n private static String unescapeJson(String value) {\n if (value == null) {\n return null;\n }\n return value.replace(\"\\\\\\\"\", \"\\\"\")\n .replace(\"\\\\n\", \"\\n\")\n .replace(\"\\\\r\", \"\\r\")\n .replace(\"\\\\t\", \"\\t\")\n .replace(\"\\\\\\\\\", \"\\\\\");\n }\n\n private static String trimLength(String message) {\n if (message == null) {\n return null;\n }\n if (message.length() <= 240) {\n return message;\n }\n return message.substring(0, 240) + \"...\";\n }\n}\n"}, "4cfc641a917fdcce4a6c7419c5a52364d8b8e9c7": {"t": 1785755756.4699786, "n": 10962, "h": "e258a11b1a2a704f", "c": "package com.yf.exam.modules.qu.support;\n\nimport com.yf.exam.core.exception.ServiceException;\nimport org.apache.commons.lang3.StringUtils;\n\nimport java.util.regex.Matcher;\nimport java.util.regex.Pattern;\n\n/**\n * AI 调用异常识别与用户可读提示\n */\npublic final class AiCallErrorSupport {\n\n private static final Pattern API_ERROR_MESSAGE = Pattern.compile(\n \"\\\"message\\\"\\\\s*:\\\\s*\\\"((?:\\\\\\\\.|[^\\\"\\\\\\\\])*)\\\"\");\n private static final Pattern EXCEPTION_PREFIX = Pattern.compile(\n \"^[\\\\w.$]+(?:Exception|Error):\\\\s*\");\n\n private AiCallErrorSupport() {\n }\n\n public static boolean isTransientError(Throwable error) {\n if (error == null) {\n return false;\n }\n String message = collectMessage(error).toLowerCase();\n return message.contains(\"service is too busy\")\n || message.contains(\"service_unavailable\")\n || message.contains(\"too many requests\")\n || message.contains(\"rate limit\")\n || message.contains(\"ratelimit\")\n || message.contains(\"overloaded\")\n || message.contains(\"temporarily unavailable\")\n || message.contains(\" 429\")\n || message.contains(\"\\\"code\\\":\\\"service_unavailable\")\n || message.contains(\"ai服务繁忙\");\n }\n\n public static String toUserMessage(Throwable error) {\n if (error == null) {\n return \"AI 调用失败\";\n }\n if (isTransientError(error)) {\n return \"AI 服务繁忙,请稍后重试\";\n }\n String raw = collectMessage(error);\n String apiMessage = extractApiErrorMessage(raw);\n String text = StringUtils.defaultIfBlank(apiMessage, raw);\n text = stripExceptionPrefix(text);\n String localized = localizeMessage(text);\n if (StringUtils.isNotBlank(localized)) {\n return trimLength(localized);\n }\n if (StringUtils.isBlank(text)) {\n return \"AI 调用失败\";\n }\n if (looksLikeEnglishApiError(text)) {\n return trimLength(\"AI 接口返回错误:\" + text);\n }\n return trimLength(text);\n }\n\n public static void sleepBeforeRetry(int attempt) {\n long delayMs = Math.min(15000L, 2000L * Math.max(1, attempt));\n try {\n Thread.sleep(delayMs);\n } catch (InterruptedException e) {\n Thread.currentThread().interrupt();\n }\n }\n\n private static String collectMessage(Throwable error) {\n StringBuilder sb = new StringBuilder();\n Throwable current = error;\n while (current != null) {\n String message = current.getMessage();\n if (StringUtils.isBlank(message) && current instanceof ServiceException) {\n message = ((ServiceException) current).getMsg();\n }\n if (StringUtils.isNotBlank(message)) {\n if (sb.length() > 0) {\n sb.append(' ');\n }\n sb.append(message.trim());\n }\n current = current.getCause();\n }\n if (sb.length() == 0 && error != null) {\n sb.append(error.getClass().getSimpleName());\n }\n return sb.toString();\n }\n\n private static String extractApiErrorMessage(String message) {\n if (StringUtils.isBlank(message)) {\n return null;\n }\n Matcher matcher = API_ERROR_MESSAGE.matcher(message);\n String last = null;\n while (matcher.find()) {\n last = unescapeJson(matcher.group(1));\n }\n return last;\n }\n\n private static String stripExceptionPrefix(String message) {\n if (StringUtils.isBlank(message)) {\n return message;\n }\n int jsonStart = message.indexOf(\"{\\\"error\\\"\");\n if (jsonStart > 0) {\n return message.substring(jsonStart).trim();\n }\n return EXCEPTION_PREFIX.matcher(message).replaceFirst(\"\").trim();\n }\n\n private static String localizeMessage(String message) {\n if (StringUtils.isBlank(message)) {\n return null;\n }\n String htmlStatus = extractHtmlHttpStatus(message);\n if (StringUtils.isNotBlank(htmlStatus)) {\n return localizeHttpStatus(htmlStatus, message);\n }\n String lower = message.toLowerCase();\n\n if (lower.contains(\"prompt must contain the word 'json'\")\n || (lower.contains(\"response_format\") && lower.contains(\"json_object\"))) {\n return \"使用 JSON 输出格式时,提示词中需包含 json 字样\";\n }\n if (lower.contains(\"incorrect api key\")\n || lower.contains(\"invalid api key\")\n || lower.contains(\"invalid authentication\")\n || lower.contains(\"authentication failed\")\n || lower.contains(\"unauthorized\")\n || lower.contains(\"invalid_api_key\")\n || lower.contains(\"api key not valid\")) {\n return \"API Key 无效或已过期,请检查配置\";\n }\n if (lower.contains(\"exceeded your current quota\")\n || lower.contains(\"insufficient_quota\")\n || lower.contains(\"billing\")\n || lower.contains(\"余额不足\")\n || lower.contains(\"quota\")) {\n return \"API 调用额度不足或账户欠费,请检查服务商账户\";\n }\n if (lower.contains(\"rate limit\")\n || lower.contains(\"too many requests\")\n || lower.contains(\"ratelimit\")\n || lower.contains(\" 429\")) {\n return \"请求过于频繁,请稍后重试\";\n }\n if (lower.contains(\"model\") && (lower.contains(\"not found\")\n || lower.contains(\"does not exist\")\n || lower.contains(\"not exist\")\n || lower.contains(\"unknown model\"))) {\n return \"模型不存在或当前账户无权访问,请检查模型名称\";\n }\n if (lower.contains(\"connect timed out\")\n || lower.contains(\"read timed out\")\n || lower.contains(\"timeout\")\n || lower.contains(\"timed out\")) {\n return \"连接 AI 服务超时,请检查网络或增大超时时间\";\n }\n if (lower.contains(\"connection refused\")\n || lower.contains(\"failed to connect\")\n || lower.contains(\"unknown host\")\n || lower.contains(\"no route to host\")\n || lower.contains(\"network is unreachable\")) {\n return \"无法连接 AI 服务,请检查 API 地址与网络\";\n }\n if (lower.contains(\"ssl\")\n || lower.contains(\"certificate\")\n || lower.contains(\"handshake\")) {\n return \"连接 AI 服务时 SSL 证书校验失败,请检查 API 地址\";\n }\n if (lower.contains(\"context length\")\n || lower.contains(\"maximum context\")\n || lower.contains(\"token limit\")\n || lower.contains(\"max_tokens\")) {\n return \"输入内容过长,超出模型上下文限制\";\n }\n if (lower.contains(\"invalid_request_error\")\n || lower.contains(\"invalid request\")\n || lower.contains(\"bad request\")\n || lower.contains(\" 400\")) {\n return \"AI 接口请求参数无效,请检查配置\";\n }\n if (lower.contains(\"forbidden\")\n || lower.contains(\" 403\")) {\n return \"无权访问该 AI 接口,请检查 API Key 与权限\";\n }\n if (lower.contains(\"not allowed\")\n || lower.contains(\"method not allowed\")\n || lower.contains(\" 405\")) {\n return \"AI 接口地址不正确或网关不允许该请求方式(405),请检查 API 地址是否填写为服务商提供的接口根地址\";\n }\n if (lower.contains(\"not found\")\n || lower.contains(\" 404\")) {\n return \"AI 接口地址不存在,请检查 API 地址配置\";\n }\n if (lower.contains(\"internal server error\")\n || lower.contains(\"server error\")\n || lower.contains(\" 500\")\n || lower.contains(\" 502\")\n || lower.contains(\" 503\")\n || lower.contains(\" 504\")) {\n return \"AI 服务暂时不可用,请稍后重试\";\n }\n if (lower.contains(\"service is too busy\")\n || lower.contains(\"service_unavailable\")\n || lower.contains(\"overloaded\")) {\n return \"AI 服务繁忙,请稍后重试\";\n }\n if (containsChinese(message)) {\n return message;\n }\n return null;\n }\n\n private static boolean looksLikeEnglishApiError(String message) {\n if (StringUtils.isBlank(message)) {\n return false;\n }\n if (StringUtils.isNotBlank(extractHtmlHttpStatus(message))) {\n return true;\n }\n return !containsChinese(message)\n && (message.contains(\"error\")\n || message.contains(\"invalid\")\n || message.contains(\"failed\")\n || message.contains(\"exception\"));\n }\n\n private static String extractHtmlHttpStatus(String message) {\n if (StringUtils.isBlank(message) || !message.toLowerCase().contains(\"<html\")) {\n return null;\n }\n Matcher matcher = Pattern.compile(\"(?i)(\\\\d{3})\\\\s+([A-Za-z ]+)\").matcher(message);\n if (matcher.find()) {\n return matcher.group(1) + \" \" + matcher.group(2).trim();\n }\n return \"HTML\";\n }\n\n private static String localizeHttpStatus(String status, String rawMessage) {\n String lower = status.toLowerCase();\n if (lower.contains(\"405\")) {\n return \"AI 接口地址不正确或网关不允许该请求方式(405),请检查 API 地址是否填写为服务商提供的接口根地址,例如 https://api.deepseek.com\";\n }\n if (lower.contains(\"404\")) {\n return \"AI 接口地址不存在(404),请检查 API 地址配置\";\n }\n if (lower.contains(\"401\") || lower.contains(\"403\")) {\n return \"无权访问 AI 接口(\" + status.trim() + \"),请检查 API Key 与地址\";\n }\n if (lower.contains(\"502\") || lower.contains(\"503\") || lower.contains(\"504\")) {\n return \"AI 服务网关异常(\" + status.trim() + \"),请稍后重试或检查 API 地址\";\n }\n if (StringUtils.containsIgnoreCase(rawMessage, \"nginx\")) {\n return \"请求被 Web 网关拒绝(\" + status.trim() + \"),API 地址可能填错了,请填写 AI 服务商的接口地址而非网站首页\";\n }\n return \"AI 接口返回异常(\" + status.trim() + \"),请检查 API 地址与网络\";\n }\n\n private static boolean containsChinese(String text) {\n if (StringUtils.isBlank(text)) {\n return false;\n }\n for (int i = 0; i < text.length(); i++) {\n if (Character.UnicodeScript.of(text.charAt(i)) == Character.UnicodeScript.HAN) {\n return true;\n }\n }\n return false;\n }\n\n private static String unescapeJson(String value) {\n if (value == null) {\n return null;\n }\n return value.replace(\"\\\\\\\"\", \"\\\"\")\n .replace(\"\\\\n\", \"\\n\")\n .replace(\"\\\\r\", \"\\r\")\n .replace(\"\\\\t\", \"\\t\")\n .replace(\"\\\\\\\\\", \"\\\\\");\n }\n\n private static String trimLength(String message) {\n if (message == null) {\n return null;\n }\n if (message.length() <= 240) {\n return message;\n }\n return message.substring(0, 240) + \"...\";\n }\n}\n"}, "d2a4383d5943cbe48b718655320fe03a04a356f2": {"t": 1785755757.8273287, "n": 2551, "h": "077e4c5b3889913e", "c": "package com.yf.exam.modules.qu.support;\n\nimport com.yf.exam.core.exception.ServiceException;\nimport org.apache.commons.lang3.StringUtils;\n\n/**\n * AI 调用异常识别与用户可读提示\n */\npublic final class AiCallErrorSupport {\n\n private AiCallErrorSupport() {\n }\n\n public static boolean isTransientError(Throwable error) {\n if (error == null) {\n return false;\n }\n String message = collectMessage(error).toLowerCase();\n return message.contains(\"service is too busy\")\n || message.contains(\"service_unavailable\")\n || message.contains(\"too many requests\")\n || message.contains(\"rate limit\")\n || message.contains(\"ratelimit\")\n || message.contains(\"overloaded\")\n || message.contains(\"temporarily unavailable\")\n || message.contains(\" 429\")\n || message.contains(\"\\\"code\\\":\\\"service_unavailable\")\n || message.contains(\"ai服务繁忙\");\n }\n\n public static String toUserMessage(Throwable error) {\n if (error == null) {\n return \"AI调用失败\";\n }\n if (isTransientError(error)) {\n return \"AI服务繁忙,请稍后重试该批次\";\n }\n String message = collectMessage(error);\n if (StringUtils.isBlank(message)) {\n return \"AI调用失败\";\n }\n if (message.length() > 180) {\n return message.substring(0, 180) + \"...\";\n }\n return message;\n }\n\n public static void sleepBeforeRetry(int attempt) {\n long delayMs = Math.min(15000L, 2000L * Math.max(1, attempt));\n try {\n Thread.sleep(delayMs);\n } catch (InterruptedException e) {\n Thread.currentThread().interrupt();\n }\n }\n\n private static String collectMessage(Throwable error) {\n StringBuilder sb = new StringBuilder();\n Throwable current = error;\n while (current != null) {\n String message = current.getMessage();\n if (StringUtils.isBlank(message) && current instanceof ServiceException) {\n message = ((ServiceException) current).getMsg();\n }\n if (StringUtils.isNotBlank(message)) {\n if (sb.length() > 0) {\n sb.append(' ');\n }\n sb.append(message.trim());\n }\n current = current.getCause();\n }\n if (sb.length() == 0 && error != null) {\n sb.append(error.getClass().getSimpleName());\n }\n return sb.toString();\n }\n}\n"}, "f9ab5b07bc349d6c0c15f7c96659edf0ea92f8e9": {"t": 1785755759.3576226, "n": 2192, "h": "5598906ac54a626c", "c": "package com.yf.exam.modules.qu.support;\n\nimport org.apache.commons.lang3.StringUtils;\n\n/**\n * AI 调用异常识别与用户可读提示\n */\npublic final class AiCallErrorSupport {\n\n private AiCallErrorSupport() {\n }\n\n public static boolean isTransientError(Throwable error) {\n if (error == null) {\n return false;\n }\n String message = collectMessage(error).toLowerCase();\n return message.contains(\"service is too busy\")\n || message.contains(\"service_unavailable\")\n || message.contains(\"too many requests\")\n || message.contains(\"rate limit\")\n || message.contains(\"ratelimit\")\n || message.contains(\"overloaded\")\n || message.contains(\"temporarily unavailable\")\n || message.contains(\" 429\")\n || message.contains(\"\\\"code\\\":\\\"service_unavailable\")\n || message.contains(\"ai服务繁忙\");\n }\n\n public static String toUserMessage(Throwable error) {\n if (error == null) {\n return \"AI调用失败\";\n }\n if (isTransientError(error)) {\n return \"AI服务繁忙,请稍后重试该批次\";\n }\n String message = collectMessage(error);\n if (StringUtils.isBlank(message)) {\n return \"AI调用失败\";\n }\n if (message.length() > 180) {\n return message.substring(0, 180) + \"...\";\n }\n return message;\n }\n\n public static void sleepBeforeRetry(int attempt) {\n long delayMs = Math.min(15000L, 2000L * Math.max(1, attempt));\n try {\n Thread.sleep(delayMs);\n } catch (InterruptedException e) {\n Thread.currentThread().interrupt();\n }\n }\n\n private static String collectMessage(Throwable error) {\n StringBuilder sb = new StringBuilder();\n Throwable current = error;\n while (current != null) {\n if (StringUtils.isNotBlank(current.getMessage())) {\n if (sb.length() > 0) {\n sb.append(' ');\n }\n sb.append(current.getMessage().trim());\n }\n current = current.getCause();\n }\n return sb.toString();\n }\n}\n"}}, "exam-api/src/main/resources/application-dev.yml": {"b13f3b6fd3ef1a6c5ebce2d89d002c084e8b865b": {"t": 1785746346.0403302, "n": 2609, "h": "a3843afa590f850e", "c": "# 开发环境配置文件\nspring:\n # 数据库配置\n datasource:\n type: com.alibaba.druid.pool.DruidDataSource\n driver-class-name: com.mysql.cj.jdbc.Driver\n url: jdbc:mysql://localhost:3306/yf_exam_lite?useSSL=false&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&allowPublicKeyRetrieval=true\n username: root\n password: rootroot\n # druid相关配置\n druid:\n max-active: 5000\n initial-size: 20\n min-idle: 5\n async-init: true\n # 监控统计\n filters: stat,wall\n filter:\n stat:\n log-slow-sql: true\n slow-sql-millis: 5000\n wall:\n config:\n create-table-allow: false\n alter-table-allow: false\n drop-table-allow: false\n truncate-allow: false\n\n # 定时任务配置(Spring Boot 2.7 使用 LocalDataSourceJobStore,勿手动指定 JobStoreTX)\n quartz:\n job-store-type: jdbc\n jdbc:\n initialize-schema: never\n properties:\n org:\n quartz:\n scheduler:\n instanceName: examScheduler\n instanceId: AUTO\n jobStore:\n tablePrefix: QRTZ_\n isClustered: true\n clusterCheckinInterval: 10000\n threadPool:\n threadCount: 10\n threadPriority: 5\n\n# 文件上传配置\nconf:\n upload:\n # 物理文件存储位置,以/结束,windows已正斜杠,如:d:/exam-upload/\n dir: ${user.home}/yfexam-upload/\n # 访问地址,注意不要去除/upload/file/,此节点为虚拟标识符\n # 如:http://localhost:8101/upload/file/exam.jpg,对应物理文件为:/data/upload/exam.jpg\n url: http://localhost:8201/upload/file/\n # 允许上传的文件后缀\n allow-extensions: jpg,jpeg,png\n\n# 开启文档\nswagger:\n enable: true\n\nlogging:\n level:\n root: debug\n path: logs/${spring.application.name}/\n# API调用配置\nexam:\n ai:\n provider: openai\n base-url: https://api.deepseek.com\n api-key: ${DEEPSEEK_API_KEY:}\n # 考试系统上传试卷文档后,自动上传到 RAGFlow 知识库并触发解析\n ragflow-auto-upload: false\n ragflow-base-url: http://localhost:9380\n ragflow-api-key: ${RAGFLOW_API_KEY:}\n # 可选:指定知识库 ID;为空时调用 /api/v1/datasets 列表接口自动获取\n ragflow-dataset-id: ${RAGFLOW_DATASET_ID:}\n # 可选:dataset-id 为空时,按知识库名称匹配;也为空则取列表第一个\n ragflow-dataset-name: ${RAGFLOW_DATASET_NAME:}\n ragflow-dataset-page-size: 10\n ragflow-upload-fail-fast: false\n ragflow-upload-timeout-seconds: 120\n model-name: deepseek-v4-flash\n timeout-seconds: 180\n # 切回 DeepSeek 时使用:\n max-text-length: 20000\n normalize-batch-length: 10000\n normalize-batch-question-count: 20\n normalize-concurrency: 3\n # 本地 RAGFlow 对并发聊天解析更敏感,先串行处理批次,避免一批文件拆成多批后同时失败\n ai-concurrency: 2\n parse-fallback-ratio: 0.3\n parse-fallback-min-expected: 3\n"}, "ec47ed5c4f762bf04602b3caf0437e48711e9829": {"t": 1785755756.6823387, "n": 2609, "h": "a3843afa590f850e", "c": "# 开发环境配置文件\nspring:\n # 数据库配置\n datasource:\n type: com.alibaba.druid.pool.DruidDataSource\n driver-class-name: com.mysql.cj.jdbc.Driver\n url: jdbc:mysql://localhost:3306/yf_exam_lite?useSSL=false&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&allowPublicKeyRetrieval=true\n username: root\n password: rootroot\n # druid相关配置\n druid:\n max-active: 5000\n initial-size: 20\n min-idle: 5\n async-init: true\n # 监控统计\n filters: stat,wall\n filter:\n stat:\n log-slow-sql: true\n slow-sql-millis: 5000\n wall:\n config:\n create-table-allow: false\n alter-table-allow: false\n drop-table-allow: false\n truncate-allow: false\n\n # 定时任务配置(Spring Boot 2.7 使用 LocalDataSourceJobStore,勿手动指定 JobStoreTX)\n quartz:\n job-store-type: jdbc\n jdbc:\n initialize-schema: never\n properties:\n org:\n quartz:\n scheduler:\n instanceName: examScheduler\n instanceId: AUTO\n jobStore:\n tablePrefix: QRTZ_\n isClustered: true\n clusterCheckinInterval: 10000\n threadPool:\n threadCount: 10\n threadPriority: 5\n\n# 文件上传配置\nconf:\n upload:\n # 物理文件存储位置,以/结束,windows已正斜杠,如:d:/exam-upload/\n dir: ${user.home}/yfexam-upload/\n # 访问地址,注意不要去除/upload/file/,此节点为虚拟标识符\n # 如:http://localhost:8101/upload/file/exam.jpg,对应物理文件为:/data/upload/exam.jpg\n url: http://localhost:8201/upload/file/\n # 允许上传的文件后缀\n allow-extensions: jpg,jpeg,png\n\n# 开启文档\nswagger:\n enable: true\n\nlogging:\n level:\n root: debug\n path: logs/${spring.application.name}/\n# API调用配置\nexam:\n ai:\n provider: openai\n base-url: https://api.deepseek.com\n api-key: ${DEEPSEEK_API_KEY:}\n # 考试系统上传试卷文档后,自动上传到 RAGFlow 知识库并触发解析\n ragflow-auto-upload: false\n ragflow-base-url: http://localhost:9380\n ragflow-api-key: ${RAGFLOW_API_KEY:}\n # 可选:指定知识库 ID;为空时调用 /api/v1/datasets 列表接口自动获取\n ragflow-dataset-id: ${RAGFLOW_DATASET_ID:}\n # 可选:dataset-id 为空时,按知识库名称匹配;也为空则取列表第一个\n ragflow-dataset-name: ${RAGFLOW_DATASET_NAME:}\n ragflow-dataset-page-size: 10\n ragflow-upload-fail-fast: false\n ragflow-upload-timeout-seconds: 120\n model-name: deepseek-v4-flash\n timeout-seconds: 180\n # 切回 DeepSeek 时使用:\n max-text-length: 20000\n normalize-batch-length: 10000\n normalize-batch-question-count: 20\n normalize-concurrency: 3\n # 本地 RAGFlow 对并发聊天解析更敏感,先串行处理批次,避免一批文件拆成多批后同时失败\n ai-concurrency: 2\n parse-fallback-ratio: 0.3\n parse-fallback-min-expected: 3\n"}, "d2a4383d5943cbe48b718655320fe03a04a356f2": {"t": 1785755757.8667278, "n": 2608, "h": "d52196668c454dfc", "c": "# 开发环境配置文件\nspring:\n # 数据库配置\n datasource:\n type: com.alibaba.druid.pool.DruidDataSource\n driver-class-name: com.mysql.cj.jdbc.Driver\n url: jdbc:mysql://localhost:3306/yf_exam_lite?useSSL=false&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&allowPublicKeyRetrieval=true\n username: root\n password: rootroot\n # druid相关配置\n druid:\n max-active: 5000\n initial-size: 20\n min-idle: 5\n async-init: true\n # 监控统计\n filters: stat,wall\n filter:\n stat:\n log-slow-sql: true\n slow-sql-millis: 5000\n wall:\n config:\n create-table-allow: false\n alter-table-allow: false\n drop-table-allow: false\n truncate-allow: false\n\n # 定时任务配置(Spring Boot 2.7 使用 LocalDataSourceJobStore,勿手动指定 JobStoreTX)\n quartz:\n job-store-type: jdbc\n jdbc:\n initialize-schema: never\n properties:\n org:\n quartz:\n scheduler:\n instanceName: examScheduler\n instanceId: AUTO\n jobStore:\n tablePrefix: QRTZ_\n isClustered: true\n clusterCheckinInterval: 10000\n threadPool:\n threadCount: 10\n threadPriority: 5\n\n# 文件上传配置\nconf:\n upload:\n # 物理文件存储位置,以/结束,windows已正斜杠,如:d:/exam-upload/\n dir: ${user.home}/yfexam-upload/\n # 访问地址,注意不要去除/upload/file/,此节点为虚拟标识符\n # 如:http://localhost:8101/upload/file/exam.jpg,对应物理文件为:/data/upload/exam.jpg\n url: http://localhost:8201/upload/file/\n # 允许上传的文件后缀\n allow-extensions: jpg,jpeg,png\n\n# 开启文档\nswagger:\n enable: true\n\nlogging:\n level:\n root: debug\n path: logs/${spring.application.name}/\n# API调用配置\nexam:\n ai:\n provider: openai\n base-url: https://api.deepseek.com\n api-key: ${DEEPSEEK_API_KEY:}\n # 考试系统上传试卷文档后,自动上传到 RAGFlow 知识库并触发解析\n ragflow-auto-upload: true\n ragflow-base-url: http://localhost:9380\n ragflow-api-key: ${RAGFLOW_API_KEY:}\n # 可选:指定知识库 ID;为空时调用 /api/v1/datasets 列表接口自动获取\n ragflow-dataset-id: ${RAGFLOW_DATASET_ID:}\n # 可选:dataset-id 为空时,按知识库名称匹配;也为空则取列表第一个\n ragflow-dataset-name: ${RAGFLOW_DATASET_NAME:}\n ragflow-dataset-page-size: 10\n ragflow-upload-fail-fast: false\n ragflow-upload-timeout-seconds: 120\n model-name: deepseek-v4-flash\n timeout-seconds: 180\n # 切回 DeepSeek 时使用:\n max-text-length: 20000\n normalize-batch-length: 10000\n normalize-batch-question-count: 20\n normalize-concurrency: 3\n # 本地 RAGFlow 对并发聊天解析更敏感,先串行处理批次,避免一批文件拆成多批后同时失败\n ai-concurrency: 2\n parse-fallback-ratio: 0.3\n parse-fallback-min-expected: 3\n"}, "f3168add4e9164e00fa727eb9107dd653920d4d0": {"t": 1785755759.34611, "n": 2053, "h": "b0182f70871c7ffc", "c": "# 开发环境配置文件\nspring:\n # 数据库配置\n datasource:\n type: com.alibaba.druid.pool.DruidDataSource\n driver-class-name: com.mysql.cj.jdbc.Driver\n url: jdbc:mysql://localhost:3306/yf_exam_lite?useSSL=false&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&allowPublicKeyRetrieval=true\n username: root\n password: rootroot\n # druid相关配置\n druid:\n max-active: 5000\n initial-size: 20\n min-idle: 5\n async-init: true\n # 监控统计\n filters: stat,wall\n filter:\n stat:\n log-slow-sql: true\n slow-sql-millis: 5000\n wall:\n config:\n create-table-allow: false\n alter-table-allow: false\n drop-table-allow: false\n truncate-allow: false\n\n # 定时任务配置(Spring Boot 2.7 使用 LocalDataSourceJobStore,勿手动指定 JobStoreTX)\n quartz:\n job-store-type: jdbc\n jdbc:\n initialize-schema: never\n properties:\n org:\n quartz:\n scheduler:\n instanceName: examScheduler\n instanceId: AUTO\n jobStore:\n tablePrefix: QRTZ_\n isClustered: true\n clusterCheckinInterval: 10000\n threadPool:\n threadCount: 10\n threadPriority: 5\n\n# 文件上传配置\nconf:\n upload:\n # 物理文件存储位置,以/结束,windows已正斜杠,如:d:/exam-upload/\n dir: ${user.home}/yfexam-upload/\n # 访问地址,注意不要去除/upload/file/,此节点为虚拟标识符\n # 如:http://localhost:8101/upload/file/exam.jpg,对应物理文件为:/data/upload/exam.jpg\n url: http://localhost:8201/upload/file/\n # 允许上传的文件后缀\n allow-extensions: jpg,jpeg,png\n\n# 开启文档\nswagger:\n enable: true\n\nlogging:\n level:\n root: debug\n path: logs/${spring.application.name}/\n# API调用配置\nexam:\n ai:\n base-url: https://api.deepseek.com\n api-key: ${DEEPSEEK_API_KEY:}\n model-name: deepseek-v4-flash\n timeout-seconds: 180\n max-text-length: 20000\n normalize-batch-length: 10000\n normalize-batch-question-count: 20\n normalize-concurrency: 3\n ai-concurrency: 3\n parse-fallback-ratio: 0.3\n parse-fallback-min-expected: 3"}, "c4c5c4a0a8736fe3a1c4ddcaf33096232e42579c": {"t": 1785755760.763598, "n": 2052, "h": "bb298ea0e1832845", "c": "# 开发环境配置文件\nspring:\n # 数据库配置\n datasource:\n type: com.alibaba.druid.pool.DruidDataSource\n driver-class-name: com.mysql.cj.jdbc.Driver\n url: jdbc:mysql://localhost:3306/yf_exam_lite?useSSL=false&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&allowPublicKeyRetrieval=true\n username: root\n password: rootroot\n # druid相关配置\n druid:\n max-active: 5000\n initial-size: 20\n min-idle: 5\n async-init: true\n # 监控统计\n filters: stat,wall\n filter:\n stat:\n log-slow-sql: true\n slow-sql-millis: 5000\n wall:\n config:\n create-table-allow: false\n alter-table-allow: false\n drop-table-allow: false\n truncate-allow: false\n\n # 定时任务配置(Spring Boot 2.7 使用 LocalDataSourceJobStore,勿手动指定 JobStoreTX)\n quartz:\n job-store-type: jdbc\n jdbc:\n initialize-schema: never\n properties:\n org:\n quartz:\n scheduler:\n instanceName: examScheduler\n instanceId: AUTO\n jobStore:\n tablePrefix: QRTZ_\n isClustered: true\n clusterCheckinInterval: 10000\n threadPool:\n threadCount: 10\n threadPriority: 5\n\n# 文件上传配置\nconf:\n upload:\n # 物理文件存储位置,以/结束,windows已正斜杠,如:d:/exam-upload/\n dir: ${user.home}/yfexam-upload/\n # 访问地址,注意不要去除/upload/file/,此节点为虚拟标识符\n # 如:http://localhost:8101/upload/file/exam.jpg,对应物理文件为:/data/upload/exam.jpg\n url: http://localhost:8201/upload/file/\n # 允许上传的文件后缀\n allow-extensions: jpg,jpeg,png\n\n# 开启文档\nswagger:\n enable: true\n\nlogging:\n level:\n root: debug\n path: logs/${spring.application.name}/\n# API调用配置\nexam:\n ai:\n base-url: https://api.deepseek.com\n api-key: ${DEEPSEEK_API_KEY:}\n model-name: deepseek-v4-flash\n timeout-seconds: 60\n max-text-length: 20000\n normalize-batch-length: 10000\n normalize-batch-question-count: 20\n normalize-concurrency: 3\n ai-concurrency: 3\n parse-fallback-ratio: 0.3\n parse-fallback-min-expected: 3"}, "4cb20190fc360efa4cb75b6b9061ec0b4d0447ef": {"t": 1785755762.1354966, "n": 1965, "h": "357bd5a1b5ec9303", "c": "# 开发环境配置文件\nspring:\n # 数据库配置\n datasource:\n type: com.alibaba.druid.pool.DruidDataSource\n driver-class-name: com.mysql.cj.jdbc.Driver\n url: jdbc:mysql://localhost:3306/yf_exam_lite?useSSL=false&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&allowPublicKeyRetrieval=true\n username: root\n password: rootroot\n # druid相关配置\n druid:\n max-active: 5000\n initial-size: 20\n min-idle: 5\n async-init: true\n # 监控统计\n filters: stat,wall\n filter:\n stat:\n log-slow-sql: true\n slow-sql-millis: 5000\n wall:\n config:\n create-table-allow: false\n alter-table-allow: false\n drop-table-allow: false\n truncate-allow: false\n\n # 定时任务配置(Spring Boot 2.7 使用 LocalDataSourceJobStore,勿手动指定 JobStoreTX)\n quartz:\n job-store-type: jdbc\n jdbc:\n initialize-schema: never\n properties:\n org:\n quartz:\n scheduler:\n instanceName: examScheduler\n instanceId: AUTO\n jobStore:\n tablePrefix: QRTZ_\n isClustered: true\n clusterCheckinInterval: 10000\n threadPool:\n threadCount: 10\n threadPriority: 5\n\n# 文件上传配置\nconf:\n upload:\n # 物理文件存储位置,以/结束,windows已正斜杠,如:d:/exam-upload/\n dir: ${user.home}/yfexam-upload/\n # 访问地址,注意不要去除/upload/file/,此节点为虚拟标识符\n # 如:http://localhost:8101/upload/file/exam.jpg,对应物理文件为:/data/upload/exam.jpg\n url: http://localhost:8201/upload/file/\n # 允许上传的文件后缀\n allow-extensions: jpg,jpeg,png\n\n# 开启文档\nswagger:\n enable: true\n\nlogging:\n level:\n root: debug\n path: logs/${spring.application.name}/\n# API调用配置\nexam:\n ai:\n base-url: https://api.deepseek.com\n api-key: ${DEEPSEEK_API_KEY:}\n model-name: deepseek-v4-flash\n timeout-seconds: 60\n max-text-length: 20000\n normalize-batch-length: 10000\n normalize-batch-question-count: 20\n normalize-concurrency: 3"}, "46c0daf9b2b2c052ad13ac1aed99fee08ee4b510": {"t": 1785755763.9681308, "n": 1863, "h": "723a9cca36c073d5", "c": "# 开发环境配置文件\nspring:\n # 数据库配置\n datasource:\n type: com.alibaba.druid.pool.DruidDataSource\n driver-class-name: com.mysql.cj.jdbc.Driver\n url: jdbc:mysql://localhost:3306/yf_exam_lite?useSSL=false&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&allowPublicKeyRetrieval=true\n username: root\n password: rootroot\n # druid相关配置\n druid:\n max-active: 5000\n initial-size: 20\n min-idle: 5\n async-init: true\n # 监控统计\n filters: stat,wall\n filter:\n stat:\n log-slow-sql: true\n slow-sql-millis: 5000\n wall:\n config:\n create-table-allow: false\n alter-table-allow: false\n drop-table-allow: false\n truncate-allow: false\n\n # 定时任务配置(Spring Boot 2.7 使用 LocalDataSourceJobStore,勿手动指定 JobStoreTX)\n quartz:\n job-store-type: jdbc\n jdbc:\n initialize-schema: never\n properties:\n org:\n quartz:\n scheduler:\n instanceName: examScheduler\n instanceId: AUTO\n jobStore:\n tablePrefix: QRTZ_\n isClustered: true\n clusterCheckinInterval: 10000\n threadPool:\n threadCount: 10\n threadPriority: 5\n\n# 文件上传配置\nconf:\n upload:\n # 物理文件存储位置,以/结束,windows已正斜杠,如:d:/exam-upload/\n dir: ${user.home}/yfexam-upload/\n # 访问地址,注意不要去除/upload/file/,此节点为虚拟标识符\n # 如:http://localhost:8101/upload/file/exam.jpg,对应物理文件为:/data/upload/exam.jpg\n url: http://localhost:8201/upload/file/\n # 允许上传的文件后缀\n allow-extensions: jpg,jpeg,png\n\n# 开启文档\nswagger:\n enable: true\n\nlogging:\n level:\n root: debug\n path: logs/${spring.application.name}/\n# API调用配置\nexam:\n ai:\n base-url: https://api.deepseek.com\n api-key: ${DEEPSEEK_API_KEY:}\n model-name: deepseek-v4-flash\n timeout-seconds: 60\n max-text-length: 20000"}, "c09ebfd04be7b3a12422fd1783f080889d08def0": {"t": 1785755765.369936, "n": 1869, "h": "5b66a2884c9a5d19", "c": "# 开发环境配置文件\nspring:\n # 数据库配置\n datasource:\n type: com.alibaba.druid.pool.DruidDataSource\n driver-class-name: com.mysql.cj.jdbc.Driver\n url: jdbc:mysql://localhost:3306/yf_exam_lite?useSSL=false&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&allowPublicKeyRetrieval=true\n username: root\n password: rootroot\n # druid相关配置\n druid:\n max-active: 5000\n initial-size: 20\n min-idle: 5\n async-init: true\n # 监控统计\n filters: stat,wall\n filter:\n stat:\n log-slow-sql: true\n slow-sql-millis: 5000\n wall:\n config:\n create-table-allow: false\n alter-table-allow: false\n drop-table-allow: false\n truncate-allow: false\n\n # 定时任务配置(Spring Boot 2.7 使用 LocalDataSourceJobStore,勿手动指定 JobStoreTX)\n quartz:\n job-store-type: jdbc\n jdbc:\n initialize-schema: never\n properties:\n org:\n quartz:\n scheduler:\n instanceName: examScheduler\n instanceId: AUTO\n jobStore:\n tablePrefix: QRTZ_\n isClustered: true\n clusterCheckinInterval: 10000\n threadPool:\n threadCount: 10\n threadPriority: 5\n\n# 文件上传配置\nconf:\n upload:\n # 物理文件存储位置,以/结束,windows已正斜杠,如:d:/exam-upload/\n dir: /Users/van/Documents/work/upload/\n # 访问地址,注意不要去除/upload/file/,此节点为虚拟标识符\n # 如:http://localhost:8101/upload/file/exam.jpg,对应物理文件为:/data/upload/exam.jpg\n url: http://localhost:8201/upload/file/\n # 允许上传的文件后缀\n allow-extensions: jpg,jpeg,png\n\n# 开启文档\nswagger:\n enable: true\n\nlogging:\n level:\n root: debug\n path: logs/${spring.application.name}/\n# API调用配置\nexam:\n ai:\n base-url: https://api.deepseek.com\n api-key: ${DEEPSEEK_API_KEY:}\n model-name: deepseek-v4-flash\n timeout-seconds: 60\n max-text-length: 20000"}, "27ad69c8cfc180380e4a33c77250f50655984f02": {"t": 1785755766.606263, "n": 2092, "h": "78581d624debe865", "c": "# 开发环境配置文件\nspring:\n # 数据库配置\n datasource:\n type: com.alibaba.druid.pool.DruidDataSource\n driver-class-name: com.mysql.cj.jdbc.Driver\n url: jdbc:mysql://localhost:3306/yf_exam_lite?useSSL=false&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&allowPublicKeyRetrieval=true\n username: root\n password: rootroot\n # druid相关配置\n druid:\n max-active: 5000\n initial-size: 20\n min-idle: 5\n async-init: true\n # 监控统计\n filters: stat,wall\n filter:\n stat:\n log-slow-sql: true\n slow-sql-millis: 5000\n wall:\n config:\n create-table-allow: false\n alter-table-allow: false\n drop-table-allow: false\n truncate-allow: false\n\n # 定时任务配置\n quartz:\n # 数据库方式\n job-store-type: jdbc\n # quartz 相关属性配置\n properties:\n org:\n quartz:\n scheduler:\n instanceName: eamScheduler\n instanceId: AUTO\n jobStore:\n class: org.quartz.impl.jdbcjobstore.JobStoreTX\n driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate\n tablePrefix: QRTZ_\n isClustered: true\n clusterCheckinInterval: 10000\n useProperties: false\n threadPool:\n class: org.quartz.simpl.SimpleThreadPool\n threadCount: 10\n threadPriority: 5\n threadsInheritContextClassLoaderOfInitializingThread: true\n\n# 文件上传配置\nconf:\n upload:\n # 物理文件存储位置,以/结束,windows已正斜杠,如:d:/exam-upload/\n dir: /Users/van/Documents/work/upload/\n # 访问地址,注意不要去除/upload/file/,此节点为虚拟标识符\n # 如:http://localhost:8101/upload/file/exam.jpg,对应物理文件为:/data/upload/exam.jpg\n url: http://localhost:8201/upload/file/\n # 允许上传的文件后缀\n allow-extensions: jpg,jpeg,png\n\n# 开启文档\nswagger:\n enable: true\n\nlogging:\n level:\n root: debug\n path: logs/${spring.application.name}/\n# API调用配置\nexam:\n ai:\n base-url: https://api.deepseek.com\n api-key: ${DEEPSEEK_API_KEY:}\n model-name: deepseek-v4-flash\n timeout-seconds: 60\n max-text-length: 20000"}, "96964dc17114f137a1329127bf5e33b1f2a878ae": {"t": 1785755768.1733057, "n": 1913, "h": "f57ee4c105e52157", "c": "# 开发环境配置文件\nspring:\n # 数据库配置\n datasource:\n type: com.alibaba.druid.pool.DruidDataSource\n driver-class-name: com.mysql.cj.jdbc.Driver\n url: jdbc:mysql://localhost:3306/yf_exam_lite?useSSL=false&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&allowPublicKeyRetrieval=true\n username: root\n password: root@123\n # druid相关配置\n druid:\n max-active: 5000\n initial-size: 20\n min-idle: 5\n async-init: true\n # 监控统计\n filters: stat,wall\n filter:\n stat:\n log-slow-sql: true\n slow-sql-millis: 5000\n wall:\n config:\n create-table-allow: false\n alter-table-allow: false\n drop-table-allow: false\n truncate-allow: false\n\n # 定时任务配置\n quartz:\n # 数据库方式\n job-store-type: jdbc\n # quartz 相关属性配置\n properties:\n org:\n quartz:\n scheduler:\n instanceName: eamScheduler\n instanceId: AUTO\n jobStore:\n class: org.quartz.impl.jdbcjobstore.JobStoreTX\n driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate\n tablePrefix: QRTZ_\n isClustered: true\n clusterCheckinInterval: 10000\n useProperties: false\n threadPool:\n class: org.quartz.simpl.SimpleThreadPool\n threadCount: 10\n threadPriority: 5\n threadsInheritContextClassLoaderOfInitializingThread: true\n\n# 文件上传配置\nconf:\n upload:\n # 物理文件存储位置,以/结束,windows已正斜杠,如:d:/exam-upload/\n dir: /Users/van/Documents/work/upload/\n # 访问地址,注意不要去除/upload/file/,此节点为虚拟标识符\n # 如:http://localhost:8101/upload/file/exam.jpg,对应物理文件为:/data/upload/exam.jpg\n url: http://localhost:8201/upload/file/\n # 允许上传的文件后缀\n allow-extensions: jpg,jpeg,png\n\n# 开启文档\nswagger:\n enable: true\n\nlogging:\n level:\n root: debug\n path: logs/${spring.application.name}/\n"}, "0777de7269c34adc5579d2dd6df61a50e0abda2e": {"t": 1785755769.6894522, "n": 1914, "h": "01ee6e089b0eff32", "c": "# 开发环境配置文件\nspring:\n # 数据库配置\n datasource:\n type: com.alibaba.druid.pool.DruidDataSource\n driver-class-name: com.mysql.cj.jdbc.Driver\n url: jdbc:mysql://localhost:3306/yf_exam_lite?useSSL=false&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&allowPublicKeyRetrieval=true\n username: root\n password: root@123\n # druid相关配置\n druid:\n max-active: 5000\n initial-size: 20\n min-idle: 5\n async-init: true\n # 监控统计\n filters: stat,wall\n filter:\n stat:\n log-slow-sql: true\n slow-sql-millis: 5000\n wall:\n config:\n create-table-allow: false\n alter-table-allow: false\n drop-table-allow: false\n truncate-allow: false\n\n # 定时任务配置\n quartz:\n # 数据库方式\n job-store-type: jdbc\n # quartz 相关属性配置\n properties:\n org:\n quartz:\n scheduler:\n instanceName: examScheduler\n instanceId: AUTO\n jobStore:\n class: org.quartz.impl.jdbcjobstore.JobStoreTX\n driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate\n tablePrefix: QRTZ_\n isClustered: true\n clusterCheckinInterval: 10000\n useProperties: false\n threadPool:\n class: org.quartz.simpl.SimpleThreadPool\n threadCount: 10\n threadPriority: 5\n threadsInheritContextClassLoaderOfInitializingThread: true\n\n# 文件上传配置\nconf:\n upload:\n # 物理文件存储位置,以/结束,windows已正斜杠,如:d:/exam-upload/\n dir: /Users/van/Documents/work/upload/\n # 访问地址,注意不要去除/upload/file/,此节点为虚拟标识符\n # 如:http://localhost:8101/upload/file/exam.jpg,对应物理文件为:/data/upload/exam.jpg\n url: http://localhost:8201/upload/file/\n # 允许上传的文件后缀\n allow-extensions: jpg,jpeg,png\n\n# 开启文档\nswagger:\n enable: true\n\nlogging:\n level:\n root: debug\n path: logs/${spring.application.name}/\n"}, "50bc3d60f6e7eea2175de923a671833d064e4bd2": {"t": 1785755771.1302114, "n": 1087, "h": "26fb28df46b07d24", "c": "# 开发环境配置文件\nspring:\n # 数据库配置\n datasource:\n type: com.alibaba.druid.pool.DruidDataSource\n driver-class-name: com.mysql.cj.jdbc.Driver\n url: jdbc:mysql://localhost:3306/yf_exam?useSSL=false&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&allowPublicKeyRetrieval=true\n username: root\n password: root@123\n filters: stat\n max-active: 5000\n initial-size: 20\n max-wait: 6000\n min-idle: 1\n time-between-eviction-runs-millis: 60000\n min-evictable-idle-time-millis: 300000\n test-while-idle: true\n test-on-borrow: false\n test-on-return: false\n pool-prepared-statements: true\n max-open-prepared-statements: 1000\n async-init: true\n\n# 文件上传配置\nconf:\n upload:\n # 物理文件存储位置,以/结束,windows已正斜杠,如:d:/exam-upload/\n dir: /Users/van/Documents/work/upload/\n # 访问地址,注意不要去除/upload/file/,此节点为虚拟标识符\n # 如:http://localhost:8101/upload/file/exam.jpg,对应物理文件为:/data/upload/exam.jpg\n url: http://localhost:8111/upload/file/\n\n# 开启文档\nswagger:\n enable: true\n\nlogging:\n level:\n root: debug\n path: logs/${spring.application.name}/\n"}}}}