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

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:
Maciej Pienczyn 2026-03-11 04:05:15 +01:00
parent cdd31b77a8
commit 42e42c5fa0
2 changed files with 84 additions and 184 deletions

View File

@ -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,
}

View File

@ -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>