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:
@@ -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