| """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 |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| _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) |
|
|
| |
| from kernel.pipeline import Pipeline |
| from kernel.registry import SkillRegistry |
| from kernel.services import Services |
| from kernel.skill_base import PharmaSkill, RawInput, ReportSections |
|
|
| |
| |
| try: |
| import streamlit as st |
| except Exception: |
| st = None |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| |
| |
| |
|
|
| DEFAULT_LANG = "zh" |
|
|
| |
| DEFAULT_ROLE = "researcher" |
|
|
| |
| SESSION_CTX = "pk_app_context" |
| SESSION_LANG = "pk_lang" |
| SESSION_USER = "pk_user" |
| SESSION_ROLE = "pk_role" |
| SESSION_SECTIONS = "pk_sections" |
| SESSION_SKILL = "pk_skill_id" |
| SESSION_USER_MODEL = "pk_user_model" |
| SESSION_TASK_SHEET = "pk_task_sheet" |
| SESSION_SHEET_STATE = "pk_sheet_state" |
|
|
|
|
| |
| |
| |
|
|
| 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: |
| 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), |
| ) |
| |
| setattr(svc, "_build_warnings", warnings) |
| return svc |
|
|
|
|
| |
| |
|
|
| 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 |
|
|
| |
| 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() |
|
|
|
|
| |
| |
| |
|
|
| 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: |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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: |
| 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: |
| 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: |
| 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: |
| 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) |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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: |
| 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: |
| logger.info("用户数据库不可用,跳过管理员初始化:%s", exc) |
| return False |
| try: |
| conn = db.get_db_connection() |
| conn.close() |
| return True |
| except Exception as exc: |
| 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: |
| logger.warning("数据库用户查询不可用:%s", exc) |
| return None |
|
|
|
|
| |
| |
| |
|
|
| 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: |
| 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) |
| |
| 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: |
| logger.warning("解析会话模型配置失败(保留原 LLM):%s", exc) |
| return "env" |
|
|
|
|
| |
| |
| |
|
|
| 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: |
| logger.debug("记录分析执行审计失败(已忽略)。", exc_info=True) |
|
|
|
|
| |
| |
| |
|
|
| def build_orchestrator(registry: SkillRegistry, svc: Services): |
| """构造 ``AnalysisOrchestrator``(理解→确认→分析两阶段编排)。 |
| |
| 放在独立纯函数中便于单测;导入失败(理解层未就绪)时返回 ``None``,调用方据此 |
| 回退到旧的直接分发路径,保证向后兼容。 |
| """ |
| try: |
| from kernel.orchestrator import AnalysisOrchestrator |
|
|
| return AnalysisOrchestrator(registry, svc) |
| except Exception as exc: |
| 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 [] |
| |
| 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: |
| |
| items.append(orig_sigs[sig]) |
| else: |
| |
| 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 |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| 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: |
| 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] |
|
|
| |
| 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: |
| 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: |
| 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: |
| 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: |
| 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: |
| 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: |
| 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: |
| 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: |
| 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", |
| ) |
|
|
| |
| 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 |
| 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: |
| 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 |
|
|
| 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: |
| 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: |
| 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): |
| |
| 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: |
| 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 |
|
|
| |
| 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 |
|
|
| |
| |
| |
| |
| 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: |
| 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: |
| 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: |
| |
| 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 |
|
|
| |
| 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: |
| 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: |
| new_sheet.setdefault("intent_slots", []) |
| return new_sheet |
|
|
|
|
| |
| _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: |
| 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: |
| |
| return _t(svc, f"{skill_id}.{key}") |
|
|
| try: |
| setattr(skill, "_i18n_t", _translate) |
| except Exception: |
| pass |
| try: |
| raw = skill.render_inputs(st_ctx) |
| return raw if isinstance(raw, RawInput) else RawInput() |
| except Exception as exc: |
| 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: |
| |
| _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", |
| ) |
| |
| 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: |
| 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: |
| 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: |
| 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: |
| 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: |
| raise RuntimeError( |
| "Streamlit 未安装,无法渲染界面。请通过 " |
| "`streamlit run core/app.py` 启动。" |
| ) |
|
|
| |
| st.set_page_config( |
| page_title="Pharma K", |
| page_icon="💊", |
| layout="wide", |
| initial_sidebar_state="expanded", |
| ) |
| load_css() |
|
|
| svc, registry = get_or_create_context() |
|
|
| |
| render_top_bar(svc) |
| render_build_warnings(svc) |
|
|
| |
| selected = render_sidebar(svc, registry) |
|
|
| |
| if selected is not None: |
| render_main(svc, selected, registry) |
| else: |
| st.info(_t(svc, "msg.no_data")) |
|
|
| |
| render_audit(svc) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|