MichaelWelsch commited on
Commit
f4915e7
·
verified ·
1 Parent(s): 016c9c4

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +133 -73
app.py CHANGED
@@ -1,3 +1,6 @@
 
 
 
1
  """
2
  Gradio UI (robust gegen kurzzeitigen Browser-Verbindungsverlust):
3
  - Startet einen Hintergrund-Job und speichert Status/Progress/Ergebnisse per job_id.
@@ -97,7 +100,6 @@ CURL_DATA_RE = re.compile(
97
  re.DOTALL
98
  )
99
  HDR_XTOKEN_RE = re.compile(r"""-H\s+(?P<q>['"])X-Token-Id:\s*(?P<val>[^'"]+)(?P=q)""", re.IGNORECASE)
100
- HDR_AUTH_RE = re.compile(r"""-H\s+(?P<q>['"])Authorization:\s*Bearer\s+(?P<val>[^'"]+)(?P=q)""", re.IGNORECASE)
101
 
102
  def parse_curl(curl_text: str) -> Tuple[str, Dict[str, Any]]:
103
  """
@@ -110,7 +112,6 @@ def parse_curl(curl_text: str) -> Tuple[str, Dict[str, Any]]:
110
  if m:
111
  token_id = m.group("val").strip()
112
 
113
- # Some users might pass token in env; if absent, try to look for it inline as 'X-Token-Id: ...'
114
  if not token_id:
115
  hdr_inline = re.search(r"X-Token-Id:\s*([a-zA-Z0-9\-\._]+)", curl_text, re.IGNORECASE)
116
  if hdr_inline:
@@ -120,7 +121,6 @@ def parse_curl(curl_text: str) -> Tuple[str, Dict[str, Any]]:
120
  if md:
121
  body_str = md.group("body").strip()
122
  else:
123
- # fallback for -d '...'
124
  md2 = re.search(r"-d\s+(?P<q>['\"])(?P<body>.*?)(?P=q)", curl_text, re.DOTALL)
125
  if md2:
126
  body_str = md2.group("body").strip()
@@ -128,16 +128,13 @@ def parse_curl(curl_text: str) -> Tuple[str, Dict[str, Any]]:
128
  if not body_str:
129
  raise ValueError("Konnte den JSON Body aus dem curl nicht finden (erwarte --data-raw '...').")
130
 
131
- # The curl example uses single quotes around valid JSON → parse directly
132
  try:
133
  payload = json.loads(body_str)
134
- except json.JSONDecodeError as e:
135
- # attempt to unescape smart single quotes or stray CRs
136
  candidate = body_str.replace("\r\n", "\n").replace("\r", "\n")
137
  payload = json.loads(candidate)
138
 
139
  if not token_id:
140
- # allow token in the payload via "X-Token-Id" or env var
141
  env_token = os.getenv("X_TOKEN_ID", "").strip()
142
  if env_token:
143
  token_id = env_token
@@ -228,9 +225,11 @@ def lead_suggest(token_id: str, filters: Dict[str, Any], icp_text: str, exclude_
228
  time.sleep(min(2.4, 0.6 * attempt))
229
 
230
  def _ci_get(d: Dict[str, Any], key: str) -> Any:
 
 
231
  if key in d and str(d[key]).strip() != "":
232
  return d[key]
233
- k = next((k for k in d.keys() if k.lower() == key.lower() and str(d[k]).strip() != ""), None)
234
  return d.get(k) if k else None
235
 
236
  def _normalize_draft_result(raw: Any) -> Dict[str, Any]:
@@ -261,7 +260,7 @@ def _normalize_draft_result(raw: Any) -> Dict[str, Any]:
261
  if isinstance(src, dict):
262
  for k in keys:
263
  v = _ci_get(src, k)
264
- if v is not None:
265
  return v
266
  return ""
267
 
@@ -318,7 +317,7 @@ def email_generate(token_id: str, variables: Dict[str, Any], items: List[Dict[st
318
  attempt += 1
319
  time.sleep(min(2.4, 0.6 * attempt))
320
 
321
- # ======== DROP-IN: JS-kompatible Wholix-Mapping & Store (1:1) ========
322
 
323
  ALLOWED_FIELDS = {
324
  "firstname",
@@ -344,7 +343,6 @@ ALLOWED_FIELDS = {
344
 
345
  def filter_wholix_contact_fields(obj: dict) -> dict:
346
  """
347
- 1:1 wie JS filterWholixContactFields:
348
  - nur erlaubte Felder
349
  - email immer getrimmt
350
  - Strings getrimmt; leere Werte raus
@@ -366,24 +364,25 @@ def filter_wholix_contact_fields(obj: dict) -> dict:
366
 
367
  def normalize_wholix_dropdown(val):
368
  """
369
- 1:1 wie JS normalizeWholixDropdown:
370
  akzeptiert {keys,values}, Array oder String
371
  → normalisiert zu {keys:[...], values:[...]} oder None
372
  """
373
  if isinstance(val, dict) and ("keys" in val or "values" in val):
374
- ks = [str(x) for x in (val.get("keys") or []) if str(x).strip()]
375
- vs = [str(x) for x in (val.get("values") or []) if str(x).strip()]
376
  if not vs and ks:
377
  vs = ks[:]
378
  return {"keys": ks, "values": vs} if (ks or vs) else None
379
  if isinstance(val, list):
380
- ks = [str(x) for x in val if str(x).strip()]
381
  return {"keys": ks, "values": ks} if ks else None
382
  if isinstance(val, str) and val.strip():
383
  s = val.strip()
384
  return {"keys": [s], "values": [s]}
385
  return None
386
 
 
 
387
  def _first_non_empty(*vals):
388
  for v in vals:
389
  if isinstance(v, str) and v.strip():
@@ -399,14 +398,73 @@ def _from_ci(d: dict, *keys, default=None):
399
  if k in d and str(d[k]).strip() != "":
400
  return d[k]
401
  for dk in d.keys():
402
- if dk.lower() == k.lower() and str(d[dk]).strip() != "":
403
  return d[dk]
404
  return default
405
 
406
  def _join_nonempty(parts, sep=" "):
407
  return sep.join([str(x).strip() for x in parts if str(x or "").strip()])
408
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
409
  def map_to_wholix_record(lead: dict, draft: dict, tag_text: str = "AI") -> dict:
 
 
 
 
 
 
 
410
  p = (lead or {}).get("person") or {}
411
  c = (lead or {}).get("company") or {}
412
  m = (lead or {}).get("messages") or {}
@@ -419,40 +477,29 @@ def map_to_wholix_record(lead: dict, draft: dict, tag_text: str = "AI") -> dict:
419
  e.name = "ValidationError"
420
  raise e
421
 
422
- # --- Departments as TEXT + we’ll mirror into tags dropdown later ---
423
- depts_raw = p.get("departments")
424
- if isinstance(depts_raw, list):
425
- departments_txt = ", ".join([str(x).strip() for x in depts_raw if str(x).strip()]) or None
426
- depts_list_for_tags = [str(x).strip() for x in depts_raw if str(x).strip()]
427
- else:
428
- departments_txt = str(depts_raw).strip() if depts_raw not in (None, "", []) else None
429
- depts_list_for_tags = [departments_txt] if departments_txt else []
430
 
431
  # --- Company URL with wide fallbacks ---
432
  company_url = _first_non_empty(
433
- c.get("url"),
434
- c.get("website"),
435
- c.get("domain"),
436
- c.get("homepage_url"),
437
- c.get("website_url"),
438
- c.get("url_normalized"),
439
- ctx.get("url"),
440
- (lead or {}).get("homepage_url"),
441
  )
442
 
443
- # --- Message fields: PULL FROM 'draft' (fix) ---
444
  draft = draft or {}
445
  draft_email = draft.get("email") if isinstance(draft, dict) else {}
446
- # tolerate shapes like {'email': {'subject','body','to'}} + followups at root
447
  msg_subject = _first_non_empty(
448
  _from_ci(draft_email, "subject", "email_subject"),
449
  _from_ci(draft, "subject", "email_subject", "Betreff"),
450
- _from_ci(m, "message_mail_subject"), # legacy
451
  )
452
  msg_body = _first_non_empty(
453
  _from_ci(draft_email, "body", "text", "content"),
454
  _from_ci(draft, "body", "Text", "content", "email_body"),
455
- _from_ci(m, "message_mail"), # legacy
456
  )
457
  followup1 = _first_non_empty(
458
  _from_ci(draft, "followup1", "FollowUp1", "LinkedIn", "linkedin", "li"),
@@ -463,30 +510,35 @@ def map_to_wholix_record(lead: dict, draft: dict, tag_text: str = "AI") -> dict:
463
  _from_ci(m, "followup2", "message_followup2"),
464
  )
465
 
466
- # --- Address with wide fallbacks ---
467
  street = _first_non_empty(
468
  _join_nonempty([c.get("street_name"), c.get("street_number")]),
469
- c.get("address"),
470
- c.get("address1"),
471
- c.get("address_line1"),
472
- c.get("street"),
473
- c.get("street_address"),
474
  )
475
-
476
- city = _first_non_empty(
477
- c.get("city"),
478
- c.get("town"),
479
- c.get("locality"),
 
 
 
 
 
480
  )
481
 
482
- postcode = _first_non_empty(
483
- c.get("zip_code"),
484
- c.get("postal_code"),
485
- c.get("postcode"),
486
- c.get("zip"),
 
 
 
487
  )
488
 
489
- # --- exclude_hash with fallbacks ---
490
  exclude_hash = _first_non_empty(
491
  lead.get("exclude_hash"),
492
  c.get("exclude_hash"),
@@ -494,11 +546,8 @@ def map_to_wholix_record(lead: dict, draft: dict, tag_text: str = "AI") -> dict:
494
  lead.get("combined_id"),
495
  )
496
 
497
- # --- Build tags dropdown: keep existing tag_text and add departments (if any) ---
498
- tag_items = [str(tag_text).strip("[]")] if str(tag_text).strip("[]") else []
499
- for d in depts_list_for_tags:
500
- if d and d not in tag_items:
501
- tag_items.append(d)
502
  tags_dropdown = {"keys": tag_items, "values": tag_items} if tag_items else None
503
 
504
  payload = {
@@ -506,19 +555,19 @@ def map_to_wholix_record(lead: dict, draft: dict, tag_text: str = "AI") -> dict:
506
  "firstname": p.get("first_name") or None,
507
  "lastname": p.get("last_name") or None,
508
  "email": email,
509
- "adress": street or None, # (sic) exact key
510
  "city": city or None,
511
  "postcode": postcode or None,
512
  "phonenumber": p.get("phone") or None,
513
- "job_title": p.get("job_title") or None,
514
- "departments": departments_txt, # text field, as before
515
- "linkedin_url": p.get("linkedin_url") or None,
516
 
517
  # Company
518
- "company_name": c.get("name") or c.get("company_name") or None,
519
  "company_url": company_url or None,
520
 
521
- # Message (now from 'draft')
522
  "message_mail": msg_body or None,
523
  "message_mail_subject": msg_subject or None,
524
  "message_followup1": followup1 or None,
@@ -534,7 +583,7 @@ def map_to_wholix_record(lead: dict, draft: dict, tag_text: str = "AI") -> dict:
534
 
535
  normalized = filter_wholix_contact_fields(payload)
536
 
537
- # Normalize dropdowns (status/tags). Departments stays text.
538
  if "status_field" in normalized:
539
  fixed = normalize_wholix_dropdown(normalized["status_field"])
540
  if fixed: normalized["status_field"] = fixed
@@ -547,10 +596,12 @@ def map_to_wholix_record(lead: dict, draft: dict, tag_text: str = "AI") -> dict:
547
 
548
  return normalized
549
 
550
-
551
  def wholix_store_contact(token: str, record: dict, module: str = "Contacts") -> dict:
552
  """
553
- JS wholixStoreContact exakt nachgebildet (mit Fallbacks)
 
 
 
554
  """
555
  email = str((record or {}).get("email") or "").strip()
556
  if not email:
@@ -623,8 +674,6 @@ def wholix_store_contact(token: str, record: dict, module: str = "Contacts") ->
623
  stripped.pop("tags", None)
624
  body3 = {"module": module, "action": "store", "data": [stripped]}
625
  return req(url, method="POST", headers=headers, json_body=body3, timeout=60)
626
- # ======== END DROP-IN =====================================================
627
-
628
 
629
  # ====================== Background-Jobs (robust UI) =======================
630
 
@@ -671,12 +720,14 @@ def _job_finish(job_id: str, error: Optional[str] = None):
671
 
672
  def run_pipeline_bg(job_id: str, curl_text: str, n_leads: int):
673
  """
674
- 1:1 wie vorherige run_pipeline(), aber ohne yield – stattdessen Status ins JOBS-Store schreiben.
 
 
 
675
  """
676
  results: List[Dict[str, Any]] = []
677
 
678
  def log(msg: str):
679
- # kleine Bequemlichkeit, auto-emit mit aktuellem Fortschritt
680
  st = JOBS.get(job_id, {})
681
  prog = st.get("progress", 0.0)
682
  _job_emit(job_id, msg=msg, progress=prog, rows=results)
@@ -700,7 +751,16 @@ def run_pipeline_bg(job_id: str, curl_text: str, n_leads: int):
700
  signature = payload.get("Signatur") or ""
701
  cta = payload.get("CTA") or ""
702
  homepage_url = payload.get("icp_homepage_url") or ""
703
- tag_text = payload.get("Wholic_tag") or payload.get("Wholix_tag") or "AI"
 
 
 
 
 
 
 
 
 
704
 
705
  total_steps = max(1, n_leads) * 4 + 2 # login + excludes + (lead + email + store)*N
706
  step = 0
@@ -756,7 +816,7 @@ def run_pipeline_bg(job_id: str, curl_text: str, n_leads: int):
756
  _job_emit(job_id, msg=f"❌ Email-Generate-Fehler: {e}", progress=step/total_steps, rows=results)
757
  continue
758
 
759
- # 5) Store
760
  step += 1
761
  _job_emit(job_id, msg=" → Speichere Kontakt + Nachricht in Wholix …", progress=step/total_steps, rows=results)
762
  try:
 
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
  """
5
  Gradio UI (robust gegen kurzzeitigen Browser-Verbindungsverlust):
6
  - Startet einen Hintergrund-Job und speichert Status/Progress/Ergebnisse per job_id.
 
100
  re.DOTALL
101
  )
102
  HDR_XTOKEN_RE = re.compile(r"""-H\s+(?P<q>['"])X-Token-Id:\s*(?P<val>[^'"]+)(?P=q)""", re.IGNORECASE)
 
103
 
104
  def parse_curl(curl_text: str) -> Tuple[str, Dict[str, Any]]:
105
  """
 
112
  if m:
113
  token_id = m.group("val").strip()
114
 
 
115
  if not token_id:
116
  hdr_inline = re.search(r"X-Token-Id:\s*([a-zA-Z0-9\-\._]+)", curl_text, re.IGNORECASE)
117
  if hdr_inline:
 
121
  if md:
122
  body_str = md.group("body").strip()
123
  else:
 
124
  md2 = re.search(r"-d\s+(?P<q>['\"])(?P<body>.*?)(?P=q)", curl_text, re.DOTALL)
125
  if md2:
126
  body_str = md2.group("body").strip()
 
128
  if not body_str:
129
  raise ValueError("Konnte den JSON Body aus dem curl nicht finden (erwarte --data-raw '...').")
130
 
 
131
  try:
132
  payload = json.loads(body_str)
133
+ except json.JSONDecodeError:
 
134
  candidate = body_str.replace("\r\n", "\n").replace("\r", "\n")
135
  payload = json.loads(candidate)
136
 
137
  if not token_id:
 
138
  env_token = os.getenv("X_TOKEN_ID", "").strip()
139
  if env_token:
140
  token_id = env_token
 
225
  time.sleep(min(2.4, 0.6 * attempt))
226
 
227
  def _ci_get(d: Dict[str, Any], key: str) -> Any:
228
+ if not isinstance(d, dict):
229
+ return None
230
  if key in d and str(d[key]).strip() != "":
231
  return d[key]
232
+ k = next((k for k in d.keys() if isinstance(k, str) and k.lower() == key.lower() and str(d[k]).strip() != ""), None)
233
  return d.get(k) if k else None
234
 
235
  def _normalize_draft_result(raw: Any) -> Dict[str, Any]:
 
260
  if isinstance(src, dict):
261
  for k in keys:
262
  v = _ci_get(src, k)
263
+ if v is not None and str(v).strip() != "":
264
  return v
265
  return ""
266
 
 
317
  attempt += 1
318
  time.sleep(min(2.4, 0.6 * attempt))
319
 
320
+ # ======== DROP-IN: Wholix-Mapping & Store (FIXED) ========================
321
 
322
  ALLOWED_FIELDS = {
323
  "firstname",
 
343
 
344
  def filter_wholix_contact_fields(obj: dict) -> dict:
345
  """
 
346
  - nur erlaubte Felder
347
  - email immer getrimmt
348
  - Strings getrimmt; leere Werte raus
 
364
 
365
  def normalize_wholix_dropdown(val):
366
  """
 
367
  akzeptiert {keys,values}, Array oder String
368
  → normalisiert zu {keys:[...], values:[...]} oder None
369
  """
370
  if isinstance(val, dict) and ("keys" in val or "values" in val):
371
+ ks = [str(x).strip() for x in (val.get("keys") or []) if str(x).strip()]
372
+ vs = [str(x).strip() for x in (val.get("values") or []) if str(x).strip()]
373
  if not vs and ks:
374
  vs = ks[:]
375
  return {"keys": ks, "values": vs} if (ks or vs) else None
376
  if isinstance(val, list):
377
+ ks = [str(x).strip() for x in val if str(x).strip()]
378
  return {"keys": ks, "values": ks} if ks else None
379
  if isinstance(val, str) and val.strip():
380
  s = val.strip()
381
  return {"keys": [s], "values": [s]}
382
  return None
383
 
384
+ # ---------- helpers for robust mapping ----------
385
+
386
  def _first_non_empty(*vals):
387
  for v in vals:
388
  if isinstance(v, str) and v.strip():
 
398
  if k in d and str(d[k]).strip() != "":
399
  return d[k]
400
  for dk in d.keys():
401
+ if isinstance(dk, str) and dk.lower() == k.lower() and str(d[dk]).strip() != "":
402
  return d[dk]
403
  return default
404
 
405
  def _join_nonempty(parts, sep=" "):
406
  return sep.join([str(x).strip() for x in parts if str(x or "").strip()])
407
 
408
+ def _parse_maybe_json_list(value):
409
+ """
410
+ Accept list, JSON-string list, or bracketed string → return list[str]
411
+ """
412
+ if value is None:
413
+ return []
414
+ if isinstance(value, list):
415
+ return [str(x).strip() for x in value if str(x).strip()]
416
+ s = str(value).strip()
417
+ if not s:
418
+ return []
419
+ # try JSON
420
+ if (s.startswith("[") and s.endswith("]")) or (s.startswith("(") and s.endswith(")")):
421
+ try:
422
+ arr = json.loads(s.replace("(", "[").replace(")", "]"))
423
+ if isinstance(arr, list):
424
+ return [str(x).strip() for x in arr if str(x).strip()]
425
+ except Exception:
426
+ # crude fallback
427
+ s2 = s.strip("[]()")
428
+ parts = [p.strip().strip("'").strip('"') for p in s2.split(",")]
429
+ return [p for p in parts if p]
430
+ # plain string, maybe delimited
431
+ if "," in s:
432
+ return [p.strip() for p in s.split(",") if p.strip()]
433
+ return [s]
434
+
435
+ def _normalize_tag_items(tag_text):
436
+ """
437
+ Accepts: "AI", "[AI]", "AI, Sales", '["AI","Sales"]' → returns list[str]
438
+ """
439
+ if isinstance(tag_text, list):
440
+ return [str(x).strip() for x in tag_text if str(x).strip()]
441
+ if tag_text is None:
442
+ return []
443
+ s = str(tag_text).strip()
444
+ if not s:
445
+ return []
446
+ try:
447
+ if s.startswith("[") and s.endswith("]"):
448
+ arr = json.loads(s)
449
+ if isinstance(arr, list):
450
+ return [str(x).strip() for x in arr if str(x).strip()]
451
+ except Exception:
452
+ pass
453
+ for sep in [",", "|", ";"]:
454
+ if sep in s:
455
+ return [p.strip() for p in s.split(sep) if p.strip()]
456
+ return [s.strip("[]")]
457
+
458
+ # ---------- the fixed mapper ----------
459
+
460
  def map_to_wholix_record(lead: dict, draft: dict, tag_text: str = "AI") -> dict:
461
+ """
462
+ FIXED:
463
+ - nutzt jetzt 'draft' für message_mail/subject/followups
464
+ - bereinigt departments (keine ["..."] Reste)
465
+ - breite Fallbacks für job_title / linkedin_url / adress / city / postcode / company_url / exclude_hash
466
+ - Tags: nur Benutzer-Tags, KEINE Departments mehr
467
+ """
468
  p = (lead or {}).get("person") or {}
469
  c = (lead or {}).get("company") or {}
470
  m = (lead or {}).get("messages") or {}
 
477
  e.name = "ValidationError"
478
  raise e
479
 
480
+ # --- Departments (TEXT) clean up list-like strings ---
481
+ depts_list = _parse_maybe_json_list(p.get("departments"))
482
+ departments_txt = ", ".join(depts_list) if depts_list else None
 
 
 
 
 
483
 
484
  # --- Company URL with wide fallbacks ---
485
  company_url = _first_non_empty(
486
+ c.get("url"), c.get("website"), c.get("domain"),
487
+ c.get("homepage_url"), c.get("website_url"), c.get("url_normalized"),
488
+ ctx.get("url"), (lead or {}).get("homepage_url"),
 
 
 
 
 
489
  )
490
 
491
+ # --- Message from generated draft ---
492
  draft = draft or {}
493
  draft_email = draft.get("email") if isinstance(draft, dict) else {}
 
494
  msg_subject = _first_non_empty(
495
  _from_ci(draft_email, "subject", "email_subject"),
496
  _from_ci(draft, "subject", "email_subject", "Betreff"),
497
+ _from_ci(m, "message_mail_subject"),
498
  )
499
  msg_body = _first_non_empty(
500
  _from_ci(draft_email, "body", "text", "content"),
501
  _from_ci(draft, "body", "Text", "content", "email_body"),
502
+ _from_ci(m, "message_mail"),
503
  )
504
  followup1 = _first_non_empty(
505
  _from_ci(draft, "followup1", "FollowUp1", "LinkedIn", "linkedin", "li"),
 
510
  _from_ci(m, "followup2", "message_followup2"),
511
  )
512
 
513
+ # --- Address / City / Postcode fallbacks ---
514
  street = _first_non_empty(
515
  _join_nonempty([c.get("street_name"), c.get("street_number")]),
516
+ c.get("address"), c.get("address1"), c.get("address_line1"),
517
+ c.get("street"), c.get("street_address"),
 
 
 
518
  )
519
+ city = _first_non_empty(c.get("city"), c.get("town"), c.get("locality"))
520
+ postcode = _first_non_empty(c.get("zip_code"), c.get("postal_code"), c.get("postcode"), c.get("zip"))
521
+
522
+ # --- Job title with fallbacks ---
523
+ job_title = _first_non_empty(
524
+ p.get("job_title"),
525
+ p.get("job_title_de_DE"),
526
+ p.get("title"),
527
+ p.get("position"),
528
+ _from_ci(p, "role"),
529
  )
530
 
531
+ # --- LinkedIn URL with fallbacks ---
532
+ linkedin_url = _first_non_empty(
533
+ p.get("linkedin_url"),
534
+ p.get("linkedin"),
535
+ p.get("linkedin_profile"),
536
+ p.get("linkedinUrl"),
537
+ p.get("li"),
538
+ p.get("li_url"),
539
  )
540
 
541
+ # --- exclude_hash fallbacks ---
542
  exclude_hash = _first_non_empty(
543
  lead.get("exclude_hash"),
544
  c.get("exclude_hash"),
 
546
  lead.get("combined_id"),
547
  )
548
 
549
+ # --- Tags: ONLY what user provided (no departments mirroring) ---
550
+ tag_items = _normalize_tag_items(tag_text)
 
 
 
551
  tags_dropdown = {"keys": tag_items, "values": tag_items} if tag_items else None
552
 
553
  payload = {
 
555
  "firstname": p.get("first_name") or None,
556
  "lastname": p.get("last_name") or None,
557
  "email": email,
558
+ "adress": street or None, # (sic)
559
  "city": city or None,
560
  "postcode": postcode or None,
561
  "phonenumber": p.get("phone") or None,
562
+ "job_title": job_title or None,
563
+ "departments": departments_txt,
564
+ "linkedin_url": linkedin_url or None,
565
 
566
  # Company
567
+ "company_name": _first_non_empty(c.get("name"), c.get("company_name")),
568
  "company_url": company_url or None,
569
 
570
+ # Message
571
  "message_mail": msg_body or None,
572
  "message_mail_subject": msg_subject or None,
573
  "message_followup1": followup1 or None,
 
583
 
584
  normalized = filter_wholix_contact_fields(payload)
585
 
586
+ # Normalize dropdowns
587
  if "status_field" in normalized:
588
  fixed = normalize_wholix_dropdown(normalized["status_field"])
589
  if fixed: normalized["status_field"] = fixed
 
596
 
597
  return normalized
598
 
 
599
  def wholix_store_contact(token: str, record: dict, module: str = "Contacts") -> dict:
600
  """
601
+ Wholix-Store mit Dropdown-Fallbacks:
602
+ 1) Normales {keys,values}
603
+ 2) Legacy {value}
604
+ 3) Ohne problematische Felder
605
  """
606
  email = str((record or {}).get("email") or "").strip()
607
  if not email:
 
674
  stripped.pop("tags", None)
675
  body3 = {"module": module, "action": "store", "data": [stripped]}
676
  return req(url, method="POST", headers=headers, json_body=body3, timeout=60)
 
 
677
 
678
  # ====================== Background-Jobs (robust UI) =======================
679
 
 
720
 
721
  def run_pipeline_bg(job_id: str, curl_text: str, n_leads: int):
722
  """
723
+ Background-Pipeline:
724
+ 1) Wholix-Login
725
+ 2) Excludes laden
726
+ 3..N) Lead holen → Nachricht generieren → in Wholix speichern
727
  """
728
  results: List[Dict[str, Any]] = []
729
 
730
  def log(msg: str):
 
731
  st = JOBS.get(job_id, {})
732
  prog = st.get("progress", 0.0)
733
  _job_emit(job_id, msg=msg, progress=prog, rows=results)
 
751
  signature = payload.get("Signatur") or ""
752
  cta = payload.get("CTA") or ""
753
  homepage_url = payload.get("icp_homepage_url") or ""
754
+ # Tags: in beliebigen Formen erlauben (AI | [AI] | "AI, Sales" | ["AI","Sales"])
755
+ raw_tag = payload.get("Wholic_tag") or payload.get("Wholix_tag") or "AI"
756
+ tag_text = raw_tag # Mapper parst das robust
757
+
758
+ # optional limit aus Payload
759
+ try:
760
+ n_leads = int(payload.get("limit", n_leads))
761
+ except Exception:
762
+ pass
763
+ n_leads = max(1, n_leads)
764
 
765
  total_steps = max(1, n_leads) * 4 + 2 # login + excludes + (lead + email + store)*N
766
  step = 0
 
816
  _job_emit(job_id, msg=f"❌ Email-Generate-Fehler: {e}", progress=step/total_steps, rows=results)
817
  continue
818
 
819
+ # 5) Store (MAPPER FIXED)
820
  step += 1
821
  _job_emit(job_id, msg=" → Speichere Kontakt + Nachricht in Wholix …", progress=step/total_steps, rows=results)
822
  try: