server.py 53 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470
  1. """
  2. KnowHub Server
  3. Agent 工具使用经验的共享平台。
  4. FastAPI + SQLite,单文件部署。
  5. """
  6. import os
  7. import re
  8. import json
  9. import sqlite3
  10. import asyncio
  11. from contextlib import asynccontextmanager
  12. from datetime import datetime, timezone
  13. from typing import Optional
  14. from pathlib import Path
  15. from fastapi import FastAPI, HTTPException, Query
  16. from fastapi.responses import HTMLResponse
  17. from pydantic import BaseModel, Field
  18. # 导入 LLM 调用(需要 agent 模块在 Python path 中)
  19. import sys
  20. sys.path.insert(0, str(Path(__file__).parent.parent))
  21. # 加载环境变量
  22. from dotenv import load_dotenv
  23. load_dotenv(Path(__file__).parent.parent / ".env")
  24. from agent.llm.openrouter import openrouter_llm_call
  25. BRAND_NAME = os.getenv("BRAND_NAME", "KnowHub")
  26. BRAND_API_ENV = os.getenv("BRAND_API_ENV", "KNOWHUB_API")
  27. BRAND_DB = os.getenv("BRAND_DB", "knowhub.db")
  28. DB_PATH = Path(__file__).parent / BRAND_DB
  29. # --- 数据库 ---
  30. def get_db() -> sqlite3.Connection:
  31. conn = sqlite3.connect(str(DB_PATH))
  32. conn.row_factory = sqlite3.Row
  33. conn.execute("PRAGMA journal_mode=WAL")
  34. return conn
  35. def init_db():
  36. conn = get_db()
  37. conn.execute("""
  38. CREATE TABLE IF NOT EXISTS experiences (
  39. id INTEGER PRIMARY KEY AUTOINCREMENT,
  40. name TEXT NOT NULL,
  41. url TEXT DEFAULT '',
  42. category TEXT DEFAULT '',
  43. task TEXT NOT NULL,
  44. score INTEGER CHECK(score BETWEEN 1 AND 5),
  45. outcome TEXT DEFAULT '',
  46. tips TEXT DEFAULT '',
  47. content_id TEXT DEFAULT '',
  48. submitted_by TEXT DEFAULT '',
  49. created_at TEXT NOT NULL
  50. )
  51. """)
  52. conn.execute("CREATE INDEX IF NOT EXISTS idx_name ON experiences(name)")
  53. conn.execute("""
  54. CREATE TABLE IF NOT EXISTS contents (
  55. id TEXT PRIMARY KEY,
  56. title TEXT DEFAULT '',
  57. body TEXT NOT NULL,
  58. sort_order INTEGER DEFAULT 0,
  59. submitted_by TEXT DEFAULT '',
  60. created_at TEXT NOT NULL
  61. )
  62. """)
  63. conn.execute("""
  64. CREATE TABLE IF NOT EXISTS knowledge (
  65. id TEXT PRIMARY KEY,
  66. message_id TEXT DEFAULT '',
  67. types TEXT NOT NULL, -- JSON array: ["strategy", "tool"]
  68. task TEXT NOT NULL,
  69. tags TEXT DEFAULT '{}', -- JSON object: {"category": "...", "domain": "..."}
  70. scopes TEXT DEFAULT '["org:cybertogether"]', -- JSON array
  71. owner TEXT DEFAULT '',
  72. content TEXT NOT NULL,
  73. source TEXT DEFAULT '{}', -- JSON object: {name, category, urls, agent_id, submitted_by, timestamp}
  74. eval TEXT DEFAULT '{}', -- JSON object: {score, helpful, harmful, confidence, histories}
  75. created_at TEXT NOT NULL,
  76. updated_at TEXT DEFAULT ''
  77. )
  78. """)
  79. conn.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_types ON knowledge(types)")
  80. conn.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_task ON knowledge(task)")
  81. conn.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_owner ON knowledge(owner)")
  82. conn.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_scopes ON knowledge(scopes)")
  83. conn.commit()
  84. conn.close()
  85. # --- Models ---
  86. class ContentIn(BaseModel):
  87. id: str
  88. title: str = ""
  89. body: str
  90. sort_order: int = 0
  91. submitted_by: str = ""
  92. # Knowledge Models
  93. class KnowledgeIn(BaseModel):
  94. task: str
  95. content: str
  96. types: list[str] = ["strategy"]
  97. tags: dict = {}
  98. scopes: list[str] = ["org:cybertogether"]
  99. owner: str = ""
  100. message_id: str = ""
  101. source: dict = {} # {name, category, urls, agent_id, submitted_by, timestamp}
  102. eval: dict = {} # {score, helpful, harmful, confidence}
  103. class KnowledgeOut(BaseModel):
  104. id: str
  105. message_id: str
  106. types: list[str]
  107. task: str
  108. tags: dict
  109. scopes: list[str]
  110. owner: str
  111. content: str
  112. source: dict
  113. eval: dict
  114. created_at: str
  115. updated_at: str
  116. class KnowledgeUpdateIn(BaseModel):
  117. add_helpful_case: Optional[dict] = None
  118. add_harmful_case: Optional[dict] = None
  119. update_score: Optional[int] = Field(default=None, ge=1, le=5)
  120. evolve_feedback: Optional[str] = None
  121. class KnowledgePatchIn(BaseModel):
  122. """PATCH /api/knowledge/{id} 请求体(直接字段编辑)"""
  123. task: Optional[str] = None
  124. content: Optional[str] = None
  125. types: Optional[list[str]] = None
  126. tags: Optional[dict] = None
  127. scopes: Optional[list[str]] = None
  128. owner: Optional[str] = None
  129. class MessageExtractIn(BaseModel):
  130. """POST /api/extract 请求体(消息历史提取)"""
  131. messages: list[dict] # [{role: str, content: str}, ...]
  132. agent_id: str = "unknown"
  133. submitted_by: str # 必填,作为 owner
  134. session_key: str = ""
  135. class KnowledgeBatchUpdateIn(BaseModel):
  136. feedback_list: list[dict]
  137. class KnowledgeSearchResponse(BaseModel):
  138. results: list[dict]
  139. count: int
  140. class ContentNode(BaseModel):
  141. id: str
  142. title: str
  143. class ContentOut(BaseModel):
  144. id: str
  145. title: str
  146. body: str
  147. toc: Optional[ContentNode] = None
  148. children: list[ContentNode]
  149. prev: Optional[ContentNode] = None
  150. next: Optional[ContentNode] = None
  151. # --- App ---
  152. @asynccontextmanager
  153. async def lifespan(app: FastAPI):
  154. init_db()
  155. yield
  156. app = FastAPI(title=BRAND_NAME, lifespan=lifespan)
  157. # --- Knowledge API ---
  158. @app.post("/api/content", status_code=201)
  159. def submit_content(content: ContentIn):
  160. conn = get_db()
  161. try:
  162. now = datetime.now(timezone.utc).isoformat()
  163. conn.execute(
  164. "INSERT OR REPLACE INTO contents"
  165. "(id, title, body, sort_order, submitted_by, created_at)"
  166. " VALUES (?, ?, ?, ?, ?, ?)",
  167. (content.id, content.title, content.body, content.sort_order, content.submitted_by, now),
  168. )
  169. conn.commit()
  170. return {"status": "ok"}
  171. finally:
  172. conn.close()
  173. @app.get("/api/content/{content_id:path}", response_model=ContentOut)
  174. def get_content(content_id: str):
  175. conn = get_db()
  176. try:
  177. row = conn.execute(
  178. "SELECT id, title, body, sort_order FROM contents WHERE id = ?",
  179. (content_id,),
  180. ).fetchone()
  181. if not row:
  182. raise HTTPException(status_code=404, detail=f"Content not found: {content_id}")
  183. # 计算导航上下文
  184. root_id = content_id.split("/")[0] if "/" in content_id else content_id
  185. # TOC (根节点)
  186. toc = None
  187. if "/" in content_id:
  188. toc_row = conn.execute(
  189. "SELECT id, title FROM contents WHERE id = ?",
  190. (root_id,),
  191. ).fetchone()
  192. if toc_row:
  193. toc = ContentNode(id=toc_row["id"], title=toc_row["title"])
  194. # Children (子节点)
  195. children = []
  196. children_rows = conn.execute(
  197. "SELECT id, title FROM contents WHERE id LIKE ? AND id != ? ORDER BY sort_order",
  198. (f"{content_id}/%", content_id),
  199. ).fetchall()
  200. children = [ContentNode(id=r["id"], title=r["title"]) for r in children_rows]
  201. # Prev/Next (同级节点)
  202. prev_node = None
  203. next_node = None
  204. if "/" in content_id:
  205. siblings = conn.execute(
  206. "SELECT id, title, sort_order FROM contents WHERE id LIKE ? AND id NOT LIKE ? ORDER BY sort_order",
  207. (f"{root_id}/%", f"{root_id}/%/%"),
  208. ).fetchall()
  209. for i, sib in enumerate(siblings):
  210. if sib["id"] == content_id:
  211. if i > 0:
  212. prev_node = ContentNode(id=siblings[i-1]["id"], title=siblings[i-1]["title"])
  213. if i < len(siblings) - 1:
  214. next_node = ContentNode(id=siblings[i+1]["id"], title=siblings[i+1]["title"])
  215. break
  216. return ContentOut(
  217. id=row["id"],
  218. title=row["title"],
  219. body=row["body"],
  220. toc=toc,
  221. children=children,
  222. prev=prev_node,
  223. next=next_node,
  224. )
  225. finally:
  226. conn.close()
  227. # ===== Knowledge API =====
  228. # 两阶段检索逻辑
  229. async def _route_knowledge_by_llm(query_text: str, metadata_list: list[dict], k: int = 5) -> list[str]:
  230. """
  231. 第一阶段:语义路由。
  232. 让 LLM 挑选出 2*k 个语义相关的 ID。
  233. """
  234. if not metadata_list:
  235. return []
  236. routing_k = k * 2
  237. routing_data = [
  238. {
  239. "id": m["id"],
  240. "types": m["types"],
  241. "task": m["task"][:100]
  242. } for m in metadata_list
  243. ]
  244. prompt = f"""
  245. 你是一个知识检索专家。根据用户的当前任务需求,从下列原子知识元数据中挑选出最相关的最多 {routing_k} 个知识 ID。
  246. 任务需求:"{query_text}"
  247. 可选知识列表:
  248. {json.dumps(routing_data, ensure_ascii=False, indent=1)}
  249. 请直接输出 ID 列表,用逗号分隔(例如: knowledge-20260302-001, research-20260302-002)。若无相关项请输出 "None"。
  250. """
  251. try:
  252. print(f"\n[Step 1: 知识语义路由] 任务: '{query_text}' | 候选总数: {len(metadata_list)} | 目标提取数: {routing_k}")
  253. response = await openrouter_llm_call(
  254. messages=[{"role": "user", "content": prompt}],
  255. model="google/gemini-2.0-flash-001"
  256. )
  257. content = response.get("content", "").strip()
  258. selected_ids = [idx.strip() for idx in re.split(r'[,\s]+', content) if idx.strip().startswith(("knowledge-", "research-"))]
  259. print(f"[Step 1: 知识语义路由] LLM 初选 ID ({len(selected_ids)}个): {selected_ids}")
  260. return selected_ids
  261. except Exception as e:
  262. print(f"LLM 知识路由失败: {e}")
  263. return []
  264. async def _search_knowledge_two_stage(
  265. query_text: str,
  266. top_k: int = 5,
  267. min_score: int = 3,
  268. types_filter: Optional[list[str]] = None,
  269. owner_filter: Optional[str] = None,
  270. conn: sqlite3.Connection = None
  271. ) -> list[dict]:
  272. """
  273. 两阶段检索:语义路由 + 质量精排
  274. """
  275. if conn is None:
  276. conn = get_db()
  277. should_close = True
  278. else:
  279. should_close = False
  280. try:
  281. # 阶段 1: 解析所有知识
  282. query = "SELECT * FROM knowledge"
  283. rows = conn.execute(query).fetchall()
  284. if not rows:
  285. return []
  286. content_map = {}
  287. metadata_list = []
  288. for row in rows:
  289. kid = row["id"]
  290. types = json.loads(row["types"])
  291. # 标签过滤
  292. if types_filter:
  293. if not any(t in types for t in types_filter):
  294. continue
  295. # owner 过滤
  296. if owner_filter and row["owner"] != owner_filter:
  297. continue
  298. task = row["task"]
  299. content_text = row["content"]
  300. eval_data = json.loads(row["eval"])
  301. source = json.loads(row["source"])
  302. meta_item = {
  303. "id": kid,
  304. "types": types,
  305. "task": task,
  306. "score": eval_data.get("score", 3),
  307. "helpful": eval_data.get("helpful", 0),
  308. "harmful": eval_data.get("harmful", 0),
  309. }
  310. metadata_list.append(meta_item)
  311. content_map[kid] = {
  312. "task": task,
  313. "content": content_text,
  314. "types": types,
  315. "tags": json.loads(row["tags"]),
  316. "scopes": json.loads(row["scopes"]),
  317. "owner": row["owner"],
  318. "score": meta_item["score"],
  319. "helpful": meta_item["helpful"],
  320. "harmful": meta_item["harmful"],
  321. "message_id": row["message_id"],
  322. "source": source,
  323. "eval": eval_data,
  324. "created_at": row["created_at"],
  325. "updated_at": row["updated_at"]
  326. }
  327. if not metadata_list:
  328. return []
  329. # 阶段 2: 语义路由 (取 2*k)
  330. candidate_ids = await _route_knowledge_by_llm(query_text, metadata_list, k=top_k)
  331. # 阶段 3: 质量精排
  332. print(f"[Step 2: 知识质量精排] 正在根据评分和反馈进行打分...")
  333. scored_items = []
  334. for kid in candidate_ids:
  335. if kid in content_map:
  336. item = content_map[kid]
  337. score = item["score"]
  338. helpful = item["helpful"]
  339. harmful = item["harmful"]
  340. # 计算综合分:基础分 + helpful - harmful*2
  341. quality_score = score + helpful - (harmful * 2.0)
  342. # 过滤门槛
  343. if score < min_score or quality_score < 0:
  344. print(f" - 剔除低质量知识: {kid} (Score: {score}, Helpful: {helpful}, Harmful: {harmful})")
  345. continue
  346. scored_items.append({
  347. "id": kid,
  348. "message_id": item["message_id"],
  349. "types": item["types"],
  350. "task": item["task"],
  351. "tags": item["tags"],
  352. "scopes": item["scopes"],
  353. "owner": item["owner"],
  354. "content": item["content"],
  355. "source": item["source"],
  356. "eval": item["eval"],
  357. "quality_score": quality_score,
  358. "created_at": item["created_at"],
  359. "updated_at": item["updated_at"]
  360. })
  361. # 按照质量分排序
  362. final_sorted = sorted(scored_items, key=lambda x: x["quality_score"], reverse=True)
  363. # 截取最终的 top_k
  364. result = final_sorted[:top_k]
  365. print(f"[Step 2: 知识质量精排] 最终选定知识: {[it['id'] for it in result]}")
  366. print(f"[Knowledge System] 检索结束。\n")
  367. return result
  368. finally:
  369. if should_close:
  370. conn.close()
  371. @app.get("/api/knowledge/search")
  372. async def search_knowledge_api(
  373. q: str = Query(..., description="查询文本"),
  374. top_k: int = Query(default=5, ge=1, le=20),
  375. min_score: int = Query(default=3, ge=1, le=5),
  376. types: Optional[str] = None,
  377. owner: Optional[str] = None
  378. ):
  379. """检索知识(两阶段:语义路由 + 质量精排)"""
  380. conn = get_db()
  381. try:
  382. types_filter = types.split(",") if types else None
  383. results = await _search_knowledge_two_stage(
  384. query_text=q,
  385. top_k=top_k,
  386. min_score=min_score,
  387. types_filter=types_filter,
  388. owner_filter=owner,
  389. conn=conn
  390. )
  391. return {"results": results, "count": len(results)}
  392. finally:
  393. conn.close()
  394. @app.post("/api/knowledge", status_code=201)
  395. def save_knowledge(knowledge: KnowledgeIn):
  396. """保存新知识"""
  397. import uuid
  398. conn = get_db()
  399. try:
  400. # 生成 ID
  401. timestamp = datetime.now().strftime('%Y%m%d-%H%M%S')
  402. random_suffix = uuid.uuid4().hex[:4]
  403. knowledge_id = f"knowledge-{timestamp}-{random_suffix}"
  404. now = datetime.now(timezone.utc).isoformat()
  405. # 设置默认值
  406. owner = knowledge.owner or f"agent:{knowledge.source.get('agent_id', 'unknown')}"
  407. # 准备 source
  408. source = {
  409. "name": knowledge.source.get("name", ""),
  410. "category": knowledge.source.get("category", ""),
  411. "urls": knowledge.source.get("urls", []),
  412. "agent_id": knowledge.source.get("agent_id", "unknown"),
  413. "submitted_by": knowledge.source.get("submitted_by", ""),
  414. "timestamp": now,
  415. "message_id": knowledge.message_id
  416. }
  417. # 准备 eval
  418. eval_data = {
  419. "score": knowledge.eval.get("score", 3),
  420. "helpful": knowledge.eval.get("helpful", 1),
  421. "harmful": knowledge.eval.get("harmful", 0),
  422. "confidence": knowledge.eval.get("confidence", 0.5),
  423. "helpful_history": [],
  424. "harmful_history": []
  425. }
  426. conn.execute(
  427. """INSERT INTO knowledge
  428. (id, message_id, types, task, tags, scopes, owner, content,
  429. source, eval, created_at, updated_at)
  430. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
  431. (
  432. knowledge_id,
  433. knowledge.message_id,
  434. json.dumps(knowledge.types),
  435. knowledge.task,
  436. json.dumps(knowledge.tags),
  437. json.dumps(knowledge.scopes),
  438. owner,
  439. knowledge.content,
  440. json.dumps(source),
  441. json.dumps(eval_data),
  442. now,
  443. now,
  444. ),
  445. )
  446. conn.commit()
  447. return {"status": "ok", "knowledge_id": knowledge_id}
  448. finally:
  449. conn.close()
  450. @app.get("/api/knowledge")
  451. def list_knowledge(
  452. limit: int = Query(default=100, ge=1, le=1000),
  453. types: Optional[str] = None,
  454. scopes: Optional[str] = None,
  455. owner: Optional[str] = None,
  456. tags: Optional[str] = None
  457. ):
  458. """列出知识(支持后端筛选)"""
  459. conn = get_db()
  460. try:
  461. query = "SELECT * FROM knowledge"
  462. params = []
  463. conditions = []
  464. # types 支持多个,用 OR 连接
  465. if types:
  466. type_list = [t.strip() for t in types.split(',') if t.strip()]
  467. if type_list:
  468. type_conditions = []
  469. for t in type_list:
  470. type_conditions.append("types LIKE ?")
  471. params.append(f"%{t}%")
  472. conditions.append(f"({' OR '.join(type_conditions)})")
  473. if scopes:
  474. conditions.append("scopes LIKE ?")
  475. params.append(f"%{scopes}%")
  476. if owner:
  477. conditions.append("owner LIKE ?")
  478. params.append(f"%{owner}%")
  479. # tags 支持多个,用 OR 连接
  480. if tags:
  481. tag_list = [t.strip() for t in tags.split(',') if t.strip()]
  482. if tag_list:
  483. tag_conditions = []
  484. for t in tag_list:
  485. tag_conditions.append("tags LIKE ?")
  486. params.append(f"%{t}%")
  487. conditions.append(f"({' OR '.join(tag_conditions)})")
  488. if conditions:
  489. query += " WHERE " + " AND ".join(conditions)
  490. query += " ORDER BY created_at DESC LIMIT ?"
  491. params.append(limit)
  492. rows = conn.execute(query, params).fetchall()
  493. results = []
  494. for row in rows:
  495. results.append({
  496. "id": row["id"],
  497. "message_id": row["message_id"],
  498. "types": json.loads(row["types"]),
  499. "task": row["task"],
  500. "tags": json.loads(row["tags"]),
  501. "scopes": json.loads(row["scopes"]),
  502. "owner": row["owner"],
  503. "content": row["content"],
  504. "source": json.loads(row["source"]),
  505. "eval": json.loads(row["eval"]),
  506. "created_at": row["created_at"],
  507. "updated_at": row["updated_at"]
  508. })
  509. return {"results": results, "count": len(results)}
  510. finally:
  511. conn.close()
  512. @app.get("/api/knowledge/meta/tags")
  513. def get_all_tags():
  514. """获取所有已有的 tags"""
  515. conn = get_db()
  516. try:
  517. rows = conn.execute("SELECT tags FROM knowledge").fetchall()
  518. all_tags = set()
  519. for row in rows:
  520. tags_dict = json.loads(row["tags"])
  521. for key in tags_dict.keys():
  522. all_tags.add(key)
  523. return {"tags": sorted(list(all_tags))}
  524. finally:
  525. conn.close()
  526. @app.get("/api/knowledge/{knowledge_id}")
  527. def get_knowledge(knowledge_id: str):
  528. """获取单条知识"""
  529. conn = get_db()
  530. try:
  531. row = conn.execute(
  532. "SELECT * FROM knowledge WHERE id = ?",
  533. (knowledge_id,)
  534. ).fetchone()
  535. if not row:
  536. raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
  537. return {
  538. "id": row["id"],
  539. "message_id": row["message_id"],
  540. "types": json.loads(row["types"]),
  541. "task": row["task"],
  542. "tags": json.loads(row["tags"]),
  543. "scopes": json.loads(row["scopes"]),
  544. "owner": row["owner"],
  545. "content": row["content"],
  546. "source": json.loads(row["source"]),
  547. "eval": json.loads(row["eval"]),
  548. "created_at": row["created_at"],
  549. "updated_at": row["updated_at"]
  550. }
  551. finally:
  552. conn.close()
  553. async def _evolve_knowledge_with_llm(old_content: str, feedback: str) -> str:
  554. """使用 LLM 进行知识进化重写"""
  555. prompt = f"""你是一个 AI Agent 知识库管理员。请根据反馈建议,对现有的知识内容进行重写进化。
  556. 【原知识内容】:
  557. {old_content}
  558. 【实战反馈建议】:
  559. {feedback}
  560. 【重写要求】:
  561. 1. 融合知识:将反馈中的避坑指南、新参数或修正后的选择逻辑融入原知识,使其更具通用性和准确性。
  562. 2. 保持结构:如果原内容有特定格式(如 Markdown、代码示例等),请保持该格式。
  563. 3. 语言:简洁直接,使用中文。
  564. 4. 禁止:严禁输出任何开场白、解释语或额外的 Markdown 标题,直接返回重写后的正文。
  565. """
  566. try:
  567. response = await openrouter_llm_call(
  568. messages=[{"role": "user", "content": prompt}],
  569. model="google/gemini-2.0-flash-001"
  570. )
  571. evolved = response.get("content", "").strip()
  572. if len(evolved) < 5:
  573. raise ValueError("LLM output too short")
  574. return evolved
  575. except Exception as e:
  576. print(f"知识进化失败,采用追加模式回退: {e}")
  577. return f"{old_content}\n\n---\n[Update {datetime.now().strftime('%Y-%m-%d')}]: {feedback}"
  578. @app.put("/api/knowledge/{knowledge_id}")
  579. async def update_knowledge(knowledge_id: str, update: KnowledgeUpdateIn):
  580. """更新知识评估,支持知识进化"""
  581. conn = get_db()
  582. try:
  583. row = conn.execute("SELECT * FROM knowledge WHERE id = ?", (knowledge_id,)).fetchone()
  584. if not row:
  585. raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
  586. now = datetime.now(timezone.utc).isoformat()
  587. eval_data = json.loads(row["eval"])
  588. # 更新评分
  589. if update.update_score is not None:
  590. eval_data["score"] = update.update_score
  591. # 添加有效案例
  592. if update.add_helpful_case:
  593. eval_data["helpful"] = eval_data.get("helpful", 0) + 1
  594. if "helpful_history" not in eval_data:
  595. eval_data["helpful_history"] = []
  596. eval_data["helpful_history"].append(update.add_helpful_case)
  597. # 添加有害案例
  598. if update.add_harmful_case:
  599. eval_data["harmful"] = eval_data.get("harmful", 0) + 1
  600. if "harmful_history" not in eval_data:
  601. eval_data["harmful_history"] = []
  602. eval_data["harmful_history"].append(update.add_harmful_case)
  603. # 知识进化
  604. content = row["content"]
  605. if update.evolve_feedback:
  606. content = await _evolve_knowledge_with_llm(content, update.evolve_feedback)
  607. eval_data["helpful"] = eval_data.get("helpful", 0) + 1
  608. # 更新数据库
  609. conn.execute(
  610. "UPDATE knowledge SET content = ?, eval = ?, updated_at = ? WHERE id = ?",
  611. (content, json.dumps(eval_data, ensure_ascii=False), now, knowledge_id)
  612. )
  613. conn.commit()
  614. return {"status": "ok", "knowledge_id": knowledge_id}
  615. finally:
  616. conn.close()
  617. @app.patch("/api/knowledge/{knowledge_id}")
  618. def patch_knowledge(knowledge_id: str, patch: KnowledgePatchIn):
  619. """直接编辑知识字段"""
  620. conn = get_db()
  621. try:
  622. row = conn.execute("SELECT * FROM knowledge WHERE id = ?", (knowledge_id,)).fetchone()
  623. if not row:
  624. raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
  625. updates = []
  626. params = []
  627. if patch.task is not None:
  628. updates.append("task = ?")
  629. params.append(patch.task)
  630. if patch.content is not None:
  631. updates.append("content = ?")
  632. params.append(patch.content)
  633. if patch.types is not None:
  634. updates.append("types = ?")
  635. params.append(json.dumps(patch.types, ensure_ascii=False))
  636. if patch.tags is not None:
  637. updates.append("tags = ?")
  638. params.append(json.dumps(patch.tags, ensure_ascii=False))
  639. if patch.scopes is not None:
  640. updates.append("scopes = ?")
  641. params.append(json.dumps(patch.scopes, ensure_ascii=False))
  642. if patch.owner is not None:
  643. updates.append("owner = ?")
  644. params.append(patch.owner)
  645. if not updates:
  646. return {"status": "ok", "knowledge_id": knowledge_id}
  647. now = datetime.now(timezone.utc).isoformat()
  648. updates.append("updated_at = ?")
  649. params.append(now)
  650. params.append(knowledge_id)
  651. query = f"UPDATE knowledge SET {', '.join(updates)} WHERE id = ?"
  652. conn.execute(query, params)
  653. conn.commit()
  654. return {"status": "ok", "knowledge_id": knowledge_id}
  655. finally:
  656. conn.close()
  657. @app.post("/api/knowledge/batch_update")
  658. async def batch_update_knowledge(batch: KnowledgeBatchUpdateIn):
  659. """批量反馈知识有效性"""
  660. if not batch.feedback_list:
  661. return {"status": "ok", "updated": 0}
  662. conn = get_db()
  663. try:
  664. # 先处理无需进化的,收集需要进化的
  665. evolution_tasks = [] # [(knowledge_id, old_content, feedback, eval_data)]
  666. simple_updates = [] # [(knowledge_id, is_effective, eval_data)]
  667. for item in batch.feedback_list:
  668. knowledge_id = item.get("knowledge_id")
  669. is_effective = item.get("is_effective")
  670. feedback = item.get("feedback", "")
  671. if not knowledge_id:
  672. continue
  673. row = conn.execute("SELECT * FROM knowledge WHERE id = ?", (knowledge_id,)).fetchone()
  674. if not row:
  675. continue
  676. eval_data = json.loads(row["eval"])
  677. if is_effective and feedback:
  678. evolution_tasks.append((knowledge_id, row["content"], feedback, eval_data))
  679. else:
  680. simple_updates.append((knowledge_id, is_effective, eval_data))
  681. # 执行简单更新
  682. now = datetime.now(timezone.utc).isoformat()
  683. for knowledge_id, is_effective, eval_data in simple_updates:
  684. if is_effective:
  685. eval_data["helpful"] = eval_data.get("helpful", 0) + 1
  686. else:
  687. eval_data["harmful"] = eval_data.get("harmful", 0) + 1
  688. conn.execute(
  689. "UPDATE knowledge SET eval = ?, updated_at = ? WHERE id = ?",
  690. (json.dumps(eval_data, ensure_ascii=False), now, knowledge_id)
  691. )
  692. # 并发执行知识进化
  693. if evolution_tasks:
  694. print(f"🧬 并发处理 {len(evolution_tasks)} 条知识进化...")
  695. evolved_results = await asyncio.gather(
  696. *[_evolve_knowledge_with_llm(old, fb) for _, old, fb, _ in evolution_tasks]
  697. )
  698. for (knowledge_id, _, _, eval_data), evolved_content in zip(evolution_tasks, evolved_results):
  699. eval_data["helpful"] = eval_data.get("helpful", 0) + 1
  700. conn.execute(
  701. "UPDATE knowledge SET content = ?, eval = ?, updated_at = ? WHERE id = ?",
  702. (evolved_content, json.dumps(eval_data, ensure_ascii=False), now, knowledge_id)
  703. )
  704. conn.commit()
  705. return {"status": "ok", "updated": len(simple_updates) + len(evolution_tasks)}
  706. finally:
  707. conn.close()
  708. @app.post("/api/knowledge/slim")
  709. async def slim_knowledge(model: str = "google/gemini-2.0-flash-001"):
  710. """知识库瘦身:合并语义相似知识"""
  711. conn = get_db()
  712. try:
  713. rows = conn.execute("SELECT * FROM knowledge").fetchall()
  714. if len(rows) < 2:
  715. return {"status": "ok", "message": f"知识库仅有 {len(rows)} 条,无需瘦身"}
  716. # 构造发给大模型的内容
  717. entries_text = ""
  718. for row in rows:
  719. eval_data = json.loads(row["eval"])
  720. types = json.loads(row["types"])
  721. entries_text += f"[ID: {row['id']}] [Types: {','.join(types)}] "
  722. entries_text += f"[Helpful: {eval_data.get('helpful', 0)}, Harmful: {eval_data.get('harmful', 0)}] [Score: {eval_data.get('score', 3)}]\n"
  723. entries_text += f"Task: {row['task']}\n"
  724. entries_text += f"Content: {row['content'][:200]}...\n\n"
  725. prompt = f"""你是一个 AI Agent 知识库管理员。以下是当前知识库的全部条目,请执行瘦身操作:
  726. 【任务】:
  727. 1. 识别语义高度相似或重复的知识,将它们合并为一条更精炼、更通用的知识。
  728. 2. 合并时保留 helpful 最高的那条的 ID(helpful 取各条之和)。
  729. 3. 对于独立的、无重复的知识,保持原样不动。
  730. 【当前知识库】:
  731. {entries_text}
  732. 【输出格式要求】:
  733. 严格按以下格式输出每条知识,条目之间用 === 分隔:
  734. ID: <保留的id>
  735. TYPES: <逗号分隔的type列表>
  736. HELPFUL: <合并后的helpful计数>
  737. HARMFUL: <合并后的harmful计数>
  738. SCORE: <评分>
  739. TASK: <任务描述>
  740. CONTENT: <合并后的知识内容>
  741. ===
  742. 最后输出合并报告:
  743. REPORT: 原有 X 条,合并后 Y 条,精简了 Z 条。
  744. 禁止输出任何开场白或解释。"""
  745. print(f"\n[知识瘦身] 正在调用 {model} 分析 {len(rows)} 条知识...")
  746. response = await openrouter_llm_call(
  747. messages=[{"role": "user", "content": prompt}],
  748. model=model
  749. )
  750. content = response.get("content", "").strip()
  751. if not content:
  752. raise HTTPException(status_code=500, detail="LLM 返回为空")
  753. # 解析大模型输出
  754. report_line = ""
  755. new_entries = []
  756. blocks = [b.strip() for b in content.split("===") if b.strip()]
  757. for block in blocks:
  758. if block.startswith("REPORT:"):
  759. report_line = block
  760. continue
  761. lines = block.split("\n")
  762. kid, types, helpful, harmful, score, task, content_lines = None, [], 0, 0, 3, "", []
  763. current_field = None
  764. for line in lines:
  765. if line.startswith("ID:"):
  766. kid = line[3:].strip()
  767. current_field = None
  768. elif line.startswith("TYPES:"):
  769. types_str = line[6:].strip()
  770. types = [t.strip() for t in types_str.split(",") if t.strip()]
  771. current_field = None
  772. elif line.startswith("HELPFUL:"):
  773. try:
  774. helpful = int(line[8:].strip())
  775. except Exception:
  776. helpful = 0
  777. current_field = None
  778. elif line.startswith("HARMFUL:"):
  779. try:
  780. harmful = int(line[8:].strip())
  781. except Exception:
  782. harmful = 0
  783. current_field = None
  784. elif line.startswith("SCORE:"):
  785. try:
  786. score = int(line[6:].strip())
  787. except Exception:
  788. score = 3
  789. current_field = None
  790. elif line.startswith("TASK:"):
  791. task = line[5:].strip()
  792. current_field = "task"
  793. elif line.startswith("CONTENT:"):
  794. content_lines.append(line[8:].strip())
  795. current_field = "content"
  796. elif current_field == "task":
  797. task += "\n" + line
  798. elif current_field == "content":
  799. content_lines.append(line)
  800. if kid and content_lines:
  801. new_entries.append({
  802. "id": kid,
  803. "types": types if types else ["strategy"],
  804. "helpful": helpful,
  805. "harmful": harmful,
  806. "score": score,
  807. "task": task.strip(),
  808. "content": "\n".join(content_lines).strip()
  809. })
  810. if not new_entries:
  811. raise HTTPException(status_code=500, detail="解析大模型输出失败")
  812. # 原子化写回
  813. now = datetime.now(timezone.utc).isoformat()
  814. conn.execute("DELETE FROM knowledge")
  815. for e in new_entries:
  816. eval_data = {
  817. "score": e["score"],
  818. "helpful": e["helpful"],
  819. "harmful": e["harmful"],
  820. "confidence": 0.9,
  821. "helpful_history": [],
  822. "harmful_history": []
  823. }
  824. source = {
  825. "name": "slim",
  826. "category": "exp",
  827. "urls": [],
  828. "agent_id": "slim",
  829. "submitted_by": "system",
  830. "timestamp": now
  831. }
  832. conn.execute(
  833. """INSERT INTO knowledge
  834. (id, message_id, types, task, tags, scopes, owner, content, source, eval, created_at, updated_at)
  835. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
  836. (
  837. e["id"],
  838. "",
  839. json.dumps(e["types"]),
  840. e["task"],
  841. json.dumps({}),
  842. json.dumps(["org:cybertogether"]),
  843. "agent:slim",
  844. e["content"],
  845. json.dumps(source, ensure_ascii=False),
  846. json.dumps(eval_data, ensure_ascii=False),
  847. now,
  848. now
  849. )
  850. )
  851. conn.commit()
  852. result_msg = f"瘦身完成:{len(rows)} → {len(new_entries)} 条知识"
  853. if report_line:
  854. result_msg += f"\n{report_line}"
  855. print(f"[知识瘦身] {result_msg}")
  856. return {"status": "ok", "before": len(rows), "after": len(new_entries), "report": report_line}
  857. finally:
  858. conn.close()
  859. @app.post("/api/extract")
  860. async def extract_knowledge_from_messages(extract_req: MessageExtractIn):
  861. """从消息历史中提取知识(LLM 分析)"""
  862. if not extract_req.submitted_by:
  863. raise HTTPException(status_code=400, detail="submitted_by is required")
  864. messages = extract_req.messages
  865. if not messages or len(messages) == 0:
  866. return {"status": "ok", "extracted_count": 0, "knowledge_ids": []}
  867. # 构造消息历史文本
  868. messages_text = ""
  869. for msg in messages:
  870. role = msg.get("role", "unknown")
  871. content = msg.get("content", "")
  872. messages_text += f"[{role}]: {content}\n\n"
  873. # LLM 提取知识
  874. prompt = f"""你是一个知识提取专家。请从以下 Agent 对话历史中提取有价值的知识。
  875. 【对话历史】:
  876. {messages_text}
  877. 【提取要求】:
  878. 1. 识别对话中的关键知识点(工具使用经验、问题解决方案、最佳实践、踩坑经验等)
  879. 2. 每条知识必须包含:
  880. - task: 任务场景描述(在什么情况下,要完成什么目标)
  881. - content: 核心知识内容(具体可操作的方法、注意事项)
  882. - types: 知识类型(从 strategy/tool/user_profile/usecase/definition/plan 中选择)
  883. - score: 评分 1-5(根据知识的价值和可操作性)
  884. 3. 只提取有实际价值的知识,不要提取泛泛而谈的内容
  885. 4. 如果没有值得提取的知识,返回空列表
  886. 【输出格式】:
  887. 严格按以下 JSON 格式输出,每条知识之间用逗号分隔:
  888. [
  889. {{
  890. "task": "任务场景描述",
  891. "content": "核心知识内容",
  892. "types": ["strategy"],
  893. "score": 4
  894. }},
  895. {{
  896. "task": "另一个任务场景",
  897. "content": "另一个知识内容",
  898. "types": ["tool"],
  899. "score": 5
  900. }}
  901. ]
  902. 如果没有知识,输出: []
  903. 禁止输出任何解释或额外文本,只输出 JSON 数组。"""
  904. try:
  905. print(f"\n[Extract] 正在从 {len(messages)} 条消息中提取知识...")
  906. response = await openrouter_llm_call(
  907. messages=[{"role": "user", "content": prompt}],
  908. model="google/gemini-2.0-flash-001"
  909. )
  910. content = response.get("content", "").strip()
  911. # 尝试解析 JSON
  912. # 移除可能的 markdown 代码块标记
  913. if content.startswith("```json"):
  914. content = content[7:]
  915. if content.startswith("```"):
  916. content = content[3:]
  917. if content.endswith("```"):
  918. content = content[:-3]
  919. content = content.strip()
  920. extracted_knowledge = json.loads(content)
  921. if not isinstance(extracted_knowledge, list):
  922. raise ValueError("LLM output is not a list")
  923. # 保存提取的知识
  924. conn = get_db()
  925. knowledge_ids = []
  926. now = datetime.now(timezone.utc).isoformat()
  927. try:
  928. for item in extracted_knowledge:
  929. task = item.get("task", "")
  930. knowledge_content = item.get("content", "")
  931. types = item.get("types", ["strategy"])
  932. score = item.get("score", 3)
  933. if not task or not knowledge_content:
  934. continue
  935. # 生成 ID
  936. import uuid
  937. timestamp = datetime.now().strftime('%Y%m%d-%H%M%S')
  938. random_suffix = uuid.uuid4().hex[:4]
  939. knowledge_id = f"knowledge-{timestamp}-{random_suffix}"
  940. # 准备数据
  941. source = {
  942. "name": "message_extraction",
  943. "category": "exp",
  944. "urls": [],
  945. "agent_id": extract_req.agent_id,
  946. "submitted_by": extract_req.submitted_by,
  947. "timestamp": now,
  948. "session_key": extract_req.session_key
  949. }
  950. eval_data = {
  951. "score": score,
  952. "helpful": 1,
  953. "harmful": 0,
  954. "confidence": 0.7,
  955. "helpful_history": [],
  956. "harmful_history": []
  957. }
  958. # 插入数据库
  959. conn.execute(
  960. """INSERT INTO knowledge
  961. (id, message_id, types, task, tags, scopes, owner, content,
  962. source, eval, created_at, updated_at)
  963. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
  964. (
  965. knowledge_id,
  966. "",
  967. json.dumps(types),
  968. task,
  969. json.dumps({}),
  970. json.dumps(["org:cybertogether"]),
  971. extract_req.submitted_by,
  972. knowledge_content,
  973. json.dumps(source, ensure_ascii=False),
  974. json.dumps(eval_data, ensure_ascii=False),
  975. now,
  976. now,
  977. ),
  978. )
  979. knowledge_ids.append(knowledge_id)
  980. conn.commit()
  981. print(f"[Extract] 成功提取并保存 {len(knowledge_ids)} 条知识")
  982. return {
  983. "status": "ok",
  984. "extracted_count": len(knowledge_ids),
  985. "knowledge_ids": knowledge_ids
  986. }
  987. finally:
  988. conn.close()
  989. except json.JSONDecodeError as e:
  990. print(f"[Extract] JSON 解析失败: {e}")
  991. print(f"[Extract] LLM 输出: {content[:500]}")
  992. return {"status": "error", "error": "Failed to parse LLM output", "extracted_count": 0}
  993. except Exception as e:
  994. print(f"[Extract] 提取失败: {e}")
  995. return {"status": "error", "error": str(e), "extracted_count": 0}
  996. @app.get("/", response_class=HTMLResponse)
  997. def frontend():
  998. """KnowHub 管理前端"""
  999. return """<!DOCTYPE html>
  1000. <html lang="zh-CN">
  1001. <head>
  1002. <meta charset="UTF-8">
  1003. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  1004. <title>KnowHub 管理</title>
  1005. <script src="https://cdn.tailwindcss.com"></script>
  1006. </head>
  1007. <body class="bg-gray-50">
  1008. <div class="container mx-auto px-4 py-8 max-w-7xl">
  1009. <div class="flex justify-between items-center mb-8">
  1010. <h1 class="text-3xl font-bold text-gray-800">KnowHub 全局知识库</h1>
  1011. <button onclick="openAddModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg">
  1012. + 新增知识
  1013. </button>
  1014. </div>
  1015. <!-- 筛选栏 -->
  1016. <div class="bg-white rounded-lg shadow p-6 mb-6">
  1017. <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
  1018. <div>
  1019. <label class="block text-sm font-medium text-gray-700 mb-2">类型 (Types)</label>
  1020. <div class="space-y-2">
  1021. <label class="flex items-center"><input type="checkbox" value="strategy" class="mr-2 type-filter"> Strategy</label>
  1022. <label class="flex items-center"><input type="checkbox" value="tool" class="mr-2 type-filter"> Tool</label>
  1023. <label class="flex items-center"><input type="checkbox" value="user_profile" class="mr-2 type-filter"> User Profile</label>
  1024. <label class="flex items-center"><input type="checkbox" value="usecase" class="mr-2 type-filter"> Usecase</label>
  1025. <label class="flex items-center"><input type="checkbox" value="definition" class="mr-2 type-filter"> Definition</label>
  1026. <label class="flex items-center"><input type="checkbox" value="plan" class="mr-2 type-filter"> Plan</label>
  1027. </div>
  1028. </div>
  1029. <div>
  1030. <label class="block text-sm font-medium text-gray-700 mb-2">Tags</label>
  1031. <div id="tagsFilterContainer" class="space-y-2 max-h-40 overflow-y-auto">
  1032. <p class="text-sm text-gray-500">加载中...</p>
  1033. </div>
  1034. </div>
  1035. <div>
  1036. <label class="block text-sm font-medium text-gray-700 mb-2">Owner</label>
  1037. <input type="text" id="ownerFilter" placeholder="输入 owner" class="w-full border rounded px-3 py-2">
  1038. </div>
  1039. <div>
  1040. <label class="block text-sm font-medium text-gray-700 mb-2">Scopes</label>
  1041. <input type="text" id="scopesFilter" placeholder="输入 scope" class="w-full border rounded px-3 py-2">
  1042. </div>
  1043. </div>
  1044. <button onclick="applyFilters()" class="mt-4 bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded">
  1045. 应用筛选
  1046. </button>
  1047. </div>
  1048. <!-- 知识列表 -->
  1049. <div id="knowledgeList" class="space-y-4"></div>
  1050. </div>
  1051. <!-- 新增/编辑 Modal -->
  1052. <div id="modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4">
  1053. <div class="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto p-6">
  1054. <h2 id="modalTitle" class="text-2xl font-bold mb-4">新增知识</h2>
  1055. <form id="knowledgeForm" class="space-y-4">
  1056. <input type="hidden" id="editId">
  1057. <div>
  1058. <label class="block text-sm font-medium mb-1">Task *</label>
  1059. <input type="text" id="taskInput" required class="w-full border rounded px-3 py-2">
  1060. </div>
  1061. <div>
  1062. <label class="block text-sm font-medium mb-1">Content *</label>
  1063. <textarea id="contentInput" required rows="6" class="w-full border rounded px-3 py-2"></textarea>
  1064. </div>
  1065. <div>
  1066. <label class="block text-sm font-medium mb-1">Types (多选)</label>
  1067. <div class="space-y-1">
  1068. <label class="flex items-center"><input type="checkbox" value="strategy" class="mr-2 type-checkbox"> Strategy</label>
  1069. <label class="flex items-center"><input type="checkbox" value="tool" class="mr-2 type-checkbox"> Tool</label>
  1070. <label class="flex items-center"><input type="checkbox" value="user_profile" class="mr-2 type-checkbox"> User Profile</label>
  1071. <label class="flex items-center"><input type="checkbox" value="usecase" class="mr-2 type-checkbox"> Usecase</label>
  1072. <label class="flex items-center"><input type="checkbox" value="definition" class="mr-2 type-checkbox"> Definition</label>
  1073. <label class="flex items-center"><input type="checkbox" value="plan" class="mr-2 type-checkbox"> Plan</label>
  1074. </div>
  1075. </div>
  1076. <div>
  1077. <label class="block text-sm font-medium mb-1">Tags (JSON)</label>
  1078. <textarea id="tagsInput" rows="2" placeholder='{"key": "value"}' class="w-full border rounded px-3 py-2"></textarea>
  1079. </div>
  1080. <div>
  1081. <label class="block text-sm font-medium mb-1">Scopes (逗号分隔)</label>
  1082. <input type="text" id="scopesInput" placeholder="org:cybertogether" class="w-full border rounded px-3 py-2">
  1083. </div>
  1084. <div>
  1085. <label class="block text-sm font-medium mb-1">Owner</label>
  1086. <input type="text" id="ownerInput" class="w-full border rounded px-3 py-2">
  1087. </div>
  1088. <div class="flex gap-2 pt-4">
  1089. <button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded">保存</button>
  1090. <button type="button" onclick="closeModal()" class="bg-gray-300 hover:bg-gray-400 px-6 py-2 rounded">取消</button>
  1091. </div>
  1092. </form>
  1093. </div>
  1094. </div>
  1095. <script>
  1096. let allKnowledge = [];
  1097. let availableTags = [];
  1098. async function loadTags() {
  1099. const res = await fetch('/api/knowledge/meta/tags');
  1100. const data = await res.json();
  1101. availableTags = data.tags;
  1102. renderTagsFilter();
  1103. }
  1104. function renderTagsFilter() {
  1105. const container = document.getElementById('tagsFilterContainer');
  1106. if (availableTags.length === 0) {
  1107. container.innerHTML = '<p class="text-sm text-gray-500">暂无 tags</p>';
  1108. return;
  1109. }
  1110. container.innerHTML = availableTags.map(tag =>
  1111. `<label class="flex items-center"><input type="checkbox" value="${escapeHtml(tag)}" class="mr-2 tag-filter"> ${escapeHtml(tag)}</label>`
  1112. ).join('');
  1113. }
  1114. async function loadKnowledge() {
  1115. const params = new URLSearchParams();
  1116. params.append('limit', '1000');
  1117. const selectedTypes = Array.from(document.querySelectorAll('.type-filter:checked')).map(el => el.value);
  1118. if (selectedTypes.length > 0) {
  1119. params.append('types', selectedTypes.join(','));
  1120. }
  1121. const selectedTags = Array.from(document.querySelectorAll('.tag-filter:checked')).map(el => el.value);
  1122. if (selectedTags.length > 0) {
  1123. params.append('tags', selectedTags.join(','));
  1124. }
  1125. const ownerFilter = document.getElementById('ownerFilter').value.trim();
  1126. if (ownerFilter) {
  1127. params.append('owner', ownerFilter);
  1128. }
  1129. const scopesFilter = document.getElementById('scopesFilter').value.trim();
  1130. if (scopesFilter) {
  1131. params.append('scopes', scopesFilter);
  1132. }
  1133. const res = await fetch(`/api/knowledge?${params.toString()}`);
  1134. const data = await res.json();
  1135. allKnowledge = data.results;
  1136. renderKnowledge(allKnowledge);
  1137. }
  1138. function applyFilters() {
  1139. loadKnowledge();
  1140. }
  1141. function renderKnowledge(list) {
  1142. const container = document.getElementById('knowledgeList');
  1143. if (list.length === 0) {
  1144. container.innerHTML = '<p class="text-gray-500 text-center py-8">暂无知识</p>';
  1145. return;
  1146. }
  1147. container.innerHTML = list.map(k => `
  1148. <div class="bg-white rounded-lg shadow p-6 hover:shadow-lg transition cursor-pointer" onclick="openEditModal('${k.id}')">
  1149. <div class="flex justify-between items-start mb-2">
  1150. <div class="flex gap-2 flex-wrap">
  1151. ${k.types.map(t => `<span class="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">${t}</span>`).join('')}
  1152. </div>
  1153. <span class="text-sm text-gray-500">${k.eval.score || 3}/5</span>
  1154. </div>
  1155. <h3 class="text-lg font-semibold text-gray-800 mb-2">${escapeHtml(k.task)}</h3>
  1156. <p class="text-sm text-gray-600 mb-2">${escapeHtml(k.content.substring(0, 150))}${k.content.length > 150 ? '...' : ''}</p>
  1157. <div class="flex justify-between text-xs text-gray-500">
  1158. <span>Owner: ${k.owner || 'N/A'}</span>
  1159. <span>${new Date(k.created_at).toLocaleDateString()}</span>
  1160. </div>
  1161. </div>
  1162. `).join('');
  1163. }
  1164. function openAddModal() {
  1165. document.getElementById('modalTitle').textContent = '新增知识';
  1166. document.getElementById('knowledgeForm').reset();
  1167. document.getElementById('editId').value = '';
  1168. document.querySelectorAll('.type-checkbox').forEach(el => el.checked = false);
  1169. document.getElementById('modal').classList.remove('hidden');
  1170. }
  1171. async function openEditModal(id) {
  1172. const k = allKnowledge.find(item => item.id === id);
  1173. if (!k) return;
  1174. document.getElementById('modalTitle').textContent = '编辑知识';
  1175. document.getElementById('editId').value = k.id;
  1176. document.getElementById('taskInput').value = k.task;
  1177. document.getElementById('contentInput').value = k.content;
  1178. document.getElementById('tagsInput').value = JSON.stringify(k.tags);
  1179. document.getElementById('scopesInput').value = k.scopes.join(', ');
  1180. document.getElementById('ownerInput').value = k.owner;
  1181. document.querySelectorAll('.type-checkbox').forEach(el => {
  1182. el.checked = k.types.includes(el.value);
  1183. });
  1184. document.getElementById('modal').classList.remove('hidden');
  1185. }
  1186. function closeModal() {
  1187. document.getElementById('modal').classList.add('hidden');
  1188. }
  1189. document.getElementById('knowledgeForm').addEventListener('submit', async (e) => {
  1190. e.preventDefault();
  1191. const editId = document.getElementById('editId').value;
  1192. const task = document.getElementById('taskInput').value;
  1193. const content = document.getElementById('contentInput').value;
  1194. const types = Array.from(document.querySelectorAll('.type-checkbox:checked')).map(el => el.value);
  1195. const tagsText = document.getElementById('tagsInput').value.trim();
  1196. const scopesText = document.getElementById('scopesInput').value.trim();
  1197. const owner = document.getElementById('ownerInput').value.trim();
  1198. let tags = {};
  1199. if (tagsText) {
  1200. try {
  1201. tags = JSON.parse(tagsText);
  1202. } catch (e) {
  1203. alert('Tags JSON 格式错误');
  1204. return;
  1205. }
  1206. }
  1207. const scopes = scopesText ? scopesText.split(',').map(s => s.trim()).filter(s => s) : ['org:cybertogether'];
  1208. if (editId) {
  1209. // 编辑
  1210. const res = await fetch(`/api/knowledge/${editId}`, {
  1211. method: 'PATCH',
  1212. headers: {'Content-Type': 'application/json'},
  1213. body: JSON.stringify({task, content, types, tags, scopes, owner})
  1214. });
  1215. if (!res.ok) {
  1216. alert('更新失败');
  1217. return;
  1218. }
  1219. } else {
  1220. // 新增
  1221. const res = await fetch('/api/knowledge', {
  1222. method: 'POST',
  1223. headers: {'Content-Type': 'application/json'},
  1224. body: JSON.stringify({task, content, types, tags, scopes, owner})
  1225. });
  1226. if (!res.ok) {
  1227. alert('新增失败');
  1228. return;
  1229. }
  1230. }
  1231. closeModal();
  1232. await loadKnowledge();
  1233. });
  1234. function escapeHtml(text) {
  1235. const div = document.createElement('div');
  1236. div.textContent = text;
  1237. return div.innerHTML;
  1238. }
  1239. loadTags();
  1240. loadKnowledge();
  1241. </script>
  1242. </body>
  1243. </html>"""
  1244. if __name__ == "__main__":
  1245. import uvicorn
  1246. uvicorn.run(app, host="0.0.0.0", port=9999)