Commit 3487d1a5 authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

feat: implement Event Daily, Autotrain sync via Langfuse API, and clean up codebase

parent 059a13d4
...@@ -114,6 +114,38 @@ def init_db() -> None: ...@@ -114,6 +114,38 @@ def init_db() -> None:
""" """
) )
cur.execute(
"""
CREATE TABLE IF NOT EXISTS event_daily_prompts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT UNIQUE NOT NULL,
event_name TEXT NOT NULL,
event_type TEXT NOT NULL,
event_date TEXT NOT NULL,
is_today BOOLEAN DEFAULT 0,
prompt TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
# Initialize mock data for event_daily_prompts if empty
cur.execute("SELECT COUNT(*) FROM event_daily_prompts")
if cur.fetchone()[0] == 0:
mock_events = [
('daily_default', 'Daily Standard', 'Daily', 'Mỗi ngày', 0, '- Tư vấn dựa trên yêu cầu cơ bản của khách hàng.\n- Tập trung vào tính tiện dụng và sự thoải mái.\n- Ưu tiên các sản phẩm mới nhất hoặc best seller nếu khách không có yêu cầu đặc biệt.'),
('womens_day', 'Quốc Tế Phụ Nữ 8/3', 'Campaign', '08/03/2026', 1, '- Ưu tiên gợi ý các sản phẩm váy đầm, đồ đôi, hoặc quà tặng cho phái nữ.\n- Gợi ý các combo quà tặng có giá trị cao.\n- Tone giọng: Nhẹ nhàng, tôn vinh phái đẹp, kèm lời chúc mừng 8/3.\n- Báo cho khách biết đang có chương trình giảm giá 30% cho đồ nữ.'),
('summer_sale', 'Đón Hè Sôi Động', 'Campaign', '01/05/2026 - 31/05/2026', 0, '- Tuyệt đối không gợi ý áo khoác dày, áo len.\n- Chỉ tập trung vào áo phông, đồ bơi, quần short, chất liệu thoáng mát (cotton, lanh).\n- Gợi ý đồ mặc đi biển, đi chơi ngày hè.'),
('black_friday', 'Black Friday Siêu Sale', 'Campaign', '27/11/2026', 0, '- NHẤN MẠNH vào giá sale cực sốc.\n- Up-sale mạnh tay, gợi ý mua theo combo để được giảm thêm.\n- Tạo cảm giác khan hiếm: \'Số lượng có hạn\', \'Sale lớn nhất năm\'.')
]
cur.executemany(
"""
INSERT INTO event_daily_prompts (event_id, event_name, event_type, event_date, is_today, prompt)
VALUES (?, ?, ?, ?, ?, ?)
""",
mock_events
)
conn.commit() conn.commit()
conn.close() conn.close()
logger.info("Auto-Train DB initialized at %s", DB_PATH) logger.info("Auto-Train DB initialized at %s", DB_PATH)
...@@ -397,6 +429,55 @@ async def list_feedbacks( ...@@ -397,6 +429,55 @@ async def list_feedbacks(
conn = get_db() conn = get_db()
cur = conn.cursor() cur = conn.cursor()
# --- Langfuse Sync ---
try:
from config import LANGFUSE_BASE_URL, LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY
import httpx
if LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY:
# Fetch recent scores from Langfuse
auth = (LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY)
# Only fetch scores where value is less than 1 (e.g. 0 for dislike)
# or just fetch recent scores
url = f"{LANGFUSE_BASE_URL.rstrip('/')}/api/public/scores"
params = {"page": 1, "limit": 20}
async with httpx.AsyncClient() as client:
res = await client.get(url, auth=auth, params=params, timeout=5.0)
if res.status_code == 200:
data = res.json()
scores = data.get("data", [])
for s in scores:
# Consider score < 1 as negative feedback to be trained
if s.get("value", 1) < 1:
trace_id = s.get("traceId", "")
comment = s.get("comment", "")
if not trace_id:
continue
# Check if already in DB
cur.execute("SELECT id FROM feedbacks WHERE feedback_id = ?", (trace_id,))
if not cur.fetchone():
# Insert new feedback from Langfuse
cur.execute(
"""
INSERT INTO feedbacks (feedback_id, user_id, query, ai_response, feedback_text, feedback_type, channel, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
trace_id,
"langfuse_sync",
"N/A (Synced from Langfuse)", # Requires trace fetch for actual query
"N/A",
comment if comment else "Dislike rating",
"bug",
"langfuse",
"PENDING"
),
)
conn.commit()
except Exception as e:
logger.warning(f"Failed to sync feedbacks from Langfuse: {e}")
# ---------------------
if status: if status:
cur.execute( cur.execute(
""" """
......
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import sqlite3
from pathlib import Path
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/event_daily", tags=["Event Daily"])
BASE_DIR = Path(__file__).resolve().parents[2]
DB_PATH = BASE_DIR / "database" / "autotrain.db"
def get_db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
class EventPromptUpdate(BaseModel):
prompt: str
@router.get("/events")
async def list_events():
conn = get_db()
cur = conn.cursor()
try:
cur.execute("SELECT * FROM event_daily_prompts")
rows = cur.fetchall()
except sqlite3.OperationalError:
rows = []
finally:
conn.close()
events = []
for row in rows:
events.append({
"id": row["event_id"],
"name": row["event_name"],
"type": row["event_type"],
"date": row["event_date"],
"isToday": bool(row["is_today"]),
"prompt": row["prompt"]
})
return events
@router.put("/events/{event_id}")
async def update_event_prompt(event_id: str, data: EventPromptUpdate):
conn = get_db()
cur = conn.cursor()
cur.execute("UPDATE event_daily_prompts SET prompt = ?, updated_at = CURRENT_TIMESTAMP WHERE event_id = ?", (data.prompt, event_id))
if cur.rowcount == 0:
conn.close()
raise HTTPException(status_code=404, detail="Event not found")
conn.commit()
conn.close()
return {"status": "success"}
...@@ -9,18 +9,10 @@ from pydantic import BaseModel ...@@ -9,18 +9,10 @@ from pydantic import BaseModel
from auth import create_access_token, hash_password, verify_password from auth import create_access_token, hash_password, verify_password
from auth.middleware import get_current_user from auth.middleware import get_current_user
from auth.models import DASHBOARD_SCHEMA, get_conn, init_dashboard_schema from auth.models import DASHBOARD_SCHEMA, get_conn
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
# Ensure schema exists
try:
init_dashboard_schema()
except Exception:
pass
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str username: str
password: str password: str
......
from fastapi import APIRouter from fastapi import APIRouter
from api.ai_report.report_html_route import router as report_html_router from api.ai_report.report_html_route import router as report_html_router
from api.api_sql.ai_sql_trace_route import router as ai_sql_trace_router from api.api_sql.ai_sql_trace_route import router as ai_sql_trace_router
...@@ -9,6 +9,7 @@ from api.api_sql.text_to_sql_route import router as text_to_sql_router ...@@ -9,6 +9,7 @@ from api.api_sql.text_to_sql_route import router as text_to_sql_router
from api.api_sql.user_insight_route import router as user_insight_router from api.api_sql.user_insight_route import router as user_insight_router
from api.autotrain.auto_pipeline_route import router as pipeline_router from api.autotrain.auto_pipeline_route import router as pipeline_router
from api.autotrain.autotrain_route import router as autotrain_router from api.autotrain.autotrain_route import router as autotrain_router
from api.autotrain.event_daily_route import router as event_daily_router
from api.cache.cache_route import router as cache_router from api.cache.cache_route import router as cache_router
from api.common.auth_route import router as auth_router from api.common.auth_route import router as auth_router
...@@ -115,6 +116,7 @@ api_router.include_router(ai_sql_trace_router) ...@@ -115,6 +116,7 @@ api_router.include_router(ai_sql_trace_router)
api_router.include_router(user_insight_router) api_router.include_router(user_insight_router)
api_router.include_router(autotrain_router) api_router.include_router(autotrain_router)
api_router.include_router(pipeline_router) api_router.include_router(pipeline_router)
api_router.include_router(event_daily_router)
# Dashboards & Monitoring # Dashboards & Monitoring
api_router.include_router(dashboard_router) api_router.include_router(dashboard_router)
......
This diff is collapsed.
...@@ -8,9 +8,18 @@ from common.pool_wrapper import get_pooled_connection_compat ...@@ -8,9 +8,18 @@ from common.pool_wrapper import get_pooled_connection_compat
from config import CHECKPOINT_POSTGRES_URL from config import CHECKPOINT_POSTGRES_URL
DASHBOARD_SCHEMA = "dashboard_canifa" DASHBOARD_SCHEMA = "dashboard_canifa"
_schema_initialized = False
def get_conn(): def get_conn():
"""Get a pooled connection (returns to pool on .close()).""" """Get a pooled connection (returns to pool on .close())."""
global _schema_initialized
if not _schema_initialized:
try:
init_dashboard_schema()
_schema_initialized = True
except Exception as e:
print(f"[WARN] Lazy Dashboard DB init skipped: {e}")
return get_pooled_connection_compat() return get_pooled_connection_compat()
def _get_raw_conn(): def _get_raw_conn():
...@@ -131,8 +140,4 @@ def init_dashboard_schema(): ...@@ -131,8 +140,4 @@ def init_dashboard_schema():
print(f"[OK] Schema '{DASHBOARD_SCHEMA}' and all tables initialized (5 tables).") print(f"[OK] Schema '{DASHBOARD_SCHEMA}' and all tables initialized (5 tables).")
# Auto-init on import # Auto-init on import removed in favor of lazy loading inside get_conn()
try:
init_dashboard_schema()
except Exception as e:
print(f"[WARN] Dashboard DB init skipped (PG not available): {e}")
This diff is collapsed.
import sqlite3
import json
db_path = 'canifa_ai_dump.sqlite'
try:
conn = sqlite3.connect(db_path)
c = conn.cursor()
# Find rows where description_data_full is short or only contains mau_sac
c.execute("SELECT magento_ref_code, description_data_full FROM sr__test_db__magento_product_dimension_with_text_embedding WHERE description_data_full LIKE '%mau_sac%' AND length(description_data_full) < 100 LIMIT 10")
rows_mau_sac = c.fetchall()
print("--- Rows with short JSON / only mau_sac ---")
for r in rows_mau_sac:
print(r)
c.execute("SELECT magento_ref_code, description_data_full FROM sr__test_db__magento_product_dimension_with_text_embedding WHERE description_data_full NOT LIKE '{%' LIMIT 5")
rows_invalid = c.fetchall()
print("\n--- Rows not starting with { ---")
for r in rows_invalid:
print(r)
except Exception as e:
print(f"Error in {db_path}: {e}")
...@@ -34,69 +34,10 @@ if not os.path.exists(STATIC_DIR): ...@@ -34,69 +34,10 @@ if not os.path.exists(STATIC_DIR):
print(f"[OK] Static dir resolved: {STATIC_DIR}") print(f"[OK] Static dir resolved: {STATIC_DIR}")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Initialize Redis cache, EventBus, and start background workers."""
await redis_cache.initialize()
logger.info("✅ Redis cache initialized")
# Start FastStream EventBus
await event_bus.start()
# Cleaned up Note APIs
# Start report worker if Redis is available
if REDIS_CACHE_TURN_ON and redis_cache.get_client():
from agent.report_agent.report_queue import report_worker_loop
asyncio.create_task(report_worker_loop())
logger.info("✅ Report Queue Worker started (background task)")
# Start Autonomous Curator Loop
try:
from agent.report_agent.autonomous_loop import ReportCurator
curator = ReportCurator()
asyncio.create_task(curator.start_loop())
logger.info("✅ Report Curator Loop started (autonomous insights generator)")
except ImportError as e:
logger.warning(f"⚠️ Report Curator not available: {e}")
# Start multi-job cron scheduler
try:
from agent.report_agent.scheduler import start_scheduler
start_scheduler()
logger.info("✅ Report Scheduler registered multi-job cron tasks")
except ImportError as e:
logger.warning(f"⚠️ Report Scheduler not available: {e}")
# ─── Start publish engine background loop ───────────────────────────────────
from common.social.scheduler import start_publish_engine
start_publish_engine(app) # Auto-publish scheduled content every 30s
yield
"""Cleanup resources before exit to prevent connection leaks during hot-reload."""
# Stop FastStream EventBus
await event_bus.stop()
from common.db_pool import db_pool
if db_pool:
db_pool.close_all()
logger.info("🛑 Postgres Connection Pool nicely closed")
# Optional: If you want to also clean up StarRocks you can do it here
try:
from common.starrocks_connection import StarRocksConnection
if StarRocksConnection._shared_conn and StarRocksConnection._shared_conn.open:
StarRocksConnection._shared_conn.close()
logger.info("🛑 StarRocks Connection nicely closed")
except Exception:
pass
app = FastAPI( app = FastAPI(
title="Contract AI Service", title="Contract AI Service",
description="API for Contract AI Service", description="API for Contract AI Service",
version="1.0.0", version="1.0.0",
lifespan=lifespan,
) )
...@@ -172,6 +113,7 @@ if __name__ == "__main__": ...@@ -172,6 +113,7 @@ if __name__ == "__main__":
print(f"Media Library: http://localhost:{PORT}/static/media-library/index.html") print(f"Media Library: http://localhost:{PORT}/static/media-library/index.html")
print(f"Social Inbox: http://localhost:{PORT}/static/social-inbox/index.html") print(f"Social Inbox: http://localhost:{PORT}/static/social-inbox/index.html")
print(f"Composer: http://localhost:{PORT}/static/content-composer/index.html") print(f"Composer: http://localhost:{PORT}/static/content-composer/index.html")
print(f"Event Daily: http://localhost:{PORT}/static/event_daily/index.html")
print("=" * 60) print("=" * 60)
ENABLE_RELOAD = False ENABLE_RELOAD = False
......
body { background: var(--bg); margin: 0; color: var(--t); font-family: 'Inter', sans-serif; display: flex; height: 100vh; overflow: hidden; }
.fm-layout { display: flex; width: 100%; height: 100%; }
.fm-left { width: 340px; border-right: 1px solid var(--border); display: flex; flex-direction: column; background: var(--card); }
.fm-right { flex: 1; display: flex; flex-direction: column; overflow-y: auto; background: var(--bg); }
.fm-left-header { padding: 16px; border-bottom: 1px solid var(--border); font-weight: 600; font-size: 15px; display: flex; justify-content: space-between; align-items: center;}
.fb-list { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
/* Custom scrollbar for list */
.fb-list::-webkit-scrollbar { width: 6px; }
.fb-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.fb-item { padding: 16px; border-radius: 8px; border: 1px solid var(--border); background: var(--card); cursor: pointer; transition: all 0.2s; position: relative;}
.fb-item:hover { border-color: var(--gold, #B45309); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.fb-item.active { border-color: var(--primary); background: rgba(var(--primary-rgb), 0.05); }
.fb-tag { position: absolute; top: 12px; right: 12px; font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 4px; letter-spacing: 0.5px;}
.fb-tag.pending { background: rgba(245,158,11,0.15); border: 1px solid rgba(245,158,11,0.3); color: #fbbf24; }
.fb-tag.trained { background: rgba(16,185,129,0.15); border: 1px solid rgba(16,185,129,0.3); color: #34d399; }
.fb-title { font-weight: 600; font-size: 13px; margin-bottom: 6px; padding-right: 50px;}
.fb-desc { font-size: 12px; color: var(--m); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.panel-header { padding: 16px 24px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; background: var(--card); }
.panel-body { padding: 32px 40px; max-width: 1200px; margin: 0 auto; width: 100%; }
.diff-wrapper { display: flex; gap: 20px; flex-wrap: wrap; margin-top: 20px; }
.diff-box { flex: 1; min-width: 300px; background: #121212; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; display: flex; flex-direction: column; box-shadow: 0 4px 20px rgba(0,0,0,0.1);}
.diff-header { padding: 10px 16px; background: #1e1e1e; font-family: 'Consolas', monospace; font-size: 12px; color: #a3a3a3; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border); }
.diff-content { padding: 20px; font-family: 'Consolas', 'Courier New', monospace; font-size: 13px; color: #e4e4e7; line-height: 1.7; white-space: pre-wrap; flex:1; }
ins { background: rgba(16,185,129,0.2); color: #6ee7b7; text-decoration: none; padding: 2px 4px; border-radius: 2px; border-bottom: 1px solid #6ee7b7; }
del { background: rgba(239,68,68,0.2); text-decoration: line-through; color: #fca5a5; padding: 2px 4px; border-radius: 2px; }
.bubble { background: var(--card); border-left: 4px solid var(--primary); padding: 16px 24px; border-radius: 0 8px 8px 0; font-style: italic; font-size: 14px; color: var(--t); margin-bottom: 24px; border: 1px solid var(--border); border-left: 4px solid var(--danger, #dc2626); box-shadow: 0 2px 8px rgba(0,0,0,0.05);}
.spinner { animation: spin 1.5s linear infinite; }
@keyframes spin { 100% { transform: rotate(360deg); } }
This diff is collapsed.
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Auto-Train Admin</title>
<link rel="stylesheet" href="/static/common/theme.css">
<link rel="stylesheet" href="/static/common/components.css">
<link rel="stylesheet" href="/static/autotrain/autotrain.css">
<script src="/static/common/frame-detect.js"></script>
<script src="https://unpkg.com/lucide@latest"></script>
</head>
<body>
<div class="fm-layout">
<!-- LEFT LIST -->
<div class="fm-left">
<div class="fm-left-header">
<div style="display:flex; align-items:center; gap:8px;"><i data-lucide="inbox" class="icon-sm"></i> Auto-Train Queue</div>
<span class="badge badge-warning" style="font-size:10px;" id="pending-count">0 pending</span>
</div>
<div style="padding:10px 12px;border-bottom:1px solid var(--border);">
<div class="search-bar" style="position: relative; display:flex; align-items:center;">
<i data-lucide="search" class="icon-sm" style="position: absolute; left: 10px; color: var(--m);"></i>
<input type="text" placeholder="Tìm ID hoặc feedback..." style="width:100%; padding: 8px 12px 8px 32px; font-size: 12px; border: 1px solid var(--border); background: var(--bg); color: var(--t); border-radius: 6px; outline:none;">
</div>
</div>
<div class="fb-list" id="fb-list">
<!-- Will be populated by JS -->
</div>
</div>
<!-- RIGHT DETAIL -->
<div class="fm-right" id="contentArea" style="display:none;">
<div class="panel-header">
<div style="font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px;">
<i data-lucide="code" class="icon-md" style="color:var(--primary);"></i>
Target Module: <code style="background:var(--bg);padding:4px 8px;border-radius:4px;color:var(--primary);font-size:13px;border:1px solid var(--border);">fashion_rules.json</code>
</div>
<div style="display:flex;gap:8px;">
<button class="btn btn-outline btn-sm" onclick="redoTrain()">
<i data-lucide="bot" class="icon-sm"></i> Re-Refine Code
</button>
<button class="btn btn-primary btn-sm" style="background-color: var(--danger, #dc2626); color: white; border-color: var(--danger, #dc2626);" onclick="approve()">
<i data-lucide="check-circle-2" class="icon-sm"></i> Approve & Deploy to Prod
</button>
</div>
</div>
<div class="panel-body">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:16px;">
<div style="display:flex; align-items:center; gap:8px;">
<i data-lucide="message-square-warning" class="icon-sm" style="color:var(--danger, #dc2626)"></i>
<h3 style="font-size:12px; color:var(--m); margin:0; text-transform:uppercase; letter-spacing:1px; font-weight:600;">Context: Trích xuất User Feedback</h3>
</div>
<button class="btn btn-outline" style="padding:4px 10px; font-size:11px;"><i data-lucide="history" class="icon-sm"></i> Xem full Chat History</button>
</div>
<div style="display:flex; gap:16px; margin-bottom: 32px;">
<div style="width:44px; height:44px; border-radius:50%; background: rgba(99, 102, 241, 0.15); border: 1px solid rgba(99, 102, 241, 0.4); display:flex; align-items:center; justify-content:center; color: var(--primary);"><i data-lucide="user"></i></div>
<div style="flex:1;">
<div style="display:flex; gap:12px; align-items:baseline; margin-bottom:8px;">
<span style="font-weight:600; font-size:14px;">Trace ID: <span style="color:var(--primary)" id="detail-trace-id">...</span></span>
<span style="font-size:12px; color:var(--m)" id="detail-time">· Lúc ...</span>
</div>
<div class="bubble" id="detail-comment" style="margin-bottom:0; padding:16px; border-radius: 0 12px 12px 12px; box-shadow:none; background: rgba(255,255,255,0.03); border:1px solid var(--border); font-style:normal; color:#e2e8f0; font-size:13px; line-height: 1.6;">
...
</div>
</div>
</div>
<div style="display:flex; align-items:center; gap:8px; margin-bottom:12px; margin-top:32px;">
<i data-lucide="file-diff" class="icon-sm" style="color:var(--primary)"></i>
<h3 style="font-size:12px; color:var(--m); margin:0; text-transform:uppercase; letter-spacing:1px; font-weight:600;">AI Prompt Optimization Proposal</h3>
</div>
<div class="diff-wrapper" id="diff-content-wrapper">
<!-- Will be populated when generate is clicked -->
<div style="text-align: center; width: 100%; padding: 40px; color: var(--m); font-style: italic;">
Đang chờ lệnh phân tích... Bấm "Re-Refine Code" để AI xử lý.
</div>
</div>
</div>
</div>
<div class="fm-right" id="emptyArea" style="display:flex; align-items:center; justify-content:center;">
<div style="text-align: center; color: var(--m);">
<i data-lucide="inbox" style="width:48px; height:48px; margin-bottom:16px; opacity:0.5;"></i>
<p>Chọn một feedback bên trái để xem chi tiết</p>
</div>
</div>
<!-- LOADING OVERLAY -->
<div id="loading" style="display:none; position:absolute; top:0; left:340px; right:0; bottom:0; background:rgba(15,23,42,0.8); backdrop-filter:blur(8px); z-index:100; flex-direction:column; justify-content:center; align-items:center;">
<i data-lucide="cpu" class="icon-xl spinner" style="color:var(--primary); margin-bottom:20px; width:48px; height:48px;"></i>
<h3 style="color:#fff; font-size:18px; margin:0 0 8px 0; font-weight:600;">AI Executor is Reasoning...</h3>
<p style="color:var(--m); font-size:13px; margin:0;">Synthesizing semantic context and updating prompt rules.</p>
</div>
</div>
<script src="/static/autotrain/autotrain.js"></script>
</body>
</html>
This diff is collapsed.
:root {
--bg-color: #f3f4f6;
--sidebar-bg: #ffffff;
--main-bg: #ffffff;
--text-main: #111827;
--text-muted: #6b7280;
--border-color: #e5e7eb;
--primary: #3b82f6;
--primary-hover: #2563eb;
--highlight-bg: #eff6ff;
--highlight-text: #1d4ed8;
--success: #10b981;
--danger: #ef4444;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg-color);
color: var(--text-main);
height: 100vh;
overflow: hidden;
}
.app-container {
display: flex;
height: 100vh;
}
/* Sidebar */
.sidebar {
width: 320px;
background-color: var(--sidebar-bg);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
}
.sidebar-header {
padding: 24px 20px;
border-bottom: 1px solid var(--border-color);
}
.sidebar-header h2 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 4px;
}
.sidebar-header p {
font-size: 0.875rem;
color: var(--text-muted);
}
.date-highlight {
margin: 20px;
padding: 16px;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
}
.date-highlight .label {
display: block;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.9;
margin-bottom: 4px;
}
.date-highlight .date-text {
font-size: 1.5rem;
font-weight: 700;
}
.event-list {
flex: 1;
overflow-y: auto;
padding: 10px 20px;
}
.event-item {
padding: 16px;
border: 1px solid var(--border-color);
border-radius: 8px;
margin-bottom: 12px;
cursor: pointer;
transition: all 0.2s;
background-color: #fff;
}
.event-item:hover {
border-color: var(--primary);
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.event-item.active {
border-color: var(--primary);
background-color: var(--highlight-bg);
box-shadow: 0 0 0 1px var(--primary);
}
.event-item.is-today {
border-left: 4px solid var(--danger);
}
.event-item .event-title {
font-weight: 600;
margin-bottom: 4px;
display: flex;
justify-content: space-between;
}
.event-item .event-date {
font-size: 0.875rem;
color: var(--text-muted);
}
.event-item .event-type {
font-size: 0.75rem;
padding: 2px 8px;
border-radius: 999px;
background-color: #e5e7eb;
color: #4b5563;
}
.event-item.active .event-type {
background-color: var(--primary);
color: white;
}
/* Main Content */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
background-color: var(--bg-color);
padding: 32px;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
}
.title-group h1 {
font-size: 1.875rem;
font-weight: 700;
margin-top: 8px;
}
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 999px;
font-size: 0.875rem;
font-weight: 500;
background-color: var(--highlight-bg);
color: var(--highlight-text);
}
.btn {
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
border: none;
transition: all 0.2s;
}
.btn-primary {
background-color: var(--primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--primary-hover);
}
.btn-primary:disabled {
background-color: #9ca3af;
cursor: not-allowed;
}
.prompt-editor-container {
background-color: var(--main-bg);
border-radius: 12px;
border: 1px solid var(--border-color);
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
}
.editor-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
background-color: #f9fafb;
}
.editor-header h3 {
font-size: 1.125rem;
font-weight: 600;
}
.editor-header .subtitle {
font-size: 0.875rem;
color: var(--text-muted);
margin-top: 4px;
}
.prompt-textarea {
flex: 1;
width: 100%;
padding: 24px;
border: none;
resize: none;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.95rem;
line-height: 1.6;
color: var(--text-main);
background-color: white;
}
.prompt-textarea:focus {
outline: none;
}
.prompt-textarea:disabled {
background-color: #f9fafb;
color: var(--text-muted);
}
.editor-footer {
padding: 16px 24px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
background-color: #f9fafb;
font-size: 0.875rem;
color: var(--text-muted);
}
.status-indicator.success {
color: var(--success);
font-weight: 500;
}
document.addEventListener('DOMContentLoaded', () => {
let events = [];
let currentEventId = null;
// Hiển thị ngày hôm nay
const today = new Date();
const dateOptions = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
document.getElementById('today-date').textContent = today.toLocaleDateString('vi-VN', dateOptions);
// Fetch events from API
async function fetchEvents() {
try {
const response = await fetch('/api/event_daily/events');
if (response.ok) {
events = await response.json();
renderEvents();
// Select today's event by default if available
if (currentEventId === null) {
const todayEvent = events.find(e => e.isToday);
if (todayEvent) {
selectEvent(todayEvent.id);
} else if (events.length > 0) {
selectEvent(events[0].id);
}
} else {
selectEvent(currentEventId);
}
} else {
console.error('Failed to fetch events');
}
} catch (error) {
console.error('Error fetching events:', error);
}
}
// Render danh sách sự kiện
const eventListEl = document.getElementById('event-list');
function renderEvents() {
eventListEl.innerHTML = '';
events.forEach(event => {
const el = document.createElement('div');
el.className = `event-item ${event.isToday ? 'is-today' : ''} ${event.id === currentEventId ? 'active' : ''}`;
el.innerHTML = `
<div class="event-title">
<span>${event.name} ${event.isToday ? '🔥' : ''}</span>
<span class="event-type">${event.type}</span>
</div>
<div class="event-date">${event.date}</div>
`;
el.addEventListener('click', () => selectEvent(event.id));
eventListEl.appendChild(el);
});
}
function selectEvent(id) {
currentEventId = id;
renderEvents();
const event = events.find(e => e.id === id);
if (event) {
document.getElementById('current-event-name').textContent = event.name;
document.getElementById('current-event-type').textContent = event.type;
const textarea = document.getElementById('prompt-textarea');
textarea.value = event.prompt;
textarea.disabled = false;
document.getElementById('btn-save').disabled = false;
updateCharCount();
document.getElementById('save-status').textContent = '';
}
}
// Xử lý đếm ký tự
const textarea = document.getElementById('prompt-textarea');
textarea.addEventListener('input', updateCharCount);
function updateCharCount() {
document.getElementById('char-count').textContent = `${textarea.value.length} ký tự`;
}
// Xử lý lưu
document.getElementById('btn-save').addEventListener('click', async () => {
const btn = document.getElementById('btn-save');
btn.textContent = 'Đang lưu...';
btn.disabled = true;
try {
const response = await fetch(`/api/event_daily/events/${currentEventId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ prompt: textarea.value })
});
if (response.ok) {
const event = events.find(e => e.id === currentEventId);
if (event) {
event.prompt = textarea.value;
}
const status = document.getElementById('save-status');
status.textContent = '✓ Đã lưu thành công';
status.className = 'status-indicator success';
setTimeout(() => {
status.textContent = '';
status.className = 'status-indicator';
}, 3000);
} else {
throw new Error('Failed to save');
}
} catch (error) {
console.error('Error saving prompt:', error);
const status = document.getElementById('save-status');
status.textContent = '✗ Lỗi khi lưu';
status.className = 'status-indicator error';
status.style.color = 'red';
} finally {
btn.textContent = 'Lưu Cập Nhật';
btn.disabled = false;
}
});
// Init
fetchEvents();
});
This diff is collapsed.
...@@ -161,6 +161,11 @@ body{margin:0;display:flex;min-height:100vh} ...@@ -161,6 +161,11 @@ body{margin:0;display:flex;min-height:100vh}
<span>Prompt Optimizer</span> <span>Prompt Optimizer</span>
<span class="nav-badge badge-beta">NEW</span> <span class="nav-badge badge-beta">NEW</span>
</a> </a>
<a data-page="manage_prompt/index.html" class="nav-item" onclick="navigateTo(this)" title="Prompt Manager">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>
<span>Prompt Manager</span>
<span class="nav-badge badge-live">LIVE</span>
</a>
<a data-page="user-simulator/user-simulator.html" class="nav-item" onclick="navigateTo(this)" title="User Simulator"> <a data-page="user-simulator/user-simulator.html" class="nav-item" onclick="navigateTo(this)" title="User Simulator">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></span> <span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></span>
<span>User Simulator</span> <span>User Simulator</span>
...@@ -210,6 +215,11 @@ body{margin:0;display:flex;min-height:100vh} ...@@ -210,6 +215,11 @@ body{margin:0;display:flex;min-height:100vh}
<span>Competitor Research</span> <span>Competitor Research</span>
<span class="nav-badge badge-beta">NEW</span> <span class="nav-badge badge-beta">NEW</span>
</a> </a>
<a data-page="event_daily/index.html" class="nav-item" onclick="navigateTo(this)" title="Event Daily">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></span>
<span>Event Daily</span>
<span class="nav-badge badge-new">NEW</span>
</a>
</div> </div>
<div class="nav-group"> <div class="nav-group">
......
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Prompt Manager</title>
<link rel="stylesheet" href="/static/common/theme.css">
<link rel="stylesheet" href="/static/common/components.css">
<link rel="stylesheet" href="/static/manage_prompt/manage_prompt.css">
<script src="https://unpkg.com/lucide@latest"></script>
</head>
<body>
<div class="shell" id="app">
<div class="body">
<!-- LEFT LIST -->
<div class="panel panel-left">
<div class="panel-header panel-header-flex">
<div class="panel-brand">
<i data-lucide="folder-code" class="icon-sm"></i> Prompt Manager
</div>
<div class="panel-actions">
<button class="tb-btn" onclick="importFromFiles()" title="Import từ local files">
<i data-lucide="download" class="icon-sm"></i>
</button>
<button class="tb-btn" onclick="refreshAll()" title="Refresh">
<i data-lucide="refresh-cw" class="icon-sm"></i>
</button>
</div>
</div>
<div class="panel-section search-section">
<div class="search-bar search-bar-pm">
<i data-lucide="search" class="icon-sm search-icon"></i>
<input type="text" id="module-search" class="search-input" placeholder="Tìm module (ví dụ: core, thaomai)..." oninput="filterModules(this.value)">
</div>
<div class="stats-row">
<span>Modules: <strong id="stat-modules" class="stat-val-text">—</strong></span>
<span>In Sync: <strong id="stat-sync" class="stat-sync-text">—</strong></span>
</div>
</div>
<div class="module-list" id="module-list">
<div class="list-loading">
<i data-lucide="loader" class="spinner icon-md text-primary"></i>
</div>
</div>
</div>
<!-- CENTER DETAIL -->
<div class="panel panel-center">
<div id="detail-area" class="detail-area">
<div class="centered-empty">
<i data-lucide="mouse-pointer-click" class="empty-icon-lg"></i>
<div class="empty-title">Chọn một module</div>
<div class="empty-desc">Click vào module bên trái để xem và quản lý phiên bản dev / production</div>
</div>
</div>
</div>
<!-- RIGHT PANEL -->
<div class="panel panel-right">
<div class="panel-section">
<div class="section-title split-title">
<span>Langfuse Status</span>
<span class="lf-status-wrap">
<i id="lf-dot" data-lucide="cloud" class="icon-xs status-dot checking"></i>
<span id="lf-status" class="lf-status-text">Connecting...</span>
<strong id="stat-lf-count" class="lf-count-text">—</strong>
</span>
</div>
<div id="langfuse-panel"></div>
</div>
<div class="panel-section" style="flex:1; display:flex; flex-direction:column; overflow:hidden;">
<div class="section-title">Version History</div>
<div id="history-panel" style="flex:1;"></div>
</div>
</div>
</div>
</div>
<!-- ═══ MODAL: Diff Viewer ═══ -->
<div class="modal-overlay" id="diff-modal">
<div class="modal-box modal-lg">
<div class="modal-head">
<div class="modal-head-title">
<i data-lucide="git-compare" class="icon-md text-primary"></i>
<h3 id="diff-title">So sánh Dev ↔ Production</h3>
</div>
<button class="modal-close" onclick="closeDiffModal()"><i data-lucide="x" class="icon-sm"></i></button>
</div>
<div class="modal-body bg-dark">
<div class="diff-wrapper">
<div class="diff-box">
<div class="diff-header dev-header">
<span class="diff-header-title"><i data-lucide="terminal" class="icon-sm"></i> DEV (Local)</span>
</div>
<pre class="diff-content" id="diff-dev"></pre>
</div>
<div class="diff-box">
<div class="diff-header prod-header">
<span class="diff-header-title"><i data-lucide="cloud" class="icon-sm"></i> PRODUCTION (Langfuse)</span>
</div>
<pre class="diff-content" id="diff-prod"></pre>
</div>
</div>
</div>
<div class="modal-foot">
<button class="detail-btn outline" onclick="closeDiffModal()">Đóng</button>
<button class="detail-btn primary" id="btn-promote-modal" onclick="promoteFromModal()">
<i data-lucide="rocket" class="icon-sm"></i> Promote to Production
</button>
</div>
</div>
</div>
<!-- ═══ MODAL: Editor ═══ -->
<div class="modal-overlay" id="editor-modal">
<div class="modal-box modal-lg">
<div class="modal-head">
<div class="modal-head-title">
<i data-lucide="edit-3" class="icon-md text-primary"></i>
<h3 id="editor-title">Chỉnh sửa Prompt</h3>
</div>
<button class="modal-close" onclick="closeEditorModal()"><i data-lucide="x" class="icon-sm"></i></button>
</div>
<div class="modal-body">
<div class="editor-notes-wrap">
<input type="text" id="editor-notes" class="editor-notes-input" placeholder="Ghi chú phiên bản (Ví dụ: Update rule thời tiết mùa hè)...">
</div>
<textarea class="editor-textarea" id="editor-content" spellcheck="false" placeholder="Nội dung prompt..."></textarea>
<div class="editor-stats">
<span id="editor-chars" class="editor-stat-item"><i data-lucide="type" class="icon-xs"></i> 0 ký tự</span>
<span id="editor-lines" class="editor-stat-item"><i data-lucide="list" class="icon-xs"></i> 0 dòng</span>
</div>
</div>
<div class="modal-foot">
<button class="detail-btn outline" onclick="closeEditorModal()">Hủy</button>
<button class="detail-btn primary" onclick="saveEditorContent()">
<i data-lucide="save" class="icon-sm"></i> Lưu Dev Version
</button>
</div>
</div>
</div>
<!-- ═══ Toast ═══ -->
<div class="notif" id="toast"></div>
<!-- ═══ Loading Overlay ═══ -->
<div id="loading-overlay" class="loading-overlay">
<i data-lucide="loader" class="icon-xl spinner text-primary"></i>
<h3 class="loading-title" id="loading-title">Đang xử lý...</h3>
</div>
<script src="/static/manage_prompt/manage_prompt.js"></script>
<script>
// Initialize lucide icons
lucide.createIcons();
</script>
</body>
</html>
This diff is collapsed.
This diff is collapsed.
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