titusgiap Claude Opus 4.8 commited on
Commit
00d2c12
·
1 Parent(s): 1d527a5

feat(analytics): tab "Quản lý thời lượng" theo dõi người dùng cho quản trị

Browse files

Ghi log thô raw_events rồi suy ra 9 chỉ số (A1-A5, B1-B4) bằng ETL, không lưu
sẵn chỉ số. Lưu Postgres nếu có DATABASE_URL, không thì SQLite trong STORAGE_DIR
(giữ 90 ngày, tự dọn).

- backend: services/analytics (storage + etl + track), api/analytics
(POST /api/events + các endpoint quản trị), middleware reqctx đọc header
X-TNT-* để dòng log máy chạy gắn đúng task; JobManager tự phát
job_queued/started/done kèm latency_ms (đã trừ thời gian chờ hàng đợi).
- frontend: lib/track.js (đồng hồ người), nút 👍/👎 (ThumbsFeedback) cho B3,
dashboard SVG thuần trong tab Quản lý thời lượng; gắn điểm đo vào 8 tool.

Kèm sửa lỗi phát hiện khi test:
- khôi phục core.files.rel_path() + gom mọi đường dẫn phiên ở cut/dub/shuffle/
download về "/" (trước nối "\" của Windows làm hỏng URL).
- get_file trả 404 khi trỏ vào thư mục (thay vì 500).
- run_tests.py cô lập STORAGE_DIR sang temp (trước dọn nhầm phiên thật).
- cập nhật test_cut/test_dub theo API hiện hành + thêm test_analytics (14 test).
Toàn bộ 66/66 PASS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

backend/app/api/analytics.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """API theo dõi người dùng.
2
+
3
+ Hai nhóm endpoint:
4
+ - POST /api/events — app gửi log lên (không cần token).
5
+ - GET /api/analytics/admin/* — quản trị đọc báo cáo (cần
6
+ VIDEO_ADMIN_TOKEN, dùng chung mật khẩu với tab "Quản lý người dùng").
7
+
8
+ Vì app đăng nhập bằng tên (không có token phiên), endpoint nhận log KHÔNG xác
9
+ thực được người gửi — đây là tool nội bộ nên chấp nhận, nhưng có chặn kích
10
+ thước lô để không ai bơm được cả biển dữ liệu trong một lần gọi.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import csv
16
+ import io
17
+ import time
18
+ from datetime import datetime, timedelta, timezone
19
+
20
+ from fastapi import APIRouter, HTTPException, Request
21
+ from fastapi.responses import StreamingResponse
22
+
23
+ from ..config import settings
24
+ from ..services.analytics import etl
25
+ from ..services.analytics import storage as store
26
+
27
+ router = APIRouter(tags=["analytics"])
28
+
29
+ # Trần số event một lần POST (frontend gửi lô ~20).
30
+ MAX_BATCH = 200
31
+
32
+ # Dọn log quá hạn nhiều nhất 1 lần/giờ, đi ké request có sẵn (khỏi cần cron).
33
+ _last_purge = 0.0
34
+ _PURGE_EVERY = 3600.0
35
+
36
+
37
+ def _maybe_purge():
38
+ global _last_purge
39
+ now = time.time()
40
+ if now - _last_purge < _PURGE_EVERY:
41
+ return
42
+ _last_purge = now
43
+ try:
44
+ store.purge_old()
45
+ except Exception as ex: # dọn dẹp lỗi thì kệ, không được làm hỏng request
46
+ print(f"[analytics] purge error: {ex}")
47
+
48
+
49
+ def _check(token: str):
50
+ if not settings.VIDEO_ADMIN_TOKEN or token != settings.VIDEO_ADMIN_TOKEN:
51
+ raise HTTPException(403, "Not authorized")
52
+
53
+
54
+ def _tz():
55
+ return timezone(timedelta(hours=settings.ANALYTICS_TZ_OFFSET))
56
+
57
+
58
+ def _range(days: int, date_from: str, date_to: str) -> tuple[float, float]:
59
+ """Đổi bộ lọc ngày (theo múi giờ báo cáo) thành khoảng epoch [from, to)."""
60
+ tz = _tz()
61
+ if date_from or date_to:
62
+ try:
63
+ start = (datetime.strptime(date_from, "%Y-%m-%d").replace(tzinfo=tz).timestamp()
64
+ if date_from else 0.0)
65
+ end = ((datetime.strptime(date_to, "%Y-%m-%d").replace(tzinfo=tz) + timedelta(days=1)).timestamp()
66
+ if date_to else time.time() + 60)
67
+ return start, end
68
+ except ValueError:
69
+ raise HTTPException(400, "Ngày phải theo dạng YYYY-MM-DD")
70
+ days = max(1, min(int(days or 30), 365))
71
+ now = datetime.now(tz)
72
+ start = (now - timedelta(days=days - 1)).replace(hour=0, minute=0, second=0, microsecond=0)
73
+ return start.timestamp(), time.time() + 60
74
+
75
+
76
+ # ── Nhận log ─────────────────────────────────────────────────────
77
+
78
+ @router.post("/api/events")
79
+ async def ingest(request: Request):
80
+ """Nhận một lô event. Chấp cả `{"events":[...]}` lẫn mảng trần.
81
+
82
+ `sendBeacon` lúc đóng tab gửi Content-Type lạ nên đọc body thủ công.
83
+ """
84
+ try:
85
+ body = await request.json()
86
+ except Exception:
87
+ return {"ok": False, "error": "Body không phải JSON", "saved": 0}
88
+
89
+ items = body.get("events") if isinstance(body, dict) else body
90
+ if not isinstance(items, list):
91
+ return {"ok": False, "error": "Thiếu mảng events", "saved": 0}
92
+ if len(items) > MAX_BATCH:
93
+ items = items[:MAX_BATCH]
94
+
95
+ now = time.time()
96
+ rows = [store.normalize(it, now) for it in items if isinstance(it, dict)]
97
+
98
+ # CHỈNH LỆCH ĐỒNG HỒ: dời cả lô theo đúng một hằng số sao cho event MỚI NHẤT
99
+ # trùng giờ máy chủ. Hiệu số giữa các dòng (thời gian mỗi bước) giữ nguyên,
100
+ # nhưng mốc tuyệt đối khớp với dòng log do backend ghi -> ghép chung một
101
+ # dòng thời gian được. Lô nào cũng mới gửi nên sai số chỉ còn độ trễ mạng.
102
+ if rows:
103
+ skew = now - max(r["event_ts"] for r in rows)
104
+ if abs(skew) > 1.0:
105
+ for r in rows:
106
+ r["event_ts"] += skew
107
+
108
+ saved = store.insert(rows)
109
+ _maybe_purge()
110
+ return {"ok": True, "saved": saved, "received": len(rows)}
111
+
112
+
113
+ # ── Báo cáo cho quản trị ─────────────────────────────────────────
114
+
115
+ @router.get("/api/analytics/admin/summary")
116
+ def admin_summary(token: str = "", days: int = 30, date_from: str = "",
117
+ date_to: str = "", feature: str = "", user_id: str = ""):
118
+ """Toàn bộ số liệu cho tab "Quản lý thời lượng"."""
119
+ _check(token)
120
+ start, end = _range(days, date_from, date_to)
121
+ events = store.query(start, end, user_id=user_id, feature=feature)
122
+ data = etl.summarize(events)
123
+ data["range"] = {"from_ts": start, "to_ts": end, "days": days,
124
+ "tz_offset": settings.ANALYTICS_TZ_OFFSET}
125
+ data["storage"] = store.stats()
126
+ return {"ok": True, **data}
127
+
128
+
129
+ @router.get("/api/analytics/admin/user")
130
+ def admin_user_detail(token: str = "", user_id: str = "", days: int = 30,
131
+ date_from: str = "", date_to: str = ""):
132
+ """Chi tiết một người: từng phiên -> từng task -> từng bước."""
133
+ _check(token)
134
+ if not user_id.strip():
135
+ raise HTTPException(400, "Thiếu user_id")
136
+ start, end = _range(days, date_from, date_to)
137
+ events = store.query(start, end, user_id=user_id)
138
+ return {"ok": True, **etl.user_detail(events, user_id.strip().lower())}
139
+
140
+
141
+ @router.get("/api/analytics/admin/events")
142
+ def admin_raw_events(token: str = "", days: int = 7, date_from: str = "",
143
+ date_to: str = "", user_id: str = "", feature: str = "",
144
+ task_id: str = "", limit: int = 500):
145
+ """Log thô — để soi đúng dòng khi số liệu trông lạ."""
146
+ _check(token)
147
+ start, end = _range(days, date_from, date_to)
148
+ rows = store.query(start, end, user_id=user_id, feature=feature,
149
+ task_id=task_id, limit=max(1, min(int(limit or 500), 5000)),
150
+ newest_first=True)
151
+ return {"ok": True, "events": rows, "count": len(rows)}
152
+
153
+
154
+ @router.get("/api/analytics/admin/export.csv")
155
+ def admin_export_csv(token: str = "", days: int = 30, date_from: str = "",
156
+ date_to: str = "", user_id: str = "", feature: str = ""):
157
+ """Xuất log thô đúng 11 cột của `raw_events_schema_v2.xlsx`."""
158
+ _check(token)
159
+ start, end = _range(days, date_from, date_to)
160
+ rows = store.query(start, end, user_id=user_id, feature=feature)
161
+
162
+ buf = io.StringIO()
163
+ cols = ["event_id", "event_time", "user_id", "session_id", "task_id", "feature",
164
+ "step", "event_name", "event_value", "latency_ms", "properties_json"]
165
+ w = csv.writer(buf)
166
+ w.writerow(cols)
167
+ import json as _json
168
+ for r in rows:
169
+ w.writerow([
170
+ r.get("event_id", ""), r.get("event_time", ""), r.get("user_id", ""),
171
+ r.get("session_id", ""), r.get("task_id", ""), r.get("feature", ""),
172
+ r.get("step", ""), r.get("event_name", ""), r.get("event_value", ""),
173
+ "" if r.get("latency_ms") is None else r["latency_ms"],
174
+ _json.dumps(r.get("properties") or {}, ensure_ascii=False),
175
+ ])
176
+ buf.seek(0)
177
+ fname = f"raw_events_{datetime.now(_tz()).strftime('%Y%m%d')}.csv"
178
+ return StreamingResponse(
179
+ # BOM để Excel mở tiếng Việt không lỗi phông
180
+ iter(["" + buf.getvalue()]),
181
+ media_type="text/csv; charset=utf-8",
182
+ headers={"Content-Disposition": f'attachment; filename="{fname}"'},
183
+ )
184
+
185
+
186
+ @router.get("/api/analytics/admin/storage")
187
+ def admin_storage(token: str = ""):
188
+ _check(token)
189
+ return {"ok": True, **store.stats()}
190
+
191
+
192
+ @router.post("/api/analytics/admin/purge")
193
+ def admin_purge(body: dict):
194
+ """Dọn tay log cũ hơn `days` ngày."""
195
+ _check(body.get("token") or "")
196
+ days = body.get("days")
197
+ deleted = store.purge_old(int(days) if days else None)
198
+ return {"ok": True, "deleted": deleted}
backend/app/api/common.py CHANGED
@@ -62,8 +62,12 @@ def list_inputs(sid: str):
62
  @router.get("/files/{sid}/{path:path}")
63
  def get_file(sid: str, path: str):
64
  base = os.path.abspath(session_dir(sid))
65
- full = os.path.abspath(os.path.join(base, path))
66
- if not full.startswith(base) or not os.path.exists(full):
 
 
 
 
67
  raise HTTPException(404, "file không tồn tại")
68
  # inline (KHÔNG attachment) -> trình duyệt render video/ảnh trực tiếp trong <video>.
69
  # Nút tải vẫn hoạt động nhờ thuộc tính `download` phía frontend.
 
62
  @router.get("/files/{sid}/{path:path}")
63
  def get_file(sid: str, path: str):
64
  base = os.path.abspath(session_dir(sid))
65
+ # Chấp cả "/" lẫn "\" trong path (Windows) -> chuẩn hoá trước khi nối.
66
+ rel = path.replace("\\", "/").strip("/")
67
+ full = os.path.abspath(os.path.join(base, *rel.split("/")))
68
+ # Phải nằm trong phiên VÀ là FILE thật: trỏ vào THƯ MỤC thì FileResponse
69
+ # ném lỗi 500, trong khi đúng nghĩa là 404.
70
+ if not full.startswith(base) or not os.path.isfile(full):
71
  raise HTTPException(404, "file không tồn tại")
72
  # inline (KHÔNG attachment) -> trình duyệt render video/ảnh trực tiếp trong <video>.
73
  # Nút tải vẫn hoạt động nhờ thuộc tính `download` phía frontend.
backend/app/api/cut.py CHANGED
@@ -13,7 +13,7 @@ import os
13
  from fastapi import APIRouter, File, Form, UploadFile
14
  from pydantic import BaseModel
15
 
16
- from ..core.files import new_session, session_dir, sub_dir, save_upload_stream
17
  from ..core.jobs import jobs
18
  from ..config import settings
19
  from ..services.cutter import cut as cutter
@@ -32,7 +32,7 @@ def upload(file: UploadFile = File(...), session: str = Form(None)):
32
  info = probe(path)
33
  return {
34
  "session": sid,
35
- "file": os.path.relpath(path, session_dir(sid)),
36
  "info": {"duration": info.duration, "fps": info.fps,
37
  "nb_frames": info.nb_frames,
38
  "width": info.width, "height": info.height},
@@ -63,8 +63,7 @@ def split_audio(req: SplitAudioReq):
63
  res = cutter.split_audio(src, out_dir)
64
 
65
  def url(p):
66
- return f"/api/files/{req.session}/" + os.path.relpath(
67
- p, session_dir(req.session)).replace(os.sep, "/")
68
 
69
  result = {
70
  "muted_video_url": url(res.muted_video),
 
13
  from fastapi import APIRouter, File, Form, UploadFile
14
  from pydantic import BaseModel
15
 
16
+ from ..core.files import new_session, rel_path, save_upload_stream, session_dir, sub_dir
17
  from ..core.jobs import jobs
18
  from ..config import settings
19
  from ..services.cutter import cut as cutter
 
32
  info = probe(path)
33
  return {
34
  "session": sid,
35
+ "file": rel_path(path, sid),
36
  "info": {"duration": info.duration, "fps": info.fps,
37
  "nb_frames": info.nb_frames,
38
  "width": info.width, "height": info.height},
 
63
  res = cutter.split_audio(src, out_dir)
64
 
65
  def url(p):
66
+ return f"/api/files/{req.session}/" + rel_path(p, req.session)
 
67
 
68
  result = {
69
  "muted_video_url": url(res.muted_video),
backend/app/api/download.py CHANGED
@@ -10,7 +10,7 @@ from fastapi import APIRouter
10
  from pydantic import BaseModel
11
 
12
  from ..config import settings
13
- from ..core.files import new_session, session_dir, sub_dir, zip_paths
14
  from ..core.jobs import DONE, ERROR, RUNNING, jobs
15
  from ..services.downloader.ydl import detect_source, download_one, parse_links
16
  from ..services.history.auto_save import auto_save_result
@@ -58,7 +58,7 @@ def start(req: StartReq):
58
  res = download_one(url, out_dir, req.cookies_file)
59
  if res.ok:
60
  done_paths.append(res.path)
61
- rel = os.path.relpath(res.path, session_dir(sid)).replace(os.sep, "/")
62
  h.item_status(it, DONE, detail=res.title, result={
63
  "path": rel,
64
  # quote() để encode '#', khoảng trắng... trong tên file
@@ -128,5 +128,5 @@ def push_to_tool(req: PushReq):
128
  if os.path.exists(src):
129
  d = os.path.join(dst, os.path.basename(src))
130
  shutil.copy(src, d)
131
- copied.append(os.path.relpath(d, session_dir(new_sid)))
132
  return {"session": new_sid, "target_tool": req.target_tool, "files": copied}
 
10
  from pydantic import BaseModel
11
 
12
  from ..config import settings
13
+ from ..core.files import new_session, rel_path, session_dir, sub_dir, zip_paths
14
  from ..core.jobs import DONE, ERROR, RUNNING, jobs
15
  from ..services.downloader.ydl import detect_source, download_one, parse_links
16
  from ..services.history.auto_save import auto_save_result
 
58
  res = download_one(url, out_dir, req.cookies_file)
59
  if res.ok:
60
  done_paths.append(res.path)
61
+ rel = rel_path(res.path, sid)
62
  h.item_status(it, DONE, detail=res.title, result={
63
  "path": rel,
64
  # quote() để encode '#', khoảng trắng... trong tên file
 
128
  if os.path.exists(src):
129
  d = os.path.join(dst, os.path.basename(src))
130
  shutil.copy(src, d)
131
+ copied.append(rel_path(d, new_sid))
132
  return {"session": new_sid, "target_tool": req.target_tool, "files": copied}
backend/app/api/dub.py CHANGED
@@ -18,7 +18,7 @@ from fastapi import APIRouter, File, Form, HTTPException, UploadFile
18
  from fastapi.responses import FileResponse
19
  from pydantic import BaseModel
20
 
21
- from ..core.files import new_session, session_dir, sub_dir, save_upload, save_upload_stream
22
  from ..core.jobs import jobs
23
  from ..config import settings
24
  from ..services.dubber import pipeline
@@ -127,7 +127,7 @@ def upload(file: UploadFile = File(...), session: str = Form(None)):
127
  # stream xuống đĩa (không nạp cả video vào RAM) + chặn file quá lớn
128
  path = save_upload_stream(sid, file.filename, file.file, folder="inputs",
129
  max_mb=settings.MAX_UPLOAD_MB)
130
- rel = os.path.relpath(path, session_dir(sid))
131
  return {"session": sid, "file": rel, "name": os.path.basename(path),
132
  "size": os.path.getsize(path)}
133
 
@@ -138,7 +138,7 @@ def voice_sample(file: UploadFile = File(...), session: str = Form(None)):
138
  sid = session or new_session("dub")
139
  path = save_upload_stream(sid, file.filename, file.file, folder="voice",
140
  max_mb=settings.MAX_UPLOAD_MB)
141
- rel = os.path.relpath(path, session_dir(sid))
142
  return {"session": sid, "file": rel, "name": os.path.basename(path)}
143
 
144
 
@@ -216,8 +216,7 @@ def start(req: DubReq):
216
  res = pipeline.run_dub(src, out_dir, opt, prog)
217
 
218
  def url(path):
219
- return f"/api/files/{req.session}/" + os.path.relpath(
220
- path, session_dir(req.session)).replace(os.sep, "/")
221
 
222
  result = {
223
  "name": base,
@@ -329,7 +328,7 @@ def prepare(req: DubReq):
329
  ar = pipeline.analyze(src, out_dir, opt, prog)
330
 
331
  def rel_to(path):
332
- return os.path.relpath(path, session_dir(req.session)).replace(os.sep, "/")
333
 
334
  def url(path):
335
  return f"/api/files/{req.session}/" + rel_to(path)
@@ -421,8 +420,7 @@ def synthesize(req: SynthReq):
421
  res = pipeline.synthesize(state_abs, lines, opt, prog)
422
 
423
  def url(path):
424
- return f"/api/files/{req.session}/" + os.path.relpath(
425
- path, session_dir(req.session)).replace(os.sep, "/")
426
 
427
  result = {
428
  "name": base, "language": res.language, "n_lines": res.n_lines,
@@ -526,7 +524,7 @@ def tts_from_text(req: TtsReq):
526
  h.set_progress(p)
527
  h.item_status(_it, "running", detail=m)
528
  wav = pipeline.run_tts(opt, out_dir, prog)
529
- rel = os.path.relpath(wav, session_dir(sid)).replace(os.sep, "/")
530
  result = {"name": "voice.wav", "audio_url": f"/api/files/{sid}/{rel}"}
531
  h.item_status(it, "done", detail="đã tạo voice", result=result)
532
  return {"session": sid, "output": result}
 
18
  from fastapi.responses import FileResponse
19
  from pydantic import BaseModel
20
 
21
+ from ..core.files import new_session, rel_path, save_upload, save_upload_stream, session_dir, sub_dir
22
  from ..core.jobs import jobs
23
  from ..config import settings
24
  from ..services.dubber import pipeline
 
127
  # stream xuống đĩa (không nạp cả video vào RAM) + chặn file quá lớn
128
  path = save_upload_stream(sid, file.filename, file.file, folder="inputs",
129
  max_mb=settings.MAX_UPLOAD_MB)
130
+ rel = rel_path(path, sid)
131
  return {"session": sid, "file": rel, "name": os.path.basename(path),
132
  "size": os.path.getsize(path)}
133
 
 
138
  sid = session or new_session("dub")
139
  path = save_upload_stream(sid, file.filename, file.file, folder="voice",
140
  max_mb=settings.MAX_UPLOAD_MB)
141
+ rel = rel_path(path, sid)
142
  return {"session": sid, "file": rel, "name": os.path.basename(path)}
143
 
144
 
 
216
  res = pipeline.run_dub(src, out_dir, opt, prog)
217
 
218
  def url(path):
219
+ return f"/api/files/{req.session}/" + rel_path(path, req.session)
 
220
 
221
  result = {
222
  "name": base,
 
328
  ar = pipeline.analyze(src, out_dir, opt, prog)
329
 
330
  def rel_to(path):
331
+ return rel_path(path, req.session)
332
 
333
  def url(path):
334
  return f"/api/files/{req.session}/" + rel_to(path)
 
420
  res = pipeline.synthesize(state_abs, lines, opt, prog)
421
 
422
  def url(path):
423
+ return f"/api/files/{req.session}/" + rel_path(path, req.session)
 
424
 
425
  result = {
426
  "name": base, "language": res.language, "n_lines": res.n_lines,
 
524
  h.set_progress(p)
525
  h.item_status(_it, "running", detail=m)
526
  wav = pipeline.run_tts(opt, out_dir, prog)
527
+ rel = rel_path(wav, sid)
528
  result = {"name": "voice.wav", "audio_url": f"/api/files/{sid}/{rel}"}
529
  h.item_status(it, "done", detail="đã tạo voice", result=result)
530
  return {"session": sid, "output": result}
backend/app/api/flux2.py CHANGED
@@ -14,6 +14,8 @@ from pydantic import BaseModel
14
  from gradio_client import Client, handle_file
15
 
16
  from ..core.bg import submit_bg
 
 
17
 
18
  WANGP_URL = "https://00114dcf4c286c050a.gradio.live/"
19
  OUTPUT_DIR = Path(__file__).resolve().parent.parent / "flux2_outputs"
@@ -106,7 +108,10 @@ def _download_image(img_info: Any, dest: Path):
106
  dest.write_bytes(resp.content)
107
 
108
 
109
- def _generate(task_id: str, params: dict):
 
 
 
110
  try:
111
  with _tasks_lock:
112
  _tasks[task_id] = {"status": "processing"}
@@ -236,6 +241,8 @@ def _generate(task_id: str, params: dict):
236
 
237
  with _tasks_lock:
238
  _tasks[task_id] = {"status": "done", "images": saved}
 
 
239
 
240
  except Exception as e:
241
  import traceback
@@ -246,6 +253,8 @@ def _generate(task_id: str, params: dict):
246
  "error": str(e),
247
  "traceback": traceback.format_exc(),
248
  }
 
 
249
 
250
 
251
  @router.get("/health")
@@ -264,7 +273,11 @@ async def api_generate(req: GenRequest):
264
  with _tasks_lock:
265
  _tasks[task_id] = {"status": "queued"}
266
 
267
- submit_bg(_generate, task_id, req.model_dump())
 
 
 
 
268
 
269
  return {"task_id": task_id, "status": "queued"}
270
 
 
14
  from gradio_client import Client, handle_file
15
 
16
  from ..core.bg import submit_bg
17
+ from ..core.reqctx import get_ctx
18
+ from ..services.analytics import track as tracking
19
 
20
  WANGP_URL = "https://00114dcf4c286c050a.gradio.live/"
21
  OUTPUT_DIR = Path(__file__).resolve().parent.parent / "flux2_outputs"
 
108
  dest.write_bytes(resp.content)
109
 
110
 
111
+ def _generate(task_id: str, params: dict, ctx: dict | None = None):
112
+ ctx = ctx or {}
113
+ started = time.time()
114
+ tracking.emit("job_started", ctx, job_id=task_id, kind="flux2")
115
  try:
116
  with _tasks_lock:
117
  _tasks[task_id] = {"status": "processing"}
 
241
 
242
  with _tasks_lock:
243
  _tasks[task_id] = {"status": "done", "images": saved}
244
+ tracking.emit("job_done", ctx, latency_ms=int((time.time() - started) * 1000),
245
+ job_id=task_id, kind="flux2", phase="generate", items=len(saved))
246
 
247
  except Exception as e:
248
  import traceback
 
253
  "error": str(e),
254
  "traceback": traceback.format_exc(),
255
  }
256
+ tracking.emit("job_failed", ctx, latency_ms=int((time.time() - started) * 1000),
257
+ job_id=task_id, kind="flux2", phase="generate", error=str(e)[:200])
258
 
259
 
260
  @router.get("/health")
 
273
  with _tasks_lock:
274
  _tasks[task_id] = {"status": "queued"}
275
 
276
+ # Bắt ngữ cảnh theo dõi NGAY tại request: hàm chạy ở luồng nền, ContextVar
277
+ # của request không còn sống tới đó.
278
+ ctx = get_ctx()
279
+ tracking.emit("job_queued", ctx, job_id=task_id, kind="flux2")
280
+ submit_bg(_generate, task_id, req.model_dump(), ctx)
281
 
282
  return {"task_id": task_id, "status": "queued"}
283
 
backend/app/api/listing.py CHANGED
@@ -18,6 +18,8 @@ from ..services.listing.textgen import generate_copy_pack, generate_shopee_text
18
  from ..services.auth.storage import is_authorized as _check_user_auth
19
  from ..services.history.auto_save import auto_save_result
20
  from ..core.bg import submit_bg
 
 
21
 
22
 
23
  router = APIRouter(prefix="/api/listing", tags=["listing"])
@@ -222,12 +224,17 @@ async def generate(body: dict):
222
  s["model"] = model
223
  s["quality"] = quality
224
 
225
- submit_bg(_run_generation, sid, image_prompts)
 
 
226
 
227
  return {"ok": True, "session_id": sid, "status": "started", "total": len(image_prompts)}
228
 
229
 
230
- def _run_generation(sid, image_prompts):
 
 
 
231
  s = _sessions.get(sid)
232
  if not s:
233
  return
@@ -369,6 +376,9 @@ def _run_generation(sid, image_prompts):
369
  s["errors"].append({"type": p["type"], "error": str(last_error) if last_error else "Unknown error"})
370
 
371
  s["status"] = "completed"
 
 
 
372
  s["last_status"] = {"level": "success", "message": "✅ Hoàn thành tất cả! Đang sinh text Shopee...", "time": time.time()}
373
 
374
  if s.get("attributes"):
 
18
  from ..services.auth.storage import is_authorized as _check_user_auth
19
  from ..services.history.auto_save import auto_save_result
20
  from ..core.bg import submit_bg
21
+ from ..core.reqctx import get_ctx
22
+ from ..services.analytics import track as tracking
23
 
24
 
25
  router = APIRouter(prefix="/api/listing", tags=["listing"])
 
224
  s["model"] = model
225
  s["quality"] = quality
226
 
227
+ ctx = get_ctx() # luồng nền không giữ được ContextVar -> bắt tại đây
228
+ tracking.emit("job_queued", ctx, job_id=sid, kind="listing")
229
+ submit_bg(_run_generation, sid, image_prompts, ctx)
230
 
231
  return {"ok": True, "session_id": sid, "status": "started", "total": len(image_prompts)}
232
 
233
 
234
+ def _run_generation(sid, image_prompts, ctx=None):
235
+ ctx = ctx or {}
236
+ _t0 = time.time()
237
+ tracking.emit("job_started", ctx, job_id=sid, kind="listing")
238
  s = _sessions.get(sid)
239
  if not s:
240
  return
 
376
  s["errors"].append({"type": p["type"], "error": str(last_error) if last_error else "Unknown error"})
377
 
378
  s["status"] = "completed"
379
+ tracking.emit("job_done", ctx, latency_ms=int((time.time() - _t0) * 1000),
380
+ job_id=sid, kind="listing", phase="analyze_generate",
381
+ items=len(s.get("results") or []))
382
  s["last_status"] = {"level": "success", "message": "✅ Hoàn thành tất cả! Đang sinh text Shopee...", "time": time.time()}
383
 
384
  if s.get("attributes"):
backend/app/api/shuffle.py CHANGED
@@ -18,7 +18,7 @@ from dataclasses import replace
18
  from fastapi import APIRouter, File, Form, HTTPException, UploadFile
19
  from pydantic import BaseModel
20
 
21
- from ..core.files import new_session, session_dir, sub_dir, save_upload_stream
22
  from ..core.jobs import jobs
23
  from ..config import settings
24
  from ..services.shuffler import engine
@@ -41,7 +41,7 @@ def upload(
41
  folder = f"seg_{segment}"
42
  path = save_upload_stream(sid, file.filename, file.file, folder=folder,
43
  max_mb=settings.MAX_UPLOAD_MB)
44
- rel = os.path.relpath(path, session_dir(sid))
45
  return {
46
  "session": sid, "segment": segment, "file": rel,
47
  "name": os.path.basename(path), "size": os.path.getsize(path),
@@ -57,7 +57,7 @@ def upload_audio(
57
  """Tải 1 track âm thanh dùng để lồng vào các biến thể."""
58
  path = save_upload_stream(session, file.filename, file.file, folder="audio",
59
  max_mb=settings.MAX_UPLOAD_MB)
60
- rel = os.path.relpath(path, session_dir(session))
61
  return {
62
  "session": session, "file": rel,
63
  "name": os.path.basename(path), "size": os.path.getsize(path),
 
18
  from fastapi import APIRouter, File, Form, HTTPException, UploadFile
19
  from pydantic import BaseModel
20
 
21
+ from ..core.files import new_session, rel_path, save_upload_stream, session_dir, sub_dir
22
  from ..core.jobs import jobs
23
  from ..config import settings
24
  from ..services.shuffler import engine
 
41
  folder = f"seg_{segment}"
42
  path = save_upload_stream(sid, file.filename, file.file, folder=folder,
43
  max_mb=settings.MAX_UPLOAD_MB)
44
+ rel = rel_path(path, sid)
45
  return {
46
  "session": sid, "segment": segment, "file": rel,
47
  "name": os.path.basename(path), "size": os.path.getsize(path),
 
57
  """Tải 1 track âm thanh dùng để lồng vào các biến thể."""
58
  path = save_upload_stream(session, file.filename, file.file, folder="audio",
59
  max_mb=settings.MAX_UPLOAD_MB)
60
+ rel = rel_path(path, session)
61
  return {
62
  "session": session, "file": rel,
63
  "name": os.path.basename(path), "size": os.path.getsize(path),
backend/app/config.py CHANGED
@@ -23,6 +23,20 @@ def _env(key: str, default: str = "") -> str:
23
  return os.environ.get(key, default)
24
 
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  @dataclass
27
  class Settings:
28
  # Vùng LÀM VIỆC TẠM theo phiên (không phải kho lưu trữ): mặc định nằm trong
@@ -43,6 +57,18 @@ class Settings:
43
  # Database cho user auth (để trống thì dùng JSON file)
44
  DATABASE_URL: str = field(default_factory=lambda: _env("DATABASE_URL", ""))
45
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  # Danh sách user được dùng key chung (cách nhau bằng dấu phẩy)
47
  AUTHORIZED_USERS: list[str] = field(
48
  default_factory=lambda: [u.strip() for u in _env("AUTHORIZED_USERS", "admin").split(",") if u.strip()]
 
23
  return os.environ.get(key, default)
24
 
25
 
26
+ def _env_int(key: str, default: int) -> int:
27
+ try:
28
+ return int(os.environ.get(key, "") or default)
29
+ except (TypeError, ValueError):
30
+ return default
31
+
32
+
33
+ def _env_float(key: str, default: float) -> float:
34
+ try:
35
+ return float(os.environ.get(key, "") or default)
36
+ except (TypeError, ValueError):
37
+ return default
38
+
39
+
40
  @dataclass
41
  class Settings:
42
  # Vùng LÀM VIỆC TẠM theo phiên (không phải kho lưu trữ): mặc định nằm trong
 
57
  # Database cho user auth (để trống thì dùng JSON file)
58
  DATABASE_URL: str = field(default_factory=lambda: _env("DATABASE_URL", ""))
59
 
60
+ # --- Theo dõi người dùng (tab "Quản lý thời lượng") ---
61
+ # Log thô lưu chung DATABASE_URL nếu có, không thì SQLite trong STORAGE_DIR.
62
+ # Giữ bao nhiêu ngày rồi tự dọn (0 = giữ mãi).
63
+ ANALYTICS_RETENTION_DAYS: int = field(
64
+ default_factory=lambda: _env_int("ANALYTICS_RETENTION_DAYS", 90))
65
+ # Múi giờ dùng để gộp "theo ngày" trong báo cáo (giờ so với UTC).
66
+ ANALYTICS_TZ_OFFSET: float = field(
67
+ default_factory=lambda: _env_float("ANALYTICS_TZ_OFFSET", 7.0))
68
+ # Im lặng quá ngần này phút -> coi như hết phiên (spec §6, mốc mềm).
69
+ SESSION_IDLE_MINUTES: int = field(
70
+ default_factory=lambda: _env_int("SESSION_IDLE_MINUTES", 30))
71
+
72
  # Danh sách user được dùng key chung (cách nhau bằng dấu phẩy)
73
  AUTHORIZED_USERS: list[str] = field(
74
  default_factory=lambda: [u.strip() for u in _env("AUTHORIZED_USERS", "admin").split(",") if u.strip()]
backend/app/core/files.py CHANGED
@@ -45,6 +45,15 @@ def sub_dir(sid: str, *parts: str) -> str:
45
  return d
46
 
47
 
 
 
 
 
 
 
 
 
 
48
  def save_upload(sid: str, filename: str, data: bytes, folder: str = "uploads") -> str:
49
  if not data or len(data) == 0:
50
  raise ValueError(f"Upload file '{filename}' is empty (0 bytes)")
 
45
  return d
46
 
47
 
48
+ def rel_path(full: str, sid: str) -> str:
49
+ """Đường dẫn tương đối trong phiên, LUÔN dùng "/".
50
+
51
+ An toàn cho URL/JSON: dấu "\\" của Windows lọt vào `<video src>` hay khoá
52
+ khớp đường dẫn là hỏng. Đừng gọi thẳng os.path.relpath ở endpoint.
53
+ """
54
+ return os.path.relpath(full, session_dir(sid)).replace(os.sep, "/")
55
+
56
+
57
  def save_upload(sid: str, filename: str, data: bytes, folder: str = "uploads") -> str:
58
  if not data or len(data) == 0:
59
  raise ValueError(f"Upload file '{filename}' is empty (0 bytes)")
backend/app/core/jobs.py CHANGED
@@ -69,6 +69,13 @@ class Job:
69
  result: dict | None = None
70
  error: str = ""
71
  created_at: float = field(default_factory=time.time)
 
 
 
 
 
 
 
72
  # Vị trí trong hàng đợi (0 = đang chạy/đến lượt). Manager cập nhật khi xem.
73
  queue_pos: int = 0
74
  queue_total: int = 0
@@ -86,6 +93,8 @@ class Job:
86
  "result": self.result,
87
  "error": self.error,
88
  "created_at": self.created_at,
 
 
89
  "failed_items": [
90
  {"id": i.id, "label": i.label, "error": i.error}
91
  for i in self.items if i.status == ERROR
@@ -129,6 +138,21 @@ class JobHandle:
129
  return self._m._cancel.get(self.job.id, False)
130
 
131
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  class JobManager:
133
  def __init__(self):
134
  self._jobs: dict[str, Job] = {}
@@ -152,8 +176,15 @@ class JobManager:
152
  def _cat(kind: str) -> str:
153
  return "heavy" if kind in HEAVY_KINDS else "io"
154
 
155
- def create(self, kind: str) -> Job:
156
- job = Job(id=uuid.uuid4().hex[:12], kind=kind)
 
 
 
 
 
 
 
157
  with self._lock:
158
  self._jobs[job.id] = job
159
  self._order.append(job.id)
@@ -222,6 +253,7 @@ class JobManager:
222
  with self._lock:
223
  self._waiting[cat].append(job.id)
224
  job.status = QUEUED
 
225
 
226
  def runner():
227
  with self._lock:
@@ -232,7 +264,9 @@ class JobManager:
232
  return
233
  self._running[cat].add(job.id)
234
  job.status = RUNNING
 
235
  job.queue_pos = 0
 
236
  try:
237
  result = fn(handle)
238
  with self._lock:
@@ -248,7 +282,13 @@ class JobManager:
248
  handle.log(traceback.format_exc(), level="error")
249
  finally:
250
  with self._lock:
 
251
  self._running[cat].discard(job.id)
 
 
 
 
 
252
 
253
  self._pools[cat].submit(runner)
254
 
 
69
  result: dict | None = None
70
  error: str = ""
71
  created_at: float = field(default_factory=time.time)
72
+ # Mốc RỜI HÀNG ĐỢI và mốc XONG -> tách "chờ tới lượt" khỏi "máy chạy thật".
73
+ # latency_ms của chỉ số B2/B4 = finished_at - started_at, KHÔNG tính chờ.
74
+ started_at: float | None = None
75
+ finished_at: float | None = None
76
+ # 4 tầng định danh do frontend gửi kèm (user/session/task/feature) để dòng
77
+ # log máy chạy gắn được vào đúng task của đúng người. Rỗng -> không ghi log.
78
+ ctx: dict = field(default_factory=dict)
79
  # Vị trí trong hàng đợi (0 = đang chạy/đến lượt). Manager cập nhật khi xem.
80
  queue_pos: int = 0
81
  queue_total: int = 0
 
93
  "result": self.result,
94
  "error": self.error,
95
  "created_at": self.created_at,
96
+ "started_at": self.started_at,
97
+ "finished_at": self.finished_at,
98
  "failed_items": [
99
  {"id": i.id, "label": i.label, "error": i.error}
100
  for i in self.items if i.status == ERROR
 
138
  return self._m._cancel.get(self.job.id, False)
139
 
140
 
141
+ def _emit(job: "Job", event_name: str, **props):
142
+ """Bắn một dòng log máy-chạy cho job (nuốt mọi lỗi).
143
+
144
+ Job không mang ctx (endpoint chưa gửi user/session/task) -> bỏ qua im lặng,
145
+ tracking không bao giờ được làm hỏng pipeline.
146
+ """
147
+ if not job.ctx or not job.ctx.get("user_id"):
148
+ return
149
+ try:
150
+ from ..services.analytics.track import emit
151
+ emit(event_name, job.ctx, step="run", job_id=job.id, kind=job.kind, **props)
152
+ except Exception:
153
+ pass
154
+
155
+
156
  class JobManager:
157
  def __init__(self):
158
  self._jobs: dict[str, Job] = {}
 
176
  def _cat(kind: str) -> str:
177
  return "heavy" if kind in HEAVY_KINDS else "io"
178
 
179
+ def create(self, kind: str, ctx: dict | None = None) -> Job:
180
+ if ctx is None:
181
+ # Không truyền tay -> lấy từ header X-TNT-* của request đang chạy.
182
+ try:
183
+ from .reqctx import get_ctx
184
+ ctx = get_ctx()
185
+ except Exception:
186
+ ctx = {}
187
+ job = Job(id=uuid.uuid4().hex[:12], kind=kind, ctx=dict(ctx or {}))
188
  with self._lock:
189
  self._jobs[job.id] = job
190
  self._order.append(job.id)
 
253
  with self._lock:
254
  self._waiting[cat].append(job.id)
255
  job.status = QUEUED
256
+ _emit(job, "job_queued")
257
 
258
  def runner():
259
  with self._lock:
 
264
  return
265
  self._running[cat].add(job.id)
266
  job.status = RUNNING
267
+ job.started_at = time.time()
268
  job.queue_pos = 0
269
+ _emit(job, "job_started")
270
  try:
271
  result = fn(handle)
272
  with self._lock:
 
282
  handle.log(traceback.format_exc(), level="error")
283
  finally:
284
  with self._lock:
285
+ job.finished_at = time.time()
286
  self._running[cat].discard(job.id)
287
+ # latency_ms = CHẠY THẬT (started -> finished), không gồm chờ.
288
+ _emit(job, "job_done" if job.status == DONE else "job_failed",
289
+ latency_ms=int(max(0.0, job.finished_at - (job.started_at or job.finished_at)) * 1000),
290
+ status=job.status, items=len(job.items) or None,
291
+ error=(job.error or None))
292
 
293
  self._pools[cat].submit(runner)
294
 
backend/app/core/reqctx.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ngữ cảnh theo dõi đi kèm mỗi request (user / session / task / feature).
2
+
3
+ VÌ SAO LÀ HEADER, KHÔNG PHẢI BODY:
4
+ Job được tạo ở ~15 endpoint với ~15 model Pydantic khác nhau, lại có cả
5
+ endpoint nhận multipart (upload). Thêm 6 trường vào từng model là sửa rải
6
+ rác và rất dễ sót. Frontend gắn header `X-TNT-*` một chỗ duy nhất (bản vá
7
+ `fetch` trong `lib/track.js`), backend đọc một chỗ duy nhất ở đây, và
8
+ `jobs.create()` tự nhặt — endpoint không phải biết gì về tracking.
9
+
10
+ Dùng ContextVar nên an toàn với cả endpoint `async def` lẫn `def` (Starlette
11
+ chạy endpoint đồng bộ trong threadpool có sao chép context).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from contextvars import ContextVar
17
+
18
+ _ctx: ContextVar[dict] = ContextVar("tnt_track_ctx", default={})
19
+
20
+ HEADERS = {
21
+ "user_id": "x-tnt-user",
22
+ "session_id": "x-tnt-session",
23
+ "task_id": "x-tnt-task",
24
+ "feature": "x-tnt-feature",
25
+ "phase": "x-tnt-phase",
26
+ "attempt": "x-tnt-attempt",
27
+ }
28
+
29
+
30
+ def get_ctx() -> dict:
31
+ return _ctx.get() or {}
32
+
33
+
34
+ class TrackContextMiddleware:
35
+ """ASGI middleware thuần — CỐ Ý không dùng BaseHTTPMiddleware.
36
+
37
+ BaseHTTPMiddleware bọc response vào một luồng trung gian; app này phục vụ
38
+ file video lớn bằng FileResponse nên không đáng đánh đổi. Ở đây chỉ đọc vài
39
+ header rồi đặt ContextVar, không đụng gì tới request/response.
40
+ """
41
+
42
+ def __init__(self, app):
43
+ self.app = app
44
+
45
+ async def __call__(self, scope, receive, send):
46
+ if scope.get("type") != "http":
47
+ return await self.app(scope, receive, send)
48
+
49
+ raw = {}
50
+ for k, v in scope.get("headers") or ():
51
+ key = k.decode("latin-1").lower()
52
+ if key.startswith("x-tnt-"):
53
+ raw[key] = v.decode("latin-1", "ignore")[:120]
54
+
55
+ ctx = {k: raw.get(v, "") for k, v in HEADERS.items()}
56
+ try:
57
+ ctx["attempt"] = int(ctx["attempt"]) if ctx["attempt"] else None
58
+ except (TypeError, ValueError):
59
+ ctx["attempt"] = None
60
+ ctx["user_id"] = ctx["user_id"].strip().lower()
61
+
62
+ token = _ctx.set(ctx if ctx["user_id"] else {})
63
+ try:
64
+ await self.app(scope, receive, send)
65
+ finally:
66
+ _ctx.reset(token)
backend/app/main.py CHANGED
@@ -28,14 +28,18 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
28
 
29
  from .config import settings
30
  from .core.files import cleanup_old
 
31
  _here = os.path.dirname(os.path.abspath(__file__))
32
  _parent = os.path.dirname(_here)
33
  sys.path.insert(0, _parent)
34
 
35
- from .api import common, download, cut, shuffle, dub, listing, video, auth, drive, history_api, flux2, listing_free
36
 
37
  app = FastAPI(title="TNT Group - AI Content Create", version="1.0.0")
38
 
 
 
 
39
  app.add_middleware(
40
  CORSMiddleware,
41
  allow_origins=settings.CORS_ORIGINS or ["*"],
@@ -56,6 +60,7 @@ app.include_router(drive.router)
56
  app.include_router(history_api.router)
57
  app.include_router(flux2.router)
58
  app.include_router(listing_free.router)
 
59
 
60
 
61
  @app.get("/api/health")
 
28
 
29
  from .config import settings
30
  from .core.files import cleanup_old
31
+ from .core.reqctx import TrackContextMiddleware
32
  _here = os.path.dirname(os.path.abspath(__file__))
33
  _parent = os.path.dirname(_here)
34
  sys.path.insert(0, _parent)
35
 
36
+ from .api import common, download, cut, shuffle, dub, listing, video, auth, drive, history_api, flux2, listing_free, analytics
37
 
38
  app = FastAPI(title="TNT Group - AI Content Create", version="1.0.0")
39
 
40
+ # Nhặt ngữ cảnh theo dõi (X-TNT-*) trước khi vào endpoint.
41
+ app.add_middleware(TrackContextMiddleware)
42
+
43
  app.add_middleware(
44
  CORSMiddleware,
45
  allow_origins=settings.CORS_ORIGINS or ["*"],
 
60
  app.include_router(history_api.router)
61
  app.include_router(flux2.router)
62
  app.include_router(listing_free.router)
63
+ app.include_router(analytics.router)
64
 
65
 
66
  @app.get("/api/health")
backend/app/services/analytics/__init__.py ADDED
File without changes
backend/app/services/analytics/etl.py ADDED
@@ -0,0 +1,472 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ETL: từ `raw_events` suy ra 9 chỉ số (A1-A5, B1-B4).
2
+
3
+ Không bảng chỉ số dựng sẵn — mọi con số ở đây tính lại từ log thô mỗi lần gọi
4
+ (spec §1 "một dòng log, suy ra mọi chỉ số"). Dữ liệu nội bộ vài chục nghìn dòng
5
+ /tháng nên gộp bằng Python là đủ nhanh và tránh viết SQL hai lần cho PG/SQLite.
6
+
7
+ HAI ĐỒNG HỒ — dùng đúng chỗ:
8
+ - `event_ts` (giờ máy người dùng) -> MỌI phép TRỪ thời gian. Lệch đồng hồ
9
+ là hằng số trong một phiên nên hiệu số vẫn đúng. Không dùng `server_ts`
10
+ cho việc này: frontend gom event thành lô rồi mới gửi, mọi dòng trong lô
11
+ có `server_ts` gần bằng nhau -> thời gian mỗi bước sẽ hoá 0.
12
+ - `server_ts` (giờ máy chủ) -> LỌC khoảng ngày & gộp "theo ngày", vì đây là
13
+ đồng hồ duy nhất tin được khi so giữa nhiều người dùng.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections import defaultdict
19
+ from datetime import datetime, timedelta, timezone
20
+
21
+ from ...config import settings
22
+
23
+ # Thứ tự bước theo spec §2.
24
+ STEP_ORDER = ["open", "prepare", "run", "review", "output", "done"]
25
+
26
+ # Nhãn tiếng Việt cho FE khỏi phải nhúng bảng dịch.
27
+ FEATURE_LABELS = {
28
+ "down_list": "Down list",
29
+ "cut": "Cắt video",
30
+ "shuffle": "Xào video",
31
+ "dub": "Dịch / đổi giọng",
32
+ "text_video": "Chèn chữ vào video",
33
+ "listing_image": "Listing ảnh",
34
+ "listing_free": "Listing ảnh Free",
35
+ "flux2": "Flux2",
36
+ "create_video": "Tạo video AI",
37
+ "history": "Lịch sử cá nhân",
38
+ "guide": "Hướng dẫn",
39
+ "support": "Hỗ trợ",
40
+ "home": "Trang chủ",
41
+ "admin": "Quản trị",
42
+ }
43
+
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
+
50
+ def _median(xs: list[float]) -> float | None:
51
+ if not xs:
52
+ return None
53
+ ys = sorted(xs)
54
+ n = len(ys)
55
+ mid = n // 2
56
+ return float(ys[mid]) if n % 2 else (ys[mid - 1] + ys[mid]) / 2.0
57
+
58
+
59
+ def _pct(xs: list[float], p: float) -> float | None:
60
+ """Phân vị kiểu nearest-rank (đủ dùng, không cần numpy)."""
61
+ if not xs:
62
+ return None
63
+ ys = sorted(xs)
64
+ k = max(0, min(len(ys) - 1, int(round(p * (len(ys) - 1)))))
65
+ return float(ys[k])
66
+
67
+
68
+ def _avg(xs: list[float]) -> float | None:
69
+ return (sum(xs) / len(xs)) if xs else None
70
+
71
+
72
+ def _ratio(num: int, den: int) -> float | None:
73
+ return (num / den) if den else None
74
+
75
+
76
+ def local_date(ts: float) -> str:
77
+ """Epoch -> 'YYYY-MM-DD' theo múi giờ báo cáo (mặc định +07:00)."""
78
+ tz = timezone(timedelta(hours=settings.ANALYTICS_TZ_OFFSET))
79
+ return datetime.fromtimestamp(ts, tz).strftime("%Y-%m-%d")
80
+
81
+
82
+ # ── Dựng Task từ log ─────────────────────────────────────────────
83
+
84
+ def build_tasks(events: list[dict]) -> list[dict]:
85
+ """Gom event theo `task_id` -> danh sách task đã tính sẵn mốc chính.
86
+
87
+ Dòng không có `task_id` (vd `session_start`) không thuộc task nào.
88
+ """
89
+ groups: dict[str, list[dict]] = defaultdict(list)
90
+ for e in events:
91
+ if e.get("task_id"):
92
+ groups[e["task_id"]].append(e)
93
+
94
+ tasks = []
95
+ for task_id, evs in groups.items():
96
+ evs.sort(key=lambda x: (x.get("event_ts") or 0.0))
97
+ first, last = evs[0], evs[-1]
98
+
99
+ # Mốc bắt đầu = feature_open nếu có (spec: A1 đo từ lúc mở tool).
100
+ open_ev = next((e for e in evs if e["event_name"] == "feature_open"), None)
101
+ start_ts = (open_ev or first).get("event_ts") or 0.0
102
+ end_ts = last.get("event_ts") or start_ts
103
+
104
+ outputs = [e for e in evs if e["event_name"].startswith("output_")]
105
+ abandon = next((e for e in evs if e["event_name"] == "feature_abandon"), None)
106
+ complete = next((e for e in evs if e["event_name"] == "feature_complete"), None)
107
+
108
+ first_output_ts = outputs[0]["event_ts"] if outputs else None
109
+ success_type = ""
110
+ if outputs:
111
+ success_type = (outputs[0].get("properties") or {}).get("success_type") or (
112
+ "forward" if outputs[0]["event_name"] == "output_forward" else "download")
113
+ elif complete:
114
+ success_type = (complete.get("properties") or {}).get("success_type") or "done"
115
+
116
+ # B2/B4: chỉ dòng máy chạy mới có latency_ms.
117
+ jobs = [e for e in evs if e.get("latency_ms") is not None]
118
+ machine_ms = sum(e["latency_ms"] for e in jobs)
119
+
120
+ # Thời gian CHỜ tới lượt (queued -> started) — không tính vào tốc độ máy.
121
+ wait_ms = 0.0
122
+ queued: dict[str, float] = {}
123
+ for e in evs:
124
+ jid = (e.get("properties") or {}).get("job_id") or ""
125
+ if e["event_name"] == "job_queued":
126
+ queued[jid] = e.get("event_ts") or 0.0
127
+ elif e["event_name"] == "job_started" and jid in queued:
128
+ wait_ms += max(0.0, ((e.get("event_ts") or 0.0) - queued.pop(jid)) * 1000.0)
129
+
130
+ attempts = sum(1 for e in evs if e["event_name"] in ATTEMPT_EVENTS)
131
+
132
+ # B1: số sản phẩm sinh ra (properties.count/items nếu có, không thì đếm dòng).
133
+ produced = 0
134
+ for e in outputs:
135
+ p = e.get("properties") or {}
136
+ try:
137
+ produced += int(p.get("count") or p.get("items") or 1)
138
+ except (TypeError, ValueError):
139
+ produced += 1
140
+
141
+ thumbs = [e for e in evs if e["event_name"] == "thumbs_feedback"]
142
+
143
+ tasks.append({
144
+ "task_id": task_id,
145
+ "user_id": first.get("user_id", ""),
146
+ "session_id": first.get("session_id", ""),
147
+ "feature": first.get("feature", "") or (open_ev or first).get("feature", ""),
148
+ "start_ts": start_ts,
149
+ "end_ts": end_ts,
150
+ "server_ts": first.get("server_ts") or 0.0,
151
+ "date": local_date(first.get("server_ts") or 0.0),
152
+ "events": evs,
153
+ "n_events": len(evs),
154
+ "has_output": bool(outputs),
155
+ "first_output_ts": first_output_ts,
156
+ # A1 — xong một tính năng mất bao lâu (chỉ task có đầu ra).
157
+ "duration_ms": ((first_output_ts - start_ts) * 1000.0) if first_output_ts else None,
158
+ "span_ms": (end_ts - start_ts) * 1000.0,
159
+ "success_type": success_type,
160
+ "abandoned": bool(abandon) and not outputs,
161
+ "last_step": ((abandon.get("properties") or {}).get("last_step")
162
+ or abandon.get("step") or "") if abandon else "",
163
+ "abandon_reason": (abandon.get("properties") or {}).get("reason", "") if abandon else "",
164
+ "attempts": attempts,
165
+ "machine_ms": machine_ms, # B4
166
+ "wait_ms": wait_ms,
167
+ "jobs": [{"name": e["event_name"], "latency_ms": e["latency_ms"],
168
+ "step": e.get("step", ""),
169
+ "attempt": (e.get("properties") or {}).get("attempt")} for e in jobs],
170
+ "produced": produced, # B1
171
+ "thumbs_up": sum(1 for e in thumbs if e.get("event_value") == "up"),
172
+ "thumbs_down": sum(1 for e in thumbs if e.get("event_value") == "down"),
173
+ })
174
+
175
+ tasks.sort(key=lambda t: t["start_ts"])
176
+ return tasks
177
+
178
+
179
+ def step_durations(task: dict) -> list[dict]:
180
+ """A2 — thời gian mỗi bước: hiệu hai mốc `step` kề nhau trong một task."""
181
+ marks = []
182
+ for e in task["events"]:
183
+ st = e.get("step") or ""
184
+ if st and (not marks or marks[-1]["step"] != st):
185
+ marks.append({"step": st, "ts": e.get("event_ts") or 0.0})
186
+ # `done` là mốc cuối. Việc bấm 👍 sau khi tải xong mang step=output
187
+ # nên nếu không dừng ở đây, dòng thời gian hiện "…done → output",
188
+ # đọc như thể còn một bước nữa sau khi đã xong.
189
+ if st == "done":
190
+ break
191
+ out = []
192
+ for i, m in enumerate(marks):
193
+ end = marks[i + 1]["ts"] if i + 1 < len(marks) else task["end_ts"]
194
+ out.append({"step": m["step"], "ms": max(0.0, (end - m["ts"]) * 1000.0)})
195
+ return out
196
+
197
+
198
+ # ── Dựng Phiên (session) ─────────────────────────────────────────
199
+
200
+ def build_sessions(events: list[dict]) -> list[dict]:
201
+ """Gom theo `session_id`, cắt tiếp khi im lặng quá ngưỡng (spec §6).
202
+
203
+ Trả cả hai con số vì chúng khác nhau và đều cần:
204
+ span_ms = mốc cuối − mốc đầu (kể cả lúc bỏ máy đi pha cà phê).
205
+ active_ms = tổng khoảng cách giữa hai thao tác LIÊN TIẾP còn dưới ngưỡng
206
+ im lặng -> "thời lượng dùng thật".
207
+ """
208
+ idle_s = max(1, settings.SESSION_IDLE_MINUTES) * 60
209
+ groups: dict[str, list[dict]] = defaultdict(list)
210
+ for e in events:
211
+ if e.get("session_id"):
212
+ groups[e["session_id"]].append(e)
213
+
214
+ sessions = []
215
+ for sid, evs in groups.items():
216
+ evs.sort(key=lambda x: (x.get("event_ts") or 0.0))
217
+ # Một session_id để lâu vẫn có thể chứa nhiều lần dùng cách nhau nửa ngày
218
+ # (tab mở suốt) -> cắt thành nhiều phiên con.
219
+ chunk: list[dict] = []
220
+ chunks: list[list[dict]] = []
221
+ for e in evs:
222
+ if chunk and ((e.get("event_ts") or 0.0) - (chunk[-1].get("event_ts") or 0.0)) > idle_s:
223
+ chunks.append(chunk)
224
+ chunk = []
225
+ chunk.append(e)
226
+ if chunk:
227
+ chunks.append(chunk)
228
+
229
+ for i, ch in enumerate(chunks):
230
+ active = 0.0
231
+ for a, b in zip(ch, ch[1:]):
232
+ gap = (b.get("event_ts") or 0.0) - (a.get("event_ts") or 0.0)
233
+ if 0 <= gap <= idle_s:
234
+ active += gap
235
+ tids = {e["task_id"] for e in ch if e.get("task_id")}
236
+ feats = [e.get("feature", "") for e in ch if e.get("feature")]
237
+ sessions.append({
238
+ "session_id": sid if len(chunks) == 1 else f"{sid}#{i + 1}",
239
+ "user_id": ch[0].get("user_id", ""),
240
+ "start_ts": ch[0].get("event_ts") or 0.0,
241
+ "end_ts": ch[-1].get("event_ts") or 0.0,
242
+ "date": local_date(ch[0].get("server_ts") or 0.0),
243
+ "span_ms": ((ch[-1].get("event_ts") or 0.0) - (ch[0].get("event_ts") or 0.0)) * 1000.0,
244
+ "active_ms": active * 1000.0,
245
+ "n_events": len(ch),
246
+ "task_ids": sorted(tids),
247
+ "n_tasks": len(tids),
248
+ "features": sorted(set(feats)),
249
+ })
250
+
251
+ sessions.sort(key=lambda s: s["start_ts"])
252
+ return sessions
253
+
254
+
255
+ # ── Tổng hợp cho tab quản trị ────────────────────────────────────
256
+
257
+ def _bucket_metrics(tasks: list[dict]) -> dict:
258
+ """Cụm chỉ số dùng chung cho mọi cách gộp (toàn cục / theo tool / theo người)."""
259
+ done = [t for t in tasks if t["has_output"]]
260
+ durations = [t["duration_ms"] for t in done if t["duration_ms"] is not None]
261
+ machine = [t["machine_ms"] for t in tasks if t["machine_ms"]]
262
+ up = sum(t["thumbs_up"] for t in tasks)
263
+ down = sum(t["thumbs_down"] for t in tasks)
264
+ return {
265
+ "tasks": len(tasks),
266
+ "tasks_done": len(done),
267
+ "completion_rate": _ratio(len(done), len(tasks)), # A3
268
+ "duration_median_ms": _median(durations), # A1
269
+ "duration_p90_ms": _pct(durations, 0.9),
270
+ "attempts_avg": _avg([t["attempts"] for t in tasks]), # A4
271
+ "produced_total": sum(t["produced"] for t in tasks), # B1
272
+ "machine_median_ms": _median(machine), # B4
273
+ "machine_total_ms": sum(machine),
274
+ "wait_total_ms": sum(t["wait_ms"] for t in tasks),
275
+ "thumbs_up": up,
276
+ "thumbs_down": down,
277
+ "thumbs_rate": _ratio(up, up + down), # B3
278
+ "abandoned": sum(1 for t in tasks if t["abandoned"]),
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)
285
+ sessions = build_sessions(events)
286
+
287
+ by_user_tasks: dict[str, list[dict]] = defaultdict(list)
288
+ for t in tasks:
289
+ by_user_tasks[t["user_id"]].append(t)
290
+ by_user_sessions: dict[str, list[dict]] = defaultdict(list)
291
+ for s in sessions:
292
+ by_user_sessions[s["user_id"]].append(s)
293
+
294
+ # A5 — quay lại dùng: xuất hiện ở >= 2 ngày khác nhau.
295
+ days_by_user: dict[str, set[str]] = defaultdict(set)
296
+ for e in events:
297
+ if e.get("user_id"):
298
+ days_by_user[e["user_id"]].add(local_date(e.get("server_ts") or 0.0))
299
+
300
+ users = []
301
+ for uid in sorted(set(list(by_user_tasks) + list(by_user_sessions) + list(days_by_user))):
302
+ if not uid:
303
+ continue
304
+ ut, us = by_user_tasks.get(uid, []), by_user_sessions.get(uid, [])
305
+ feat_count: dict[str, int] = defaultdict(int)
306
+ for t in ut:
307
+ feat_count[t["feature"]] += 1
308
+ m = _bucket_metrics(ut)
309
+ users.append({
310
+ "user_id": uid,
311
+ "days_active": len(days_by_user.get(uid, ())),
312
+ "returning": len(days_by_user.get(uid, ())) >= 2, # A5
313
+ "sessions": len(us),
314
+ "active_ms": sum(s["active_ms"] for s in us),
315
+ "span_ms": sum(s["span_ms"] for s in us),
316
+ "first_seen": min((s["start_ts"] for s in us), default=None),
317
+ "last_seen": max((s["end_ts"] for s in us), default=None),
318
+ # Kèm nhãn tiếng Việt: bảng trên giao diện hiển thị tên tool ở mọi
319
+ # cột khác, riêng cột này mà hiện khoá thô (down_list) thì lệch.
320
+ "top_features": [
321
+ {"feature": f, "label": FEATURE_LABELS.get(f, f), "tasks": n}
322
+ for f, n in sorted(feat_count.items(), key=lambda kv: -kv[1])[:3]
323
+ ],
324
+ **m,
325
+ })
326
+ users.sort(key=lambda u: -(u["active_ms"] or 0))
327
+
328
+ # Theo tool
329
+ by_feat: dict[str, list[dict]] = defaultdict(list)
330
+ for t in tasks:
331
+ by_feat[t["feature"]].append(t)
332
+ features = []
333
+ for f, ft in sorted(by_feat.items()):
334
+ abandon_steps: dict[str, int] = defaultdict(int)
335
+ for t in ft:
336
+ if t["abandoned"]:
337
+ abandon_steps[t["last_step"] or "?"] += 1
338
+ step_ms: dict[str, list[float]] = defaultdict(list)
339
+ for t in ft:
340
+ for d in step_durations(t):
341
+ step_ms[d["step"]].append(d["ms"])
342
+ features.append({
343
+ "feature": f,
344
+ "label": FEATURE_LABELS.get(f, f),
345
+ "users": len({t["user_id"] for t in ft}),
346
+ "abandon_by_step": dict(abandon_steps), # A2
347
+ "step_median_ms": {k: _median(v) for k, v in step_ms.items()
348
+ if k in STEP_ORDER},
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)
355
+ for e in events:
356
+ if e.get("latency_ms") is not None:
357
+ job_ms[f"{e.get('feature', '')}|{(e.get('properties') or {}).get('phase') or e['event_name']}"].append(
358
+ float(e["latency_ms"]))
359
+ jobs = [{
360
+ "feature": k.split("|", 1)[0],
361
+ "label": FEATURE_LABELS.get(k.split("|", 1)[0], k.split("|", 1)[0]),
362
+ "phase": k.split("|", 1)[1],
363
+ "runs": len(v),
364
+ "median_ms": _median(v),
365
+ "p90_ms": _pct(v, 0.9),
366
+ "total_ms": sum(v),
367
+ } for k, v in job_ms.items()]
368
+ jobs.sort(key=lambda j: -j["runs"])
369
+
370
+ # Sản phẩm làm ra: gộp theo tool + loại file
371
+ products: dict[str, int] = defaultdict(int)
372
+ for e in events:
373
+ if e["event_name"].startswith("output_"):
374
+ # Chuyển tiếp sang tool khác cũng là một đầu ra, nhưng phải đọc ra
375
+ # được là "chuyển sang cut" chứ không lẫn với "file .cut".
376
+ if e["event_name"] == "output_forward":
377
+ kind = "→ " + (e.get("event_value") or "tool khác")
378
+ else:
379
+ kind = e.get("event_value") or "file"
380
+ n = (e.get("properties") or {}).get("count") or 1
381
+ try:
382
+ n = int(n)
383
+ except (TypeError, ValueError):
384
+ n = 1
385
+ products[f"{e.get('feature', '')}|{kind}"] += n
386
+ product_rows = [{
387
+ "feature": k.split("|", 1)[0],
388
+ "label": FEATURE_LABELS.get(k.split("|", 1)[0], k.split("|", 1)[0]),
389
+ "kind": k.split("|", 1)[1],
390
+ "count": v,
391
+ } for k, v in sorted(products.items(), key=lambda kv: -kv[1])]
392
+
393
+ # Chuỗi theo ngày
394
+ daily: dict[str, dict] = defaultdict(lambda: {
395
+ "date": "", "users": set(), "sessions": 0, "tasks": 0,
396
+ "tasks_done": 0, "active_ms": 0.0, "machine_ms": 0.0, "outputs": 0})
397
+ for s in sessions:
398
+ d = daily[s["date"]]
399
+ d["date"] = s["date"]
400
+ d["users"].add(s["user_id"])
401
+ d["sessions"] += 1
402
+ d["active_ms"] += s["active_ms"]
403
+ for t in tasks:
404
+ d = daily[t["date"]]
405
+ d["date"] = t["date"]
406
+ d["tasks"] += 1
407
+ d["tasks_done"] += 1 if t["has_output"] else 0
408
+ d["machine_ms"] += t["machine_ms"]
409
+ d["outputs"] += t["produced"]
410
+ timeseries = [{**v, "users": len(v["users"])} for v in
411
+ sorted(daily.values(), key=lambda x: x["date"])]
412
+
413
+ overall = _bucket_metrics(tasks)
414
+ overall.update({
415
+ "users": len([u for u in users if u["tasks"] or u["sessions"]]),
416
+ "returning_users": sum(1 for u in users if u["returning"]),
417
+ "sessions": len(sessions),
418
+ "active_ms": sum(s["active_ms"] for s in sessions),
419
+ "session_median_ms": _median([s["active_ms"] for s in sessions]),
420
+ "events": len(events),
421
+ })
422
+
423
+ return {
424
+ "overall": overall,
425
+ "users": users,
426
+ "features": features,
427
+ "jobs": jobs,
428
+ "products": product_rows,
429
+ "timeseries": timeseries,
430
+ }
431
+
432
+
433
+ def user_detail(events: list[dict], user_id: str) -> dict:
434
+ """Chi tiết một người: từng phiên, từng task trong phiên."""
435
+ evs = [e for e in events if e.get("user_id") == user_id]
436
+ tasks = {t["task_id"]: t for t in build_tasks(evs)}
437
+ sessions = build_sessions(evs)
438
+
439
+ out_sessions = []
440
+ for s in sessions:
441
+ rows = []
442
+ for tid in s["task_ids"]:
443
+ t = tasks.get(tid)
444
+ if not t:
445
+ continue
446
+ rows.append({
447
+ "task_id": t["task_id"],
448
+ "feature": t["feature"],
449
+ "label": FEATURE_LABELS.get(t["feature"], t["feature"]),
450
+ "start_ts": t["start_ts"],
451
+ "duration_ms": t["duration_ms"],
452
+ "span_ms": t["span_ms"],
453
+ "machine_ms": t["machine_ms"],
454
+ "wait_ms": t["wait_ms"],
455
+ "attempts": t["attempts"],
456
+ "produced": t["produced"],
457
+ "has_output": t["has_output"],
458
+ "success_type": t["success_type"],
459
+ "abandoned": t["abandoned"],
460
+ "last_step": t["last_step"],
461
+ "abandon_reason": t["abandon_reason"],
462
+ "thumbs_up": t["thumbs_up"],
463
+ "thumbs_down": t["thumbs_down"],
464
+ "steps": step_durations(t),
465
+ "jobs": t["jobs"],
466
+ })
467
+ rows.sort(key=lambda r: r["start_ts"])
468
+ out_sessions.append({**{k: v for k, v in s.items() if k != "task_ids"}, "tasks": rows})
469
+
470
+ out_sessions.sort(key=lambda s: -s["start_ts"])
471
+ return {"user_id": user_id, "sessions": out_sessions,
472
+ "totals": _bucket_metrics(list(tasks.values()))}
backend/app/services/analytics/storage.py ADDED
@@ -0,0 +1,382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Kho log sự kiện thô (`raw_events`) — nền cho tab "Quản lý thời lượng".
2
+
3
+ NGUYÊN TẮC (theo User_Flow_and_Tracking_Spec §1):
4
+ Không lưu sẵn chỉ số. Chỉ ghi log thô, mọi chỉ số tính sau bằng ETL
5
+ (xem `etl.py`). Một thao tác = một dòng.
6
+
7
+ BACKEND LƯU TRỮ — giống `services/auth/storage.py`:
8
+ - Có DATABASE_URL -> PostgreSQL (bền, dùng trên HF Spaces).
9
+ - Không -> SQLite trong STORAGE_DIR (đủ cho máy dev).
10
+ KHÔNG dùng JSON như bảng user: log nhiều gấp hàng trăm lần, cần append
11
+ rẻ + lọc theo khoảng thời gian.
12
+
13
+ MỐC THỜI GIAN: mỗi dòng giữ HAI đồng hồ.
14
+ - event_ts = giờ máy người dùng (dùng tính hiệu số trong cùng phiên).
15
+ - server_ts = giờ máy chủ lúc nhận (dùng xếp thứ tự & lọc theo ngày,
16
+ không lệch khi đồng hồ máy khách sai).
17
+ Cả hai là epoch giây (float) nên hai backend query giống hệt nhau; chuỗi ISO
18
+ gốc vẫn giữ ở cột `event_time` để đối chiếu khi cần.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import sqlite3
26
+ import threading
27
+ import time
28
+ import uuid
29
+ from datetime import datetime, timezone
30
+
31
+ from ...config import settings
32
+
33
+ _lock = threading.Lock()
34
+
35
+ # Trần số dòng trả về cho một lần query (chặn kéo cả bảng về RAM).
36
+ MAX_ROWS = 300_000
37
+
38
+ COLUMNS = (
39
+ "event_id", "event_time", "event_ts", "server_ts", "user_id", "session_id",
40
+ "task_id", "feature", "step", "event_name", "event_value", "latency_ms",
41
+ "properties_json",
42
+ )
43
+
44
+
45
+ # ── Chuẩn hoá một dòng event ─────────────────────────────────────
46
+
47
+ def _parse_iso(s: str) -> float | None:
48
+ """ISO8601 (kèm ms + múi giờ) -> epoch giây. Sai định dạng -> None."""
49
+ if not s:
50
+ return None
51
+ try:
52
+ txt = s.strip().replace("Z", "+00:00")
53
+ dt = datetime.fromisoformat(txt)
54
+ if dt.tzinfo is None:
55
+ dt = dt.replace(tzinfo=timezone.utc)
56
+ return dt.timestamp()
57
+ except (ValueError, TypeError):
58
+ return None
59
+
60
+
61
+ def normalize(raw: dict, server_ts: float | None = None) -> dict:
62
+ """Ép một event từ client về đúng schema, cắt bớt chuỗi quá dài.
63
+
64
+ latency_ms: CHỈ giữ khi là dòng máy chạy. Client gửi 0/rỗng -> None
65
+ (spec §2 "quy tắc vàng": đừng nhét 0, làm hỏng trung bình).
66
+ """
67
+ srv = server_ts if server_ts is not None else time.time()
68
+
69
+ def s(key: str, limit: int = 200) -> str:
70
+ v = raw.get(key)
71
+ return "" if v is None else str(v)[:limit]
72
+
73
+ lat = raw.get("latency_ms")
74
+ try:
75
+ lat = int(lat) if lat not in (None, "", False) else None
76
+ except (TypeError, ValueError):
77
+ lat = None
78
+ if lat is not None and lat < 0:
79
+ lat = None
80
+
81
+ props = raw.get("properties")
82
+ if not isinstance(props, dict):
83
+ props = raw.get("properties_json")
84
+ if isinstance(props, str):
85
+ try:
86
+ props = json.loads(props)
87
+ except (json.JSONDecodeError, TypeError):
88
+ props = {"_raw": props[:500]}
89
+ if not isinstance(props, dict):
90
+ props = {}
91
+
92
+ event_time = s("event_time", 64)
93
+ ts = _parse_iso(event_time)
94
+ if ts is None:
95
+ ts = srv
96
+ event_time = datetime.fromtimestamp(srv, timezone.utc).isoformat(timespec="milliseconds")
97
+
98
+ return {
99
+ "event_id": s("event_id", 64) or uuid.uuid4().hex,
100
+ "event_time": event_time,
101
+ "event_ts": ts,
102
+ "server_ts": srv,
103
+ "user_id": s("user_id", 120).strip().lower(),
104
+ "session_id": s("session_id", 64),
105
+ "task_id": s("task_id", 64),
106
+ "feature": s("feature", 40),
107
+ "step": s("step", 20),
108
+ "event_name": s("event_name", 60),
109
+ "event_value": s("event_value", 200),
110
+ "latency_ms": lat,
111
+ "properties_json": json.dumps(props, ensure_ascii=False)[:4000],
112
+ }
113
+
114
+
115
+ def _row_to_dict(row) -> dict:
116
+ d = dict(zip(COLUMNS, row))
117
+ try:
118
+ d["properties"] = json.loads(d.pop("properties_json") or "{}")
119
+ except (json.JSONDecodeError, TypeError):
120
+ d.pop("properties_json", None)
121
+ d["properties"] = {}
122
+ return d
123
+
124
+
125
+ # ── SQLite backend ───────────────────────────────────────────────
126
+
127
+ def _sqlite_path() -> str:
128
+ return os.path.join(settings.STORAGE_DIR, "analytics.db")
129
+
130
+
131
+ def _sq_conn():
132
+ os.makedirs(settings.STORAGE_DIR, exist_ok=True)
133
+ conn = sqlite3.connect(_sqlite_path(), timeout=10)
134
+ conn.execute("PRAGMA journal_mode=WAL")
135
+ return conn
136
+
137
+
138
+ def _sq_init():
139
+ conn = _sq_conn()
140
+ conn.execute("""
141
+ CREATE TABLE IF NOT EXISTS raw_events (
142
+ event_id TEXT PRIMARY KEY,
143
+ event_time TEXT NOT NULL,
144
+ event_ts REAL NOT NULL,
145
+ server_ts REAL NOT NULL,
146
+ user_id TEXT NOT NULL DEFAULT '',
147
+ session_id TEXT NOT NULL DEFAULT '',
148
+ task_id TEXT NOT NULL DEFAULT '',
149
+ feature TEXT NOT NULL DEFAULT '',
150
+ step TEXT NOT NULL DEFAULT '',
151
+ event_name TEXT NOT NULL DEFAULT '',
152
+ event_value TEXT NOT NULL DEFAULT '',
153
+ latency_ms INTEGER,
154
+ properties_json TEXT NOT NULL DEFAULT '{}'
155
+ )
156
+ """)
157
+ conn.execute("CREATE INDEX IF NOT EXISTS ix_ev_server_ts ON raw_events (server_ts)")
158
+ conn.execute("CREATE INDEX IF NOT EXISTS ix_ev_user ON raw_events (user_id, server_ts)")
159
+ conn.execute("CREATE INDEX IF NOT EXISTS ix_ev_task ON raw_events (task_id)")
160
+ conn.commit()
161
+ conn.close()
162
+
163
+
164
+ def _sq_insert(rows: list[dict]) -> int:
165
+ conn = _sq_conn()
166
+ placeholders = ",".join(["?"] * len(COLUMNS))
167
+ cur = conn.executemany(
168
+ "INSERT OR IGNORE INTO raw_events (%s) VALUES (%s)" % (",".join(COLUMNS), placeholders),
169
+ [tuple(r[c] for c in COLUMNS) for r in rows],
170
+ )
171
+ n = cur.rowcount
172
+ conn.commit()
173
+ conn.close()
174
+ return max(0, n)
175
+
176
+
177
+ def _sq_query(where: str, params: list, limit: int, desc: bool) -> list[dict]:
178
+ conn = _sq_conn()
179
+ cur = conn.execute(
180
+ "SELECT %s FROM raw_events %s ORDER BY server_ts %s LIMIT ?"
181
+ % (",".join(COLUMNS), where, "DESC" if desc else "ASC"),
182
+ (*params, limit),
183
+ )
184
+ rows = [_row_to_dict(r) for r in cur.fetchall()]
185
+ conn.close()
186
+ return rows
187
+
188
+
189
+ def _sq_purge(before_ts: float) -> int:
190
+ conn = _sq_conn()
191
+ cur = conn.execute("DELETE FROM raw_events WHERE server_ts < ?", (before_ts,))
192
+ n = cur.rowcount
193
+ conn.commit()
194
+ conn.close()
195
+ return max(0, n)
196
+
197
+
198
+ def _sq_stats() -> dict:
199
+ conn = _sq_conn()
200
+ row = conn.execute("SELECT COUNT(*), MIN(server_ts), MAX(server_ts) FROM raw_events").fetchone()
201
+ conn.close()
202
+ return {"rows": row[0] or 0, "first_ts": row[1], "last_ts": row[2]}
203
+
204
+
205
+ # ── PostgreSQL backend ───────────────────────────────────────────
206
+
207
+ try:
208
+ import psycopg2
209
+ import psycopg2.extras
210
+ _PG_AVAILABLE = True
211
+ except ImportError:
212
+ _PG_AVAILABLE = False
213
+
214
+
215
+ def _pg_conn():
216
+ conn = psycopg2.connect(settings.DATABASE_URL, connect_timeout=5)
217
+ conn.autocommit = True
218
+ return conn
219
+
220
+
221
+ def _pg_init():
222
+ conn = _pg_conn()
223
+ cur = conn.cursor()
224
+ cur.execute("""
225
+ CREATE TABLE IF NOT EXISTS raw_events (
226
+ event_id TEXT PRIMARY KEY,
227
+ event_time TEXT NOT NULL,
228
+ event_ts DOUBLE PRECISION NOT NULL,
229
+ server_ts DOUBLE PRECISION NOT NULL,
230
+ user_id TEXT NOT NULL DEFAULT '',
231
+ session_id TEXT NOT NULL DEFAULT '',
232
+ task_id TEXT NOT NULL DEFAULT '',
233
+ feature TEXT NOT NULL DEFAULT '',
234
+ step TEXT NOT NULL DEFAULT '',
235
+ event_name TEXT NOT NULL DEFAULT '',
236
+ event_value TEXT NOT NULL DEFAULT '',
237
+ latency_ms BIGINT,
238
+ properties_json TEXT NOT NULL DEFAULT '{}'
239
+ )
240
+ """)
241
+ cur.execute("CREATE INDEX IF NOT EXISTS ix_ev_server_ts ON raw_events (server_ts)")
242
+ cur.execute("CREATE INDEX IF NOT EXISTS ix_ev_user ON raw_events (user_id, server_ts)")
243
+ cur.execute("CREATE INDEX IF NOT EXISTS ix_ev_task ON raw_events (task_id)")
244
+ cur.close()
245
+ conn.close()
246
+
247
+
248
+ def _pg_insert(rows: list[dict]) -> int:
249
+ conn = _pg_conn()
250
+ cur = conn.cursor()
251
+ psycopg2.extras.execute_values(
252
+ cur,
253
+ "INSERT INTO raw_events (%s) VALUES %%s ON CONFLICT (event_id) DO NOTHING" % ",".join(COLUMNS),
254
+ [tuple(r[c] for c in COLUMNS) for r in rows],
255
+ page_size=200,
256
+ )
257
+ n = cur.rowcount
258
+ cur.close()
259
+ conn.close()
260
+ return max(0, n)
261
+
262
+
263
+ def _pg_query(where: str, params: list, limit: int, desc: bool) -> list[dict]:
264
+ conn = _pg_conn()
265
+ cur = conn.cursor()
266
+ cur.execute(
267
+ "SELECT %s FROM raw_events %s ORDER BY server_ts %s LIMIT %%s"
268
+ % (",".join(COLUMNS), where.replace("?", "%s"), "DESC" if desc else "ASC"),
269
+ (*params, limit),
270
+ )
271
+ rows = [_row_to_dict(r) for r in cur.fetchall()]
272
+ cur.close()
273
+ conn.close()
274
+ return rows
275
+
276
+
277
+ def _pg_purge(before_ts: float) -> int:
278
+ conn = _pg_conn()
279
+ cur = conn.cursor()
280
+ cur.execute("DELETE FROM raw_events WHERE server_ts < %s", (before_ts,))
281
+ n = cur.rowcount
282
+ cur.close()
283
+ conn.close()
284
+ return max(0, n)
285
+
286
+
287
+ def _pg_stats() -> dict:
288
+ conn = _pg_conn()
289
+ cur = conn.cursor()
290
+ cur.execute("SELECT COUNT(*), MIN(server_ts), MAX(server_ts) FROM raw_events")
291
+ row = cur.fetchone()
292
+ cur.close()
293
+ conn.close()
294
+ return {"rows": row[0] or 0, "first_ts": row[1], "last_ts": row[2]}
295
+
296
+
297
+ # ── Chọn backend (lười; thử PG trước, lỗi thì lùi SQLite) ────────
298
+
299
+ _backend: str | None = None
300
+
301
+
302
+ def _resolve() -> str:
303
+ global _backend
304
+ if _backend is not None:
305
+ return _backend
306
+ if _PG_AVAILABLE and settings.DATABASE_URL:
307
+ try:
308
+ _pg_init()
309
+ _backend = "pg"
310
+ print("[analytics] Using PostgreSQL backend")
311
+ return _backend
312
+ except Exception as ex:
313
+ print(f"[analytics] PostgreSQL unavailable ({ex}), fallback to SQLite")
314
+ _sq_init()
315
+ _backend = "sqlite"
316
+ print(f"[analytics] Using SQLite backend ({_sqlite_path()})")
317
+ return _backend
318
+
319
+
320
+ def backend_name() -> str:
321
+ return _resolve()
322
+
323
+
324
+ # ── API công khai ────────────────────────────────────────────────
325
+
326
+ def insert(events: list[dict]) -> int:
327
+ """Ghi một lô event ĐÃ chuẩn hoá. Trả số dòng thực sự thêm mới."""
328
+ rows = [e for e in events if e.get("event_name") and e.get("user_id")]
329
+ if not rows:
330
+ return 0
331
+ with _lock:
332
+ return _pg_insert(rows) if _resolve() == "pg" else _sq_insert(rows)
333
+
334
+
335
+ def query(from_ts: float | None = None, to_ts: float | None = None,
336
+ user_id: str = "", feature: str = "", task_id: str = "",
337
+ limit: int = MAX_ROWS, newest_first: bool = False) -> list[dict]:
338
+ """Lấy event thô trong khoảng (lọc theo server_ts — đồng hồ máy chủ).
339
+
340
+ `newest_first`: BẮT BUỘC bật khi có cắt `limit` mà muốn xem log mới nhất.
341
+ Sắp tăng dần rồi LIMIT sẽ lấy nhầm N dòng CŨ NHẤT — ETL cần thứ tự tăng dần
342
+ và lấy hết nên để mặc định, còn màn "log thô" thì phải đảo.
343
+ """
344
+ clauses: list[str] = []
345
+ params: list = []
346
+ if from_ts is not None:
347
+ clauses.append("server_ts >= ?")
348
+ params.append(from_ts)
349
+ if to_ts is not None:
350
+ clauses.append("server_ts < ?")
351
+ params.append(to_ts)
352
+ if user_id:
353
+ clauses.append("user_id = ?")
354
+ params.append(user_id.strip().lower())
355
+ if feature:
356
+ clauses.append("feature = ?")
357
+ params.append(feature)
358
+ if task_id:
359
+ clauses.append("task_id = ?")
360
+ params.append(task_id)
361
+ where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
362
+ limit = max(1, min(int(limit or MAX_ROWS), MAX_ROWS))
363
+ if _resolve() == "pg":
364
+ return _pg_query(where, params, limit, newest_first)
365
+ return _sq_query(where, params, limit, newest_first)
366
+
367
+
368
+ def purge_old(days: int | None = None) -> int:
369
+ """Xoá log cũ hơn `days` ngày (mặc định ANALYTICS_RETENTION_DAYS)."""
370
+ d = days if days is not None else settings.ANALYTICS_RETENTION_DAYS
371
+ if not d or d <= 0:
372
+ return 0
373
+ before = time.time() - d * 86400
374
+ with _lock:
375
+ return _pg_purge(before) if _resolve() == "pg" else _sq_purge(before)
376
+
377
+
378
+ def stats() -> dict:
379
+ st = _pg_stats() if _resolve() == "pg" else _sq_stats()
380
+ st["backend"] = _backend
381
+ st["retention_days"] = settings.ANALYTICS_RETENTION_DAYS
382
+ return st
backend/app/services/analytics/track.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ghi event TỪ PHÍA MÁY CHỦ (dòng máy chạy).
2
+
3
+ Phân vai rõ ràng (spec §1 "hai chiếc đồng hồ"):
4
+ - Hành vi người -> frontend ghi (thời gian tường, gồm cả lúc ngồi nghĩ).
5
+ - Máy chạy -> BACKEND ghi ở đây, vì chỉ máy chủ biết job rời hàng đợi
6
+ lúc nào và chạy hết bao lâu. Frontend KHÔNG được bắn `job_done` nữa,
7
+ tránh đếm đôi.
8
+
9
+ Ghi log không bao giờ được làm hỏng job: mọi lỗi ở đây đều nuốt.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import threading
15
+ import uuid
16
+ from datetime import datetime, timezone
17
+
18
+ # Ghi vào DB ở luồng riêng: một cú INSERT chậm (Neon vừa thức dậy) không được
19
+ # chặn worker đang chạy job.
20
+ _pool_lock = threading.Lock()
21
+
22
+
23
+ def now_iso() -> str:
24
+ return datetime.now(timezone.utc).isoformat(timespec="milliseconds")
25
+
26
+
27
+ def ctx_from(body: dict | None) -> dict:
28
+ """Bóc 4 tầng định danh mà frontend gửi kèm khi tạo job."""
29
+ b = body or {}
30
+ return {
31
+ "user_id": str(b.get("user_id") or b.get("username") or "")[:120],
32
+ "session_id": str(b.get("session_id") or "")[:64],
33
+ "task_id": str(b.get("task_id") or "")[:64],
34
+ "feature": str(b.get("feature") or "")[:40],
35
+ "phase": str(b.get("phase") or "")[:40],
36
+ "attempt": b.get("attempt"),
37
+ }
38
+
39
+
40
+ def emit(event_name: str, ctx: dict, *, step: str = "run",
41
+ latency_ms: int | None = None, event_value: str = "",
42
+ **props) -> None:
43
+ """Ghi một dòng event. Thiếu user_id -> bỏ qua (không có gì để quy về ai)."""
44
+ ctx = ctx or {}
45
+ if not ctx.get("user_id"):
46
+ return
47
+
48
+ def _work():
49
+ try:
50
+ from . import storage as store
51
+ extra = {k: v for k, v in props.items() if v is not None}
52
+ if ctx.get("phase"):
53
+ extra.setdefault("phase", ctx["phase"])
54
+ if ctx.get("attempt") is not None:
55
+ extra.setdefault("attempt", ctx["attempt"])
56
+ extra.setdefault("src", "backend")
57
+ store.insert([store.normalize({
58
+ "event_id": uuid.uuid4().hex,
59
+ "event_time": now_iso(),
60
+ "user_id": ctx.get("user_id", ""),
61
+ "session_id": ctx.get("session_id", ""),
62
+ "task_id": ctx.get("task_id", ""),
63
+ "feature": ctx.get("feature", ""),
64
+ "step": step,
65
+ "event_name": event_name,
66
+ "event_value": event_value,
67
+ "latency_ms": latency_ms,
68
+ "properties": extra,
69
+ })])
70
+ except Exception as ex: # pragma: no cover
71
+ print(f"[analytics] emit {event_name} error: {ex}")
72
+
73
+ threading.Thread(target=_work, daemon=True).start()
backend/tests/run_tests.py CHANGED
@@ -22,6 +22,21 @@ BACKEND = os.path.dirname(HERE)
22
  if BACKEND not in sys.path:
23
  sys.path.insert(0, BACKEND)
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  GREEN, RED, DIM, BOLD, RESET = "\033[32m", "\033[31m", "\033[2m", "\033[1m", "\033[0m"
26
 
27
 
@@ -44,6 +59,7 @@ def main() -> int:
44
  failures = []
45
 
46
  print(f"{BOLD}TNT Content AI — bộ test luồng backend{RESET}")
 
47
  print(f"{DIM}python {sys.version.split()[0]} · {len(modules)} file test{RESET}\n")
48
 
49
  for modname in modules:
 
22
  if BACKEND not in sys.path:
23
  sys.path.insert(0, BACKEND)
24
 
25
+ # VÙNG LÀM VIỆC RIÊNG CHO TEST — đặt TRƯỚC khi import bất kỳ module app nào.
26
+ # Không ép thì test dùng STORAGE_DIR thật trong .env: mỗi lần chạy vừa đổ thư mục
27
+ # rác vào đó, vừa kích hoạt cleanup_old xoá mất phiên làm việc thật của người dùng.
28
+ # Chỉ set biến môi trường là chưa đủ vì config load .env với override=True.
29
+ import tempfile # noqa: E402
30
+
31
+ _TEST_STORAGE = tempfile.mkdtemp(prefix="tnt_tests_")
32
+ os.environ["STORAGE_DIR"] = _TEST_STORAGE
33
+ try:
34
+ from app.config import settings as _settings
35
+
36
+ _settings.STORAGE_DIR = _TEST_STORAGE
37
+ except Exception: # pragma: no cover - thiếu deps thì để test tự báo lỗi
38
+ pass
39
+
40
  GREEN, RED, DIM, BOLD, RESET = "\033[32m", "\033[31m", "\033[2m", "\033[1m", "\033[0m"
41
 
42
 
 
59
  failures = []
60
 
61
  print(f"{BOLD}TNT Content AI — bộ test luồng backend{RESET}")
62
+ print(f"{DIM}vùng làm việc test: {_TEST_STORAGE}{RESET}")
63
  print(f"{DIM}python {sys.version.split()[0]} · {len(modules)} file test{RESET}\n")
64
 
65
  for modname in modules:
backend/tests/test_analytics.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test theo dõi người dùng: ghi log thô -> suy ra 9 chỉ số (A1-A5, B1-B4).
2
+
3
+ Cô lập bằng cách trỏ STORAGE_DIR vào thư mục tạm TRƯỚC khi import storage, và
4
+ ép DATABASE_URL rỗng để luôn chạy nhánh SQLite (không đụng Postgres thật).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import tempfile
10
+ import time
11
+ import uuid
12
+ from datetime import datetime, timedelta, timezone
13
+
14
+ _TEST_STORAGE = tempfile.mkdtemp(prefix="tnt_an_")
15
+ os.environ["STORAGE_DIR"] = _TEST_STORAGE
16
+
17
+ from app.config import settings # noqa: E402
18
+
19
+ settings.STORAGE_DIR = _TEST_STORAGE # .env của máy load override -> ép lại
20
+ settings.DATABASE_URL = "" # ép SQLite
21
+ os.makedirs(_TEST_STORAGE, exist_ok=True)
22
+
23
+ from app.services.analytics import etl, storage as store # noqa: E402
24
+
25
+ TZ = timezone(timedelta(hours=7))
26
+ T0 = time.time() - 3600
27
+
28
+
29
+ def _ev(off, name, step, *, feature="dub", task="task_1", sess="sess_1",
30
+ user="an", lat=None, value="", **props):
31
+ """Một dòng log đã chuẩn hoá, đặt tại mốc T0+off giây."""
32
+ ts = T0 + off
33
+ row = store.normalize({
34
+ "event_id": uuid.uuid4().hex,
35
+ "event_time": datetime.fromtimestamp(ts, TZ).isoformat(timespec="milliseconds"),
36
+ "user_id": user, "session_id": sess, "task_id": task, "feature": feature,
37
+ "step": step, "event_name": name, "event_value": value,
38
+ "latency_ms": lat, "properties": props,
39
+ }, ts)
40
+ row["event_ts"] = ts # giữ nguyên trục thời gian giả lập
41
+ # storage.normalize() trả dạng GHI (properties_json). Khi đọc lại từ DB,
42
+ # _row_to_dict() bung thành `properties` dict — ETL dùng dạng đó. Cho dòng
43
+ # mang cả hai để test được cả nhánh ghi lẫn nhánh tính chỉ số.
44
+ row["properties"] = dict(props)
45
+ return row
46
+
47
+
48
+ def _one_full_task():
49
+ """Một task dub hoàn chỉnh: 2 lần máy, người sửa ở giữa, tải về, 👍."""
50
+ return [
51
+ _ev(0, "session_start", "open", feature="", task=""),
52
+ _ev(2, "feature_open", "open"),
53
+ _ev(6, "upload_done", "prepare"),
54
+ _ev(9, "job_queued", "run", job_id="j1"),
55
+ _ev(11, "job_started", "run", job_id="j1"),
56
+ _ev(41, "job_done", "run", lat=30000, job_id="j1", phase="asr_translate"),
57
+ _ev(120, "edit_translation", "review"),
58
+ _ev(200, "job_queued", "run", job_id="j2"),
59
+ _ev(202, "job_started", "run", job_id="j2"),
60
+ _ev(260, "job_done", "run", lat=59000, job_id="j2", phase="tts"),
61
+ _ev(275, "output_download", "output", value="mp4", count=2,
62
+ success_type="download"),
63
+ _ev(277, "thumbs_feedback", "output", value="up"),
64
+ _ev(280, "feature_complete", "done", success_type="download"),
65
+ ]
66
+
67
+
68
+ # ── Chuẩn hoá dòng log ───────────────────────────────────────────
69
+
70
+ def test_normalize_drops_zero_latency():
71
+ """latency_ms chỉ dành cho dòng MÁY chạy: 0/rỗng phải thành None.
72
+
73
+ Nhét 0 vào là kéo tụt trung bình tốc độ máy (spec §2 "quy tắc vàng").
74
+ """
75
+ assert store.normalize({"event_name": "x", "user_id": "a", "latency_ms": 0})["latency_ms"] is None
76
+ assert store.normalize({"event_name": "x", "user_id": "a", "latency_ms": ""})["latency_ms"] is None
77
+ assert store.normalize({"event_name": "x", "user_id": "a", "latency_ms": -5})["latency_ms"] is None
78
+ assert store.normalize({"event_name": "x", "user_id": "a", "latency_ms": 1200})["latency_ms"] == 1200
79
+
80
+
81
+ def test_normalize_parses_time_and_props():
82
+ r = store.normalize({
83
+ "event_name": "feature_open", "user_id": " AN ",
84
+ "event_time": "2026-08-24T09:25:09.300+07:00",
85
+ "properties": {"entry": "sidebar"},
86
+ })
87
+ assert r["user_id"] == "an" # chuẩn hoá để gom đúng người
88
+ assert abs(r["event_ts"] - 1787538309.3) < 1.0 # 09:25:09.300 +07:00
89
+ assert '"entry"' in r["properties_json"]
90
+ # thời gian sai định dạng -> lùi về giờ máy chủ, không làm vỡ luồng
91
+ bad = store.normalize({"event_name": "x", "user_id": "a", "event_time": "hôm qua"})
92
+ assert bad["event_ts"] > 0
93
+
94
+
95
+ # ── Kho lưu ──────────────────────────────────────────────────────
96
+
97
+ def test_insert_query_and_dedup():
98
+ rows = _one_full_task()
99
+ n = store.insert(rows)
100
+ assert n == len(rows)
101
+ assert store.insert(rows) == 0 # cùng event_id -> không nhân đôi
102
+ got = store.query(user_id="an")
103
+ assert len(got) >= len(rows)
104
+ assert all(g["user_id"] == "an" for g in got)
105
+ # dòng thiếu user_id thì bỏ, không rác vào bảng
106
+ assert store.insert([store.normalize({"event_name": "x"})]) == 0
107
+
108
+
109
+ def test_query_newest_first_respects_limit():
110
+ """Cắt `limit` mà xếp tăng dần sẽ lấy nhầm N dòng CŨ NHẤT — màn log thô cần MỚI nhất."""
111
+ old = store.query(limit=3)
112
+ new = store.query(limit=3, newest_first=True)
113
+ assert len(old) == len(new) == 3
114
+ assert new[0]["server_ts"] >= old[-1]["server_ts"]
115
+ assert [r["server_ts"] for r in new] == sorted([r["server_ts"] for r in new], reverse=True)
116
+
117
+
118
+ def test_purge_old_keeps_recent():
119
+ before = store.stats()["rows"]
120
+ assert store.purge_old(days=3650) == 0 # chưa có gì cũ tới vậy
121
+ assert store.stats()["rows"] == before
122
+
123
+
124
+ # ── ETL: dựng task & chỉ số ──────────────────────────────────────
125
+
126
+ def test_task_metrics_from_one_session():
127
+ evs = _one_full_task()
128
+ tasks = etl.build_tasks(evs)
129
+ assert len(tasks) == 1
130
+ t = tasks[0]
131
+
132
+ assert t["has_output"] and t["success_type"] == "download"
133
+ # A1 = từ lúc mở tool tới ĐẦU RA đầu tiên (275 - 2 giây)
134
+ assert abs(t["duration_ms"] - 273000) < 50
135
+ # B4 = tổng thời gian máy chạy thật, KHÔNG gồm chờ hàng đợi
136
+ assert t["machine_ms"] == 89000
137
+ assert abs(t["wait_ms"] - 4000) < 50 # (11-9) + (202-200)
138
+ assert t["attempts"] == 2 # A4: hai lần job_done
139
+ assert t["produced"] == 2 # B1: count=2
140
+ assert t["thumbs_up"] == 1 and t["thumbs_down"] == 0
141
+
142
+
143
+ def test_step_durations_stop_at_done():
144
+ """Bấm 👍 sau khi xong mang step=output; dòng thời gian không được hiện thêm bước sau 'done'."""
145
+ steps = [d["step"] for d in etl.step_durations(etl.build_tasks(_one_full_task())[0])]
146
+ assert steps[-1] == "done"
147
+ assert steps.count("done") == 1
148
+
149
+
150
+ def test_abandon_and_completion_rate():
151
+ """A3 = task có đầu ra / tổng task; bỏ dở ghi rõ bước cuối chạm tới."""
152
+ evs = _one_full_task() + [
153
+ _ev(300, "feature_open", "open", feature="shuffle", task="task_2"),
154
+ _ev(340, "upload_done", "prepare", feature="shuffle", task="task_2"),
155
+ _ev(900, "feature_abandon", "prepare", feature="shuffle", task="task_2",
156
+ last_step="prepare", reason="qua_nhieu_clip"),
157
+ ]
158
+ s = etl.summarize(evs)
159
+ assert s["overall"]["tasks"] == 2
160
+ assert s["overall"]["tasks_done"] == 1
161
+ assert abs(s["overall"]["completion_rate"] - 0.5) < 1e-9
162
+ sh = next(f for f in s["features"] if f["feature"] == "shuffle")
163
+ assert sh["abandon_by_step"] == {"prepare": 1}
164
+ assert sh["label"] == "Xào video"
165
+
166
+
167
+ def test_forward_counts_as_success():
168
+ """Chuyển tiếp sang tool khác cũng là ĐẦU RA hợp lệ (spec §6.2)."""
169
+ evs = [
170
+ _ev(0, "feature_open", "open", feature="down_list", task="t_fw"),
171
+ _ev(5, "output_forward", "output", feature="down_list", task="t_fw",
172
+ value="cut", success_type="forward", to="cut", count=3),
173
+ ]
174
+ t = etl.build_tasks(evs)[0]
175
+ assert t["has_output"] and t["success_type"] == "forward"
176
+ prods = etl.summarize(evs)["products"]
177
+ assert prods[0]["kind"].startswith("→") # đọc ra "chuyển sang", không lẫn đuôi file
178
+ assert prods[0]["count"] == 3
179
+
180
+
181
+ def test_job_speed_split_by_phase():
182
+ """B2 phải tách tốc độ theo TỪNG BƯỚC máy, không gộp một cục."""
183
+ jobs = etl.summarize(_one_full_task())["jobs"]
184
+ phases = {j["phase"]: j["median_ms"] for j in jobs}
185
+ assert phases["asr_translate"] == 30000
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() + [
192
+ _ev(400, "page_view", "open", feature="home", task=""),
193
+ _ev(410, "page_view", "open", feature="guide", task=""),
194
+ ]
195
+ s = etl.summarize(evs)
196
+ assert s["overall"]["tasks"] == 1
197
+ assert all(f["feature"] not in ("home", "guide") for f in s["features"])
198
+
199
+
200
+ # ── ETL: phiên làm việc ──────────────────────────────────────────
201
+
202
+ def test_session_splits_on_long_silence():
203
+ """Im lặng quá ngưỡng -> tách thành hai phiên (spec §6: session_end là mốc mềm)."""
204
+ gap = (settings.SESSION_IDLE_MINUTES + 10) * 60
205
+ evs = [
206
+ _ev(0, "session_start", "open", feature="", task="", sess="s"),
207
+ _ev(60, "feature_open", "open", task="t1", sess="s"),
208
+ _ev(60 + gap, "feature_open", "open", task="t2", sess="s"),
209
+ _ev(120 + gap, "upload_done", "prepare", task="t2", sess="s"),
210
+ ]
211
+ sessions = etl.build_sessions(evs)
212
+ assert len(sessions) == 2
213
+ # thời lượng DÙNG THẬT không được cộng cả quãng im lặng
214
+ assert sum(s["active_ms"] for s in sessions) < gap * 1000
215
+ assert abs(sessions[0]["active_ms"] - 60000) < 50
216
+
217
+
218
+ def test_returning_user_needs_two_days():
219
+ """A5 = một user xuất hiện ở >= 2 ngày khác nhau."""
220
+ day = 86400
221
+ evs = [
222
+ _ev(0, "feature_open", "open", task="t1", user="an"),
223
+ _ev(-2 * day, "feature_open", "open", task="t0", user="an", sess="s0"),
224
+ _ev(0, "feature_open", "open", task="t2", user="binh", sess="sb"),
225
+ ]
226
+ users = {u["user_id"]: u for u in etl.summarize(evs)["users"]}
227
+ assert users["an"]["returning"] is True
228
+ assert users["binh"]["returning"] is False
229
+
230
+
231
+ def test_user_detail_lists_sessions_and_tasks():
232
+ d = etl.user_detail(_one_full_task(), "an")
233
+ assert d["user_id"] == "an" and len(d["sessions"]) == 1
234
+ task = d["sessions"][0]["tasks"][0]
235
+ assert task["label"] == "Dịch / đổi giọng"
236
+ assert task["has_output"] and task["machine_ms"] == 89000
237
+ assert [j["latency_ms"] for j in task["jobs"]] == [30000, 59000]
backend/tests/test_cut.py CHANGED
@@ -73,16 +73,17 @@ def _contiguous(segs, total):
73
  assert b.start_frame == a.end_frame
74
 
75
 
76
- def test_extract_features_no_cv2():
77
- """Đọc frame qua pipe ffmpeg (không cần OpenCV) -> có motion + histogram."""
 
 
 
78
  d = tempfile.mkdtemp()
79
  v = _multi_scene_video(os.path.join(d, "in.mp4"))
80
- info = probe(v)
81
- feats = modes.extract_features(info, use_clip=False)
82
- assert feats.times.size > 3
83
- assert feats.motion.size == feats.times.size
84
- assert feats.color_hist.shape[0] == feats.times.size
85
-
86
 
87
  def test_smart_cut_runs_and_detects_scenes():
88
  """Smart chạy được KHÔNG có CLIP (fallback histogram) và bắt được ranh giới."""
@@ -113,22 +114,38 @@ def test_scene_and_highlight_no_crash():
113
  _contiguous(segs, info.nb_frames)
114
 
115
 
116
- def test_smart_uses_clip_when_available():
117
- """Khi có open_clip+torch -> smart DÙNG CLIP (embedding 512 chiều), không phải histogram."""
118
- if not modes._clip_available():
119
- return # môi trường không có CLIP -> bỏ qua
 
 
 
 
 
 
 
 
120
  d = tempfile.mkdtemp()
121
  v = _multi_scene_video(os.path.join(d, "in.mp4"))
122
  info = probe(v)
123
- feats = modes.extract_features(info, use_clip=True)
124
- assert feats.clip_emb is not None
125
- assert feats.clip_emb.shape[0] == feats.times.size
126
- assert feats.clip_emb.shape[1] == 512 # ViT-B-32
127
- # và vẫn cắt ra khúc liền mạch
128
- segs = modes.build_segments(info, "smart", n=3)
129
- assert len(segs) == 3
130
- _contiguous(segs, info.nb_frames)
131
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
 
133
  def test_smart_segments_are_cuttable():
134
  """Khúc smart cắt ra file được, không tiếng."""
 
73
  assert b.start_frame == a.end_frame
74
 
75
 
76
+ def test_frame_signals_returns_motion_and_hist():
77
+ """Quét video 1 lần -> hai chuỗi tín hiệu (đổi màu, chuyển động) cùng độ dài.
78
+
79
+ Đây là nền của scene_cut/highlight_cut: sai độ dài là mọi mốc cắt lệch theo.
80
+ """
81
  d = tempfile.mkdtemp()
82
  v = _multi_scene_video(os.path.join(d, "in.mp4"))
83
+ hist_diff, motion = modes._frame_signals(v)
84
+ assert hist_diff.size > 3
85
+ assert hist_diff.size == motion.size
86
+ assert float(hist_diff.max()) > 0.0 # video nhiều cảnh -> phải có đổi màu
 
 
87
 
88
  def test_smart_cut_runs_and_detects_scenes():
89
  """Smart chạy được KHÔNG có CLIP (fallback histogram) và bắt được ranh giới."""
 
114
  _contiguous(segs, info.nb_frames)
115
 
116
 
117
+ def test_smart_cut_uses_clip_or_falls_back():
118
+ """smart dùng CLIP khi có torch+open_clip; thiếu thì LÙI scene_cut, không vỡ.
119
+
120
+ Dù đi nhánh nào, các khúc trả về vẫn phải liền mạch và phủ hết video.
121
+ """
122
+ try:
123
+ import torch # noqa: F401
124
+ import open_clip # noqa: F401
125
+ has_clip = True
126
+ except Exception:
127
+ has_clip = False
128
+
129
  d = tempfile.mkdtemp()
130
  v = _multi_scene_video(os.path.join(d, "in.mp4"))
131
  info = probe(v)
 
 
 
 
 
 
 
 
132
 
133
+ called = {"clip": False}
134
+ orig = modes._smart_cut_clip
135
+
136
+ def spy(inf, n):
137
+ called["clip"] = True
138
+ return orig(inf, n)
139
+
140
+ modes._smart_cut_clip = spy
141
+ try:
142
+ segs = modes.smart_cut(info, n=3)
143
+ finally:
144
+ modes._smart_cut_clip = orig
145
+
146
+ assert called["clip"] is has_clip, "có CLIP thì phải dùng CLIP, không có thì lùi scene_cut"
147
+ assert len(segs) >= 1
148
+ _contiguous(segs, info.nb_frames)
149
 
150
  def test_smart_segments_are_cuttable():
151
  """Khúc smart cắt ra file được, không tiếng."""
backend/tests/test_dub.py CHANGED
@@ -54,37 +54,48 @@ def test_run_dub_multi_speaker_flag_ok():
54
  assert has_video(res.video_out) # bật nhiều người nói vẫn ra video
55
 
56
 
57
- def test_asr_routes_to_local_without_groq_key():
58
- """Không có GROQ key -> transcribe gọi thẳng faster-whisper local (không gọi Groq)."""
 
 
 
 
 
59
  from app.config import settings
60
- called = {"local": False}
61
- orig_local, orig_key = asr._transcribe_local, settings.GROQ_API_KEY
62
- asr._transcribe_local = lambda w: (called.__setitem__("local", True),
63
- ([asr.Segment(0.0, 1.0, "hi")], "en"))[1]
64
  settings.GROQ_API_KEY = ""
65
  try:
66
- segs, lang = asr.transcribe("whatever.wav")
 
 
 
 
 
67
  finally:
68
- asr._transcribe_local, settings.GROQ_API_KEY = orig_local, orig_key
69
- assert called["local"] and lang == "en" and segs[0].text == "hi"
70
-
71
 
72
  def test_translate_free_fallback_without_keys():
73
- """Không có key Gemini/Groq -> dùng dịch miễn phí (deep-translator)."""
74
  from app.config import settings
75
  seg = translate.Segment(0.0, 1.5, "hello world")
76
- orig_free = translate._free_translate
 
77
  orig_g, orig_q = settings.GOOGLE_API_KEY, settings.GROQ_API_KEY
78
- translate._free_translate = lambda text, lang: f"[{lang}]{text}"
 
 
 
 
 
79
  settings.GOOGLE_API_KEY = settings.GROQ_API_KEY = ""
80
  try:
81
  out = translate.translate_line(seg, "vi")
82
  finally:
83
- translate._free_translate = orig_free
 
84
  settings.GOOGLE_API_KEY, settings.GROQ_API_KEY = orig_g, orig_q
85
  assert out == "[vi]hello world"
86
- assert translate.GT_CODE["fil"] == "tl"
87
-
88
 
89
  def test_run_tts_fallback_wav():
90
  d = tempfile.mkdtemp()
@@ -104,25 +115,34 @@ def test_atempo_chain_valid():
104
 
105
 
106
  def test_pick_engine_defaults_and_override():
107
- """fil mặc định Edge; vi/en theo model local; engine chỉ định thì giữ nguyên."""
 
 
 
 
108
  assert tts.pick_engine(tts.VoiceConfig(target_lang="fil", engine="auto")) == "edge"
109
  assert tts.pick_engine(tts.VoiceConfig(target_lang="vi", engine="auto")) == "vieneu"
110
- assert tts.pick_engine(tts.VoiceConfig(target_lang="en", engine="auto")) == "xtts"
111
- # chọn edge cho mọi ngôn ngữ
112
  assert tts.pick_engine(tts.VoiceConfig(target_lang="vi", engine="edge")) == "edge"
113
- assert tts.pick_engine(tts.VoiceConfig(target_lang="en", engine="edge")) == "edge"
114
 
 
 
115
 
116
- def test_edge_voice_mapping():
117
- """Edge chọn giọng theo (ngôn ngữ, giới tính); preset là id giọng Edge thì ưu tiên."""
118
- assert tts.edge_voice(tts.VoiceConfig(target_lang="vi", gender="female")) == "vi-VN-HoaiMyNeural"
119
- assert tts.edge_voice(tts.VoiceConfig(target_lang="vi", gender="male")) == "vi-VN-NamMinhNeural"
120
- assert tts.edge_voice(tts.VoiceConfig(target_lang="fil", gender="male")) == "fil-PH-AngeloNeural"
121
- # "nam" tiếng Việt cũng hiểu là male
122
- assert tts.edge_voice(tts.VoiceConfig(target_lang="en", gender="Nam")) == "en-US-GuyNeural"
123
- # preset là id giọng Edge -> dùng thẳng
124
- assert tts.edge_voice(tts.VoiceConfig(target_lang="en", preset="en-GB-RyanNeural")) == "en-GB-RyanNeural"
125
 
 
 
 
 
 
 
126
 
127
  def test_synth_line_falls_back_when_engine_breaks():
128
  """Engine lỗi HOẶC không ra file -> synth_line vẫn để lại 1 wav (khoảng lặng)."""
@@ -143,40 +163,45 @@ def test_synth_line_falls_back_when_engine_breaks():
143
  assert os.path.exists(out) and duration(out) > 0.0
144
 
145
 
146
- def test_resilient_tts_falls_back_to_edge():
147
- """Model chính (vieneu/xtts/voxcpm) hỏng -> ResilientTTS tự dùng Edge để VẪN ra giọng."""
148
- cfg = tts.VoiceConfig(target_lang="vi", engine="vieneu")
149
 
150
- class BadPrimary(tts.TTSEngine):
151
- def synth(self, text, out, c):
152
- raise RuntimeError("model dead")
 
 
 
153
 
154
- calls = {"edge": 0}
155
 
156
  class FakeEdge(tts.TTSEngine):
157
  def __init__(self):
158
- pass
159
 
160
  def synth(self, text, out, c):
161
- calls["edge"] += 1
162
- with open(out, "wb") as f:
163
- f.write(b"RIFFfakecontent") # file KHÔNG rỗng
164
  return out
165
 
166
- orig_load, orig_edge = tts.load_engine, tts.EdgeTTSEngine
167
- tts.load_engine = lambda c: BadPrimary()
 
 
168
  tts.EdgeTTSEngine = FakeEdge
 
169
  try:
170
- eng = tts.ResilientTTS(cfg)
171
- out = os.path.join(tempfile.mkdtemp(), "v.wav")
172
- eng.synth("xin chào", out, cfg)
173
  finally:
174
- tts.load_engine, tts.EdgeTTSEngine = orig_load, orig_edge
175
- assert calls["edge"] == 1 and os.path.exists(out) and os.path.getsize(out) > 0
 
 
176
 
 
 
177
 
178
- def test_translate_prompt_is_faithful_and_carries_style():
179
- """Prompt dịch phải YÊU CẦU TRUNG THÀNH (không thêm thắt) + mang ngữ điệu đã chọn."""
 
180
  from app.config import settings
181
  seg = translate.Segment(0.0, 2.0, "Hello everyone, welcome back to the channel.")
182
  captured = {}
@@ -189,23 +214,27 @@ def test_translate_prompt_is_faithful_and_carries_style():
189
  translate._gemini = fake_gemini
190
  settings.GOOGLE_API_KEY = "x"
191
  try:
192
- out = translate.translate_line(seg, "vi", style="ads")
193
  finally:
194
  translate._gemini, settings.GOOGLE_API_KEY = orig_g, orig_key
195
  assert "chào mừng" in out
196
  p = captured["p"].lower()
197
- assert "be faithful" in p # ưu tiên trung thành
198
- assert "do not add" in p and "embellish" in p # cấm thêm thắt/phóng đại
199
- assert "register" in p # ngữ điệu (nhẹ) có trong prompt
200
  assert "hello everyone" in p # có câu gốc
201
- assert "vietnamese" in p
202
-
203
-
204
- def test_style_guide_covers_all_styles():
205
- for s in ("natural", "ads", "news", "story"):
206
- assert s in translate.STYLE_GUIDE
207
- assert translate.has_llm() in (True, False)
208
-
 
 
 
 
 
209
 
210
  def test_srt_time_and_write():
211
  d = tempfile.mkdtemp()
 
54
  assert has_video(res.video_out) # bật nhiều người nói vẫn ra video
55
 
56
 
57
+ def test_asr_requires_groq_key():
58
+ """ASR chạy Groq Whisper; thiếu key -> báo lỗi RÕ RÀNG để pipeline lùi passthrough.
59
+
60
+ Bản dự phòng faster-whisper local đã được GỠ CÓ CHỦ Ý (xem requirements.txt:
61
+ "faster-whisper — GỠ: không dùng trong code"), nên hợp đồng hiện tại là
62
+ transcribe() phải NÉM LỖI thay vì im lặng trả rỗng.
63
+ """
64
  from app.config import settings
65
+ orig_key = settings.GROQ_API_KEY
 
 
 
66
  settings.GROQ_API_KEY = ""
67
  try:
68
+ raised = False
69
+ try:
70
+ asr.transcribe("khong-ton-tai.wav")
71
+ except Exception:
72
+ raised = True
73
+ assert raised, "thiếu GROQ_API_KEY thì transcribe phải ném lỗi"
74
  finally:
75
+ settings.GROQ_API_KEY = orig_key
 
 
76
 
77
  def test_translate_free_fallback_without_keys():
78
+ """Không có key Gemini/Groq -> dịch miễn phí bằng deep-translator (không cần key)."""
79
  from app.config import settings
80
  seg = translate.Segment(0.0, 1.5, "hello world")
81
+ orig_deep = translate._deep_translate
82
+ orig_gem, orig_groq = translate._gemini, translate._groq_llm
83
  orig_g, orig_q = settings.GOOGLE_API_KEY, settings.GROQ_API_KEY
84
+
85
+ def boom(*a, **k):
86
+ raise RuntimeError("no key")
87
+
88
+ translate._deep_translate = lambda text, lang: f"[{lang}]{text}"
89
+ translate._gemini = translate._groq_llm = boom
90
  settings.GOOGLE_API_KEY = settings.GROQ_API_KEY = ""
91
  try:
92
  out = translate.translate_line(seg, "vi")
93
  finally:
94
+ translate._deep_translate = orig_deep
95
+ translate._gemini, translate._groq_llm = orig_gem, orig_groq
96
  settings.GOOGLE_API_KEY, settings.GROQ_API_KEY = orig_g, orig_q
97
  assert out == "[vi]hello world"
98
+ assert translate._DEEP_CODE["fil"] == "tl" # Filipino -> mã "tl" của Google
 
99
 
100
  def test_run_tts_fallback_wav():
101
  d = tempfile.mkdtemp()
 
115
 
116
 
117
  def test_pick_engine_defaults_and_override():
118
+ """vi -> VieNeu (local); en & fil -> Edge; engine chỉ định thì giữ nguyên.
119
+
120
+ en dùng Edge chứ không phải XTTS: XTTS/Coqui nặng RAM và đã được gỡ khỏi
121
+ requirements ("XTTS-v2 không dùng mặc định — en/fil chạy Edge").
122
+ """
123
  assert tts.pick_engine(tts.VoiceConfig(target_lang="fil", engine="auto")) == "edge"
124
  assert tts.pick_engine(tts.VoiceConfig(target_lang="vi", engine="auto")) == "vieneu"
125
+ assert tts.pick_engine(tts.VoiceConfig(target_lang="en", engine="auto")) == "edge"
126
+ # chọn tay thì tôn trọng lựa chọn
127
  assert tts.pick_engine(tts.VoiceConfig(target_lang="vi", engine="edge")) == "edge"
128
+ assert tts.pick_engine(tts.VoiceConfig(target_lang="en", engine="xtts")) == "xtts"
129
 
130
+ def test_edge_voice_defaults_and_voice_list():
131
+ """Giọng Edge: mặc định theo ngôn ngữ, người dùng chọn tay qua `preset`.
132
 
133
+ Bản cũ suy giọng t��� giới tính (edge_voice(cfg)); nay giao diện hiện HẲN danh
134
+ sách giọng (list_voices) rồi gửi id xuống làm `preset`, nên hợp đồng cần giữ là
135
+ danh sách và giọng mặc định phải khớp nhau.
136
+ """
137
+ for lang, prefix in (("vi", "vi-VN-"), ("fil", "fil-PH-"), ("en", "en-")):
138
+ assert tts._EDGE_VOICES[lang].startswith(prefix)
 
 
 
139
 
140
+ for lang in ("en", "fil"):
141
+ ids = [vid for _label, vid in tts._EDGE_VOICE_LIST[lang]]
142
+ assert tts._EDGE_VOICES[lang] in ids, "giọng mặc định phải nằm trong danh sách"
143
+ voices = tts.list_voices(lang)
144
+ assert voices and all(v.get("id") and v.get("label") for v in voices)
145
+ assert tts._EDGE_VOICES[lang] in [v["id"] for v in voices]
146
 
147
  def test_synth_line_falls_back_when_engine_breaks():
148
  """Engine lỗi HOẶC không ra file -> synth_line vẫn để lại 1 wav (khoảng lặng)."""
 
163
  assert os.path.exists(out) and duration(out) > 0.0
164
 
165
 
166
+ def test_load_engine_falls_back_to_edge_when_model_missing():
167
+ """Model nặng không nạp được -> load_engine tự lùi về Edge để VẪN ra giọng.
 
168
 
169
+ Trước đây việc này do lớp bọc ResilientTTS lo; nay nằm thẳng trong load_engine.
170
+ Nhánh "engine chạy nhưng ghi file rỗng" do synth_line lo (test riêng bên dưới).
171
+ """
172
+ class Boom(tts.TTSEngine):
173
+ def __init__(self):
174
+ raise RuntimeError("model chưa cài")
175
 
176
+ made = {"edge": 0}
177
 
178
  class FakeEdge(tts.TTSEngine):
179
  def __init__(self):
180
+ made["edge"] += 1
181
 
182
  def synth(self, text, out, c):
 
 
 
183
  return out
184
 
185
+ orig_engines = dict(tts._ENGINES)
186
+ orig_inst = dict(tts._INSTANCES)
187
+ orig_edge = tts.EdgeTTSEngine
188
+ tts._ENGINES["vieneu"] = Boom
189
  tts.EdgeTTSEngine = FakeEdge
190
+ tts._INSTANCES.clear()
191
  try:
192
+ eng = tts.load_engine(tts.VoiceConfig(target_lang="vi", engine="vieneu"))
 
 
193
  finally:
194
+ tts._ENGINES.clear(); tts._ENGINES.update(orig_engines)
195
+ tts._INSTANCES.clear(); tts._INSTANCES.update(orig_inst)
196
+ tts.EdgeTTSEngine = orig_edge
197
+ assert isinstance(eng, FakeEdge) and made["edge"] == 1
198
 
199
+ def test_translate_prompt_is_faithful_and_bounded():
200
+ """Prompt dịch phải GIỮ ĐÚNG NGHĨA (cấm thêm thắt) và chốt độ dài đọc vừa khung giờ.
201
 
202
+ Ngữ điệu (style) KHÔNG còn nằm trong prompt dịch — nó đã chuyển sang khâu TTS
203
+ (VoiceConfig.style), nên ở đây chỉ kiểm tính trung thành + ràng buộc thời lượng.
204
+ """
205
  from app.config import settings
206
  seg = translate.Segment(0.0, 2.0, "Hello everyone, welcome back to the channel.")
207
  captured = {}
 
214
  translate._gemini = fake_gemini
215
  settings.GOOGLE_API_KEY = "x"
216
  try:
217
+ out = translate.translate_line(seg, "vi", source_lang="en")
218
  finally:
219
  translate._gemini, settings.GOOGLE_API_KEY = orig_g, orig_key
220
  assert "chào mừng" in out
221
  p = captured["p"].lower()
222
+ assert "keep all the real meaning" in p # trung thành
223
+ assert "do not pad" in p and "never invent" in p # cấm thêm thắt
 
224
  assert "hello everyone" in p # có câu gốc
225
+ assert "vietnamese" in p # ngôn ngữ đích
226
+ assert "english" in p # ngôn ngữ nguồn
227
+
228
+ def test_all_ui_styles_reach_voice_config():
229
+ """4 ngữ điệu trên giao diện phải đi lọt xuống VoiceConfig của khâu TTS.
230
+
231
+ Trước đây ngữ điệu nằm ở prompt dịch (translate.STYLE_GUIDE); nay nó thuộc
232
+ khâu đọc, nên hợp đồng cần giữ là pipeline chuyển đúng giá trị xuống TTS.
233
+ """
234
+ for st in ("natural", "ads", "news", "story"):
235
+ cfg = tts.VoiceConfig(target_lang="vi", style=st)
236
+ assert cfg.style == st
237
+ assert P.DubOptions(target_lang="vi", style=st).style == st
238
 
239
  def test_srt_time_and_write():
240
  d = tempfile.mkdtemp()
frontend/src/api.js CHANGED
@@ -1,3 +1,4 @@
 
1
  // Client gọi backend FastAPI. Dev: Vite proxy /api -> :8000.
2
 
3
  const BASE = "";
@@ -30,6 +31,18 @@ export function uploadWithProgress(path, file, fields = {}, onProgress) {
30
  for (const [k, v] of Object.entries(fields)) if (v != null) fd.append(k, v);
31
  const xhr = new XMLHttpRequest();
32
  xhr.open("POST", BASE + path);
 
 
 
 
 
 
 
 
 
 
 
 
33
  if (xhr.upload && onProgress) {
34
  xhr.upload.onprogress = (e) => {
35
  if (e.lengthComputable) onProgress(e.loaded, e.total);
 
1
+ import { currentCtx as trackCtx } from "./lib/track";
2
  // Client gọi backend FastAPI. Dev: Vite proxy /api -> :8000.
3
 
4
  const BASE = "";
 
31
  for (const [k, v] of Object.entries(fields)) if (v != null) fd.append(k, v);
32
  const xhr = new XMLHttpRequest();
33
  xhr.open("POST", BASE + path);
34
+ // Upload đi bằng XHR (để có % tiến độ) nên KHÔNG qua bản vá fetch trong
35
+ // lib/track.js -> gắn tay ngữ cảnh theo dõi để backend vẫn quy được về
36
+ // đúng task của đúng người.
37
+ try {
38
+ const c = trackCtx();
39
+ if (c.user_id) {
40
+ xhr.setRequestHeader("X-TNT-User", c.user_id);
41
+ xhr.setRequestHeader("X-TNT-Session", c.session_id);
42
+ if (c.task_id) xhr.setRequestHeader("X-TNT-Task", c.task_id);
43
+ if (c.feature) xhr.setRequestHeader("X-TNT-Feature", c.feature);
44
+ }
45
+ } catch { /* thiếu header theo dõi không được chặn upload */ }
46
  if (xhr.upload && onProgress) {
47
  xhr.upload.onprogress = (e) => {
48
  if (e.lengthComputable) onProgress(e.loaded, e.total);
frontend/src/components/Layout.jsx CHANGED
@@ -4,6 +4,7 @@ import Sidebar from "./Sidebar";
4
  import { useVideoGen } from "../stores/videoGenStore";
5
  import { useDirty } from "../stores/dirtyStore";
6
  import { useAuth } from "../stores/authStore";
 
7
 
8
  // Fallback nhẹ khi chunk trang đang tải (code-splitting).
9
  function PageFallback() {
@@ -29,7 +30,21 @@ export default function Layout() {
29
  if (!authorized) navigate("/dang-nhap", { replace: true });
30
  }, [authorized]);
31
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  const handleLogout = () => {
 
 
33
  logout();
34
  navigate("/dang-nhap");
35
  };
 
4
  import { useVideoGen } from "../stores/videoGenStore";
5
  import { useDirty } from "../stores/dirtyStore";
6
  import { useAuth } from "../stores/authStore";
7
+ import track, { ROUTE_FEATURE } from "../lib/track";
8
 
9
  // Fallback nhẹ khi chunk trang đang tải (code-splitting).
10
  function PageFallback() {
 
30
  if (!authorized) navigate("/dang-nhap", { replace: true });
31
  }, [authorized]);
32
 
33
+ // Theo dõi người dùng: bật một lần, rồi mỗi lần đổi trang là mở "task" mới
34
+ // cho tool đó (task cũ chưa có đầu ra sẽ tự ghi là bỏ dở).
35
+ useEffect(() => {
36
+ if (authorized) track.initTracking();
37
+ }, [authorized]);
38
+
39
+ useEffect(() => {
40
+ if (!authorized) return;
41
+ const feature = ROUTE_FEATURE[location.pathname];
42
+ if (feature) track.featureOpen(feature, { entry: "router" });
43
+ }, [authorized, location.pathname]);
44
+
45
  const handleLogout = () => {
46
+ track.abandon("logout");
47
+ track.flush();
48
  logout();
49
  navigate("/dang-nhap");
50
  };
frontend/src/components/ThumbsFeedback.jsx ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Nút 👍/👎 ở chỗ hiện kết quả — nguồn duy nhất cho chỉ số B3 (chất lượng đầu ra).
2
+ // Trước đây app không có nút nào để người dùng chấm kết quả, nên B3 không có gì
3
+ // để đếm (User_Flow_and_Tracking_Spec §1.5).
4
+ //
5
+ // Hỏi MỘT LẦN cho mỗi mẻ kết quả, không hỏi từng file: mỗi task chỉ cần một
6
+ // phiếu, hỏi nhiều lần thì người dùng bỏ qua hết.
7
+
8
+ import { useState } from "react";
9
+ import track from "../lib/track";
10
+
11
+ export default function ThumbsFeedback({ label = "Kết quả này ổn chứ?", target = "result_set" }) {
12
+ const [sent, setSent] = useState("");
13
+ const [note, setNote] = useState("");
14
+ const [noteSent, setNoteSent] = useState(false);
15
+
16
+ const vote = (v) => {
17
+ if (sent) return;
18
+ setSent(v);
19
+ track.thumbs(v, { target });
20
+ };
21
+
22
+ const sendNote = () => {
23
+ if (!note.trim() || noteSent) return;
24
+ setNoteSent(true);
25
+ track.step("feedback_note", "output", { target, chars: note.trim().length }, note.trim().slice(0, 200));
26
+ };
27
+
28
+ return (
29
+ <div style={{
30
+ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap",
31
+ padding: "8px 12px", background: "var(--orange-tint)",
32
+ border: "1px solid var(--line)", borderRadius: "var(--radius-sm)",
33
+ fontSize: 13,
34
+ }}>
35
+ {!sent ? (
36
+ <>
37
+ <span style={{ color: "var(--ink-2)" }}>{label}</span>
38
+ <button className="btn sm" onClick={() => vote("up")} title="Ổn">👍 Ổn</button>
39
+ <button className="btn sm" onClick={() => vote("down")} title="Chưa ổn">👎 Chưa ổn</button>
40
+ </>
41
+ ) : sent === "up" ? (
42
+ <span style={{ color: "var(--green)", fontWeight: 600 }}>👍 Cảm ơn bạn đã đánh giá!</span>
43
+ ) : (
44
+ <>
45
+ <span style={{ color: "var(--ink-2)", fontWeight: 600 }}>👎 Chưa ổn ở đâu?</span>
46
+ {noteSent ? (
47
+ <span style={{ color: "var(--green)", fontWeight: 600 }}>Đã ghi nhận, cảm ơn bạn!</span>
48
+ ) : (
49
+ <>
50
+ <input value={note} onChange={(e) => setNote(e.target.value)}
51
+ onKeyDown={(e) => e.key === "Enter" && sendNote()}
52
+ placeholder="Không bắt buộc — vd: chữ bị lệch, dịch sai câu cuối…"
53
+ style={{
54
+ flex: "1 1 240px", minWidth: 180, padding: "6px 10px", fontSize: 13,
55
+ border: "1px solid var(--line)", borderRadius: "var(--radius-sm)",
56
+ }} />
57
+ <button className="btn sm" onClick={sendNote} disabled={!note.trim()}>Gửi</button>
58
+ </>
59
+ )}
60
+ </>
61
+ )}
62
+ </div>
63
+ );
64
+ }
frontend/src/components/admin/UsageCharts.jsx ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Biểu đồ SVG thuần cho tab "Quản lý thời lượng".
2
+ // Không thêm thư viện biểu đồ: cả frontend hiện chỉ có react + react-router,
3
+ // mà mấy dạng ở đây (cột ngang, đường theo ngày) vẽ tay còn nhẹ và gọn hơn.
4
+ // Mọi con số đều IN THẲNG lên biểu đồ, không bắt người xem ước lượng theo trục.
5
+
6
+ // ── Định dạng ───────────────────────────────────────────────────
7
+
8
+ export function fmtDur(ms) {
9
+ if (ms == null || Number.isNaN(ms)) return "—";
10
+ const s = Math.round(ms / 1000);
11
+ if (s < 60) return `${s} giây`;
12
+ const m = Math.floor(s / 60);
13
+ if (m < 60) return s % 60 ? `${m} phút ${s % 60} giây` : `${m} phút`;
14
+ const h = Math.floor(m / 60);
15
+ return m % 60 ? `${h} giờ ${m % 60} phút` : `${h} giờ`;
16
+ }
17
+
18
+ export function fmtShort(ms) {
19
+ if (ms == null || Number.isNaN(ms)) return "—";
20
+ const s = ms / 1000;
21
+ if (s < 60) return `${s < 10 ? s.toFixed(1) : Math.round(s)}s`;
22
+ const m = s / 60;
23
+ if (m < 60) return `${m < 10 ? m.toFixed(1) : Math.round(m)}p`;
24
+ return `${(m / 60).toFixed(1)}h`;
25
+ }
26
+
27
+ export const fmtPct = (v) => (v == null ? "—" : `${Math.round(v * 100)}%`);
28
+ export const fmtNum = (v) => (v == null ? "—" : Number(v).toLocaleString("vi-VN"));
29
+
30
+ export const fmtTime = (ts) =>
31
+ ts ? new Date(ts * 1000).toLocaleString("vi-VN", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }) : "—";
32
+
33
+ // ── Ô chỉ số ────────────────────────────────────────────────────
34
+
35
+ export function Kpi({ label, value, hint, tone = "" }) {
36
+ const color = tone === "good" ? "var(--green)" : tone === "warn" ? "var(--orange)" : "var(--ink)";
37
+ return (
38
+ <div style={{
39
+ background: "var(--white)", border: "1px solid var(--line)",
40
+ borderRadius: "var(--radius-sm)", padding: "12px 14px", minWidth: 0,
41
+ }}>
42
+ <div style={{ fontSize: 11.5, color: "var(--muted)", fontWeight: 600, marginBottom: 4 }}>{label}</div>
43
+ <div style={{ fontSize: 20, fontWeight: 800, color, letterSpacing: "-0.5px", lineHeight: 1.2 }}>{value}</div>
44
+ {hint && <div style={{ fontSize: 11.5, color: "var(--muted)", marginTop: 3 }}>{hint}</div>}
45
+ </div>
46
+ );
47
+ }
48
+
49
+ // ── Cột ngang ───────────────────────────────────────────────────
50
+ // data: [{ label, value, text?, sub? }]
51
+
52
+ export function BarChart({ data, color = "var(--orange)", empty = "Chưa có dữ liệu" }) {
53
+ const rows = (data || []).filter((d) => d && d.value != null);
54
+ if (!rows.length) return <p className="small muted" style={{ fontStyle: "italic", margin: 0 }}>{empty}</p>;
55
+ const max = Math.max(...rows.map((r) => Math.abs(r.value)), 1);
56
+
57
+ return (
58
+ <div style={{ display: "flex", flexDirection: "column", gap: 9 }}>
59
+ {rows.map((r, i) => (
60
+ <div key={r.label + i}>
61
+ <div style={{ display: "flex", justifyContent: "space-between", gap: 10, fontSize: 12.5, marginBottom: 3 }}>
62
+ <span style={{ fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
63
+ {r.label}
64
+ </span>
65
+ <span style={{ color: "var(--ink-2)", whiteSpace: "nowrap", fontWeight: 700 }}>
66
+ {r.text != null ? r.text : fmtNum(r.value)}
67
+ {r.sub && <span style={{ color: "var(--muted)", fontWeight: 500 }}> · {r.sub}</span>}
68
+ </span>
69
+ </div>
70
+ <div style={{ height: 8, background: "var(--line-2)", borderRadius: 99, overflow: "hidden" }}>
71
+ <div style={{
72
+ width: `${Math.max(2, (Math.abs(r.value) / max) * 100)}%`,
73
+ height: "100%", background: color, borderRadius: 99,
74
+ }} />
75
+ </div>
76
+ </div>
77
+ ))}
78
+ </div>
79
+ );
80
+ }
81
+
82
+ // ── Đường theo ngày ─────────────────────────────────────────────
83
+ // series: [{ key, label, color, values: number[] }] · labels: string[]
84
+
85
+ export function LineChart({ labels, series, height = 170, unit = "" }) {
86
+ const pts = labels || [];
87
+ if (pts.length < 2) {
88
+ return <p className="small muted" style={{ fontStyle: "italic", margin: 0 }}>Cần ít nhất 2 ngày dữ liệu để vẽ.</p>;
89
+ }
90
+ const W = 720, H = height, PADL = 38, PADR = 10, PADT = 12, PADB = 24;
91
+ const max = Math.max(1, ...series.flatMap((s) => s.values.map((v) => v || 0)));
92
+ const x = (i) => PADL + (i * (W - PADL - PADR)) / (pts.length - 1);
93
+ const y = (v) => H - PADB - ((v || 0) / max) * (H - PADT - PADB);
94
+
95
+ // Nhãn trục X: thưa ra để không chồng chữ.
96
+ const stepX = Math.max(1, Math.ceil(pts.length / 8));
97
+
98
+ return (
99
+ <div style={{ width: "100%", overflowX: "auto" }}>
100
+ <svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", minWidth: 420, display: "block" }}
101
+ role="img" aria-label="Hoạt động theo ngày">
102
+ {[0, 0.5, 1].map((f) => (
103
+ <g key={f}>
104
+ <line x1={PADL} x2={W - PADR} y1={y(max * f)} y2={y(max * f)}
105
+ stroke="var(--line)" strokeWidth="1" />
106
+ <text x={PADL - 6} y={y(max * f) + 4} textAnchor="end"
107
+ fontSize="10" fill="var(--muted)">
108
+ {max * f >= 1000 ? `${Math.round((max * f) / 1000)}k` : Math.round(max * f)}
109
+ </text>
110
+ </g>
111
+ ))}
112
+ {series.map((s) => (
113
+ <g key={s.key}>
114
+ <polyline
115
+ points={s.values.map((v, i) => `${x(i)},${y(v)}`).join(" ")}
116
+ fill="none" stroke={s.color} strokeWidth="2"
117
+ strokeLinejoin="round" strokeLinecap="round" />
118
+ {s.values.map((v, i) => (
119
+ <circle key={i} cx={x(i)} cy={y(v)} r="2.5" fill={s.color}>
120
+ <title>{`${pts[i]} · ${s.label}: ${fmtNum(v)}${unit}`}</title>
121
+ </circle>
122
+ ))}
123
+ </g>
124
+ ))}
125
+ {pts.map((p, i) => (i % stepX === 0 ? (
126
+ <text key={p} x={x(i)} y={H - 7} textAnchor="middle" fontSize="10" fill="var(--muted)">
127
+ {p.slice(5)}
128
+ </text>
129
+ ) : null))}
130
+ </svg>
131
+ <div style={{ display: "flex", gap: 14, flexWrap: "wrap", marginTop: 4 }}>
132
+ {series.map((s) => (
133
+ <span key={s.key} style={{ fontSize: 12, color: "var(--ink-2)", display: "flex", alignItems: "center", gap: 6 }}>
134
+ <i style={{ width: 10, height: 3, background: s.color, borderRadius: 2, display: "inline-block" }} />
135
+ {s.label}
136
+ </span>
137
+ ))}
138
+ </div>
139
+ </div>
140
+ );
141
+ }
142
+
143
+ // ── Thanh chia phần (dùng cho "bỏ dở ở bước nào") ──────────────
144
+
145
+ const STEP_COLORS = {
146
+ open: "#c9c2ba", prepare: "var(--orange)", run: "#f2b21c",
147
+ review: "#3b82f6", output: "var(--green)", done: "#16a34a",
148
+ };
149
+
150
+ export function StepBar({ counts }) {
151
+ const entries = Object.entries(counts || {}).filter(([, v]) => v > 0);
152
+ const total = entries.reduce((a, [, v]) => a + v, 0);
153
+ if (!total) return <span className="small muted">—</span>;
154
+ return (
155
+ <div>
156
+ <div style={{ display: "flex", height: 8, borderRadius: 99, overflow: "hidden", background: "var(--line-2)" }}>
157
+ {entries.map(([k, v]) => (
158
+ <div key={k} title={`${k}: ${v}`} style={{
159
+ width: `${(v / total) * 100}%`, background: STEP_COLORS[k] || "var(--muted)",
160
+ }} />
161
+ ))}
162
+ </div>
163
+ <div style={{ fontSize: 11.5, color: "var(--muted)", marginTop: 3 }}>
164
+ {entries.map(([k, v]) => `${k} ${v}`).join(" · ")}
165
+ </div>
166
+ </div>
167
+ );
168
+ }
frontend/src/components/admin/UsageDashboard.jsx ADDED
@@ -0,0 +1,393 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Tab "Quản lý thời lượng" — người dùng nào, dùng tool gì, mất bao lâu,
2
+ // làm ra sản phẩm gì. Mọi con số tính từ bảng log thô `raw_events` ở backend.
3
+ //
4
+ // Mã chỉ số ghi kèm nhãn (A1..B4) để đối chiếu với User_Flow_and_Tracking_Spec.
5
+
6
+ import { useCallback, useEffect, useState } from "react";
7
+ import {
8
+ BarChart, Kpi, LineChart, StepBar,
9
+ fmtDur, fmtNum, fmtPct, fmtShort, fmtTime,
10
+ } from "./UsageCharts";
11
+
12
+ const RANGES = [
13
+ { days: 7, label: "7 ngày" },
14
+ { days: 30, label: "30 ngày" },
15
+ { days: 90, label: "90 ngày" },
16
+ ];
17
+
18
+ export default function UsageDashboard({ token }) {
19
+ const [days, setDays] = useState(30);
20
+ const [feature, setFeature] = useState("");
21
+ const [data, setData] = useState(null);
22
+ const [busy, setBusy] = useState(false);
23
+ const [err, setErr] = useState("");
24
+ const [openUser, setOpenUser] = useState("");
25
+ const [detail, setDetail] = useState(null);
26
+ const [rawOpen, setRawOpen] = useState(false);
27
+ const [raw, setRaw] = useState([]);
28
+
29
+ const qs = useCallback((extra = {}) => {
30
+ const p = new URLSearchParams({ token, days: String(days), ...extra });
31
+ if (feature) p.set("feature", feature);
32
+ return p.toString();
33
+ }, [token, days, feature]);
34
+
35
+ const load = useCallback(async () => {
36
+ setBusy(true); setErr("");
37
+ try {
38
+ const r = await fetch(`/api/analytics/admin/summary?${qs()}`);
39
+ const d = await r.json();
40
+ if (!d.ok) throw new Error(d.detail || "Không đọc được số liệu");
41
+ setData(d);
42
+ } catch (e) { setErr(e.message); }
43
+ finally { setBusy(false); }
44
+ }, [qs]);
45
+
46
+ useEffect(() => { load(); }, [load]);
47
+
48
+ const openDetail = async (uid) => {
49
+ if (openUser === uid) { setOpenUser(""); setDetail(null); return; }
50
+ setOpenUser(uid); setDetail(null);
51
+ try {
52
+ const r = await fetch(`/api/analytics/admin/user?${qs({ user_id: uid })}`);
53
+ const d = await r.json();
54
+ if (d.ok) setDetail(d);
55
+ } catch { /* im lặng, panel sẽ hiện "đang tải" */ }
56
+ };
57
+
58
+ const loadRaw = async () => {
59
+ setRawOpen((v) => !v);
60
+ if (raw.length) return;
61
+ try {
62
+ const r = await fetch(`/api/analytics/admin/events?${qs({ limit: "300" })}`);
63
+ const d = await r.json();
64
+ if (d.ok) setRaw(d.events);
65
+ } catch { /* bỏ qua */ }
66
+ };
67
+
68
+ const o = data?.overall;
69
+ const ts = data?.timeseries || [];
70
+
71
+ return (
72
+ <div className="stack" style={{ gap: 18 }}>
73
+ {/* Thanh lọc */}
74
+ <div className="card" style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
75
+ <span style={{ fontSize: 13, fontWeight: 700 }}>Khoảng thời gian</span>
76
+ {RANGES.map((r) => (
77
+ <button key={r.days} className={"btn sm" + (days === r.days ? " primary" : "")}
78
+ onClick={() => setDays(r.days)}>{r.label}</button>
79
+ ))}
80
+ <select value={feature} onChange={(e) => setFeature(e.target.value)}
81
+ style={{ padding: "7px 10px", fontSize: 13, border: "1px solid var(--line)", borderRadius: "var(--radius-sm)" }}>
82
+ <option value="">Tất cả tool</option>
83
+ {(data?.features || []).map((f) => (
84
+ <option key={f.feature} value={f.feature}>{f.label}</option>
85
+ ))}
86
+ </select>
87
+ <div style={{ flex: 1 }} />
88
+ <button className="btn sm" onClick={load} disabled={busy}>{busy ? "⏳ Đang tải…" : "↻ Tải lại"}</button>
89
+ <a className="btn sm" href={`/api/analytics/admin/export.csv?${qs()}`}>⬇ Tải CSV log thô</a>
90
+ </div>
91
+
92
+ {err && (
93
+ <div style={{ padding: "10px 14px", background: "var(--red-soft)", borderRadius: "var(--radius-sm)", fontSize: 13, fontWeight: 600 }}>
94
+ Lỗi: {err}
95
+ </div>
96
+ )}
97
+
98
+ {!data ? (
99
+ <p className="small muted">Đang tải số liệu…</p>
100
+ ) : o.events === 0 ? (
101
+ <div className="card">
102
+ <div className="panel-title">Chưa có dữ liệu</div>
103
+ <p className="small muted" style={{ margin: 0 }}>
104
+ Chưa ghi được thao tác nào trong {days} ngày qua. Số liệu sẽ hiện dần khi người dùng
105
+ mở tool và làm việc — không cần thao tác gì thêm.
106
+ </p>
107
+ </div>
108
+ ) : (
109
+ <>
110
+ {/* Ô chỉ số */}
111
+ <div style={{ display: "grid", gap: 10, gridTemplateColumns: "repeat(auto-fill, minmax(168px, 1fr))" }}>
112
+ <Kpi label="Người dùng hoạt động" value={fmtNum(o.users)}
113
+ hint={`${o.returning_users} người quay lại (A5)`} />
114
+ <Kpi label="Phiên làm việc" value={fmtNum(o.sessions)}
115
+ hint={`trung vị ${fmtShort(o.session_median_ms)}/phiên`} />
116
+ <Kpi label="Tổng thời lượng dùng" value={fmtDur(o.active_ms)}
117
+ hint="đã trừ lúc bỏ máy > 30′" />
118
+ <Kpi label="Số việc (task)" value={fmtNum(o.tasks)}
119
+ hint={`${o.tasks_done} có sản phẩm`} />
120
+ <Kpi label="Tỷ lệ làm xong (A3)" value={fmtPct(o.completion_rate)}
121
+ tone={o.completion_rate >= 0.7 ? "good" : "warn"}
122
+ hint={`${o.abandoned} task bỏ dở`} />
123
+ <Kpi label="Xong 1 việc mất (A1)" value={fmtDur(o.duration_median_ms)}
124
+ hint={`chậm nhất 10%: ${fmtShort(o.duration_p90_ms)}`} />
125
+ <Kpi label="Số lần bấm/việc (A4)" value={o.attempts_avg == null ? "—" : o.attempts_avg.toFixed(1)}
126
+ hint="chạy lại nhiều = khó ưng" />
127
+ <Kpi label="Máy chạy tổng (B4)" value={fmtDur(o.machine_total_ms)}
128
+ hint={`chờ hàng đợi ${fmtShort(o.wait_total_ms)}`} />
129
+ <Kpi label="Sản phẩm làm ra (B1)" value={fmtNum(o.produced_total)} />
130
+ <Kpi label="Hài lòng (B3)"
131
+ value={o.thumbs_up + o.thumbs_down ? fmtPct(o.thumbs_rate) : "—"}
132
+ tone={o.thumbs_rate >= 0.7 ? "good" : o.thumbs_rate == null ? "" : "warn"}
133
+ hint={`${o.thumbs_up}👍 / ${o.thumbs_down}👎`} />
134
+ </div>
135
+
136
+ {/* Hoạt động theo ngày */}
137
+ <div className="card">
138
+ <div className="panel-title">📈 Hoạt động theo ngày</div>
139
+ <LineChart
140
+ labels={ts.map((d) => d.date)}
141
+ series={[
142
+ { key: "users", label: "Người dùng", color: "var(--orange)", values: ts.map((d) => d.users) },
143
+ { key: "tasks", label: "Số việc", color: "#3b82f6", values: ts.map((d) => d.tasks) },
144
+ { key: "done", label: "Việc có sản phẩm", color: "var(--green)", values: ts.map((d) => d.tasks_done) },
145
+ ]}
146
+ />
147
+ <div style={{ marginTop: 16 }}>
148
+ <div className="small muted" style={{ marginBottom: 8, fontWeight: 600 }}>Thời lượng dùng mỗi ngày</div>
149
+ <BarChart data={ts.slice(-14).map((d) => ({
150
+ label: d.date.slice(5), value: d.active_ms, text: fmtDur(d.active_ms),
151
+ sub: `${d.users} người`,
152
+ }))} />
153
+ </div>
154
+ </div>
155
+
156
+ {/* Tool & sản phẩm */}
157
+ <div style={{ display: "grid", gap: 18, gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))" }}>
158
+ <div className="card">
159
+ <div className="panel-title">🧰 Tool dùng nhiều nhất</div>
160
+ <BarChart data={(data.features || []).map((f) => ({
161
+ label: f.label, value: f.tasks, text: `${f.tasks} việc`,
162
+ sub: `${f.users} người`,
163
+ }))} />
164
+ </div>
165
+ <div className="card">
166
+ <div className="panel-title">📦 Sản phẩm làm ra</div>
167
+ <BarChart color="var(--green)" data={(data.products || []).slice(0, 10).map((p) => ({
168
+ label: `${p.label} · ${p.kind}`, value: p.count, text: fmtNum(p.count),
169
+ }))} empty="Chưa có sản phẩm nào được tải về." />
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>
176
+ <div className="tablewrap">
177
+ <table className="list">
178
+ <thead>
179
+ <tr>
180
+ <th>Tool</th><th>Người</th><th>Việc</th><th>Xong (A3)</th>
181
+ <th>Xong mất (A1)</th><th>Bấm/việc (A4)</th><th>Máy (B4)</th>
182
+ <th>Bỏ dở ở bước nào (A2)</th><th>👍 (B3)</th>
183
+ </tr>
184
+ </thead>
185
+ <tbody>
186
+ {(data.features || []).map((f) => (
187
+ <tr key={f.feature}>
188
+ <td style={{ fontWeight: 700 }}>{f.label}</td>
189
+ <td>{f.users}</td>
190
+ <td>{f.tasks}</td>
191
+ <td style={{ color: f.completion_rate >= 0.7 ? "var(--green)" : "var(--orange)", fontWeight: 700 }}>
192
+ {fmtPct(f.completion_rate)}
193
+ </td>
194
+ <td>{fmtShort(f.duration_median_ms)}</td>
195
+ <td>{f.attempts_avg == null ? "—" : f.attempts_avg.toFixed(1)}</td>
196
+ <td>{fmtShort(f.machine_median_ms)}</td>
197
+ <td style={{ minWidth: 150 }}><StepBar counts={f.abandon_by_step} /></td>
198
+ <td>{f.thumbs_up + f.thumbs_down ? fmtPct(f.thumbs_rate) : "—"}</td>
199
+ </tr>
200
+ ))}
201
+ </tbody>
202
+ </table>
203
+ </div>
204
+ <p className="small muted" style={{ margin: "10px 0 0" }}>
205
+ “Xong mất (A1)” là trung vị từ lúc mở tool tới lúc có sản phẩm đầu tiên — gồm cả thời gian
206
+ người ngồi chỉnh. “Máy (B4)” chỉ tính lúc máy chạy thật, đã trừ thời gian chờ hàng đợi.
207
+ </p>
208
+ </div>
209
+
210
+ {/* Bảng theo người */}
211
+ <div className="card">
212
+ <div className="panel-title">👥 Theo từng người ({(data.users || []).length})</div>
213
+ <div className="tablewrap">
214
+ <table className="list">
215
+ <thead>
216
+ <tr>
217
+ <th>Người dùng</th><th>Lần cuối</th><th>Ngày</th><th>Phiên</th>
218
+ <th>Thời lượng dùng</th><th>Việc</th><th>Xong</th>
219
+ <th>Sản phẩm</th><th>Máy chạy</th><th>Hay dùng</th><th></th>
220
+ </tr>
221
+ </thead>
222
+ <tbody>
223
+ {(data.users || []).map((u) => (
224
+ <tr key={u.user_id}>
225
+ <td style={{ fontWeight: 700 }}>
226
+ {u.user_id}
227
+ {u.returning && <span title="Có quay lại dùng (A5)" style={{ marginLeft: 6 }}>🔁</span>}
228
+ </td>
229
+ <td className="small muted">{fmtTime(u.last_seen)}</td>
230
+ <td>{u.days_active}</td>
231
+ <td>{u.sessions}</td>
232
+ <td style={{ fontWeight: 700 }}>{fmtDur(u.active_ms)}</td>
233
+ <td>{u.tasks}</td>
234
+ <td style={{ color: u.completion_rate >= 0.7 ? "var(--green)" : "var(--orange)", fontWeight: 700 }}>
235
+ {u.tasks ? `${u.tasks_done} · ${fmtPct(u.completion_rate)}` : "—"}
236
+ </td>
237
+ <td>{fmtNum(u.produced_total)}</td>
238
+ <td>{fmtShort(u.machine_total_ms)}</td>
239
+ <td className="small">
240
+ {(u.top_features || []).map((f) => `${f.label} (${f.tasks})`).join(", ") || "—"}
241
+ </td>
242
+ <td>
243
+ <button className="btn sm" onClick={() => openDetail(u.user_id)}>
244
+ {openUser === u.user_id ? "Đóng" : "Xem phiên"}
245
+ </button>
246
+ </td>
247
+ </tr>
248
+ ))}
249
+ </tbody>
250
+ </table>
251
+ </div>
252
+
253
+ {openUser && (
254
+ <div style={{ marginTop: 16, borderTop: "1px solid var(--line)", paddingTop: 14 }}>
255
+ <div style={{ fontWeight: 800, marginBottom: 10 }}>
256
+ Nhật ký làm việc của <span style={{ color: "var(--orange)" }}>{openUser}</span>
257
+ </div>
258
+ {!detail ? <p className="small muted">Đang tải…</p> : <UserSessions detail={detail} />}
259
+ </div>
260
+ )}
261
+ </div>
262
+
263
+ {/* Tốc độ máy */}
264
+ <div className="card">
265
+ <div className="panel-title">⚙️ Tốc độ máy mỗi bước (B2)</div>
266
+ <div className="tablewrap">
267
+ <table className="list">
268
+ <thead>
269
+ <tr><th>Tool</th><th>Bước máy</th><th>Số lần chạy</th><th>Trung vị</th><th>Chậm nhất 10%</th><th>Tổng</th></tr>
270
+ </thead>
271
+ <tbody>
272
+ {(data.jobs || []).map((j, i) => (
273
+ <tr key={j.feature + j.phase + i}>
274
+ <td style={{ fontWeight: 600 }}>{j.label || "—"}</td>
275
+ <td>{j.phase}</td>
276
+ <td>{j.runs}</td>
277
+ <td style={{ fontWeight: 700 }}>{fmtShort(j.median_ms)}</td>
278
+ <td>{fmtShort(j.p90_ms)}</td>
279
+ <td className="small muted">{fmtDur(j.total_ms)}</td>
280
+ </tr>
281
+ ))}
282
+ </tbody>
283
+ </table>
284
+ </div>
285
+ </div>
286
+
287
+ {/* Log thô */}
288
+ <div className="card">
289
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
290
+ <div className="panel-title" style={{ margin: 0 }}>🧾 Log thô</div>
291
+ <button className="btn sm" onClick={loadRaw}>{rawOpen ? "Ẩn" : "Xem 300 dòng gần nhất"}</button>
292
+ </div>
293
+ {rawOpen && (
294
+ <div className="tablewrap" style={{ marginTop: 12, maxHeight: 420, overflowY: "auto" }}>
295
+ <table className="list">
296
+ <thead>
297
+ <tr><th>Thời gian</th><th>Người</th><th>Tool</th><th>Bước</th><th>Sự kiện</th><th>Giá trị</th><th>Máy (ms)</th><th>Chi tiết</th></tr>
298
+ </thead>
299
+ <tbody>
300
+ {raw.map((e) => (
301
+ <tr key={e.event_id}>
302
+ <td className="small muted" style={{ whiteSpace: "nowrap" }}>{fmtTime(e.event_ts)}</td>
303
+ <td className="small">{e.user_id}</td>
304
+ <td className="small">{e.feature || "—"}</td>
305
+ <td className="small">{e.step || "—"}</td>
306
+ <td className="small" style={{ fontWeight: 600 }}>{e.event_name}</td>
307
+ <td className="small">{e.event_value || "—"}</td>
308
+ <td className="small">{e.latency_ms == null ? "" : fmtNum(e.latency_ms)}</td>
309
+ <td className="small muted" style={{ maxWidth: 260, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
310
+ {JSON.stringify(e.properties || {})}
311
+ </td>
312
+ </tr>
313
+ ))}
314
+ </tbody>
315
+ </table>
316
+ </div>
317
+ )}
318
+ <p className="small muted" style={{ margin: "10px 0 0" }}>
319
+ Lưu tại <b>{data.storage?.backend === "pg" ? "PostgreSQL" : "SQLite (máy chủ)"}</b> ·
320
+ {" "}{fmtNum(data.storage?.rows)} dòng · giữ {data.storage?.retention_days} ngày rồi tự dọn.
321
+ </p>
322
+ </div>
323
+ </>
324
+ )}
325
+ </div>
326
+ );
327
+ }
328
+
329
+ // ── Nhật ký từng phiên của một người ───────────────────────────
330
+
331
+ function UserSessions({ detail }) {
332
+ const sessions = detail.sessions || [];
333
+ if (!sessions.length) return <p className="small muted">Không có phiên nào trong khoảng đã chọn.</p>;
334
+
335
+ return (
336
+ <div className="stack" style={{ gap: 12 }}>
337
+ {sessions.map((s) => (
338
+ <div key={s.session_id} style={{
339
+ border: "1px solid var(--line)", borderRadius: "var(--radius-sm)",
340
+ padding: "10px 12px", background: "var(--cream)",
341
+ }}>
342
+ <div style={{ display: "flex", gap: 12, flexWrap: "wrap", alignItems: "baseline", marginBottom: 8 }}>
343
+ <b style={{ fontSize: 13 }}>{fmtTime(s.start_ts)}</b>
344
+ <span className="small muted">
345
+ dùng {fmtDur(s.active_ms)} · {s.n_tasks} việc · {s.n_events} thao tác
346
+ </span>
347
+ <span className="small muted" style={{ marginLeft: "auto" }}>{s.session_id}</span>
348
+ </div>
349
+
350
+ {(s.tasks || []).map((t) => (
351
+ <div key={t.task_id} style={{
352
+ borderTop: "1px dashed var(--line)", paddingTop: 8, marginTop: 8,
353
+ }}>
354
+ <div style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "center" }}>
355
+ <span style={{ fontWeight: 700, fontSize: 13 }}>{t.label}</span>
356
+ {t.has_output ? (
357
+ <span style={{ fontSize: 11.5, fontWeight: 700, color: "var(--green)", background: "var(--green-soft)", padding: "2px 8px", borderRadius: 99 }}>
358
+ ✓ {t.success_type === "forward" ? "chuyển tiếp" : "tải về"} · {t.produced} sản phẩm
359
+ </span>
360
+ ) : t.abandoned ? (
361
+ <span style={{ fontSize: 11.5, fontWeight: 700, color: "var(--red)", background: "var(--red-soft)", padding: "2px 8px", borderRadius: 99 }}>
362
+ ✕ bỏ dở ở “{t.last_step || "?"}”{t.abandon_reason ? ` · ${t.abandon_reason}` : ""}
363
+ </span>
364
+ ) : (
365
+ <span className="small muted">đang làm dở</span>
366
+ )}
367
+ <span className="small muted">
368
+ {t.has_output ? `xong sau ${fmtDur(t.duration_ms)}` : `kéo dài ${fmtDur(t.span_ms)}`}
369
+ {" · "}máy {fmtShort(t.machine_ms)}
370
+ {t.wait_ms ? ` (chờ ${fmtShort(t.wait_ms)})` : ""}
371
+ {t.attempts ? ` · bấm chạy ${t.attempts} lần` : ""}
372
+ {t.thumbs_up ? " · 👍" : ""}{t.thumbs_down ? " · 👎" : ""}
373
+ </span>
374
+ </div>
375
+ {!!(t.steps || []).length && (
376
+ <div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginTop: 6 }}>
377
+ {t.steps.map((st, i) => (
378
+ <span key={i} style={{
379
+ fontSize: 11, color: "var(--ink-2)", background: "var(--white)",
380
+ border: "1px solid var(--line)", borderRadius: 99, padding: "2px 8px",
381
+ }}>
382
+ {st.step} {fmtShort(st.ms)}
383
+ </span>
384
+ ))}
385
+ </div>
386
+ )}
387
+ </div>
388
+ ))}
389
+ </div>
390
+ ))}
391
+ </div>
392
+ );
393
+ }
frontend/src/lib/track.js ADDED
@@ -0,0 +1,330 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Ghi log hành vi người dùng (phần "đồng hồ người" — thời gian tường).
2
+ //
3
+ // Phân vai với backend: ở đây CHỈ ghi thao tác của người. Thời gian máy chạy do
4
+ // backend tự ghi trong JobManager (job_queued/job_started/job_done) vì chỉ máy
5
+ // chủ biết job rời hàng đợi lúc nào — frontend không bắn job_done, tránh đếm đôi.
6
+ //
7
+ // Bốn tầng định danh: user_id > session_id > task_id > step.
8
+ // session_id: một lần dùng app; im lặng quá 30' thì mở phiên mới.
9
+ // task_id : một lần làm ra một sản phẩm. Mở khi mở tool, đóng khi có đầu ra
10
+ // hoặc bỏ dở. Chạy lại/regenerate KHÔNG mở task mới, chỉ attempt+1.
11
+ //
12
+ // Log gom thành lô, gửi mỗi 5 giây; lúc đóng tab dùng sendBeacon để không mất
13
+ // dòng feature_abandon.
14
+
15
+ const ENDPOINT = "/api/events";
16
+ const FLUSH_MS = 5000;
17
+ const MAX_BATCH = 20;
18
+ const IDLE_MS = 30 * 60 * 1000; // ngưỡng im lặng -> hết phiên (spec §6)
19
+
20
+ const STEPS = ["open", "prepare", "run", "review", "output", "done"];
21
+
22
+ // Đường dẫn -> khoá tính năng (feature) dùng trong log.
23
+ export const ROUTE_FEATURE = {
24
+ "/": "home",
25
+ "/down-list": "down_list",
26
+ "/cut": "cut",
27
+ "/xao": "shuffle",
28
+ "/dub": "dub",
29
+ "/chen-chu": "text_video",
30
+ "/listing-anh": "listing_image",
31
+ "/listing-free": "listing_free",
32
+ "/flux2": "flux2",
33
+ "/tao-video": "create_video",
34
+ "/huong-dan": "guide",
35
+ "/ho-tro": "support",
36
+ "/quan-tri": "admin",
37
+ "/lich-su": "history",
38
+ };
39
+
40
+ // Trang chỉ để XEM, không làm ra sản phẩm. Ghi `page_view` KHÔNG kèm task_id:
41
+ // mở trang chủ/hướng dẫn mà tính là một "task 0% hoàn thành" thì bảng theo tool
42
+ // và tỷ lệ A3 bị pha loãng bằng thứ không phải công việc.
43
+ const PASSIVE = new Set(["home", "guide", "support", "admin", "history"]);
44
+
45
+ const rid = () => Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4);
46
+
47
+ const nowIso = () => {
48
+ const d = new Date();
49
+ const off = -d.getTimezoneOffset();
50
+ const sign = off >= 0 ? "+" : "-";
51
+ const p = (n, w = 2) => String(Math.abs(n)).padStart(w, "0");
52
+ return (
53
+ `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}` +
54
+ `T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}` +
55
+ `${sign}${p(off / 60 | 0)}:${p(off % 60)}`
56
+ );
57
+ };
58
+
59
+ const ls = {
60
+ get(k) { try { return localStorage.getItem(k); } catch { return null; } },
61
+ set(k, v) { try { localStorage.setItem(k, v); } catch { /* chế độ riêng tư */ } },
62
+ };
63
+
64
+ // ── Trạng thái phiên / task ─────────────────────────────────────
65
+
66
+ let queue = [];
67
+ let timer = null;
68
+ let session = "";
69
+ let task = null; // { id, feature, startedAt, lastStep, closed, attempt, phase }
70
+ let started = false;
71
+ let lastOpen = { feature: "", ts: 0 }; // chặn mở trùng (xem featureOpen)
72
+
73
+ function touch() {
74
+ ls.set("tnt_track_last", String(Date.now()));
75
+ }
76
+
77
+ function sessionId() {
78
+ if (session) return session;
79
+ const last = Number(ls.get("tnt_track_last") || 0);
80
+ const prev = ls.get("tnt_track_sess") || "";
81
+ if (prev && last && Date.now() - last < IDLE_MS) {
82
+ session = prev; // quay lại trong 30' -> vẫn phiên cũ
83
+ } else {
84
+ session = "sess_" + rid();
85
+ ls.set("tnt_track_sess", session);
86
+ started = false;
87
+ }
88
+ return session;
89
+ }
90
+
91
+ function userId() {
92
+ return (ls.get("tnt_user") || "").trim().toLowerCase();
93
+ }
94
+
95
+ // ── Hàng đợi gửi ────────────────────────────────────────────────
96
+
97
+ function push(row) {
98
+ const user = userId();
99
+ if (!user) return; // chưa đăng nhập -> không có gì để quy về ai
100
+ queue.push({ event_id: "evt_" + rid(), event_time: nowIso(), user_id: user, ...row });
101
+ touch();
102
+ if (queue.length >= MAX_BATCH) flush();
103
+ else if (!timer) timer = setTimeout(flush, FLUSH_MS);
104
+ }
105
+
106
+ export function flush(useBeacon = false) {
107
+ if (timer) { clearTimeout(timer); timer = null; }
108
+ if (!queue.length) return;
109
+ const body = JSON.stringify({ events: queue });
110
+ queue = [];
111
+ try {
112
+ if (useBeacon && navigator.sendBeacon) {
113
+ navigator.sendBeacon(ENDPOINT, new Blob([body], { type: "application/json" }));
114
+ return;
115
+ }
116
+ fetch(ENDPOINT, {
117
+ method: "POST",
118
+ headers: { "Content-Type": "application/json" },
119
+ body,
120
+ keepalive: true,
121
+ }).catch(() => {});
122
+ } catch { /* mất log còn hơn vỡ app */ }
123
+ }
124
+
125
+ // ── Ghi event ───────────────────────────────────────────────────
126
+
127
+ function emit(event_name, step, props = {}, event_value = "") {
128
+ push({
129
+ session_id: sessionId(),
130
+ task_id: task ? task.id : "",
131
+ feature: task ? task.feature : "",
132
+ step: STEPS.includes(step) ? step : "prepare",
133
+ event_name,
134
+ event_value: event_value == null ? "" : String(event_value),
135
+ properties: props,
136
+ });
137
+ if (task && STEPS.includes(step) && step !== "done") task.lastStep = step;
138
+ }
139
+
140
+ /** Mở app / đăng nhập xong. Gọi một lần khi vào Layout. */
141
+ export function sessionStart(props = {}) {
142
+ sessionId();
143
+ if (started) return;
144
+ started = true;
145
+ // Cờ `started` nằm trong bộ nhớ nên F5 là mất, trong khi session_id vẫn giữ
146
+ // trong localStorage -> không chốt lại thì mỗi lần tải lại trang đẻ thêm một
147
+ // dòng session_start cho cùng một phiên.
148
+ if (ls.get("tnt_track_started") === session) return;
149
+ ls.set("tnt_track_started", session);
150
+ push({
151
+ session_id: session, task_id: "", feature: "", step: "open",
152
+ event_name: "session_start", event_value: "",
153
+ properties: { client: "web", ...props },
154
+ });
155
+ }
156
+
157
+ /** Mở một tool -> đóng task cũ (nếu dở) và mở task mới. */
158
+ export function featureOpen(feature, props = {}) {
159
+ if (!feature) return;
160
+
161
+ // Chống mở TRÙNG: React.StrictMode (dev) chạy effect hai lần, và một lần
162
+ // re-render lạc cũng gọi lại. Không chặn thì mỗi lần mở tool đẻ thêm một task
163
+ // rỗng kèm feature_abandon dài 1ms -> tỷ lệ "làm xong" tụt oan.
164
+ if (Date.now() - lastOpen.ts < 2000 && lastOpen.feature === feature) return;
165
+ lastOpen = { feature, ts: Date.now() };
166
+
167
+ abandonIfOpen();
168
+
169
+ if (PASSIVE.has(feature)) {
170
+ task = null; // trang xem: không mở task
171
+ push({
172
+ session_id: sessionId(), task_id: "", feature, step: "open",
173
+ event_name: "page_view", event_value: "", properties: props,
174
+ });
175
+ return;
176
+ }
177
+
178
+ task = {
179
+ id: "task_" + rid(), feature, startedAt: Date.now(),
180
+ lastStep: "open", closed: false, attempt: 0, phase: "",
181
+ };
182
+ emit("feature_open", "open", props);
183
+ }
184
+
185
+ /** Một thao tác thường của người (dán link, tải file lên, sửa lời...). */
186
+ export function step(event_name, stepName = "prepare", props = {}, value = "") {
187
+ if (!task) return;
188
+ // Task đã có đầu ra mà người dùng làm tiếp -> đó là sản phẩm mới -> task mới.
189
+ if (task.closed) featureOpen(task.feature, { from: "continue" });
190
+ emit(event_name, stepName, props, value);
191
+ }
192
+
193
+ /** Người bấm nút chạy máy. Tăng attempt (A4) — backend đọc qua header. */
194
+ export function runClick(props = {}) {
195
+ if (!task) return;
196
+ if (task.closed) featureOpen(task.feature, { from: "continue" });
197
+ task.attempt += 1;
198
+ // Nhớ tên pha máy sắp chạy (asr_translate / tts / burn / cut…) và gửi kèm
199
+ // header, để dòng job_done do backend ghi biết mình là bước nào -> chỉ số B2
200
+ // tách được tốc độ từng bước thay vì gộp một cục.
201
+ if (props.phase) task.phase = props.phase;
202
+ emit("run_click", "run", { attempt: task.attempt, ...props });
203
+ return task.attempt;
204
+ }
205
+
206
+ /** Làm lại (Tạo lại / Yêu cầu sửa) — cùng task, attempt+1. */
207
+ export function retry(event_name = "regenerate", props = {}) {
208
+ if (!task) return;
209
+ task.attempt += 1;
210
+ emit(event_name, "run", { attempt: task.attempt, ...props });
211
+ }
212
+
213
+ /** Có sản phẩm: tải về hoặc chuyển tiếp sang tool khác. Cả hai đều là XONG. */
214
+ export function output(kind = "file", props = {}, forwardTo = "") {
215
+ if (!task) return;
216
+ const isForward = !!forwardTo;
217
+ emit(
218
+ isForward ? "output_forward" : "output_download",
219
+ "output",
220
+ { success_type: isForward ? "forward" : "download", ...(isForward ? { to: forwardTo } : {}), ...props },
221
+ isForward ? forwardTo : kind,
222
+ );
223
+ if (!task.closed) {
224
+ emit("feature_complete", "done", {
225
+ success_type: isForward ? "forward" : "download",
226
+ dur_ms: Date.now() - task.startedAt,
227
+ attempts: task.attempt,
228
+ });
229
+ task.closed = true;
230
+ }
231
+ }
232
+
233
+ /** Nút 👍/👎 ở chỗ hiện kết quả (chỉ số B3). */
234
+ export function thumbs(value, props = {}) {
235
+ if (!task) return;
236
+ emit("thumbs_feedback", task.closed ? "output" : "review", props, value === "up" ? "up" : "down");
237
+ }
238
+
239
+ /** Nút "Làm mới / Bắt đầu lại" -> đóng task hiện tại, mở task mới. */
240
+ export function restart(props = {}) {
241
+ if (!task) return;
242
+ const f = task.feature;
243
+ featureOpen(f, { from: "restart", ...props });
244
+ }
245
+
246
+ function abandonIfOpen(extra = {}) {
247
+ if (!task || task.closed) return;
248
+ emit("feature_abandon", task.lastStep || "open", {
249
+ last_step: task.lastStep || "open",
250
+ dur_ms: Date.now() - task.startedAt,
251
+ attempts: task.attempt,
252
+ ...extra,
253
+ });
254
+ task.closed = true;
255
+ }
256
+
257
+ /** Bỏ dở có lý do rõ (vd thiếu API key). */
258
+ export function abandon(reason = "", lastStep = "") {
259
+ if (!task || task.closed) return;
260
+ if (lastStep) task.lastStep = lastStep;
261
+ abandonIfOpen(reason ? { reason } : {});
262
+ }
263
+
264
+ /** Ngữ cảnh hiện tại — dùng để gắn header X-TNT-* vào request API. */
265
+ export function currentCtx() {
266
+ return {
267
+ user_id: userId(),
268
+ session_id: session || sessionId(),
269
+ task_id: task ? task.id : "",
270
+ feature: task ? task.feature : "",
271
+ phase: task ? task.phase || "" : "",
272
+ attempt: task ? task.attempt : 0,
273
+ };
274
+ }
275
+
276
+ // ── Gắn ngữ cảnh vào MỌI request /api/ ──────────────────────────
277
+ //
278
+ // Vá `fetch` một lần ở đây thay vì sửa ~15 chỗ gọi API: backend đọc header
279
+ // X-TNT-* trong middleware rồi tự gắn vào job, nên dòng log "máy chạy" biết
280
+ // thuộc task nào của ai mà không phải đổi model request nào cả.
281
+
282
+ let patched = false;
283
+
284
+ function patchFetch() {
285
+ if (patched || typeof window === "undefined") return;
286
+ patched = true;
287
+ const orig = window.fetch.bind(window);
288
+ window.fetch = (input, init = {}) => {
289
+ try {
290
+ const url = typeof input === "string" ? input : (input && input.url) || "";
291
+ const isApi = url.startsWith("/api/") || url.includes("/api/");
292
+ if (isApi && !url.includes("/api/events")) {
293
+ const c = currentCtx();
294
+ if (c.user_id) {
295
+ const h = new Headers((init && init.headers) || (input instanceof Request ? input.headers : undefined));
296
+ h.set("X-TNT-User", c.user_id);
297
+ h.set("X-TNT-Session", c.session_id);
298
+ if (c.task_id) h.set("X-TNT-Task", c.task_id);
299
+ if (c.feature) h.set("X-TNT-Feature", c.feature);
300
+ if (c.phase) h.set("X-TNT-Phase", c.phase);
301
+ if (c.attempt) h.set("X-TNT-Attempt", String(c.attempt));
302
+ return orig(input, { ...init, headers: h });
303
+ }
304
+ }
305
+ } catch { /* vá lỗi thì gọi như cũ */ }
306
+ return orig(input, init);
307
+ };
308
+ }
309
+
310
+ let inited = false;
311
+
312
+ /** Gọi một lần lúc app khởi động (trong Layout). */
313
+ export function initTracking() {
314
+ if (inited || typeof window === "undefined") return;
315
+ inited = true;
316
+ patchFetch();
317
+ sessionStart();
318
+
319
+ const bye = () => { abandonIfOpen({ reason: "close_tab" }); flush(true); };
320
+ window.addEventListener("pagehide", bye);
321
+ window.addEventListener("visibilitychange", () => {
322
+ if (document.visibilityState === "hidden") flush(true);
323
+ });
324
+ setInterval(() => flush(), FLUSH_MS * 3);
325
+ }
326
+
327
+ export default {
328
+ initTracking, sessionStart, featureOpen, step, runClick, retry,
329
+ output, thumbs, restart, abandon, flush, currentCtx, ROUTE_FEATURE,
330
+ };
frontend/src/pages/AdminUsers.jsx CHANGED
@@ -1,6 +1,7 @@
1
  import { useState } from "react";
2
  import { useNavigate } from "react-router-dom";
3
  import { useAuth } from "../stores/authStore";
 
4
 
5
  export default function AdminUsers() {
6
  const { user, logout } = useAuth();
@@ -13,6 +14,8 @@ export default function AdminUsers() {
13
  const [note, setNote] = useState("");
14
  const [busy, setBusy] = useState(false);
15
  const [msg, setMsg] = useState("");
 
 
16
 
17
  const load = async (t) => {
18
  const tk = t || token;
@@ -92,13 +95,17 @@ export default function AdminUsers() {
92
  return (
93
  <div>
94
  <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
95
- <div className="h1" style={{ margin: 0 }}>Quản lý <span className="accent">người dùng</span></div>
96
  <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
97
  <span style={{ fontSize: 13, color: "var(--ink-2)" }}>👤 {user}</span>
98
  <button className="btn sm" onClick={() => { logout(); navigate("/dang-nhap"); }}>Đăng xuất</button>
99
  </div>
100
  </div>
101
- <p className="subtitle">Duyệt và quản lý những tài khoản được phép đăng nhập, sử dụng app.</p>
 
 
 
 
102
 
103
  <div className="card" style={{ maxWidth: 600, marginBottom: 20 }}>
104
  <div className="panel-title">🔑 Đăng nhập quản trị</div>
@@ -113,6 +120,17 @@ export default function AdminUsers() {
113
  {msg && <div style={{ padding: "10px 14px", background: msg.startsWith("✅") ? "var(--green-soft)" : "var(--red-soft)", borderRadius: "var(--radius-sm)", marginBottom: 16, fontSize: 13, fontWeight: 600 }}>{msg}</div>}
114
 
115
  {authenticated && (
 
 
 
 
 
 
 
 
 
 
 
116
  <>
117
  {pending.length > 0 && (
118
  <div className="card" style={{ maxWidth: 600, marginBottom: 20, border: "2px solid var(--orange)" }}>
 
1
  import { useState } from "react";
2
  import { useNavigate } from "react-router-dom";
3
  import { useAuth } from "../stores/authStore";
4
+ import UsageDashboard from "../components/admin/UsageDashboard";
5
 
6
  export default function AdminUsers() {
7
  const { user, logout } = useAuth();
 
14
  const [note, setNote] = useState("");
15
  const [busy, setBusy] = useState(false);
16
  const [msg, setMsg] = useState("");
17
+ // Sau khi nhập mật khẩu quản trị: 2 tab — quản lý người dùng / theo dõi thời lượng.
18
+ const [tab, setTab] = useState("users");
19
 
20
  const load = async (t) => {
21
  const tk = t || token;
 
95
  return (
96
  <div>
97
  <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
98
+ <div className="h1" style={{ margin: 0 }}>Quản trị <span className="accent">{tab === "users" ? "người dùng" : "thời lượng"}</span></div>
99
  <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
100
  <span style={{ fontSize: 13, color: "var(--ink-2)" }}>👤 {user}</span>
101
  <button className="btn sm" onClick={() => { logout(); navigate("/dang-nhap"); }}>Đăng xuất</button>
102
  </div>
103
  </div>
104
+ <p className="subtitle">
105
+ {tab === "users"
106
+ ? "Duyệt và quản lý những tài khoản được phép đăng nhập, sử dụng app."
107
+ : "Ai dùng tool gì, mất bao lâu, làm ra sản phẩm gì — tính từ log thao tác thật."}
108
+ </p>
109
 
110
  <div className="card" style={{ maxWidth: 600, marginBottom: 20 }}>
111
  <div className="panel-title">🔑 Đăng nhập quản trị</div>
 
120
  {msg && <div style={{ padding: "10px 14px", background: msg.startsWith("✅") ? "var(--green-soft)" : "var(--red-soft)", borderRadius: "var(--radius-sm)", marginBottom: 16, fontSize: 13, fontWeight: 600 }}>{msg}</div>}
121
 
122
  {authenticated && (
123
+ <div style={{ display: "flex", gap: 8, marginBottom: 18 }}>
124
+ <button className={"btn" + (tab === "users" ? " primary" : "")}
125
+ onClick={() => setTab("users")}>👥 Quản lý người dùng</button>
126
+ <button className={"btn" + (tab === "usage" ? " primary" : "")}
127
+ onClick={() => setTab("usage")}>📊 Quản lý thời lượng</button>
128
+ </div>
129
+ )}
130
+
131
+ {authenticated && tab === "usage" && <UsageDashboard token={token} />}
132
+
133
+ {authenticated && tab === "users" && (
134
  <>
135
  {pending.length > 0 && (
136
  <div className="card" style={{ maxWidth: 600, marginBottom: 20, border: "2px solid var(--orange)" }}>
frontend/src/pages/CreateVideo.jsx CHANGED
@@ -1,5 +1,6 @@
1
  import { useState, useEffect } from "react";
2
  import { useVideoGen } from "../stores/videoGenStore";
 
3
  import { useDirty } from "../stores/dirtyStore";
4
  import { IcVideo } from "../components/Icons";
5
 
@@ -25,6 +26,10 @@ export default function CreateVideo() {
25
  makeDirty("Bạn đang kết nối tạo video. Mất tiến trình nếu rời đi.");
26
  if (!connectUrl.trim()) return;
27
  setBusy(true);
 
 
 
 
28
  try {
29
  const r = await fetch("/api/video/connect", {
30
  method: "POST",
@@ -34,7 +39,9 @@ export default function CreateVideo() {
34
  const data = await r.json();
35
  if (!r.ok) throw new Error(data.detail || data.error || "Lỗi kết nối");
36
  connect(connectUrl, data);
 
37
  } catch (e) {
 
38
  alert("Lỗi kết nối: " + e.message);
39
  } finally {
40
  setBusy(false);
 
1
  import { useState, useEffect } from "react";
2
  import { useVideoGen } from "../stores/videoGenStore";
3
+ import track from "../lib/track";
4
  import { useDirty } from "../stores/dirtyStore";
5
  import { IcVideo } from "../components/Icons";
6
 
 
26
  makeDirty("Bạn đang kết nối tạo video. Mất tiến trình nếu rời đi.");
27
  if (!connectUrl.trim()) return;
28
  setBusy(true);
29
+ // Tool này chỉ bàn giao sang app ngoài: đo được tới đây là hết, KHÔNG có
30
+ // output nội bộ để đo (spec §4.8).
31
+ const t0 = Date.now();
32
+ track.runClick({ phase: "connect" });
33
  try {
34
  const r = await fetch("/api/video/connect", {
35
  method: "POST",
 
39
  const data = await r.json();
40
  if (!r.ok) throw new Error(data.detail || data.error || "Lỗi kết nối");
41
  connect(connectUrl, data);
42
+ track.step("connect_success", "output", { latency_ms_client: Date.now() - t0 });
43
  } catch (e) {
44
+ track.abandon("connect_failed", "run");
45
  alert("Lỗi kết nối: " + e.message);
46
  } finally {
47
  setBusy(false);
frontend/src/pages/CutVideo.jsx CHANGED
@@ -7,6 +7,8 @@ import { ProcessRail, FileDrop, ProcessingOverlay, CacheNote } from "../componen
7
  import {
8
  IcStar, IcPlay, IcArrow, IcDownload, IcPlus, IcTrash, IcShuffle,
9
  } from "../components/Icons";
 
 
10
 
11
  // Luồng: tải video -> "Tách khúc tự động" (máy tự tìm điểm cắt theo nội dung) ->
12
  // XEM/chỉnh điểm cắt trên timeline (thêm/xoá/kéo) -> "Cắt & xuất khúc".
@@ -217,6 +219,7 @@ export default function CutVideo() {
217
  info: r.info, thumbs: [], cuts: [],
218
  };
219
  set((s) => ({ videos: [...(s.videos || []), v] }));
 
220
  } catch (e) { alert("Lỗi tải video: " + e.message); }
221
  }
222
  setUploading(null);
@@ -247,6 +250,7 @@ export default function CutVideo() {
247
  const previewAll = async () => {
248
  if (!videos.length) return;
249
  setPrevBusy(true);
 
250
  try {
251
  for (let i = 0; i < videos.length; i++) await doPreview(i);
252
  } catch (e) { alert("Lỗi tách khúc: " + e.message); }
@@ -266,6 +270,9 @@ export default function CutVideo() {
266
  const executeAll = async () => {
267
  if (!videos.length) return;
268
  setBusy(true); set({ results: [] });
 
 
 
269
  try {
270
  const sid = videos[0].session;
271
  const payload = {
@@ -306,6 +313,7 @@ export default function CutVideo() {
306
  }
307
  try {
308
  const r = await api.cutPushShuffle(st.resultSession, groups);
 
309
  nav(`/xao?session=${r.session}`);
310
  } catch (e) { alert("Lỗi: " + e.message); }
311
  };
@@ -321,11 +329,13 @@ export default function CutVideo() {
321
  (done.items || []).find((i) => i.status === "done")?.result;
322
  if (!out) throw new Error(done.error || "Không tách được (xem log).");
323
  set({ splitResult: out });
 
324
  } catch (e) { alert("Lỗi tách âm thanh: " + e.message); }
325
  finally { set({ splitBusy: false }); }
326
  };
327
 
328
  const downloadAll = () => {
 
329
  allClips().forEach((r, i) => setTimeout(() => {
330
  const a = document.createElement("a");
331
  a.href = fileUrl(r.url); a.download = r.name;
@@ -442,6 +452,7 @@ export default function CutVideo() {
442
  <button className="btn sm primary" onClick={pushToShuffle}><IcShuffle size={15} /> Chuyển sang Xào</button>
443
  </div>
444
  </div>
 
445
  {st.results.map((v, vi) => (
446
  <div key={vi} className="mt16">
447
  <div className="flex between center">
 
7
  import {
8
  IcStar, IcPlay, IcArrow, IcDownload, IcPlus, IcTrash, IcShuffle,
9
  } from "../components/Icons";
10
+ import track from "../lib/track";
11
+ import ThumbsFeedback from "../components/ThumbsFeedback";
12
 
13
  // Luồng: tải video -> "Tách khúc tự động" (máy tự tìm điểm cắt theo nội dung) ->
14
  // XEM/chỉnh điểm cắt trên timeline (thêm/xoá/kéo) -> "Cắt & xuất khúc".
 
219
  info: r.info, thumbs: [], cuts: [],
220
  };
221
  set((s) => ({ videos: [...(s.videos || []), v] }));
222
+ track.step("upload_done", "prepare", { name: file.name, idx: i + 1, total: arr.length });
223
  } catch (e) { alert("Lỗi tải video: " + e.message); }
224
  }
225
  setUploading(null);
 
250
  const previewAll = async () => {
251
  if (!videos.length) return;
252
  setPrevBusy(true);
253
+ track.runClick({ phase: "auto_segment", videos: videos.length });
254
  try {
255
  for (let i = 0; i < videos.length; i++) await doPreview(i);
256
  } catch (e) { alert("Lỗi tách khúc: " + e.message); }
 
270
  const executeAll = async () => {
271
  if (!videos.length) return;
272
  setBusy(true); set({ results: [] });
273
+ // Người đã xem & chỉnh điểm cắt xong mới bấm -> đây là mốc "duyệt xong".
274
+ track.step("review_cuts_done", "review", { videos: videos.length });
275
+ track.runClick({ phase: "cut", videos: videos.length });
276
  try {
277
  const sid = videos[0].session;
278
  const payload = {
 
313
  }
314
  try {
315
  const r = await api.cutPushShuffle(st.resultSession, groups);
316
+ track.output("", { count: groups.length }, "shuffle");
317
  nav(`/xao?session=${r.session}`);
318
  } catch (e) { alert("Lỗi: " + e.message); }
319
  };
 
329
  (done.items || []).find((i) => i.status === "done")?.result;
330
  if (!out) throw new Error(done.error || "Không tách được (xem log).");
331
  set({ splitResult: out });
332
+ track.step("split_audio_done", "output", { subflow: "split_audio" });
333
  } catch (e) { alert("Lỗi tách âm thanh: " + e.message); }
334
  finally { set({ splitBusy: false }); }
335
  };
336
 
337
  const downloadAll = () => {
338
+ track.output("mp4", { count: allClips().length });
339
  allClips().forEach((r, i) => setTimeout(() => {
340
  const a = document.createElement("a");
341
  a.href = fileUrl(r.url); a.download = r.name;
 
452
  <button className="btn sm primary" onClick={pushToShuffle}><IcShuffle size={15} /> Chuyển sang Xào</button>
453
  </div>
454
  </div>
455
+ <div className="mt12"><ThumbsFeedback label="Các khúc cắt có đúng ý không?" /></div>
456
  {st.results.map((v, vi) => (
457
  <div key={vi} className="mt16">
458
  <div className="flex between center">
frontend/src/pages/DownList.jsx CHANGED
@@ -5,6 +5,8 @@ import { useToolState } from "../store";
5
  import { useAuth } from "../stores/authStore";
6
  import { ProcessRail, Toggle, ProcessingOverlay, CacheNote } from "../components/ui";
7
  import { IcTrash, IcDownload, IcArrow, IcScissors, IcMic, IcCheck } from "../components/Icons";
 
 
8
 
9
  const STEPS = [
10
  { t: "Dán danh sách link", d: "Dán hoặc nhập danh sách các link video cần tải." },
@@ -41,6 +43,7 @@ export default function DownList() {
41
  const [analyzing, setAnalyzing] = useState(false);
42
  const parseTimer = useRef(null);
43
  const forwardingRef = useRef(false);
 
44
  // Huỷ vòng poll job khi rời trang.
45
  const pollAbort = useRef(new AbortController());
46
  useEffect(() => () => pollAbort.current.abort(), []);
@@ -52,6 +55,11 @@ export default function DownList() {
52
  try {
53
  const r = await api.parseLinks(text);
54
  set({ items: r.items });
 
 
 
 
 
55
  } catch (e) { /* im lặng khi đang gõ dở */ }
56
  finally { setAnalyzing(false); }
57
  };
@@ -67,6 +75,7 @@ export default function DownList() {
67
  const urls = st.items.map((i) => i.url);
68
  if (!urls.length) return;
69
  setBusy(true);
 
70
  set({ results: [], failed: [] });
71
  try {
72
  const { job_id } = await api.startDownload(urls, st.zip, user);
@@ -99,12 +108,16 @@ export default function DownList() {
99
  if (r.error) { alert(r.error); return; }
100
  if (!firstRoute) firstRoute = `/${t.route}?session=${r.session}`;
101
  }
 
 
102
  if (firstRoute) nav(firstRoute);
103
  } catch (e) { alert("Lỗi: " + e.message); }
104
  finally { forwardingRef.current = false; }
105
  };
106
 
107
  const downloadAll = () => {
 
 
108
  st.results.forEach((r, i) => setTimeout(() => {
109
  const a = document.createElement("a");
110
  a.href = fileUrl(r.url); a.download = `${r.title}.${r.ext}`;
@@ -193,6 +206,7 @@ export default function DownList() {
193
  )}
194
  </div>
195
  </div>
 
196
  <div className="tablewrap mt12">
197
  <table className="list">
198
  <thead><tr><th>#</th><th>Tên file</th><th>Nguồn</th><th>Dung lượng</th><th>Tải</th><th>Chuyển tiếp (chọn tool)</th></tr></thead>
@@ -205,7 +219,8 @@ export default function DownList() {
205
  <td className="url-cell" title={`${r.title}.${r.ext}`}>{r.title}.{r.ext}</td>
206
  <td>{SOURCE_LABEL[r.source] || r.source}</td>
207
  <td className="muted">{(r.size / 1048576).toFixed(1)} MB</td>
208
- <td><a className="btn sm" href={fileUrl(r.url)} download={`${r.title}.${r.ext}`}>Về máy</a></td>
 
209
  <td>
210
  <div className="flex gap8 center" style={{ flexWrap: "nowrap", whiteSpace: "nowrap" }}>
211
  <div className="toolchips" style={{ flexWrap: "nowrap" }}>
 
5
  import { useAuth } from "../stores/authStore";
6
  import { ProcessRail, Toggle, ProcessingOverlay, CacheNote } from "../components/ui";
7
  import { IcTrash, IcDownload, IcArrow, IcScissors, IcMic, IcCheck } from "../components/Icons";
8
+ import track from "../lib/track";
9
+ import ThumbsFeedback from "../components/ThumbsFeedback";
10
 
11
  const STEPS = [
12
  { t: "Dán danh sách link", d: "Dán hoặc nhập danh sách các link video cần tải." },
 
43
  const [analyzing, setAnalyzing] = useState(false);
44
  const parseTimer = useRef(null);
45
  const forwardingRef = useRef(false);
46
+ const pastedRef = useRef(false); // đã ghi mốc 'dán link' cho task này chưa
47
  // Huỷ vòng poll job khi rời trang.
48
  const pollAbort = useRef(new AbortController());
49
  useEffect(() => () => pollAbort.current.abort(), []);
 
55
  try {
56
  const r = await api.parseLinks(text);
57
  set({ items: r.items });
58
+ // Ghi một lần cho mỗi task: mốc "bắt đầu chuẩn bị" của luồng Down list.
59
+ if (r.items.length && !pastedRef.current) {
60
+ pastedRef.current = true;
61
+ track.step("paste_links", "prepare", { count: r.items.length, chars: text.length });
62
+ }
63
  } catch (e) { /* im lặng khi đang gõ dở */ }
64
  finally { setAnalyzing(false); }
65
  };
 
75
  const urls = st.items.map((i) => i.url);
76
  if (!urls.length) return;
77
  setBusy(true);
78
+ track.runClick({ phase: "analyze_download", count: urls.length, zip: st.zip });
79
  set({ results: [], failed: [] });
80
  try {
81
  const { job_id } = await api.startDownload(urls, st.zip, user);
 
108
  if (r.error) { alert(r.error); return; }
109
  if (!firstRoute) firstRoute = `/${t.route}?session=${r.session}`;
110
  }
111
+ track.output("", { count: tools.length }, tools.map((t) => t.id).join("+"));
112
+ pastedRef.current = false;
113
  if (firstRoute) nav(firstRoute);
114
  } catch (e) { alert("Lỗi: " + e.message); }
115
  finally { forwardingRef.current = false; }
116
  };
117
 
118
  const downloadAll = () => {
119
+ track.output("video", { count: st.results.length });
120
+ pastedRef.current = false;
121
  st.results.forEach((r, i) => setTimeout(() => {
122
  const a = document.createElement("a");
123
  a.href = fileUrl(r.url); a.download = `${r.title}.${r.ext}`;
 
206
  )}
207
  </div>
208
  </div>
209
+ <div className="mt12"><ThumbsFeedback label="Bộ video tải về có ổn không?" /></div>
210
  <div className="tablewrap mt12">
211
  <table className="list">
212
  <thead><tr><th>#</th><th>Tên file</th><th>Nguồn</th><th>Dung lượng</th><th>Tải</th><th>Chuyển tiếp (chọn tool)</th></tr></thead>
 
219
  <td className="url-cell" title={`${r.title}.${r.ext}`}>{r.title}.{r.ext}</td>
220
  <td>{SOURCE_LABEL[r.source] || r.source}</td>
221
  <td className="muted">{(r.size / 1048576).toFixed(1)} MB</td>
222
+ <td><a className="btn sm" href={fileUrl(r.url)} download={`${r.title}.${r.ext}`}
223
+ onClick={() => track.output(r.ext || "video", { count: 1 })}>Về máy</a></td>
224
  <td>
225
  <div className="flex gap8 center" style={{ flexWrap: "nowrap", whiteSpace: "nowrap" }}>
226
  <div className="toolchips" style={{ flexWrap: "nowrap" }}>
frontend/src/pages/DubVideo.jsx CHANGED
@@ -5,6 +5,8 @@ import { useToolState } from "../store";
5
  import { useAuth } from "../stores/authStore";
6
  import { ProcessRail, FileDrop, Toggle, ProcessingOverlay, CacheNote } from "../components/ui";
7
  import { IcMic, IcDownload, IcPlay, IcDoc, IcText } from "../components/Icons";
 
 
8
  import SubtitleEditor from "../components/SubtitleEditor";
9
 
10
  const STEPS = [
@@ -179,6 +181,7 @@ export default function DubVideo() {
179
  }
180
  setUploading(null);
181
  set((cur) => ({ session: sid, videos: [...cur.videos, ...added] }));
 
182
  };
183
  const removeVideo = (i) => set((cur) => ({ videos: cur.videos.filter((_, j) => j !== i) }));
184
 
@@ -197,6 +200,7 @@ export default function DubVideo() {
197
  const doPrepare = async () => {
198
  if (!st.videos.length) return;
199
  setBusy(true); set({ prepared: [], outputs: [] });
 
200
  try {
201
  const { job_id } = await api.dubPrepare({
202
  session: st.session, files: st.videos.map((v) => v.file),
@@ -208,6 +212,7 @@ export default function DubVideo() {
208
  (done.items || []).filter((i) => i.status === "done").map((i) => i.result);
209
  // cùng ngôn ngữ -> mặc định GIỮ NGUYÊN (không đọc lại), user bỏ tick nếu muốn đọc lại
210
  set({ prepared: (outs || []).map((p) => ({ ...p, keep: !!p.same_language })) });
 
211
  } catch (e) { alert("Lỗi: " + e.message); }
212
  finally { setBusy(false); }
213
  };
@@ -281,6 +286,7 @@ export default function DubVideo() {
281
  }));
282
  if (!editable.length && !kept.length) return;
283
  setBusy(true);
 
284
  try {
285
  let synthOuts = [];
286
  if (editable.length) {
@@ -322,6 +328,7 @@ export default function DubVideo() {
322
  if (busy || ttsBusy) return;
323
  if (!st.videos.length && !st.prepared.length && !st.outputs.length) return;
324
  if (!window.confirm("Xoá video & kết quả hiện tại để làm video khác?")) return;
 
325
  set({ videos: [], prepared: [], outputs: [], job: null, outStamp: 0 });
326
  };
327
 
@@ -627,6 +634,9 @@ export default function DubVideo() {
627
  + Làm video mới
628
  </button>
629
  </div>
 
 
 
630
  <div className="stack">
631
  {st.outputs.map((o) => {
632
  const isSameLang = o.note && o.note.includes("giữ nguyên");
@@ -662,9 +672,12 @@ export default function DubVideo() {
662
  </div>
663
  </div>
664
  <div className="flex gap8 mt12" style={{ flexWrap: "wrap" }}>
665
- <a className="btn sm" href={fileUrl(o.video_url)} download><IcDownload size={14} /> Video MP4</a>
666
- <a className="btn sm" href={fileUrl(o.audio_url)} download><IcDownload size={14} /> Audio</a>
667
- <a className="btn sm" href={fileUrl(o.srt_url)} download><IcDoc size={14} /> Subtitle SRT</a>
 
 
 
668
  {!isSameLang && (
669
  <button className="btn sm primary" onClick={() => {
670
  const rel = (u) => (u || "").replace(`/api/files/${st.session}/`, "");
 
5
  import { useAuth } from "../stores/authStore";
6
  import { ProcessRail, FileDrop, Toggle, ProcessingOverlay, CacheNote } from "../components/ui";
7
  import { IcMic, IcDownload, IcPlay, IcDoc, IcText } from "../components/Icons";
8
+ import track from "../lib/track";
9
+ import ThumbsFeedback from "../components/ThumbsFeedback";
10
  import SubtitleEditor from "../components/SubtitleEditor";
11
 
12
  const STEPS = [
 
181
  }
182
  setUploading(null);
183
  set((cur) => ({ session: sid, videos: [...cur.videos, ...added] }));
184
+ if (added.length) track.step("upload_done", "prepare", { count: added.length });
185
  };
186
  const removeVideo = (i) => set((cur) => ({ videos: cur.videos.filter((_, j) => j !== i) }));
187
 
 
200
  const doPrepare = async () => {
201
  if (!st.videos.length) return;
202
  setBusy(true); set({ prepared: [], outputs: [] });
203
+ track.runClick({ phase: "asr_translate", videos: st.videos.length, target: st.target });
204
  try {
205
  const { job_id } = await api.dubPrepare({
206
  session: st.session, files: st.videos.map((v) => v.file),
 
212
  (done.items || []).filter((i) => i.status === "done").map((i) => i.result);
213
  // cùng ngôn ngữ -> mặc định GIỮ NGUYÊN (không đọc lại), user bỏ tick nếu muốn đọc lại
214
  set({ prepared: (outs || []).map((p) => ({ ...p, keep: !!p.same_language })) });
215
+ track.step("translation_shown", "review", { videos: (outs || []).length });
216
  } catch (e) { alert("Lỗi: " + e.message); }
217
  finally { setBusy(false); }
218
  };
 
286
  }));
287
  if (!editable.length && !kept.length) return;
288
  setBusy(true);
289
+ track.runClick({ phase: "tts", videos: editable.length, kept: kept.length });
290
  try {
291
  let synthOuts = [];
292
  if (editable.length) {
 
328
  if (busy || ttsBusy) return;
329
  if (!st.videos.length && !st.prepared.length && !st.outputs.length) return;
330
  if (!window.confirm("Xoá video & kết quả hiện tại để làm video khác?")) return;
331
+ track.restart({ reason: "start_new" });
332
  set({ videos: [], prepared: [], outputs: [], job: null, outStamp: 0 });
333
  };
334
 
 
634
  + Làm video mới
635
  </button>
636
  </div>
637
+ <div style={{ marginBottom: 12 }}>
638
+ <ThumbsFeedback label="Bản lồng tiếng nghe có ổn không?" />
639
+ </div>
640
  <div className="stack">
641
  {st.outputs.map((o) => {
642
  const isSameLang = o.note && o.note.includes("giữ nguyên");
 
672
  </div>
673
  </div>
674
  <div className="flex gap8 mt12" style={{ flexWrap: "wrap" }}>
675
+ <a className="btn sm" href={fileUrl(o.video_url)} download
676
+ onClick={() => track.output("mp4", { count: 1 })}><IcDownload size={14} /> Video MP4</a>
677
+ <a className="btn sm" href={fileUrl(o.audio_url)} download
678
+ onClick={() => track.output("audio", { count: 1 })}><IcDownload size={14} /> Audio</a>
679
+ <a className="btn sm" href={fileUrl(o.srt_url)} download
680
+ onClick={() => track.output("srt", { count: 1 })}><IcDoc size={14} /> Subtitle SRT</a>
681
  {!isSameLang && (
682
  <button className="btn sm primary" onClick={() => {
683
  const rel = (u) => (u || "").replace(`/api/files/${st.session}/`, "");
frontend/src/pages/Flux2Gen.jsx CHANGED
@@ -1,5 +1,7 @@
1
  import { useState, useCallback, useRef } from "react";
2
  import { IcBolt, IcDownload as IcDl, IcImage, IcSliders } from "../components/Icons";
 
 
3
 
4
  const RESOLUTIONS = [
5
  { label: "1024×1024 (Square)", value: "1024x1024" },
@@ -250,6 +252,10 @@ export default function Flux2Gen() {
250
  setImages([]);
251
  setError(null);
252
  startTimeRef.current = Date.now();
 
 
 
 
253
 
254
  try {
255
  const res = await fetch("/api/flux2/generate", {
@@ -280,6 +286,7 @@ export default function Flux2Gen() {
280
  }, [prompt, negativePrompt, resolution, steps, guidance, seed, batch, pollStatus]);
281
 
282
  const downloadAll = useCallback(() => {
 
283
  images.forEach((img, i) => {
284
  if (img.url) {
285
  const a = document.createElement("a");
@@ -490,6 +497,10 @@ export default function Flux2Gen() {
490
  </button>
491
  </div>
492
 
 
 
 
 
493
  <div style={styles.gallery}>
494
  {images.map((img, i) =>
495
  img.error ? (
@@ -516,6 +527,7 @@ export default function Flux2Gen() {
516
  <button
517
  style={styles.downloadBtn}
518
  onClick={() => {
 
519
  const a = document.createElement("a");
520
  a.href = img.url;
521
  a.download = `flux2_${i + 1}.png`;
 
1
  import { useState, useCallback, useRef } from "react";
2
  import { IcBolt, IcDownload as IcDl, IcImage, IcSliders } from "../components/Icons";
3
+ import track from "../lib/track";
4
+ import ThumbsFeedback from "../components/ThumbsFeedback";
5
 
6
  const RESOLUTIONS = [
7
  { label: "1024×1024 (Square)", value: "1024x1024" },
 
252
  setImages([]);
253
  setError(null);
254
  startTimeRef.current = Date.now();
255
+ // Mỗi lần bấm Generate = một lần "hỏi máy" (A4). Thời gian máy chạy do
256
+ // backend tự ghi, ở đây chỉ ghi thao tác của người.
257
+ track.step("edit_prompt", "prepare", { chars: prompt.trim().length });
258
+ track.runClick({ phase: "generate", batch, resolution, steps });
259
 
260
  try {
261
  const res = await fetch("/api/flux2/generate", {
 
286
  }, [prompt, negativePrompt, resolution, steps, guidance, seed, batch, pollStatus]);
287
 
288
  const downloadAll = useCallback(() => {
289
+ track.output("png", { count: images.filter((im) => im.url).length });
290
  images.forEach((img, i) => {
291
  if (img.url) {
292
  const a = document.createElement("a");
 
497
  </button>
498
  </div>
499
 
500
+ <div style={{ marginBottom: 14 }}>
501
+ <ThumbsFeedback label="Ảnh sinh ra có đúng ý không?" />
502
+ </div>
503
+
504
  <div style={styles.gallery}>
505
  {images.map((img, i) =>
506
  img.error ? (
 
527
  <button
528
  style={styles.downloadBtn}
529
  onClick={() => {
530
+ track.output("png", { count: 1 });
531
  const a = document.createElement("a");
532
  a.href = img.url;
533
  a.download = `flux2_${i + 1}.png`;
frontend/src/pages/ListingImage.jsx CHANGED
@@ -3,6 +3,8 @@ import { ProcessRail, CacheNote } from "../components/ui";
3
  import { useDirty } from "../stores/dirtyStore";
4
  import { useAuth } from "../stores/authStore";
5
  import { IcImage, IcUpload, IcDownload, IcRefresh } from "../components/Icons";
 
 
6
 
7
  const STEPS = [
8
  { t: "Tải ảnh", d: "Ảnh sản phẩm là bắt buộc — chụp chính diện, đủ sáng, rõ nhãn. Thêm ảnh người mẫu / bối cảnh nếu muốn AI dùng đúng gương mặt và khung cảnh." },
@@ -122,6 +124,7 @@ export default function ListingImage() {
122
 
123
  const handleFile = (slot, f) => {
124
  if (!f || !f.type.startsWith("image/")) return;
 
125
  if (previews[slot]) URL.revokeObjectURL(previews[slot]);
126
  setFiles(prev => ({ ...prev, [slot]: f }));
127
  setPreviews(prev => ({ ...prev, [slot]: URL.createObjectURL(f) }));
@@ -151,6 +154,11 @@ export default function ListingImage() {
151
  addLog("info", "🔍 Đang phân tích ảnh với GPT-4o...");
152
 
153
  const ak = _apiKey();
 
 
 
 
 
154
  const fd = new FormData();
155
  fd.append("file", files.product);
156
  if (files.person) fd.append("person_image", files.person);
@@ -251,6 +259,7 @@ export default function ListingImage() {
251
  const startEdit = async (type) => {
252
  if (!sessionId || editBusy) return;
253
  setEditBusy(true);
 
254
  addLog("info", `✏️ Đang edit ảnh "${type}" (giữ nguyên mẫu mã)...`);
255
  try {
256
  const fd = new FormData();
@@ -285,6 +294,7 @@ export default function ListingImage() {
285
  if (!sessionId || regeneratingSingle) return;
286
  const savedResults = [...results];
287
  setRegeneratingSingle(true);
 
288
  addLog("info", `🔄 Đang tạo lại ảnh "${viewingPromptType}"...`);
289
  const promptObj = prompts.find(p => p.type === viewingPromptType);
290
  const customPrompt = { ...promptObj, prompt: editedPromptText };
@@ -327,11 +337,13 @@ export default function ListingImage() {
327
  const a = document.createElement("a");
328
  a.href = url; a.download = `listing_images_${sessionId.slice(0, 8)}.zip`;
329
  a.click(); URL.revokeObjectURL(url);
 
330
  } catch (e) { addLog("error", "❌ " + e.message); }
331
  };
332
 
333
  const resetAll = () => {
334
  makeClean();
 
335
  if (pollRef.current) clearTimeout(pollRef.current);
336
  if (sessionId) fetch(`/api/listing/session/${sessionId}`, { method: "DELETE" }).catch(() => {});
337
  setSessionId(null); setPrompts([]); setResults([]); setLogs([]);
@@ -562,6 +574,11 @@ export default function ListingImage() {
562
  </div>
563
  </div>
564
  )}
 
 
 
 
 
565
  {!generating && !regeneratingSingle && (
566
  <div style={{ display: "flex", gap: 8, marginTop: 14 }}>
567
  <button className="btn primary" onClick={downloadAll}><IcDownload size={16} /> Tải tất cả về</button>
 
3
  import { useDirty } from "../stores/dirtyStore";
4
  import { useAuth } from "../stores/authStore";
5
  import { IcImage, IcUpload, IcDownload, IcRefresh } from "../components/Icons";
6
+ import track from "../lib/track";
7
+ import ThumbsFeedback from "../components/ThumbsFeedback";
8
 
9
  const STEPS = [
10
  { t: "Tải ảnh", d: "Ảnh sản phẩm là bắt buộc — chụp chính diện, đủ sáng, rõ nhãn. Thêm ảnh người mẫu / bối cảnh nếu muốn AI dùng đúng gương mặt và khung cảnh." },
 
124
 
125
  const handleFile = (slot, f) => {
126
  if (!f || !f.type.startsWith("image/")) return;
127
+ track.step("upload_done", "prepare", { slot });
128
  if (previews[slot]) URL.revokeObjectURL(previews[slot]);
129
  setFiles(prev => ({ ...prev, [slot]: f }));
130
  setPreviews(prev => ({ ...prev, [slot]: URL.createObjectURL(f) }));
 
154
  addLog("info", "🔍 Đang phân tích ảnh với GPT-4o...");
155
 
156
  const ak = _apiKey();
157
+ // Bước nhập API key là chỗ tắc riêng của tool này (spec §4.6). Ghi cờ
158
+ // has_key để đếm được "bao nhiêu task chạy khi chưa có key riêng" — KHÔNG
159
+ // ghi bỏ dở ở đây vì thiếu key vẫn chạy được bằng key chung của máy chủ.
160
+ track.step("api_key_set", "prepare", { has_key: !!ak });
161
+ track.runClick({ phase: "analyze_generate", quantity, types: selectedTypes.length });
162
  const fd = new FormData();
163
  fd.append("file", files.product);
164
  if (files.person) fd.append("person_image", files.person);
 
259
  const startEdit = async (type) => {
260
  if (!sessionId || editBusy) return;
261
  setEditBusy(true);
262
+ track.retry("edit_request", { type, chars: editRequest.length });
263
  addLog("info", `✏️ Đang edit ảnh "${type}" (giữ nguyên mẫu mã)...`);
264
  try {
265
  const fd = new FormData();
 
294
  if (!sessionId || regeneratingSingle) return;
295
  const savedResults = [...results];
296
  setRegeneratingSingle(true);
297
+ track.retry("regenerate", { type: viewingPromptType });
298
  addLog("info", `🔄 Đang tạo lại ảnh "${viewingPromptType}"...`);
299
  const promptObj = prompts.find(p => p.type === viewingPromptType);
300
  const customPrompt = { ...promptObj, prompt: editedPromptText };
 
337
  const a = document.createElement("a");
338
  a.href = url; a.download = `listing_images_${sessionId.slice(0, 8)}.zip`;
339
  a.click(); URL.revokeObjectURL(url);
340
+ track.output("zip", { count: results.length });
341
  } catch (e) { addLog("error", "❌ " + e.message); }
342
  };
343
 
344
  const resetAll = () => {
345
  makeClean();
346
+ track.restart({ reason: "reset" });
347
  if (pollRef.current) clearTimeout(pollRef.current);
348
  if (sessionId) fetch(`/api/listing/session/${sessionId}`, { method: "DELETE" }).catch(() => {});
349
  setSessionId(null); setPrompts([]); setResults([]); setLogs([]);
 
574
  </div>
575
  </div>
576
  )}
577
+ {!generating && !regeneratingSingle && (
578
+ <div style={{ marginTop: 14 }}>
579
+ <ThumbsFeedback label="Bộ ảnh listing này có dùng được không?" />
580
+ </div>
581
+ )}
582
  {!generating && !regeneratingSingle && (
583
  <div style={{ display: "flex", gap: 8, marginTop: 14 }}>
584
  <button className="btn primary" onClick={downloadAll}><IcDownload size={16} /> Tải tất cả về</button>
frontend/src/pages/ShuffleVideo.jsx CHANGED
@@ -6,6 +6,8 @@ import { useAuth } from "../stores/authStore";
6
  import { ProcessRail, FileDrop, ProcessingOverlay, CacheNote } from "../components/ui";
7
  import { IcShuffle, IcDownload, IcCheck, IcMusic } from "../components/Icons";
8
  import ComboTable from "../components/ComboTable";
 
 
9
 
10
  const STEPS = [
11
  { t: "Nhập số khúc", d: "Khai báo video gồm bao nhiêu khúc theo kịch bản." },
@@ -167,6 +169,8 @@ export default function ShuffleVideo() {
167
  n[idx].files.push(...ok);
168
  return { session: sid, segs: n };
169
  });
 
 
170
  };
171
 
172
  const addAudio = async (files) => {
@@ -178,6 +182,7 @@ export default function ShuffleVideo() {
178
  const { ok } = await uploadMany(
179
  arr, (file, sid, onP) => api.shuffleUploadAudioP(file, sid, onP), "audio");
180
  set((cur) => ({ audio: [...cur.audio, ...ok] }));
 
181
  };
182
 
183
  const removeClip = (si, fi) => set((cur) => {
@@ -216,6 +221,7 @@ export default function ShuffleVideo() {
216
  try {
217
  const r = await api.shufflePlan(payload());
218
  set({ plan: r, maxRender: r.max_render || 10000 });
 
219
  if (r.capped) alert(
220
  `Tổng ${Number(r.raw_total).toLocaleString()} tổ hợp — hiển thị MẪU ĐA DẠNG `
221
  + `${Number(r.count).toLocaleString()} tổ hợp (trần ${Number(r.plan_cap).toLocaleString()}).`);
@@ -235,6 +241,7 @@ export default function ShuffleVideo() {
235
  sequences = sequences.slice(0, MAX_RENDER);
236
  }
237
  setBusy(true); set({ results: [] });
 
238
  try {
239
  const { job_id } = await api.shuffleGenerate({ ...payload(), sequences });
240
  const done = await pollJob(job_id, setJob, 1000, pollAbort.current.signal);
@@ -248,6 +255,7 @@ export default function ShuffleVideo() {
248
  };
249
 
250
  const downloadAll = () => {
 
251
  st.results.forEach((r, i) => setTimeout(() => {
252
  const a = document.createElement("a");
253
  a.href = fileUrl(r.url); a.download = r.name;
@@ -412,9 +420,11 @@ export default function ShuffleVideo() {
412
  <div className="panel-title" style={{ margin: 0 }}>Xem trước kết quả ({st.results.length} biến thể)</div>
413
  <div className="flex gap8">
414
  <button className="btn sm" onClick={downloadAll}><IcDownload size={15} /> Tải tất cả theo thứ tự</button>
415
- <a className="btn sm" href={fileUrl(`/api/files/${st.session}/variations.zip`)} download><IcDownload size={15} /> Tải ZIP</a>
 
416
  </div>
417
  </div>
 
418
  <div className="results mt12">
419
  {st.results.map((r) => {
420
  const bust = st.resStamp ? `?v=${st.resStamp}` : "";
 
6
  import { ProcessRail, FileDrop, ProcessingOverlay, CacheNote } from "../components/ui";
7
  import { IcShuffle, IcDownload, IcCheck, IcMusic } from "../components/Icons";
8
  import ComboTable from "../components/ComboTable";
9
+ import track from "../lib/track";
10
+ import ThumbsFeedback from "../components/ThumbsFeedback";
11
 
12
  const STEPS = [
13
  { t: "Nhập số khúc", d: "Khai báo video gồm bao nhiêu khúc theo kịch bản." },
 
169
  n[idx].files.push(...ok);
170
  return { session: sid, segs: n };
171
  });
172
+ // Thả clip vào từng khúc = chỗ nghẽn của Xào (spec §4.3) -> ghi từng lần.
173
+ track.step("upload_done", "prepare", { seg: idx, count: ok.length });
174
  };
175
 
176
  const addAudio = async (files) => {
 
182
  const { ok } = await uploadMany(
183
  arr, (file, sid, onP) => api.shuffleUploadAudioP(file, sid, onP), "audio");
184
  set((cur) => ({ audio: [...cur.audio, ...ok] }));
185
+ track.step("upload_done", "prepare", { seg: "audio", count: ok.length });
186
  };
187
 
188
  const removeClip = (si, fi) => set((cur) => {
 
221
  try {
222
  const r = await api.shufflePlan(payload());
223
  set({ plan: r, maxRender: r.max_render || 10000 });
224
+ track.step("plan_shown", "review", { count: r.count, raw_total: r.raw_total });
225
  if (r.capped) alert(
226
  `Tổng ${Number(r.raw_total).toLocaleString()} tổ hợp — hiển thị MẪU ĐA DẠNG `
227
  + `${Number(r.count).toLocaleString()} tổ hợp (trần ${Number(r.plan_cap).toLocaleString()}).`);
 
241
  sequences = sequences.slice(0, MAX_RENDER);
242
  }
243
  setBusy(true); set({ results: [] });
244
+ track.runClick({ phase: "render", variants: sequences ? sequences.length : null, segs: segs.length });
245
  try {
246
  const { job_id } = await api.shuffleGenerate({ ...payload(), sequences });
247
  const done = await pollJob(job_id, setJob, 1000, pollAbort.current.signal);
 
255
  };
256
 
257
  const downloadAll = () => {
258
+ track.output("mp4", { count: st.results.length });
259
  st.results.forEach((r, i) => setTimeout(() => {
260
  const a = document.createElement("a");
261
  a.href = fileUrl(r.url); a.download = r.name;
 
420
  <div className="panel-title" style={{ margin: 0 }}>Xem trước kết quả ({st.results.length} biến thể)</div>
421
  <div className="flex gap8">
422
  <button className="btn sm" onClick={downloadAll}><IcDownload size={15} /> Tải tất cả theo thứ tự</button>
423
+ <a className="btn sm" href={fileUrl(`/api/files/${st.session}/variations.zip`)} download
424
+ onClick={() => track.output("zip", { count: st.results.length })}><IcDownload size={15} /> Tải ZIP</a>
425
  </div>
426
  </div>
427
+ <div className="mt12"><ThumbsFeedback label="Các biến thể xào ra có dùng được không?" /></div>
428
  <div className="results mt12">
429
  {st.results.map((r) => {
430
  const bust = st.resStamp ? `?v=${st.resStamp}` : "";
frontend/src/pages/TextVideo.jsx CHANGED
@@ -5,6 +5,8 @@ import { useAuth } from "../stores/authStore";
5
  import { ProcessRail, FileDrop, ProcessingOverlay, CacheNote } from "../components/ui";
6
  import { IcText, IcDownload, IcVideo } from "../components/Icons";
7
  import SubtitleEditor from "../components/SubtitleEditor";
 
 
8
 
9
  const STEPS = [
10
  { t: "Tải video", d: "Tải 1 hoặc nhiều video cần chèn chữ." },
@@ -61,6 +63,7 @@ export default function TextVideo() {
61
  // nếu chưa mở video nào -> mở video đầu tiên vừa thêm
62
  selIdx: cur.selIdx < 0 || !cur.videos.length ? cur.videos.length : cur.selIdx,
63
  }));
 
64
  };
65
 
66
  const removeVideo = (i) => set((cur) => {
@@ -125,12 +128,14 @@ export default function TextVideo() {
125
  setMsg(count > 0
126
  ? `✅ Đã áp KIỂU chữ cho ${count} video (giữ lời riêng từng video).`
127
  : "Không có video nào khác đang có chữ để áp kiểu.");
 
128
  };
129
 
130
  // Nhận dạng lời gốc trong video đang mở (ASR) -> trả lines cho editor đổ vào.
131
  const fetchLines = async () => {
132
  if (!selV) return [];
133
  setBusy(true);
 
134
  try {
135
  const { job_id } = await api.dubTranscribe({ session: st.session, video: selV.file });
136
  const done = await pollJob(job_id, setJob, 1000, pollAbort.current.signal);
@@ -150,6 +155,7 @@ export default function TextVideo() {
150
  return;
151
  }
152
  setBusy(true); set({ outputs: [] });
 
153
  const outs = [];
154
  try {
155
  for (const v of targets) {
@@ -164,6 +170,7 @@ export default function TextVideo() {
164
  if (out) outs.push({ ...out, srcName: v.name, srcUrl: v.url });
165
  }
166
  set({ outputs: outs, outStamp: Date.now() });
 
167
  if (!outs.length) alert("Không tạo được video nào (xem log).");
168
  } catch (e) { alert("Lỗi: " + e.message); }
169
  finally { setBusy(false); }
@@ -173,6 +180,7 @@ export default function TextVideo() {
173
  if (busy) return;
174
  if (!st.videos.length && !st.outputs.length) return;
175
  if (!window.confirm("Xoá video & kết quả hiện tại để làm mẻ khác?")) return;
 
176
  set({ videos: [], forms: {}, outputs: [], selIdx: -1, job: null, outStamp: 0 });
177
  };
178
 
@@ -292,6 +300,9 @@ export default function TextVideo() {
292
  + Làm mẻ mới
293
  </button>
294
  </div>
 
 
 
295
  <div className="stack">
296
  {st.outputs.map((o) => {
297
  const bust = st.outStamp ? `?v=${st.outStamp}` : "";
@@ -314,7 +325,8 @@ export default function TextVideo() {
314
  </div>
315
  </div>
316
  <div className="flex gap8 mt12" style={{ flexWrap: "wrap" }}>
317
- <a className="btn sm" href={fileUrl(o.video_url)} download><IcDownload size={14} /> Tải video MP4</a>
 
318
  </div>
319
  </div>
320
  );
 
5
  import { ProcessRail, FileDrop, ProcessingOverlay, CacheNote } from "../components/ui";
6
  import { IcText, IcDownload, IcVideo } from "../components/Icons";
7
  import SubtitleEditor from "../components/SubtitleEditor";
8
+ import track from "../lib/track";
9
+ import ThumbsFeedback from "../components/ThumbsFeedback";
10
 
11
  const STEPS = [
12
  { t: "Tải video", d: "Tải 1 hoặc nhiều video cần chèn chữ." },
 
63
  // nếu chưa mở video nào -> mở video đầu tiên vừa thêm
64
  selIdx: cur.selIdx < 0 || !cur.videos.length ? cur.videos.length : cur.selIdx,
65
  }));
66
+ if (added.length) track.step("upload_done", "prepare", { count: added.length });
67
  };
68
 
69
  const removeVideo = (i) => set((cur) => {
 
128
  setMsg(count > 0
129
  ? `✅ Đã áp KIỂU chữ cho ${count} video (giữ lời riêng từng video).`
130
  : "Không có video nào khác đang có chữ để áp kiểu.");
131
+ if (count > 0) track.step("apply_form_all", "prepare", { count });
132
  };
133
 
134
  // Nhận dạng lời gốc trong video đang mở (ASR) -> trả lines cho editor đổ vào.
135
  const fetchLines = async () => {
136
  if (!selV) return [];
137
  setBusy(true);
138
+ track.runClick({ phase: "asr" });
139
  try {
140
  const { job_id } = await api.dubTranscribe({ session: st.session, video: selV.file });
141
  const done = await pollJob(job_id, setJob, 1000, pollAbort.current.signal);
 
155
  return;
156
  }
157
  setBusy(true); set({ outputs: [] });
158
+ track.runClick({ phase: "burn", videos: targets.length });
159
  const outs = [];
160
  try {
161
  for (const v of targets) {
 
170
  if (out) outs.push({ ...out, srcName: v.name, srcUrl: v.url });
171
  }
172
  set({ outputs: outs, outStamp: Date.now() });
173
+ track.step("burn_batch_done", "review", { videos_in: targets.length, videos_out: outs.length });
174
  if (!outs.length) alert("Không tạo được video nào (xem log).");
175
  } catch (e) { alert("Lỗi: " + e.message); }
176
  finally { setBusy(false); }
 
180
  if (busy) return;
181
  if (!st.videos.length && !st.outputs.length) return;
182
  if (!window.confirm("Xoá video & kết quả hiện tại để làm mẻ khác?")) return;
183
+ track.restart({ reason: "start_new" });
184
  set({ videos: [], forms: {}, outputs: [], selIdx: -1, job: null, outStamp: 0 });
185
  };
186
 
 
300
  + Làm mẻ mới
301
  </button>
302
  </div>
303
+ <div style={{ marginBottom: 10 }}>
304
+ <ThumbsFeedback label="Chữ chèn vào video có đúng ý không?" />
305
+ </div>
306
  <div className="stack">
307
  {st.outputs.map((o) => {
308
  const bust = st.outStamp ? `?v=${st.outStamp}` : "";
 
325
  </div>
326
  </div>
327
  <div className="flex gap8 mt12" style={{ flexWrap: "wrap" }}>
328
+ <a className="btn sm" href={fileUrl(o.video_url)} download
329
+ onClick={() => track.output("mp4", { count: 1 })}><IcDownload size={14} /> Tải video MP4</a>
330
  </div>
331
  </div>
332
  );