参考文献模型校对换阿里云百炼,完善各种校对细节

This commit is contained in:
wyn
2026-08-06 10:14:36 +08:00
parent f4ebd10bfa
commit bffac7755d
7 changed files with 1551 additions and 135 deletions

View File

@@ -454,66 +454,377 @@ class ReferenceRelevanceCheckService
if (!empty($fresh) && intval($fresh['status']) === self::RECORD_COMPLETED) {
return $this->formatReturnFromRow($fresh);
}
throw new \RuntimeException('Citation group leader not finished, reference_no=' . $leaderRefNo);
// 组长已完成时,允许任一 pending 成员继续跑剩余分块(断点续跑)
$leaderCompleted = false;
foreach ($groupRows as $gr) {
if (intval($gr['reference_no']) === $leaderRefNo
&& intval($gr['status']) === self::RECORD_COMPLETED) {
$leaderCompleted = true;
break;
}
}
if (!$leaderCompleted) {
// 组员不应单独跑;由 worker 跳过,等组长处理
throw new \RuntimeException(
'Citation group leader not finished, reference_no=' . $leaderRefNo,
9001
);
}
}
}
// 只跑尚未完成的编号;已落库的成功块不再重跑
$pendingGroupRows = [];
foreach ($groupRows as $gr) {
if (intval($gr['status']) !== self::RECORD_COMPLETED) {
$pendingGroupRows[] = $gr;
}
}
if (empty($pendingGroupRows)) {
$fresh = Db::name('article_reference_relevance_check_result')->where('id', $checkId)->find();
return $this->formatReturnFromRow(!empty($fresh) ? $fresh : $row);
}
DbReconnectHelper::release();
DbReconnectHelper::ensure();
$sectionText = $this->refUtil->resolveMainContentForJob($row);
$localContext = $this->resolveLocalContextForJob($row);
$citeGroupRefs = $this->resolveCiteGroupRefs($row, $groupRows);
$referText = $this->buildCombinedReferText($groupRows);
$referTypeMap = $this->resolveReferTypeMap($groupRows);
$citeGroupRefs = $this->buildCiteGroupRefsFromRows($pendingGroupRows);
$referText = $this->buildCombinedReferText($pendingGroupRows);
$referTypeMap = $this->resolveReferTypeMap($pendingGroupRows);
if ($skipLiteratureFetch) {
$literatureBundle = $this->resolveGroupLiteratureBundle($groupRows, $referTypeMap, false);
$literatureBundle = $this->resolveGroupLiteratureBundle($pendingGroupRows, $referTypeMap, false);
$abstractText = $literatureBundle['combined_text'];
} else {
// 优先读 t_production_article_refer摘要与清洗内容都为空时再抓取并回写 refer 表
DbReconnectHelper::release();
$literatureBundle = $this->resolveGroupLiteratureBundle($groupRows, $referTypeMap, true);
$literatureBundle = $this->resolveGroupLiteratureBundle($pendingGroupRows, $referTypeMap, true);
$abstractText = $literatureBundle['combined_text'];
DbReconnectHelper::ensure();
}
$noLiteratureEvidence = !$literatureBundle['has_verification_evidence'];
if ($noLiteratureEvidence && trim((string)$abstractText) === '') {
// 无摘要/全文时,退化为仅基于参考文献书目信息校对,并打标识供前端区分
$abstractText = "【文献书目信息(无摘要/全文)】\n" . $referText;
}
if ($sectionText === '' || $referText === '') {
$msg = 'Missing section content or refer_text';
$this->failGroupWithQueue($groupRows, $msg);
$this->failGroupWithQueue($pendingGroupRows, $msg);
throw new \RuntimeException($msg);
}
$llmService = new ReferenceRelevanceLlmService();
$appliedInCallback = 0;
$pArticleIdForHeartbeat = intval($row['p_article_id']);
$onChunkDone = function (array $part) use ($groupRows, &$appliedInCallback, $llmService, $pArticleIdForHeartbeat) {
DbReconnectHelper::ensure();
$n = $this->applyPartialGroupResults($groupRows, $part);
$appliedInCallback += $n;
if ($n > 0) {
$this->refreshGroupCombinedFields($groupRows, $llmService);
}
// 分块心跳:避免超大联合组单次跑超过僵尸阈值
if ($pArticleIdForHeartbeat > 0) {
Db::name('article_reference_relevance_check_batch')
->where('p_article_id', $pArticleIdForHeartbeat)
->where('batch_status', 1)
->update(['updated_at' => date('Y-m-d H:i:s')]);
}
$this->log(sprintf(
'relevance chunk persisted applied=%d',
$n
));
};
DbReconnectHelper::release();
$llm = (new ReferenceRelevanceLlmService())->checkRelevance(
$llm = $llmService->checkRelevance(
$sectionText,
$localContext,
$referText,
$abstractText,
$citeGroupRefs,
$referTypeMap
$referTypeMap,
$onChunkDone
);
DbReconnectHelper::ensure();
if (!empty($llm['request_failed']) || !$this->applyGroupResults($groupRows, $llm)) {
// 回调未覆盖的结果(单次调用或回调异常时)再补写一次
$applied = $appliedInCallback;
if (!empty($llm['results']) && is_array($llm['results'])) {
$applied += $this->applyPartialGroupResults($groupRows, $llm);
}
if ($applied <= 0 && !empty($llm['request_failed'])) {
$msg = isset($llm['reason']) ? (string)$llm['reason'] : 'LLM failed or empty results';
$this->failGroupWithQueue($groupRows, $msg);
// 逐篇/分块:单次 LLM 失败不整组标 failed留 pending 续跑
$this->resetIncompleteGroupToPending($groupRows, $msg);
throw new \RuntimeException($msg);
}
$combinedOverride = [];
if (!empty($llm['combined_locked'])) {
$combinedOverride = [
'combined_relevance_score' => $llm['combined_relevance_score'] ?? 0,
'combined_reason' => $llm['combined_reason'] ?? '',
'combined_author_comment' => $llm['combined_author_comment'] ?? '',
];
}
$this->refreshGroupCombinedFields($groupRows, $llmService, $combinedOverride);
if ($noLiteratureEvidence) {
$this->markGroupNoLiteratureEvidence($groupRows);
$completedRows = [];
foreach ($this->findCitationGroupRows($row) as $gr) {
if (intval($gr['status']) === self::RECORD_COMPLETED) {
$completedRows[] = $gr;
}
}
if (!empty($completedRows)) {
$this->markGroupNoLiteratureEvidence($completedRows);
}
}
$this->markGroupQueueRuntime($groupRows, self::QUEUE_COMPLETED);
// 仍未完成的编号保持 pending供后续重跑只补缺
$stillPending = $this->listIncompleteGroupRows($this->findCitationGroupRows($row));
foreach ($stillPending as $gr) {
$gid = intval($gr['id']);
if ($gid <= 0) {
continue;
}
if (intval($gr['status']) !== self::RECORD_PENDING) {
$this->updateRow($gid, [
'status' => self::RECORD_PENDING,
'error_msg' => isset($llm['reason']) ? mb_substr((string)$llm['reason'], 0, 512) : 'chunk pending retry',
]);
}
$this->markQueueRuntime($gid, self::QUEUE_PENDING);
}
$fresh = Db::name('article_reference_relevance_check_result')->where('id', $checkId)->find();
if (!empty($stillPending)) {
$this->log(sprintf(
'relevance group partial check_id=%d applied=%d still_pending=%d reason=%s',
$checkId,
$applied,
count($stillPending),
isset($llm['reason']) ? (string)$llm['reason'] : ''
));
}
return $this->formatReturnFromRow(!empty($fresh) ? $fresh : $row);
}
private function buildCiteGroupRefsFromRows(array $rows)
{
$nums = [];
foreach ($rows as $gr) {
$n = intval(isset($gr['reference_no']) ? $gr['reference_no'] : 0);
if ($n > 0) {
$nums[$n] = $n;
}
}
$list = array_values($nums);
sort($list, SORT_NUMERIC);
return implode(',', $list);
}
private function listIncompleteGroupRows(array $groupRows)
{
$out = [];
foreach ($groupRows as $gr) {
if (intval($gr['status']) !== self::RECORD_COMPLETED) {
$out[] = $gr;
}
}
return $out;
}
/**
* LLM 单次失败:未完成行保持/恢复 pending不标 failed。
*/
private function resetIncompleteGroupToPending(array $groupRows, $msg = '')
{
$msg = mb_substr(trim((string)$msg), 0, 512);
foreach ($this->listIncompleteGroupRows($groupRows) as $gr) {
$gid = intval($gr['id']);
if ($gid <= 0) {
continue;
}
$this->updateRow($gid, [
'status' => self::RECORD_PENDING,
'error_msg' => $msg,
]);
$this->markQueueRuntime($gid, self::QUEUE_PENDING);
}
}
public function isRelevanceLlmFailureMessage($msg)
{
$msg = (string)$msg;
if ($msg === '') {
return false;
}
$needles = [
'LLM curl error',
'LLM HTTP ',
'LLM failed',
'LLM split batch',
'LLM request failed',
'LLM not configured',
'LLM exception',
'LLM response',
'Operation timed out',
'0 bytes received',
];
foreach ($needles as $n) {
if (stripos($msg, $n) !== false) {
return true;
}
}
return false;
}
/**
* 仅落库 results 里出现的编号;返回成功写入条数。
*/
private function applyPartialGroupResults(array $groupRows, array $llmResponse)
{
$results = isset($llmResponse['results']) && is_array($llmResponse['results'])
? $llmResponse['results'] : [];
if (empty($results)) {
return 0;
}
$combinedScore = floatval(isset($llmResponse['combined_relevance_score']) ? $llmResponse['combined_relevance_score'] : 0);
$combinedReason = trim((string)(isset($llmResponse['combined_reason']) ? $llmResponse['combined_reason'] : ''));
$combinedAuthorComment = $this->resolveCombinedAuthorComment(
$llmResponse,
$combinedScore,
$combinedReason
);
$claimsJson = $this->encodeClaimsJson(isset($llmResponse['claims']) ? $llmResponse['claims'] : []);
$byRef = [];
foreach ($results as $item) {
if (!is_array($item)) {
continue;
}
$refNo = intval(isset($item['reference_no']) ? $item['reference_no'] : 0);
if ($refNo > 0) {
$byRef[$refNo] = $item;
}
}
if (empty($byRef)) {
return 0;
}
$applied = 0;
foreach ($groupRows as $gr) {
$refNo = intval($gr['reference_no']);
$gid = intval($gr['id']);
if ($refNo <= 0 || $gid <= 0 || !isset($byRef[$refNo])) {
continue;
}
$freshStatus = Db::name('article_reference_relevance_check_result')
->where('id', $gid)
->value('status');
// 已完成的不覆盖
if (intval($freshStatus) === self::RECORD_COMPLETED) {
continue;
}
$item = $byRef[$refNo];
$rowCombinedScore = $combinedScore > 0
? $combinedScore
: floatval(isset($item['combined_relevance_score']) ? $item['combined_relevance_score'] : $item['relevance_score']);
$rowCombinedReason = $combinedReason !== ''
? $combinedReason
: (string)(isset($item['combined_reason']) ? $item['combined_reason'] : $item['reason']);
$rowCombinedAuthorComment = $combinedAuthorComment !== ''
? $combinedAuthorComment
: $this->resolveCombinedAuthorComment($item, $rowCombinedScore, $rowCombinedReason);
$this->updateRow($gid, [
'is_relevant' => !empty($item['is_relevant']) ? 1 : 0,
'relevance_score' => floatval($item['relevance_score']),
'reason' => (string)$item['reason'],
'author_comment' => (string)($item['author_comment'] ?? ''),
'combined_relevance_score' => $rowCombinedScore,
'combined_reason' => $rowCombinedReason,
'combined_author_comment' => $rowCombinedAuthorComment,
'claims_json' => $claimsJson,
'status' => self::RECORD_COMPLETED,
'error_msg' => '',
]);
$this->markQueueRuntime($gid, self::QUEUE_COMPLETED);
$applied++;
}
return $applied;
}
/**
* 用组内已完成行重算并回写 combined_* / claims 对齐。
*/
private function refreshGroupCombinedFields(array $groupRows, ReferenceRelevanceLlmService $llmService = null, array $combinedOverride = [])
{
DbReconnectHelper::ensure();
$ids = [];
foreach ($groupRows as $gr) {
$id = intval(isset($gr['id']) ? $gr['id'] : 0);
if ($id > 0) {
$ids[] = $id;
}
}
if (empty($ids)) {
return;
}
$freshRows = Db::name('article_reference_relevance_check_result')
->whereIn('id', $ids)
->select();
$completed = [];
foreach ($freshRows as $gr) {
if (intval($gr['status']) === self::RECORD_COMPLETED) {
$completed[] = [
'reference_no' => intval($gr['reference_no']),
'relevance_score' => floatval($gr['relevance_score']),
'is_relevant' => intval($gr['is_relevant']),
'reason' => (string)$gr['reason'],
];
}
}
if (empty($completed)) {
return;
}
if (!empty($combinedOverride)) {
// 整组程序核验的结论不可由逐篇分数反推覆盖,直接沿用
$combinedScore = floatval($combinedOverride['combined_relevance_score'] ?? 0);
$combinedReason = (string)($combinedOverride['combined_reason'] ?? '');
$combinedAuthorComment = (string)($combinedOverride['combined_author_comment'] ?? '');
if ($combinedAuthorComment === '') {
$combinedAuthorComment = $llmService === null
? (new ReferenceRelevanceLlmService())->buildCombinedAuthorCommentFromReason($combinedScore, $combinedReason)
: $llmService->buildCombinedAuthorCommentFromReason($combinedScore, $combinedReason);
}
} else {
if ($llmService === null) {
$llmService = new ReferenceRelevanceLlmService();
}
$summary = $llmService->rebuildCombinedFromResults($completed);
$combinedScore = floatval($summary['combined_relevance_score']);
$combinedReason = (string)$summary['combined_reason'];
$combinedAuthorComment = (string)($summary['combined_author_comment'] ?? '');
}
foreach ($freshRows as $gr) {
if (intval($gr['status']) !== self::RECORD_COMPLETED) {
continue;
}
$this->updateRow(intval($gr['id']), [
'combined_relevance_score' => $combinedScore,
'combined_reason' => $combinedReason,
'combined_author_comment' => $combinedAuthorComment,
]);
}
}
public function getProgressByPArticleId($pArticleId)
{
$pArticleId = intval($pArticleId);
@@ -581,8 +892,13 @@ class ReferenceRelevanceCheckService
}
$claims = $this->decodeClaimsJson(isset($row['claims_json']) ? $row['claims_json'] : '');
$author_comment = $this->resolveAuthorCommentFromRow($row, $claims);
$author_comment = $author_comment?"<b>[文献 ".intval($row['reference_no'])."]: </b>".$author_comment:$author_comment;
$combined_author_comment = $this->resolveCombinedAuthorCommentFromRow($row);
$author_comment = $this->fillAuthorCommentFromCombined(
$this->resolveAuthorCommentFromRow($row, $claims),
$combined_author_comment,
floatval($row['combined_relevance_score'] ?? 0)
);
$author_comment = $author_comment ? ("<b>[文献 " . intval($row['reference_no']) . "]: </b>" . $author_comment) : $author_comment;
$g['records'][] = [
'check_id' => intval($row['id']),
'reference_no' => intval($row['reference_no']),
@@ -595,6 +911,7 @@ class ReferenceRelevanceCheckService
'author_comment' => $author_comment,
'combined_relevance_score' => floatval($row['combined_relevance_score']),
'combined_reason' => (string)$row['combined_reason'],
'combined_author_comment' => $combined_author_comment,
'cite_group_refs' => (string)$row['cite_group_refs'],
'claims' => $claims,
'evidence_mode' => ((string)($row['score_ceiling_trigger'] ?? '') === 'no_literature_evidence')
@@ -1116,6 +1433,8 @@ class ReferenceRelevanceCheckService
'abstract_text' => $abstract,
'content_text' => $content,
'mesh_terms' => $mesh,
'pub_language' => $bundle['language'] ?? '',
'pub_country' => $bundle['country'] ?? '',
'refer_content_cleaned' => '',
'literature_pdf_url' => $pdfUrl,
'fetch_sources' => $bundle['sources'] ?? [],
@@ -1491,6 +1810,113 @@ class ReferenceRelevanceCheckService
return $this->findCitationGroupRows($row);
}
/**
* 联合引用组:仅组长(最小 reference_no负责跑 LLM组员等组长分块落库。
* 组长已完成但组内仍有 pending 时,允许任一 pending 行续跑剩余分块。
*/
public function shouldProcessRelevanceRowNow(array $row)
{
$groupRows = $this->findCitationGroupRows($row);
if (!$this->isCitationGroupCheck($groupRows)) {
return true;
}
$leaderRefNo = $this->resolveGroupLeaderRefNo($groupRows);
$currentRefNo = intval($row['reference_no']);
if ($currentRefNo === $leaderRefNo) {
return true;
}
foreach ($groupRows as $gr) {
if (intval($gr['reference_no']) !== $leaderRefNo) {
continue;
}
if (intval($gr['status']) === self::RECORD_COMPLETED) {
return true;
}
return false;
}
return true;
}
/**
* 组员无法处理时,若组长卡在 pending+RUNNING 则立即收回(单消费者下即僵尸)
*/
public function recoverStuckGroupLeaderForRow(array $row)
{
$groupRows = $this->findCitationGroupRows($row);
if (!$this->isCitationGroupCheck($groupRows)) {
return false;
}
$leaderRefNo = $this->resolveGroupLeaderRefNo($groupRows);
foreach ($groupRows as $gr) {
if (intval($gr['reference_no']) !== $leaderRefNo) {
continue;
}
if (intval($gr['status']) !== self::RECORD_PENDING) {
return false;
}
if (intval($gr['queue_status']) !== self::QUEUE_RUNNING) {
return false;
}
$leaderId = $this->resolveCheckRowId($gr);
if ($leaderId <= 0) {
return false;
}
$n = Db::name('article_reference_relevance_check_result')
->where('id', $leaderId)
->where('status', self::RECORD_PENDING)
->where('queue_status', self::QUEUE_RUNNING)
->update([
'queue_status' => self::QUEUE_PENDING,
'updated_at' => date('Y-m-d H:i:s'),
]);
return intval($n) > 0;
}
return false;
}
/**
* 收回卡死的 pending+RUNNING单消费者场景下 RUNNING 即异常)
*/
public function recoverStuckRunningPendingRows($pArticleId, $staleSeconds = 180)
{
$pArticleId = intval($pArticleId);
if ($pArticleId <= 0) {
return 0;
}
$staleBefore = date('Y-m-d H:i:s', time() - max(60, intval($staleSeconds)));
return Db::name('article_reference_relevance_check_result')
->where('p_article_id', $pArticleId)
->where('status', self::RECORD_PENDING)
->where('queue_status', self::QUEUE_RUNNING)
->where('updated_at', '<', $staleBefore)
->update([
'queue_status' => self::QUEUE_PENDING,
'updated_at' => date('Y-m-d H:i:s'),
]);
}
/** 无时间门槛:收回本篇所有 pending+RUNNING无可领取行时用 */
public function recoverAllRunningPendingRows($pArticleId)
{
$pArticleId = intval($pArticleId);
if ($pArticleId <= 0) {
return 0;
}
return Db::name('article_reference_relevance_check_result')
->where('p_article_id', $pArticleId)
->where('status', self::RECORD_PENDING)
->where('queue_status', self::QUEUE_RUNNING)
->update([
'queue_status' => self::QUEUE_PENDING,
'updated_at' => date('Y-m-d H:i:s'),
]);
}
private function findCitationGroupRows(array $row)
{
$amId = intval($row['am_id']);
@@ -1658,6 +2084,7 @@ class ReferenceRelevanceCheckService
'author_comment' => '',
'combined_relevance_score' => 0,
'combined_reason' => $reason,
'combined_author_comment' => (new ReferenceRelevanceLlmService())->buildCombinedAuthorCommentFromReason(0, $reason),
'claims_json' => '',
'status' => self::RECORD_COMPLETED,
'queue_status' => self::QUEUE_COMPLETED,
@@ -2077,6 +2504,8 @@ class ReferenceRelevanceCheckService
'abstract_text' => $abstract,
'content_text' => $contentText,
'mesh_terms' => $bundle['mesh_terms'] ?? [],
'pub_language' => $bundle['language'] ?? '',
'pub_country' => $bundle['country'] ?? '',
'refer_content_cleaned' => $cleaned,
'literature_pdf_url' => $pdfUrl,
'fetch_sources' => $bundle['sources'] ?? [],
@@ -2148,7 +2577,7 @@ class ReferenceRelevanceCheckService
if (!empty($pReferIds)) {
DbReconnectHelper::ensure();
$rows = Db::name('production_article_refer')
->field('p_refer_id,refer_type,isbn,refer_doi,doilink,refer_content,refer_frag')
->field('p_refer_id,refer_type,isbn,refer_doi,doilink,refer_content,refer_frag,joura,dateno')
->whereIn('p_refer_id', array_values($pReferIds))
->select();
foreach ($rows as $r) {
@@ -2156,42 +2585,142 @@ class ReferenceRelevanceCheckService
}
}
$metaById = [];
if (!empty($pReferIds)) {
try {
$metaRows = Db::name('production_article_refer_literature')
->field('p_refer_id,pub_language,pub_country,fetch_sources')
->whereIn('p_refer_id', array_values($pReferIds))
->select();
foreach ($metaRows ?: [] as $r) {
$metaById[intval($r['p_refer_id'])] = $r;
}
} catch (\Throwable $e) {
// 迁移未执行时字段不存在,语言/国别核验降级为「元数据缺失」
\think\Log::warning('resolveReferTypeMap literature meta unavailable: ' . $e->getMessage());
}
}
$map = [];
foreach ($groupRows as $gr) {
$refNo = intval($gr['reference_no']);
if ($refNo <= 0) {
continue;
}
$refer = $referById[intval($gr['p_refer_id'] ?? 0)] ?? [];
$type = $this->normalizeReferType($refer, trim((string)($gr['refer_text'] ?? '')));
$pReferId = intval($gr['p_refer_id'] ?? 0);
$refer = $referById[$pReferId] ?? [];
$meta = $metaById[$pReferId] ?? [];
$referText = trim((string)($gr['refer_text'] ?? ''));
$type = $this->normalizeReferType($refer, $referText);
$published = $this->resolvePublishedJournalStatus($type, $refer, $meta, $referText);
$map[$refNo] = [
'type' => $type,
'check_mode' => $type === 'book' ? 'bibliographic_inference' : ($type === 'journal' ? 'abstract_verification' : 'best_effort'),
'type' => $type,
'check_mode' => $type === 'book' ? 'bibliographic_inference' : ($type === 'journal' ? 'abstract_verification' : 'best_effort'),
'language' => strtolower(trim((string)($meta['pub_language'] ?? ''))),
'country' => strtolower(trim((string)($meta['pub_country'] ?? ''))),
'published' => $published['published'],
'published_evidence' => $published['evidence'],
];
}
return $map;
}
/**
* 「已发表期刊论文」判定:类型为 journal 尚不够,还需排除未发表状态,并有正式发表证据。
*
* @return array{published:?bool,evidence:string} published=true 已发表false 非已发表期刊null 证据不足
*/
private function resolvePublishedJournalStatus($type, array $refer, array $litMeta, $referTextFallback = '')
{
if ($type !== 'journal') {
return ['published' => false, 'evidence' => 'not_journal'];
}
$sourceText = trim((string)$referTextFallback);
foreach (['refer_content', 'refer_frag', 'joura', 'dateno'] as $field) {
$sourceText .= ' ' . trim((string)($refer[$field] ?? ''));
}
$sourceText = trim($sourceText);
// 明确未发表 / 投稿中 / 仅在刊前预印
if (preg_match('/\b(?:in\s+press|submitted|unpublished|manuscript|under\s+review|accepted\s+manuscript)\b/i', $sourceText)
|| preg_match('/待发表|已接收未刊|未发表|投稿中/u', $sourceText)) {
return ['published' => false, 'evidence' => 'unpublished_marker'];
}
$sources = strtolower(trim((string)($litMeta['fetch_sources'] ?? '')));
$hasPubmed = (strpos($sources, 'pubmed') !== false)
|| trim((string)($litMeta['pub_language'] ?? '')) !== '';
if ($hasPubmed) {
// PubMed 正式收录(非预印本站)即视为已发表期刊论文;预印本已在 normalizeReferType 剔为 other
return ['published' => true, 'evidence' => 'pubmed'];
}
$dateno = trim((string)($refer['dateno'] ?? ''));
$joura = trim((string)($refer['joura'] ?? ''));
$hasYear = (bool)preg_match('/(19|20)\d{2}/', $dateno !== '' ? $dateno : $sourceText);
// 正式卷期页2021:56:103200 / 1996:23(5):1024-1029 / 2024;23(1):12-18
$hasVolumePages = (bool)preg_match(
'/(?:^|[:;.\s])\d+\s*(?:\([^)]+\))?\s*[:]\s*\d+/',
$dateno !== '' ? $dateno : $sourceText
) || (bool)preg_match(
'/\d+\s*\(\d+\)\s*:\s*\d+/',
$dateno !== '' ? $dateno : $sourceText
);
$hasDoi = trim((string)($refer['refer_doi'] ?? '')) !== ''
|| trim((string)($refer['doilink'] ?? '')) !== ''
|| (bool)preg_match('/\b10\.\d{4,9}\//', $sourceText);
if ($joura !== '' && $hasYear && $hasVolumePages) {
return ['published' => true, 'evidence' => 'citation_volume'];
}
if ($joura !== '' && $hasYear && $hasDoi) {
// 有刊名+年份+DOI但无卷期页多为已正式发表的电子刊/文章编号,作已发表
return ['published' => true, 'evidence' => 'citation_doi'];
}
if ($hasYear && $hasVolumePages && $hasDoi) {
return ['published' => true, 'evidence' => 'citation_volume'];
}
// 仅有 journal 类型或仅有 DOI不足以断言“已发表”
return ['published' => null, 'evidence' => 'insufficient'];
}
/**
* 归一化文献类型:优先取 refer_type 字段,其次按 ISBN/DOI 规则兜底。
*/
private function normalizeReferType(array $refer, $referTextFallback = '')
{
$type = strtolower(trim((string)($refer['refer_type'] ?? '')));
if ($type === 'book' || $type === 'journal') {
return $type;
$sourceText = $referTextFallback;
foreach (['refer_content', 'refer_frag'] as $field) {
$sourceText .= ' ' . trim((string)($refer[$field] ?? ''));
}
$sourceText = trim($sourceText);
$sourceLower = strtolower($sourceText);
// 预印本/会议摘要等并非“已发表期刊”。
if (strpos($sourceLower, 'medrxiv') !== false
|| strpos($sourceLower, 'biorxiv') !== false
|| strpos($sourceLower, 'arxiv') !== false
|| preg_match('/\bpreprint\b/i', $sourceText)
|| preg_match('/\bconference\b|\bproceedings\b|\bsymposium\b|\bworkshop\b/i', $sourceText)) {
return 'other';
}
if ($type === 'book') {
return 'book';
}
if ($type === 'journal') {
// refer_type 标记为 journal 但命中预印本/会议关键词时,上面已提前返回 other。
return 'journal';
}
$isbn = trim((string)($refer['isbn'] ?? ''));
$hasDoi = trim((string)($refer['refer_doi'] ?? '')) !== ''
|| trim((string)($refer['doilink'] ?? '')) !== '';
$sourceText = $referTextFallback;
foreach (['refer_content', 'refer_frag'] as $field) {
$sourceText .= ' ' . trim((string)($refer[$field] ?? ''));
}
if ($isbn !== '' || preg_match('/\bISBN\b/i', $sourceText)
|| preg_match('/\b\d+(?:st|nd|rd|th)\s+ed\.?/i', $sourceText)) {
return 'book';
@@ -2213,6 +2742,11 @@ class ReferenceRelevanceCheckService
$combinedScore = floatval(isset($llmResponse['combined_relevance_score']) ? $llmResponse['combined_relevance_score'] : 0);
$combinedReason = trim((string)(isset($llmResponse['combined_reason']) ? $llmResponse['combined_reason'] : ''));
$combinedAuthorComment = $this->resolveCombinedAuthorComment(
$llmResponse,
$combinedScore,
$combinedReason
);
$claimsJson = $this->encodeClaimsJson(isset($llmResponse['claims']) ? $llmResponse['claims'] : []);
$byRef = [];
@@ -2244,6 +2778,9 @@ class ReferenceRelevanceCheckService
$rowCombinedReason = $combinedReason !== ''
? $combinedReason
: (string)(isset($item['combined_reason']) ? $item['combined_reason'] : $item['reason']);
$rowCombinedAuthorComment = $combinedAuthorComment !== ''
? $combinedAuthorComment
: $this->resolveCombinedAuthorComment($item, $rowCombinedScore, $rowCombinedReason);
$this->updateRow(intval($gr['id']), [
'is_relevant' => !empty($item['is_relevant']) ? 1 : 0,
'relevance_score' => floatval($item['relevance_score']),
@@ -2251,6 +2788,7 @@ class ReferenceRelevanceCheckService
'author_comment' => (string)($item['author_comment'] ?? ''),
'combined_relevance_score' => $rowCombinedScore,
'combined_reason' => $rowCombinedReason,
'combined_author_comment' => $rowCombinedAuthorComment,
'claims_json' => $claimsJson,
'status' => self::RECORD_COMPLETED,
'error_msg' => '',
@@ -2284,6 +2822,9 @@ class ReferenceRelevanceCheckService
if (isset($fields['combined_reason'])) {
$fields['combined_reason'] = mb_substr(trim((string)$fields['combined_reason']), 0, 2000);
}
if (isset($fields['combined_author_comment'])) {
$fields['combined_author_comment'] = mb_substr(trim((string)$fields['combined_author_comment']), 0, 2000);
}
if (isset($fields['claims_json'])) {
$fields['claims_json'] = mb_substr(trim((string)$fields['claims_json']), 0, 4000);
}
@@ -2305,6 +2846,7 @@ class ReferenceRelevanceCheckService
'author_comment' => '',
'combined_relevance_score' => 0,
'combined_reason' => '',
'combined_author_comment' => '',
'claims_json' => '',
'score_ceiling_trigger' => '',
'error_msg' => '',
@@ -2350,8 +2892,14 @@ class ReferenceRelevanceCheckService
}
}
$author_comment = $this->resolveAuthorCommentFromRow($row, $claims);
if($author_comment){
$reason = $reason . "\n" . $author_comment;
$combined_author_comment = $this->resolveCombinedAuthorCommentFromRow($row);
$author_comment = $this->fillAuthorCommentFromCombined(
$author_comment,
$combined_author_comment,
floatval($row['combined_relevance_score'] ?? 0)
);
if ($author_comment) {
$reason = $reason . "\n" . $author_comment;
}
return [
'check_id' => intval($row['id']),
@@ -2365,6 +2913,7 @@ class ReferenceRelevanceCheckService
'author_comment' => $author_comment,
'combined_relevance_score' => floatval($row['combined_relevance_score']),
'combined_reason' => (string)$row['combined_reason'],
'combined_author_comment' => $combined_author_comment,
'cite_group_refs' => (string)$row['cite_group_refs'],
'claims' => $claims,
'evidence_mode' => ((string)($row['score_ceiling_trigger'] ?? '') === 'no_literature_evidence')
@@ -2375,6 +2924,62 @@ class ReferenceRelevanceCheckService
];
}
/**
* 联合分 <= 0.65 且单条批注为空时,用组合批注回填展示用 author_comment。
*/
private function fillAuthorCommentFromCombined($authorComment, $combinedAuthorComment, $combinedScore)
{
$authorComment = trim((string)$authorComment);
if ($authorComment !== '') {
return $authorComment;
}
if (floatval($combinedScore) > 0.65 + 0.001) {
return '';
}
$combinedAuthorComment = trim((string)$combinedAuthorComment);
return $combinedAuthorComment;
}
/**
* 联合批注优先读库score<=0.65 且库空时按 author_comment 规则即时生成。
*/
private function resolveCombinedAuthorCommentFromRow(array $row)
{
$stored = '';
if (array_key_exists('combined_author_comment', $row)) {
$stored = trim((string)$row['combined_author_comment']);
}
$score = floatval($row['combined_relevance_score'] ?? 0);
if ($score > 0.65 + 0.001) {
return '';
}
if ($stored !== '') {
return $stored;
}
return (new ReferenceRelevanceLlmService())->buildCombinedAuthorCommentFromReason(
$score,
(string)($row['combined_reason'] ?? '')
);
}
/**
* 从 LLM/程序结果解析 combined_author_comment缺省时按联合分规则生成。
*/
private function resolveCombinedAuthorComment(array $payload, $combinedScore, $combinedReason)
{
$comment = trim((string)($payload['combined_author_comment'] ?? ''));
if ($comment !== '') {
return $comment;
}
return (new ReferenceRelevanceLlmService())->buildCombinedAuthorCommentFromReason(
floatval($combinedScore),
(string)$combinedReason
);
}
/**
* @param array|string $claims
*/