diff --git a/.dev_session/SESSION_INFO b/.dev_session/SESSION_INFO index 3c6e3258..a25fed5c 100644 --- a/.dev_session/SESSION_INFO +++ b/.dev_session/SESSION_INFO @@ -1 +1 @@ -{"task_id": "30388f42-8544-8088-bc48-e59e9b973e91", "token": "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", "readme_path": null, "session_start_time": "2026-03-08T14:55:15.337017"} \ No newline at end of file +{"task_id": "31988f42-8544-80fc-8bc1-dcc57acdfd4f", "token": "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", "readme_path": null, "session_start_time": "2026-03-09T06:47:23.911130"} \ No newline at end of file diff --git a/lead-engine/app.py b/lead-engine/app.py index 9a1836ca..3fdb955c 100644 --- a/lead-engine/app.py +++ b/lead-engine/app.py @@ -50,6 +50,21 @@ st.title("🚀 Lead Engine: TradingTwins") # Sidebar Actions st.sidebar.header("Actions") +if st.sidebar.button("🚀 Trigger Test-Lead (Teams)"): + try: + import requests + # The feedback server runs on port 8004 inside the same container + response = requests.get("http://localhost:8004/test_lead") + if response.status_code == 202: + st.sidebar.success("Test lead triggered successfully!") + st.toast("Check Teams for the notification.") + else: + st.sidebar.error(f"Error: {response.status_code} - {response.text}") + except Exception as e: + st.sidebar.error(f"Failed to trigger test: {e}") + +st.sidebar.divider() + if st.sidebar.button("1. Ingest Emails (Mock)"): from ingest import ingest_mock_leads init_db() @@ -246,6 +261,24 @@ if not df.empty: # Always display the draft from the database if it exists if row.get('response_draft'): st.text_area("Email Entwurf", value=row['response_draft'], height=400) + + if st.button("🧪 Test-Versand (an floke.com@gmail.com)", key=f"test_send_{row['id']}"): + try: + import requests + payload = { + "company_name": row['company_name'], + "contact_name": row['contact_name'], + "opener": row['response_draft'] + } + response = requests.post("http://localhost:8004/test_specific_lead", json=payload) + if response.status_code == 202: + st.success("Specific test lead triggered!") + st.toast("Check Teams for the notification.") + else: + st.error(f"Error: {response.status_code} - {response.text}") + except Exception as e: + st.error(f"Failed to trigger test: {e}") + st.button("📋 Copy to Clipboard", key=f"copy_{row['id']}", on_click=lambda: st.write("Copy functionality simulated")) else: st.info("Sync with Company Explorer first to generate a response.") diff --git a/lead-engine/generate_reply.py b/lead-engine/generate_reply.py index de5caffc..855b7902 100644 --- a/lead-engine/generate_reply.py +++ b/lead-engine/generate_reply.py @@ -194,14 +194,12 @@ def generate_email_draft(lead_data, company_data, booking_link="[IHR BUCHUNGSLIN 2. EINSTIEG: Nutze den inhaltlichen Kern von: "{ce_opener}". 3. DER ÜBERGANG: Verknüpfe dies mit der Anfrage zu {purpose}. Erkläre, dass manuelle Prozesse bei {qualitative_area} angesichts der Dokumentationspflichten und des Fachkräftemangels zum Risiko werden. 4. DIE LÖSUNG: Schlage die Kombination aus {solution['solution_text']} als integriertes Konzept vor, um das Team in Reinigung, Service und Patientenansprache spürbar zu entlasten. - 5. ROI: Sprich kurz die Amortisation (18-24 Monate) an – als Argument für den wirtschaftlichen Entscheider. - 6. CTA: Schlag konkret den {suggested_date} vor. Alternativ: {booking_link} + - ROI: Sprich kurz die Amortisation (18-24 Monate) an – als Argument für den wirtschaftlichen Entscheider. + 6. CTA: Schließe die E-Mail ab und leite zu den nächsten Schritten über, ohne direkt Termine vorzuschlagen oder nach Links zu fragen. STIL: Senior, lösungsorientiert, direkt. Keine unnötigen Füllwörter. FORMAT: - Betreff: [Prägnant, z.B. Automatisierungskonzept für {company_name}] - [E-Mail Text] """ @@ -214,7 +212,9 @@ def generate_email_draft(lead_data, company_data, booking_link="[IHR BUCHUNGSLIN response = requests.post(url, headers=headers, json=payload) response.raise_for_status() result = response.json() - return result['candidates'][0]['content']['parts'][0]['text'] + # Remove the placeholder from the LLM-generated text + cleaned_text = result['candidates'][0]['content']['parts'][0]['text'].replace(booking_link, '').strip() + return cleaned_text except Exception as e: return f"Error generating draft: {str(e)}" diff --git a/lead-engine/trading_twins/image001.png b/lead-engine/trading_twins/image001.png new file mode 100644 index 00000000..f54abafd Binary files /dev/null and b/lead-engine/trading_twins/image001.png differ diff --git a/lead-engine/trading_twins/image002.png b/lead-engine/trading_twins/image002.png new file mode 100644 index 00000000..eda77657 Binary files /dev/null and b/lead-engine/trading_twins/image002.png differ diff --git a/lead-engine/trading_twins/image003.png b/lead-engine/trading_twins/image003.png new file mode 100644 index 00000000..b1c2ba8c Binary files /dev/null and b/lead-engine/trading_twins/image003.png differ diff --git a/lead-engine/trading_twins/image004.png b/lead-engine/trading_twins/image004.png new file mode 100644 index 00000000..7dffde09 Binary files /dev/null and b/lead-engine/trading_twins/image004.png differ diff --git a/lead-engine/trading_twins/image005.png b/lead-engine/trading_twins/image005.png new file mode 100644 index 00000000..da9d5bc2 Binary files /dev/null and b/lead-engine/trading_twins/image005.png differ diff --git a/lead-engine/trading_twins/manager.py b/lead-engine/trading_twins/manager.py index 608ec520..541d3986 100644 --- a/lead-engine/trading_twins/manager.py +++ b/lead-engine/trading_twins/manager.py @@ -9,11 +9,31 @@ from datetime import datetime, timedelta from zoneinfo import ZoneInfo from threading import Thread, Lock import uvicorn +import logging from fastapi import FastAPI, Response, BackgroundTasks +from pydantic import BaseModel from sqlalchemy.orm import sessionmaker + +# --- Setup Logging --- +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') import msal from .models import init_db, ProposalJob, ProposedSlot +class TestLeadPayload(BaseModel): + company_name: str + contact_name: str + opener: str + +def format_date_for_email(dt: datetime) -> str: + """Formats a datetime object to 'Heute HH:MM', 'Morgen HH:MM', or 'DD.MM. HH:MM'.""" + now = datetime.now(TZ_BERLIN).date() + if dt.date() == now: + return dt.strftime("Heute %H:%M Uhr") + elif dt.date() == (now + timedelta(days=1)): + return dt.strftime("Morgen %H:%M Uhr") + else: + return dt.strftime("%d.%m. %H:%M Uhr") + # --- Setup --- TZ_BERLIN = ZoneInfo("Europe/Berlin") DB_FILE_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "trading_twins.db") @@ -48,32 +68,31 @@ def get_access_token(client_id, client_secret, tenant_id): return result.get('access_token') def get_availability(target_email, app_creds): - print(f"DEBUG: Requesting availability for {target_email}") + logging.info(f"Requesting availability for {target_email}") token = get_access_token(*app_creds) if not token: - print("DEBUG: Failed to acquire access token.") + logging.error("Failed to acquire access token for calendar.") return None headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json", "Prefer": 'outlook.timezone="Europe/Berlin"'} start_time = datetime.now(TZ_BERLIN).replace(hour=0, minute=0, second=0, microsecond=0) end_time = start_time + timedelta(days=3) # Use 15-minute intervals for finer granularity - payload = {"schedules": [target_email], "startTime": {"dateTime": start_time.isoformat()}, "endTime": {"dateTime": end_time.isoformat()}, "availabilityViewInterval": 15} + payload = {"schedules": [target_email], "startTime": {"dateTime": start_time.isoformat(), "timeZone": str(TZ_BERLIN)}, "endTime": {"dateTime": end_time.isoformat(), "timeZone": str(TZ_BERLIN)}, "availabilityViewInterval": 15} try: url = f"{GRAPH_API_ENDPOINT}/users/{target_email}/calendar/getSchedule" r = requests.post(url, headers=headers, json=payload) - print(f"DEBUG: API Status Code: {r.status_code}") + logging.info(f"Graph API getSchedule status code: {r.status_code}") if r.status_code == 200: view = r.json()['value'][0].get('availabilityView', '') - print(f"DEBUG: Availability View received (Length: {len(view)})") + logging.info(f"Availability View received (Length: {len(view)})") return start_time, view, 15 else: - print(f"DEBUG: API Error Response: {r.text}") + logging.error(f"Graph API Error Response: {r.text}") except Exception as e: - print(f"DEBUG: Exception during API call: {e}") - pass + logging.error(f"Exception during Graph API call: {e}") return None def find_slots(start, view, interval): @@ -135,6 +154,22 @@ def trigger_test_lead(background_tasks: BackgroundTasks): background_tasks.add_task(process_lead, req_id, "Testfirma GmbH", "Wir haben Ihre Anfrage erhalten.", TEST_RECEIVER_EMAIL, "Max Mustermann") return {"status": "Test lead triggered", "id": req_id} +@app.post("/test_specific_lead", status_code=202) +def trigger_specific_test_lead(payload: TestLeadPayload, background_tasks: BackgroundTasks): + """Triggers a lead process with specific data but sends email to the TEST_RECEIVER_EMAIL.""" + req_id = f"test_specific_{int(time.time())}" + # Key difference: Use data from payload, but force the receiver email + background_tasks.add_task( + process_lead, + request_id=req_id, + company=payload.company_name, + opener=payload.opener, + receiver=TEST_RECEIVER_EMAIL, # <--- FORCED TEST EMAIL + name=payload.contact_name + ) + return {"status": "Specific test lead triggered", "id": req_id} + + @app.get("/stop/{job_uuid}") def stop(job_uuid: str): db = SessionLocal(); job = db.query(ProposalJob).filter(ProposalJob.job_uuid == job_uuid).first() @@ -157,80 +192,144 @@ def book_slot(job_uuid: str, ts: int): db.close(); return Response("Fehler bei Kalender.", 500) # --- Workflow Logic --- -def send_email(subject, body, to_email, signature, banner_path=None): +def send_email(subject, body, to_email): + """ + Sends an email using Microsoft Graph API, attaching a dynamically generated + HTML signature with multiple inline images. + """ + logging.info(f"Preparing to send email to {to_email} with subject: '{subject}'") + + # 1. Read the signature file + try: + with open(SIGNATURE_FILE_PATH, 'r', encoding='utf-8') as f: + signature_html = f.read() + except Exception as e: + logging.error(f"Could not read signature file: {e}") + signature_html = "" # Fallback to no signature + + # 2. Find and prepare all signature images as attachments attachments = [] - if banner_path and os.path.exists(banner_path): - with open(banner_path, "rb") as f: - content_bytes = f.read() - content_b64 = base64.b64encode(content_bytes).decode("utf-8") - attachments.append({ - "@odata.type": "#microsoft.graph.fileAttachment", - "name": "RoboPlanetBannerWebinarEinladung.png", - "contentBytes": content_b64, - "isInline": True, - "contentId": "banner_image" - }) + image_dir = os.path.dirname(SIGNATURE_FILE_PATH) + image_files = [f for f in os.listdir(image_dir) if f.startswith('image') and f.endswith('.png')] + + for filename in image_files: + try: + with open(os.path.join(image_dir, filename), "rb") as f: + content_bytes = f.read() + content_b64 = base64.b64encode(content_bytes).decode("utf-8") + + attachments.append({ + "@odata.type": "#microsoft.graph.fileAttachment", + "name": filename, + "contentBytes": content_b64, + "isInline": True, + "contentId": filename + }) + except Exception as e: + logging.error(f"Could not process image {filename}: {e}") + + # 3. Get access token catchall = os.getenv("EMAIL_CATCHALL"); to_email = catchall if catchall else to_email token = get_access_token(AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID) - if not token: return + if not token: + logging.error("Failed to get access token for sending email.") + return + + # 4. Construct and send the email headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} - payload = {"message": {"subject": subject, "body": {"contentType": "HTML", "content": body + signature}, "toRecipients": [{"emailAddress": {"address": to_email}}]}, "saveToSentItems": "true"} - if attachments: payload["message"]["attachments"] = attachments - requests.post(f"{GRAPH_API_ENDPOINT}/users/{SENDER_EMAIL}/sendMail", headers=headers, json=payload) + full_body = body + signature_html + payload = { + "message": { + "subject": subject, + "body": {"contentType": "HTML", "content": full_body}, + "toRecipients": [{"emailAddress": {"address": to_email}}] + }, + "saveToSentItems": "true" + } + if attachments: + payload["message"]["attachments"] = attachments + + response = requests.post(f"{GRAPH_API_ENDPOINT}/users/{SENDER_EMAIL}/sendMail", headers=headers, json=payload) + logging.info(f"Send mail API response status: {response.status_code}") + if response.status_code not in [200, 202]: + logging.error(f"Error sending mail: {response.text}") + def process_lead(request_id, company, opener, receiver, name): + logging.info(f"--- Starting process_lead for request_id: {request_id} ---") db = SessionLocal() - job = ProposalJob(job_uuid=request_id, customer_email=receiver, customer_company=company, customer_name=name, status="pending") - db.add(job); db.commit() - - cal_data = get_availability("e.melcer@robo-planet.de", (CAL_APPID, CAL_SECRET, CAL_TENNANT_ID)) - suggestions = find_slots(*cal_data) if cal_data else [] - - # --- FALLBACK LOGIC --- - if not suggestions: - print("WARNING: No slots found via API. Creating fallback slots.") - now = datetime.now(TZ_BERLIN) - # Tomorrow 10:00 - tomorrow = (now + timedelta(days=1)).replace(hour=10, minute=0, second=0, microsecond=0) - # Day after tomorrow 14:00 - overmorrow = (now + timedelta(days=2)).replace(hour=14, minute=0, second=0, microsecond=0) - suggestions = [tomorrow, overmorrow] - # -------------------- - - for s in suggestions: db.add(ProposedSlot(job_id=job.id, start_time=s, end_time=s+timedelta(minutes=15))) - db.commit() - - send_time = datetime.now(TZ_BERLIN) + timedelta(minutes=DEFAULT_WAIT_MINUTES) - # Using the more detailed card from teams_notification.py - from .teams_notification import send_approval_card - send_approval_card(job_uuid=request_id, customer_name=company, time_string=send_time.strftime("%H:%M"), webhook_url=TEAMS_WEBHOOK_URL, api_base_url=FEEDBACK_SERVER_BASE_URL) - - send_time = datetime.now(TZ_BERLIN) + timedelta(minutes=DEFAULT_WAIT_MINUTES) - while datetime.now(TZ_BERLIN) < send_time: - db.refresh(job) - if job.status in ["cancelled", "send_now"]: break - time.sleep(5) - - if job.status == "cancelled": db.close(); return - - booking_html = "" - try: - with open(SIGNATURE_FILE_PATH, 'r') as f: sig = f.read() - except: sig = "" + job = ProposalJob(job_uuid=request_id, customer_email=receiver, customer_company=company, customer_name=name, status="pending") + db.add(job) + db.commit() + logging.info(f"Job {request_id} created and saved to DB.") + + cal_data = get_availability("e.melcer@robo-planet.de", (CAL_APPID, CAL_SECRET, CAL_TENNANT_ID)) + suggestions = find_slots(*cal_data) if cal_data else [] + + if not suggestions: + logging.warning(f"No slots found via API for job {request_id}. Creating fallback slots.") + now = datetime.now(TZ_BERLIN) + tomorrow = (now + timedelta(days=1)).replace(hour=10, minute=0, second=0, microsecond=0) + overmorrow = (now + timedelta(days=2)).replace(hour=14, minute=0, second=0, microsecond=0) + suggestions = [tomorrow, overmorrow] + + logging.info(f"Found/created {len(suggestions)} slot suggestions for job {request_id}.") + for s in suggestions: + db.add(ProposedSlot(job_id=job.id, start_time=s, end_time=s + timedelta(minutes=15))) + db.commit() + + send_time = datetime.now(TZ_BERLIN) + timedelta(minutes=DEFAULT_WAIT_MINUTES) + logging.info(f"Sending Teams approval card for job {request_id}.") + from .teams_notification import send_approval_card + send_approval_card(job_uuid=request_id, customer_name=company, time_string=send_time.strftime("%H:%M"), webhook_url=TEAMS_WEBHOOK_URL, api_base_url=FEEDBACK_SERVER_BASE_URL) + + logging.info(f"Waiting for response or timeout until {send_time.strftime('%H:%M:%S')} for job {request_id}") + wait_until = datetime.now(TZ_BERLIN) + timedelta(minutes=DEFAULT_WAIT_MINUTES) + while datetime.now(TZ_BERLIN) < wait_until: + db.refresh(job) + if job.status in ["cancelled", "send_now"]: + logging.info(f"Status for job {request_id} changed to '{job.status}'. Exiting wait loop.") + break + time.sleep(5) + + db.refresh(job) + if job.status == "cancelled": + logging.info(f"Job {request_id} was cancelled. No email will be sent.") + return + + logging.info(f"Timeout reached or 'Send Now' clicked for job {request_id}. Proceeding to send email.") + booking_html = "" + + try: + with open(SIGNATURE_FILE_PATH, 'r') as f: + sig = f.read() + except: + sig = "" + + email_body = f""" +

Hallo {name},

+{opener} +

Ich freue mich auf den Austausch und schlage Ihnen hierfür konkrete Termine vor:

+ +

Mit freundlichen Grüßen,

+""" + + send_email(f"Ihr Kontakt mit RoboPlanet - {company}", email_body, receiver) + job.status = "sent" + db.commit() + logging.info(f"--- Finished process_lead for request_id: {request_id} ---") + + except Exception as e: + logging.error(f"FATAL error in process_lead for request_id {request_id}: {e}", exc_info=True) + finally: + db.close() - # THIS IS THE CORRECTED EMAIL BODY - email_body = f""" -

Hallo {name},

-

{opener}

-

Hätten Sie an einem dieser Termine Zeit für ein kurzes Gespräch?

- {booking_html} - """ - - send_email(f"Ihr Kontakt mit RoboPlanet - {company}", email_body, receiver, sig, BANNER_FILE_PATH) - job.status = "sent"; db.commit(); db.close() if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8004) diff --git a/lead-engine/trading_twins/signature.html b/lead-engine/trading_twins/signature.html index 9bc9a3f1..066482c2 100644 --- a/lead-engine/trading_twins/signature.html +++ b/lead-engine/trading_twins/signature.html @@ -1,13 +1,77 @@ -Freundliche Grüße
-Elizabeta Melcer
-Inside Sales Managerin
-Wackler Logo
-RoboPlanet GmbH
-Schatzbogen 39, 81829 München
-T: +49 89 420490-402 | M: +49 175 8334071
-e.melcer@robo-planet.de | www.robo-planet.de
-LinkedIn Instagram Newsletteranmeldung
-Sitz der Gesellschaft München | Geschäftsführung: Axel Banoth
-Registergericht AG München, HRB 296113 | USt.-IdNr. DE400464410
-Hinweispflichten zum Datenschutz
-RoboPlanet Webinar Einladung \ No newline at end of file + + + + + + +
+

Freundliche Grüße
+
+
Elizabeta Melcer
+
Inside Sales Managerin

+
+

 

+ + + + + + + + + + + + + +
+

Wackler Logo

+
+

RoboPlanet GmbH
+
Schatzbogen 39, 81829 München
+
T: ++49 89 420490-402 +| M: ++49 175 8334071
+e.melcer@robo-planet.de
 | +www.robo-planet.de +

+
+

 

+ + + + + + +
+

LinkedIn Instagram Newsletteranmeldung

+
+

 

+ + + + + + +
+

Sitz der Gesellschaft München | Geschäftsführung: Axel Banoth
+
Registergericht AG München, HRB 296113 | USt.-IdNr. DE400464410
+Hinweispflichten
 zum + Datenschutz +

+
+

 

+ + + + + + +
+

RoboPlanetBannerWebinarEinladung.png

+
+

 

+ + + diff --git a/lead-engine/trading_twins/teams_notification.py b/lead-engine/trading_twins/teams_notification.py index 0cc1fddd..4f86beb5 100644 --- a/lead-engine/trading_twins/teams_notification.py +++ b/lead-engine/trading_twins/teams_notification.py @@ -33,7 +33,7 @@ def send_approval_card(job_uuid, customer_name, time_string, webhook_url=DEFAULT }, { "type": "TextBlock", - "text": "Wenn Du bis {time_string} Uhr NICHT reagierst, wird die generierte E-Mail automatisch ausgesendet.", + "text": f"Wenn Du bis {time_string} Uhr NICHT reagierst, wird die generierte E-Mail automatisch ausgesendet.", "isSubtle": True, "wrap": True } @@ -60,5 +60,5 @@ def send_approval_card(job_uuid, customer_name, time_string, webhook_url=DEFAULT response.raise_for_status() return True except Exception as e: - print(f"Fehler beim Senden an Teams: {e}") + logging.error(f"Fehler beim Senden an Teams: {e}") return False diff --git a/scripts/extract_signature_assets.py b/scripts/extract_signature_assets.py new file mode 100644 index 00000000..495a9e75 --- /dev/null +++ b/scripts/extract_signature_assets.py @@ -0,0 +1,85 @@ +import email +from email.message import Message +import os +import re + +# Define paths +eml_file_path = '/app/docs/FYI .eml' +output_dir = '/app/lead-engine/trading_twins/' +signature_file_path = os.path.join(output_dir, 'signature.html') + +def extract_assets(): + """ + Parses an .eml file to extract the HTML signature and its embedded images. + The images are saved to disk, and the HTML is cleaned up to use simple + Content-ID (cid) references for use with the Microsoft Graph API. + """ + if not os.path.exists(eml_file_path): + print(f"Error: EML file not found at {eml_file_path}") + return + + with open(eml_file_path, 'r', errors='ignore') as f: + msg = email.message_from_file(f) + + html_content = "" + images = {} + + for part in msg.walk(): + content_type = part.get_content_type() + content_disposition = str(part.get("Content-Disposition")) + + if content_type == 'text/html' and "attachment" not in content_disposition: + payload = part.get_payload(decode=True) + charset = part.get_content_charset() or 'Windows-1252' + try: + html_content = payload.decode(charset) + except (UnicodeDecodeError, AttributeError): + html_content = payload.decode('latin1') + + + if content_type.startswith('image/') and "attachment" not in content_disposition: + content_id = part.get('Content-ID', '').strip('<>') + filename = part.get_filename() + if filename and content_id: + images[filename] = { + "data": part.get_payload(decode=True), + "original_cid": content_id + } + + if not html_content: + print("Error: Could not find HTML part in the EML file.") + return + + # Isolate the signature part of the HTML + signature_start = html_content.find('Freundliche Gr') + if signature_start != -1: + # Step back to the start of the table containing the greeting + table_start = html_content.rfind('