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

- New aihr_knowledge_answer_feedback table; unique (tenant, query_norm,
  fragment_id) pair keeps submissions idempotent
- POST /api/knowledge/answer-feedback (fragmentIds array), GET pending-review
  list, POST /{id}/review — all under existing auth scope
- Down-voted (question, fragment) pairs are filtered from dbSearch fusion
  results; if filtering empties the hit list the original results are kept
  with an explanatory caution (never degrade to no-answer)
- Feedback table missing/unreachable → filter silently skipped, search
  behavior unchanged (degradation chain preserved)
- SnippetResponse gains optional trailing fragmentId (db hits only; seed
  fallback stays null); mobile sopPanel adds 👍/👎 with locked state after
  submit, up votes auto-marked reviewed to keep the review queue clean
This commit is contained in:
2026-07-08 15:09:27 +08:00
parent 8d78bc69d1
commit 15cd789da6
5 changed files with 389 additions and 11 deletions
+100
View File
@@ -1029,6 +1029,26 @@
<ol v-if="sopResult.keyPoints?.length" class="digest-points">
<li v-for="point in sopResult.keyPoints" :key="point">{{ point }}</li>
</ol>
<div class="answer-feedback-actions">
<button
type="button"
aria-label="这条答案有帮助"
:class="{ active: sopFeedbackVerdict === 'up' }"
:disabled="sopFeedbackSubmitting || !!sopFeedbackVerdict"
@click="submitSopFeedback('up')"
>
👍
</button>
<button
type="button"
aria-label="这条答案不准确"
:class="{ active: sopFeedbackVerdict === 'down' }"
:disabled="sopFeedbackSubmitting || !!sopFeedbackVerdict"
@click="submitSopFeedback('down')"
>
👎
</button>
</div>
<div v-if="sopResult.cautions?.length" class="digest-cautions">
<p v-for="item in sopResult.cautions" :key="item">⚠ {{ item }}</p>
</div>
@@ -1449,6 +1469,7 @@ interface KnowledgeDoc {
interface KnowledgeSnippet {
title: string;
text: string;
fragmentId?: number;
}
interface KnowledgeRecord {
@@ -1473,6 +1494,8 @@ interface KnowledgeSearchResponse {
cautions?: string[];
}
type AnswerFeedbackVerdict = 'up' | 'down';
interface SummaryCardStep {
label: string;
desc: string;
@@ -1764,6 +1787,8 @@ const sopQuery = ref('');
const sopResult = ref<KnowledgeSearchResponse | null>(null);
const sopLoading = ref(false);
const sopMessage = ref('');
const sopFeedbackVerdict = ref<AnswerFeedbackVerdict | ''>('');
const sopFeedbackSubmitting = ref(false);
const sopRecording = ref(false);
const sopAsrBusy = ref(false);
const sopUseAudioFileFallback = ref(false);
@@ -1805,6 +1830,14 @@ let sopAutoSearchAfterTranscribe = false;
let mentorScrollRaf = 0;
let mentorScrollListening = false;
const showToast = (message: string) => {
toastMessage.value = message;
if (toastTimer) window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => {
toastMessage.value = '';
}, 2200);
};
const mentorImageSrc = `${import.meta.env.BASE_URL}mentor.png`;
const roleAvatarSrc = computed(() => {
if (roleKey.value === 'candidate') return `${import.meta.env.BASE_URL}candidate-avatar.png`;
@@ -2123,6 +2156,8 @@ const openSopPanel = () => {
sopQuery.value = '';
sopResult.value = null;
sopMessage.value = '';
sopFeedbackVerdict.value = '';
sopFeedbackSubmitting.value = false;
summaryCard.value = null;
summaryCardMessage.value = '';
summaryCardOpen.value = false;
@@ -2401,6 +2436,8 @@ const searchSopKnowledge = async () => {
sopLoading.value = true;
sopMessage.value = '';
sopResult.value = null;
sopFeedbackVerdict.value = '';
sopFeedbackSubmitting.value = false;
sopSnippetsOpen.value = false;
expandedSnippets.value = new Set();
summaryCard.value = null;
@@ -2431,6 +2468,42 @@ const searchSopKnowledge = async () => {
}
};
const submitSopFeedback = async (verdict: AnswerFeedbackVerdict) => {
if (sopFeedbackSubmitting.value || sopFeedbackVerdict.value || !sopResult.value) return;
const fragmentIds = Array.from(new Set(
(sopResult.value.snippets || [])
.map((item) => item.fragmentId)
.filter((id): id is number => typeof id === 'number' && id > 0)
));
if (!fragmentIds.length) {
showToast('暂无可反馈依据');
return;
}
sopFeedbackSubmitting.value = true;
try {
await readApi<{ saved: number }>(
await fetch('/dev-api/api/knowledge/answer-feedback', {
method: 'POST',
headers: apiHeaders(),
body: JSON.stringify({
queryText: sopResult.value.queryText || sopQuery.value,
fragmentIds,
verdict,
category: sopResult.value.category || 'sop',
position: '生活顾问',
source: 'mobile_sop'
})
})
);
sopFeedbackVerdict.value = verdict;
showToast(verdict === 'down' ? '已记录,该答案将进入复核' : '感谢反馈');
} catch (error) {
showToast(error instanceof Error ? error.message : '反馈提交失败');
} finally {
sopFeedbackSubmitting.value = false;
}
};
const askMentorChip = (query: string) => {
openSopPanel();
sopQuery.value = query;
@@ -7170,6 +7243,33 @@ p {
line-height: 1.5;
}
.answer-feedback-actions {
margin-top: 10px;
display: flex;
gap: 8px;
}
.answer-feedback-actions button {
width: 40px;
height: 40px;
border: 1px solid rgba(46, 107, 230, 0.18);
border-radius: 12px;
background: #ffffff;
font-size: 18px;
line-height: 1;
transition: var(--transition-spring-fast);
}
.answer-feedback-actions button.active {
border-color: rgba(46, 107, 230, 0.55);
background: rgba(46, 107, 230, 0.12);
}
.answer-feedback-actions button:disabled {
opacity: 0.68;
transform: none;
}
.digest-cautions {
margin-top: 10px;
padding: 8px 10px;