Commit 41ee4c0a authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

Update product desc and chatbot integration

parent 90c34457
...@@ -185,7 +185,7 @@ _SECTIONS = [ ...@@ -185,7 +185,7 @@ _SECTIONS = [
def _render_description_text(data: dict, product_name: str = "") -> str: def _render_description_text(data: dict, product_name: str = "") -> str:
"""Render JSON description_data into a single formatted text block.""" """Render JSON description_data into a single formatted text block."""
if not data: if not data:
return "" data = {}
lines = [] lines = []
title = data.get("ten_san_pham", product_name or "Sản phẩm") title = data.get("ten_san_pham", product_name or "Sản phẩm")
...@@ -231,6 +231,10 @@ def _render_description_text(data: dict, product_name: str = "") -> str: ...@@ -231,6 +231,10 @@ def _render_description_text(data: dict, product_name: str = "") -> str:
lines.append(f"── {section_title} ──") lines.append(f"── {section_title} ──")
lines.extend(section_lines) lines.extend(section_lines)
lines.append("") lines.append("")
else:
lines.append(f"── {section_title} ──")
lines.append(" Chưa có dữ liệu.")
lines.append("")
return "\n".join(lines) return "\n".join(lines)
...@@ -376,6 +380,7 @@ async def overview(): ...@@ -376,6 +380,7 @@ async def overview():
# Saved descriptions from Postgres # Saved descriptions from Postgres
pg_stats = UltraDescriptionDB.get_stats() pg_stats = UltraDescriptionDB.get_stats()
has_desc = pg_stats.get("total", 0) has_desc = pg_stats.get("total", 0)
missing_clean_desc = UltraDescriptionDB.count_missing_clean_descriptions()
return { return {
"status": "success", "status": "success",
...@@ -384,6 +389,8 @@ async def overview(): ...@@ -384,6 +389,8 @@ async def overview():
"approved": pg_stats.get("approved", 0), "approved": pg_stats.get("approved", 0),
"pending": pg_stats.get("pending", 0), "pending": pg_stats.get("pending", 0),
"missing": total - has_desc, "missing": total - has_desc,
"has_clean_desc": pg_stats.get("has_clean_desc", 0),
"missing_clean_desc": missing_clean_desc,
"progress": round(has_desc / total * 100, 1) if total > 0 else 0, "progress": round(has_desc / total * 100, 1) if total > 0 else 0,
"db_stats": { "db_stats": {
"enriched": pg_stats.get("enriched", 0), "enriched": pg_stats.get("enriched", 0),
...@@ -717,7 +724,8 @@ async def get_saved_desc(internal_ref_code: str): ...@@ -717,7 +724,8 @@ async def get_saved_desc(internal_ref_code: str):
else: else:
result[k] = v result[k] = v
# Render formatted text from description_data # Prefer the persisted clean_description from DB.
# Fallback to generated text only when that column is empty.
desc_data = result.get("description_data", {}) desc_data = result.get("description_data", {})
if isinstance(desc_data, str): if isinstance(desc_data, str):
import json as _json import json as _json
...@@ -725,7 +733,10 @@ async def get_saved_desc(internal_ref_code: str): ...@@ -725,7 +733,10 @@ async def get_saved_desc(internal_ref_code: str):
desc_data = _json.loads(desc_data) desc_data = _json.loads(desc_data)
except Exception: except Exception:
desc_data = {} desc_data = {}
result["rendered_text"] = _render_description_text(desc_data, result.get("product_name", "")) result["rendered_text"] = result.get("clean_description") or _render_description_text(
desc_data,
result.get("product_name", ""),
)
return {"status": "success", "description": result} return {"status": "success", "description": result}
except Exception as e: except Exception as e:
...@@ -851,16 +862,19 @@ async def approve_desc(req: StatusRequest): ...@@ -851,16 +862,19 @@ async def approve_desc(req: StatusRequest):
try: try:
clean_desc = "" clean_desc = ""
if req.status == 1: if req.status == 1:
# Render clean_description khi duyệt # When approving, keep any manually edited clean_description already
# stored in DB. Only auto-render if it does not exist yet.
row = UltraDescriptionDB.get_by_code(req.internal_ref_code) row = UltraDescriptionDB.get_by_code(req.internal_ref_code)
if row: if row:
desc_data = row.get("description_data", {}) clean_desc = (row.get("clean_description") or "").strip()
if isinstance(desc_data, str): if not clean_desc:
try: desc_data = row.get("description_data", {})
desc_data = json.loads(desc_data) if isinstance(desc_data, str):
except Exception: try:
desc_data = {} desc_data = json.loads(desc_data)
clean_desc = _render_description_text(desc_data, row.get("product_name", "")) except Exception:
desc_data = {}
clean_desc = _render_description_text(desc_data, row.get("product_name", ""))
updated = UltraDescriptionDB.set_status(req.internal_ref_code, req.status, clean_description=clean_desc) updated = UltraDescriptionDB.set_status(req.internal_ref_code, req.status, clean_description=clean_desc)
if updated: if updated:
...@@ -885,6 +899,42 @@ async def approve_all_desc(): ...@@ -885,6 +899,42 @@ async def approve_all_desc():
logger.error(f"Approve all error: {e}") logger.error(f"Approve all error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)}) return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.get("/clean-missing-summary", summary="Count rows missing valid clean_description")
async def clean_missing_summary():
"""Return how many rows still need clean_description backfill."""
try:
missing_count = UltraDescriptionDB.count_missing_clean_descriptions()
stats = UltraDescriptionDB.get_stats()
return {
"status": "success",
"missing_clean_desc": missing_count,
"has_desc": stats.get("total", 0),
"has_clean_desc": stats.get("has_clean_desc", 0),
}
except Exception as e:
logger.error(f"Clean missing summary error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.post("/backfill-clean-description", summary="Backfill missing clean_description in bulk")
async def backfill_clean_description():
"""Render clean_description in bulk from existing description_data."""
try:
updated_count = UltraDescriptionDB.backfill_missing_clean_descriptions(
render_fn=_render_description_text
)
remaining = UltraDescriptionDB.count_missing_clean_descriptions()
return {
"status": "success",
"message": f"Đã render lại clean description cho {updated_count} sản phẩm.",
"updated_count": updated_count,
"remaining_missing_clean_desc": remaining,
}
except Exception as e:
logger.error(f"Backfill clean description error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
# ═══ 7. DELETE ═══ # ═══ 7. DELETE ═══
@router.delete("/saved/{internal_ref_code}", summary="Delete saved ultra description") @router.delete("/saved/{internal_ref_code}", summary="Delete saved ultra description")
async def delete_saved_desc(internal_ref_code: str): async def delete_saved_desc(internal_ref_code: str):
......
...@@ -193,6 +193,27 @@ class UltraDescriptionDB: ...@@ -193,6 +193,27 @@ class UltraDescriptionDB:
if conn: if conn:
conn.close() conn.close()
@staticmethod
def is_clean_description_ready(clean_description: Any) -> bool:
"""Check whether clean_description looks complete enough for downstream use."""
if not isinstance(clean_description, str):
return False
text = clean_description.strip()
if not text:
return False
if len(text) < 120:
return False
required_markers = [
"==================================================",
"── 📦 THÔNG TIN CƠ BẢN ──",
"── 🧵 CHẤT LIỆU & BẢO QUẢN ──",
"── 🎯 ĐỐI TƯỢNG ──",
"── 📅 DỊP MẶC & STYLING ──",
"── ❓ FAQ ──",
"── 🛒 HỖ TRỢ BÁN HÀNG ──",
]
return all(marker in text for marker in required_markers)
@classmethod @classmethod
def get_all_codes_with_status(cls) -> dict[str, int]: def get_all_codes_with_status(cls) -> dict[str, int]:
"""Get all codes with their status: {code: status}.""" """Get all codes with their status: {code: status}."""
...@@ -225,6 +246,7 @@ class UltraDescriptionDB: ...@@ -225,6 +246,7 @@ class UltraDescriptionDB:
COUNT(*) AS total, COUNT(*) AS total,
COUNT(CASE WHEN status = 1 THEN 1 END) AS approved, COUNT(CASE WHEN status = 1 THEN 1 END) AS approved,
COUNT(CASE WHEN status = 0 THEN 1 END) AS pending, COUNT(CASE WHEN status = 0 THEN 1 END) AS pending,
COUNT(CASE WHEN clean_description IS NOT NULL AND BTRIM(clean_description) != '' THEN 1 END) AS has_clean_desc,
COUNT(CASE WHEN phase = 'enriched' THEN 1 END) AS enriched, COUNT(CASE WHEN phase = 'enriched' THEN 1 END) AS enriched,
COUNT(CASE WHEN phase = 'raw' THEN 1 END) AS raw_only, COUNT(CASE WHEN phase = 'raw' THEN 1 END) AS raw_only,
MIN(created_at) AS first_created, MIN(created_at) AS first_created,
...@@ -239,10 +261,11 @@ class UltraDescriptionDB: ...@@ -239,10 +261,11 @@ class UltraDescriptionDB:
"total": row[0], "total": row[0],
"approved": row[1], "approved": row[1],
"pending": row[2], "pending": row[2],
"enriched": row[3], "has_clean_desc": row[3],
"raw_only": row[4], "enriched": row[4],
"first_created": row[5], "raw_only": row[5],
"last_updated": row[6], "first_created": row[6],
"last_updated": row[7],
} }
except Exception as e: except Exception as e:
logger.error("Error getting ultra desc stats: %s", e) logger.error("Error getting ultra desc stats: %s", e)
...@@ -334,18 +357,23 @@ class UltraDescriptionDB: ...@@ -334,18 +357,23 @@ class UltraDescriptionDB:
cur = conn.cursor() cur = conn.cursor()
if render_fn: if render_fn:
# Fetch pending rows, render clean_description, update individually # Fetch pending rows, preserve any existing manual clean_description,
cur.execute(f"SELECT internal_ref_code, product_name, description_data FROM {TABLE} WHERE status = 0") # otherwise render it from description_data.
cur.execute(
f"SELECT internal_ref_code, product_name, description_data, clean_description FROM {TABLE} WHERE status = 0"
)
rows = cur.fetchall() rows = cur.fetchall()
count = 0 count = 0
for code, name, desc_data in rows: for code, name, desc_data, clean_description in rows:
if isinstance(desc_data, str): clean = (clean_description or "").strip()
import json as _json if not clean:
try: if isinstance(desc_data, str):
desc_data = _json.loads(desc_data) import json as _json
except Exception: try:
desc_data = {} desc_data = _json.loads(desc_data)
clean = render_fn(desc_data, name or "") except Exception:
desc_data = {}
clean = render_fn(desc_data, name or "")
cur.execute( cur.execute(
f"UPDATE {TABLE} SET status = 1, clean_description = %s, updated_at = NOW() WHERE internal_ref_code = %s", f"UPDATE {TABLE} SET status = 1, clean_description = %s, updated_at = NOW() WHERE internal_ref_code = %s",
(clean, code), (clean, code),
...@@ -365,6 +393,66 @@ class UltraDescriptionDB: ...@@ -365,6 +393,66 @@ class UltraDescriptionDB:
if conn: if conn:
conn.close() conn.close()
@classmethod
def count_missing_clean_descriptions(cls) -> int:
"""Count rows where clean_description is empty or not formatted as expected."""
cls.ensure_table()
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
cur.execute(f"SELECT clean_description FROM {TABLE}")
rows = cur.fetchall()
cur.close()
return sum(0 if cls.is_clean_description_ready(row[0]) else 1 for row in rows)
except Exception as e:
logger.error("Error counting missing clean descriptions: %s", e)
return 0
finally:
if conn:
conn.close()
@classmethod
def backfill_missing_clean_descriptions(cls, render_fn) -> int:
"""Render and save clean_description for rows still missing/invalid."""
cls.ensure_table()
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
cur.execute(
f"SELECT internal_ref_code, product_name, description_data, clean_description FROM {TABLE}"
)
rows = cur.fetchall()
updated_count = 0
for code, name, desc_data, clean_description in rows:
if cls.is_clean_description_ready(clean_description):
continue
if isinstance(desc_data, str):
try:
desc_data = json.loads(desc_data)
except Exception:
desc_data = {}
if not isinstance(desc_data, dict) or not desc_data:
continue
rendered = render_fn(desc_data, name or "")
if not cls.is_clean_description_ready(rendered):
continue
cur.execute(
f"UPDATE {TABLE} SET clean_description = %s, updated_at = NOW() WHERE internal_ref_code = %s",
(rendered, code),
)
updated_count += 1
cur.close()
logger.info("✅ Backfilled clean_description for %d rows", updated_count)
return updated_count
except Exception as e:
logger.error("Error backfilling clean descriptions: %s", e)
return 0
finally:
if conn:
conn.close()
# Auto-init table on import # Auto-init table on import
try: try:
......
...@@ -17,7 +17,7 @@ body { margin:0; min-height:100vh; background:var(--background); font-family:var ...@@ -17,7 +17,7 @@ body { margin:0; min-height:100vh; background:var(--background); font-family:var
.page-hdr p { font-size:13px; color:var(--muted-fg); margin:2px 0 0; } .page-hdr p { font-size:13px; color:var(--muted-fg); margin:2px 0 0; }
/* Stats Cards */ /* Stats Cards */
.stats-row { display:grid; grid-template-columns:repeat(3,1fr) repeat(3,1fr); gap:12px; margin-bottom:20px; } .stats-row { display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin-bottom:20px; }
.stat-card { background:var(--card); border:1px solid var(--border); border-radius:10px; padding:16px 18px; } .stat-card { background:var(--card); border:1px solid var(--border); border-radius:10px; padding:16px 18px; }
.stat-card .label { font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.04em; color:var(--muted-fg); margin-bottom:6px; } .stat-card .label { font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.04em; color:var(--muted-fg); margin-bottom:6px; }
.stat-card .value { font-size:26px; font-weight:700; line-height:1.1; } .stat-card .value { font-size:26px; font-weight:700; line-height:1.1; }
...@@ -37,7 +37,7 @@ body { margin:0; min-height:100vh; background:var(--background); font-family:var ...@@ -37,7 +37,7 @@ body { margin:0; min-height:100vh; background:var(--background); font-family:var
} }
.filter-bar select:focus, .filter-bar input:focus { border-color:var(--primary); box-shadow:0 0 0 3px rgba(59,89,152,0.08); } .filter-bar select:focus, .filter-bar input:focus { border-color:var(--primary); box-shadow:0 0 0 3px rgba(59,89,152,0.08); }
.filter-bar .search-input { flex:1; min-width:180px; } .filter-bar .search-input { flex:1; min-width:180px; }
.filter-actions { display:flex; gap:6px; margin-left:auto; } .filter-actions { display:flex; gap:6px; margin-left:auto; flex-wrap:wrap; justify-content:flex-end; }
/* Product Table */ /* Product Table */
.product-table { width:100%; border-collapse:collapse; background:var(--card); border:1px solid var(--border); border-radius:10px; overflow:hidden; } .product-table { width:100%; border-collapse:collapse; background:var(--card); border:1px solid var(--border); border-radius:10px; overflow:hidden; }
...@@ -155,6 +155,16 @@ body { margin:0; min-height:100vh; background:var(--background); font-family:var ...@@ -155,6 +155,16 @@ body { margin:0; min-height:100vh; background:var(--background); font-family:var
<div class="value" id="statProgress"></div> <div class="value" id="statProgress"></div>
<div class="progress-bar-wrap"><div class="progress-bar-fill" id="progressFill" style="width:0%"></div></div> <div class="progress-bar-wrap"><div class="progress-bar-fill" id="progressFill" style="width:0%"></div></div>
</div> </div>
<div class="stat-card">
<div class="label">Thiếu Clean Desc</div>
<div class="value warn" id="statMissingClean"></div>
<div class="sub" id="statMissingCleanSub">Cần render lại theo format chuẩn</div>
</div>
<div class="stat-card">
<div class="label">Đã Có Clean Desc</div>
<div class="value success" id="statHasClean"></div>
<div class="sub">Đếm theo cột `clean_description` trong DB</div>
</div>
</div> </div>
<!-- Filters --> <!-- Filters -->
...@@ -175,6 +185,7 @@ body { margin:0; min-height:100vh; background:var(--background); font-family:var ...@@ -175,6 +185,7 @@ body { margin:0; min-height:100vh; background:var(--background); font-family:var
<button class="btn btn-outline btn-sm" onclick="resetFilters()">Reset</button> <button class="btn btn-outline btn-sm" onclick="resetFilters()">Reset</button>
<button class="btn btn-sm" id="btnApprovePage" style="background:#28a745;color:#fff;border:none;" onclick="batchApprovePage()">✅ Duyệt (Trang này)</button> <button class="btn btn-sm" id="btnApprovePage" style="background:#28a745;color:#fff;border:none;" onclick="batchApprovePage()">✅ Duyệt (Trang này)</button>
<button class="btn btn-sm" id="btnApproveAll" style="background:#0d6efd;color:#fff;border:none;font-weight:600;" onclick="approveAll()">✅ Duyệt TOÀN BỘ</button> <button class="btn btn-sm" id="btnApproveAll" style="background:#0d6efd;color:#fff;border:none;font-weight:600;" onclick="approveAll()">✅ Duyệt TOÀN BỘ</button>
<button class="btn btn-sm" id="btnBackfillClean" style="background:#f59e0b;color:#fff;border:none;font-weight:600;" onclick="backfillMissingCleanDescriptions()">🧹 Bù Clean Desc</button>
<button class="btn btn-sm" id="btnBatch" style="background:#6a0dad;color:#fff;border:none;" onclick="batchGeneratePage()">🤖 Sinh AI (Trang này)</button> <button class="btn btn-sm" id="btnBatch" style="background:#6a0dad;color:#fff;border:none;" onclick="batchGeneratePage()">🤖 Sinh AI (Trang này)</button>
<button class="btn btn-sm" id="btnBatchAll" style="background:#d63384;color:#fff;border:none;" onclick="batchGenerateAll()">🚀 Sinh AI (Toàn bộ thiếu)</button> <button class="btn btn-sm" id="btnBatchAll" style="background:#d63384;color:#fff;border:none;" onclick="batchGenerateAll()">🚀 Sinh AI (Toàn bộ thiếu)</button>
</div> </div>
...@@ -360,6 +371,9 @@ async function loadOverview() { ...@@ -360,6 +371,9 @@ async function loadOverview() {
document.getElementById('statApproved').textContent = (data.approved || 0).toLocaleString(); document.getElementById('statApproved').textContent = (data.approved || 0).toLocaleString();
document.getElementById('statPending').textContent = (data.pending || 0).toLocaleString(); document.getElementById('statPending').textContent = (data.pending || 0).toLocaleString();
document.getElementById('statMissing').textContent = data.missing.toLocaleString(); document.getElementById('statMissing').textContent = data.missing.toLocaleString();
document.getElementById('statMissingClean').textContent = (data.missing_clean_desc || 0).toLocaleString();
document.getElementById('statHasClean').textContent = (data.has_clean_desc || 0).toLocaleString();
document.getElementById('statMissingCleanSub').textContent = `Đã có ${((data.has_desc || 0) - (data.missing_clean_desc || 0)).toLocaleString()} / ${(data.has_desc || 0).toLocaleString()} bản có format sạch`;
const approveProgress = data.total > 0 ? ((data.approved || 0) / data.total * 100).toFixed(1) : 0; const approveProgress = data.total > 0 ? ((data.approved || 0) / data.total * 100).toFixed(1) : 0;
document.getElementById('statProgress').textContent = approveProgress + '%'; document.getElementById('statProgress').textContent = approveProgress + '%';
document.getElementById('progressFill').style.width = approveProgress + '%'; document.getElementById('progressFill').style.width = approveProgress + '%';
...@@ -579,6 +593,44 @@ async function approveAll() { ...@@ -579,6 +593,44 @@ async function approveAll() {
btn.disabled = false; btn.disabled = false;
} }
} }
async function backfillMissingCleanDescriptions() {
const summaryRes = await fetch(`${API}/clean-missing-summary`);
const summary = await summaryRes.json();
if (summary.status !== 'success') {
alert('Không lấy được thống kê clean description.');
return;
}
const missing = Number(summary.missing_clean_desc || 0);
if (missing <= 0) {
alert('Không có sản phẩm nào thiếu clean description chuẩn.');
return;
}
if (!confirm(`Phát hiện ${missing} sản phẩm đang thiếu hoặc lỗi format clean description.\n\nBấm OK để render lại hàng loạt theo format chuẩn từ description_data hiện có.`)) {
return;
}
const btn = document.getElementById('btnBackfillClean');
const oldText = btn.innerHTML;
btn.innerHTML = '<div class="spinner-sm"></div> Đang render...';
btn.disabled = true;
try {
const res = await fetch(`${API}/backfill-clean-description`, { method: 'POST' });
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message || 'Unknown error');
alert(`✅ ${data.message}\nCòn thiếu: ${data.remaining_missing_clean_desc}`);
loadOverview();
loadProducts();
} catch (e) {
alert('Lỗi backfill clean description: ' + e.message);
} finally {
btn.innerHTML = oldText;
btn.disabled = false;
}
}
async function batchGeneratePage() { async function batchGeneratePage() {
const missingCodes = currentProducts.filter(p => p.desc_status === -1).map(p => p.internal_ref_code); const missingCodes = currentProducts.filter(p => p.desc_status === -1).map(p => p.internal_ref_code);
if (missingCodes.length === 0) { if (missingCodes.length === 0) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment