PromptService.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. <?php
  2. namespace App\Services;
  3. use App\Models\PromptTemplate;
  4. use Illuminate\Support\Facades\Log;
  5. class PromptService
  6. {
  7. private const DEFAULT_QUESTION_PROMPT_NAME = 'question_generation_default';
  8. private const DEFAULT_QUESTION_PROMPT_TYPE = 'question_generation';
  9. private const DEFAULT_ENRICH_PROMPT_NAME = 'question_enrich_default';
  10. private const DEFAULT_ENRICH_PROMPT_TYPE = 'question_enrich';
  11. private const DEFAULT_SOLUTION_PROMPT_NAME = 'question_solution_regen_default';
  12. private const DEFAULT_SOLUTION_PROMPT_TYPE = 'question_solution_regen';
  13. /**
  14. * 获取提示词列表
  15. */
  16. public function listPrompts(?string $type = null, ?string $active = null): array
  17. {
  18. if ($type === null || $type === self::DEFAULT_QUESTION_PROMPT_TYPE) {
  19. $this->ensureDefaultQuestionPrompt();
  20. }
  21. if ($type === null || $type === self::DEFAULT_ENRICH_PROMPT_TYPE) {
  22. $this->ensureDefaultEnrichPrompt();
  23. }
  24. if ($type === null || $type === self::DEFAULT_SOLUTION_PROMPT_TYPE) {
  25. $this->ensureDefaultSolutionPrompt();
  26. }
  27. $query = PromptTemplate::query();
  28. if ($type) {
  29. $query->where('template_type', $type);
  30. }
  31. if ($active !== null) {
  32. $query->where('is_active', $active === 'yes' || $active === true);
  33. }
  34. return $query->orderByDesc('updated_at')
  35. ->get()
  36. ->map(fn (PromptTemplate $prompt) => $this->mapPrompt($prompt))
  37. ->values()
  38. ->all();
  39. }
  40. /**
  41. * 保存提示词
  42. */
  43. public function savePrompt(array $data): array
  44. {
  45. try {
  46. $templateName = (string) ($data['template_name'] ?? '');
  47. if ($templateName === '') {
  48. return ['success' => false, 'message' => '模板名称不能为空'];
  49. }
  50. $payload = [
  51. 'template_type' => $data['template_type'] ?? 'question_generation',
  52. 'template_content' => $data['template_content'] ?? '',
  53. 'variables' => $this->parseJsonField($data['variables'] ?? []),
  54. 'description' => $data['description'] ?? null,
  55. 'tags' => $this->parseJsonField($data['tags'] ?? []),
  56. 'is_active' => ($data['is_active'] ?? 'yes') === 'yes' || ($data['is_active'] === true),
  57. ];
  58. PromptTemplate::updateOrCreate(
  59. ['template_name' => $templateName],
  60. $payload
  61. );
  62. return ['success' => true, 'message' => '提示词已保存'];
  63. } catch (\Exception $e) {
  64. Log::error('保存提示词异常', [
  65. 'error' => $e->getMessage()
  66. ]);
  67. return ['success' => false, 'message' => '保存失败'];
  68. }
  69. }
  70. public function deletePrompt(string $templateName): bool
  71. {
  72. return PromptTemplate::where('template_name', $templateName)->delete() > 0;
  73. }
  74. public function importFromArray(array $prompts): array
  75. {
  76. $imported = 0;
  77. $updated = 0;
  78. $errors = [];
  79. foreach ($prompts as $prompt) {
  80. $templateName = (string) ($prompt['template_name'] ?? $prompt['name'] ?? '');
  81. if ($templateName === '') {
  82. continue;
  83. }
  84. $payload = [
  85. 'template_name' => $templateName,
  86. 'template_type' => $prompt['template_type'] ?? $prompt['type'] ?? 'question_generation',
  87. 'template_content' => $prompt['template_content'] ?? '',
  88. 'variables' => $this->parseJsonField($prompt['variables'] ?? []),
  89. 'description' => $prompt['description'] ?? null,
  90. 'tags' => $this->parseJsonField($prompt['tags'] ?? []),
  91. 'is_active' => ($prompt['is_active'] ?? 'yes') === 'yes' || ($prompt['is_active'] === true),
  92. ];
  93. try {
  94. $existing = PromptTemplate::query()->where('template_name', $templateName)->first();
  95. PromptTemplate::updateOrCreate(['template_name' => $templateName], $payload);
  96. $existing ? $updated++ : $imported++;
  97. } catch (\Throwable $e) {
  98. $errors[] = $templateName . ': ' . $e->getMessage();
  99. }
  100. }
  101. return [
  102. 'success' => true,
  103. 'imported' => $imported,
  104. 'updated' => $updated,
  105. 'errors' => $errors,
  106. ];
  107. }
  108. public function getPrompt(string $templateName): ?array
  109. {
  110. $prompt = PromptTemplate::where('template_name', $templateName)->first();
  111. return $prompt ? $this->mapPrompt($prompt) : null;
  112. }
  113. public function getPromptContent(string $templateName): ?string
  114. {
  115. $prompt = PromptTemplate::where('template_name', $templateName)->first();
  116. return $prompt?->template_content;
  117. }
  118. /**
  119. * 获取默认提示词模板
  120. */
  121. public function getDefaultPromptTemplate(): string
  122. {
  123. return '你是资深的中学数学命题专家,请为{knowledge_point}知识点生成高质量题目。
  124. 【核心要求】
  125. 1. 题目必须符合{grade_level}年级水平
  126. 2. 难度分布:基础({basic_ratio}%) + 中等({intermediate_ratio}%) + 拔高({advanced_ratio}%)
  127. 3. 题型分配:选择题({choice}道) + 填空题({fill}道) + 解答题({solution}道)
  128. 【难度提示(来源于卷子位置)】
  129. - 若提供题目编号/位置:{question_index} / {question_position_hint}
  130. - 可据此对难度系数做初步判断:卷首偏基础,卷中偏中等,卷末偏拔高
  131. 【技能覆盖】
  132. {skill_coverage}
  133. 【图示处理】
  134. - 如果题目涉及图形/示意图/坐标系/几何草图,必须在题干内内嵌一段完整的 <svg> 标签来还原图形;不要使用外链图片、base64 或占位符。
  135. - SVG 要包含明确的宽高(建议 260~360 像素),只使用基础图元(line、rect、circle、polygon、path、text),并给出必要的坐标、角点和标注文本。
  136. - 确保题干文本描述与 SVG 一致,例如“如图所示”后紧跟 SVG,且 SVG 放在题干末尾即可被前端直接渲染。
  137. 【质量标准】
  138. - 准确性:100%正确
  139. - 多样性:避免重复
  140. - 梯度性:难度递进合理
  141. - 实用性:贴近实际应用
  142. 【输出格式】
  143. {
  144. "total": {count},
  145. "questions": [
  146. {
  147. "id": "唯一标识",
  148. "stem": "题干",
  149. "answer": "标准答案",
  150. "solution": ["详细解答1", "详细解答2", "详细解答3"],
  151. "difficulty": 0.6,
  152. "question_type": "choice/fill/answer",
  153. "skills": ["技能点1", "技能点2"],
  154. "knowledge_points": ["{knowledge_point}"]
  155. }
  156. ]
  157. }';
  158. }
  159. public function getDefaultEnrichPromptTemplate(): string
  160. {
  161. return '你是一名“数学题目完善助手”。给定原题干与可选图片外链,请补全题目关键信息并做轻量修订。
  162. 要求:
  163. - 只输出 JSON
  164. - 必须包含字段:stem, options, answer, solution, question_type, difficulty, knowledge_points, solution_steps
  165. - 可选字段:abilities(能力/技能点数组)
  166. - stem 只做轻微修订(排版/符号/错别字),不得改题意
  167. - 若提供图片外链(image_urls),可结合图片理解题意,但不要生成 SVG
  168. - question_type 仅允许:choice / fill / answer
  169. - answer 类题目必须提供 solution_steps(分步评分),每步包含 score 与 kp_codes
  170. - knowledge_points 为题目级知识点列表
  171. 材料内容(含题干与图片):
  172. {content}
  173. 输出 JSON 示例:
  174. {
  175. "stem": "...",
  176. "options": {"A": "...", "B": "..."},
  177. "answer": "...",
  178. "solution": "...",
  179. "question_type": "answer",
  180. "difficulty": 0.6,
  181. "knowledge_points": ["A01"],
  182. "solution_steps": [
  183. {"step_index": 1, "title": "...", "content": "...", "score": 4, "kp_codes": ["A01"]}
  184. ],
  185. "abilities": ["计算能力", "分析能力"]
  186. }';
  187. }
  188. public function getDefaultSolutionPromptTemplate(): string
  189. {
  190. return '你是一名“中学数学解题专家”。给定题干、正确答案与可选图片外链,请生成与答案一致的详细解题过程。
  191. 要求:
  192. - 只输出 JSON
  193. - 必须包含字段:solution, steps
  194. - solution 为简洁的整体思路(不写空话)
  195. - steps 为数组,每步包含:step_index, title, content, score, kp_codes
  196. - steps 必须给出可操作的计算/推理过程,避免“思想/方法类空话”
  197. - 若题型为选择/填空,也要给出关键推理步骤,但 steps 不能省略核心计算
  198. - 严格保证最终结论与 provided_answer 一致
  199. - 若提供图片外链(image_urls),可结合图片理解题意,但不要生成 SVG
  200. 题干:
  201. {stem}
  202. 正确答案:
  203. {provided_answer}
  204. 图片外链(如有):
  205. {image_urls}
  206. 输出 JSON 示例:
  207. {
  208. "solution": "整体解题思路概述",
  209. "steps": [
  210. {"step_index": 1, "title": "列式", "content": "...", "score": 4, "kp_codes": ["A01"]},
  211. {"step_index": 2, "title": "求解", "content": "...", "score": 6, "kp_codes": ["A01"]}
  212. ]
  213. }';
  214. }
  215. /**
  216. * 检查服务健康状态
  217. */
  218. public function checkHealth(): bool
  219. {
  220. return true;
  221. }
  222. private function parseJsonField($value): array
  223. {
  224. if (is_array($value)) {
  225. return $value;
  226. }
  227. if (is_string($value) && $value !== '') {
  228. try {
  229. $decoded = json_decode($value, true, 512, JSON_THROW_ON_ERROR);
  230. return is_array($decoded) ? $decoded : [];
  231. } catch (\Throwable) {
  232. return [];
  233. }
  234. }
  235. return [];
  236. }
  237. private function ensureDefaultQuestionPrompt(): void
  238. {
  239. $exists = PromptTemplate::query()
  240. ->where('template_name', self::DEFAULT_QUESTION_PROMPT_NAME)
  241. ->exists();
  242. if ($exists) {
  243. return;
  244. }
  245. $this->savePrompt([
  246. 'template_name' => self::DEFAULT_QUESTION_PROMPT_NAME,
  247. 'template_type' => self::DEFAULT_QUESTION_PROMPT_TYPE,
  248. 'template_content' => $this->getDefaultPromptTemplate(),
  249. 'variables' => json_encode([
  250. 'knowledge_point',
  251. 'grade_level',
  252. 'basic_ratio',
  253. 'intermediate_ratio',
  254. 'advanced_ratio',
  255. 'choice',
  256. 'fill',
  257. 'solution',
  258. 'skill_coverage',
  259. 'question_index',
  260. 'question_position_hint',
  261. 'count',
  262. ], JSON_UNESCAPED_UNICODE),
  263. 'description' => '默认知识点题目生成模板(题型+难度分布)',
  264. 'tags' => json_encode(['default', 'knowledge_point'], JSON_UNESCAPED_UNICODE),
  265. 'is_active' => 'yes',
  266. ]);
  267. }
  268. private function ensureDefaultEnrichPrompt(): void
  269. {
  270. $exists = PromptTemplate::query()
  271. ->where('template_name', self::DEFAULT_ENRICH_PROMPT_NAME)
  272. ->exists();
  273. if ($exists) {
  274. return;
  275. }
  276. $this->savePrompt([
  277. 'template_name' => self::DEFAULT_ENRICH_PROMPT_NAME,
  278. 'template_type' => self::DEFAULT_ENRICH_PROMPT_TYPE,
  279. 'template_content' => $this->getDefaultEnrichPromptTemplate(),
  280. 'variables' => json_encode(['content'], JSON_UNESCAPED_UNICODE),
  281. 'description' => '基于题干+图片的题目信息补全模板',
  282. 'tags' => json_encode(['default', 'enrich'], JSON_UNESCAPED_UNICODE),
  283. 'is_active' => 'yes',
  284. ]);
  285. }
  286. private function ensureDefaultSolutionPrompt(): void
  287. {
  288. $exists = PromptTemplate::query()
  289. ->where('template_name', self::DEFAULT_SOLUTION_PROMPT_NAME)
  290. ->exists();
  291. if ($exists) {
  292. return;
  293. }
  294. $this->savePrompt([
  295. 'template_name' => self::DEFAULT_SOLUTION_PROMPT_NAME,
  296. 'template_type' => self::DEFAULT_SOLUTION_PROMPT_TYPE,
  297. 'template_content' => $this->getDefaultSolutionPromptTemplate(),
  298. 'variables' => json_encode(['stem', 'provided_answer', 'image_urls'], JSON_UNESCAPED_UNICODE),
  299. 'description' => '基于答案重写解题思路的系统模板',
  300. 'tags' => json_encode(['default', 'solution'], JSON_UNESCAPED_UNICODE),
  301. 'is_active' => 'yes',
  302. ]);
  303. }
  304. private function mapPrompt(PromptTemplate $prompt): array
  305. {
  306. $variables = $prompt->variables ?? [];
  307. $tags = $prompt->tags ?? [];
  308. return [
  309. 'template_name' => $prompt->template_name,
  310. 'template_type' => $prompt->template_type,
  311. 'template_content' => $prompt->template_content,
  312. 'variables' => json_encode($variables, JSON_UNESCAPED_UNICODE),
  313. 'description' => $prompt->description,
  314. 'tags' => json_encode($tags, JSON_UNESCAPED_UNICODE),
  315. 'is_active' => $prompt->is_active ? 'yes' : 'no',
  316. 'updated_at' => $prompt->updated_at,
  317. ];
  318. }
  319. }