00c4c13283
build / build (push) Successful in 1m19s
列表正文改为按需读取;保留完整导出、版本隔离和有效标签筛选。 验证:前端全量、Go全量及隔离PG全mirror竞态通过;覆盖首批回调、续载失败与筛选分页。
507 lines
19 KiB
TypeScript
507 lines
19 KiB
TypeScript
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
|
||
}
|