229 lines
7.0 KiB
PHP
229 lines
7.0 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace app\note\service;
|
||
|
||
use app\note\model\NoteAiSummary;
|
||
use app\note\model\NoteItem;
|
||
|
||
/**
|
||
* note 模块 AI 总结服务
|
||
*
|
||
* 当前先提供规则版总结,保证接口可用。
|
||
* 后续如需接入大模型,可在本服务内部替换实现,不影响控制器接口。
|
||
*/
|
||
class AiService
|
||
{
|
||
/**
|
||
* 生成或刷新总结
|
||
*
|
||
* @param NoteItem $note
|
||
* @param string $summaryType
|
||
* @param bool $forceRefresh
|
||
* @return array
|
||
*/
|
||
public function createSummary(NoteItem $note, string $summaryType = 'brief', bool $forceRefresh = false): array
|
||
{
|
||
$summaryType = in_array($summaryType, ['brief', 'outline', 'todo'], true) ? $summaryType : 'brief';
|
||
$existing = NoteAiSummary::findLatestByNoteId((int) $note->id);
|
||
|
||
if ($existing && !$forceRefresh && $existing->status === 'success') {
|
||
return $this->formatSummary($existing);
|
||
}
|
||
|
||
$sourceText = trim((string) $note->content);
|
||
if ($sourceText === '') {
|
||
$sourceText = trim((string) $note->transcript_text);
|
||
}
|
||
if ($sourceText === '') {
|
||
throw new \Exception('笔记内容为空,无法生成总结', 400);
|
||
}
|
||
|
||
$generated = $this->generateSummaryPayload($sourceText, $summaryType);
|
||
$now = time();
|
||
|
||
$summary = $existing ?: new NoteAiSummary();
|
||
if (!$existing) {
|
||
$summary->note_id = (int) $note->id;
|
||
$summary->created_at = $now;
|
||
}
|
||
|
||
$summary->summary_type = $summaryType;
|
||
$summary->summary_text = $generated['summary_text'];
|
||
$summary->todo_list = json_encode($generated['todo_list'], JSON_UNESCAPED_UNICODE);
|
||
$summary->keywords = json_encode($generated['keywords'], JSON_UNESCAPED_UNICODE);
|
||
$summary->status = 'success';
|
||
$summary->error_message = '';
|
||
$summary->updated_at = $now;
|
||
$summary->save();
|
||
|
||
$note->summary_status = 'success';
|
||
$note->updated_at = $now;
|
||
$note->save();
|
||
|
||
return $this->formatSummary($summary);
|
||
}
|
||
|
||
/**
|
||
* 读取总结
|
||
*
|
||
* @param int $noteId
|
||
* @return array
|
||
* @throws \Exception
|
||
*/
|
||
public function getSummary(int $noteId): array
|
||
{
|
||
$summary = NoteAiSummary::findLatestByNoteId($noteId);
|
||
if (!$summary) {
|
||
throw new \Exception('该笔记暂无总结结果', 404);
|
||
}
|
||
|
||
return $this->formatSummary($summary);
|
||
}
|
||
|
||
/**
|
||
* 生成规则版总结
|
||
*
|
||
* @param string $text
|
||
* @param string $summaryType
|
||
* @return array
|
||
*/
|
||
private function generateSummaryPayload(string $text, string $summaryType): array
|
||
{
|
||
$sentences = $this->splitSentences($text);
|
||
$summarySentences = array_slice($sentences, 0, $summaryType === 'outline' ? 5 : 3);
|
||
$summaryText = implode("\n", array_map(function ($item, $index) use ($summaryType) {
|
||
if ($summaryType === 'outline') {
|
||
return sprintf('%d. %s', $index + 1, $item);
|
||
}
|
||
return $item;
|
||
}, $summarySentences, array_keys($summarySentences)));
|
||
|
||
$todoList = $this->extractTodoList($text);
|
||
$keywords = $this->extractKeywords($text);
|
||
|
||
if ($summaryType === 'todo' && !empty($todoList)) {
|
||
$summaryText = "待办事项:\n" . implode("\n", array_map(function ($item, $index) {
|
||
return sprintf('%d. %s', $index + 1, $item);
|
||
}, $todoList, array_keys($todoList)));
|
||
}
|
||
|
||
return [
|
||
'summary_text' => $summaryText !== '' ? $summaryText : mb_substr($text, 0, 200),
|
||
'todo_list' => $todoList,
|
||
'keywords' => $keywords,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 切分句子
|
||
*
|
||
* @param string $text
|
||
* @return array
|
||
*/
|
||
private function splitSentences(string $text): array
|
||
{
|
||
$normalized = preg_replace('/\s+/', ' ', trim($text));
|
||
$parts = preg_split('/[。!?!?;;\n\r]+/u', (string) $normalized);
|
||
$parts = array_values(array_filter(array_map('trim', $parts), function ($item) {
|
||
return $item !== '';
|
||
}));
|
||
|
||
return empty($parts) ? [mb_substr($normalized, 0, 200)] : $parts;
|
||
}
|
||
|
||
/**
|
||
* 提取待办
|
||
*
|
||
* @param string $text
|
||
* @return array
|
||
*/
|
||
private function extractTodoList(string $text): array
|
||
{
|
||
$lines = preg_split('/[\n\r]+/u', $text);
|
||
$todoList = [];
|
||
|
||
foreach ($lines as $line) {
|
||
$line = trim($line);
|
||
if ($line === '') {
|
||
continue;
|
||
}
|
||
if (preg_match('/^(待办|todo|todo:|TODO|TODO:|需要|安排|跟进|完成|处理)/u', $line)) {
|
||
$todoList[] = $line;
|
||
}
|
||
}
|
||
|
||
if (!empty($todoList)) {
|
||
return array_slice(array_values(array_unique($todoList)), 0, 10);
|
||
}
|
||
|
||
$sentences = $this->splitSentences($text);
|
||
$fallback = [];
|
||
foreach ($sentences as $sentence) {
|
||
if (preg_match('/(需要|安排|跟进|完成|处理|确认|整理|联系)/u', $sentence)) {
|
||
$fallback[] = $sentence;
|
||
}
|
||
}
|
||
|
||
return array_slice(array_values(array_unique($fallback)), 0, 10);
|
||
}
|
||
|
||
/**
|
||
* 提取关键词
|
||
*
|
||
* @param string $text
|
||
* @return array
|
||
*/
|
||
private function extractKeywords(string $text): array
|
||
{
|
||
preg_match_all('/[\x{4e00}-\x{9fa5}A-Za-z0-9]{2,20}/u', $text, $matches);
|
||
$words = $matches[0] ?? [];
|
||
$stopWords = ['我们', '你们', '他们', '这个', '那个', '然后', '以及', '因为', '所以', '可以', '进行', '一个', '没有', '已经', '需要', '今天', '目前', '如果', '就是'];
|
||
$countMap = [];
|
||
|
||
foreach ($words as $word) {
|
||
if (in_array($word, $stopWords, true)) {
|
||
continue;
|
||
}
|
||
$countMap[$word] = ($countMap[$word] ?? 0) + 1;
|
||
}
|
||
|
||
arsort($countMap);
|
||
return array_slice(array_keys($countMap), 0, 10);
|
||
}
|
||
|
||
/**
|
||
* 格式化总结结果
|
||
*
|
||
* @param NoteAiSummary $summary
|
||
* @return array
|
||
*/
|
||
private function formatSummary(NoteAiSummary $summary): array
|
||
{
|
||
return [
|
||
'summary_id' => (int) $summary->id,
|
||
'note_id' => (int) $summary->note_id,
|
||
'summary_type' => (string) $summary->summary_type,
|
||
'summary_text' => (string) $summary->summary_text,
|
||
'todo_list' => $this->decodeJsonList((string) $summary->todo_list),
|
||
'keywords' => $this->decodeJsonList((string) $summary->keywords),
|
||
'status' => (string) $summary->status,
|
||
'error_message' => (string) $summary->error_message,
|
||
'created_at' => (int) $summary->created_at,
|
||
'updated_at' => (int) $summary->updated_at,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 解析 JSON 列表
|
||
*
|
||
* @param string $value
|
||
* @return array
|
||
*/
|
||
private function decodeJsonList(string $value): array
|
||
{
|
||
$decoded = json_decode($value, true);
|
||
return is_array($decoded) ? $decoded : [];
|
||
}
|
||
}
|