You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
243 lines
7.5 KiB
243 lines
7.5 KiB
<?php |
|
|
|
namespace App\Services; |
|
|
|
use Illuminate\Support\Facades\DB; |
|
use RuntimeException; |
|
use Throwable; |
|
|
|
class ProjectSearchService |
|
{ |
|
public function __construct( |
|
private readonly OllamaService $ollama |
|
) { |
|
} |
|
|
|
public function isAvailable(): bool |
|
{ |
|
try { |
|
return DB::connection('project_search')->getSchemaBuilder()->hasTable('project_index'); |
|
} catch (Throwable) { |
|
return false; |
|
} |
|
} |
|
|
|
/** |
|
* @return array<int, string> |
|
*/ |
|
public function configuredSources(): array |
|
{ |
|
return array_keys(config('project_search.sources', [])); |
|
} |
|
|
|
/** |
|
* @return array<int, array<string, mixed>> |
|
*/ |
|
public function listSources(): array |
|
{ |
|
$sources = config('project_search.sources', []); |
|
|
|
return collect($sources) |
|
->map(fn (array $meta, string $id) => [ |
|
'id' => $id, |
|
'label' => $meta['label'] ?? $id, |
|
'smb' => $meta['smb'] ?? null, |
|
]) |
|
->values() |
|
->all(); |
|
} |
|
|
|
/** |
|
* @param array<int, string> $sources |
|
* @param array<int, array{role: string, content: string}> $history |
|
* @return array{answer: string, results: array<int, array<string, mixed>>, meta: array<string, mixed>} |
|
*/ |
|
public function ask(string $query, array $sources, int $limit, array $history = []): array |
|
{ |
|
if (! $this->isAvailable()) { |
|
throw new RuntimeException('Indeks project_search belum tersedia di PostgreSQL.'); |
|
} |
|
|
|
$sources = $this->normalizeSources($sources); |
|
$limit = max(1, min($limit, (int) config('project_search.max_limit', 50))); |
|
$terms = $this->extractTerms($query); |
|
|
|
$results = $this->searchIndex($sources, $terms, $limit); |
|
$answer = $this->formatAnswer($query, $results, $history); |
|
|
|
return [ |
|
'answer' => $answer, |
|
'results' => $results, |
|
'meta' => [ |
|
'query' => $query, |
|
'sources' => $sources, |
|
'result_count' => count($results), |
|
'terms' => $terms, |
|
], |
|
]; |
|
} |
|
|
|
/** |
|
* @return array<int, array<string, mixed>> |
|
*/ |
|
public function indexStatus(): array |
|
{ |
|
if (! $this->isAvailable()) { |
|
return []; |
|
} |
|
|
|
return DB::connection('project_search') |
|
->table('project_index_runs') |
|
->orderByDesc('id') |
|
->limit(20) |
|
->get() |
|
->map(fn ($row) => [ |
|
'id' => (int) $row->id, |
|
'source_id' => $row->source_id, |
|
'status' => $row->status, |
|
'started_at' => $row->started_at, |
|
'finished_at' => $row->finished_at, |
|
'rows_upserted' => (int) $row->rows_upserted, |
|
'error_message' => $row->error_message, |
|
]) |
|
->all(); |
|
} |
|
|
|
/** |
|
* @param array<int, string> $sources |
|
* @return array<int, string> |
|
*/ |
|
private function normalizeSources(array $sources): array |
|
{ |
|
$allowed = $this->configuredSources(); |
|
$sources = array_values(array_filter(array_map('trim', $sources))); |
|
|
|
if ($sources === []) { |
|
return config('project_search.default_sources', ['project']); |
|
} |
|
|
|
foreach ($sources as $source) { |
|
if (! in_array($source, $allowed, true)) { |
|
throw new RuntimeException("Source tidak dikenal: {$source}"); |
|
} |
|
} |
|
|
|
return $sources; |
|
} |
|
|
|
/** |
|
* @return array<int, string> |
|
*/ |
|
private function extractTerms(string $query): array |
|
{ |
|
$normalized = mb_strtolower($query); |
|
$normalized = preg_replace('/[^\p{L}\p{N}\s]+/u', ' ', $normalized) ?? $normalized; |
|
$parts = preg_split('/\s+/', trim($normalized)) ?: []; |
|
|
|
$stopwords = ['carikan', 'cari', 'projek', 'project', 'folder', 'lokasi', 'dan', 'atau', 'the', 'a']; |
|
$terms = array_values(array_filter($parts, fn (string $part) => strlen($part) >= 3 && ! in_array($part, $stopwords, true))); |
|
|
|
return array_slice(array_unique($terms), 0, 5); |
|
} |
|
|
|
/** |
|
* @param array<int, string> $sources |
|
* @param array<int, string> $terms |
|
* @return array<int, array<string, mixed>> |
|
*/ |
|
private function searchIndex(array $sources, array $terms, int $limit): array |
|
{ |
|
$builder = DB::connection('project_search') |
|
->table('project_index') |
|
->whereIn('source_id', $sources) |
|
->where('is_directory', true); |
|
|
|
if ($terms !== []) { |
|
$builder->where(function ($query) use ($terms) { |
|
foreach ($terms as $term) { |
|
$like = '%'.$term.'%'; |
|
$query->orWhere('client_name', 'ilike', $like) |
|
->orWhere('relative_path', 'ilike', $like); |
|
} |
|
}); |
|
} |
|
|
|
return $builder |
|
->orderByDesc('year_folder') |
|
->orderByDesc('mtime') |
|
->limit($limit) |
|
->get() |
|
->map(fn ($row) => [ |
|
'source_id' => $row->source_id, |
|
'path' => $row->full_smb_path, |
|
'relative_path' => $row->relative_path, |
|
'client_name' => $row->client_name, |
|
'year_folder' => $row->year_folder, |
|
'mtime' => $row->mtime, |
|
]) |
|
->all(); |
|
} |
|
|
|
/** |
|
* @param array<int, array<string, mixed>> $results |
|
* @param array<int, array{role: string, content: string}> $history |
|
*/ |
|
private function formatAnswer(string $query, array $results, array $history = []): string |
|
{ |
|
if ($results === []) { |
|
return 'Tidak ditemukan folder proyek yang cocok di indeks metadata. Pastikan indexer sudah dijalankan.'; |
|
} |
|
|
|
if (! $this->ollama->isConfigured()) { |
|
return $this->fallbackAnswer($results); |
|
} |
|
|
|
$context = collect($results) |
|
->map(function (array $row, int $index) { |
|
$year = $row['year_folder'] ?? '-'; |
|
$mtime = $row['mtime'] ?? '-'; |
|
|
|
return ($index + 1).'. '.$row['path'].' | tahun='.$year.' | mtime='.$mtime; |
|
}) |
|
->implode("\n"); |
|
|
|
$system = 'Anda asisten pencarian folder proyek Geonet. Jawab dalam Bahasa Indonesia. ' |
|
.'Gunakan HANYA daftar hasil berikut. Jangan mengarang path. ' |
|
.'Sebutkan lokasi UNC path dan tahun folder bila ada.'; |
|
|
|
$user = "Pertanyaan: {$query}\n\nHasil indeks:\n{$context}"; |
|
|
|
$messages = [['role' => 'system', 'content' => $system]]; |
|
|
|
foreach ($history as $item) { |
|
$role = $item['role'] ?? ''; |
|
$content = $item['content'] ?? ''; |
|
if (! in_array($role, ['user', 'assistant'], true) || $content === '') { |
|
continue; |
|
} |
|
$messages[] = ['role' => $role, 'content' => $content]; |
|
} |
|
|
|
$messages[] = ['role' => 'user', 'content' => $user]; |
|
|
|
try { |
|
return $this->ollama->chat($messages); |
|
} catch (Throwable) { |
|
return $this->fallbackAnswer($results); |
|
} |
|
} |
|
|
|
/** |
|
* @param array<int, array<string, mixed>> $results |
|
*/ |
|
private function fallbackAnswer(array $results): string |
|
{ |
|
$count = count($results); |
|
$lines = collect($results) |
|
->take(5) |
|
->map(fn (array $row) => '- '.$row['path']) |
|
->implode("\n"); |
|
|
|
return "Ditemukan {$count} folder proyek:\n{$lines}"; |
|
} |
|
}
|
|
|