检索逻辑修改

This commit is contained in:
董宏凯 committed 2026-05-19 15:14:51 +08:00
1 parent 294d06f22b
commit 2402a84c71
5 files changed
+346 -15

No files matched your search

+30
View File
@@ -46,6 +46,36 @@ ai_writing:
---
## 1.6 素材总量上限
素材收集专家最终保留的素材**总条数**由后端配置文件统一控制,并按检索词数**均分**到每个词。
配置位置:`offline-backend-20260512/backend/config.yaml` 的 `ai_writing` 段:
```yaml
ai_writing:
# 素材总量上限,范围 1~100,非法值回退 20
max_materials: 20
```
均分规则 —— `每个检索词的检索条数 = ⌈ max_materials / 实际检索词数 ⌉`(向上取整):
| `max_materials` | 检索词数 | 每词检索条数 |
|---|---|---|
| 20 | 1 | 20 |
| 20 | 2 | 10 |
| 20 | 3 | 7(共 21,合并后截断到 20) |
| 20 | 5 | 4 |
- 各检索词按上表条数检索 → 合并去重 → 最终统一截断到 `max_materials`。
- 向上取整是为了保证去重后素材数仍能接近上限;去重后实际条数可能略少于上限(不同词命中同一篇),属正常。
- 与 [1.5](#15-检索关键词数量) 的关系:`keyword_count` 决定**搜几个词**,`max_materials` 决定**总共要多少条**,两者相除得到每词条数。
- 涉及代码:`researcher.py` 的 `_resolve_max_materials()`(读配置 + 规整)与 `_per_keyword_limit()`(均分计算)。
> `config.yaml` 支持热加载,改完一般无需重启后端。
---
## 2. 内网接口规格
- **地址**:`http://10.106.57.180:7863/knowledge_base/search_docs_es`
@@ -0,0 +1,192 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { Editor } from "@tiptap/react";
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { cn } from "@/lib/utils";
import { useDocumentOutline } from "../../hooks/useDocumentOutline";
import type { OutlineHeading } from "../../hooks/useDocumentOutline";
interface DraftOutlineSidebarProps {
/** Tiptap 编辑器实例,用于提取标题并解析标题 DOM */
editor: Editor | null;
/** 文档滚动容器,点击目录项 / scroll-spy 时在其内部滚动与定位 */
scrollEl: HTMLElement | null;
}
/** 点击跳转时标题距容器顶部预留的间距(px) */
const SCROLL_TOP_OFFSET = 16;
/** scroll-spy:标题顶部进入此阈值内即视为「当前章节」(px) */
const ACTIVE_THRESHOLD = 80;
/**
* 目录项的展示层级:
* - 文档 H1 = 文章标题,单独作顶部标题块(不进列表)
* - 文档 H2 = 章节名称 → 列表一级项
* - 文档 H3 = 要点 → 列表二级项
*/
function displayLevel(h: OutlineHeading): 1 | 2 {
return h.level >= 3 ? 2 : 1;
}
/**
* 草稿文档左侧目录栏(仿豆包 / Word 目录窗格):
* 顶部独立标题块 + 章节/要点两级列表,支持点击平滑跳转、滚动高亮、折叠。
*/
export function DraftOutlineSidebar({ editor, scrollEl }: DraftOutlineSidebarProps) {
const headings = useDocumentOutline(editor);
const [activeId, setActiveId] = useState<string | null>(null);
const [collapsed, setCollapsed] = useState(false);
// 第一个 H1 作为文章标题,其余标题进目录列表
const { titleHeading, items } = useMemo(() => {
const titleIdx = headings.findIndex((h) => h.level === 1);
return {
titleHeading: titleIdx >= 0 ? headings[titleIdx] : null,
items: headings.filter((_, i) => i !== titleIdx),
};
}, [headings]);
const scrollToHeading = useCallback(
(pos: number, id: string) => {
if (!editor || !scrollEl) return;
const dom = editor.view.nodeDOM(pos) as HTMLElement | null;
if (!dom) return;
const elTop = dom.getBoundingClientRect().top;
const containerTop = scrollEl.getBoundingClientRect().top;
const target = scrollEl.scrollTop + (elTop - containerTop) - SCROLL_TOP_OFFSET;
scrollEl.scrollTo({ top: Math.max(0, target), behavior: "smooth" });
setActiveId(id);
},
[editor, scrollEl],
);
// scroll-spy:随文档滚动高亮当前章节(rAF 节流)
useEffect(() => {
if (!editor || !scrollEl || headings.length === 0) return;
let raf = 0;
const recompute = () => {
raf = 0;
const containerTop = scrollEl.getBoundingClientRect().top;
let current = headings[0].id;
for (const h of headings) {
const dom = editor.view.nodeDOM(h.pos) as HTMLElement | null;
if (!dom) continue;
const relTop = dom.getBoundingClientRect().top - containerTop;
// 标题在阈值内或之上 → 当前章节;首个落在阈值下方时其余皆下方,停止
if (relTop <= ACTIVE_THRESHOLD) {
current = h.id;
} else {
break;
}
}
setActiveId(current);
};
const onScroll = () => {
if (raf) return;
raf = requestAnimationFrame(recompute);
};
recompute();
scrollEl.addEventListener("scroll", onScroll, { passive: true });
return () => {
if (raf) cancelAnimationFrame(raf);
scrollEl.removeEventListener("scroll", onScroll);
};
}, [editor, scrollEl, headings]);
// 没有可导航的章节时不渲染目录栏
if (items.length === 0) return null;
// 折叠态:仅留一条窄轨 + 展开按钮
if (collapsed) {
return (
<div className="w-9 shrink-0 border-r bg-muted/10 flex flex-col items-center pt-2">
<button
type="button"
onClick={() => setCollapsed(false)}
title="展开目录"
className="size-6 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
>
<PanelLeftOpen className="size-4" />
</button>
</div>
);
}
return (
<div className="w-60 shrink-0 border-r bg-muted/10 flex flex-col overflow-hidden">
{/* 头部:目录标签 + 折叠按钮 */}
<div className="h-9 flex items-center justify-between pl-3 pr-1.5 shrink-0">
<span className="text-[11px] font-medium tracking-wide text-muted-foreground/80">目录</span>
<button
type="button"
onClick={() => setCollapsed(true)}
title="收起目录"
className="size-6 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
>
<PanelLeftClose className="size-3.5" />
</button>
</div>
{/* 文章标题块 */}
{titleHeading && (
<button
type="button"
onClick={() => scrollToHeading(titleHeading.pos, titleHeading.id)}
title={titleHeading.text}
className={cn(
"shrink-0 w-full text-left px-3 pb-2.5 pt-0.5 border-b transition-colors",
"text-[13px] font-semibold leading-snug line-clamp-2",
activeId === titleHeading.id
? "text-primary"
: "text-foreground hover:text-primary",
)}
>
{titleHeading.text}
</button>
)}
{/* 章节 / 要点列表 */}
<nav className="flex-1 overflow-y-auto py-1.5">
{items.map((h) => {
const lvl = displayLevel(h);
const isActive = h.id === activeId;
return (
<button
key={h.id}
type="button"
onClick={() => scrollToHeading(h.pos, h.id)}
title={h.text}
className={cn(
"group flex items-center w-full text-left py-1.5 pr-2 border-l-2 transition-colors",
lvl === 1 ? "pl-3 text-xs" : "pl-6 text-[11px]",
isActive
? "border-primary bg-primary/[0.07] text-primary font-medium"
: cn(
"border-transparent hover:bg-muted/50",
lvl === 1
? "text-foreground/85 font-medium hover:text-foreground"
: "text-muted-foreground hover:text-foreground",
),
)}
>
{/* 二级(要点)项前的小圆点 */}
{lvl === 2 && (
<span
className={cn(
"shrink-0 size-1 rounded-full mr-1.5",
isActive ? "bg-primary" : "bg-muted-foreground/40",
)}
/>
)}
<span className="truncate">{h.text}</span>
</button>
);
})}
</nav>
</div>
);
}
@@ -13,6 +13,7 @@ import { WritingBubbleMenu } from "./WritingBubbleMenu";
import { WritingParagraphMenu } from "./WritingParagraphMenu";
import { WritingAiMenu } from "./WritingAiMenu";
import { WritingRewritePreview } from "./WritingRewritePreview";
import { DraftOutlineSidebar } from "./DraftOutlineSidebar";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
import { applyReplace, applyInsert } from "../../utils/apply-rewrite";
@@ -45,6 +46,9 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
const [showScrollBtn, setShowScrollBtn] = useState(false);
const autoScrollRef = useRef(true);
// 编辑模式文档滚动容器:用 state 而非 ref,使目录栏在容器挂载后能拿到它
const [docScrollEl, setDocScrollEl] = useState<HTMLDivElement | null>(null);
// ── AI rewrite flow state ──────────────────────────────────────────────
const [phase, setPhase] = useState<FlowPhase>("idle");
const [panelPos, setPanelPos] = useState<PanelPos>({ x: 0, y: 0 });
@@ -271,15 +275,21 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
<div className="h-full flex flex-col overflow-hidden">
<WritingToolbar editor={editor} />
<div className="flex-1 overflow-y-auto">
<div className="max-w-2xl mx-auto px-10 pt-10">
{editor && (
<>
<WritingBubbleMenu editor={editor} onAiRewrite={openAiMenu} />
<WritingParagraphMenu editor={editor} onAiRewrite={openAiMenu} />
</>
)}
<TiptapEditor editor={editor} className="tiptap-writing-editor" />
<div className="flex-1 flex min-h-0 overflow-hidden">
{/* 左侧目录栏(仿豆包):文档标题层级导航 */}
<DraftOutlineSidebar editor={editor} scrollEl={docScrollEl} />
{/* 文档滚动区 */}
<div ref={setDocScrollEl} className="flex-1 overflow-y-auto">
<div className="max-w-2xl mx-auto px-10 pt-10">
{editor && (
<>
<WritingBubbleMenu editor={editor} onAiRewrite={openAiMenu} />
<WritingParagraphMenu editor={editor} onAiRewrite={openAiMenu} />
</>
)}
<TiptapEditor editor={editor} className="tiptap-writing-editor" />
</div>
</div>
</div>
@@ -0,0 +1,72 @@
import { useEffect, useState } from "react";
import type { Editor } from "@tiptap/react";
/** 文档目录中的一个标题项 */
export interface OutlineHeading {
/** React key + scroll-spy 标识,按 ProseMirror 位置生成 */
id: string;
/** 标题层级,仅保留 1~3 */
level: 1 | 2 | 3;
/** 标题纯文本 */
text: string;
/** 标题节点在 ProseMirror 文档中的位置,用于解析 DOM 元素 */
pos: number;
}
/** 仅纳入目录的最大标题层级(H1~H3) */
const MAX_LEVEL = 3;
/** 内容变化后重建目录的防抖间隔(ms) */
const DEBOUNCE_MS = 200;
/**
* 从 Tiptap 编辑器实时提取 H1~H3 标题,构成文档目录。
*
* 首次挂载立即构建一次;之后监听 `editor.on('update')`,按 DEBOUNCE_MS 防抖重建,
* 避免逐字输入时频繁全量遍历。
*/
export function useDocumentOutline(editor: Editor | null): OutlineHeading[] {
const [headings, setHeadings] = useState<OutlineHeading[]>([]);
useEffect(() => {
if (!editor) {
setHeadings([]);
return;
}
const compute = () => {
const out: OutlineHeading[] = [];
editor.state.doc.descendants((node, pos) => {
if (node.type.name !== "heading") return true;
const level = node.attrs.level as number;
if (level >= 1 && level <= MAX_LEVEL) {
out.push({
id: `heading-${pos}`,
level: level as 1 | 2 | 3,
text: node.textContent.trim() || "(无标题)",
pos,
});
}
return true;
});
setHeadings(out);
};
// 首次立即构建
compute();
// 内容变化时防抖重建
let timer: ReturnType<typeof setTimeout> | undefined;
const onUpdate = () => {
clearTimeout(timer);
timer = setTimeout(compute, DEBOUNCE_MS);
};
editor.on("update", onUpdate);
return () => {
clearTimeout(timer);
editor.off("update", onUpdate);
};
}, [editor]);
return headings;
}
@@ -18,7 +18,10 @@ from ._utils import llm_json
logger = logging.getLogger(__name__)
_MAX_MATERIALS = 12
# 素材总量上限边界:config.yaml 的 ai_writing.max_materials,非法值回退默认 20
_DEFAULT_MAX_MATERIALS = 20
_MIN_MAX_MATERIALS = 1
_MAX_MAX_MATERIALS = 100
# 素材检索关键词数量边界:用户可指定 1~10 个,非法值回退默认 6
_DEFAULT_KEYWORD_COUNT = 6
@@ -34,6 +37,26 @@ def _resolve_keyword_count(raw: object) -> int:
return max(1, min(_MAX_KEYWORD_COUNT, n))
def _resolve_max_materials(raw: object) -> int:
"""把 config.yaml 配置的素材总量上限规整到 [1, 100],非法值回退默认 20。"""
try:
n = int(raw) # type: ignore[arg-type]
except (TypeError, ValueError):
return _DEFAULT_MAX_MATERIALS
return max(_MIN_MAX_MATERIALS, min(_MAX_MAX_MATERIALS, n))
def _per_keyword_limit(max_materials: int, total_keywords: int) -> int:
"""把素材总量上限均分到每个检索词上的检索条数(向上取整)。
向上取整保证合并去重后素材数仍能接近总量上限;最终会再统一截断到上限。
total_keywords <= 0 时回退为总量上限本身。
"""
if total_keywords <= 0:
return max_materials
return (max_materials + total_keywords - 1) // total_keywords
def _notice(title: str, detail: str, raw: str = "") -> dict:
"""构造一个非致命告警事件,前端在时间线显示「标题 + 真实原因」,不打断写作流程。
@@ -67,6 +90,8 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
ai_writing_config = cfg.get("ai_writing_config", {}) or {}
keyword_model: str | None = ai_writing_config.get("keyword_model") or None
rank_model: str | None = ai_writing_config.get("rank_model") or None
# 素材总量上限(config.yaml: ai_writing.max_materials),按检索词数均分到每个词
max_materials = _resolve_max_materials(ai_writing_config.get("max_materials"))
async def _emit(message: str) -> None:
if sse_queue is None:
@@ -103,6 +128,8 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
search_limit = max(search_limit, len(keywords))
search_keywords = keywords[:search_limit]
total = len(search_keywords)
# 每个检索词分到的检索条数 = 总量上限 / 检索词数(向上取整)
per_keyword = _per_keyword_limit(max_materials, total)
await _emit(f"检索词已生成,开始检索 {total} 个方向…")
# 并发搜索,但每完成一个就推送进度(计数在单线程 asyncio 下无需加锁)
@@ -111,7 +138,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
async def _search_one(kw: str) -> list[SearchResult]:
nonlocal completed
try:
return await provider.search(kw, max_results=3)
return await provider.search(kw, max_results=per_keyword)
finally:
completed += 1
if completed < total:
@@ -159,7 +186,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# Step 3: 相关度排序(如素材较多,让 LLM 排序)
await _emit("检索完成,正在筛选排序素材…")
rank_error = ""
if len(raw_materials) > _MAX_MATERIALS:
if len(raw_materials) > max_materials:
materials_list = "\n".join(
f"- ID: {m['id']} 标题: {m['title']} 摘要: {m['content'][:100]}"
for m in raw_materials
@@ -167,7 +194,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
rank_result = await llm_json(
RANK_MATERIALS_SYSTEM,
RANK_MATERIALS_USER.format(user_intent=intent, materials_list=materials_list),
fallback={"ranked_ids": [m["id"] for m in raw_materials[:_MAX_MATERIALS]], "summary": f"共搜索到{len(raw_materials)}条素材"},
fallback={"ranked_ids": [m["id"] for m in raw_materials[:max_materials]], "summary": f"共搜索到{len(raw_materials)}条素材"},
config=config,
model_name=rank_model,
)
@@ -180,10 +207,10 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
for m in raw_materials:
if m["id"] not in sorted_ids_set:
sorted_materials.append(m)
materials = sorted_materials[:_MAX_MATERIALS]
materials = sorted_materials[:max_materials]
summary = rank_result.get("summary", f"共搜索到{len(raw_materials)}条素材,精选{len(materials)}条")
else:
materials = sorted(raw_materials, key=lambda m: m.get("relevance_score", 0), reverse=True)[:_MAX_MATERIALS]
materials = sorted(raw_materials, key=lambda m: m.get("relevance_score", 0), reverse=True)[:max_materials]
summary = f"共搜索到{len(raw_materials)}条素材,精选{len(materials)}条"
# 空结果兜底:未检索到任何素材时给出明确文案,前端据此弹出「无数据继续」选项