feat(audit): Add AI-powered audit analysis and action generation
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
Add Gemini AI integration to SEO, GBP, and Social Media audits that generates contextual analysis summaries and prioritized action items with ready-to-use content (Schema.org, meta descriptions, social posts, GBP descriptions, review responses, content calendars). New files: - audit_ai_service.py: Central AI service with caching (7-day TTL) - blueprints/api/routes_audit_actions.py: 4 API endpoints - database/migrations/056_audit_actions.sql: 3 new tables - templates/partials/audit_ai_actions.html: Reusable UI component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1791db6593
commit
7383ec74a5
886
audit_ai_service.py
Normal file
886
audit_ai_service.py
Normal file
@ -0,0 +1,886 @@
|
|||||||
|
"""
|
||||||
|
Audit AI Service
|
||||||
|
=================
|
||||||
|
|
||||||
|
Centralny serwis AI do analizy wyników audytów i generowania
|
||||||
|
priorytetowanych akcji z treścią gotową do wdrożenia.
|
||||||
|
|
||||||
|
Obsługiwane typy audytów:
|
||||||
|
- SEO (PageSpeed, on-page, technical, local SEO)
|
||||||
|
- GBP (Google Business Profile completeness)
|
||||||
|
- Social Media (presence across platforms)
|
||||||
|
|
||||||
|
Używa Gemini API (via gemini_service.py) do generowania analiz.
|
||||||
|
|
||||||
|
Author: Norda Biznes Development Team
|
||||||
|
Created: 2026-02-07
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from database import (
|
||||||
|
SessionLocal, Company, CompanyWebsiteAnalysis, CompanySocialMedia,
|
||||||
|
CompanyCitation, AuditAction, AuditAICache
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Cache expiry: 7 days
|
||||||
|
CACHE_EXPIRY_DAYS = 7
|
||||||
|
|
||||||
|
|
||||||
|
def _get_gemini_service():
|
||||||
|
"""Get the initialized Gemini service instance."""
|
||||||
|
from gemini_service import get_gemini_service
|
||||||
|
service = get_gemini_service()
|
||||||
|
if not service:
|
||||||
|
raise RuntimeError("Gemini service not initialized")
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_data(data: dict) -> str:
|
||||||
|
"""Generate SHA256 hash of audit data for cache invalidation."""
|
||||||
|
serialized = json.dumps(data, sort_keys=True, default=str)
|
||||||
|
return hashlib.sha256(serialized.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# SEO AUDIT DATA COLLECTION
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def _collect_seo_data(db, company) -> dict:
|
||||||
|
"""Collect SEO audit data for AI analysis."""
|
||||||
|
analysis = db.query(CompanyWebsiteAnalysis).filter(
|
||||||
|
CompanyWebsiteAnalysis.company_id == company.id
|
||||||
|
).order_by(CompanyWebsiteAnalysis.seo_audited_at.desc()).first()
|
||||||
|
|
||||||
|
if not analysis or not analysis.seo_audited_at:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
citations = db.query(CompanyCitation).filter(
|
||||||
|
CompanyCitation.company_id == company.id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'company_name': company.name,
|
||||||
|
'company_category': company.category,
|
||||||
|
'website': company.website,
|
||||||
|
'city': company.address_city,
|
||||||
|
# PageSpeed scores
|
||||||
|
'seo_score': analysis.pagespeed_seo_score,
|
||||||
|
'performance_score': analysis.pagespeed_performance_score,
|
||||||
|
'accessibility_score': analysis.pagespeed_accessibility_score,
|
||||||
|
'best_practices_score': analysis.pagespeed_best_practices_score,
|
||||||
|
# On-page
|
||||||
|
'meta_title': analysis.meta_title,
|
||||||
|
'meta_description': analysis.meta_description,
|
||||||
|
'h1_count': analysis.h1_count,
|
||||||
|
'h1_text': analysis.h1_text,
|
||||||
|
'h2_count': analysis.h2_count,
|
||||||
|
'h3_count': analysis.h3_count,
|
||||||
|
'total_images': analysis.total_images,
|
||||||
|
'images_without_alt': analysis.images_without_alt,
|
||||||
|
# Technical
|
||||||
|
'has_ssl': analysis.has_ssl,
|
||||||
|
'has_sitemap': analysis.has_sitemap,
|
||||||
|
'has_robots_txt': analysis.has_robots_txt,
|
||||||
|
'has_canonical': analysis.has_canonical,
|
||||||
|
'is_indexable': analysis.is_indexable,
|
||||||
|
'is_mobile_friendly': getattr(analysis, 'is_mobile_friendly', None),
|
||||||
|
'load_time_ms': analysis.load_time_ms,
|
||||||
|
# Structured data
|
||||||
|
'has_structured_data': analysis.has_structured_data,
|
||||||
|
'structured_data_types': analysis.structured_data_types,
|
||||||
|
'has_local_business_schema': analysis.has_local_business_schema,
|
||||||
|
# Social/analytics
|
||||||
|
'has_og_tags': analysis.has_og_tags,
|
||||||
|
'has_twitter_cards': analysis.has_twitter_cards,
|
||||||
|
'has_google_analytics': analysis.has_google_analytics,
|
||||||
|
'has_google_tag_manager': analysis.has_google_tag_manager,
|
||||||
|
# Local SEO
|
||||||
|
'local_seo_score': analysis.local_seo_score,
|
||||||
|
'has_google_maps_embed': analysis.has_google_maps_embed,
|
||||||
|
'has_local_keywords': analysis.has_local_keywords,
|
||||||
|
'nap_on_website': analysis.nap_on_website,
|
||||||
|
# Core Web Vitals
|
||||||
|
'lcp_ms': analysis.largest_contentful_paint_ms,
|
||||||
|
'fid_ms': analysis.first_input_delay_ms,
|
||||||
|
'cls': float(analysis.cumulative_layout_shift) if analysis.cumulative_layout_shift else None,
|
||||||
|
# Content
|
||||||
|
'content_freshness_score': analysis.content_freshness_score,
|
||||||
|
'word_count_homepage': analysis.word_count_homepage,
|
||||||
|
# Links
|
||||||
|
'internal_links_count': analysis.internal_links_count,
|
||||||
|
'external_links_count': analysis.external_links_count,
|
||||||
|
'broken_links_count': analysis.broken_links_count,
|
||||||
|
# Citations
|
||||||
|
'citations_count': len(citations),
|
||||||
|
'citations_found': len([c for c in citations if c.status == 'found']),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_gbp_data(db, company) -> dict:
|
||||||
|
"""Collect GBP audit data for AI analysis."""
|
||||||
|
try:
|
||||||
|
from gbp_audit_service import get_company_audit
|
||||||
|
audit = get_company_audit(db, company.id)
|
||||||
|
except ImportError:
|
||||||
|
audit = None
|
||||||
|
|
||||||
|
if not audit:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'company_name': company.name,
|
||||||
|
'company_category': company.category,
|
||||||
|
'city': company.address_city,
|
||||||
|
'completeness_score': audit.completeness_score,
|
||||||
|
# Field presence
|
||||||
|
'has_name': audit.has_name,
|
||||||
|
'has_address': audit.has_address,
|
||||||
|
'has_phone': audit.has_phone,
|
||||||
|
'has_website': audit.has_website,
|
||||||
|
'has_hours': audit.has_hours,
|
||||||
|
'has_categories': audit.has_categories,
|
||||||
|
'has_photos': audit.has_photos,
|
||||||
|
'has_description': audit.has_description,
|
||||||
|
'has_services': audit.has_services,
|
||||||
|
'has_reviews': audit.has_reviews,
|
||||||
|
# Reviews
|
||||||
|
'review_count': audit.review_count,
|
||||||
|
'average_rating': float(audit.average_rating) if audit.average_rating else None,
|
||||||
|
'reviews_with_response': audit.reviews_with_response,
|
||||||
|
'reviews_without_response': audit.reviews_without_response,
|
||||||
|
'review_response_rate': float(audit.review_response_rate) if audit.review_response_rate else None,
|
||||||
|
# Activity
|
||||||
|
'has_posts': audit.has_posts,
|
||||||
|
'posts_count_30d': audit.posts_count_30d,
|
||||||
|
'has_products': audit.has_products,
|
||||||
|
'has_qa': audit.has_qa,
|
||||||
|
# Photos
|
||||||
|
'photo_count': audit.photo_count,
|
||||||
|
'logo_present': audit.logo_present,
|
||||||
|
'cover_photo_present': audit.cover_photo_present,
|
||||||
|
# NAP
|
||||||
|
'nap_consistent': audit.nap_consistent,
|
||||||
|
'nap_issues': audit.nap_issues,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_social_data(db, company) -> dict:
|
||||||
|
"""Collect social media audit data for AI analysis."""
|
||||||
|
profiles = db.query(CompanySocialMedia).filter(
|
||||||
|
CompanySocialMedia.company_id == company.id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
all_platforms = ['facebook', 'instagram', 'linkedin', 'youtube', 'twitter', 'tiktok']
|
||||||
|
profiles_dict = {}
|
||||||
|
for p in profiles:
|
||||||
|
profiles_dict[p.platform] = {
|
||||||
|
'url': p.url,
|
||||||
|
'is_valid': p.is_valid,
|
||||||
|
'followers_count': p.followers_count,
|
||||||
|
'has_bio': p.has_bio,
|
||||||
|
'has_profile_photo': p.has_profile_photo,
|
||||||
|
'has_cover_photo': p.has_cover_photo,
|
||||||
|
'posts_count_30d': p.posts_count_30d,
|
||||||
|
'last_post_date': str(p.last_post_date) if p.last_post_date else None,
|
||||||
|
'posting_frequency_score': p.posting_frequency_score,
|
||||||
|
'engagement_rate': float(p.engagement_rate) if p.engagement_rate else None,
|
||||||
|
'profile_completeness_score': p.profile_completeness_score,
|
||||||
|
}
|
||||||
|
|
||||||
|
present = [p for p in all_platforms if p in profiles_dict]
|
||||||
|
missing = [p for p in all_platforms if p not in profiles_dict]
|
||||||
|
|
||||||
|
return {
|
||||||
|
'company_name': company.name,
|
||||||
|
'company_category': company.category,
|
||||||
|
'city': company.address_city,
|
||||||
|
'platforms_present': present,
|
||||||
|
'platforms_missing': missing,
|
||||||
|
'profiles': profiles_dict,
|
||||||
|
'total_platforms': len(all_platforms),
|
||||||
|
'platforms_found': len(present),
|
||||||
|
'score': int((len(present) / len(all_platforms)) * 100) if all_platforms else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# GEMINI PROMPTS
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def _build_seo_prompt(data: dict) -> str:
|
||||||
|
"""Build Gemini prompt for SEO audit analysis."""
|
||||||
|
return f"""Jesteś ekspertem SEO analizującym stronę internetową lokalnej firmy w Polsce.
|
||||||
|
|
||||||
|
DANE FIRMY:
|
||||||
|
- Nazwa: {data.get('company_name', 'N/A')}
|
||||||
|
- Branża: {data.get('company_category', 'N/A')}
|
||||||
|
- Miasto: {data.get('city', 'N/A')}
|
||||||
|
- Strona: {data.get('website', 'N/A')}
|
||||||
|
|
||||||
|
WYNIKI AUDYTU SEO:
|
||||||
|
- Wynik SEO (PageSpeed): {data.get('seo_score', 'brak')}/100
|
||||||
|
- Wydajność: {data.get('performance_score', 'brak')}/100
|
||||||
|
- Dostępność: {data.get('accessibility_score', 'brak')}/100
|
||||||
|
- Best Practices: {data.get('best_practices_score', 'brak')}/100
|
||||||
|
|
||||||
|
Core Web Vitals:
|
||||||
|
- LCP: {data.get('lcp_ms', 'brak')} ms
|
||||||
|
- FID: {data.get('fid_ms', 'brak')} ms
|
||||||
|
- CLS: {data.get('cls', 'brak')}
|
||||||
|
|
||||||
|
On-Page SEO:
|
||||||
|
- Meta title: {data.get('meta_title', 'brak')}
|
||||||
|
- Meta description: {'tak' if data.get('meta_description') else 'BRAK'}
|
||||||
|
- H1: {data.get('h1_count', 0)} (treść: {data.get('h1_text', 'brak')})
|
||||||
|
- H2: {data.get('h2_count', 0)}, H3: {data.get('h3_count', 0)}
|
||||||
|
- Obrazy: {data.get('total_images', 0)} (bez alt: {data.get('images_without_alt', 0)})
|
||||||
|
- Linki wewnętrzne: {data.get('internal_links_count', 0)}, zewnętrzne: {data.get('external_links_count', 0)}, uszkodzone: {data.get('broken_links_count', 0)}
|
||||||
|
|
||||||
|
Technical SEO:
|
||||||
|
- SSL: {'tak' if data.get('has_ssl') else 'NIE'}
|
||||||
|
- Sitemap: {'tak' if data.get('has_sitemap') else 'NIE'}
|
||||||
|
- Robots.txt: {'tak' if data.get('has_robots_txt') else 'NIE'}
|
||||||
|
- Canonical: {'tak' if data.get('has_canonical') else 'NIE'}
|
||||||
|
- Indeksowalna: {'tak' if data.get('is_indexable') else 'NIE'}
|
||||||
|
- Mobile-friendly: {'tak' if data.get('is_mobile_friendly') else 'NIE/brak danych'}
|
||||||
|
|
||||||
|
Dane strukturalne:
|
||||||
|
- Schema.org: {'tak' if data.get('has_structured_data') else 'NIE'} (typy: {data.get('structured_data_types', [])})
|
||||||
|
- LocalBusiness Schema: {'tak' if data.get('has_local_business_schema') else 'NIE'}
|
||||||
|
|
||||||
|
Social & Analytics:
|
||||||
|
- Open Graph: {'tak' if data.get('has_og_tags') else 'NIE'}
|
||||||
|
- Twitter Cards: {'tak' if data.get('has_twitter_cards') else 'NIE'}
|
||||||
|
- Google Analytics: {'tak' if data.get('has_google_analytics') else 'NIE'}
|
||||||
|
- GTM: {'tak' if data.get('has_google_tag_manager') else 'NIE'}
|
||||||
|
|
||||||
|
Local SEO (wynik: {data.get('local_seo_score', 'brak')}/100):
|
||||||
|
- Mapa Google: {'tak' if data.get('has_google_maps_embed') else 'NIE'}
|
||||||
|
- Lokalne słowa kluczowe: {'tak' if data.get('has_local_keywords') else 'NIE'}
|
||||||
|
- NAP na stronie: {'tak' if data.get('nap_on_website') else 'NIE'}
|
||||||
|
- Cytacje: {data.get('citations_found', 0)}/{data.get('citations_count', 0)} znalezionych
|
||||||
|
|
||||||
|
Treść:
|
||||||
|
- Świeżość: {data.get('content_freshness_score', 'brak')}/100
|
||||||
|
- Słów na stronie głównej: {data.get('word_count_homepage', 'brak')}
|
||||||
|
|
||||||
|
ZADANIE:
|
||||||
|
Przygotuj analizę w formacie JSON z dwoma kluczami:
|
||||||
|
|
||||||
|
1. "summary" - krótki akapit (2-4 zdania) podsumowujący stan SEO strony, co jest dobrze, a co wymaga poprawy. Pisz bezpośrednio do właściciela firmy, po polsku.
|
||||||
|
|
||||||
|
2. "actions" - lista od 3 do 8 priorytetowanych akcji do podjęcia. Każda akcja to obiekt:
|
||||||
|
{{
|
||||||
|
"action_type": "typ akcji z listy: generate_schema_org, generate_meta_description, suggest_heading_fix, generate_alt_texts, seo_roadmap, add_analytics, add_sitemap, fix_ssl, add_og_tags, improve_performance, add_local_keywords, add_nap, fix_broken_links",
|
||||||
|
"title": "krótki tytuł po polsku",
|
||||||
|
"description": "opis co trzeba zrobić i dlaczego, 1-2 zdania",
|
||||||
|
"priority": "critical/high/medium/low",
|
||||||
|
"impact_score": 1-10,
|
||||||
|
"effort_score": 1-10,
|
||||||
|
"platform": "website"
|
||||||
|
}}
|
||||||
|
|
||||||
|
Priorytetyzuj wg: impact_score / effort_score (wyższy stosunek = wyższy priorytet).
|
||||||
|
NIE sugeruj akcji dla rzeczy, które firma już ma poprawnie.
|
||||||
|
Odpowiedz WYŁĄCZNIE poprawnym JSON-em, bez markdown, bez komentarzy."""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_gbp_prompt(data: dict) -> str:
|
||||||
|
"""Build Gemini prompt for GBP audit analysis."""
|
||||||
|
return f"""Jesteś ekspertem Google Business Profile analizującym wizytówkę lokalnej firmy w Polsce.
|
||||||
|
|
||||||
|
DANE FIRMY:
|
||||||
|
- Nazwa: {data.get('company_name', 'N/A')}
|
||||||
|
- Branża: {data.get('company_category', 'N/A')}
|
||||||
|
- Miasto: {data.get('city', 'N/A')}
|
||||||
|
|
||||||
|
WYNIKI AUDYTU GBP (kompletność: {data.get('completeness_score', 'brak')}/100):
|
||||||
|
- Nazwa: {'✓' if data.get('has_name') else '✗'}
|
||||||
|
- Adres: {'✓' if data.get('has_address') else '✗'}
|
||||||
|
- Telefon: {'✓' if data.get('has_phone') else '✗'}
|
||||||
|
- Strona WWW: {'✓' if data.get('has_website') else '✗'}
|
||||||
|
- Godziny otwarcia: {'✓' if data.get('has_hours') else '✗'}
|
||||||
|
- Kategorie: {'✓' if data.get('has_categories') else '✗'}
|
||||||
|
- Zdjęcia: {'✓' if data.get('has_photos') else '✗'} ({data.get('photo_count', 0)} zdjęć)
|
||||||
|
- Opis: {'✓' if data.get('has_description') else '✗'}
|
||||||
|
- Usługi: {'✓' if data.get('has_services') else '✗'}
|
||||||
|
- Logo: {'✓' if data.get('logo_present') else '✗'}
|
||||||
|
- Zdjęcie w tle: {'✓' if data.get('cover_photo_present') else '✗'}
|
||||||
|
|
||||||
|
Opinie:
|
||||||
|
- Liczba opinii: {data.get('review_count', 0)}
|
||||||
|
- Średnia ocena: {data.get('average_rating', 'brak')}
|
||||||
|
- Z odpowiedzią: {data.get('reviews_with_response', 0)}
|
||||||
|
- Bez odpowiedzi: {data.get('reviews_without_response', 0)}
|
||||||
|
- Wskaźnik odpowiedzi: {data.get('review_response_rate', 'brak')}%
|
||||||
|
|
||||||
|
Aktywność:
|
||||||
|
- Posty: {'✓' if data.get('has_posts') else '✗'} ({data.get('posts_count_30d', 0)} w ostatnich 30 dniach)
|
||||||
|
- Produkty: {'✓' if data.get('has_products') else '✗'}
|
||||||
|
- Pytania i odpowiedzi: {'✓' if data.get('has_qa') else '✗'}
|
||||||
|
|
||||||
|
NAP:
|
||||||
|
- Spójność NAP: {'✓' if data.get('nap_consistent') else '✗'}
|
||||||
|
- Problemy NAP: {data.get('nap_issues', 'brak')}
|
||||||
|
|
||||||
|
ZADANIE:
|
||||||
|
Przygotuj analizę w formacie JSON z dwoma kluczami:
|
||||||
|
|
||||||
|
1. "summary" - krótki akapit (2-4 zdania) podsumowujący stan wizytówki Google, co jest dobrze, a co wymaga poprawy. Pisz bezpośrednio do właściciela firmy, po polsku.
|
||||||
|
|
||||||
|
2. "actions" - lista od 3 do 8 priorytetowanych akcji. Każda akcja:
|
||||||
|
{{
|
||||||
|
"action_type": "typ z listy: generate_gbp_description, generate_gbp_post, respond_to_review, suggest_categories, gbp_improvement_plan, add_photos, add_hours, add_services, add_products",
|
||||||
|
"title": "krótki tytuł po polsku",
|
||||||
|
"description": "opis co trzeba zrobić i dlaczego",
|
||||||
|
"priority": "critical/high/medium/low",
|
||||||
|
"impact_score": 1-10,
|
||||||
|
"effort_score": 1-10,
|
||||||
|
"platform": "google"
|
||||||
|
}}
|
||||||
|
|
||||||
|
NIE sugeruj akcji dla pól, które firma już ma poprawnie uzupełnione.
|
||||||
|
Odpowiedz WYŁĄCZNIE poprawnym JSON-em, bez markdown, bez komentarzy."""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_social_prompt(data: dict) -> str:
|
||||||
|
"""Build Gemini prompt for social media audit analysis."""
|
||||||
|
profiles_info = ""
|
||||||
|
for platform, info in data.get('profiles', {}).items():
|
||||||
|
profiles_info += f"\n {platform}: followers={info.get('followers_count', '?')}, "
|
||||||
|
profiles_info += f"bio={'✓' if info.get('has_bio') else '✗'}, "
|
||||||
|
profiles_info += f"photo={'✓' if info.get('has_profile_photo') else '✗'}, "
|
||||||
|
profiles_info += f"posty_30d={info.get('posts_count_30d', '?')}, "
|
||||||
|
profiles_info += f"kompletność={info.get('profile_completeness_score', '?')}%"
|
||||||
|
|
||||||
|
return f"""Jesteś ekspertem social media analizującym obecność lokalnej firmy w Polsce w mediach społecznościowych.
|
||||||
|
|
||||||
|
DANE FIRMY:
|
||||||
|
- Nazwa: {data.get('company_name', 'N/A')}
|
||||||
|
- Branża: {data.get('company_category', 'N/A')}
|
||||||
|
- Miasto: {data.get('city', 'N/A')}
|
||||||
|
|
||||||
|
OBECNOŚĆ W SOCIAL MEDIA (wynik: {data.get('score', 0)}/100):
|
||||||
|
- Platformy znalezione ({data.get('platforms_found', 0)}/{data.get('total_platforms', 6)}): {', '.join(data.get('platforms_present', []))}
|
||||||
|
- Platformy brakujące: {', '.join(data.get('platforms_missing', []))}
|
||||||
|
|
||||||
|
Szczegóły profili:{profiles_info or ' brak profili'}
|
||||||
|
|
||||||
|
ZADANIE:
|
||||||
|
Przygotuj analizę w formacie JSON z dwoma kluczami:
|
||||||
|
|
||||||
|
1. "summary" - krótki akapit (2-4 zdania) podsumowujący obecność firmy w social media. Pisz po polsku, do właściciela firmy.
|
||||||
|
|
||||||
|
2. "actions" - lista od 3 do 8 priorytetowanych akcji. Każda akcja:
|
||||||
|
{{
|
||||||
|
"action_type": "typ z listy: generate_social_post, generate_bio, content_calendar, content_strategy, create_profile, improve_profile, increase_engagement",
|
||||||
|
"title": "krótki tytuł po polsku",
|
||||||
|
"description": "opis co trzeba zrobić i dlaczego",
|
||||||
|
"priority": "critical/high/medium/low",
|
||||||
|
"impact_score": 1-10,
|
||||||
|
"effort_score": 1-10,
|
||||||
|
"platform": "facebook/instagram/linkedin/youtube/twitter/tiktok/all"
|
||||||
|
}}
|
||||||
|
|
||||||
|
Dla firm lokalnych priorytetyzuj: Facebook > Instagram > LinkedIn > reszta.
|
||||||
|
NIE sugeruj tworzenia profili na platformach nieistotnych dla branży.
|
||||||
|
Odpowiedz WYŁĄCZNIE poprawnym JSON-em, bez markdown, bez komentarzy."""
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# CONTENT GENERATION PROMPTS
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
CONTENT_PROMPTS = {
|
||||||
|
'generate_schema_org': """Wygeneruj kompletny JSON-LD Schema.org LocalBusiness dla firmy:
|
||||||
|
- Nazwa: {company_name}
|
||||||
|
- Branża: {category}
|
||||||
|
- Adres: {address}
|
||||||
|
- Miasto: {city}
|
||||||
|
- Telefon: {phone}
|
||||||
|
- Strona: {website}
|
||||||
|
- Email: {email}
|
||||||
|
|
||||||
|
Wygeneruj WYŁĄCZNIE poprawny tag <script type="application/ld+json"> z JSON-LD. Bez komentarzy.""",
|
||||||
|
|
||||||
|
'generate_meta_description': """Napisz meta description (150-160 znaków) dla strony firmy:
|
||||||
|
- Nazwa: {company_name}
|
||||||
|
- Branża: {category}
|
||||||
|
- Miasto: {city}
|
||||||
|
- Usługi: {services}
|
||||||
|
|
||||||
|
Opis powinien zawierać lokalne słowa kluczowe, CTA i zachęcać do kliknięcia.
|
||||||
|
Odpowiedz WYŁĄCZNIE tekstem meta description, bez cudzysłowów, bez komentarzy.""",
|
||||||
|
|
||||||
|
'suggest_heading_fix': """Zaproponuj poprawioną strukturę nagłówków (H1, H2, H3) dla strony firmy:
|
||||||
|
- Nazwa: {company_name}
|
||||||
|
- Branża: {category}
|
||||||
|
- Obecny H1: {h1_text}
|
||||||
|
- Liczba H1: {h1_count}, H2: {h2_count}, H3: {h3_count}
|
||||||
|
|
||||||
|
Zaproponuj strukturę z jednym H1 i logiczną hierarchią H2/H3. Po polsku.
|
||||||
|
Format odpowiedzi - lista nagłówków z poziomami, np.:
|
||||||
|
H1: ...
|
||||||
|
H2: ...
|
||||||
|
H3: ...
|
||||||
|
H2: ...""",
|
||||||
|
|
||||||
|
'generate_alt_texts': """Zaproponuj teksty alternatywne (alt) dla obrazów na stronie firmy:
|
||||||
|
- Nazwa: {company_name}
|
||||||
|
- Branża: {category}
|
||||||
|
- Liczba obrazów bez alt: {images_without_alt}
|
||||||
|
|
||||||
|
Napisz {images_without_alt} propozycji tekstów alt (max 125 znaków każdy).
|
||||||
|
Uwzględnij lokalne słowa kluczowe i opis kontekstowy. Po polsku.
|
||||||
|
Format: po jednym alt tekście na linię.""",
|
||||||
|
|
||||||
|
'generate_gbp_description': """Napisz opis firmy na Google Business Profile (max 750 znaków):
|
||||||
|
- Nazwa: {company_name}
|
||||||
|
- Branża: {category}
|
||||||
|
- Miasto: {city}
|
||||||
|
- Usługi: {services}
|
||||||
|
|
||||||
|
Opis powinien:
|
||||||
|
- Zawierać lokalne słowa kluczowe (miasto, region)
|
||||||
|
- Wymieniać główne usługi/produkty
|
||||||
|
- Zawierać CTA (zachętę do kontaktu)
|
||||||
|
- Być profesjonalny ale przyjazny
|
||||||
|
|
||||||
|
Odpowiedz WYŁĄCZNIE tekstem opisu, po polsku.""",
|
||||||
|
|
||||||
|
'generate_gbp_post': """Napisz post na Google Business Profile dla firmy:
|
||||||
|
- Nazwa: {company_name}
|
||||||
|
- Branża: {category}
|
||||||
|
- Miasto: {city}
|
||||||
|
|
||||||
|
Post powinien:
|
||||||
|
- Mieć 150-300 słów
|
||||||
|
- Zawierać CTA (np. "Zadzwoń", "Odwiedź stronę")
|
||||||
|
- Być angażujący i profesjonalny
|
||||||
|
- Dotyczyć aktualnej oferty/promocji/wydarzenia
|
||||||
|
|
||||||
|
Odpowiedz WYŁĄCZNIE tekstem postu, po polsku.""",
|
||||||
|
|
||||||
|
'respond_to_review': """Napisz profesjonalną odpowiedź na opinię Google:
|
||||||
|
- Firma: {company_name}
|
||||||
|
- Ocena: {review_rating}/5
|
||||||
|
- Treść opinii: {review_text}
|
||||||
|
|
||||||
|
Odpowiedź powinna:
|
||||||
|
- Podziękować za opinię
|
||||||
|
- Odnieść się do konkretnych punktów
|
||||||
|
- {'Przeprosić i zaproponować rozwiązanie' if int(str({review_rating}).replace('None','3')) <= 3 else 'Zachęcić do ponownych odwiedzin'}
|
||||||
|
- Być profesjonalna i osobista
|
||||||
|
- Max 200 słów
|
||||||
|
|
||||||
|
Odpowiedz WYŁĄCZNIE tekstem odpowiedzi, po polsku.""",
|
||||||
|
|
||||||
|
'generate_social_post': """Napisz post na {platform} dla firmy:
|
||||||
|
- Nazwa: {company_name}
|
||||||
|
- Branża: {category}
|
||||||
|
- Miasto: {city}
|
||||||
|
|
||||||
|
Wymagania dla {platform}:
|
||||||
|
- Facebook: 100-250 słów, może zawierać emoji, CTA
|
||||||
|
- Instagram: 100-150 słów, 5-10 hashtagów, emoji
|
||||||
|
- LinkedIn: 150-300 słów, profesjonalny ton, 3-5 hashtagów
|
||||||
|
- Twitter: max 280 znaków, 2-3 hashtagi
|
||||||
|
|
||||||
|
Odpowiedz WYŁĄCZNIE tekstem postu z hashtagami, po polsku.""",
|
||||||
|
|
||||||
|
'generate_bio': """Napisz bio/opis profilu na {platform} dla firmy:
|
||||||
|
- Nazwa: {company_name}
|
||||||
|
- Branża: {category}
|
||||||
|
- Miasto: {city}
|
||||||
|
- Strona: {website}
|
||||||
|
|
||||||
|
Limity znaków:
|
||||||
|
- Facebook: 255 znaków
|
||||||
|
- Instagram: 150 znaków
|
||||||
|
- LinkedIn: 2000 znaków (opis firmy)
|
||||||
|
- Twitter: 160 znaków
|
||||||
|
|
||||||
|
Odpowiedz WYŁĄCZNIE tekstem bio, po polsku.""",
|
||||||
|
|
||||||
|
'content_calendar': """Przygotuj kalendarz treści na tydzień (pon-pt) dla firmy w social media:
|
||||||
|
- Nazwa: {company_name}
|
||||||
|
- Branża: {category}
|
||||||
|
- Miasto: {city}
|
||||||
|
- Platformy: {platforms}
|
||||||
|
|
||||||
|
Dla każdego dnia podaj:
|
||||||
|
- Dzień tygodnia
|
||||||
|
- Platforma
|
||||||
|
- Temat postu
|
||||||
|
- Krótki zarys treści (1-2 zdania)
|
||||||
|
- Sugerowany typ: tekst/zdjęcie/wideo/karuzela
|
||||||
|
|
||||||
|
Format JSON array:
|
||||||
|
[{{"day": "Poniedziałek", "platform": "...", "topic": "...", "outline": "...", "content_type": "..."}}]
|
||||||
|
|
||||||
|
Odpowiedz WYŁĄCZNIE JSON-em, po polsku.""",
|
||||||
|
|
||||||
|
'content_strategy': """Przygotuj krótką strategię obecności w social media dla firmy:
|
||||||
|
- Nazwa: {company_name}
|
||||||
|
- Branża: {category}
|
||||||
|
- Miasto: {city}
|
||||||
|
- Obecne platformy: {platforms_present}
|
||||||
|
- Brakujące platformy: {platforms_missing}
|
||||||
|
|
||||||
|
Strategia powinna zawierać:
|
||||||
|
1. Rekomendowane platformy (z uzasadnieniem)
|
||||||
|
2. Częstotliwość publikacji per platforma
|
||||||
|
3. Typy treści per platforma
|
||||||
|
4. Ton komunikacji
|
||||||
|
5. Cele na 3 miesiące
|
||||||
|
|
||||||
|
Max 500 słów, po polsku. Pisz bezpośrednio do właściciela firmy.""",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# MAIN SERVICE FUNCTIONS
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def generate_analysis(company_id: int, audit_type: str, user_id: int = None, force: bool = False) -> dict:
|
||||||
|
"""
|
||||||
|
Generate AI analysis for an audit.
|
||||||
|
|
||||||
|
Returns cached version if available and data hasn't changed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
company_id: Company ID
|
||||||
|
audit_type: 'seo', 'gbp', or 'social'
|
||||||
|
user_id: Current user ID for cost tracking
|
||||||
|
force: Force regeneration even if cache is valid
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with 'summary' and 'actions' keys
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
company = db.query(Company).filter_by(id=company_id, status='active').first()
|
||||||
|
if not company:
|
||||||
|
return {'error': 'Firma nie znaleziona'}
|
||||||
|
|
||||||
|
# Collect audit data
|
||||||
|
collectors = {
|
||||||
|
'seo': _collect_seo_data,
|
||||||
|
'gbp': _collect_gbp_data,
|
||||||
|
'social': _collect_social_data,
|
||||||
|
}
|
||||||
|
collector = collectors.get(audit_type)
|
||||||
|
if not collector:
|
||||||
|
return {'error': f'Nieznany typ audytu: {audit_type}'}
|
||||||
|
|
||||||
|
data = collector(db, company)
|
||||||
|
if not data:
|
||||||
|
return {'error': f'Brak danych audytu {audit_type} dla tej firmy'}
|
||||||
|
|
||||||
|
data_hash = _hash_data(data)
|
||||||
|
|
||||||
|
# Check cache
|
||||||
|
if not force:
|
||||||
|
cache = db.query(AuditAICache).filter_by(
|
||||||
|
company_id=company_id,
|
||||||
|
audit_type=audit_type
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if cache and cache.audit_data_hash == data_hash and cache.expires_at and cache.expires_at > datetime.now():
|
||||||
|
logger.info(f"AI analysis cache hit for company {company_id} audit_type={audit_type}")
|
||||||
|
return {
|
||||||
|
'summary': cache.analysis_summary,
|
||||||
|
'actions': cache.actions_json or [],
|
||||||
|
'cached': True,
|
||||||
|
'generated_at': cache.generated_at.isoformat() if cache.generated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build prompt
|
||||||
|
prompt_builders = {
|
||||||
|
'seo': _build_seo_prompt,
|
||||||
|
'gbp': _build_gbp_prompt,
|
||||||
|
'social': _build_social_prompt,
|
||||||
|
}
|
||||||
|
prompt = prompt_builders[audit_type](data)
|
||||||
|
|
||||||
|
# Call Gemini
|
||||||
|
gemini = _get_gemini_service()
|
||||||
|
response_text = gemini.generate_text(
|
||||||
|
prompt=prompt,
|
||||||
|
temperature=0.3,
|
||||||
|
feature='audit_analysis',
|
||||||
|
user_id=user_id,
|
||||||
|
company_id=company_id,
|
||||||
|
related_entity_type=f'{audit_type}_audit',
|
||||||
|
)
|
||||||
|
|
||||||
|
if not response_text:
|
||||||
|
return {'error': 'Gemini nie zwrócił odpowiedzi'}
|
||||||
|
|
||||||
|
# Parse JSON response
|
||||||
|
try:
|
||||||
|
# Clean possible markdown code blocks
|
||||||
|
cleaned = response_text.strip()
|
||||||
|
if cleaned.startswith('```'):
|
||||||
|
cleaned = cleaned.split('\n', 1)[1] if '\n' in cleaned else cleaned[3:]
|
||||||
|
if cleaned.endswith('```'):
|
||||||
|
cleaned = cleaned[:-3]
|
||||||
|
cleaned = cleaned.strip()
|
||||||
|
|
||||||
|
result = json.loads(cleaned)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error(f"Failed to parse Gemini response as JSON: {e}\nResponse: {response_text[:500]}")
|
||||||
|
return {'error': 'Nie udało się przetworzyć odpowiedzi AI', 'raw_response': response_text}
|
||||||
|
|
||||||
|
summary = result.get('summary', '')
|
||||||
|
actions = result.get('actions', [])
|
||||||
|
|
||||||
|
# Save to cache (upsert)
|
||||||
|
cache = db.query(AuditAICache).filter_by(
|
||||||
|
company_id=company_id,
|
||||||
|
audit_type=audit_type
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if cache:
|
||||||
|
cache.analysis_summary = summary
|
||||||
|
cache.actions_json = actions
|
||||||
|
cache.audit_data_hash = data_hash
|
||||||
|
cache.generated_at = datetime.now()
|
||||||
|
cache.expires_at = datetime.now() + timedelta(days=CACHE_EXPIRY_DAYS)
|
||||||
|
else:
|
||||||
|
cache = AuditAICache(
|
||||||
|
company_id=company_id,
|
||||||
|
audit_type=audit_type,
|
||||||
|
analysis_summary=summary,
|
||||||
|
actions_json=actions,
|
||||||
|
audit_data_hash=data_hash,
|
||||||
|
generated_at=datetime.now(),
|
||||||
|
expires_at=datetime.now() + timedelta(days=CACHE_EXPIRY_DAYS),
|
||||||
|
)
|
||||||
|
db.add(cache)
|
||||||
|
|
||||||
|
# Save individual actions to audit_actions table
|
||||||
|
for action_data in actions:
|
||||||
|
action = AuditAction(
|
||||||
|
company_id=company_id,
|
||||||
|
audit_type=audit_type,
|
||||||
|
action_type=action_data.get('action_type', 'unknown'),
|
||||||
|
title=action_data.get('title', 'Akcja'),
|
||||||
|
description=action_data.get('description', ''),
|
||||||
|
priority=action_data.get('priority', 'medium'),
|
||||||
|
impact_score=action_data.get('impact_score'),
|
||||||
|
effort_score=action_data.get('effort_score'),
|
||||||
|
platform=action_data.get('platform', 'website'),
|
||||||
|
ai_model=gemini.model_name,
|
||||||
|
status='suggested',
|
||||||
|
created_by=user_id,
|
||||||
|
)
|
||||||
|
db.add(action)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'summary': summary,
|
||||||
|
'actions': actions,
|
||||||
|
'cached': False,
|
||||||
|
'generated_at': datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Error generating AI analysis: {e}", exc_info=True)
|
||||||
|
return {'error': f'Błąd generowania analizy: {str(e)}'}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_content(company_id: int, action_type: str, context: dict = None, user_id: int = None) -> dict:
|
||||||
|
"""
|
||||||
|
Generate specific content for an audit action.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
company_id: Company ID
|
||||||
|
action_type: Content type (e.g. 'generate_schema_org', 'generate_gbp_post')
|
||||||
|
context: Additional context (e.g. platform, review_text)
|
||||||
|
user_id: Current user ID for cost tracking
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with 'content' key
|
||||||
|
"""
|
||||||
|
if action_type not in CONTENT_PROMPTS:
|
||||||
|
return {'error': f'Nieznany typ akcji: {action_type}'}
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
company = db.query(Company).filter_by(id=company_id, status='active').first()
|
||||||
|
if not company:
|
||||||
|
return {'error': 'Firma nie znaleziona'}
|
||||||
|
|
||||||
|
# Build context for prompt
|
||||||
|
prompt_context = {
|
||||||
|
'company_name': company.name,
|
||||||
|
'category': company.category or 'Usługi',
|
||||||
|
'city': company.address_city or 'Polska',
|
||||||
|
'website': company.website or '',
|
||||||
|
'phone': company.phone or '',
|
||||||
|
'email': company.email or '',
|
||||||
|
'address': f"{company.address_street or ''}, {company.address_city or ''}".strip(', '),
|
||||||
|
'services': ', '.join([s.name for s in company.services[:5]]) if hasattr(company, 'services') and company.services else 'brak danych',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Merge in extra context
|
||||||
|
if context:
|
||||||
|
prompt_context.update(context)
|
||||||
|
|
||||||
|
# Add audit-specific data
|
||||||
|
if action_type in ('suggest_heading_fix', 'generate_alt_texts'):
|
||||||
|
analysis = db.query(CompanyWebsiteAnalysis).filter(
|
||||||
|
CompanyWebsiteAnalysis.company_id == company.id
|
||||||
|
).order_by(CompanyWebsiteAnalysis.seo_audited_at.desc()).first()
|
||||||
|
if analysis:
|
||||||
|
prompt_context.update({
|
||||||
|
'h1_text': analysis.h1_text or 'brak',
|
||||||
|
'h1_count': analysis.h1_count or 0,
|
||||||
|
'h2_count': analysis.h2_count or 0,
|
||||||
|
'h3_count': analysis.h3_count or 0,
|
||||||
|
'images_without_alt': analysis.images_without_alt or 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
if action_type in ('content_calendar', 'content_strategy'):
|
||||||
|
profiles = db.query(CompanySocialMedia).filter(
|
||||||
|
CompanySocialMedia.company_id == company.id
|
||||||
|
).all()
|
||||||
|
all_platforms = ['facebook', 'instagram', 'linkedin', 'youtube', 'twitter', 'tiktok']
|
||||||
|
present = [p.platform for p in profiles]
|
||||||
|
missing = [p for p in all_platforms if p not in present]
|
||||||
|
prompt_context.update({
|
||||||
|
'platforms': ', '.join(present) if present else 'brak',
|
||||||
|
'platforms_present': ', '.join(present) if present else 'brak',
|
||||||
|
'platforms_missing': ', '.join(missing) if missing else 'brak',
|
||||||
|
})
|
||||||
|
|
||||||
|
# Build prompt from template
|
||||||
|
prompt_template = CONTENT_PROMPTS[action_type]
|
||||||
|
try:
|
||||||
|
prompt = prompt_template.format(**prompt_context)
|
||||||
|
except KeyError as e:
|
||||||
|
prompt = prompt_template # Fall back to raw template if context is incomplete
|
||||||
|
logger.warning(f"Missing prompt context key: {e}")
|
||||||
|
|
||||||
|
# Call Gemini
|
||||||
|
gemini = _get_gemini_service()
|
||||||
|
content = gemini.generate_text(
|
||||||
|
prompt=prompt,
|
||||||
|
temperature=0.5,
|
||||||
|
feature='audit_content_generation',
|
||||||
|
user_id=user_id,
|
||||||
|
company_id=company_id,
|
||||||
|
related_entity_type=f'audit_action_{action_type}',
|
||||||
|
)
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
return {'error': 'Gemini nie zwrócił odpowiedzi'}
|
||||||
|
|
||||||
|
# Update action record if it exists
|
||||||
|
action = db.query(AuditAction).filter_by(
|
||||||
|
company_id=company_id,
|
||||||
|
action_type=action_type,
|
||||||
|
status='suggested'
|
||||||
|
).order_by(AuditAction.created_at.desc()).first()
|
||||||
|
|
||||||
|
if action:
|
||||||
|
action.ai_content = content
|
||||||
|
action.ai_model = gemini.model_name
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'content': content,
|
||||||
|
'action_type': action_type,
|
||||||
|
'model': gemini.model_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Error generating content: {e}", exc_info=True)
|
||||||
|
return {'error': f'Błąd generowania treści: {str(e)}'}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_actions_for_company(company_id: int, audit_type: str = None) -> list:
|
||||||
|
"""
|
||||||
|
Get all audit actions for a company, optionally filtered by audit type.
|
||||||
|
|
||||||
|
Returns list of action dicts sorted by priority.
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
query = db.query(AuditAction).filter_by(company_id=company_id)
|
||||||
|
if audit_type:
|
||||||
|
query = query.filter_by(audit_type=audit_type)
|
||||||
|
|
||||||
|
actions = query.filter(
|
||||||
|
AuditAction.status.in_(['suggested', 'approved'])
|
||||||
|
).order_by(AuditAction.created_at.desc()).all()
|
||||||
|
|
||||||
|
# Deduplicate by action_type (keep newest)
|
||||||
|
seen = set()
|
||||||
|
unique_actions = []
|
||||||
|
for a in actions:
|
||||||
|
if a.action_type not in seen:
|
||||||
|
seen.add(a.action_type)
|
||||||
|
unique_actions.append(a)
|
||||||
|
|
||||||
|
# Sort by priority
|
||||||
|
priority_order = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3}
|
||||||
|
unique_actions.sort(key=lambda a: priority_order.get(a.priority, 2))
|
||||||
|
|
||||||
|
return [{
|
||||||
|
'id': a.id,
|
||||||
|
'action_type': a.action_type,
|
||||||
|
'title': a.title,
|
||||||
|
'description': a.description,
|
||||||
|
'priority': a.priority,
|
||||||
|
'impact_score': a.impact_score,
|
||||||
|
'effort_score': a.effort_score,
|
||||||
|
'ai_content': a.ai_content,
|
||||||
|
'status': a.status,
|
||||||
|
'platform': a.platform,
|
||||||
|
'created_at': a.created_at.isoformat() if a.created_at else None,
|
||||||
|
} for a in unique_actions]
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def update_action_status(action_id: int, new_status: str) -> dict:
|
||||||
|
"""Update the status of an audit action."""
|
||||||
|
valid_statuses = ['suggested', 'approved', 'implemented', 'dismissed']
|
||||||
|
if new_status not in valid_statuses:
|
||||||
|
return {'error': f'Niepoprawny status. Dozwolone: {", ".join(valid_statuses)}'}
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
action = db.query(AuditAction).filter_by(id=action_id).first()
|
||||||
|
if not action:
|
||||||
|
return {'error': 'Akcja nie znaleziona'}
|
||||||
|
|
||||||
|
action.status = new_status
|
||||||
|
if new_status == 'implemented':
|
||||||
|
action.implemented_at = datetime.now()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {'success': True, 'id': action.id, 'status': new_status}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Error updating action status: {e}")
|
||||||
|
return {'error': str(e)}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@ -17,3 +17,4 @@ from . import routes_gbp_audit # noqa: E402, F401
|
|||||||
from . import routes_social_audit # noqa: E402, F401
|
from . import routes_social_audit # noqa: E402, F401
|
||||||
from . import routes_company # noqa: E402, F401
|
from . import routes_company # noqa: E402, F401
|
||||||
from . import routes_membership # noqa: E402, F401
|
from . import routes_membership # noqa: E402, F401
|
||||||
|
from . import routes_audit_actions # noqa: E402, F401
|
||||||
|
|||||||
201
blueprints/api/routes_audit_actions.py
Normal file
201
blueprints/api/routes_audit_actions.py
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
"""
|
||||||
|
Audit AI Actions API Routes - API blueprint
|
||||||
|
|
||||||
|
Endpoints for AI-powered audit analysis and content generation.
|
||||||
|
Uses audit_ai_service.py for Gemini integration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from flask import jsonify, request
|
||||||
|
from flask_login import current_user, login_required
|
||||||
|
|
||||||
|
from database import SessionLocal, Company
|
||||||
|
from . import bp
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/audit/analyze', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_audit_analyze():
|
||||||
|
"""
|
||||||
|
Generate AI analysis for an audit.
|
||||||
|
|
||||||
|
Request JSON:
|
||||||
|
company_id: int (required)
|
||||||
|
audit_type: str - 'seo', 'gbp', or 'social' (required)
|
||||||
|
force: bool - Force regeneration even if cache valid (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with summary and actions list
|
||||||
|
"""
|
||||||
|
import audit_ai_service
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({'success': False, 'error': 'Brak danych w żądaniu'}), 400
|
||||||
|
|
||||||
|
company_id = data.get('company_id')
|
||||||
|
audit_type = data.get('audit_type')
|
||||||
|
force = data.get('force', False)
|
||||||
|
|
||||||
|
if not company_id or not audit_type:
|
||||||
|
return jsonify({'success': False, 'error': 'Wymagane: company_id i audit_type'}), 400
|
||||||
|
|
||||||
|
if audit_type not in ('seo', 'gbp', 'social'):
|
||||||
|
return jsonify({'success': False, 'error': 'audit_type musi być: seo, gbp lub social'}), 400
|
||||||
|
|
||||||
|
# Access control
|
||||||
|
if not current_user.can_edit_company(company_id):
|
||||||
|
return jsonify({'success': False, 'error': 'Brak uprawnień do analizy tej firmy'}), 403
|
||||||
|
|
||||||
|
result = audit_ai_service.generate_analysis(
|
||||||
|
company_id=company_id,
|
||||||
|
audit_type=audit_type,
|
||||||
|
user_id=current_user.id,
|
||||||
|
force=force,
|
||||||
|
)
|
||||||
|
|
||||||
|
if 'error' in result:
|
||||||
|
return jsonify({'success': False, 'error': result['error']}), 422
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'summary': result.get('summary', ''),
|
||||||
|
'actions': result.get('actions', []),
|
||||||
|
'cached': result.get('cached', False),
|
||||||
|
'generated_at': result.get('generated_at'),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/audit/generate-content', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_audit_generate_content():
|
||||||
|
"""
|
||||||
|
Generate specific content for an audit action.
|
||||||
|
|
||||||
|
Request JSON:
|
||||||
|
company_id: int (required)
|
||||||
|
action_type: str (required) - e.g. 'generate_schema_org', 'generate_gbp_post'
|
||||||
|
context: dict (optional) - Extra context like platform, review_text
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with generated content
|
||||||
|
"""
|
||||||
|
import audit_ai_service
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({'success': False, 'error': 'Brak danych w żądaniu'}), 400
|
||||||
|
|
||||||
|
company_id = data.get('company_id')
|
||||||
|
action_type = data.get('action_type')
|
||||||
|
context = data.get('context', {})
|
||||||
|
|
||||||
|
if not company_id or not action_type:
|
||||||
|
return jsonify({'success': False, 'error': 'Wymagane: company_id i action_type'}), 400
|
||||||
|
|
||||||
|
# Access control
|
||||||
|
if not current_user.can_edit_company(company_id):
|
||||||
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||||
|
|
||||||
|
result = audit_ai_service.generate_content(
|
||||||
|
company_id=company_id,
|
||||||
|
action_type=action_type,
|
||||||
|
context=context,
|
||||||
|
user_id=current_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if 'error' in result:
|
||||||
|
return jsonify({'success': False, 'error': result['error']}), 422
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'content': result.get('content', ''),
|
||||||
|
'action_type': result.get('action_type'),
|
||||||
|
'model': result.get('model'),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/audit/actions/<slug>')
|
||||||
|
@login_required
|
||||||
|
def api_audit_actions_by_slug(slug):
|
||||||
|
"""
|
||||||
|
Get audit actions for a company.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
audit_type: str (optional) - Filter by 'seo', 'gbp', or 'social'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with list of actions
|
||||||
|
"""
|
||||||
|
import audit_ai_service
|
||||||
|
|
||||||
|
audit_type = request.args.get('audit_type')
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
company = db.query(Company).filter_by(slug=slug, status='active').first()
|
||||||
|
if not company:
|
||||||
|
return jsonify({'success': False, 'error': 'Firma nie znaleziona'}), 404
|
||||||
|
|
||||||
|
if not current_user.can_edit_company(company.id):
|
||||||
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||||
|
|
||||||
|
actions = audit_ai_service.get_actions_for_company(
|
||||||
|
company_id=company.id,
|
||||||
|
audit_type=audit_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'company_id': company.id,
|
||||||
|
'actions': actions,
|
||||||
|
'count': len(actions),
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/audit/actions/<int:action_id>/status', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_audit_action_update_status(action_id):
|
||||||
|
"""
|
||||||
|
Update the status of an audit action.
|
||||||
|
|
||||||
|
Request JSON:
|
||||||
|
status: str - 'implemented' or 'dismissed'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with updated status
|
||||||
|
"""
|
||||||
|
import audit_ai_service
|
||||||
|
from database import AuditAction
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({'success': False, 'error': 'Brak danych'}), 400
|
||||||
|
|
||||||
|
new_status = data.get('status')
|
||||||
|
if not new_status:
|
||||||
|
return jsonify({'success': False, 'error': 'Wymagane: status'}), 400
|
||||||
|
|
||||||
|
# Verify access
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
action = db.query(AuditAction).filter_by(id=action_id).first()
|
||||||
|
if not action:
|
||||||
|
return jsonify({'success': False, 'error': 'Akcja nie znaleziona'}), 404
|
||||||
|
|
||||||
|
if not current_user.can_edit_company(action.company_id):
|
||||||
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
result = audit_ai_service.update_action_status(action_id, new_status)
|
||||||
|
|
||||||
|
if 'error' in result:
|
||||||
|
return jsonify({'success': False, 'error': result['error']}), 422
|
||||||
|
|
||||||
|
return jsonify({'success': True, **result})
|
||||||
101
database.py
101
database.py
@ -5078,6 +5078,107 @@ class BenefitClick(Base):
|
|||||||
return f"<BenefitClick {self.id} benefit={self.benefit_id}>"
|
return f"<BenefitClick {self.id} benefit={self.benefit_id}>"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# AUDIT AI ACTIONS & CACHE
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class AuditAction(Base):
|
||||||
|
"""
|
||||||
|
AI-generated action items from audit analysis.
|
||||||
|
Tracks suggestions, their generated content, and implementation status.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'audit_actions'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False)
|
||||||
|
audit_type = Column(String(20), nullable=False) # 'seo', 'gbp', 'social'
|
||||||
|
action_type = Column(String(50), nullable=False) # 'generate_schema_org', etc.
|
||||||
|
title = Column(String(255), nullable=False)
|
||||||
|
description = Column(Text)
|
||||||
|
priority = Column(String(20), default='medium') # 'critical', 'high', 'medium', 'low'
|
||||||
|
impact_score = Column(Integer) # 1-10
|
||||||
|
effort_score = Column(Integer) # 1-10
|
||||||
|
ai_content = Column(Text) # Generated content
|
||||||
|
ai_model = Column(String(50))
|
||||||
|
status = Column(String(20), default='suggested') # 'suggested', 'approved', 'implemented', 'dismissed'
|
||||||
|
platform = Column(String(30)) # 'google', 'facebook', etc.
|
||||||
|
created_by = Column(Integer, ForeignKey('users.id'))
|
||||||
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
|
implemented_at = Column(DateTime)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
company = relationship('Company', backref='audit_actions')
|
||||||
|
creator = relationship('User', foreign_keys=[created_by])
|
||||||
|
|
||||||
|
PRIORITY_ORDER = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def priority_rank(self):
|
||||||
|
return self.PRIORITY_ORDER.get(self.priority, 2)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<AuditAction {self.id} {self.audit_type}/{self.action_type} [{self.status}]>"
|
||||||
|
|
||||||
|
|
||||||
|
class AuditAICache(Base):
|
||||||
|
"""
|
||||||
|
Cache for AI-generated audit analyses.
|
||||||
|
Avoids regenerating analysis when audit data hasn't changed.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'audit_ai_cache'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False)
|
||||||
|
audit_type = Column(String(20), nullable=False)
|
||||||
|
analysis_summary = Column(Text)
|
||||||
|
actions_json = Column(JSONB)
|
||||||
|
audit_data_hash = Column(String(64))
|
||||||
|
generated_at = Column(DateTime, default=datetime.now)
|
||||||
|
expires_at = Column(DateTime)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
company = relationship('Company', backref='audit_ai_caches')
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('company_id', 'audit_type', name='uq_audit_ai_cache_company_type'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<AuditAICache {self.id} company={self.company_id} type={self.audit_type}>"
|
||||||
|
|
||||||
|
|
||||||
|
class SocialConnection(Base):
|
||||||
|
"""
|
||||||
|
OAuth connections for social media publishing (Phase 2-3).
|
||||||
|
Stores access/refresh tokens for GBP, Facebook, Instagram, LinkedIn APIs.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'social_connections'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False)
|
||||||
|
platform = Column(String(30), nullable=False)
|
||||||
|
access_token = Column(Text)
|
||||||
|
refresh_token = Column(Text)
|
||||||
|
token_expires_at = Column(DateTime)
|
||||||
|
scope = Column(Text)
|
||||||
|
external_account_id = Column(String(255))
|
||||||
|
external_account_name = Column(String(255))
|
||||||
|
connected_by = Column(Integer, ForeignKey('users.id'))
|
||||||
|
connected_at = Column(DateTime, default=datetime.now)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
company = relationship('Company', backref='social_connections')
|
||||||
|
connector = relationship('User', foreign_keys=[connected_by])
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('company_id', 'platform', name='uq_social_connection_company_platform'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<SocialConnection {self.id} company={self.company_id} platform={self.platform}>"
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# DATABASE INITIALIZATION
|
# DATABASE INITIALIZATION
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
76
database/migrations/056_audit_actions.sql
Normal file
76
database/migrations/056_audit_actions.sql
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
-- Migration 056: Audit AI Actions, Cache, and Social Connections
|
||||||
|
-- Created: 2026-02-07
|
||||||
|
-- Purpose: Tables for AI-powered audit analysis, action tracking, and social media OAuth connections
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- AUDIT ACTIONS - AI-generated action items per audit type
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_actions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
company_id INT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
||||||
|
audit_type VARCHAR(20) NOT NULL, -- 'seo', 'gbp', 'social'
|
||||||
|
action_type VARCHAR(50) NOT NULL, -- 'generate_schema_org', 'generate_gbp_post', etc.
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
priority VARCHAR(20) DEFAULT 'medium', -- 'critical', 'high', 'medium', 'low'
|
||||||
|
impact_score INT, -- 1-10
|
||||||
|
effort_score INT, -- 1-10
|
||||||
|
ai_content TEXT, -- Generated content (JSON for complex structures)
|
||||||
|
ai_model VARCHAR(50), -- Model used for generation
|
||||||
|
status VARCHAR(20) DEFAULT 'suggested', -- 'suggested', 'approved', 'implemented', 'dismissed'
|
||||||
|
platform VARCHAR(30), -- 'google', 'facebook', 'instagram', 'linkedin', 'website'
|
||||||
|
created_by INT REFERENCES users(id),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
implemented_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_audit_actions_company ON audit_actions(company_id, audit_type);
|
||||||
|
CREATE INDEX idx_audit_actions_status ON audit_actions(status);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- AUDIT AI CACHE - Cached AI analyses to avoid regeneration
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_ai_cache (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
company_id INT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
||||||
|
audit_type VARCHAR(20) NOT NULL,
|
||||||
|
analysis_summary TEXT, -- AI-generated summary paragraph
|
||||||
|
actions_json JSONB, -- Cached action list
|
||||||
|
audit_data_hash VARCHAR(64), -- SHA256 of input data (invalidate when data changes)
|
||||||
|
generated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMP, -- Auto-expire after 7 days
|
||||||
|
UNIQUE(company_id, audit_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- SOCIAL CONNECTIONS - OAuth tokens for publishing (Phase 2-3)
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS social_connections (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
company_id INT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
||||||
|
platform VARCHAR(30) NOT NULL, -- 'google_business', 'facebook', 'instagram', 'linkedin'
|
||||||
|
access_token TEXT,
|
||||||
|
refresh_token TEXT,
|
||||||
|
token_expires_at TIMESTAMP,
|
||||||
|
scope TEXT,
|
||||||
|
external_account_id VARCHAR(255),
|
||||||
|
external_account_name VARCHAR(255),
|
||||||
|
connected_by INT REFERENCES users(id),
|
||||||
|
connected_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
UNIQUE(company_id, platform)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- GRANTS
|
||||||
|
-- ============================================================
|
||||||
|
GRANT ALL ON TABLE audit_actions TO nordabiz_app;
|
||||||
|
GRANT ALL ON TABLE audit_ai_cache TO nordabiz_app;
|
||||||
|
GRANT ALL ON TABLE social_connections TO nordabiz_app;
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCE audit_actions_id_seq TO nordabiz_app;
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCE audit_ai_cache_id_seq TO nordabiz_app;
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCE social_connections_id_seq TO nordabiz_app;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@ -1517,6 +1517,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% with audit_type='gbp' %}
|
||||||
|
{% include 'partials/audit_ai_actions.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<!-- No Audit State -->
|
<!-- No Audit State -->
|
||||||
<div class="no-audit-state">
|
<div class="no-audit-state">
|
||||||
@ -1900,4 +1904,168 @@ async function runAudit() {
|
|||||||
if (btn) btn.disabled = false;
|
if (btn) btn.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
AI AUDIT ACTIONS
|
||||||
|
============================================================ */
|
||||||
|
const companyId = {{ company.id }};
|
||||||
|
const auditType = 'gbp';
|
||||||
|
|
||||||
|
async function runAIAnalysis(force) {
|
||||||
|
const prompt = document.getElementById('aiAnalyzePrompt');
|
||||||
|
const loading = document.getElementById('aiLoading');
|
||||||
|
const results = document.getElementById('aiResults');
|
||||||
|
const btn = document.getElementById('aiAnalyzeBtn');
|
||||||
|
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
if (prompt) prompt.style.display = 'none';
|
||||||
|
if (results) results.style.display = 'none';
|
||||||
|
if (loading) loading.style.display = 'block';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/audit/analyze', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrfToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
company_id: companyId,
|
||||||
|
audit_type: auditType,
|
||||||
|
force: !!force
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (loading) loading.style.display = 'none';
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
renderAIResults(data);
|
||||||
|
} else {
|
||||||
|
if (prompt) prompt.style.display = 'block';
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
showInfoModal('Blad analizy AI', data.error || 'Wystapil blad', false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (loading) loading.style.display = 'none';
|
||||||
|
if (prompt) prompt.style.display = 'block';
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
showInfoModal('Blad polaczenia', error.message, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAIResults(data) {
|
||||||
|
const results = document.getElementById('aiResults');
|
||||||
|
const summaryEl = document.getElementById('aiSummaryText');
|
||||||
|
const cacheInfo = document.getElementById('aiCacheInfo');
|
||||||
|
const actionsList = document.getElementById('aiActionsList');
|
||||||
|
|
||||||
|
summaryEl.textContent = data.summary || '';
|
||||||
|
cacheInfo.style.display = data.cached ? 'block' : 'none';
|
||||||
|
|
||||||
|
actionsList.innerHTML = '';
|
||||||
|
const actions = data.actions || [];
|
||||||
|
const priorityLabels = {critical: 'KRYTYCZNE', high: 'WYSOKI', medium: 'SREDNI', low: 'NISKI'};
|
||||||
|
|
||||||
|
actions.forEach((action, idx) => {
|
||||||
|
const impact = action.impact_score || 5;
|
||||||
|
const effort = action.effort_score || 5;
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'ai-action-card priority-' + (action.priority || 'medium');
|
||||||
|
card.id = 'ai-action-' + idx;
|
||||||
|
card.innerHTML = `
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: var(--spacing-sm); flex-wrap: wrap; gap: var(--spacing-xs);">
|
||||||
|
<div style="display: flex; align-items: center; gap: var(--spacing-sm);">
|
||||||
|
<span class="ai-priority-badge ${action.priority || 'medium'}">${priorityLabels[action.priority] || 'SREDNI'}</span>
|
||||||
|
<span class="ai-action-title" style="font-weight: 600; color: var(--text-primary);">${escapeHtml(action.title || '')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style="color: var(--text-secondary); font-size: var(--font-size-sm); margin-bottom: var(--spacing-sm);">${escapeHtml(action.description || '')}</p>
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-md); margin-bottom: var(--spacing-sm);">
|
||||||
|
<div>
|
||||||
|
<div style="font-size: var(--font-size-xs); color: var(--text-tertiary); margin-bottom: 2px;">Wplyw: ${impact}/10</div>
|
||||||
|
<div class="ai-score-bar"><div class="ai-score-bar-fill impact" style="width: ${impact * 10}%;"></div></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: var(--font-size-xs); color: var(--text-tertiary); margin-bottom: 2px;">Wysilek: ${effort}/10</div>
|
||||||
|
<div class="ai-score-bar"><div class="ai-score-bar-fill effort" style="width: ${effort * 10}%;"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ai-action-buttons">
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="generateContent('${action.action_type}', ${idx})">
|
||||||
|
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
|
||||||
|
Wygeneruj tresc
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="markAction(${idx}, 'implemented')" style="color: #10b981; border-color: #10b981;">
|
||||||
|
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
Zrobione
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="markAction(${idx}, 'dismissed')" style="color: var(--text-tertiary); border-color: var(--border);">Odrzuc</button>
|
||||||
|
</div>
|
||||||
|
<div id="ai-content-${idx}" style="display: none;"></div>
|
||||||
|
`;
|
||||||
|
actionsList.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
results.style.display = 'block';
|
||||||
|
window._aiActions = actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateContent(actionType, idx) {
|
||||||
|
const container = document.getElementById('ai-content-' + idx);
|
||||||
|
if (!container) return;
|
||||||
|
if (container.dataset.loaded === 'true') {
|
||||||
|
container.style.display = container.style.display === 'none' ? 'block' : 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = '<div style="padding: var(--spacing-md); color: var(--text-secondary); font-size: var(--font-size-sm);">Generowanie tresci...</div>';
|
||||||
|
container.style.display = 'block';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/audit/generate-content', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrfToken},
|
||||||
|
body: JSON.stringify({company_id: companyId, action_type: actionType, context: {}})
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success && data.content) {
|
||||||
|
container.innerHTML = `<div class="ai-content-output"><button class="ai-copy-btn" onclick="copyContent(this)">Kopiuj</button><code>${escapeHtml(data.content)}</code></div>`;
|
||||||
|
container.dataset.loaded = 'true';
|
||||||
|
} else {
|
||||||
|
container.innerHTML = `<div style="padding: var(--spacing-sm); color: #ef4444; font-size: var(--font-size-sm);">${escapeHtml(data.error || 'Blad generowania')}</div>`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
container.innerHTML = `<div style="padding: var(--spacing-sm); color: #ef4444; font-size: var(--font-size-sm);">Blad: ${escapeHtml(error.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyContent(btn) {
|
||||||
|
const code = btn.parentElement.querySelector('code');
|
||||||
|
if (!code) return;
|
||||||
|
navigator.clipboard.writeText(code.textContent).then(() => {
|
||||||
|
const orig = btn.textContent;
|
||||||
|
btn.textContent = 'Skopiowano!';
|
||||||
|
setTimeout(() => { btn.textContent = orig; }, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAction(idx, status) {
|
||||||
|
const card = document.getElementById('ai-action-' + idx);
|
||||||
|
if (!card) return;
|
||||||
|
if (status === 'implemented') card.classList.add('implemented');
|
||||||
|
else if (status === 'dismissed') card.classList.add('dismissed');
|
||||||
|
const actions = window._aiActions || [];
|
||||||
|
if (actions[idx] && actions[idx].id) {
|
||||||
|
fetch('/api/audit/actions/' + actions[idx].id + '/status', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrfToken},
|
||||||
|
body: JSON.stringify({status: status})
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
160
templates/partials/audit_ai_actions.html
Normal file
160
templates/partials/audit_ai_actions.html
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
{#
|
||||||
|
Partial: AI Audit Actions Section
|
||||||
|
|
||||||
|
Variables required:
|
||||||
|
company - Company object (with .id, .slug)
|
||||||
|
audit_type - 'seo', 'gbp', or 'social'
|
||||||
|
|
||||||
|
Include this at the bottom of audit templates:
|
||||||
|
{% include 'partials/audit_ai_actions.html' %}
|
||||||
|
#}
|
||||||
|
|
||||||
|
<!-- AI Analysis & Actions Section -->
|
||||||
|
<div id="aiActionsSection" style="margin-top: var(--spacing-2xl);">
|
||||||
|
<h2 class="section-title" style="font-size: var(--font-size-xl); font-weight: 600; color: var(--text-primary); margin-bottom: var(--spacing-md); display: flex; align-items: center; gap: var(--spacing-sm);">
|
||||||
|
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
|
||||||
|
</svg>
|
||||||
|
Analiza AI i Rekomendacje
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- Generate Analysis Button -->
|
||||||
|
<div id="aiAnalyzePrompt" style="background: var(--surface); padding: var(--spacing-xl); border-radius: var(--radius-lg); box-shadow: var(--shadow); text-align: center;">
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: var(--spacing-md);">
|
||||||
|
AI przeanalizuje wyniki audytu i zaproponuje priorytetowane akcje do podjecia.
|
||||||
|
</p>
|
||||||
|
<button class="btn btn-primary" onclick="runAIAnalysis()" id="aiAnalyzeBtn">
|
||||||
|
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
|
||||||
|
</svg>
|
||||||
|
Wygeneruj analize AI
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AI Loading Spinner -->
|
||||||
|
<div id="aiLoading" style="display: none; background: var(--surface); padding: var(--spacing-xl); border-radius: var(--radius-lg); box-shadow: var(--shadow); text-align: center;">
|
||||||
|
<div style="width: 40px; height: 40px; border: 3px solid var(--border); border-top-color: var(--primary); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto var(--spacing-md);"></div>
|
||||||
|
<p style="color: var(--text-secondary);">Analiza AI w toku... (moze potrwac 5-10 sekund)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AI Results Container -->
|
||||||
|
<div id="aiResults" style="display: none;">
|
||||||
|
<!-- Summary -->
|
||||||
|
<div id="aiSummary" style="background: linear-gradient(135deg, #eff6ff 0%, #f0fdf4 100%); padding: var(--spacing-lg); border-radius: var(--radius-lg); margin-bottom: var(--spacing-lg); border: 1px solid #bfdbfe;">
|
||||||
|
<div style="display: flex; align-items: flex-start; gap: var(--spacing-sm);">
|
||||||
|
<svg width="20" height="20" fill="none" stroke="#2563eb" viewBox="0 0 24 24" style="flex-shrink: 0; margin-top: 2px;">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
<p id="aiSummaryText" style="color: var(--text-primary); line-height: 1.6; margin: 0;"></p>
|
||||||
|
</div>
|
||||||
|
<div id="aiCacheInfo" style="display: none; margin-top: var(--spacing-sm); font-size: var(--font-size-xs); color: var(--text-tertiary);">
|
||||||
|
Analiza z cache — <a href="#" onclick="runAIAnalysis(true); return false;" style="color: var(--primary);">Wygeneruj ponownie</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions List -->
|
||||||
|
<div style="font-size: var(--font-size-lg); font-weight: 600; color: var(--text-primary); margin-bottom: var(--spacing-md);">
|
||||||
|
Priorytetowe akcje
|
||||||
|
</div>
|
||||||
|
<div id="aiActionsList"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ai-action-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
border-left: 4px solid var(--border);
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
.ai-action-card:hover {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.ai-action-card.priority-critical { border-left-color: #ef4444; }
|
||||||
|
.ai-action-card.priority-high { border-left-color: #f97316; }
|
||||||
|
.ai-action-card.priority-medium { border-left-color: #f59e0b; }
|
||||||
|
.ai-action-card.priority-low { border-left-color: #84cc16; }
|
||||||
|
|
||||||
|
.ai-priority-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.ai-priority-badge.critical { background: #fee2e2; color: #dc2626; }
|
||||||
|
.ai-priority-badge.high { background: #ffedd5; color: #ea580c; }
|
||||||
|
.ai-priority-badge.medium { background: #fef3c7; color: #d97706; }
|
||||||
|
.ai-priority-badge.low { background: #ecfccb; color: #65a30d; }
|
||||||
|
|
||||||
|
.ai-score-bar {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ai-score-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
.ai-score-bar-fill.impact { background: #3b82f6; }
|
||||||
|
.ai-score-bar-fill.effort { background: #f59e0b; }
|
||||||
|
|
||||||
|
.ai-content-output {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
font-family: 'Menlo', 'Monaco', 'Consolas', monospace;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
position: relative;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-copy-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.ai-copy-btn:hover {
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-action-card.implemented {
|
||||||
|
opacity: 0.6;
|
||||||
|
border-left-color: #10b981;
|
||||||
|
}
|
||||||
|
.ai-action-card.implemented .ai-action-title {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
.ai-action-card.dismissed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -847,6 +847,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if seo_data %}
|
||||||
|
{% with audit_type='seo' %}
|
||||||
|
{% include 'partials/audit_ai_actions.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Loading Overlay -->
|
<!-- Loading Overlay -->
|
||||||
<div class="loading-overlay" id="loadingOverlay">
|
<div class="loading-overlay" id="loadingOverlay">
|
||||||
<div class="loading-content">
|
<div class="loading-content">
|
||||||
@ -939,4 +945,206 @@ async function runAudit() {
|
|||||||
if (btn) btn.disabled = false;
|
if (btn) btn.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
AI AUDIT ACTIONS
|
||||||
|
============================================================ */
|
||||||
|
const companyId = {{ company.id }};
|
||||||
|
const auditType = 'seo';
|
||||||
|
|
||||||
|
async function runAIAnalysis(force) {
|
||||||
|
const prompt = document.getElementById('aiAnalyzePrompt');
|
||||||
|
const loading = document.getElementById('aiLoading');
|
||||||
|
const results = document.getElementById('aiResults');
|
||||||
|
const btn = document.getElementById('aiAnalyzeBtn');
|
||||||
|
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
if (prompt) prompt.style.display = 'none';
|
||||||
|
if (results) results.style.display = 'none';
|
||||||
|
if (loading) loading.style.display = 'block';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/audit/analyze', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrfToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
company_id: companyId,
|
||||||
|
audit_type: auditType,
|
||||||
|
force: !!force
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (loading) loading.style.display = 'none';
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
renderAIResults(data);
|
||||||
|
} else {
|
||||||
|
if (prompt) prompt.style.display = 'block';
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
showInfoModal('Blad analizy AI', data.error || 'Wystapil blad', false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (loading) loading.style.display = 'none';
|
||||||
|
if (prompt) prompt.style.display = 'block';
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
showInfoModal('Blad polaczenia', error.message, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAIResults(data) {
|
||||||
|
const results = document.getElementById('aiResults');
|
||||||
|
const summaryEl = document.getElementById('aiSummaryText');
|
||||||
|
const cacheInfo = document.getElementById('aiCacheInfo');
|
||||||
|
const actionsList = document.getElementById('aiActionsList');
|
||||||
|
|
||||||
|
summaryEl.textContent = data.summary || '';
|
||||||
|
if (data.cached) {
|
||||||
|
cacheInfo.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
cacheInfo.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
actionsList.innerHTML = '';
|
||||||
|
const actions = data.actions || [];
|
||||||
|
const priorityLabels = {critical: 'KRYTYCZNE', high: 'WYSOKI', medium: 'SREDNI', low: 'NISKI'};
|
||||||
|
|
||||||
|
actions.forEach((action, idx) => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'ai-action-card priority-' + (action.priority || 'medium');
|
||||||
|
card.id = 'ai-action-' + idx;
|
||||||
|
|
||||||
|
const impact = action.impact_score || 5;
|
||||||
|
const effort = action.effort_score || 5;
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: var(--spacing-sm); flex-wrap: wrap; gap: var(--spacing-xs);">
|
||||||
|
<div style="display: flex; align-items: center; gap: var(--spacing-sm);">
|
||||||
|
<span class="ai-priority-badge ${action.priority || 'medium'}">${priorityLabels[action.priority] || 'SREDNI'}</span>
|
||||||
|
<span class="ai-action-title" style="font-weight: 600; color: var(--text-primary);">${escapeHtml(action.title || '')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style="color: var(--text-secondary); font-size: var(--font-size-sm); margin-bottom: var(--spacing-sm);">${escapeHtml(action.description || '')}</p>
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-md); margin-bottom: var(--spacing-sm);">
|
||||||
|
<div>
|
||||||
|
<div style="font-size: var(--font-size-xs); color: var(--text-tertiary); margin-bottom: 2px;">Wplyw: ${impact}/10</div>
|
||||||
|
<div class="ai-score-bar"><div class="ai-score-bar-fill impact" style="width: ${impact * 10}%;"></div></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: var(--font-size-xs); color: var(--text-tertiary); margin-bottom: 2px;">Wysilek: ${effort}/10</div>
|
||||||
|
<div class="ai-score-bar"><div class="ai-score-bar-fill effort" style="width: ${effort * 10}%;"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ai-action-buttons">
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="generateContent('${action.action_type}', ${idx})">
|
||||||
|
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
|
||||||
|
Wygeneruj tresc
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="markAction(${idx}, 'implemented')" style="color: #10b981; border-color: #10b981;">
|
||||||
|
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
Zrobione
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="markAction(${idx}, 'dismissed')" style="color: var(--text-tertiary); border-color: var(--border);">
|
||||||
|
Odrzuc
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="ai-content-${idx}" style="display: none;"></div>
|
||||||
|
`;
|
||||||
|
actionsList.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
results.style.display = 'block';
|
||||||
|
|
||||||
|
// Store actions data for content generation
|
||||||
|
window._aiActions = actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateContent(actionType, idx) {
|
||||||
|
const container = document.getElementById('ai-content-' + idx);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// If already has content, toggle visibility
|
||||||
|
if (container.dataset.loaded === 'true') {
|
||||||
|
container.style.display = container.style.display === 'none' ? 'block' : 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = '<div style="padding: var(--spacing-md); color: var(--text-secondary); font-size: var(--font-size-sm);">Generowanie tresci...</div>';
|
||||||
|
container.style.display = 'block';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/audit/generate-content', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrfToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
company_id: companyId,
|
||||||
|
action_type: actionType,
|
||||||
|
context: {}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.content) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="ai-content-output">
|
||||||
|
<button class="ai-copy-btn" onclick="copyContent(this)">Kopiuj</button>
|
||||||
|
<code>${escapeHtml(data.content)}</code>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.dataset.loaded = 'true';
|
||||||
|
} else {
|
||||||
|
container.innerHTML = `<div style="padding: var(--spacing-sm); color: #ef4444; font-size: var(--font-size-sm);">${escapeHtml(data.error || 'Blad generowania')}</div>`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
container.innerHTML = `<div style="padding: var(--spacing-sm); color: #ef4444; font-size: var(--font-size-sm);">Blad: ${escapeHtml(error.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyContent(btn) {
|
||||||
|
const code = btn.parentElement.querySelector('code');
|
||||||
|
if (!code) return;
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(code.textContent).then(() => {
|
||||||
|
const orig = btn.textContent;
|
||||||
|
btn.textContent = 'Skopiowano!';
|
||||||
|
setTimeout(() => { btn.textContent = orig; }, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAction(idx, status) {
|
||||||
|
const card = document.getElementById('ai-action-' + idx);
|
||||||
|
if (!card) return;
|
||||||
|
|
||||||
|
if (status === 'implemented') {
|
||||||
|
card.classList.add('implemented');
|
||||||
|
} else if (status === 'dismissed') {
|
||||||
|
card.classList.add('dismissed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire and forget status update to backend
|
||||||
|
const actions = window._aiActions || [];
|
||||||
|
if (actions[idx] && actions[idx].id) {
|
||||||
|
fetch('/api/audit/actions/' + actions[idx].id + '/status', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrfToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ status: status })
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -1036,6 +1036,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if social_data %}
|
||||||
|
{% with audit_type='social' %}
|
||||||
|
{% include 'partials/audit_ai_actions.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Loading Overlay -->
|
<!-- Loading Overlay -->
|
||||||
<div class="loading-overlay" id="loadingOverlay">
|
<div class="loading-overlay" id="loadingOverlay">
|
||||||
<div class="loading-content">
|
<div class="loading-content">
|
||||||
@ -1348,4 +1354,168 @@ document.getElementById('modalOverlay').addEventListener('click', function(e) {
|
|||||||
closeModal();
|
closeModal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
AI AUDIT ACTIONS
|
||||||
|
============================================================ */
|
||||||
|
const companyId = {{ company.id }};
|
||||||
|
const auditType = 'social';
|
||||||
|
|
||||||
|
async function runAIAnalysis(force) {
|
||||||
|
const prompt = document.getElementById('aiAnalyzePrompt');
|
||||||
|
const loading = document.getElementById('aiLoading');
|
||||||
|
const results = document.getElementById('aiResults');
|
||||||
|
const btn = document.getElementById('aiAnalyzeBtn');
|
||||||
|
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
if (prompt) prompt.style.display = 'none';
|
||||||
|
if (results) results.style.display = 'none';
|
||||||
|
if (loading) loading.style.display = 'block';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/audit/analyze', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrfToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
company_id: companyId,
|
||||||
|
audit_type: auditType,
|
||||||
|
force: !!force
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (loading) loading.style.display = 'none';
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
renderAIResults(data);
|
||||||
|
} else {
|
||||||
|
if (prompt) prompt.style.display = 'block';
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
showModal('Blad analizy AI', data.error || 'Wystapil blad', false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (loading) loading.style.display = 'none';
|
||||||
|
if (prompt) prompt.style.display = 'block';
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
showModal('Blad polaczenia', error.message, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAIResults(data) {
|
||||||
|
const results = document.getElementById('aiResults');
|
||||||
|
const summaryEl = document.getElementById('aiSummaryText');
|
||||||
|
const cacheInfo = document.getElementById('aiCacheInfo');
|
||||||
|
const actionsList = document.getElementById('aiActionsList');
|
||||||
|
|
||||||
|
summaryEl.textContent = data.summary || '';
|
||||||
|
cacheInfo.style.display = data.cached ? 'block' : 'none';
|
||||||
|
|
||||||
|
actionsList.innerHTML = '';
|
||||||
|
const actions = data.actions || [];
|
||||||
|
const priorityLabels = {critical: 'KRYTYCZNE', high: 'WYSOKI', medium: 'SREDNI', low: 'NISKI'};
|
||||||
|
|
||||||
|
actions.forEach((action, idx) => {
|
||||||
|
const impact = action.impact_score || 5;
|
||||||
|
const effort = action.effort_score || 5;
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'ai-action-card priority-' + (action.priority || 'medium');
|
||||||
|
card.id = 'ai-action-' + idx;
|
||||||
|
card.innerHTML = `
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: var(--spacing-sm); flex-wrap: wrap; gap: var(--spacing-xs);">
|
||||||
|
<div style="display: flex; align-items: center; gap: var(--spacing-sm);">
|
||||||
|
<span class="ai-priority-badge ${action.priority || 'medium'}">${priorityLabels[action.priority] || 'SREDNI'}</span>
|
||||||
|
<span class="ai-action-title" style="font-weight: 600; color: var(--text-primary);">${escapeHtml(action.title || '')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style="color: var(--text-secondary); font-size: var(--font-size-sm); margin-bottom: var(--spacing-sm);">${escapeHtml(action.description || '')}</p>
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-md); margin-bottom: var(--spacing-sm);">
|
||||||
|
<div>
|
||||||
|
<div style="font-size: var(--font-size-xs); color: var(--text-tertiary); margin-bottom: 2px;">Wplyw: ${impact}/10</div>
|
||||||
|
<div class="ai-score-bar"><div class="ai-score-bar-fill impact" style="width: ${impact * 10}%;"></div></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: var(--font-size-xs); color: var(--text-tertiary); margin-bottom: 2px;">Wysilek: ${effort}/10</div>
|
||||||
|
<div class="ai-score-bar"><div class="ai-score-bar-fill effort" style="width: ${effort * 10}%;"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ai-action-buttons">
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="generateContent('${action.action_type}', ${idx})">
|
||||||
|
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
|
||||||
|
Wygeneruj tresc
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="markAction(${idx}, 'implemented')" style="color: #10b981; border-color: #10b981;">
|
||||||
|
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
Zrobione
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="markAction(${idx}, 'dismissed')" style="color: var(--text-tertiary); border-color: var(--border);">Odrzuc</button>
|
||||||
|
</div>
|
||||||
|
<div id="ai-content-${idx}" style="display: none;"></div>
|
||||||
|
`;
|
||||||
|
actionsList.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
results.style.display = 'block';
|
||||||
|
window._aiActions = actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateContent(actionType, idx) {
|
||||||
|
const container = document.getElementById('ai-content-' + idx);
|
||||||
|
if (!container) return;
|
||||||
|
if (container.dataset.loaded === 'true') {
|
||||||
|
container.style.display = container.style.display === 'none' ? 'block' : 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = '<div style="padding: var(--spacing-md); color: var(--text-secondary); font-size: var(--font-size-sm);">Generowanie tresci...</div>';
|
||||||
|
container.style.display = 'block';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/audit/generate-content', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrfToken},
|
||||||
|
body: JSON.stringify({company_id: companyId, action_type: actionType, context: {}})
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success && data.content) {
|
||||||
|
container.innerHTML = `<div class="ai-content-output"><button class="ai-copy-btn" onclick="copyContent(this)">Kopiuj</button><code>${escapeHtml(data.content)}</code></div>`;
|
||||||
|
container.dataset.loaded = 'true';
|
||||||
|
} else {
|
||||||
|
container.innerHTML = `<div style="padding: var(--spacing-sm); color: #ef4444; font-size: var(--font-size-sm);">${escapeHtml(data.error || 'Blad generowania')}</div>`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
container.innerHTML = `<div style="padding: var(--spacing-sm); color: #ef4444; font-size: var(--font-size-sm);">Blad: ${escapeHtml(error.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyContent(btn) {
|
||||||
|
const code = btn.parentElement.querySelector('code');
|
||||||
|
if (!code) return;
|
||||||
|
navigator.clipboard.writeText(code.textContent).then(() => {
|
||||||
|
const orig = btn.textContent;
|
||||||
|
btn.textContent = 'Skopiowano!';
|
||||||
|
setTimeout(() => { btn.textContent = orig; }, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAction(idx, status) {
|
||||||
|
const card = document.getElementById('ai-action-' + idx);
|
||||||
|
if (!card) return;
|
||||||
|
if (status === 'implemented') card.classList.add('implemented');
|
||||||
|
else if (status === 'dismissed') card.classList.add('dismissed');
|
||||||
|
const actions = window._aiActions || [];
|
||||||
|
if (actions[idx] && actions[idx].id) {
|
||||||
|
fetch('/api/audit/actions/' + actions[idx].id + '/status', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrfToken},
|
||||||
|
body: JSON.stringify({status: status})
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user