fix: highlight full knowledge search terms
This commit is contained in:
@@ -388,20 +388,24 @@ function matchedLabel(values: string[]) {
|
|||||||
return labels.join("、");
|
return labels.join("、");
|
||||||
}
|
}
|
||||||
|
|
||||||
function highlightSnippet(text: string) {
|
function highlightSnippet(text: string, terms: string[] = []) {
|
||||||
const keyword = contentSearch.keyword.trim();
|
const keywords = Array.from(new Set([...terms, contentSearch.keyword.trim()].filter(Boolean)))
|
||||||
if (!keyword) return [{ text, match: false }];
|
.sort((left, right) => right.length - left.length);
|
||||||
|
if (!keywords.length) return [{ text, match: false }];
|
||||||
const parts: Array<{ text: string; match: boolean }> = [];
|
const parts: Array<{ text: string; match: boolean }> = [];
|
||||||
const source = text || "";
|
const source = text || "";
|
||||||
const lowerSource = source.toLowerCase();
|
const lowerSource = source.toLowerCase();
|
||||||
const lowerKeyword = keyword.toLowerCase();
|
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
while (cursor < source.length) {
|
while (cursor < source.length) {
|
||||||
const index = lowerSource.indexOf(lowerKeyword, cursor);
|
const next = keywords
|
||||||
if (index < 0) {
|
.map((keyword) => ({ keyword, index: lowerSource.indexOf(keyword.toLowerCase(), cursor) }))
|
||||||
|
.filter((item) => item.index >= 0)
|
||||||
|
.sort((left, right) => left.index - right.index || right.keyword.length - left.keyword.length)[0];
|
||||||
|
if (!next) {
|
||||||
parts.push({ text: source.slice(cursor), match: false });
|
parts.push({ text: source.slice(cursor), match: false });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
const { index, keyword } = next;
|
||||||
if (index > cursor) parts.push({ text: source.slice(cursor, index), match: false });
|
if (index > cursor) parts.push({ text: source.slice(cursor, index), match: false });
|
||||||
parts.push({ text: source.slice(index, index + keyword.length), match: true });
|
parts.push({ text: source.slice(index, index + keyword.length), match: true });
|
||||||
cursor = index + keyword.length;
|
cursor = index + keyword.length;
|
||||||
@@ -493,7 +497,7 @@ async function confirmAction(
|
|||||||
{{ item.knowledgeName }} · V{{ item.versionNo }} · {{ item.sectionKey }} · 命中{{ matchedLabel(item.matchedIn) }}
|
{{ item.knowledgeName }} · V{{ item.versionNo }} · {{ item.sectionKey }} · 命中{{ matchedLabel(item.matchedIn) }}
|
||||||
</p>
|
</p>
|
||||||
<p class="content-search-hit-snippet">
|
<p class="content-search-hit-snippet">
|
||||||
<template v-for="(part, index) in highlightSnippet(item.snippet)" :key="index">
|
<template v-for="(part, index) in highlightSnippet(item.snippet, item.highlightTerms)" :key="index">
|
||||||
<mark v-if="part.match">{{ part.text }}</mark><span v-else>{{ part.text }}</span>
|
<mark v-if="part.match">{{ part.text }}</mark><span v-else>{{ part.text }}</span>
|
||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ export interface KnowledgeContentSearchItem {
|
|||||||
sectionKey: string;
|
sectionKey: string;
|
||||||
title: string;
|
title: string;
|
||||||
snippet: string;
|
snippet: string;
|
||||||
|
highlightTerms?: string[];
|
||||||
matchedIn: Array<"title" | "content">;
|
matchedIn: Array<"title" | "content">;
|
||||||
hasChunks: boolean;
|
hasChunks: boolean;
|
||||||
isCurrentVersion: boolean;
|
isCurrentVersion: boolean;
|
||||||
|
|||||||
@@ -657,6 +657,9 @@ def _merge_content_search_rows(rows, keyword: str, chunk_section_ids: set[int])
|
|||||||
for matched in _matched_in(str(row["title"] or ""), str(row["content"] or ""), keyword):
|
for matched in _matched_in(str(row["title"] or ""), str(row["content"] or ""), keyword):
|
||||||
if matched not in current["matchedIn"]:
|
if matched not in current["matchedIn"]:
|
||||||
current["matchedIn"].append(matched)
|
current["matchedIn"].append(matched)
|
||||||
|
for term in _highlight_terms(str(row["title"] or ""), str(row["content"] or ""), keyword):
|
||||||
|
if term not in current["highlightTerms"]:
|
||||||
|
current["highlightTerms"].append(term)
|
||||||
current["itemType"] = _primary_item_type(current["itemTypes"])
|
current["itemType"] = _primary_item_type(current["itemTypes"])
|
||||||
return list(grouped.values())
|
return list(grouped.values())
|
||||||
|
|
||||||
@@ -688,6 +691,7 @@ def _content_search_item(row, keyword: str, has_chunks: bool) -> dict:
|
|||||||
"title": title,
|
"title": title,
|
||||||
"snippet": _snippet(f"{title}\n{content}", keyword),
|
"snippet": _snippet(f"{title}\n{content}", keyword),
|
||||||
"matchedIn": _matched_in(title, content, keyword),
|
"matchedIn": _matched_in(title, content, keyword),
|
||||||
|
"highlightTerms": _highlight_terms(title, content, keyword),
|
||||||
"itemTypes": [row["item_type"]],
|
"itemTypes": [row["item_type"]],
|
||||||
"hasChunks": has_chunks,
|
"hasChunks": has_chunks,
|
||||||
"isCurrentVersion": True,
|
"isCurrentVersion": True,
|
||||||
@@ -712,6 +716,28 @@ def _matched_in(title: str, content: str, keyword: str) -> list[str]:
|
|||||||
return result or ["content"]
|
return result or ["content"]
|
||||||
|
|
||||||
|
|
||||||
|
def _highlight_terms(title: str, content: str, keyword: str) -> list[str]:
|
||||||
|
keyword = keyword.strip()
|
||||||
|
if not keyword:
|
||||||
|
return []
|
||||||
|
text = f"{title}\n{content}".lower()
|
||||||
|
terms = [keyword]
|
||||||
|
if keyword.lower() not in text:
|
||||||
|
terms.extend(_keyword_parts(keyword))
|
||||||
|
result: list[str] = []
|
||||||
|
for term in sorted({item.strip() for item in terms if item.strip()}, key=len, reverse=True):
|
||||||
|
if term.lower() in text and term not in result:
|
||||||
|
result.append(term)
|
||||||
|
return result or [keyword]
|
||||||
|
|
||||||
|
|
||||||
|
def _keyword_parts(keyword: str) -> list[str]:
|
||||||
|
parts = [part for part in keyword.replace(",", " ").replace(",", " ").split() if part]
|
||||||
|
if parts:
|
||||||
|
return parts
|
||||||
|
return list(keyword) if len(keyword) <= 8 else []
|
||||||
|
|
||||||
|
|
||||||
def _snippet(text: str, keyword: str, radius: int = 90) -> str:
|
def _snippet(text: str, keyword: str, radius: int = 90) -> str:
|
||||||
compact = " ".join(text.split())
|
compact = " ".join(text.split())
|
||||||
if not compact:
|
if not compact:
|
||||||
|
|||||||
@@ -359,3 +359,25 @@ def test_content_search_can_exclude_closed_knowledge():
|
|||||||
assert included["data"]["total"] == 1
|
assert included["data"]["total"] == 1
|
||||||
assert included["data"]["items"][0]["canAgentUse"] is False
|
assert included["data"]["items"][0]["canAgentUse"] is False
|
||||||
assert excluded["data"]["total"] == 0
|
assert excluded["data"]["total"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_search_highlight_terms_keep_full_keyword():
|
||||||
|
with _database() as db:
|
||||||
|
item = _knowledge(db)
|
||||||
|
db.add(KnowledgeSection(
|
||||||
|
knowledge_id=item.id,
|
||||||
|
version_id=item.current_version_id,
|
||||||
|
section_key="S0002",
|
||||||
|
title="能力心密钥",
|
||||||
|
content="能力心钥用于说明能力心的核心方向。",
|
||||||
|
source_start=0,
|
||||||
|
source_end=10,
|
||||||
|
sort_order=1,
|
||||||
|
content_hash="highlight-section",
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = content_search("心钥", includeClosed=True, page=1, pageSize=10, db=db, current_admin=_admin())
|
||||||
|
|
||||||
|
assert response["data"]["total"] == 1
|
||||||
|
assert response["data"]["items"][0]["highlightTerms"][0] == "心钥"
|
||||||
|
|||||||
Reference in New Issue
Block a user