diff --git a/application/api/controller/Finalreview.php b/application/api/controller/Finalreview.php index 7efa5bdf..686767cc 100644 --- a/application/api/controller/Finalreview.php +++ b/application/api/controller/Finalreview.php @@ -217,6 +217,24 @@ class Finalreview extends Base } } } + + + foreach ($aLists as $key => $value) { + $rr = Db::name("article_reviewer_final")->where("reviewer_id",$value['user_id'])->order("id desc")->limit(1)->select(); + if(isset($rr[0])){ + $aLists[$key]['last_time'] = $rr[0]['invited_time']; + }else{ + $aLists[$key]['last_time'] = 0; + } + $info = Db::name("user_reviewer_info")->field("field,field_ai")->where("reviewer_id",$value['user_id'])->find(); + if($info){ + $aLists[$key]['field'] = $info['field']; + $aLists[$key]['field_ai'] = $info['field_ai']; + }else{ + $aLists[$key]['field'] = ''; + $aLists[$key]['field_ai'] = ''; + } + } return json_encode(['status' => 1,'msg' => 'success','data' => ['total' => $iCount,'lists' => $aLists]]); } diff --git a/application/api/controller/Order.php b/application/api/controller/Order.php index 90f80736..66897fee 100644 --- a/application/api/controller/Order.php +++ b/application/api/controller/Order.php @@ -172,6 +172,193 @@ class Order extends base{ } } + /**提交改价申请 + * @return void + */ + public function applyPrice(){ + $data = $this->request->post(); + $rule = new Validate([ + "article_id"=>"require", + "fee"=>"require", + "remark"=>"require" + ]); + if(!$rule->check($data)){ + return jsonError($rule->getError()); + } + + // 查询文章、期刊和编辑者信息 + $article_info = $this->article_obj->where("article_id",$data['article_id'])->find(); + $journal_info = $this->journal_obj->where("journal_id",$article_info['journal_id'])->find(); + $editor_info = $this->user_obj->where("user_id",$journal_info['editor_id'])->find(); + + // 验证编辑者权限 + if($journal_info['editor_id']!=$editor_info['user_id']){ + return jsonError("You are not the editor of this journal"); + } + + $check = Db::name("article_price_apply")->where("article_id",$data['article_id'])->where("state",0)->find(); + if($check){ + return jsonError("There is an unfinished price change application for this article"); + } + + $insert['article_id'] = $data['article_id']; + $insert['old_fee'] = $article_info['fee']; + $insert['fee'] = $data['fee']; + $insert['remark'] = $data['remark']; + $insert['ctime'] = time(); + Db::name("article_price_apply")->insert($insert); + + + //发送邮件 + $tt = '您有新的价格审批!link'; + + sendEmail("18812616272@qq.com", "tmr-文章价格申请", "tmr-文章价格申请", $tt, $journal_info['email'], $journal_info['epassword']); + + + + + + return jsonSuccess(); + + } + + + public function getApplyList(){ + $data = $this->request->post(); + $data['pageIndex'] = isset($data['pageIndex'])?$data['pageIndex']:0; + $data['pageSize'] = isset($data['pageSize'])?$data['pageSize']:10; + $start_page = ($data['pageIndex']-1)*$data['pageSize']; + $list = Db::name("article_price_apply")->where("state", $data['state'] ?? 0)->limit($start_page,$data['pageSize'])->select(); + foreach ($list as $k=>$v){ + $list[$k]['article_info'] = $this->article_obj->field("article_id,journal_id,accept_sn,title")->where("article_id",$v['article_id'])->find(); + $list[$k]['journal_info'] = $this->journal_obj->field("journal_id,title")->where("journal_id",$list[$k]['article_info']['journal_id'])->find(); + $list[$k]['history'] = Db::name("article_price_apply")->where("article_id",$list[$k]['article_info']['article_id'])->select(); + } + $re['list'] = $list; + $re['total'] = Db::name("article_price_apply")->where("state", $data['state'] ?? 0)->count(); + + return jsonSuccess($re); + } + + public function getApplyArticleList(){ + $data = $this->request->post(); + $rule = new Validate([ + "state"=>"require", + "pageIndex"=>"require", + "pageSize"=>"require" + ]); + if(!$rule->check($data)){ + return jsonError($rule->getError()); + } + $start_page = ($data['pageIndex']-1)*$data['pageSize']; + $sql = "SELECT a.article_id,a.title,a.accept_sn,j.title journal_title FROM t_article a +INNER JOIN ( + SELECT MAX(id) AS max_id, article_id + FROM t_article_price_apply + GROUP BY article_id +) t ON a.article_id = t.article_id +INNER JOIN t_article_price_apply apa ON apa.id = t.max_id +LEFT JOIN t_journal j ON a.journal_id = j.journal_id +WHERE apa.state = ".$data['state']." +LIMIT ".$start_page.",".$data['pageSize']; + + $row = Db::query($sql); + + foreach ($row as $k=>$v){ + $row[$k]['history'] = Db::name("article_price_apply")->where("article_id",$v['article_id'])->select(); + } + + + $countSql = "SELECT COUNT(*) AS total FROM t_article a +INNER JOIN ( + SELECT MAX(id) AS max_id, article_id + FROM t_article_price_apply + GROUP BY article_id +) t ON a.article_id = t.article_id +INNER JOIN t_article_price_apply apa ON apa.id = t.max_id +WHERE apa.state = ".$data['state']; + $total = Db::query($countSql); + $total = intval($total[0]['total'] ?? 0); + $re['list'] = $row; + $re['count'] = $total; + + return jsonSuccess($re); + } + + + + + public function getApplyCount(){ + $res = Db::name("article_price_apply")->field("state,count(*) as count")->group("state")->select(); + + return jsonSuccess($res); + } + + + + public function acceptPriceApply(){ + $data = $this->request->post(); + $rule = new Validate([ + "id"=>"require" + ]); + if(!$rule->check($data)){ + return jsonError($rule->getError()); + } + + $apply_info = Db::name("article_price_apply")->where("id",$data['id'])->find(); + $article_update['fee']=$apply_info['fee']; + $article_update['fee_remark']=$apply_info['remark']; + + if(intval($apply_info['fee'])==0) { + $article_update['is_buy']=1; + } + $this->article_obj->where("article_id",$apply_info['article_id'])->update($article_update); + + Db::name("article_price_apply")->where("id",$data['id'])->update(['state'=>1]); + + $order_info = $this->order_obj->where("article_id",$apply_info['article_id'])->whereIn("state",[0,1])->find(); + if($order_info){ + if(intval($apply_info['fee'])==0){ + $this->order_obj->where("order_id",$order_info['order_id'])->update(['state'=>2]); + }else{ + $update1['order_fee']=$apply_info['fee']; + $this->order_obj->where("order_id",$order_info['order_id'])->update($update1); + } + } + + return jsonSuccess(); + } + + public function getArticlePriceApplyList(){ + $data = $this->request->post(); + $rule = new Validate([ + "article_id"=>"require" + ]); + if(!$rule->check($data)){ + return jsonError($rule->getError()); + } + + $list = Db::name("article_price_apply")->where("article_id",$data['article_id'])->select(); + + return jsonSuccess($list); + } + + + public function rejectPriceApply(){ + $data = $this->request->post(); + $rule = new Validate([ + "id"=>"require", + "remark"=>"require" + ]); + if(!$rule->check($data)){ + return jsonError($rule->getError()); + } +// $apply_info = Db::name("article_price_apply")->where("id",$data['id'])->find(); + Db::name("article_price_apply")->where("id",$data['id'])->update(['state'=>2,"remark"=>trim($data['remark'])]); + return jsonSuccess(); + } + + /** * 修改文章价格 * diff --git a/application/api/controller/Production.php b/application/api/controller/Production.php index dbaf23de..f6f1a0c5 100644 --- a/application/api/controller/Production.php +++ b/application/api/controller/Production.php @@ -11,6 +11,7 @@ use think\Queue; use think\Validate; use think\log; use app\common\ArticleSymbolNormalizer; +use app\common\ReferenceDispatchService; /** * @title 公共管理相关 @@ -1931,7 +1932,7 @@ class Production extends Base } $this->referToDoi($data['p_article_id']); - $this->doiTofrag($data['p_article_id']); + (new ReferenceDispatchService())->dispatchRefersByType($data['p_article_id']); return jsonSuccess([]); } @@ -1998,19 +1999,7 @@ class Production extends Base public function doiTofrag($p_article_id) { - $p_info = $this->production_article_obj->where('p_article_id', $p_article_id)->find(); - $refers = $this->production_article_refer_obj->where('p_article_id', $p_info['p_article_id'])->where('state', 0)->select(); - foreach ($refers as $v) { - if ($v['refer_doi'] == '') { - $this->production_article_refer_obj->where('p_refer_id', $v['p_refer_id'])->update(['refer_frag' => $v['refer_content']]); - } else { - - //修改队列兼容对接OPENAI接口 chengxiaoling 20251128 start - // Queue::push('app\api\job\ts@fire1', $v, 'ts'); - Queue::push('app\api\job\ArticleReferDetailQueue@fire', $v, 'ArticleReferDetailQueue'); - //修改队列兼容对接OPENAI接口 chengxiaoling 20251128 end - } - } + (new ReferenceDispatchService())->dispatchRefersByType($p_article_id); return jsonSuccess([]); } diff --git a/application/api/controller/Ucenter.php b/application/api/controller/Ucenter.php index bc5a29e5..638ce7d2 100644 --- a/application/api/controller/Ucenter.php +++ b/application/api/controller/Ucenter.php @@ -164,7 +164,10 @@ class Ucenter extends Base{ if($check){ return jsonError("Your application for Editorial Board is processing. Please do not repeat it."); } - + $check_has = $this->user_to_yboard_obj->where('user_id',$data['user_id'])->where('state',0)->find(); + if ($check_has){ + return jsonError("You have already applied for Editorial Board."); + } //判断是否上传CV $aWhere = ['user_id' => $data['user_id'],'state' => 0]; $aCv = $this->user_cv_obj->field('user_cv_id')->where($aWhere)->find(); diff --git a/application/common/CrossrefService.php b/application/common/CrossrefService.php index e5babfb9..6c029425 100644 --- a/application/common/CrossrefService.php +++ b/application/common/CrossrefService.php @@ -273,26 +273,83 @@ class CrossrefService } /** - * 提取标题 + * 提取标题(英文优先) + * + * CrossRef 的 title / original-title 可能包含多个语言版本(中文期刊/双语文章常见)。 + * 这里优先返回不含中日韩字符的版本,全部为中文时才退回第一个。 */ public function getTitle($aDoiInfo = []) { - return $aDoiInfo['title'][0] ?? ''; + $candidates = []; + if (!empty($aDoiInfo['title']) && is_array($aDoiInfo['title'])) { + $candidates = array_merge($candidates, $aDoiInfo['title']); + } + if (!empty($aDoiInfo['original-title']) && is_array($aDoiInfo['original-title'])) { + $candidates = array_merge($candidates, $aDoiInfo['original-title']); + } + return $this->pickPreferredLatin($candidates); } /** - * 提取期刊/出版社相关信息 + * 提取期刊/出版社相关信息(期刊名英文优先) */ public function getPublisher($aDoiInfo = []) { + $containerTitles = (!empty($aDoiInfo['container-title']) && is_array($aDoiInfo['container-title'])) + ? $aDoiInfo['container-title'] : []; + $shortTitles = (!empty($aDoiInfo['short-container-title']) && is_array($aDoiInfo['short-container-title'])) + ? $aDoiInfo['short-container-title'] : []; + return [ - 'title' => isset($aDoiInfo['container-title'][0]) ? $aDoiInfo['container-title'][0] : '', - 'short_title' => isset($aDoiInfo['short-container-title'][0]) ? $aDoiInfo['short-container-title'][0] : '', + 'title' => $this->pickPreferredLatin($containerTitles), + 'short_title' => $this->pickPreferredLatin($shortTitles), 'ISSN' => $aDoiInfo['ISSN'] ?? [], 'publisher' => $aDoiInfo['publisher'] ?? '', ]; } + /** + * 是否包含中日韩(CJK)字符 + */ + public function hasCjk($str) + { + $str = (string)$str; + if ($str === '') { + return false; + } + return preg_match('/[\x{4e00}-\x{9fff}\x{3040}-\x{30ff}\x{ac00}-\x{d7af}\x{3400}-\x{4dbf}]/u', $str) === 1; + } + + /** + * 从多个候选字符串中优先挑选「不含 CJK 字符」的一个; + * 全部为空则返回空串,全部含 CJK 则返回第一个非空值。 + * + * @param array $candidates + * @return string + */ + private function pickPreferredLatin($candidates) + { + if (!is_array($candidates) || empty($candidates)) { + return ''; + } + + $firstNonEmpty = ''; + foreach ($candidates as $item) { + $s = trim((string)$item); + if ($s === '') { + continue; + } + if ($firstNonEmpty === '') { + $firstNonEmpty = $s; + } + if (!$this->hasCjk($s)) { + return $s; + } + } + + return $firstNonEmpty; + } + /** * 用 PubMed/NLM 反查期刊规范缩写(CrossRef 无缩写时的兜底)。 * 任何异常都吞掉并返回空串,保证不影响主流程。 diff --git a/application/common/ProductionArticleRefer.php b/application/common/ProductionArticleRefer.php index 51f53d0c..cb3684c9 100644 --- a/application/common/ProductionArticleRefer.php +++ b/application/common/ProductionArticleRefer.php @@ -54,7 +54,7 @@ class ProductionArticleRefer return json_encode(['status' => 1,'msg' => 'Add to reference processing queue']); } /** - * 处理参考文献 + * 处理参考文献(单个) * * @return void */ @@ -71,10 +71,17 @@ class ProductionArticleRefer } //查询未处理过的数据 $aWhere = ['p_refer_id' => $iPReferId,'p_article_id' => $iPArticleId,'state' => 0]; - $aRefer = Db::name('production_article_refer')->field('refer_doi,refer_content')->where($aWhere)->find(); + $aRefer = Db::name('production_article_refer')->field('refer_doi,refer_content,refer_type')->where($aWhere)->find(); if(empty($aRefer)){ return json_encode(array('status' => 2,'msg' => 'No reference records found'.json_encode($aParam))); } + + // 非期刊类型已在分流阶段处理,队列不再走期刊解析 + $referType = strtolower(trim((string)($aRefer['refer_type'] ?? 'journal'))); + if (in_array($referType, ['book', 'other'], true)) { + return json_encode(['status' => 1, 'msg' => 'Skipped non-journal reference']); + } + if(empty($aRefer['refer_doi'])){ return json_encode(['status' => 4,'msg' => 'Reference DOI is empty'.json_encode($aParam)]); } @@ -89,40 +96,48 @@ class ProductionArticleRefer ]); $summary = $svc->fetchWorkSummary($doiNorm); if ($summary !== null && !empty($summary['doi'])) { - $update_a = []; $title = trim((string)($summary['title'] ?? '')); $jouraRaw = trim((string)($summary['joura'] ?? '')); // 姓全写 + 名首字母,超过 3 个作者取前 3 个 + et al $authorCitation = $svc->getAuthorsCitation($summary['raw'] ?? [], 3); - $dateno = trim((string)($summary['dateno'] ?? '')); - $doilink = trim((string)($summary['doilink'] ?? '')); - $update_a['title'] = $title; - $update_a['author'] = $authorCitation !== '' ? $authorCitation . '.' : ''; - $update_a['joura'] = $jouraRaw; - $update_a['dateno'] = $dateno; - $update_a['refer_type'] = "journal"; - $update_a['is_ja'] = 1; - $update_a['doilink'] = $doilink; - $update_a['cs'] = 1; - $update_a['update_time'] = time(); - $update_a['is_deal'] = 1; - try { - (new ReferenceReferAuthorService())->syncFromWorkSummary( - $iPReferId, - $iPArticleId, - $doiNorm, - $summary - ); - } catch (\Throwable $e) { - \think\Log::error( - 'ProductionArticleRefer sync refer authors failed p_refer_id=' - . $iPReferId . ' ' . $e->getMessage() - ); + // 英文优先兜底:若 CrossRef 结果的标题/期刊/作者仍含中日韩字符, + // 说明该 DOI 元数据是中文,放弃 CrossRef 路径,改走下方 citation.doi.org(lang=en-US) + $hasCjk = $svc->hasCjk($title) || $svc->hasCjk($jouraRaw) || $svc->hasCjk($authorCitation); + if (!$hasCjk) { + $update_a = []; + $dateno = trim((string)($summary['dateno'] ?? '')); + $doilink = trim((string)($summary['doilink'] ?? '')); + $update_a['title'] = $title; + $update_a['author'] = $authorCitation !== '' ? $authorCitation . '.' : ''; + $update_a['joura'] = $jouraRaw; + $update_a['dateno'] = $dateno; + // CrossRef 的 type 最权威,据此确定参考文献类型,未命中回退 journal + $crossrefType = isset($summary['raw']['type']) ? $summary['raw']['type'] : ''; + $mappedType = (new ReferenceTypeClassifier())->mapCrossrefType($crossrefType); + $update_a['refer_type'] = $mappedType !== '' ? $mappedType : "journal"; + $update_a['is_ja'] = 1; + $update_a['doilink'] = $doilink; + $update_a['cs'] = 1; + $update_a['update_time'] = time(); + $update_a['is_deal'] = 1; + + try { + (new ReferenceReferAuthorService())->syncFromWorkSummary( + $iPReferId, + $iPArticleId, + $doiNorm, + $summary + ); + } catch (\Throwable $e) { + \think\Log::error( + 'ProductionArticleRefer sync refer authors failed p_refer_id=' + . $iPReferId . ' ' . $e->getMessage() + ); + } + Db::name('production_article_refer')->where(['p_refer_id' => $iPReferId])->limit(1)->update($update_a); + return json_encode(['status' => 1,'msg' => 'Update successful']); } - - Db::name('production_article_refer')->where(['p_refer_id' => $iPReferId])->limit(1)->update($update_a); - return json_encode(['status' => 1,'msg' => 'Update successful']); } //结束---用crossref接口的方式处理数据 @@ -135,7 +150,13 @@ class ProductionArticleRefer $res = myGet($url); $frag = trim(substr($res, strpos($res, '.') + 1)); if(empty($frag)){ - $aUpdate = ['refer_frag' => $aRefer['refer_content'],'refer_type' => 'other','is_deal' => 1,'update_time' => time()]; + // 依据作者原文识别类型;仅在识别出具体非期刊类型时覆盖,否则保持 other + $referType = 'other'; + $typeInfo = (new ReferenceTypeClassifier())->classify((string)$aRefer['refer_content']); + if (in_array($typeInfo['type'], ['book','conference','thesis','web'], true)) { + $referType = $typeInfo['type']; + } + $aUpdate = ['refer_frag' => $aRefer['refer_content'],'refer_type' => $referType,'is_deal' => 1,'update_time' => time()]; $aWhere = ['p_refer_id' => $iPReferId]; $result = Db::name('production_article_refer')->where($aWhere)->limit(1)->update($aUpdate); //写入通过AI获取参考文献详情队列 @@ -148,6 +169,11 @@ class ProductionArticleRefer if (mb_substr_count($frag, '.') != 3){ $f = $frag . " Available at: " . PHP_EOL . "https://doi.org/" . $aRefer['refer_doi']; $update['refer_type'] = "other"; + // 依据作者原文识别类型;仅在识别出具体非期刊类型时覆盖 + $typeInfo = (new ReferenceTypeClassifier())->classify((string)$aRefer['refer_content']); + if (in_array($typeInfo['type'], ['book','conference','thesis','web'], true)) { + $update['refer_type'] = $typeInfo['type']; + } $update['refer_frag'] = $f; $update['cs'] = 1; //写入通过AI获取参考文献详情队列 @@ -164,6 +190,11 @@ class ProductionArticleRefer if ($joura == trim($bj[0])) { } $update['refer_type'] = "journal"; + // 依据作者原文的强特征识别更精确的类型(禁用 LLM,仅规则强命中才覆盖) + $ruleType = (new ReferenceTypeClassifier(['use_llm' => false]))->classifyByRule((string)$aRefer['refer_content']); + if ($ruleType['confidence'] >= 0.8 && !in_array($ruleType['type'], ['journal','other'], true)) { + $update['refer_type'] = $ruleType['type']; + } $update['is_ja'] = $joura == trim($bj[0]) ? 0 : 1; $update['dateno'] = str_replace(' ', '', str_replace('-', '–', trim($bj[1]))); //新增处理 期卷页码 20251127 start diff --git a/application/common/ReferenceDispatchService.php b/application/common/ReferenceDispatchService.php new file mode 100644 index 00000000..8358f35a --- /dev/null +++ b/application/common/ReferenceDispatchService.php @@ -0,0 +1,251 @@ +classifier = new ReferenceTypeClassifier(['use_llm' => true]); + } + + /** + * 对某篇生产文章的全部参考文献按类型分流处理 + */ + public function dispatchRefersByType($pArticleId) + { + $pArticleId = intval($pArticleId); + if ($pArticleId <= 0) { + return; + } + + $refers = Db::name('production_article_refer') + ->where('p_article_id', $pArticleId) + ->where('state', 0) + ->order('index asc, p_refer_id asc') + ->select(); + + if (empty($refers)) { + return; + } + + $crossref = new CrossrefService([ + 'mailto' => trim((string)Env::get('crossref_mailto', '')), + ]); + + foreach ($refers as $refer) { + $summary = null; + $crossrefType = ''; + if (trim((string)$refer['refer_doi']) !== '') { + $doiNorm = $this->normalizeDoi($refer['refer_doi']); + if ($doiNorm !== '') { + $summary = $crossref->fetchWorkSummary($doiNorm); + if ($summary && !empty($summary['raw']['type'])) { + $crossrefType = (string)$summary['raw']['type']; + } + } + } + + $typeInfo = $this->classifier->classify((string)$refer['refer_content'], $crossrefType); + $dispatchType = $this->classifier->normalizeDispatchType($typeInfo['type']); + + Db::name('production_article_refer')->where('p_refer_id', $refer['p_refer_id'])->update([ + 'refer_type' => $dispatchType, + 'update_time' => time(), + ]); + $refer['refer_type'] = $dispatchType; + + switch ($dispatchType) { + case ReferenceTypeClassifier::TYPE_BOOK: + $this->processBookRefer($refer, $summary, $crossref); + break; + case ReferenceTypeClassifier::TYPE_OTHER: + $this->processOtherRefer($refer); + break; + default: + $this->processJournalRefer($refer); + break; + } + } + } + + private function processJournalRefer(array $refer) + { + $pReferId = intval($refer['p_refer_id']); + if (trim((string)$refer['refer_doi']) === '') { + Db::name('production_article_refer')->where('p_refer_id', $pReferId)->update([ + 'refer_frag' => $refer['refer_content'], + 'refer_type' => ReferenceTypeClassifier::TYPE_JOURNAL, + 'is_deal' => 1, + 'update_time' => time(), + ]); + return; + } + + Queue::push('app\api\job\ArticleReferDetailQueue@fire', $refer, 'ArticleReferDetailQueue'); + } + + private function processOtherRefer(array $refer) + { + Db::name('production_article_refer')->where('p_refer_id', intval($refer['p_refer_id']))->update([ + 'refer_frag' => $refer['refer_content'], + 'refer_type' => ReferenceTypeClassifier::TYPE_OTHER, + 'cs' => 0, + 'is_deal' => 1, + 'update_time' => time(), + ]); + } + + /** + * book:结构化字段,DOI 仅用于补数据 + */ + private function processBookRefer(array $refer, $summary, CrossrefService $crossref) + { + $pReferId = intval($refer['p_refer_id']); + $content = (string)$refer['refer_content']; + $update = [ + 'refer_type' => ReferenceTypeClassifier::TYPE_BOOK, + 'is_deal' => 1, + 'update_time' => time(), + ]; + + if (is_array($summary) && !empty($summary['raw'])) { + $raw = $summary['raw']; + $authorCitation = $crossref->getAuthorsCitation($raw, 3); + $update['author'] = $authorCitation !== '' ? rtrim($authorCitation, '.') . '.' : ''; + $update['title'] = trim((string)($summary['title'] ?? '')); + $update['joura'] = $this->extractBookPublisher($raw, $summary); + $update['dateno'] = $this->extractBookDateno($raw); + $isbn = $this->extractIsbnFromRaw($raw); + if ($isbn === '' && !empty($refer['refer_doi'])) { + $doi = $this->normalizeDoi($refer['refer_doi']); + $isbn = $doi !== '' ? 'https://doi.org/' . $doi : ''; + } + $update['isbn'] = $isbn; + $update['is_ja'] = 1; + } else { + $parsed = $this->parseBookFromContent($content); + $update = array_merge($update, $parsed); + } + + $hasCore = trim((string)($update['author'] ?? '')) !== '' + && trim((string)($update['title'] ?? '')) !== ''; + $update['cs'] = $hasCore ? 1 : 0; + if (!$hasCore) { + $update['refer_frag'] = $content; + } + + Db::name('production_article_refer')->where('p_refer_id', $pReferId)->update($update); + } + + private function extractBookPublisher(array $raw, array $summary) + { + $publisher = trim((string)($raw['publisher'] ?? '')); + if ($publisher !== '') { + return $publisher; + } + $pub = $summary['publisher'] ?? []; + if (!empty($pub['publisher'])) { + return trim((string)$pub['publisher']); + } + if (!empty($pub['title'])) { + return trim((string)$pub['title']); + } + return ''; + } + + private function extractBookDateno(array $raw) + { + if (!empty($raw['published']['date-parts'][0][0])) { + return (string)$raw['published']['date-parts'][0][0]; + } + if (!empty($raw['issued']['date-parts'][0][0])) { + return (string)$raw['issued']['date-parts'][0][0]; + } + if (!empty($raw['created']['date-parts'][0][0])) { + return (string)$raw['created']['date-parts'][0][0]; + } + return ''; + } + + private function extractIsbnFromRaw(array $raw) + { + if (empty($raw['ISBN']) || !is_array($raw['ISBN'])) { + return ''; + } + foreach ($raw['ISBN'] as $isbn) { + $isbn = trim((string)$isbn); + if ($isbn !== '') { + return $isbn; + } + } + return ''; + } + + /** + * 无 Crossref 时从原文尽量抽取 book 结构化字段 + */ + private function parseBookFromContent($content) + { + $content = trim((string)$content); + $out = [ + 'author' => '', + 'title' => '', + 'joura' => '', + 'dateno' => '', + 'isbn' => '', + 'is_ja' => 1, + ]; + + if ($content === '') { + return $out; + } + + if (preg_match('/\bISBN[:\s]*([\d\-Xx\s]+)/i', $content, $m)) { + $out['isbn'] = preg_replace('/\s+/', '-', trim($m[1])); + } + + if (preg_match('/\b(19|20)\d{2}\b/', $content, $m)) { + $out['dateno'] = $m[0]; + } + + // Place: Publisher; Year → joura 取 Publisher + if (preg_match('/:\s*([^;]+);\s*(19|20)\d{2}/', $content, $m)) { + $out['joura'] = trim($m[1]); + } elseif (preg_match('/\b([A-Z][A-Za-z .&]+(?:Press|Publishing|Publisher|Books?))\b/i', $content, $m)) { + $out['joura'] = trim($m[1]); + } + + // 作者. 标题. ... 简单拆分 + $parts = preg_split('/\.\s+/', $content, 3); + if (is_array($parts) && count($parts) >= 2) { + $out['author'] = trim($parts[0]); + if (substr($out['author'], -1) !== '.') { + $out['author'] .= '.'; + } + $out['title'] = trim(rtrim($parts[1], '.')); + } + + return $out; + } + + private function normalizeDoi($doi) + { + $doi = preg_replace('#^https?://(dx\.)?doi\.org/#i', '', trim((string)$doi)); + return trim($doi, " \t\n\r\0\x0B/"); + } +} diff --git a/application/common/ReferenceTypeClassifier.php b/application/common/ReferenceTypeClassifier.php new file mode 100644 index 00000000..9367481a --- /dev/null +++ b/application/common/ReferenceTypeClassifier.php @@ -0,0 +1,236 @@ +useLlm = (bool)$config['use_llm']; + } + } + + /** + * 主入口:根据作者原文(可选 CrossRef type)识别类型 + * + * @param string $referContent 作者提供的原始参考文献字符串 + * @param string $crossrefType CrossRef 返回的 type(可选) + * @return array ['type' => string, 'confidence' => float, 'source' => string] + */ + public function classify($referContent, $crossrefType = '') + { + $text = trim((string)$referContent); + + // 1. CrossRef type 优先 + $byCrossref = $this->mapCrossrefType($crossrefType); + if ($byCrossref !== '') { + return ['type' => $byCrossref, 'confidence' => 0.95, 'source' => 'crossref']; + } + + if ($text === '') { + return ['type' => self::TYPE_OTHER, 'confidence' => 0.0, 'source' => 'fallback']; + } + + // 2. 规则启发式 + $byRule = $this->classifyByRule($text); + if ($byRule['type'] !== self::TYPE_OTHER && $byRule['confidence'] >= 0.8) { + return ['type' => $byRule['type'], 'confidence' => $byRule['confidence'], 'source' => 'rule']; + } + + // 3. 大模型兜底 + if ($this->useLlm) { + $byLlm = $this->classifyByLlm($text); + if ($byLlm !== '') { + return ['type' => $byLlm, 'confidence' => 0.7, 'source' => 'llm']; + } + } + + // 规则给出的弱结果(若有)优先于纯兜底 + if ($byRule['type'] !== self::TYPE_OTHER) { + return ['type' => $byRule['type'], 'confidence' => $byRule['confidence'], 'source' => 'rule']; + } + + // 4. 兜底 + return ['type' => self::TYPE_OTHER, 'confidence' => 0.0, 'source' => 'fallback']; + } + + /** + * CrossRef type → 内部枚举映射;未命中返回空串 + */ + /** + * 将细分类归并为排版用的三类:journal / book / other + */ + public function normalizeDispatchType($type) + { + $type = strtolower(trim((string)$type)); + if ($type === self::TYPE_BOOK) { + return self::TYPE_BOOK; + } + if ($type === self::TYPE_JOURNAL) { + return self::TYPE_JOURNAL; + } + return self::TYPE_OTHER; + } + + public function mapCrossrefType($crossrefType) + { + $t = strtolower(trim((string)$crossrefType)); + if ($t === '') { + return ''; + } + + $map = [ + // 期刊 + 'journal-article' => self::TYPE_JOURNAL, + 'journal' => self::TYPE_JOURNAL, + 'journal-volume' => self::TYPE_JOURNAL, + 'journal-issue' => self::TYPE_JOURNAL, + // 图书 + 'book' => self::TYPE_BOOK, + 'book-chapter' => self::TYPE_BOOK, + 'book-part' => self::TYPE_BOOK, + 'book-section' => self::TYPE_BOOK, + 'book-set' => self::TYPE_BOOK, + 'book-series' => self::TYPE_BOOK, + 'book-track' => self::TYPE_BOOK, + 'reference-book' => self::TYPE_BOOK, + 'edited-book' => self::TYPE_BOOK, + 'monograph' => self::TYPE_BOOK, + // 会议 + 'proceedings-article' => self::TYPE_CONFERENCE, + 'proceedings' => self::TYPE_CONFERENCE, + 'proceedings-series' => self::TYPE_CONFERENCE, + // 学位论文 + 'dissertation' => self::TYPE_THESIS, + // 在线/预印本 + 'posted-content' => self::TYPE_WEB, + ]; + + return isset($map[$t]) ? $map[$t] : ''; + } + + /** + * 英文规则启发式:按「最独特 → 最普通」顺序判定 + * + * @return array ['type' => string, 'confidence' => float] + */ + public function classifyByRule($text) + { + $hasDoi = (bool)preg_match('/\bdoi:\s*10\./i', $text) || (bool)preg_match('#doi\.org/#i', $text); + $hasUrl = (bool)preg_match('#https?://#i', $text); + // 期刊卷期页结构,如 2020;382(8):727-733 或 2020;10:100 + $hasJournalVol = (bool)preg_match('/\b(19|20)\d{2}\s*[;:]\s*\d+\s*(\(\d+\))?\s*:\s*[A-Za-z]?\d+/', $text); + + // 1) 学位论文 + if (preg_match('/\[(ph\.?d\.?|master(\'s)?|doctoral|masters)?\s*(thesis|dissertation)\]/i', $text) + || preg_match('/\b(ph\.?d\.?|master\'?s|doctoral|doctorate)\b[^.]{0,40}\b(thesis|dissertation)\b/i', $text) + || preg_match('/\b(thesis|dissertation)\b/i', $text)) { + return ['type' => self::TYPE_THESIS, 'confidence' => 0.9]; + } + + // 2) 会议论文 + if (preg_match('/\bproceedings\b/i', $text) + || preg_match('/\bin:\s*proc\b/i', $text) + || preg_match('/\b(conference|symposium|workshop|congress)\b/i', $text) + || preg_match('/\bannual meeting\b/i', $text)) { + return ['type' => self::TYPE_CONFERENCE, 'confidence' => 0.85]; + } + + // 3) 网页 / 在线资源 + if (preg_match('/\[(internet|online)\]/i', $text) + || preg_match('/\baccessed\b/i', $text) + || preg_match('/\bavailable\s+(from|at)\b/i', $text) + || preg_match('/\bcited\s+(19|20)\d{2}/i', $text) + || ($hasUrl && !$hasDoi && !$hasJournalVol)) { + return ['type' => self::TYPE_WEB, 'confidence' => 0.8]; + } + + // 4) 图书 + if (preg_match('/\b\d+(st|nd|rd|th)\s+ed(ition)?\.?/i', $text) + || preg_match('/\bisbn\b/i', $text) + || preg_match('/\b(press|publisher|publishing house)\b/i', $text) + || preg_match('/[A-Z][A-Za-z .]+:\s*[A-Z][A-Za-z .&]+;\s*(19|20)\d{2}/', $text)) { + // 期刊卷期结构更强时不判为书 + if (!$hasJournalVol) { + return ['type' => self::TYPE_BOOK, 'confidence' => 0.8]; + } + } + + // 5) 期刊 + if ($hasJournalVol || $hasDoi + || preg_match('/\bvol\.?\s*\d+/i', $text) + || preg_match('/\bpp?\.\s*\d+/i', $text)) { + return ['type' => self::TYPE_JOURNAL, 'confidence' => $hasJournalVol ? 0.85 : 0.6]; + } + + return ['type' => self::TYPE_OTHER, 'confidence' => 0.0]; + } + + /** + * 大模型兜底:只返回枚举值之一,失败返回空串 + */ + public function classifyByLlm($text) + { + try { + $llm = new LLMService(); + $system = 'You are a bibliography classifier. Classify the reference into exactly one type. ' + . 'Allowed types: journal, book, conference, thesis, web, other. ' + . 'journal=journal article; book=book or book chapter; conference=conference/proceedings paper; ' + . 'thesis=dissertation/thesis; web=website or online resource; other=none of the above. ' + . 'Respond with ONLY a JSON object: {"type":""}. No explanation.'; + $user = "Reference:\n" . mb_substr($text, 0, 2000); + + $content = $llm->requestChat([ + ['role' => 'system', 'content' => $system], + ['role' => 'user', 'content' => $user], + ], 0); + + if ($content === null || $content === '') { + return ''; + } + + $parsed = $llm->parseJsonResponse($content); + $type = ''; + if (is_array($parsed) && isset($parsed['type'])) { + $type = strtolower(trim((string)$parsed['type'])); + } else { + // 兜底:直接从文本里找枚举词 + if (preg_match('/\b(journal|book|conference|thesis|web|other)\b/i', $content, $m)) { + $type = strtolower($m[1]); + } + } + + $allowed = [ + self::TYPE_JOURNAL, self::TYPE_BOOK, self::TYPE_CONFERENCE, + self::TYPE_THESIS, self::TYPE_WEB, self::TYPE_OTHER, + ]; + return in_array($type, $allowed, true) ? $type : ''; + } catch (\Throwable $e) { + return ''; + } + } +}