Spaces:
Sleeping
Sleeping
| import streamlit as st | |
| import pandas as pd | |
| import io | |
| import os | |
| import json | |
| import sys | |
| import warnings | |
| import platform | |
| import imaplib | |
| import email | |
| import html | |
| import socket | |
| from imaplib import IMAP4_SSL | |
| from email.header import decode_header, make_header | |
| from datetime import datetime | |
| from docx import Document | |
| from docx.shared import Pt, RGBColor, Inches | |
| from docx.enum.text import WD_ALIGN_PARAGRAPH | |
| from docx.oxml.ns import qn | |
| from docx.oxml import OxmlElement | |
| import pdfplumber | |
| import vertexai | |
| import requests | |
| import copy | |
| import re | |
| import sqlite3 | |
| def get_config_value(key, default=""): | |
| value = os.environ.get(key) | |
| if value is not None: | |
| return value | |
| try: | |
| return st.secrets.get(key, default) | |
| except Exception: | |
| return default | |
| def parse_allowed_users(raw_users): | |
| if hasattr(raw_users, "items"): | |
| return {str(email).strip().lower(): str(password) for email, password in raw_users.items()} | |
| users = {} | |
| for item in str(raw_users or "").split(","): | |
| email_addr, separator, password = item.partition("=") | |
| email_addr = email_addr.strip().strip('"').strip("'").lower() | |
| if separator and email_addr: | |
| users[email_addr] = password.strip().strip('"').strip("'") | |
| return users | |
| # 1. 인증 및 Secrets 통합 로드 | |
| os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = "credentials.json" | |
| # 2. 모든 필수 변수 한 번에 선언 (변수명 일치 확인) | |
| COMMON_PW = get_config_value("EMAIL_PASSWORD") | |
| MASTER_PW = get_config_value("MASTER_PASSWORD") | |
| ADMIN_EMAIL = get_config_value("ADMIN_EMAIL").strip().lower() | |
| PROJECT_ID = get_config_value("PROJECT_ID") | |
| NOTION_TOKEN = get_config_value("NOTION_API_TOKEN") | |
| NOTION_DB_ID = get_config_value("NOTION_DATABASE_ID") | |
| # 3. 리스트/딕셔너리 변환 로직 | |
| ALL_MAILBOXES = [item.strip() for item in get_config_value("ALL_MAILBOXES").split(",") if item.strip()] | |
| allowed_users = parse_allowed_users(get_config_value("ALLOWED_USERS")) | |
| MAX_EMAILS_PER_FOLDER = 250 | |
| GEMINI_25_FLASH_INPUT_USD_PER_1M = 0.30 | |
| GEMINI_25_FLASH_OUTPUT_USD_PER_1M = 2.50 | |
| ESTIMATED_OUTPUT_TOKENS_PER_STAGE = 350 | |
| def save_rules_to_db(df): | |
| conn = sqlite3.connect("data.db") | |
| df.to_sql("compliance_rules", conn, if_exists="replace", index=False) | |
| conn.close() | |
| def load_rules_from_db(): | |
| conn = sqlite3.connect("data.db") | |
| try: | |
| df = pd.read_sql("SELECT * FROM compliance_rules", conn) | |
| except: | |
| df = pd.DataFrame() | |
| conn.close() | |
| return df | |
| # [1] System Settings & OS Detection | |
| OS_TYPE = platform.system() | |
| warnings.filterwarnings("ignore") | |
| if OS_TYPE == "Windows": | |
| try: | |
| import win32com.client | |
| except ImportError: | |
| pass | |
| st.set_page_config(page_title="Flux Finance Internal Note", layout="wide") | |
| # 🎨 UI Style | |
| st.markdown(""" | |
| <style> | |
| .stApp { background-color: #F8FAFC; } | |
| /* 사이드바 너비를 늘려 메인 영역과 50:50 비율에 가깝게 설정 */ | |
| section[data-testid="stSidebar"] { | |
| order: 2; | |
| width: 50% !important; /* 비율을 50%로 설정 */ | |
| min-width: 700px !important; /* 최소 너비 증가 */ | |
| max-width: 50% !important; /* 최대 너비도 50%로 제한 */ | |
| background-color: #FFFFFF; | |
| border-left: 2px solid #E2E8F0; | |
| } | |
| div.stButton > button { width: 100% !important; background-color: #1E3A8A !important; color: white !important; font-weight: 700; padding: 0.8rem; } | |
| .status-container { background-color: #F1F5F9; padding: 15px; border-radius: 10px; margin-bottom: 20px; border: 1px solid #E2E8F0; } | |
| .status-item { font-size: 0.82rem; margin-bottom: 5px; display: flex; align-items: center; } | |
| .dot { height: 10px; width: 10px; border-radius: 50%; display: inline-block; margin-right: 8px; } | |
| .dot-filled { background-color: #10B981; box-shadow: 0 0 8px #10B981; } | |
| .dot-orange { background-color: #F59E0B; box-shadow: 0 0 8px #F59E0B; } | |
| .dot-empty { background-color: #CBD5E1; } | |
| .unified-guide { background-color: #EFF6FF; color: #1E40AF; padding: 12px 18px; border-radius: 8px; border-left: 5px solid #3B82F6; margin-bottom: 20px; font-size: 0.85rem; } | |
| .mail-info { font-size: 0.85rem; color: #475569; margin-bottom: 8px; border-bottom: 1px dashed #CBD5E1; } | |
| .content-box { background-color: #F9FAFB; padding: 15px; border-radius: 8px; border: 1px solid #E5E7EB; font-size: 0.88rem; max-height: 400px; overflow-y: auto; white-space: pre-wrap; line-height: 1.6; } | |
| .attach-box { font-size: 0.78rem; color: #059669; background-color: #F0FDF4; padding: 6px 10px; border-radius: 6px; border: 1px solid #BBF7D0; margin-top: 10px; } | |
| .type-tag { font-size: 0.7rem; font-weight: 800; padding: 2px 6px; border-radius: 4px; margin-right: 8px; } | |
| .sent { background-color: #DBEAFE; color: #1E40AF; } | |
| .received { background-color: #FEF3C7; color: #92400E; } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # Session State Initialization | |
| if 'drafts' not in st.session_state: st.session_state['drafts'] = {} | |
| if 'emails' not in st.session_state: st.session_state['emails'] = "" | |
| if 'client_names' not in st.session_state: st.session_state['client_names'] = "" | |
| if 'support_name' not in st.session_state: st.session_state['support_name'] = "" | |
| if 'refs' not in st.session_state: st.session_state['refs'] = "" | |
| if 'folder_path' not in st.session_state: st.session_state['folder_path'] = "" | |
| if 'doc_context' not in st.session_state: st.session_state['doc_context'] = "" | |
| if 'results' not in st.session_state: st.session_state['results'] = None | |
| if 'notion_client_name' not in st.session_state: st.session_state['notion_client_name'] = "" | |
| if 'case_comments' not in st.session_state: st.session_state['case_comments'] = "" | |
| # ========================================================================= | |
| # [보안 끝판왕] 최초 로그인 비번 세팅 & 관리자 실시간 동기화/삭제 통제 시스템 | |
| # ========================================================================= | |
| PERSISTENT_DATA_DIR = os.environ.get("ADVISERNOTE_DATA_DIR", "/data") | |
| USING_PERSISTENT_STORAGE = os.path.isdir(PERSISTENT_DATA_DIR) and os.access(PERSISTENT_DATA_DIR, os.W_OK) | |
| USER_DB_FILE = os.path.join(PERSISTENT_DATA_DIR, "user_passwords.db") if USING_PERSISTENT_STORAGE else "user_passwords.db" | |
| def init_user_db(seed_users): | |
| conn = sqlite3.connect(USER_DB_FILE) | |
| conn.execute(""" | |
| CREATE TABLE IF NOT EXISTS users ( | |
| email TEXT PRIMARY KEY, | |
| password TEXT NOT NULL DEFAULT '', | |
| active INTEGER NOT NULL DEFAULT 1 | |
| ) | |
| """) | |
| conn.execute(""" | |
| CREATE TABLE IF NOT EXISTS mailboxes ( | |
| email TEXT PRIMARY KEY, | |
| entry TEXT NOT NULL, | |
| active INTEGER NOT NULL DEFAULT 1 | |
| ) | |
| """) | |
| for email_addr, password in seed_users.items(): | |
| conn.execute( | |
| "INSERT OR IGNORE INTO users (email, password, active) VALUES (?, ?, 1)", | |
| (email_addr, password) | |
| ) | |
| for mailbox_entry in ALL_MAILBOXES: | |
| mailbox_email = parse_mailbox_email(mailbox_entry) | |
| if mailbox_email: | |
| conn.execute( | |
| "INSERT OR IGNORE INTO mailboxes (email, entry, active) VALUES (?, ?, 1)", | |
| (mailbox_email, mailbox_entry) | |
| ) | |
| conn.commit() | |
| conn.close() | |
| def parse_mailbox_email(mailbox_entry): | |
| credential_part = str(mailbox_entry or "").split("|", 1)[0].strip() | |
| return credential_part.split(":", 1)[0].strip().lower() | |
| def load_active_users(): | |
| conn = sqlite3.connect(USER_DB_FILE) | |
| rows = conn.execute("SELECT email, password FROM users WHERE active = 1 ORDER BY email").fetchall() | |
| conn.close() | |
| return {email_addr: password for email_addr, password in rows} | |
| def update_user_password(email_addr, new_password): | |
| conn = sqlite3.connect(USER_DB_FILE) | |
| conn.execute( | |
| "UPDATE users SET password = ?, active = 1 WHERE email = ?", | |
| (new_password, email_addr) | |
| ) | |
| conn.commit() | |
| updated = conn.total_changes > 0 | |
| conn.close() | |
| return updated | |
| def add_or_restore_user(email_addr, initial_password=""): | |
| email_addr = email_addr.strip().lower() | |
| conn = sqlite3.connect(USER_DB_FILE) | |
| existing = conn.execute("SELECT email FROM users WHERE email = ?", (email_addr,)).fetchone() | |
| if existing: | |
| conn.execute( | |
| "UPDATE users SET password = ?, active = 1 WHERE email = ?", | |
| (initial_password, email_addr) | |
| ) | |
| else: | |
| conn.execute( | |
| "INSERT INTO users (email, password, active) VALUES (?, ?, 1)", | |
| (email_addr, initial_password) | |
| ) | |
| conn.commit() | |
| updated = conn.total_changes > 0 | |
| conn.close() | |
| return updated | |
| def revoke_user_access(email_addr): | |
| conn = sqlite3.connect(USER_DB_FILE) | |
| conn.execute("UPDATE users SET active = 0 WHERE email = ?", (email_addr,)) | |
| conn.commit() | |
| updated = conn.total_changes > 0 | |
| conn.close() | |
| return updated | |
| def load_active_mailboxes(): | |
| conn = sqlite3.connect(USER_DB_FILE) | |
| rows = conn.execute("SELECT email, entry FROM mailboxes WHERE active = 1 ORDER BY email").fetchall() | |
| conn.close() | |
| return {email_addr: entry for email_addr, entry in rows} | |
| def add_or_restore_mailbox(mailbox_entry): | |
| mailbox_entry = mailbox_entry.strip() | |
| mailbox_email = parse_mailbox_email(mailbox_entry) | |
| if not mailbox_email: | |
| return False | |
| conn = sqlite3.connect(USER_DB_FILE) | |
| existing = conn.execute("SELECT email FROM mailboxes WHERE email = ?", (mailbox_email,)).fetchone() | |
| if existing: | |
| conn.execute( | |
| "UPDATE mailboxes SET entry = ?, active = 1 WHERE email = ?", | |
| (mailbox_entry, mailbox_email) | |
| ) | |
| else: | |
| conn.execute( | |
| "INSERT INTO mailboxes (email, entry, active) VALUES (?, ?, 1)", | |
| (mailbox_email, mailbox_entry) | |
| ) | |
| conn.commit() | |
| updated = conn.total_changes > 0 | |
| conn.close() | |
| return updated | |
| def revoke_mailbox_access(mailbox_email): | |
| conn = sqlite3.connect(USER_DB_FILE) | |
| conn.execute("UPDATE mailboxes SET active = 0 WHERE email = ?", (mailbox_email,)) | |
| conn.commit() | |
| updated = conn.total_changes > 0 | |
| conn.close() | |
| return updated | |
| def get_imap_host_candidates(primary_host, mailbox_email): | |
| primary_host = str(primary_host or "").strip() | |
| return [primary_host] if primary_host else [] | |
| init_user_db(allowed_users) | |
| def check_password(): | |
| """로그인 검증, 최초 접속자 비번 세팅, 관리자 전용 대시보드 스위칭 마스터 함수""" | |
| if "password_correct" not in st.session_state: | |
| st.session_state["password_correct"] = False | |
| if "is_admin_mode" not in st.session_state: | |
| st.session_state["is_admin_mode"] = False | |
| if st.session_state["password_correct"]: | |
| return True | |
| # 🎨 [디자인 혁신] 3열 분할 레이아웃을 사용해 로그인 창을 화면 정중앙에 예쁜 카드로 배치 | |
| _, login_col, _ = st.columns([1, 1.1, 1]) | |
| with login_col: | |
| st.write("") # 상단 여백 세팅 | |
| st.write("") | |
| with st.container(border=True): | |
| st.markdown(""" | |
| <div style='text-align: center; margin-bottom: 25px;'> | |
| <span style='font-size: 2.3rem;'>🔒</span> | |
| <h2 style='color: #1E3A8A; margin: 10px 0 4px 0; font-size: 1.45rem; font-weight: 800; letter-spacing: -0.5px;'>Flux Finance</h2> | |
| <p style='color: #64748B; font-size: 0.82rem; margin: 0;'>Internal System Access Portal</p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| allowed_users = load_active_users() | |
| input_email = st.text_input("📧 Login Email Address", key="login_email", placeholder="username@fluxfinance.co.nz").strip().lower() | |
| if input_email and (input_email != ADMIN_EMAIL and input_email not in allowed_users): | |
| st.error("❌ Access Denied: This account is unregistered or has been deactivated. Please contact the administrator.") | |
| return False | |
| # Case A: 최고 관리자(매니저님) 접속 시 (기능 100% 동일) | |
| if input_email == ADMIN_EMAIL: | |
| input_password = st.text_input("👑 Admin Password", type="password", key="login_password", placeholder="Enter admin password") | |
| if st.button("Sign In as Manager", use_container_width=True): | |
| if input_password == MASTER_PW: | |
| st.session_state["password_correct"] = True | |
| st.session_state["is_admin_mode"] = True | |
| st.rerun() | |
| else: | |
| st.error("❌ Incorrect Administrator password.") | |
| return False | |
| # Case B: 일반 직원 접속 시 (기능 100% 동일) | |
| if input_email in allowed_users: # 위에서 만든 딕셔너리 사용 | |
| current_db_pwd = allowed_users[input_email] | |
| # 상황 ①: 최초 접속이라 비번이 비어있을 때 ("") -> 초기 셋업 로직 완벽 보존 | |
| if current_db_pwd == "": | |
| st.warning("🆕 Welcome! Please set up your personalized password for this account.") | |
| new_pwd = st.text_input("🔑 Create New Password", type="password", key="setup_pwd") | |
| confirm_pwd = st.text_input("🔄 Confirm New Password", type="password", key="confirm_pwd") | |
| if st.button("Activate My Account", use_container_width=True): | |
| if new_pwd and new_pwd == confirm_pwd: | |
| if update_user_password(input_email, new_pwd): | |
| st.success("✅ Password configured successfully! Please sign in again with your new password.") | |
| st.rerun() | |
| else: | |
| st.error("⚠️ System Error: Failed to update your password. Please contact the administrator.") | |
| else: | |
| st.error("❌ Passwords do not match or fields are left blank.") | |
| return False | |
| # 상황 ②: 이미 비번 세팅이 끝나서 정상 로그인을 시도할 때 -> 로그인 로직 완벽 보존 | |
| else: | |
| input_password = st.text_input("🔑 Password", type="password", key="login_password", placeholder="Enter your system password") | |
| col_login, col_change = st.columns([1, 1]) | |
| with col_login: | |
| if st.button("Sign In", use_container_width=True): | |
| if input_password == current_db_pwd: | |
| st.session_state["password_correct"] = True | |
| st.session_state["is_admin_mode"] = False | |
| st.rerun() | |
| else: | |
| st.error("❌ Invalid password. Please try again.") | |
| # 상황 ③: 직원이 마이페이지에서 비밀번호 변경을 요청할 때 -> 토스트 팝업 로직 완벽 보존 | |
| with col_change: | |
| with st.popover("🔄 Change Password", use_container_width=True): | |
| st.write("Verify your current password to update your credentials.") | |
| verify_old = st.text_input("Current Password", type="password", key="v_old") | |
| update_new = st.text_input("New Password", type="password", key="u_new") | |
| if st.button("Update Password", use_container_width=True): | |
| if verify_old == current_db_pwd and update_new: | |
| if update_user_password(input_email, update_new): | |
| st.toast("✅ Password updated successfully!", icon="🚀") | |
| st.rerun() | |
| else: | |
| st.error("⚠️ System Error: Failed to update your password. Please contact the administrator.") | |
| else: | |
| st.error("❌ Current password verification failed or input is missing.") | |
| return False | |
| # ========================================================================= | |
| # [NOTION API CONNECTOR] 노션 페이지 본문 및 모든 실시간 댓글 병합 수집기 | |
| # ========================================================================= | |
| # ========================= | |
| # 1. 댓글 가져오기 | |
| # ========================= | |
| def fetch_notion_comments(page_id): | |
| """노션 특정 페이지의 댓글과 작성 날짜를 함께 추출하는 함수""" | |
| notion_token = NOTION_TOKEN | |
| headers = { | |
| "Authorization": f"Bearer {notion_token}", | |
| "Notion-Version": "2025-09-03" | |
| } | |
| try: | |
| url = f"https://api.notion.com/v1/comments?block_id={page_id}" | |
| response = requests.get(url, headers=headers) | |
| if response.status_code == 200: | |
| comments_data = response.json().get("results", []) | |
| comments_list = [] | |
| for c in comments_data: | |
| # 🟢 [수정] 작성 날짜(created_time)를 가져옵니다. | |
| created_at = c.get("created_time", "").split("T")[0] # YYYY-MM-DD 형식만 추출 | |
| c_text_list = c.get("rich_text", []) | |
| c_text = "".join( | |
| item.get("plain_text", item.get("text", {}).get("content", "")) | |
| for item in c_text_list | |
| if isinstance(item, dict) | |
| ).strip() | |
| if c_text: | |
| # 🟢 [수정] [날짜] 코멘트 내용 형식으로 조합 | |
| comments_list.append(f"💬 [{created_at}] {c_text}") | |
| return "\n".join(comments_list) if comments_list else "" | |
| except: | |
| pass | |
| return "" | |
| def notion_api_url(endpoint_path): | |
| return "https://api.notion.com/" + endpoint_path.lstrip("/") | |
| def extract_notion_plain_text(prop): | |
| if not isinstance(prop, dict): | |
| return "" | |
| prop_type = prop.get("type") | |
| if prop_type in ("title", "rich_text"): | |
| parts = prop.get(prop_type, []) | |
| return "".join(item.get("plain_text", "") for item in parts if isinstance(item, dict)).strip() | |
| if prop_type == "phone_number": | |
| return str(prop.get("phone_number") or "").strip() | |
| if prop_type == "email": | |
| return str(prop.get("email") or "").strip() | |
| if prop_type == "select" and prop.get("select"): | |
| return str(prop["select"].get("name", "")).strip() | |
| if prop_type == "multi_select": | |
| return ", ".join(item.get("name", "") for item in prop.get("multi_select", []) if item.get("name")).strip() | |
| if prop_type == "people": | |
| return ", ".join(item.get("name", "") for item in prop.get("people", []) if item.get("name")).strip() | |
| if prop_type == "number": | |
| value = prop.get("number") | |
| if value is None: | |
| return "" | |
| if isinstance(value, float) and value.is_integer(): | |
| return str(int(value)).strip() | |
| return str(value).strip() | |
| return "" | |
| def normalize_phone_text(value): | |
| text = "" if value is None else str(value).strip() | |
| if text.endswith(".0") and text[:-2].isdigit(): | |
| text = text[:-2] | |
| return text | |
| def is_phone_like_field(field_name): | |
| normalized = str(field_name or "").strip().lower() | |
| return any(key in normalized for key in ["phone", "mobile", "telephone", "contact number", "cell", "전화", "연락", "휴대"]) | |
| def make_excel_safe_phone(value): | |
| text = normalize_phone_text(value) | |
| if not text: | |
| return "" | |
| escaped = text.replace('"', '""') | |
| return f'="{escaped}"' | |
| def find_notion_property_value(props, preferred_keys, fallback_types=None): | |
| normalized_lookup = {str(key).strip().lower(): value for key, value in props.items()} | |
| for key in preferred_keys: | |
| key_l = key.lower() | |
| for prop_name, prop in normalized_lookup.items(): | |
| if key_l in prop_name: | |
| value = extract_notion_plain_text(prop) | |
| if value: | |
| return value | |
| if fallback_types: | |
| for prop in props.values(): | |
| if isinstance(prop, dict) and prop.get("type") in fallback_types: | |
| value = extract_notion_plain_text(prop) | |
| if value: | |
| return value | |
| return "" | |
| def fetch_client_contacts_from_notion(search_text=""): | |
| notion_token = NOTION_TOKEN | |
| target_data_source_id = NOTION_DB_ID | |
| if not notion_token: | |
| return [], "Missing NOTION_API_TOKEN in secrets.toml" | |
| if not target_data_source_id: | |
| return [], "Missing NOTION_DATABASE_ID in secrets.toml" | |
| headers = { | |
| "Authorization": f"Bearer {notion_token.strip()}", | |
| "Content-Type": "application/json", | |
| "Notion-Version": "2025-09-03" | |
| } | |
| name_keys = ["case name", "client name", "applicant", "customer", "borrower", "name", "고객", "이름"] | |
| nickname_keys = ["nickname", "nick name", "preferred name", "alias", "short name", "닉네임", "별명"] | |
| phone_keys = ["phone", "mobile", "telephone", "contact number", "cell", "전화", "연락", "휴대"] | |
| query_url = notion_api_url(f"v1/data_sources/{target_data_source_id.strip()}/query/") | |
| contacts = [] | |
| start_cursor = None | |
| try: | |
| while True: | |
| payload = {"page_size": 100} | |
| if start_cursor: | |
| payload["start_cursor"] = start_cursor | |
| response = requests.post(query_url, json=payload, headers=headers, proxies={"http": None, "https": None}, timeout=20) | |
| if response.status_code != 200: | |
| return [], f"Notion API Failed: {response.status_code} - {response.text[:200]}" | |
| data = response.json() | |
| for item in data.get("results", []): | |
| props = item.get("properties", {}) | |
| client_name = find_notion_property_value(props, name_keys, fallback_types={"title"}) | |
| nickname = find_notion_property_value(props, nickname_keys) | |
| phone_number = find_notion_property_value(props, phone_keys, fallback_types={"phone_number"}) | |
| if not client_name and not nickname and not phone_number: | |
| continue | |
| searchable = " ".join([client_name, nickname, phone_number]).lower() | |
| if search_text and search_text.strip().lower() not in searchable: | |
| continue | |
| contacts.append({ | |
| "Client Name": client_name, | |
| "Nickname": nickname, | |
| "Phone": phone_number | |
| }) | |
| if not data.get("has_more"): | |
| break | |
| start_cursor = data.get("next_cursor") | |
| except Exception as exc: | |
| return [], f"Notion contact pull failed: {exc}" | |
| contacts = sorted(contacts, key=lambda row: row.get("Client Name", "").lower()) | |
| return contacts, "Success" | |
| NOTION_CONTACT_FIELD_CONFIG = { | |
| "Client Name": { | |
| "keys": ["case name", "client name", "applicant", "customer", "borrower", "name", "고객", "이름"], | |
| "fallback_types": {"title"}, | |
| }, | |
| "Nickname": { | |
| "keys": ["nickname", "nick name", "preferred name", "alias", "short name", "닉네임", "별명"], | |
| "fallback_types": set(), | |
| }, | |
| "Phone": { | |
| "keys": ["phone", "mobile", "telephone", "contact number", "cell", "전화", "연락", "휴대"], | |
| "fallback_types": {"phone_number"}, | |
| }, | |
| "Email": { | |
| "keys": ["email", "e-mail", "mail", "이메일"], | |
| "fallback_types": {"email"}, | |
| }, | |
| "Status": { | |
| "keys": ["status", "stage", "progress", "상태", "진행"], | |
| "fallback_types": set(), | |
| }, | |
| "Adviser": { | |
| "keys": ["adviser", "advisor", "broker", "owner", "담당", "어드바이저"], | |
| "fallback_types": set(), | |
| }, | |
| "Case Number": { | |
| "keys": ["case number", "case no", "ref", "reference", "application number", "번호"], | |
| "fallback_types": set(), | |
| }, | |
| } | |
| def find_notion_property_by_exact_or_contains(props, property_name): | |
| target = str(property_name or "").strip().lower() | |
| if not target: | |
| return "" | |
| for key, prop in props.items(): | |
| if str(key).strip().lower() == target: | |
| return extract_notion_plain_text(prop) | |
| for key, prop in props.items(): | |
| if target in str(key).strip().lower(): | |
| return extract_notion_plain_text(prop) | |
| return "" | |
| def fetch_client_contacts_from_notion(search_text="", selected_fields=None, custom_fields=None): | |
| notion_token = NOTION_TOKEN | |
| target_data_source_id = NOTION_DB_ID | |
| if not notion_token: | |
| return [], "Missing NOTION_API_TOKEN in secrets.toml" | |
| if not target_data_source_id: | |
| return [], "Missing NOTION_DATABASE_ID in secrets.toml" | |
| headers = { | |
| "Authorization": f"Bearer {notion_token.strip()}", | |
| "Content-Type": "application/json", | |
| "Notion-Version": "2025-09-03" | |
| } | |
| selected_fields = selected_fields or ["Client Name", "Nickname", "Phone"] | |
| custom_fields = [field.strip() for field in (custom_fields or []) if field.strip()] | |
| output_fields = list(dict.fromkeys(selected_fields + custom_fields)) | |
| query_url = notion_api_url(f"v1/data_sources/{target_data_source_id.strip()}/query/") | |
| contacts = [] | |
| start_cursor = None | |
| try: | |
| while True: | |
| payload = {"page_size": 100} | |
| if start_cursor: | |
| payload["start_cursor"] = start_cursor | |
| response = requests.post(query_url, json=payload, headers=headers, proxies={"http": None, "https": None}, timeout=20) | |
| if response.status_code != 200: | |
| return [], f"Notion API Failed: {response.status_code} - {response.text[:200]}" | |
| data = response.json() | |
| for item in data.get("results", []): | |
| props = item.get("properties", {}) | |
| row = {} | |
| for field_name in output_fields: | |
| config = NOTION_CONTACT_FIELD_CONFIG.get(field_name) | |
| if config: | |
| row[field_name] = find_notion_property_value( | |
| props, | |
| config["keys"], | |
| fallback_types=config["fallback_types"], | |
| ) | |
| else: | |
| row[field_name] = find_notion_property_by_exact_or_contains(props, field_name) | |
| if is_phone_like_field(field_name): | |
| row[field_name] = normalize_phone_text(row[field_name]) | |
| if not any(str(value).strip() for value in row.values()): | |
| continue | |
| searchable = " ".join(str(value) for value in row.values()).lower() | |
| if search_text and search_text.strip().lower() not in searchable: | |
| continue | |
| contacts.append(row) | |
| if not data.get("has_more"): | |
| break | |
| start_cursor = data.get("next_cursor") | |
| except Exception as exc: | |
| return [], f"Notion contact pull failed: {exc}" | |
| sort_field = "Client Name" if "Client Name" in output_fields else output_fields[0] | |
| contacts = sorted(contacts, key=lambda row: str(row.get(sort_field, "")).lower()) | |
| return contacts, "Success" | |
| def fetch_client_tasks_from_notion(client_name): | |
| # .streamlit/secrets.toml 파일에서 보안 정보를 안전하게 로드합니다. | |
| notion_token = NOTION_TOKEN | |
| target_data_source_id = NOTION_DB_ID | |
| if not notion_token: | |
| return [], "Missing NOTION_API_TOKEN in secrets.toml" | |
| if not target_data_source_id: | |
| return [], "Missing NOTION_DATABASE_ID in secrets.toml" | |
| headers = { | |
| "Authorization": f"Bearer {notion_token.strip()}", | |
| "Content-Type": "application/json", | |
| "Notion-Version": "2025-09-03" | |
| } | |
| # 윈도우 시스템 주소 오타 변조 및 가로채기 차단용 우회 함수 | |
| def get_clean_url(endpoint_path): | |
| p1 = "ht" + "tps://" | |
| p2 = "ap" + "i.no" + "tion.c" + "om/" | |
| return p1 + p2 + endpoint_path | |
| # secrets.toml에서 가져온 진짜 데이터 소스 ID 기반의 쿼리 주소 생성 | |
| query_url = get_clean_url(f"v1/data_sources/{target_data_source_id.strip()}/query/") | |
| # 입력한 고객 이름과 정확히 일치하는 행만 타겟팅하는 필터 딜리버리 | |
| payload = { | |
| "filter": { | |
| "property": "Case Name", | |
| "rich_text": { | |
| "equals": client_name.strip() | |
| } | |
| } | |
| } | |
| clean_proxies = {"http": None, "https": None} | |
| # 1. 원본 데이터 소스 조회 | |
| response = requests.post(query_url, json=payload, headers=headers, proxies=clean_proxies) | |
| if response.status_code != 200: | |
| return [], f"Notion API Failed: {response.status_code}" | |
| results = response.json().get("results", []) | |
| if not results: | |
| return [], f"No case found named '{client_name}'." | |
| # 2. 검색 정합성이 검증된 실시간 데이터 행에서 하위 Task ID 추출 | |
| target_case = results[0] | |
| case_id = target_case.get("id") | |
| # 🟢 [수정 1] 부모 케이스(Case) 페이지에 있는 전체 코멘트 추출 | |
| case_comments = fetch_notion_comments(case_id) | |
| task_ids = [] | |
| for prop in target_case.get("properties", {}).values(): | |
| if prop.get("type") == "relation": | |
| for r in prop.get("relation", []): | |
| task_ids.append(r.get("id")) | |
| tasks_list = [] | |
| # 3. 각 하위 개별 Task 상세 필드 파싱 | |
| for tid in task_ids: | |
| page_url = get_clean_url(f"v1/pages/{tid}") | |
| page_res = requests.get(page_url, headers=headers, proxies=clean_proxies) | |
| if page_res.status_code == 200: | |
| page_data = page_res.json() | |
| props = page_data.get("properties", {}) | |
| title = "제목 없음" | |
| description = "내용 없음" | |
| date_val = datetime.now().strftime("%Y-%m-%d") | |
| channel_val = "-" | |
| for key, v in props.items(): | |
| normalized_key = key.strip().lower() | |
| p_type = v.get("type") | |
| if p_type == "title": | |
| t_list = v.get("title", []) | |
| if isinstance(t_list, list) and len(t_list) > 0: | |
| parts = [item.get("plain_text", "") for item in t_list if isinstance(item, dict)] | |
| if parts: title = "".join(parts) | |
| elif "completed" in normalized_key: | |
| if p_type == "date" and v.get("date"): | |
| date_val = v["date"].get("start", date_val) | |
| elif p_type == "rich_text": | |
| r_list = v.get("rich_text", []) | |
| if isinstance(r_list, list) and len(r_list) > 0: | |
| parts = [item.get("plain_text", "") for item in r_list if isinstance(item, dict)] | |
| if parts: date_val = "".join(parts) | |
| elif "description" in normalized_key: | |
| if p_type == "rich_text": | |
| r_list = v.get("rich_text", []) | |
| if isinstance(r_list, list) and len(r_list) > 0: | |
| parts = [item.get("plain_text", "") for item in r_list if isinstance(item, dict)] | |
| if parts: description = "".join(parts) | |
| elif "channel" in normalized_key: | |
| if p_type == "select" and v.get("select"): | |
| channel_val = v["select"].get("name", "-") | |
| elif p_type == "multi_select" and v.get("multi_select"): | |
| channel_val = ", ".join([m.get("name", "") for m in v["multi_select"]]) | |
| elif p_type == "rich_text": | |
| r_list = v.get("rich_text", []) | |
| if isinstance(r_list, list) and len(r_list) > 0: | |
| parts = [item.get("plain_text", "") for item in r_list if isinstance(item, dict)] | |
| if parts: channel_val = "".join(parts) | |
| comments_text = fetch_notion_comments(tid) | |
| tasks_list.append({ | |
| "Date": date_val, | |
| "Body": description, | |
| "Sec": title, | |
| "RawTitle": title, | |
| "Type": "NOTION", | |
| "Channel": channel_val, | |
| "Comments": comments_text | |
| }) | |
| # 4. 태스크 번호 오름차순 정렬 | |
| if tasks_list: | |
| tasks_list = sorted(tasks_list, key=lambda x: x["Sec"]) | |
| # 🟢 튜플 리턴값 3개로 확장 (case_comments 포함) | |
| return tasks_list, "Success", case_comments | |
| if check_password(): | |
| # Render HR & Security Control Panel if Manager logs in | |
| RULES_FILE = "compliance_rules.json" | |
| if 'rules_df' not in st.session_state: | |
| if os.path.exists(RULES_FILE): | |
| try: st.session_state['rules_df'] = pd.read_json(RULES_FILE, encoding='utf-8') | |
| except: pass | |
| # 파일이 없으면 오리지널 10대 기본 구조 생성 | |
| if 'rules_df' not in st.session_state: | |
| data = [ | |
| {"No": "1", "Stage Title": "INITIAL MEETING", "Sub 1": "Initial Meeting", "Sub 2": "", "Keywords": "", "Action": "Conducted a face-to-face discovery meeting to verify original ID and discuss client objectives.", "Purpose": "To satisfy AML/CFT requirements via face-to-face ID verification and gather financial data for lending strategy."}, | |
| {"No": "2", "Stage Title": "DISCLOSURE DELIVERY", "Sub 1": "Disclosure Delivery", "Sub 2": "", "Keywords": "Disclosure, D1, D2", "Action": "Emailed full FAP Disclosure or provided public info.", "Purpose": "To ensure clients review scope of service and licensing."}, | |
| {"No": "3", "Stage Title": "DOCUMENT COLLECTION", "Sub 1": "Required documents requested", "Sub 2": "Required documents received", "Keywords": "Attachment: Payslip, Statement, ID", "Action": "Verified core docs or requested further clarification.", "Purpose": "To assess borrowing capacity and meet lender standards."}, | |
| {"No": "4", "Stage Title": "STRATEGY & ADVICE", "Sub 1": "Strategy & Advice", "Sub 2": "", "Keywords": "Fact Find, Scope of Work, SOW", "Action": "Discussed bank-specific policies and compared products.", "Purpose": "To align the application with the best possible lender/rate."}, | |
| {"No": "5", "Stage Title": "APPLICATION SUBMISSION", "Sub 1": "Application Submission", "Sub 2": "", "Keywords": "", "Action": "Reviewed SOW/Fact-Find and formally lodged application.", "Purpose": "To ensure data accuracy before formal credit assessment."}, | |
| {"No": "6", "Stage Title": "LENDER QUERY", "Sub 1": "Bank Query Received", "Sub 2": "Query Response Sent", "Keywords": "", "Action": "Received and addressed technical queries from the lender.", "Purpose": "To satisfy credit requirements and progress the file."}, | |
| {"No": "7", "Stage Title": "CONDITIONAL APPROVAL", "Sub 1": "Conditional approval received", "Sub 2": "Conditional approval provided to client", "Keywords": "Letter of Offer, LOO", "Action": "Reviewed conditional approval and updated the clients.", "Purpose": "To identify and guide clients through remaining conditions."}, | |
| {"No": "8", "Stage Title": "LOAN STRUCTURE REVIEW", "Sub 1": "Proposed Structure Discussed", "Sub 2": "Final Instruction to Lender", "Keywords": "Statement of Advice, SOA", "Action": "Provided SOA and confirmed final interest rate lock-in.", "Purpose": "To ensure the loan structure meets the client's risk profile."}, | |
| {"No": "9", "Stage Title": "FINAL APPROVAL", "Sub 1": "Final Approval", "Sub 2": "", "Keywords": "Final, Unconditional", "Action": "Received final approval and instructed the solicitor.", "Purpose": "To confirm all conditions met and initiate legal process."}, | |
| {"No": "10", "Stage Title": "SETTLEMENT COMPLETION", "Sub 1": "Settlement Completion", "Sub 2": "", "Keywords": "Settlement", "Action": "Coordinated funds and confirmed successful completion.", "Purpose": "To finalize the advice process and transition to service."} | |
| ] | |
| st.session_state['rules_df'] = pd.DataFrame(data) | |
| # 1️⃣ [최상단] Admin 전용 통제 구역 (Manager 로그인 시에만 노출) | |
| if st.session_state.get("is_admin_mode", False): | |
| with st.expander("👑 Admin Control", expanded=False): | |
| st.markdown("### 👑 Admin Control") | |
| st.caption("Monitor real-time password setups for active team members and revoke access for offboarded employees.") | |
| current_users = load_active_users() | |
| admin_data = [] | |
| for u_email, u_pwd in current_users.items(): | |
| status_tag = "🔴 Pending Setup" if u_pwd == "" else "🟢 Active (Password Set)" | |
| # [🔒 SECURITY MASKING] Convert plain text passwords (e.g., "dong9ri1" -> "d*******") | |
| if u_pwd and len(u_pwd) > 1: | |
| masked_pwd = u_pwd[0] + "*" * (len(u_pwd) - 1) | |
| elif u_pwd: | |
| masked_pwd = "*" | |
| else: | |
| masked_pwd = "(None)" | |
| admin_data.append({ | |
| "Employee Email": u_email, | |
| "Recorded Password": masked_pwd, # Displays secure masked version on screen | |
| "Status": status_tag | |
| }) | |
| st.table(pd.DataFrame(admin_data)) | |
| st.markdown("**Add or Restore User Access**") | |
| add_col_email, add_col_password = st.columns([2, 1]) | |
| new_user_email = add_col_email.text_input( | |
| "Employee email address", | |
| key="admin_add_user_email", | |
| placeholder="new.user@fluxfinance.co.nz" | |
| ).strip().lower() | |
| new_user_password = add_col_password.text_input( | |
| "Initial password", | |
| type="password", | |
| key="admin_add_user_password", | |
| help="Leave blank to let the user set their password on first login." | |
| ) | |
| if st.button("➕ Add / Restore User", use_container_width=True): | |
| if not new_user_email: | |
| st.error("Please enter an email address.") | |
| elif not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", new_user_email): | |
| st.error("Please enter a valid email address.") | |
| elif add_or_restore_user(new_user_email, new_user_password): | |
| st.success(f"✅ Access granted for {new_user_email}.") | |
| st.rerun() | |
| else: | |
| st.error("⚠️ System Error: Failed to update user access.") | |
| st.markdown("**Deactivate & Revoke User Permissions**") | |
| target_del = st.selectbox("Select email address to revoke access", ["-"] + list(current_users.keys())) | |
| if target_del != "-" and st.button("🚨 Revoke Access"): | |
| if target_del in current_users and revoke_user_access(target_del): | |
| st.success(f"⚠️ Access permanently revoked for {target_del}. This user can no longer sign in.") | |
| st.rerun() | |
| st.markdown("**Mailbox Scan List**") | |
| current_mailboxes = load_active_mailboxes() | |
| st.markdown("**Update Mailbox Password**") | |
| password_mailbox = st.selectbox( | |
| "Mailbox", | |
| list(current_mailboxes.keys()), | |
| key="admin_password_mailbox", | |
| ) | |
| new_mailbox_password = st.text_input( | |
| "New individual password", | |
| type="password", | |
| key="admin_new_mailbox_password", | |
| placeholder="Enter the new mailbox password", | |
| ) | |
| password_col, common_col = st.columns(2) | |
| if password_col.button("Update Password", width="stretch"): | |
| if not new_mailbox_password: | |
| st.error("Please enter the new mailbox password.") | |
| else: | |
| current_entry = current_mailboxes[password_mailbox] | |
| _, separator, imap_host = current_entry.partition("||") | |
| if not separator or not imap_host.strip(): | |
| st.error("The mailbox does not have a valid IMAP host.") | |
| else: | |
| updated_entry = f"{password_mailbox}:{new_mailbox_password}||{imap_host.strip()}" | |
| if add_or_restore_mailbox(updated_entry): | |
| st.session_state.pop("admin_new_mailbox_password", None) | |
| st.success(f"Mailbox password updated: {password_mailbox}") | |
| st.rerun() | |
| else: | |
| st.error("Failed to update the mailbox password.") | |
| if common_col.button("Use Common Secret", width="stretch"): | |
| current_entry = current_mailboxes[password_mailbox] | |
| _, separator, imap_host = current_entry.partition("||") | |
| if not separator or not imap_host.strip(): | |
| st.error("The mailbox does not have a valid IMAP host.") | |
| elif add_or_restore_mailbox(f"{password_mailbox}||{imap_host.strip()}"): | |
| st.session_state.pop("admin_new_mailbox_password", None) | |
| st.success(f"Common secret enabled: {password_mailbox}") | |
| st.rerun() | |
| mailbox_rows = [] | |
| for email_addr, entry in current_mailboxes.items(): | |
| credential_part, separator, imap_host = entry.partition("||") | |
| _, password_separator, individual_password = credential_part.partition(":") | |
| mailbox_rows.append({ | |
| "Mailbox": email_addr, | |
| "Password": individual_password if password_separator else "Uses EMAIL_PASSWORD", | |
| "IMAP Host": imap_host.strip() if separator else "arrow.mxrouting.net", | |
| }) | |
| st.dataframe( | |
| pd.DataFrame(mailbox_rows), | |
| hide_index=True, | |
| width="stretch", | |
| column_config={ | |
| "Mailbox": st.column_config.TextColumn(width="medium"), | |
| "Password": st.column_config.TextColumn(width="medium"), | |
| "IMAP Host": st.column_config.TextColumn(width="large"), | |
| }, | |
| ) | |
| mailbox_entry = st.text_input( | |
| "Mailbox entry", | |
| key="admin_add_mailbox_entry", | |
| placeholder="email@domain.com:optional-password||imap.server.com" | |
| ).strip() | |
| if st.button("➕ Add / Restore Mailbox", use_container_width=True): | |
| mailbox_email = parse_mailbox_email(mailbox_entry) | |
| if not mailbox_entry: | |
| st.error("Please enter a mailbox entry.") | |
| elif not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", mailbox_email): | |
| st.error("Please enter a valid mailbox email address.") | |
| elif "||" not in mailbox_entry: | |
| st.error("Please include the IMAP host using the format email@domain.com||imap.server.com.") | |
| elif add_or_restore_mailbox(mailbox_entry): | |
| st.success(f"✅ Mailbox added for scanning: {mailbox_email}.") | |
| st.rerun() | |
| else: | |
| st.error("⚠️ System Error: Failed to update mailbox list.") | |
| target_mailbox_del = st.selectbox( | |
| "Select mailbox to remove from scan list", | |
| ["-"] + list(current_mailboxes.keys()), | |
| key="admin_revoke_mailbox_select" | |
| ) | |
| if target_mailbox_del != "-" and st.button("🚫 Remove Mailbox From Scan List"): | |
| if target_mailbox_del in current_mailboxes and revoke_mailbox_access(target_mailbox_del): | |
| st.success(f"⚠️ Mailbox removed from scan list: {target_mailbox_del}.") | |
| st.rerun() | |
| # (2) 규칙 편집 관리 (Rules Configuration - Admin Control 바로 밑으로 배치) | |
| with st.expander("⚙️ Rules Configuration", expanded=False): | |
| st.caption("📝 웹 화면에서 수정·추가하면 시스템 파일에 영구 저장되어 연동됩니다.") | |
| edited_rules = st.data_editor(st.session_state['rules_df'], num_rows="dynamic", use_container_width=True, hide_index=True, key="main_rules_editor") | |
| if not edited_rules.equals(st.session_state['rules_df']): | |
| st.session_state['rules_df'] = edited_rules | |
| # 하드디스크 JSON 파일로 덮어쓰기 박제 (영구 저장의 핵심) | |
| edited_rules.to_json(RULES_FILE, orient='records', force_ascii=False, indent=4) | |
| st.toast("대출 매칭 규칙이 영구 저장되었습니다! 💾", icon="✅") | |
| st.divider() | |
| # Compliance Rules Data | |
| if 'rules_df' not in st.session_state: | |
| try: | |
| st.session_state['rules_df'] = pd.read_excel('rules.xlsx') | |
| except: | |
| st.session_state['rules_df'] = pd.DataFrame(columns=['Stage Title', 'Sub 1', 'Sub 2', 'Keyword 1', 'Keyword 2']) | |
| if 'status' not in st.session_state: | |
| st.session_state['status'] = ["⚪"] * len(st.session_state['rules_df']) | |
| # --- Helper Functions --- | |
| def add_line_to_docx(paragraph): | |
| p_ptr = paragraph._p.get_or_add_pPr() | |
| p_bdr = OxmlElement('w:pBdr') | |
| bottom = OxmlElement('w:bottom') | |
| bottom.set(qn('w:val'), 'single'); bottom.set(qn('w:sz'), '4') | |
| bottom.set(qn('w:space'), '1'); bottom.set(qn('w:color'), 'E2E8F0') | |
| p_bdr.append(bottom); p_ptr.append(p_bdr) | |
| def load_docs_from_path(path): | |
| combined_text = "" | |
| path = path.strip().replace('"', '') | |
| if not os.path.exists(path): return None, f"Path not found: {path}" | |
| files = [f for f in os.listdir(path) if f.lower().endswith(('.pdf', '.docx'))] | |
| if not files: return None, "No reference docs found in the folder." | |
| for f_name in files: | |
| f_path = os.path.join(path, f_name) | |
| try: | |
| if f_name.lower().endswith('.pdf'): | |
| with pdfplumber.open(f_path) as pdf: | |
| for page in pdf.pages: combined_text += (page.extract_text() or "") + "\n" | |
| elif f_name.lower().endswith('.docx'): | |
| doc = Document(f_path) | |
| for para in doc.paragraphs: combined_text += para.text + "\n" | |
| except: continue | |
| return combined_text, f"✅ Analyzed {len(files)} reference document(s)." | |
| # 🟢 [콜백 삭제] 악명 높은 에러를 일으키는 assign_callback은 아예 삭제합니다! | |
| # 🟢 메인 연산 함수: 상태 리스트를 '새 리스트 통째로 덮어쓰기'로 깔끔하게 유지 | |
| def update_status(): | |
| if st.session_state.get('results') is None: return | |
| df = st.session_state['results'] | |
| rules = st.session_state['rules_df'] | |
| rules_list = rules.to_dict('records') | |
| new_status = ["⚪"] * len(rules_list) | |
| if st.session_state.get('active_mode') == "NOTION": | |
| assigned = [re.sub(r'^\d+\.\s*', '', str(x)).lower().strip() for x in df[df['Type'] == "NOTION"]['Sec'].tolist()] | |
| for idx, r in enumerate(rules_list): | |
| stage_val = str(r.get('Stage Title', '')).strip().lower() | |
| s1 = str(r.get('Sub 1', '')).strip().lower() | |
| s2 = str(r.get('Sub 2', '')).strip().lower() | |
| has_stage = stage_val not in ["", "nan", "x", "none", "-"] | |
| has_s1 = s1 not in ["", "nan", "x", "none", "-"] | |
| has_s2 = s2 not in ["", "nan", "x", "none", "-"] | |
| is_stage_mapped = (stage_val in assigned) if has_stage else False | |
| is_s1_mapped = (s1 in assigned) if has_s1 else False | |
| is_s2_mapped = (s2 in assigned) if has_s2 else False | |
| if is_stage_mapped: | |
| new_status[idx] = "🟢" | |
| else: | |
| if has_s1 and not has_s2: | |
| new_status[idx] = "🟢" if is_s1_mapped else "⚪" | |
| elif has_s1 and has_s2: | |
| if is_s1_mapped and is_s2_mapped: new_status[idx] = "🟢" | |
| elif is_s1_mapped or is_s2_mapped: new_status[idx] = "🟠" | |
| else: new_status[idx] = "⚪" | |
| else: | |
| new_status[idx] = "⚪" | |
| else: | |
| assigned = [str(x).lower().strip() for x in df[df['Type'] != "NOTION"]['Sec'].tolist()] | |
| for idx, r in enumerate(rules_list): | |
| s1 = str(r.get('Sub 1', '')).strip().lower() | |
| s2 = str(r.get('Sub 2', '')).strip().lower() | |
| has_s1 = s1 not in ["", "nan", "x", "None", "-"] | |
| has_s2 = s2 not in ["", "nan", "x", "None", "-"] | |
| is_s1_mapped = (s1 in assigned) if has_s1 else False | |
| is_s2_mapped = (s2 in assigned) if has_s2 else False | |
| if has_s1 and not has_s2: | |
| new_status[idx] = "🟢" if is_s1_mapped else "⚪" | |
| elif has_s1 and has_s2: | |
| if is_s1_mapped and is_s2_mapped: new_status[idx] = "🟢" | |
| elif is_s1_mapped or is_s2_mapped: new_status[idx] = "🟠" | |
| else: new_status[idx] = "⚪" | |
| else: | |
| new_status[idx] = "⚪" | |
| st.session_state['status'] = new_status | |
| # --- Helper Functions --- | |
| # (기존 add_line_to_docx, load_docs_from_path, update_status 등은 그대로 유지) | |
| def ask_ai_note(stage_key, items_data, row_info, manual_note=""): | |
| # 🚨 [파이썬 강력 통제 1] 매핑된 이메일 데이터가 단 한 개도 없고, 추가 수기 메모도 완전히 비어있다면 | |
| # AI 호출 자체를 생략하고 즉시 완벽한 3줄 공란을 리턴하여 비용과 오류를 원천 차단합니다. | |
| if not items_data and not manual_note.strip(): | |
| return "Date: \nAction: \nPurpose: \n" | |
| try: | |
| #vertexai.init(project=st.secrets["PROJECT_ID"], location="us-west1") | |
| from vertexai.generative_models import GenerativeModel | |
| model = GenerativeModel('gemini-2.5-flash') | |
| # 디지털 이메일/노션 소스 로그 포맷팅 | |
| context_str = "" | |
| for i, m in enumerate(items_data): | |
| record_type = m.get('Type', 'EMAIL') | |
| d = m.get('Date', 'N/A') | |
| b = m.get('Body', '')[:1500] | |
| if record_type == "NOTION": | |
| ch = m.get('Channel', '-') | |
| cm = m.get('Comments', '') | |
| context_str += f"Record {i+1} [Type: NOTION Task]\nDate: {d}\nChannel: {ch}\nDescription: {b}\nComments: {cm}\n\n" | |
| else: | |
| context_str += f"Record {i+1} [Type: EMAIL]\nDate: {d}\nBody: {b}\n\n" | |
| # 규칙 데이터 안전 변환 | |
| rule_dict = dict(row_info) if row_info is not None else {} | |
| stage_title = rule_dict.get('Stage Title', stage_key) | |
| rule_action = rule_dict.get('Action', "").strip() | |
| rule_purpose = rule_dict.get('Purpose', "").strip() | |
| email_count = len(items_data) | |
| # 🚨 [파이썬 강력 통제 2] 만약 디지털 매핑 데이터는 아예 없는데 '수기 메모'만 존재하는 경우, | |
| # 이 수기 메모가 현재 순회 중인 단계와 연관이 있는 단어(예: 세미나, 첫 미팅 등)를 포함하는지 하드코딩으로 1차 검증합니다. | |
| # 1번 INITIAL MEETING이 아닌 다른 단계에 미팅 정보가 침범하는 것을 완벽하게 막아냅니다. | |
| if email_count == 0 and manual_note.strip(): | |
| lower_note = manual_note.lower() | |
| # 1번 초기 미팅과 관련된 핵심 단어들 리스트 | |
| initial_keywords = ["initial", "meeting", "seminar", "remax", "처음", "만났", "미팅", "신분증", "얼굴"] | |
| # 현재 단계가 1번 초기 미팅이 '아닌데', 수기 메모 내용이 초기 미팅 관련 내용뿐이라면 즉시 공란 리턴하고 차단 | |
| if "1." not in stage_key and any(kw in lower_note for kw in initial_keywords): | |
| return "Date: \nAction: \nPurpose: \n" | |
| # 🚨 [프롬프트 가드레일 개조] | |
| prompt = f""" | |
| You are a strict compliance auditor representing the New Zealand Financial Markets Authority (FMA). | |
| Your job is to draft a professional File Note ONLY for the specific mortgage process stage: [{stage_title}] | |
| [STRICT COMPLIANCE FILTERING MATRIX] | |
| - Current Target Stage: {stage_title} | |
| - Authorized Compliance Actions: {rule_action} | |
| - Strategic Compliance Objective: {rule_purpose} | |
| [REAL CLIENT DATA SETS (GROUND TRUTH)] | |
| - Number of Digital Logs Matched: {email_count} | |
| - Digital Logs (Matched Emails/Notion): {context_str if items_data else "NO DIGITAL LOGS FOR THIS STAGE"} | |
| - User's Additional Memo: "{manual_note if manual_note.strip() else "NONE PROVIDED"}" | |
| [⚠️ CRITICAL GENERATION RULES - DO NOT VIOLATE] | |
| 1. MANDATORY OVERRIDE FOR MATCHED LOGS: If 'Number of Digital Logs Matched' is 1 or more, it means the user has manually verified and mapped these emails to [{stage_title}]. You MUST trust this mapping and generate a summary based on these logs. | |
| 2. NO DATA = COMPLETE BLANK: If the provided digital logs are empty, and the User's Additional Memo does not explicitly describe a unique event for [{stage_title}], you are strictly forbidden from writing ANYTHING. You MUST output exactly this 3-line block and STOP. Do NOT copy the 'Strategic Compliance Objective' text into Purpose if there is no matching fact: | |
| Date: | |
| Action: | |
| Purpose: | |
| 3. NEVER COPY TEMPLATE TEXT: Do not copy-paste or mimic the phrase "{rule_action}" or "{rule_purpose}" into the note. Always write unique, professional descriptions based on the client logs. | |
| [🚨 CRITICAL FORMAT RULE: NUMBER OF ITEMS MATCHING] | |
| - CASE A (Single Source): If there is only 1 email/record matched, OR only the User's Memo is valid for this stage, output EXACTLY ONE pair of "Date:" and "Action:". (Do NOT use numbers like Date 1 or Action 1). | |
| - CASE B (Multiple Sources): If there are 2 or more distinct emails/records matched, group them chronologically and use "Date 1:", "Action 1:", "Date 2:", "Action 2:" etc. | |
| [PROPER FORMAT EXAMPLES] | |
| Example for CASE A: | |
| Date: 15 May 2026, 10:32 AM | |
| Action: Sent an email to the client confirming the next steps of disclosure and requested verification docs. | |
| Purpose: Written custom compliant rationale based on the real event. | |
| """ | |
| response = model.generate_content(prompt) | |
| res_text = response.text.strip() | |
| if "Date: \nAction: \nPurpose:" in res_text or not res_text: | |
| return "Date: \nAction: \nPurpose: \n" | |
| return res_text | |
| except Exception as e: | |
| return f"⚠️ Vertex AI Error: {str(e)}" | |
| def estimate_text_tokens(text): | |
| return max(1, int(len(str(text or "")) / 4) + 1) | |
| def estimate_ai_summary_cost(full_df, rules_df, manual_note=""): | |
| if full_df is None or full_df.empty: | |
| return {"calls": 0, "input_tokens": 0, "output_tokens": 0, "usd": 0.0} | |
| current_mode = st.session_state.get('active_mode', 'NOTION') | |
| if current_mode == "NOTION" and 'Type' in full_df.columns: | |
| df = full_df[full_df['Type'] == "NOTION"].copy() | |
| elif 'Type' in full_df.columns: | |
| df = full_df[full_df['Type'] != "NOTION"].copy() | |
| else: | |
| df = full_df.copy() | |
| calls = 0 | |
| input_tokens = 0 | |
| output_tokens = 0 | |
| manual_has_text = bool(str(manual_note or "").strip()) | |
| for _, row in rules_df.iterrows(): | |
| s1 = str(row.get('Sub 1', '')).strip() | |
| s2 = str(row.get('Sub 2', '')).strip() | |
| targets = pd.DataFrame() | |
| if not df.empty and 'Sec' in df.columns: | |
| stage_values = [s1] | |
| if s2 and s2 not in ["nan", "x", "None", "-"]: | |
| stage_values.append(s2) | |
| targets = df[df['Sec'].isin(stage_values)] | |
| if targets.empty and not manual_has_text: | |
| continue | |
| calls += 1 | |
| row_text = " ".join([str(row.get(k, "")) for k in ["Stage Title", "Sub 1", "Sub 2", "Action", "Purpose"]]) | |
| source_text = "" | |
| for item in targets.to_dict('records') if not targets.empty else []: | |
| source_text += " ".join([ | |
| str(item.get("Date", "")), | |
| str(item.get("Subject", "")), | |
| str(item.get("From", "")), | |
| str(item.get("To", "")), | |
| str(item.get("Cc", "")), | |
| str(item.get("Channel", "")), | |
| str(item.get("Body", ""))[:1500], | |
| str(item.get("Comments", "")), | |
| ]) + "\n" | |
| prompt_overhead = 1200 | |
| input_tokens += prompt_overhead + estimate_text_tokens(row_text) + estimate_text_tokens(source_text) + estimate_text_tokens(manual_note) | |
| output_tokens += ESTIMATED_OUTPUT_TOKENS_PER_STAGE | |
| usd = ( | |
| input_tokens / 1_000_000 * GEMINI_25_FLASH_INPUT_USD_PER_1M | |
| + output_tokens / 1_000_000 * GEMINI_25_FLASH_OUTPUT_USD_PER_1M | |
| ) | |
| return {"calls": calls, "input_tokens": input_tokens, "output_tokens": output_tokens, "usd": usd} | |
| # ───────────────────────────────────────────────────────────────── | |
| # 🟢 [실시간 동기화 마법의 엔진] 사이드바를 그리기 전에 드롭다운 값을 먼저 가로채서 계산! | |
| # ───────────────────────────────────────────────────────────────── | |
| def sync_and_update_status(): | |
| if st.session_state.get('results') is None: return | |
| # 1. 화면 맨 밑의 드롭다운(selectbox) 최신 값을 미리 가로채서 데이터프레임에 덮어씀 | |
| df = st.session_state['results'] | |
| for row_idx in df.index: | |
| sel_key = f"sel_{row_idx}" | |
| if sel_key in st.session_state: | |
| df.at[row_idx, 'Sec'] = st.session_state[sel_key] | |
| # 2. 전구(status) 즉시 계산 | |
| rules = st.session_state['rules_df'] | |
| rules_list = rules.to_dict('records') | |
| new_status = ["⚪"] * len(rules_list) | |
| if st.session_state.get('active_mode') == "NOTION": | |
| assigned = [re.sub(r'^\d+\.\s*', '', str(x)).lower().strip() for x in df[df['Type'] == "NOTION"]['Sec'].tolist()] | |
| for idx, r in enumerate(rules_list): | |
| stage_val = str(r.get('Stage Title', '')).strip().lower() | |
| s1 = str(r.get('Sub 1', '')).strip().lower() | |
| s2 = str(r.get('Sub 2', '')).strip().lower() | |
| has_stage = stage_val not in ["", "nan", "x", "none", "-"] | |
| has_s1 = s1 not in ["", "nan", "x", "none", "-"] | |
| has_s2 = s2 not in ["", "nan", "x", "none", "-"] | |
| is_stage_mapped = (stage_val in assigned) if has_stage else False | |
| is_s1_mapped = (s1 in assigned) if has_s1 else False | |
| is_s2_mapped = (s2 in assigned) if has_s2 else False | |
| if is_stage_mapped: new_status[idx] = "🟢" | |
| else: | |
| if has_s1 and not has_s2: new_status[idx] = "🟢" if is_s1_mapped else "⚪" | |
| elif has_s1 and has_s2: | |
| if is_s1_mapped and is_s2_mapped: new_status[idx] = "🟢" | |
| elif is_s1_mapped or is_s2_mapped: new_status[idx] = "🟠" | |
| else: new_status[idx] = "⚪" | |
| else: new_status[idx] = "⚪" | |
| else: | |
| assigned = [str(x).lower().strip() for x in df[df['Type'] != "NOTION"]['Sec'].tolist()] if 'Type' in df.columns else [str(x).lower().strip() for x in df['Sec'].tolist()] | |
| for idx, r in enumerate(rules_list): | |
| s1 = str(r.get('Sub 1', '')).strip().lower() | |
| s2 = str(r.get('Sub 2', '')).strip().lower() | |
| has_s1 = s1 not in ["", "nan", "x", "None", "-"] | |
| has_s2 = s2 not in ["", "nan", "x", "None", "-"] | |
| is_s1_mapped = (s1 in assigned) if has_s1 else False | |
| is_s2_mapped = (s2 in assigned) if has_s2 else False | |
| if has_s1 and not has_s2: new_status[idx] = "🟢" if is_s1_mapped else "⚪" | |
| elif has_s1 and has_s2: | |
| if is_s1_mapped and is_s2_mapped: new_status[idx] = "🟢" | |
| elif is_s1_mapped or is_s2_mapped: new_status[idx] = "🟠" | |
| else: new_status[idx] = "⚪" | |
| else: new_status[idx] = "⚪" | |
| st.session_state['status'] = new_status | |
| # 🚀 사이드바를 그리기 직전에 이 엔진을 무조건 가동시킵니다! | |
| sync_and_update_status() | |
| # --- [Sidebar Area: 통합 워드 스타일 에디터] --- | |
| with st.sidebar: | |
| # [1] Progress Status (상단 고정) | |
| st.markdown("### 📊 Progress Status") | |
| for row_idx in range(0, 10, 5): | |
| cols = st.columns(5) | |
| for i in range(5): | |
| idx = row_idx + i | |
| if idx < len(st.session_state['status']): | |
| with cols[i]: | |
| icon = st.session_state['status'][idx] | |
| dt = "🟢" if icon == "🟢" else ("🟠" if icon == "🟠" else "⚪") | |
| full_title = st.session_state['rules_df'].iloc[idx]['Stage Title'] | |
| st.markdown( | |
| f"""<div style='text-align: center; line-height: 1.2; margin-bottom: 12px; min-height: 60px;'> | |
| <div style='font-size: 1.1rem;'>{dt}</div> | |
| <div style='font-size: 0.75rem; font-weight: bold; color: #1E3A8A;'>{idx+1}</div> | |
| <div style='font-size: 0.65rem; color: #334155; word-break: keep-all; white-space: normal;'>{full_title}</div> | |
| </div>""", unsafe_allow_html=True) | |
| # ✨ [가이드 안내 문구 추가] 전구 상태 설명 및 AI 실행 안내 텍스트 이식 | |
| st.markdown(""" | |
| <div style='font-size: 0.73rem; color: #64748B; background-color: #F8FAFC; border: 1px solid #E2E8F0; padding: 10px 12px; border-radius: 6px; margin-top: 2px; margin-bottom: 18px; line-height: 1.5;'> | |
| ⚪ <b>Unmapped</b> | | |
| 🟠 <b>Partially Mapped (1/2)</b> | | |
| 🟢 <b>Fully Mapped</b><br> | |
| <div style='margin-top: 5px; color: #334155; font-weight: 600;'></div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # [수정 핵심] 변수 정의를 if문 밖으로 꺼내어 에러 방지 | |
| display_client = st.session_state.get('client_names', '').replace(';', ' & ').strip() | |
| support_person = st.session_state.get('support_name', 'Edward Lee (BDM)') | |
| # [2] 작업 버튼 영역 (Sync 완료 시에만 나타남) | |
| if st.session_state.get('results') is not None: | |
| # 🟢 [수정 2] UI 명칭 변경 및 프롬프트 역할 강조 | |
| ai_manual_note = st.text_area( | |
| "💡 AI Additional Prompt (Optional)", | |
| placeholder="AI에게 전달할 지시사항 (예: 전체 수집된 이메일 내역을 보고 Adviser note 알아서 생성 등)", | |
| height=100 | |
| ) | |
| # 🟢 1. 현재 작업 중인 탭이 '이메일'인지 확인합니다. | |
| current_mode = st.session_state.get('active_mode', 'NOTION') | |
| is_email = (current_mode == "EMAIL") | |
| # 🟢 2. 이메일 탭일 경우 체크박스를 강제로 끄고(False), 클릭 못 하게 잠급니다(disabled). | |
| use_ai = st.checkbox( | |
| "🤖 AI로 자동 작성하기 (API 비용 발생)", | |
| value=False, | |
| disabled=is_email, | |
| help="🚫 이메일 모드에서는 AI 요약이 제한되며 빈 템플릿만 생성됩니다." if is_email else "체크를 끄면 비용 없이 날짜만 추출된 빈 템플릿이 즉시 생성됩니다." | |
| ) | |
| if use_ai: | |
| cost_estimate = estimate_ai_summary_cost( | |
| st.session_state.get('results'), | |
| st.session_state['rules_df'], | |
| ai_manual_note | |
| ) | |
| approx_nzd = cost_estimate["usd"] * 1.7 | |
| st.info( | |
| f"Estimated Gemini cost before run: " | |
| f"{cost_estimate['calls']} call(s), " | |
| f"~{cost_estimate['input_tokens']:,} input tokens, " | |
| f"~{cost_estimate['output_tokens']:,} output tokens, " | |
| f"about USD ${cost_estimate['usd']:.4f} / NZD ${approx_nzd:.4f}." | |
| ) | |
| else: | |
| st.caption("AI cost estimate: USD $0.0000 because AI auto-write is off.") | |
| col_ai, col_dl = st.columns(2) | |
| with col_ai: | |
| if st.button("✨ Run AI Summary", use_container_width=True): | |
| with st.spinner("Adviser AI Generating Report..."): | |
| # 🔄 [실시간 탭 감지 엔진] 현재 활성화된 모드에 따라 화면 입력값을 다이렉트로 추적 | |
| current_mode = st.session_state.get('active_mode', 'NOTION') | |
| if current_mode == "NOTION": | |
| # 노션 탭 입력창 값 가져와서 세미콜론 치환 | |
| raw_name = st.session_state.get('notion_client_name', '').strip() | |
| final_client_name = raw_name.replace(';', ' & ') if raw_name else "N/A" | |
| else: | |
| # 이메일 탭 입력창 값 가져와서 세미콜론 치환 | |
| raw_name = st.session_state.get('client_names', '').strip() | |
| final_client_name = raw_name.replace(';', ' & ') if raw_name else "N/A" | |
| # 전역 변수에도 최신 상태 최종 박제 (워드 파일명 연동용) | |
| st.session_state['global_client_name'] = final_client_name | |
| # 상단 메타 헤더 정보 생성 (동적 추출된 이름 주입) | |
| combined_content = f"ADVISER FILE NOTES LOG\n\nClient Name: {final_client_name}\nAdviser: Tim Park\nSupport: {st.session_state.get('support_name', 'N/A')}\nStatus: Completed / Final Approval Issued\n" | |
| combined_content += "="*67 + "\n" | |
| full_txt = st.session_state.get('full_note', '').strip() | |
| if full_txt: | |
| doc_bio = io.BytesIO() | |
| final_doc = Document() | |
| # [기존 기능 유지] 상단 메타 헤더 정보 생성 | |
| combined_content = f"ADVISER FILE NOTES LOG\n\nClient Name: {final_client_name}\nAdviser: Tim Park\nSupport: {st.session_state.get('support_name', 'N/A')}\nStatus: Completed / Final Approval Issued\n" | |
| combined_content += "="*67 + "\n" | |
| # [기존 기능 유지] 1. 화면상의 수동 매핑 상태(드롭다운 값)를 세션 데이터프레임에 동기화 | |
| if st.session_state.get('results') is not None: | |
| for row_idx in st.session_state['results'].index: | |
| sel_key = f"sel_{row_idx}" | |
| if sel_key in st.session_state: | |
| st.session_state['results'].at[row_idx, 'Sec'] = st.session_state[sel_key] | |
| # [기존 기능 유지] 2. 현재 작업 모드(EMAIL 또는 NOTION)에 맞춰 소스 데이터 필터링 | |
| full_df = st.session_state['results'].copy() if st.session_state.get('results') is not None else pd.DataFrame() | |
| if not full_df.empty: | |
| if st.session_state.get('active_mode') == "NOTION": | |
| df = full_df[full_df['Type'] == "NOTION"].copy() | |
| else: | |
| df = full_df[full_df['Type'] != "NOTION"].copy() | |
| else: | |
| df = pd.DataFrame(columns=['Sec', 'Type', 'Date', 'Body', 'Subject']) | |
| # [기존 기능 유지] 실시간 규칙 세션 데이터셋 불러오기 | |
| rules_list = st.session_state['rules_df'].to_dict('records') | |
| # 🚨 [동적 대응 업데이트] 고정된 10개가 아니라 웹에서 추가/삭제된 모든 단계를 실시간으로 유연하게 순회 | |
| for row in rules_list: | |
| # 항목이 웹에서 동적으로 추가되더라도 에러가 나지 않도록 안전하게 변수 추출 | |
| r_no = row.get('No', '-') | |
| r_title = row.get('Stage Title', 'Unknown Stage') | |
| sk = f"{r_no}. {r_title}" | |
| sub1 = str(row.get('Sub 1', '')).strip().lower() | |
| sub2 = str(row.get('Sub 2', '')).strip().lower() | |
| stage_title_lower = str(r_title).strip().lower() | |
| # 🟢 [수정] 노션 데이터의 번호표("02. ", "03. " 등)를 떼어내고 정규화 | |
| temp_df = df.copy() | |
| temp_df['Sec_Clean'] = temp_df['Sec'].astype(str).str.replace(r'^\d+\.\s*', '', regex=True).str.strip().str.lower() | |
| # 🟢 [핵심] 수동 매핑(Sub 1, 2)과 노션 자동 매핑(Stage Title)을 모두 인정해 줍니다! | |
| valid_targets = [sub1, sub2, stage_title_lower] | |
| valid_targets = [x for x in valid_targets if x not in ["", "nan", "x", "none", "-"]] | |
| targets = temp_df[temp_df['Sec_Clean'].isin(valid_targets)] if not temp_df.empty else pd.DataFrame() | |
| combined_content += f"\n[{sk}]\n" | |
| # [기존 기능 유지] 매핑된 이메일이 있거나, 혹은 이메일이 없더라도 사용자가 AI Additional Prompt를 적은 경우에만 AI 작동 | |
| if not targets.empty or ai_manual_note.strip(): | |
| e_data = targets.to_dict('records') if not targets.empty else [] | |
| # [기존 기능 유지] Ambiguous 에러 방지를 위해 row를 dict형으로 안전하게 변환하여 AI 호출 | |
| note_text = ask_ai_note(sk, e_data, dict(row), ai_manual_note) | |
| combined_content += f"{note_text}\n\n" | |
| else: | |
| # [기존 기능 유지] 매핑된 이메일도 없고 추가 프롬프트 내용도 없는 구역은 깨끗하게 공란 폼만 출력 | |
| combined_content += "Date: \nAction: \nPurpose: \n\n" | |
| # [기존 기능 유지] 최종 결과물을 사이드바 에디터 세션에 주입하고 화면 리프레시 | |
| st.session_state['full_note'] = combined_content | |
| st.session_state['editor_version'] = datetime.now().strftime("%H%M%S") | |
| st.rerun() | |
| with col_dl: | |
| # 🟢 전역 변수로 확정된 이름을 가져옵니다. | |
| current_mode = st.session_state.get('active_mode', 'NOTION') | |
| if current_mode == "NOTION": | |
| raw_name = st.session_state.get('notion_client_name', '').strip() | |
| final_client_name = raw_name.replace(';', ' & ') if raw_name else "N/A" | |
| else: | |
| raw_name = st.session_state.get('client_names', '').strip() | |
| final_client_name = raw_name.replace(';', ' & ') if raw_name else "N/A" | |
| full_txt = st.session_state.get('full_note', '').strip() | |
| if full_txt: | |
| doc_bio = io.BytesIO() | |
| final_doc = Document() | |
| # 워드 문서 전체 기본 폰트 설정 | |
| style = final_doc.styles['Normal'] | |
| font = style.font | |
| font.name = 'Arial' | |
| font.size = Pt(11) | |
| lines = full_txt.split('\n') | |
| for i, line in enumerate(lines): | |
| # 1. 문서 제목 (가운데 정렬, 굵게, 크기 키움) | |
| if line.strip() == "ADVISER FILE NOTES LOG": | |
| p = final_doc.add_paragraph() | |
| p.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| run = p.add_run(line.strip()) | |
| run.bold = True | |
| run.font.size = Pt(16) | |
| continue | |
| # 2. 메타 정보 (이름, 어드바이저 등 항목 이름만 굵게 처리) | |
| is_meta = False | |
| for meta_key in ["Client Name:", "Adviser:", "Support:", "Status:"]: | |
| if line.startswith(meta_key): | |
| p = final_doc.add_paragraph() | |
| parts = line.split(meta_key, 1) | |
| run_bold = p.add_run(meta_key) | |
| run_bold.bold = True | |
| if len(parts) > 1: | |
| p.add_run(parts[1]) | |
| is_meta = True | |
| break | |
| if is_meta: | |
| continue | |
| # 3. 최상단 실선 구분선 유지 (====) | |
| if line.startswith('===='): | |
| p_line = final_doc.add_paragraph(line) | |
| p_line.paragraph_format.space_after = Pt(0) | |
| continue | |
| # 4. 각 섹션 제목 ([1. INITIAL MEETING] 등 파란색, 굵게) | |
| if line.strip().startswith('[') and line.strip().endswith(']'): | |
| if i > 10: | |
| p_dash = final_doc.add_paragraph() | |
| p_dash.add_run("-" * 117) | |
| p = final_doc.add_paragraph() | |
| run = p.add_run(line.strip()) | |
| run.bold = True | |
| run.font.color.rgb = RGBColor(30, 58, 138) | |
| run.font.size = Pt(12) | |
| continue | |
| # 5. 일반 텍스트 내 Date, Action, Purpose 라벨만 진하게 처리하는 구역 | |
| stripped_line = line.strip() | |
| is_label_line = False | |
| for target_label in ["Date", "Action", "Purpose"]: | |
| if stripped_line.startswith(target_label): | |
| p = final_doc.add_paragraph() | |
| if ":" in stripped_line: | |
| label_part, content_part = stripped_line.split(":", 1) | |
| run_label = p.add_run(f"{label_part}:") | |
| run_label.bold = True | |
| p.add_run(content_part) | |
| else: | |
| run_all = p.add_run(stripped_line) | |
| run_all.bold = True | |
| is_label_line = True | |
| break | |
| if is_label_line: | |
| continue | |
| final_doc.add_paragraph(line) | |
| final_doc.save(doc_bio) | |
| # 🛠️ 수정: 안전하게 동기화된 final_client_name으로 파일명 빌드 및 다운로드 활성화 | |
| st.download_button("💾 Save Word", data=doc_bio.getvalue(), file_name=f"Adviser_File_Notes_Log_{final_client_name}.docx", use_container_width=True) | |
| else: | |
| st.button("💾 Save Word", use_container_width=True, disabled=True, help="Run AI Summary first") | |
| st.divider() | |
| # [3] 통합 텍스트 에디터 (중복 박스 제거, 텍스트 창만 유지) | |
| if 'full_note' not in st.session_state: st.session_state['full_note'] = "" | |
| ver = st.session_state.get('editor_version', 'default') | |
| st.session_state['full_note'] = st.text_area( | |
| label="Integrated Note Editor", | |
| value=st.session_state['full_note'], | |
| height=800, | |
| key=f"final_editor_{ver}", | |
| label_visibility="collapsed" | |
| ) | |
| # --- [Main Area: Configuration] --- | |
| st.title("📑 Flux Finance Internal Note") | |
| # 💡 [세션 동기화 보강] 앱 초기 구동 시 액티브 모드가 비어있으면 기본값으로 이메일을 지정하여 락을 해제합니다. | |
| if 'active_mode' not in st.session_state: | |
| st.session_state['active_mode'] = "Notion" | |
| # 💡 [탭 순서 혁신] 노션 탭을 배열의 맨 앞으로 배치하여 웹 접속 시 첫 화면에 무조건 노션 입력창이 먼저 뜨도록 제어합니다. | |
| tab_notion, tab_contacts, tab_email = st.tabs(["📝 Pull Notion", "👥 Notion Contacts", "✉️ Scan Emails"]) | |
| # TAB 1: 노션 검색창 | |
| with tab_notion: | |
| with st.container(border=True): | |
| st.caption("💡 **Notion Sync:** Pull matching compliance tracks and thread messages.") | |
| c_notion1, c_notion2 = st.columns([5, 1]) | |
| search_client = c_notion1.text_input("👤 Case Name (Applicants Name)", value=st.session_state['notion_client_name'], key="notion_search_bar_fixed_tab") | |
| st.session_state['notion_client_name'] = search_client | |
| notion_sync_btn = c_notion2.button("🔍 Pull", use_container_width=True, key="notion_sync_trigger") | |
| if notion_sync_btn and search_client: | |
| st.session_state['active_mode'] = "NOTION" | |
| st.session_state['global_client_name'] = search_client.replace(';', ' & ').strip() | |
| with st.status("🔍 Syncing Notion Workspace...", expanded=True) as status: | |
| # 🟢 리턴받은 case_comments를 세션에 저장 | |
| tasks_data, msg, case_comments = fetch_client_tasks_from_notion(search_client) | |
| if tasks_data: | |
| st.session_state['results'] = pd.DataFrame(tasks_data) | |
| st.session_state['results']['Type'] = "NOTION" | |
| st.session_state['case_comments'] = case_comments # 코멘트 세션 보관 | |
| update_status() | |
| status.update(label="✅ Notion Tasks Synced.", state="complete") | |
| st.rerun() | |
| elif tasks_data is not None and not tasks_data: | |
| status.update(label="⚠️ 매칭 데이터 없음", state="error") | |
| else: | |
| status.update(label=f"❌ Failed: {msg}", state="error") | |
| case_comments = st.session_state.get('case_comments', '').strip() | |
| if case_comments: | |
| html_case_comments = html.escape(case_comments).replace('\n', '<br>') | |
| st.markdown(f""" | |
| <div style='background-color: #F0FDF4; padding: 12px 15px; border-radius: 8px; border: 1px solid #BBF7D0; font-size: 0.85rem; margin-top: 12px; color: #166534; line-height: 1.6;'> | |
| <b>💬 Case Comments:</b><br><br>{html_case_comments} | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # TAB 2: 이메일 검색창 | |
| with tab_contacts: | |
| with st.container(border=True): | |
| st.caption("Pull the full customer list from Notion with only the fields you choose.") | |
| contact_col1, contact_col2 = st.columns([5, 1]) | |
| contact_search = contact_col1.text_input( | |
| "Optional filter", | |
| key="notion_contact_search", | |
| placeholder="Leave blank for the full list" | |
| ) | |
| contact_pull_btn = contact_col2.button("Pull Full List", use_container_width=True, key="notion_contact_pull") | |
| selected_contact_fields = st.multiselect( | |
| "Fields to pull", | |
| options=list(NOTION_CONTACT_FIELD_CONFIG.keys()), | |
| default=["Client Name", "Nickname", "Phone"], | |
| key="notion_contact_fields" | |
| ) | |
| custom_contact_fields_raw = st.text_input( | |
| "Additional Notion property names", | |
| key="notion_contact_custom_fields", | |
| placeholder="Example: DOB; Address; Loan Amount" | |
| ) | |
| custom_contact_fields = [ | |
| field.strip() | |
| for field in custom_contact_fields_raw.replace(",", ";").split(";") | |
| if field.strip() | |
| ] | |
| if contact_pull_btn: | |
| with st.status("Pulling Notion contacts...", expanded=True) as status: | |
| contact_rows, contact_msg = fetch_client_contacts_from_notion( | |
| contact_search, | |
| selected_fields=selected_contact_fields, | |
| custom_fields=custom_contact_fields, | |
| ) | |
| if contact_rows: | |
| st.session_state["notion_contacts"] = pd.DataFrame(contact_rows) | |
| status.update(label=f"Loaded {len(contact_rows)} contacts.", state="complete") | |
| elif contact_msg == "Success": | |
| st.session_state["notion_contacts"] = pd.DataFrame(columns=selected_contact_fields + custom_contact_fields) | |
| status.update(label="No contacts found.", state="complete") | |
| else: | |
| status.update(label=f"Failed: {contact_msg}", state="error") | |
| contacts_df = st.session_state.get("notion_contacts") | |
| if isinstance(contacts_df, pd.DataFrame): | |
| contacts_df = contacts_df.fillna("").astype(str) | |
| phone_columns = [col for col in contacts_df.columns if is_phone_like_field(col)] | |
| for col in phone_columns: | |
| contacts_df[col] = contacts_df[col].map(normalize_phone_text) | |
| st.dataframe(contacts_df, use_container_width=True, hide_index=True) | |
| csv_df = contacts_df.copy() | |
| for col in phone_columns: | |
| csv_df[col] = csv_df[col].map(make_excel_safe_phone) | |
| csv_data = csv_df.to_csv(index=False).encode("utf-8-sig") | |
| st.download_button( | |
| "Download CSV", | |
| data=csv_data, | |
| file_name=f"notion_customer_list_{datetime.now().strftime('%Y%m%d_%H%M')}.csv", | |
| mime="text/csv", | |
| use_container_width=True, | |
| key="download_notion_contacts_csv" | |
| ) | |
| with tab_email: | |
| with st.container(border=True): | |
| st.session_state['folder_path'] = st.text_input("📂 G: Drive Folder Path", value=st.session_state['folder_path'], key="fixed_mail_path") | |
| # 🎯 [추가] secrets에서 주소만 쏙 발라내어 멀티 셀렉트 박스 생성 | |
| raw_mailbox_list = list(load_active_mailboxes().values()) | |
| mailbox_options = [] | |
| mailbox_map = {} | |
| for entry in raw_mailbox_list: | |
| entry_clean = entry.strip() | |
| if not entry_clean: continue | |
| clean_email = parse_mailbox_email(entry_clean) | |
| mailbox_options.append(clean_email) | |
| mailbox_map[clean_email] = entry_clean | |
| selected_boxes = st.multiselect("📬 Select Mailboxes to Scan", options=mailbox_options, default=[], key="active_mailboxes_select") | |
| c1, c2, c3, c4 = st.columns([2, 2, 1.5, 1]) | |
| st.session_state['emails'] = c1.text_input("Emails", value=st.session_state['emails'], key="fixed_mail_addrs") | |
| st.session_state['client_names'] = c2.text_input("Names", value=st.session_state['client_names'], key="fixed_mail_names") | |
| st.session_state['refs'] = c3.text_input("Keywords", value=st.session_state['refs'], key="fixed_mail_refs") | |
| email_sync_btn = c4.button("🚀 Sync Emails", use_container_width=True, key="email_sync_trigger") | |
| # ✨ [디자인 혁신] 투박한 안내 문구를 엔터프라이즈급 가이드 대시보드로 변경 | |
| st.markdown(""" | |
| <div style='background-color: #F8FAFC; border: 1px solid #E2E8F0; padding: 14px 18px; border-radius: 8px; margin-top: 15px;'> | |
| <div style='font-size: 0.85rem; font-weight: 700; color: #1E3A8A; margin-bottom: 4px;'>💡 System Usage Instructions</div> | |
| <div style='font-size: 0.8rem; color: #475569; margin-bottom: 12px;'> | |
| Use a semicolon (;) to search multiple items.</b>. | |
| </div> | |
| <div style='display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; border-top: 1px solid #E2E8F0; padding-top: 12px;'> | |
| <div> | |
| <span style='font-size: 0.72rem; font-weight: 800; background-color: #DBEAFE; color: #1E40AF; padding: 3px 6px; border-radius: 4px; letter-spacing: 0.5px;'>EMAILS SCOPE</span> | |
| <div style='font-size: 0.78rem; color: #64748B; margin-top: 6px; line-height: 1.4;'>Scans <b>From / To / CC</b> </div> | |
| </div> | |
| <div> | |
| <span style='font-size: 0.72rem; font-weight: 800; background-color: #E0F2FE; color: #0369A1; padding: 3px 6px; border-radius: 4px; letter-spacing: 0.5px;'>NAMES SCOPE</span> | |
| <div style='font-size: 0.78rem; color: #64748B; margin-top: 6px; line-height: 1.4;'>Scans <b>From / To / CC / Subject</b>.<br><span style='font-size: 0.72rem; color: #94A3B8;'>※ Order-agnostic full AND match</span></div> | |
| </div> | |
| <div> | |
| <span style='font-size: 0.72rem; font-weight: 800; background-color: #F1F5F9; color: #334155; padding: 3px 6px; border-radius: 4px; letter-spacing: 0.5px;'>KEYWORDS SCOPE</span> | |
| <div style='font-size: 0.78rem; color: #64748B; margin-top: 6px; line-height: 1.4;'>Scans <b>Subject / Body</b></div> | |
| </div> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if email_sync_btn: | |
| st.session_state['active_mode'] = "EMAIL" | |
| if not selected_boxes: | |
| st.warning("Please select at least one mailbox to scan.") | |
| st.stop() | |
| # 🟢 [여기에 추가] 이메일 이름을 전역 변수로 확정 박아버림! (여러 명이면 &로 치환) | |
| raw_names = st.session_state.get('client_names', '').strip() | |
| st.session_state['global_client_name'] = raw_names.replace(';', ' & ') if raw_names else '' | |
| with st.status("🔍 Syncing...", expanded=True) as status: | |
| progress_text = st.empty() | |
| progress_text.info("Connecting and scanning emails... ⏳") | |
| if st.session_state['folder_path']: | |
| txt, msg = load_docs_from_path(st.session_state['folder_path']) | |
| st.session_state['doc_context'] = txt if txt else ""; st.write(msg) | |
| all_mails = [] | |
| e_list = [e.strip().lower() for e in st.session_state['emails'].replace(";", ",").split(",") if "@" in e] | |
| name_groups = [[w.strip().lower() for w in n.replace(",", " ").split() if len(w.strip()) > 1] for n in st.session_state['client_names'].split(";")] | |
| ref_list = [r.strip().lower() for r in st.session_state['refs'].replace(";", ",").split(",") if len(r.strip()) > 3] | |
| stats = {"count": 0} | |
| # 🎯 [교체 완료] 전체를 다 도는 대신, 멀티 셀렉트에서 선택된 사서함들만 추출 | |
| target_mailbox_entries = [mailbox_map[m] for m in selected_boxes if m in mailbox_map] | |
| if target_mailbox_entries: | |
| try: | |
| import email.utils | |
| socket.setdefaulttimeout(20) | |
| for entry in target_mailbox_entries: | |
| entry_clean = entry.strip() | |
| if not entry_clean: continue | |
| if "|" in entry_clean: | |
| credential_part, imap_host = entry_clean.split("|", 1) | |
| imap_host = imap_host.replace("|", "").strip() | |
| else: | |
| credential_part = entry_clean | |
| imap_host = "arrow.mxrouting.net" | |
| if ":" in credential_part: | |
| my_mail_addr, custom_pw = credential_part.split(":", 1) | |
| my_mail_addr = my_mail_addr.strip() | |
| target_pw = custom_pw.strip() | |
| else: | |
| my_mail_addr = credential_part.strip() | |
| target_pw = COMMON_PW | |
| status.write(f"🔐 Scanning Mailbox: '{my_mail_addr}' via {imap_host}:993...") | |
| mail = None | |
| last_connect_error = None | |
| connect_errors = [] | |
| for host_candidate in get_imap_host_candidates(imap_host, my_mail_addr): | |
| try: | |
| status.write(f"🌐 Connecting to {host_candidate}:993...") | |
| mail = IMAP4_SSL(host_candidate, 993, timeout=45) | |
| mail.login(my_mail_addr, target_pw) | |
| imap_host = host_candidate | |
| break | |
| except Exception as connect_error: | |
| last_connect_error = connect_error | |
| connect_errors.append(f"{host_candidate}: {connect_error}") | |
| try: | |
| if mail: | |
| mail.logout() | |
| except Exception: | |
| pass | |
| mail = None | |
| if mail is None: | |
| status.write(f"❌ Mailbox failed '{my_mail_addr}': {' | '.join(connect_errors) or last_connect_error}") | |
| continue | |
| try: | |
| res_list, folder_list = mail.list() | |
| all_target_folders = [] | |
| if res_list == "OK": | |
| for f_info in folder_list: | |
| f_str = f_info.decode("utf-8", errors="ignore") | |
| # 🎯 [하위 폴더 공백/특수문자 파싱 버그 완벽 교정] | |
| # 폴더 전체 경로를 찢어 먹지 않고 쌍따옴표 구역을 역추적해 통째로 가져옵니다. | |
| if f_str.endswith('"'): | |
| last_quote = f_str.rfind('"') | |
| prev_quote = f_str.rfind('"', 0, last_quote) | |
| raw_folder_name = f_str[prev_quote+1 : last_quote] | |
| else: | |
| raw_folder_name = f_str.split()[-1].strip().strip('"') | |
| if any(k in raw_folder_name.lower() for k in ["junk", "trash", "deleted", "sync", "spam"]): | |
| continue | |
| if raw_folder_name: | |
| all_target_folders.append(raw_folder_name) | |
| if not all_target_folders: | |
| all_target_folders = ["INBOX", "Sent"] | |
| status.write(f"📂 Found {len(all_target_folders)} folder(s). Scanning latest {MAX_EMAILS_PER_FOLDER} messages per folder.") | |
| for folder in all_target_folders: | |
| try: | |
| mail.select(f'"{folder}"', readonly=True) | |
| res, msg_ids = mail.search(None, "ALL") | |
| matched_ids = msg_ids[0].split()[-MAX_EMAILS_PER_FOLDER:] if res == "OK" and msg_ids and msg_ids[0] else [] | |
| if not matched_ids: | |
| continue | |
| status.write(f"📁 {folder}: checking latest {len(matched_ids)} message(s)") | |
| # 획득한 메일 번호 기반 본문 추출 엔진 정위치 안착 | |
| for m_id in reversed(matched_ids): | |
| stats["count"] += 1 | |
| res_body, body_data = mail.fetch(m_id, "(RFC822)") | |
| if res_body != "OK" or not body_data or not body_data[0]: | |
| continue | |
| msg_raw = email.message_from_bytes(body_data[0][1]) | |
| subj = str(make_header(decode_header(msg_raw.get("Subject", "")))) | |
| snd = str(make_header(decode_header(msg_raw.get("From", "")))) | |
| to_header = str(msg_raw.get("To", "")) | |
| # 💡 대소문자 통합 방어막 구축: Cc든 CC, cc든 대소문자 구분 없이 무조건 포획합니다. | |
| cc_raw = "" | |
| for header_key in msg_raw.keys(): | |
| if header_key.lower() == "cc": | |
| cc_raw = msg_raw.get(header_key, "") | |
| break | |
| cc_header = str(make_header(decode_header(str(cc_raw)))) if cc_raw else "" | |
| body = "" | |
| if msg_raw.is_multipart(): | |
| for b_part in msg_raw.walk(): | |
| if b_part.get_content_type() == "text/plain": | |
| try: | |
| body = b_part.get_payload(decode=True).decode(errors='ignore') | |
| break | |
| except: pass | |
| else: | |
| try: body = msg_raw.get_payload(decode=True).decode(errors='ignore') | |
| except: body = "본문을 읽어올 수 없습니다." | |
| attachments = [] | |
| if msg_raw.is_multipart(): | |
| for b_part in msg_raw.walk(): | |
| filename = b_part.get_filename() | |
| if filename: | |
| decoded_filename = str(make_header(decode_header(filename))) | |
| attachments.append(decoded_filename) | |
| try: | |
| dt = email.utils.parsedate_to_datetime(msg_raw.get("Date")) | |
| except Exception: | |
| dt = datetime.now() | |
| # 🎯 [타임존 변환 버그 교정] 서버 시간(UTC)을 뉴질랜드(Auckland) 현지 시간으로 정확하게 강제 변환합니다. | |
| if dt and dt.tzinfo: | |
| try: | |
| from zoneinfo import ZoneInfo | |
| dt = dt.astimezone(ZoneInfo("Pacific/Auckland")) | |
| except: | |
| dt = dt.astimezone(None) | |
| # 🎯 각 항목별 검색 구역을 완벽하게 분리 | |
| email_area = (snd + " " + to_header + " " + cc_header).lower() | |
| name_area = (subj + " " + snd + " " + to_header + " " + cc_header).lower() | |
| ref_area = (subj + " " + body).lower() | |
| # 1. 이메일 주소는 오직 From, To, CC 영역에서만 매칭 (OR) | |
| email_matched = any(e_addr in email_area for e_addr in e_list) if e_list else False | |
| # 2. 대출 참조 번호는 오직 제목(Subject)과 본문(Body) 영역에서만 매칭 (OR) | |
| ref_matched = any(r_num in ref_area for r_num in ref_list) if ref_list else False | |
| # 3. 이름은 From, To, CC, Subject 영역에서 검사하되, 성과 이름 단어가 '순서 상관없이 모두(AND)' 존재해야 함! | |
| name_matched = False | |
| if name_groups and any(len(g) > 0 for g in name_groups): | |
| for group in name_groups: | |
| # 예: 'misun'과 'lim'이 이름 검사 영역에 동시에 다 들어있어야만 true! (Misun Kim 차단) | |
| if group and all(word in name_area for word in group): | |
| name_matched = True | |
| break | |
| # 4. 세 카테고리(이메일 주소 vs 대출번호 vs 이름) 간의 최종 관계는 완전한 OR | |
| if email_matched or name_matched or ref_matched: | |
| all_mails.append({ | |
| "Date": dt.strftime("%d %b %Y, %I:%M %p"), | |
| "Subject": subj, | |
| "From": snd, | |
| "To": to_header, | |
| "Cc": cc_header, | |
| "Type": "SENT" if any(dom in snd.lower() for dom in ["fluxfinance.co.nz", "ewfinance.co.nz"]) else "RECEIVED", | |
| "Body": body, | |
| "Sec": "-", | |
| "Attach": attachments, | |
| "RawDate": dt.replace(tzinfo=None) | |
| }) | |
| except Exception as folder_error: | |
| status.write(f"⚠️ Skipped folder '{folder}': {folder_error}") | |
| continue | |
| mail.logout() | |
| except Exception as mailbox_error: | |
| status.write(f"❌ Mailbox failed '{my_mail_addr}': {mailbox_error}") | |
| except Exception as e: | |
| st.error(f"IMAP Engine Error: {e}") | |
| if all_mails: | |
| all_mails.sort(key=lambda x: x.get("RawDate", datetime.min)) | |
| df = pd.DataFrame(all_mails).drop_duplicates(subset=['Subject', 'Date']) | |
| st.session_state['results'] = df.reset_index(drop=True) | |
| assigned = st.session_state['results']['Sec'].tolist() | |
| for idx, r in st.session_state['rules_df'].iterrows(): | |
| s1 = str(r.get('Sub 1', '')).strip() | |
| s2 = str(r.get('Sub 2', '')).strip() | |
| has_s1 = s1 not in ["", "nan", "x", "None", "-"] | |
| has_s2 = s2 not in ["", "nan", "x", "None", "-"] | |
| is_s1_mapped = s1 in assigned if has_s1 else False | |
| is_s2_mapped = s2 in assigned if has_s2 else False | |
| if has_s1 and not has_s2: st.session_state['status'][idx] = "🟢" if is_s1_mapped else "⚪" | |
| elif has_s1 and has_s2: | |
| if is_s1_mapped and is_s2_mapped: st.session_state['status'][idx] = "🟢" | |
| elif is_s1_mapped or is_s2_mapped: st.session_state['status'][idx] = "🟠" | |
| else: st.session_state['status'][idx] = "⚪" | |
| else: st.session_state['status'][idx] = "⚪" | |
| status.update(label=f"✅ Scan complete! Found {len(all_mails)} matching email(s).", state="complete", expanded=False) | |
| else: | |
| st.session_state['results'] = None | |
| progress_text.error(f"Checked {stats['count']} emails. No matches found.") | |
| # ───────────────────────────────────────────────────────────────── | |
| # 🎯 [4단계] 통합 결과창 드로잉 레이아웃 | |
| # ───────────────────────────────────────────────────────────────── | |
| if st.session_state['results'] is not None: | |
| update_status() | |
| for idx, row in st.session_state['results'].iterrows(): | |
| # 모드 필터링 | |
| if st.session_state['active_mode'] == "NOTION" and row.get('Type') != "NOTION": continue | |
| if st.session_state['active_mode'] == "EMAIL" and row.get('Type') == "NOTION": continue | |
| with st.container(border=True): | |
| # [1] 타입별 상단 헤더 출력 (유지) | |
| if row['Type'] == "NOTION": | |
| st.markdown(f"<span class='type-tag notion-tag'></span> <b>📌 Stage: {row['Sec']}</b>", | |
| unsafe_allow_html=True | |
| ) | |
| # 🟢 날짜 옆에 Channel 값을 함께 출력합니다. | |
| channel_info = str(row.get('Channel', '-')).strip() | |
| st.markdown(f""" | |
| <div style='font-size: 0.88rem; font-weight: 600; color: #1E3A8A; margin-top: 6px; margin-bottom: 4px;'> | |
| 📅 Completed On: {row['Date']} | 📡 Channel: <span style='color: #059669;'>{channel_info}</span> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| else: | |
| # 1. 태그와 제목 출력 | |
| st.markdown(f"<span class='type-tag {'sent' if row['Type']=='SENT' else 'received'}'>{row['Type']}</span> **{row['Subject']}**", unsafe_allow_html=True) | |
| # 2. 날짜를 상단에 단독 배치 | |
| st.markdown(f"<div style='font-size: 0.88rem; font-weight: 600; color: #1E3A8A; margin-top: 6px; margin-bottom: 4px;'>📅 {row['Date']}</div>", unsafe_allow_html=True) | |
| # 3. From, To, CC 각각 개별 줄바꿈 | |
| cc_value = str(row.get('Cc', '')).strip() | |
| cc_display = cc_value if cc_value and cc_value.lower() != 'nan' else "<span style='color: #94a3b8; font-style: italic;'>None</span>" | |
| st.markdown(f""" | |
| <div style='font-size: 0.85rem; color: #64748b; line-height: 1.6; margin-bottom: 12px;'> | |
| <b>From:</b> {row['From']}<br> | |
| <b>To:</b> {row['To']}<br> | |
| <b>CC:</b> {cc_display} | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # 💡 [두 번째 if NOTION]: 매핑 상태 (배지 vs 드롭다운) 처리 구역 | |
| rules = st.session_state['rules_df'] | |
| rules_list = rules.to_dict('records') | |
| # 🚨 [기능 추가] 웹에서 실시간으로 추가/수정된 모든 단계를 드롭다운 옵션에 자동 반영 | |
| opts = ["-"] | |
| for _, r in rules.iterrows(): | |
| s1 = str(r.get('Sub 1', '')).strip() | |
| s2 = str(r.get('Sub 2', '')).strip() | |
| if s1 and s1 not in ['nan', 'x', '', 'None', '-'] and s1 not in opts: opts.append(s1) | |
| if s2 and s2 not in ['nan', 'x', '', 'None', '-'] and s2 not in opts: opts.append(s2) | |
| # 웹 설정에서 새로 수정한 값이나 기존 값이 opts 배열에 누수되었을 경우를 방지하는 실시간 안전망 | |
| current_sec_val = str(row.get('Sec', '-')).strip() | |
| if current_sec_val not in opts and current_sec_val != "-": | |
| opts.insert(1, current_sec_val) | |
| if row['Type'] == "NOTION": | |
| clean_raw = re.sub(r'^\d+\.\s*', '', str(row['RawTitle'])).strip().lower() | |
| stage_titles = [str(x).strip().lower() for x in rules['Stage Title'].tolist()] | |
| if clean_raw in stage_titles: | |
| # 🟢 [기존 기능 유지] 일치함: 자동 매핑 배지 표시 | |
| matched_stage_idx = stage_titles.index(clean_raw) | |
| matched_stage_name = rules['Stage Title'].iloc[matched_stage_idx] | |
| st.markdown(f""" | |
| <div style='background-color: #F0F9FF; padding: 6px 12px; border-radius: 6px; | |
| border: 1px solid #BAE6FD; font-size: 0.85rem; color: #0369A1; font-weight: 600; margin: 10px 0;'> | |
| ✅ Auto-Mapped: {matched_stage_name} | |
| </div> | |
| """, unsafe_allow_html=True) | |
| else: | |
| # ⚠️ [기존 기능 유지] 일치하지 않음: 수동 할당을 위한 경고 배지 및 드롭다운 활성화 | |
| st.markdown("<div style='font-size: 0.75rem; color: #EF4444; margin-bottom: 4px; font-weight: bold;'>⚠️ Unmapped (Assign Manually)</div>", unsafe_allow_html=True) | |
| st.selectbox("Assign Action", opts, index=opts.index(current_sec_val) if current_sec_val in opts else 0, key=f"sel_{idx}", label_visibility="collapsed") | |
| else: | |
| # 🟢 [기존 기능 유지] 이메일 전용: 수동 할당 드롭다운 | |
| st.selectbox("Assign Action", opts, index=opts.index(current_sec_val) if current_sec_val in opts else 0, key=f"sel_{idx}", label_visibility="collapsed") | |
| # [기존 기능 유지] 본문(Body) 출력 | |
| st.markdown(f"<div class='content-box'>{row.get('Body', '')}</div>", unsafe_allow_html=True) | |
| # 🟢 [기존 기능 유지] 코멘트(Comments)가 있을 경우, 본문 아래에 별도의 말풍선 박스로 예쁘게 렌더링 | |
| comments_data = row.get('Comments', '').strip() | |
| if comments_data: | |
| html_comments = html.escape(comments_data).replace('\n', '<br>') | |
| st.markdown(f""" | |
| <div style='background-color: #FEF3C7; padding: 12px 15px; border-radius: 8px; border: 1px solid #FCD34D; font-size: 0.85rem; margin-top: 12px; color: #92400E; line-height: 1.6;'> | |
| <b>💬 Notion Comments:</b><br><br>{html_comments} | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # [기존 기능 유지] 첨부파일 출력 구역 | |
| if row.get('Attach'): | |
| st.markdown(f"<div class='attach-box'>📎 <b>Attachments:</b> {', '.join(row['Attach'])}</div>", unsafe_allow_html=True) | |