QuestionServiceApi.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557
  1. <?php
  2. namespace App\Services;
  3. use Illuminate\Http\Client\PendingRequest;
  4. use Illuminate\Http\Client\RequestException;
  5. use Illuminate\Support\Collection;
  6. use Illuminate\Support\Facades\Cache;
  7. use Illuminate\Support\Facades\Http;
  8. use Illuminate\Support\Facades\Log;
  9. class QuestionServiceApi
  10. {
  11. public function __construct(
  12. protected string $baseUrl = '',
  13. protected int $timeout = 10,
  14. protected int $cacheTtl = 300,
  15. ) {
  16. $this->baseUrl = rtrim($this->baseUrl ?: config('question_bank.api_base', 'http://localhost:5015'), '/');
  17. // 确保 baseUrl 以 /api 结尾
  18. if (!str_ends_with($this->baseUrl, '/api')) {
  19. $this->baseUrl .= '/api';
  20. }
  21. $this->timeout = (int) config('question_bank.timeout', 10);
  22. $this->cacheTtl = (int) config('question_bank.cache_ttl', 300);
  23. }
  24. /**
  25. * 获取所有题目(分页)
  26. */
  27. public function listQuestions(int $page = 1, int $perPage = 50, array $filters = []): array
  28. {
  29. // 移除缓存,直接请求最新数据
  30. $query = array_filter([
  31. 'page' => $page,
  32. 'per_page' => $perPage,
  33. 'kp_code' => $filters['kp_code'] ?? null,
  34. 'difficulty' => $filters['difficulty'] ?? null,
  35. 'type' => $filters['type'] ?? null,
  36. 'skill' => $filters['skill'] ?? null,
  37. 'search' => $filters['search'] ?? null,
  38. ], fn ($value) => filled($value));
  39. // 使用更短的超时时间避免阻塞
  40. try {
  41. $response = Http::timeout(5) // 5秒超时
  42. ->get($this->baseUrl . '/questions', $query);
  43. if (!$response->successful()) {
  44. Log::warning('题目列表API调用失败', [
  45. 'status' => $response->status(),
  46. 'url' => $this->baseUrl . '/questions'
  47. ]);
  48. return [
  49. 'data' => [],
  50. 'meta' => [
  51. 'page' => $page,
  52. 'per_page' => $perPage,
  53. 'total' => 0,
  54. 'total_pages' => 0,
  55. ]
  56. ];
  57. }
  58. $response = $response->json();
  59. } catch (\Illuminate\Http\Client\ConnectionException $e) {
  60. Log::error('题目列表API连接超时', [
  61. 'error' => $e->getMessage()
  62. ]);
  63. // 返回空数据,避免页面卡死
  64. return [
  65. 'data' => [],
  66. 'meta' => [
  67. 'page' => $page,
  68. 'per_page' => $perPage,
  69. 'total' => 0,
  70. 'total_pages' => 0,
  71. ]
  72. ];
  73. }
  74. // 处理数学公式
  75. $data = $response['data'] ?? [];
  76. foreach ($data as &$question) {
  77. // 使用数学公式处理器处理题目数据
  78. $question = MathFormulaProcessor::processQuestionData($question);
  79. }
  80. return [
  81. 'data' => $data,
  82. 'meta' => $response['meta'] ?? [
  83. 'page' => $page,
  84. 'per_page' => $perPage,
  85. 'total' => is_array($response) ? count($response) : 0,
  86. 'total_pages' => 1,
  87. ],
  88. ];
  89. }
  90. /**
  91. * 获取技能点名称映射
  92. */
  93. public function getSkillNameMapping(?string $kpCode = null): array
  94. {
  95. // 如果没有指定知识点,返回空映射
  96. if (empty($kpCode)) {
  97. return [];
  98. }
  99. try {
  100. // 从知识服务API获取知识点详情,包括技能点
  101. $baseUrl = config('knowledge.base_url', 'http://localhost:5011');
  102. $url = rtrim($baseUrl, '/') . '/graph/node/' . $kpCode;
  103. $response = Http::timeout(5)
  104. ->get($url);
  105. if (!$response->successful()) {
  106. Log::warning('获取知识点详情失败', [
  107. 'kp_code' => $kpCode,
  108. 'status' => $response->status(),
  109. 'url' => $url
  110. ]);
  111. return [];
  112. }
  113. $data = $response->json();
  114. $mapping = [];
  115. // 从响应中提取技能点信息
  116. $skills = $data['skills'] ?? [];
  117. foreach ($skills as $skill) {
  118. $code = $skill['skill_code'] ?? '';
  119. $name = $skill['skill_name'] ?? $skill['cn_name'] ?? $code;
  120. if (!empty($code)) {
  121. $mapping[$code] = $name;
  122. }
  123. }
  124. Log::info('成功获取技能点映射', [
  125. 'kp_code' => $kpCode,
  126. 'skill_count' => count($mapping)
  127. ]);
  128. return $mapping;
  129. } catch (\Exception $e) {
  130. Log::warning('获取技能点名称映射失败', [
  131. 'kp_code' => $kpCode,
  132. 'error' => $e->getMessage()
  133. ]);
  134. return [];
  135. }
  136. }
  137. /**
  138. * 获取题目统计信息
  139. */
  140. public function getStatistics(array $filters = []): array
  141. {
  142. // 移除缓存,直接请求最新数据
  143. try {
  144. $query = array_filter([
  145. 'kp_code' => $filters['kp_code'] ?? null,
  146. 'difficulty' => $filters['difficulty'] ?? null,
  147. 'type' => $filters['type'] ?? null,
  148. 'skill' => $filters['skill'] ?? null,
  149. 'search' => $filters['search'] ?? null,
  150. ], fn ($value) => filled($value));
  151. $response = Http::timeout(5) // 5秒超时
  152. ->get($this->baseUrl . '/questions/statistics', $query);
  153. if (!$response->successful()) {
  154. Log::warning('统计API调用失败', [
  155. 'status' => $response->status(),
  156. 'filters' => $filters
  157. ]);
  158. return [
  159. 'total' => 0,
  160. 'by_difficulty' => [],
  161. 'by_type' => [],
  162. 'by_kp' => [],
  163. 'by_source' => [],
  164. ];
  165. }
  166. $response = $response->json();
  167. } catch (\Illuminate\Http\Client\ConnectionException $e) {
  168. Log::error('统计API连接超时', [
  169. 'error' => $e->getMessage(),
  170. 'filters' => $filters
  171. ]);
  172. return [
  173. 'total' => 0,
  174. 'by_difficulty' => [],
  175. 'by_type' => [],
  176. 'by_kp' => [],
  177. 'by_source' => [],
  178. ];
  179. }
  180. return $response ?? [
  181. 'total' => 0,
  182. 'by_difficulty' => [],
  183. 'by_type' => [],
  184. 'by_source' => [],
  185. ];
  186. }
  187. /**
  188. * 根据知识点获取题型统计
  189. */
  190. public function getQuestionTypeStatisticsByKpCode(string $kpCode): array
  191. {
  192. try {
  193. // 获取该知识点下的所有题目
  194. $response = Http::timeout(5)
  195. ->get($this->baseUrl . '/questions', ['kp_code' => $kpCode, 'per_page' => 1000]);
  196. if (!$response->successful()) {
  197. return [];
  198. }
  199. $questions = $response['data'] ?? [];
  200. $typeStats = [];
  201. // 直接使用 question_type 字段统计,而不是猜测
  202. foreach ($questions as $question) {
  203. $type = $question['type'] ?? 'CALCULATION';
  204. if (!isset($typeStats[$type])) {
  205. $typeStats[$type] = 0;
  206. }
  207. $typeStats[$type]++;
  208. }
  209. // 按数量排序
  210. arsort($typeStats);
  211. return $typeStats;
  212. } catch (\Exception $e) {
  213. Log::error('获取题型统计失败', [
  214. 'kp_code' => $kpCode,
  215. 'error' => $e->getMessage()
  216. ]);
  217. return [];
  218. }
  219. }
  220. /**
  221. * 根据 kp_code 获取题目
  222. */
  223. public function getQuestionsByKpCode(string $kpCode, int $limit = 100): array
  224. {
  225. $response = $this->request('GET', '/questions', [
  226. 'kp_code' => $kpCode,
  227. 'limit' => $limit,
  228. ]);
  229. // 处理数学公式
  230. if ($response && isset($response['data']) && is_array($response['data'])) {
  231. foreach ($response['data'] as &$question) {
  232. $question = MathFormulaProcessor::processQuestionData($question);
  233. }
  234. }
  235. return $response;
  236. }
  237. /**
  238. * 语义搜索题目
  239. */
  240. public function searchQuestions(string $query, int $limit = 20): array
  241. {
  242. $cacheKey = sprintf('question-search-%s-%d', md5($query), $limit);
  243. return Cache::remember(
  244. $cacheKey,
  245. now()->addSeconds($this->cacheTtl),
  246. function () use ($query, $limit): array {
  247. try {
  248. $response = $this->request('POST', '/questions/search', [
  249. 'query' => $query,
  250. 'limit' => $limit,
  251. ]);
  252. // 处理数学公式
  253. if ($response && is_array($response)) {
  254. $response = MathFormulaProcessor::processArray($response, [
  255. 'stem', 'content', 'question_text', 'answer', 'explanation'
  256. ]);
  257. }
  258. return $response ?? [];
  259. } catch (\Exception $e) {
  260. \Log::error('Question search failed: ' . $e->getMessage());
  261. return [];
  262. }
  263. }
  264. );
  265. }
  266. /**
  267. * 获取单个题目详情
  268. */
  269. public function getQuestionById(int $id): ?array
  270. {
  271. $cacheKey = "question-{$id}";
  272. return Cache::remember(
  273. $cacheKey,
  274. now()->addSeconds($this->cacheTtl),
  275. function () use ($id): ?array {
  276. try {
  277. $response = $this->request('GET', "/questions/{$id}");
  278. // 处理数学公式
  279. if ($response && is_array($response)) {
  280. $response = MathFormulaProcessor::processQuestionData($response);
  281. }
  282. return $response ?: null;
  283. } catch (\Exception $e) {
  284. \Log::error("Failed to get question {$id}: " . $e->getMessage());
  285. return null;
  286. }
  287. }
  288. );
  289. }
  290. /**
  291. * 获取题目详情(通过 question_id)
  292. */
  293. public function getQuestionDetail(string $questionId): array
  294. {
  295. try {
  296. // 先通过ID获取题目信息(使用批量接口)
  297. $batchResponse = Http::timeout(5)
  298. ->get($this->baseUrl . '/questions/batch', [
  299. 'ids' => $questionId
  300. ]);
  301. if (!$batchResponse->successful()) {
  302. Log::warning('批量获取题目详情失败', [
  303. 'question_id' => $questionId,
  304. 'status' => $batchResponse->status()
  305. ]);
  306. return ['data' => null];
  307. }
  308. $batchData = $batchResponse->json();
  309. $questions = $batchData['data'] ?? [];
  310. if (empty($questions)) {
  311. Log::warning('题目不存在', [
  312. 'question_id' => $questionId
  313. ]);
  314. return ['data' => null];
  315. }
  316. $question = $questions[0];
  317. // 处理数学公式
  318. $question = MathFormulaProcessor::processQuestionData($question);
  319. return ['data' => $question];
  320. } catch (\Exception $e) {
  321. Log::error('获取题目详情异常', [
  322. 'question_id' => $questionId,
  323. 'error' => $e->getMessage()
  324. ]);
  325. return ['data' => null];
  326. }
  327. }
  328. /**
  329. * 创建题目(通过 AI 生成)
  330. */
  331. public function generateQuestions(array $params): array
  332. {
  333. try {
  334. $response = $this->request('POST', '/questions/generate', $params);
  335. return $response ?? [
  336. 'success' => false,
  337. 'message' => '生成失败',
  338. ];
  339. } catch (\Exception $e) {
  340. \Log::error('Question generation failed: ' . $e->getMessage());
  341. return [
  342. 'success' => false,
  343. 'message' => '生成失败:' . $e->getMessage(),
  344. ];
  345. }
  346. }
  347. /**
  348. * 导入题目(JSON 批量导入)
  349. */
  350. public function importQuestions(array $questions): array
  351. {
  352. try {
  353. $response = $this->request('POST', '/questions/import', [
  354. 'questions' => $questions,
  355. ]);
  356. return $response ?? [
  357. 'success' => false,
  358. 'message' => '导入失败',
  359. ];
  360. } catch (\Exception $e) {
  361. \Log::error('Question import failed: ' . $e->getMessage());
  362. return [
  363. 'success' => false,
  364. 'message' => '导入失败:' . $e->getMessage(),
  365. ];
  366. }
  367. }
  368. /**
  369. * 删除题目
  370. */
  371. public function deleteQuestion(int $id): bool
  372. {
  373. try {
  374. $response = $this->request('DELETE', "/questions/{$id}");
  375. return $response['success'] ?? false;
  376. } catch (\Exception $e) {
  377. \Log::error("Failed to delete question {$id}: " . $e->getMessage());
  378. return false;
  379. }
  380. }
  381. /**
  382. * 获取知识点选项(从知识图谱服务)
  383. */
  384. public function getKnowledgePointOptions(): array
  385. {
  386. // 使用缓存来避免重复请求
  387. $cacheKey = 'knowledge-point-options';
  388. return Cache::remember(
  389. $cacheKey,
  390. now()->addHours(1), // 缓存1小时
  391. function () {
  392. try {
  393. // 使用新的知识图谱API
  394. $knowledgeApiBase = config('services.knowledge_api.base_url', 'http://localhost:5011');
  395. $response = Http::timeout(30) // 增加超时时间到30秒
  396. ->get($knowledgeApiBase . '/graph/export');
  397. if ($response->successful()) {
  398. $data = $response->json();
  399. $nodes = $data['nodes'] ?? [];
  400. // 转换为键值对格式
  401. $options = [];
  402. foreach ($nodes as $node) {
  403. $code = $node['kp_code'] ?? null;
  404. $name = $node['cn_name'] ?? null;
  405. if ($code && $name) {
  406. $options[$code] = $name;
  407. }
  408. }
  409. // 按代码排序
  410. ksort($options);
  411. \Log::info('成功获取知识点选项', ['count' => count($options)]);
  412. return $options;
  413. }
  414. \Log::warning('知识图谱API调用失败', [
  415. 'status' => $response->status(),
  416. 'url' => $knowledgeApiBase . '/graph/export'
  417. ]);
  418. } catch (\Exception $e) {
  419. \Log::error('Failed to get knowledge points: ' . $e->getMessage());
  420. }
  421. // 返回空数组作为fallback
  422. return [];
  423. }
  424. );
  425. }
  426. /**
  427. * 获取提示词模板列表
  428. */
  429. public function listPrompts(?string $type = null, ?string $active = null): array
  430. {
  431. $query = array_filter([
  432. 'type' => $type,
  433. 'active' => $active,
  434. ], fn ($value) => filled($value));
  435. try {
  436. $response = $this->request('GET', '/prompts', $query);
  437. return $response ?? [];
  438. } catch (\Exception $e) {
  439. \Log::error('Failed to get prompts: ' . $e->getMessage());
  440. return [];
  441. }
  442. }
  443. /**
  444. * 保存提示词模板
  445. */
  446. public function savePrompt(array $data): array
  447. {
  448. try {
  449. // 先尝试更新
  450. $response = $this->request('PUT', '/prompts/default', $data);
  451. // 如果更新失败(可能不存在),则尝试创建
  452. if (!isset($response['success']) || !$response['success']) {
  453. $response = $this->request('POST', '/prompts', $data);
  454. }
  455. return $response ?? [
  456. 'success' => true,
  457. 'message' => '提示词保存成功',
  458. ];
  459. } catch (\Exception $e) {
  460. \Log::error('Failed to save prompt: ' . $e->getMessage());
  461. return [
  462. 'success' => false,
  463. 'message' => '保存失败:' . $e->getMessage(),
  464. ];
  465. }
  466. }
  467. /**
  468. * @throws RequestException
  469. * @return mixed
  470. */
  471. protected function request(string $method, string $path, array $params = []): mixed
  472. {
  473. $response = $this->http()->send($method, ltrim($path, '/'), [
  474. 'query' => $method === 'GET' ? $params : null,
  475. 'json' => $method !== 'GET' ? $params : null,
  476. ]);
  477. return $response->throw()->json();
  478. }
  479. protected function http(): PendingRequest
  480. {
  481. return Http::baseUrl($this->baseUrl)
  482. ->acceptJson()
  483. ->timeout($this->timeout)
  484. ->retry(2, 200)
  485. ->withHeaders([
  486. 'User-Agent' => 'Laravel/' . (app()->version()),
  487. 'Accept' => 'application/json',
  488. ]);
  489. }
  490. }