后端:新增自包含 deerflow.knowledge 模块(models/repository/schemas/ extractor/sources/markdown_vault/manifest/obsidian_adapter/exporter/ search/service),通过 deps.py 装配为 app.state.knowledge_service, 挂载 /api/knowledge 路由;config.yaml 增加 knowledge 配置段, AppConfig 注册 KnowledgeConfig,ORM 注册 4 张 knowledge_* 表。 前端:新增知识库页面(KnowledgeBasePage/KnowledgeGraphPage)、 core/knowledge api/hooks/types、聊天侧 knowledge-save-trigger, 侧边栏增加「知识库」入口。 知识库为全局共享,不做 user_id 隔离,写入记录 created_by/updated_by 作为审计。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
91 lines
3.0 KiB
Python
91 lines
3.0 KiB
Python
"""Keyword / frontmatter / Markdown-content search (first release).
|
|
|
|
Vector and hybrid modes are reserved for phase 2 (see the dev doc §9.9 / §13);
|
|
for now ``vector`` and ``hybrid`` transparently fall back to keyword search so
|
|
the API contract is stable. Scoring weights title > tags > summary > body.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Any
|
|
|
|
from deerflow.knowledge.repository import KnowledgeRepository
|
|
|
|
_TOKEN_RE = re.compile(r"[\w一-鿿]+", re.UNICODE)
|
|
|
|
|
|
def _tokens(text: str) -> list[str]:
|
|
return [t.lower() for t in _TOKEN_RE.findall(text or "")]
|
|
|
|
|
|
def _snippet(content: str, terms: list[str], *, width: int = 160) -> str:
|
|
body = re.sub(r"\s+", " ", content or "").strip()
|
|
low = body.lower()
|
|
for term in terms:
|
|
idx = low.find(term)
|
|
if idx >= 0:
|
|
start = max(0, idx - width // 3)
|
|
end = min(len(body), start + width)
|
|
prefix = "…" if start > 0 else ""
|
|
suffix = "…" if end < len(body) else ""
|
|
return f"{prefix}{body[start:end]}{suffix}"
|
|
return body[:width] + ("…" if len(body) > width else "")
|
|
|
|
|
|
async def search_notes(
|
|
repo: KnowledgeRepository,
|
|
query: str,
|
|
*,
|
|
mode: str = "keyword",
|
|
limit: int = 8,
|
|
candidate_cap: int = 500,
|
|
) -> list[dict[str, Any]]:
|
|
"""Score notes against ``query`` and return the top ``limit`` matches."""
|
|
terms = _tokens(query)
|
|
if not terms:
|
|
return []
|
|
|
|
# Pull a candidate window (non-archived). Phase 2 will replace this with
|
|
# an index; for the first release in-Python scoring is sufficient.
|
|
candidates, _total = await repo.list_notes(limit=candidate_cap, offset=0)
|
|
|
|
scored: list[tuple[float, dict[str, Any]]] = []
|
|
for note in candidates:
|
|
title = (note.get("title") or "").lower()
|
|
summary = (note.get("summary") or "").lower()
|
|
content = (note.get("content_md") or "").lower()
|
|
tags = " ".join(note.get("tags") or []).lower()
|
|
score = 0.0
|
|
for term in terms:
|
|
if term in title:
|
|
score += 3.0
|
|
if term in tags:
|
|
score += 2.0
|
|
if term in summary:
|
|
score += 2.0
|
|
score += min(content.count(term), 5) * 0.5
|
|
if score <= 0:
|
|
continue
|
|
scored.append((score, note))
|
|
|
|
scored.sort(key=lambda x: x[0], reverse=True)
|
|
max_score = scored[0][0] if scored else 1.0
|
|
results = []
|
|
for score, note in scored[:limit]:
|
|
results.append(
|
|
{
|
|
"note_id": note["id"],
|
|
"chunk_id": None,
|
|
"title": note.get("title"),
|
|
"summary": note.get("summary"),
|
|
"snippet": _snippet(note.get("content_md") or note.get("summary") or "", terms),
|
|
"score": round(score / max_score, 4),
|
|
"tags": note.get("tags") or [],
|
|
"source_type": note.get("source_type"),
|
|
"updated_at": note.get("updated_at"),
|
|
"sources": [],
|
|
}
|
|
)
|
|
return results
|