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

507 lines
19 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 { parseAIWorkState } from './qaAITypes'
import type { AIWorkState } from './qaAITypes'
import { parseQAQuality } from './qaQuality'
import { regionNames, parseRegionDefinitions } from './qaOrg'
import type { QAQuality } from './qaQuality'
export const qaColumns = [
{ key: 'question_chinese', label: '中文问题' },
{ key: 'answer', label: 'AI答案' },
{ key: 'coverage', label: '是否覆盖' },
{ key: 'is_correct', label: '是否正确' },
{ key: 'product_category', label: '产品分类' },
{ key: 'problem_type', label: '问题类型' },
{ key: 'root_cause', label: '根因分析' },
{ key: 'asked_at', label: '提问时间' },
{ key: 'asker', label: '提问人' },
{ key: 'question', label: '原始问题' },
{ key: 'language', label: '语言' },
{ key: 'trigger_person', label: '流程触发人员信息' },
{ key: 'department', label: '部门' },
{ key: 'region', label: '区域' },
{ key: 'feedback', label: '是否点赞' },
] as const
export type QAColumn = (typeof qaColumns)[number]['key']
export interface QAOrganizationVersion {
region_names?: string[]
state: 'ready' | 'empty'
directory_id: string
directory_source: string
synced_at: string | null
revision_id: string
basis: 'current_directory'
}
export interface QARecordOrganization {
version: QAOrganizationVersion
state: string
departments: string[]
region: string
source_department: string
source_region: string
department_origin: string
region_origin: string
}
export function parseQAOrganizationVersion(value: unknown): QAOrganizationVersion {
const v = object(value)
if (!['ready', 'empty'].includes(text(v.state)) || v.basis !== 'current_directory') return invalid()
const region_names =
v.region_names === undefined
? [...regionNames]
: Array.isArray(v.region_names)
? parseRegionDefinitions(v.region_names.map((name) => ({ id: name, name }))).map((r) => r.name)
: invalid()
const result = {
region_names,
state: v.state as QAOrganizationVersion['state'],
directory_id: text(v.directory_id),
directory_source: text(v.directory_source),
synced_at: v.synced_at === null ? null : instant(v.synced_at),
revision_id: text(v.revision_id),
basis: 'current_directory' as const,
}
if (
result.state === 'ready' &&
(!result.directory_id ||
!result.synced_at ||
!['weekly_report_live', 'weekly_report_snapshot'].includes(result.directory_source))
)
return invalid()
if (result.state === 'empty' && (result.directory_id || result.revision_id || result.synced_at !== null))
return invalid()
return result
}
export function mergeQAPages(current: QARecordPage, next: QARecordPage): QARecordPage {
if (current.local_revision !== next.local_revision)
throw new Error('本地问答已更新,请重新查询,不能混合不同同步版本的数据')
if (
current.organization?.directory_id !== next.organization?.directory_id ||
current.organization?.revision_id !== next.organization?.revision_id
)
throw new Error('通讯录或区域配置已变化,请重新查询,不能混合不同版本的分页结果')
const ids = new Set(current.items.map((item) => item.id))
return {
...next,
items: [...current.items, ...next.items.filter((item) => !ids.has(item.id))],
}
}
export interface QARecord {
content_projection?: 'summary'
field_edit_revision?: number
background_tasks?: AIWorkState[]
record_origin?: 'dobotai_2_0' | 'historical_excel'
master_fields?: Partial<Record<QAColumn, string>>
quality?: QAQuality
organization?: QARecordOrganization
id: string
status: string
question: string
answer: string
asked_at: string
time_source: string
asker: string
trigger_person: string
department: string
region: string
references: unknown
media: unknown
source_coverage: { verdict: string; reason_code: string; evaluator_version: string }
feedback: { asker_value: string; up: number; down: number }
coverage: {
state: 'pending' | 'checked'
verdict?: 'uncovered' | 'covered'
rule_version: string
matched_text?: string
}
question_chinese?: string
is_correct?: boolean | null
product_category?: string
problem_type?: string
root_cause?: string
language?: string
}
export interface QARecordPage {
local_revision?: number
organization?: QAOrganizationVersion
items: QARecord[]
columns: QAColumn[]
total: number
has_more: boolean
next_cursor: string
as_of: string
}
export interface QAQuery {
projection?: 'summary' | 'full'
filters?: Partial<Record<'coverage' | 'is_correct' | 'product_category' | 'problem_type' | 'root_cause', string>>
dataset?: 'master'
start: string
end: string
keyword: string
cursor: string
limit?: number
}
export function qualityFiltersKey(filters: QAQuery['filters']): string {
return JSON.stringify(Object.entries(filters ?? {}).sort(([a], [b]) => a.localeCompare(b)))
}
export function parseQualityFilters(value: unknown): NonNullable<QAQuery['filters']> {
if (value === undefined) return {}
const fields = object(value),
out: NonNullable<QAQuery['filters']> = {}
for (const [key, v] of Object.entries(fields)) {
if (
!['coverage', 'is_correct', 'product_category', 'problem_type', 'root_cause'].includes(key) ||
typeof v !== 'string' ||
!v ||
v.length > 400
)
return invalid()
out[key as keyof typeof out] = v
}
return out
}
export interface QAStatus {
state: 'disabled' | 'unavailable' | 'blocked' | 'connected'
code: string
warnings: string[]
read_only: true
source_version: '2.0'
channel: 'dingtalk'
}
const invalid = (): never => {
throw new Error('问答运营响应无效,请联系管理员检查接口契约')
}
const object = (value: unknown): Record<string, unknown> => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return invalid()
return value as Record<string, unknown>
}
const text = (value: unknown): string => (typeof value === 'string' ? value : invalid())
const count = (value: unknown): number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : invalid()
const instant = (value: unknown): string => {
const result = text(value)
if (!result || Number.isNaN(Date.parse(result))) return invalid()
return result
}
const columnKeys = new Set<string>(qaColumns.map(({ key }) => key))
export function parseQARecord(value: unknown): QARecord {
const row = object(value)
if (row.content_projection !== undefined && row.content_projection !== 'summary') return invalid()
const feedback = object(row.feedback)
const source = object(row.source_coverage)
const coverage = object(row.coverage)
if (coverage.state !== 'pending' && coverage.state !== 'checked') return invalid()
const masterV2 =
['dobotai_2_0', 'historical_excel'].includes(String(row.record_origin)) &&
coverage.rule_version === 'master-answer-substring-v2'
if (
coverage.verdict !== undefined &&
coverage.verdict !== 'uncovered' &&
!(masterV2 && coverage.verdict === 'covered')
)
return invalid()
if (coverage.state === 'checked' && coverage.verdict !== 'uncovered' && !(masterV2 && coverage.verdict === 'covered'))
return invalid()
if (coverage.state === 'pending' && coverage.verdict !== undefined) return invalid()
const askerValue = text(feedback.asker_value)
if (!['', 'up', 'down'].includes(askerValue)) return invalid()
const item: QARecord = {
...(row.content_projection === 'summary' ? { content_projection: 'summary' as const } : {}),
...(row.quality === undefined
? {}
: {
quality: parseQAQuality(
row.quality,
['dobotai_2_0', 'historical_excel'].includes(String(row.record_origin)),
askerValue === 'down' || count(feedback.down) > 0
),
}),
id: text(row.id),
status: text(row.status),
question: text(row.question),
answer: text(row.answer),
asked_at: instant(row.asked_at),
time_source: text(row.time_source),
asker: text(row.asker),
trigger_person: text(row.trigger_person),
department: text(row.department),
region: text(row.region),
references: row.references,
media: row.media,
source_coverage: {
verdict: text(source.verdict),
reason_code: text(source.reason_code),
evaluator_version: text(source.evaluator_version),
},
feedback: { asker_value: askerValue, up: count(feedback.up), down: count(feedback.down) },
coverage: { state: coverage.state, verdict: coverage.verdict, rule_version: text(coverage.rule_version) },
}
if (!item.id || !item.coverage.rule_version) return invalid()
if (coverage.matched_text !== undefined) item.coverage.matched_text = text(coverage.matched_text)
for (const key of ['question_chinese', 'product_category', 'problem_type', 'root_cause', 'language'] as const) {
if (row[key] !== undefined) item[key] = text(row[key])
}
if (row.is_correct !== undefined) {
if (typeof row.is_correct !== 'boolean' && row.is_correct !== null) return invalid()
item.is_correct = row.is_correct
}
if (row.field_edit_revision !== undefined) item.field_edit_revision = count(row.field_edit_revision)
if (row.record_origin !== undefined) {
if (row.record_origin !== 'dobotai_2_0' && row.record_origin !== 'historical_excel') return invalid()
item.record_origin = row.record_origin
}
if (row.master_fields !== undefined) {
const f = object(row.master_fields)
const fields: Partial<Record<QAColumn, string>> = {}
for (const [key, value] of Object.entries(f)) {
if (!columnKeys.has(key)) return invalid()
fields[key as QAColumn] = text(value)
}
item.master_fields = fields
}
if (row.background_tasks !== undefined) {
if (!Array.isArray(row.background_tasks)) return invalid()
item.background_tasks = row.background_tasks.map(parseAIWorkState)
if (item.background_tasks.some((task) => task.record_id !== item.id)) return invalid()
}
if (row.organization !== undefined) {
const o = object(row.organization)
if (
!Array.isArray(o.departments) ||
![
'matched',
'unassigned',
'cross_region',
'unmatched',
'identity_conflict',
'no_identity',
'directory_missing',
].includes(text(o.state))
)
return invalid()
const origin = (v: unknown) => {
const s = text(v)
return ['source_snapshot', 'current_directory', 'unavailable'].includes(s) ? s : invalid()
}
item.organization = {
version: parseQAOrganizationVersion(o.version),
state: text(o.state),
departments: o.departments.map(text),
region: text(o.region),
source_department: text(o.source_department),
source_region: text(o.source_region),
department_origin: origin(o.department_origin),
region_origin: origin(o.region_origin),
}
}
return item
}
export function parseQARecordPage(value: unknown): QARecordPage {
const page = object(value)
if (!Array.isArray(page.items) || !Array.isArray(page.columns) || typeof page.has_more !== 'boolean') return invalid()
const items = page.items.map(parseQARecord)
const columns = page.columns.map((key) =>
typeof key === 'string' && columnKeys.has(key) ? (key as QAColumn) : invalid()
)
const total = count(page.total)
const nextCursor = text(page.next_cursor)
if (total < items.length || (page.has_more && (!items.length || !nextCursor)) || (!page.has_more && nextCursor))
return invalid()
if (new Set(items.map(({ id }) => id)).size !== items.length || new Set(columns).size !== columns.length)
return invalid()
return {
items,
columns,
total,
has_more: page.has_more,
next_cursor: nextCursor,
as_of: instant(page.as_of),
...(page.organization === undefined ? {} : { organization: parseQAOrganizationVersion(page.organization) }),
...(page.local_revision === undefined ? {} : { local_revision: count(page.local_revision) }),
}
}
export class QARequestError extends Error {
constructor(
message: string,
public readonly status: number
) {
super(message)
this.name = 'QARequestError'
}
}
export async function getPayload(
path: string,
signal?: AbortSignal,
request: Pick<RequestInit, 'method' | 'headers' | 'body'> = {}
): Promise<unknown> {
const response = await fetch(path, { ...request, credentials: 'same-origin', cache: 'no-store', signal })
const raw: unknown = await response.json().catch(() => null)
if (!response.ok) {
const payload = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : null
throw new QARequestError(
typeof payload?.error === 'string' ? payload.error : `问答数据读取失败(HTTP ${response.status})`,
response.status
)
}
return object(raw).data
}
export async function fetchQARecords(query: QAQuery, signal?: AbortSignal): Promise<QARecordPage> {
const parameters = new URLSearchParams({
start: query.start,
end: query.end,
keyword: query.keyword,
cursor: query.cursor,
limit: String(query.limit ?? 20),
})
if (query.projection) parameters.set('projection', query.projection)
for (const [key, value] of Object.entries(query.filters ?? {})) if (value) parameters.set(key, value)
return parseQARecordPage(await getPayload(`${qaDatasetPrefix(query.dataset)}/records?${parameters}`, signal))
}
export async function fetchAllQARecords(
query: QAQuery,
signal?: AbortSignal,
onProgress?: (loaded: number, total: number) => void,
onPage?: (page: QARecordPage) => void
): Promise<QARecordPage> {
if (signal?.aborted) throw new DOMException('读取已取消', 'AbortError')
let page = await fetchQARecords({ ...query, cursor: '', limit: 100 }, signal)
const total = page.total
const ids = new Set(page.items.map((item) => item.id))
const cursors = new Set<string>()
onProgress?.(ids.size, total)
onPage?.(page)
while (page.has_more) {
if (signal?.aborted) throw new DOMException('读取已取消', 'AbortError')
const cursor = page.next_cursor
if (cursors.has(cursor) || ids.size >= total) throw new Error('全量读取游标异常,请重新查询')
cursors.add(cursor)
const next = await fetchQARecords({ ...query, cursor, limit: 100 }, signal)
if (next.total !== total) throw new Error('查询总数已变化,请重新查询,未将部分记录当作全部结果')
for (const item of next.items) {
if (ids.has(item.id)) throw new Error('全量读取出现重复记录,请重新查询')
ids.add(item.id)
}
page = mergeQAPages(page, next)
onProgress?.(ids.size, total)
onPage?.(page)
}
if (ids.size !== total) throw new Error('全量读取数量不完整,请重试,未将部分记录当作全部结果')
return page
}
export function qaDatasetPrefix(dataset?: 'master') {
return dataset === 'master' ? '/api/v1/qa-ops/master' : '/api/v1/qa-ops'
}
export async function fetchQARecord(id: string, signal?: AbortSignal, dataset?: 'master'): Promise<QARecord> {
return parseQARecord(await getPayload(`${qaDatasetPrefix(dataset)}/records/${encodeURIComponent(id)}`, signal))
}
export async function fetchQAStatus(signal?: AbortSignal): Promise<QAStatus> {
const value = object(await getPayload('/api/v1/qa-ops/status', signal))
if (
!['disabled', 'unavailable', 'blocked', 'connected'].includes(text(value.state)) ||
!Array.isArray(value.warnings) ||
value.read_only !== true ||
value.source_version !== '2.0' ||
value.channel !== 'dingtalk'
)
return invalid()
return {
state: value.state as QAStatus['state'],
code: text(value.code),
warnings: value.warnings.map(text),
read_only: true,
source_version: '2.0',
channel: 'dingtalk',
}
}
export function triggerPersonName(raw: string): string | null {
const value = raw.trim()
if (!value) return null
if (!/^[{[]/.test(value)) return value
try {
const person: unknown = JSON.parse(value)
if (!person || typeof person !== 'object' || Array.isArray(person)) return '人员信息待解析'
const fields = person as Record<string, unknown>
for (const key of ['name', 'nickName']) {
if (typeof fields[key] === 'string' && fields[key].trim()) return fields[key].trim()
}
return '未提供姓名'
} catch {
return '人员信息待解析'
}
}
const qaAskedAtFormatter = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
export function qaCellValue(item: QARecord, column: QAColumn): string | null {
if (column === 'root_cause' && item.field_edit_revision && item.quality?.effective.correctness === 'correct')
return null
if (column === 'trigger_person') return triggerPersonName(item.master_fields?.trigger_person ?? item.trigger_person)
const reviewedQuality =
item.quality?.state === 'reviewed' &&
['coverage', 'is_correct', 'product_category', 'problem_type', 'root_cause'].includes(column)
if (!reviewedQuality && item.master_fields && Object.hasOwn(item.master_fields, column)) {
const stored = item.master_fields[column]
if (stored?.trim()) return stored
if (column === 'department' || column === 'region') return item[column] || null
const f = item.quality?.effective
const projected =
column === 'coverage'
? f?.coverage
: column === 'is_correct'
? f?.correctness
: column === 'product_category' || column === 'problem_type' || column === 'root_cause'
? f?.[column]
: undefined
if (!projected) return null
}
if (column === 'question_chinese')
return item.question_chinese || (['zh', '中文'].includes(item.language ?? '') ? item.question : '待翻译')
if (column === 'language') return item.language || '未识别'
const f = item.quality?.effective
if (f) {
if (column === 'coverage')
return f.coverage
? (
{ covered: '是', uncovered: '否', partial: '部分覆盖', pending: '待确认', excluded: '不计入' } as Record<
string,
string
>
)[f.coverage]
: null
if (column === 'is_correct')
return f.correctness
? ({ correct: '是', incorrect: '否', pending: '待确认' } as Record<string, string>)[f.correctness]
: null
if (column === 'product_category' || column === 'problem_type' || column === 'root_cause') return f[column] || null
}
if (column === 'coverage')
return item.coverage.verdict === 'uncovered' ? '否' : item.coverage.verdict === 'covered' ? '是' : null
if (column === 'is_correct') return typeof item.is_correct === 'boolean' ? (item.is_correct ? '是' : '否') : null
if (column === 'feedback') {
if (item.feedback.asker_value === 'up') return '是'
if (item.feedback.asker_value === 'down') return '否(点踩)'
if (item.feedback.up || item.feedback.down) return `其他人:赞 ${item.feedback.up} / 踩 ${item.feedback.down}`
return null
}
if (column === 'asked_at') return qaAskedAtFormatter.format(new Date(item.asked_at))
return item[column] || null
}