import os import sqlite3 from io import BytesIO from datetime import date, datetime import pandas as pd import streamlit as st from openpyxl import Workbook from openpyxl.styles import Font, Alignment, Border, Side, PatternFill from openai import OpenAI # ========================================================= # BeeCare Client Center 연동형 욕구사정 + 결과평가 생성기 # ========================================================= st.set_page_config( page_title="욕구사정기록지", page_icon="🏥", layout="wide" ) # ----------------------------- # DB 경로 설정 # ----------------------------- CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) DB_CANDIDATES = [ os.path.join(CURRENT_DIR, "data", "clients.db"), os.path.join(CURRENT_DIR, "..", "Client Center", "data", "clients.db"), os.path.join(CURRENT_DIR, "..", "ClientCenter", "data", "clients.db"), os.path.join(CURRENT_DIR, "..", "Client_Center", "data", "clients.db"), ] DB_PATH = None for path in DB_CANDIDATES: if os.path.exists(path): DB_PATH = path break # ----------------------------- # AI 연결 설정 # ----------------------------- USE_LM_STUDIO = st.sidebar.toggle("LM Studio / Qwen 사용", value=False) client = None if USE_LM_STUDIO: try: client = OpenAI(base_url="http://127.0.0.1:1234/v1", api_key="lm-studio") except Exception: client = None def get_ai_content(system_prompt: str, user_prompt: str) -> str: """LM Studio 연결이 안 되면 기본 문장을 생성합니다.""" if not USE_LM_STUDIO or client is None: return generate_rule_based_text(user_prompt) try: completion = 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 except Exception: return generate_rule_based_text(user_prompt) def generate_rule_based_text(prompt: str) -> str: """AI 연결 없이도 기본 문서가 나오도록 하는 안전 장치""" if "결과평가" in prompt or "30일" in prompt: return ( "1. 30일간 급여제공 후 일상생활 지원과 정서적 지지 영역에서 일부 긍정적인 변화가 관찰되었습니다.\n" "2. 신체기능 유지를 위해 이동 보조, 자세변경, 안전관찰을 지속적으로 제공할 필요가 있습니다.\n" "3. 낙상예방과 인지기능 저하 예방을 위해 환경정비와 반복적인 안내가 필요합니다.\n" "4. 향후 급여제공계획에 따라 개인별 상태에 맞춘 지원을 유지하고, 변화 발생 시 재사정을 실시하겠습니다." ) return ( "1. 신체기능과 일상생활 수행능력 전반에서 지속적인 관찰과 지원이 필요합니다.\n" "2. 안전한 이동 지원, 식사 및 배변 관리, 정서적 지지, 인지기능 유지 활동을 제공할 필요가 있습니다.\n" "3. 낙상예방과 관절구축 예방을 위해 자세변경, 가벼운 관절운동, 환경정비가 필요합니다.\n" "4. 수급자의 상태 변화가 확인될 경우 급여제공계획에 반영하여 재사정합니다." ) # ----------------------------- # DB 함수 # ----------------------------- def load_clients() -> pd.DataFrame: if DB_PATH is None: return pd.DataFrame() try: conn = sqlite3.connect(DB_PATH) # Client Center 버전이 달라도 오류가 나지 않도록 실제 존재하는 컬럼만 읽습니다. available_cols = pd.read_sql_query("PRAGMA table_info(clients)", conn)["name"].tolist() wanted_cols = [ "id", "name", "masked_name", "gender", "birth_year", "care_grade", "recognition_number", "recognition_start_date", "recognition_end_date", "institution_type", "institution_name", "admission_date", "main_condition", "cognitive_status", "mobility_status", "diet_status", "toileting_status", "guardian_name", "guardian_phone", "notes", "status" ] select_cols = [c for c in wanted_cols if c in available_cols] if not select_cols: conn.close() return pd.DataFrame() query = "SELECT " + ", ".join(select_cols) + " FROM clients ORDER BY id DESC LIMIT 300" df = pd.read_sql_query(query, conn) conn.close() # 없는 컬럼은 빈칸으로 채워 다른 입력칸과 매칭되게 합니다. for col in wanted_cols: if col not in df.columns: df[col] = "" return df except Exception as e: st.error(f"Client Center DB를 불러오는 중 오류가 발생했습니다: {e}") return pd.DataFrame() def safe_value(row, key, default=""): try: value = row.get(key, default) if pd.isna(value): return default return str(value) except Exception: return default def format_numbered_text(text: str) -> str: """총평과 결과평가를 번호형으로 정돈합니다.""" text = (text or "").strip() if not text: return "" lines = [line.strip() for line in text.splitlines() if line.strip()] if len(lines) >= 2 and all(line[:2].replace('.', '').isdigit() or line[0].isdigit() for line in lines[:2]): return "\n".join(lines) # 문장 단위로 나누어 1, 2, 3번 형태로 정리 import re sentences = re.split(r'(?<=[.!?。]|다\.)\s+', text.replace("\n", " ")) sentences = [s.strip() for s in sentences if s.strip()] cleaned = [] for s in sentences: s = re.sub(r'^\d+[.)]\s*', '', s).strip() if s: cleaned.append(s) return "\n".join([f"{i}. {sentence}" for i, sentence in enumerate(cleaned[:6], start=1)]) # ----------------------------- # 욕구사정 품질 고도화 엔진 # ----------------------------- RESTRICTED_REPLACEMENTS = { "상태 양호": "현재 신체·인지·정서 상태가 비교적 안정적으로 유지되나 정기 관찰이 요구됨", "특이사항 없음": "즉시 개입이 필요한 돌발 상황은 확인되지 않았으나 서비스 제공 중 상태변화 관찰이 요구됨", "문제 없음": "현재 확인된 위험요인은 제한적이나 욕구와 기능상태 변화 여부를 지속 확인할 필요가 있음", "이전과 동일": "기존 관찰 항목의 급격한 악화는 제한적이나 기능 유지 여부를 구체적으로 재확인함", "치매 있으심": "인지기능 저하 및 치매 관련 행동 변화 여부에 대한 관찰이 요구됨", "욕창 있음": "욕창 또는 피부손상 위험이 확인되어 피부상태와 압박부위 관리가 요구됨", } INSTITUTION_NEEDS_GUIDES = { "방문요양": "가정 내 잔존기능 유지, 주거환경 안전, 보호자 부재시간, 신체활동 및 정서지원의 적정성을 중심으로 사정함", "방문목욕": "목욕 전후 건강상태, 피부상태, 미끄럼·화상·낙상예방, 목욕 거부감 및 보호자 협조를 중심으로 사정함", "방문간호": "만성질환 관리, 복약 이행, 간호처치 상태, 욕창·상처관리, 의료기관 연계 필요성을 중심으로 사정함", "주간보호": "송영 안전, 프로그램 참여, 식사·위생, 센터 내 관계, 보호자 부양 부담 경감을 중심으로 사정함", "요양원": "입소생활 적응, 식사·수면·배변, 낙상·욕창예방, 공동생활 적응 및 보호자 상담 반영을 중심으로 사정함", "공동생활가정": "소규모 공동생활 적응, 개별 생활습관 존중, 관계 갈등 조정, 실내 이동 동선 안전을 중심으로 사정함", } FALL_KEYWORDS = ["낙상", "넘어", "미끄", "어지", "현기", "휘청", "보행", "부축", "워커", "지팡", "휠체어", "문턱", "야간", "화장실", "송영"] PRESSURE_KEYWORDS = ["욕창", "발적", "상처", "피부", "와상", "침상", "체위", "드레싱", "삼출", "감염", "영양불량", "탈수", "통증"] DEMENTIA_KEYWORDS = ["치매", "인지", "기억", "반복", "배회", "망상", "환각", "불안", "공격", "귀가", "집에", "야간배회", "혼동", "고집"] MOOD_KEYWORDS = ["우울", "불안", "무기력", "외로", "눈물", "거부", "짜증", "화"] NUTRITION_KEYWORDS = ["식사량", "식욕", "체중", "영양", "사레", "연하", "탈수"] GUARDIAN_KEYWORDS = ["힘들", "소진", "부담", "걱정", "확대", "변경", "동행", "상담", "요청", "불안"] SUPPORT_METHOD_RULES = { "이동지원": { "keywords": ["이동", "보행", "부축", "휠체어", "지팡", "워커", "화장실", "낙상", "휘청", "어지"], "items": [ "침상과 휠체어 이동 시 부축을 제공함", "보행기 및 지팡이 사용을 지원함", "실내 이동 시 안전을 확인함", "이동 동선을 정리하여 낙상을 예방함", "화장실 이동 시 동행 및 안전관찰을 실시함", ], }, "일상생활지원": { "keywords": ["신체활동", "위생", "세면", "구강", "의복", "착탈", "손발톱", "목욕", "피부"], "items": [ "세면 및 구강관리를 지원함", "의복 착탈의를 지원함", "개인위생 상태를 점검함", "손발톱 상태를 확인하고 위생관리를 지원함", "목욕 전후 건강상태와 피부상태를 확인함", ], }, "낙상예방 지원": { "keywords": FALL_KEYWORDS + ["낙상예방"], "items": [ "보행 시 부축을 제공함", "미끄럼 방지 환경을 점검함", "침상 난간 상태를 확인함", "야간 이동 시 안전관찰을 실시함", "이동 전 어지럼 여부를 확인함", "보행보조기 사용을 안내함", ], }, "욕창예방 지원": { "keywords": PRESSURE_KEYWORDS + ["욕창예방"], "items": [ "정기적 체위변경을 실시함", "피부상태를 확인함", "욕창 발생 여부를 관찰함", "영양상태를 확인함", "장시간 동일 자세를 예방함", "피부 청결을 유지함", ], }, "인지지원": { "keywords": DEMENTIA_KEYWORDS + ["인지활동", "인지저하", "기억저하", "일정표", "안전수칙"], "items": [ "날짜와 장소를 반복 안내함", "회상대화를 실시함", "익숙한 환경을 유지하도록 지원함", "인지자극 활동을 제공함", "일정표를 활용하여 안내함", "안전수칙을 반복 교육함", ], }, "BPSD 지원": { "keywords": ["반복질문", "망상", "배회", "공격", "폭언", "귀가", "환각", "거부", "불안", "초조"], "items": [ "동일 질문에 안정적으로 응대함", "시각자료를 활용하여 설명함", "정면 반박을 지양함", "감정을 공감함", "화제를 전환함", "안전한 이동을 지원함", "자극요인을 제거하고 차분한 환경을 제공함", ], }, "정서지원": { "keywords": MOOD_KEYWORDS + ["정서지원", "외로움", "소외", "가족"], "items": [ "말벗 서비스를 제공함", "공감적 경청을 실시함", "긍정적 피드백을 제공함", "취미활동 참여를 지원함", "성취감을 경험하도록 지원함", "안정적인 환경을 제공함", "가족 연락을 지원함", ], }, "사회참여지원": { "keywords": ["고립", "프로그램", "관계", "대인", "참여", "활동", "말다툼", "갈등"], "items": [ "집단 프로그램 참여를 지원함", "또래 관계 형성을 지원함", "지역사회 활동 참여를 연계함", "대화 기회를 제공함", "공동활동 참여를 유도함", ], }, "영양지원": { "keywords": NUTRITION_KEYWORDS + ["식사 지원", "기호식", "선호 음식"], "items": [ "선호 음식을 확인함", "기호식을 반영함", "수분섭취를 독려함", "영양상태를 관찰함", "체중변화를 확인함", "영양보충을 지원함", ], }, "배변배뇨지원": { "keywords": ["배변", "배뇨", "변비", "요실금", "실금", "기저귀", "화장실"], "items": [ "수분섭취를 독려함", "규칙적 배변습관을 지원함", "활동량 증가를 유도함", "배변배뇨 패턴을 확인함", "정기 변기유도를 실시함", "위생관리를 지원함", ], }, "복약지원": { "keywords": ["복약", "약", "투약", "약물", "복용", "혈압", "혈당", "복약확인"], "items": [ "복약 여부를 확인함", "약물 보관상태를 점검함", "보호자와 복약 정보를 공유함", "복약일지를 확인함", "투약시간을 안내함", ], }, "보호자지원": { "keywords": GUARDIAN_KEYWORDS + ["보호자", "가족", "가족상담", "가족갈등"], "items": [ "보호자 상담을 실시함", "돌봄 부담을 경청함", "지역자원 연계를 안내함", "가족 의견을 조정함", "의사소통을 지원함", ], }, "지역자원 연계": { "keywords": ["치매", "건강관리", "경제", "고립", "병원", "보건소", "복지관", "행정복지센터", "지역자원", "방문간호"], "items": [ "치매안심센터 연계를 검토함", "방문간호 서비스를 연계함", "보건소 프로그램을 안내함", "행정복지센터 연계를 검토함", "복지관 프로그램 참여를 연계함", ], }, } def normalize_assessment_text(text: str) -> str: value = str(text or "").strip() for bad, good in RESTRICTED_REPLACEMENTS.items(): value = value.replace(bad, good) return value def has_keyword(text: str, keywords) -> bool: source = str(text or "") return any(keyword in source for keyword in keywords) def formal_sentence(text: str) -> str: value = normalize_assessment_text(text).strip() if not value: return "" if value.endswith(("확인됨", "관찰됨", "사정됨", "판단됨", "요구됨", "계획함", "실시함", "필요함", "있음", "없음", "됨", "함", "하심", "말씀하심")): return value last = value[-1] if "가" <= last <= "힣" and (ord(last) - ord("가")) % 28: return f"{value}으로 확인됨" return f"{value}로 확인됨" def build_individual_support_methods(data: dict, ctx: dict | None = None) -> str: combined = " ".join([ data.get("institution_type", ""), data.get("grade", ""), data.get("main_condition", ""), data.get("cognitive_status", ""), data.get("mobility_status", ""), data.get("diet_status", ""), data.get("toileting_status", ""), data.get("notes", ""), data.get("guardian_opinion", ""), " ".join(data.get("service_needs", [])), ]) selected = {} for category, rule in SUPPORT_METHOD_RULES.items(): if has_keyword(combined, rule["keywords"]): selected[category] = rule["items"][:3] if ctx: if ctx.get("fall_detected"): selected.setdefault("낙상예방 지원", SUPPORT_METHOD_RULES["낙상예방 지원"]["items"][:4]) selected.setdefault("이동지원", SUPPORT_METHOD_RULES["이동지원"]["items"][:3]) if ctx.get("pressure_detected"): selected.setdefault("욕창예방 지원", SUPPORT_METHOD_RULES["욕창예방 지원"]["items"][:4]) if ctx.get("dementia_detected"): selected.setdefault("인지지원", SUPPORT_METHOD_RULES["인지지원"]["items"][:4]) selected.setdefault("BPSD 지원", SUPPORT_METHOD_RULES["BPSD 지원"]["items"][2:5]) if ctx.get("guardian_detected"): selected.setdefault("보호자지원", SUPPORT_METHOD_RULES["보호자지원"]["items"][:3]) if not selected: selected = { "이동지원": SUPPORT_METHOD_RULES["이동지원"]["items"][:2], "일상생활지원": SUPPORT_METHOD_RULES["일상생활지원"]["items"][:2], "정서지원": SUPPORT_METHOD_RULES["정서지원"]["items"][:2], } lines = ["[개인별 이용계획 지원방법]"] for category, items in list(selected.items())[:5]: lines.append(category) for item in items[:5]: lines.append(f"- {item}") return "\n".join(lines) def build_needs_context(data: dict) -> dict: combined = " ".join([ data.get("main_condition", ""), data.get("cognitive_status", ""), data.get("mobility_status", ""), data.get("diet_status", ""), data.get("toileting_status", ""), data.get("notes", ""), data.get("guardian_opinion", ""), " ".join(data.get("service_needs", [])), ]) fall_detected = data.get("fall_risk") == "높음" or has_keyword(combined, FALL_KEYWORDS) pressure_detected = data.get("bedsore_risk") == "높음" or has_keyword(combined, PRESSURE_KEYWORDS) dementia_detected = data.get("dementia_status") in ["의심", "진단 있음", "중증"] or has_keyword(combined, DEMENTIA_KEYWORDS) mood_detected = has_keyword(combined, MOOD_KEYWORDS) or "정서지원" in data.get("service_needs", []) nutrition_detected = has_keyword(combined, NUTRITION_KEYWORDS) or "식사 지원" in data.get("service_needs", []) guardian_detected = has_keyword(data.get("guardian_opinion", ""), GUARDIAN_KEYWORDS) or "가족상담" in data.get("service_needs", []) risk_score = 0 risk_score += {"낮음": 0, "보통": 1, "높음": 2}.get(data.get("fall_risk"), 0) risk_score += {"낮음": 0, "보통": 1, "높음": 2}.get(data.get("bedsore_risk"), 0) risk_score += {"없음": 0, "의심": 1, "진단 있음": 2, "중증": 3}.get(data.get("dementia_status"), 0) risk_score += 1 if guardian_detected else 0 fall_level = "고위험" if data.get("fall_risk") == "높음" or has_keyword(combined, ["낙상", "넘어", "휘청"]) else "중위험" if data.get("fall_risk") == "보통" or fall_detected else "저위험" if data.get("dementia_status") == "중증" or has_keyword(combined, ["배회", "공격", "환각", "망상", "야간배회"]): bpsd_level = "고위험" elif dementia_detected or has_keyword(combined, ["반복질문", "귀가", "불안"]): bpsd_level = "중위험" else: bpsd_level = "저위험" overall_level = "고위험" if risk_score >= 5 else "중위험" if risk_score >= 2 else "저위험" goals = [] if fall_detected: goals.append("낙상예방을 위해 이동 동선 정비, 보행보조, 보호자 교육을 통해 안전한 이동을 유지함") if pressure_detected: goals.append("욕창예방을 위해 피부관찰, 체위변경, 영양·수분관리, 감염예방을 지속함") if dementia_detected: goals.append("인지기능 유지를 위해 반복 안내, 정서 안정, BPSD 예방 중심의 개별화 지원을 실시함") if mood_detected: goals.append("우울·불안 완화를 위해 정서지원과 사회적 상호작용 기회를 제공함") if nutrition_detected: goals.append("영양관리 강화를 위해 식사량, 연하상태, 수분섭취를 정기 확인함") if guardian_detected: goals.append("보호자 소진 예방을 위해 상담, 정보공유, 서비스 조정 가능 범위를 안내함") if not goals: goals.append("현재 기능 유지와 일상생활 안정성 확보를 목표로 정기적인 상태 관찰을 실시함") flags = { "사례관리 검토 필요": overall_level == "고위험" or bpsd_level == "고위험", "급여제공계획 변경 필요": any([fall_detected, pressure_detected, dementia_detected, nutrition_detected, guardian_detected]), "상태변화기록 작성 필요": any([fall_detected, pressure_detected, dementia_detected]), "보호자 상담 필요": guardian_detected or bool(str(data.get("guardian_opinion", "")).strip()), } return { "fall_detected": fall_detected, "pressure_detected": pressure_detected, "dementia_detected": dementia_detected, "guardian_detected": guardian_detected, "fall_level": fall_level, "bpsd_level": bpsd_level, "overall_level": overall_level, "goals": goals, "flags": flags, "institution_guide": INSTITUTION_NEEDS_GUIDES.get(data.get("institution_type"), INSTITUTION_NEEDS_GUIDES["방문요양"]), } def build_enhanced_needs_summary(data: dict) -> str: ctx = build_needs_context(data) support_methods = build_individual_support_methods(data, ctx) guardian_text = formal_sentence(data.get("guardian_opinion")) or "보호자 의견은 추가 상담 시 구체적으로 확인할 계획임" client_need = ", ".join(data.get("service_needs", [])) or "일상생활 지원 및 상태관찰" problem_parts = [] if ctx["fall_detected"]: problem_parts.append(f"낙상위험 {ctx['fall_level']} 단계") if ctx["pressure_detected"]: problem_parts.append("피부손상 및 욕창예방 관리 필요") if ctx["dementia_detected"]: problem_parts.append(f"인지저하 및 BPSD {ctx['bpsd_level']} 단계") if not problem_parts: problem_parts.append("급격한 위험 단서는 제한적이나 기능 유지와 정기 관찰 필요") flag_text = " / ".join([f"□ {name}" for name, enabled in ctx["flags"].items() if enabled]) or "□ 정기 관찰 유지" plan_parts = [ctx["institution_guide"], " / ".join(ctx["goals"][:4])] if ctx["fall_detected"]: plan_parts.append("낙상 위험요인 분석, 환경개선, 보호자 교육, 이동지원 계획을 급여제공 과정에 반영함") if ctx["pressure_detected"]: plan_parts.append("피부상태 분석, 체위변경, 영양관리, 감염예방 계획을 급여제공 과정에 반영함") if ctx["dementia_detected"]: plan_parts.append("기억력, 지남력, BPSD, 보호자 부담을 함께 사정하고 공감과 전환 중심으로 지원함") summary = f""" 1. 현재상태: {data['masked_name']} 수급자는 {data['grade']} 대상자로서 {data['institution_type']} 이용 특성에 따라 {ctx['institution_guide']} 주요 질환 및 상태는 {formal_sentence(data.get('main_condition')) or '지속 관찰이 요구됨'}. 2. 핵심문제: {', '.join(problem_parts)}로 사정되며, 낙상위험은 {ctx['fall_level']}, BPSD 위험도는 {ctx['bpsd_level']}, 종합 위험도는 {ctx['overall_level']}으로 판단됨. 3. 수급자 및 보호자 욕구: 수급자는 {client_need} 욕구가 확인되며, 보호자 의견은 {guardian_text} 4. 장단기 목표: 단기적으로 안전사고 예방과 일상생활 안정화를 목표로 하고, 장기적으로 {' / '.join(ctx['goals'][:4])} 5. 급여제공 방향: {' '.join(plan_parts)} 사례관리 연계 판단은 {flag_text}로 정리됨. 6. 개인별 이용계획 지원방법: 아래 지원방법은 단순한 신체지원, 인지지원, 정서지원 표현을 대체하여 실제 제공 가능한 서비스로 적용함. {support_methods} """.strip() return format_numbered_text(normalize_assessment_text(summary)) def build_enhanced_result_evaluation(data: dict) -> str: ctx = build_needs_context(data) support_methods = build_individual_support_methods(data, ctx) achievement = "부분달성 또는 미달성" if ctx["overall_level"] == "고위험" else "부분달성" if ctx["overall_level"] == "중위험" else "달성" eval_items = [ f"욕구사정 총평에서 확인된 {ctx['overall_level']} 수준의 위험요인에 대해 30일간 급여제공 후 목표 달성도는 {achievement}으로 평가됨", f"낙상예방 목표는 {ctx['fall_level']} 수준에 따라 이동지원, 환경정비, 보호자 교육의 지속 여부를 확인함", "욕창예방 목표는 피부발적, 체위변경, 영양·수분섭취, 감염징후 관찰 결과를 기준으로 평가함" if ctx["pressure_detected"] else "피부상태는 정기 관찰을 유지하며 압박부위 이상 여부를 다음 평가 시 재확인함", "인지기능 유지 목표는 기억력, 지남력, BPSD 변화와 보호자 부담 정도를 함께 확인함" if ctx["dementia_detected"] else "인지기능은 현재 관찰 수준을 유지하되 변화 발생 시 재사정을 실시함", "급여제공계획은 확인된 욕구와 위험요인에 따라 변경 필요성을 검토하고, 상태변화기록·보호자 상담·사례관리 연계 여부를 함께 판단함", "개인별 이용계획 지원방법은 다음 급여제공 시 실제 서비스 제공 항목으로 적용하고 이행 여부를 확인함\n" + support_methods, ] return format_numbered_text(normalize_assessment_text("\n".join([f"{i}. {item}" for i, item in enumerate(eval_items, 1)]))) # ----------------------------- # 디자인 # ----------------------------- st.markdown( """ """, unsafe_allow_html=True, ) st.markdown( """
BeeCare Client Center 연동

욕구사정기록지

등록된 수급자 정보를 불러와 욕구사정 총평과 30일 후 결과평가를 깔끔한 문서로 생성합니다.

""", unsafe_allow_html=True, ) st.markdown( """
중요: 이 앱은 Client Center의 SQLite DB(data/clients.db)를 불러오는 통합연동형 코드입니다. DB가 없으면 직접 입력 모드로 사용할 수 있습니다.
""", unsafe_allow_html=True, ) # ----------------------------- # 수급자 선택 / 직접 입력 # ----------------------------- clients_df = load_clients() if DB_PATH: st.sidebar.success(f"Client Center DB 연결됨") st.sidebar.caption(DB_PATH) else: st.sidebar.warning("Client Center DB를 찾지 못했습니다. 직접 입력 모드로 실행됩니다.") mode = st.radio( "입력 방식 선택", ["Client Center에서 수급자 선택", "직접 입력"], horizontal=True, ) selected_client = None if mode == "Client Center에서 수급자 선택" and not clients_df.empty: options = clients_df.apply( lambda x: f"{x['id']}번 - {x['masked_name']} / {x['care_grade']} / {x['institution_type']} / {x['status']}", axis=1, ).tolist() selected_option = st.selectbox("수급자 선택", options) selected_id = int(selected_option.split("번")[0]) selected_client = clients_df[clients_df["id"] == selected_id].iloc[0] st.markdown("### 👤 선택된 수급자 기본정보") c1, c2, c3, c4 = st.columns(4) c1.metric("성명", safe_value(selected_client, "masked_name")) c2.metric("성별", safe_value(selected_client, "gender")) c3.metric("출생연도", safe_value(selected_client, "birth_year")) c4.metric("등급", safe_value(selected_client, "care_grade")) c5, c6, c7 = st.columns(3) c5.metric("기관유형", safe_value(selected_client, "institution_type")) c6.metric("기관명", safe_value(selected_client, "institution_name")) c7.metric("상태", safe_value(selected_client, "status")) c8, c9, c10 = st.columns(3) c8.metric("인정번호", safe_value(selected_client, "recognition_number", "미입력")) c9.metric("인정기간 시작일", safe_value(selected_client, "recognition_start_date", "미입력")) c10.metric("인정기간 종료일", safe_value(selected_client, "recognition_end_date", "미입력")) elif mode == "Client Center에서 수급자 선택" and clients_df.empty: st.warning("등록된 수급자 정보가 없거나 DB를 찾지 못했습니다. 직접 입력 모드로 전환하세요.") # ----------------------------- # 입력값 구성 # ----------------------------- st.markdown("### 📝 욕구사정 기본 입력") if selected_client is not None: default_name = safe_value(selected_client, "name") default_masked_name = safe_value(selected_client, "masked_name") default_gender = safe_value(selected_client, "gender", "여") default_birth_year = safe_value(selected_client, "birth_year") default_grade = safe_value(selected_client, "care_grade", "3등급") default_institution_type = safe_value(selected_client, "institution_type", "방문요양") default_institution_name = safe_value(selected_client, "institution_name") default_recognition_number = safe_value(selected_client, "recognition_number") default_recognition_start_date = safe_value(selected_client, "recognition_start_date") default_recognition_end_date = safe_value(selected_client, "recognition_end_date") default_main_condition = safe_value(selected_client, "main_condition") default_cognitive_status = safe_value(selected_client, "cognitive_status") default_mobility_status = safe_value(selected_client, "mobility_status") default_diet_status = safe_value(selected_client, "diet_status") default_toileting_status = safe_value(selected_client, "toileting_status") default_notes = safe_value(selected_client, "notes") default_guardian_opinion = "" else: default_name = "홍길동" default_masked_name = "홍○동" default_gender = "여" default_birth_year = "1945" default_grade = "3등급" default_institution_type = "방문요양" default_institution_name = "" default_recognition_number = "" default_recognition_start_date = "" default_recognition_end_date = "" default_main_condition = "" default_cognitive_status = "" default_mobility_status = "" default_diet_status = "" default_toileting_status = "" default_notes = "" default_guardian_opinion = "" grade_options = ["1등급", "2등급", "3등급", "4등급", "5등급", "인지지원등급", "등급외", "미입력"] gender_options = ["여", "남", "기타"] institution_options = ["방문요양", "방문목욕", "방문간호", "주간보호", "요양원", "공동생활가정"] col1, col2, col3, col4 = st.columns(4) name = col1.text_input("수급자 성명", value=default_name) masked_name = col2.text_input("가명 표기", value=default_masked_name) birth_year = col3.text_input("출생연도", value=default_birth_year) gender = col4.selectbox( "성별", gender_options, index=gender_options.index(default_gender) if default_gender in gender_options else 0, ) col5, col6, col7 = st.columns(3) grade = col5.selectbox( "장기요양 등급", grade_options, index=grade_options.index(default_grade) if default_grade in grade_options else 2, ) institution_type = col6.selectbox( "기관 유형", institution_options, index=institution_options.index(default_institution_type) if default_institution_type in institution_options else 0, ) institution_name = col7.text_input("기관명", value=default_institution_name) col8, col9, col10 = st.columns(3) recognition_number = col8.text_input("인정번호", value=default_recognition_number, help="Client Center에서 숫자로 입력해도 L이 자동으로 붙어 저장됩니다.") recognition_start_date = col9.text_input("인정기간 시작일", value=default_recognition_start_date, placeholder="예: 2025-03-03") recognition_end_date = col10.text_input("인정기간 종료일", value=default_recognition_end_date, placeholder="예: 2027-03-02") st.markdown("### 🧩 상태 정보") c1, c2 = st.columns(2) main_condition = c1.text_area("주요 질환 / 상태", value=default_main_condition, height=90) cognitive_status = c2.text_area("인지 상태", value=default_cognitive_status, height=90) c3, c4 = st.columns(2) mobility_status = c3.text_area("이동 상태", value=default_mobility_status, height=90) diet_status = c4.text_area("식사 상태", value=default_diet_status, height=90) c5, c6 = st.columns(2) toileting_status = c5.text_area("배변 / 배뇨 상태", value=default_toileting_status, height=90) notes = c6.text_area("특이사항 / 메모", value=default_notes, height=90) guardian_opinion = st.text_area( "보호자 의견", value=default_guardian_opinion, height=90, placeholder="예: 최근 어지럼이 잦아 낙상이 걱정됨, 돌봄 부담이 커 서비스 조정 상담을 희망함", ) st.markdown("### 📌 욕구사정 세부 체크") cc1, cc2, cc3, cc4 = st.columns(4) fall_risk = cc1.selectbox("낙상 위험", ["낮음", "보통", "높음"]) bedsore_risk = cc2.selectbox("욕창 위험", ["낮음", "보통", "높음"]) dementia_status = cc3.selectbox("인지저하 / 치매", ["없음", "의심", "진단 있음", "중증"]) diaper_use = cc4.selectbox("기저귀 사용", ["미사용", "일부 사용", "상시 사용"]) service_needs = st.multiselect( "주요 서비스 욕구", [ "신체활동 지원", "인지활동 지원", "정서지원", "식사 지원", "배변/배뇨 지원", "이동 지원", "낙상예방", "욕창예방", "복약확인", "가족상담", ], default=["신체활동 지원", "정서지원", "낙상예방"], ) # ----------------------------- # AI 전문 소견 생성 # ----------------------------- st.markdown("---") st.subheader("🤖 전문 소견 생성") system_prompt = ( "당신은 장기요양기관 평가와 문서작성에 익숙한 20년 경력의 사회복지사입니다. " "평가기준에 맞는 전문적이고 따뜻한 문체로 작성하세요. " "개인정보는 과도하게 노출하지 말고, 수급자 상태와 급여 반영 방향이 드러나게 작성하세요." ) user_summary = f""" 수급자: {masked_name} 성별: {gender} 출생연도: {birth_year} 장기요양등급: {grade} 기관유형: {institution_type} 인정번호: {recognition_number} 인정기간: {recognition_start_date} ~ {recognition_end_date} 주요질환/상태: {main_condition} 인지상태: {cognitive_status} 이동상태: {mobility_status} 식사상태: {diet_status} 배변/배뇨상태: {toileting_status} 낙상위험: {fall_risk} 욕창위험: {bedsore_risk} 인지저하/치매: {dementia_status} 기저귀사용: {diaper_use} 주요 서비스 욕구: {', '.join(service_needs)} 특이사항: {notes} 보호자 의견: {guardian_opinion} """ needs_data = { "masked_name": masked_name, "gender": gender, "birth_year": birth_year, "grade": grade, "institution_type": institution_type, "institution_name": institution_name, "recognition_number": recognition_number, "recognition_start_date": recognition_start_date, "recognition_end_date": recognition_end_date, "main_condition": main_condition, "cognitive_status": cognitive_status, "mobility_status": mobility_status, "diet_status": diet_status, "toileting_status": toileting_status, "fall_risk": fall_risk, "bedsore_risk": bedsore_risk, "dementia_status": dementia_status, "diaper_use": diaper_use, "service_needs": service_needs, "notes": notes, "guardian_opinion": guardian_opinion, } needs_context = build_needs_context(needs_data) auto_support_methods = build_individual_support_methods(needs_data, needs_context) st.markdown("### 🧭 자동 연계 판단") flag_cols = st.columns(4) for idx, (flag_name, enabled) in enumerate(needs_context["flags"].items()): flag_cols[idx].metric(flag_name, "필요" if enabled else "관찰") st.caption(f"낙상 위험도: {needs_context['fall_level']} / BPSD 위험도: {needs_context['bpsd_level']} / 종합 위험도: {needs_context['overall_level']}") col_ai1, col_ai2 = st.columns(2) with col_ai1: if st.button("욕구사정 총평 생성"): if USE_LM_STUDIO: prompt = user_summary + "\n현재상태, 핵심문제, 수급자 욕구, 보호자 욕구, 장단기 목표, 급여제공 방향이 모두 드러나도록 평가 적합 문체로 욕구사정 종합소견을 1번부터 5번까지 작성해줘. 개인별 이용계획 지원방법은 아래 실제 제공 항목을 반영해줘.\n" + auto_support_methods + "\n금지문구는 사용하지 마." generated = get_ai_content(system_prompt, prompt) st.session_state["총평"] = format_numbered_text(normalize_assessment_text(generated + "\n\n" + auto_support_methods)) else: st.session_state["총평"] = build_enhanced_needs_summary(needs_data) with col_ai2: if st.button("30일 후 결과평가 생성"): if USE_LM_STUDIO: prompt = user_summary + "\n욕구사정 총평과 연결되도록 목표별 달성/부분달성/미달성 평가, 급여제공계획 반영 방향, 상태변화기록·사례관리·보호자상담 필요 여부를 포함하여 1번부터 5번까지 작성해줘. 개인별 이용계획 지원방법 이행 여부도 포함해줘.\n" + auto_support_methods + "\n금지문구는 사용하지 마." generated = get_ai_content(system_prompt, prompt) st.session_state["결과평가"] = format_numbered_text(normalize_assessment_text(generated + "\n\n" + auto_support_methods)) else: st.session_state["결과평가"] = build_enhanced_result_evaluation(needs_data) v_total = st.text_area( "11. 총평 / 종합소견", value=st.session_state.get("총평", ""), height=170, help="생성 버튼을 누르면 번호순으로 정돈되어 작성됩니다. 직접 입력한 문장도 다운로드 시 번호형으로 정리됩니다.", ) v_total_clean = format_numbered_text(v_total) v_support_methods = st.text_area( "개인별 이용계획 지원방법", value=st.session_state.get("지원방법", auto_support_methods), height=190, help="규칙에 따라 신체지원, 인지지원, 정서지원 같은 추상어 대신 실제 제공 가능한 지원방법이 자동 선택됩니다.", ) v_support_methods_clean = normalize_assessment_text(v_support_methods) st.markdown("---") st.subheader("📊 30일 후 결과평가") v_result = st.selectbox("해결 여부", ["해결됨", "부분 해결", "진행 중", "재사정 필요"]) v_next_plan = st.text_area( "미해결 시 조치 계획 및 향후 급여 반영 계획", value=st.session_state.get("결과평가", ""), height=170, help="다운로드 시 번호순 문장으로 정돈됩니다.", ) v_next_plan_clean = format_numbered_text(v_next_plan) auto_link_judgement = " / ".join([f"□ {name}" for name, enabled in needs_context["flags"].items() if enabled]) or "□ 정기 관찰 유지" auto_risk_summary = f"낙상 위험도: {needs_context['fall_level']} / BPSD 위험도: {needs_context['bpsd_level']} / 종합 위험도: {needs_context['overall_level']}" # ----------------------------- # 엑셀 생성 # ----------------------------- def set_cell(ws, cell, value, bold=False, size=11, fill=None): ws[cell] = value ws[cell].font = Font(name="Malgun Gothic", size=size, bold=bold) ws[cell].alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) if fill: ws[cell].fill = fill def generate_excel(): wb = Workbook() thin = Side(style="thin", color="888888") border = Border(left=thin, right=thin, top=thin, bottom=thin) title_fill = PatternFill("solid", fgColor="E2F0D9") header_fill = PatternFill("solid", fgColor="F2F2F2") def style_range(ws, cell_range, border_on=True, fill=None, font=None, alignment=None): """병합 셀 포함 범위 전체에 스타일 적용""" for row in ws[cell_range]: for cell in row: if border_on: cell.border = border if fill: cell.fill = fill if font: cell.font = font if alignment: cell.alignment = alignment def write_cell(ws, cell, value, bold=False, size=11, fill=None, align="center"): ws[cell] = value ws[cell].font = Font(name="Malgun Gothic", size=size, bold=bold) ws[cell].alignment = Alignment(horizontal=align, vertical="center", wrap_text=True) ws[cell].border = border if fill: ws[cell].fill = fill def approval_box(ws, start_col, end_col): """큰제목 우측 상단 결재란: 3번째 이미지처럼 제목과 같은 높이에 배치""" headers = ["담당자", "시설장"] for idx, col in enumerate(range(start_col, end_col + 1)): c1 = ws.cell(1, col) c2 = ws.cell(2, col) c1.value = headers[idx] if idx < len(headers) else "" c2.value = "" c1.font = Font(name="Malgun Gothic", size=12, bold=True) c2.font = Font(name="Malgun Gothic", size=11) c1.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) c2.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) c1.fill = header_fill c1.border = border c2.border = border # ===================================================== # 시트 1: 욕구사정평가서 # ===================================================== ws1 = wb.active ws1.title = "욕구사정기록지" # 엑셀 기본 눈금선 제거 (깔끔한 표시) ws1.sheet_view.showGridLines = False widths = {"A": 16, "B": 16, "C": 16, "D": 16, "E": 16, "F": 16, "G": 16, "H": 14, "I": 14} for col, width in widths.items(): ws1.column_dimensions[col].width = width ws1.merge_cells("A1:G2") write_cell(ws1, "A1", "욕 구 사 정 기 록 지", True, 26, title_fill) style_range(ws1, "A1:G2", fill=title_fill, font=Font(name="Malgun Gothic", size=26, bold=True), alignment=Alignment(horizontal="center", vertical="center", wrap_text=True)) approval_box(ws1, 8, 9) ws1.row_dimensions[1].height = 42 ws1.row_dimensions[2].height = 52 basic_rows = [ ("수급자명", masked_name, "성별", gender, "출생연도", birth_year), ("장기요양등급", grade, "기관유형", institution_type, "기관명", institution_name), ("인정번호", recognition_number, "인정기간 시작일", recognition_start_date, "인정기간 종료일", recognition_end_date), ("작성일자", str(date.today()), "낙상위험", fall_risk, "욕창위험", bedsore_risk), ("인지저하/치매", dementia_status, "기저귀 사용", diaper_use, "해결여부", v_result), ] row_num = 3 for row in basic_rows: ws1.merge_cells(start_row=row_num, start_column=2, end_row=row_num, end_column=3) ws1.merge_cells(start_row=row_num, start_column=5, end_row=row_num, end_column=6) ws1.merge_cells(start_row=row_num, start_column=8, end_row=row_num, end_column=9) label_value_pairs = [(1, row[0]), (2, row[1]), (4, row[2]), (5, row[3]), (7, row[4]), (8, row[5])] for col, value in label_value_pairs: is_label = col in [1, 4, 7] write_cell(ws1, ws1.cell(row_num, col).coordinate, value, bold=is_label, size=11, fill=header_fill if is_label else None) style_range(ws1, f"A{row_num}:I{row_num}") ws1.row_dimensions[row_num].height = 28 row_num += 1 detail_rows = [ ("주요 질환 / 상태", main_condition), ("인지 상태", cognitive_status), ("이동 상태", mobility_status), ("식사 상태", diet_status), ("배변 / 배뇨 상태", toileting_status), ("보호자 의견", guardian_opinion), ("주요 서비스 욕구", ", ".join(service_needs)), ("자동 연계 판단", auto_link_judgement), ("자동 위험도", auto_risk_summary), ("개인별 이용계획 지원방법", v_support_methods_clean), ("특이사항", notes), ("총평 / 종합소견", v_total_clean), ] for label, value in detail_rows: ws1.merge_cells(start_row=row_num, start_column=2, end_row=row_num, end_column=9) write_cell(ws1, f"A{row_num}", label, True, 11, header_fill) write_cell(ws1, f"B{row_num}", value, False, 11, None, align="left") ws1[f"B{row_num}"].alignment = Alignment(horizontal="left", vertical="top", wrap_text=True) style_range(ws1, f"A{row_num}:I{row_num}") if label in ["총평 / 종합소견", "개인별 이용계획 지원방법"]: ws1.row_dimensions[row_num].height = 110 else: ws1.row_dimensions[row_num].height = 50 row_num += 1 ws1.freeze_panes = "A3" ws1.page_setup.orientation = "landscape" ws1.page_setup.fitToWidth = 1 ws1.page_setup.fitToHeight = 0 ws1.sheet_properties.pageSetUpPr.fitToPage = True ws1.print_area = f"A1:I{row_num - 1}" # ===================================================== # 시트 2: 30일 후 결과평가 기록지 # ===================================================== ws2 = wb.create_sheet("결과평가서") # 엑셀 기본 눈금선 제거 (지저분한 여백 제거) ws2.sheet_view.showGridLines = False for col in "ABCDE": ws2.column_dimensions[col].width = 24 ws2.merge_cells("A1:C2") write_cell(ws2, "A1", "30일 후 결과평가 기록지", True, 24, title_fill) style_range(ws2, "A1:C2", fill=title_fill, font=Font(name="Malgun Gothic", size=24, bold=True), alignment=Alignment(horizontal="center", vertical="center", wrap_text=True)) approval_box(ws2, 4, 5) ws2.row_dimensions[1].height = 42 ws2.row_dimensions[2].height = 52 result_rows = [ ("수급자 성명", masked_name), ("장기요양등급", grade), ("기관유형", institution_type), ("인정번호", recognition_number), ("인정기간", f"{recognition_start_date} ~ {recognition_end_date}"), ("평가일자", str(date.today())), ("해결 여부", v_result), ("자동 연계 판단", auto_link_judgement), ("자동 위험도", auto_risk_summary), ("개인별 이용계획 지원방법", v_support_methods_clean), ("결과평가 및 조치계획", v_next_plan_clean), ] r = 3 for label, value in result_rows: ws2.merge_cells(start_row=r, start_column=2, end_row=r, end_column=5) write_cell(ws2, f"A{r}", label, True, 11, header_fill) write_cell(ws2, f"B{r}", value, False, 11, None, align="left") ws2[f"B{r}"].alignment = Alignment(horizontal="left", vertical="top", wrap_text=True) style_range(ws2, f"A{r}:E{r}") if label in ["개인별 이용계획 지원방법", "결과평가 및 조치계획"]: ws2.row_dimensions[r].height = 110 else: ws2.row_dimensions[r].height = 48 r += 1 ws2.freeze_panes = "A3" ws2.page_setup.orientation = "landscape" ws2.page_setup.fitToWidth = 1 ws2.page_setup.fitToHeight = 0 ws2.sheet_properties.pageSetUpPr.fitToPage = True ws2.print_area = f"A1:E{r - 1}" # 시트 3(2차사례관리결과평가서) 생성 코드 제거됨 buffer = BytesIO() wb.save(buffer) buffer.seek(0) return buffer file_name = f"욕구사정_결과평가_{masked_name}_{datetime.now().strftime('%Y%m%d')}.xlsx" if st.button("📦 엑셀 파일 준비하기", use_container_width=True, key="prepare_needs_excel_fast"): st.session_state["needs_excel_fast"] = generate_excel() st.success("엑셀 파일 준비가 완료되었습니다. 아래 다운로드 버튼을 눌러주세요.") if "needs_excel_fast" in st.session_state: st.download_button( "📥 통합 엑셀 다운로드", data=st.session_state["needs_excel_fast"], file_name=file_name, mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", use_container_width=True, key="download_needs_excel_fast", ) st.caption("빠른 버전: 화면 생성 후, 엑셀은 필요할 때만 준비합니다.")