feat: add note module and route fixes

This commit is contained in:
nepiedg
2026-04-17 07:48:44 +00:00
parent 866ddb046b
commit 84e1c0daac
25 changed files with 2196 additions and 38 deletions
+228
View File
@@ -0,0 +1,228 @@
<?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 : [];
}
}