sync.py 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  1. from __future__ import annotations
  2. from collections.abc import Mapping, Sequence
  3. from typing import Protocol
  4. from app.core.knowledge.chunking import build_chunks, embedding_input_hash
  5. from app.core.knowledge.contracts import (
  6. KnowledgeSnapshot,
  7. PointChange,
  8. PreparedPublication,
  9. SnapshotDiff,
  10. SyncResult,
  11. )
  12. from app.core.knowledge.diff import diff_snapshots
  13. class KnowledgeRepository(Protocol):
  14. def load_active_snapshot(
  15. self, source_type: str, source_uid: str
  16. ) -> KnowledgeSnapshot | None: ...
  17. def load_embedding(self, embedding_hash: str) -> Sequence[float] | None: ...
  18. def activate(self, publication: PreparedPublication) -> None: ...
  19. class EmbeddingProvider(Protocol):
  20. profile_key: str
  21. def embed(self, texts: list[str]) -> list[list[float]]: ...
  22. def _initial_diff(snapshot: KnowledgeSnapshot) -> SnapshotDiff:
  23. return SnapshotDiff(
  24. added=tuple(
  25. PointChange(
  26. point_key=point.point_key,
  27. change_kind="added",
  28. new=point,
  29. )
  30. for point in snapshot.points
  31. )
  32. )
  33. class KnowledgeSyncService:
  34. def __init__(
  35. self, *, repository: KnowledgeRepository, embedder: EmbeddingProvider
  36. ) -> None:
  37. self._repository = repository
  38. self._embedder = embedder
  39. def sync(self, snapshot: KnowledgeSnapshot) -> SyncResult:
  40. old = self._repository.load_active_snapshot(
  41. snapshot.source_type, snapshot.source_uid
  42. )
  43. if old and snapshot.source_revision < old.source_revision:
  44. raise ValueError("source revision cannot move backwards")
  45. if old and snapshot.source_revision == old.source_revision:
  46. if snapshot.source_snapshot_hash != old.source_snapshot_hash:
  47. raise ValueError(
  48. "same source revision cannot have a different snapshot hash"
  49. )
  50. return SyncResult(
  51. status="canonical_active",
  52. source_type=snapshot.source_type,
  53. source_uid=snapshot.source_uid,
  54. source_revision=snapshot.source_revision,
  55. diff_counts={
  56. "added": 0,
  57. "modified": 0,
  58. "deleted": 0,
  59. "unchanged": len(snapshot.points),
  60. },
  61. embedded_chunk_count=0,
  62. reused_embedding_count=len(snapshot.points),
  63. )
  64. diff = diff_snapshots(old, snapshot) if old else _initial_diff(snapshot)
  65. chunks = build_chunks(snapshot)
  66. changed_point_keys = diff.changed_point_keys
  67. embeddings: dict[str, tuple[float, ...]] = {}
  68. missing_hashes: list[str] = []
  69. missing_texts: list[str] = []
  70. reused_count = 0
  71. for chunk in chunks:
  72. embedding_hash = embedding_input_hash(chunk, self._embedder.profile_key)
  73. existing = self._repository.load_embedding(embedding_hash)
  74. if existing is not None:
  75. reused_count += 1
  76. continue
  77. if chunk.primary_point_key not in changed_point_keys:
  78. raise RuntimeError(
  79. "unchanged knowledge point is missing a reusable embedding"
  80. )
  81. missing_hashes.append(embedding_hash)
  82. missing_texts.append(chunk.content)
  83. if missing_texts:
  84. vectors = self._embedder.embed(missing_texts)
  85. if len(vectors) != len(missing_texts):
  86. raise RuntimeError(
  87. "embedding provider returned an unexpected vector count"
  88. )
  89. embeddings = {
  90. embedding_hash: tuple(float(value) for value in vector)
  91. for embedding_hash, vector in zip(missing_hashes, vectors, strict=True)
  92. }
  93. publication = PreparedPublication(
  94. snapshot=snapshot,
  95. diff=diff,
  96. chunks=chunks,
  97. embeddings=embeddings,
  98. status="canonical_active",
  99. )
  100. self._repository.activate(publication)
  101. counts: Mapping[str, int] = {
  102. "added": len(diff.added),
  103. "modified": len(diff.modified),
  104. "deleted": len(diff.deleted),
  105. "unchanged": len(diff.unchanged),
  106. }
  107. return SyncResult(
  108. status=publication.status,
  109. source_type=snapshot.source_type,
  110. source_uid=snapshot.source_uid,
  111. source_revision=snapshot.source_revision,
  112. diff_counts=counts,
  113. embedded_chunk_count=len(embeddings),
  114. reused_embedding_count=reused_count,
  115. )