diff --git a/blueprints/public/__init__.py b/blueprints/public/__init__.py index 2874a0d..b8c876c 100644 --- a/blueprints/public/__init__.py +++ b/blueprints/public/__init__.py @@ -14,3 +14,4 @@ from . import routes_zopk # noqa: E402, F401 from . import routes_pej # noqa: E402, F401 from . import routes_announcements # noqa: E402, F401 from . import routes_company_edit # noqa: E402, F401 +from . import routes_rss # noqa: E402, F401 diff --git a/blueprints/public/routes_rss.py b/blueprints/public/routes_rss.py new file mode 100644 index 0000000..258bd2a --- /dev/null +++ b/blueprints/public/routes_rss.py @@ -0,0 +1,166 @@ +""" +RSS Feed Routes +=============== + +Public RSS feeds for KIG (Krajowa Izba Gospodarcza) integration. +Format: RSS 2.0 with custom KIG fields (thumbnail, datawydarzenia, dc:creator, content). + +Feeds: +- /feed/events.xml — upcoming Norda events +- /feed/news.xml — published announcements +- /feed/pej.xml — approved PEJ/nuclear news +""" + +import logging +from datetime import date, datetime +from xml.sax.saxutils import escape + +from flask import Response, request + +from . import bp +from database import SessionLocal, NordaEvent, Announcement, ZOPKNews + +logger = logging.getLogger(__name__) + +SITE_URL = 'https://nordabiznes.pl' +ORG_NAME = 'Izba Gospodarcza Norda Biznes' + + +def _rss_date(dt): + """Format datetime to RFC 822 for RSS pubDate.""" + if isinstance(dt, date) and not isinstance(dt, datetime): + dt = datetime(dt.year, dt.month, dt.day) + if dt is None: + return '' + return dt.strftime('%a, %d %b %Y %H:%M:%S +0100') + + +def _kig_date(dt): + """Format date to DD/MM/YYYY for KIG field.""" + if dt is None: + return '' + return dt.strftime('%d/%m/%Y') + + +def _build_rss(title, description, link, items): + """Build RSS 2.0 XML string with KIG custom fields.""" + xml_parts = [ + '', + '', + '', + f'{escape(title)}', + f'{escape(link)}', + f'{escape(description)}', + f'pl', + f'{_rss_date(datetime.now())}', + ] + + for item in items: + xml_parts.append('') + if item.get('thumbnail'): + xml_parts.append(f'{escape(item["thumbnail"])}') + xml_parts.append(f'{escape(item["title"])}') + xml_parts.append(f'{escape(item["link"])}') + xml_parts.append(f'{escape(item.get("creator", ORG_NAME))}') + xml_parts.append(f'{escape(item.get("content", ""))}') + if item.get('datawydarzenia'): + xml_parts.append(f'{escape(item["datawydarzenia"])}') + xml_parts.append(f'{_rss_date(item.get("pub_date"))}') + xml_parts.append(f'{escape(item["link"])}') + xml_parts.append('') + + xml_parts.append('') + xml_parts.append('') + + return '\n'.join(xml_parts) + + +@bp.route('/feed/events.xml') +def feed_events(): + """RSS feed of upcoming Norda events for KIG aggregation.""" + with SessionLocal() as db: + events = db.query(NordaEvent).filter( + NordaEvent.event_date >= date.today(), + NordaEvent.access_level.in_(['public', 'members_only']), + ).order_by(NordaEvent.event_date.asc()).limit(20).all() + + items = [] + for e in events: + items.append({ + 'title': e.title, + 'link': f'{SITE_URL}/kalendarz/{e.id}', + 'thumbnail': e.image_url or '', + 'creator': e.organizer_name or ORG_NAME, + 'content': e.description or '', + 'datawydarzenia': _kig_date(e.event_date), + 'pub_date': e.created_at or e.event_date, + }) + + xml = _build_rss( + title=ORG_NAME, + description='Nadchodzące wydarzenia Izby Gospodarczej Norda Biznes', + link=f'{SITE_URL}/kalendarz/', + items=items, + ) + return Response(xml, mimetype='application/rss+xml; charset=utf-8') + + +@bp.route('/feed/news.xml') +def feed_news(): + """RSS feed of published announcements for KIG aggregation.""" + with SessionLocal() as db: + announcements = db.query(Announcement).filter( + Announcement.status == 'published', + ).order_by(Announcement.published_at.desc()).limit(20).all() + + now = datetime.now() + items = [] + for a in announcements: + if a.expires_at and a.expires_at < now: + continue + items.append({ + 'title': a.title, + 'link': f'{SITE_URL}/ogloszenia/{a.slug}', + 'thumbnail': a.image_url or '', + 'creator': ORG_NAME, + 'content': a.excerpt or a.content or '', + 'datawydarzenia': '', + 'pub_date': a.published_at or a.created_at, + }) + + xml = _build_rss( + title=ORG_NAME, + description='Aktualności i ogłoszenia Izby Gospodarczej Norda Biznes', + link=f'{SITE_URL}/ogloszenia', + items=items, + ) + return Response(xml, mimetype='application/rss+xml; charset=utf-8') + + +@bp.route('/feed/pej.xml') +def feed_pej(): + """RSS feed of approved PEJ/nuclear news for KIG aggregation.""" + with SessionLocal() as db: + news = db.query(ZOPKNews).filter( + ZOPKNews.status.in_(['approved', 'auto_approved']), + ).order_by(ZOPKNews.published_at.desc()).limit(20).all() + + items = [] + for n in news: + items.append({ + 'title': n.title, + 'link': n.url or f'{SITE_URL}/pej/aktualnosci', + 'thumbnail': n.image_url or '', + 'creator': n.source_name or ORG_NAME, + 'content': n.ai_summary or n.description or '', + 'datawydarzenia': '', + 'pub_date': n.published_at or n.created_at, + }) + + xml = _build_rss( + title=f'{ORG_NAME} — Polska Elektrownia Jądrowa', + description='Aktualności o Polskiej Elektrowni Jądrowej z portalu Norda Biznes', + link=f'{SITE_URL}/pej/aktualnosci', + items=items, + ) + return Response(xml, mimetype='application/rss+xml; charset=utf-8') diff --git a/docs/PLAN_EXTERNAL_EVENTS_AND_AI_MATCHING.md b/docs/PLAN_EXTERNAL_EVENTS_AND_AI_MATCHING.md new file mode 100644 index 0000000..831c958 --- /dev/null +++ b/docs/PLAN_EXTERNAL_EVENTS_AND_AI_MATCHING.md @@ -0,0 +1,167 @@ +# Plan: Wydarzenia zewnętrzne w kalendarzu + AI matching + +**Status:** KONCEPT (do zatwierdzenia) +**Data:** 2026-03-19 +**Kontekst:** Mail od ARP (Broker Eksportowy) z wydarzeniami dla MŚP, przekazany przez biuro Nordy (Magda Klóska). Współpraca z KIG (feedy RSS). + +--- + +## Problem + +Biuro Nordy regularnie dostaje maile z wartościowymi wydarzeniami od partnerów (ARP, KIG, urzędy, inne izby). Targi, seminaria, szkolenia, webinary — treści istotne dla firm członkowskich. Dziś giną w skrzynkach mailowych. + +## Rozwiązanie + +Jeden kalendarz z wyraźnym podziałem na wydarzenia Nordy i zewnętrzne, z filtrowaniem, systemem zainteresowań i AI-dopasowaniem. + +--- + +## Faza 1: Wydarzenia zewnętrzne w kalendarzu + +### Model danych + +Rozszerzenie `NordaEvent` o 3 pola: + +| Pole | Typ | Opis | +|------|-----|------| +| `is_external` | Boolean (default False) | Czy wydarzenie pochodzi z zewnątrz | +| `external_url` | String(1000) | Link do rejestracji u organizatora | +| `external_source` | String(255) | Nazwa źródła (np. "Agencja Rozwoju Pomorza") | + +### Wyróżnienie wizualne + +**Widok siatki (grid):** +- Nowy kolor dla zewnętrznych — szary lub jasnopomarańczowy, wyraźnie "cichszy" od kolorów wydarzeń Nordy + +**Widok listy:** +- Badge "ZEWNĘTRZNE" (szary) przy tytule +- Nazwa źródła pod tytułem (np. "Źródło: Agencja Rozwoju Pomorza") +- Lekko wyciszona oprawa wizualna + +**Legenda kalendarza** — rozszerzona o nowy typ + +### Filtrowanie + +Toggle u góry strony kalendarza: "Pokaż wydarzenia zewnętrzne" (domyślnie włączony). +Jedno kliknięcie — zewnętrzne znikają, zostają tylko wydarzenia Nordy. +Ustawienie zapamiętywane w localStorage. + +### "Jestem zainteresowany" zamiast "Zapisz się" + +| Aspekt | Wydarzenia Nordy | Wydarzenia zewnętrzne | +|--------|-----------------|----------------------| +| Przycisk | "Zapisz się" (zielony) | "Jestem zainteresowany/a" (szary/niebieski) | +| Znaczenie | Deklaracja udziału | Wyrażenie zainteresowania, bez zobowiązań | +| Lista osób | "Uczestnicy (12)" | "Zainteresowani (3)" | +| Limit miejsc | Tak (max_attendees) | Nie | +| Rejestracja | Na portalu | Link zewnętrzny | +| Wartość | Wiem kto idze | Widzę kto się interesuje — mogę się dogadać | + +Technicznie: ta sama tabela `event_attendees`. Dla zewnętrznych eventów `status='confirmed'` oznacza "zainteresowany". Rozróżnienie na poziomie UI. + +### Strona szczegółów wydarzenia zewnętrznego + +Podobna do obecnej, z kluczowymi różnicami: + +1. **Banner "Wydarzenie zewnętrzne"** z nazwą źródła/organizatora +2. **Lokalizacja z linkiem do Google Maps** — obecny kalendarz już to robi automatycznie (auto-link jeśli adres nie jest "Online" ani "do ustalenia"). Dla zewnętrznych wydarzeń to szczególnie ważne — użytkownik od razu widzi jak daleko to jest. +3. **Prominentny przycisk "Przejdź do rejestracji →"** — link do strony organizatora, wyróżniony wizualnie (duży, kolorowy), widoczny bez scrollowania +4. **Dane kontaktowe organizatora** — email, telefon (jeśli dostępne) — wyświetlane wprost na stronie wydarzenia, bez konieczności przechodzenia na zewnętrzną stronę +5. **Koszt uczestnictwa** — jeśli znany (np. "bezpłatne", "35 000 PLN brutto z dofinansowaniem 85%") +6. **Sekcja "Zainteresowani"** zamiast "Uczestnicy" +7. **Eksport ICS** — opcjonalny (data i miejsce są znane) + +### Automatyczne wyciąganie danych ze stron zewnętrznych + +**Zweryfikowano (2026-03-19):** Strony brokereksportowy.pl pozwalają na automatyczną ekstrakcję: +- Tytuł, data, godzina +- Pełny adres (np. "Olivia Centre, Al. Grunwaldzka 472D, 80-309 Gdańsk") +- Sposób rejestracji (formularz, email, telefon, deadline) +- Koszt / dofinansowanie +- Organizator + +**Możliwy workflow admina:** +1. Admin wkleja URL zewnętrznego wydarzenia +2. System (AI) automatycznie wyciąga: tytuł, datę, lokalizację, opis, dane rejestracji, koszt +3. Admin weryfikuje i zatwierdza — ewentualnie poprawia +4. Oszczędność: z 5-10 minut ręcznego przepisywania → 30 sekund + +### Formularz admina + +Rozszerzenie istniejącego formularza o: +- Checkbox "Wydarzenie zewnętrzne" — po zaznaczeniu: + - Pole "URL źródła" (link do strony wydarzenia) + przycisk "Pobierz dane" (AI) + - Pole "Link do rejestracji" (może być inny niż URL źródła) + - Pole "Źródło / Organizator zewnętrzny" + - Pole "Koszt uczestnictwa" (opcjonalne, tekst) + - Pole "Kontakt do organizatora" (email, telefon) + - Ukryty limit miejsc (nieistotny dla zewnętrznych) + +--- + +## Faza 2: AI matching wydarzeń do profili firm + +### Dane do matchingu (już w systemie) + +| Dane w profilu firmy | Użycie | +|---------------------|--------| +| Kategoria (IT, Construction, Services...) | Główny filtr branżowy | +| Opis firmy | Szczegółowe dopasowanie tematyczne | +| Usługi | Dopasowanie do typu wydarzenia | +| Słowa kluczowe | Precyzyjny matching | +| Zainteresowania PEJ (widoczność ZOPK) | Dopasowanie do wydarzeń obronnych/energetycznych | + +### Mechanizm + +1. **Przy dodaniu wydarzenia** — Gemini generuje "profil wydarzenia": branże, słowa kluczowe, typ firmy docelowej +2. **Matching** — porównanie profilu wydarzenia z profilami firm (kategoria + opis + usługi) +3. **Wynik** — lista par (wydarzenie → firma, score) przechowywana w bazie +4. **Koszt** — jedno wywołanie Gemini per wydarzenie (nie per logowanie) + +### Gdzie pokazywać sugestie + +| Miejsce | Co widać | +|---------|---------| +| Kalendarz — sekcja "Sugerowane dla Ciebie" u góry | 2-3 najbardziej dopasowane wydarzenia | +| Profil firmy (widok właściciela) | "Nadchodzące wydarzenia powiązane z Twoją branżą" | +| Powiadomienie (bell icon) | "Nowe wydarzenie może Cię zainteresować" | + +### Wyjaśnienie dopasowania + +Przy każdej sugestii krótki tekst: "Pasuje do: Construction, Local Content" — użytkownik rozumie logikę. + +--- + +## Faza 3: Sugestie i powiadomienia + +- Powiadomienia przy nowych wydarzeniach dopasowanych do profilu +- "3 firmy z Twojej branży są zainteresowane tym wydarzeniem" +- Agregacja zainteresowań w profilu użytkownika/firmy + +--- + +## Powiązanie z KIG + +Dwukierunkowy przepływ: +- **Nordabiznes → KIG:** Feedy RSS z wydarzeniami Nordy (Faza osobna, patrz PLAN_KIG_RSS) +- **KIG → Nordabiznes:** Gdy KIG i inne izby uruchomią RSS, możemy automatycznie importować ich wydarzenia jako zewnętrzne + +--- + +## Kolejność wdrożenia + +1. **Faza 1** — wydarzenia zewnętrzne + filtr + "jestem zainteresowany" + auto-ekstrakcja z URL +2. **Faza 2** — AI matching do profili firm +3. **Faza 3** — powiadomienia i sugestie w UI + +Każda faza jest niezależna i daje wartość sama w sobie. + +--- + +## Przykładowe źródła wydarzeń zewnętrznych + +- Agencja Rozwoju Pomorza (brokereksportowy.pl) +- Krajowa Izba Gospodarcza (kig.pl) +- Urząd Miasta Wejherowo +- Inne izby gospodarcze (via RSS w przyszłości) +- PARP, NCBiR, inne agencje rządowe diff --git a/templates/base.html b/templates/base.html index b944358..c215ecf 100755 --- a/templates/base.html +++ b/templates/base.html @@ -26,6 +26,11 @@ + + + + +