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:
+21
@@ -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);
|
||||
|
||||
+16
-1
@@ -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) {
|
||||
|
||||
+233
-10
@@ -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`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user