Spaces:
Sleeping
Sleeping
feat(analytics): xếp hạng tool đóng góp nhiều/ít nhất; tạm ẩn bảng B2
Browse filesĐiểm đóng góp mỗi tool (etl.score_contribution), trọng số nghiệp vụ chốt:
40% nhu cầu người dùng — số việc người giao cho tool
20% ít phải bấm chạy lại — A4 nghịch đảo, làm 1 phát ăn ngay = điểm cao
40% sản phẩm đầu ra — B1
Mỗi thành phần quy về thang 100 theo tool cao nhất TRONG KỲ ĐANG LỌC, nên
điểm chỉ so được giữa các tool cùng khoảng thời gian. Tool chưa chạy việc
nào = 0 điểm.
Giao diện: thẻ "🏆 Tool đóng góp nhiều nhất / ít nhất" — 2 ô nổi bật + bảng
đủ 3 điểm thành phần (có thanh tỷ lệ) để đối chiếu vì sao ra thứ hạng đó.
Tạm ẩn (comment) bảng "Tốc độ máy mỗi bước (B2)" theo yêu cầu; backend vẫn
tính và trả data.jobs như cũ, muốn hiện lại chỉ cần bỏ comment.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
backend/app/services/analytics/etl.py
CHANGED
|
@@ -44,6 +44,13 @@ FEATURE_LABELS = {
|
|
| 44 |
# Event tính là "một lần bấm/hỏi" cho chỉ số A4.
|
| 45 |
ATTEMPT_EVENTS = {"job_done", "regenerate", "edit_request"}
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
# ── Tiện ích thống kê ────────────────────────────────────────────
|
| 49 |
|
|
@@ -279,6 +286,42 @@ def _bucket_metrics(tasks: list[dict]) -> dict:
|
|
| 279 |
}
|
| 280 |
|
| 281 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
def summarize(events: list[dict]) -> dict:
|
| 283 |
"""Toàn bộ số liệu cho tab "Quản lý thời lượng"."""
|
| 284 |
tasks = build_tasks(events)
|
|
@@ -349,6 +392,7 @@ def summarize(events: list[dict]) -> dict:
|
|
| 349 |
**_bucket_metrics(ft),
|
| 350 |
})
|
| 351 |
features.sort(key=lambda f: -f["tasks"])
|
|
|
|
| 352 |
|
| 353 |
# Tốc độ máy từng loại job (B2)
|
| 354 |
job_ms: dict[str, list[float]] = defaultdict(list)
|
|
@@ -427,6 +471,7 @@ def summarize(events: list[dict]) -> dict:
|
|
| 427 |
"jobs": jobs,
|
| 428 |
"products": product_rows,
|
| 429 |
"timeseries": timeseries,
|
|
|
|
| 430 |
}
|
| 431 |
|
| 432 |
|
|
|
|
| 44 |
# Event tính là "một lần bấm/hỏi" cho chỉ số A4.
|
| 45 |
ATTEMPT_EVENTS = {"job_done", "regenerate", "edit_request"}
|
| 46 |
|
| 47 |
+
# ĐIỂM ĐÓNG GÓP của mỗi tool (để xếp "đóng góp nhiều nhất / ít nhất").
|
| 48 |
+
# Trọng số do bên nghiệp vụ chốt:
|
| 49 |
+
# 40% nhu cầu người dùng — số việc người giao cho tool (càng nhiều càng cần)
|
| 50 |
+
# 20% ít phải bấm chạy lại — A4 càng THẤP điểm càng CAO (làm 1 phát ăn ngay)
|
| 51 |
+
# 40% sản phẩm đầu ra — B1, thứ thật sự mang lại giá trị
|
| 52 |
+
CONTRIB_WEIGHTS = {"demand": 0.40, "rerun": 0.20, "output": 0.40}
|
| 53 |
+
|
| 54 |
|
| 55 |
# ── Tiện ích thống kê ────────────────────────────────────────────
|
| 56 |
|
|
|
|
| 286 |
}
|
| 287 |
|
| 288 |
|
| 289 |
+
def score_contribution(features: list[dict]) -> None:
|
| 290 |
+
"""Chấm ĐIỂM ĐÓNG GÓP cho từng tool (ghi thẳng vào từng dict trong `features`).
|
| 291 |
+
|
| 292 |
+
Ba thành phần đều quy về thang 0-100 rồi nhân trọng số CONTRIB_WEIGHTS:
|
| 293 |
+
- demand: số việc / số việc của tool cao nhất kỳ này.
|
| 294 |
+
- rerun : số-bấm-ít-nhất / số-bấm-của-tool-này (nghịch đảo -> ít bấm = 100đ).
|
| 295 |
+
- output: sản phẩm / sản phẩm của tool cao nhất kỳ này.
|
| 296 |
+
|
| 297 |
+
Vì chuẩn hoá theo tool cao nhất TRONG KỲ ĐANG LỌC nên điểm chỉ dùng để SO SÁNH
|
| 298 |
+
GIỮA CÁC TOOL cùng khoảng thời gian, không so được giữa hai khoảng khác nhau.
|
| 299 |
+
Tool chưa chạy việc nào -> 0 điểm hết (chưa đóng góp gì thật).
|
| 300 |
+
"""
|
| 301 |
+
if not features:
|
| 302 |
+
return
|
| 303 |
+
max_tasks = max((f["tasks"] for f in features), default=0)
|
| 304 |
+
max_prod = max((f["produced_total"] for f in features), default=0)
|
| 305 |
+
# A4 chỉ có nghĩa với tool thật sự có chạy; sàn 1.0 = chạy đúng 1 lần ăn ngay.
|
| 306 |
+
atts = [max(1.0, f["attempts_avg"] or 1.0) for f in features if f["tasks"]]
|
| 307 |
+
min_att = min(atts) if atts else 1.0
|
| 308 |
+
for f in features:
|
| 309 |
+
if not f["tasks"]:
|
| 310 |
+
f.update({"score_demand": 0.0, "score_rerun": 0.0,
|
| 311 |
+
"score_output": 0.0, "score_total": 0.0})
|
| 312 |
+
continue
|
| 313 |
+
a = max(1.0, f["attempts_avg"] or 1.0)
|
| 314 |
+
d = (f["tasks"] / max_tasks * 100.0) if max_tasks else 0.0
|
| 315 |
+
r = (min_att / a) * 100.0
|
| 316 |
+
o = (f["produced_total"] / max_prod * 100.0) if max_prod else 0.0
|
| 317 |
+
f["score_demand"] = round(d, 1)
|
| 318 |
+
f["score_rerun"] = round(r, 1)
|
| 319 |
+
f["score_output"] = round(o, 1)
|
| 320 |
+
f["score_total"] = round(CONTRIB_WEIGHTS["demand"] * d
|
| 321 |
+
+ CONTRIB_WEIGHTS["rerun"] * r
|
| 322 |
+
+ CONTRIB_WEIGHTS["output"] * o, 1)
|
| 323 |
+
|
| 324 |
+
|
| 325 |
def summarize(events: list[dict]) -> dict:
|
| 326 |
"""Toàn bộ số liệu cho tab "Quản lý thời lượng"."""
|
| 327 |
tasks = build_tasks(events)
|
|
|
|
| 392 |
**_bucket_metrics(ft),
|
| 393 |
})
|
| 394 |
features.sort(key=lambda f: -f["tasks"])
|
| 395 |
+
score_contribution(features) # điểm đóng góp nhiều/ít nhất
|
| 396 |
|
| 397 |
# Tốc độ máy từng loại job (B2)
|
| 398 |
job_ms: dict[str, list[float]] = defaultdict(list)
|
|
|
|
| 471 |
"jobs": jobs,
|
| 472 |
"products": product_rows,
|
| 473 |
"timeseries": timeseries,
|
| 474 |
+
"contrib_weights": CONTRIB_WEIGHTS,
|
| 475 |
}
|
| 476 |
|
| 477 |
|
backend/tests/test_analytics.py
CHANGED
|
@@ -186,6 +186,46 @@ def test_job_speed_split_by_phase():
|
|
| 186 |
assert phases["tts"] == 59000
|
| 187 |
|
| 188 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
def test_passive_pages_are_not_tasks():
|
| 190 |
"""page_view (trang chủ/hướng dẫn) không có task_id -> không tính vào A3."""
|
| 191 |
evs = _one_full_task() + [
|
|
|
|
| 186 |
assert phases["tts"] == 59000
|
| 187 |
|
| 188 |
|
| 189 |
+
def test_contribution_score_ranks_tools():
|
| 190 |
+
"""Điểm đóng góp = 40% nhu cầu + 20% ít bấm chạy lại + 40% sản phẩm đầu ra.
|
| 191 |
+
|
| 192 |
+
Tool A: 2 việc, 4 sản phẩm, mỗi việc bấm 1 lần -> phải đứng trên
|
| 193 |
+
Tool B: 1 việc, 1 sản phẩm, bấm 2 lần/việc.
|
| 194 |
+
"""
|
| 195 |
+
evs = []
|
| 196 |
+
for i in (1, 2): # cut: 2 việc, mỗi việc 1 lần bấm, 2 sản phẩm
|
| 197 |
+
evs += [
|
| 198 |
+
_ev(i * 10, "feature_open", "open", feature="cut", task=f"c{i}"),
|
| 199 |
+
_ev(i * 10 + 2, "job_done", "run", feature="cut", task=f"c{i}", lat=1000),
|
| 200 |
+
_ev(i * 10 + 4, "output_download", "output", feature="cut", task=f"c{i}",
|
| 201 |
+
value="mp4", count=2),
|
| 202 |
+
]
|
| 203 |
+
evs += [ # shuffle: 1 việc, 2 lần bấm, 1 sản phẩm
|
| 204 |
+
_ev(100, "feature_open", "open", feature="shuffle", task="s1"),
|
| 205 |
+
_ev(102, "job_done", "run", feature="shuffle", task="s1", lat=1000),
|
| 206 |
+
_ev(104, "regenerate", "run", feature="shuffle", task="s1"),
|
| 207 |
+
_ev(106, "output_download", "output", feature="shuffle", task="s1",
|
| 208 |
+
value="mp4", count=1),
|
| 209 |
+
]
|
| 210 |
+
feats = {f["feature"]: f for f in etl.summarize(evs)["features"]}
|
| 211 |
+
cut, sh = feats["cut"], feats["shuffle"]
|
| 212 |
+
# cut cao nhất ở cả 3 thành phần -> tròn 100 điểm
|
| 213 |
+
assert cut["score_demand"] == 100.0 and cut["score_output"] == 100.0
|
| 214 |
+
assert cut["score_rerun"] == 100.0 and cut["score_total"] == 100.0
|
| 215 |
+
# shuffle: nhu cầu 1/2, sản phẩm 1/4, bấm gấp đôi -> 0.4*50 + 0.2*50 + 0.4*25
|
| 216 |
+
assert sh["score_demand"] == 50.0
|
| 217 |
+
assert sh["score_output"] == 25.0
|
| 218 |
+
assert sh["score_rerun"] == 50.0
|
| 219 |
+
assert abs(sh["score_total"] - 40.0) < 0.05
|
| 220 |
+
assert cut["score_total"] > sh["score_total"]
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def test_contribution_score_handles_single_tool():
|
| 224 |
+
"""Chỉ có 1 tool -> không vỡ, tự là tool cao nhất (100 điểm)."""
|
| 225 |
+
f = etl.summarize(_one_full_task())["features"]
|
| 226 |
+
assert len(f) == 1 and f[0]["score_total"] == 100.0
|
| 227 |
+
|
| 228 |
+
|
| 229 |
def test_passive_pages_are_not_tasks():
|
| 230 |
"""page_view (trang chủ/hướng dẫn) không có task_id -> không tính vào A3."""
|
| 231 |
evs = _one_full_task() + [
|
frontend/src/components/admin/UsageDashboard.jsx
CHANGED
|
@@ -170,6 +170,9 @@ export default function UsageDashboard({ token }) {
|
|
| 170 |
</div>
|
| 171 |
</div>
|
| 172 |
|
|
|
|
|
|
|
|
|
|
| 173 |
{/* Bảng theo tool */}
|
| 174 |
<div className="card">
|
| 175 |
<div className="panel-title">🧪 Chi tiết theo tool</div>
|
|
@@ -264,7 +267,9 @@ export default function UsageDashboard({ token }) {
|
|
| 264 |
)}
|
| 265 |
</div>
|
| 266 |
|
| 267 |
-
{/* Tốc độ máy
|
|
|
|
|
|
|
| 268 |
<div className="card">
|
| 269 |
<div className="panel-title">⚙️ Tốc độ máy mỗi bước (B2)</div>
|
| 270 |
<div className="tablewrap">
|
|
@@ -287,6 +292,7 @@ export default function UsageDashboard({ token }) {
|
|
| 287 |
</table>
|
| 288 |
</div>
|
| 289 |
</div>
|
|
|
|
| 290 |
|
| 291 |
{/* Log thô */}
|
| 292 |
<div className="card">
|
|
@@ -330,6 +336,97 @@ export default function UsageDashboard({ token }) {
|
|
| 330 |
);
|
| 331 |
}
|
| 332 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
// ── Nhật ký từng phiên của một người ───────────────────────────
|
| 334 |
|
| 335 |
function UserSessions({ detail }) {
|
|
|
|
| 170 |
</div>
|
| 171 |
</div>
|
| 172 |
|
| 173 |
+
{/* Tool đóng góp nhiều / ít nhất */}
|
| 174 |
+
<ContribRanking features={data.features || []} weights={data.contrib_weights} />
|
| 175 |
+
|
| 176 |
{/* Bảng theo tool */}
|
| 177 |
<div className="card">
|
| 178 |
<div className="panel-title">🧪 Chi tiết theo tool</div>
|
|
|
|
| 267 |
)}
|
| 268 |
</div>
|
| 269 |
|
| 270 |
+
{/* TẠM BỎ theo yêu cầu: bảng "Tốc độ máy mỗi bước (B2)". Backend vẫn
|
| 271 |
+
tính và trả `data.jobs` như cũ, muốn hiện lại chỉ cần bỏ comment.
|
| 272 |
+
|
| 273 |
<div className="card">
|
| 274 |
<div className="panel-title">⚙️ Tốc độ máy mỗi bước (B2)</div>
|
| 275 |
<div className="tablewrap">
|
|
|
|
| 292 |
</table>
|
| 293 |
</div>
|
| 294 |
</div>
|
| 295 |
+
*/}
|
| 296 |
|
| 297 |
{/* Log thô */}
|
| 298 |
<div className="card">
|
|
|
|
| 336 |
);
|
| 337 |
}
|
| 338 |
|
| 339 |
+
// ── Tool đóng góp nhiều / ít nhất ──────────────────────────────
|
| 340 |
+
// Điểm tổng hợp do backend chấm (etl.score_contribution):
|
| 341 |
+
// 40% nhu cầu người dùng (số việc) + 20% ít phải bấm chạy lại (A4 nghịch đảo)
|
| 342 |
+
// + 40% sản phẩm đầu ra (B1). Mỗi phần quy về thang 100 theo tool cao nhất
|
| 343 |
+
// TRONG KỲ ĐANG LỌC -> chỉ so giữa các tool cùng khoảng, không so giữa 2 khoảng.
|
| 344 |
+
|
| 345 |
+
function ScoreBar({ value, color = "var(--orange)" }) {
|
| 346 |
+
return (
|
| 347 |
+
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
| 348 |
+
<div style={{ flex: 1, minWidth: 46, height: 6, borderRadius: 99, background: "var(--line)", overflow: "hidden" }}>
|
| 349 |
+
<div style={{ width: `${Math.max(0, Math.min(100, value || 0))}%`, height: "100%", background: color }} />
|
| 350 |
+
</div>
|
| 351 |
+
<span className="small" style={{ width: 34, textAlign: "right" }}>{(value ?? 0).toFixed(0)}</span>
|
| 352 |
+
</div>
|
| 353 |
+
);
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
function ContribRanking({ features, weights }) {
|
| 357 |
+
const rows = (features || []).filter((f) => f.tasks)
|
| 358 |
+
.slice().sort((a, b) => (b.score_total ?? 0) - (a.score_total ?? 0));
|
| 359 |
+
const w = weights || { demand: 0.4, rerun: 0.2, output: 0.4 };
|
| 360 |
+
const pct = (x) => `${Math.round((x || 0) * 100)}%`;
|
| 361 |
+
|
| 362 |
+
if (!rows.length) {
|
| 363 |
+
return (
|
| 364 |
+
<div className="card">
|
| 365 |
+
<div className="panel-title">🏆 Tool đóng góp nhiều nhất / ít nhất</div>
|
| 366 |
+
<p className="small muted" style={{ margin: 0 }}>Chưa có tool nào chạy việc trong khoảng đã chọn.</p>
|
| 367 |
+
</div>
|
| 368 |
+
);
|
| 369 |
+
}
|
| 370 |
+
const best = rows[0];
|
| 371 |
+
const worst = rows[rows.length - 1];
|
| 372 |
+
const card = (title, f, tone) => (
|
| 373 |
+
<div style={{
|
| 374 |
+
flex: "1 1 220px", padding: "12px 14px", borderRadius: "var(--radius-sm)",
|
| 375 |
+
background: tone === "good" ? "var(--green-soft)" : "var(--red-soft)",
|
| 376 |
+
border: `1px solid ${tone === "good" ? "var(--green)" : "var(--red)"}`,
|
| 377 |
+
}}>
|
| 378 |
+
<div className="small" style={{ fontWeight: 700, color: tone === "good" ? "var(--green)" : "var(--red)" }}>{title}</div>
|
| 379 |
+
<div style={{ fontSize: 20, fontWeight: 800, margin: "2px 0" }}>{f.label}</div>
|
| 380 |
+
<div className="small muted">
|
| 381 |
+
{(f.score_total ?? 0).toFixed(1)} điểm · {f.tasks} việc · {fmtNum(f.produced_total)} sản phẩm ·
|
| 382 |
+
{" "}{f.attempts_avg == null ? "—" : f.attempts_avg.toFixed(1)} lần bấm/việc
|
| 383 |
+
</div>
|
| 384 |
+
</div>
|
| 385 |
+
);
|
| 386 |
+
|
| 387 |
+
return (
|
| 388 |
+
<div className="card">
|
| 389 |
+
<div className="panel-title">🏆 Tool đóng góp nhiều nhất / ít nhất</div>
|
| 390 |
+
<p className="small muted" style={{ marginTop: -6 }}>
|
| 391 |
+
Điểm = <b>{pct(w.demand)}</b> nhu cầu người dùng (số việc) + <b>{pct(w.rerun)}</b> ít phải bấm chạy lại
|
| 392 |
+
(A4 càng thấp điểm càng cao) + <b>{pct(w.output)}</b> số sản phẩm đầu ra (B1).
|
| 393 |
+
Mỗi phần quy về thang 100 theo tool cao nhất trong khoảng đang lọc — chỉ dùng để so các tool với nhau.
|
| 394 |
+
</p>
|
| 395 |
+
{rows.length > 1 && (
|
| 396 |
+
<div style={{ display: "flex", gap: 12, flexWrap: "wrap", margin: "12px 0" }}>
|
| 397 |
+
{card("Đóng góp nhiều nhất", best, "good")}
|
| 398 |
+
{card("Đóng góp ít nhất", worst, "warn")}
|
| 399 |
+
</div>
|
| 400 |
+
)}
|
| 401 |
+
<div className="tablewrap">
|
| 402 |
+
<table className="list">
|
| 403 |
+
<thead>
|
| 404 |
+
<tr>
|
| 405 |
+
<th>#</th><th>Tool</th>
|
| 406 |
+
<th>Nhu cầu ({pct(w.demand)})</th>
|
| 407 |
+
<th>Ít chạy lại ({pct(w.rerun)})</th>
|
| 408 |
+
<th>Sản phẩm ({pct(w.output)})</th>
|
| 409 |
+
<th>Tổng điểm</th>
|
| 410 |
+
</tr>
|
| 411 |
+
</thead>
|
| 412 |
+
<tbody>
|
| 413 |
+
{rows.map((f, i) => (
|
| 414 |
+
<tr key={f.feature}>
|
| 415 |
+
<td className="small muted">{i + 1}</td>
|
| 416 |
+
<td style={{ fontWeight: 700 }}>{f.label}</td>
|
| 417 |
+
<td style={{ minWidth: 120 }}><ScoreBar value={f.score_demand} /></td>
|
| 418 |
+
<td style={{ minWidth: 120 }}><ScoreBar value={f.score_rerun} color="#3b82f6" /></td>
|
| 419 |
+
<td style={{ minWidth: 120 }}><ScoreBar value={f.score_output} color="var(--green)" /></td>
|
| 420 |
+
<td style={{ fontWeight: 800 }}>{(f.score_total ?? 0).toFixed(1)}</td>
|
| 421 |
+
</tr>
|
| 422 |
+
))}
|
| 423 |
+
</tbody>
|
| 424 |
+
</table>
|
| 425 |
+
</div>
|
| 426 |
+
</div>
|
| 427 |
+
);
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
// ── Nhật ký từng phiên của một người ───────────────────────────
|
| 431 |
|
| 432 |
function UserSessions({ detail }) {
|