feat: add knowledge content search

This commit is contained in:
2026-07-30 11:02:04 +08:00
parent c2655361b5
commit d1f573108e
7 changed files with 629 additions and 8 deletions

View File

@@ -3,12 +3,13 @@ from __future__ import annotations
import json
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy import func, literal, or_, select, union_all
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.api.pagination import page_result
from app.models.admin import Admin
from app.models.knowledge import (
Knowledge,
@@ -165,6 +166,153 @@ def version_content(
)
@router.get("/knowledge/content-search")
def content_search(
keyword: str = Query(default="", min_length=1, max_length=100),
includeClosed: bool = Query(default=True),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=10, ge=5, le=50),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
keyword = keyword.strip()
if not keyword:
return api_success({"items": [], "total": 0, "page": page, "pageSize": pageSize})
pattern = f"%{_escape_like(keyword)}%"
base_filters = [
Knowledge.current_version_id.is_not(None),
Knowledge.current_version_id == KnowledgeVersion.id,
Knowledge.lifecycle_status == "active",
]
if not includeClosed:
base_filters.append(Knowledge.status == 1)
section_query = (
select(
Knowledge.id.label("knowledge_id"),
Knowledge.name.label("knowledge_name"),
Knowledge.source_title.label("source_title"),
Knowledge.knowledge_type.label("knowledge_type"),
Knowledge.status.label("knowledge_status"),
Knowledge.lifecycle_status.label("lifecycle_status"),
Knowledge.source_status.label("source_status"),
KnowledgeVersion.id.label("version_id"),
KnowledgeVersion.version_no.label("version_no"),
KnowledgeVersion.published_at.label("published_at"),
literal("section").label("item_type"),
literal(1).label("item_rank"),
KnowledgeSection.id.label("item_id"),
KnowledgeSection.id.label("section_id"),
KnowledgeSection.section_key.label("section_key"),
KnowledgeSection.title.label("title"),
KnowledgeSection.content.label("content"),
KnowledgeSection.sort_order.label("sort_order"),
)
.select_from(KnowledgeSection)
.join(Knowledge, Knowledge.id == KnowledgeSection.knowledge_id)
.join(KnowledgeVersion, KnowledgeVersion.id == KnowledgeSection.version_id)
.where(*base_filters)
.where(or_(KnowledgeSection.title.like(pattern, escape="\\"), KnowledgeSection.content.like(pattern, escape="\\")))
)
card_query = (
select(
Knowledge.id.label("knowledge_id"),
Knowledge.name.label("knowledge_name"),
Knowledge.source_title.label("source_title"),
Knowledge.knowledge_type.label("knowledge_type"),
Knowledge.status.label("knowledge_status"),
Knowledge.lifecycle_status.label("lifecycle_status"),
Knowledge.source_status.label("source_status"),
KnowledgeVersion.id.label("version_id"),
KnowledgeVersion.version_no.label("version_no"),
KnowledgeVersion.published_at.label("published_at"),
literal("card").label("item_type"),
literal(2).label("item_rank"),
KnowledgeCard.id.label("item_id"),
KnowledgeCard.section_id.label("section_id"),
KnowledgeSection.section_key.label("section_key"),
KnowledgeCard.title.label("title"),
KnowledgeCard.summary.label("content"),
KnowledgeSection.sort_order.label("sort_order"),
)
.select_from(KnowledgeCard)
.join(Knowledge, Knowledge.id == KnowledgeCard.knowledge_id)
.join(KnowledgeVersion, KnowledgeVersion.id == KnowledgeCard.version_id)
.join(KnowledgeSection, KnowledgeSection.id == KnowledgeCard.section_id)
.where(*base_filters)
.where(
or_(
KnowledgeCard.title.like(pattern, escape="\\"),
KnowledgeCard.summary.like(pattern, escape="\\"),
KnowledgeCard.core_conclusion.like(pattern, escape="\\"),
KnowledgeCard.applicable_questions.like(pattern, escape="\\"),
KnowledgeCard.keywords.like(pattern, escape="\\"),
KnowledgeCard.synonyms.like(pattern, escape="\\"),
)
)
)
chunk_query = (
select(
Knowledge.id.label("knowledge_id"),
Knowledge.name.label("knowledge_name"),
Knowledge.source_title.label("source_title"),
Knowledge.knowledge_type.label("knowledge_type"),
Knowledge.status.label("knowledge_status"),
Knowledge.lifecycle_status.label("lifecycle_status"),
Knowledge.source_status.label("source_status"),
KnowledgeVersion.id.label("version_id"),
KnowledgeVersion.version_no.label("version_no"),
KnowledgeVersion.published_at.label("published_at"),
literal("chunk").label("item_type"),
literal(3).label("item_rank"),
KnowledgeChunk.id.label("item_id"),
KnowledgeChunk.section_id.label("section_id"),
KnowledgeSection.section_key.label("section_key"),
KnowledgeChunk.title.label("title"),
KnowledgeChunk.content.label("content"),
KnowledgeSection.sort_order.label("sort_order"),
)
.select_from(KnowledgeChunk)
.join(Knowledge, Knowledge.id == KnowledgeChunk.knowledge_id)
.join(KnowledgeVersion, KnowledgeVersion.id == KnowledgeChunk.version_id)
.join(KnowledgeSection, KnowledgeSection.id == KnowledgeChunk.section_id)
.where(*base_filters)
.where(
or_(
KnowledgeChunk.title.like(pattern, escape="\\"),
KnowledgeChunk.content.like(pattern, escape="\\"),
KnowledgeChunk.normalized_text.like(pattern, escape="\\"),
KnowledgeChunk.keywords.like(pattern, escape="\\"),
KnowledgeChunk.synonyms.like(pattern, escape="\\"),
)
)
)
search = union_all(section_query, card_query, chunk_query).subquery()
total = db.scalar(select(func.count()).select_from(search)) or 0
rows = db.execute(
select(search)
.order_by(
search.c.knowledge_status.desc(),
search.c.knowledge_id.desc(),
search.c.sort_order.asc(),
search.c.item_rank.asc(),
search.c.item_id.asc(),
)
.offset((page - 1) * pageSize)
.limit(pageSize)
).mappings().all()
section_ids = [int(row["section_id"]) for row in rows if row["section_id"]]
chunk_section_ids = set()
if section_ids:
chunk_section_ids = set(
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]
return api_success(page_result(items, total=total, page=page, page_size=pageSize))
@router.get("/knowledge/{knowledge_id}/sync-jobs")
def sync_jobs(
knowledge_id: int,
@@ -456,3 +604,64 @@ def _json_value(raw: str | None):
return json.loads(raw)
except json.JSONDecodeError:
return raw
def _escape_like(value: str) -> str:
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _content_search_item(row, keyword: str, has_chunks: bool) -> dict:
title = str(row["title"] or "")
content = str(row["content"] or "")
is_active_current = (
int(row["knowledge_status"] or 0) == 1
and row["lifecycle_status"] == "active"
and row["source_status"] == "normal"
and bool(row["version_id"])
)
return {
"knowledgeId": row["knowledge_id"],
"knowledgeName": row["knowledge_name"],
"sourceTitle": row["source_title"],
"knowledgeType": row["knowledge_type"],
"knowledgeStatus": row["knowledge_status"],
"lifecycleStatus": row["lifecycle_status"],
"sourceStatus": row["source_status"],
"versionId": row["version_id"],
"versionNo": row["version_no"],
"publishedAt": row["published_at"],
"itemType": row["item_type"],
"itemId": row["item_id"],
"sectionId": row["section_id"],
"sectionKey": row["section_key"],
"title": title,
"snippet": _snippet(f"{title}\n{content}", keyword),
"matchedIn": _matched_in(title, content, keyword),
"hasChunks": has_chunks,
"isCurrentVersion": True,
"canAgentUse": is_active_current and has_chunks,
}
def _matched_in(title: str, content: str, keyword: str) -> list[str]:
result: list[str] = []
lowered = keyword.lower()
if lowered in title.lower():
result.append("title")
if lowered in content.lower():
result.append("content")
return result or ["content"]
def _snippet(text: str, keyword: str, radius: int = 90) -> str:
compact = " ".join(text.split())
if not compact:
return ""
index = compact.lower().find(keyword.lower())
if index < 0:
return compact[: radius * 2] + ("..." if len(compact) > radius * 2 else "")
start = max(0, index - radius)
end = min(len(compact), index + len(keyword) + radius)
prefix = "..." if start > 0 else ""
suffix = "..." if end < len(compact) else ""
return f"{prefix}{compact[start:end]}{suffix}"