File size: 75,988 Bytes
19729e9 0e6887b 19729e9 0e6887b 19729e9 0e6887b 19729e9 0e6887b 19729e9 0e6887b 19729e9 0e6887b 19729e9 0e6887b 19729e9 0e6887b 19729e9 0e6887b 19729e9 0e6887b 19729e9 0e6887b 19729e9 0e6887b 19729e9 0e6887b 19729e9 0e6887b 19729e9 0e6887b 19729e9 0e6887b 19729e9 0e6887b 19729e9 0e6887b 19729e9 0e6887b 19729e9 0e6887b 19729e9 0e6887b 19729e9 0e6887b 19729e9 0e6887b 19729e9 0e6887b 19729e9 0e6887b 19729e9 | 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 | """Streamlit 主应用入口(``core/app.py``)— 需求 15 / 17.1 / 17.2。
底座主框架:**仅负责加载、导航与分发**,不承载任何科学计算(计算在 Skill 的
``compute`` 阶段,纯 Python)。运行方式(与设计一致)::
streamlit run core/app.py --server.port=7860 --server.address=0.0.0.0
此时 ``core/`` 目录位于 ``sys.path[0]``,故 ``kernel`` / ``services`` 以**顶层包**
方式导入。``skills`` 为仓库根下的顶层包,由 ``SkillRegistry.discover("skills")``
自动发现(需求 1.1)。
职责装配(对应 design.md「Architecture / app.py」与需求 15):
1. **构建 Services 容器**(:func:`build_services`):实例化全部底座共享服务;
``LLMService`` 从环境变量读取密钥;任一可选重依赖缺失时优雅降级为 ``None``,
不导致应用崩溃(需求 6.5)。
2. **发现 Skill 并合并 i18n**(:func:`discover_skills` / :func:`merge_skill_i18n`):
扫描 ``skills/`` 注册 Skill,并把每个 Skill 的 ``i18n`` 资源合并进 ``I18nService``
(需求 17.6)。
3. **统一布局**(:func:`main` 及各 ``render_*``):侧边栏参数区(导航 + 各 Skill 的
``render_inputs``)、中央主画布区(结果 / 报告)、可折叠审计追踪区(需求 15.1)。
4. **角色可见性**(:func:`visible_skills_for_role` / :func:`can_view_audit`):按登录
用户角色显示可见 Skill 与审计区(接 ``AuthService``,需求 15.5)。管理员初始密码
取自环境变量 ``ADMIN_INIT_PASSWORD``(需求 5.1,明文不入代码 / 日志)。
5. **顶部语言切换**(:func:`set_session_language` / :func:`render_language_switcher`):
写入 ``Services.lang`` 并触发重渲染;界面文案一律经 ``I18nService.t`` 取用
(需求 17.1 / 17.2 / 17.4)。
6. **分发**(:func:`dispatch`):把选定 Skill + 收集到的 ``RawInput`` 交给固定
``Pipeline`` 执行(extract→compute→explain→report),渲染返回的 ``ReportSections``。
可测试性取向:所有**装配逻辑**(构建服务、发现 + 合并 i18n、角色过滤、语言状态、
认证、分发)都放在**纯函数 / 可导入的辅助函数**中,与 ``st.*`` 渲染调用分离,
便于在无 Streamlit 的环境下用 pytest 直接测试(见 ``tests/test_app.py``)。
安全说明:本应用自身**不暴露任何免认证的网络端点**——它是 Streamlit 渲染的 UI;
角色隔离在应用层完成:审计区仅 ``admin`` / ``auditor`` 可见,分析 Skill 的可见性
由 ``AuthService.visible_skills`` 决定。管理员密码来自环境变量、加盐存储,
日志中不出现任何明文密码或密钥。
"""
from __future__ import annotations
import logging
import os
import re
import sys
from typing import Any, Callable, Optional
# ---------------------------------------------------------------------------
# 导入路径自举(关键):
# 运行 ``streamlit run core/app.py`` 时,Streamlit 仅把 ``core/`` 目录放入
# ``sys.path[0]``,使 ``kernel`` / ``services`` 能以顶层包导入;但顶层包
# ``skills`` / ``utils`` / ``layers`` 位于**仓库根目录**,默认不可导入,导致
# Skill 发现为空(导航「暂无数据」)与注册 / 登录报 ``No module named 'utils'``。
# 因此在模块导入期把「``core/`` 目录」与「仓库根目录」都补进 ``sys.path``,
# 使两类顶层包均可被发现(与 tests/conftest.py 的路径设置保持一致)。
_CORE_DIR = os.path.dirname(os.path.abspath(__file__))
_REPO_ROOT = os.path.dirname(_CORE_DIR)
for _p in (_CORE_DIR, _REPO_ROOT):
if _p not in sys.path:
sys.path.insert(0, _p)
# 底座以**顶层包** kernel / services 导入(core/ 位于 sys.path[0])。
from kernel.pipeline import Pipeline
from kernel.registry import SkillRegistry
from kernel.services import Services
from kernel.skill_base import PharmaSkill, RawInput, ReportSections
# Streamlit 为前端运行期依赖;导入失败(如纯逻辑单测环境)时降级为 None,
# 使本模块的装配逻辑仍可被导入与测试。
try: # pragma: no cover - 取决于运行环境是否安装 streamlit
import streamlit as st
except Exception: # noqa: BLE001 - 无 streamlit 时仅禁用渲染,不影响逻辑导入
st = None # type: ignore[assignment]
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# 常量
# ---------------------------------------------------------------------------
DEFAULT_LANG = "zh"
#: 默认会话角色(未登录):研发人员——可使用分析 Skill,但不可查看审计区(需求 15.5)。
DEFAULT_ROLE = "researcher"
#: Streamlit 会话状态键。
SESSION_CTX = "pk_app_context" # (svc, registry) 缓存,跨重渲染复用
SESSION_LANG = "pk_lang" # 当前会话语言
SESSION_USER = "pk_user" # 当前登录用户名
SESSION_ROLE = "pk_role" # 当前会话角色
SESSION_SECTIONS = "pk_sections" # 最近一次分析返回的 ReportSections
SESSION_SKILL = "pk_skill_id" # 当前选中的 Skill id
SESSION_USER_MODEL = "pk_user_model" # 用户自配模型 (UserModelConfig)
SESSION_TASK_SHEET = "pk_task_sheet" # 最近一次理解层产出的任务单 (dict)
SESSION_SHEET_STATE = "pk_sheet_state" # "none" | "pending_confirm" | "confirmed"
# ===========================================================================
# 一、服务构建(需求 7 / 6.5:缺库优雅降级)
# ===========================================================================
def build_services(
env: Optional[dict] = None,
*,
skill_ids: Optional[list] = None,
audit_db_path: str = ":memory:",
) -> Services:
"""构建并返回底座 :class:`Services` 容器。
实例化全部底座共享服务;``LLMService`` 从环境变量读取密钥(无密钥则自然无可用
提供商,``complete`` 返回结构化错误而非静默)。**任一服务构建失败都被捕获**并把
对应字段降级为 ``None``(需求 6.5:可选重依赖缺失不得崩溃),失败信息收集到
``svc._build_warnings`` 供 UI 提示。
参数:
- ``env``:环境映射,默认 ``os.environ``。
- ``skill_ids``:已注册 Skill 的 id 列表,用于 ``AuthService.visible_skills``
的角色可见性判定;省略时回退到 AuthService 的默认集合。
- ``audit_db_path``:审计库路径,默认 ``":memory:"``(按会话保存)。
"""
env = os.environ if env is None else env
warnings: list[tuple[str, str]] = []
def _try(name: str, factory: Callable[[], Any]) -> Any:
try:
return factory()
except Exception as exc: # noqa: BLE001 - 单个服务失败即降级,不阻断整体
logger.warning("构建服务 %s 失败,已降级为不可用:%s", name, exc)
warnings.append((name, str(exc)))
return None
i18n = _try("i18n", _build_i18n)
usage = _try("usage", _build_usage)
llm = _try("llm", lambda: _build_llm(env, usage))
audit = _try("audit", lambda: _build_audit(audit_db_path))
report = _try("report", _build_report)
chart = _try("chart", _build_chart)
file = _try("file", _build_file)
prompt_guard = _try("prompt_guard", _build_prompt_guard)
auth = _try("auth", lambda: _build_auth(skill_ids))
svc = Services(
llm=llm,
report=report,
chart=chart,
file=file,
auth=auth,
audit=audit,
usage=usage,
prompt_guard=prompt_guard,
i18n=i18n,
lang=(getattr(i18n, "lang", DEFAULT_LANG) if i18n is not None else DEFAULT_LANG),
)
# 把构建告警挂在实例上供 UI 友好提示(Services 为普通 dataclass,可附加属性)。
setattr(svc, "_build_warnings", warnings)
return svc
# 各服务的惰性构建器:在函数内部 import,使任一服务的重依赖问题被 build_services
# 的 try/except 隔离,而不在模块导入期就连带失败。
def _build_i18n():
from services.i18n_service import I18nService
return I18nService()
def _build_usage():
from services.usage_service import UsageService
return UsageService()
def _build_llm(env: dict, usage: Any):
from services.llm_service import LLMService, SlidingWindowRateLimiter
# 全局滑动窗口限流:默认 60 秒内最多 30 次 LLM 调用(需求 12.1)。
limiter = SlidingWindowRateLimiter(max_calls=30, window=60.0)
return LLMService.from_env(env=env, usage=usage, rate_limiter=limiter)
def _build_audit(db_path: str):
from services.audit_service import AuditService
return AuditService(db_path=db_path)
def _build_report():
from services.report_service import ReportService
return ReportService()
def _build_chart():
from services.chart_service import ChartService
return ChartService()
def _build_file():
from services.file_service import FileService
return FileService()
def _build_prompt_guard():
from services.prompt_guard import PromptGuard
return PromptGuard()
def _build_auth(skill_ids: Optional[list]):
from services.auth_service import AuthService
if skill_ids:
return AuthService(all_skill_ids=skill_ids)
return AuthService()
# ===========================================================================
# 二、技能发现与 i18n 合并(需求 1.1 / 17.6)
# ===========================================================================
def discover_skills(skills_source: str = "skills", svc: Optional[Services] = None) -> SkillRegistry:
"""扫描 ``skills_source`` 注册全部 Skill,返回 :class:`SkillRegistry`。
单个 Skill 导入失败由注册器内部容错跳过(需求 1.3);``svc`` 注入后失败会落审计。
"""
registry = SkillRegistry(svc)
registry.discover(skills_source)
return registry
def merge_skill_i18n(svc: Services, registry: SkillRegistry) -> int:
"""把每个已注册 Skill 的 ``i18n`` 资源合并进底座 ``I18nService``(需求 17.6)。
兼容两种 Skill i18n 约定,避免命名空间前缀重复:
- 若 Skill 的 CATALOG 键**已**全部以 ``"<id>."`` 前缀(如 ``general_qa.title``),
直接合并、不再加前缀。
- 否则(如稳定性 / 相容性的 ``name`` / ``section.results``)以 ``namespace=<id>``
合并,键自动加 ``"<id>."`` 前缀。
返回成功合并资源的 Skill 数量。``i18n`` 不可用时安全返回 0。
"""
i18n = getattr(svc, "i18n", None)
merge = getattr(i18n, "merge", None)
if not callable(merge):
return 0
merged = 0
for skill in registry.all():
catalog = getattr(skill, "i18n", None)
if not catalog:
continue
skill_id = getattr(skill.meta, "id", "") or ""
prefix = f"{skill_id}."
try:
keys = list(catalog.keys())
already_prefixed = bool(keys) and all(str(k).startswith(prefix) for k in keys)
if already_prefixed or not skill_id:
merge(catalog)
else:
merge(catalog, namespace=skill_id)
merged += 1
except Exception as exc: # noqa: BLE001 - 单个 Skill 文案合并失败不阻断其它
logger.warning("合并 Skill %s 的 i18n 资源失败:%s", skill_id, exc)
return merged
def bootstrap(
env: Optional[dict] = None,
skills_source: str = "skills",
*,
audit_db_path: str = ":memory:",
) -> tuple[Services, SkillRegistry]:
"""一次性装配:发现 Skill → 构建 Services(含角色可见性所需的 skill_ids)→ 合并 i18n。
返回 ``(svc, registry)``。本函数为纯装配、无 ``st.*`` 依赖,便于单测。
"""
env = os.environ if env is None else env
registry = discover_skills(skills_source)
skill_ids = [s.meta.id for s in registry.all()]
svc = build_services(env, skill_ids=skill_ids, audit_db_path=audit_db_path)
registry.svc = svc # 后续失败可落审计
merge_skill_i18n(svc, registry)
return svc, registry
# ===========================================================================
# 三、角色可见性与导航(需求 15.5)
# ===========================================================================
def visible_skills_for_role(
registry: SkillRegistry, auth: Any, role: Optional[str]
) -> list:
"""返回该角色在导航中可见的 Skill 列表(按导航顺序)。
可见 id 集合由 ``AuthService.visible_skills(role)`` 决定(需求 15.5):
admin / researcher 可见全部分析 Skill;auditor 只读、不展示可执行 Skill。
``auth`` 不可用时回退为展示全部已注册 Skill(避免空界面)。
"""
all_skills = registry.all()
visible = getattr(auth, "visible_skills", None)
if not callable(visible):
return all_skills
try:
visible_ids = set(visible(role))
except Exception as exc: # noqa: BLE001 - 判定异常时回退全部
logger.warning("计算角色可见 Skill 失败,回退展示全部:%s", exc)
return all_skills
return [s for s in all_skills if s.meta.id in visible_ids]
def can_view_audit(auth: Any, role: Optional[str]) -> bool:
"""该角色是否可查看审计追踪区(admin / auditor 可见,需求 15.5)。"""
checker = getattr(auth, "can_view_audit", None)
if not callable(checker):
return False
try:
return bool(checker(role))
except Exception: # noqa: BLE001
return False
def skill_nav_label(skill: PharmaSkill, i18n: Any = None) -> str:
"""构造 Skill 的导航标签(图标 + 本地化名称),文案经 i18n 取用(需求 17.4)。
优先取底座导航键 ``nav.<id>``,其次取 Skill 自身的 ``<id>.name``,再退回
``meta.display_name``。``i18n`` 不可用时直接用 display_name。
"""
meta = skill.meta
icon = getattr(meta, "icon", "") or ""
display = getattr(meta, "display_name", meta.id)
has = getattr(i18n, "has", None)
t = getattr(i18n, "t", None)
if callable(has) and callable(t):
for key in (f"nav.{meta.id}", f"{meta.id}.name"):
try:
if has(key):
display = t(key)
break
except Exception: # noqa: BLE001 - 文案取用失败回退 display_name
pass
return f"{icon} {display}".strip()
def skill_description(skill: PharmaSkill, i18n: Any = None, lang: Optional[str] = None) -> str:
"""返回 Skill 的本地化说明文字(随语言切换,需求 17.3 / 1 修复)。
优先取底座 ``desc.<id>``,其次取 Skill 自身的 ``<id>.description``,
再退回 ``meta.description``(硬编码中文兜底)。
"""
meta = skill.meta
fallback = getattr(meta, "description", "") or ""
has = getattr(i18n, "has", None)
t = getattr(i18n, "t", None)
if callable(t):
for key in (f"desc.{meta.id}", f"{meta.id}.description"):
try:
if not callable(has) or has(key):
return t(key, lang) if lang else t(key)
except Exception: # noqa: BLE001 - 取用失败回退
pass
return fallback
def skill_action_label(skill: PharmaSkill, svc: Services) -> str:
"""返回 Skill 主操作按钮文案:问答类用「开始提问」,其余用「开始分析」(需求 5 修复)。"""
action_key = "btn.ask" if getattr(skill.meta, "id", "") == "general_qa" else "btn.analyze"
return _t(svc, action_key)
# ===========================================================================
# 四、语言状态(需求 17.1 / 17.2)
# ===========================================================================
def set_session_language(svc: Services, requested: Optional[str]) -> str:
"""设置当前会话语言:写入 ``Services.lang`` 与 ``I18nService.lang``,返回归一语言码。
未知 / 空输入回退默认语言(不报错,需求 17.5)。这是「写入 Services.lang 并触发
重渲染」的纯逻辑部分;触发重渲染由 :func:`render_language_switcher` 在 UI 侧完成。
"""
i18n = getattr(svc, "i18n", None)
set_lang = getattr(i18n, "set_language", None)
if callable(set_lang):
lang = set_lang(requested)
else:
lang = (requested or DEFAULT_LANG)
svc.lang = lang
return lang
# ===========================================================================
# 五、认证(需求 5.1 / 5.4 / 15.5)
# ===========================================================================
def authenticate(
username: str,
password: str,
*,
user_lookup: Callable[[str], Optional[dict]],
auth: Any = None,
) -> Optional[dict]:
"""校验用户名 / 密码,成功返回 ``{"user", "role"}``,失败返回 ``None``。
- ``user_lookup(email) -> record``:返回含 ``password_hash``(或 ``password``)与
``role`` 的记录;返回 ``None`` 表示用户不存在。注入此参数使认证逻辑与具体
存储后端解耦,便于无数据库的单测。
- ``auth``::class:`AuthService`,用于加盐哈希校验(``verify_password``,需求 5.4)
与角色归一(``normalize_role``)。省略时按需构造。
本函数不记录任何明文密码(需求 5.2)。
"""
if not username or not password:
return None
if auth is None:
from services.auth_service import AuthService
auth = AuthService()
try:
record = user_lookup(username)
except Exception as exc: # noqa: BLE001 - 存储查询异常视为登录失败(不回显细节)
logger.warning("用户查询失败,登录被拒绝:%s", exc)
return None
if not record:
return None
stored = record.get("password_hash") or record.get("password") or ""
if not auth.verify_password(password, stored):
return None
role = auth.normalize_role(record.get("role"))
return {"user": username, "role": role}
def ensure_admin_from_env(env: Optional[dict] = None) -> bool:
"""尽力按环境变量 ``ADMIN_INIT_PASSWORD`` 初始化管理员账户(需求 5.1)。
复用 ``utils.database``:``get_db_connection`` 在建表时会按环境变量创建管理员
(加盐哈希、明文不入库 / 日志)。数据库不可用时安全返回 ``False``,不抛出。
"""
try:
from utils import database as db
except Exception as exc: # noqa: BLE001 - 无数据库模块时跳过
logger.info("用户数据库不可用,跳过管理员初始化:%s", exc)
return False
try:
conn = db.get_db_connection()
conn.close()
return True
except Exception as exc: # noqa: BLE001 - 初始化失败不阻断应用
logger.warning("管理员初始化失败(已忽略):%s", exc)
return False
def _db_user_lookup(username: str) -> Optional[dict]:
"""基于 ``utils.database`` 的用户查询(供 UI 登录使用);不可用时返回 ``None``。"""
try:
from utils import database as db
return db.get_user_by_email(username)
except Exception as exc: # noqa: BLE001
logger.warning("数据库用户查询不可用:%s", exc)
return None
# ===========================================================================
# 六、模型配置解析(需求 5:用户 > 管理员 > 环境变量)
# ===========================================================================
def get_admin_model_config() -> Optional[dict]:
"""读取管理员级模型配置(来自 ``utils.database``);不可用时返回 ``None``。"""
try:
from utils import database as db
return db.get_default_llm_config()
except Exception as exc: # noqa: BLE001 - 数据库不可用时忽略管理员档
logger.info("读取管理员模型配置失败(已忽略):%s", exc)
return None
def resolve_session_llm(svc: Services, user_model: Any = None) -> str:
"""按 用户 > 管理员 > 环境变量 解析并重建 ``svc.llm``,返回首选档来源。
- ``user_model``:本会话用户自配(:class:`UserModelConfig`)或 ``None``。
- 解析出有序 Fallback 链后用 :func:`build_llm_service` 重建 ``svc.llm``,
保留底座的用量计量与限流(沿用原 ``svc.llm`` 的 usage / rate_limiter)。
返回来源标识:``"user"`` / ``"admin"`` / ``"env"`` / ``"none"``。
"""
try:
from services.model_config import build_llm_service, resolve_chain
admin_cfg = get_admin_model_config()
chain = resolve_chain(user_cfg=user_model, admin_cfg=admin_cfg, env=os.environ)
# 复用既有 usage / rate_limiter,保持计量与限流连续。
old = getattr(svc, "llm", None)
usage = getattr(old, "_usage", None) or getattr(svc, "usage", None)
rate_limiter = getattr(old, "_rate_limiter", None)
svc.llm = build_llm_service(chain, usage=usage, rate_limiter=rate_limiter)
return chain.source
except Exception as exc: # noqa: BLE001 - 解析失败不应阻断应用
logger.warning("解析会话模型配置失败(保留原 LLM):%s", exc)
return "env"
# ===========================================================================
# 七、分发(需求 2.2:固定流水线)
# ===========================================================================
def dispatch(
skill: PharmaSkill,
raw: RawInput,
svc: Services,
*,
pipeline: Optional[Pipeline] = None,
user: str = "anonymous",
) -> ReportSections:
"""把选定 Skill + ``RawInput`` 交给固定 ``Pipeline`` 执行并返回 ``ReportSections``。
底座流水线顺序固定(extract→compute→explain→report),``compute`` 不接收 ``svc``
(需求 2.1 由契约保证)。本函数另对「分析执行」事件做尽力而为的审计记录
(需求 7.4)。
"""
pipeline = pipeline or Pipeline()
sections = pipeline.run(skill, raw, svc)
_audit_analysis(svc, user, getattr(skill.meta, "id", ""))
return sections
def _audit_analysis(svc: Services, user: str, skill_id: str) -> None:
audit = getattr(svc, "audit", None)
recorder = getattr(audit, "record_analysis", None)
if callable(recorder):
try:
recorder(user or "anonymous", skill_id, step="execute")
except Exception: # noqa: BLE001 - 审计失败不得影响主流程
logger.debug("记录分析执行审计失败(已忽略)。", exc_info=True)
# ===========================================================================
# 七·b、理解层两阶段编排(intent-understanding-layer)
# ===========================================================================
def build_orchestrator(registry: SkillRegistry, svc: Services):
"""构造 ``AnalysisOrchestrator``(理解→确认→分析两阶段编排)。
放在独立纯函数中便于单测;导入失败(理解层未就绪)时返回 ``None``,调用方据此
回退到旧的直接分发路径,保证向后兼容。
"""
try:
from kernel.orchestrator import AnalysisOrchestrator
return AnalysisOrchestrator(registry, svc)
except Exception as exc: # noqa: BLE001 - 理解层不可用时回退
logger.info("理解层编排器不可用,回退直接分发:%s", exc)
return None
def stale_session_keys_on_switch(prev_skill_id: Optional[str], new_skill_id: str) -> list[str]:
"""切换技能时应清理的会话键(纯函数,供 UI 与测试)。
首次进入(``prev_skill_id`` 为空)或技能未变化时返回空列表;技能切换时返回需清理的
残留键(上一技能的报告结果与任务单状态),避免旧报告串显到新技能。
"""
if not prev_skill_id or prev_skill_id == new_skill_id:
return []
return [SESSION_SECTIONS, SESSION_TASK_SHEET, SESSION_SHEET_STATE]
def sheet_is_confirmable(sheet_dict: Optional[dict]) -> bool:
"""任务单是否可被确认执行(存在、且非澄清态)。
纯函数,供 UI 与测试判定「确认并开始分析」按钮是否可用(需求 5.4 / 5.7)。
"""
if not sheet_dict:
return False
clar = sheet_dict.get("clarification")
if clar and clar.get("needed"):
return False
return True
def items_to_rows(items: list[dict]) -> list[dict]:
"""把任务单提取项转为可编辑表格行(纯函数,供 UI 与测试,需求 5.3)。"""
rows: list[dict] = []
for it in items or []:
group = it.get("group") or {}
rows.append({
"use": True,
"field": it.get("field", ""),
"value": it.get("value", ""),
"strength": group.get("strength", ""),
"batch": group.get("batch", ""),
"attribute": group.get("attribute", ""),
"source_ref": it.get("source_ref", ""),
"origin": it.get("source", "llm"),
})
return rows
def rows_to_items(rows: list[dict], original_items: Optional[list[dict]] = None) -> list[dict]:
"""把编辑后的表格行转回任务单提取项(纯函数,需求 5.5)。
判定每行是否被用户改动:与对应的原始项逐字段比对;改动或新增的行标记
``source="user_edited"``(回填校验据此豁免)。未勾选 ``use`` 的行被丢弃(删除)。
"""
original_items = original_items or []
# 以 (field,value,attribute) 粗略对齐原始项,判断是否被编辑。
def _orig_signature(it: dict) -> tuple:
g = it.get("group") or {}
return (str(it.get("field", "")), str(it.get("value", "")),
str(g.get("strength", "")), str(g.get("batch", "")),
str(g.get("attribute", "")), str(it.get("source_ref", "")))
orig_sigs = {_orig_signature(it): it for it in original_items}
items: list[dict] = []
for row in rows or []:
if not row.get("use", True):
continue # 删除:未勾选纳入
field = str(row.get("field", "") or "").strip()
value = str(row.get("value", "") or "").strip()
if not field and not value:
continue # 空行跳过
sig = (field, value, str(row.get("strength", "") or ""),
str(row.get("batch", "") or ""), str(row.get("attribute", "") or ""),
str(row.get("source_ref", "") or ""))
if sig in orig_sigs:
# 未改动:保留原项(含其 source / located)。
items.append(orig_sigs[sig])
else:
# 改动 / 新增:标记 user_edited,豁免回填校验。
items.append({
"field": field,
"value": value,
"source_ref": str(row.get("source_ref", "") or ""),
"located": True,
"group": {
"strength": str(row.get("strength", "") or ""),
"batch": str(row.get("batch", "") or ""),
"attribute": str(row.get("attribute", "") or ""),
},
"source": "user_edited",
})
return items
def apply_sheet_edits(
sheet_dict: dict,
edited_rows: list[dict],
proposed_skill_id: Optional[str] = None,
) -> dict:
"""把用户编辑(行 + 功能切换)应用回任务单 dict(纯函数,需求 5.5)。"""
new_sheet = dict(sheet_dict or {})
new_sheet["extracted_items"] = rows_to_items(edited_rows, sheet_dict.get("extracted_items"))
if proposed_skill_id is not None:
new_sheet["proposed_skill_id"] = proposed_skill_id
return new_sheet
# —— 意图槽位确认(意图保真,阶段四)——
#: 槽位名 → 本地化展示标签(中文兜底)。
_SLOT_LABELS = {
"primary_cqa": "主要质量指标",
"target_timepoints": "目标外推时间点",
"spec_limit": "规格限度",
"target_attributes": "目标质量属性",
}
#: 来源态 → 展示文案。
_SLOT_STATE_LABELS = {
"stated": "用户明示",
"inferred": "系统推断",
"missing": "缺失(待确认)",
}
def slots_to_rows(sheet_dict: dict) -> list[dict]:
"""把任务单意图槽位转为可编辑确认表格行(纯函数,供 UI 与测试)。
每行暴露:槽位名 / 标签 / 当前值 / 来源态 / 依据 / 是否锁定。``missing`` 槽位
的值为空字符串供用户填写。仅返回影响结论的槽位之外的全部槽位(含规格)。
"""
rows: list[dict] = []
for s in sheet_dict.get("intent_slots") or []:
name = str(s.get("name", ""))
val = s.get("value")
if isinstance(val, list):
val_txt = ", ".join(str(v) for v in val)
else:
val_txt = "" if val is None else str(val)
rows.append({
"name": name,
"label": _SLOT_LABELS.get(name, name),
"value": val_txt,
"state": str(s.get("state", "missing")),
"state_label": _SLOT_STATE_LABELS.get(str(s.get("state", "missing")), ""),
"evidence": str(s.get("evidence", "")),
"locked": bool(s.get("locked", False)),
"affects_result": bool(s.get("affects_result", True)),
})
return rows
def _parse_slot_value(name: str, raw_value: str):
"""把编辑框文本解析回槽位值类型(时间点 → int 列表;其余 → 去空白字符串)。"""
txt = str(raw_value or "").strip()
if not txt:
return None
if name == "target_timepoints":
nums: list[int] = []
for tok in re.split(r"[,,、\s]+", txt):
tok = tok.strip()
if not tok:
continue
try:
nums.append(int(float(tok)))
except ValueError:
continue
return nums or None
return txt
def apply_slot_edits(sheet_dict: dict, edited_slot_rows: list[dict]) -> dict:
"""把用户对意图槽位的确认/编辑写回任务单(纯函数,采纳真实意图的硬保证,需求 5.5)。
规则:用户填入/修改了某槽位的值,或显式勾选锁定 → 该槽位变为 ``stated`` + ``locked``
(置信 1.0),下游计算据此覆盖派生值,且后续重推断不得推翻。未改动且未锁定的槽位
保持原状。这是「用户说了算」的工程落地。
"""
new_sheet = dict(sheet_dict or {})
by_name = {str(r.get("name", "")): r for r in (edited_slot_rows or [])}
new_slots: list[dict] = []
for s in sheet_dict.get("intent_slots") or []:
slot = dict(s)
name = str(slot.get("name", ""))
row = by_name.get(name)
if row is not None:
new_val = _parse_slot_value(name, row.get("value"))
user_locked = bool(row.get("locked", False))
old_val = slot.get("value")
changed = new_val is not None and new_val != old_val
if changed or user_locked:
if new_val is not None:
slot["value"] = new_val
# 有有效值才视为已确认明示;仅勾锁却无值则保持缺失(避免锁定空值)。
if slot.get("value") not in (None, "", []):
slot["state"] = "stated"
slot["locked"] = True
slot["confidence"] = 1.0
new_slots.append(slot)
new_sheet["intent_slots"] = new_slots
return new_sheet
# ===========================================================================
# 八、Streamlit 渲染层(统一布局,需求 15.1)
# ===========================================================================
#
# 以下函数依赖 ``st``(Streamlit 模块)。装配逻辑均在上方纯函数中完成,这里只做
# 渲染编排:顶部语言切换 → 侧边栏(登录 + 导航 + 当前 Skill 输入面板)→ 中央主画布
# (结果 / 报告)→ 可折叠审计追踪区。无 streamlit 时这些函数不会被调用(``main``
# 在入口处守护),故纯逻辑单测不受影响。
def _t(svc: Services, key: str, **params) -> str:
"""经底座 I18nService 取用文案的便捷封装(缺失安全回退,需求 17.4 / 17.5)。"""
i18n = getattr(svc, "i18n", None)
t = getattr(i18n, "t", None)
if callable(t):
try:
return t(key, **params) if params else t(key)
except Exception: # noqa: BLE001 - 取用失败回退 key 本身
return key
return key
def get_or_create_context() -> tuple[Services, SkillRegistry]:
"""在 Streamlit 会话中构建并缓存 ``(svc, registry)``,跨重渲染复用。"""
if SESSION_CTX not in st.session_state:
ensure_admin_from_env()
svc, registry = bootstrap(
os.environ, "skills", audit_db_path=_default_audit_db_path()
)
st.session_state[SESSION_CTX] = (svc, registry)
svc, registry = st.session_state[SESSION_CTX]
# 把会话语言同步进 Services(语言切换后重渲染时生效,需求 17.2)。
lang = st.session_state.get(SESSION_LANG, svc.lang or DEFAULT_LANG)
set_session_language(svc, lang)
return svc, registry
def _default_audit_db_path() -> str:
"""审计库落盘路径(``<repo>/data/audit.db``);目录不可用则回退内存库。"""
try:
from pathlib import Path
data_dir = Path(__file__).resolve().parents[1] / "data"
data_dir.mkdir(parents=True, exist_ok=True)
return str(data_dir / "audit.db")
except Exception: # noqa: BLE001 - 落盘不可用回退内存库
return ":memory:"
def render_language_switcher(svc: Services) -> None:
"""顶部语言切换入口:写入会话语言与 ``Services.lang`` 并触发重渲染(需求 17.1/17.2)。"""
i18n = getattr(svc, "i18n", None)
langs = list(getattr(i18n, "available_languages", lambda: ("zh", "en"))())
current = st.session_state.get(SESSION_LANG, svc.lang or DEFAULT_LANG)
cols = st.columns([6, 1])
with cols[1]:
choice = st.selectbox(
_t(svc, "label.language"),
options=langs,
index=langs.index(current) if current in langs else 0,
format_func=lambda code: _t(svc, f"lang.{code}"),
key="pk_lang_select",
)
if choice != current:
st.session_state[SESSION_LANG] = choice
set_session_language(svc, choice)
_rerun()
def render_login(svc: Services) -> None:
"""侧边栏账户区:登录 / 注册 / 退出;明文密码不记录(需求 5.1 / 5.2)。"""
user = st.session_state.get(SESSION_USER)
if user:
role = st.session_state.get(SESSION_ROLE, DEFAULT_ROLE)
st.sidebar.caption(f"👤 {user} · {_t(svc, f'role.{role}')}")
if st.sidebar.button(_t(svc, "btn.logout"), key="pk_logout", use_container_width=True):
for k in (SESSION_USER, SESSION_ROLE, SESSION_USER_MODEL, "pk_user_model_loaded"):
st.session_state.pop(k, None)
_rerun()
return
with st.sidebar.expander(_t(svc, "auth.account"), expanded=False):
login_tab, register_tab = st.tabs(
[_t(svc, "auth.login_tab"), _t(svc, "auth.register_tab")]
)
# —— 登录 ——
with login_tab:
username = st.text_input(_t(svc, "label.username"), key="pk_login_user")
password = st.text_input(
_t(svc, "label.password"), type="password", key="pk_login_pass"
)
if st.button(_t(svc, "btn.login"), key="pk_login_submit", use_container_width=True):
result = authenticate(
username, password,
user_lookup=_db_user_lookup,
auth=getattr(svc, "auth", None),
)
if result:
st.session_state[SESSION_USER] = result["user"]
st.session_state[SESSION_ROLE] = result["role"]
_audit_login(svc, result["user"], True)
_rerun()
else:
_audit_login(svc, username or "(unknown)", False)
st.error(_t(svc, "auth.login_fail"))
# —— 注册 ——
with register_tab:
reg_email = st.text_input(_t(svc, "auth.email"), key="pk_reg_email")
reg_pass = st.text_input(
_t(svc, "label.password"), type="password", key="pk_reg_pass"
)
if st.button(_t(svc, "btn.register"), key="pk_reg_submit", use_container_width=True):
ok, msg = _register_user(reg_email, reg_pass)
if ok:
st.success(_t(svc, "auth.register_ok"))
else:
st.error(f"{_t(svc, 'auth.register_fail')}: {msg}")
def _register_user(email: str, password: str):
"""注册新用户(复用 ``utils.auth``);返回 ``(ok, message)``,不可用时友好失败。"""
try:
from utils.auth import register_user
return register_user(email, password)
except Exception as exc: # noqa: BLE001 - 注册后端不可用时不崩溃
logger.warning("注册功能不可用:%s", exc)
return False, str(exc)
def _render_login_legacy_unused(svc: Services) -> None:
"""(已废弃,保留空壳避免历史引用报错)"""
return None
def _audit_login(svc: Services, user: str, success: bool) -> None:
audit = getattr(svc, "audit", None)
recorder = getattr(audit, "record_login", None)
if callable(recorder):
try:
recorder(user, success=success)
except Exception: # noqa: BLE001
logger.debug("记录登录审计失败(已忽略)。", exc_info=True)
def _load_persisted_user_model(svc: Services) -> None:
"""登录用户首次进入会话时,把持久化的自配模型载入会话(若有且加密可用)。
仅在会话尚未载入过、且尚无会话级用户配置时执行,避免覆盖用户本次的临时修改。
"""
user = st.session_state.get(SESSION_USER)
if not user:
return
if st.session_state.get("pk_user_model_loaded") == user:
return
st.session_state["pk_user_model_loaded"] = user
if st.session_state.get(SESSION_USER_MODEL) is not None:
return
try:
from services.model_config import UserModelConfig
from utils import database as db
rec = db.get_user_llm_config(user)
if rec and rec.get("enabled"):
cfg = UserModelConfig(
provider=rec.get("provider", ""),
model=rec.get("model", ""),
api_key=rec.get("api_key", ""),
base_url=rec.get("base_url", ""),
)
if cfg.is_usable():
st.session_state[SESSION_USER_MODEL] = cfg
except Exception as exc: # noqa: BLE001 - 持久化不可用时静默退回仅会话
logger.info("载入持久化用户模型配置不可用:%s", exc)
def _persist_user_model(cfg: Any) -> bool:
"""把登录用户的自配模型加密持久化;未登录 / 加密不可用时返回 False(仅会话)。"""
user = st.session_state.get(SESSION_USER)
if not user:
return False
try:
from utils import database as db
return db.set_user_llm_config(
user, cfg.provider, cfg.api_key, model=cfg.model,
base_url=cfg.base_url, enabled=True,
)
except Exception as exc: # noqa: BLE001
logger.info("持久化用户模型配置不可用:%s", exc)
return False
def _clear_persisted_user_model() -> None:
"""删除登录用户持久化的自配模型(若已登录)。"""
user = st.session_state.get(SESSION_USER)
if not user:
return
try:
from utils import database as db
db.clear_user_llm_config(user)
except Exception as exc: # noqa: BLE001
logger.info("清除持久化用户模型配置不可用:%s", exc)
def render_model_settings(svc: Services) -> None:
"""侧边栏「模型设置」面板:用户自配模型(最高优先级),并显示当前生效来源。
用户可选择提供商 / 模型并填写自己的密钥;留空则使用管理员 / 环境配置(需求 5)。
登录用户在配置了 ``PHARMAK_SECRET_KEY`` 时,密钥会**加密后持久化**并随 DB 同步到
私有 Dataset,跨会话免重填;否则仅存会话状态(``SESSION_USER_MODEL``)。
"""
try:
from layers.llm_providers import AVAILABLE_MODELS, LLMProvider, get_available_providers
from services.model_config import UserModelConfig
except Exception as exc: # noqa: BLE001 - 依赖不可用时跳过该面板
logger.info("模型设置面板不可用:%s", exc)
return
# 登录用户:尝试载入持久化配置(仅首次)。
_load_persisted_user_model(svc)
providers = get_available_providers()
with st.sidebar.expander(_t(svc, "model.settings"), expanded=False):
st.caption(_t(svc, "model.use_admin_hint"))
# 提示持久化能力:登录 + 加密可用时密钥跨会话保留,否则仅本次会话。
try:
from utils import crypto
if st.session_state.get(SESSION_USER) and crypto.is_available():
st.caption(_t(svc, "model.persist_on"))
else:
st.caption(_t(svc, "model.persist_off"))
except Exception: # noqa: BLE001
st.caption(_t(svc, "model.persist_off"))
ids = [p["id"] for p in providers]
names = {p["id"]: p["name"] for p in providers}
# 默认选中:会话已存用户配置则定位之,否则第一个。
existing: Optional[UserModelConfig] = st.session_state.get(SESSION_USER_MODEL)
default_idx = ids.index(existing.provider) if (existing and existing.provider in ids) else 0
pid = st.selectbox(
_t(svc, "model.provider"), options=ids,
index=default_idx, format_func=lambda i: names.get(i, i),
key="pk_user_provider",
)
# 模型:已知提供商给下拉 + "其他(手动)";custom 直接手填。
model_value = ""
try:
prov_enum = LLMProvider(pid)
except ValueError:
prov_enum = None
known_models = list(AVAILABLE_MODELS.get(prov_enum, [])) if prov_enum else []
if pid == "custom":
model_value = st.text_input(_t(svc, "model.model_custom"), key="pk_user_model_custom") or ""
base_url = st.text_input(
_t(svc, "model.base_url"), help=_t(svc, "model.base_url_hint"),
key="pk_user_base_url",
) or ""
else:
base_url = ""
if known_models:
choice = st.selectbox(
_t(svc, "model.model"),
options=known_models + ["—"],
key="pk_user_model_pick",
)
model_value = "" if choice == "—" else choice
custom_m = st.text_input(_t(svc, "model.model_custom"), key="pk_user_model_free") or ""
if custom_m.strip():
model_value = custom_m.strip()
api_key = st.text_input(_t(svc, "model.api_key"), type="password", key="pk_user_api_key") or ""
cols = st.columns(2)
with cols[0]:
if st.button(_t(svc, "model.save"), key="pk_user_model_save"):
cfg = UserModelConfig(
provider=pid, model=model_value, api_key=api_key, base_url=base_url
)
if pid == "custom" and not _custom_base_url_ok(svc, base_url):
pass # 错误已在 _custom_base_url_ok 内提示
elif cfg.is_usable():
st.session_state[SESSION_USER_MODEL] = cfg
persisted = _persist_user_model(cfg)
st.success(_t(svc, "model.saved_persisted" if persisted else "model.saved"))
_rerun()
else:
st.warning(_t(svc, "model.test_fail"))
with cols[1]:
if st.button(_t(svc, "model.clear"), key="pk_user_model_clear"):
st.session_state.pop(SESSION_USER_MODEL, None)
_clear_persisted_user_model()
st.info(_t(svc, "model.cleared"))
_rerun()
if st.button(_t(svc, "model.test"), key="pk_user_model_test"):
cfg = UserModelConfig(provider=pid, model=model_value, api_key=api_key, base_url=base_url)
if pid == "custom" and not _custom_base_url_ok(svc, base_url):
pass # 错误已提示
elif _test_model_config(cfg):
st.success(_t(svc, "model.test_ok"))
else:
st.error(_t(svc, "model.test_fail"))
# 显示当前生效来源(用户/管理员/环境/无)。
source = resolve_session_llm(svc, st.session_state.get(SESSION_USER_MODEL))
st.caption(_t(svc, f"model.source.{source}"))
def _custom_base_url_ok(svc: Any, base_url: str) -> bool:
"""校验用户填写的 custom base_url 是否安全(防 SSRF);不安全时在 UI 提示并返回 False。"""
try:
from utils.url_guard import is_safe_public_url
ok, reason = is_safe_public_url(base_url)
if not ok:
st.error(f"{_t(svc, 'model.base_url_unsafe')}:{reason}")
return ok
except Exception as exc: # noqa: BLE001 - 校验器不可用时保守拒绝自定义端点
logger.warning("base_url 安全校验不可用:%s", exc)
st.error(_t(svc, "model.base_url_unsafe"))
return False
def _test_model_config(cfg: Any) -> bool:
"""对单个用户模型配置做一次轻量真实调用测试(失败返回 False,不抛出)。"""
try:
from services.llm_service import LLMService
from services.model_config import UserModelConfig # noqa: F401
spec = cfg.to_spec()
if spec is None:
return False
svc = LLMService([spec])
res = svc.complete("只回复一个词。", "OK", scope="model-test")
return bool(res.ok)
except Exception as exc: # noqa: BLE001
logger.info("模型连接测试失败:%s", exc)
return False
def render_admin_model_settings(svc: Services) -> None:
"""管理员「全局模型配置」面板:仅管理员可见,设置后供未自配的用户使用(需求 5)。"""
role = st.session_state.get(SESSION_ROLE, DEFAULT_ROLE)
auth = getattr(svc, "auth", None)
is_admin = getattr(auth, "is_admin", None)
if not (callable(is_admin) and is_admin(role)):
return
try:
from layers.llm_providers import get_available_providers
from utils import database as db
except Exception as exc: # noqa: BLE001
logger.info("管理员模型面板不可用:%s", exc)
return
providers = get_available_providers()
ids = [p["id"] for p in providers]
names = {p["id"]: p["name"] for p in providers}
current = db.get_default_llm_config()
with st.sidebar.expander(_t(svc, "admin.model.title"), expanded=False):
# 持久化状态提示 + Space Secrets 引导(HF 免费档)。
try:
from utils import hf_storage
if hf_storage.is_enabled():
st.caption(_t(svc, "admin.storage.synced"))
else:
st.caption(_t(svc, "admin.storage.ephemeral"))
except Exception: # noqa: BLE001
st.caption(_t(svc, "admin.storage.ephemeral"))
st.caption(_t(svc, "admin.model.secrets_hint"))
enabled = st.checkbox(
_t(svc, "admin.model.enable"),
value=bool(current.get("enabled")), key="pk_admin_enable",
)
idx = ids.index(current.get("provider")) if current.get("provider") in ids else 0
pid = st.selectbox(
_t(svc, "model.provider"), options=ids, index=idx,
format_func=lambda i: names.get(i, i), key="pk_admin_provider",
)
model = st.text_input(_t(svc, "model.model_custom"), value=current.get("model", ""), key="pk_admin_model") or ""
api_key = st.text_input(_t(svc, "model.api_key"), type="password", key="pk_admin_api_key") or ""
base_url = ""
if pid == "custom":
base_url = st.text_input(
_t(svc, "model.base_url"), value=current.get("base_url", ""),
help=_t(svc, "model.base_url_hint"), key="pk_admin_base_url",
) or ""
if st.button(_t(svc, "admin.model.save"), key="pk_admin_model_save"):
# 密钥留空时保留原密钥(避免每次都要重输)。
key_to_save = api_key or current.get("api_key", "")
db.set_default_llm_config(
pid, key_to_save, model=model, base_url=base_url, enabled=enabled
)
st.success(_t(svc, "admin.model.saved"))
_rerun()
def render_sidebar(svc: Services, registry: SkillRegistry) -> Optional[PharmaSkill]:
"""渲染侧边栏参数区:登录 → 导航(按角色过滤)→ 当前 Skill 输入面板(需求 15.1/15.5)。
返回当前选中的 Skill(无可见 Skill 时返回 ``None``)。
"""
st.sidebar.title(_t(svc, "app.title"))
render_login(svc)
# 模型设置(用户自配,最高优先级)+ 管理员全局配置(仅管理员可见)。
render_model_settings(svc)
render_admin_model_settings(svc)
role = st.session_state.get(SESSION_ROLE, DEFAULT_ROLE)
skills = visible_skills_for_role(registry, getattr(svc, "auth", None), role)
if not skills:
st.sidebar.info(_t(svc, "msg.no_data"))
return None
i18n = getattr(svc, "i18n", None)
labels = [skill_nav_label(s, i18n) for s in skills]
st.sidebar.markdown(f"**{_t(svc, 'nav.section')}**")
idx = st.sidebar.radio(
_t(svc, "nav.section"),
options=list(range(len(skills))),
format_func=lambda i: labels[i],
key="pk_nav",
label_visibility="collapsed",
)
selected = skills[idx]
# 切换功能时清理上一个技能的残留报告 / 任务单状态,避免旧报告串显到新技能(用户反馈)。
prev_id = st.session_state.get(SESSION_SKILL)
for k in stale_session_keys_on_switch(prev_id, selected.meta.id):
st.session_state.pop(k, None)
st.session_state[SESSION_SKILL] = selected.meta.id
# 当前 Skill 的输入面板(参数区)。
st.sidebar.markdown("---")
st.sidebar.subheader(skill_nav_label(selected, i18n))
return selected
def render_main(svc: Services, skill: PharmaSkill, registry: Optional[SkillRegistry] = None) -> None:
"""中央主画布区:收集输入 → 理解层产出任务单 → 用户确认 → 分析 → 渲染报告。
两阶段流程(intent-understanding-layer):
1. 用户点击主操作按钮 → 理解层 ``build_task_sheet`` 产出《分析任务单》;
2. 展示任务单(含画像 / 意图 / 提取项 / 缺失),用户**每次都需确认**后才执行;
3. 澄清态任务单展示澄清提示与可选功能,不进入计算(需求 6)。
理解层 / 编排器不可用时回退到旧的直接分发路径(向后兼容)。
"""
i18n = getattr(svc, "i18n", None)
lang = getattr(svc, "lang", None)
st.header(skill_nav_label(skill, i18n))
st.caption(skill_description(skill, i18n, lang))
raw = _safe_render_inputs(skill, st.sidebar, svc)
orchestrator = build_orchestrator(registry, svc) if registry is not None else None
# 输入签名确定的技能(通用问答 / 相容性):尊重用户在侧边栏的显式选择,
# 直接走固定流水线(extract→compute→explain),不经理解层意图澄清——
# 修复「问答被『请选择质量属性梳理/货架期外推』澄清拦截」与「相容性多余确认」。
# 理解层 / 编排器不可用时同样回退直接分发(向后兼容)。
if orchestrator is None or not getattr(skill.meta, "two_phase", True):
_render_main_legacy(svc, skill, raw)
return
action_label = skill_action_label(skill, svc)
if st.button(action_label, key=f"pk_run_{skill.meta.id}", type="primary", use_container_width=True):
resolve_session_llm(svc, st.session_state.get(SESSION_USER_MODEL))
with st.spinner(_t(svc, "app.loading")):
try:
sheet = orchestrator.build_task_sheet(raw)
st.session_state[SESSION_TASK_SHEET] = sheet.to_dict()
st.session_state[SESSION_SHEET_STATE] = "pending_confirm"
st.session_state.pop(SESSION_SECTIONS, None)
except Exception as exc: # noqa: BLE001
logger.warning("理解层处理失败:%s", exc, exc_info=True)
st.error(_t(svc, "error.generic"))
state = st.session_state.get(SESSION_SHEET_STATE, "none")
sheet_dict = st.session_state.get(SESSION_TASK_SHEET)
if state == "pending_confirm" and sheet_dict:
if sheet_is_confirmable(sheet_dict):
# 展示文档画像(只读)+ 可编辑提取项 + 可切换功能。
_render_task_sheet(svc, sheet_dict)
edited_rows, chosen_skill = _render_task_sheet_editor(svc, sheet_dict, registry)
edited_slot_rows = _render_intent_slots_editor(svc, sheet_dict)
st.caption(_t(svc, "task.confirm_required"))
cols = st.columns(2)
with cols[0]:
if st.button(_t(svc, "task.btn.confirm"), key="pk_sheet_confirm",
type="primary", use_container_width=True):
final_sheet = apply_sheet_edits(sheet_dict, edited_rows, chosen_skill)
final_sheet = apply_slot_edits(final_sheet, edited_slot_rows)
_audit_sheet_edit(svc, sheet_dict, final_sheet)
_run_confirmed_sheet(svc, orchestrator, final_sheet, raw)
with cols[1]:
if st.button(_t(svc, "task.btn.cancel"), key="pk_sheet_cancel",
use_container_width=True):
st.session_state[SESSION_SHEET_STATE] = "none"
st.session_state.pop(SESSION_TASK_SHEET, None)
_rerun()
else:
_render_task_sheet(svc, sheet_dict)
chosen_intent = _render_clarification(svc, sheet_dict)
if chosen_intent:
# 用户在澄清态选定意图 → 解析为可确认任务单(清除澄清、派生槽位),
# 下一轮进入完整可编辑确认面板(提取项编辑 + 槽位确认)。
st.session_state[SESSION_TASK_SHEET] = _resolve_clarified_sheet(
sheet_dict, chosen_intent, raw
)
st.session_state[SESSION_SHEET_STATE] = "pending_confirm"
_rerun()
return
sections = st.session_state.get(SESSION_SECTIONS)
if sections is not None:
render_report(svc, sections)
else:
st.info(_t(svc, "main.empty_hint"))
def _run_confirmed_sheet(svc: Services, orchestrator, sheet_dict: dict, raw: RawInput) -> None:
"""把用户确认的任务单交编排器执行并渲染(需求 5.4 / 5.5)。"""
from kernel.task_sheet import AnalysisTaskSheet
with st.spinner(_t(svc, "app.loading")):
try:
sheet = AnalysisTaskSheet.from_dict(sheet_dict)
sections = orchestrator.run_confirmed(
sheet, raw, user=st.session_state.get(SESSION_USER, "anonymous")
)
st.session_state[SESSION_SECTIONS] = sections
st.session_state[SESSION_SHEET_STATE] = "confirmed"
except Exception as exc: # noqa: BLE001
logger.warning("确认执行失败:%s", exc, exc_info=True)
st.error(_t(svc, "error.generic"))
_rerun()
def _render_task_sheet(svc: Services, sheet_dict: dict) -> None:
"""展示《分析任务单》:意图 / 文档画像 / 提取项 / 缺失项(需求 5.2)。"""
st.subheader(_t(svc, "task.sheet.title"))
st.caption(_t(svc, "task.sheet.intro"))
intent = sheet_dict.get("intent", "unknown")
st.markdown(f"**{_t(svc, 'task.intent')}**: {_t(svc, f'task.intent.{intent}')}")
profile = sheet_dict.get("document_profile") or {}
tables = profile.get("tables") or []
if tables:
st.markdown(f"**{_t(svc, 'task.profile')}**")
st.dataframe(
[
{
_t(svc, "task.profile.table"): t.get("title", ""),
_t(svc, "task.profile.type"): t.get("table_type", ""),
_t(svc, "task.profile.source"): t.get("source_document", ""),
_t(svc, "task.profile.is_stability"): "✓" if t.get("is_stability_time_series") else "—",
}
for t in tables
],
use_container_width=True,
)
if not profile.get("has_stability_time_series", False):
st.caption(_t(svc, "task.profile.no_stability"))
items = sheet_dict.get("extracted_items") or []
missing = sheet_dict.get("missing_items") or []
if missing:
st.markdown(f"**{_t(svc, 'task.missing')}**: " + "、".join(str(m) for m in missing))
def _render_task_sheet_editor(svc: Services, sheet_dict: dict, registry: Optional[SkillRegistry]):
"""可编辑提取项 + 可切换分析功能(需求 5.3 / 5.5)。
返回 ``(edited_rows, chosen_skill_id)``。无 ``st.data_editor`` 的旧版 Streamlit
回退为只读展示并返回原始行 / 原功能(保证不崩溃)。
"""
st.markdown(f"**{_t(svc, 'task.edit.items')}**")
st.caption(_t(svc, "task.edit.hint"))
rows = items_to_rows(sheet_dict.get("extracted_items") or [])
column_config = None
data_editor = getattr(st, "data_editor", None) or getattr(st, "experimental_data_editor", None)
if callable(data_editor):
edited = data_editor(
rows,
num_rows="dynamic", # 允许增 / 删行
use_container_width=True,
key="pk_sheet_editor",
column_config=column_config,
)
edited_rows = list(edited) if isinstance(edited, list) else rows
else: # 旧版回退:只读
st.dataframe(rows, use_container_width=True)
edited_rows = rows
# 分析功能切换。
chosen_skill = sheet_dict.get("proposed_skill_id", "")
if registry is not None:
ids = [s.meta.id for s in registry.all()]
if ids:
default_idx = ids.index(chosen_skill) if chosen_skill in ids else 0
i18n = getattr(svc, "i18n", None)
def _skill_label(sid: str) -> str:
# 优先 nav.<id>,否则取 Skill 自身本地化名 / display_name(避免暴露原始键)。
has = getattr(i18n, "has", None)
if callable(has) and has(f"nav.{sid}"):
return _t(svc, f"nav.{sid}")
sk = registry.get(sid)
return skill_nav_label(sk, i18n) if sk is not None else sid
chosen_skill = st.selectbox(
_t(svc, "task.edit.skill"),
options=ids,
index=default_idx,
format_func=_skill_label,
key="pk_sheet_skill_pick",
)
return edited_rows, chosen_skill
def _render_intent_slots_editor(svc: Services, sheet_dict: dict) -> list[dict]:
"""渲染意图槽位确认子面板(意图保真,阶段四);返回可回写的槽位行。
展示每个意图维度的当前值、来源态(明示/推断/缺失)与依据,允许用户**修正并锁定**。
用户填值/锁定后经 :func:`apply_slot_edits` 写回为 ``stated+locked``,下游计算据此
采纳用户真实意图、不被模型推断推翻。无槽位时不渲染(返回空列表)。
"""
rows = slots_to_rows(sheet_dict)
if not rows:
return []
st.markdown(f"**{_t(svc, 'task.slots.title')}**")
st.caption(_t(svc, "task.slots.hint"))
data_editor = getattr(st, "data_editor", None) or getattr(st, "experimental_data_editor", None)
# 仅暴露可编辑的「值 / 锁定」列;其余为只读上下文。
display = [
{
"label": r["label"],
"value": r["value"],
"state_label": r["state_label"],
"evidence": r["evidence"],
"locked": r["locked"],
}
for r in rows
]
if callable(data_editor):
edited = data_editor(
display,
use_container_width=True,
key="pk_slot_editor",
disabled=["label", "state_label", "evidence"],
)
edited_list = list(edited) if isinstance(edited, list) else display
else: # 旧版回退:只读
st.dataframe(display, use_container_width=True)
edited_list = display
# 回填 name(展示层用 label,回写需 name)。
out: list[dict] = []
for r, e in zip(rows, edited_list):
out.append({
"name": r["name"],
"value": e.get("value", r["value"]),
"locked": bool(e.get("locked", r["locked"])),
})
return out
def _audit_sheet_edit(svc: Services, original: dict, edited: dict) -> None:
"""记录用户对任务单的编辑(需求 8.2 可追溯)。"""
orig_items = original.get("extracted_items") or []
new_items = edited.get("extracted_items") or []
user_edited = sum(1 for it in new_items if it.get("source") == "user_edited")
audit = getattr(svc, "audit", None)
recorder = getattr(audit, "record_event", None) or getattr(audit, "record_analysis", None)
if callable(recorder):
try:
recorder("task_sheet_edited",
edited.get("proposed_skill_id", ""),
f"orig={len(orig_items)} final={len(new_items)} user_edited={user_edited}")
except Exception: # noqa: BLE001 - 审计失败不影响主流程
logger.debug("记录任务单编辑审计失败(已忽略)。", exc_info=True)
def _render_clarification(svc: Services, sheet_dict: dict):
"""澄清态:提示 + **交互式意图选择** + 确认按钮(需求 6)。
返回用户选定的意图值(如 ``"shelf_life_extrapolation"``)当且仅当用户点击确认按钮;
否则返回 ``None``。本函数本身不进入计算——选定后由调用方解析为可确认任务单。
"""
clar = sheet_dict.get("clarification") or {}
st.warning(_t(svc, "understanding.clarify.title"))
msg_key = clar.get("message_key") or ""
if msg_key:
st.info(_t(svc, msg_key))
options = [o for o in (clar.get("options") or []) if o]
if not options:
return None
st.caption(_t(svc, "understanding.clarify.choose"))
labels = {o: _t(svc, f"task.intent.{o}") for o in options}
radio = getattr(st, "radio", None)
if callable(radio):
choice = radio(
_t(svc, "understanding.clarify.choose"),
options=options,
format_func=lambda o: labels.get(o, o),
key="pk_clarify_choice",
)
else: # 极简回退:选择框
choice = st.selectbox(
_t(svc, "understanding.clarify.choose"), options=options,
format_func=lambda o: labels.get(o, o), key="pk_clarify_choice",
)
if st.button(_t(svc, "task.clarify.apply"), key="pk_clarify_apply",
type="primary", use_container_width=True):
return choice
return None
def _resolve_clarified_sheet(sheet_dict: dict, intent_value: str, raw: RawInput) -> dict:
"""把用户在澄清态选定的意图解析为**可确认任务单**(纯函数,供 UI 与测试)。
- 设定 ``intent`` 与 ``proposed_skill_id``(按意图映射),并**清除澄清态**;
- 依选定意图与现有提取项派生意图槽位(``intent_slots``),使后续确认面板完整呈现
来源标记与待确认事项。意图解析 / 槽位派生失败时安全降级(不抛异常)。
"""
new_sheet = dict(sheet_dict or {})
new_sheet["intent"] = intent_value
new_sheet["proposed_skill_id"] = _INTENT_SKILL_MAP.get(intent_value, "")
new_sheet["clarification"] = None
try:
from kernel.task_sheet import ExtractedItem, Intent
from kernel.understanding import build_intent_slots
intent = Intent(intent_value)
items = [ExtractedItem.from_dict(it) for it in (sheet_dict.get("extracted_items") or [])]
goal = str(getattr(raw, "goal", "") or "")
new_sheet["intent_slots"] = [s.to_dict() for s in build_intent_slots(goal, intent, items)]
except Exception: # noqa: BLE001 - 槽位派生失败不阻断澄清解析
new_sheet.setdefault("intent_slots", [])
return new_sheet
#: 意图值 → Skill id(与 kernel.router.INTENT_TO_SKILL 一致)。
_INTENT_SKILL_MAP = {
"descriptive_summary": "descriptive_summary",
"shelf_life_extrapolation": "stability",
"compatibility": "compatibility",
}
def _render_main_legacy(svc: Services, skill: PharmaSkill, raw: RawInput) -> None:
"""旧的直接分发路径(理解层不可用时回退,保持向后兼容)。"""
action_label = skill_action_label(skill, svc)
if st.button(action_label, key=f"pk_run_legacy_{skill.meta.id}", type="primary", use_container_width=True):
resolve_session_llm(svc, st.session_state.get(SESSION_USER_MODEL))
with st.spinner(_t(svc, "app.loading")):
try:
sections = dispatch(
skill, raw, svc,
user=st.session_state.get(SESSION_USER, "anonymous"),
)
st.session_state[SESSION_SECTIONS] = sections
except Exception as exc: # noqa: BLE001
logger.warning("分析执行失败:%s", exc, exc_info=True)
st.error(_t(svc, "error.generic"))
st.session_state.pop(SESSION_SECTIONS, None)
sections = st.session_state.get(SESSION_SECTIONS)
if sections is not None:
render_report(svc, sections)
else:
st.info(_t(svc, "main.empty_hint"))
def _safe_render_inputs(skill: PharmaSkill, st_ctx, svc: Optional[Services] = None) -> RawInput:
"""调用 Skill 的 ``render_inputs``,异常时降级空输入(不崩溃)。
渲染前把一个**已绑定底座 i18n 与命名空间**的翻译函数注入到 Skill 实例
(``skill._i18n_t``),使各 Skill 的输入面板文案经底座 ``I18nService`` 取用、
随语言切换(需求 3 / 17.4)。注入失败或无 ``svc`` 时,面板按其内置中文兜底,
不影响无 GUI 的纯逻辑单测。
"""
if svc is not None:
skill_id = getattr(skill.meta, "id", "") or ""
def _translate(key: str) -> str:
# 面板传入局部键(如 "input.goal");底座目录以 "<id>." 前缀合并。
return _t(svc, f"{skill_id}.{key}")
try:
setattr(skill, "_i18n_t", _translate)
except Exception: # noqa: BLE001 - 注入失败不阻断渲染
pass
try:
raw = skill.render_inputs(st_ctx)
return raw if isinstance(raw, RawInput) else RawInput()
except Exception as exc: # noqa: BLE001
logger.warning("渲染 Skill %s 输入面板失败:%s", skill.meta.id, exc)
return RawInput()
def render_report(svc: Services, sections: ReportSections) -> None:
"""渲染分析返回的 ``ReportSections``:优先完整 HTML,否则逐段展示,并提供下载。"""
html = getattr(sections, "html", "") or ""
if html:
# 用 iframe 承载完整自包含报告(含样式);跨 Streamlit 版本兼容。
_render_html_component(html, height=760)
cols = st.columns(2)
with cols[0]:
st.download_button(
_t(svc, "btn.download_report"),
data=html,
file_name="report.html",
mime="text/html",
key="pk_download_report",
)
# 专业 Word(.docx) 下载:表格化、分级标题、去 AI/Markdown 噪声。
with cols[1]:
_render_word_download(svc, html)
return
section_map = getattr(sections, "sections", {}) or {}
if not section_map:
st.info(_t(svc, "msg.no_data"))
return
for key, value in section_map.items():
st.subheader(_t(svc, f"report.section.{key}") if key else key)
text = value if isinstance(value, str) else str(value)
if text.strip().startswith("<"):
st.markdown(text, unsafe_allow_html=True)
else:
st.write(text)
def _render_word_download(svc: Services, html: str) -> None:
"""生成并提供专业 Word(.docx) 下载;python-docx 不可用时给出友好提示。"""
try:
report = getattr(svc, "report", None)
to_docx = getattr(report, "to_docx", None)
data = to_docx(html) if callable(to_docx) else None
except Exception: # noqa: BLE001 - 导出失败不影响主流程
data = None
if data:
st.download_button(
_t(svc, "btn.download_word"),
data=data,
file_name="report.docx",
mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
key="pk_download_word",
)
else:
st.caption(_t(svc, "msg.word_unavailable"))
def _render_html_component(html: str, *, height: int = 760) -> None:
"""跨版本兼容地嵌入 HTML:优先 ``st.components.v1.html``,回退 ``st.html``。"""
try:
comp = getattr(getattr(st, "components", None), "v1", None)
if comp is not None and hasattr(comp, "html"):
comp.html(html, height=height, scrolling=True)
return
except Exception: # noqa: BLE001 - 回退到 st.html
pass
html_fn = getattr(st, "html", None)
if callable(html_fn):
html_fn(html)
else:
st.markdown(html, unsafe_allow_html=True)
def render_audit(svc: Services) -> None:
"""可折叠审计追踪区:仅 admin / auditor 可见(需求 15.1 / 15.5)。"""
role = st.session_state.get(SESSION_ROLE, DEFAULT_ROLE)
if not can_view_audit(getattr(svc, "auth", None), role):
return
audit = getattr(svc, "audit", None)
recent = getattr(audit, "recent", None)
with st.expander(_t(svc, "nav.audit"), expanded=False):
if not callable(recent):
st.info(_t(svc, "msg.no_data"))
return
try:
events = recent(50)
except Exception as exc: # noqa: BLE001
logger.warning("查询审计事件失败:%s", exc)
events = []
if not events:
st.info(_t(svc, "msg.no_data"))
return
rows = [
{
"ts": getattr(e, "ts_iso", ""),
"user": getattr(e, "user", ""),
"event": getattr(e, "event_type", ""),
"skill": getattr(e, "skill_id", "") or "",
"step": getattr(e, "step", "") or "",
}
for e in events
]
st.dataframe(rows, use_container_width=True)
def render_build_warnings(svc: Services) -> None:
"""渲染服务构建期降级告警(如某可选重依赖缺失),帮助用户理解功能降级。"""
warnings = getattr(svc, "_build_warnings", None) or []
if not warnings:
return
with st.expander("⚠️ " + _t(svc, "warn.pdf_fallback"), expanded=False):
for name, msg in warnings:
st.caption(f"{name}: {msg}")
def _rerun() -> None:
"""跨 Streamlit 版本兼容的重渲染触发。"""
rerun = getattr(st, "rerun", None) or getattr(st, "experimental_rerun", None)
if callable(rerun):
rerun()
def load_css() -> None:
"""注入 Nordic 极简主题 CSS(``assets/style.css``);缺失时静默跳过。"""
try:
from pathlib import Path
css_path = Path(__file__).resolve().parents[1] / "assets" / "style.css"
if css_path.is_file():
css = css_path.read_text(encoding="utf-8")
st.markdown(f"<style>{css}</style>", unsafe_allow_html=True)
except Exception as exc: # noqa: BLE001 - 样式缺失不应影响功能
logger.info("加载样式表失败(已忽略):%s", exc)
def render_top_bar(svc: Services) -> None:
"""顶部品牌条 + 右侧语言切换:左品牌名/副标题,右语言选择(修复 #0 布局)。"""
left, right = st.columns([5, 1])
with left:
st.markdown(
f"<div style='display:flex;align-items:baseline;gap:.6rem;'>"
f"<span style='font-size:1.5rem;font-weight:800;color:#1f6f78;'>"
f"💊 {_html_escape(_t(svc, 'app.title'))}</span>"
f"<span style='font-size:.85rem;color:#5d6b76;'>"
f"{_html_escape(_t(svc, 'app.subtitle'))}</span></div>",
unsafe_allow_html=True,
)
with right:
i18n = getattr(svc, "i18n", None)
langs = list(getattr(i18n, "available_languages", lambda: ("zh", "en"))())
current = st.session_state.get(SESSION_LANG, svc.lang or DEFAULT_LANG)
choice = st.selectbox(
_t(svc, "label.language"),
options=langs,
index=langs.index(current) if current in langs else 0,
format_func=lambda code: _t(svc, f"lang.{code}"),
key="pk_lang_select",
label_visibility="collapsed",
)
if choice != current:
st.session_state[SESSION_LANG] = choice
set_session_language(svc, choice)
_rerun()
st.markdown(
"<hr style='margin:.4rem 0 1.2rem;border:none;border-top:1px solid #e7ebee;'>",
unsafe_allow_html=True,
)
def _html_escape(s: str) -> str:
import html as _h
return _h.escape(str(s or ""))
def main() -> None:
"""Streamlit 应用主入口:装配上下文并按统一布局渲染。"""
if st is None: # pragma: no cover - 无 streamlit 环境下不渲染
raise RuntimeError(
"Streamlit 未安装,无法渲染界面。请通过 "
"`streamlit run core/app.py` 启动。"
)
# set_page_config 必须是第一个 Streamlit 调用。
st.set_page_config(
page_title="Pharma K",
page_icon="💊",
layout="wide",
initial_sidebar_state="expanded",
)
load_css()
svc, registry = get_or_create_context()
# 顶部品牌条 + 语言切换(需求 17.1 / 17.2;修复 #0 布局)。
render_top_bar(svc)
render_build_warnings(svc)
# 侧边栏参数区(账户 + 导航 + 输入面板,需求 15.1 / 15.5)。
selected = render_sidebar(svc, registry)
# 中央主画布区(结果 / 报告,需求 15.1)。
if selected is not None:
render_main(svc, selected, registry)
else:
st.info(_t(svc, "msg.no_data"))
# 可折叠审计追踪区(按角色,需求 15.1 / 15.5)。
render_audit(svc)
if __name__ == "__main__": # pragma: no cover - 由 `streamlit run` 调用
main()
|