From db0bde39e3afbaa332559cf41a998ed4f08b49b9 Mon Sep 17 00:00:00 2001 From: cst61 Date: Sun, 5 Apr 2026 15:10:40 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/rj/common/AudioAnalysisSceneType.java | 10 +- .../controller/AudioManagementController.java | 16 +- .../controller/SalesManagementController.java | 2 +- .../service/IAudioTextAnalysisLlmService.java | 11 + .../impl/AudioTextAnalysisLlmServiceImpl.java | 260 +++++++++++++++++- src/main/resources/application.yml | 6 +- ...dio_text_analysis_summary_user_prompts.txt | 2 +- 7 files changed, 293 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/rj/common/AudioAnalysisSceneType.java b/src/main/java/com/rj/common/AudioAnalysisSceneType.java index f6ffc71..88da947 100644 --- a/src/main/java/com/rj/common/AudioAnalysisSceneType.java +++ b/src/main/java/com/rj/common/AudioAnalysisSceneType.java @@ -15,8 +15,8 @@ public enum AudioAnalysisSceneType { "qwen-plus" ), SCENARIO_SOFT_SALE( - "prompts/audio_text_analysis_furniture_system.txt", - "prompts/audio_text_analysis_furniture_user.txt", + "prompts/audio_text_analysis_soft_sale_system_prompts.txt", + "prompts/audio_text_analysis_soft_sale_user_prompts.txt", "qwen-plus" ), @@ -24,9 +24,9 @@ public enum AudioAnalysisSceneType { * 会议纪要/会议分析场景 * 提示词文件和模型可根据实际需要进行调整。 */ - SCENARIO_COMMON_SALE( - "prompts/audio_text_analysis_meeting_system.txt", - "prompts/audio_text_analysis_meeting_user.txt", + SCENARIO_SUMMARY( + "prompts/audio_text_analysis_summary_system_prompts.txt", + "prompts/audio_text_analysis_summary_user_prompts.txt", "qwen-plus" ), diff --git a/src/main/java/com/rj/controller/AudioManagementController.java b/src/main/java/com/rj/controller/AudioManagementController.java index ca1eb0a..bf68701 100644 --- a/src/main/java/com/rj/controller/AudioManagementController.java +++ b/src/main/java/com/rj/controller/AudioManagementController.java @@ -49,7 +49,7 @@ public class AudioManagementController { private static final String SCENARIO_FURNITURE_SALE = "FURNITURE"; private static final String SCENARIO_MEETING_SUMMARY = "MEETING_SUMMARY"; private static final String SCENARIO_CAR_SALE = "CAR_SALE"; - private static final String SCENARIO_COMMON_SALE = "COMMON_SALE"; + private static final String SCENARIO_SUMMARY = "SUMMARY"; private static final String SCENARIO_SPEAKING_TRAINING= "SPEAKING_TRAINING"; //租赁模式 @@ -510,6 +510,7 @@ public class AudioManagementController { customerToUpdate.setDetailedAddress(audioManagement.getRemarks()); customerToUpdate.setSalesPhone(audioManagement.getSalesPhone()); customerToUpdate.setSalesName(audioManagement.getSalesName()); + customerToUpdate.setRecordingCount(customerToUpdate.getRecordingCount()+1); customerToUpdate.setUpdateTime(LocalDateTime.now()); customerManagementService.updateById(customerToUpdate); log.info("同步更新客户信息成功,客户ID: {}", customerToUpdate.getId()); @@ -728,6 +729,15 @@ public class AudioManagementController { audioManagement.getCustomerName(), audioManagement.getCustomerPhone() ); +// AudioTextAnalysisFurniture furniture = audioTextAnalysisLlmService.generateSummaryAndSave_V2( +// sceneType, +// recordingText, +// audioManagement.getId(), +// audioManagement.getSalesName(), +// audioManagement.getSalesPhone(), +// audioManagement.getCustomerName(), +// audioManagement.getCustomerPhone() +// ); log.info("llm return furniture Analysis : ",furniture.toString() ); if (recordingText == null || recordingText.trim().isEmpty()) { result.put("success", false); @@ -762,8 +772,8 @@ public class AudioManagementController { if (SCENARIO_FURNITURE_SALE.equals(s)) { return AudioAnalysisSceneType.SCENARIO_FURNITURE_SALE; } - if (SCENARIO_COMMON_SALE.equals(s)) { - return AudioAnalysisSceneType.SCENARIO_COMMON_SALE; + if (SCENARIO_SUMMARY.equals(s)) { + return AudioAnalysisSceneType.SCENARIO_SUMMARY; } if (SCENARIO_CAR_SALE.equals(s)) { return AudioAnalysisSceneType.SCENARIO_CAR_SALE; diff --git a/src/main/java/com/rj/controller/SalesManagementController.java b/src/main/java/com/rj/controller/SalesManagementController.java index 78ddef8..6ead081 100644 --- a/src/main/java/com/rj/controller/SalesManagementController.java +++ b/src/main/java/com/rj/controller/SalesManagementController.java @@ -197,7 +197,7 @@ public class SalesManagementController { Map result = new HashMap<>(); try { LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - queryWrapper.eq(SalesManagement::getLoginAccount, loginAccount); + queryWrapper.like(SalesManagement::getLoginAccount, loginAccount); List salesList = salesManagementService.list(queryWrapper); result.put("success", true); result.put("message", "查询成功"); diff --git a/src/main/java/com/rj/service/IAudioTextAnalysisLlmService.java b/src/main/java/com/rj/service/IAudioTextAnalysisLlmService.java index 764b2b0..4ad75b2 100644 --- a/src/main/java/com/rj/service/IAudioTextAnalysisLlmService.java +++ b/src/main/java/com/rj/service/IAudioTextAnalysisLlmService.java @@ -21,6 +21,17 @@ public interface IAudioTextAnalysisLlmService { */ String generateSummaryByLLM(AudioAnalysisSceneType sceneType, String recordingText); + /** + * 与 {@link #generateSummaryByLLM} 相同的提示词与场景逻辑,但通过本地 OpenAI 兼容接口调用大模型 + * (配置项与客户问答本地模型一致:{@code ai.qa.local.chat-url}、{@code ai.qa.local.default-model}、{@code ai.qa.local.max-tokens}; + * 另支持 {@code ai.qa.local.context-length}、{@code ai.qa.local.token-margin}、{@code ai.qa.local.min-output-tokens}、{@code ai.qa.local.input-tokens-per-char} 以避免超出本地模型上下文。) + * + * @param sceneType 业务场景类型 + * @param recordingText 录音转写文本 + * @return 大模型返回的原始内容 + */ + String generateSummaryByLocalLLM(AudioAnalysisSceneType sceneType, String recordingText); + /** * 处理完整的业务逻辑:调用大模型生成总结,解析JSON,保存数据 * 此方法用于家具场景的完整业务处理 diff --git a/src/main/java/com/rj/service/impl/AudioTextAnalysisLlmServiceImpl.java b/src/main/java/com/rj/service/impl/AudioTextAnalysisLlmServiceImpl.java index 70be0f0..cfe28e2 100644 --- a/src/main/java/com/rj/service/impl/AudioTextAnalysisLlmServiceImpl.java +++ b/src/main/java/com/rj/service/impl/AudioTextAnalysisLlmServiceImpl.java @@ -21,10 +21,16 @@ import com.rj.service.IAudioTextAnalysisFurnitureService; import com.rj.service.IAudioTextAnalysisLlmService; import com.rj.service.IAudioTextAnalysisSopService; import com.rj.service.ITodoItemService; +import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.chat.response.StreamingChatResponseHandler; +import dev.langchain4j.model.openai.OpenAiStreamingChatModel; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import jakarta.annotation.PostConstruct; + import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; @@ -33,6 +39,9 @@ import java.util.Map; import java.util.Scanner; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; /** * 音频文本分析 - 大模型通用服务实现 @@ -59,6 +68,54 @@ public class AudioTextAnalysisLlmServiceImpl implements IAudioTextAnalysisLlmSer @Autowired private IAudioTextAnalysisSopService sopService; + @Value("${ai.qa.local.chat-url:http://192.168.1.44:8000/v1/chat/completions}") + private String chatCompletionsUrl; + + @Value("${ai.qa.local.default-model:Qwen2.5-7B-Instruct}") + private String localDefaultModel; + + /** 单次补全上限(OpenAI max_tokens);与「模型总上下文 context-length」不是同一概念 */ + @Value("${ai.qa.local.max-tokens:512}") + private int localMaxTokens; + + /** 本地模型上下文长度(与推理侧 max context 一致,用于避免 input+max_tokens 超过上限;默认 16384) */ + @Value("${ai.qa.local.context-length:16384}") + private int localContextLength; + + /** 预留余量,避免服务端计数与本地估算不一致 */ + @Value("${ai.qa.local.token-margin:80}") + private int localTokenMargin; + + /** + * 截断转写时希望保留的「最小输出预算」启发值(须显著小于 context-length,且不得大于 max-tokens, + * 因为实际 effMax 恒为 min(max-tokens, 剩余上下文))。 + */ + @Value("${ai.qa.local.min-output-tokens:128}") + private int configuredLocalMinOutputTokens; + + /** {@link #configuredLocalMinOutputTokens} 经与 max-tokens 对齐后的实际取值 */ + private int effectiveLocalMinOutputTokens; + + /** + * 对完整 prompt 字符串做输入 token 的保守上界估算(偏大会多截断、偏少仍可能 400)。 + * 中文为主可保持默认;英文偏多时可酌情调低(如 0.35)。 + */ + @Value("${ai.qa.local.input-tokens-per-char:1.25}") + private double localInputTokensPerChar; + + @PostConstruct + void clampLocalMinOutputTokens() { + effectiveLocalMinOutputTokens = Math.max(1, Math.min(configuredLocalMinOutputTokens, localMaxTokens)); + if (effectiveLocalMinOutputTokens < configuredLocalMinOutputTokens) { + log.warn( + "ai.qa.local.min-output-tokens={} 大于 ai.qa.local.max-tokens={}:有效输出 effMax 恒 ≤ max-tokens,已钳制为 {}。" + + " 切勿将 min-output-tokens 配成与 context-length 相同。", + configuredLocalMinOutputTokens, + localMaxTokens, + effectiveLocalMinOutputTokens); + } + } + @Override public String generateSummaryByLLM(AudioAnalysisSceneType sceneType, String recordingText) { long startTime = System.currentTimeMillis(); @@ -112,6 +169,195 @@ public class AudioTextAnalysisLlmServiceImpl implements IAudioTextAnalysisLlmSer } } + @Override + public String generateSummaryByLocalLLM(AudioAnalysisSceneType sceneType, String recordingText) { + long startTime = System.currentTimeMillis(); + log.info("开始调用本地大模型生成总结,场景: {}", sceneType); + + String systemPrompt = getSystemPrompt(sceneType); + String userPromptTemplate = getUserPromptTemplate(sceneType); + String recordingForPrompt = fitRecordingTextToLocalContext(systemPrompt, userPromptTemplate, recordingText); + String userPrompt = buildPrompt(userPromptTemplate, recordingForPrompt); + + try { + int effectiveMaxTokens = computeEffectiveLocalMaxTokens(systemPrompt, userPrompt); + String rawContent = callLocalOpenAiChatCompletionsByLangChain4j( + systemPrompt, + userPrompt, + localDefaultModel, + effectiveMaxTokens); + log.info("本地大模型生成总结完成,场景: {}, 结果长度: {}", + sceneType, rawContent != null ? rawContent.length() : 0); + return rawContent; + } catch (Exception e) { + log.error("调用本地大模型生成总结失败, 场景: {}", sceneType, e); + throw new RuntimeException("调用本地大模型失败: " + e.getMessage(), e); + } finally { + long endTime = System.currentTimeMillis(); + long durationMillis = endTime - startTime; + double durationSeconds = durationMillis / 1000.0; + double durationMinutes = durationSeconds / 60.0; + log.info("调用本地大模型耗时: {} 毫秒, {} 秒, {} 分钟, 场景: {}", + durationMillis, + String.format("%.2f", durationSeconds), + String.format("%.2f", durationMinutes), + sceneType); + } + } + + /** + * 与 {@link AiQaCustomerAskServiceImpl} 中非流式问答路径一致:LangChain4j + OpenAI 兼容接口,流式聚合为完整文本。 + */ + private String callLocalOpenAiChatCompletionsByLangChain4j( + String systemPrompt, String userContent, String model, int maxTokens) { + log.info("音频分析本地 LLM:model={}, chatCompletionsUrl={}", model, chatCompletionsUrl); + String openAiBaseUrl = normalizeOpenAiBaseUrl(chatCompletionsUrl); + OpenAiStreamingChatModel chatModel = OpenAiStreamingChatModel.builder() + .baseUrl(openAiBaseUrl) + .apiKey("not-used") + .modelName(model) + .maxTokens(maxTokens) + .build(); + + String prompt = buildFullLocalLlmPrompt(systemPrompt, userContent); + StringBuilder answerBuffer = new StringBuilder(); + CountDownLatch done = new CountDownLatch(1); + AtomicReference errorRef = new AtomicReference<>(); + + chatModel.chat(prompt, new StreamingChatResponseHandler() { + @Override + public void onPartialResponse(String partialResponse) { + if (partialResponse != null && !partialResponse.isEmpty()) { + answerBuffer.append(partialResponse); + } + } + + @Override + public void onCompleteResponse(ChatResponse completeResponse) { + done.countDown(); + } + + @Override + public void onError(Throwable error) { + errorRef.set(error); + done.countDown(); + } + }); + + try { + boolean finished = done.await(120, TimeUnit.SECONDS); + if (!finished) { + throw new IllegalStateException("本地大模型流式调用超时(120s)"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("本地大模型流式调用被中断", e); + } + + if (errorRef.get() != null) { + throw new IllegalStateException("本地大模型流式调用失败: " + errorRef.get().getMessage(), errorRef.get()); + } + + String answer = answerBuffer.toString(); + if (answer.isEmpty()) { + throw new IllegalStateException("本地大模型流式调用返回空响应"); + } + return answer; + } + + private String normalizeOpenAiBaseUrl(String rawUrl) { + if (rawUrl == null || rawUrl.trim().isEmpty()) { + throw new IllegalArgumentException("ai.qa.local.chat-url 不能为空"); + } + String normalized = rawUrl.trim(); + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + String suffix = "/chat/completions"; + if (normalized.endsWith(suffix)) { + normalized = normalized.substring(0, normalized.length() - suffix.length()); + } + return normalized; + } + + private String buildFullLocalLlmPrompt(String systemPrompt, String userContent) { + return "系统指令:" + systemPrompt + "\n\n用户输入:" + userContent; + } + + private int estimateLocalPromptInputTokens(String fullPrompt) { + if (fullPrompt == null || fullPrompt.isEmpty()) { + return 0; + } + return (int) Math.ceil(fullPrompt.length() * localInputTokensPerChar); + } + + private int computeEffectiveLocalMaxTokens(String systemPrompt, String userPrompt) { + String full = buildFullLocalLlmPrompt(systemPrompt, userPrompt); + int estIn = estimateLocalPromptInputTokens(full); + int capByContext = localContextLength - localTokenMargin - estIn; + int effective = Math.min(localMaxTokens, capByContext); + return Math.max(1, effective); + } + + /** + * 在不超过本地模型上下文的前提下,尽量保留更多转写文本;必要时从尾部截断。 + */ + private String fitRecordingTextToLocalContext( + String systemPrompt, String userPromptTemplate, String recordingText) { + String recording = recordingText != null ? recordingText : ""; + if (recording.isEmpty()) { + return recording; + } + String userFull = buildPrompt(userPromptTemplate, recording); + String full = buildFullLocalLlmPrompt(systemPrompt, userFull); + int est = estimateLocalPromptInputTokens(full); + int effMax = Math.min(localMaxTokens, localContextLength - localTokenMargin - est); + if (effMax >= effectiveLocalMinOutputTokens) { + return recording; + } + int lo = 0; + int hi = recording.length(); + int best = 0; + while (lo <= hi) { + int mid = (lo + hi) >>> 1; + String sub = recording.substring(0, mid); + userFull = buildPrompt(userPromptTemplate, sub); + full = buildFullLocalLlmPrompt(systemPrompt, userFull); + est = estimateLocalPromptInputTokens(full); + effMax = Math.min(localMaxTokens, localContextLength - localTokenMargin - est); + if (effMax >= effectiveLocalMinOutputTokens) { + best = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + if (best >= recording.length()) { + return recording; + } + if (best <= 0) { + userFull = buildPrompt(userPromptTemplate, ""); + full = buildFullLocalLlmPrompt(systemPrompt, userFull); + int estEmpty = estimateLocalPromptInputTokens(full); + int effMaxEmpty = Math.min(localMaxTokens, localContextLength - localTokenMargin - estEmpty); + log.warn( + "本地 LLM 上下文过紧:无转写时估算剩余输出预算 effMax≈{}(max-tokens={}),仍小于 effective min-output-tokens={}" + + "(配置 min-output-tokens={})。请减小提示词、提高 context-length,或降低 min-output-tokens / 提高 max-tokens。将仍尝试调用。", + effMaxEmpty, + localMaxTokens, + effectiveLocalMinOutputTokens, + configuredLocalMinOutputTokens); + return ""; + } + log.warn( + "录音转写过长,已按本地模型上下文截断:原长度 {} 字符,保留 {} 字符(context-length={},max-tokens={})", + recording.length(), + best, + localContextLength, + localMaxTokens); + return recording.substring(0, best); + } + private String buildPrompt(String template, String recordingText) { if (template == null) { return recordingText != null ? recordingText : ""; @@ -245,10 +491,18 @@ public class AudioTextAnalysisLlmServiceImpl implements IAudioTextAnalysisLlmSer String ownerPhone, String customerName, String customerPhone) { - + // 1. 调用大模型生成总结 + String rawContent = generateSummaryByLocalLLM(sceneType, recordingText); + if (rawContent == null || rawContent.trim().isEmpty()) { + log.warn("大模型返回内容为空"); + return null; + } AudioTextAnalysisFurniture furniture = new AudioTextAnalysisFurniture(); - furniture.setRecordingText(recordingText); - + // 4. 更新AudioManagement的summary字段 + AudioManagement audioManagement = new AudioManagement(); + audioManagement.setId(parentId); + audioManagement.setSummary(rawContent); + audioManagementService.updateById(audioManagement); return furniture; } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 03dae46..e921da4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,7 +16,11 @@ ai: local: chat-url: http://192.168.1.44:8000/v1/chat/completions default-model: Qwen2.5-7B-Instruct - max-tokens: 512 + # 模型总上下文(与推理服务一致);勿与 max-tokens、min-output-tokens 混用同一数值 + context-length: 16384 + # 单次补全上限。过小(如 512)会导致课堂纪要等 JSON 总结被截断、字段残缺或不准;建议 2048~4096(须保证 input+max_tokens 不超过 context-length) + max-tokens: 2048 + min-output-tokens: 128 langchain4j: open-ai: diff --git a/src/main/resources/prompts/audio_text_analysis_summary_user_prompts.txt b/src/main/resources/prompts/audio_text_analysis_summary_user_prompts.txt index d1a02f3..b6fb263 100644 --- a/src/main/resources/prompts/audio_text_analysis_summary_user_prompts.txt +++ b/src/main/resources/prompts/audio_text_analysis_summary_user_prompts.txt @@ -1,4 +1,4 @@ -请阅读以下录音文本,从中提取客户信息并生成 JSON。必须覆盖所有字段,缺失信息请基于语境合理推断或标注"暂无信息"。 +请阅读以下录音文本,从中提取重要关键信息并生成 JSON。必须覆盖所有字段,缺失信息请基于语境合理推断或标注"暂无信息"。 字段要求: 1. style_type:主题类型,仅可填「会议纪要」「课堂纪要」「面试纪要」。