2997 lines
138 KiB
PHP
2997 lines
138 KiB
PHP
<?php
|
||
|
||
namespace app\common\service;
|
||
|
||
use think\Env;
|
||
|
||
/**
|
||
* 参考文献「主题相关性」LLM 校对(独立于支撑力度校对 LLMService)
|
||
*/
|
||
class ReferenceRelevanceLlmService
|
||
{
|
||
private $url;
|
||
private $model;
|
||
private $apiKey;
|
||
private $timeout;
|
||
private $lastPostError = '';
|
||
private $maxSectionChars;
|
||
private $maxLocalContextChars;
|
||
private $maxReferChars;
|
||
private $maxAbstractChars;
|
||
private $maxTokens;
|
||
/** 本次校对的联合引用组总篇数(分块时仍为整组篇数,供单篇分摊评分使用) */
|
||
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.78(partially_related)**,不给 0.85+
|
||
7. **联合分不是「取最高单条分」**:当各单篇都只覆盖整句一部分、需互补拼合时,联合分应反映「整句作为一个整体被支撑的完整度」,**允许低于任何一篇单条分**。
|
||
8. **主语/研究对象层级必须对齐**:引用处主语为某化合物/分子(如「X has been demonstrated…」)时,文献核心对象须为 **X 本身**或以 X 为核心的实验/综述。
|
||
- **植物提取物/混合物**研究、**其他物种/其他植物**的计算预测、成分表中顺带出现 X → 通常 **0.45 或更低**
|
||
- **关键:提取物即使 X 含量很高(如 50%+)且显示了抗癌/凋亡活性,活性归因于提取物整体而非 X 单体单独验证 → 仍属 weakly_related(≤0.45)、`minimal_relevance`**,不得评为 supplementary_relevance/0.78
|
||
- 只有当文献**针对 X 单体单独做了验证**(X monomer 处理、X 单独剂量效应等)时,主语才算对齐,方可进入 0.65+
|
||
- **不得**因摘要/讨论出现与引用句相同的通路名、凋亡、抗癌等词就给 0.78+
|
||
9. **证据层级与 demonstrated / mechanistically**:
|
||
- 本文实验结果或针对 X 的系统综述 > 计算预测/混合成分推测
|
||
- **讨论(Discussion)转引他人关于 X 的机制总结 ≠ 该文自身证据**;据此最多 **0.45~0.65**,不得评为 highly_related
|
||
- in silico / computational prediction 不足以支撑「has been demonstrated to mechanistically…」式强语气 claim 的高分
|
||
10. **点名通路/功能结局须逐项核对**:原句逐条列举通路(如 PI3K/AKT、MAPK、NF-κB)或结局(增殖、凋亡、血管生成、炎症信号等)时,**每一项单独核对是否在本文证据中成立**(非仅背景提及)。
|
||
- 讨论转述既往文献 ≠ 本文证明该项
|
||
- 缺原句任一点名项(如 angiogenesis)→ 单条通常 **不得 0.78+**
|
||
- **「覆盖部分结局」不足以进入 0.78**:原句点名了多条通路 + 多个结局,文献仅命中其中 1~2 个结局(如仅凋亡/增殖),且**点名通路在本文结果中全部缺失(仅讨论转引)**或主语层级不对 → 单条 **限 0.45(weakly_related / minimal_relevance)**,不得给 0.65~0.78
|
||
- 仅同领域沾边 1–2 项、主语或机制层级不对 → **0.45**
|
||
- **进入 0.65~0.78 的前提**:主语对齐(X 单体)+ 本文自身结果命中原句点名通路/结局的多数项;几乎全部明确对应 → **0.85+**
|
||
11. **文献「主题粒度」必须匹配 claim「主题粒度」**:引用处为**疾病总论型 claim**(流行病学负担、标准/多模态治疗现状与局限、基因组异质性、单靶点治疗受限、亟需新策略等总体背景)时:
|
||
- 最适合的来源是**疾病总体综述 / 分子病理综述 / 精准肿瘤学 / 耐药综述**;此类文献正面、系统地为该总论 claim 提供依据 → 可 **0.85+**
|
||
- **单一药物 / 单一成分 / 单一通路的专题综述**(如「某化合物抗某癌:A review」),即使同病、同大方向,也只是专题视角、并非为该总论 claim 做系统总结 → 通常 **partially_related(0.72~0.78)**,**不得给 0.85+**
|
||
- **单基因 / 单通路的机制原始研究**对纯流行病学负担 claim → 仍按规则 3 给 **0.45**
|
||
- 判断要点:文献类型是否「为该总论 claim 本身做系统综述/总论」;仅同病同方向、或只支撑整段中某一两句(如「需要更安全的新策略」),不足以进入 highly_related
|
||
12. **图书/教材参考文献(无 DOI、无摘要)**:
|
||
- 识别特征:ISBN、版次(3rd ed.)、出版社+年份、无期刊名/无 DOI
|
||
- **不得因缺少外部摘要就默认 0.25**;须从**书名、副标题、作者专业领域、出版社、版次**判断文献类型
|
||
- 若书名/副标题明确为某学科理论/模型/知识体系的**分析、评价、教材、手册**(书名/副标题含"理论/模型/知识/原理/导论/手册/概论"等指示词),且引用处 claim 为该学科理论功能/概念框架/实践指导概述 → 可按**教材/理论专著**匹配,通常 **0.85~0.98**
|
||
- 若仅有书目信息、无法确认主题粒度,给 **0.65~0.78**,不得轻易 0.25
|
||
13. **学科理论/概念「多功能并列」总论句**(一句并列列举某类理论/概念/方法的多项功能,例如"提供概念框架、描述现象、组织专业推理、解释情境、指导设计与实践、为行动提供依据",或"并非历史遗产而是当前仍具价值"等总括表述):
|
||
- 判分核心 = **文献系统覆盖并列功能的完整度 + 主题层级是否为「整个学科理论/概念体系」**,据此在 highly_related 内做档内区分,不得一律 0.98 也不得因是教材就压到 0.65
|
||
- **综合性理论经典教材 / 学科知识体系专著**(对该学科理论做全面分析、评价、体系化阐述),系统覆盖全部并列功能 → **0.98**
|
||
- 教材/专著但**聚焦子类**(某一子类理论、单一模型、单一学派/作者)或个别并列功能非其重点 → **0.92**(仍 highly_related,因层级/覆盖略窄不给 0.98)
|
||
- **综述**强调该领域理论当代价值/发展/未来方向,但非系统罗列全部功能 → **0.85**
|
||
- **哲学 / 元层(元范式、上位框架)/ 学科本体论论文**:概念层级高于「具体功能」,通常覆盖"仍具价值、提供概念资源",但对"描述现象/组织推理/指导设计/提供依据"等具体功能仅部分覆盖 → **0.78(partially_related)**,不给 0.92
|
||
- 仅间接沾边、层级或主语明显不符 → **≤0.65**
|
||
- **联合分**:多篇经典教材已系统覆盖全部并列功能时,联合可 **0.98**,不得因个别子类/哲学文献偏窄而压低整体
|
||
14. **分值必须与你写出的 Claim 覆盖自洽(写完 reason 后必须自检;通用规则,适用所有文献类型)**:
|
||
- **先判本组是单独引用还是联合引用**: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.98(0.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.65(partially_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_relevant:score>=0.65 为 1,否则 0
|
||
- reason:仅中文结论,禁止 reason_en、【English】等英文字段;默认每条约 220 字以内(≥5 篇联合时按下方指令压缩),须体现步骤①主语→②Claim覆盖(仅字母+✔/部分/✘,具体内容见顶层 claims)→③类型匹配→④分值理由;分值须与覆盖自洽(硬规则14)
|
||
|
||
主语/层级不对 → 单条 **0.45**,不得因讨论提及相同通路给 0.78:
|
||
引用处 claim 为「化合物 X 经 PI3K/AKT 等机制 demonstrated…」,文献为其他植物提取物或计算预测、仅在讨论转引他人 X 机制 → 0.45,weakly_related,is_relevant=0。
|
||
|
||
机制文引用流行病学句 → 单条 **0.45**,不得 0.92:
|
||
文献为 CRC 机制研究,引用处 claim 为全球高发/死亡率,文献无流行病学数据 → 0.45,minimal_relevance,is_relevant=0。
|
||
|
||
==================================================
|
||
【三、联合引用 combined_*(写在 JSON 顶层,只出现一次)】
|
||
当 cite_group_refs 为 "1,2" 等多篇时,除逐篇判断外,必须在 JSON **顶层**给出引用组整体结论(不要写入 results 各条):
|
||
- 这些文献合起来,是否足以支撑/匹配该引用位置的整体表述?
|
||
- combined_relevance_score:八档固定分值之一,**不是单条平均分**
|
||
- 若一篇已强相关、其余仅弱补充,联合分可接近主相关文献,但**不必等于最高单条分**
|
||
- 若原句含具体列举项(学名等)且材料未逐一核实,联合分通常 **0.85**,不给 0.98
|
||
- 若核心 claim 无任何文献明确覆盖,联合分不能虚高
|
||
- 多篇联合仍缺主语对齐、缺原句点名通路/结局、或主要靠讨论转引 → 联合分通常 **≤0.45~0.65**,不得因单篇讨论出现相同关键词给到 0.78+
|
||
- combined_reason:仅中文综合结论(禁止 combined_reason_en、【English】),只写一次;须说明各篇如何分工互补、整句 claim 覆盖完整度、联合分为何高于/低于最高单条分
|
||
|
||
单条引用时:顶层 combined_* 与单条一致;combined_reason 可与 reason 相同。
|
||
|
||
**联合引用 ≥5 篇时**:优先保证 results 条数完整;为防截断每条 reason ≤120 字、顶层 combined_reason ≤180 字,但仍须**点名各 Claim 覆盖**(哪几条✔/部分/✘),不可只写"部分相关"。
|
||
|
||
==================================================
|
||
【四、评分与等级对照】
|
||
0.98 / 0.92 / 0.85 = highly_related
|
||
文献直接支持整句主旨,大部分关键要素都在文中明确出现
|
||
0.78 / 0.65 = partially_related
|
||
文献只支撑其中一部分,或支撑方式偏间接
|
||
0.45 = weakly_related
|
||
只是同领域文献,但与句子事实对应很弱
|
||
0.25 / 0.15 = unrelated
|
||
基本不支撑该句
|
||
≤0.15 = not_support
|
||
不支撑
|
||
|
||
==================================================
|
||
【五、输出 JSON(仅 JSON,无 markdown)】
|
||
先拆解 Claim,在顶层 `claims` 写出各条具体内容(键为 A/B/C…,值为中文一句话);再逐篇输出 results。
|
||
{
|
||
"cite_group_refs": "1,2",
|
||
"claims": {
|
||
"A": "该理论/概念仍具当代价值而非仅历史遗产",
|
||
"B": "提供概念框架或概念资源",
|
||
"C": "帮助描述研究/护理/临床现象",
|
||
"D": "组织专业推理或解释情境",
|
||
"E": "指导实践设计与为行动提供依据"
|
||
},
|
||
"combined_relevance_score": 0.92,
|
||
"combined_reason": "[1] 系统覆盖 A/B/C,[2] 补充 D/E。整句各项 Claim 由两篇互补覆盖、无明显缺口;联合略高于单篇因分工互补,故联合 0.92。",
|
||
"results": [
|
||
{
|
||
"reference_no": 1,
|
||
"is_relevant": 0,
|
||
"relevance_score": 0.45,
|
||
"reason": "系统综述,主语一致。Claim覆盖:A✔ B✔ C部分 D✘。类型部分匹配。因缺 D 且 C 仅背景,未达 0.65,故 0.45。",
|
||
"author_comment": "该处主要讨论急诊护士继发性创伤应激的影响因素(如护理压力、共情与心理韧性),文献似乎未直接涉及正文所述焦虑、抑郁、情绪耗竭、职业倦怠及认知与决策能力下降等内容。建议替换相应编号文献以更直接支持上述表述,或酌情调整该句,使引用内容与文献证据保持一致。"
|
||
},
|
||
{
|
||
"reference_no": 2,
|
||
"is_relevant": 1,
|
||
"relevance_score": 0.92,
|
||
"reason": "综述,主语一致。Claim覆盖:A✔ B✔ D✔ E部分。类型完全匹配。多数关键要素明确覆盖、仅 E 略间接,故 0.92。",
|
||
"author_comment": ""
|
||
}
|
||
]
|
||
}
|
||
|
||
**顶层 claims**:本引用组共用,只写一次;键为 A/B/C…(按实际拆解数量),值为各 Claim 中文具体内容。
|
||
**results 每条仅含**:reference_no、is_relevant、relevance_score、reason、author_comment。
|
||
`author_comment` 规则:当 `relevance_score <= 0.65` 时必须生成(中文、**资深期刊编辑与作者沟通的委婉审稿口吻**,避免生硬判定与命令式,**100–150字**,句尾需句号);当 `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.15;is_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_comment:score<=0.65 时 80–120 字委婉建议(可替换编号文献或调整句子);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 时输出委婉建议性中文批注(资深编辑与作者沟通口吻,避免生硬判定与命令式;结构为①客观概述该处正文主张→②委婉指出文献差距,用“似乎未直接涉及/侧重点略有不同”等→③给出两条可选建议:替换相应编号文献 或 酌情调整该句使引用与文献一致;100–150字,句尾需句号);语气用“建议/似可/不妨/可考虑”,禁止“必须/应当/务必”;禁止“补充/新增/增加文献”,一律改为“替换相应编号文献/改引”,以保持参考文献编号不变;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 条 results;reason ≤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.65),combined_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;
|
||
}
|
||
}
|