| """理解层 ``UnderstandingLayer``(intent-understanding-layer 任务 2–7)。 |
| |
| 底座**横切能力**:在固定流水线(``extract → compute → explain``)之前运行,使用 |
| 注入式 LLM 完成「文档画像 + 意图分类 + 信息提取 + 回填校验」,产出强结构化、可审计 |
| 的《分析任务单》(:class:`~kernel.task_sheet.AnalysisTaskSheet`)。 |
| |
| 合规取向(与设计 / 需求一致): |
| |
| - **不进行任何分析数值计算**(需求 1.4 / Property 4):理解层只产出「来源可定位的 |
| 提取项 + 分类 + 缺失/歧义 + 澄清决策」,所有分析数值由后续 ``compute``(纯 Python) |
| 产出。 |
| - **无 LLM 即澄清,不退启发式硬算**(需求 6.1 / Property 6):无可用 LLM 时返回澄清 |
| 任务单(``reason_code="no_llm"``),绝不回退到有风险的启发式自动分析。 |
| - **确定性防护优先于 LLM 判断**(需求 2.6 / 4.3 / Property 2、3):即便 LLM 误判, |
| 保留时间 / RRT / 峰面积列也不得当作稳定性时间点;表头 / 标签单元格不得当作测定值; |
| 绝不注入静默默认值(规格 0.5 / 默认 CQA / [24,36]),缺失即标记缺失。 |
| |
| 本模块仅依赖标准库与本仓库 kernel 类型,LLM 通过注入适配器访问,便于无 LLM / 无 UI |
| 的确定性单元测试。 |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import logging |
| import re |
| from typing import Any, Optional, Protocol |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| |
| |
| |
|
|
| class UnderstandingLLM(Protocol): |
| """理解层所需的最小 LLM 能力契约(便于注入 mock 做确定性测试)。""" |
|
|
| def profile_and_extract(self, text: str, goal: str) -> Optional[dict]: |
| """对原文 + 目标做一次结构化理解,返回 dict 或 ``None``(不可用 / 解析失败)。""" |
| ... |
|
|
|
|
| |
| _UNDERSTANDING_SYSTEM = ( |
| "你是药学文档理解助手。你的唯一职责是【理解与提取】,绝不进行任何数值计算或外推。" |
| "请阅读用户上传的文档文本与分析目标,输出严格的 JSON,描述:文档中有哪些表、" |
| "每张表的类型、是否为稳定性时间序列;用户意图(描述性梳理 descriptive_summary / " |
| "货架期外推 shelf_life_extrapolation / 药辅料相容性 compatibility / 不确定 unknown);以及可在原文中逐字定位的" |
| "提取项(每项给出 source_ref 原文片段)。严禁臆造任何不在原文中的数值。" |
| ) |
|
|
| _UNDERSTANDING_USER_TMPL = ( |
| "【文档文本】\n{text}\n\n【分析目标】\n{goal}\n\n" |
| "【提取规则(务必遵守)】\n" |
| "1. 逐属性拆分:每个测定值必须是**独立**的一项,一项只承载**单一**质量属性的" |
| "单一数值。严禁把同一行的多个属性 / 多个数值塞进一个 value(如禁止 " |
| "\"0.5 | 0.05 | 60\")。\n" |
| "2. 每项必须给出 group:包含 strength(规格,如 20μg)、batch(批次,如有)、" |
| "attribute(质量属性名,如 膜厚 / 总杂 / 含量)、table(该项所属表名,如 " |
| "含量均匀度 / 有关物质检测结果,用于区分同名列并支撑专项判定)。\n" |
| "3. 每项必须给出可在原文逐字定位的 source_ref(仅含该项自身的原文片段)。\n" |
| "4. 规格限度单独成项(field 用 spec_limit),并在 group.attribute 标明其约束的属性。\n" |
| "5. 严禁臆造原文中不存在的任何数值。\n\n" |
| "【输出 JSON 结构】\n" |
| "{{\n" |
| ' "tables": [{{"table_type": "...", "title": "...", "source_document": "...", ' |
| '"is_stability_time_series": false, "columns": [], "sample_rows": []}}],\n' |
| ' "intent": "descriptive_summary|shelf_life_extrapolation|compatibility|unknown",\n' |
| ' "items": [\n' |
| ' {{"field": "膜厚", "value": "0.05", "source_ref": "膜厚 0.05", ' |
| '"group": {{"strength": "20μg", "batch": "B1", "attribute": "膜厚", "table": "物理特性检测结果"}}}},\n' |
| ' {{"field": "spec_limit", "value": "总杂≤2.0%", "source_ref": "限度 总杂≤2.0%", ' |
| '"group": {{"attribute": "总杂", "table": "有关物质检测结果"}}}}\n' |
| " ],\n" |
| ' "spec_limits_present": true\n' |
| "}}\n只输出 JSON,不要解释。" |
| ) |
|
|
|
|
| class _LLMServiceAdapter: |
| """把底座 ``svc.llm``(``LLMService.complete``)适配为 :class:`UnderstandingLLM`。""" |
|
|
| def __init__(self, llm: Any) -> None: |
| self._llm = llm |
|
|
| def profile_and_extract(self, text: str, goal: str) -> Optional[dict]: |
| complete = getattr(self._llm, "complete", None) |
| if not callable(complete): |
| return None |
| |
| max_chars = 8000 |
| clipped = text[:max_chars] + ("\n…[截断]" if len(text) > max_chars else "") |
| user = _UNDERSTANDING_USER_TMPL.format(text=clipped, goal=goal or "(未提供)") |
| try: |
| result = complete(_UNDERSTANDING_SYSTEM, user, temperature=0.0, scope="understanding") |
| except Exception as exc: |
| logger.info("理解层 LLM 调用失败:%s", exc) |
| return None |
| if not getattr(result, "ok", False): |
| return None |
| return _safe_parse_json(getattr(result, "content", "") or "") |
|
|
|
|
| def make_understanding_llm(svc: Any) -> Optional[UnderstandingLLM]: |
| """构造理解层 LLM 适配器;无可用 LLM 时返回 ``None``(驱动澄清模式,需求 6.1)。 |
| |
| 判定「可用」的最小条件:``svc.llm`` 存在且具备可调用的 ``complete``。真实可用性 |
| (密钥 / 网络)在调用时体现——失败会被 :meth:`_LLMServiceAdapter.profile_and_extract` |
| 转为 ``None``,上层据此进入澄清。 |
| """ |
| llm = getattr(svc, "llm", None) |
| if llm is None: |
| return None |
| if not callable(getattr(llm, "complete", None)): |
| return None |
| return _LLMServiceAdapter(llm) |
|
|
|
|
| def _safe_parse_json(content: str) -> Optional[dict]: |
| """从 LLM 文本中安全解析 JSON 对象;失败返回 ``None``(不抛异常)。""" |
| if not content: |
| return None |
| |
| m = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", content) |
| candidate = m.group(1) if m else content.strip() |
| if not candidate.startswith("{"): |
| |
| start = candidate.find("{") |
| end = candidate.rfind("}") |
| if start < 0 or end <= start: |
| return None |
| candidate = candidate[start : end + 1] |
| try: |
| parsed = json.loads(candidate) |
| except (ValueError, TypeError): |
| return None |
| return parsed if isinstance(parsed, dict) else None |
|
|
|
|
| |
| |
| |
|
|
| |
| _RETENTION_LEXICON = ( |
| "保留时间", "retention", "rrt", "峰面积", "peak area", "peakarea", |
| "理论塔板", "拖尾因子", "分离度", |
| ) |
|
|
| |
| _HEADER_LABEL_LEXICON = ( |
| "样品名称", "名称", "限度", "结论", "项目", "批号", "编号", |
| "接受标准", "标准", "规格", "时间", "time", |
| ) |
|
|
|
|
| def is_retention_like_column(header: str) -> bool: |
| """判断列名是否属于「保留时间 / RRT / 峰面积」等非稳定性时间语义(需求 2.6)。 |
| |
| 命中则其数值**永不**作为稳定性时间点,即使 LLM 误标也由本防护兜底。 |
| """ |
| h = (header or "").strip().lower() |
| if not h: |
| return False |
| |
| if h in ("min", "保留时间min", "rt", "rt(min)"): |
| return True |
| return any(kw in h for kw in _RETENTION_LEXICON) |
|
|
|
|
| def is_header_or_label_cell(cell: str, known_headers: Optional[list[str]] = None) -> bool: |
| """判断单元格是否为表头 / 标签类文字(不得当作测定值,需求 2.6)。 |
| |
| - 命中通用表头 / 标签词典;或 |
| - 等于本表已知列名之一(``known_headers``)。 |
| """ |
| c = (cell or "").strip() |
| if not c: |
| return False |
| cl = c.lower() |
| if known_headers: |
| norm_headers = {str(h).strip().lower() for h in known_headers} |
| if cl in norm_headers: |
| return True |
| return any(kw == cl or kw in c for kw in _HEADER_LABEL_LEXICON if kw) |
|
|
|
|
| def looks_like_stability_timepoints(values: list, headers: Optional[list[str]] = None) -> bool: |
| """判断一组数值是否像合法的稳定性时间点序列(需求 2.6 防护)。 |
| |
| 合法时间点的确定性最小特征: |
| - 至少 2 个数值; |
| - 非负、且能解析为数值; |
| - 单调非降(稳定性考察时间点递增); |
| - 不全部相等(保留时间常彼此接近且非递增)。 |
| 若列名命中保留时间词典,则**直接判否**(最强防护)。 |
| """ |
| if headers and any(is_retention_like_column(h) for h in headers): |
| return False |
| nums: list[float] = [] |
| for v in values: |
| try: |
| nums.append(float(str(v).strip())) |
| except (TypeError, ValueError): |
| return False |
| if len(nums) < 2: |
| return False |
| if any(n < 0 for n in nums): |
| return False |
| if len(set(nums)) == 1: |
| return False |
| |
| if any(b < a for a, b in zip(nums, nums[1:])): |
| return False |
| return True |
|
|
|
|
| def no_silent_defaults( |
| extracted: dict, |
| missing_items: list[str], |
| ) -> dict: |
| """移除静默默认值:把缺失的规格 / 主 CQA / 目标时间点标记为缺失,绝不填默认。 |
| |
| (需求 4.3 / Property 2)入参 ``extracted`` 为初步提取的字段映射;本函数原地补充 |
| ``missing_items`` 并返回**清洗后的** dict(不含 0.5 / 总杂质 / [24,36] 之类默认)。 |
| """ |
| cleaned = dict(extracted or {}) |
|
|
| |
| spec = cleaned.get("spec_limit") |
| if spec in (None, "", []) or not cleaned.get("spec_limit_source"): |
| cleaned.pop("spec_limit", None) |
| if "规格限度" not in missing_items: |
| missing_items.append("规格限度") |
|
|
| |
| if not cleaned.get("primary_cqa"): |
| cleaned.pop("primary_cqa", None) |
| if "主要质量指标" not in missing_items: |
| missing_items.append("主要质量指标") |
|
|
| |
| tps = cleaned.get("target_timepoints") |
| if not tps: |
| cleaned.pop("target_timepoints", None) |
| if "目标时间点" not in missing_items: |
| missing_items.append("目标时间点") |
|
|
| return cleaned |
|
|
|
|
| __all__ = [ |
| "UnderstandingLLM", |
| "make_understanding_llm", |
| "is_retention_like_column", |
| "is_header_or_label_cell", |
| "looks_like_stability_timepoints", |
| "no_silent_defaults", |
| "DocumentProfiler", |
| "IntentClassifier", |
| "InformationExtractor", |
| "BackReferenceValidator", |
| "UnderstandingLayer", |
| "harvest_observations", |
| "build_intent_slots", |
| "SLOT_PRIMARY_CQA", |
| "SLOT_SPEC_LIMIT", |
| "SLOT_TARGET_TIMEPOINTS", |
| "SLOT_TARGET_ATTRIBUTES", |
| ] |
|
|
|
|
| |
| |
| |
|
|
| from kernel.task_sheet import ( |
| CLARIFY_DATA_INTENT_MISMATCH, |
| CLARIFY_INTENT_UNKNOWN, |
| CLARIFY_NO_LLM, |
| AnalysisTaskSheet, |
| ClarificationDecision, |
| DetectedTable, |
| DocumentProfile, |
| ExtractedItem, |
| Intent, |
| IntentSlot, |
| SlotState, |
| TableType, |
| ) |
|
|
| |
| _TABLE_TYPE_LEXICON: tuple[tuple[TableType, tuple[str, ...]], ...] = ( |
| (TableType.RELATED_SUBSTANCES, ("有关物质", "杂质", "related substance", "impurit")), |
| (TableType.CONTENT_UNIFORMITY, ("含量均匀度", "content uniformity", "uniformity")), |
| (TableType.DISSOLUTION, ("溶出", "dissolution")), |
| (TableType.ASSAY, ("含量", "assay", "potency", "效价")), |
| (TableType.PHYSICAL_PROPERTIES, ("物理特性", "性状", "physical", "外观", "硬度", "脆碎")), |
| ) |
|
|
|
|
| def _canon_table_type(title: str, columns: Optional[list[str]] = None) -> TableType: |
| """按标题 / 列名关键词推断表类型(确定性,不依赖 LLM)。""" |
| hay = (title or "") + " " + " ".join(columns or []) |
| hay = hay.lower() |
| for ttype, kws in _TABLE_TYPE_LEXICON: |
| if any(kw in hay for kw in kws): |
| return ttype |
| return TableType.UNKNOWN |
|
|
|
|
| class DocumentProfiler: |
| """文档画像器:识别表类型、是否稳定性时序,并跨文档聚合(需求 2)。""" |
|
|
| def __init__(self, llm: Optional[UnderstandingLLM] = None) -> None: |
| self._llm = llm |
|
|
| def profile( |
| self, |
| text: str, |
| file_names: Optional[list[str]] = None, |
| *, |
| goal: str = "", |
| ) -> DocumentProfile: |
| """产出 :class:`DocumentProfile`。 |
| |
| 优先使用 LLM 的表清单(若注入且可用),再用确定性防护**强制**校正:保留时间 / |
| 峰面积列绝不标为稳定性时序(需求 2.6 / Property 3)。无 LLM 时退回标题/列名 |
| 关键词的确定性画像(仍受同一防护约束)。 |
| """ |
| file_names = file_names or [] |
| tables: list[DetectedTable] = [] |
|
|
| llm_out = self._llm.profile_and_extract(text, goal) if self._llm else None |
| if llm_out and isinstance(llm_out.get("tables"), list): |
| for raw in llm_out["tables"]: |
| tables.append(self._build_table_from_llm(raw, file_names)) |
| else: |
| tables.extend(self._heuristic_tables(text, file_names)) |
|
|
| |
| for t in tables: |
| if any(is_retention_like_column(c) for c in t.columns): |
| t.is_stability_time_series = False |
|
|
| has_stability = any(t.is_stability_time_series for t in tables) |
| notes: list[str] = [] |
| if not has_stability: |
| notes.append("未检测到稳定性时间序列数据。") |
| return DocumentProfile(tables=tables, has_stability_time_series=has_stability, notes=notes) |
|
|
| def _build_table_from_llm(self, raw: dict, file_names: list[str]) -> DetectedTable: |
| raw = raw or {} |
| title = str(raw.get("title", "")) |
| columns = [str(c) for c in (raw.get("columns") or [])] |
| |
| ttype = TableType.UNKNOWN |
| try: |
| ttype = TableType(str(raw.get("table_type"))) |
| except (ValueError, TypeError): |
| ttype = _canon_table_type(title, columns) |
| if ttype is TableType.UNKNOWN: |
| ttype = _canon_table_type(title, columns) |
| src = str(raw.get("source_document") or (file_names[0] if file_names else "")) |
| is_ts = bool(raw.get("is_stability_time_series", False)) |
| if ttype is TableType.STABILITY_TIME_SERIES: |
| is_ts = True |
| return DetectedTable( |
| table_type=ttype, |
| title=title, |
| source_document=src, |
| is_stability_time_series=is_ts, |
| columns=columns, |
| sample_rows=[[str(c) for c in row] for row in (raw.get("sample_rows") or [])], |
| ) |
|
|
| def _heuristic_tables(self, text: str, file_names: list[str]) -> list[DetectedTable]: |
| """无 LLM 时的确定性画像:按「=== File: name ===」分段 + 标题关键词识别表类型。 |
| |
| 这是一个**保守**的画像——它不试图解析数值(那是 compute 的事),只识别文本中 |
| 出现了哪些类型的表,以支撑意图/澄清判定。 |
| """ |
| tables: list[DetectedTable] = [] |
| segments = _split_by_file(text, file_names) |
| for src, body in segments: |
| for title in _candidate_table_titles(body): |
| ttype = _canon_table_type(title) |
| if ttype is TableType.UNKNOWN: |
| continue |
| tables.append(DetectedTable( |
| table_type=ttype, title=title.strip(), source_document=src, |
| is_stability_time_series=False, |
| )) |
| return tables |
|
|
|
|
| def _split_by_file(text: str, file_names: list[str]) -> list[tuple[str, str]]: |
| """按 ``=== File: name ===`` 分隔符切分文本为 ``[(source, body)]``。""" |
| if not text: |
| return [] |
| parts = re.split(r"=== File:\s*(.*?)\s*===", text) |
| if len(parts) <= 1: |
| default_src = file_names[0] if file_names else "" |
| return [(default_src, text)] |
| out: list[tuple[str, str]] = [] |
| |
| i = 1 |
| while i < len(parts) - 1: |
| out.append((parts[i].strip(), parts[i + 1])) |
| i += 2 |
| return out |
|
|
|
|
| def _candidate_table_titles(body: str) -> list[str]: |
| """启发式抽取可能的表标题行(短行、含已知表类型关键词)。""" |
| titles: list[str] = [] |
| for line in (body or "").split("\n"): |
| s = line.strip() |
| if not s or len(s) > 40: |
| continue |
| if _canon_table_type(s) is not TableType.UNKNOWN: |
| titles.append(s) |
| return titles |
|
|
|
|
| |
| |
| |
|
|
| _DESCRIPTIVE_KW = ("梳理", "总结", "汇总", "规律", "概览", "概述", "整理", "describe", "summary", "summarize", "overview") |
| _SHELF_LIFE_KW = ("货架期", "有效期", "外推", "预测", "shelf life", "shelf-life", "extrapolat", "多少个月", "多少月") |
| _COMPATIBILITY_KW = ( |
| "相容性", "配伍", "相互作用", "相容", "compatibility", "compatible", |
| "辅料筛选", "处方筛选", "excipient compatibility", "drug-excipient", |
| ) |
|
|
|
|
| class IntentClassifier: |
| """意图分类器:LLM 优先 + 确定性关键词兜底(需求 3)。""" |
|
|
| def __init__(self, llm: Optional[UnderstandingLLM] = None) -> None: |
| self._llm = llm |
|
|
| def classify( |
| self, |
| goal: str, |
| profile: DocumentProfile, |
| *, |
| llm_hint: Optional[str] = None, |
| has_smiles: bool = False, |
| has_excipient: bool = False, |
| ) -> Intent: |
| """返回 :class:`Intent`。 |
| |
| - 若提供 ``llm_hint``(来自一次已发生的 LLM 调用),优先采用其可识别值。 |
| - **相容性强信号**:同时提供 SMILES 与辅料(相容性 Skill 的输入签名)→ 直接判定 |
| 为 COMPATIBILITY(这是确定性输入信号,优先于易混的关键词)。 |
| - 否则用目标文本关键词判定;相容性 / 外推 / 描述关键词;都未命中则 UNKNOWN。 |
| """ |
| if llm_hint: |
| try: |
| hinted = Intent(str(llm_hint)) |
| |
| if hinted is not Intent.UNKNOWN: |
| return hinted |
| except (ValueError, TypeError): |
| pass |
| |
| if has_smiles and has_excipient: |
| return Intent.COMPATIBILITY |
| g = (goal or "").lower() |
| has_compat = any(kw.lower() in g for kw in _COMPATIBILITY_KW) |
| has_desc = any(kw in g for kw in _DESCRIPTIVE_KW) |
| has_shelf = any(kw in g for kw in _SHELF_LIFE_KW) |
| |
| if has_compat and not has_shelf: |
| return Intent.COMPATIBILITY |
| if has_shelf and not has_desc: |
| return Intent.SHELF_LIFE |
| if has_desc and not has_shelf: |
| return Intent.DESCRIPTIVE_SUMMARY |
| |
| if has_smiles and not (has_shelf or has_desc): |
| return Intent.COMPATIBILITY |
| |
| return Intent.UNKNOWN |
|
|
|
|
| |
| |
| |
|
|
| |
| _NUM_TOKEN_RE = re.compile(r"-?\d+(?:\.\d+)?") |
| |
| |
| _CENSORED_OBS_RE = re.compile( |
| r"^\s*(?:≤|≥|<=|>=|<=?|>=?|<|>)\s*-?\d+(?:\.\d+)?\s*[%a-zA-Zμ·/℃°]*$" |
| ) |
| _STRENGTH_RE = re.compile(r"\d+\s*(?:μg|ug|mg|g)\b", re.IGNORECASE) |
| _BATCH_RE = re.compile(r"[A-Za-z]{2,}-?\d{4,}[-\w]*") |
|
|
|
|
| def _is_observation_value(s: str) -> bool: |
| """单元格是否为可采集的观测值:纯数值,或删失/不等式型数值(如 >100、<0.05)。""" |
| s = (s or "").strip() |
| if not s: |
| return False |
| return bool(_NUM_TOKEN_RE.fullmatch(s) or _CENSORED_OBS_RE.match(s)) |
|
|
|
|
| |
| |
| |
| _HELPER_COLUMN_LEXICON = ( |
| "a+2.2s", "a+", "样品量", "含量均值", "相对偏差", "稀释倍数", |
| "峰面积", "rsd", "标准偏差", "sd", "理论塔板", "拖尾", |
| ) |
|
|
|
|
| def _is_helper_column(col: str) -> bool: |
| """是否为统计/中间计算列(非真实质量属性)。""" |
| c = (col or "").strip().lower() |
| if not c: |
| return True |
| if c in ("a", "s"): |
| return True |
| return any(k in c for k in _HELPER_COLUMN_LEXICON) |
|
|
|
|
| def _header_attr_count(row: list) -> int: |
| """行中"像属性名"的非数值单元格数量(用于识别表头行)。""" |
| cnt = 0 |
| for c in row: |
| if c is None: |
| continue |
| s = str(c).strip() |
| if not s: |
| continue |
| if _NUM_TOKEN_RE.fullmatch(s): |
| continue |
| cnt += 1 |
| return cnt |
|
|
|
|
| def _row_strength(row: list) -> str: |
| """从一行中提取规格 token(任一单元格命中 数字+μg/mg)。""" |
| for c in row: |
| if c is None: |
| continue |
| m = _STRENGTH_RE.search(str(c)) |
| if m: |
| return m.group(0).replace(" ", "") |
| return "" |
|
|
|
|
| def _row_batch(row: list) -> str: |
| for c in row: |
| if c is None: |
| continue |
| m = _BATCH_RE.search(str(c)) |
| if m: |
| return m.group(0) |
| return "" |
|
|
|
|
| def harvest_observations(profile: "DocumentProfile", tables: Optional[list] = None) -> list[ExtractedItem]: |
| """从**结构化表格网格**确定性采集逐属性观测(修复列错位,需求 4 / 7.2)。 |
| |
| ``tables`` 为 :class:`services.file_service.TableGrid` 列表(保留空单元格=None)。 |
| 对每张表:定位属性表头行 → 其后数据行按**真实列索引**与表头属性配对 → 跳过保留时间 / |
| 统计辅助列。空单元格被保留为 None,列位置不漂移(这是相对"拍平文本"的关键修复)。 |
| |
| 数值直接来自文件,天然满足防捏造。无 ``tables`` 时返回空(上层据此走澄清 / 兜底)。 |
| """ |
| grids = tables or [] |
| items: list[ExtractedItem] = [] |
| observed_attrs: set[str] = set() |
| attrs_with_spec: set[str] = set() |
| for grid in grids: |
| rows = getattr(grid, "rows", None) or (grid.get("rows") if isinstance(grid, dict) else None) or [] |
| title = getattr(grid, "title", "") or (grid.get("title", "") if isinstance(grid, dict) else "") |
| |
| header_idx = -1 |
| for i, row in enumerate(rows): |
| if _header_attr_count(row) >= 2: |
| numeric = sum(1 for c in row if c is not None and _NUM_TOKEN_RE.fullmatch(str(c).strip())) |
| if numeric == 0: |
| header_idx = i |
| break |
| if header_idx < 0: |
| continue |
| header = [(str(c).strip() if c is not None else "") for c in rows[header_idx]] |
|
|
| for row in rows[header_idx + 1:]: |
| if not any(c is not None for c in row): |
| continue |
| label_cell = next((str(c) for c in row if c is not None), "") |
|
|
| |
| if _is_limit_row(label_cell): |
| for idx, cell in enumerate(row): |
| if cell is None or idx >= len(header): |
| continue |
| s = str(cell).strip() |
| if not s or s in ("/", "—", "-", "/"): |
| continue |
| col = header[idx] |
| if not col or is_retention_like_column(col) or _is_helper_column(col): |
| continue |
| if is_header_or_label_cell(col) or not _looks_like_limit_value(s): |
| continue |
| attribute = _clean_header_attribute(col) |
| if not attribute: |
| continue |
| items.append(ExtractedItem( |
| field="spec_limit", |
| value=s, |
| source_ref=f"{label_cell} {col} {s}".strip(), |
| located=True, |
| group={"attribute": attribute, "table": title}, |
| source="llm", |
| )) |
| attrs_with_spec.add(attribute) |
| continue |
|
|
| strength = _row_strength(row) |
| batch = _row_batch(row) |
| |
| if not (strength or batch): |
| continue |
| for idx, cell in enumerate(row): |
| if cell is None or idx >= len(header): |
| continue |
| s = str(cell).strip() |
| if not _is_observation_value(s): |
| continue |
| col = header[idx] |
| if not col or is_retention_like_column(col) or _is_helper_column(col): |
| continue |
| if is_header_or_label_cell(col): |
| continue |
| attribute = _clean_header_attribute(col) |
| if not attribute: |
| continue |
| observed_attrs.add(attribute) |
| items.append(ExtractedItem( |
| field=attribute, |
| value=s, |
| source_ref=f"{label_cell} {col} {s}".strip(), |
| located=True, |
| group={"strength": strength, "batch": batch, |
| "attribute": attribute, "table": title}, |
| source="llm", |
| )) |
|
|
| |
| items.extend(_harvest_prose_limits(grids, observed_attrs, attrs_with_spec)) |
| return items |
|
|
|
|
| def _attr_core(attribute: str) -> str: |
| """属性核心名:去掉尾部 % / 单位 / 空白,用于在散文中做字面匹配。""" |
| s = re.sub(r"[%%\s]+$", "", str(attribute or "")).strip() |
| s = re.sub(r"(mg/g|mg|g|s|mpa·s|mpa|cm|mm)$", "", s, flags=re.IGNORECASE).strip() |
| return s |
|
|
|
|
| def _harvest_prose_limits(grids: list, observed_attrs: set, attrs_with_spec: set) -> list[ExtractedItem]: |
| """从散文式限度文字中**保守**提取规格限度(需求 7.4,零误判取向)。 |
| |
| 规则(严格、宁可放弃也不误关联): |
| - 只在某**已观测到的属性名**于一个子句中**紧邻**比较符 / 区间出现时才关联; |
| - 已有列对齐限度的属性不再覆盖; |
| - 形如"含右美托咪啶应为90~110%"(用 API 名而非属性名"含量")→ 字面不含"含量" → **放弃**; |
| - "L=15.0""忽略限"等无明确属性归属 → **放弃**。 |
| """ |
| out: list[ExtractedItem] = [] |
| if not observed_attrs: |
| return out |
| |
| cores = {a: _attr_core(a) for a in observed_attrs} |
| seen: set[str] = set(attrs_with_spec) |
|
|
| for grid in grids: |
| rows = getattr(grid, "rows", None) or (grid.get("rows") if isinstance(grid, dict) else None) or [] |
| title = getattr(grid, "title", "") or (grid.get("title", "") if isinstance(grid, dict) else "") |
| for row in rows: |
| for cell in row: |
| if cell is None: |
| continue |
| s = str(cell).strip() |
| if not _has_limit_operator(s): |
| continue |
| for clause in re.split(r"[;;。\n]", s): |
| clause = clause.strip() |
| if not clause or not _has_limit_operator(clause): |
| continue |
| for attr, core in cores.items(): |
| if attr in seen or not core: |
| continue |
| if _attr_adjacent_to_limit(core, clause): |
| out.append(ExtractedItem( |
| field="spec_limit", |
| value=clause, |
| source_ref=clause, |
| located=True, |
| group={"attribute": attr, "table": title}, |
| source="llm", |
| )) |
| seen.add(attr) |
| |
| |
| |
| |
| assay_attr = _assay_percent_attr(observed_attrs) |
| if assay_attr and assay_attr not in seen: |
| am = _ASSAY_RANGE_RE.search(clause) |
| if am and float(am.group(2)) >= 80.0: |
| out.append(ExtractedItem( |
| field="spec_limit", |
| value=f"{am.group(1)}~{am.group(2)}%", |
| source_ref=clause, |
| located=True, |
| group={"attribute": assay_attr, "table": title}, |
| source="llm", |
| )) |
| seen.add(assay_attr) |
| return out |
|
|
|
|
| |
| _ASSAY_RANGE_RE = re.compile( |
| r"含[^,。;,;\s]{0,20}?(?:应为|应是|为)\s*(\d+(?:\.\d+)?)\s*[~~\--]\s*(\d+(?:\.\d+)?)\s*%?" |
| ) |
|
|
|
|
| def _assay_percent_attr(observed_attrs) -> str: |
| """在已观测属性中找 assay 相对标示量百分比列(优先「百分含量」,其次含%的「含量」)。""" |
| for a in observed_attrs: |
| if "百分含量" in str(a): |
| return a |
| for a in observed_attrs: |
| if "含量" in str(a) and "%" in str(a): |
| return a |
| return "" |
|
|
|
|
| def _has_limit_operator(s: str) -> bool: |
| return bool(re.search(r"[≤≥<><>]=?|~|~|不大于|不小于|nmt|nlt", str(s or ""), re.IGNORECASE)) |
|
|
|
|
| def _attr_adjacent_to_limit(core: str, clause: str) -> bool: |
| """属性核心名是否在子句中**紧邻**(其后 0–6 字符内)出现比较符/区间+数值。""" |
| if core not in clause: |
| return False |
| pat = re.escape(core) + r".{0,6}?(?:[≤≥<><>]=?|~|~|不大于|不小于|nmt|nlt)\s*\d" |
| return bool(re.search(pat, clause, re.IGNORECASE)) |
|
|
|
|
| def _is_limit_row(label_cell: str) -> bool: |
| """首列是否表明这是一条"限度 / 接受标准"行。""" |
| c = (label_cell or "").strip().lower() |
| return any(k in c for k in ("限度", "接受标准", "标准", "规格", "spec", "acceptance", "limit")) |
|
|
|
|
| def _looks_like_limit_value(s: str) -> bool: |
| """单元格是否是一个**简洁的**限度表述(用于列对齐限度,排除长句散文)。 |
| |
| 仅接受:起始即比较符+数字(≤12.0 / <120s)、区间(2000~7000)、或纯数值。 |
| 长句 / 散文(如"含右美托咪啶应为90~110%""应符合…规定(L=15.0)")一律拒绝, |
| 交由保守的散文匹配器按字面相邻关联(避免列对齐误绑)。 |
| """ |
| s = (s or "").strip() |
| if not s or len(s) > 12: |
| return False |
| if re.match(r"^[≤≥<><>]=?\s*\d", s): |
| return True |
| if re.match(r"^\d+(?:\.\d+)?\s*[~~]\s*\d", s): |
| return True |
| if _NUM_TOKEN_RE.fullmatch(s): |
| return True |
| return False |
|
|
|
|
| def _clean_header_attribute(col: str) -> str: |
| """清洗列名为属性名:去单位括号 / 斜杠单位后缀。""" |
| s = re.sub(r"[//].*$", "", str(col or "")).strip() |
| s = re.sub(r"[\((].*?[\))]", "", s).strip() |
| return s |
|
|
|
|
| class InformationExtractor: |
| """信息提取器:把 LLM 的 items 规整为 :class:`ExtractedItem` 列表(需求 4)。 |
| |
| 本层**不**判定是否可定位(那是 :class:`BackReferenceValidator` 的职责);它只负责 |
| 结构化整形,并保留每项的 ``source_ref`` 原文片段。无 LLM 时返回空列表(上层据此 |
| 判定缺失 / 进入澄清,绝不臆造)。 |
| """ |
|
|
| def __init__(self, llm: Optional[UnderstandingLLM] = None) -> None: |
| self._llm = llm |
|
|
| def extract( |
| self, |
| text: str, |
| profile: DocumentProfile, |
| intent: Intent, |
| *, |
| llm_out: Optional[dict] = None, |
| goal: str = "", |
| tables: Optional[list] = None, |
| ) -> list[ExtractedItem]: |
| out = llm_out |
| if out is None and self._llm is not None: |
| out = self._llm.profile_and_extract(text, goal) |
|
|
| items: list[ExtractedItem] = [] |
| if out and isinstance(out.get("items"), list): |
| for raw in out["items"]: |
| if not isinstance(raw, dict): |
| continue |
| field_name = str(raw.get("field", "")).strip() |
| if not field_name: |
| continue |
| value = str(raw.get("value", "")).strip() |
| |
| if field_name == "value" and is_header_or_label_cell(value): |
| continue |
| items.append(ExtractedItem( |
| field=field_name, |
| value=value, |
| source_ref=str(raw.get("source_ref", "")).strip(), |
| located=True, |
| group=dict(raw.get("group") or {}), |
| )) |
|
|
| |
| |
| |
| |
| |
| if tables: |
| harvested = harvest_observations(profile, tables) |
| if harvested: |
| return harvested |
| |
| return items |
|
|
|
|
| class BackReferenceValidator: |
| """回填校验器:确认提取项的**数值确实存在于原文**,丢弃疑似捏造项(需求 4.1 / 4.2 / Property 1)。 |
| |
| 设计要点(修复「引文过严误杀」):防捏造的本质是「**数值必须来自原文**」,而非「引文 |
| 必须逐字连续」。因此校验**以数值为中心**: |
| 1. 用户手动项(``source=="user_edited"``)豁免(责任在用户)。 |
| 2. 提取项的数值 token 若出现在原文(归一空白后)→ 判定可定位、保留。 |
| 3. 非数值项(如文字型规格 / 结论)退回到「``source_ref`` 子串」判定,保持原有严格度。 |
| 4. 均不满足 → 视为疑似捏造,丢弃并记入 missing。 |
| """ |
|
|
| _NUM_RE = re.compile(r"-?\d+(?:\.\d+)?") |
|
|
| @staticmethod |
| def validate(items: list[ExtractedItem], text: str) -> tuple[list[ExtractedItem], list[str]]: |
| """返回 ``(kept, missing_fields)``。""" |
| haystack = text or "" |
| norm_hay = re.sub(r"\s+", "", haystack) |
| kept: list[ExtractedItem] = [] |
| missing: list[str] = [] |
| for it in items: |
| |
| if getattr(it, "source", "llm") == "user_edited": |
| it.located = True |
| kept.append(it) |
| continue |
|
|
| if BackReferenceValidator._is_grounded(it, haystack, norm_hay): |
| it.located = True |
| kept.append(it) |
| else: |
| if it.field not in missing: |
| missing.append(it.field) |
| return kept, missing |
|
|
| @staticmethod |
| def _is_grounded(it: ExtractedItem, haystack: str, norm_hay: str) -> bool: |
| """判定单项是否「来自原文」:以数值为中心,文字项回退引文子串。""" |
| value = (it.value or "").strip() |
| nums = BackReferenceValidator._NUM_RE.findall(value) |
| if nums: |
| |
| norm_nums_ok = all( |
| BackReferenceValidator._number_in_text(n, haystack) for n in nums |
| ) |
| if norm_nums_ok: |
| return True |
| |
| |
| ref = re.sub(r"\s+", "", (it.source_ref or "").strip()) |
| if ref and ref in norm_hay: |
| return True |
| |
| if not nums: |
| nv = re.sub(r"\s+", "", value) |
| return bool(nv and nv in norm_hay) |
| return False |
|
|
| @staticmethod |
| def _number_in_text(num: str, haystack: str) -> bool: |
| """数值 token 是否出现在原文(允许尾随零差异,如 0.5 ↔ 0.50)。""" |
| if not num: |
| return False |
| if num in haystack: |
| return True |
| |
| try: |
| target = float(num) |
| except ValueError: |
| return False |
| for m in BackReferenceValidator._NUM_RE.findall(haystack): |
| try: |
| if float(m) == target: |
| return True |
| except ValueError: |
| continue |
| return False |
|
|
|
|
| |
| |
| |
|
|
| |
| SLOT_PRIMARY_CQA = "primary_cqa" |
| SLOT_SPEC_LIMIT = "spec_limit" |
| SLOT_TARGET_TIMEPOINTS = "target_timepoints" |
| |
| SLOT_TARGET_ATTRIBUTES = "target_attributes" |
|
|
| |
| _CQA_KEYWORDS = ( |
| "总杂", "单杂", "有关物质", "杂质", "含量", "溶出", "水分", "效价", |
| "assay", "impurit", "dissolution", "potency", "moisture", |
| ) |
|
|
| |
| _MONTHS_RE = re.compile(r"(\d+)\s*个?\s*月") |
|
|
|
|
| def _confidence_for(state: "SlotState") -> float: |
| """来源态 → 默认置信度(STATED 最高、INFERRED 居中、MISSING 为 0)。""" |
| return {SlotState.STATED: 1.0, SlotState.INFERRED: 0.6, SlotState.MISSING: 0.0}[state] |
|
|
|
|
| def build_intent_slots( |
| goal: str, |
| intent: Intent, |
| extracted_items: Optional[list[ExtractedItem]] = None, |
| ) -> list[IntentSlot]: |
| """派生带保真元信息的意图槽位(需求 3 / 5;意图保真核心)。 |
| |
| 原则(确保用户真实意图被采纳): |
| - 用户在 ``goal`` 中**明示** → ``STATED``(置信 1.0,最高优先)。 |
| - 仅能由提取项 / 文档**推断** → ``INFERRED``(置信居中,必须回显来源供改正)。 |
| - 既无明示也无可靠推断(或多候选歧义)→ ``MISSING``(触发选择性澄清,**绝不填默认**)。 |
| |
| 刻意**不产生任何默认值**(不填 0.5 / 总杂质 / [24,36])——缺失即 ``MISSING``, |
| 交由澄清回到用户。当前为货架期外推(``SHELF_LIFE``)与描述性梳理 |
| (``DESCRIPTIVE_SUMMARY``)派生关键槽位;其它意图返回空列表(不强加槽位, |
| 保持向后兼容)。 |
| """ |
| if intent is Intent.DESCRIPTIVE_SUMMARY: |
| return _build_descriptive_slots(goal, extracted_items or []) |
| if intent is not Intent.SHELF_LIFE: |
| return [] |
|
|
| g = goal or "" |
| items = extracted_items or [] |
| slots: list[IntentSlot] = [] |
|
|
| |
| months = _MONTHS_RE.findall(g) |
| if months: |
| values = sorted({int(m) for m in months}) |
| slots.append(IntentSlot( |
| name=SLOT_TARGET_TIMEPOINTS, value=values, state=SlotState.STATED, |
| confidence=_confidence_for(SlotState.STATED), |
| evidence=_first_match_span(_MONTHS_RE, g), affects_result=True, |
| )) |
| else: |
| slots.append(IntentSlot( |
| name=SLOT_TARGET_TIMEPOINTS, value=None, state=SlotState.MISSING, |
| confidence=0.0, affects_result=True, |
| )) |
|
|
| |
| cqa_in_goal = next((k for k in _CQA_KEYWORDS if k.lower() in g.lower()), "") |
| if cqa_in_goal: |
| slots.append(IntentSlot( |
| name=SLOT_PRIMARY_CQA, value=cqa_in_goal, state=SlotState.STATED, |
| confidence=_confidence_for(SlotState.STATED), |
| evidence=cqa_in_goal, affects_result=True, |
| )) |
| else: |
| candidates = _distinct_attributes(items) |
| if len(candidates) == 1: |
| slots.append(IntentSlot( |
| name=SLOT_PRIMARY_CQA, value=candidates[0], state=SlotState.INFERRED, |
| confidence=_confidence_for(SlotState.INFERRED), |
| evidence=f"提取自文档属性:{candidates[0]}", affects_result=True, |
| )) |
| else: |
| |
| slots.append(IntentSlot( |
| name=SLOT_PRIMARY_CQA, value=None, state=SlotState.MISSING, |
| confidence=0.0, affects_result=True, |
| )) |
|
|
| |
| |
| |
| spec_item = next((it for it in items if it.field == "spec_limit" and it.value), None) |
| goal_spec = bool(re.search(r"[≤≥<><>]=?\s*\d|不大于|不小于|nmt|nlt", g, re.IGNORECASE)) |
| if goal_spec: |
| slots.append(IntentSlot( |
| name=SLOT_SPEC_LIMIT, value=None, state=SlotState.STATED, |
| confidence=_confidence_for(SlotState.STATED), |
| evidence="指令中含限度表述", affects_result=False, |
| )) |
| elif spec_item is not None: |
| slots.append(IntentSlot( |
| name=SLOT_SPEC_LIMIT, value=spec_item.value, state=SlotState.INFERRED, |
| confidence=_confidence_for(SlotState.INFERRED), |
| evidence=spec_item.source_ref or spec_item.value, affects_result=False, |
| )) |
| else: |
| slots.append(IntentSlot( |
| name=SLOT_SPEC_LIMIT, value=None, state=SlotState.MISSING, |
| confidence=0.0, affects_result=False, |
| )) |
|
|
| return slots |
|
|
|
|
| def _build_descriptive_slots(goal: str, items: list[ExtractedItem]) -> list[IntentSlot]: |
| """为描述性梳理(``DESCRIPTIVE_SUMMARY``)派生意图槽位(需求 15.1 / 15.3)。 |
| |
| 唯一关键意图维度:``target_attributes``——用户希望梳理哪些质量属性。 |
| - 指令**点名**质量属性(命中 ``_CQA_KEYWORDS``)→ ``STATED``,``value`` 为去重属性名列表。 |
| - 未点名 → ``MISSING``(``value=None``),但 ``affects_result=False``:描述性梳理未指定时 |
| 默认梳理**全部**属性是良性、透明的默认(非篡改意图的静默默认),故不强制澄清,仅在确认 |
| 面板透明呈现供用户可选收窄。**绝不臆造具体属性默认值**。 |
| """ |
| g = goal or "" |
| gl = g.lower() |
| mentioned: list[str] = [] |
| for kw in _CQA_KEYWORDS: |
| if kw.lower() in gl and kw not in mentioned: |
| mentioned.append(kw) |
| if mentioned: |
| return [IntentSlot( |
| name=SLOT_TARGET_ATTRIBUTES, value=mentioned, state=SlotState.STATED, |
| confidence=_confidence_for(SlotState.STATED), |
| evidence="、".join(mentioned), affects_result=False, |
| )] |
| return [IntentSlot( |
| name=SLOT_TARGET_ATTRIBUTES, value=None, state=SlotState.MISSING, |
| confidence=0.0, affects_result=False, |
| )] |
|
|
|
|
| def _distinct_attributes(items: list["ExtractedItem"]) -> list[str]: |
| """从提取项收集去重的质量属性名(排除规格限度项与空值)。""" |
| seen: list[str] = [] |
| for it in items: |
| if it.field == "spec_limit": |
| continue |
| attr = str((it.group or {}).get("attribute") or "").strip() |
| if attr and attr not in seen: |
| seen.append(attr) |
| return seen |
|
|
|
|
| def _first_match_span(pattern: re.Pattern, text: str) -> str: |
| """返回首个匹配的原文片段(供槽位 evidence 溯源),无匹配返回空串。""" |
| m = pattern.search(text or "") |
| return m.group(0) if m else "" |
|
|
|
|
| |
| |
| |
|
|
| |
| _INTENT_TO_SKILL = { |
| Intent.DESCRIPTIVE_SUMMARY: "descriptive_summary", |
| Intent.SHELF_LIFE: "stability", |
| Intent.COMPATIBILITY: "compatibility", |
| } |
|
|
|
|
| class UnderstandingLayer: |
| """理解层编排器:产出《分析任务单》或澄清任务单(需求 1 / 6)。""" |
|
|
| def __init__(self, svc: Any) -> None: |
| self.svc = svc |
|
|
| def understand(self, raw: Any) -> AnalysisTaskSheet: |
| """Phase-A 入口:纯编排、无分析数值计算(需求 1.4 / Property 4)。 |
| |
| 流程:无 LLM → 澄清;汇集文本;文档画像 → 意图分类 → 提取 → 回填校验; |
| 数据/意图不匹配 → 澄清;意图未知 → 澄清;任何异常 → 澄清(宁可多问)。 |
| """ |
| try: |
| llm = make_understanding_llm(self.svc) |
| if llm is None: |
| return self._clarify(CLARIFY_NO_LLM, "understanding.clarify.no_llm") |
|
|
| goal = str(getattr(raw, "goal", "") or "") |
| file_names = [getattr(f, "name", "") or str(f) for f in (getattr(raw, "files", None) or [])] |
| |
| text, grids = self._gather_structured(raw) |
|
|
| |
| llm_out = llm.profile_and_extract(text, goal) |
|
|
| profiler = DocumentProfiler(llm) |
| profile = self._profile_with_cached(profiler, text, file_names, goal, llm_out) |
|
|
| |
| |
| |
| self._augment_stability_from_grids(profile, grids, file_names) |
|
|
| classifier = IntentClassifier(llm) |
| intent = classifier.classify( |
| goal, profile, |
| llm_hint=(llm_out or {}).get("intent") if llm_out else None, |
| has_smiles=bool(str(getattr(raw, "smiles", "") or "").strip()), |
| has_excipient=bool(str(getattr(raw, "excipient", "") or "").strip()), |
| ) |
|
|
| extractor = InformationExtractor(llm) |
| raw_items = extractor.extract( |
| text, profile, intent, llm_out=llm_out, goal=goal, tables=grids |
| ) |
| kept, missing = BackReferenceValidator.validate(raw_items, text) |
|
|
| |
| if intent is Intent.UNKNOWN: |
| sheet = self._clarify(CLARIFY_INTENT_UNKNOWN, "understanding.clarify.intent_unknown") |
| sheet.document_profile = profile |
| sheet.extracted_items = kept |
| sheet.missing_items = missing |
| self._audit("understanding_clarify", CLARIFY_INTENT_UNKNOWN) |
| return sheet |
|
|
| |
| if intent is Intent.SHELF_LIFE and not profile.has_stability_time_series: |
| sheet = self._clarify( |
| CLARIFY_DATA_INTENT_MISMATCH, "understanding.clarify.data_intent_mismatch" |
| ) |
| sheet.intent = intent |
| sheet.document_profile = profile |
| sheet.extracted_items = kept |
| sheet.missing_items = missing |
| self._audit("understanding_clarify", CLARIFY_DATA_INTENT_MISMATCH) |
| return sheet |
|
|
| sheet = AnalysisTaskSheet( |
| intent=intent, |
| document_profile=profile, |
| extracted_items=kept, |
| proposed_skill_id=_INTENT_TO_SKILL.get(intent, ""), |
| missing_items=missing, |
| source_method="llm", |
| intent_slots=build_intent_slots(goal, intent, kept), |
| ) |
| self._audit("understanding_built", intent.value) |
| return sheet |
| except Exception as exc: |
| logger.warning("理解层处理异常,转入澄清:%s", exc, exc_info=True) |
| return self._clarify(CLARIFY_INTENT_UNKNOWN, "understanding.clarify.error") |
|
|
| |
| @staticmethod |
| def _augment_stability_from_grids( |
| profile: DocumentProfile, grids: list, file_names: list[str] |
| ) -> None: |
| """用结构化网格确定性识别转置/宽表稳定性时序,命中则增强 profile(原地)。 |
| |
| 转置宽表(批次为列、时间点为行,如「0天/加速1月/加速6月」)LLM 与启发式都常漏判。 |
| 本方法用 :func:`skills.stability.grid_parser.parse_stability_grids` 做确定性识别; |
| 命中则设 ``has_stability_time_series=True``、补一张稳定性表、清除「未检测到」提示。 |
| 任何异常安全忽略(不改变原 profile)。 |
| """ |
| if profile.has_stability_time_series or not grids: |
| return |
| try: |
| from skills.stability.grid_parser import parse_stability_grids |
|
|
| data = parse_stability_grids(grids) |
| except Exception: |
| return |
| if not data or not data.get("batches"): |
| return |
| src = file_names[0] if file_names else "" |
| cqa = str(data.get("primary_cqa", "") or "稳定性") |
| profile.tables.append(DetectedTable( |
| table_type=TableType.STABILITY_TIME_SERIES, |
| title=cqa, |
| source_document=src, |
| is_stability_time_series=True, |
| columns=[cqa], |
| )) |
| profile.has_stability_time_series = True |
| profile.notes = [n for n in (profile.notes or []) if "未检测到稳定性" not in n] |
|
|
| |
| @staticmethod |
| def _profile_with_cached( |
| profiler: "DocumentProfiler", |
| text: str, |
| file_names: list[str], |
| goal: str, |
| llm_out: Optional[dict], |
| ) -> DocumentProfile: |
| """复用已有的 llm_out 构建画像,避免二次 LLM 调用。""" |
| tables: list[DetectedTable] = [] |
| if llm_out and isinstance(llm_out.get("tables"), list): |
| for raw_t in llm_out["tables"]: |
| tables.append(profiler._build_table_from_llm(raw_t, file_names)) |
| else: |
| tables.extend(profiler._heuristic_tables(text, file_names)) |
| for t in tables: |
| if any(is_retention_like_column(c) for c in t.columns): |
| t.is_stability_time_series = False |
| has_stability = any(t.is_stability_time_series for t in tables) |
| notes: list[str] = [] if has_stability else ["未检测到稳定性时间序列数据。"] |
| return DocumentProfile(tables=tables, has_stability_time_series=has_stability, notes=notes) |
|
|
| def _gather_text(self, raw: Any) -> str: |
| """汇集上传文件文本(经 ``svc.file``)与直接粘贴文本。失败安全降级。""" |
| text, _ = self._gather_structured(raw) |
| return text |
|
|
| def _gather_structured(self, raw: Any): |
| """汇集为 ``(flattened_text, table_grids)``。 |
| |
| 优先用 ``svc.file.parse_structured`` 拿到保留空单元格的表格网格(供确定性采集 |
| 按真实列索引配对);同时拼接拍平文本供 LLM。``parse_structured`` 不可用时退回 |
| ``parse_text``(仅文本、无网格),保证向后兼容与稳健。 |
| """ |
| file_svc = getattr(self.svc, "file", None) |
| parse_structured = getattr(file_svc, "parse_structured", None) |
| parse_text = getattr(file_svc, "parse_text", None) |
|
|
| parts: list[str] = [] |
| grids: list = [] |
| for f in (getattr(raw, "files", None) or []): |
| name = getattr(f, "name", None) or str(f) |
| try: |
| path, cleanup = _materialize_file(f) |
| try: |
| if callable(parse_structured): |
| doc = parse_structured(path) |
| for g in getattr(doc, "tables", None) or []: |
| grids.append(g) |
| content = getattr(doc, "text", "") or "" |
| elif callable(parse_text): |
| content = parse_text(path) or "" |
| else: |
| content = "" |
| finally: |
| cleanup() |
| except Exception as exc: |
| logger.info("理解层结构化解析文件失败(%s):%s", name, exc) |
| content = "" |
| if content: |
| parts.append(f"\n=== File: {name} ===\n{content}\n") |
|
|
| pasted = (getattr(raw, "extra", None) or {}).get("text_content") |
| if pasted: |
| parts.append(str(pasted)) |
| return "".join(parts), grids |
|
|
| def _clarify(self, reason_code: str, message_key: str) -> AnalysisTaskSheet: |
| return AnalysisTaskSheet( |
| intent=Intent.UNKNOWN, |
| clarification=ClarificationDecision( |
| needed=True, |
| reason_code=reason_code, |
| message_key=message_key, |
| options=["descriptive_summary", "shelf_life_extrapolation"], |
| ), |
| source_method="clarification", |
| ) |
|
|
| def _audit(self, event: str, detail: str) -> None: |
| audit = getattr(self.svc, "audit", None) |
| recorder = getattr(audit, "record_event", None) or getattr(audit, "record_analysis", None) |
| if callable(recorder): |
| try: |
| recorder("understanding", event, detail) |
| except Exception: |
| logger.debug("理解层审计记录失败(已忽略)。", exc_info=True) |
|
|
|
|
| def _materialize_file(f): |
| """把上传对象 / 路径解析为可被 FileService 读取的磁盘路径,返回 ``(path, cleanup)``。""" |
| import os |
| import tempfile |
|
|
| def _noop() -> None: |
| return None |
|
|
| if isinstance(f, (str, os.PathLike)): |
| p = str(f) |
| if os.path.exists(p): |
| return p, _noop |
|
|
| name = getattr(f, "name", "") or "upload" |
| suffix = os.path.splitext(name)[1] or "" |
| data = None |
| getvalue = getattr(f, "getvalue", None) |
| if callable(getvalue): |
| data = getvalue() |
| else: |
| read = getattr(f, "read", None) |
| if callable(read): |
| try: |
| f.seek(0) |
| except Exception: |
| pass |
| data = read() |
| if data is None: |
| return str(f), _noop |
| if isinstance(data, str): |
| data = data.encode("utf-8", errors="ignore") |
| fd, tmp_path = tempfile.mkstemp(suffix=suffix) |
| try: |
| with os.fdopen(fd, "wb") as fh: |
| fh.write(data) |
| except Exception: |
| return str(f), _noop |
|
|
| def _cleanup() -> None: |
| try: |
| os.remove(tmp_path) |
| except OSError: |
| pass |
|
|
| return tmp_path, _cleanup |
|
|