Files
tougao/application/common/service/ReferenceRelevanceLlmService.php
2026-07-16 17:02:51 +08:00

2051 lines
93 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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', ''));
// 相关性校对固定至少 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.78partially_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.45weakly_related / minimal_relevance**,不得给 0.65~0.78
- 仅同领域沾边 12 项、主语或机制层级不对 → **0.45**
- **进入 0.65~0.78 的前提**主语对齐X 单体)+ 本文自身结果命中原句点名通路/结局的多数项;几乎全部明确对应 → **0.85+**
11. **文献「主题粒度」必须匹配 claim「主题粒度」**:引用处为**疾病总论型 claim**(流行病学负担、标准/多模态治疗现状与局限、基因组异质性、单靶点治疗受限、亟需新策略等总体背景)时:
- 最适合的来源是**疾病总体综述 / 分子病理综述 / 精准肿瘤学 / 耐药综述**;此类文献正面、系统地为该总论 claim 提供依据 → 可 **0.85+**
- **单一药物 / 单一成分 / 单一通路的专题综述**如「某化合物抗某癌A review」即使同病、同大方向也只是专题视角、并非为该总论 claim 做系统总结 → 通常 **partially_related0.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.78partially_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.65partially_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_relevantscore>=0.65 为 1否则 0
- reason仅中文结论禁止 reason_en、【English】等英文字段默认每条约 220 字以内≥5 篇联合时按下方指令压缩须体现步骤①主语→②Claim覆盖仅字母+✔/部分/✘,具体内容见顶层 claims→③类型匹配→④分值理由分值须与覆盖自洽硬规则14
主语/层级不对 → 单条 **0.45**,不得因讨论提及相同通路给 0.78
引用处 claim 为「化合物 X 经 PI3K/AKT 等机制 demonstrated…」文献为其他植物提取物或计算预测、仅在讨论转引他人 X 机制 → 0.45weakly_relatedis_relevant=0。
机制文引用流行病学句 → 单条 **0.45**,不得 0.92
文献为 CRC 机制研究,引用处 claim 为全球高发/死亡率,文献无流行病学数据 → 0.45minimal_relevanceis_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` 时必须生成(中文、**以期刊编辑审稿口吻**、礼貌可执行,**80100字**);当 `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 时输出以期刊编辑审稿口吻的中文批注礼貌、可执行80100字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 mb_substr($authorComment, 0, 100);
}
}
$fallback = '编辑意见:该条参考文献与正文匹配度有限,关键论点仍缺直接证据。请改引更能支撑核心结论的文献,或补充说明引用依据。';
$reason = trim((string)$reason);
if ($reason === '') {
return $fallback;
}
// 去掉显式分数结论,保留给作者可读的编辑批注
$reason = preg_replace('/Claim覆盖[:].*/u', '', $reason);
$reason = preg_replace('/故\s*(?:联合分?)?\s*[01](?:\.\d+)?[。.]?/u', '', $reason);
$reason = trim((string)$reason);
if ($reason === '') {
return $fallback;
}
$reason = $this->sanitizeAuthorCommentText($reason);
if ($reason === '') {
return $fallback;
}
return mb_substr('编辑意见:' . $reason, 0, 100);
}
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;
}
}