From edb3c1b27b1e731ff8e3c3dcc041609254dd7182 Mon Sep 17 00:00:00 2001 From: wyn <1074145239@qq.com> Date: Thu, 25 Jun 2026 13:50:03 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E5=8F=82=E8=80=83=E6=96=87=E7=8C=AE?= =?UTF-8?q?=E6=A0=A1=E5=AF=B9=EF=BC=8C=E6=9C=AC=E5=9C=B0=E5=A4=A7=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=8F=90=E7=A4=BA=E8=AF=8D=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ReferenceRelevanceLlmService.php | 609 ++++++++++++++++++ 1 file changed, 609 insertions(+) create mode 100644 application/common/service/ReferenceRelevanceLlmService.php diff --git a/application/common/service/ReferenceRelevanceLlmService.php b/application/common/service/ReferenceRelevanceLlmService.php new file mode 100644 index 00000000..90e1fdef --- /dev/null +++ b/application/common/service/ReferenceRelevanceLlmService.php @@ -0,0 +1,609 @@ +url = trim((string)Env::get('promotion.promotion_llm_url', '')); + $this->model = trim((string)Env::get('promotion.promotion_llm_model', '')); + $this->apiKey = trim((string)Env::get('promotion.promotion_llm_api_key', '')); + $this->timeout = max(180, intval(Env::get('promotion.promotion_llm_timeout', 180))); + // 控制发送给 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))); + } + + /** + * @return array{results:array,request_failed?:bool,reason?:string} + */ + public function checkRelevance($sectionText, $localContext, $referText, $abstractText = '', $citeGroupRefs = '') + { + $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); + } + if (mb_strlen($referText) > $this->maxReferChars) { + $referText = mb_substr($referText, 0, $this->maxReferChars); + } + if (mb_strlen($abstractText) > $this->maxAbstractChars) { + $abstractText = mb_substr($abstractText, 0, $this->maxAbstractChars); + } + + $payload = [ + 'model' => $this->model, + 'temperature' => 0, + 'messages' => [ + ['role' => 'system', 'content' => $this->buildSystemPrompt()], + ['role' => 'user', 'content' => $this->buildUserPrompt($sectionText, $localContext, $referText, $abstractText, $citeGroupRefs)], + ], + ]; + + $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) { + return array_merge($fallback, ['reason' => 'LLM response JSON parse failed']); + } + + $results = $this->normalizeResults($parsed, $citeGroupRefs, $localContext, $referText, $abstractText); + if (empty($results)) { + return array_merge($fallback, ['reason' => 'LLM returned empty or invalid results']); + } + + return ['results' => $results]; + } + + private function buildSystemPrompt() + { + 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**,与单条分必须可分离(例如 [1,2] 时文献1=0.45、文献2=0.92、联合=0.92)。 +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+** + +================================================== +【一、必须先拆解 claim】 +从【本引用位置附近上下文】中提炼最小主张单元(Claim A, Claim B…),**不要**把整句笼统归为「大概讲抗癌」。例如: +- **主语/研究对象**(化合物单体 vs 植物提取物 vs 其他物种;是否「X has been demonstrated」) +- **证据语气与层级**(demonstrated / mechanistically vs predict / suggest;本文结果 vs 讨论转引) +- 疾病流行病学(高发、死亡率) +- **点名通路/分子机制**(PI3K/AKT、MAPK、NF-κB 等,须逐项) +- **点名功能结局**(抑制增殖、凋亡、血管生成、炎症信号等,须逐项) +- 治疗/干预现状 +- **化合物化学类别**(如 pentacyclic triterpenoid) +- **天然来源分布**(fruits / vegetables / medicinal plants) +- **具体列举项**(植物学名、药名、基因名等,须逐项核对) + +================================================== +【二、逐篇文献单独判断(每条 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 +- relevance_level:highly_related | partially_related | weakly_related | unrelated +- is_relevant:score>=0.65 为 1,否则 0 +- relevance_role: + - primary_relevance:该文献是引用处主题的主要相关来源 + - supplementary_relevance:部分相关、补充性 + - minimal_relevance:仅边缘/背景沾边 + - no_meaningful_relevance:与引用处核心表述基本无关 +- reason:中文,须写明:①文献类型与**核心研究对象** ②**本文自身证据**覆盖了哪些 claim / 哪些未覆盖(含点名通路、功能结局、具体列举项;讨论转引须标明) ③**若仅为同主题不同 claim 或主语层级不对须明确写出** ④为何此分值而非更高或更低 + +主语/层级不对 → 单条 **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_*(同一 cite_group_refs 内各行必须一致)】 +当 cite_group_refs 为 "1,2" 等多篇时,除逐篇判断外,必须给出引用组整体结论: +- 这些文献合起来,是否足以支撑/匹配该引用位置的整体表述? +- combined_relevance_score:八档固定分值之一,**不是单条平均分** +- 若一篇已强相关、其余仅弱补充,联合分可接近主相关文献,但**不必等于最高单条分** +- 若原句含具体列举项(学名等)且材料未逐一核实,联合分通常 **0.85**,不给 0.98 +- 若核心 claim 无任何文献明确覆盖,联合分不能虚高 +- 多篇联合仍缺主语对齐、缺原句点名通路/结局、或主要靠讨论转引 → 联合分通常 **≤0.45~0.65**,不得因单篇讨论出现相同关键词给到 0.78+ +- combined_is_relevant:combined_relevance_score>=0.65 为 1 +- combined_relevance_level:与 combined 分数对应的等级 +- combined_reason:中文综合结论,说明各文献分工(如「文献2综述覆盖流行病学主 claim,文献1仅机制补充」)及最终分值理由 + +单条引用时:combined_* 与单条一致,combined_reason 可写「」。 + +================================================== +【四、评分与等级对照】 +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)】 +{ + "results": [ + { + "reference_no": 1, + "cite_group_refs": "1,2", + "is_relevant": 0, + "relevance_score": 0.45, + "relevance_level": "weakly_related", + "relevance_role": "minimal_relevance", + "reason": "中文单条结论", + "combined_is_relevant": 1, + "combined_relevance_score": 0.92, + "combined_relevance_level": "highly_related", + "combined_reason": "中文联合结论" + }, + { + "reference_no": 2, + "cite_group_refs": "1,2", + ... + } + ] +} +PROMPT; + } + + private function buildUserPrompt($sectionText, $localContext, $referText, $abstractText, $citeGroupRefs) + { + $parts = ["【正文节 t_article_main】\n" . $sectionText]; + if (trim((string)$citeGroupRefs) !== '') { + $mode = strpos($citeGroupRefs, ',') !== false ? '联合引用' : '单独引用'; + $parts[] = "【引用文献组 cite_group_refs】{$citeGroupRefs}({$mode})"; + } + if ($localContext !== '') { + $parts[] = "【本引用位置附近上下文(优先据此拆解 claim)】\n" . $localContext; + } + $parts[] = "【参考文献书目(按编号)】\n" . $referText; + if ($abstractText !== '') { + $parts[] = "【文献摘要/清洗后内容(Europe PMC·PubMed·Crossref·PDF)】\n" . $abstractText; + } + $parts[] = '请先拆解最小主张单元(主语层级、证据来源、点名通路/结局逐项核对),判断每篇文献类型与**本文自身证据**,再**逐篇独立**给出单条 relevance_score(讨论转引、提取物/计算预测不得抬高;弱相关文献不得因联合而高分),最后给出 combined_*。仅输出 results 数组 JSON。'; + + return implode("\n\n", $parts); + } + + private function normalizeResults(array $parsed, $defaultCiteGroupRefs, $localContext = '', $referText = '', $abstractText = '') + { + $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(); + $localContext = trim((string)$localContext); + $referText = trim((string)$referText); + $abstractText = trim((string)$abstractText); + + $out = []; + foreach ($rows as $item) { + if (!is_array($item)) { + continue; + } + $refNo = intval(isset($item['reference_no']) ? $item['reference_no'] : 0); + 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']); + } + + $level = $this->levelFromScore($score, isset($item['relevance_level']) ? $item['relevance_level'] : ''); + $role = $this->normalizeRelevanceRole(isset($item['relevance_role']) ? $item['relevance_role'] : ''); + $reason = $this->cleanReason(isset($item['reason']) ? $item['reason'] : ''); + + list($score, $level, $isRelevant, $role) = $this->enforceSingleReferenceConsistency( + $score, + $level, + $isRelevant, + $role, + $bands + ); + + $combinedScore = $this->snapScore( + floatval(isset($item['combined_relevance_score']) ? $item['combined_relevance_score'] : $score), + $bands + ); + $combinedRelevant = $combinedScore >= 0.65 - 0.001; + if (array_key_exists('combined_is_relevant', $item)) { + $combinedRelevant = $this->boolVal($item['combined_is_relevant']); + } + + $combinedLevel = $this->levelFromScore( + $combinedScore, + isset($item['combined_relevance_level']) ? $item['combined_relevance_level'] : '' + ); + list($combinedScore, $combinedLevel, $combinedRelevant) = $this->enforceCombinedConsistency( + $combinedScore, + $combinedLevel, + $combinedRelevant, + $bands + ); + + $citeGroupRefs = trim((string)(isset($item['cite_group_refs']) ? $item['cite_group_refs'] : $defaultCiteGroupRefs)); + if ($citeGroupRefs === '' && $defaultCiteGroupRefs !== '') { + $citeGroupRefs = trim((string)$defaultCiteGroupRefs); + } + + $out[] = [ + 'reference_no' => $refNo, + 'cite_group_refs' => $citeGroupRefs, + 'is_relevant' => $isRelevant ? 1 : 0, + 'relevance_score' => $score, + 'relevance_level' => $level, + 'relevance_role' => $role, + 'reason' => $reason, + 'combined_is_relevant' => $combinedRelevant ? 1 : 0, + 'combined_relevance_score' => $combinedScore, + 'combined_relevance_level' => $combinedLevel, + 'combined_reason' => $this->cleanReason(isset($item['combined_reason']) ? $item['combined_reason'] : ''), + ]; + } + + $out = $this->syncCombinedFieldsAcrossGroup($out); + + return $out; + } + + 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, $combinedRelevant, array $bands) + { + $combinedScore = $this->snapScore(floatval($combinedScore), $bands); + $combinedLevel = $this->levelFromScore($combinedScore, $combinedLevel); + $combinedRelevant = $combinedScore >= 0.65 - 0.001; + + return [$combinedScore, $combinedLevel, $combinedRelevant]; + } + + private function syncCombinedFieldsAcrossGroup(array $out) + { + $groups = []; + foreach ($out as $idx => $row) { + $key = (string)$row['cite_group_refs']; + if ($key === '') { + $key = 'ref:' . $row['reference_no']; + } + $groups[$key][] = $idx; + } + + foreach ($groups as $indices) { + if (count($indices) <= 1) { + continue; + } + $bestIdx = $indices[0]; + $bestScore = floatval($out[$bestIdx]['combined_relevance_score']); + foreach ($indices as $idx) { + $s = floatval($out[$idx]['combined_relevance_score']); + if ($s >= $bestScore) { + $bestScore = $s; + $bestIdx = $idx; + } + } + $src = $out[$bestIdx]; + foreach ($indices as $idx) { + $out[$idx]['combined_is_relevant'] = intval($src['combined_is_relevant']); + $out[$idx]['combined_relevance_score'] = floatval($src['combined_relevance_score']); + $out[$idx]['combined_relevance_level'] = (string)$src['combined_relevance_level']; + $out[$idx]['combined_reason'] = (string)$src['combined_reason']; + } + } + + return $out; + } + + 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 'no_meaningful_relevance'; + } + + private function cleanReason($reason) + { + $reason = trim(preg_replace('/\s+/u', ' ', (string)$reason)); + return mb_substr($reason, 0, 2000); + } + + 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 = ''; + 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); + if ($raw === false) { + $this->lastPostError = 'LLM curl error: ' . curl_error($ch); + \think\Log::warning('ReferenceRelevanceLlm: ' . $this->lastPostError); + curl_close($ch); + return null; + } + $httpCode = intval(curl_getinfo($ch, CURLINFO_HTTP_CODE)); + curl_close($ch); + 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 parseJson($raw) + { + $raw = trim((string)$raw); + if ($raw === '') { + return null; + } + $raw = preg_replace('/^```[a-zA-Z]*\s*|```$/m', '', $raw); + $raw = trim($raw); + $decoded = json_decode($raw, true); + if (is_array($decoded)) { + return $decoded; + } + if (preg_match('/\{[\s\S]*\}/', $raw, $m)) { + $decoded = json_decode($m[0], true); + if (is_array($decoded)) { + return $decoded; + } + } + + return null; + } +} From 6fdc4efb6f88bb95b3119689e40fd30b1ed0aa2c Mon Sep 17 00:00:00 2001 From: wyn <1074145239@qq.com> Date: Mon, 29 Jun 2026 10:23:27 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E5=8F=82=E8=80=83=E6=96=87=E7=8C=AE?= =?UTF-8?q?=E6=A0=A1=E5=AF=B9=E5=8D=87=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application/api/controller/Base.php | 101 + application/api/controller/Preaccept.php | 45 +- application/api/controller/References.php | 264 ++- application/command.php | 1 + application/common/PubmedService.php | 62 + application/common/ReferenceCheckService.php | 1721 ++++++++++++++--- application/common/mq/RabbitMqConfig.php | 6 + .../common/mq/ReferenceCheckArticleWorker.php | 4 + application/common/service/LLMService.php | 843 ++++++-- .../service/ReferenceRelevanceLlmService.php | 6 + application/extra/rabbitmq.php | 7 + 11 files changed, 2680 insertions(+), 380 deletions(-) diff --git a/application/api/controller/Base.php b/application/api/controller/Base.php index 380066a2..6d76cb01 100644 --- a/application/api/controller/Base.php +++ b/application/api/controller/Base.php @@ -1178,6 +1178,107 @@ class Base extends Controller return $ids; } + /** + * 解析方括号引用内层(如 1,2 / 3-5),展开为文献序号列表。 + * + * @return int[] + */ + protected function expandCitationBracketNumbers(string $referencePart): array + { + $referencePart = trim($referencePart); + if ($referencePart === '') { + return []; + } + $referencePart = str_replace( + [',', '–', '—', '−', '‐', '‑'], + [',', '-', '-', '-', '-', '-'], + $referencePart + ); + $out = []; + $segments = preg_split('/\s*,\s*/', $referencePart); + foreach ($segments as $seg) { + $seg = trim((string)$seg); + if ($seg === '') { + continue; + } + $seg = str_replace(['–', '—', '−', '‐', '‑'], '-', $seg); + if (preg_match('/^(\d+)\s*-\s*(\d+)$/', $seg, $m)) { + $a = intval($m[1]); + $b = intval($m[2]); + if ($a > $b) { + $t = $a; + $a = $b; + $b = $t; + } + for ($i = $a; $i <= $b; $i++) { + $out[] = $i; + } + } else { + $n = intval($seg); + if ($n > 0) { + $out[] = $n; + } + } + } + return $out; + } + + /** + * 从正文片段提取被引用的文献序号(reference_no = index+1)。 + * 兼容 [n] / [n] 两种形态。 + * + * @return int[] + */ + protected function extractCitationRefNosFromMainContent(string $text, int $pArticleId = 0): array + { + if ($text === '') { + return []; + } + + $nos = []; + + $pReferIds = $this->extractMyciteIds($text); + if (!empty($pReferIds) && $pArticleId > 0) { + $refers = Db::name('production_article_refer') + ->where('p_article_id', $pArticleId) + ->whereIn('p_refer_id', $pReferIds) + ->where('state', 0) + ->field('p_refer_id,index') + ->select(); + $idToNo = []; + foreach ($refers as $row) { + $idToNo[intval($row['p_refer_id'])] = intval($row['index']) + 1; + } + foreach ($pReferIds as $pid) { + if (isset($idToNo[$pid])) { + $nos[] = $idToNo[$pid]; + } + } + } + + if (preg_match_all('/(?:<\s*blue[^>]*>)?\[([^\]]+)\](?:<\/\s*blue\s*>)?/iu', $text, $m)) { + foreach ($m[1] as $inner) { + $innerNorm = str_replace( + [',', '–', '—', '−', '‐', '‑'], + [',', '-', '-', '-', '-', '-'], + trim((string)$inner) + ); + if (!preg_match('/^[\d\s,\-]+$/u', $innerNorm)) { + continue; + } + foreach ($this->expandCitationBracketNumbers($innerNorm) as $n) { + if ($n > 0) { + $nos[] = $n; + } + } + } + } + + $nos = array_values(array_unique($nos)); + sort($nos, SORT_NUMERIC); + return $nos; + } + /** * table_data:二维数组 JSON [[{text,colspan,rowspan},...],...];支持双重 JSON 字符串编码。 * diff --git a/application/api/controller/Preaccept.php b/application/api/controller/Preaccept.php index 79794434..9f89b31d 100644 --- a/application/api/controller/Preaccept.php +++ b/application/api/controller/Preaccept.php @@ -7,7 +7,7 @@ use think\Env; use think\Queue; use think\Validate; use app\common\CrossrefService; -use app\common\ReferenceCheckService; +use app\common\ReferenceRelevanceCheckService; class Preaccept extends Base { @@ -27,7 +27,7 @@ class Preaccept extends Base return; } try { - (new ReferenceCheckService())->clearArticleChecksByPArticleId($pArticleId); + (new ReferenceRelevanceCheckService())->clearArticleChecksByPArticleId($pArticleId); } catch (\Exception $e) { \think\Log::error( 'resetArticleChecksOnReferChange[' . $sourceTag . '] p_article_id=' @@ -1220,6 +1220,14 @@ class Preaccept extends Base $insert['ctime'] = time(); $this->article_main_log_obj->insert($insert); +// $articleId = intval($am_info['article_id']); +// $amId = intval($data['am_id']); +// +// // 本段引用集合变化(如 10,11 → 11,12)时仅清空该 am_id 下的校对明细 +// if ($this->hasMainCitationChange($old_content, $new_raw_content, $articleId)) { +// $this->clearMainChecksOnCitationChange($articleId, $amId); +// } + // 判断是否存在“引用删除”(新 content 相对旧 content 缺少 ) $hasCitationDeletion = $this->hasMyciteDeletion($old_content, $new_raw_content); @@ -1245,6 +1253,39 @@ class Preaccept extends Base //返回更新数据 20260119 end } + /** + * 正文单节保存后,仅清空该 am_id 下已有的引用校对明细(按 article_id 定位)。 + */ + private function clearMainChecksOnCitationChange(int $articleId, int $amId) + { + if ($articleId <= 0 || $amId <= 0) { + return; + } + try { + (new ReferenceCheckService())->clearChecksByAmId($articleId, $amId); + } catch (\Exception $e) { + \think\Log::error( + 'clearMainChecksOnCitationChange article_id=' . $articleId + . ' am_id=' . $amId . ' ' . $e->getMessage() + ); + } + } + + /** + * 本段正文引用集合是否变化(增删改任一即 true)。 + * old 多为库内 [n],new 多为编辑器提交的 。 + */ + private function hasMainCitationChange(string $oldContent, string $newContent, int $articleId): bool + { + $pArticleId = intval(Db::name('production_article') + ->where('article_id', $articleId) + ->whereIn('state', [0, 2]) + ->value('p_article_id')); + $oldNos = $this->extractCitationRefNosFromMainContent($oldContent, $pArticleId); + $newNos = $this->extractCitationRefNosFromMainContent($newContent, $pArticleId); + return $oldNos !== $newNos; + } + /** * 是否发生 删除(new 相对 old 少了任意引用 id) */ diff --git a/application/api/controller/References.php b/application/api/controller/References.php index 331edd62..ea539ed6 100644 --- a/application/api/controller/References.php +++ b/application/api/controller/References.php @@ -12,6 +12,8 @@ use think\Db; use think\Env; use think\Queue; use app\common\ReferenceCheckService; +use app\common\ReferenceRelevanceCheckService; +use app\common\DbReconnectHelper; /** * @title 参考文献 * @description 相关方法汇总 @@ -1309,11 +1311,195 @@ class References extends Base } return json_encode(['status' => 8,'msg' => 'fail']); } + // ============================================================ + // 参考文献「主题相关性」校对(独立模块,RabbitMQ 链式消费) + // 表:t_article_reference_relevance_check_result / t_article_reference_relevance_check_batch + // 消费:php think reference_relevance:mq-consume + // ============================================================ + /** - * 参考文献第一次校对 + * 启动整篇参考文献相关性校对 + * POST: p_article_id(必填) + * + * 文献摘要/内容优先读 t_production_article_refer.abstract_text、refer_content_cleaned; + * 二者都为空时在校对执行阶段抓取并回写 refer 表,校对时始终从 refer 表读取。 + */ + public function allReferenceCheckAI() + { + $aParam = $this->request->post(); + $iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']); + if ($iPArticleId <= 0) { + return jsonError('Please select an article'); + } + + $aProductionArticle = Db::name('production_article') + ->field('p_article_id,article_id') + ->where(['p_article_id' => $iPArticleId, 'state' => ['in', [0, 2]]]) + ->find(); + if (empty($aProductionArticle)) { + return jsonError('No articles found'); + } + if ($this->checkReferStatus($iPArticleId) == 0) { + return jsonError('Please correct the reference content before running the check.'); + } + + $existing = Db::name('article_reference_relevance_check_result') + ->where('p_article_id', $iPArticleId) + ->count(); + if (intval($existing) > 0) { + return jsonError('This article already has relevance check records. Use referenceRelevanceCheckResetAI to rerun.'); + } + + try { + DbReconnectHelper::ensure(); + $result = (new ReferenceRelevanceCheckService())->enqueueByPArticle($aProductionArticle); + if (empty($result['check_ids'])) { + return jsonError('No reference citations were found in the article.'); + } + return jsonSuccess($result); + } catch (\Exception $e) { + return jsonError($e->getMessage()); + } + } + + /** + * 相关性校对进度 + * POST: p_article_id + */ + public function referenceRelevanceCheckProgressAI() + { + $aParam = $this->request->post(); + $iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']); + if ($iPArticleId <= 0) { + return jsonError('p_article_id is required'); + } + try { + $result = (new ReferenceRelevanceCheckService())->getProgressByPArticleId($iPArticleId); + return jsonSuccess($result); + } catch (\Exception $e) { + return jsonError($e->getMessage()); + } + } + + /** + * 按 p_article_id 查整篇文章相关性校对总状态(用于前端按钮分流) + * + * POST/GET: p_article_id(必填) + * + * 返回 status:0=未校对 1=校对中 2=校对完成 + * 计数维度为参考文献(按 reference_no 分组),与 referenceRelevanceCheckProgressAI 一致。 + */ + public function referenceRelevanceCheckArticleStatusAI() + { + $aParam = $this->request->post(); + if (empty($aParam)) { + $aParam = $this->request->param(); + } + + $iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']); + if ($iPArticleId <= 0) { + return jsonError('p_article_id is required'); + } + + try { + $result = (new ReferenceRelevanceCheckService())->getArticleProgressStatusByPArticleId($iPArticleId); + return jsonSuccess($result); + } catch (\Exception $e) { + return jsonError($e->getMessage()); + } + } + + /** + * 按 p_refer_id 查相关性校对明细 + * POST: p_refer_id + */ + public function referenceRelevanceCheckDetailsAI() + { + $aParam = $this->request->post(); + $iPReferId = empty($aParam['p_refer_id']) ? 0 : intval($aParam['p_refer_id']); + if ($iPReferId <= 0) { + return jsonError('p_refer_id is required'); + } + try { + $result = (new ReferenceRelevanceCheckService())->getDetailsByPReferId($iPReferId); + return jsonSuccess($result); + } catch (\Exception $e) { + return jsonError($e->getMessage()); + } + } + + /** + * 清空并重新执行相关性校对 + * POST: p_article_id + */ + public function referenceRelevanceCheckResetAI() + { + $aParam = $this->request->post(); + $iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']); + if ($iPArticleId <= 0) { + return jsonError('Please select an article'); + } + $aProductionArticle = Db::name('production_article') + ->field('p_article_id,article_id') + ->where(['p_article_id' => $iPArticleId, 'state' => ['in', [0, 2]]]) + ->find(); + if (empty($aProductionArticle)) { + return jsonError('No articles found'); + } + if ($this->checkReferStatus($iPArticleId) == 0) { + return jsonError('Please correct the reference content before running the check.'); + } + try { + $result = (new ReferenceRelevanceCheckService())->resetAndRecheckByArticle($aProductionArticle); + return jsonSuccess($result); + } catch (\Exception $e) { + return jsonError($e->getMessage()); + } + } + + /** + * 仅清空相关性校对记录(不重跑) + * POST: p_article_id + */ + public function referenceRelevanceCheckClearAI() + { + $aParam = $this->request->post(); + $iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']); + if ($iPArticleId <= 0) { + return jsonError('p_article_id is required'); + } + try { + $deleted = (new ReferenceRelevanceCheckService())->clearByPArticleId($iPArticleId); + return jsonSuccess(['p_article_id' => $iPArticleId, 'deleted' => intval($deleted)]); + } catch (\Exception $e) { + return jsonError($e->getMessage()); + } + } + + /** + * 仅重跑相关性 status=0 的记录(不清空,不抓摘要,不清洗文献内容) + * POST: p_article_id + */ + public function referenceRelevanceCheckRecheckPendingAI() + { + $aParam = $this->request->post(); + $iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']); + if ($iPArticleId <= 0) { + return jsonError('p_article_id is required'); + } + try { + $result = (new ReferenceRelevanceCheckService())->recheckPendingOnlyByArticle($iPArticleId); + return jsonSuccess($result); + } catch (\Exception $e) { + return jsonError($e->getMessage()); + } + } + + /** + * 参考文献第一次校对(支撑力度) * @return \think\response\Json */ - public function allReferenceCheckAI(){ + public function allReferenceCheckAI2(){ //获取参数 $aParam = empty($aParam) ? $this->request->post() : $aParam; @@ -1537,7 +1723,6 @@ class References extends Base * p_article_id(可选) * * 仅重跑 status=3(校对失败)的记录;不改动 refer_text,只重置结果字段后入 RabbitMQ 批次队列。 - * 返回:p_refer_id、p_article_id、reset、queued、check_ids、queue */ public function referenceCheckRecheckFailedAI() { @@ -1561,6 +1746,36 @@ class References extends Base } } + /** + * 某条参考文献下「校对失败」重跑,并联动同一引用标签分组(如 [1,2])全部重跑(异步) + * + * POST/GET: p_refer_id(必填) + * p_article_id(可选) + * + * 返回:p_refer_id、p_article_id、reset、queued、check_ids、queue + */ + public function referenceCheckRecheckFailedWithGroupAI() + { + $aParam = $this->request->post(); + if (empty($aParam)) { + $aParam = $this->request->param(); + } + + $iPReferId = empty($aParam['p_refer_id']) ? 0 : intval($aParam['p_refer_id']); + if ($iPReferId <= 0) { + return json_encode(array('status' => 2, 'msg' => 'Please select a reference')); + } + + $iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']); + + try { + $result = (new ReferenceCheckService())->enqueueRecheckFailedByPReferIdWithGroup($iPReferId, $iPArticleId); + return jsonSuccess($result); + } catch (\Exception $e) { + return jsonError($e->getMessage()); + } + } + /** * 按 p_refer_id 查单条参考文献的校对明细与进度 * @@ -1590,6 +1805,47 @@ class References extends Base } } + /** + * 对校对明细中从未出现过的参考文献(p_refer_id 差集)重新扫描全文并入队校对 + * + * POST/GET: p_article_id(必填) + * + * 差集:production_article_refer(state=0) 减去 article_reference_check_result 已出现的 p_refer_id。 + * 适用:首次校对漏匹配、表格后上传、正文补标等场景。不重置已有明细。 + * 前置:须已执行过第一次校对(库中已有校对记录)。 + * + * 返回:missing_p_refer_ids、matched_p_refer_ids、still_unmatched_p_refer_ids、 + * queued、new_reference_nos、check_ids、queue + */ + public function referenceCheckRematchNewAI() + { + $aParam = $this->request->post(); + if (empty($aParam)) { + $aParam = $this->request->param(); + } + + $iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']); + if ($iPArticleId <= 0) { + return json_encode(array('status' => 2, 'msg' => 'Please select an article')); + } + + $aWhere = ['p_article_id' => $iPArticleId, 'state' => ['in', [0, 2]]]; + $aProductionArticle = Db::name('production_article')->field('p_article_id,article_id')->where($aWhere)->find(); + if (empty($aProductionArticle)) { + return json_encode(array('status' => 3, 'msg' => 'No articles found')); + } + if ($this->checkReferStatus($iPArticleId) == 0) { + return jsonError('Please correct the reference content before running the check.'); + } + + try { + $result = (new ReferenceCheckService())->enqueueNewlyMatchedByPArticle($aProductionArticle); + return jsonSuccess($result); + } catch (\Exception $e) { + return jsonError($e->getMessage()); + } + } + public function checkReferStatus($p_article_id){ $list = $this->production_article_refer_obj->where('p_article_id', $p_article_id)->where('state', 0)->select(); if (!$list) { @@ -1604,4 +1860,6 @@ class References extends Base } return $frag; } + + } diff --git a/application/command.php b/application/command.php index 43892e98..cbea1b3b 100644 --- a/application/command.php +++ b/application/command.php @@ -11,4 +11,5 @@ return [ 'app\\command\\ReferenceCheckMqConsume', + 'app\\command\\ReferenceRelevanceMqConsume', ]; diff --git a/application/common/PubmedService.php b/application/common/PubmedService.php index ad17e2da..50f565ec 100644 --- a/application/common/PubmedService.php +++ b/application/common/PubmedService.php @@ -96,6 +96,68 @@ class PubmedService return $info; } + /** + * 按书目信息检索 PubMed(标题 + 第一作者 + 年份) + */ + public function searchByBibliographic($title, $author = '', $year = ''): ?array + { + $title = trim((string)$title); + if ($title === '') { + return null; + } + + $terms = ['(' . $this->quoteTerm($title) . '[Title])']; + $author = trim((string)$author); + if ($author !== '') { + $parts = preg_split('/[,;]/', $author); + $first = trim((string)($parts[0] ?? '')); + if ($first !== '') { + $terms[] = '(' . $this->quoteTerm($first) . '[Author])'; + } + } + $year = trim((string)$year); + if ($year !== '' && preg_match('/^(19|20)\d{2}$/', $year)) { + $terms[] = '(' . $year . '[pdat])'; + } + + $pmid = $this->esearch(implode(' AND ', $terms)); + if (!$pmid) { + return null; + } + + $info = $this->fetchByPmid($pmid); + if (!$info) { + return null; + } + $info['pmid'] = $pmid; + $info['doi'] = $this->extractDoiFromPmidRecord($pmid); + return $info; + } + + private function quoteTerm($text) + { + return str_replace('"', '', trim((string)$text)); + } + + private function extractDoiFromPmidRecord($pmid) + { + $url = $this->base . 'efetch.fcgi?' . http_build_query([ + 'db' => 'pubmed', + 'id' => $pmid, + 'retmode' => 'xml', + 'tool' => $this->tool, + 'email' => $this->email, + ]); + $xml = $this->httpGet($url); + if ($xml === '') { + return ''; + } + if (preg_match('/([^<]+)<\/ArticleId>/i', $xml, $m)) { + return trim($m[1]); + } + return ''; + } + // ----------------- Internals ----------------- private function esearch(string $term): ?string diff --git a/application/common/ReferenceCheckService.php b/application/common/ReferenceCheckService.php index e551a482..0cf20986 100644 --- a/application/common/ReferenceCheckService.php +++ b/application/common/ReferenceCheckService.php @@ -9,6 +9,8 @@ use app\common\mq\ReferenceCheckMqPublisher; /** * 正文 <blue>[n]</blue> 引用与 t_production_article_refer(index+1=n)相关性校对。 + * 校对上下文取 t_article_main 一条记录(正文 content 或表格 table_data 展平)。 + * 同一引用标签 [1,2]、[4-6] 联合校对,cite_group_refs 存展开序号。 * LLM 配置与 PromotionLlmService 相同;异步任务走 RabbitMQ(一篇一条消息)。 */ class ReferenceCheckService @@ -69,6 +71,9 @@ class ReferenceCheckService /** LLM 评分(confidence)通过阈值:>= 该值视为"通过" */ const PASS_CONFIDENCE_THRESHOLD = 0.65; + /** 是否启用二轮 DOI/Crossref 复核(暂时关闭时设为 false) */ + const SECOND_PASS_ENABLED = false; + /** * 正文引用标签两种排版(带 /u): * 1) [8, 9][13-15] —— 方括号在 blue 内 @@ -93,6 +98,11 @@ class ReferenceCheckService return isset($arr[$key]) ? $arr[$key] : $default; } + private function isSecondPassEnabled() + { + return self::SECOND_PASS_ENABLED; + } + /** 新建/重置校对明细时的队列初始字段 */ private function newCheckRecordFields(array $fields, $queueStatus = self::QUEUE_PENDING, $retryCount = 0) { @@ -101,8 +111,26 @@ class ReferenceCheckService return $fields; } + /** 重置校对结果时清零的字段(含支撑力度扩展字段) */ + private function referenceCheckResultResetFields(array $extra = []) + { + return array_merge([ + 'status' => self::RECORD_PENDING, + 'is_match' => 0, + 'can_support' => 0, + 'confidence' => 0, + 'reason' => '', + 'support_role' => '', + 'combined_can_support' => 0, + 'combined_confidence' => 0, + 'combined_reason' => '', + 'error_msg' => '', + ], $extra); + } + public function markQueueRuntime($checkId, $queueStatus, $retryCount = null) { + DbReconnectHelper::ensure(); $checkId = intval($checkId); if ($checkId <= 0) { return 0; @@ -113,6 +141,84 @@ class ReferenceCheckService } return Db::name('article_reference_check_result')->where('id', $checkId)->update($fields); } + public function enqueueByPArticle($prod){ + if (empty($prod)) { + throw new \RuntimeException('production_article not found'); + } + $pArticleId = intval($prod['p_article_id']); + $articleId = intval($prod['article_id']); + $referMap = $this->loadReferMapByPArticleId($pArticleId); + + $mains = Db::name('article_main') + ->field('am_id,content,article_id,type,amt_id') + ->where('article_id', $articleId) + ->whereIn('state', [0, 2]) + ->order('sort asc') + ->select(); + if (empty($mains)) { + throw new \RuntimeException('article_main is empty'); + } + $queued = 0; + $skipped = 0; + $pendingJobs = []; + $amIdsWithJobs = []; + $now = date('Y-m-d H:i:s'); + foreach ($mains as $main) { + $amId = intval($main['am_id']); + $citations = $this->extractReferencesForArticleMain($main); + if (empty($citations)) { + $this->setAmRefCheckStatus($amId, self::AM_STATUS_NONE); + continue; + } + $sectionText = $this->resolveSectionTextForArticleMain($main); + foreach ($citations as $cite) { + foreach ($cite['reference_numbers'] as $refNo) { + $referIndex = $refNo - 1; + if ($referIndex < 0 || !isset($referMap[$referIndex])) { + $skipped++; + continue; + } + $scope = [ + 'article_id' => $main['article_id'], + 'p_article_id' => $pArticleId, + 'am_id' => $amId, + 'section_text' => $sectionText, + ]; + $checkId = $this->insertCitationCheckRecord($scope, $cite, $refNo, $referMap[$referIndex], $now); + if ($checkId <= 0) { + $skipped++; + continue; + } + + $this->appendCitationPendingJob($pendingJobs, $checkId, $refNo, $amId, $cite['text_start']); + $queued++; + $amIdsWithJobs[$amId] = true; + } + } + break; + } + $checkIds = $this->enqueueChecksSortedByReferenceNo($pendingJobs, $pArticleId, 'enqueue'); + foreach (array_keys($amIdsWithJobs) as $amId) { + $this->setAmRefCheckStatus($amId, self::AM_STATUS_RUNNING); + } + + return [ + 'article_id' => $articleId, + 'p_article_id' => $pArticleId, + 'queued' => $queued, + 'skipped' => $skipped, + 'check_ids' => $checkIds, + 'queue' => self::TRANSPORT_RABBITMQ, + ]; + } + + + + + + + + /** * 合并匹配两种 blue 引用排版,按在正文中的起始位置排序。 @@ -173,6 +279,7 @@ class ReferenceCheckService 'refer_index' => intval($this->arrGet($extra, 'refer_index', 0)), 'reference_no' => intval($this->arrGet($extra, 'reference_no', 0)), 'reference_raw' => (string)$this->arrGet($extra, 'reference_raw', ''), + 'cite_group_refs' => (string)$this->arrGet($extra, 'cite_group_refs', ''), 'cite_tag_start' => intval($this->arrGet($extra, 'cite_tag_start', 0)), 'cite_tag_end' => intval($this->arrGet($extra, 'cite_tag_end', 0)), 'text_start' => intval($this->arrGet($extra, 'text_start', 0)), @@ -229,6 +336,7 @@ class ReferenceCheckService $skipped = 0; $pendingJobs = []; $now = date('Y-m-d H:i:s'); + $sectionText = $this->resolveSectionTextForArticleMain($main); foreach ($citations as $cite) { foreach ($cite['reference_numbers'] as $refNo) { $referIndex = $refNo - 1; @@ -236,30 +344,24 @@ class ReferenceCheckService $skipped++; continue; } - $refer = $referMap[$referIndex]; - $referText = $this->formatReferForLlm($refer); - - $checkId = Db::name('article_reference_check_result')->insertGetId($this->newCheckRecordFields([ - 'article_id' => $main['article_id'], - 'p_article_id' => $pArticleId, - 'am_id' => intval($main['am_id']), - 'reference_no' => $refNo, - 'refer_index' => $refNo, - 'origin_text' => $cite['original_text'], - 'refer_text' => $referText, - 'p_refer_id' => $referMap[$referIndex]['p_refer_id'], - 'text_start' => $cite['text_start'], - 'text_end' => $cite['text_end'], - 'status' => self::RECORD_PENDING, - 'created_at' => $now, - 'updated_at' => $now, - ])); - $pendingJobs[] = [ - 'check_id' => intval($checkId), - 'reference_no' => intval($refNo), + $scope = [ + 'article_id' => $main['article_id'], + 'p_article_id' => $pArticleId, 'am_id' => intval($main['am_id']), - 'text_start' => intval($cite['text_start']), + 'section_text' => $sectionText, ]; + $checkId = $this->insertCitationCheckRecord($scope, $cite, $refNo, $referMap[$referIndex], $now); + if ($checkId <= 0) { + $skipped++; + continue; + } + $this->appendCitationPendingJob( + $pendingJobs, + $checkId, + $refNo, + intval($main['am_id']), + $cite['text_start'] + ); } } @@ -276,6 +378,14 @@ class ReferenceCheckService throw new \InvalidArgumentException('article_id is required'); } + if (!$this->isSecondPassEnabled()) { + return [ + 'article_id' => $articleId, + 'check_ids2' => [], + 'queued' => 0, + ]; + } + $rows = Db::name('article_reference_check_result') ->where('article_id', $articleId) ->where('status', self::RECORD_COMPLETED) @@ -300,87 +410,6 @@ class ReferenceCheckService 'queued' => count($checkIds2), ]; } - public function enqueueByPArticle($prod){ - if (empty($prod)) { - throw new \RuntimeException('production_article not found'); - } - $pArticleId = intval($prod['p_article_id']); - $articleId = intval($prod['article_id']); - $referMap = $this->loadReferMapByPArticleId($pArticleId); - - $mains = Db::name('article_main') - ->field('am_id,content,article_id,type,amt_id') - ->where('article_id', $articleId) - ->whereIn('state', [0, 2]) - ->order('sort asc') - ->select(); - if (empty($mains)) { - throw new \RuntimeException('article_main is empty'); - } - $queued = 0; - $skipped = 0; - $pendingJobs = []; - $amIdsWithJobs = []; - $now = date('Y-m-d H:i:s'); - foreach ($mains as $main) { - $amId = intval($main['am_id']); - $citations = $this->extractReferencesForArticleMain($main); - if (empty($citations)) { - $this->setAmRefCheckStatus($amId, self::AM_STATUS_NONE); - continue; - } - foreach ($citations as $cite) { - foreach ($cite['reference_numbers'] as $refNo) { - $referIndex = $refNo - 1; - if ($referIndex < 0 || !isset($referMap[$referIndex])) { - $skipped++; - continue; - } - $refer = $referMap[$referIndex]; - $referText = $this->formatReferForLlm($refer); - - // [70-73] 展开为 reference_no=70,71,72,73 共 4 条记录;先入队表,再按文献号正序校对 - $checkId = Db::name('article_reference_check_result')->insertGetId($this->newCheckRecordFields([ - 'article_id' => $main['article_id'], - 'p_article_id' => $pArticleId, - 'am_id' => $amId, - 'reference_no' => $refNo, - 'refer_index' => $refNo, - 'origin_text' => $cite['original_text'], - 'refer_text' => $referText, - 'p_refer_id' => $referMap[$referIndex]['p_refer_id'], - 'text_start' => $cite['text_start'], - 'text_end' => $cite['text_end'], - 'status' => self::RECORD_PENDING, - 'created_at' => $now, - 'updated_at' => $now, - ])); - - $pendingJobs[] = [ - 'check_id' => intval($checkId), - 'reference_no' => intval($refNo), - 'am_id' => $amId, - 'text_start' => intval($cite['text_start']), - ]; - $queued++; - $amIdsWithJobs[$amId] = true; - } - } - } - $checkIds = $this->enqueueChecksSortedByReferenceNo($pendingJobs, $pArticleId, 'enqueue'); - foreach (array_keys($amIdsWithJobs) as $amId) { - $this->setAmRefCheckStatus($amId, self::AM_STATUS_RUNNING); - } - - return [ - 'article_id' => $articleId, - 'p_article_id' => $pArticleId, - 'queued' => $queued, - 'skipped' => $skipped, - 'check_ids' => $checkIds, - 'queue' => self::TRANSPORT_RABBITMQ, - ]; - } public function enqueueByArticle($articleId){ if ($articleId <= 0) { throw new \InvalidArgumentException('article_id is required'); @@ -416,6 +445,7 @@ class ReferenceCheckService $this->setAmRefCheckStatus($amId, self::AM_STATUS_NONE); continue; } + $sectionText = $this->resolveSectionTextForArticleMain($main); foreach ($citations as $cite) { foreach ($cite['reference_numbers'] as $refNo) { $referIndex = $refNo - 1; @@ -423,32 +453,19 @@ class ReferenceCheckService $skipped++; continue; } - $refer = $referMap[$referIndex]; - $referText = $this->formatReferForLlm($refer); - - // [70-73] 展开为 reference_no=70,71,72,73 共 4 条记录;先入队表,再按文献号正序校对 - $checkId = Db::name('article_reference_check_result')->insertGetId($this->newCheckRecordFields([ - 'article_id' => $main['article_id'], - 'p_article_id' => $pArticleId, - 'am_id' => $amId, - 'reference_no' => $refNo, - 'refer_index' => $refNo, - 'origin_text' => $cite['original_text'], - 'refer_text' => $referText, - 'p_refer_id' => $referMap[$referIndex]['p_refer_id'], - 'text_start' => $cite['text_start'], - 'text_end' => $cite['text_end'], - 'status' => self::RECORD_PENDING, - 'created_at' => $now, - 'updated_at' => $now, - ])); - - $pendingJobs[] = [ - 'check_id' => intval($checkId), - 'reference_no' => intval($refNo), + $scope = [ + 'article_id' => $main['article_id'], + 'p_article_id' => $pArticleId, 'am_id' => $amId, - 'text_start' => intval($cite['text_start']), + 'section_text' => $sectionText, ]; + $checkId = $this->insertCitationCheckRecord($scope, $cite, $refNo, $referMap[$referIndex], $now); + if ($checkId <= 0) { + $skipped++; + continue; + } + + $this->appendCitationPendingJob($pendingJobs, $checkId, $refNo, $amId, $cite['text_start']); $queued++; $amIdsWithJobs[$amId] = true; } @@ -470,6 +487,200 @@ class ReferenceCheckService ]; } + /** + * 对「参考文献表中有、校对明细中从未出现过的 p_refer_id」重新扫描全文并入队校对。 + * + * 差集:production_article_refer(state=0) 的 p_refer_id + * 减去 article_reference_check_result 中已出现过的 p_refer_id。 + * 仅对上述缺失文献在全文(含表格)中查找引用标签,命中则新增明细并入队。 + * 不删除、不重跑已有明细。 + * + * @param array $prod production_article 行(需含 p_article_id、article_id) + * @return array + */ + public function enqueueNewlyMatchedByPArticle($prod) + { + if (empty($prod) || !is_array($prod)) { + throw new \RuntimeException('production_article not found'); + } + $pArticleId = intval($this->arrGet($prod, 'p_article_id', 0)); + $articleId = intval($this->arrGet($prod, 'article_id', 0)); + if ($pArticleId <= 0 || $articleId <= 0) { + throw new \InvalidArgumentException('production_article requires both p_article_id and article_id'); + } + + $existingCount = Db::name('article_reference_check_result') + ->where('p_article_id', $pArticleId) + ->count(); + if (intval($existingCount) <= 0) { + throw new \RuntimeException('no existing reference check records for p_article_id=' . $pArticleId . '; please run the first check first'); + } + + $missingCtx = $this->loadMissingPReferIdsByPArticleId($pArticleId); + $missingPReferIds = $missingCtx['missing_p_refer_ids']; + $missingRefNos = $missingCtx['missing_reference_nos']; + + if (empty($missingPReferIds)) { + return [ + 'article_id' => $articleId, + 'p_article_id' => $pArticleId, + 'queued' => 0, + 'skipped' => 0, + 'existing' => intval($existingCount), + 'missing_p_refer_ids' => [], + 'matched_p_refer_ids' => [], + 'still_unmatched_p_refer_ids' => [], + 'new_reference_nos' => [], + 'check_ids' => [], + 'queue' => self::TRANSPORT_RABBITMQ, + ]; + } + + $referMap = $this->loadReferMapByPArticleId($pArticleId); + + $mains = Db::name('article_main') + ->field('am_id,content,article_id,type,amt_id') + ->where('article_id', $articleId) + ->whereIn('state', [0, 2]) + ->order('sort asc') + ->select(); + if (empty($mains)) { + throw new \RuntimeException('article_main is empty'); + } + + $queued = 0; + $skipped = 0; + $pendingJobs = []; + $amIdsWithJobs = []; + $newReferenceNos = []; + $matchedPReferIds = []; + $now = date('Y-m-d H:i:s'); + + foreach ($mains as $main) { + $amId = intval($main['am_id']); + $citations = $this->extractReferencesForArticleMain($main); + if (empty($citations)) { + continue; + } + $sectionText = $this->resolveSectionTextForArticleMain($main); + foreach ($citations as $cite) { + foreach ($cite['reference_numbers'] as $refNo) { + if (!isset($missingRefNos[$refNo])) { + $skipped++; + continue; + } + + $referIndex = $refNo - 1; + if ($referIndex < 0 || !isset($referMap[$referIndex])) { + $skipped++; + continue; + } + + $refer = $referMap[$referIndex]; + $pReferId = intval($this->arrGet($refer, 'p_refer_id', 0)); + $scope = [ + 'article_id' => $main['article_id'], + 'p_article_id' => $pArticleId, + 'am_id' => $amId, + 'section_text' => $sectionText, + ]; + $checkId = $this->insertCitationCheckRecord($scope, $cite, $refNo, $refer, $now); + if ($checkId <= 0) { + $skipped++; + continue; + } + + $this->appendCitationPendingJob($pendingJobs, $checkId, $refNo, $amId, $cite['text_start']); + $queued++; + $amIdsWithJobs[$amId] = true; + $newReferenceNos[$refNo] = true; + if ($pReferId > 0) { + $matchedPReferIds[$pReferId] = true; + } + } + } + } + + $checkIds = $this->enqueueChecksSortedByReferenceNo($pendingJobs, $pArticleId, 'rematch_new'); + foreach (array_keys($amIdsWithJobs) as $amId) { + $this->setAmRefCheckStatus($amId, self::AM_STATUS_RUNNING); + } + + $newRefList = array_keys($newReferenceNos); + sort($newRefList, SORT_NUMERIC); + + $matchedList = array_keys($matchedPReferIds); + sort($matchedList, SORT_NUMERIC); + + $stillUnmatched = array_values(array_diff($missingPReferIds, $matchedList)); + sort($stillUnmatched, SORT_NUMERIC); + + return [ + 'article_id' => $articleId, + 'p_article_id' => $pArticleId, + 'queued' => $queued, + 'skipped' => $skipped, + 'existing' => intval($existingCount), + 'missing_p_refer_ids' => $missingPReferIds, + 'matched_p_refer_ids' => $matchedList, + 'still_unmatched_p_refer_ids' => $stillUnmatched, + 'new_reference_nos' => $newRefList, + 'check_ids' => $checkIds, + 'queue' => self::TRANSPORT_RABBITMQ, + ]; + } + + /** + * 参考文献表(state=0) 与校对明细中已出现的 p_refer_id 做差集。 + * + * @return array{missing_p_refer_ids:int[], missing_reference_nos:array} + */ + private function loadMissingPReferIdsByPArticleId($pArticleId) + { + $pArticleId = intval($pArticleId); + $missingPReferIds = []; + $missingRefNos = []; + + if ($pArticleId <= 0) { + return [ + 'missing_p_refer_ids' => $missingPReferIds, + 'missing_reference_nos' => $missingRefNos, + ]; + } + + $refers = Db::name('production_article_refer') + ->field('p_refer_id,index') + ->where('p_article_id', $pArticleId) + ->where('state', 0) + ->order('index asc') + ->select(); + + $checkedIds = Db::name('article_reference_check_result') + ->where('p_article_id', $pArticleId) + ->where('p_refer_id', '>', 0) + ->group('p_refer_id') + ->column('p_refer_id'); + $checkedSet = []; + foreach ($checkedIds as $id) { + $checkedSet[intval($id)] = true; + } + + foreach ($refers as $refer) { + $pReferId = intval($this->arrGet($refer, 'p_refer_id', 0)); + if ($pReferId <= 0 || isset($checkedSet[$pReferId])) { + continue; + } + $refNo = intval($this->arrGet($refer, 'index', 0)) + 1; + $missingPReferIds[] = $pReferId; + $missingRefNos[$refNo] = $pReferId; + } + + return [ + 'missing_p_refer_ids' => $missingPReferIds, + 'missing_reference_nos' => $missingRefNos, + ]; + } + /** * 根据该节全部明细行汇总更新 t_article_main.ref_check_status */ @@ -553,7 +764,7 @@ class ReferenceCheckService * * @return int 被删除的明细条数 */ - public function clearArticleChecksByPArticleId($pArticleId) + public function clearArticleChecksByPArticleId($pArticleId,$articleId=0) { $pArticleId = intval($pArticleId); if ($pArticleId <= 0) { @@ -561,10 +772,12 @@ class ReferenceCheckService } // 先反查 article_id(用于重置 article_main.ref_check_status 节级状态) - $articleId = intval(Db::name('production_article') - ->where('p_article_id', $pArticleId) - ->whereIn('state', [0, 2]) - ->value('article_id')); + if($articleId==0){ + $articleId = intval(Db::name('production_article') + ->where('p_article_id', $pArticleId) + ->whereIn('state', [0, 2]) + ->value('article_id')); + } $deleted = Db::name('article_reference_check_result') ->where('p_article_id', $pArticleId) @@ -598,6 +811,29 @@ class ReferenceCheckService return intval($deleted); } + /** + * 按 article_id + am_id 删除单节正文下的引用校对明细,并同步该节 ref_check_status。 + * + * @return int 被删除的明细条数 + */ + public function clearChecksByAmId($articleId, $amId) + { + $articleId = intval($articleId); + $amId = intval($amId); + if ($articleId <= 0 || $amId <= 0) { + return 0; + } + + $deleted = Db::name('article_reference_check_result') + ->where('article_id', $articleId) + ->where('am_id', $amId) + ->delete(); + + $this->syncAmRefCheckStatus($amId); + + return intval($deleted); + } + /** * 文献列表局部挪动后,仅刷新指定 p_refer_id 对应的校对明细 reference_no / refer_index。 * @@ -850,7 +1086,7 @@ class ReferenceCheckService } $rows = Db::name('article_reference_check_result') - ->field('id,p_refer_id,reference_no,am_id,status,confidence,is_match,reason,text_start,text_end,updated_at') + ->field('id,p_refer_id,reference_no,am_id,status,confidence,is_match,reason,support_role,combined_can_support,combined_confidence,combined_reason,text_start,text_end,cite_group_refs,updated_at') ->where('p_article_id', $pArticleId) ->order('reference_no asc, id asc') ->select(); @@ -916,16 +1152,22 @@ class ReferenceCheckService } $groups[$refNo]['records'][] = [ - 'check_id' => intval($this->arrGet($row, 'id', 0)), - 'am_id' => intval($this->arrGet($row, 'am_id', 0)), - 'status' => $st, - 'confidence' => $confidence, - 'is_pass' => $isPass, - 'is_match' => intval($this->arrGet($row, 'is_match', 0)), - 'reason' => (string)$this->arrGet($row, 'reason', ''), - 'text_start' => intval($this->arrGet($row, 'text_start', 0)), - 'text_end' => intval($this->arrGet($row, 'text_end', 0)), - 'last_updated_at' => $upd, + 'check_id' => intval($this->arrGet($row, 'id', 0)), + 'am_id' => intval($this->arrGet($row, 'am_id', 0)), + 'status' => $st, + 'confidence' => $confidence, + 'is_pass' => $isPass, + 'is_match' => intval($this->arrGet($row, 'is_match', 0)), + 'reason' => (string)$this->arrGet($row, 'reason', ''), + 'support_role' => (string)$this->arrGet($row, 'support_role', ''), + 'combined_can_support' => intval($this->arrGet($row, 'combined_can_support', 0)), + 'combined_confidence' => floatval($this->arrGet($row, 'combined_confidence', 0)), + 'combined_reason' => (string)$this->arrGet($row, 'combined_reason', ''), + 'cite_group_refs' => (string)$this->arrGet($row, 'cite_group_refs', ''), + 'cite_check_mode' => $this->isJointCiteGroupRefs($this->arrGet($row, 'cite_group_refs', '')) ? 'joint' : 'single', + 'text_start' => intval($this->arrGet($row, 'text_start', 0)), + 'text_end' => intval($this->arrGet($row, 'text_end', 0)), + 'last_updated_at' => $upd, ]; } @@ -993,7 +1235,7 @@ class ReferenceCheckService } $rows = Db::name('article_reference_check_result') - ->field('id,p_article_id,reference_no,am_id,status,confidence,is_match,reason,updated_at') + ->field('id,p_article_id,reference_no,am_id,status,confidence,is_match,reason,support_role,combined_can_support,combined_confidence,combined_reason,cite_group_refs,updated_at') ->where('p_refer_id', $pReferId) ->order('id asc') ->select(); @@ -1036,13 +1278,19 @@ class ReferenceCheckService } $list[] = [ - 'check_id' => intval($this->arrGet($row, 'id', 0)), - 'am_id' => intval($this->arrGet($row, 'am_id', 0)), - 'status' => $st, - 'confidence' => $confidence, - 'reason' => (string)$this->arrGet($row, 'reason', ''), - 'is_match' => intval($this->arrGet($row, 'is_match', 0)), - 'is_pass' => $isPass, + 'check_id' => intval($this->arrGet($row, 'id', 0)), + 'am_id' => intval($this->arrGet($row, 'am_id', 0)), + 'status' => $st, + 'confidence' => $confidence, + 'reason' => (string)$this->arrGet($row, 'reason', ''), + 'is_match' => intval($this->arrGet($row, 'is_match', 0)), + 'is_pass' => $isPass, + 'support_role' => (string)$this->arrGet($row, 'support_role', ''), + 'combined_can_support' => intval($this->arrGet($row, 'combined_can_support', 0)), + 'combined_confidence' => floatval($this->arrGet($row, 'combined_confidence', 0)), + 'combined_reason' => (string)$this->arrGet($row, 'combined_reason', ''), + 'cite_group_refs' => (string)$this->arrGet($row, 'cite_group_refs', ''), + 'cite_check_mode' => $this->isJointCiteGroupRefs($this->arrGet($row, 'cite_group_refs', '')) ? 'joint' : 'single', ]; } @@ -1117,8 +1365,8 @@ class ReferenceCheckService throw new \RuntimeException('no existing reference check records for p_article_id=' . $pArticleId); } - $cleared = $this->clearArticleChecks($articleId); - $enqueueResult = $this->enqueueByArticle($articleId); + $cleared = $this->clearArticleChecksByPArticleId($aProductionArticle['p_article_id'],$aProductionArticle['article_id']); + $enqueueResult = $this->enqueueByPArticle($aProductionArticle); if (!is_array($enqueueResult)) { $enqueueResult = []; @@ -1178,6 +1426,7 @@ class ReferenceCheckService */ public function updateCheckResult($checkId, array $fields) { + DbReconnectHelper::ensure(); $checkId = intval($checkId); if ($checkId <= 0) { throw new \InvalidArgumentException('invalid check id'); @@ -1186,6 +1435,9 @@ class ReferenceCheckService if (isset($fields['reason'])) { $fields['reason'] = mb_substr(trim((string)$fields['reason']), 0, 512); } + if (isset($fields['combined_reason'])) { + $fields['combined_reason'] = mb_substr(trim((string)$fields['combined_reason']), 0, 512); + } if (isset($fields['error_msg'])) { $fields['error_msg'] = mb_substr(trim((string)$fields['error_msg']), 0, 512); } @@ -1540,6 +1792,581 @@ class ReferenceCheckService return implode("\n", $parts); } + /** + * 从 extractReferences 结果提取引用标签元数据([1,2]、[70-73] 等同标签下各明细共用) + */ + private function citationMetaFromExtract(array $cite) + { + return [ + 'reference_raw' => (string)$this->arrGet($cite, 'reference_raw', ''), + 'cite_group_refs' => $this->formatCiteGroupRefs((array)$this->arrGet($cite, 'reference_numbers', [])), + 'cite_tag_start' => intval($this->arrGet($cite, 'reference_start', 0)), + 'cite_tag_end' => intval($this->arrGet($cite, 'reference_end', 0)), + 'origin_text' => (string)$this->arrGet($cite, 'original_text', ''), + 'text_start' => intval($this->arrGet($cite, 'text_start', 0)), + 'text_end' => intval($this->arrGet($cite, 'text_end', 0)), + ]; + } + + /** + * 引用组展开序号:1,2 或 4,5,6 或 3 + */ + private function formatCiteGroupRefs(array $refNumbers) + { + $nums = []; + foreach ($refNumbers as $n) { + $n = intval($n); + if ($n > 0) { + $nums[$n] = $n; + } + } + if (empty($nums)) { + return ''; + } + $list = array_values($nums); + sort($list, SORT_NUMERIC); + + return implode(',', $list); + } + + private function isJointCiteGroupRefs($citeGroupRefs) + { + return strpos((string)$citeGroupRefs, ',') !== false; + } + + private function resolveCiteGroupRefsFromRow(array $row, array $groupRows = null) + { + $refs = trim((string)$this->arrGet($row, 'cite_group_refs', '')); + if ($refs !== '') { + return $refs; + } + if ($groupRows === null) { + $groupRows = $this->findCitationGroupRows($row); + } + $nums = []; + foreach ($groupRows as $gr) { + $n = intval($this->arrGet($gr, 'reference_no', 0)); + if ($n > 0) { + $nums[$n] = $n; + } + } + if (empty($nums)) { + $n = intval($this->arrGet($row, 'reference_no', 0)); + return $n > 0 ? (string)$n : ''; + } + $list = array_values($nums); + sort($list, SORT_NUMERIC); + + return implode(',', $list); + } + + private function hasSecondPassCompleted(array $row) + { + $reason = (string)$this->arrGet($row, 'reason', ''); + return stripos($reason, '[DOI复核') !== false || stripos($reason, '[Crossref复核') !== false; + } + + private function buildSecondPassReasonTag(array $row, array $payload, array $groupRows = null) + { + $citeGroupRefs = $this->resolveCiteGroupRefsFromRow($row, $groupRows); + $tag = '[DOI复核'; + if ($citeGroupRefs !== '') { + $tag .= ' 文献' . $citeGroupRefs; + } + if (trim((string)$this->arrGet($payload, 'doi_used', '')) !== '') { + $tag .= ' ' . trim((string)$payload['doi_used']); + } + $tag .= ']'; + + return $tag; + } + + /** + * 从 refer 抓取 DOI 真实文献块(PubMed 优先,回落 Crossref) + * + * @return array{text:string, has_abstract:bool, doi:string} + */ + private function resolveDoiRecheckFromRefer($refer) + { + if (!is_array($refer) || empty($refer)) { + return ['text' => '', 'has_abstract' => false, 'doi' => '']; + } + $text = trim($this->fetchDoiLiteratureBlock($refer)); + $hasAbstract = $text !== '' && preg_match('/Abstract:\s*\S/u', $text); + + return [ + 'text' => $text, + 'has_abstract' => $hasAbstract, + 'doi' => $this->extractDoiFromRefer($refer), + ]; + } + + /** + * 校对时使用的正文:t_article_main 一条记录(正文或表格展平文本) + */ + public function resolveParagraphContextForJob(array $row, $maxChars = 8000) + { + return $this->resolveMainContentForJob($row, $maxChars); + } + + /** + * 入队时快照引用处局部上下文,写入 origin_text(与 text_start/text_end 对应) + */ + private function resolveSectionTextForArticleMain(array $main, $maxChars = 8000) + { + $raw = trim($this->resolveArticleMainCheckContent($main)); + if ($raw === '') { + return ''; + } + + return $this->normalizeCheckContentForLlm($raw, $maxChars); + } + + /** + * 同一 blue 引用标签(如 [1,2])下为单个文献号写入校对明细 + * + * @return int|null check_id + */ + private function insertCitationCheckRecord(array $scope, array $cite, $refNo, array $refer, $now) + { + $refNo = intval($refNo); + $referIndex = $refNo - 1; + if ($referIndex < 0) { + return null; + } + + $meta = $this->citationMetaFromExtract($cite); + $referText = $this->formatReferForLlm($refer); + // origin_text 存引用处局部上下文(extractLocalCitationContext),非整节 am 正文 + $originText = trim((string)$meta['origin_text']); + if ($originText === '') { + $originText = trim((string)$this->arrGet($scope, 'section_text', '')); + } + + return intval(Db::name('article_reference_check_result')->insertGetId($this->newCheckRecordFields([ + 'article_id' => intval($this->arrGet($scope, 'article_id', 0)), + 'p_article_id' => intval($this->arrGet($scope, 'p_article_id', 0)), + 'am_id' => intval($this->arrGet($scope, 'am_id', 0)), + 'reference_no' => $refNo, + 'refer_index' => $refNo, + 'origin_text' => $originText, + 'refer_text' => $referText, + 'p_refer_id' => intval($this->arrGet($refer, 'p_refer_id', 0)), + 'reference_raw' => $meta['reference_raw'], + 'cite_group_refs' => $meta['cite_group_refs'], + 'cite_tag_start' => $meta['cite_tag_start'], + 'cite_tag_end' => $meta['cite_tag_end'], + 'text_start' => $meta['text_start'], + 'text_end' => $meta['text_end'], + 'status' => self::RECORD_PENDING, + 'created_at' => $now, + 'updated_at' => $now, + ]))); + } + + private function appendCitationPendingJob(array &$pendingJobs, $checkId, $refNo, $amId, $textStart) + { + $pendingJobs[] = [ + 'check_id' => intval($checkId), + 'reference_no' => intval($refNo), + 'am_id' => intval($amId), + 'text_start' => intval($textStart), + ]; + } + + /** + * 同一引用标签下的全部校对明细([1,2] 展开后 reference_no 不同但 cite_tag_* 相同) + * + * @return array[] + */ + private function findCitationGroupRows(array $row) + { + $amId = intval($this->arrGet($row, 'am_id', 0)); + if ($amId <= 0) { + return [$row]; + } + + $citeTagStart = intval($this->arrGet($row, 'cite_tag_start', 0)); + $citeTagEnd = intval($this->arrGet($row, 'cite_tag_end', 0)); + $q = Db::name('article_reference_check_result')->where('am_id', $amId); + + if ($citeTagStart > 0 && $citeTagEnd > $citeTagStart) { + $q->where('cite_tag_start', $citeTagStart)->where('cite_tag_end', $citeTagEnd); + } else { + $textStart = intval($this->arrGet($row, 'text_start', 0)); + $textEnd = intval($this->arrGet($row, 'text_end', 0)); + $referenceRaw = trim((string)$this->arrGet($row, 'reference_raw', '')); + $citeGroupRefs = trim((string)$this->arrGet($row, 'cite_group_refs', '')); + $q->where('text_start', $textStart)->where('text_end', $textEnd); + if ($referenceRaw !== '') { + $q->where('reference_raw', $referenceRaw); + } elseif ($citeGroupRefs !== '') { + $q->where('cite_group_refs', $citeGroupRefs); + } + } + + $rows = $q->order('reference_no asc')->select(); + return empty($rows) ? [$row] : $rows; + } + + private function resolveCitationGroupLeaderRefNo(array $groupRows) + { + $leader = PHP_INT_MAX; + foreach ($groupRows as $gr) { + $refNo = intval($this->arrGet($gr, 'reference_no', 0)); + if ($refNo > 0 && $refNo < $leader) { + $leader = $refNo; + } + } + + return $leader === PHP_INT_MAX ? 0 : $leader; + } + + private function findCitationGroupRowByRefNo(array $groupRows, $refNo) + { + $refNo = intval($refNo); + foreach ($groupRows as $gr) { + if (intval($this->arrGet($gr, 'reference_no', 0)) === $refNo) { + return $gr; + } + } + + return null; + } + + private function isCitationGroupCheck(array $groupRows) + { + return count($groupRows) > 1; + } + + private function resolveReferTextForCheckRow(array $row, $refer = null) + { + if (is_array($refer) && !empty($refer)) { + return $this->formatReferForLlm($refer); + } + + return trim((string)$this->arrGet($row, 'refer_text', '')); + } + + /** + * 将同一引用标签下多条文献书目拼成一次 LLM 校对的 refer_text + */ + private function buildCombinedReferTextForCitationGroup(array $groupRows) + { + $blocks = []; + foreach ($groupRows as $gr) { + $refNo = intval($this->arrGet($gr, 'reference_no', 0)); + if ($refNo <= 0) { + continue; + } + + $refer = null; + if (intval($this->arrGet($gr, 'p_refer_id', 0)) > 0) { + $refer = Db::name('production_article_refer') + ->where('p_refer_id', intval($gr['p_refer_id'])) + ->where('state', 0) + ->find(); + } + $text = $this->resolveReferTextForCheckRow($gr, $refer); + if ($text === '') { + continue; + } + $blocks[] = '【参考文献 ' . $refNo . '】' . "\n" . $text; + } + + return implode("\n\n", $blocks); + } + + /** + * @return array{refer_text:string, doi_block:string, has_abstract:bool, doi_used:string} + */ + private function prepareRecheckPayloadForCitationGroup(array $groupRows) + { + $referText = $this->buildCombinedReferTextForCitationGroup($groupRows); + $doiParts = []; + $doiUsed = []; + $hasAbstract = false; + + foreach ($groupRows as $gr) { + $refNo = intval($this->arrGet($gr, 'reference_no', 0)); + if ($refNo <= 0 || intval($this->arrGet($gr, 'p_refer_id', 0)) <= 0) { + continue; + } + DbReconnectHelper::ensure(); + $refer = Db::name('production_article_refer') + ->where('p_refer_id', intval($gr['p_refer_id'])) + ->where('state', 0) + ->find(); + if (empty($refer)) { + continue; + } + $bundle = (new ReferenceLiteratureFetchService())->fetchAndCleanForRefer($refer); + $checkId = intval($this->arrGet($gr, 'id', $this->arrGet($gr, 'check_id', 0))); + if ($checkId > 0) { + $this->persistLiteratureOnCheckRow($checkId, $bundle); + } + + $block = $this->buildLiteratureBlockFromBundle($refNo, $bundle); + if ($block === '') { + continue; + } + if (trim((string)($bundle['abstract_final'] ?? '')) !== '') { + $hasAbstract = true; + } + $doiParts[] = $block; + $doi = trim((string)($bundle['doi'] ?? '')); + if ($doi !== '') { + $doiUsed[] = $doi; + } + } + + return [ + 'refer_text' => $referText, + 'doi_block' => implode("\n\n", $doiParts), + 'has_abstract' => $hasAbstract, + 'doi_used' => implode(',', $doiUsed), + ]; + } + + private function applyCheckResultFromRow($checkId, array $sourceRow) + { + $this->updateCheckResult($checkId, [ + 'can_support' => intval($this->arrGet($sourceRow, 'can_support', 0)), + 'is_match' => intval($this->arrGet($sourceRow, 'is_match', 0)), + 'confidence' => floatval($this->arrGet($sourceRow, 'confidence', 0)), + 'reason' => (string)$this->arrGet($sourceRow, 'reason', ''), + 'support_role' => (string)$this->arrGet($sourceRow, 'support_role', ''), + 'combined_can_support' => intval($this->arrGet($sourceRow, 'combined_can_support', 0)), + 'combined_confidence' => floatval($this->arrGet($sourceRow, 'combined_confidence', 0)), + 'combined_reason' => (string)$this->arrGet($sourceRow, 'combined_reason', ''), + 'status' => self::RECORD_COMPLETED, + 'error_msg' => '', + ]); + } + + /** + * 将 LLM results 数组按 reference_no 写入同一引用组内的各行 + */ + private function applyCitationGroupCheckResults(array $groupRows, array $llmResponse, $reasonPrefix = '') + { + $results = isset($llmResponse['results']) && is_array($llmResponse['results']) + ? $llmResponse['results'] : []; + if (empty($results)) { + return false; + } + + $byRefNo = []; + foreach ($results as $item) { + if (!is_array($item)) { + continue; + } + $refNo = intval($this->arrGet($item, 'reference_no', 0)); + if ($refNo > 0) { + $byRefNo[$refNo] = $item; + } + } + + $applied = 0; + $expected = 0; + $reasonPrefix = trim((string)$reasonPrefix); + foreach ($groupRows as $gr) { + $refNo = intval($this->arrGet($gr, 'reference_no', 0)); + if ($refNo <= 0) { + continue; + } + $expected++; + if (!isset($byRefNo[$refNo])) { + continue; + } + $item = $byRefNo[$refNo]; + $canSupport = !empty($item['can_support']) ? 1 : 0; + $reason = trim($reasonPrefix . ' ' . (string)$this->arrGet($item, 'reason', '')); + $this->updateCheckResult($this->resolveCheckRowId($gr), [ + 'can_support' => $canSupport, + 'is_match' => array_key_exists('is_match', $item) ? (!empty($item['is_match']) ? 1 : 0) : $canSupport, + 'confidence' => floatval($this->arrGet($item, 'confidence', 0)), + 'reason' => $reason, + 'support_role' => (string)$this->arrGet($item, 'support_role', ''), + 'combined_can_support' => !empty($item['combined_can_support']) ? 1 : 0, + 'combined_confidence' => floatval($this->arrGet($item, 'combined_confidence', 0)), + 'combined_reason' => (string)$this->arrGet($item, 'combined_reason', ''), + 'status' => self::RECORD_COMPLETED, + 'error_msg' => '', + ]); + $applied++; + } + + return $expected > 0 && $applied === $expected; + } + + private function findLlmResultItemForRow(array $llmResponse, array $row) + { + $results = isset($llmResponse['results']) && is_array($llmResponse['results']) + ? $llmResponse['results'] : []; + $refNo = intval($this->arrGet($row, 'reference_no', 0)); + foreach ($results as $item) { + if (is_array($item) && intval($this->arrGet($item, 'reference_no', 0)) === $refNo) { + return $item; + } + } + + return null; + } + + private function formatCheckReturnFromRow(array $row) + { + $checkId = $this->resolveCheckRowId($row); + + return [ + 'check_id' => $checkId, + 'can_support' => intval($this->arrGet($row, 'can_support', 0)), + 'is_match' => intval($this->arrGet($row, 'is_match', 0)), + 'confidence' => floatval($this->arrGet($row, 'confidence', 0)), + 'reason' => (string)$this->arrGet($row, 'reason', ''), + 'support_role' => (string)$this->arrGet($row, 'support_role', ''), + 'combined_can_support' => intval($this->arrGet($row, 'combined_can_support', 0)), + 'combined_confidence' => floatval($this->arrGet($row, 'combined_confidence', 0)), + 'combined_reason' => (string)$this->arrGet($row, 'combined_reason', ''), + ]; + } + + private function shouldRunSecondPassForLlmResults(array $groupRows, array $llmResponse) + { + if (!$this->isSecondPassEnabled()) { + return false; + } + + $results = isset($llmResponse['results']) && is_array($llmResponse['results']) + ? $llmResponse['results'] : []; + if (empty($results)) { + return false; + } + + $byRefNo = []; + foreach ($results as $item) { + if (!is_array($item)) { + continue; + } + $refNo = intval($this->arrGet($item, 'reference_no', 0)); + if ($refNo > 0) { + $byRefNo[$refNo] = $item; + } + } + + foreach ($groupRows as $gr) { + $refNo = intval($this->arrGet($gr, 'reference_no', 0)); + if ($refNo <= 0 || !isset($byRefNo[$refNo])) { + continue; + } + $item = $byRefNo[$refNo]; + if (floatval($this->arrGet($item, 'confidence', 0)) <= self::PASS_CONFIDENCE_THRESHOLD) { + return true; + } + if (floatval($this->arrGet($item, 'combined_confidence', 0)) <= self::PASS_CONFIDENCE_THRESHOLD) { + return true; + } + } + + return false; + } + + /** + * 本引用位置附近上下文(用于 LLM 判断具体支撑哪句) + */ + public function resolveCitationLocalContextForJob(array $row, $maxChars = 3000) + { + $textStart = intval($this->arrGet($row, 'text_start', 0)); + $textEnd = intval($this->arrGet($row, 'text_end', 0)); + $amId = intval($this->arrGet($row, 'am_id', 0)); + if ($amId <= 0 || $textEnd <= $textStart) { + return ''; + } + + $main = Db::name('article_main') + ->field('content,type,amt_id,article_id') + ->where('am_id', $amId) + ->find(); + if (empty($main)) { + return ''; + } + + $raw = trim($this->resolveArticleMainCheckContent($main)); + if ($raw === '') { + return ''; + } + + $slice = $this->buildCitationContextText($raw, $textStart, $textEnd); + if (trim($slice) === '') { + return ''; + } + + return $this->normalizeCheckContentForLlm($slice, $maxChars); + } + + /** + * 相关性校对专用:段内后续引用在「本句」基础上再向前扩展 1–2 句(不早于上一引用标签), + * 以便覆盖紧邻的前置 claim,同时避免整段混用多个引用点的论述。 + */ + public function resolveCitationLocalContextForRelevanceJob(array $row, $maxChars = 3000, $extraSentences = 2) + { + DbReconnectHelper::ensure(); + $textStart = intval($this->arrGet($row, 'text_start', 0)); + $textEnd = intval($this->arrGet($row, 'text_end', 0)); + $amId = intval($this->arrGet($row, 'am_id', 0)); + $tagStart = intval($this->arrGet($row, 'cite_tag_start', 0)); + $fallback = trim((string)$this->arrGet($row, 'origin_text', '')); + + if ($amId <= 0 || $textEnd <= $textStart) { + return $fallback; + } + + $main = Db::name('article_main') + ->field('content,type,amt_id,article_id') + ->where('am_id', $amId) + ->find(); + if (empty($main)) { + return $fallback; + } + + $raw = trim($this->resolveArticleMainCheckContent($main)); + if ($raw === '') { + return $fallback; + } + + $paragraphStart = $tagStart > 0 ? $this->findParagraphStart($raw, $tagStart) : 0; + $prevTagEnd = $tagStart > 0 ? $this->resolvePriorCitationTagEnd($raw, $tagStart) : $paragraphStart; + $extendedStart = $textStart; + if ($prevTagEnd > $paragraphStart) { + $extendedStart = $this->extendContextStartBackward( + $raw, + $textStart, + max($paragraphStart, $prevTagEnd), + $extraSentences + ); + } + + $slice = $this->buildCitationContextText($raw, $extendedStart, $textEnd); + $slice = ltrim($slice, ". \t\n\r"); + if (trim($slice) === '') { + return $fallback; + } + + return $this->normalizeCheckContentForLlm($slice, $maxChars); + } + + /** + * 联合校对结果写回同一引用标签下的全部明细 + */ + private function applyCheckResultToCitationGroup(array $groupRows, array $fields) + { + foreach ($groupRows as $gr) { + $gid = $this->resolveCheckRowId($gr); + if ($gid > 0) { + $this->updateCheckResult($gid, $fields); + } + } + } + /** * 编辑某条文献内容后,按 p_refer_id 异步重新校对该文献对应的全部 check 明细 * @@ -1597,18 +2424,12 @@ class ReferenceCheckService ]; } - $resetFields = $this->newCheckRecordFields([ + $resetFields = $this->newCheckRecordFields($this->referenceCheckResultResetFields([ 'refer_text' => $referText, 'refer_index' => $referenceNo, 'reference_no' => $referenceNo, - 'status' => self::RECORD_PENDING, - 'is_match' => 0, - 'can_support' => 0, - 'confidence' => 0, - 'reason' => '', - 'error_msg' => '', 'updated_at' => $now, - ], self::QUEUE_PENDING, 0); + ]), self::QUEUE_PENDING, 0); $pendingJobs = []; $amIds = []; @@ -1686,15 +2507,9 @@ class ReferenceCheckService } $now = date('Y-m-d H:i:s'); - $resetFields = $this->newCheckRecordFields([ - 'status' => self::RECORD_PENDING, - 'is_match' => 0, - 'can_support' => 0, - 'confidence' => 0, - 'reason' => '', - 'error_msg' => '', - 'updated_at' => $now, - ], self::QUEUE_PENDING, 0); + $resetFields = $this->newCheckRecordFields($this->referenceCheckResultResetFields([ + 'updated_at' => $now, + ]), self::QUEUE_PENDING, 0); $pendingJobs = []; $amIds = []; @@ -1729,6 +2544,93 @@ class ReferenceCheckService ]; } + /** + * 某条参考文献下「校对失败」重跑,并将失败行所在同一引用标签分组(如 [1,2])全部一并重跑。 + * + * @param int $pReferId + * @param int $pArticleId + * @return array{p_refer_id:int, p_article_id:int, reset:int, queued:int, check_ids:int[], queue:string} + */ + public function enqueueRecheckFailedByPReferIdWithGroup($pReferId, $pArticleId = 0) + { + $pReferId = intval($pReferId); + if ($pReferId <= 0) { + throw new \InvalidArgumentException('p_refer_id is required'); + } + + $q = Db::name('article_reference_check_result') + ->where('p_refer_id', $pReferId) + ->where('status', self::RECORD_FAILED); + $pArticleId = intval($pArticleId); + if ($pArticleId > 0) { + $q->where('p_article_id', $pArticleId); + } + + $rows = $q->select(); + if (empty($rows)) { + return [ + 'p_refer_id' => $pReferId, + 'p_article_id' => $pArticleId, + 'reset' => 0, + 'queued' => 0, + 'check_ids' => [], + 'queue' => self::TRANSPORT_RABBITMQ, + ]; + } + + if ($pArticleId <= 0) { + $pArticleId = intval($this->arrGet($rows[0], 'p_article_id', 0)); + } + + $now = date('Y-m-d H:i:s'); + $resetFields = $this->newCheckRecordFields($this->referenceCheckResultResetFields([ + 'updated_at' => $now, + ]), self::QUEUE_PENDING, 0); + + $targetRows = []; + foreach ($rows as $row) { + $groupRows = $this->findCitationGroupRows($row); + foreach ($groupRows as $gr) { + $checkId = $this->resolveCheckRowId($gr); + if ($checkId > 0) { + $targetRows[$checkId] = $gr; + } + } + } + + $pendingJobs = []; + $amIds = []; + foreach ($targetRows as $row) { + $checkId = $this->resolveCheckRowId($row); + Db::name('article_reference_check_result')->where('id', $checkId)->update($resetFields); + $pendingJobs[] = [ + 'check_id' => $checkId, + 'reference_no' => intval($this->arrGet($row, 'reference_no', 0)), + 'am_id' => intval($this->arrGet($row, 'am_id', 0)), + 'text_start' => intval($this->arrGet($row, 'text_start', 0)), + ]; + $amId = intval($this->arrGet($row, 'am_id', 0)); + if ($amId > 0) { + $amIds[$amId] = true; + } + } + + foreach (array_keys($amIds) as $amId) { + $this->setAmRefCheckStatus($amId, self::AM_STATUS_RUNNING); + } + + $checkIds = $this->enqueueChecksSortedByReferenceNo($pendingJobs, $pArticleId, 'recheck_failed'); + + return [ + 'p_refer_id' => $pReferId, + 'p_article_id' => $pArticleId, + 'reset' => count($targetRows), + 'queued' => count($checkIds), + 'check_ids' => $checkIds, + 'queue' => self::TRANSPORT_RABBITMQ, + ]; + } + public function recheckByRefer($articleId, $pReferId = 0, $referenceNo = 0) { $articleId = intval($articleId); @@ -1763,19 +2665,13 @@ class ReferenceCheckService ]; } - $resetFields = $this->newCheckRecordFields([ + $resetFields = $this->newCheckRecordFields($this->referenceCheckResultResetFields([ 'refer_text' => $referText, 'p_refer_id' => $pReferId, 'p_article_id' => $pArticleId, 'refer_index' => $referenceNo, - 'status' => 0, - 'is_match' => 0, - 'can_support' => 0, - 'confidence' => 0, - 'reason' => '', - 'error_msg' => '', 'updated_at' => $now, - ], self::QUEUE_PENDING, 0); + ]), self::QUEUE_PENDING, 0); $pendingJobs = []; $amIds = []; @@ -1847,13 +2743,34 @@ class ReferenceCheckService */ public function runReferenceCheckOnce($checkId) { + DbReconnectHelper::ensure(); $checkId = intval($checkId); $row = Db::name('article_reference_check_result')->where('id', $checkId)->find(); if (empty($row)) { throw new \RuntimeException('article_reference_check_result not found, id=' . $checkId); } - $contentA = $this->resolveMainContentForJob($row); + if (intval($row['status']) === self::RECORD_COMPLETED) { + return $this->formatCheckReturnFromRow($row); + } + + $groupRows = $this->findCitationGroupRows($row); + $isGroup = $this->isCitationGroupCheck($groupRows); + if ($isGroup) { + $leaderRefNo = $this->resolveCitationGroupLeaderRefNo($groupRows); + $currentRefNo = intval($this->arrGet($row, 'reference_no', 0)); + if ($currentRefNo !== $leaderRefNo) { + $freshRow = Db::name('article_reference_check_result')->where('id', $checkId)->find(); + if (!empty($freshRow) && intval($freshRow['status']) === self::RECORD_COMPLETED) { + return $this->formatCheckReturnFromRow($freshRow); + } + throw new \RuntimeException('Citation group leader check not finished yet, reference_no=' . $leaderRefNo); + } + } + + $contentA = $this->resolveParagraphContextForJob($row); + $localContext = $this->resolveCitationLocalContextForJob($row); + $citeGroupRefs = $this->resolveCiteGroupRefsFromRow($row, $groupRows); $refer = null; if (intval($row['p_refer_id']) > 0) { $refer = Db::name('production_article_refer') @@ -1862,95 +2779,151 @@ class ReferenceCheckService ->find(); } - if ($refer) { - $contentB = $this->formatReferForLlm($refer); - } else { - $contentB = trim((string)$this->arrGet($row, 'refer_text', '')); - } + $contentB = $this->buildCombinedReferTextForCitationGroup($groupRows); + DbReconnectHelper::release(); + $doiPayload = $this->prepareRecheckPayloadForCitationGroup($groupRows); + $doiBlock = trim((string)$this->arrGet($doiPayload, 'doi_block', '')); + DbReconnectHelper::ensure(); if ($contentA === '' || $contentB === '') { - $this->updateCheckResult($checkId, [ + $failFields = [ 'status' => self::RECORD_FAILED, 'error_msg' => 'Missing section content (text/table) or refer_text', - ]); + ]; + if ($isGroup) { + $this->applyCheckResultToCitationGroup($groupRows, $failFields); + } else { + $this->updateCheckResult($checkId, $failFields); + } throw new \RuntimeException('Missing section content (text/table) or refer_text'); } - $llmResult = (new LLMService())->checkReference($contentA, $contentB, false); + DbReconnectHelper::release(); + $llmResult = (new LLMService())->checkReference( + $contentA, + $contentB, + false, + $doiBlock !== '' ? $doiBlock : null, + $citeGroupRefs, + $localContext + ); + DbReconnectHelper::ensure(); $requestFailed = !empty($llmResult['request_failed']); - $canSupport = $this->parseLlmCanSupport($llmResult); - $confidence = floatval(isset($llmResult['confidence']) ? $llmResult['confidence'] : 0); - $reason = isset($llmResult['reason']) ? $llmResult['reason'] : ''; - // LLM 通讯失败:写 status=RECORD_FAILED(3) + error_msg,抛异常由 MQ worker 重试 - if ($requestFailed) { - $this->updateCheckResult($checkId, [ - 'confidence' => $confidence, - 'reason' => $reason, - 'status' => self::RECORD_FAILED, - 'error_msg' => $reason, - ]); - throw new \RuntimeException($reason !== '' ? $reason : 'LLM request failed'); + if ($requestFailed || !$this->applyCitationGroupCheckResults($groupRows, $llmResult)) { + $failReason = isset($llmResult['reason']) ? (string)$llmResult['reason'] : 'LLM request failed or empty results'; + $failFields = [ + 'status' => self::RECORD_FAILED, + 'error_msg' => $failReason, + ]; + if ($isGroup) { + $this->applyCheckResultToCitationGroup($groupRows, $failFields); + } else { + $this->updateCheckResult($checkId, $failFields); + } + throw new \RuntimeException($failReason !== '' ? $failReason : 'LLM request failed'); } - $this->updateCheckResult($checkId, [ - 'can_support' => $canSupport ? 1 : 0, - 'is_match' => $canSupport ? 1 : 0, - 'confidence' => $confidence, - 'reason' => $reason, - 'status' => self::RECORD_COMPLETED, - 'error_msg' => '', - ]); - - if ($confidence <= self::PASS_CONFIDENCE_THRESHOLD) { - $this->runSecondPassBlocking($checkId, $row, $contentA, $refer, $contentB); + if ($this->shouldRunSecondPassForLlmResults($groupRows, $llmResult)) { + $this->runSecondPassBlocking($checkId, $row, $contentA, $refer, $contentB, $groupRows); } - return [ - 'check_id' => $checkId, - 'can_support' => $canSupport ? 1 : 0, - 'is_match' => $canSupport ? 1 : 0, - 'confidence' => $confidence, - 'reason' => $reason, - ]; + $freshRow = Db::name('article_reference_check_result')->where('id', $checkId)->find(); + return $this->formatCheckReturnFromRow(!empty($freshRow) ? $freshRow : $row); } /** * 低分结果的二轮 DOI 复核(同步阻塞执行;失败重试一次) */ - public function runSecondPassBlocking($checkId, array $row, $contentA, $refer, $referText) + public function runSecondPassBlocking($checkId, array $row, $contentA, $refer, $referText, array $groupRows = null) { + if (!$this->isSecondPassEnabled()) { + return false; + } + + DbReconnectHelper::ensure(); $checkId = intval($checkId); if ($checkId <= 0) { return false; } - $payload = $this->prepareRecheckPayload(is_array($refer) ? $refer : [], trim((string)$referText)); + if ($groupRows === null) { + $groupRows = $this->findCitationGroupRows($row); + } + $isGroup = $this->isCitationGroupCheck($groupRows); + + if ($isGroup) { + $leaderRefNo = $this->resolveCitationGroupLeaderRefNo($groupRows); + $currentRefNo = intval($this->arrGet($row, 'reference_no', 0)); + if ($currentRefNo !== $leaderRefNo) { + $freshRow = Db::name('article_reference_check_result')->where('id', $checkId)->find(); + if (!empty($freshRow) && $this->hasSecondPassCompleted($freshRow)) { + return true; + } + return false; + } + } + + if (trim((string)$contentA) === '') { + $contentA = $this->resolveParagraphContextForJob($row); + } + $localContext = $this->resolveCitationLocalContextForJob($row); + + DbReconnectHelper::release(); + if ($isGroup) { + $payload = $this->prepareRecheckPayloadForCitationGroup($groupRows); + $referText = trim((string)$payload['refer_text']); + } else { + if (trim((string)$referText) === '') { + $referText = $this->resolveReferTextForCheckRow($row, is_array($refer) ? $refer : null); + } + $payload = $this->prepareRecheckPayload(is_array($refer) ? $refer : [], trim((string)$referText)); + } + DbReconnectHelper::ensure(); if (empty($payload['has_abstract']) || trim((string)$payload['doi_block']) === '') { return false; } + $citeGroupRefs = $this->resolveCiteGroupRefsFromRow($row, $groupRows); $lastError = ''; for ($attempt = 0; $attempt < 2; $attempt++) { try { - $llmResult = (new LLMService())->checkReference($contentA, trim((string)$referText), true, $payload['doi_block']); + DbReconnectHelper::release(); + $llmResult = (new LLMService())->checkReference( + $contentA, + trim((string)$referText), + true, + $payload['doi_block'], + $citeGroupRefs, + $localContext + ); + DbReconnectHelper::ensure(); $requestFailed = !empty($llmResult['request_failed']); - $canSupport = $this->parseLlmCanSupport($llmResult); - $confidence = floatval(isset($llmResult['confidence']) ? $llmResult['confidence'] : 0); - $tag = '[Crossref复核' . (trim((string)$payload['doi_used']) !== '' ? (' ' . trim((string)$payload['doi_used'])) : '') . ']'; - $reason = $tag . ' ' . (isset($llmResult['reason']) ? $llmResult['reason'] : ''); + $tag = $this->buildSecondPassReasonTag($row, $payload, $groupRows); + if ($tag !== '' && !empty($llmResult['results']) && is_array($llmResult['results'])) { + foreach ($llmResult['results'] as &$one) { + if (!is_array($one)) { + continue; + } + $one['reason'] = $tag . ' ' . (isset($one['reason']) ? (string)$one['reason'] : ''); + } + unset($one); + } - if ($requestFailed) { - $lastError = isset($llmResult['reason']) ? (string)$llmResult['reason'] : 'LLM request failed'; + if ($requestFailed || !$this->applyCitationGroupCheckResults($groupRows, $llmResult)) { + $lastError = isset($llmResult['reason']) ? (string)$llmResult['reason'] : 'LLM request failed or empty results'; if ($attempt < 1) { continue; } - $this->updateCheckResult($checkId, [ - 'confidence' => $confidence, - 'reason' => $reason, - 'status' => self::RECORD_FAILED, - 'error_msg' => $lastError, - ]); + $failFields = [ + 'status' => self::RECORD_FAILED, + 'error_msg' => $lastError, + ]; + if ($isGroup) { + $this->applyCheckResultToCitationGroup($groupRows, $failFields); + } else { + $this->updateCheckResult($checkId, $failFields); + } $amId = intval(isset($row['am_id']) ? $row['am_id'] : 0); if ($amId > 0) { $this->syncAmRefCheckStatus($amId); @@ -1958,14 +2931,6 @@ class ReferenceCheckService return false; } - $this->updateCheckResult($checkId, [ - 'can_support' => $canSupport ? 1 : 0, - 'is_match' => $canSupport ? 1 : 0, - 'confidence' => $confidence, - 'reason' => $reason, - 'status' => self::RECORD_COMPLETED, - 'error_msg' => '', - ]); $amId = intval(isset($row['am_id']) ? $row['am_id'] : 0); if ($amId > 0) { $this->syncAmRefCheckStatus($amId); @@ -1976,10 +2941,15 @@ class ReferenceCheckService if ($attempt < 1) { continue; } - $this->updateCheckResult($checkId, [ + $failFields = [ 'status' => self::RECORD_FAILED, 'error_msg' => $lastError, - ]); + ]; + if ($isGroup) { + $this->applyCheckResultToCitationGroup($groupRows, $failFields); + } else { + $this->updateCheckResult($checkId, $failFields); + } $amId = intval(isset($row['am_id']) ? $row['am_id'] : 0); if ($amId > 0) { $this->syncAmRefCheckStatus($amId); @@ -2087,6 +3057,18 @@ class ReferenceCheckService if (!is_array($llmResult)) { return false; } + if (!empty($llmResult['results']) && is_array($llmResult['results'])) { + foreach ($llmResult['results'] as $item) { + if (!is_array($item)) { + continue; + } + if (!empty($item['can_support']) || !empty($item['is_match'])) { + return true; + } + } + + return false; + } if (array_key_exists('can_support', $llmResult)) { return $this->parseLlmIsMatch($llmResult['can_support']); } @@ -2098,6 +3080,7 @@ class ReferenceCheckService */ public function resolveMainContentForJob(array $row, $maxChars = 8000) { + DbReconnectHelper::ensure(); $amId = intval($this->arrGet($row, 'am_id', 0)); if ($amId <= 0) { return ''; @@ -2387,10 +3370,14 @@ class ReferenceCheckService } /** - * 引用处局部上下文(origin_text),供其它场景使用 + * 引用处局部上下文:优先按 text_start/text_end 从节正文重算,回落 origin_text 快照 */ public function resolveCitationContextForJob(array $row) { + $local = $this->resolveCitationLocalContextForJob($row); + if ($local !== '') { + return $local; + } $text = trim((string)$this->arrGet($row, 'origin_text', '')); if ($text === '') { $text = trim((string)$this->arrGet($row, 'content_a', '')); @@ -2503,6 +3490,8 @@ class ReferenceCheckService */ public function fetchDoiLiteratureBlock($refer) { + DbReconnectHelper::release(); + $candidates = $this->extractAllDoiCandidatesFromRefer($refer); if (empty($candidates)) { return ''; @@ -2670,15 +3659,80 @@ class ReferenceCheckService public function prepareRecheckPayload($refer, $referText = '') { $base = trim($referText) !== '' ? trim($referText) : $this->formatReferForLlm($refer); - $cr = $this->fetchCrossrefAbstractByReferDoi($refer); + $bundle = (new ReferenceLiteratureFetchService())->fetchAndCleanForRefer(is_array($refer) ? $refer : []); + $block = $this->buildLiteratureBlockFromBundle(0, $bundle); + if ($block === '') { + $cr = $this->resolveDoiRecheckFromRefer(is_array($refer) ? $refer : []); + return [ + 'refer_text' => $base, + 'doi_block' => $cr['text'], + 'has_abstract' => $cr['has_abstract'], + 'doi_used' => $cr['doi'], + ]; + } return [ 'refer_text' => $base, - 'doi_block' => $cr['text'], - 'has_abstract' => $cr['has_abstract'], - 'doi_used' => $cr['doi'], + 'doi_block' => $block, + 'has_abstract' => trim((string)($bundle['abstract_final'] ?? '')) !== '', + 'doi_used' => trim((string)($bundle['doi'] ?? '')), ]; } + private function buildLiteratureBlockFromBundle($refNo, array $bundle) + { + $abstract = trim((string)($bundle['abstract_final'] ?? $bundle['abstract'] ?? '')); + $cleaned = trim((string)($bundle['content_cleaned'] ?? '')); + $raw = trim((string)($bundle['raw_content'] ?? '')); + if ($cleaned === '' && $raw !== '') { + $cleaned = mb_substr($raw, 0, 6000); + } + if ($abstract === '' && $cleaned === '') { + return ''; + } + + $head = $refNo > 0 ? ('【参考文献 ' . intval($refNo) . '】') : '【文献内容】'; + $doi = trim((string)($bundle['doi'] ?? '')); + if ($doi !== '') { + $head .= ' DOI: ' . $doi; + } + $parts = [$head]; + if ($abstract !== '') { + $parts[] = '【摘要】' . "\n" . $abstract; + } + if ($cleaned !== '') { + $parts[] = '【清洗后文献内容】' . "\n" . $cleaned; + } + $sources = isset($bundle['sources']) && is_array($bundle['sources']) ? implode(',', $bundle['sources']) : ''; + if ($sources !== '') { + $parts[] = 'Sources: ' . $sources; + } + return implode("\n\n", $parts); + } + + private function persistLiteratureOnCheckRow($checkId, array $bundle) + { + $checkId = intval($checkId); + if ($checkId <= 0) { + return; + } + $abstract = trim((string)($bundle['abstract_final'] ?? $bundle['abstract'] ?? '')); + $raw = trim((string)($bundle['raw_content'] ?? '')); + $cleaned = trim((string)($bundle['content_cleaned'] ?? '')); + if ($cleaned === '' && $raw !== '') { + $cleaned = mb_substr($raw, 0, 6000); + } + try { + DbReconnectHelper::ensure(); + Db::name('article_reference_check_result')->where('id', $checkId)->update([ + 'abstract_text' => $abstract, + 'refer_content_cleaned' => $cleaned, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } catch (\Throwable $e) { + \think\Log::warning('persistLiteratureOnCheckRow: ' . $e->getMessage()); + } + } + /** * 旧接口:拼接成单块文本(向后兼容,建议调用方改用 prepareRecheckPayload) */ @@ -2710,6 +3764,24 @@ class ReferenceCheckService return false; } + if ($this->hasSecondPassCompleted($row)) { + return true; + } + + $groupRows = $this->findCitationGroupRows($row); + $isGroup = $this->isCitationGroupCheck($groupRows); + if ($isGroup) { + $leaderRefNo = $this->resolveCitationGroupLeaderRefNo($groupRows); + $currentRefNo = intval($this->arrGet($row, 'reference_no', 0)); + if ($currentRefNo !== $leaderRefNo) { + $freshRow = Db::name('article_reference_check_result')->where('id', $checkId)->find(); + if (!empty($freshRow) && $this->hasSecondPassCompleted($freshRow)) { + return true; + } + return false; + } + } + $refer = null; if (intval($row['p_refer_id']) > 0) { $refer = Db::name('production_article_refer') @@ -2717,22 +3789,15 @@ class ReferenceCheckService ->where('state', 0) ->find(); } - if (empty($refer) || $this->extractReferDoiOnly($refer) === '') { - return false; + + $contentA = $this->resolveParagraphContextForJob($row); + if ($isGroup) { + $referText = $this->buildCombinedReferTextForCitationGroup($groupRows); + } else { + $referText = $this->resolveReferTextForCheckRow($row, $refer); } - $cr = $this->fetchCrossrefAbstractByReferDoi($refer); - if (empty($cr['has_abstract'])) { - return false; - } - - $contentA = $this->resolveMainContentForJob($row); - $referText = trim((string)$this->arrGet($row, 'refer_text', '')); - if ($referText === '' && is_array($refer)) { - $referText = $this->formatReferForLlm($refer); - } - - return $this->runSecondPassBlocking($checkId, $row, $contentA, $refer, $referText); + return $this->runSecondPassBlocking($checkId, $row, $contentA, $refer, $referText, $groupRows); } /** @@ -2787,6 +3852,27 @@ class ReferenceCheckService return $result; } + /** + * 按段落截取引用上下文:同一段落内各处引用共用段落文本,分别按 cite_group_refs 校对。 + */ + private function extractParagraphCitationContext($content, $tagStart, $tagEnd, array $tagSpans) + { + $paragraphStart = $this->findParagraphStart($content, $tagStart); + $paragraphEnd = $this->findParagraphEnd($content, $tagEnd); + $originalText = $this->buildCitationContextText($content, $paragraphStart, $paragraphEnd); + + if (!$this->isMeaningfulCitationContext($originalText)) { + return $this->extractLocalCitationContext( + $content, + $tagStart, + $tagEnd, + $tagSpans + ); + } + + return [$paragraphStart, $paragraphEnd, $originalText]; + } + /** * 按引用位置截取局部上下文:优先取标签前叙述;同句多引时后续引用从上一标签后开始。 */ @@ -2929,6 +4015,89 @@ class ReferenceCheckService return $text; } + /** + * 同段落内、当前引用标签之前最近一个引用标签的结束字节位置 + */ + private function resolvePriorCitationTagEnd($content, $tagStart) + { + $tagStart = intval($tagStart); + $paragraphStart = $this->findParagraphStart($content, $tagStart); + $prevTagEnd = $paragraphStart; + + $matches = $this->collectBlueTagMatches($content); + if (empty($matches[0])) { + return $paragraphStart; + } + + foreach ($matches[0] as $match) { + $end = intval($match[1]) + strlen($match[0]); + if ($end <= $tagStart && $end > $prevTagEnd) { + $prevTagEnd = $end; + } + } + + return $prevTagEnd; + } + + /** + * 给定当前句起点,返回上一句起点 + */ + private function findPreviousSentenceStart($content, $sentenceStart) + { + $sentenceStart = intval($sentenceStart); + if ($sentenceStart <= 0) { + return 0; + } + + $pos = $sentenceStart - 1; + while ($pos > 0 && isset($content[$pos]) && ctype_space($content[$pos])) { + $pos--; + } + if ($pos <= 0) { + return 0; + } + + $prev = $this->findSentenceStart($content, $pos); + if ($prev >= $sentenceStart) { + $pos--; + while ($pos > 0 && isset($content[$pos]) && ctype_space($content[$pos])) { + $pos--; + } + if ($pos <= 0) { + return 0; + } + $prev = $this->findSentenceStart($content, $pos); + } + + return max(0, $prev); + } + + /** + * 从当前句起点向前扩展若干完整句子,但不早于 $minStart + */ + private function extendContextStartBackward($content, $start, $minStart, $extraSentences = 2) + { + $start = intval($start); + $minStart = max(0, intval($minStart)); + $extraSentences = max(0, intval($extraSentences)); + if ($extraSentences === 0 || $start <= $minStart) { + return max($minStart, $start); + } + + for ($i = 0; $i < $extraSentences; $i++) { + if ($start <= $minStart) { + break; + } + $prev = $this->findPreviousSentenceStart($content, $start); + if ($prev >= $start) { + break; + } + $start = max($minStart, $prev); + } + + return $start; + } + /** * 过滤仅标点、过短或无字母/汉字的上下文(如去掉标签后只剩 ".") */ @@ -3049,6 +4218,36 @@ class ReferenceCheckService return $best; } + /** + * 段落结束(

、双换行、下一段

之前) + */ + private function findParagraphEnd($content, $tagEnd) + { + $length = strlen($content); + $pos = max(0, intval($tagEnd)); + if ($pos >= $length) { + return $length; + } + + $candidates = [$length]; + + if (preg_match('/<\/p>/i', $content, $m, PREG_OFFSET_CAPTURE, $pos)) { + $candidates[] = intval($m[0][1]) + strlen($m[0][0]); + } + if (preg_match('/]*>/i', $content, $m, PREG_OFFSET_CAPTURE, $pos + 1)) { + $candidates[] = intval($m[0][1]); + } + if (preg_match('/\n\n/', $content, $m, PREG_OFFSET_CAPTURE, $pos)) { + $candidates[] = intval($m[0][1]); + } + if (preg_match('/\s*/i', $content, $m, PREG_OFFSET_CAPTURE, $pos)) { + $candidates[] = intval($m[0][1]) + strlen($m[0][0]); + } + + $end = min($candidates); + return max($pos, $end); + } + /** * 段落过长时从引用处向前截取上限,避免单次 LLM 上下文过大 */ diff --git a/application/common/mq/RabbitMqConfig.php b/application/common/mq/RabbitMqConfig.php index df30aa5e..84e19c17 100644 --- a/application/common/mq/RabbitMqConfig.php +++ b/application/common/mq/RabbitMqConfig.php @@ -21,4 +21,10 @@ class RabbitMqConfig $rc = self::get('reference_check', []); return is_array($rc) ? $rc : []; } + + public static function referenceRelevance() + { + $rc = self::get('reference_relevance', []); + return is_array($rc) ? $rc : []; + } } diff --git a/application/common/mq/ReferenceCheckArticleWorker.php b/application/common/mq/ReferenceCheckArticleWorker.php index e71da22d..46f61026 100644 --- a/application/common/mq/ReferenceCheckArticleWorker.php +++ b/application/common/mq/ReferenceCheckArticleWorker.php @@ -3,6 +3,7 @@ namespace app\common\mq; use think\Db; +use app\common\DbReconnectHelper; use app\common\ReferenceCheckService; /** @@ -25,6 +26,7 @@ class ReferenceCheckArticleWorker public function handleMessage(array $payload) { + DbReconnectHelper::ensure(); $pArticleId = intval(isset($payload['p_article_id']) ? $payload['p_article_id'] : 0); $batchId = intval(isset($payload['batch_id']) ? $payload['batch_id'] : 0); if ($pArticleId <= 0 || $batchId <= 0) { @@ -115,6 +117,7 @@ class ReferenceCheckArticleWorker */ private function processOneRow($checkId, array $row) { + DbReconnectHelper::ensure(); $claimed = Db::name('article_reference_check_result') ->where('id', intval($checkId)) ->where('queue_status', ReferenceCheckService::QUEUE_PENDING) @@ -134,6 +137,7 @@ class ReferenceCheckArticleWorker return 'ok'; } catch (\Exception $e) { $this->svc->log('ReferenceCheckArticleWorker check_id=' . $checkId . ' err=' . $e->getMessage()); + DbReconnectHelper::ensure(); if ($retryCount < ReferenceCheckService::QUEUE_MAX_RETRY) { $this->svc->markQueueRuntime($checkId, ReferenceCheckService::QUEUE_PENDING, $retryCount + 1); return $this->processOneRow($checkId, array_merge($row, ['retry_count' => $retryCount + 1])); diff --git a/application/common/service/LLMService.php b/application/common/service/LLMService.php index 20e25fc1..3daca96a 100644 --- a/application/common/service/LLMService.php +++ b/application/common/service/LLMService.php @@ -28,18 +28,17 @@ class LLMService * @param string $contextText 正文引用处句子 * @param string $referText 参考文献条目(或 refer 格式化文本) * @param bool $isAgain 是否为 DOI 二次复核 - * @param string|null $doiBlock 可选:系统抓取到的 DOI 真实文献内容(仅二次复核使用) + * @param string|null $doiBlock 可选:系统抓取到的 DOI 真实文献内容(仅二次复核使用) + * @param string $citeGroupRefs 引用文献组,如 1,2 或 4,5,6 + * @param string $localContext 本引用位置附近上下文(可选) + * @return array{results:array,request_failed?:bool} */ - public function checkReference($contextText, $referText, $isAgain = false, $doiBlock = null) + public function checkReference($contextText, $referText, $isAgain = false, $doiBlock = null, $citeGroupRefs = '', $localContext = '') { - // request_failed=true 表示"LLM 通讯/解析层面的失败"(可重试,区别于业务上的"未命中"); - // 上游 runReferenceCheckOnce 会据此把 DB.status 置为 3(失败) 并抛异常触发 MQ worker 重试 $fallback = [ - 'can_support' => false, - 'is_match' => false, - 'confidence' => 0.0, - 'reason' => 'LLM not configured or request failed', + 'results' => [], 'request_failed' => true, + 'reason' => 'LLM not configured or request failed', ]; if ($this->url === '' || $this->model === '') { \think\Log::warning('ReferenceCheck LLM: url or model not configured'); @@ -47,15 +46,16 @@ class LLMService } $contextText = trim($contextText); + \think\Log::info('llm checkReference:' . $contextText); $referText = trim($referText); + \think\Log::info('llm referText:' . $referText); $doiBlock = trim((string)$doiBlock); + $citeGroupRefs = trim((string)$citeGroupRefs); + $localContext = trim((string)$localContext); if ($contextText === '' || $referText === '') { - // 空文本是入参问题,不是 LLM 故障,不需要重试 return [ - 'can_support' => false, - 'is_match' => false, - 'confidence' => 0.0, - 'reason' => 'Empty citation context or reference text', + 'results' => [], + 'reason' => 'Empty citation context or reference text', ]; } @@ -63,27 +63,30 @@ class LLMService if (mb_strlen($contextText) > $maxContextLen) { $contextText = mb_substr($contextText, 0, $maxContextLen); } - if (mb_strlen($referText) > 4000) { - $referText = mb_substr($referText, 0, 4000); + if (mb_strlen($localContext) > 3000) { + $localContext = mb_substr($localContext, 0, 3000); } - if (mb_strlen($doiBlock) > 4000) { - $doiBlock = mb_substr($doiBlock, 0, 4000); + if (mb_strlen($referText) > 6000) { + $referText = mb_substr($referText, 0, 6000); + } + if (mb_strlen($doiBlock) > 8000) { + $doiBlock = mb_substr($doiBlock, 0, 8000); } if ($isAgain) { $system = $this->buildReferenceCheckSecondPassPrompt(); - $user = $this->buildReferenceCheckSecondPassUserPrompt($contextText, $referText, $doiBlock); + $user = $this->buildReferenceCheckSecondPassUserPrompt($contextText, $referText, $doiBlock, $citeGroupRefs, $localContext); } else { $system = $this->buildReferenceCheckFirstPassPrompt(); - $user = $this->buildReferenceCheckFirstPassUserPrompt($contextText, $referText); + $user = $this->buildReferenceCheckFirstPassUserPrompt($contextText, $referText, $citeGroupRefs, $localContext, $doiBlock); } - \think\Log::info('ReferenceCheck system head: ' . mb_substr($system, 0, 200)); - \think\Log::info('ReferenceCheck user head: ' . mb_substr($user, 0, 600)); +// \think\Log::info('ReferenceCheck system head: ' . mb_substr($system, 0, 200)); +// \think\Log::info('ReferenceCheck user head: ' . mb_substr($user, 0, 600)); $payload = [ - 'model' => $this->model, + 'model' => $this->model, 'temperature' => 0, - 'messages' => [ + 'messages' => [ ['role' => 'system', 'content' => $system], ['role' => 'user', 'content' => $user], ], @@ -101,23 +104,14 @@ class LLMService return $fallback; } - $canSupport = $this->parseCanSupportFromParsed($parsed); - $confidence = $this->snapReferenceCheckConfidence( - $this->normalizeConfidence(isset($parsed['confidence']) ? $parsed['confidence'] : 0), - $canSupport - ); - $reason = $this->cleanReason((string)(isset($parsed['reason']) ? $parsed['reason'] : '')); - \think\Log::info( - 'ReferenceCheck result: can_support=' . ($canSupport ? '1' : '0') - . ', confidence=' . $confidence - . ', reason=' . $reason - ); - return [ - 'can_support' => $canSupport, - 'is_match' => $canSupport, - 'confidence' => $confidence, - 'reason' => $reason, - ]; + $results = $this->parseReferenceCheckResultsFromParsed($parsed, $citeGroupRefs, $localContext, $doiBlock); + if (empty($results)) { + \think\Log::warning('ReferenceCheck LLM: empty results array'); + return $fallback; + } + + \think\Log::info($results); + return ['results' => $results]; } /** @@ -174,83 +168,541 @@ class LLMService $s = strtolower(trim((string)$value)); return in_array($s, ['1', 'true', 'yes', 'support', 'supported'], true); } + private function bulidReferenceCheckFirstPassPrompt(){ + return <<<'PROMPT' +你是一名护理、医学与科研期刊的资深文献编辑,专门校对「正文引用句」与「对应参考文献条目」是否匹配。 - /** 第一次校对:书目条目 vs 正文全文 */ +你的目标是严格识别错引、张冠李戴、方法不符、对象不符、结论不成立的问题。 + +宁可少判 true,也不要漏掉错引。 + +你只能依据用户提供的内容判断: +1. 正文引用句 +2. 当前对应参考文献条目 + +禁止假设已阅读全文。 +禁止联网。 +禁止脑补文献内容。 +禁止根据学科常识推断研究结果。 + +==================== +【核心任务】 + +判断: + +正文在该引用位置表达的核心观点、结论、方法、数据、定义、模型、研究发现、指南依据等, + +是否能够被该条参考文献合理支撑。 + +你判断的是: + +“引用是否成立” + +不是: + +“正文是否正确”。 + +==================== +【总原则(最高优先级)】 + +采用严格审稿标准: + +边界不清时,一律判 false。 + +宁可误杀(人工复核),不要漏掉错引。 + +同领域 ≠ 匹配。 + +同关键词 ≠ 匹配。 + +相关 ≠ 能支撑。 + +==================== +【强制规则】 + +1. 严禁关键词硬匹配 + +不能因为出现: +患者、护理、治疗、研究、模型、算法、深度学习、机器学习、焦虑、效果 + +等泛化词汇就判定匹配。 + +必须看: + +- 核心对象 +- 研究问题 +- 方法 +- 场景 +- 结局指标 +- 核心论点 + +是否一致。 + +==================== +2. 方法学必须严格一致(极重要) + +若正文明确提到: + +- 算法 +- 模型 +- 聚类方法 +- 深度学习架构 +- 统计方法 +- 数学模型 +- 评价指标 + +必须要求文献与其存在明确关联。 + +例如: + +不匹配: +- fuzzy clustering ≠ deep learning +- CNN ≠ LSTM +- random forest ≠ SVM +- 聚类 ≠ 分类 +- 特征选择 ≠ 分类预测 +- 风险因素分析 ≠ 干预研究 + +仅属于同一“大领域(AI/ML)” +不能判定匹配。 + +若方法体系不同: + +优先判 false + 0.10。 + +==================== +3. 医学护理引用严格一致 + +若正文涉及: + +- 疾病 +- 人群 +- 护理场景 +- 干预措施 +- 结局指标 + +必须基本一致。 + +例如: + +不匹配: +- ICU ≠ 普通病房 +- 老年人 ≠ 儿童 +- 糖尿病 ≠ 高血压 +- 心理护理 ≠ 运动干预 +- 焦虑改善 ≠ 生存率提高 + +==================== +4. 强结论必须强证据 + +正文若出现: + +- 显著改善 +- 明显降低 +- 证实 +- 优于 +- 有效预测 +- 危险因素 +- 因果关系 + +文献必须能合理支撑该强结论。 + +仅“应用研究”“相关研究”“观察研究” +不能自动支持强结论。 + +否则 false。 + +==================== +5. 特定证据类型必须一致 + +正文若明确写: + +- RCT/randomized trial +- Meta-analysis +- Guideline +- Systematic review +- Expert consensus + +而参考文献类型明显不符: + +直接 false。 + +==================== +6. 信息不足从严 + +若参考文献只有: + +作者 + 年份 + +或信息过少, + +无法建立明确关联: + +false + 0.30 + +==================== +【判定逻辑】 + +只有同时满足以下条件,才能 true: + +1. 主题一致 +2. 核心对象一致 +3. 核心论点一致 +4. 方法/研究方向一致 +5. 无明显错引风险 + +任意一点明显不符: + +false。 + +==================== +【评分(只能四选一)】 + +只能输出: + +0.90 +0.75 +0.30 +0.10 + +禁止任何其他分数。 + +评分规则: + +0.90 +明确匹配: +主题、对象、方法、核心论点均明显一致。 + +0.75 +基本匹配: +整体支撑成立,但存在轻微概括或小范围表述差异。 + +0.30 +存疑: +同领域但支撑不足; +信息不足; +需人工复核。 + +0.10 +明确错引: +主题、对象、方法或核心论点明显不符。 + +硬规则: + +is_match=true +只能: +0.75 或 0.90 + +is_match=false +只能: +0.10 或 0.30 + +==================== +【reason 要求】 + +仅说明: + +1. 是否主题一致; +2. 核心论点/方法是否能支撑。 + +禁止模糊措辞: +“可能” +“看起来” +“应该” +“疑似” + +长度: + +20~60字。 + +==================== +【输出要求】 + +仅输出一行 minified JSON。 + +禁止 markdown。 +禁止解释。 +禁止换行。 +禁止任何额外内容。 + +格式: + +{"is_match":true|false,"confidence":0.10|0.30|0.75|0.90,"reason":"简体中文说明"} +PROMPT; + + } + /** 第一次校对:参考文献真实性与支撑力度 */ private function buildReferenceCheckFirstPassPrompt() { - return <<<'PROMPT' -你是文献引用校对助手。判断【正文全文】与【参考文献书目】是否相关、能否用于支撑正文中的引用。 - -【核心原则:从宽判断,避免误杀】 -默认倾向 can_support=true。只要文献与正文不是「风马牛不相及」,即判为相关、能支撑。 -不要求变量一致、不要求结论逐条对应、不要求研究设计相同。 - -【仅当以下情况才判 can_support=false(与正文明显无关)】 -- 学科/主题完全无关(如正文讲深度学习聚类,文献是糖尿病步态检测)。 -- 明显张冠李戴(正文断言 A 疗法的效果,文献研究的是完全不同的 B 问题且无关联)。 -- 文献条目与正文讨论的对象/场景毫无交集,且无法作背景或理论引用。 - -【以下情况均应 can_support=true】 -- 同一大领域或相邻方向(如护理、心理、管理、医学、统计、AI 等相近子领域)。 -- 可作背景文献、综述性引用、理论或方法的一般性依据。 -- 表述略宽、略有概括、变量名不完全一致,但大方向说得通。 - -【confidence 固定档位(禁止其它小数)】 -can_support=true:0.65(有关联但较泛)/ 0.78 / 0.85 / 0.92 / 0.98(非常确定相关) -can_support=false:0.15(明确风马牛不相及)/ 0.25 / 0.35 / 0.45(仅当实在无法建立任何合理关联) - -【输出】仅一行 minified JSON,无 markdown: -{"can_support":true|false,"is_match":true|false,"confidence":0.15|0.25|0.35|0.45|0.65|0.78|0.85|0.92|0.98,"reason":"30-80字简体中文"} -is_match 必须与 can_support 相同。 -PROMPT; + return $this->buildReferenceCheckSupportSystemPrompt(false); } - private function buildReferenceCheckFirstPassUserPrompt($contextText, $referText) + private function buildReferenceCheckSupportSystemPrompt($isSecondPass = false) { - return "【正文全文 article_main.content】\n" . $contextText - . "\n\n【参考文献书目 refer_text】\n" . $referText - . "\n\n请从宽判断:文献与正文非风马牛不相即可判 can_support=true,只返回 JSON。"; + $prompt = <<<'PROMPT' +你是一名护理、医学、生物医学与科研期刊的资深学术编辑,正在执行“参考文献真实性与支撑力度校对”。 + +你的任务不是判断“主题是否相关”,而是判断: +【稿件正文中某段被引用内容】是否真的能被【对应编号的参考文献】直接或充分支撑。 + +你必须严格基于用户提供的材料作出判断,不得凭常识、不得脑补、不得假设参考文献中“可能写过但未提供”的内容。 + +================================================== +【一、任务目标】 +你需要判断: +“正文引用位置的核心论点、结论、背景陈述、机制解释、疗效描述、数据表达或因果表述, +是否能被对应参考文献真实支持。” + +这里的“支持”不是指“文献主题相关”或“研究领域接近”,而是指: +参考文献中确实包含足以支持正文该处表述的内容。 + +================================================== +【二、输出原则:结果必须直接对应数据库行】 + +你输出的结果将直接写入数据库表 t_article_reference_check_result。 + +因此: +## 输出必须是 results 数组,数组中的每一个对象对应数据库中的一行,也就是“一个引用位置中的一条参考文献结果”。 + +换句话说: +- 如果某个引用位置是 [3],则输出 1 条 result(reference_no=3) +- 如果某个引用位置是 [1,2],则输出 2 条 result: + - 一条对应 reference_no=1 + - 一条对应 reference_no=2 + +每条 result 都必须给出该参考文献“单独”对正文引用句的支撑判断。 +如果该引用位置是联合引用(citation group 中有多篇文献),则除了单条判断外,还必须给出该引用组整体的联合判断(combined_* 字段)。 + +================================================== +【三、最重要原则:只看“是否支撑正文核心断言”,不是看“主题是否沾边”】 + +以下情况不能判为强支撑: +1. 参考文献只和主题大致相关,但没有明确支持正文中的关键表述 +2. 正文说的是“疗效提升/死亡率下降/全球高发/耐药/多通路机制”等明确论点,而文献只是在背景里泛泛提到疾病 +3. 正文是多层复合句,文献只支撑其中一小部分 +4. 正文有因果、比较、趋势、机制、疗效强度等强表述,而文献没有明确证据 +5. 文献是基础机制研究,但正文引用它来支撑宏观流行病学、临床治疗现状或指南式结论 +6. 文献可以“推测支持”但不是“直接/明确支持” + +================================================== +【三b、多 claim 复合句 → 0.78 部分支撑(勿误降到 0.45)】 + +正文常为 2~4 个连续 claim 的复合句。须逐 claim 比对后综合给分: + +- 若文献(含 DOI 摘要)能**明确支撑多数关键概念**(如遗传异质性/多基因改变、多 survival pathway 并存、耐药或治疗挑战), + 但**未逐字写出**正文完整因果链(如「异质性→多通路→单靶点疗效下降」), + → 应判 **partial_support**,confidence 通常 **0.78**(边界情况 0.65),**不得**仅因文献主标题聚焦某化合物/干预就降到 0.45。 + +- 0.45 仅用于:文献与 claim 方向明显不符、仅同病沾边、或几乎无可用证据。 + +**校准样例(单条 [4],须接近此逻辑):** + +引用句: +Furthermore, the genomic heterogeneity of colorectal cancer (CRC) presents additional difficulties because tumors frequently make use of several survival pathways at once, which reduces the efficacy of single-target treatments [4]. + +文献4(Sheikhnia et al., thymoquinone CRC 机制综述): +- Claim1 遗传异质性/多基因改变:文献有 APC/KRAS/TP53、MSI/CIN 等 → 支撑较强 +- Claim2 多 survival pathway:文献列举 PI3K/Akt、Wnt、STAT3、NF-κB 等多通路 → 支撑较强 +- Claim3 单靶点疗效下降:文献有 drug resistance/治疗挑战,但未直述因果链 → 部分支撑 +- **输出**:can_support=1, confidence=**0.78**, support_role=supplementary_support(**不是 0.45**) + +用户消息中若提供【DOI 真实文献内容】,**必须结合摘要判断**,不得仅凭书目标题给分。 + +================================================== +【四、评分规则】 + +你必须使用以下 8 个固定分值之一: +0.98 / 0.92 / 0.85 / 0.78 / 0.65 / 0.45 / 0.25 / 0.15 + +判定含义: +- 0.98 / 0.92 / 0.85 => 强支撑(strong_support) +- 0.78 / 0.65 => 部分支撑(partial_support) +- 0.45 / 0.25 => 支撑不足(insufficient_support) +- 0.15 => 不支撑(not_support) + +can_support 取值规则: +- 若该文献/联合引文整体可判为 strong_support 或 partial_support,则 can_support = 1 +- 若判为 insufficient_support 或 not_support,则 can_support = 0 + +================================================== +【五、单条文献结果如何判断】 + +对于每一条参考文献,你必须判断它“单独”能否支撑该引用位置的正文内容,并输出: +- can_support +- confidence +- reason +- support_role + +其中: +### support_role 只能取以下值之一 +- primary_support:该文献本身就是主要证据来源,能支撑引用句核心内容 +- supplementary_support:能支撑部分重要内容,但不是主要来源 +- minimal_support:只提供少量背景或边缘支撑 +- no_meaningful_support:几乎不能支撑该引用句 + +### reason 的写法要求 +必须使用中文,明确写出: +1. 这篇文献具体支撑正文的哪一部分 +2. 哪些部分没有支撑到 +3. 是否存在文献类型与引用用途不匹配的问题 +4. 为什么给这个分值,而不是更高或更低 + +================================================== +【六、联合引用的判断规则】 + +当同一个引用位置包含多篇参考文献时(例如 [1,2] / [4,5,6]),除了逐条给单条结果外,还要额外判断: +“这些文献合起来,是否足以支撑该引用位置的正文内容?” + +联合结论输出到: +- combined_can_support +- combined_confidence +- combined_reason + +规则: +1. 联合评分不是单条评分平均值 +2. 如果其中一篇文献已强支撑,其他文献只是补充,则联合评分可接近主支撑文献 +3. 如果多篇文献分别覆盖不同部分,合起来能较完整支撑正文,则联合评分可以高于某些单条评分 +4. 但如果最关键的核心断言没有被任何文献明确支撑,则联合评分不能虚高 +5. 如果多篇文献都只是零散相关,需要大量推断才能拼出正文结论,则联合评分通常不应过高 + +================================================== +【七、单引文的 combined_* 字段处理规则】 + +即使某个引用位置只有 1 条参考文献,也仍然必须输出 combined_* 字段。 +此时: +- combined_can_support = can_support +- combined_confidence = confidence +- combined_reason = “该引用位置仅包含单条文献,联合结论等同于该文献的单条结论。” 或等价表述 + +这样可以保证输出结构统一,便于数据库写入。 + +================================================== +【八、输出 JSON 结构】 + +你必须输出合法 JSON,且只能输出以下结构: + +{ + "results": [ + { + "reference_no": 1, + "cite_group_refs": "1,2", + "can_support": 0, + "confidence": 0.65, + "reason": "中文,单条文献结论", + "support_role": "supplementary_support", + "combined_can_support": 1, + "combined_confidence": 0.85, + "combined_reason": "中文,联合引用整体结论" + } + ] +} + +================================================== +【九、字段约束】 + +### 1)results 中每个对象都必须包含以下字段: +- reference_no +- cite_group_refs +- can_support +- confidence +- reason +- support_role +- combined_can_support +- combined_confidence +- combined_reason + +### 2)reference_no +必须对应当前引用位置中的某一条参考文献编号。 + +### 3)cite_group_refs +必须是该引用位置的完整引文组,格式如: +- "3" +- "1,2" +- "4,5,6" + +### 4)同一引用位置若包含多条参考文献,则必须输出多条 result +例如 cite_group_refs = "1,2" 时,必须输出: +- 一条 reference_no=1 +- 一条 reference_no=2 + +### 5)同一引用位置下的 combined_* 必须一致 +例如同属 "1,2" 的两条 result,它们的: +- combined_can_support +- combined_confidence +- combined_reason +必须完全一致。 + +================================================== +【十、禁止事项】 +你绝对不能: +- 杜撰文献中不存在的结论 +- 把“主题相关”当作“内容支撑” +- 因为是同一疾病就默认支持 +- 输出 JSON 以外的任何内容 + +现在开始,读取用户提供的引用位置正文、参考文献信息和文献内容,输出结果。 +PROMPT; + + if ($isSecondPass) { + $prompt .= <<<'PROMPT' + + +================================================== +【二次校对补充(DOI 真实文献内容)】 +用户消息中会提供【DOI 真实文献内容(PubMed/Crossref)】。 +必须以 DOI 真实内容为准复核支撑力度;书目信息与 DOI 冲突时以 DOI 为准。 +仍须输出完整 results 数组,逐条给出单文献判断与联合判断。 +PROMPT; + } + + return $prompt; } - /** 第二次校对:Crossref 摘要(Refer_doi) */ + private function buildReferenceCheckFirstPassUserPrompt($contextText, $referText, $citeGroupRefs = '', $localContext = '', $doiBlock = '') + { + return $this->buildReferenceCheckSupportUserPrompt($contextText, $referText, $citeGroupRefs, $localContext, $doiBlock); + } + + private function buildReferenceCheckSupportUserPrompt($contextText, $referText, $citeGroupRefs, $localContext, $doiBlock) + { + $citeGroupRefs = trim((string)$citeGroupRefs); + $localContext = trim((string)$localContext); + $doiBlock = trim((string)$doiBlock); + + $parts = [ + "【正文节 t_article_main】\n" . $contextText, + ]; + if ($citeGroupRefs !== '') { + $mode = strpos($citeGroupRefs, ',') !== false ? '联合引用' : '单独引用'; + $parts[] = "【引用文献组 cite_group_refs】{$citeGroupRefs}({$mode})"; + } + if ($localContext !== '') { + $parts[] = "【本引用位置附近上下文】\n" . $localContext; + } + $parts[] = "【参考文献书目(按编号列出)】\n" . $referText; + if ($doiBlock !== '') { + $parts[] = "【DOI 真实文献内容(PubMed/Crossref,一轮校对已提供)】\n" . $doiBlock; + } + $parts[] = '请严格按 system 要求输出 results 数组 JSON,每条 result 对应一个 reference_no,并包含 combined_* 字段。'; + + return implode("\n\n", $parts); + } + + /** 第二次校对:DOI 真实文献内容复核 */ private function buildReferenceCheckSecondPassPrompt() { - return <<<'PROMPT' -你是文献引用二次校对助手。已根据 Refer_doi 从 Crossref(https://api.crossref.org/works/)获取摘要,请结合【正文全文】复核该文献是否相关。 - -【核心原则:与第一次相同,从宽判断】 -默认倾向 can_support=true。只要 Crossref 摘要(或书目)与正文不是风马牛不相及,即判相关、能支撑。 -以【Crossref 摘要】为准;摘要与书目冲突时以摘要为准。 - -【仅当以下情况才判 can_support=false】 -- 摘要显示的研究主题/对象/方法与正文讨论内容完全风马牛不相及。 -- 典型风马牛不相及、张冠李戴,且无法解释为背景或泛化引用。 - -【以下情况均应 can_support=true】 -- 摘要与正文属同领域或相近方向,能作背景、理论或方向性支撑。 -- 细节不完全一致,但不存在明显矛盾。 - -【无 Crossref 摘要时】 -结合 refer_text 从宽判断;非明显无关仍可 can_support=true,confidence 建议 0.65。 - -【confidence 固定档位(禁止其它小数)】 -can_support=true:0.65 / 0.78 / 0.85 / 0.92 / 0.98 -can_support=false:0.15 / 0.25 / 0.35 / 0.45 - -【输出】仅一行 minified JSON: -{"can_support":true|false,"is_match":true|false,"confidence":0.15|0.25|0.35|0.45|0.65|0.78|0.85|0.92|0.98,"reason":"30-80字简体中文"} -is_match 必须与 can_support 相同。 -PROMPT; + return $this->buildReferenceCheckSupportSystemPrompt(true); } - private function buildReferenceCheckSecondPassUserPrompt($contextText, $referText, $doiBlock) + private function buildReferenceCheckSecondPassUserPrompt($contextText, $referText, $doiBlock, $citeGroupRefs = '', $localContext = '') { - $doiBlock = trim((string)$doiBlock); - return "【正文全文 article_main.content】\n" . $contextText - . "\n\n【参考文献书目 refer_text】\n" . $referText - . "\n\n【Crossref 摘要】(Refer_doi → api.crossref.org/works/)\n" - . ($doiBlock !== '' ? $doiBlock : '(未获取到摘要,请结合 refer_text 从宽判断)') - . "\n\n文献与正文非风马牛不相即可判 can_support=true,只返回 JSON。"; + return $this->buildReferenceCheckSupportUserPrompt( + $contextText, + $referText, + $citeGroupRefs, + $localContext, + $doiBlock !== '' ? $doiBlock : '(未获取到 DOI 摘要或元数据,请结合书目条目从严判断)' + ); } private function buildReferenceCheckSystemPrompt3() { @@ -1169,13 +1621,174 @@ PROMPT; private function buildReferenceCheckRecheckUserPrompt($contextText, $referText, $doiBlock) { - return $this->buildReferenceCheckSecondPassUserPrompt($contextText, $referText, $doiBlock); + return $this->buildReferenceCheckSecondPassUserPrompt($contextText, $referText, $doiBlock, '', ''); } /** - * 与 buildReferenceCheckSystemPrompt3 一致的 confidence 档位 + * @return array */ - private function getReferenceCheckConfidenceBands($isMatch) + private function parseReferenceCheckResultsFromParsed(array $parsed, $defaultCiteGroupRefs = '', $localContext = '', $doiBlock = '') + { + $rows = []; + if (isset($parsed['results']) && is_array($parsed['results'])) { + $rows = $parsed['results']; + } elseif (isset($parsed['reference_no']) || isset($parsed['confidence'])) { + $rows = [$parsed]; + } + + $normalized = []; + foreach ($rows as $item) { + if (!is_array($item)) { + continue; + } + $refNo = intval(isset($item['reference_no']) ? $item['reference_no'] : 0); + if ($refNo <= 0) { + continue; + } + + $confidence = $this->snapReferenceCheckConfidenceValue( + $this->normalizeConfidence(isset($item['confidence']) ? $item['confidence'] : 0) + ); + $canSupport = $this->canSupportFromConfidence($confidence); + if (array_key_exists('can_support', $item)) { + $canSupport = $this->boolFromLlmValue($item['can_support']); + } elseif (array_key_exists('is_match', $item)) { + $canSupport = $this->boolFromLlmValue($item['is_match']); + } + + $reason = $this->cleanReason((string)(isset($item['reason']) ? $item['reason'] : '')); + $supportRole = $this->normalizeSupportRole(isset($item['support_role']) ? $item['support_role'] : ''); + list($confidence, $canSupport, $supportRole) = $this->applyMultiClaimPartialSupportFloor( + $localContext, + $doiBlock, + $confidence, + $canSupport, + $supportRole, + $reason + ); + + $combinedConfidence = $this->snapReferenceCheckConfidenceValue( + $this->normalizeConfidence(isset($item['combined_confidence']) ? $item['combined_confidence'] : $confidence) + ); + $combinedCanSupport = $this->canSupportFromConfidence($combinedConfidence); + if (array_key_exists('combined_can_support', $item)) { + $combinedCanSupport = $this->boolFromLlmValue($item['combined_can_support']); + } + + $citeGroupRefs = trim((string)(isset($item['cite_group_refs']) ? $item['cite_group_refs'] : $defaultCiteGroupRefs)); + if ($citeGroupRefs === '' && $defaultCiteGroupRefs !== '') { + $citeGroupRefs = trim((string)$defaultCiteGroupRefs); + } + + $normalized[] = [ + 'reference_no' => $refNo, + 'cite_group_refs' => $citeGroupRefs, + 'can_support' => $canSupport, + 'is_match' => $canSupport, + 'confidence' => $confidence, + 'reason' => $reason, + 'support_role' => $supportRole, + 'combined_can_support' => $combinedCanSupport, + 'combined_confidence' => $combinedConfidence, + 'combined_reason' => $this->cleanReason((string)(isset($item['combined_reason']) ? $item['combined_reason'] : '')), + ]; + } + + return $normalized; + } + + private function normalizeSupportRole($role) + { + $role = strtolower(trim((string)$role)); + $allowed = [ + 'primary_support', + 'supplementary_support', + 'minimal_support', + 'no_meaningful_support', + ]; + return in_array($role, $allowed, true) ? $role : 'no_meaningful_support'; + } + + private function canSupportFromConfidence($confidence) + { + return floatval($confidence) >= 0.65 - 0.001; + } + + /** + * 多通路/异质性 claim + DOI 有多通路证据时,防止误打 0.45(应对齐 0.78 部分支撑) + */ + private function applyMultiClaimPartialSupportFloor($localContext, $doiBlock, $confidence, $canSupport, $supportRole, $reason) + { + $confidence = floatval($confidence); + if ($confidence > 0.45) { + return [$confidence, $canSupport, $supportRole]; + } + + $claimText = trim((string)$localContext); + if ($claimText === '') { + return [$confidence, $canSupport, $supportRole]; + } + + $claimIsMechanism = (bool)preg_match( + '/\b(genomic heterogeneity|heterogeneity|survival pathway|pathways at once|single-target|multi.?pathway|genetic alteration|drug resistance|异质性|生存通路|多.*通路|单靶点|耐药)\b/ui', + $claimText + ); + if (!$claimIsMechanism) { + return [$confidence, $canSupport, $supportRole]; + } + + $corpus = trim((string)$doiBlock) . ' ' . trim((string)$reason); + if ($corpus === '') { + return [$confidence, $canSupport, $supportRole]; + } + + $refHasPathwayEvidence = (bool)preg_match( + '/\b(pathway|PI3K|Akt|mTOR|Wnt|STAT3|NF-κB|NF-kB|genetic alteration|MSI|CIN|drug resistance|signaling|multiple|APC|KRAS|TP53|通路|耐药|信号)\b/ui', + $corpus + ); + if (!$refHasPathwayEvidence) { + return [$confidence, $canSupport, $supportRole]; + } + + $confidence = 0.78; + $canSupport = true; + if ($supportRole === 'no_meaningful_support' || $supportRole === 'minimal_support') { + $supportRole = 'supplementary_support'; + } + + return [$confidence, $canSupport, $supportRole]; + } + + private function getReferenceCheckConfidenceBands() + { + return [0.15, 0.25, 0.45, 0.65, 0.78, 0.85, 0.92, 0.98]; + } + + private function snapReferenceCheckConfidenceValue($confidence) + { + $bands = $this->getReferenceCheckConfidenceBands(); + foreach ($bands as $band) { + if (abs($confidence - $band) < 0.001) { + return $band; + } + } + $nearest = $bands[0]; + $minDiff = abs($confidence - $nearest); + foreach ($bands as $band) { + $diff = abs($confidence - $band); + if ($diff < $minDiff) { + $minDiff = $diff; + $nearest = $band; + } + } + + return $nearest; + } + + /** + * @deprecated 兼容旧逻辑 + */ + private function getReferenceCheckConfidenceBandsLegacy($isMatch) { return $isMatch ? [0.65, 0.78, 0.85, 0.92, 0.98] @@ -1183,22 +1796,24 @@ PROMPT; } /** - * 将模型输出的 confidence 吸附到合法档位(如 0.95 → 0.92,0.75 → 0.78) + * 将模型输出的 confidence 吸附到合法档位 */ private function snapReferenceCheckConfidence($confidence, $isMatch) { - $bands = $this->getReferenceCheckConfidenceBands($isMatch); - + $snapped = $this->snapReferenceCheckConfidenceValue($confidence); + $bands = $this->getReferenceCheckConfidenceBandsLegacy($isMatch); + if (in_array($snapped, $bands, true)) { + return $snapped; + } foreach ($bands as $band) { - if (abs($confidence - $band) < 0.001) { + if (abs($snapped - $band) < 0.001) { return $band; } } - $nearest = $bands[0]; - $minDiff = abs($confidence - $nearest); + $minDiff = abs($snapped - $nearest); foreach ($bands as $band) { - $diff = abs($confidence - $band); + $diff = abs($snapped - $band); if ($diff < $minDiff) { $minDiff = $diff; $nearest = $band; diff --git a/application/common/service/ReferenceRelevanceLlmService.php b/application/common/service/ReferenceRelevanceLlmService.php index 90e1fdef..f37fb7d1 100644 --- a/application/common/service/ReferenceRelevanceLlmService.php +++ b/application/common/service/ReferenceRelevanceLlmService.php @@ -138,12 +138,18 @@ class ReferenceRelevanceLlmService - **「覆盖部分结局」不足以进入 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 ================================================== 【一、必须先拆解 claim】 从【本引用位置附近上下文】中提炼最小主张单元(Claim A, Claim B…),**不要**把整句笼统归为「大概讲抗癌」。例如: - **主语/研究对象**(化合物单体 vs 植物提取物 vs 其他物种;是否「X has been demonstrated」) - **证据语气与层级**(demonstrated / mechanistically vs predict / suggest;本文结果 vs 讨论转引) +- **claim 主题粒度**:是否为疾病总论型(流行病学负担 / 治疗现状与局限 / 基因组异质性 / 单靶点受限 / 亟需新策略);若是,要求「总体综述 / 分子病理 / 精准肿瘤学 / 耐药综述」类来源,单一药物专题综述只算 partially_related - 疾病流行病学(高发、死亡率) - **点名通路/分子机制**(PI3K/AKT、MAPK、NF-κB 等,须逐项) - **点名功能结局**(抑制增殖、凋亡、血管生成、炎症信号等,须逐项) diff --git a/application/extra/rabbitmq.php b/application/extra/rabbitmq.php index 05aa89b4..4b41a649 100644 --- a/application/extra/rabbitmq.php +++ b/application/extra/rabbitmq.php @@ -13,4 +13,11 @@ return [ 'dlq' => 'ref_check.article.dlq', 'route_key' => 'article.start', ], + + 'reference_relevance' => [ + 'exchange' => 'reference_relevance', + 'queue' => 'ref_relevance.article', + 'dlq' => 'ref_relevance.article.dlq', + 'route_key' => 'article.start', + ], ]; From 9c8f7cc3b68756a7e0dd77e537407c4decdd73b1 Mon Sep 17 00:00:00 2001 From: wyn <1074145239@qq.com> Date: Mon, 29 Jun 2026 10:43:07 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E5=BC=BA=E5=88=B6=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- composer.json | 3 +- ...reference_check_result_alter_positions.sql | 3 +- vendor/composer/autoload_files.php | 3 +- vendor/composer/autoload_namespaces.php | 1 + vendor/composer/autoload_psr4.php | 5 +- vendor/composer/autoload_static.php | 32 +- vendor/composer/installed.json | 832 +++++++++--------- vendor/phpmailer/phpmailer/README.md | 2 +- vendor/phpmailer/phpmailer/VERSION | 2 +- .../phpmailer/language/phpmailer.lang-es.php | 6 +- vendor/phpmailer/phpmailer/src/SMTP.php | 53 +- .../phpoffice/math/.github/workflows/php.yml | 2 + vendor/phpoffice/math/docs/index.md | 2 +- .../phpoffice/math/src/Math/Reader/MathML.php | 12 +- .../phpoffice/math/src/Math/Writer/MathML.php | 23 + .../math/tests/Math/Writer/MathMLTest.php | 29 + vendor/phpoffice/phpword/composer.json | 91 +- .../phpoffice/phpword/phpstan-baseline.neon | 140 +-- .../phpword/src/PhpWord/Collection/Charts.php | 2 + .../src/PhpWord/Collection/Comments.php | 2 + .../src/PhpWord/Collection/Endnotes.php | 2 + .../src/PhpWord/Collection/Footnotes.php | 2 + .../phpword/src/PhpWord/Collection/Titles.php | 2 + .../ComplexType/FootnoteProperties.php | 1 + .../src/PhpWord/ComplexType/ProofState.php | 1 + .../src/PhpWord/ComplexType/TblWidth.php | 1 + .../PhpWord/ComplexType/TrackChangesView.php | 1 + .../src/PhpWord/Element/AbstractContainer.php | 22 +- .../src/PhpWord/Element/AbstractElement.php | 13 +- .../phpword/src/PhpWord/Element/Bookmark.php | 1 + .../phpword/src/PhpWord/Element/Cell.php | 7 +- .../phpword/src/PhpWord/Element/CheckBox.php | 1 + .../phpword/src/PhpWord/Element/Endnote.php | 1 + .../phpword/src/PhpWord/Element/Field.php | 11 +- .../phpword/src/PhpWord/Element/Footer.php | 1 + .../phpword/src/PhpWord/Element/Footnote.php | 7 +- .../phpword/src/PhpWord/Element/Formula.php | 1 + .../phpword/src/PhpWord/Element/ListItem.php | 9 +- .../src/PhpWord/Element/ListItemRun.php | 5 +- .../phpword/src/PhpWord/Element/OLEObject.php | 5 +- .../phpword/src/PhpWord/Element/Row.php | 11 +- .../phpword/src/PhpWord/Element/SDT.php | 1 + .../phpword/src/PhpWord/Element/TOC.php | 11 +- .../phpword/src/PhpWord/Element/Text.php | 23 +- .../phpword/src/PhpWord/Element/TextBox.php | 5 +- .../phpword/src/PhpWord/Element/Title.php | 1 + .../src/PhpWord/Escaper/AbstractEscaper.php | 1 + .../src/PhpWord/Escaper/EscaperInterface.php | 1 + .../phpword/src/PhpWord/Escaper/RegExp.php | 1 + .../phpword/src/PhpWord/Escaper/Xml.php | 1 + .../CreateTemporaryFileException.php | 1 + .../Exception/InvalidImageException.php | 1 + .../Exception/InvalidObjectException.php | 1 + .../Exception/InvalidStyleException.php | 1 + .../UnsupportedImageTypeException.php | 1 + .../src/PhpWord/Metadata/Compatibility.php | 1 + .../phpword/src/PhpWord/Metadata/Settings.php | 7 +- .../src/PhpWord/Reader/AbstractReader.php | 1 + .../phpword/src/PhpWord/Reader/HTML.php | 3 +- .../phpword/src/PhpWord/Reader/MsDoc.php | 1 + .../PhpWord/Reader/ODText/AbstractPart.php | 1 + .../src/PhpWord/Reader/ODText/Content.php | 1 + .../phpword/src/PhpWord/Reader/RTF.php | 3 +- .../src/PhpWord/Reader/RTF/Document.php | 3 +- .../src/PhpWord/Reader/ReaderInterface.php | 1 + .../phpword/src/PhpWord/Reader/Word2007.php | 3 +- .../PhpWord/Reader/Word2007/AbstractPart.php | 56 +- .../PhpWord/Reader/Word2007/DocPropsApp.php | 1 + .../PhpWord/Reader/Word2007/DocPropsCore.php | 1 + .../Reader/Word2007/DocPropsCustom.php | 1 + .../src/PhpWord/Reader/Word2007/Document.php | 3 +- .../src/PhpWord/Reader/Word2007/Endnotes.php | 1 + .../src/PhpWord/Reader/Word2007/Numbering.php | 1 + .../src/PhpWord/Reader/Word2007/Settings.php | 1 + .../src/PhpWord/Reader/Word2007/Styles.php | 4 + .../phpword/src/PhpWord/Settings.php | 58 +- .../src/PhpWord/Shared/AbstractEnum.php | 1 + .../phpword/src/PhpWord/Shared/Css.php | 5 +- .../phpword/src/PhpWord/Shared/Drawing.php | 1 + .../Shared/Microsoft/PasswordEncoder.php | 8 +- .../phpword/src/PhpWord/Shared/Text.php | 6 + .../phpword/src/PhpWord/SimpleType/Border.php | 1 + .../src/PhpWord/SimpleType/DocProtect.php | 1 + .../phpword/src/PhpWord/SimpleType/Jc.php | 3 +- .../src/PhpWord/SimpleType/JcTable.php | 1 + .../src/PhpWord/SimpleType/NumberFormat.php | 1 + .../src/PhpWord/SimpleType/TblWidth.php | 1 + .../src/PhpWord/SimpleType/VerticalJc.php | 1 + .../phpword/src/PhpWord/SimpleType/Zoom.php | 1 + .../phpoffice/phpword/src/PhpWord/Style.php | 15 +- .../src/PhpWord/Style/AbstractStyle.php | 10 +- .../phpword/src/PhpWord/Style/Cell.php | 129 ++- .../phpword/src/PhpWord/Style/Extrusion.php | 1 + .../phpword/src/PhpWord/Style/Fill.php | 1 + .../phpword/src/PhpWord/Style/Language.php | 1 + .../phpword/src/PhpWord/Style/Line.php | 1 + .../phpword/src/PhpWord/Style/ListItem.php | 1 + .../src/PhpWord/Style/NumberingLevel.php | 1 + .../phpword/src/PhpWord/Style/Outline.php | 1 + .../phpword/src/PhpWord/Style/Row.php | 1 + .../phpword/src/PhpWord/Style/Shadow.php | 1 + .../phpword/src/PhpWord/Style/Spacing.php | 1 + .../phpword/src/PhpWord/Style/Tab.php | 1 + .../phpword/src/PhpWord/Style/Table.php | 13 +- .../src/PhpWord/Style/TablePosition.php | 1 + .../phpword/src/PhpWord/Style/TextBox.php | 1 + .../phpword/src/PhpWord/TemplateProcessor.php | 26 +- .../phpword/src/PhpWord/Writer/HTML.php | 3 +- .../Writer/HTML/Element/AbstractElement.php | 3 +- .../PhpWord/Writer/HTML/Element/Bookmark.php | 1 + .../PhpWord/Writer/HTML/Element/Container.php | 3 +- .../PhpWord/Writer/HTML/Element/Endnote.php | 1 + .../PhpWord/Writer/HTML/Element/Footnote.php | 1 + .../src/PhpWord/Writer/HTML/Element/Image.php | 1 + .../PhpWord/Writer/HTML/Element/ListItem.php | 1 + .../Writer/HTML/Element/ListItemRun.php | 1 + .../src/PhpWord/Writer/HTML/Element/Table.php | 1 + .../PhpWord/Writer/HTML/Element/TextBreak.php | 1 + .../src/PhpWord/Writer/HTML/Element/Title.php | 1 + .../src/PhpWord/Writer/HTML/Part/Head.php | 6 +- .../Writer/HTML/Style/AbstractStyle.php | 1 + .../src/PhpWord/Writer/HTML/Style/Font.php | 1 + .../src/PhpWord/Writer/HTML/Style/Generic.php | 1 + .../src/PhpWord/Writer/HTML/Style/Image.php | 1 + .../src/PhpWord/Writer/HTML/Style/Table.php | 4 + .../Writer/ODText/Element/AbstractElement.php | 29 + .../Writer/ODText/Element/Container.php | 6 + .../PhpWord/Writer/ODText/Element/Field.php | 1 + .../PhpWord/Writer/ODText/Element/Image.php | 18 +- .../PhpWord/Writer/ODText/Element/Link.php | 1 + .../Writer/ODText/Element/PageBreak.php | 1 + .../PhpWord/Writer/ODText/Element/Table.php | 5 +- .../PhpWord/Writer/ODText/Element/TextRun.php | 1 + .../PhpWord/Writer/ODText/Element/Title.php | 1 + .../PhpWord/Writer/ODText/Part/Content.php | 13 +- .../src/PhpWord/Writer/ODText/Part/Meta.php | 1 + .../Writer/ODText/Style/AbstractStyle.php | 1 + .../src/PhpWord/Writer/ODText/Style/Font.php | 3 +- .../PhpWord/Writer/ODText/Style/Paragraph.php | 9 +- .../PhpWord/Writer/ODText/Style/Section.php | 1 + .../src/PhpWord/Writer/ODText/Style/Table.php | 1 + .../phpword/src/PhpWord/Writer/PDF.php | 3 +- .../PhpWord/Writer/PDF/AbstractRenderer.php | 1 + .../phpword/src/PhpWord/Writer/PDF/DomPDF.php | 1 + .../phpword/src/PhpWord/Writer/PDF/MPDF.php | 1 + .../phpword/src/PhpWord/Writer/PDF/TCPDF.php | 1 + .../phpword/src/PhpWord/Writer/RTF.php | 3 +- .../Writer/RTF/Element/AbstractElement.php | 11 +- .../src/PhpWord/Writer/RTF/Element/Field.php | 1 + .../src/PhpWord/Writer/RTF/Element/Image.php | 1 + .../src/PhpWord/Writer/RTF/Element/Link.php | 1 + .../PhpWord/Writer/RTF/Element/ListItem.php | 1 + .../src/PhpWord/Writer/RTF/Element/Table.php | 1 + .../src/PhpWord/Writer/RTF/Element/Text.php | 1 + .../PhpWord/Writer/RTF/Element/TextRun.php | 1 + .../PhpWord/Writer/RTF/Part/AbstractPart.php | 1 + .../src/PhpWord/Writer/RTF/Part/Document.php | 9 +- .../src/PhpWord/Writer/RTF/Part/Header.php | 5 +- .../Writer/RTF/Style/AbstractStyle.php | 1 + .../PhpWord/Writer/RTF/Style/Paragraph.php | 1 + .../src/PhpWord/Writer/RTF/Style/Tab.php | 1 + .../phpword/src/PhpWord/Writer/Word2007.php | 11 +- .../Writer/Word2007/Element/Bookmark.php | 1 + .../PhpWord/Writer/Word2007/Element/Chart.php | 1 + .../Writer/Word2007/Element/Endnote.php | 1 + .../PhpWord/Writer/Word2007/Element/Field.php | 5 +- .../Writer/Word2007/Element/Formula.php | 1 + .../PhpWord/Writer/Word2007/Element/Line.php | 1 + .../PhpWord/Writer/Word2007/Element/Link.php | 1 + .../Writer/Word2007/Element/ListItem.php | 1 + .../Writer/Word2007/Element/OLEObject.php | 1 + .../Writer/Word2007/Element/PageBreak.php | 1 + .../PhpWord/Writer/Word2007/Element/SDT.php | 1 + .../PhpWord/Writer/Word2007/Element/Table.php | 1 + .../PhpWord/Writer/Word2007/Element/Text.php | 1 + .../Writer/Word2007/Element/TextBox.php | 8 + .../Writer/Word2007/Element/TextBreak.php | 1 + .../Writer/Word2007/Element/TextRun.php | 1 + .../PhpWord/Writer/Word2007/Element/Title.php | 1 + .../Writer/Word2007/Part/AbstractPart.php | 7 +- .../PhpWord/Writer/Word2007/Part/Chart.php | 3 +- .../PhpWord/Writer/Word2007/Part/Comments.php | 5 +- .../Writer/Word2007/Part/ContentTypes.php | 3 +- .../Writer/Word2007/Part/DocPropsApp.php | 1 + .../PhpWord/Writer/Word2007/Part/Document.php | 1 + .../PhpWord/Writer/Word2007/Part/Endnotes.php | 1 + .../PhpWord/Writer/Word2007/Part/Footer.php | 1 + .../Writer/Word2007/Part/Footnotes.php | 3 +- .../PhpWord/Writer/Word2007/Part/Header.php | 1 + .../Writer/Word2007/Part/Numbering.php | 3 +- .../src/PhpWord/Writer/Word2007/Part/Rels.php | 1 + .../Writer/Word2007/Part/RelsDocument.php | 1 + .../PhpWord/Writer/Word2007/Part/RelsPart.php | 1 + .../PhpWord/Writer/Word2007/Style/Cell.php | 35 + .../Writer/Word2007/Style/Extrusion.php | 1 + .../PhpWord/Writer/Word2007/Style/Fill.php | 1 + .../PhpWord/Writer/Word2007/Style/Frame.php | 1 + .../Writer/Word2007/Style/Indentation.php | 4 + .../PhpWord/Writer/Word2007/Style/Line.php | 1 + .../PhpWord/Writer/Word2007/Style/Outline.php | 1 + .../src/PhpWord/Writer/Word2007/Style/Row.php | 1 + .../PhpWord/Writer/Word2007/Style/Shadow.php | 1 + .../PhpWord/Writer/Word2007/Style/Table.php | 1 + .../Writer/Word2007/Style/TablePosition.php | 1 + .../src/PhpWord/Writer/WriterInterface.php | 1 + vendor/psr/http-message/composer.json | 4 +- .../psr/http-message/src/RequestInterface.php | 15 +- .../http-message/src/ResponseInterface.php | 8 +- .../src/ServerRequestInterface.php | 24 +- .../psr/http-message/src/StreamInterface.php | 28 +- vendor/psr/http-message/src/UriInterface.php | 34 +- vendor/psr/log/composer.json | 6 +- .../think-image/src/image/Exception.php | 3 +- .../think-image/src/image/gif/Decoder.php | 3 +- .../topthink/think-image/tests/CropTest.php | 2 +- .../topthink/think-image/tests/FlipTest.php | 3 +- .../topthink/think-image/tests/RotateTest.php | 2 +- .../topthink/think-image/tests/TestCase.php | 2 +- .../topthink/think-image/tests/WaterTest.php | 2 +- .../topthink/think-image/tests/autoload.php | 2 +- 220 files changed, 1360 insertions(+), 857 deletions(-) diff --git a/composer.json b/composer.json index a0a034eb..e8b8d190 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,8 @@ "paypal/paypal-server-sdk": "^0.6.1", "guzzlehttp/guzzle": "^7.9", "php-amqplib/php-amqplib": "^2.12", - "tectalic/openai": "^1.6" + "tectalic/openai": "^1.6", + "smalot/pdfparser": "^2.0" }, "autoload": { "psr-4": { diff --git a/sql/reference_check_result_alter_positions.sql b/sql/reference_check_result_alter_positions.sql index 688dd47e..db6d65b3 100644 --- a/sql/reference_check_result_alter_positions.sql +++ b/sql/reference_check_result_alter_positions.sql @@ -1,5 +1,6 @@ -- 为预览标记增加原文位置字段([70-73] 展开为 4 条时共用 cite_tag_* / text_*) -ALTER TABLE `t_reference_check_result` +-- 已合并到 article_reference_check_result_alter_columns.sql(表名 t_article_reference_check_result) +ALTER TABLE `t_article_reference_check_result` ADD COLUMN `cite_tag_start` int(11) NOT NULL DEFAULT 0 COMMENT 'blue标签起始字节偏移' AFTER `reference_raw`, ADD COLUMN `cite_tag_end` int(11) NOT NULL DEFAULT 0 COMMENT 'blue标签结束字节偏移' AFTER `cite_tag_start`, ADD COLUMN `text_start` int(11) NOT NULL DEFAULT 0 COMMENT '引用句起始字节偏移' AFTER `cite_tag_end`, diff --git a/vendor/composer/autoload_files.php b/vendor/composer/autoload_files.php index 5db964a2..355b401b 100644 --- a/vendor/composer/autoload_files.php +++ b/vendor/composer/autoload_files.php @@ -7,12 +7,13 @@ $baseDir = dirname($vendorDir); return array( '0e6d7bf4a5811bfa5cf40c5ccd6fae6a' => $vendorDir . '/symfony/polyfill-mbstring/bootstrap.php', + '6e3fae29631ef280660b3cdad06f25a8' => $vendorDir . '/symfony/deprecation-contracts/function.php', + 'a4a119a56e50fbb293281d9a48007e0e' => $vendorDir . '/symfony/polyfill-php80/bootstrap.php', '9c67151ae59aff4788964ce8eb2a0f43' => $vendorDir . '/clue/stream-filter/src/functions_include.php', '7b11c4dc42b3b3023073cb14e519683c' => $vendorDir . '/ralouphie/getallheaders/src/getallheaders.php', '2cffec82183ee1cea088009cef9a6fc3' => $vendorDir . '/ezyang/htmlpurifier/library/HTMLPurifier.composer.php', '8cff32064859f4559445b89279f3199c' => $vendorDir . '/php-http/message/src/filters.php', 'decc78cc4436b1292c6c0d151b19445c' => $vendorDir . '/phpseclib/phpseclib/phpseclib/bootstrap.php', - '6e3fae29631ef280660b3cdad06f25a8' => $vendorDir . '/symfony/deprecation-contracts/function.php', '9b552a3cc426e3287cc811caefa3cf53' => $vendorDir . '/topthink/think-helper/src/helper.php', '37a3dc5111fe8f707ab4c132ef1dbc62' => $vendorDir . '/guzzlehttp/guzzle/src/functions_include.php', '1cfd2761b63b0a29ed23657ea394cb2d' => $vendorDir . '/topthink/think-captcha/src/helper.php', diff --git a/vendor/composer/autoload_namespaces.php b/vendor/composer/autoload_namespaces.php index 990c758f..eb4850fe 100644 --- a/vendor/composer/autoload_namespaces.php +++ b/vendor/composer/autoload_namespaces.php @@ -6,6 +6,7 @@ $vendorDir = dirname(__DIR__); $baseDir = dirname($vendorDir); return array( + 'Smalot\\PdfParser\\' => array($vendorDir . '/smalot/pdfparser/src'), 'Rs\\Json' => array($vendorDir . '/php-jsonpointer/php-jsonpointer/src'), 'PHPExcel' => array($vendorDir . '/phpoffice/phpexcel/Classes'), 'HTMLPurifier' => array($vendorDir . '/ezyang/htmlpurifier/library'), diff --git a/vendor/composer/autoload_psr4.php b/vendor/composer/autoload_psr4.php index 6ce0181d..6db206ef 100644 --- a/vendor/composer/autoload_psr4.php +++ b/vendor/composer/autoload_psr4.php @@ -15,11 +15,12 @@ return array( 'ZipStream\\' => array($vendorDir . '/maennchen/zipstream-php/src'), 'Unirest\\' => array($vendorDir . '/apimatic/unirest-php/src'), 'Tectalic\\OpenAi\\' => array($vendorDir . '/tectalic/openai/src'), + 'Symfony\\Polyfill\\Php80\\' => array($vendorDir . '/symfony/polyfill-php80'), 'Symfony\\Polyfill\\Mbstring\\' => array($vendorDir . '/symfony/polyfill-mbstring'), 'Symfony\\Component\\HttpFoundation\\' => array($vendorDir . '/symfony/http-foundation'), 'Spatie\\DataTransferObject\\' => array($vendorDir . '/spatie/data-transfer-object/src'), 'Psr\\SimpleCache\\' => array($vendorDir . '/psr/simple-cache/src'), - 'Psr\\Log\\' => array($vendorDir . '/psr/log/src'), + 'Psr\\Log\\' => array($vendorDir . '/psr/log/Psr/Log'), 'Psr\\Http\\Message\\' => array($vendorDir . '/psr/http-factory/src', $vendorDir . '/psr/http-message/src'), 'Psr\\Http\\Client\\' => array($vendorDir . '/psr/http-client/src'), 'PhpOffice\\PhpWord\\' => array($vendorDir . '/phpoffice/phpword/src/PhpWord'), @@ -30,6 +31,7 @@ return array( 'ParagonIE\\ConstantTime\\' => array($vendorDir . '/paragonie/constant_time_encoding/src'), 'PHPMailer\\PHPMailer\\' => array($vendorDir . '/phpmailer/phpmailer/src'), 'Nyholm\\Psr7\\' => array($vendorDir . '/nyholm/psr7/src'), + 'MyCLabs\\Enum\\' => array($vendorDir . '/myclabs/php-enum/src'), 'Matrix\\' => array($vendorDir . '/markbaker/matrix/classes/src'), 'Http\\Message\\MultipartStream\\' => array($vendorDir . '/php-http/multipart-stream-builder/src'), 'Http\\Message\\' => array($vendorDir . '/php-http/message/src'), @@ -39,7 +41,6 @@ return array( 'GuzzleHttp\\' => array($vendorDir . '/guzzlehttp/guzzle/src'), 'Core\\' => array($vendorDir . '/apimatic/core/src'), 'CoreInterfaces\\' => array($vendorDir . '/apimatic/core-interfaces/src'), - 'Composer\\Pcre\\' => array($vendorDir . '/composer/pcre/src'), 'Complex\\' => array($vendorDir . '/markbaker/complex/classes/src'), 'Clue\\StreamFilter\\' => array($vendorDir . '/clue/stream-filter/src'), ); diff --git a/vendor/composer/autoload_static.php b/vendor/composer/autoload_static.php index 38ba10b9..b2e237ca 100644 --- a/vendor/composer/autoload_static.php +++ b/vendor/composer/autoload_static.php @@ -8,12 +8,13 @@ class ComposerStaticInit2bc4f313dba415539e266f7ac2c87dcd { public static $files = array ( '0e6d7bf4a5811bfa5cf40c5ccd6fae6a' => __DIR__ . '/..' . '/symfony/polyfill-mbstring/bootstrap.php', + '6e3fae29631ef280660b3cdad06f25a8' => __DIR__ . '/..' . '/symfony/deprecation-contracts/function.php', + 'a4a119a56e50fbb293281d9a48007e0e' => __DIR__ . '/..' . '/symfony/polyfill-php80/bootstrap.php', '9c67151ae59aff4788964ce8eb2a0f43' => __DIR__ . '/..' . '/clue/stream-filter/src/functions_include.php', '7b11c4dc42b3b3023073cb14e519683c' => __DIR__ . '/..' . '/ralouphie/getallheaders/src/getallheaders.php', '2cffec82183ee1cea088009cef9a6fc3' => __DIR__ . '/..' . '/ezyang/htmlpurifier/library/HTMLPurifier.composer.php', '8cff32064859f4559445b89279f3199c' => __DIR__ . '/..' . '/php-http/message/src/filters.php', 'decc78cc4436b1292c6c0d151b19445c' => __DIR__ . '/..' . '/phpseclib/phpseclib/phpseclib/bootstrap.php', - '6e3fae29631ef280660b3cdad06f25a8' => __DIR__ . '/..' . '/symfony/deprecation-contracts/function.php', '9b552a3cc426e3287cc811caefa3cf53' => __DIR__ . '/..' . '/topthink/think-helper/src/helper.php', '37a3dc5111fe8f707ab4c132ef1dbc62' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/functions_include.php', '1cfd2761b63b0a29ed23657ea394cb2d' => __DIR__ . '/..' . '/topthink/think-captcha/src/helper.php', @@ -50,6 +51,7 @@ class ComposerStaticInit2bc4f313dba415539e266f7ac2c87dcd ), 'S' => array ( + 'Symfony\\Polyfill\\Php80\\' => 23, 'Symfony\\Polyfill\\Mbstring\\' => 26, 'Symfony\\Component\\HttpFoundation\\' => 33, 'Spatie\\DataTransferObject\\' => 26, @@ -74,6 +76,7 @@ class ComposerStaticInit2bc4f313dba415539e266f7ac2c87dcd ), 'M' => array ( + 'MyCLabs\\Enum\\' => 13, 'Matrix\\' => 7, ), 'H' => @@ -92,7 +95,6 @@ class ComposerStaticInit2bc4f313dba415539e266f7ac2c87dcd array ( 'Core\\' => 5, 'CoreInterfaces\\' => 15, - 'Composer\\Pcre\\' => 14, 'Complex\\' => 8, 'Clue\\StreamFilter\\' => 18, ), @@ -138,6 +140,10 @@ class ComposerStaticInit2bc4f313dba415539e266f7ac2c87dcd array ( 0 => __DIR__ . '/..' . '/tectalic/openai/src', ), + 'Symfony\\Polyfill\\Php80\\' => + array ( + 0 => __DIR__ . '/..' . '/symfony/polyfill-php80', + ), 'Symfony\\Polyfill\\Mbstring\\' => array ( 0 => __DIR__ . '/..' . '/symfony/polyfill-mbstring', @@ -156,7 +162,7 @@ class ComposerStaticInit2bc4f313dba415539e266f7ac2c87dcd ), 'Psr\\Log\\' => array ( - 0 => __DIR__ . '/..' . '/psr/log/src', + 0 => __DIR__ . '/..' . '/psr/log/Psr/Log', ), 'Psr\\Http\\Message\\' => array ( @@ -199,6 +205,10 @@ class ComposerStaticInit2bc4f313dba415539e266f7ac2c87dcd array ( 0 => __DIR__ . '/..' . '/nyholm/psr7/src', ), + 'MyCLabs\\Enum\\' => + array ( + 0 => __DIR__ . '/..' . '/myclabs/php-enum/src', + ), 'Matrix\\' => array ( 0 => __DIR__ . '/..' . '/markbaker/matrix/classes/src', @@ -235,10 +245,6 @@ class ComposerStaticInit2bc4f313dba415539e266f7ac2c87dcd array ( 0 => __DIR__ . '/..' . '/apimatic/core-interfaces/src', ), - 'Composer\\Pcre\\' => - array ( - 0 => __DIR__ . '/..' . '/composer/pcre/src', - ), 'Complex\\' => array ( 0 => __DIR__ . '/..' . '/markbaker/complex/classes/src', @@ -250,6 +256,13 @@ class ComposerStaticInit2bc4f313dba415539e266f7ac2c87dcd ); public static $prefixesPsr0 = array ( + 'S' => + array ( + 'Smalot\\PdfParser\\' => + array ( + 0 => __DIR__ . '/..' . '/smalot/pdfparser/src', + ), + ), 'R' => array ( 'Rs\\Json' => @@ -274,7 +287,12 @@ class ComposerStaticInit2bc4f313dba415539e266f7ac2c87dcd ); public static $classMap = array ( + 'Attribute' => __DIR__ . '/..' . '/symfony/polyfill-php80/Resources/stubs/Attribute.php', 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php', + 'PhpToken' => __DIR__ . '/..' . '/symfony/polyfill-php80/Resources/stubs/PhpToken.php', + 'Stringable' => __DIR__ . '/..' . '/myclabs/php-enum/stubs/Stringable.php', + 'UnhandledMatchError' => __DIR__ . '/..' . '/symfony/polyfill-php80/Resources/stubs/UnhandledMatchError.php', + 'ValueError' => __DIR__ . '/..' . '/symfony/polyfill-php80/Resources/stubs/ValueError.php', ); public static function getInitializer(ClassLoader $loader) diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json index 360dacfc..68c16be7 100644 --- a/vendor/composer/installed.json +++ b/vendor/composer/installed.json @@ -2,24 +2,18 @@ "packages": [ { "name": "apimatic/core", - "version": "0.3.16", - "version_normalized": "0.3.16.0", + "version": "0.3.17", + "version_normalized": "0.3.17.0", "source": { "type": "git", "url": "https://github.com/apimatic/core-lib-php.git", - "reference": "ae4ab4ca26a41be41718f33c703d67b7a767c07b" + "reference": "a48a583f686ee3786432b976c795a2817ec095b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/apimatic/core-lib-php/zipball/ae4ab4ca26a41be41718f33c703d67b7a767c07b", - "reference": "ae4ab4ca26a41be41718f33c703d67b7a767c07b", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] + "url": "https://api.github.com/repos/apimatic/core-lib-php/zipball/a48a583f686ee3786432b976c795a2817ec095b3", + "reference": "a48a583f686ee3786432b976c795a2817ec095b3", + "shasum": "" }, "require": { "apimatic/core-interfaces": "~0.1.5", @@ -38,7 +32,7 @@ "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", "squizlabs/php_codesniffer": "^3.5" }, - "time": "2025-11-25T04:42:27+00:00", + "time": "2026-01-27T05:14:10+00:00", "type": "library", "installation-source": "dist", "autoload": { @@ -60,7 +54,7 @@ ], "support": { "issues": "https://github.com/apimatic/core-lib-php/issues", - "source": "https://github.com/apimatic/core-lib-php/tree/0.3.16" + "source": "https://github.com/apimatic/core-lib-php/tree/0.3.17" }, "install-path": "../apimatic/core" }, @@ -294,94 +288,6 @@ }, "install-path": "../clue/stream-filter" }, - { - "name": "composer/pcre", - "version": "3.3.2", - "version_normalized": "3.3.2.0", - "source": { - "type": "git", - "url": "https://github.com/composer/pcre.git", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "conflict": { - "phpstan/phpstan": "<1.11.10" - }, - "require-dev": { - "phpstan/phpstan": "^1.12 || ^2", - "phpstan/phpstan-strict-rules": "^1 || ^2", - "phpunit/phpunit": "^8 || ^9" - }, - "time": "2024-11-12T16:29:46+00:00", - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.x-dev" - }, - "phpstan": { - "includes": [ - "extension.neon" - ] - } - }, - "installation-source": "dist", - "autoload": { - "psr-4": { - "Composer\\Pcre\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - } - ], - "description": "PCRE wrapping library that offers type-safe preg_* replacements.", - "keywords": [ - "PCRE", - "preg", - "regex", - "regular expression" - ], - "support": { - "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.2" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "install-path": "./pcre" - }, { "name": "ezyang/htmlpurifier", "version": "v4.19.0", @@ -635,24 +541,18 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.8.0", - "version_normalized": "2.8.0.0", + "version": "2.9.0", + "version_normalized": "2.9.0.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "21dc724a0583619cd1652f673303492272778051" + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", - "reference": "21dc724a0583619cd1652f673303492272778051", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", + "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", @@ -667,12 +567,13 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "http-interop/http-factory-tests": "0.9.0", + "jshttp/mime-db": "1.54.0.1", "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" }, - "time": "2025-08-23T21:21:41+00:00", + "time": "2026-03-10T16:41:02+00:00", "type": "library", "extra": { "bamarni-bin": { @@ -740,7 +641,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.8.0" + "source": "https://github.com/guzzle/psr7/tree/2.9.0" }, "funding": [ { @@ -760,45 +661,32 @@ }, { "name": "maennchen/zipstream-php", - "version": "3.1.2", - "version_normalized": "3.1.2.0", + "version": "2.1.0", + "version_normalized": "2.1.0.0", "source": { "type": "git", "url": "https://github.com/maennchen/ZipStream-PHP.git", - "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f" + "reference": "c4c5803cc1f93df3d2448478ef79394a5981cc58" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/aeadcf5c412332eb426c0f9b4485f6accba2a99f", - "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/c4c5803cc1f93df3d2448478ef79394a5981cc58", + "reference": "c4c5803cc1f93df3d2448478ef79394a5981cc58", + "shasum": "" }, "require": { - "ext-mbstring": "*", - "ext-zlib": "*", - "php-64bit": "^8.2" + "myclabs/php-enum": "^1.5", + "php": ">= 7.1", + "psr/http-message": "^1.0", + "symfony/polyfill-mbstring": "^1.0" }, "require-dev": { - "brianium/paratest": "^7.7", "ext-zip": "*", - "friendsofphp/php-cs-fixer": "^3.16", - "guzzlehttp/guzzle": "^7.5", + "guzzlehttp/guzzle": ">= 6.3", "mikey179/vfsstream": "^1.6", - "php-coveralls/php-coveralls": "^2.5", - "phpunit/phpunit": "^11.0", - "vimeo/psalm": "^6.0" + "phpunit/phpunit": ">= 7.5" }, - "suggest": { - "guzzlehttp/psr7": "^2.4", - "psr/http-message": "^2.0" - }, - "time": "2025-01-27T12:07:53+00:00", + "time": "2020-05-30T13:11:16+00:00", "type": "library", "installation-source": "dist", "autoload": { @@ -835,12 +723,16 @@ ], "support": { "issues": "https://github.com/maennchen/ZipStream-PHP/issues", - "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.2" + "source": "https://github.com/maennchen/ZipStream-PHP/tree/2.1.0" }, "funding": [ { "url": "https://github.com/maennchen", "type": "github" + }, + { + "url": "https://opencollective.com/zipstream", + "type": "open_collective" } ], "install-path": "../maennchen/zipstream-php" @@ -958,6 +850,72 @@ }, "install-path": "../markbaker/matrix" }, + { + "name": "myclabs/php-enum", + "version": "1.8.5", + "version_normalized": "1.8.5.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/php-enum.git", + "reference": "e7be26966b7398204a234f8673fdad5ac6277802" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/php-enum/zipball/e7be26966b7398204a234f8673fdad5ac6277802", + "reference": "e7be26966b7398204a234f8673fdad5ac6277802", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.3 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "1.*", + "vimeo/psalm": "^4.6.2 || ^5.2" + }, + "time": "2025-01-14T11:49:03+00:00", + "type": "library", + "installation-source": "dist", + "autoload": { + "psr-4": { + "MyCLabs\\Enum\\": "src/" + }, + "classmap": [ + "stubs/Stringable.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP Enum contributors", + "homepage": "https://github.com/myclabs/php-enum/graphs/contributors" + } + ], + "description": "PHP Enum implementation", + "homepage": "https://github.com/myclabs/php-enum", + "keywords": [ + "enum" + ], + "support": { + "issues": "https://github.com/myclabs/php-enum/issues", + "source": "https://github.com/myclabs/php-enum/tree/1.8.5" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/php-enum", + "type": "tidelift" + } + ], + "install-path": "../myclabs/php-enum" + }, { "name": "nyholm/psr7", "version": "1.8.2", @@ -1031,17 +989,17 @@ }, { "name": "paragonie/constant_time_encoding", - "version": "v3.1.3", - "version_normalized": "3.1.3.0", + "version": "v2.8.2", + "version_normalized": "2.8.2.0", "source": { "type": "git", "url": "https://github.com/paragonie/constant_time_encoding.git", - "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + "reference": "e30811f7bc69e4b5b6d5783e712c06c8eabf0226" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", - "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/e30811f7bc69e4b5b6d5783e712c06c8eabf0226", + "reference": "e30811f7bc69e4b5b6d5783e712c06c8eabf0226", "shasum": "", "mirrors": [ { @@ -1051,15 +1009,13 @@ ] }, "require": { - "php": "^8" + "php": "^7|^8" }, "require-dev": { - "infection/infection": "^0", - "nikic/php-fuzzer": "^0", - "phpunit/phpunit": "^9|^10|^11", - "vimeo/psalm": "^4|^5|^6" + "phpunit/phpunit": "^6|^7|^8|^9", + "vimeo/psalm": "^1|^2|^3|^4" }, - "time": "2025-09-24T15:06:41+00:00", + "time": "2025-09-24T15:12:37+00:00", "type": "library", "installation-source": "dist", "autoload": { @@ -1386,24 +1342,18 @@ }, { "name": "php-http/message", - "version": "1.16.1", - "version_normalized": "1.16.1.0", + "version": "1.16.2", + "version_normalized": "1.16.2.0", "source": { "type": "git", "url": "https://github.com/php-http/message.git", - "reference": "5997f3289332c699fa2545c427826272498a2088" + "reference": "06dd5e8562f84e641bf929bfe699ee0f5ce8080a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-http/message/zipball/5997f3289332c699fa2545c427826272498a2088", - "reference": "5997f3289332c699fa2545c427826272498a2088", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] + "url": "https://api.github.com/repos/php-http/message/zipball/06dd5e8562f84e641bf929bfe699ee0f5ce8080a", + "reference": "06dd5e8562f84e641bf929bfe699ee0f5ce8080a", + "shasum": "" }, "require": { "clue/stream-filter": "^1.5", @@ -1428,7 +1378,7 @@ "laminas/laminas-diactoros": "Used with Diactoros Factories", "slim/slim": "Used with Slim Framework PSR-7 implementation" }, - "time": "2024-03-07T13:22:09+00:00", + "time": "2024-10-02T11:34:13+00:00", "type": "library", "installation-source": "dist", "autoload": { @@ -1458,30 +1408,24 @@ ], "support": { "issues": "https://github.com/php-http/message/issues", - "source": "https://github.com/php-http/message/tree/1.16.1" + "source": "https://github.com/php-http/message/tree/1.16.2" }, "install-path": "../php-http/message" }, { "name": "php-http/multipart-stream-builder", - "version": "1.3.1", - "version_normalized": "1.3.1.0", + "version": "1.4.2", + "version_normalized": "1.4.2.0", "source": { "type": "git", "url": "https://github.com/php-http/multipart-stream-builder.git", - "reference": "ed56da23b95949ae4747378bed8a5b61a2fdae24" + "reference": "10086e6de6f53489cca5ecc45b6f468604d3460e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-http/multipart-stream-builder/zipball/ed56da23b95949ae4747378bed8a5b61a2fdae24", - "reference": "ed56da23b95949ae4747378bed8a5b61a2fdae24", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] + "url": "https://api.github.com/repos/php-http/multipart-stream-builder/zipball/10086e6de6f53489cca5ecc45b6f468604d3460e", + "reference": "10086e6de6f53489cca5ecc45b6f468604d3460e", + "shasum": "" }, "require": { "php": "^7.1 || ^8.0", @@ -1494,7 +1438,7 @@ "php-http/message-factory": "^1.0.2", "phpunit/phpunit": "^7.5.15 || ^8.5 || ^9.3" }, - "time": "2024-06-10T14:51:55+00:00", + "time": "2024-09-04T13:22:54+00:00", "type": "library", "installation-source": "dist", "autoload": { @@ -1523,7 +1467,7 @@ ], "support": { "issues": "https://github.com/php-http/multipart-stream-builder/issues", - "source": "https://github.com/php-http/multipart-stream-builder/tree/1.3.1" + "source": "https://github.com/php-http/multipart-stream-builder/tree/1.4.2" }, "install-path": "../php-http/multipart-stream-builder" }, @@ -1588,24 +1532,18 @@ }, { "name": "phpmailer/phpmailer", - "version": "v6.11.1", - "version_normalized": "6.11.1.0", + "version": "v6.12.0", + "version_normalized": "6.12.0.0", "source": { "type": "git", "url": "https://github.com/PHPMailer/PHPMailer.git", - "reference": "d9e3b36b47f04b497a0164c5a20f92acb4593284" + "reference": "d1ac35d784bf9f5e61b424901d5a014967f15b12" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/d9e3b36b47f04b497a0164c5a20f92acb4593284", - "reference": "d9e3b36b47f04b497a0164c5a20f92acb4593284", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] + "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/d1ac35d784bf9f5e61b424901d5a014967f15b12", + "reference": "d1ac35d784bf9f5e61b424901d5a014967f15b12", + "shasum": "" }, "require": { "ext-ctype": "*", @@ -1625,7 +1563,6 @@ }, "suggest": { "decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication", - "ext-imap": "Needed to support advanced email address parsing according to RFC822", "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses", "ext-openssl": "Needed for secure SMTP sending and DKIM signing", "greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication", @@ -1635,7 +1572,7 @@ "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)", "thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication" }, - "time": "2025-09-30T11:54:53+00:00", + "time": "2025-10-15T16:49:08+00:00", "type": "library", "installation-source": "dist", "autoload": { @@ -1667,7 +1604,7 @@ "description": "PHPMailer is a full-featured email creation and transfer class for PHP", "support": { "issues": "https://github.com/PHPMailer/PHPMailer/issues", - "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.11.1" + "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.12.0" }, "funding": [ { @@ -1679,24 +1616,18 @@ }, { "name": "phpoffice/math", - "version": "0.2.0", - "version_normalized": "0.2.0.0", + "version": "0.3.0", + "version_normalized": "0.3.0.0", "source": { "type": "git", "url": "https://github.com/PHPOffice/Math.git", - "reference": "fc2eb6d1a61b058d5dac77197059db30ee3c8329" + "reference": "fc31c8f57a7a81f962cbf389fd89f4d9d06fc99a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPOffice/Math/zipball/fc2eb6d1a61b058d5dac77197059db30ee3c8329", - "reference": "fc2eb6d1a61b058d5dac77197059db30ee3c8329", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] + "url": "https://api.github.com/repos/PHPOffice/Math/zipball/fc31c8f57a7a81f962cbf389fd89f4d9d06fc99a", + "reference": "fc31c8f57a7a81f962cbf389fd89f4d9d06fc99a", + "shasum": "" }, "require": { "ext-dom": "*", @@ -1707,7 +1638,7 @@ "phpstan/phpstan": "^0.12.88 || ^1.0.0", "phpunit/phpunit": "^7.0 || ^9.0" }, - "time": "2024-08-12T07:30:45+00:00", + "time": "2025-05-29T08:31:49+00:00", "type": "library", "installation-source": "dist", "autoload": { @@ -1734,7 +1665,7 @@ ], "support": { "issues": "https://github.com/PHPOffice/Math/issues", - "source": "https://github.com/PHPOffice/Math/tree/0.2.0" + "source": "https://github.com/PHPOffice/Math/tree/0.3.0" }, "install-path": "../phpoffice/math" }, @@ -1810,27 +1741,20 @@ }, { "name": "phpoffice/phpspreadsheet", - "version": "1.30.1", - "version_normalized": "1.30.1.0", + "version": "1.25.2", + "version_normalized": "1.25.2.0", "source": { "type": "git", "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", - "reference": "fa8257a579ec623473eabfe49731de5967306c4c" + "reference": "a317a09e7def49852400a4b3eca4a4b0790ceeb5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/fa8257a579ec623473eabfe49731de5967306c4c", - "reference": "fa8257a579ec623473eabfe49731de5967306c4c", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/a317a09e7def49852400a4b3eca4a4b0790ceeb5", + "reference": "a317a09e7def49852400a4b3eca4a4b0790ceeb5", + "shasum": "" }, "require": { - "composer/pcre": "^1||^2||^3", "ext-ctype": "*", "ext-dom": "*", "ext-fileinfo": "*", @@ -1845,26 +1769,26 @@ "ext-zip": "*", "ext-zlib": "*", "ezyang/htmlpurifier": "^4.15", - "maennchen/zipstream-php": "^2.1 || ^3.0", + "maennchen/zipstream-php": "^2.1", "markbaker/complex": "^3.0", "markbaker/matrix": "^3.0", - "php": ">=7.4.0 <8.5.0", + "php": "^7.3 || ^8.0", "psr/http-client": "^1.0", "psr/http-factory": "^1.0", "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" }, "require-dev": { - "dealerdirect/phpcodesniffer-composer-installer": "dev-main", - "dompdf/dompdf": "^1.0 || ^2.0 || ^3.0", + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "dompdf/dompdf": "^1.0 || ^2.0", "friendsofphp/php-cs-fixer": "^3.2", - "mitoteam/jpgraph": "^10.3", - "mpdf/mpdf": "^8.1.1", + "mitoteam/jpgraph": "10.2.4", + "mpdf/mpdf": "8.1.1", "phpcompatibility/php-compatibility": "^9.3", "phpstan/phpstan": "^1.1", "phpstan/phpstan-phpunit": "^1.0", "phpunit/phpunit": "^8.5 || ^9.0", "squizlabs/php_codesniffer": "^3.7", - "tecnickcom/tcpdf": "^6.5" + "tecnickcom/tcpdf": "6.5" }, "suggest": { "dompdf/dompdf": "Option for rendering PDF with PDF Writer", @@ -1873,7 +1797,7 @@ "mpdf/mpdf": "Option for rendering PDF with PDF Writer", "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" }, - "time": "2025-10-26T16:01:04+00:00", + "time": "2022-09-25T17:21:01+00:00", "type": "library", "installation-source": "dist", "autoload": { @@ -1919,59 +1843,52 @@ ], "support": { "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", - "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.1" + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.25.2" }, "install-path": "../phpoffice/phpspreadsheet" }, { "name": "phpoffice/phpword", - "version": "1.3.0", - "version_normalized": "1.3.0.0", + "version": "1.4.0", + "version_normalized": "1.4.0.0", "source": { "type": "git", "url": "https://github.com/PHPOffice/PHPWord.git", - "reference": "8392134ce4b5dba65130ba956231a1602b848b7f" + "reference": "6d75328229bc93790b37e93741adf70646cea958" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPOffice/PHPWord/zipball/8392134ce4b5dba65130ba956231a1602b848b7f", - "reference": "8392134ce4b5dba65130ba956231a1602b848b7f", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] + "url": "https://api.github.com/repos/PHPOffice/PHPWord/zipball/6d75328229bc93790b37e93741adf70646cea958", + "reference": "6d75328229bc93790b37e93741adf70646cea958", + "shasum": "" }, "require": { "ext-dom": "*", + "ext-gd": "*", "ext-json": "*", "ext-xml": "*", + "ext-zip": "*", "php": "^7.1|^8.0", - "phpoffice/math": "^0.2" + "phpoffice/math": "^0.3" }, "require-dev": { - "dompdf/dompdf": "^2.0", - "ext-gd": "*", + "dompdf/dompdf": "^2.0 || ^3.0", "ext-libxml": "*", - "ext-zip": "*", "friendsofphp/php-cs-fixer": "^3.3", - "mpdf/mpdf": "^8.1", + "mpdf/mpdf": "^7.0 || ^8.0", "phpmd/phpmd": "^2.13", - "phpstan/phpstan-phpunit": "@stable", + "phpstan/phpstan": "^0.12.88 || ^1.0.0", + "phpstan/phpstan-phpunit": "^1.0 || ^2.0", "phpunit/phpunit": ">=7.0", "symfony/process": "^4.4 || ^5.0", "tecnickcom/tcpdf": "^6.5" }, "suggest": { "dompdf/dompdf": "Allows writing PDF", - "ext-gd2": "Allows adding images", "ext-xmlwriter": "Allows writing OOXML and ODF", - "ext-xsl": "Allows applying XSL style sheet to headers, to main document part, and to footers of an OOXML template", - "ext-zip": "Allows writing OOXML and ODF" + "ext-xsl": "Allows applying XSL style sheet to headers, to main document part, and to footers of an OOXML template" }, - "time": "2024-08-30T18:03:42+00:00", + "time": "2025-06-05T10:32:36+00:00", "type": "library", "installation-source": "dist", "autoload": { @@ -1981,7 +1898,7 @@ }, "notification-url": "https://packagist.org/downloads/", "license": [ - "LGPL-3.0" + "LGPL-3.0-only" ], "authors": [ { @@ -2037,7 +1954,7 @@ ], "support": { "issues": "https://github.com/PHPOffice/PHPWord/issues", - "source": "https://github.com/PHPOffice/PHPWord/tree/1.3.0" + "source": "https://github.com/PHPOffice/PHPWord/tree/1.4.0" }, "install-path": "../phpoffice/phpword" }, @@ -2217,30 +2134,24 @@ }, { "name": "psr/http-factory", - "version": "1.0.2", - "version_normalized": "1.0.2.0", + "version": "1.1.0", + "version_normalized": "1.1.0.0", "source": { "type": "git", "url": "https://github.com/php-fig/http-factory.git", - "reference": "e616d01114759c4c489f93b099585439f795fe35" + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-factory/zipball/e616d01114759c4c489f93b099585439f795fe35", - "reference": "e616d01114759c4c489f93b099585439f795fe35", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" }, "require": { - "php": ">=7.0.0", + "php": ">=7.1", "psr/http-message": "^1.0 || ^2.0" }, - "time": "2023-04-10T20:10:41+00:00", + "time": "2024-04-15T12:06:14+00:00", "type": "library", "extra": { "branch-alias": { @@ -2263,7 +2174,7 @@ "homepage": "https://www.php-fig.org/" } ], - "description": "Common interfaces for PSR-7 HTTP message factories", + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", "keywords": [ "factory", "http", @@ -2275,39 +2186,33 @@ "response" ], "support": { - "source": "https://github.com/php-fig/http-factory/tree/1.0.2" + "source": "https://github.com/php-fig/http-factory" }, "install-path": "../psr/http-factory" }, { "name": "psr/http-message", - "version": "2.0", - "version_normalized": "2.0.0.0", + "version": "1.1", + "version_normalized": "1.1.0.0", "source": { "type": "git", "url": "https://github.com/php-fig/http-message.git", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] + "url": "https://api.github.com/repos/php-fig/http-message/zipball/cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "shasum": "" }, "require": { "php": "^7.2 || ^8.0" }, - "time": "2023-04-04T09:54:51+00:00", + "time": "2023-04-04T09:50:52+00:00", "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-master": "1.1.x-dev" } }, "installation-source": "dist", @@ -2323,7 +2228,7 @@ "authors": [ { "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "homepage": "http://www.php-fig.org/" } ], "description": "Common interface for HTTP messages", @@ -2337,45 +2242,39 @@ "response" ], "support": { - "source": "https://github.com/php-fig/http-message/tree/2.0" + "source": "https://github.com/php-fig/http-message/tree/1.1" }, "install-path": "../psr/http-message" }, { "name": "psr/log", - "version": "3.0.1", - "version_normalized": "3.0.1.0", + "version": "1.1.4", + "version_normalized": "1.1.4.0", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "79dff0b268932c640297f5208d6298f71855c03e" + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/79dff0b268932c640297f5208d6298f71855c03e", - "reference": "79dff0b268932c640297f5208d6298f71855c03e", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" }, "require": { - "php": ">=8.0.0" + "php": ">=5.3.0" }, - "time": "2024-08-21T13:31:24+00:00", + "time": "2021-05-03T11:20:27+00:00", "type": "library", "extra": { "branch-alias": { - "dev-master": "3.x-dev" + "dev-master": "1.1.x-dev" } }, "installation-source": "dist", "autoload": { "psr-4": { - "Psr\\Log\\": "src" + "Psr\\Log\\": "Psr/Log/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2396,39 +2295,33 @@ "psr-3" ], "support": { - "source": "https://github.com/php-fig/log/tree/3.0.1" + "source": "https://github.com/php-fig/log/tree/1.1.4" }, "install-path": "../psr/log" }, { "name": "psr/simple-cache", - "version": "3.0.0", - "version_normalized": "3.0.0.0", + "version": "1.0.1", + "version_normalized": "1.0.1.0", "source": { "type": "git", "url": "https://github.com/php-fig/simple-cache.git", - "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + "reference": "408d5eafb83c57f6365a3ca330ff23aa4a5fa39b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", - "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/408d5eafb83c57f6365a3ca330ff23aa4a5fa39b", + "reference": "408d5eafb83c57f6365a3ca330ff23aa4a5fa39b", + "shasum": "" }, "require": { - "php": ">=8.0.0" + "php": ">=5.3.0" }, - "time": "2021-10-29T13:26:27+00:00", + "time": "2017-10-23T01:57:42+00:00", "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0.x-dev" + "dev-master": "1.0.x-dev" } }, "installation-source": "dist", @@ -2444,7 +2337,7 @@ "authors": [ { "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "homepage": "http://www.php-fig.org/" } ], "description": "Common interfaces for simple caching", @@ -2456,7 +2349,7 @@ "simple-cache" ], "support": { - "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + "source": "https://github.com/php-fig/simple-cache/tree/master" }, "install-path": "../psr/simple-cache" }, @@ -2507,6 +2400,66 @@ }, "install-path": "../ralouphie/getallheaders" }, + { + "name": "smalot/pdfparser", + "version": "v2.9.0", + "version_normalized": "2.9.0.0", + "source": { + "type": "git", + "url": "https://github.com/smalot/pdfparser.git", + "reference": "6b53144fcb24af77093d4150dd7d0dd571f25761" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/smalot/pdfparser/zipball/6b53144fcb24af77093d4150dd7d0dd571f25761", + "reference": "6b53144fcb24af77093d4150dd7d0dd571f25761", + "shasum": "", + "mirrors": [ + { + "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", + "preferred": true + } + ] + }, + "require": { + "ext-iconv": "*", + "ext-zlib": "*", + "php": ">=7.1", + "symfony/polyfill-mbstring": "^1.18" + }, + "time": "2024-03-01T09:51:10+00:00", + "type": "library", + "installation-source": "dist", + "autoload": { + "psr-0": { + "Smalot\\PdfParser\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0" + ], + "authors": [ + { + "name": "Sebastien MALOT", + "email": "sebastien@malot.fr" + } + ], + "description": "Pdf parser library. Can read and extract information from pdf file.", + "homepage": "https://www.pdfparser.org", + "keywords": [ + "extract", + "parse", + "parser", + "pdf", + "text" + ], + "support": { + "issues": "https://github.com/smalot/pdfparser/issues", + "source": "https://github.com/smalot/pdfparser/tree/v2.9.0" + }, + "install-path": "../smalot/pdfparser" + }, { "name": "spatie/data-transfer-object", "version": "1.14.1", @@ -2564,29 +2517,23 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", - "version_normalized": "3.6.0.0", + "version": "v2.5.4", + "version_normalized": "2.5.4.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "605389f2a7e5625f273b53960dc46aeaf9c62918" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/605389f2a7e5625f273b53960dc46aeaf9c62918", + "reference": "605389f2a7e5625f273b53960dc46aeaf9c62918", + "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=7.1" }, - "time": "2024-09-25T14:21:43+00:00", + "time": "2024-09-25T14:11:13+00:00", "type": "library", "extra": { "thanks": { @@ -2594,7 +2541,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "2.5-dev" } }, "installation-source": "dist", @@ -2620,7 +2567,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.4" }, "funding": [ { @@ -2640,44 +2587,38 @@ }, { "name": "symfony/http-foundation", - "version": "v8.0.1", - "version_normalized": "8.0.1.0", + "version": "v5.4.50", + "version_normalized": "5.4.50.0", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "3690740e2e8b19d877f20d4f10b7a489cddf0fe2" + "reference": "1a0706e8b8041046052ea2695eb8aeee04f97609" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/3690740e2e8b19d877f20d4f10b7a489cddf0fe2", - "reference": "3690740e2e8b19d877f20d4f10b7a489cddf0fe2", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/1a0706e8b8041046052ea2695eb8aeee04f97609", + "reference": "1a0706e8b8041046052ea2695eb8aeee04f97609", + "shasum": "" }, "require": { - "php": ">=8.4", - "symfony/polyfill-mbstring": "^1.1" - }, - "conflict": { - "doctrine/dbal": "<4.3" + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/polyfill-mbstring": "~1.1", + "symfony/polyfill-php80": "^1.16" }, "require-dev": { - "doctrine/dbal": "^4.3", - "predis/predis": "^1.1|^2.0", - "symfony/cache": "^7.4|^8.0", - "symfony/clock": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/expression-language": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/mime": "^7.4|^8.0", - "symfony/rate-limiter": "^7.4|^8.0" + "predis/predis": "^1.0|^2.0", + "symfony/cache": "^4.4|^5.0|^6.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/expression-language": "^4.4|^5.0|^6.0", + "symfony/http-kernel": "^5.4.12|^6.0.12|^6.1.4", + "symfony/mime": "^4.4|^5.0|^6.0", + "symfony/rate-limiter": "^5.2|^6.0" }, - "time": "2025-12-07T11:23:24+00:00", + "suggest": { + "symfony/mime": "To use the file extension guesser" + }, + "time": "2025-11-03T12:58:48+00:00", "type": "library", "installation-source": "dist", "autoload": { @@ -2705,7 +2646,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v8.0.1" + "source": "https://github.com/symfony/http-foundation/tree/v5.4.50" }, "funding": [ { @@ -2729,24 +2670,18 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.32.0", - "version_normalized": "1.32.0.0", + "version": "v1.37.0", + "version_normalized": "1.37.0.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", + "shasum": "" }, "require": { "ext-iconv": "*", @@ -2758,7 +2693,7 @@ "suggest": { "ext-mbstring": "For best performance" }, - "time": "2024-12-23T08:48:59+00:00", + "time": "2026-04-10T17:25:58+00:00", "type": "library", "extra": { "thanks": { @@ -2799,7 +2734,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" }, "funding": [ { @@ -2810,6 +2745,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -2817,6 +2756,93 @@ ], "install-path": "../symfony/polyfill-mbstring" }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "version_normalized": "1.37.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "time": "2026-04-10T16:19:22+00:00", + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "installation-source": "dist", + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "install-path": "../symfony/polyfill-php80" + }, { "name": "tectalic/openai", "version": "v1.6.0", @@ -3003,24 +3029,18 @@ }, { "name": "topthink/think-helper", - "version": "v3.1.11", - "version_normalized": "3.1.11.0", + "version": "v3.1.12", + "version_normalized": "3.1.12.0", "source": { "type": "git", "url": "https://github.com/top-think/think-helper.git", - "reference": "1d6ada9b9f3130046bf6922fe1bd159c8d88a33c" + "reference": "fe277121112a8f1c872e169a733ca80bb11c4acb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/top-think/think-helper/zipball/1d6ada9b9f3130046bf6922fe1bd159c8d88a33c", - "reference": "1d6ada9b9f3130046bf6922fe1bd159c8d88a33c", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] + "url": "https://api.github.com/repos/top-think/think-helper/zipball/fe277121112a8f1c872e169a733ca80bb11c4acb", + "reference": "fe277121112a8f1c872e169a733ca80bb11c4acb", + "shasum": "" }, "require": { "php": ">=7.1.0" @@ -3028,7 +3048,7 @@ "require-dev": { "phpunit/phpunit": "^9.5" }, - "time": "2025-04-07T06:55:59+00:00", + "time": "2025-12-26T09:58:29+00:00", "type": "library", "installation-source": "dist", "autoload": { @@ -3052,30 +3072,24 @@ "description": "The ThinkPHP6 Helper Package", "support": { "issues": "https://github.com/top-think/think-helper/issues", - "source": "https://github.com/top-think/think-helper/tree/v3.1.11" + "source": "https://github.com/top-think/think-helper/tree/v3.1.12" }, "install-path": "../topthink/think-helper" }, { "name": "topthink/think-image", - "version": "v1.0.7", - "version_normalized": "1.0.7.0", + "version": "v1.0.8", + "version_normalized": "1.0.8.0", "source": { "type": "git", "url": "https://github.com/top-think/think-image.git", - "reference": "8586cf47f117481c6d415b20f7dedf62e79d5512" + "reference": "d1d748cbb2fe2f29fca6138cf96cb8b5113892f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/top-think/think-image/zipball/8586cf47f117481c6d415b20f7dedf62e79d5512", - "reference": "8586cf47f117481c6d415b20f7dedf62e79d5512", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] + "url": "https://api.github.com/repos/top-think/think-image/zipball/d1d748cbb2fe2f29fca6138cf96cb8b5113892f1", + "reference": "d1d748cbb2fe2f29fca6138cf96cb8b5113892f1", + "shasum": "" }, "require": { "ext-gd": "*" @@ -3084,7 +3098,7 @@ "phpunit/phpunit": "4.8.*", "topthink/framework": "^5.0" }, - "time": "2016-09-29T06:05:43+00:00", + "time": "2024-08-07T10:06:35+00:00", "type": "library", "installation-source": "dist", "autoload": { @@ -3105,7 +3119,7 @@ "description": "The ThinkPHP5 Image Package", "support": { "issues": "https://github.com/top-think/think-image/issues", - "source": "https://github.com/top-think/think-image/tree/master" + "source": "https://github.com/top-think/think-image/tree/v1.0.8" }, "install-path": "../topthink/think-image" }, diff --git a/vendor/phpmailer/phpmailer/README.md b/vendor/phpmailer/phpmailer/README.md index 51c97517..646649c9 100644 --- a/vendor/phpmailer/phpmailer/README.md +++ b/vendor/phpmailer/phpmailer/README.md @@ -48,7 +48,7 @@ This software is distributed under the [LGPL 2.1](https://www.gnu.org/licenses/o PHPMailer is available on [Packagist](https://packagist.org/packages/phpmailer/phpmailer) (using semantic versioning), and installation via [Composer](https://getcomposer.org) is the recommended way to install PHPMailer. Just add this line to your `composer.json` file: ```json -"phpmailer/phpmailer": "^6.11.1" +"phpmailer/phpmailer": "^6.12.0" ``` or run diff --git a/vendor/phpmailer/phpmailer/VERSION b/vendor/phpmailer/phpmailer/VERSION index fac714a3..d4e6cb42 100644 --- a/vendor/phpmailer/phpmailer/VERSION +++ b/vendor/phpmailer/phpmailer/VERSION @@ -1 +1 @@ -6.11.1 +6.12.0 diff --git a/vendor/phpmailer/phpmailer/language/phpmailer.lang-es.php b/vendor/phpmailer/phpmailer/language/phpmailer.lang-es.php index a871824f..4e74bfb7 100644 --- a/vendor/phpmailer/phpmailer/language/phpmailer.lang-es.php +++ b/vendor/phpmailer/phpmailer/language/phpmailer.lang-es.php @@ -9,7 +9,7 @@ */ $PHPMAILER_LANG['authenticate'] = 'Error SMTP: Imposible autentificar.'; -$PHPMAILER_LANG['buggy_php'] = 'Tu versión de PHP ha sido afectada por un bug que puede resultar en mensajes corruptos. Para arreglarlo, cambia a enviar usando SMTP, deshabilita la opción mail.add_x_header en tu php.ini, cambia a MacOS o Linux, o actualiza tu PHP a la versión 7.0.17+ o 7.1.3+.'; +$PHPMAILER_LANG['buggy_php'] = 'Tu versión de PHP está afectada por un bug que puede resultar en mensajes corruptos. Para arreglarlo, cambia a enviar usando SMTP, deshabilita la opción mail.add_x_header en tu php.ini, cambia a MacOS o Linux, o actualiza tu PHP a la versión 7.0.17+ o 7.1.3+.'; $PHPMAILER_LANG['connect_host'] = 'Error SMTP: Imposible conectar al servidor SMTP.'; $PHPMAILER_LANG['data_not_accepted'] = 'Error SMTP: Datos no aceptados.'; $PHPMAILER_LANG['empty_message'] = 'El cuerpo del mensaje está vacío.'; @@ -18,7 +18,7 @@ $PHPMAILER_LANG['execute'] = 'Imposible ejecutar: '; $PHPMAILER_LANG['extension_missing'] = 'Extensión faltante: '; $PHPMAILER_LANG['file_access'] = 'Imposible acceder al archivo: '; $PHPMAILER_LANG['file_open'] = 'Error de Archivo: Imposible abrir el archivo: '; -$PHPMAILER_LANG['from_failed'] = 'La siguiente dirección de remitente falló: '; +$PHPMAILER_LANG['from_failed'] = 'La(s) siguiente(s) direcciones de remitente fallaron: '; $PHPMAILER_LANG['instantiate'] = 'Imposible crear una instancia de la función Mail.'; $PHPMAILER_LANG['invalid_address'] = 'Imposible enviar: dirección de email inválido: '; $PHPMAILER_LANG['invalid_header'] = 'Nombre o valor de encabezado no válido'; @@ -34,5 +34,3 @@ $PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP Connect() falló.'; $PHPMAILER_LANG['smtp_detail'] = 'Detalle: '; $PHPMAILER_LANG['smtp_error'] = 'Error del servidor SMTP: '; $PHPMAILER_LANG['variable_set'] = 'No se pudo configurar la variable: '; -$PHPMAILER_LANG['imap_recommended'] = 'No se recomienda usar el analizador de direcciones simplificado. Instala la extensión IMAP de PHP para un análisis RFC822 más completo.'; -$PHPMAILER_LANG['deprecated_argument'] = 'El argumento $useimap ha quedado obsoleto'; diff --git a/vendor/phpmailer/phpmailer/src/SMTP.php b/vendor/phpmailer/phpmailer/src/SMTP.php index 3772c94a..d8749c5b 100644 --- a/vendor/phpmailer/phpmailer/src/SMTP.php +++ b/vendor/phpmailer/phpmailer/src/SMTP.php @@ -35,7 +35,7 @@ class SMTP * * @var string */ - const VERSION = '6.11.1'; + const VERSION = '6.12.0'; /** * SMTP line break constant. @@ -205,7 +205,6 @@ class SMTP 'Haraka' => '/[\d]{3} Message Queued \((.*)\)/', 'ZoneMTA' => '/[\d]{3} Message queued as (.*)/', 'Mailjet' => '/[\d]{3} OK queued as (.*)/', - 'Gsmtp' => '/[\d]{3} 2\.0\.0 OK (.*) - gsmtp/', ]; /** @@ -634,41 +633,10 @@ class SMTP return false; } $oauth = $OAuth->getOauth64(); - /* - * An SMTP command line can have a maximum length of 512 bytes, including the command name, - * so the base64-encoded OAUTH token has a maximum length of: - * 512 - 13 (AUTH XOAUTH2) - 2 (CRLF) = 497 bytes - * If the token is longer than that, the command and the token must be sent separately as described in - * https://www.rfc-editor.org/rfc/rfc4954#section-4 - */ - if ($oauth === '') { - //Sending an empty auth token is legitimate, but it must be encoded as '=' - //to indicate it's not a 2-part command - if (!$this->sendCommand('AUTH', 'AUTH XOAUTH2 =', 235)) { - return false; - } - } elseif (strlen($oauth) <= 497) { - //Authenticate using a token in the initial-response part - if (!$this->sendCommand('AUTH', 'AUTH XOAUTH2 ' . $oauth, 235)) { - return false; - } - } else { - //The token is too long, so we need to send it in two parts. - //Send the auth command without a token and expect a 334 - if (!$this->sendCommand('AUTH', 'AUTH XOAUTH2', 334)) { - return false; - } - //Send the token - if (!$this->sendCommand('OAuth TOKEN', $oauth, [235, 334])) { - return false; - } - //If the server answers with 334, send an empty line and wait for a 235 - if ( - substr($this->last_reply, 0, 3) === '334' - && $this->sendCommand('AUTH End', '', 235) - ) { - return false; - } + + //Start authentication + if (!$this->sendCommand('AUTH', 'AUTH XOAUTH2 ' . $oauth, 235)) { + return false; } break; default: @@ -1341,16 +1309,7 @@ class SMTP //stream_select returns false when the `select` system call is interrupted //by an incoming signal, try the select again - if ( - stripos($message, 'interrupted system call') !== false || - ( - // on applications with a different locale than english, the message above is not found because - // it's translated. So we also check for the SOCKET_EINTR constant which is defined under - // Windows and UNIX-like platforms (if available on the platform). - defined('SOCKET_EINTR') && - stripos($message, 'stream_select(): Unable to select [' . SOCKET_EINTR . ']') !== false - ) - ) { + if (stripos($message, 'interrupted system call') !== false) { $this->edebug( 'SMTP -> get_lines(): retrying stream_select', self::DEBUG_LOWLEVEL diff --git a/vendor/phpoffice/math/.github/workflows/php.yml b/vendor/phpoffice/math/.github/workflows/php.yml index df807baa..5390049e 100644 --- a/vendor/phpoffice/math/.github/workflows/php.yml +++ b/vendor/phpoffice/math/.github/workflows/php.yml @@ -45,6 +45,7 @@ jobs: - '8.1' - '8.2' - '8.3' + - '8.4' steps: - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -75,6 +76,7 @@ jobs: - '8.1' - '8.2' - '8.3' + - '8.4' steps: - name: Setup PHP uses: shivammathur/setup-php@v2 diff --git a/vendor/phpoffice/math/docs/index.md b/vendor/phpoffice/math/docs/index.md index 340b0dcc..80b4333c 100644 --- a/vendor/phpoffice/math/docs/index.md +++ b/vendor/phpoffice/math/docs/index.md @@ -48,7 +48,7 @@ Math is an open source project licensed under the terms of [MIT](https://github. | **Simple** | Fraction | :material-check: | :material-check: | | | Superscript | :material-check: | | | **Architectural** | Row | :material-check: | :material-check: | -| | Semantics | | | +| | Semantics | :material-check: | | ## Contributing diff --git a/vendor/phpoffice/math/src/Math/Reader/MathML.php b/vendor/phpoffice/math/src/Math/Reader/MathML.php index 58d93935..cf1edb8d 100644 --- a/vendor/phpoffice/math/src/Math/Reader/MathML.php +++ b/vendor/phpoffice/math/src/Math/Reader/MathML.php @@ -10,6 +10,7 @@ use PhpOffice\Math\Element; use PhpOffice\Math\Exception\InvalidInputException; use PhpOffice\Math\Exception\NotImplementedException; use PhpOffice\Math\Math; +use PhpOffice\Math\Reader\Security\XmlScanner; class MathML implements ReaderInterface { @@ -22,8 +23,17 @@ class MathML implements ReaderInterface /** @var DOMXPath */ private $xpath; + /** @var XmlScanner */ + private $xmlScanner; + + public function __construct() + { + $this->xmlScanner = XmlScanner::getInstance(); + } + public function read(string $content): ?Math { + $content = $this->xmlScanner->scan($content); $content = str_replace( [ '⁢', @@ -35,7 +45,7 @@ class MathML implements ReaderInterface ); $this->dom = new DOMDocument(); - $this->dom->loadXML($content, LIBXML_DTDLOAD); + $this->dom->loadXML($content); $this->math = new Math(); $this->parseNode(null, $this->math); diff --git a/vendor/phpoffice/math/src/Math/Writer/MathML.php b/vendor/phpoffice/math/src/Math/Writer/MathML.php index d8905f00..34a20dc3 100644 --- a/vendor/phpoffice/math/src/Math/Writer/MathML.php +++ b/vendor/phpoffice/math/src/Math/Writer/MathML.php @@ -40,6 +40,26 @@ class MathML implements WriterInterface { $tagName = $this->getElementTagName($element); + // Element\AbstractGroupElement + if ($element instanceof Element\Semantics) { + $this->output->startElement($tagName); + // Write elements + foreach ($element->getElements() as $childElement) { + $this->writeElementItem($childElement); + } + + // Write annotations + foreach ($element->getAnnotations() as $encoding => $annotation) { + $this->output->startElement('annotation'); + $this->output->writeAttribute('encoding', $encoding); + $this->output->text($annotation); + $this->output->endElement(); + } + $this->output->endElement(); + + return; + } + // Element\AbstractGroupElement if ($element instanceof Element\AbstractGroupElement) { $this->output->startElement($tagName); @@ -121,6 +141,9 @@ class MathML implements WriterInterface if ($element instanceof Element\Operator) { return 'mo'; } + if ($element instanceof Element\Semantics) { + return 'semantics'; + } throw new NotImplementedException(sprintf( '%s : The element of the class `%s` has no tag name', diff --git a/vendor/phpoffice/math/tests/Math/Writer/MathMLTest.php b/vendor/phpoffice/math/tests/Math/Writer/MathMLTest.php index 06aac383..90724856 100644 --- a/vendor/phpoffice/math/tests/Math/Writer/MathMLTest.php +++ b/vendor/phpoffice/math/tests/Math/Writer/MathMLTest.php @@ -80,6 +80,35 @@ class MathMLTest extends WriterTestCase $this->assertIsSchemaMathMLValid($output); } + public function testWriteSemantics(): void + { + $opTimes = new Element\Operator('⁢'); + + $math = new Math(); + + $semantics = new Element\Semantics(); + $semantics->add(new Element\Identifier('y')); + $semantics->addAnnotation('application/x-tex', ' y '); + + $math->add($semantics); + + $writer = new MathML(); + $output = $writer->write($math); + + $expected = '' + . PHP_EOL + . '' + . '' + . '' + . 'y' + . ' y ' + . '' + . '' + . PHP_EOL; + $this->assertEquals($expected, $output); + $this->assertIsSchemaMathMLValid($output); + } + public function testWriteNotImplemented(): void { $this->expectException(NotImplementedException::class); diff --git a/vendor/phpoffice/phpword/composer.json b/vendor/phpoffice/phpword/composer.json index bce0f4b8..90b1c2e5 100644 --- a/vendor/phpoffice/phpword/composer.json +++ b/vendor/phpoffice/phpword/composer.json @@ -8,7 +8,7 @@ ], "homepage": "https://phpoffice.github.io/PHPWord/", "type": "library", - "license": "LGPL-3.0", + "license": "LGPL-3.0-only", "authors": [ { "name": "Mark Baker" @@ -36,20 +36,67 @@ ], "scripts": { "test": [ - "phpunit --color=always" + "@php vendor/bin/phpunit --color=always" ], "test-no-coverage": [ - "phpunit --color=always --no-coverage" + "@php vendor/bin/phpunit --color=always --no-coverage" ], "check": [ - "php-cs-fixer fix --ansi --dry-run --diff", - "phpcs --report-width=200 --report-summary --report-full samples/ src/ tests/ --ignore=src/PhpWord/Shared/PCLZip --standard=PSR2 -n", - "phpmd src/,tests/ text ./phpmd.xml.dist --exclude pclzip.lib.php", + "@php vendor/bin/php-cs-fixer fix --ansi --dry-run --diff", + "@php vendor/bin/phpmd src/,tests/ text ./phpmd.xml.dist --exclude pclzip.lib.php", "@test-no-coverage", - "phpstan analyse --ansi" + "@php vendor/bin/phpstan analyse --ansi" ], "fix": [ - "php-cs-fixer fix --ansi" + "@php vendor/bin/php-cs-fixer fix --ansi" + ], + "samples": [ + "php samples/Sample_01_SimpleText.php", + "php samples/Sample_02_TabStops.php", + "php samples/Sample_03_Sections.php", + "php samples/Sample_04_Textrun.php", + "php samples/Sample_05_Multicolumn.php", + "php samples/Sample_06_Footnote.php", + "php samples/Sample_07_TemplateCloneRow.php", + "php samples/Sample_08_ParagraphPagination.php", + "php samples/Sample_09_Tables.php", + "php samples/Sample_10_EastAsianFontStyle.php", + "php samples/Sample_11_ReadWord97.php", + "php samples/Sample_11_ReadWord2007.php", + "php samples/Sample_12_HeaderFooter.php", + "php samples/Sample_13_Images.php", + "php samples/Sample_14_ListItem.php", + "php samples/Sample_15_Link.php", + "php samples/Sample_16_Object.php", + "php samples/Sample_17_TitleTOC.php", + "php samples/Sample_18_Watermark.php", + "php samples/Sample_19_TextBreak.php", + "php samples/Sample_20_BGColor.php", + "php samples/Sample_21_TableRowRules.php", + "php samples/Sample_22_CheckBox.php", + "php samples/Sample_23_TemplateBlock.php", + "php samples/Sample_24_ReadODText.php", + "php samples/Sample_25_TextBox.php", + "php samples/Sample_26_Html.php", + "php samples/Sample_27_Field.php", + "php samples/Sample_28_ReadRTF.php", + "php samples/Sample_29_Line.php", + "php samples/Sample_30_ReadHTML.php", + "php samples/Sample_31_Shape.php", + "php samples/Sample_32_Chart.php", + "php samples/Sample_33_FormField.php", + "php samples/Sample_34_SDT.php", + "php samples/Sample_35_InternalLink.php", + "php samples/Sample_36_RTL.php", + "php samples/Sample_37_Comments.php", + "php samples/Sample_38_Protection.php", + "php samples/Sample_39_TrackChanges.php", + "php samples/Sample_40_TemplateSetComplexValue.php", + "php samples/Sample_41_TemplateSetChart.php", + "php samples/Sample_42_TemplateSetCheckbox.php", + "php samples/Sample_43_RTLDefault.php", + "php samples/Sample_44_ExtractVariablesFromReaderWord2007.php", + "php samples/Sample_45_Autoloader.php" ] }, "scripts-descriptions": { @@ -58,34 +105,28 @@ "check": "Runs PHP CheckStyle and PHP Mess detector", "fix": "Fixes issues found by PHP-CS" }, - "config": { - "platform": { - "php": "8.0" - } - }, "require": { "php": "^7.1|^8.0", "ext-dom": "*", + "ext-gd": "*", + "ext-zip": "*", "ext-json": "*", "ext-xml": "*", - "phpoffice/math": "^0.2" + "phpoffice/math": "^0.3" }, "require-dev": { - "ext-zip": "*", - "ext-gd": "*", "ext-libxml": "*", - "dompdf/dompdf": "^2.0", - "mpdf/mpdf": "^8.1", - "phpmd/phpmd": "^2.13", - "phpunit/phpunit": ">=7.0", - "tecnickcom/tcpdf": "^6.5", - "symfony/process": "^4.4 || ^5.0", + "dompdf/dompdf": "^2.0 || ^3.0", "friendsofphp/php-cs-fixer": "^3.3", - "phpstan/phpstan-phpunit": "@stable" + "mpdf/mpdf": "^7.0 || ^8.0", + "phpmd/phpmd": "^2.13", + "phpstan/phpstan": "^0.12.88 || ^1.0.0", + "phpstan/phpstan-phpunit": "^1.0 || ^2.0", + "phpunit/phpunit": ">=7.0", + "symfony/process": "^4.4 || ^5.0", + "tecnickcom/tcpdf": "^6.5" }, "suggest": { - "ext-zip": "Allows writing OOXML and ODF", - "ext-gd2": "Allows adding images", "ext-xmlwriter": "Allows writing OOXML and ODF", "ext-xsl": "Allows applying XSL style sheet to headers, to main document part, and to footers of an OOXML template", "dompdf/dompdf": "Allows writing PDF" diff --git a/vendor/phpoffice/phpword/phpstan-baseline.neon b/vendor/phpoffice/phpword/phpstan-baseline.neon index 909718b8..86a54276 100644 --- a/vendor/phpoffice/phpword/phpstan-baseline.neon +++ b/vendor/phpoffice/phpword/phpstan-baseline.neon @@ -375,11 +375,6 @@ parameters: count: 1 path: src/PhpWord/Shared/Drawing.php - - - message: "#^Binary operation \"\\*\" between string and 50 results in an error\\.$#" - count: 1 - path: src/PhpWord/Shared/Html.php - - message: "#^Call to an undefined method DOMNode\\:\\:getAttribute\\(\\)\\.$#" count: 1 @@ -435,6 +430,11 @@ parameters: count: 1 path: src/PhpWord/Shared/Html.php + - + message: "#^Method PhpOffice\\\\PhpWord\\\\Shared\\\\Html\\:\\:parseRuby\\(\\) has no return type specified\\.$#" + count: 1 + path: src/PhpWord/Shared/Html.php + - message: "#^Method PhpOffice\\\\PhpWord\\\\Shared\\\\Html\\:\\:parseStyleDeclarations\\(\\) has no return type specified\\.$#" count: 1 @@ -447,7 +447,7 @@ parameters: - message: "#^Parameter \\#1 \\$attribute of static method PhpOffice\\\\PhpWord\\\\Shared\\\\Html\\:\\:parseStyle\\(\\) expects DOMAttr, DOMNode given\\.$#" - count: 1 + count: 3 path: src/PhpWord/Shared/Html.php - @@ -685,11 +685,6 @@ parameters: count: 1 path: src/PhpWord/Style/Cell.php - - - message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Cell\\:\\:\\$shading is never written, only read\\.$#" - count: 1 - path: src/PhpWord/Style/Cell.php - - message: "#^Method PhpOffice\\\\PhpWord\\\\Style\\\\Chart\\:\\:getMajorTickPosition\\(\\) has no return type specified\\.$#" count: 1 @@ -760,41 +755,6 @@ parameters: count: 1 path: src/PhpWord/Style/Font.php - - - message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Font\\:\\:\\$allCaps is never written, only read\\.$#" - count: 1 - path: src/PhpWord/Style/Font.php - - - - message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Font\\:\\:\\$doubleStrikethrough is never written, only read\\.$#" - count: 1 - path: src/PhpWord/Style/Font.php - - - - message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Font\\:\\:\\$lang is never written, only read\\.$#" - count: 1 - path: src/PhpWord/Style/Font.php - - - - message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Font\\:\\:\\$paragraph is never written, only read\\.$#" - count: 1 - path: src/PhpWord/Style/Font.php - - - - message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Font\\:\\:\\$shading is never written, only read\\.$#" - count: 1 - path: src/PhpWord/Style/Font.php - - - - message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Font\\:\\:\\$smallCaps is never written, only read\\.$#" - count: 1 - path: src/PhpWord/Style/Font.php - - - - message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Font\\:\\:\\$strikethrough is never written, only read\\.$#" - count: 1 - path: src/PhpWord/Style/Font.php - - message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Line\\:\\:\\$weight \\(int\\) does not accept float\\|int\\|null\\.$#" count: 1 @@ -815,21 +775,6 @@ parameters: count: 1 path: src/PhpWord/Style/Paragraph.php - - - message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Paragraph\\:\\:\\$indentation is never written, only read\\.$#" - count: 1 - path: src/PhpWord/Style/Paragraph.php - - - - message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Paragraph\\:\\:\\$shading is never written, only read\\.$#" - count: 1 - path: src/PhpWord/Style/Paragraph.php - - - - message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Paragraph\\:\\:\\$spacing is never written, only read\\.$#" - count: 1 - path: src/PhpWord/Style/Paragraph.php - - message: "#^Result of && is always false\\.$#" count: 1 @@ -840,36 +785,6 @@ parameters: count: 1 path: src/PhpWord/Style/Section.php - - - message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Section\\:\\:\\$lineNumbering is never written, only read\\.$#" - count: 1 - path: src/PhpWord/Style/Section.php - - - - message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Shape\\:\\:\\$extrusion is never written, only read\\.$#" - count: 1 - path: src/PhpWord/Style/Shape.php - - - - message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Shape\\:\\:\\$fill is never written, only read\\.$#" - count: 1 - path: src/PhpWord/Style/Shape.php - - - - message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Shape\\:\\:\\$frame is never written, only read\\.$#" - count: 1 - path: src/PhpWord/Style/Shape.php - - - - message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Shape\\:\\:\\$outline is never written, only read\\.$#" - count: 1 - path: src/PhpWord/Style/Shape.php - - - - message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Shape\\:\\:\\$shadow is never written, only read\\.$#" - count: 1 - path: src/PhpWord/Style/Shape.php - - message: "#^Method PhpOffice\\\\PhpWord\\\\Style\\\\TOC\\:\\:setTabLeader\\(\\) should return PhpOffice\\\\PhpWord\\\\Style\\\\TOC but returns PhpOffice\\\\PhpWord\\\\Style\\\\Tab\\.$#" count: 1 @@ -1120,11 +1035,6 @@ parameters: count: 1 path: src/PhpWord/Writer/AbstractWriter.php - - - message: "#^PHPDoc tag @param has invalid value \\(\\\\PhpOffice\\\\PhpWord\\\\PhpWord\\)\\: Unexpected token \"\\\\n \\*\", expected variable at offset 78$#" - count: 1 - path: src/PhpWord/Writer/AbstractWriter.php - - message: "#^Method PhpOffice\\\\PhpWord\\\\Writer\\\\HTML\\\\Element\\\\AbstractElement\\:\\:write\\(\\) has no return type specified\\.$#" count: 1 @@ -1146,14 +1056,14 @@ parameters: path: src/PhpWord/Writer/ODText/Element/Table.php - - message: "#^Method PhpOffice\\\\PhpWord\\\\Writer\\\\ODText\\\\Element\\\\Text\\:\\:replacetabs\\(\\) has parameter \\$text with no type specified\\.$#" + message: "#^Method PhpOffice\\\\PhpWord\\\\Writer\\\\ODText\\\\Element\\\\AbstractElement\\:\\:replaceTabs\\(\\) has parameter \\$text with no type specified\\.$#" count: 1 - path: src/PhpWord/Writer/ODText/Element/Text.php + path: src/PhpWord/Writer/ODText/Element/AbstractElement.php - - message: "#^Method PhpOffice\\\\PhpWord\\\\Writer\\\\ODText\\\\Element\\\\Text\\:\\:replacetabs\\(\\) has parameter \\$xmlWriter with no type specified\\.$#" + message: "#^Method PhpOffice\\\\PhpWord\\\\Writer\\\\ODText\\\\Element\\\\AbstractElement\\:\\:replaceTabs\\(\\) has parameter \\$xmlWriter with no type specified\\.$#" count: 1 - path: src/PhpWord/Writer/ODText/Element/Text.php + path: src/PhpWord/Writer/ODText/Element/AbstractElement.php - message: "#^Method PhpOffice\\\\PhpWord\\\\Writer\\\\ODText\\\\Element\\\\Text\\:\\:writeChangeInsertion\\(\\) has parameter \\$start with no type specified\\.$#" @@ -1315,11 +1225,6 @@ parameters: count: 1 path: src/PhpWord/Writer/RTF/Style/Border.php - - - message: "#^PHPDoc tag @param has invalid value \\(\\\\PhpOffice\\\\PhpWord\\\\PhpWord\\)\\: Unexpected token \"\\\\n \", expected variable at offset 86$#" - count: 1 - path: src/PhpWord/Writer/Word2007.php - - message: "#^Method PhpOffice\\\\PhpWord\\\\Writer\\\\Word2007\\\\Element\\\\AbstractElement\\:\\:write\\(\\) has no return type specified\\.$#" count: 1 @@ -1426,29 +1331,29 @@ parameters: path: tests/PhpWordTests/AbstractTestReader.php - - message: "#^Method PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbeddedTest\\:\\:getBaseUrl\\(\\) has no return type specified\\.$#" + message: "#^Method PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbedded\\:\\:getBaseUrl\\(\\) has no return type specified\\.$#" count: 1 - path: tests/PhpWordTests/AbstractWebServerEmbeddedTest.php + path: tests/PhpWordTests/AbstractWebServerEmbedded.php - - message: "#^Method PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbeddedTest\\:\\:getRemoteBmpImageUrl\\(\\) has no return type specified\\.$#" + message: "#^Method PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbedded\\:\\:getRemoteBmpImageUrl\\(\\) has no return type specified\\.$#" count: 1 - path: tests/PhpWordTests/AbstractWebServerEmbeddedTest.php + path: tests/PhpWordTests/AbstractWebServerEmbedded.php - - message: "#^Method PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbeddedTest\\:\\:getRemoteGifImageUrl\\(\\) has no return type specified\\.$#" + message: "#^Method PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbedded\\:\\:getRemoteGifImageUrl\\(\\) has no return type specified\\.$#" count: 1 - path: tests/PhpWordTests/AbstractWebServerEmbeddedTest.php + path: tests/PhpWordTests/AbstractWebServerEmbedded.php - - message: "#^Method PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbeddedTest\\:\\:getRemoteImageUrl\\(\\) has no return type specified\\.$#" + message: "#^Method PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbedded\\:\\:getRemoteImageUrl\\(\\) has no return type specified\\.$#" count: 1 - path: tests/PhpWordTests/AbstractWebServerEmbeddedTest.php + path: tests/PhpWordTests/AbstractWebServerEmbedded.php - - message: "#^Property PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbeddedTest\\:\\:\\$httpServer has no type specified\\.$#" + message: "#^Property PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbedded\\:\\:\\$httpServer has no type specified\\.$#" count: 1 - path: tests/PhpWordTests/AbstractWebServerEmbeddedTest.php + path: tests/PhpWordTests/AbstractWebServerEmbedded.php - message: "#^Parameter \\#1 \\$width of class PhpOffice\\\\PhpWord\\\\Element\\\\Cell constructor expects int\\|null, string given\\.$#" @@ -1789,6 +1694,11 @@ parameters: message: "#^Cannot access property \\$length on DOMNodeList\\\\|false\\.$#" count: 9 path: tests/PhpWordTests/Writer/HTML/ElementTest.php + + - + message: "#^Cannot access property \\$length on DOMNodeList\\\\|false\\.$#" + count: 2 + path: tests/PhpWordTests/Writer/HTML/Element/RubyTest.php - message: "#^Cannot call method item\\(\\) on DOMNodeList\\\\|false\\.$#" diff --git a/vendor/phpoffice/phpword/src/PhpWord/Collection/Charts.php b/vendor/phpoffice/phpword/src/PhpWord/Collection/Charts.php index 7c2dfbab..ef1c6597 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Collection/Charts.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Collection/Charts.php @@ -1,4 +1,5 @@ */ class Charts extends AbstractCollection diff --git a/vendor/phpoffice/phpword/src/PhpWord/Collection/Comments.php b/vendor/phpoffice/phpword/src/PhpWord/Collection/Comments.php index 5fa4020a..4f139435 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Collection/Comments.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Collection/Comments.php @@ -1,4 +1,5 @@ */ class Comments extends AbstractCollection diff --git a/vendor/phpoffice/phpword/src/PhpWord/Collection/Endnotes.php b/vendor/phpoffice/phpword/src/PhpWord/Collection/Endnotes.php index 09903b1b..3de1ad22 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Collection/Endnotes.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Collection/Endnotes.php @@ -1,4 +1,5 @@ */ class Endnotes extends AbstractCollection diff --git a/vendor/phpoffice/phpword/src/PhpWord/Collection/Footnotes.php b/vendor/phpoffice/phpword/src/PhpWord/Collection/Footnotes.php index 0387fce3..804a45c8 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Collection/Footnotes.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Collection/Footnotes.php @@ -1,4 +1,5 @@ */ class Footnotes extends AbstractCollection diff --git a/vendor/phpoffice/phpword/src/PhpWord/Collection/Titles.php b/vendor/phpoffice/phpword/src/PhpWord/Collection/Titles.php index 543aabda..1a943697 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Collection/Titles.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Collection/Titles.php @@ -1,4 +1,5 @@ */ class Titles extends AbstractCollection diff --git a/vendor/phpoffice/phpword/src/PhpWord/ComplexType/FootnoteProperties.php b/vendor/phpoffice/phpword/src/PhpWord/ComplexType/FootnoteProperties.php index 2e7743ca..10c426e8 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/ComplexType/FootnoteProperties.php +++ b/vendor/phpoffice/phpword/src/PhpWord/ComplexType/FootnoteProperties.php @@ -1,4 +1,5 @@ newInstanceArgs($elementArgs); // Set parent container @@ -165,7 +167,7 @@ abstract class AbstractContainer extends AbstractElement /** * Get all elements. * - * @return \PhpOffice\PhpWord\Element\AbstractElement[] + * @return AbstractElement[] */ public function getElements() { @@ -177,7 +179,7 @@ abstract class AbstractContainer extends AbstractElement * * @param int $index * - * @return null|\PhpOffice\PhpWord\Element\AbstractElement + * @return null|AbstractElement */ public function getElement($index) { @@ -191,13 +193,13 @@ abstract class AbstractContainer extends AbstractElement /** * Removes the element at requested index. * - * @param int|\PhpOffice\PhpWord\Element\AbstractElement $toRemove + * @param AbstractElement|int $toRemove */ public function removeElement($toRemove): void { if (is_int($toRemove) && array_key_exists($toRemove, $this->elements)) { unset($this->elements[$toRemove]); - } elseif ($toRemove instanceof \PhpOffice\PhpWord\Element\AbstractElement) { + } elseif ($toRemove instanceof AbstractElement) { foreach ($this->elements as $key => $element) { if ($element->getElementId() === $toRemove->getElementId()) { unset($this->elements[$key]); @@ -253,7 +255,7 @@ abstract class AbstractContainer extends AbstractElement 'Footnote' => ['Section', 'TextRun', 'Cell', 'ListItemRun'], 'Endnote' => ['Section', 'TextRun', 'Cell'], 'PreserveText' => ['Section', 'Header', 'Footer', 'Cell'], - 'Title' => ['Section', 'Cell'], + 'Title' => ['Section', 'Cell', 'Header'], 'TOC' => ['Section'], 'PageBreak' => ['Section'], 'Chart' => ['Section', 'Cell'], diff --git a/vendor/phpoffice/phpword/src/PhpWord/Element/AbstractElement.php b/vendor/phpoffice/phpword/src/PhpWord/Element/AbstractElement.php index 385e4d31..3a29b686 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Element/AbstractElement.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Element/AbstractElement.php @@ -1,4 +1,5 @@ elementId = substr(md5(mt_rand()), 0, 6); + $this->elementId = substr(md5((string) mt_rand()), 0, 6); } /** @@ -291,8 +290,6 @@ abstract class AbstractElement /** * Get comments start. - * - * @return Comments */ public function getCommentsRangeStart(): ?Comments { @@ -301,8 +298,6 @@ abstract class AbstractElement /** * Get comment start. - * - * @return Comment */ public function getCommentRangeStart(): ?Comment { @@ -339,8 +334,6 @@ abstract class AbstractElement /** * Get comments end. - * - * @return Comments */ public function getCommentsRangeEnd(): ?Comments { @@ -349,8 +342,6 @@ abstract class AbstractElement /** * Get comment end. - * - * @return Comment */ public function getCommentRangeEnd(): ?Comment { diff --git a/vendor/phpoffice/phpword/src/PhpWord/Element/Bookmark.php b/vendor/phpoffice/phpword/src/PhpWord/Element/Bookmark.php index 4fe3d0f0..151d5a48 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Element/Bookmark.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Element/Bookmark.php @@ -1,4 +1,5 @@ phpWord->getTitles()->getItems(); foreach ($titles as $i => $title) { - /** @var \PhpOffice\PhpWord\Element\Title $title Type hint */ + /** @var Title $title Type hint */ $depth = $title->getDepth(); if ($this->minDepth > $depth) { unset($titles[$i]); @@ -110,7 +111,7 @@ class TOC extends AbstractElement /** * Get TOC Style. * - * @return \PhpOffice\PhpWord\Style\TOC + * @return TOCStyle */ public function getStyleTOC() { @@ -120,7 +121,7 @@ class TOC extends AbstractElement /** * Get Font Style. * - * @return \PhpOffice\PhpWord\Style\Font|string + * @return Font|string */ public function getStyleFont() { diff --git a/vendor/phpoffice/phpword/src/PhpWord/Element/Text.php b/vendor/phpoffice/phpword/src/PhpWord/Element/Text.php index 96953953..f20b273e 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Element/Text.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Element/Text.php @@ -1,4 +1,5 @@ text; } diff --git a/vendor/phpoffice/phpword/src/PhpWord/Element/TextBox.php b/vendor/phpoffice/phpword/src/PhpWord/Element/TextBox.php index af37f657..b9f8b50b 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Element/TextBox.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Element/TextBox.php @@ -1,4 +1,5 @@ getElements('w:r|w:hyperlink', $domNode); - if ($nodes->length === 1) { + $hasRubyElement = $xmlReader->elementExists('w:r/w:ruby', $domNode); + if ($nodes->length === 1 && !$hasRubyElement) { $textContent = htmlspecialchars($xmlReader->getValue('w:t', $nodes->item(0)), ENT_QUOTES, 'UTF-8'); } else { $textContent = new TextRun($paragraphStyle); @@ -450,7 +455,7 @@ abstract class AbstractPart /** * Read w:r. * - * @param \PhpOffice\PhpWord\Element\AbstractContainer $parent + * @param AbstractContainer $parent * @param string $docPart * @param mixed $paragraphStyle * @@ -584,9 +589,47 @@ abstract class AbstractPart } } elseif ($node->nodeName == 'w:softHyphen') { $element = $parent->addText("\u{200c}", $fontStyle, $paragraphStyle); + } elseif ($node->nodeName == 'w:ruby') { + $rubyPropertiesNode = $xmlReader->getElement('w:rubyPr', $node); + $properties = $this->readRubyProperties($xmlReader, $rubyPropertiesNode); + // read base text node + $baseText = new TextRun($paragraphStyle); + $baseTextNode = $xmlReader->getElement('w:rubyBase/w:r', $node); + $this->readRun($xmlReader, $baseTextNode, $baseText, $docPart, $paragraphStyle); + // read the actual ruby text (e.g. furigana in Japanese) + $rubyText = new TextRun($paragraphStyle); + $rubyTextNode = $xmlReader->getElement('w:rt/w:r', $node); + $this->readRun($xmlReader, $rubyTextNode, $rubyText, $docPart, $paragraphStyle); + // add element to parent + $parent->addRuby($baseText, $rubyText, $properties); } } + /** + * Read w:rubyPr element. + * + * @param XMLReader $xmlReader reader for XML + * @param DOMElement $domNode w:RubyPr element + * + * @return RubyProperties ruby properties from element + */ + protected function readRubyProperties(XMLReader $xmlReader, DOMElement $domNode): RubyProperties + { + $rubyAlignment = $xmlReader->getElement('w:rubyAlign', $domNode)->getAttribute('w:val'); + $rubyHps = $xmlReader->getElement('w:hps', $domNode)->getAttribute('w:val'); // font face + $rubyHpsRaise = $xmlReader->getElement('w:hpsRaise', $domNode)->getAttribute('w:val'); // pts above base text + $rubyHpsBaseText = $xmlReader->getElement('w:hpsBaseText', $domNode)->getAttribute('w:val'); // base text size + $rubyLid = $xmlReader->getElement('w:lid', $domNode)->getAttribute('w:val'); // type of ruby + $properties = new RubyProperties(); + $properties->setAlignment($rubyAlignment); + $properties->setFontFaceSize((float) $rubyHps); + $properties->setFontPointsAboveBaseText((float) $rubyHpsRaise); + $properties->setFontSizeForBaseText((float) $rubyHpsBaseText); + $properties->setLanguageId($rubyLid); + + return $properties; + } + /** * Read w:tbl. * @@ -661,8 +704,11 @@ abstract class AbstractPart 'alignment' => [self::READ_VALUE, 'w:jc'], 'basedOn' => [self::READ_VALUE, 'w:basedOn'], 'next' => [self::READ_VALUE, 'w:next'], - 'indent' => [self::READ_VALUE, 'w:ind', 'w:left'], - 'hanging' => [self::READ_VALUE, 'w:ind', 'w:hanging'], + 'indentLeft' => [self::READ_VALUE, 'w:ind', 'w:left'], + 'indentRight' => [self::READ_VALUE, 'w:ind', 'w:right'], + 'indentHanging' => [self::READ_VALUE, 'w:ind', 'w:hanging'], + 'indentFirstLine' => [self::READ_VALUE, 'w:ind', 'w:firstLine'], + 'indentFirstLineChars' => [self::READ_VALUE, 'w:ind', 'w:firstLineChars'], 'spaceAfter' => [self::READ_VALUE, 'w:spacing', 'w:after'], 'spaceBefore' => [self::READ_VALUE, 'w:spacing', 'w:before'], 'widowControl' => [self::READ_FALSE, 'w:widowControl'], diff --git a/vendor/phpoffice/phpword/src/PhpWord/Reader/Word2007/DocPropsApp.php b/vendor/phpoffice/phpword/src/PhpWord/Reader/Word2007/DocPropsApp.php index 9d6f3cbb..c7ecb007 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Reader/Word2007/DocPropsApp.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Reader/Word2007/DocPropsApp.php @@ -1,4 +1,5 @@ setDefaultFontSize($fontDefaultStyle['size']); } + if (array_key_exists('color', $fontDefaultStyle)) { + $phpWord->setDefaultFontColor($fontDefaultStyle['color']); + } if (array_key_exists('lang', $fontDefaultStyle)) { $phpWord->getSettings()->setThemeFontLang(new Language($fontDefaultStyle['lang'])); } diff --git a/vendor/phpoffice/phpword/src/PhpWord/Settings.php b/vendor/phpoffice/phpword/src/PhpWord/Settings.php index 984486cc..16f49166 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Settings.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Settings.php @@ -1,4 +1,5 @@ cssContent); preg_match_all('/(.+?)\s?\{\s?(.+?)\s?\}/', $cssContent, $cssExtracted); - // Check the number of extracted - if (count($cssExtracted) != 3) { - return; - } // Check if there are x selectors and x rules if (count($cssExtracted[1]) != count($cssExtracted[2])) { return; diff --git a/vendor/phpoffice/phpword/src/PhpWord/Shared/Drawing.php b/vendor/phpoffice/phpword/src/PhpWord/Shared/Drawing.php index df218108..8af7da2f 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Shared/Drawing.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Shared/Drawing.php @@ -1,4 +1,5 @@ vAlign = null; + + return $this; + } + VerticalJc::validate($value); $this->vAlign = $this->setEnumVal($value, VerticalJc::values(), $this->vAlign); @@ -235,7 +262,7 @@ class Cell extends Border /** * Get vertical merge (rowspan). * - * @return string + * @return null|string */ public function getVMerge() { @@ -245,12 +272,18 @@ class Cell extends Border /** * Set vertical merge (rowspan). * - * @param string $value + * @param null|string $value * * @return self */ public function setVMerge($value = null) { + if ($value === null) { + $this->vMerge = null; + + return $this; + } + $enum = [self::VMERGE_RESTART, self::VMERGE_CONTINUE]; $this->vMerge = $this->setEnumVal($value, $enum, $this->vMerge); @@ -260,7 +293,7 @@ class Cell extends Border /** * Get shading. * - * @return \PhpOffice\PhpWord\Style\Shading + * @return Shading */ public function getShading() { @@ -344,4 +377,84 @@ class Cell extends Border { return $this->noWrap; } + + /** + * Get style padding-top. + */ + public function getPaddingTop(): ?int + { + return $this->paddingTop; + } + + /** + * Set style padding-top. + * + * @return $this + */ + public function setPaddingTop(int $value): self + { + $this->paddingTop = $value; + + return $this; + } + + /** + * Get style padding-bottom. + */ + public function getPaddingBottom(): ?int + { + return $this->paddingBottom; + } + + /** + * Set style padding-bottom. + * + * @return $this + */ + public function setPaddingBottom(int $value): self + { + $this->paddingBottom = $value; + + return $this; + } + + /** + * Get style padding-left. + */ + public function getPaddingLeft(): ?int + { + return $this->paddingLeft; + } + + /** + * Set style padding-left. + * + * @return $this + */ + public function setPaddingLeft(int $value): self + { + $this->paddingLeft = $value; + + return $this; + } + + /** + * Get style padding-right. + */ + public function getPaddingRight(): ?int + { + return $this->paddingRight; + } + + /** + * Set style padding-right. + * + * @return $this + */ + public function setPaddingRight(int $value): self + { + $this->paddingRight = $value; + + return $this; + } } diff --git a/vendor/phpoffice/phpword/src/PhpWord/Style/Extrusion.php b/vendor/phpoffice/phpword/src/PhpWord/Style/Extrusion.php index dd002312..c31c51eb 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Style/Extrusion.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Style/Extrusion.php @@ -1,4 +1,5 @@ zip()->AddFromString("word/media/image1.jpg", file_get_contents($file));
* To read a file: $templateProcessor->zip()->getFromName("word/media/image1.jpg"); * - * @return \PhpOffice\PhpWord\Shared\ZipArchive + * @return ZipArchive */ public function zip() { @@ -269,7 +269,7 @@ class TemplateProcessor */ protected static function ensureUtf8Encoded($subject) { - return $subject ? Text::toUTF8($subject) : ''; + return (null !== $subject) ? Text::toUTF8($subject) : ''; } /** @@ -281,7 +281,7 @@ class TemplateProcessor $objectClass = 'PhpOffice\\PhpWord\\Writer\\Word2007\\Element\\' . $elementName; $xmlWriter = new XMLWriter(); - /** @var \PhpOffice\PhpWord\Writer\Word2007\Element\AbstractElement $elementWriter */ + /** @var Writer\Word2007\Element\AbstractElement $elementWriter */ $elementWriter = new $objectClass($xmlWriter, $complexType, true); $elementWriter->write(); @@ -308,7 +308,7 @@ class TemplateProcessor $objectClass = 'PhpOffice\\PhpWord\\Writer\\Word2007\\Element\\' . $elementName; $xmlWriter = new XMLWriter(); - /** @var \PhpOffice\PhpWord\Writer\Word2007\Element\AbstractElement $elementWriter */ + /** @var Writer\Word2007\Element\AbstractElement $elementWriter */ $elementWriter = new $objectClass($xmlWriter, $complexType, false); $elementWriter->write(); @@ -362,10 +362,10 @@ class TemplateProcessor /** * Set values from a one-dimensional array of "variable => value"-pairs. */ - public function setValues(array $values): void + public function setValues(array $values, int $limit = self::MAXIMUM_REPLACEMENTS_DEFAULT): void { foreach ($values as $macro => $replace) { - $this->setValue($macro, $replace); + $this->setValue($macro, $replace, $limit); } } @@ -406,7 +406,7 @@ class TemplateProcessor $filename = "charts/chart{$rId}.xml"; // Get the part writer - $writerPart = new \PhpOffice\PhpWord\Writer\Word2007\Part\Chart(); + $writerPart = new Writer\Word2007\Part\Chart(); $writerPart->setElement($chart); // ContentTypes.xml @@ -499,20 +499,22 @@ class TemplateProcessor $widthFloat = $heightFloat * $imageRatio; $matches = []; preg_match('/\\d([a-z%]+)$/', $height, $matches); - $width = $widthFloat . $matches[1]; + $width = $widthFloat . (!empty($matches) ? $matches[1] : 'px'); } elseif ($height === '') { // defined height is empty $widthFloat = (float) $width; $heightFloat = $widthFloat / $imageRatio; $matches = []; preg_match('/\\d([a-z%]+)$/', $width, $matches); - $height = $heightFloat . $matches[1]; + $height = $heightFloat . (!empty($matches) ? $matches[1] : 'px'); } else { // we have defined size, but we need also check it aspect ratio $widthMatches = []; preg_match('/\\d([a-z%]+)$/', $width, $widthMatches); $heightMatches = []; preg_match('/\\d([a-z%]+)$/', $height, $heightMatches); // try to fix only if dimensions are same - if ($widthMatches[1] == $heightMatches[1]) { + if (!empty($widthMatches) + && !empty($heightMatches) + && $widthMatches[1] == $heightMatches[1]) { $dimention = $widthMatches[1]; $widthFloat = (float) $width; $heightFloat = (float) $height; @@ -1287,7 +1289,7 @@ class TemplateProcessor * @param int $count * @param string $xmlBlock * - * @return string + * @return array */ protected function indexClonedVariables($count, $xmlBlock) { @@ -1339,7 +1341,7 @@ class TemplateProcessor * @param string $block New block content * @param string $blockType XML tag type of block * - * @return \PhpOffice\PhpWord\TemplateProcessor Fluent interface + * @return TemplateProcessor Fluent interface */ public function replaceXmlBlock($macro, $block, $blockType = 'w:p') { diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/HTML.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/HTML.php index 64789059..41229b45 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/HTML.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/HTML.php @@ -1,4 +1,5 @@ parts as $partName) { $partClass = 'PhpOffice\\PhpWord\\Writer\\HTML\\Part\\' . $partName; if (class_exists($partClass)) { - /** @var \PhpOffice\PhpWord\Writer\HTML\Part\AbstractPart $part Type hint */ + /** @var HTML\Part\AbstractPart $part Type hint */ $part = new $partClass(); $part->setParentWriter($this); $this->writerParts[strtolower($partName)] = $part; diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/HTML/Element/AbstractElement.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/HTML/Element/AbstractElement.php index 7c7bde31..0e6a112e 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/HTML/Element/AbstractElement.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/HTML/Element/AbstractElement.php @@ -1,4 +1,5 @@ namespace, $elementClass); if (class_exists($writerClass)) { - /** @var \PhpOffice\PhpWord\Writer\HTML\Element\AbstractElement $writer Type hint */ + /** @var AbstractElement $writer Type hint */ $writer = new $writerClass($this->parentWriter, $element, $withoutP); $content .= $writer->write(); } diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/HTML/Element/Endnote.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/HTML/Element/Endnote.php index 1c35e8fa..7e7f31d4 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/HTML/Element/Endnote.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/HTML/Element/Endnote.php @@ -1,4 +1,5 @@ ' . PHP_EOL; - + $defaultFontColor = Settings::getDefaultFontColor(); // Default styles $astarray = [ 'font-family' => $this->getFontFamily(Settings::getDefaultFontName(), $this->getParentWriter()->getDefaultGenericFont()), 'font-size' => Settings::getDefaultFontSize() . 'pt', + 'color' => "#{$defaultFontColor}", ]; // Mpdf sometimes needs separate tag for body; doesn't harm others. $bodyarray = $astarray; diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/HTML/Style/AbstractStyle.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/HTML/Style/AbstractStyle.php index a6507867..4672347b 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/HTML/Style/AbstractStyle.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/HTML/Style/AbstractStyle.php @@ -1,4 +1,5 @@ getVAlign(); + } foreach (['Top', 'Left', 'Bottom', 'Right'] as $direction) { $method = 'getBorder' . $direction . 'Style'; diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/AbstractElement.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/AbstractElement.php index 5cd396aa..97d1875c 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/AbstractElement.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/AbstractElement.php @@ -1,4 +1,5 @@ startElement('text:s'); + $xmlWriter->writeAttributeIf($num > 1, 'text:c', "$num"); + $xmlWriter->endElement(); + $text = preg_replace('/^ +/', '', $text); + } + preg_match_all('/([\\s\\S]*?)(\\t| +| ?$)/', $text, $matches, PREG_SET_ORDER); + foreach ($matches as $match) { + $this->writeText($match[1]); + if ($match[2] === '') { + break; + } elseif ($match[2] === "\t") { + $xmlWriter->writeElement('text:tab'); + } elseif ($match[2] === ' ') { + $xmlWriter->writeElement('text:s'); + + break; + } else { + $num = strlen($match[2]); + $xmlWriter->startElement('text:s'); + $xmlWriter->writeAttributeIf($num > 1, 'text:c', "$num"); + $xmlWriter->endElement(); + } + } + } } diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/Container.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/Container.php index 6e6b88ea..6b0fee8c 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/Container.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/Container.php @@ -1,4 +1,5 @@ + */ + protected $containerWithoutP = ['TextRun', 'Footnote', 'Endnote']; } diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/Field.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/Field.php index 46f62b0f..6b548078 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/Field.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/Field.php @@ -1,4 +1,5 @@ getXmlWriter(); $element = $this->getElement(); - if (!$element instanceof \PhpOffice\PhpWord\Element\Image) { + if (!$element instanceof ElementImage) { return; } @@ -43,11 +44,16 @@ class Image extends AbstractElement $width = Converter::pixelToCm($style->getWidth()); $height = Converter::pixelToCm($style->getHeight()); - $xmlWriter->startElement('text:p'); - $xmlWriter->writeAttribute('text:style-name', 'IM' . $mediaIndex); + $xmlWriter = $this->getXmlWriter(); + + if (!$this->withoutP) { + $xmlWriter->startElement('text:p'); + $xmlWriter->writeAttribute('text:style-name', 'IM' . $mediaIndex); + } $xmlWriter->startElement('draw:frame'); $xmlWriter->writeAttribute('draw:style-name', 'fr' . $mediaIndex); + $xmlWriter->writeAttributeIf($this->withoutP, 'draw:text-style-name', 'IM' . $mediaIndex); $xmlWriter->writeAttribute('draw:name', $element->getElementId()); $xmlWriter->writeAttribute('text:anchor-type', 'as-char'); $xmlWriter->writeAttribute('svg:width', $width . 'cm'); @@ -63,6 +69,8 @@ class Image extends AbstractElement $xmlWriter->endElement(); // draw:frame - $xmlWriter->endElement(); // text:p + if (!$this->withoutP) { + $xmlWriter->endElement(); // text:p + } } } diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/Link.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/Link.php index 0375b11b..9ef35692 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/Link.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/Link.php @@ -1,4 +1,5 @@ getXmlWriter(); $element = $this->getElement(); - if (!$element instanceof \PhpOffice\PhpWord\Element\Table) { + if (!$element instanceof TableElement) { return; } $rows = $element->getRows(); @@ -77,7 +78,7 @@ class Table extends AbstractElement private function writeRow(XMLWriter $xmlWriter, RowElement $row): void { $xmlWriter->startElement('table:table-row'); - /** @var \PhpOffice\PhpWord\Element\Row $row Type hint */ + /** @var RowElement $row Type hint */ foreach ($row->getCells() as $cell) { $xmlWriter->startElement('table:table-cell'); $xmlWriter->writeAttribute('office:value-type', 'string'); diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/TextRun.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/TextRun.php index 6d1e1a19..9d009c39 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/TextRun.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/TextRun.php @@ -1,4 +1,5 @@ imageParagraphStyles as $style) { - $styleWriter = new \PhpOffice\PhpWord\Writer\ODText\Style\Paragraph($xmlWriter, $style); + $styleWriter = new ParagraphStyleWriter($xmlWriter, $style); $styleWriter->write(); } } @@ -256,7 +257,7 @@ class Content extends AbstractPart * * Table style can be null or string of the style name * - * @param \PhpOffice\PhpWord\Element\AbstractContainer $container + * @param AbstractContainer $container * @param int $paragraphStyleCount * @param int $fontStyleCount * @@ -277,7 +278,7 @@ class Content extends AbstractPart $style = $element->getStyle(); $style->setStyleName('fr' . $element->getMediaIndex()); $this->autoStyles['Image'][] = $style; - $sty = new \PhpOffice\PhpWord\Style\Paragraph(); + $sty = new Paragraph(); $sty->setStyleName('IM' . $element->getMediaIndex()); $sty->setAuto(); $sty->setAlignment($style->getAlignment()); @@ -300,7 +301,7 @@ class Content extends AbstractPart /** * Get style of individual element. * - * @param \PhpOffice\PhpWord\Element\Text $element + * @param Text $element * @param int $paragraphStyleCount * @param int $fontStyleCount */ @@ -346,7 +347,7 @@ class Content extends AbstractPart /** * Get font style of individual field element. * - * @param \PhpOffice\PhpWord\Element\Field $element + * @param Field $element * @param int $fontStyleCount */ private function getElementStyleField($element, &$fontStyleCount): void @@ -371,7 +372,7 @@ class Content extends AbstractPart /** * Get style of individual element. * - * @param \PhpOffice\PhpWord\Element\TextRun $element + * @param TextRun $element * @param int $paragraphStyleCount */ private function getElementStyleTextRun($element, &$paragraphStyleCount): void diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Part/Meta.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Part/Meta.php index 8a35d02a..21410ba9 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Part/Meta.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Part/Meta.php @@ -1,4 +1,5 @@ setStyleName($style->getStyleName()); - $temp2 = new \PhpOffice\PhpWord\Writer\ODText\Style\Paragraph($xmlWriter, $temp1); + $temp2 = new Paragraph($xmlWriter, $temp1); $temp2->write(); } diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Style/Paragraph.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Style/Paragraph.php index 4459c76c..99963bd4 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Style/Paragraph.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Style/Paragraph.php @@ -1,4 +1,5 @@ getStyle(); - if (!$style instanceof \PhpOffice\PhpWord\Style\Paragraph) { + if (!$style instanceof Style\Paragraph) { return; } $xmlWriter = $this->getXmlWriter(); @@ -73,13 +74,13 @@ class Paragraph extends AbstractStyle } elseif (substr($styleName, 0, 2) === 'HD') { $styleAuto = true; $psm = 'Heading_' . substr($styleName, 2); - $stylep = \PhpOffice\PhpWord\Style::getStyle($psm); - if ($stylep instanceof \PhpOffice\PhpWord\Style\Font) { + $stylep = Style::getStyle($psm); + if ($stylep instanceof Style\Font) { if (method_exists($stylep, 'getParagraph')) { $stylep = $stylep->getParagraph(); } } - if ($stylep instanceof \PhpOffice\PhpWord\Style\Paragraph) { + if ($stylep instanceof Style\Paragraph) { if ($stylep->hasPageBreakBefore()) { $breakbefore = true; } diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Style/Section.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Style/Section.php index 0a250194..05152a39 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Style/Section.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Style/Section.php @@ -1,4 +1,5 @@ parts as $partName) { $partClass = static::class . '\\Part\\' . $partName; if (class_exists($partClass)) { - /** @var \PhpOffice\PhpWord\Writer\RTF\Part\AbstractPart $part Type hint */ + /** @var RTF\Part\AbstractPart $part Type hint */ $part = new $partClass(); $part->setParentWriter($this); $this->writerParts[strtolower($partName)] = $part; diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/RTF/Element/AbstractElement.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/RTF/Element/AbstractElement.php index 5c33868a..e007e6aa 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/RTF/Element/AbstractElement.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/RTF/Element/AbstractElement.php @@ -1,4 +1,5 @@ parentWriter; /** @var \PhpOffice\PhpWord\Element\Text $element Type hint */ @@ -188,7 +189,7 @@ abstract class AbstractElement return ''; } - /** @var \PhpOffice\PhpWord\Writer\RTF $parentWriter Type hint */ + /** @var WriterRTF $parentWriter Type hint */ $parentWriter = $this->parentWriter; // Create style writer and set color/name index diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/RTF/Element/Field.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/RTF/Element/Field.php index 34024f5e..c5e49c1d 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/RTF/Element/Field.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/RTF/Element/Field.php @@ -1,4 +1,5 @@ getHeaders() as $header) { $type = $header->getType(); - if ($evenOdd || $type !== FOOTER::EVEN) { + if ($evenOdd || $type !== Footer::EVEN) { $content .= '{\\header'; if ($type === Footer::FIRST) { $content .= 'f'; } elseif ($evenOdd) { - $content .= ($type === FOOTER::EVEN) ? 'l' : 'r'; + $content .= ($type === Footer::EVEN) ? 'l' : 'r'; } foreach ($header->getElements() as $element) { $cl = get_class($element); @@ -182,12 +183,12 @@ class Document extends AbstractPart } foreach ($section->getFooters() as $footer) { $type = $footer->getType(); - if ($evenOdd || $type !== FOOTER::EVEN) { + if ($evenOdd || $type !== Footer::EVEN) { $content .= '{\\footer'; if ($type === Footer::FIRST) { $content .= 'f'; } elseif ($evenOdd) { - $content .= ($type === FOOTER::EVEN) ? 'l' : 'r'; + $content .= ($type === Footer::EVEN) ? 'l' : 'r'; } foreach ($footer->getElements() as $element) { $cl = get_class($element); diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/RTF/Part/Header.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/RTF/Part/Header.php index 7f8cc84b..97644fe4 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/RTF/Part/Header.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/RTF/Part/Header.php @@ -1,4 +1,5 @@ parts) as $partName) { $partClass = static::class . '\\Part\\' . $partName; if (class_exists($partClass)) { - /** @var \PhpOffice\PhpWord\Writer\Word2007\Part\AbstractPart $part Type hint */ + /** @var Word2007\Part\AbstractPart $part Type hint */ $part = new $partClass(); $part->setParentWriter($this); $this->writerParts[strtolower($partName)] = $part; @@ -179,7 +178,7 @@ class Word2007 extends AbstractWriter implements WriterInterface $this->registerContentTypes($media); } - /** @var \PhpOffice\PhpWord\Writer\Word2007\Part\AbstractPart $writerPart Type hint */ + /** @var Word2007\Part\AbstractPart $writerPart Type hint */ $writerPart = $this->getWriterPart('relspart')->setMedia($media); $zip->addFromString("word/_rels/{$file}.xml.rels", $writerPart->write()); } @@ -206,7 +205,7 @@ class Word2007 extends AbstractWriter implements WriterInterface $this->contentTypes['override']["/word/$elmFile"] = $elmType; $this->relationships[] = ['target' => $elmFile, 'type' => $elmType, 'rID' => $rId]; - /** @var \PhpOffice\PhpWord\Writer\Word2007\Part\AbstractPart $writerPart Type hint */ + /** @var Word2007\Part\AbstractPart $writerPart Type hint */ $writerPart = $this->getWriterPart($elmType)->setElement($element); $zip->addFromString("word/$elmFile", $writerPart->write()); } @@ -236,7 +235,7 @@ class Word2007 extends AbstractWriter implements WriterInterface // Write relationships file, e.g. word/_rels/footnotes.xml if (!empty($media)) { - /** @var \PhpOffice\PhpWord\Writer\Word2007\Part\AbstractPart $writerPart Type hint */ + /** @var Word2007\Part\AbstractPart $writerPart Type hint */ $writerPart = $this->getWriterPart('relspart')->setMedia($media); $zip->addFromString("word/_rels/{$partName}.xml.rels", $writerPart->write()); } diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Element/Bookmark.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Element/Bookmark.php index 1e618af9..ba61ad69 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Element/Bookmark.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Element/Bookmark.php @@ -1,4 +1,5 @@ endElement(); // w:r if ($element->getText() != null) { - if ($element->getText() instanceof \PhpOffice\PhpWord\Element\TextRun) { + if ($element->getText() instanceof TextRun) { $containerWriter = new Container($xmlWriter, $element->getText(), true); $containerWriter->write(); } @@ -262,7 +263,7 @@ class Field extends Text $xmlWriter->endElement(); // w:r if ($element->getText() != null) { - if ($element->getText() instanceof \PhpOffice\PhpWord\Element\TextRun) { + if ($element->getText() instanceof TextRun) { $containerWriter = new Container($xmlWriter, $element->getText(), true); $containerWriter->write(); diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Element/Formula.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Element/Formula.php index 6abb74b7..95567062 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Element/Formula.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Element/Formula.php @@ -1,4 +1,5 @@ getBgColor()) { $xmlWriter->writeAttribute('fillcolor', $style->getBgColor()); + } else { + $xmlWriter->writeAttribute('filled', 'f'); + } + + if (!$style->getBorderColor()) { + $xmlWriter->writeAttribute('stroked', 'f'); + $xmlWriter->writeAttribute('strokecolor', 'white'); } $styleWriter->write(); diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Element/TextBreak.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Element/TextBreak.php index bcae3b2e..4c2ecde7 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Element/TextBreak.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Element/TextBreak.php @@ -1,4 +1,5 @@ endElement(); // w:tcW } + $paddingTop = $style->getPaddingTop(); + $paddingLeft = $style->getPaddingLeft(); + $paddingBottom = $style->getPaddingBottom(); + $paddingRight = $style->getPaddingRight(); + + if ($paddingTop !== null || $paddingLeft !== null || $paddingBottom !== null || $paddingRight !== null) { + $xmlWriter->startElement('w:tcMar'); + if ($paddingTop !== null) { + $xmlWriter->startElement('w:top'); + $xmlWriter->writeAttribute('w:w', $paddingTop); + $xmlWriter->writeAttribute('w:type', \PhpOffice\PhpWord\SimpleType\TblWidth::TWIP); + $xmlWriter->endElement(); // w:top + } + if ($paddingLeft !== null) { + $xmlWriter->startElement('w:start'); + $xmlWriter->writeAttribute('w:w', $paddingLeft); + $xmlWriter->writeAttribute('w:type', \PhpOffice\PhpWord\SimpleType\TblWidth::TWIP); + $xmlWriter->endElement(); // w:start + } + if ($paddingBottom !== null) { + $xmlWriter->startElement('w:bottom'); + $xmlWriter->writeAttribute('w:w', $paddingBottom); + $xmlWriter->writeAttribute('w:type', \PhpOffice\PhpWord\SimpleType\TblWidth::TWIP); + $xmlWriter->endElement(); // w:bottom + } + if ($paddingRight !== null) { + $xmlWriter->startElement('w:end'); + $xmlWriter->writeAttribute('w:w', $paddingRight); + $xmlWriter->writeAttribute('w:type', \PhpOffice\PhpWord\SimpleType\TblWidth::TWIP); + $xmlWriter->endElement(); // w:end + } + $xmlWriter->endElement(); // w:tcMar + } + // Text direction $textDir = $style->getTextDirection(); $xmlWriter->writeElementIf(null !== $textDir, 'w:textDirection', 'w:val', $textDir); diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Style/Extrusion.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Style/Extrusion.php index f6ad6221..8bb92187 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Style/Extrusion.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Style/Extrusion.php @@ -1,4 +1,5 @@ getFirstLine(); $xmlWriter->writeAttributeIf(null !== $firstLine, 'w:firstLine', $this->convertTwip($firstLine)); + $firstLineChars = $style->getFirstLineChars(); + $xmlWriter->writeAttributeIf(0 !== $firstLineChars, 'w:firstLineChars', $this->convertTwip($firstLineChars)); + $hanging = $style->getHanging(); $xmlWriter->writeAttributeIf(null !== $hanging, 'w:hanging', $this->convertTwip($hanging)); diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Style/Line.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Style/Line.php index 90107f8e..2603545f 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Style/Line.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Style/Line.php @@ -1,4 +1,5 @@ =8.0.0" + "php": ">=5.3.0" }, "autoload": { "psr-4": { - "Psr\\Log\\": "src" + "Psr\\Log\\": "Psr/Log/" } }, "extra": { "branch-alias": { - "dev-master": "3.x-dev" + "dev-master": "1.1.x-dev" } } } diff --git a/vendor/topthink/think-image/src/image/Exception.php b/vendor/topthink/think-image/src/image/Exception.php index 2ebafd8c..c35c71b9 100644 --- a/vendor/topthink/think-image/src/image/Exception.php +++ b/vendor/topthink/think-image/src/image/Exception.php @@ -11,8 +11,7 @@ namespace think\image; - class Exception extends \RuntimeException { -} \ No newline at end of file +} diff --git a/vendor/topthink/think-image/src/image/gif/Decoder.php b/vendor/topthink/think-image/src/image/gif/Decoder.php index a5630926..95fa2b84 100644 --- a/vendor/topthink/think-image/src/image/gif/Decoder.php +++ b/vendor/topthink/think-image/src/image/gif/Decoder.php @@ -11,7 +11,6 @@ namespace think\image\gif; - class Decoder { public $GIF_buffer = []; @@ -204,4 +203,4 @@ class Decoder { return ($this->GIF_delays); } -} \ No newline at end of file +} diff --git a/vendor/topthink/think-image/tests/CropTest.php b/vendor/topthink/think-image/tests/CropTest.php index 26a05313..e2676307 100644 --- a/vendor/topthink/think-image/tests/CropTest.php +++ b/vendor/topthink/think-image/tests/CropTest.php @@ -64,4 +64,4 @@ class CropTest extends TestCase @unlink($pathname); } -} \ No newline at end of file +} diff --git a/vendor/topthink/think-image/tests/FlipTest.php b/vendor/topthink/think-image/tests/FlipTest.php index bf7a5e26..d91d3af9 100644 --- a/vendor/topthink/think-image/tests/FlipTest.php +++ b/vendor/topthink/think-image/tests/FlipTest.php @@ -27,7 +27,6 @@ class FlipTest extends TestCase @unlink($pathname); } - public function testGif() { $pathname = TEST_PATH . 'tmp/flip.gif'; @@ -40,4 +39,4 @@ class FlipTest extends TestCase @unlink($pathname); } -} \ No newline at end of file +} diff --git a/vendor/topthink/think-image/tests/RotateTest.php b/vendor/topthink/think-image/tests/RotateTest.php index 56572d27..202062d3 100644 --- a/vendor/topthink/think-image/tests/RotateTest.php +++ b/vendor/topthink/think-image/tests/RotateTest.php @@ -39,4 +39,4 @@ class RotateTest extends TestCase @unlink($pathname); } -} \ No newline at end of file +} diff --git a/vendor/topthink/think-image/tests/TestCase.php b/vendor/topthink/think-image/tests/TestCase.php index 89ba1b45..3fd401ce 100644 --- a/vendor/topthink/think-image/tests/TestCase.php +++ b/vendor/topthink/think-image/tests/TestCase.php @@ -30,4 +30,4 @@ abstract class TestCase extends \PHPUnit_Framework_TestCase { return new File(TEST_PATH . 'images/test.gif'); } -} \ No newline at end of file +} diff --git a/vendor/topthink/think-image/tests/WaterTest.php b/vendor/topthink/think-image/tests/WaterTest.php index b6a2bcce..7c2c1dac 100644 --- a/vendor/topthink/think-image/tests/WaterTest.php +++ b/vendor/topthink/think-image/tests/WaterTest.php @@ -55,4 +55,4 @@ class WaterTest extends TestCase @unlink($pathname); } -} \ No newline at end of file +} diff --git a/vendor/topthink/think-image/tests/autoload.php b/vendor/topthink/think-image/tests/autoload.php index f2e8aaef..d21954b2 100644 --- a/vendor/topthink/think-image/tests/autoload.php +++ b/vendor/topthink/think-image/tests/autoload.php @@ -12,4 +12,4 @@ define('TEST_PATH', __DIR__ . '/'); // 加载框架基础文件 require __DIR__ . '/../thinkphp/base.php'; \think\Loader::addNamespace('tests', TEST_PATH); -\think\Loader::addNamespace('think', __DIR__ . '/../src/'); \ No newline at end of file +\think\Loader::addNamespace('think', __DIR__ . '/../src/'); From da71dfc04e6979f305c5441b7dd42c14cc68b262 Mon Sep 17 00:00:00 2001 From: wyn <1074145239@qq.com> Date: Tue, 30 Jun 2026 09:30:33 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E6=96=B0=E9=80=BB=E8=BE=91=E5=8F=82?= =?UTF-8?q?=E8=80=83=E6=96=87=E7=8C=AE=E7=9B=B8=E5=85=B3=E6=80=A7=E6=95=B4?= =?UTF-8?q?=E5=90=88=E4=B9=8B=E5=89=8D=E7=9A=84=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application/api/controller/References.php | 141 ++++++------------ application/command.php | 1 - .../command/ReferenceCheckMqConsume.php | 2 +- application/common/ReferenceCheckService.php | 37 +---- application/common/mq/RabbitMqConfig.php | 6 - .../common/mq/ReferenceCheckArticleWorker.php | 85 ++++++----- .../service/ReferenceRelevanceLlmService.php | 73 +++++++-- application/extra/rabbitmq.php | 7 - 8 files changed, 162 insertions(+), 190 deletions(-) diff --git a/application/api/controller/References.php b/application/api/controller/References.php index ea539ed6..3408dc06 100644 --- a/application/api/controller/References.php +++ b/application/api/controller/References.php @@ -1312,9 +1312,9 @@ class References extends Base return json_encode(['status' => 8,'msg' => 'fail']); } // ============================================================ - // 参考文献「主题相关性」校对(独立模块,RabbitMQ 链式消费) + // 参考文献「主题相关性」校对(RabbitMQ 链式消费,复用 reference_check 队列) // 表:t_article_reference_relevance_check_result / t_article_reference_relevance_check_batch - // 消费:php think reference_relevance:mq-consume + // 消费:php think reference_check:mq-consume // ============================================================ /** @@ -1347,7 +1347,7 @@ class References extends Base ->where('p_article_id', $iPArticleId) ->count(); if (intval($existing) > 0) { - return jsonError('This article already has relevance check records. Use referenceRelevanceCheckResetAI to rerun.'); + return jsonError('This article already has relevance check records.'); } try { @@ -1363,16 +1363,21 @@ class References extends Base } /** - * 相关性校对进度 - * POST: p_article_id + * 相关性校对进度(同 referenceCheckProgressAI) + * POST/GET: p_article_id(必填) */ public function referenceRelevanceCheckProgressAI() { $aParam = $this->request->post(); + if (empty($aParam)) { + $aParam = $this->request->param(); + } + $iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']); if ($iPArticleId <= 0) { - return jsonError('p_article_id is required'); + return json_encode(array('status' => 2, 'msg' => 'Please select an article')); } + try { $result = (new ReferenceRelevanceCheckService())->getProgressByPArticleId($iPArticleId); return jsonSuccess($result); @@ -1382,12 +1387,8 @@ class References extends Base } /** - * 按 p_article_id 查整篇文章相关性校对总状态(用于前端按钮分流) - * + * 按 p_article_id 查整篇文章相关性校对总状态(同 referenceCheckArticleStatusAI) * POST/GET: p_article_id(必填) - * - * 返回 status:0=未校对 1=校对中 2=校对完成 - * 计数维度为参考文献(按 reference_no 分组),与 referenceRelevanceCheckProgressAI 一致。 */ public function referenceRelevanceCheckArticleStatusAI() { @@ -1398,7 +1399,7 @@ class References extends Base $iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']); if ($iPArticleId <= 0) { - return jsonError('p_article_id is required'); + return json_encode(array('status' => 2, 'msg' => 'Please select an article')); } try { @@ -1410,16 +1411,21 @@ class References extends Base } /** - * 按 p_refer_id 查相关性校对明细 - * POST: p_refer_id + * 按 p_refer_id 查单条参考文献的相关性校对明细与进度(同 referenceCheckDetailsAI) + * POST/GET: p_refer_id(必填) */ public function referenceRelevanceCheckDetailsAI() { $aParam = $this->request->post(); + if (empty($aParam)) { + $aParam = $this->request->param(); + } + $iPReferId = empty($aParam['p_refer_id']) ? 0 : intval($aParam['p_refer_id']); if ($iPReferId <= 0) { - return jsonError('p_refer_id is required'); + return json_encode(array('status' => 2, 'msg' => 'Please select a reference')); } + try { $result = (new ReferenceRelevanceCheckService())->getDetailsByPReferId($iPReferId); return jsonSuccess($result); @@ -1496,47 +1502,15 @@ class References extends Base } /** - * 参考文献第一次校对(支撑力度) - * @return \think\response\Json + * @deprecated 支撑力度校对已下线,请使用 allReferenceCheckAI(主题相关性校对) */ - public function allReferenceCheckAI2(){ - //获取参数 - $aParam = empty($aParam) ? $this->request->post() : $aParam; - - //必填值验证 - $iPArticleId = empty($aParam['p_article_id']) ? '' : $aParam['p_article_id']; - if(empty($iPArticleId)){ - return json_encode(array('status' => 2,'msg' => 'Please select an article' )); - } - //查询文章(p_article_id 与 article_id 都要带,下游服务方法两者都用) - $aWhere = ['p_article_id' => $iPArticleId,'state' => ['in',[0,2]]]; - $aProductionArticle = Db::name('production_article')->field('p_article_id,article_id')->where($aWhere)->find(); - if(empty($aProductionArticle)){ - return json_encode(array('status' => 3,'msg' => 'No articles found' )); - } - if($this->checkReferStatus($iPArticleId)==0){ - return jsonError('Please correct the reference content before running the check.'); - } - //已存在校对记录则禁止重复执行第一次校对,提示走重置接口 - $iExisting = Db::name('article_reference_check_result') - ->where('p_article_id', $iPArticleId) - ->count(); - if(intval($iExisting) > 0){ - return jsonError('This article already has a reference check record. Please use the "Reset Check" endpoint to run the check again.'); - } - try { - $svc = new ReferenceCheckService(); - $result = $svc->enqueueByPArticle($aProductionArticle); - if (empty($result['check_ids'])) { - return jsonError('No reference citations were found in the article.'); - } - return jsonSuccess($result); - } catch (\Exception $e) { - return jsonError($e->getMessage()); - } + public function allReferenceCheckAI2() + { + return jsonError('Support strength check is deprecated. Please use allReferenceCheckAI.'); } + /** - * 文献校对重置:删除该文章已有的全部校对明细,并重新入队整篇校对 + * 文献校对重置:删除该文章已有的全部相关性校对明细,并重新入队整篇校对 * POST/GET: article_id(必填) * @url /api/Article/referenceCheckReset */ @@ -1564,7 +1538,7 @@ class References extends Base return json_encode(array('status' => 4,'msg' => 'Unbound article' )); } try { - $result = (new ReferenceCheckService())->resetAndRecheckByArticle($aProductionArticle); + $result = (new ReferenceRelevanceCheckService())->resetAndRecheckByArticle($aProductionArticle); return jsonSuccess($result); } catch (\Exception $e) { return jsonError($e->getMessage()); @@ -1601,7 +1575,7 @@ class References extends Base } try { - $deleted = (new ReferenceCheckService())->clearArticleChecksByPArticleId($iPArticleId); + $deleted = (new ReferenceRelevanceCheckService())->clearByPArticleId($iPArticleId); return jsonSuccess([ 'p_article_id' => $iPArticleId, 'deleted' => intval($deleted), @@ -1612,7 +1586,7 @@ class References extends Base } /** - * 按 p_article_id 查整篇引用校对进度(按 reference_no 分组聚合) + * 按 p_article_id 查整篇相关性校对进度(按 reference_no 分组聚合) * * POST/GET: p_article_id(必填) * @@ -1625,6 +1599,8 @@ class References extends Base * records[i].status 与分组同一套数值含义(但 record 不会出现 1=校对中): * 0 = 待校验 2 = 校对完成 3 = 校对失败 * + * records[i] 含 reason(中英双语)、reason_en、combined_reason(中英双语)、combined_reason_en + * * summary 用字符串键:pending / checking / completed / failed */ public function referenceCheckProgressAI() @@ -1639,7 +1615,7 @@ class References extends Base return json_encode(array('status' => 2, 'msg' => 'Please select an article')); } try { - $result = (new ReferenceCheckService())->getProgressByPArticleId($iPArticleId); + $result = (new ReferenceRelevanceCheckService())->getProgressByPArticleId($iPArticleId); return jsonSuccess($result); } catch (\Exception $e) { return jsonError($e->getMessage()); @@ -1647,7 +1623,7 @@ class References extends Base } /** - * 按 p_article_id 查整篇文章引用校对总状态(用于前端按钮分流) + * 按 p_article_id 查整篇文章相关性校对总状态(用于前端按钮分流) * * POST/GET: p_article_id(必填) * @@ -1681,7 +1657,7 @@ class References extends Base } try { - $result = (new ReferenceCheckService())->getArticleProgressStatusByPArticleId($iPArticleId); + $result = (new ReferenceRelevanceCheckService())->getArticleProgressStatusByPArticleId($iPArticleId); return jsonSuccess($result); } catch (\Exception $e) { return jsonError($e->getMessage()); @@ -1709,7 +1685,7 @@ class References extends Base } try { - $result = (new ReferenceCheckService())->getArticleCheckQueuePositionByPArticleId($iPArticleId); + $result = (new ReferenceRelevanceCheckService())->getArticleCheckQueuePositionByPArticleId($iPArticleId); return jsonSuccess($result); } catch (\Exception $e) { return jsonError($e->getMessage()); @@ -1717,7 +1693,7 @@ class References extends Base } /** - * 某条参考文献下「校对失败」的明细重新校对(异步) + * 某条参考文献下「相关性校对失败」的明细重新校对(异步) * * POST/GET: p_refer_id(必填) * p_article_id(可选) @@ -1739,8 +1715,8 @@ class References extends Base $iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']); try { - $result = (new ReferenceCheckService())->enqueueRecheckFailedByPReferId($iPReferId, $iPArticleId); - return jsonSuccess([]); + $result = (new ReferenceRelevanceCheckService())->enqueueRecheckFailedByPReferId($iPReferId, $iPArticleId); + return jsonSuccess($result); } catch (\Exception $e) { return jsonError($e->getMessage()); } @@ -1769,7 +1745,7 @@ class References extends Base $iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']); try { - $result = (new ReferenceCheckService())->enqueueRecheckFailedByPReferIdWithGroup($iPReferId, $iPArticleId); + $result = (new ReferenceRelevanceCheckService())->enqueueRecheckFailedByPReferIdWithGroup($iPReferId, $iPArticleId); return jsonSuccess($result); } catch (\Exception $e) { return jsonError($e->getMessage()); @@ -1777,13 +1753,15 @@ class References extends Base } /** - * 按 p_refer_id 查单条参考文献的校对明细与进度 + * 按 p_refer_id 查单条参考文献的相关性校对明细与进度 * * POST/GET: p_refer_id(必填) * * 分组进度:progress_status(0待/1中/2完成/3失败)、pending、done、failed、pass、 * is_pass、progress_percent、last_updated_at - * list 每项:check_id、am_id、status、confidence、reason、is_match、is_pass + * list 每项:check_id、am_id、status、is_relevant、relevance_level、relevance_role、 + * relevance_score、reason(中英双语【中文】/【English】)、reason_en、 + * combined_*、combined_reason_en、cite_group_refs、cite_check_mode、is_pass */ public function referenceCheckDetailsAI() { @@ -1798,7 +1776,7 @@ class References extends Base } try { - $result = (new ReferenceCheckService())->getCheckDetailsByPReferId($iPReferId); + $result = (new ReferenceRelevanceCheckService())->getDetailsByPReferId($iPReferId); return jsonSuccess($result); } catch (\Exception $e) { return jsonError($e->getMessage()); @@ -1817,33 +1795,12 @@ class References extends Base * 返回:missing_p_refer_ids、matched_p_refer_ids、still_unmatched_p_refer_ids、 * queued、new_reference_nos、check_ids、queue */ + /** + * @deprecated 支撑力度漏匹配补扫已下线,请清空后使用 allReferenceCheckAI 重新校对 + */ public function referenceCheckRematchNewAI() { - $aParam = $this->request->post(); - if (empty($aParam)) { - $aParam = $this->request->param(); - } - - $iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']); - if ($iPArticleId <= 0) { - return json_encode(array('status' => 2, 'msg' => 'Please select an article')); - } - - $aWhere = ['p_article_id' => $iPArticleId, 'state' => ['in', [0, 2]]]; - $aProductionArticle = Db::name('production_article')->field('p_article_id,article_id')->where($aWhere)->find(); - if (empty($aProductionArticle)) { - return json_encode(array('status' => 3, 'msg' => 'No articles found')); - } - if ($this->checkReferStatus($iPArticleId) == 0) { - return jsonError('Please correct the reference content before running the check.'); - } - - try { - $result = (new ReferenceCheckService())->enqueueNewlyMatchedByPArticle($aProductionArticle); - return jsonSuccess($result); - } catch (\Exception $e) { - return jsonError($e->getMessage()); - } + return jsonError('Support strength rematch is deprecated. Please use referenceCheckResetAI or allReferenceCheckAI.'); } public function checkReferStatus($p_article_id){ diff --git a/application/command.php b/application/command.php index cbea1b3b..43892e98 100644 --- a/application/command.php +++ b/application/command.php @@ -11,5 +11,4 @@ return [ 'app\\command\\ReferenceCheckMqConsume', - 'app\\command\\ReferenceRelevanceMqConsume', ]; diff --git a/application/command/ReferenceCheckMqConsume.php b/application/command/ReferenceCheckMqConsume.php index 4f2f22f7..6d0103e5 100644 --- a/application/command/ReferenceCheckMqConsume.php +++ b/application/command/ReferenceCheckMqConsume.php @@ -13,7 +13,7 @@ class ReferenceCheckMqConsume extends Command protected function configure() { $this->setName('reference_check:mq-consume') - ->setDescription('Consume RabbitMQ reference check article queue'); + ->setDescription('Consume RabbitMQ reference relevance check article queue (ref_check.article)'); } protected function execute(Input $input, Output $output) diff --git a/application/common/ReferenceCheckService.php b/application/common/ReferenceCheckService.php index 0cf20986..b9ea2586 100644 --- a/application/common/ReferenceCheckService.php +++ b/application/common/ReferenceCheckService.php @@ -5,7 +5,6 @@ namespace app\common; use think\Db; use think\Env; use app\common\service\LLMService; -use app\common\mq\ReferenceCheckMqPublisher; /** * 正文 <blue>[n]</blue> 引用与 t_production_article_refer(index+1=n)相关性校对。 @@ -4350,41 +4349,7 @@ class ReferenceCheckService */ public function startArticleCheckQueue(array $checkIds, $pArticleId = 0, $trigger = 'enqueue') { - $checkIds = array_values(array_filter(array_map('intval', $checkIds))); - if (empty($checkIds)) { - return []; - } - - $pArticleId = intval($pArticleId); - if ($pArticleId <= 0) { - $firstRow = Db::name('article_reference_check_result')->where('id', $checkIds[0])->find(); - $pArticleId = empty($firstRow) ? 0 : intval($this->arrGet($firstRow, 'p_article_id', 0)); - } - if ($pArticleId <= 0) { - throw new \RuntimeException('p_article_id is required for reference check queue'); - } - - $now = date('Y-m-d H:i:s'); - $batchId = Db::name('article_reference_check_batch')->insertGetId([ - 'p_article_id' => $pArticleId, - 'batch_status' => 0, - 'total_count' => count($checkIds), - 'done_count' => 0, - 'failed_count' => 0, - 'trigger' => (string)$trigger, - 'created_at' => $now, - 'updated_at' => $now, - ]); - - $shouldPublish = !$this->hasEarlierWaitingBatch($batchId) && !$this->hasRunningReferenceCheckBatch(); - if ($shouldPublish) { - (new ReferenceCheckMqPublisher())->publishArticleStart($pArticleId, intval($batchId), $trigger); - $this->log('startArticleCheckQueue publish p_article_id=' . $pArticleId . ' batch_id=' . $batchId); - } else { - $this->log('startArticleCheckQueue queued batch_id=' . $batchId . ' p_article_id=' . $pArticleId); - } - - return $checkIds; + throw new \RuntimeException('Support strength check queue is deprecated. Use ReferenceRelevanceCheckService.'); } private function hasRunningReferenceCheckBatch() diff --git a/application/common/mq/RabbitMqConfig.php b/application/common/mq/RabbitMqConfig.php index 84e19c17..df30aa5e 100644 --- a/application/common/mq/RabbitMqConfig.php +++ b/application/common/mq/RabbitMqConfig.php @@ -21,10 +21,4 @@ class RabbitMqConfig $rc = self::get('reference_check', []); return is_array($rc) ? $rc : []; } - - public static function referenceRelevance() - { - $rc = self::get('reference_relevance', []); - return is_array($rc) ? $rc : []; - } } diff --git a/application/common/mq/ReferenceCheckArticleWorker.php b/application/common/mq/ReferenceCheckArticleWorker.php index 46f61026..5c2701db 100644 --- a/application/common/mq/ReferenceCheckArticleWorker.php +++ b/application/common/mq/ReferenceCheckArticleWorker.php @@ -4,10 +4,11 @@ namespace app\common\mq; use think\Db; use app\common\DbReconnectHelper; -use app\common\ReferenceCheckService; +use app\common\ReferenceRelevanceCheckService; /** - * RabbitMQ 消费:按文章串行,文章内 reference_no 升序逐条校对(含低分同步二轮) + * RabbitMQ 消费(队列 reference_check / ref_check.article): + * 全局文章串行,文章内 reference_no 升序链式逐条「主题相关性」校对。 */ class ReferenceCheckArticleWorker { @@ -16,12 +17,12 @@ class ReferenceCheckArticleWorker const BATCH_DONE = 2; const BATCH_PARTIAL_FAILED = 3; - /** @var ReferenceCheckService */ + /** @var ReferenceRelevanceCheckService */ private $svc; public function __construct() { - $this->svc = new ReferenceCheckService(); + $this->svc = new ReferenceRelevanceCheckService(); } public function handleMessage(array $payload) @@ -29,6 +30,7 @@ class ReferenceCheckArticleWorker DbReconnectHelper::ensure(); $pArticleId = intval(isset($payload['p_article_id']) ? $payload['p_article_id'] : 0); $batchId = intval(isset($payload['batch_id']) ? $payload['batch_id'] : 0); + $trigger = isset($payload['trigger']) ? (string)$payload['trigger'] : 'enqueue'; if ($pArticleId <= 0 || $batchId <= 0) { $this->svc->log('ReferenceCheckArticleWorker invalid payload'); return; @@ -36,7 +38,11 @@ class ReferenceCheckArticleWorker if (!$this->canStartArticleWork($batchId)) { $this->svc->log('ReferenceCheckArticleWorker defer batch_id=' . $batchId . ' other article running'); - (new ReferenceCheckMqPublisher())->publishArticleStart($pArticleId, $batchId, isset($payload['trigger']) ? $payload['trigger'] : 'enqueue'); + (new ReferenceCheckMqPublisher())->publishArticleStart( + $pArticleId, + $batchId, + isset($payload['trigger']) ? $payload['trigger'] : 'enqueue' + ); sleep(3); return; } @@ -48,6 +54,11 @@ class ReferenceCheckArticleWorker } } + $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); $done = 0; @@ -61,7 +72,7 @@ class ReferenceCheckArticleWorker if ($checkId <= 0) { continue; } - $result = $this->processOneRow($checkId, $row); + $result = $this->processOneRow($checkId, $row, $trigger === 'recheck_pending_only'); if ($result === 'ok') { $done++; } elseif ($result === 'failed') { @@ -77,7 +88,7 @@ class ReferenceCheckArticleWorker private function canStartArticleWork($batchId) { - $running = Db::name('article_reference_check_batch') + $running = Db::name('article_reference_relevance_check_batch') ->where('batch_status', self::BATCH_RUNNING) ->where('id', '<>', intval($batchId)) ->count(); @@ -87,7 +98,7 @@ class ReferenceCheckArticleWorker private function claimBatch($batchId) { $now = date('Y-m-d H:i:s'); - $affected = Db::name('article_reference_check_batch') + $affected = Db::name('article_reference_relevance_check_batch') ->where('id', intval($batchId)) ->whereIn('batch_status', [self::BATCH_WAITING, self::BATCH_RUNNING]) ->update([ @@ -99,15 +110,15 @@ class ReferenceCheckArticleWorker private function getBatch($batchId) { - return Db::name('article_reference_check_batch')->where('id', intval($batchId))->find(); + return Db::name('article_reference_relevance_check_batch')->where('id', intval($batchId))->find(); } private function fetchNextPendingRow($pArticleId) { - return Db::name('article_reference_check_result') + return Db::name('article_reference_relevance_check_result') ->where('p_article_id', intval($pArticleId)) - ->where('queue_status', ReferenceCheckService::QUEUE_PENDING) - ->where('status', ReferenceCheckService::RECORD_PENDING) + ->where('queue_status', ReferenceRelevanceCheckService::QUEUE_PENDING) + ->where('status', ReferenceRelevanceCheckService::RECORD_PENDING) ->order('reference_no asc,am_id asc,text_start asc,id asc') ->find(); } @@ -115,46 +126,44 @@ class ReferenceCheckArticleWorker /** * @return string ok|failed|skip */ - private function processOneRow($checkId, array $row) + private function processOneRow($checkId, array $row, $skipLiteratureFetch = false) { DbReconnectHelper::ensure(); - $claimed = Db::name('article_reference_check_result') + $claimed = Db::name('article_reference_relevance_check_result') ->where('id', intval($checkId)) - ->where('queue_status', ReferenceCheckService::QUEUE_PENDING) - ->update(['queue_status' => ReferenceCheckService::QUEUE_RUNNING]); + ->where('queue_status', ReferenceRelevanceCheckService::QUEUE_PENDING) + ->update(['queue_status' => ReferenceRelevanceCheckService::QUEUE_RUNNING]); if (intval($claimed) <= 0) { return 'skip'; } $retryCount = intval(isset($row['retry_count']) ? $row['retry_count'] : 0); try { - $this->svc->runReferenceCheckOnce($checkId); - $amId = intval(isset($row['am_id']) ? $row['am_id'] : 0); - if ($amId > 0) { - $this->svc->syncAmRefCheckStatus($amId); - } - $this->svc->markQueueRuntime($checkId, ReferenceCheckService::QUEUE_COMPLETED, $retryCount); + $this->svc->runCheckOnce($checkId, $skipLiteratureFetch); + $this->svc->markQueueRuntime($checkId, ReferenceRelevanceCheckService::QUEUE_COMPLETED, $retryCount); return 'ok'; } catch (\Exception $e) { $this->svc->log('ReferenceCheckArticleWorker check_id=' . $checkId . ' err=' . $e->getMessage()); DbReconnectHelper::ensure(); - if ($retryCount < ReferenceCheckService::QUEUE_MAX_RETRY) { - $this->svc->markQueueRuntime($checkId, ReferenceCheckService::QUEUE_PENDING, $retryCount + 1); - return $this->processOneRow($checkId, array_merge($row, ['retry_count' => $retryCount + 1])); + if ($retryCount < ReferenceRelevanceCheckService::QUEUE_MAX_RETRY) { + $this->svc->markQueueRuntime($checkId, ReferenceRelevanceCheckService::QUEUE_PENDING, $retryCount + 1); + return $this->processOneRow($checkId, array_merge($row, ['retry_count' => $retryCount + 1]), $skipLiteratureFetch); } try { - $this->svc->updateCheckResult($checkId, [ - 'status' => ReferenceCheckService::RECORD_FAILED, - 'error_msg' => $e->getMessage(), - ]); - $this->svc->markQueueRuntime($checkId, ReferenceCheckService::QUEUE_FAILED, $retryCount); + $fresh = Db::name('article_reference_relevance_check_result')->where('id', intval($checkId))->find(); + $groupRows = !empty($fresh) ? $this->svc->findCitationGroupRowsForWorker($fresh) : []; + if (!empty($groupRows)) { + $this->svc->failGroupWithQueue($groupRows, $e->getMessage(), $retryCount); + } else { + $this->svc->updateCheckResult($checkId, [ + 'status' => ReferenceRelevanceCheckService::RECORD_FAILED, + 'error_msg' => $e->getMessage(), + ]); + $this->svc->markQueueRuntime($checkId, ReferenceRelevanceCheckService::QUEUE_FAILED, $retryCount); + } } catch (\Exception $e2) { \think\Log::error('ReferenceCheckArticleWorker markFailed: ' . $e2->getMessage()); } - $amId = intval(isset($row['am_id']) ? $row['am_id'] : 0); - if ($amId > 0) { - $this->svc->syncAmRefCheckStatus($amId); - } return 'failed'; } } @@ -170,7 +179,7 @@ class ReferenceCheckArticleWorker if ($failed > 0) { $status = self::BATCH_PARTIAL_FAILED; } - Db::name('article_reference_check_batch')->where('id', intval($batchId))->update([ + Db::name('article_reference_relevance_check_batch')->where('id', intval($batchId))->update([ 'batch_status' => $status, 'done_count' => intval($done), 'failed_count' => intval($failed), @@ -183,7 +192,7 @@ class ReferenceCheckArticleWorker private function publishNextWaitingBatch() { - $next = Db::name('article_reference_check_batch') + $next = Db::name('article_reference_relevance_check_batch') ->where('batch_status', self::BATCH_WAITING) ->order('id asc') ->find(); @@ -197,8 +206,8 @@ class ReferenceCheckArticleWorker isset($next['trigger']) ? $next['trigger'] : 'enqueue' ); } catch (\Exception $e) { - $this->svc->log('publishNextWaitingBatch failed: ' . $e->getMessage()); - \think\Log::error('publishNextWaitingBatch: ' . $e->getMessage()); + $this->svc->log('ReferenceCheck publishNextWaitingBatch failed: ' . $e->getMessage()); + \think\Log::error('ReferenceCheck publishNextWaitingBatch: ' . $e->getMessage()); } } } diff --git a/application/common/service/ReferenceRelevanceLlmService.php b/application/common/service/ReferenceRelevanceLlmService.php index f37fb7d1..975ead5f 100644 --- a/application/common/service/ReferenceRelevanceLlmService.php +++ b/application/common/service/ReferenceRelevanceLlmService.php @@ -171,7 +171,10 @@ class ReferenceRelevanceLlmService - supplementary_relevance:部分相关、补充性 - minimal_relevance:仅边缘/背景沾边 - no_meaningful_relevance:与引用处核心表述基本无关 -- reason:中文,须写明:①文献类型与**核心研究对象** ②**本文自身证据**覆盖了哪些 claim / 哪些未覆盖(含点名通路、功能结局、具体列举项;讨论转引须标明) ③**若仅为同主题不同 claim 或主语层级不对须明确写出** ④为何此分值而非更高或更低 +- reason:中英双语结论,格式固定为两行: + 【中文】(中文结论,须写明:①文献类型与**核心研究对象** ②**本文自身证据**覆盖了哪些 claim / 哪些未覆盖 ③主语/claim 不匹配须明确写出 ④为何此分值) + 【English】(与中文对应的英文结论,语义一致) +- reason_en:仅英文结论(与 reason 中【English】段相同,勿留空) 主语/层级不对 → 单条 **0.45**,不得因讨论提及相同通路给 0.78: 引用处 claim 为「化合物 X 经 PI3K/AKT 等机制 demonstrated…」,文献为其他植物提取物或计算预测、仅在讨论转引他人 X 机制 → 0.45,weakly_related,is_relevant=0。 @@ -190,9 +193,10 @@ class ReferenceRelevanceLlmService - 多篇联合仍缺主语对齐、缺原句点名通路/结局、或主要靠讨论转引 → 联合分通常 **≤0.45~0.65**,不得因单篇讨论出现相同关键词给到 0.78+ - combined_is_relevant:combined_relevance_score>=0.65 为 1 - combined_relevance_level:与 combined 分数对应的等级 -- combined_reason:中文综合结论,说明各文献分工(如「文献2综述覆盖流行病学主 claim,文献1仅机制补充」)及最终分值理由 +- combined_reason:中英双语综合结论,格式同 reason(【中文】/【English】),说明各文献分工及最终分值理由 +- combined_reason_en:仅英文综合结论(与 combined_reason 中【English】段相同) -单条引用时:combined_* 与单条一致,combined_reason 可写「」。 +单条引用时:combined_* 与单条一致;combined_reason / combined_reason_en 可与 reason / reason_en 相同。 ================================================== 【四、评分与等级对照】 @@ -218,11 +222,13 @@ class ReferenceRelevanceLlmService "relevance_score": 0.45, "relevance_level": "weakly_related", "relevance_role": "minimal_relevance", - "reason": "中文单条结论", + "reason": "【中文】中文单条结论\n【English】English single-reference conclusion", + "reason_en": "English single-reference conclusion", "combined_is_relevant": 1, "combined_relevance_score": 0.92, "combined_relevance_level": "highly_related", - "combined_reason": "中文联合结论" + "combined_reason": "【中文】中文联合结论\n【English】English combined conclusion", + "combined_reason_en": "English combined conclusion" }, { "reference_no": 2, @@ -248,7 +254,7 @@ PROMPT; if ($abstractText !== '') { $parts[] = "【文献摘要/清洗后内容(Europe PMC·PubMed·Crossref·PDF)】\n" . $abstractText; } - $parts[] = '请先拆解最小主张单元(主语层级、证据来源、点名通路/结局逐项核对),判断每篇文献类型与**本文自身证据**,再**逐篇独立**给出单条 relevance_score(讨论转引、提取物/计算预测不得抬高;弱相关文献不得因联合而高分),最后给出 combined_*。仅输出 results 数组 JSON。'; + $parts[] = '请先拆解最小主张单元(主语层级、证据来源、点名通路/结局逐项核对),判断每篇文献类型与**本文自身证据**,再**逐篇独立**给出单条 relevance_score(讨论转引、提取物/计算预测不得抬高;弱相关文献不得因联合而高分),最后给出 combined_*。reason / combined_reason 必须中英双语(【中文】/【English】),并分别填写 reason_en / combined_reason_en。仅输出 results 数组 JSON。'; return implode("\n\n", $parts); } @@ -285,7 +291,10 @@ PROMPT; $level = $this->levelFromScore($score, isset($item['relevance_level']) ? $item['relevance_level'] : ''); $role = $this->normalizeRelevanceRole(isset($item['relevance_role']) ? $item['relevance_role'] : ''); - $reason = $this->cleanReason(isset($item['reason']) ? $item['reason'] : ''); + list($reason, $reasonEn) = $this->normalizeBilingualReason( + isset($item['reason']) ? $item['reason'] : '', + isset($item['reason_en']) ? $item['reason_en'] : '' + ); list($score, $level, $isRelevant, $role) = $this->enforceSingleReferenceConsistency( $score, @@ -320,6 +329,14 @@ PROMPT; $citeGroupRefs = trim((string)$defaultCiteGroupRefs); } + list($combinedReason, $combinedReasonEn) = $this->normalizeBilingualReason( + isset($item['combined_reason']) ? $item['combined_reason'] : '', + isset($item['combined_reason_en']) ? $item['combined_reason_en'] : '' + ); + if ($combinedReason === '' && $combinedReasonEn === '') { + list($combinedReason, $combinedReasonEn) = [$reason, $reasonEn]; + } + $out[] = [ 'reference_no' => $refNo, 'cite_group_refs' => $citeGroupRefs, @@ -328,10 +345,12 @@ PROMPT; 'relevance_level' => $level, 'relevance_role' => $role, 'reason' => $reason, + 'reason_en' => $reasonEn, 'combined_is_relevant' => $combinedRelevant ? 1 : 0, 'combined_relevance_score' => $combinedScore, 'combined_relevance_level' => $combinedLevel, - 'combined_reason' => $this->cleanReason(isset($item['combined_reason']) ? $item['combined_reason'] : ''), + 'combined_reason' => $combinedReason, + 'combined_reason_en' => $combinedReasonEn, ]; } @@ -441,6 +460,7 @@ PROMPT; $out[$idx]['combined_relevance_score'] = floatval($src['combined_relevance_score']); $out[$idx]['combined_relevance_level'] = (string)$src['combined_relevance_level']; $out[$idx]['combined_reason'] = (string)$src['combined_reason']; + $out[$idx]['combined_reason_en'] = (string)$src['combined_reason_en']; } } @@ -524,10 +544,45 @@ PROMPT; private function cleanReason($reason) { - $reason = trim(preg_replace('/\s+/u', ' ', (string)$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); } + /** + * @return array{0:string,1:string} [bilingual reason, english only] + */ + private function normalizeBilingualReason($reason, $reasonEn) + { + $reason = trim((string)$reason); + $reasonEn = $this->cleanReason($reasonEn); + + if ($reasonEn === '' && preg_match('/【English】\s*(.+)$/us', $reason, $m)) { + $reasonEn = $this->cleanReason($m[1]); + } + + $zh = ''; + if (preg_match('/【中文】\s*(.*?)(?:\n【English】|$)/us', $reason, $m)) { + $zh = trim($m[1]); + } elseif ($reason !== '' && strpos($reason, '【English】') === false) { + $zh = trim($reason); + } + + if ($zh !== '' && $reasonEn !== '' && strpos($reason, '【English】') === false) { + $reason = "【中文】{$zh}\n【English】{$reasonEn}"; + } elseif ($zh !== '' && $reasonEn !== '' && strpos($reason, '【中文】') === false) { + $reason = "【中文】{$zh}\n【English】{$reasonEn}"; + } else { + $reason = $this->cleanReason($reason); + } + + if ($reasonEn === '' && $zh !== '') { + $reasonEn = ''; + } + + return [$reason, $reasonEn]; + } + private function boolVal($v) { if (is_bool($v)) { diff --git a/application/extra/rabbitmq.php b/application/extra/rabbitmq.php index 4b41a649..05aa89b4 100644 --- a/application/extra/rabbitmq.php +++ b/application/extra/rabbitmq.php @@ -13,11 +13,4 @@ return [ 'dlq' => 'ref_check.article.dlq', 'route_key' => 'article.start', ], - - 'reference_relevance' => [ - 'exchange' => 'reference_relevance', - 'queue' => 'ref_relevance.article', - 'dlq' => 'ref_relevance.article.dlq', - 'route_key' => 'article.start', - ], ];