Spaces:
Sleeping
Sleeping
Download app.py from devorah-ai-2016/BeeCareCore: direct link, hf CLI and curl.
- Browser
- Download file 38.4 kB
-
https://huggingface.co/spaces/devorah-ai-2016/BeeCareCore/resolve/main/app.py
- Command line
-
hf download hf://spaces/devorah-ai-2016/BeeCareCore/app.py
-
curl -L -o app.py https://huggingface.co/spaces/devorah-ai-2016/BeeCareCore/resolve/main/app.py
38.4 kB
| import sys | |
| import os | |
| import random | |
| import re | |
| import pandas as pd | |
| import streamlit as st | |
| from datetime import date, datetime, timedelta | |
| from io import BytesIO | |
| from openpyxl import Workbook, load_workbook | |
| from openpyxl.styles import Font, Alignment, Border, Side, PatternFill | |
| from openpyxl.utils import get_column_letter | |
| from openai import OpenAI | |
| # 로컬 db_sync 모듈 | |
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| import db_sync | |
| CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| # ----------------------------- | |
| # Streamlit 기본 설정 & 테마 CSS | |
| # ----------------------------- | |
| st.set_page_config(page_title="BeeCare Core Integration", page_icon="🐝", layout="wide") | |
| st.markdown(""" | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap'); | |
| html, body, [class*="css"], .stApp { | |
| font-family: 'Inter', 'Noto Sans KR', sans-serif; | |
| background-color: #fbf9f5; | |
| color: #1b1c1a; | |
| } | |
| .block-container { | |
| max-width: 1200px; | |
| padding-top: 1.5rem; | |
| padding-bottom: 3rem; | |
| } | |
| .hero { | |
| background: linear-gradient(135deg, #f5e6d3, #efe0cd); | |
| border: 1px solid #ccc3d8; | |
| border-left: 9px solid #2E7D32; | |
| border-radius: 20px; | |
| padding: 28px 32px; | |
| margin-bottom: 24px; | |
| box-shadow: 0px 8px 24px rgba(46, 125, 50, 0.05); | |
| } | |
| .hero .eyebrow { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| background: #2E7D32; | |
| color: #ffffff; | |
| border-radius: 999px; | |
| padding: 4px 12px; | |
| font-size: 13px; | |
| font-weight: 700; | |
| margin-bottom: 12px; | |
| } | |
| .hero h1 { | |
| margin: 0; | |
| font-size: clamp(24px, 3vw, 36px); | |
| line-height: 1.2; | |
| color: #1b5e20; | |
| font-weight: 800; | |
| } | |
| .hero p { | |
| margin: 8px 0 0; | |
| font-size: 15px; | |
| color: #4e483b; | |
| line-height: 1.6; | |
| } | |
| .notice { | |
| background: #ffffff; | |
| border: 1px solid #e4e2de; | |
| border-left: 6px solid #fb8c00; | |
| border-radius: 12px; | |
| padding: 14px 16px; | |
| margin-bottom: 20px; | |
| font-size: 14px; | |
| line-height: 1.6; | |
| color: #4a4455; | |
| } | |
| .section-card { | |
| background: #ffffff; | |
| border: 1px solid #e4e2de; | |
| border-radius: 16px; | |
| padding: 20px; | |
| margin-bottom: 18px; | |
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.01); | |
| } | |
| div[data-testid="stMetric"] { | |
| background: #ffffff; | |
| border: 1px solid #e4e2de; | |
| border-radius: 14px; | |
| padding: 12px; | |
| box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.01); | |
| } | |
| .stButton > button { | |
| border-radius: 10px !important; | |
| background: linear-gradient(135deg, #2E7D32, #4CAF50) !important; | |
| color: #ffffff !important; | |
| font-weight: 700 !important; | |
| border: none !important; | |
| padding: 8px 20px !important; | |
| min-height: 42px !important; | |
| box-shadow: 0px 4px 10px rgba(46, 125, 50, 0.2) !important; | |
| transition: all 0.2s ease-in-out !important; | |
| width: 100% !important; | |
| } | |
| .stButton > button:hover { | |
| transform: translateY(-1px) !important; | |
| box-shadow: 0px 6px 14px rgba(46, 125, 50, 0.3) !important; | |
| } | |
| .stDownloadButton > button { | |
| border-radius: 10px !important; | |
| background: #ffffff !important; | |
| color: #2E7D32 !important; | |
| font-weight: 700 !important; | |
| border: 1.5px solid #2E7D32 !important; | |
| padding: 8px 20px !important; | |
| min-height: 42px !important; | |
| width: 100% !important; | |
| } | |
| .stDownloadButton > button:hover { | |
| background: #e8f5e9 !important; | |
| color: #1b5e20 !important; | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # ----------------------------- | |
| # 데이터 동기화 기동 시 자동 다운로드 | |
| # ----------------------------- | |
| if "db_downloaded" not in st.session_state: | |
| with st.spinner("허깅페이스 DB 데이터 로딩 중..."): | |
| db_sync.download_all() | |
| st.session_state["db_downloaded"] = True | |
| # ----------------------------- | |
| # 엑셀 데이터 파일 입출력 헬퍼 | |
| # ----------------------------- | |
| def load_excel_data(relative_path, default_cols): | |
| local_path = os.path.join(CURRENT_DIR, relative_path) | |
| os.makedirs(os.path.dirname(local_path), exist_ok=True) | |
| if os.path.exists(local_path): | |
| try: | |
| df = pd.read_excel(local_path) | |
| # 결락 컬럼 보완 | |
| for col in default_cols: | |
| if col not in df.columns: | |
| df[col] = "" | |
| return df | |
| except Exception: | |
| pass | |
| df = pd.DataFrame(columns=default_cols) | |
| df.to_excel(local_path, index=False) | |
| return df | |
| def save_excel_data(df, relative_path): | |
| local_path = os.path.join(CURRENT_DIR, relative_path) | |
| os.makedirs(os.path.dirname(local_path), exist_ok=True) | |
| df.to_excel(local_path, index=False) | |
| # ----------------------------- | |
| # 기능별 컬럼 및 옵션 상수 선언 | |
| # ----------------------------- | |
| CLIENT_COLS = [ | |
| "NO.", "수급자이름", "가명표기", "성별", "생년월일", "실질생일날짜", "장기요양등급", "인정번호", | |
| "인정기간시작일", "인정기간종료일", "입소일", "이용상태", "주요질환", "인지상태", "이동상태", | |
| "식사상태", "배변/배뇨상태", "보호자이름", "보호자연락처", "특이사항", "비고", "기관유형", "기관명" | |
| ] | |
| COUNSELING_COLS = [ | |
| "NO.", "수급자이름", "가명표기", "상담일자", "상담자", "상담분류", "상담사유", "상담내용", "조치계획", | |
| "추가욕구요청사항", "사례관리필요", "욕구사정필요", "급여제공계획변경필요", "등록일자" | |
| ] | |
| CASE_MANAGEMENT_COLS = [ | |
| "NO.", "수급자이름", "가명표기", "회의일자", "참석자", "회의분류", "사례선정이유", | |
| "해결방안(1차계획)", "시행과정", "종합결과(2차평가)", "등록일자" | |
| ] | |
| VALID_GENDERS = ["여", "남", "기타"] | |
| VALID_GRADES = ["1등급", "2등급", "3등급", "4등급", "5등급", "인지지원등급", "등급외", "미입력"] | |
| VALID_INST_TYPES = ["방문요양", "방문목욕", "방문간호", "주간보호", "요양원", "공동생활가정"] | |
| VALID_STATUS = ["이용중", "입소중", "퇴소", "중지", "사망"] | |
| def load_clients(): | |
| df = load_excel_data("data/clients.xlsx", CLIENT_COLS) | |
| # 등급 숫자 정규화 | |
| if not df.empty and "장기요양등급" in df.columns: | |
| df["장기요양등급"] = df["장기요양등급"].apply(lambda x: str(x).strip() + "등급" if str(x).strip() in ["1", "2", "3", "4", "5"] else str(x).strip()) | |
| return df | |
| def load_counseling(): | |
| return load_excel_data("data/counseling_logs.xlsx", COUNSELING_COLS) | |
| def load_case_management(): | |
| return load_excel_data("data/case_management.xlsx", CASE_MANAGEMENT_COLS) | |
| def safe(val, default=""): | |
| if pd.isna(val) or val is None: | |
| return default | |
| return str(val).strip() | |
| def mask_name(name): | |
| name = safe(name) | |
| if len(name) >= 3: | |
| return name[0] + "○" + name[2:] | |
| if len(name) == 2: | |
| return name[0] + "○" | |
| return name | |
| # 🎂 생신 알림 기초 도구 | |
| def get_this_year_birthday(birth_date_str, real_birth_date_str): | |
| md = "" | |
| r_val = safe(real_birth_date_str) | |
| if r_val and re.match(r"^\d{2}-\d{2}$", r_val): | |
| md = r_val | |
| if not md: | |
| b_val = safe(birth_date_str) | |
| if re.match(r"^\d{4}-\d{2}-\d{2}$", b_val): | |
| md = b_val[5:10] | |
| if not md: | |
| return None | |
| try: | |
| m, d = map(int, md.split("-")) | |
| return date(datetime.now().year, m, d) | |
| except Exception: | |
| return None | |
| def display_birthday_alerts(df): | |
| if df.empty: | |
| return | |
| today = date.today() | |
| this_month = today.month | |
| today_birthdays = [] | |
| month_birthdays = [] | |
| for _, row in df.iterrows(): | |
| name_masked = mask_name(row.get("수급자이름")) | |
| bday = get_this_year_birthday(row.get("생년월일"), row.get("실질생일날짜")) | |
| if bday: | |
| if bday.month == today.month and bday.day == today.day: | |
| today_birthdays.append(f"**{name_masked} 어르신**" + (f" (실질생일: {row.get('실질생일날짜')})" if row.get('실질생일날짜') else "")) | |
| elif bday.month == this_month: | |
| month_birthdays.append(f"**{name_masked} 어르신** ({bday.day}일 - " + (f"실질: {row.get('실질생일날짜')}" if row.get('실질생일날짜') else f"생년월일: {row.get('생년월일')[5:10] if row.get('생년월일') else ''}") + ")") | |
| if today_birthdays or month_birthdays: | |
| st.markdown(f"### 🎂 {this_month}월 생신 알림") | |
| if today_birthdays: | |
| st.markdown(f""" | |
| <div style="background: #fff3e0; border-left: 6px solid #fb8c00; padding: 12px; border-radius: 10px; margin-bottom: 12px; color: #222;"> | |
| 🎉 <strong>오늘 생신!</strong> 오늘은 {', '.join(today_birthdays)}의 생신입니다. 축하드립니다! 🎂 | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if month_birthdays: | |
| items = "".join([f"<li>{item}</li>" for item in month_birthdays]) | |
| st.markdown(f""" | |
| <div style="background: #e8f5e9; border-left: 6px solid #43a047; padding: 12px; border-radius: 10px; margin-bottom: 12px; color: #222;"> | |
| 🎁 <strong>{this_month}월 생신 어르신 목록:</strong> | |
| <ul style="margin: 6px 0 0 16px; padding: 0;">{items}</ul> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # 🤖 AI 전문 소견 API 연동부 | |
| def generate_ai_opinion(system_prompt, user_prompt, client_name, reason, content): | |
| # API 키를 사이드바 혹은 환경 변수에서 가져옵니다 | |
| api_key = st.sidebar.text_input("OpenAI API Key (선택)", value=os.environ.get("OPENAI_API_KEY", ""), type="password") | |
| use_lm_studio = st.sidebar.toggle("LM Studio 사용 (Qwen)", value=False) | |
| if use_lm_studio: | |
| try: | |
| ai_client = OpenAI(base_url="http://127.0.0.1:1234/v1", api_key="lm-studio") | |
| completion = ai_client.chat.completions.create( | |
| model="qwen2.5-7b-instruct", | |
| messages=[ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": user_prompt} | |
| ], | |
| temperature=0.7 | |
| ) | |
| return completion.choices[0].message.content.strip() | |
| except Exception as e: | |
| st.sidebar.error(f"LM Studio 연결 실패: {e}") | |
| if api_key.strip(): | |
| try: | |
| ai_client = OpenAI(api_key=api_key) | |
| completion = ai_client.chat.completions.create( | |
| model="gpt-4o-mini", | |
| messages=[ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": user_prompt} | |
| ], | |
| temperature=0.5 | |
| ) | |
| return completion.choices[0].message.content.strip() | |
| except Exception as e: | |
| st.sidebar.error(f"OpenAI API 호출 실패: {e}") | |
| # Fallback Rule-based (룰 기반 대체 텍스트) | |
| return ( | |
| f"1. {client_name} 어르신의 금번 상담 사유인 '{reason}'에 따라 수급자의 건강 및 생활 패턴 변화를 세밀하게 관찰하였습니다.\n" | |
| f"2. {content[:30]}...의 입력을 바탕으로 관찰한 결과, 일상 생활 지원 및 신체 활동 관리에 적극적인 개입이 요망됩니다.\n" | |
| f"3. 작성된 조치계획의 충실한 이행 여부를 정기 검토하고 상태 이상 징후 확인 시 즉각 보호자 및 유관 직종과 공유하겠습니다." | |
| ) | |
| # ----------------------------- | |
| # 공통 레이아웃 구성 | |
| # ----------------------------- | |
| st.markdown(""" | |
| <div class="hero"> | |
| <div class="eyebrow">BeeCare Core v1.0</div> | |
| <h1>🐝 BeeCare Core 통합 연동 문서시스템</h1> | |
| <p>수급자등록, 상담일지, 사례관리를 하나의 앱과 데이터베이스 환경으로 완전 연동하여 실시간 공유를 보장합니다.</p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # 🔄 사이드바 동기화 제어판 | |
| st.sidebar.subheader("🔄 실시간 동기화 제어") | |
| if st.sidebar.button("허깅페이스에서 DB 다운로드"): | |
| with st.spinner("다운로드 중..."): | |
| if db_sync.download_all(): | |
| st.sidebar.success("성공적으로 다운로드되었습니다!") | |
| st.rerun() | |
| else: | |
| st.sidebar.error("다운로드 실패!") | |
| if st.sidebar.button("허깅페이스에 DB 업로드"): | |
| with st.spinner("업로드 중..."): | |
| if db_sync.upload_all(): | |
| st.sidebar.success("성공적으로 업로드되었습니다!") | |
| else: | |
| st.sidebar.error("업로드 실패!") | |
| # ----------------------------- | |
| # 탭 정의 (1차 통합) | |
| # ----------------------------- | |
| tab1, tab2, tab3 = st.tabs(["👥 수급자 등록 · 관리", "📝 상담일지 작성", "📋 사례관리 회의록"]) | |
| # ========================================== | |
| # 탭 1: 수급자 등록 | |
| # ========================================== | |
| with tab1: | |
| st.subheader("👥 수급자 등록 및 기초 데이터 관리") | |
| df_clients = load_clients() | |
| display_birthday_alerts(df_clients) | |
| sub_menu = st.radio("수급자 관리 메뉴", ["수급자 직접 등록", "엑셀 일괄 업로드", "등록된 수급자 목록"], horizontal=True) | |
| if sub_menu == "수급자 직접 등록": | |
| with st.form("client_register_form"): | |
| st.markdown("##### 👤 기본 정보 입력") | |
| c1, c2, c3 = st.columns(3) | |
| name = c1.text_input("수급자 이름") | |
| gender = c2.selectbox("성별", VALID_GENDERS) | |
| birth_date = c3.text_input("생년월일 (YYYY-MM-DD)", placeholder="예: 1942-09-20") | |
| c4, c5, c6 = st.columns(3) | |
| real_birth_date = c4.text_input("실질생일날짜 (MM-DD)", placeholder="예: 09-20 (음력 등)") | |
| care_grade = c5.selectbox("장기요양등급", VALID_GRADES) | |
| rec_num = c6.text_input("인정번호", placeholder="숫자만 입력해도 L 자동 결합") | |
| c7, c8, c9 = st.columns(3) | |
| rec_start = c7.text_input("인정기간 시작일 (YYYY-MM-DD)", placeholder="예: 2025-03-03") | |
| rec_end = c8.text_input("인정기간 종료일 (YYYY-MM-DD)", placeholder="예: 2027-03-02") | |
| admission_date = c9.text_input("입소일/이용시작일 (YYYY-MM-DD)", placeholder="예: 2025-03-05") | |
| c10, c11, c12 = st.columns(3) | |
| inst_type = c10.selectbox("기관 유형", VALID_INST_TYPES) | |
| inst_name = c11.text_input("기관명") | |
| status = c12.selectbox("이용 상태", VALID_STATUS) | |
| st.markdown("##### 🏥 어르신 상태 및 케어 조건") | |
| cc1, cc2, cc3 = st.columns(3) | |
| main_cond = cc1.text_area("주요 질환 / 상태", height=80) | |
| cog_stat = cc2.text_area("인지 상태", height=80) | |
| mob_stat = cc3.text_area("이동 상태", height=80) | |
| cc4, cc5, cc6 = st.columns(3) | |
| diet_stat = cc4.text_area("식사 상태", height=80) | |
| toil_stat = cc5.text_area("배변/배뇨 상태", height=80) | |
| notes = cc6.text_area("특이사항 / 메모", height=80) | |
| st.markdown("##### 👥 보호자 정보") | |
| ccc1, ccc2, ccc3 = st.columns(3) | |
| guard_name = ccc1.text_input("보호자 성함") | |
| guard_phone = ccc2.text_input("보호자 연락처") | |
| remarks = ccc3.text_input("비고") | |
| submit = st.form_submit_button("수급자 정보 저장 및 동기화") | |
| if submit: | |
| if not name.strip(): | |
| st.error("수급자 이름은 필수 입력 항목입니다.") | |
| else: | |
| # 인정번호 L 포맷 정리 | |
| rec_num_clean = rec_num.replace(" ", "").upper() | |
| if rec_num_clean and not rec_num_clean.startswith("L"): | |
| rec_num_clean = "L" + re.sub(r"[^0-9]", "", rec_num_clean) | |
| new_no = len(df_clients) + 1 | |
| new_row = { | |
| "NO.": new_no, | |
| "수급자이름": name.strip(), | |
| "가명표기": mask_name(name), | |
| "성별": gender, | |
| "생년월일": birth_date.strip(), | |
| "실질생일날짜": real_birth_date.strip(), | |
| "장기요양등급": care_grade, | |
| "인정번호": rec_num_clean, | |
| "인정기간시작일": rec_start.strip(), | |
| "인정기간종료일": rec_end.strip(), | |
| "입소일": admission_date.strip(), | |
| "이용상태": status, | |
| "주요질환": main_cond.strip(), | |
| "인지상태": cog_stat.strip(), | |
| "이동상태": mob_stat.strip(), | |
| "식사상태": diet_stat.strip(), | |
| "배변/배뇨상태": toil_stat.strip(), | |
| "보호자이름": guard_name.strip(), | |
| "보호자연락처": guard_phone.strip(), | |
| "특이사항": notes.strip(), | |
| "비고": remarks.strip(), | |
| "기관유형": inst_type, | |
| "기관명": inst_name.strip() | |
| } | |
| df_clients = pd.concat([df_clients, pd.DataFrame([new_row])], ignore_index=True) | |
| save_excel_data(df_clients, "data/clients.xlsx") | |
| db_sync.upload_all() | |
| st.success(f"{name} 어르신의 정보가 등록 및 동기화 완료되었습니다!") | |
| st.rerun() | |
| elif sub_menu == "엑셀 일괄 업로드": | |
| st.info("기존에 제공된 수급자 등록 양식과 동일한 구조의 엑셀 파일을 업로드해 주세요.") | |
| uploaded_file = st.file_uploader("수급자 엑셀 파일 선택", type=["xlsx"]) | |
| overwrite = st.checkbox("중복 이름이 있을 경우 기존 데이터 덮어쓰기", value=True) | |
| if uploaded_file: | |
| try: | |
| uploaded_df = pd.read_excel(uploaded_file) | |
| # 정규화 및 바인딩 | |
| st.dataframe(uploaded_df.head(3), use_container_width=True) | |
| if st.button("데이터 업로드 실행"): | |
| # 기존 데이터에 결합 로직 구현 | |
| for _, row in uploaded_df.iterrows(): | |
| u_name = safe(row.get("수급자이름")) | |
| if not u_name: | |
| continue | |
| # 등급 정규화 | |
| grade_val = safe(row.get("장기요양등급")) | |
| if grade_val in ["1", "2", "3", "4", "5"]: | |
| grade_val += "등급" | |
| if grade_val not in VALID_GRADES: | |
| grade_val = "미입력" | |
| # 인정번호 L 정규화 | |
| rec_raw = safe(row.get("인정번호")).upper() | |
| if rec_raw and not rec_raw.startswith("L"): | |
| rec_raw = "L" + re.sub(r"[^0-9]", "", rec_raw) | |
| # 중복 여부 확인 | |
| duplicate_mask = df_clients["수급자이름"] == u_name | |
| row_data = { | |
| "NO.": len(df_clients) + 1 if not duplicate_mask.any() else df_clients.loc[duplicate_mask, "NO."].values[0], | |
| "수급자이름": u_name, | |
| "가명표기": mask_name(u_name), | |
| "성별": safe(row.get("성별")) if safe(row.get("성별")) in VALID_GENDERS else "여", | |
| "생년월일": safe(row.get("생년월일")), | |
| "실질생일날짜": safe(row.get("실질생일날짜")), | |
| "장기요양등급": grade_val, | |
| "인정번호": rec_raw, | |
| "인정기간시작일": safe(row.get("인정기간시작일")), | |
| "인정기간종료일": safe(row.get("인정기간종료일")), | |
| "입소일": safe(row.get("입소일")), | |
| "이용상태": safe(row.get("이용상태")) if safe(row.get("이용상태")) in VALID_STATUS else "이용중", | |
| "주요질환": safe(row.get("주요질환")), | |
| "인지상태": safe(row.get("인지상태")), | |
| "이동상태": safe(row.get("이동상태")), | |
| "식사상태": safe(row.get("식사상태")), | |
| "배변/배뇨상태": safe(row.get("배변/배뇨상태")), | |
| "보호자이름": safe(row.get("보호자이름")), | |
| "보호자연락처": safe(row.get("보호자연락처")), | |
| "특이사항": safe(row.get("특이사항")), | |
| "비고": safe(row.get("비고")), | |
| "기관유형": safe(row.get("기관유형")) if safe(row.get("기관유형")) in VALID_INST_TYPES else "방문요양", | |
| "기관명": safe(row.get("기관명")) | |
| } | |
| if duplicate_mask.any() and overwrite: | |
| df_clients.loc[duplicate_mask] = list(row_data.values()) | |
| else: | |
| df_clients = pd.concat([df_clients, pd.DataFrame([row_data])], ignore_index=True) | |
| # 번호 재정렬 | |
| df_clients["NO."] = range(1, len(df_clients) + 1) | |
| save_excel_data(df_clients, "data/clients.xlsx") | |
| db_sync.upload_all() | |
| st.success("엑셀 데이터 업로드 및 실시간 동기화가 완료되었습니다!") | |
| st.rerun() | |
| except Exception as e: | |
| st.error(f"엑셀 처리 중 오류 발생: {e}") | |
| elif sub_menu == "등록된 수급자 목록": | |
| if df_clients.empty: | |
| st.info("등록된 수급자 정보가 없습니다.") | |
| else: | |
| st.dataframe(df_clients, use_container_width=True, hide_index=True) | |
| # ========================================== | |
| # 탭 2: 상담일지 | |
| # ========================================== | |
| with tab2: | |
| st.subheader("📝 연동 상담일지 작성") | |
| df_clients = load_clients() | |
| df_counseling = load_counseling() | |
| if df_clients.empty: | |
| st.warning("먼저 '수급자 등록' 탭에서 수급자 기본 정보를 1명 이상 등록해 주세요.") | |
| else: | |
| st.markdown("##### 1단계: 상담 대상 어르신 선택") | |
| client_options = df_clients.apply( | |
| lambda x: f"{x['NO.']}번 - {x['가명표기']} / {x['장기요양등급']} / {x['기관유형']} / {x['이용상태']}", | |
| axis=1 | |
| ).tolist() | |
| selected_client_opt = st.selectbox("수급자 선택", client_options, key="counsel_client_select") | |
| selected_no = int(selected_client_opt.split("번")[0]) | |
| client_row = df_clients[df_clients["NO."] == selected_no].iloc[0] | |
| st.markdown("---") | |
| st.markdown("##### 2단계: 상담 상세 내용 기록") | |
| with st.form("counseling_form"): | |
| col_c1, col_c2, col_c3 = st.columns(3) | |
| c_name = col_c1.text_input("수급자 성명", value=safe(client_row["수급자이름"]), disabled=True) | |
| c_masked = col_c2.text_input("가명 표기", value=safe(client_row["가명표기"]), disabled=True) | |
| c_date = col_c3.date_input("상담일자", value=date.today()) | |
| col_c4, col_c5 = st.columns(2) | |
| c_writer = col_c4.text_input("상담자", value="사회복지사") | |
| c_type = col_c5.selectbox("상담분류", ["수급자상담", "보호자상담", "종사자상담", "기타상담"]) | |
| c_reason = st.selectbox("상담사유", [ | |
| "정기상담", "상태변화에 따른 상담", "수급자 요청", "보호자 요청", "요양보호사 건의", "응급조치 피드백" | |
| ]) | |
| c_content = st.text_area("상담내용", placeholder="상담한 핵심 내용을 육하원칙에 의거해 기록해 주세요.", height=120) | |
| c_plan = st.text_area("조치계획", placeholder="상담 이후 어르신에게 제공될 조치나 조율사항을 적어주세요.", height=80) | |
| c_needs = st.text_area("추가욕구요청사항", placeholder="추가로 전달받은 서비스 요구나 보호자의 개선 요청 등을 상세히 입력해 주세요.", height=80) | |
| st.markdown("##### ⚠️ 후속 업무 연동 지정") | |
| st.info("상담 결과에 따라 연동 처리가 필요한 업무를 체크해 주세요. 탭 3(사례관리)은 '사례관리필요'가 활성화된 어르신만 연동됩니다.") | |
| col_cb1, col_cb2, col_cb3 = st.columns(3) | |
| need_case = col_cb1.checkbox("사례관리 회의 회부 필요", value=False) | |
| need_needs = col_cb2.checkbox("욕구사정 재평가 필요", value=False) | |
| need_careplan = col_cb3.checkbox("급여제공계획 변경/재작성 필요", value=False) | |
| st.markdown("##### 🤖 AI 소견 생성") | |
| st.caption("작성된 내용을 바탕으로 AI가 전문적인 요약을 생성합니다. (임의의 증상/상태는 지어내지 않습니다)") | |
| ai_opinion = "" | |
| if st.form_submit_button("🤖 AI 총평 임시 생성"): | |
| if not c_content.strip(): | |
| st.error("상담내용을 입력해야 AI 총평을 구성할 수 있습니다.") | |
| else: | |
| sys_prompt = ( | |
| "당신은 장기요양기관 평가와 상담기록 작성에 숙련된 20년 경력의 사회복지사입니다. " | |
| "평가 기준에 부합하게 핵심이 일목요연하고 명료한 종합소견으로 정리해 주십시오." | |
| ) | |
| user_prompt = f""" | |
| [수급자 정보] | |
| 이름: {c_name} (가명: {c_masked}) | |
| 장기요양등급: {safe(client_row["장기요양등급"])} | |
| [상담 기록] | |
| 상담사유: {c_reason} | |
| 상담내용: {c_content} | |
| 조치계획: {c_plan} | |
| 추가욕구요청사항: {c_needs} | |
| [주의사항/제약] | |
| - 상담 내용과 조치 계획에 기재되지 않은 사실(예: 낙상 흔적, 욕창 발생, 침해 행동, 기저귀 젖음 등)을 AI가 독단적으로 지어내거나 추정하여 기술하지 마십시오. | |
| - 오직 입력된 정보만을 바탕으로 사회복지사 종합 소견 형식의 문장들로 1., 2., 3. 번호 형태로 정리해주십시오. | |
| """ | |
| ai_opinion = generate_ai_opinion(sys_prompt, user_prompt, c_name, c_reason, c_content) | |
| st.session_state["temp_ai_counsel"] = ai_opinion | |
| final_opinion = st.text_area("종합소견 (직접 보완 가능)", value=st.session_state.get("temp_ai_counsel", ""), height=120) | |
| save_counsel = st.form_submit_button("📝 상담일지 저장 및 허깅페이스 연동") | |
| if save_counsel: | |
| if not c_content.strip(): | |
| st.error("상담내용을 입력해 주세요.") | |
| else: | |
| new_no = len(df_counseling) + 1 | |
| new_row = { | |
| "NO.": new_no, | |
| "수급자이름": c_name, | |
| "가명표기": c_masked, | |
| "상담일자": str(c_date), | |
| "상담자": c_writer.strip(), | |
| "상담분류": c_type, | |
| "상담사유": c_reason, | |
| "상담내용": c_content.strip(), | |
| "조치계획": c_plan.strip() if c_plan.strip() else final_opinion.strip(), | |
| "추가욕구요청사항": c_needs.strip(), | |
| "사례관리필요": "True" if need_case else "False", | |
| "욕구사정필요": "True" if need_needs else "False", | |
| "급여제공계획변경필요": "True" if need_careplan else "False", | |
| "등록일자": datetime.now().strftime("%Y-%m-%d %H:%M:%S") | |
| } | |
| df_counseling = pd.concat([df_counseling, pd.DataFrame([new_row])], ignore_index=True) | |
| save_excel_data(df_counseling, "data/counseling_logs.xlsx") | |
| db_sync.upload_all() | |
| # 세션 템플릿 비우기 | |
| if "temp_ai_counsel" in st.session_state: | |
| del st.session_state["temp_ai_counsel"] | |
| st.success("상담일지 저장이 완료되었으며 실시간 업로드 동기화가 끝났습니다!") | |
| st.rerun() | |
| st.markdown("---") | |
| st.markdown("##### 📂 등록된 상담일지 목록") | |
| if df_counseling.empty: | |
| st.info("작성된 상담일지 내역이 없습니다.") | |
| else: | |
| st.dataframe(df_counseling, use_container_width=True, hide_index=True) | |
| # ========================================== | |
| # 탭 3: 사례관리 | |
| # ========================================== | |
| with tab3: | |
| st.subheader("📋 사례관리 회의록 작성 및 연동 관리") | |
| df_counseling = load_counseling() | |
| df_case = load_case_management() | |
| # ⚠️ 연동 규칙 필터링 | |
| # 상담일지에서 사례관리필요 == True 이거나 추가욕구요청사항에 "사례관리" 단어가 포함된 것만 추출 | |
| qualified_clients = [] | |
| if not df_counseling.empty: | |
| # 문자열로 변환하여 안전하게 필터링 | |
| df_counseling["사례관리필요"] = df_counseling["사례관리필요"].astype(str) | |
| df_counseling["추가욕구요청사항"] = df_counseling["추가욕구요청사항"].fillna("") | |
| filtered_df = df_counseling[ | |
| (df_counseling["사례관리필요"] == "True") | | |
| (df_counseling["추가욕구요청사항"].str.contains("사례관리", case=False)) | |
| ] | |
| if not filtered_df.empty: | |
| qualified_clients = filtered_df["수급자이름"].unique().tolist() | |
| if not qualified_clients: | |
| st.warning("현재 상담일지에서 '사례관리 필요'로 선별되거나 '추가욕구요청사항'에 '사례관리'가 지정된 연동 대상 어르신이 없습니다.") | |
| st.info("상담일지 작성 시 후속 업무 연동에서 '사례관리 회의 회부 필요'를 체크하시면 이곳에 자동으로 등록됩니다.") | |
| else: | |
| st.markdown("##### 🔗 상담 연동 사례관리 대상자 선택") | |
| selected_case_name = st.selectbox("사례관리 대상 어르신 선택", qualified_clients, key="case_client_select") | |
| # 해당 어르신의 최근 연동 상담 내역 요약 노출 | |
| ref_counsel_logs = df_counseling[df_counseling["수급자이름"] == selected_case_name].sort_values(by="상담일자", ascending=False) | |
| latest_counsel = ref_counsel_logs.iloc[0] | |
| st.markdown(f""" | |
| <div style="background: #e1f5fe; border-left: 6px solid #03a9f4; padding: 12px; border-radius: 10px; margin-bottom: 12px; color: #222;"> | |
| 📝 <strong>연동된 최근 상담 정보 ({latest_counsel['상담일자']}):</strong><br> | |
| • <strong>상담사유:</strong> {latest_counsel['상담사유']}<br> | |
| • <strong>상담내용:</strong> {latest_counsel['상담내용']}<br> | |
| • <strong>추가욕구/요청:</strong> {latest_counsel['추가욕구요청사항']} | |
| </div> | |
| """, unsafe_allow_html=True) | |
| with st.form("case_management_form"): | |
| col_m1, col_m2 = st.columns(2) | |
| m_name = col_m1.text_input("수급자 성명", value=selected_case_name, disabled=True) | |
| m_masked = col_m2.text_input("가명 표기", value=safe(latest_counsel["가명표기"]), disabled=True) | |
| col_m3, col_m4, col_m5 = st.columns(3) | |
| m_date = col_m3.date_input("회의 일자", value=date.today()) | |
| m_participants = col_m4.text_input("참석자", value="시설장, 사회복지사, 간호조무사, 요양보호사") | |
| m_type = col_m5.selectbox("회의 구분", ["정기 사례회의", "응급 사례회의", "수시 사례회의"]) | |
| m_reason = st.text_area("사례 선정 이유", value=f"상담기록 연동에 의거, 상담 사유 '{latest_counsel['상담사유']}' 및 추가 요청 '{latest_counsel['추가욕구요청사항']}' 해결을 위한 조율이 시급하여 사례회의 대상자로 선정함.", height=70) | |
| st.markdown("##### 📝 회의 핵심 기록") | |
| m_first_plan = st.text_area("해결 방안 및 1차 계획", placeholder="회의를 통해 결정된 구체적인 실행 계획을 작성해 주세요.", height=90) | |
| m_process = st.text_area("시행 과정 기록", placeholder="해결 방안 적용 후 실행 과정상의 피드백이나 특이사항을 입력해 주세요.", height=90) | |
| m_result = st.text_area("종합 결과 및 2차 평가", placeholder="회의 결과 평가 및 향후 반영할 급여 내용을 작성해 주세요.", height=90) | |
| st.markdown("##### 🤖 AI 전문 소견 생성") | |
| st.caption("입력된 구체적인 내용에만 한정하여 AI가 사례회의 종합 의견을 생성합니다.") | |
| ai_case_opinion = "" | |
| if st.form_submit_button("🤖 AI 종합 의견 임시 생성"): | |
| if not m_first_plan.strip(): | |
| st.error("1차 계획 및 해결방안을 입력해야 종합 소견 작성이 가능합니다.") | |
| else: | |
| sys_prompt = ( | |
| "당신은 장기요양기관 평가에 정통한 노인복지 전문가이자 사회복지사입니다. " | |
| "사례관리 회의록의 결과를 평가 기준에 부합하게 명료한 문장들로 정리해 주십시오." | |
| ) | |
| user_prompt = f""" | |
| [수급자] | |
| 이름: {m_name} (가명: {m_masked}) | |
| [사례관리 기록] | |
| 선정이유: {m_reason} | |
| 해결방안 및 1차계획: {m_first_plan} | |
| 시행과정: {m_process} | |
| 종합결과(2차평가): {m_result} | |
| [제약조건] | |
| - 절대로 입력된 내용 이외에 다른 병증 상태나 응급 낙상 사건 등을 거짓으로 지어내지 마십시오. | |
| - 오직 입력된 문맥에 의거하여 종합적인 사례회의 소견을 1., 2., 3. 번호 형태로 정리해주십시오. | |
| """ | |
| ai_case_opinion = generate_ai_opinion(sys_prompt, user_prompt, m_name, "사례회의", m_first_plan) | |
| st.session_state["temp_ai_case"] = ai_case_opinion | |
| final_case_opinion = st.text_area("사례회의 종합의견 보완", value=st.session_state.get("temp_ai_case", ""), height=100) | |
| save_case = st.form_submit_button("📋 사례관리 회의록 저장 및 허깅페이스 연동") | |
| if save_case: | |
| if not m_first_plan.strip(): | |
| st.error("해결방안(1차계획)을 입력해 주세요.") | |
| else: | |
| new_no = len(df_case) + 1 | |
| new_row = { | |
| "NO.": new_no, | |
| "수급자이름": m_name, | |
| "가명표기": m_masked, | |
| "회의일자": str(m_date), | |
| "참석자": m_participants.strip(), | |
| "회의분류": m_type, | |
| "사례선정이유": m_reason.strip(), | |
| "해결방안(1차계획)": m_first_plan.strip(), | |
| "시행과정": m_process.strip(), | |
| "종합결과(2차평가)": m_result.strip() if m_result.strip() else final_case_opinion.strip(), | |
| "등록일자": datetime.now().strftime("%Y-%m-%d %H:%M:%S") | |
| } | |
| df_case = pd.concat([df_case, pd.DataFrame([new_row])], ignore_index=True) | |
| save_excel_data(df_case, "data/case_management.xlsx") | |
| db_sync.upload_all() | |
| if "temp_ai_case" in st.session_state: | |
| del st.session_state["temp_ai_case"] | |
| st.success("사례관리 회의록 저장이 완료되었으며 실시간 업로드 동기화가 끝났습니다!") | |
| st.rerun() | |
| st.markdown("---") | |
| st.markdown("##### 📂 등록된 사례관리 회의록 목록") | |
| if df_case.empty: | |
| st.info("작성된 사례관리 회의록 내역이 없습니다.") | |
| else: | |
| st.dataframe(df_case, use_container_width=True, hide_index=True) | |