client = $client ?? new OpenAICompatibleClient($this->clientConfig()); $this->retryQueue = $retryQueue ?? new LLMRetryQueue(); } public function generate(string $query): array { if (!$this->client->isConfigured()) { return $this->fallback($query, 'LLM API is not configured.'); } try { $result = $this->retryQueue->run( fn (): array => $this->client->chatJson($this->messages($query), [ 'model' => config('LLMapi.search_keywords.model', config('LLMapi.metadata.model')), 'temperature' => config('LLMapi.search_keywords.temperature', 0.1), 'max_tokens' => config('LLMapi.search_keywords.max_tokens', 300), 'stream' => false, 'response_format' => config('LLMapi.search_keywords.response_format', ['type' => 'json_object']), 'thinking' => config('LLMapi.search_keywords.thinking', ['type' => 'disabled']), 'request_id' => 'search-keywords-' . substr(hash('sha256', $query), 0, 32), ]), config('LLMapi.search_keywords.retry', config('LLMapi.metadata.retry', [])) ); } catch (Throwable $exception) { return $this->fallback($query, $exception->getMessage()); } $keywords = $this->keywords($result); if ($keywords === []) { return $this->fallback($query, 'LLM returned no usable keywords.'); } return [ 'enabled' => true, 'attempted' => true, 'error' => null, 'keywords' => $keywords, 'query' => implode(' ', $keywords), 'model' => config('LLMapi.search_keywords.model', config('LLMapi.metadata.model')), ]; } private function messages(string $query): array { return [ [ 'role' => 'system', 'content' => implode("\n", [ '你是历史档案检索关键词生成助手。', '任务:把用户的自然语言问题改写为适合 BM25 全文检索的关键词。', '优先输出档案中可能出现的英文专名、政策名、事件名、人物名、地点名、缩写和年份。', '如果用户输入中文,请翻译或扩展为可能出现在英文档案中的关键词。', '只返回 JSON 对象,不要 Markdown,不要解释。', 'JSON 格式:{"keywords":["keyword1","keyword2"],"query":"keyword1 keyword2"}。', 'keywords 数量 3-12 个;不要编造过于具体而输入中没有依据的事实。', ]), ], [ 'role' => 'user', 'content' => json_encode(['query' => $query], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ], ]; } private function clientConfig(): array { $config = config('LLMapi.default', []); $config['timeout'] = config('LLMapi.search_keywords.timeout', 12); $config['connect_timeout'] = config('LLMapi.search_keywords.connect_timeout', 5); return $config; } private function keywords(array $result): array { $keywords = []; if (isset($result['keywords']) && is_array($result['keywords'])) { $keywords = $result['keywords']; } elseif (isset($result['query']) && is_string($result['query'])) { $keywords = preg_split('/\s+/', $result['query']) ?: []; } return array_values(array_unique(array_filter(array_map( static fn (mixed $value): string => trim((string) $value), $keywords )))); } private function fallback(string $query, string $error): array { return [ 'enabled' => true, 'attempted' => false, 'error' => $error, 'keywords' => [], 'query' => $query, 'model' => null, ]; } }