|
@@ -101,49 +101,6 @@ def init_db():
|
|
|
|
|
|
|
|
# --- Models ---
|
|
# --- Models ---
|
|
|
|
|
|
|
|
-class ExperienceIn(BaseModel):
|
|
|
|
|
- name: str
|
|
|
|
|
- url: str = ""
|
|
|
|
|
- category: str = ""
|
|
|
|
|
- task: str
|
|
|
|
|
- score: int = Field(ge=1, le=5)
|
|
|
|
|
- outcome: str = ""
|
|
|
|
|
- tips: str = ""
|
|
|
|
|
- content_id: str = ""
|
|
|
|
|
- submitted_by: str = ""
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-class ExperienceOut(BaseModel):
|
|
|
|
|
- task: str
|
|
|
|
|
- score: int
|
|
|
|
|
- outcome: str
|
|
|
|
|
- tips: str
|
|
|
|
|
- content_id: str
|
|
|
|
|
- submitted_by: str
|
|
|
|
|
- created_at: str
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-class ResourceResult(BaseModel):
|
|
|
|
|
- name: str
|
|
|
|
|
- url: str
|
|
|
|
|
- relevant_experiences: list[ExperienceOut]
|
|
|
|
|
- avg_score: float
|
|
|
|
|
- experience_count: int
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-class SearchResponse(BaseModel):
|
|
|
|
|
- results: list[ResourceResult]
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-class ResourceDetailResponse(BaseModel):
|
|
|
|
|
- name: str
|
|
|
|
|
- url: str
|
|
|
|
|
- category: str
|
|
|
|
|
- avg_score: float
|
|
|
|
|
- experience_count: int
|
|
|
|
|
- experiences: list[ExperienceOut]
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
class ContentIn(BaseModel):
|
|
class ContentIn(BaseModel):
|
|
|
id: str
|
|
id: str
|
|
|
title: str = ""
|
|
title: str = ""
|
|
@@ -197,6 +154,14 @@ class KnowledgePatchIn(BaseModel):
|
|
|
owner: Optional[str] = None
|
|
owner: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+class MessageExtractIn(BaseModel):
|
|
|
|
|
+ """POST /api/extract 请求体(消息历史提取)"""
|
|
|
|
|
+ messages: list[dict] # [{role: str, content: str}, ...]
|
|
|
|
|
+ agent_id: str = "unknown"
|
|
|
|
|
+ submitted_by: str # 必填,作为 owner
|
|
|
|
|
+ session_key: str = ""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
class KnowledgeBatchUpdateIn(BaseModel):
|
|
class KnowledgeBatchUpdateIn(BaseModel):
|
|
|
feedback_list: list[dict]
|
|
feedback_list: list[dict]
|
|
|
|
|
|
|
@@ -232,521 +197,110 @@ async def lifespan(app: FastAPI):
|
|
|
app = FastAPI(title=BRAND_NAME, lifespan=lifespan)
|
|
app = FastAPI(title=BRAND_NAME, lifespan=lifespan)
|
|
|
|
|
|
|
|
|
|
|
|
|
-@app.get("/", response_class=HTMLResponse)
|
|
|
|
|
-def frontend():
|
|
|
|
|
- """KnowHub 管理前端"""
|
|
|
|
|
- return """<!DOCTYPE html>
|
|
|
|
|
-<html lang="zh-CN">
|
|
|
|
|
-<head>
|
|
|
|
|
- <meta charset="UTF-8">
|
|
|
|
|
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
- <title>KnowHub 管理</title>
|
|
|
|
|
- <script src="https://cdn.tailwindcss.com"></script>
|
|
|
|
|
-</head>
|
|
|
|
|
-<body class="bg-gray-50">
|
|
|
|
|
- <div class="container mx-auto px-4 py-8 max-w-7xl">
|
|
|
|
|
- <div class="flex justify-between items-center mb-8">
|
|
|
|
|
- <h1 class="text-3xl font-bold text-gray-800">KnowHub 全局知识库</h1>
|
|
|
|
|
- <button onclick="openAddModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg">
|
|
|
|
|
- + 新增知识
|
|
|
|
|
- </button>
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+# --- Knowledge API ---
|
|
|
|
|
|
|
|
- <!-- 筛选栏 -->
|
|
|
|
|
- <div class="bg-white rounded-lg shadow p-6 mb-6">
|
|
|
|
|
- <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
|
|
|
- <div>
|
|
|
|
|
- <label class="block text-sm font-medium text-gray-700 mb-2">类型 (Types)</label>
|
|
|
|
|
- <div class="space-y-2">
|
|
|
|
|
- <label class="flex items-center"><input type="checkbox" value="strategy" class="mr-2 type-filter"> Strategy</label>
|
|
|
|
|
- <label class="flex items-center"><input type="checkbox" value="tool" class="mr-2 type-filter"> Tool</label>
|
|
|
|
|
- <label class="flex items-center"><input type="checkbox" value="user_profile" class="mr-2 type-filter"> User Profile</label>
|
|
|
|
|
- <label class="flex items-center"><input type="checkbox" value="usecase" class="mr-2 type-filter"> Usecase</label>
|
|
|
|
|
- <label class="flex items-center"><input type="checkbox" value="definition" class="mr-2 type-filter"> Definition</label>
|
|
|
|
|
- <label class="flex items-center"><input type="checkbox" value="plan" class="mr-2 type-filter"> Plan</label>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div>
|
|
|
|
|
- <label class="block text-sm font-medium text-gray-700 mb-2">Tags</label>
|
|
|
|
|
- <div id="tagsFilterContainer" class="space-y-2 max-h-40 overflow-y-auto">
|
|
|
|
|
- <p class="text-sm text-gray-500">加载中...</p>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div>
|
|
|
|
|
- <label class="block text-sm font-medium text-gray-700 mb-2">Owner</label>
|
|
|
|
|
- <input type="text" id="ownerFilter" placeholder="输入 owner" class="w-full border rounded px-3 py-2">
|
|
|
|
|
- </div>
|
|
|
|
|
- <div>
|
|
|
|
|
- <label class="block text-sm font-medium text-gray-700 mb-2">Scopes</label>
|
|
|
|
|
- <input type="text" id="scopesFilter" placeholder="输入 scope" class="w-full border rounded px-3 py-2">
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- <button onclick="applyFilters()" class="mt-4 bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded">
|
|
|
|
|
- 应用筛选
|
|
|
|
|
- </button>
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+@app.post("/api/content", status_code=201)
|
|
|
|
|
+def submit_content(content: ContentIn):
|
|
|
|
|
+ conn = get_db()
|
|
|
|
|
+ try:
|
|
|
|
|
+ now = datetime.now(timezone.utc).isoformat()
|
|
|
|
|
+ conn.execute(
|
|
|
|
|
+ "INSERT OR REPLACE INTO contents"
|
|
|
|
|
+ "(id, title, body, sort_order, submitted_by, created_at)"
|
|
|
|
|
+ " VALUES (?, ?, ?, ?, ?, ?)",
|
|
|
|
|
+ (content.id, content.title, content.body, content.sort_order, content.submitted_by, now),
|
|
|
|
|
+ )
|
|
|
|
|
+ conn.commit()
|
|
|
|
|
+ return {"status": "ok"}
|
|
|
|
|
+ finally:
|
|
|
|
|
+ conn.close()
|
|
|
|
|
|
|
|
- <!-- 知识列表 -->
|
|
|
|
|
- <div id="knowledgeList" class="space-y-4"></div>
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
|
|
|
- <!-- 新增/编辑 Modal -->
|
|
|
|
|
- <div id="modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4">
|
|
|
|
|
- <div class="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto p-6">
|
|
|
|
|
- <h2 id="modalTitle" class="text-2xl font-bold mb-4">新增知识</h2>
|
|
|
|
|
- <form id="knowledgeForm" class="space-y-4">
|
|
|
|
|
- <input type="hidden" id="editId">
|
|
|
|
|
- <div>
|
|
|
|
|
- <label class="block text-sm font-medium mb-1">Task *</label>
|
|
|
|
|
- <input type="text" id="taskInput" required class="w-full border rounded px-3 py-2">
|
|
|
|
|
- </div>
|
|
|
|
|
- <div>
|
|
|
|
|
- <label class="block text-sm font-medium mb-1">Content *</label>
|
|
|
|
|
- <textarea id="contentInput" required rows="6" class="w-full border rounded px-3 py-2"></textarea>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div>
|
|
|
|
|
- <label class="block text-sm font-medium mb-1">Types (多选)</label>
|
|
|
|
|
- <div class="space-y-1">
|
|
|
|
|
- <label class="flex items-center"><input type="checkbox" value="strategy" class="mr-2 type-checkbox"> Strategy</label>
|
|
|
|
|
- <label class="flex items-center"><input type="checkbox" value="tool" class="mr-2 type-checkbox"> Tool</label>
|
|
|
|
|
- <label class="flex items-center"><input type="checkbox" value="user_profile" class="mr-2 type-checkbox"> User Profile</label>
|
|
|
|
|
- <label class="flex items-center"><input type="checkbox" value="usecase" class="mr-2 type-checkbox"> Usecase</label>
|
|
|
|
|
- <label class="flex items-center"><input type="checkbox" value="definition" class="mr-2 type-checkbox"> Definition</label>
|
|
|
|
|
- <label class="flex items-center"><input type="checkbox" value="plan" class="mr-2 type-checkbox"> Plan</label>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div>
|
|
|
|
|
- <label class="block text-sm font-medium mb-1">Tags (JSON)</label>
|
|
|
|
|
- <textarea id="tagsInput" rows="2" placeholder='{"key": "value"}' class="w-full border rounded px-3 py-2"></textarea>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div>
|
|
|
|
|
- <label class="block text-sm font-medium mb-1">Scopes (逗号分隔)</label>
|
|
|
|
|
- <input type="text" id="scopesInput" placeholder="org:cybertogether" class="w-full border rounded px-3 py-2">
|
|
|
|
|
- </div>
|
|
|
|
|
- <div>
|
|
|
|
|
- <label class="block text-sm font-medium mb-1">Owner</label>
|
|
|
|
|
- <input type="text" id="ownerInput" class="w-full border rounded px-3 py-2">
|
|
|
|
|
- </div>
|
|
|
|
|
- <div class="flex gap-2 pt-4">
|
|
|
|
|
- <button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded">保存</button>
|
|
|
|
|
- <button type="button" onclick="closeModal()" class="bg-gray-300 hover:bg-gray-400 px-6 py-2 rounded">取消</button>
|
|
|
|
|
- </div>
|
|
|
|
|
- </form>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+@app.get("/api/content/{content_id:path}", response_model=ContentOut)
|
|
|
|
|
+def get_content(content_id: str):
|
|
|
|
|
+ conn = get_db()
|
|
|
|
|
+ try:
|
|
|
|
|
+ row = conn.execute(
|
|
|
|
|
+ "SELECT id, title, body, sort_order FROM contents WHERE id = ?",
|
|
|
|
|
+ (content_id,),
|
|
|
|
|
+ ).fetchone()
|
|
|
|
|
+ if not row:
|
|
|
|
|
+ raise HTTPException(status_code=404, detail=f"Content not found: {content_id}")
|
|
|
|
|
|
|
|
- <script>
|
|
|
|
|
- let allKnowledge = [];
|
|
|
|
|
- let availableTags = [];
|
|
|
|
|
|
|
+ # 计算导航上下文
|
|
|
|
|
+ root_id = content_id.split("/")[0] if "/" in content_id else content_id
|
|
|
|
|
|
|
|
- async function loadTags() {
|
|
|
|
|
- const res = await fetch('/api/knowledge/meta/tags');
|
|
|
|
|
- const data = await res.json();
|
|
|
|
|
- availableTags = data.tags;
|
|
|
|
|
- renderTagsFilter();
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ # TOC (根节点)
|
|
|
|
|
+ toc = None
|
|
|
|
|
+ if "/" in content_id:
|
|
|
|
|
+ toc_row = conn.execute(
|
|
|
|
|
+ "SELECT id, title FROM contents WHERE id = ?",
|
|
|
|
|
+ (root_id,),
|
|
|
|
|
+ ).fetchone()
|
|
|
|
|
+ if toc_row:
|
|
|
|
|
+ toc = ContentNode(id=toc_row["id"], title=toc_row["title"])
|
|
|
|
|
|
|
|
- function renderTagsFilter() {
|
|
|
|
|
- const container = document.getElementById('tagsFilterContainer');
|
|
|
|
|
- if (availableTags.length === 0) {
|
|
|
|
|
- container.innerHTML = '<p class="text-sm text-gray-500">暂无 tags</p>';
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- container.innerHTML = availableTags.map(tag =>
|
|
|
|
|
- `<label class="flex items-center"><input type="checkbox" value="${escapeHtml(tag)}" class="mr-2 tag-filter"> ${escapeHtml(tag)}</label>`
|
|
|
|
|
- ).join('');
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ # Children (子节点)
|
|
|
|
|
+ children = []
|
|
|
|
|
+ children_rows = conn.execute(
|
|
|
|
|
+ "SELECT id, title FROM contents WHERE id LIKE ? AND id != ? ORDER BY sort_order",
|
|
|
|
|
+ (f"{content_id}/%", content_id),
|
|
|
|
|
+ ).fetchall()
|
|
|
|
|
+ children = [ContentNode(id=r["id"], title=r["title"]) for r in children_rows]
|
|
|
|
|
|
|
|
- async function loadKnowledge() {
|
|
|
|
|
- const params = new URLSearchParams();
|
|
|
|
|
- params.append('limit', '1000');
|
|
|
|
|
|
|
+ # Prev/Next (同级节点)
|
|
|
|
|
+ prev_node = None
|
|
|
|
|
+ next_node = None
|
|
|
|
|
+ if "/" in content_id:
|
|
|
|
|
+ siblings = conn.execute(
|
|
|
|
|
+ "SELECT id, title, sort_order FROM contents WHERE id LIKE ? AND id NOT LIKE ? ORDER BY sort_order",
|
|
|
|
|
+ (f"{root_id}/%", f"{root_id}/%/%"),
|
|
|
|
|
+ ).fetchall()
|
|
|
|
|
+ for i, sib in enumerate(siblings):
|
|
|
|
|
+ if sib["id"] == content_id:
|
|
|
|
|
+ if i > 0:
|
|
|
|
|
+ prev_node = ContentNode(id=siblings[i-1]["id"], title=siblings[i-1]["title"])
|
|
|
|
|
+ if i < len(siblings) - 1:
|
|
|
|
|
+ next_node = ContentNode(id=siblings[i+1]["id"], title=siblings[i+1]["title"])
|
|
|
|
|
+ break
|
|
|
|
|
|
|
|
- const selectedTypes = Array.from(document.querySelectorAll('.type-filter:checked')).map(el => el.value);
|
|
|
|
|
- if (selectedTypes.length > 0) {
|
|
|
|
|
- params.append('types', selectedTypes.join(','));
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ return ContentOut(
|
|
|
|
|
+ id=row["id"],
|
|
|
|
|
+ title=row["title"],
|
|
|
|
|
+ body=row["body"],
|
|
|
|
|
+ toc=toc,
|
|
|
|
|
+ children=children,
|
|
|
|
|
+ prev=prev_node,
|
|
|
|
|
+ next=next_node,
|
|
|
|
|
+ )
|
|
|
|
|
+ finally:
|
|
|
|
|
+ conn.close()
|
|
|
|
|
|
|
|
- const selectedTags = Array.from(document.querySelectorAll('.tag-filter:checked')).map(el => el.value);
|
|
|
|
|
- if (selectedTags.length > 0) {
|
|
|
|
|
- params.append('tags', selectedTags.join(','));
|
|
|
|
|
- }
|
|
|
|
|
|
|
|
|
|
- const ownerFilter = document.getElementById('ownerFilter').value.trim();
|
|
|
|
|
- if (ownerFilter) {
|
|
|
|
|
- params.append('owner', ownerFilter);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+# ===== Knowledge API =====
|
|
|
|
|
|
|
|
- const scopesFilter = document.getElementById('scopesFilter').value.trim();
|
|
|
|
|
- if (scopesFilter) {
|
|
|
|
|
- params.append('scopes', scopesFilter);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+# 两阶段检索逻辑
|
|
|
|
|
+async def _route_knowledge_by_llm(query_text: str, metadata_list: list[dict], k: int = 5) -> list[str]:
|
|
|
|
|
+ """
|
|
|
|
|
+ 第一阶段:语义路由。
|
|
|
|
|
+ 让 LLM 挑选出 2*k 个语义相关的 ID。
|
|
|
|
|
+ """
|
|
|
|
|
+ if not metadata_list:
|
|
|
|
|
+ return []
|
|
|
|
|
|
|
|
- const res = await fetch(`/api/knowledge?${params.toString()}`);
|
|
|
|
|
- const data = await res.json();
|
|
|
|
|
- allKnowledge = data.results;
|
|
|
|
|
- renderKnowledge(allKnowledge);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ routing_k = k * 2
|
|
|
|
|
|
|
|
- function applyFilters() {
|
|
|
|
|
- loadKnowledge();
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ routing_data = [
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": m["id"],
|
|
|
|
|
+ "types": m["types"],
|
|
|
|
|
+ "task": m["task"][:100]
|
|
|
|
|
+ } for m in metadata_list
|
|
|
|
|
+ ]
|
|
|
|
|
|
|
|
- function renderKnowledge(list) {
|
|
|
|
|
- const container = document.getElementById('knowledgeList');
|
|
|
|
|
- if (list.length === 0) {
|
|
|
|
|
- container.innerHTML = '<p class="text-gray-500 text-center py-8">暂无知识</p>';
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- container.innerHTML = list.map(k => `
|
|
|
|
|
- <div class="bg-white rounded-lg shadow p-6 hover:shadow-lg transition cursor-pointer" onclick="openEditModal('${k.id}')">
|
|
|
|
|
- <div class="flex justify-between items-start mb-2">
|
|
|
|
|
- <div class="flex gap-2 flex-wrap">
|
|
|
|
|
- ${k.types.map(t => `<span class="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">${t}</span>`).join('')}
|
|
|
|
|
- </div>
|
|
|
|
|
- <span class="text-sm text-gray-500">${k.eval.score || 3}/5</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- <h3 class="text-lg font-semibold text-gray-800 mb-2">${escapeHtml(k.task)}</h3>
|
|
|
|
|
- <p class="text-sm text-gray-600 mb-2">${escapeHtml(k.content.substring(0, 150))}${k.content.length > 150 ? '...' : ''}</p>
|
|
|
|
|
- <div class="flex justify-between text-xs text-gray-500">
|
|
|
|
|
- <span>Owner: ${k.owner || 'N/A'}</span>
|
|
|
|
|
- <span>${new Date(k.created_at).toLocaleDateString()}</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- `).join('');
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- function openAddModal() {
|
|
|
|
|
- document.getElementById('modalTitle').textContent = '新增知识';
|
|
|
|
|
- document.getElementById('knowledgeForm').reset();
|
|
|
|
|
- document.getElementById('editId').value = '';
|
|
|
|
|
- document.querySelectorAll('.type-checkbox').forEach(el => el.checked = false);
|
|
|
|
|
- document.getElementById('modal').classList.remove('hidden');
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- async function openEditModal(id) {
|
|
|
|
|
- const k = allKnowledge.find(item => item.id === id);
|
|
|
|
|
- if (!k) return;
|
|
|
|
|
-
|
|
|
|
|
- document.getElementById('modalTitle').textContent = '编辑知识';
|
|
|
|
|
- document.getElementById('editId').value = k.id;
|
|
|
|
|
- document.getElementById('taskInput').value = k.task;
|
|
|
|
|
- document.getElementById('contentInput').value = k.content;
|
|
|
|
|
- document.getElementById('tagsInput').value = JSON.stringify(k.tags);
|
|
|
|
|
- document.getElementById('scopesInput').value = k.scopes.join(', ');
|
|
|
|
|
- document.getElementById('ownerInput').value = k.owner;
|
|
|
|
|
-
|
|
|
|
|
- document.querySelectorAll('.type-checkbox').forEach(el => {
|
|
|
|
|
- el.checked = k.types.includes(el.value);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- document.getElementById('modal').classList.remove('hidden');
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- function closeModal() {
|
|
|
|
|
- document.getElementById('modal').classList.add('hidden');
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- document.getElementById('knowledgeForm').addEventListener('submit', async (e) => {
|
|
|
|
|
- e.preventDefault();
|
|
|
|
|
-
|
|
|
|
|
- const editId = document.getElementById('editId').value;
|
|
|
|
|
- const task = document.getElementById('taskInput').value;
|
|
|
|
|
- const content = document.getElementById('contentInput').value;
|
|
|
|
|
- const types = Array.from(document.querySelectorAll('.type-checkbox:checked')).map(el => el.value);
|
|
|
|
|
- const tagsText = document.getElementById('tagsInput').value.trim();
|
|
|
|
|
- const scopesText = document.getElementById('scopesInput').value.trim();
|
|
|
|
|
- const owner = document.getElementById('ownerInput').value.trim();
|
|
|
|
|
-
|
|
|
|
|
- let tags = {};
|
|
|
|
|
- if (tagsText) {
|
|
|
|
|
- try {
|
|
|
|
|
- tags = JSON.parse(tagsText);
|
|
|
|
|
- } catch (e) {
|
|
|
|
|
- alert('Tags JSON 格式错误');
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const scopes = scopesText ? scopesText.split(',').map(s => s.trim()).filter(s => s) : ['org:cybertogether'];
|
|
|
|
|
-
|
|
|
|
|
- if (editId) {
|
|
|
|
|
- // 编辑
|
|
|
|
|
- const res = await fetch(`/api/knowledge/${editId}`, {
|
|
|
|
|
- method: 'PATCH',
|
|
|
|
|
- headers: {'Content-Type': 'application/json'},
|
|
|
|
|
- body: JSON.stringify({task, content, types, tags, scopes, owner})
|
|
|
|
|
- });
|
|
|
|
|
- if (!res.ok) {
|
|
|
|
|
- alert('更新失败');
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- } else {
|
|
|
|
|
- // 新增
|
|
|
|
|
- const res = await fetch('/api/knowledge', {
|
|
|
|
|
- method: 'POST',
|
|
|
|
|
- headers: {'Content-Type': 'application/json'},
|
|
|
|
|
- body: JSON.stringify({task, content, types, tags, scopes, owner})
|
|
|
|
|
- });
|
|
|
|
|
- if (!res.ok) {
|
|
|
|
|
- alert('新增失败');
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- closeModal();
|
|
|
|
|
- await loadKnowledge();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- function escapeHtml(text) {
|
|
|
|
|
- const div = document.createElement('div');
|
|
|
|
|
- div.textContent = text;
|
|
|
|
|
- return div.innerHTML;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- loadTags();
|
|
|
|
|
- loadKnowledge();
|
|
|
|
|
- </script>
|
|
|
|
|
-</body>
|
|
|
|
|
-</html>"""
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-def _search_rows(conn: sqlite3.Connection, q: str, category: Optional[str]) -> list[sqlite3.Row]:
|
|
|
|
|
- """LIKE 搜索,拆词后 AND 连接,匹配 task + tips + outcome + name"""
|
|
|
|
|
- terms = q.split()
|
|
|
|
|
- if not terms:
|
|
|
|
|
- return []
|
|
|
|
|
-
|
|
|
|
|
- conditions = []
|
|
|
|
|
- params: list[str] = []
|
|
|
|
|
- for term in terms:
|
|
|
|
|
- like = f"%{term}%"
|
|
|
|
|
- conditions.append(
|
|
|
|
|
- "(task LIKE ? OR tips LIKE ? OR outcome LIKE ? OR name LIKE ?)"
|
|
|
|
|
- )
|
|
|
|
|
- params.extend([like, like, like, like])
|
|
|
|
|
-
|
|
|
|
|
- if category:
|
|
|
|
|
- conditions.append("category = ?")
|
|
|
|
|
- params.append(category)
|
|
|
|
|
-
|
|
|
|
|
- sql = (
|
|
|
|
|
- "SELECT name, url, category, task, score, outcome, tips, content_id, "
|
|
|
|
|
- "submitted_by, created_at FROM experiences WHERE "
|
|
|
|
|
- + " AND ".join(conditions)
|
|
|
|
|
- + " ORDER BY created_at DESC"
|
|
|
|
|
- )
|
|
|
|
|
- return conn.execute(sql, params).fetchall()
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-def _group_by_resource(rows: list[sqlite3.Row], limit: int) -> list[ResourceResult]:
|
|
|
|
|
- """按 name 分组并聚合"""
|
|
|
|
|
- groups: dict[str, list[sqlite3.Row]] = {}
|
|
|
|
|
- for row in rows:
|
|
|
|
|
- name = row["name"]
|
|
|
|
|
- if name not in groups:
|
|
|
|
|
- groups[name] = []
|
|
|
|
|
- groups[name].append(row)
|
|
|
|
|
-
|
|
|
|
|
- results = []
|
|
|
|
|
- for resource_name, resource_rows in groups.items():
|
|
|
|
|
- scores = [r["score"] for r in resource_rows]
|
|
|
|
|
- avg = sum(scores) / len(scores)
|
|
|
|
|
- results.append(ResourceResult(
|
|
|
|
|
- name=resource_name,
|
|
|
|
|
- url=resource_rows[0]["url"],
|
|
|
|
|
- relevant_experiences=[
|
|
|
|
|
- ExperienceOut(
|
|
|
|
|
- task=r["task"],
|
|
|
|
|
- score=r["score"],
|
|
|
|
|
- outcome=r["outcome"],
|
|
|
|
|
- tips=r["tips"],
|
|
|
|
|
- content_id=r["content_id"],
|
|
|
|
|
- submitted_by=r["submitted_by"],
|
|
|
|
|
- created_at=r["created_at"],
|
|
|
|
|
- )
|
|
|
|
|
- for r in resource_rows
|
|
|
|
|
- ],
|
|
|
|
|
- avg_score=round(avg, 1),
|
|
|
|
|
- experience_count=len(resource_rows),
|
|
|
|
|
- ))
|
|
|
|
|
-
|
|
|
|
|
- results.sort(key=lambda r: r.avg_score * r.experience_count, reverse=True)
|
|
|
|
|
- return results[:limit]
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-@app.get("/api/search", response_model=SearchResponse)
|
|
|
|
|
-def search_experiences(
|
|
|
|
|
- q: str = Query(..., min_length=1),
|
|
|
|
|
- category: Optional[str] = None,
|
|
|
|
|
- limit: int = Query(default=10, ge=1, le=50),
|
|
|
|
|
-):
|
|
|
|
|
- conn = get_db()
|
|
|
|
|
- try:
|
|
|
|
|
- rows = _search_rows(conn, q, category)
|
|
|
|
|
- return SearchResponse(results=_group_by_resource(rows, limit))
|
|
|
|
|
- finally:
|
|
|
|
|
- conn.close()
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-@app.post("/api/experience", status_code=201)
|
|
|
|
|
-def submit_experience(exp: ExperienceIn):
|
|
|
|
|
- conn = get_db()
|
|
|
|
|
- try:
|
|
|
|
|
- now = datetime.now(timezone.utc).isoformat()
|
|
|
|
|
- conn.execute(
|
|
|
|
|
- "INSERT INTO experiences"
|
|
|
|
|
- "(name, url, category, task, score, outcome, tips, content_id, submitted_by, created_at)"
|
|
|
|
|
- " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
|
|
|
- (exp.name, exp.url, exp.category, exp.task,
|
|
|
|
|
- exp.score, exp.outcome, exp.tips, exp.content_id, exp.submitted_by, now),
|
|
|
|
|
- )
|
|
|
|
|
- conn.commit()
|
|
|
|
|
- return {"status": "ok"}
|
|
|
|
|
- finally:
|
|
|
|
|
- conn.close()
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-@app.get("/api/resource/{name}", response_model=ResourceDetailResponse)
|
|
|
|
|
-def get_resource_experiences(name: str):
|
|
|
|
|
- conn = get_db()
|
|
|
|
|
- try:
|
|
|
|
|
- rows = conn.execute(
|
|
|
|
|
- "SELECT name, url, category, task, score, outcome, tips, content_id, "
|
|
|
|
|
- "submitted_by, created_at FROM experiences "
|
|
|
|
|
- "WHERE name = ? ORDER BY created_at DESC",
|
|
|
|
|
- (name,),
|
|
|
|
|
- ).fetchall()
|
|
|
|
|
- if not rows:
|
|
|
|
|
- raise HTTPException(status_code=404, detail=f"No experiences found for resource: {name}")
|
|
|
|
|
-
|
|
|
|
|
- scores = [r["score"] for r in rows]
|
|
|
|
|
- avg = sum(scores) / len(scores)
|
|
|
|
|
- return ResourceDetailResponse(
|
|
|
|
|
- name=name,
|
|
|
|
|
- url=rows[0]["url"],
|
|
|
|
|
- category=rows[0]["category"],
|
|
|
|
|
- avg_score=round(avg, 1),
|
|
|
|
|
- experience_count=len(rows),
|
|
|
|
|
- experiences=[
|
|
|
|
|
- ExperienceOut(
|
|
|
|
|
- task=r["task"],
|
|
|
|
|
- score=r["score"],
|
|
|
|
|
- outcome=r["outcome"],
|
|
|
|
|
- tips=r["tips"],
|
|
|
|
|
- content_id=r["content_id"],
|
|
|
|
|
- submitted_by=r["submitted_by"],
|
|
|
|
|
- created_at=r["created_at"],
|
|
|
|
|
- )
|
|
|
|
|
- for r in rows
|
|
|
|
|
- ],
|
|
|
|
|
- )
|
|
|
|
|
- finally:
|
|
|
|
|
- conn.close()
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-@app.post("/api/content", status_code=201)
|
|
|
|
|
-def submit_content(content: ContentIn):
|
|
|
|
|
- conn = get_db()
|
|
|
|
|
- try:
|
|
|
|
|
- now = datetime.now(timezone.utc).isoformat()
|
|
|
|
|
- conn.execute(
|
|
|
|
|
- "INSERT OR REPLACE INTO contents"
|
|
|
|
|
- "(id, title, body, sort_order, submitted_by, created_at)"
|
|
|
|
|
- " VALUES (?, ?, ?, ?, ?, ?)",
|
|
|
|
|
- (content.id, content.title, content.body, content.sort_order, content.submitted_by, now),
|
|
|
|
|
- )
|
|
|
|
|
- conn.commit()
|
|
|
|
|
- return {"status": "ok"}
|
|
|
|
|
- finally:
|
|
|
|
|
- conn.close()
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-@app.get("/api/content/{content_id:path}", response_model=ContentOut)
|
|
|
|
|
-def get_content(content_id: str):
|
|
|
|
|
- conn = get_db()
|
|
|
|
|
- try:
|
|
|
|
|
- row = conn.execute(
|
|
|
|
|
- "SELECT id, title, body, sort_order FROM contents WHERE id = ?",
|
|
|
|
|
- (content_id,),
|
|
|
|
|
- ).fetchone()
|
|
|
|
|
- if not row:
|
|
|
|
|
- raise HTTPException(status_code=404, detail=f"Content not found: {content_id}")
|
|
|
|
|
-
|
|
|
|
|
- # 计算导航上下文
|
|
|
|
|
- root_id = content_id.split("/")[0] if "/" in content_id else content_id
|
|
|
|
|
-
|
|
|
|
|
- # TOC (根节点)
|
|
|
|
|
- toc = None
|
|
|
|
|
- if "/" in content_id:
|
|
|
|
|
- toc_row = conn.execute(
|
|
|
|
|
- "SELECT id, title FROM contents WHERE id = ?",
|
|
|
|
|
- (root_id,),
|
|
|
|
|
- ).fetchone()
|
|
|
|
|
- if toc_row:
|
|
|
|
|
- toc = ContentNode(id=toc_row["id"], title=toc_row["title"])
|
|
|
|
|
-
|
|
|
|
|
- # Children (子节点)
|
|
|
|
|
- children = []
|
|
|
|
|
- children_rows = conn.execute(
|
|
|
|
|
- "SELECT id, title FROM contents WHERE id LIKE ? AND id != ? ORDER BY sort_order",
|
|
|
|
|
- (f"{content_id}/%", content_id),
|
|
|
|
|
- ).fetchall()
|
|
|
|
|
- children = [ContentNode(id=r["id"], title=r["title"]) for r in children_rows]
|
|
|
|
|
-
|
|
|
|
|
- # Prev/Next (同级节点)
|
|
|
|
|
- prev_node = None
|
|
|
|
|
- next_node = None
|
|
|
|
|
- if "/" in content_id:
|
|
|
|
|
- siblings = conn.execute(
|
|
|
|
|
- "SELECT id, title, sort_order FROM contents WHERE id LIKE ? AND id NOT LIKE ? ORDER BY sort_order",
|
|
|
|
|
- (f"{root_id}/%", f"{root_id}/%/%"),
|
|
|
|
|
- ).fetchall()
|
|
|
|
|
- for i, sib in enumerate(siblings):
|
|
|
|
|
- if sib["id"] == content_id:
|
|
|
|
|
- if i > 0:
|
|
|
|
|
- prev_node = ContentNode(id=siblings[i-1]["id"], title=siblings[i-1]["title"])
|
|
|
|
|
- if i < len(siblings) - 1:
|
|
|
|
|
- next_node = ContentNode(id=siblings[i+1]["id"], title=siblings[i+1]["title"])
|
|
|
|
|
- break
|
|
|
|
|
-
|
|
|
|
|
- return ContentOut(
|
|
|
|
|
- id=row["id"],
|
|
|
|
|
- title=row["title"],
|
|
|
|
|
- body=row["body"],
|
|
|
|
|
- toc=toc,
|
|
|
|
|
- children=children,
|
|
|
|
|
- prev=prev_node,
|
|
|
|
|
- next=next_node,
|
|
|
|
|
- )
|
|
|
|
|
- finally:
|
|
|
|
|
- conn.close()
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ===== Knowledge API =====
|
|
|
|
|
-
|
|
|
|
|
-# 两阶段检索逻辑
|
|
|
|
|
-async def _route_knowledge_by_llm(query_text: str, metadata_list: list[dict], k: int = 5) -> list[str]:
|
|
|
|
|
- """
|
|
|
|
|
- 第一阶段:语义路由。
|
|
|
|
|
- 让 LLM 挑选出 2*k 个语义相关的 ID。
|
|
|
|
|
- """
|
|
|
|
|
- if not metadata_list:
|
|
|
|
|
- return []
|
|
|
|
|
-
|
|
|
|
|
- routing_k = k * 2
|
|
|
|
|
-
|
|
|
|
|
- routing_data = [
|
|
|
|
|
- {
|
|
|
|
|
- "id": m["id"],
|
|
|
|
|
- "types": m["types"],
|
|
|
|
|
- "task": m["task"][:100]
|
|
|
|
|
- } for m in metadata_list
|
|
|
|
|
- ]
|
|
|
|
|
-
|
|
|
|
|
- prompt = f"""
|
|
|
|
|
-你是一个知识检索专家。根据用户的当前任务需求,从下列原子知识元数据中挑选出最相关的最多 {routing_k} 个知识 ID。
|
|
|
|
|
-任务需求:"{query_text}"
|
|
|
|
|
|
|
+ prompt = f"""
|
|
|
|
|
+你是一个知识检索专家。根据用户的当前任务需求,从下列原子知识元数据中挑选出最相关的最多 {routing_k} 个知识 ID。
|
|
|
|
|
+任务需求:"{query_text}"
|
|
|
|
|
|
|
|
可选知识列表:
|
|
可选知识列表:
|
|
|
{json.dumps(routing_data, ensure_ascii=False, indent=1)}
|
|
{json.dumps(routing_data, ensure_ascii=False, indent=1)}
|
|
@@ -777,6 +331,7 @@ async def _search_knowledge_two_stage(
|
|
|
top_k: int = 5,
|
|
top_k: int = 5,
|
|
|
min_score: int = 3,
|
|
min_score: int = 3,
|
|
|
types_filter: Optional[list[str]] = None,
|
|
types_filter: Optional[list[str]] = None,
|
|
|
|
|
+ owner_filter: Optional[str] = None,
|
|
|
conn: sqlite3.Connection = None
|
|
conn: sqlite3.Connection = None
|
|
|
) -> list[dict]:
|
|
) -> list[dict]:
|
|
|
"""
|
|
"""
|
|
@@ -808,6 +363,10 @@ async def _search_knowledge_two_stage(
|
|
|
if not any(t in types for t in types_filter):
|
|
if not any(t in types for t in types_filter):
|
|
|
continue
|
|
continue
|
|
|
|
|
|
|
|
|
|
+ # owner 过滤
|
|
|
|
|
+ if owner_filter and row["owner"] != owner_filter:
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
task = row["task"]
|
|
task = row["task"]
|
|
|
content_text = row["content"]
|
|
content_text = row["content"]
|
|
|
eval_data = json.loads(row["eval"])
|
|
eval_data = json.loads(row["eval"])
|
|
@@ -900,7 +459,8 @@ async def search_knowledge_api(
|
|
|
q: str = Query(..., description="查询文本"),
|
|
q: str = Query(..., description="查询文本"),
|
|
|
top_k: int = Query(default=5, ge=1, le=20),
|
|
top_k: int = Query(default=5, ge=1, le=20),
|
|
|
min_score: int = Query(default=3, ge=1, le=5),
|
|
min_score: int = Query(default=3, ge=1, le=5),
|
|
|
- types: Optional[str] = None
|
|
|
|
|
|
|
+ types: Optional[str] = None,
|
|
|
|
|
+ owner: Optional[str] = None
|
|
|
):
|
|
):
|
|
|
"""检索知识(两阶段:语义路由 + 质量精排)"""
|
|
"""检索知识(两阶段:语义路由 + 质量精排)"""
|
|
|
conn = get_db()
|
|
conn = get_db()
|
|
@@ -912,6 +472,7 @@ async def search_knowledge_api(
|
|
|
top_k=top_k,
|
|
top_k=top_k,
|
|
|
min_score=min_score,
|
|
min_score=min_score,
|
|
|
types_filter=types_filter,
|
|
types_filter=types_filter,
|
|
|
|
|
+ owner_filter=owner,
|
|
|
conn=conn
|
|
conn=conn
|
|
|
)
|
|
)
|
|
|
|
|
|
|
@@ -1462,6 +1023,448 @@ REPORT: 原有 X 条,合并后 Y 条,精简了 Z 条。
|
|
|
conn.close()
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+@app.post("/api/extract")
|
|
|
|
|
+async def extract_knowledge_from_messages(extract_req: MessageExtractIn):
|
|
|
|
|
+ """从消息历史中提取知识(LLM 分析)"""
|
|
|
|
|
+ if not extract_req.submitted_by:
|
|
|
|
|
+ raise HTTPException(status_code=400, detail="submitted_by is required")
|
|
|
|
|
+
|
|
|
|
|
+ messages = extract_req.messages
|
|
|
|
|
+ if not messages or len(messages) == 0:
|
|
|
|
|
+ return {"status": "ok", "extracted_count": 0, "knowledge_ids": []}
|
|
|
|
|
+
|
|
|
|
|
+ # 构造消息历史文本
|
|
|
|
|
+ messages_text = ""
|
|
|
|
|
+ for msg in messages:
|
|
|
|
|
+ role = msg.get("role", "unknown")
|
|
|
|
|
+ content = msg.get("content", "")
|
|
|
|
|
+ messages_text += f"[{role}]: {content}\n\n"
|
|
|
|
|
+
|
|
|
|
|
+ # LLM 提取知识
|
|
|
|
|
+ prompt = f"""你是一个知识提取专家。请从以下 Agent 对话历史中提取有价值的知识。
|
|
|
|
|
+
|
|
|
|
|
+【对话历史】:
|
|
|
|
|
+{messages_text}
|
|
|
|
|
+
|
|
|
|
|
+【提取要求】:
|
|
|
|
|
+1. 识别对话中的关键知识点(工具使用经验、问题解决方案、最佳实践、踩坑经验等)
|
|
|
|
|
+2. 每条知识必须包含:
|
|
|
|
|
+ - task: 任务场景描述(在什么情况下,要完成什么目标)
|
|
|
|
|
+ - content: 核心知识内容(具体可操作的方法、注意事项)
|
|
|
|
|
+ - types: 知识类型(从 strategy/tool/user_profile/usecase/definition/plan 中选择)
|
|
|
|
|
+ - score: 评分 1-5(根据知识的价值和可操作性)
|
|
|
|
|
+3. 只提取有实际价值的知识,不要提取泛泛而谈的内容
|
|
|
|
|
+4. 如果没有值得提取的知识,返回空列表
|
|
|
|
|
+
|
|
|
|
|
+【输出格式】:
|
|
|
|
|
+严格按以下 JSON 格式输出,每条知识之间用逗号分隔:
|
|
|
|
|
+[
|
|
|
|
|
+ {{
|
|
|
|
|
+ "task": "任务场景描述",
|
|
|
|
|
+ "content": "核心知识内容",
|
|
|
|
|
+ "types": ["strategy"],
|
|
|
|
|
+ "score": 4
|
|
|
|
|
+ }},
|
|
|
|
|
+ {{
|
|
|
|
|
+ "task": "另一个任务场景",
|
|
|
|
|
+ "content": "另一个知识内容",
|
|
|
|
|
+ "types": ["tool"],
|
|
|
|
|
+ "score": 5
|
|
|
|
|
+ }}
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+如果没有知识,输出: []
|
|
|
|
|
+
|
|
|
|
|
+禁止输出任何解释或额外文本,只输出 JSON 数组。"""
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ print(f"\n[Extract] 正在从 {len(messages)} 条消息中提取知识...")
|
|
|
|
|
+
|
|
|
|
|
+ response = await openrouter_llm_call(
|
|
|
|
|
+ messages=[{"role": "user", "content": prompt}],
|
|
|
|
|
+ model="google/gemini-2.0-flash-001"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ content = response.get("content", "").strip()
|
|
|
|
|
+
|
|
|
|
|
+ # 尝试解析 JSON
|
|
|
|
|
+ # 移除可能的 markdown 代码块标记
|
|
|
|
|
+ if content.startswith("```json"):
|
|
|
|
|
+ content = content[7:]
|
|
|
|
|
+ if content.startswith("```"):
|
|
|
|
|
+ content = content[3:]
|
|
|
|
|
+ if content.endswith("```"):
|
|
|
|
|
+ content = content[:-3]
|
|
|
|
|
+ content = content.strip()
|
|
|
|
|
+
|
|
|
|
|
+ extracted_knowledge = json.loads(content)
|
|
|
|
|
+
|
|
|
|
|
+ if not isinstance(extracted_knowledge, list):
|
|
|
|
|
+ raise ValueError("LLM output is not a list")
|
|
|
|
|
+
|
|
|
|
|
+ # 保存提取的知识
|
|
|
|
|
+ conn = get_db()
|
|
|
|
|
+ knowledge_ids = []
|
|
|
|
|
+ now = datetime.now(timezone.utc).isoformat()
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ for item in extracted_knowledge:
|
|
|
|
|
+ task = item.get("task", "")
|
|
|
|
|
+ knowledge_content = item.get("content", "")
|
|
|
|
|
+ types = item.get("types", ["strategy"])
|
|
|
|
|
+ score = item.get("score", 3)
|
|
|
|
|
+
|
|
|
|
|
+ if not task or not knowledge_content:
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ # 生成 ID
|
|
|
|
|
+ import uuid
|
|
|
|
|
+ timestamp = datetime.now().strftime('%Y%m%d-%H%M%S')
|
|
|
|
|
+ random_suffix = uuid.uuid4().hex[:4]
|
|
|
|
|
+ knowledge_id = f"knowledge-{timestamp}-{random_suffix}"
|
|
|
|
|
+
|
|
|
|
|
+ # 准备数据
|
|
|
|
|
+ source = {
|
|
|
|
|
+ "name": "message_extraction",
|
|
|
|
|
+ "category": "exp",
|
|
|
|
|
+ "urls": [],
|
|
|
|
|
+ "agent_id": extract_req.agent_id,
|
|
|
|
|
+ "submitted_by": extract_req.submitted_by,
|
|
|
|
|
+ "timestamp": now,
|
|
|
|
|
+ "session_key": extract_req.session_key
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ eval_data = {
|
|
|
|
|
+ "score": score,
|
|
|
|
|
+ "helpful": 1,
|
|
|
|
|
+ "harmful": 0,
|
|
|
|
|
+ "confidence": 0.7,
|
|
|
|
|
+ "helpful_history": [],
|
|
|
|
|
+ "harmful_history": []
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ # 插入数据库
|
|
|
|
|
+ conn.execute(
|
|
|
|
|
+ """INSERT INTO knowledge
|
|
|
|
|
+ (id, message_id, types, task, tags, scopes, owner, content,
|
|
|
|
|
+ source, eval, created_at, updated_at)
|
|
|
|
|
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
|
|
|
+ (
|
|
|
|
|
+ knowledge_id,
|
|
|
|
|
+ "",
|
|
|
|
|
+ json.dumps(types),
|
|
|
|
|
+ task,
|
|
|
|
|
+ json.dumps({}),
|
|
|
|
|
+ json.dumps(["org:cybertogether"]),
|
|
|
|
|
+ extract_req.submitted_by,
|
|
|
|
|
+ knowledge_content,
|
|
|
|
|
+ json.dumps(source, ensure_ascii=False),
|
|
|
|
|
+ json.dumps(eval_data, ensure_ascii=False),
|
|
|
|
|
+ now,
|
|
|
|
|
+ now,
|
|
|
|
|
+ ),
|
|
|
|
|
+ )
|
|
|
|
|
+ knowledge_ids.append(knowledge_id)
|
|
|
|
|
+
|
|
|
|
|
+ conn.commit()
|
|
|
|
|
+ print(f"[Extract] 成功提取并保存 {len(knowledge_ids)} 条知识")
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ "status": "ok",
|
|
|
|
|
+ "extracted_count": len(knowledge_ids),
|
|
|
|
|
+ "knowledge_ids": knowledge_ids
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ finally:
|
|
|
|
|
+ conn.close()
|
|
|
|
|
+
|
|
|
|
|
+ except json.JSONDecodeError as e:
|
|
|
|
|
+ print(f"[Extract] JSON 解析失败: {e}")
|
|
|
|
|
+ print(f"[Extract] LLM 输出: {content[:500]}")
|
|
|
|
|
+ return {"status": "error", "error": "Failed to parse LLM output", "extracted_count": 0}
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ print(f"[Extract] 提取失败: {e}")
|
|
|
|
|
+ return {"status": "error", "error": str(e), "extracted_count": 0}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@app.get("/", response_class=HTMLResponse)
|
|
|
|
|
+def frontend():
|
|
|
|
|
+ """KnowHub 管理前端"""
|
|
|
|
|
+ return """<!DOCTYPE html>
|
|
|
|
|
+<html lang="zh-CN">
|
|
|
|
|
+<head>
|
|
|
|
|
+ <meta charset="UTF-8">
|
|
|
|
|
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
+ <title>KnowHub 管理</title>
|
|
|
|
|
+ <script src="https://cdn.tailwindcss.com"></script>
|
|
|
|
|
+</head>
|
|
|
|
|
+<body class="bg-gray-50">
|
|
|
|
|
+ <div class="container mx-auto px-4 py-8 max-w-7xl">
|
|
|
|
|
+ <div class="flex justify-between items-center mb-8">
|
|
|
|
|
+ <h1 class="text-3xl font-bold text-gray-800">KnowHub 全局知识库</h1>
|
|
|
|
|
+ <button onclick="openAddModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg">
|
|
|
|
|
+ + 新增知识
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 筛选栏 -->
|
|
|
|
|
+ <div class="bg-white rounded-lg shadow p-6 mb-6">
|
|
|
|
|
+ <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label class="block text-sm font-medium text-gray-700 mb-2">类型 (Types)</label>
|
|
|
|
|
+ <div class="space-y-2">
|
|
|
|
|
+ <label class="flex items-center"><input type="checkbox" value="strategy" class="mr-2 type-filter"> Strategy</label>
|
|
|
|
|
+ <label class="flex items-center"><input type="checkbox" value="tool" class="mr-2 type-filter"> Tool</label>
|
|
|
|
|
+ <label class="flex items-center"><input type="checkbox" value="user_profile" class="mr-2 type-filter"> User Profile</label>
|
|
|
|
|
+ <label class="flex items-center"><input type="checkbox" value="usecase" class="mr-2 type-filter"> Usecase</label>
|
|
|
|
|
+ <label class="flex items-center"><input type="checkbox" value="definition" class="mr-2 type-filter"> Definition</label>
|
|
|
|
|
+ <label class="flex items-center"><input type="checkbox" value="plan" class="mr-2 type-filter"> Plan</label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label class="block text-sm font-medium text-gray-700 mb-2">Tags</label>
|
|
|
|
|
+ <div id="tagsFilterContainer" class="space-y-2 max-h-40 overflow-y-auto">
|
|
|
|
|
+ <p class="text-sm text-gray-500">加载中...</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label class="block text-sm font-medium text-gray-700 mb-2">Owner</label>
|
|
|
|
|
+ <input type="text" id="ownerFilter" placeholder="输入 owner" class="w-full border rounded px-3 py-2">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label class="block text-sm font-medium text-gray-700 mb-2">Scopes</label>
|
|
|
|
|
+ <input type="text" id="scopesFilter" placeholder="输入 scope" class="w-full border rounded px-3 py-2">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button onclick="applyFilters()" class="mt-4 bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded">
|
|
|
|
|
+ 应用筛选
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 知识列表 -->
|
|
|
|
|
+ <div id="knowledgeList" class="space-y-4"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 新增/编辑 Modal -->
|
|
|
|
|
+ <div id="modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4">
|
|
|
|
|
+ <div class="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto p-6">
|
|
|
|
|
+ <h2 id="modalTitle" class="text-2xl font-bold mb-4">新增知识</h2>
|
|
|
|
|
+ <form id="knowledgeForm" class="space-y-4">
|
|
|
|
|
+ <input type="hidden" id="editId">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label class="block text-sm font-medium mb-1">Task *</label>
|
|
|
|
|
+ <input type="text" id="taskInput" required class="w-full border rounded px-3 py-2">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label class="block text-sm font-medium mb-1">Content *</label>
|
|
|
|
|
+ <textarea id="contentInput" required rows="6" class="w-full border rounded px-3 py-2"></textarea>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label class="block text-sm font-medium mb-1">Types (多选)</label>
|
|
|
|
|
+ <div class="space-y-1">
|
|
|
|
|
+ <label class="flex items-center"><input type="checkbox" value="strategy" class="mr-2 type-checkbox"> Strategy</label>
|
|
|
|
|
+ <label class="flex items-center"><input type="checkbox" value="tool" class="mr-2 type-checkbox"> Tool</label>
|
|
|
|
|
+ <label class="flex items-center"><input type="checkbox" value="user_profile" class="mr-2 type-checkbox"> User Profile</label>
|
|
|
|
|
+ <label class="flex items-center"><input type="checkbox" value="usecase" class="mr-2 type-checkbox"> Usecase</label>
|
|
|
|
|
+ <label class="flex items-center"><input type="checkbox" value="definition" class="mr-2 type-checkbox"> Definition</label>
|
|
|
|
|
+ <label class="flex items-center"><input type="checkbox" value="plan" class="mr-2 type-checkbox"> Plan</label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label class="block text-sm font-medium mb-1">Tags (JSON)</label>
|
|
|
|
|
+ <textarea id="tagsInput" rows="2" placeholder='{"key": "value"}' class="w-full border rounded px-3 py-2"></textarea>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label class="block text-sm font-medium mb-1">Scopes (逗号分隔)</label>
|
|
|
|
|
+ <input type="text" id="scopesInput" placeholder="org:cybertogether" class="w-full border rounded px-3 py-2">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label class="block text-sm font-medium mb-1">Owner</label>
|
|
|
|
|
+ <input type="text" id="ownerInput" class="w-full border rounded px-3 py-2">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="flex gap-2 pt-4">
|
|
|
|
|
+ <button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded">保存</button>
|
|
|
|
|
+ <button type="button" onclick="closeModal()" class="bg-gray-300 hover:bg-gray-400 px-6 py-2 rounded">取消</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </form>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <script>
|
|
|
|
|
+ let allKnowledge = [];
|
|
|
|
|
+ let availableTags = [];
|
|
|
|
|
+
|
|
|
|
|
+ async function loadTags() {
|
|
|
|
|
+ const res = await fetch('/api/knowledge/meta/tags');
|
|
|
|
|
+ const data = await res.json();
|
|
|
|
|
+ availableTags = data.tags;
|
|
|
|
|
+ renderTagsFilter();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function renderTagsFilter() {
|
|
|
|
|
+ const container = document.getElementById('tagsFilterContainer');
|
|
|
|
|
+ if (availableTags.length === 0) {
|
|
|
|
|
+ container.innerHTML = '<p class="text-sm text-gray-500">暂无 tags</p>';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ container.innerHTML = availableTags.map(tag =>
|
|
|
|
|
+ `<label class="flex items-center"><input type="checkbox" value="${escapeHtml(tag)}" class="mr-2 tag-filter"> ${escapeHtml(tag)}</label>`
|
|
|
|
|
+ ).join('');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function loadKnowledge() {
|
|
|
|
|
+ const params = new URLSearchParams();
|
|
|
|
|
+ params.append('limit', '1000');
|
|
|
|
|
+
|
|
|
|
|
+ const selectedTypes = Array.from(document.querySelectorAll('.type-filter:checked')).map(el => el.value);
|
|
|
|
|
+ if (selectedTypes.length > 0) {
|
|
|
|
|
+ params.append('types', selectedTypes.join(','));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const selectedTags = Array.from(document.querySelectorAll('.tag-filter:checked')).map(el => el.value);
|
|
|
|
|
+ if (selectedTags.length > 0) {
|
|
|
|
|
+ params.append('tags', selectedTags.join(','));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const ownerFilter = document.getElementById('ownerFilter').value.trim();
|
|
|
|
|
+ if (ownerFilter) {
|
|
|
|
|
+ params.append('owner', ownerFilter);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const scopesFilter = document.getElementById('scopesFilter').value.trim();
|
|
|
|
|
+ if (scopesFilter) {
|
|
|
|
|
+ params.append('scopes', scopesFilter);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const res = await fetch(`/api/knowledge?${params.toString()}`);
|
|
|
|
|
+ const data = await res.json();
|
|
|
|
|
+ allKnowledge = data.results;
|
|
|
|
|
+ renderKnowledge(allKnowledge);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function applyFilters() {
|
|
|
|
|
+ loadKnowledge();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function renderKnowledge(list) {
|
|
|
|
|
+ const container = document.getElementById('knowledgeList');
|
|
|
|
|
+ if (list.length === 0) {
|
|
|
|
|
+ container.innerHTML = '<p class="text-gray-500 text-center py-8">暂无知识</p>';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ container.innerHTML = list.map(k => `
|
|
|
|
|
+ <div class="bg-white rounded-lg shadow p-6 hover:shadow-lg transition cursor-pointer" onclick="openEditModal('${k.id}')">
|
|
|
|
|
+ <div class="flex justify-between items-start mb-2">
|
|
|
|
|
+ <div class="flex gap-2 flex-wrap">
|
|
|
|
|
+ ${k.types.map(t => `<span class="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">${t}</span>`).join('')}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span class="text-sm text-gray-500">${k.eval.score || 3}/5</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <h3 class="text-lg font-semibold text-gray-800 mb-2">${escapeHtml(k.task)}</h3>
|
|
|
|
|
+ <p class="text-sm text-gray-600 mb-2">${escapeHtml(k.content.substring(0, 150))}${k.content.length > 150 ? '...' : ''}</p>
|
|
|
|
|
+ <div class="flex justify-between text-xs text-gray-500">
|
|
|
|
|
+ <span>Owner: ${k.owner || 'N/A'}</span>
|
|
|
|
|
+ <span>${new Date(k.created_at).toLocaleDateString()}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `).join('');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function openAddModal() {
|
|
|
|
|
+ document.getElementById('modalTitle').textContent = '新增知识';
|
|
|
|
|
+ document.getElementById('knowledgeForm').reset();
|
|
|
|
|
+ document.getElementById('editId').value = '';
|
|
|
|
|
+ document.querySelectorAll('.type-checkbox').forEach(el => el.checked = false);
|
|
|
|
|
+ document.getElementById('modal').classList.remove('hidden');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function openEditModal(id) {
|
|
|
|
|
+ const k = allKnowledge.find(item => item.id === id);
|
|
|
|
|
+ if (!k) return;
|
|
|
|
|
+
|
|
|
|
|
+ document.getElementById('modalTitle').textContent = '编辑知识';
|
|
|
|
|
+ document.getElementById('editId').value = k.id;
|
|
|
|
|
+ document.getElementById('taskInput').value = k.task;
|
|
|
|
|
+ document.getElementById('contentInput').value = k.content;
|
|
|
|
|
+ document.getElementById('tagsInput').value = JSON.stringify(k.tags);
|
|
|
|
|
+ document.getElementById('scopesInput').value = k.scopes.join(', ');
|
|
|
|
|
+ document.getElementById('ownerInput').value = k.owner;
|
|
|
|
|
+
|
|
|
|
|
+ document.querySelectorAll('.type-checkbox').forEach(el => {
|
|
|
|
|
+ el.checked = k.types.includes(el.value);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ document.getElementById('modal').classList.remove('hidden');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function closeModal() {
|
|
|
|
|
+ document.getElementById('modal').classList.add('hidden');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ document.getElementById('knowledgeForm').addEventListener('submit', async (e) => {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+
|
|
|
|
|
+ const editId = document.getElementById('editId').value;
|
|
|
|
|
+ const task = document.getElementById('taskInput').value;
|
|
|
|
|
+ const content = document.getElementById('contentInput').value;
|
|
|
|
|
+ const types = Array.from(document.querySelectorAll('.type-checkbox:checked')).map(el => el.value);
|
|
|
|
|
+ const tagsText = document.getElementById('tagsInput').value.trim();
|
|
|
|
|
+ const scopesText = document.getElementById('scopesInput').value.trim();
|
|
|
|
|
+ const owner = document.getElementById('ownerInput').value.trim();
|
|
|
|
|
+
|
|
|
|
|
+ let tags = {};
|
|
|
|
|
+ if (tagsText) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ tags = JSON.parse(tagsText);
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ alert('Tags JSON 格式错误');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const scopes = scopesText ? scopesText.split(',').map(s => s.trim()).filter(s => s) : ['org:cybertogether'];
|
|
|
|
|
+
|
|
|
|
|
+ if (editId) {
|
|
|
|
|
+ // 编辑
|
|
|
|
|
+ const res = await fetch(`/api/knowledge/${editId}`, {
|
|
|
|
|
+ method: 'PATCH',
|
|
|
|
|
+ headers: {'Content-Type': 'application/json'},
|
|
|
|
|
+ body: JSON.stringify({task, content, types, tags, scopes, owner})
|
|
|
|
|
+ });
|
|
|
|
|
+ if (!res.ok) {
|
|
|
|
|
+ alert('更新失败');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 新增
|
|
|
|
|
+ const res = await fetch('/api/knowledge', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: {'Content-Type': 'application/json'},
|
|
|
|
|
+ body: JSON.stringify({task, content, types, tags, scopes, owner})
|
|
|
|
|
+ });
|
|
|
|
|
+ if (!res.ok) {
|
|
|
|
|
+ alert('新增失败');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ closeModal();
|
|
|
|
|
+ await loadKnowledge();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ function escapeHtml(text) {
|
|
|
|
|
+ const div = document.createElement('div');
|
|
|
|
|
+ div.textContent = text;
|
|
|
|
|
+ return div.innerHTML;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ loadTags();
|
|
|
|
|
+ loadKnowledge();
|
|
|
|
|
+ </script>
|
|
|
|
|
+</body>
|
|
|
|
|
+</html>"""
|
|
|
|
|
+
|
|
|
if __name__ == "__main__":
|
|
if __name__ == "__main__":
|
|
|
import uvicorn
|
|
import uvicorn
|
|
|
uvicorn.run(app, host="0.0.0.0", port=9999)
|
|
uvicorn.run(app, host="0.0.0.0", port=9999)
|