From ab26750fb82b07b7fb6b63163f1501b5bf36d36a Mon Sep 17 00:00:00 2001 From: let5sne Date: Tue, 14 Jul 2026 11:39:46 +0800 Subject: [PATCH] feat(aihr): expose model usage telemetry --- .../org/dromara/aihr/domain/AihrModelDto.java | 11 +++++- .../aihr/service/AihrModelSeedService.java | 38 +++++++++++++++---- .../aihr/service/AihrSensitiveTextTest.java | 3 ++ docs/BRD_IMPLEMENTATION_AUDIT.md | 1 + frontend/src/api/aihr/model.ts | 6 +++ 5 files changed, 50 insertions(+), 9 deletions(-) diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrModelDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrModelDto.java index 7c755322..a8891252 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrModelDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrModelDto.java @@ -70,6 +70,14 @@ public final class AihrModelDto { public record ChatRequest(String prompt, String model, String systemPrompt) { } + public record ModelUsage( + Integer promptTokens, + Integer completionTokens, + Integer totalTokens, + long latencyMs + ) { + } + public record ChatResponse( boolean configured, String providerCode, @@ -77,7 +85,8 @@ public final class AihrModelDto { String answer, String mode, String error, - List hints + List hints, + ModelUsage usage ) { } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java index 0edc839a..53fdbd3e 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java @@ -14,6 +14,7 @@ import org.dromara.aihr.domain.AihrModelDto.ConfigResponse; import org.dromara.aihr.domain.AihrModelDto.ProviderRequest; import org.dromara.aihr.domain.AihrModelDto.ProviderResponse; import org.dromara.aihr.domain.AihrModelDto.ProviderStatusRequest; +import org.dromara.aihr.domain.AihrModelDto.ModelUsage; import org.dromara.common.satoken.utils.LoginHelper; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DataAccessException; @@ -200,7 +201,8 @@ public class AihrModelSeedService { "AI 调用已被试点成本闸门关闭,当前返回文本兜底:先安抚业主情绪,确认事实与责任人,再承诺首次反馈时间。", "cost-guard-fallback", null, - List.of("需要恢复真实调用时设置 AIHR_AI_RUNTIME_ENABLED=true 且 AIHR_AI_CHAT_ENABLED=true。") + List.of("需要恢复真实调用时设置 AIHR_AI_RUNTIME_ENABLED=true 且 AIHR_AI_CHAT_ENABLED=true。"), + null ); } @@ -215,13 +217,14 @@ public class AihrModelSeedService { List.of( "在 aihr_model_provider / aihr_model_config 配置 api_host、api_key、model_name 后可切换到真实 OpenAI-compatible 调用。", "接口不会返回 api_key。" - ) + ), + null ); } try { - String content = callOpenAiCompatible(runtime, modelName, prompt, request == null ? null : request.systemPrompt(), 0.2); - return new ChatResponse(true, runtime.providerCode(), modelName, content, "openai-compatible", null, List.of()); + ModelCallResult call = callOpenAiCompatible(runtime, modelName, prompt, request == null ? null : request.systemPrompt(), 0.2); + return new ChatResponse(true, runtime.providerCode(), modelName, call.content(), "openai-compatible", null, List.of(), call.usage()); } catch (Exception e) { return new ChatResponse( true, @@ -230,7 +233,8 @@ public class AihrModelSeedService { "真实模型调用失败,已回退内置默认兜底:先确认事实、同步时限、再生成工单闭环。", "openai-compatible-failed", "模型调用失败,已回退内置兜底;请检查模型供应商配置。", - List.of("检查 aihr_model_provider.api_host/api_key 和 aihr_model_config.model_name 是否与供应商一致。") + List.of("检查 aihr_model_provider.api_host/api_key 和 aihr_model_config.model_name 是否与供应商一致。"), + null ); } } @@ -284,7 +288,7 @@ public class AihrModelSeedService { return Optional.empty(); } try { - return Optional.of(callOpenAiCompatible(runtime, runtime.modelName(), userPrompt, systemPrompt, temperature)); + return Optional.of(callOpenAiCompatible(runtime, runtime.modelName(), userPrompt, systemPrompt, temperature).content()); } catch (Exception e) { log.warn("aihr llm tryChat failed, caller falls back to seed(处理错误已隐藏)"); return Optional.empty(); @@ -376,7 +380,8 @@ public class AihrModelSeedService { } } - private String callOpenAiCompatible(RuntimeConfig runtime, String modelName, String prompt, String systemPrompt, double temperature) throws Exception { + private ModelCallResult callOpenAiCompatible(RuntimeConfig runtime, String modelName, String prompt, String systemPrompt, double temperature) throws Exception { + long startedAt = System.nanoTime(); ObjectNode body = objectMapper.createObjectNode(); body.put("model", modelName); body.put("temperature", temperature); @@ -422,7 +427,21 @@ public class AihrModelSeedService { if (isBlank(content)) { throw new IllegalStateException("LLM response missing message.content"); } - return content; + JsonNode usage = root.path("usage"); + ModelUsage modelUsage = new ModelUsage( + integerUsage(usage, "prompt_tokens"), + integerUsage(usage, "completion_tokens"), + integerUsage(usage, "total_tokens"), + Duration.ofNanos(System.nanoTime() - startedAt).toMillis() + ); + return new ModelCallResult(content, modelUsage); + } + + private static Integer integerUsage(JsonNode usage, String field) { + if (usage == null || usage.isMissingNode() || !usage.has(field) || !usage.get(field).canConvertToInt()) { + return null; + } + return usage.get(field).intValue(); } private RuntimeConfig runtimeConfig(String requestedModel) { @@ -593,6 +612,9 @@ public class AihrModelSeedService { private record RuntimeConfig(String providerCode, String modelName, String baseUrl, String apiKey, boolean configured, String source) { } + private record ModelCallResult(String content, ModelUsage usage) { + } + private record ProviderData( String providerName, String providerCode, diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSensitiveTextTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSensitiveTextTest.java index 6ff8c361..c12322f6 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSensitiveTextTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSensitiveTextTest.java @@ -48,6 +48,9 @@ class AihrSensitiveTextTest { String speechControllerSource = Files.readString(controllerSource("AihrSpeechController.java")); assertTrue(modelSource.contains("AihrSensitiveText.forModel(prompt)")); + assertTrue(modelSource.contains("prompt_tokens")); + assertTrue(modelSource.contains("completion_tokens")); + assertTrue(modelSource.contains("latencyMs")); assertTrue(modelSource.contains("外部响应体已隐藏")); assertTrue(modelSource.contains("模型调用失败,已回退内置兜底;请检查模型供应商配置。")); assertTrue(sopSource.contains("AihrSensitiveText.forModel(renderPrompt(prompt.template(), queryText, context))")); diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index ccb095a1..1beef281 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -197,3 +197,4 @@ - 2026-07-14 BRD 4.4 每日三题结果留痕补强:每日三题提交返回的分数此前只存在响应内,刷新任务列表后无法恢复数值分数;现新增 `aihr_practice_assignment.score`,提交、查询、员工学习页均保留“得分 + 反馈”。当前得分仍来自可解释关键词规则兜底,不把它宣称为语义级 AI 评分;后续若启用模型评估,需单独补评分模式、成本闸门和人工抽检证据。 - 2026-07-14 BRD 4.4 每日一练评分能力复核:当前 `scoreDailyDrill` 使用参考答案关键词命中和长度规则即时反馈,属于可解释的规则兜底,不等同于 BRD 所称的语义级“AI 秒评”;正式升级前需确认模型、成本闸门、评分维度和人工抽检样本,当前不把规则分数宣称为 AI 评测结果。 - 2026-07-14 BRD G6 成本计量复核:模型调用已有手动运行断路器,但当前没有 `llm_call_log` 或等价的租户级 token、延迟、费用持久化;由于供应商计费字段、单价和月度阈值尚未确认,本轮不新增价格硬编码或生产数据表,先把该项保留为生产前闸门。 +- 2026-07-14 TechSpec 3.8 调用反馈补强:模型测试接口成功调用时现在返回供应商 `prompt_tokens/completion_tokens/total_tokens` 和本次 `latencyMs`;未配置、成本断路器或调用失败仍返回空 usage,不伪造 token 或费用。该反馈可作为后续 `llm_call_log` 的输入,但不等于已完成月度成本计量和自动阈值降级。 diff --git a/frontend/src/api/aihr/model.ts b/frontend/src/api/aihr/model.ts index e62d5bf5..1f28fc70 100644 --- a/frontend/src/api/aihr/model.ts +++ b/frontend/src/api/aihr/model.ts @@ -62,6 +62,12 @@ export type ModelChatResponse = { mode: string; error?: string; hints: string[]; + usage?: { + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + latencyMs: number; + } | null; }; export type VectorIndexStatus = {