参考文献作者堆叠

参考文献相关性检测
作者ai写作辅助检测工作
This commit is contained in:
wyn
2026-07-15 10:49:05 +08:00
parent da71dfc04e
commit 8785610e6d
27 changed files with 10008 additions and 204 deletions

View File

@@ -0,0 +1,283 @@
<?php
namespace app\common;
use think\Db;
/**
* 参考文献堆叠 — 作者精准定位OpenAlex ID / ORCID姓名仅作模糊参考
*/
class ReferenceAuthorIdentityService
{
const MATCH_PRECISE = 'precise';
const MATCH_FUZZY = 'fuzzy';
const MATCH_NONE = 'none';
/** @var BackgroundCheckService */
private $bgCheck;
/** @var ReferenceCheckService */
private $refUtil;
/** @var CrossrefService */
private $crossref;
public function __construct()
{
$this->bgCheck = new BackgroundCheckService();
$this->refUtil = new ReferenceCheckService();
$this->crossref = new CrossrefService([
'mailto' => trim((string)\think\Env::get('crossref_mailto', '')),
]);
}
/**
* 本文作者身份(精准统计仅认 ORCID → OpenAlex / ORCID 键)
*
* @return array<int, array{
* p_article_author_id:int,
* display_name:string,
* openalex_id:string,
* orcid:string,
* identity_keys:string[],
* match_confidence:string
* }>
*/
public function resolveManuscriptAuthors($pArticleId)
{
$pArticleId = intval($pArticleId);
if ($pArticleId <= 0) {
return [];
}
$rows = Db::name('production_article_author')
->field('p_article_author_id,first_name,last_name,author_name,orcid')
->where('p_article_id', $pArticleId)
->where('state', 0)
->select();
$identities = [];
foreach ($rows as $row) {
$identities[] = $this->resolveOneManuscriptAuthor($row);
}
return $identities;
}
/**
* 单条参考文献的作者身份(优先 OpenAlex work by DOI其次 Crossref ORCID
*
* @return array{
* authorships:array,
* first_author:array|null,
* match_confidence:string,
* identity_source:string
* }
*/
public function resolveReferAuthorships(array $refer, array &$workCache, array &$crossrefCache)
{
$pReferId = intval($refer['p_refer_id'] ?? 0);
if ($pReferId > 0) {
$stored = (new ReferenceReferAuthorService())->loadAuthorshipsByPReferId($pReferId);
if (!empty($stored)) {
return [
'authorships' => $stored,
'first_author' => $this->pickFirstAuthorship($stored),
'match_confidence' => self::MATCH_PRECISE,
'identity_source' => 'stored',
];
}
}
$doi = $this->refUtil->extractDoiFromRefer($refer);
if ($doi === '') {
return [
'authorships' => [],
'first_author' => null,
'match_confidence' => self::MATCH_NONE,
'identity_source' => '',
];
}
if (!array_key_exists($doi, $workCache)) {
$workCache[$doi] = $this->fetchOpenAlexAuthorships($doi);
usleep(80000);
}
$authorships = $workCache[$doi];
$source = 'openalex';
if (empty($authorships)) {
if (!array_key_exists($doi, $crossrefCache)) {
$crossrefCache[$doi] = $this->fetchCrossrefAuthorships($doi);
usleep(80000);
}
$authorships = $crossrefCache[$doi];
$source = 'crossref_orcid';
}
$firstAuthor = $this->pickFirstAuthorship($authorships);
$confidence = empty($authorships) ? self::MATCH_NONE : self::MATCH_PRECISE;
return [
'authorships' => $authorships,
'first_author' => $firstAuthor,
'match_confidence' => $confidence,
'identity_source' => $source,
];
}
/**
* @param array<int, array{identity_keys:string[]}> $manuscriptAuthors
* @param array<int, array{identity_keys:string[]}> $referAuthorships
*/
public function matchManuscriptToRefer(array $manuscriptAuthors, array $referAuthorships)
{
$manuscriptKeys = [];
foreach ($manuscriptAuthors as $author) {
if (($author['match_confidence'] ?? '') !== self::MATCH_PRECISE) {
continue;
}
foreach ((array)($author['identity_keys'] ?? []) as $key) {
$manuscriptKeys[$key] = $author;
}
}
foreach ($referAuthorships as $auth) {
foreach ((array)($auth['identity_keys'] ?? []) as $key) {
if (isset($manuscriptKeys[$key])) {
return $manuscriptKeys[$key];
}
}
}
return null;
}
public function buildFuzzyAuthorKey($authorCitationPart)
{
$part = trim(preg_replace('/\.+$/u', '', trim((string)$authorCitationPart)));
if ($part === '' || preg_match('/^et\s+al\.?$/iu', $part)) {
return '';
}
$tokens = preg_split('/\s+/u', $part, -1, PREG_SPLIT_NO_EMPTY);
if (count($tokens) === 1) {
return 'fuzzy:' . mb_strtoupper($tokens[0]) . '|';
}
$last = array_pop($tokens);
if (preg_match('/^[A-Za-z]{1,4}$/u', $last)) {
$family = implode(' ', $tokens);
return 'fuzzy:' . mb_strtoupper(preg_replace('/\s+/u', ' ', trim($family))) . '|' . mb_strtoupper($last);
}
$family = $last;
$initials = '';
foreach ($tokens as $token) {
$initials .= mb_strtoupper(mb_substr($token, 0, 1));
}
return 'fuzzy:' . mb_strtoupper($family) . '|' . $initials;
}
public function extractFuzzyFirstAuthorKeyFromRefer(array $refer, array $meta)
{
$author = trim(trim((string)($meta['author'] ?? $refer['author'] ?? '')), '.');
if ($author === '') {
return '';
}
$parts = preg_split('/,\s*/u', $author);
$first = trim((string)($parts[0] ?? ''));
return $this->buildFuzzyAuthorKey($first);
}
private function resolveOneManuscriptAuthor(array $row)
{
$first = trim((string)($row['first_name'] ?? ''));
$last = trim((string)($row['last_name'] ?? ''));
$displayName = ($first !== '' && $last !== '') ? trim($first . ' ' . $last) : trim((string)($row['author_name'] ?? ''));
$orcid = $this->bgCheck->cleanOrcid($row['orcid'] ?? '');
$openalexId = '';
$identityKeys = [];
$confidence = self::MATCH_NONE;
$fuzzyKey = '';
if ($last !== '') {
$initials = $this->initialsFromGiven($first);
$fuzzyKey = 'fuzzy:' . mb_strtoupper($last) . '|' . $initials;
}
if ($orcid !== '') {
$identityKeys[] = 'orcid:' . $orcid;
$confidence = self::MATCH_PRECISE;
$res = $this->bgCheck->resolveAuthor(['orcid' => $orcid]);
if (!empty($res['success']) && !empty($res['data'])) {
$openalexId = $this->bgCheck->extractOpenAlexId($res['data']['id'] ?? '');
if ($openalexId !== '') {
$identityKeys[] = 'openalex:' . $openalexId;
}
if (trim((string)($res['data']['display_name'] ?? '')) !== '') {
$displayName = trim((string)$res['data']['display_name']);
}
}
}
return [
'p_article_author_id' => intval($row['p_article_author_id']),
'display_name' => $displayName,
'openalex_id' => $openalexId,
'orcid' => $orcid,
'fuzzy_key' => $fuzzyKey,
'identity_keys' => array_values(array_unique($identityKeys)),
'match_confidence' => $confidence,
];
}
private function initialsFromGiven($given)
{
$given = trim((string)$given);
if ($given === '') {
return '';
}
$parts = preg_split('/[\s\-\.]+/u', $given, -1, PREG_SPLIT_NO_EMPTY);
$initials = '';
foreach ($parts as $part) {
$first = mb_substr($part, 0, 1);
if ($first !== '') {
$initials .= mb_strtoupper($first);
}
}
return $initials;
}
private function fetchOpenAlexAuthorships($doi)
{
$res = $this->bgCheck->fetchOpenAlexWorkByDoi($doi);
if (empty($res['success']) || empty($res['work']) || !is_array($res['work'])) {
return [];
}
return $this->bgCheck->parseWorkAuthorships($res['work']);
}
private function fetchCrossrefAuthorships($doi)
{
$res = $this->bgCheck->fetchCrossRefWork($doi);
if (empty($res['success']) || empty($res['message'])) {
return [];
}
return $this->bgCheck->authorshipsFromCrossrefAuthors($res['message']['author'] ?? []);
}
private function pickFirstAuthorship(array $authorships)
{
if (empty($authorships)) {
return null;
}
foreach ($authorships as $auth) {
if (($auth['author_position'] ?? '') === 'first') {
return $auth;
}
}
return reset($authorships) ?: null;
}
}