| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337 |
- from __future__ import annotations
- import uuid
- from flask import current_app, g, jsonify, request
- from sqlalchemy import text
- from app import db
- from app.api.knowledge_base import bp
- from app.core.knowledge.access import build_access_context
- from app.core.knowledge.admin import (
- change_set_detail,
- knowledge_status,
- list_change_sets,
- retry_change_set,
- rollback_change_set,
- )
- from app.core.knowledge.audit import run_canonical_audit
- from app.core.knowledge.qa import AnswerSynthesizer, DeepSeekAnswerModel
- from app.core.knowledge.retrieval.pipeline import KnowledgeRetrievalPipeline
- from app.core.knowledge.retrieval.sql import (
- SqlLexicalRetriever,
- SqlVectorRetriever,
- UnavailableVectorRetriever,
- )
- from app.models.result import failed, success
- def _bounded_int(value, *, field: str, default: int, lower: int, upper: int) -> int:
- try:
- parsed = int(default if value is None else value)
- except (TypeError, ValueError) as exc:
- raise ValueError(f"{field} 必须为整数") from exc
- return min(max(parsed, lower), upper)
- def _pipeline() -> KnowledgeRetrievalPipeline:
- configured = current_app.extensions.get("knowledge_retrieval_pipeline")
- if configured is not None:
- return configured
- api_key = current_app.config.get("QWEN_EMBEDDING_API_KEY", "")
- base_url = current_app.config.get("QWEN_EMBEDDING_BASE_URL", "")
- if api_key and base_url:
- from app.services.embedding.qwen import QwenEmbeddingClient
- embedder = QwenEmbeddingClient(
- api_key=api_key,
- base_url=base_url,
- model=current_app.config["QWEN_EMBEDDING_MODEL"],
- dimension=current_app.config["QWEN_EMBEDDING_DIMENSION"],
- )
- vector = SqlVectorRetriever(db.session, embedder)
- else:
- vector = UnavailableVectorRetriever()
- return KnowledgeRetrievalPipeline(
- lexical=SqlLexicalRetriever(db.session),
- vector=vector,
- )
- def _serialize_evidence(item) -> dict:
- return {
- "chunk_id": item.chunk_id,
- "content": item.content,
- "score": item.score,
- "retriever": item.retriever,
- "object_uid": item.object_uid,
- "object_type": item.object_type,
- "object_version": item.object_version,
- "business_domain_uid": item.business_domain_uid,
- "point_keys": list(item.point_keys),
- "point_revisions": list(item.point_revisions),
- "index_generation": item.generation,
- "source_updated_at": item.source_updated_at,
- "freshness_status": item.freshness_status,
- "section_path": item.section_path,
- }
- def _answer_synthesizer() -> AnswerSynthesizer:
- configured = current_app.extensions.get("knowledge_answer_synthesizer")
- return configured or AnswerSynthesizer(DeepSeekAnswerModel())
- def _request_context(payload):
- requested_domains = payload.get("business_domain_uids")
- if requested_domains is not None and not isinstance(requested_domains, list):
- raise ValueError("business_domain_uids 必须为数组")
- correlation_id = request.headers.get("X-Correlation-ID") or str(uuid.uuid4())
- context = build_access_context(
- db.session,
- identity=g.current_user,
- requested_business_domains=requested_domains,
- correlation_id=correlation_id,
- )
- return context, correlation_id
- @bp.post("/search")
- def search():
- payload = request.get_json(silent=True) or {}
- query = str(payload.get("query") or "").strip()
- if not query:
- return jsonify(failed("query 不能为空", code=400)), 400
- mode = str(payload.get("mode") or "auto")
- if mode not in {"auto", "exact", "semantic", "relationship", "global"}:
- return jsonify(failed("不支持的检索模式", code=400)), 400
- try:
- limit = _bounded_int(
- payload.get("limit"), field="limit", default=20, lower=1, upper=100
- )
- context, correlation_id = _request_context(payload)
- except ValueError as exc:
- return jsonify(failed(str(exc), code=400)), 400
- result = _pipeline().search(
- query,
- context=context,
- mode=mode,
- limit=limit,
- )
- return jsonify(
- success(
- {
- "query": query,
- "mode": result.mode,
- "evidence": [_serialize_evidence(item) for item in result.evidence],
- "degraded_components": list(result.degraded_components),
- "correlation_id": correlation_id,
- }
- )
- )
- @bp.post("/ask")
- def ask():
- payload = request.get_json(silent=True) or {}
- query = str(payload.get("query") or "").strip()
- if not query:
- return jsonify(failed("query 不能为空", code=400)), 400
- mode = str(payload.get("mode") or "auto")
- if mode not in {"auto", "exact", "semantic", "relationship", "global"}:
- return jsonify(failed("不支持的检索模式", code=400)), 400
- try:
- context, correlation_id = _request_context(payload)
- except ValueError as exc:
- return jsonify(failed(str(exc), code=400)), 400
- retrieval = _pipeline().search(query, context=context, mode=mode, limit=30)
- answer = _answer_synthesizer().answer(query, retrieval.evidence)
- return jsonify(
- success(
- {
- "query_id": correlation_id,
- "mode": retrieval.mode,
- "answer": answer.answer,
- "answer_status": answer.status,
- "degraded_components": list(retrieval.degraded_components),
- "citations": [citation.__dict__ for citation in answer.citations],
- "freshness_status": answer.freshness_status,
- }
- )
- )
- def _source_document(source_uid: str, version: int | None = None):
- context, _correlation_id = _request_context({})
- version_clause = (
- "AND d.object_version = :version"
- if version is not None
- else "AND d.status = 'active'"
- )
- row = (
- db.session.execute(
- text(
- f"""
- SELECT d.object_uid::text, d.object_type, d.object_version, d.object_name,
- d.business_domain_uid::text, d.content, d.source_updated_at,
- d.active_generation, d.status
- FROM public.governance_documents d
- WHERE d.object_uid = CAST(:uid AS uuid) {version_clause}
- """
- ),
- {"uid": source_uid, "version": version},
- )
- .mappings()
- .one_or_none()
- )
- if row is None or not context.permits_domain(row["business_domain_uid"]):
- return None
- return dict(row)
- @bp.get("/sources/<source_uid>")
- def source(source_uid: str):
- row = _source_document(source_uid)
- if row is None:
- return jsonify(failed("来源不存在或无权访问", code=404)), 404
- return jsonify(success(row))
- @bp.get("/sources/<source_uid>/versions/<int:version>")
- def source_version(source_uid: str, version: int):
- row = _source_document(source_uid, version)
- if row is None:
- return jsonify(failed("来源版本不存在或无权访问", code=404)), 404
- return jsonify(success(row))
- @bp.get("/capabilities")
- def capabilities():
- return jsonify(
- success(
- {
- "standard_retrieval": True,
- "lightrag_enabled": bool(
- current_app.config.get("KNOWLEDGE_LIGHTRAG_ENABLED", False)
- ),
- "lightrag_shadow_only": bool(
- current_app.config.get("KNOWLEDGE_LIGHTRAG_SHADOW_ONLY", True)
- ),
- "answer_generation": bool(current_app.config.get("DEEPSEEK_API_KEY")),
- }
- )
- )
- @bp.get("/admin/sync")
- def admin_sync():
- return jsonify(success(knowledge_status(db.session)))
- @bp.get("/admin/change-sets")
- def admin_change_sets():
- try:
- limit = _bounded_int(
- request.args.get("limit"), field="limit", default=50, lower=1, upper=200
- )
- except ValueError as exc:
- return jsonify(failed(str(exc), code=400)), 400
- return jsonify(success(list_change_sets(db.session, limit=limit)))
- @bp.get("/admin/change-sets/<change_set_id>")
- def admin_change_set(change_set_id: str):
- detail = change_set_detail(db.session, change_set_id)
- if detail is None:
- return jsonify(failed("change set 不存在", code=404)), 404
- return jsonify(success(detail))
- @bp.post("/admin/change-sets/<change_set_id>/retry")
- def admin_retry_change_set(change_set_id: str):
- if not retry_change_set(db.session, change_set_id):
- db.session.rollback()
- return jsonify(failed("当前状态不允许重试", code=409)), 409
- db.session.commit()
- return jsonify(success({"change_set_id": change_set_id, "status": "pending"}))
- @bp.post("/admin/change-sets/<change_set_id>/rollback")
- def admin_rollback_change_set(change_set_id: str):
- if not rollback_change_set(db.session, change_set_id):
- db.session.rollback()
- return jsonify(failed("没有可安全回退的上一版本", code=409)), 409
- db.session.commit()
- return jsonify(success({"change_set_id": change_set_id, "status": "rolled_back"}))
- @bp.post("/admin/audit")
- def admin_audit():
- payload = request.get_json(silent=True) or {}
- repair = payload.get("repair") is True
- findings = run_canonical_audit(db.session)
- repaired = 0
- if repair:
- repaired = db.session.execute(
- text(
- "DELETE FROM public.knowledge_cache_dependencies "
- "WHERE expires_at <= CURRENT_TIMESTAMP"
- )
- ).rowcount
- db.session.commit()
- return jsonify(
- success(
- {
- "mode": "repair" if repair else "report",
- "findings": [finding.__dict__ for finding in findings],
- "repaired_cache_dependencies": repaired,
- }
- )
- )
- @bp.post("/admin/retry-projection")
- def admin_retry_projection():
- projection_id = str(
- (request.get_json(silent=True) or {}).get("projection_id") or ""
- )
- if not projection_id:
- return jsonify(failed("projection_id 不能为空", code=400)), 400
- updated = db.session.execute(
- text(
- """
- UPDATE public.knowledge_index_projections
- SET status = 'pending', last_error = NULL, external_track_id = NULL,
- available_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
- WHERE id = CAST(:id AS uuid) AND status IN ('failed','unverified')
- """
- ),
- {"id": projection_id},
- ).rowcount
- if updated != 1:
- db.session.rollback()
- return jsonify(failed("当前投影状态不允许重试", code=409)), 409
- db.session.commit()
- return jsonify(success({"projection_id": projection_id, "status": "pending"}))
- @bp.get("/admin/evaluations")
- def admin_evaluations():
- rows = db.session.execute(
- text(
- """
- SELECT run.id::text, evaluation_set.name, run.status, run.configuration,
- run.started_at, run.finished_at,
- COUNT(result.case_id) AS case_count,
- COUNT(result.case_id) FILTER (WHERE result.passed) AS passed_count
- FROM public.knowledge_evaluation_runs run
- JOIN public.knowledge_evaluation_sets evaluation_set
- ON evaluation_set.id = run.evaluation_set_id
- LEFT JOIN public.knowledge_evaluation_results result ON result.run_id = run.id
- GROUP BY run.id, evaluation_set.name
- ORDER BY run.started_at DESC
- LIMIT 50
- """
- )
- ).mappings()
- return jsonify(success([dict(row) for row in rows]))
|