fix(mira): reply @mira indicator + RAG match 寬鬆化

leo 試用反饋:

bug:reply 中 @mira 「看起來無效」
- 真正 root cause: trigger 確實 fire,但 thinking indicator 沒亮起 → leo 沒看到回應
  以為失敗(其實 mira 後台跑 30s+ 才回)
- 修:MiraTriggerContext 把 thinking trigger 抽成 context callback
  PostComposer / PostEditor / BlockEditor / ReplyLine 五個 @mira 觸發點
  都呼叫 triggerThinking?.(docPageName) 啟動 5s polling

bug:RAG 抓不到 entity(leo 寫《李飛飛的視界之旅》沒被識別)
- 之前只看 type=index-entry,且嚴格 substring。新建 wiki 還沒 index-entry
  時就漏;entity 名稱跟貼文文字稍微不同也漏
- 修 fetchRelevantWikiContext:
  - 平行撈 index-entry + wiki-page 兩種,合併 entity set
  - 雙向匹配:entity 完整 substring postContent(嚴格) OR
    entity 內 3+ 字片段在 postContent 出現(寬鬆,中文友善)
  - top 5(從 3 增加),給 mira 更多上下文
This commit is contained in:
2026-05-16 11:43:28 +08:00
parent 94368ec981
commit d6bff9d551
+86 -35
View File
@@ -3,10 +3,17 @@
// Mira 河道 — 社群貼文式設計(FB / X 風格)
// SDD: polaris/mira/.agents/specs/mira-app/design.md §5.3
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { useEffect, useState, useCallback, useRef, useMemo, createContext, useContext } from 'react';
import { MarkdownView } from '../_shared/markdown';
import '../mira.css';
// Context:把「mira 已觸發要顯示 thinking + 啟動 polling」設定 callback 傳到深層 reply / edit 元件
// 不用 prop drill。對應 P0 #5b bug fixleo 反饋:reply 中 @mira 沒看到 indicator → 以為失敗)
const MiraTriggerContext = createContext<((pageName: string) => void) | null>(null);
function useMiraTrigger() {
return useContext(MiraTriggerContext);
}
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
const KBDB_BASE = 'https://kbdb.finally.click';
const KBDB_INGEST = 'https://kbdb-ingest.arcrun.dev';
@@ -101,26 +108,30 @@ export default function MiraPage() {
return () => { cancelled = true; };
}, []);
// 把 thinking trigger 抽成共用 callbackPostComposer 跟所有 reply / edit 子元件用同一份)
const triggerThinking = useCallback((pageName: string) => {
setAiThinking(prev => new Set(prev).add(pageName));
// 90 秒後 hard timeoutMira 思考 + KBDB write 通常 10-45 秒,給寬限)
// 卡片內 useEffect 偵測 AI reply 真的進來會主動呼叫 onAiResponded 清掉
setTimeout(() => {
setAiThinking(prev => {
if (!prev.has(pageName)) return prev;
const n = new Set(prev);
n.delete(pageName);
return n;
});
}, 90000);
}, []);
return (
<MiraTriggerContext.Provider value={triggerThinking}>
<div className="mira-app">
<div className="mira-content">
{me && (
<PostComposer
me={me}
onPosted={reload}
onAiTriggered={(pageName) => {
setAiThinking(prev => new Set(prev).add(pageName));
// 90 秒後 hard timeoutMira 思考 + KBDB write 通常 10-45 秒,給寬限)
// 卡片內 useEffect 偵測 AI reply 真的進來會主動呼叫 onAiResponded 清掉
setTimeout(() => {
setAiThinking(prev => {
if (!prev.has(pageName)) return prev;
const n = new Set(prev);
n.delete(pageName);
return n;
});
}, 90000);
}}
onAiTriggered={triggerThinking}
/>
)}
@@ -153,6 +164,7 @@ export default function MiraPage() {
)}
</div>
</div>
</MiraTriggerContext.Provider>
);
}
@@ -165,30 +177,61 @@ function hasMiraMention(text: string): boolean {
}
// 撈相關 wiki context 給 mira reply 用(簡單 RAG
// 策略:拿全部 index-entry blocks → 用 entity 名稱 keyword match against post content
// → 取 top 3 把整篇 markdown 餵進 prompt(每篇通常 < 1KB3 篇還在 token 預算內)
// 策略:拿全部 index-entry + wiki-page,雙向 substring match against post content
// → 取 top 5 餵進 prompt
async function fetchRelevantWikiContext(apiKey: string, postContent: string): Promise<string> {
try {
const res = await fetch(`${KBDB_BASE}/blocks?type=index-entry&limit=200`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!res.ok) return '';
const data = await res.json() as { blocks?: Array<{ id: string; page_name: string | null; content: string }> };
const all = data.blocks ?? [];
if (all.length === 0) return '';
const headers = { Authorization: `Bearer ${apiKey}` };
// 平行撈 index-entry(含 markdown 摘要)跟 wiki-pagecontent 是 entity 名稱)
const [idxRes, pageRes] = await Promise.all([
fetch(`${KBDB_BASE}/blocks?type=index-entry&limit=200`, { headers }),
fetch(`${KBDB_BASE}/blocks?type=wiki-page&source=ai-canon-wiki&limit=200`, { headers }),
]);
type B = { id: string; page_name: string | null; content: string };
const idxData = idxRes.ok ? await idxRes.json() as { blocks?: B[] } : { blocks: [] };
const pageData = pageRes.ok ? await pageRes.json() as { blocks?: B[] } : { blocks: [] };
// 收集 unique entity 名稱(從 index-entry page_name 跟 wiki-page content+ 對應 markdown
const entityMap = new Map<string, string>(); // entity name -> 顯示用 markdown context
for (const b of (idxData.blocks ?? [])) {
const entity = (b.page_name ?? '').replace(/^index-/, '').trim();
if (entity && !entityMap.has(entity)) entityMap.set(entity, b.content);
}
for (const b of (pageData.blocks ?? [])) {
const entity = (b.content ?? '').trim();
if (entity && !entityMap.has(entity)) {
// wiki-page content 只有 entity 名稱,沒摘要 — 給最簡 placeholder
entityMap.set(entity, `(已建 wiki-page,但尚無 index-entry 摘要)`);
}
}
if (entityMap.size === 0) return '';
// 雙向 substring match
// - entity 名稱 substring of postContent,或
// - postContent 中任 4+ 字 sliding window substring of entity(中文場景容忍)
const lower = postContent.toLowerCase();
// 評分:page_name 剝 `index-` prefix = entity 名稱,看 postContent 是否含
const scored = all
.map(b => {
const entity = (b.page_name ?? '').replace(/^index-/, '').trim();
const score = entity && lower.includes(entity.toLowerCase()) ? entity.length : 0;
return { entity, content: b.content, score };
})
.filter(x => x.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 3);
if (scored.length === 0) return '';
return scored.map(s => `### entity: ${s.entity}\n${s.content}`).join('\n\n');
const scored: Array<{ entity: string; content: string; score: number }> = [];
for (const [entity, content] of entityMap) {
const e = entity.toLowerCase();
let score = 0;
// 直接 substring(嚴格)
if (lower.includes(e)) score = e.length * 2;
// 較寬鬆:entity 內 ≥ 3 字片段在 postContent 出現
else if (e.length >= 3) {
for (let i = 0; i + 3 <= e.length; i++) {
const frag = e.slice(i, i + Math.min(4, e.length - i));
if (frag.length >= 3 && lower.includes(frag)) {
score = Math.max(score, frag.length);
break;
}
}
}
if (score > 0) scored.push({ entity, content, score });
}
scored.sort((a, b) => b.score - a.score);
const top = scored.slice(0, 5);
if (top.length === 0) return '';
return top.map(s => `### entity: ${s.entity}\n${s.content}`).join('\n\n');
} catch (e) {
console.warn('[fetchRelevantWikiContext] failed', e);
return '';
@@ -978,6 +1021,7 @@ function PostEditor({
const [draft, setDraft] = useState(initial);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
const triggerThinking = useMiraTrigger();
const save = async () => {
if (saving) return;
@@ -1031,6 +1075,7 @@ function PostEditor({
void triggerAiReply({
apiKey, postContent: trimmed, parentBlockId: firstId, pageName,
});
triggerThinking?.(pageName);
}
}
} catch (e) {
@@ -1107,6 +1152,7 @@ function BlockLine({
const [draft, setDraft] = useState(block.content ?? '');
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
const triggerThinking = useMiraTrigger();
const startEdit = () => { setDraft(block.content ?? ''); setMode('edit'); setErr(null); };
const startReply = () => { setDraft(''); setMode('reply'); setErr(null); };
@@ -1130,6 +1176,7 @@ function BlockLine({
void triggerAiReply({
apiKey, postContent: draft, parentBlockId: block.id, pageName: docPageName,
});
triggerThinking?.(docPageName);
}
setMode('view');
} catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
@@ -1166,6 +1213,7 @@ function BlockLine({
void triggerAiReply({
apiKey, postContent: trimmed, parentBlockId: data.data.id, pageName: docPageName,
});
triggerThinking?.(docPageName);
}
setMode('view'); setDraft('');
} catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
@@ -1235,6 +1283,7 @@ function ReplyLine({
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
const isAI = reply.source?.startsWith('ai-') ?? false;
const triggerThinking = useMiraTrigger();
const startEdit = () => { setDraft(reply.content ?? ''); setMode('edit'); setErr(null); };
const startReply = () => { setDraft(''); setMode('reply'); setErr(null); };
@@ -1257,6 +1306,7 @@ function ReplyLine({
void triggerAiReply({
apiKey, postContent: draft, parentBlockId: reply.id, pageName: docPageName,
});
triggerThinking?.(docPageName);
}
setMode('view');
} catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
@@ -1293,6 +1343,7 @@ function ReplyLine({
void triggerAiReply({
apiKey, postContent: trimmed, parentBlockId: data.data.id, pageName: docPageName,
});
triggerThinking?.(docPageName);
}
setMode('view'); setDraft('');
} catch (e) { setErr(e instanceof Error ? e.message : String(e)); }