@echothink-ui/quality 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/components/QCDecisionBadge.d.ts +6 -0
- package/dist/components/QCJobMonitor.d.ts +14 -0
- package/dist/components/QCMetricsInspector.d.ts +21 -0
- package/dist/components/QCProfileEditor.d.ts +18 -0
- package/dist/components/QCRoutingTimeline.d.ts +11 -0
- package/dist/index.cjs +371 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +329 -0
- package/dist/index.js.map +1 -0
- package/package.json +42 -0
- package/src/components/QCDecisionBadge.tsx +34 -0
- package/src/components/QCJobMonitor.tsx +77 -0
- package/src/components/QCMetricsInspector.tsx +108 -0
- package/src/components/QCProfileEditor.tsx +155 -0
- package/src/components/QCRoutingTimeline.tsx +35 -0
- package/src/index.tsx +14 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// src/components/QCMetricsInspector.tsx
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import clsx from "clsx";
|
|
4
|
+
import { Badge, StatusDot, Surface } from "@echothink-ui/core";
|
|
5
|
+
import { DataTable } from "@echothink-ui/data";
|
|
6
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
function QCMetricsInspector({ metrics, decision, className }) {
|
|
8
|
+
const columns = React.useMemo(
|
|
9
|
+
() => [
|
|
10
|
+
{ key: "label", header: "Metric" },
|
|
11
|
+
{
|
|
12
|
+
key: "value",
|
|
13
|
+
header: "Value",
|
|
14
|
+
render: (metric) => `${metric.value}${metric.unit ? ` ${metric.unit}` : ""}`
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
key: "threshold",
|
|
18
|
+
header: "Threshold",
|
|
19
|
+
render: (metric) => thresholdLabel(metric.threshold)
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
key: "status",
|
|
23
|
+
header: "Status",
|
|
24
|
+
render: (metric) => /* @__PURE__ */ jsx(StatusDot, { status: metricStatus(metric.status), label: metricStatusLabel(metric.status) })
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
[]
|
|
28
|
+
);
|
|
29
|
+
return /* @__PURE__ */ jsxs(
|
|
30
|
+
Surface,
|
|
31
|
+
{
|
|
32
|
+
className: clsx("eth-quality-qc-metrics-inspector", className),
|
|
33
|
+
title: "QC metrics",
|
|
34
|
+
subtitle: decision ? "Automated decision available" : void 0,
|
|
35
|
+
"data-eth-component": "QCMetricsInspector",
|
|
36
|
+
children: [
|
|
37
|
+
decision ? /* @__PURE__ */ jsxs("div", { className: "eth-quality-qc-metrics-inspector__decision", children: [
|
|
38
|
+
/* @__PURE__ */ jsx(Badge, { severity: decisionSeverity(decision.status), children: decisionLabel(decision.status) }),
|
|
39
|
+
decision.reasons?.length ? /* @__PURE__ */ jsx("ul", { children: decision.reasons.map((reason) => /* @__PURE__ */ jsx("li", { children: reason }, reason)) }) : null
|
|
40
|
+
] }) : null,
|
|
41
|
+
/* @__PURE__ */ jsx(DataTable, { rows: metrics, columns })
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
function thresholdLabel(threshold) {
|
|
47
|
+
if (!threshold) return "-";
|
|
48
|
+
if (threshold.min !== void 0 && threshold.max !== void 0) {
|
|
49
|
+
return `${threshold.min} - ${threshold.max}`;
|
|
50
|
+
}
|
|
51
|
+
if (threshold.min !== void 0) return `>= ${threshold.min}`;
|
|
52
|
+
if (threshold.max !== void 0) return `<= ${threshold.max}`;
|
|
53
|
+
return "-";
|
|
54
|
+
}
|
|
55
|
+
function metricStatus(status) {
|
|
56
|
+
if (status === "pass") return "succeeded";
|
|
57
|
+
if (status === "fail") return "failed";
|
|
58
|
+
if (status === "warning") return "warning";
|
|
59
|
+
return "approval-required";
|
|
60
|
+
}
|
|
61
|
+
function metricStatusLabel(status) {
|
|
62
|
+
if (status === "manual-review") return "Manual review";
|
|
63
|
+
return status.charAt(0).toUpperCase() + status.slice(1);
|
|
64
|
+
}
|
|
65
|
+
function decisionSeverity(status) {
|
|
66
|
+
if (status === "pass") return "success";
|
|
67
|
+
if (status === "fail") return "danger";
|
|
68
|
+
return "warning";
|
|
69
|
+
}
|
|
70
|
+
function decisionLabel(status) {
|
|
71
|
+
if (status === "manual-review-required") return "Manual review required";
|
|
72
|
+
return status.charAt(0).toUpperCase() + status.slice(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// src/components/QCProfileEditor.tsx
|
|
76
|
+
import * as React2 from "react";
|
|
77
|
+
import clsx2 from "clsx";
|
|
78
|
+
import { ConfigForm, RuleBuilder } from "@echothink-ui/forms";
|
|
79
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
80
|
+
var operatorOptions = [
|
|
81
|
+
{ value: "lt", label: "Less than" },
|
|
82
|
+
{ value: "lte", label: "Less than or equal" },
|
|
83
|
+
{ value: "gt", label: "Greater than" },
|
|
84
|
+
{ value: "gte", label: "Greater than or equal" },
|
|
85
|
+
{ value: "eq", label: "Equals" },
|
|
86
|
+
{ value: "between", label: "Between" }
|
|
87
|
+
];
|
|
88
|
+
function QCProfileEditor({ profile, onChange, onSubmit, className }) {
|
|
89
|
+
const values = React2.useMemo(() => valuesFromProfile(profile), [profile]);
|
|
90
|
+
const rules = React2.useMemo(() => rulesFromProfile(profile), [profile]);
|
|
91
|
+
return /* @__PURE__ */ jsxs2("div", { className: clsx2("eth-quality-qc-profile-editor", className), "data-eth-component": "QCProfileEditor", children: [
|
|
92
|
+
/* @__PURE__ */ jsx2(
|
|
93
|
+
ConfigForm,
|
|
94
|
+
{
|
|
95
|
+
title: "QC profile",
|
|
96
|
+
description: `Profile ${profile.id}`,
|
|
97
|
+
sections: [
|
|
98
|
+
{
|
|
99
|
+
id: "profile",
|
|
100
|
+
title: "Thresholds",
|
|
101
|
+
description: "Language and threshold values used by the automated QC job.",
|
|
102
|
+
fields: [
|
|
103
|
+
{
|
|
104
|
+
name: "language",
|
|
105
|
+
type: "text",
|
|
106
|
+
label: "Language",
|
|
107
|
+
required: true
|
|
108
|
+
},
|
|
109
|
+
...profile.thresholds.flatMap((threshold) => [
|
|
110
|
+
{
|
|
111
|
+
name: operatorKey(threshold.id),
|
|
112
|
+
type: "select",
|
|
113
|
+
label: `${threshold.label} operator`,
|
|
114
|
+
options: operatorOptions
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: valueKey(threshold.id),
|
|
118
|
+
type: typeof threshold.value === "number" ? "number" : "text",
|
|
119
|
+
label: `${threshold.label} value`
|
|
120
|
+
}
|
|
121
|
+
])
|
|
122
|
+
]
|
|
123
|
+
}
|
|
124
|
+
],
|
|
125
|
+
values,
|
|
126
|
+
submitLabel: "Save profile",
|
|
127
|
+
onChange: (nextValues) => onChange?.(profileFromValues(profile, nextValues)),
|
|
128
|
+
onSubmit: (nextValues) => onSubmit?.(profileFromValues(profile, nextValues))
|
|
129
|
+
}
|
|
130
|
+
),
|
|
131
|
+
/* @__PURE__ */ jsx2(
|
|
132
|
+
RuleBuilder,
|
|
133
|
+
{
|
|
134
|
+
className: "eth-quality-qc-profile-editor__rules",
|
|
135
|
+
rules,
|
|
136
|
+
variables: profile.thresholds.map((threshold) => threshold.id),
|
|
137
|
+
onChange: (nextRules) => onChange?.(profileFromRules(profile, nextRules))
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
] });
|
|
141
|
+
}
|
|
142
|
+
function valuesFromProfile(profile) {
|
|
143
|
+
return profile.thresholds.reduce(
|
|
144
|
+
(values, threshold) => ({
|
|
145
|
+
...values,
|
|
146
|
+
[operatorKey(threshold.id)]: threshold.operator,
|
|
147
|
+
[valueKey(threshold.id)]: threshold.value
|
|
148
|
+
}),
|
|
149
|
+
{ language: profile.language }
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
function profileFromValues(profile, values) {
|
|
153
|
+
return {
|
|
154
|
+
...profile,
|
|
155
|
+
language: String(values.language ?? ""),
|
|
156
|
+
thresholds: profile.thresholds.map((threshold) => ({
|
|
157
|
+
...threshold,
|
|
158
|
+
operator: String(values[operatorKey(threshold.id)] ?? threshold.operator),
|
|
159
|
+
value: normalizeThresholdValue(threshold.value, values[valueKey(threshold.id)])
|
|
160
|
+
}))
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function rulesFromProfile(profile) {
|
|
164
|
+
return profile.thresholds.map((threshold) => ({
|
|
165
|
+
id: threshold.id,
|
|
166
|
+
when: {
|
|
167
|
+
kind: "leaf",
|
|
168
|
+
field: threshold.id,
|
|
169
|
+
op: threshold.operator,
|
|
170
|
+
value: threshold.value
|
|
171
|
+
},
|
|
172
|
+
then: {
|
|
173
|
+
type: "require-approval",
|
|
174
|
+
target: "manual-review"
|
|
175
|
+
}
|
|
176
|
+
}));
|
|
177
|
+
}
|
|
178
|
+
function profileFromRules(profile, rules) {
|
|
179
|
+
return {
|
|
180
|
+
...profile,
|
|
181
|
+
thresholds: profile.thresholds.map((threshold) => {
|
|
182
|
+
const rule = rules.find((item) => item.id === threshold.id);
|
|
183
|
+
if (!rule) return threshold;
|
|
184
|
+
return {
|
|
185
|
+
...threshold,
|
|
186
|
+
operator: rule.when.op ?? threshold.operator,
|
|
187
|
+
value: normalizeThresholdValue(threshold.value, rule.when.value)
|
|
188
|
+
};
|
|
189
|
+
})
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
function normalizeThresholdValue(previousValue, nextValue) {
|
|
193
|
+
if (typeof previousValue === "number") {
|
|
194
|
+
const numeric = Number(nextValue);
|
|
195
|
+
return Number.isNaN(numeric) ? previousValue : numeric;
|
|
196
|
+
}
|
|
197
|
+
return String(nextValue ?? "");
|
|
198
|
+
}
|
|
199
|
+
function operatorKey(id) {
|
|
200
|
+
return `${id}.operator`;
|
|
201
|
+
}
|
|
202
|
+
function valueKey(id) {
|
|
203
|
+
return `${id}.value`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// src/components/QCJobMonitor.tsx
|
|
207
|
+
import * as React3 from "react";
|
|
208
|
+
import clsx3 from "clsx";
|
|
209
|
+
import { StatusDot as StatusDot2, Surface as Surface2 } from "@echothink-ui/core";
|
|
210
|
+
import { JobQueuePanel } from "@echothink-ui/admin";
|
|
211
|
+
import { DataTable as DataTable2 } from "@echothink-ui/data";
|
|
212
|
+
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
213
|
+
function QCJobMonitor({ jobs, className }) {
|
|
214
|
+
const columns = React3.useMemo(
|
|
215
|
+
() => [
|
|
216
|
+
{ key: "recordingId", header: "Recording" },
|
|
217
|
+
{
|
|
218
|
+
key: "status",
|
|
219
|
+
header: "Status",
|
|
220
|
+
render: (job) => /* @__PURE__ */ jsx3(StatusDot2, { status: job.status, label: job.status })
|
|
221
|
+
},
|
|
222
|
+
{ key: "queuedAt", header: "Queued" },
|
|
223
|
+
{ key: "durationMs", header: "Duration", render: (job) => formatDuration(job.durationMs) },
|
|
224
|
+
{ key: "profile", header: "Profile", render: (job) => job.profile ?? "default" }
|
|
225
|
+
],
|
|
226
|
+
[]
|
|
227
|
+
);
|
|
228
|
+
return /* @__PURE__ */ jsxs3(
|
|
229
|
+
Surface2,
|
|
230
|
+
{
|
|
231
|
+
className: clsx3("eth-quality-qc-job-monitor", className),
|
|
232
|
+
title: "QC job monitor",
|
|
233
|
+
"data-eth-component": "QCJobMonitor",
|
|
234
|
+
children: [
|
|
235
|
+
/* @__PURE__ */ jsx3(JobQueuePanel, { queues: queuesFromJobs(jobs) }),
|
|
236
|
+
/* @__PURE__ */ jsx3(DataTable2, { rows: jobs, columns })
|
|
237
|
+
]
|
|
238
|
+
}
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
function queuesFromJobs(jobs) {
|
|
242
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
243
|
+
for (const job of jobs) {
|
|
244
|
+
const key = job.profile ?? "default";
|
|
245
|
+
grouped.set(key, [...grouped.get(key) ?? [], job]);
|
|
246
|
+
}
|
|
247
|
+
return Array.from(grouped.entries()).map(([profile, profileJobs]) => ({
|
|
248
|
+
id: profile,
|
|
249
|
+
name: `${profile} QC`,
|
|
250
|
+
depth: profileJobs.filter((job) => job.status === "queued").length,
|
|
251
|
+
processing: profileJobs.filter((job) => job.status === "running" || job.status === "in-progress").length,
|
|
252
|
+
failed: profileJobs.filter((job) => job.status === "failed").length,
|
|
253
|
+
retryRate: retryRate(profileJobs)
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
function retryRate(jobs) {
|
|
257
|
+
if (!jobs.length) return 0;
|
|
258
|
+
return Math.round(jobs.filter((job) => job.status === "failed").length / jobs.length * 100);
|
|
259
|
+
}
|
|
260
|
+
function formatDuration(durationMs) {
|
|
261
|
+
if (durationMs === void 0) return "-";
|
|
262
|
+
if (durationMs < 1e3) return `${durationMs}ms`;
|
|
263
|
+
const seconds = Math.round(durationMs / 1e3);
|
|
264
|
+
return `${seconds}s`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// src/components/QCDecisionBadge.tsx
|
|
268
|
+
import clsx4 from "clsx";
|
|
269
|
+
import { Badge as Badge2 } from "@echothink-ui/core";
|
|
270
|
+
import { jsx as jsx4 } from "react/jsx-runtime";
|
|
271
|
+
function QCDecisionBadge({ decision, reason, className }) {
|
|
272
|
+
return /* @__PURE__ */ jsx4(
|
|
273
|
+
"span",
|
|
274
|
+
{
|
|
275
|
+
className: clsx4("eth-quality-qc-decision-badge", className),
|
|
276
|
+
title: reason,
|
|
277
|
+
"data-eth-component": "QCDecisionBadge",
|
|
278
|
+
children: /* @__PURE__ */ jsx4(Badge2, { severity: decisionSeverity2(decision), children: decisionLabel2(decision) })
|
|
279
|
+
}
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
function decisionSeverity2(decision) {
|
|
283
|
+
if (decision === "pass") return "success";
|
|
284
|
+
if (decision === "fail") return "danger";
|
|
285
|
+
if (decision === "overridden") return "info";
|
|
286
|
+
return "warning";
|
|
287
|
+
}
|
|
288
|
+
function decisionLabel2(decision) {
|
|
289
|
+
if (decision === "manual-review-required") return "Manual review required";
|
|
290
|
+
return decision.charAt(0).toUpperCase() + decision.slice(1);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// src/components/QCRoutingTimeline.tsx
|
|
294
|
+
import clsx5 from "clsx";
|
|
295
|
+
import { Badge as Badge3, Surface as Surface3 } from "@echothink-ui/core";
|
|
296
|
+
import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
297
|
+
function QCRoutingTimeline({ events, className }) {
|
|
298
|
+
return /* @__PURE__ */ jsx5(
|
|
299
|
+
Surface3,
|
|
300
|
+
{
|
|
301
|
+
className: clsx5("eth-quality-qc-routing-timeline", className),
|
|
302
|
+
title: "Routing timeline",
|
|
303
|
+
"data-eth-component": "QCRoutingTimeline",
|
|
304
|
+
children: /* @__PURE__ */ jsx5("ol", { className: "eth-quality-qc-routing-timeline__events", children: events.map((event) => /* @__PURE__ */ jsxs4("li", { children: [
|
|
305
|
+
/* @__PURE__ */ jsx5("time", { children: event.at }),
|
|
306
|
+
/* @__PURE__ */ jsx5(Badge3, { severity: event.severity ?? "info", children: event.severity ?? "info" }),
|
|
307
|
+
/* @__PURE__ */ jsx5("span", { children: event.label })
|
|
308
|
+
] }, `${event.at}-${event.label}`)) })
|
|
309
|
+
}
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// src/index.tsx
|
|
314
|
+
var QualityComponentNames = [
|
|
315
|
+
"QCMetricsInspector",
|
|
316
|
+
"QCProfileEditor",
|
|
317
|
+
"QCJobMonitor",
|
|
318
|
+
"QCDecisionBadge",
|
|
319
|
+
"QCRoutingTimeline"
|
|
320
|
+
];
|
|
321
|
+
export {
|
|
322
|
+
QCDecisionBadge,
|
|
323
|
+
QCJobMonitor,
|
|
324
|
+
QCMetricsInspector,
|
|
325
|
+
QCProfileEditor,
|
|
326
|
+
QCRoutingTimeline,
|
|
327
|
+
QualityComponentNames
|
|
328
|
+
};
|
|
329
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/components/QCMetricsInspector.tsx","../src/components/QCProfileEditor.tsx","../src/components/QCJobMonitor.tsx","../src/components/QCDecisionBadge.tsx","../src/components/QCRoutingTimeline.tsx","../src/index.tsx"],"sourcesContent":["import * as React from \"react\";\nimport clsx from \"clsx\";\nimport { Badge, StatusDot, Surface } from \"@echothink-ui/core\";\nimport type { EthOperationalStatus, EthSeverity } from \"@echothink-ui/core\";\nimport { DataTable } from \"@echothink-ui/data\";\nimport type { DataColumn } from \"@echothink-ui/data\";\n\nexport interface QCMetric extends Record<string, unknown> {\n id: string;\n label: string;\n value: number | string;\n unit?: string;\n threshold?: { min?: number; max?: number };\n status: \"pass\" | \"fail\" | \"warning\" | \"manual-review\";\n}\n\nexport interface QCDecision {\n status: \"pass\" | \"fail\" | \"manual-review-required\";\n reasons?: string[];\n}\n\nexport interface QCMetricsInspectorProps {\n metrics: QCMetric[];\n decision?: QCDecision;\n className?: string;\n}\n\nexport function QCMetricsInspector({ metrics, decision, className }: QCMetricsInspectorProps) {\n const columns = React.useMemo<DataColumn<QCMetric>[]>(\n () => [\n { key: \"label\", header: \"Metric\" },\n {\n key: \"value\",\n header: \"Value\",\n render: (metric) => `${metric.value}${metric.unit ? ` ${metric.unit}` : \"\"}`\n },\n {\n key: \"threshold\",\n header: \"Threshold\",\n render: (metric) => thresholdLabel(metric.threshold)\n },\n {\n key: \"status\",\n header: \"Status\",\n render: (metric) => (\n <StatusDot status={metricStatus(metric.status)} label={metricStatusLabel(metric.status)} />\n )\n }\n ],\n []\n );\n\n return (\n <Surface\n className={clsx(\"eth-quality-qc-metrics-inspector\", className)}\n title=\"QC metrics\"\n subtitle={decision ? \"Automated decision available\" : undefined}\n data-eth-component=\"QCMetricsInspector\"\n >\n {decision ? (\n <div className=\"eth-quality-qc-metrics-inspector__decision\">\n <Badge severity={decisionSeverity(decision.status)}>{decisionLabel(decision.status)}</Badge>\n {decision.reasons?.length ? (\n <ul>\n {decision.reasons.map((reason) => (\n <li key={reason}>{reason}</li>\n ))}\n </ul>\n ) : null}\n </div>\n ) : null}\n <DataTable rows={metrics} columns={columns} />\n </Surface>\n );\n}\n\nfunction thresholdLabel(threshold?: QCMetric[\"threshold\"]) {\n if (!threshold) return \"-\";\n if (threshold.min !== undefined && threshold.max !== undefined) {\n return `${threshold.min} - ${threshold.max}`;\n }\n if (threshold.min !== undefined) return `>= ${threshold.min}`;\n if (threshold.max !== undefined) return `<= ${threshold.max}`;\n return \"-\";\n}\n\nfunction metricStatus(status: QCMetric[\"status\"]): EthOperationalStatus {\n if (status === \"pass\") return \"succeeded\";\n if (status === \"fail\") return \"failed\";\n if (status === \"warning\") return \"warning\";\n return \"approval-required\";\n}\n\nfunction metricStatusLabel(status: QCMetric[\"status\"]) {\n if (status === \"manual-review\") return \"Manual review\";\n return status.charAt(0).toUpperCase() + status.slice(1);\n}\n\nfunction decisionSeverity(status: QCDecision[\"status\"]): EthSeverity {\n if (status === \"pass\") return \"success\";\n if (status === \"fail\") return \"danger\";\n return \"warning\";\n}\n\nfunction decisionLabel(status: QCDecision[\"status\"]) {\n if (status === \"manual-review-required\") return \"Manual review required\";\n return status.charAt(0).toUpperCase() + status.slice(1);\n}\n","import * as React from \"react\";\nimport clsx from \"clsx\";\nimport { ConfigForm, RuleBuilder } from \"@echothink-ui/forms\";\nimport type { FormValues, RuleDefinition } from \"@echothink-ui/forms\";\n\nexport interface QCThreshold {\n id: string;\n label: string;\n operator: string;\n value: number | string;\n}\n\nexport interface QCProfile {\n id: string;\n language: string;\n thresholds: QCThreshold[];\n}\n\nexport interface QCProfileEditorProps {\n profile: QCProfile;\n onChange?: (profile: QCProfile) => void;\n onSubmit?: (profile: QCProfile) => void;\n className?: string;\n}\n\nconst operatorOptions = [\n { value: \"lt\", label: \"Less than\" },\n { value: \"lte\", label: \"Less than or equal\" },\n { value: \"gt\", label: \"Greater than\" },\n { value: \"gte\", label: \"Greater than or equal\" },\n { value: \"eq\", label: \"Equals\" },\n { value: \"between\", label: \"Between\" }\n];\n\nexport function QCProfileEditor({ profile, onChange, onSubmit, className }: QCProfileEditorProps) {\n const values = React.useMemo(() => valuesFromProfile(profile), [profile]);\n const rules = React.useMemo(() => rulesFromProfile(profile), [profile]);\n\n return (\n <div className={clsx(\"eth-quality-qc-profile-editor\", className)} data-eth-component=\"QCProfileEditor\">\n <ConfigForm\n title=\"QC profile\"\n description={`Profile ${profile.id}`}\n sections={[\n {\n id: \"profile\",\n title: \"Thresholds\",\n description: \"Language and threshold values used by the automated QC job.\",\n fields: [\n {\n name: \"language\",\n type: \"text\",\n label: \"Language\",\n required: true\n },\n ...profile.thresholds.flatMap((threshold) => [\n {\n name: operatorKey(threshold.id),\n type: \"select\" as const,\n label: `${threshold.label} operator`,\n options: operatorOptions\n },\n {\n name: valueKey(threshold.id),\n type: typeof threshold.value === \"number\" ? (\"number\" as const) : (\"text\" as const),\n label: `${threshold.label} value`\n }\n ])\n ]\n }\n ]}\n values={values}\n submitLabel=\"Save profile\"\n onChange={(nextValues) => onChange?.(profileFromValues(profile, nextValues))}\n onSubmit={(nextValues) => onSubmit?.(profileFromValues(profile, nextValues))}\n />\n <RuleBuilder\n className=\"eth-quality-qc-profile-editor__rules\"\n rules={rules}\n variables={profile.thresholds.map((threshold) => threshold.id)}\n onChange={(nextRules) => onChange?.(profileFromRules(profile, nextRules))}\n />\n </div>\n );\n}\n\nfunction valuesFromProfile(profile: QCProfile): FormValues {\n return profile.thresholds.reduce<FormValues>(\n (values, threshold) => ({\n ...values,\n [operatorKey(threshold.id)]: threshold.operator,\n [valueKey(threshold.id)]: threshold.value\n }),\n { language: profile.language }\n );\n}\n\nfunction profileFromValues(profile: QCProfile, values: FormValues): QCProfile {\n return {\n ...profile,\n language: String(values.language ?? \"\"),\n thresholds: profile.thresholds.map((threshold) => ({\n ...threshold,\n operator: String(values[operatorKey(threshold.id)] ?? threshold.operator),\n value: normalizeThresholdValue(threshold.value, values[valueKey(threshold.id)])\n }))\n };\n}\n\nfunction rulesFromProfile(profile: QCProfile): RuleDefinition[] {\n return profile.thresholds.map((threshold) => ({\n id: threshold.id,\n when: {\n kind: \"leaf\",\n field: threshold.id,\n op: threshold.operator,\n value: threshold.value\n },\n then: {\n type: \"require-approval\",\n target: \"manual-review\"\n }\n }));\n}\n\nfunction profileFromRules(profile: QCProfile, rules: RuleDefinition[]): QCProfile {\n return {\n ...profile,\n thresholds: profile.thresholds.map((threshold) => {\n const rule = rules.find((item) => item.id === threshold.id);\n if (!rule) return threshold;\n return {\n ...threshold,\n operator: rule.when.op ?? threshold.operator,\n value: normalizeThresholdValue(threshold.value, rule.when.value)\n };\n })\n };\n}\n\nfunction normalizeThresholdValue(previousValue: string | number, nextValue: unknown) {\n if (typeof previousValue === \"number\") {\n const numeric = Number(nextValue);\n return Number.isNaN(numeric) ? previousValue : numeric;\n }\n return String(nextValue ?? \"\");\n}\n\nfunction operatorKey(id: string) {\n return `${id}.operator`;\n}\n\nfunction valueKey(id: string) {\n return `${id}.value`;\n}\n","import * as React from \"react\";\nimport clsx from \"clsx\";\nimport { StatusDot, Surface } from \"@echothink-ui/core\";\nimport type { EthOperationalStatus } from \"@echothink-ui/core\";\nimport { JobQueuePanel } from \"@echothink-ui/admin\";\nimport { DataTable } from \"@echothink-ui/data\";\nimport type { DataColumn } from \"@echothink-ui/data\";\n\nexport interface QCJob extends Record<string, unknown> {\n id: string;\n recordingId: string;\n status: EthOperationalStatus;\n queuedAt: string;\n durationMs?: number;\n profile?: string;\n}\n\nexport interface QCJobMonitorProps {\n jobs: QCJob[];\n className?: string;\n}\n\nexport function QCJobMonitor({ jobs, className }: QCJobMonitorProps) {\n const columns = React.useMemo<DataColumn<QCJob>[]>(\n () => [\n { key: \"recordingId\", header: \"Recording\" },\n {\n key: \"status\",\n header: \"Status\",\n render: (job) => <StatusDot status={job.status} label={job.status} />\n },\n { key: \"queuedAt\", header: \"Queued\" },\n { key: \"durationMs\", header: \"Duration\", render: (job) => formatDuration(job.durationMs) },\n { key: \"profile\", header: \"Profile\", render: (job) => job.profile ?? \"default\" }\n ],\n []\n );\n\n return (\n <Surface\n className={clsx(\"eth-quality-qc-job-monitor\", className)}\n title=\"QC job monitor\"\n data-eth-component=\"QCJobMonitor\"\n >\n <JobQueuePanel queues={queuesFromJobs(jobs)} />\n <DataTable rows={jobs} columns={columns} />\n </Surface>\n );\n}\n\nfunction queuesFromJobs(jobs: QCJob[]) {\n const grouped = new Map<string, QCJob[]>();\n for (const job of jobs) {\n const key = job.profile ?? \"default\";\n grouped.set(key, [...(grouped.get(key) ?? []), job]);\n }\n return Array.from(grouped.entries()).map(([profile, profileJobs]) => ({\n id: profile,\n name: `${profile} QC`,\n depth: profileJobs.filter((job) => job.status === \"queued\").length,\n processing: profileJobs.filter((job) => job.status === \"running\" || job.status === \"in-progress\").length,\n failed: profileJobs.filter((job) => job.status === \"failed\").length,\n retryRate: retryRate(profileJobs)\n }));\n}\n\nfunction retryRate(jobs: QCJob[]) {\n if (!jobs.length) return 0;\n return Math.round((jobs.filter((job) => job.status === \"failed\").length / jobs.length) * 100);\n}\n\nfunction formatDuration(durationMs?: number) {\n if (durationMs === undefined) return \"-\";\n if (durationMs < 1000) return `${durationMs}ms`;\n const seconds = Math.round(durationMs / 1000);\n return `${seconds}s`;\n}\n","import * as React from \"react\";\nimport clsx from \"clsx\";\nimport { Badge } from \"@echothink-ui/core\";\nimport type { EthSeverity } from \"@echothink-ui/core\";\n\nexport interface QCDecisionBadgeProps {\n decision: \"pass\" | \"fail\" | \"manual-review-required\" | \"overridden\";\n reason?: string;\n className?: string;\n}\n\nexport function QCDecisionBadge({ decision, reason, className }: QCDecisionBadgeProps) {\n return (\n <span\n className={clsx(\"eth-quality-qc-decision-badge\", className)}\n title={reason}\n data-eth-component=\"QCDecisionBadge\"\n >\n <Badge severity={decisionSeverity(decision)}>{decisionLabel(decision)}</Badge>\n </span>\n );\n}\n\nfunction decisionSeverity(decision: QCDecisionBadgeProps[\"decision\"]): EthSeverity {\n if (decision === \"pass\") return \"success\";\n if (decision === \"fail\") return \"danger\";\n if (decision === \"overridden\") return \"info\";\n return \"warning\";\n}\n\nfunction decisionLabel(decision: QCDecisionBadgeProps[\"decision\"]) {\n if (decision === \"manual-review-required\") return \"Manual review required\";\n return decision.charAt(0).toUpperCase() + decision.slice(1);\n}\n","import * as React from \"react\";\nimport clsx from \"clsx\";\nimport { Badge, Surface } from \"@echothink-ui/core\";\nimport type { EthSeverity } from \"@echothink-ui/core\";\n\nexport interface QCRoutingEvent {\n at: string;\n label: string;\n severity?: EthSeverity;\n}\n\nexport interface QCRoutingTimelineProps {\n events: QCRoutingEvent[];\n className?: string;\n}\n\nexport function QCRoutingTimeline({ events, className }: QCRoutingTimelineProps) {\n return (\n <Surface\n className={clsx(\"eth-quality-qc-routing-timeline\", className)}\n title=\"Routing timeline\"\n data-eth-component=\"QCRoutingTimeline\"\n >\n <ol className=\"eth-quality-qc-routing-timeline__events\">\n {events.map((event) => (\n <li key={`${event.at}-${event.label}`}>\n <time>{event.at}</time>\n <Badge severity={event.severity ?? \"info\"}>{event.severity ?? \"info\"}</Badge>\n <span>{event.label}</span>\n </li>\n ))}\n </ol>\n </Surface>\n );\n}\n","export * from \"./components/QCMetricsInspector\";\nexport * from \"./components/QCProfileEditor\";\nexport * from \"./components/QCJobMonitor\";\nexport * from \"./components/QCDecisionBadge\";\nexport * from \"./components/QCRoutingTimeline\";\n\nexport const QualityComponentNames = [\n \"QCMetricsInspector\",\n \"QCProfileEditor\",\n \"QCJobMonitor\",\n \"QCDecisionBadge\",\n \"QCRoutingTimeline\"\n] as const;\nexport type QualityComponentName = (typeof QualityComponentNames)[number];\n"],"mappings":"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","names":["React","clsx","jsx","jsxs","React","clsx","StatusDot","Surface","DataTable","jsx","jsxs","clsx","Badge","jsx","decisionSeverity","decisionLabel","clsx","Badge","Surface","jsx","jsxs"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@echothink-ui/quality",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"main": "./dist/index.cjs",
|
|
8
|
+
"module": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"require": "./dist/index.cjs"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"src",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"react": ">=18.3.0",
|
|
24
|
+
"react-dom": ">=18.3.0"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"clsx": "^2.1.1",
|
|
28
|
+
"@echothink-ui/data": "0.2.0",
|
|
29
|
+
"@echothink-ui/core": "0.2.0",
|
|
30
|
+
"@echothink-ui/forms": "0.2.0",
|
|
31
|
+
"@echothink-ui/admin": "0.1.0"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsup src/index.tsx --format esm,cjs --sourcemap --clean --external react --external react-dom && tsc -p tsconfig.json --declaration --emitDeclarationOnly --noEmit false --outDir dist",
|
|
38
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
39
|
+
"test": "vitest run --config ../../vitest.config.ts --passWithNoTests",
|
|
40
|
+
"lint": "eslint src"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import { Badge } from "@echothink-ui/core";
|
|
4
|
+
import type { EthSeverity } from "@echothink-ui/core";
|
|
5
|
+
|
|
6
|
+
export interface QCDecisionBadgeProps {
|
|
7
|
+
decision: "pass" | "fail" | "manual-review-required" | "overridden";
|
|
8
|
+
reason?: string;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function QCDecisionBadge({ decision, reason, className }: QCDecisionBadgeProps) {
|
|
13
|
+
return (
|
|
14
|
+
<span
|
|
15
|
+
className={clsx("eth-quality-qc-decision-badge", className)}
|
|
16
|
+
title={reason}
|
|
17
|
+
data-eth-component="QCDecisionBadge"
|
|
18
|
+
>
|
|
19
|
+
<Badge severity={decisionSeverity(decision)}>{decisionLabel(decision)}</Badge>
|
|
20
|
+
</span>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function decisionSeverity(decision: QCDecisionBadgeProps["decision"]): EthSeverity {
|
|
25
|
+
if (decision === "pass") return "success";
|
|
26
|
+
if (decision === "fail") return "danger";
|
|
27
|
+
if (decision === "overridden") return "info";
|
|
28
|
+
return "warning";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function decisionLabel(decision: QCDecisionBadgeProps["decision"]) {
|
|
32
|
+
if (decision === "manual-review-required") return "Manual review required";
|
|
33
|
+
return decision.charAt(0).toUpperCase() + decision.slice(1);
|
|
34
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import { StatusDot, Surface } from "@echothink-ui/core";
|
|
4
|
+
import type { EthOperationalStatus } from "@echothink-ui/core";
|
|
5
|
+
import { JobQueuePanel } from "@echothink-ui/admin";
|
|
6
|
+
import { DataTable } from "@echothink-ui/data";
|
|
7
|
+
import type { DataColumn } from "@echothink-ui/data";
|
|
8
|
+
|
|
9
|
+
export interface QCJob extends Record<string, unknown> {
|
|
10
|
+
id: string;
|
|
11
|
+
recordingId: string;
|
|
12
|
+
status: EthOperationalStatus;
|
|
13
|
+
queuedAt: string;
|
|
14
|
+
durationMs?: number;
|
|
15
|
+
profile?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface QCJobMonitorProps {
|
|
19
|
+
jobs: QCJob[];
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function QCJobMonitor({ jobs, className }: QCJobMonitorProps) {
|
|
24
|
+
const columns = React.useMemo<DataColumn<QCJob>[]>(
|
|
25
|
+
() => [
|
|
26
|
+
{ key: "recordingId", header: "Recording" },
|
|
27
|
+
{
|
|
28
|
+
key: "status",
|
|
29
|
+
header: "Status",
|
|
30
|
+
render: (job) => <StatusDot status={job.status} label={job.status} />
|
|
31
|
+
},
|
|
32
|
+
{ key: "queuedAt", header: "Queued" },
|
|
33
|
+
{ key: "durationMs", header: "Duration", render: (job) => formatDuration(job.durationMs) },
|
|
34
|
+
{ key: "profile", header: "Profile", render: (job) => job.profile ?? "default" }
|
|
35
|
+
],
|
|
36
|
+
[]
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Surface
|
|
41
|
+
className={clsx("eth-quality-qc-job-monitor", className)}
|
|
42
|
+
title="QC job monitor"
|
|
43
|
+
data-eth-component="QCJobMonitor"
|
|
44
|
+
>
|
|
45
|
+
<JobQueuePanel queues={queuesFromJobs(jobs)} />
|
|
46
|
+
<DataTable rows={jobs} columns={columns} />
|
|
47
|
+
</Surface>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function queuesFromJobs(jobs: QCJob[]) {
|
|
52
|
+
const grouped = new Map<string, QCJob[]>();
|
|
53
|
+
for (const job of jobs) {
|
|
54
|
+
const key = job.profile ?? "default";
|
|
55
|
+
grouped.set(key, [...(grouped.get(key) ?? []), job]);
|
|
56
|
+
}
|
|
57
|
+
return Array.from(grouped.entries()).map(([profile, profileJobs]) => ({
|
|
58
|
+
id: profile,
|
|
59
|
+
name: `${profile} QC`,
|
|
60
|
+
depth: profileJobs.filter((job) => job.status === "queued").length,
|
|
61
|
+
processing: profileJobs.filter((job) => job.status === "running" || job.status === "in-progress").length,
|
|
62
|
+
failed: profileJobs.filter((job) => job.status === "failed").length,
|
|
63
|
+
retryRate: retryRate(profileJobs)
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function retryRate(jobs: QCJob[]) {
|
|
68
|
+
if (!jobs.length) return 0;
|
|
69
|
+
return Math.round((jobs.filter((job) => job.status === "failed").length / jobs.length) * 100);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatDuration(durationMs?: number) {
|
|
73
|
+
if (durationMs === undefined) return "-";
|
|
74
|
+
if (durationMs < 1000) return `${durationMs}ms`;
|
|
75
|
+
const seconds = Math.round(durationMs / 1000);
|
|
76
|
+
return `${seconds}s`;
|
|
77
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import { Badge, StatusDot, Surface } from "@echothink-ui/core";
|
|
4
|
+
import type { EthOperationalStatus, EthSeverity } from "@echothink-ui/core";
|
|
5
|
+
import { DataTable } from "@echothink-ui/data";
|
|
6
|
+
import type { DataColumn } from "@echothink-ui/data";
|
|
7
|
+
|
|
8
|
+
export interface QCMetric extends Record<string, unknown> {
|
|
9
|
+
id: string;
|
|
10
|
+
label: string;
|
|
11
|
+
value: number | string;
|
|
12
|
+
unit?: string;
|
|
13
|
+
threshold?: { min?: number; max?: number };
|
|
14
|
+
status: "pass" | "fail" | "warning" | "manual-review";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface QCDecision {
|
|
18
|
+
status: "pass" | "fail" | "manual-review-required";
|
|
19
|
+
reasons?: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface QCMetricsInspectorProps {
|
|
23
|
+
metrics: QCMetric[];
|
|
24
|
+
decision?: QCDecision;
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function QCMetricsInspector({ metrics, decision, className }: QCMetricsInspectorProps) {
|
|
29
|
+
const columns = React.useMemo<DataColumn<QCMetric>[]>(
|
|
30
|
+
() => [
|
|
31
|
+
{ key: "label", header: "Metric" },
|
|
32
|
+
{
|
|
33
|
+
key: "value",
|
|
34
|
+
header: "Value",
|
|
35
|
+
render: (metric) => `${metric.value}${metric.unit ? ` ${metric.unit}` : ""}`
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
key: "threshold",
|
|
39
|
+
header: "Threshold",
|
|
40
|
+
render: (metric) => thresholdLabel(metric.threshold)
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
key: "status",
|
|
44
|
+
header: "Status",
|
|
45
|
+
render: (metric) => (
|
|
46
|
+
<StatusDot status={metricStatus(metric.status)} label={metricStatusLabel(metric.status)} />
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
],
|
|
50
|
+
[]
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<Surface
|
|
55
|
+
className={clsx("eth-quality-qc-metrics-inspector", className)}
|
|
56
|
+
title="QC metrics"
|
|
57
|
+
subtitle={decision ? "Automated decision available" : undefined}
|
|
58
|
+
data-eth-component="QCMetricsInspector"
|
|
59
|
+
>
|
|
60
|
+
{decision ? (
|
|
61
|
+
<div className="eth-quality-qc-metrics-inspector__decision">
|
|
62
|
+
<Badge severity={decisionSeverity(decision.status)}>{decisionLabel(decision.status)}</Badge>
|
|
63
|
+
{decision.reasons?.length ? (
|
|
64
|
+
<ul>
|
|
65
|
+
{decision.reasons.map((reason) => (
|
|
66
|
+
<li key={reason}>{reason}</li>
|
|
67
|
+
))}
|
|
68
|
+
</ul>
|
|
69
|
+
) : null}
|
|
70
|
+
</div>
|
|
71
|
+
) : null}
|
|
72
|
+
<DataTable rows={metrics} columns={columns} />
|
|
73
|
+
</Surface>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function thresholdLabel(threshold?: QCMetric["threshold"]) {
|
|
78
|
+
if (!threshold) return "-";
|
|
79
|
+
if (threshold.min !== undefined && threshold.max !== undefined) {
|
|
80
|
+
return `${threshold.min} - ${threshold.max}`;
|
|
81
|
+
}
|
|
82
|
+
if (threshold.min !== undefined) return `>= ${threshold.min}`;
|
|
83
|
+
if (threshold.max !== undefined) return `<= ${threshold.max}`;
|
|
84
|
+
return "-";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function metricStatus(status: QCMetric["status"]): EthOperationalStatus {
|
|
88
|
+
if (status === "pass") return "succeeded";
|
|
89
|
+
if (status === "fail") return "failed";
|
|
90
|
+
if (status === "warning") return "warning";
|
|
91
|
+
return "approval-required";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function metricStatusLabel(status: QCMetric["status"]) {
|
|
95
|
+
if (status === "manual-review") return "Manual review";
|
|
96
|
+
return status.charAt(0).toUpperCase() + status.slice(1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function decisionSeverity(status: QCDecision["status"]): EthSeverity {
|
|
100
|
+
if (status === "pass") return "success";
|
|
101
|
+
if (status === "fail") return "danger";
|
|
102
|
+
return "warning";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function decisionLabel(status: QCDecision["status"]) {
|
|
106
|
+
if (status === "manual-review-required") return "Manual review required";
|
|
107
|
+
return status.charAt(0).toUpperCase() + status.slice(1);
|
|
108
|
+
}
|