generate_examples.js 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. // 为雅思英语单词批量补充例句(写入 Words.ExampleSentence)
  2. // 策略:
  3. // 1) 复用:97% 的雅思词在现有词典(BookID=110, 47401词)或其它词书中已有例句,
  4. // 直接复用其 ExampleSentence,并按难度筛选为 2简单(A2)+2中等(B1/B2)+1难(C1)。
  5. // 2) 生成:仅对完全没例句的少量词,用本机 Ollama(qwen3) 本地生成,不依赖外网。
  6. // 输出格式对齐六级:{"word":..,"CEFR_Level":..,"Sentences":[{"Sentence":..,"Translate":..,"Level":..}]}
  7. //
  8. // 用法:
  9. // node generate_examples.js # 复用+本地生成
  10. // REUSE_ONLY=1 node generate_examples.js # 只做复用(跳过 Ollama),可先跑这步看覆盖
  11. // LIMIT=50 node generate_examples.js # 只处理前 N 个未生成词(验证)
  12. // OLLAMA_MODEL=qwen3.8:27b-mlx BATCH=8 node generate_examples.js
  13. import { query } from '../../../src/util/db.js';
  14. import axios from 'axios';
  15. import fs from 'fs';
  16. import path from 'path';
  17. import { fileURLToPath } from 'url';
  18. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  19. const PROGRESS_FILE = path.join(__dirname, '.ielts_examples_progress.json');
  20. const BOOK_IDS = [212, 213, 214, 215, 216, 217];
  21. const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434/api/generate';
  22. const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'qwen3.8:27b-mlx';
  23. const BATCH = Number(process.env.BATCH || 8); // Ollama 每批词数
  24. const LIMIT = Number(process.env.LIMIT || 0); // >0 只处理前 N 个
  25. const REUSE_ONLY = process.env.REUSE_ONLY === '1';
  26. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  27. // ---------- 难度筛选:2 A2 + 2 B1/B2 + 1 C1,不足则就近补 ----------
  28. function pickSentences(sentences) {
  29. const valid = (sentences || []).filter((s) => s && s.Sentence);
  30. const byLevel = {};
  31. for (const s of valid) {
  32. const l = (s.Level || 'B1').toUpperCase();
  33. (byLevel[l] = byLevel[l] || []).push(s);
  34. }
  35. const chosen = [];
  36. const take = (levels, n) => {
  37. for (const lv of levels) {
  38. while (n > 0 && byLevel[lv] && byLevel[lv].length) {
  39. chosen.push(byLevel[lv].shift());
  40. n--;
  41. }
  42. if (n <= 0) break;
  43. }
  44. return n;
  45. };
  46. take(['A2', 'A1'], 2); // 简单
  47. take(['B1', 'B2'], 2); // 中等
  48. take(['C1', 'C2'], 1); // 难
  49. const order = ['A2', 'A1', 'B1', 'B2', 'C1', 'C2'];
  50. const rem = [];
  51. for (const lv of order) if (byLevel[lv]) rem.push(...byLevel[lv]);
  52. while (chosen.length < 5 && rem.length) chosen.push(rem.shift());
  53. return chosen.slice(0, 5).map((s) => ({
  54. Sentence: s.Sentence,
  55. Translate: s.Translate || '',
  56. Level: (s.Level || 'B1').toUpperCase(),
  57. }));
  58. }
  59. function buildExampleSentence(word, cefr, sentences) {
  60. return JSON.stringify({
  61. word,
  62. CEFR_Level: (cefr || 'B1').toUpperCase(),
  63. Sentences: sentences,
  64. });
  65. }
  66. // ---------- 复用源:优先 BookID=110 大词典 ----------
  67. async function buildSourceMap() {
  68. const rows = await query(
  69. `select Word, ExampleSentence, BookID from kylx365_db.Words where ExampleSentence is not null and ExampleSentence!=''`
  70. );
  71. const map = new Map();
  72. for (const r of rows) {
  73. const key = r.Word.toLowerCase();
  74. const cur = map.get(key);
  75. if (!cur) map.set(key, r);
  76. else if (cur.BookID !== 110 && r.BookID === 110) map.set(key, r); // 大词典优先
  77. }
  78. return map;
  79. }
  80. function parseSourceExample(raw) {
  81. try {
  82. const o = JSON.parse(raw);
  83. const sents = o.Sentences || o.sentences || [];
  84. if (!sents.length) return null;
  85. return { cefr: o.CEFR_Level || o.cefr, sentences: sents };
  86. } catch {
  87. return null;
  88. }
  89. }
  90. // ---------- 本地 Ollama 生成 ----------
  91. async function callOllama(userPrompt) {
  92. const resp = await axios.post(
  93. OLLAMA_URL,
  94. { model: OLLAMA_MODEL, format: 'json', stream: false, think: false, prompt: userPrompt },
  95. { timeout: 300000 }
  96. );
  97. const data = resp.data || {};
  98. let text = data.response || '';
  99. if (!text && data.thinking) text = data.thinking; // 思考模式兜底
  100. text = (text || '').replace(/^```(?:json)?\s*\n/i, '').replace(/\n```\s*$/i, '');
  101. return text;
  102. }
  103. function buildGenPrompt(words) {
  104. const lines = words.map((w, i) => `${i + 1}. ${w.Word} | ${w.Translate || ''}`);
  105. return `Generate example sentences for each English word (with Chinese meaning).
  106. Per word: exactly 5 sentences — 2 at CEFR A2 (simple), 2 at B1-B2 (medium), 1 at C1 (relatively hard).
  107. Each sentence must use the word naturally and correctly per its meaning. Add a concise Chinese translation. Also give the word's approximate CEFR level.
  108. Return JSON only: {"items":[{"word":"emperor","cefr":"B1","sentences":[{"sentence":"...","translate":"...","level":"A2"}, ...5]}]}
  109. The "word" field must exactly match the input word. Include every input word once.
  110. Input words (word | meaning):
  111. ${lines.join('\n')}`;
  112. }
  113. function loadProgress() {
  114. try {
  115. return JSON.parse(fs.readFileSync(PROGRESS_FILE, 'utf8'));
  116. } catch {
  117. return { done: [] };
  118. }
  119. }
  120. function saveProgress(p) {
  121. fs.writeFileSync(PROGRESS_FILE, JSON.stringify(p));
  122. }
  123. async function main() {
  124. let words = await query(
  125. `select ID, Word, Translate from kylx365_db.Words where BookID in (${BOOK_IDS.join(',')}) and (ExampleSentence is null or ExampleSentence='') order by BookID, ID`
  126. );
  127. if (LIMIT > 0) words = words.slice(0, LIMIT);
  128. console.log(`待处理雅思词行数: ${words.length}`);
  129. const progress = loadProgress();
  130. const doneIds = new Set(progress.done);
  131. words = words.filter((w) => !doneIds.has(w.ID));
  132. console.log(`本次新增处理: ${words.length}(已跳过 ${doneIds.size})`);
  133. const srcMap = await buildSourceMap();
  134. console.log(`复用源词条数: ${srcMap.size}`);
  135. let reuseOk = 0,
  136. genOk = 0,
  137. fail = 0;
  138. const genQueue = [];
  139. // 第一步:尽量复用
  140. for (const w of words) {
  141. const src = srcMap.get(w.Word.toLowerCase());
  142. const parsed = src ? parseSourceExample(src.ExampleSentence) : null;
  143. if (parsed && parsed.sentences.length) {
  144. const picked = pickSentences(parsed.sentences);
  145. if (picked.length >= 1) {
  146. const json = buildExampleSentence(w.Word, parsed.cefr, picked);
  147. await query('update kylx365_db.Words set ExampleSentence=? where ID=?', [json, w.ID]);
  148. reuseOk++;
  149. progress.done.push(w.ID);
  150. continue;
  151. }
  152. }
  153. if (!REUSE_ONLY) genQueue.push(w);
  154. else {
  155. fail++;
  156. console.warn(` ✗ 无复用源: ${w.Word}`);
  157. }
  158. }
  159. saveProgress(progress);
  160. console.log(`复用完成: ${reuseOk},待生成: ${genQueue.length}`);
  161. // 第二步:本地 Ollama 生成剩余
  162. if (genQueue.length) {
  163. const batches = [];
  164. for (let i = 0; i < genQueue.length; i += BATCH) batches.push(genQueue.slice(i, i + BATCH));
  165. console.log(`Ollama 生成批次数: ${batches.length}`);
  166. for (let bi = 0; bi < batches.length; bi++) {
  167. const b = batches[bi];
  168. const label = `gen batch ${bi + 1}/${batches.length} (${b.map((w) => w.Word).join(',')})`;
  169. let ok = false;
  170. for (let attempt = 1; attempt <= 3 && !ok; attempt++) {
  171. try {
  172. const raw = await callOllama(buildGenPrompt(b));
  173. let parsed = JSON.parse(raw);
  174. const items = parsed.items || parsed;
  175. const m = new Map();
  176. for (const it of items) if (it && it.word) m.set(it.word.toLowerCase(), it);
  177. for (const w of b) {
  178. const it = m.get(w.Word.toLowerCase());
  179. if (it && it.sentences && it.sentences.length >= 5) {
  180. const sents = it.sentences.slice(0, 5).map((s) => ({
  181. Sentence: s.sentence,
  182. Translate: s.translate || '',
  183. Level: (s.level || 'B1').toUpperCase(),
  184. }));
  185. const json = buildExampleSentence(w.Word, it.cefr, sents);
  186. await query('update kylx365_db.Words set ExampleSentence=? where ID=?', [json, w.ID]);
  187. progress.done.push(w.ID);
  188. genOk++;
  189. } else {
  190. console.warn(` ✗ 生成缺句: ${w.Word} (got ${(it?.sentences || []).length})`);
  191. }
  192. }
  193. saveProgress(progress);
  194. ok = true;
  195. console.log(`✓ ${label} (genOk=${genOk})`);
  196. } catch (err) {
  197. console.error(`✗ ${label} 尝试${attempt}失败: ${err?.message || err}`);
  198. await sleep(3000 * attempt);
  199. }
  200. }
  201. if (!ok) fail += b.length;
  202. await sleep(500);
  203. }
  204. }
  205. console.log(`\n完成。reuseOk=${reuseOk}, genOk=${genOk}, fail=${fail}, 累计已生成=${progress.done.length}`);
  206. }
  207. main()
  208. .then(() => process.exit(0))
  209. .catch((e) => {
  210. console.error(e);
  211. process.exit(1);
  212. });