feat: изменил логику анализа отзывов
Backend CI / build-and-test (push) Failing after 14m19s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Failing after 12m5s
Frontend CI / build-and-check (push) Failing after 17m58s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 10m11s
🚀 Create and publish a Docker image / Build & publish backend image (push) Failing after 11m3s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Failing after 14m58s

This commit is contained in:
2026-05-22 01:30:41 +03:00
parent 168d6af860
commit 8ac593d36f
36 changed files with 858 additions and 457 deletions
+210 -85
View File
@@ -5,7 +5,7 @@ import DataTable from '@/components/ui/DataTable.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import { reviewsApi } from '@/api'
import type { ReviewDto } from '@/api/types'
import type { ApiReviewLlmStatus, ApiReviewSentiment, ReviewDto } from '@/api/types'
const columns = [
{ key: 'id', label: 'ID' },
@@ -14,10 +14,7 @@ const columns = [
{ key: 'rating', label: 'Оценка', align: 'center' },
{ key: 'text', label: 'Текст' },
{ key: 'date', label: 'Дата' },
{ key: 'status', label: 'LLM-статус', align: 'center' },
{ key: 'sentiment', label: 'Sentiment', align: 'center' },
{ key: 'quality', label: 'Quality', align: 'center' },
{ key: 'informativeTags', label: 'Informative / tags' },
{ key: 'analysis', label: 'Результат нейронки' },
{ key: 'actions', label: 'Действия', align: 'right' },
]
@@ -33,12 +30,32 @@ const ratingLabel: Record<string, string> = {
Dislike: '👎 Dislike',
}
const statusFilters: Array<{ label: string; value: ApiReviewLlmStatus | 'All' }> = [
{ label: 'Все', value: 'All' },
{ label: 'На проверке', value: 'Pending' },
{ label: 'Проверены', value: 'Analyzed' },
{ label: 'Отклонены', value: 'Rejected' },
]
const sentimentLabel: Record<ApiReviewSentiment, string> = {
Positive: 'Позитивный',
Neutral: 'Нейтральный',
Negative: 'Негативный',
}
const sentimentClass: Record<ApiReviewSentiment, string> = {
Positive: 'badge-green',
Neutral: 'badge-orange',
Negative: 'badge-red',
}
const reviews = ref<ReviewDto[]>([])
const loading = ref(false)
const page = ref(1)
const pageSize = ref(20)
const totalCount = ref(0)
const totalPages = ref(0)
const statusFilter = ref<ApiReviewLlmStatus | 'All'>('All')
const reanalyzingId = ref<number | null>(null)
const error = ref('')
const promptText = ref('')
@@ -50,28 +67,36 @@ const promptError = ref('')
const promptSuccess = ref('')
const rows = computed(() =>
reviews.value.map((review) => ({
id: review.id,
lecture: review.lectureTitle || `#${review.lectureId}`,
student: review.userName || `#${review.userId}`,
rating: ratingLabel[review.rating] ?? review.rating,
text: review.text || '—',
date: new Date(review.createdAt).toLocaleString('ru-RU'),
status: statusMap[review.llmStatus] ?? review.llmStatus,
rawStatus: review.llmStatus,
sentiment: review.sentiment,
quality: review.qualityScore,
informative: review.isInformative,
tags: review.llmTags ?? [],
informativeTags: [
review.isInformative === null || review.isInformative === undefined
? '—'
: review.isInformative
? 'Да'
: 'Нет',
...(review.llmTags ?? []),
].join(' / '),
})),
reviews.value.map((review) => {
const analysisReady = review.llmStatus === 'Analyzed'
const sentiment = analysisReady ? review.sentiment : null
const tags = analysisReady ? normalizeTags(review.llmTags) : []
return {
id: review.id,
lecture: review.lectureTitle || `#${review.lectureId}`,
student: review.userName || `#${review.userId}`,
rating: ratingLabel[review.rating] ?? review.rating,
text: review.text || '—',
date: new Date(review.createdAt).toLocaleString('ru-RU'),
status: statusMap[review.llmStatus] ?? review.llmStatus,
rawStatus: review.llmStatus,
analysis: review.llmStatus,
analysisReady,
analysisMessage: getAnalysisMessage(review),
sentiment,
sentimentLabel: sentiment ? sentimentLabel[sentiment] : '',
sentimentClass: sentiment ? sentimentClass[sentiment] : 'badge-gray',
quality: analysisReady ? review.qualityScore : null,
qualityLabel: analysisReady ? formatQuality(review.qualityScore) : '—',
qualityClass: getQualityClass(analysisReady ? review.qualityScore : null),
informative: analysisReady ? review.isInformative : null,
informativeLabel: analysisReady ? formatInformative(review.isInformative) : '—',
informativeClass: getInformativeClass(analysisReady ? review.isInformative : null),
tags,
llmRawOutput: analysisReady ? formatRawOutput(review.llmRawOutput) : '',
}
}),
)
const pageStart = computed(() =>
@@ -93,9 +118,44 @@ const canSavePrompt = computed(
promptText.value !== savedPromptText.value,
)
function normalizeTags(tags: string[] | null | undefined) {
return Array.from(new Set((tags ?? []).map((tag) => tag.trim()).filter(Boolean)))
}
function formatQuality(value: number | null | undefined) {
if (value === null || value === undefined) return '—'
return Number(value).toFixed(2)
const numeric = Number(value)
if (!Number.isFinite(numeric)) return '—'
const score = Math.max(0, Math.min(1, numeric))
return `${Math.round(score * 100)}%`
}
function getQualityClass(value: number | null | undefined) {
if (value === null || value === undefined) return 'badge-gray'
if (!Number.isFinite(Number(value))) return 'badge-gray'
if (value >= 0.7) return 'badge-green'
if (value >= 0.4) return 'badge-orange'
return 'badge-red'
}
function formatInformative(value: boolean | null | undefined) {
if (value === null || value === undefined) return '—'
return value ? 'Информативный' : 'Неинформативный'
}
function getInformativeClass(value: boolean | null | undefined) {
if (value === null || value === undefined) return 'badge-gray'
return value ? 'badge-green' : 'badge-red'
}
function getAnalysisMessage(review: ReviewDto) {
if (review.llmStatus === 'Pending') return 'Ожидает обработки'
if (review.llmStatus === 'Rejected') return 'Отзыв отклонён нейронкой'
return 'Анализ завершён, данных нет'
}
function formatRawOutput(value: string | null | undefined) {
return value?.trim() || ''
}
async function fetchPrompt() {
@@ -144,6 +204,7 @@ async function fetchReviews() {
const result = await reviewsApi.listPage({
Page: page.value,
PageSize: pageSize.value,
...(statusFilter.value === 'All' ? {} : { LlmStatus: statusFilter.value }),
})
reviews.value = result.items
totalCount.value = result.totalCount
@@ -155,6 +216,13 @@ async function fetchReviews() {
}
}
async function selectStatusFilter(nextStatus: ApiReviewLlmStatus | 'All') {
if (statusFilter.value === nextStatus) return
statusFilter.value = nextStatus
page.value = 1
await fetchReviews()
}
async function goToPage(nextPage: number) {
if (nextPage < 1 || (totalPages.value && nextPage > totalPages.value)) return
page.value = nextPage
@@ -224,6 +292,21 @@ onMounted(() => {
</GlassCard>
<GlassCard>
<div class="review-toolbar">
<div class="status-filters" aria-label="Фильтр статуса LLM">
<button
v-for="filter in statusFilters"
:key="filter.value"
type="button"
:class="{ active: statusFilter === filter.value }"
:disabled="loading"
@click="selectStatusFilter(filter.value)"
>
{{ filter.label }}
</button>
</div>
</div>
<EmptyState v-if="error" title="Не удалось загрузить отзывы" :subtitle="error" />
<EmptyState
v-else-if="!rows.length && !loading"
@@ -247,58 +330,33 @@ onMounted(() => {
<template #text="{ value }">
<span class="review-text" :title="value">{{ value }}</span>
</template>
<template #status="{ value, row }">
<StatusBadge :status="value" />
<span class="raw-status">{{ row.rawStatus }}</span>
</template>
<template #sentiment="{ value }">
<span
class="badge"
:class="
value === 'Positive'
? 'badge-green'
: value === 'Negative'
? 'badge-red'
: 'badge-orange'
"
>
{{ value }}
</span>
</template>
<template #quality="{ value }">
<span
:class="
(value ?? 0) >= 0.7
? 'badge badge-green'
: (value ?? 0) >= 0.4
? 'badge badge-orange'
: 'badge badge-red'
"
>
{{ formatQuality(value) }}
</span>
</template>
<template #informativeTags="{ row }">
<div class="tags-cell">
<span
class="badge"
:class="
row.informative
? 'badge-green'
: row.informative === false
? 'badge-red'
: 'badge-gray'
"
>
{{
row.informative === null || row.informative === undefined
? '—'
: row.informative
? 'Informative'
: 'Not informative'
}}
</span>
<span v-for="tag in row.tags" :key="tag" class="badge badge-blue">{{ tag }}</span>
<template #analysis="{ row }">
<div class="analysis-cell">
<div v-if="!row.analysisReady" class="analysis-head">
<StatusBadge :status="row.status" />
<span class="raw-status">{{ row.rawStatus }}</span>
</div>
<div v-if="row.analysisReady" class="analysis-result">
<div class="analysis-metrics">
<span class="badge" :class="row.qualityClass">
Качество {{ row.qualityLabel }}
</span>
<span class="badge" :class="row.sentimentClass">{{ row.sentimentLabel }}</span>
<span class="badge" :class="row.informativeClass">
{{ row.informativeLabel }}
</span>
</div>
<div v-if="row.tags.length" class="analysis-tags">
<span v-for="tag in row.tags" :key="tag" class="badge badge-blue">{{ tag }}</span>
</div>
<span v-else class="analysis-note">Теги не найдены</span>
<details v-if="row.llmRawOutput" class="raw-output">
<summary>Текст ответа нейронки</summary>
<pre>{{ row.llmRawOutput }}</pre>
</details>
<span v-else class="analysis-note">Текст ответа нейронки не сохранён</span>
</div>
<span v-else class="analysis-note">{{ row.analysisMessage }}</span>
</div>
</template>
<template #actions="{ row }">
@@ -405,8 +463,7 @@ onMounted(() => {
vertical-align: middle;
}
.raw-status {
display: block;
margin-top: 4px;
display: inline-flex;
color: var(--color-text-secondary);
font-size: 11px;
}
@@ -415,6 +472,34 @@ onMounted(() => {
flex-direction: column;
gap: 12px;
}
.review-toolbar {
display: flex;
justify-content: flex-start;
margin-bottom: 12px;
}
.status-filters {
display: inline-flex;
border: 1px solid var(--color-border-glass);
border-radius: 10px;
overflow: hidden;
}
.status-filters button {
background: var(--color-white-a70);
border: none;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 13px;
padding: 8px 14px;
}
.status-filters button.active {
background: var(--color-primary-a18);
color: var(--color-primary-dark);
font-weight: 600;
}
.status-filters button:disabled {
cursor: wait;
opacity: 0.7;
}
.pagination-bar {
display: flex;
align-items: center;
@@ -429,10 +514,50 @@ onMounted(() => {
align-items: center;
gap: 8px;
}
.tags-cell {
.analysis-cell {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 260px;
}
.analysis-head,
.analysis-metrics,
.analysis-tags {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
min-width: 160px;
gap: 6px;
}
.analysis-result {
display: flex;
flex-direction: column;
gap: 6px;
}
.analysis-note {
color: var(--color-text-secondary);
font-size: 12px;
}
.raw-output {
max-width: 640px;
}
.raw-output summary {
color: var(--color-primary-dark);
cursor: pointer;
font-size: 12px;
font-weight: 600;
}
.raw-output pre {
margin: 6px 0 0;
max-height: 220px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
border: 1px solid var(--color-border-glass);
border-radius: 8px;
background: var(--color-white-a60);
padding: 10px;
color: var(--color-text);
font-size: 12px;
line-height: 1.45;
}
</style>