GeoNetAgent, LDAPWeb, server-audit, server-connection
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

<?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}";
}
}