feat(ai): S8 导入解析切 Dify chat 解析器 + provider 选择

MuseAiImportParseService 从单点注入 NewApiMuseAiImportLlmParser 改为按
muse.ai.import.provider(缺省 dify)从 List<MuseAiImportLlmParser> 选择;来源标识
由选中 parser 的 summary.parser 自报(dify_import_llm / new_api_import_llm)。

新增 DifyMuseAiImportLlmParser:走 Dify「muse-全书解析」chat app(passthrough M3、
克隆自写作 app)的 /chat-messages blocking 调用,把镜像 New-API 的 system+user 解析
prompt 合并成一条 query 发出,从 answer 消费章节 JSON。语义逐条对齐 New-API 版:
401/403 fail-closed 不重试、408/429/5xx 退避重试、输入上限 180k、空/超 300 章节、
脱敏 summary、直连绕代理、key 不落库不打印。截断检测差异:Dify chat 无 finish_reason,
改为对 answer 做 JSON 完整性校验(解析失败→AI_IMPORT_LLM_TRUNCATED 不可重试)。
MuseAiProperties.Dify 补 maxAttempts/retryBackoffSeconds;新增 ImportParse.provider。

规格允许 app/workflow;创始人定 chat app 顶替空 workflow 壳 fed4d25c。p1r 配置
MUSE_AI_DIFY_PARSER_APP_ID/API_KEY 指向新 chat app 17438ceb。

验证(独立重跑核验,不采信子代理自报):
- ai 模块单测 20/0/0/0(DifyImportLlmParser 7、ImportParseService provider 选择 11、
  NewApi 2)+ content ImportParseService IT 16/0/0/0,MVN_EXIT=0 BUILD SUCCESS
- Dify 解析 app 直连 /v1/chat-messages 冒烟真出严格章节 JSON {"chapters":[...]}
- 代码 review:确认真 chat 契约(/chat-messages、读 answer、无 workflow 残留)
未做:完整上传链 live 全书解析(import→对象存储→storageRef→parse job→LLM)属 S9
黄金旅程/import-wizard.spec.ts 范畴——inline contentText 走同步 splitter 绕 LLM,
LLM 路径需对象存储上传流,非本次 parser 改动引入。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-08 07:59:26 -07:00
parent bdc07a4321
commit 0b109a2961
8 changed files with 774 additions and 6 deletions

View File

@ -0,0 +1,359 @@
package cn.iocoder.muse.module.ai.application.muse;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.module.ai.framework.ai.config.MuseAiProperties;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.net.ConnectException;
import java.net.ProxySelector;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 基于 Difymuse-全书解析chat app(passthrough,pre_prompt )的导入章节解析器
*
* <p>语义与 {@link NewApiMuseAiImportLlmParser} 逐条对齐:401/403 fail-closed 不重试408/429/5xx 退避重试
* token/provider 原文不落库不打印模型正文只在本次解析内转成 AI Shadow 章节差异仅在传输载体:
* Dify chat blocking 调用,把镜像 New-API system+user prompt 合并成一条 query 发出,
* {@code answer} 消费模型产出的章节 JSON 字符串</p>
*
* <p>截断检测:Dify chat 没有 New-API 那种 provider {@code finish_reason=length} 信号,只能靠
* 对模型产出 JSON 本身做完整性校验JSON 解析失败即判定输出被截断( {@link #mapChatResult}),
* 再叠加章节数合理性( / 超上限)检查</p>
*/
@Component
@Slf4j
public class DifyMuseAiImportLlmParser implements MuseAiImportLlmParser {
/** summary/落库来源标识,与 New-API 版对称。 */
private static final String PARSER_TAG = "dify_import_llm";
/** provider 选择键,归一化后与 muse.ai.import.provider 比较。 */
private static final String PROVIDER_KEY = "dify";
/** S8 全书解析 chat app 固定凭据 ref;真实 API Key 由 env 注入到 muse.ai.dify.credentials,禁止落库/打印。 */
private static final String PARSER_CREDENTIAL_REF = "dify-parser-s1";
/** Dify chat blocking 端点;baseUrl 已含 /v1,故只拼 /chat-messages(app 由 API Key 确定)。 */
private static final String CHAT_MESSAGES_PATH = "/chat-messages";
private static final String RESPONSE_MODE_BLOCKING = "blocking";
private static final int MAX_LLM_INPUT_CHARS = 180_000;
private static final int MAX_LLM_CHAPTERS = 300;
private static final ProxySelector DIRECT_PROXY_SELECTOR = ProxySelector.of(null);
private final MuseAiProperties.Dify properties;
private final HttpClient httpClient;
public DifyMuseAiImportLlmParser(MuseAiProperties museAiProperties) {
this.properties = museAiProperties == null ? null : museAiProperties.getDify();
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(positive(connectTimeoutSeconds(), 5)))
// 全书解析同样调用内网 Dify chat app,必须绕开 JVM/系统代理,避免可用服务被误判为 502
.proxy(DIRECT_PROXY_SELECTOR)
.build();
}
@Override
public String providerKey() {
return PROVIDER_KEY;
}
@Override
public LlmParseResult parse(LlmParseCommand command) {
// fail-closed:Dify 未启用 / baseUrl 缺失 / dify-parser-s1 凭据缺失,一律不可重试地判为未配置
if (!isConfigured()) {
return LlmParseResult.failed("AI_IMPORT_LLM_UNAVAILABLE", "AI 全书解析服务未配置", false,
Map.of("parser", PARSER_TAG));
}
if (command == null || !StringUtils.hasText(command.contentText())) {
return LlmParseResult.failed("AI_IMPORT_LLM_EMPTY_INPUT", "AI 全书解析输入为空", false,
Map.of("parser", PARSER_TAG));
}
if (command.contentText().length() > MAX_LLM_INPUT_CHARS) {
return LlmParseResult.failed("AI_IMPORT_LLM_INPUT_TOO_LARGE",
"当前 AI 全书解析输入超过模型上下文上限,请先按章节拆分后重试", false,
Map.of("parser", PARSER_TAG, "inputChars", command.contentText().length()));
}
int maxAttempts = maxAttempts();
LlmParseResult lastResult = null;
for (int attemptNo = 1; attemptNo <= maxAttempts; attemptNo++) {
lastResult = parseOnce(command);
LlmParseResult resultWithAttempts = withAttemptSummary(lastResult, attemptNo, maxAttempts);
// 成功 / 不可重试(如鉴权失败坏响应)/ 已到上限:立即返回,不再退避
if (resultWithAttempts.success() || !resultWithAttempts.retryable() || attemptNo >= maxAttempts) {
return resultWithAttempts;
}
sleepBeforeRetry(command, attemptNo);
}
return withAttemptSummary(lastResult, maxAttempts, maxAttempts);
}
private LlmParseResult parseOnce(LlmParseCommand command) {
try {
HttpRequest request = buildRequest(command);
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
return mapResponse(command, response);
} catch (HttpTimeoutException ex) {
return LlmParseResult.failed("AI_IMPORT_LLM_TIMEOUT", "AI 全书解析请求超时", true,
Map.of("parser", PARSER_TAG));
} catch (ConnectException ex) {
return LlmParseResult.failed("AI_IMPORT_LLM_CONNECT_FAILED", "AI 全书解析服务连接失败", true,
Map.of("parser", PARSER_TAG));
} catch (IOException ex) {
return LlmParseResult.failed("AI_IMPORT_LLM_CONNECT_FAILED", "AI 全书解析服务连接失败", true,
Map.of("parser", PARSER_TAG));
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
return LlmParseResult.failed("AI_IMPORT_LLM_TIMEOUT", "AI 全书解析请求被中断", true,
Map.of("parser", PARSER_TAG));
} catch (RuntimeException ex) {
// 只记脱敏错误类型,绝不打印响应体/凭据;Dify envelope 无法解析走此分支,不可重试
log.warn("AI 全书解析(Dify)运行时失败jobId={}, errorType={}", command.jobId(), ex.getClass().getSimpleName());
return LlmParseResult.failed("AI_IMPORT_LLM_BAD_RESPONSE", "AI 全书解析响应无法解析", false,
Map.of("parser", PARSER_TAG));
}
}
private LlmParseResult withAttemptSummary(LlmParseResult result, int attemptNo, int maxAttempts) {
if (result == null) {
return LlmParseResult.failed("AI_IMPORT_LLM_UNKNOWN", "AI 全书解析未返回结果", false,
Map.of("parser", PARSER_TAG, "attemptNo", attemptNo, "maxAttempts", maxAttempts));
}
Map<String, Object> summary = new LinkedHashMap<>(result.summary() == null ? Map.of() : result.summary());
summary.put("attemptNo", attemptNo);
summary.put("maxAttempts", maxAttempts);
return new LlmParseResult(result.success(), result.chapters(), result.errorCode(), result.errorMessage(),
result.retryable(), summary);
}
private void sleepBeforeRetry(LlmParseCommand command, int attemptNo) {
int backoffSeconds = backoffSeconds(attemptNo);
log.warn("AI 全书解析(Dify)可重试失败准备重试jobId={}, attemptNo={}, nextAttemptNo={}, backoffSeconds={}",
command.jobId(), attemptNo, attemptNo + 1, backoffSeconds);
if (backoffSeconds <= 0) {
return;
}
try {
Thread.sleep(Duration.ofSeconds(backoffSeconds).toMillis());
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
private HttpRequest buildRequest(LlmParseCommand command) {
// chat app passthrough(pre_prompt ),故把 system 指令与 user 数据合并成一条 query 发出;inputs 留空
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("inputs", Map.of());
payload.put("query", systemPrompt() + "\n\n" + userPrompt(command));
payload.put("response_mode", RESPONSE_MODE_BLOCKING);
payload.put("conversation_id", "");
payload.put("user", user(command));
String body = JsonUtils.toJsonString(payload);
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(chatMessagesUri())
.timeout(Duration.ofSeconds(positive(nonStreamReadTimeoutSeconds(), 90)))
// 凭据只在 Authorization 头出现,来自安全配置解析,不进 body/日志/summary
.header("Authorization", "Bearer " + apiKey(PARSER_CREDENTIAL_REF))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("X-Muse-Scene", "ai_import_parse")
.POST(HttpRequest.BodyPublishers.ofString(body));
if (StringUtils.hasText(command.commandId())) {
builder.header("X-Request-Id", command.commandId());
builder.header("X-Trace-Id", command.commandId());
}
return builder.build();
}
private LlmParseResult mapResponse(LlmParseCommand command, HttpResponse<String> response) {
int status = response.statusCode();
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("parser", PARSER_TAG);
summary.put("providerStatusCode", status);
response.headers().firstValue("X-Request-Id").ifPresent(value -> summary.put("providerRequestId", value));
// HTTP 层错误:错误码/重试策略与 New-API 版逐条一致(401/403 不重试,408/429/5xx 重试)
if (status < 200 || status >= 300) {
return LlmParseResult.failed(errorCode(status), errorMessage(status), isRetryableStatus(status), summary);
}
// Dify chat blocking 成功响应:{"answer":"<JSON字符串>","metadata":{...},"message_id":..}
// chat app workflow status/outputs 概念:HTTP 200 answer 非空即视为成功,进入 JSON 解析
JsonNode root = JsonUtils.parseTree(response.body());
String messageId = textOrNull(root.path("message_id"));
if (StringUtils.hasText(messageId)) {
summary.put("providerRequestId", messageId);
}
String answer = textOrNull(root.path("answer"));
if (!StringUtils.hasText(answer)) {
return LlmParseResult.failed("AI_IMPORT_LLM_BAD_RESPONSE", "AI 全书解析响应缺少正文", false, summary);
}
return mapChatResult(answer, summary);
}
private LlmParseResult mapChatResult(String answer, Map<String, Object> summary) {
String json = stripJsonFence(answer);
JsonNode resultRoot;
try {
resultRoot = JsonUtils.parseTree(json);
} catch (RuntimeException ex) {
// 截断检测核心:Dify finish_reason 信号,模型产出 JSON 不完整(解析失败)即视为输出被截断
return LlmParseResult.failed("AI_IMPORT_LLM_TRUNCATED", "AI 全书解析响应被截断", false, summary);
}
List<LlmChapter> chapters = extractChapters(resultRoot);
if (chapters.isEmpty()) {
return LlmParseResult.failed("AI_IMPORT_LLM_NO_CHAPTERS", "AI 全书解析未返回有效章节", false, summary);
}
if (chapters.size() > MAX_LLM_CHAPTERS) {
return LlmParseResult.failed("AI_IMPORT_LLM_TOO_MANY_CHAPTERS", "AI 全书解析章节数超过上限", false, summary);
}
summary.put("chapterCount", chapters.size());
return LlmParseResult.success(chapters, summary);
}
private List<LlmChapter> extractChapters(JsonNode resultRoot) {
JsonNode chaptersNode = resultRoot.isArray() ? resultRoot : resultRoot.path("chapters");
List<LlmChapter> chapters = new ArrayList<>();
if (!chaptersNode.isArray()) {
return chapters;
}
for (JsonNode item : chaptersNode) {
String title = item.path("title").asText("").strip();
String content = item.path("content").asText("").strip();
if (!StringUtils.hasText(content)) {
continue;
}
chapters.add(new LlmChapter(StringUtils.hasText(title) ? title : "导入章节", content));
}
return chapters;
}
private String stripJsonFence(String content) {
String normalized = content == null ? "" : content.strip();
if (normalized.startsWith("```")) {
normalized = normalized.replaceFirst("(?s)^```(?:json)?\\s*", "");
normalized = normalized.replaceFirst("(?s)\\s*```$", "");
}
return normalized.strip();
}
private String systemPrompt() {
// 文案照搬 NewApiMuseAiImportLlmParser.systemPrompt();chat app passthrough,须由 query 携带全部指令
return """
你是长篇小说导入解析器只输出 JSON不要解释
JSON 格式必须是 {"chapters":[{"title":"章节标题","content":"章节正文"}]}
章节正文必须来自用户提供文本不得补写改写总结或虚构
如果原文已有章节标题按原标题切分如果没有标题按叙事段落保守切分并给出中性标题
""";
}
private String userPrompt(LlmParseCommand command) {
// 文案照搬 NewApiMuseAiImportLlmParser.userPrompt(),保证与 New-API 版产出同一份严格章节 JSON
return """
文件名%s
文件格式%s
内容摘要哈希%s
请把下面全文切分为可供用户审核的章节 JSON
%s
""".formatted(command.fileName(), command.format(), command.contentHash(), command.contentText());
}
private URI chatMessagesUri() {
return URI.create(trimRight(properties.getBaseUrl(), "/") + CHAT_MESSAGES_PATH);
}
private String user(LlmParseCommand command) {
Long owner = command == null ? null : command.ownerUserId();
return "muse-import-" + (owner == null ? "unknown" : owner);
}
private String apiKey(String credentialRef) {
if (properties == null || properties.getCredentials() == null) {
return null;
}
return properties.getCredentials().stream()
.filter(credential -> credential != null && credentialRef.equals(credential.getRef()))
.map(MuseAiProperties.Dify.Credential::getApiKey)
.filter(StringUtils::hasText)
.findFirst()
.orElse(null);
}
private boolean isConfigured() {
return properties != null
&& properties.isEnabled()
&& StringUtils.hasText(properties.getBaseUrl())
&& StringUtils.hasText(apiKey(PARSER_CREDENTIAL_REF));
}
private String errorCode(int status) {
return switch (status) {
case 401, 403 -> "AI_IMPORT_LLM_AUTH_FAILED";
case 408, 504 -> "AI_IMPORT_LLM_TIMEOUT";
case 429 -> "AI_IMPORT_LLM_RATE_LIMITED";
default -> status >= 500 ? "AI_IMPORT_LLM_PROVIDER_5XX" : "AI_IMPORT_LLM_BAD_REQUEST";
};
}
private String errorMessage(int status) {
return switch (status) {
case 401, 403 -> "AI 全书解析鉴权失败";
case 408, 504 -> "AI 全书解析请求超时";
case 429 -> "AI 全书解析被限流";
default -> status >= 500 ? "AI 全书解析服务暂不可用" : "AI 全书解析请求被拒绝";
};
}
private boolean isRetryableStatus(int status) {
return status == 408 || status == 429 || status == 500 || status == 502 || status == 503 || status == 504;
}
private Integer connectTimeoutSeconds() {
return properties == null ? null : properties.getConnectTimeoutSeconds();
}
private Integer nonStreamReadTimeoutSeconds() {
return properties == null ? null : properties.getNonStreamReadTimeoutSeconds();
}
private int maxAttempts() {
return positive(properties == null ? null : properties.getMaxAttempts(), 3);
}
private int backoffSeconds(int attemptNo) {
List<Integer> backoff = properties == null ? null : properties.getRetryBackoffSeconds();
if (backoff == null || backoff.isEmpty()) {
return 0;
}
int index = Math.min(Math.max(attemptNo - 1, 0), backoff.size() - 1);
Integer seconds = backoff.get(index);
return seconds == null || seconds < 0 ? 0 : seconds;
}
private int positive(Integer value, int defaultValue) {
return value == null || value <= 0 ? defaultValue : value;
}
private String trimRight(String value, String suffix) {
String result = value == null ? "" : value;
while (result.endsWith(suffix)) {
result = result.substring(0, result.length() - suffix.length());
}
return result;
}
private static String textOrNull(JsonNode node) {
return node == null || node.isMissingNode() || node.isNull() ? null : node.asText();
}
}

View File

@ -15,6 +15,13 @@ public interface MuseAiImportLlmParser {
*/
LlmParseResult parse(LlmParseCommand command);
/**
* parser 归属的 runtime provider 标识 {@code new-api} / {@code dify}
*
* <p> {@code muse.ai.import.provider} 在多实现并存时按 provider 选择;归一化比较由调用方负责</p>
*/
String providerKey();
/**
* LLM 解析命令{@code contentText} 是短生命周期载荷调用方禁止记录
*/

View File

@ -4,6 +4,7 @@ import cn.iocoder.muse.framework.common.exception.ServiceException;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.module.ai.api.MuseAiImportParseApi;
import cn.iocoder.muse.module.ai.application.muse.facade.MuseContentWorkOwnerFacade;
import cn.iocoder.muse.module.ai.framework.ai.config.MuseAiProperties;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiChapterParseResultDO;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiParseJobDO;
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAiChapterParseResultMapper;
@ -61,6 +62,8 @@ public class MuseAiImportParseService implements MuseAiImportParseApi {
private static final int MAX_EXTRACTED_CHARS = 1_000_000;
private static final int MAX_PARSE_CHAPTERS = 300;
private static final int PREVIEW_MAX_CHARS = 240;
// 全书解析 provider 缺省 dify(S8 New-API 切到 Dify;New-API 链路保留以便回退)
private static final String DEFAULT_IMPORT_PROVIDER = "dify";
private static final Pattern MARKDOWN_HEADING_PATTERN = Pattern.compile("^#{1,3}\\s+(.{1,120})$");
private static final Pattern CN_CHAPTER_HEADING_PATTERN = Pattern.compile("^第[\\p{N}一二三四五六七八九十百千万零〇两]+[章节卷回].{0,120}$");
private static final Pattern EN_CHAPTER_HEADING_PATTERN = Pattern.compile("(?i)^chapter\\s+[\\p{Alnum}]+.{0,120}$");
@ -80,8 +83,14 @@ public class MuseAiImportParseService implements MuseAiImportParseApi {
private MuseAiChapterParseResultMapper chapterResultMapper;
@Resource
private MuseContentWorkOwnerFacade contentWorkOwnerFacade;
/**
* 所有全书解析 parser 实现(new-api / dify),运行时按 muse.ai.import.provider 选择;
* List 注入而非单点,避免两实现并存时的注入歧义
*/
@Resource
private MuseAiImportLlmParser llmParser;
private List<MuseAiImportLlmParser> importLlmParsers;
@Resource
private MuseAiProperties museAiProperties;
public MuseAiImportParseService(ObjectProvider<FileApi> fileApiProvider) {
this.fileApi = fileApiProvider.getIfAvailable();
@ -326,7 +335,12 @@ public class MuseAiImportParseService implements MuseAiImportParseApi {
private ParseExecution parseWithLlm(MuseAiParseJobDO job, String format, String normalizedText, String contentHash,
ExtractedText extractedText) {
MuseAiImportLlmParser.LlmParseResult llmResult = llmParser.parse(new MuseAiImportLlmParser.LlmParseCommand(
MuseAiImportLlmParser parser = resolveImportLlmParser();
if (parser == null) {
// 没有任何可用的全书解析 parser 实现:直接 fail-closed,不落任何 shadow 章节
return ParseExecution.failed("llm_parse", "AI_IMPORT_LLM_UNAVAILABLE", "AI 全书解析服务未配置", false);
}
MuseAiImportLlmParser.LlmParseResult llmResult = parser.parse(new MuseAiImportLlmParser.LlmParseCommand(
job.getCommandId(), job.getId(), job.getOwnerUserId(), job.getWorkId(), job.getFileName(), format,
normalizedText, contentHash, readJson(job.getParseConfig())));
if (!llmResult.success()) {
@ -354,7 +368,59 @@ public class MuseAiImportParseService implements MuseAiImportParseApi {
Map<String, Object> summary = new LinkedHashMap<>(llmResult.summary());
summary.put("extractor", extractedText.parser());
summary.put("extractedChars", normalizedText.length());
return ParseExecution.success(chapters, contentHash, "new_api_import_llm", summary);
// 来源标识由选中 parser summary.parser 自报(new_api_import_llm / dify_import_llm),避免此处硬编码 provider
String parserTag = stringValue(llmResult.summary() == null ? null : llmResult.summary().get("parser"));
return ParseExecution.success(chapters, contentHash,
StringUtils.hasText(parserTag) ? parserTag : "ai_import_llm", summary);
}
/**
* {@code muse.ai.import.provider} 选择全书解析 parser(缺省 dify)
*
* <p>WHY:S8 把全书解析从 New-API 切到 Dify,但两条链路并存以便回退单实现场景(仅注册一个 parser,
* 或单测注入单个 mock)直接返回,不依赖配置;多实现时按归一化 provider 匹配,匹配不到回退默认再兜底首个</p>
*/
private MuseAiImportLlmParser resolveImportLlmParser() {
List<MuseAiImportLlmParser> parsers = importLlmParsers;
if (parsers == null || parsers.isEmpty()) {
return null;
}
if (parsers.size() == 1) {
return parsers.get(0);
}
String provider = importProvider();
MuseAiImportLlmParser matched = matchProvider(parsers, provider);
if (matched != null) {
return matched;
}
// 配置的 provider 无对应实现:回退缺省 dify,再兜底首个,保证解析链路不空转
log.warn("AI 全书解析 provider 无匹配实现回退默认configuredProvider={}", provider);
MuseAiImportLlmParser fallback = matchProvider(parsers, DEFAULT_IMPORT_PROVIDER);
return fallback != null ? fallback : parsers.get(0);
}
private MuseAiImportLlmParser matchProvider(List<MuseAiImportLlmParser> parsers, String provider) {
for (MuseAiImportLlmParser parser : parsers) {
if (provider.equals(normalizeProvider(parser.providerKey()))) {
return parser;
}
}
return null;
}
private String importProvider() {
String configured = museAiProperties == null || museAiProperties.getImport() == null
? null : museAiProperties.getImport().getProvider();
String normalized = normalizeProvider(configured);
return StringUtils.hasText(normalized) ? normalized : DEFAULT_IMPORT_PROVIDER;
}
private String normalizeProvider(String provider) {
if (!StringUtils.hasText(provider)) {
return null;
}
// 统一小写并把下划线视作连字符,兼容 new_api / new-api / NEW_API 等写法
return provider.trim().toLowerCase(Locale.ROOT).replace('_', '-');
}
private ExtractedText extractText(String format, byte[] bytes) {

View File

@ -48,6 +48,11 @@ public class NewApiMuseAiImportLlmParser implements MuseAiImportLlmParser {
.build();
}
@Override
public String providerKey() {
return "new-api";
}
@Override
public LlmParseResult parse(LlmParseCommand command) {
if (!isConfigured()) {

View File

@ -1,6 +1,8 @@
package cn.iocoder.muse.module.ai.framework.ai.config;
import lombok.AccessLevel;
import lombok.Data;
import lombok.Getter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.List;
@ -75,6 +77,22 @@ public class MuseAiProperties {
*/
private Dify dify = new Dify();
/**
* Muse 导入全书解析 provider 选择(S8)
*
* <p>字段名不能直接叫 {@code import}(Java 关键字),故手写 {@link #getImport()}
* {@code muse.ai.import.provider} 走标量 relaxed-binding( {@code MUSE_AI_IMPORT_PROVIDER})</p>
*/
@Getter(AccessLevel.NONE)
private final ImportParse importParse = new ImportParse();
/**
* 绑定 {@code muse.ai.import.*};返回固定实例,provider 缺省 dify
*/
public ImportParse getImport() {
return importParse;
}
/**
* Muse Events 发布配置
*/
@ -272,6 +290,16 @@ public class MuseAiProperties {
private Integer nonStreamReadTimeoutSeconds = 180;
private Integer totalTimeoutSeconds = 180;
/**
* 全书解析(S8)可重试失败的最大尝试次数408/429/5xx 退避重试,401/403 fail-closed 不重试
*/
private Integer maxAttempts = 3;
/**
* 各次重试前的退避秒数;下标对应第几次重试,超出取最后一档
*/
private List<Integer> retryBackoffSeconds = List.of(1, 2, 4);
@Data
public static class Credential {
@ -332,4 +360,16 @@ public class MuseAiProperties {
}
@Data
public static class ImportParse {
/**
* 导入全书解析 provider,缺省 dify;可用 MUSE_AI_IMPORT_PROVIDER 覆盖为 new-api
*
* <p>取值经服务层归一化(小写下划线视作连字符), new_api / new-api 等价</p>
*/
private String provider = "dify";
}
}

View File

@ -0,0 +1,217 @@
package cn.iocoder.muse.module.ai.application.muse;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.module.ai.framework.ai.config.MuseAiProperties;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Dify 导入解析器契约测试
*
* <p>用本地 HttpServer Dify chat blocking 端点(/chat-messages),覆盖成功多章鉴权 fail-closed 不重试
* 限流重试后成功5xx 可重试耗尽answer JSON 判截断空章节未配置 fail-closed</p>
*/
class DifyMuseAiImportLlmParserTest {
private HttpServer server;
private final AtomicInteger requestCount = new AtomicInteger();
@AfterEach
void tearDown() {
if (server != null) {
server.stop(0);
}
}
@Test
void should_parseMultipleChaptersOnChatSuccess() throws Exception {
startServer(exchange -> {
requestCount.incrementAndGet();
writeJson(exchange, 200, chatAnswerBody(twoChaptersAnswerJson()));
});
DifyMuseAiImportLlmParser parser = new DifyMuseAiImportLlmParser(difyProperties(3, List.of(0, 0)));
MuseAiImportLlmParser.LlmParseResult result = parser.parse(command());
assertTrue(result.success());
assertEquals(1, requestCount.get());
assertEquals(2, result.chapters().size());
assertEquals("第一章", result.chapters().getFirst().title());
assertEquals("dify_import_llm", result.summary().get("parser"));
assertEquals(2, result.summary().get("chapterCount"));
}
@Test
void should_notRetryAuthFailure() throws Exception {
startServer(exchange -> {
requestCount.incrementAndGet();
writeJson(exchange, 401, "{\"error\":\"unauthorized\"}");
});
DifyMuseAiImportLlmParser parser = new DifyMuseAiImportLlmParser(difyProperties(3, List.of(0, 0)));
MuseAiImportLlmParser.LlmParseResult result = parser.parse(command());
assertFalse(result.success());
assertEquals("AI_IMPORT_LLM_AUTH_FAILED", result.errorCode());
assertFalse(result.retryable());
assertEquals(1, requestCount.get(), "鉴权失败不可重试");
}
@Test
void should_retryRateLimitedThenReturnChapters() throws Exception {
startServer(exchange -> {
int count = requestCount.incrementAndGet();
if (count == 1) {
writeJson(exchange, 429, "{\"error\":\"rate limited\"}");
return;
}
writeJson(exchange, 200, chatAnswerBody(twoChaptersAnswerJson()));
});
DifyMuseAiImportLlmParser parser = new DifyMuseAiImportLlmParser(difyProperties(3, List.of(0, 0)));
MuseAiImportLlmParser.LlmParseResult result = parser.parse(command());
assertTrue(result.success());
assertEquals(2, requestCount.get(), "429 应自动重试一次后成功");
assertEquals(2, result.summary().get("attemptNo"));
}
@Test
void should_retryProvider5xxUntilExhausted() throws Exception {
// chat app workflow status 概念;HTTP 5xx 归为 provider 5xx 可重试,耗尽后仍以可重试失败返回
startServer(exchange -> {
requestCount.incrementAndGet();
writeJson(exchange, 502, "{\"error\":\"bad gateway\"}");
});
DifyMuseAiImportLlmParser parser = new DifyMuseAiImportLlmParser(difyProperties(2, List.of(0, 0)));
MuseAiImportLlmParser.LlmParseResult result = parser.parse(command());
assertFalse(result.success());
assertEquals("AI_IMPORT_LLM_PROVIDER_5XX", result.errorCode());
assertTrue(result.retryable());
assertEquals(2, requestCount.get(), "5xx 应重试到 maxAttempts");
assertEquals(2, result.summary().get("attemptNo"));
}
@Test
void should_failTruncatedWhenAnswerNotValidJson() throws Exception {
startServer(exchange -> {
requestCount.incrementAndGet();
// answer 是不完整 JSON:Dify chat finish_reason, JSON 完整性判定输出被截断
writeJson(exchange, 200, chatAnswerBody("{\"chapters\":[{\"title\":\"第一章\",\"content\":\"正文"));
});
DifyMuseAiImportLlmParser parser = new DifyMuseAiImportLlmParser(difyProperties(3, List.of(0, 0)));
MuseAiImportLlmParser.LlmParseResult result = parser.parse(command());
assertFalse(result.success());
assertEquals("AI_IMPORT_LLM_TRUNCATED", result.errorCode());
assertFalse(result.retryable());
}
@Test
void should_failNoChaptersWhenChaptersEmpty() throws Exception {
startServer(exchange -> {
requestCount.incrementAndGet();
writeJson(exchange, 200, chatAnswerBody("{\"chapters\":[]}"));
});
DifyMuseAiImportLlmParser parser = new DifyMuseAiImportLlmParser(difyProperties(3, List.of(0, 0)));
MuseAiImportLlmParser.LlmParseResult result = parser.parse(command());
assertFalse(result.success());
assertEquals("AI_IMPORT_LLM_NO_CHAPTERS", result.errorCode());
assertFalse(result.retryable());
}
@Test
void should_failUnavailableWhenNotConfigured() {
// Dify 未启用(默认 enabled=false):fail-closed,不发起任何 HTTP
DifyMuseAiImportLlmParser parser = new DifyMuseAiImportLlmParser(new MuseAiProperties());
MuseAiImportLlmParser.LlmParseResult result = parser.parse(command());
assertFalse(result.success());
assertEquals("AI_IMPORT_LLM_UNAVAILABLE", result.errorCode());
assertFalse(result.retryable());
assertEquals(0, requestCount.get());
}
private MuseAiImportLlmParser.LlmParseCommand command() {
return new MuseAiImportLlmParser.LlmParseCommand(
"cmd-dify-import-parser-test",
1L,
9001L,
10001L,
"book.txt",
"txt",
"第一章\n正文。",
"sha256:test",
Map.of("mode", "full_book", "strategy", "llm_full_book"));
}
private MuseAiProperties difyProperties(int maxAttempts, List<Integer> backoffSeconds) {
MuseAiProperties properties = new MuseAiProperties();
MuseAiProperties.Dify dify = new MuseAiProperties.Dify();
dify.setEnabled(true);
dify.setBaseUrl("http://127.0.0.1:" + server.getAddress().getPort());
MuseAiProperties.Dify.Credential credential = new MuseAiProperties.Dify.Credential();
credential.setRef("dify-parser-s1");
credential.setApiKey("test-dify-key");
dify.setCredentials(List.of(credential));
dify.setMaxAttempts(maxAttempts);
dify.setRetryBackoffSeconds(backoffSeconds);
dify.setConnectTimeoutSeconds(1);
dify.setNonStreamReadTimeoutSeconds(5);
properties.setDify(dify);
return properties;
}
private String twoChaptersAnswerJson() {
return JsonUtils.toJsonString(Map.of("chapters", List.of(
Map.of("title", "第一章", "content", "正文一。"),
Map.of("title", "第二章", "content", "正文二。"))));
}
private String chatAnswerBody(String answerJson) {
// Dify chat blocking 响应形状:answer 承载模型产出的章节 JSON 字符串
return JsonUtils.toJsonString(Map.of(
"answer", answerJson,
"message_id", "msg-dify-test",
"metadata", Map.of()));
}
private void startServer(ExchangeHandler handler) throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/chat-messages", handler::handle);
server.start();
}
private void writeJson(HttpExchange exchange, int status, String responseBody) throws IOException {
byte[] bytes = responseBody.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
}
@FunctionalInterface
private interface ExchangeHandler {
void handle(HttpExchange exchange) throws IOException;
}
}

View File

@ -8,6 +8,7 @@ import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiChapterParseResultDO;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiParseJobDO;
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAiChapterParseResultMapper;
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAiParseJobMapper;
import cn.iocoder.muse.module.ai.framework.ai.config.MuseAiProperties;
import cn.iocoder.muse.module.infra.api.file.FileApi;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -65,7 +66,8 @@ class MuseAiImportParseServiceTest extends BaseMockitoUnitTest {
ReflectionTestUtils.setField(service, "parseJobMapper", parseJobMapper);
ReflectionTestUtils.setField(service, "chapterResultMapper", chapterResultMapper);
ReflectionTestUtils.setField(service, "contentWorkOwnerFacade", contentWorkOwnerFacade);
ReflectionTestUtils.setField(service, "llmParser", llmParser);
// 单实现注入:size==1 resolveImportLlmParser 直接返回,不读 provider 配置,兼容既有用例
ReflectionTestUtils.setField(service, "importLlmParsers", List.of(llmParser));
}
@Test
@ -271,6 +273,77 @@ class MuseAiImportParseServiceTest extends BaseMockitoUnitTest {
verify(parseJobMapper).updateReviewCounters(JOB_ID, 1, 1);
}
@Test
void should_selectDifyParserWhenProviderIsDify() {
MuseAiImportLlmParser newApiParser = org.mockito.Mockito.mock(MuseAiImportLlmParser.class);
MuseAiImportLlmParser difyParser = org.mockito.Mockito.mock(MuseAiImportLlmParser.class);
org.mockito.Mockito.lenient().when(newApiParser.providerKey()).thenReturn("new-api");
org.mockito.Mockito.lenient().when(difyParser.providerKey()).thenReturn("dify");
ReflectionTestUtils.setField(service, "importLlmParsers", List.of(newApiParser, difyParser));
ReflectionTestUtils.setField(service, "museAiProperties", importProviderProperties("dify"));
stubFullBookInsert();
when(difyParser.parse(any(MuseAiImportLlmParser.LlmParseCommand.class))).thenReturn(
MuseAiImportLlmParser.LlmParseResult.success(
List.of(new MuseAiImportLlmParser.LlmChapter("Dify 章节", "Dify 切出的正文。")),
Map.of("parser", "dify_import_llm")));
MuseAiImportParseApi.ParseJobProjection result = service.createParseJob(createCommand("txt", "full_book"));
assertEquals("completed", result.status());
verify(difyParser).parse(any(MuseAiImportLlmParser.LlmParseCommand.class));
verify(newApiParser, never()).parse(any(MuseAiImportLlmParser.LlmParseCommand.class));
ArgumentCaptor<MuseAiChapterParseResultDO> chapterCaptor =
ArgumentCaptor.forClass(MuseAiChapterParseResultDO.class);
verify(chapterResultMapper).insert(chapterCaptor.capture());
org.junit.jupiter.api.Assertions.assertTrue(
chapterCaptor.getValue().getQualityResult().contains("dify_import_llm"));
}
@Test
void should_selectNewApiParserWhenProviderIsNewApi() {
MuseAiImportLlmParser newApiParser = org.mockito.Mockito.mock(MuseAiImportLlmParser.class);
MuseAiImportLlmParser difyParser = org.mockito.Mockito.mock(MuseAiImportLlmParser.class);
org.mockito.Mockito.lenient().when(newApiParser.providerKey()).thenReturn("new-api");
org.mockito.Mockito.lenient().when(difyParser.providerKey()).thenReturn("dify");
ReflectionTestUtils.setField(service, "importLlmParsers", List.of(newApiParser, difyParser));
// 用下划线写法验证服务层归一化:new_api 应等价 new-api
ReflectionTestUtils.setField(service, "museAiProperties", importProviderProperties("new_api"));
stubFullBookInsert();
when(newApiParser.parse(any(MuseAiImportLlmParser.LlmParseCommand.class))).thenReturn(
MuseAiImportLlmParser.LlmParseResult.success(
List.of(new MuseAiImportLlmParser.LlmChapter("New-API 章节", "New-API 切出的正文。")),
Map.of("parser", "new_api_import_llm")));
MuseAiImportParseApi.ParseJobProjection result = service.createParseJob(createCommand("txt", "full_book"));
assertEquals("completed", result.status());
verify(newApiParser).parse(any(MuseAiImportLlmParser.LlmParseCommand.class));
verify(difyParser, never()).parse(any(MuseAiImportLlmParser.LlmParseCommand.class));
ArgumentCaptor<MuseAiChapterParseResultDO> chapterCaptor =
ArgumentCaptor.forClass(MuseAiChapterParseResultDO.class);
verify(chapterResultMapper).insert(chapterCaptor.capture());
org.junit.jupiter.api.Assertions.assertTrue(
chapterCaptor.getValue().getQualityResult().contains("new_api_import_llm"));
}
private void stubFullBookInsert() {
when(parseJobMapper.selectByCommandId("cmd-parse-1")).thenReturn(null);
when(parseJobMapper.selectByImportTaskIdAndOwner(IMPORT_TASK_ID, USER_ID)).thenReturn(null);
when(fileApi.getFileBytes("100/content/import/book.md")).thenReturn(
"一段没有标题的正文。".getBytes(StandardCharsets.UTF_8));
doAnswer(invocation -> {
MuseAiParseJobDO job = invocation.getArgument(0);
job.setId(JOB_ID);
return 1;
}).when(parseJobMapper).insert(any(MuseAiParseJobDO.class));
}
private MuseAiProperties importProviderProperties(String provider) {
MuseAiProperties properties = new MuseAiProperties();
properties.getImport().setProvider(provider);
return properties;
}
private MuseAiImportParseApi.CreateParseJobCommand createCommand(String format) {
return createCommand(format, "chapter");
}

View File

@ -37,8 +37,9 @@ MUSE_AI_DIFY_CONSOLE_BASE_URLS=http://100.64.0.8:18080
MUSE_AI_DIFY_WRITING_APP_ID=75f105e8-dbca-4aa6-a296-2113a65ad005
MUSE_AI_DIFY_WRITING_API_KEY=app-Un5DDoOiK7eQc617ahdn7ku2
MUSE_AI_DIFY_WRITING_CREDENTIAL_REF=dify-writing-s1
MUSE_AI_DIFY_PARSER_APP_ID=fed4d25c-1289-4090-9771-a811c89aba44
MUSE_AI_DIFY_PARSER_API_KEY=app-It4V7WukHohP8rhWV0gOn2H5
# S8: 解析器用 chat app(passthrough M3,克隆自写作 app;规格允许 app/workflow 二选一,创始人定 chat app),弃用空 workflow 壳 fed4d25c。
MUSE_AI_DIFY_PARSER_APP_ID=17438ceb-6e47-4c2b-adeb-eda21d6e6a88
MUSE_AI_DIFY_PARSER_API_KEY=app-9mLRF1ZhFLX1I81pouHpvBi8
MUSE_AI_DIFY_PARSER_CREDENTIAL_REF=dify-parser-s1
MUSE_AI_DIFY_CONNECT_TIMEOUT_SECONDS=5
MUSE_AI_DIFY_FIRST_BYTE_TIMEOUT_SECONDS=15