analiza AI rozbudowana

This commit is contained in:
zzdrojewskipaw
2025-09-07 20:41:59 +02:00
parent 60a7959e0d
commit a1ddb05402

View File

@@ -2,43 +2,47 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
analysisAI.py — pobiera dane z MySQL, liczy preagregaty, renderuje HTML i dokłada analizę AI. analysisAI.py — pobiera dane z MySQL, liczy preagregaty, renderuje HTML i dodaje analizę AI.
ZMIENNE ŚRODOWISKOWE (mają domyślne wartości): ZMIENNE ŚRODOWISKOWE (wszystkie mają domyślne wartości):
OPENAI_API_KEY - klucz do OpenAI (gdy pusty -> skrypt pokaże wersję bez AI) OPENAI_API_KEY - klucz do OpenAI (gdy pusty -> fallback bez AI)
OPENAI_MODEL - np. gpt-4.1 (domyślnie) OPENAI_MODEL - np. gpt-4.1 (domyślnie), alternatywnie gpt-4.1-mini
MYSQL_HOST - host MySQL (domyślnie: localhost) MYSQL_HOST - host MySQL (domyślnie: twinpol-mysql56 lub localhost)
MYSQL_USER - użytkownik MySQL (domyślnie: root) MYSQL_USER - użytkownik MySQL (domyślnie: root)
MYSQL_PASSWORD - hasło MySQL (domyślnie: rootpassword) MYSQL_PASSWORD - hasło MySQL (domyślnie: rootpassword)
MYSQL_DATABASE - nazwa bazy (domyślnie: preDb_0dcc87940d3655fa574b253df04ca1c3) MYSQL_DATABASE - nazwa bazy (domyślnie: preDb_0dcc87940d3655fa574b253df04ca1c3)
MYSQL_PORT - port MySQL (domyślnie: 3306) MYSQL_PORT - port MySQL (domyślnie: 3306)
PERIOD_FROM - data od (YYYY-MM-DD); gdy brak -> poprzedni pełny miesiąc PERIOD_FROM - data od (YYYY-MM-DD); gdy brak -> poprzedni pełny miesiąc
PERIOD_TO - data do (YYYY-MM-DD, exclusive); gdy brak -> pierwszy dzień bieżącego miesiąca PERIOD_TO - data do (YYYY-MM-DD, exclusive); gdy brak -> 1. dzień bieżącego miesiąca
INVOICE_TYPE - typ dokumentu (domyślnie: normal) INVOICE_TYPE - typ dokumentu (domyślnie: normal)
""" """
import os, sys, json, math, time, warnings import os, sys, json, math, time, warnings
from datetime import date, timedelta from datetime import date, timedelta
#gtp-4.1
API_KEY = "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" API_KEY = "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"
#5 pro #5 pro
#API_KEY = "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" #API_KEY = "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"
# ──(1) Wycisz ostrzeżenia urllib3 (LibreSSL / stary OpenSSL) ───────────────────
# Wycisz ostrzeżenie urllib3 (LibreSSL na macOS itp.)
try: try:
from urllib3.exceptions import NotOpenSSLWarning from urllib3.exceptions import NotOpenSSLWarning
warnings.filterwarnings("ignore", category=NotOpenSSLWarning) warnings.filterwarnings("ignore", category=NotOpenSSLWarning)
except Exception: except Exception:
pass pass
# ──(2) Importy zewnętrzne ──────────────────────────────────────────────────────
import requests import requests
import mysql.connector import mysql.connector
# Twoje preagregaty (muszą być w tym samym katalogu / PYTHONPATH)
from preaggregates import compute_preaggregates, serialize_for_ai from preaggregates import compute_preaggregates, serialize_for_ai
# --------- utils --------- # ──(3) Konfiguracja klucza AI ──────────────────────────────────────────────────
# Wpisz tutaj klucz jeśli chcesz mieć go „na sztywno”, inaczej zostaw pusty:
API_KEY_HARDCODE = API_KEY # np. "sk-xxxx..." (NIEZALECANE w produkcji)
# ──(4) Utils ───────────────────────────────────────────────────────────────────
def getenv(k, d=None): def getenv(k, d=None):
return os.environ.get(k, d) return os.environ.get(k, d)
@@ -51,12 +55,13 @@ def last_full_month_bounds():
return from_dt.isoformat(), to_dt.isoformat() return from_dt.isoformat(), to_dt.isoformat()
def compact_table(table, limit=30): def compact_table(table, limit=30):
"""Przytnij listę rekordów i znormalizuj liczby (NaN/Inf -> None).""" """Przytnij listę rekordów (list[dict]) i znormalizuj liczby (NaN/Inf -> None)."""
out = [] out = []
if not table: if not table:
return out return out
lim = int(limit)
for i, row in enumerate(table): for i, row in enumerate(table):
if i >= int(limit): break if i >= lim: break
new = {} new = {}
for k, v in row.items(): for k, v in row.items():
if isinstance(v, float): if isinstance(v, float):
@@ -67,15 +72,15 @@ def compact_table(table, limit=30):
return out return out
def build_ai_payload(serialized, period_label): def build_ai_payload(serialized, period_label):
"""Kompaktowy JSON do AI (ograniczone rozmiary).""" """Kompaktowy JSON do AI (rozmiar przycięty, ale zawiera wszystkie główne tabele)."""
return { return {
"kpis_hint": {"period_label": period_label}, "kpis_hint": {"period_label": period_label},
"daily_sales": compact_table(serialized.get("daily_sales"), 30), "daily_sales": compact_table(serialized.get("daily_sales"), 60),
"product_summary": compact_table(serialized.get("product_summary"), 50), "product_summary": compact_table(serialized.get("product_summary"), 100),
"customer_summary": compact_table(serialized.get("customer_summary"), 50), "customer_summary": compact_table(serialized.get("customer_summary"), 100),
"top10_products_by_sales": compact_table(serialized.get("top10_products_by_sales"), 10), "top10_products_by_sales": compact_table(serialized.get("top10_products_by_sales"), 10),
"top10_customers_by_sales": compact_table(serialized.get("top10_customers_by_sales"), 10), "top10_customers_by_sales": compact_table(serialized.get("top10_customers_by_sales"), 10),
"product_daily_sample": compact_table(serialized.get("product_daily"), 40), "product_daily_sample": compact_table(serialized.get("product_daily"), 100),
} }
def call_openai_chat(api_key, model, system_prompt, user_payload_json, def call_openai_chat(api_key, model, system_prompt, user_payload_json,
@@ -90,7 +95,7 @@ def call_openai_chat(api_key, model, system_prompt, user_payload_json,
{"role": "user", "content": "Dane (JSON):\n\n" + user_payload_json}, {"role": "user", "content": "Dane (JSON):\n\n" + user_payload_json},
], ],
"temperature": temperature, "temperature": temperature,
# "max_tokens": 1200, # możesz odkomentować, aby ogranicz długość odpowiedzi # "max_tokens": 1200, # opcjonalnie ogranicz długość odpowiedzi
} }
last_err = None last_err = None
for attempt in range(1, int(max_retries) + 1): for attempt in range(1, int(max_retries) + 1):
@@ -112,7 +117,7 @@ def fmt_money(v):
return str(v) return str(v)
def html_table(records, title=None, max_rows=20): def html_table(records, title=None, max_rows=20):
"""Proste generowanie tabeli HTML z listy dict-ów.""" """Proste generowanie tabeli HTML z listy dict-ów (lekki CSS inline w <style> poniżej)."""
if not records: if not records:
return '<div class="empty">Brak danych</div>' return '<div class="empty">Brak danych</div>'
cols = list(records[0].keys()) cols = list(records[0].keys())
@@ -124,9 +129,8 @@ def html_table(records, title=None, max_rows=20):
for c in cols: for c in cols:
val = r.get(c, "") val = r.get(c, "")
if isinstance(val, (int, float)): if isinstance(val, (int, float)):
# format dla kolumn „sales”, „qty”, „asp”, itp. lekko ładniej if any(x in c.lower() for x in ("sales", "total", "netto", "value", "asp", "qty", "quantity", "share", "change")):
if "sales" in c or "total" in c or "netto" in c: tds.append('<td class="num">{}</td>'.format(fmt_money(val) if "sales" in c.lower() or "total" in c.lower() or "netto" in c.lower() else val))
tds.append('<td class="num">{}</td>'.format(fmt_money(val)))
else: else:
tds.append('<td class="num">{}</td>'.format(val)) tds.append('<td class="num">{}</td>'.format(val))
else: else:
@@ -139,7 +143,7 @@ def html_table(records, title=None, max_rows=20):
'<thead><tr>{}</tr></thead><tbody>{}</tbody></table></div>'.format(thead, "".join(trs)) '<thead><tr>{}</tr></thead><tbody>{}</tbody></table></div>'.format(thead, "".join(trs))
) )
def render_report_html(period_label, kpis, parts, ai_section): def render_report_html(period_label, kpis, parts, ai_section, model_alias):
"""Składa finalny jeden <div> z lekkim CSS inline.""" """Składa finalny jeden <div> z lekkim CSS inline."""
css = ( css = (
"font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;" "font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;"
@@ -150,11 +154,8 @@ def render_report_html(period_label, kpis, parts, ai_section):
'<div class="kpi"><div class="kpi-label">{label}</div>' '<div class="kpi"><div class="kpi-label">{label}</div>'
'<div class="kpi-value">{value}</div></div>' '<div class="kpi-value">{value}</div></div>'
) )
kpi_html = "".join( kpi_html = "".join(kpi_item.format(label=lbl, value=val) for (lbl, val) in kpis)
kpi_item.format(label=lbl, value=val) for (lbl, val) in kpis
)
sections_html = "".join(parts) sections_html = "".join(parts)
# jeśli AI nie zwróciło <div>, owiń
if ai_section and not ai_section.lstrip().startswith("<div"): if ai_section and not ai_section.lstrip().startswith("<div"):
ai_section = '<div class="ai-section">{}</div>'.format(ai_section) ai_section = '<div class="ai-section">{}</div>'.format(ai_section)
@@ -166,8 +167,8 @@ def render_report_html(period_label, kpis, parts, ai_section):
</div> </div>
{sections_html} {sections_html}
<div style="margin-top:20px;border-top:1px solid #e5e7eb;padding-top:16px;"> <div style="margin-top:20px;border-top:1px solid #e5e7eb;padding-top:16px;">
<h3 style="margin:0 0 8px;font-size:18px;">Analiza i rekomendacje (AI)</h3> <h3 style="margin:0 0 8px;font-size:18px;">Analiza i rekomendacje (AI{(' · ' + model_alias) if model_alias else ''})</h3>
{ai_section if ai_section else '<div style="color:#6b7280">Brak odpowiedzi AI (brak OPENAI_API_KEY)</div>'} {ai_section if ai_section else '<div style="color:#6b7280">Brak odpowiedzi AI</div>'}
</div> </div>
</div> </div>
<style> <style>
@@ -185,13 +186,11 @@ def render_report_html(period_label, kpis, parts, ai_section):
</style> </style>
""" """
# --------- main --------- # ──(5) Główna logika ───────────────────────────────────────────────────────────
def main(): def main():
# Konfiguracja DB # Konfiguracja DB
cfg = { cfg = {
"host": getenv("MYSQL_HOST", "twinpol-mysql56"), "host": getenv("MYSQL_HOST", "twinpol-mysql56"),
# "host": getenv("MYSQL_HOST", "localhost"),
"user": getenv("MYSQL_USER", "root"), "user": getenv("MYSQL_USER", "root"),
"password": getenv("MYSQL_PASSWORD", "rootpassword"), "password": getenv("MYSQL_PASSWORD", "rootpassword"),
"database": getenv("MYSQL_DATABASE", "preDb_0dcc87940d3655fa574b253df04ca1c3"), "database": getenv("MYSQL_DATABASE", "preDb_0dcc87940d3655fa574b253df04ca1c3"),
@@ -206,15 +205,21 @@ def main():
period_label = "{} .. {}".format(period_from, period_to) period_label = "{} .. {}".format(period_from, period_to)
invoice_type = getenv("INVOICE_TYPE", "normal") invoice_type = getenv("INVOICE_TYPE", "normal")
# Konfiguracja AI # Konfiguracja AI (model do API + alias do UI)
#api_key = getenv("OPENAI_API_KEY", "") api_key = API_KEY_HARDCODE or getenv("OPENAI_API_KEY", "")
api_key = API_KEY
model = getenv("OPENAI_MODEL", "gpt-4.1") model = getenv("OPENAI_MODEL", "gpt-4.1")
#model = getenv("OPENAI_MODEL", "gpt-5") MODEL_ALIAS = {
"gpt-4.1": "GPT-4.1",
"gpt-4.1-mini": "GPT-4.1-mini",
"gpt-4o": "GPT-4o",
"gpt-4o-mini": "GPT-4o-mini",
}
model_alias = MODEL_ALIAS.get(model, model)
system_prompt = ( system_prompt = (
"Jesteś analitykiem sprzedaży. Zwróć TYLKO jedną sekcję HTML (bez <html>/<head>/<body>), " "Jesteś analitykiem sprzedaży. Zwróć TYLKO jedną sekcję HTML (bez <html>/<head>/<body>), "
"może być pojedynczy <div> z nagłówkami i listami. Podsumuj trendy, wskaż kluczowe produkty/klientów, " "może być pojedynczy <div> z nagłówkami i listami. Podsumuj kluczowe trendy (dzień, mix), wskaż top produkty/klientów, "
"anomalia/odchylenia oraz daj 36 praktycznych rekomendacji. Krótko, konkretnie, po polsku." "anomalia/odchylenia oraz daj 36 praktycznych rekomendacji dla sprzedaży/zaopatrzenia/marketingu. Krótko i konkretnie, po polsku."
) )
# SQL -> rows # SQL -> rows
@@ -247,7 +252,7 @@ def main():
'max-width:900px;margin:24px auto;padding:16px 20px;border:1px solid #fecaca;' 'max-width:900px;margin:24px auto;padding:16px 20px;border:1px solid #fecaca;'
'border-radius:12px;background:#fff5f5;color:#991b1b;">' 'border-radius:12px;background:#fff5f5;color:#991b1b;">'
'<h3 style="margin:0 0 8px;font-size:18px;">Błąd połączenia/zapytania MySQL</h3>' '<h3 style="margin:0 0 8px;font-size:18px;">Błąd połączenia/zapytania MySQL</h3>'
'<p style="margin:0;">{}</p></div>'.format(str(e)) f'<p style="margin:0;">{str(e)}</p></div>'
) )
sys.exit(1) sys.exit(1)
@@ -261,7 +266,7 @@ def main():
'max-width:900px;margin:24px auto;padding:16px 20px;border:1px solid #fecaca;' 'max-width:900px;margin:24px auto;padding:16px 20px;border:1px solid #fecaca;'
'border-radius:12px;background:#fff5f5;color:#991b1b;">' 'border-radius:12px;background:#fff5f5;color:#991b1b;">'
'<h3 style="margin:0 0 8px;font-size:18px;">Błąd preagregacji</h3>' '<h3 style="margin:0 0 8px;font-size:18px;">Błąd preagregacji</h3>'
'<p style="margin:0;">{}</p></div>'.format(str(e)) f'<p style="margin:0;">{str(e)}</p></div>'
) )
sys.exit(1) sys.exit(1)
@@ -278,17 +283,21 @@ def main():
("ASP (PLN/szt.)", fmt_money(asp) if asp is not None else ""), ("ASP (PLN/szt.)", fmt_money(asp) if asp is not None else ""),
] ]
# Sekcje HTML z Twoich preagregatów # Sekcje HTML — WYŚWIETLAMY WSZYSTKIE KLUCZOWE PREAGREGATY
top_prod = serialized.get("top10_products_by_sales") or [] top_prod = serialized.get("top10_products_by_sales") or []
top_cli = serialized.get("top10_customers_by_sales") or [] top_cli = serialized.get("top10_customers_by_sales") or []
prod_tbl = html_table(top_prod, title="Top 10 produktów (po sprzedaży)", max_rows=10) daily_tbl = html_table(serialized.get("daily_sales") or [], title="Sprzedaż dzienna (skrót)", max_rows=30)
cust_tbl = html_table(top_cli, title="Top 10 klientów (po sprzedaży)", max_rows=10) prod_sum_tbl = html_table(serialized.get("product_summary") or [], title="Podsumowanie produktów (skrót)", max_rows=30)
cust_sum_tbl = html_table(serialized.get("customer_summary") or [], title="Podsumowanie klientów (skrót)", max_rows=30)
prod_daily_tbl= html_table(serialized.get("product_daily") or [], title="Produkt × Dzień (próbka)", max_rows=30)
prod_tbl = html_table(top_prod, title="Top 10 produktów (po sprzedaży)", max_rows=10)
cust_tbl = html_table(top_cli, title="Top 10 klientów (po sprzedaży)", max_rows=10)
# Dane do AI # Dane do AI
ai_data = build_ai_payload(serialized, period_label) ai_data = build_ai_payload(serialized, period_label)
ai_json = json.dumps(ai_data, ensure_ascii=False, separators=(",", ":"), default=str) ai_json = json.dumps(ai_data, ensure_ascii=False, separators=(",", ":"), default=str)
# Wołanie AI (opcjonalne) # Wołanie AI (z fallbackiem na mini model przy 429: insufficient_quota)
ai_section = "" ai_section = ""
if api_key: if api_key:
try: try:
@@ -303,19 +312,42 @@ def main():
max_retries=3, max_retries=3,
) )
except Exception as e: except Exception as e:
ai_section = ( err = str(e)
'<div style="color:#991b1b;background:#fff5f5;border:1px solid #fecaca;' if "insufficient_quota" in err or "You exceeded your current quota" in err:
'padding:10px;border-radius:8px;">Błąd wywołania AI: {}</div>'.format(str(e)) # spróbuj tańszego modelu
) try:
ai_section = call_openai_chat(
api_key=api_key,
model="gpt-4.1-mini",
system_prompt=system_prompt,
user_payload_json=ai_json,
temperature=0.3,
connect_timeout=10,
read_timeout=90,
max_retries=2,
)
model_alias = "GPT-5 Mini"
except Exception as ee:
ai_section = (
'<div style="color:#991b1b;background:#fff5f5;border:1px solid #fecaca;'
'padding:10px;border-radius:8px;">'
f'Brak dostępnego limitu API. {str(ee)}</div>'
)
else:
ai_section = (
'<div style="color:#991b1b;background:#fff5f5;border:1px solid #fecaca;'
'padding:10px;border-radius:8px;">'
f'Błąd wywołania AI: {err}</div>'
)
# Finalny HTML (jeden <div>) # Finalny HTML (jeden <div>)
report_html = render_report_html( report_html = render_report_html(
period_label=period_label, period_label=period_label,
kpis=kpis, kpis=kpis,
parts=[prod_tbl, cust_tbl], parts=[prod_tbl, cust_tbl, daily_tbl, prod_sum_tbl, cust_sum_tbl, prod_daily_tbl],
ai_section=ai_section ai_section=ai_section,
model_alias=model_alias if api_key else ""
) )
sys.stdout.write(report_html) sys.stdout.write(report_html)
if __name__ == "__main__": if __name__ == "__main__":