server.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  1. """
  2. KnowHub Server
  3. Agent 工具使用经验的共享平台。
  4. FastAPI + SQLite,单文件部署。
  5. """
  6. import os
  7. import sqlite3
  8. from contextlib import asynccontextmanager
  9. from datetime import datetime, timezone
  10. from typing import Optional
  11. from pathlib import Path
  12. from fastapi import FastAPI, HTTPException, Query
  13. from pydantic import BaseModel, Field
  14. BRAND_NAME = os.getenv("BRAND_NAME", "KnowHub")
  15. BRAND_API_ENV = os.getenv("BRAND_API_ENV", "KNOWHUB_API")
  16. BRAND_DB = os.getenv("BRAND_DB", "knowhub.db")
  17. DB_PATH = Path(__file__).parent / BRAND_DB
  18. # --- 数据库 ---
  19. def get_db() -> sqlite3.Connection:
  20. conn = sqlite3.connect(str(DB_PATH))
  21. conn.row_factory = sqlite3.Row
  22. conn.execute("PRAGMA journal_mode=WAL")
  23. return conn
  24. def init_db():
  25. conn = get_db()
  26. conn.execute("""
  27. CREATE TABLE IF NOT EXISTS experiences (
  28. id INTEGER PRIMARY KEY AUTOINCREMENT,
  29. name TEXT NOT NULL,
  30. url TEXT DEFAULT '',
  31. category TEXT DEFAULT '',
  32. task TEXT NOT NULL,
  33. score INTEGER CHECK(score BETWEEN 1 AND 5),
  34. outcome TEXT DEFAULT '',
  35. tips TEXT DEFAULT '',
  36. content_id TEXT DEFAULT '',
  37. submitted_by TEXT DEFAULT '',
  38. created_at TEXT NOT NULL
  39. )
  40. """)
  41. conn.execute("CREATE INDEX IF NOT EXISTS idx_name ON experiences(name)")
  42. conn.execute("""
  43. CREATE TABLE IF NOT EXISTS contents (
  44. id TEXT PRIMARY KEY,
  45. title TEXT DEFAULT '',
  46. body TEXT NOT NULL,
  47. sort_order INTEGER DEFAULT 0,
  48. submitted_by TEXT DEFAULT '',
  49. created_at TEXT NOT NULL
  50. )
  51. """)
  52. conn.commit()
  53. conn.close()
  54. # --- Models ---
  55. class ExperienceIn(BaseModel):
  56. name: str
  57. url: str = ""
  58. category: str = ""
  59. task: str
  60. score: int = Field(ge=1, le=5)
  61. outcome: str = ""
  62. tips: str = ""
  63. content_id: str = ""
  64. submitted_by: str = ""
  65. class ExperienceOut(BaseModel):
  66. task: str
  67. score: int
  68. outcome: str
  69. tips: str
  70. content_id: str
  71. submitted_by: str
  72. created_at: str
  73. class ResourceResult(BaseModel):
  74. name: str
  75. url: str
  76. relevant_experiences: list[ExperienceOut]
  77. avg_score: float
  78. experience_count: int
  79. class SearchResponse(BaseModel):
  80. results: list[ResourceResult]
  81. class ResourceDetailResponse(BaseModel):
  82. name: str
  83. url: str
  84. category: str
  85. avg_score: float
  86. experience_count: int
  87. experiences: list[ExperienceOut]
  88. class ContentIn(BaseModel):
  89. id: str
  90. title: str = ""
  91. body: str
  92. sort_order: int = 0
  93. submitted_by: str = ""
  94. class ContentNode(BaseModel):
  95. id: str
  96. title: str
  97. class ContentOut(BaseModel):
  98. id: str
  99. title: str
  100. body: str
  101. toc: Optional[ContentNode] = None
  102. children: list[ContentNode]
  103. prev: Optional[ContentNode] = None
  104. next: Optional[ContentNode] = None
  105. # --- App ---
  106. @asynccontextmanager
  107. async def lifespan(app: FastAPI):
  108. init_db()
  109. yield
  110. app = FastAPI(title=BRAND_NAME, lifespan=lifespan)
  111. def _search_rows(conn: sqlite3.Connection, q: str, category: Optional[str]) -> list[sqlite3.Row]:
  112. """LIKE 搜索,拆词后 AND 连接,匹配 task + tips + outcome + name"""
  113. terms = q.split()
  114. if not terms:
  115. return []
  116. conditions = []
  117. params: list[str] = []
  118. for term in terms:
  119. like = f"%{term}%"
  120. conditions.append(
  121. "(task LIKE ? OR tips LIKE ? OR outcome LIKE ? OR name LIKE ?)"
  122. )
  123. params.extend([like, like, like, like])
  124. if category:
  125. conditions.append("category = ?")
  126. params.append(category)
  127. sql = (
  128. "SELECT name, url, category, task, score, outcome, tips, content_id, "
  129. "submitted_by, created_at FROM experiences WHERE "
  130. + " AND ".join(conditions)
  131. + " ORDER BY created_at DESC"
  132. )
  133. return conn.execute(sql, params).fetchall()
  134. def _group_by_resource(rows: list[sqlite3.Row], limit: int) -> list[ResourceResult]:
  135. """按 name 分组并聚合"""
  136. groups: dict[str, list[sqlite3.Row]] = {}
  137. for row in rows:
  138. name = row["name"]
  139. if name not in groups:
  140. groups[name] = []
  141. groups[name].append(row)
  142. results = []
  143. for resource_name, resource_rows in groups.items():
  144. scores = [r["score"] for r in resource_rows]
  145. avg = sum(scores) / len(scores)
  146. results.append(ResourceResult(
  147. name=resource_name,
  148. url=resource_rows[0]["url"],
  149. relevant_experiences=[
  150. ExperienceOut(
  151. task=r["task"],
  152. score=r["score"],
  153. outcome=r["outcome"],
  154. tips=r["tips"],
  155. content_id=r["content_id"],
  156. submitted_by=r["submitted_by"],
  157. created_at=r["created_at"],
  158. )
  159. for r in resource_rows
  160. ],
  161. avg_score=round(avg, 1),
  162. experience_count=len(resource_rows),
  163. ))
  164. results.sort(key=lambda r: r.avg_score * r.experience_count, reverse=True)
  165. return results[:limit]
  166. @app.get("/api/search", response_model=SearchResponse)
  167. def search_experiences(
  168. q: str = Query(..., min_length=1),
  169. category: Optional[str] = None,
  170. limit: int = Query(default=10, ge=1, le=50),
  171. ):
  172. conn = get_db()
  173. try:
  174. rows = _search_rows(conn, q, category)
  175. return SearchResponse(results=_group_by_resource(rows, limit))
  176. finally:
  177. conn.close()
  178. @app.post("/api/experience", status_code=201)
  179. def submit_experience(exp: ExperienceIn):
  180. conn = get_db()
  181. try:
  182. now = datetime.now(timezone.utc).isoformat()
  183. conn.execute(
  184. "INSERT INTO experiences"
  185. "(name, url, category, task, score, outcome, tips, content_id, submitted_by, created_at)"
  186. " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
  187. (exp.name, exp.url, exp.category, exp.task,
  188. exp.score, exp.outcome, exp.tips, exp.content_id, exp.submitted_by, now),
  189. )
  190. conn.commit()
  191. return {"status": "ok"}
  192. finally:
  193. conn.close()
  194. @app.get("/api/resource/{name}", response_model=ResourceDetailResponse)
  195. def get_resource_experiences(name: str):
  196. conn = get_db()
  197. try:
  198. rows = conn.execute(
  199. "SELECT name, url, category, task, score, outcome, tips, content_id, "
  200. "submitted_by, created_at FROM experiences "
  201. "WHERE name = ? ORDER BY created_at DESC",
  202. (name,),
  203. ).fetchall()
  204. if not rows:
  205. raise HTTPException(status_code=404, detail=f"No experiences found for resource: {name}")
  206. scores = [r["score"] for r in rows]
  207. avg = sum(scores) / len(scores)
  208. return ResourceDetailResponse(
  209. name=name,
  210. url=rows[0]["url"],
  211. category=rows[0]["category"],
  212. avg_score=round(avg, 1),
  213. experience_count=len(rows),
  214. experiences=[
  215. ExperienceOut(
  216. task=r["task"],
  217. score=r["score"],
  218. outcome=r["outcome"],
  219. tips=r["tips"],
  220. content_id=r["content_id"],
  221. submitted_by=r["submitted_by"],
  222. created_at=r["created_at"],
  223. )
  224. for r in rows
  225. ],
  226. )
  227. finally:
  228. conn.close()
  229. @app.post("/api/content", status_code=201)
  230. def submit_content(content: ContentIn):
  231. conn = get_db()
  232. try:
  233. now = datetime.now(timezone.utc).isoformat()
  234. conn.execute(
  235. "INSERT OR REPLACE INTO contents"
  236. "(id, title, body, sort_order, submitted_by, created_at)"
  237. " VALUES (?, ?, ?, ?, ?, ?)",
  238. (content.id, content.title, content.body, content.sort_order, content.submitted_by, now),
  239. )
  240. conn.commit()
  241. return {"status": "ok"}
  242. finally:
  243. conn.close()
  244. @app.get("/api/content/{content_id:path}", response_model=ContentOut)
  245. def get_content(content_id: str):
  246. conn = get_db()
  247. try:
  248. row = conn.execute(
  249. "SELECT id, title, body, sort_order FROM contents WHERE id = ?",
  250. (content_id,),
  251. ).fetchone()
  252. if not row:
  253. raise HTTPException(status_code=404, detail=f"Content not found: {content_id}")
  254. # 计算导航上下文
  255. root_id = content_id.split("/")[0] if "/" in content_id else content_id
  256. # TOC (根节点)
  257. toc = None
  258. if "/" in content_id:
  259. toc_row = conn.execute(
  260. "SELECT id, title FROM contents WHERE id = ?",
  261. (root_id,),
  262. ).fetchone()
  263. if toc_row:
  264. toc = ContentNode(id=toc_row["id"], title=toc_row["title"])
  265. # Children (子节点)
  266. children = []
  267. children_rows = conn.execute(
  268. "SELECT id, title FROM contents WHERE id LIKE ? AND id != ? ORDER BY sort_order",
  269. (f"{content_id}/%", content_id),
  270. ).fetchall()
  271. children = [ContentNode(id=r["id"], title=r["title"]) for r in children_rows]
  272. # Prev/Next (同级节点)
  273. prev_node = None
  274. next_node = None
  275. if "/" in content_id:
  276. siblings = conn.execute(
  277. "SELECT id, title, sort_order FROM contents WHERE id LIKE ? AND id NOT LIKE ? ORDER BY sort_order",
  278. (f"{root_id}/%", f"{root_id}/%/%"),
  279. ).fetchall()
  280. for i, sib in enumerate(siblings):
  281. if sib["id"] == content_id:
  282. if i > 0:
  283. prev_node = ContentNode(id=siblings[i-1]["id"], title=siblings[i-1]["title"])
  284. if i < len(siblings) - 1:
  285. next_node = ContentNode(id=siblings[i+1]["id"], title=siblings[i+1]["title"])
  286. break
  287. return ContentOut(
  288. id=row["id"],
  289. title=row["title"],
  290. body=row["body"],
  291. toc=toc,
  292. children=children,
  293. prev=prev_node,
  294. next=next_node,
  295. )
  296. finally:
  297. conn.close()
  298. if __name__ == "__main__":
  299. import uvicorn
  300. uvicorn.run(app, host="0.0.0.0", port=8000)