refactor: kb & station & terminal
This commit is contained in:
@@ -110,7 +110,6 @@ class DocumentConversionService
|
||||
|
||||
$document->update([
|
||||
'markdown_path' => $markdownPath,
|
||||
'markdown_preview' => $preview,
|
||||
'conversion_status' => 'completed',
|
||||
'conversion_error' => null,
|
||||
]);
|
||||
|
||||
@@ -3,100 +3,103 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Models\User;
|
||||
use App\Models\KnowledgeBase;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 文档搜索服务
|
||||
* 负责处理文档的全文搜索和 Meilisearch 索引管理
|
||||
*/
|
||||
class DocumentSearchService
|
||||
{
|
||||
/**
|
||||
* 搜索文档
|
||||
* 使用 Laravel Scout 和 Meilisearch 进行全文搜索
|
||||
*
|
||||
* @param string $query 搜索关键词
|
||||
* @param User $user 当前用户
|
||||
* @param array $filters 额外的筛选条件
|
||||
* @param array $accessibleStationIds 权限边界的线站 IDs(空=不限制)
|
||||
* @param array $filters 用户主动筛选:
|
||||
* - station_ids: int[] 线站 ID
|
||||
* - knowledge_base_ids: int[] 知识库 ID
|
||||
* - uploaded_by: int 上传者 ID
|
||||
* @return Collection
|
||||
*/
|
||||
public function search(string $query, User $user, array $filters = []): Collection
|
||||
public function search(string $query, array $accessibleStationIds = [], array $filters = []): Collection
|
||||
{
|
||||
try {
|
||||
// 使用 Scout 进行搜索
|
||||
$kbIds = $this->resolveKnowledgeBaseIds($accessibleStationIds, $filters);
|
||||
|
||||
$searchBuilder = Document::search($query);
|
||||
|
||||
// 应用额外的筛选条件
|
||||
if (!empty($filters['type'])) {
|
||||
$searchBuilder->where('type', $filters['type']);
|
||||
}
|
||||
|
||||
if (!empty($filters['group_id'])) {
|
||||
$searchBuilder->where('group_id', $filters['group_id']);
|
||||
}
|
||||
|
||||
if (!empty($filters['uploaded_by'])) {
|
||||
$searchBuilder->where('uploaded_by', $filters['uploaded_by']);
|
||||
}
|
||||
|
||||
if (!empty($filters['knowledge_base_id'])) {
|
||||
$searchBuilder->where('knowledge_base_id', $filters['knowledge_base_id']);
|
||||
if (!empty($kbIds)) {
|
||||
$searchBuilder->query(fn ($q) => $q->whereIn('knowledge_base_id', $kbIds));
|
||||
}
|
||||
|
||||
// 执行搜索并获取结果
|
||||
$results = $searchBuilder->get();
|
||||
|
||||
// 应用用户权限过滤
|
||||
return $this->filterByUserPermissions($results, $user);
|
||||
return $searchBuilder->get();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('文档搜索失败', [
|
||||
'query' => $query,
|
||||
'user_id' => $user->id,
|
||||
'filters' => $filters,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
// 搜索失败时返回空集合
|
||||
return new Collection();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户权限过滤搜索结果
|
||||
* 确保用户只能看到有权限访问的文档
|
||||
* 解析最终可搜索的知识库 ID 列表
|
||||
*
|
||||
* @param Collection $results 搜索结果
|
||||
* @param User $user 当前用户
|
||||
* @return Collection
|
||||
* 1. 从 accessibleStationIds 得到可访问 KB(全局 KB + 关联 station 的 KB)
|
||||
* 2. 从 filters 得到用户指定的 KB(station_ids + knowledge_base_ids 并集)
|
||||
* 3. 取交集(若有 filter 指定),否则用可访问范围
|
||||
*/
|
||||
public function filterByUserPermissions(Collection $results, User $user): Collection
|
||||
private function resolveKnowledgeBaseIds(array $accessibleStationIds, array $filters): array
|
||||
{
|
||||
// 获取用户所属的所有分组 ID
|
||||
$userGroupIds = $user->groups()->pluck('groups.id')->toArray();
|
||||
// 权限边界
|
||||
$accessibleKbIds = null;
|
||||
if (!empty($accessibleStationIds)) {
|
||||
$accessibleKbIds = KnowledgeBase::where(function ($q) use ($accessibleStationIds) {
|
||||
$q->whereDoesntHave('stations')
|
||||
->orWhereHas('stations', fn ($sq) => $sq->whereIn('stations.id', $accessibleStationIds));
|
||||
})->pluck('id')->toArray();
|
||||
}
|
||||
|
||||
return $results->filter(function (Document $document) use ($userGroupIds) {
|
||||
// 全局文档对所有用户可见
|
||||
if ($document->type === 'global') {
|
||||
return true;
|
||||
}
|
||||
// 用户主动筛选
|
||||
$kbIdsFromStations = [];
|
||||
if (!empty($filters['station_ids'])) {
|
||||
$kbIdsFromStations = KnowledgeBase::where(function ($q) use ($filters) {
|
||||
$q->whereDoesntHave('stations')
|
||||
->orWhereHas('stations', fn ($sq) => $sq->whereIn('stations.id', $filters['station_ids']));
|
||||
})->pluck('id')->toArray();
|
||||
}
|
||||
|
||||
// 专用文档只对所属分组的用户可见
|
||||
if ($document->type === 'dedicated') {
|
||||
return in_array($document->group_id, $userGroupIds);
|
||||
}
|
||||
$kbIdsFromFilter = $filters['knowledge_base_ids'] ?? [];
|
||||
|
||||
return false;
|
||||
});
|
||||
if (!empty($kbIdsFromStations) && !empty($kbIdsFromFilter)) {
|
||||
$filterKbIds = array_values(array_intersect($kbIdsFromStations, $kbIdsFromFilter));
|
||||
} elseif (!empty($kbIdsFromStations)) {
|
||||
$filterKbIds = $kbIdsFromStations;
|
||||
} elseif (!empty($kbIdsFromFilter)) {
|
||||
$filterKbIds = $kbIdsFromFilter;
|
||||
} else {
|
||||
$filterKbIds = [];
|
||||
}
|
||||
|
||||
// 合并
|
||||
if ($accessibleKbIds === null) {
|
||||
return $filterKbIds; // 无权限限制
|
||||
}
|
||||
|
||||
if (empty($filterKbIds)) {
|
||||
return $accessibleKbIds; // 无主动筛选,用权限范围
|
||||
}
|
||||
|
||||
return array_values(array_intersect($filterKbIds, $accessibleKbIds)); // 取交集
|
||||
}
|
||||
|
||||
/**
|
||||
* 准备文档的可搜索数据
|
||||
* 包含完整的 Markdown 内容用于索引
|
||||
*
|
||||
* @param Document $document 文档模型
|
||||
* @return array
|
||||
*/
|
||||
public function prepareSearchableData(Document $document): array
|
||||
{
|
||||
@@ -105,8 +108,6 @@ class DocumentSearchService
|
||||
'title' => $document->title,
|
||||
'description' => $document->description,
|
||||
'markdown_content' => $document->getMarkdownContent(),
|
||||
'type' => $document->type,
|
||||
'group_id' => $document->group_id,
|
||||
'knowledge_base_id' => $document->knowledge_base_id,
|
||||
'uploaded_by' => $document->uploaded_by,
|
||||
'created_at' => $document->created_at?->timestamp,
|
||||
@@ -114,99 +115,37 @@ class DocumentSearchService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 索引文档到 Meilisearch
|
||||
* 读取 Markdown 文件并创建搜索索引
|
||||
*
|
||||
* @param Document $document 文档模型
|
||||
* @return void
|
||||
*/
|
||||
public function indexDocument(Document $document): void
|
||||
{
|
||||
try {
|
||||
// 只索引已完成转换的文档
|
||||
if (!$document->shouldBeSearchable()) {
|
||||
Log::info('文档未完成转换,跳过索引', [
|
||||
'document_id' => $document->id,
|
||||
'conversion_status' => $document->conversion_status,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 Scout 的 searchable 方法进行索引
|
||||
$document->searchable();
|
||||
|
||||
Log::info('文档索引成功', [
|
||||
'document_id' => $document->id,
|
||||
'title' => $document->title,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('文档索引失败', [
|
||||
'document_id' => $document->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
// 索引失败不影响文档的正常使用,只记录错误
|
||||
Log::error('文档索引失败', ['document_id' => $document->id, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新文档在 Meilisearch 中的索引
|
||||
*
|
||||
* @param Document $document 文档模型
|
||||
* @return void
|
||||
*/
|
||||
public function updateDocumentIndex(Document $document): void
|
||||
{
|
||||
try {
|
||||
// 如果文档应该被索引,则更新索引
|
||||
if ($document->shouldBeSearchable()) {
|
||||
$document->searchable();
|
||||
|
||||
Log::info('文档索引更新成功', [
|
||||
'document_id' => $document->id,
|
||||
'title' => $document->title,
|
||||
]);
|
||||
} else {
|
||||
// 如果文档不应该被索引(例如转换失败),则从索引中移除
|
||||
$this->removeDocumentFromIndex($document);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('文档索引更新失败', [
|
||||
'document_id' => $document->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
// 索引更新失败不影响文档的正常使用,只记录错误
|
||||
Log::error('文档索引更新失败', ['document_id' => $document->id, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Meilisearch 中移除文档索引
|
||||
*
|
||||
* @param Document $document 文档模型
|
||||
* @return void
|
||||
*/
|
||||
public function removeDocumentFromIndex(Document $document): void
|
||||
{
|
||||
try {
|
||||
// 使用 Scout 的 unsearchable 方法移除索引
|
||||
$document->unsearchable();
|
||||
|
||||
Log::info('文档索引移除成功', [
|
||||
'document_id' => $document->id,
|
||||
'title' => $document->title,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('文档索引移除失败', [
|
||||
'document_id' => $document->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
// 索引移除失败不影响文档的正常删除,只记录错误
|
||||
Log::error('文档索引移除失败', ['document_id' => $document->id, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,16 +15,7 @@ class DocumentService
|
||||
*/
|
||||
public function validateDocumentAccess(Document $document, User $user): bool
|
||||
{
|
||||
if ($document->type === 'global') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($document->type === 'dedicated') {
|
||||
$userGroupIds = $user->groups()->pluck('groups.id')->toArray();
|
||||
return in_array($document->group_id, $userGroupIds);
|
||||
}
|
||||
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,80 +2,34 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Models\KnowledgeBase;
|
||||
use App\Models\Terminal;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class KnowledgeContextService
|
||||
{
|
||||
private const MAX_CONTEXT_LENGTH = 2000;
|
||||
private const TOP_K = 5;
|
||||
|
||||
public function __construct(
|
||||
private DocumentSearchService $searchService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 搜索所有知识库中的文档
|
||||
*
|
||||
* @param string $query
|
||||
* @return array{context: string, sources: array}
|
||||
* 搜索知识库中的文档,返回 RAG 上下文
|
||||
*/
|
||||
public function search(string $query): array
|
||||
public function search(string $query, ?Terminal $terminal = null): array
|
||||
{
|
||||
$knowledgeBaseIds = KnowledgeBase::where('status', 'active')->pluck('id')->toArray();
|
||||
$stationIds = $terminal?->station_id ? [$terminal->station_id] : [];
|
||||
|
||||
if (empty($knowledgeBaseIds)) {
|
||||
return [
|
||||
'context' => '',
|
||||
'sources' => [],
|
||||
];
|
||||
$results = $this->searchService->search($query, $stationIds);
|
||||
|
||||
if ($results->isEmpty()) {
|
||||
return ['context' => '', 'sources' => []];
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用 Scout/Meilisearch 搜索并获取排名分数
|
||||
$rawResults = Document::search($query, function ($meilisearch, $query, $options) use ($knowledgeBaseIds) {
|
||||
$options['showRankingScore'] = true;
|
||||
$filter = collect($knowledgeBaseIds)
|
||||
->map(fn($id) => "knowledge_base_id = {$id}")
|
||||
->implode(' OR ');
|
||||
$options['filter'] = $filter;
|
||||
$options['limit'] = self::TOP_K;
|
||||
return $meilisearch->search($query, $options);
|
||||
})->raw();
|
||||
|
||||
$hits = $rawResults['hits'] ?? [];
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('Knowledge search failed', [
|
||||
'query' => $query,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'context' => '',
|
||||
'sources' => [],
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($hits)) {
|
||||
return [
|
||||
'context' => '',
|
||||
'sources' => [],
|
||||
];
|
||||
}
|
||||
|
||||
// 取出文档 ID 并加载 Eloquent 模型
|
||||
$hitIds = collect($hits)->pluck('id')->toArray();
|
||||
$documents = Document::whereIn('id', $hitIds)->get()->keyBy('id');
|
||||
|
||||
// 构建排名分数映射
|
||||
$rankingScores = collect($hits)->pluck('_rankingScore', 'id');
|
||||
|
||||
$context = '';
|
||||
$sources = [];
|
||||
|
||||
foreach ($hits as $hit) {
|
||||
$document = $documents->get($hit['id']);
|
||||
if (!$document) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($results as $document) {
|
||||
$snippet = $this->extractSnippet($document);
|
||||
|
||||
if (mb_strlen($context) + mb_strlen($snippet) > self::MAX_CONTEXT_LENGTH) {
|
||||
@@ -86,7 +40,6 @@ class KnowledgeContextService
|
||||
$sources[] = [
|
||||
'title' => $document->title,
|
||||
'id' => 'kb-doc-' . str_pad($document->id, 3, '0', STR_PAD_LEFT),
|
||||
'relevance' => round($rankingScores->get($document->id, 0), 2),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -96,12 +49,9 @@ class KnowledgeContextService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文档中提取摘要片段
|
||||
*/
|
||||
private function extractSnippet($document): string
|
||||
{
|
||||
$content = $document->markdown_preview ?? $document->description ?? '';
|
||||
$content = $document->getMarkdownContent() ?? $document->description ?? '';
|
||||
|
||||
if (mb_strlen($content) <= 500) {
|
||||
return "【{$document->title}】\n{$content}";
|
||||
|
||||
@@ -38,8 +38,7 @@ class SecurityLogger
|
||||
'user_email' => $user->email,
|
||||
'document_id' => $document->id,
|
||||
'document_title' => $document->title,
|
||||
'document_type' => $document->type,
|
||||
'document_group_id' => $document->group_id,
|
||||
'document_knowledge_base_id' => $document->knowledge_base_id,
|
||||
'ip_address' => $ipAddress,
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
|
||||
Reference in New Issue
Block a user