Files
knowledge-governance/web/src/qaWeekly.ts
T
姚章浩 00c4c13283
build / build (push) Successful in 1m19s
fix(qa): 首批即显并统一五字段后端筛选
列表正文改为按需读取;保留完整导出、版本隔离和有效标签筛选。
验证:前端全量、Go全量及隔离PG全mirror竞态通过;覆盖首批回调、续载失败与筛选分页。
2026-10-25 12:45:31 +08:00

152 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { QARecordPage, QAQuery } from './qaOps'
import { qaCellValue, qaColumns } from './qaOps'
import type { QualitySummary, QualityRate } from './qaQuality'
import { qaDailySeries, qaCategorySeries } from './qaDashboard'
export function weeklyQualitySourceLabel(source: unknown): string {
return (
new Map([
['live-week', '当周实时质量指标'],
['frozen-week', '已冻结周指标'],
['reported-history', '历史上报指标'],
['master_live_calendar_month_v1', '自然月累计质量指标(跨月周按上月)'],
]).get(String(source)) ?? '指标来源未确认'
)
}
export interface QAWeeklyDraft {
markdown: string
csv: string
questions: number
revision: number
generatedAt: string
}
const stamp = (v: string) => new Date(v).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })
const rate = (v: QualityRate) =>
v.percent === null ? '—' : `${v.percent.toFixed(1)}%(${v.numerator} / ${v.denominator})`
const cell = (v: unknown) =>
String(v ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/[\`*_[\]|]/g, '\\$&')
.replace(/[\r\n]+/g, ' ')
function csvCell(value: unknown) {
const v = String(value ?? '')
const safe = /^[\t\r\n]|^\s*[=+\-@]/.test(v) ? "'" + v : v
return '"' + safe.replace(/"/g, '""') + '"'
}
export function buildQAWeeklyCSV(page: QARecordPage): string {
if (page.items.some((row) => row.content_projection === 'summary'))
throw Error('列表仅为预览,请重新读取完整明细后导出')
if (page.has_more || page.total !== page.items.length || new Set(page.items.map((r) => r.id)).size !== page.total)
throw Error('明细未完整读取,未导出部分数据')
const csv =
'\ufeff' +
[
qaColumns.map((c) => csvCell(c.label)).join(','),
...page.items.map((row) => qaColumns.map((c) => csvCell(qaCellValue(row, c.key))).join(',')),
].join('\r\n')
return csv
}
export function buildQAWeeklyDraft(
page: QARecordPage,
query: QAQuery,
quality: QualitySummary,
generatedAt: string
): QAWeeklyDraft {
if (
page.has_more ||
page.items.length !== page.total ||
new Set(page.items.map((i) => i.id)).size !== page.total ||
quality.summary.total !== page.total ||
Date.parse(quality.start) !== Date.parse(query.start) ||
Date.parse(quality.end) !== Date.parse(query.end) ||
quality.keyword !== query.keyword ||
!Number.isFinite(Date.parse(generatedAt))
)
throw Error('周报范围或完整性不一致,请重新生成')
const groups: Record<string, number> = {
covered: 0,
uncovered: 0,
partial: 0,
pending: 0,
unassessed: 0,
excluded: 0,
stale: 0,
}
for (const r of page.items) {
const state =
r.quality?.state === 'stale' ? 'stale' : r.quality?.effective.coverage || r.coverage?.verdict || 'unassessed'
const key = state === 'unknown' ? 'unassessed' : state
if (!(key in groups)) throw Error('周报存在未知覆盖状态')
groups[key]++
}
for (const [key, count] of Object.entries(groups))
if (quality.summary.rates.breakdown[key as keyof typeof quality.summary.rates.breakdown] !== count)
throw Error('复核结果在生成期间发生变化,请重新生成')
const s = quality.summary
const daily = qaDailySeries(page.items, query)
.map((p) => `| ${p.label} | ${p.count} |`)
.join('\n')
const products = qaCategorySeries(page.items, 'product_category')
.map((p) => `| ${cell(p.label)} | ${p.count} |`)
.join('\n')
const gaps = page.items.filter(
(r) => (r.quality?.effective.coverage || r.coverage?.verdict) === 'uncovered' && r.quality?.state !== 'stale'
)
const md =
[
'# DobotAI 问答运营周报(草稿)',
`统计范围:${stamp(query.start)} 至 ${stamp(query.end)}(结束不含)`,
query.keyword ? `关键词:${cell(query.keyword)}` : '',
`生成:${stamp(generatedAt)}`,
`本地数据版本:${page.local_revision ?? '—'}`,
'## 问答概览',
`- 问题总量:${page.total}`,
`- 知识覆盖率:${rate(s.rates.coverage)}`,
`- 回答正确率:${rate(s.rates.correctness)}`,
`- 已人工复核:${s.reviewed}`,
`- 待重新核对:${s.stale}`,
`- 人工待确认:${s.pending_confirmation}`,
'## 质量分布',
'| 已覆盖 | 未覆盖 | 部分覆盖 | 待确认 | 未评估 | 不计入 | 待重新核对 |',
'| --- | --- | --- | --- | --- | --- | --- |',
`| ${groups.covered} | ${groups.uncovered} | ${groups.partial} | ${groups.pending} | ${groups.unassessed} | ${groups.excluded} | ${groups.stale} |`,
'## 每日提问',
'| 日期 | 问题量 |',
'| --- | ---: |',
daily,
'## 产品分类',
'| 分类 | 问题量 |',
'| --- | ---: |',
products,
'## 未覆盖问题',
gaps.length ? '| 提问时间 | 问题 | 产品分类 |' : '当前无有效未覆盖记录',
gaps.length ? '| --- | --- | --- |' : '',
...gaps.map(
(r) =>
`| ${stamp(r.asked_at)} | ${cell(qaCellValue(r, 'question_chinese') || r.question)} | ${cell(r.quality?.effective.product_category || r.product_category || '—')} |`
),
]
.filter(Boolean)
.join('\n\n') + '\n'
const csv = buildQAWeeklyCSV(page)
return {
markdown: md.replace(/\|\n\n\|/g, '|\n|'),
csv,
questions: page.total,
revision: page.local_revision ?? 0,
generatedAt,
}
}
export function downloadQAWeekly(content: string, filename: string, type: string) {
const url = URL.createObjectURL(new Blob([content], { type }))
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.append(a)
a.click()
a.remove()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}