Spaces:
Sleeping
Sleeping
File size: 90,483 Bytes
67064aa 2e55539 67064aa 2e55539 67064aa 2e55539 67064aa 2e55539 67064aa 2e55539 67064aa 2e55539 67064aa 2e55539 67064aa 2e55539 67064aa | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 1923 1924 1925 1926 1927 1928 1929 1930 1931 1932 1933 1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961 1962 1963 1964 1965 1966 1967 1968 1969 1970 1971 1972 1973 1974 1975 1976 1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047 2048 2049 2050 2051 2052 2053 2054 2055 2056 2057 2058 2059 2060 2061 2062 2063 2064 2065 2066 2067 2068 2069 2070 2071 2072 2073 2074 2075 2076 2077 2078 2079 2080 2081 2082 2083 2084 2085 2086 2087 2088 2089 2090 2091 2092 2093 2094 2095 2096 2097 2098 2099 2100 2101 2102 2103 2104 2105 2106 2107 2108 2109 2110 2111 2112 2113 | import os
import re
import glob
import tempfile
from typing import Dict, List, TypedDict, Optional, Tuple, Set, Any, Union
from dataclasses import dataclass
from enum import Enum
import numpy as np
import pandas as pd
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.vectorstores import FAISS
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.schema import Document
from langgraph.graph import StateGraph, END
import json
from datetime import datetime
import logging
import streamlit as st
from streamlit_lottie import st_lottie
import requests
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# ========== 데이터 모델 정의 ==========
class DiseaseStage(Enum):
"""신장 질환 단계"""
CKD_1 = "CKD Stage 1"
CKD_2 = "CKD Stage 2"
CKD_3 = "CKD Stage 3"
CKD_4 = "CKD Stage 4"
CKD_5 = "CKD Stage 5"
DIALYSIS = "Dialysis"
TRANSPLANT = "Transplant"
class TaskType(Enum):
"""질문 유형 분류"""
DIET_RECOMMENDATION = "diet_recommendation" # 식단 추천
DIET_ANALYSIS = "diet_analysis" # 특정 식품 분석
MEDICATION = "medication" # 복약 관련
LIFESTYLE = "lifestyle" # 생활 관리
DIAGNOSIS = "diagnosis" # 진단/검사
EXERCISE = "exercise" # 운동
GENERAL = "general" # 일반 정보
@dataclass
class PatientConstraints:
"""환자 개별 제약조건"""
egfr: float # 사구체여과율
disease_stage: DiseaseStage
on_dialysis: bool
comorbidities: List[str] # 동반질환 목록
medications: List[str] # 복용 약물 목록
age: int
gender: str
# 영양 제한사항
protein_restriction: Optional[float] = None # g/day
sodium_restriction: Optional[float] = None # mg/day
potassium_restriction: Optional[float] = None # mg/day
phosphorus_restriction: Optional[float] = None # mg/day
fluid_restriction: Optional[float] = None # ml/day
calorie_target: Optional[float] = None # kcal/day
@dataclass
class RecommendationItem:
"""추천 항목"""
name: str
category: str # 식이, 운동, 약물 등
description: str
constraints_satisfied: bool
embedding: Optional[np.ndarray] = None
@dataclass
class FoodItem:
"""식품 정보 (실제 CSV 구조 반영)"""
food_code: str # 식품코드
name: str # 식품명
food_category_major: str # 식품대분류명
food_category_minor: str # 식품중분류명
serving_size: float # 영양성분함량기준량 (보통 100g)
calories: float # 에너지(kcal)
water: float # 수분(g)
protein: float # 단백질(g)
fat: float # 지방(g)
carbohydrate: float # 탄수화물(g)
sugar: float # 당류(g)
dietary_fiber: float # 식이섬유(g)
calcium: float # 칼슘(mg)
iron: float # 철(mg)
phosphorus: float # 인(mg)
potassium: float # 칼륨(mg)
sodium: float # 나트륨(mg)
cholesterol: float # 콜레스테롤(mg)
saturated_fat: float # 포화지방산(g)
def get_nutrients_per_serving(self, serving_g: float = 100) -> Dict[str, float]:
"""지정된 양(g)에 대한 영양소 함량 계산"""
ratio = serving_g / self.serving_size
return {
'calories': self.calories * ratio,
'protein': self.protein * ratio,
'fat': self.fat * ratio,
'carbohydrate': self.carbohydrate * ratio,
'sodium': self.sodium * ratio,
'potassium': self.potassium * ratio,
'phosphorus': self.phosphorus * ratio
}
def is_suitable_for_patient(self, constraints: PatientConstraints,
serving_g: float = 100) -> Tuple[bool, List[str]]:
"""환자 제약조건에 적합한지 확인"""
issues = []
nutrients = self.get_nutrients_per_serving(serving_g)
# 일일 제한량의 30%를 한 끼 기준으로 설정
meal_ratio = 0.3
# 단백질 체크
if constraints.protein_restriction:
if nutrients['protein'] > constraints.protein_restriction * meal_ratio:
issues.append(f"단백질 함량이 높음 ({nutrients['protein']:.1f}g)")
# 나트륨 체크
if constraints.sodium_restriction:
if nutrients['sodium'] > constraints.sodium_restriction * meal_ratio:
issues.append(f"나트륨 함량이 높음 ({nutrients['sodium']:.0f}mg)")
# 칼륨 체크
if constraints.potassium_restriction:
if nutrients['potassium'] > constraints.potassium_restriction * meal_ratio:
issues.append(f"칼륨 함량이 높음 ({nutrients['potassium']:.0f}mg)")
# 인 체크
if constraints.phosphorus_restriction:
if nutrients['phosphorus'] > constraints.phosphorus_restriction * meal_ratio:
issues.append(f"인 함량이 높음 ({nutrients['phosphorus']:.0f}mg)")
return len(issues) == 0, issues
# ========== State 정의 ==========
class GraphState(TypedDict):
"""LangGraph State"""
user_query: str
patient_constraints: PatientConstraints
task_type: TaskType
draft_response: str
draft_items: List[RecommendationItem]
corrected_items: List[RecommendationItem]
final_response: str
catalog_results: List[Document]
iteration_count: int
error: Optional[str]
food_analysis_results: Optional[Dict[str, Any]]
recommended_foods: Optional[List[FoodItem]]
meal_plan: Optional[Dict[str, List[FoodItem]]]
current_node: str # 현재 처리 중인 노드
processing_log: List[str] # 처리 로그
# ========== Catalog 관리 ==========
class KidneyDiseaseCatalog:
"""신장질환 정보 카탈로그 - 싱글톤 패턴 적용"""
_instance = None
_initialized = False
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super(KidneyDiseaseCatalog, cls).__new__(cls)
return cls._instance
def __init__(self, documents_path: str = "./data"):
if KidneyDiseaseCatalog._initialized:
return
self.embeddings = OpenAIEmbeddings()
self.vectorstore = None
self.documents_path = documents_path
self.metadata_index = {} # 문서 메타데이터 인덱스
# 태그 매핑 정의
self.field_mapping = {
"식이": "diet", "운동": "exercise", "진단": "diagnosis",
"복약": "medication", "치료": "treatment", "교육": "education",
"생활": "lifestyle"
}
self.status_mapping = {
"CKD": "chronic_kidney_disease", "HD": "hemodialysis",
"PD": "peritoneal_dialysis", "DIA": "dialysis",
"TX": "transplant", "ALL": "all"
}
self.level_mapping = {
"COM": "common", "STD": "standard", "DM": "diabetes",
"HTN": "hypertension", "OLD": "elderly", "PREG": "pregnancy",
"OBES": "obesity", "SYM": "symptom"
}
self.priority_mapping = {
"S1": "emergency", "S2": "caution", "S3": "general", "S4": "reference"
}
# 초기화 시 문서 로드
self.load_documents()
KidneyDiseaseCatalog._initialized = True
def parse_filename_tags(self, filename: str) -> Dict[str, str]:
"""파일명에서 태그 파싱"""
pattern = r'\[([^-]+)-([^-]+)-([^-]+)-([^\]]+)\]'
match = re.search(pattern, filename)
if match:
field, status, level, priority = match.groups()
return {
"field": self.field_mapping.get(field, field),
"patient_status": self.status_mapping.get(status, status),
"personalization_level": self.level_mapping.get(level, level),
"safety_priority": self.priority_mapping.get(priority, priority),
"raw_tags": f"{field}-{status}-{level}-{priority}"
}
return {}
def load_documents(self):
"""권위있는 기관의 문서들을 로드"""
if self.vectorstore is not None:
logger.info("Documents already loaded")
return
documents = []
# data 폴더의 모든 txt 파일 로드
file_pattern = os.path.join(self.documents_path, "*.txt")
file_paths = glob.glob(file_pattern)
if not file_paths:
logger.warning(f"No documents found in {self.documents_path}. Creating sample files...")
file_paths = self._create_comprehensive_sample_files()
for file_path in file_paths:
try:
filename = os.path.basename(file_path)
tags = self.parse_filename_tags(filename)
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
title_pattern = r'^#\s*(.+)$'
title_match = re.search(title_pattern, content, re.MULTILINE)
if title_match:
title = title_match.group(1)
else:
title = filename.split(']')[-1].replace('.txt', '').strip()
if not title:
title = filename.replace('.txt', '')
source = self._extract_source(content, filename)
doc = Document(
page_content=content,
metadata={
"filename": filename,
"title": title,
"source": source,
"timestamp": datetime.now().isoformat(),
**tags
}
)
documents.append(doc)
self.metadata_index[filename] = doc.metadata
logger.info(f"Loaded document: {filename}")
except Exception as e:
logger.error(f"Error loading file {file_path}: {e}")
continue
# 텍스트 분할
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=2000,
chunk_overlap=100,
separators=["\n\n", "\n", ".", "!", "?", ",", " ", ""]
)
split_docs = text_splitter.split_documents(documents)
for doc in split_docs:
doc.metadata["chunk_id"] = f"{doc.metadata['filename']}_{hash(doc.page_content)}"
self.vectorstore = FAISS.from_documents(split_docs, self.embeddings)
logger.info(f"Loaded {len(documents)} documents ({len(split_docs)} chunks) into vectorstore")
def _extract_source(self, content: str, filename: str) -> str:
"""문서 내용에서 출처 기관 추출"""
source_patterns = [
"보건복지부", "질병관리청", "대한의학회", "대한신장학회",
"대한당뇨병학회", "대한의료사회복지사협회"
]
for pattern in source_patterns:
if pattern in content:
return pattern
return "관련 기관"
def _create_comprehensive_sample_files(self) -> List[str]:
"""포괄적인 샘플 파일 생성"""
sample_files = []
samples = [
{
"filename": "[식이-CKD-STD-S3] 만성콩팥병 환자의 단백질 섭취 가이드.txt",
"content": """# 만성콩팥병 환자의 단백질 섭취 가이드
## 개요
만성콩팥병(CKD) 환자의 적절한 단백질 섭취는 질병 진행을 늦추고 영양 상태를 유지하는 데 중요합니다.
## 단계별 단백질 섭취 권장량
- CKD 1-2단계: 정상 섭취 (체중 kg당 0.8-1.0g)
- CKD 3-4단계: 제한 필요 (체중 kg당 0.6-0.8g)
- CKD 5단계(투석 전): 엄격한 제한 (체중 kg당 0.6g)
- 혈액투석 환자: 증가 필요 (체중 kg당 1.2g)
- 복막투석 환자: 더 증가 필요 (체중 kg당 1.2-1.3g)
## 양질의 단백질 선택
1. 동물성 단백질: 달걀, 생선, 닭가슴살
2. 식물성 단백질: 두부, 콩류 (인 함량 주의)
## 주의사항
- 과도한 단백질 섭취는 신장에 부담을 줍니다
- 개인별 상태에 따라 섭취량 조절이 필요합니다
- 정기적인 영양 상담을 받으세요
출처: 대한신장학회"""
},
{
"filename": "[복약-HD-HTN-S2] 혈액투석 환자의 고혈압 약물 관리.txt",
"content": """# 혈액투석 환자의 고혈압 약물 관리
## 주요 원칙
혈액투석 환자의 약 70-80%가 고혈압을 동반하며, 적절한 약물 관리가 필수적입니다.
## 복약 시간 조절
1. 투석 후 복용 권장 약물
- ACE 억제제, ARB: 투석으로 제거될 수 있음
- 베타차단제: 투석 중 저혈압 위험
2. 투석과 무관하게 복용 가능한 약물
- 칼슘채널차단제: 투석으로 제거되지 않음
## 약물 상호작용 주의
- 인결합제와 다른 약물은 최소 2시간 간격
- 철분제와 일부 항생제는 동시 복용 금지
## 혈압 목표
- 투석 전: 140/90 mmHg 미만
- 투석 후: 130/80 mmHg 미만
출처: 대한신장학회"""
},
{
"filename": "[식이-HD-STD-S2] 혈액투석 환자의 칼륨 제한 식이요법.txt",
"content": """# 혈액투석 환자의 칼륨 제한 식이요법
## 칼륨 제한의 중요성
혈액투석 환자는 소변량 감소로 칼륨 배설이 어려워 고칼륨혈증 위험이 높습니다.
## 일일 칼륨 섭취 권장량
- 혈액투석 환자: 2000-2500mg/일
- 잔여 신기능에 따라 조절 필요
## 고칼륨 식품 (제한 필요)
- 과일: 바나나, 참외, 토마토, 오렌지
- 채소: 시금치, 감자, 고구마, 버섯
- 기타: 초콜릿, 견과류, 우유
## 칼륨 감소 조리법
1. 채소는 잘게 썰어 물에 2시간 담근 후 헹구기
2. 끓는 물에 데친 후 국물은 버리기
3. 과일은 통조림 사용 (시럽 제거)
출처: 보건복지부"""
},
{
"filename": "[생활-CKD-STD-S3] 만성콩팥병 환자의 수분 섭취 관리.txt",
"content": """# 만성콩팥병 환자의 수분 섭취 관리
## 수분 제한이 필요한 경우
- 소변량이 하루 500ml 이하로 감소
- 부종이 있는 경우
- 심부전을 동반한 경우
## 일일 수분 섭취량 계산
- 기본 공식: 전날 소변량 + 500ml
- 투석 환자: 투석 간 체중 증가 1kg 이내
## 수분 섭취 관리 요령
1. 모든 액체류 포함 (국, 우유, 아이스크림 등)
2. 작은 컵 사용하기
3. 얼음 조각으로 갈증 해소
4. 무설탕 껌이나 신 사탕 활용
## 주의사항
- 과도한 수분 제한도 위험
- 개인별 상태에 따라 조절
- 정기적인 체중 측정 필요
출처: 대한의학회"""
},
{
"filename": "[운동-CKD-STD-S3] 만성콩팥병 환자의 운동 가이드.txt",
"content": """# 만성콩팥병 환자의 운동 가이드
## 운동의 이점
- 심혈관 기능 개선
- 혈압 조절
- 근력 유지
- 우울감 감소
## 권장 운동
1. 유산소 운동
- 걷기: 주 5회, 30분
- 자전거: 저강도로 시작
- 수영: 관절에 무리 없음
2. 근력 운동
- 가벼운 덤벨 운동
- 저항 밴드 운동
- 주 2-3회, 15-20분
## 운동 시 주의사항
- 투석 직후는 피하기
- 탈수 주의
- 가슴 통증, 호흡곤란 시 즉시 중단
- 운동 전후 혈압 체크
출처: 대한의료사회복지사협회"""
},
{
"filename": "[진단-CKD-STD-S3] 만성콩팥병의 진단과 검사.txt",
"content": """# 만성콩팥병의 진단과 검사
## 진단 기준
3개월 이상 다음 중 하나 이상 존재 시:
- eGFR < 60 ml/min/1.73m²
- 알부민뇨 (ACR ≥ 30mg/g)
- 신장 손상의 증거
## 주요 검사
1. 혈액검사
- 크레아티닌, eGFR
- 전해질 (Na, K, Ca, P)
- 빈혈 지표 (Hb, ferritin)
2. 소변검사
- 단백뇨/알부민뇨
- 현미경 검사
3. 영상검사
- 신장 초음파
- 필요시 CT, MRI
## 정기 검진 주기
- CKD 1-2단계: 연 1회
- CKD 3단계: 6개월마다
- CKD 4-5단계: 3개월마다
출처: 질병관리청"""
},
{
"filename": "[식이-CKD-DM-S2] 당뇨병성 신증 환자의 식사 관리.txt",
"content": """# 당뇨병성 신증 환자의 식사 관리
## 특별 고려사항
당뇨병과 신장병을 함께 관리해야 하는 복잡한 상황입니다.
## 영양 관리 원칙
1. 혈당 조절
- 규칙적인 식사 시간
- 당지수가 낮은 식품 선택
- 단순당 제한
2. 단백질 조절
- CKD 3-4단계: 0.6-0.8g/kg/일
- 양질의 단백질 위주
3. 나트륨 제한
- 2000mg/일 이하
- 가공식품 피하기
## 주의 식품
- 과일: 당도 높은 과일 제한
- 곡류: 현미, 잡곡 (인 함량 주의)
- 음료: 과일주스, 스포츠음료 금지
출처: 대한당뇨병학회"""
},
{
"filename": "[식이-ALL-COM-S4] 식사가 건강에 미치는 영향.txt",
"content": """# 식사가 건강에 미치는 영향
# 이대서울병원 신장내과 류동열
Key Message: 우리 몸에 나쁜 음식은 결코 없습니다. 골고루 적당량을 섭취하면 식사가 보약입니다.
건강을 유지하기 위해서는 식사를 통해 우리 몸과 마음이 필요한 것을 적절한 시간에 적당한 양만큼 얻을 수 있어야만 합니다. 건강한 식사행동이 질병과 사망에 미치는 영향을 조사한 연구에 따르면 건강을 해치는 15가지 나쁜 식사행동과 일반인에게 권장되는 적절한 양은 다음과 같습니다:
| 번호 | 나쁜 식사행동 | 일일 권장량 (권장 범위) |
| -- | --------------------------- | -------------------- |
| 1 | 과일을 적게 먹는 것 | 250 g (200–300) |
| 2 | 채소를 적게 먹는 것 | 360 g (290–430) |
| 3 | 콩류를 적게 먹는 것 | 60 g (50–70) |
| 4 | 정백하지 않은 통알곡을 적게 먹는 것 | 125 g (100–150) |
| 5 | 견과류를 적게 먹는 것 | 21 g (16–25) |
| 6 | 우유를 적게 먹는 것 | 435 g (350–520) |
| 7 | 붉은 살코기를 많이 먹는 것 | 23 g (18–27) |
| 8 | 가공육류를 많이 먹는 것 | 2 g (0–4) |
| 9 | 설탕이 첨가된 음료수를 많이 먹는 것 | 3 g (0–5) |
| 10 | 식이섬유를 적게 먹는 것 | 24 g (19–28) |
| 11 | 칼슘을 적게 먹는 것 | 1.25 g (1.00–1.50) |
| 12 | 해산물에 포함된 오메가-3 지방산을 적게 먹는 것 | 250 mg (200–300) |
| 13 | 다가불포화지방산을 적게 먹는 것 | 총 열량의 11% (9–13) |
| 14 | 트랜스지방산을 많이 먹는 것 | 총 열량의 0.5% (0.0–1.0) |
| 15 | 염분을 많이 먹는 것 | 3 g (1–5) |
전 세계 사람들이 사망하는 원인 중 22%가 이처럼 나쁜 식사행동과 관련이 있으며, 특히 우리나라에서는 과일과 통알곡을 적게 먹고 염분 섭취가 많은 것이 사망과 관련된 나쁜 식사행동 중 가장 주요한 것들이었습니다.
만성콩팥병 환자라고 하더라도 과일과 채소 섭취량을 적절하게 섭취하는 균형 잡힌 식사를 하면 사망 위험을 줄여준다는 연구결과도 있습니다.
출처: 이대서울병원 신장내과"""
},
{
"filename": "[식이-ALL-STD-S2] 만성콩팥병 환자의 칼륨 제한 주의사항.txt",
"content": """# 만성콩팥병 환자는 칼륨이 많이 들어있는 과일이나 채소를 지나치게 섭취하지 않도록 주의합니다
콩팥 기능이 저하된 만성콩팥병 환자는 칼륨 배설기능이 저하되어 있으므로 과일류, 채소류, 콩류, 견과류의 섭취량을 줄여야 합니다.
칼륨이 적게 들어 있는 음식을 골라 먹는 법과 칼륨을 낮추는 조리법을 배워 실천해야 합니다.
고칼륨혈증이 발견된 경우 칼륨을 올릴 수 있는 약물에 대해 확인이 필요하기도 합니다.
## 관련 상식
칼륨은 식품에 널리 들어 있지만 주요 공급원은 과일류, 채소류, 콩류, 견과류입니다.
콩팥 기능이 정상인 경우 이러한 식품은 섬유질, 비타민, 미네랄, 기타 중요한 영양소의 주요 공급원이므로 과일류, 채소류, 콩류, 견과류 섭취를 제한하지 않습니다.
하지만 만성콩팥병 환자가 남아 있는 콩팥 기능에 비해 많은 양의 칼륨을 섭취하면 혈액 속의 칼륨 수치가 지나치게 높아지는 고칼륨혈증이 유발되어 근육쇠약감, 부정맥 등이 발생할 수 있으며, 심하면 심장마비 같은 치명적이고 위급한 합병증을 유발할 수 있습니다.
## 실천 방법
만성콩팥병 환자는 콩팥 기능 감소에 따라 담당의와 상의하여 과일류, 채소류, 콩류, 견과류의 섭취량을 줄여야 합니다.
채소는 따뜻한 물에 2시간 이상 담가 놓았다가 새 물에 몇 번 헹군 후 섭취합니다.
채소는 물에 삶거나 데친 후 물은 버리고 채소만 섭취합니다.
저나트륨 소금은 소금의 주성분인 나트륨 일부를 칼륨으로 대체한 소금이라 콩팥 기능이 나쁘면 오히려 고칼륨혈증을 일으킬 수 있어 주의해야 합니다.
출처: 나와 가족을 위한 만성콩팥병 예방과 관리 정보"""
},
{
"filename": "[식이-HD-STD-S3] 외식하고 싶은데 무엇을 먹을 수 있나요.txt",
"content": """# 외식하고 싶은데 무엇을 먹을 수 있나요?
여의도성모병원 영양팀, 한국임상영양학회, 영양사 박주연
Key Message: 미리 계획하고 염분이 적으면서 균형된 메뉴를 선택합니다.
최근 1인 가구 증가 및 서구화된 식습관 등 식생활에 많은 변화가 이뤄지고 있으며, 식생활에 있어서 외식이 차지하는 비율 또한 높아지고 있는 추세입니다. 사회생활을 병행하는 혈액투석 환자에게서도 회식, 약속 등으로 인한 외식은 피할 수 없는 부분입니다.
## 1. 외식 시 식사 원칙
### 1) 미리 계획 합니다.
① 외식이 필요한 날은 외식 전, 후 집에서 먹는 식사의 양이나 종류를 평소보다 더욱 주의 깊게 조절합니다.
② 신선한 재료를 사용하고, 염분 조절 등 개별 주문이 가능한 식당을 선택하는 것이 좋습니다.
### 2) 적절한 단백질이 포함된 균형 잡힌 메뉴를 선택합니다.
① 빈혈 예방과 투석시 손실되는 아미노산 보충을 위해 적절한 단백질 섭취가 필요합니다.
② 과량의 단백질 섭취는 투석 간 노폐물을 축적시킬 수 있으므로, 한 끼에 몰아서 섭취하지 않도록 합니다.
### 3) 염분, 칼륨, 인 함량이 높은 식품은 피합니다.
① 집에서 직접 준비한 식사에 비해 외식 메뉴는 염분 함량이 높은 경우가 많습니다.
② 식사 주문 시 염분을 넣지 않도록 주문하고, 소금 등은 별도로 요청하여 적당량 첨가합니다.
## 2. 외식 메뉴 선택 및 섭취 요령
### 1) 비빔밥, 회덮밥 등
- 칼륨 함량이 높은 채소는 제외하고, 생채소는 제공량의 절반만 섭취합니다.
- 염분조절을 위해 고추장, 간장 등 양념을 최소한으로 사용합니다.
### 2) 갈비탕, 설렁탕 등 탕류
- 소금을 추가로 넣지 않고, 건더기 위주로 섭취합니다.
### 3) 칼국수, 비빔국수, 냉면 등 면류
- 염분 함량이 높아 주의가 필요합니다.
- 국물이 있는 면류는 국물은 먹지 않고, 비빔양념은 최소량만 사용합니다.
### 4) 스테이크, 돈까스
- 제공되는 고기양이 많은 편으로, 한 번에 섭취하는 고기 양을 조절해야 합니다.
- 염분제한을 위해 소스는 가급적이면 뿌리지 않습니다.
### 5) 파스타, 리조또
- 오일로 조리된 메뉴를 선택합니다.
- 인 함량이 높은 크림소스나, 칼륨/염분이 높은 토마토소스는 절반만 섭취합니다.
출처: 여의도성모병원 영양팀, 한국임상영양학회"""
},
{
"filename": "[운동-ALL-STD-S2] 운동을 시작하기 전 주의사항이 있나요.txt",
"content": """# 운동을 시작하기 전 주의사항이 있나요?
구미차병원 신장내과, 대한신장학회 근육감소증 및 여림 연구회 김준철
## 1. 유산소 운동을 해야 해요? 근력 운동을 해야 해요?
한 마디로 얘기하자면 두 가지 운동 모두가 필요하고 또 중요합니다. 만성 콩팥병 환자들에게 있어 만성콩팥병의 원인이 되는 당뇨병이나 고혈압 그리고 합병증으로 동반되는 여러 심혈관계 질환의 위험 인자들을 조절하거나 예방 혹은 치료하는 데 유산소운동은 큰 도움이 됩니다.
그리고 만성콩팥병 환자들에게서 흔히 볼 수 있는 단백질-에너지 소모(Protein Energy Wasting), 근감소증(sarcopenia), 그리고 노쇠(Frailty)로 인한 일상 생활의 장애 및 그로 인한 부작용들을 예방 혹은 치료하는 데 있어 지속적인 근력 운동은 특별히 더 큰 도움이 되므로 두 가지 형태의 운동을 함께 유지하는 것이 가장 좋습니다.
## 2. 유산소 운동과 근력 운동 모두 공통적으로 준비운동과 정리운동이 필요합니다.
본격적인 운동 시작 전에는 근육과 인대 그리고 심장에 갑작스런 부담으로 인한 부상이나 부작용을 피하기 위해 준비운동을 시행하는 것이 안전합니다. 일반적으로 5분에서 10분 정도의 시간을 할애하여 가벼운 몸 풀기를 하시면 됩니다.
본격적인 운동을 마친 후에도 준비 운동과 같은 형태의 가벼운 몸 풀기나, 앞서 실행하였던 같은 종류의 유산소 운동을 "중등도 강도"에서 "가벼운 강도"로 낮춰서 5분에서 10분 정도의 시간을 들여서 정리운동을 해 주는 것이 좋습니다.
## 3. 운동을 해서는 안 되는 상황
다음과 같은 경우는 운동을 피하고 담당의사와 상의하는 것이 좋습니다.
### 절대적 운동 금기 상황
- 2일 이내의 급성 심근 경색증 혹은 협심증
- 불안정성 협심증을 진단받고 치료 중인 경우
- 조절되지 않는 심각한 종류의 부정맥을 가지고 있는 경우
- 증상을 동반하는 심한 대동맥 협착증이 있는 경우
- 조절되지 않는 호흡 곤란의 증상을 동반하는 심부전
- 급성 폐경색 혹은 색전증
- 급성 심근염이나 급성 심막염
- 이미 진단되었거나 의심되는 대동맥 박리증
- 발열, 전신근육통 혹은 림프염 등을 동반한 급성 전신 감염 상태
### 상대적 운동 금기 상황
- 좌측 주관상동맥 협착증
- 중등도의 협착성 판막 심장 질환
- 저칼륨혈증이나 고칼륨혈증과 같은 전해질 이상
- 조절되지 않은 고혈압(안정시 수축기 혈압 200 mmHg 혹은 이완기 혈압 110 mmHg 이상)
- 증상을 동반하는 빈맥이나 서맥
- 비후성 심근병증이나 폐쇄성 심장 질환을 진단받은 경우
- 운동으로 인해 악화 가능성이 있는 신경운동계 혹은 근골격근계 질환을 동반한 경우
- 조절되지 않은 대사성 질환(예: 당뇨병, 갑상선 기능 항진증)
출처: 구미차병원 신장내과, 대한신장학회"""
},
{
"filename": "[운동-DIA-STD-S3] 적절한 근력 운동 방법에 대해 알려주세요.txt",
"content": """# 적절한 근력 운동 방법에 대해 알려주세요.
구미차병원 신장내과, 대한신장학회 근육감소증 및 여림 연구회 김준철
## 1. 운동 횟수(Frequency)
1. 같은 부위의 근육에 대한 근력 운동은 최소 48시간의 간격을 두는 것이 부상을 최소화 할 수 있습니다.
2. 매일 근육 운동을 하고자 한다면 운동하고자 하는 근육 부위를 달리하여 이틀에 한 번씩 해당 근육 운동이 차례가 돌아올 수 있도록 하면 됩니다.
3. 일반적으로 5일 이상 근력 운동을 쉬게 되면 이전 운동의 효과가 없어지기 시작하기 때문에 해당 근육 부위의 운동을 최소 주 2회를 시행하는 것이 근력의 유지 및 향상을 도모할 수 있습니다.
## 2. 운동 강도(Intensity)
1. 운동 기구를 이용하여 근력운동을 하는 경우는 특정 무게나 저항을 정하여 해당 부위 근육 운동을 할 때 1회 운동(1 set)을 할 때, 12회-14회를 반복하였을 때 해당 근육이 뻐근함을 느낄 정도로 무게와 저항 정도를 정하는 것이 안전합니다.
2. 이 때 뻐근함을 넘어서 통증을 느낄 정도의 무게나 저항은 운동 강도가 지나치게 높게 정한 것을 의미하므로 그 정도를 더 낮게 정하여 부상에 유의하셔야 합니다.
3. 이미 어느 정도의 좋은 근력을 가지고 있는 경우는 더 적은 횟수, 예를 들면 8회-10회를 시행하면 해당 근육의 뻐근함을 느낄 정도로 무게와 저항을 정하여 근력 운동을 시행하기도 하지만 이 경우 부상 위험도는 더 증가할 수 있어 조심스러운 운동 시작이 필요합니다.
## 3. 운동 시간(Time)
근력 운동에서의 운동 시간에 해당되는 것은 "운동 강도" 부분에서 설명드린 1회 운동을 총 몇 차례 반복하여 시행하는지에 따라 정해집니다. 근력 운동에서는 1회 운동을 "한 세트(1 set)"라고 표현합니다.
## 4. 운동량(Volume)과 증량 속도(Progression)
운동량은 해당 근육의 근력 운동을 한 주간 동안 시행하는 횟수와 시행할 때 적용하는 무게 혹은 저항, 그리고 각각 몇 "세트"를 시행하는 지를 곱한 값으로 결정됩니다.
### 운동량과 운동 속도는 어떻게 증가시켜야 하나요?
1. 평균적인 체력을 가지고 있는 환우께서 처음 근력 운동을 시작하는 경우 우선 욕심내지 않고 12회-14회를 반복하였을 때 해당 근육이 뻐근함을 느낄 정도로 무게와 저항 정도를 정하여 가능한 부상을 피하는 것이 가장 중요합니다.
2. 우선 12회-14회를 무리 없이 반복할 수 있는 무게와 저항을 유지한 채 3분-5분 간격으로 같은 무게 혹은 저항으로 12회-14회를 처음 세트와 마찬가지로 반복하게 합니다.
3. 이렇게 보통 2-4세트까지 무리 없이 시행할 수 있는 근력을 확보하게 되고, 현재 시행하고 있는 무게나 저항을 한 번에 16회-18회 정도를 쉽게 반복하여 운동할 수 있는 단계에 도달하면 무게나 저항을 현재보다 10% 전후를 기준으로 증가하여 시행합니다.
4. 일반적으로 2주-4주 전후의 간격이 필요하지만 개인차가 있을 수 있어 근력 운동의 증량 속도는 다양할 수 있습니다.
5. 무엇보다 부상을 피하는 것이 가장 중요하게 유념해야 할 부분입니다.
출처: 구미차병원 신장내과, 대한신장학회"""
},
{
"filename": "[진단-ALL-COM-S4] 건강한 사람에게서 콩팥병을 의심할 수 있는 증상은 무엇이 있나요.txt",
"content": """# 건강한 사람에게서 콩팥병을 의심할 수 있는 증상은 무엇이 있나요?
## 일반인을 위한 만성콩팥병 바로알기
1. 소변에서 거품이 보이면 단백뇨를 의심해야 합니다.
2. 붉은 소변의 원인은 다양하므로 빠른 시간 내에 진료가 필요합니다.
3. 소변을 자주 보면 여성의 경우 방광염을, 중년 이후의 남성인 경우 전립선 질환을 먼저 의심해야 합니다.
4. 옆구리 통증의 원인은 콩팥 질환도 가능하지만 다른 질환일 가능성도 있으므로 검사가 필요합니다.
5. 아침에 일어났을 때 얼굴이 붓는다면 소변 검사와 혈액 검사를 통하여 콩팥병을 확인해야 합니다.
6. 임신 중의 부종은 흔한 일이지만 임신과 연관된 합병증인 임신 중독증 혹은 콩팥병을 의심해야 하므로 주기적 산전 진찰이 필요합니다.
출처: 일반인을 위한 만성콩팥병 바로알기"""
},
{
"filename": "[진단-ALL-HTN-S4] 고혈압이 콩팥병에 의한 것인지 의심해야 할 경우는 무엇인지요.txt",
"content": """# 고혈압이 콩팥병에 의한 것인지 의심해야 할 경우는 무엇인지요?
## 일반인을 위한 만성콩팥병 바로알기
다음과 같은 경우에 고혈압이 콩팥병에 의한 것인지 의심해 보아야 합니다:
1. 소변 검사에서 혈뇨나 단백뇨가 동반되는 경우
2. 몸이 붓는 증상(부종)이 같이 동반되는 경우
3. 염분 섭취량에 따라 혈압이 크게 영향 받을 때
4. 35세 이전에 발생한 고혈압 또는 60세 이후에 발생한 고혈압인 경우
5. 고혈압이 갑자기 발생할 때
6. 혈압이 약물 치료에도 불구하고 잘 조절되지 않을 때
7. 잘 조절되던 혈압이 뚜렷한 이유 없이 상승할 때
출처: 일반인을 위한 만성콩팥병 바로알기"""
},
{
"filename": "[진단-ALL-SYM-S2] 혈액 검사에서 나트륨 농도가 낮다고 합니다. 무슨 이야기인가요.txt",
"content": """# 혈액 검사에서 나트륨 농도가 낮다고 합니다. 무슨 이야기인가요?
## 일반인을 위한 만성콩팥병 바로알기
혈액 나트륨 농도가 정상보다 낮아지는 '저나트륨혈증'을 말하며, 이는 노인에게 가장 흔하게 발생하는 전해질 이상입니다.
## 원인
원인은 매우 다양한데, 이뇨제나 정신 질환 치료 약제 사용, 체액량 감소, 심부전, 간경화, 각종 폐 또는 뇌질환 등이 있습니다.
특히, 최근에는 혈압약에 이뇨제가 포함되어 있는 경우가 많으며, 이러한 약제를 복용하는 상태에서 설사, 구토, 식사량 저하 등이 갑자기 발생하는 경우 저나트륨혈증 발병의 위험도가 증가합니다.
## 치료의 중요성
저나트륨혈증의 원인과 발생 속도에 따라서 위중도가 달라질 수 있으며, 급격하게 낮아지는 경우 전신 경련이나 의식 저하가 발생될 수 있기 때문에 원인에 대한 철저한 조사와 더불어 적극적인 치료가 필요합니다.
출처: 일반인을 위한 만성콩팥병 바로알기"""
},
{
"filename": "[진단-DIA-SYM-S2] 빈혈이 심해요.txt",
"content": """# 빈혈이 심해요.
서울대학교병원 신장내과 이하정
Key Message: 투석 환자의 빈혈은 심혈관합병증 및 사망의 위험을 높일 수 있어 경구 혹은 주사 철분제 및 합성조혈호르몬을 이용한 적극적인 치료가 필요합니다.
## 빈혈의 원인
빈혈은 투석 환자의 거의 대부분에서 나타날 수 있는 흔한 현상입니다. 신장은 에리스로포이에틴(erythropoietin)이라는 혈액을 만드는 것을 돕는 조혈호르몬을 분비합니다. 신장 기능이 나빠지면 조혈호르몬의 분비가 감소하여 빈혈이 생깁니다.
조혈호르몬 이외에도 다음과 같은 원인으로 빈혈이 생길 수 있습니다:
- 요독으로 인한 적혈구 수명 단축
- 철분의 결핍
- 출혈성 질환
- 심한 부갑상선 항진증으로 인한 골수의 섬유화
- 급성 혹은 만성 염증성 질환
- 엽산 결핍
## 빈혈의 증상과 합병증
빈혈이 생기면 쉽게 피로하고 전신 쇠약감을 느끼며, 추위를 잘 견디지 못하고 심한 경우 호흡곤란을 호소하는 경우도 있으며 이로 인해 삶의 질이 저하됩니다.
장기간 빈혈에 적응하여 특별한 증상을 느끼지 못하는 경우도 많지만, 증상이 없다고 하더라도 빈혈이 적절히 치료되지 못하고 장기간 지속되는 경우 심장에 부담을 주어 심비대 및 이로 인한 심부전을 유발하게 됩니다. 심비대와 심부전은 모두 투석 환자의 중요한 심혈관계 합병증으로 주요 사망의 원인이 될 수 있습니다.
## 빈혈의 치료
빈혈을 치료하기 위해서는 다음과 같은 치료가 필요합니다:
1. **철분 보충**: 경구 철분제 혹은 주사 철분제로 보충이 가능하며, 정기적으로 체내 저장량을 모니터링 하면서 충분히 보충해야 합니다.
2. **엽산 보충**: 경구 약제로 보충이 가능합니다.
3. **조혈호르몬 보충**: 피하 혹은 정맥 주사 제제로 개발된 합성에리스로포이에틴을 정기적으로 맞아 보충할 수 있습니다.
4. **적절한 투석**: 요독을 최소화하기 위해 적절한 효율의 투석 치료를 유지하는 것이 중요합니다.
## 치료 저항성 빈혈
철분, 엽산, 조혈호르몬을 충분히 보충하여 주는 경우에도 빈혈이 호전되지 않는다면, 출혈성 질환이 동반되어 있지 않는지 확인이 필요합니다. 또한 조혈호르몬에 대한 저항성이 생기지 않았는지 확인할 필요가 있습니다.
급성 염증성 질환, 인 조절이 잘 되지 않아 발생하는 심한 부갑상선 기능 항진증, 악성 종양과 같은 질환 등은 합성 조혈호르몬의 저항성을 유도할 수 있으므로 빈혈 교정을 위해 치료가 필요합니다.
출처: 서울대학교병원 신장내과"""
}
]
for filename, content in samples:
filepath = os.path.join(self.documents_path, filename)
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
sample_files.append(filepath)
logger.info(f"Created sample file: {filename}")
return sample_files
def search(self, query: str, k: int = 5,
filters: Optional[Dict[str, Any]] = None) -> List[Document]:
"""관련 문서 검색"""
if not self.vectorstore:
logger.warning("Vectorstore not loaded, loading now...")
self.load_documents()
logger.info(f"Searching for: '{query}' with k={k}, filters={filters}")
results = self.vectorstore.similarity_search(query, k=k*2)
if filters:
filtered_results = []
for doc in results:
match = True
for key, value in filters.items():
if key in doc.metadata and doc.metadata[key] != value:
match = False
break
if match:
filtered_results.append(doc)
results = filtered_results[:k]
else:
results = results[:k]
logger.info(f"Found {len(results)} documents")
return results
def search_by_patient_context(self, query: str,
constraints: PatientConstraints,
task_type: TaskType,
k: int = 5) -> List[Document]:
"""환자 상태와 작업 유형을 고려한 맞춤형 검색"""
filters = {}
# 작업 유형에 따른 필터
task_field_mapping = {
TaskType.DIET_RECOMMENDATION: "diet",
TaskType.DIET_ANALYSIS: "diet",
TaskType.MEDICATION: "medication",
TaskType.LIFESTYLE: "lifestyle",
TaskType.DIAGNOSIS: "diagnosis",
TaskType.EXERCISE: "exercise"
}
if task_type in task_field_mapping:
filters["field"] = task_field_mapping[task_type]
# 환자 상태에 따른 필터
if constraints.on_dialysis:
filters["patient_status"] = "hemodialysis"
elif constraints.disease_stage in [DiseaseStage.CKD_3, DiseaseStage.CKD_4]:
filters["patient_status"] = "chronic_kidney_disease"
# 동반질환에 따른 검색
additional_results = []
if "당뇨" in constraints.comorbidities:
additional_results.extend(
self.search(query, k=k//3, filters={"personalization_level": "diabetes"})
)
if "고혈압" in constraints.comorbidities:
additional_results.extend(
self.search(query, k=k//3, filters={"personalization_level": "hypertension"})
)
main_results = self.search(query, k=k-len(additional_results), filters=filters)
all_results = main_results + additional_results
logger.info(f"Patient context search found {len(all_results)} total documents")
return all_results
# ========== 식품 영양 분석 ==========
class FoodNutritionDatabase:
"""식품 영양 성분 데이터베이스 - 싱글톤 패턴 적용"""
_instance = None
_initialized = False
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super(FoodNutritionDatabase, cls).__new__(cls)
return cls._instance
def __init__(self, csv_path: str = "통합식품영양성분정보(음식)_20241224.csv"):
if FoodNutritionDatabase._initialized:
return
self.csv_path = csv_path
self.food_data = None
self.load_food_data()
FoodNutritionDatabase._initialized = True
def load_food_data(self):
"""CSV 파일에서 식품 데이터 로드"""
try:
# CSV 파일 로드 시도
if os.path.exists(self.csv_path):
self.food_data = pd.read_csv(self.csv_path, encoding='utf-8')
logger.info(f"Loaded food data from {self.csv_path}")
else:
raise FileNotFoundError(f"CSV file not found: {self.csv_path}")
# 컬럼명 정리 (실제 CSV 구조에 맞게)
column_mapping = {
'식품코드': 'food_code',
'식품명': 'name',
'식품대분류명': 'category_major',
'식품중분류명': 'category_minor',
'영양성분함량기준량': 'serving_size',
'에너지(kcal)': 'calories',
'수분(g)': 'water',
'단백질(g)': 'protein',
'지방(g)': 'fat',
'탄수화물(g)': 'carbohydrate',
'당류(g)': 'sugar',
'식이섬유(g)': 'dietary_fiber',
'칼슘(mg)': 'calcium',
'철(mg)': 'iron',
'인(mg)': 'phosphorus',
'칼륨(mg)': 'potassium',
'나트륨(mg)': 'sodium',
'콜레스테롤(mg)': 'cholesterol',
'포화지방산(g)': 'saturated_fat'
}
self.food_data = self.food_data.rename(columns=column_mapping)
# 숫자형 컬럼 변환
numeric_columns = ['calories', 'protein', 'fat', 'carbohydrate',
'sodium', 'potassium', 'phosphorus', 'calcium',
'water', 'sugar', 'dietary_fiber', 'iron',
'cholesterol', 'saturated_fat']
for col in numeric_columns:
if col in self.food_data.columns:
self.food_data[col] = pd.to_numeric(self.food_data[col], errors='coerce')
# serving_size를 숫자로 변환 (예: "100g" -> 100)
if 'serving_size' in self.food_data.columns:
if self.food_data['serving_size'].dtype == 'object':
self.food_data['serving_size'] = self.food_data['serving_size'].str.extract('(\d+)').astype(float)
else:
self.food_data['serving_size'] = pd.to_numeric(self.food_data['serving_size'], errors='coerce')
# NaN 값을 0으로 채우기
self.food_data = self.food_data.fillna(0)
logger.info(f"Loaded {len(self.food_data)} food items from database")
except Exception as e:
logger.error(f"Error loading food database: {e}")
logger.info("Creating sample food data...")
self.food_data = self._create_sample_data()
def _create_sample_data(self):
"""샘플 식품 데이터 생성"""
sample_data = {
'food_code': ['D101-001', 'D101-002', 'D101-003', 'D101-004', 'D101-005', 'D101-006',
'D101-007', 'D101-008', 'D101-009', 'D101-010'],
'name': ['쌀밥', '닭가슴살', '브로콜리', '사과', '두부', '달걀', '감자', '우유', '연어', '시금치'],
'category_major': ['곡류', '육류', '채소류', '과일류', '콩류', '난류', '서류', '유제품류', '어패류', '채소류'],
'category_minor': ['밥류', '가금류', '녹황색채소', '과일', '두부', '계란', '감자류', '우유류', '생선류', '엽채류'],
'serving_size': [100, 100, 100, 100, 100, 100, 100, 100, 100, 100],
'calories': [130, 165, 34, 52, 76, 155, 77, 61, 208, 23],
'water': [68.5, 65.3, 89.3, 85.6, 84.6, 76.2, 79.3, 87.7, 68.5, 91.4],
'protein': [2.7, 31.0, 2.8, 0.3, 8.1, 13.0, 2.0, 3.3, 20.4, 2.9],
'fat': [0.3, 3.6, 0.4, 0.2, 4.8, 11.0, 0.1, 3.3, 13.4, 0.4],
'carbohydrate': [28.2, 0, 6.6, 13.8, 1.9, 1.1, 17.6, 4.8, 0, 3.6],
'sugar': [0.1, 0, 1.7, 10.4, 0.7, 0.4, 0.8, 5.0, 0, 0.4],
'dietary_fiber': [0.4, 0, 2.6, 2.4, 0.3, 0, 1.8, 0, 0, 2.2],
'calcium': [10, 11, 47, 6, 350, 56, 10, 113, 12, 99],
'iron': [0.5, 0.9, 0.7, 0.1, 5.4, 1.8, 0.8, 0.1, 0.8, 2.7],
'phosphorus': [43, 210, 66, 11, 110, 198, 57, 93, 252, 49],
'potassium': [35, 256, 316, 107, 121, 138, 421, 150, 490, 558],
'sodium': [1, 74, 30, 1, 7, 142, 6, 50, 44, 79],
'cholesterol': [0, 85, 0, 0, 0, 373, 0, 12, 55, 0],
'saturated_fat': [0.1, 1.0, 0.1, 0, 0.7, 3.3, 0, 1.9, 3.1, 0.1]
}
return pd.DataFrame(sample_data)
def search_foods(self, query: str, limit: int = 10) -> List[FoodItem]:
"""식품 검색"""
logger.info(f"Searching for food: '{query}'")
# 검색어가 포함된 식품 찾기
mask = self.food_data['name'].str.contains(query, case=False, na=False)
results = self.food_data[mask].head(limit)
food_items = []
for _, row in results.iterrows():
food_item = FoodItem(
food_code=str(row.get('food_code', '')),
name=row['name'],
food_category_major=row.get('category_major', ''),
food_category_minor=row.get('category_minor', ''),
serving_size=float(row.get('serving_size', 100)),
calories=float(row['calories']),
water=float(row.get('water', 0)),
protein=float(row['protein']),
fat=float(row['fat']),
carbohydrate=float(row['carbohydrate']),
sugar=float(row.get('sugar', 0)),
dietary_fiber=float(row.get('dietary_fiber', 0)),
calcium=float(row.get('calcium', 0)),
iron=float(row.get('iron', 0)),
phosphorus=float(row['phosphorus']),
potassium=float(row['potassium']),
sodium=float(row['sodium']),
cholesterol=float(row.get('cholesterol', 0)),
saturated_fat=float(row.get('saturated_fat', 0))
)
food_items.append(food_item)
logger.info(f"Found {len(food_items)} food items for '{query}'")
return food_items
def recommend_foods_for_patient(self, constraints: PatientConstraints,
meal_type: str = "all",
limit: int = 20) -> List[FoodItem]:
"""환자 제약조건에 맞는 식품 추천"""
logger.info(f"Recommending foods for patient with constraints, meal_type={meal_type}")
# 필터링 조건 설정
filtered_data = self.food_data.copy()
# 단백질 제한 (한 끼 기준 = 일일 제한량의 30%)
if constraints.protein_restriction:
max_protein = constraints.protein_restriction * 0.3
filtered_data = filtered_data[filtered_data['protein'] <= max_protein]
# 나트륨 제한
if constraints.sodium_restriction:
max_sodium = constraints.sodium_restriction * 0.3
filtered_data = filtered_data[filtered_data['sodium'] <= max_sodium]
# 칼륨 제한
if constraints.potassium_restriction:
max_potassium = constraints.potassium_restriction * 0.3
filtered_data = filtered_data[filtered_data['potassium'] <= max_potassium]
# 인 제한
if constraints.phosphorus_restriction:
max_phosphorus = constraints.phosphorus_restriction * 0.3
filtered_data = filtered_data[filtered_data['phosphorus'] <= max_phosphorus]
# 식사 유형에 따른 필터링
if meal_type == "breakfast":
# 아침식사에 적합한 카테고리
breakfast_categories = ['곡류', '유제품류', '과일류', '난류']
mask = filtered_data['category_major'].isin(breakfast_categories)
if mask.any():
filtered_data = filtered_data[mask]
elif meal_type == "lunch" or meal_type == "dinner":
# 점심/저녁에 적합한 카테고리
main_categories = ['곡류', '육류', '어패류', '채소류', '콩류']
mask = filtered_data['category_major'].isin(main_categories)
if mask.any():
filtered_data = filtered_data[mask]
# 칼로리 기준으로 정렬 (적절한 칼로리 범위 우선)
if constraints.calorie_target:
target_cal_per_meal = constraints.calorie_target / 3
filtered_data['cal_diff'] = abs(filtered_data['calories'] - target_cal_per_meal * 0.5)
filtered_data = filtered_data.sort_values('cal_diff')
# 상위 N개 선택
top_foods = filtered_data.head(limit)
# FoodItem 객체로 변환
recommended_foods = []
for _, row in top_foods.iterrows():
food_item = FoodItem(
food_code=str(row.get('food_code', '')),
name=row['name'],
food_category_major=row.get('category_major', ''),
food_category_minor=row.get('category_minor', ''),
serving_size=float(row.get('serving_size', 100)),
calories=float(row['calories']),
water=float(row.get('water', 0)),
protein=float(row['protein']),
fat=float(row['fat']),
carbohydrate=float(row['carbohydrate']),
sugar=float(row.get('sugar', 0)),
dietary_fiber=float(row.get('dietary_fiber', 0)),
calcium=float(row.get('calcium', 0)),
iron=float(row.get('iron', 0)),
phosphorus=float(row['phosphorus']),
potassium=float(row['potassium']),
sodium=float(row['sodium']),
cholesterol=float(row.get('cholesterol', 0)),
saturated_fat=float(row.get('saturated_fat', 0))
)
recommended_foods.append(food_item)
logger.info(f"Recommended {len(recommended_foods)} foods for {meal_type}")
return recommended_foods
def create_daily_meal_plan(self, constraints: PatientConstraints) -> Dict[str, List[FoodItem]]:
"""하루 식단 계획 생성"""
logger.info("Creating daily meal plan")
meal_plan = {
'breakfast': [],
'lunch': [],
'dinner': [],
'snack': []
}
# 각 식사별 추천 식품
meal_plan['breakfast'] = self.recommend_foods_for_patient(
constraints, meal_type='breakfast', limit=5
)
meal_plan['lunch'] = self.recommend_foods_for_patient(
constraints, meal_type='lunch', limit=5
)
meal_plan['dinner'] = self.recommend_foods_for_patient(
constraints, meal_type='dinner', limit=5
)
# 간식 추천 (칼로리가 낮은 식품)
snack_data = self.food_data[self.food_data['calories'] < 100]
if constraints.protein_restriction:
snack_data = snack_data[snack_data['protein'] < constraints.protein_restriction * 0.1]
snack_foods = []
for _, row in snack_data.head(3).iterrows():
food_item = FoodItem(
food_code=str(row.get('food_code', '')),
name=row['name'],
food_category_major=row.get('category_major', ''),
food_category_minor=row.get('category_minor', ''),
serving_size=float(row.get('serving_size', 100)),
calories=float(row['calories']),
water=float(row.get('water', 0)),
protein=float(row['protein']),
fat=float(row['fat']),
carbohydrate=float(row['carbohydrate']),
sugar=float(row.get('sugar', 0)),
dietary_fiber=float(row.get('dietary_fiber', 0)),
calcium=float(row.get('calcium', 0)),
iron=float(row.get('iron', 0)),
phosphorus=float(row['phosphorus']),
potassium=float(row['potassium']),
sodium=float(row['sodium']),
cholesterol=float(row.get('cholesterol', 0)),
saturated_fat=float(row.get('saturated_fat', 0))
)
snack_foods.append(food_item)
meal_plan['snack'] = snack_foods
logger.info("Daily meal plan created successfully")
return meal_plan
# ========== LLM 응답 생성 ==========
class DraftGenerator:
"""초안 응답 생성기"""
def __init__(self):
self.llm = ChatOpenAI(temperature=0.7, model="gpt-4o")
def generate_draft(self, query: str, constraints: PatientConstraints,
context_docs: List[Document]) -> Tuple[str, List[RecommendationItem]]:
"""제약조건을 고려한 초안 생성"""
logger.info("Generating draft response")
context = "\n".join([doc.page_content for doc in context_docs])
constraints_text = f"""
환자 정보:
- eGFR: {constraints.egfr} ml/min
- 질병 단계: {constraints.disease_stage.value}
- 투석 여부: {'예' if constraints.on_dialysis else '아니오'}
- 동반질환: {', '.join(constraints.comorbidities) if constraints.comorbidities else '없음'}
- 복용 약물: {', '.join(constraints.medications) if constraints.medications else '없음'}
- 연령: {constraints.age}세
- 성별: {constraints.gender}
영양 제한사항:
- 단백질: {constraints.protein_restriction}g/일
- 나트륨: {constraints.sodium_restriction}mg/일
- 칼륨: {constraints.potassium_restriction}mg/일
- 인: {constraints.phosphorus_restriction}mg/일
- 수분: {constraints.fluid_restriction}ml/일
"""
prompt = f"""
다음 신장질환 환자의 질문에 대해 답변하세요.
질문: {query}
참고 문서:
{context}
{constraints_text}
답변 시 다음 사항을 준수하세요:
1. 환자의 개별 상태를 반영한 맞춤형 답변 제공
2. 구체적인 권장사항은 <item>태그</item>로 표시
3. 의학적으로 정확하고 이해하기 쉬운 설명 제공
4. 참고 문서의 내용을 활용하여 근거 있는 답변 작성
"""
response = self.llm.predict(prompt)
items = self._extract_items(response)
logger.info(f"Generated draft with {len(items)} recommendation items")
return response, items
def _extract_items(self, response: str) -> List[RecommendationItem]:
"""응답에서 추천 항목 추출"""
items = []
pattern = r'<item>(.*?)</item>'
matches = re.findall(pattern, response, re.DOTALL)
for match in matches:
category = "식이" if any(word in match for word in ["섭취", "식사", "음식"]) else "기타"
item = RecommendationItem(
name=match.strip(),
category=category,
description=match.strip(),
constraints_satisfied=False
)
items.append(item)
return items
# ========== Correction Algorithm ==========
class CorrectionAlgorithm:
"""제약조건 만족을 위한 보정 알고리즘"""
def __init__(self, catalog: KidneyDiseaseCatalog):
self.catalog = catalog
self.embeddings = OpenAIEmbeddings()
def correct_items(self, draft_items: List[RecommendationItem],
constraints: PatientConstraints) -> List[RecommendationItem]:
"""초안 항목들을 제약조건에 맞게 보정"""
logger.info(f"Correcting {len(draft_items)} draft items")
corrected_items = []
for item in draft_items:
item.embedding = self._get_embedding(item.name)
similar_docs = self.catalog.search(item.name, k=10)
best_replacement = self._find_best_replacement(
item, similar_docs, constraints
)
if best_replacement:
corrected_items.append(best_replacement)
else:
item.constraints_satisfied = False
corrected_items.append(item)
logger.info(f"Corrected to {len(corrected_items)} items")
return corrected_items
def _get_embedding(self, text: str) -> np.ndarray:
"""텍스트 임베딩 생성"""
return np.array(self.embeddings.embed_query(text))
def _find_best_replacement(self, original_item: RecommendationItem,
candidates: List[Document],
constraints: PatientConstraints) -> Optional[RecommendationItem]:
"""제약조건을 만족하는 최적 대체 항목 찾기"""
best_item = None
best_score = float('inf')
for doc in candidates:
if self._check_constraints(doc, constraints):
doc_embedding = self._get_embedding(doc.page_content)
distance = np.linalg.norm(original_item.embedding - doc_embedding)
if distance < best_score:
best_score = distance
best_item = RecommendationItem(
name=doc.metadata.get('title', doc.page_content[:50]),
category=doc.metadata.get('field', original_item.category),
description=doc.page_content,
constraints_satisfied=True,
embedding=doc_embedding
)
return best_item
def _check_constraints(self, doc: Document, constraints: PatientConstraints) -> bool:
"""문서가 환자 제약조건을 만족하는지 검증"""
content = doc.page_content.lower()
if constraints.on_dialysis:
if "투석 금지" in content or "투석 환자 제외" in content:
return False
if constraints.disease_stage in [DiseaseStage.CKD_4, DiseaseStage.CKD_5]:
if "진행성 신부전 주의" in content:
return False
for comorbidity in constraints.comorbidities:
if comorbidity == "당뇨" and "당뇨 금기" in content:
return False
if comorbidity == "고혈압" and "혈압 상승 주의" in content:
return False
return True
# ========== LangGraph Nodes ==========
def classify_task(state: GraphState) -> GraphState:
"""질문 유형 분류 - LLM 사용"""
logger.info("=== CLASSIFY TASK NODE ===")
logger.info(f"User query: {state['user_query']}")
state["current_node"] = "분류"
state["processing_log"].append("질문 유형 분석 중...")
llm = ChatOpenAI(temperature=0.3, model="gpt-4o")
prompt = f"""
다음 질문을 분석하여 적절한 카테고리로 분류하세요.
질문: {state['user_query']}
카테고리:
- diet_recommendation: 식단 추천, 하루 식사 계획, 무엇을 먹어야 할지 묻는 질문
- diet_analysis: 특정 음식의 영양 성분, 섭취 가능 여부, 영양소 함량 분석
- medication: 약물 복용 방법, 시간, 부작용, 상호작용
- lifestyle: 일상생활 관리, 수면, 스트레스, 수분 섭취
- diagnosis: 검사 결과 해석, 질병 단계, 수치 의미
- exercise: 운동 방법, 종류, 강도, 주의사항
- general: 위 카테고리에 속하지 않는 일반적인 질문
카테고리 이름만 반환하세요.
"""
response = llm.predict(prompt).strip().lower()
# 카테고리 매핑
category_mapping = {
'diet_recommendation': TaskType.DIET_RECOMMENDATION,
'diet_analysis': TaskType.DIET_ANALYSIS,
'medication': TaskType.MEDICATION,
'lifestyle': TaskType.LIFESTYLE,
'diagnosis': TaskType.DIAGNOSIS,
'exercise': TaskType.EXERCISE,
'general': TaskType.GENERAL
}
selected_task = category_mapping.get(response, TaskType.GENERAL)
state["task_type"] = selected_task
logger.info(f"Task classified as: {selected_task.value}")
state["processing_log"].append(f"질문 유형: {selected_task.value}")
logger.info("=== END CLASSIFY TASK ===\n")
return state
def retrieve_context(state: GraphState) -> GraphState:
"""관련 문서 검색"""
logger.info("=== RETRIEVE CONTEXT NODE ===")
logger.info(f"Query: {state['user_query']}")
logger.info(f"Task type: {state['task_type'].value}")
state["current_node"] = "검색"
state["processing_log"].append("관련 문서 검색 중...")
catalog = KidneyDiseaseCatalog()
results = catalog.search_by_patient_context(
state["user_query"],
state["patient_constraints"],
state["task_type"]
)
state["catalog_results"] = results
for i, doc in enumerate(results[:3]):
logger.info(f"Document {i+1}: {doc.metadata.get('title', 'Unknown')} "
f"[{doc.metadata.get('raw_tags', 'No tags')}]")
state["processing_log"].append(f"{len(results)}개 관련 문서 검색 완료")
logger.info("=== END RETRIEVE CONTEXT ===\n")
return state
def analyze_diet_request(state: GraphState) -> GraphState:
"""식이 관련 요청 분석 및 식품 데이터베이스 검색"""
logger.info("=== ANALYZE DIET REQUEST NODE ===")
state["current_node"] = "식품 분석"
state["processing_log"].append("식품 정보 분석 중...")
food_db = FoodNutritionDatabase()
query = state["user_query"]
constraints = state["patient_constraints"]
# LLM을 사용하여 질문에서 언급된 식품 추출
llm = ChatOpenAI(temperature=0.3, model="gpt-4o")
prompt = f"""
다음 질문에서 언급된 모든 식품명을 추출하세요.
질문: {query}
식품명만 쉼표로 구분하여 나열하세요. 없으면 "없음"이라고 답하세요.
"""
food_names_response = llm.predict(prompt).strip()
logger.info(f"Extracted food names: {food_names_response}")
mentioned_foods = []
if food_names_response != "없음":
food_names = [name.strip() for name in food_names_response.split(',')]
for food_name in food_names:
found_foods = food_db.search_foods(food_name, limit=3)
mentioned_foods.extend(found_foods)
# 식품 분석 결과 생성
analysis_results = {
'mentioned_foods': [],
'suitable_foods': [],
'unsuitable_foods': [],
'nutritional_summary': {}
}
# 언급된 식품 분석
for food in mentioned_foods:
is_suitable, issues = food.is_suitable_for_patient(constraints)
food_info = {
'name': food.name,
'nutrients': food.get_nutrients_per_serving(100),
'suitable': is_suitable,
'issues': issues
}
analysis_results['mentioned_foods'].append(food_info)
if is_suitable:
analysis_results['suitable_foods'].append(food)
else:
analysis_results['unsuitable_foods'].append((food, issues))
state["food_analysis_results"] = analysis_results
logger.info(f"Analyzed {len(mentioned_foods)} foods")
state["processing_log"].append(f"{len(mentioned_foods)}개 식품 분석 완료")
logger.info("=== END ANALYZE DIET REQUEST ===\n")
return state
def generate_meal_plan(state: GraphState) -> GraphState:
"""일일 식단 계획 생성"""
logger.info("=== GENERATE MEAL PLAN NODE ===")
state["current_node"] = "식단 생성"
state["processing_log"].append("일일 식단 계획 생성 중...")
food_db = FoodNutritionDatabase()
constraints = state["patient_constraints"]
# 하루 식단 생성
meal_plan = food_db.create_daily_meal_plan(constraints)
# 영양소 총량 계산
daily_totals = {
'calories': 0,
'protein': 0,
'sodium': 0,
'potassium': 0,
'phosphorus': 0
}
for meal_type, foods in meal_plan.items():
for food in foods:
nutrients = food.get_nutrients_per_serving(100)
for nutrient, value in nutrients.items():
if nutrient in daily_totals:
daily_totals[nutrient] += value
state["meal_plan"] = meal_plan
# 기존 food_analysis_results가 있으면 업데이트, 없으면 생성
if state.get("food_analysis_results") is None:
state["food_analysis_results"] = {}
state["food_analysis_results"].update({
'meal_plan': meal_plan,
'daily_totals': daily_totals,
'recommendations': []
})
# 제약조건 대비 검증
if daily_totals['protein'] > constraints.protein_restriction:
state["food_analysis_results"]['recommendations'].append(
f"주의: 추천 식단의 단백질 총량({daily_totals['protein']:.1f}g)이 "
f"일일 제한량({constraints.protein_restriction}g)을 초과합니다."
)
logger.info("Meal plan generated successfully")
state["processing_log"].append("식단 계획 생성 완료")
logger.info("=== END GENERATE MEAL PLAN ===\n")
return state
def generate_diet_response(state: GraphState) -> GraphState:
"""식이 관련 최종 응답 생성"""
logger.info("=== GENERATE DIET RESPONSE NODE ===")
state["current_node"] = "응답 생성"
state["processing_log"].append("식이 관련 답변 생성 중...")
llm = ChatOpenAI(temperature=0.5, model="gpt-4o")
task_type = state["task_type"]
constraints = state["patient_constraints"]
context_docs = state.get("catalog_results", [])
# 참고 문서 내용 추출
context = "\n\n".join([
f"[{doc.metadata.get('title', 'Document')}]\n{doc.page_content[:500]}..."
for doc in context_docs[:3]
])
if task_type == TaskType.DIET_RECOMMENDATION and state.get("meal_plan"):
# 식단 추천 응답
meal_plan = state["meal_plan"]
daily_totals = state["food_analysis_results"]["daily_totals"]
prompt = f"""
신장질환 환자를 위한 일일 식단을 추천합니다.
환자 정보:
- 질병 단계: {constraints.disease_stage.value}
- 단백질 제한: {constraints.protein_restriction}g/일
- 나트륨 제한: {constraints.sodium_restriction}mg/일
- 칼륨 제한: {constraints.potassium_restriction}mg/일
- 인 제한: {constraints.phosphorus_restriction}mg/일
추천 식단:
아침: {', '.join([f.name for f in meal_plan['breakfast'][:3]])}
점심: {', '.join([f.name for f in meal_plan['lunch'][:3]])}
저녁: {', '.join([f.name for f in meal_plan['dinner'][:3]])}
간식: {', '.join([f.name for f in meal_plan['snack'][:2]])}
영양소 총량:
- 칼로리: {daily_totals['calories']:.0f} kcal
- 단백질: {daily_totals['protein']:.1f} g
- 나트륨: {daily_totals['sodium']:.0f} mg
- 칼륨: {daily_totals['potassium']:.0f} mg
- 인: {daily_totals['phosphorus']:.0f} mg
참고 자료:
{context}
위 정보를 바탕으로 환자가 이해하기 쉽게 설명하고,
각 식사의 영양학적 장점과 주의사항을 포함해주세요.
의료진과의 상담 필요성도 언급하세요.
"""
elif task_type == TaskType.DIET_ANALYSIS and state.get("food_analysis_results"):
# 특정 식품 분석 응답
analysis = state["food_analysis_results"]
foods_summary = []
for food_info in analysis.get('mentioned_foods', []):
summary = f"{food_info['name']}: "
if food_info['suitable']:
summary += "섭취 가능"
else:
summary += f"주의 필요 ({', '.join(food_info['issues'])})"
foods_summary.append(summary)
prompt = f"""
환자가 질문한 식품들의 영양 분석 결과입니다.
질문: {state['user_query']}
분석 결과:
{chr(10).join(foods_summary) if foods_summary else "분석된 식품이 없습니다."}
환자의 제한사항:
- 단백질: {constraints.protein_restriction}g/일
- 나트륨: {constraints.sodium_restriction}mg/일
- 칼륨: {constraints.potassium_restriction}mg/일
- 인: {constraints.phosphorus_restriction}mg/일
참고 자료:
{context}
위 분석을 바탕으로 각 식품의 섭취 가능 여부와
적절한 섭취량을 구체적으로 설명해주세요.
"""
else:
# 일반 식이 관련 응답
prompt = f"""
신장질환 환자의 식이 관련 질문에 답변하세요.
질문: {state['user_query']}
환자 정보:
- 질병 단계: {constraints.disease_stage.value}
- 영양 제한사항이 있습니다.
참고 자료:
{context}
환자 상태를 고려한 구체적이고 실용적인 답변을 제공하세요.
의료진과의 상담 필요성도 언급하세요.
"""
response = llm.predict(prompt)
state["final_response"] = response
logger.info("Diet response generated")
state["processing_log"].append("답변 생성 완료")
logger.info("=== END GENERATE DIET RESPONSE ===\n")
return state
def generate_general_response(state: GraphState) -> GraphState:
"""일반 질문에 대한 응답 생성"""
logger.info("=== GENERATE GENERAL RESPONSE NODE ===")
state["current_node"] = "응답 생성"
state["processing_log"].append("일반 답변 생성 중...")
generator = DraftGenerator()
draft_response, draft_items = generator.generate_draft(
state["user_query"],
state["patient_constraints"],
state.get("catalog_results", [])
)
state["draft_response"] = draft_response
state["draft_items"] = draft_items
# 보정이 필요한 경우
if draft_items:
catalog = KidneyDiseaseCatalog()
corrector = CorrectionAlgorithm(catalog)
corrected_items = corrector.correct_items(draft_items, state["patient_constraints"])
state["corrected_items"] = corrected_items
# 최종 응답 생성
llm = ChatOpenAI(temperature=0.3, model="gpt-4o")
context_docs = state.get("catalog_results", [])
context = "\n\n".join([
f"[{doc.metadata.get('title', 'Document')}]\n{doc.page_content[:500]}..."
for doc in context_docs[:3]
])
if state.get("corrected_items"):
corrected_names = [item.name for item in state["corrected_items"]]
prompt = f"""
다음 정보를 포함하여 환자 질문에 답변하세요:
질문: {state["user_query"]}
환자 정보:
- 질병 단계: {state["patient_constraints"].disease_stage.value}
- 투석 여부: {'예' if state["patient_constraints"].on_dialysis else '아니오'}
참고 자료:
{context}
초안 답변: {draft_response}
검증된 권장사항: {json.dumps(corrected_names, ensure_ascii=False)}
위 정보를 종합하여 환자에게 도움이 되는 답변을 작성하세요.
의료진과의 상담 필요성을 반드시 언급하세요.
"""
else:
prompt = f"""
다음 질문에 대해 정확하고 이해하기 쉽게 답변하세요:
질문: {state["user_query"]}
환자 정보:
- 질병 단계: {state["patient_constraints"].disease_stage.value}
- 투석 여부: {'예' if state["patient_constraints"].on_dialysis else '아니오'}
참고 자료:
{context}
초안: {draft_response}
위 정보를 바탕으로 환자에게 도움이 되는 답변을 작성하세요.
의료진과의 상담 필요성을 반드시 언급하세요.
"""
final_response = llm.predict(prompt)
state["final_response"] = final_response
logger.info("General response generated")
state["processing_log"].append("답변 생성 완료")
logger.info("=== END GENERATE GENERAL RESPONSE ===\n")
return state
def route_after_classification(state: GraphState) -> str:
"""태스크 분류 후 라우팅"""
task_type = state["task_type"]
if task_type in [TaskType.DIET_RECOMMENDATION, TaskType.DIET_ANALYSIS]:
logger.info(f"Routing to diet_path for task type: {task_type.value}")
return "diet_path"
else:
logger.info(f"Routing to general_path for task type: {task_type.value}")
return "general_path"
def route_diet_subtask(state: GraphState) -> str:
"""식이 관련 세부 태스크 라우팅"""
if state["task_type"] == TaskType.DIET_RECOMMENDATION:
logger.info("Routing to meal_plan for diet recommendation")
return "meal_plan"
else:
logger.info("Routing to food_analysis for diet analysis")
return "food_analysis"
def route_after_retrieve(state: GraphState) -> str:
"""문서 검색 후 라우팅"""
if state["task_type"] in [TaskType.DIET_RECOMMENDATION, TaskType.DIET_ANALYSIS]:
logger.info("Routing to diet_response")
return "diet_response"
else:
logger.info("Routing to general_response")
return "general_response"
# ========== Workflow 구성 ==========
def create_kidney_disease_rag_workflow():
"""신장질환 RAG 워크플로우 생성"""
logger.info("Creating kidney disease RAG workflow")
workflow = StateGraph(GraphState)
# 노드 추가
workflow.add_node("classify", classify_task)
workflow.add_node("retrieve", retrieve_context)
workflow.add_node("analyze_diet", analyze_diet_request)
workflow.add_node("generate_meal_plan", generate_meal_plan)
workflow.add_node("generate_diet_response", generate_diet_response)
workflow.add_node("generate_general_response", generate_general_response)
# 시작점
workflow.set_entry_point("classify")
# 분류 후 라우팅
workflow.add_conditional_edges(
"classify",
route_after_classification,
{
"diet_path": "analyze_diet",
"general_path": "retrieve"
}
)
# 식이 경로 - 세부 분기
workflow.add_conditional_edges(
"analyze_diet",
route_diet_subtask,
{
"meal_plan": "generate_meal_plan",
"food_analysis": "retrieve"
}
)
# 식단 생성 후 문서 검색
workflow.add_edge("generate_meal_plan", "retrieve")
# 문서 검색 후 응답 생성으로 라우팅
workflow.add_conditional_edges(
"retrieve",
route_after_retrieve,
{
"diet_response": "generate_diet_response",
"general_response": "generate_general_response"
}
)
# 최종 노드들은 END로
workflow.add_edge("generate_diet_response", END)
workflow.add_edge("generate_general_response", END)
compiled_workflow = workflow.compile()
logger.info("Workflow compiled successfully")
return compiled_workflow
# ========== Streamlit UI ==========
def main():
# 페이지 설정
st.set_page_config(
page_title="신장질환 AI 상담 시스템",
page_icon="🏥",
layout="wide",
initial_sidebar_state="expanded"
)
# 사용자 정의 CSS
st.markdown("""
<style>
.main {
padding: 2rem;
}
.stButton>button {
background-color: #10b981;
color: white;
border-radius: 10px;
border: none;
padding: 0.5rem 1rem;
font-weight: bold;
transition: background-color 0.3s;
}
.stButton>button:hover {
background-color: #059669;
}
.chat-message {
padding: 1.5rem;
border-radius: 1rem;
margin-bottom: 1rem;
background-color: #f3f4f6;
}
.user-message {
background-color: #e0f2fe;
}
.assistant-message {
background-color: #f0fdf4;
}
</style>
""", unsafe_allow_html=True)
# Lottie 애니메이션 로드
def load_lottie_url(url: str):
try:
r = requests.get(url)
if r.status_code == 200:
return r.json()
except:
pass
return None
# 헤더
col1, col2, col3 = st.columns([1, 2, 1])
with col2:
st.title("🏥 신장질환 AI 상담 시스템")
st.caption("맞춤형 의료 정보를 제공하는 AI 시스템 - OpenAI & LangGraph 기반")
# 사이드바 - 환자 정보 입력
with st.sidebar:
st.header("⚙️ 설정")
# API 키 입력
api_key = st.text_input(
"OpenAI API Key",
type="password",
placeholder="sk-...",
help="OpenAI API 키를 입력하세요"
)
if api_key:
os.environ["OPENAI_API_KEY"] = api_key
st.divider()
st.header("👤 환자 정보")
# 기본 정보
col1, col2 = st.columns(2)
with col1:
age = st.number_input("나이", min_value=0, max_value=150, value=65)
gender = st.selectbox("성별", ["남성", "여성"])
with col2:
egfr = st.number_input("eGFR (ml/min)", min_value=0.0, max_value=150.0, value=25.0)
disease_stage = st.selectbox(
"신장 질환 단계",
options=[stage.value for stage in DiseaseStage],
index=3 # CKD Stage 4
)
on_dialysis = st.checkbox("투석 중", value=False)
# 동반질환 및 약물
st.subheader("🏥 동반질환")
comorbidities = st.multiselect(
"동반질환 선택",
["당뇨", "고혈압", "심부전", "간질환", "통풍"],
default=["당뇨", "고혈압"]
)
st.subheader("💊 복용 약물")
medications = st.text_area(
"복용 중인 약물 (쉼표로 구분)",
value="ARB, 인결합제",
help="예: ARB, 인결합제, 베타차단제"
).split(",")
medications = [med.strip() for med in medications if med.strip()]
st.divider()
# 영양 제한사항
st.header("🥗 영양 제한사항 (일일)")
protein = st.number_input("단백질 (g)", min_value=0.0, value=40.0)
sodium = st.number_input("나트륨 (mg)", min_value=0.0, value=2000.0)
potassium = st.number_input("칼륨 (mg)", min_value=0.0, value=2000.0)
phosphorus = st.number_input("인 (mg)", min_value=0.0, value=800.0)
fluid = st.number_input("수분 (ml)", min_value=0.0, value=1500.0)
calorie = st.number_input("칼로리 (kcal)", min_value=0.0, value=1800.0)
# 메인 영역
# 세션 상태 초기화
if "messages" not in st.session_state:
st.session_state.messages = []
if "workflow" not in st.session_state:
st.session_state.workflow = None
# 워크플로우 초기화
if api_key and st.session_state.workflow is None:
with st.spinner("시스템 초기화 중..."):
try:
st.session_state.workflow = create_kidney_disease_rag_workflow()
st.success("✅ 시스템이 준비되었습니다!")
except Exception as e:
st.error(f"초기화 실패: {e}")
# 채팅 기록 표시
for message in st.session_state.messages:
with st.chat_message(message["role"]):
st.markdown(message["content"])
# 처리 로그가 있으면 표시
if "processing_log" in message:
with st.expander("🔍 처리 과정 보기"):
for log in message["processing_log"]:
st.caption(log)
# 사용자 입력
if prompt := st.chat_input("신장질환에 대해 무엇이든 물어보세요..."):
if not api_key:
st.error("⚠️ OpenAI API 키를 입력해주세요!")
return
if not st.session_state.workflow:
st.error("⚠️ 시스템이 초기화되지 않았습니다!")
return
# 사용자 메시지 추가
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)
# AI 응답 생성
with st.chat_message("assistant"):
with st.spinner("생각 중..."):
try:
# 환자 제약조건 생성
patient_constraints = PatientConstraints(
egfr=egfr,
disease_stage=next(s for s in DiseaseStage if s.value == disease_stage),
on_dialysis=on_dialysis,
comorbidities=comorbidities,
medications=medications,
age=age,
gender=gender,
protein_restriction=protein,
sodium_restriction=sodium,
potassium_restriction=potassium,
phosphorus_restriction=phosphorus,
fluid_restriction=fluid,
calorie_target=calorie
)
# 초기 상태 생성
initial_state = GraphState(
user_query=prompt,
patient_constraints=patient_constraints,
task_type=TaskType.GENERAL,
draft_response="",
draft_items=[],
corrected_items=[],
final_response="",
catalog_results=[],
iteration_count=0,
error=None,
food_analysis_results=None,
recommended_foods=None,
meal_plan=None,
current_node="",
processing_log=[]
)
# 워크플로우 실행
result = st.session_state.workflow.invoke(initial_state)
# 응답 표시
response = result["final_response"]
st.markdown(response)
# 식단 계획이 있으면 표시
if result.get("meal_plan"):
st.divider()
st.subheader("📋 추천 식단")
meal_plan = result["meal_plan"]
cols = st.columns(4)
for idx, (meal_type, foods) in enumerate(meal_plan.items()):
with cols[idx % 4]:
st.markdown(f"**{meal_type.upper()}**")
for food in foods[:3]:
nutrients = food.get_nutrients_per_serving(100)
st.caption(f"• {food.name}")
st.caption(f" 칼로리: {nutrients['calories']:.0f}kcal")
st.caption(f" 단백질: {nutrients['protein']:.1f}g")
# 식품 분석 결과가 있으면 표시
if result.get("food_analysis_results") and result["food_analysis_results"].get("mentioned_foods"):
st.divider()
st.subheader("🔍 식품 영양 분석")
for food_info in result["food_analysis_results"]["mentioned_foods"]:
col1, col2 = st.columns([1, 3])
with col1:
if food_info['suitable']:
st.success("✅ 적합")
else:
st.warning("⚠️ 주의")
with col2:
st.markdown(f"**{food_info['name']}**")
if not food_info['suitable']:
for issue in food_info['issues']:
st.caption(f"• {issue}")
nutrients = food_info['nutrients']
st.caption(
f"100g당: 단백질 {nutrients['protein']:.1f}g, "
f"나트륨 {nutrients['sodium']:.0f}mg, "
f"칼륨 {nutrients['potassium']:.0f}mg"
)
# 응답 저장
message_data = {
"role": "assistant",
"content": response,
"processing_log": result.get("processing_log", [])
}
st.session_state.messages.append(message_data)
except Exception as e:
st.error(f"오류 발생: {str(e)}")
logger.error(f"Error: {e}", exc_info=True)
# 하단 정보
st.divider()
col1, col2, col3 = st.columns(3)
with col1:
st.caption("⚠️ 이 시스템은 의료 정보 제공 목적이며, 실제 진료를 대체할 수 없습니다.")
with col2:
if st.button("💬 새 대화 시작"):
st.session_state.messages = []
st.rerun()
with col3:
if st.button("📥 대화 내용 다운로드"):
conversation = "\n\n".join([
f"{'사용자' if msg['role'] == 'user' else 'AI'}: {msg['content']}"
for msg in st.session_state.messages
])
st.download_button(
label="다운로드",
data=conversation,
file_name=f"kidney_consultation_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt",
mime="text/plain"
)
if __name__ == "__main__":
main() |