titusgiap Claude Opus 5 commited on
Commit
392e31c
·
1 Parent(s): 683c672

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 }) {