fix: deduplicate knowledge content search results
This commit is contained in:
@@ -486,7 +486,7 @@ async function confirmAction(
|
|||||||
<div class="content-search-hit-main">
|
<div class="content-search-hit-main">
|
||||||
<div class="content-search-hit-title">
|
<div class="content-search-hit-title">
|
||||||
<strong>{{ item.title }}</strong>
|
<strong>{{ item.title }}</strong>
|
||||||
<el-tag size="small" effect="plain">{{ contentItemTypeLabel(item.itemType) }}</el-tag>
|
<el-tag v-for="type in item.itemTypes || [item.itemType]" :key="type" size="small" effect="plain">{{ contentItemTypeLabel(type) }}</el-tag>
|
||||||
<el-tag size="small" :type="item.canAgentUse ? 'success' : 'warning'">{{ item.canAgentUse ? '可参与召回' : '不可默认召回' }}</el-tag>
|
<el-tag size="small" :type="item.canAgentUse ? 'success' : 'warning'">{{ item.canAgentUse ? '可参与召回' : '不可默认召回' }}</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<p class="content-search-hit-meta">
|
<p class="content-search-hit-meta">
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ export interface KnowledgeContentSearchItem {
|
|||||||
versionNo: number;
|
versionNo: number;
|
||||||
publishedAt?: string | null;
|
publishedAt?: string | null;
|
||||||
itemType: "section" | "card" | "chunk";
|
itemType: "section" | "card" | "chunk";
|
||||||
|
itemTypes?: Array<"section" | "card" | "chunk">;
|
||||||
itemId: number;
|
itemId: number;
|
||||||
sectionId: number;
|
sectionId: number;
|
||||||
sectionKey: string;
|
sectionKey: string;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy import func, literal, or_, select, union_all
|
from sqlalchemy import and_, func, literal, or_, select, union_all
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
@@ -290,9 +290,43 @@ def content_search(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
search = union_all(section_query, card_query, chunk_query).subquery()
|
search = union_all(section_query, card_query, chunk_query).subquery()
|
||||||
total = db.scalar(select(func.count()).select_from(search)) or 0
|
grouped = (
|
||||||
|
select(
|
||||||
|
search.c.knowledge_id,
|
||||||
|
search.c.version_id,
|
||||||
|
search.c.section_id,
|
||||||
|
func.max(search.c.knowledge_status).label("knowledge_status"),
|
||||||
|
func.min(search.c.sort_order).label("sort_order"),
|
||||||
|
func.min(search.c.item_rank).label("item_rank"),
|
||||||
|
func.min(search.c.item_id).label("item_id"),
|
||||||
|
)
|
||||||
|
.group_by(search.c.knowledge_id, search.c.version_id, search.c.section_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
total = db.scalar(select(func.count()).select_from(grouped)) or 0
|
||||||
|
page_groups = (
|
||||||
|
select(grouped.c.knowledge_id, grouped.c.version_id, grouped.c.section_id)
|
||||||
|
.order_by(
|
||||||
|
grouped.c.knowledge_status.desc(),
|
||||||
|
grouped.c.knowledge_id.desc(),
|
||||||
|
grouped.c.sort_order.asc(),
|
||||||
|
grouped.c.item_rank.asc(),
|
||||||
|
grouped.c.item_id.asc(),
|
||||||
|
)
|
||||||
|
.offset((page - 1) * pageSize)
|
||||||
|
.limit(pageSize)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
select(search)
|
select(search)
|
||||||
|
.join(
|
||||||
|
page_groups,
|
||||||
|
and_(
|
||||||
|
page_groups.c.knowledge_id == search.c.knowledge_id,
|
||||||
|
page_groups.c.version_id == search.c.version_id,
|
||||||
|
page_groups.c.section_id == search.c.section_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
.order_by(
|
.order_by(
|
||||||
search.c.knowledge_status.desc(),
|
search.c.knowledge_status.desc(),
|
||||||
search.c.knowledge_id.desc(),
|
search.c.knowledge_id.desc(),
|
||||||
@@ -300,8 +334,6 @@ def content_search(
|
|||||||
search.c.item_rank.asc(),
|
search.c.item_rank.asc(),
|
||||||
search.c.item_id.asc(),
|
search.c.item_id.asc(),
|
||||||
)
|
)
|
||||||
.offset((page - 1) * pageSize)
|
|
||||||
.limit(pageSize)
|
|
||||||
).mappings().all()
|
).mappings().all()
|
||||||
section_ids = [int(row["section_id"]) for row in rows if row["section_id"]]
|
section_ids = [int(row["section_id"]) for row in rows if row["section_id"]]
|
||||||
chunk_section_ids = set()
|
chunk_section_ids = set()
|
||||||
@@ -309,8 +341,8 @@ def content_search(
|
|||||||
chunk_section_ids = set(
|
chunk_section_ids = set(
|
||||||
db.scalars(select(KnowledgeChunk.section_id).where(KnowledgeChunk.section_id.in_(section_ids))).all()
|
db.scalars(select(KnowledgeChunk.section_id).where(KnowledgeChunk.section_id.in_(section_ids))).all()
|
||||||
)
|
)
|
||||||
items = [_content_search_item(row, keyword, int(row["section_id"]) in chunk_section_ids) for row in rows]
|
merged = _merge_content_search_rows(rows, keyword, chunk_section_ids)
|
||||||
return api_success(page_result(items, total=total, page=page, page_size=pageSize))
|
return api_success(page_result(merged, total=total, page=page, page_size=pageSize))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/knowledge/{knowledge_id}/sync-jobs")
|
@router.get("/knowledge/{knowledge_id}/sync-jobs")
|
||||||
@@ -610,6 +642,25 @@ def _escape_like(value: str) -> str:
|
|||||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_content_search_rows(rows, keyword: str, chunk_section_ids: set[int]) -> list[dict]:
|
||||||
|
grouped: dict[tuple[int, int, int], dict] = {}
|
||||||
|
for row in rows:
|
||||||
|
key = (int(row["knowledge_id"]), int(row["version_id"]), int(row["section_id"]))
|
||||||
|
item_type = str(row["item_type"])
|
||||||
|
current = grouped.get(key)
|
||||||
|
if current is None:
|
||||||
|
current = _content_search_item(row, keyword, int(row["section_id"]) in chunk_section_ids)
|
||||||
|
current["itemTypes"] = []
|
||||||
|
grouped[key] = current
|
||||||
|
if item_type not in current["itemTypes"]:
|
||||||
|
current["itemTypes"].append(item_type)
|
||||||
|
for matched in _matched_in(str(row["title"] or ""), str(row["content"] or ""), keyword):
|
||||||
|
if matched not in current["matchedIn"]:
|
||||||
|
current["matchedIn"].append(matched)
|
||||||
|
current["itemType"] = _primary_item_type(current["itemTypes"])
|
||||||
|
return list(grouped.values())
|
||||||
|
|
||||||
|
|
||||||
def _content_search_item(row, keyword: str, has_chunks: bool) -> dict:
|
def _content_search_item(row, keyword: str, has_chunks: bool) -> dict:
|
||||||
title = str(row["title"] or "")
|
title = str(row["title"] or "")
|
||||||
content = str(row["content"] or "")
|
content = str(row["content"] or "")
|
||||||
@@ -637,12 +688,20 @@ 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),
|
||||||
|
"itemTypes": [row["item_type"]],
|
||||||
"hasChunks": has_chunks,
|
"hasChunks": has_chunks,
|
||||||
"isCurrentVersion": True,
|
"isCurrentVersion": True,
|
||||||
"canAgentUse": is_active_current and has_chunks,
|
"canAgentUse": is_active_current and has_chunks,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _primary_item_type(item_types: list[str]) -> str:
|
||||||
|
for item_type in ("section", "card", "chunk"):
|
||||||
|
if item_type in item_types:
|
||||||
|
return item_type
|
||||||
|
return item_types[0] if item_types else "section"
|
||||||
|
|
||||||
|
|
||||||
def _matched_in(title: str, content: str, keyword: str) -> list[str]:
|
def _matched_in(title: str, content: str, keyword: str) -> list[str]:
|
||||||
result: list[str] = []
|
result: list[str] = []
|
||||||
lowered = keyword.lower()
|
lowered = keyword.lower()
|
||||||
|
|||||||
@@ -325,8 +325,10 @@ def test_content_search_finds_current_sections_cards_and_chunks():
|
|||||||
response = content_search("静水流深", includeClosed=True, page=1, pageSize=10, db=db, current_admin=_admin())
|
response = content_search("静水流深", includeClosed=True, page=1, pageSize=10, db=db, current_admin=_admin())
|
||||||
|
|
||||||
data = response["data"]
|
data = response["data"]
|
||||||
assert data["total"] == 3
|
assert data["total"] == 1
|
||||||
assert {item["itemType"] for item in data["items"]} == {"section", "card", "chunk"}
|
assert len(data["items"]) == 1
|
||||||
|
assert data["items"][0]["itemType"] == "section"
|
||||||
|
assert data["items"][0]["itemTypes"] == ["section", "card", "chunk"]
|
||||||
assert data["items"][0]["sectionKey"] == "S0098"
|
assert data["items"][0]["sectionKey"] == "S0098"
|
||||||
assert data["items"][0]["hasChunks"] is True
|
assert data["items"][0]["hasChunks"] is True
|
||||||
assert data["items"][0]["canAgentUse"] is True
|
assert data["items"][0]["canAgentUse"] is True
|
||||||
|
|||||||
Reference in New Issue
Block a user