Files
tougao/application/common/UserInfoFromFileService.php
wyn 8785610e6d 参考文献作者堆叠
参考文献相关性检测
作者ai写作辅助检测工作
2026-07-15 10:49:05 +08:00

359 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\common;
use app\common\service\LLMService;
use Smalot\PdfParser\Parser as PdfParser;
use think\Db;
use think\Env;
use think\Exception;
/**
* 从用户简历文件提取文本,调用与参考文献校对相同的 LLMService 解析用户基本信息。
*/
class UserInfoFromFileService
{
const CV_BASE_URL_DEFAULT = 'https://submission.tmrjournals.com/public/reviewer/';
/** @var LLMService */
private $llm;
public function __construct(LLMService $llm = null)
{
$this->llm = $llm ?: new LLMService();
}
/**
* 按 user_id 查 user_cv取最新一条简历解析。
*
* @param int $userId
* @return array
*/
public function parseFromUserId($userId)
{
$userId = intval($userId);
if ($userId <= 0) {
throw new Exception('user_id 无效');
}
$row = Db::name('user_cv')
->where('user_id', $userId)
->where('state', 0)
->order('ctime desc')
->find();
if (empty($row) || trim((string) $row['cv']) === '') {
throw new Exception('未找到该用户的简历user_cv');
}
$cvRel = $this->normalizeCvRelativePath($row['cv']);
$cvUrl = $this->buildCvUrl($cvRel);
$local = $this->resolveLocalCvPath($cvRel);
$temp = false;
$filePath = $local;
if ($filePath === '' || !is_file($filePath)) {
$filePath = $this->downloadCvToTemp($cvUrl, $cvRel);
$temp = true;
}
try {
$result = $this->parseFromFile($filePath);
} finally {
if ($temp && is_file($filePath)) {
@unlink($filePath);
}
}
$result['user_id'] = $userId;
$result['user_cv_id'] = intval($row['user_cv_id'] ?? 0);
$result['cv'] = $cvRel;
$result['cv_url'] = $cvUrl;
return $result;
}
/**
* @param string $filePath 服务器本地绝对路径
* @return array
*/
public function parseFromFile($filePath)
{
$filePath = trim((string) $filePath);
if ($filePath === '' || !is_file($filePath)) {
throw new Exception('文件不存在');
}
$text = trim($this->extractFileText($filePath));
if ($text === '') {
throw new Exception('未能从文件中提取到文本');
}
$text = $this->sanitizeUtf8($text);
$maxChars = max(1000, (int) Env::get('promotion.promotion_llm_max_chars', 2500));
if (mb_strlen($text) > $maxChars) {
$text = mb_substr($text, 0, $maxChars) . "\n...(truncated)";
}
$llmResult = $this->parseUserInfoWithLlm($text);
return [
'file' => basename($filePath),
'text_length' => mb_strlen($text),
'text_preview' => mb_substr($text, 0, 600),
'user_info' => $llmResult['user_info'],
'llm_raw' => $llmResult['raw'],
];
}
private function normalizeCvRelativePath($cv)
{
$cv = trim(str_replace('\\', '/', (string) $cv));
if ($cv === '') {
return '';
}
if (preg_match('#^https?://#i', $cv)) {
return $cv;
}
return ltrim($cv, '/');
}
private function buildCvUrl($cvRel)
{
if (preg_match('#^https?://#i', $cvRel)) {
return $cvRel;
}
$base = rtrim(trim((string) Env::get('reviewer.cv_base_url', self::CV_BASE_URL_DEFAULT)), '/') . '/';
return $base . $cvRel;
}
private function resolveLocalCvPath($cvRel)
{
if (preg_match('#^https?://#i', $cvRel)) {
return '';
}
$path = ROOT_PATH . 'public' . DS . 'reviewer' . DS . str_replace('/', DS, $cvRel);
return is_file($path) ? $path : '';
}
private function downloadCvToTemp($url, $cvRel)
{
$ext = strtolower(pathinfo(parse_url($cvRel, PHP_URL_PATH) ?: $cvRel, PATHINFO_EXTENSION));
if ($ext === '') {
$ext = 'dat';
}
$saveDir = ROOT_PATH . 'runtime' . DS . 'user_file_parse';
if (!is_dir($saveDir)) {
@mkdir($saveDir, 0755, true);
}
$path = $saveDir . DS . date('YmdHis') . '_' . uniqid('', true) . '.' . $ext;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_TIMEOUT => 90,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => 0,
]);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($body === false || $code < 200 || $code >= 300) {
throw new Exception('下载简历失败HTTP ' . $code . ': ' . $url . ($err !== '' ? ' ' . $err : ''));
}
if (@file_put_contents($path, $body) === false) {
throw new Exception('保存简历临时文件失败');
}
return $path;
}
private function extractFileText($filePath)
{
$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
if (in_array($ext, ['txt', 'text', 'md', 'csv', 'log'], true)) {
$raw = @file_get_contents($filePath);
return is_string($raw) ? $raw : '';
}
if ($ext === 'pdf') {
return $this->extractPdfText($filePath);
}
if (in_array($ext, ['doc', 'docx'], true)) {
return $this->extractDocxText($filePath);
}
// 未识别扩展名:先尝试 Word再尝试 PDF最后按纯文本读
$text = $this->tryExtractDocxText($filePath);
if ($text !== '') {
return $text;
}
$text = $this->tryExtractPdfText($filePath);
if ($text !== '') {
return $text;
}
$raw = @file_get_contents($filePath);
if (!is_string($raw) || $raw === '') {
return '';
}
if ($this->looksLikeBinary($raw)) {
throw new Exception('不支持的简历文件格式: .' . ($ext !== '' ? $ext : 'unknown'));
}
return $raw;
}
private function extractPdfText($filePath)
{
$text = $this->tryExtractPdfText($filePath);
if ($text === '') {
throw new Exception('PDF 文本提取失败,请确认已安装 smalot/pdfparsercomposer require smalot/pdfparser');
}
return $text;
}
private function tryExtractPdfText($filePath)
{
if (!class_exists(PdfParser::class)) {
\think\Log::warning('UserInfoFromFile: smalot/pdfparser not installed');
return '';
}
try {
$parser = new PdfParser();
$pdf = $parser->parseFile($filePath);
$text = $pdf->getText();
return is_string($text) ? $text : '';
} catch (\Throwable $e) {
\think\Log::warning('UserInfoFromFile PDF extract: ' . $e->getMessage());
return '';
}
}
private function extractDocxText($filePath)
{
$text = $this->tryExtractDocxText($filePath);
if ($text === '') {
throw new Exception('Word 文档文本提取失败');
}
return $text;
}
private function tryExtractDocxText($filePath)
{
if (!function_exists('docxReader')) {
return '';
}
try {
$text = docxReader($filePath);
return is_string($text) ? $text : '';
} catch (\Throwable $e) {
\think\Log::warning('UserInfoFromFile docxReader: ' . $e->getMessage());
return '';
}
}
private function looksLikeBinary($raw)
{
if (strpos($raw, "\0") !== false) {
return true;
}
$sample = substr($raw, 0, 4096);
if ($sample === '') {
return false;
}
$nonPrintable = preg_match_all('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $sample);
return $nonPrintable > 10;
}
private function parseUserInfoWithLlm($text)
{
$system = <<<'PROMPT'
你是学术人员信息抽取助手。根据用户上传的简历、简介或申请表文本,提取可用于期刊投稿/审稿人档案的基本信息。
只输出一个 JSON 对象,不要 markdown不要解释。字段说明
- realname: 中文姓名;若只有拼音可填拼音;无法判断则空字符串
- email: 邮箱
- phone: 手机或电话
- orcid: ORCID 号(仅数字与连字符)
- technical: 职称/职务(如教授、副主任护师、医学博士)
- field: 主要研究领域中文13 个词用顿号分隔
- company: 当前主要任职机构/工作单位(必填优先提取;填完整机构名,如「中南大学湘雅二医院」「暨南大学」,不要只写科室;多机构取最主要或最近一条)
- department: 所在科室/部门/学院(如感染科、药学院)
- country: 国家,默认中国可填「中国」
- introduction: 100字以内中文简介职务+机构+方向)
缺失字段用空字符串 ""不要编造。机构信息请从「工作单位」「任职」「所在单位」「affiliation」等段落提取。
PROMPT;
\think\Log::info('UserInfoFromFile user head: ' . mb_substr($text, 0, 600));
$raw = $this->llm->requestChat([
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => "请从以下文本提取用户信息:\n\n" . $text],
], 0.1);
if ($raw === null) {
throw new Exception('大模型请求失败,请检查 .env 中 [promotion] PROMOTION_LLM_URL / PROMOTION_LLM_MODEL与参考文献校对相同');
}
$parsed = $this->llm->parseJsonResponse($raw);
if ($parsed === null) {
throw new Exception('大模型返回无法解析为 JSON' . mb_substr($raw, 0, 200));
}
return [
'user_info' => $this->normalizeUserInfo($parsed),
'raw' => $raw,
];
}
private function normalizeUserInfo(array $parsed)
{
$keys = ['realname', 'email', 'phone', 'orcid', 'technical', 'field', 'company', 'department', 'country', 'introduction'];
$out = [];
foreach ($keys as $k) {
$out[$k] = trim((string) ($parsed[$k] ?? ''));
}
// 机构:兼容大模型可能返回的别名字段
if ($out['company'] === '') {
foreach (['机构', '单位', 'institution', 'affiliation', 'organization', 'org', '工作单位', '任职单位'] as $alias) {
$val = trim((string) ($parsed[$alias] ?? ''));
if ($val !== '') {
$out['company'] = $val;
break;
}
}
}
if ($out['orcid'] !== '') {
$out['orcid'] = preg_replace('/\s+/', '', $out['orcid']);
}
// 对外同时返回 institution与 company 同值)
$out['institution'] = $out['company'];
return $out;
}
private function sanitizeUtf8($text)
{
$text = (string) $text;
if (function_exists('mb_convert_encoding')) {
$text = mb_convert_encoding($text, 'UTF-8', 'UTF-8');
}
if (function_exists('iconv')) {
$fixed = @iconv('UTF-8', 'UTF-8//IGNORE', $text);
if (is_string($fixed)) {
$text = $fixed;
}
}
return $text;
}
}