feat(contacts): Modal "Dodaj z AI" + widoki grupowanie/tabela

- Dodano modal "Dodaj z AI" z parsowaniem tekstu/obrazów przez Gemini
- API endpoints: /api/contacts/ai-parse, /api/contacts/bulk-create
- Nowy widok grupowania kontaktów po organizacji (domyślny)
- Widok tabeli dla kompaktowego przeglądu
- Przełącznik widoków z zapamiętywaniem preferencji
- Drag & drop dla zdjęć wizytówek
- Docker: PostgreSQL 16 (zgodność z produkcją)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-27 08:52:35 +01:00
parent 9a36d1fb08
commit ffc766d034
3 changed files with 1406 additions and 15 deletions

300
app.py
View File

@ -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)
# ============================================================

View File

@ -1,7 +1,7 @@
version: '3.8'
services:
postgres:
image: postgres:15
image: postgres:16
container_name: nordabiz-postgres
environment:
POSTGRES_DB: nordabiz

File diff suppressed because it is too large Load Diff