server.py 121 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977
  1. """
  2. KnowHub Server
  3. Agent 工具使用经验的共享平台。
  4. FastAPI + Milvus Lite(知识)+ SQLite(资源),单文件部署。
  5. """
  6. import os
  7. import re
  8. import json
  9. import asyncio
  10. import base64
  11. import time
  12. import uuid
  13. from contextlib import asynccontextmanager
  14. from datetime import datetime, timezone
  15. from typing import Optional, List, Dict
  16. from pathlib import Path
  17. from cryptography.hazmat.primitives.ciphers.aead import AESGCM
  18. from fastapi import FastAPI, HTTPException, Query, Header, Body, BackgroundTasks
  19. from fastapi.responses import HTMLResponse
  20. from pydantic import BaseModel, Field
  21. # 导入 LLM 调用(需要 agent 模块在 Python path 中)
  22. import sys
  23. sys.path.insert(0, str(Path(__file__).parent.parent))
  24. # 加载环境变量
  25. from dotenv import load_dotenv
  26. load_dotenv(Path(__file__).parent.parent / ".env")
  27. from agent.llm import create_openrouter_llm_call, create_qwen_llm_call
  28. _dedup_llm = create_openrouter_llm_call(model="google/gemini-2.5-flash-lite")
  29. _tool_analysis_llm = create_qwen_llm_call(model="qwen3.5-plus")
  30. # 导入向量存储和 embedding
  31. from knowhub.knowhub_db.pg_store import PostgreSQLStore
  32. from knowhub.knowhub_db.pg_resource_store import PostgreSQLResourceStore
  33. from knowhub.embeddings import get_embedding, get_embeddings_batch
  34. BRAND_NAME = os.getenv("BRAND_NAME", "KnowHub")
  35. BRAND_API_ENV = os.getenv("BRAND_API_ENV", "KNOWHUB_API")
  36. BRAND_DB = os.getenv("BRAND_DB", "knowhub.db")
  37. # 组织密钥配置(格式:org1:key1_base64,org2:key2_base64)
  38. ORG_KEYS_RAW = os.getenv("ORG_KEYS", "")
  39. ORG_KEYS = {}
  40. if ORG_KEYS_RAW:
  41. for pair in ORG_KEYS_RAW.split(","):
  42. if ":" in pair:
  43. org, key_b64 = pair.split(":", 1)
  44. ORG_KEYS[org.strip()] = key_b64.strip()
  45. DB_PATH = Path(__file__).parent / BRAND_DB
  46. # 全局 PostgreSQL 存储实例
  47. pg_store: Optional[PostgreSQLStore] = None
  48. pg_resource_store: Optional[PostgreSQLResourceStore] = None
  49. # --- 加密/解密 ---
  50. def get_org_key(resource_id: str) -> Optional[bytes]:
  51. """从content_id提取组织前缀,返回对应密钥"""
  52. if "/" in resource_id:
  53. org = resource_id.split("/")[0]
  54. if org in ORG_KEYS:
  55. return base64.b64decode(ORG_KEYS[org])
  56. return None
  57. def encrypt_content(resource_id: str, plaintext: str) -> str:
  58. """加密内容,返回格式:encrypted:AES256-GCM:{base64_data}"""
  59. if not plaintext:
  60. return ""
  61. key = get_org_key(resource_id)
  62. if not key:
  63. # 没有配置密钥,明文存储(不推荐)
  64. return plaintext
  65. aesgcm = AESGCM(key)
  66. nonce = os.urandom(12) # 96-bit nonce
  67. ciphertext = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
  68. # 组合 nonce + ciphertext
  69. encrypted_data = nonce + ciphertext
  70. encoded = base64.b64encode(encrypted_data).decode("ascii")
  71. return f"encrypted:AES256-GCM:{encoded}"
  72. def decrypt_content(resource_id: str, encrypted_text: str, provided_key: Optional[str] = None) -> str:
  73. """解密内容,如果没有提供密钥或密钥错误,返回[ENCRYPTED]"""
  74. if not encrypted_text:
  75. return ""
  76. if not encrypted_text.startswith("encrypted:AES256-GCM:"):
  77. # 未加密的内容,直接返回
  78. return encrypted_text
  79. # 提取加密数据
  80. encoded = encrypted_text.split(":", 2)[2]
  81. encrypted_data = base64.b64decode(encoded)
  82. nonce = encrypted_data[:12]
  83. ciphertext = encrypted_data[12:]
  84. # 获取密钥
  85. key = None
  86. if provided_key:
  87. # 使用提供的密钥
  88. try:
  89. key = base64.b64decode(provided_key)
  90. except Exception:
  91. return "[ENCRYPTED]"
  92. else:
  93. # 从配置中获取
  94. key = get_org_key(resource_id)
  95. if not key:
  96. return "[ENCRYPTED]"
  97. try:
  98. aesgcm = AESGCM(key)
  99. plaintext = aesgcm.decrypt(nonce, ciphertext, None)
  100. return plaintext.decode("utf-8")
  101. except Exception:
  102. return "[ENCRYPTED]"
  103. def serialize_milvus_result(data):
  104. """将 Milvus 返回的数据转换为可序列化的字典"""
  105. # 基本类型直接返回
  106. if data is None or isinstance(data, (str, int, float, bool)):
  107. return data
  108. # 字典类型递归处理
  109. if isinstance(data, dict):
  110. return {k: serialize_milvus_result(v) for k, v in data.items()}
  111. # 列表/元组类型递归处理
  112. if isinstance(data, (list, tuple)):
  113. return [serialize_milvus_result(item) for item in data]
  114. # 尝试转换为字典(对于有 to_dict 方法的对象)
  115. if hasattr(data, 'to_dict') and callable(getattr(data, 'to_dict')):
  116. try:
  117. return serialize_milvus_result(data.to_dict())
  118. except:
  119. pass
  120. # 尝试转换为列表(对于可迭代对象,如 RepeatedScalarContainer)
  121. if hasattr(data, '__iter__') and not isinstance(data, (str, bytes, dict)):
  122. try:
  123. # 强制转换为列表并递归处理
  124. result = []
  125. for item in data:
  126. result.append(serialize_milvus_result(item))
  127. return result
  128. except:
  129. pass
  130. # 尝试获取对象的属性字典
  131. if hasattr(data, '__dict__'):
  132. try:
  133. return serialize_milvus_result(vars(data))
  134. except:
  135. pass
  136. # 最后的 fallback:对于无法处理的类型,返回 None 而不是字符串表示
  137. # 这样可以避免产生无法序列化的字符串
  138. return None
  139. # --- Models ---
  140. class ResourceIn(BaseModel):
  141. id: str
  142. title: str = ""
  143. body: str
  144. secure_body: str = ""
  145. content_type: str = "text" # text|code|credential|cookie
  146. metadata: dict = {}
  147. sort_order: int = 0
  148. submitted_by: str = ""
  149. class ResourcePatchIn(BaseModel):
  150. """PATCH /api/resource/{id} 请求体"""
  151. title: Optional[str] = None
  152. body: Optional[str] = None
  153. secure_body: Optional[str] = None
  154. content_type: Optional[str] = None
  155. metadata: Optional[dict] = None
  156. # Knowledge Models
  157. class KnowledgeIn(BaseModel):
  158. task: str
  159. content: str
  160. types: list[str] = ["strategy"]
  161. tags: dict = {}
  162. scopes: list[str] = ["org:cybertogether"]
  163. owner: str = ""
  164. message_id: str = ""
  165. resource_ids: list[str] = []
  166. source: dict = {} # {name, category, urls, agent_id, submitted_by, timestamp}
  167. eval: dict = {} # {score, helpful, harmful, confidence}
  168. class KnowledgeOut(BaseModel):
  169. id: str
  170. message_id: str
  171. types: list[str]
  172. task: str
  173. tags: dict
  174. scopes: list[str]
  175. owner: str
  176. content: str
  177. resource_ids: list[str]
  178. source: dict
  179. eval: dict
  180. created_at: str
  181. updated_at: str
  182. class KnowledgeUpdateIn(BaseModel):
  183. add_helpful_case: Optional[dict] = None
  184. add_harmful_case: Optional[dict] = None
  185. update_score: Optional[int] = Field(default=None, ge=1, le=5)
  186. evolve_feedback: Optional[str] = None
  187. class KnowledgePatchIn(BaseModel):
  188. """PATCH /api/knowledge/{id} 请求体(直接字段编辑)"""
  189. task: Optional[str] = None
  190. content: Optional[str] = None
  191. types: Optional[list[str]] = None
  192. tags: Optional[dict] = None
  193. scopes: Optional[list[str]] = None
  194. owner: Optional[str] = None
  195. class MessageExtractIn(BaseModel):
  196. """POST /api/extract 请求体(消息历史提取)"""
  197. messages: list[dict] # [{role: str, content: str}, ...]
  198. agent_id: str = "unknown"
  199. submitted_by: str # 必填,作为 owner
  200. session_key: str = ""
  201. class KnowledgeBatchUpdateIn(BaseModel):
  202. feedback_list: list[dict]
  203. class KnowledgeVerifyIn(BaseModel):
  204. action: str # "approve" | "reject"
  205. verified_by: str = "user"
  206. class KnowledgeBatchVerifyIn(BaseModel):
  207. knowledge_ids: List[str]
  208. action: str # "approve"
  209. verified_by: str
  210. class KnowledgeSearchResponse(BaseModel):
  211. results: list[dict]
  212. count: int
  213. class ResourceNode(BaseModel):
  214. id: str
  215. title: str
  216. class ResourceOut(BaseModel):
  217. id: str
  218. title: str
  219. body: str
  220. secure_body: str = ""
  221. content_type: str = "text"
  222. metadata: dict = {}
  223. toc: Optional[ResourceNode] = None
  224. children: list[ResourceNode]
  225. prev: Optional[ResourceNode] = None
  226. next: Optional[ResourceNode] = None
  227. # --- Dedup: Globals & Prompt ---
  228. knowledge_processor: Optional["KnowledgeProcessor"] = None
  229. DEDUP_RELATION_PROMPT = """你是知识库管理专家。请判断【新知识】与【相似知识列表】中每条知识的关系。
  230. 【新知识】
  231. Task: {new_task}
  232. Content: {new_content}
  233. 【相似知识列表】(向量召回 top-10,按相似度排序)
  234. {existing_list}
  235. 格式: [序号] ID: xxx | Task: xxx | Content: xxx
  236. 【关系类型定义】
  237. - duplicate: task 和 content 语义完全相同,无新增信息 → 新知识应 rejected
  238. - subset: task语义一致,新知识的content信息完全被某条已有知识覆盖 → 新知识应 rejected
  239. - superset: task语义一致,新知识包含某条已有知识的全部信息,且有额外内容 → 新知识应 approved
  240. - conflict: 同一 task 下给出相互矛盾的结论 → 新知识应 approved
  241. - complement: 描述同一 task 的不同方面,互补 → 新知识应 approved
  242. - none: task 语义不同,或无实质关系 → 新知识应 approved,不写入 relations
  243. 【判断步骤】
  244. 第一步:逐条比较新知识的 task 与列表中每条知识的 task 语义是否一致。
  245. - task 语义一致 = 两者描述的是同一个问题或目标(即使措辞不同)
  246. - task 语义不同 = 描述的是不同的问题、不同的工具、不同的场景
  247. - 如果 task 语义不同,该条关系直接判定为 none,**不再看 content**
  248. - 只有 task 语义一致时,才进入第二步比较 content
  249. 第二步:对 task 语义一致的知识,比较 content,判断具体关系类型(duplicate/subset/superset/conflict/complement)。
  250. **规则**:
  251. 1. 如果以上类型无法准确描述,可自定义关系类型(英文小写下划线),并自行决定 approved/rejected
  252. 2. final_decision 为 rejected 时,relations 中必须至少有一条关系说明拒绝原因(type 不能为 none)
  253. 【输出格式】(严格 JSON,不要其他内容)
  254. 示例1 - 无关知识(task 不同):
  255. {{
  256. "final_decision": "approved",
  257. "relations": []
  258. }}
  259. 示例2 - 重复知识:
  260. {{
  261. "final_decision": "rejected",
  262. "relations": [
  263. {{
  264. "old_id": "knowledge-xxx",
  265. "type": "duplicate",
  266. "reverse_type": "duplicate"
  267. }}
  268. ]
  269. }}
  270. 示例3 - 互补知识:
  271. {{
  272. "final_decision": "approved",
  273. "relations": [
  274. {{
  275. "old_id": "knowledge-xxx",
  276. "type": "complement",
  277. "reverse_type": "complement"
  278. }}
  279. ]
  280. }}
  281. """
  282. TOOL_ANALYSIS_PROMPT = """\
  283. 分析以下知识条目,判断是否涉及"图像创作或解构任务中使用的工具"。
  284. 工具范畴(包括但不限于):
  285. - AI 生图平台/模型:Midjourney、Stable Diffusion、DALL-E、Flux、ComfyUI
  286. - SD 插件/节点:ControlNet、IP-Adapter、InstantID、DWPose、DSINE
  287. - 图像处理库:rembg、PIL/Pillow、OpenCV、scikit-image
  288. - LoRA/checkpoint 模型、ComfyUI 自定义节点、AI 绘图辅助工具
  289. 知识条目:
  290. task: {task}
  291. content: {content}
  292. 要求:
  293. - 如果涉及上述工具,提取每个工具的信息并以 JSON 格式返回。
  294. - 如果不涉及任何工具,返回 {{"has_tools": false}}。
  295. - 只输出 JSON,不要输出其他内容。
  296. 输出格式:
  297. {{
  298. "has_tools": true,
  299. "tools": [
  300. {{
  301. "name": "工具名称(原名)",
  302. "slug": "小写英文短名,空格换下划线,如 controlnet、ip_adapter",
  303. "category": "image_gen | image_process | model | plugin | workflow | other",
  304. "version": "版本号或 null",
  305. "description": "一句话功能介绍",
  306. "usage": "核心用法",
  307. "scenarios": ["应用场景1", "应用场景2"],
  308. "input": "输入类型描述或 null",
  309. "output": "输出类型描述或 null",
  310. "source": "来源/文档链接或 null",
  311. "status": "未接入"
  312. }}
  313. ]
  314. }}
  315. """
  316. # --- Dedup: RelationCache ---
  317. class RelationCache:
  318. """关系缓存,存储在内存中"""
  319. def __init__(self):
  320. self._cache: Dict[str, List[str]] = {}
  321. def load(self) -> dict:
  322. return self._cache
  323. def save(self, cache: dict):
  324. self._cache = cache
  325. def add_relation(self, relation_type: str, knowledge_id: str):
  326. if relation_type not in self._cache:
  327. self._cache[relation_type] = []
  328. if knowledge_id not in self._cache[relation_type]:
  329. self._cache[relation_type].append(knowledge_id)
  330. # --- Dedup: KnowledgeProcessor ---
  331. class KnowledgeProcessor:
  332. def __init__(self):
  333. self._lock = asyncio.Lock()
  334. self._relation_cache = RelationCache()
  335. async def process_pending(self):
  336. """持续处理 pending 和 dedup_passed 知识直到队列为空,有锁防并发"""
  337. if self._lock.locked():
  338. return
  339. async with self._lock:
  340. # 第一阶段:处理 pending(去重)
  341. while True:
  342. try:
  343. pending = pg_store.query('status == "pending"', limit=50)
  344. except Exception as e:
  345. print(f"[KnowledgeProcessor] 查询 pending 失败: {e}")
  346. break
  347. if not pending:
  348. break
  349. for knowledge in pending:
  350. await self._process_one(knowledge)
  351. # 第二阶段:处理 dedup_passed(工具关联)
  352. while True:
  353. try:
  354. dedup_passed = pg_store.query('status == "dedup_passed"', limit=50)
  355. except Exception as e:
  356. print(f"[KnowledgeProcessor] 查询 dedup_passed 失败: {e}")
  357. break
  358. if not dedup_passed:
  359. break
  360. for knowledge in dedup_passed:
  361. await self._analyze_tool_relation(knowledge)
  362. async def _process_one(self, knowledge: dict):
  363. kid = knowledge["id"]
  364. now = int(time.time())
  365. # 乐观锁:pending → processing(时间戳存秒级)
  366. try:
  367. pg_store.update(kid, {"status": "processing", "updated_at": now})
  368. except Exception as e:
  369. print(f"[KnowledgeProcessor] 锁定 {kid} 失败: {e}")
  370. return
  371. try:
  372. # 向量召回 top-10(只召回 approved/checked)
  373. embedding = knowledge.get("embedding")
  374. if not embedding:
  375. embedding = await get_embedding(knowledge["task"])
  376. candidates = pg_store.search(
  377. query_embedding=embedding,
  378. filters='(status == "approved" or status == "checked")',
  379. limit=10
  380. )
  381. candidates = [c for c in candidates if c["id"] != kid]
  382. # 只保留相似度 >= 0.75 的候选,低于阈值的 task 语义差异太大,直接视为 none
  383. candidates = [c for c in candidates if c.get("score", 0) >= 0.75]
  384. if not candidates:
  385. pg_store.update(kid, {"status": "dedup_passed", "updated_at": now})
  386. return
  387. llm_result = await self._llm_judge_relations(knowledge, candidates)
  388. await self._apply_decision(knowledge, llm_result)
  389. except Exception as e:
  390. print(f"[KnowledgeProcessor] 处理 {kid} 失败: {e},回退到 pending")
  391. try:
  392. pg_store.update(kid, {"status": "pending", "updated_at": int(time.time())})
  393. except Exception:
  394. pass
  395. async def _llm_judge_relations(self, new_knowledge: dict, candidates: list) -> dict:
  396. existing_list = "\n".join([
  397. f"[{i+1}] ID: {c['id']} | Task: {c['task']} | Content: {c['content'][:300]}"
  398. for i, c in enumerate(candidates)
  399. ])
  400. prompt = DEDUP_RELATION_PROMPT.format(
  401. new_task=new_knowledge["task"],
  402. new_content=new_knowledge["content"],
  403. existing_list=existing_list
  404. )
  405. for attempt in range(3):
  406. try:
  407. response = await _dedup_llm(
  408. messages=[{"role": "user", "content": prompt}],
  409. )
  410. content = response.get("content", "").strip()
  411. # 清理 markdown 代码块
  412. if "```" in content:
  413. parts = content.split("```")
  414. for part in parts:
  415. part = part.strip()
  416. if part.startswith("json"):
  417. part = part[4:].strip()
  418. try:
  419. result = json.loads(part)
  420. if "final_decision" in result:
  421. content = part
  422. break
  423. except Exception:
  424. continue
  425. result = json.loads(content)
  426. assert result.get("final_decision") in ("approved", "rejected")
  427. return result
  428. except Exception as e:
  429. print(f"[LLM Judge] 第{attempt+1}次失败: {e}")
  430. if attempt < 2:
  431. await asyncio.sleep(1)
  432. return {"final_decision": "approved", "relations": []}
  433. async def _apply_decision(self, new_knowledge: dict, llm_result: dict):
  434. kid = new_knowledge["id"]
  435. final_decision = llm_result.get("final_decision", "approved")
  436. relations = llm_result.get("relations", [])
  437. now = int(time.time())
  438. # 强制规则:如果存在 duplicate 或 subset 关系,必须 rejected
  439. if any(rel.get("type") in ("duplicate", "subset") for rel in relations):
  440. final_decision = "rejected"
  441. if final_decision == "rejected":
  442. # 记录 rejected 知识的关系(便于溯源为什么被拒绝)
  443. rejected_relationships = []
  444. for rel in relations:
  445. old_id = rel.get("old_id")
  446. rel_type = rel.get("type", "none")
  447. if old_id and rel_type != "none":
  448. rejected_relationships.append({"type": rel_type, "target": old_id})
  449. if rel_type in ("duplicate", "subset") and old_id:
  450. try:
  451. old = pg_store.get_by_id(old_id)
  452. if not old:
  453. continue
  454. eval_data = old.get("eval") or {}
  455. eval_data["helpful"] = eval_data.get("helpful", 0) + 1
  456. helpful_history = eval_data.get("helpful_history") or []
  457. helpful_history.append({
  458. "source": "dedup",
  459. "related_id": kid,
  460. "relation_type": rel_type,
  461. "timestamp": now
  462. })
  463. eval_data["helpful_history"] = helpful_history
  464. pg_store.update(old_id, {"eval": eval_data, "updated_at": now})
  465. except Exception as e:
  466. print(f"[Apply Decision] 更新旧知识 {old_id} helpful 失败: {e}")
  467. pg_store.update(kid, {"status": "rejected", "relationships": json.dumps(rejected_relationships), "updated_at": now})
  468. else:
  469. new_relationships = []
  470. for rel in relations:
  471. rel_type = rel.get("type", "none")
  472. reverse_type = rel.get("reverse_type", "none")
  473. old_id = rel.get("old_id")
  474. if not old_id or rel_type == "none":
  475. continue
  476. new_relationships.append({"type": rel_type, "target": old_id})
  477. self._relation_cache.add_relation(rel_type, kid)
  478. self._relation_cache.add_relation(rel_type, old_id)
  479. if reverse_type and reverse_type != "none":
  480. try:
  481. old = pg_store.get_by_id(old_id)
  482. if old:
  483. old_rels = old.get("relationships") or []
  484. old_rels.append({"type": reverse_type, "target": kid})
  485. pg_store.update(old_id, {"relationships": json.dumps(old_rels), "updated_at": now})
  486. self._relation_cache.add_relation(reverse_type, old_id)
  487. self._relation_cache.add_relation(reverse_type, kid)
  488. except Exception as e:
  489. print(f"[Apply Decision] 更新旧知识关系 {old_id} 失败: {e}")
  490. pg_store.update(kid, {
  491. "status": "dedup_passed",
  492. "relationships": json.dumps(new_relationships),
  493. "updated_at": now
  494. })
  495. async def _llm_analyze_tools(self, knowledge: dict) -> dict:
  496. """使用 LLM 分析知识中涉及的工具(复用迁移脚本逻辑)"""
  497. task = (knowledge.get("task") or "")[:600]
  498. content = (knowledge.get("content") or "")[:1200]
  499. prompt = TOOL_ANALYSIS_PROMPT.format(task=task, content=content)
  500. try:
  501. response = await _tool_analysis_llm(
  502. messages=[{"role": "user", "content": prompt}],
  503. max_tokens=2048,
  504. temperature=0.1,
  505. )
  506. raw = (response.get("content") or "").strip()
  507. if raw.startswith("```"):
  508. lines = raw.split("\n")
  509. inner = []
  510. in_block = False
  511. for line in lines:
  512. if line.startswith("```"):
  513. in_block = not in_block
  514. continue
  515. if in_block:
  516. inner.append(line)
  517. raw = "\n".join(inner).strip()
  518. return json.loads(raw)
  519. except Exception as e:
  520. print(f"[Tool Analysis LLM] 调用失败: {e}")
  521. raise
  522. async def _create_or_get_tool_resource(self, tool_info: dict) -> Optional[str]:
  523. """创建或获取工具资源(存入 PostgreSQL tool_table)"""
  524. category = tool_info.get("category", "other")
  525. slug = tool_info.get("slug", "")
  526. if not slug:
  527. return None
  528. tool_id = f"tools/{category}/{slug}"
  529. now_ts = int(time.time())
  530. cursor = pg_store._get_cursor()
  531. try:
  532. cursor.execute("SELECT id FROM tool_table WHERE id = %s", (tool_id,))
  533. if cursor.fetchone():
  534. return tool_id
  535. cursor.execute("""
  536. INSERT INTO tool_table (id, name, version, introduction, tutorial, input, output,
  537. updated_time, status, knowledge, case_knowledge, process_knowledge)
  538. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
  539. """, (
  540. tool_id,
  541. tool_info.get("name", slug),
  542. tool_info.get("version") or None,
  543. tool_info.get("description", ""),
  544. tool_info.get("usage", ""),
  545. json.dumps(tool_info.get("input", "")),
  546. json.dumps(tool_info.get("output", "")),
  547. now_ts,
  548. tool_info.get("status", "未接入"),
  549. json.dumps([]),
  550. json.dumps([]),
  551. json.dumps([]),
  552. ))
  553. pg_store.conn.commit()
  554. print(f"[Tool Resource] 创建新工具: {tool_id}")
  555. return tool_id
  556. finally:
  557. cursor.close()
  558. async def _update_tool_knowledge_index(self, tool_id: str, knowledge_id: str):
  559. """更新工具的 knowledge 关联索引(PostgreSQL tool_table)"""
  560. now_ts = int(time.time())
  561. cursor = pg_store._get_cursor()
  562. try:
  563. cursor.execute("SELECT knowledge FROM tool_table WHERE id = %s", (tool_id,))
  564. row = cursor.fetchone()
  565. if not row:
  566. return
  567. knowledge_ids = row["knowledge"] if isinstance(row["knowledge"], list) else json.loads(row["knowledge"] or "[]")
  568. if knowledge_id not in knowledge_ids:
  569. knowledge_ids.append(knowledge_id)
  570. cursor.execute(
  571. "UPDATE tool_table SET knowledge = %s, updated_time = %s WHERE id = %s",
  572. (json.dumps(knowledge_ids), now_ts, tool_id)
  573. )
  574. pg_store.conn.commit()
  575. finally:
  576. cursor.close()
  577. async def _analyze_tool_relation(self, knowledge: dict):
  578. """分析知识与工具的关联关系"""
  579. kid = knowledge["id"]
  580. now = int(time.time())
  581. # 乐观锁:dedup_passed → analyzing
  582. try:
  583. pg_store.update(kid, {"status": "analyzing", "updated_at": now})
  584. except Exception as e:
  585. print(f"[Tool Analysis] 锁定 {kid} 失败: {e}")
  586. return
  587. try:
  588. tool_analysis = await self._llm_analyze_tools(knowledge)
  589. has_tools = bool(tool_analysis and tool_analysis.get("has_tools"))
  590. existing_tags = knowledge.get("tags") or {}
  591. has_tool_tag = existing_tags.get("tool") is True
  592. # 情况1:LLM 判定无工具,但有 tool tag → 重新分析一次
  593. if not has_tools and has_tool_tag:
  594. print(f"[Tool Analysis] {kid} LLM 判定无工具但有 tool tag,重新分析")
  595. tool_analysis = await self._llm_analyze_tools(knowledge)
  596. has_tools = bool(tool_analysis and tool_analysis.get("has_tools"))
  597. # 重新分析后仍然不一致 → 知识模糊,rejected
  598. if not has_tools:
  599. pg_store.update(kid, {"status": "rejected", "updated_at": now})
  600. print(f"[Tool Analysis] {kid} 两次判定不一致,知识模糊,rejected")
  601. return
  602. # 情况2:无工具且无 tool tag → 直接 approved
  603. if not has_tools:
  604. pg_store.update(kid, {"status": "approved", "updated_at": now})
  605. return
  606. # 情况3/4:有工具 → 创建资源并关联
  607. tool_ids = []
  608. for tool_info in (tool_analysis.get("tools") or []):
  609. tool_id = await self._create_or_get_tool_resource(tool_info)
  610. if tool_id:
  611. tool_ids.append(tool_id)
  612. existing_resource_ids = knowledge.get("resource_ids") or []
  613. updated_resource_ids = list(set(existing_resource_ids + tool_ids))
  614. updates: dict = {
  615. "status": "approved",
  616. "resource_ids": updated_resource_ids,
  617. "updated_at": now
  618. }
  619. # 有工具但无 tool tag → 添加 tag
  620. if not has_tool_tag:
  621. updated_tags = dict(existing_tags)
  622. updated_tags["tool"] = True
  623. updates["tags"] = updated_tags
  624. print(f"[Tool Analysis] {kid} 添加 tool tag")
  625. pg_store.update(kid, updates)
  626. for tool_id in tool_ids:
  627. await self._update_tool_knowledge_index(tool_id, kid)
  628. print(f"[Tool Analysis] {kid} 关联了 {len(tool_ids)} 个工具")
  629. except Exception as e:
  630. print(f"[Tool Analysis] {kid} 分析失败: {e},回退到 dedup_passed")
  631. try:
  632. pg_store.update(kid, {"status": "dedup_passed", "updated_at": int(time.time())})
  633. except Exception:
  634. pass
  635. async def _periodic_processor():
  636. """每60秒检测超时条目并回滚:processing(>5min)→pending,analyzing(>10min)→dedup_passed"""
  637. while True:
  638. await asyncio.sleep(60)
  639. try:
  640. now = int(time.time())
  641. # 回滚超时的 processing(5分钟 → pending)
  642. timeout_5min = now - 300
  643. processing = pg_store.query('status == "processing"', limit=200)
  644. for item in processing:
  645. updated_at = item.get("updated_at", 0) or 0
  646. updated_at_sec = updated_at // 1000 if updated_at > 1_000_000_000_000 else updated_at
  647. if updated_at_sec < timeout_5min:
  648. print(f"[Periodic] 回滚超时 processing → pending: {item['id']}")
  649. pg_store.update(item["id"], {"status": "pending", "updated_at": int(time.time())})
  650. # 回滚超时的 analyzing(10分钟 → dedup_passed)
  651. timeout_10min = now - 600
  652. analyzing = pg_store.query('status == "analyzing"', limit=200)
  653. for item in analyzing:
  654. updated_at = item.get("updated_at", 0) or 0
  655. updated_at_sec = updated_at // 1000 if updated_at > 1_000_000_000_000 else updated_at
  656. if updated_at_sec < timeout_10min:
  657. print(f"[Periodic] 回滚超时 analyzing → dedup_passed: {item['id']}")
  658. pg_store.update(item["id"], {"status": "dedup_passed", "updated_at": int(time.time())})
  659. except Exception as e:
  660. print(f"[Periodic] 定时任务错误: {e}")
  661. # --- App ---
  662. @asynccontextmanager
  663. async def lifespan(app: FastAPI):
  664. global pg_store, pg_resource_store, knowledge_processor
  665. # 初始化 PostgreSQL(knowledge + resources)
  666. pg_store = PostgreSQLStore()
  667. pg_resource_store = PostgreSQLResourceStore()
  668. # 初始化去重处理器 + 启动定时兜底任务
  669. knowledge_processor = KnowledgeProcessor()
  670. periodic_task = asyncio.create_task(_periodic_processor())
  671. yield
  672. # 清理
  673. periodic_task.cancel()
  674. try:
  675. await periodic_task
  676. except asyncio.CancelledError:
  677. pass
  678. pg_store.close()
  679. pg_resource_store.close()
  680. app = FastAPI(title=BRAND_NAME, lifespan=lifespan)
  681. # --- Knowledge API ---
  682. @app.post("/api/resource", status_code=201)
  683. def submit_resource(resource: ResourceIn):
  684. """提交资源(存入 PostgreSQL resources 表)"""
  685. try:
  686. # 加密敏感内容
  687. encrypted_secure_body = encrypt_content(resource.id, resource.secure_body)
  688. pg_resource_store.insert_or_update({
  689. 'id': resource.id,
  690. 'title': resource.title,
  691. 'body': resource.body,
  692. 'secure_body': encrypted_secure_body,
  693. 'content_type': resource.content_type,
  694. 'metadata': resource.metadata,
  695. 'sort_order': resource.sort_order,
  696. 'submitted_by': resource.submitted_by
  697. })
  698. return {"status": "ok", "id": resource.id}
  699. except Exception as e:
  700. raise HTTPException(status_code=500, detail=str(e))
  701. @app.get("/api/resource/{resource_id:path}", response_model=ResourceOut)
  702. def get_resource(resource_id: str, x_org_key: Optional[str] = Header(None)):
  703. """获取资源详情(从 PostgreSQL)"""
  704. try:
  705. row = pg_resource_store.get_by_id(resource_id)
  706. if not row:
  707. raise HTTPException(status_code=404, detail=f"Resource not found: {resource_id}")
  708. # 解密敏感内容
  709. secure_body = decrypt_content(resource_id, row.get("secure_body", ""), x_org_key)
  710. # 计算导航上下文
  711. root_id = resource_id.split("/")[0] if "/" in resource_id else resource_id
  712. # TOC (根节点)
  713. toc = None
  714. if "/" in resource_id:
  715. toc_row = pg_resource_store.get_by_id(root_id)
  716. if toc_row:
  717. toc = ResourceNode(id=toc_row["id"], title=toc_row["title"])
  718. # Children (子节点)
  719. children_rows = pg_resource_store.list_resources(prefix=f"{resource_id}/", limit=1000)
  720. children = [ResourceNode(id=r["id"], title=r["title"]) for r in children_rows
  721. if r["id"].count("/") == resource_id.count("/") + 1]
  722. # Prev/Next (同级节点)
  723. prev_node, next_node = pg_resource_store.get_siblings(resource_id)
  724. prev = ResourceNode(id=prev_node["id"], title=prev_node["title"]) if prev_node else None
  725. next = ResourceNode(id=next_node["id"], title=next_node["title"]) if next_node else None
  726. return ResourceOut(
  727. id=row["id"],
  728. title=row["title"],
  729. body=row["body"],
  730. secure_body=secure_body,
  731. content_type=row["content_type"],
  732. metadata=row.get("metadata", {}),
  733. toc=toc,
  734. children=children,
  735. prev=prev,
  736. next=next,
  737. )
  738. except HTTPException:
  739. raise
  740. except Exception as e:
  741. raise HTTPException(status_code=500, detail=str(e))
  742. @app.patch("/api/resource/{resource_id:path}")
  743. def patch_resource(resource_id: str, patch: ResourcePatchIn):
  744. """更新resource字段(PostgreSQL)"""
  745. try:
  746. # 检查是否存在
  747. if not pg_resource_store.get_by_id(resource_id):
  748. raise HTTPException(status_code=404, detail=f"Resource not found: {resource_id}")
  749. # 构建更新字典
  750. updates = {}
  751. if patch.title is not None:
  752. updates['title'] = patch.title
  753. if patch.body is not None:
  754. updates['body'] = patch.body
  755. if patch.secure_body is not None:
  756. updates['secure_body'] = encrypt_content(resource_id, patch.secure_body)
  757. if patch.content_type is not None:
  758. updates['content_type'] = patch.content_type
  759. if patch.metadata is not None:
  760. updates['metadata'] = patch.metadata
  761. if not updates:
  762. return {"status": "ok", "message": "No fields to update"}
  763. pg_resource_store.update(resource_id, updates)
  764. return {"status": "ok", "id": resource_id}
  765. except HTTPException:
  766. raise
  767. except Exception as e:
  768. raise HTTPException(status_code=500, detail=str(e))
  769. @app.get("/api/resource")
  770. def list_resources(
  771. content_type: Optional[str] = Query(None),
  772. limit: int = Query(100, ge=1, le=1000)
  773. ):
  774. """列出所有resource(PostgreSQL)"""
  775. try:
  776. results = pg_resource_store.list_resources(
  777. content_type=content_type,
  778. limit=limit
  779. )
  780. return {"results": results, "count": len(results)}
  781. except Exception as e:
  782. raise HTTPException(status_code=500, detail=str(e))
  783. @app.delete("/api/resource/{resource_id:path}")
  784. def delete_resource(resource_id: str):
  785. """删除单个resource(PostgreSQL)"""
  786. try:
  787. if not pg_resource_store.get_by_id(resource_id):
  788. raise HTTPException(status_code=404, detail=f"Resource not found: {resource_id}")
  789. pg_resource_store.delete(resource_id)
  790. return {"status": "ok", "id": resource_id}
  791. except HTTPException:
  792. raise
  793. except Exception as e:
  794. raise HTTPException(status_code=500, detail=str(e))
  795. # --- Knowledge API ---
  796. # ===== Knowledge API =====
  797. async def _llm_rerank(query: str, candidates: list[dict], top_k: int) -> list[str]:
  798. """
  799. 使用 LLM 对候选知识进行精排
  800. Args:
  801. query: 查询文本
  802. candidates: 候选知识列表
  803. top_k: 返回数量
  804. Returns:
  805. 排序后的知识 ID 列表
  806. """
  807. if not candidates:
  808. return []
  809. # 构造 prompt
  810. candidates_text = "\n".join([
  811. f"[{i+1}] ID: {c['id']}\nTask: {c['task']}\nContent: {c['content'][:200]}..."
  812. for i, c in enumerate(candidates)
  813. ])
  814. prompt = f"""你是知识检索专家。根据用户查询,从候选知识中选出最相关的 {top_k} 条。
  815. 用户查询:"{query}"
  816. 候选知识:
  817. {candidates_text}
  818. 请输出最相关的 {top_k} 个知识 ID,按相关性从高到低排序,用逗号分隔。
  819. 只输出 ID,不要其他内容。"""
  820. try:
  821. response = await _dedup_llm(
  822. messages=[{"role": "user", "content": prompt}],
  823. )
  824. content = response.get("content", "").strip()
  825. # 解析 ID 列表
  826. selected_ids = [
  827. idx.strip()
  828. for idx in re.split(r'[,\s]+', content)
  829. if idx.strip().startswith(("knowledge-", "research-"))
  830. ]
  831. return selected_ids[:top_k]
  832. except Exception as e:
  833. print(f"[LLM Rerank] 失败: {e}")
  834. return []
  835. @app.get("/api/knowledge/search")
  836. async def search_knowledge_api(
  837. q: str = Query(..., description="查询文本"),
  838. top_k: int = Query(default=5, ge=1, le=20),
  839. min_score: int = Query(default=3, ge=1, le=5),
  840. types: Optional[str] = None,
  841. owner: Optional[str] = None
  842. ):
  843. """检索知识(向量召回 + LLM 精排)"""
  844. try:
  845. # 1. 生成查询向量
  846. query_embedding = await get_embedding(q)
  847. # 2. 构建过滤表达式
  848. filters = []
  849. if types:
  850. type_list = [t.strip() for t in types.split(',') if t.strip()]
  851. for t in type_list:
  852. filters.append(f'array_contains(types, "{t}")')
  853. if owner:
  854. owner_list = [o.strip() for o in owner.split(',') if o.strip()]
  855. if len(owner_list) == 1:
  856. filters.append(f'owner == "{owner_list[0]}"')
  857. else:
  858. # 多个owner用OR连接
  859. owner_filters = [f'owner == "{o}"' for o in owner_list]
  860. filters.append(f'({" or ".join(owner_filters)})')
  861. # 添加 min_score 过滤
  862. filters.append(f'eval["score"] >= {min_score}')
  863. # 只搜索 approved 和 checked 的知识
  864. filters.append('(status == "approved" or status == "checked")')
  865. filter_expr = ' and '.join(filters) if filters else None
  866. # 3. 向量召回(3*k 个候选)
  867. recall_limit = top_k * 3
  868. candidates = pg_store.search(
  869. query_embedding=query_embedding,
  870. filters=filter_expr,
  871. limit=recall_limit
  872. )
  873. if not candidates:
  874. return {"results": [], "count": 0, "reranked": False}
  875. # 转换为可序列化的格式
  876. serialized_candidates = [serialize_milvus_result(c) for c in candidates]
  877. # 4. LLM 精排
  878. reranked_ids = await _llm_rerank(q, serialized_candidates, top_k)
  879. if reranked_ids:
  880. # 按 LLM 排序返回
  881. id_to_candidate = {c["id"]: c for c in serialized_candidates}
  882. results = [id_to_candidate[id] for id in reranked_ids if id in id_to_candidate]
  883. return {"results": results, "count": len(results), "reranked": True}
  884. else:
  885. # Fallback:直接返回向量召回的 top k
  886. print(f"[Knowledge Search] LLM 精排失败,fallback 到向量 top-{top_k}")
  887. return {"results": serialized_candidates[:top_k], "count": len(serialized_candidates[:top_k]), "reranked": False}
  888. except Exception as e:
  889. print(f"[Knowledge Search] 错误: {e}")
  890. raise HTTPException(status_code=500, detail=str(e))
  891. @app.post("/api/knowledge", status_code=201)
  892. async def save_knowledge(knowledge: KnowledgeIn, background_tasks: BackgroundTasks):
  893. """保存新知识"""
  894. try:
  895. # 生成 ID
  896. timestamp = datetime.now().strftime('%Y%m%d-%H%M%S')
  897. random_suffix = uuid.uuid4().hex[:4]
  898. knowledge_id = f"knowledge-{timestamp}-{random_suffix}"
  899. now = int(time.time())
  900. # 设置默认值
  901. owner = knowledge.owner or f"agent:{knowledge.source.get('agent_id', 'unknown')}"
  902. # 准备 source
  903. source = {
  904. "name": knowledge.source.get("name", ""),
  905. "category": knowledge.source.get("category", ""),
  906. "urls": knowledge.source.get("urls", []),
  907. "agent_id": knowledge.source.get("agent_id", "unknown"),
  908. "submitted_by": knowledge.source.get("submitted_by", ""),
  909. "timestamp": datetime.now(timezone.utc).isoformat(),
  910. "message_id": knowledge.message_id
  911. }
  912. # 准备 eval
  913. eval_data = {
  914. "score": knowledge.eval.get("score", 3),
  915. "helpful": knowledge.eval.get("helpful", 1),
  916. "harmful": knowledge.eval.get("harmful", 0),
  917. "confidence": knowledge.eval.get("confidence", 0.5),
  918. "helpful_history": [],
  919. "harmful_history": []
  920. }
  921. # 生成向量(只基于 task,因为搜索时用户描述的是任务场景)
  922. embedding = await get_embedding(knowledge.task)
  923. # 提取 tag keys(用于高效筛选)
  924. tag_keys = list(knowledge.tags.keys()) if isinstance(knowledge.tags, dict) else []
  925. # 准备插入数据
  926. insert_data = {
  927. "id": knowledge_id,
  928. "embedding": embedding,
  929. "message_id": knowledge.message_id,
  930. "task": knowledge.task,
  931. "content": knowledge.content,
  932. "types": knowledge.types,
  933. "tags": knowledge.tags,
  934. "tag_keys": tag_keys,
  935. "scopes": knowledge.scopes,
  936. "owner": owner,
  937. "resource_ids": knowledge.resource_ids,
  938. "source": source,
  939. "eval": eval_data,
  940. "created_at": now,
  941. "updated_at": now,
  942. "status": "pending",
  943. "relationships": json.dumps([]),
  944. }
  945. print(f"[Save Knowledge] 插入数据: {json.dumps({k: v for k, v in insert_data.items() if k != 'embedding'}, ensure_ascii=False)}")
  946. # 插入 Milvus
  947. pg_store.insert(insert_data)
  948. # 触发后台去重处理
  949. background_tasks.add_task(knowledge_processor.process_pending)
  950. return {"status": "pending", "knowledge_id": knowledge_id, "message": "知识已入队,正在处理去重..."}
  951. except Exception as e:
  952. print(f"[Save Knowledge] 错误: {e}")
  953. raise HTTPException(status_code=500, detail=str(e))
  954. @app.get("/api/knowledge")
  955. def list_knowledge(
  956. page: int = Query(default=1, ge=1),
  957. page_size: int = Query(default=200, ge=1, le=500),
  958. types: Optional[str] = None,
  959. scopes: Optional[str] = None,
  960. owner: Optional[str] = None,
  961. tags: Optional[str] = None,
  962. status: Optional[str] = None
  963. ):
  964. """列出知识(支持后端筛选和分页)"""
  965. try:
  966. # 构建过滤表达式
  967. filters = []
  968. # types 支持多个,用 AND 连接(交集:必须同时包含所有选中的type)
  969. if types:
  970. type_list = [t.strip() for t in types.split(',') if t.strip()]
  971. for t in type_list:
  972. filters.append(f'array_contains(types, "{t}")')
  973. if scopes:
  974. filters.append(f'array_contains(scopes, "{scopes}")')
  975. if owner:
  976. owner_list = [o.strip() for o in owner.split(',') if o.strip()]
  977. if len(owner_list) == 1:
  978. filters.append(f'owner == "{owner_list[0]}"')
  979. else:
  980. # 多个owner用OR连接
  981. owner_filters = [f'owner == "{o}"' for o in owner_list]
  982. filters.append(f'({" or ".join(owner_filters)})')
  983. # tags 支持多个,用 AND 连接(使用 tag_keys 数组进行高效筛选)
  984. if tags:
  985. tag_list = [t.strip() for t in tags.split(',') if t.strip()]
  986. for t in tag_list:
  987. filters.append(f'array_contains(tag_keys, "{t}")')
  988. # 只返回指定 status 的知识(默认 approved 和 checked)
  989. status_list = [s.strip() for s in (status or "approved,checked").split(',') if s.strip()]
  990. status_conditions = ' or '.join([f'status == "{s}"' for s in status_list])
  991. filters.append(f'({status_conditions})')
  992. # 如果没有过滤条件,查询所有
  993. filter_expr = ' and '.join(filters) if filters else 'id != ""'
  994. # 查询 Milvus(先获取所有符合条件的数据)
  995. # Milvus 的 limit 是总数限制,我们需要获取足够多的数据来支持分页
  996. max_limit = 10000 # 设置一个合理的上限
  997. results = pg_store.query(filter_expr, limit=max_limit)
  998. # 转换为可序列化的格式
  999. serialized_results = [serialize_milvus_result(r) for r in results]
  1000. # 按 created_at 降序排序(最新的在前)
  1001. serialized_results.sort(key=lambda x: x.get('created_at', 0), reverse=True)
  1002. # 计算分页
  1003. total = len(serialized_results)
  1004. total_pages = (total + page_size - 1) // page_size # 向上取整
  1005. start_idx = (page - 1) * page_size
  1006. end_idx = start_idx + page_size
  1007. page_results = serialized_results[start_idx:end_idx]
  1008. return {
  1009. "results": page_results,
  1010. "pagination": {
  1011. "page": page,
  1012. "page_size": page_size,
  1013. "total": total,
  1014. "total_pages": total_pages
  1015. }
  1016. }
  1017. except Exception as e:
  1018. print(f"[List Knowledge] 错误: {e}")
  1019. raise HTTPException(status_code=500, detail=str(e))
  1020. @app.get("/api/knowledge/meta/tags")
  1021. def get_all_tags():
  1022. """获取所有已有的 tags"""
  1023. try:
  1024. # 查询所有知识
  1025. results = pg_store.query('id != ""', limit=10000)
  1026. all_tags = set()
  1027. for item in results:
  1028. # 转换为标准字典
  1029. serialized_item = serialize_milvus_result(item)
  1030. tags_dict = serialized_item.get("tags", {})
  1031. if isinstance(tags_dict, dict):
  1032. for key in tags_dict.keys():
  1033. all_tags.add(key)
  1034. return {"tags": sorted(list(all_tags))}
  1035. except Exception as e:
  1036. print(f"[Get Tags] 错误: {e}")
  1037. raise HTTPException(status_code=500, detail=str(e))
  1038. @app.get("/api/knowledge/pending")
  1039. def get_pending_knowledge(limit: int = Query(default=50, ge=1, le=200)):
  1040. """查询待处理队列(pending + processing + dedup_passed + analyzing)"""
  1041. try:
  1042. pending = pg_store.query(
  1043. 'status == "pending" or status == "processing" or status == "dedup_passed" or status == "analyzing"',
  1044. limit=limit
  1045. )
  1046. serialized = [serialize_milvus_result(r) for r in pending]
  1047. return {"results": serialized, "count": len(serialized)}
  1048. except Exception as e:
  1049. print(f"[Pending] 错误: {e}")
  1050. raise HTTPException(status_code=500, detail=str(e))
  1051. @app.post("/api/knowledge/process")
  1052. async def trigger_process(force: bool = Query(default=False)):
  1053. """手动触发去重处理。force=true 时先回滚所有 processing → pending,analyzing → dedup_passed"""
  1054. try:
  1055. if force:
  1056. processing = pg_store.query('status == "processing"', limit=200)
  1057. for item in processing:
  1058. pg_store.update(item["id"], {"status": "pending", "updated_at": int(time.time())})
  1059. print(f"[Manual Process] 回滚 {len(processing)} 条 processing → pending")
  1060. analyzing = pg_store.query('status == "analyzing"', limit=200)
  1061. for item in analyzing:
  1062. pg_store.update(item["id"], {"status": "dedup_passed", "updated_at": int(time.time())})
  1063. print(f"[Manual Process] 回滚 {len(analyzing)} 条 analyzing → dedup_passed")
  1064. asyncio.create_task(knowledge_processor.process_pending())
  1065. return {"status": "ok", "message": "处理任务已触发"}
  1066. except Exception as e:
  1067. print(f"[Manual Process] 错误: {e}")
  1068. raise HTTPException(status_code=500, detail=str(e))
  1069. @app.post("/api/knowledge/migrate")
  1070. async def migrate_knowledge_schema():
  1071. """手动触发 schema 迁移(PostgreSQL不需要此功能)"""
  1072. return {"status": "ok", "message": "PostgreSQL不需要schema迁移"}
  1073. @app.get("/api/knowledge/status/{knowledge_id}")
  1074. def get_knowledge_status(knowledge_id: str):
  1075. """查询单条知识的处理状态和关系"""
  1076. try:
  1077. result = pg_store.get_by_id(knowledge_id)
  1078. if not result:
  1079. raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
  1080. serialized = serialize_milvus_result(result)
  1081. return {
  1082. "id": knowledge_id,
  1083. "status": serialized.get("status", "approved"),
  1084. "relationships": serialized.get("relationships", []),
  1085. "created_at": serialized.get("created_at"),
  1086. "updated_at": serialized.get("updated_at"),
  1087. }
  1088. except HTTPException:
  1089. raise
  1090. except Exception as e:
  1091. print(f"[Knowledge Status] 错误: {e}")
  1092. raise HTTPException(status_code=500, detail=str(e))
  1093. @app.get("/api/knowledge/{knowledge_id}")
  1094. def get_knowledge(knowledge_id: str):
  1095. """获取单条知识"""
  1096. try:
  1097. result = pg_store.get_by_id(knowledge_id)
  1098. if not result:
  1099. raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
  1100. return serialize_milvus_result(result)
  1101. except HTTPException:
  1102. raise
  1103. except Exception as e:
  1104. print(f"[Get Knowledge] 错误: {e}")
  1105. raise HTTPException(status_code=500, detail=str(e))
  1106. async def _evolve_knowledge_with_llm(old_content: str, feedback: str) -> str:
  1107. """使用 LLM 进行知识进化重写"""
  1108. prompt = f"""你是一个 AI Agent 知识库管理员。请根据反馈建议,对现有的知识内容进行重写进化。
  1109. 【原知识内容】:
  1110. {old_content}
  1111. 【实战反馈建议】:
  1112. {feedback}
  1113. 【重写要求】:
  1114. 1. 融合知识:将反馈中的避坑指南、新参数或修正后的选择逻辑融入原知识,使其更具通用性和准确性。
  1115. 2. 保持结构:如果原内容有特定格式(如 Markdown、代码示例等),请保持该格式。
  1116. 3. 语言:简洁直接,使用中文。
  1117. 4. 禁止:严禁输出任何开场白、解释语或额外的 Markdown 标题,直接返回重写后的正文。
  1118. """
  1119. try:
  1120. response = await _dedup_llm(
  1121. messages=[{"role": "user", "content": prompt}],
  1122. )
  1123. evolved = response.get("content", "").strip()
  1124. if len(evolved) < 5:
  1125. raise ValueError("LLM output too short")
  1126. return evolved
  1127. except Exception as e:
  1128. print(f"知识进化失败,采用追加模式回退: {e}")
  1129. return f"{old_content}\n\n---\n[Update {datetime.now().strftime('%Y-%m-%d')}]: {feedback}"
  1130. @app.put("/api/knowledge/{knowledge_id}")
  1131. async def update_knowledge(knowledge_id: str, update: KnowledgeUpdateIn):
  1132. """更新知识评估,支持知识进化"""
  1133. try:
  1134. # 获取现有知识
  1135. existing = pg_store.get_by_id(knowledge_id)
  1136. if not existing:
  1137. raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
  1138. eval_data = existing.get("eval", {})
  1139. # 更新评分
  1140. if update.update_score is not None:
  1141. eval_data["score"] = update.update_score
  1142. # 添加有效案例
  1143. if update.add_helpful_case:
  1144. eval_data["helpful"] = eval_data.get("helpful", 0) + 1
  1145. if "helpful_history" not in eval_data:
  1146. eval_data["helpful_history"] = []
  1147. eval_data["helpful_history"].append(update.add_helpful_case)
  1148. # 添加有害案例
  1149. if update.add_harmful_case:
  1150. eval_data["harmful"] = eval_data.get("harmful", 0) + 1
  1151. if "harmful_history" not in eval_data:
  1152. eval_data["harmful_history"] = []
  1153. eval_data["harmful_history"].append(update.add_harmful_case)
  1154. # 知识进化
  1155. content = existing["content"]
  1156. need_reembed = False
  1157. if update.evolve_feedback:
  1158. content = await _evolve_knowledge_with_llm(content, update.evolve_feedback)
  1159. eval_data["helpful"] = eval_data.get("helpful", 0) + 1
  1160. need_reembed = True
  1161. # 准备更新数据
  1162. updates = {
  1163. "content": content,
  1164. "eval": eval_data,
  1165. }
  1166. # 如果内容变化,重新生成向量
  1167. if need_reembed:
  1168. embedding = await get_embedding(existing['task'])
  1169. updates["embedding"] = embedding
  1170. # 更新 Milvus
  1171. pg_store.update(knowledge_id, updates)
  1172. return {"status": "ok", "knowledge_id": knowledge_id}
  1173. except HTTPException:
  1174. raise
  1175. except Exception as e:
  1176. print(f"[Update Knowledge] 错误: {e}")
  1177. raise HTTPException(status_code=500, detail=str(e))
  1178. @app.patch("/api/knowledge/{knowledge_id}")
  1179. async def patch_knowledge(knowledge_id: str, patch: KnowledgePatchIn):
  1180. """直接编辑知识字段"""
  1181. try:
  1182. # 获取现有知识
  1183. existing = pg_store.get_by_id(knowledge_id)
  1184. if not existing:
  1185. raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
  1186. updates = {}
  1187. need_reembed = False
  1188. if patch.task is not None:
  1189. updates["task"] = patch.task
  1190. need_reembed = True
  1191. if patch.content is not None:
  1192. updates["content"] = patch.content
  1193. # content 变化不需要重新生成 embedding(只基于 task)
  1194. if patch.types is not None:
  1195. updates["types"] = patch.types
  1196. if patch.tags is not None:
  1197. updates["tags"] = patch.tags
  1198. # 同时更新 tag_keys
  1199. updates["tag_keys"] = list(patch.tags.keys()) if isinstance(patch.tags, dict) else []
  1200. if patch.scopes is not None:
  1201. updates["scopes"] = patch.scopes
  1202. if patch.owner is not None:
  1203. updates["owner"] = patch.owner
  1204. if not updates:
  1205. return {"status": "ok", "knowledge_id": knowledge_id}
  1206. # 如果 task 变化,重新生成向量
  1207. if need_reembed:
  1208. task = updates.get("task", existing["task"])
  1209. embedding = await get_embedding(task)
  1210. updates["embedding"] = embedding
  1211. # 更新 Milvus
  1212. pg_store.update(knowledge_id, updates)
  1213. return {"status": "ok", "knowledge_id": knowledge_id}
  1214. except HTTPException:
  1215. raise
  1216. except Exception as e:
  1217. print(f"[Patch Knowledge] 错误: {e}")
  1218. raise HTTPException(status_code=500, detail=str(e))
  1219. @app.delete("/api/knowledge/{knowledge_id}")
  1220. def delete_knowledge(knowledge_id: str):
  1221. """删除单条知识"""
  1222. try:
  1223. # 检查知识是否存在
  1224. existing = pg_store.get_by_id(knowledge_id)
  1225. if not existing:
  1226. raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
  1227. # 从 PostgreSQL 删除
  1228. pg_store.delete(knowledge_id)
  1229. print(f"[Delete Knowledge] 已删除知识: {knowledge_id}")
  1230. return {"status": "ok", "knowledge_id": knowledge_id}
  1231. except HTTPException:
  1232. raise
  1233. except Exception as e:
  1234. print(f"[Delete Knowledge] 错误: {e}")
  1235. raise HTTPException(status_code=500, detail=str(e))
  1236. @app.post("/api/knowledge/batch_delete")
  1237. def batch_delete_knowledge(knowledge_ids: List[str] = Body(...)):
  1238. """批量删除知识"""
  1239. try:
  1240. if not knowledge_ids:
  1241. raise HTTPException(status_code=400, detail="knowledge_ids cannot be empty")
  1242. # 批量删除
  1243. cursor = pg_store._get_cursor()
  1244. try:
  1245. cursor.execute(
  1246. "DELETE FROM knowledge WHERE id = ANY(%s)",
  1247. (knowledge_ids,)
  1248. )
  1249. pg_store.conn.commit()
  1250. deleted_count = cursor.rowcount
  1251. print(f"[Batch Delete] 已删除 {deleted_count} 条知识")
  1252. return {"status": "ok", "deleted_count": deleted_count}
  1253. finally:
  1254. cursor.close()
  1255. except HTTPException:
  1256. raise
  1257. except Exception as e:
  1258. print(f"[Batch Delete] 错误: {e}")
  1259. raise HTTPException(status_code=500, detail=str(e))
  1260. @app.post("/api/knowledge/batch_verify")
  1261. async def batch_verify_knowledge(batch: KnowledgeBatchVerifyIn):
  1262. """批量验证通过(approved → checked)"""
  1263. if not batch.knowledge_ids:
  1264. return {"status": "ok", "updated": 0}
  1265. try:
  1266. now_iso = datetime.now(timezone.utc).isoformat()
  1267. updated_count = 0
  1268. for kid in batch.knowledge_ids:
  1269. existing = pg_store.get_by_id(kid)
  1270. if not existing:
  1271. continue
  1272. eval_data = existing.get("eval") or {}
  1273. eval_data["verification"] = {
  1274. "status": "checked",
  1275. "verified_by": batch.verified_by,
  1276. "verified_at": now_iso,
  1277. "note": None,
  1278. "issue_type": None,
  1279. "issue_action": None,
  1280. }
  1281. pg_store.update(kid, {"eval": eval_data, "status": "checked", "updated_at": int(time.time())})
  1282. updated_count += 1
  1283. return {"status": "ok", "updated": updated_count}
  1284. except Exception as e:
  1285. print(f"[Batch Verify] 错误: {e}")
  1286. raise HTTPException(status_code=500, detail=str(e))
  1287. @app.post("/api/knowledge/{knowledge_id}/verify")
  1288. async def verify_knowledge(knowledge_id: str, verify: KnowledgeVerifyIn):
  1289. """知识验证:approve 切换 approved↔checked,reject 设为 rejected"""
  1290. try:
  1291. existing = pg_store.get_by_id(knowledge_id)
  1292. if not existing:
  1293. raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
  1294. current_status = existing.get("status", "approved")
  1295. if verify.action == "approve":
  1296. # checked → approved(取消验证),其他 → checked
  1297. new_status = "approved" if current_status == "checked" else "checked"
  1298. pg_store.update(knowledge_id, {
  1299. "status": new_status,
  1300. "updated_at": int(time.time())
  1301. })
  1302. return {"status": "ok", "new_status": new_status,
  1303. "message": "已取消验证" if new_status == "approved" else "验证通过"}
  1304. elif verify.action == "reject":
  1305. pg_store.update(knowledge_id, {
  1306. "status": "rejected",
  1307. "updated_at": int(time.time())
  1308. })
  1309. return {"status": "ok", "new_status": "rejected", "message": "已拒绝"}
  1310. else:
  1311. raise HTTPException(status_code=400, detail=f"Unknown action: {verify.action}")
  1312. except HTTPException:
  1313. raise
  1314. except Exception as e:
  1315. print(f"[Verify Knowledge] 错误: {e}")
  1316. raise HTTPException(status_code=500, detail=str(e))
  1317. @app.post("/api/knowledge/batch_update")
  1318. async def batch_update_knowledge(batch: KnowledgeBatchUpdateIn):
  1319. """批量反馈知识有效性"""
  1320. if not batch.feedback_list:
  1321. return {"status": "ok", "updated": 0}
  1322. try:
  1323. # 先处理无需进化的,收集需要进化的
  1324. evolution_tasks = [] # [(knowledge_id, old_content, feedback, eval_data)]
  1325. simple_updates = [] # [(knowledge_id, is_effective, eval_data)]
  1326. for item in batch.feedback_list:
  1327. knowledge_id = item.get("knowledge_id")
  1328. is_effective = item.get("is_effective")
  1329. feedback = item.get("feedback", "")
  1330. if not knowledge_id:
  1331. continue
  1332. existing = pg_store.get_by_id(knowledge_id)
  1333. if not existing:
  1334. continue
  1335. eval_data = existing.get("eval", {})
  1336. if is_effective and feedback:
  1337. evolution_tasks.append((knowledge_id, existing["content"], feedback, eval_data, existing["task"]))
  1338. else:
  1339. simple_updates.append((knowledge_id, is_effective, eval_data))
  1340. # 执行简单更新
  1341. for knowledge_id, is_effective, eval_data in simple_updates:
  1342. if is_effective:
  1343. eval_data["helpful"] = eval_data.get("helpful", 0) + 1
  1344. else:
  1345. eval_data["harmful"] = eval_data.get("harmful", 0) + 1
  1346. pg_store.update(knowledge_id, {"eval": eval_data})
  1347. # 并发执行知识进化
  1348. if evolution_tasks:
  1349. print(f"🧬 并发处理 {len(evolution_tasks)} 条知识进化...")
  1350. evolved_results = await asyncio.gather(
  1351. *[_evolve_knowledge_with_llm(old, fb) for _, old, fb, _, _ in evolution_tasks]
  1352. )
  1353. for (knowledge_id, _, _, eval_data, task), evolved_content in zip(evolution_tasks, evolved_results):
  1354. eval_data["helpful"] = eval_data.get("helpful", 0) + 1
  1355. # 重新生成向量(只基于 task)
  1356. embedding = await get_embedding(task)
  1357. pg_store.update(knowledge_id, {
  1358. "content": evolved_content,
  1359. "eval": eval_data,
  1360. "embedding": embedding
  1361. })
  1362. return {"status": "ok", "updated": len(simple_updates) + len(evolution_tasks)}
  1363. except Exception as e:
  1364. print(f"[Batch Update] 错误: {e}")
  1365. raise HTTPException(status_code=500, detail=str(e))
  1366. @app.post("/api/knowledge/slim")
  1367. async def slim_knowledge(model: str = "google/gemini-2.5-flash-lite"):
  1368. """知识库瘦身:合并语义相似知识"""
  1369. try:
  1370. # 获取所有知识
  1371. all_knowledge = pg_store.query('id != ""', limit=10000)
  1372. # 转换为可序列化的格式
  1373. all_knowledge = [serialize_milvus_result(item) for item in all_knowledge]
  1374. if len(all_knowledge) < 2:
  1375. return {"status": "ok", "message": f"知识库仅有 {len(all_knowledge)} 条,无需瘦身"}
  1376. # 构造发给大模型的内容
  1377. entries_text = ""
  1378. for item in all_knowledge:
  1379. eval_data = item.get("eval", {})
  1380. types = item.get("types", [])
  1381. entries_text += f"[ID: {item['id']}] [Types: {','.join(types)}] "
  1382. entries_text += f"[Helpful: {eval_data.get('helpful', 0)}, Harmful: {eval_data.get('harmful', 0)}] [Score: {eval_data.get('score', 3)}]\n"
  1383. entries_text += f"Task: {item['task']}\n"
  1384. entries_text += f"Content: {item['content'][:200]}...\n\n"
  1385. prompt = f"""你是一个 AI Agent 知识库管理员。以下是当前知识库的全部条目,请执行瘦身操作:
  1386. 【任务】:
  1387. 1. 识别语义高度相似或重复的知识,将它们合并为一条更精炼、更通用的知识。
  1388. 2. 合并时保留 helpful 最高的那条的 ID(helpful 取各条之和)。
  1389. 3. 对于独立的、无重复的知识,保持原样不动。
  1390. 【当前知识库】:
  1391. {entries_text}
  1392. 【输出格式要求】:
  1393. 严格按以下格式输出每条知识,条目之间用 === 分隔:
  1394. ID: <保留的id>
  1395. TYPES: <逗号分隔的type列表>
  1396. HELPFUL: <合并后的helpful计数>
  1397. HARMFUL: <合并后的harmful计数>
  1398. SCORE: <评分>
  1399. TASK: <任务描述>
  1400. CONTENT: <合并后的知识内容>
  1401. ===
  1402. 最后输出合并报告:
  1403. REPORT: 原有 X 条,合并后 Y 条,精简了 Z 条。
  1404. 禁止输出任何开场白或解释。"""
  1405. print(f"\n[知识瘦身] 正在调用 {model} 分析 {len(all_knowledge)} 条知识...")
  1406. slim_llm = create_openrouter_llm_call(model=model)
  1407. response = await slim_llm(
  1408. messages=[{"role": "user", "content": prompt}],
  1409. )
  1410. content = response.get("content", "").strip()
  1411. if not content:
  1412. raise HTTPException(status_code=500, detail="LLM 返回为空")
  1413. # 解析大模型输出
  1414. report_line = ""
  1415. new_entries = []
  1416. blocks = [b.strip() for b in content.split("===") if b.strip()]
  1417. for block in blocks:
  1418. if block.startswith("REPORT:"):
  1419. report_line = block
  1420. continue
  1421. lines = block.split("\n")
  1422. kid, types, helpful, harmful, score, task, content_lines = None, [], 0, 0, 3, "", []
  1423. current_field = None
  1424. for line in lines:
  1425. if line.startswith("ID:"):
  1426. kid = line[3:].strip()
  1427. current_field = None
  1428. elif line.startswith("TYPES:"):
  1429. types_str = line[6:].strip()
  1430. types = [t.strip() for t in types_str.split(",") if t.strip()]
  1431. current_field = None
  1432. elif line.startswith("HELPFUL:"):
  1433. try:
  1434. helpful = int(line[8:].strip())
  1435. except Exception:
  1436. helpful = 0
  1437. current_field = None
  1438. elif line.startswith("HARMFUL:"):
  1439. try:
  1440. harmful = int(line[8:].strip())
  1441. except Exception:
  1442. harmful = 0
  1443. current_field = None
  1444. elif line.startswith("SCORE:"):
  1445. try:
  1446. score = int(line[6:].strip())
  1447. except Exception:
  1448. score = 3
  1449. current_field = None
  1450. elif line.startswith("TASK:"):
  1451. task = line[5:].strip()
  1452. current_field = "task"
  1453. elif line.startswith("CONTENT:"):
  1454. content_lines.append(line[8:].strip())
  1455. current_field = "content"
  1456. elif current_field == "task":
  1457. task += "\n" + line
  1458. elif current_field == "content":
  1459. content_lines.append(line)
  1460. if kid and content_lines:
  1461. new_entries.append({
  1462. "id": kid,
  1463. "types": types if types else ["strategy"],
  1464. "helpful": helpful,
  1465. "harmful": harmful,
  1466. "score": score,
  1467. "task": task.strip(),
  1468. "content": "\n".join(content_lines).strip()
  1469. })
  1470. if not new_entries:
  1471. raise HTTPException(status_code=500, detail="解析大模型输出失败")
  1472. # 生成向量并重建知识库
  1473. print(f"[知识瘦身] 正在为 {len(new_entries)} 条知识生成向量...")
  1474. # 批量生成向量(只基于 task)
  1475. texts = [e['task'] for e in new_entries]
  1476. embeddings = await get_embeddings_batch(texts)
  1477. # 清空并重建(PostgreSQL使用TRUNCATE)
  1478. cursor = pg_store._get_cursor()
  1479. try:
  1480. cursor.execute("TRUNCATE TABLE knowledge")
  1481. pg_store.conn.commit()
  1482. finally:
  1483. cursor.close()
  1484. knowledge_list = []
  1485. for e, embedding in zip(new_entries, embeddings):
  1486. eval_data = {
  1487. "score": e["score"],
  1488. "helpful": e["helpful"],
  1489. "harmful": e["harmful"],
  1490. "confidence": 0.9,
  1491. "helpful_history": [],
  1492. "harmful_history": []
  1493. }
  1494. source = {
  1495. "name": "slim",
  1496. "category": "exp",
  1497. "urls": [],
  1498. "agent_id": "slim",
  1499. "submitted_by": "system",
  1500. "timestamp": datetime.now(timezone.utc).isoformat()
  1501. }
  1502. knowledge_list.append({
  1503. "id": e["id"],
  1504. "embedding": embedding,
  1505. "message_id": "",
  1506. "task": e["task"],
  1507. "content": e["content"],
  1508. "types": e["types"],
  1509. "tags": {},
  1510. "tag_keys": [],
  1511. "scopes": ["org:cybertogether"],
  1512. "owner": "agent:slim",
  1513. "resource_ids": [],
  1514. "source": source,
  1515. "eval": eval_data,
  1516. "created_at": now,
  1517. "updated_at": now,
  1518. "status": "approved",
  1519. "relationships": json.dumps([])
  1520. })
  1521. pg_store.insert_batch(knowledge_list)
  1522. result_msg = f"瘦身完成:{len(all_knowledge)} → {len(new_entries)} 条知识"
  1523. if report_line:
  1524. result_msg += f"\n{report_line}"
  1525. print(f"[知识瘦身] {result_msg}")
  1526. return {"status": "ok", "before": len(all_knowledge), "after": len(new_entries), "report": report_line}
  1527. except HTTPException:
  1528. raise
  1529. except Exception as e:
  1530. print(f"[Slim Knowledge] 错误: {e}")
  1531. raise HTTPException(status_code=500, detail=str(e))
  1532. @app.post("/api/extract")
  1533. async def extract_knowledge_from_messages(extract_req: MessageExtractIn, background_tasks: BackgroundTasks):
  1534. """从消息历史中提取知识(LLM 分析)"""
  1535. if not extract_req.submitted_by:
  1536. raise HTTPException(status_code=400, detail="submitted_by is required")
  1537. messages = extract_req.messages
  1538. if not messages or len(messages) == 0:
  1539. return {"status": "ok", "extracted_count": 0, "knowledge_ids": []}
  1540. # 构造消息历史文本
  1541. messages_text = ""
  1542. for msg in messages:
  1543. role = msg.get("role", "unknown")
  1544. content = msg.get("content", "")
  1545. messages_text += f"[{role}]: {content}\n\n"
  1546. # LLM 提取知识
  1547. prompt = f"""你是一个知识提取专家。请从以下 Agent 对话历史中提取有价值的知识。
  1548. 【对话历史】:
  1549. {messages_text}
  1550. 【提取要求】:
  1551. 1. 识别对话中的关键知识点(工具使用经验、问题解决方案、最佳实践、踩坑经验等)
  1552. 2. 每条知识必须包含:
  1553. - task: 任务场景描述(在什么情况下,要完成什么目标)
  1554. - content: 核心知识内容(具体可操作的方法、注意事项)
  1555. - types: 知识类型(从 strategy/tool/user_profile/usecase/definition/plan 中选择)
  1556. - score: 评分 1-5(根据知识的价值和可操作性)
  1557. 3. 只提取有实际价值的知识,不要提取泛泛而谈的内容,一次就成功或比较简单的经验就不要记录了。
  1558. 4. 如果没有值得提取的知识,返回空列表
  1559. 【输出格式】:
  1560. 严格按以下 JSON 格式输出,每条知识之间用逗号分隔:
  1561. [
  1562. {{
  1563. "task": "任务场景描述",
  1564. "content": "核心知识内容",
  1565. "types": ["strategy"],
  1566. "score": 4
  1567. }},
  1568. {{
  1569. "task": "另一个任务场景",
  1570. "content": "另一个知识内容",
  1571. "types": ["tool"],
  1572. "score": 5
  1573. }}
  1574. ]
  1575. 如果没有知识,输出: []
  1576. **注意**:只记录经过多次尝试、或经过用户指导才成功的知识,一次就成功或比较简单的经验就不要记录了。
  1577. 禁止输出任何解释或额外文本,只输出 JSON 数组。"""
  1578. try:
  1579. print(f"\n[Extract] 正在从 {len(messages)} 条消息中提取知识...")
  1580. response = await _dedup_llm(
  1581. messages=[{"role": "user", "content": prompt}],
  1582. )
  1583. content = response.get("content", "").strip()
  1584. # 尝试解析 JSON
  1585. # 移除可能的 markdown 代码块标记
  1586. if content.startswith("```json"):
  1587. content = content[7:]
  1588. if content.startswith("```"):
  1589. content = content[3:]
  1590. if content.endswith("```"):
  1591. content = content[:-3]
  1592. content = content.strip()
  1593. extracted_knowledge = json.loads(content)
  1594. if not isinstance(extracted_knowledge, list):
  1595. raise ValueError("LLM output is not a list")
  1596. if not extracted_knowledge:
  1597. return {"status": "ok", "extracted_count": 0, "knowledge_ids": []}
  1598. # 批量生成向量(只基于 task)
  1599. texts = [item.get('task', '') for item in extracted_knowledge]
  1600. embeddings = await get_embeddings_batch(texts)
  1601. # 保存提取的知识
  1602. knowledge_ids = []
  1603. now = int(time.time())
  1604. knowledge_list = []
  1605. for item, embedding in zip(extracted_knowledge, embeddings):
  1606. task = item.get("task", "")
  1607. knowledge_content = item.get("content", "")
  1608. types = item.get("types", ["strategy"])
  1609. score = item.get("score", 3)
  1610. if not task or not knowledge_content:
  1611. continue
  1612. # 生成 ID
  1613. timestamp = datetime.now().strftime('%Y%m%d-%H%M%S')
  1614. random_suffix = uuid.uuid4().hex[:4]
  1615. knowledge_id = f"knowledge-{timestamp}-{random_suffix}"
  1616. # 准备数据
  1617. source = {
  1618. "name": "message_extraction",
  1619. "category": "exp",
  1620. "urls": [],
  1621. "agent_id": extract_req.agent_id,
  1622. "submitted_by": extract_req.submitted_by,
  1623. "timestamp": datetime.now(timezone.utc).isoformat(),
  1624. "session_key": extract_req.session_key
  1625. }
  1626. eval_data = {
  1627. "score": score,
  1628. "helpful": 1,
  1629. "harmful": 0,
  1630. "confidence": 0.7,
  1631. "helpful_history": [],
  1632. "harmful_history": []
  1633. }
  1634. knowledge_list.append({
  1635. "id": knowledge_id,
  1636. "embedding": embedding,
  1637. "message_id": "",
  1638. "task": task,
  1639. "content": knowledge_content,
  1640. "types": types,
  1641. "tags": {},
  1642. "tag_keys": [],
  1643. "scopes": ["org:cybertogether"],
  1644. "owner": extract_req.submitted_by,
  1645. "resource_ids": [],
  1646. "source": source,
  1647. "eval": eval_data,
  1648. "created_at": now,
  1649. "updated_at": now,
  1650. "status": "pending",
  1651. "relationships": json.dumps([]),
  1652. })
  1653. knowledge_ids.append(knowledge_id)
  1654. # 批量插入
  1655. if knowledge_list:
  1656. pg_store.insert_batch(knowledge_list)
  1657. background_tasks.add_task(knowledge_processor.process_pending)
  1658. print(f"[Extract] 成功提取并保存 {len(knowledge_ids)} 条知识")
  1659. return {
  1660. "status": "ok",
  1661. "extracted_count": len(knowledge_ids),
  1662. "knowledge_ids": knowledge_ids
  1663. }
  1664. except json.JSONDecodeError as e:
  1665. print(f"[Extract] JSON 解析失败: {e}")
  1666. print(f"[Extract] LLM 输出: {content[:500]}")
  1667. return {"status": "error", "error": "Failed to parse LLM output", "extracted_count": 0}
  1668. except Exception as e:
  1669. print(f"[Extract] 提取失败: {e}")
  1670. return {"status": "error", "error": str(e), "extracted_count": 0}
  1671. @app.get("/", response_class=HTMLResponse)
  1672. def frontend():
  1673. """KnowHub 管理前端"""
  1674. return """<!DOCTYPE html>
  1675. <html lang="zh-CN">
  1676. <head>
  1677. <meta charset="UTF-8">
  1678. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  1679. <title>KnowHub 管理</title>
  1680. <script src="https://cdn.tailwindcss.com"></script>
  1681. <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
  1682. </head>
  1683. <body class="bg-gray-50">
  1684. <div class="container mx-auto px-4 py-8 max-w-7xl">
  1685. <div class="flex justify-between items-center mb-8">
  1686. <h1 class="text-3xl font-bold text-gray-800">KnowHub 全局知识库</h1>
  1687. <div class="flex gap-3">
  1688. <button onclick="toggleSelectAll()" id="selectAllBtn" class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg">
  1689. 全选
  1690. </button>
  1691. <button onclick="batchDelete()" id="batchDeleteBtn" class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed" disabled>
  1692. 删除选中 (<span id="selectedCount">0</span>)
  1693. </button>
  1694. <button onclick="batchVerify()" id="batchVerifyBtn" class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed" disabled>
  1695. ✓ 批量验证通过 (<span id="verifyCount">0</span>)
  1696. </button>
  1697. <button onclick="openToolTableModal()" class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg">
  1698. 🔧 工具表
  1699. </button>
  1700. <button onclick="openAddModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg">
  1701. + 新增知识
  1702. </button>
  1703. </div>
  1704. </div>
  1705. <!-- 搜索栏 -->
  1706. <div class="bg-white rounded-lg shadow p-6 mb-6">
  1707. <div class="flex gap-4">
  1708. <input type="text" id="searchInput" placeholder="输入任务描述进行语义搜索..."
  1709. class="flex-1 border rounded px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
  1710. onkeypress="if(event.key==='Enter') performSearch()">
  1711. <button onclick="performSearch()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded">
  1712. 搜索
  1713. </button>
  1714. <button onclick="clearSearch()" class="bg-gray-500 hover:bg-gray-600 text-white px-6 py-2 rounded">
  1715. 清除
  1716. </button>
  1717. </div>
  1718. <div id="searchStatus" class="mt-2 text-sm text-gray-600 hidden"></div>
  1719. </div>
  1720. <!-- 筛选栏 -->
  1721. <div class="bg-white rounded-lg shadow p-6 mb-6">
  1722. <div class="grid grid-cols-1 md:grid-cols-5 gap-4">
  1723. <div>
  1724. <label class="block text-sm font-medium text-gray-700 mb-2">类型 (Types)</label>
  1725. <div class="space-y-2">
  1726. <label class="flex items-center"><input type="checkbox" value="strategy" class="mr-2 type-filter"> Strategy</label>
  1727. <label class="flex items-center"><input type="checkbox" value="tool" class="mr-2 type-filter"> Tool</label>
  1728. <label class="flex items-center"><input type="checkbox" value="user_profile" class="mr-2 type-filter"> User Profile</label>
  1729. <label class="flex items-center"><input type="checkbox" value="usecase" class="mr-2 type-filter"> Usecase</label>
  1730. <label class="flex items-center"><input type="checkbox" value="definition" class="mr-2 type-filter"> Definition</label>
  1731. <label class="flex items-center"><input type="checkbox" value="plan" class="mr-2 type-filter"> Plan</label>
  1732. </div>
  1733. </div>
  1734. <div>
  1735. <label class="block text-sm font-medium text-gray-700 mb-2">Tags</label>
  1736. <div id="tagsFilterContainer" class="space-y-2 max-h-40 overflow-y-auto">
  1737. <p class="text-sm text-gray-500">加载中...</p>
  1738. </div>
  1739. </div>
  1740. <div>
  1741. <label class="block text-sm font-medium text-gray-700 mb-2">Owner</label>
  1742. <input type="text" id="ownerFilter" placeholder="输入 owner" class="w-full border rounded px-3 py-2">
  1743. </div>
  1744. <div>
  1745. <label class="block text-sm font-medium text-gray-700 mb-2">Scopes</label>
  1746. <input type="text" id="scopesFilter" placeholder="输入 scope" class="w-full border rounded px-3 py-2">
  1747. </div>
  1748. <div>
  1749. <label class="block text-sm font-medium text-gray-700 mb-2">Status</label>
  1750. <div class="space-y-2">
  1751. <label class="flex items-center"><input type="checkbox" value="approved" class="mr-2 status-filter" checked> Approved</label>
  1752. <label class="flex items-center"><input type="checkbox" value="checked" class="mr-2 status-filter" checked> Checked</label>
  1753. <label class="flex items-center"><input type="checkbox" value="rejected" class="mr-2 status-filter"> Rejected</label>
  1754. <label class="flex items-center"><input type="checkbox" value="pending" class="mr-2 status-filter"> Pending</label>
  1755. </div>
  1756. </div>
  1757. </div>
  1758. <button onclick="applyFilters()" class="mt-4 bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded">
  1759. 应用筛选
  1760. </button>
  1761. </div>
  1762. <!-- 知识列表 -->
  1763. <div id="knowledgeList" class="space-y-4"></div>
  1764. <!-- 分页控件 -->
  1765. <div id="pagination" class="flex justify-center items-center gap-4 mt-6 hidden">
  1766. <button onclick="goToPage(currentPage - 1)" id="prevBtn" class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded disabled:opacity-50 disabled:cursor-not-allowed">
  1767. 上一页
  1768. </button>
  1769. <span id="pageInfo" class="text-gray-700"></span>
  1770. <button onclick="goToPage(currentPage + 1)" id="nextBtn" class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded disabled:opacity-50 disabled:cursor-not-allowed">
  1771. 下一页
  1772. </button>
  1773. </div>
  1774. </div>
  1775. <!-- 新增/编辑 Modal -->
  1776. <div id="modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4">
  1777. <div class="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto p-6">
  1778. <h2 id="modalTitle" class="text-2xl font-bold mb-4">新增知识</h2>
  1779. <form id="knowledgeForm" class="space-y-4">
  1780. <input type="hidden" id="editId">
  1781. <div>
  1782. <label class="block text-sm font-medium mb-1">Task *</label>
  1783. <input type="text" id="taskInput" required class="w-full border rounded px-3 py-2">
  1784. </div>
  1785. <div>
  1786. <label class="block text-sm font-medium mb-1">Content *</label>
  1787. <textarea id="contentInput" required rows="6" class="w-full border rounded px-3 py-2"></textarea>
  1788. </div>
  1789. <div>
  1790. <label class="block text-sm font-medium mb-1">Types (多选)</label>
  1791. <div class="space-y-1">
  1792. <label class="flex items-center"><input type="checkbox" value="strategy" class="mr-2 type-checkbox"> Strategy</label>
  1793. <label class="flex items-center"><input type="checkbox" value="tool" class="mr-2 type-checkbox"> Tool</label>
  1794. <label class="flex items-center"><input type="checkbox" value="user_profile" class="mr-2 type-checkbox"> User Profile</label>
  1795. <label class="flex items-center"><input type="checkbox" value="usecase" class="mr-2 type-checkbox"> Usecase</label>
  1796. <label class="flex items-center"><input type="checkbox" value="definition" class="mr-2 type-checkbox"> Definition</label>
  1797. <label class="flex items-center"><input type="checkbox" value="plan" class="mr-2 type-checkbox"> Plan</label>
  1798. </div>
  1799. </div>
  1800. <div>
  1801. <label class="block text-sm font-medium mb-1">Tags (JSON)</label>
  1802. <textarea id="tagsInput" rows="2" placeholder='{"key": "value"}' class="w-full border rounded px-3 py-2"></textarea>
  1803. </div>
  1804. <div>
  1805. <label class="block text-sm font-medium mb-1">Scopes (逗号分隔)</label>
  1806. <input type="text" id="scopesInput" placeholder="org:cybertogether" class="w-full border rounded px-3 py-2">
  1807. </div>
  1808. <div>
  1809. <label class="block text-sm font-medium mb-1">Owner</label>
  1810. <input type="text" id="ownerInput" class="w-full border rounded px-3 py-2">
  1811. </div>
  1812. <div id="relationshipsSection" class="hidden">
  1813. <label class="block text-sm font-medium text-gray-700 mb-2">关联知识</label>
  1814. <div id="relationshipsList" class="space-y-1 text-sm bg-gray-50 rounded p-3"></div>
  1815. </div>
  1816. <div class="flex gap-2 pt-4">
  1817. <button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded">保存</button>
  1818. <button type="button" onclick="closeModal()" class="bg-gray-300 hover:bg-gray-400 px-6 py-2 rounded">取消</button>
  1819. </div>
  1820. </form>
  1821. </div>
  1822. </div>
  1823. <!-- 工具表 Modal -->
  1824. <div id="toolTableModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
  1825. <div class="bg-white rounded-xl shadow-xl w-full max-w-5xl max-h-[90vh] flex flex-col">
  1826. <div class="flex justify-between items-center p-6 border-b">
  1827. <h2 class="text-2xl font-bold">🔧 工具表</h2>
  1828. <button onclick="closeToolTableModal()" class="text-gray-400 hover:text-gray-600 text-2xl">✕</button>
  1829. </div>
  1830. <div id="toolCategoryTabs" class="flex gap-2 px-6 pt-4 flex-wrap border-b pb-4"></div>
  1831. <div class="flex flex-1 overflow-hidden">
  1832. <div id="toolList" class="w-[250px] border-r overflow-y-auto p-4 space-y-2 bg-gray-50 flex-shrink-0">
  1833. <p class="text-sm text-gray-500 text-center mt-4">加载中...</p>
  1834. </div>
  1835. <div id="toolDetail" class="flex-1 overflow-y-auto p-6 bg-white">
  1836. <div class="flex h-full items-center justify-center text-gray-400">
  1837. ← 请在左侧选择要查看的工具
  1838. </div>
  1839. </div>
  1840. </div>
  1841. </div>
  1842. </div>
  1843. <!-- 知识详情弹窗(只读)-->
  1844. <div id="knowledgeDetailModal" class="hidden fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center p-4 z-[60]">
  1845. <div class="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[85vh] flex flex-col">
  1846. <div class="flex justify-between items-center p-6 border-b">
  1847. <h2 class="text-xl font-bold text-gray-800">知识详情</h2>
  1848. <button onclick="closeKnowledgeDetailModal()" class="text-gray-400 hover:text-gray-600 text-2xl">✕</button>
  1849. </div>
  1850. <div id="knowledgeDetailContent" class="flex-1 overflow-y-auto p-6">
  1851. <p class="text-gray-400 text-center animate-pulse">加载中...</p>
  1852. </div>
  1853. </div>
  1854. </div>
  1855. <script>
  1856. let allKnowledge = [];
  1857. let availableTags = [];
  1858. let currentPage = 1;
  1859. let pageSize = 200; // 每页显示200条
  1860. let totalPages = 1;
  1861. let totalCount = 0;
  1862. let isSearchMode = false; // 标记是否在搜索模式
  1863. let selectedIds = new Set(); // 选中的知识ID集合
  1864. async function loadTags() {
  1865. const res = await fetch('/api/knowledge/meta/tags');
  1866. const data = await res.json();
  1867. availableTags = data.tags;
  1868. renderTagsFilter();
  1869. }
  1870. function renderTagsFilter() {
  1871. const container = document.getElementById('tagsFilterContainer');
  1872. if (availableTags.length === 0) {
  1873. container.innerHTML = '<p class="text-sm text-gray-500">暂无 tags</p>';
  1874. return;
  1875. }
  1876. container.innerHTML = availableTags.map(tag =>
  1877. `<label class="flex items-center"><input type="checkbox" value="${escapeHtml(tag)}" class="mr-2 tag-filter"> ${escapeHtml(tag)}</label>`
  1878. ).join('');
  1879. }
  1880. async function loadKnowledge(page = 1) {
  1881. const params = new URLSearchParams();
  1882. params.append('page', page);
  1883. params.append('page_size', pageSize);
  1884. const selectedTypes = Array.from(document.querySelectorAll('.type-filter:checked')).map(el => el.value);
  1885. if (selectedTypes.length > 0) {
  1886. params.append('types', selectedTypes.join(','));
  1887. }
  1888. const selectedTags = Array.from(document.querySelectorAll('.tag-filter:checked')).map(el => el.value);
  1889. if (selectedTags.length > 0) {
  1890. params.append('tags', selectedTags.join(','));
  1891. }
  1892. const ownerFilter = document.getElementById('ownerFilter').value.trim();
  1893. if (ownerFilter) {
  1894. params.append('owner', ownerFilter);
  1895. }
  1896. const scopesFilter = document.getElementById('scopesFilter').value.trim();
  1897. if (scopesFilter) {
  1898. params.append('scopes', scopesFilter);
  1899. }
  1900. const selectedStatus = Array.from(document.querySelectorAll('.status-filter:checked')).map(el => el.value);
  1901. if (selectedStatus.length > 0) {
  1902. params.append('status', selectedStatus.join(','));
  1903. }
  1904. try {
  1905. const res = await fetch(`/api/knowledge?${params.toString()}`);
  1906. if (!res.ok) {
  1907. console.error('加载失败:', res.status, res.statusText);
  1908. document.getElementById('knowledgeList').innerHTML = '<p class="text-red-500 text-center py-8">加载失败,请刷新页面重试</p>';
  1909. return;
  1910. }
  1911. const data = await res.json();
  1912. allKnowledge = data.results || [];
  1913. currentPage = data.pagination.page;
  1914. totalPages = data.pagination.total_pages;
  1915. totalCount = data.pagination.total;
  1916. renderKnowledge(allKnowledge);
  1917. updatePagination();
  1918. } catch (error) {
  1919. console.error('加载错误:', error);
  1920. document.getElementById('knowledgeList').innerHTML = '<p class="text-red-500 text-center py-8">加载错误: ' + error.message + '</p>';
  1921. }
  1922. }
  1923. function applyFilters() {
  1924. currentPage = 1; // 重置到第一页
  1925. loadKnowledge(currentPage);
  1926. }
  1927. function goToPage(page) {
  1928. if (page < 1 || page > totalPages) return;
  1929. loadKnowledge(page);
  1930. }
  1931. function updatePagination() {
  1932. const paginationDiv = document.getElementById('pagination');
  1933. const pageInfo = document.getElementById('pageInfo');
  1934. const prevBtn = document.getElementById('prevBtn');
  1935. const nextBtn = document.getElementById('nextBtn');
  1936. if (totalPages <= 1) {
  1937. paginationDiv.classList.add('hidden');
  1938. } else {
  1939. paginationDiv.classList.remove('hidden');
  1940. pageInfo.textContent = `第 ${currentPage} / ${totalPages} 页 (共 ${totalCount} 条)`;
  1941. prevBtn.disabled = currentPage === 1;
  1942. nextBtn.disabled = currentPage === totalPages;
  1943. }
  1944. }
  1945. async function performSearch() {
  1946. const query = document.getElementById('searchInput').value.trim();
  1947. if (!query) {
  1948. alert('请输入搜索内容');
  1949. return;
  1950. }
  1951. isSearchMode = true;
  1952. const statusDiv = document.getElementById('searchStatus');
  1953. statusDiv.textContent = '搜索中...';
  1954. statusDiv.classList.remove('hidden');
  1955. try {
  1956. const params = new URLSearchParams();
  1957. params.append('q', query);
  1958. params.append('top_k', '20');
  1959. params.append('min_score', '1'); // 搜索时降低最低分数要求
  1960. // 应用筛选条件
  1961. const selectedTypes = Array.from(document.querySelectorAll('.type-filter:checked')).map(el => el.value);
  1962. if (selectedTypes.length > 0) {
  1963. params.append('types', selectedTypes.join(','));
  1964. }
  1965. const ownerFilter = document.getElementById('ownerFilter').value.trim();
  1966. if (ownerFilter) {
  1967. params.append('owner', ownerFilter);
  1968. }
  1969. const res = await fetch(`/api/knowledge/search?${params.toString()}`);
  1970. if (!res.ok) {
  1971. throw new Error(`搜索失败: ${res.status}`);
  1972. }
  1973. const data = await res.json();
  1974. allKnowledge = data.results || [];
  1975. statusDiv.textContent = `找到 ${allKnowledge.length} 条相关知识${data.reranked ? ' (已智能排序)' : ''}`;
  1976. renderKnowledge(allKnowledge);
  1977. // 搜索模式下隐藏分页
  1978. document.getElementById('pagination').classList.add('hidden');
  1979. } catch (error) {
  1980. console.error('搜索错误:', error);
  1981. statusDiv.textContent = '搜索失败: ' + error.message;
  1982. statusDiv.classList.add('text-red-500');
  1983. }
  1984. }
  1985. function clearSearch() {
  1986. document.getElementById('searchInput').value = '';
  1987. document.getElementById('searchStatus').classList.add('hidden');
  1988. document.getElementById('searchStatus').classList.remove('text-red-500');
  1989. isSearchMode = false;
  1990. currentPage = 1;
  1991. loadKnowledge(currentPage);
  1992. }
  1993. function renderKnowledge(list) {
  1994. const container = document.getElementById('knowledgeList');
  1995. if (list.length === 0) {
  1996. container.innerHTML = '<p class="text-gray-500 text-center py-8">暂无知识</p>';
  1997. return;
  1998. }
  1999. container.innerHTML = list.map(k => {
  2000. // 确保types是数组
  2001. let types = [];
  2002. if (Array.isArray(k.types)) {
  2003. types = k.types;
  2004. } else if (typeof k.types === 'string') {
  2005. // 如果是JSON字符串(以[开头),尝试解析
  2006. if (k.types.startsWith('[')) {
  2007. try {
  2008. types = JSON.parse(k.types);
  2009. } catch (e) {
  2010. console.error('解析types失败:', k.types, e);
  2011. types = [k.types];
  2012. }
  2013. } else {
  2014. // 如果是普通字符串,包装成数组
  2015. types = [k.types];
  2016. }
  2017. }
  2018. const eval_data = k.eval || {};
  2019. const isChecked = selectedIds.has(k.id);
  2020. const statusColor = {
  2021. 'approved': 'bg-green-100 text-green-800',
  2022. 'checked': 'bg-blue-100 text-blue-800',
  2023. 'rejected': 'bg-red-100 text-red-800',
  2024. 'pending': 'bg-yellow-100 text-yellow-800',
  2025. 'processing': 'bg-orange-100 text-orange-800',
  2026. };
  2027. const statusClass = statusColor[k.status] || 'bg-gray-100 text-gray-800';
  2028. const statusLabel = k.status || 'approved';
  2029. // 工具 tag(来自 resource_ids 中 tools/ 前缀的条目)
  2030. const toolIds = (k.resource_ids || []).filter(id => id.startsWith('tools/'));
  2031. const toolTagsHtml = toolIds.length > 0
  2032. ? `<div class="flex gap-1 flex-wrap mt-2">
  2033. ${toolIds.map(tid => {
  2034. const name = tid.split('/').pop();
  2035. return `<span onclick="event.stopPropagation(); openToolTableModal('${tid}')"
  2036. class="text-[11px] px-2 py-0.5 bg-indigo-50 text-indigo-700 border border-indigo-200 rounded-full cursor-pointer hover:bg-indigo-100 transition">
  2037. 🔧 ${name}
  2038. </span>`;
  2039. }).join('')}
  2040. </div>`
  2041. : '';
  2042. return `
  2043. <div class="bg-white rounded-lg shadow p-6 hover:shadow-lg transition relative">
  2044. <div class="absolute top-4 left-4">
  2045. <input type="checkbox" class="knowledge-checkbox w-5 h-5 cursor-pointer"
  2046. data-id="${k.id}" ${isChecked ? 'checked' : ''}
  2047. onclick="event.stopPropagation(); toggleSelect('${k.id}')">
  2048. </div>
  2049. <div class="ml-10 cursor-pointer" onclick="openEditModal('${k.id}')">
  2050. <div class="flex justify-between items-start mb-2">
  2051. <div class="flex gap-2 flex-wrap">
  2052. ${types.map(t => `<span class="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">${t}</span>`).join('')}
  2053. </div>
  2054. <div class="flex items-center gap-2">
  2055. <span class="text-xs px-2 py-1 rounded ${statusClass}">${statusLabel}</span>
  2056. <span class="text-sm text-gray-500">${eval_data.score || 3}/5</span>
  2057. </div>
  2058. </div>
  2059. <h3 class="text-lg font-semibold text-gray-800 mb-2">${escapeHtml(k.task)}</h3>
  2060. <p class="text-sm text-gray-600 mb-2">${escapeHtml(k.content.substring(0, 150))}${k.content.length > 150 ? '...' : ''}</p>
  2061. <div class="flex justify-between text-xs text-gray-500">
  2062. <span>Owner: ${k.owner || 'N/A'}</span>
  2063. <span>${new Date(k.created_at).toLocaleDateString()}</span>
  2064. </div>
  2065. ${toolTagsHtml}
  2066. </div>
  2067. <div class="flex gap-2 mt-3 pt-3 border-t border-gray-100">
  2068. <button onclick="event.stopPropagation(); verifyKnowledge('${k.id}', 'approve', this)"
  2069. class="${k.status === 'checked' ? 'bg-gray-300 hover:bg-gray-400 text-gray-700' : 'bg-green-400 hover:bg-green-500 text-white'} text-xs px-3 py-1 rounded transition-colors">
  2070. ${k.status === 'checked' ? '取消验证' : '✓ 验证通过'}
  2071. </button>
  2072. <button onclick="event.stopPropagation(); verifyKnowledge('${k.id}', 'reject', this)"
  2073. class="bg-red-400 hover:bg-red-500 text-white text-xs px-3 py-1 rounded transition-colors">
  2074. ✗ 拒绝
  2075. </button>
  2076. </div>
  2077. </div>
  2078. `;
  2079. }).join('');
  2080. }
  2081. function toggleSelect(id) {
  2082. if (selectedIds.has(id)) {
  2083. selectedIds.delete(id);
  2084. } else {
  2085. selectedIds.add(id);
  2086. }
  2087. updateBatchDeleteButton();
  2088. }
  2089. function toggleSelectAll() {
  2090. if (selectedIds.size === allKnowledge.length) {
  2091. // 全部取消选中
  2092. selectedIds.clear();
  2093. } else {
  2094. // 全部选中
  2095. selectedIds.clear();
  2096. allKnowledge.forEach(k => selectedIds.add(k.id));
  2097. }
  2098. renderKnowledge(allKnowledge);
  2099. updateBatchDeleteButton();
  2100. }
  2101. function updateBatchDeleteButton() {
  2102. const count = selectedIds.size;
  2103. document.getElementById('selectedCount').textContent = count;
  2104. document.getElementById('verifyCount').textContent = count;
  2105. document.getElementById('batchDeleteBtn').disabled = count === 0;
  2106. document.getElementById('batchVerifyBtn').disabled = count === 0;
  2107. document.getElementById('selectAllBtn').textContent =
  2108. selectedIds.size === allKnowledge.length ? '取消全选' : '全选';
  2109. }
  2110. async function batchDelete() {
  2111. if (selectedIds.size === 0) return;
  2112. if (!confirm(`确定要删除选中的 ${selectedIds.size} 条知识吗?此操作不可恢复!`)) {
  2113. return;
  2114. }
  2115. try {
  2116. const ids = Array.from(selectedIds);
  2117. const res = await fetch('/api/knowledge/batch_delete', {
  2118. method: 'POST',
  2119. headers: {'Content-Type': 'application/json'},
  2120. body: JSON.stringify(ids)
  2121. });
  2122. if (!res.ok) {
  2123. throw new Error(`删除失败: ${res.status}`);
  2124. }
  2125. const data = await res.json();
  2126. alert(`成功删除 ${data.deleted_count} 条知识`);
  2127. // 清空选择并重新加载
  2128. selectedIds.clear();
  2129. updateBatchDeleteButton();
  2130. if (isSearchMode) {
  2131. clearSearch();
  2132. } else {
  2133. loadKnowledge(currentPage);
  2134. }
  2135. } catch (error) {
  2136. console.error('批量删除错误:', error);
  2137. alert('删除失败: ' + error.message);
  2138. }
  2139. }
  2140. function openAddModal() {
  2141. document.getElementById('modalTitle').textContent = '新增知识';
  2142. document.getElementById('knowledgeForm').reset();
  2143. document.getElementById('editId').value = '';
  2144. document.querySelectorAll('.type-checkbox').forEach(el => el.checked = false);
  2145. document.getElementById('modal').classList.remove('hidden');
  2146. }
  2147. async function openEditModal(id) {
  2148. let k = allKnowledge.find(item => item.id === id);
  2149. if (!k) {
  2150. // 当前列表中找不到(可能是 rejected/其他状态),通过 API 单独获取
  2151. try {
  2152. const res = await fetch('/api/knowledge/' + encodeURIComponent(id));
  2153. if (!res.ok) { alert('知识未找到: ' + id); return; }
  2154. k = await res.json();
  2155. } catch (e) { alert('获取知识失败: ' + e.message); return; }
  2156. }
  2157. document.getElementById('modalTitle').textContent = '编辑知识';
  2158. document.getElementById('editId').value = k.id;
  2159. document.getElementById('taskInput').value = k.task || '';
  2160. document.getElementById('contentInput').value = k.content || '';
  2161. document.getElementById('tagsInput').value = JSON.stringify(k.tags || {});
  2162. // 防御性检查:确保 scopes 是数组
  2163. const scopes = Array.isArray(k.scopes) ? k.scopes : [];
  2164. document.getElementById('scopesInput').value = scopes.join(', ');
  2165. document.getElementById('ownerInput').value = k.owner || '';
  2166. // 防御性检查:确保 types 是数组
  2167. const types = Array.isArray(k.types) ? k.types : [];
  2168. document.querySelectorAll('.type-checkbox').forEach(el => {
  2169. el.checked = types.includes(el.value);
  2170. });
  2171. // 填充 relationships(可能是 JSON 字符串或数组)
  2172. let rels = [];
  2173. if (Array.isArray(k.relationships)) {
  2174. rels = k.relationships;
  2175. } else if (typeof k.relationships === 'string' && k.relationships.startsWith('[')) {
  2176. try { rels = JSON.parse(k.relationships); } catch(e) {}
  2177. }
  2178. const section = document.getElementById('relationshipsSection');
  2179. if (rels.length > 0) {
  2180. const typeColor = {
  2181. superset: 'text-green-700', subset: 'text-orange-600',
  2182. conflict: 'text-red-600', complement: 'text-blue-600',
  2183. duplicate: 'text-gray-500'
  2184. };
  2185. document.getElementById('relationshipsList').innerHTML = rels.map(r =>
  2186. `<div class="flex gap-2 items-center">
  2187. <span class="font-medium ${typeColor[r.type] || 'text-gray-700'}">[${r.type}]</span>
  2188. <span class="font-mono text-xs text-gray-500 cursor-pointer hover:underline"
  2189. onclick="openEditModal('${r.target}')">${r.target}</span>
  2190. </div>`
  2191. ).join('');
  2192. section.classList.remove('hidden');
  2193. } else {
  2194. section.classList.add('hidden');
  2195. }
  2196. document.getElementById('modal').classList.remove('hidden');
  2197. }
  2198. function closeModal() {
  2199. document.getElementById('modal').classList.add('hidden');
  2200. }
  2201. document.getElementById('knowledgeForm').addEventListener('submit', async (e) => {
  2202. e.preventDefault();
  2203. const editId = document.getElementById('editId').value;
  2204. const task = document.getElementById('taskInput').value;
  2205. const content = document.getElementById('contentInput').value;
  2206. const types = Array.from(document.querySelectorAll('.type-checkbox:checked')).map(el => el.value);
  2207. const tagsText = document.getElementById('tagsInput').value.trim();
  2208. const scopesText = document.getElementById('scopesInput').value.trim();
  2209. const owner = document.getElementById('ownerInput').value.trim();
  2210. let tags = {};
  2211. if (tagsText) {
  2212. try {
  2213. tags = JSON.parse(tagsText);
  2214. } catch (e) {
  2215. alert('Tags JSON 格式错误');
  2216. return;
  2217. }
  2218. }
  2219. const scopes = scopesText ? scopesText.split(',').map(s => s.trim()).filter(s => s) : ['org:cybertogether'];
  2220. if (editId) {
  2221. // 编辑
  2222. const res = await fetch(`/api/knowledge/${editId}`, {
  2223. method: 'PATCH',
  2224. headers: {'Content-Type': 'application/json'},
  2225. body: JSON.stringify({task, content, types, tags, scopes, owner})
  2226. });
  2227. if (!res.ok) {
  2228. alert('更新失败');
  2229. return;
  2230. }
  2231. } else {
  2232. // 新增
  2233. const res = await fetch('/api/knowledge', {
  2234. method: 'POST',
  2235. headers: {'Content-Type': 'application/json'},
  2236. body: JSON.stringify({task, content, types, tags, scopes, owner})
  2237. });
  2238. if (!res.ok) {
  2239. alert('新增失败');
  2240. return;
  2241. }
  2242. }
  2243. closeModal();
  2244. await loadKnowledge();
  2245. });
  2246. async function verifyKnowledge(id, action, btn) {
  2247. if (btn) {
  2248. btn.disabled = true;
  2249. btn._origText = btn.textContent;
  2250. btn.textContent = '处理中...';
  2251. }
  2252. try {
  2253. const res = await fetch('/api/knowledge/' + id + '/verify', {
  2254. method: 'POST',
  2255. headers: {'Content-Type': 'application/json'},
  2256. body: JSON.stringify({ action })
  2257. });
  2258. if (!res.ok) throw new Error('请求失败: ' + res.status);
  2259. if (isSearchMode) {
  2260. clearSearch();
  2261. } else {
  2262. loadKnowledge(currentPage);
  2263. }
  2264. } catch (error) {
  2265. console.error('验证错误:', error);
  2266. alert('操作失败: ' + error.message);
  2267. if (btn) {
  2268. btn.disabled = false;
  2269. btn.textContent = btn._origText;
  2270. }
  2271. }
  2272. }
  2273. async function batchVerify() {
  2274. if (selectedIds.size === 0) return;
  2275. if (!confirm(`确定要批量验证通过选中的 ${selectedIds.size} 条知识吗?`)) return;
  2276. const btn = document.getElementById('batchVerifyBtn');
  2277. if (btn) { btn.disabled = true; btn.textContent = `处理中...`; }
  2278. try {
  2279. const ids = Array.from(selectedIds);
  2280. const res = await fetch('/api/knowledge/batch_verify', {
  2281. method: 'POST',
  2282. headers: {'Content-Type': 'application/json'},
  2283. body: JSON.stringify({ knowledge_ids: ids, action: 'approve', verified_by: 'user' })
  2284. });
  2285. if (!res.ok) throw new Error('请求失败: ' + res.status);
  2286. selectedIds.clear();
  2287. updateBatchDeleteButton();
  2288. if (isSearchMode) {
  2289. clearSearch();
  2290. } else {
  2291. loadKnowledge(currentPage);
  2292. }
  2293. } catch (error) {
  2294. console.error('批量验证错误:', error);
  2295. alert('验证失败: ' + error.message);
  2296. if (btn) { btn.disabled = false; updateBatchDeleteButton(); }
  2297. }
  2298. }
  2299. function escapeHtml(text) {
  2300. const div = document.createElement('div');
  2301. div.textContent = text;
  2302. return div.innerHTML;
  2303. }
  2304. loadTags();
  2305. loadKnowledge();
  2306. // --- 工具表逻辑 ---
  2307. let _allTools = [];
  2308. let _activeCategory = 'all';
  2309. async function openToolTableModal(targetToolId = null) {
  2310. document.getElementById('toolTableModal').classList.remove('hidden');
  2311. if (_allTools.length === 0) {
  2312. await loadToolList();
  2313. } else {
  2314. renderCategoryTabs();
  2315. renderToolList('all');
  2316. }
  2317. if (targetToolId) {
  2318. const targetTool = _allTools.find(t => t.id === targetToolId);
  2319. if (targetTool) {
  2320. const cat = targetTool.metadata && targetTool.metadata.category ? targetTool.metadata.category : 'other';
  2321. renderToolList(cat);
  2322. }
  2323. loadToolDetail(targetToolId);
  2324. setTimeout(() => {
  2325. const el = document.querySelector(`.tool-item[data-id="${targetToolId}"]`);
  2326. if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
  2327. }, 100);
  2328. }
  2329. }
  2330. function closeToolTableModal() {
  2331. document.getElementById('toolTableModal').classList.add('hidden');
  2332. }
  2333. async function loadToolList() {
  2334. try {
  2335. const res = await fetch('/api/resource?limit=1000');
  2336. const data = await res.json();
  2337. _allTools = (data.results || []).filter(r => r.id.startsWith('tools/'));
  2338. renderCategoryTabs();
  2339. renderToolList('all');
  2340. } catch (err) {
  2341. console.error('加载工具列表失败', err);
  2342. document.getElementById('toolList').innerHTML = '<p class="text-red-500 text-sm text-center">加载失败</p>';
  2343. }
  2344. }
  2345. function renderCategoryTabs() {
  2346. const cats = ['all', ...new Set(_allTools.map(t => t.metadata && t.metadata.category ? t.metadata.category : 'other'))];
  2347. document.getElementById('toolCategoryTabs').innerHTML = cats.map(cat => {
  2348. const isActive = cat === _activeCategory;
  2349. const activeClass = 'bg-indigo-600 text-white border-indigo-600';
  2350. const inactiveClass = 'bg-white text-gray-600 border-gray-300 hover:border-indigo-400';
  2351. return `<button onclick="renderToolList('${cat}')"
  2352. id="tab_${cat}"
  2353. class="tool-cat-tab px-4 py-1.5 rounded-full text-sm font-medium border transition ${isActive ? activeClass : inactiveClass}">
  2354. ${cat === 'all' ? '全部' : cat}
  2355. </button>`;
  2356. }).join('');
  2357. }
  2358. function renderToolList(category) {
  2359. _activeCategory = category;
  2360. document.querySelectorAll('.tool-cat-tab').forEach(btn => {
  2361. const isCurrent = btn.id === `tab_${category}`;
  2362. btn.className = `tool-cat-tab px-4 py-1.5 rounded-full text-sm font-medium border transition ${
  2363. isCurrent ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-600 border-gray-300 hover:border-indigo-400'
  2364. }`;
  2365. });
  2366. const filtered = category === 'all'
  2367. ? _allTools
  2368. : _allTools.filter(t => (t.metadata && t.metadata.category ? t.metadata.category : 'other') === category);
  2369. const listHtml = filtered.length === 0
  2370. ? '<p class="text-sm text-gray-400 text-center mt-4">该分类下暂无工具</p>'
  2371. : filtered.map(t => `
  2372. <div onclick="loadToolDetail('${t.id}')"
  2373. class="tool-item p-3 rounded-lg border border-gray-200 cursor-pointer hover:border-indigo-400 hover:shadow-sm bg-white transition"
  2374. data-id="${t.id}">
  2375. <div class="font-bold text-gray-800 text-sm truncate" title="${escapeHtml(t.title || t.id)}">${escapeHtml(t.title || t.id.split('/').pop())}</div>
  2376. <div class="mt-1 flex items-center justify-between">
  2377. <span class="text-xs px-2 py-0.5 rounded truncate max-w-[100px] ${(t.metadata && t.metadata.status === '已接入') ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}">${escapeHtml(t.metadata && t.metadata.status ? t.metadata.status : '未设置')}</span>
  2378. <span class="text-[10px] text-gray-400">${escapeHtml(t.content_type || '')}</span>
  2379. </div>
  2380. </div>`).join('');
  2381. document.getElementById('toolList').innerHTML = listHtml;
  2382. }
  2383. async function loadToolDetail(id) {
  2384. document.querySelectorAll('.tool-item').forEach(el => {
  2385. if (el.dataset.id === id) {
  2386. el.classList.add('border-indigo-500', 'ring-1', 'ring-indigo-500');
  2387. el.classList.remove('border-gray-200');
  2388. } else {
  2389. el.classList.remove('border-indigo-500', 'ring-1', 'ring-indigo-500');
  2390. el.classList.add('border-gray-200');
  2391. }
  2392. });
  2393. const detailEl = document.getElementById('toolDetail');
  2394. detailEl.innerHTML = '<div class="flex h-full items-center justify-center"><p class="text-gray-400 animate-pulse">加载详情中...</p></div>';
  2395. try {
  2396. const res = await fetch('/api/resource/' + id);
  2397. const tool = await res.json();
  2398. const knowledgeIds = (tool.metadata && tool.metadata.knowledge_ids) ? tool.metadata.knowledge_ids : [];
  2399. const knowledgeHtml = knowledgeIds.length === 0
  2400. ? '<span class="text-gray-400 text-xs">暂无</span>'
  2401. : knowledgeIds.map(kid => `
  2402. <span onclick="openKnowledgeDetailModal('${kid}')"
  2403. class="text-xs px-2 py-1 bg-gray-100 hover:bg-gray-200 border rounded cursor-pointer text-gray-700 font-mono transition">
  2404. ${kid.length > 24 ? kid.slice(0, 24) + '...' : kid}
  2405. </span>`).join('');
  2406. const toolhubItems = (tool.metadata && tool.metadata.toolhub_items) ? tool.metadata.toolhub_items : [];
  2407. const toolhubHtml = toolhubItems.length === 0
  2408. ? '<span class="text-gray-400 text-xs">暂无</span>'
  2409. : toolhubItems.map(item => {
  2410. const [id, desc] = Object.entries(item)[0];
  2411. return `<span class="text-xs px-2 py-1 bg-blue-50 hover:bg-blue-100 border border-blue-200 rounded text-blue-700 font-mono transition">
  2412. ${escapeHtml(id)}: ${escapeHtml(desc)}
  2413. </span>`;
  2414. }).join('');
  2415. const meta = tool.metadata || {};
  2416. const scenariosMd = Array.isArray(meta.scenarios) && meta.scenarios.length > 0
  2417. ? meta.scenarios.map(s => `<li>${escapeHtml(s)}</li>`).join('')
  2418. : '<li class="text-gray-400">暂无</li>';
  2419. detailEl.innerHTML = `
  2420. <div class="mb-6 border-b pb-4">
  2421. <h2 class="text-3xl font-black text-gray-900 mb-3">${escapeHtml(tool.title || id)}</h2>
  2422. <div class="flex gap-2 flex-wrap text-sm mb-3">
  2423. <span class="px-2.5 py-1 bg-indigo-50 text-indigo-700 rounded-md border border-indigo-100">
  2424. 📁 分类: ${escapeHtml(meta.category || '–')}
  2425. </span>
  2426. <span class="px-2.5 py-1 rounded-md border ${(meta.status === '已接入') ? 'bg-green-50 text-green-700 border-green-200' : 'bg-gray-50 text-gray-700 border-gray-200'}">
  2427. 🏷️ 状态: ${escapeHtml(meta.status || '–')}
  2428. </span>
  2429. <span class="px-2.5 py-1 bg-blue-50 text-blue-700 rounded-md border border-blue-100">
  2430. 📌 Slug: ${escapeHtml(meta.tool_slug || '–')}
  2431. </span>
  2432. </div>
  2433. <div class="flex gap-1 flex-wrap items-center">
  2434. <span class="text-xs text-gray-500 mr-1">🔗 关联知识:</span>
  2435. ${knowledgeHtml}
  2436. </div>
  2437. <div class="flex gap-1 flex-wrap items-center mt-2">
  2438. <span class="text-xs text-gray-500 mr-1">🔧 工具项:</span>
  2439. ${toolhubHtml}
  2440. </div>
  2441. </div>
  2442. <div class="text-gray-800 leading-relaxed max-w-none space-y-6">
  2443. <div>
  2444. <h3 class="text-lg font-bold border-b pb-2 mb-3">基础概览</h3>
  2445. <div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
  2446. <div><span class="text-gray-500 font-semibold">工具版本:</span> ${escapeHtml(meta.version || '–')}</div>
  2447. </div>
  2448. <div class="mt-3">
  2449. <span class="text-gray-500 font-semibold text-sm block mb-1">功能介绍:</span>
  2450. <div class="bg-gray-50 p-3 rounded-md text-sm border">${escapeHtml(meta.description || '暂无')}</div>
  2451. </div>
  2452. </div>
  2453. <div>
  2454. <h3 class="text-lg font-bold border-b pb-2 mb-3">使用指南</h3>
  2455. <div class="mb-4">
  2456. <span class="text-gray-500 font-semibold text-sm block mb-1">用法:</span>
  2457. <div class="bg-gray-50 p-3 rounded-md text-sm border whitespace-pre-wrap">${escapeHtml(meta.usage || '暂无')}</div>
  2458. </div>
  2459. <div>
  2460. <span class="text-gray-500 font-semibold text-sm block mb-1">应用场景:</span>
  2461. <ul class="list-disc pl-5 space-y-1 text-sm">${scenariosMd}</ul>
  2462. </div>
  2463. </div>
  2464. <div>
  2465. <h3 class="text-lg font-bold border-b pb-2 mb-3">技术规格</h3>
  2466. <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
  2467. <div>
  2468. <span class="text-gray-500 font-semibold text-sm block mb-1">输入:</span>
  2469. <div class="bg-gray-50 p-3 rounded-md text-sm border h-full">${escapeHtml(meta.input || '暂无')}</div>
  2470. </div>
  2471. <div>
  2472. <span class="text-gray-500 font-semibold text-sm block mb-1">输出:</span>
  2473. <div class="bg-gray-50 p-3 rounded-md text-sm border h-full">${escapeHtml(meta.output || '暂无')}</div>
  2474. </div>
  2475. </div>
  2476. </div>
  2477. ${meta.source ? `
  2478. <div>
  2479. <h3 class="text-lg font-bold border-b pb-2 mb-3">消息信源</h3>
  2480. <div class="text-sm overflow-hidden break-words text-blue-600 hover:underline">
  2481. ${escapeHtml(meta.source)}
  2482. </div>
  2483. </div>` : ''}
  2484. ${tool.body ? `
  2485. <div class="pt-4 mt-6 border-t border-dashed">
  2486. <h3 class="text-lg font-bold mb-3 text-gray-500">补充说明 (文档内容)</h3>
  2487. <div class="markdown-body bg-gray-50 p-4 rounded-lg border text-sm">
  2488. ${typeof marked !== 'undefined' ? marked.parse(tool.body) : escapeHtml(tool.body)}
  2489. </div>
  2490. </div>` : ''}
  2491. </div>`;
  2492. } catch (err) {
  2493. detailEl.innerHTML = '<div class="text-red-500 flex h-full items-center justify-center">加载详情失败,请检查网络或日志</div>';
  2494. console.error(err);
  2495. }
  2496. }
  2497. async function openKnowledgeDetailModal(id) {
  2498. document.getElementById('knowledgeDetailModal').classList.remove('hidden');
  2499. const contentEl = document.getElementById('knowledgeDetailContent');
  2500. contentEl.innerHTML = '<p class="text-gray-400 text-center animate-pulse">加载中...</p>';
  2501. try {
  2502. const res = await fetch(`/api/knowledge/${encodeURIComponent(id)}`);
  2503. if (!res.ok) { contentEl.innerHTML = '<p class="text-red-500 text-center">知识未找到</p>'; return; }
  2504. const k = await res.json();
  2505. const statusColor = {
  2506. 'approved': 'bg-green-100 text-green-800',
  2507. 'checked': 'bg-blue-100 text-blue-800',
  2508. 'rejected': 'bg-red-100 text-red-800',
  2509. 'pending': 'bg-yellow-100 text-yellow-800',
  2510. };
  2511. const types = Array.isArray(k.types) ? k.types : [];
  2512. const tags = k.tags || {};
  2513. const tagKeys = Object.keys(tags);
  2514. contentEl.innerHTML = `
  2515. <div class="flex gap-2 flex-wrap mb-4">
  2516. ${types.map(t => `<span class="px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs">${escapeHtml(t)}</span>`).join('')}
  2517. <span class="px-2 py-0.5 rounded text-xs ${statusColor[k.status] || 'bg-gray-100 text-gray-700'}">${escapeHtml(k.status || '–')}</span>
  2518. </div>
  2519. <h3 class="text-lg font-bold text-gray-900 mb-3">${escapeHtml(k.task || '')}</h3>
  2520. <div class="text-sm text-gray-700 bg-gray-50 rounded-lg p-4 mb-4 whitespace-pre-wrap leading-relaxed">
  2521. ${escapeHtml(k.content || '')}
  2522. </div>
  2523. <div class="text-xs text-gray-500 space-y-1 border-t pt-3">
  2524. <div>📌 ID:<span class="font-mono">${escapeHtml(k.id || '')}</span></div>
  2525. <div>👤 Owner:${escapeHtml(k.owner || '–')}</div>
  2526. <div>🕐 创建:${k.created_at ? new Date(k.created_at * 1000).toLocaleString() : '–'}</div>
  2527. ${tagKeys.length > 0 ? `<div>🏷️ Tags:${tagKeys.map(t => `<span class="bg-gray-100 px-1 rounded">${escapeHtml(t)}</span>`).join(' ')}</div>` : ''}
  2528. </div>`;
  2529. } catch (err) {
  2530. contentEl.innerHTML = '<p class="text-red-500 text-center">加载失败</p>';
  2531. console.error(err);
  2532. }
  2533. }
  2534. function closeKnowledgeDetailModal() {
  2535. document.getElementById('knowledgeDetailModal').classList.add('hidden');
  2536. }
  2537. </script>
  2538. </body>
  2539. </html>"""
  2540. if __name__ == "__main__":
  2541. import uvicorn
  2542. uvicorn.run(app, host="0.0.0.0", port=9999)