1. 修复假流式:对话接口边收边吐(stream_chat 生成器直驱 _chat_stream_raw);思考模型先流式思考内容(折叠)再流式回答;回答块显示 tok/s 2. 历史会话列表:重命名/置顶/删除(pinned/use_kb 列);📝Markdown 一键开关,默认 Markdown 渲染 3. 模型能力标签(chat/thinking/vision/audio_in/audio_out/image_gen/video_gen/embedding/rerank):接口库每模型勾选; 对话按能力适配:视觉→传图、语音入→🎤录音(默认ASR转写)、语音出→🔊朗读(默认TTS)、思考→思考折叠;不匹配提前提示 4. 模型库标签:能力矩阵 + 系统默认模型(语音识别/合成/生图/生视频/embedding/rerank) 5. 计费修正:按次=元/千次;逐模型定价=模型名 输入价 缓存输入价 输出价(缓存价计缓存命中token) 6. 弹窗未保存提醒(点外部/X 时 confirm,全弹窗通用) 7. 知识库导航:#/kb 全局文档 CRUD + txt/md/pdf/docx 上传解析 + jieba BM25 检索;对话可一键注入知识库
141 lines
4.7 KiB
Python
141 lines
4.7 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
V3.5.2 全局知识库
|
||
=================
|
||
- kb_documents / kb_chunks:文档 + 分块(jieba 分词,BM25 风格检索)
|
||
- 基本功能:增删改查、上传(txt/md/pdf)、全文检索、上下文注入(对话可选)
|
||
"""
|
||
import json
|
||
import re
|
||
import os
|
||
|
||
import db
|
||
import config
|
||
|
||
KB_UPLOAD_DIR = os.path.join(config.DATA_DIR, 'kb_uploads')
|
||
|
||
|
||
def _tok(text):
|
||
"""jieba 分词(去停用字、只留长度>=2 的 token)"""
|
||
try:
|
||
import jieba
|
||
toks = []
|
||
for t in jieba.cut_for_search((text or '').lower()):
|
||
t = t.strip()
|
||
if len(t) >= 2 and not t.isdigit():
|
||
toks.append(t)
|
||
return toks
|
||
except Exception:
|
||
return [w for w in re.findall(r'[\u4e00-\u9fff]{2,}|[a-zA-Z0-9_]{2,}', (text or '').lower())]
|
||
|
||
|
||
def _chunks(content, size=400, overlap=60):
|
||
"""把文档切成小块(按段落聚合 + 超长硬切 + 前后重叠)"""
|
||
content = content or ''
|
||
paras = [p for p in re.split(r'\n+', content) if p.strip()]
|
||
blocks, buf = [], ''
|
||
for p in paras:
|
||
if buf and len(buf) + len(p) > size:
|
||
blocks.append(buf)
|
||
buf = ''
|
||
buf = (buf + '\n' + p) if buf else p
|
||
if buf:
|
||
blocks.append(buf)
|
||
out = []
|
||
for b in blocks:
|
||
while len(b) > size:
|
||
out.append(b[:size])
|
||
b = b[size - overlap:]
|
||
if b:
|
||
out.append(b)
|
||
return out or ['']
|
||
|
||
|
||
def rebuild_chunks(doc_id):
|
||
doc = db.q('SELECT * FROM kb_documents WHERE id=?', (doc_id,), one=True)
|
||
if not doc:
|
||
return 0
|
||
db.w('DELETE FROM kb_chunks WHERE doc_id=?', (doc_id,))
|
||
n = 0
|
||
for i, c in enumerate(_chunks(doc.get('content') or '')):
|
||
db.w('INSERT INTO kb_chunks (doc_id, idx, content, tokens) VALUES (?,?,?,?)',
|
||
(doc_id, i, c, json.dumps(_tok(c))))
|
||
n += 1
|
||
return n
|
||
|
||
|
||
def search(q, top_k=6):
|
||
"""BM25 风格检索:返回 [{doc_id,title,content,score}](按命中 token 数 + IDF 加权)"""
|
||
q_tokens = _tok(q)
|
||
if not q_tokens:
|
||
return []
|
||
rows = db.q('SELECT * FROM kb_chunks ORDER BY doc_id, idx')
|
||
if not rows:
|
||
return []
|
||
docs = {d['id']: d for d in db.q('SELECT id,title FROM kb_documents')}
|
||
df = {}
|
||
for c in rows:
|
||
for t in set(json.loads(c['tokens'] or '[]')):
|
||
df[t] = df.get(t, 0) + 1
|
||
n_docs = max(1, len(set(r['doc_id'] for r in rows)))
|
||
scored = []
|
||
for c in rows:
|
||
toks = json.loads(c['tokens'] or '[]')
|
||
tf = {}
|
||
for t in toks:
|
||
tf[t] = tf.get(t, 0) + 1
|
||
score = 0.0
|
||
for t in q_tokens:
|
||
if t in tf:
|
||
score += (1 + tf[t]) * max(0.1, (n_docs - df.get(t, 0) + 0.5) / (df.get(t, 0) + 0.5))
|
||
if score > 0:
|
||
scored.append({'doc_id': c['doc_id'], 'idx': c['idx'],
|
||
'content': c['content'], 'score': round(score, 3),
|
||
'title': docs.get(c['doc_id'], {}).get('title', '')})
|
||
scored.sort(key=lambda x: -x['score'])
|
||
return scored[:top_k]
|
||
|
||
|
||
def build_context(q, top_k=4):
|
||
"""把检索结果拼成可注入的上下文(用于对话/任务),返回 (ctx, hits)"""
|
||
hits = search(q, top_k)
|
||
if not hits:
|
||
return '', []
|
||
parts = []
|
||
for i, h in enumerate(hits):
|
||
parts.append(f"[{i + 1}]《{h['title']}》\n{h['content'][:900]}")
|
||
ctx = ('以下是与你问题相关的【知识库参考】资料(可据此回答):\n' + '\n\n'.join(parts) + '\n\n----\n')
|
||
return ctx, hits
|
||
|
||
|
||
def extract_text(filename, raw):
|
||
"""按扩展名抽取文本:txt/md/html/csv/json;pdf 用 pypdf(有则装)。返回 (text, ok)"""
|
||
ext = os.path.splitext(filename)[1].lower()
|
||
name = filename or 'doc'
|
||
if ext in ('.txt', '.md', '.markdown', '.html', '.htm', '.csv', '.json', '.log', '.py', '.js', '.css'):
|
||
for enc in ('utf-8', 'gbk', 'utf-8-sig'):
|
||
try:
|
||
return raw.decode(enc), True
|
||
except Exception:
|
||
continue
|
||
return raw.decode('utf-8', errors='ignore'), True
|
||
if ext == '.pdf':
|
||
try:
|
||
from pypdf import PdfReader
|
||
import io
|
||
reader = PdfReader(io.BytesIO(raw))
|
||
text = '\n'.join((pg.extract_text() or '') for pg in reader.pages)
|
||
return text, bool(text.strip())
|
||
except Exception:
|
||
return '', False
|
||
if ext in ('.docx',):
|
||
try:
|
||
import io
|
||
from docx import Document
|
||
doc = Document(io.BytesIO(raw))
|
||
text = '\n'.join(p.text for p in doc.paragraphs)
|
||
return text, bool(text.strip())
|
||
except Exception:
|
||
return '', False
|
||
return '', False
|