Spaces:
Sleeping
Sleeping
Download app.py from devorah-ai-2016/CounselingLog: direct link, hf CLI and curl.
- Browser
- Download file 85.8 kB
-
https://huggingface.co/spaces/devorah-ai-2016/CounselingLog/resolve/main/app.py
- Command line
-
hf download hf://spaces/devorah-ai-2016/CounselingLog/app.py
-
curl -L -o app.py https://huggingface.co/spaces/devorah-ai-2016/CounselingLog/resolve/main/app.py
85.8 kB
| import random | |
| 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 openpyxl.utils import get_column_letter | |
| from openpyxl.worksheet.pagebreak import Break | |
| from openai import OpenAI | |
| st.set_page_config(page_title="상담일지", page_icon="📝", layout="wide") | |
| # ----------------------------- | |
| # DB 경로 설정 | |
| # ----------------------------- | |
| CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| DB_CANDIDATES = [ | |
| "/app/data/clients.db", | |
| 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"), | |
| os.path.join(CURRENT_DIR, "data", "clients.db"), | |
| ] | |
| DB_PATH = None | |
| for path in DB_CANDIDATES: | |
| if os.path.exists(path): | |
| DB_PATH = path | |
| break | |
| if DB_PATH is None: | |
| os.makedirs(os.path.join(CURRENT_DIR, "data"), exist_ok=True) | |
| DB_PATH = os.path.join(CURRENT_DIR, "data", "clients.db") | |
| # ----------------------------- | |
| # DB 초기화 함수 | |
| # ----------------------------- | |
| def init_db(): | |
| try: | |
| conn = sqlite3.connect(DB_PATH) | |
| cur = conn.cursor() | |
| # clients 테이블 (기존 테이블 존재 시 무시) | |
| cur.execute(""" | |
| CREATE TABLE IF NOT EXISTS clients ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| name TEXT, | |
| masked_name TEXT, | |
| gender TEXT, | |
| birth_year TEXT, | |
| care_grade TEXT, | |
| institution_type TEXT, | |
| institution_name TEXT, | |
| recognition_number TEXT, | |
| recognition_start_date TEXT, | |
| recognition_end_date TEXT, | |
| status TEXT | |
| ) | |
| """) | |
| # counseling_records 테이블 | |
| cur.execute(""" | |
| CREATE TABLE IF NOT EXISTS counseling_records ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| client_id INTEGER, | |
| client_masked_name TEXT, | |
| institution_type TEXT, | |
| counseling_type TEXT, | |
| counseling_date TEXT, | |
| counselor TEXT, | |
| counseling_reason TEXT, | |
| client_status_change TEXT, | |
| guardian_opinion TEXT, | |
| counseling_content TEXT, | |
| action_plan TEXT, | |
| evaluation_score INTEGER, | |
| missing_items TEXT, | |
| compliance_result TEXT, | |
| next_due_note TEXT, | |
| created_at TEXT | |
| ) | |
| """) | |
| # 동적 컬럼 추가 (ALTER TABLE) | |
| cur.execute("PRAGMA table_info(counseling_records)") | |
| existing_cols = [r[1] for r in cur.fetchall()] | |
| new_cols = { | |
| "counseling_purpose": "TEXT", | |
| "selected_items": "TEXT", | |
| "keywords": "TEXT", | |
| "additional_needs": "TEXT", | |
| "case_needed": "INTEGER DEFAULT 0", | |
| "needs_assessment_needed": "INTEGER DEFAULT 0", | |
| "careplan_change_needed": "INTEGER DEFAULT 0", | |
| "care_grade_review_needed": "INTEGER DEFAULT 0", | |
| "followup_counseling_needed": "INTEGER DEFAULT 0", | |
| } | |
| for col, col_type in new_cols.items(): | |
| if col not in existing_cols: | |
| cur.execute(f"ALTER TABLE counseling_records ADD COLUMN {col} {col_type}") | |
| conn.commit() | |
| conn.close() | |
| except Exception as e: | |
| st.sidebar.error(f"DB 초기화 실패: {e}") | |
| init_db() | |
| # ----------------------------- | |
| # 수급자 정보 로드 함수 | |
| # ----------------------------- | |
| def load_clients() -> pd.DataFrame: | |
| # 1. DB에서 로드 시도 | |
| if DB_PATH and os.path.exists(DB_PATH): | |
| try: | |
| conn = sqlite3.connect(DB_PATH) | |
| 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", "status" | |
| ] | |
| select_cols = [c for c in wanted_cols if c in available_cols] | |
| query = "SELECT " + ", ".join(select_cols) + " FROM clients ORDER BY id DESC" | |
| df = pd.read_sql_query(query, conn) | |
| conn.close() | |
| # DB 컬럼 한글 바인딩 | |
| db_to_excel_map = { | |
| "name": "수급자이름", | |
| "gender": "성별", | |
| "birth_year": "생년월일", | |
| "care_grade": "장기요양등급", | |
| "recognition_number": "인정번호", | |
| "recognition_start_date": "인정기간 시작일", | |
| "recognition_end_date": "인정기간 종료일", | |
| "institution_type": "기관유형", | |
| "institution_name": "기관명" | |
| } | |
| for db_col, kor_col in db_to_excel_map.items(): | |
| if db_col in df.columns: | |
| df[kor_col] = df[db_col] | |
| if "수급자이름" not in df.columns and "name" in df.columns: | |
| df["수급자이름"] = df["name"] | |
| return df | |
| except Exception as e: | |
| st.sidebar.warning(f"DB 로드 실패: {e}. Excel 시도 중...") | |
| # 2. Excel에서 로드 시도 | |
| excel_path = os.path.join(CURRENT_DIR, "data", "clients.xlsx") | |
| if os.path.exists(excel_path): | |
| try: | |
| df = pd.read_excel(excel_path) | |
| if "수급자명" in df.columns and "수급자이름" not in df.columns: | |
| df["수급자이름"] = df["수급자명"] | |
| expected = ["수급자이름", "성별", "생년월일", "장기요양등급", "인정번호", "인정기간 시작일", "인정기간 종료일", "기관유형", "기관명"] | |
| for col in expected: | |
| if col not in df.columns: | |
| df[col] = "" | |
| return df | |
| except Exception as e: | |
| st.sidebar.error(f"Excel 로드 실패: {e}") | |
| return pd.DataFrame() | |
| # ----------------------------- | |
| # AI 프롬프트 규칙 정의 | |
| # ----------------------------- | |
| COUNSELING_GENERATION_RULE = """ | |
| 너는 장기요양기관 상담일지 작성 보조 AI이다. | |
| 반드시 사용자가 입력한 내용만 사용한다. | |
| 선택된 항목, 핵심단어, 추가욕구/요청사항에 없는 내용은 작성하지 않는다. | |
| 욕창, 배회, 공격성, 귀가욕구, 낙상, 치매행동 등은 입력된 경우에만 작성한다. | |
| AI는 새로운 사실을 만들지 않고 문장을 자연스럽게 정리만 한다. | |
| 상담 목적이 사례관리 검토 상담이면 사례관리 필요 사유 중심으로 작성한다. | |
| 상담 목적이 욕구사정 재검토 상담이면 욕구사정 재검토 사유 중심으로 작성한다. | |
| 상담 목적이 급여제공계획 변경 상담이면 급여제공계획 변경 사유 중심으로 작성한다. | |
| 복합 상담이면 체크된 항목별로 구분하여 작성한다. | |
| 작성 형식: | |
| 1. 상담 사유 | |
| 2. 상담 내용 | |
| 3. 전문적 사정 | |
| 4. 조치계획 | |
| 5. 연동 필요 항목 | |
| """ | |
| # ----------------------------- | |
| # 상담일지 문장 파싱 함수 | |
| # ----------------------------- | |
| def parse_sections(text): | |
| sections = { | |
| "상담 사유": "", | |
| "상담 내용": "", | |
| "전문적 사정": "", | |
| "조치계획": "", | |
| "연동 필요 항목": "" | |
| } | |
| import re | |
| headers = [ | |
| ("상담 사유", ["1. 상담 사유", "상담 사유:", "상담 사유"]), | |
| ("상담 내용", ["2. 상담 내용", "상담 내용:", "상담 내용"]), | |
| ("전문적 사정", ["3. 전문적 사정", "전문적 사정:", "전문적 사정"]), | |
| ("조치계획", ["4. 조치계획", "조치계획:", "조치계획"]), | |
| ("연동 필요 항목", ["5. 연동 필요 항목", "연동 필요 항목:", "연동 필요 항목"]) | |
| ] | |
| current_text = text | |
| for i in range(len(headers)): | |
| header_name, header_aliases = headers[i] | |
| next_header_aliases = headers[i+1][1] if i+1 < len(headers) else [] | |
| start_idx = -1 | |
| matched_alias = "" | |
| for alias in header_aliases: | |
| idx = current_text.find(alias) | |
| if idx != -1: | |
| start_idx = idx | |
| matched_alias = alias | |
| break | |
| if start_idx != -1: | |
| end_idx = len(current_text) | |
| for next_alias in next_header_aliases: | |
| n_idx = current_text.find(next_alias, start_idx + len(matched_alias)) | |
| if n_idx != -1: | |
| end_idx = n_idx | |
| break | |
| section_content = current_text[start_idx + len(matched_alias):end_idx].strip() | |
| section_content = re.sub(r'^[\s\:\-\=]*', '', section_content).strip() | |
| sections[header_name] = section_content | |
| # 파싱 실패 시 대체 분할 | |
| if not sections["상담 사유"] and not sections["상담 내용"]: | |
| lines = text.split("\n\n") | |
| if len(lines) >= 5: | |
| sections["상담 사유"] = lines[0].strip() | |
| sections["상담 내용"] = lines[1].strip() | |
| sections["전문적 사정"] = lines[2].strip() | |
| sections["조치계획"] = lines[3].strip() | |
| sections["연동 필요 항목"] = lines[4].strip() | |
| return sections | |
| # ----------------------------- | |
| # 규칙 기반 문장 컴파일러 (AI 오프라인 대비) | |
| # ----------------------------- | |
| def generate_rule_based_counseling_text(data): | |
| name = data["masked_name"] | |
| grade = data["care_grade"] | |
| inst_type = data["institution_type"] | |
| reason = data["counseling_reason"] | |
| purpose = data["counseling_purpose"] | |
| selected_items_str = ", ".join(data.get("selected_items_list", [])) | |
| keywords = data.get("keywords", "").strip() | |
| additional_needs = data.get("additional_needs", "").strip() | |
| # 입력 정보 기반 위험 키워드 탐지 | |
| combined_inputs = keywords + " " + additional_needs | |
| has_fall = any(k in combined_inputs for k in ["낙상", "넘어", "미끄", "어지", "보행", "부축", "지팡", "워커", "휠체어"]) | |
| has_bedsore = any(k in combined_inputs for k in ["욕창", "발적", "상처", "피부", "짓무", "체위", "침상"]) | |
| has_dementia = any(k in combined_inputs for k in ["치매", "인지", "기억", "배회", "망상", "불안", "귀가", "집에", "혼동"]) | |
| has_guardian = any(k in combined_inputs for k in ["힘들", "소진", "부담", "요청", "가족", "보호자"]) | |
| # 1. 상담 사유 | |
| reason_lines = [] | |
| reason_lines.append(f"{name} 수급자는 장기요양 {grade} 등급으로서 {inst_type} 서비스 이용 중, [{reason}] 사유로 상담을 실시하였습니다.") | |
| if purpose != "일반 상담": | |
| reason_lines.append(f"이번 상담은 수급자의 상황 변화에 따른 [{purpose}] 진행을 목적으로 합니다.") | |
| counseling_reason_text = " ".join(reason_lines) | |
| # 2. 상담 내용 | |
| content_lines = [] | |
| if selected_items_str: | |
| content_lines.append(f"상담 목적에 따라 선택한 검토 항목({selected_items_str})을 중심으로 현재 상태와 주요 애로사항을 청취하고 확인하였습니다.") | |
| if keywords: | |
| content_lines.append(f"수급자 및 관련인의 핵심 요청 사항으로 '{keywords}' 관련 내용이 직접적으로 제시되었으며 이에 대해 면밀히 이야기를 나누었습니다.") | |
| if additional_needs: | |
| content_lines.append(f"추가적으로 제시된 욕구 및 구체적 상황으로 '{additional_needs}'가 파악되었습니다.") | |
| risk_mentions = [] | |
| if has_fall: | |
| risk_mentions.append("보행 불안정성 및 낙상 위험 예방 필요성") | |
| if has_bedsore: | |
| risk_mentions.append("와상 상태 및 장시간 침상 생활로 인한 피부 발적/욕창 예방 관리") | |
| if has_dementia: | |
| risk_mentions.append("인지 기능 변화에 따른 행동 변화와 정서 안정적 응대 필요성") | |
| if has_guardian: | |
| risk_mentions.append("주수 돌봄 보호자의 부양 부담 및 서비스 조정 의견") | |
| if risk_mentions: | |
| content_lines.append("상담 중 입력된 구체적 상태 및 관찰 사항으로 " + ", ".join(risk_mentions) + "에 관한 세부 정황을 확인하였습니다.") | |
| if not content_lines: | |
| content_lines.append("수급자 및 보호자의 일상적인 생활 상태와 서비스 만족도를 확인하고 격려의 말씀을 전하였습니다.") | |
| counseling_content_text = " ".join(content_lines) | |
| # 3. 전문적 사정 | |
| assessment_lines = [] | |
| if selected_items_str: | |
| assessment_lines.append(f"선택된 검토 항목({selected_items_str})과 관련하여 기존 급여제공계획의 적절성 재검토 및 실질적인 돌봄 방법 조정이 요구되는 상태로 판단됩니다.") | |
| if keywords: | |
| assessment_lines.append(f"입력된 키워드 '{keywords}'를 바탕으로 사정한 결과, 기능 저하나 심리적 불편감을 완화하기 위한 요양보호사의 적극적인 지원과 관찰이 중요합니다.") | |
| if additional_needs: | |
| assessment_lines.append(f"추가 요청인 '{additional_needs}'에 근거할 때, 가정 내 환경 개선이나 유관 서비스 변경 검토가 즉각 반영될 필요가 있는 사정 결과를 얻었습니다.") | |
| risk_assess = [] | |
| if has_fall: | |
| risk_assess.append("낙상 방지를 위한 이동 시 전적인 부축 및 화장실 동행 안전 확보") | |
| if has_bedsore: | |
| risk_assess.append("체위 변경 빈도 준수 및 꼬리뼈 부위 등 발적 집중 관찰") | |
| if has_dementia: | |
| risk_assess.append("배회 및 치매 인지 혼돈 예방을 위한 일관적 환경 조성 및 공감적 의사소통 적용") | |
| if risk_assess: | |
| assessment_lines.append("전문 사정상 " + ", ".join(risk_assess) + " 관련 개별 케어 전략 수립이 핵심적임이 도출되었습니다.") | |
| if not assessment_lines: | |
| assessment_lines.append("전반적인 수급자의 기능 상태는 안정적으로 유지되고 있으며, 급격한 상태 저하 징후나 위험 요인은 사정되지 않아 현행 관리를 유지합니다.") | |
| professional_assessment_text = " ".join(assessment_lines) | |
| # 4. 조치계획 | |
| plan_lines = [] | |
| plan_lines.append("상담 과정에서 수집된 내용과 사정 의견을 서비스 제공 인력(요양보호사 등)과 공유하고, 필요한 조치를 이행하기로 하였습니다.") | |
| interlock_actions = [] | |
| if data["case_needed"]: | |
| interlock_actions.append("사례관리 회의 안건으로 상정하여 다각적 지원 방안(보조기구 교체, 외부자원 등)을 논의함") | |
| if data["needs_assessment_needed"]: | |
| interlock_actions.append("욕구사정 재평가를 신속히 시행하여 수급자 ADL 및 환경 변화를 공식적으로 기록함") | |
| if data["careplan_change_needed"]: | |
| interlock_actions.append("급여제공계획의 목표를 수정하고 제공되는 서비스 시간/내용 변경 계약을 준비함") | |
| if data["care_grade_review_needed"]: | |
| interlock_actions.append("국민건강보험공단 등급 신청 절차 및 서류 구비 요령에 대해 보호자에게 안내함") | |
| if data["followup_counseling_needed"]: | |
| interlock_actions.append("추후 보호자와의 추가 대면 및 전화 상담 일정을 조율하여 조치 결과의 반응을 확인함") | |
| if interlock_actions: | |
| plan_lines.append("세부 조치 계획으로는: " + ", ".join(interlock_actions) + "를 시행할 예정입니다.") | |
| else: | |
| plan_lines.append("수급자의 욕구에 기반하여 현 서비스 상태를 면밀히 모니터링하고 정기적인 유선 확인을 지속하기로 하였습니다.") | |
| action_plan_text = " ".join(plan_lines) | |
| # 5. 연동 필요 항목 | |
| interlock_names = [] | |
| if data["case_needed"]: interlock_names.append("사례관리 필요") | |
| if data["needs_assessment_needed"]: interlock_names.append("욕구사정 재검토 필요") | |
| if data["careplan_change_needed"]: interlock_names.append("급여제공계획 변경 필요") | |
| if data["care_grade_review_needed"]: interlock_names.append("등급변경 신청 검토 필요") | |
| if data["followup_counseling_needed"]: interlock_names.append("보호자 추가 상담 필요") | |
| interlock_text = ", ".join(interlock_names) if interlock_names else "해당 사항 없음" | |
| full_text = f""" | |
| 1. 상담 사유 | |
| {counseling_reason_text} | |
| 2. 상담 내용 | |
| {counseling_content_text} | |
| 3. 전문적 사정 | |
| {professional_assessment_text} | |
| 4. 조치계획 | |
| {action_plan_text} | |
| 5. 연동 필요 항목 | |
| {interlock_text} | |
| """.strip() | |
| return full_text | |
| # ----------------------------- | |
| # DB 저장 함수 | |
| # ----------------------------- | |
| def save_counseling_record(data) -> bool: | |
| try: | |
| conn = sqlite3.connect(DB_PATH) | |
| cur = conn.cursor() | |
| insert_query = """ | |
| INSERT INTO counseling_records ( | |
| client_id, client_masked_name, institution_type, counseling_type, | |
| counseling_date, counselor, counseling_reason, client_status_change, | |
| guardian_opinion, counseling_content, action_plan, evaluation_score, | |
| missing_items, compliance_result, next_due_note, created_at, | |
| counseling_purpose, selected_items, keywords, additional_needs, | |
| case_needed, needs_assessment_needed, careplan_change_needed, | |
| care_grade_review_needed, followup_counseling_needed | |
| ) | |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| """ | |
| cur.execute( | |
| insert_query, | |
| ( | |
| data.get("client_id", 0), | |
| data["masked_name"], | |
| data["institution_type"], | |
| data["counseling_type"], | |
| data["counseling_date"], | |
| data["counselor"], | |
| data["counseling_reason"], | |
| data.get("client_status_change", ""), | |
| data.get("guardian_opinion", ""), | |
| data["counseling_content"], | |
| data["action_plan"], | |
| data["evaluation_score"], | |
| data["missing_items"], | |
| data["compliance_result"], | |
| data.get("next_due_note", ""), | |
| datetime.now().strftime("%Y-%m-%d %H:%M:%S"), | |
| data["counseling_purpose"], | |
| ", ".join(data.get("selected_items_list", [])), | |
| data["keywords"], | |
| data["additional_needs"], | |
| 1 if data["case_needed"] else 0, | |
| 1 if data["needs_assessment_needed"] else 0, | |
| 1 if data["careplan_change_needed"] else 0, | |
| 1 if data["care_grade_review_needed"] else 0, | |
| 1 if data["followup_counseling_needed"] else 0, | |
| ) | |
| ) | |
| conn.commit() | |
| conn.close() | |
| return True | |
| except Exception as e: | |
| st.error(f"DB 저장 중 오류 발생: {e}") | |
| return False | |
| 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: 1100px; | |
| padding-top: 1.5rem; | |
| padding-bottom: 3rem; | |
| } | |
| .hero { | |
| background: linear-gradient(135deg, #f5e6d3, #efe0cd); | |
| border: 1px solid #ccc3d8; | |
| border-left: 9px solid #7c3aed; | |
| border-radius: 24px; | |
| padding: 34px 36px; | |
| margin-bottom: 24px; | |
| box-shadow: 0px 10px 30px rgba(124, 58, 237, 0.04); | |
| } | |
| .hero h1 { | |
| margin: 0; | |
| font-size: clamp(28px, 3.5vw, 42px); | |
| line-height: 1.2; | |
| color: #630ed4; | |
| font-weight: 800; | |
| } | |
| .hero p { | |
| margin: 12px 0 0; | |
| font-size: 16px; | |
| color: #4a4455; | |
| line-height: 1.6; | |
| } | |
| .notice { | |
| background: #ffffff; | |
| border: 1px solid #e4e2de; | |
| border-left: 7px solid #606c38; | |
| border-radius: 16px; | |
| padding: 16px 18px; | |
| margin-bottom: 22px; | |
| font-size: 14.5px; | |
| line-height: 1.6; | |
| color: #4a4455; | |
| box-shadow: 0px 4px 12px rgba(96, 108, 56, 0.02); | |
| } | |
| .okbox { | |
| background: #f4fcf6; | |
| border: 1px solid #d2ecd9; | |
| border-left: 8px solid #606c38; | |
| padding: 16px 18px; | |
| border-radius: 16px; | |
| margin: 10px 0; | |
| color: #2b472a; | |
| font-size: 15px; | |
| } | |
| .warnbox { | |
| background: #fffbf4; | |
| border: 1px solid #fce8c8; | |
| border-left: 8px solid #d98b2b; | |
| padding: 16px 18px; | |
| border-radius: 16px; | |
| margin: 10px 0; | |
| color: #5c3f1b; | |
| font-size: 15px; | |
| } | |
| div[data-testid="stMetric"] { | |
| background: #ffffff; | |
| border: 1px solid #e4e2de; | |
| border-radius: 16px; | |
| padding: 14px; | |
| box-shadow: 0px 6px 18px rgba(0, 0, 0, 0.01); | |
| } | |
| div[data-testid="stMetric"] label, | |
| div[data-testid="stMetric"] [data-testid="stMetricLabel"] { | |
| color: #7b7487 !important; | |
| font-size: 13px !important; | |
| font-weight: 600 !important; | |
| } | |
| div[data-testid="stMetric"] [data-testid="stMetricValue"] { | |
| color: #630ed4 !important; | |
| font-size: 22px !important; | |
| font-weight: 700 !important; | |
| } | |
| .stTextInput input, .stTextArea textarea, .stSelectbox div, .stMultiSelect div { | |
| font-size: 15px !important; | |
| } | |
| input, textarea { | |
| border-radius: 12px !important; | |
| border: 1px solid #e4e2de !important; | |
| background: #ffffff !important; | |
| color: #1b1c1a !important; | |
| } | |
| .stButton > button { | |
| border-radius: 12px !important; | |
| background: linear-gradient(135deg, #7c3aed, #630ed4) !important; | |
| color: #ffffff !important; | |
| font-weight: 700 !important; | |
| border: none !important; | |
| padding: 10px 24px !important; | |
| min-height: 46px !important; | |
| box-shadow: 0px 4px 12px rgba(124, 58, 237, 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 16px rgba(124, 58, 237, 0.3) !important; | |
| } | |
| .stDownloadButton > button { | |
| border-radius: 12px !important; | |
| background: #ffffff !important; | |
| color: #7c3aed !important; | |
| font-weight: 700 !important; | |
| border: 1.5px solid #7c3aed !important; | |
| padding: 10px 24px !important; | |
| min-height: 46px !important; | |
| transition: all 0.2s ease-in-out !important; | |
| width: 100% !important; | |
| } | |
| .stDownloadButton > button:hover { | |
| background: #f3e8ff !important; | |
| color: #630ed4 !important; | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| st.markdown(""" | |
| <div class="hero"> | |
| <h1>📝 상담일지 생성기</h1> | |
| <p>빠른 직접입력 버전입니다. 상담 과정과 확인 절차가 포함되도록 문서를 생성합니다.</p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.markdown(""" | |
| <div class="notice"> | |
| <strong>출시 테스트용:</strong> 속도와 문서 품질을 먼저 확인하기 위해 수급자 DB 자동조회 기능을 제외했습니다. 직접 입력 후 상담 과정·확인 내용·조치계획이 포함된 상담일지를 생성합니다. | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.markdown(""" | |
| <div class="notice"> | |
| ※ 생성된 문서는 그대로 사용하셔도 되며, 기관의 운영방침, 수급자 상태, 보호자 의견 및 평가기준에 따라 추가 보완사항은 얼마든지 수정 가능합니다. 기관의 상황에 맞게 활용하시기 바랍니다. | |
| </div> | |
| """, unsafe_allow_html=True) | |
| INSTITUTION_GUIDES = { | |
| "방문요양": ["서비스 시간", "요양보호사 변경", "가사지원 범위", "인지활동", "복약확인", "낙상예방"], | |
| "방문목욕": ["목욕 전후 건강상태", "피부상태", "낙상예방", "이동 보조", "목욕 거부감", "보호자 협조"], | |
| "방문간호": ["혈압/혈당 관찰", "상처관리", "투약관리", "건강상담", "의료기관 연계", "간호처치"], | |
| "주간보호": ["송영", "프로그램 참여", "식사", "배변", "낙상예방", "정서지원", "가족소통"], | |
| "요양원": ["입소생활 적응", "식사/수면/배변", "욕창예방", "낙상예방", "프로그램 참여", "가족면회"], | |
| "공동생활가정": ["공동생활 적응", "정서지원", "개별 욕구", "가족소통", "프로그램 참여", "안전관리"], | |
| } | |
| TYPE_GUIDES = { | |
| "수급자상담": { | |
| "focus": "수급자의 건강상태, 정서상태, 일상생활 불편감, 서비스 만족도, 상태변화를 중심으로 상담", | |
| "action": "수급자 욕구를 급여제공계획과 일일 서비스 제공 내용에 반영", | |
| }, | |
| "보호자상담": { | |
| "focus": "보호자의 의견, 가족 요청사항, 상태변화 전달, 서비스 조정 필요성을 중심으로 상담", | |
| "action": "보호자 의견을 기록하고 필요한 경우 급여제공계획 변경 또는 직원 공유", | |
| }, | |
| "종사자상담": { | |
| "focus": "서비스 제공 중 관찰사항, 업무 고충, 수급자 상태 변화, 안전관리 필요성을 중심으로 상담", | |
| "action": "종사자 관찰 내용을 반영하여 서비스 방법을 조정하고 필요 시 교육·회의 안건으로 연결", | |
| }, | |
| "기타상담": { | |
| "focus": "기관 운영상 필요한 상담, 지역자원 연계, 민원 예방, 특이사항 확인을 중심으로 상담", | |
| "action": "상담 내용을 기관 기록으로 남기고 후속조치 담당자와 기한을 설정", | |
| }, | |
| } | |
| QA_RULES = { | |
| "수급자상담": [ | |
| "현재 가장 불편하거나 힘든 점은 무엇입니까?", | |
| "최근 건강상태, 통증, 어지럼, 식사량, 수면, 배변·배뇨에 변화가 있었습니까?", | |
| "서비스 제공 과정에서 만족하는 점과 불편한 점은 무엇입니까?", | |
| "요양보호사 또는 기관에 추가로 요청하고 싶은 도움이 있습니까?", | |
| "낙상, 욕창, 인지혼동, 정서불안 등 안전상 걱정되는 일이 있었습니까?", | |
| ], | |
| "보호자상담": [ | |
| "보호자가 보기에 수급자의 최근 건강상태나 생활상태 변화는 무엇입니까?", | |
| "가족이 걱정하는 위험요인이나 병원진료 필요성이 있습니까?", | |
| "현재 서비스 시간, 제공내용, 종사자 배치에 조정이 필요합니까?", | |
| "보호자가 기관에 요청하거나 공유하고 싶은 사항은 무엇입니까?", | |
| "급여제공계획에 반영해야 할 가족 의견이 있습니까?", | |
| ], | |
| "종사자상담": [ | |
| "서비스 제공 중 관찰한 수급자의 주요 변화는 무엇입니까?", | |
| "낙상, 욕창, 식사량 감소, 인지혼동, 폭언·거부 등 위험 징후가 있었습니까?", | |
| "현재 제공 방법 중 조정이 필요한 부분은 무엇입니까?", | |
| "업무 수행 중 어려움이나 추가 교육·회의가 필요한 내용은 무엇입니까?", | |
| "보호자 또는 담당자에게 즉시 공유해야 할 사항이 있습니까?", | |
| ], | |
| "기타상담": [ | |
| "상담이 필요한 주요 상황은 무엇입니까?", | |
| "수급자, 보호자, 종사자 중 누구와 관련된 내용입니까?", | |
| "기관 차원의 조치나 외부 연계가 필요한 부분은 무엇입니까?", | |
| "후속 확인이 필요한 시점과 담당자는 누구입니까?", | |
| ], | |
| } | |
| STATUS_CHANGE_KEYWORDS = ["낙상", "욕창", "발적", "상처", "어지", "통증", "식사량", "사레", "연하", "수면", "배변", "배뇨", "실금", "기저귀", "보행", "휘청", "인지", "혼동", "불안", "우울", "입원", "퇴원"] | |
| CASE_RISK_KEYWORDS = ["반복", "지속", "악화", "공격", "폭언", "거부", "망상", "배회", "갈등", "다툼", "학대", "방임", "자해", "위험", "입원", "응급", "보호자 부재"] | |
| CARE_PLAN_KEYWORDS = ["서비스", "시간", "변경", "조정", "추가", "요청", "병원", "동행", "식사", "이동", "목욕", "복약", "프로그램", "급여", "계획"] | |
| FALL_KEYWORDS = ["낙상", "넘어", "미끄", "어지", "현기", "휘청", "보행", "부축", "지팡", "워커", "휠체어", "문턱", "조명", "야간", "화장실", "송영"] | |
| PRESSURE_INJURY_KEYWORDS = ["욕창", "발적", "상처", "피부", "짓무", "삼출", "드레싱", "체위", "와상", "침상", "휠체어", "통증", "열감", "영양", "탈수"] | |
| DEMENTIA_KEYWORDS = ["치매", "인지", "기억", "반복", "배회", "망상", "환각", "불안", "초조", "공격", "거부", "귀가", "집에", "고집", "혼동", "섬망"] | |
| GUARDIAN_BURDEN_KEYWORDS = ["힘들", "소진", "부담", "지침", "혼자", "걱정", "불안", "요청", "확대", "변경", "동행", "상담"] | |
| GENERATION_RULE = """ | |
| 상담일지 작성 규칙: | |
| 1. 사용자가 입력한 상담내용에 없는 문제는 절대 생성하지 않는다. | |
| 2. 욕창, 배회, 공격성, 귀가욕구, 치매행동, 낙상 등 위험요인은 입력된 경우에만 작성한다. | |
| 3. 입력되지 않은 위험요인을 '확인 필요', '검토 필요'라는 표현으로도 넣지 않는다. | |
| 4. 전문적 사정(A)은 실제 입력된 상담내용을 바탕으로만 작성한다. | |
| 5. 조치계획(P)은 확인된 문제에 대한 계획만 작성한다. | |
| 6. 상담내용이 단순 일반상담이면 일반상담 내용으로만 작성한다. | |
| """.strip() | |
| RESTRICTED_REPLACEMENTS = { | |
| "이전과 동일": "상담 시점의 상태는 이전 기록과 큰 차이 없이 유지되는 것으로 입력됨", | |
| "상태 양호": "식사, 이동, 정서 표현 등 일상 기능이 비교적 안정적으로 유지되는 것으로 확인함", | |
| "특이사항 없음": "상담 시점에 추가로 입력된 특이사항은 없음", | |
| "별일 없음": "상담 시점에 추가로 입력된 별도 사항은 없음", | |
| "문제 없음": "상담 시점에 별도 문제로 입력된 내용은 없음", | |
| } | |
| INSTITUTION_SOAP_GUIDES = { | |
| "방문요양": { | |
| "focus": "가정 내 잔존 기능 유지, 주거환경 낙상 위험, 보호자 부재시간, 비급여성 요구와 급여제공 범위 조율", | |
| "plan": "가정 내 이동 동선과 화장실 이용 환경을 점검하고 급여제공계획 범위 안에서 신체활동 및 정서지원 방법을 조정함", | |
| }, | |
| "방문목욕": { | |
| "focus": "목욕 전후 건강상태, 피부 발적, 감염성 피부질환, 이동·미끄럼·화상 예방, 목욕 거부감", | |
| "plan": "목욕 전후 컨디션과 피부상태를 확인하고 미끄럼 방지, 체온 변화, 피부 마찰 최소화 조치를 적용함", | |
| }, | |
| "방문간호": { | |
| "focus": "만성질환 관리, 복약 이행, 간호처치 상태, 방문간호지시서 유효성, 보호자의 처치 이해도", | |
| "plan": "활력징후와 처치 상태를 확인하고 복약·상처·관 삽입 관리 필요 시 의료기관 또는 보호자와 연계함", | |
| }, | |
| "주간보호": { | |
| "focus": "송영 안전, 등원 거부, 프로그램 참여도, 식사·위생 참여, 보호자 부양 부담 경감", | |
| "plan": "송영 및 센터 내 이동 동선을 점검하고 프로그램 참여 수준과 보호자 부담 변화를 함께 확인함", | |
| }, | |
| "요양원": { | |
| "focus": "입소생활 적응, 공동생활 갈등, 식사·수면·배변, 욕창·낙상 예방, 보호자 상담 반영", | |
| "plan": "공동생활 적응과 신체·정서 상태를 관찰하고 보호자 면회·상담 내용을 시설 케어계획에 반영함", | |
| }, | |
| "공동생활가정": { | |
| "focus": "소규모 공동체 관계, 자기결정권, 개별 생활습관, 가정형 환경 유지, 실내 이동 동선 안전", | |
| "plan": "개별 생활습관과 공동생활 갈등요인을 조율하고 소규모 환경에서의 정서 안정 및 안전관리 계획을 수립함", | |
| }, | |
| } | |
| SUPPORT_METHOD_RULES = { | |
| "이동지원": { | |
| "keywords": ["이동", "보행", "부축", "휠체어", "지팡", "워커", "화장실", "낙상", "휘청", "어지"], | |
| "items": [ | |
| "침상과 휠체어 이동 시 부축을 제공함", | |
| "보행기 및 지팡이 사용을 지원함", | |
| "실내 이동 시 안전을 확인함", | |
| "이동 동선을 정리하여 낙상을 예방함", | |
| "화장실 이동 시 동행 및 안전관찰을 실시함", | |
| ], | |
| }, | |
| "일상생활지원": { | |
| "keywords": ["위생", "세면", "구강", "의복", "착탈", "손발톱", "목욕", "피부"], | |
| "items": [ | |
| "세면 및 구강관리를 지원함", | |
| "의복 착탈의를 지원함", | |
| "개인위생 상태를 점검함", | |
| "손발톱 상태를 확인하고 위생관리를 지원함", | |
| "목욕 전후 건강상태와 피부상태를 확인함", | |
| ], | |
| }, | |
| "낙상예방 지원": { | |
| "keywords": FALL_KEYWORDS, | |
| "items": [ | |
| "보행 시 부축을 제공함", | |
| "미끄럼 방지 환경을 점검함", | |
| "침상 난간 상태를 확인함", | |
| "야간 이동 시 안전관찰을 실시함", | |
| "이동 전 어지럼 여부를 확인함", | |
| "보행보조기 사용을 안내함", | |
| ], | |
| }, | |
| "욕창예방 지원": { | |
| "keywords": PRESSURE_INJURY_KEYWORDS, | |
| "items": [ | |
| "정기적 체위변경을 실시함", | |
| "피부상태를 확인함", | |
| "욕창 발생 여부를 관찰함", | |
| "영양상태를 확인함", | |
| "장시간 동일 자세를 예방함", | |
| "피부 청결을 유지함", | |
| ], | |
| }, | |
| "인지지원": { | |
| "keywords": DEMENTIA_KEYWORDS + ["인지저하", "기억저하", "일정표", "안전수칙"], | |
| "items": [ | |
| "날짜와 장소를 반복 안내함", | |
| "회상대화를 실시함", | |
| "익숙한 환경을 유지하도록 지원함", | |
| "인지자극 활동을 제공함", | |
| "일정표를 활용하여 안내함", | |
| "안전수칙을 반복 교육함", | |
| ], | |
| }, | |
| "BPSD 지원": { | |
| "keywords": ["반복질문", "망상", "배회", "공격", "폭언", "귀가", "환각", "거부", "불안", "초조"], | |
| "items": [ | |
| "동일 질문에 안정적으로 응대함", | |
| "시각자료를 활용하여 설명함", | |
| "정면 반박을 지양함", | |
| "감정을 공감함", | |
| "화제를 전환함", | |
| "안전한 이동을 지원함", | |
| "자극요인을 제거하고 차분한 환경을 제공함", | |
| ], | |
| }, | |
| "정서지원": { | |
| "keywords": ["외로움", "우울", "불안", "무기력", "소외", "눈물", "짜증", "정서", "가족", "걱정"], | |
| "items": [ | |
| "말벗 서비스를 제공함", | |
| "공감적 경청을 실시함", | |
| "긍정적 피드백을 제공함", | |
| "취미활동 참여를 지원함", | |
| "성취감을 경험하도록 지원함", | |
| "안정적인 환경을 제공함", | |
| "가족 연락을 지원함", | |
| ], | |
| }, | |
| "사회참여지원": { | |
| "keywords": ["고립", "프로그램", "관계", "대인", "참여", "활동", "말다툼", "갈등"], | |
| "items": [ | |
| "집단 프로그램 참여를 지원함", | |
| "또래 관계 형성을 지원함", | |
| "지역사회 활동 참여를 연계함", | |
| "대화 기회를 제공함", | |
| "공동활동 참여를 유도함", | |
| ], | |
| }, | |
| "영양지원": { | |
| "keywords": ["식사", "식욕", "체중", "영양", "수분", "탈수", "기호식", "선호 음식"], | |
| "items": [ | |
| "선호 음식을 확인함", | |
| "기호식을 반영함", | |
| "수분섭취를 독려함", | |
| "영양상태를 관찰함", | |
| "체중변화를 확인함", | |
| "영양보충을 지원함", | |
| ], | |
| }, | |
| "배변배뇨지원": { | |
| "keywords": ["배변", "배뇨", "변비", "요실금", "실금", "기저귀", "화장실"], | |
| "items": [ | |
| "수분섭취를 독려함", | |
| "규칙적 배변습관을 지원함", | |
| "활동량 증가를 유도함", | |
| "배변배뇨 패턴을 확인함", | |
| "정기 변기유도를 실시함", | |
| "위생관리를 지원함", | |
| ], | |
| }, | |
| "복약지원": { | |
| "keywords": ["복약", "약", "투약", "약물", "복용", "혈압", "혈당"], | |
| "items": [ | |
| "복약 여부를 확인함", | |
| "약물 보관상태를 점검함", | |
| "보호자와 복약 정보를 공유함", | |
| "복약일지를 확인함", | |
| "투약시간을 안내함", | |
| ], | |
| }, | |
| "보호자지원": { | |
| "keywords": GUARDIAN_BURDEN_KEYWORDS + ["보호자", "가족", "가족갈등"], | |
| "items": [ | |
| "보호자 상담을 실시함", | |
| "돌봄 부담을 경청함", | |
| "지역자원 연계를 안내함", | |
| "가족 의견을 조정함", | |
| "의사소통을 지원함", | |
| ], | |
| }, | |
| "지역자원 연계": { | |
| "keywords": ["치매", "건강관리", "경제", "고립", "병원", "보건소", "복지관", "행정복지센터", "지역자원", "방문간호"], | |
| "items": [ | |
| "입력된 내용에 따라 관련 지역자원을 안내함", | |
| "방문간호 서비스를 연계함", | |
| "보건소 프로그램을 안내함", | |
| "행정복지센터 이용 정보를 안내함", | |
| "복지관 프로그램 참여를 연계함", | |
| ], | |
| }, | |
| } | |
| def _join_qna_answer_values(qna_answers): | |
| if not qna_answers: | |
| return "" | |
| answers = [] | |
| for answer in qna_answers.values(): | |
| answer = _clean_text(answer) | |
| if answer: | |
| answers.append(answer) | |
| return " ".join(answers) | |
| def _contains_keywords(text, keywords): | |
| source = str(text or "") | |
| return any(keyword in source for keyword in keywords) | |
| def _replace_restricted_phrases(text): | |
| result = str(text or "") | |
| for bad, good in RESTRICTED_REPLACEMENTS.items(): | |
| result = result.replace(bad, good) | |
| return result | |
| def _formalize_sentence(text): | |
| cleaned = _replace_restricted_phrases(_clean_text(text)) | |
| if not cleaned: | |
| return "" | |
| if cleaned.endswith(("함", "임", "됨", "확인됨", "판단됨", "사정됨", "있음", "없음", "하심", "말씀하심", ".")): | |
| return cleaned.rstrip(".") | |
| return f"{cleaned}로 확인됨" | |
| def _risk_flag(text, keywords): | |
| return _contains_keywords(text, keywords) | |
| def build_individual_support_methods(data, specialized=None): | |
| qna_text = _join_qna_answer_values(data.get("qna_answers", {})) | |
| combined = " ".join([ | |
| data.get("institution_type", ""), | |
| data.get("care_grade", ""), | |
| data.get("main_condition", ""), | |
| data.get("cognitive_status", ""), | |
| data.get("mobility_status", ""), | |
| data.get("diet_status", ""), | |
| data.get("toileting_status", ""), | |
| data.get("client_status_change", ""), | |
| data.get("guardian_opinion", ""), | |
| data.get("staff_observation", ""), | |
| data.get("custom_need", ""), | |
| qna_text, | |
| ]) | |
| selected = {} | |
| for category, rule in SUPPORT_METHOD_RULES.items(): | |
| if _contains_keywords(combined, rule["keywords"]): | |
| selected[category] = rule["items"][:3] | |
| if specialized: | |
| if specialized.get("fall"): | |
| selected.setdefault("낙상예방 지원", SUPPORT_METHOD_RULES["낙상예방 지원"]["items"][:3]) | |
| if specialized.get("pressure"): | |
| selected.setdefault("욕창예방 지원", SUPPORT_METHOD_RULES["욕창예방 지원"]["items"][:3]) | |
| if specialized.get("dementia"): | |
| selected.setdefault("인지지원", SUPPORT_METHOD_RULES["인지지원"]["items"][:3]) | |
| selected.setdefault("BPSD 지원", SUPPORT_METHOD_RULES["BPSD 지원"]["items"][2:5]) | |
| if specialized.get("guardian_burden"): | |
| selected.setdefault("보호자지원", SUPPORT_METHOD_RULES["보호자지원"]["items"][:3]) | |
| if not selected: | |
| return "[개인별 이용계획 지원방법]\n- 입력된 상담내용에 별도 지원방법 변경 사항이 없어 현재 서비스 제공 내용을 유지함" | |
| limited = list(selected.items())[:5] | |
| lines = ["[개인별 이용계획 지원방법]"] | |
| for category, items in limited: | |
| lines.append(f"{category}") | |
| for item in items[:3]: | |
| lines.append(f"- {item}") | |
| return "\n".join(lines) | |
| def analyze_specialized_risks(data): | |
| qna_text = _join_qna_answer_values(data.get("qna_answers", {})) | |
| combined = " ".join([ | |
| data.get("main_condition", ""), | |
| data.get("cognitive_status", ""), | |
| data.get("mobility_status", ""), | |
| data.get("diet_status", ""), | |
| data.get("toileting_status", ""), | |
| data.get("client_status_change", ""), | |
| data.get("guardian_opinion", ""), | |
| data.get("staff_observation", ""), | |
| data.get("custom_need", ""), | |
| qna_text, | |
| ]) | |
| fall = _risk_flag(combined, FALL_KEYWORDS) | |
| pressure = _risk_flag(combined, PRESSURE_INJURY_KEYWORDS) | |
| dementia = _risk_flag(combined, DEMENTIA_KEYWORDS) | |
| guardian_burden = _risk_flag(data.get("guardian_opinion", "") + " " + qna_text, GUARDIAN_BURDEN_KEYWORDS) | |
| count = sum([fall, pressure, dementia]) | |
| if count >= 2 or _contains_keywords(combined, ["응급", "입원", "악화", "공격", "상처", "삼출", "감염", "반복 낙상"]): | |
| level = "위험" | |
| elif count == 1 or guardian_burden: | |
| level = "주의" | |
| else: | |
| level = "안정" | |
| details = [] | |
| plans = [] | |
| if fall: | |
| details.append("입력된 상담내용에 낙상 관련 단서가 포함됨") | |
| plans.append("낙상예방을 위해 이동 동선, 화장실 이용, 미끄럼 방지, 보행보조기 사용 여부를 점검하고 이동 시 부축 및 직원 동행을 강화함") | |
| if pressure: | |
| details.append("입력된 상담내용에 욕창 또는 피부손상 관련 단서가 포함됨") | |
| plans.append("입력된 피부 관련 내용에 따라 체위변경, 압박부위 관리, 영양·수분 섭취 지원을 서비스 제공 과정에 반영함") | |
| if dementia: | |
| details.append("입력된 상담내용에 치매 또는 인지·행동 변화 관련 단서가 포함됨") | |
| plans.append("정면 반박을 피하고 감정 공감, 짧은 설명, 익숙한 화제 전환, 개별 프로그램 또는 안정 환경 제공을 적용함") | |
| if guardian_burden: | |
| details.append("입력된 상담내용에 보호자의 돌봄 부담 또는 추가 요청이 포함됨") | |
| plans.append("보호자 의견을 상담기록에 반영하고 서비스 조정 가능 범위, 병원진료, 상담, 지역자원 연계 필요성을 안내함") | |
| if not details: | |
| details.append("입력된 상담내용에서 별도 위험요인은 작성되지 않음") | |
| if not plans: | |
| plans.append("입력된 상담내용을 상담기록으로 보관하고 현재 서비스 제공 내용을 유지함") | |
| return { | |
| "combined": combined, | |
| "fall": fall, | |
| "pressure": pressure, | |
| "dementia": dementia, | |
| "guardian_burden": guardian_burden, | |
| "level": level, | |
| "details": details, | |
| "plans": plans, | |
| } | |
| def build_soap_sections(data, analysis, specialized): | |
| subject_sources = [ | |
| data.get("guardian_opinion", ""), | |
| data.get("custom_need", ""), | |
| _join_qna_answer_values(data.get("qna_answers", {})), | |
| data.get("client_status_change", ""), | |
| ] | |
| subjective = _clean_text(" / ".join([_formalize_sentence(x) for x in subject_sources if _clean_text(x)])) | |
| if not subjective: | |
| subjective = f"{data['counseling_type']} 과정에서 {data['counseling_reason']} 내용으로 일반상담을 진행함" | |
| objective_items = [] | |
| objective_fields = [ | |
| ("주요 질환 및 상태", "main_condition"), | |
| ("인지상태", "cognitive_status"), | |
| ("이동상태", "mobility_status"), | |
| ("식사상태", "diet_status"), | |
| ("배변·배뇨상태", "toileting_status"), | |
| ("상태변화", "client_status_change"), | |
| ("종사자 관찰사항", "staff_observation"), | |
| ] | |
| for label, key in objective_fields: | |
| value = _formalize_sentence(data.get(key)) | |
| if value: | |
| objective_items.append(f"{label}: {value}") | |
| if not objective_items: | |
| objective_items.append("추가 위험요인 입력 없이 일반 상담으로 기록함") | |
| objective = "\n".join([f"- {item}" for item in objective_items]) | |
| risk_summary = " / ".join(specialized["details"]) | |
| assessment_parts = [ | |
| f"상담대상은 {data['counseling_type']}이며, 입력된 상담내용을 기준으로 사정함.", | |
| risk_summary, | |
| ] | |
| if analysis["status_needed"]: | |
| assessment_parts.append("입력 내용에 상태변화 단서가 있어 상태변화기록지 작성 대상으로 판단함.") | |
| if analysis["case_needed"] or specialized["level"] == "위험": | |
| assessment_parts.append("입력 내용에 사례관리 연계가 필요한 위험 단서가 포함됨.") | |
| assessment = " ".join([part for part in assessment_parts if _clean_text(part)]) | |
| plan_items = list(specialized["plans"]) | |
| if analysis["care_plan_needed"]: | |
| plan_items.append(analysis["care_plan_reason"]) | |
| if analysis["status_needed"]: | |
| plan_items.append(analysis["status_reason"]) | |
| if analysis["case_needed"] or specialized["level"] == "위험": | |
| plan_items.append(analysis["case_reason"]) | |
| unique_plans = [] | |
| for item in plan_items: | |
| item = _formalize_sentence(item) | |
| if item and item not in unique_plans: | |
| unique_plans.append(item) | |
| plan = "\n".join([f"- {item}" for item in unique_plans[:8]]) | |
| return f""" | |
| [SOAP 상담기록] | |
| S(주관적 진술): {subjective} | |
| O(객관적 관찰): | |
| {objective} | |
| A(전문적 사정): {assessment} | |
| P(조치계획): | |
| {plan} | |
| """.strip() | |
| def analyze_counseling_answers(data): | |
| qna_text = _join_qna_answer_values(data.get("qna_answers", {})) | |
| combined = " ".join([ | |
| data.get("main_condition", ""), | |
| data.get("cognitive_status", ""), | |
| data.get("mobility_status", ""), | |
| data.get("diet_status", ""), | |
| data.get("toileting_status", ""), | |
| data.get("client_status_change", ""), | |
| data.get("guardian_opinion", ""), | |
| data.get("staff_observation", ""), | |
| data.get("custom_need", ""), | |
| qna_text, | |
| ]) | |
| status_needed = _contains_keywords(combined, STATUS_CHANGE_KEYWORDS) | |
| case_needed = _contains_keywords(combined, CASE_RISK_KEYWORDS) | |
| care_plan_needed = _contains_keywords(combined, CARE_PLAN_KEYWORDS) or status_needed or case_needed | |
| if case_needed: | |
| risk_level = "위험" | |
| elif status_needed: | |
| risk_level = "주의" | |
| else: | |
| risk_level = "안정" | |
| return { | |
| "qna_text": qna_text, | |
| "combined": combined, | |
| "risk_level": risk_level, | |
| "status_needed": status_needed, | |
| "case_needed": case_needed, | |
| "care_plan_needed": care_plan_needed, | |
| "status_reason": "상담 답변에서 상태변화 단서가 확인되어 상태변화기록지 작성 대상으로 기록함" if status_needed else "해당 없음", | |
| "case_reason": "상담 답변에서 반복·악화·위험 수준의 문제가 확인되어 사례관리 연계 대상으로 기록함" if case_needed else "해당 없음", | |
| "care_plan_reason": "상담 결과를 급여제공계획 또는 서비스 제공 방법에 반영할 필요가 있음" if care_plan_needed else "상담내용만 기록함", | |
| } | |
| def build_qna_generated_sections(data): | |
| analysis = analyze_counseling_answers(data) | |
| qna_text = analysis["qna_text"] or "Q&A 입력 내용 없음" | |
| status_text = _clean_text(data.get("client_status_change")) or "상태변화 입력 내용 없음" | |
| guardian_text = _clean_text(data.get("guardian_opinion")) or "보호자 의견 입력 내용 없음" | |
| staff_text = _clean_text(data.get("staff_observation")) or "종사자 관찰사항 입력 내용 없음" | |
| need_text = _clean_text(data.get("custom_need")) or "추가 욕구 및 요청사항 입력 내용 없음" | |
| follow_items = [] | |
| if analysis["status_needed"]: | |
| follow_items.append(analysis["status_reason"]) | |
| if analysis["case_needed"]: | |
| follow_items.append(analysis["case_reason"]) | |
| if analysis["care_plan_needed"]: | |
| follow_items.append(analysis["care_plan_reason"]) | |
| follow_text = "\n".join([f"- {item}" for item in follow_items]) or "- 단순 일반상담으로 기록함" | |
| if analysis["status_needed"] or analysis["case_needed"]: | |
| status_analysis_text = f"입력된 상담내용을 기준으로 위험수준은 {analysis['risk_level']} 단계로 기록함. 입력되지 않은 위험요인은 생성하지 않음." | |
| else: | |
| status_analysis_text = "입력된 상담내용을 기준으로 일반상담으로 기록함." | |
| optional_sections = [] | |
| if analysis["care_plan_needed"]: | |
| optional_sections.append(f"[급여제공계획 반영 여부]\n{analysis['care_plan_reason']}") | |
| if analysis["status_needed"]: | |
| optional_sections.append(f"[상태변화기록지 작성 필요 여부]\n{analysis['status_reason']}") | |
| if analysis["case_needed"]: | |
| optional_sections.append(f"[사례관리 연계 필요 여부]\n{analysis['case_reason']}") | |
| optional_text = "\n\n".join(optional_sections) | |
| return f""" | |
| [Q&A 확인 내용] | |
| 입력 답변: {qna_text} | |
| [상담내용] | |
| 상담분류는 {data['counseling_type']}이며, 상담사유는 {data['counseling_reason']}임. Q&A 및 기본 입력을 종합한 결과, 수급자 상태변화는 "{status_text}"로 확인됨. 보호자 의견은 "{guardian_text}"이며, 종사자 관찰사항은 "{staff_text}"로 정리됨. | |
| [상태분석] | |
| {status_analysis_text} | |
| [조치계획] | |
| 확인된 욕구 및 요청사항은 "{need_text}"임. 입력된 상담내용을 담당자와 공유하고 필요한 범위에서만 조치함. | |
| [후속관리] | |
| {follow_text} | |
| {chr(10) + chr(10) + optional_text if optional_text else ""} | |
| """.strip(), analysis | |
| OPENERS = [ | |
| "상담 과정에서 확인한 결과", | |
| "금일 상담을 통해 파악한 내용은", | |
| "최근 서비스 제공 상황을 종합해 보면", | |
| "수급자 및 관련자의 의견을 확인한 결과", | |
| ] | |
| TONES = [ | |
| "현재 상태를 유지하면서도 세심한 관찰이 요구됩니다.", | |
| "일상생활의 안정성을 높이기 위한 지속적인 지원이 필요합니다.", | |
| "개별 욕구가 급여제공 과정에 충분히 반영되도록 조정이 필요합니다.", | |
| "상태 변화 가능성을 고려하여 정기적인 확인과 기록이 필요합니다.", | |
| ] | |
| def mask_name(name: str) -> str: | |
| name = str(name or "").strip() | |
| if len(name) >= 3: | |
| return name[0] + "○" + name[2:] | |
| if len(name) == 2: | |
| return name[0] + "○" | |
| return name | |
| def _clean_text(value: str) -> str: | |
| text = str(value or "").strip() | |
| while ",," in text: | |
| text = text.replace(",,", ",") | |
| return text.strip(" ,") | |
| def _symptom_process(data): | |
| """입력된 상태변화에 따라 상담 과정이 단순 복붙이 되지 않도록 확인 절차를 자동 구성합니다.""" | |
| combined = " ".join([ | |
| data.get("main_condition", ""), | |
| data.get("client_status_change", ""), | |
| data.get("guardian_opinion", ""), | |
| data.get("staff_observation", ""), | |
| data.get("custom_need", ""), | |
| ]) | |
| steps = [] | |
| if any(k in combined for k in ["어지", "현기", "휘청", "기립", "멀미"]): | |
| steps.extend([ | |
| "입력된 어지럼 관련 내용을 상담기록에 반영하였다.", | |
| "어지럼과 함께 입력된 생활상 불편 사항을 담당자와 공유하기로 하였다.", | |
| ]) | |
| if any(k in combined for k in ["식사", "식욕", "영양", "체중", "사레", "연하"]): | |
| steps.extend([ | |
| "입력된 식사 관련 내용을 상담기록에 반영하였다.", | |
| "식사와 관련하여 입력된 요청사항을 서비스 제공 과정에 참고하기로 하였다.", | |
| ]) | |
| if any(k in combined for k in ["낙상", "보행", "지팡", "휠체어", "부축", "이동"]): | |
| steps.extend([ | |
| "보행 보조도구 사용상태, 이동 동선, 화장실 이동 시 위험요인, 바닥 미끄럼 여부를 확인하였다.", | |
| "낙상예방을 위해 이동 전후 직원 확인, 자리 이동 시 부축, 프로그램 참여 시 안전한 좌석 배치를 점검하기로 하였다.", | |
| ]) | |
| if any(k in combined for k in ["치매", "인지", "기억", "불안", "초조", "배회"]): | |
| steps.extend([ | |
| "입력된 인지 및 정서 관련 내용을 상담기록에 반영하였다.", | |
| "설명은 짧고 반복적으로 제공하고, 익숙한 환경에서 안정감을 느끼도록 정서지지를 병행하기로 하였다.", | |
| ]) | |
| if any(k in combined for k in ["배변", "배뇨", "기저귀", "실금", "변비"]): | |
| steps.extend([ | |
| "배변·배뇨 양상, 화장실 이동 가능 여부, 실금 또는 변비 여부를 확인하고 위생관리 필요성을 점검하였다.", | |
| ]) | |
| if not steps: | |
| steps = [ | |
| "입력된 상담내용을 중심으로 상담 사유와 요청사항을 기록하였다.", | |
| "상담 중 별도로 입력된 위험요인이 없어 일반 상담 내용으로 정리하였다.", | |
| "입력된 내용은 담당자와 공유하고 현재 서비스 제공 과정에 참고하기로 하였다.", | |
| ] | |
| # 중복 제거 | |
| unique = [] | |
| for step in steps: | |
| if step not in unique: | |
| unique.append(step) | |
| return "\n".join([f"{i}. {step}" for i, step in enumerate(unique[:7], 1)]) | |
| def build_counseling_text(data): | |
| # Prepare inputs | |
| selected_items_str = ", ".join(data.get("selected_items_list", [])) | |
| keywords = data.get("keywords", "").strip() | |
| additional_needs = data.get("additional_needs", "").strip() | |
| # Check if AI should be used | |
| use_ai = st.session_state.get("use_lm_studio", False) | |
| ai_generated = False | |
| ai_content = "" | |
| if use_ai: | |
| system_prompt = COUNSELING_GENERATION_RULE | |
| user_prompt = f""" | |
| 수급자명: {data['masked_name']} | |
| 장기요양등급: {data['care_grade']} | |
| 기관유형: {data['institution_type']} | |
| 상담 사유: {data['counseling_reason']} | |
| 상담 목적: {data['counseling_purpose']} | |
| 선택된 검토 항목: {selected_items_str} | |
| 핵심 단어: {keywords} | |
| 추가 욕구/요청 사항: {additional_needs} | |
| 연동 여부: 사례관리 필요({data['case_needed']}), 욕구사정 재검토 필요({data['needs_assessment_needed']}), 급여제공계획 변경 필요({data['careplan_change_needed']}), 등급변경 신청 검토 필요({data['care_grade_review_needed']}), 보호자 추가 상담 필요({data['followup_counseling_needed']}) | |
| """ | |
| basic_status = [] | |
| if data.get("cognitive_status", "").strip(): basic_status.append(f"- 인지상태: {data['cognitive_status']}") | |
| if data.get("mobility_status", "").strip(): basic_status.append(f"- 이동상태: {data['mobility_status']}") | |
| if data.get("diet_status", "").strip(): basic_status.append(f"- 식사상태: {data['diet_status']}") | |
| if data.get("toileting_status", "").strip(): basic_status.append(f"- 배변/배뇨상태: {data['toileting_status']}") | |
| if data.get("client_status_change", "").strip(): basic_status.append(f"- 상태변화: {data['client_status_change']}") | |
| if data.get("guardian_opinion", "").strip(): basic_status.append(f"- 보호자 의견: {data['guardian_opinion']}") | |
| if data.get("staff_observation", "").strip(): basic_status.append(f"- 종사자 관찰사항: {data['staff_observation']}") | |
| basic_status_str = "\n".join(basic_status) | |
| if basic_status_str: | |
| user_prompt += f"\n수급자 상태 참고 정보:\n{basic_status_str}" | |
| try: | |
| client = OpenAI(base_url="http://127.0.0.1:1234/v1", api_key="lm-studio") | |
| completion = client.chat.completions.create( | |
| model="qwen2.5-7b-instruct", | |
| messages=[ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": user_prompt}, | |
| ], | |
| temperature=0.7, | |
| ) | |
| ai_content = completion.choices[0].message.content | |
| ai_generated = True | |
| except Exception as e: | |
| st.error(f"LM Studio AI 생성 실패 ({e}). 규칙 기반 생성기로 대체합니다.") | |
| if not ai_generated: | |
| ai_content = generate_rule_based_counseling_text(data) | |
| # Parse sections | |
| parsed = parse_sections(ai_content) | |
| # Build content by combining sections 1, 2, 3, 5 | |
| content_parts = [] | |
| if parsed["상담 사유"]: | |
| content_parts.append(f"[상담 사유]\n{parsed['상담 사유']}") | |
| if parsed["상담 내용"]: | |
| content_parts.append(f"[상담 내용]\n{parsed['상담 내용']}") | |
| if parsed["전문적 사정"]: | |
| content_parts.append(f"[전문적 사정]\n{parsed['전문적 사정']}") | |
| if parsed["연동 필요 항목"]: | |
| content_parts.append(f"[연동 필요 항목]\n{parsed['연동 필요 항목']}") | |
| content = "\n\n".join(content_parts) | |
| if not content: | |
| content = ai_content # fallback | |
| action = parsed["조치계획"] or "상담 결과를 담당 직원들과 공유하고 서비스 제공에 반영함." | |
| # Generate support methods | |
| support_methods = build_individual_support_methods(data) | |
| return content, action, support_methods | |
| def evaluate_record(data): | |
| # 새 필드(핵심단어, 추가욕구) 반영하여 평가 기준 완화 및 검증 | |
| has_keywords_or_needs = bool(data.get("keywords", "").strip()) or bool(data.get("additional_needs", "").strip()) | |
| required = { | |
| "상담일자": bool(data["counseling_date"]), | |
| "상담분류": bool(data["counseling_type"]), | |
| "상담사유": bool(data["counseling_reason"]), | |
| "상태변화 반영": bool(data.get("client_status_change", "").strip()) or has_keywords_or_needs or "상태변화" in data.get("counseling_content", ""), | |
| "보호자 의견 반영": bool(data.get("guardian_opinion", "").strip()) or has_keywords_or_needs or data["counseling_type"] == "보호자상담", | |
| "종사자 관찰 반영": bool(data.get("staff_observation", "").strip()) or has_keywords_or_needs or data["counseling_type"] == "종사자상담", | |
| "후속조치": bool(data.get("action_plan", "").strip()), | |
| "급여제공 반영": "급여" in data.get("counseling_content", "") or "계획" in data.get("action_plan", ""), | |
| "지원방법 구체화": bool(data.get("support_methods", "").strip()) or "개인별 이용계획 지원방법" in data.get("counseling_content", ""), | |
| } | |
| score = int(sum(required.values()) / len(required) * 100) | |
| missing = [k for k, v in required.items() if not v] | |
| if score >= 90: | |
| result = "평가 적합도가 높습니다. 상담사유, 상태변화, 보호자 의견, 후속조치가 잘 반영되었습니다." | |
| elif score >= 75: | |
| result = "대체로 적합하나 일부 보완이 필요합니다. 부족 항목을 추가하면 평가 대응력이 높아집니다." | |
| else: | |
| result = "보완이 필요합니다. 상담사유, 상태변화, 보호자 의견, 후속조치를 더 구체적으로 입력해 주세요." | |
| return score, missing, result | |
| def style_range(ws, cell_range, fill=None, font=None, alignment=None): | |
| thin = Side(style="thin", color="000000") | |
| border = Border(left=thin, right=thin, top=thin, bottom=thin) | |
| cells = ws[cell_range] | |
| if not isinstance(cells, tuple): | |
| cells = ((cells,),) | |
| for row in cells: | |
| for cell in row: | |
| cell.border = border | |
| if fill: | |
| cell.fill = fill | |
| if font: | |
| cell.font = font | |
| if alignment: | |
| cell.alignment = alignment | |
| def split_text_by_length(text, max_len=650): | |
| text = str(text or "").strip() | |
| if not text: | |
| return [""] | |
| chunks = [] | |
| while len(text) > max_len: | |
| cut = text.rfind("\n", 0, max_len) | |
| if cut == -1: | |
| cut = text.rfind(". ", 0, max_len) | |
| if cut == -1: | |
| cut = text.rfind("다.", 0, max_len) | |
| if cut != -1: | |
| cut += 2 | |
| if cut == -1 or cut < int(max_len * 0.45): | |
| cut = max_len | |
| chunks.append(text[:cut].strip()) | |
| text = text[cut:].strip() | |
| if text: | |
| chunks.append(text) | |
| return chunks or [""] | |
| def generate_excel(data): | |
| wb = Workbook() | |
| ws = wb.active | |
| ws.title = "상담일지" | |
| ws.page_setup.paperSize = ws.PAPERSIZE_A4 | |
| ws.page_setup.orientation = "portrait" | |
| ws.page_setup.fitToWidth = 1 | |
| ws.page_setup.fitToHeight = 0 | |
| ws.sheet_properties.pageSetUpPr.fitToPage = True | |
| ws.page_margins.left = 0.3 | |
| ws.page_margins.right = 0.3 | |
| ws.page_margins.top = 0.4 | |
| ws.page_margins.bottom = 0.4 | |
| ws.sheet_view.showGridLines = False | |
| title_fill = PatternFill("solid", fgColor="E2F0D9") | |
| header_fill = PatternFill("solid", fgColor="F2F2F2") | |
| for col in range(1, 10): | |
| ws.column_dimensions[get_column_letter(col)].width = 11.5 | |
| ws.merge_cells("A1:G2") | |
| ws.cell(1, 1).value = "상 담 일 지" | |
| ws.cell(1, 1).font = Font(name="Malgun Gothic", size=24, bold=True) | |
| ws.cell(1, 1).alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) | |
| style_range(ws, "A1:G2", fill=title_fill) | |
| ws.cell(1, 8).value = "담당자" | |
| ws.cell(1, 9).value = "시설장" | |
| ws.cell(2, 8).value = "" | |
| ws.cell(2, 9).value = "" | |
| for r in [1, 2]: | |
| for c in [8, 9]: | |
| cell = ws.cell(r, c) | |
| cell.alignment = Alignment(horizontal="center", vertical="center") | |
| cell.font = Font(name="Malgun Gothic", size=11, bold=(r == 1)) | |
| if r == 1: | |
| cell.fill = header_fill | |
| style_range(ws, f"{get_column_letter(c)}{r}:{get_column_letter(c)}{r}") | |
| ws.row_dimensions[1].height = 28 | |
| ws.row_dimensions[2].height = 45 | |
| def write_info_row(row, a, b, c_val, d, e, f): | |
| ws.cell(row, 1).value = a | |
| ws.merge_cells(start_row=row, start_column=2, end_row=row, end_column=3) | |
| ws.cell(row, 2).value = b | |
| ws.cell(row, 4).value = c_val | |
| ws.merge_cells(start_row=row, start_column=5, end_row=row, end_column=6) | |
| ws.cell(row, 5).value = d | |
| ws.cell(row, 7).value = e | |
| ws.merge_cells(start_row=row, start_column=8, end_row=row, end_column=9) | |
| ws.cell(row, 8).value = f | |
| for col in [1, 4, 7]: | |
| ws.cell(row, col).fill = header_fill | |
| ws.cell(row, col).font = Font(name="Malgun Gothic", size=11, bold=True) | |
| ws.cell(row, col).alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) | |
| for col in [2, 5, 8]: | |
| ws.cell(row, col).font = Font(name="Malgun Gothic", size=11) | |
| ws.cell(row, col).alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) | |
| style_range(ws, f"A{row}:I{row}") | |
| ws.row_dimensions[row].height = 30 | |
| r = 3 | |
| write_info_row(r, "기관명", data["institution_name"], "기관유형", data["institution_type"], "상담분류", data["counseling_type"]); r += 1 | |
| write_info_row(r, "수급자", data["masked_name"], "장기요양등급", data["care_grade"], "상담일자", data["counseling_date"]); r += 1 | |
| write_info_row(r, "상담자", data["counselor"], "상담사유", data["counseling_reason"], "평가적합도", f"{data['evaluation_score']}점"); r += 1 | |
| def estimate_body_rows(text, min_rows=4): | |
| text = str(text or "") | |
| visual_lines = 0 | |
| for line in text.split("\n") or [""]: | |
| visual_lines += max(1, (len(line) // 62) + 1) | |
| return max(min_rows, min(24, visual_lines + 2)) | |
| def write_text_section(row, label, content_val, min_rows=4): | |
| pages = split_text_by_length(content_val, 650) | |
| for page_idx, page_text in enumerate(pages, start=1): | |
| display_label = f"{label} ({page_idx}/{len(pages)}페이지)" if len(pages) > 1 else label | |
| ws.merge_cells(start_row=row, start_column=1, end_row=row, end_column=9) | |
| ws.cell(row, 1).value = display_label | |
| ws.cell(row, 1).font = Font(name="Malgun Gothic", size=11, bold=True) | |
| ws.cell(row, 1).alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) | |
| ws.cell(row, 1).fill = header_fill | |
| style_range(ws, f"A{row}:I{row}", fill=header_fill) | |
| ws.row_dimensions[row].height = 24 | |
| body_start = row + 1 | |
| body_rows = estimate_body_rows(page_text, min_rows) | |
| body_end = body_start + body_rows - 1 | |
| ws.merge_cells(start_row=body_start, start_column=1, end_row=body_end, end_column=9) | |
| ws.cell(body_start, 1).value = page_text | |
| ws.cell(body_start, 1).font = Font(name="Malgun Gothic", size=10.5) | |
| ws.cell(body_start, 1).alignment = Alignment(horizontal="left", vertical="top", wrap_text=True) | |
| for body_row in range(body_start, body_end + 1): | |
| ws.row_dimensions[body_row].height = 19 | |
| style_range(ws, f"A{body_start}:I{body_end}") | |
| row = body_end + 1 | |
| if page_idx < len(pages): | |
| ws.row_breaks.append(Break(id=row)) | |
| return row | |
| detail_rows = [ | |
| ("상담내용", data["counseling_content"], 8), | |
| ("개인별 이용계획 지원방법", data.get("support_methods", ""), 6), | |
| ("조치계획", data["action_plan"], 5), | |
| ("평가 적합도 점검", data["compliance_result"], 3), | |
| ("부족한 항목", data["missing_items"], 3), | |
| ] | |
| for label, content_val, min_rows in detail_rows: | |
| r = write_text_section(r, label, content_val, min_rows) | |
| # 공통 안내문구 추가 | |
| ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=9) | |
| ws.cell(r, 1).value = "※ 생성된 문서는 그대로 사용하셔도 되며, 기관의 운영방침, 수급자 상태, 보호자 의견 및 평가기준에 따라 추가 보완사항은 얼마든지 수정 가능합니다. 기관의 상황에 맞게 활용하시기 바랍니다." | |
| ws.cell(r, 1).font = Font(name="Malgun Gothic", size=8.5, color="595959", italic=True) | |
| ws.cell(r, 1).alignment = Alignment(horizontal="left", vertical="center", wrap_text=True) | |
| style_range(ws, f"A{r}:I{r}") | |
| ws.row_dimensions[r].height = 36 | |
| r += 1 | |
| ws.print_area = f"A1:I{r - 1}" | |
| output = BytesIO() | |
| wb.save(output) | |
| output.seek(0) | |
| return output | |
| clients_df = load_clients() | |
| # Sidebar config | |
| st.sidebar.subheader("AI 설정") | |
| use_lm_studio = st.sidebar.toggle("LM Studio / Qwen 사용", value=False) | |
| st.session_state["use_lm_studio"] = use_lm_studio | |
| if DB_PATH and os.path.exists(DB_PATH): | |
| st.sidebar.success("Client Center DB 연결됨") | |
| st.sidebar.caption(DB_PATH) | |
| else: | |
| st.sidebar.warning("Client Center DB를 찾지 못했습니다. 직접 입력 모드로 작동합니다.") | |
| st.subheader("1단계. 수급자 선택 및 기본정보") | |
| 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['장기요양등급']} / {x['기관유형']} / {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] | |
| elif mode == "Client Center에서 수급자 선택" and clients_df.empty: | |
| st.warning("등록된 수급자 정보가 없거나 DB를 찾지 못했습니다. 직접 입력 모드로 진행하세요.") | |
| # Prefill variables | |
| if selected_client is not None: | |
| default_name = selected_client.get("name", "홍길동") | |
| default_masked_name = selected_client.get("masked_name", "홍○동") | |
| default_grade = str(selected_client.get("장기요양등급", "3등급")).strip() | |
| if default_grade in ["1", "2", "3", "4", "5"]: | |
| default_grade = default_grade + "등급" | |
| default_inst_type = selected_client.get("기관유형", "방문요양") | |
| default_inst_name = selected_client.get("기관명", "") | |
| default_recognition_number = selected_client.get("인정번호", "") | |
| default_recognition_start = selected_client.get("인정기간 시작일", "") | |
| default_recognition_end = selected_client.get("인정기간 종료일", "") | |
| default_main_condition = selected_client.get("main_condition", "") | |
| # Load status inputs if available | |
| default_cognitive = selected_client.get("cognitive_status", "") | |
| default_mobility = selected_client.get("mobility_status", "") | |
| default_diet = selected_client.get("diet_status", "") | |
| default_toileting = selected_client.get("toileting_status", "") | |
| default_notes = selected_client.get("notes", "") | |
| else: | |
| default_name = "홍길동" | |
| default_masked_name = "홍○동" | |
| default_grade = "3등급" | |
| default_inst_type = "방문요양" | |
| default_inst_name = "장기요양기관" | |
| default_recognition_number = "" | |
| default_recognition_start = "" | |
| default_recognition_end = "" | |
| default_main_condition = "" | |
| default_cognitive = "" | |
| default_mobility = "" | |
| default_diet = "" | |
| default_toileting = "" | |
| default_notes = "" | |
| # Show input columns | |
| col1, col2, col3 = st.columns(3) | |
| institution_name = col1.text_input("기관명", default_inst_name) | |
| institution_type = col2.selectbox( | |
| "기관유형", | |
| list(INSTITUTION_GUIDES.keys()), | |
| index=list(INSTITUTION_GUIDES.keys()).index(default_inst_type) if default_inst_type in INSTITUTION_GUIDES else 0 | |
| ) | |
| counseling_type = col3.radio("상담분류", ["수급자상담", "보호자상담", "종사자상담", "기타상담"], horizontal=True) | |
| col4, col5, col6, col7 = st.columns(4) | |
| name = col4.text_input("수급자 이름", default_name) | |
| masked_name = col5.text_input("가명 표기", default_masked_name) | |
| care_grade = col6.selectbox( | |
| "장기요양등급", | |
| ["1등급", "2등급", "3등급", "4등급", "5등급", "인지지원등급", "미입력"], | |
| index=["1등급", "2등급", "3등급", "4등급", "5등급", "인지지원등급", "미입력"].index(default_grade) if default_grade in ["1등급", "2등급", "3등급", "4등급", "5등급", "인지지원등급", "미입력"] else 2 | |
| ) | |
| counseling_date = col7.date_input("상담일자", value=date.today()) | |
| col8, col9, col10 = st.columns(3) | |
| counselor = col8.text_input("상담자", "사회복지사") | |
| counseling_reason = col9.selectbox( | |
| "상담 사유 (trigger)", | |
| [ | |
| "상태변화기록지 상담필요 기록", | |
| "수급자 요청", | |
| "보호자 요청", | |
| "요양보호사/종사자 요청", | |
| "사회복지사 판단", | |
| "시설장/관리책임자 판단", | |
| "사례관리 검토 필요", | |
| "욕구사정 재검토 필요", | |
| "급여제공계획 변경 필요", | |
| "기타 직접입력", | |
| ] | |
| ) | |
| main_condition = col10.text_input("주요 질환/상태", default_main_condition) | |
| # Recognition numbers and dates (Section 3-1) | |
| col_rec1, col_rec2, col_rec3 = st.columns(3) | |
| recognition_number = col_rec1.text_input("인정번호", default_recognition_number) | |
| recognition_start_date = col_rec2.text_input("인정기간 시작일", default_recognition_start, placeholder="예: 2025-03-03") | |
| recognition_end_date = col_rec3.text_input("인정기간 종료일", default_recognition_end, placeholder="예: 2027-03-02") | |
| st.subheader("2단계. 상담 목적 및 관련 항목 선택") | |
| counseling_purpose = st.radio( | |
| "이번 상담의 목적", | |
| [ | |
| "일반 상담", | |
| "사례관리 검토 상담", | |
| "욕구사정 재검토 상담", | |
| "급여제공계획 변경 상담", | |
| "복합 상담", | |
| ], | |
| horizontal=True | |
| ) | |
| selected_items_list = [] | |
| # Dynamic multiselect elements based on purpose | |
| if counseling_purpose in ["사례관리 검토 상담", "복합 상담"]: | |
| case_items = st.multiselect( | |
| "사례관리 검토 항목", | |
| [ | |
| "이동능력 저하", | |
| "워커 사용에서 휠체어 사용으로 변경", | |
| "반복 낙상 또는 낙상위험 증가", | |
| "하지근력 약화", | |
| "입원 또는 퇴원 후 관리 필요", | |
| "장기요양등급 변경 신청 검토", | |
| "보호자 돌봄 부담 증가", | |
| "서비스 시간 또는 횟수 조정 필요", | |
| "보조기구 변경 필요", | |
| "치매행동 또는 인지기능 변화", | |
| "식사량 감소 또는 영양불균형", | |
| "배변·배뇨 문제 증가", | |
| "정서불안, 우울, 수면문제", | |
| "가족·주거·경제적 문제", | |
| "지역사회 자원 연계 필요", | |
| "기타 직접입력", | |
| ] | |
| ) | |
| selected_items_list.extend(case_items) | |
| if counseling_purpose in ["욕구사정 재검토 상담", "복합 상담"]: | |
| needs_items = st.multiselect( | |
| "욕구사정 재검토 항목", | |
| [ | |
| "ADL 변화", | |
| "IADL 변화", | |
| "보행 및 이동능력 변화", | |
| "보조기구 사용 변화", | |
| "인지기능 변화", | |
| "의사소통 변화", | |
| "식사 및 영양상태 변화", | |
| "배변·배뇨 상태 변화", | |
| "낙상위험 증가", | |
| "욕창위험 증가", | |
| "통증 증가", | |
| "수면상태 변화", | |
| "정서상태 변화", | |
| "보호자 또는 가족환경 변화", | |
| "주거환경 변화", | |
| "입원·퇴원·질병상태 변화", | |
| "기타 직접입력", | |
| ] | |
| ) | |
| selected_items_list.extend(needs_items) | |
| if counseling_purpose in ["급여제공계획 변경 상담", "복합 상담"]: | |
| careplan_items = st.multiselect( | |
| "급여제공계획 변경 항목", | |
| [ | |
| "서비스 시간 변경 필요", | |
| "서비스 횟수 변경 필요", | |
| "서비스 내용 변경 필요", | |
| "신체활동지원 변경", | |
| "인지활동지원 변경", | |
| "정서지원 변경", | |
| "일상생활지원 변경", | |
| "가족상담 또는 보호자 지원 필요", | |
| "낙상예방 계획 반영", | |
| "욕창예방 계획 반영", | |
| "식사·영양관리 계획 반영", | |
| "배변·배뇨관리 계획 반영", | |
| "보조기구 변경 반영", | |
| "등급변경 신청 검토", | |
| "목표 수정 필요", | |
| "공단 입력 참고자료 필요", | |
| "기타 직접입력", | |
| ] | |
| ) | |
| selected_items_list.extend(careplan_items) | |
| st.subheader("3단계. 상세 정보 입력") | |
| col_text1, col_text2 = st.columns(2) | |
| with col_text1: | |
| keywords = st.text_area( | |
| "핵심 단어 또는 짧은 문장 입력", | |
| placeholder="예: 워커→휠체어, 등급변경, 보호자 부담", | |
| help="입력한 단어와 선택한 항목만 문서에 반영됩니다. 입력하지 않은 내용은 임의 생성되지 않습니다." | |
| ) | |
| st.caption("※ 사례관리, 욕구사정 재검토, 급여제공계획 변경이 필요한 경우에는 관련 항목을 선택하고 위에 핵심 단어를 2~3개 입력해 주세요.") | |
| with col_text2: | |
| additional_needs = st.text_area( | |
| "추가욕구 / 요청사항", | |
| placeholder="예: 사례관리 필요 - 워커 사용에서 휠체어 사용으로 변경되어 이동지원 재검토 필요", | |
| help="입력된 내용은 해당 문서 작성 시 참고자료로 연동됩니다." | |
| ) | |
| st.caption("※ 사례관리, 욕구사정 재검토, 급여제공계획 변경이 필요한 경우에는 이곳에 구체적인 사유를 입력해 주세요.") | |
| # Optional/Basic input fields just in case they want to add them | |
| with st.expander("🔍 추가 상태 관찰 정보 입력 (기본 상태/Q&A 입력 펼치기)"): | |
| input_mode = st.radio("입력 방식", ["기본 입력", "Q&A 입력", "기본+Q&A 입력"], horizontal=True) | |
| use_basic_input = input_mode in ["기본 입력", "기본+Q&A 입력"] | |
| use_qna_input = input_mode in ["Q&A 입력", "기본+Q&A 입력"] | |
| cognitive_status = "" | |
| mobility_status = "" | |
| diet_status = "" | |
| toileting_status = "" | |
| client_status_change = "" | |
| guardian_opinion = "" | |
| staff_observation = "" | |
| custom_need = "" | |
| qna_answers = {} | |
| if use_basic_input: | |
| c1, c2 = st.columns(2) | |
| cognitive_status = c1.text_area("인지상태", value=default_cognitive, placeholder="예: 반복질문 증가, 시간/장소 혼동 등", height=80) | |
| mobility_status = c2.text_area("이동상태", value=default_mobility, placeholder="예: 보행 불안정, 부축 필요 등", height=80) | |
| c3, c4 = st.columns(2) | |
| diet_status = c3.text_area("식사상태", value=default_diet, placeholder="예: 식사량 감소, 사레, 연하곤란 등", height=80) | |
| toileting_status = c4.text_area("배변/배뇨상태", value=default_toileting, placeholder="예: 변비, 실금, 기저귀 사용 증가 등", height=80) | |
| c5, c6 = st.columns(2) | |
| client_status_change = c5.text_area("수급자 상태변화", placeholder="예: 최근 어지럼증이 있어 보행을 꺼림, 식사량 감소, 수면 변화 등", height=100) | |
| guardian_opinion = c6.text_area("보호자 의견", placeholder="예: 어지럼이 심해지면 병원진료를 위해 병원동행 요청", height=100) | |
| c7, c8 = st.columns(2) | |
| staff_observation = c7.text_area("종사자 관찰사항", placeholder="예: 이동 시 부축 제공, 안색/보행상태 관찰, 식사량 확인 등", height=100) | |
| custom_need = c8.text_area("추가 욕구 / 요청사항", value=default_notes, placeholder="예: 이동 도움 강화, 혈압 재확인, 보호자에게 증상변화 안내 등", height=100) | |
| if use_qna_input: | |
| st.info(f"{counseling_type}에 맞는 질문이 자동 표시됩니다. 답변 내용은 상담내용 정돈에 활용됩니다.") | |
| questions = QA_RULES.get(counseling_type, QA_RULES["기타상담"]) | |
| for idx, question in enumerate(questions, 1): | |
| qna_answers[question] = st.text_area(f"Q{idx}. {question}", key=f"qna_{counseling_type}_{idx}", height=80) | |
| st.subheader("4단계. 연동 설정 및 생성") | |
| col_chk1, col_chk2, col_chk3, col_chk4, col_chk5 = st.columns(5) | |
| case_needed = col_chk1.checkbox("사례관리 필요", value=(counseling_purpose == "사례관리 검토 상담")) | |
| needs_assessment_needed = col_chk2.checkbox("욕구사정 재검토 필요", value=(counseling_purpose == "욕구사정 재검토 상담")) | |
| careplan_change_needed = col_chk3.checkbox("급여제공계획 변경 필요", value=(counseling_purpose == "급여제공계획 변경 상담")) | |
| care_grade_review_needed = col_chk4.checkbox("등급변경 신청 검토 필요") | |
| followup_counseling_needed = col_chk5.checkbox("보호자 추가 상담 필요") | |
| if st.button("📝 상담일지 빠르게 생성", use_container_width=True): | |
| data = { | |
| "client_id": int(selected_client["id"]) if selected_client is not None and "id" in selected_client else 0, | |
| "institution_name": institution_name, | |
| "institution_type": institution_type, | |
| "counseling_type": counseling_type, | |
| "name": name, | |
| "masked_name": masked_name, | |
| "care_grade": care_grade, | |
| "counseling_date": str(counseling_date), | |
| "counselor": counselor, | |
| "counseling_reason": counseling_reason, # trigger | |
| "counseling_purpose": counseling_purpose, | |
| "selected_items_list": selected_items_list, | |
| "keywords": keywords, | |
| "additional_needs": additional_needs, | |
| "case_needed": case_needed, | |
| "needs_assessment_needed": needs_assessment_needed, | |
| "careplan_change_needed": careplan_change_needed, | |
| "care_grade_review_needed": care_grade_review_needed, | |
| "followup_counseling_needed": followup_counseling_needed, | |
| "main_condition": main_condition, | |
| # Optional basic status fields | |
| "cognitive_status": cognitive_status, | |
| "mobility_status": mobility_status, | |
| "diet_status": diet_status, | |
| "toileting_status": toileting_status, | |
| "client_status_change": client_status_change, | |
| "guardian_opinion": guardian_opinion, | |
| "staff_observation": staff_observation, | |
| "custom_need": custom_need, | |
| "qna_answers": qna_answers, | |
| } | |
| content, action, support_methods = build_counseling_text(data) | |
| data["counseling_content"] = content | |
| data["action_plan"] = action | |
| data["support_methods"] = support_methods | |
| score, missing, result = evaluate_record(data) | |
| data["evaluation_score"] = score | |
| data["missing_items"] = ", ".join(missing) if missing else "부족 항목 없음" | |
| data["compliance_result"] = result | |
| st.session_state["counseling_data"] = data | |
| st.session_state.pop("counseling_excel", None) | |
| if "counseling_data" in st.session_state: | |
| data = st.session_state["counseling_data"] | |
| st.markdown("---") | |
| st.subheader("5단계. 생성 결과 확인/수정") | |
| data["counseling_content"] = st.text_area("상담 사유 및 내용", value=data["counseling_content"], height=280) | |
| data["action_plan"] = st.text_area("조치계획", value=data["action_plan"], height=130) | |
| data["support_methods"] = st.text_area("개인별 이용계획 지원방법", value=data.get("support_methods", ""), height=150) | |
| # Recalculate score on modification | |
| score, missing, result = evaluate_record(data) | |
| data["evaluation_score"] = score | |
| data["missing_items"] = ", ".join(missing) if missing else "부족 항목 없음" | |
| data["compliance_result"] = result | |
| analysis = analyze_counseling_answers(data) | |
| m1, m2, m3 = st.columns(3) | |
| m1.metric("평가 적합도", f"{score}점") | |
| m2.metric("부족 항목", data["missing_items"]) | |
| m3.metric("출력", "엑셀 선택 생성") | |
| link1, link2, link3 = st.columns(3) | |
| link1.metric("사례관리 연계", "필요" if data["case_needed"] else "해당 없음") | |
| link2.metric("욕구사정 재검토", "필요" if data["needs_assessment_needed"] else "해당 없음") | |
| link3.metric("급여계획 반영", "필요" if data["careplan_change_needed"] else "유지") | |
| if score >= 90: | |
| st.markdown(f'<div class="okbox">{result}</div>', unsafe_allow_html=True) | |
| else: | |
| st.markdown(f'<div class="warnbox">{result}</div>', unsafe_allow_html=True) | |
| col_save1, col_save2 = st.columns(2) | |
| with col_save1: | |
| if st.button("💾 DB에 상담기록 저장하기", use_container_width=True): | |
| success = save_counseling_record(data) | |
| if success: | |
| st.success("대표님! 상담일지 기록이 성공적으로 DB에 저장되었습니다! 😎") | |
| else: | |
| st.error("DB 저장 실패. 로그를 확인하세요.") | |
| with col_save2: | |
| if st.button("📦 엑셀 파일 준비하기", use_container_width=True): | |
| st.session_state["counseling_excel"] = generate_excel(data) | |
| st.success("엑셀 파일이 준비되었습니다.") | |
| if "counseling_excel" in st.session_state: | |
| filename = f"상담일지_{data['masked_name']}_{data['counseling_type']}_{datetime.now().strftime('%Y%m%d')}.xlsx" | |
| st.download_button("📥 상담일지 엑셀 다운로드", data=st.session_state["counseling_excel"], file_name=filename, mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", use_container_width=True) | |
| st.caption("통합 연동 스마트 버전: Client Center DB 및 Excel 데이터와 상호작용하여 상담일지를 생성합니다.") | |