Files
tougao/application/common/service/ReferenceRelevanceLlmService.php

2997 lines
138 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;
/** 本次校对的联合引用组总篇数(分块时仍为整组篇数,供单篇分摊评分使用) */
private $groupRefCount = 0;
public function __construct()
{
// 相关性校对优先用 RELEVANCE_LLM_*(百炼等);未配置则回退 PROMOTION_LLM_*(本地)
$this->url = $this->resolveRelevanceEnv('relevance_llm_url', 'promotion_llm_url');
$this->model = $this->resolveRelevanceEnv('relevance_llm_model', 'promotion_llm_model');
$this->apiKey = $this->resolveRelevanceEnv('relevance_llm_api_key', 'promotion_llm_api_key');
$timeout = intval(Env::get('promotion.relevance_llm_timeout', 0));
if ($timeout <= 0) {
$timeout = 120;
}
$this->timeout = max(60, $timeout);
// 控制发送给 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)));
}
/**
* 读取 promotion.relevance_llm_*,为空则回退 promotion.promotion_llm_*。
*/
private function resolveRelevanceEnv($relevanceKey, $fallbackKey)
{
$v = trim((string)Env::get('promotion.' . $relevanceKey, ''));
if ($v !== '') {
return $v;
}
return trim((string)Env::get('promotion.' . $fallbackKey, ''));
}
/**
* @param callable|null $onChunkDone 分块成功回调,用于立即落库
* @return array{results:array,claims?:array,combined_relevance_score?:float,combined_reason?:string,request_failed?:bool,reason?:string,partial?:bool}
*/
public function checkRelevance($sectionText, $localContext, $referText, $abstractText = '', $citeGroupRefs = '', array $referTypeMap = [], $onChunkDone = null)
{
$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);
}
$refCount = $this->countCiteGroupRefs($citeGroupRefs);
$this->groupRefCount = $refCount;
// 统计型引用(正文在统计「纳入的 N 项研究」)走整组程序核验,不调 LLM
$studySet = $this->tryStudySetShortCircuit($localContext, $sectionText, $citeGroupRefs, $referTypeMap, $refCount);
if ($studySet !== null) {
return $studySet;
}
// ≥4 篇(大于 3强制逐篇≤3 篇可用批量(默认每批最多 2
$perRefThreshold = max(2, intval(Env::get('promotion.relevance_llm_per_ref_threshold', 4)));
if ($refCount >= $perRefThreshold) {
$maxRefsPerCall = 1;
} else {
$maxRefsPerCall = max(1, intval(Env::get('promotion.relevance_llm_max_refs_per_call', 2)));
}
if ($refCount > $maxRefsPerCall) {
return $this->checkRelevanceByChunks(
$sectionText,
$localContext,
$referText,
$abstractText,
$citeGroupRefs,
$referTypeMap,
$refCount,
$maxRefsPerCall,
$onChunkDone
);
}
$referText = $this->truncateText($referText, $this->maxReferChars);
$abstractText = $this->truncateText($abstractText, $this->maxAbstractChars);
return $this->checkRelevanceOnce(
$sectionText,
$localContext,
$referText,
$abstractText,
$citeGroupRefs,
$referTypeMap,
$refCount,
$fallback
);
}
/**
* 命中「纳入研究统计型」引用时,用书目元数据整组核验替代 LLM 逐篇判断。
* 这类引用编号是被统计的对象本身,逐篇问「是否支撑该论点」语义不成立,
* 且大组会退化成几十次 LLM 调用,故直接短路。
*
* @return array|null 未命中返回 null交由常规 LLM 流程处理
*/
private function tryStudySetShortCircuit($localContext, $sectionText, $citeGroupRefs, array $referTypeMap, $refCount)
{
if (!Env::get('promotion.relevance_study_set_shortcut', true)) {
return null;
}
$minRefs = max(2, intval(Env::get('promotion.relevance_study_set_min_refs', 5)));
if ($refCount < $minRefs) {
return null;
}
$refNos = $this->parseCiteGroupRefNumbers($citeGroupRefs);
if (count($refNos) < $minRefs) {
return null;
}
$verifier = new StudySetClaimVerifyService();
$claimContext = $verifier->buildContext($localContext, $sectionText);
$declared = $verifier->detectDeclared($claimContext);
if (empty($declared)) {
return null;
}
// 声明篇数与引用组篇数吻合,或同时命中三个以上统计维度,才认定为枚举式统计引用
$totalMatches = intval($declared['total']) > 0 && intval($declared['total']) === count($refNos);
if (!$totalMatches && intval($declared['aspect_hits']) < 3) {
return null;
}
$verified = $verifier->verify($claimContext, $refNos, $referTypeMap, $declared);
if (empty($verified['results'])) {
return null;
}
\think\Log::info(sprintf(
'ReferenceRelevanceLlm study-set short-circuit: refs=%d claims=%d combined=%.2f cite=%s (LLM skipped)',
count($refNos),
count($verified['claims']),
floatval($verified['combined_relevance_score']),
$citeGroupRefs
));
return [
'results' => $verified['results'],
'claims' => $verified['claims'],
'combined_relevance_score' => floatval($verified['combined_relevance_score']),
'combined_reason' => (string)$verified['combined_reason'],
'combined_author_comment' => (string)($verified['combined_author_comment'] ?? ''),
'combined_locked' => true,
'llm_skipped' => true,
];
}
private function truncateText($text, $maxChars)
{
$text = (string)$text;
$maxChars = intval($maxChars);
if ($maxChars <= 0 || mb_strlen($text) <= $maxChars) {
return $text;
}
return mb_substr($text, 0, $maxChars);
}
/**
* @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,
array $fixedClaims = [],
$fullCiteGroupRefs = ''
) {
$systemPrompt = $this->buildSystemPrompt();
$userPrompt = $this->buildUserPrompt(
$sectionText,
$localContext,
$referText,
$abstractText,
$citeGroupRefs,
$refCount,
$referTypeMap,
$fixedClaims,
$fullCiteGroupRefs
);
$payload = [
'model' => $this->model,
'temperature' => 0,
'max_tokens' => $this->resolveMaxTokens($refCount),
'messages' => [
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userPrompt],
],
];
\think\Log::info(sprintf(
'ReferenceRelevanceLlm request prepare: url=%s timeout=%d refs=%d group=%d sys=%d user=%d max_tokens=%d compact=%d per_ref=%d',
$this->url,
intval($this->timeout),
intval($refCount),
intval($this->groupRefCount),
mb_strlen($systemPrompt),
mb_strlen($userPrompt),
intval($payload['max_tokens']),
$this->shouldUseCompactSystemPrompt() ? 1 : 0,
($fullCiteGroupRefs !== '' && $refCount === 1) ? 1 : 0
));
$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, $referTypeMap);
$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,
'combined_author_comment' => $this->buildCombinedAuthorCommentFromReason($combinedScore, $combinedReason),
];
}
private function shouldUseCompactSystemPrompt()
{
// ≥4 篇逐篇时用压缩提示,降低单次请求体积与网关挂死概率
return intval($this->groupRefCount) >= 4;
}
private function buildSystemPrompt()
{
if ($this->shouldUseCompactSystemPrompt()) {
return $this->buildCompactSystemPrompt();
}
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(此处「单条弱」指主语不一致/证据层级不足等**实质弱相关**不含规则18的分工覆盖情形
**但联合分不是「取单篇最高分」**:各篇分工覆盖不同 Claim 时联合按规则18的整组覆盖并集定档可高于任一单篇。
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 后必须自检;通用规则,适用所有文献类型)**
- **先判本组是单独引用还是联合引用**cite_group_refs 含多篇时按下方「联合引用分摊原则」评分;仅 1 篇时才按整段 Claim 总数算覆盖比例。
- **联合引用分摊原则(多篇联合时优先适用)**:整段 Claim 由组内各篇**分工覆盖****不要求任何单篇覆盖全部 Claim**。
- 单篇评分只看「**它覆盖的那几项 Claim 支撑得是否充分**」,**不看**「占整段 Claim 的比例」
- **主语一致 + 完整✔至少 1 项 Claim** → 单篇 **不低于 0.85**(已充分承担分工),**严禁**因未覆盖他篇负责的 Claim 而降到 0.45/0.25
- 主语一致但只有「部分」、无任何完整✔ → **0.65**
- 单篇**上限 0.85**:只承担整段一两项分工者不给 0.92/0.980.92+ 留给单篇即覆盖整段绝大多数 Claim 的情形)
- 组内多篇覆盖同一项 Claim 属**重复引用**,各篇分值照常给,但须在 combined_reason 点名
- 单独引用(组内仅 1 篇)时,按你写出的覆盖标注计算「覆盖比例」= (完整✔数 + 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
18. **联合分按「整组 Claim 覆盖并集」定档(联合引用必用)**
- 把组内各篇的 ✔/部分**取并集**:某项 Claim 只要有任一篇 ✔ 即记完整,只有「部分」记 0.5,无任何篇覆盖记 ✘
- 整组覆盖率 = (并集完整数 + 0.5×并集部分数) / Claim 总数,据此定档:
全部 Claim 均被覆盖 → **0.92~0.98**≥0.72 → **0.85**≥0.58 → **0.78**≥0.28 → **0.65**;更低按实际
- **联合分与单篇分母不同**单篇按分工评规则14联合按整段覆盖评单篇偏低不构成压低联合分的理由
- **combined_reason 必须点名两件事**(这是本次校对对作者最有价值的输出):
①**无人覆盖的 Claim** → 写「X 暂无文献支撑」,提示作者该处需替换/改引
②**被多篇重复覆盖的 Claim** → 写「X 由文献 a、b、c 重复覆盖」,提示引用堆砌可精简
==================================================
【一、必须先拆解 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` 时必须生成(中文、**资深期刊编辑与作者沟通的委婉审稿口吻**,避免生硬判定与命令式,**100150字**,句尾需句号);当 `relevance_score > 0.65` 时必须返回空字符串 `""`。
写法要求(按此结构,语气委婉):
① 先客观概述该处正文主要表述/主张(点出正文关注的具体对象或论点);
② 再用**委婉措辞**指出文献与该表述的差距,用“似乎未直接涉及/似未充分覆盖/侧重点略有不同/对……着墨不多”等,不要用“并未提供/完全不符/错误”等生硬定性;
③ 最后给出**两条可选建议**:既可“替换相应编号文献以更直接支持上述表述”,也可“酌情调整/修改该句,使引用内容与文献证据保持一致”,供作者自行取舍。
硬性要求:禁止“补充/新增/增加文献”等引导加文献的措辞(会打乱编号),一律写“替换相应编号文献/改引”;语气用“建议/似可/不妨/可考虑/或可”,不得出现“必须/应当/务必”。
注意:`author_comment` 禁止出现 A/B/C/D、✔/✘、"Claim覆盖"、具体分值(如 0.65)、流行病学等技术术语,需改写为作者易读的委婉批注。
**禁止**在 results 各条中写 combined_relevance_score、combined_reason、cite_group_refs、claims。
PROMPT;
}
/**
* 大联合组压缩提示:保留评分硬规则与 JSON 契约,去掉长示例,降低网关挂死概率。
*/
private function buildCompactSystemPrompt()
{
return <<<'PROMPT'
你是资深学术编辑,执行「参考文献主题相关性校对」:判断引用处 claim 与各编号文献是否匹配。不是判断「是否同一疾病/领域」。
【硬规则】
1. 单条 score 只评该编号文献本身,不得因联合组抬高弱相关文献。
2. 联合分写在顶层 combined_relevance_score/combined_reason禁止写入 results 各条。
3. 联合引用分摊:整段 Claim 由各篇分工覆盖单篇主语一致且完整✔≥1 项 Claim → 单篇不低于 0.85(上限 0.85);仅「部分」→ 0.65;严禁因未覆盖他篇负责的 Claim 降到 0.45/0.25。
4. 联合分按整组 Claim 覆盖并集定档:全覆盖 0.92~0.98≥0.72→0.85≥0.58→0.78≥0.28→0.65。combined_reason 须点名无人覆盖的 Claim 与被多篇重复覆盖的 Claim。
5. 主语/证据层级不对、类型不适配、几乎无覆盖 → 可给 0.45/0.25;主语一致且有任意✔/部分时禁止 0.25。
6. 流行病学 claim 用机制原始研究 → 通常 0.45。同病不等于高分。
7. 分值仅用0.98/0.92/0.85/0.78/0.65/0.45/0.25/0.15is_relevant = score>=0.65 ? 1 : 0。
【流程】①拆 Claim 写入顶层 claims → ②文献证据 → ③Claim Mapping(✔/部分/✘) → ④覆盖率 → ⑤类型匹配 → ⑥分值与 reason。
【输出】仅 JSON无 markdown。reason/author_comment 仅中文。
{
"cite_group_refs": "1,2",
"claims": {"A": "……", "B": "……"},
"combined_relevance_score": 0.85,
"combined_reason": "……",
"results": [
{"reference_no":1,"is_relevant":0,"relevance_score":0.45,"reason":"……","author_comment":"……"},
{"reference_no":2,"is_relevant":1,"relevance_score":0.85,"reason":"……","author_comment":""}
]
}
results 每条仅含上述 5 字段。reason ≤120 字,须含 Claim 覆盖字母标注。combined_reason ≤180 字。
author_commentscore<=0.65 时 80120 字委婉建议可替换编号文献或调整句子score>0.65 时 ""。禁止补充/新增文献措辞,改为替换/改引。
PROMPT;
}
private function buildUserPrompt(
$sectionText,
$localContext,
$referText,
$abstractText,
$citeGroupRefs,
$refCount = 0,
array $referTypeMap = [],
array $fixedClaims = [],
$fullCiteGroupRefs = ''
) {
$parts = ["【正文节 t_article_main】\n" . $sectionText];
$fullCiteGroupRefs = trim((string)$fullCiteGroupRefs);
$citeGroupRefs = trim((string)$citeGroupRefs);
$perRefInGroup = ($fullCiteGroupRefs !== ''
&& $refCount === 1
&& $this->countCiteGroupRefs($fullCiteGroupRefs) > 1);
if ($perRefInGroup) {
$parts[] = "【引用文献组 cite_group_refs】{$fullCiteGroupRefs}(联合引用,共"
. $this->countCiteGroupRefs($fullCiteGroupRefs) . "篇)";
$parts[] = "【本批仅评】文献 {$citeGroupRefs}(仅输出该编号 1 条 results联合结论由系统汇总不必写完整组 combined_reason";
} elseif ($citeGroupRefs !== '') {
$mode = strpos($citeGroupRefs, ',') !== false ? '联合引用' : '单独引用';
$parts[] = "【引用文献组 cite_group_refs】{$citeGroupRefs}{$mode}";
}
if ($localContext !== '') {
$parts[] = "【本引用位置附近上下文(优先据此拆解 claim\n" . $localContext;
}
if (!empty($fixedClaims)) {
$claimLines = [];
foreach ($fixedClaims as $letter => $text) {
$claimLines[] = $letter . '' . $text;
}
$parts[] = "【已确定的 Claim 列表(本引用位置已拆解完成,必须沿用相同字母与含义,不得重新编号、改写或增删)】\n"
. implode("\n", $claimLines);
}
$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 时输出委婉建议性中文批注(资深编辑与作者沟通口吻,避免生硬判定与命令式;结构为①客观概述该处正文主张→②委婉指出文献差距,用“似乎未直接涉及/侧重点略有不同”等→③给出两条可选建议:替换相应编号文献 或 酌情调整该句使引用与文献一致100150字句尾需句号语气用“建议/似可/不妨/可考虑”,禁止“必须/应当/务必”;禁止“补充/新增/增加文献”,一律改为“替换相应编号文献/改引”以保持参考文献编号不变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 ($perRefInGroup) {
$tail .= sprintf(
' 本批仅评文献 %s联合组分篇校对必须且仅输出 1 条 resultsreason ≤180 字,须含 Claim 覆盖字母标注A✔/部分/✘)。按联合引用分摊原则评分:主语一致且完整✔至少 1 项 Claim → 单篇不低于 0.85(上限 0.85),严禁因未覆盖他篇负责的 Claim 而降到 0.45/0.25;仅「部分」则 0.65。顶层 combined_* 可与单条一致(整组联合结论由系统汇总,不必写完整组分工)。若无【已确定的 Claim 列表】则先拆解 claims若有则必须沿用。',
$citeGroupRefs
);
} elseif ($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_* 与单条一致。';
}
if ($refCount > 1 && !$perRefInGroup) {
$tail .= ' 联合引用分摊原则(务必遵守):整段 Claim 由各篇分工覆盖,单篇只要主语一致且完整✔至少 1 项 Claim 即不低于 0.85(上限 0.85),严禁因未覆盖他篇负责的 Claim 而降到 0.45/0.25;仅「部分」覆盖则 0.65。顶层 combined_relevance_score 按各篇覆盖并集算整组覆盖率定档(全覆盖 0.92~0.98、≥0.72 给 0.85、≥0.58 给 0.78、≥0.28 给 0.65combined_reason 必须点名「哪些 Claim 无任何文献支撑」与「哪些 Claim 被多篇重复覆盖(写明文献编号)」。';
}
$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));
// 过高 max_tokens 会拖慢本地推理;分块后单次篇数少,给够即可
if ($refCount <= 2) {
$dynamic = 2560;
} elseif ($refCount <= 4) {
$dynamic = 4096;
} else {
$dynamic = min(8192, $refCount * 900 + 1200);
}
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先按编号取块再在块内截断单块失败不丢弃已成功块。
*
* @param callable|null $onChunkDone function(array $part, array $chunkRefNos): void 每块成功后回调(用于立即落库)
* @return array{results:array,claims?:array,combined_relevance_score?:float,combined_reason?:string,request_failed?:bool,reason?:string,partial?:bool}
*/
private function checkRelevanceByChunks(
$sectionText,
$localContext,
$referText,
$abstractText,
$citeGroupRefs,
array $referTypeMap,
$refCount,
$chunkSize,
$onChunkDone = null
) {
$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(1, intval($chunkSize));
$chunks = array_chunk($refNums, $chunkSize);
$allResults = [];
$claims = [];
$failedReasons = [];
// ≥4 篇逐篇时:联合结论完全由程序汇总,不采用各批 LLM 的 combined_*
$programmaticCombined = ($chunkSize === 1 && $refCount > 3);
foreach ($chunks as $chunk) {
$chunkRefs = implode(',', $chunk);
// 先按编号取块,再截断——保证本块文献文本完整进入预算
$chunkRefer = $this->filterRefBlocks($referText, $chunk);
$chunkAbstract = $this->filterRefBlocks($abstractText, $chunk);
if ($chunkRefer === '') {
$failedReasons[] = sprintf('chunk[%s] missing refer blocks after split', $chunkRefs);
\think\Log::warning('ReferenceRelevanceLlm: empty refer blocks for chunk=' . $chunkRefs);
continue;
}
$chunkRefer = $this->truncateText($chunkRefer, $this->maxReferChars);
$chunkAbstract = $this->truncateText($chunkAbstract, $this->maxAbstractChars);
$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,
$claims,
$programmaticCombined ? $citeGroupRefs : ''
);
if (!empty($part['request_failed']) || empty($part['results'])) {
$reason = isset($part['reason']) ? (string)$part['reason'] : 'LLM split batch failed';
$failedReasons[] = sprintf('chunk[%s] %s', $chunkRefs, $reason);
\think\Log::warning(sprintf(
'ReferenceRelevanceLlm: chunk failed cite=%s reason=%s',
$chunkRefs,
$reason
));
continue;
}
if (empty($claims) && !empty($part['claims']) && is_array($part['claims'])) {
$claims = $part['claims'];
}
$chunkRows = [];
foreach ($part['results'] as $row) {
$refNo = intval(isset($row['reference_no']) ? $row['reference_no'] : 0);
if ($refNo > 0 && !isset($allResults[$refNo])) {
$allResults[$refNo] = $row;
$chunkRows[] = $row;
}
}
if (!empty($chunkRows) && is_callable($onChunkDone)) {
try {
$callbackPart = [
'results' => $chunkRows,
'claims' => $claims,
];
if ($programmaticCombined) {
// 暂不写最终联合结论;落库后由 refreshGroupCombinedFields 程序汇总
$callbackPart['combined_relevance_score'] = 0;
$callbackPart['combined_reason'] = '';
} else {
$callbackPart['combined_relevance_score'] = floatval(isset($part['combined_relevance_score']) ? $part['combined_relevance_score'] : 0);
$callbackPart['combined_reason'] = (string)(isset($part['combined_reason']) ? $part['combined_reason'] : '');
}
call_user_func($onChunkDone, $callbackPart, $chunk);
} catch (\Throwable $e) {
\think\Log::error('ReferenceRelevanceLlm onChunkDone: ' . $e->getMessage());
}
}
}
ksort($allResults, SORT_NUMERIC);
$results = array_values($allResults);
if (empty($results)) {
$msg = !empty($failedReasons)
? implode('; ', array_slice($failedReasons, 0, 3))
: 'LLM split batch failed';
return array_merge($fallback, ['reason' => $msg]);
}
$combined = $this->rebuildCombinedFromResults($results);
$combinedScore = floatval($combined['combined_relevance_score']);
$combinedReason = (string)$combined['combined_reason'];
$combinedAuthorComment = (string)($combined['combined_author_comment'] ?? '');
$partial = count($results) < count($refNums);
\think\Log::info(sprintf(
'ReferenceRelevanceLlm: split %d refs into %d batches (chunk=%d) got=%d partial=%d programmatic_combined=%d cite_group_refs=%s',
$refCount,
count($chunks),
$chunkSize,
count($results),
$partial ? 1 : 0,
$programmaticCombined ? 1 : 0,
$citeGroupRefs
));
$out = [
'results' => $results,
'claims' => $claims,
'combined_relevance_score' => $combinedScore,
'combined_reason' => $combinedReason,
'combined_author_comment' => $combinedAuthorComment,
];
if ($partial) {
$out['partial'] = true;
$out['reason'] = sprintf(
'LLM split batch partial: got %d/%d; %s',
count($results),
count($refNums),
!empty($failedReasons) ? implode('; ', array_slice($failedReasons, 0, 2)) : 'some chunks missing'
);
}
return $out;
}
/**
* 与单条 author_comment 同一套规则score > 0.65 返回空;否则委婉批注。
*/
public function buildAuthorCommentByScore($score, $reason, $seedComment = '', $maxChars = 160)
{
return $this->normalizeAuthorComment($seedComment, $score, $reason, $maxChars);
}
/**
* 组合批注:把 combined_reason 委婉改写即可(规则对齐 author_comment
* score > 0.65 返回空。
*/
public function buildCombinedAuthorCommentFromReason($score, $combinedReason, $maxChars = 800)
{
$score = floatval($score);
if ($score > 0.65 + 0.001) {
return '';
}
$soft = $this->softenCombinedReasonTone((string)$combinedReason);
// 作为 seed 走与 author_comment 相同的清洗/收尾
return $this->normalizeAuthorComment($soft, $score, '', $maxChars);
}
/**
* 将 combined_reason 转为作者可读语气:去掉技术符号,措辞委婉,保留原意。
*/
private function softenCombinedReasonTone($reason)
{
$text = trim((string)$reason);
if ($text === '') {
return '';
}
$text = preg_replace('/整组统计核验([^]*[:]\s*/u', '该处对纳入研究的整组核对显示:', $text);
$text = preg_replace('/逐篇校对汇总[:]\s*/u', '该处多篇文献汇总核对显示:', $text);
$text = preg_replace('/【需核实】/u', '建议优先核实:', $text);
$text = preg_replace('/建议优先核对上述编号的书目\/元数据或正文统计数字。?/u', '建议优先核对上述编号的书目信息或正文统计数字。', $text);
$text = preg_replace('/暂缺作者单位国别与语种信息,影响国家数\/语言\/区域分布核验/u', '暂缺作者单位国别与语种信息,相关统计数字似需再核', $text);
$text = preg_replace('/暂缺作者单位国别,影响国家数\/区域分布核验/u', '暂缺作者单位国别,国家数与区域分布数字似需再核', $text);
$text = preg_replace('/暂缺语种信息,影响语言构成核验/u', '暂缺语种信息,语言构成数字似需再核', $text);
$text = preg_replace('/仅为期刊类型、尚缺正式发表证据/u', '似乎尚缺正式发表证据', $text);
$text = preg_replace('/有未发表标记in press\/submitted 等)/u', '似乎带有未正式发表标记', $text);
$text = preg_replace('/非期刊文献(图书\/预印本\/会议等)/u', '似乎并非已发表期刊论文', $text);
$text = preg_replace('/下列编号归属该区域需核对/u', '下列编号归属该区域,似需再核', $text);
$text = preg_replace('/【整组覆盖】/u', '', $text);
$text = preg_replace('/Claim\s*覆盖[:]?\s*/u', '', $text);
$text = preg_replace('/\b[A-E]\s*[✔✘?]/u', '', $text);
$text = preg_replace('/[✔✘?]/u', '', $text);
$text = preg_replace('/通过\s*(\d+)\s*项、不符\s*(\d+)\s*项、元数据不足\s*(\d+)\s*项。?/u', '其中约$1项较为吻合、$2项似乎尚不完全吻合、$3项因文献信息不足暂难确认。', $text);
$text = preg_replace('/程序按作者单位核到/u', '按所引文献作者单位汇总似乎为', $text);
$text = preg_replace('/程序核到/u', '按所引文献书目汇总似乎为', $text);
$text = preg_replace('/已核到/u', '目前按书目汇总可见', $text);
$text = preg_replace('/正文所称的/u', '正文所写的', $text);
$text = preg_replace('/与正文所称/u', '与正文所写', $text);
$text = preg_replace('/正文称/u', '正文写为', $text);
$text = preg_replace('/([一-龥A-Za-z]+)\s*称\s*(\d+)\s*实核\s*(\d+)/u', '$1正文写为$2、汇总似乎为$3', $text);
$text = preg_replace('/以下项实核多于正文,缺失文献无法解释——/u', '其中', $text);
$text = preg_replace('/实核多于正文,缺失文献无法解释/u', '与正文似乎尚不完全吻合,且似难以仅用缺失文献完全解释', $text);
$text = preg_replace('/无法解释/u', '似难以完全对应', $text);
$text = preg_replace('/多出\s*(\d+)/u', '约多出$1', $text);
$text = preg_replace('/未取到作者单位国别/u', '暂缺作者单位国别信息', $text);
$text = preg_replace('/未取到语种元数据/u', '暂缺语种信息', $text);
$text = preg_replace('/需补齐元数据后确认/u', '似需补齐相应文献信息后再确认', $text);
$text = preg_replace('/排除预印本\/会议\/未发表标记,并经 PubMed 收录或刊名\+年份\+卷期页\/DOI 核验/u', '经书目与收录信息核对', $text);
$text = preg_replace('/建议按上述不符项核对正文数字,或补正相应文献编号。?/u', '建议核对正文中的相关数字,或酌情调整该句表述。', $text);
$text = preg_replace('/元数据不足项需补齐[^。]*。?/u', '部分文献信息似需补齐后再复核。', $text);
$text = preg_replace('/此处编号是被统计的纳入研究本身,不逐篇做语义相关性判断[:]?\s*/u', '', $text);
$text = preg_replace('/联合分\s*[01](?:\.\d+)?/u', '', $text);
$text = preg_replace('/\s*[;]\s*/u', '', $text);
$text = preg_replace('/[;]{2,}/u', '', $text);
$text = preg_replace('/\s{2,}/u', ' ', $text);
$text = preg_replace('/^[\s;,。]+|[\s;,。]+$/u', '', $text);
if ($text === '') {
return '该处正文表述与所引文献汇总的对应关系似乎尚不够充分。建议核对正文相关内容,或酌情调整该句,使引用与文献证据保持一致';
}
if (!preg_match('/建议|似可|不妨|可考虑/u', $text)) {
$text .= '。建议核对正文中的相关数字或表述,或酌情调整该句,使引用内容与文献证据保持一致';
}
return $text;
}
/**
* 根据已落库/已返回的单篇结果重算联合分(供分块落库后刷新整组 combined_*)。
*
* @param array $results 元素含 reference_no/relevance_score/reason/is_relevant
* @return array{combined_relevance_score:float,combined_reason:string,combined_author_comment:string}
*/
public function rebuildCombinedFromResults(array $results)
{
$results = array_values($results);
if (empty($results)) {
return [
'combined_relevance_score' => 0.0,
'combined_reason' => '',
'combined_author_comment' => '',
];
}
if (count($results) === 1) {
$score = floatval($results[0]['relevance_score'] ?? 0);
$reason = (string)($results[0]['reason'] ?? '');
return [
'combined_relevance_score' => $score,
'combined_reason' => $reason,
'combined_author_comment' => $this->buildCombinedAuthorCommentFromReason($score, $reason),
];
}
$bands = $this->getScoreBands();
$coverage = $this->summarizeGroupCoverage($results);
$combinedScore = $this->enforceCombinedAgainstSingles($results, $coverage['floor'], $bands);
if ($this->maxSingleRelevanceScore($results) >= 0.65 - 0.001
&& $coverage['floor'] > $combinedScore) {
$combinedScore = $coverage['floor'];
}
if ($combinedScore <= 0) {
$combinedScore = $this->maxSingleRelevanceScore($results);
}
$penalty = $this->applyWeakMajorityPenalty($combinedScore, $results, $coverage);
$combinedScore = floatval($penalty['score']);
$combinedReason = $this->buildProgrammaticCombinedReason(
$results,
$combinedScore,
$coverage,
(string)$penalty['note']
);
return [
'combined_relevance_score' => floatval($combinedScore),
'combined_reason' => $combinedReason,
'combined_author_comment' => $this->buildCombinedAuthorCommentFromReason($combinedScore, $combinedReason),
];
}
/**
* 由各篇单条结果程序汇总 combined_reason重复覆盖 / Claim 缺口等)。
*/
private function buildProgrammaticCombinedReason(array $results, $combinedScore, array $coverage, $penaltyNote = '')
{
$high = $partial = $weak = 0;
$weakRefs = [];
foreach ($results as $row) {
$score = floatval(isset($row['relevance_score']) ? $row['relevance_score'] : 0);
$refNo = intval(isset($row['reference_no']) ? $row['reference_no'] : 0);
if ($score >= 0.85 - 0.001) {
$high++;
} elseif ($score >= 0.65 - 0.001) {
$partial++;
} else {
$weak++;
if ($refNo > 0) {
$hint = $this->briefVerifyHintFromReason((string)(isset($row['reason']) ? $row['reason'] : ''));
$weakRefs[] = $hint !== ''
? sprintf('文献%d%s', $refNo, $hint)
: sprintf('文献%d', $refNo);
}
}
}
$head = sprintf(
'逐篇校对汇总:共%d篇高度相关%d、部分相关%d、弱/不相关%d联合分%.2f。',
count($results),
$high,
$partial,
$weak,
floatval($combinedScore)
);
$penaltyNote = trim((string)$penaltyNote);
if ($penaltyNote !== '') {
$head .= ' ' . $penaltyNote;
}
$reason = $this->appendGroupCoverageNote($head, $coverage);
if (!empty($weakRefs)) {
$shown = $weakRefs;
$suffix = '';
if (count($shown) > 8) {
$shown = array_slice($shown, 0, 8);
$suffix = '等';
}
$reason .= sprintf(
' 【需核实】%s%s单条相关度偏低建议核对正文主张与该编号文献证据是否匹配或酌情替换相应编号文献/调整该句。',
implode('', $shown),
$suffix
);
}
return $reason;
}
/**
* 从单条 reason 抽一句短核实提示(去掉 Claim 符号与过长细节)。
*/
private function briefVerifyHintFromReason($reason)
{
$reason = trim((string)$reason);
if ($reason === '') {
return '';
}
$reason = preg_replace('/Claim覆盖[:].*/u', '', $reason);
$reason = preg_replace('/[A-E]\s*[✔✘?]|[✔✘?]/u', '', $reason);
$reason = preg_replace('/\s{2,}/u', ' ', $reason);
$reason = trim($reason, " ;,。");
if ($reason === '') {
return '与正文主张对应不足';
}
return mb_substr($reason, 0, 36, 'UTF-8');
}
/**
* 大联合组弱相关占比过高时,压低联合分,避免 1 篇中等相关抬高整组结论。
* 例外:仅 1 条 Claim 且已完整覆盖时,允许维持 0.65。
*
* @return array{score:float,note:string}
*/
private function applyWeakMajorityPenalty($combinedScore, array $results, array $coverage)
{
$combinedScore = floatval($combinedScore);
$totalRefs = count($results);
if ($totalRefs < 10) {
return ['score' => $combinedScore, 'note' => ''];
}
$supportCount = 0;
foreach ($results as $row) {
$score = floatval(isset($row['relevance_score']) ? $row['relevance_score'] : 0);
if ($score >= 0.65 - 0.001) {
$supportCount++;
}
}
// >=80% 为弱相关(<=20% 支撑)时触发惩罚
if ($supportCount * 5 > $totalRefs) {
return ['score' => $combinedScore, 'note' => ''];
}
$singleClaimFullyCovered = intval(isset($coverage['total']) ? $coverage['total'] : 0) === 1
&& intval(isset($coverage['full']) ? $coverage['full'] : 0) === 1;
$cap = $singleClaimFullyCovered ? 0.65 : 0.45;
if ($combinedScore <= $cap + 0.001) {
return ['score' => $combinedScore, 'note' => ''];
}
$score = $this->snapScore($cap, $this->getScoreBands());
$note = sprintf(
'弱相关占比过高(%d/%d 文献得分<0.65),联合分按规则下调至 %.2f。',
$totalRefs - $supportCount,
$totalRefs,
$score
);
return ['score' => $score, 'note' => $note];
}
/**
* 按编号过滤「【参考文献 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 = '', array $referTypeMap = [])
{
$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);
// 联合引用分摊保底:整段 Claim 由多篇分工覆盖时,按整段分母算出的低分与✘数封顶均不适用
$shareFloor = $this->resolveJointShareFloor($reason);
if ($shareFloor > $capped) {
\think\Log::warning(sprintf(
'ReferenceRelevanceLlm: ref#%d joint share floor %.2f->%.2f (group=%d)',
$refNo,
$capped,
$shareFloor,
$this->groupRefCount
));
$capped = $shareFloor;
}
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,
];
}
$claims = $this->normalizeClaims(isset($parsed['claims']) ? $parsed['claims'] : []);
$out = $this->applyStudySetMetadataClaimOverride($out, $claims, $localContext, $referTypeMap, $bands);
$groupCombined = $this->resolveGroupCombinedFields($parsed, $rows, $out, $citeGroupRefs, $bands);
return [
'results' => $out,
'claims' => $claims,
'combined_relevance_score' => floatval($groupCombined['combined_relevance_score']),
'combined_reason' => (string)$groupCombined['combined_reason'],
'combined_author_comment' => (string)($groupCombined['combined_author_comment'] ?? ''),
];
}
/**
* 枚举式引用(正文统计「纳入的 N 项研究」的出版形式/国家数/语言/区域分布):
* 这些编号是被统计的对象本身,不是论点的证据来源,逐篇做主题相关性判断无意义。
* 此时由程序核验文献类型并整体重写 reason避免 LLM 给出 A✘ 与程序 A✔ 自相矛盾。
*/
private function applyStudySetMetadataClaimOverride(array $outRows, array $claims, $localContext, array $referTypeMap, array $bands)
{
if (empty($outRows)) {
return $outRows;
}
if (!$this->isStudySetMetadataClaimSet($claims, $localContext)) {
return $outRows;
}
$allJournal = !empty($referTypeMap);
foreach ($outRows as $row) {
$refNo = intval(isset($row['reference_no']) ? $row['reference_no'] : 0);
$info = isset($referTypeMap[$refNo]) ? $referTypeMap[$refNo] : null;
$type = is_array($info) ? (string)($info['type'] ?? '') : (string)$info;
if ($type !== 'journal') {
$allJournal = false;
break;
}
}
$score = $this->snapScore($allJournal ? 0.92 : 0.65, $bands);
foreach ($outRows as &$row) {
$refNo = intval(isset($row['reference_no']) ? $row['reference_no'] : 0);
$row['relevance_score'] = $score;
$row['is_relevant'] = 1;
if ($allJournal) {
$row['reason'] = sprintf(
'枚举式引用:文献%d 是正文所统计的纳入研究之一而非论点的证据来源。程序核验其文献类型为已发表期刊论文A✔。国家/地区数、语言构成、区域分布属整组统计项,需按整组元数据核验,不计入单篇覆盖。',
$refNo
);
$row['author_comment'] = '';
} else {
$row['reason'] = sprintf(
'枚举式引用:文献%d 是正文所统计的纳入研究之一但程序未能确认其为已发表期刊论文A 存疑,可能为图书/预印本/会议文献)。其余统计项需按整组元数据核验。',
$refNo
);
$row['author_comment'] = $this->normalizeAuthorComment(
'该处正文在统计纳入研究的出版形式,本条文献的书目信息似乎未能确认为正式发表的期刊论文,建议核对该编号的期刊名、卷期页码或 DOI或酌情调整该句表述使统计口径与文献实际情况保持一致。',
$score,
$row['reason']
);
}
}
unset($row);
return $outRows;
}
/**
* 判断本引用位置的 Claim 是否属于「纳入研究集合的统计描述」。
*/
private function isStudySetMetadataClaimSet(array $claims, $localContext)
{
$text = '';
foreach ($claims as $t) {
$text .= ' ' . (string)$t;
}
$text .= ' ' . (string)$localContext;
$text = trim($text);
if ($text === '') {
return false;
}
$hits = 0;
if (preg_match('/included\s+stud(?:y|ies)/i', $text)
|| preg_match('/纳入(?:的)?研究/u', $text)) {
$hits++;
}
if (preg_match('/published\s+journal\s+articles?/i', $text)
|| preg_match('/(?:发表于|已发表).*期刊/u', $text)) {
$hits++;
}
if (preg_match('/conducted\s+across\s+\d+\s+countr/i', $text)
|| preg_match('/\d+\s*个?(?:国家|地区)/u', $text)) {
$hits++;
}
if (preg_match('/published\s+in\s+english/i', $text)
|| preg_match('/in\s+chinese/i', $text)
|| preg_match('/语言(?:构成|为)/u', $text)) {
$hits++;
}
if (preg_match('/\b(?:studies|篇)\s*from\s+(?:Europe|Africa|Asia|Oceania)/i', $text)
|| preg_match('/(?:欧洲|非洲|亚洲|大洋洲|北美|南美)/u', $text)) {
$hits++;
}
return $hits >= 2;
}
/**
* 归一化顶层 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));
}
}
// 联合分按整组覆盖并集定档:各篇分工覆盖时不受单篇分母稀释拖累
$coverage = $this->summarizeGroupCoverage($outRows);
if ($this->maxSingleRelevanceScore($outRows) >= 0.65 - 0.001
&& $coverage['floor'] > $combinedScore) {
$combinedScore = $coverage['floor'];
}
list($combinedScore,) = $this->enforceCombinedConsistency($combinedScore, '', $bands);
$combinedReason = $this->reconcileReasonScore($this->cleanReason($combinedReason), $combinedScore);
$combinedReason = $this->appendGroupCoverageNote($combinedReason, $coverage);
return [
'combined_relevance_score' => $combinedScore,
'combined_reason' => $combinedReason,
'combined_author_comment' => $this->buildCombinedAuthorCommentFromReason($combinedScore, $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,
];
}
/**
* 解析 reason 中的 Claim 覆盖标注,返回 字母 => full|partial|fail。
* 同一字母多次出现时取最强标注(完整 > 部分 > 未覆盖)。
*/
private function extractCoverageMarks($reason)
{
$reason = (string)$reason;
$covText = $reason;
if (preg_match('/Claim\s*覆盖[:]\s*(.+?)(?:。|$)/us', $reason, $m)) {
$covText = $m[1];
}
$marks = [];
if (preg_match_all('/([A-Z])(?:\s*(✔|✘|×|部分))/u', $covText, $tokens, PREG_SET_ORDER)) {
foreach ($tokens as $tok) {
$letter = $tok[1];
$mark = $tok[2] === '✔' ? 'full' : ($tok[2] === '部分' ? 'partial' : 'fail');
$cur = isset($marks[$letter]) ? $marks[$letter] : '';
if ($cur === 'full' || ($cur === 'partial' && $mark === 'fail')) {
continue;
}
$marks[$letter] = $mark;
}
}
return $marks;
}
/**
* 联合引用中,整段 Claim 由各篇分工覆盖,单篇不应因未覆盖他篇负责的 Claim 而被判弱相关。
* 按「分摊份额」(本篇覆盖数 ÷ 人均应覆盖数)给一个保底分。
*
* @return float 保底分0 表示不适用(单独引用、主语不一致、无覆盖标注等)
*/
private function resolveJointShareFloor($reason)
{
$groupSize = intval($this->groupRefCount);
if ($groupSize < 2) {
return 0.0;
}
$reason = (string)$reason;
if ($reason === '' || !preg_match('/主语一致/u', $reason)) {
return 0.0;
}
// 主语/层级/类型不适配属实质弱相关,分摊原则不适用
if (preg_match('/主语不一致|主语层级|层级不对|层级不一致|类型不符合|类型不适配|不适合支撑/ui', $reason)) {
return 0.0;
}
$cov = $this->countCoverageSignals($reason);
$full = $cov['full'];
$partial = $cov['partial'];
$total = $cov['total'];
if ($total < 1 || ($full === 0 && $partial === 0)) {
return 0.0;
}
$expected = $total / $groupSize;
if ($expected <= 0) {
return 0.0;
}
$shareRatio = min(1.0, ($full + 0.5 * $partial) / $expected);
if ($shareRatio >= 0.72) {
$floor = 0.92;
} elseif ($shareRatio >= 0.58) {
$floor = 0.85;
} elseif ($shareRatio >= 0.45) {
$floor = 0.78;
} elseif ($shareRatio >= 0.28) {
$floor = 0.65;
} else {
return 0.0;
}
// 完成分摊份额不等于覆盖全段:完整覆盖至少一项封顶 0.85,仅部分覆盖封顶 0.65
$ceiling = $full >= 1 ? 0.85 : 0.65;
return $this->snapScore(min($floor, $ceiling), $this->getScoreBands());
}
/**
* 汇总整组 Claim 覆盖情况:各篇覆盖取并集,得出整组覆盖率、无人覆盖项与重复覆盖项。
*
* @return array{total:int,full:int,partial:int,ratio:float,floor:float,missing:array,duplicated:array}
*/
private function summarizeGroupCoverage(array $results)
{
$empty = [
'total' => 0,
'full' => 0,
'partial' => 0,
'ratio' => 0.0,
'floor' => 0.0,
'missing' => [],
'duplicated' => [],
];
if (count($results) < 2) {
return $empty;
}
$union = [];
$coveredBy = [];
foreach ($results as $row) {
$reason = isset($row['reason']) ? (string)$row['reason'] : '';
if ($reason === '') {
continue;
}
$refNo = intval(isset($row['reference_no']) ? $row['reference_no'] : 0);
foreach ($this->extractCoverageMarks($reason) as $letter => $mark) {
$cur = isset($union[$letter]) ? $union[$letter] : 'fail';
if ($mark === 'full' || $cur === 'full') {
$union[$letter] = 'full';
} elseif ($mark === 'partial' || $cur === 'partial') {
$union[$letter] = 'partial';
} else {
$union[$letter] = 'fail';
}
if ($mark === 'full' && $refNo > 0) {
$coveredBy[$letter][] = $refNo;
}
}
}
$total = count($union);
if ($total < 1) {
return $empty;
}
ksort($union, SORT_STRING);
$full = $partial = 0;
$missing = [];
foreach ($union as $letter => $mark) {
if ($mark === 'full') {
$full++;
} elseif ($mark === 'partial') {
$partial++;
} else {
$missing[] = $letter;
}
}
if ($full === 0 && $partial === 0) {
return $empty;
}
$ratio = ($full + 0.5 * $partial) / $total;
if ($ratio >= 0.9 && $full === $total) {
$floor = 0.92;
} elseif ($ratio >= 0.72) {
$floor = 0.85;
} elseif ($ratio >= 0.58) {
$floor = 0.78;
} elseif ($ratio >= 0.28) {
$floor = 0.65;
} else {
$floor = 0.0;
}
$duplicated = [];
foreach ($coveredBy as $letter => $refNos) {
$refNos = array_values(array_unique($refNos));
if (count($refNos) > 1) {
sort($refNos, SORT_NUMERIC);
$duplicated[$letter] = $refNos;
}
}
ksort($duplicated, SORT_STRING);
return [
'total' => $total,
'full' => $full,
'partial' => $partial,
'ratio' => $ratio,
'floor' => $floor > 0 ? $this->snapScore($floor, $this->getScoreBands()) : 0.0,
'missing' => $missing,
'duplicated' => $duplicated,
];
}
/**
* 在 combined_reason 末尾追加整组覆盖说明(缺口 Claim 与重复覆盖 Claim
* 分块校对时各批会各写一次,故先清除旧标记再按全组重写。
*/
private function appendGroupCoverageNote($combinedReason, array $summary)
{
$combinedReason = trim((string)$combinedReason);
$combinedReason = trim(preg_replace('/【整组覆盖】.*$/us', '', $combinedReason));
if (intval($summary['total']) < 1) {
return $combinedReason;
}
$segments = [sprintf('Claim 覆盖 %d/%d', $summary['full'], $summary['total'])];
if (!empty($summary['missing'])) {
$segments[] = implode('、', $summary['missing']) . ' 暂无文献支撑,建议替换相应编号文献或调整该句表述';
}
if (!empty($summary['duplicated'])) {
$dup = [];
foreach ($summary['duplicated'] as $letter => $refNos) {
$dup[] = $letter . ' 由文献 ' . implode('、', $refNos) . ' 重复覆盖';
}
$segments[] = implode('', $dup) . ',可酌情精简';
}
if (count($segments) < 2) {
return $combinedReason;
}
$note = '【整组覆盖】' . implode('', $segments) . '。';
return $combinedReason === '' ? $note : $combinedReason . ' ' . $note;
}
/**
* 从联合文献块中提取指定编号的摘要/清洗内容。
*/
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, $maxChars = 160)
{
$score = floatval($score);
if ($score > 0.65 + 0.001) {
return '';
}
$authorComment = trim((string)$authorComment);
if ($authorComment !== '') {
$authorComment = $this->sanitizeAuthorCommentText($authorComment);
if ($authorComment !== '') {
return $this->finalizeAuthorComment($authorComment, $maxChars);
}
}
$fallback = '该处参考文献与正文表述的对应关系似乎尚不够充分,文献侧重点与正文核心论点略有不同。建议替换相应编号文献以更直接支持此处表述,或酌情调整该句,使引用内容与文献证据保持一致。';
$reason = trim((string)$reason);
if ($reason === '') {
return $this->finalizeAuthorComment($fallback, $maxChars);
}
// 去掉显式分数结论,保留给作者可读的委婉批注
$reason = preg_replace('/Claim覆盖[:].*/u', '', $reason);
$reason = preg_replace('/故\s*(?:联合分?)?\s*[01](?:\.\d+)?[。.]?/u', '', $reason);
$reason = trim((string)$reason);
if ($reason === '') {
return $this->finalizeAuthorComment($fallback, $maxChars);
}
$reason = $this->sanitizeAuthorCommentText($reason);
if ($reason === '') {
return $this->finalizeAuthorComment($fallback, $maxChars);
}
return $this->finalizeAuthorComment($reason, $maxChars);
}
private function sanitizeAuthorCommentText($text)
{
$text = trim((string)$text);
if ($text === '') {
return '';
}
$text = preg_replace('/Claim覆盖[:][^。;;\n]*/u', '', $text);
$text = preg_replace('/\b[A-E]\s*(?:[✔✘]|部分)\b/u', '', $text);
$text = preg_replace('/[✔✘]/u', '', $text);
$text = preg_replace('/\b0?\.\d{1,2}\b/u', '', $text);
$text = preg_replace('/\b\d{1,3}%\b/u', '', $text);
// 「补充/新增」易引导加文献、打乱编号 → 统一改为替换/改引
$text = preg_replace('/(?:建议|请|可|需|应|还|另|再)?\s*补充(?:一篇|一条|相关)?(?:参考文献|文献)?/u', '建议替换现有编号文献', $text);
$text = preg_replace('/补充文献/u', '替换文献', $text);
$text = preg_replace('/补充说明/u', '进一步明确依据', $text);
$text = preg_replace('/(?:新增|增加|另增)\s*(?:一篇|一条)?(?:参考文献|文献)/u', '替换现有编号文献', $text);
$text = preg_replace('/请\s*(?:改引|替换)/u', '建议替换', $text);
// 生硬定性 → 委婉措辞
$text = preg_replace('/必须\s*/u', '建议', $text);
$text = preg_replace('/应当\s*/u', '建议', $text);
$text = preg_replace('/务必\s*/u', '建议', $text);
$text = preg_replace('/仍缺/u', '稍显不足', $text);
$text = preg_replace('/并未(?:提供|涉及|覆盖|体现)/u', '似乎未直接涉及', $text);
$text = preg_replace('/完全不(?:符|相关|匹配)/u', '契合度略显不足', $text);
$text = preg_replace('/无法支持/u', '对该表述支持稍显不足', $text);
$text = preg_replace('/\s{2,}/u', ' ', $text);
$text = preg_replace('/^[\s;,。]+|[\s;,。]+$/u', '', (string)$text);
return $text;
}
private function finalizeAuthorComment($text, $maxChars = 160)
{
$text = trim((string)$text);
if ($text === '') {
return '';
}
if (!mb_check_encoding($text, 'UTF-8')) {
$converted = @iconv('UTF-8', 'UTF-8//IGNORE', $text);
$text = is_string($converted) ? $converted : preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $text);
}
$maxChars = max(80, intval($maxChars));
$text = mb_substr($text, 0, $maxChars, 'UTF-8');
// 禁止把中文标点放进 trim() 字符表PHP trim 按字节剥离,会拆坏多字节汉字
$text = preg_replace('/^[\s;,。]+|[\s;,。]+$/u', '', $text);
if ($text === '') {
return '';
}
return $text . '。';
}
private function boolVal($v)
{
if (is_bool($v)) {
return $v;
}
if (is_numeric($v)) {
return intval($v) !== 0;
}
$s = strtolower(trim((string)$v));
return in_array($s, ['1', 'true', 'yes', 'y'], true);
}
private function postChat(array $payload)
{
$this->lastPostError = '';
$maxAttempts = max(1, intval(Env::get('promotion.relevance_llm_retries', 1)));
$lastError = '';
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
$content = $this->postChatOnce($payload, $attempt, $maxAttempts);
if ($content !== null) {
return $content;
}
$lastError = $this->lastPostError;
$retryable = $this->isRetryableLlmError($lastError);
if (!$retryable || $attempt >= $maxAttempts) {
break;
}
$sleepSec = min(8, $attempt * 2);
\think\Log::warning(sprintf(
'ReferenceRelevanceLlm: retryable failure attempt=%d/%d sleep=%ds err=%s',
$attempt,
$maxAttempts,
$sleepSec,
$lastError
));
sleep($sleepSec);
}
if ($lastError !== '') {
$this->lastPostError = $lastError;
}
return null;
}
private function isRetryableLlmError($error)
{
$error = (string)$error;
if ($error === '') {
return false;
}
$needles = [
'timed out',
'Operation timed out',
'0 bytes received',
'Empty reply from server',
'Failed to connect',
'Connection reset',
'HTTP 502',
'HTTP 503',
'HTTP 504',
];
foreach ($needles as $n) {
if (stripos($error, $n) !== false) {
return true;
}
}
return false;
}
private function postChatOnce(array $payload, $attempt = 1, $maxAttempts = 1)
{
$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; attempt=%d/%d; timing={%s}',
$this->lastPostError,
$errno,
$attempt,
$maxAttempts,
$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; attempt=%d/%d; timing={%s}',
$httpCode,
$attempt,
$maxAttempts,
$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;
}
}