diff --git a/application/common/AiTemplateSentenceRuleDetectService.php b/application/common/AiTemplateSentenceRuleDetectService.php new file mode 100644 index 00000000..2ced0b3b --- /dev/null +++ b/application/common/AiTemplateSentenceRuleDetectService.php @@ -0,0 +1,168 @@ +|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)); + } +} diff --git a/application/common/AiTemplateSentenceService.php b/application/common/AiTemplateSentenceService.php new file mode 100644 index 00000000..1206e155 --- /dev/null +++ b/application/common/AiTemplateSentenceService.php @@ -0,0 +1,176 @@ +|null */ + private static $ruleCache; + + /** + * 按稿件分节统计模版句出现次数 + * + * @param array $sectionTexts 键:abstract/introduction/methods/results/discussion/conclusion + * @return array{total_matches:int,matched_rule_count:int,items:array,by_section:array} + */ + public function countInManuscript(array $sectionTexts): array + { + $rules = $this->loadActiveRules(); + if (empty($rules)) { + return [ + 'total_matches' => 0, + 'matched_rule_count' => 0, + 'items' => [], + 'by_section' => [], + ]; + } + + $normalizedSections = []; + foreach ($sectionTexts as $key => $text) { + $sectionKey = strtolower(trim((string) $key)); + if ($sectionKey === '') { + continue; + } + $normalizedSections[$sectionKey] = $this->normalizeText((string) $text); + } + + $allText = $this->normalizeText(implode("\n\n", array_filter($normalizedSections))); + + $items = []; + $bySection = []; + + foreach ($rules as $rule) { + $ruleSection = strtolower(trim((string) ($rule['section'] ?? ''))); + $pattern = trim((string) ($rule['sentence_pattern'] ?? '')); + if ($pattern === '') { + continue; + } + + $targetText = ''; + if ($ruleSection === 'all') { + $targetText = $allText; + } elseif (isset($normalizedSections[$ruleSection])) { + $targetText = $normalizedSections[$ruleSection]; + } else { + continue; + } + + if ($targetText === '') { + continue; + } + + $count = $this->countPatternMatches($targetText, $pattern); + if ($count <= 0) { + continue; + } + + $item = [ + 'id' => intval($rule['id'] ?? 0), + 'section' => $ruleSection, + 'sentence_pattern' => $pattern, + 'count' => $count, + 'risk_level' => intval($rule['risk_level'] ?? 0), + 'weight' => intval($rule['weight'] ?? 0), + 'remark' => (string) ($rule['remark'] ?? ''), + ]; + $items[] = $item; + + if (!isset($bySection[$ruleSection])) { + $bySection[$ruleSection] = [ + 'total_matches' => 0, + 'matched_rule_count' => 0, + 'items' => [], + ]; + } + $bySection[$ruleSection]['items'][] = $item; + $bySection[$ruleSection]['total_matches'] += $count; + $bySection[$ruleSection]['matched_rule_count']++; + } + + usort($items, function ($a, $b) { + if ($a['count'] !== $b['count']) { + return $b['count'] - $a['count']; + } + if ($a['weight'] !== $b['weight']) { + return $b['weight'] - $a['weight']; + } + return $a['id'] - $b['id']; + }); + + foreach ($bySection as &$sectionStats) { + usort($sectionStats['items'], function ($a, $b) { + if ($a['count'] !== $b['count']) { + return $b['count'] - $a['count']; + } + return $b['weight'] - $a['weight']; + }); + } + unset($sectionStats); + + $totalMatches = 0; + foreach ($items as $item) { + $totalMatches += intval($item['count']); + } + + return [ + 'total_matches' => $totalMatches, + 'matched_rule_count' => count($items), + 'items' => $items, + 'by_section' => $bySection, + ]; + } + + /** + * @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,article_type,version') + ->order('section asc,id asc') + ->select(); + } catch (\Throwable $e) { + self::$ruleCache = []; + } + + return self::$ruleCache; + } + + private function normalizeText(string $text): string + { + return ManuscriptTextCleanService::clean($text); + } + + private function countPatternMatches(string $text, string $pattern): int + { + if ($text === '' || $pattern === '') { + return 0; + } + + $regex = '#' . $pattern . '#iu'; + $error = null; + set_error_handler(function ($errno, $errstr) use (&$error) { + $error = $errstr; + return true; + }); + $count = preg_match_all($regex, $text, $matches); + restore_error_handler(); + + if ($count === false || $error !== null) { + return 0; + } + + return intval($count); + } +} diff --git a/application/common/AiWritingRiskAnalysisService.php b/application/common/AiWritingRiskAnalysisService.php new file mode 100644 index 00000000..2f0f3382 --- /dev/null +++ b/application/common/AiWritingRiskAnalysisService.php @@ -0,0 +1,230 @@ +analyzeTemplateSentenceRisk($templateStats), + $this->analyzeRepeatedPhraseRisk($repeatedStats), + $this->analyzeSentenceLengthRisk($sentenceStats), + ]; + + $overallScore = 0; + $levelRank = ['low' => 1, 'medium' => 2, 'high' => 3]; + $maxRank = 1; + $flags = []; + + foreach ($dimensions as $dimension) { + $overallScore += intval($dimension['score'] ?? 0); + $rank = $levelRank[$dimension['level'] ?? 'low'] ?? 1; + if ($rank > $maxRank) { + $maxRank = $rank; + } + foreach ((array) ($dimension['flags'] ?? []) as $flag) { + if ($flag !== '') { + $flags[] = $flag; + } + } + } + + $overallLevel = array_search($maxRank, $levelRank, true) ?: 'low'; + + return [ + 'overall_level' => $overallLevel, + 'overall_score' => min(100, $overallScore), + 'summary' => $this->buildSummary($overallLevel, $dimensions, $flags), + 'dimensions' => $dimensions, + 'flags' => array_values(array_unique($flags)), + ]; + } + + private function analyzeTemplateSentenceRisk(array $stats): array + { + $items = (array) ($stats['items'] ?? []); + $totalMatches = intval($stats['total_matches'] ?? 0); + $matchedRuleCount = intval($stats['matched_rule_count'] ?? 0); + + $score = 0; + $flags = []; + foreach ($items as $item) { + $count = intval($item['count'] ?? 0); + $riskLevel = max(1, intval($item['risk_level'] ?? 1)); + $weight = max(1, intval($item['weight'] ?? 1)); + $score += $count * $riskLevel * $weight; + + if ($count > 0) { + $section = (string) ($item['section'] ?? ''); + $remark = trim((string) ($item['remark'] ?? '')); + $label = $remark !== '' ? $remark : (string) ($item['sentence_pattern'] ?? ''); + $flags[] = sprintf('%s段命中模版句:%s(%d次)', $section, $label, $count); + } + } + + $level = $this->scoreToLevel($score, 15, 40); + + return [ + 'key' => 'template_sentence', + 'name' => '模版句特征', + 'level' => $level, + 'score' => min(100, $score), + 'summary' => $totalMatches > 0 + ? sprintf('命中 %d 条模版句规则,共 %d 处', $matchedRuleCount, $totalMatches) + : '未检测到典型 AI 模版句', + 'metrics' => [ + 'total_matches' => $totalMatches, + 'matched_rule_count' => $matchedRuleCount, + ], + 'details' => $stats, + 'flags' => array_slice($flags, 0, 8), + ]; + } + + private function analyzeRepeatedPhraseRisk(array $stats): array + { + $items = (array) ($stats['suspicious_items'] ?? []); + if (empty($items)) { + $items = array_values(array_filter((array) ($stats['items'] ?? []), function ($item) { + return ($item['category'] ?? '') === 'suspicious'; + })); + } + $uniqueCount = count($items); + $expectedCount = count((array) ($stats['expected_topic_items'] ?? [])); + $topCount = 0; + $flags = []; + + foreach (array_slice($items, 0, 5) as $item) { + $count = intval($item['count'] ?? 0); + if ($count > $topCount) { + $topCount = $count; + } + if ($count >= 4) { + $flags[] = sprintf('可疑重复短语「%s」出现 %d 次', (string) ($item['phrase'] ?? ''), $count); + } + } + + $score = min(100, $uniqueCount * 3 + max(0, $topCount - 3) * 6); + $level = 'low'; + if ($uniqueCount >= 10 || $topCount >= 8) { + $level = 'high'; + } elseif ($uniqueCount >= 4 || $topCount >= 5) { + $level = 'medium'; + } + + $summary = '未发现可疑机械重复短语'; + if ($uniqueCount > 0) { + $summary = sprintf('发现 %d 组可疑重复短语,最高重复 %d 次', $uniqueCount, $topCount); + } elseif ($expectedCount > 0) { + $summary = sprintf('主题词正常重复 %d 组,未触发可疑短语', $expectedCount); + } + + return [ + 'key' => 'repeated_phrase', + 'name' => '重复短语', + 'level' => $level, + 'score' => $score, + 'summary' => $summary, + 'metrics' => [ + 'suspicious_count' => $uniqueCount, + 'expected_topic_count' => $expectedCount, + 'top_count' => $topCount, + ], + 'details' => $stats, + 'flags' => $flags, + ]; + } + + private function analyzeSentenceLengthRisk(array $stats): array + { + $avg = floatval($stats['avg'] ?? 0); + $std = floatval($stats['std'] ?? 0); + $max = intval($stats['max'] ?? 0); + $sentenceCount = intval($stats['sentence_count'] ?? 0); + + $score = 0; + $flags = []; + $level = 'low'; + + if ($sentenceCount >= 8) { + if ($avg >= 32) { + $score += 20; + $flags[] = sprintf('平均句长偏高(%.1f 词/句)', $avg); + } + if ($std > 0 && $std <= 5 && $avg >= 12 && $avg <= 28) { + $score += 25; + $flags[] = sprintf('句长分布过于均匀(均值 %.1f,标准差 %.1f)', $avg, $std); + } + if ($max >= 50) { + $score += 10; + $flags[] = sprintf('存在超长句(最长 %d 词)', $max); + } + } + + $level = $this->scoreToLevel($score, 10, 25); + + return [ + 'key' => 'sentence_length', + 'name' => '句长特征', + 'level' => $level, + 'score' => min(100, $score), + 'summary' => $sentenceCount > 0 + ? sprintf('平均句长 %.1f,标准差 %.1f,共 %d 句', $avg, $std, $sentenceCount) + : '正文句长数据不足', + 'metrics' => [ + 'avg' => $avg, + 'std' => $std, + 'max' => $max, + 'min' => intval($stats['min'] ?? 0), + 'sentence_count' => $sentenceCount, + ], + 'details' => $stats, + 'flags' => $flags, + ]; + } + + private function scoreToLevel(int $score, int $mediumThreshold, int $highThreshold): string + { + if ($score >= $highThreshold) { + return 'high'; + } + if ($score >= $mediumThreshold) { + return 'medium'; + } + return 'low'; + } + + private function buildSummary(string $overallLevel, array $dimensions, array $flags): string + { + $levelText = [ + 'low' => '低风险', + 'medium' => '中等风险', + 'high' => '较高风险', + ]; + $parts = [$levelText[$overallLevel] ?? '低风险']; + + foreach ($dimensions as $dimension) { + if (($dimension['level'] ?? 'low') === 'low') { + continue; + } + $parts[] = $dimension['name'] . '需关注'; + } + + if (!empty($flags)) { + $parts[] = '重点:' . $flags[0]; + } + + return implode(';', $parts); + } +} diff --git a/application/common/ManuscriptTextCleanService.php b/application/common/ManuscriptTextCleanService.php new file mode 100644 index 00000000..0b71e9fa --- /dev/null +++ b/application/common/ManuscriptTextCleanService.php @@ -0,0 +1,121 @@ + $sections + * @return array + */ + public static function cleanSections(array $sections): array + { + $cleaned = []; + foreach ($sections as $key => $text) { + $cleaned[$key] = self::clean((string) $text); + } + return $cleaned; + } + + private static function normalizeEncoding(string $text): string + { + if (!mb_check_encoding($text, 'UTF-8')) { + $text = mb_convert_encoding($text, 'UTF-8', 'GBK,GB2312,GB18030,ISO-8859-1'); + } + return $text; + } + + private static function normalizeQuotesAndDashes(string $text): string + { + return strtr($text, [ + '‘' => "'", + '’' => "'", + '“' => '"', + '”' => '"', + '„' => '"', + '‟' => '"', + '—' => '-', + '–' => '-', + '‑' => '-', + ' ' => ' ', + ]); + } + + private static function stripInvisibleChars(string $text): string + { + $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $text); + $text = preg_replace('/[\x{200B}-\x{200F}\x{202A}-\x{202E}\x{2060}\x{FEFF}]/u', '', $text); + $text = str_replace([chr(0xC2) . chr(0xA0), ' '], ' ', $text); + return $text; + } + + private static function stripUrlsAndEmails(string $text): string + { + $text = preg_replace('/https?:\/\/\S+/iu', ' ', $text); + $text = preg_replace('/\b[\w.+-]+@[\w.-]+\.[a-z]{2,}\b/iu', ' ', $text); + return $text; + } + + private static function stripCitationMarkers(string $text): string + { + // [1] [1,2] [1-3] [1, 2-4] + $text = preg_replace('/\[\s*\d+(?:\s*[,,\-–—]\s*\d+)*\s*\]/u', ' ', $text); + // 上标残留数字(如 word 提取后的 ^1^) + $text = preg_replace('/\b\^\d+\^/u', ' ', $text); + return $text; + } + + private static function stripFigureTableTags(string $text): string + { + $text = preg_replace('/\((?:Fig(?:ure)?|Table|Tab\.)\s*[A-Za-z0-9.-]+\)/iu', ' ', $text); + $text = preg_replace('/\b(?:Fig(?:ure)?|Table|Tab\.)\s*[A-Za-z0-9.-]+\b/iu', ' ', $text); + return $text; + } + + private static function stripFootnoteMarkers(string $text): string + { + $text = preg_replace('/[*#†‡§]+\d*/u', ' ', $text); + return $text; + } + + /** + * 去除文献检索式、数据库字段等 Methods 常见残留 + */ + private static function stripSearchStrategyNoise(string $text): string + { + $text = preg_replace('/\btitle\s*[\/\-]\s*abstract\b/iu', ' ', $text); + $text = preg_replace('/\babstract\s*[\/\-]\s*title\b/iu', ' ', $text); + $text = preg_replace('/\bti\s*[\-\/]?\s*ab\b/iu', ' ', $text); + $text = preg_replace('/\bmesh\s+terms?\b/iu', ' ', $text); + $text = preg_replace('/\b(search\s+terms?|search\s+strategy)\b/iu', ' ', $text); + return $text; + } +}