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:
parent
9a36d1fb08
commit
ffc766d034
300
app.py
300
app.py
@ -14111,6 +14111,306 @@ def contact_delete(contact_id):
|
|||||||
db.close()
|
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)
|
# HONEYPOT ENDPOINTS (trap for malicious bots)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15
|
image: postgres:16
|
||||||
container_name: nordabiz-postgres
|
container_name: nordabiz-postgres
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: nordabiz
|
POSTGRES_DB: nordabiz
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user