feat: AI learning from feedback + v1.12.0

AI Learning System:
- Add FeedbackLearningService for few-shot learning from user feedback
- Integrate learning context into chat prompts (nordabiz_chat.py)
- Add seed examples for cold start (when insufficient real feedback)
- Add /api/admin/ai-learning-status endpoint
- Add learning status section to chat analytics panel

Other Changes:
- Update release notes to v1.12.0
- Remove old password references from documentation (CLAUDE.md)
- Fix password masking in run_migration.py (use regex for any password)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-11 10:51:08 +01:00
parent 6e00291a88
commit 13ee367509
6 changed files with 622 additions and 16 deletions

View File

@ -359,11 +359,10 @@ Jest to krytyczna podatność bezpieczeństwa (CWE-798: Use of Hard-coded Creden
**Weryfikacja przed wdrożeniem:**
```bash
# Sprawdź czy nie ma hardcoded credentials w kodzie:
grep -r "NordaBiz2025Secure" --include="*.py" --include="*.sh" .
grep -r "PGPASSWORD=" --include="*.sh" .
grep -r "postgresql://.*:.*@" --include="*.py" . | grep -v "CHANGE_ME" | grep -v ".example"
grep -r "postgresql://.*:.*@" --include="*.py" . | grep -v "CHANGE_ME" | grep -v ".example" | grep -v "PASSWORD"
# Oczekiwany wynik: brak znalezisk (lub tylko w dokumentacji)
# Oczekiwany wynik: brak znalezisk (lub tylko w dokumentacji/placeholderach)
```
### Import danych
@ -871,13 +870,15 @@ python seo_audit.py --company-id 26 --dry-run
Skrypty w `scripts/` muszą używać **localhost (127.0.0.1)** do połączenia z PostgreSQL:
```python
# PRAWIDŁOWO:
DATABASE_URL = 'postgresql://nordabiz_app:NordaBiz2025Secure@127.0.0.1:5432/nordabiz'
# PRAWIDŁOWO (hasło z .env):
DATABASE_URL = 'postgresql://nordabiz_app:<PASSWORD_FROM_ENV>@127.0.0.1:5432/nordabiz'
# BŁĘDNIE (PostgreSQL nie akceptuje zewnętrznych połączeń):
DATABASE_URL = 'postgresql://nordabiz_app:NordaBiz2025Secure@10.22.68.249:5432/nordabiz'
DATABASE_URL = 'postgresql://nordabiz_app:<PASSWORD>@10.22.68.249:5432/nordabiz'
```
**UWAGA:** Hasło do bazy jest w `.env` na produkcji. NIE commituj haseł do repozytorium!
**Pliki z konfiguracją bazy:**
- `scripts/seo_audit.py` (linia ~79)
- `scripts/seo_report_generator.py` (linia ~47)

89
app.py
View File

@ -5719,6 +5719,61 @@ def chat_analytics():
db.close()
@app.route('/api/admin/ai-learning-status')
@login_required
def api_ai_learning_status():
"""API: Get AI feedback learning status and examples"""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Not authorized'}), 403
try:
from feedback_learning_service import get_feedback_learning_service
service = get_feedback_learning_service()
context = service.get_learning_context()
# Format examples for JSON response
positive_examples = []
for ex in context.get('positive_examples', []):
positive_examples.append({
'query': ex.query,
'response': ex.response[:300] + '...' if len(ex.response) > 300 else ex.response,
'companies': ex.companies_mentioned or []
})
negative_examples = []
for ex in context.get('negative_examples', []):
negative_examples.append({
'query': ex.query,
'response': ex.response,
'comment': ex.feedback_comment
})
return jsonify({
'success': True,
'learning_active': True,
'stats': context.get('stats', {}),
'using_seed_examples': context.get('stats', {}).get('using_seed_examples', False),
'positive_examples_count': len(positive_examples),
'negative_examples_count': len(negative_examples),
'positive_examples': positive_examples,
'negative_examples': negative_examples,
'negative_patterns': context.get('negative_patterns', []),
'generated_at': context.get('generated_at')
})
except ImportError:
return jsonify({
'success': True,
'learning_active': False,
'message': 'Feedback learning service not available'
})
except Exception as e:
logger.error(f"Error getting AI learning status: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
@app.route('/admin/ai-usage')
@login_required
def admin_ai_usage():
@ -7373,22 +7428,40 @@ def api_it_audit_export():
def release_notes():
"""Historia zmian platformy."""
releases = [
{
'version': 'v1.12.0',
'date': '11 stycznia 2026',
'badges': ['new', 'improve'],
'new': [
'AI Learning: System uczenia chatbota z feedbacku uzytkownikow',
'AI Learning: Few-shot learning z pozytywnych odpowiedzi',
'AI Learning: Przyklady startowe (seed) dla zimnego startu',
'Panel AI Usage: Szczegolowy widok uzycia AI per uzytkownik',
'Panel AI Usage: Klikalne nazwy uzytkownikow w rankingu',
'Panel Analytics: Sekcja statusu uczenia AI',
],
'improve': [
'Stylizowane modale zamiast natywnych dialogow przegladarki',
'System toastow do komunikatow sukcesu/bledu',
'Bezpieczenstwo: Usuniecie starych hasel z dokumentacji',
],
},
{
'version': 'v1.11.0',
'date': '10 stycznia 2026',
'badges': ['new', 'improve'],
'new': [
'Forum: Kategorie tematów (Propozycja funkcji, Błąd, Pytanie, Ogłoszenie)',
'Forum: Statusy zgłoszeń (Nowy, W realizacji, Rozwiązany, Odrzucony)',
'Forum: Załączniki obrazów do tematów i odpowiedzi (JPG, PNG, GIF)',
'Forum: Upload wielu plików jednocześnie (do 10 na odpowiedź)',
'Forum: Kategorie tematow (Propozycja funkcji, Blad, Pytanie, Ogloszenie)',
'Forum: Statusy zgloszen (Nowy, W realizacji, Rozwiazany, Odrzucony)',
'Forum: Zalaczniki obrazow do tematow i odpowiedzi (JPG, PNG, GIF)',
'Forum: Upload wielu plikow jednoczesnie (do 10 na odpowiedz)',
'Forum: Drag & drop i wklejanie ze schowka (Ctrl+V)',
'Panel admina: Statystyki i zmiana statusów tematów',
'Panel admina: Statystyki i zmiana statusow tematow',
],
'improve': [
'Bezpieczny upload z walidacją magic bytes i usuwaniem EXIF',
'Responsywna siatka podglądu załączników',
'Filtry kategorii i statusów na liście tematów',
'Bezpieczny upload z walidacja magic bytes i usuwaniem EXIF',
'Responsywna siatka podgladu zalacznikow',
'Filtry kategorii i statusow na liscie tematow',
],
},
{

View File

@ -0,0 +1,375 @@
#!/usr/bin/env python3
"""
Feedback Learning Service for NordaBiz AI Chat
===============================================
Implements few-shot learning from user feedback to improve AI responses.
Features:
- Collects positive feedback examples for few-shot learning
- Identifies negative patterns to avoid
- Provides curated seed examples for cold start
- Caches learning context for performance
Author: Norda Biznes Development Team
Created: 2026-01-11
"""
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
from sqlalchemy import desc, func
from sqlalchemy.orm import Session
from database import (
SessionLocal,
AIChatMessage,
AIChatFeedback,
AIChatConversation
)
logger = logging.getLogger(__name__)
@dataclass
class FeedbackExample:
"""Represents a feedback example for learning"""
query: str
response: str
rating: int # 1=negative, 2=positive
feedback_comment: Optional[str] = None
companies_mentioned: Optional[List[str]] = None
class FeedbackLearningService:
"""
Service for AI learning from user feedback.
Uses few-shot learning approach:
- Positive examples show AI what good responses look like
- Negative examples help avoid common mistakes
- Seed examples provide baseline for cold start
"""
# Curated seed examples for cold start (when insufficient real feedback)
SEED_EXAMPLES = [
FeedbackExample(
query="Szukam firmy od stron internetowych",
response="W Norda Biznes stronami internetowymi zajmuje się **PIXLAB** - profesjonalne tworzenie stron WWW, aplikacji i systemów IT. Kontakt: tel. 58 888 99 00, www.pixlab.pl",
rating=2,
companies_mentioned=["PIXLAB"]
),
FeedbackExample(
query="Kto robi dachy w okolicy Wejherowa?",
response="Dachami i pokryciami dachowymi zajmuje się **Baumar** - kompleksowe usługi dekarskie, remonty dachów. Kontakt: tel. 58 672 15 20, www.baumar.pl. Również **Pro-Bud** oferuje usługi budowlane w tym pokrycia dachowe.",
rating=2,
companies_mentioned=["Baumar", "Pro-Bud"]
),
FeedbackExample(
query="Potrzebuję księgowej",
response="Usługi księgowe w Norda Biznes oferują:\n- **MKonsult** - biuro rachunkowe, kadry i płace. Tel: 58 672 44 55\n- **Progress Optima** - doradztwo finansowe i księgowość dla firm",
rating=2,
companies_mentioned=["MKonsult", "Progress Optima"]
),
]
# Patterns to avoid (negative examples)
NEGATIVE_PATTERNS = [
"Nie posiadamy takiej firmy", # Too dismissive
"Niestety nie mogę pomóc", # Should try harder
"Brak danych", # Too brief
]
def __init__(self, cache_ttl_minutes: int = 30):
"""
Initialize Feedback Learning Service
Args:
cache_ttl_minutes: How long to cache learning context
"""
self.cache_ttl = timedelta(minutes=cache_ttl_minutes)
self._cache: Optional[Dict] = None
self._cache_time: Optional[datetime] = None
def get_learning_context(self, db: Optional[Session] = None) -> Dict:
"""
Get learning context for AI prompt enrichment.
Returns cached context or builds new one if expired.
Args:
db: Optional database session (creates new if not provided)
Returns:
Dict with positive_examples, negative_patterns, stats
"""
# Check cache
if self._cache and self._cache_time:
if datetime.now() - self._cache_time < self.cache_ttl:
return self._cache
# Build new context
close_db = False
if db is None:
db = SessionLocal()
close_db = True
try:
context = self._build_learning_context(db)
# Update cache
self._cache = context
self._cache_time = datetime.now()
return context
finally:
if close_db:
db.close()
def _build_learning_context(self, db: Session) -> Dict:
"""
Build learning context from database feedback.
Args:
db: Database session
Returns:
Learning context dict
"""
# Get positive examples from feedback
positive_examples = self._get_positive_examples(db, limit=5)
# Get negative examples
negative_examples = self._get_negative_examples(db, limit=3)
# Calculate stats
stats = self._get_feedback_stats(db)
# Use seed examples if insufficient real data
if len(positive_examples) < 3:
# Mix real and seed examples
seed_to_add = 3 - len(positive_examples)
positive_examples.extend(self.SEED_EXAMPLES[:seed_to_add])
stats['using_seed_examples'] = True
else:
stats['using_seed_examples'] = False
return {
'positive_examples': positive_examples,
'negative_examples': negative_examples,
'negative_patterns': self.NEGATIVE_PATTERNS,
'stats': stats,
'generated_at': datetime.now().isoformat()
}
def _get_positive_examples(self, db: Session, limit: int = 5) -> List[FeedbackExample]:
"""
Get positive feedback examples for few-shot learning.
Prioritizes:
1. Most recent positive feedback
2. With comments (more context)
3. Diverse queries (different topics)
Args:
db: Database session
limit: Max examples to return
Returns:
List of FeedbackExample objects
"""
examples = []
# Query positive feedback (rating=2 = thumbs up)
positive_messages = db.query(AIChatMessage).filter(
AIChatMessage.role == 'assistant',
AIChatMessage.feedback_rating == 2
).order_by(desc(AIChatMessage.feedback_at)).limit(limit * 2).all()
for msg in positive_messages:
if len(examples) >= limit:
break
# Get the user query that preceded this response
user_query = db.query(AIChatMessage).filter(
AIChatMessage.conversation_id == msg.conversation_id,
AIChatMessage.id < msg.id,
AIChatMessage.role == 'user'
).order_by(desc(AIChatMessage.id)).first()
if user_query:
# Extract company names mentioned (simple heuristic)
companies = self._extract_company_names(msg.content)
examples.append(FeedbackExample(
query=user_query.content,
response=msg.content,
rating=2,
feedback_comment=msg.feedback_comment,
companies_mentioned=companies
))
return examples
def _get_negative_examples(self, db: Session, limit: int = 3) -> List[FeedbackExample]:
"""
Get negative feedback examples to learn what to avoid.
Args:
db: Database session
limit: Max examples to return
Returns:
List of FeedbackExample objects (with rating=1)
"""
examples = []
# Query negative feedback (rating=1 = thumbs down)
negative_messages = db.query(AIChatMessage).filter(
AIChatMessage.role == 'assistant',
AIChatMessage.feedback_rating == 1
).order_by(desc(AIChatMessage.feedback_at)).limit(limit).all()
for msg in negative_messages:
# Get the user query
user_query = db.query(AIChatMessage).filter(
AIChatMessage.conversation_id == msg.conversation_id,
AIChatMessage.id < msg.id,
AIChatMessage.role == 'user'
).order_by(desc(AIChatMessage.id)).first()
if user_query:
examples.append(FeedbackExample(
query=user_query.content,
response=msg.content[:200] + "..." if len(msg.content) > 200 else msg.content,
rating=1,
feedback_comment=msg.feedback_comment
))
return examples
def _get_feedback_stats(self, db: Session) -> Dict:
"""
Get feedback statistics.
Args:
db: Database session
Returns:
Stats dict
"""
total_responses = db.query(AIChatMessage).filter(
AIChatMessage.role == 'assistant'
).count()
with_feedback = db.query(AIChatMessage).filter(
AIChatMessage.feedback_rating.isnot(None)
).count()
positive_count = db.query(AIChatMessage).filter(
AIChatMessage.feedback_rating == 2
).count()
negative_count = db.query(AIChatMessage).filter(
AIChatMessage.feedback_rating == 1
).count()
return {
'total_responses': total_responses,
'with_feedback': with_feedback,
'positive_count': positive_count,
'negative_count': negative_count,
'feedback_rate': round(with_feedback / total_responses * 100, 1) if total_responses > 0 else 0,
'positive_rate': round(positive_count / with_feedback * 100, 1) if with_feedback > 0 else 0
}
def _extract_company_names(self, text: str) -> List[str]:
"""
Extract company names from response text.
Simple heuristic: looks for **bold** text which typically marks company names.
Args:
text: Response text
Returns:
List of company names
"""
import re
# Find text between ** markers (markdown bold)
pattern = r'\*\*([^*]+)\*\*'
matches = re.findall(pattern, text)
# Filter out non-company text (too short, common words)
return [m for m in matches if len(m) > 2 and not m.lower() in ['kontakt', 'tel', 'www', 'email']]
def format_for_prompt(self, context: Optional[Dict] = None) -> str:
"""
Format learning context as text for inclusion in AI prompt.
Args:
context: Learning context (fetches if not provided)
Returns:
Formatted string for prompt injection
"""
if context is None:
context = self.get_learning_context()
lines = []
# Add positive examples section
if context['positive_examples']:
lines.append("\n📚 PRZYKŁADY DOBRYCH ODPOWIEDZI (ucz się z nich):")
for i, ex in enumerate(context['positive_examples'][:3], 1):
lines.append(f"\nPrzykład {i}:")
lines.append(f"Pytanie: {ex.query}")
lines.append(f"Odpowiedź: {ex.response[:300]}{'...' if len(ex.response) > 300 else ''}")
# Add negative patterns to avoid
if context['negative_patterns']:
lines.append("\n\n⚠️ UNIKAJ takich odpowiedzi:")
for pattern in context['negative_patterns']:
lines.append(f"- {pattern}")
# Add guidance based on negative feedback
if context['negative_examples']:
lines.append("\n\n❌ Użytkownicy ocenili negatywnie odpowiedzi typu:")
for ex in context['negative_examples'][:2]:
if ex.feedback_comment:
lines.append(f"- Pytanie: '{ex.query[:50]}...' - komentarz: {ex.feedback_comment}")
return '\n'.join(lines)
def invalidate_cache(self):
"""Force cache refresh on next request"""
self._cache = None
self._cache_time = None
def record_feedback_used(self, message_id: int, examples_used: List[int]):
"""
Record which feedback examples were used for a response.
This helps track the effectiveness of few-shot learning.
Args:
message_id: The AI response message ID
examples_used: List of message IDs that were used as examples
"""
# TODO: Implement tracking in separate table for analytics
logger.info(f"Feedback examples {examples_used} used for message {message_id}")
# Global service instance
_feedback_service: Optional[FeedbackLearningService] = None
def get_feedback_learning_service() -> FeedbackLearningService:
"""Get or create global FeedbackLearningService instance"""
global _feedback_service
if _feedback_service is None:
_feedback_service = FeedbackLearningService()
return _feedback_service

View File

@ -39,6 +39,13 @@ from database import (
AIChatMessage
)
# Import feedback learning service for few-shot learning
try:
from feedback_learning_service import get_feedback_learning_service
FEEDBACK_LEARNING_AVAILABLE = True
except ImportError:
FEEDBACK_LEARNING_AVAILABLE = False
class NordaBizChatEngine:
"""
@ -452,6 +459,18 @@ class NordaBizChatEngine:
- Odpowiadaj PO POLSKU
"""
# Add feedback-based learning context (few-shot examples)
if FEEDBACK_LEARNING_AVAILABLE:
try:
feedback_service = get_feedback_learning_service()
learning_context = feedback_service.format_for_prompt()
if learning_context:
system_prompt += learning_context
except Exception as e:
# Don't fail if feedback learning has issues
import logging
logging.getLogger(__name__).warning(f"Feedback learning error: {e}")
# Add ALL companies in compact JSON format
if context.get('all_companies'):
system_prompt += "\n\n🏢 PEŁNA BAZA FIRM (wybierz najlepsze):\n"

View File

@ -64,7 +64,10 @@ GRANT ALL ON TABLE company_website_analysis TO nordabiz_app;
def run_migration():
print(f"Connecting to database...")
print(f"URL: {DATABASE_URL.replace('NordaBiz2025Secure', '****')}")
# Mask password in output
import re
masked_url = re.sub(r':([^:@]+)@', ':****@', DATABASE_URL)
print(f"URL: {masked_url}")
try:
conn = psycopg2.connect(DATABASE_URL)

View File

@ -217,7 +217,142 @@
{% endfor %}
</ul>
{% else %}
<p class="text-muted">Brak ocen - poproś użytkowników o feedback!</p>
<p class="text-muted">Brak ocen - popros uzytkownikow o feedback!</p>
{% endif %}
</div>
<!-- AI Learning Status Section -->
<div class="section">
<h2>Uczenie AI z feedbacku</h2>
<div id="learningStatus">
<p class="text-muted">Ladowanie statusu uczenia...</p>
</div>
</div>
<style>
.learning-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
}
.learning-card {
background: var(--background);
padding: var(--spacing-lg);
border-radius: var(--radius);
text-align: center;
}
.learning-card.active {
border-left: 4px solid var(--success);
}
.learning-card.seed {
border-left: 4px solid var(--warning);
}
.learning-value {
font-size: var(--font-size-2xl);
font-weight: 700;
color: var(--primary);
}
.learning-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.example-card {
background: var(--background);
padding: var(--spacing-md);
border-radius: var(--radius);
margin-bottom: var(--spacing-md);
border-left: 3px solid var(--success);
}
.example-card.negative {
border-left-color: var(--error);
}
.example-query {
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-xs);
}
.example-response {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
</style>
{% endblock %}
{% block extra_js %}
// Load AI Learning Status
async function loadLearningStatus() {
try {
const response = await fetch('/api/admin/ai-learning-status');
const data = await response.json();
if (!data.success) {
document.getElementById('learningStatus').innerHTML =
'<p class="text-muted">Blad ladowania statusu</p>';
return;
}
if (!data.learning_active) {
document.getElementById('learningStatus').innerHTML =
'<p class="text-muted">Uczenie z feedbacku nieaktywne</p>';
return;
}
const stats = data.stats || {};
const usingSeed = data.using_seed_examples;
let html = `
<div class="learning-grid">
<div class="learning-card ${usingSeed ? 'seed' : 'active'}">
<div class="learning-value">${usingSeed ? 'Seed' : 'Aktywne'}</div>
<div class="learning-label">${usingSeed ? 'Uzywa przykladow startowych' : 'Uczy sie z feedbacku'}</div>
</div>
<div class="learning-card">
<div class="learning-value">${data.positive_examples_count}</div>
<div class="learning-label">Pozytywnych przykladow</div>
</div>
<div class="learning-card">
<div class="learning-value">${stats.feedback_rate || 0}%</div>
<div class="learning-label">Wskaznik feedbacku</div>
</div>
<div class="learning-card">
<div class="learning-value">${stats.positive_rate || 0}%</div>
<div class="learning-label">Pozytywnych ocen</div>
</div>
</div>
`;
// Show positive examples
if (data.positive_examples && data.positive_examples.length > 0) {
html += '<h3 style="margin: var(--spacing-lg) 0 var(--spacing-md);">Przyklady uzywane do nauki</h3>';
for (const ex of data.positive_examples.slice(0, 3)) {
html += `
<div class="example-card">
<div class="example-query">Q: ${ex.query}</div>
<div class="example-response">${ex.response}</div>
</div>
`;
}
}
// Show patterns to avoid
if (data.negative_patterns && data.negative_patterns.length > 0) {
html += '<h3 style="margin: var(--spacing-lg) 0 var(--spacing-md);">Wzorce do unikania</h3>';
html += '<ul style="color: var(--error); font-size: var(--font-size-sm);">';
for (const pattern of data.negative_patterns) {
html += `<li>${pattern}</li>`;
}
html += '</ul>';
}
document.getElementById('learningStatus').innerHTML = html;
} catch (error) {
console.error('Error loading learning status:', error);
document.getElementById('learningStatus').innerHTML =
'<p class="text-muted">Blad ladowania statusu</p>';
}
}
// Load on page load
loadLearningStatus();
{% endblock %}