109 lines
3.7 KiB
Python
109 lines
3.7 KiB
Python
"""Knowledge base service: document CRUD + simple RAG search."""
|
|||
|
|
import json
|
||
|
|
import re
|
||
|
|
from sqlalchemy.orm import Session
|
||
|
|
from typing import List, Optional
|
||
|
|
from app.models.knowledge import KnowledgeDoc
|
||
|
|
|
||
|
|
|
||
|
|
class KnowledgeService:
|
||
|
|
@staticmethod
|
||
|
|
def list_docs(db, tenant_id, project_id=None):
|
||
|
|
q = db.query(KnowledgeDoc).filter(KnowledgeDoc.tenant_id == tenant_id)
|
||
|
|
if project_id:
|
||
|
|
q = q.filter(
|
||
|
|
(KnowledgeDoc.project_id == project_id) | (KnowledgeDoc.project_id == None)
|
||
|
|
)
|
||
|
|
return q.order_by(KnowledgeDoc.created_at.desc()).all()
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def get_doc(db, tenant_id, doc_id):
|
||
|
|
return db.query(KnowledgeDoc).filter(
|
||
|
|
KnowledgeDoc.id == doc_id, KnowledgeDoc.tenant_id == tenant_id
|
||
|
|
).first()
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def create_doc(db, tenant_id, req):
|
||
|
|
keywords = KnowledgeService._extract_keywords(req.content + " " + req.title + " " + req.tags)
|
||
|
|
doc = KnowledgeDoc(
|
||
|
|
tenant_id=tenant_id, project_id=req.project_id,
|
||
|
|
title=req.title, content=req.content,
|
||
|
|
doc_type=req.doc_type, source=req.source,
|
||
|
|
tags=req.tags, keywords=json.dumps(keywords, ensure_ascii=False),
|
||
|
|
)
|
||
|
|
db.add(doc)
|
||
|
|
db.commit()
|
||
|
|
db.refresh(doc)
|
||
|
|
return doc
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def update_doc(db, tenant_id, doc_id, content=None, title=None):
|
||
|
|
doc = KnowledgeService.get_doc(db, tenant_id, doc_id)
|
||
|
|
if not doc:
|
||
|
|
return None
|
||
|
|
if content is not None:
|
||
|
|
doc.content = content
|
||
|
|
if title is not None:
|
||
|
|
doc.title = title
|
||
|
|
doc.keywords = json.dumps(
|
||
|
|
KnowledgeService._extract_keywords(doc.content + " " + doc.title),
|
||
|
|
ensure_ascii=False,
|
||
|
|
)
|
||
|
|
db.commit()
|
||
|
|
db.refresh(doc)
|
||
|
|
return doc
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def delete_doc(db, tenant_id, doc_id):
|
||
|
|
doc = KnowledgeService.get_doc(db, tenant_id, doc_id)
|
||
|
|
if not doc:
|
||
|
|
return False
|
||
|
|
db.delete(doc)
|
||
|
|
db.commit()
|
||
|
|
return True
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def search(db, tenant_id, query, project_id=None, limit=5):
|
||
|
|
"""Simple keyword-based search (MVP RAG without vector DB)."""
|
||
|
|
docs = KnowledgeService.list_docs(db, tenant_id, project_id)
|
||
|
|
query_lower = query.lower()
|
||
|
|
query_terms = set(re.findall(r'\w+', query_lower))
|
||
|
|
|
||
|
|
scored = []
|
||
|
|
for doc in docs:
|
||
|
|
content_lower = doc.content.lower()
|
||
|
|
title_lower = doc.title.lower()
|
||
|
|
keywords = set(json.loads(doc.keywords or "[]"))
|
||
|
|
|
||
|
|
score = 0.0
|
||
|
|
# Title match (high weight)
|
||
|
|
for term in query_terms:
|
||
|
|
if term in title_lower:
|
||
|
|
score += 3.0
|
||
|
|
if term in keywords:
|
||
|
|
score += 2.0
|
||
|
|
count = content_lower.count(term)
|
||
|
|
score += count * 0.5
|
||
|
|
|
||
|
|
if score > 0:
|
||
|
|
snippet = doc.content[:200] + "..." if len(doc.content) > 200 else doc.content
|
||
|
|
scored.append({
|
||
|
|
"doc_id": doc.id,
|
||
|
|
"title": doc.title,
|
||
|
|
"snippet": snippet,
|
||
|
|
"score": round(score, 2),
|
||
|
|
})
|
||
|
|
|
||
|
|
scored.sort(key=lambda x: x["score"], reverse=True)
|
||
|
|
return scored[:limit]
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _extract_keywords(text):
|
||
|
|
"""Extract keywords from text (simple: Chinese 2-4 char segments + English words)."""
|
||
|
|
# English words
|
||
|
|
en_words = re.findall(r'[a-zA-Z]{2,20}', text)
|
||
|
|
# Chinese segments (2-4 chars)
|
||
|
|
cn_segs = re.findall(r'[\u4e00-\u9fff]{2,4}', text)
|
||
|
|
# Deduplicate and limit
|
||
|
|
all_kw = list(set(en_words + cn_segs))[:20]
|
||
|
|
return all_kw
|