feat: add knowledge content search
This commit is contained in:
@@ -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}"
|
||||
|
||||
@@ -54,7 +54,7 @@ class ModelConfig(Base):
|
||||
class SystemConfig(Base):
|
||||
__tablename__ = "sys_system_config"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True)
|
||||
config_key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||
config_value: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
@@ -11,14 +11,16 @@ from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.api.admin_knowledge import delete_knowledge
|
||||
from app.api.admin_knowledge_lifecycle import batch_sync, batch_update_metadata, update_metadata
|
||||
from app.api.admin_knowledge_lifecycle import batch_sync, batch_update_metadata, content_search, update_metadata
|
||||
from app.models import Base
|
||||
from app.models.admin import Admin, Role
|
||||
from app.models.knowledge import (
|
||||
Knowledge,
|
||||
KnowledgeCard,
|
||||
KnowledgeChunk,
|
||||
KnowledgeManifest,
|
||||
KnowledgePublishLog,
|
||||
KnowledgeSection,
|
||||
KnowledgeSourceSnapshot,
|
||||
KnowledgeSyncJob,
|
||||
KnowledgeVersion,
|
||||
@@ -268,3 +270,90 @@ def test_sync_validation_failure_keeps_current_version_active():
|
||||
assert item.current_version_id == previous_version_id
|
||||
assert current.status == "published"
|
||||
assert len(versions) == 1
|
||||
|
||||
|
||||
def test_content_search_finds_current_sections_cards_and_chunks():
|
||||
with _database() as db:
|
||||
item = _knowledge(db)
|
||||
version_id = item.current_version_id
|
||||
section = KnowledgeSection(
|
||||
knowledge_id=item.id,
|
||||
version_id=version_id,
|
||||
section_key="S0098",
|
||||
title="三十、练习一:静水流深静心",
|
||||
content="选择舒适安全的姿势,觉察呼吸和身体,不适时立即停止。",
|
||||
source_start=0,
|
||||
source_end=40,
|
||||
sort_order=1,
|
||||
content_hash="section-hash",
|
||||
)
|
||||
db.add(section)
|
||||
db.flush()
|
||||
db.add_all([
|
||||
KnowledgeChunk(
|
||||
knowledge_id=item.id,
|
||||
version_id=version_id,
|
||||
section_id=section.id,
|
||||
title=section.title,
|
||||
content="静水流深静心需要选择舒适安全的姿势。",
|
||||
normalized_text="静水流深静心 选择舒适安全的姿势",
|
||||
keywords='["静水流深静心"]',
|
||||
source_start=0,
|
||||
source_end=20,
|
||||
sort_order=1,
|
||||
content_hash="chunk-hash",
|
||||
),
|
||||
KnowledgeCard(
|
||||
knowledge_id=item.id,
|
||||
version_id=version_id,
|
||||
section_id=section.id,
|
||||
title=section.title,
|
||||
summary="静水流深静心的摘要",
|
||||
core_conclusion="回到身体觉察",
|
||||
applicable_questions="静水流深静心怎么做",
|
||||
inapplicable_questions="天气",
|
||||
keywords='["静水流深静心"]',
|
||||
risk_level="normal",
|
||||
source_range="0-40",
|
||||
content_hash="card-hash",
|
||||
generation_rule_version="test-v1",
|
||||
review_status="approved",
|
||||
),
|
||||
])
|
||||
db.commit()
|
||||
|
||||
response = content_search("静水流深", includeClosed=True, page=1, pageSize=10, db=db, current_admin=_admin())
|
||||
|
||||
data = response["data"]
|
||||
assert data["total"] == 3
|
||||
assert {item["itemType"] for item in data["items"]} == {"section", "card", "chunk"}
|
||||
assert data["items"][0]["sectionKey"] == "S0098"
|
||||
assert data["items"][0]["hasChunks"] is True
|
||||
assert data["items"][0]["canAgentUse"] is True
|
||||
assert "静水流深" in data["items"][0]["snippet"]
|
||||
|
||||
|
||||
def test_content_search_can_exclude_closed_knowledge():
|
||||
with _database() as db:
|
||||
item = _knowledge(db)
|
||||
item.status = 0
|
||||
db.add(item)
|
||||
db.add(KnowledgeSection(
|
||||
knowledge_id=item.id,
|
||||
version_id=item.current_version_id,
|
||||
section_key="S0001",
|
||||
title="合一作业",
|
||||
content="合一作业内容",
|
||||
source_start=0,
|
||||
source_end=10,
|
||||
sort_order=1,
|
||||
content_hash="closed-section",
|
||||
))
|
||||
db.commit()
|
||||
|
||||
included = content_search("合一", includeClosed=True, page=1, pageSize=10, db=db, current_admin=_admin())
|
||||
excluded = content_search("合一", includeClosed=False, page=1, pageSize=10, db=db, current_admin=_admin())
|
||||
|
||||
assert included["data"]["total"] == 1
|
||||
assert included["data"]["items"][0]["canAgentUse"] is False
|
||||
assert excluded["data"]["total"] == 0
|
||||
|
||||
Reference in New Issue
Block a user