nordabiz/feedback_learning_service.py
Maciej Pienczyn 5030b71beb
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
chore: update Author to Maciej Pienczyn, InPi sp. z o.o. across all files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:20:47 +02:00

376 lines
12 KiB
Python

#!/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: Maciej Pienczyn, InPi sp. z o.o.
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