getSchemaBuilder()->hasTable('project_index'); } catch (Throwable) { return false; } } /** * @return array */ public function configuredSources(): array { return array_keys(config('project_search.sources', [])); } /** * @return array> */ 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 $sources * @param array $history * @return array{answer: string, results: array>, meta: array} */ 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> */ 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 $sources * @return array */ 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 */ 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 $sources * @param array $terms * @return array> */ 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> $results * @param array $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> $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}"; } }