Spaces:
Running
feat(analytics): tab "Quản lý thời lượng" theo dõi người dùng cho quản trị
Browse filesGhi 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 +198 -0
- backend/app/api/common.py +6 -2
- backend/app/api/cut.py +3 -4
- backend/app/api/download.py +3 -3
- backend/app/api/dub.py +7 -9
- backend/app/api/flux2.py +15 -2
- backend/app/api/listing.py +12 -2
- backend/app/api/shuffle.py +3 -3
- backend/app/config.py +26 -0
- backend/app/core/files.py +9 -0
- backend/app/core/jobs.py +42 -2
- backend/app/core/reqctx.py +66 -0
- backend/app/main.py +6 -1
- backend/app/services/analytics/__init__.py +0 -0
- backend/app/services/analytics/etl.py +472 -0
- backend/app/services/analytics/storage.py +382 -0
- backend/app/services/analytics/track.py +73 -0
- backend/tests/run_tests.py +16 -0
- backend/tests/test_analytics.py +237 -0
- backend/tests/test_cut.py +37 -20
- backend/tests/test_dub.py +90 -61
- frontend/src/api.js +13 -0
- frontend/src/components/Layout.jsx +15 -0
- frontend/src/components/ThumbsFeedback.jsx +64 -0
- frontend/src/components/admin/UsageCharts.jsx +168 -0
- frontend/src/components/admin/UsageDashboard.jsx +393 -0
- frontend/src/lib/track.js +330 -0
- frontend/src/pages/AdminUsers.jsx +20 -2
- frontend/src/pages/CreateVideo.jsx +7 -0
- frontend/src/pages/CutVideo.jsx +11 -0
- frontend/src/pages/DownList.jsx +16 -1
- frontend/src/pages/DubVideo.jsx +16 -3
- frontend/src/pages/Flux2Gen.jsx +12 -0
- frontend/src/pages/ListingImage.jsx +17 -0
- frontend/src/pages/ShuffleVideo.jsx +11 -1
- frontend/src/pages/TextVideo.jsx +13 -1
|
@@ -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}
|
|
@@ -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 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
@@ -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,
|
| 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":
|
| 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}/" +
|
| 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),
|
|
@@ -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 =
|
| 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(
|
| 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}
|
|
@@ -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,
|
| 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 =
|
| 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 =
|
| 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}/" +
|
| 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
|
| 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}/" +
|
| 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 =
|
| 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}
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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 |
-
|
|
|
|
|
|
|
| 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"):
|
|
@@ -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,
|
| 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 =
|
| 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 =
|
| 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),
|
|
@@ -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()]
|
|
@@ -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)")
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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)
|
|
@@ -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")
|
|
File without changes
|
|
@@ -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()))}
|
|
@@ -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
|
|
@@ -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()
|
|
@@ -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:
|
|
@@ -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]
|
|
@@ -73,16 +73,17 @@ def _contiguous(segs, total):
|
|
| 73 |
assert b.start_frame == a.end_frame
|
| 74 |
|
| 75 |
|
| 76 |
-
def
|
| 77 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 78 |
d = tempfile.mkdtemp()
|
| 79 |
v = _multi_scene_video(os.path.join(d, "in.mp4"))
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
assert
|
| 83 |
-
assert
|
| 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
|
| 117 |
-
"""
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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."""
|
|
@@ -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
|
| 58 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
from app.config import settings
|
| 60 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
finally:
|
| 68 |
-
|
| 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 ->
|
| 74 |
from app.config import settings
|
| 75 |
seg = translate.Segment(0.0, 1.5, "hello world")
|
| 76 |
-
|
|
|
|
| 77 |
orig_g, orig_q = settings.GOOGLE_API_KEY, settings.GROQ_API_KEY
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
settings.GOOGLE_API_KEY = settings.GROQ_API_KEY = ""
|
| 80 |
try:
|
| 81 |
out = translate.translate_line(seg, "vi")
|
| 82 |
finally:
|
| 83 |
-
translate.
|
|
|
|
| 84 |
settings.GOOGLE_API_KEY, settings.GROQ_API_KEY = orig_g, orig_q
|
| 85 |
assert out == "[vi]hello world"
|
| 86 |
-
assert translate.
|
| 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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")) == "
|
| 111 |
-
# chọn
|
| 112 |
assert tts.pick_engine(tts.VoiceConfig(target_lang="vi", engine="edge")) == "edge"
|
| 113 |
-
assert tts.pick_engine(tts.VoiceConfig(target_lang="en", engine="
|
| 114 |
|
|
|
|
|
|
|
| 115 |
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 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
|
| 147 |
-
"""Model
|
| 148 |
-
cfg = tts.VoiceConfig(target_lang="vi", engine="vieneu")
|
| 149 |
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
| 153 |
|
| 154 |
-
|
| 155 |
|
| 156 |
class FakeEdge(tts.TTSEngine):
|
| 157 |
def __init__(self):
|
| 158 |
-
|
| 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 |
-
|
| 167 |
-
|
|
|
|
|
|
|
| 168 |
tts.EdgeTTSEngine = FakeEdge
|
|
|
|
| 169 |
try:
|
| 170 |
-
eng = tts.
|
| 171 |
-
out = os.path.join(tempfile.mkdtemp(), "v.wav")
|
| 172 |
-
eng.synth("xin chào", out, cfg)
|
| 173 |
finally:
|
| 174 |
-
tts.
|
| 175 |
-
|
|
|
|
|
|
|
| 176 |
|
|
|
|
|
|
|
| 177 |
|
| 178 |
-
|
| 179 |
-
|
|
|
|
| 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",
|
| 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 "
|
| 198 |
-
assert "do not
|
| 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
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 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()
|
|
@@ -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);
|
|
@@ -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 |
};
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
};
|
|
@@ -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
|
| 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">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)" }}>
|
|
@@ -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);
|
|
@@ -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">
|
|
@@ -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}`}
|
|
|
|
| 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" }}>
|
|
@@ -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
|
| 666 |
-
|
| 667 |
-
<a className="btn sm" href={fileUrl(o.
|
|
|
|
|
|
|
|
|
|
| 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}/`, "");
|
|
@@ -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`;
|
|
@@ -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>
|
|
@@ -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
|
|
|
|
| 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}` : "";
|
|
@@ -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
|
|
|
|
| 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 |
);
|