routes.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. from __future__ import annotations
  2. import uuid
  3. from flask import current_app, g, jsonify, request
  4. from sqlalchemy import text
  5. from app import db
  6. from app.api.knowledge_base import bp
  7. from app.core.knowledge.access import build_access_context
  8. from app.core.knowledge.admin import (
  9. change_set_detail,
  10. knowledge_status,
  11. list_change_sets,
  12. retry_change_set,
  13. rollback_change_set,
  14. )
  15. from app.core.knowledge.audit import run_canonical_audit
  16. from app.core.knowledge.qa import AnswerSynthesizer, DeepSeekAnswerModel
  17. from app.core.knowledge.retrieval.pipeline import KnowledgeRetrievalPipeline
  18. from app.core.knowledge.retrieval.sql import (
  19. SqlLexicalRetriever,
  20. SqlVectorRetriever,
  21. UnavailableVectorRetriever,
  22. )
  23. from app.models.result import failed, success
  24. def _bounded_int(value, *, field: str, default: int, lower: int, upper: int) -> int:
  25. try:
  26. parsed = int(default if value is None else value)
  27. except (TypeError, ValueError) as exc:
  28. raise ValueError(f"{field} 必须为整数") from exc
  29. return min(max(parsed, lower), upper)
  30. def _pipeline() -> KnowledgeRetrievalPipeline:
  31. configured = current_app.extensions.get("knowledge_retrieval_pipeline")
  32. if configured is not None:
  33. return configured
  34. api_key = current_app.config.get("QWEN_EMBEDDING_API_KEY", "")
  35. base_url = current_app.config.get("QWEN_EMBEDDING_BASE_URL", "")
  36. if api_key and base_url:
  37. from app.services.embedding.qwen import QwenEmbeddingClient
  38. embedder = QwenEmbeddingClient(
  39. api_key=api_key,
  40. base_url=base_url,
  41. model=current_app.config["QWEN_EMBEDDING_MODEL"],
  42. dimension=current_app.config["QWEN_EMBEDDING_DIMENSION"],
  43. )
  44. vector = SqlVectorRetriever(db.session, embedder)
  45. else:
  46. vector = UnavailableVectorRetriever()
  47. return KnowledgeRetrievalPipeline(
  48. lexical=SqlLexicalRetriever(db.session),
  49. vector=vector,
  50. )
  51. def _serialize_evidence(item) -> dict:
  52. return {
  53. "chunk_id": item.chunk_id,
  54. "content": item.content,
  55. "score": item.score,
  56. "retriever": item.retriever,
  57. "object_uid": item.object_uid,
  58. "object_type": item.object_type,
  59. "object_version": item.object_version,
  60. "business_domain_uid": item.business_domain_uid,
  61. "point_keys": list(item.point_keys),
  62. "point_revisions": list(item.point_revisions),
  63. "index_generation": item.generation,
  64. "source_updated_at": item.source_updated_at,
  65. "freshness_status": item.freshness_status,
  66. "section_path": item.section_path,
  67. }
  68. def _answer_synthesizer() -> AnswerSynthesizer:
  69. configured = current_app.extensions.get("knowledge_answer_synthesizer")
  70. return configured or AnswerSynthesizer(DeepSeekAnswerModel())
  71. def _request_context(payload):
  72. requested_domains = payload.get("business_domain_uids")
  73. if requested_domains is not None and not isinstance(requested_domains, list):
  74. raise ValueError("business_domain_uids 必须为数组")
  75. correlation_id = request.headers.get("X-Correlation-ID") or str(uuid.uuid4())
  76. context = build_access_context(
  77. db.session,
  78. identity=g.current_user,
  79. requested_business_domains=requested_domains,
  80. correlation_id=correlation_id,
  81. )
  82. return context, correlation_id
  83. @bp.post("/search")
  84. def search():
  85. payload = request.get_json(silent=True) or {}
  86. query = str(payload.get("query") or "").strip()
  87. if not query:
  88. return jsonify(failed("query 不能为空", code=400)), 400
  89. mode = str(payload.get("mode") or "auto")
  90. if mode not in {"auto", "exact", "semantic", "relationship", "global"}:
  91. return jsonify(failed("不支持的检索模式", code=400)), 400
  92. try:
  93. limit = _bounded_int(
  94. payload.get("limit"), field="limit", default=20, lower=1, upper=100
  95. )
  96. context, correlation_id = _request_context(payload)
  97. except ValueError as exc:
  98. return jsonify(failed(str(exc), code=400)), 400
  99. result = _pipeline().search(
  100. query,
  101. context=context,
  102. mode=mode,
  103. limit=limit,
  104. )
  105. return jsonify(
  106. success(
  107. {
  108. "query": query,
  109. "mode": result.mode,
  110. "evidence": [_serialize_evidence(item) for item in result.evidence],
  111. "degraded_components": list(result.degraded_components),
  112. "correlation_id": correlation_id,
  113. }
  114. )
  115. )
  116. @bp.post("/ask")
  117. def ask():
  118. payload = request.get_json(silent=True) or {}
  119. query = str(payload.get("query") or "").strip()
  120. if not query:
  121. return jsonify(failed("query 不能为空", code=400)), 400
  122. mode = str(payload.get("mode") or "auto")
  123. if mode not in {"auto", "exact", "semantic", "relationship", "global"}:
  124. return jsonify(failed("不支持的检索模式", code=400)), 400
  125. try:
  126. context, correlation_id = _request_context(payload)
  127. except ValueError as exc:
  128. return jsonify(failed(str(exc), code=400)), 400
  129. retrieval = _pipeline().search(query, context=context, mode=mode, limit=30)
  130. answer = _answer_synthesizer().answer(query, retrieval.evidence)
  131. return jsonify(
  132. success(
  133. {
  134. "query_id": correlation_id,
  135. "mode": retrieval.mode,
  136. "answer": answer.answer,
  137. "answer_status": answer.status,
  138. "degraded_components": list(retrieval.degraded_components),
  139. "citations": [citation.__dict__ for citation in answer.citations],
  140. "freshness_status": answer.freshness_status,
  141. }
  142. )
  143. )
  144. def _source_document(source_uid: str, version: int | None = None):
  145. context, _correlation_id = _request_context({})
  146. version_clause = (
  147. "AND d.object_version = :version"
  148. if version is not None
  149. else "AND d.status = 'active'"
  150. )
  151. row = (
  152. db.session.execute(
  153. text(
  154. f"""
  155. SELECT d.object_uid::text, d.object_type, d.object_version, d.object_name,
  156. d.business_domain_uid::text, d.content, d.source_updated_at,
  157. d.active_generation, d.status
  158. FROM public.governance_documents d
  159. WHERE d.object_uid = CAST(:uid AS uuid) {version_clause}
  160. """
  161. ),
  162. {"uid": source_uid, "version": version},
  163. )
  164. .mappings()
  165. .one_or_none()
  166. )
  167. if row is None or not context.permits_domain(row["business_domain_uid"]):
  168. return None
  169. return dict(row)
  170. @bp.get("/sources/<source_uid>")
  171. def source(source_uid: str):
  172. row = _source_document(source_uid)
  173. if row is None:
  174. return jsonify(failed("来源不存在或无权访问", code=404)), 404
  175. return jsonify(success(row))
  176. @bp.get("/sources/<source_uid>/versions/<int:version>")
  177. def source_version(source_uid: str, version: int):
  178. row = _source_document(source_uid, version)
  179. if row is None:
  180. return jsonify(failed("来源版本不存在或无权访问", code=404)), 404
  181. return jsonify(success(row))
  182. @bp.get("/capabilities")
  183. def capabilities():
  184. return jsonify(
  185. success(
  186. {
  187. "standard_retrieval": True,
  188. "lightrag_enabled": bool(
  189. current_app.config.get("KNOWLEDGE_LIGHTRAG_ENABLED", False)
  190. ),
  191. "lightrag_shadow_only": bool(
  192. current_app.config.get("KNOWLEDGE_LIGHTRAG_SHADOW_ONLY", True)
  193. ),
  194. "answer_generation": bool(current_app.config.get("DEEPSEEK_API_KEY")),
  195. }
  196. )
  197. )
  198. @bp.get("/admin/sync")
  199. def admin_sync():
  200. return jsonify(success(knowledge_status(db.session)))
  201. @bp.get("/admin/change-sets")
  202. def admin_change_sets():
  203. try:
  204. limit = _bounded_int(
  205. request.args.get("limit"), field="limit", default=50, lower=1, upper=200
  206. )
  207. except ValueError as exc:
  208. return jsonify(failed(str(exc), code=400)), 400
  209. return jsonify(success(list_change_sets(db.session, limit=limit)))
  210. @bp.get("/admin/change-sets/<change_set_id>")
  211. def admin_change_set(change_set_id: str):
  212. detail = change_set_detail(db.session, change_set_id)
  213. if detail is None:
  214. return jsonify(failed("change set 不存在", code=404)), 404
  215. return jsonify(success(detail))
  216. @bp.post("/admin/change-sets/<change_set_id>/retry")
  217. def admin_retry_change_set(change_set_id: str):
  218. if not retry_change_set(db.session, change_set_id):
  219. db.session.rollback()
  220. return jsonify(failed("当前状态不允许重试", code=409)), 409
  221. db.session.commit()
  222. return jsonify(success({"change_set_id": change_set_id, "status": "pending"}))
  223. @bp.post("/admin/change-sets/<change_set_id>/rollback")
  224. def admin_rollback_change_set(change_set_id: str):
  225. if not rollback_change_set(db.session, change_set_id):
  226. db.session.rollback()
  227. return jsonify(failed("没有可安全回退的上一版本", code=409)), 409
  228. db.session.commit()
  229. return jsonify(success({"change_set_id": change_set_id, "status": "rolled_back"}))
  230. @bp.post("/admin/audit")
  231. def admin_audit():
  232. payload = request.get_json(silent=True) or {}
  233. repair = payload.get("repair") is True
  234. findings = run_canonical_audit(db.session)
  235. repaired = 0
  236. if repair:
  237. repaired = db.session.execute(
  238. text(
  239. "DELETE FROM public.knowledge_cache_dependencies "
  240. "WHERE expires_at <= CURRENT_TIMESTAMP"
  241. )
  242. ).rowcount
  243. db.session.commit()
  244. return jsonify(
  245. success(
  246. {
  247. "mode": "repair" if repair else "report",
  248. "findings": [finding.__dict__ for finding in findings],
  249. "repaired_cache_dependencies": repaired,
  250. }
  251. )
  252. )
  253. @bp.post("/admin/retry-projection")
  254. def admin_retry_projection():
  255. projection_id = str(
  256. (request.get_json(silent=True) or {}).get("projection_id") or ""
  257. )
  258. if not projection_id:
  259. return jsonify(failed("projection_id 不能为空", code=400)), 400
  260. updated = db.session.execute(
  261. text(
  262. """
  263. UPDATE public.knowledge_index_projections
  264. SET status = 'pending', last_error = NULL, external_track_id = NULL,
  265. available_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
  266. WHERE id = CAST(:id AS uuid) AND status IN ('failed','unverified')
  267. """
  268. ),
  269. {"id": projection_id},
  270. ).rowcount
  271. if updated != 1:
  272. db.session.rollback()
  273. return jsonify(failed("当前投影状态不允许重试", code=409)), 409
  274. db.session.commit()
  275. return jsonify(success({"projection_id": projection_id, "status": "pending"}))
  276. @bp.get("/admin/evaluations")
  277. def admin_evaluations():
  278. rows = db.session.execute(
  279. text(
  280. """
  281. SELECT run.id::text, evaluation_set.name, run.status, run.configuration,
  282. run.started_at, run.finished_at,
  283. COUNT(result.case_id) AS case_count,
  284. COUNT(result.case_id) FILTER (WHERE result.passed) AS passed_count
  285. FROM public.knowledge_evaluation_runs run
  286. JOIN public.knowledge_evaluation_sets evaluation_set
  287. ON evaluation_set.id = run.evaluation_set_id
  288. LEFT JOIN public.knowledge_evaluation_results result ON result.run_id = run.id
  289. GROUP BY run.id, evaluation_set.name
  290. ORDER BY run.started_at DESC
  291. LIMIT 50
  292. """
  293. )
  294. ).mappings()
  295. return jsonify(success([dict(row) for row in rows]))