diff --git a/.env.example b/.env.example index 4014febb..07ea46f6 100644 --- a/.env.example +++ b/.env.example @@ -40,7 +40,7 @@ POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_DB=interview_guide POSTGRES_USER=postgres -POSTGRES_PASSWORD=password +POSTGRES_PASSWORD=123456 # Redis(compose 映射 6379) REDIS_HOST=localhost diff --git a/app/src/main/java/interview/guide/common/ai/AgentUtilsConfiguration.java b/app/src/main/java/interview/guide/common/ai/AgentUtilsConfiguration.java index 439a5d6f..e4fcd205 100644 --- a/app/src/main/java/interview/guide/common/ai/AgentUtilsConfiguration.java +++ b/app/src/main/java/interview/guide/common/ai/AgentUtilsConfiguration.java @@ -26,12 +26,17 @@ public AgentUtilsConfiguration( this.agentUtilsProperties = agentUtilsProperties; } + /** + * 创建 SkillsTool,并注册为 Spring Bean。 + */ @Bean("interviewSkillsToolCallback") public ToolCallback interviewSkillsToolCallback() { String configuredSkillsRoot = agentUtilsProperties.getSkillsRoot(); + // 规范化路径:处理 null/空白、\\ 分隔符、/SKILL.md 后缀、通配符、末尾斜杠等边界情况 String normalizedSkillsRoot = normalizeSkillsRoot(configuredSkillsRoot); Resource skillsRootResource = resourceLoader.getResource(normalizedSkillsRoot); + // 技能目录不存在时直接拦停启动,避免 Agent 调用时静默报错难以排查 if (!skillsRootResource.exists()) { throw new IllegalStateException("未找到 skills 根目录,请检查配置: " + normalizedSkillsRoot); } @@ -43,7 +48,13 @@ public ToolCallback interviewSkillsToolCallback() { .build(); } + /** + * 规范化 skills 路径:兼容用户配置可能携带的各种后缀和格式。 + * 用户可能配置 classpath:skills、classpath:skills/*、classpath:skills/SKILL.md 等变体, + * 统一归约到目录形式后再交给 ResourceLoader。 + */ private String normalizeSkillsRoot(String raw) { + // 未配置时使用默认值,确保 SkillsTool 能找到内置技能 if (raw == null || raw.isBlank()) { return "classpath:skills"; } @@ -51,15 +62,19 @@ private String normalizeSkillsRoot(String raw) { String normalized = raw.trim(); normalized = normalized.replace('\\', '/'); + // 用户可能直接指向某个 SKILL.md 文件而非目录 if (normalized.endsWith("/SKILL.md")) { + // 去掉 SKILL.md normalized = normalized.substring(0, normalized.length() - "/SKILL.md".length()); } + // 去掉 classpath:skills/* 中的通配符,SkillsTool 会扫描子目录 int wildcardIndex = normalized.indexOf('*'); if (wildcardIndex >= 0) { normalized = normalized.substring(0, wildcardIndex); } + // 去掉末尾的 / while (normalized.endsWith("/")) { normalized = normalized.substring(0, normalized.length() - 1); } diff --git a/app/src/main/java/interview/guide/common/ai/ApiPathResolver.java b/app/src/main/java/interview/guide/common/ai/ApiPathResolver.java index 358d5a9d..234530dc 100644 --- a/app/src/main/java/interview/guide/common/ai/ApiPathResolver.java +++ b/app/src/main/java/interview/guide/common/ai/ApiPathResolver.java @@ -10,11 +10,21 @@ import java.time.Duration; import java.util.regex.Pattern; +/** + * OpenAI API 客户端构造器。 + * 不同 LLM Provider 的 baseUrl 结构不同: + * - OpenAI 系:{@code https://api.openai.com/v1} → API 路径为 /v1/chat/completions + * - Ollama 等:{@code http://localhost:11434} → 路径就是 /chat/completions(无版本前缀) + * 此类自动识别 baseUrl 中是否已包含版本段,决定是否需要补全 API 路径。 + */ public final class ApiPathResolver { - private static final int DEFAULT_CONNECT_TIMEOUT = 10000; - private static final int DEFAULT_READ_TIMEOUT = 300000; + // 连接超时 10s:网络握手不应等待过久 + private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10); + // 读取超时 5min:LLM 流式响应可能持续较长时间 + private static final Duration DEFAULT_READ_TIMEOUT = Duration.ofMinutes(5); + // 匹配 baseUrl 末尾的 /v1、/v1beta 等版本段 private static final Pattern TRAILING_VERSION = Pattern.compile("/v\\d+[a-zA-Z0-9]*$"); private ApiPathResolver() {} @@ -24,10 +34,10 @@ public static OpenAIClient buildOpenAiClient(String baseUrl, String apiKey) { } public static OpenAIClient buildOpenAiClient(String baseUrl, String apiKey, - int connectTimeout, int readTimeout) { + Duration connectTimeout, Duration readTimeout) { Timeout timeout = Timeout.builder() - .connect(Duration.ofMillis(connectTimeout)) - .read(Duration.ofMillis(readTimeout)) + .connect(connectTimeout) + .read(readTimeout) .build(); ClientOptions options = ClientOptions.Companion.builder() .apiKey(apiKey) @@ -47,6 +57,7 @@ public static String resolveVersionedBaseUrl(String baseUrl) { return stripped + "/v1"; } + /** 判断 baseUrl 末尾是否包含 /v1 之类的版本段。 */ public static boolean baseUrlContainsVersion(String baseUrl) { if (baseUrl == null || baseUrl.isBlank()) { return false; @@ -55,6 +66,7 @@ public static boolean baseUrlContainsVersion(String baseUrl) { return TRAILING_VERSION.matcher(stripped).find(); } + /** 去除字符串末尾的斜杠,便于路径拼接和正则匹配。 */ public static String stripTrailingSlashes(String value) { if (value == null) { return ""; diff --git a/app/src/main/java/interview/guide/common/ai/LlmProviderRegistry.java b/app/src/main/java/interview/guide/common/ai/LlmProviderRegistry.java index 9125967e..daebe768 100644 --- a/app/src/main/java/interview/guide/common/ai/LlmProviderRegistry.java +++ b/app/src/main/java/interview/guide/common/ai/LlmProviderRegistry.java @@ -39,14 +39,24 @@ import java.util.concurrent.ConcurrentHashMap; /** - * Registry for managing and caching LLM providers. - * Supports dynamic creation of ChatClient based on provider configurations. + * + * * Registry for managing and caching LLM providers. + * * Supports dynamic creation of ChatClient based on provider configurations. + *

+ * LLM Provider 注册中心,负责 ChatClient / EmbeddingModel 的创建、缓存和路由。 + *

+ * 职责: + * - 按 providerId 创建并缓存 ChatClient,避免重复创建底层 ChatModel + * - 区分 通用/纯文本(plain)/语音(voice) 三种客户端场景,各自装配不同的 Advisor + * - Provider 配置支持 DB 和配置文件两种来源,DB 优先 + * - 运行时支持 reload 重新加载(配置热更新) */ @Component @Slf4j public class LlmProviderRegistry { private final LlmProviderProperties properties; + // 三层缓存,按粒度拆分:ChatModel 和 EmbeddingModel 可被不同 Client 共享复用 private final Map clientCache = new ConcurrentHashMap<>(); private final Map chatModelCache = new ConcurrentHashMap<>(); private final Map embeddingModelCache = new ConcurrentHashMap<>(); @@ -57,6 +67,7 @@ public class LlmProviderRegistry { private final ToolCallingManager toolCallingManager; private final ObservationRegistry observationRegistry; private final ToolCallback interviewSkillsToolCallback; + // 各厂商推荐的 Embedding 模型名映射,用于用户误配聊天模型时给出具体建议 private static final Map RECOMMENDED_EMBEDDING_MODELS = Map.of( "dashscope", "text-embedding-v3", "glm", "embedding-3", @@ -71,6 +82,8 @@ public LlmProviderRegistry( LlmProviderRepository providerRepository, LlmGlobalSettingRepository globalSettingRepository, ApiKeyEncryptionService encryptionService, + // 以下三个入参依赖第三方 jar(spring-ai、spring-ai-agent-utils), + // 用 required=false 避免它们不在类路径时启动失败 @Autowired(required = false) ToolCallingManager toolCallingManager, @Autowired(required = false) ObservationRegistry observationRegistry, @Autowired(required = false) @Qualifier("interviewSkillsToolCallback") ToolCallback interviewSkillsToolCallback) { @@ -83,6 +96,7 @@ public LlmProviderRegistry( this.interviewSkillsToolCallback = interviewSkillsToolCallback; } + // 无 DB 依赖的构造器重载,供单测或无需 DB 持久化的环境使用 public LlmProviderRegistry( LlmProviderProperties properties, ToolCallingManager toolCallingManager, @@ -131,6 +145,7 @@ public ChatClient getChatClientOrDefault(String providerId) { */ public ChatClient getPlainChatClient(String providerId) { String id = resolveProviderId(providerId); + // ":plain" 后缀与通用 ChatClient 隔离,避免缓存污染 return clientCache.computeIfAbsent(id + ":plain", key -> createPlainChatClient(id)); } @@ -140,11 +155,13 @@ public ChatClient getPlainChatClient(String providerId) { */ public ChatClient getVoiceChatClient(String providerId) { String id = resolveProviderId(providerId); + // ":voice" 后缀与通用/plain ChatClient 隔离 return clientCache.computeIfAbsent(id + ":voice", key -> createVoiceChatClient(id)); } /** * 清空缓存,重新加载所有 provider。 + * 配置页修改后调用,下次请求重建 ChatClient。 */ public void reload() { int size = clientCache.size() + chatModelCache.size() + embeddingModelCache.size(); @@ -154,6 +171,9 @@ public void reload() { log.info("[LlmProviderRegistry] Cache cleared ({} entries). Next access will re-create clients.", size); } + /** + * 获取 EmbeddingModel(用于知识库向量化)。 + */ public EmbeddingModel getEmbeddingModel(String providerId) { return embeddingModelCache.computeIfAbsent(providerId, id -> { log.info("[LlmProviderRegistry] Creating new embedding model for provider: {}", id); @@ -161,14 +181,24 @@ public EmbeddingModel getEmbeddingModel(String providerId) { }); } + /** + * 获取默认的 EmbeddingModel。 + * 优先使用 app.ai.default-embedding-provider,未配置时退化为 default-provider。 + */ public EmbeddingModel getDefaultEmbeddingModel() { return getEmbeddingModel(resolveDefaultEmbeddingProviderId()); } + // ---- 三种 ChatClient 创建 ---- + + /** + * 创建通用 ChatClient:SkillsTool + 配置装配的 Advisor 列表。 + */ private ChatClient createChatClient(String providerId) { OpenAiChatModel chatModel = getChatModel(providerId); ChatClient.Builder builder = ChatClient.builder(chatModel); + // SkillsTool 可选依赖,不存在时跳过(如测试环境) if (interviewSkillsToolCallback != null) { builder.defaultTools(interviewSkillsToolCallback); } @@ -181,6 +211,10 @@ private ChatClient createChatClient(String providerId) { return builder.build(); } + /** + * 纯文本 ChatClient:仅保留敏感词护卫,不带工具调用和记忆。 + * 用于出题、评分等结构化输出场景——工具调用消息会干扰模型生成纯净 JSON。 + */ private ChatClient createPlainChatClient(String providerId) { OpenAiChatModel chatModel = getChatModel(providerId); ChatClient.Builder builder = ChatClient.builder(chatModel); @@ -189,6 +223,10 @@ private ChatClient createPlainChatClient(String providerId) { return builder.build(); } + /** + * 语音面试 ChatClient:SkillsTool + 流式 ToolCallAdvisor。 + * 不加 Memory Advisor——语音面试按对话轮次管理历史,不走 Spring AI 的窗口记忆。 + */ private ChatClient createVoiceChatClient(String providerId) { OpenAiChatModel chatModel = getChatModel(providerId); @@ -208,6 +246,11 @@ private ChatClient createVoiceChatClient(String providerId) { return builder.build(); } + // ---- Model 创建与缓存 ---- + + /** + * 按 provider 缓存 ChatModel,避免相同 Provider 的 ChatClient 反复重建底层模型。 + */ private OpenAiChatModel getChatModel(String providerId) { return chatModelCache.computeIfAbsent(providerId, id -> { log.info("[LlmProviderRegistry] Creating new ChatModel for provider: {}", id); @@ -215,6 +258,10 @@ private OpenAiChatModel getChatModel(String providerId) { }); } + /** + * 构造 OpenAiChatModel。 + * temperature 默认 0.2,偏向确定性和可复现的面试评估——过高 temperature 的输出难以评分。 + */ private OpenAiChatModel buildChatModel(String providerId) { ProviderSnapshot config = loadProviderOrThrow(providerId); log.info("[LlmProviderRegistry] Building ChatModel - Provider: {}, BaseUrl: {}, Model: {}", @@ -235,6 +282,12 @@ private OpenAiChatModel buildChatModel(String providerId) { .build(); } + /** + * 创建 EmbeddingModel(知识库向量化)。 + * 创建前校验: + * - 未配置 Embedding 模型时抛异常 + * - 用户可能误填聊天模型名,通过 looksLikeChatModel 预先拦截并给出推荐修正 + */ private EmbeddingModel createEmbeddingModel(String providerId) { ProviderSnapshot config = loadProviderOrThrow(providerId); if (!config.supportsEmbedding() || isBlank(config.embeddingModel())) { @@ -267,6 +320,12 @@ private EmbeddingModel createEmbeddingModel(String providerId) { .build(); } + // ---- Advisor 装配 ---- + + /** + * 按配置逐项装配 Advisor。 + * 工具调用 → 对话记忆 → 请求日志 → 敏感词护卫,全由 application.yml 控制开关。 + */ private List buildDefaultAdvisors(String providerId) { AdvisorConfig config = properties.getAdvisors(); if (config == null || !config.isEnabled()) { @@ -284,6 +343,7 @@ private List buildDefaultAdvisors(String providerId) { } if (config.isMessageChatMemoryEnabled()) { + // 窗口记忆最少保留 20 条,避免记忆太短对话失去上下文 int maxMessages = Math.max(20, config.getMessageChatMemoryMaxMessages()); MessageChatMemoryAdvisor memoryAdvisor = MessageChatMemoryAdvisor.builder( MessageWindowChatMemory.builder() @@ -309,6 +369,10 @@ private ToolCallingAdvisor buildToolCallAdvisor(boolean conversationHistoryEnabl .build(); } + /** + * 敏感词护卫:拦截求职者试图让 AI 做面试无关的事情(写代码、聊天等)。 + * 提示词设计为"只能协助面试相关任务",保持边界安全。 + */ private Optional buildSafeGuardAdvisor() { AdvisorConfig config = properties.getAdvisors(); if (config == null || !config.isSafeguardEnabled()) { @@ -322,11 +386,17 @@ private Optional buildSafeGuardAdvisor() { return Optional.of(advisor); } + // ---- Provider ID 解析(DB 优先,properties 兜底) ---- + private String resolveProviderId(String providerId) { return (providerId != null && !providerId.isBlank()) ? providerId : resolveDefaultChatProviderId(); } + /** + * 从 DB 查询默认聊天 Provider,查询不到时用配置文件的默认值。 + * providerRepository 为 null 说明是无 DB 环境(测试/配置驱动模式),直接返回配置默认值。 + */ private String resolveDefaultChatProviderId() { if (globalSettingRepository == null) { return properties.getDefaultProvider(); @@ -337,6 +407,10 @@ private String resolveDefaultChatProviderId() { .orElse(properties.getDefaultProvider()); } + /** + * 解析默认 Embedding Provider:优先查 DB,未配置时退化为 default-provider。 + * 比聊天 Provider 多一层 fallback:先查 default-embedding-provider,再退化为 default-provider。 + */ private String resolveDefaultEmbeddingProviderId() { if (globalSettingRepository == null) { return !isBlank(properties.getDefaultEmbeddingProvider()) @@ -351,6 +425,11 @@ private String resolveDefaultEmbeddingProviderId() { : properties.getDefaultProvider()); } + + /** + * 加载 Provider 配置:DB 有数据则走 DB(支持加密存储的 API Key),否则退化为配置文件。 + * providerRepository 为 null 时直接走配置文件路径(测试/配置驱动模式)。 + */ private ProviderSnapshot loadProviderOrThrow(String providerId) { if (providerRepository == null) { return loadProviderFromPropertiesOrThrow(providerId); @@ -361,6 +440,7 @@ private ProviderSnapshot loadProviderOrThrow(String providerId) { return new ProviderSnapshot( entity.getId(), entity.getBaseUrl(), + // API Key 在 DB 中以 nonce + ciphertext 分存,需解密后使用 encryptionService.decrypt(entity.getApiKeyNonce(), entity.getApiKeyCiphertext()), entity.getModel(), entity.getEmbeddingModel(), @@ -370,6 +450,10 @@ private ProviderSnapshot loadProviderOrThrow(String providerId) { ); } + /** + * 从配置文件加载 Provider 配置。 + * supportsEmbedding 自动推导:显式开启或配置了 embeddingModel 即视为支持。 + */ private ProviderSnapshot loadProviderFromPropertiesOrThrow(String providerId) { ProviderConfig config = properties.getProviders().get(providerId); if (config == null) { @@ -390,10 +474,15 @@ private ProviderSnapshot loadProviderFromPropertiesOrThrow(String providerId) { ); } + // ---- 工具方法 ---- + private boolean isBlank(String value) { return value == null || value.isBlank(); } + /** + * 解析 Embedding 维度:配置值 > 0 则用配置值,否则用全局默认 dimensions。 + */ private Integer resolveEmbeddingDimensions(Integer configuredDimensions) { if (configuredDimensions != null && configuredDimensions > 0) { return configuredDimensions; @@ -401,6 +490,10 @@ private Integer resolveEmbeddingDimensions(Integer configuredDimensions) { return properties.getEmbeddingDimensions(); } + /** + * 启发式判断用户是否误将聊天模型名填入了 Embedding 模型配置。 + * 常见聊天模型名以 glm- / deepseek / qwen 等开头,Embedding 模型名不会有这些前缀。 + */ private boolean looksLikeChatModel(String model) { String lower = model.toLowerCase(); return lower.startsWith("glm-") @@ -411,6 +504,8 @@ private boolean looksLikeChatModel(String model) { || lower.startsWith("ernie"); } + // ---- 内部数据载体 ---- + private record ProviderSnapshot( String id, String baseUrl, diff --git a/app/src/main/java/interview/guide/common/ai/StructuredOutputInvoker.java b/app/src/main/java/interview/guide/common/ai/StructuredOutputInvoker.java index 4426e736..070fb9a8 100644 --- a/app/src/main/java/interview/guide/common/ai/StructuredOutputInvoker.java +++ b/app/src/main/java/interview/guide/common/ai/StructuredOutputInvoker.java @@ -14,11 +14,20 @@ import java.util.regex.Pattern; /** - * 统一封装结构化输出调用与重试策略。 + * 结构化输出调用与重试的统一封装。 + *

+ * LLM 生成 JSON 格式输出时,常见问题: + * - 输出 Markdown 代码块包裹的 JSON(```json ... ```) + * - JSON 字符串值内引号未转义 + * - 附带解释文字 + * 此类通过重试 + 本地修复 + 严格指令组合处理上述问题。 */ @Component public class StructuredOutputInvoker { + // === 常量区 === + + // 重试时追加的严格 JSON 指令——提醒模型不要输出 Markdown 包裹、额外文字或未转义引号 private static final String STRICT_JSON_INSTRUCTION = """ 请仅返回可被 JSON 解析器直接解析的 JSON 对象,并严格满足字段结构要求: 1) 不要输出 Markdown 代码块(如 ```json)。 @@ -26,21 +35,26 @@ public class StructuredOutputInvoker { 3) 所有字符串内引号必须正确转义。 """; + // invocations = 一次 invoke() 整体成功/失败,attempts = 每次 LLM 请求(含重试),latency = 总耗时 private static final String METRIC_INVOCATIONS = "app.ai.structured_output.invocations"; private static final String METRIC_ATTEMPTS = "app.ai.structured_output.attempts"; private static final String METRIC_LATENCY = "app.ai.structured_output.latency"; private static final String STATUS_SUCCESS = "success"; private static final String STATUS_FAILURE = "failure"; + // 以下用于 context tag 归一化,适配 Prometheus/Micrometer tag 命名规范 private static final int MAX_CONTEXT_TAG_LENGTH = 48; private static final Pattern NON_ALNUM_PATTERN = Pattern.compile("[^a-z0-9_]+"); private static final Pattern MULTI_UNDERSCORE = Pattern.compile("_+"); + // === 配置字段(从 StructuredOutputProperties 注入) === + private final int maxAttempts; private final boolean includeLastErrorInRetryPrompt; private final boolean retryUseRepairPrompt; private final boolean retryAppendStrictJsonInstruction; private final int errorMessageMaxLength; private final boolean metricsEnabled; + // MeterRegistry 可选——无 Micrometer 依赖时不报错 private final boolean schemaValidationEnabled; private final MeterRegistry meterRegistry; @@ -48,16 +62,26 @@ public StructuredOutputInvoker( StructuredOutputProperties properties, @Autowired(required = false) MeterRegistry meterRegistry ) { + // 最少重试 1 次(至少一次调用),避免配置意外设为 0 或负数 this.maxAttempts = Math.max(1, properties.getStructuredMaxAttempts()); this.includeLastErrorInRetryPrompt = properties.isStructuredIncludeLastError(); this.retryUseRepairPrompt = properties.isStructuredRetryUseRepairPrompt(); this.retryAppendStrictJsonInstruction = properties.isStructuredRetryAppendStrictJsonInstruction(); + // 错误消息截断最少保留 20 字符,避免太短看不出问题 this.errorMessageMaxLength = Math.max(20, properties.getStructuredErrorMessageMaxLength()); this.metricsEnabled = properties.isStructuredMetricsEnabled(); this.schemaValidationEnabled = properties.isStructuredSchemaValidationEnabled(); this.meterRegistry = meterRegistry; } + /** + * 调用 LLM 并解析结构化输出,失败时按配置重试。 + *

+ * 重试策略三层递进: + * 1. 本地 repair 修复常见 JSON 格式问题(未转义引号) + * 2. 重试时追加严格 JSON 指令 + 上次失败原因 + * 3. 达到 maxAttempts 后抛出 BusinessException + */ public T invoke( ChatClient chatClient, String systemPromptWithFormat, @@ -70,10 +94,12 @@ public T invoke( ) { long startNanos = System.nanoTime(); String contextTag = normalizeContextTag(logContext); + // 追加防注入指令:防止用户输入通过 system prompt 劫持 LLM 行为 String securedSystemPrompt = systemPromptWithFormat + PromptSecurityConstants.ANTI_INJECTION_INSTRUCTION; Exception lastError = null; for (int attempt = 1; attempt <= maxAttempts; attempt++) { + // 首次用原始 prompt,重试用增强 prompt(含上次错误信息和严格 JSON 指令) String attemptSystemPrompt = attempt == 1 ? securedSystemPrompt : buildRetrySystemPrompt(securedSystemPrompt, lastError); @@ -96,6 +122,7 @@ public T invoke( } } + // 所有重试耗尽,记录失败指标并抛出业务异常 recordInvocation(contextTag, STATUS_FAILURE, startNanos); throw new BusinessException( errorCode, @@ -145,13 +172,18 @@ private T convertWithRepair( } } + /** + * 修复 JSON 字符串值内未转义的引号(双引号前面缺少反斜杠)。 + * 这是 LLM 输出 JSON 最频繁的错误模式:如 {"name":他说"你好"} -> {"name":他说\"你好\"} + * 使用状态机逐字符扫描,仅在确信是字符串终结符时才保留原始 "。 + */ private String repairUnescapedQuotesInJsonStrings(String content) { if (content == null || content.isBlank()) { return content; } StringBuilder repaired = new StringBuilder(content.length() + 16); - boolean inString = false; - boolean escaping = false; + boolean inString = false; // 当前是否在 JSON 字符串值内部 + boolean escaping = false; // 前一个字符是否为反斜杠(转义模式) for (int i = 0; i < content.length(); i++) { char ch = content.charAt(i); if (!inString) { @@ -173,6 +205,7 @@ private String repairUnescapedQuotesInJsonStrings(String content) { continue; } if (ch == '"') { + // 根据后续字符判断这是字符串终结符还是字符串内部的双引号 if (isLikelyJsonStringTerminator(content, i + 1)) { inString = false; repaired.append(ch); @@ -186,6 +219,11 @@ private String repairUnescapedQuotesInJsonStrings(String content) { return repaired.toString(); } + /** + * 启发式判断当前位置的双引号是否可能是字符串的终结符。 + * 跳过空白后,如果后面的字符是 ,}]: 之一,则很可能是终结符; + * 如果后面紧跟其他字符(如中文),则很可能是字符串内部的引号。 + */ private boolean isLikelyJsonStringTerminator(String content, int start) { for (int i = start; i < content.length(); i++) { char next = content.charAt(i); @@ -197,6 +235,10 @@ private boolean isLikelyJsonStringTerminator(String content, int start) { return true; } + /** + * 构建重试 prompt:保留原 system prompt,追加严格 JSON 指令和(可选的)上次失败原因。 + * 由配置控制是否启用:retryUseRepairPrompt、retryAppendStrictJsonInstruction、includeLastErrorInRetryPrompt。 + */ private String buildRetrySystemPrompt(String systemPromptWithFormat, Exception lastError) { if (!retryUseRepairPrompt) { return systemPromptWithFormat; @@ -217,6 +259,9 @@ private String buildRetrySystemPrompt(String systemPromptWithFormat, Exception l return prompt.toString(); } + /** + * 清洗错误消息:去换行、截断,避免重试 prompt 中的错误消息太长稀释有效指令。 + */ private String sanitizeErrorMessage(String message) { String oneLine = message.replace('\n', ' ').replace('\r', ' ').trim(); if (oneLine.length() > errorMessageMaxLength) { @@ -225,6 +270,8 @@ private String sanitizeErrorMessage(String message) { return oneLine; } + // === 指标记录 === + private void recordAttempt(String contextTag, String status) { if (!isMetricsAvailable()) { return; @@ -235,12 +282,17 @@ private void recordAttempt(String contextTag, String status) { ).increment(); } + /** + * 记录一次调用。 + */ private void recordInvocation(String contextTag, String status, long startNanos) { if (!isMetricsAvailable()) { return; } Tags tags = Tags.of("context", contextTag, "status", status); + // 调用次数 meterRegistry.counter(METRIC_INVOCATIONS, tags).increment(); + // 调用耗时 meterRegistry.timer(METRIC_LATENCY, tags) .record(System.nanoTime() - startNanos, java.util.concurrent.TimeUnit.NANOSECONDS); } @@ -249,6 +301,10 @@ private boolean isMetricsAvailable() { return metricsEnabled && meterRegistry != null; } + /** + * 将上下文标签归一化为 Prometheus tag 可接受的格式: + * 小写 + 下划线 + 最多 48 字符。 + */ private String normalizeContextTag(String raw) { String source = (raw == null || raw.isBlank()) ? "unknown" : raw; String normalized = source.toLowerCase(Locale.ROOT).trim().replace(' ', '_'); diff --git a/app/src/main/java/interview/guide/common/aspect/RateLimitAspect.java b/app/src/main/java/interview/guide/common/aspect/RateLimitAspect.java index 3cf2abbd..cdaf8ac4 100644 --- a/app/src/main/java/interview/guide/common/aspect/RateLimitAspect.java +++ b/app/src/main/java/interview/guide/common/aspect/RateLimitAspect.java @@ -9,6 +9,7 @@ import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.reflect.MethodSignature; +import org.jspecify.annotations.Nullable; import org.redisson.api.RScript; import org.redisson.api.RedissonClient; import org.redisson.client.codec.StringCodec; @@ -100,31 +101,31 @@ private Long executeRateLimitScript(String key, long nowMs, String requestId, lo }; try { - Object resultObj = rScript.evalSha( - RScript.Mode.READ_WRITE, - luaScriptSha, - RScript.ReturnType.VALUE, - keysList, - args - ); - return convertToLong(resultObj); + return executeRateLimitScript(keysList, args); } catch (org.redisson.client.RedisException e) { // Redis 重启后脚本缓存丢失,重新加载并重试 if (e.getMessage() != null && e.getMessage().contains("NOSCRIPT")) { loadScript(); - Object resultObj = rScript.evalSha( - RScript.Mode.READ_WRITE, - luaScriptSha, - RScript.ReturnType.VALUE, - keysList, - args - ); - return convertToLong(resultObj); + return executeRateLimitScript(keysList, args); } throw e; } } + /** + * 执行限流 Lua 脚本 + */ + private @Nullable Long executeRateLimitScript(List keysList, Object[] args) { + Object resultObj = rScript.evalSha( + RScript.Mode.READ_WRITE, + luaScriptSha, + RScript.ReturnType.VALUE, + keysList, + args + ); + return convertToLong(resultObj); + } + private long calculateIntervalMs(long interval, RateLimit.TimeUnit unit) { return switch (unit) { case MILLISECONDS -> interval; @@ -162,8 +163,7 @@ private String generateKey(String className, String methodName, RateLimit.Dimens }; } - private Object handleRateLimitExceeded(ProceedingJoinPoint joinPoint, RateLimit rateLimit, String key) - throws Throwable { + private Object handleRateLimitExceeded(ProceedingJoinPoint joinPoint, RateLimit rateLimit, String key) { String methodName = joinPoint.getSignature().getName(); if (rateLimit.fallback() != null && !rateLimit.fallback().isEmpty()) { diff --git a/app/src/main/java/interview/guide/common/async/AbstractStreamConsumer.java b/app/src/main/java/interview/guide/common/async/AbstractStreamConsumer.java index c07518fd..ebe18513 100644 --- a/app/src/main/java/interview/guide/common/async/AbstractStreamConsumer.java +++ b/app/src/main/java/interview/guide/common/async/AbstractStreamConsumer.java @@ -15,21 +15,49 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +/** + * Redis Stream 消费者模板 —— 保证所有异步消费者共享一致的消费生命周期。 + *

+ * 选择 Redis Stream + 模板方法模式而非简单队列或函数式接口,原因有三: + *

    + *
  1. Consumer Group 提供显式 ACK 机制,实现至少一次投递语义,消息处理失败后不会丢失;
  2. + *
  3. 同一 Group 内的多个消费者可水平扩展,Stream 自动做负载均衡;
  4. + *
  5. 模板强制子类只关心业务钩子(解析、处理、状态标记), + * 基础设施(Consumer Group 创建、轮询循环、ACK 与重试)由本类统一管理,不可覆写。
  6. + *
+ *

+ * 工作流程:初始化 → 创建/确认 Consumer Group → 阻塞轮询 → 逐条解析 → 回调子类业务 → ACK → 失败时按 {@link AsyncTaskStreamConstants#MAX_RETRY_COUNT} 重试。 + * + * @param 消息荷载类型,子类在 {@link #parsePayload} 中反序列化 + */ @Slf4j public abstract class AbstractStreamConsumer { private final RedisService redisService; + // 使用 AtomicBoolean 而非 volatile,因为 init(主线程)和 shutdown(容器关闭线程)不在同一线程, + // 需要保证 running 的更新对消费者线程立即可见且不会出现指令重排导致的"停不掉" private final AtomicBoolean running = new AtomicBoolean(false); private ExecutorService executorService; private String consumerName; + /** + * @param redisService 基础设施注入,子类不应直接持有 RedisService —— 如需读取辅助状态, + * 请使用受保护的 {@link #redisService()} 访问器 + */ protected AbstractStreamConsumer(RedisService redisService) { this.redisService = redisService; } + /** + * Spring 容器完成 Bean 组装后立即启动消费线程,不等待第一个消息到达。 + * 如果等懒加载则可能在启动高峰期间积压消息,失去 Stream 削峰填谷的优势。 + */ @PostConstruct public void init() { + // Consumer Name 添加随机后缀:同一服务的多实例部署时实例名相同,但 Redis Consumer Group 中每个消费者须唯一 this.consumerName = consumerPrefix() + UUID.randomUUID().toString().substring(0, 8); + // 单线程 + 容量为 0 的队列:保证单消费者内消息严格顺序处理;AbortPolicy 使线程池在异常状态时快速失败, + // 避免消费者静默消失(整个生命周期仅提交 1 个任务,拒绝意味着无法恢复) this.executorService = new ThreadPoolExecutor( 1, 1, @@ -49,6 +77,11 @@ public void init() { log.info("{} consumer started: consumerName={}", taskDisplayName(), consumerName); } + /** + * 容器关闭时先置 running 为 false 让轮询循环自然退出,再关闭线程池。 + * 顺序不可颠倒:先 shutdown 线程池会打断正在处理的消息,导致该消息既未 ACK 也未标记失败, + * 重启后 XREADGROUP 重投递时无从判断是"未处理"还是"已处理但丢 ACK"。 + */ @PreDestroy public void shutdown() { running.set(false); @@ -58,6 +91,10 @@ public void shutdown() { log.info("{} consumer stopped: consumerName={}", taskDisplayName(), consumerName); } + /** + * 启动第一步:确保 Consumer Group 存在(允许抛异常,Group 可能已由其他消费者创建——那是幂等且预期的)。 + * 即使创建失败也继续进入轮询,因为 Group 可能已在上一轮重启前创建好。 + */ private void startConsumer() { try { redisService.createStreamGroup(streamKey(), groupName()); @@ -69,6 +106,10 @@ private void startConsumer() { consumeLoop(); } + /** + * 长连接轮询。底层 {@link RedisService#streamConsumeMessages} 使用 XREADGROUP 阻塞读取, + * 并非真正的忙等。唯一非 running 的出口是 {@link #shutdown()} 置标志位,或 JVM 向守护线程发中断。 + */ private void consumeLoop() { while (running.get()) { try { @@ -83,6 +124,7 @@ private void consumeLoop() { this::processMessage ); } catch (Exception e) { + // 守护线程在 JVM 关闭时收到中断信号而不是 running=false —— 识别后优雅退出,避免日志污染 if (Thread.currentThread().isInterrupted()) { log.info("Consumer thread interrupted"); break; @@ -92,6 +134,12 @@ private void consumeLoop() { } } + /** + * 单条消息的处理管线:解析 → 前置标记 → 业务处理 → 完成标记 → ACK。 + * 失败时依据重试次数决定是重新投递还是进入死信(标记 FAILED + ACK 移除)。 + * ACK 时机:无论成功或最终失败都立即 ACK(死信策略是"移除后外部记录",而非"保留在 Stream 中人工介入"), + * 避免未 ACK 消息堆积阻塞同 Group 中其他消费者的正常消费。 + */ private void processMessage(StreamMessageId messageId, Map data) { T payload; try { @@ -126,6 +174,8 @@ private void processMessage(StreamMessageId messageId, Map data) log.info("{} task completed: {}", taskDisplayName(), payloadIdentifier(payload)); } catch (Exception e) { log.error("{} task failed: {}", taskDisplayName(), payloadIdentifier(payload), e); + // 自主控制重试计数而非使用 Redis XCLAIM/Autoclaim,因为 Stream 原生的超时重投递时延不可控, + // 无法保证在合理时间内完成指定次数重试 if (retryCount < AsyncTaskStreamConstants.MAX_RETRY_COUNT) { retryMessage(payload, retryCount + 1); } else { @@ -137,6 +187,9 @@ private void processMessage(StreamMessageId messageId, Map data) } } + /** + * 从 Stream 消息体解析重试次数。旧版本生产者的消息可能不含此字段,兜底返回 0 表示首次处理。 + */ protected int parseRetryCount(Map data) { if (data == null) { return 0; @@ -148,6 +201,10 @@ protected int parseRetryCount(Map data) { } } + /** + * 截断错误消息至 500 字符。各模块的失败标记({@link #markFailed(Object, String)})通常写入数据库, + * 而 error 字段长度有限(多为 VARCHAR(500)),超出则导致持久化失败——本应是错误记录的日志反而引入新错误。 + */ protected String truncateError(String error) { if (error == null) { return null; @@ -155,6 +212,11 @@ protected String truncateError(String error) { return error.length() > 500 ? error.substring(0, 500) : error; } + /** + * ACK 失败时吞掉异常而不是传播。因为即使 ACK 失败,消息最终会被 Stream 重新投递给另一个消费者, + * 同一消息重入时子类的业务处理({@link #processBusiness})应保证幂等。 + * 因 ACK 异常而导致消费者线程崩溃,代价远大于偶发的重复处理。 + */ private void ackMessage(StreamMessageId messageId) { try { redisService.streamAck(streamKey(), groupName(), messageId); @@ -163,10 +225,16 @@ private void ackMessage(StreamMessageId messageId) { } } + /** + * 子类若需读取 Redis 中与 Stream 消费相关的辅助状态(如检查对应实体是否存在),可通过此访问器获取。 + * 设计为 protected 方法而非直接 protected 字段,保留将来在访问点上添加横切逻辑(如指标收集)的可能。 + */ protected RedisService redisService() { return redisService; } + // ==================== 子类必须实现的抽象方法 ==================== + protected abstract String taskDisplayName(); protected abstract String streamKey(); diff --git a/app/src/main/java/interview/guide/common/config/LlmEmbeddingConfig.java b/app/src/main/java/interview/guide/common/config/LlmEmbeddingConfig.java index e31d6268..1be6fb98 100644 --- a/app/src/main/java/interview/guide/common/config/LlmEmbeddingConfig.java +++ b/app/src/main/java/interview/guide/common/config/LlmEmbeddingConfig.java @@ -9,10 +9,30 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +/** + * 将 {@link LlmProviderRegistry} 包装为 Spring AI 的 {@link EmbeddingModel} Bean。 + *

+ * 之所以不直接注入或暴露某个 Provider 的 EmbeddingModel,而是通过一个薄委托层中转,是因为: + *

    + *
  1. {@link LlmProviderRegistry} 负责在运行时决定使用哪个 Provider(按配置路由、兜底、缓存), + * 而 Spring 的 {@code @Bean} 在启动时即固化——如果直接注入一个具体的 EmbeddingModel, + * 切换 Provider 必须重启应用;
  2. + *
  3. 所有依赖 {@code EmbeddingModel} 的组件(如向量化服务)只需注入这一个 Bean,感知不到其背后的 Provider 切换, + * 符合依赖倒置原则。
  4. + *
+ * 本 Bean 本身是无状态的纯委托,不持有 Provider 实例,所有调用最终由 Registry 按当前配置路由。 + */ @Configuration @Slf4j public class LlmEmbeddingConfig { + /** + * 注册一个委托 EmbeddingModel Bean,所有方法转发至 Registry 的默认 Provider。 + *

+ * 显式重写 {@code embed(Document)} 是因为 Spring AI 在某些 Provider 实现中为 {@code embed(Document)}
+ * 提供了独立的优化路径(如批量编码、缓存),与 {@code call(EmbeddingRequest)} 走不同内部逻辑—— + * 仅委托 {@code call()} 会导致这些优化丢失。 + */ @Bean public EmbeddingModel embeddingModel(LlmProviderRegistry registry) { log.info("EmbeddingModel bean initialized as registry delegate"); diff --git a/app/src/main/java/interview/guide/common/evaluation/EvaluationReport.java b/app/src/main/java/interview/guide/common/evaluation/EvaluationReport.java index fc0ab7f5..cf83580e 100644 --- a/app/src/main/java/interview/guide/common/evaluation/EvaluationReport.java +++ b/app/src/main/java/interview/guide/common/evaluation/EvaluationReport.java @@ -3,25 +3,58 @@ import java.util.List; /** - * 通用面试评估报告(文字面试和语音面试共用) + * 通用面试评估报告(文字面试和语音面试共用)。 + *

+ * 由 AI 模型根据候选人的回答生成,包含整体评分、各维度得分、逐题评价和参考答案。 + * 用于前端展示面试结果和 PDF 报告导出。 */ public record EvaluationReport( + /* 面试会话 ID,关联 InterviewSessionEntity 或 VoiceInterviewSessionEntity */ String sessionId, + /* 本次面试的总题数 */ int totalQuestions, + /* 综合得分(0-100),由 AI 根据各题表现加权计算 */ int overallScore, + /* 各评估维度得分(如:技术能力、项目经验、沟通表达等) */ List categoryScores, + /* 逐题评价详情,包含候选人回答、得分和反馈 */ List questionDetails, + /* 整体评价总结,概括候选人的综合表现 */ String overallFeedback, + /* 候选人优势列表(如:"基础扎实"、"表达清晰") */ List strengths, + /* 待改进项列表(如:"缺乏分布式经验"、"项目描述过于简略") */ List improvements, + /* 参考答案列表,供候选人对照学习 */ List referenceAnswers ) { + /** + * 评估维度得分。 + *

+ * 例如:技术能力 85 分(3 题)、项目经验 78 分(2 题)、沟通表达 90 分(1 题) + * + * @param category 维度名称(如"技术能力"、"项目经验"、"沟通表达") + * @param score 该维度的得分(0-100) + * @param questionCount 该维度下的题目数量 + */ public record CategoryScore( String category, int score, int questionCount ) {} + /** + * 单题评价详情。 + *

+ * 记录每道题的问题内容、候选人回答、AI 评分和反馈意见。 + * + * @param questionIndex 题目序号(从 1 开始) + * @param question 面试问题内容 + * @param category 题目所属维度(如"技术能力") + * @param userAnswer 候选人的回答内容 + * @param score 该题得分(0-100) + * @param feedback AI 对该回答的具体反馈 + */ public record QuestionEvaluation( int questionIndex, String question, @@ -31,6 +64,16 @@ public record QuestionEvaluation( String feedback ) {} + /** + * 参考答案。 + *

+ * 为每道题提供标准答案和关键要点,帮助候选人了解理想回答应包含的内容。 + * + * @param questionIndex 题目序号(从 1 开始) + * @param question 面试问题内容 + * @param referenceAnswer 参考答案全文 + * @param keyPoints 关键要点列表(如:"提到索引优化"、"解释事务隔离级别") + */ public record ReferenceAnswer( int questionIndex, String question, diff --git a/app/src/main/java/interview/guide/common/evaluation/QaRecord.java b/app/src/main/java/interview/guide/common/evaluation/QaRecord.java index 9183d893..67983143 100644 --- a/app/src/main/java/interview/guide/common/evaluation/QaRecord.java +++ b/app/src/main/java/interview/guide/common/evaluation/QaRecord.java @@ -1,11 +1,19 @@ package interview.guide.common.evaluation; /** - * 通用面试问答记录(文字面试和语音面试共用) + * 单题问答记录,用于将面试问答数据传递给 AI 评估模型。 + *

+ * 封装一道面试题的完整上下文:题目序号、问题内容、所属维度和候选人回答。 + * 作为 {@link EvaluationReport} 评估流程的输入数据载体。 + * + * @param questionIndex 题目序号(从 1 开始),用于保持题目顺序 + * @param question 面试问题内容 + * @param category 题目所属评估维度(如"技术能力"、"项目经验"、"沟通表达") + * @param userAnswer 候选人的回答内容;null 表示候选人未回答该题 */ public record QaRecord( int questionIndex, String question, String category, - String userAnswer // null 表示未回答 + String userAnswer ) {} diff --git a/app/src/main/java/interview/guide/common/evaluation/UnifiedEvaluationService.java b/app/src/main/java/interview/guide/common/evaluation/UnifiedEvaluationService.java index f145190c..0abc07f4 100644 --- a/app/src/main/java/interview/guide/common/evaluation/UnifiedEvaluationService.java +++ b/app/src/main/java/interview/guide/common/evaluation/UnifiedEvaluationService.java @@ -25,13 +25,27 @@ import java.util.stream.Collectors; /** - * 统一面试评估服务 - * 文字面试和语音面试共用的评估逻辑:分批评估 + 结构化输出 + 二次汇总 + 降级兜底 + * 文字面试和语音面试共用的评估服务。 + *

+ * 评估采用分批评估 → 二次汇总的两阶段设计: + *

    + *
  1. 分批评估:将问答记录按 {@code batchSize} 分组,每组调用 LLM 生成结构化评估。分批是因为 LLM 的 + * 上下文窗口有限,一次塞入数十题会导致注意力分散、早期信息被遗忘(lost-in-the-middle),降低评分准确性;
  2. + *
  3. 二次汇总:将各批次的评估结果(按题目维度)和批次级总评(按批次维度)一并喂给 LLM 重新整理, + * 输出统一的综合评价。避免"各批次各说各话"导致的融合感差。
  4. + *
+ * 每次 LLM 调用均使用 {@link StructuredOutputInvoker} 自动重试 + 结构化输出, + * 确保返回格式稳定可解析。任一批次失败不影响其他批次——有降级兜底。 + *

+ * 之所以放在 {@code common.evaluation} 而非某个业务模块,是因为 {@code interview} 和 + * {@code voiceinterview} 两个模块都需要评估,避免重复实现。 */ @Service public class UnifiedEvaluationService { private static final Logger log = LoggerFactory.getLogger(UnifiedEvaluationService.class); + // 参考基线文本最长 6000 字符(约 3000~4000 tokens),与简历截断策略配合, + // 确保两路上下文合计不超过单批次 LLM 输入的上限 private static final int MAX_REFERENCE_CONTEXT_CHARS = 6000; private final PromptTemplate systemPromptTemplate; @@ -44,35 +58,63 @@ public class UnifiedEvaluationService { private final int evaluationBatchSize; private final ResourceLoader resourceLoader; - // 批次评估结果 + // 以下四个内部 record 仅在本服务内部传递数据,不暴露到外部 API: + // BatchReportDTO 对应 LLM 单批次输出的结构化 schema + // QuestionEvalDTO 是单题评估的扁平载体,按 questionIndex 对齐 + // BatchResult 封装某批次的索引范围+报告,合并阶段按索引定位 + // SummaryDTO 是二次汇总 LLM 调用的输出 schema,只包含汇总级别的字段 private record BatchReportDTO( + // 本批次整体评分(0-100) int overallScore, + // 本批次整体评语 String overallFeedback, + // 候选人表现优点列表 List strengths, + // 候选人待改进点列表 List improvements, + // 本批次每道题的逐题评估结果 List questionEvaluations ) {} private record QuestionEvalDTO( + // 题目在完整问答列表中的全局索引(0-based),用于跨批次合并时对齐 int questionIndex, + // 单题评分(0-100) int score, + // 单题评估反馈 String feedback, + // 参考答案 String referenceAnswer, + // 参考答案的关键要点 List keyPoints ) {} private record BatchResult( + // 本批次在完整问答列表中的起始索引(含) int startIndex, + // 本批次在完整问答列表中的结束索引(不含) int endIndex, + // 本批次的评估报告,评估失败时为 null BatchReportDTO report ) {} private record SummaryDTO( + // 二次汇总后的综合评语,为空时降级使用批次聚合结果 String overallFeedback, + // 汇总后的优点列表 List strengths, + // 汇总后的改进点列表 List improvements ) {} + /** + * @param structuredOutputInvoker 结构化输出重试封装——两次 LLM 调用(批次评估 + 二次汇总)都经过它, + * 确保反序列化失败时自动重试,不因一次格式异常中断整个评估流程 + * @param resourceLoader 从 classpath 加载 Prompt 模板文件(.st 格式), + * 路径由配置 {@code app.evaluation.prompts.*} 指定 + * @param evaluationProperties 批大小可调:调小降低每次 LLM 调用丢给模型的压力但总调用次数增加, + * 调大减少调用次数但增加上下文丢失风险——默认值需按实际模型上下文长度调整 + */ public UnifiedEvaluationService( StructuredOutputInvoker structuredOutputInvoker, ResourceLoader resourceLoader, @@ -85,17 +127,12 @@ public UnifiedEvaluationService( this.summarySystemPromptTemplate = new PromptTemplate(loadPrompt(evaluationProperties.getSummarySystemPromptPath())); this.summaryUserPromptTemplate = new PromptTemplate(loadPrompt(evaluationProperties.getSummaryUserPromptPath())); this.summaryOutputConverter = new BeanOutputConverter<>(SummaryDTO.class); + // 批大小 <= 0 则退化为一次评一题——安全性兜底,撞墙场景(配置错误)下不至于抛出异常 this.evaluationBatchSize = Math.max(1, evaluationProperties.getBatchSize()); } /** - * 评估面试问答(文字和语音通用) - * - * @param chatClient LLM 客户端 - * @param sessionId 会话ID(用于日志) - * @param qaRecords 问答记录列表 - * @param resumeText 简历摘要(可选,可为 null) - * @return 评估报告 + * 评估入口(无参考基线版本)。 */ public EvaluationReport evaluate(ChatClient chatClient, String sessionId, @@ -104,6 +141,22 @@ public EvaluationReport evaluate(ChatClient chatClient, return evaluate(chatClient, sessionId, qaRecords, resumeText, null); } + /** + * 评估入口(含参考基线)。 + *

+ * 处理流程: + *

    + *
  1. 截断简历和参考基线至合理长度,避免单次 LLM 调用超过上下文上限(截断在前,分批在后);
  2. + *
  3. 按 {@link #evaluationBatchSize} 分批调用 LLM,每批输出结构化的评分子集;
  4. + *
  5. 合并所有批次的评估结果为扁平列表(漏评的题兜底为 0 分);
  6. + *
  7. 将合并结果 + 原始问答喂给 LLM 做二次汇总,生成统一的综合评价(这一步骤才是最终呈现在报告中的总评);
  8. + *
  9. 二次汇总失败时降级使用批次级合并结果,确保评估报告不会为空。
  10. + *
+ * + * @param referenceContext 参考基线(期望的回答标准),传 null 或空串时 LLM 自行评判。 + * 仅在 {@code voiceinterview} 模块的预评估场景中提供, + * {@code interview} 模块不用——这是为什么提供两个重载,而非统一的必选参数 + */ public EvaluationReport evaluate(ChatClient chatClient, String sessionId, List qaRecords, @@ -112,7 +165,9 @@ public EvaluationReport evaluate(ChatClient chatClient, log.info("开始评估面试: sessionId={}, 共{}题", sessionId, qaRecords.size()); String resumeContext = resumeText != null ? resumeText : ""; - // 超长简历截断,保留前 3000 字符(约 1500~2000 tokens),避免极端情况下 token 消耗过大 + // 截断至 3000 字符(约 1500~2000 tokens):保证简历上下文 + 参考基线 + N 道问答的总和 + // 不超过绝大多数 embedding 模型和 chat 模型的输入上限。截断在批次处理之前做, + // 避免每批都重复截断同一份简历 if (resumeContext.length() > 3000) { resumeContext = resumeContext.substring(0, 3000) + "\n...(简历内容过长,已截断)"; } @@ -148,6 +203,14 @@ private String loadPrompt(String path) throws IOException { return resource.getContentAsString(StandardCharsets.UTF_8); } + /** + * 顺序执行分批评估而非并行,原因有二: + *
    + *
  1. 保证评估结果的确定性顺序——题号递增,便于后续按索引合并;
  2. + *
  3. 避免同一 LLM Provider 的并发限流或资源争抢(常见于 Serverless 部署)。
  4. + *
+ * 某个批次失败(返回 null)不影响后续批次继续评估,最终合并逻辑能按题位填补兜底分数。 + */ private List evaluateInBatches(ChatClient chatClient, String sessionId, String resumeContext, List qaRecords, String referenceContext) { @@ -161,6 +224,16 @@ private List evaluateInBatches(ChatClient chatClient, String sessio return results; } + /** + * 单批次评估:组装 Prompt → 调用 LLM → 结构化输出 → 返回 DTO。 + * 失败时返回 null 而非抛异常,因为: + *
    + *
  1. 批间解耦——某一题评估失败不应牵连其他题目;
  2. + *
  3. 合并逻辑已具备兜底能力(0 分 + 占位文本),无需上层额外处理。
  4. + *
+ * 格式指令({@link BeanOutputConverter#getFormat})追加到 system prompt 末尾而非嵌在 user prompt 中, + * 因为 AI 模型通常将 system prompt 的内容视为更强的约束指令格式,结构化输出命中率更高。 + */ private BatchReportDTO evaluateBatch(ChatClient chatClient, String sessionId, String resumeContext, String referenceContext, List batch) { @@ -199,6 +272,11 @@ private String buildQARecords(List batch) { return sb.toString(); } + /** + * 将各批次的评估结果按原始题序合并为扁平列表。 + * LLM 可能漏评某些题目(返回数量少于预期),或整批评估失败(report == null), + * 此时用 0 分 + 占位文本填充缺失位,保证返回列表长度 = 题目总数,后续 buildReport 无需检查越界。 + */ private List mergeQuestionEvaluations(List batchResults) { List merged = new ArrayList<>(); for (BatchResult result : batchResults) { @@ -221,6 +299,10 @@ private List mergeQuestionEvaluations(List batchRe return merged; } + /** + * 合并各批次的总体评语。批次间用双换行分隔,保留 LLM 对各批次独立的语段边界, + * 比用句号/逗号拼接更易于阅读(且二次汇总 LLM 也能据此识别分段)。 + */ private String mergeOverallFeedback(List batchResults) { String feedback = batchResults.stream() .map(BatchResult::report) @@ -230,6 +312,12 @@ private String mergeOverallFeedback(List batchResults) { return feedback.isBlank() ? "本次面试已完成分批评估,但未生成有效综合评语。" : feedback; } + /** + * 合并各批次的优点/改进点列表。 + * 使用 {@link LinkedHashSet} 在去重的同时保留原始出现顺序——同一优点被多个批次提及说明重要性高, + * 保留在前更能反映 LLM 的关注焦点。 + * 限制 8 条:评估报告 UI 通常在折叠区域展示列表,条目过多会过度撑大报告篇幅。 + */ private List mergeListItems(List batchResults, boolean strengthsMode) { Set merged = new LinkedHashSet<>(); for (BatchResult result : batchResults) { @@ -245,6 +333,19 @@ private List mergeListItems(List batchResults, boolean stre return merged.stream().limit(8).toList(); } + /** + * 二次汇总:将各批次的分散评估结果送入 LLM 做一次综合提炼。 + *

+ * 这一步之所以必要,是因为分批评估中每批 LLM 只看到自己那几道题的问答,无法产出全局视角的总评。 + * 二次汇总的 prompt 包含三类信息: + *

    + *
  1. 分维度的平均分(categorySummary)——让 LLM 看到强弱势领域;
  2. + *
  3. 每道题的简要评估摘要(questionHighlights)——提供逐题粒度但不暴露完整原文(已截断),节省 tokens;
  4. + *
  5. 分批聚合的 fallback 总评/优点/改进点——LLM 即使拒绝输出也可降级使用。
  6. + *
+ * 降级设计:任一字段为空时退守 fallback 值;调用本身抛异常时使用完整的 fallback SummaryDTO。 + * 这就是统一评估"最少也有基础结果"的最后兜底。 + */ private SummaryDTO summarizeBatchResults( ChatClient chatClient, String sessionId, String resumeContext, String referenceContext, List qaRecords, List evaluations, @@ -279,6 +380,15 @@ private SummaryDTO summarizeBatchResults( } } + /** + * 合并 LLM 输出与 fallback 列表,优先使用 LLM 输出但确保不为空。 + * null 与 empty 的区别很重要: + *
    + *
  • primary == null → LLM 未生成该字段(结构化输出字段缺失),退到 fallback;
  • + *
  • primary.isEmpty() → LLM 生成了一个空数组,视为"没有值得列出的项",同样退到 fallback;
  • + *
  • primary 有值但全部是空白字符串 → 清理后变空,退到 fallback。
  • + *
+ */ private List sanitizeItems(List primary, List fallback) { List source = (primary != null && !primary.isEmpty()) ? primary : fallback; if (source == null || source.isEmpty()) return List.of(); @@ -287,6 +397,16 @@ private List sanitizeItems(List primary, List fallback) .map(String::trim).distinct().limit(8).toList(); } + /** + * 将内部评估数据组装为 {@link EvaluationReport} 输出对象。 + *

+ * 关键安全设计: + *

    + *
  • 未回答的题强制 0 分,即使 LLM 给出了评分(防止 AI 对"未回答"给出同情分);
  • + *
  • evaluations 长度不足时(合并阶段已尽力填充,仍差少数),靠 {@code i < evalSize} 边界保护,取不到时视作未评分;
  • + *
  • overallScore 的计算仅计入有回答的题目,避免未答题拉低总分。
  • + *
+ */ private EvaluationReport buildReport(String sessionId, List qaRecords, List evaluations, String overallFeedback, @@ -295,6 +415,7 @@ private EvaluationReport buildReport(String sessionId, List qaRecords, List referenceAnswers = new ArrayList<>(); Map> categoryScoresMap = new HashMap<>(); + // 先统计实际回答的题数,用于后续 overallScore 的除数——不应将未答题计入分母 long answeredCount = qaRecords.stream() .filter(q -> q.userAnswer() != null && !q.userAnswer().isBlank()) .count(); @@ -343,6 +464,12 @@ private EvaluationReport buildReport(String sessionId, List qaRecords, ); } + /** + * 生成分维度的平均分文本,作为二次汇总 prompt 的输入素材。 + * 之所以在此重新统计而非复用 {@link #buildReport} 中已完成的 categoryScores, + * 是因为这里的输出是供 LLM 读的纯文本,而非最终报告中的结构化数据——两者的消费者不同, + * 不共享同一份中间状态可以减少方法间耦合。 + */ private String buildCategorySummary(List qaRecords, List evaluations) { Map> categoryScores = new HashMap<>(); for (int i = 0; i < qaRecords.size(); i++) { @@ -363,6 +490,11 @@ private String buildCategorySummary(List qaRecords, List qaRecords, List evaluations) { List highlights = new ArrayList<>(); for (int i = 0; i < qaRecords.size(); i++) { diff --git a/app/src/test/java/interview/guide/common/ai/ApiPathResolverTest.java b/app/src/test/java/interview/guide/common/ai/ApiPathResolverTest.java index 7cbf4912..f3f758d4 100644 --- a/app/src/test/java/interview/guide/common/ai/ApiPathResolverTest.java +++ b/app/src/test/java/interview/guide/common/ai/ApiPathResolverTest.java @@ -4,6 +4,8 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import java.time.Duration; + import static org.assertj.core.api.Assertions.assertThat; class ApiPathResolverTest { @@ -151,7 +153,7 @@ void plainBaseUrlBuildsClient() { @DisplayName("自定义超时参数生效") void customTimeouts() { var client = ApiPathResolver.buildOpenAiClient( - "https://api.openai.com", "test-key", 5000, 60000); + "https://api.openai.com", "test-key", Duration.ofSeconds(5), Duration.ofMinutes(1)); assertThat(client).isNotNull(); } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 34be1f4f..b081761c 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -40,8 +40,8 @@ services: container_name: interview-rustfs command: ["/data"] environment: - RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:-rustfsadmin} - RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:-rustfsadmin} + RUSTFS_ACCESS_KEY: ${APP_STORAGE_ACCESS_KEY:-admin} + RUSTFS_SECRET_KEY: ${APP_STORAGE_ACCESS_KEY:-admin} RUSTFS_CONSOLE_ENABLE: "true" ports: - "9000:9000"