ingest.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. """External knowledge ingest API client and draft-level orchestration."""
  2. from __future__ import annotations
  3. import time
  4. from dataclasses import dataclass
  5. from typing import Any, Callable
  6. import httpx
  7. from core.config import IngestApiConfig
  8. from core.text_limits import ERROR_MESSAGE_MAX_CHARS, clip_text
  9. from decode_content.models import IngestRecord, PayloadDraft
  10. from decode_content.payloads import IngestPayloadValidationError, validate_ingest_payload
  11. from decode_content.repository import DecodeRepository
  12. PostFn = Callable[..., Any]
  13. class IngestApiError(RuntimeError):
  14. def __init__(
  15. self,
  16. message: str,
  17. *,
  18. response_payload: dict[str, Any] | None = None,
  19. http_status: int | None = None,
  20. attempt_count: int = 1,
  21. ) -> None:
  22. super().__init__(message)
  23. self.response_payload = response_payload or {}
  24. self.http_status = http_status
  25. self.attempt_count = attempt_count
  26. @dataclass(frozen=True)
  27. class IngestApiResult:
  28. knowledge_id: str | None
  29. source_id: str | None
  30. http_status: int
  31. attempt_count: int
  32. response_payload: dict[str, Any]
  33. class KnowledgeIngestClient:
  34. """Small synchronous client for POST /api/v1/knowledge/ingest."""
  35. def __init__(
  36. self,
  37. config: IngestApiConfig,
  38. *,
  39. post_fn: PostFn | None = None,
  40. sleep_fn: Callable[[float], None] = time.sleep,
  41. ) -> None:
  42. self.config = config
  43. self.post_fn = post_fn or httpx.post
  44. self.sleep_fn = sleep_fn
  45. def ingest(self, payload: dict[str, Any]) -> IngestApiResult:
  46. validate_ingest_payload(payload)
  47. if not self.config.url:
  48. raise IngestApiError(
  49. "CK_INGEST_API_URL 未配置,真实 ingest 已关闭",
  50. response_payload={"dry_run": False, "missing_config": "CK_INGEST_API_URL"},
  51. )
  52. headers = {"Content-Type": "application/json"}
  53. if self.config.token:
  54. headers["Authorization"] = f"Bearer {self.config.token}"
  55. delays = tuple(self.config.retry_delays_seconds)
  56. last_error: IngestApiError | None = None
  57. for attempt_index in range(len(delays) + 1):
  58. attempt_count = attempt_index + 1
  59. try:
  60. response = self.post_fn(
  61. self.config.url,
  62. json=payload,
  63. headers=headers,
  64. timeout=self.config.timeout_seconds,
  65. )
  66. raw = _response_payload(response)
  67. status_code = int(getattr(response, "status_code", 0) or 0)
  68. if 200 <= status_code < 300:
  69. knowledge_id = raw.get("knowledge_id") if isinstance(raw, dict) else None
  70. source_id = raw.get("source_id") if isinstance(raw, dict) else None
  71. return IngestApiResult(
  72. knowledge_id=str(knowledge_id) if knowledge_id else None,
  73. source_id=str(source_id) if source_id else None,
  74. http_status=status_code,
  75. attempt_count=attempt_count,
  76. response_payload={
  77. "dry_run": False,
  78. "http_status": status_code,
  79. "knowledge_id": knowledge_id,
  80. "source_id": source_id,
  81. "attempt_count": attempt_count,
  82. "response": raw,
  83. },
  84. )
  85. last_error = IngestApiError(
  86. f"ingest API 返回 {status_code}",
  87. response_payload={
  88. "dry_run": False,
  89. "http_status": status_code,
  90. "attempt_count": attempt_count,
  91. "response": raw,
  92. },
  93. http_status=status_code,
  94. attempt_count=attempt_count,
  95. )
  96. except IngestApiError as exc:
  97. last_error = exc
  98. except Exception as exc: # network/client errors
  99. last_error = IngestApiError(
  100. str(exc) or exc.__class__.__name__,
  101. response_payload={
  102. "dry_run": False,
  103. "exception_type": exc.__class__.__name__,
  104. "attempt_count": attempt_count,
  105. },
  106. attempt_count=attempt_count,
  107. )
  108. if attempt_index < len(delays):
  109. self.sleep_fn(delays[attempt_index])
  110. assert last_error is not None
  111. raise last_error
  112. def _response_payload(response: Any) -> dict[str, Any] | str:
  113. try:
  114. value = response.json()
  115. except Exception:
  116. value = getattr(response, "text", "")
  117. return value if isinstance(value, (dict, str)) else {"response": value}
  118. def ingest_payload_draft(
  119. repo: DecodeRepository,
  120. draft: PayloadDraft,
  121. *,
  122. dry_run: bool = True,
  123. client: KnowledgeIngestClient | None = None,
  124. ) -> IngestRecord:
  125. payload = draft.payload or {}
  126. source_id = ((payload.get("source") or {}).get("id") or "") if isinstance(payload, dict) else ""
  127. try:
  128. validate_ingest_payload(payload)
  129. if dry_run:
  130. updated = _mark_ingested(repo, draft)
  131. return repo.save_ingest_record(
  132. payload_draft_id=draft.id,
  133. target_system="dry-run",
  134. target_id=str(draft.id) if draft.id else None,
  135. status="ingested",
  136. attempt_count=1,
  137. response_payload={
  138. "dry_run": True,
  139. "source_id": source_id,
  140. "note": "payload validated by formal ingest path; external ingest API not called",
  141. "payload": updated.payload if hasattr(updated, "payload") else payload,
  142. },
  143. )
  144. if client is None:
  145. raise IngestApiError("真实 ingest 需要 KnowledgeIngestClient")
  146. result = client.ingest(payload)
  147. _mark_ingested(repo, draft)
  148. return repo.save_ingest_record(
  149. payload_draft_id=draft.id,
  150. target_system="knowledge-ingest-api",
  151. target_id=result.knowledge_id,
  152. status="ingested",
  153. attempt_count=result.attempt_count,
  154. response_payload=result.response_payload,
  155. )
  156. except (IngestPayloadValidationError, IngestApiError) as exc:
  157. response_payload = getattr(exc, "response_payload", {}) or {}
  158. response_payload.setdefault("dry_run", dry_run)
  159. response_payload.setdefault("source_id", source_id)
  160. return repo.save_ingest_record(
  161. payload_draft_id=draft.id,
  162. target_system="dry-run" if dry_run else "knowledge-ingest-api",
  163. target_id=None,
  164. status="failed",
  165. attempt_count=getattr(exc, "attempt_count", 1),
  166. response_payload=response_payload,
  167. error_message=clip_text(str(exc), ERROR_MESSAGE_MAX_CHARS),
  168. )
  169. def _mark_ingested(repo: DecodeRepository, draft: PayloadDraft) -> PayloadDraft:
  170. if draft.id is None:
  171. return draft
  172. marker = getattr(repo, "mark_payload_draft_ingested", None)
  173. if marker is None:
  174. return draft
  175. return marker(draft.id)