fix(knowledge): bind summary cards to cited evidence
This commit is contained in:
+1
-1
@@ -32,7 +32,7 @@ public final class AihrSopDto {
|
|||||||
public record AuthorizedKnowledgeHit(Long fragmentId, String title, String content) {
|
public record AuthorizedKnowledgeHit(Long fragmentId, String title, String content) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public record SummaryCardRequest(String queryText, String category) {
|
public record SummaryCardRequest(String queryText, String category, List<Long> fragmentIds) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public record SopReviewRequest(Boolean usable, String reviewer, String note) {
|
public record SopReviewRequest(Boolean usable, String reviewer, String note) {
|
||||||
|
|||||||
+63
-100
@@ -115,11 +115,12 @@ public class AihrSopSeedService {
|
|||||||
private static final Set<String> FEEDBACK_REASON_CODES = Set.of(
|
private static final Set<String> FEEDBACK_REASON_CODES = Set.of(
|
||||||
"no_answer", "not_specific", "wrong_reference", "outdated", "not_applicable"
|
"no_answer", "not_specific", "wrong_reference", "outdated", "not_applicable"
|
||||||
);
|
);
|
||||||
private static final String SOP_ANSWER_PROMPT_VERSION = "builtin-sop_answer-v1";
|
private static final String SOP_ANSWER_PROMPT_VERSION = "builtin-sop_answer-v2";
|
||||||
private static final String SOP_ANSWER_SYSTEM_PROMPT = "你是物业一线员工的SOP助手。只返回 JSON,不要 markdown。字段固定为 answer, keyPoints, cautions。"
|
private static final String SOP_ANSWER_SYSTEM_PROMPT = "你是物业一线员工的SOP助手。只返回 JSON,不要 markdown。字段固定为 answer, keyPoints, cautions。"
|
||||||
+ "answer 必须结论先行,60字内直接告诉员工现在怎么做;keyPoints 必须是3到5条可执行步骤,每条以「一、」「二、」「三、」「四、」「五、」开头,包含责任人、动作、时限或留痕要求;"
|
+ "answer 必须结论先行,60字内直接告诉员工片段能够支持的做法;keyPoints 必须是1到5条有依据的可执行要点,每条以「一、」「二、」「三、」「四、」「五、」开头;"
|
||||||
+ "cautions 是0到3条注意事项或红线。引用片段由系统后置展示,不要在 answer 中堆大段原文。硬约束:只能基于给定片段内容作答,片段中没有的信息不得编造;"
|
+ "cautions 是0到3条注意事项或红线。引用片段由系统后置展示,不要在 answer 中堆大段原文。硬约束:只能基于给定片段内容作答,片段中没有的信息不得编造;"
|
||||||
+ "若片段无法回答问题,answer 固定为「" + NO_CLEAR_SOP_EVIDENCE + "」且 keyPoints 为空数组。";
|
+ "只要片段包含与问题相关的操作、适用范围、用量或安全要求,就回答这些已支持内容,并明确缺少的完整流程;"
|
||||||
|
+ "只有全部片段与问题完全无关时,answer 才固定为「" + NO_CLEAR_SOP_EVIDENCE + "」且 keyPoints 为空数组。";
|
||||||
private static final String SOP_ANSWER_USER_TEMPLATE = """
|
private static final String SOP_ANSWER_USER_TEMPLATE = """
|
||||||
员工的问题:{{queryText}}
|
员工的问题:{{queryText}}
|
||||||
|
|
||||||
@@ -531,19 +532,10 @@ public class AihrSopSeedService {
|
|||||||
if (isBlank(queryText)) {
|
if (isBlank(queryText)) {
|
||||||
throw new ServiceException("请先输入问题");
|
throw new ServiceException("请先输入问题");
|
||||||
}
|
}
|
||||||
String category = request == null ? "sop" : firstNonBlank(request.category(), "sop");
|
List<KnowledgeHit> hits = summaryCardHits(requiredSummaryFragmentIds(request), allowedKnowledgeIds);
|
||||||
List<KnowledgeHit> hits;
|
|
||||||
try {
|
|
||||||
hits = dbHits(dbCategory(category), queryText, 5, allowedKnowledgeIds);
|
|
||||||
} catch (DataAccessException e) {
|
|
||||||
hits = List.of();
|
|
||||||
}
|
|
||||||
if (hits.isEmpty()) {
|
|
||||||
throw new ServiceException("知识库中未检索到相关内容");
|
|
||||||
}
|
|
||||||
Optional<ChatRuntime> runtime = chatRuntime();
|
Optional<ChatRuntime> runtime = chatRuntime();
|
||||||
if (runtime.isEmpty()) {
|
if (runtime.isEmpty()) {
|
||||||
return evidenceSummaryCard(queryText, hits);
|
throw new ServiceException("总结卡模型未配置或未启用,请联系管理员");
|
||||||
}
|
}
|
||||||
|
|
||||||
StringBuilder context = new StringBuilder();
|
StringBuilder context = new StringBuilder();
|
||||||
@@ -557,8 +549,8 @@ public class AihrSopSeedService {
|
|||||||
JsonNode root = objectMapper.readTree(jsonObjectText(content));
|
JsonNode root = objectMapper.readTree(jsonObjectText(content));
|
||||||
String title = truncate(root.path("title").asText("").strip(), 14);
|
String title = truncate(root.path("title").asText("").strip(), 14);
|
||||||
List<CardStep> steps = jsonCardSteps(root.path("steps"));
|
List<CardStep> steps = jsonCardSteps(root.path("steps"));
|
||||||
if (isBlank(title) || steps.isEmpty()) {
|
if (isBlank(title) || !hasRequiredSummarySteps(steps)) {
|
||||||
throw new IllegalStateException("summary card missing title or steps");
|
throw new IllegalStateException("summary card response does not match the required structure");
|
||||||
}
|
}
|
||||||
return new SummaryCardResponse(
|
return new SummaryCardResponse(
|
||||||
title,
|
title,
|
||||||
@@ -568,98 +560,69 @@ public class AihrSopSeedService {
|
|||||||
jsonStrings(root.path("reminders"), 5, 20)
|
jsonStrings(root.path("reminders"), 5, 20)
|
||||||
);
|
);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("summary card model unavailable, falling back to retrieved evidence: {}", e.getClass().getSimpleName());
|
log.warn("summary card model failed: {}", e.getClass().getSimpleName());
|
||||||
return evidenceSummaryCard(queryText, hits);
|
throw new ServiceException("总结卡生成暂不可用,请稍后重试");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
static boolean hasRequiredSummarySteps(List<CardStep> steps) {
|
||||||
* 模型未配置或暂时不可用时,直接把已命中的 SOP 原文整理成卡片。
|
return Optional.ofNullable(steps).orElse(List.of()).stream().map(CardStep::label).toList()
|
||||||
* 该降级路径只摘录检索证据,不生成话术或异议回应,避免把推测内容冒充制度要求。
|
.equals(List.of("问题", "建议", "标准"));
|
||||||
*/
|
|
||||||
static SummaryCardResponse evidenceSummaryCard(String queryText, List<KnowledgeHit> hits) {
|
|
||||||
List<CardStep> steps = new ArrayList<>();
|
|
||||||
Set<String> seenEvidence = new LinkedHashSet<>();
|
|
||||||
Set<String> sourceTitles = new LinkedHashSet<>();
|
|
||||||
List<KnowledgeHit> relevantHits = summaryEvidenceHits(queryText, hits);
|
|
||||||
if (relevantHits.isEmpty()) {
|
|
||||||
throw new ServiceException("检索片段与问题相关度不足,暂不能生成总结卡");
|
|
||||||
}
|
|
||||||
for (KnowledgeHit hit : relevantHits) {
|
|
||||||
if (!isBlank(hit.title())) {
|
|
||||||
sourceTitles.add(hit.title().strip());
|
|
||||||
}
|
|
||||||
String content = Optional.ofNullable(hit.content()).orElse("");
|
|
||||||
for (String candidate : content.split("[。!?!?;;\\r\\n]+")) {
|
|
||||||
String evidence = candidate
|
|
||||||
.replaceFirst("^[\\s\\-—–•·*\\d一二三四五六七八九十、.()()]+", "")
|
|
||||||
.replaceAll("\\s+", " ")
|
|
||||||
.strip();
|
|
||||||
if (evidence.length() < 4 || !seenEvidence.add(evidence)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
steps.add(new CardStep("原文" + (steps.size() + 1), truncate(evidence, 40)));
|
|
||||||
if (steps.size() >= 5) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (steps.size() >= 5) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (steps.isEmpty()) {
|
|
||||||
throw new ServiceException("检索片段中没有可生成总结卡的文字内容");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String normalizedQuery = Optional.ofNullable(queryText).orElse("")
|
static List<Long> requiredSummaryFragmentIds(SummaryCardRequest request) {
|
||||||
.replaceAll("[\\s??!!。]+", "")
|
LinkedHashSet<Long> ids = new LinkedHashSet<>();
|
||||||
.strip();
|
for (Long id : Optional.ofNullable(request == null ? null : request.fragmentIds()).orElse(List.of())) {
|
||||||
String title = isBlank(normalizedQuery) ? "SOP原文总结卡" : truncate(normalizedQuery, 14);
|
if (id != null && id > 0) {
|
||||||
List<String> reminders = sourceTitles.stream()
|
ids.add(id);
|
||||||
.limit(3)
|
}
|
||||||
.map(source -> "来源:" + truncate(source, 15))
|
}
|
||||||
.toList();
|
if (ids.isEmpty()) {
|
||||||
return new SummaryCardResponse(title, List.copyOf(steps), List.of(), List.of(), reminders);
|
throw new ServiceException("总结卡需要当前回答的引用片段,请重新查询");
|
||||||
|
}
|
||||||
|
if (ids.size() > 5) {
|
||||||
|
throw new ServiceException("总结卡最多使用 5 条引用片段");
|
||||||
|
}
|
||||||
|
return List.copyOf(ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<KnowledgeHit> summaryEvidenceHits(String queryText, List<KnowledgeHit> hits) {
|
private List<KnowledgeHit> summaryCardHits(List<Long> fragmentIds, Set<Long> allowedKnowledgeIds) {
|
||||||
List<KnowledgeHit> candidates = Optional.ofNullable(hits).orElse(List.of()).stream().limit(5).toList();
|
String placeholders = String.join(",", java.util.Collections.nCopies(fragmentIds.size(), "?"));
|
||||||
if (candidates.isEmpty()) {
|
String sql = """
|
||||||
return List.of();
|
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,
|
||||||
|
f.content,
|
||||||
|
f.idx
|
||||||
|
from aihr_knowledge_fragment f
|
||||||
|
join aihr_knowledge_info i on i.id = f.knowledge_id and i.tenant_id = f.tenant_id
|
||||||
|
left join aihr_knowledge_attach a on a.knowledge_id = f.knowledge_id and a.doc_id = f.doc_id and a.tenant_id = f.tenant_id
|
||||||
|
where f.tenant_id = ?
|
||||||
|
and f.id in (%s)
|
||||||
|
%s
|
||||||
|
""".formatted(placeholders, knowledgeScopeSql("f", allowedKnowledgeIds));
|
||||||
|
List<Object> args = new ArrayList<>();
|
||||||
|
args.add(tenantId());
|
||||||
|
args.addAll(fragmentIds);
|
||||||
|
addKnowledgeScopeArgs(args, allowedKnowledgeIds);
|
||||||
|
List<KnowledgeHit> rows = jdbcTemplate.query(sql, (rs, rowNum) -> new KnowledgeHit(
|
||||||
|
rs.getLong("fragment_id"),
|
||||||
|
rs.getString("title"),
|
||||||
|
rs.getString("category"),
|
||||||
|
rs.getString("description"),
|
||||||
|
rs.getString("doc_id"),
|
||||||
|
rs.getString("content"),
|
||||||
|
rs.getInt("idx"),
|
||||||
|
1.0
|
||||||
|
), args.toArray());
|
||||||
|
Map<Long, KnowledgeHit> byId = rows.stream()
|
||||||
|
.collect(java.util.stream.Collectors.toMap(KnowledgeHit::fragmentId, hit -> hit));
|
||||||
|
if (byId.size() != fragmentIds.size()) {
|
||||||
|
throw new ServiceException("引用片段已失效或无权访问,请重新查询后再生成总结卡", 403);
|
||||||
}
|
}
|
||||||
Set<String> queryBigrams = evidenceBigrams(queryText);
|
return fragmentIds.stream().map(byId::get).toList();
|
||||||
if (queryBigrams.isEmpty()) {
|
|
||||||
return List.of(candidates.get(0));
|
|
||||||
}
|
|
||||||
Map<KnowledgeHit, Integer> scores = new LinkedHashMap<>();
|
|
||||||
int bestScore = 0;
|
|
||||||
for (KnowledgeHit hit : candidates) {
|
|
||||||
String evidence = evidenceText(firstNonBlank(hit.title(), "") + " " + firstNonBlank(hit.content(), ""));
|
|
||||||
int score = (int) queryBigrams.stream().filter(evidence::contains).count();
|
|
||||||
scores.put(hit, score);
|
|
||||||
bestScore = Math.max(bestScore, score);
|
|
||||||
}
|
|
||||||
if (bestScore == 0) {
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
int threshold = bestScore >= 3 ? Math.max(2, (bestScore + 1) / 2) : bestScore;
|
|
||||||
return scores.entrySet().stream()
|
|
||||||
.filter(entry -> entry.getValue() >= threshold)
|
|
||||||
.limit(MAX_EVIDENCE_SNIPPETS)
|
|
||||||
.map(Map.Entry::getKey)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Set<String> evidenceBigrams(String value) {
|
|
||||||
String normalized = evidenceText(value);
|
|
||||||
if (normalized.length() < 2) {
|
|
||||||
return Set.of();
|
|
||||||
}
|
|
||||||
Set<String> bigrams = new LinkedHashSet<>();
|
|
||||||
for (int index = 0; index < normalized.length() - 1; index++) {
|
|
||||||
bigrams.add(normalized.substring(index, index + 2));
|
|
||||||
}
|
|
||||||
return Set.copyOf(bigrams);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
|||||||
+47
-42
@@ -519,56 +519,61 @@ public class AihrSopSeedServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Tag("dev")
|
@Tag("dev")
|
||||||
public void summaryCardFallsBackToRetrievedEvidenceWithoutInventingScripts() {
|
public void summaryCardRequiresBoundEvidenceFragments() {
|
||||||
List<AihrSopSeedService.KnowledgeHit> hits = List.of(
|
ServiceException error = assertThrows(ServiceException.class,
|
||||||
new AihrSopSeedService.KnowledgeHit(
|
() -> AihrSopSeedService.requiredSummaryFragmentIds(
|
||||||
1L,
|
new AihrSopDto.SummaryCardRequest("厕所打扫的 SOP 是什么?", "sop", List.of())
|
||||||
"投诉处理 SOP.pdf",
|
));
|
||||||
"sop",
|
|
||||||
"",
|
|
||||||
"doc-1",
|
|
||||||
"先记录业主诉求、房号和联系方式。30分钟内反馈首个处理节点,并保留工单记录。",
|
|
||||||
1,
|
|
||||||
1.0
|
|
||||||
),
|
|
||||||
new AihrSopSeedService.KnowledgeHit(
|
|
||||||
2L,
|
|
||||||
"报修跟进 SOP.pdf",
|
|
||||||
"sop",
|
|
||||||
"",
|
|
||||||
"doc-2",
|
|
||||||
"派单后确认责任人和完成时限;复查通过后再关闭工单。",
|
|
||||||
1,
|
|
||||||
0.9
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
AihrSopDto.SummaryCardResponse response = AihrSopSeedService.evidenceSummaryCard(
|
assertTrue(error.getMessage().contains("引用片段"));
|
||||||
"业主投诉漏水,第一步怎么处理?",
|
|
||||||
hits
|
|
||||||
);
|
|
||||||
|
|
||||||
assertFalse(response.title().isBlank());
|
|
||||||
assertTrue(response.steps().size() >= 2);
|
|
||||||
assertTrue(response.steps().stream().anyMatch(step -> step.desc().contains("记录业主诉求")));
|
|
||||||
assertFalse(response.steps().stream().anyMatch(step -> step.desc().contains("确认责任人")));
|
|
||||||
assertTrue(response.scripts().isEmpty());
|
|
||||||
assertTrue(response.objections().isEmpty());
|
|
||||||
assertEquals(List.of("来源:投诉处理 SOP.pdf"), response.reminders());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Tag("dev")
|
@Tag("dev")
|
||||||
public void summaryCardRejectsUnrelatedEvidenceInsteadOfPackagingFirstHit() {
|
public void summaryCardDeduplicatesAndCapsBoundEvidenceFragments() {
|
||||||
List<AihrSopSeedService.KnowledgeHit> hits = List.of(
|
assertEquals(List.of(10L, 20L), AihrSopSeedService.requiredSummaryFragmentIds(
|
||||||
new AihrSopSeedService.KnowledgeHit(1L, "信息安全制度.pdf", "sop", "", "doc-1",
|
new AihrSopDto.SummaryCardRequest(
|
||||||
"员工电脑必须启用屏幕锁定并定期更新密码。", 1, 1.0)
|
"厕所打扫的 SOP 是什么?",
|
||||||
);
|
"sop",
|
||||||
|
Arrays.asList(10L, null, 10L, 20L)
|
||||||
|
)
|
||||||
|
));
|
||||||
|
|
||||||
ServiceException error = assertThrows(ServiceException.class,
|
ServiceException error = assertThrows(ServiceException.class,
|
||||||
() -> AihrSopSeedService.evidenceSummaryCard("停车费催缴怎么沟通?", hits));
|
() -> AihrSopSeedService.requiredSummaryFragmentIds(
|
||||||
|
new AihrSopDto.SummaryCardRequest(
|
||||||
|
"厕所打扫的 SOP 是什么?",
|
||||||
|
"sop",
|
||||||
|
List.of(1L, 2L, 3L, 4L, 5L, 6L)
|
||||||
|
)
|
||||||
|
));
|
||||||
|
|
||||||
assertTrue(error.getMessage().contains("相关度不足"));
|
assertTrue(error.getMessage().contains("最多使用 5 条"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Tag("dev")
|
||||||
|
public void summaryCardRequestCarriesCurrentAnswerFragmentIds() {
|
||||||
|
AihrSopDto.SummaryCardRequest request = new AihrSopDto.SummaryCardRequest(
|
||||||
|
"厕所打扫的 SOP 是什么?",
|
||||||
|
"sop",
|
||||||
|
List.of(101293L, 105626L)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(List.of(101293L, 105626L), request.fragmentIds());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Tag("dev")
|
||||||
|
public void summaryCardRequiresProblemSuggestionAndStandardInOrder() {
|
||||||
|
assertTrue(AihrSopSeedService.hasRequiredSummarySteps(List.of(
|
||||||
|
new AihrSopDto.CardStep("问题", "待处理问题"),
|
||||||
|
new AihrSopDto.CardStep("建议", "下一步动作"),
|
||||||
|
new AihrSopDto.CardStep("标准", "SOP依据")
|
||||||
|
)));
|
||||||
|
assertFalse(AihrSopSeedService.hasRequiredSummarySteps(List.of(
|
||||||
|
new AihrSopDto.CardStep("原文1", "无结构片段")
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- DeepSeek 当前接口仅接受 deepseek-v4-flash / deepseek-v4-pro。
|
||||||
|
-- 默认切换到已验证可返回结构化 JSON 的 flash;若目标配置已存在,则保留目标配置并停用旧名称。
|
||||||
|
|
||||||
|
UPDATE aihr_model_config legacy
|
||||||
|
LEFT JOIN aihr_model_config current
|
||||||
|
ON current.tenant_id = legacy.tenant_id
|
||||||
|
AND current.category = legacy.category
|
||||||
|
AND current.model_name = 'deepseek-v4-flash'
|
||||||
|
SET legacy.model_name = 'deepseek-v4-flash',
|
||||||
|
legacy.update_time = NOW()
|
||||||
|
WHERE legacy.category = 'chat'
|
||||||
|
AND legacy.provider_code = 'deepseek'
|
||||||
|
AND legacy.model_name = 'deepseek-chat'
|
||||||
|
AND current.id IS NULL;
|
||||||
|
|
||||||
|
UPDATE aihr_model_config legacy
|
||||||
|
JOIN aihr_model_config current
|
||||||
|
ON current.tenant_id = legacy.tenant_id
|
||||||
|
AND current.category = legacy.category
|
||||||
|
AND current.model_name = 'deepseek-v4-flash'
|
||||||
|
SET current.enabled = GREATEST(current.enabled, legacy.enabled),
|
||||||
|
current.model_show = IF(legacy.model_show = 'Y', 'Y', current.model_show),
|
||||||
|
current.update_time = NOW(),
|
||||||
|
legacy.enabled = 0,
|
||||||
|
legacy.model_show = 'N',
|
||||||
|
legacy.update_time = NOW()
|
||||||
|
WHERE legacy.category = 'chat'
|
||||||
|
AND legacy.provider_code = 'deepseek'
|
||||||
|
AND legacy.model_name = 'deepseek-chat'
|
||||||
|
AND legacy.id <> current.id;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- 只要命中片段含有可执行依据,就回答已支持的部分;仅在片段完全无关时返回无依据。
|
||||||
|
-- 新增 v2,不覆盖租户自定义版本;可重复执行。
|
||||||
|
|
||||||
|
INSERT INTO `aihr_prompt_template`
|
||||||
|
(`tenant_id`, `code`, `name`, `category`, `output_format`, `system_prompt`, `template`, `variables`, `version`, `enabled`, `description`, `create_time`, `update_time`)
|
||||||
|
VALUES
|
||||||
|
('000000', 'sop_answer', 'SOP问答直给', 'knowledge', 'json_answer',
|
||||||
|
'你是物业一线员工的SOP助手。只返回 JSON,不要 markdown。字段固定为 answer, keyPoints, cautions。answer 必须结论先行,60字内直接告诉员工片段能够支持的做法;keyPoints 必须是1到5条有依据的可执行要点,每条以「一、」「二、」「三、」「四、」「五、」开头;cautions 是0到3条注意事项或红线。引用片段由系统后置展示,不要在 answer 中堆大段原文。硬约束:只能基于给定片段内容作答,片段中没有的信息不得编造;只要片段包含与问题相关的操作、适用范围、用量或安全要求,就回答这些已支持内容,并明确缺少的完整流程;只有全部片段与问题完全无关时,answer 才固定为「未在已入库 SOP 中找到明确依据」且 keyPoints 为空数组。',
|
||||||
|
'员工的问题:{{queryText}}\n\n检索命中的 SOP 片段:\n{{context}}',
|
||||||
|
'queryText,context', 'v2', 1, 'SOP问答支持部分证据并明确缺口', NOW(), NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
`name` = VALUES(`name`),
|
||||||
|
`category` = VALUES(`category`),
|
||||||
|
`system_prompt` = VALUES(`system_prompt`),
|
||||||
|
`template` = VALUES(`template`),
|
||||||
|
`variables` = VALUES(`variables`),
|
||||||
|
`enabled` = VALUES(`enabled`),
|
||||||
|
`description` = VALUES(`description`),
|
||||||
|
`update_time` = NOW();
|
||||||
@@ -123,7 +123,7 @@ SOP 文档上传第三片已经落最小后端边界:
|
|||||||
| 向量库状态与重建 | `GET /api/knowledge/doc/vector-index-status`、`POST /api/knowledge/doc/rebuild-vector-index` | 模型配置页展示当前 vector 模型维度、Qdrant collection 维度、点数和片段向量数;维度不一致时可一键清空旧 embedding、删除 collection 并按当前模型重建 |
|
| 向量库状态与重建 | `GET /api/knowledge/doc/vector-index-status`、`POST /api/knowledge/doc/rebuild-vector-index` | 模型配置页展示当前 vector 模型维度、Qdrant collection 维度、点数和片段向量数;维度不一致时可一键清空旧 embedding、删除 collection 并按当前模型重建 |
|
||||||
| Qdrant 向量索引 | 同一上传接口内机会性执行 | embedding 写入 MySQL 后尽力 upsert 到 Qdrant;同名文档替换会尽力删除旧 points;Qdrant 不可用不影响上传和 MySQL 检索;外部 embedding 成功但 collection 维度不一致时不静默降级为 `local-hash-v1`,通过状态接口和重建入口处理 |
|
| Qdrant 向量索引 | 同一上传接口内机会性执行 | embedding 写入 MySQL 后尽力 upsert 到 Qdrant;同名文档替换会尽力删除旧 points;Qdrant 不可用不影响上传和 MySQL 检索;外部 embedding 成功但 collection 维度不一致时不静默降级为 `local-hash-v1`,通过状态接口和重建入口处理 |
|
||||||
| 混合检索 | `POST /api/knowledge/search` | 先跑中文关键词 `LIKE` 打分和 MySQL Fulltext,再生成 query embedding 走 Qdrant,RRF 融合后交给 `category='rerank'` 模型(如硅基流动 bge-reranker-v2-m3)按语义相关性重排;rerank 未配置或失败保持 RRF 顺序,Qdrant 或外部向量接口不可用时保留关键词/全文检索。命中后若 chat 模型可用,自动生成结构化轻概要写入响应 `answer/keyPoints/cautions`(≤60字直答+3-6要点+0-3注意;temperature=0,15s 超时,硬约束不得编造、答不了明示无依据);概要失败回退片段原文拼接,检索不报错 |
|
| 混合检索 | `POST /api/knowledge/search` | 先跑中文关键词 `LIKE` 打分和 MySQL Fulltext,再生成 query embedding 走 Qdrant,RRF 融合后交给 `category='rerank'` 模型(如硅基流动 bge-reranker-v2-m3)按语义相关性重排;rerank 未配置或失败保持 RRF 顺序,Qdrant 或外部向量接口不可用时保留关键词/全文检索。命中后若 chat 模型可用,自动生成结构化轻概要写入响应 `answer/keyPoints/cautions`(≤60字直答+3-6要点+0-3注意;temperature=0,15s 超时,硬约束不得编造、答不了明示无依据);概要失败回退片段原文拼接,检索不报错 |
|
||||||
| 检索总结卡 | `POST /api/knowledge/summary-card`(JSON `{queryText, category}`) | 按需生成信息图式总结卡 `{title, steps[], objections[], scripts[], reminders[]}`:复用检索命中片段(最多5条)调 chat 模型,temperature=0、30s 超时;无命中/无 chat 模型/生成失败均抛明确错误供前端重试,不兜假数据。移动端查SOP结果页「生成总结卡」按钮触发,底部弹层展示并可 TTS 朗读 |
|
| 检索总结卡 | `POST /api/knowledge/summary-card`(JSON `{queryText, category, fragmentIds}`) | `fragmentIds` 必须来自当前回答且最多5条;服务端按当前身份与知识空间重新鉴权后,只使用这些引用片段生成 `{title, steps[], objections[], scripts[], reminders[]}`,不再二次检索。chat 模型未配置、调用失败或未返回固定「问题/建议/标准」三段结构时抛明确错误供前端重试,不把原文冒充核心步骤。移动端查SOP结果页「生成总结卡」按钮触发,底部弹层展示并可 TTS 朗读 |
|
||||||
| 解析状态聚合 | `GET /api/knowledge/processing/overview` | 聚合 `aihr_knowledge_attach.status`、fragment 数、embedding 数、`sys_oss.ext1.fileSize`,生成资料处理页指标、分类、任务、链路和事件列表 |
|
| 解析状态聚合 | `GET /api/knowledge/processing/overview` | 聚合 `aihr_knowledge_attach.status`、fragment 数、embedding 数、`sys_oss.ext1.fileSize`,生成资料处理页指标、分类、任务、链路和事件列表 |
|
||||||
| 服务端目录导入(运维) | `POST /api/knowledge/doc/import-local` | JSON `{ directory, category, limit }`;`directory` 只能是 `AIHR_IMPORT_ROOT` / `aihr.import.root` 下的相对目录,默认根目录为 `./.data/import`;逐文件复用上传解析链路,同步执行,仅保留给运维/调试,不在资料处理页暴露 |
|
| 服务端目录导入(运维) | `POST /api/knowledge/doc/import-local` | JSON `{ directory, category, limit }`;`directory` 只能是 `AIHR_IMPORT_ROOT` / `aihr.import.root` 下的相对目录,默认根目录为 `./.data/import`;逐文件复用上传解析链路,同步执行,仅保留给运维/调试,不在资料处理页暴露 |
|
||||||
| 服务端导入任务(运维) | `POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 启动后台目录导入并返回任务;任务写入 `aihr_knowledge_import_task`,调用方可查询总数、成功数、失败数、当前文件和进度,运行中任务可取消;仅保留给运维/调试,不在资料处理页暴露 |
|
| 服务端导入任务(运维) | `POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 启动后台目录导入并返回任务;任务写入 `aihr_knowledge_import_task`,调用方可查询总数、成功数、失败数、当前文件和进度,运行中任务可取消;仅保留给运维/调试,不在资料处理页暴露 |
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih
|
|||||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260724_broadcast_attachment_mysql8.sql
|
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260724_broadcast_attachment_mysql8.sql
|
||||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260722_broadcast_question_context_mysql8.sql
|
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260722_broadcast_question_context_mysql8.sql
|
||||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260724_agent_orchestration_mysql8.sql
|
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260724_agent_orchestration_mysql8.sql
|
||||||
|
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260725_deepseek_v4_model_mysql8.sql
|
||||||
|
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260725_sop_answer_partial_evidence_prompt_mysql8.sql
|
||||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/aihr_personal_knowledge_mysql8.sql
|
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/aihr_personal_knowledge_mysql8.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -262,7 +262,7 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="模型名称" required>
|
<el-form-item label="模型名称" required>
|
||||||
<el-input v-model="modelForm.modelName" placeholder="deepseek-chat" />
|
<el-input v-model="modelForm.modelName" placeholder="deepseek-v4-flash" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="供应商" required>
|
<el-form-item label="供应商" required>
|
||||||
<el-select v-model="modelForm.providerCode">
|
<el-select v-model="modelForm.providerCode">
|
||||||
@@ -460,7 +460,7 @@ const seedModels: ModelConfig[] = [
|
|||||||
{
|
{
|
||||||
id: -1,
|
id: -1,
|
||||||
category: 'chat',
|
category: 'chat',
|
||||||
modelName: 'deepseek-chat',
|
modelName: 'deepseek-v4-flash',
|
||||||
providerCode: 'deepseek',
|
providerCode: 'deepseek',
|
||||||
modelDescribe: '用于通用对话与 RAG 生成',
|
modelDescribe: '用于通用对话与 RAG 生成',
|
||||||
modelShow: 'Y',
|
modelShow: 'Y',
|
||||||
|
|||||||
@@ -161,7 +161,7 @@
|
|||||||
<button v-if="msg.result.snippets?.length" class="bubble-action" @click="msg.citationsOpen = !msg.citationsOpen">
|
<button v-if="msg.result.snippets?.length" class="bubble-action" @click="msg.citationsOpen = !msg.citationsOpen">
|
||||||
引用 {{ msg.result.snippets.length }} 条 {{ msg.citationsOpen ? '▲' : '▼' }}
|
引用 {{ msg.result.snippets.length }} 条 {{ msg.citationsOpen ? '▲' : '▼' }}
|
||||||
</button>
|
</button>
|
||||||
<button v-if="msg.result.agentIntent === 'KNOWLEDGE_QA' && !broadcastContext" class="bubble-action" :disabled="summaryLoading" @click="buildSummaryCard(msg)">
|
<button v-if="msg.result.agentIntent === 'KNOWLEDGE_QA' && summaryFragmentIds(msg).length && !broadcastContext" class="bubble-action" :disabled="summaryLoading" @click="buildSummaryCard(msg)">
|
||||||
{{ summaryLoading ? '生成中' : '总结卡' }}
|
{{ summaryLoading ? '生成中' : '总结卡' }}
|
||||||
</button>
|
</button>
|
||||||
<button v-if="msg.result.noEvidence && msg.result.agentIntent === 'KNOWLEDGE_QA' && !broadcastContext" class="bubble-action primary" @click="openWebAi">去查全网</button>
|
<button v-if="msg.result.noEvidence && msg.result.agentIntent === 'KNOWLEDGE_QA' && !broadcastContext" class="bubble-action primary" @click="openWebAi">去查全网</button>
|
||||||
@@ -1332,11 +1332,15 @@ const buildSummaryCard = async (msg: MasterMsg) => {
|
|||||||
}
|
}
|
||||||
if (summaryLoading.value) return;
|
if (summaryLoading.value) return;
|
||||||
const value = (msg.result.queryText || '').trim();
|
const value = (msg.result.queryText || '').trim();
|
||||||
if (!value) return;
|
const fragmentIds = summaryFragmentIds(msg);
|
||||||
|
if (!value || !fragmentIds.length) {
|
||||||
|
pushSystem('当前回答没有可用于总结卡的引用片段,请重新提问');
|
||||||
|
return;
|
||||||
|
}
|
||||||
summaryLoading.value = true;
|
summaryLoading.value = true;
|
||||||
summaryCard.value = null;
|
summaryCard.value = null;
|
||||||
try {
|
try {
|
||||||
summaryCard.value = await generateSummaryCard(value);
|
summaryCard.value = await generateSummaryCard({ queryText: value, fragmentIds });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pushSystem(requestErrorText(error, '总结卡生成失败'));
|
pushSystem(requestErrorText(error, '总结卡生成失败'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1344,6 +1348,13 @@ const buildSummaryCard = async (msg: MasterMsg) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const summaryFragmentIds = (msg: MasterMsg) => Array.from(new Set(
|
||||||
|
(msg.result.snippets || [])
|
||||||
|
.filter((item) => !item.sourceType || item.sourceType === 'DOCUMENT')
|
||||||
|
.map((item) => item.fragmentId)
|
||||||
|
.filter((id): id is number => typeof id === 'number' && Number.isSafeInteger(id) && id > 0)
|
||||||
|
)).slice(0, 5);
|
||||||
|
|
||||||
const closeSummary = () => {
|
const closeSummary = () => {
|
||||||
if (speechState.value.key === 'summary') speechPlayback.stop();
|
if (speechState.value.key === 'summary') speechPlayback.stop();
|
||||||
summaryCard.value = null;
|
summaryCard.value = null;
|
||||||
|
|||||||
@@ -250,13 +250,14 @@ export const submitAnswerFeedback = (params: {
|
|||||||
timeout: 15000
|
timeout: 15000
|
||||||
});
|
});
|
||||||
|
|
||||||
export const generateSummaryCard = (queryText: string) =>
|
export const generateSummaryCard = (params: { queryText: string; fragmentIds: number[] }) =>
|
||||||
apiRequest<SummaryCardResponse>({
|
apiRequest<SummaryCardResponse>({
|
||||||
url: '/api/knowledge/summary-card',
|
url: '/api/knowledge/summary-card',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: {
|
data: {
|
||||||
queryText,
|
queryText: params.queryText,
|
||||||
category: 'sop'
|
category: 'sop',
|
||||||
|
fragmentIds: params.fragmentIds
|
||||||
},
|
},
|
||||||
timeout: 45000
|
timeout: 45000
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -286,13 +286,20 @@ test('超大字模式下今日安排将文字和操作按钮纵向排布', async
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('问师傅页保留搜索框、答案区和总结卡入口', async () => {
|
test('问师傅页保留搜索框、答案区和总结卡入口', async () => {
|
||||||
const source = await pageSource('../src/pages/user/sop/index.vue');
|
const [source, service] = await Promise.all([
|
||||||
|
pageSource('../src/pages/user/sop/index.vue'),
|
||||||
|
pageSource('../src/services/knowledge.ts')
|
||||||
|
]);
|
||||||
|
|
||||||
assert.match(source, /query-panel/);
|
assert.match(source, /query-panel/);
|
||||||
assert.match(source, /answer-panel/);
|
assert.match(source, /answer-panel/);
|
||||||
assert.match(source, /summary-panel/);
|
assert.match(source, /summary-panel/);
|
||||||
assert.match(source, /MY_PRACTICE_SUMMARY/);
|
assert.match(source, /MY_PRACTICE_SUMMARY/);
|
||||||
assert.match(source, /TEAM_PRACTICE_SUMMARY/);
|
assert.match(source, /TEAM_PRACTICE_SUMMARY/);
|
||||||
|
assert.match(source, /const summaryFragmentIds = \(msg: MasterMsg\)/);
|
||||||
|
assert.match(source, /generateSummaryCard\(\{ queryText: value, fragmentIds \}\)/);
|
||||||
|
assert.match(service, /fragmentIds: number\[\]/);
|
||||||
|
assert.match(service, /fragmentIds: params\.fragmentIds/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('SOP 没解决先收集结构化原因再提交', async () => {
|
test('SOP 没解决先收集结构化原因再提交', async () => {
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry
|
|||||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_knowledge_mysql8.sql"
|
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_knowledge_mysql8.sql"
|
||||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_personal_knowledge_mysql8.sql"
|
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_personal_knowledge_mysql8.sql"
|
||||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_model_mysql8.sql"
|
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_model_mysql8.sql"
|
||||||
|
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260725_deepseek_v4_model_mysql8.sql"
|
||||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_practice_mysql8.sql"
|
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_practice_mysql8.sql"
|
||||||
|
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260725_sop_answer_partial_evidence_prompt_mysql8.sql"
|
||||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260717_practice_five_position_catalog_mysql8.sql"
|
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260717_practice_five_position_catalog_mysql8.sql"
|
||||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260723_practice_content_operations_mysql8.sql"
|
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260723_practice_content_operations_mysql8.sql"
|
||||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260724_practice_growth_curriculum_mysql8.sql"
|
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260724_practice_growth_curriculum_mysql8.sql"
|
||||||
|
|||||||
@@ -74,8 +74,28 @@ CREATE TABLE aihr_prompt_template (
|
|||||||
SQL
|
SQL
|
||||||
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_prompt_template_seed_mysql8.sql"
|
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_prompt_template_seed_mysql8.sql"
|
||||||
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_prompt_template_seed_mysql8.sql"
|
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_prompt_template_seed_mysql8.sql"
|
||||||
|
partial_evidence_prompt_migration="$ROOT_DIR/backend/script/sql/update/aihr_20260725_sop_answer_partial_evidence_prompt_mysql8.sql"
|
||||||
|
mysql "$DB" < "$partial_evidence_prompt_migration"
|
||||||
|
mysql "$DB" < "$partial_evidence_prompt_migration"
|
||||||
prompt_template_count="$(mysql "$DB" -N -B -e "SELECT COUNT(*) FROM aihr_prompt_template WHERE tenant_id='000000';")"
|
prompt_template_count="$(mysql "$DB" -N -B -e "SELECT COUNT(*) FROM aihr_prompt_template WHERE tenant_id='000000';")"
|
||||||
test "$prompt_template_count" = '6'
|
test "$prompt_template_count" = '7'
|
||||||
|
partial_evidence_prompt_count="$(mysql "$DB" -N -B -e "SELECT COUNT(*) FROM aihr_prompt_template WHERE tenant_id='000000' AND code='sop_answer' AND version='v2' AND enabled=1 AND system_prompt LIKE '%只要片段包含与问题相关的操作%';")"
|
||||||
|
test "$partial_evidence_prompt_count" = '1'
|
||||||
|
|
||||||
|
mysql "$DB" < "$ROOT_DIR/backend/script/sql/aihr_model_mysql8.sql"
|
||||||
|
mysql "$DB" <<'SQL'
|
||||||
|
INSERT INTO aihr_model_config
|
||||||
|
(tenant_id, category, model_name, provider_code, model_show, enabled, create_time, update_time)
|
||||||
|
VALUES
|
||||||
|
('000000', 'chat', 'deepseek-chat', 'deepseek', 'Y', 1, NOW(), NOW()),
|
||||||
|
('100001', 'chat', 'deepseek-chat', 'deepseek', 'Y', 1, NOW(), NOW()),
|
||||||
|
('100001', 'chat', 'deepseek-v4-flash', 'deepseek', 'N', 0, NOW(), NOW());
|
||||||
|
SQL
|
||||||
|
deepseek_model_migration="$ROOT_DIR/backend/script/sql/update/aihr_20260725_deepseek_v4_model_mysql8.sql"
|
||||||
|
mysql "$DB" < "$deepseek_model_migration"
|
||||||
|
mysql "$DB" < "$deepseek_model_migration"
|
||||||
|
deepseek_model_rows="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(CONCAT(tenant_id, ':', model_name, ':', enabled, ':', model_show) ORDER BY tenant_id, model_name) FROM aihr_model_config WHERE provider_code='deepseek';")"
|
||||||
|
test "$deepseek_model_rows" = '000000:deepseek-v4-flash:1:Y,100001:deepseek-chat:0:N,100001:deepseek-v4-flash:1:Y'
|
||||||
|
|
||||||
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260715_knowledge_feedback_mysql8.sql"
|
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260715_knowledge_feedback_mysql8.sql"
|
||||||
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260715_knowledge_feedback_mysql8.sql"
|
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260715_knowledge_feedback_mysql8.sql"
|
||||||
|
|||||||
Reference in New Issue
Block a user