diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java index 847df036..e43260d7 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java @@ -1,6 +1,10 @@ package org.dromara.aihr.controller; import lombok.RequiredArgsConstructor; +import org.dromara.aihr.domain.AihrSopDto.AnswerFeedbackItemResponse; +import org.dromara.aihr.domain.AihrSopDto.AnswerFeedbackRequest; +import org.dromara.aihr.domain.AihrSopDto.AnswerFeedbackResponse; +import org.dromara.aihr.domain.AihrSopDto.AnswerFeedbackReviewResponse; import org.dromara.aihr.domain.AihrSopDto.LocalImportRequest; import org.dromara.aihr.domain.AihrSopDto.LocalImportResponse; import org.dromara.aihr.domain.AihrSopDto.LocalImportTaskResponse; @@ -65,6 +69,23 @@ public class AihrSopController { return R.ok(sopSeedService.search(request)); } + @PostMapping("/answer-feedback") + public R answerFeedback(@RequestBody AnswerFeedbackRequest request) { + return R.ok(sopSeedService.answerFeedback(request)); + } + + @GetMapping("/answer-feedback") + public R> answerFeedbackList(@RequestParam(value = "status", required = false, defaultValue = "待复核") String status, + @RequestParam(value = "limit", required = false, defaultValue = "50") int limit) { + return R.ok(sopSeedService.answerFeedbackList(status, limit)); + } + + @PostMapping("/answer-feedback/{id}/review") + public R reviewAnswerFeedback(@PathVariable Long id) { + AnswerFeedbackReviewResponse response = sopSeedService.reviewAnswerFeedback(id); + return response == null ? R.fail("答案反馈记录不存在") : R.ok(response); + } + @PostMapping("/reviews/{id}") public R review(@PathVariable Long id, @RequestBody(required = false) SopReviewRequest request) { SopReviewResponse response = sopSeedService.reviewSearch(id, request); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java index 564d1006..d0fb28be 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java @@ -19,6 +19,18 @@ public final class AihrSopDto { public record SopReviewResponse(Long id, Boolean usable, String status, String reviewedAt) { } + public record AnswerFeedbackRequest(String queryText, Long fragmentId, List fragmentIds, String verdict, String category, String position, String source) { + } + + public record AnswerFeedbackResponse(Integer saved, String verdict, String reviewStatus) { + } + + public record AnswerFeedbackItemResponse(Long id, String queryText, String queryNorm, Long fragmentId, String verdict, String category, String position, String source, String reviewStatus, String createTime, String updateTime) { + } + + public record AnswerFeedbackReviewResponse(Long id, String reviewStatus) { + } + public record SummaryCardResponse(String title, List steps, List objections, List scripts, List reminders) { } @@ -93,7 +105,10 @@ public final class AihrSopDto { public record DocResponse(String title, String desc, String hit, String type) { } - public record SnippetResponse(String title, String text) { + public record SnippetResponse(String title, String text, Long fragmentId) { + public SnippetResponse(String title, String text) { + this(title, text, null); + } } public record RecordResponse(String title, String category, String version, String status, String owner, String summary) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java index 30b1230f..0711dfef 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java @@ -6,6 +6,10 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.extern.slf4j.Slf4j; import org.dromara.aihr.domain.AihrSopDto.CategoryStatResponse; +import org.dromara.aihr.domain.AihrSopDto.AnswerFeedbackItemResponse; +import org.dromara.aihr.domain.AihrSopDto.AnswerFeedbackRequest; +import org.dromara.aihr.domain.AihrSopDto.AnswerFeedbackResponse; +import org.dromara.aihr.domain.AihrSopDto.AnswerFeedbackReviewResponse; import org.dromara.aihr.domain.AihrSopDto.CardObjection; import org.dromara.aihr.domain.AihrSopDto.CardStep; import org.dromara.aihr.domain.AihrSopDto.DocResponse; @@ -70,9 +74,11 @@ import java.util.HexFormat; import java.util.LinkedHashSet; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ScheduledExecutorService; @@ -114,6 +120,7 @@ public class AihrSopSeedService { private final AihrVideoService videoService; private volatile boolean knowledgeGapTableReady; private volatile boolean sopReviewTableReady; + private volatile boolean answerFeedbackTableReady; public AihrSopSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, ISysOssService ossService, @Value("${aihr.import.root:}") String importRootConfig, @@ -168,6 +175,97 @@ public class AihrSopSeedService { return updated == 0 ? null : sopReview(id); } + public AnswerFeedbackResponse answerFeedback(AnswerFeedbackRequest request) { + String queryText = request == null ? "" : Optional.ofNullable(request.queryText()).orElse("").trim(); + String queryNorm = normalizeFeedbackQuery(queryText); + if (isBlank(queryNorm)) { + throw new ServiceException("请先输入问题"); + } + String verdict = normalizeFeedbackVerdict(request == null ? null : request.verdict()); + if (isBlank(verdict)) { + throw new ServiceException("反馈类型只支持 up/down"); + } + List fragmentIds = feedbackFragmentIds(request); + if (fragmentIds.isEmpty()) { + throw new ServiceException("缺少片段ID"); + } + ensureAnswerFeedbackTable(); + String reviewStatus = "down".equals(verdict) ? "待复核" : "已复核"; + Timestamp now = Timestamp.valueOf(java.time.LocalDateTime.now()); + int saved = 0; + for (Long fragmentId : fragmentIds) { + saved += jdbcTemplate.update(""" + INSERT INTO aihr_knowledge_answer_feedback + (tenant_id, query_text, query_norm, fragment_id, verdict, category, position, source, review_status, create_time, update_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + query_text = VALUES(query_text), + verdict = VALUES(verdict), + category = VALUES(category), + position = VALUES(position), + source = VALUES(source), + review_status = VALUES(review_status), + update_time = VALUES(update_time) + """, + TENANT_ID, + truncate(queryText, 1000), + queryNorm, + fragmentId, + verdict, + firstNonBlank(request.category(), "sop"), + firstNonBlank(request.position(), "生活顾问"), + normalizeSearchSource(request.source()), + reviewStatus, + now, + now + ); + } + return new AnswerFeedbackResponse(saved, verdict, reviewStatus); + } + + public List answerFeedbackList(String status, int limit) { + ensureAnswerFeedbackTable(); + String reviewStatus = firstNonBlank(status, "待复核"); + int limitValue = Math.max(1, Math.min(limit, 200)); + return jdbcTemplate.query(""" + SELECT id, query_text, query_norm, fragment_id, verdict, category, position, source, review_status, create_time, update_time + FROM aihr_knowledge_answer_feedback + WHERE tenant_id = ? + AND (? = '' OR review_status = ?) + ORDER BY create_time DESC, id DESC + LIMIT ? + """, (rs, rowNum) -> new AnswerFeedbackItemResponse( + rs.getLong("id"), + rs.getString("query_text"), + rs.getString("query_norm"), + rs.getLong("fragment_id"), + rs.getString("verdict"), + rs.getString("category"), + rs.getString("position"), + rs.getString("source"), + rs.getString("review_status"), + formatTimestamp(rs.getTimestamp("create_time")), + formatTimestamp(rs.getTimestamp("update_time")) + ), TENANT_ID, reviewStatus, reviewStatus, limitValue); + } + + public AnswerFeedbackReviewResponse reviewAnswerFeedback(Long id) { + if (id == null) { + return null; + } + ensureAnswerFeedbackTable(); + int updated = jdbcTemplate.update(""" + UPDATE aihr_knowledge_answer_feedback + SET review_status = '已复核', update_time = ? + WHERE tenant_id = ? AND id = ? + """, + Timestamp.valueOf(java.time.LocalDateTime.now()), + TENANT_ID, + id + ); + return updated == 0 ? null : new AnswerFeedbackReviewResponse(id, "已复核"); + } + public SummaryCardResponse summaryCard(SummaryCardRequest request) { String queryText = request == null ? "" : Optional.ofNullable(request.queryText()).orElse("").trim(); if (isBlank(queryText)) { @@ -1659,7 +1757,8 @@ public class AihrSopSeedService { if (hits.isEmpty()) { return null; } - return responseFromHits(category, queryText, hits); + FeedbackFilterResult filtered = filterDownFeedback(queryText, hits); + return responseFromHits(category, queryText, filtered.hits(), filtered.cautions()); } catch (DataAccessException e) { return null; } @@ -1795,6 +1894,38 @@ public class AihrSopSeedService { } } + private void ensureAnswerFeedbackTable() { + if (answerFeedbackTableReady) { + return; + } + synchronized (this) { + if (answerFeedbackTableReady) { + return; + } + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS `aihr_knowledge_answer_feedback` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `query_text` varchar(1000) NOT NULL COMMENT '问题原文', + `query_norm` varchar(500) NOT NULL COMMENT '规范化问题', + `fragment_id` bigint NOT NULL COMMENT '知识片段ID', + `verdict` varchar(10) NOT NULL COMMENT '反馈: up/down', + `category` varchar(100) DEFAULT NULL COMMENT '知识分类', + `position` varchar(80) DEFAULT NULL COMMENT '岗位', + `source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源', + `review_status` varchar(30) DEFAULT '待复核' COMMENT '复核状态', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_answer_feedback_pair` (`tenant_id`, `query_norm`, `fragment_id`), + KEY `idx_aihr_answer_feedback_verdict` (`tenant_id`, `query_norm`, `fragment_id`, `verdict`), + KEY `idx_aihr_answer_feedback_review` (`tenant_id`, `review_status`, `create_time`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 知识答案反馈'; + """); + answerFeedbackTableReady = true; + } + } + private void ensureKnowledgeGapTable() { if (knowledgeGapTableReady) { return; @@ -1843,6 +1974,36 @@ public class AihrSopSeedService { return source.isBlank() ? "knowledge_search" : source; } + private static String normalizeFeedbackQuery(String value) { + return truncate(Optional.ofNullable(value).orElse("") + .trim() + .toLowerCase(Locale.ROOT) + .replaceAll("\\s+", " ") + .trim(), 500); + } + + private static String normalizeFeedbackVerdict(String value) { + String verdict = Optional.ofNullable(value).orElse("").trim().toLowerCase(Locale.ROOT); + return "up".equals(verdict) || "down".equals(verdict) ? verdict : ""; + } + + private static List feedbackFragmentIds(AnswerFeedbackRequest request) { + LinkedHashSet ids = new LinkedHashSet<>(); + if (request != null && request.fragmentIds() != null) { + request.fragmentIds().stream() + .filter(id -> id != null && id > 0) + .forEach(ids::add); + } + if (ids.isEmpty() && request != null && request.fragmentId() != null && request.fragmentId() > 0) { + ids.add(request.fragmentId()); + } + return ids.stream().toList(); + } + + private static String formatTimestamp(Timestamp timestamp) { + return timestamp == null ? "" : timestamp.toLocalDateTime().format(TIME_FORMATTER); + } + private List dbHits(String category, String queryText, Integer limit) { int limitValue = limitValue(limit); List keywordHits = likeHits(category, queryText, limitValue); @@ -1850,6 +2011,41 @@ public class AihrSopSeedService { return rerankHits(queryText, rrf(textHits, vectorHits(category, queryText, limitValue), limitValue)); } + private FeedbackFilterResult filterDownFeedback(String queryText, List hits) { + Set rejected = downFeedbackFragmentIds(queryText); + if (rejected.isEmpty()) { + return new FeedbackFilterResult(hits, List.of()); + } + List filtered = hits.stream() + .filter(hit -> hit.fragmentId() == null || !rejected.contains(hit.fragmentId())) + .toList(); + if (filtered.isEmpty()) { + return new FeedbackFilterResult( + hits, + List.of("该问题下命中的片段均曾被点踩,本次保留原结果避免无答案。") + ); + } + return new FeedbackFilterResult(filtered, List.of()); + } + + private Set downFeedbackFragmentIds(String queryText) { + String queryNorm = normalizeFeedbackQuery(queryText); + if (isBlank(queryNorm)) { + return Set.of(); + } + try { + return new LinkedHashSet<>(jdbcTemplate.queryForList(""" + SELECT fragment_id + FROM aihr_knowledge_answer_feedback + WHERE tenant_id = ? + AND query_norm = ? + AND verdict = 'down' + """, Long.class, TENANT_ID, queryNorm)); + } catch (DataAccessException ignored) { + return Set.of(); + } + } + private List vectorHits(String category, String queryText, int limit) { for (EmbeddingRuntime runtime : embeddingRuntimes()) { try { @@ -1874,7 +2070,8 @@ public class AihrSopSeedService { private List fulltextHits(String category, String queryText, int limit) { return jdbcTemplate.query(""" - select coalesce(a.name, i.name) as title, + select f.id as fragment_id, + coalesce(a.name, i.name) as title, i.name as category, coalesce(i.description, '') as description, f.doc_id, @@ -1890,6 +2087,7 @@ public class AihrSopSeedService { order by score desc, f.idx asc limit ? """, (rs, rowNum) -> new KnowledgeHit( + rs.getLong("fragment_id"), rs.getString("title"), rs.getString("category"), rs.getString("description"), @@ -1912,7 +2110,8 @@ public class AihrSopSeedService { whereParts.add("f.content like ? escape '\\\\'"); } String sql = """ - select coalesce(a.name, i.name) as title, + select f.id as fragment_id, + coalesce(a.name, i.name) as title, i.name as category, coalesce(i.description, '') as description, f.doc_id, @@ -1936,6 +2135,7 @@ public class AihrSopSeedService { terms.stream().map(AihrSopSeedService::likePattern).forEach(args::add); args.add(limit); return jdbcTemplate.query(sql, (rs, rowNum) -> new KnowledgeHit( + rs.getLong("fragment_id"), rs.getString("title"), rs.getString("category"), rs.getString("description"), @@ -1948,7 +2148,8 @@ public class AihrSopSeedService { private Optional hydrateQdrantHit(VectorMatch match) { List rows = jdbcTemplate.query(""" - select coalesce(a.name, i.name) as title, + select f.id as fragment_id, + coalesce(a.name, i.name) as title, i.name as category, coalesce(i.description, '') as description, f.doc_id, @@ -1962,6 +2163,7 @@ public class AihrSopSeedService { and f.idx = ? limit 1 """, (rs, rowNum) -> new KnowledgeHit( + rs.getLong("fragment_id"), rs.getString("title"), rs.getString("category"), rs.getString("description"), @@ -1999,10 +2201,14 @@ public class AihrSopSeedService { } private SearchResponse responseFromHits(String category, String queryText, List hits) { + return responseFromHits(category, queryText, hits, List.of()); + } + + private SearchResponse responseFromHits(String category, String queryText, List hits, List extraCautions) { KnowledgeHit first = hits.get(0); String responseCategory = isBlank(category) ? first.category() : category; List snippets = hits.stream() - .map(hit -> new SnippetResponse("第 " + hit.idx() + " 段:" + hit.title(), hit.content())) + .map(hit -> new SnippetResponse("第 " + hit.idx() + " 段:" + hit.title(), hit.content(), hit.fragmentId())) .toList(); Map docs = new LinkedHashMap<>(); for (KnowledgeHit hit : hits) { @@ -2010,10 +2216,10 @@ public class AihrSopSeedService { } SearchDigest digest = searchDigest(queryText, hits); if (digest == null) { - return noEvidenceResponse(queryText, responseCategory); + return noEvidenceResponse(queryText, responseCategory, extraCautions); } if (isNoEvidenceAnswer(digest.answer())) { - return noEvidenceResponse(queryText, responseCategory, digest.cautions()); + return noEvidenceResponse(queryText, responseCategory, appendCautions(digest.cautions(), extraCautions)); } return new SearchResponse( queryText, @@ -2025,7 +2231,7 @@ public class AihrSopSeedService { List.of("题 1:请按命中 SOP 复述处理步骤。", "题 2:哪些信息必须留痕并可追溯?"), records(), digest.keyPoints(), - digest.cautions(), + appendCautions(digest.cautions(), extraCautions), null ); } @@ -2198,6 +2404,20 @@ public class AihrSopSeedService { return List.copyOf(values); } + private static List appendCautions(List base, List extra) { + if ((base == null || base.isEmpty()) && (extra == null || extra.isEmpty())) { + return List.of(); + } + List values = new ArrayList<>(); + if (base != null) { + values.addAll(base); + } + if (extra != null) { + values.addAll(extra); + } + return List.copyOf(values); + } + private static List jsonCardSteps(JsonNode node) { if (!node.isArray()) { return List.of(); @@ -3033,7 +3253,10 @@ public class AihrSopSeedService { ) { } - private record KnowledgeHit(String title, String category, String description, String docId, String content, int idx, double score) { + private record KnowledgeHit(Long fragmentId, String title, String category, String description, String docId, String content, int idx, double score) { + } + + private record FeedbackFilterResult(List hits, List cautions) { } private record KnowledgeConfig(long knowledgeId, int blockSize, int overlap) { @@ -3147,7 +3370,7 @@ public class AihrSopSeedService { } private KnowledgeHit toHit() { - return new KnowledgeHit(hit.title(), hit.category(), hit.description(), hit.docId(), hit.content(), hit.idx(), score); + return new KnowledgeHit(hit.fragmentId(), hit.title(), hit.category(), hit.description(), hit.docId(), hit.content(), hit.idx(), score); } } } diff --git a/backend/script/sql/aihr_practice_mysql8.sql b/backend/script/sql/aihr_practice_mysql8.sql index f2678d36..82464770 100644 --- a/backend/script/sql/aihr_practice_mysql8.sql +++ b/backend/script/sql/aihr_practice_mysql8.sql @@ -219,6 +219,25 @@ CREATE TABLE IF NOT EXISTS `aihr_sop_answer_review` ( KEY `idx_aihr_sop_answer_review_status` (`tenant_id`, `status`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR SOP问答人工评审'; +CREATE TABLE IF NOT EXISTS `aihr_knowledge_answer_feedback` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `query_text` varchar(1000) NOT NULL COMMENT '问题原文', + `query_norm` varchar(500) NOT NULL COMMENT '规范化问题', + `fragment_id` bigint NOT NULL COMMENT '知识片段ID', + `verdict` varchar(10) NOT NULL COMMENT '反馈: up/down', + `category` varchar(100) DEFAULT NULL COMMENT '知识分类', + `position` varchar(80) DEFAULT NULL COMMENT '岗位', + `source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源', + `review_status` varchar(30) DEFAULT '待复核' COMMENT '复核状态', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_answer_feedback_pair` (`tenant_id`, `query_norm`, `fragment_id`), + KEY `idx_aihr_answer_feedback_verdict` (`tenant_id`, `query_norm`, `fragment_id`, `verdict`), + KEY `idx_aihr_answer_feedback_review` (`tenant_id`, `review_status`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 知识答案反馈'; + INSERT INTO `aihr_practice_scenario` (`tenant_id`, `scenario_code`, `scenario_name`, `position`, `scenario_type`, `project_type`, `difficulty`, `customer_persona`, `opening_text`, `explicit_need`, `hidden_need`, `irritants`, `delights`, `sop_refs`, diff --git a/mobile/src/App.vue b/mobile/src/App.vue index aa755951..eb8c6913 100644 --- a/mobile/src/App.vue +++ b/mobile/src/App.vue @@ -1029,6 +1029,26 @@
  1. {{ point }}
+
+ + +

⚠ {{ item }}

@@ -1449,6 +1469,7 @@ interface KnowledgeDoc { interface KnowledgeSnippet { title: string; text: string; + fragmentId?: number; } interface KnowledgeRecord { @@ -1473,6 +1494,8 @@ interface KnowledgeSearchResponse { cautions?: string[]; } +type AnswerFeedbackVerdict = 'up' | 'down'; + interface SummaryCardStep { label: string; desc: string; @@ -1764,6 +1787,8 @@ const sopQuery = ref(''); const sopResult = ref(null); const sopLoading = ref(false); const sopMessage = ref(''); +const sopFeedbackVerdict = ref(''); +const sopFeedbackSubmitting = ref(false); const sopRecording = ref(false); const sopAsrBusy = ref(false); const sopUseAudioFileFallback = ref(false); @@ -1805,6 +1830,14 @@ let sopAutoSearchAfterTranscribe = false; let mentorScrollRaf = 0; let mentorScrollListening = false; +const showToast = (message: string) => { + toastMessage.value = message; + if (toastTimer) window.clearTimeout(toastTimer); + toastTimer = window.setTimeout(() => { + toastMessage.value = ''; + }, 2200); +}; + const mentorImageSrc = `${import.meta.env.BASE_URL}mentor.png`; const roleAvatarSrc = computed(() => { if (roleKey.value === 'candidate') return `${import.meta.env.BASE_URL}candidate-avatar.png`; @@ -2123,6 +2156,8 @@ const openSopPanel = () => { sopQuery.value = ''; sopResult.value = null; sopMessage.value = ''; + sopFeedbackVerdict.value = ''; + sopFeedbackSubmitting.value = false; summaryCard.value = null; summaryCardMessage.value = ''; summaryCardOpen.value = false; @@ -2401,6 +2436,8 @@ const searchSopKnowledge = async () => { sopLoading.value = true; sopMessage.value = ''; sopResult.value = null; + sopFeedbackVerdict.value = ''; + sopFeedbackSubmitting.value = false; sopSnippetsOpen.value = false; expandedSnippets.value = new Set(); summaryCard.value = null; @@ -2431,6 +2468,42 @@ const searchSopKnowledge = async () => { } }; +const submitSopFeedback = async (verdict: AnswerFeedbackVerdict) => { + if (sopFeedbackSubmitting.value || sopFeedbackVerdict.value || !sopResult.value) return; + const fragmentIds = Array.from(new Set( + (sopResult.value.snippets || []) + .map((item) => item.fragmentId) + .filter((id): id is number => typeof id === 'number' && id > 0) + )); + if (!fragmentIds.length) { + showToast('暂无可反馈依据'); + return; + } + sopFeedbackSubmitting.value = true; + try { + await readApi<{ saved: number }>( + await fetch('/dev-api/api/knowledge/answer-feedback', { + method: 'POST', + headers: apiHeaders(), + body: JSON.stringify({ + queryText: sopResult.value.queryText || sopQuery.value, + fragmentIds, + verdict, + category: sopResult.value.category || 'sop', + position: '生活顾问', + source: 'mobile_sop' + }) + }) + ); + sopFeedbackVerdict.value = verdict; + showToast(verdict === 'down' ? '已记录,该答案将进入复核' : '感谢反馈'); + } catch (error) { + showToast(error instanceof Error ? error.message : '反馈提交失败'); + } finally { + sopFeedbackSubmitting.value = false; + } +}; + const askMentorChip = (query: string) => { openSopPanel(); sopQuery.value = query; @@ -7170,6 +7243,33 @@ p { line-height: 1.5; } +.answer-feedback-actions { + margin-top: 10px; + display: flex; + gap: 8px; +} + +.answer-feedback-actions button { + width: 40px; + height: 40px; + border: 1px solid rgba(46, 107, 230, 0.18); + border-radius: 12px; + background: #ffffff; + font-size: 18px; + line-height: 1; + transition: var(--transition-spring-fast); +} + +.answer-feedback-actions button.active { + border-color: rgba(46, 107, 230, 0.55); + background: rgba(46, 107, 230, 0.12); +} + +.answer-feedback-actions button:disabled { + opacity: 0.68; + transform: none; +} + .digest-cautions { margin-top: 10px; padding: 8px 10px;