一处原文引用20多个参考文献优化

This commit is contained in:
wyn
2026-08-04 17:11:53 +08:00
parent 485d7c8d97
commit 32fb903c5d
4 changed files with 559 additions and 106 deletions

View File

@@ -837,7 +837,10 @@ class ReferenceRelevanceCheckService
/**
* 修复卡住队列:已完成但 queue 未同步;长时间 RUNNING 回退为待执行
*/
public function recoverQueueRowsForArticle($pArticleId)
/**
* @param bool $forceRunning 为 true 时立即回收本篇仍为 RUNNING 的 pending 行(断点续跑用),不再等 10 分钟
*/
public function recoverQueueRowsForArticle($pArticleId, $forceRunning = false)
{
$pArticleId = intval($pArticleId);
if ($pArticleId <= 0) {
@@ -853,17 +856,19 @@ class ReferenceRelevanceCheckService
'queue_status' => self::QUEUE_COMPLETED,
'updated_at' => $now,
]);
// 仅回收“长时间未更新”的 RUNNING,避免多消费者并发时把正在执行的任务误回退成 PENDING
$runningStaleBefore = date('Y-m-d H:i:s', time() - 600);
Db::name('article_reference_relevance_check_result')
// 默认仅回收“长时间未更新”的 RUNNINGforce 时立刻回退,从卡死条继续
$runningQuery = Db::name('article_reference_relevance_check_result')
->where('p_article_id', $pArticleId)
->where('queue_status', self::QUEUE_RUNNING)
->where('status', self::RECORD_PENDING)
->where('updated_at', '<', $runningStaleBefore)
->update([
'queue_status' => self::QUEUE_PENDING,
'updated_at' => $now,
]);
->where('status', self::RECORD_PENDING);
if (!$forceRunning) {
$runningStaleBefore = date('Y-m-d H:i:s', time() - 600);
$runningQuery->where('updated_at', '<', $runningStaleBefore);
}
$runningQuery->update([
'queue_status' => self::QUEUE_PENDING,
'updated_at' => $now,
]);
$staleBefore = date('Y-m-d H:i:s', time() - 600);
Db::name('article_reference_relevance_check_result')
->where('p_article_id', $pArticleId)

View File

@@ -9,6 +9,7 @@ use app\common\ReferenceRelevanceCheckService;
/**
* RabbitMQ 消费(队列 reference_check / ref_check.article
* 全局文章串行,文章内 reference_no 升序链式逐条「主题相关性」校对。
* 支持断点续跑:已完成条跳过,从卡死的 pending 条继续。
*/
class ReferenceCheckArticleWorker
{
@@ -17,6 +18,9 @@ class ReferenceCheckArticleWorker
const BATCH_DONE = 2;
const BATCH_PARTIAL_FAILED = 3;
/** 批次/心跳超时:超过该秒数无 updated_at 更新视为僵尸,可抢占续跑 */
const BATCH_STALE_SECONDS = 1200;
/** @var ReferenceRelevanceCheckService */
private $svc;
@@ -36,6 +40,9 @@ class ReferenceCheckArticleWorker
return;
}
// 先释放其它文章上的僵尸 RUNNING避免全局串行永久堵死
$this->recoverStaleForeignBatches($batchId);
if (!$this->canStartArticleWork($batchId)) {
$this->svc->log('ReferenceCheckArticleWorker defer batch_id=' . $batchId . ' other article running');
(new ReferenceCheckMqPublisher())->publishArticleStart(
@@ -47,95 +54,206 @@ class ReferenceCheckArticleWorker
return;
}
if (!$this->claimBatch($batchId)) {
$batch = $this->getBatch($batchId);
// 已被其他消费者领取或已结束,当前消息直接跳过,避免同批次并发重复执行
if (empty($batch)
|| intval($batch['batch_status']) === self::BATCH_RUNNING
|| intval($batch['batch_status']) === self::BATCH_DONE
|| intval($batch['batch_status']) === self::BATCH_PARTIAL_FAILED) {
return;
$claim = $this->claimOrResumeBatch($batchId);
if ($claim === 'skip') {
return;
}
$resume = ($claim === 'resume');
$owned = true;
$finished = false;
try {
// 续跑时强制把卡死行收回 pending已完成行不动
$this->svc->recoverQueueRowsForArticle($pArticleId, $resume);
if ($trigger !== 'recheck_pending_only'
&& ReferenceRelevanceCheckService::PREPARE_LITERATURE_BEFORE_CHECK) {
$this->svc->prepareLiteratureContentByArticle($pArticleId);
}
$this->svc->log(
'ReferenceCheckArticleWorker start p_article_id=' . $pArticleId
. ' batch_id=' . $batchId
. ($resume ? ' resume=1' : '')
);
while (true) {
$row = $this->fetchNextPendingRow($pArticleId);
if (empty($row)) {
break;
}
$checkId = $this->svc->resolveCheckRowId($row);
if ($checkId <= 0) {
continue;
}
$this->processOneRow($checkId, $row, $trigger === 'recheck_pending_only');
// 每条结束后刷新批次心跳,长文不会被误判为僵尸
$this->touchBatch($batchId);
}
$stats = $this->summarizeArticleCheckStats($pArticleId);
$this->finalizeBatch($batchId, $stats['done'], $stats['failed'], $stats['total']);
$finished = true;
$this->svc->log(
'ReferenceCheckArticleWorker done p_article_id=' . $pArticleId
. ' batch_id=' . $batchId
. ' done=' . $stats['done']
. ' failed=' . $stats['failed']
);
$this->publishNextWaitingBatch();
} catch (\Throwable $e) {
// 异常不 finalize保持 RUNNING靠心跳超时后由后续消息断点续跑
if ($owned) {
$this->touchBatch($batchId);
$this->svc->log(
'ReferenceCheckArticleWorker abort batch_id=' . $batchId
. ' p_article_id=' . $pArticleId
. ' err=' . $e->getMessage()
);
}
throw $e;
} finally {
if ($owned && !$finished) {
// 消息进 DLQ 后主队列可能没人再推本批:主动再投递,便于稍后续跑
try {
(new ReferenceCheckMqPublisher())->publishArticleStart($pArticleId, $batchId, $trigger);
} catch (\Exception $pubErr) {
$this->svc->log('ReferenceCheckArticleWorker republish after abort failed: ' . $pubErr->getMessage());
}
}
}
$this->svc->recoverQueueRowsForArticle($pArticleId);
if ($trigger !== 'recheck_pending_only'
&& ReferenceRelevanceCheckService::PREPARE_LITERATURE_BEFORE_CHECK) {
$this->svc->prepareLiteratureContentByArticle($pArticleId);
}
$this->svc->log('ReferenceCheckArticleWorker start p_article_id=' . $pArticleId . ' batch_id=' . $batchId);
// 快照本批待处理 id联合引用组长一次会整组落库循环计数会小于 total_count收尾按快照回填
$trackedIds = $this->listPendingCheckIds($pArticleId);
$done = 0;
$failed = 0;
while (true) {
$row = $this->fetchNextPendingRow($pArticleId);
if (empty($row)) {
break;
}
$checkId = $this->svc->resolveCheckRowId($row);
if ($checkId <= 0) {
continue;
}
$result = $this->processOneRow($checkId, $row, $trigger === 'recheck_pending_only');
if ($result === 'ok') {
$done++;
} elseif ($result === 'failed') {
$failed++;
}
}
if (!empty($trackedIds)) {
$stats = $this->summarizeTrackedCheckIds($trackedIds);
$done = intval($stats['done']);
$failed = intval($stats['failed']);
}
$this->finalizeBatch($batchId, $done, $failed);
$this->svc->log('ReferenceCheckArticleWorker done p_article_id=' . $pArticleId . ' batch_id=' . $batchId . ' done=' . $done . ' failed=' . $failed);
$this->publishNextWaitingBatch();
}
private function listPendingCheckIds($pArticleId)
/**
* @return string claim|resume|skip
*/
private function claimOrResumeBatch($batchId)
{
$batchId = intval($batchId);
$now = date('Y-m-d H:i:s');
$claimed = Db::name('article_reference_relevance_check_batch')
->where('id', $batchId)
->where('batch_status', self::BATCH_WAITING)
->update([
'batch_status' => self::BATCH_RUNNING,
'updated_at' => $now,
]);
if (intval($claimed) > 0) {
return 'claim';
}
$batch = $this->getBatch($batchId);
if (empty($batch)) {
return 'skip';
}
$status = intval($batch['batch_status']);
if ($status === self::BATCH_DONE || $status === self::BATCH_PARTIAL_FAILED) {
return 'skip';
}
if ($status === self::BATCH_RUNNING) {
if ($this->isBatchStale($batch)) {
// 僵尸 RUNNING抢占续跑不重置已完成明细
Db::name('article_reference_relevance_check_batch')
->where('id', $batchId)
->where('batch_status', self::BATCH_RUNNING)
->update(['updated_at' => $now]);
$this->svc->log('ReferenceCheckArticleWorker reclaim stale batch_id=' . $batchId);
return 'resume';
}
// 仍有心跳,说明别的消费者在跑本批
return 'skip';
}
return 'skip';
}
private function isBatchStale(array $batch)
{
$updatedAt = isset($batch['updated_at']) ? strtotime((string)$batch['updated_at']) : 0;
if ($updatedAt <= 0) {
return true;
}
return (time() - $updatedAt) >= self::BATCH_STALE_SECONDS;
}
private function touchBatch($batchId)
{
Db::name('article_reference_relevance_check_batch')
->where('id', intval($batchId))
->where('batch_status', self::BATCH_RUNNING)
->update(['updated_at' => date('Y-m-d H:i:s')]);
}
/**
* 其它文章僵尸 RUNNING → 改回 WAITING 并重新入队,从卡死条续跑
*/
private function recoverStaleForeignBatches($exceptBatchId)
{
$exceptBatchId = intval($exceptBatchId);
$staleBefore = date('Y-m-d H:i:s', time() - self::BATCH_STALE_SECONDS);
$rows = Db::name('article_reference_relevance_check_batch')
->where('batch_status', self::BATCH_RUNNING)
->where('id', '<>', $exceptBatchId)
->whereRaw('(updated_at IS NULL OR updated_at < ?)', [$staleBefore])
->order('id asc')
->limit(20)
->select();
if (empty($rows)) {
return;
}
$publisher = new ReferenceCheckMqPublisher();
foreach ($rows as $row) {
$bid = intval($row['id']);
$pid = intval($row['p_article_id']);
$affected = Db::name('article_reference_relevance_check_batch')
->where('id', $bid)
->where('batch_status', self::BATCH_RUNNING)
->whereRaw('(updated_at IS NULL OR updated_at < ?)', [$staleBefore])
->update([
'batch_status' => self::BATCH_WAITING,
'updated_at' => date('Y-m-d H:i:s'),
]);
if (intval($affected) <= 0) {
continue;
}
// 强制收回卡死行;已完成条保持不动
$this->svc->recoverQueueRowsForArticle($pid, true);
$this->svc->log('ReferenceCheckArticleWorker recover stale foreign batch_id=' . $bid . ' p_article_id=' . $pid);
try {
$publisher->publishArticleStart(
$pid,
$bid,
isset($row['trigger']) ? $row['trigger'] : 'enqueue'
);
} catch (\Exception $e) {
$this->svc->log('ReferenceCheckArticleWorker recover publish failed batch_id=' . $bid . ' err=' . $e->getMessage());
}
}
}
private function summarizeArticleCheckStats($pArticleId)
{
$rows = Db::name('article_reference_relevance_check_result')
->where('p_article_id', intval($pArticleId))
->where('queue_status', ReferenceRelevanceCheckService::QUEUE_PENDING)
->where('status', ReferenceRelevanceCheckService::RECORD_PENDING)
->field('id')
->select();
$ids = [];
foreach ($rows as $row) {
$id = intval(isset($row['id']) ? $row['id'] : 0);
if ($id > 0) {
$ids[] = $id;
}
}
return $ids;
}
private function summarizeTrackedCheckIds(array $checkIds)
{
$checkIds = array_values(array_filter(array_map('intval', $checkIds)));
if (empty($checkIds)) {
return ['done' => 0, 'failed' => 0];
}
$rows = Db::name('article_reference_relevance_check_result')
->whereIn('id', $checkIds)
->field('id,status')
->field('status')
->select();
$done = 0;
$failed = 0;
$pending = 0;
foreach ($rows as $row) {
$st = intval(isset($row['status']) ? $row['status'] : -1);
if ($st === ReferenceRelevanceCheckService::RECORD_COMPLETED) {
$done++;
} elseif ($st === ReferenceRelevanceCheckService::RECORD_FAILED) {
$failed++;
} elseif ($st === ReferenceRelevanceCheckService::RECORD_PENDING) {
$pending++;
}
}
return ['done' => $done, 'failed' => $failed];
return [
'done' => $done,
'failed' => $failed,
'pending' => $pending,
'total' => $done + $failed + $pending,
];
}
private function canStartArticleWork($batchId)
@@ -147,20 +265,6 @@ class ReferenceCheckArticleWorker
return intval($running) === 0;
}
private function claimBatch($batchId)
{
$now = date('Y-m-d H:i:s');
$affected = Db::name('article_reference_relevance_check_batch')
->where('id', intval($batchId))
// 只允许 WAITING -> RUNNING禁止已 RUNNING 的批次被重复 claim
->where('batch_status', self::BATCH_WAITING)
->update([
'batch_status' => self::BATCH_RUNNING,
'updated_at' => $now,
]);
return intval($affected) > 0;
}
private function getBatch($batchId)
{
return Db::name('article_reference_relevance_check_batch')->where('id', intval($batchId))->find();
@@ -185,7 +289,10 @@ class ReferenceCheckArticleWorker
$claimed = Db::name('article_reference_relevance_check_result')
->where('id', intval($checkId))
->where('queue_status', ReferenceRelevanceCheckService::QUEUE_PENDING)
->update(['queue_status' => ReferenceRelevanceCheckService::QUEUE_RUNNING]);
->update([
'queue_status' => ReferenceRelevanceCheckService::QUEUE_RUNNING,
'updated_at' => date('Y-m-d H:i:s'),
]);
if (intval($claimed) <= 0) {
return 'skip';
}
@@ -223,16 +330,18 @@ class ReferenceCheckArticleWorker
}
}
private function finalizeBatch($batchId, $done, $failed)
private function finalizeBatch($batchId, $done, $failed, $total = 0)
{
$batch = $this->getBatch($batchId);
if (empty($batch)) {
return;
}
$total = intval($batch['total_count']);
$done = intval($done);
$failed = intval($failed);
// 快照回填后若实际终态条数多于入队 total抬升 total 保持一致
$total = intval($total);
if ($total <= 0) {
$total = intval($batch['total_count']);
}
if (($done + $failed) > $total) {
$total = $done + $failed;
}

View File

@@ -19,6 +19,8 @@ class ReferenceRelevanceLlmService
private $maxReferChars;
private $maxAbstractChars;
private $maxTokens;
/** 本次校对的联合引用组总篇数(分块时仍为整组篇数,供单篇分摊评分使用) */
private $groupRefCount = 0;
public function __construct()
{
@@ -72,6 +74,7 @@ class ReferenceRelevanceLlmService
}
$refCount = $this->countCiteGroupRefs($citeGroupRefs);
$this->groupRefCount = $refCount;
// 默认每批最多 4 篇,降低单次排队/超时风险(可用 env 覆盖)
$maxRefsPerCall = max(2, intval(Env::get('promotion.relevance_llm_max_refs_per_call', 4)));
if ($refCount > $maxRefsPerCall) {
@@ -111,7 +114,8 @@ class ReferenceRelevanceLlmService
$citeGroupRefs,
array $referTypeMap,
$refCount,
array $fallback
array $fallback,
array $fixedClaims = []
) {
$payload = [
'model' => $this->model,
@@ -119,7 +123,7 @@ class ReferenceRelevanceLlmService
'max_tokens' => $this->resolveMaxTokens($refCount),
'messages' => [
['role' => 'system', 'content' => $this->buildSystemPrompt()],
['role' => 'user', 'content' => $this->buildUserPrompt($sectionText, $localContext, $referText, $abstractText, $citeGroupRefs, $refCount, $referTypeMap)],
['role' => 'user', 'content' => $this->buildUserPrompt($sectionText, $localContext, $referText, $abstractText, $citeGroupRefs, $refCount, $referTypeMap, $fixedClaims)],
],
];
@@ -258,7 +262,8 @@ class ReferenceRelevanceLlmService
- 不得因摘要提到 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;联合分不得远高于最高单条分(禁止「单条全弱、联合高分」)。
**联合分不得与单条分矛盾**:若全部单条均为 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**不得**因联合而三篇都给高分
@@ -301,7 +306,14 @@ class ReferenceRelevanceLlmService
- 仅间接沾边、层级或主语明显不符 → **≤0.65**
- **联合分**:多篇经典教材已系统覆盖全部并列功能时,联合可 **0.98**,不得因个别子类/哲学文献偏窄而压低整体
14. **分值必须与你写出的 Claim 覆盖自洽(写完 reason 后必须自检;通用规则,适用所有文献类型)**
- 先按你写出的覆盖标注计算「覆盖比例」= (完整✔数 + 0.5×部分数) / Claim总数,再据此定分:
- **先判本组是单独引用还是联合引用**cite_group_refs 含多篇时按下方「联合引用分摊原则」评分;仅 1 篇时才按整段 Claim 总数算覆盖比例。
- **联合引用分摊原则(多篇联合时优先适用)**:整段 Claim 由组内各篇**分工覆盖****不要求任何单篇覆盖全部 Claim**。
- 单篇评分只看「**它覆盖的那几项 Claim 支撑得是否充分**」,**不看**「占整段 Claim 的比例」
- **主语一致 + 完整✔至少 1 项 Claim** → 单篇 **不低于 0.85**(已充分承担分工),**严禁**因未覆盖他篇负责的 Claim 而降到 0.45/0.25
- 主语一致但只有「部分」、无任何完整✔ → **0.65**
- 单篇**上限 0.85**:只承担整段一两项分工者不给 0.92/0.980.92+ 留给单篇即覆盖整段绝大多数 Claim 的情形)
- 组内多篇覆盖同一项 Claim 属**重复引用**,各篇分值照常给,但须在 combined_reason 点名
- 单独引用(组内仅 1 篇)时,按你写出的覆盖标注计算「覆盖比例」= (完整✔数 + 0.5×部分数) / Claim总数再据此定分
- 覆盖比例 ≥0.9 且无✘ → **0.92~0.98**(系统/全部覆盖=0.98
- ≥0.72 → **0.92**≥0.58 → **0.85**≥0.45 → **0.78**≥0.28 → **0.65**≥0.15 → **0.45**;几乎全✘/无覆盖 → 0.25/0.15
- **0.25/0.15 仅用于「主语不一致」或「几乎完全无覆盖(无任何✔/部分)」**。只要**主语一致**且 reason 写出**任意 ✔ 或部分**,就**禁止**给 0.25,至少 **0.45**。
@@ -327,6 +339,14 @@ class ReferenceRelevanceLlmService
- **典型示例(多事实背景句 + 单篇原始研究)**引用处「HF诊疗取得进展 + 老年HF患者QOL较低 + 低于其他慢病老人 + 因长疗程/并发症/急性加重」,文献为 Lee & Song 2015 SEM 症状管理与QOL研究
- Claim 应拆为A诊疗进展✘、B HF患者QOL相关✔或部分、C 低于其他慢病比较✘、D 具体成因列举部分或✘
- 覆盖约 35%~45% → **0.65partially_related****不得**因「都研究HF+QOL」给 0.92**不得**因有多项✘就给 0.25
18. **联合分按「整组 Claim 覆盖并集」定档(联合引用必用)**
- 把组内各篇的 ✔/部分**取并集**:某项 Claim 只要有任一篇 ✔ 即记完整,只有「部分」记 0.5,无任何篇覆盖记 ✘
- 整组覆盖率 = (并集完整数 + 0.5×并集部分数) / Claim 总数,据此定档:
全部 Claim 均被覆盖 → **0.92~0.98**≥0.72 → **0.85**≥0.58 → **0.78**≥0.28 → **0.65**;更低按实际
- **联合分与单篇分母不同**单篇按分工评规则14联合按整段覆盖评单篇偏低不构成压低联合分的理由
- **combined_reason 必须点名两件事**(这是本次校对对作者最有价值的输出):
①**无人覆盖的 Claim** → 写「X 暂无文献支撑」,提示作者该处需替换/改引
②**被多篇重复覆盖的 Claim** → 写「X 由文献 a、b、c 重复覆盖」,提示引用堆砌可精简
==================================================
【一、必须先拆解 claim】
@@ -489,7 +509,7 @@ Claim 具体内容见顶层 `claims`combined_reason **只用字母指代**
PROMPT;
}
private function buildUserPrompt($sectionText, $localContext, $referText, $abstractText, $citeGroupRefs, $refCount = 0, array $referTypeMap = [])
private function buildUserPrompt($sectionText, $localContext, $referText, $abstractText, $citeGroupRefs, $refCount = 0, array $referTypeMap = [], array $fixedClaims = [])
{
$parts = ["【正文节 t_article_main】\n" . $sectionText];
if (trim((string)$citeGroupRefs) !== '') {
@@ -499,6 +519,14 @@ PROMPT;
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;
@@ -534,6 +562,9 @@ PROMPT;
} else {
$tail .= ' 单条引用reason ≤220 字,点名各 Claim 覆盖与分值依据;顶层 combined_* 与单条一致。';
}
if ($refCount > 1) {
$tail .= ' 联合引用分摊原则(务必遵守):整段 Claim 由各篇分工覆盖,单篇只要主语一致且完整✔至少 1 项 Claim 即不低于 0.85(上限 0.85),严禁因未覆盖他篇负责的 Claim 而降到 0.45/0.25;仅「部分」覆盖则 0.65。顶层 combined_relevance_score 按各篇覆盖并集算整组覆盖率定档(全覆盖 0.92~0.98、≥0.72 给 0.85、≥0.58 给 0.78、≥0.28 给 0.65combined_reason 必须点名「哪些 Claim 无任何文献支撑」与「哪些 Claim 被多篇重复覆盖(写明文献编号)」。';
}
$parts[] = $tail;
return implode("\n\n", $parts);
@@ -732,7 +763,8 @@ PROMPT;
$chunkRefs,
$chunkTypeMap,
count($chunk),
$fallback
$fallback,
$claims
);
if (!empty($part['request_failed']) || empty($part['results'])) {
$reason = isset($part['reason']) ? (string)$part['reason'] : 'LLM split batch failed';
@@ -771,6 +803,12 @@ PROMPT;
if (count($results) > 1) {
$bands = $this->getScoreBands();
$adjustedCombined = $this->enforceCombinedAgainstSingles($results, $combinedScore, $bands);
// 各批只看到局部,联合分与说明按全组覆盖并集重算
$coverage = $this->summarizeGroupCoverage($results);
if ($this->maxSingleRelevanceScore($results) >= 0.65 - 0.001
&& $coverage['floor'] > $adjustedCombined) {
$adjustedCombined = $coverage['floor'];
}
if ($combinedScore <= 0 || abs($adjustedCombined - $combinedScore) > 0.001) {
$combinedScore = $adjustedCombined;
$combinedReason = $this->fallbackReasonFromScore(
@@ -778,6 +816,7 @@ PROMPT;
$this->levelFromScore($combinedScore)
);
}
$combinedReason = $this->appendGroupCoverageNote($combinedReason, $coverage);
} elseif (count($results) === 1) {
$combinedScore = floatval($results[0]['relevance_score'] ?? 0);
$combinedReason = (string)($results[0]['reason'] ?? '');
@@ -939,6 +978,18 @@ PROMPT;
$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(
@@ -1070,8 +1121,16 @@ PROMPT;
}
}
// 联合分按整组覆盖并集定档:各篇分工覆盖时不受单篇分母稀释拖累
$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,
@@ -1372,6 +1431,217 @@ PROMPT;
];
}
/**
* 解析 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;
}
/**
* 从联合文献块中提取指定编号的摘要/清洗内容。
*/