Commit 3259bf5e authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

feat: Full A-Z token optimization for AI Report Generator

parent 53e1bcd9
Pipeline #3386 failed with stage
"""
AI Feedback Classifier
Uses GPT-4.1-mini to classify user complaints into categories and severity.
Runs async in background — does not block user response.
"""
import json
import logging
from langchain_openai import ChatOpenAI
logger = logging.getLogger(__name__)
CLASSIFY_PROMPT = """Bạn là hệ thống phân loại phàn nàn khách hàng cho chatbot Canifa AI Stylist.
Phân loại phàn nàn sau đây của khách hàng.
## Phàn nàn:
"{comment}"
## Danh mục (chọn 1):
- thieu_du_lieu: Thiếu thông tin sản phẩm (size, màu, giá, mô tả...)
- du_lieu_sai: Thông tin sai (giá sai, tên sản phẩm sai, link sai...)
- bot_khong_hieu: Bot không hiểu ý khách hàng, trả lời lạc đề
- trai_nghiem_kem: Bot chậm, khó dùng, giao diện kém
- khac: Không thuộc các loại trên
## Mức độ nghiêm trọng:
- high: Ảnh hưởng trực tiếp đến quyết định mua hàng
- medium: Gây khó chịu nhưng không ảnh hưởng mua hàng
- low: Góp ý nhỏ, cải thiện trải nghiệm
## Trả về JSON (chỉ JSON, không giải thích):
{{"category": "...", "severity": "...", "summary": "tóm tắt ngắn gọn vấn đề"}}"""
async def classify_feedback(comment: str) -> dict:
"""
Classify a user complaint using GPT-4.1-mini.
Args:
comment: The user's complaint text
Returns:
dict with keys: category, severity, summary
Falls back to defaults on error.
"""
if not comment or not comment.strip():
return {
"category": "khac",
"severity": "low",
"summary": "Không có nội dung phàn nàn",
}
try:
llm = ChatOpenAI(
model="gpt-4.1-mini",
temperature=0,
max_tokens=200,
)
prompt = CLASSIFY_PROMPT.format(comment=comment)
response = await llm.ainvoke(prompt)
content = response.content.strip()
# Parse JSON from response (handle markdown code blocks)
if "```" in content:
content = content.split("```")[1]
if content.startswith("json"):
content = content[4:]
content = content.strip()
result = json.loads(content)
# Validate category
valid_categories = ["thieu_du_lieu", "du_lieu_sai", "bot_khong_hieu", "trai_nghiem_kem", "khac"]
if result.get("category") not in valid_categories:
result["category"] = "khac"
# Validate severity
valid_severities = ["low", "medium", "high"]
if result.get("severity") not in valid_severities:
result["severity"] = "medium"
if "summary" not in result:
result["summary"] = comment[:100]
logger.info(f"🤖 AI classified: {result['category']} / {result['severity']}")
return result
except Exception as e:
logger.warning(f"⚠️ AI classification failed: {e}")
return {
"category": "khac",
"severity": "medium",
"summary": comment[:100] if comment else "Classification error",
}
"""
AI Report HTML Generator — SSE Streaming Endpoint.
POST /api/report-html
Input: { question: str }
Output: SSE stream → final event contains body HTML
Same ReAct data gathering loop as report_agent_route.py,
but Writer outputs raw HTML body instead of JSON.
"""
import asyncio
import json
import logging
import re
import traceback
import time
from typing import Any
from fastapi import APIRouter
from pydantic import BaseModel
from starlette.responses import StreamingResponse
from common.starrocks_connection import StarRocksConnection
from common.postgres_readonly import PostgresReadonly
from prompts.report_html_prompt import HTML_AGENT_PROMPT, HTML_WRITER_PROMPT
logger = logging.getLogger(__name__)
router = APIRouter()
# ─── Request Model ───────────────────────────────────────────────────
class ReportHTMLRequest(BaseModel):
question: str
model: str = "codex/gpt-5.3-codex"
parent_report_id: int | None = None
# ─── SQL Safety (same as report_agent_route) ─────────────────────────
_FORBIDDEN = re.compile(
r"\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|REPLACE|MERGE|GRANT|REVOKE)\b",
re.IGNORECASE,
)
def _validate_sql(sql: str) -> str | None:
sql_clean = sql.strip().rstrip(";")
if not sql_clean.upper().startswith("SELECT"):
return "Only SELECT queries allowed"
if _FORBIDDEN.search(sql_clean):
return "Forbidden SQL keyword"
check = sql_clean.lower().replace("_text_embedding", "").replace("_embedding", "")
if re.search(r'\b(embedding|vector)\b', check):
return "Cannot query embedding/vector column"
return None
# ─── LLM Call (Codex) ────────────────────────────────────────────────
async def _call_llm(system_prompt: str, user_content: str, model_id: str = "codex/gpt-5.3-codex") -> str:
from common.codex_auth import get_codex_access_token, get_codex_account_id, get_refresh_token
from common.codex_chat_model import CodexChatModel
from langchain_core.messages import HumanMessage, SystemMessage
access_token = get_codex_access_token()
if not access_token:
raise RuntimeError("Codex auth not available. Run `codex login` first.")
actual_model = model_id.replace("codex/", "") if model_id.startswith("codex/") else model_id
model = CodexChatModel(
model_name=actual_model,
access_token=access_token,
account_id=get_codex_account_id() or "",
refresh_token=get_refresh_token(),
streaming=False,
)
messages = [SystemMessage(content=system_prompt), HumanMessage(content=user_content)]
result = await model._agenerate(messages)
return result.generations[0].message.content
def _parse_json(raw: str) -> dict:
json_match = re.search(r'```(?:json)?\s*([\s\S]*?)```', raw)
json_str = json_match.group(1).strip() if json_match else raw.strip()
return json.loads(json_str)
def _extract_html(raw: str) -> str:
"""Extract HTML from LLM response, stripping markdown fences if present."""
html_match = re.search(r'```(?:html)?\s*([\s\S]*?)```', raw)
if html_match:
return html_match.group(1).strip()
return raw.strip()
# ─── Tool Executors (same as report_agent_route) ────────────────────
async def _exec_tool(tool_name: str, params: dict) -> dict[str, Any]:
if tool_name in ("sql_langfuse", "sql_starrocks"):
sql = params.get("sql", "")
err = _validate_sql(sql)
if err:
return {"error": err, "data": []}
db = StarRocksConnection()
try:
rows = await db.execute_query_async(sql)
data = [_serialize_row(dict(r)) for r in rows]
return {"data": data, "columns": list(rows[0].keys()) if rows else [], "row_count": len(data)}
except Exception as e:
return {"error": str(e)[:300], "data": []}
elif tool_name == "sql_postgres":
sql = params.get("sql", "")
err = _validate_sql(sql)
if err:
return {"error": err, "data": []}
try:
rows = await PostgresReadonly.execute_query_async(sql)
data = [_serialize_row(dict(r)) for r in rows]
return {"data": data, "columns": list(rows[0].keys()) if rows else [], "row_count": len(data)}
except Exception as e:
return {"error": str(e)[:300], "data": []}
elif tool_name == "calculator":
expr = params.get("expression", "")
description = params.get("description", "")
try:
allowed = set("0123456789.+-*/() ,")
if all(c in allowed for c in expr.replace(" ", "")):
result = eval(expr) # noqa: S307
return {"expression": expr, "result": result, "description": description}
else:
return {"error": "Invalid expression", "expression": expr}
except Exception as e:
return {"error": str(e), "expression": expr}
return {"error": f"Unknown tool: {tool_name}"}
def _serialize_row(row: dict) -> dict:
for key, val in row.items():
if isinstance(val, (bytes, bytearray)):
row[key] = val.hex()
elif hasattr(val, "isoformat"):
row[key] = val.isoformat()
elif isinstance(val, float) and val != val:
row[key] = 0
elif hasattr(val, "as_tuple"):
row[key] = float(val)
return row
async def _execute_tools_parallel(tools: list[dict]) -> list[dict]:
tasks = [_exec_tool(t.get("name", ""), t.get("params", {})) for t in tools]
return await asyncio.gather(*tasks, return_exceptions=True)
def _make_result_preview(result: dict) -> dict:
preview = {}
if isinstance(result, Exception):
return {"error": str(result)[:200]}
if "data" in result:
data = result["data"]
if isinstance(data, list):
preview["row_count"] = len(data)
preview["preview"] = data[:3] if data else []
if "error" in result:
preview["error"] = result["error"]
if "result" in result:
preview["result"] = result["result"]
return preview
def _summarize_results(all_results: dict[str, Any]) -> str:
"""Compress tool results into concise format optimized for token efficiency.
Strategy inspired by DeepAgents argument truncation:
- Small results (≤20 rows): tab-separated table
- Large results (>20 rows): auto-compute summary statistics
- Numeric columns: min, max, avg, sum
- Text columns: unique count + top values
"""
parts = []
for key, result in all_results.items():
if isinstance(result, Exception):
parts.append(f"\n## {key}\nERROR: {str(result)[:200]}")
continue
result_dict = dict(result) if isinstance(result, dict) else {"error": str(result)}
if "error" in result_dict:
parts.append(f"\n## {key}\nERROR: {result_dict['error']}")
continue
if "result" in result_dict:
parts.append(f"\n## {key}\nResult: {result_dict['result']}")
continue
data = result_dict.get("data", [])
if not isinstance(data, list) or not data:
parts.append(f"\n## {key}\n(no data)")
continue
columns = result_dict.get("columns", list(data[0].keys()) if data else [])
row_count = len(data)
if row_count <= 20:
# Small result: show full tab-separated table (perfect for charts)
lines = [f"\n## {key} ({row_count} rows)"]
lines.append("\t".join(str(c) for c in columns))
for row in data:
cells = []
for col in columns:
val = row.get(col, "")
s = str(val) if val is not None else ""
if len(s) > 60:
s = s[:57] + "..."
cells.append(s)
lines.append("\t".join(cells))
parts.append("\n".join(lines))
else:
# Large result: auto-aggregate to save tokens
lines = [f"\n## {key} ({row_count} rows → auto-summarized)"]
# Show first 10 rows for chart data
lines.append("### Sample (first 10):")
lines.append("\t".join(str(c) for c in columns))
for row in data[:10]:
cells = []
for col in columns:
val = row.get(col, "")
s = str(val) if val is not None else ""
if len(s) > 40:
s = s[:37] + "..."
cells.append(s)
lines.append("\t".join(cells))
# Compute stats for numeric columns
lines.append("### Statistics:")
for col in columns:
values = [row.get(col) for row in data if row.get(col) is not None]
if not values:
continue
# Check if numeric
try:
nums = [float(v) for v in values if v != "" and v is not None]
if nums and len(nums) > len(values) * 0.5:
lines.append(
f" {col}: min={min(nums):.2f}, max={max(nums):.2f}, "
f"avg={sum(nums)/len(nums):.2f}, sum={sum(nums):.2f}, count={len(nums)}"
)
except (ValueError, TypeError):
# Text column: show unique count + top 5
unique = list(set(str(v) for v in values[:100]))
top = unique[:5]
lines.append(f" {col}: {len(unique)} unique values, top: {', '.join(top)}")
parts.append("\n".join(lines))
return "\n".join(parts)
# ─── SSE Streaming Endpoint ─────────────────────────────────────────
MAX_REFLECT_CYCLES = 5
def _sse(data: dict) -> str:
return f"data: {json.dumps(data, ensure_ascii=False, default=str)}\n\n"
@router.post("/api/report-html", summary="AI Report HTML Generator (SSE Stream)")
async def report_html_stream(req: ReportHTMLRequest):
"""
Generate a report as raw HTML body content via SSE.
Same ReAct loop for data gathering, but Writer outputs HTML instead of JSON.
"""
async def event_stream():
start_time = time.time()
try:
logger.info("📝 HTML Report Agent: %s", req.question[:100])
all_tool_results: dict[str, Any] = {}
cycle = 0
MAX_REFLECT_CYCLES = 4
tool_counter = 0
# --- Handle Follow-up Context ---
parent_context = ""
is_followup = bool(req.parent_report_id)
if is_followup:
sql = "SELECT prompt, tools_used FROM public.ai_reports WHERE id = $1"
pool = await PostgresReadonly._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(sql, req.parent_report_id)
if row:
parent_context = f"THIS IS A FOLLOW-UP QUERY to a previous report.\nPrevious Report Topic: {row['prompt']}\nTools already used: {row['tools_used']}\n\nYou must focus ONLY on answering the follow-up question by querying new data if needed.\n\n"
logger.info("Context loaded for Follow-up to Report #%s", req.parent_report_id)
# ── THINK ──
yield _sse({"type": "thinking", "step": "🧠 Đang phân tích yêu cầu..."})
think_input = (
f"{parent_context}"
f"User request: {req.question}\n\n"
f"## INSTRUCTION:\nBreak down this request into sub_tasks and provide the FIRST set of tools to run.\n"
f"RESPOND WITH RAW JSON ONLY (no markdown blocks)."
)
agent_raw = await _call_llm(HTML_AGENT_PROMPT, think_input, req.model)
agent_response = _parse_json(agent_raw)
# ── ReAct Loop ──
while cycle < MAX_REFLECT_CYCLES:
action = agent_response.get("action", "execute")
thinking = agent_response.get("thinking", "")
current_task = agent_response.get("current_task", "")
sub_tasks = agent_response.get("sub_tasks", [])
tools_to_run = []
if action == "execute":
tools_to_run = agent_response.get("tools", [])
elif action == "reflect":
data_sufficient = agent_response.get("data_sufficient", False)
missing = agent_response.get("missing", [])
completed = agent_response.get("completed_sub_tasks", [])
drill_downs = agent_response.get("drill_down_opportunities", [])
next_task = agent_response.get("next_task", "")
next_subs = agent_response.get("next_sub_tasks", [])
yield _sse({
"type": "reflect", "cycle": cycle + 1,
"thinking": thinking, "data_sufficient": data_sufficient,
"missing": missing,
"current_task": current_task,
"completed_sub_tasks": completed,
"drill_down_opportunities": drill_downs,
"next_task": next_task,
"next_sub_tasks": next_subs,
})
if data_sufficient and not missing:
break
tools_to_run = agent_response.get("next_tools", [])
if not tools_to_run:
break
elif action == "write_report":
break
if not tools_to_run:
break
# ── ACT ──
yield _sse({
"type": "plan_ready", "thinking": thinking,
"tools_count": len(tools_to_run), "cycle": cycle + 1,
"current_task": current_task,
"sub_tasks": sub_tasks,
})
for i, tool_spec in enumerate(tools_to_run):
yield _sse({
"type": "tool_call", "index": tool_counter + i,
"tool": tool_spec.get("name", ""),
"params": tool_spec.get("params", {}),
"purpose": tool_spec.get("purpose", ""),
})
results = await _execute_tools_parallel(tools_to_run)
# ── OBSERVE ──
for i, (tool_spec, result) in enumerate(zip(tools_to_run, results)):
tool_name = tool_spec.get("name", "unknown")
if isinstance(result, Exception):
result = {"error": str(result)[:300], "data": []}
key = f"{tool_name}_{tool_counter + i}"
all_tool_results[key] = result
yield _sse({
"type": "tool_result", "index": tool_counter + i,
"tool": tool_name,
"success": "error" not in result,
"preview": _make_result_preview(result),
})
tool_counter += len(tools_to_run)
cycle += 1
# ── REFLECT ──
yield _sse({"type": "thinking", "step": f"🔍 Đánh giá dữ liệu (vòng {cycle}/{MAX_REFLECT_CYCLES})..."})
# Only send NEW results from this cycle + compact summary of previous
new_keys = [f"{t.get('name', 'unknown')}_{tool_counter - len(tools_to_run) + i}" for i, t in enumerate(tools_to_run)]
new_results = {k: v for k, v in all_tool_results.items() if k in new_keys}
prev_keys = [k for k in all_tool_results if k not in new_keys]
prev_summary = ""
if prev_keys:
prev_summary = f"### Previously collected ({len(prev_keys)} queries): {', '.join(prev_keys)}\n"
new_summary = _summarize_results(new_results)
reflect_input = (
f"User request: {req.question}\n\n"
f"## DATA COLLECTED (cycle {cycle}/{MAX_REFLECT_CYCLES}):\n"
f"{prev_summary}"
f"### New results this cycle:\n{new_summary}\n\n"
f"## INSTRUCTION:\nReview the data. Respond with action='reflect'.\n"
f"If data_sufficient=true AND missing=[] → proceed to write.\n"
f"If not, provide next_tools.\nRESPOND WITH RAW JSON ONLY."
)
reflect_raw = await _call_llm(HTML_AGENT_PROMPT, reflect_input, req.model)
agent_response = _parse_json(reflect_raw)
# ── WRITE HTML ──
yield _sse({"type": "thinking", "step": "✍️ Đang viết báo cáo HTML từ dữ liệu thật..."})
data_context = _summarize_results(all_tool_results)
writer_input = (
f"{parent_context}"
f"User request: {req.question}\n\n"
f"## REAL DATA FROM TOOLS ({tool_counter} queries, {cycle} cycles):\n{data_context}\n\n"
f"## DATA FORMAT:\n"
f"Data is provided as tab-separated tables. To create Chart.js charts:\n"
f"- First column = labels[] array (x-axis: dates, categories, names)\n"
f"- Other columns = data[] arrays (y-axis: numbers, counts, amounts)\n"
f"- Example: if data shows 'ngay\\tdoanh_thu', use labels=['2024-01','2024-02',...] and data=[120M, 150M,...]\n\n"
f"Generate the FULL report HTML body using the data above.\n"
f"CRITICAL: You MUST generate AT LEAST 6 pages (6 `.page` divs). Include: Cover page, TOC + Executive Summary + KPIs, "
f"2-3 analysis pages with charts and tables, and a Conclusion + Recommendations page. "
f"Do NOT skip pages or abbreviate. Each content page MUST have .rh header and .pf footer."
)
if is_followup:
writer_input += " OVERRIDE: Since this is a follow-up, DO NOT generate a full HTML page. ONLY generate a `<div class='followup-section'>...</div>` containing the answer to the follow-up question, styled using the existing report_template.css classes (e.g. .section, .kpi-grid). Output RAW HTML ONLY."
else:
writer_input += " Output RAW HTML ONLY."
html_raw = await _call_llm(HTML_WRITER_PROMPT, writer_input, req.model)
html_body = _extract_html(html_raw)
# ── SAVE TO DB ──
generation_time_ms = int((time.time() - start_time) * 1000)
tools_used = list(all_tool_results.keys())
# Simple title generation from the prompt
title = req.question[:100] + ("..." if len(req.question) > 100 else "")
insert_sql = """
INSERT INTO public.ai_reports
(title, prompt, html_content, tools_used, cycles_count, generation_time_ms)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
"""
report_id = None
try:
report_id = await PostgresReadonly.execute_insert_async(
insert_sql,
title,
req.question,
html_body,
tools_used,
cycle,
generation_time_ms
)
logger.info("💾 Saved report to DB with ID: %s", report_id)
except Exception as e:
logger.error("Failed to save report to DB: %s", e)
yield _sse({"type": "html_complete", "html": html_body, "report_id": report_id})
logger.info("✅ HTML Report complete: %d chars, %d tools, %d cycles",
len(html_body), tool_counter, cycle)
yield _sse({"type": "done"})
except json.JSONDecodeError as e:
logger.error("Report HTML JSON error: %s", e)
yield _sse({"type": "error", "message": f"AI response was not valid JSON: {str(e)[:200]}"})
except RuntimeError as e:
logger.error("Report HTML runtime error: %s", e)
yield _sse({"type": "error", "message": str(e)[:300]})
except Exception as e:
logger.error("Report HTML error: %s\n%s", e, traceback.format_exc())
yield _sse({"type": "error", "message": str(e)[:300]})
return StreamingResponse(event_stream(), media_type="text/event-stream")
# ─── Report History API ──────────────────────────────────────────────
@router.get("/reports")
async def get_reports():
"""List all saved reports (metadata only, no full HTML)."""
sql = """
SELECT id, title, prompt, tools_used, cycles_count, generation_time_ms, report_pages, status, created_by, created_at
FROM public.ai_reports
ORDER BY created_at DESC
LIMIT 50
"""
try:
rows = await PostgresReadonly.execute_query_async(sql)
for r in rows:
if "created_at" in r and r["created_at"]:
r["created_at"] = r["created_at"].isoformat()
return {"reports": rows}
except Exception as e:
logger.error("Error fetching reports: %s", e)
return {"error": str(e), "reports": []}
@router.get("/reports/{report_id}")
async def get_report_by_id(report_id: int):
"""Get a single report with full HTML content."""
sql = "SELECT * FROM public.ai_reports WHERE id = $1"
try:
pool = await PostgresReadonly._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(sql, report_id)
if not row:
return {"error": "Report not found"}
r = dict(row)
if "created_at" in r and r["created_at"]:
r["created_at"] = r["created_at"].isoformat()
if "updated_at" in r and r["updated_at"]:
r["updated_at"] = r["updated_at"].isoformat()
return {"report": r}
except Exception as e:
logger.error("Error fetching report %d: %s", report_id, e)
return {"error": str(e)}
@router.delete("/reports/{report_id}")
async def delete_report(report_id: int):
"""Delete a generated report."""
sql = "DELETE FROM public.ai_reports WHERE id = $1"
try:
pool = await PostgresReadonly._get_pool()
async with pool.acquire() as conn:
status = await conn.execute(sql, report_id)
return {"success": True, "status": status}
except Exception as e:
logger.error("Error deleting report %d: %s", report_id, e)
return {"error": str(e)}
...@@ -54,3 +54,11 @@ class PostgresReadonly: ...@@ -54,3 +54,11 @@ class PostgresReadonly:
async with pool.acquire() as conn: async with pool.acquire() as conn:
rows = await conn.fetch(sql) rows = await conn.fetch(sql)
return [dict(r) for r in rows] return [dict(r) for r in rows]
@classmethod
async def execute_insert_async(cls, sql: str, *args) -> int:
"""Execute an INSERT query with args and return the new ID."""
pool = await cls._get_pool()
async with pool.acquire() as conn:
result = await conn.fetchval(sql, *args)
return result
"""
Report HTML Writer Prompt — AI sinh body HTML dùng CSS classes cố định.
AI output = body HTML + Chart.js <script>, KHÔNG chứa CSS.
CSS load từ report_template.css (static).
Imported by api/report_html_route.py
"""
from prompts.dashboard_prompt import STARROCKS_TABLE, POSTGRES_TABLE
# ═══════════════════════════════════════════════════════════════════
# LANGFUSE TABLE (in StarRocks)
# ═══════════════════════════════════════════════════════════════════
LANGFUSE_TABLE = "analytic.chatbot_rsa_trace_event_detail"
LANGFUSE_SCHEMA = f"""
## Table: {LANGFUSE_TABLE} (StarRocks — Langfuse Analytics)
Columns:
- trace_id (VARCHAR), project_name (VARCHAR), org_name (VARCHAR)
- session_id (VARCHAR), customer_id (VARCHAR), device_id (VARCHAR)
- trace_latency (DECIMAL — seconds), user_latency (DECIMAL — seconds)
- input_cost (DECIMAL — USD), output_cost (DECIMAL — USD), total_cost (DECIMAL — USD)
- model_name (VARCHAR — e.g. 'gpt-5.2-codex', 'gemini-3.1-flash-lite')
- total_obs (BIGINT), total_obs_error (BIGINT), nb_generation_error (BIGINT)
- is_guest (TINYINT — 1/0), is_user (TINYINT — 1/0)
- traced_at (DATETIME), created_at (DATETIME), updated_at (DATETIME)
"""
# ═══════════════════════════════════════════════════════════════════
# AGENT PROMPT — Deep Reasoning ReAct loop with Sub-Tasks
# ═══════════════════════════════════════════════════════════════════
HTML_AGENT_PROMPT = f"""You are a Senior AI Data Analyst Agent for Canifa (Vietnamese fashion brand).
You work in a deep ReAct loop: THINK → PLAN → ACT → OBSERVE → REFLECT → (drill-down or finish).
## YOUR ANALYSIS PHILOSOPHY:
You do NOT just collect surface-level data. You analyze like a senior analyst:
1. **Level 1 — Overview**: Aggregate metrics, totals, trends over the full time range
2. **Level 2 — Drill-down**: When you spot patterns (anomalies, spikes, drops), you MUST create sub-tasks to investigate WHY
3. **Level 3 — Cross-reference**: Compare across data sources to find correlations and root causes
## ANALYSIS STRATEGY:
When given a report request, you MUST create a hierarchical task plan:
Example task tree:
```
Task 1: Tổng quan hiệu suất (Overview)
1.1: Tổng traces, chi phí, latency theo ngày → sql_langfuse
1.2: Schema check → sql_postgres
Task 2: Phân tích chi tiết (Drill-down)
2.1: Ngày có latency cao nhất → drill vào traces cụ thể
2.2: So sánh lỗi vs thành công → tìm root cause
2.3: Phân tích nội dung hội thoại → feedback patterns
Task 3: Cross-reference & Insight
3.1: Correlation giữa chat volume vs cost
3.2: Phân tích user segments (guest vs member)
```
## AVAILABLE TOOLS:
1. **sql_langfuse** — Query `{LANGFUSE_TABLE}` on StarRocks
Use for: chatbot usage, costs, latency, models, errors, users
{LANGFUSE_SCHEMA}
2. **sql_starrocks** — Query `{STARROCKS_TABLE}` on StarRocks
Use for: product analytics, sales, categories. NEVER SELECT 'vector'/'embedding' columns.
3. **sql_postgres** — Query `{POSTGRES_TABLE}` on PostgreSQL
Use for: chat history, user messages, conversation analysis.
IMPORTANT: Check column names first! Use: SELECT column_name FROM information_schema.columns WHERE table_name='langgraph_chat_histories'
4. **calculator** — Pure math only (digits + -, *, /, parentheses).
## RESPONSE FORMAT (JSON):
At each step, respond with ONE of:
### Execute tools (with sub-task tracking):
```json
{{
"action": "execute",
"thinking": "What I need to find out and why",
"current_task": "1. Tổng quan hiệu suất",
"sub_tasks": ["1.1 Aggregate metrics", "1.2 Schema check"],
"tools": [
{{"name": "tool_name", "params": {{"sql": "SELECT ..."}}, "purpose": "Sub-task 1.1: Why this query"}}
]
}}
```
### Reflect (with drill-down decisions):
```json
{{
"action": "reflect",
"thinking": "Assessment of data collected so far",
"current_task": "1. Tổng quan hiệu suất",
"completed_sub_tasks": ["1.1 ✅ Got 177 traces over 6 days", "1.2 ✅ Schema confirmed"],
"data_sufficient": false,
"drill_down_opportunities": [
"Ngày 18/03 có latency 15.45s, cao gấp đôi trung bình → cần drill-down traces ngày đó",
"Có 0 errors nhưng chưa kiểm tra chất lượng nội dung phản hồi → cần check chat logs"
],
"next_task": "2. Phân tích chi tiết",
"next_sub_tasks": ["2.1 Drill latency spikes", "2.2 Check chat quality"],
"missing": ["Detailed traces for high-latency day", "Chat content quality analysis"],
"next_tools": [
{{"name": "sql_langfuse", "params": {{"sql": "SELECT ..."}}, "purpose": "Sub-task 2.1: Drill into high-latency traces"}}
]
}}
```
## SQL TOKEN OPTIMIZATION RULES (CRITICAL — saves cost & enables longer reports):
**NEVER return raw rows. ALWAYS aggregate in SQL.**
1. **Use GROUP BY + aggregate functions**: SUM(), COUNT(), AVG(), MIN(), MAX()
- ❌ BAD: `SELECT date, amount FROM orders` → returns 10,000 rows!
- ✅ GOOD: `SELECT DATE(created_at) as ngay, SUM(amount) as doanh_thu, COUNT(*) as so_don FROM orders GROUP BY ngay ORDER BY ngay`
2. **LIMIT all queries to 20 rows max**: `LIMIT 20` at end of every query
3. **Pre-compute metrics in SQL** — don't send raw data:
- ❌ BAD: `SELECT * FROM traces` → AI Writer gets 500 rows of raw data
- ✅ GOOD: `SELECT model_name, COUNT(*) as traces, AVG(trace_latency) as avg_latency, SUM(total_cost) as total_cost FROM ... GROUP BY model_name`
4. **For time-series charts**: Group by DAY/WEEK/MONTH, not individual timestamps
- ✅ `SELECT DATE(traced_at) as ngay, COUNT(*) as so_luong FROM ... GROUP BY ngay ORDER BY ngay LIMIT 20`
5. **For distributions/rankings**: Use TOP N with ORDER BY
- ✅ `SELECT category, COUNT(*) as cnt FROM ... GROUP BY category ORDER BY cnt DESC LIMIT 10`
6. **For comparisons**: Compute ratios/percentages IN the SQL
- ✅ `SELECT model_name, COUNT(*) as total, SUM(CASE WHEN total_obs_error > 0 THEN 1 ELSE 0 END) as errors, ROUND(SUM(CASE WHEN total_obs_error > 0 THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 1) as error_pct FROM ... GROUP BY model_name`
## DEEP REASONING RULES:
1. **First cycle**: Start with 3-5 diverse overview queries (aggregates, schema checks, recent samples)
2. **Reflect deeply**: After getting results, ALWAYS ask:
- "What patterns do I see?" → Find anomalies, spikes, drops
- "WHY did this happen?" → Create drill-down sub-tasks
- "What's MISSING from a senior analyst's perspective?" → Cross-reference
3. **Second cycle**: Drill-down queries based on patterns discovered
- If a day has unusually high latency → query traces for that specific day
- If a model has more errors → compare with other models
- If chat logs show escalation patterns → analyze which intents trigger escalation
4. **Third cycle**: Cross-reference and fill gaps
- Correlate findings across sources
- Get specific examples to support findings
- Calculate derived metrics (ratios, percentages, growth rates)
5. **Only mark data_sufficient=true when** you have:
- Overview metrics ✅
- At least 2 drill-down insights ✅
- Specific examples/evidence ✅
- Cross-referenced findings ✅
- Business impact assessment ✅
6. **Max 5 reflect cycles** — but aim for at least 2-3 for depth
7. All SQL must be SELECT-only
8. ALWAYS include sub-task labels in your tool purposes
## BUSINESS-PERSPECTIVE ANALYSIS (BẮT BUỘC):
You are NOT a DBA dumping tables. You are a SENIOR BUSINESS ANALYST.
Every report MUST analyze data through these 5 BUSINESS LENSES:
### 🏭 Lens 1: Operational Health (Vận hành)
- System uptime & error rate → Is the system stable?
- Latency trends → User experience degrading?
- Throughput capacity → Can we handle more traffic?
### 💰 Lens 2: Cost & ROI (Chi phí & Hiệu quả đầu tư)
- Cost per interaction → Benchmark against industry ($0.50-$2.00/interaction)
- Cost trend → Growing or optimized?
- Cost breakdown → Where do the dollars go? Input vs Output?
- Monthly projection → Will we stay within budget?
- ROI calculation → How much manual labor does AI replace?
Example: "Mỗi trace thay thế ~5 phút tư vấn viên, tiết kiệm ~$1.50/trace vs chi phí AI $0.016/trace → ROI 93.7x"
### 👥 Lens 3: User Experience (Trải nghiệm KH)
- User vs Guest ratio → Engagement depth?
- Return rate → Users coming back?
- Session patterns → When do users need help most?
- Chat quality indicators → Response relevance, completion rate
### 📈 Lens 4: Growth & Trends (Tăng trưởng)
- Week-over-week growth → Accelerating or plateauing?
- User acquisition cost → Organic vs paid?
- Feature adoption → Which capabilities used most?
- Forecast → Projected volume next 30 days
### ⚠️ Lens 5: Risk & Optimization (Rủi ro & Tối ưu)
- Single point of failure → One model dependency?
- Cost concentration → 80/20 rule on spending?
- Tail latency → P95/P99 vs average
- Budget runway → When will we exceed limits?
- Optimization opportunities → Caching, prompt compression, model switching
RESPOND WITH RAW JSON ONLY.
"""
# ═══════════════════════════════════════════════════════════════════
# HTML WRITER PROMPT — AI sinh body HTML dùng CSS classes
# ═══════════════════════════════════════════════════════════════════
HTML_WRITER_PROMPT = r"""You are an expert report writer. You generate ONLY the <body> HTML content for a professional A4 business report.
## CRITICAL RULES:
1. Output ONLY the inner HTML of <body>. Do NOT include <!DOCTYPE>, <html>, <head>, or <body> tags.
2. Use ONLY the CSS classes defined below. Do NOT write inline styles except for layout grids.
3. All data MUST come from the real tool results provided. NEVER fabricate numbers.
4. All text in Vietnamese.
5. Include Chart.js <script> at the very end of your output.
## CSS CLASSES REFERENCE:
### Page Structure
- `.page` — A4 page container (210mm wide, white background)
- `.cover` — Cover page wrapper (min-height:297mm)
- `.cp` — Content page (adds padding + page-break)
### Cover Page Elements
- `.cover-stripe` — Navy bar top
- `.cover-stripe-gold` — Gold accent bar
- `.cover-deco`, `.cover-deco2` — Decorative circles (position:absolute)
- `.cover-inner` — Main content area
- `.cover-toprow` — Logo + meta row
- `.org-logo > .org-badge + .org-info > .org-name + .org-dept`
- `.doc-meta` — Right-aligned document info
- `.cover-hero` — Title area
- `.cover-tag` — Category badge (blue pill)
- `.cover-title` — Main title (30pt navy)
- `.cover-subtitle` — Subtitle (italic, gray)
- `.cover-divider` — Gradient line separator
- `.cover-metarow > .cover-metaitem > .cover-metalabel + .cover-metaval` — 4-column info grid
- `.cover-footer > .cover-footer-note + .cover-stamp`
### Content Page Elements
- `.rh > .rh-left (with .rh-dot) + .rh-right` — Running header with page numbers
- `.pf > .pf-left + .pf-center + .pf-right` — Page footer
### Headings
- `<h1>` — Page title (20pt navy)
- `<h2 data-n="X">` — Section heading with numbered navy badge
- `<h3>` — Sub-heading (italic blue)
- `<p>` — Normal paragraph (justified)
- `<p class="lead">` — Lead paragraph (larger, lighter)
### Executive Summary
- `.esbox` — Blue-left-bordered box
- `.esbox-label` — "EXECUTIVE SUMMARY" label
- `.esbox p` — Summary paragraphs
### KPI Grid (4 columns)
```html
<div class="kpi-grid">
<div class="kpi" style="--kpi-accent:var(--blue-mid)">
<span class="kpi-label">LABEL</span>
<span class="kpi-value">123<span class="kpi-unit">unit</span></span>
<span class="kpi-change up">↑ 18.4% YoY</span>
</div>
</div>
```
Accent colors: `var(--blue-mid)`, `var(--green)`, `var(--gold)`, `var(--red)`
Change classes: `.up` (green), `.dn` (red), `.nt` (gray)
### Charts (Chart.js canvas)
```html
<div class="fig">
<div class="fig-header">
<span class="fig-num">Hình X.Y</span>
<span class="fig-title">Title</span>
</div>
<div class="fig-wrap"><canvas id="c-unique-id" height="175"></canvas></div>
<div class="fig-cap"><strong>Nguồn:</strong> Description.</div>
</div>
```
### Tables
```html
<div class="tbl-wrap">
<div class="tbl-cap">Bảng X.Y — Title</div>
<table>
<thead><tr><th>Col</th><th class="r">Right-aligned</th></tr></thead>
<tbody>
<tr><td>Data</td><td class="r">123</td></tr>
<tr class="trow-total"><td>Tổng</td><td class="r">456</td></tr>
</tbody>
</table>
</div>
```
Badge in table: `<span class="badge up">↑ 21%</span>`, `.badge.dn`, `.badge.nt`
### Callouts
```html
<div class="note info"><div class="note-icon">i</div><p><strong>Lưu ý:</strong> Text.</p></div>
<div class="note warn"><div class="note-icon">!</div><p><strong>Cảnh báo:</strong> Text.</p></div>
<div class="note success"><div class="note-icon">✓</div><p><strong>Tích cực:</strong> Text.</p></div>
```
### Table of Contents
```html
<div class="toc-item"><span class="toc-num">1.</span><span class="toc-text">Title</span><span class="toc-dots"></span><span class="toc-pg">3</span></div>
<div class="toc-item toc-sub"><span class="toc-num">1.1</span><span class="toc-text">Sub</span><span class="toc-dots"></span><span class="toc-pg">3</span></div>
```
### Recommendations
```html
<div class="rec-item"><span class="rec-n">1</span><span class="rec-text">Text.</span></div>
```
### Signature Block
```html
<div class="sigblock">
<div class="sig-item">
<div class="sig-label">Title</div>
<div class="sig-line" style="height:14mm"></div>
<div class="sig-name">Name</div>
<div class="sig-role">Role</div>
</div>
<!-- 3 columns total -->
</div>
```
### Footnotes
```html
<hr class="fn-rule">
<p class="fn">¹ Footnote text.</p>
```
### Two-column layout (only place inline style is allowed)
```html
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8mm;margin:4mm 0">
<div class="fig">...</div>
<div class="fig">...</div>
</div>
```
## CHART.JS BOILERPLATE (always include at bottom):
```javascript
const F = {family:"'Inter',sans-serif",size:9}
const TIP = {backgroundColor:"#0F2044",titleColor:"#93C5FD",bodyColor:"#F1F5F9",padding:11,cornerRadius:6,titleFont:{...F,size:9},bodyFont:{...F,size:11,weight:"700"}}
const AX = (cb)=>({grid:{color:"rgba(0,0,0,.05)"},border:{display:false},ticks:{color:"#6B7280",font:F,...(cb?{callback:cb}:{})}})
const AXX = {grid:{display:false},border:{display:false},ticks:{color:"#6B7280",font:F}}
```
Chart colors (use in order):
- `#1A4A8C` (navy), `#C8860A` (gold), `#0D6E4F` (green), `#7C3AED` (purple), `#B91C1C` (red), `#0891B2` (cyan)
- For "previous period" datasets: use same color with `rgba(r,g,b,.22)` for lighter shade
Chart types:
- **Bar**: `type:"bar"` with `borderRadius:4, borderSkipped:false`
- **Horizontal Bar**: add `indexAxis:"y"` to options
- **Donut**: `type:"doughnut"` with `cutout:"65%"`, `borderWidth:2, borderColor:"#fff"`
- **Line/Area**: `type:"line"` with `tension:0.4, pointRadius:3.5`, add `fill:true` for area
- **Grouped**: multiple datasets with same x-axis labels
## REPORT MIN PAGES: 6-8 pages (see REPORT STRUCTURE below for detailed breakdown)
Every `.page.cp` MUST have:
- `.rh` running header at top
- `.pf` page footer at bottom
## FULL EXAMPLE OUTPUT:
Below is a COMPLETE example of the expected output format. Your output should follow this exact structure:
```html
<button class="print-btn no-print" onclick="window.print()">🖨&nbsp; In / Lưu PDF</button>
<!-- COVER PAGE -->
<div class="page">
<div class="cover">
<div class="cover-stripe"></div>
<div class="cover-stripe-gold"></div>
<div class="cover-deco"></div>
<div class="cover-deco2"></div>
<div class="cover-inner">
<div class="cover-toprow">
<div class="org-logo">
<div class="org-badge">CANIFA<br>AI</div>
<div class="org-info">
<span class="org-name">Canifa — AI Platform</span>
<span class="org-dept">Phòng Phân tích Dữ liệu & AI</span>
</div>
</div>
<div class="doc-meta">
Số hiệu: <strong>CANIFA-RPT-2026-001</strong><br>
Phiên bản: <strong>1.0 — AI Generated</strong><br>
Phân loại: <em>Nội bộ</em>
</div>
</div>
<div class="cover-hero">
<div class="cover-tag">Báo cáo Phân tích</div>
<h1 class="cover-title">Tiêu đề Báo cáo<br>Được AI Tạo Ra</h1>
<p class="cover-subtitle">Mô tả ngắn về nội dung báo cáo dựa trên dữ liệu thật</p>
<div class="cover-divider"></div>
<div class="cover-metarow">
<div class="cover-metaitem"><span class="cover-metalabel">Kỳ báo cáo</span><span class="cover-metaval">01/03 – 31/03/2026</span></div>
<div class="cover-metaitem"><span class="cover-metalabel">Ngày phát hành</span><span class="cover-metaval">19 tháng 3, 2026</span></div>
<div class="cover-metaitem"><span class="cover-metalabel">Người lập</span><span class="cover-metaval">AI Report Agent</span></div>
<div class="cover-metaitem"><span class="cover-metalabel">Phê duyệt</span><span class="cover-metaval">—</span></div>
</div>
</div>
<div class="cover-footer">
<span class="cover-footer-note">Báo cáo được tạo tự động bởi AI Report Agent — Canifa AI Platform</span>
<span class="cover-stamp">AI GENERATED</span>
</div>
</div>
</div>
</div>
<!-- PAGE 2: TOC + SUMMARY + KPIs -->
<div class="page cp">
<div class="rh"><span class="rh-left"><span class="rh-dot"></span>Tiêu đề Báo cáo — Canifa</span><span class="rh-right">Trang 2 / 5</span></div>
<h1>Mục Lục</h1>
<div style="margin:5mm 0 8mm">
<div class="toc-item"><span class="toc-num">1.</span><span class="toc-text">Tóm tắt Điều hành</span><span class="toc-dots"></span><span class="toc-pg">2</span></div>
<div class="toc-item"><span class="toc-num">2.</span><span class="toc-text">Section Title</span><span class="toc-dots"></span><span class="toc-pg">3</span></div>
</div>
<hr style="border:none;border-top:1pt solid var(--rule);margin:6mm 0">
<h2 data-n="1">Tóm tắt Điều hành</h2>
<div class="esbox">
<div class="esbox-label">Executive Summary</div>
<p>Summary paragraph using REAL data from tools...</p>
</div>
<div class="kpi-grid">
<div class="kpi" style="--kpi-accent:var(--blue-mid)"><span class="kpi-label">KPI 1</span><span class="kpi-value">123<span class="kpi-unit">unit</span></span><span class="kpi-change up">↑ 18% YoY</span></div>
<div class="kpi" style="--kpi-accent:var(--green)"><span class="kpi-label">KPI 2</span><span class="kpi-value">456<span class="kpi-unit">unit</span></span><span class="kpi-change up">↑ 12%</span></div>
<div class="kpi" style="--kpi-accent:var(--gold)"><span class="kpi-label">KPI 3</span><span class="kpi-value">789<span class="kpi-unit">unit</span></span><span class="kpi-change dn">↓ 5%</span></div>
<div class="kpi" style="--kpi-accent:var(--red)"><span class="kpi-label">KPI 4</span><span class="kpi-value">99<span class="kpi-unit">%</span></span><span class="kpi-change nt">— 0%</span></div>
</div>
<div class="pf"><span class="pf-left">Canifa · AI Generated</span><span class="pf-center">CANIFA-RPT-2026-001</span><span class="pf-right">2 / 5</span></div>
</div>
<!-- PAGE 3: Analysis with Charts + Tables -->
<div class="page cp">
<div class="rh"><span class="rh-left"><span class="rh-dot"></span>Tiêu đề Báo cáo — Canifa</span><span class="rh-right">Trang 3 / 5</span></div>
<h2 data-n="2">Section Title</h2>
<p class="lead">Lead paragraph...</p>
<p>Analysis paragraph with <strong>real numbers</strong> from data...</p>
<div class="fig">
<div class="fig-header"><span class="fig-num">Hình 2.1</span><span class="fig-title">Chart Title</span></div>
<div class="fig-wrap"><canvas id="c-example" height="175"></canvas></div>
<div class="fig-cap"><strong>Nguồn:</strong> Data source description.</div>
</div>
<div class="note info"><div class="note-icon">i</div><p><strong>Lưu ý:</strong> Insight from data.</p></div>
<div class="tbl-wrap">
<div class="tbl-cap">Bảng 2.1 — Table Title</div>
<table>
<thead><tr><th>Col 1</th><th class="r">Col 2</th><th class="r">Tăng trưởng</th></tr></thead>
<tbody>
<tr><td>Row 1</td><td class="r">123</td><td class="r"><span class="badge up">↑ 15%</span></td></tr>
<tr class="trow-total"><td>Tổng</td><td class="r">456</td><td class="r">↑ 10%</td></tr>
</tbody>
</table>
</div>
<div class="pf"><span class="pf-left">Canifa · AI Generated</span><span class="pf-center">CANIFA-RPT-2026-001</span><span class="pf-right">3 / 5</span></div>
</div>
<!-- PAGE 5: Conclusion + Recommendations -->
<div class="page cp">
<div class="rh"><span class="rh-left"><span class="rh-dot"></span>Tiêu đề Báo cáo — Canifa</span><span class="rh-right">Trang 5 / 5</span></div>
<h2 data-n="7">Kết luận & Khuyến nghị</h2>
<h3>7.1 Kết luận</h3>
<p>Conclusion paragraph...</p>
<h3>7.2 Khuyến nghị</h3>
<div style="margin-top:3mm">
<div class="rec-item"><span class="rec-n">1</span><span class="rec-text">Recommendation 1.</span></div>
<div class="rec-item"><span class="rec-n">2</span><span class="rec-text">Recommendation 2.</span></div>
</div>
<hr class="fn-rule">
<p class="fn">¹ Footnote.</p>
<div class="sigblock">
<div class="sig-item"><div class="sig-label">Người lập</div><div class="sig-line" style="height:14mm"></div><div class="sig-name">AI Report Agent</div><div class="sig-role">Canifa AI Platform</div></div>
<div class="sig-item"><div class="sig-label">Kiểm tra</div><div class="sig-line" style="height:14mm"></div><div class="sig-name">—</div><div class="sig-role">—</div></div>
<div class="sig-item"><div class="sig-label">Phê duyệt</div><div class="sig-line" style="height:14mm"></div><div class="sig-name">—</div><div class="sig-role">—</div></div>
</div>
<div class="pf" style="margin-top:6mm"><span class="pf-left">Canifa · AI Generated</span><span class="pf-center">CANIFA-RPT-2026-001</span><span class="pf-right">5 / 5 · Trang cuối</span></div>
</div>
<!-- CHART.JS SCRIPTS (always at the end) -->
<script>
const F = {family:"'Inter',sans-serif",size:9}
const TIP = {backgroundColor:"#0F2044",titleColor:"#93C5FD",bodyColor:"#F1F5F9",padding:11,cornerRadius:6,titleFont:{...F,size:9},bodyFont:{...F,size:11,weight:"700"}}
const AX = (cb)=>({grid:{color:"rgba(0,0,0,.05)"},border:{display:false},ticks:{color:"#6B7280",font:F,...(cb?{callback:cb}:{})}})
const AXX = {grid:{display:false},border:{display:false},ticks:{color:"#6B7280",font:F}}
// Example bar chart
new Chart("c-example",{type:"bar",data:{
labels:["Label 1","Label 2","Label 3"],
datasets:[{label:"Dataset",data:[38,49,55],backgroundColor:"#1A4A8C",borderRadius:4,borderSkipped:false}]
},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false},tooltip:TIP},scales:{x:AXX,y:AX(v=>v+"T")}}})
</script>
```
## DEEP ANALYSIS FRAMEWORK — Viết như Senior Analyst:
### 5 Lenses (Mỗi section PHẢI trả lời ít nhất 3/5):
1. **WHAT** — Con số cụ thể là gì? (dữ liệu thô)
2. **WHY** — Tại sao lại như vậy? (root cause analysis)
3. **SO WHAT** — Điều này có ý nghĩa gì? (business impact)
4. **COMPARED TO** — So với gì? (benchmark, kỳ trước, mục tiêu)
5. **WHAT NEXT** — Cần làm gì tiếp? (actionable insight)
### Cách viết từng section (BẮT BUỘC):
```
Đoạn 1 (Lead): Insight chính + con số nổi bật
Đoạn 2 (Analysis): Phân tích WHY — tìm root cause, correlation
Đoạn 3 (Context): So sánh → benchmark, kỳ trước, mục tiêu
Đoạn 4 (Impact): SO WHAT — business impact cụ thể
[Chart/Table]: Minh họa trực quan
[Callout .note]: Insight quan trọng hoặc cảnh báo
```
### Multi-perspective Analysis (ĐA GÓC NHÌN):
Mỗi chủ đề phải được nhìn từ nhiều góc:
- **Góc vận hành**: Hệ thống có ổn định không? Lỗi ở đâu?
- **Góc chi phí**: Tốn bao nhiêu? ROI thế nào? Có thể optimize?
- **Góc người dùng**: UX thế nào? User hài lòng không? Pattern gì?
- **Góc tăng trưởng**: Xu hướng đi lên hay xuống? Tốc độ?
- **Góc rủi ro**: Điểm yếu? Bottleneck? Single point of failure?
### Ví dụ viết SÂU vs viết NÔNG:
❌ NÔNG: "Tổng số traces là 177, trung bình latency là 7.2s"
✅ SÂU: "Hệ thống ghi nhận 177 traces trong 6 ngày hoạt động (TB 29.5 traces/ngày).
Đáng chú ý, latency trung bình 7.2s — cao hơn ngưỡng chấp nhận 5s. Phân tích sâu cho thấy
ngày 18/03 có latency đột biến 15.4s (gấp 2.1x trung bình), trùng với thời điểm traffic
tăng 40%. Điều này cho thấy hệ thống chưa scale tốt dưới tải cao.
Khuyến nghị: cần implement caching layer hoặc pre-computed responses cho câu hỏi phổ biến."
❌ NÔNG: "Mô hình gpt-5.3-codex có chi phí $0.15/trace"
✅ SÂU: "Chi phí trung bình $0.15/trace cho gpt-5.3-codex — nếu giữ nguyên mức 29.5 traces/ngày,
ước tính monthly cost ~$133. So với mục tiêu $100/tháng, đang vượt 33%.
Tuy nhiên, phân tích cho thấy 62% chi phí tập trung ở output tokens (do câu trả lời dài).
Nếu giảm output từ 800 → 500 tokens bằng prompt optimization, có thể tiết kiệm ~$35/tháng (26%),
đưa total cost về đúng target."
## CONTENT RULES:
1. Write 5-7 analytical sections, each with 3-5 paragraphs using the 5 Lenses framework
2. Each section MUST have: data → analysis → context → impact → visualization
3. Use 4-6 KPI cards with real highlights and YoY/MoM comparison if available
4. Include 5-8 actionable recommendations with expected impact
5. Use callouts (.note) to highlight INSIGHTS (not just data)
- `.note.info` → insight/pattern phát hiện
- `.note.warn` → rủi ro/cảnh báo cần chú ý
- `.note.success` → điểm tích cực/đạt target
6. Chart data MUST exactly match tool results — NEVER invent numbers
7. For donut charts: use `cutout:"65%"`, `borderWidth:2`, `borderColor:"#fff"`
8. For bar charts: use `borderRadius:4`, `borderSkipped:false`
9. For line charts: use `tension:0.4`, `pointRadius:3.5`
10. Generate unique canvas IDs (e.g. c-revenue, c-cost, c-trend)
11. Two-column chart layouts: use `display:grid;grid-template-columns:1fr 1fr;gap:8mm`
12. EVERY paragraph must contain specific numbers from data — no vague statements
13. Use COMPARISON language: "cao hơn X%", "so với kỳ trước", "vượt ngưỡng Y"
14. Include CALCULATED METRICS: ratios, percentages, averages, projections
15. End each section with a "Đánh giá" sentence: positive/negative/neutral assessment
## REPORT STRUCTURE (6-8 pages for depth):
Page 1: Cover page (`.page > .cover`)
Page 2: TOC + Executive Summary + KPI Grid (`.page.cp`)
Page 3-4: Deep analysis sections with charts, tables, callouts (`.page.cp`)
Page 5-6: Cross-reference analysis + Multi-perspective insights (`.page.cp`)
Page 7: Forecast + Risk Assessment (`.page.cp`)
Page 8: Conclusion + Actionable Recommendations + Signatures (`.page.cp`)
OUTPUT THE RAW HTML ONLY. No markdown fences. No explanations.
"""
...@@ -25,6 +25,7 @@ from api.product_route import router as product_router ...@@ -25,6 +25,7 @@ from api.product_route import router as product_router
from api.sql_chat_route import router as sql_chat_router from api.sql_chat_route import router as sql_chat_router
from api.cache_route import router as cache_router from api.cache_route import router as cache_router
from api.report_agent_route import router as report_agent_router from api.report_agent_route import router as report_agent_router
from api.report_html_route import router as report_html_router
from common.cache import redis_cache from common.cache import redis_cache
from common.middleware import middleware_manager from common.middleware import middleware_manager
from config import PORT from config import PORT
...@@ -113,6 +114,7 @@ app.include_router(product_router) # Product performance dashboard ...@@ -113,6 +114,7 @@ app.include_router(product_router) # Product performance dashboard
app.include_router(sql_chat_router) # AI Data Analyst (Text-to-SQL) app.include_router(sql_chat_router) # AI Data Analyst (Text-to-SQL)
app.include_router(cache_router) # Cache management dashboard app.include_router(cache_router) # Cache management dashboard
app.include_router(report_agent_router) # AI Report Agent (SSE) app.include_router(report_agent_router) # AI Report Agent (SSE)
app.include_router(report_html_router) # AI Report HTML Generator
if __name__ == "__main__": if __name__ == "__main__":
......
...@@ -121,7 +121,7 @@ ...@@ -121,7 +121,7 @@
<div class="container"> <div class="container">
<a href="ai-analytic.html" class="back-link">← Quay lại Report Agent</a> <a href="ai-report.html" class="back-link">← Quay lại Report Agent</a>
<!-- ═══════════════════════════════════════════════════════════════════ --> <!-- ═══════════════════════════════════════════════════════════════════ -->
<!-- OVERVIEW --> <!-- OVERVIEW -->
...@@ -357,7 +357,7 @@ ...@@ -357,7 +357,7 @@
<!-- ═══════════════════════════════════════════════════════════════════ --> <!-- ═══════════════════════════════════════════════════════════════════ -->
<div style="text-align:center;padding:32px 0 20px;border-top:1px solid #E2E8F0"> <div style="text-align:center;padding:32px 0 20px;border-top:1px solid #E2E8F0">
<p style="font-size:14px;color:#64748B;margin-bottom:16px">Sẵn sàng tạo báo cáo?</p> <p style="font-size:14px;color:#64748B;margin-bottom:16px">Sẵn sàng tạo báo cáo?</p>
<a href="ai-analytic.html" style="display:inline-flex;align-items:center;gap:8px;padding:12px 28px;background:linear-gradient(135deg,#1E40AF,#4338CA);color:white;text-decoration:none;border-radius:12px;font-size:14px;font-weight:700;box-shadow:0 4px 14px rgba(67,56,202,.3);transition:all .2s" onmouseenter="this.style.transform='translateY(-1px)'" onmouseleave="this.style.transform='translateY(0)'"> <a href="ai-report.html" style="display:inline-flex;align-items:center;gap:8px;padding:12px 28px;background:linear-gradient(135deg,#1E40AF,#4338CA);color:white;text-decoration:none;border-radius:12px;font-size:14px;font-weight:700;box-shadow:0 4px 14px rgba(67,56,202,.3);transition:all .2s" onmouseenter="this.style.transform='translateY(-1px)'" onmouseleave="this.style.transform='translateY(0)'">
📋 Mở AI Report Agent → 📋 Mở AI Report Agent →
</a> </a>
</div> </div>
......
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Report Agent — Canifa AI</title>
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useRef, useEffect, useCallback } = React;
const ChartJS = Chart;
ChartJS.register(
ChartJS.CategoryScale, ChartJS.LinearScale, ChartJS.BarElement,
ChartJS.LineElement, ChartJS.PointElement, ChartJS.ArcElement,
ChartJS.Filler, ChartJS.Tooltip, ChartJS.Legend
)
const PAL = ["#2563EB","#059669","#D97706","#DC2626","#7C3AED","#0891B2","#EA580C","#DB2777"]
const rgba = (hex, a) => {
const [r,g,b] = [hex.slice(1,3),hex.slice(3,5),hex.slice(5,7)].map(v=>parseInt(v,16))
return `rgba(${r},${g},${b},${a})`
}
const SUGGESTIONS = [
"Báo cáo hiệu suất chatbot AI Canifa tháng 3/2026",
"Phân tích chi phí sử dụng LLM 30 ngày qua",
"Báo cáo chất lượng trả lời chatbot (scores & feedback)",
"Phân tích sản phẩm bán chạy nhất trên StarRocks",
]
// ─── CHARTS ──────────────────────────────────────────────────────────────────
const chartBase = (extra={}) => ({
responsive: true, maintainAspectRatio: false,
animation: { duration: 500, easing: "easeInOutQuart" },
plugins: {
legend: { display: false },
tooltip: { backgroundColor: "#1E293B", titleColor: "#94A3B8", bodyColor: "#F8FAFC", padding: 11, cornerRadius: 8,
titleFont:{size:10}, bodyFont:{size:12,weight:"600"} }
},
scales: {
x: { grid:{display:false}, border:{display:false}, ticks:{color:"#94A3B8",font:{size:10}} },
y: { grid:{color:"rgba(0,0,0,.05)"}, border:{display:false}, ticks:{color:"#94A3B8",font:{size:10}} }
},
...extra
})
function useChart(ref, build, deps) {
const ch = useRef(null)
useEffect(() => {
if (!ref.current) return
ch.current?.destroy()
ch.current = build(ref.current)
return () => ch.current?.destroy()
}, deps)
}
function InlineChart({ chart }) {
const ref = useRef(null)
const { type, labels=[], datasets=[], segments=[] } = chart
useChart(ref, (el) => {
if (type === "donut") {
return new ChartJS(el, {
type: "doughnut",
data: { labels: segments.map(s=>s.name), datasets: [{ data: segments.map(s=>s.value), backgroundColor: PAL, borderWidth:
0, hoverOffset: 4 }] },
options: { responsive:true, maintainAspectRatio:false, cutout:"68%", animation:{duration:500}, plugins:{ legend:{
display:true, position:"right", labels:{font:{size:11},color:"#475569",boxWidth:10,padding:10} }, tooltip:{
backgroundColor:"#1E293B",titleColor:"#94A3B8",bodyColor:"#F8FAFC",padding:10,cornerRadius:8 } } }
})
}
if (type === "hbar") {
return new ChartJS(el, {
type: "bar",
data: { labels, datasets: datasets.map((d,i)=>({ data:d.values,
backgroundColor:labels.map((_,j)=>rgba(PAL[j%PAL.length],.75)),
hoverBackgroundColor:labels.map((_,j)=>PAL[j%PAL.length]), borderRadius:4, borderSkipped:false })) },
options: { ...chartBase(), indexAxis:"y", scales:{ x:{
grid:{color:"rgba(0,0,0,.05)"},border:{display:false},ticks:{color:"#94A3B8",font:{size:10}} }, y:{
grid:{display:false},border:{display:false},ticks:{color:"#334155",font:{size:11,weight:"500"}} } } }
})
}
if (type === "area") {
const ctx = el.getContext("2d")
return new ChartJS(el, {
type: "line",
data: { labels, datasets: datasets.map((d,i) => {
const c = PAL[i%PAL.length], g = ctx.createLinearGradient(0,0,0,260)
g.addColorStop(0,rgba(c,.15)); g.addColorStop(1,rgba(c,0))
return { label:d.label, data:d.values, borderColor:c, backgroundColor:g, fill:true, borderWidth:2, tension:0.4,
pointRadius:3, pointBackgroundColor:c, pointBorderWidth:0 }
}) },
options: { ...chartBase(), plugins:{ ...chartBase().plugins, legend:{ display:datasets.length>1, position:"top",
labels:{font:{size:10},color:"#64748B",boxWidth:9,padding:10} } } }
})
}
if (type === "line") {
return new ChartJS(el, {
type: "line",
data: { labels, datasets: datasets.map((d,i)=>({ label:d.label, data:d.values, borderColor:PAL[i%PAL.length],
backgroundColor:"transparent", borderWidth:2.5, tension:0.4, pointRadius:3, pointBackgroundColor:PAL[i%PAL.length],
pointBorderWidth:0 })) },
options: { ...chartBase(), plugins:{ ...chartBase().plugins, legend:{ display:datasets.length>1, position:"top",
labels:{font:{size:10},color:"#64748B",boxWidth:9,padding:10} } } }
})
}
// bar default
return new ChartJS(el, {
type: "bar",
data: { labels, datasets: datasets.map((d,i)=>({ label:d.label, data:d.values,
backgroundColor:rgba(PAL[i%PAL.length],.75), hoverBackgroundColor:PAL[i%PAL.length], borderRadius:5, borderSkipped:false
})) },
options: { ...chartBase(), plugins:{ ...chartBase().plugins, legend:{ display:datasets.length>1, position:"top",
labels:{font:{size:10},color:"#64748B",boxWidth:9,padding:10} } } }
})
}, [JSON.stringify(chart)])
const h = type==="hbar" ? Math.max(180, (labels.length||4)*38) : 260
return (
<div style={{ margin:"20px 0" }}>
<p style={{
fontSize:12,fontWeight:600,color:"#64748B",textTransform:"uppercase",letterSpacing:"0.06em",marginBottom:10 }}>
{chart.title}
</p>
<div style={{ height:h, background:"#FAFBFC", borderRadius:10, padding:"16px", border:"1px solid #E8ECF0" }}>
<canvas ref={ref} />
</div>
{chart.caption && (
<p style={{ fontSize:12,color:"#94A3B8",marginTop:8,fontStyle:"italic",lineHeight:1.5 }}>
Hình: {chart.caption}
</p>
)}
</div>
)
}
function InlineTable({ table }) {
const { columns=[], rows=[] } = table
return (
<div style={{ margin:"20px 0" }}>
{table.title && <p style={{
fontSize:12,fontWeight:600,color:"#64748B",textTransform:"uppercase",letterSpacing:"0.06em",marginBottom:10 }}>
{table.title}</p>}
<div style={{ overflowX:"auto", border:"1px solid #E8ECF0", borderRadius:10 }}>
<table style={{ width:"100%", borderCollapse:"collapse", fontSize:13 }}>
<thead>
<tr style={{ background:"#F8FAFC" }}>
{columns.map((c,i) => <th key={i} style={{ textAlign:"left",padding:"10px 16px",color:"#475569",fontWeight:600,fontSize:11,borderBottom:"1px solid #E8ECF0",whiteSpace:"nowrap" }}>{c}</th>)}
</tr>
</thead>
<tbody>
{rows.map((row,i) => (
<tr key={i} style={{ borderBottom:i<rows.length-1?"1px solid #F1F5F9":"none" }} onMouseEnter={e=>
e.currentTarget.style.background="#F8FAFC"}
onMouseLeave={e=>e.currentTarget.style.background="transparent"}>
{row.map((cell,j) => <td key={j} style={{ padding:"10px 16px",color:j===0?"#0F172A":"#475569",fontWeight:j===0?600:400 }}>{cell}</td>)}
</tr>
))}
</tbody>
</table>
</div>
{table.caption && <p style={{ fontSize:12,color:"#94A3B8",marginTop:8,fontStyle:"italic" }}>Bng: {table.caption}
</p>}
</div>
)
}
function Callout({ type, text }) {
const styles = {
info: { bg:"#EFF6FF", border:"#BFDBFE", icon:"ℹ", iconBg:"#DBEAFE", iconColor:"#1D4ED8", textColor:"#1E40AF" },
warning: { bg:"#FFFBEB", border:"#FDE68A", icon:"!", iconBg:"#FEF3C7", iconColor:"#D97706", textColor:"#92400E" },
success: { bg:"#F0FDF4", border:"#BBF7D0", icon:"✓", iconBg:"#DCFCE7", iconColor:"#15803D", textColor:"#14532D" },
}
const s = styles[type]||styles.info
return (
<div style={{ background:s.bg, border:`1px solid ${s.border}`, borderRadius:10, padding:"14px 16px", margin:"20px 0",
display:"flex", gap:12, alignItems:"flex-start" }}>
<div style={{
width:22,height:22,borderRadius:"50%",background:s.iconBg,display:"flex",alignItems:"center",justifyContent:"center",flexShrink:0,fontSize:11,fontWeight:700,color:s.iconColor
}}>{s.icon}</div>
<p style={{ fontSize:13,color:s.textColor,lineHeight:1.6,margin:0 }}>{text}</p>
</div>
)
}
// ─── REPORT DOCUMENT ─────────────────────────────────────────────────────────
function ReportDoc({ report, onExport }) {
const now = new Date().toLocaleDateString("vi-VN", { year:"numeric", month:"long", day:"numeric" })
const TREND = { up:"↑", down:"↓", neutral:"→" }
const TREND_COLOR = { up:"#059669", down:"#DC2626", neutral:"#64748B" }
return (
<div style={{ maxWidth:780, margin:"0 auto", padding:"32px 24px 60px", fontFamily:"'Georgia',serif" }}>
{/* ── Cover ── */}
<div style={{ borderBottom:"3px solid #0F172A", paddingBottom:32, marginBottom:40 }}>
<div style={{ display:"flex", justifyContent:"space-between", alignItems:"flex-start", marginBottom:28 }}>
<div style={{ display:"flex", alignItems:"center", gap:10 }}>
<div style={{
width:36,height:36,borderRadius:9,background:"linear-gradient(135deg,#1E40AF,#4338CA)",display:"flex",alignItems:"center",justifyContent:"center",fontSize:16,color:"white"
}}></div>
<div>
<p style={{
fontSize:11,fontWeight:700,color:"#6366F1",textTransform:"uppercase",letterSpacing:"0.08em",margin:0
}}>ReportAI Agent</p>
<p style={{ fontSize:10,color:"#94A3B8",margin:0 }}>{report.organization||"Phòng Phân tích"}</p>
</div>
</div>
<div style={{ textAlign:"right" }}>
<p style={{ fontSize:10,color:"#94A3B8",margin:"0 0 2px" }}>Ngày xut bn</p>
<p style={{ fontSize:12,fontWeight:600,color:"#374151",margin:0 }}>{now}</p>
</div>
</div>
<div style={{ background:"linear-gradient(135deg,#F8FAFF,#EEF2FF)", borderRadius:14, padding:"28px 28px 24px",
border:"1px solid #E0E7FF" }}>
<div style={{
display:"inline-flex",alignItems:"center",gap:7,background:"#E0E7FF",borderRadius:100,padding:"4px 12px",marginBottom:14 }}>
<div style={{ width:6,height:6,borderRadius:"50%",background:"#4338CA" }} />
<span style={{
fontSize:10,fontWeight:700,color:"#3730A3",textTransform:"uppercase",letterSpacing:"0.07em" }}>Báo
cáo phân tích D liu tht</span>
</div>
<h1 style={{ fontSize:28,fontWeight:700,color:"#0F172A",lineHeight:1.25,margin:"0 0 10px",letterSpacing:"-0.02em",fontFamily:"'Georgia',serif" }}>{report.title}</h1>
<p style={{ fontSize:15,color:"#475569",margin:"0 0 20px",lineHeight:1.5,fontFamily:"system-ui,sans-serif"
}}>{report.subtitle}</p>
<div style={{ display:"flex", gap:20, flexWrap:"wrap" }}>
{[["Kỳ báo cáo",report.period],["Người lập",report.prepared_by]].map(([k,v])=>v&&(
<div key={k}>
<p style={{ fontSize:10,color:"#94A3B8",margin:"0 0 2px",textTransform:"uppercase",letterSpacing:"0.06em",fontFamily:"system-ui" }}>{k}</p>
<p style={{ fontSize:12.5,fontWeight:600,color:"#374151",margin:0,fontFamily:"system-ui" }}>{v}</p>
</div>
))}
</div>
</div>
</div>
{/* ── Highlights row ── */}
{report.highlights?.length>0 && (
<div style={{ display:"flex", gap:12, flexWrap:"wrap", marginBottom:36 }}>
{report.highlights.map((h,i) => (
<div key={i} style={{ flex:"1 1 120px",background:"#fff",borderRadius:10,padding:"14px 16px",border:"1px solid #E8ECF0",borderTop:`3px solid ${PAL[i%PAL.length]}` }}>
<p style={{
fontSize:9.5,fontWeight:700,color:"#94A3B8",textTransform:"uppercase",letterSpacing:"0.08em",margin:"0 0 6px",fontFamily:"system-ui" }}>{h.label}</p>
<p style={{ fontSize:22,fontWeight:800,color:"#0F172A",margin:"0 0 4px",letterSpacing:"-0.03em",fontFamily:"system-ui" }}>{h.value}</p>
<span style={{ fontSize:11,color:TREND_COLOR[h.trend]||"#64748B",fontFamily:"system-ui",fontWeight:600
}}>{TREND[h.trend]} {h.trend==="up"?"Tăng":h.trend==="down"?"Giảm":"Ổn định"}</span>
</div>
))}
</div>
)}
{/* ── Executive Summary ── */}
<div style={{ background:"#F8FAFF", borderLeft:"4px solid #4338CA", borderRadius:"0 10px 10px 0", padding:"20px 24px", marginBottom:36 }}>
<p style={{
fontSize:10,fontWeight:700,color:"#4338CA",textTransform:"uppercase",letterSpacing:"0.08em",margin:"0 0 10px",fontFamily:"system-ui" }}>Tóm tt điu hành</p>
<p style={{ fontSize:14,color:"#1E293B",lineHeight:1.8,margin:0 }}>{report.executive_summary}</p>
</div>
{/* ── Table of Contents ── */}
<div style={{ marginBottom:36, padding:"16px 20px", background:"#FAFBFC", borderRadius:10, border:"1px solid #E8ECF0" }}>
<p style={{
fontSize:10,fontWeight:700,color:"#64748B",textTransform:"uppercase",letterSpacing:"0.08em",margin:"0 0 12px",fontFamily:"system-ui" }}>Mc lc</p>
<div style={{ display:"flex", flexDirection:"column", gap:6 }}>
{report.sections?.map((s,i) => (
<div key={i} style={{ display:"flex", alignItems:"center", gap:10 }}>
<span style={{ fontSize:12,fontWeight:600,color:"#4338CA",fontFamily:"system-ui",width:20
}}>{s.number}.</span>
<span style={{ fontSize:13,color:"#334155",flex:1 }}>{s.title}</span>
<div style={{ flex:1,borderBottom:"1px dotted #CBD5E1",margin:"0 8px" }} />
<span style={{ fontSize:12,color:"#94A3B8",fontFamily:"system-ui" }}>{i+1+3}</span>
</div>
))}
</div>
</div>
{/* ── Sections ── */}
{report.sections?.map((section, si) => (
<div key={section.id||si} style={{ marginBottom:48 }}>
<div style={{ display:"flex", alignItems:"center", gap:14, marginBottom:18, paddingBottom:12, borderBottom:"1px solid #E8ECF0" }}>
<div style={{
width:36,height:36,borderRadius:10,background:`linear-gradient(135deg,${PAL[si%PAL.length]},${PAL[(si+1)%PAL.length]})`,display:"flex",alignItems:"center",justifyContent:"center",fontSize:14,fontWeight:800,color:"white",flexShrink:0,fontFamily:"system-ui"
}}>
{section.number}
</div>
<h2 style={{ fontSize:20,fontWeight:700,color:"#0F172A",margin:0,letterSpacing:"-0.01em" }}>{section.title}
</h2>
</div>
{section.paragraphs?.map((p, pi) => (
<p key={pi} style={{ fontSize:14,color:"#1E293B",lineHeight:1.85,marginBottom:14,textAlign:"justify" }}>{p}</p>
))}
{section.callout &&
<Callout type={section.callout.type} text={section.callout.text} />}
{section.chart &&
<InlineChart chart={section.chart} />}
{section.table &&
<InlineTable table={section.table} />}
</div>
))}
{/* ── Conclusion ── */}
{report.conclusion && (
<div style={{ marginBottom:36 }}>
<h2 style={{ fontSize:18,fontWeight:700,color:"#0F172A",marginBottom:14,paddingBottom:10,borderBottom:"1px solid #E8ECF0" }}>Kết lun</h2>
<p style={{ fontSize:14,color:"#1E293B",lineHeight:1.85 }}>{report.conclusion}</p>
</div>
)}
{/* ── Recommendations ── */}
{report.recommendations?.length>0 && (
<div style={{ background:"#F0FDF4", border:"1px solid #BBF7D0", borderRadius:14, padding:"22px 24px",
marginBottom:36 }}>
<p style={{
fontSize:10,fontWeight:700,color:"#15803D",textTransform:"uppercase",letterSpacing:"0.08em",margin:"0 0 14px",fontFamily:"system-ui" }}> Khuyến ngh</p>
<div style={{ display:"flex", flexDirection:"column", gap:10 }}>
{report.recommendations.map((r,i) => (
<div key={i} style={{ display:"flex", gap:12, alignItems:"flex-start" }}>
<div style={{
width:22,height:22,borderRadius:"50%",background:"#DCFCE7",display:"flex",alignItems:"center",justifyContent:"center",fontSize:10,fontWeight:700,color:"#15803D",flexShrink:0,marginTop:1,fontFamily:"system-ui"
}}>{i+1}</div>
<p style={{ fontSize:13.5,color:"#14532D",lineHeight:1.65,margin:0 }}>{r}</p>
</div>
))}
</div>
</div>
)}
{/* ── Footer ── */}
<div style={{ borderTop:"1px solid #E8ECF0", paddingTop:20, display:"flex", justifyContent:"space-between",
alignItems:"center" }}>
<p style={{ fontSize:11,color:"#94A3B8",fontFamily:"system-ui",margin:0 }}>Generated by ReportAI Agent · Powered by
Canifa AI Platform</p>
<button onClick={onExport} style={{ background:"#0F172A",color:"white",border:"none",borderRadius:8,padding:"8px 18px",fontSize:12,fontWeight:600,cursor:"pointer",fontFamily:"system-ui",display:"flex",alignItems:"center",gap:7
}}>
🖨 In / Lưu PDF
</button>
</div>
</div>
)
}
// ─── AGENT STEP CARD (shows tool calls in sidebar) ───────────────────────────
function AgentStep({ step }) {
const ICONS = {
thinking: "🧠",
plan_ready: "📋",
tool_call: "🔧",
tool_result: "📊",
section_ready: "📄",
report_complete: "✅",
reflect: "🔍",
error: "❌",
done: "🏁",
}
const icon = ICONS[step.type] || "•"
if (step.type === "tool_call") {
return (
<div style={{ padding:"7px 10px",background:"rgba(99,102,241,.12)",borderRadius:8,fontSize:11.5,color:"#C7D2FE",lineHeight:1.5 }}>
{icon} Gi <b style={{color:"#A5B4FC"}}>{step.tool}</b>
{step.params && Object.keys(step.params).length > 0 && (
<div style={{ fontSize:10,color:"#818CF8",marginTop:3,fontFamily:"monospace",wordBreak:"break-all" }}>
{JSON.stringify(step.params).slice(0, 120)}
</div>
)}
</div>
)
}
if (step.type === "tool_result") {
return (
<div style={{ padding:"7px 10px",background:step.success?"rgba(16,185,129,.12)":"rgba(239,68,68,.12)",borderRadius:8,fontSize:11.5,color:step.success?"#6EE7B7":"#FCA5A5",lineHeight:1.5 }}>
{step.success ? "✅" : "❌"} {step.tool}
{step.preview?.row_count != null && <span style={{color:"#A7F3D0"}}> {step.preview.row_count} rows</span>}
{step.preview?.summary?.total_traces != null && <span style={{color:"#A7F3D0"}}> {step.preview.summary.total_traces} traces</span>}
{step.preview?.error && <div style={{color:"#FCA5A5",fontSize:10,marginTop:2}}>{step.preview.error.slice(0,100)}</div>}
</div>
)
}
if (step.type === "plan_ready") {
return (
<div style={{ padding:"7px 10px",background:"rgba(99,102,241,.08)",borderRadius:8,fontSize:11.5,color:"#C7D2FE",lineHeight:1.5 }}>
📋 Kế hoch: <b>{step.tools_count} tools</b>{step.cycle ? ` · vòng ${step.cycle}` : ""}
{step.thinking && <div style={{fontSize:10,color:"#A5B4FC",marginTop:3,fontStyle:"italic"}}>{step.thinking.slice(0,150)}</div>}
</div>
)
}
if (step.type === "reflect") {
return (
<div style={{ padding:"7px 10px",background:step.data_sufficient?"rgba(16,185,129,.12)":"rgba(245,158,11,.12)",borderRadius:8,fontSize:11.5,color:step.data_sufficient?"#6EE7B7":"#FCD34D",lineHeight:1.5 }}>
{step.data_sufficient ? "✅" : "🔍"} Đánh giá vòng {step.cycle}: {step.data_sufficient ? <b>D liu đủ!</b> : <b>Cần thêm dữ liệu</b>}
{step.thinking && <div style={{fontSize:10,color:step.data_sufficient?"#A7F3D0":"#FDE68A",marginTop:3,fontStyle:"italic"}}>{step.thinking.slice(0,150)}</div>}
{step.missing?.length > 0 && <div style={{fontSize:10,color:"#FCA5A5",marginTop:2}}>Thiếu: {step.missing.join(", ").slice(0,100)}</div>}
{step.quality_issues?.length > 0 && <div style={{fontSize:10,color:"#FDBA74",marginTop:2}}>⚠️ {step.quality_issues.join(", ").slice(0,100)}</div>}
</div>
)
}
const text = step.step || step.message || (step.type === "done" ? "Hoàn thành!" : step.type)
return (
<div style={{ padding:"6px 10px",background:"rgba(255,255,255,.05)",borderRadius:8,fontSize:11.5,color:"#D1D5DB",lineHeight:1.5 }}>
{icon} {text}
</div>
)
}
// ─── GUIDE ────────────────────────────────────────────────────────────────────
function Guide() {
const S = (props) => <div style={{marginBottom:40,...props.style}}>{props.children}</div>
const Label = ({children}) => <div style={{display:"inline-flex",alignItems:"center",gap:8,fontSize:11,fontWeight:700,textTransform:"uppercase",letterSpacing:"0.1em",color:"#6366F1",marginBottom:12}}><span style={{width:8,height:8,borderRadius:"50%",background:"#6366F1"}}/>{children}</div>
const Card = ({icon,bg,border,title,desc,sub}) => <div style={{background:"#fff",border:`1.5px solid ${border||'#E2E8F0'}`,borderRadius:14,padding:20}}><div style={{width:40,height:40,borderRadius:10,background:bg,border:`1px solid ${border}`,display:"flex",alignItems:"center",justifyContent:"center",fontSize:18,marginBottom:12}}>{icon}</div><h3 style={{fontSize:14,fontWeight:700,color:"#0F172A",marginBottom:6}}>{title}</h3><p style={{fontSize:12.5,color:"#64748B",lineHeight:1.6}}>{desc}</p>{sub&&<p style={{marginTop:8,fontSize:11,color:"#94A3B8"}}>{sub}</p>}</div>
const FlowStep = ({icon,bg,border,title,desc,tags}) => <div style={{display:"flex",gap:16,alignItems:"flex-start",marginBottom:8}}><div style={{width:44,height:44,borderRadius:12,background:bg,border:`1.5px solid ${border}`,display:"flex",alignItems:"center",justifyContent:"center",fontSize:18,flexShrink:0}}>{icon}</div><div style={{flex:1,paddingBottom:20}}><h3 style={{fontSize:14,fontWeight:700,color:"#0F172A",marginBottom:4}}>{title}</h3><p style={{fontSize:12.5,color:"#64748B",lineHeight:1.6}}>{desc}</p>{tags&&<div style={{display:"flex",gap:6,marginTop:6,flexWrap:"wrap"}}>{tags.map((t,i)=><span key={i} style={{fontSize:10,fontWeight:700,padding:"3px 8px",borderRadius:6,background:t.bg,color:t.color,textTransform:"uppercase",letterSpacing:"0.03em"}}>{t.text}</span>)}</div>}</div></div>
const schemaRows = [
["trace_id","VARCHAR","ID unique của trace"],
["session_id","VARCHAR","ID phiên hội thoại"],
["customer_id","VARCHAR","Mã khách hàng"],
["device_id","VARCHAR","ID thiết bị user"],
["model_name","VARCHAR","Model LLM (gpt-5.2-codex, gemini-3.1-flash-lite...)"],
["trace_latency","DECIMAL","Tổng latency trace (giây)"],
["user_latency","DECIMAL","Latency user cảm nhận (giây)"],
["input_cost","DECIMAL","Chi phí input tokens (USD)"],
["output_cost","DECIMAL","Chi phí output tokens (USD)"],
["total_cost","DECIMAL","Tổng chi phí (USD)"],
["total_obs","BIGINT","Tổng observations"],
["total_obs_error","BIGINT","Observations bị lỗi"],
["nb_generation_error","BIGINT","Số lỗi generation"],
["is_guest","TINYINT","1=khách vãng lai, 0=đã login"],
["traced_at","DATETIME","Thời điểm trace xảy ra"],
]
const typeColor = {VARCHAR:"#4338CA",DECIMAL:"#059669",BIGINT:"#D97706",DATETIME:"#E11D48",TINYINT:"#0284C7"}
const typeBg = {VARCHAR:"#EEF2FF",DECIMAL:"#ECFDF5",BIGINT:"#FEF3C7",DATETIME:"#FFF1F2",TINYINT:"#F0F9FF"}
return (
<div style={{maxWidth:880,margin:"0 auto",padding:"40px 28px 80px",fontFamily:"'Inter',system-ui,sans-serif"}}>
{/* Hero */}
<div style={{background:"linear-gradient(135deg,#0F172A,#1E293B 50%,#312E81)",borderRadius:20,padding:"48px 36px 40px",textAlign:"center",color:"white",marginBottom:40,position:"relative",overflow:"hidden"}}>
<div style={{position:"absolute",inset:0,background:"radial-gradient(circle at 30% 20%, rgba(99,102,241,.2) 0%, transparent 50%), radial-gradient(circle at 70% 80%, rgba(59,130,246,.15) 0%, transparent 50%)"}} />
<div style={{position:"relative"}}>
<div style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",background:"rgba(99,102,241,.2)",border:"1px solid rgba(99,102,241,.3)",borderRadius:100,fontSize:12,fontWeight:600,color:"#A5B4FC",marginBottom:18}}>📋 Documentation · v2.0</div>
<h1 style={{fontSize:28,fontWeight:800,letterSpacing:"-0.03em",marginBottom:10}}>AI Report Agent</h1>
<p style={{fontSize:14,color:"#94A3B8",maxWidth:550,margin:"0 auto",lineHeight:1.7}}>Hướng dn chi tiết v lung hot động, kiến trúc ReAct Reflect Loop, và các công c mà Agent s dng.</p>
</div>
</div>
{/* Overview */}
<S><Label>Tng quan</Label>
<h2 style={{fontSize:20,fontWeight:800,color:"#0F172A",letterSpacing:"-0.02em",marginBottom:14}}>Agent hot động như thế nào?</h2>
<p style={{fontSize:13.5,color:"#475569",lineHeight:1.7,marginBottom:14}}>AI Report Agent s dng kiến trúc <b>ReAct (Reasoning + Acting)</b> với cơ chế <b>Reflect Loop</b>. Thay vì chy cng mt pipeline, Agent t suy nghĩ, query d liu, đánh giá xem đã đủ chưa, và t quyết định khi nào dng li để viết báo cáo.</p>
<div style={{padding:"14px 18px",background:"#EEF2FF",border:"1px solid #E0E7FF",borderRadius:12,fontSize:13,color:"#3730A3",display:"flex",gap:12,alignItems:"flex-start"}}>
<span style={{fontSize:18,flexShrink:0}}>💡</span>
<div><b>Ti sao dùng Reflect Loop?</b><br/>Pipeline truyn thng (Planner Tools Writer) s quyết định tt c tools t đầu. Nếu 1 tool li hoc data thiếu, không có cơ chế b sung. Reflect Loop cho phép Agent t đánh giá và query thêm khi cn.</div>
</div>
</S>
{/* Flow */}
<S><Label>Lung hot động</Label>
<h2 style={{fontSize:20,fontWeight:800,color:"#0F172A",letterSpacing:"-0.02em",marginBottom:14}}>ReAct Reflect Loop Chi tiết</h2>
<div style={{background:"#fff",border:"1.5px solid #E2E8F0",borderRadius:16,padding:"24px 28px",marginBottom:24}}>
<pre style={{fontFamily:"'Inter',system-ui",fontSize:12.5,color:"#475569",lineHeight:2.1,whiteSpace:"pre-wrap"}}>{` 📝 User nhập yêu cầu báo cáo
🧠 THINK — Agent phân tích, chọn tools + viết SQL
🔧 ACT — Execute tools song song (asyncio.gather)
👁️ OBSERVE — Thu thập kết quả từ các tools
🔍 REFLECT — LLM tự đánh giá:
│ • Dữ liệu đã đủ chưa?
│ • Chất lượng data có ổn không?
│ • Còn thiếu góc nhìn nào?
├── ❌ Chưa đủ → Quay lại THINK (max 3 vòng)
└── ✅ Đủ rồi → Tiếp tục xuống
✍️ WRITE — LLM viết báo cáo hoàn chỉnh từ data thật
📄 Stream sections lên frontend
🏁 DONE!`}</pre>
</div>
<FlowStep icon="🧠" bg="linear-gradient(135deg,#EEF2FF,#E0E7FF)" border="#C7D2FE" title="Phase 1: THINK — Phân tích & Lên kế hoạch" desc="Agent nhận yêu cầu từ user, phân tích cần data gì, tạo SQL queries phù hợp. Chọn 2-4 tools đa dạng cho batch đầu." tags={[{text:"LLM Call #1",bg:"#EEF2FF",color:"#4338CA"}]} />
<FlowStep icon="🔧" bg="linear-gradient(135deg,#FEF3C7,#FDE68A)" border="#FCD34D" title="Phase 2: ACT — Execute Tools Song Song" desc="Tất cả tools trong batch được execute đồng thời bằng asyncio.gather(). Nhanh gấp 3-4x so với tuần tự." tags={[{text:"Parallel I/O",bg:"#ECFDF5",color:"#059669"}]} />
<FlowStep icon="🔍" bg="linear-gradient(135deg,#FFF7ED,#FFEDD5)" border="#FDBA74" title="Phase 3: REFLECT — Tự đánh giá dữ liệu" desc="LLM xem lại toàn bộ data đã thu thập. Nếu thiếu → tạo thêm queries. Nếu đủ → chuyển sang viết." tags={[{text:"LLM Call #2",bg:"#FEF3C7",color:"#D97706"},{text:"Max 3 vòng",bg:"#FFF1F2",color:"#E11D48"}]} />
<FlowStep icon="✍️" bg="linear-gradient(135deg,#ECFDF5,#D1FAE5)" border="#6EE7B7" title="Phase 4: WRITE — Viết báo cáo hoàn chỉnh" desc="LLM nhận toàn bộ data thật → viết báo cáo JSON: executive summary, highlights, 4-6 sections (chart/table), conclusion, recommendations." tags={[{text:"LLM Call #3",bg:"#ECFDF5",color:"#059669"}]} />
</S>
{/* Tools */}
<S><Label>Công c d liu</Label>
<h2 style={{fontSize:20,fontWeight:800,color:"#0F172A",letterSpacing:"-0.02em",marginBottom:14}}>4 Tools Agent s dng</h2>
<p style={{fontSize:13.5,color:"#475569",marginBottom:16}}>Agent có quyn truy cp 4 tools để thu thp d liu. Mi tool ch được phép <b>SELECT</b> (read-only).</p>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:14}}>
<Card icon="📊" bg="#EEF2FF" border="#E0E7FF" title="sql_langfuse" desc="Query bảng analytic.chatbot_rsa_trace_event_detail — traces, latency, costs, models, errors." sub="👉 Hiệu suất chatbot, chi phí LLM, user analytics" />
<Card icon="🏷️" bg="#FEF3C7" border="#FDE68A" title="sql_starrocks" desc="Query bảng sản phẩm — tên, giá, số lượng bán, màu, chất liệu, mùa." sub="👉 Phân tích sản phẩm, doanh thu, thống kê catalog" />
<Card icon="💬" bg="#ECFDF5" border="#A7F3D0" title="sql_postgres" desc="Query chat histories — tin nhắn user, bot, timestamp, identity." sub="👉 Phân tích hội thoại, volume chat, engagement" />
<Card icon="🧮" bg="#FFF1F2" border="#FECDD3" title="calculator" desc="Tính toán math: cộng, trừ, nhân, chia, phần trăm. Chỉ nhận digits + operators." sub="👉 Tỉ lệ tăng trưởng, % thay đổi, trung bình" />
</div>
</S>
{/* Schema */}
<S><Label>Schema</Label>
<h2 style={{fontSize:20,fontWeight:800,color:"#0F172A",letterSpacing:"-0.02em",marginBottom:14}}>Bng Langfuse trên StarRocks</h2>
<p style={{fontSize:13.5,color:"#475569",marginBottom:14}}>Bng <code style={{background:"#F1F5F9",padding:"2px 6px",borderRadius:4,fontSize:11,color:"#6366F1"}}>analytic.chatbot_rsa_trace_event_detail</code> — chứa toàn bộ traces từ Langfuse.</p>
<table style={{width:"100%",borderCollapse:"collapse",fontSize:12.5}}>
<thead><tr style={{background:"#F8FAFC"}}><th style={{textAlign:"left",padding:"8px 12px",fontWeight:700,color:"#475569",borderBottom:"2px solid #E2E8F0"}}>Ct</th><th style={{textAlign:"left",padding:"8px 12px",fontWeight:700,color:"#475569",borderBottom:"2px solid #E2E8F0"}}>Kiểu</th><th style={{textAlign:"left",padding:"8px 12px",fontWeight:700,color:"#475569",borderBottom:"2px solid #E2E8F0"}}>Mô t</th></tr></thead>
<tbody>{schemaRows.map(([col,type,desc],i) => <tr key={i}><td style={{padding:"8px 12px",borderBottom:"1px solid #F1F5F9",color:"#334155"}}><code style={{background:"#F1F5F9",padding:"2px 6px",borderRadius:4,fontSize:11}}>{col}</code></td><td style={{padding:"8px 12px",borderBottom:"1px solid #F1F5F9"}}><span style={{fontSize:10,fontWeight:600,padding:"2px 8px",borderRadius:4,fontFamily:"monospace",background:typeBg[type],color:typeColor[type]}}>{type}</span></td><td style={{padding:"8px 12px",borderBottom:"1px solid #F1F5F9",color:"#334155"}}>{desc}</td></tr>)}</tbody>
</table>
</S>
{/* Tech */}
<S><Label>Tech Stack</Label>
<h2 style={{fontSize:20,fontWeight:800,color:"#0F172A",letterSpacing:"-0.02em",marginBottom:14}}>Kiến trúc k thut</h2>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:14}}>
<Card icon="🖥️" bg="#F8FAFC" border="#E2E8F0" title="Frontend" desc="React 18 (UMD) + Babel + Chart.js 4. SSE streaming qua fetch() + ReadableStream." />
<Card icon="⚡" bg="#F8FAFC" border="#E2E8F0" title="Backend" desc="FastAPI + StreamingResponse (SSE). Async tools via asyncio.gather(). POST /api/report-agent" />
<Card icon="🤖" bg="#F8FAFC" border="#E2E8F0" title="LLM" desc="Codex GPT-5.3. 2-3 LLM calls/report: Think + Reflect + Write. Custom system prompts." />
<Card icon="🗄️" bg="#F8FAFC" border="#E2E8F0" title="Databases" desc="StarRocks (products + Langfuse) + PostgreSQL (chat). Read-only, SQL injection protected." />
</div>
</S>
</div>
)
}
// ─── EMPTY ────────────────────────────────────────────────────────────────────
function Empty({ onSuggest, onShowGuide }) {
return (
<div style={{
flex:1,display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",gap:24,padding:40,background:"#FAFBFD"
}}>
<div style={{ width:72,height:72,borderRadius:22,background:"linear-gradient(135deg,#EEF2FF,#F5F3FF)",border:"1px solid #E0E7FF",display:"flex",alignItems:"center",justifyContent:"center",fontSize:32 }}>📋</div>
<div style={{ textAlign:"center",maxWidth:460 }}>
<p style={{
fontSize:20,fontWeight:800,color:"#0F172A",letterSpacing:"-0.02em",marginBottom:8,fontFamily:"'Georgia',serif"
}}>AI Report Agent</p>
<p style={{ fontSize:13.5,color:"#64748B",lineHeight:1.6,fontFamily:"system-ui" }}>
Mô t ch đề báo cáo Agent s t query d liu tht<br />t Langfuse, StarRocks, Postgres ri viết báo
cáo hoàn chnh
</p>
</div>
<div style={{ display:"grid",gridTemplateColumns:"1fr 1fr",gap:10,width:"100%",maxWidth:540 }}>
{SUGGESTIONS.map((s,i) => (
<button key={i} onClick={()=>onSuggest(s)} style={{ background:"#fff",border:"1.5px solid #E2E8F0",borderRadius:11,padding:"13px 15px",fontSize:12.5,color:"#475569",cursor:"pointer",textAlign:"left",lineHeight:1.5,fontFamily:"system-ui",transition:"all .15s" }} onMouseEnter={e=>{e.currentTarget.style.borderColor="#C7D2FE";e.currentTarget.style.color="#4338CA";e.currentTarget.style.background="#FAFBFF"}} onMouseLeave={e=>{e.currentTarget.style.borderColor="#E2E8F0";e.currentTarget.style.color="#475569";e.currentTarget.style.background="#fff"}}>
{s}
</button>
))}
</div>
<button onClick={onShowGuide} style={{marginTop:8,display:"inline-flex",alignItems:"center",gap:6,padding:"10px 20px",background:"#F8FAFC",border:"1.5px solid #E2E8F0",borderRadius:10,fontSize:12.5,fontWeight:600,color:"#6366F1",cursor:"pointer",transition:"all .15s"}} onMouseEnter={e=>{e.currentTarget.style.background="#EEF2FF";e.currentTarget.style.borderColor="#C7D2FE"}} onMouseLeave={e=>{e.currentTarget.style.background="#F8FAFC";e.currentTarget.style.borderColor="#E2E8F0"}}>
📖 Xem hướng dn Agent hot động
</button>
</div>
)
}
// ─── MAIN ─────────────────────────────────────────────────────────────────────
const SMIN=52, SDEF=340, SMAX=500
function ReportAI() {
const [messages, setMessages] = useState([{role:"assistant",content:"👋 Xin chào!\n\nTôi là AI Report Agent — tạo báo cáo từ dữ liệu thật:\n\n• 📊 Query Langfuse: traces, scores, costs\n• 🗄️ Query StarRocks: sản phẩm, doanh thu\n• 💬 Query Postgres: lịch sử chat\n• 📈 Tạo biểu đồ + bảng số liệu\n\nChọn gợi ý bên phải hoặc nhập chủ đề →"}])
const [steps, setSteps] = useState([]) // Agent steps for sidebar
const [input, setInput] = useState("")
const [loading, setLoading] = useState(false)
const [report, setReport] = useState(null)
const [showGuide, setShowGuide] = useState(false)
const [sideW, setSideW] = useState(SDEF)
const [collapsed, setCollapsed] = useState(false)
const bottomRef = useRef(null)
const dragging = useRef(false), dragX = useRef(0), startW = useRef(0)
const abortRef = useRef(null)
useEffect(() => { bottomRef.current?.scrollIntoView({behavior:"smooth"}) }, [messages, steps, loading])
const onMouseDown = useCallback(e => {
if (collapsed) return
dragging.current=true; dragX.current=e.clientX; startW.current=sideW; e.preventDefault()
}, [collapsed, sideW])
useEffect(() => {
const mv = e => { if(!dragging.current) return; setSideW(Math.max(SMIN+1, Math.min(SMAX,
startW.current+(e.clientX-dragX.current)))) }
const up = () => { dragging.current=false }
window.addEventListener("mousemove",mv); window.addEventListener("mouseup",up)
return () => { window.removeEventListener("mousemove",mv); window.removeEventListener("mouseup",up) }
}, [])
// ─── SSE STREAMING ──────────────────────────────────────────────────────────
const send = async (text) => {
const msg = text||input
if (!msg.trim()||loading) return
setInput(""); setLoading(true); setReport(null); setSteps([])
if (collapsed) setCollapsed(false)
const uMsgs = [...messages,{role:"user",content:msg}]; setMessages(uMsgs)
try {
const abort = new AbortController()
abortRef.current = abort
const res = await fetch("/api/report-agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question: msg }),
signal: abort.signal,
})
if (!res.ok) {
throw new Error(`Server error: ${res.status}`)
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() || ""
for (const line of lines) {
if (!line.startsWith("data: ")) continue
const jsonStr = line.slice(6).trim()
if (!jsonStr) continue
try {
const evt = JSON.parse(jsonStr)
// Add to steps sidebar
if (["thinking","plan_ready","tool_call","tool_result","reflect"].includes(evt.type)) {
setSteps(prev => [...prev, evt])
}
// Progressive section rendering
if (evt.type === "section_ready") {
setReport(prev => {
if (!prev) return { sections: [evt.section] }
return { ...prev, sections: [...(prev.sections||[]), evt.section] }
})
setSteps(prev => [...prev, { type: "section_ready", step: `📄 Section ${evt.index + 1}: ${evt.section?.title || ""}` }])
}
// Full report
if (evt.type === "report_complete") {
setReport(evt.report)
const rpt = evt.report
const numSec = rpt.sections?.length||0
const numChart = rpt.sections?.filter(s=>s.chart).length||0
const numTable = rpt.sections?.filter(s=>s.table).length||0
setMessages(prev => [...prev, {
role:"assistant",
content:` Báo cáo **"${rpt.title}"** hoàn thành!\n\n${numSec} chương · ${numChart} biu đồ · ${numTable} bng\n\nD liu tht t Langfuse + DB.`
}])
}
if (evt.type === "error") {
setMessages(prev => [...prev, {role:"assistant", content:` Li: ${evt.message}`}])
setSteps(prev => [...prev, evt])
}
if (evt.type === "done") {
setSteps(prev => [...prev, { type: "done" }])
}
} catch (parseErr) {
console.warn("SSE parse error:", parseErr, jsonStr)
}
}
}
} catch(e) {
if (e.name !== "AbortError") {
setMessages(prev => [...prev, {role:"assistant", content:`❌ Lỗi: ${e.message}`}])
}
}
setLoading(false)
abortRef.current = null
}
const handleExport = () => {
window.print()
}
const handleReset = () => {
if (abortRef.current) abortRef.current.abort()
setReport(null)
setSteps([])
setMessages(messages.slice(0,1))
setLoading(false)
}
const bot = <div
style={{width:26,height:26,borderRadius:8,background:"linear-gradient(135deg,#1E40AF,#4338CA)",display:"flex",alignItems:"center",justifyContent:"center",flexShrink:0,fontSize:11,color:"white",fontFamily:"system-ui"}}>
</div>
return (
<div style={{display:"flex",height:"100vh",fontFamily:"system-ui,sans-serif",background:"#F0F2F8"}}>
<style>
{
` @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Georgia&display=swap');
* {
box-sizing: border-box;
margin: 0;
padding: 0
}
::-webkit-scrollbar {
width: 5px
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, .1);
border-radius: 4px
}
.sb-scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, .1)
}
@keyframes pulse {
0%,100% { opacity: .3 }
50% { opacity: 1 }
}
@keyframes pop {
from { opacity: 0; transform: translateY(5px) }
to { opacity: 1; transform: translateY(0) }
}
@keyframes spin {
to { transform: rotate(360deg) }
}
@keyframes fadeIn {
from { opacity: 0 }
to { opacity: 1 }
}
.msg-row { animation: pop .2s ease }
textarea:focus, button:focus { outline: none }
button { cursor: pointer }
@media print {
.no-print { display: none !important }
body { background: white }
.report-area { overflow: visible !important; height: auto !important }
}
`
}
</style>
{/* ── Sidebar ── */}
<div className="no-print"
style={{width:collapsed?SMIN:sideW,flexShrink:0,background:"#111827",display:"flex",flexDirection:"column",transition:collapsed?"width .25s ease":"none",overflow:"hidden",borderRight:"1px solid rgba(255,255,255,.06)"}}>
{/* header */}
<div style={{height:54,display:"flex",alignItems:"center",padding:"0 13px",gap:10,borderBottom:"1px solid rgba(255,255,255,.06)",flexShrink:0}}>
{collapsed ? (
<button onClick={()=>setCollapsed(false)}
style={{width:36,height:36,borderRadius:10,background:"linear-gradient(135deg,#1E40AF,#4338CA)",border:"none",display:"flex",alignItems:"center",justifyContent:"center",margin:"0 auto",fontSize:14,color:"white"}}></button>
) : (
<React.Fragment>
<div
style={{width:34,height:34,borderRadius:10,background:"linear-gradient(135deg,#1E40AF,#4338CA)",display:"flex",alignItems:"center",justifyContent:"center",flexShrink:0,fontSize:14,color:"white"}}>
</div>
<div style={{flex:1,minWidth:0}}>
<p style={{fontSize:13,fontWeight:700,color:"#F9FAFB",letterSpacing:"-0.01em"}}>ReportAI Agent</p>
<div style={{display:"flex",alignItems:"center",gap:4,marginTop:1}}>
<div style={{width:5,height:5,borderRadius:"50%",background:loading?"#FBBF24":"#4ADE80",animation:"pulse 2.5s ease-in-out infinite"}} />
<span style={{fontSize:10,color:loading?"#FBBF24":"#4ADE80",fontWeight:500}}>{loading?"Working...":"Codex · Ready"}</span>
</div>
</div>
<button onClick={()=>setCollapsed(true)}
style={{background:"none",border:"none",fontSize:18,width:28,height:28,borderRadius:6,display:"flex",alignItems:"center",justifyContent:"center",color:"#4B5563"}}></button>
</React.Fragment>
)}
</div>
{/* messages + agent steps */}
{!collapsed && (
<div className="sb-scroll" style={{flex:1,overflowY:"auto",padding:"14px 12px",display:"flex",flexDirection:"column",gap:8}}>
{messages.map((m,i) => (
<div key={`m${i}`} className="msg-row" style={{display:"flex",flexDirection:m.role==="user"
?"row-reverse":"row",gap:8,alignItems:"flex-end"}}>
{m.role==="assistant"&&bot}
<div style={{maxWidth:"83%",padding:"9px 13px",borderRadius:m.role==="user" ?"13px 13px 3px 13px":"13px 13px 13px 3px",background:m.role==="user"
?"linear-gradient(135deg,#1E40AF,#4338CA)":"rgba(255,255,255,.07)",fontSize:12.5,lineHeight:1.6,color:m.role==="user"
?"#fff":"#D1D5DB",whiteSpace:"pre-wrap"}}>
{m.content.replace(/\*\*(.*?)\*\*/g,"$1")}
</div>
</div>
))}
{/* Agent steps */}
{steps.length > 0 && (
<div style={{display:"flex",flexDirection:"column",gap:5,marginTop:4}}>
<p style={{fontSize:9,fontWeight:700,color:"#4B5563",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:2}}>Agent Steps</p>
{steps.map((s,i) => <AgentStep key={`s${i}`} step={s} />)}
</div>
)}
{loading && (
<div className="msg-row" style={{display:"flex",gap:8,alignItems:"flex-end"}}>
{bot}
<div style={{padding:"11px 14px",background:"rgba(255,255,255,.07)",borderRadius:"13px 13px 13px 3px",display:"flex",gap:5,alignItems:"center"}}>
{[0,1,2].map(i=>
<div key={i} style={{width:5,height:5,borderRadius:"50%",background:"#3B82F6",animation:`pulse 1.4s
ease-in-out ${i*.2}s infinite`}} />)}
</div>
</div>
)}
<div ref={bottomRef} />
</div>
)}
{/* collapsed icons */}
{collapsed && (
<div style={{flex:1,display:"flex",flexDirection:"column",alignItems:"center",paddingTop:12,gap:8}}>
{["📋","📊","💬"].map((ic,i) => <button key={i} onClick={()=>setCollapsed(false)}
style={{width:34,height:34,borderRadius:8,background:"rgba(255,255,255,.05)",border:"none",fontSize:14}}>{ic}</button>)}
</div>
)}
{/* input */}
{!collapsed && (
<div style={{padding:"11px 12px",borderTop:"1px solid rgba(255,255,255,.06)",flexShrink:0}}>
<div style={{background:"rgba(255,255,255,.06)",borderRadius:12,padding:"8px 8px 8px 12px",border:"1px solid rgba(255,255,255,.09)",display:"flex",gap:8,alignItems:"flex-end"}}>
<textarea value={input} onChange={e=>setInput(e.target.value)} onKeyDown={e=>{if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();send()}}}
placeholder="Mô tả báo cáo cần tạo..." rows={2}
style={{flex:1,background:"transparent",border:"none",color:"#F9FAFB",fontSize:12.5,lineHeight:1.5,resize:"none",fontFamily:"inherit"}}/>
<button onClick={()=>send()} disabled={!input.trim()||loading}
style={{width:32,height:32,borderRadius:8,background:input.trim()&&!loading?"linear-gradient(135deg,#1E40AF,#4338CA)":"rgba(255,255,255,.07)",border:"none",display:"flex",alignItems:"center",justifyContent:"center",flexShrink:0,color:"white",fontSize:16,opacity:input.trim()&&!loading?1:.35}}></button>
</div>
<p style={{textAlign:"center",fontSize:9.5,color:"#1F2937",marginTop:5}}>Enter gi · Shift+Enter xung dòng</p>
</div>
)}
</div>
{/* drag handle */}
{!collapsed && (
<div className="no-print" onMouseDown={onMouseDown} style={{width:4,flexShrink:0,cursor:"col-resize",position:"relative",zIndex:10}}
onMouseEnter={e=>e.currentTarget.children[0].style.opacity=1}
onMouseLeave={e=>e.currentTarget.children[0].style.opacity=0}>
<div style={{position:"absolute",inset:0,background:"rgba(37,99,235,.6)",borderRadius:2,opacity:0,transition:"opacity .15s"}}/>
</div>
)}
{/* ── Report area ── */}
<div className="report-area" style={{flex:1,overflowY:"auto",minWidth:0,background:"#F0F2F8"}}>
{/* topbar */}
<div className="no-print" style={{height:52,background:"#fff",borderBottom:"1px solid #E8ECF0",display:"flex",alignItems:"center",padding:"0 20px",gap:12,flexShrink:0,position:"sticky",top:0,zIndex:5}}>
{collapsed && <button onClick={()=>setCollapsed(false)} style={{width:30,height:30,borderRadius:8,background:"#EEF2FF",border:"none",color:"#4338CA",fontSize:16,display:"flex",alignItems:"center",justifyContent:"center"}}></button>}
<div style={{width:28,height:28,borderRadius:8,background:"#EEF2FF",display:"flex",alignItems:"center",justifyContent:"center",fontSize:13,flexShrink:0}}>📋</div>
<div style={{minWidth:0,flex:1}}>
<p style={{fontWeight:700,color:"#0F172A",fontSize:13.5,whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis"}}>{report?.title||"AI Report Agent"}</p>
{report?.period && <p style={{fontSize:10.5,color:"#94A3B8",marginTop:1}}>K báo cáo: {report.period}</p>}
</div>
<div style={{display:"flex",gap:8,flexShrink:0}}>
<button onClick={()=>setShowGuide(!showGuide)} style={{background:showGuide?"#EEF2FF":"#F8FAFC",border:`1px solid ${showGuide?"#C7D2FE":"#E2E8F0"}`,borderRadius:8,padding:"0 12px",height:28,display:"flex",alignItems:"center",gap:5,color:showGuide?"#4338CA":"#64748B",fontSize:11.5,fontWeight:600,cursor:"pointer"}}>
📖 {showGuide?"Đóng":"Hướng dẫn"}
</button>
{report && (
<>
<span style={{fontSize:11,color:"#4338CA",background:"#EEF2FF",padding:"4px 12px",borderRadius:100,fontWeight:600,border:"1px solid #E0E7FF"}}>
{report.sections?.length||0} chương · {report.sections?.filter(s=>s.chart).length||0} biu đồ
</span>
<button onClick={handleExport} style={{background:"#0F172A",color:"white",border:"none",borderRadius:9,padding:"7px 14px",fontSize:12,fontWeight:600,display:"flex",alignItems:"center",gap:6}}>
<span style={{fontSize:13}}>🖨</span> Lưu PDF
</button>
<button onClick={handleReset} style={{background:"#F8FAFC",border:"1px solid #E2E8F0",borderRadius:8,width:28,height:28,display:"flex",alignItems:"center",justifyContent:"center",color:"#64748B",fontSize:13}}></button>
</>
)}
</div>
</div>
{/* content */}
{showGuide ? (
<div style={{background:"#fff",minHeight:"calc(100vh - 52px)",overflowY:"auto",animation:"fadeIn .3s ease"}}>
<Guide />
</div>
) : loading && !report ? (
<div style={{flex:1,display:"flex",alignItems:"center",justifyContent:"center",minHeight:"calc(100vh - 52px)"}}>
<div style={{textAlign:"center"}}>
<div style={{width:44,height:44,borderRadius:"50%",border:"3px solid #DBEAFE",borderTopColor:"#2563EB",animation:"spin .8s linear infinite",margin:"0 auto 16px"}}/>
<p style={{fontSize:14,color:"#374151",fontWeight:600,fontFamily:"'Georgia',serif"}}>Agent đang x lý...</p>
<p style={{fontSize:12,color:"#94A3B8",marginTop:6}}>Query d liu tht · Phân tích · Viết báo cáo</p>
</div>
</div>
) : !report ? (
<div style={{display:"flex",flex:1,minHeight:"calc(100vh - 52px)"}}>
<Empty onSuggest={send} onShowGuide={()=>setShowGuide(true)}/>
</div>
) : (
<div style={{background:"#fff",margin:"0",minHeight:"calc(100vh - 52px)",animation:"fadeIn .4s ease"}}>
<ReportDoc report={report} onExport={handleExport}/>
</div>
)}
</div>
</div>
)
}
ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(ReportAI));
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>AI Report Generator — Canifa</title>
<link rel="stylesheet" href="/static/css/report_template.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<style>
/* ── RESET for app UI (scoped, won't affect report) ── */
.app-ui, .app-ui * { box-sizing: border-box; }
.app-ui { font-family: 'Inter', system-ui, -apple-system, sans-serif; }
/* ── INPUT OVERLAY ── */
.input-overlay {
position: fixed; inset: 0; z-index: 1000;
background: linear-gradient(135deg, #0F172A 0%, #1E293B 50%, #312E81 100%);
display: flex; align-items: center; justify-content: center;
transition: opacity .5s ease, visibility .5s ease;
}
.input-overlay.hidden { opacity: 0; visibility: hidden; pointer-events: none; }
.input-card {
width: 560px; max-width: 92vw;
text-align: center; color: white;
}
.input-card h1 { font-size: 28px; font-weight: 800; letter-spacing: -0.03em; margin-bottom: 8px; color: white; font-family: 'Inter', sans-serif; }
.input-card p { font-size: 14px; color: #94A3B8; margin-bottom: 32px; line-height: 1.6; }
/* ── REPORT TEMPLATES ── */
.templates-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 24px;
}
.template-card {
background: rgba(255,255,255,.04); border: 1.5px solid rgba(255,255,255,.08);
border-radius: 12px; padding: 14px 16px; text-align: left; cursor: pointer;
transition: all .2s ease; display: flex; flex-direction: column; gap: 6px;
}
.template-card:hover {
background: rgba(99,102,241,.1); border-color: rgba(99,102,241,.4);
transform: translateY(-2px);
}
.tc-header { display: flex; align-items: center; gap: 8px; }
.tc-icon {
width: 28px; height: 28px; border-radius: 6px; background: rgba(255,255,255,.08);
display: flex; align-items: center; justify-content: center; font-size: 14px;
}
.tc-title { font-size: 13px; font-weight: 600; color: #F1F5F9; }
.tc-desc { font-size: 11px; color: #94A3B8; line-height: 1.4; }
.input-box {
display: flex; gap: 10px; align-items: flex-end;
background: rgba(255,255,255,.06); border: 1.5px solid rgba(255,255,255,.1);
border-radius: 14px; padding: 10px 12px;
}
.input-box textarea {
flex: 1; background: transparent; border: none; color: #F9FAFB;
font-size: 14px; line-height: 1.5; resize: none; font-family: inherit; outline: none;
}
.input-box button {
width: 40px; height: 40px; border-radius: 10px;
background: linear-gradient(135deg, #1E40AF, #4338CA);
border: none; color: white; font-size: 18px; cursor: pointer;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.input-box button:disabled { opacity: .3; cursor: default; }
/* ── PROGRESS BAR ── */
.progress-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 2000; height: 3px; }
.progress-bar .fill {
height: 100%; width: 0%;
background: linear-gradient(90deg, #4338CA, #2563EB, #0EA5E9);
transition: width .5s ease; border-radius: 0 2px 2px 0;
}
/* ═══════════════════════════════════════
AGENT FLOW PANEL (left sidebar)
═══════════════════════════════════════ */
.flow-panel {
position: fixed; left: 0; top: 0; bottom: 0; width: 340px; z-index: 999;
background: #0F172A;
border-right: 1px solid rgba(255,255,255,.06);
display: flex; flex-direction: column;
font-family: 'Inter', system-ui, sans-serif;
transition: transform .3s ease;
overflow: hidden;
}
.flow-panel.hidden { transform: translateX(-100%); }
/* Panel header */
.flow-panel .fp-header {
padding: 16px 20px; border-bottom: 1px solid rgba(255,255,255,.06);
display: flex; align-items: center; gap: 10px; flex-shrink: 0;
}
.fp-logo {
width: 36px; height: 36px; border-radius: 10px;
background: linear-gradient(135deg, #1E40AF, #7C3AED);
display: flex; align-items: center; justify-content: center;
font-size: 16px; color: white;
}
.fp-title { font-size: 14px; font-weight: 700; color: #F9FAFB; }
.fp-subtitle { font-size: 11px; color: #64748B; margin-top: 2px; }
/* ── TABS ── */
.fp-tabs {
display: flex; gap: 4px; padding: 12px 16px 0;
border-bottom: 1px solid rgba(255,255,255,.06);
}
.fp-tab {
flex: 1; padding: 8px 0; background: transparent; border: none;
color: #64748B; font-size: 11px; font-weight: 600; font-family: 'Inter', sans-serif;
cursor: pointer; border-bottom: 2px solid transparent; transition: all .2s;
}
.fp-tab.active { color: #A5B4FC; border-bottom-color: #6366F1; }
.fp-tab:hover:not(.active) { color: #94A3B8; }
/* ── HISTORY LIST ── */
.history-list {
flex: 1; overflow-y: auto; padding: 12px;
display: flex; flex-direction: column; gap: 8px;
}
.history-item {
background: rgba(255,255,255,.03); border: 1px solid rgba(255,255,255,.08);
border-radius: 10px; padding: 12px; cursor: pointer; transition: all .2s;
}
.history-item:hover { background: rgba(255,255,255,.06); border-color: rgba(99,102,241,.4); }
.history-item.active { border-color: #6366F1; background: rgba(99,102,241,.1); }
.history-title { font-size: 13px; font-weight: 600; color: #F1F5F9; margin-bottom: 6px; line-height: 1.4; }
.history-meta { font-size: 11px; color: #64748B; display: flex; justify-content: space-between; align-items: center; }
.history-badge { background: #1E293B; padding: 2px 6px; border-radius: 4px; font-size: 10px; }
/* ── FLOW DIAGRAM & TABS WRAPPERS ── */
.tab-content { display: flex; flex-direction: column; flex: 1; overflow: hidden; }
.flow-diagram { padding: 20px; flex-shrink: 0; }
.flow-nodes {
display: flex; flex-direction: column; gap: 0; align-items: center;
}
.flow-node {
width: 100%; padding: 10px 14px;
border-radius: 10px; border: 1.5px solid rgba(255,255,255,.08);
background: rgba(255,255,255,.03);
display: flex; align-items: center; gap: 10px;
transition: all .4s ease; position: relative;
}
.flow-node .fn-icon {
width: 32px; height: 32px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 14px; flex-shrink: 0;
background: rgba(255,255,255,.06); transition: all .4s ease;
}
.flow-node .fn-label { font-size: 12px; font-weight: 600; color: #64748B; transition: color .4s; }
.flow-node .fn-detail { font-size: 10px; color: #475569; margin-top: 2px; transition: color .4s; display: none; }
/* Connector arrow */
.flow-arrow {
width: 2px; height: 16px; background: rgba(255,255,255,.08);
margin: 0 auto; position: relative; transition: background .4s;
}
.flow-arrow::after {
content: ''; position: absolute; bottom: -3px; left: -3px;
width: 8px; height: 8px; border-right: 2px solid rgba(255,255,255,.08);
border-bottom: 2px solid rgba(255,255,255,.08);
transform: rotate(45deg); transition: border-color .4s;
}
/* Reflect loop arrow */
.flow-loop {
width: 80%; height: 24px; margin: -4px auto 0;
border: 1.5px dashed rgba(255,255,255,.08);
border-top: none; border-radius: 0 0 12px 12px;
position: relative; transition: border-color .4s;
}
.flow-loop-label {
position: absolute; bottom: -8px; left: 50%; transform: translateX(-50%);
font-size: 9px; color: #475569; background: #0F172A; padding: 0 6px;
white-space: nowrap; transition: color .4s;
}
/* ── Node States ── */
.flow-node.active {
border-color: rgba(99,102,241,.6);
background: rgba(99,102,241,.12);
box-shadow: 0 0 20px rgba(99,102,241,.15);
}
.flow-node.active .fn-icon { background: #4338CA; }
.flow-node.active .fn-label { color: #C7D2FE; }
.flow-node.active .fn-detail { display: block; color: #A5B4FC; }
.flow-node.done {
border-color: rgba(16,185,129,.4);
background: rgba(16,185,129,.08);
}
.flow-node.done .fn-icon { background: #059669; }
.flow-node.done .fn-label { color: #6EE7B7; }
.flow-node.error {
border-color: rgba(239,68,68,.4);
background: rgba(239,68,68,.08);
}
.flow-node.error .fn-icon { background: #DC2626; }
.flow-node.error .fn-label { color: #FCA5A5; }
/* Active arrow glow */
.flow-arrow.active { background: rgba(99,102,241,.5); }
.flow-arrow.active::after { border-color: rgba(99,102,241,.5); }
.flow-loop.active { border-color: rgba(245,158,11,.5); }
.flow-loop.active .flow-loop-label { color: #FBBF24; }
/* Pulse animation */
@keyframes nodePulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(99,102,241,.3); }
50% { box-shadow: 0 0 20px 4px rgba(99,102,241,.15); }
}
.flow-node.active { animation: nodePulse 2s ease infinite; }
/* ── Sub-task chips ── */
.sub-tasks {
padding: 0 12px; margin: -2px 0 4px;
display: flex; flex-direction: column; gap: 3px;
}
.sub-task-chip {
display: flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: 6px; font-size: 10px;
color: #94A3B8; background: rgba(255,255,255,.03);
border-left: 2px solid rgba(255,255,255,.08);
animation: logIn .2s ease;
line-height: 1.4;
}
.sub-task-chip.active {
color: #C7D2FE; background: rgba(99,102,241,.08);
border-left-color: #6366F1;
}
.sub-task-chip.done {
color: #6EE7B7; background: rgba(16,185,129,.06);
border-left-color: #10B981;
}
.sub-task-chip .st-icon { flex-shrink: 0; width: 13px; text-align: center; }
/* ── Current task header ── */
.task-header {
padding: 6px 16px; font-size: 10px; font-weight: 700;
color: #A5B4FC; background: rgba(99,102,241,.08);
border-bottom: 1px solid rgba(99,102,241,.12);
letter-spacing: .02em; text-transform: uppercase;
display: none; flex-shrink: 0;
}
.task-header.visible { display: block; }
/* ── Drill-down log items ── */
.log-item.drill {
color: #C084FC; background: rgba(124,58,237,.08);
border-left: 2px solid #7C3AED; padding-left: 12px;
}
/* ── EVENT LOG (below flow) ── */
.event-log {
flex: 1; overflow-y: auto; padding: 0 16px 16px;
display: flex; flex-direction: column; gap: 4px;
}
.event-log::-webkit-scrollbar { width: 4px; }
.event-log::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: 4px; }
.log-divider {
font-size: 10px; color: #475569; padding: 8px 0 4px;
border-top: 1px solid rgba(255,255,255,.04); margin-top: 4px;
font-weight: 600; letter-spacing: .05em; text-transform: uppercase;
}
.log-item {
padding: 6px 10px; border-radius: 6px; font-size: 11px;
color: #94A3B8; line-height: 1.5; animation: logIn .2s ease;
background: rgba(255,255,255,.02);
}
.log-item.tool { color: #A5B4FC; }
.log-item.success { color: #6EE7B7; }
.log-item.error { color: #FCA5A5; }
.log-item.warn { color: #FCD34D; }
.log-item .sql { font-family: monospace; font-size: 10px; color: #64748B; display: block; margin-top: 2px; word-break: break-all; max-height: 36px; overflow: hidden; }
@keyframes logIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
/* ── Toggle button ── */
.toggle-flow {
position: fixed; left: 16px; top: 16px; z-index: 998;
width: 44px; height: 44px; border-radius: 12px;
background: #1E293B; border: 1px solid rgba(255,255,255,.1);
color: white; font-size: 18px; cursor: pointer;
display: none; align-items: center; justify-content: center;
box-shadow: 0 4px 20px rgba(0,0,0,.3);
}
/* ── Report offset when flow panel is visible ── */
#report-output { transition: margin-left .3s ease; }
#report-output.with-panel { margin-left: 340px; }
/* ── Export Button ── */
.export-pdf-btn {
position: fixed; right: 30px; top: 30px; z-index: 998;
background: white; color: #0F172A; border: 1px solid #E2E8F0;
padding: 10px 16px; border-radius: 10px; font-size: 13px; font-weight: 600;
cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,.1);
display: none; align-items: center; gap: 8px; transition: all .2s;
font-family: 'Inter', sans-serif;
}
.export-pdf-btn:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,.15); }
/* ── Chat Follow-up Bar ── */
.chat-followup-bar {
position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%); width: 680px; max-width: 90vw;
background: white; border: 1px solid #E2E8F0; border-radius: 14px; padding: 10px;
display: flex; gap: 10px; box-shadow: 0 12px 32px rgba(0,0,0,.12); z-index: 1000;
transition: margin-left .3s ease, transform .3s ease; display: none;
}
.chat-followup-bar.with-panel {
margin-left: 170px;
}
.chat-followup-bar input {
flex: 1; border: none; outline: none; padding: 12px 16px; font-size: 14px;
background: #F8FAFC; border-radius: 10px; color: #0F172A; font-family: 'Inter', sans-serif;
}
.chat-followup-bar button {
background: #4F46E5; color: white; border: none; padding: 0 24px;
border-radius: 10px; font-weight: 600; cursor: pointer; transition: background .2s;
font-family: 'Inter', sans-serif; white-space: nowrap;
}
.chat-followup-bar button:hover { background: #4338CA; }
@media print {
.flow-panel, .toggle-flow, .input-overlay, .progress-bar, .export-pdf-btn, .chat-followup-bar { display: none !important; }
#report-output { margin-left: 0 !important; }
body { background: white !important; }
}
</style>
</head>
<body>
<!-- Progress bar -->
<div class="progress-bar" id="progress"><div class="fill" id="progress-fill"></div></div>
<!-- Input overlay -->
<div class="input-overlay app-ui" id="overlay">
<div class="input-card">
<div style="width:64px;height:64px;border-radius:18px;background:linear-gradient(135deg,#EEF2FF,#F5F3FF);border:1px solid #E0E7FF;display:flex;align-items:center;justify-content:center;font-size:28px;margin:0 auto 20px">📋</div>
<h1>AI Report Generator</h1>
<p>Chọn template mẫu hoặc nhập yêu cầu của bạn để hệ thống AI Query<br>data từ Langfuse, StarRocks, Postgres & Sinh báo cáo HTML A4</p>
<div class="templates-grid">
<div class="template-card" onclick="selectTemplate('Phân tích chi phí sử dụng LLM, usage tokens và hiệu suất agent 30 ngày qua (tập trung vào tối ưu chi phí)')">
<div class="tc-header">
<div class="tc-icon">💰</div>
<div class="tc-title">Báo cáo Chi phí LLM</div>
</div>
<div class="tc-desc">Phân tích token usage, cost/user, latency và đề xuất tối ưu.</div>
</div>
<div class="template-card" onclick="selectTemplate('Báo cáo hiệu suất chatbot AI Canifa tháng 3/2026: user engagement, success rate, và lỗi thường gặp')">
<div class="tc-header">
<div class="tc-icon"></div>
<div class="tc-title">Hiệu suất Chatbot</div>
</div>
<div class="tc-desc">Đánh giá tương tác user, tỷ lệ thành công và các bottleneck.</div>
</div>
<div class="template-card" onclick="selectTemplate('Phân tích chất lượng câu trả lời của AI dựa trên feedback của user (Score, Like/Dislike, Complaints) 30 ngày qua')">
<div class="tc-header">
<div class="tc-icon"></div>
<div class="tc-title">Chất lượng Feedback</div>
</div>
<div class="tc-desc">Deep-dive vào user ratings, complaints và độ chính xác (RAG).</div>
</div>
<div class="template-card" onclick="selectTemplate('Phân tích top sản phẩm bán chạy nhất trên StarRocks và xu hướng quan tâm của user qua chatbot')">
<div class="tc-header">
<div class="tc-icon">👕</div>
<div class="tc-title">Product & Sales Insights</div>
</div>
<div class="tc-desc">Phân tích top sales map với intent tìm kiếm của khách hàng.</div>
</div>
</div>
<div class="input-box">
<textarea id="input" rows="2" placeholder="Hoặc mô tả yêu cầu báo cáo tự do (vd: Phân tích nhóm khách hàng xài bot nhiều nhất)..." onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();submitQuery()}"></textarea>
<button onclick="submitQuery()" id="send-btn"></button>
</div>
<p style="font-size:11px;color:rgba(255,255,255,.2);margin-top:10px">Enter gửi · Shift+Enter xuống dòng</p>
</div>
</div>
<!-- ═══ FLOW PANEL (left sidebar) ═══ -->
<div class="flow-panel hidden app-ui" id="flowPanel">
<div class="fp-header">
<div class="fp-logo"></div>
<div>
<div class="fp-title">Report Agent</div>
<div class="fp-subtitle" id="fp-status">Waiting...</div>
</div>
<button onclick="toggleFlow()" style="margin-left:auto;background:none;border:none;color:#475569;font-size:16px;cursor:pointer"></button>
</div>
<div class="fp-tabs">
<button class="fp-tab active" id="tab-btn-flow" onclick="switchTab('flow')">LIVE FLOW</button>
<button class="fp-tab" id="tab-btn-history" onclick="switchTab('history')">HISTORY</button>
</div>
<!-- FLOW TAB -->
<div class="tab-content" id="tab-flow">
<!-- Flow Diagram -->
<div class="flow-diagram">
<div class="flow-nodes">
<!-- Node 1: THINK -->
<div class="flow-node" id="fn-think">
<div class="fn-icon">🧠</div>
<div>
<div class="fn-label">THINK — Phân tích yêu cầu</div>
<div class="fn-detail" id="fn-think-detail">Đang suy nghĩ...</div>
</div>
</div>
<div class="flow-arrow" id="fa-1"></div>
<!-- Node 2: ACT -->
<div class="flow-node" id="fn-act">
<div class="fn-icon">🔧</div>
<div>
<div class="fn-label">ACT — Chạy tools song song</div>
<div class="fn-detail" id="fn-act-detail"></div>
</div>
</div>
<div class="flow-arrow" id="fa-2"></div>
<!-- Dynamic sub-tasks container (below ACT arrow) -->
<div class="sub-tasks" id="subTasksContainer"></div>
<!-- Node 3: OBSERVE -->
<div class="flow-node" id="fn-observe">
<div class="fn-icon">👁️</div>
<div>
<div class="fn-label">OBSERVE — Thu thập kết quả</div>
<div class="fn-detail" id="fn-observe-detail"></div>
</div>
</div>
<div class="flow-arrow" id="fa-3"></div>
<!-- Node 4: REFLECT -->
<div class="flow-node" id="fn-reflect">
<div class="fn-icon">🔍</div>
<div>
<div class="fn-label">REFLECT — Đánh giá data</div>
<div class="fn-detail" id="fn-reflect-detail"></div>
</div>
</div>
<!-- Loop arrow back to ACT -->
<div class="flow-loop" id="fl-loop">
<span class="flow-loop-label">↩ Cần thêm data → quay lại ACT</span>
</div>
<div class="flow-arrow" id="fa-4"></div>
<!-- Node 5: WRITE -->
<div class="flow-node" id="fn-write">
<div class="fn-icon">✍️</div>
<div>
<div class="fn-label">WRITE — Sinh báo cáo HTML</div>
<div class="fn-detail" id="fn-write-detail"></div>
</div>
</div>
<div class="flow-arrow" id="fa-5"></div>
<!-- Node 6: DONE -->
<div class="flow-node" id="fn-done">
<div class="fn-icon">📄</div>
<div>
<div class="fn-label">DONE — Render A4 Report</div>
<div class="fn-detail" id="fn-done-detail"></div>
</div>
</div>
</div>
</div>
<!-- Current task header -->
<div class="task-header" id="taskHeader"></div>
<!-- Log divider -->
<div class="log-divider" style="padding:8px 16px 4px">EVENT LOG</div>
<!-- Event log -->
<div class="event-log" id="eventLog"></div>
</div>
<!-- HISTORY TAB -->
<div class="tab-content" id="tab-history" style="display: none;">
<div class="history-list" id="historyList">
<div style="text-align:center;color:#64748B;font-size:12px;margin-top:20px;">Loading history...</div>
</div>
</div>
</div>
<!-- Toggle button -->
<button class="toggle-flow" id="toggleBtn" onclick="toggleFlow()" style="display:none">🔧</button>
<!-- Export PDF button -->
<button class="export-pdf-btn app-ui" id="exportBtn" onclick="window.print()">
<span style="font-size:16px">📄</span> Export PDF
</button>
<!-- Report output -->
<div id="report-output"></div>
<!-- Chat Follow-up Bar -->
<div class="chat-followup-bar app-ui" id="chatFollowup">
<input type="text" id="followupInput" placeholder="Hỏi thêm về báo cáo này (VD: Phân tích chi tiết lỗi Timeout...)" onkeydown="if(event.key==='Enter') submitQuery(null, currentReportId)">
<button onclick="submitQuery(null, currentReportId)">Hỏi tiếp ↵</button>
</div>
<script>
const overlay = document.getElementById('overlay')
const flowPanel = document.getElementById('flowPanel')
const eventLog = document.getElementById('eventLog')
const fpStatus = document.getElementById('fp-status')
const progressEl = document.getElementById('progress-fill')
const toggleBtn = document.getElementById('toggleBtn')
const reportOut = document.getElementById('report-output')
const inputEl = document.getElementById('input')
const exportBtn = document.getElementById('exportBtn')
const followupBar = document.getElementById('chatFollowup')
const followupInput = document.getElementById('followupInput')
let currentReportId = null;
// TAB SWITCHING
function switchTab(tabId) {
document.getElementById('tab-btn-flow').classList.toggle('active', tabId === 'flow');
document.getElementById('tab-btn-history').classList.toggle('active', tabId === 'history');
document.getElementById('tab-flow').style.display = tabId === 'flow' ? 'flex' : 'none';
document.getElementById('tab-history').style.display = tabId === 'history' ? 'flex' : 'none';
if (tabId === 'history') {
loadHistory();
}
}
//let currentReportId = null;
let isAppending = false;
// HISTORY LOGIC
async function loadHistory() {
const listEl = document.getElementById('historyList');
try {
const res = await fetch('/api/reports');
const data = await res.json();
if (data.reports) {
if (data.reports.length === 0) {
listEl.innerHTML = '<div style="text-align:center;color:#64748B;font-size:12px;margin-top:20px;">No previous reports.</div>';
return;
}
listEl.innerHTML = data.reports.map(r => `
<div class="history-item ${currentReportId === r.id ? 'active' : ''}" onclick="viewReport(${r.id})">
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
<div class="history-title" style="flex:1">${escapeHTML(r.title)}</div>
<button onclick="event.stopPropagation(); deleteReport(${r.id})" style="background:none; border:none; color:#EF4444; cursor:pointer; font-size:12px; padding:0 4px; margin-left:4px;" title="Xóa report này">×</button>
</div>
<div class="history-meta">
<span>${new Date(r.created_at).toLocaleDateString('vi-VN')}</span>
<span class="history-badge">⚡ ${r.generation_time_ms ? (r.generation_time_ms/1000).toFixed(1) + 's' : ''}</span>
</div>
</div>
`).join('');
}
} catch (err) {
console.error("History load error:", err);
listEl.innerHTML = '<div style="text-align:center;color:#ef4444;font-size:12px;margin-top:20px;">Lỗi tải lịch sử</div>';
}
}
async function deleteReport(reportId) {
if (!confirm('Bạn có chắc chắn muốn xóa report này?')) return;
try {
const res = await fetch(`/api/reports/${reportId}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
if (currentReportId === reportId) {
currentReportId = null;
reportOut.innerHTML = '';
fpStatus.textContent = 'Waiting...';
exportBtn.style.display = 'none';
followupBar.style.display = 'none';
}
loadHistory();
} else {
alert('Delete failed: ' + data.error);
}
} catch (err) {
alert('Failed to delete report.');
}
}
async function viewReport(reportId) {
currentReportId = reportId;
// Update active state for history items
document.querySelectorAll('.history-item').forEach(item => {
item.classList.toggle('active', parseInt(item.getAttribute('onclick').match(/\d+/)[0]) === reportId);
});
overlay.classList.add('hidden');
document.getElementById('taskHeader').classList.remove('visible');
progressEl.style.width = '100%';
progressEl.style.background = 'linear-gradient(90deg, #10B981, #34D399)';
if (!flowPanel.classList.contains('hidden')) {
reportOut.classList.add('with-panel');
}
reportOut.innerHTML = '<div style="padding:100px;text-align:center;color:#64748B;">Đang tải...</div>';
try {
const res = await fetch(`/api/reports/${reportId}`);
const data = await res.json();
if (data.report) {
reportOut.innerHTML = data.report.html_content;
fpStatus.textContent = `Report #${reportId}`;
exportBtn.style.display = 'flex';
followupBar.style.display = 'flex';
} else {
reportOut.innerHTML = `<div style="padding:100px;text-align:center;color:#ef4444;">${data.error || 'Report not found'}</div>`;
followupBar.style.display = 'none';
exportBtn.style.display = 'none';
}
} catch (err) {
reportOut.innerHTML = `<div style="padding:100px;text-align:center;color:#ef4444;">Lỗi tải report</div>`;
followupBar.style.display = 'none';
exportBtn.style.display = 'none';
}
}
function escapeHTML(str) {
if (!str) return '';
return str.replace(/[&<>'"]/g,
tag => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
"'": '&#39;',
'"': '&quot;'
}[tag])
);
}
let panelVisible = false
let totalTools = 0
let totalResults = 0
function toggleFlow() {
panelVisible = !panelVisible
flowPanel.classList.toggle('hidden', !panelVisible)
reportOut.classList.toggle('with-panel', panelVisible)
followupBar.classList.toggle('with-panel', panelVisible)
toggleBtn.style.display = panelVisible ? 'none' : 'flex'
}
// ── Flow node helpers ──
function resetAllNodes() {
document.querySelectorAll('.flow-node').forEach(n => n.classList.remove('active','done','error'))
document.querySelectorAll('.flow-arrow').forEach(a => a.classList.remove('active'))
document.getElementById('fl-loop').classList.remove('active')
}
function activateNode(nodeId, detail) {
// Mark previous as done
document.querySelectorAll('.flow-node.active').forEach(n => {
n.classList.remove('active')
n.classList.add('done')
})
const node = document.getElementById(nodeId)
if (node) {
node.classList.remove('done','error')
node.classList.add('active')
if (detail) {
const detailEl = document.getElementById(nodeId + '-detail')
if (detailEl) { detailEl.textContent = detail; detailEl.style.display = 'block'; }
}
}
}
function activateArrow(arrowId) {
const arrow = document.getElementById(arrowId)
if (arrow) arrow.classList.add('active')
}
function markNodeDone(nodeId, detail) {
const node = document.getElementById(nodeId)
if (node) {
node.classList.remove('active')
node.classList.add('done')
if (detail) {
const detailEl = document.getElementById(nodeId + '-detail')
if (detailEl) { detailEl.textContent = detail; detailEl.style.display = 'block'; }
}
}
}
// ── Log helpers ──
function addLog(cls, text, sql) {
const el = document.createElement('div')
el.className = `log-item ${cls}`
el.innerHTML = text + (sql ? `<span class="sql">${sql}</span>` : '')
eventLog.appendChild(el)
eventLog.scrollTop = eventLog.scrollHeight
}
function setProgress(pct) { progressEl.style.width = pct + '%' }
// ── Main submit ──
async function submitQuery(text, parentId = null) {
const query = text || (parentId ? followupInput.value.trim() : inputEl.value.trim())
if (!query) return
isAppending = !!parentId;
// Show panel, hide overlay
overlay.classList.add('hidden')
panelVisible = true
flowPanel.classList.remove('hidden')
reportOut.classList.add('with-panel')
followupBar.classList.add('with-panel')
if (!parentId) {
reportOut.innerHTML = ''
currentReportId = null;
exportBtn.style.display = 'none';
followupBar.style.display = 'none';
} else {
reportOut.innerHTML += `<div class="followup-trigger" style="margin:40px 0;padding:24px;background:#F8FAFC;border-left:4px solid #4F46E5;border-radius:12px;font-family:'Inter',sans-serif;box-shadow:0 4px 6px -1px rgba(0,0,0,0.05);"><strong style="color:#0F172A;font-size:15px;display:flex;align-items:center;gap:8px;"><span style="font-size:18px">💬</span> Bạn hỏi thêm:</strong><div style="margin-top:8px;color:#334155;font-size:14px;line-height:1.6;">${escapeHTML(query)}</div></div>`
followupInput.value = '';
followupBar.style.display = 'none'; // hide while processing
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
}
eventLog.innerHTML = ''
totalTools = 0; totalResults = 0
resetAllNodes()
fpStatus.textContent = 'Khởi tạo Agent...'
fpStatus.style.color = '#94A3B8'
setProgress(0)
switchTab('flow'); // Ensure flow tab is active when starting a new query
try {
const bodyPayload = { question: query };
if (parentId) bodyPayload.parent_report_id = parentId;
const res = await fetch('/api/report-html', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bodyPayload),
})
if (!res.ok) throw new Error(`Server error: ${res.status}`)
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const jsonStr = line.slice(6).trim()
if (!jsonStr) continue
try {
const data = JSON.parse(jsonStr)
if (data.report_id && !parentId) { // only overwrite current if it's a new report
currentReportId = data.report_id;
}
handleEvent(data)
} catch (e) { console.warn('parse error', e) }
}
}
} catch (e) {
addLog('error', `❌ ${e.message}`)
fpStatus.textContent = 'Error'
fpStatus.style.color = '#F87171'
followupBar.style.display = parentId ? 'flex' : 'none';
}
}
function setTaskHeader(text) {
const h = document.getElementById('taskHeader')
if (text) { h.textContent = text; h.classList.add('visible') }
}
function renderSubTasks(subTasks, completed) {
const container = document.getElementById('subTasksContainer')
container.innerHTML = ''
const completedSet = new Set((completed || []).map(s => s.replace(/✅.*$/, '').trim().split(' ')[0]))
;(subTasks || []).forEach(st => {
const id = st.split(' ')[0]
const isDone = completedSet.has(id)
const chip = document.createElement('div')
chip.className = `sub-task-chip ${isDone ? 'done' : 'active'}`
chip.innerHTML = `<span class="st-icon">${isDone ? '✅' : '⏳'}</span>${st}`
container.appendChild(chip)
})
}
function renderCompletedSubTasks(completed) {
const container = document.getElementById('subTasksContainer')
container.innerHTML = ''
;(completed || []).forEach(st => {
const chip = document.createElement('div')
chip.className = 'sub-task-chip done'
chip.innerHTML = `<span class="st-icon">✅</span>${st}`
container.appendChild(chip)
})
}
function handleEvent(evt) {
switch (evt.type) {
case 'thinking':
if (evt.step.includes('phân tích') || evt.step.includes('Phân tích')) {
activateNode('fn-think', evt.step)
activateArrow('fa-1')
setProgress(10)
} else if (evt.step.includes('Đánh giá') || evt.step.includes('đánh giá')) {
activateNode('fn-reflect', `Vòng ${evt.step.match(/\d+/)?.[0] || '?'}`)
activateArrow('fa-3')
} else if (evt.step.includes('viết') || evt.step.includes('Viết')) {
markNodeDone('fn-reflect', '✅ Data đủ')
activateArrow('fa-4')
activateNode('fn-write', 'Đang sinh HTML body...')
setProgress(80)
}
addLog('', `🧠 ${evt.step}`)
fpStatus.textContent = evt.step
break
case 'plan_ready':
totalTools = evt.tools_count || 0
markNodeDone('fn-think', '✅ Đã phân tích')
activateNode('fn-act', `${totalTools} tools · vòng ${evt.cycle}`)
activateArrow('fa-2')
setProgress(15 + (evt.cycle || 1) * 5)
if (evt.current_task) setTaskHeader(`📋 ${evt.current_task}`)
if (evt.sub_tasks?.length) renderSubTasks(evt.sub_tasks)
addLog('', `📋 <b>Task: ${evt.current_task || 'Vòng ' + evt.cycle}</b> — ${totalTools} tools`)
if (evt.sub_tasks?.length) addLog('', `Sub-tasks: ${evt.sub_tasks.join(' · ')}`)
if (evt.thinking) addLog('', evt.thinking.slice(0, 200))
break
case 'tool_call': {
const sqlPreview = evt.params?.sql ? evt.params.sql.slice(0, 120) : ''
const purpose = evt.purpose ? ` — <i>${evt.purpose.slice(0, 80)}</i>` : ''
addLog('tool', `🔧 <b>${evt.tool}</b>${purpose}`, sqlPreview)
activateNode('fn-act', `Gọi ${evt.tool}...`)
setProgress(20 + (evt.index || 0) * 5)
break
}
case 'tool_result': {
totalResults++
const icon = evt.success ? '✅' : '❌'
let info = evt.tool
if (evt.preview?.row_count != null) info += ` → ${evt.preview.row_count} rows`
if (evt.preview?.error) info += ` → ${evt.preview.error.slice(0, 80)}`
addLog(evt.success ? 'success' : 'error', `${icon} ${info}`)
activateNode('fn-observe', `${totalResults}/${totalTools} results`)
activateArrow('fa-3')
setProgress(30 + totalResults * 5)
break
}
case 'reflect': {
activateNode('fn-reflect', evt.data_sufficient ? '✅ Đủ data!' : '⏳ Drill-down...')
if (evt.completed_sub_tasks?.length) renderCompletedSubTasks(evt.completed_sub_tasks)
if (evt.data_sufficient) {
addLog('success', `✅ Vòng ${evt.cycle}: Dữ liệu đủ — chuyển WRITE`)
markNodeDone('fn-observe', '✅ Done')
if (evt.current_task) setTaskHeader(`✅ ${evt.current_task} — Done`)
} else {
if (evt.drill_down_opportunities?.length) {
addLog('warn', `🔍 Vòng ${evt.cycle}: Phát hiện ${evt.drill_down_opportunities.length} cơ hội drill-down`)
evt.drill_down_opportunities.forEach(d => addLog('drill', `🔬 ${d.slice(0, 150)}`))
} else {
addLog('warn', `🔍 Vòng ${evt.cycle}: Cần thêm data`)
}
if (evt.missing?.length) addLog('warn', `Missing: ${evt.missing.join(', ')}`)
if (evt.next_task) {
setTaskHeader(`📋 ${evt.next_task}`)
addLog('', `➡️ Tiếp: <b>${evt.next_task}</b>`)
}
if (evt.next_sub_tasks?.length) {
renderSubTasks(evt.next_sub_tasks)
addLog('', `Sub-tasks: ${evt.next_sub_tasks.join(' · ')}`)
}
document.getElementById('fl-loop').classList.add('active')
totalResults = 0
}
if (evt.thinking) addLog('', evt.thinking.slice(0, 200))
setProgress(50 + (evt.cycle || 0) * 10)
break
}
case 'html_complete':
markNodeDone('fn-write', '✅ HTML ready')
activateArrow('fa-5')
activateNode('fn-done', `${(evt.html||'').length.toLocaleString()} chars`)
setProgress(100)
fpStatus.textContent = '✅ Hoàn thành!'
fpStatus.style.color = '#4ADE80'
addLog('success', `🏁 Báo cáo hoàn thành! (${(evt.html||'').length.toLocaleString()} chars)`)
if (isAppending) {
reportOut.innerHTML += '\n\n' + (evt.html || '');
} else {
reportOut.innerHTML = evt.html || '';
}
reportOut.querySelectorAll('script').forEach(old => {
const s = document.createElement('script')
s.textContent = old.textContent
old.parentNode.replaceChild(s, old)
})
setTimeout(() => markNodeDone('fn-done', '📄 Đã render'), 500)
exportBtn.style.display = 'flex';
followupBar.style.display = 'flex';
if (isAppending) {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
}
break
case 'error':
addLog('error', `❌ ${evt.message}`)
document.querySelectorAll('.flow-node.active').forEach(n => {
n.classList.remove('active')
n.classList.add('error')
})
fpStatus.textContent = '❌ Error'
fpStatus.style.color = '#F87171'
followupBar.style.display = isAppending ? 'flex' : 'none';
break
case 'done':
setProgress(100)
break
}
}
</script>
</body>
</html>
@import url('https://fonts.googleapis.com/css2?family=Crimson+Pro:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400;1,600&family=Inter:wght@400;500;600;700&display=swap');
/* ═══════════════════════════════════════════════════════════════════
ALL REPORT STYLES SCOPED TO #report-output
These rules MUST NOT leak into the app UI (sidebar, overlay, etc.)
═══════════════════════════════════════════════════════════════════ */
:root{
--navy:#0F2044;
--blue:#1A4A8C;
--blue-mid:#2563EB;
--blue-light:#EFF6FF;
--gold:#C8860A;
--gold-light:#FEF3C7;
--ink:#1C1C1C;
--mid:#4B5563;
--muted:#9CA3AF;
--rule:#D1D9E6;
--rule-light:#EBF0F8;
--bg:#F3F6FB;
--paper:#FFFFFF;
--green:#0D6E4F;
--green-light:#ECFDF5;
--red:#B91C1C;
--red-light:#FEF2F2;
--chart-1:#1A4A8C;
--chart-2:#C8860A;
--chart-3:#0D6E4F;
--chart-4:#7C3AED;
--chart-5:#B91C1C;
--chart-6:#0891B2;
}
/* ── Global reset — ONLY inside #report-output ── */
#report-output *{box-sizing:border-box;margin:0;padding:0}
#report-output{font-family:'Crimson Pro',Georgia,serif;color:var(--ink);font-size:11pt;line-height:1.75;background:var(--bg);padding:10mm 0 24mm}
/* ── PAGE ── */
#report-output .page{width:210mm;margin:0 auto 10mm;background:var(--paper);position:relative;overflow:hidden}
@media screen{
#report-output .page{box-shadow:0 4px 40px rgba(15,32,68,.14),0 0 0 0.5pt rgba(15,32,68,.08)}
}
/* ══ COVER ══ */
#report-output .cover{min-height:297mm;display:flex;flex-direction:column;padding:0}
#report-output .cover-stripe{height:8mm;background:var(--navy);width:100%}
#report-output .cover-stripe-gold{height:2mm;background:linear-gradient(90deg,var(--gold),#E8A020,var(--gold));width:100%}
#report-output .cover-inner{flex:1;padding:14mm 20mm 10mm;display:flex;flex-direction:column}
#report-output .cover-toprow{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:14mm}
#report-output .org-logo{display:flex;align-items:center;gap:10pt}
#report-output .org-badge{width:42pt;height:42pt;background:var(--navy);border-radius:6pt;display:flex;align-items:center;justify-content:center;font-family:'Inter',sans-serif;font-size:9pt;font-weight:700;color:#fff;letter-spacing:.04em;line-height:1.2;text-align:center}
#report-output .org-info{display:flex;flex-direction:column;gap:2pt}
#report-output .org-name{font-family:'Inter',sans-serif;font-size:9pt;font-weight:600;color:var(--navy);letter-spacing:.02em}
#report-output .org-dept{font-family:'Inter',sans-serif;font-size:8pt;color:var(--muted)}
#report-output .doc-meta{text-align:right;font-family:'Inter',sans-serif;font-size:7.5pt;color:var(--muted);line-height:1.7}
#report-output .doc-meta strong{color:var(--ink);font-weight:600}
#report-output .cover-hero{flex:1;display:flex;flex-direction:column;justify-content:center;padding:8mm 0}
#report-output .cover-tag{display:inline-flex;align-items:center;gap:6pt;background:var(--blue-light);border:0.5pt solid #BFDBFE;border-radius:3pt;padding:3pt 10pt;margin-bottom:8mm;font-family:'Inter',sans-serif;font-size:7.5pt;font-weight:600;color:var(--blue-mid);letter-spacing:.08em;text-transform:uppercase}
#report-output .cover-tag::before{content:'';width:6pt;height:6pt;border-radius:50%;background:var(--blue-mid)}
#report-output .cover-title{font-size:30pt;font-weight:700;line-height:1.15;color:var(--navy);margin-bottom:4mm;letter-spacing:-.01em}
#report-output .cover-subtitle{font-size:13pt;font-weight:300;font-style:italic;color:var(--mid);margin-bottom:10mm;line-height:1.55}
#report-output .cover-divider{width:50mm;height:2.5pt;background:linear-gradient(90deg,var(--navy),var(--blue-mid));border-radius:2pt;margin-bottom:10mm}
#report-output .cover-metarow{display:grid;grid-template-columns:repeat(4,1fr);gap:0;border:0.5pt solid var(--rule);border-radius:6pt;overflow:hidden;margin-bottom:12mm}
#report-output .cover-metaitem{padding:5mm 5mm 4mm;border-right:0.5pt solid var(--rule)}
#report-output .cover-metaitem:last-child{border-right:none}
#report-output .cover-metalabel{font-family:'Inter',sans-serif;font-size:7pt;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);display:block;margin-bottom:3pt}
#report-output .cover-metaval{font-family:'Inter',sans-serif;font-size:9.5pt;font-weight:600;color:var(--navy)}
#report-output .cover-footer{border-top:0.5pt solid var(--rule);padding-top:5mm;display:flex;justify-content:space-between;align-items:center}
#report-output .cover-footer-note{font-family:'Inter',sans-serif;font-size:7.5pt;color:var(--muted);font-style:italic}
#report-output .cover-stamp{font-family:'Inter',sans-serif;font-size:7pt;font-weight:700;letter-spacing:.15em;text-transform:uppercase;color:var(--paper);background:var(--navy);padding:3pt 10pt;border-radius:2pt}
/* cover decorative shapes */
#report-output .cover-deco{position:absolute;right:-30mm;top:60mm;width:100mm;height:100mm;border-radius:50%;border:20pt solid rgba(37,99,235,.05);pointer-events:none}
#report-output .cover-deco2{position:absolute;right:-10mm;top:100mm;width:60mm;height:60mm;border-radius:50%;background:rgba(26,74,140,.04);pointer-events:none}
/* ══ CONTENT PAGES ══ */
#report-output .cp{padding:14mm 20mm 12mm;page-break-before:always}
/* running header */
#report-output .rh{display:flex;justify-content:space-between;align-items:center;padding-bottom:4mm;border-bottom:1.5pt solid var(--navy);margin-bottom:9mm}
#report-output .rh-left{font-family:'Inter',sans-serif;font-size:8pt;color:var(--mid);font-style:normal;display:flex;align-items:center;gap:6pt}
#report-output .rh-dot{width:4pt;height:4pt;border-radius:50%;background:var(--blue-mid)}
#report-output .rh-right{font-family:'Inter',sans-serif;font-size:8pt;color:var(--muted)}
/* headings */
#report-output h1{font-size:20pt;font-weight:700;color:var(--navy);margin-bottom:5mm;letter-spacing:-.02em;line-height:1.2}
#report-output h2{font-size:13.5pt;font-weight:600;color:var(--navy);margin:8mm 0 3mm;display:flex;align-items:center;gap:8pt}
#report-output h2::before{content:attr(data-n);font-family:'Inter',sans-serif;font-size:8pt;font-weight:700;color:var(--paper);background:var(--navy);width:18pt;height:18pt;border-radius:3pt;display:flex;align-items:center;justify-content:center;flex-shrink:0;letter-spacing:.02em}
#report-output h3{font-size:11.5pt;font-weight:600;color:var(--blue);margin:5mm 0 2mm;font-style:italic}
#report-output p{margin-bottom:4mm;text-align:justify}
#report-output .lead{font-size:12pt;font-weight:300;color:var(--mid);margin-bottom:6mm;line-height:1.8}
/* executive summary */
#report-output .esbox{border-left:3pt solid var(--navy);background:var(--blue-light);padding:6mm 8mm;margin:5mm 0 7mm;border-radius:0 6pt 6pt 0}
#report-output .esbox-label{font-family:'Inter',sans-serif;font-size:7.5pt;font-weight:700;text-transform:uppercase;letter-spacing:.12em;color:var(--blue-mid);margin-bottom:4mm}
#report-output .esbox p{margin-bottom:3mm;font-size:10.5pt}
#report-output .esbox p:last-child{margin-bottom:0}
/* kpi grid */
#report-output .kpi-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10pt;margin:5mm 0 7mm}
#report-output .kpi{background:var(--paper);border:0.5pt solid var(--rule);border-radius:8pt;padding:5mm;position:relative;overflow:hidden}
#report-output .kpi::after{content:'';position:absolute;top:0;left:0;right:0;height:3pt;background:var(--kpi-accent,var(--navy))}
#report-output .kpi-label{font-family:'Inter',sans-serif;font-size:7.5pt;font-weight:500;text-transform:uppercase;letter-spacing:.09em;color:var(--muted);display:block;margin-bottom:4pt}
#report-output .kpi-value{font-size:22pt;font-weight:700;color:var(--navy);line-height:1;display:block;margin-bottom:4pt;letter-spacing:-.02em}
#report-output .kpi-unit{font-size:9pt;font-weight:400;color:var(--muted);margin-left:2pt}
#report-output .kpi-change{font-family:'Inter',sans-serif;font-size:8.5pt;font-weight:600;display:inline-flex;align-items:center;gap:3pt;padding:2pt 7pt;border-radius:100pt}
#report-output .kpi-change.up{background:var(--green-light);color:var(--green)}
#report-output .kpi-change.dn{background:var(--red-light);color:var(--red)}
#report-output .kpi-change.nt{background:#F3F4F6;color:var(--muted)}
/* figure */
#report-output .fig{margin:6mm 0 4mm;page-break-inside:avoid}
#report-output .fig-header{display:flex;align-items:center;gap:7pt;margin-bottom:4mm}
#report-output .fig-num{font-family:'Inter',sans-serif;font-size:7.5pt;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:var(--paper);background:var(--blue-mid);padding:2pt 7pt;border-radius:3pt;white-space:nowrap}
#report-output .fig-title{font-family:'Inter',sans-serif;font-size:9pt;font-weight:600;color:var(--navy)}
#report-output .fig-wrap{background:#FAFCFF;border:0.5pt solid var(--rule);border-radius:8pt;padding:6mm}
#report-output .fig-cap{font-family:'Inter',sans-serif;font-size:8pt;color:var(--muted);margin-top:3mm;font-style:italic;line-height:1.5}
#report-output .fig-cap strong{font-style:normal;color:var(--mid)}
/* table */
#report-output .tbl-wrap{margin:5mm 0 3mm;overflow:hidden;border:0.5pt solid var(--rule);border-radius:8pt}
#report-output .tbl-cap{font-family:'Inter',sans-serif;font-size:8pt;font-weight:600;color:var(--navy);padding:4mm 5mm 3mm;background:#F8FAFF;border-bottom:0.5pt solid var(--rule)}
#report-output table{width:100%;border-collapse:collapse;font-size:9.5pt}
#report-output thead tr{background:var(--navy)}
#report-output thead th{padding:3mm 4mm;text-align:left;font-family:'Inter',sans-serif;font-size:8pt;font-weight:600;color:#fff;letter-spacing:.02em}
#report-output thead th.r{text-align:right}
#report-output tbody td{padding:2.5mm 4mm;border-bottom:0.5pt solid var(--rule-light);vertical-align:middle}
#report-output tbody td.r{text-align:right;font-family:'Inter',sans-serif;font-size:9pt}
#report-output tbody tr:last-child td{border-bottom:none}
#report-output tbody tr:hover{background:#F8FAFF}
#report-output .trow-total td{background:#F0F6FF;font-family:'Inter',sans-serif;font-weight:700;color:var(--navy);border-top:0.5pt solid var(--rule)!important}
#report-output .badge{display:inline-flex;align-items:center;font-family:'Inter',sans-serif;font-size:8pt;font-weight:600;padding:1.5pt 7pt;border-radius:100pt}
#report-output .badge.up{background:var(--green-light);color:var(--green)}
#report-output .badge.dn{background:var(--red-light);color:var(--red)}
#report-output .badge.nt{background:#F3F4F6;color:var(--muted)}
/* note/callout */
#report-output .note{display:flex;gap:8pt;align-items:flex-start;padding:4mm 5mm;border-radius:6pt;margin:4mm 0}
#report-output .note.info{background:var(--blue-light);border:0.5pt solid #BFDBFE}
#report-output .note.warn{background:var(--gold-light);border:0.5pt solid #FDE68A}
#report-output .note.success{background:var(--green-light);border:0.5pt solid #A7F3D0}
#report-output .note-icon{width:18pt;height:18pt;border-radius:50%;display:flex;align-items:center;justify-content:center;font-family:'Inter',sans-serif;font-size:9pt;font-weight:700;flex-shrink:0;margin-top:1pt}
#report-output .note.info .note-icon{background:#DBEAFE;color:var(--blue-mid)}
#report-output .note.warn .note-icon{background:#FDE68A;color:var(--gold)}
#report-output .note.success .note-icon{background:#A7F3D0;color:var(--green)}
#report-output .note p{margin:0;font-size:10pt;line-height:1.6}
#report-output .note.info p{color:#1E40AF}
#report-output .note.warn p{color:#78350F}
#report-output .note.success p{color:#065F46}
/* toc */
#report-output .toc-item{display:flex;align-items:baseline;gap:6pt;margin-bottom:3mm;font-size:10.5pt}
#report-output .toc-num{font-family:'Inter',sans-serif;font-weight:700;font-size:9pt;color:var(--blue-mid);min-width:18pt}
#report-output .toc-text{flex:1}
#report-output .toc-dots{flex:1;border-bottom:0.5pt dotted var(--rule);margin:0 5pt 3pt}
#report-output .toc-pg{font-family:'Inter',sans-serif;font-size:9pt;color:var(--muted);min-width:12pt;text-align:right}
#report-output .toc-sub{padding-left:18pt;font-size:10pt;color:var(--mid)}
/* recs */
#report-output .rec-item{display:flex;gap:10pt;align-items:flex-start;margin-bottom:4mm;padding:4mm 5mm;background:#FAFBFF;border-radius:6pt;border:0.5pt solid var(--rule)}
#report-output .rec-n{font-family:'Inter',sans-serif;font-size:8.5pt;font-weight:700;color:var(--paper);background:var(--navy);width:20pt;height:20pt;border-radius:50%;display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:1pt}
#report-output .rec-text{font-size:10.5pt;line-height:1.65}
/* sig block */
#report-output .sigblock{display:grid;grid-template-columns:repeat(3,1fr);gap:8mm;margin-top:9mm;padding-top:6mm;border-top:0.5pt solid var(--rule)}
#report-output .sig-item{display:block}
#report-output .sig-label{font-family:'Inter',sans-serif;font-size:7.5pt;color:var(--muted);margin-bottom:10mm}
#report-output .sig-line{border-bottom:0.5pt solid var(--ink);margin-bottom:3mm}
#report-output .sig-name{font-family:'Inter',sans-serif;font-size:9.5pt;font-weight:600;color:var(--navy)}
#report-output .sig-role{font-family:'Inter',sans-serif;font-size:8pt;color:var(--muted);font-style:italic}
/* footnotes */
#report-output .fn-rule{border:none;border-top:0.5pt solid var(--rule);margin:8mm 0 4mm;width:45%}
#report-output .fn{font-family:'Inter',sans-serif;font-size:7.5pt;color:var(--muted);line-height:1.55;margin-bottom:2mm}
/* page footer */
#report-output .pf{border-top:0.5pt solid var(--rule);padding-top:4mm;margin-top:8mm;display:flex;justify-content:space-between;align-items:center}
#report-output .pf-left{font-family:'Inter',sans-serif;font-size:7.5pt;color:var(--muted)}
#report-output .pf-right{font-family:'Inter',sans-serif;font-size:7.5pt;color:var(--muted)}
#report-output .pf-center{font-family:'Inter',sans-serif;font-size:7.5pt;color:var(--navy);font-weight:600}
/* ── PRINT ── */
@media print{
html,body{background:white}
#report-output .page{box-shadow:none;margin:0}
.no-print{display:none!important}
@page{size:A4;margin:0}
}
/* print btn */
#report-output .print-btn{position:fixed;bottom:22px;right:22px;background:var(--navy);color:white;border:none;border-radius:8pt;padding:9pt 20pt;font-family:'Inter',sans-serif;font-size:10pt;font-weight:600;cursor:pointer;box-shadow:0 4px 20px rgba(15,32,68,.35);z-index:999;letter-spacing:.03em;transition:all .2s}
#report-output .print-btn:hover{background:var(--blue);transform:translateY(-1px)}
/* followup section */
#report-output .followup-section{border-top:2pt solid var(--blue-mid);margin-top:8mm;padding-top:6mm}
...@@ -48,7 +48,7 @@ body{margin:0;display:flex;min-height:100vh} ...@@ -48,7 +48,7 @@ body{margin:0;display:flex;min-height:100vh}
<span class="nav-icon">🏷️</span> <span class="nav-icon">🏷️</span>
<span>Product Perf.</span> <span>Product Perf.</span>
</a> </a>
<a data-page="ai-analytic.html" class="nav-item" onclick="navigateTo(this)"> <a data-page="ai-report.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon">📊</span> <span class="nav-icon">📊</span>
<span>AI Data Analyst</span> <span>AI Data Analyst</span>
<span class="nav-badge badge-beta">NEW</span> <span class="nav-badge badge-beta">NEW</span>
......
# 📋 Hướng dẫn CSS Class cho AI sinh Report
> **Mục đích:** AI đọc file này → biết dùng class nào → sinh body HTML đúng format.
> CSS file: `report_template.css` (load qua `<link>`, KHÔNG thay đổi).
---
## 🏗️ Cấu trúc cơ bản
```
<body>
<!-- Nút in -->
<button class="print-btn no-print" onclick="window.print()">🖨 In / Lưu PDF</button>
<!-- Trang bìa -->
<div class="page">
<div class="cover">...</div>
</div>
<!-- Các trang nội dung (mỗi trang = 1 div.page.cp) -->
<div class="page cp">...</div>
<div class="page cp">...</div>
<!-- Chart.js script cuối body -->
<script>...</script>
</body>
```
---
## 📄 1. TRANG BÌA (Cover Page)
```html
<div class="page">
<div class="cover">
<div class="cover-stripe"></div> <!-- thanh navy trên cùng -->
<div class="cover-stripe-gold"></div> <!-- thanh vàng mỏng -->
<div class="cover-deco"></div> <!-- trang trí tròn (decorative) -->
<div class="cover-deco2"></div>
<div class="cover-inner">
<!-- Logo + Meta -->
<div class="cover-toprow">
<div class="org-logo">
<div class="org-badge">LOGO</div> <!-- badge 42x42pt -->
<div class="org-info">
<span class="org-name">Tên công ty</span>
<span class="org-dept">Phòng ban</span>
</div>
</div>
<div class="doc-meta">
Số hiệu: <strong>MÃ-SỐ</strong><br>
Phiên bản: <strong>1.0</strong><br>
Phân loại: <em>Nội bộ</em>
</div>
</div>
<!-- Tiêu đề chính -->
<div class="cover-hero">
<div class="cover-tag">Loại báo cáo</div>
<h1 class="cover-title">Tiêu đề<br>Báo cáo</h1>
<p class="cover-subtitle">Mô tả ngắn</p>
<div class="cover-divider"></div>
<!-- 4 ô thông tin -->
<div class="cover-metarow">
<div class="cover-metaitem">
<span class="cover-metalabel">Label</span>
<span class="cover-metaval">Value</span>
</div>
<!-- ... thêm 3 ô nữa -->
</div>
</div>
<div class="cover-footer">
<span class="cover-footer-note">Ghi chú bảo mật</span>
<span class="cover-stamp">BẢO MẬT NỘI BỘ</span>
</div>
</div>
</div>
</div>
```
---
## 📃 2. TRANG NỘI DUNG
```html
<div class="page cp">
<!-- Header chạy -->
<div class="rh">
<span class="rh-left"><span class="rh-dot"></span>Tiêu đề báo cáo</span>
<span class="rh-right">Trang X / Y</span>
</div>
<!-- Nội dung ở đây -->
<!-- Footer trang -->
<div class="pf">
<span class="pf-left">Tên công ty · Bảo mật nội bộ</span>
<span class="pf-center">MÃ-SỐ</span>
<span class="pf-right">X / Y</span>
</div>
</div>
```
---
## 🧩 3. CÁC COMPONENT
### Heading
```html
<h1>Tiêu đề lớn</h1>
<h2 data-n="1">Heading cấp 2 (số tự động hiện trong badge)</h2>
<h3>Heading cấp 3 (in nghiêng)</h3>
```
### Đoạn văn
```html
<p>Nội dung thường</p>
<p class="lead">Đoạn mở đầu (to hơn, nhạt hơn)</p>
```
### Executive Summary Box
```html
<div class="esbox">
<div class="esbox-label">Executive Summary</div>
<p>Nội dung tóm tắt...</p>
</div>
```
### KPI Cards (grid 4 cột)
```html
<div class="kpi-grid">
<div class="kpi" style="--kpi-accent:var(--blue-mid)">
<span class="kpi-label">TÊN CHỈ SỐ</span>
<span class="kpi-value">142,6<span class="kpi-unit">tỷ</span></span>
<span class="kpi-change up">↑ 18,4% YoY</span> <!-- up = xanh, dn = đỏ, nt = xám -->
</div>
<!-- ... thêm 3 KPI nữa -->
</div>
```
**Màu accent KPI:** `var(--blue-mid)`, `var(--green)`, `var(--gold)`, `var(--red)`
### Biểu đồ (Chart.js)
```html
<div class="fig">
<div class="fig-header">
<span class="fig-num">Hình X.Y</span>
<span class="fig-title">Tiêu đề biểu đồ</span>
</div>
<div class="fig-wrap"><canvas id="c-unique-id" height="175"></canvas></div>
<div class="fig-cap"><strong>Nguồn:</strong> Mô tả nguồn dữ liệu.</div>
</div>
```
### Bảng dữ liệu
```html
<div class="tbl-wrap">
<div class="tbl-cap">Bảng X.Y — Tiêu đề bảng</div>
<table>
<thead><tr>
<th>Cột 1</th>
<th class="r">Cột phải</th> <!-- class="r" = căn phải -->
</tr></thead>
<tbody>
<tr>
<td>Dữ liệu</td>
<td class="r">123,4</td>
</tr>
<tr class="trow-total"> <!-- dòng tổng -->
<td>Tổng</td>
<td class="r">456,7</td>
</tr>
</tbody>
</table>
</div>
```
### Badge (trong bảng)
```html
<span class="badge up">↑ 18,4%</span> <!-- xanh lá -->
<span class="badge dn">↓ 4,3%</span> <!-- đỏ -->
<span class="badge nt">— 0%</span> <!-- xám -->
```
### Callout / Note
```html
<!-- 3 loại: info (xanh), warn (vàng), success (xanh lá) -->
<div class="note info">
<div class="note-icon">i</div>
<p><strong>Lưu ý:</strong> Nội dung ghi chú.</p>
</div>
<div class="note warn">
<div class="note-icon">!</div>
<p><strong>Cảnh báo:</strong> Nội dung cảnh báo.</p>
</div>
<div class="note success">
<div class="note-icon"></div>
<p><strong>Tích cực:</strong> Nội dung tích cực.</p>
</div>
```
### Mục lục (TOC)
```html
<div class="toc-item">
<span class="toc-num">1.</span>
<span class="toc-text">Tên mục</span>
<span class="toc-dots"></span>
<span class="toc-pg">3</span>
</div>
<div class="toc-item toc-sub"> <!-- mục con: thêm class toc-sub -->
<span class="toc-num">1.1</span>
<span class="toc-text">Mục con</span>
<span class="toc-dots"></span>
<span class="toc-pg">3</span>
</div>
```
### Khuyến nghị
```html
<div class="rec-item">
<span class="rec-n">1</span>
<span class="rec-text">Nội dung khuyến nghị.</span>
</div>
```
### Khối chữ ký
```html
<div class="sigblock">
<div class="sig-item">
<div class="sig-label">Chức danh</div>
<div class="sig-line" style="height:14mm"></div>
<div class="sig-name">Họ Tên</div>
<div class="sig-role">Vai trò</div>
</div>
<!-- ... thêm 2 sig-item nữa (3 cột) -->
</div>
```
### Chú thích cuối trang
```html
<hr class="fn-rule">
<p class="fn">¹ Nội dung chú thích.</p>
```
---
## 🎨 4. CHART.JS CONFIG (AI cũng sinh phần này)
**Boilerplate helpers** (copy nguyên):
```javascript
const F = {family:"'Inter',sans-serif",size:9}
const TIP = {backgroundColor:"#0F2044",titleColor:"#93C5FD",bodyColor:"#F1F5F9",padding:11,cornerRadius:6,titleFont:{...F,size:9},bodyFont:{...F,size:11,weight:"700"}}
const AX = (cb)=>({grid:{color:"rgba(0,0,0,.05)"},border:{display:false},ticks:{color:"#6B7280",font:F,...(cb?{callback:cb}:{})}})
const AXX = {grid:{display:false},border:{display:false},ticks:{color:"#6B7280",font:F}}
```
**Bảng màu chart** (dùng theo thứ tự):
| Biến | Hex | Dùng cho |
|------|-----|----------|
| `--chart-1` | `#1A4A8C` | Primary (navy blue) |
| `--chart-2` | `#C8860A` | Secondary (gold) |
| `--chart-3` | `#0D6E4F` | Tertiary (green) |
| `--chart-4` | `#7C3AED` | Quaternary (purple) |
| `--chart-5` | `#B91C1C` | Quinary (red) |
| `--chart-6` | `#0891B2` | Senary (cyan) |
**Các loại chart thường dùng:**
| Loại | Chart.js type | Khi nào dùng |
|------|---------------|-------------|
| Bar (dọc) | `bar` | So sánh theo tháng/quý |
| Bar (ngang) | `bar` + `indexAxis:"y"` | Ranking/tăng trưởng |
| Donut | `doughnut` + `cutout:"65%"` | Cơ cấu/tỷ trọng |
| Line | `line` | Xu hướng theo thời gian |
| Grouped bar | `bar` + 2 datasets | So sánh YoY |
---
## 📐 5. LAYOUT PATTERNS
### 2 cột (chart cạnh nhau)
```html
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8mm;margin:4mm 0">
<div class="fig">...</div>
<div class="fig">...</div>
</div>
```
### 2 cột không đều (1.2:1)
```html
<div style="display:grid;grid-template-columns:1.2fr 1fr;gap:8mm;margin:5mm 0 3mm">
<div class="fig">...</div>
<div class="fig">...</div>
</div>
```
---
## ⚡ 6. CHECKLIST CHO AI KHI SINH REPORT
- [ ] Bắt đầu bằng `<button class="print-btn">`
- [ ] Trang bìa: `.page > .cover`
- [ ] Mỗi trang nội dung: `.page.cp`
- [ ] Mỗi trang có `.rh` (header) và `.pf` (footer)
- [ ] `<h2 data-n="X">` — số X tự hiện trong badge navy
- [ ] KPI dùng `.kpi-grid` + 4 `.kpi` cards
- [ ] Chart: `<canvas id="c-xxx">` + script cuối body
- [ ] Badge trong bảng: `.badge.up` / `.badge.dn` / `.badge.nt`
- [ ] Callout dùng `.note.info` / `.note.warn` / `.note.success`
- [ ] Cuối report: `.sigblock` + `.fn` + `.fn-rule`
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Báo cáo Kinh doanh Q1/2025 — Thời Trang Việt</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<!-- ▼ CSS CỐ ĐỊNH — KHÔNG ĐỔI ▼ -->
<link rel="stylesheet" href="report_template.css">
</head>
<body>
<!-- ════════════════════════════════════════════════════════════════════
▼▼▼ PHẦN DƯỚI ĐÂY DO AI SINH — CHỈ CẦN OUTPUT HTML + CHART.JS ▼▼▼
════════════════════════════════════════════════════════════════════ -->
<button class="print-btn no-print" onclick="window.print()">🖨&nbsp; In / Lưu PDF</button>
<!-- ═══ TRANG BÌA ═══ -->
<div class="page">
<div class="cover">
<div class="cover-stripe"></div>
<div class="cover-stripe-gold"></div>
<div class="cover-deco"></div>
<div class="cover-deco2"></div>
<div class="cover-inner">
<div class="cover-toprow">
<div class="org-logo">
<div class="org-badge">TTV<br>N</div>
<div class="org-info">
<span class="org-name">Công ty CP Thương mại Thời trang Việt</span>
<span class="org-dept">Phòng Phân tích Chiến lược &amp; Phát triển Kinh doanh</span>
</div>
</div>
<div class="doc-meta">
Số hiệu: <strong>TTVN-BC-2025-Q1-001</strong><br>
Phiên bản: <strong>1.0 — Chính thức</strong><br>
Phân loại: <em>Nội bộ · Bảo mật</em>
</div>
</div>
<div class="cover-hero">
<div class="cover-tag">Báo cáo Kinh doanh Định kỳ</div>
<h1 class="cover-title">Phân tích Kết quả<br>Kinh doanh Quý I<br>Năm Tài chính 2025</h1>
<p class="cover-subtitle">Đánh giá toàn diện hiệu quả hoạt động, xu hướng thị trường<br>và định hướng chiến lược cho Quý II/2025</p>
<div class="cover-divider"></div>
<div class="cover-metarow">
<div class="cover-metaitem">
<span class="cover-metalabel">Kỳ báo cáo</span>
<span class="cover-metaval">01/01 – 31/03/2025</span>
</div>
<div class="cover-metaitem">
<span class="cover-metalabel">Ngày phát hành</span>
<span class="cover-metaval">15 tháng 4, 2025</span>
</div>
<div class="cover-metaitem">
<span class="cover-metalabel">Người lập</span>
<span class="cover-metaval">Ban Phân tích Dữ liệu</span>
</div>
<div class="cover-metaitem">
<span class="cover-metalabel">Phê duyệt</span>
<span class="cover-metaval">Tổng Giám đốc</span>
</div>
</div>
</div>
<div class="cover-footer">
<span class="cover-footer-note">Tài liệu chứa thông tin kinh doanh nhạy cảm. Chỉ lưu hành nội bộ theo danh sách phân phối được phê duyệt.</span>
<span class="cover-stamp">Bảo mật nội bộ</span>
</div>
</div>
</div>
</div>
<!-- ═══ TRANG 2 — MỤC LỤC + TÓM TẮT ═══ -->
<div class="page cp">
<div class="rh">
<span class="rh-left"><span class="rh-dot"></span>Báo cáo Kết quả Kinh doanh Q1/2025 — TTVN</span>
<span class="rh-right">Trang 2 / 5</span>
</div>
<h1>Mục Lục</h1>
<div style="margin:5mm 0 8mm">
<div class="toc-item"><span class="toc-num">1.</span><span class="toc-text">Tóm tắt Điều hành</span><span class="toc-dots"></span><span class="toc-pg">3</span></div>
<div class="toc-item"><span class="toc-num">2.</span><span class="toc-text">Tổng quan Kết quả Kinh doanh</span><span class="toc-dots"></span><span class="toc-pg">3</span></div>
<div class="toc-item toc-sub"><span class="toc-num">2.1</span><span class="toc-text">Diễn biến doanh thu hàng tháng</span><span class="toc-dots"></span><span class="toc-pg">3</span></div>
<div class="toc-item toc-sub"><span class="toc-num">2.2</span><span class="toc-text">Các chỉ số vận hành cốt lõi</span><span class="toc-dots"></span><span class="toc-pg">3</span></div>
<div class="toc-item"><span class="toc-num">3.</span><span class="toc-text">Phân tích Doanh thu theo Danh mục Sản phẩm</span><span class="toc-dots"></span><span class="toc-pg">4</span></div>
<div class="toc-item"><span class="toc-num">4.</span><span class="toc-text">Hiệu quả Kênh Phân phối</span><span class="toc-dots"></span><span class="toc-pg">4</span></div>
<div class="toc-item"><span class="toc-num">5.</span><span class="toc-text">Phân tích Chi phí &amp; Lợi nhuận</span><span class="toc-dots"></span><span class="toc-pg">5</span></div>
<div class="toc-item"><span class="toc-num">6.</span><span class="toc-text">Xu hướng &amp; Dự báo Q2/2025</span><span class="toc-dots"></span><span class="toc-pg">5</span></div>
<div class="toc-item"><span class="toc-num">7.</span><span class="toc-text">Kết luận &amp; Khuyến nghị Chiến lược</span><span class="toc-dots"></span><span class="toc-pg">5</span></div>
</div>
<hr style="border:none;border-top:1pt solid var(--rule);margin:6mm 0">
<h2 data-n="1">Tóm tắt Điều hành</h2>
<div class="esbox">
<div class="esbox-label">Executive Summary</div>
<p>Quý I năm 2025, Công ty Cổ phần Thương mại Thời trang Việt ghi nhận tổng doanh thu đạt <strong>142,6 tỷ đồng</strong>, tăng trưởng <strong>18,4%</strong> so với cùng kỳ năm 2024 và vượt kế hoạch đề ra <strong>6,2%</strong>. Đây là mức tăng trưởng cao nhất trong vòng sáu quý gần đây, phản ánh hiệu quả của chiến lược mở rộng kênh thương mại điện tử và tái định vị thương hiệu phân khúc cao cấp triển khai từ Q3/2024.</p>
<p>Lợi nhuận gộp đạt <strong>61,3 tỷ đồng</strong>, biên lợi nhuận gộp cải thiện từ 38,7% lên <strong>43,0%</strong>, chủ yếu nhờ tối ưu hóa chuỗi cung ứng và gia tăng tỷ trọng sản phẩm tự thiết kế. Kênh thương mại điện tử lần đầu tiên vượt kênh bán lẻ truyền thống, chiếm <strong>52,3%</strong> tổng doanh thu — cột mốc chiến lược quan trọng trong lộ trình chuyển đổi số của công ty.</p>
<p>Chi phí vận hành tăng 22,1% do mở thêm ba showroom mới và đầu tư hạ tầng công nghệ. Ban lãnh đạo đánh giá đây là khoản đầu tư chiến lược, dự kiến hoàn vốn trong 4–6 quý tới. Lợi nhuận trước thuế đạt <strong>16,2 tỷ đồng</strong>, tăng 40,9% YoY.</p>
</div>
<div class="kpi-grid">
<div class="kpi" style="--kpi-accent:var(--blue-mid)">
<span class="kpi-label">Tổng Doanh thu</span>
<span class="kpi-value">142,6<span class="kpi-unit">tỷ</span></span>
<span class="kpi-change up">↑ 18,4% YoY</span>
</div>
<div class="kpi" style="--kpi-accent:var(--green)">
<span class="kpi-label">Lợi nhuận Gộp</span>
<span class="kpi-value">61,3<span class="kpi-unit">tỷ</span></span>
<span class="kpi-change up">↑ Biên 43,0%</span>
</div>
<div class="kpi" style="--kpi-accent:var(--gold)">
<span class="kpi-label">Tổng Đơn hàng</span>
<span class="kpi-value">94.280<span class="kpi-unit">đơn</span></span>
<span class="kpi-change up">↑ 23,7% YoY</span>
</div>
<div class="kpi" style="--kpi-accent:var(--red)">
<span class="kpi-label">Giá trị TB/Đơn</span>
<span class="kpi-value">1,51<span class="kpi-unit">triệu</span></span>
<span class="kpi-change dn">↓ 4,3% vs Q4</span>
</div>
</div>
<div class="pf">
<span class="pf-left">Công ty CP Thương mại Thời trang Việt · Bảo mật nội bộ</span>
<span class="pf-center">TTVN-BC-2025-Q1-001</span>
<span class="pf-right">2 / 5</span>
</div>
</div>
<!-- ═══ TRANG 3 — TỔNG QUAN + SẢN PHẨM ═══ -->
<div class="page cp">
<div class="rh">
<span class="rh-left"><span class="rh-dot"></span>Báo cáo Kết quả Kinh doanh Q1/2025 — TTVN</span>
<span class="rh-right">Trang 3 / 5</span>
</div>
<h2 data-n="2">Tổng quan Kết quả Kinh doanh</h2>
<p class="lead">Quý I/2025 đánh dấu bước chuyển biến tích cực với tăng trưởng vượt dự báo và cải thiện đồng đều trên hầu hết các chỉ số hoạt động cốt lõi.</p>
<p>Doanh thu tháng 3 đạt đỉnh <strong>54,8 tỷ đồng</strong>, tăng 31,2% so với tháng 1 cùng quý, phản ánh xu hướng tiêu dùng theo mùa vụ với nhu cầu mua sắm dịp Quốc tế Phụ nữ 8/3 và mùa hè đến sớm tại các tỉnh miền Nam. Hiệu ứng này mạnh hơn kỳ vọng khoảng 15%, cho thấy các chiến dịch truyền thông tập trung đã phát huy hiệu quả tích cực. Số lượng khách hàng mới đạt <strong>18.640 người</strong>, tăng 28,4% YoY, trong khi tỷ lệ giữ chân khách hàng cải thiện đáng kể từ 41,2% lên 47,8%.</p>
<div class="fig">
<div class="fig-header">
<span class="fig-num">Hình 2.1</span>
<span class="fig-title">Doanh thu hàng tháng Q1/2025 so với Q1/2024 (tỷ đồng)</span>
</div>
<div class="fig-wrap"><canvas id="c-monthly" height="175"></canvas></div>
<div class="fig-cap"><strong>Nguồn:</strong> Hệ thống quản lý bán hàng POS &amp; ERP nội bộ. Số liệu chưa bao gồm doanh thu từ nhượng quyền thương hiệu.</div>
</div>
<div class="note info">
<div class="note-icon">i</div>
<p><strong>Lưu ý:</strong> Tăng đột biến tháng 3 chịu ảnh hưởng yếu tố mùa vụ. Tăng trưởng hữu cơ (loại trừ lễ hội) ước tính 14–16% YoY, phù hợp tốc độ tăng trưởng bền vững dài hạn của thị trường.</p>
</div>
<h2 data-n="3">Phân tích Doanh thu theo Danh mục Sản phẩm</h2>
<p><strong>Thời trang Nữ</strong> tiếp tục dẫn đầu với 47,3% tổng doanh thu (67,5 tỷ đồng), tăng 21,7% YoY. Nhóm sản phẩm cao cấp (trên 1,5 triệu đồng) tăng 38,2% — vượt xa nhóm phổ thông (9,4%), khẳng định chiến lược premium đúng hướng. <strong>Phụ kiện &amp; Túi xách</strong> là danh mục tăng trưởng nhanh nhất với 29,4% YoY và biên lợi nhuận gộp cao nhất ở mức 52,7%.</p>
<div style="display:grid;grid-template-columns:1.2fr 1fr;gap:8mm;margin:5mm 0 3mm">
<div class="fig">
<div class="fig-header"><span class="fig-num">Hình 3.1</span><span class="fig-title">Cơ cấu doanh thu</span></div>
<div class="fig-wrap" style="padding:4mm"><canvas id="c-donut" height="200"></canvas></div>
</div>
<div class="fig">
<div class="fig-header"><span class="fig-num">Hình 3.2</span><span class="fig-title">Tăng trưởng YoY (%)</span></div>
<div class="fig-wrap" style="padding:4mm"><canvas id="c-cat-bar" height="200"></canvas></div>
</div>
</div>
<div class="tbl-wrap">
<div class="tbl-cap">Bảng 3.1 — Chi tiết kết quả theo danh mục sản phẩm Q1/2025</div>
<table>
<thead><tr>
<th>Danh mục</th>
<th class="r">Doanh thu (tỷ)</th>
<th class="r">Tỷ trọng</th>
<th class="r">Tăng trưởng YoY</th>
<th class="r">Biên LN gộp</th>
</tr></thead>
<tbody>
<tr><td>Thời trang Nữ</td><td class="r">67,5</td><td class="r">47,3%</td><td class="r"><span class="badge up">↑ 21,7%</span></td><td class="r">46,2%</td></tr>
<tr><td>Thời trang Nam</td><td class="r">38,2</td><td class="r">26,8%</td><td class="r"><span class="badge up">↑ 15,3%</span></td><td class="r">41,8%</td></tr>
<tr><td>Thời trang Trẻ em</td><td class="r">21,4</td><td class="r">15,0%</td><td class="r"><span class="badge up">↑ 12,6%</span></td><td class="r">39,1%</td></tr>
<tr><td>Phụ kiện &amp; Túi xách</td><td class="r">15,5</td><td class="r">10,9%</td><td class="r"><span class="badge up">↑ 29,4%</span></td><td class="r">52,7%</td></tr>
<tr class="trow-total"><td>Tổng cộng</td><td class="r">142,6</td><td class="r">100,0%</td><td class="r">↑ 18,4%</td><td class="r">43,0%</td></tr>
</tbody>
</table>
</div>
<div class="pf">
<span class="pf-left">Công ty CP Thương mại Thời trang Việt · Bảo mật nội bộ</span>
<span class="pf-center">TTVN-BC-2025-Q1-001</span>
<span class="pf-right">3 / 5</span>
</div>
</div>
<!-- ═══ TRANG 4 — KÊNH + CHI PHÍ ═══ -->
<div class="page cp">
<div class="rh">
<span class="rh-left"><span class="rh-dot"></span>Báo cáo Kết quả Kinh doanh Q1/2025 — TTVN</span>
<span class="rh-right">Trang 4 / 5</span>
</div>
<h2 data-n="4">Hiệu quả Kênh Phân phối</h2>
<p>Cấu trúc kênh phân phối bước qua cột mốc lịch sử trong Q1/2025 khi <strong>kênh thương mại điện tử lần đầu tiên vượt kênh bán lẻ truyền thống</strong>, chiếm 52,3% tổng doanh thu. Kênh TMĐT tự vận hành (owned) tăng 79,4% YoY nhờ đầu tư mạnh vào UX/UI website và chiến lược livestream bán hàng. Kênh bán lẻ trực tiếp với 23 showroom ghi nhận doanh thu 47,8 tỷ đồng, năng suất bình quân mỗi điểm bán tăng từ 1,87 lên <strong>2,08 tỷ đồng/quý</strong>.</p>
<div class="fig">
<div class="fig-header">
<span class="fig-num">Hình 4.1</span>
<span class="fig-title">Doanh thu theo Kênh Phân phối — So sánh Q1/2025 vs Q1/2024 (tỷ đồng)</span>
</div>
<div class="fig-wrap"><canvas id="c-channel" height="195"></canvas></div>
<div class="fig-cap"><strong>Nguồn:</strong> Hệ thống ERP và báo cáo sàn TMĐT. *Kênh Đối tác bao gồm đại lý bán buôn khu vực và đối tác B2B doanh nghiệp.</div>
</div>
<div class="note warn">
<div class="note-icon">!</div>
<p><strong>Cảnh báo chiến lược:</strong> Sự dịch chuyển nhanh sang kênh số tiềm ẩn rủi ro xói mòn trải nghiệm thương hiệu cao cấp. Cần triển khai chuẩn hóa trải nghiệm omnichannel trong Q2/2025.</p>
</div>
<h2 data-n="5">Phân tích Chi phí &amp; Biên Lợi nhuận</h2>
<p>Tổng chi phí hoạt động đạt <strong>126,4 tỷ đồng</strong>, tăng 14,1% — thấp hơn tốc độ tăng doanh thu (18,4%), cho thấy đòn bẩy hoạt động đang phát huy tích cực. Giá vốn hàng bán giảm từ 61,3% xuống còn <strong>57,0% doanh thu</strong> nhờ cải thiện điều khoản với nhà cung cấp và tăng tỷ trọng hàng tự sản xuất lên 28%.</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8mm;margin:4mm 0">
<div class="fig">
<div class="fig-header"><span class="fig-num">Hình 5.1</span><span class="fig-title">Cơ cấu chi phí (% DT)</span></div>
<div class="fig-wrap" style="padding:4mm"><canvas id="c-cost-pie" height="195"></canvas></div>
</div>
<div class="fig">
<div class="fig-header"><span class="fig-num">Hình 5.2</span><span class="fig-title">Xu hướng biên LN gộp (%)</span></div>
<div class="fig-wrap" style="padding:4mm"><canvas id="c-margin" height="195"></canvas></div>
</div>
</div>
<div class="tbl-wrap">
<div class="tbl-cap">Bảng 5.1 — Kết quả Kinh doanh tóm tắt (tỷ đồng)</div>
<table>
<thead><tr>
<th>Chỉ tiêu</th><th class="r">Q1/2025</th><th class="r">Q1/2024</th><th class="r">Tăng trưởng</th><th class="r">% Doanh thu</th>
</tr></thead>
<tbody>
<tr><td>Doanh thu thuần</td><td class="r">142,6</td><td class="r">120,4</td><td class="r"><span class="badge up">↑ 18,4%</span></td><td class="r">100,0%</td></tr>
<tr><td>Giá vốn hàng bán</td><td class="r">(81,3)</td><td class="r">(73,8)</td><td class="r"><span class="badge dn">↑ 10,2%</span></td><td class="r">57,0%</td></tr>
<tr><td><strong>Lợi nhuận gộp</strong></td><td class="r"><strong>61,3</strong></td><td class="r"><strong>46,6</strong></td><td class="r"><span class="badge up">↑ 31,5%</span></td><td class="r"><strong>43,0%</strong></td></tr>
<tr><td>Chi phí bán hàng &amp; MKT</td><td class="r">(18,7)</td><td class="r">(14,8)</td><td class="r"><span class="badge dn">↑ 26,4%</span></td><td class="r">13,1%</td></tr>
<tr><td>Chi phí quản lý DN</td><td class="r">(9,8)</td><td class="r">(8,5)</td><td class="r"><span class="badge dn">↑ 15,3%</span></td><td class="r">6,9%</td></tr>
<tr><td>Chi phí tài chính (ròng)</td><td class="r">(16,6)</td><td class="r">(11,8)</td><td class="r"><span class="badge dn">↑ 40,7%</span></td><td class="r">11,6%</td></tr>
<tr class="trow-total"><td>Lợi nhuận trước thuế (EBT)</td><td class="r">16,2</td><td class="r">11,5</td><td class="r">↑ 40,9%</td><td class="r">11,4%</td></tr>
</tbody>
</table>
</div>
<div class="pf">
<span class="pf-left">Công ty CP Thương mại Thời trang Việt · Bảo mật nội bộ</span>
<span class="pf-center">TTVN-BC-2025-Q1-001</span>
<span class="pf-right">4 / 5</span>
</div>
</div>
<!-- ═══ TRANG 5 — DỰ BÁO + KẾT LUẬN ═══ -->
<div class="page cp">
<div class="rh">
<span class="rh-left"><span class="rh-dot"></span>Báo cáo Kết quả Kinh doanh Q1/2025 — TTVN</span>
<span class="rh-right">Trang 5 / 5</span>
</div>
<h2 data-n="6">Xu hướng &amp; Dự báo Q2/2025</h2>
<p>Dựa trên dữ liệu đơn hàng tháng 4/2025 và mô hình phân tích nội bộ, doanh thu Q2/2025 ước đạt <strong>148–155 tỷ đồng</strong> (kịch bản cơ sở: <strong>151,5 tỷ</strong>, +17,4% YoY). Các động lực tăng trưởng chính gồm mùa hè cao điểm, ba showroom mới đạt hiệu quả bình thường, và bộ sưu tập Hè 2025 được phản hồi tích cực trong đợt xem trước VIP.</p>
<div class="fig">
<div class="fig-header">
<span class="fig-num">Hình 6.1</span>
<span class="fig-title">Dự báo Doanh thu Q2–Q4/2025 theo kịch bản (tỷ đồng)</span>
</div>
<div class="fig-wrap"><canvas id="c-forecast" height="195"></canvas></div>
<div class="fig-cap"><strong>Ghi chú:</strong> Đường liền = thực tế; đường đứt nét = dự báo kịch bản cơ sở. Kịch bản lạc quan giả định doanh thu TMĐT tăng thêm 8%; kịch bản xấu giả định tỷ giá tăng 5% và chi phí logistics tăng 8%.</div>
</div>
<div class="note success">
<div class="note-icon"></div>
<p><strong>Tín hiệu tích cực:</strong> Tỷ lệ đơn hàng lặp lại trong tháng 4 đạt 51,3% — cao nhất lịch sử công ty — cho thấy chương trình khách hàng thân thiết Style Club đang tạo ra nền tảng doanh thu ổn định và có thể dự đoán.</p>
</div>
<hr style="border:none;border-top:1pt solid var(--rule);margin:6mm 0">
<h2 data-n="7">Kết luận &amp; Khuyến nghị Chiến lược</h2>
<h3>7.1 Kết luận</h3>
<p>Quý I/2025 xác nhận đà phục hồi mạnh và bền vững của Thời trang Việt sau giai đoạn tái cơ cấu. Các chỉ số cốt lõi đều vượt kế hoạch, với điểm nhấn là cải thiện biên lợi nhuận gộp (+4,3 điểm phần trăm) và cột mốc kênh thương mại điện tử trở thành kênh chủ đạo. Nền tảng kinh doanh đủ vững chắc để hỗ trợ mục tiêu tăng trưởng doanh thu <strong>20% cho cả năm 2025</strong>.</p>
<h3>7.2 Khuyến nghị</h3>
<div style="margin-top:3mm">
<div class="rec-item"><span class="rec-n">1</span><span class="rec-text">Tăng ngân sách marketing digital thêm 20% trong Q2/2025, tập trung TikTok Shop và chiến dịch livestream để duy trì đà tăng trưởng kênh TMĐT trước mùa hè cao điểm.</span></div>
<div class="rec-item"><span class="rec-n">2</span><span class="rec-text">Đẩy nhanh lộ trình tăng tỷ trọng hàng tự thiết kế lên 35% trong Q3/2025, nhằm cải thiện thêm biên lợi nhuận gộp và giảm phụ thuộc nhà cung cấp nước ngoài.</span></div>
<div class="rec-item"><span class="rec-n">3</span><span class="rec-text">Triển khai chính sách hedging tỷ giá cho 60–70% lượng nhập khẩu theo hợp đồng kỳ hạn 3–6 tháng, giảm thiểu rủi ro biến động chi phí trong Q2–Q3/2025.</span></div>
<div class="rec-item"><span class="rec-n">4</span><span class="rec-text">Tạm dừng mở rộng showroom trong Q2/2025, tập trung nâng cao năng suất 3 điểm mới đạt mức hòa vốn trong vòng 2 quý, trước khi xem xét mở rộng tiếp.</span></div>
<div class="rec-item"><span class="rec-n">5</span><span class="rec-text">Mở rộng chương trình Style Club cho phân khúc B2B (đồng phục doanh nghiệp, đối tác phân phối) nhằm tạo nguồn doanh thu ổn định và nâng tỷ lệ giữ chân lên mục tiêu 55%.</span></div>
</div>
<hr class="fn-rule">
<p class="fn">¹ Số liệu trong báo cáo này là số liệu quản trị chưa qua kiểm toán. Báo cáo tài chính kiểm toán chính thức sẽ được công bố ngày 30/4/2025.</p>
<p class="fn">² Tốc độ tăng trưởng YoY so sánh Q1/2025 với Q1/2024. Số liệu Q1/2024 được điều chỉnh theo chuẩn mực kế toán mới áp dụng từ 01/01/2025.</p>
<div class="sigblock">
<div class="sig-item">
<div class="sig-label">Người lập báo cáo</div>
<div class="sig-line" style="height:14mm"></div>
<div class="sig-name">Nguyễn Minh Khoa</div>
<div class="sig-role">Trưởng phòng Phân tích Dữ liệu</div>
</div>
<div class="sig-item">
<div class="sig-label">Người kiểm tra</div>
<div class="sig-line" style="height:14mm"></div>
<div class="sig-name">Trần Thị Thu Hà</div>
<div class="sig-role">Giám đốc Tài chính (CFO)</div>
</div>
<div class="sig-item">
<div class="sig-label">Phê duyệt</div>
<div class="sig-line" style="height:14mm"></div>
<div class="sig-name">Lê Văn Đức</div>
<div class="sig-role">Tổng Giám đốc (CEO)</div>
</div>
</div>
<div class="pf" style="margin-top:6mm">
<span class="pf-left">Công ty CP Thương mại Thời trang Việt · Bảo mật nội bộ</span>
<span class="pf-center">TTVN-BC-2025-Q1-001</span>
<span class="pf-right">5 / 5 · Trang cuối</span>
</div>
</div>
<!-- ════════════════════════════════════════════════════════════════════
▼▼▼ CHART.JS CONFIG — AI CŨNG SINH PHẦN NÀY ▼▼▼
════════════════════════════════════════════════════════════════════ -->
<script>
const F = {family:"'Inter',sans-serif",size:9}
const TIP = {backgroundColor:"#0F2044",titleColor:"#93C5FD",bodyColor:"#F1F5F9",padding:11,cornerRadius:6,titleFont:{...F,size:9},bodyFont:{...F,size:11,weight:"700"}}
const AX = (cb)=>({grid:{color:"rgba(0,0,0,.05)"},border:{display:false},ticks:{color:"#6B7280",font:F,...(cb?{callback:cb}:{})}})
const AXX = {grid:{display:false},border:{display:false},ticks:{color:"#6B7280",font:F}}
// 1 Monthly
new Chart("c-monthly",{type:"bar",data:{
labels:["Tháng 1","Tháng 2","Tháng 3"],
datasets:[
{label:"Q1/2025",data:[38.4,49.4,54.8],backgroundColor:"#1A4A8C",hoverBackgroundColor:"#0F2044",borderRadius:4,borderSkipped:false},
{label:"Q1/2024",data:[32.1,41.7,46.6],backgroundColor:"rgba(26,74,140,.22)",hoverBackgroundColor:"rgba(26,74,140,.35)",borderRadius:4,borderSkipped:false}
]},options:{
responsive:true,maintainAspectRatio:false,
plugins:{legend:{display:true,position:"top",labels:{...F,color:"#374151",boxWidth:11,padding:14}},tooltip:TIP},
scales:{x:AXX,y:AX(v=>v+"T")}
}})
// 2 Donut
new Chart("c-donut",{type:"doughnut",data:{
labels:["Thời trang Nữ","Thời trang Nam","Trẻ em","Phụ kiện"],
datasets:[{data:[47.3,26.8,15.0,10.9],backgroundColor:["#1A4A8C","#C8860A","#0D6E4F","#7C3AED"],borderWidth:2,borderColor:"#fff",hoverOffset:5}]
},options:{
responsive:true,maintainAspectRatio:false,cutout:"65%",
plugins:{legend:{display:true,position:"bottom",labels:{...F,color:"#374151",boxWidth:10,padding:8}},tooltip:TIP}
}})
// 3 Category growth hbar
new Chart("c-cat-bar",{type:"bar",data:{
labels:["Phụ kiện","Nữ","Nam","Trẻ em"],
datasets:[{data:[29.4,21.7,15.3,12.6],backgroundColor:["#7C3AED","#1A4A8C","#C8860A","#0D6E4F"],borderRadius:4,borderSkipped:false}]
},options:{
responsive:true,maintainAspectRatio:false,indexAxis:"y",
plugins:{legend:{display:false},tooltip:TIP},
scales:{x:{...AX(v=>v+"%"),grid:{color:"rgba(0,0,0,.05)"},border:{display:false}},y:AXX}
}})
// 4 Channel grouped bar
new Chart("c-channel",{type:"bar",data:{
labels:["TMĐT (Owned)","Shopee / Lazada","Bán lẻ trực tiếp","Đối tác / B2B"],
datasets:[
{label:"Q1/2025",data:[34.8,39.8,47.8,20.2],backgroundColor:["#1A4A8C","#C8860A","#0D6E4F","#7C3AED"],borderRadius:4,borderSkipped:false},
{label:"Q1/2024",data:[19.4,31.7,42.9,26.4],backgroundColor:["rgba(26,74,140,.2)","rgba(200,134,10,.2)","rgba(13,110,79,.2)","rgba(124,58,237,.2)"],borderRadius:4,borderSkipped:false}
]},options:{
responsive:true,maintainAspectRatio:false,
plugins:{legend:{display:true,position:"top",labels:{...F,color:"#374151",boxWidth:11,padding:14}},tooltip:TIP},
scales:{x:AXX,y:AX(v=>v+"T")}
}})
// 5 Cost pie
new Chart("c-cost-pie",{type:"doughnut",data:{
labels:["Giá vốn (COGS)","Bán hàng & MKT","Quản lý DN","Chi phí TC","Lợi nhuận"],
datasets:[{data:[57.0,13.1,6.9,11.6,11.4],backgroundColor:["#1A4A8C","#C8860A","#0D6E4F","#7C3AED","#0891B2"],borderWidth:2,borderColor:"#fff",hoverOffset:4}]
},options:{
responsive:true,maintainAspectRatio:false,cutout:"58%",
plugins:{legend:{display:true,position:"bottom",labels:{...F,color:"#374151",boxWidth:10,padding:7,font:{...F,size:8.5}}},tooltip:TIP}
}})
// 6 Margin trend line
new Chart("c-margin",{type:"line",data:{
labels:["Q1/23","Q2/23","Q3/23","Q4/23","Q1/24","Q2/24","Q3/24","Q4/24","Q1/25"],
datasets:[
{label:"Biên LN gộp (%)",data:[36.4,37.1,37.8,38.2,38.7,39.4,40.1,41.3,43.0],borderColor:"#1A4A8C",backgroundColor:"rgba(26,74,140,.07)",fill:true,borderWidth:2.5,tension:0.4,pointRadius:3.5,pointBackgroundColor:"#1A4A8C",pointBorderWidth:0},
{label:"Biên EBT (%)",data:[8.1,8.4,8.9,9.2,9.6,9.8,10.1,10.5,11.4],borderColor:"#0D6E4F",backgroundColor:"rgba(13,110,79,.06)",fill:true,borderWidth:2,tension:0.4,pointRadius:3,pointBackgroundColor:"#0D6E4F",pointBorderWidth:0}
]},options:{
responsive:true,maintainAspectRatio:false,
plugins:{legend:{display:true,position:"top",labels:{...F,color:"#374151",boxWidth:10,padding:10}},tooltip:TIP},
scales:{x:{...AXX,ticks:{...AXX.ticks,maxRotation:35}},y:AX(v=>v+"%")}
}})
// 7 Forecast
new Chart("c-forecast",{type:"line",data:{
labels:["Q1/24","Q2/24","Q3/24","Q4/24","Q1/25","Q2/25","Q3/25","Q4/25"],
datasets:[
{label:"Thực tế",data:[120.4,131.2,128.7,149.3,142.6,null,null,null],borderColor:"#1A4A8C",backgroundColor:"rgba(26,74,140,.08)",fill:true,borderWidth:2.5,tension:0.3,pointRadius:4,pointBackgroundColor:"#1A4A8C"},
{label:"Cơ sở",data:[null,null,null,null,142.6,151.5,163.4,178.2],borderColor:"#C8860A",borderDash:[6,4],borderWidth:2,tension:0.3,pointRadius:4,pointBackgroundColor:"#fff",pointBorderColor:"#C8860A",pointBorderWidth:2,fill:false},
{label:"Lạc quan",data:[null,null,null,null,null,155.0,169.0,186.0],borderColor:"#0D6E4F",borderDash:[2,4],borderWidth:1.5,tension:0.3,pointRadius:0,fill:false},
{label:"Bi quan",data:[null,null,null,null,null,148.0,157.0,170.0],borderColor:"#B91C1C",borderDash:[2,4],borderWidth:1.5,tension:0.3,pointRadius:0,fill:false}
]},options:{
responsive:true,maintainAspectRatio:false,
plugins:{legend:{display:true,position:"top",labels:{...F,color:"#374151",boxWidth:14,padding:14}},tooltip:TIP},
scales:{x:AXX,y:{...AX(v=>v+"T"),min:100,max:200}}
}})
</script>
</body>
</html>
@import url('https://fonts.googleapis.com/css2?family=Crimson+Pro:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400;1,600&family=Inter:wght@400;500;600;700&display=swap');
*{box-sizing:border-box;margin:0;padding:0}
:root{
--navy:#0F2044;
--blue:#1A4A8C;
--blue-mid:#2563EB;
--blue-light:#EFF6FF;
--gold:#C8860A;
--gold-light:#FEF3C7;
--ink:#1C1C1C;
--mid:#4B5563;
--muted:#9CA3AF;
--rule:#D1D9E6;
--rule-light:#EBF0F8;
--bg:#F3F6FB;
--paper:#FFFFFF;
--green:#0D6E4F;
--green-light:#ECFDF5;
--red:#B91C1C;
--red-light:#FEF2F2;
--chart-1:#1A4A8C;
--chart-2:#C8860A;
--chart-3:#0D6E4F;
--chart-4:#7C3AED;
--chart-5:#B91C1C;
--chart-6:#0891B2;
}
html{background:var(--bg)}
body{font-family:'Crimson Pro',Georgia,serif;color:var(--ink);font-size:11pt;line-height:1.75;background:var(--bg)}
/* ── PAGE ── */
.page{width:210mm;margin:0 auto 10mm;background:var(--paper);position:relative;overflow:hidden}
@media screen{
body{padding:10mm 0 24mm}
.page{box-shadow:0 4px 40px rgba(15,32,68,.14),0 0 0 0.5pt rgba(15,32,68,.08)}
}
/* ══ COVER ══ */
.cover{min-height:297mm;display:flex;flex-direction:column;padding:0}
.cover-stripe{height:8mm;background:var(--navy);width:100%}
.cover-stripe-gold{height:2mm;background:linear-gradient(90deg,var(--gold),#E8A020,var(--gold));width:100%}
.cover-inner{flex:1;padding:14mm 20mm 10mm;display:flex;flex-direction:column}
.cover-toprow{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:14mm}
.org-logo{display:flex;align-items:center;gap:10pt}
.org-badge{width:42pt;height:42pt;background:var(--navy);border-radius:6pt;display:flex;align-items:center;justify-content:center;font-family:'Inter',sans-serif;font-size:9pt;font-weight:700;color:#fff;letter-spacing:.04em;line-height:1.2;text-align:center}
.org-info{display:flex;flex-direction:column;gap:2pt}
.org-name{font-family:'Inter',sans-serif;font-size:9pt;font-weight:600;color:var(--navy);letter-spacing:.02em}
.org-dept{font-family:'Inter',sans-serif;font-size:8pt;color:var(--muted)}
.doc-meta{text-align:right;font-family:'Inter',sans-serif;font-size:7.5pt;color:var(--muted);line-height:1.7}
.doc-meta strong{color:var(--ink);font-weight:600}
.cover-hero{flex:1;display:flex;flex-direction:column;justify-content:center;padding:8mm 0}
.cover-tag{display:inline-flex;align-items:center;gap:6pt;background:var(--blue-light);border:0.5pt solid #BFDBFE;border-radius:3pt;padding:3pt 10pt;margin-bottom:8mm;font-family:'Inter',sans-serif;font-size:7.5pt;font-weight:600;color:var(--blue-mid);letter-spacing:.08em;text-transform:uppercase}
.cover-tag::before{content:'';width:6pt;height:6pt;border-radius:50%;background:var(--blue-mid)}
.cover-title{font-size:30pt;font-weight:700;line-height:1.15;color:var(--navy);margin-bottom:4mm;letter-spacing:-.01em}
.cover-subtitle{font-size:13pt;font-weight:300;font-style:italic;color:var(--mid);margin-bottom:10mm;line-height:1.55}
.cover-divider{width:50mm;height:2.5pt;background:linear-gradient(90deg,var(--navy),var(--blue-mid));border-radius:2pt;margin-bottom:10mm}
.cover-metarow{display:grid;grid-template-columns:repeat(4,1fr);gap:0;border:0.5pt solid var(--rule);border-radius:6pt;overflow:hidden;margin-bottom:12mm}
.cover-metaitem{padding:5mm 5mm 4mm;border-right:0.5pt solid var(--rule)}
.cover-metaitem:last-child{border-right:none}
.cover-metalabel{font-family:'Inter',sans-serif;font-size:7pt;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);display:block;margin-bottom:3pt}
.cover-metaval{font-family:'Inter',sans-serif;font-size:9.5pt;font-weight:600;color:var(--navy)}
.cover-footer{border-top:0.5pt solid var(--rule);padding-top:5mm;display:flex;justify-content:space-between;align-items:center}
.cover-footer-note{font-family:'Inter',sans-serif;font-size:7.5pt;color:var(--muted);font-style:italic}
.cover-stamp{font-family:'Inter',sans-serif;font-size:7pt;font-weight:700;letter-spacing:.15em;text-transform:uppercase;color:var(--paper);background:var(--navy);padding:3pt 10pt;border-radius:2pt}
/* cover decorative shapes */
.cover-deco{position:absolute;right:-30mm;top:60mm;width:100mm;height:100mm;border-radius:50%;border:20pt solid rgba(37,99,235,.05);pointer-events:none}
.cover-deco2{position:absolute;right:-10mm;top:100mm;width:60mm;height:60mm;border-radius:50%;background:rgba(26,74,140,.04);pointer-events:none}
/* ══ CONTENT PAGES ══ */
.cp{padding:14mm 20mm 12mm;page-break-before:always}
/* running header */
.rh{display:flex;justify-content:space-between;align-items:center;padding-bottom:4mm;border-bottom:1.5pt solid var(--navy);margin-bottom:9mm}
.rh-left{font-family:'Inter',sans-serif;font-size:8pt;color:var(--mid);font-style:normal;display:flex;align-items:center;gap:6pt}
.rh-dot{width:4pt;height:4pt;border-radius:50%;background:var(--blue-mid)}
.rh-right{font-family:'Inter',sans-serif;font-size:8pt;color:var(--muted)}
/* headings */
h1{font-size:20pt;font-weight:700;color:var(--navy);margin-bottom:5mm;letter-spacing:-.02em;line-height:1.2}
h2{font-size:13.5pt;font-weight:600;color:var(--navy);margin:8mm 0 3mm;display:flex;align-items:center;gap:8pt}
h2::before{content:attr(data-n);font-family:'Inter',sans-serif;font-size:8pt;font-weight:700;color:var(--paper);background:var(--navy);width:18pt;height:18pt;border-radius:3pt;display:flex;align-items:center;justify-content:center;flex-shrink:0;letter-spacing:.02em}
h3{font-size:11.5pt;font-weight:600;color:var(--blue);margin:5mm 0 2mm;font-style:italic}
p{margin-bottom:4mm;text-align:justify}
.lead{font-size:12pt;font-weight:300;color:var(--mid);margin-bottom:6mm;line-height:1.8}
/* executive summary */
.esbox{border-left:3pt solid var(--navy);background:var(--blue-light);padding:6mm 8mm;margin:5mm 0 7mm;border-radius:0 6pt 6pt 0}
.esbox-label{font-family:'Inter',sans-serif;font-size:7.5pt;font-weight:700;text-transform:uppercase;letter-spacing:.12em;color:var(--blue-mid);margin-bottom:4mm}
.esbox p{margin-bottom:3mm;font-size:10.5pt}
.esbox p:last-child{margin-bottom:0}
/* kpi grid */
.kpi-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10pt;margin:5mm 0 7mm}
.kpi{background:var(--paper);border:0.5pt solid var(--rule);border-radius:8pt;padding:5mm;position:relative;overflow:hidden}
.kpi::after{content:'';position:absolute;top:0;left:0;right:0;height:3pt;background:var(--kpi-accent,var(--navy))}
.kpi-label{font-family:'Inter',sans-serif;font-size:7.5pt;font-weight:500;text-transform:uppercase;letter-spacing:.09em;color:var(--muted);display:block;margin-bottom:4pt}
.kpi-value{font-size:22pt;font-weight:700;color:var(--navy);line-height:1;display:block;margin-bottom:4pt;letter-spacing:-.02em}
.kpi-unit{font-size:9pt;font-weight:400;color:var(--muted);margin-left:2pt}
.kpi-change{font-family:'Inter',sans-serif;font-size:8.5pt;font-weight:600;display:inline-flex;align-items:center;gap:3pt;padding:2pt 7pt;border-radius:100pt}
.kpi-change.up{background:var(--green-light);color:var(--green)}
.kpi-change.dn{background:var(--red-light);color:var(--red)}
.kpi-change.nt{background:#F3F4F6;color:var(--muted)}
/* figure */
.fig{margin:6mm 0 4mm;page-break-inside:avoid}
.fig-header{display:flex;align-items:center;gap:7pt;margin-bottom:4mm}
.fig-num{font-family:'Inter',sans-serif;font-size:7.5pt;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:var(--paper);background:var(--blue-mid);padding:2pt 7pt;border-radius:3pt;white-space:nowrap}
.fig-title{font-family:'Inter',sans-serif;font-size:9pt;font-weight:600;color:var(--navy)}
.fig-wrap{background:#FAFCFF;border:0.5pt solid var(--rule);border-radius:8pt;padding:6mm}
.fig-cap{font-family:'Inter',sans-serif;font-size:8pt;color:var(--muted);margin-top:3mm;font-style:italic;line-height:1.5}
.fig-cap strong{font-style:normal;color:var(--mid)}
/* table */
.tbl-wrap{margin:5mm 0 3mm;overflow:hidden;border:0.5pt solid var(--rule);border-radius:8pt}
.tbl-cap{font-family:'Inter',sans-serif;font-size:8pt;font-weight:600;color:var(--navy);padding:4mm 5mm 3mm;background:#F8FAFF;border-bottom:0.5pt solid var(--rule)}
table{width:100%;border-collapse:collapse;font-size:9.5pt}
thead tr{background:var(--navy)}
thead th{padding:3mm 4mm;text-align:left;font-family:'Inter',sans-serif;font-size:8pt;font-weight:600;color:#fff;letter-spacing:.02em}
thead th.r{text-align:right}
tbody td{padding:2.5mm 4mm;border-bottom:0.5pt solid var(--rule-light);vertical-align:middle}
tbody td.r{text-align:right;font-family:'Inter',sans-serif;font-size:9pt}
tbody tr:last-child td{border-bottom:none}
tbody tr:hover{background:#F8FAFF}
.trow-total td{background:#F0F6FF;font-family:'Inter',sans-serif;font-weight:700;color:var(--navy);border-top:0.5pt solid var(--rule)!important}
.badge{display:inline-flex;align-items:center;font-family:'Inter',sans-serif;font-size:8pt;font-weight:600;padding:1.5pt 7pt;border-radius:100pt}
.badge.up{background:var(--green-light);color:var(--green)}
.badge.dn{background:var(--red-light);color:var(--red)}
.badge.nt{background:#F3F4F6;color:var(--muted)}
/* note/callout */
.note{display:flex;gap:8pt;align-items:flex-start;padding:4mm 5mm;border-radius:6pt;margin:4mm 0}
.note.info{background:var(--blue-light);border:0.5pt solid #BFDBFE}
.note.warn{background:var(--gold-light);border:0.5pt solid #FDE68A}
.note.success{background:var(--green-light);border:0.5pt solid #A7F3D0}
.note-icon{width:18pt;height:18pt;border-radius:50%;display:flex;align-items:center;justify-content:center;font-family:'Inter',sans-serif;font-size:9pt;font-weight:700;flex-shrink:0;margin-top:1pt}
.note.info .note-icon{background:#DBEAFE;color:var(--blue-mid)}
.note.warn .note-icon{background:#FDE68A;color:var(--gold)}
.note.success .note-icon{background:#A7F3D0;color:var(--green)}
.note p{margin:0;font-size:10pt;line-height:1.6}
.note.info p{color:#1E40AF}
.note.warn p{color:#78350F}
.note.success p{color:#065F46}
/* toc */
.toc-item{display:flex;align-items:baseline;gap:6pt;margin-bottom:3mm;font-size:10.5pt}
.toc-num{font-family:'Inter',sans-serif;font-weight:700;font-size:9pt;color:var(--blue-mid);min-width:18pt}
.toc-text{flex:1}
.toc-dots{flex:1;border-bottom:0.5pt dotted var(--rule);margin:0 5pt 3pt}
.toc-pg{font-family:'Inter',sans-serif;font-size:9pt;color:var(--muted);min-width:12pt;text-align:right}
.toc-sub{padding-left:18pt;font-size:10pt;color:var(--mid)}
/* recs */
.rec-item{display:flex;gap:10pt;align-items:flex-start;margin-bottom:4mm;padding:4mm 5mm;background:#FAFBFF;border-radius:6pt;border:0.5pt solid var(--rule)}
.rec-n{font-family:'Inter',sans-serif;font-size:8.5pt;font-weight:700;color:var(--paper);background:var(--navy);width:20pt;height:20pt;border-radius:50%;display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:1pt}
.rec-text{font-size:10.5pt;line-height:1.65}
/* sig block */
.sigblock{display:grid;grid-template-columns:repeat(3,1fr);gap:8mm;margin-top:9mm;padding-top:6mm;border-top:0.5pt solid var(--rule)}
.sig-item{}
.sig-label{font-family:'Inter',sans-serif;font-size:7.5pt;color:var(--muted);margin-bottom:10mm}
.sig-line{border-bottom:0.5pt solid var(--ink);margin-bottom:3mm}
.sig-name{font-family:'Inter',sans-serif;font-size:9.5pt;font-weight:600;color:var(--navy)}
.sig-role{font-family:'Inter',sans-serif;font-size:8pt;color:var(--muted);font-style:italic}
/* footnotes */
.fn-rule{border:none;border-top:0.5pt solid var(--rule);margin:8mm 0 4mm;width:45%}
.fn{font-family:'Inter',sans-serif;font-size:7.5pt;color:var(--muted);line-height:1.55;margin-bottom:2mm}
/* page footer */
.pf{border-top:0.5pt solid var(--rule);padding-top:4mm;margin-top:8mm;display:flex;justify-content:space-between;align-items:center}
.pf-left{font-family:'Inter',sans-serif;font-size:7.5pt;color:var(--muted)}
.pf-right{font-family:'Inter',sans-serif;font-size:7.5pt;color:var(--muted)}
.pf-center{font-family:'Inter',sans-serif;font-size:7.5pt;color:var(--navy);font-weight:600}
/* print */
@media print{
html,body{background:white}
.page{box-shadow:none;margin:0}
.no-print{display:none!important}
@page{size:A4;margin:0}
}
/* print btn */
.print-btn{position:fixed;bottom:22px;right:22px;background:var(--navy);color:white;border:none;border-radius:8pt;padding:9pt 20pt;font-family:'Inter',sans-serif;font-size:10pt;font-weight:600;cursor:pointer;box-shadow:0 4px 20px rgba(15,32,68,.35);z-index:999;letter-spacing:.03em;transition:all .2s}
.print-btn:hover{background:var(--blue);transform:translateY(-1px)}
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