Compare commits

..

2 Commits

Author SHA1 Message Date
posimai ce195cee72 fix(posimai-sc): 用語インデックス抽出アルゴリズムを再設計
旧アルゴリズムの問題:
- <strong> の出現位置を問わず全て用語として分割していたため
  説明文中の強調タグ(例: <strong>種類の異なる</strong>)が
  偽の用語エントリになっていた(104件中80件以上が不正)
- ヒントが助詞(は、が)や記号で始まるケースを除去できていなかった

新アルゴリズム:
- <br> で行分割し、各行の先頭 <strong> だけを用語として扱う
- 先頭より前に地の文があれば inline 強調とみなしてスキップ
- hint クリーンアップに =・は を追加
- 結果: 104件 → 26件の正常エントリのみ残留

SW v8 → v9

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 22:51:41 +09:00
posimai cd4159fec9 feat(together): TOGETHER_GEMINI_API_KEY で Brain と Gemini quota を分離
TOGETHER_GEMINI_API_KEY が設定されている場合はそちらを使い、
未設定時は既存の GEMINI_API_KEY にフォールバック。
posimai-brain と posimai-together の quota が独立して管理できるようになる。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 22:51:31 +09:00
3 changed files with 77 additions and 19 deletions

View File

@ -16,24 +16,42 @@ function shuffleInPlace(arr) {
function extractTermsFromBeginnerBox(box) { function extractTermsFromBeginnerBox(box) {
const clone = box.cloneNode(true); const clone = box.cloneNode(true);
clone.querySelector('.formula-label')?.remove(); clone.querySelector('.formula-label')?.remove();
const inner = clone.innerHTML.trim();
const parts = inner.split(/(?=<strong>)/i).map(s => s.trim()).filter(s => /^<strong>/i.test(s)); // <br> で行分割し、各行の先頭 <strong> だけを用語として扱う。
// 行中の inline <strong>(強調)は無視することで偽エントリを防ぐ。
const segments = [];
let cur = [];
for (const child of clone.childNodes) {
if (child.nodeName === 'BR') { segments.push(cur); cur = []; }
else cur.push(child);
}
if (cur.length) segments.push(cur);
const terms = []; const terms = [];
for (const p of parts) { for (const seg of segments) {
const doc = new DOMParser().parseFromString('<div class="seg">'+p+'</div>', 'text/html'); // 先頭ノードが <strong> でなければ(前に地の文がある)定義行ではない
const root = doc.querySelector('.seg'); let termNode = null;
const st = root && root.querySelector('strong'); const beforeNodes = [];
if (!st) continue; for (const node of seg) {
const term = st.textContent.trim(); if (node.nodeName === 'STRONG') { termNode = node; break; }
let hint = ''; beforeNodes.push(node);
let n = st.nextSibling;
while (n) {
if (n.nodeType === 3) hint += n.textContent;
else if (n.nodeType === 1) hint += n.textContent;
n = n.nextSibling;
} }
hint = hint.replace(/^[:\s]+/, '').trim(); if (!termNode) continue;
if (term) terms.push({ term, hint }); // 先頭 <strong> の前に地の文があれば inline 強調とみなしてスキップ
if (beforeNodes.some(n => (n.textContent || '').trim())) continue;
const term = termNode.textContent.trim();
if (!term) continue;
// termNode 以降の全ノードの textContent を結合してヒントにする
const afterIdx = seg.indexOf(termNode) + 1;
const hint = seg.slice(afterIdx)
.map(n => n.textContent || '')
.join('')
.replace(/^[:\sは]+/, '')
.trim();
terms.push({ term, hint });
} }
return terms; return terms;
} }

View File

@ -1,5 +1,5 @@
// posimai-sc SW — same-origin の静的資産のみキャッシュCDN は対象外) // posimai-sc SW — same-origin の静的資産のみキャッシュCDN は対象外)
const CACHE = 'posimai-sc-v8'; const CACHE = 'posimai-sc-v9';
const STATIC = [ const STATIC = [
'/', '/',
'/index.html', '/index.html',

View File

@ -192,6 +192,9 @@ pool.on('error', (err) => {
// ── Gemini ──────────────────────────────── // ── Gemini ────────────────────────────────
const genAI = process.env.GEMINI_API_KEY const genAI = process.env.GEMINI_API_KEY
? new GoogleGenerativeAI(process.env.GEMINI_API_KEY) : null; ? new GoogleGenerativeAI(process.env.GEMINI_API_KEY) : null;
// Together アーカイブ専用キー(未設定時は genAI にフォールバック)
const togetherGenAI = process.env.TOGETHER_GEMINI_API_KEY
? new GoogleGenerativeAI(process.env.TOGETHER_GEMINI_API_KEY) : genAI;
// ── API Key 認証 ────────────────────────── // ── API Key 認証 ──────────────────────────
@ -1984,6 +1987,43 @@ ${excerpt}
} }
}); });
// POST /ai/generate — 汎用 Gemini プロキシDigest / Think 用。認証必須・レート制限あり)
r.post('/ai/generate', authMiddleware, async (req, res) => {
if (!genAI) return res.status(503).json({ error: 'AI not configured' });
if (!checkRateLimit('ai_generate', req.userId, 30, 60 * 60 * 1000)) {
return res.status(429).json({ error: 'AI利用回数が上限に達しました。1時間後に再試行してください' });
}
const { contents, systemPrompt, config = {} } = req.body || {};
if (!Array.isArray(contents) || !contents.length) {
return res.status(400).json({ error: 'contents required' });
}
if (JSON.stringify(contents).length > 60000) {
return res.status(400).json({ error: 'contents too large' });
}
try {
const modelOpts = { model: 'gemini-2.5-flash' };
if (systemPrompt && typeof systemPrompt === 'string') {
modelOpts.systemInstruction = systemPrompt;
}
const model = genAI.getGenerativeModel(modelOpts);
const generationConfig = {
maxOutputTokens: Math.min(config.maxOutputTokens || 500, 1000),
temperature: config.temperature ?? 0.5
};
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('timeout')), 15000)
);
const result = await Promise.race([
model.generateContent({ contents, generationConfig }),
timeoutPromise
]);
res.json({ text: result.response.text() });
} catch (e) {
console.error('[ai/generate]', e.message);
res.status(500).json({ error: 'AI generation failed' });
}
});
r.post('/journal/upload', authMiddleware, (req, res) => { r.post('/journal/upload', authMiddleware, (req, res) => {
try { try {
const { base64 } = req.body || {}; const { base64 } = req.body || {};
@ -2582,12 +2622,12 @@ ${excerpt}
let summary = null; let summary = null;
let tags = []; let tags = [];
if (genAI && fullContent) { if (togetherGenAI && fullContent) {
try { try {
// 最初の ## 見出し以降を本文とみなし 4000 字を Gemini に渡す // 最初の ## 見出し以降を本文とみなし 4000 字を Gemini に渡す
const bodyStart = fullContent.search(/^#{1,2}\s/m); const bodyStart = fullContent.search(/^#{1,2}\s/m);
const excerpt = (bodyStart >= 0 ? fullContent.slice(bodyStart) : fullContent).slice(0, 4000); const excerpt = (bodyStart >= 0 ? fullContent.slice(bodyStart) : fullContent).slice(0, 4000);
const model = genAI.getGenerativeModel({ model: 'gemini-2.5-flash' }); const model = togetherGenAI.getGenerativeModel({ model: 'gemini-2.5-flash' });
const prompt = `以下の記事を分析して、JSONのみを返してくださいコードブロック不要\n\n{"summary":"1〜2文の日本語要約","tags":["タグ1","タグ2","タグ3"]}\n\n- summary: 読者が読む価値があるかを判断できる1〜2文\n- tags: 内容を表す具体的な日本語タグを2〜4個。「その他」は絶対に使わないこと。内容が不明な場合でも最も近いカテゴリを選ぶ例: AI, テクノロジー, ビジネス, 健康, 旅行, 料理, スポーツ, 政治, 経済, エンタメ, ゲーム, 科学, デザイン, ライフスタイル, 教育, 環境, 医療, 法律, 文化, 歴史)\n\n記事:\n${excerpt}`; const prompt = `以下の記事を分析して、JSONのみを返してくださいコードブロック不要\n\n{"summary":"1〜2文の日本語要約","tags":["タグ1","タグ2","タグ3"]}\n\n- summary: 読者が読む価値があるかを判断できる1〜2文\n- tags: 内容を表す具体的な日本語タグを2〜4個。「その他」は絶対に使わないこと。内容が不明な場合でも最も近いカテゴリを選ぶ例: AI, テクノロジー, ビジネス, 健康, 旅行, 料理, スポーツ, 政治, 経済, エンタメ, ゲーム, 科学, デザイン, ライフスタイル, 教育, 環境, 医療, 法律, 文化, 歴史)\n\n記事:\n${excerpt}`;
const timeoutP = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 30000)); const timeoutP = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 30000));
const result = await Promise.race([model.generateContent(prompt), timeoutP]); const result = await Promise.race([model.generateContent(prompt), timeoutP]);