QuestionServiceApi.php 18 KB

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