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