dy-dashboard/routes/knowledge.py
2026-10-28 11:14:17 +08:00

560 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""知识库路由 - 完整 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}})