url = trim((string)Env::get('promotion.promotion_llm_url', '')); $this->model = trim((string)Env::get('promotion.promotion_llm_model', '')); $this->apiKey = trim((string)Env::get('promotion.promotion_llm_api_key', '')); // 相关性校对固定至少 200s(不跟 promotion_llm_timeout=120);可用 relevance_llm_timeout 单独加大 $timeout = intval(Env::get('promotion.relevance_llm_timeout', 0)); $this->timeout = max(200, $timeout > 0 ? $timeout : 200); // 控制发送给 LLM 的上下文长度,降低单次推理耗时(可通过 env 覆盖) $this->maxSectionChars = max(1500, intval(Env::get('promotion.relevance_llm_max_section_chars', 4500))); $this->maxLocalContextChars = max(600, intval(Env::get('promotion.relevance_llm_max_local_context_chars', 1800))); $this->maxReferChars = max(1500, intval(Env::get('promotion.relevance_llm_max_refer_chars', 3500))); $this->maxAbstractChars = max(1500, intval(Env::get('promotion.relevance_llm_max_abstract_chars', 3500))); $this->maxTokens = max(0, intval(Env::get('promotion.relevance_llm_max_tokens', 0))); } /** * @return array{results:array,claims?:array,combined_relevance_score?:float,combined_reason?:string,request_failed?:bool,reason?:string} */ public function checkRelevance($sectionText, $localContext, $referText, $abstractText = '', $citeGroupRefs = '', array $referTypeMap = []) { $fallback = [ 'results' => [], 'request_failed' => true, 'reason' => 'LLM not configured or request failed', ]; if ($this->url === '' || $this->model === '') { return $fallback; } $sectionText = trim((string)$sectionText); $localContext = trim((string)$localContext); $referText = trim((string)$referText); $abstractText = trim((string)$abstractText); if ($sectionText === '' || $referText === '') { return ['results' => [], 'reason' => 'Empty section or reference text']; } if (mb_strlen($sectionText) > $this->maxSectionChars) { $sectionText = mb_substr($sectionText, 0, $this->maxSectionChars); } if (mb_strlen($localContext) > $this->maxLocalContextChars) { $localContext = mb_substr($localContext, 0, $this->maxLocalContextChars); } if (mb_strlen($referText) > $this->maxReferChars) { $referText = mb_substr($referText, 0, $this->maxReferChars); } if (mb_strlen($abstractText) > $this->maxAbstractChars) { $abstractText = mb_substr($abstractText, 0, $this->maxAbstractChars); } $refCount = $this->countCiteGroupRefs($citeGroupRefs); // 默认每批最多 4 篇,降低单次排队/超时风险(可用 env 覆盖) $maxRefsPerCall = max(2, intval(Env::get('promotion.relevance_llm_max_refs_per_call', 4))); if ($refCount > $maxRefsPerCall) { return $this->checkRelevanceByChunks( $sectionText, $localContext, $referText, $abstractText, $citeGroupRefs, $referTypeMap, $refCount, $maxRefsPerCall ); } return $this->checkRelevanceOnce( $sectionText, $localContext, $referText, $abstractText, $citeGroupRefs, $referTypeMap, $refCount, $fallback ); } /** * @param array{results:array,request_failed?:bool,reason?:string} $fallback * @return array{results:array,claims?:array,combined_relevance_score?:float,combined_reason?:string,request_failed?:bool,reason?:string} */ private function checkRelevanceOnce( $sectionText, $localContext, $referText, $abstractText, $citeGroupRefs, array $referTypeMap, $refCount, array $fallback ) { $payload = [ 'model' => $this->model, 'temperature' => 0, 'max_tokens' => $this->resolveMaxTokens($refCount), 'messages' => [ ['role' => 'system', 'content' => $this->buildSystemPrompt()], ['role' => 'user', 'content' => $this->buildUserPrompt($sectionText, $localContext, $referText, $abstractText, $citeGroupRefs, $refCount, $referTypeMap)], ], ]; $content = $this->postChat($payload); if ($content === null) { $reason = $this->lastPostError !== '' ? $this->lastPostError : 'LLM request failed'; return array_merge($fallback, ['reason' => $reason]); } $parsed = $this->parseJson($content); if ($parsed === null) { $saved = $this->saveBadJsonResponse($content, [ 'cite_group_refs' => $citeGroupRefs, 'section_chars' => mb_strlen($sectionText), 'refer_chars' => mb_strlen($referText), ]); $truncHint = $this->isTruncatedResponse($content) ? ' (response likely truncated)' : ''; $savedHint = $saved !== '' ? '; saved=' . $saved : ''; return array_merge($fallback, ['reason' => 'LLM response JSON parse failed' . $truncHint . $savedHint]); } $normalized = $this->normalizeResults($parsed, $citeGroupRefs, $localContext, $referText, $abstractText); $results = isset($normalized['results']) && is_array($normalized['results']) ? $normalized['results'] : []; $combinedScore = floatval(isset($normalized['combined_relevance_score']) ? $normalized['combined_relevance_score'] : 0); $combinedReason = (string)(isset($normalized['combined_reason']) ? $normalized['combined_reason'] : ''); $claims = isset($normalized['claims']) && is_array($normalized['claims']) ? $normalized['claims'] : []; if (!empty($results) && $refCount > 1 && count($results) < $refCount) { $beforeFill = count($results); $results = $this->fillMissingGroupResults($results, $citeGroupRefs, $refCount, $combinedScore, $combinedReason); if (count($results) > $beforeFill) { \think\Log::warning(sprintf( 'ReferenceRelevanceLlm: filled %d missing results for cite_group_refs=%s', count($results) - $beforeFill, $citeGroupRefs )); } } if (empty($results)) { $rawCount = 0; if (isset($parsed['results']) && is_array($parsed['results'])) { $rawCount = count($parsed['results']); } elseif (isset($parsed['reference_no']) || isset($parsed['relevance_score'])) { $rawCount = 1; } $saved = $this->saveBadJsonResponse($content, [ 'cite_group_refs' => $citeGroupRefs, 'section_chars' => mb_strlen($sectionText), 'refer_chars' => mb_strlen($referText), 'raw_count' => $rawCount, 'kept_count' => 0, ]); $savedHint = $saved !== '' ? '; saved=' . $saved : ''; $detail = $rawCount > 0 ? sprintf(' (parsed %d rows, kept 0%s)', $rawCount, $savedHint) : $savedHint; return array_merge($fallback, ['reason' => 'LLM returned empty or invalid results' . $detail]); } if ($refCount > 1 && count($results) < $refCount) { if ($refCount >= 4) { $chunkSize = max(3, intval(ceil($refCount / 2))); $retry = $this->checkRelevanceByChunks( $sectionText, $localContext, $referText, $abstractText, $citeGroupRefs, $referTypeMap, $refCount, $chunkSize ); if (empty($retry['request_failed']) && count($retry['results']) >= $refCount) { \think\Log::warning(sprintf( 'ReferenceRelevanceLlm: recovered via split retry (%d refs, chunk=%d) cite_group_refs=%s', $refCount, $chunkSize, $citeGroupRefs )); return $retry; } } $saved = $this->saveBadJsonResponse($content, [ 'cite_group_refs' => $citeGroupRefs, 'section_chars' => mb_strlen($sectionText), 'refer_chars' => mb_strlen($referText), 'partial_count' => count($results), 'expected_count' => $refCount, ]); $savedHint = $saved !== '' ? '; saved=' . $saved : ''; return array_merge($fallback, [ 'reason' => sprintf('LLM returned %d/%d results (likely truncated)%s', count($results), $refCount, $savedHint), ]); } if (count($results) > 1) { $bands = $this->getScoreBands(); $adjustedCombined = $this->enforceCombinedAgainstSingles($results, $combinedScore, $bands); if (abs($adjustedCombined - $combinedScore) > 0.001) { $combinedScore = $adjustedCombined; $combinedReason = $this->fallbackReasonFromScore( $combinedScore, $this->levelFromScore($combinedScore) ); \think\Log::warning(sprintf( 'ReferenceRelevanceLlm: combined score clamped to %.2f for cite_group_refs=%s', $combinedScore, $citeGroupRefs )); } } return [ 'results' => $results, 'claims' => $claims, 'combined_relevance_score' => $combinedScore, 'combined_reason' => $combinedReason, ]; } private function buildSystemPrompt() { return <<<'PROMPT' 你是一名护理、医学、生物医学与科研期刊的资深学术编辑,正在执行「参考文献主题相关性校对」。 你的任务:判断【引用位置正文表述】与【对应编号参考文献】在主题、研究对象、疾病/场景/结局方向上是否相关,能否作为该处引用的合理来源。 注意:这是「相关性」校对,侧重引用处具体 claim 与文献内容是否匹配;**不是**判断「是否同一疾病/同一领域」。 ================================================== 【零、最硬规则(违反则输出无效)】 1. **单条 relevance_score 只评价该编号文献单独**与引用处的关系;不得因联合组整体合理而抬高弱相关文献的单条分。 2. **禁止「同病高分」**:正文与文献都涉及 CRC,不等于单条可给 0.85~0.92。 **但若引用处 claim 本身就是机制/通路/异质性/耐药/治疗挑战**,且**研究主语一致**(同一疾病/同一化合物/同一干预对象),文献(含摘要/清洗内容)讨论同病多通路、遗传改变、耐药等,应给 **0.65~0.78**,不得误降到 0.45。 **主语不一致时仍适用本条禁止高分**:引用处主语为化合物 X,文献却是其他植物/提取物/计算预测,即使提到 X 或相同通路名,也不得因此给 0.78+。 3. 引用处若为**流行病学/负担类 claim**(most common、incidence、mortality、burden、全球高发等): - 机制研究、分子通路、细胞增殖/迁移、血管生成等**原始研究** → 单条通常 **0.45 或更低**,`is_relevant=0`,`minimal_relevance` - 不得因摘要提到 colorectal cancer 就给 0.92 - 仅当文献为流行病学综述/公共卫生研究,或明确讨论发病率、死亡率、疾病负担时,单条才可 **0.85~0.92** 4. **联合分写在顶层 combined_relevance_score / combined_reason**,与单条分必须可分离(例如 [1,2] 时文献1=0.45、文献2=0.92、联合=0.92);**禁止在 results 各条中重复 combined_***。 **联合分不得与单条分矛盾**:若全部单条均为 0.25,联合必须为 0.25;若全部单条均 ≤0.45,联合不得 >0.45;联合分不得远高于最高单条分(禁止「单条全弱、联合高分」)。 5. **「来源/化学分类」型句子**(naturally occurring、pentacyclic triterpenoid、found in fruits/vegetables/medicinal plants、并列举具体植物学名): - 先判文献类型:来源综述 / 生物活性综述 最适合;**抗癌治疗综述**对「来源分布」claim 通常仅 **0.65** - 单篇可差异化打分(如 0.92 / 0.92 / 0.65),**不得**因联合而三篇都给高分 - 若原句含**具体列举项**(如多个植物学名),而材料未逐一核实全部学名,联合分通常 **≤0.85**(不得给 0.98) 6. **多要素综括句**(一句同时塞入:药学/研究兴趣 + 大量前临床研究 + 多种活性[抗炎/抗氧化/抗癌等] + 多个癌种/对象列举): - 单篇即使是综述,通常仅 partially_related ~ near-direct(**0.78~0.86**),**不轻易给 0.92**(单篇难逐项覆盖全部要素) - **联合分是整句覆盖度评估,可低于最高单条分**:若整句要素需多篇拼合、且含作者整合概括,联合通常 **0.72~0.78(partially_related)**,不给 0.85+ 7. **联合分不是「取最高单条分」**:当各单篇都只覆盖整句一部分、需互补拼合时,联合分应反映「整句作为一个整体被支撑的完整度」,**允许低于任何一篇单条分**。 8. **主语/研究对象层级必须对齐**:引用处主语为某化合物/分子(如「X has been demonstrated…」)时,文献核心对象须为 **X 本身**或以 X 为核心的实验/综述。 - **植物提取物/混合物**研究、**其他物种/其他植物**的计算预测、成分表中顺带出现 X → 通常 **0.45 或更低** - **关键:提取物即使 X 含量很高(如 50%+)且显示了抗癌/凋亡活性,活性归因于提取物整体而非 X 单体单独验证 → 仍属 weakly_related(≤0.45)、`minimal_relevance`**,不得评为 supplementary_relevance/0.78 - 只有当文献**针对 X 单体单独做了验证**(X monomer 处理、X 单独剂量效应等)时,主语才算对齐,方可进入 0.65+ - **不得**因摘要/讨论出现与引用句相同的通路名、凋亡、抗癌等词就给 0.78+ 9. **证据层级与 demonstrated / mechanistically**: - 本文实验结果或针对 X 的系统综述 > 计算预测/混合成分推测 - **讨论(Discussion)转引他人关于 X 的机制总结 ≠ 该文自身证据**;据此最多 **0.45~0.65**,不得评为 highly_related - in silico / computational prediction 不足以支撑「has been demonstrated to mechanistically…」式强语气 claim 的高分 10. **点名通路/功能结局须逐项核对**:原句逐条列举通路(如 PI3K/AKT、MAPK、NF-κB)或结局(增殖、凋亡、血管生成、炎症信号等)时,**每一项单独核对是否在本文证据中成立**(非仅背景提及)。 - 讨论转述既往文献 ≠ 本文证明该项 - 缺原句任一点名项(如 angiogenesis)→ 单条通常 **不得 0.78+** - **「覆盖部分结局」不足以进入 0.78**:原句点名了多条通路 + 多个结局,文献仅命中其中 1~2 个结局(如仅凋亡/增殖),且**点名通路在本文结果中全部缺失(仅讨论转引)**或主语层级不对 → 单条 **限 0.45(weakly_related / minimal_relevance)**,不得给 0.65~0.78 - 仅同领域沾边 1–2 项、主语或机制层级不对 → **0.45** - **进入 0.65~0.78 的前提**:主语对齐(X 单体)+ 本文自身结果命中原句点名通路/结局的多数项;几乎全部明确对应 → **0.85+** 11. **文献「主题粒度」必须匹配 claim「主题粒度」**:引用处为**疾病总论型 claim**(流行病学负担、标准/多模态治疗现状与局限、基因组异质性、单靶点治疗受限、亟需新策略等总体背景)时: - 最适合的来源是**疾病总体综述 / 分子病理综述 / 精准肿瘤学 / 耐药综述**;此类文献正面、系统地为该总论 claim 提供依据 → 可 **0.85+** - **单一药物 / 单一成分 / 单一通路的专题综述**(如「某化合物抗某癌:A review」),即使同病、同大方向,也只是专题视角、并非为该总论 claim 做系统总结 → 通常 **partially_related(0.72~0.78)**,**不得给 0.85+** - **单基因 / 单通路的机制原始研究**对纯流行病学负担 claim → 仍按规则 3 给 **0.45** - 判断要点:文献类型是否「为该总论 claim 本身做系统综述/总论」;仅同病同方向、或只支撑整段中某一两句(如「需要更安全的新策略」),不足以进入 highly_related 12. **图书/教材参考文献(无 DOI、无摘要)**: - 识别特征:ISBN、版次(3rd ed.)、出版社+年份、无期刊名/无 DOI - **不得因缺少外部摘要就默认 0.25**;须从**书名、副标题、作者专业领域、出版社、版次**判断文献类型 - 若书名/副标题明确为某学科理论/模型/知识体系的**分析、评价、教材、手册**(书名/副标题含"理论/模型/知识/原理/导论/手册/概论"等指示词),且引用处 claim 为该学科理论功能/概念框架/实践指导概述 → 可按**教材/理论专著**匹配,通常 **0.85~0.98** - 若仅有书目信息、无法确认主题粒度,给 **0.65~0.78**,不得轻易 0.25 13. **学科理论/概念「多功能并列」总论句**(一句并列列举某类理论/概念/方法的多项功能,例如"提供概念框架、描述现象、组织专业推理、解释情境、指导设计与实践、为行动提供依据",或"并非历史遗产而是当前仍具价值"等总括表述): - 判分核心 = **文献系统覆盖并列功能的完整度 + 主题层级是否为「整个学科理论/概念体系」**,据此在 highly_related 内做档内区分,不得一律 0.98 也不得因是教材就压到 0.65 - **综合性理论经典教材 / 学科知识体系专著**(对该学科理论做全面分析、评价、体系化阐述),系统覆盖全部并列功能 → **0.98** - 教材/专著但**聚焦子类**(某一子类理论、单一模型、单一学派/作者)或个别并列功能非其重点 → **0.92**(仍 highly_related,因层级/覆盖略窄不给 0.98) - **综述**强调该领域理论当代价值/发展/未来方向,但非系统罗列全部功能 → **0.85** - **哲学 / 元层(元范式、上位框架)/ 学科本体论论文**:概念层级高于「具体功能」,通常覆盖"仍具价值、提供概念资源",但对"描述现象/组织推理/指导设计/提供依据"等具体功能仅部分覆盖 → **0.78(partially_related)**,不给 0.92 - 仅间接沾边、层级或主语明显不符 → **≤0.65** - **联合分**:多篇经典教材已系统覆盖全部并列功能时,联合可 **0.98**,不得因个别子类/哲学文献偏窄而压低整体 14. **分值必须与你写出的 Claim 覆盖自洽(写完 reason 后必须自检;通用规则,适用所有文献类型)**: - 先按你写出的覆盖标注计算「覆盖比例」= (完整✔数 + 0.5×部分数) / Claim总数,再据此定分: - 覆盖比例 ≥0.9 且无✘ → **0.92~0.98**(系统/全部覆盖=0.98) - ≥0.72 → **0.92**;≥0.58 → **0.85**;≥0.45 → **0.78**;≥0.28 → **0.65**;≥0.15 → **0.45**;几乎全✘/无覆盖 → 0.25/0.15 - **0.25/0.15 仅用于「主语不一致」或「几乎完全无覆盖(无任何✔/部分)」**。只要**主语一致**且 reason 写出**任意 ✔ 或部分**,就**禁止**给 0.25,至少 **0.45**。 - **流行病学/患病率数据型 Claim** 引用诊疗指南、治疗进展说明、原始机制研究等**非流行病学文献**时:即使主语一致(同病),通常 **0.45**(同领域但证据类型不匹配),**不是 0.25**。 - **仅 1 个 Claim 且 A✔ + 主语一致 + 类型完全匹配**(如流行病学权威数据明确支撑患病率/发病率 claim)→ **0.92~0.98**,**严禁 0.25** - **以下均不是给低分的正当理由**(属于常见误判,须避免):"偏哲学/元范式""侧重子类/知识发展/发展史""非最全面/非全功能""某功能非全书重点""偏教育应用"——这些至多按覆盖比例降档(如 0.78/0.85),**不得**据此判 0.25。 - 正当低分只来自:主语/研究对象层级不对、证据层级不足(计算预测/讨论转引/提取物非单体)、文献类型完全不适配、或覆盖比例确实 <0.28。 - 若所写覆盖与分值矛盾,**以覆盖比例为准修正分值**,再输出 15. **学科理论发展/关键议题/未来方向型综述**(叙述+文献回顾,讨论某学科理论演变、贡献、挑战、知识结构与实践知识未来): - 摘要若回顾理论贡献、提出知识发展结构/框架、展望基于理论的实践知识 → **A✔ B✔ 通常成立**;与"实践/研究/知识/推理"相关的 Claim 至少 2 项应为 ✔或部分,**不得因非教科书式逐条罗列就全部标 ✘** - 典型分值:**0.85**(强调当代价值与发展方向,覆盖 A/B 及若干实践相关 Claim);系统阐述知识结构与多数功能 → **0.92** - **禁止**将此类综述因"非系统介绍全部功能"判为 0.25 16. **哲学/元范式/学科本体论论文**(讨论 metaparadigm、disciplinary ontology、学科探究基础,而非专业价值观或实践技能): - 当引用处 Claim 涉及**学科知识/理论素养基础/理论传统**时:A(学科知识/disciplinary knowledge)通常 **✔或部分**;B(护理理论)**部分/间接**;C(专业价值观/实践技能类)常 **✘** → 典型 **0.78**,**不是 0.25** - **禁止**因「侧重本体论/元范式/非专业价值」就把 A 标 ✘ 或给 0.25;"哲学/元层"只意味着不给 0.92,应给 **0.65~0.78** 17. **「多事实背景综括句」封顶(通用,适用所有文献类型;防止命中单一主题即给高分)**: 当引用处在一句/一段内并列多个独立事实断言(典型:疾病进展/现状 + 某人群结局 + 与其他人群/疾病的比较 + 具体成因列举 + 流行病学数据),**必须把每个断言拆成独立 Claim**,尤其下列三类不得并入其它 Claim、不得省略: - **比较型 Claim**("A 高于/低于/优于/区别于 B""显著低于其他……"):仅当文献**明确提供该对比数据或对比结论**时才可标 ✔;文献只研究 A 本身、未与 B 比较 → 该项标 **✘**(不得标"部分") - **成因/因果型 Claim**("因……导致""原因包括……"):仅当文献**明确论证该因果或列举相同成因**时才 ✔;文献仅涉及相关变量但未确立该因果 → **部分或✘** - **进展/现状型 Claim**("取得较大进展""诊疗水平提高"等):仅当文献**正面陈述该进展/现状**时才 ✔ - **单篇原始研究**(非系统综述、非流行病学/公共卫生研究)支撑此类多事实综括句:即使主语一致、命中其中 1 项主题,通常也仅 **0.65~0.78**,**不得给 0.85+**(单篇原始研究难以覆盖背景综括句的全部并列断言) - 按硬规则14计算覆盖比例后,**若含 ≥2 项明确 ✘,单条不得 ≥0.85**;含 ≥1 项比较型/成因型 ✘ 时,不得因其余 Claim ✔ 而升入 highly_related - **典型示例(多事实背景句 + 单篇原始研究)**:引用处「HF诊疗取得进展 + 老年HF患者QOL较低 + 低于其他慢病老人 + 因长疗程/并发症/急性加重」,文献为 Lee & Song 2015 SEM 症状管理与QOL研究: - Claim 应拆为:A诊疗进展✘、B HF患者QOL相关✔或部分、C 低于其他慢病比较✘、D 具体成因列举部分或✘ - 覆盖约 35%~45% → **0.65(partially_related)**;**不得**因「都研究HF+QOL」给 0.92,**不得**因有多项✘就给 0.25 ================================================== 【一、必须先拆解 claim】 从【本引用位置附近上下文】中提炼最小主张单元(Claim A, Claim B…),**不要**把整句笼统归为「大概讲同一领域」。 拆解结果写入 JSON 顶层 **`claims` 字段**(键 A/B/C…,值为一行中文具体内容),本引用组只写一次;reason/combined_reason 中**禁止重复 Claim 全文**,仅用字母指代。 例如可拆解维度: - **主语/研究对象**(总论对象 vs 子类专题 vs 上位概念;化合物单体 vs 混合物/提取物;是否「X has been demonstrated」) - **证据语气与层级**(demonstrated / mechanistically vs predict / suggest;本文结果 vs 讨论转引) - **claim 主题粒度**:是否为总论型 claim(流行病学负担 / 治疗现状与局限 / 学科功能概述 / 理论作用概述等);子类专题文献不得因同领域就给满分 - 疾病流行病学(高发、死亡率) - **比较型断言**(A 高于/低于/优于/区别于 B、显著低于其他人群/疾病等,须单独成 Claim) - **成因/因果型断言**("因……导致""原因包括……",须单独成 Claim) - **进展/现状型断言**("取得较大进展""诊疗水平提高"等,须单独成 Claim) - **点名通路/分子机制**(PI3K/AKT、MAPK、NF-κB 等,须逐项) - **点名功能结局**(抑制增殖、凋亡、血管生成、炎症信号等,须逐项) - **概念/理论/方法功能**(定义、分类、机制、推理、实践指导、理论依据等,须逐项) - 治疗/干预现状 - **化合物化学类别**(如 pentacyclic triterpenoid) - **天然来源分布**(fruits / vegetables / medicinal plants) - **具体列举项**(植物学名、药名、基因名等,须逐项核对) ================================================== 【标准校对流程(每篇文献必须按此顺序推理,再写入 reason)】 对 cite_group_refs 中**每一篇**文献,严格按以下**六步**判断,不得跳步: **步骤① 提取 Claim**(写入顶层 `claims`;多事实背景句须拆出比较型/成因型/进展型/数据型独立 Claim) **步骤② 提取文献证据**(从摘要/清洗内容提取研究对象、变量、结论;不得仅凭题名臆测) **步骤③ Claim Mapping**(逐项 ✔ / 部分 / ✘;比较型无对比数据一律 ✘) **步骤④ 计算覆盖率**((✔数 + 0.5×部分数) / Claim总数) **步骤⑤ 判断支持类型**(整句直接支持 / 部分支持 / 仅同领域) **步骤⑥ 输出分值与理由**(分值与覆盖自洽;写明支持项与缺失项) 在 reason 中须体现:文献类型与**步骤①③**主语是否一致;**步骤③ Claim 覆盖**(字母+✔/部分/✘);**步骤③**类型是否匹配;**步骤⑥**分值理由。 **步骤① 主语是否一致(并入 Claim Mapping 前必判)** - 正文 claim 的主语/核心对象是什么? - 文献的核心研究对象是什么?(总论对象 / 子类专题 / 上位概念 / 单一机制 / 单一干预等) - 主语层级不一致时,即使同领域也不得给 0.85+ **步骤② Claim 覆盖** - 逐步核对 Claim A、B、C… 是否覆盖(✔ 完全覆盖 / 部分 / 不覆盖) - 不得因文献「同领域」就默认全部 ✔ - 正文逐条列举的功能、通路、结局、理论作用,须逐项核对 **步骤③ 文献类型是否匹配** - 先判文献类型:教材 / 图书 / 综述 / 原始研究 / 哲学论文 / 专题综述 / 流行病学研究 等 - **图书/教材**:无摘要时据书名、副标题、ISBN、出版社判断,不得因缺摘要直接判 unrelated - **哲学 / 元范式 / 本体论论文**:概念层级高于「具体理论功能」,对"实践功能"类并列 claim 通常仅部分覆盖 → partially_related(见硬规则13),不因同学科给 0.92 - **综述 vs 综合性教材**:面对"多功能并列总论句",系统性综合教材>聚焦子类教材>综述>哲学论文(见硬规则13的档内区分) - 判断该类型是否适合支撑**本引用处具体 claim**(不是仅适合该学科) **步骤④ 分值理由(为何是此分而非更高/更低)** - 说明与上一档或下一档分值的差异原因 - 常见降分情形:主语收窄为子类、概念层级偏高/偏低、专题视角非系统总论、仅覆盖部分 claim、证据层级不足 - 常见升分情形:系统综述/经典文献完整覆盖全部 claim、类型与引用用途完全匹配、本文自身证据直接支撑 **reason 写法(中文,默认 220 字以内;≥5 篇联合时按下方指令压缩)** Claim 的具体内容**只写在 JSON 顶层 `claims` 字段**,reason **禁止重复写出 Claim 全文**。 必须包含:①文献类型与主语是否一致;②**Claim 覆盖标注**(仅用字母+✔/部分/✘,如 "A✔ B✔ C部分 D✘");③类型是否匹配引用用途;④为何是此分而非上/下一档。 **写完后自检(硬规则14):全部/绝大多数 Claim ✔ 且类型匹配 ⇒ 0.92~0.98,绝不可给 ≤0.45;若分值与覆盖矛盾,以覆盖为准修正分值。** 高分示例:「教材,主语一致。Claim覆盖:A✔ B✔ C✔ D✔ E✔。类型完全匹配、系统阐述各项功能。全部覆盖且类型匹配,故 0.98。」 降分示例:「综述,主语一致。Claim覆盖:A✔ B✔ C部分 D✘。类型部分匹配。因缺 D 且 C 仅背景,未达 0.92,故 0.85。」 部分支持示例:「原始研究,主语一致(HF患者)。Claim覆盖:A✘ B✔ C✘ D部分。覆盖率约38%。类型不匹配整句(无诊疗进展/无跨病种比较)。支持QOL主题但不支持比较与进展,故 0.65。」 同领域错类型示例:「指南更新说明,主语一致。Claim覆盖:A✘ B部分。无患病率数据。流行病学claim用指南支撑,故 0.45。」 **combined_reason 写法(联合引用,中文,默认 300 字以内;≥5 篇联合时按下方指令压缩)** Claim 具体内容见顶层 `claims`,combined_reason **只用字母指代**(如 A/B/C),说明各篇分工、整句覆盖完整度、联合分升降依据。 格式示例:「[1][3] 系统覆盖 A/B/C/D,[2] 补充 E,[4] 仅覆盖 A/B。整句各项 Claim 均获多篇互补覆盖、无明显缺口;联合高于任一单篇因分工互补,故联合 X.XX。」 ================================================== 【二、逐篇文献单独判断(每条 result 对应一个 reference_no)】 对 cite_group_refs 中的每一篇文献,单独输出: - 该文献与引用处哪些 claim 主题相关、哪些不相关(含具体列举项是否覆盖) - 文献类型是否匹配引用用途(来源综述 / 生物活性综述 / 机制研究 / 流行病学综述 / 抗癌治疗综述等) - relevance_score:只能使用 0.98 / 0.92 / 0.85 / 0.78 / 0.65 / 0.45 / 0.25 / 0.15 - is_relevant:score>=0.65 为 1,否则 0 - reason:仅中文结论,禁止 reason_en、【English】等英文字段;默认每条约 220 字以内(≥5 篇联合时按下方指令压缩),须体现步骤①主语→②Claim覆盖(仅字母+✔/部分/✘,具体内容见顶层 claims)→③类型匹配→④分值理由;分值须与覆盖自洽(硬规则14) 主语/层级不对 → 单条 **0.45**,不得因讨论提及相同通路给 0.78: 引用处 claim 为「化合物 X 经 PI3K/AKT 等机制 demonstrated…」,文献为其他植物提取物或计算预测、仅在讨论转引他人 X 机制 → 0.45,weakly_related,is_relevant=0。 机制文引用流行病学句 → 单条 **0.45**,不得 0.92: 文献为 CRC 机制研究,引用处 claim 为全球高发/死亡率,文献无流行病学数据 → 0.45,minimal_relevance,is_relevant=0。 ================================================== 【三、联合引用 combined_*(写在 JSON 顶层,只出现一次)】 当 cite_group_refs 为 "1,2" 等多篇时,除逐篇判断外,必须在 JSON **顶层**给出引用组整体结论(不要写入 results 各条): - 这些文献合起来,是否足以支撑/匹配该引用位置的整体表述? - combined_relevance_score:八档固定分值之一,**不是单条平均分** - 若一篇已强相关、其余仅弱补充,联合分可接近主相关文献,但**不必等于最高单条分** - 若原句含具体列举项(学名等)且材料未逐一核实,联合分通常 **0.85**,不给 0.98 - 若核心 claim 无任何文献明确覆盖,联合分不能虚高 - 多篇联合仍缺主语对齐、缺原句点名通路/结局、或主要靠讨论转引 → 联合分通常 **≤0.45~0.65**,不得因单篇讨论出现相同关键词给到 0.78+ - combined_reason:仅中文综合结论(禁止 combined_reason_en、【English】),只写一次;须说明各篇如何分工互补、整句 claim 覆盖完整度、联合分为何高于/低于最高单条分 单条引用时:顶层 combined_* 与单条一致;combined_reason 可与 reason 相同。 **联合引用 ≥5 篇时**:优先保证 results 条数完整;为防截断每条 reason ≤120 字、顶层 combined_reason ≤180 字,但仍须**点名各 Claim 覆盖**(哪几条✔/部分/✘),不可只写"部分相关"。 ================================================== 【四、评分与等级对照】 0.98 / 0.92 / 0.85 = highly_related 文献直接支持整句主旨,大部分关键要素都在文中明确出现 0.78 / 0.65 = partially_related 文献只支撑其中一部分,或支撑方式偏间接 0.45 = weakly_related 只是同领域文献,但与句子事实对应很弱 0.25 / 0.15 = unrelated 基本不支撑该句 ≤0.15 = not_support 不支撑 ================================================== 【五、输出 JSON(仅 JSON,无 markdown)】 先拆解 Claim,在顶层 `claims` 写出各条具体内容(键为 A/B/C…,值为中文一句话);再逐篇输出 results。 { "cite_group_refs": "1,2", "claims": { "A": "该理论/概念仍具当代价值而非仅历史遗产", "B": "提供概念框架或概念资源", "C": "帮助描述研究/护理/临床现象", "D": "组织专业推理或解释情境", "E": "指导实践设计与为行动提供依据" }, "combined_relevance_score": 0.92, "combined_reason": "[1] 系统覆盖 A/B/C,[2] 补充 D/E。整句各项 Claim 由两篇互补覆盖、无明显缺口;联合略高于单篇因分工互补,故联合 0.92。", "results": [ { "reference_no": 1, "is_relevant": 0, "relevance_score": 0.45, "reason": "系统综述,主语一致。Claim覆盖:A✔ B✔ C部分 D✘。类型部分匹配。因缺 D 且 C 仅背景,未达 0.65,故 0.45。", "author_comment": "该文献与正文主张仅部分对应,关键论点仍缺直接证据。请改引更能支撑核心结论的文献,或明确现有引用依据。" }, { "reference_no": 2, "is_relevant": 1, "relevance_score": 0.92, "reason": "综述,主语一致。Claim覆盖:A✔ B✔ D✔ E部分。类型完全匹配。多数关键要素明确覆盖、仅 E 略间接,故 0.92。", "author_comment": "" } ] } **顶层 claims**:本引用组共用,只写一次;键为 A/B/C…(按实际拆解数量),值为各 Claim 中文具体内容。 **results 每条仅含**:reference_no、is_relevant、relevance_score、reason、author_comment。 `author_comment` 规则:当 `relevance_score <= 0.65` 时必须生成(中文、**以期刊编辑审稿口吻**、礼貌可执行,**80–100字**,句尾需句号);当 `relevance_score > 0.65` 时必须返回空字符串 `""`。 注意:`author_comment` 禁止出现 A/B/C/D、✔/✘、"Claim覆盖"、具体分值(如 0.65)等技术表达,需改写为作者易读的编辑批注。 **禁止**在 results 各条中写 combined_relevance_score、combined_reason、cite_group_refs、claims。 PROMPT; } private function buildUserPrompt($sectionText, $localContext, $referText, $abstractText, $citeGroupRefs, $refCount = 0, array $referTypeMap = []) { $parts = ["【正文节 t_article_main】\n" . $sectionText]; if (trim((string)$citeGroupRefs) !== '') { $mode = strpos($citeGroupRefs, ',') !== false ? '联合引用' : '单独引用'; $parts[] = "【引用文献组 cite_group_refs】{$citeGroupRefs}({$mode})"; } if ($localContext !== '') { $parts[] = "【本引用位置附近上下文(优先据此拆解 claim)】\n" . $localContext; } $typeBlock = $this->formatReferTypeBlock($referTypeMap); if ($typeBlock !== '') { $parts[] = $typeBlock; } $parts[] = "【参考文献书目(按编号)】\n" . $referText; if ($abstractText !== '') { $parts[] = "【文献摘要/清洗后内容(Europe PMC·PubMed·Crossref·PDF)】\n" . $abstractText; } $tail = '请严格按六步标准校对流程执行:①提取Claim→②提取文献证据→③Claim Mapping(✔/部分/✘)→④计算覆盖率→⑤判断支持类型→⑥输出分值与理由。先在 JSON 顶层 claims 写出各 Claim 具体内容(多事实背景句须拆出比较型/成因型/进展型/数据型独立 Claim),再对每篇文献给出单条 relevance_score(弱相关文献不得因联合抬高),最后写顶层 combined_relevance_score 与 combined_reason。results 每条只写 reference_no、is_relevant、relevance_score、reason、author_comment。author_comment 规则:score<=0.65 时输出以期刊编辑审稿口吻的中文批注(礼貌、可执行,80–100字,句尾需句号);若出现“建议补充/补充文献/补充说明”等措辞,需改写为“建议替换/改引/明确现有依据”,避免引导新增文献编号;score>0.65 时固定空字符串;author_comment 禁止出现 A/B/C/D、✔/✘、Claim覆盖、具体分值。禁止 reason_en、combined_reason_en、relevance_level、relevance_role、combined_is_relevant 等多余字段。'; if ($this->hasMixedReferTypes($referTypeMap)) { $tail .= ' 注意:本组为图书与期刊混排引用,务必按上方【各编号文献类型标注】分轨判断——图书/教材走「书目推断」轨(据书名、副标题、作者、ISBN、出版社判断主题,缺摘要不得默认 0.25);期刊/原始研究走「摘要核对」轨(据摘要/清洗内容逐项核对 claim)。同组内两类分别按各自标准独立打分,切勿用同一把尺子。'; } elseif ($this->allReferType($referTypeMap, 'book')) { $tail .= ' 注意:本组全部为图书/教材(无 DOI、无外部摘要)。请走「书目推断」轨:据书名、副标题、作者专业领域、ISBN、出版社、版次判断文献类型与主题,缺摘要不得默认 0.25。'; } if ($refCount >= 5) { $tail .= sprintf( ' 本组共 %d 篇联合引用:**最硬要求:必须输出全部 %d 条 results,缺任何一条视为无效**。为防输出截断:每条 reason ≤120 字、顶层 combined_reason ≤180 字,但仍须点名各 Claim 覆盖(哪几条✔/部分/✘),不可只写"部分相关";先保证条数完整。', $refCount, $refCount ); } elseif ($refCount >= 4) { $tail .= sprintf( ' 本组共 %d 篇联合引用:必须输出全部 %d 条 results;每条 reason ≤180 字、顶层 combined_reason ≤260 字,逐条点名各 Claim 覆盖。', $refCount, $refCount ); } elseif ($refCount > 1) { $tail .= sprintf( ' 本组共 %d 篇联合引用:必须输出全部 %d 条 results;每条 reason ≤220 字、顶层 combined_reason ≤300 字,逐条点名各 Claim 覆盖与升降分依据。', $refCount, $refCount ); } else { $tail .= ' 单条引用:reason ≤220 字,点名各 Claim 覆盖与分值依据;顶层 combined_* 与单条一致。'; } $parts[] = $tail; return implode("\n\n", $parts); } /** * 渲染各编号文献类型标注块,让 LLM 明确知道哪条是图书、哪条是期刊。 */ private function formatReferTypeBlock(array $referTypeMap) { if (empty($referTypeMap)) { return ''; } ksort($referTypeMap, SORT_NUMERIC); $labels = [ 'book' => '图书/教材(书目推断轨:据书名/副标题/作者/ISBN/出版社判断,缺摘要不得默认 0.25)', 'journal' => '期刊/原始研究(摘要核对轨:据摘要/清洗内容逐项核对 claim)', 'other' => '其他/未知(尽力据书目信息判断)', ]; $lines = []; foreach ($referTypeMap as $refNo => $info) { $type = is_array($info) ? (string)($info['type'] ?? 'other') : (string)$info; $label = isset($labels[$type]) ? $labels[$type] : $labels['other']; $lines[] = '文献 ' . intval($refNo) . ':' . $label; } if (empty($lines)) { return ''; } return "【各编号文献类型标注(权威类型,按此分轨校对)】\n" . implode("\n", $lines); } private function referTypeList(array $referTypeMap) { $types = []; foreach ($referTypeMap as $info) { $type = is_array($info) ? (string)($info['type'] ?? 'other') : (string)$info; $types[$type] = true; } return array_keys($types); } private function hasMixedReferTypes(array $referTypeMap) { $types = $this->referTypeList($referTypeMap); return in_array('book', $types, true) && (in_array('journal', $types, true) || in_array('other', $types, true)); } private function allReferType(array $referTypeMap, $target) { if (empty($referTypeMap)) { return false; } $types = $this->referTypeList($referTypeMap); return count($types) === 1 && $types[0] === $target; } private function countCiteGroupRefs($citeGroupRefs) { $citeGroupRefs = trim((string)$citeGroupRefs); if ($citeGroupRefs === '') { return 0; } $parts = preg_split('/\s*,\s*/', $citeGroupRefs, -1, PREG_SPLIT_NO_EMPTY); return count($parts); } private function resolveMaxTokens($refCount) { $refCount = max(1, intval($refCount)); // reason 已压缩;过高 max_tokens 会拖慢本地推理排队,按条数给够即可 $dynamic = min(8192, max(3072, $refCount * 900 + 1200)); if ($refCount >= 4) { $dynamic = max($dynamic, 5120); } if ($this->maxTokens > 0) { return $this->maxTokens; } return $dynamic; } /** * 响应截断导致缺条时,用同组已有 combined_* 与中位单条分补全(非一致分时最多补 2 条;一致分时可补全)。 */ private function fillMissingGroupResults(array $out, $citeGroupRefs, $refCount, $combinedScore = 0, $combinedReason = '') { $expected = $this->parseCiteGroupRefNumbers($citeGroupRefs); if (empty($expected)) { return $out; } $have = []; foreach ($out as $row) { $refNo = intval(isset($row['reference_no']) ? $row['reference_no'] : 0); if ($refNo > 0) { $have[$refNo] = true; } } $missing = []; foreach ($expected as $refNo) { if (empty($have[$refNo])) { $missing[] = $refNo; } } if (empty($missing) || empty($out)) { return $out; } $uniformScore = $this->resultsHaveUniformScore($out); if (!$uniformScore && count($missing) > 2) { return $out; } $combinedScore = floatval($combinedScore); $combinedReason = trim((string)$combinedReason); $medianScore = $this->medianRelevanceScore($out); foreach ($missing as $refNo) { $fillReason = '模型输出被截断未返回该文献单条结论,已按同组中位分暂填。'; $out[] = [ 'reference_no' => intval($refNo), 'is_relevant' => $medianScore >= 0.65 - 0.001 ? 1 : 0, 'relevance_score' => $medianScore, 'reason' => $fillReason, 'author_comment' => $this->normalizeAuthorComment('', $medianScore, $fillReason), ]; } if ($combinedScore <= 0) { $combinedScore = $medianScore; } if ($combinedReason === '') { $combinedReason = '模型输出被截断,联合结论沿用同组已返回结果。'; } return $out; } /** * 大引用组拆批调用 LLM,合并逐篇结果后再计算联合分。 * * @return array{results:array,claims?:array,combined_relevance_score?:float,combined_reason?:string,request_failed?:bool,reason?:string} */ private function checkRelevanceByChunks( $sectionText, $localContext, $referText, $abstractText, $citeGroupRefs, array $referTypeMap, $refCount, $chunkSize ) { $fallback = [ 'results' => [], 'request_failed' => true, 'reason' => 'LLM split batch failed', ]; $refNums = $this->parseCiteGroupRefNumbers($citeGroupRefs); if (empty($refNums)) { return array_merge($fallback, ['reason' => 'Empty cite_group_refs']); } $chunkSize = max(2, intval($chunkSize)); $chunks = array_chunk($refNums, $chunkSize); $allResults = []; $claims = []; $combinedScore = 0.0; $combinedReason = ''; foreach ($chunks as $chunk) { $chunkRefs = implode(',', $chunk); $chunkRefer = $this->filterRefBlocks($referText, $chunk); if ($chunkRefer === '') { $chunkRefer = $referText; } $chunkAbstract = $this->filterRefBlocks($abstractText, $chunk); $chunkTypeMap = []; foreach ($chunk as $refNo) { if (isset($referTypeMap[$refNo])) { $chunkTypeMap[$refNo] = $referTypeMap[$refNo]; } } $part = $this->checkRelevanceOnce( $sectionText, $localContext, $chunkRefer, $chunkAbstract, $chunkRefs, $chunkTypeMap, count($chunk), $fallback ); if (!empty($part['request_failed']) || empty($part['results'])) { $reason = isset($part['reason']) ? (string)$part['reason'] : 'LLM split batch failed'; return array_merge($fallback, ['reason' => $reason]); } if (empty($claims) && !empty($part['claims']) && is_array($part['claims'])) { $claims = $part['claims']; } if ($combinedScore <= 0 && isset($part['combined_relevance_score'])) { $combinedScore = floatval($part['combined_relevance_score']); $combinedReason = (string)(isset($part['combined_reason']) ? $part['combined_reason'] : ''); } foreach ($part['results'] as $row) { $refNo = intval(isset($row['reference_no']) ? $row['reference_no'] : 0); if ($refNo > 0 && !isset($allResults[$refNo])) { $allResults[$refNo] = $row; } } } ksort($allResults, SORT_NUMERIC); $results = array_values($allResults); if (count($results) < count($refNums)) { return array_merge($fallback, [ 'reason' => sprintf( 'LLM split batch incomplete: got %d/%d for cite_group_refs=%s', count($results), count($refNums), $citeGroupRefs ), ]); } if (count($results) > 1) { $bands = $this->getScoreBands(); $adjustedCombined = $this->enforceCombinedAgainstSingles($results, $combinedScore, $bands); if ($combinedScore <= 0 || abs($adjustedCombined - $combinedScore) > 0.001) { $combinedScore = $adjustedCombined; $combinedReason = $this->fallbackReasonFromScore( $combinedScore, $this->levelFromScore($combinedScore) ); } } elseif (count($results) === 1) { $combinedScore = floatval($results[0]['relevance_score'] ?? 0); $combinedReason = (string)($results[0]['reason'] ?? ''); } \think\Log::info(sprintf( 'ReferenceRelevanceLlm: split %d refs into %d batches (chunk=%d) cite_group_refs=%s', $refCount, count($chunks), $chunkSize, $citeGroupRefs )); return [ 'results' => $results, 'claims' => $claims, 'combined_relevance_score' => $combinedScore, 'combined_reason' => $combinedReason, ]; } /** * 按编号过滤「【参考文献 N】」块。 */ private function filterRefBlocks($text, array $refNos) { $text = trim((string)$text); if ($text === '' || empty($refNos)) { return ''; } $want = []; foreach ($refNos as $refNo) { $want[intval($refNo)] = true; } $blocks = preg_split('/\n(?=【参考文献 \d+】)/u', $text); $out = []; $preamble = ''; foreach ($blocks as $block) { $block = trim($block); if ($block === '') { continue; } if (preg_match('/^【参考文献 (\d+)】/u', $block, $m)) { $refNo = intval($m[1]); if (!empty($want[$refNo])) { $out[] = $block; } } elseif ($preamble === '') { $preamble = $block; } } if ($preamble !== '' && !empty($out)) { array_unshift($out, $preamble); } return implode("\n\n", $out); } private function resultsHaveUniformScore(array $rows) { $scores = []; foreach ($rows as $row) { if (!isset($row['relevance_score'])) { return false; } $scores[] = round(floatval($row['relevance_score']), 2); } if (count($scores) <= 1) { return true; } return count(array_unique($scores)) === 1; } private function parseCiteGroupRefNumbers($citeGroupRefs) { $citeGroupRefs = trim((string)$citeGroupRefs); if ($citeGroupRefs === '') { return []; } $parts = preg_split('/\s*,\s*/', $citeGroupRefs, -1, PREG_SPLIT_NO_EMPTY); $nums = []; foreach ($parts as $part) { $refNo = intval($part); if ($refNo > 0) { $nums[] = $refNo; } } return array_values(array_unique($nums)); } private function medianRelevanceScore(array $rows) { $bands = $this->getScoreBands(); $scores = []; foreach ($rows as $row) { if (!isset($row['relevance_score'])) { continue; } $scores[] = $this->snapScore(floatval($row['relevance_score']), $bands); } if (empty($scores)) { return 0.45; } sort($scores, SORT_NUMERIC); $mid = intdiv(count($scores), 2); if (count($scores) % 2 === 1) { return $scores[$mid]; } return $this->snapScore(($scores[$mid - 1] + $scores[$mid]) / 2, $bands); } private function normalizeResults(array $parsed, $defaultCiteGroupRefs, $localContext = '', $referText = '', $abstractText = '') { $rows = []; if (isset($parsed['results']) && is_array($parsed['results'])) { $rows = $parsed['results']; } elseif (isset($parsed['reference_no']) || isset($parsed['relevance_score'])) { $rows = [$parsed]; } $bands = $this->getScoreBands(); $citeGroupRefs = trim((string)(isset($parsed['cite_group_refs']) ? $parsed['cite_group_refs'] : $defaultCiteGroupRefs)); if ($citeGroupRefs === '' && $defaultCiteGroupRefs !== '') { $citeGroupRefs = trim((string)$defaultCiteGroupRefs); } $out = []; foreach ($rows as $item) { if (!is_array($item)) { continue; } $refNo = $this->resolveReferenceNo($item); if ($refNo <= 0) { continue; } $score = $this->snapScore(floatval(isset($item['relevance_score']) ? $item['relevance_score'] : 0), $bands); $isRelevant = $score >= 0.65 - 0.001; if (array_key_exists('is_relevant', $item)) { $isRelevant = $this->boolVal($item['is_relevant']); } $reason = $this->normalizeChineseReason( isset($item['reason']) ? $item['reason'] : '', isset($item['reason_en']) ? $item['reason_en'] : '' ); $level = $this->levelFromScore($score, isset($item['relevance_level']) ? $item['relevance_level'] : ''); $role = $this->normalizeRelevanceRole(isset($item['relevance_role']) ? $item['relevance_role'] : ''); list($score, $level, $isRelevant, $role) = $this->enforceSingleReferenceConsistency($score, $level, $isRelevant, $role, $bands); if ($reason === '') { $reason = $this->fallbackReasonFromScore($score, $level); } else { $refLit = $this->extractRefLiteratureFromCombined($abstractText, $refNo); $adjusted = $this->reconcileScoreAgainstCoverage($score, $reason, $refLit); $capped = $this->reconcileScoreCeiling($adjusted, $reason); if ($capped > $score + 0.001 || $capped < $score - 0.001) { if ($capped > $score + 0.001) { \think\Log::warning(sprintf( 'ReferenceRelevanceLlm: raised ref#%d score %.2f->%.2f (coverage reconcile)', $refNo, $score, $capped )); } $score = $capped; $isRelevant = $score >= 0.65 - 0.001; } $reason = $this->reconcileReasonScore($reason, $score); } $authorComment = $this->normalizeAuthorComment( isset($item['author_comment']) ? $item['author_comment'] : '', $score, $reason ); $out[] = [ 'reference_no' => $refNo, 'is_relevant' => $isRelevant ? 1 : 0, 'relevance_score' => $score, 'reason' => $reason, 'author_comment' => $authorComment, ]; } $groupCombined = $this->resolveGroupCombinedFields($parsed, $rows, $out, $citeGroupRefs, $bands); $claims = $this->normalizeClaims(isset($parsed['claims']) ? $parsed['claims'] : []); return [ 'results' => $out, 'claims' => $claims, 'combined_relevance_score' => floatval($groupCombined['combined_relevance_score']), 'combined_reason' => (string)$groupCombined['combined_reason'], ]; } /** * 归一化顶层 claims:键 A/B/C…,值为各 Claim 中文具体内容。 */ private function normalizeClaims($raw) { if (!is_array($raw)) { return []; } $out = []; foreach ($raw as $key => $val) { if (is_array($val)) { $k = strtoupper(trim((string)($val['id'] ?? $val['key'] ?? $key))); $text = trim((string)($val['content'] ?? $val['text'] ?? '')); } else { $k = strtoupper(trim((string)$key)); $text = trim((string)$val); } if (!preg_match('/^[A-Z]$/', $k) || $text === '') { continue; } $out[$k] = mb_substr($text, 0, 500); } ksort($out, SORT_STRING); return $out; } /** * 从 JSON 顶层读取联合结论;兼容旧版 results 内嵌 combined_*。 */ private function resolveGroupCombinedFields(array $parsed, array $rawRows, array $outRows, $citeGroupRefs, array $bands) { $combinedScore = 0.0; if (array_key_exists('combined_relevance_score', $parsed)) { $combinedScore = floatval($parsed['combined_relevance_score']); } $combinedReason = $this->normalizeChineseReason( isset($parsed['combined_reason']) ? $parsed['combined_reason'] : '', isset($parsed['combined_reason_en']) ? $parsed['combined_reason_en'] : '' ); if ($combinedScore <= 0 || $combinedReason === '') { foreach ($rawRows as $item) { if (!is_array($item)) { continue; } if ($combinedScore <= 0 && array_key_exists('combined_relevance_score', $item)) { $combinedScore = floatval($item['combined_relevance_score']); } if ($combinedReason === '') { $combinedReason = $this->normalizeChineseReason( isset($item['combined_reason']) ? $item['combined_reason'] : '', isset($item['combined_reason_en']) ? $item['combined_reason_en'] : '' ); } if ($combinedScore > 0 && $combinedReason !== '') { break; } } } if (count($outRows) === 1) { if ($combinedScore <= 0) { $combinedScore = floatval($outRows[0]['relevance_score']); } if ($combinedReason === '') { $combinedReason = (string)$outRows[0]['reason']; } } elseif ($combinedScore <= 0 && !empty($outRows)) { $scores = []; foreach ($outRows as $row) { $scores[] = floatval($row['relevance_score']); } rsort($scores, SORT_NUMERIC); $combinedScore = floatval($scores[0]); } if ($combinedReason === '' && $combinedScore > 0) { $combinedReason = $this->fallbackReasonFromScore($combinedScore, $this->levelFromScore($combinedScore)); } $combinedScore = $this->enforceCombinedAgainstSingles($outRows, $combinedScore, $bands); if ($combinedScore <= 0.45 && $combinedReason !== '') { $maxSingle = $this->maxSingleRelevanceScore($outRows); if ($maxSingle <= 0.45) { $combinedReason = $this->fallbackReasonFromScore($combinedScore, $this->levelFromScore($combinedScore)); } } list($combinedScore,) = $this->enforceCombinedConsistency($combinedScore, '', $bands); $combinedReason = $this->reconcileReasonScore($this->cleanReason($combinedReason), $combinedScore); return [ 'combined_relevance_score' => $combinedScore, 'combined_reason' => $combinedReason, ]; } /** * 联合分不得远高于单条分;全部单条弱相关时联合不得高分。 */ private function enforceCombinedAgainstSingles(array $outRows, $combinedScore, array $bands) { $combinedScore = floatval($combinedScore); if (empty($outRows)) { return $this->snapScore($combinedScore, $bands); } $scores = []; foreach ($outRows as $row) { $scores[] = floatval(isset($row['relevance_score']) ? $row['relevance_score'] : 0); } if (empty($scores)) { return $this->snapScore($combinedScore, $bands); } $maxSingle = max($scores); if ($maxSingle <= 0.25 + 0.001) { return $this->snapScore(min($combinedScore, 0.25), $bands); } if ($maxSingle <= 0.45 + 0.001) { return $this->snapScore(min($combinedScore, 0.45), $bands); } $ceiling = $maxSingle; if ($maxSingle >= 0.92) { $ceiling = 0.98; } elseif ($maxSingle >= 0.85) { $ceiling = 0.92; } elseif ($maxSingle >= 0.78) { $ceiling = 0.92; } elseif ($maxSingle >= 0.65) { $ceiling = 0.85; } elseif ($maxSingle >= 0.45) { $ceiling = 0.78; } if ($combinedScore > $ceiling) { $combinedScore = $ceiling; } return $this->snapScore($combinedScore, $bands); } private function maxSingleRelevanceScore(array $outRows) { $max = 0.0; foreach ($outRows as $row) { $score = floatval(isset($row['relevance_score']) ? $row['relevance_score'] : 0); if ($score > $max) { $max = $score; } } return $max; } private function enforceSingleReferenceConsistency($score, $level, $isRelevant, $role, array $bands) { $score = floatval($score); if ($role === 'no_meaningful_relevance') { if ($score > 0.25) { $score = 0.25; } $level = 'unrelated'; $isRelevant = false; } elseif ($role === 'minimal_relevance') { if ($score > 0.45) { $score = 0.45; } $level = 'weakly_related'; $isRelevant = false; } elseif ($role === 'supplementary_relevance') { if ($score > 0.78) { $score = 0.78; } $level = $this->levelFromScore($score, $level); } elseif ($role === 'primary_relevance') { if ($score < 0.85) { $score = 0.85; } $isRelevant = true; $level = $this->levelFromScore($score, $level); } if ($level === 'weakly_related' && $score > 0.45) { $score = 0.45; $isRelevant = false; } elseif ($level === 'unrelated' && $score > 0.25) { $score = 0.25; $isRelevant = false; } elseif ($level === 'highly_related' && $score < 0.85) { $score = 0.85; $isRelevant = true; } elseif ($level === 'partially_related') { if ($score > 0.78) { $score = 0.78; } if ($score < 0.65) { $score = 0.65; } $isRelevant = true; } if (!$isRelevant && $score >= 0.65) { $score = 0.45; $level = 'weakly_related'; } if ($isRelevant && $score < 0.65) { $score = 0.65; $level = 'partially_related'; } $score = $this->snapScore($score, $bands); $level = $this->levelFromScore($score, $level); return [$score, $level, $isRelevant, $role]; } private function enforceCombinedConsistency($combinedScore, $combinedLevel, array $bands) { $combinedScore = $this->snapScore(floatval($combinedScore), $bands); $combinedLevel = $this->levelFromScore($combinedScore, $combinedLevel); return [$combinedScore, $combinedLevel]; } private function getScoreBands() { return [0.15, 0.25, 0.45, 0.65, 0.78, 0.85, 0.92, 0.98]; } private function snapScore($score, array $bands) { foreach ($bands as $band) { if (abs($score - $band) < 0.001) { return $band; } } $nearest = $bands[0]; $minDiff = abs($score - $nearest); foreach ($bands as $band) { $diff = abs($score - $band); if ($diff < $minDiff) { $minDiff = $diff; $nearest = $band; } } return $nearest; } private function levelFromScore($score, $levelHint = '') { $levelHint = strtolower(trim((string)$levelHint)); $allowed = ['highly_related', 'partially_related', 'weakly_related', 'unrelated']; if (in_array($levelHint, $allowed, true)) { return $levelHint; } $aliases = [ 'highly_related' => ['highly_related', 'high_related', 'strong_related', 'strong_relevance'], 'partially_related' => ['partially_related', 'partial_related', 'moderate_related'], 'weakly_related' => ['weakly_related', 'weak_related', 'low_related', 'insufficient'], 'unrelated' => ['unrelated', 'not_related', 'irrelevant', 'no_meaningful_relevance'], ]; foreach ($aliases as $canonical => $list) { if (in_array($levelHint, $list, true)) { return $canonical; } } $score = floatval($score); if ($score >= 0.85) { return 'highly_related'; } if ($score >= 0.65) { return 'partially_related'; } if ($score >= 0.45) { return 'weakly_related'; } return 'unrelated'; } private function normalizeRelevanceRole($role) { $role = strtolower(trim((string)$role)); $map = [ 'primary_relevance' => ['primary_relevance', 'primary_support', 'primary'], 'supplementary_relevance' => ['supplementary_relevance', 'supplementary_support', 'supplementary'], 'minimal_relevance' => ['minimal_relevance', 'minimal_support', 'minimal'], 'no_meaningful_relevance' => ['no_meaningful_relevance', 'no_meaningful_support', 'none'], ]; foreach ($map as $canonical => $aliases) { if ($role === $canonical || in_array($role, $aliases, true)) { return $canonical; } } return ''; } private function cleanReason($reason) { $reason = trim(preg_replace('/[ \t]+/u', ' ', (string)$reason)); $reason = trim(preg_replace("/\n{3,}/u", "\n\n", $reason)); return mb_substr($reason, 0, 2000); } private function resolveReferenceNo(array $item) { foreach (['reference_no', 'ref_no', 'reference_number'] as $key) { if (!isset($item[$key])) { continue; } $refNo = intval($item[$key]); if ($refNo > 0) { return $refNo; } } return 0; } private function fallbackReasonFromScore($score, $level = '') { $level = trim((string)$level); if ($level === 'highly_related') { return '文献与引用处主题高度相关。'; } if ($level === 'partially_related') { return '文献与引用处主题部分相关。'; } if ($level === 'weakly_related') { return '文献与引用处主题关联较弱。'; } if ($level === 'unrelated') { return '文献与引用处主题基本不相关。'; } $score = floatval($score); if ($score >= 0.85) { return '文献与引用处主题高度相关。'; } if ($score >= 0.65) { return '文献与引用处主题部分相关。'; } if ($score >= 0.45) { return '文献与引用处主题关联较弱。'; } return '文献与引用处主题基本不相关。'; } /** * 从 reason 的「Claim覆盖」段落按顺序解析各 Claim 覆盖情况, * 正确处理合并写法(如「D/F/G✘」= 3 个 ✘、「A✔ B✔」= 2 个 ✔)。 * * @return array{full:int,partial:int,fail:int,total:int} */ private function countCoverageSignals($reason) { $reason = (string)$reason; // 尽量截取「Claim覆盖:...」到句号之间的覆盖清单,避免正文其他大写字母(如 PI3K/AKT)干扰 $covText = $reason; if (preg_match('/Claim\s*覆盖[::]\s*(.+?)(?:。|$)/us', $reason, $m)) { $covText = $m[1]; } $full = $partial = $fail = 0; if (preg_match_all('/([A-Z])(?:\s*(✔|✘|×|部分))/u', $covText, $tokens, PREG_SET_ORDER)) { foreach ($tokens as $tok) { $mark = $tok[2]; if ($mark === '✔') { $full++; } elseif ($mark === '部分') { $partial++; } else { $fail++; } } } return [ 'full' => $full, 'partial' => $partial, 'fail' => $fail, 'total' => $full + $partial + $fail, ]; } /** * 从联合文献块中提取指定编号的摘要/清洗内容。 */ private function extractRefLiteratureFromCombined($abstractText, $refNo) { $abstractText = (string)$abstractText; $refNo = intval($refNo); if ($abstractText === '' || $refNo <= 0) { return ''; } if (preg_match('/【参考文献\s+' . $refNo . '】\s*\n(.*?)(?=\n\n【参考文献\s+\d+】|\z)/us', $abstractText, $m)) { return trim($m[1]); } return ''; } /** * 判断文献材料是否呈现「学科理论发展/议题/未来方向」型综述特征(通用关键词,不限定具体文献)。 */ private function literatureSupportsDisciplineTheoryReview($text) { $text = strtolower((string)$text); if ($text === '') { return false; } $keys = [ '理论', '发展', '贡献', '挑战', '未来', '知识', '实践', '框架', '结构', '学科', 'theory', 'development', 'future', 'knowledge', 'practice', 'discipline', 'framework', ]; $hits = 0; foreach ($keys as $key) { if (strpos($text, $key) !== false) { $hits++; } } return $hits >= 6; } /** * 分值-覆盖自洽兜底(通用,不针对具体文献/学科): * 当 relevance_score 偏低(≤0.45)但 reason 写出的 Claim 覆盖明显更高、 * 且无「主语不一致/证据层级不足」等合理低分信号时,按覆盖比例抬升到对应档位。 */ private function reconcileScoreAgainstCoverage($score, $reason, $literatureContext = '') { $score = floatval($score); // 仅纠正明显偏低的分值,不动中高分 if ($score > 0.45 + 0.001) { return $score; } $reason = (string)$reason; if ($reason === '') { return $score; } // 合理低分信号:主语/层级不对、证据层级不足、类型不适配、几乎无覆盖等 → 低分成立,不抬升 if (preg_match('/主语不一致|主语层级|层级不对|层级不一致|层级偏|类型不符合|类型不适配|不适合支撑|几乎全[✘×]|均未/ui', $reason)) { return $score; } $bands = $this->getScoreBands(); $litBlob = trim($literatureContext . "\n" . $reason); $isPhilosophyMeta = preg_match('/哲学|元范式|metaparadigm|本体论|ontology|学科本体|disciplinary\s+inquir/ui', $litBlob); $cov = $this->countCoverageSignals($reason); $full = $cov['full']; $partial = $cov['partial']; $fail = $cov['fail']; $total = $cov['total']; // 「基本不相关」仅当无任何✔/部分时可阻止抬分 if (preg_match('/基本不相关/ui', $reason) && $full === 0 && $partial === 0) { return $score; } // 流行病学数据 claim 用指南/治疗文献支撑:同领域错类型 → 至少 0.45 if (preg_match('/主语一致/u', $reason) && preg_match('/患病率|发病率|流行病学|标准化患病率|prevalence|incidence/ui', $reason) && preg_match('/指南|治疗进展|诊疗指南|guideline|非流行病学/ui', $reason) && $score <= 0.25 + 0.001) { return $this->snapScore(0.45, $bands); } // 主语一致且有任意覆盖:禁止 0.25 if (preg_match('/主语一致/u', $reason) && ($full >= 1 || $partial >= 1) && $score <= 0.25 + 0.001) { return $this->snapScore(0.45, $bands); } // 哲学/元范式/本体论:主语一致且至少一项 Claim 有覆盖(✔或部分)→ 不低于 0.78(规则16) if ($isPhilosophyMeta && preg_match('/主语一致/u', $reason) && ($full >= 1 || $partial >= 1) && $total >= 1) { return $this->snapScore(0.78, $bands); } // 理论发展/议题综述(非哲学元层):材料丰富且 A✔、主语一致 → 抬到 0.85 if (!$isPhilosophyMeta && preg_match('/A✔/u', $reason) && preg_match('/主语一致/u', $reason) && preg_match('/综述|回顾|述评|发展|挑战|未来|方向|review|narrative/ui', $litBlob) && $this->literatureSupportsDisciplineTheoryReview($litBlob)) { return $this->snapScore(0.85, $bands); } // 单 Claim 强匹配:A✔ + 主语一致 + 类型完全匹配/权威数据支撑 → 不得 0.25(规则14) if (preg_match('/主语一致/u', $reason) && $full >= 1 && $fail === 0 && preg_match('/类型完全匹配|完全匹配|高度匹配|权威|明确提供|直接支撑|支撑.*claim|患病率|发病率|流行病学|epidemiol/ui', $reason)) { $epiMatch = preg_match('/流行病学|患病率|发病率|负担|prevalence|incidence|mortality|epidemiol/ui', $reason . $literatureContext); $floor = ($epiMatch || preg_match('/明确提供|直接支撑|权威/ui', $reason)) ? 0.92 : 0.85; return $this->snapScore(max($score, $floor), $bands); } // 覆盖信息不足以判断(无任何标注)→ 保持原分 if ($total < 1 || ($full === 0 && $partial === 0)) { return $score; } // 覆盖比例:完整覆盖计 1、部分覆盖计 0.5 $ratio = ($full + 0.5 * $partial) / $total; $systemCover = preg_match('/系统(?:阐述|覆盖)|全部\s*Claim|全部\s*覆盖|高度匹配/u', $reason); $floor = 0.0; if ($ratio >= 0.9 && $fail === 0) { $floor = $systemCover ? 0.98 : 0.92; } elseif ($ratio >= 0.72) { $floor = 0.92; } elseif ($ratio >= 0.58) { $floor = 0.85; } elseif ($ratio >= 0.45) { $floor = 0.78; } elseif ($ratio >= 0.28) { $floor = 0.65; } elseif ($ratio >= 0.15 || ($partial >= 1 && preg_match('/主语一致/u', $reason))) { $floor = 0.45; } else { return $score; } return $this->snapScore(max($score, $floor), $bands); } /** * 对偏高分值封顶(多事实背景句、多项✘、比较型不支持等)。 */ private function reconcileScoreCeiling($score, $reason) { $score = floatval($score); $reason = (string)$reason; if ($reason === '') { return $score; } $bands = $this->getScoreBands(); $cov = $this->countCoverageSignals($reason); $fail = $cov['fail']; $total = $cov['total']; if ($total >= 3 && $fail >= 2 && $score > 0.85) { $score = 0.78; } if ($total >= 3 && $fail >= 2 && preg_match('/原始研究/ui', $reason) && $score > 0.78) { $score = 0.78; } if ($fail >= 1 && preg_match('/比较|低于其他|高于其他|与其他|慢病|chronic disease/ui', $reason) && preg_match('/[A-Z][^。]*✘/u', $reason) && $score > 0.65) { $score = 0.65; } if ($total >= 4 && $fail >= 2 && preg_match('/原始研究/ui', $reason) && $score > 0.65) { $score = 0.65; } return $this->snapScore($score, $bands); } /** * 复核 reason 结论处的「故 X.XX」分值,使其与最终 relevance_score 一致, * 避免出现「relevance_score=0.25 却 reason 故 0.92」的自相矛盾。 */ private function reconcileReasonScore($reason, $score) { $reason = trim((string)$reason); if ($reason === '') { return $reason; } $scoreStr = number_format((float)$score, 2, '.', ''); // 覆盖「故 0.92」「故联合 0.92」「故联合分 0.92」等结论写法 $pattern = '/(故\s*(?:联合分?)?\s*)([01](?:\.\d+)?)/u'; if (!preg_match($pattern, $reason)) { return $reason; } return preg_replace_callback($pattern, function ($m) use ($scoreStr) { return $m[1] . $scoreStr; }, $reason); } /** * 归一化为中文结论;兼容旧版双语格式或 reason_en 英文字段。 */ private function normalizeChineseReason($reason, $fallbackEn = '') { $reason = trim((string)$reason); if ($reason !== '' && preg_match('/【中文】\s*(.+?)(?:\s*【English】|$)/us', $reason, $m)) { $cn = $this->cleanReason($m[1]); if ($cn !== '') { return $cn; } } if ($reason !== '') { if (preg_match('/【English】\s*(.+)$/us', $reason, $m)) { return $this->cleanReason($m[1]); } return $this->cleanReason($reason); } $fallbackEn = $this->cleanReason($fallbackEn); if ($fallbackEn !== '') { return $fallbackEn; } return ''; } private function normalizeAuthorComment($authorComment, $score, $reason) { $score = floatval($score); if ($score > 0.65 + 0.001) { return ''; } $authorComment = trim((string)$authorComment); if ($authorComment !== '') { $authorComment = $this->sanitizeAuthorCommentText($authorComment); if ($authorComment !== '') { return $this->finalizeAuthorComment($authorComment); } } $fallback = '该条参考文献与正文匹配度有限,关键论点仍缺直接证据。请改引更能支撑核心结论的文献,或明确现有引用依据。'; $reason = trim((string)$reason); if ($reason === '') { return $this->finalizeAuthorComment($fallback); } // 去掉显式分数结论,保留给作者可读的编辑批注 $reason = preg_replace('/Claim覆盖[::].*/u', '', $reason); $reason = preg_replace('/故\s*(?:联合分?)?\s*[01](?:\.\d+)?[。.]?/u', '', $reason); $reason = trim((string)$reason); if ($reason === '') { return $this->finalizeAuthorComment($fallback); } $reason = $this->sanitizeAuthorCommentText($reason); if ($reason === '') { return $this->finalizeAuthorComment($fallback); } return $this->finalizeAuthorComment($reason); } private function sanitizeAuthorCommentText($text) { $text = trim((string)$text); if ($text === '') { return ''; } $text = preg_replace('/Claim覆盖[::][^。;;\n]*/u', '', $text); $text = preg_replace('/\b[A-E]\s*(?:[✔✘]|部分)\b/u', '', $text); $text = preg_replace('/[✔✘]/u', '', $text); $text = preg_replace('/\b0?\.\d{1,2}\b/u', '', $text); $text = preg_replace('/\b\d{1,3}%\b/u', '', $text); $text = preg_replace('/建议\s*补充/u', '建议替换', $text); $text = preg_replace('/补充文献/u', '替换文献', $text); $text = preg_replace('/补充说明/u', '明确依据', $text); $text = preg_replace('/\s{2,}/u', ' ', $text); $text = trim((string)$text, " \t\n\r\0\x0B;;,,"); return $text; } private function finalizeAuthorComment($text) { $text = mb_substr(trim((string)$text), 0, 100); $text = trim($text, " \t\n\r\0\x0B;;,,。"); if ($text === '') { return ''; } return $text . '。'; } private function boolVal($v) { if (is_bool($v)) { return $v; } if (is_numeric($v)) { return intval($v) !== 0; } $s = strtolower(trim((string)$v)); return in_array($s, ['1', 'true', 'yes', 'y'], true); } private function postChat(array $payload) { $this->lastPostError = ''; try { $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $this->url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE)); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, min(15, $this->timeout)); curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout); $headers = ['Content-Type: application/json']; if ($this->apiKey !== '') { $headers[] = 'Authorization: Bearer ' . $this->apiKey; } curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); $raw = curl_exec($ch); $info = curl_getinfo($ch); $timingSummary = $this->buildCurlTimingSummary($info); if ($raw === false) { $this->lastPostError = 'LLM curl error: ' . curl_error($ch); $errno = intval(curl_errno($ch)); \think\Log::warning(sprintf( 'ReferenceRelevanceLlm: %s; errno=%d; timing={%s}', $this->lastPostError, $errno, $timingSummary )); curl_close($ch); return null; } $httpCode = intval(isset($info['http_code']) ? $info['http_code'] : 0); curl_close($ch); \think\Log::info(sprintf( 'ReferenceRelevanceLlm request completed: http=%d; timing={%s}', $httpCode, $timingSummary )); if ($httpCode < 200 || $httpCode >= 300) { $snippet = mb_substr(trim((string)$raw), 0, 200); $this->lastPostError = 'LLM HTTP ' . $httpCode . ($snippet !== '' ? ': ' . $snippet : ''); \think\Log::warning('ReferenceRelevanceLlm: ' . $this->lastPostError); return null; } $data = json_decode($raw, true); if (!is_array($data)) { $this->lastPostError = 'LLM response is not valid JSON'; return null; } if (isset($data['choices'][0]['message']['content'])) { return (string)$data['choices'][0]['message']['content']; } if (isset($data['content'])) { return (string)$data['content']; } $this->lastPostError = 'LLM response missing content field'; } catch (\Exception $e) { $this->lastPostError = 'LLM exception: ' . $e->getMessage(); \think\Log::warning('ReferenceRelevanceLlm: ' . $this->lastPostError); } return null; } private function buildCurlTimingSummary(array $info) { $nameLookupMs = intval(round(floatval(isset($info['namelookup_time']) ? $info['namelookup_time'] : 0) * 1000)); $connectMs = intval(round(floatval(isset($info['connect_time']) ? $info['connect_time'] : 0) * 1000)); $appConnectMs = intval(round(floatval(isset($info['appconnect_time']) ? $info['appconnect_time'] : 0) * 1000)); $startTransferMs = intval(round(floatval(isset($info['starttransfer_time']) ? $info['starttransfer_time'] : 0) * 1000)); $totalMs = intval(round(floatval(isset($info['total_time']) ? $info['total_time'] : 0) * 1000)); $sizeDownload = intval(isset($info['size_download']) ? $info['size_download'] : 0); $httpCode = intval(isset($info['http_code']) ? $info['http_code'] : 0); return sprintf( 'dns_ms=%d, connect_ms=%d, tls_ms=%d, ttfb_ms=%d, total_ms=%d, http=%d, size_download=%d', $nameLookupMs, $connectMs, $appConnectMs, $startTransferMs, $totalMs, $httpCode, $sizeDownload ); } private function parseJson($raw) { $raw = trim((string)$raw); if ($raw === '') { return null; } $raw = preg_replace('/^```[a-zA-Z]*\s*|```$/m', '', $raw); $raw = trim($raw); $raw = $this->repairJsonNewlinesInStrings($raw); $raw = $this->repairUnescapedQuotesInStrings($raw); $decoded = json_decode($raw, true); if (is_array($decoded)) { return $this->filterCompleteResults($decoded); } if (preg_match('/\{[\s\S]*/', $raw, $m)) { $chunk = $this->repairTruncatedJson($m[0]); $decoded = json_decode($chunk, true); if (is_array($decoded)) { return $this->filterCompleteResults($decoded); } } return $this->salvagePartialResults($raw); } private function repairJsonNewlinesInStrings($json) { $out = ''; $inString = false; $escape = false; $len = strlen($json); for ($i = 0; $i < $len; $i++) { $ch = $json[$i]; if ($escape) { $out .= $ch; $escape = false; continue; } if ($ch === '\\' && $inString) { $out .= $ch; $escape = true; continue; } if ($ch === '"') { $inString = !$inString; $out .= $ch; continue; } if ($inString && ($ch === "\n" || $ch === "\r")) { $out .= '\\n'; continue; } $out .= $ch; } return $out; } /** * 修复字符串内部未转义双引号(常见于 reason 文本中的英文引号)。 * 规则:字符串内遇到引号时,若其后最近非空白字符不是 JSON 分隔符(, ] } :), * 则视为内容中的裸引号并转义为 \",避免整段 JSON 解析失败。 */ private function repairUnescapedQuotesInStrings($json) { $out = ''; $inString = false; $escape = false; $len = strlen($json); for ($i = 0; $i < $len; $i++) { $ch = $json[$i]; if ($escape) { $out .= $ch; $escape = false; continue; } if ($ch === '\\' && $inString) { $out .= $ch; $escape = true; continue; } if ($ch === '"') { if (!$inString) { $inString = true; $out .= $ch; continue; } $next = $this->nextNonSpaceChar($json, $i + 1); if ($next === null || $next === ',' || $next === ']' || $next === '}' || $next === ':') { $inString = false; $out .= $ch; } else { $out .= '\\"'; } continue; } $out .= $ch; } return $out; } private function nextNonSpaceChar($text, $start) { $len = strlen((string)$text); for ($i = max(0, intval($start)); $i < $len; $i++) { $ch = $text[$i]; if ($ch !== ' ' && $ch !== "\t" && $ch !== "\r" && $ch !== "\n") { return $ch; } } return null; } private function repairTruncatedJson($json) { $json = rtrim($json); if ($json === '') { return $json; } $inString = false; $escape = false; $stack = []; $len = strlen($json); for ($i = 0; $i < $len; $i++) { $ch = $json[$i]; if ($escape) { $escape = false; continue; } if ($ch === '\\' && $inString) { $escape = true; continue; } if ($ch === '"') { $inString = !$inString; continue; } if ($inString) { continue; } if ($ch === '{' || $ch === '[') { $stack[] = $ch; } elseif ($ch === '}' && !empty($stack) && end($stack) === '{') { array_pop($stack); } elseif ($ch === ']' && !empty($stack) && end($stack) === '[') { array_pop($stack); } } if ($inString) { $json .= '"'; } while (!empty($stack)) { $open = array_pop($stack); $json .= $open === '{' ? '}' : ']'; } return $json; } private function salvagePartialResults($raw) { $pos = strpos($raw, '"results"'); if ($pos === false) { return null; } $start = strpos($raw, '[', $pos); if ($start === false) { return null; } $objs = []; $depth = 0; $objStart = null; $len = strlen($raw); for ($i = $start + 1; $i < $len; $i++) { $ch = $raw[$i]; if ($ch === '{') { if ($depth === 0) { $objStart = $i; } $depth++; } elseif ($ch === '}') { $depth--; if ($depth === 0 && $objStart !== null) { $chunk = substr($raw, $objStart, $i - $objStart + 1); $chunk = $this->repairJsonNewlinesInStrings($chunk); $chunk = $this->repairUnescapedQuotesInStrings($chunk); $item = json_decode($chunk, true); if (is_array($item) && $this->isCompleteResultObject($item)) { $objs[] = $item; } $objStart = null; } } } if (empty($objs)) { return null; } return ['results' => $objs]; } private function filterCompleteResults(array $parsed) { if (!isset($parsed['results']) || !is_array($parsed['results'])) { return $parsed; } $parsed['results'] = array_values(array_filter($parsed['results'], function ($item) { return is_array($item) && $this->isCompleteResultObject($item); })); return $parsed; } private function isCompleteResultObject(array $item) { if ($this->resolveReferenceNo($item) <= 0) { return false; } if (!array_key_exists('relevance_score', $item) && !array_key_exists('is_relevant', $item)) { return false; } $rawReason = isset($item['reason']) ? trim((string)$item['reason']) : ''; if ($rawReason === '' && empty($item['reason_en'])) { return true; } $reason = $this->normalizeChineseReason( isset($item['reason']) ? $item['reason'] : '', isset($item['reason_en']) ? $item['reason_en'] : '' ); if ($reason !== '' && mb_strlen($reason) < 4) { return false; } if ($rawReason !== '' && $this->looksTruncatedString($rawReason)) { return false; } return true; } private function looksTruncatedString($text) { $text = trim((string)$text); if ($text === '' || mb_strlen($text) < 80) { return false; } $last = mb_substr($text, -1); return !preg_match('/[.!?)"\'\x{3002}\x{ff01}\x{ff1f}\x{ff09}\x{3011}\x{300d}\x{2026}]/u', $last); } private function isTruncatedResponse($raw) { $raw = rtrim(trim((string)$raw)); if ($raw === '') { return false; } if (substr($raw, -1) === '}') { return false; } return $this->looksTruncatedString($raw) || preg_match('/"[^"]*$/s', $raw); } private function saveBadJsonResponse($raw, array $meta = []) { $dir = dirname(dirname(dirname(__DIR__))) . DIRECTORY_SEPARATOR . 'runtime' . DIRECTORY_SEPARATOR . 'log' . DIRECTORY_SEPARATOR . 'reference_relevance_llm_bad_json'; if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) { return ''; } $cite = preg_replace('/[^\d,]/', '', (string)(isset($meta['cite_group_refs']) ? $meta['cite_group_refs'] : '')); $cite = $cite !== '' ? $cite : 'unknown'; $name = date('Ymd_His') . '_' . $cite . '_' . substr(md5((string)$raw), 0, 8) . '.json'; $path = $dir . DIRECTORY_SEPARATOR . $name; $payload = array_merge([ 'saved_at' => date('Y-m-d H:i:s'), 'model' => $this->model, 'raw_length' => strlen((string)$raw), 'raw' => (string)$raw, ], $meta); if (@file_put_contents($path, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) === false) { return ''; } return 'runtime/log/reference_relevance_llm_bad_json/' . $name; } }