feat: simplify Chat tab - topic categories and cleaner feedback view
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Replace anonymized metadata ("45 chars") with real topic categories
(O firmach, Szukanie kontaktu, O wydarzeniach, etc). Remove empty
conversion stats, UTM sources, avg message length. Keep feedback
with ratings in compact two-column layout.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
cdd31b77a8
commit
42e42c5fa0
@ -1358,13 +1358,9 @@ def _tab_overview(db, start_date, days):
|
||||
# ============================================================
|
||||
|
||||
def _tab_chat(db, start_date, days):
|
||||
"""Chat analytics tab - conversations, feedback, query patterns."""
|
||||
"""Chat analytics tab - how members use NordaGPT."""
|
||||
start_dt = datetime.combine(start_date, datetime.min.time())
|
||||
|
||||
# Basic stats
|
||||
total_conversations = db.query(AIChatConversation).count()
|
||||
total_user_messages = db.query(AIChatMessage).filter_by(role='user').count()
|
||||
|
||||
# Period stats
|
||||
period_conversations = db.query(AIChatConversation).filter(
|
||||
AIChatConversation.started_at >= start_dt
|
||||
@ -1373,6 +1369,10 @@ def _tab_chat(db, start_date, days):
|
||||
AIChatMessage.role == 'user',
|
||||
AIChatMessage.created_at >= start_dt
|
||||
).count()
|
||||
today_messages = db.query(AIChatMessage).filter(
|
||||
AIChatMessage.role == 'user',
|
||||
func.date(AIChatMessage.created_at) == date.today()
|
||||
).count()
|
||||
|
||||
# Feedback stats
|
||||
feedback_count = db.query(AIChatMessage).filter(
|
||||
@ -1382,87 +1382,54 @@ def _tab_chat(db, start_date, days):
|
||||
negative_feedback = db.query(AIChatMessage).filter_by(feedback_rating=1).count()
|
||||
satisfaction_rate = round(positive_feedback / feedback_count * 100, 1) if feedback_count > 0 else 0
|
||||
|
||||
# Recent feedback
|
||||
# Recent feedback with conversation context
|
||||
recent_feedback = db.query(AIChatMessage).filter(
|
||||
AIChatMessage.feedback_rating.isnot(None)
|
||||
).order_by(desc(AIChatMessage.feedback_at)).limit(20).all()
|
||||
|
||||
# SECURITY: Query statistics only - do NOT expose raw user content
|
||||
query_stats = {
|
||||
'total_today': db.query(AIChatMessage).filter(
|
||||
AIChatMessage.role == 'user',
|
||||
func.date(AIChatMessage.created_at) == date.today()
|
||||
).count(),
|
||||
'avg_length': db.query(func.avg(func.length(AIChatMessage.content))).filter(
|
||||
AIChatMessage.role == 'user'
|
||||
).scalar() or 0,
|
||||
'queries_with_company': db.query(AIChatMessage).filter(
|
||||
AIChatMessage.role == 'user',
|
||||
AIChatMessage.content.ilike('%firma%')
|
||||
).count(),
|
||||
'queries_with_contact': db.query(AIChatMessage).filter(
|
||||
AIChatMessage.role == 'user',
|
||||
AIChatMessage.content.ilike('%kontakt%') | AIChatMessage.content.ilike('%telefon%') | AIChatMessage.content.ilike('%email%')
|
||||
).count()
|
||||
# What do people ask about? (category counts from period)
|
||||
period_user_msgs = db.query(AIChatMessage).filter(
|
||||
AIChatMessage.role == 'user',
|
||||
AIChatMessage.created_at >= start_dt
|
||||
).all()
|
||||
|
||||
categories = {
|
||||
'O firmach': ['firma', 'spółka', 'przedsiębior', 'działalność'],
|
||||
'Szukanie kontaktu': ['kontakt', 'telefon', 'email', 'numer', 'adres', 'www'],
|
||||
'O wydarzeniach': ['wydarzen', 'spotkani', 'konferencj', 'szkoleni', 'networking'],
|
||||
'O usługach': ['usług', 'ofert', 'cennik', 'wycen', 'współprac'],
|
||||
'O stowarzyszeniu': ['norda', 'izba', 'stowarzysz', 'członk', 'składk'],
|
||||
}
|
||||
category_counts = {cat: 0 for cat in categories}
|
||||
other_count = 0
|
||||
|
||||
# Recent queries - anonymized (show only metadata, not content)
|
||||
recent_queries_raw = db.query(AIChatMessage).filter_by(role='user').order_by(
|
||||
desc(AIChatMessage.created_at)
|
||||
).limit(50).all()
|
||||
for msg in period_user_msgs:
|
||||
content = (msg.content or '').lower()
|
||||
matched = False
|
||||
for cat, keywords in categories.items():
|
||||
if any(kw in content for kw in keywords):
|
||||
category_counts[cat] += 1
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
other_count += 1
|
||||
|
||||
recent_queries = [
|
||||
{
|
||||
'length': len(q.content) if q.content else 0,
|
||||
'created_at': q.created_at,
|
||||
'has_company_mention': 'firma' in (q.content or '').lower(),
|
||||
'has_contact_request': any(kw in (q.content or '').lower() for kw in ['kontakt', 'telefon', 'email', 'www'])
|
||||
}
|
||||
for q in recent_queries_raw
|
||||
]
|
||||
|
||||
# Conversion events from chat
|
||||
conversion_stats = {}
|
||||
try:
|
||||
chat_conversions = db.query(
|
||||
ConversionEvent.event_type,
|
||||
func.count(ConversionEvent.id).label('count')
|
||||
).filter(
|
||||
ConversionEvent.event_category == 'chat',
|
||||
func.date(ConversionEvent.converted_at) >= start_date
|
||||
).group_by(ConversionEvent.event_type).all()
|
||||
conversion_stats = dict(chat_conversions)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# UTM sources leading to chat
|
||||
utm_to_chat = {}
|
||||
try:
|
||||
utm_query = db.query(
|
||||
UserSession.utm_source,
|
||||
func.count(UserSession.id).label('count')
|
||||
).filter(
|
||||
UserSession.utm_source.isnot(None),
|
||||
func.date(UserSession.started_at) >= start_date
|
||||
).group_by(UserSession.utm_source).order_by(desc('count')).limit(10).all()
|
||||
utm_to_chat = dict(utm_query)
|
||||
except Exception:
|
||||
pass
|
||||
category_counts['Inne'] = other_count
|
||||
# Sort by count, remove zeros
|
||||
topic_list = [{'name': k, 'count': v} for k, v in category_counts.items() if v > 0]
|
||||
topic_list.sort(key=lambda x: x['count'], reverse=True)
|
||||
max_topic = topic_list[0]['count'] if topic_list else 1
|
||||
|
||||
return {
|
||||
'total_conversations': total_conversations,
|
||||
'total_user_messages': total_user_messages,
|
||||
'period_conversations': period_conversations,
|
||||
'period_messages': period_messages,
|
||||
'feedback_count': feedback_count,
|
||||
'today_messages': today_messages,
|
||||
'positive_feedback': positive_feedback,
|
||||
'negative_feedback': negative_feedback,
|
||||
'satisfaction_rate': satisfaction_rate,
|
||||
'recent_feedback': recent_feedback,
|
||||
'query_stats': query_stats,
|
||||
'recent_queries': recent_queries,
|
||||
'conversion_stats': conversion_stats,
|
||||
'utm_sources': utm_to_chat,
|
||||
'topics': topic_list,
|
||||
'max_topic': max_topic,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -858,144 +858,77 @@
|
||||
<!-- ============================================================ -->
|
||||
{% elif tab == 'chat' %}
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<!-- Stats -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card success">
|
||||
<div class="stat-value">{{ data.total_conversations }}</div>
|
||||
<div class="stat-label">Rozmów ogółem</div>
|
||||
</div>
|
||||
<div class="stat-card info">
|
||||
<div class="stat-value">{{ data.period_conversations }}</div>
|
||||
<div class="stat-label">Rozmów (okres)</div>
|
||||
<div class="stat-label">Rozmów ({% if period == 'day' %}dziś{% elif period == 'week' %}7 dni{% else %}30 dni{% endif %})</div>
|
||||
</div>
|
||||
<div class="stat-card success">
|
||||
<div class="stat-value">{{ data.positive_feedback }}</div>
|
||||
<div class="stat-label">Pozytywnych ocen</div>
|
||||
</div>
|
||||
<div class="stat-card error">
|
||||
<div class="stat-value">{{ data.negative_feedback }}</div>
|
||||
<div class="stat-label">Negatywnych ocen</div>
|
||||
<div class="stat-card info">
|
||||
<div class="stat-value">{{ data.period_messages }}</div>
|
||||
<div class="stat-label">Wiadomości</div>
|
||||
</div>
|
||||
<div class="stat-card {% if data.satisfaction_rate >= 70 %}success{% elif data.satisfaction_rate >= 40 %}warning{% else %}error{% endif %}">
|
||||
<div class="stat-value">{{ data.satisfaction_rate }}%</div>
|
||||
<div class="stat-label">Satysfakcja</div>
|
||||
<div class="stat-label">Satysfakcja ({{ data.positive_feedback }}+ / {{ data.negative_feedback }}-)</div>
|
||||
</div>
|
||||
<div class="stat-card info">
|
||||
<div class="stat-value">{{ data.today_messages }}</div>
|
||||
<div class="stat-label">Zapytań dzisiaj</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="two-columns">
|
||||
<!-- Query Statistics (Anonymized) -->
|
||||
<!-- What do people ask about -->
|
||||
<div class="section-card">
|
||||
<h2>Statystyki zapytań <span style="font-size: var(--font-size-xs); color: var(--text-muted);">dane zanonimizowane</span></h2>
|
||||
<div class="stats-grid" style="grid-template-columns: repeat(2, 1fr);">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="font-size: var(--font-size-xl);">{{ data.query_stats.total_today }}</div>
|
||||
<div class="stat-label">Zapytań dzisiaj</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="font-size: var(--font-size-xl);">{{ data.query_stats.avg_length|int }}</div>
|
||||
<div class="stat-label">Śr. długość (znaki)</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="font-size: var(--font-size-xl);">{{ data.query_stats.queries_with_company }}</div>
|
||||
<div class="stat-label">O firmach</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="font-size: var(--font-size-xl);">{{ data.query_stats.queries_with_contact }}</div>
|
||||
<div class="stat-label">O kontaktach</div>
|
||||
<h2>O czym pytają członkowie</h2>
|
||||
{% if data.topics %}
|
||||
{% for t in data.topics %}
|
||||
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 10px;">
|
||||
<div style="width: 140px; font-size: var(--font-size-sm); font-weight: 500;">{{ t.name }}</div>
|
||||
<div style="flex: 1; height: 20px; background: #e2e8f0; border-radius: 4px; overflow: hidden;">
|
||||
<div style="height: 100%; width: {{ (t.count / data.max_topic * 100)|int }}%; background: #6366f1; border-radius: 4px; display: flex; align-items: center; padding-left: 6px;">
|
||||
{% if (t.count / data.max_topic * 100)|int >= 15 %}<span style="font-size: 11px; color: white; font-weight: 600;">{{ t.count }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div style="width: 40px; font-size: var(--font-size-xs); color: var(--text-muted); text-align: right;">{{ t.count }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p style="color: var(--text-muted); text-align: center; padding: var(--spacing-lg);">Brak zapytań w wybranym okresie</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Conversion Events -->
|
||||
<!-- Recent feedback -->
|
||||
<div class="section-card">
|
||||
<h2>Konwersje z chatu</h2>
|
||||
{% if data.conversion_stats %}
|
||||
<div class="stats-grid" style="grid-template-columns: repeat(2, 1fr);">
|
||||
{% for event_type, count in data.conversion_stats.items() %}
|
||||
<div style="background: var(--background); padding: var(--spacing-md); border-radius: var(--radius); text-align: center;">
|
||||
<div style="font-size: var(--font-size-xl); font-weight: 700; color: var(--primary);">{{ count }}</div>
|
||||
<div style="font-size: var(--font-size-sm); color: var(--text-secondary);">{{ event_type }}</div>
|
||||
<h2>Jak oceniają NordaGPT</h2>
|
||||
{% if data.recent_feedback %}
|
||||
<div style="max-height: 400px; overflow-y: auto;">
|
||||
{% for msg in data.recent_feedback %}
|
||||
<div style="padding: var(--spacing-sm) 0; border-bottom: 1px solid var(--border);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
|
||||
<span class="badge {% if msg.feedback_rating == 2 %}badge-active{% else %}badge-high{% endif %}">
|
||||
{% if msg.feedback_rating == 2 %}Pomocne{% else %}Do poprawy{% endif %}
|
||||
</span>
|
||||
<span style="font-size: var(--font-size-xs); color: var(--text-muted);">
|
||||
{{ msg.feedback_at.strftime('%d.%m %H:%M') if msg.feedback_at else '' }}
|
||||
</span>
|
||||
</div>
|
||||
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); max-height: 50px; overflow: hidden;">
|
||||
{{ msg.content[:150] }}{% if msg.content|length > 150 %}...{% endif %}
|
||||
</div>
|
||||
{% if msg.feedback_comment %}
|
||||
<div style="margin-top: 4px; font-size: var(--font-size-xs); color: var(--text-primary);"><strong>Komentarz:</strong> {{ msg.feedback_comment }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: var(--spacing-xl); color: var(--text-muted);">
|
||||
<p>Brak konwersji z chatu w wybranym okresie</p>
|
||||
</div>
|
||||
<p style="color: var(--text-muted); text-align: center; padding: var(--spacing-lg);">Brak ocen — członkowie mogą oceniać odpowiedzi kciukiem w górę/dół</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Feedback -->
|
||||
{% if data.recent_feedback %}
|
||||
<div class="section-card">
|
||||
<h2>Odpowiedzi z oceną</h2>
|
||||
<div class="table-scroll" style="max-height: 400px;">
|
||||
{% for msg in data.recent_feedback %}
|
||||
<div style="padding: var(--spacing-md); border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: flex-start; gap: var(--spacing-md);">
|
||||
<div style="flex: 1;">
|
||||
<span class="badge {% if msg.feedback_rating == 2 %}badge-active{% else %}badge-high{% endif %}">
|
||||
{% if msg.feedback_rating == 2 %}Pomocne{% else %}Do poprawy{% endif %}
|
||||
</span>
|
||||
<div style="margin-top: var(--spacing-sm); font-size: var(--font-size-sm); color: var(--text-secondary); max-height: 60px; overflow: hidden;">
|
||||
{{ msg.content[:200] }}{% if msg.content|length > 200 %}...{% endif %}
|
||||
</div>
|
||||
{% if msg.feedback_comment %}
|
||||
<div style="margin-top: var(--spacing-xs); font-size: var(--font-size-xs);"><strong>Komentarz:</strong> {{ msg.feedback_comment }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="font-size: var(--font-size-xs); color: var(--text-muted); white-space: nowrap;">
|
||||
{{ msg.feedback_at.strftime('%d.%m %H:%M') if msg.feedback_at else '' }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Recent Queries (Anonymized) -->
|
||||
{% if data.recent_queries %}
|
||||
<div class="section-card">
|
||||
<h2>Ostatnia aktywność <span style="font-size: var(--font-size-xs); color: var(--text-muted);">zanonimizowana</span></h2>
|
||||
<div class="table-scroll" style="max-height: 350px;">
|
||||
{% for query in data.recent_queries %}
|
||||
<div style="padding: var(--spacing-sm) var(--spacing-md); border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; font-size: var(--font-size-sm);">
|
||||
<div>
|
||||
Zapytanie ({{ query.length }} znaków)
|
||||
{% if query.has_company_mention %}
|
||||
<span class="badge badge-low" style="margin-left: 4px;">FIRMA</span>
|
||||
{% endif %}
|
||||
{% if query.has_contact_request %}
|
||||
<span class="badge badge-active" style="margin-left: 4px;">KONTAKT</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="color: var(--text-muted); font-size: var(--font-size-xs);">
|
||||
{{ query.created_at.strftime('%d.%m %H:%M') }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div style="margin-top: var(--spacing-md); padding: var(--spacing-md); background: var(--background); border-radius: var(--radius); font-size: var(--font-size-xs); color: var(--text-secondary);">
|
||||
Treść zapytań użytkowników nie jest wyświetlana w panelu admina. Widoczne są tylko zanonimizowane statystyki.
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- UTM Sources -->
|
||||
{% if data.utm_sources %}
|
||||
<div class="section-card">
|
||||
<h2>Źródła ruchu (UTM)</h2>
|
||||
<div style="display: flex; flex-direction: column; gap: var(--spacing-sm);">
|
||||
{% for source, count in data.utm_sources.items() %}
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-xs) 0; border-bottom: 1px solid var(--border);">
|
||||
<span style="font-weight: 500;">{{ source }}</span>
|
||||
<span class="badge badge-low">{{ count }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user