1 line
73 KiB
JSON
1 line
73 KiB
JSON
{"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"}}}} |