feat(aihr): add SOP answer thumbs up/down feedback loop

- New aihr_knowledge_answer_feedback table; unique (tenant, query_norm,
  fragment_id) pair keeps submissions idempotent
- POST /api/knowledge/answer-feedback (fragmentIds array), GET pending-review
  list, POST /{id}/review — all under existing auth scope
- Down-voted (question, fragment) pairs are filtered from dbSearch fusion
  results; if filtering empties the hit list the original results are kept
  with an explanatory caution (never degrade to no-answer)
- Feedback table missing/unreachable → filter silently skipped, search
  behavior unchanged (degradation chain preserved)
- SnippetResponse gains optional trailing fragmentId (db hits only; seed
  fallback stays null); mobile sopPanel adds 👍/👎 with locked state after
  submit, up votes auto-marked reviewed to keep the review queue clean
This commit is contained in:
2026-07-08 15:09:27 +08:00
parent 8d78bc69d1
commit 15cd789da6
5 changed files with 389 additions and 11 deletions
@@ -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<AnswerFeedbackResponse> answerFeedback(@RequestBody AnswerFeedbackRequest request) {
return R.ok(sopSeedService.answerFeedback(request));
}
@GetMapping("/answer-feedback")
public R<List<AnswerFeedbackItemResponse>> 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<AnswerFeedbackReviewResponse> reviewAnswerFeedback(@PathVariable Long id) {
AnswerFeedbackReviewResponse response = sopSeedService.reviewAnswerFeedback(id);
return response == null ? R.fail("答案反馈记录不存在") : R.ok(response);
}
@PostMapping("/reviews/{id}")
public R<SopReviewResponse> review(@PathVariable Long id, @RequestBody(required = false) SopReviewRequest request) {
SopReviewResponse response = sopSeedService.reviewSearch(id, request);
@@ -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<Long> 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<CardStep> steps, List<CardObjection> objections, List<String> scripts, List<String> 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) {
@@ -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<Long> 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<AnswerFeedbackItemResponse> 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<Long> feedbackFragmentIds(AnswerFeedbackRequest request) {
LinkedHashSet<Long> 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<KnowledgeHit> dbHits(String category, String queryText, Integer limit) {
int limitValue = limitValue(limit);
List<KnowledgeHit> 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<KnowledgeHit> hits) {
Set<Long> rejected = downFeedbackFragmentIds(queryText);
if (rejected.isEmpty()) {
return new FeedbackFilterResult(hits, List.of());
}
List<KnowledgeHit> 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<Long> 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<KnowledgeHit> vectorHits(String category, String queryText, int limit) {
for (EmbeddingRuntime runtime : embeddingRuntimes()) {
try {
@@ -1874,7 +2070,8 @@ public class AihrSopSeedService {
private List<KnowledgeHit> 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<KnowledgeHit> hydrateQdrantHit(VectorMatch match) {
List<KnowledgeHit> 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<KnowledgeHit> hits) {
return responseFromHits(category, queryText, hits, List.of());
}
private SearchResponse responseFromHits(String category, String queryText, List<KnowledgeHit> hits, List<String> extraCautions) {
KnowledgeHit first = hits.get(0);
String responseCategory = isBlank(category) ? first.category() : category;
List<SnippetResponse> 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<String, DocResponse> 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<String> appendCautions(List<String> base, List<String> extra) {
if ((base == null || base.isEmpty()) && (extra == null || extra.isEmpty())) {
return List.of();
}
List<String> values = new ArrayList<>();
if (base != null) {
values.addAll(base);
}
if (extra != null) {
values.addAll(extra);
}
return List.copyOf(values);
}
private static List<CardStep> 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<KnowledgeHit> hits, List<String> 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);
}
}
}
@@ -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`,
+100
View File
@@ -1029,6 +1029,26 @@
<ol v-if="sopResult.keyPoints?.length" class="digest-points">
<li v-for="point in sopResult.keyPoints" :key="point">{{ point }}</li>
</ol>
<div class="answer-feedback-actions">
<button
type="button"
aria-label="这条答案有帮助"
:class="{ active: sopFeedbackVerdict === 'up' }"
:disabled="sopFeedbackSubmitting || !!sopFeedbackVerdict"
@click="submitSopFeedback('up')"
>
👍
</button>
<button
type="button"
aria-label="这条答案不准确"
:class="{ active: sopFeedbackVerdict === 'down' }"
:disabled="sopFeedbackSubmitting || !!sopFeedbackVerdict"
@click="submitSopFeedback('down')"
>
👎
</button>
</div>
<div v-if="sopResult.cautions?.length" class="digest-cautions">
<p v-for="item in sopResult.cautions" :key="item">⚠ {{ item }}</p>
</div>
@@ -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<KnowledgeSearchResponse | null>(null);
const sopLoading = ref(false);
const sopMessage = ref('');
const sopFeedbackVerdict = ref<AnswerFeedbackVerdict | ''>('');
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;