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()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 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)
|
||||
# ============================================================
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user