|null */ private static $ruleCache; /** * @param array $sectionTexts 键:abstract/introduction/... * @return array{hits:array,rule_score:int} */ public function detect(array $sectionTexts): array { $rules = $this->loadActiveRules(); $hits = []; $ruleScore = 0; $normalized = []; foreach ($sectionTexts as $key => $text) { $section = strtolower(trim((string) $key)); if ($section === '') { continue; } $normalized[$section] = ManuscriptTextCleanService::clean((string) $text); } $allText = ManuscriptTextCleanService::clean(implode("\n\n", array_filter($normalized))); foreach ($rules as $rule) { $section = strtolower(trim((string) ($rule['section'] ?? ''))); $pattern = trim((string) ($rule['sentence_pattern'] ?? '')); if ($pattern === '') { continue; } $targetText = ''; if ($section === 'all') { $targetText = $allText; } elseif (isset($normalized[$section])) { $targetText = $normalized[$section]; } else { continue; } if ($targetText === '') { continue; } $matches = $this->matchPattern($targetText, $pattern); foreach ($matches as $matchedText) { $sentence = $this->extractSentenceContaining($targetText, $matchedText); $hitScore = max(1, intval($rule['weight'] ?? 1)) * max(1, intval($rule['risk_level'] ?? 1)); $hits[] = [ 'section' => $section, 'type' => '模板化表达', 'sentence' => $sentence !== '' ? $sentence : $matchedText, 'pattern' => $pattern, 'score' => $hitScore, 'remark' => (string) ($rule['remark'] ?? ''), ]; $ruleScore += $hitScore; } } return [ 'hits' => $hits, 'rule_score' => min(100, $ruleScore), ]; } /** * @return array */ private function loadActiveRules(): array { if (self::$ruleCache !== null) { return self::$ruleCache; } try { self::$ruleCache = Db::name('ai_template_sentence') ->where('status', 1) ->field('id,sentence_pattern,section,language,risk_level,weight,remark') ->order('section asc,id asc') ->select(); } catch (\Throwable $e) { self::$ruleCache = []; } return self::$ruleCache; } /** * @return array */ private function matchPattern(string $text, string $pattern): array { // 支持 regex: 前缀;否则优先按正则,失败则按字面量匹配 $raw = $pattern; $forceRegex = false; if (stripos($pattern, 'regex:') === 0) { $raw = trim(substr($pattern, 6)); $forceRegex = true; } $out = $this->runPregMatchAll($text, '#' . $raw . '#iu'); if (!empty($out) || $forceRegex) { return $out; } // 字面量回退(库内普通句子) return $this->runPregMatchAll($text, '#' . preg_quote($raw, '#') . '#iu'); } /** * @return array */ private function runPregMatchAll(string $text, string $regex): array { $error = null; set_error_handler(function ($errno, $errstr) use (&$error) { $error = $errstr; return true; }); $ok = preg_match_all($regex, $text, $matches); restore_error_handler(); if ($ok === false || $error !== null || empty($matches[0])) { return []; } $out = []; foreach ($matches[0] as $match) { $match = trim((string) $match); if ($match !== '') { $out[] = $match; } } return $out; } private function extractSentenceContaining(string $text, string $needle): string { $pos = mb_stripos($text, $needle); if ($pos === false) { return ''; } $before = mb_substr($text, 0, $pos); $after = mb_substr($text, $pos); $start = $before; if (preg_match('/.*[.!?。!?]\s*/us', $before, $m)) { $start = substr($before, strlen($m[0])); } $sentence = $start . $after; if (preg_match('/^(.+?[.!?。!?])/us', $sentence, $m)) { return trim($m[1]); } if (preg_match('/^.{1,500}/us', $sentence, $m)) { return trim($m[0]); } return trim(mb_substr($sentence, 0, 300)); } }