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;
|
package org.dromara.aihr.controller;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
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.LocalImportRequest;
|
||||||
import org.dromara.aihr.domain.AihrSopDto.LocalImportResponse;
|
import org.dromara.aihr.domain.AihrSopDto.LocalImportResponse;
|
||||||
import org.dromara.aihr.domain.AihrSopDto.LocalImportTaskResponse;
|
import org.dromara.aihr.domain.AihrSopDto.LocalImportTaskResponse;
|
||||||
@@ -65,6 +69,23 @@ public class AihrSopController {
|
|||||||
return R.ok(sopSeedService.search(request));
|
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}")
|
@PostMapping("/reviews/{id}")
|
||||||
public R<SopReviewResponse> review(@PathVariable Long id, @RequestBody(required = false) SopReviewRequest request) {
|
public R<SopReviewResponse> review(@PathVariable Long id, @RequestBody(required = false) SopReviewRequest request) {
|
||||||
SopReviewResponse response = sopSeedService.reviewSearch(id, 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 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) {
|
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 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) {
|
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 com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.dromara.aihr.domain.AihrSopDto.CategoryStatResponse;
|
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.CardObjection;
|
||||||
import org.dromara.aihr.domain.AihrSopDto.CardStep;
|
import org.dromara.aihr.domain.AihrSopDto.CardStep;
|
||||||
import org.dromara.aihr.domain.AihrSopDto.DocResponse;
|
import org.dromara.aihr.domain.AihrSopDto.DocResponse;
|
||||||
@@ -70,9 +74,11 @@ import java.util.HexFormat;
|
|||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.ScheduledExecutorService;
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
@@ -114,6 +120,7 @@ public class AihrSopSeedService {
|
|||||||
private final AihrVideoService videoService;
|
private final AihrVideoService videoService;
|
||||||
private volatile boolean knowledgeGapTableReady;
|
private volatile boolean knowledgeGapTableReady;
|
||||||
private volatile boolean sopReviewTableReady;
|
private volatile boolean sopReviewTableReady;
|
||||||
|
private volatile boolean answerFeedbackTableReady;
|
||||||
|
|
||||||
public AihrSopSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, ISysOssService ossService,
|
public AihrSopSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, ISysOssService ossService,
|
||||||
@Value("${aihr.import.root:}") String importRootConfig,
|
@Value("${aihr.import.root:}") String importRootConfig,
|
||||||
@@ -168,6 +175,97 @@ public class AihrSopSeedService {
|
|||||||
return updated == 0 ? null : sopReview(id);
|
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) {
|
public SummaryCardResponse summaryCard(SummaryCardRequest request) {
|
||||||
String queryText = request == null ? "" : Optional.ofNullable(request.queryText()).orElse("").trim();
|
String queryText = request == null ? "" : Optional.ofNullable(request.queryText()).orElse("").trim();
|
||||||
if (isBlank(queryText)) {
|
if (isBlank(queryText)) {
|
||||||
@@ -1659,7 +1757,8 @@ public class AihrSopSeedService {
|
|||||||
if (hits.isEmpty()) {
|
if (hits.isEmpty()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return responseFromHits(category, queryText, hits);
|
FeedbackFilterResult filtered = filterDownFeedback(queryText, hits);
|
||||||
|
return responseFromHits(category, queryText, filtered.hits(), filtered.cautions());
|
||||||
} catch (DataAccessException e) {
|
} catch (DataAccessException e) {
|
||||||
return null;
|
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() {
|
private void ensureKnowledgeGapTable() {
|
||||||
if (knowledgeGapTableReady) {
|
if (knowledgeGapTableReady) {
|
||||||
return;
|
return;
|
||||||
@@ -1843,6 +1974,36 @@ public class AihrSopSeedService {
|
|||||||
return source.isBlank() ? "knowledge_search" : source;
|
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) {
|
private List<KnowledgeHit> dbHits(String category, String queryText, Integer limit) {
|
||||||
int limitValue = limitValue(limit);
|
int limitValue = limitValue(limit);
|
||||||
List<KnowledgeHit> keywordHits = likeHits(category, queryText, limitValue);
|
List<KnowledgeHit> keywordHits = likeHits(category, queryText, limitValue);
|
||||||
@@ -1850,6 +2011,41 @@ public class AihrSopSeedService {
|
|||||||
return rerankHits(queryText, rrf(textHits, vectorHits(category, queryText, limitValue), limitValue));
|
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) {
|
private List<KnowledgeHit> vectorHits(String category, String queryText, int limit) {
|
||||||
for (EmbeddingRuntime runtime : embeddingRuntimes()) {
|
for (EmbeddingRuntime runtime : embeddingRuntimes()) {
|
||||||
try {
|
try {
|
||||||
@@ -1874,7 +2070,8 @@ public class AihrSopSeedService {
|
|||||||
|
|
||||||
private List<KnowledgeHit> fulltextHits(String category, String queryText, int limit) {
|
private List<KnowledgeHit> fulltextHits(String category, String queryText, int limit) {
|
||||||
return jdbcTemplate.query("""
|
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,
|
i.name as category,
|
||||||
coalesce(i.description, '') as description,
|
coalesce(i.description, '') as description,
|
||||||
f.doc_id,
|
f.doc_id,
|
||||||
@@ -1890,6 +2087,7 @@ public class AihrSopSeedService {
|
|||||||
order by score desc, f.idx asc
|
order by score desc, f.idx asc
|
||||||
limit ?
|
limit ?
|
||||||
""", (rs, rowNum) -> new KnowledgeHit(
|
""", (rs, rowNum) -> new KnowledgeHit(
|
||||||
|
rs.getLong("fragment_id"),
|
||||||
rs.getString("title"),
|
rs.getString("title"),
|
||||||
rs.getString("category"),
|
rs.getString("category"),
|
||||||
rs.getString("description"),
|
rs.getString("description"),
|
||||||
@@ -1912,7 +2110,8 @@ public class AihrSopSeedService {
|
|||||||
whereParts.add("f.content like ? escape '\\\\'");
|
whereParts.add("f.content like ? escape '\\\\'");
|
||||||
}
|
}
|
||||||
String sql = """
|
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,
|
i.name as category,
|
||||||
coalesce(i.description, '') as description,
|
coalesce(i.description, '') as description,
|
||||||
f.doc_id,
|
f.doc_id,
|
||||||
@@ -1936,6 +2135,7 @@ public class AihrSopSeedService {
|
|||||||
terms.stream().map(AihrSopSeedService::likePattern).forEach(args::add);
|
terms.stream().map(AihrSopSeedService::likePattern).forEach(args::add);
|
||||||
args.add(limit);
|
args.add(limit);
|
||||||
return jdbcTemplate.query(sql, (rs, rowNum) -> new KnowledgeHit(
|
return jdbcTemplate.query(sql, (rs, rowNum) -> new KnowledgeHit(
|
||||||
|
rs.getLong("fragment_id"),
|
||||||
rs.getString("title"),
|
rs.getString("title"),
|
||||||
rs.getString("category"),
|
rs.getString("category"),
|
||||||
rs.getString("description"),
|
rs.getString("description"),
|
||||||
@@ -1948,7 +2148,8 @@ public class AihrSopSeedService {
|
|||||||
|
|
||||||
private Optional<KnowledgeHit> hydrateQdrantHit(VectorMatch match) {
|
private Optional<KnowledgeHit> hydrateQdrantHit(VectorMatch match) {
|
||||||
List<KnowledgeHit> rows = jdbcTemplate.query("""
|
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,
|
i.name as category,
|
||||||
coalesce(i.description, '') as description,
|
coalesce(i.description, '') as description,
|
||||||
f.doc_id,
|
f.doc_id,
|
||||||
@@ -1962,6 +2163,7 @@ public class AihrSopSeedService {
|
|||||||
and f.idx = ?
|
and f.idx = ?
|
||||||
limit 1
|
limit 1
|
||||||
""", (rs, rowNum) -> new KnowledgeHit(
|
""", (rs, rowNum) -> new KnowledgeHit(
|
||||||
|
rs.getLong("fragment_id"),
|
||||||
rs.getString("title"),
|
rs.getString("title"),
|
||||||
rs.getString("category"),
|
rs.getString("category"),
|
||||||
rs.getString("description"),
|
rs.getString("description"),
|
||||||
@@ -1999,10 +2201,14 @@ public class AihrSopSeedService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private SearchResponse responseFromHits(String category, String queryText, List<KnowledgeHit> hits) {
|
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);
|
KnowledgeHit first = hits.get(0);
|
||||||
String responseCategory = isBlank(category) ? first.category() : category;
|
String responseCategory = isBlank(category) ? first.category() : category;
|
||||||
List<SnippetResponse> snippets = hits.stream()
|
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();
|
.toList();
|
||||||
Map<String, DocResponse> docs = new LinkedHashMap<>();
|
Map<String, DocResponse> docs = new LinkedHashMap<>();
|
||||||
for (KnowledgeHit hit : hits) {
|
for (KnowledgeHit hit : hits) {
|
||||||
@@ -2010,10 +2216,10 @@ public class AihrSopSeedService {
|
|||||||
}
|
}
|
||||||
SearchDigest digest = searchDigest(queryText, hits);
|
SearchDigest digest = searchDigest(queryText, hits);
|
||||||
if (digest == null) {
|
if (digest == null) {
|
||||||
return noEvidenceResponse(queryText, responseCategory);
|
return noEvidenceResponse(queryText, responseCategory, extraCautions);
|
||||||
}
|
}
|
||||||
if (isNoEvidenceAnswer(digest.answer())) {
|
if (isNoEvidenceAnswer(digest.answer())) {
|
||||||
return noEvidenceResponse(queryText, responseCategory, digest.cautions());
|
return noEvidenceResponse(queryText, responseCategory, appendCautions(digest.cautions(), extraCautions));
|
||||||
}
|
}
|
||||||
return new SearchResponse(
|
return new SearchResponse(
|
||||||
queryText,
|
queryText,
|
||||||
@@ -2025,7 +2231,7 @@ public class AihrSopSeedService {
|
|||||||
List.of("题 1:请按命中 SOP 复述处理步骤。", "题 2:哪些信息必须留痕并可追溯?"),
|
List.of("题 1:请按命中 SOP 复述处理步骤。", "题 2:哪些信息必须留痕并可追溯?"),
|
||||||
records(),
|
records(),
|
||||||
digest.keyPoints(),
|
digest.keyPoints(),
|
||||||
digest.cautions(),
|
appendCautions(digest.cautions(), extraCautions),
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2198,6 +2404,20 @@ public class AihrSopSeedService {
|
|||||||
return List.copyOf(values);
|
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) {
|
private static List<CardStep> jsonCardSteps(JsonNode node) {
|
||||||
if (!node.isArray()) {
|
if (!node.isArray()) {
|
||||||
return List.of();
|
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) {
|
private record KnowledgeConfig(long knowledgeId, int blockSize, int overlap) {
|
||||||
@@ -3147,7 +3370,7 @@ public class AihrSopSeedService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private KnowledgeHit toHit() {
|
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`)
|
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问答人工评审';
|
) 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`
|
INSERT INTO `aihr_practice_scenario`
|
||||||
(`tenant_id`, `scenario_code`, `scenario_name`, `position`, `scenario_type`, `project_type`, `difficulty`,
|
(`tenant_id`, `scenario_code`, `scenario_name`, `position`, `scenario_type`, `project_type`, `difficulty`,
|
||||||
`customer_persona`, `opening_text`, `explicit_need`, `hidden_need`, `irritants`, `delights`, `sop_refs`,
|
`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">
|
<ol v-if="sopResult.keyPoints?.length" class="digest-points">
|
||||||
<li v-for="point in sopResult.keyPoints" :key="point">{{ point }}</li>
|
<li v-for="point in sopResult.keyPoints" :key="point">{{ point }}</li>
|
||||||
</ol>
|
</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">
|
<div v-if="sopResult.cautions?.length" class="digest-cautions">
|
||||||
<p v-for="item in sopResult.cautions" :key="item">⚠ {{ item }}</p>
|
<p v-for="item in sopResult.cautions" :key="item">⚠ {{ item }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1449,6 +1469,7 @@ interface KnowledgeDoc {
|
|||||||
interface KnowledgeSnippet {
|
interface KnowledgeSnippet {
|
||||||
title: string;
|
title: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
fragmentId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface KnowledgeRecord {
|
interface KnowledgeRecord {
|
||||||
@@ -1473,6 +1494,8 @@ interface KnowledgeSearchResponse {
|
|||||||
cautions?: string[];
|
cautions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AnswerFeedbackVerdict = 'up' | 'down';
|
||||||
|
|
||||||
interface SummaryCardStep {
|
interface SummaryCardStep {
|
||||||
label: string;
|
label: string;
|
||||||
desc: string;
|
desc: string;
|
||||||
@@ -1764,6 +1787,8 @@ const sopQuery = ref('');
|
|||||||
const sopResult = ref<KnowledgeSearchResponse | null>(null);
|
const sopResult = ref<KnowledgeSearchResponse | null>(null);
|
||||||
const sopLoading = ref(false);
|
const sopLoading = ref(false);
|
||||||
const sopMessage = ref('');
|
const sopMessage = ref('');
|
||||||
|
const sopFeedbackVerdict = ref<AnswerFeedbackVerdict | ''>('');
|
||||||
|
const sopFeedbackSubmitting = ref(false);
|
||||||
const sopRecording = ref(false);
|
const sopRecording = ref(false);
|
||||||
const sopAsrBusy = ref(false);
|
const sopAsrBusy = ref(false);
|
||||||
const sopUseAudioFileFallback = ref(false);
|
const sopUseAudioFileFallback = ref(false);
|
||||||
@@ -1805,6 +1830,14 @@ let sopAutoSearchAfterTranscribe = false;
|
|||||||
let mentorScrollRaf = 0;
|
let mentorScrollRaf = 0;
|
||||||
let mentorScrollListening = false;
|
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 mentorImageSrc = `${import.meta.env.BASE_URL}mentor.png`;
|
||||||
const roleAvatarSrc = computed(() => {
|
const roleAvatarSrc = computed(() => {
|
||||||
if (roleKey.value === 'candidate') return `${import.meta.env.BASE_URL}candidate-avatar.png`;
|
if (roleKey.value === 'candidate') return `${import.meta.env.BASE_URL}candidate-avatar.png`;
|
||||||
@@ -2123,6 +2156,8 @@ const openSopPanel = () => {
|
|||||||
sopQuery.value = '';
|
sopQuery.value = '';
|
||||||
sopResult.value = null;
|
sopResult.value = null;
|
||||||
sopMessage.value = '';
|
sopMessage.value = '';
|
||||||
|
sopFeedbackVerdict.value = '';
|
||||||
|
sopFeedbackSubmitting.value = false;
|
||||||
summaryCard.value = null;
|
summaryCard.value = null;
|
||||||
summaryCardMessage.value = '';
|
summaryCardMessage.value = '';
|
||||||
summaryCardOpen.value = false;
|
summaryCardOpen.value = false;
|
||||||
@@ -2401,6 +2436,8 @@ const searchSopKnowledge = async () => {
|
|||||||
sopLoading.value = true;
|
sopLoading.value = true;
|
||||||
sopMessage.value = '';
|
sopMessage.value = '';
|
||||||
sopResult.value = null;
|
sopResult.value = null;
|
||||||
|
sopFeedbackVerdict.value = '';
|
||||||
|
sopFeedbackSubmitting.value = false;
|
||||||
sopSnippetsOpen.value = false;
|
sopSnippetsOpen.value = false;
|
||||||
expandedSnippets.value = new Set();
|
expandedSnippets.value = new Set();
|
||||||
summaryCard.value = null;
|
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) => {
|
const askMentorChip = (query: string) => {
|
||||||
openSopPanel();
|
openSopPanel();
|
||||||
sopQuery.value = query;
|
sopQuery.value = query;
|
||||||
@@ -7170,6 +7243,33 @@ p {
|
|||||||
line-height: 1.5;
|
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 {
|
.digest-cautions {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
|
|||||||
Reference in New Issue
Block a user