560 lines
22 KiB
Python
560 lines
22 KiB
Python
"""知识库路由 - 完整 CRUD + 分析功能"""
|
||
import os, json, shutil, re
|
||
from flask import Blueprint, jsonify, request
|
||
from datetime import datetime
|
||
from routes.auth import login_required
|
||
|
||
knowledge_bp = Blueprint('knowledge', __name__)
|
||
|
||
from config import VAULT_PATH
|
||
WIKI_PATH = os.path.join(VAULT_PATH, "知识库")
|
||
RAW_PATH = os.path.join(VAULT_PATH, "原始资料")
|
||
|
||
def _safe_path(base, path):
|
||
"""安全检查:确保路径在 base 之下"""
|
||
full = os.path.normpath(os.path.join(base, path))
|
||
if not full.startswith(os.path.normpath(base)):
|
||
return None
|
||
return full
|
||
|
||
def _update_index(article_title, rel_path, summary, date, delete=False):
|
||
"""更新索引.md"""
|
||
idx_file = os.path.join(WIKI_PATH, "索引.md")
|
||
if not os.path.exists(idx_file):
|
||
return
|
||
with open(idx_file, 'r', encoding='utf-8') as f:
|
||
content = f.read()
|
||
|
||
if delete:
|
||
# 删除对应行
|
||
lines = content.split('\n')
|
||
new_lines = []
|
||
for line in lines:
|
||
if f']({rel_path})' in line:
|
||
new_lines.append(f'| ~~{line.strip()[1:].strip()}~~ | 已删除 | — |')
|
||
else:
|
||
new_lines.append(line)
|
||
content = '\n'.join(new_lines)
|
||
else:
|
||
# 查找是否有已有条目
|
||
if f']({rel_path})' in content:
|
||
# 更新已有条目
|
||
lines = content.split('\n')
|
||
for i, line in enumerate(lines):
|
||
if f']({rel_path})' in line:
|
||
parts = line.split('|')
|
||
if len(parts) >= 4:
|
||
lines[i] = f'| [{article_title}]({rel_path}) | {summary[:50]} | {date} |'
|
||
break
|
||
content = '\n'.join(lines)
|
||
else:
|
||
# 新增条目 - 找对应主题
|
||
topic = rel_path.split('/')[0] if '/' in rel_path else ''
|
||
topic_header = f"## {topic}"
|
||
lines = content.split('\n')
|
||
new_line = f'| [{article_title}]({rel_path}) | {summary[:50]} | {date} |'
|
||
inserted = False
|
||
|
||
for i, line in enumerate(lines):
|
||
if line == topic_header:
|
||
# 在 topic 区域的表格末尾插入
|
||
for j in range(i+1, len(lines)):
|
||
if lines[j].startswith('## ') or j == len(lines)-1:
|
||
# Check if there's a "暂无" line to replace
|
||
for k in range(i+1, j):
|
||
if '暂无' in lines[k] and lines[k].startswith('|'):
|
||
lines[k] = new_line
|
||
inserted = True
|
||
break
|
||
if not inserted:
|
||
# 从 j-1 往上找最后一个非空行
|
||
k = j - 1
|
||
while k >= i + 1 and lines[k].strip() == '':
|
||
k -= 1
|
||
if k >= i + 1 and (lines[k].startswith('|') or '|—|' in lines[k]):
|
||
lines.insert(j, new_line)
|
||
inserted = True
|
||
break
|
||
break
|
||
|
||
if inserted:
|
||
content = '\n'.join(lines)
|
||
|
||
with open(idx_file, 'w', encoding='utf-8') as f:
|
||
f.write(content)
|
||
|
||
def _append_log(entry):
|
||
"""追加操作日志"""
|
||
log_file = os.path.join(WIKI_PATH, "操作日志.md")
|
||
with open(log_file, 'a', encoding='utf-8') as f:
|
||
f.write(f"\n{entry}\n")
|
||
|
||
# ─── 现有 API ───
|
||
|
||
@knowledge_bp.route('/api/knowledge/index')
|
||
@login_required
|
||
def get_index():
|
||
index_file = os.path.join(WIKI_PATH, "索引.md")
|
||
if not os.path.exists(index_file):
|
||
return jsonify({"code": 0, "data": {"content": "", "topics": []}})
|
||
with open(index_file, 'r', encoding='utf-8') as f:
|
||
content = f.read()
|
||
topics = []
|
||
current_topic = None
|
||
current_subtopic = None
|
||
for line in content.split('\n'):
|
||
if line.startswith('## '):
|
||
current_topic = line[3:].strip()
|
||
current_subtopic = None
|
||
topics.append({"name": current_topic, "articles": [], "subtopics": []})
|
||
elif line.startswith('### '):
|
||
current_subtopic = line[4:].strip()
|
||
if topics:
|
||
topics[-1].setdefault("subtopics", []).append({
|
||
"name": current_subtopic, "articles": []
|
||
})
|
||
elif line.startswith('| ') and current_topic:
|
||
parts = line.split('|')
|
||
if len(parts) >= 4:
|
||
col1 = parts[1].strip() if len(parts) > 1 else ''
|
||
col2 = parts[2].strip() if len(parts) > 2 else ''
|
||
col3 = parts[3].strip() if len(parts) > 3 else ''
|
||
title = ''
|
||
path = ''
|
||
summary = ''
|
||
updated = ''
|
||
# 格式A: | [title](path) | summary | updated | (旧格式,链接在第一列)
|
||
if '](' in col1 or ('[[' in col1 and ']]' in col1):
|
||
link = col1
|
||
summary = col2
|
||
updated = col3
|
||
if '](' in link:
|
||
title = link[link.find('[')+1:link.find(']')]
|
||
path = link[link.find('(')+1:link.find(')')]
|
||
elif '[[' in link and ']]' in link:
|
||
raw = link[link.find('[[')+2:link.find(']]')]
|
||
if '|' in raw:
|
||
path, title = [p.strip() for p in raw.split('|', 1)]
|
||
else:
|
||
path = raw.strip()
|
||
# 格式B: | date | title | [[path]] | (训练记录格式,链接在第三列)
|
||
elif '[[' in col3 and ']]' in col3:
|
||
title = col2
|
||
link = col3
|
||
if '[[' in link and ']]' in link:
|
||
raw = link[link.find('[[')+2:link.find(']]')]
|
||
if '|' in raw:
|
||
path, tmp_title = [p.strip() for p in raw.split('|', 1)]
|
||
if not title: title = tmp_title
|
||
else:
|
||
path = raw.strip()
|
||
if path:
|
||
article = {
|
||
"title": title, "path": path,
|
||
"summary": summary,
|
||
"updated": updated
|
||
}
|
||
if current_subtopic and topics:
|
||
subs = topics[-1].setdefault("subtopics", [])
|
||
for sub in subs:
|
||
if sub["name"] == current_subtopic:
|
||
sub["articles"].append(article)
|
||
break
|
||
elif topics:
|
||
topics[-1]["articles"].append(article)
|
||
|
||
# 训练记录兜底同步:根索引漏写时,自动扫描目录补入卡片,避免文件已存在但页面不可见。
|
||
training_dir = os.path.join(WIKI_PATH, "工作指南", "训练记录")
|
||
training_subtopic = None
|
||
for topic in topics:
|
||
if topic.get("name") == "工作指南":
|
||
for subtopic in topic.get("subtopics", []):
|
||
if subtopic.get("name") == "训练记录":
|
||
training_subtopic = subtopic
|
||
break
|
||
if training_subtopic is None:
|
||
training_subtopic = {"name": "训练记录", "articles": []}
|
||
topic.setdefault("subtopics", []).append(training_subtopic)
|
||
break
|
||
if training_subtopic is not None and os.path.isdir(training_dir):
|
||
existing_paths = {article.get("path") for article in training_subtopic.get("articles", [])}
|
||
for filename in os.listdir(training_dir):
|
||
if not filename.endswith('.md') or filename == '索引.md':
|
||
continue
|
||
rel_path = os.path.join("工作指南", "训练记录", filename).replace(os.sep, '/')
|
||
if rel_path in existing_paths:
|
||
continue
|
||
full_path = os.path.join(training_dir, filename)
|
||
title = filename[:-3]
|
||
try:
|
||
with open(full_path, 'r', encoding='utf-8') as article_file:
|
||
for article_line in article_file:
|
||
if article_line.startswith('# '):
|
||
title = article_line[2:].strip()
|
||
break
|
||
except OSError:
|
||
continue
|
||
updated = datetime.fromtimestamp(os.path.getmtime(full_path)).strftime('%Y-%m-%d')
|
||
training_subtopic["articles"].append({
|
||
"title": title,
|
||
"path": rel_path,
|
||
"summary": "",
|
||
"updated": updated
|
||
})
|
||
existing_paths.add(rel_path)
|
||
|
||
return jsonify({"code": 0, "data": {"content": content, "topics": topics}})
|
||
|
||
@knowledge_bp.route('/api/knowledge/article')
|
||
@login_required
|
||
def get_article():
|
||
path = request.args.get('path', '')
|
||
full_path = _safe_path(WIKI_PATH, path)
|
||
if not full_path or not full_path.endswith('.md'):
|
||
return jsonify({"code": 1, "msg": "路径非法"})
|
||
if not os.path.exists(full_path):
|
||
return jsonify({"code": 1, "msg": "文章不存在"})
|
||
with open(full_path, 'r', encoding='utf-8') as f:
|
||
content = f.read()
|
||
mtime = datetime.fromtimestamp(os.path.getmtime(full_path)).strftime('%Y-%m-%d %H:%M')
|
||
return jsonify({"code": 0, "data": {"content": content, "updated": mtime, "path": path}})
|
||
|
||
@knowledge_bp.route('/api/knowledge/search')
|
||
@login_required
|
||
def search_knowledge():
|
||
q = request.args.get('q', '').strip().lower()
|
||
if not q or len(q) < 1:
|
||
return jsonify({"code": 1, "msg": "请输入搜索关键词"})
|
||
results = []
|
||
for root, dirs, files in os.walk(WIKI_PATH):
|
||
for f in files:
|
||
fpath = os.path.join(root, f)
|
||
rel = os.path.relpath(fpath, WIKI_PATH)
|
||
if not f.endswith('.md') or f in ('索引.md', '操作日志.md'):
|
||
continue
|
||
with open(fpath, 'r', encoding='utf-8', errors='ignore') as fh:
|
||
content = fh.read()
|
||
if q in content.lower():
|
||
title = f[:-3]
|
||
for line in content.split('\n'):
|
||
if line.startswith('# '):
|
||
title = line[2:].strip()
|
||
break
|
||
results.append({"title": title, "path": rel})
|
||
return jsonify({"code": 0, "data": results, "count": len(results)})
|
||
|
||
@knowledge_bp.route('/api/knowledge/log')
|
||
@login_required
|
||
def get_log():
|
||
log_file = os.path.join(WIKI_PATH, "操作日志.md")
|
||
content = ""
|
||
if os.path.exists(log_file):
|
||
with open(log_file, 'r', encoding='utf-8') as f:
|
||
content = f.read()
|
||
return jsonify({"code": 0, "data": content})
|
||
|
||
@knowledge_bp.route('/api/knowledge/profile')
|
||
@login_required
|
||
def get_profile():
|
||
profile_file = os.path.join(VAULT_PATH, "画像", "我的画像.md")
|
||
atoms_file = os.path.join(VAULT_PATH, "画像", "atoms.json")
|
||
profile = ""
|
||
atoms = {}
|
||
if os.path.exists(profile_file):
|
||
with open(profile_file, 'r', encoding='utf-8') as f:
|
||
profile = f.read()
|
||
if os.path.exists(atoms_file):
|
||
with open(atoms_file, 'r', encoding='utf-8') as f:
|
||
try:
|
||
atoms = json.load(f)
|
||
except:
|
||
atoms = {}
|
||
return jsonify({"code": 0, "data": {"profile": profile, "atoms": atoms}})
|
||
|
||
@knowledge_bp.route('/api/knowledge/datalib')
|
||
@login_required
|
||
def get_datalib():
|
||
base = os.path.join(VAULT_PATH, "资料库")
|
||
files = ["我的注视.md", "我的技巧.md", "我的目标.md", "我的偏好.md", "我的疑问.md"]
|
||
data = {}
|
||
for fname in files:
|
||
fpath = os.path.join(base, fname)
|
||
if os.path.exists(fpath):
|
||
with open(fpath, 'r', encoding='utf-8') as f:
|
||
data[fname.replace('.md', '')] = f.read()
|
||
return jsonify({"code": 0, "data": data})
|
||
|
||
# ─── 新增 API:CRUD ───
|
||
|
||
@knowledge_bp.route('/api/knowledge/article/create', methods=['POST'])
|
||
@login_required
|
||
def create_article():
|
||
"""新建文章"""
|
||
data = request.json or {}
|
||
title = data.get('title', '').strip()
|
||
content = data.get('content', '').strip()
|
||
topic = data.get('topic', '').strip()
|
||
|
||
if not title or not content or not topic:
|
||
return jsonify({"code": 1, "msg": "标题、内容、主题不能为空"})
|
||
|
||
# 验证主题目录存在
|
||
topic_dir = os.path.join(WIKI_PATH, topic)
|
||
if not os.path.isdir(topic_dir):
|
||
return jsonify({"code": 1, "msg": f"主题「{topic}」不存在"})
|
||
|
||
# 文件名:去除特殊字符
|
||
safe_name = re.sub(r'[\\/:*?"<>|]', '_', title)[:60]
|
||
filepath = os.path.join(topic_dir, f"{safe_name}.md")
|
||
|
||
if os.path.exists(filepath):
|
||
# 加后缀
|
||
idx = 2
|
||
while os.path.exists(os.path.join(topic_dir, f"{safe_name}_{idx}.md")):
|
||
idx += 1
|
||
filepath = os.path.join(topic_dir, f"{safe_name}_{idx}.md")
|
||
|
||
full_content = f"# {title}\n\n{content}\n"
|
||
with open(filepath, 'w', encoding='utf-8') as f:
|
||
f.write(full_content)
|
||
|
||
rel_path = os.path.relpath(filepath, WIKI_PATH)
|
||
today = datetime.now().strftime('%Y-%m-%d')
|
||
_update_index(title, rel_path, "新建", today)
|
||
_append_log(f"## [{today}] 新建 | {title}")
|
||
|
||
return jsonify({"code": 0, "msg": "创建成功", "data": {"path": rel_path}})
|
||
|
||
@knowledge_bp.route('/api/knowledge/article/update', methods=['POST'])
|
||
@login_required
|
||
def update_article():
|
||
"""更新文章"""
|
||
data = request.json or {}
|
||
path = data.get('path', '')
|
||
content = data.get('content', '')
|
||
|
||
full_path = _safe_path(WIKI_PATH, path)
|
||
if not full_path or not full_path.endswith('.md'):
|
||
return jsonify({"code": 1, "msg": "路径非法"})
|
||
if not os.path.exists(full_path):
|
||
return jsonify({"code": 1, "msg": "文章不存在"})
|
||
|
||
# 保留源信息行(> Source: 等)
|
||
with open(full_path, 'r', encoding='utf-8') as f:
|
||
old_content = f.read()
|
||
|
||
# 提取原标题
|
||
title = path.split('/')[-1].replace('.md', '')
|
||
for line in old_content.split('\n'):
|
||
if line.startswith('# '):
|
||
title = line[2:].strip()
|
||
break
|
||
|
||
with open(full_path, 'w', encoding='utf-8') as f:
|
||
f.write(content)
|
||
|
||
today = datetime.now().strftime('%Y-%m-%d')
|
||
_update_index(title, path, "已更新", today)
|
||
_append_log(f"## [{today}] 更新 | {title}")
|
||
|
||
return jsonify({"code": 0, "msg": "更新成功"})
|
||
|
||
@knowledge_bp.route('/api/knowledge/article/delete', methods=['POST'])
|
||
@login_required
|
||
def delete_article():
|
||
"""删除文章"""
|
||
data = request.json or {}
|
||
path = data.get('path', '')
|
||
|
||
full_path = _safe_path(WIKI_PATH, path)
|
||
if not full_path or not full_path.endswith('.md'):
|
||
return jsonify({"code": 1, "msg": "路径非法"})
|
||
if not os.path.exists(full_path):
|
||
return jsonify({"code": 1, "msg": "文章不存在"})
|
||
|
||
title = path.split('/')[-1].replace('.md', '')
|
||
for line in open(full_path, 'r', encoding='utf-8').readlines():
|
||
if line.startswith('# '):
|
||
title = line[2:].strip()
|
||
break
|
||
|
||
os.remove(full_path)
|
||
today = datetime.now().strftime('%Y-%m-%d')
|
||
_update_index(title, path, "", today, delete=True)
|
||
_append_log(f"## [{today}] 删除 | {title}")
|
||
|
||
return jsonify({"code": 0, "msg": "已删除"})
|
||
|
||
@knowledge_bp.route('/api/knowledge/export')
|
||
@login_required
|
||
def export_article():
|
||
"""导出文章为 markdown 文件"""
|
||
path = request.args.get('path', '')
|
||
full_path = _safe_path(WIKI_PATH, path)
|
||
if not full_path or not full_path.endswith('.md'):
|
||
return jsonify({"code": 1, "msg": "路径非法"})
|
||
if not os.path.exists(full_path):
|
||
return jsonify({"code": 1, "msg": "文章不存在"})
|
||
|
||
with open(full_path, 'r', encoding='utf-8') as f:
|
||
content = f.read()
|
||
|
||
return jsonify({"code": 0, "data": {"content": content, "filename": os.path.basename(full_path)}})
|
||
|
||
@knowledge_bp.route('/api/knowledge/import', methods=['POST'])
|
||
@login_required
|
||
def import_article():
|
||
"""导入 markdown 文件"""
|
||
topic = request.form.get('topic', '').strip()
|
||
file = request.files.get('file')
|
||
|
||
if not file or not topic:
|
||
return jsonify({"code": 1, "msg": "请选择文件和主题"})
|
||
|
||
topic_dir = os.path.join(WIKI_PATH, topic)
|
||
if not os.path.isdir(topic_dir):
|
||
return jsonify({"code": 1, "msg": f"主题「{topic}」不存在"})
|
||
|
||
content = file.read().decode('utf-8', errors='ignore')
|
||
filename = file.filename or '导入文档.md'
|
||
if not filename.endswith('.md'):
|
||
filename += '.md'
|
||
|
||
filepath = os.path.join(topic_dir, filename)
|
||
if os.path.exists(filepath):
|
||
name, ext = os.path.splitext(filename)
|
||
idx = 2
|
||
while os.path.exists(os.path.join(topic_dir, f"{name}_{idx}{ext}")):
|
||
idx += 1
|
||
filepath = os.path.join(topic_dir, f"{name}_{idx}{ext}")
|
||
|
||
with open(filepath, 'w', encoding='utf-8') as f:
|
||
f.write(content)
|
||
|
||
# 提取标题
|
||
title = os.path.basename(filepath).replace('.md', '')
|
||
for line in content.split('\n'):
|
||
if line.startswith('# '):
|
||
title = line[2:].strip()
|
||
break
|
||
|
||
rel_path = os.path.relpath(filepath, WIKI_PATH)
|
||
today = datetime.now().strftime('%Y-%m-%d')
|
||
_update_index(title, rel_path, "导入", today)
|
||
_append_log(f"## [{today}] 导入 | {title}")
|
||
|
||
return jsonify({"code": 0, "msg": "导入成功", "data": {"path": rel_path, "title": title}})
|
||
|
||
# ─── 新增 API:分析功能 ───
|
||
|
||
@knowledge_bp.route('/api/knowledge/lint', methods=['POST'])
|
||
@login_required
|
||
def lint_knowledge():
|
||
"""运行检查(自动修复+报告)"""
|
||
import subprocess
|
||
result = subprocess.run(
|
||
["python3", "/home/gxd/.openclaw/workspace/dy-dashboard/lint_knowledge.py"],
|
||
capture_output=True, text=True, timeout=60
|
||
)
|
||
return jsonify({
|
||
"code": 0,
|
||
"data": {
|
||
"stdout": result.stdout,
|
||
"stderr": result.stderr[:500] if result.stderr else "",
|
||
"success": result.returncode == 0
|
||
}
|
||
})
|
||
|
||
@knowledge_bp.route('/api/knowledge/topics')
|
||
@login_required
|
||
def get_topics():
|
||
"""获取所有主题"""
|
||
topics = []
|
||
for name in sorted(os.listdir(WIKI_PATH)):
|
||
d = os.path.join(WIKI_PATH, name)
|
||
if os.path.isdir(d) and not name.startswith('.'):
|
||
topics.append(name)
|
||
return jsonify({"code": 0, "data": topics})
|
||
|
||
@knowledge_bp.route('/api/knowledge/contradictions')
|
||
@login_required
|
||
def get_contradictions():
|
||
"""矛盾分析 - 查找文章中可能矛盾的内容"""
|
||
results = []
|
||
all_articles = {}
|
||
|
||
# 收集所有文章
|
||
for root, dirs, files in os.walk(WIKI_PATH):
|
||
for f in files:
|
||
fpath = os.path.join(root, f)
|
||
rel = os.path.relpath(fpath, WIKI_PATH)
|
||
if not f.endswith('.md') or f in ('索引.md', '操作日志.md'):
|
||
continue
|
||
with open(fpath, 'r', encoding='utf-8', errors='ignore') as fh:
|
||
all_articles[rel] = fh.read()
|
||
|
||
# 简单矛盾检测:同一个概念在不同文章中有不同说法
|
||
concepts = {
|
||
'安全库存': ['安全库存', '安全库存天数'],
|
||
'日销量': ['日销量', '日销量预测', '日均销量'],
|
||
'产品分级': ['S级', 'A级', 'B级', 'C级'],
|
||
'采购': ['采购', '采购订单', '采购在途'],
|
||
'库存': ['库存', '库存量'],
|
||
}
|
||
|
||
for concept, keywords in concepts.items():
|
||
articles_with_concept = []
|
||
for path, content in all_articles.items():
|
||
for kw in keywords:
|
||
if kw in content:
|
||
articles_with_concept.append(path)
|
||
break
|
||
if len(articles_with_concept) >= 2:
|
||
results.append({
|
||
"concept": concept,
|
||
"keyword": keywords[0],
|
||
"articles": articles_with_concept
|
||
})
|
||
|
||
return jsonify({"code": 0, "data": results, "count": len(results)})
|
||
|
||
@knowledge_bp.route('/api/knowledge/graph')
|
||
@login_required
|
||
def get_graph():
|
||
"""关系图谱数据"""
|
||
nodes = []
|
||
edges = []
|
||
node_set = set()
|
||
|
||
# 收集所有文章作为节点
|
||
for root, dirs, files in os.walk(WIKI_PATH):
|
||
for f in files:
|
||
fpath = os.path.join(root, f)
|
||
rel = os.path.relpath(fpath, WIKI_PATH)
|
||
if not f.endswith('.md') or f in ('索引.md', '操作日志.md'):
|
||
continue
|
||
|
||
topic = rel.split('/')[0] if '/' in rel else '其他'
|
||
nodes.append({
|
||
"id": rel,
|
||
"label": f[:-3],
|
||
"topic": topic
|
||
})
|
||
node_set.add(rel)
|
||
|
||
# 找出文章中的内部链接作为边
|
||
with open(fpath, 'r', encoding='utf-8', errors='ignore') as fh:
|
||
content = fh.read()
|
||
for m in re.finditer(r'\]\(([^)]+\.md)\)', content):
|
||
target = m.group(1)
|
||
# 解析相对路径
|
||
target_path = os.path.normpath(os.path.join(os.path.dirname(fpath), target))
|
||
target_rel = os.path.relpath(target_path, WIKI_PATH)
|
||
if target_rel in node_set or os.path.exists(target_path):
|
||
edges.append({
|
||
"source": rel,
|
||
"target": target_rel
|
||
})
|
||
|
||
return jsonify({"code": 0, "data": {"nodes": nodes, "edges": edges}})
|