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 = "
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
|
+ Freundliche Grüße |
+
+
| + | |
| + + | ++ |
|
+ RoboPlanet GmbH |
+|
+
| + + | +
+
|
+ Sitz der Gesellschaft München | Geschäftsführung: Axel Banoth |
+
+
| + + | +