Commit 2fe68ad3 authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

feat: add n8n ultra description API + product web desc generator

parent 37a3151d
"""
n8n Ultra Description API — Endpoint rieng cho n8n goi
POST /api/product-desc/n8n-generate
Flow:
n8n gui: ten SP, chat lieu, tinh nang, link anh (URL)
API tu fetch anh -> base64 -> Vision (Llama 4 Scout) -> Writer (GPT-OSS 120B)
Tra ve: AI Description hoan chinh
n8n KHONG can gui base64 — chi can gui image_url (Link anh tu Sheets).
"""
import asyncio
import base64
import json
import logging
import re
import time
from typing import Optional
import httpx
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/product-desc", tags=["n8n Ultra Description"])
# ── Groq config (reuse tu agent) ──
GROQ_API_KEYS = [
"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",
"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",
"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",
"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",
"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",
"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",
]
_groq_idx = 0
VISION_MODEL = "meta-llama/llama-4-scout-17b-16e-instruct"
WRITER_MODEL = "openai/gpt-oss-120b"
def _next_key():
global _groq_idx
key = GROQ_API_KEYS[_groq_idx % len(GROQ_API_KEYS)]
_groq_idx += 1
return key
# ── Helpers ──
async def _fetch_image_base64(url: str) -> str:
"""Fetch anh tu URL -> base64. n8n KHONG can gui base64."""
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as c:
r = await c.get(url, headers={"User-Agent": "Mozilla/5.0"})
r.raise_for_status()
return base64.b64encode(r.content).decode()
def _extract_json(raw) -> dict | None:
if isinstance(raw, dict):
return raw
if not isinstance(raw, str):
raw = str(raw)
if "```" in raw:
m = re.search(r'```(?:json)?\s*([\s\S]*?)```', raw)
if m:
try:
return json.loads(m.group(1).strip())
except Exception:
pass
m = re.search(r'\{[\s\S]*\}', raw)
if m:
try:
return json.loads(m.group(0))
except Exception:
pass
try:
return json.loads(raw.strip())
except Exception:
return None
async def _groq_call(messages: list, model: str, max_tokens: int = 2000,
temperature: float = 0.7, is_vision: bool = False) -> str:
"""Round-robin Groq call voi fallback chain."""
fallbacks = [model]
if not is_vision:
if model != "openai/gpt-oss-120b":
fallbacks.append("openai/gpt-oss-120b")
if model != "openai/gpt-oss-20b":
fallbacks.append("openai/gpt-oss-20b")
for m in fallbacks:
for _ in range(len(GROQ_API_KEYS)):
key = _next_key()
try:
async with httpx.AsyncClient(timeout=45) as c:
resp = await c.post(
"https://api.groq.com/openai/v1/chat/completions",
headers={"Authorization": f"Bearer {key}",
"Content-Type": "application/json"},
json={"model": m, "max_tokens": max_tokens,
"temperature": temperature, "messages": messages},
)
if resp.status_code == 200:
data = resp.json()
if "error" not in data:
return data["choices"][0]["message"]["content"]
elif resp.status_code == 429:
continue # rotate key
else:
break # loi khac -> thu model tiep
except Exception as e:
logger.warning(f"Groq error ({m}): {e}")
break
await asyncio.sleep(2)
raise RuntimeError("All Groq models/keys exhausted")
# ═══════════════════════════════════════════════════════════════
# Request Schema — match voi data n8n gui tu Google Sheets
# ═══════════════════════════════════════════════════════════════
class N8nDescRequest(BaseModel):
"""
n8n chi can gui cac field nay (lay thang tu Google Sheets).
Chi co ten_san_pham va image_url la bat buoc.
"""
# Bat buoc
ten_san_pham: str
image_url: str # Link anh tu Sheets — API tu fetch
# Tu Sheets (optional)
ma_san_pham: Optional[str] = None
thanh_phan: Optional[str] = None # "100% polyamide"
dac_tinh_chat_lieu: Optional[str] = None # "Nylon (Polyamide) nhanh kho..."
tinh_nang: Optional[str] = None
kieu_dang: Optional[str] = None # Form
mo_ta_chung: Optional[str] = None
hoan_canh: Optional[str] = None
featuring: Optional[str] = None
chung_chi: Optional[str] = None
huong_dan_su_dung: Optional[str] = None
CANIFA_CATEGORIES = "Blazer, Bộ mặc nhà, Bộ quần áo, Bộ thể thao, Cardigan, Chân váy, Chăn cá nhân, Găng tay chống nắng, Khăn, Khăn mặt, Khăn tắm, Khẩu trang, Mũ, Mũ thể thao, Pyjama, Quần Body, Quần Khaki, Quần culottes, Quần dài, Quần giữ nhiệt, Quần jean, Quần leggings, Quần leggings mặc nhà, Quần lót, Quần lót tam giác, Quần lót đùi, Quần mặc nhà, Quần nỉ, Quần soóc, Quần váy, Túi xách, Tất, Váy liền, Áo Body, Áo Polo, Áo Sơ mi, Áo ba lỗ, Áo giữ nhiệt, Áo hai dây, Áo khoác, Áo khoác chần bông, Áo khoác chống nắng, Áo khoác dáng ngắn, Áo khoác dạ, Áo khoác gilet chần bông, Áo khoác gió, Áo khoác lông vũ, Áo khoác nỉ có mũ, Áo khoác nỉ không mũ, Áo khoác sợi, Áo kiểu, Áo len, Áo len gilet, Áo lót, Áo mặc nhà, Áo nỉ, Áo nỉ có mũ, Áo phông"
# ═══════════════════════════════════════════════════════════════
# Phase 1 — Vision: Llama 4 Scout nhin anh
# ═══════════════════════════════════════════════════════════════
def _build_vision_prompt(req: N8nDescRequest) -> str:
extra = []
if req.dac_tinh_chat_lieu:
extra.append(f"- Chất liệu (đã biết, KHÔNG đoán lại): {req.dac_tinh_chat_lieu}")
if req.thanh_phan:
extra.append(f"- Thành phần: {req.thanh_phan}")
if req.kieu_dang:
extra.append(f"- Form dáng: {req.kieu_dang}")
if req.tinh_nang:
extra.append(f"- Tính năng vải: {req.tinh_nang}")
if req.hoan_canh:
extra.append(f"- Dịp mặc mong muốn: {req.hoan_canh}")
if req.featuring:
extra.append(f"- Điểm nhấn thiết kế: {req.featuring}")
extra_block = "\n".join(extra) if extra else "Không có thông tin bổ sung."
return f"""Bạn là chuyên gia phân tích sản phẩm thời trang Canifa.
Nhìn ảnh sản phẩm và kết hợp thông tin đã biết để trả về JSON sau:
THÔNG TIN ĐÃ BIẾT:
- Tên SP: {req.ten_san_pham}
{extra_block}
DANH SÁCH DANH MỤC CANIFA HỢP LỆ:
{CANIFA_CATEGORIES}
QUAN TRỌNG:
- Nếu đã có chất liệu -> GIỮ NGUYÊN, không đoán lại
- Chỉ nhận xét những gì THẤY ĐƯỢC trong ảnh
- Phân loại sản phẩm này vào ĐÚNG 1 DANH MỤC trong danh sách trên.
- Trả về JSON (không text thừa):
{{
"mau_sac": "Màu sắc chính nhìn thấy trong ảnh",
"danh_muc": "Lấy 1 tên danh mục chuẩn từ danh sách trên",
"form_dang": "Form dáng (Slim/Regular/Oversize/...)",
"dac_trung_thiet_ke": "2-3 đặc trưng thiết kế nổi bật",
"chat_lieu_nhan_dien": "Chất liệu (dùng thông tin đã biết nếu có)",
"phong_cach": "Phong cách tổng thể (1-3 từ khóa)",
"doi_tuong": "Đối tượng phù hợp",
"dip_su_dung": "2-3 dịp sử dụng phù hợp"
}}"""
# ═══════════════════════════════════════════════════════════════
# Phase 2 — Writer: GPT-OSS 120B viet Ultra Description
# ═══════════════════════════════════════════════════════════════
def _build_writer_prompt(req: N8nDescRequest, vision_data: dict) -> str:
# Uu tien: chat lieu tu Sheets > vision > fallback
final_material = (
req.dac_tinh_chat_lieu
or req.thanh_phan
or vision_data.get("chat_lieu_nhan_dien", "")
or "Theo thông tin nhà sản xuất"
)
context_parts = [
f"Tên SP: {req.ten_san_pham}",
f"Chất liệu: {final_material}",
f"Thành phần: {req.thanh_phan or 'Không rõ'}",
]
if vision_data.get("danh_muc"):
context_parts.append(f"Danh mục phân loại: {vision_data['danh_muc']}")
if req.kieu_dang:
context_parts.append(f"Form dáng: {req.kieu_dang}")
elif vision_data.get("form_dang"):
context_parts.append(f"Form dáng (từ ảnh): {vision_data['form_dang']}")
if vision_data.get("mau_sac"):
context_parts.append(f"Màu sắc: {vision_data['mau_sac']}")
if vision_data.get("dac_trung_thiet_ke"):
context_parts.append(f"Đặc trưng thiết kế: {vision_data['dac_trung_thiet_ke']}")
if req.tinh_nang:
context_parts.append(f"Tính năng: {req.tinh_nang}")
if req.hoan_canh:
context_parts.append(f"Dịp mặc: {req.hoan_canh}")
elif vision_data.get("dip_su_dung"):
context_parts.append(f"Dịp mặc (từ ảnh): {vision_data['dip_su_dung']}")
if vision_data.get("phong_cach"):
context_parts.append(f"Phong cách: {vision_data['phong_cach']}")
if req.featuring:
context_parts.append(f"Featuring: {req.featuring}")
if req.mo_ta_chung:
context_parts.append(f"Mô tả gốc: {req.mo_ta_chung}")
context_block = "\n".join(f"- {p}" for p in context_parts)
return f"""Bạn là AI Stylist cao cấp và Chuyên gia phân tích sản phẩm của Canifa.
Nhiệm vụ của bạn là viết một bài đánh giá và mô tả sản phẩm thật CHI TIẾT, CHUYÊN SÂU và PHONG PHÚ nhất có thể (Ultra Deep Description).
Bài viết này sẽ được dùng làm "raw data" cho các hệ thống khác tổng hợp lại, nên hãy viết càng dài, càng nhiều thông tin, càng nhiều context càng tốt.
THÔNG TIN SẢN PHẨM:
{context_block}
DANH SÁCH DANH MỤC CANIFA HỢP LỆ:
{CANIFA_CATEGORIES}
QUY TẮC VIẾT:
1. Giọng văn: Chuyên nghiệp, phân tích chuyên sâu như một stylist và kỹ sư dệt may, nhưng vẫn truyền cảm hứng.
2. Độ dài: CỰC KỲ DÀI (khoảng 1000 - 2000 từ). Không giới hạn sự sáng tạo, viết thành rất nhiều đoạn văn hoặc các phần rõ ràng để tạo thành một bài viết cực kỳ dài, chi tiết đến từng centimet.
3. BẮT BUỘC phải đào sâu vào các khía cạnh sau với độ chi tiết cực độ:
- Nhận diện Form & Kiểu dáng: Sản phẩm là dáng áo/quần/váy gì. Sử dụng ĐÚNG tên từ DANH SÁCH DANH MỤC CANIFA phía trên. Cổ áo/tay áo/gấu quần được xử lý như thế nào? Chi tiết cắt may nào giúp tôn dáng hoặc che khuyết điểm?
- Phân tích Chất liệu cực sâu: Bề mặt vải nhìn dưới ánh sáng sẽ thế nào (lì, bóng nhẹ, thô, mềm...)? Khi sờ vào hay mặc lên da cảm giác ra sao? Ứng dụng thực tế của chất liệu đó trong điều kiện thời tiết khác nhau (thấm hút mồ hôi, giữ ấm, chống UV...).
- Hoàn cảnh sử dụng (Lifestyle): Vẽ ra ít nhất 3-4 kịch bản chi tiết. (Ví dụ: "Khi bạn ở phòng gym vào buổi sáng...", "Một chiều dạo phố cuối tuần...").
- Gợi ý Mix & Match (Outfit): Đề xuất 3-4 outfits cực kỳ chi tiết từ đầu đến chân (áo, quần/váy, giày, phụ kiện, túi xách). ƯU TIÊN gợi ý các sản phẩm thuộc DANH SÁCH DANH MỤC CANIFA. Giải thích nguyên lý thị giác TẠI SAO outfit đó lại ăn nhập với nhau.
- Vibe & Thần thái: Toát lên phong cách gì? Định vị cá nhân của người mặc là ai? (Năng động, quý phái, bụi bặm...).
4. KHÔNG bịa chất liệu. KHÔNG đề cập giá cả.
5. XÁC ĐỊNH ĐÚNG LOẠI SẢN PHẨM: TUYỆT ĐỐI phải xác định sản phẩm thuộc 1 trong các danh mục Canifa. TUYỆT ĐỐI KHÔNG gọi sai tên loại sản phẩm.
6. Sử dụng tiếng Việt tự nhiên, từ vựng phong phú, đặc tả tốt.
VÍ DỤ MINH HỌA CÁCH VIẾT (Để hiểu độ sâu và chi tiết cần có):
- Hoàn cảnh sử dụng: "Trong những buổi sáng cuối tuần thảnh thơi dạo bước trên phố, chiếc Áo ba lỗ này là lựa chọn hoàn hảo để giải phóng cơ thể khỏi sự gò bó của trang phục công sở. Hoặc trong các buổi tập gym cường độ cao, chất liệu thấm hút nhanh sẽ hỗ trợ tối đa..."
- Mix & Match: "Outfit 1 (Năng động ngày hè): Kết hợp chiếc Áo ba lỗ này cùng một chiếc Quần soóc khaki. Form đứng của Quần soóc sẽ bù trừ hoàn hảo cho độ mềm mại của áo, tạo nên tỷ lệ cơ thể vững chãi nam tính. Thêm một đôi sneaker trắng và Mũ thể thao Canifa để hoàn thiện diện mạo..."
Trả về JSON:
{{
"ai_description": "Bài viết mô tả cực kỳ chi tiết và chuyên sâu (1000-2000 từ)"
}}"""
# ═══════════════════════════════════════════════════════════════
# Endpoint
# ═══════════════════════════════════════════════════════════════
@router.post("/n8n-generate",
summary="n8n Ultra Description — Sinh AI Description tu anh + metadata")
async def n8n_generate_description(req: N8nDescRequest):
"""
Endpoint rieng cho n8n workflow:
1. Nhan metadata tu Google Sheets (ten, chat lieu, link anh...)
2. Fetch anh tu URL -> base64 (n8n KHONG can gui base64)
3. Vision (Llama 4 Scout) -> nhin anh, extract thong tin
4. Writer (GPT-OSS 120B) -> viet Ultra Description
5. Tra ve AI Description hoan chinh
n8n HTTP Request node chi can POST JSON voi cac field tu Sheets.
"""
t0 = time.time()
ma_sp = req.ma_san_pham or "UNKNOWN"
try:
# ── Phase 1: Vision ──
logger.info(f"[n8n-desc] {ma_sp} — Phase 1: Vision ({VISION_MODEL})")
vision_data = {}
try:
img_b64 = await _fetch_image_base64(req.image_url)
logger.info(f"[n8n-desc] {ma_sp} — Image fetched: {len(img_b64)} chars base64")
vision_messages = [
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{img_b64}"},
},
{"type": "text", "text": _build_vision_prompt(req)},
],
}
]
vision_raw = await _groq_call(
vision_messages, VISION_MODEL,
max_tokens=800, is_vision=True
)
vision_data = _extract_json(vision_raw) or {}
logger.info(f"[n8n-desc] {ma_sp} — Vision done: {list(vision_data.keys())}")
except Exception as vision_err:
# Vision loi -> van chay tiep voi data rong
logger.warning(f"[n8n-desc] {ma_sp} — Vision failed: {vision_err}")
# ── Phase 2: Writer ──
logger.info(f"[n8n-desc] {ma_sp} — Phase 2: Writer ({WRITER_MODEL})")
writer_messages = [
{
"role": "system",
"content": (
"Bạn là AI Stylist cao cấp Canifa. Viết mô tả sản phẩm thời trang "
"bằng tiếng Việt. Phong cách: tự tin, gợi hình, không bịa chất liệu. "
"Luôn trả về JSON chuẩn, không text thừa."
),
},
{"role": "user", "content": _build_writer_prompt(req, vision_data)},
]
writer_raw = await _groq_call(
writer_messages, WRITER_MODEL,
max_tokens=4000
)
result = _extract_json(writer_raw)
if not result or not result.get("ai_description"):
# Fallback: neu JSON fail, lay raw text lam description
ai_desc = writer_raw.strip() if writer_raw else ""
if not ai_desc:
return JSONResponse(
status_code=500,
content={
"status": "error",
"ma_san_pham": ma_sp,
"message": "AI writer khong tra ve ket qua hop le.",
"raw": writer_raw[:500] if writer_raw else None,
},
)
result = {"ai_description": ai_desc}
elapsed = round(time.time() - t0, 2)
logger.info(f"[n8n-desc] {ma_sp} — Done in {elapsed}s")
return {
"status": "success",
"ma_san_pham": ma_sp,
"ten_san_pham": req.ten_san_pham,
"ai_description": result["ai_description"],
"elapsed_s": elapsed,
# Debug (n8n co the bo qua)
"vision_data": vision_data,
}
except Exception as e:
logger.exception(f"[n8n-desc] {ma_sp} — Unhandled error: {e}")
return JSONResponse(
status_code=500,
content={
"status": "error",
"ma_san_pham": ma_sp,
"message": str(e),
},
)
"""
generate-web-desc endpoint
POST /api/product-desc/generate-web-desc
Pipeline:
Phase 1 — Vision (Llama 4 Scout): nhìn ảnh → extract thông tin thô
Phase 2 — Writer (GPT-OSS 120B) : viết mô tả marketing chuẩn cho web
→ Trả về JSON gồm: mo_ta_web, mo_ta_ngan, dac_diem_noi_bat, huong_dan_bao_quan
"""
import logging
from typing import Optional
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel
# Re-use helpers đã có sẵn trong file gốc
# from .ultra_desc_route import _call_ai_with_fallback, _fetch_image_base64, _extract_json, VISION_MODEL, ENRICH_MODEL
logger = logging.getLogger(__name__)
# ─── Nếu paste riêng file này thì dùng block dưới ──────────────────────────
# (Nếu merge vào file gốc thì xoá phần này đi vì đã có rồi)
import asyncio, re, json, httpx, base64
GROQ_API_KEYS = [
"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",
"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",
"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",
"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",
"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",
"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",
]
_groq_idx = 0
VISION_MODEL = "meta-llama/llama-4-scout-17b-16e-instruct"
ENRICH_MODEL = "openai/gpt-oss-120b"
def _next_key():
global _groq_idx
key = GROQ_API_KEYS[_groq_idx % len(GROQ_API_KEYS)]
_groq_idx += 1
return key
async def _fetch_image_base64(url: str) -> str:
async with httpx.AsyncClient(timeout=15) as c:
r = await c.get(url, headers={"User-Agent": "Mozilla/5.0"})
r.raise_for_status()
return base64.b64encode(r.content).decode()
def _extract_json(raw) -> dict | None:
if isinstance(raw, dict):
return raw
if not isinstance(raw, str):
raw = str(raw)
if "```" in raw:
m = re.search(r'```(?:json)?\s*([\s\S]*?)```', raw)
if m:
try:
return json.loads(m.group(1).strip())
except Exception:
pass
m = re.search(r'\{[\s\S]*\}', raw)
if m:
try:
return json.loads(m.group(0))
except Exception:
pass
try:
return json.loads(raw.strip())
except Exception:
return None
async def _groq_call(messages: list, model: str, max_tokens: int = 2000) -> str:
"""Round-robin Groq call, thử hết key trước khi đổi model."""
fallbacks = [model]
if model != "openai/gpt-oss-120b":
fallbacks.append("openai/gpt-oss-120b")
if model != "openai/gpt-oss-20b":
fallbacks.append("openai/gpt-oss-20b")
while True:
for m in fallbacks:
for _ in range(len(GROQ_API_KEYS)):
key = _next_key()
try:
async with httpx.AsyncClient(timeout=30) as c:
resp = await c.post(
"https://api.groq.com/openai/v1/chat/completions",
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
json={"model": m, "max_tokens": max_tokens, "temperature": 0.7, "messages": messages},
)
if resp.status_code == 200:
data = resp.json()
if "error" not in data:
return data["choices"][0]["message"]["content"]
elif resp.status_code == 429:
continue # rotate key ngay
else:
break # lỗi khác → thử model tiếp
except Exception as e:
logger.warning(f"Groq error ({m}): {e}")
break
logger.warning("All keys/models exhausted → wait 5s")
await asyncio.sleep(5)
# ─── Hết phần re-use ────────────────────────────────────────────────────────
# ─── Router ─────────────────────────────────────────────────────────────────
router = APIRouter(prefix="/api/product-desc", tags=["Ultra Description"])
# ─── Request Schema ──────────────────────────────────────────────────────────
class WebDescRequest(BaseModel):
# Bắt buộc
ma_san_pham: str
ten_san_pham: str
image_url: str
# Nên có
chat_lieu: Optional[str] = None
# Optional — từ Sheets
mo_ta_chung: Optional[str] = None
thanh_phan: Optional[str] = None
form: Optional[str] = None
hoan_canh: Optional[str] = None
dong_san_pham: Optional[str] = None
gioi_tinh: Optional[str] = None
featuring: Optional[str] = None
tinh_nang: Optional[str] = None
chung_chi: Optional[str] = None
huong_dan_su_dung: Optional[str] = None
# ─── Phase 1 Prompt — Vision ─────────────────────────────────────────────────
def _build_vision_prompt(req: WebDescRequest) -> str:
"""Llama 4 Scout nhìn ảnh, extract thông tin thô làm nền cho writer."""
extra = []
if req.chat_lieu:
extra.append(f"- Chất liệu (đã biết, KHÔNG đoán lại): {req.chat_lieu}")
if req.form:
extra.append(f"- Form dáng: {req.form}")
if req.tinh_nang:
extra.append(f"- Tính năng vải: {req.tinh_nang}")
if req.hoan_canh:
extra.append(f"- Dịp mặc mong muốn: {req.hoan_canh}")
if req.featuring:
extra.append(f"- Điểm nhấn thiết kế: {req.featuring}")
if req.chung_chi:
extra.append(f"- Chứng chỉ: {req.chung_chi}")
if req.gioi_tinh:
extra.append(f"- Giới tính: {req.gioi_tinh}")
extra_block = "\n".join(extra) if extra else "Không có thông tin bổ sung."
return f"""Bạn là chuyên gia phân tích sản phẩm thời trang Canifa.
Nhìn ảnh sản phẩm và kết hợp thông tin đã biết để trả về JSON sau:
THÔNG TIN ĐÃ BIẾT:
- Tên SP: {req.ten_san_pham}
{extra_block}
QUAN TRỌNG:
- Nếu đã có chất liệu → DÙNG NGUYÊN, không đoán lại
- Nếu không có chất liệu → ghi "Theo thông tin nhà sản xuất"
- Chỉ nhận xét những gì THẤY ĐƯỢC trong ảnh
Trả về JSON (không text thừa):
{{
"mau_sac_chinh": "Màu sắc chính nhìn thấy trong ảnh",
"form_dang": "Form dáng (Slim/Regular/Oversize/...)",
"dac_trung_thiet_ke": "2-3 đặc trưng thiết kế nổi bật",
"chat_lieu_vision": "Chất liệu (dùng thông tin đã biết nếu có)",
"tinh_nang_vai": "Tính năng vải nổi bật",
"phu_hop_dip": "2-3 dịp mặc phù hợp nhìn từ ảnh",
"phong_cach": "Phong cách tổng thể (1-3 từ khóa)",
"doi_tuong": "Đối tượng phù hợp",
"diem_manh": "Điểm mạnh nổi bật nhất của sản phẩm"
}}"""
# ─── Phase 2 Prompt — Writer ─────────────────────────────────────────────────
def _build_writer_prompt(req: WebDescRequest, vision_data: dict) -> str:
"""GPT-OSS 120B viết mô tả marketing hoàn chỉnh cho web."""
# Ưu tiên: chat_lieu từ Sheets > vision > fallback
final_material = (
req.chat_lieu
or vision_data.get("chat_lieu_vision", "")
or "Theo thông tin nhà sản xuất"
)
# Tổng hợp context đầy đủ
context_parts = [
f"Tên SP: {req.ten_san_pham}",
f"Chất liệu: {final_material}",
f"Form dáng: {req.form or vision_data.get('form_dang', 'Không xác định')}",
f"Màu sắc: {vision_data.get('mau_sac_chinh', '')}",
f"Đặc trưng thiết kế: {vision_data.get('dac_trung_thiet_ke', '')}",
f"Tính năng vải: {req.tinh_nang or vision_data.get('tinh_nang_vai', '')}",
f"Dịp mặc: {req.hoan_canh or vision_data.get('phu_hop_dip', '')}",
f"Phong cách: {vision_data.get('phong_cach', '')}",
f"Đối tượng: {vision_data.get('doi_tuong', '')}",
]
if req.featuring:
context_parts.append(f"Featuring: {req.featuring}")
if req.chung_chi:
context_parts.append(f"Chứng chỉ: {req.chung_chi}")
if req.mo_ta_chung:
context_parts.append(f"Mô tả gốc (cần enrich): {req.mo_ta_chung}")
if req.huong_dan_su_dung:
context_parts.append(f"Hướng dẫn bảo quản: {req.huong_dan_su_dung}")
context_block = "\n".join(f"- {p}" for p in context_parts)
return f"""Bạn là Stylist cao cấp Canifa. Viết mô tả sản phẩm cho website bán hàng.
THÔNG TIN SẢN PHẨM:
{context_block}
QUY TẮC VIẾT:
- Giọng văn: tự tin, gợi hình, dùng từ như "hack dáng", "tôn dáng", "thoát dáng"
- KHÔNG bịa chất liệu nếu không chắc — dùng đúng thông tin đã có
- KHÔNG đề cập giá cả
- Viết cho KHÁCH HÀNG đọc (không phải AI)
- mo_ta_web: 3-5 câu, có story, có benefit rõ ràng
- mo_ta_ngan: 1 câu súc tích cho meta description / preview card
Trả về JSON (không text thừa):
{{
"mo_ta_web": "Mô tả đầy đủ 3-5 câu cho trang web",
"mo_ta_ngan": "1 câu ngắn cho preview/meta (~120 ký tự)",
"dac_diem_noi_bat": [
"Điểm nổi bật 1 (chất liệu / tính năng)",
"Điểm nổi bật 2 (form / thiết kế)",
"Điểm nổi bật 3 (dịp mặc / đối tượng)"
],
"huong_dan_bao_quan": "Hướng dẫn giặt và bảo quản cụ thể"
}}"""
# ─── Endpoint ────────────────────────────────────────────────────────────────
@router.post("/generate-web-desc", summary="Sinh mô tả web cho SP mới (chưa cần có trong DB)")
async def generate_web_desc(req: WebDescRequest):
"""
Pipeline 2 pha:
1. Vision → extract thông tin từ ảnh
2. Writer → viết mô tả marketing cho web
Không cần SP có trong StarRocks/DB.
"""
import time
t0 = time.time()
try:
# ── Phase 1: Vision ──────────────────────────────────────────────────
logger.info(f"[generate-web-desc] {req.ma_san_pham} — Phase 1: Vision")
try:
img_b64 = await _fetch_image_base64(req.image_url)
vision_messages = [
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{img_b64}"},
},
{"type": "text", "text": _build_vision_prompt(req)},
],
}
]
vision_raw = await _groq_call(vision_messages, VISION_MODEL, max_tokens=800)
vision_data = _extract_json(vision_raw) or {}
except Exception as vision_err:
# Vision lỗi (ảnh không load được...) → vẫn chạy tiếp với data rỗng
logger.warning(f"[generate-web-desc] Vision failed: {vision_err}. Continuing without vision data.")
vision_data = {}
logger.info(f"[generate-web-desc] {req.ma_san_pham} — Phase 1 done: {vision_data}")
# ── Phase 2: Writer ──────────────────────────────────────────────────
logger.info(f"[generate-web-desc] {req.ma_san_pham} — Phase 2: Writer")
writer_messages = [
{
"role": "system",
"content": (
"Bạn là Stylist cao cấp Canifa. Viết mô tả sản phẩm thời trang bằng tiếng Việt. "
"Phong cách: tự tin, gợi hình, không bịa chất liệu, không đề cập giá cả. "
"Luôn trả về JSON chuẩn, không text thừa."
),
},
{"role": "user", "content": _build_writer_prompt(req, vision_data)},
]
writer_raw = await _groq_call(writer_messages, ENRICH_MODEL, max_tokens=1200)
result = _extract_json(writer_raw)
if not result:
return JSONResponse(
status_code=500,
content={
"status": "error",
"ma_san_pham": req.ma_san_pham,
"message": "AI writer không trả về JSON hợp lệ.",
"raw": writer_raw[:500] if writer_raw else None,
},
)
# ── Đảm bảo output có đủ 4 trường cơ bản ───────────────────────────
result.setdefault("mo_ta_web", "")
result.setdefault("mo_ta_ngan", "")
result.setdefault("dac_diem_noi_bat", [])
result.setdefault("huong_dan_bao_quan", req.huong_dan_su_dung or "")
elapsed = round(time.time() - t0, 2)
logger.info(f"[generate-web-desc] {req.ma_san_pham} — Done in {elapsed}s")
return {
"status": "success",
"ma_san_pham": req.ma_san_pham,
"ten_san_pham": req.ten_san_pham,
# ── 4 trường output chính ──
"mo_ta_web": result["mo_ta_web"],
"mo_ta_ngan": result["mo_ta_ngan"],
"dac_diem_noi_bat": result["dac_diem_noi_bat"],
"huong_dan_bao_quan": result["huong_dan_bao_quan"],
# ── Debug info ──
"elapsed_s": elapsed,
"vision_data": vision_data, # optional: xoá dòng này nếu không cần debug
}
except Exception as e:
logger.exception(f"[generate-web-desc] Unhandled error: {e}")
return JSONResponse(
status_code=500,
content={"status": "error", "ma_san_pham": req.ma_san_pham, "message": str(e)},
)
"""
Test script: Kiểm tra fetch ảnh từ URL Canifa → base64
Mục đích: Chứng minh API tự lấy ảnh từ URL, n8n KHÔNG cần gửi base64.
Chạy: python -m backend.api.product_desc.test.test_fetch_image
hoặc: python backend/api/product_desc/test/test_fetch_image.py
"""
import httpx
import base64
import sys
# ─── Sample data từ Google Sheets ────────────────────────────────────────────
SAMPLE_IMAGE_URL = "http://mdp.canifa.com/images-web/cache/c8/00/c80079b26cd5437c1473d98930fc1a07.jpg"
def test_fetch_image():
print("=" * 60)
print("🧪 TEST: Fetch ảnh từ URL Canifa → Base64")
print("=" * 60)
print(f"\n📎 URL: {SAMPLE_IMAGE_URL}")
try:
# Fetch ảnh — giống hệt logic trong product_generate_desc_web.py
r = httpx.get(
SAMPLE_IMAGE_URL,
headers={"User-Agent": "Mozilla/5.0"},
timeout=15,
follow_redirects=True,
)
r.raise_for_status()
# Thông tin response
content_type = r.headers.get("content-type", "unknown")
size_bytes = len(r.content)
size_kb = round(size_bytes / 1024, 1)
print(f"\n✅ Fetch thành công!")
print(f" Status code : {r.status_code}")
print(f" Content-Type: {content_type}")
print(f" Size : {size_bytes:,} bytes ({size_kb} KB)")
# Convert base64
b64 = base64.b64encode(r.content).decode()
b64_len = len(b64)
b64_preview = b64[:80] + "..."
print(f"\n🔄 Base64 encode:")
print(f" Length : {b64_len:,} chars")
print(f" Preview : {b64_preview}")
# Validate — check JPEG magic bytes (FFD8FF)
is_jpeg = r.content[:3] == b'\xff\xd8\xff'
is_png = r.content[:4] == b'\x89PNG'
fmt = "JPEG" if is_jpeg else ("PNG" if is_png else "Unknown")
print(f"\n🖼️ Format: {fmt}")
# Simulate Groq API payload
data_url = f"data:{content_type};base64,{b64[:20]}..."
print(f"\n📦 Data URL cho AI Vision:")
print(f" {data_url}")
print(f"\n{'=' * 60}")
print(f"✅ KẾT LUẬN: n8n KHÔNG cần gửi base64!")
print(f" → Chỉ cần gửi 'image_url' (Link ảnh từ Sheets)")
print(f" → API tự fetch + convert base64 server-side")
print(f" → Rồi gửi cho AI Vision model (Llama 4 Scout)")
print(f"{'=' * 60}")
return True
except httpx.HTTPStatusError as e:
print(f"\n❌ HTTP Error: {e.response.status_code}")
print(f" URL có thể bị chặn hoặc không tồn tại")
return False
except httpx.ConnectError as e:
print(f"\n❌ Connection Error: {e}")
print(f" Không kết nối được tới mdp.canifa.com")
return False
except Exception as e:
print(f"\n❌ Error: {type(e).__name__}: {e}")
return False
if __name__ == "__main__":
ok = test_fetch_image()
sys.exit(0 if ok else 1)
"""
Test script: Goi API /api/product-desc/n8n-generate
Simulate n8n HTTP Request node gui data tu Google Sheets.
Chay: py backend/api/product_desc/test/test_n8n_desc.py
"""
import httpx
import json
import sys
import time
# Fix encoding for Windows
if sys.platform == "win32":
sys.stdout.reconfigure(encoding="utf-8")
API_URL = "http://127.0.0.1:5000/api/product-desc/n8n-generate"
# ── Sample data giong het n8n gui tu Google Sheets ──
SAMPLE_PAYLOAD = {
"ma_san_pham": "UNKNOWN",
"ten_san_pham": "Ao ba lo",
"image_url": "https://mdp.canifa.com/images-web/cache/e5/9b/e59b8c724757b142b3eb0d6ebf8d8f7f.jpg",
"thanh_phan": "",
"dac_tinh_chat_lieu": "",
"mo_ta_chung": "",
"tinh_nang": "",
"kieu_dang": "",
"hoan_canh": "",
"featuring": "",
"chung_chi": "",
"huong_dan_su_dung": "",
}
def main():
print("=" * 60)
print("TEST: n8n Ultra Description API")
print("=" * 60)
# Step 1: Check server is running
print("\n[1/3] Checking server...")
try:
r = httpx.get("http://127.0.0.1:5000/health", timeout=5)
if r.status_code == 200:
print(" Server OK")
else:
print(f" Server returned {r.status_code}")
sys.exit(1)
except httpx.ConnectError:
print(" Server NOT running at http://127.0.0.1:5000")
print(" -> Start server first: py backend/server.py")
sys.exit(1)
# Step 2: Send request
print(f"\n[2/3] POST {API_URL}")
print(f" Product: {SAMPLE_PAYLOAD['ten_san_pham']}")
print(f" Image URL: {SAMPLE_PAYLOAD['image_url'][:60]}...")
t0 = time.time()
try:
r = httpx.post(API_URL, json=SAMPLE_PAYLOAD, timeout=120)
elapsed = round(time.time() - t0, 2)
except Exception as e:
print(f" ERROR: {e}")
sys.exit(1)
# Step 3: Parse response
print(f"\n[3/3] Response (took {elapsed}s)")
print(f" Status: {r.status_code}")
try:
data = r.json()
except Exception:
print(f" Raw: {r.text[:500]}")
sys.exit(1)
if data.get("status") == "success":
print(f" Product: {data.get('ten_san_pham')}")
print(f" Server elapsed: {data.get('elapsed_s')}s")
ai_desc = data.get("ai_description", "")
print(f"\n{'=' * 60}")
print("AI DESCRIPTION:")
print("=" * 60)
print(ai_desc)
print(f"\n{'=' * 60}")
print(f"Length: {len(ai_desc)} chars")
# Vision debug
vd = data.get("vision_data", {})
if vd:
print(f"\nVision data: {json.dumps(vd, ensure_ascii=False, indent=2)}")
print(f"\nKET QUA: OK")
else:
print(f" ERROR: {data.get('message')}")
if data.get("raw"):
print(f" Raw: {data['raw'][:300]}")
sys.exit(1)
if __name__ == "__main__":
main()
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