diff --git a/app.py b/app.py index 2621dd6..8921053 100644 --- a/app.py +++ b/app.py @@ -14111,6 +14111,306 @@ def contact_delete(contact_id): db.close() +# ============================================================ +# AI-ASSISTED EXTERNAL CONTACT CREATION +# ============================================================ + +AI_CONTACT_PARSE_PROMPT = """Jesteś asystentem systemu NordaBiz pomagającym dodawać kontakty zewnętrzne. + +ZADANIE: +Przeanalizuj podany tekst i wyodrębnij informacje o osobach kontaktowych z zewnętrznych organizacji +(urzędy, agencje, instytucje, firmy partnerskie - osoby spoza Norda Biznes). + +DANE WEJŚCIOWE: +``` +{input_text} +``` + +TYPY ORGANIZACJI: +- government = Urząd (np. ministerstwo, urząd gminy/powiatu) +- agency = Agencja (np. ARP, PARP, agencje rozwoju) +- company = Firma (przedsiębiorstwa, spółki) +- ngo = Organizacja pozarządowa (fundacje, stowarzyszenia) +- university = Uczelnia (uniwersytety, politechniki) +- other = Inne + +INSTRUKCJE: +1. Wyodrębnij każdą osobę kontaktową z tekstu +2. Dla każdej osoby zidentyfikuj: + - imię i nazwisko (WYMAGANE) + - stanowisko/funkcja (jeśli dostępne) + - telefon (jeśli dostępny) + - email (jeśli dostępny) + - organizacja (WYMAGANE - nazwa instytucji) + - typ organizacji (government/agency/company/ngo/university/other) + - projekt/kontekst (jeśli tekst wspomina o konkretnym projekcie) + - tagi (słowa kluczowe związane z osobą/projektem) +3. Jeśli brak imienia i nazwiska - pomiń osobę +4. Jeśli brak nazwy organizacji - pomiń osobę + +ZWRÓĆ TYLKO CZYSTY JSON w dokładnie takim formacie (bez żadnego tekstu przed ani po): +{{ + "analysis": "Krótki opis znalezionych kontaktów (1-2 zdania po polsku)", + "contacts": [ + {{ + "first_name": "Imię", + "last_name": "Nazwisko", + "position": "Stanowisko lub null", + "phone": "Numer telefonu lub null", + "email": "Email lub null", + "organization_name": "Nazwa organizacji", + "organization_type": "government|agency|company|ngo|university|other", + "project_name": "Nazwa projektu lub null", + "tags": "tagi, oddzielone, przecinkami", + "warnings": [] + }} + ] +}}""" + +AI_CONTACT_IMAGE_PROMPT = """Jesteś asystentem systemu NordaBiz pomagającym dodawać kontakty zewnętrzne. + +ZADANIE: +Przeanalizuj ten obraz (screenshot) i wyodrębnij informacje o osobach kontaktowych. +Szukaj: imion i nazwisk, stanowisk, telefonów, emaili, nazw organizacji, projektów. + +TYPY ORGANIZACJI: +- government = Urząd (np. ministerstwo, urząd gminy/powiatu) +- agency = Agencja (np. ARP, PARP, agencje rozwoju) +- company = Firma (przedsiębiorstwa, spółki) +- ngo = Organizacja pozarządowa (fundacje, stowarzyszenia) +- university = Uczelnia (uniwersytety, politechniki) +- other = Inne + +INSTRUKCJE: +1. Przeczytaj cały tekst widoczny na obrazie +2. Wyodrębnij każdą osobę kontaktową +3. Dla każdej osoby zidentyfikuj: + - imię i nazwisko (WYMAGANE) + - stanowisko/funkcja + - telefon + - email + - organizacja (WYMAGANE) + - typ organizacji + - projekt/kontekst + - tagi +4. Jeśli brak imienia/nazwiska lub organizacji - pomiń osobę + +ZWRÓĆ TYLKO CZYSTY JSON w dokładnie takim formacie: +{{ + "analysis": "Krótki opis znalezionych kontaktów (1-2 zdania po polsku)", + "contacts": [ + {{ + "first_name": "Imię", + "last_name": "Nazwisko", + "position": "Stanowisko lub null", + "phone": "Numer telefonu lub null", + "email": "Email lub null", + "organization_name": "Nazwa organizacji", + "organization_type": "government|agency|company|ngo|university|other", + "project_name": "Nazwa projektu lub null", + "tags": "tagi, oddzielone, przecinkami", + "warnings": [] + }} + ] +}}""" + + +@app.route('/api/contacts/ai-parse', methods=['POST']) +@login_required +def contacts_ai_parse(): + """Parse text or image with AI to extract external contact data.""" + db = SessionLocal() + try: + # Check input type + input_type = request.form.get('input_type') or (request.get_json() or {}).get('input_type', 'text') + + if input_type == 'image': + # Handle image upload + if 'file' not in request.files: + return jsonify({'success': False, 'error': 'Brak pliku obrazu'}), 400 + + file = request.files['file'] + if file.filename == '': + return jsonify({'success': False, 'error': 'Nie wybrano pliku'}), 400 + + # Validate file type + allowed_extensions = {'png', 'jpg', 'jpeg', 'gif', 'webp'} + ext = file.filename.rsplit('.', 1)[-1].lower() if '.' in file.filename else '' + if ext not in allowed_extensions: + return jsonify({'success': False, 'error': 'Dozwolone formaty: PNG, JPG, JPEG, GIF, WEBP'}), 400 + + # Save temp file + import tempfile + with tempfile.NamedTemporaryFile(delete=False, suffix=f'.{ext}') as tmp: + file.save(tmp.name) + temp_path = tmp.name + + try: + # Get Gemini service and analyze image + service = gemini_service.get_gemini_service() + ai_response = service.analyze_image(temp_path, AI_CONTACT_IMAGE_PROMPT) + finally: + # Clean up temp file + import os + if os.path.exists(temp_path): + os.unlink(temp_path) + + else: + # Handle text input + data = request.get_json() or {} + content = data.get('content', '').strip() + + if not content: + return jsonify({'success': False, 'error': 'Brak treści do analizy'}), 400 + + # Get Gemini service and analyze text + service = gemini_service.get_gemini_service() + prompt = AI_CONTACT_PARSE_PROMPT.format(input_text=content) + ai_response = service.generate_text( + prompt=prompt, + feature='ai_contact_parse', + user_id=current_user.id, + temperature=0.3 + ) + + # Parse AI response as JSON + import re + json_match = re.search(r'\{[\s\S]*\}', ai_response) + if not json_match: + logger.error(f"AI contact response not valid JSON: {ai_response[:500]}") + return jsonify({ + 'success': False, + 'error': 'AI nie zwróciło prawidłowej odpowiedzi. Spróbuj ponownie.' + }), 500 + + try: + parsed = json.loads(json_match.group()) + except json.JSONDecodeError as e: + logger.error(f"JSON parse error: {e}, response: {ai_response[:500]}") + return jsonify({ + 'success': False, + 'error': 'Błąd parsowania odpowiedzi AI. Spróbuj ponownie.' + }), 500 + + # Check for potential duplicates + from database import ExternalContact + proposed_contacts = parsed.get('contacts', []) + + for contact in proposed_contacts: + first_name = contact.get('first_name', '').strip() + last_name = contact.get('last_name', '').strip() + org_name = contact.get('organization_name', '').strip() + + if first_name and last_name and org_name: + # Check for existing similar contact + existing = db.query(ExternalContact).filter( + ExternalContact.first_name.ilike(first_name), + ExternalContact.last_name.ilike(last_name), + ExternalContact.organization_name.ilike(f'%{org_name}%'), + ExternalContact.is_active == True + ).first() + + if existing: + contact['warnings'] = contact.get('warnings', []) + [ + f'Podobny kontakt może już istnieć: {existing.full_name} @ {existing.organization_name}' + ] + contact['potential_duplicate_id'] = existing.id + + logger.info(f"User {current_user.email} used AI to parse contacts: {len(proposed_contacts)} found") + + return jsonify({ + 'success': True, + 'ai_response': parsed.get('analysis', 'Analiza zakończona'), + 'proposed_contacts': proposed_contacts + }) + + except Exception as e: + logger.error(f"Error in AI contact parse: {e}") + return jsonify({'success': False, 'error': f'Błąd: {str(e)}'}), 500 + finally: + db.close() + + +@app.route('/api/contacts/bulk-create', methods=['POST']) +@login_required +def contacts_bulk_create(): + """Create multiple external contacts from confirmed proposals.""" + from database import ExternalContact + + db = SessionLocal() + try: + data = request.get_json() or {} + contacts_to_create = data.get('contacts', []) + + if not contacts_to_create: + return jsonify({'success': False, 'error': 'Brak kontaktów do utworzenia'}), 400 + + created = [] + failed = [] + + for contact_data in contacts_to_create: + try: + # Validate required fields + first_name = contact_data.get('first_name', '').strip() + last_name = contact_data.get('last_name', '').strip() + organization_name = contact_data.get('organization_name', '').strip() + + if not first_name or not last_name or not organization_name: + failed.append({ + 'name': f"{first_name} {last_name}", + 'error': 'Brak wymaganych danych (imię, nazwisko lub organizacja)' + }) + continue + + # Create contact + contact = ExternalContact( + first_name=first_name, + last_name=last_name, + position=contact_data.get('position', '').strip() or None, + phone=contact_data.get('phone', '').strip() or None, + email=contact_data.get('email', '').strip() or None, + organization_name=organization_name, + organization_type=contact_data.get('organization_type', 'other'), + project_name=contact_data.get('project_name', '').strip() or None, + tags=contact_data.get('tags', '').strip() or None, + source_type='ai_import', + created_by=current_user.id + ) + + db.add(contact) + db.flush() + + created.append({ + 'id': contact.id, + 'name': contact.full_name, + 'organization': contact.organization_name + }) + + except Exception as e: + failed.append({ + 'name': f"{contact_data.get('first_name', '')} {contact_data.get('last_name', '')}", + 'error': str(e) + }) + + db.commit() + + logger.info(f"User {current_user.email} bulk created {len(created)} contacts via AI") + + return jsonify({ + 'success': True, + 'created': created, + 'failed': failed, + 'message': f'Utworzono {len(created)} kontaktów' + (f', {len(failed)} błędów' if failed else '') + }) + + except Exception as e: + db.rollback() + logger.error(f"Error in contacts bulk create: {e}") + return jsonify({'success': False, 'error': f'Błąd: {str(e)}'}), 500 + finally: + db.close() + + # ============================================================ # HONEYPOT ENDPOINTS (trap for malicious bots) # ============================================================ diff --git a/docker-compose.yml b/docker-compose.yml index 7b3d557..a484e0c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ version: '3.8' services: postgres: - image: postgres:15 + image: postgres:16 container_name: nordabiz-postgres environment: POSTGRES_DB: nordabiz diff --git a/templates/contacts/list.html b/templates/contacts/list.html index 58e4db7..1c575c0 100644 --- a/templates/contacts/list.html +++ b/templates/contacts/list.html @@ -21,6 +21,22 @@ color: var(--text-primary); } + .header-actions { + display: flex; + gap: var(--spacing-sm); + flex-wrap: wrap; + } + + .btn-ai { + background: linear-gradient(135deg, #8b5cf6, #6366f1); + color: white; + border: none; + } + + .btn-ai:hover { + background: linear-gradient(135deg, #7c3aed, #4f46e5); + } + .contacts-filters { background: var(--surface); border-radius: var(--radius-lg); @@ -66,6 +82,56 @@ box-shadow: 0 0 0 3px var(--primary-bg); } + /* View toggle */ + .view-controls { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-md); + flex-wrap: wrap; + gap: var(--spacing-md); + } + + .stats-bar { + font-size: var(--font-size-sm); + color: var(--text-secondary); + } + + .view-toggle { + display: flex; + background: var(--surface); + border-radius: var(--radius); + border: 1px solid var(--border); + overflow: hidden; + } + + .view-toggle button { + padding: var(--spacing-sm) var(--spacing-md); + border: none; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: var(--font-size-sm); + display: flex; + align-items: center; + gap: var(--spacing-xs); + transition: all 0.2s ease; + } + + .view-toggle button:hover { + background: var(--surface-secondary); + } + + .view-toggle button.active { + background: var(--primary); + color: white; + } + + .view-toggle button + button { + border-left: 1px solid var(--border); + } + + /* Card view */ .contacts-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); @@ -116,6 +182,12 @@ border-radius: 50%; } + .contact-avatar.small { + width: 40px; + height: 40px; + font-size: var(--font-size-base); + } + .contact-info { flex: 1; min-width: 0; @@ -229,6 +301,214 @@ .social-link.facebook { background: #1877f2; color: white; } .social-link.twitter { background: #1da1f2; color: white; } + /* Table view */ + .contacts-table-wrapper { + background: var(--surface); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + overflow: hidden; + display: none; + } + + .contacts-table-wrapper.active { + display: block; + } + + .contacts-table { + width: 100%; + border-collapse: collapse; + } + + .contacts-table th, + .contacts-table td { + padding: var(--spacing-md); + text-align: left; + border-bottom: 1px solid var(--border); + } + + .contacts-table th { + background: var(--surface-secondary); + font-weight: 600; + font-size: var(--font-size-sm); + color: var(--text-secondary); + } + + .contacts-table tr:hover { + background: var(--surface-secondary); + } + + .contacts-table .contact-cell { + display: flex; + align-items: center; + gap: var(--spacing-sm); + } + + .contacts-table .contact-cell a { + color: var(--text-primary); + text-decoration: none; + font-weight: 500; + } + + .contacts-table .contact-cell a:hover { + color: var(--primary); + } + + /* Organization group view */ + .contacts-groups { + display: none; + } + + .contacts-groups.active { + display: block; + } + + .org-group { + background: var(--surface); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + margin-bottom: var(--spacing-lg); + overflow: hidden; + } + + .org-group-header { + padding: var(--spacing-lg); + background: var(--surface-secondary); + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + transition: background 0.2s ease; + } + + .org-group-header:hover { + background: var(--border); + } + + .org-group-info { + display: flex; + align-items: center; + gap: var(--spacing-md); + } + + .org-logo { + width: 48px; + height: 48px; + border-radius: var(--radius); + background: var(--primary); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-size: var(--font-size-lg); + font-weight: 700; + } + + .org-logo img { + width: 100%; + height: 100%; + object-fit: contain; + border-radius: var(--radius); + } + + .org-details h3 { + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--text-primary); + margin-bottom: var(--spacing-xs); + } + + .org-details .org-meta { + font-size: var(--font-size-sm); + color: var(--text-secondary); + display: flex; + align-items: center; + gap: var(--spacing-md); + } + + .org-group-toggle { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--surface); + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.3s ease; + } + + .org-group.expanded .org-group-toggle { + transform: rotate(180deg); + } + + .org-group-contacts { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease; + } + + .org-group.expanded .org-group-contacts { + max-height: 2000px; + } + + .org-contact-item { + padding: var(--spacing-md) var(--spacing-lg); + border-top: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--spacing-md); + } + + .org-contact-item:hover { + background: var(--surface-secondary); + } + + .org-contact-main { + display: flex; + align-items: center; + gap: var(--spacing-md); + flex: 1; + } + + .org-contact-details { + flex: 1; + } + + .org-contact-details .name { + font-weight: 500; + color: var(--text-primary); + } + + .org-contact-details .name a { + color: inherit; + text-decoration: none; + } + + .org-contact-details .name a:hover { + color: var(--primary); + } + + .org-contact-details .position { + font-size: var(--font-size-sm); + color: var(--text-secondary); + } + + .org-contact-actions { + display: flex; + gap: var(--spacing-sm); + font-size: var(--font-size-sm); + } + + .org-contact-actions a { + color: var(--primary); + text-decoration: none; + } + + .org-contact-actions a:hover { + text-decoration: underline; + } + + /* Empty state */ .empty-state { text-align: center; padding: var(--spacing-3xl); @@ -253,6 +533,7 @@ margin-bottom: var(--spacing-lg); } + /* Pagination */ .pagination { display: flex; justify-content: center; @@ -286,13 +567,292 @@ color: white; } - .stats-bar { + /* Modal styles */ + .modal-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; + align-items: center; + justify-content: center; + padding: var(--spacing-lg); + } + + .modal-overlay.active { + display: flex; + } + + .modal { + background: var(--surface); + border-radius: var(--radius-lg); + max-width: 700px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + box-shadow: var(--shadow-lg); + } + + .modal-header { + padding: var(--spacing-lg); + border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; - margin-bottom: var(--spacing-md); + } + + .modal-header h2 { + font-size: var(--font-size-xl); + font-weight: 600; + color: var(--text-primary); + display: flex; + align-items: center; + gap: var(--spacing-sm); + } + + .modal-close { + background: none; + border: none; + font-size: var(--font-size-2xl); + color: var(--text-secondary); + cursor: pointer; + line-height: 1; + } + + .modal-close:hover { + color: var(--text-primary); + } + + .modal-body { + padding: var(--spacing-lg); + } + + .modal-footer { + padding: var(--spacing-lg); + border-top: 1px solid var(--border); + display: flex; + justify-content: flex-end; + gap: var(--spacing-sm); + } + + /* AI form styles */ + .ai-input-section { + margin-bottom: var(--spacing-lg); + } + + .ai-input-section label { + display: block; + font-weight: 500; + margin-bottom: var(--spacing-sm); + color: var(--text-primary); + } + + .ai-input-section .help-text { font-size: var(--font-size-sm); color: var(--text-secondary); + margin-bottom: var(--spacing-sm); + } + + .ai-textarea { + width: 100%; + min-height: 150px; + padding: var(--spacing-md); + border: 1px solid var(--border); + border-radius: var(--radius); + font-family: inherit; + font-size: var(--font-size-base); + resize: vertical; + } + + .ai-textarea:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--primary-bg); + } + + .ai-divider { + display: flex; + align-items: center; + gap: var(--spacing-md); + margin: var(--spacing-lg) 0; + color: var(--text-secondary); + font-size: var(--font-size-sm); + } + + .ai-divider::before, + .ai-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border); + } + + .file-upload-area { + border: 2px dashed var(--border); + border-radius: var(--radius-lg); + padding: var(--spacing-xl); + text-align: center; + cursor: pointer; + transition: all 0.2s ease; + } + + .file-upload-area:hover { + border-color: var(--primary); + background: var(--primary-bg); + } + + .file-upload-area.dragover { + border-color: var(--primary); + background: var(--primary-bg); + } + + .file-upload-area input[type="file"] { + display: none; + } + + .file-upload-icon { + font-size: 2.5rem; + margin-bottom: var(--spacing-sm); + } + + .file-upload-text { + color: var(--text-secondary); + margin-bottom: var(--spacing-sm); + } + + .file-upload-text strong { + color: var(--primary); + } + + .file-upload-hint { + font-size: var(--font-size-xs); + color: var(--text-muted); + } + + .image-preview { + margin-top: var(--spacing-md); + display: none; + } + + .image-preview.active { + display: block; + } + + .image-preview img { + max-width: 100%; + max-height: 200px; + border-radius: var(--radius); + border: 1px solid var(--border); + } + + .image-preview .remove-image { + display: inline-block; + margin-top: var(--spacing-sm); + color: var(--danger); + cursor: pointer; + font-size: var(--font-size-sm); + } + + /* AI Results */ + .ai-results { + display: none; + } + + .ai-results.active { + display: block; + } + + .ai-analysis { + background: var(--surface-secondary); + padding: var(--spacing-md); + border-radius: var(--radius); + margin-bottom: var(--spacing-lg); + font-size: var(--font-size-sm); + color: var(--text-secondary); + } + + .ai-contact-proposal { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + margin-bottom: var(--spacing-md); + overflow: hidden; + } + + .ai-contact-proposal-header { + padding: var(--spacing-md); + background: var(--surface-secondary); + display: flex; + justify-content: space-between; + align-items: center; + } + + .ai-contact-proposal-header label { + display: flex; + align-items: center; + gap: var(--spacing-sm); + cursor: pointer; + font-weight: 500; + } + + .ai-contact-proposal-header label input { + width: 18px; + height: 18px; + } + + .ai-contact-proposal-body { + padding: var(--spacing-md); + font-size: var(--font-size-sm); + } + + .ai-contact-proposal-body .field { + display: flex; + margin-bottom: var(--spacing-xs); + } + + .ai-contact-proposal-body .field-label { + color: var(--text-secondary); + min-width: 120px; + } + + .ai-contact-proposal-body .field-value { + color: var(--text-primary); + font-weight: 500; + } + + /* Loading state */ + .loading-overlay { + display: none; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.9); + align-items: center; + justify-content: center; + flex-direction: column; + gap: var(--spacing-md); + z-index: 10; + } + + .loading-overlay.active { + display: flex; + } + + .loading-spinner { + width: 40px; + height: 40px; + border: 3px solid var(--border); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 1s linear infinite; + } + + @keyframes spin { + to { transform: rotate(360deg); } } @media (max-width: 768px) { @@ -303,6 +863,19 @@ .filter-group { min-width: 100%; } + + .view-controls { + flex-direction: column; + align-items: flex-start; + } + + .contacts-table-wrapper { + overflow-x: auto; + } + + .contacts-table { + min-width: 600px; + } } {% endblock %} @@ -311,9 +884,14 @@