diff --git a/blueprints/api/routes_company.py b/blueprints/api/routes_company.py index 0877189..b73bab8 100644 --- a/blueprints/api/routes_company.py +++ b/blueprints/api/routes_company.py @@ -18,8 +18,9 @@ from flask import jsonify, request, current_app from flask_login import current_user, login_required from database import ( - SessionLocal, Company, User, Person, CompanyPerson, CompanyAIInsights + SessionLocal, Company, User, Person, CompanyPerson, CompanyAIInsights, AiEnrichmentProposal ) +from datetime import timedelta import gemini_service import krs_api_service from . import bp @@ -668,43 +669,40 @@ WAZNE: 'error': 'Blad parsowania odpowiedzi AI. Sprobuj ponownie.' }), 500 - # Save or update AI insights - existing_insights = db.query(CompanyAIInsights).filter_by(company_id=company.id).first() + # Create AI enrichment PROPOSAL (requires approval before applying) + # Instead of directly saving, we create a proposal that needs to be reviewed - if existing_insights: - # Update existing - existing_insights.business_summary = ai_data.get('business_summary') - existing_insights.services_list = ai_data.get('services_list', []) - existing_insights.target_market = ai_data.get('target_market') - existing_insights.unique_selling_points = ai_data.get('unique_selling_points', []) - existing_insights.company_values = ai_data.get('company_values', []) - existing_insights.certifications = ai_data.get('certifications', []) - existing_insights.industry_tags = ai_data.get('industry_tags', []) - existing_insights.suggested_category = ai_data.get('suggested_category') - existing_insights.category_confidence = ai_data.get('category_confidence') - existing_insights.ai_confidence_score = 0.85 # Default confidence - existing_insights.processing_time_ms = processing_time - existing_insights.analyzed_at = datetime.utcnow() + # Check for existing pending proposals + existing_pending = db.query(AiEnrichmentProposal).filter_by( + company_id=company.id, + status='pending' + ).first() + + if existing_pending: + # Update existing pending proposal + existing_pending.proposed_data = ai_data + existing_pending.data_source = company.website + existing_pending.confidence_score = 0.85 + existing_pending.ai_explanation = f"AI przeanalizowało dane z {len(sources_used)} źródeł: {', '.join(sources_used)}" + existing_pending.created_at = datetime.utcnow() + existing_pending.expires_at = datetime.utcnow() + timedelta(days=30) + proposal = existing_pending else: - # Create new - new_insights = CompanyAIInsights( + # Create new proposal + proposal = AiEnrichmentProposal( company_id=company.id, - business_summary=ai_data.get('business_summary'), - services_list=ai_data.get('services_list', []), - target_market=ai_data.get('target_market'), - unique_selling_points=ai_data.get('unique_selling_points', []), - company_values=ai_data.get('company_values', []), - certifications=ai_data.get('certifications', []), - industry_tags=ai_data.get('industry_tags', []), - suggested_category=ai_data.get('suggested_category'), - category_confidence=ai_data.get('category_confidence'), - ai_confidence_score=0.85, - processing_time_ms=processing_time, - analyzed_at=datetime.utcnow() + status='pending', + proposal_type='ai_enrichment', + data_source=company.website, + proposed_data=ai_data, + ai_explanation=f"AI przeanalizowało dane z {len(sources_used)} źródeł: {', '.join(sources_used)}", + confidence_score=0.85, + expires_at=datetime.utcnow() + timedelta(days=30) ) - db.add(new_insights) + db.add(proposal) db.commit() + proposal_id = proposal.id # Count sources used sources_used = ['database'] @@ -713,16 +711,19 @@ WAZNE: if website_content: sources_used.append('website') - logger.info(f"AI enrichment completed for {company.name}. Processing time: {processing_time}ms. Sources: {sources_used}") + logger.info(f"AI enrichment proposal created for {company.name}. Proposal ID: {proposal_id}. Sources: {sources_used}") return jsonify({ 'success': True, - 'message': f'Dane firmy "{company.name}" zostaly wzbogacone przez AI', + 'message': f'Propozycja wzbogacenia danych dla "{company.name}" została utworzona i oczekuje na akceptację', + 'proposal_id': proposal_id, + 'status': 'pending', 'processing_time_ms': processing_time, 'sources_used': sources_used, 'brave_results_count': len(brave_results['news']) + len(brave_results['web']), 'website_content_length': len(website_content), - 'insights': ai_data + 'proposed_data': ai_data, + 'requires_approval': True }) except Exception as e: @@ -736,6 +737,213 @@ WAZNE: db.close() +# ============================================================ +# AI ENRICHMENT PROPOSALS API ROUTES +# ============================================================ + +@bp.route('/company//proposals', methods=['GET']) +@login_required +def api_get_proposals(company_id): + """ + API: Get AI enrichment proposals for a company. + + Returns pending, approved, and rejected proposals. + """ + db = SessionLocal() + try: + company = db.query(Company).filter_by(id=company_id).first() + if not company: + return jsonify({'success': False, 'error': 'Firma nie istnieje'}), 404 + + # Check permissions + if not current_user.is_admin and current_user.company_id != company.id: + return jsonify({'success': False, 'error': 'Brak uprawnien'}), 403 + + proposals = db.query(AiEnrichmentProposal).filter_by( + company_id=company_id + ).order_by(AiEnrichmentProposal.created_at.desc()).all() + + return jsonify({ + 'success': True, + 'proposals': [{ + 'id': p.id, + 'status': p.status, + 'proposal_type': p.proposal_type, + 'proposed_data': p.proposed_data, + 'ai_explanation': p.ai_explanation, + 'confidence_score': float(p.confidence_score) if p.confidence_score else None, + 'created_at': p.created_at.isoformat() if p.created_at else None, + 'reviewed_at': p.reviewed_at.isoformat() if p.reviewed_at else None, + 'reviewed_by': p.reviewed_by.email if p.reviewed_by else None, + 'review_comment': p.review_comment, + 'approved_fields': p.approved_fields + } for p in proposals] + }) + finally: + db.close() + + +@bp.route('/company//proposals//approve', methods=['POST']) +@login_required +def api_approve_proposal(company_id, proposal_id): + """ + API: Approve an AI enrichment proposal. + + Optionally accepts 'fields' parameter to approve only specific fields. + When approved, the data is applied to CompanyAIInsights. + """ + db = SessionLocal() + try: + company = db.query(Company).filter_by(id=company_id).first() + if not company: + return jsonify({'success': False, 'error': 'Firma nie istnieje'}), 404 + + # Check permissions - only admin or company owner + if not current_user.is_admin and current_user.company_id != company.id: + return jsonify({'success': False, 'error': 'Brak uprawnien'}), 403 + + proposal = db.query(AiEnrichmentProposal).filter_by( + id=proposal_id, + company_id=company_id + ).first() + + if not proposal: + return jsonify({'success': False, 'error': 'Propozycja nie istnieje'}), 404 + + if proposal.status != 'pending': + return jsonify({'success': False, 'error': f'Propozycja ma status: {proposal.status}'}), 400 + + data = request.get_json() or {} + approved_fields = data.get('fields') # Optional: only approve specific fields + comment = data.get('comment', '') + + ai_data = proposal.proposed_data + + # Apply to CompanyAIInsights + existing_insights = db.query(CompanyAIInsights).filter_by(company_id=company.id).first() + + # Determine which fields to apply + if approved_fields: + # Partial approval + fields_to_apply = approved_fields + else: + # Full approval - all fields + fields_to_apply = list(ai_data.keys()) + + if existing_insights: + # Update existing + if 'business_summary' in fields_to_apply: + existing_insights.business_summary = ai_data.get('business_summary') + if 'services_list' in fields_to_apply: + existing_insights.services_list = ai_data.get('services_list', []) + if 'target_market' in fields_to_apply: + existing_insights.target_market = ai_data.get('target_market') + if 'unique_selling_points' in fields_to_apply: + existing_insights.unique_selling_points = ai_data.get('unique_selling_points', []) + if 'company_values' in fields_to_apply: + existing_insights.company_values = ai_data.get('company_values', []) + if 'certifications' in fields_to_apply: + existing_insights.certifications = ai_data.get('certifications', []) + if 'industry_tags' in fields_to_apply: + existing_insights.industry_tags = ai_data.get('industry_tags', []) + if 'suggested_category' in fields_to_apply: + existing_insights.suggested_category = ai_data.get('suggested_category') + existing_insights.ai_confidence_score = proposal.confidence_score + existing_insights.analyzed_at = datetime.utcnow() + else: + # Create new + new_insights = CompanyAIInsights( + company_id=company.id, + business_summary=ai_data.get('business_summary') if 'business_summary' in fields_to_apply else None, + services_list=ai_data.get('services_list', []) if 'services_list' in fields_to_apply else [], + target_market=ai_data.get('target_market') if 'target_market' in fields_to_apply else None, + unique_selling_points=ai_data.get('unique_selling_points', []) if 'unique_selling_points' in fields_to_apply else [], + company_values=ai_data.get('company_values', []) if 'company_values' in fields_to_apply else [], + certifications=ai_data.get('certifications', []) if 'certifications' in fields_to_apply else [], + industry_tags=ai_data.get('industry_tags', []) if 'industry_tags' in fields_to_apply else [], + suggested_category=ai_data.get('suggested_category') if 'suggested_category' in fields_to_apply else None, + ai_confidence_score=proposal.confidence_score, + analyzed_at=datetime.utcnow() + ) + db.add(new_insights) + + # Update proposal status + proposal.status = 'approved' + proposal.reviewed_at = datetime.utcnow() + proposal.reviewed_by_id = current_user.id + proposal.review_comment = comment + proposal.approved_fields = fields_to_apply + proposal.applied_at = datetime.utcnow() + + db.commit() + + logger.info(f"AI proposal {proposal_id} approved for company {company.name} by {current_user.email}") + + return jsonify({ + 'success': True, + 'message': f'Propozycja została zaakceptowana i dane zastosowane do profilu', + 'approved_fields': fields_to_apply + }) + except Exception as e: + db.rollback() + logger.error(f"Error approving proposal {proposal_id}: {str(e)}") + return jsonify({'success': False, 'error': str(e)}), 500 + finally: + db.close() + + +@bp.route('/company//proposals//reject', methods=['POST']) +@login_required +def api_reject_proposal(company_id, proposal_id): + """ + API: Reject an AI enrichment proposal. + """ + db = SessionLocal() + try: + company = db.query(Company).filter_by(id=company_id).first() + if not company: + return jsonify({'success': False, 'error': 'Firma nie istnieje'}), 404 + + # Check permissions + if not current_user.is_admin and current_user.company_id != company.id: + return jsonify({'success': False, 'error': 'Brak uprawnien'}), 403 + + proposal = db.query(AiEnrichmentProposal).filter_by( + id=proposal_id, + company_id=company_id + ).first() + + if not proposal: + return jsonify({'success': False, 'error': 'Propozycja nie istnieje'}), 404 + + if proposal.status != 'pending': + return jsonify({'success': False, 'error': f'Propozycja ma status: {proposal.status}'}), 400 + + data = request.get_json() or {} + comment = data.get('comment', '') + + # Update proposal status + proposal.status = 'rejected' + proposal.reviewed_at = datetime.utcnow() + proposal.reviewed_by_id = current_user.id + proposal.review_comment = comment + + db.commit() + + logger.info(f"AI proposal {proposal_id} rejected for company {company.name} by {current_user.email}") + + return jsonify({ + 'success': True, + 'message': 'Propozycja została odrzucona' + }) + except Exception as e: + db.rollback() + logger.error(f"Error rejecting proposal {proposal_id}: {str(e)}") + return jsonify({'success': False, 'error': str(e)}), 500 + finally: + db.close() + + # ============================================================ # UTILITY API ROUTES # ============================================================ diff --git a/blueprints/auth/CLAUDE.md b/blueprints/auth/CLAUDE.md index adfdcb1..1c6d874 100644 --- a/blueprints/auth/CLAUDE.md +++ b/blueprints/auth/CLAUDE.md @@ -3,5 +3,9 @@ -*No recent activity* +### Jan 31, 2026 + +| ID | Time | T | Title | Read | +|----|------|---|-------|------| +| #180 | 6:25 PM | 🔵 | Nordabiz project architecture analyzed revealing 16+ Flask blueprints with modular organization | ~831 | \ No newline at end of file diff --git a/database.py b/database.py index 12e872a..d25b088 100644 --- a/database.py +++ b/database.py @@ -764,6 +764,7 @@ class Company(Base): # Website scraping and AI analysis website_content = relationship('CompanyWebsiteContent', back_populates='company', cascade='all, delete-orphan') ai_insights = relationship('CompanyAIInsights', back_populates='company', uselist=False) + ai_enrichment_proposals = relationship('AiEnrichmentProposal', back_populates='company', cascade='all, delete-orphan') class Service(Base): @@ -1136,6 +1137,73 @@ class CompanyAIInsights(Base): company = relationship('Company', back_populates='ai_insights') +class AiEnrichmentProposal(Base): + """ + Propozycje wzbogacenia danych przez AI - wymagają akceptacji właściciela/admina. + + Workflow: + 1. AI analizuje dane ze strony WWW + 2. Tworzy propozycję (status: pending) + 3. Właściciel/admin przegląda i akceptuje lub odrzuca + 4. Po akceptacji dane są dodawane do profilu firmy + """ + __tablename__ = 'ai_enrichment_proposals' + + id = Column(Integer, primary_key=True) + company_id = Column(Integer, ForeignKey('companies.id'), nullable=False, index=True) + + # Status: pending, approved, rejected, expired + status = Column(String(20), default='pending', nullable=False, index=True) + + # Typ propozycji (website_extraction, krs_update, manual_suggestion) + proposal_type = Column(String(50), default='website_extraction', nullable=False) + + # Źródło danych (URL strony, API, itp.) + data_source = Column(String(500)) + + # Proponowane dane jako JSON + proposed_data = Column(JSONB, nullable=False) + # Struktura proposed_data: + # { + # "services": ["usługa 1", "usługa 2"], + # "products": ["produkt 1"], + # "keywords": ["keyword 1"], + # "specializations": ["spec 1"], + # "brands": ["marka 1"], + # "target_customers": ["klient 1"], + # "regions": ["region 1"], + # "summary": "Opis firmy..." + # } + + # Komentarz AI wyjaśniający propozycję + ai_explanation = Column(Text) + + # Wskaźnik pewności AI (0.0 - 1.0) + confidence_score = Column(Numeric(3, 2)) + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + reviewed_at = Column(DateTime) + applied_at = Column(DateTime) + expires_at = Column(DateTime) # Propozycje wygasają po X dniach + + # Kto przeglądał + reviewed_by_id = Column(Integer, ForeignKey('users.id')) + + # Komentarz przy akceptacji/odrzuceniu + review_comment = Column(Text) + + # Które pola zostały zaakceptowane (jeśli częściowa akceptacja) + approved_fields = Column(JSONB) # ["services", "keywords"] + + # Relationships + company = relationship('Company', back_populates='ai_enrichment_proposals') + reviewed_by = relationship('User', foreign_keys=[reviewed_by_id]) + + def __repr__(self): + return f"" + + class MaturityAssessment(Base): """Historical tracking of digital maturity scores over time""" __tablename__ = 'maturity_assessments' diff --git a/database/migrations/041_ai_enrichment_proposals.sql b/database/migrations/041_ai_enrichment_proposals.sql new file mode 100644 index 0000000..25890a5 --- /dev/null +++ b/database/migrations/041_ai_enrichment_proposals.sql @@ -0,0 +1,58 @@ +-- ============================================================ +-- 041_ai_enrichment_proposals.sql +-- System akceptacji propozycji wzbogacenia danych przez AI +-- ============================================================ + +-- Tabela propozycji wzbogacenia AI +CREATE TABLE IF NOT EXISTS ai_enrichment_proposals ( + id SERIAL PRIMARY KEY, + company_id INTEGER NOT NULL REFERENCES companies(id) ON DELETE CASCADE, + + -- Status workflow + status VARCHAR(20) NOT NULL DEFAULT 'pending', + + -- Typ propozycji + proposal_type VARCHAR(50) NOT NULL DEFAULT 'website_extraction', + + -- Źródło danych (URL strony, API, itp.) + data_source VARCHAR(500), + + -- Proponowane dane jako JSON + proposed_data JSONB NOT NULL, + + -- Komentarz AI wyjaśniający propozycję + ai_explanation TEXT, + + -- Wskaźnik pewności AI (0.00 - 1.00) + confidence_score NUMERIC(3, 2), + + -- Timestamps + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + reviewed_at TIMESTAMP, + applied_at TIMESTAMP, + expires_at TIMESTAMP, + + -- Kto przeglądał + reviewed_by_id INTEGER REFERENCES users(id), + + -- Komentarz przy akceptacji/odrzuceniu + review_comment TEXT, + + -- Które pola zostały zaakceptowane (częściowa akceptacja) + approved_fields JSONB +); + +-- Indeksy +CREATE INDEX IF NOT EXISTS idx_ai_proposals_company ON ai_enrichment_proposals(company_id); +CREATE INDEX IF NOT EXISTS idx_ai_proposals_status ON ai_enrichment_proposals(status); +CREATE INDEX IF NOT EXISTS idx_ai_proposals_created ON ai_enrichment_proposals(created_at DESC); + +-- Komentarze +COMMENT ON TABLE ai_enrichment_proposals IS 'Propozycje wzbogacenia danych przez AI wymagające akceptacji'; +COMMENT ON COLUMN ai_enrichment_proposals.status IS 'Status: pending, approved, rejected, expired'; +COMMENT ON COLUMN ai_enrichment_proposals.proposed_data IS 'JSON z proponowanymi danymi (services, keywords, summary, etc.)'; +COMMENT ON COLUMN ai_enrichment_proposals.approved_fields IS 'Lista zaakceptowanych pól przy częściowej akceptacji'; + +-- Grant permissions +GRANT ALL ON TABLE ai_enrichment_proposals TO nordabiz_app; +GRANT USAGE, SELECT ON SEQUENCE ai_enrichment_proposals_id_seq TO nordabiz_app; diff --git a/templates/company_detail.html b/templates/company_detail.html index 7108b57..3417222 100755 --- a/templates/company_detail.html +++ b/templates/company_detail.html @@ -568,15 +568,7 @@ {{ company.category.name }} {% endif %} - {% if quality_data %} - - ✓ Zweryfikowano {{ quality_data.verification_count }}x | Jakość: {{ quality_data.quality_score }}% - - {% else %} - - ⏳ Niezweryfikowana - - {% endif %} + {# Badge jakości usunięty - zbędny i mylący dla użytkowników #}

{{ company.name }}

@@ -3664,6 +3656,101 @@ function finishAiEnrichment(success) { } } +function showProposalApprovalButtons(companyId, proposalId, proposedData) { + // Update modal title + document.getElementById('aiModalTitle').textContent = 'Propozycja wymaga akceptacji'; + document.getElementById('aiCancelBtn').style.display = 'none'; + document.getElementById('aiSpinner').style.display = 'none'; + + // Create approval buttons container + const footer = document.getElementById('aiProgressFooter'); + footer.style.display = 'flex'; + footer.innerHTML = ` +
+ + +
+ `; + + // Handle approval + document.getElementById('approveProposalBtn').addEventListener('click', async () => { + const btn = document.getElementById('approveProposalBtn'); + btn.disabled = true; + btn.textContent = 'Zapisywanie...'; + + try { + const response = await fetch(`/api/company/${companyId}/proposals/${proposalId}/approve`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': '{{ csrf_token() }}' + } + }); + const data = await response.json(); + + if (data.success) { + addAiLogEntry('✓ Propozycja ZAAKCEPTOWANA - dane dodane do profilu!', 'success', '★'); + footer.innerHTML = ` +
+ ✓ Dane zostały dodane do profilu firmy +
Odśwież stronę aby zobaczyć zmiany +
+ `; + setTimeout(() => location.reload(), 2000); + } else { + addAiLogEntry('Błąd: ' + (data.error || 'Nieznany błąd'), 'error'); + btn.disabled = false; + btn.textContent = '✓ Akceptuj i dodaj do profilu'; + } + } catch (error) { + addAiLogEntry('Błąd połączenia: ' + error.message, 'error'); + btn.disabled = false; + btn.textContent = '✓ Akceptuj i dodaj do profilu'; + } + }); + + // Handle rejection + document.getElementById('rejectProposalBtn').addEventListener('click', async () => { + const btn = document.getElementById('rejectProposalBtn'); + btn.disabled = true; + btn.textContent = 'Odrzucanie...'; + + try { + const response = await fetch(`/api/company/${companyId}/proposals/${proposalId}/reject`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': '{{ csrf_token() }}' + } + }); + const data = await response.json(); + + if (data.success) { + addAiLogEntry('✕ Propozycja ODRZUCONA - dane nie zostały dodane', 'info'); + footer.innerHTML = ` +
+ ✕ Propozycja odrzucona +
Możesz spróbować ponownie później +
+ `; + setTimeout(() => closeAiModal(), 2000); + } else { + addAiLogEntry('Błąd: ' + (data.error || 'Nieznany błąd'), 'error'); + btn.disabled = false; + btn.textContent = '✕ Odrzuć propozycję'; + } + } catch (error) { + addAiLogEntry('Błąd połączenia: ' + error.message, 'error'); + btn.disabled = false; + btn.textContent = '✕ Odrzuć propozycję'; + } + }); +} + document.addEventListener('DOMContentLoaded', function() { const aiEnrichBtn = document.getElementById('aiEnrichBtn'); if (aiEnrichBtn && !aiEnrichBtn.disabled) { @@ -3723,38 +3810,37 @@ document.addEventListener('DOMContentLoaded', function() { const data = await response.json(); if (data.success) { - updateAiProgress(85, 'Zapisywanie wynikow...'); + updateAiProgress(85, 'Propozycja utworzona...'); addAiLogEntry('Parsowanie odpowiedzi JSON', 'info'); await sleep(200); - // Show what was generated - const insights = data.insights; - addAiLogEntry('Wygenerowane dane:', 'step'); + // Show what was proposed (not yet saved!) + const insights = data.proposed_data; + const proposalId = data.proposal_id; + + addAiLogEntry('AI przygotowało propozycję danych:', 'step'); if (insights.business_summary) { - addAiLogEntry(`Opis biznesowy: ${insights.business_summary.substring(0, 60)}...`, 'success'); + addAiLogEntry(`Opis biznesowy: ${insights.business_summary.substring(0, 80)}...`, 'info'); } if (insights.services_list && insights.services_list.length > 0) { - addAiLogEntry(`Uslugi: ${insights.services_list.length} pozycji`, 'success'); + addAiLogEntry(`Usługi (${insights.services_list.length}): ${insights.services_list.slice(0, 5).join(', ')}${insights.services_list.length > 5 ? '...' : ''}`, 'info'); } if (insights.unique_selling_points && insights.unique_selling_points.length > 0) { - addAiLogEntry(`Wyrozniki: ${insights.unique_selling_points.length} pozycji`, 'success'); + addAiLogEntry(`Wyróżniki (${insights.unique_selling_points.length}): ${insights.unique_selling_points.slice(0, 3).join(', ')}`, 'info'); } if (insights.industry_tags && insights.industry_tags.length > 0) { - addAiLogEntry(`Tagi branzowe: ${insights.industry_tags.join(', ')}`, 'success'); - } - if (insights.suggested_category) { - addAiLogEntry(`Sugerowana kategoria: ${insights.suggested_category}`, 'success'); + addAiLogEntry(`Tagi branżowe: ${insights.industry_tags.join(', ')}`, 'info'); } - updateAiProgress(95, 'Finalizacja...'); - addAiLogEntry('Zapisano do bazy danych', 'info'); - await sleep(300); - + updateAiProgress(100, 'Oczekuje na akceptację'); addAiLogEntry(`Czas przetwarzania: ${data.processing_time_ms}ms`, 'info'); - addAiLogEntry('Wzbogacanie zakonczone pomyslnie!', 'success', '★'); + addAiLogEntry('⚠️ PROPOZYCJA WYMAGA AKCEPTACJI', 'step'); + addAiLogEntry('Kliknij "Akceptuj" aby dodać dane do profilu lub "Odrzuć" aby porzucić', 'info'); - finishAiEnrichment(true); + // Show approval buttons + showProposalApprovalButtons(companyId, proposalId, insights); + return; // Don't close modal yet } else { addAiLogEntry('Blad: ' + (data.error || 'Nieznany blad'), 'error'); finishAiEnrichment(false);