检索逻辑修改
This commit is contained in:
1 parent
294d06f22b
commit
2402a84c71
5 files changed
+346
-15
No files matched your search
@@ -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;
|
||||
}
|
||||
+33
-6
@@ -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)}条"
|
||||
|
||||
# 空结果兜底:未检索到任何素材时给出明确文案,前端据此弹出「无数据继续」选项
|
||||
|
||||
Reference in new issue
Block a user