1805 lines
85 KiB
PHP
1805 lines
85 KiB
PHP
<?php
|
||
|
||
namespace app\common\service;
|
||
|
||
use think\Env;
|
||
|
||
/**
|
||
* 参考文献「主题相关性」LLM 校对(独立于支撑力度校对 LLMService)
|
||
*/
|
||
class ReferenceRelevanceLlmService
|
||
{
|
||
private $url;
|
||
private $model;
|
||
private $apiKey;
|
||
private $timeout;
|
||
private $lastPostError = '';
|
||
private $maxSectionChars;
|
||
private $maxLocalContextChars;
|
||
private $maxReferChars;
|
||
private $maxAbstractChars;
|
||
private $maxTokens;
|
||
|
||
public function __construct()
|
||
{
|
||
$this->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', ''));
|
||
$this->timeout = max(180, intval(Env::get('promotion.promotion_llm_timeout', 180)));
|
||
// 控制发送给 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(4096, 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);
|
||
$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) {
|
||
$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` 时必须生成(中文、礼貌、适合展示给作者、可执行,≤90字);当 `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 时输出给作者看的中文批注(礼貌、可执行,≤90字);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/combined_reason 加详后,单篇输出更长,相应上调 token 预算防截断
|
||
$dynamic = min(16384, max(6144, $refCount * 1600));
|
||
if ($refCount >= 6) {
|
||
$dynamic = max($dynamic, 12288);
|
||
}
|
||
if ($this->maxTokens > 0) {
|
||
return max($this->maxTokens, $dynamic);
|
||
}
|
||
|
||
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) || count($missing) > 2 || empty($out)) {
|
||
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;
|
||
}
|
||
|
||
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 mb_substr($authorComment, 0, 120);
|
||
}
|
||
}
|
||
|
||
$reason = trim((string)$reason);
|
||
if ($reason === '') {
|
||
return '该条参考文献与正文匹配度有限,建议补充更直接支持该论点的文献。';
|
||
}
|
||
// 去掉显式分数结论,保留给作者可读的改进建议
|
||
$reason = preg_replace('/Claim覆盖[::].*/u', '', $reason);
|
||
$reason = preg_replace('/故\s*(?:联合分?)?\s*[01](?:\.\d+)?[。.]?/u', '', $reason);
|
||
$reason = trim((string)$reason);
|
||
if ($reason === '') {
|
||
return '该条参考文献与正文匹配度有限,建议补充更直接支持该论点的文献。';
|
||
}
|
||
$reason = $this->sanitizeAuthorCommentText($reason);
|
||
if ($reason === '') {
|
||
return '该条参考文献与正文匹配度有限,建议补充更直接支持该论点的文献。';
|
||
}
|
||
|
||
return mb_substr('建议:' . $reason, 0, 120);
|
||
}
|
||
|
||
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{2,}/u', ' ', $text);
|
||
$text = trim((string)$text, " \t\n\r\0\x0B;;,,");
|
||
|
||
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;
|
||
}
|
||
}
|