repository.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664
  1. from __future__ import annotations
  2. import json
  3. from collections.abc import Mapping, Sequence
  4. from typing import Any
  5. from sqlalchemy import text
  6. from sqlalchemy.orm import Session
  7. from app.core.common.identifiers import new_governance_uid
  8. from app.core.knowledge.chunking import embedding_input_hash
  9. from app.core.knowledge.contracts import (
  10. KnowledgeDependencyDraft,
  11. KnowledgePointDraft,
  12. KnowledgeSnapshot,
  13. PointChange,
  14. PreparedPublication,
  15. )
  16. def _json(value: object) -> str:
  17. return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
  18. def _mapping(value: Any) -> Mapping[str, Any]:
  19. if isinstance(value, Mapping):
  20. return value
  21. if isinstance(value, str):
  22. decoded = json.loads(value)
  23. if isinstance(decoded, Mapping):
  24. return decoded
  25. return {}
  26. def _vector(value: Sequence[float]) -> str:
  27. return "[" + ",".join(str(float(item)) for item in value) + "]"
  28. class SqlKnowledgeRepository:
  29. """Persist a canonical knowledge publication in the caller's transaction."""
  30. def __init__(
  31. self,
  32. session: Session,
  33. *,
  34. embedding_profile_id: str,
  35. embedding_profile_key: str,
  36. generation: int,
  37. workspace: str,
  38. ) -> None:
  39. self._session = session
  40. self._embedding_profile_id = embedding_profile_id
  41. self._embedding_profile_key = embedding_profile_key
  42. self._generation = generation
  43. self._workspace = workspace
  44. def load_active_snapshot(
  45. self, source_type: str, source_uid: str
  46. ) -> KnowledgeSnapshot | None:
  47. document = (
  48. self._session.execute(
  49. text(
  50. """
  51. SELECT object_version, content_hash, point_set_hash,
  52. permission_scope, source_updated_at
  53. FROM public.governance_documents
  54. WHERE object_uid = CAST(:source_uid AS uuid)
  55. AND object_type = :source_type
  56. AND status = 'active'
  57. """
  58. ),
  59. {"source_type": source_type, "source_uid": source_uid},
  60. )
  61. .mappings()
  62. .one_or_none()
  63. )
  64. if document is None:
  65. return None
  66. point_rows = self._session.execute(
  67. text(
  68. """
  69. SELECT point_key, semantic_path, content, content_hash,
  70. metadata, metadata_hash, permission_scope, permission_hash
  71. FROM public.knowledge_points
  72. WHERE source_type = :source_type
  73. AND source_uid = CAST(:source_uid AS uuid)
  74. AND status = 'active'
  75. ORDER BY point_key
  76. """
  77. ),
  78. {"source_type": source_type, "source_uid": source_uid},
  79. ).mappings()
  80. points = tuple(
  81. KnowledgePointDraft(
  82. point_key=row["point_key"],
  83. semantic_path=row["semantic_path"],
  84. content=row["content"],
  85. content_hash=row["content_hash"],
  86. metadata=_mapping(row["metadata"]),
  87. metadata_hash=row["metadata_hash"],
  88. permission_scope=_mapping(row["permission_scope"]),
  89. permission_hash=row["permission_hash"],
  90. )
  91. for row in point_rows
  92. )
  93. prefix = f"{source_type}/{source_uid}/%"
  94. dependency_rows = self._session.execute(
  95. text(
  96. """
  97. SELECT from_point_key, to_point_key, relation_type, source
  98. FROM public.knowledge_point_dependencies
  99. WHERE from_point_key LIKE :prefix AND status = 'active'
  100. ORDER BY from_point_key, to_point_key, relation_type, source
  101. """
  102. ),
  103. {"prefix": prefix},
  104. ).mappings()
  105. dependencies = tuple(
  106. KnowledgeDependencyDraft(
  107. from_point_key=row["from_point_key"],
  108. to_point_key=row["to_point_key"],
  109. relation_type=row["relation_type"],
  110. source=row["source"],
  111. )
  112. for row in dependency_rows
  113. )
  114. updated_at = document["source_updated_at"]
  115. return KnowledgeSnapshot(
  116. source_type=source_type,
  117. source_uid=source_uid,
  118. source_revision=int(document["object_version"]),
  119. source_snapshot_hash=document["content_hash"],
  120. point_set_hash=document["point_set_hash"],
  121. permission_scope=_mapping(document["permission_scope"]),
  122. points=points,
  123. dependencies=dependencies,
  124. source_updated_at=updated_at.isoformat() if updated_at else None,
  125. )
  126. def load_embedding(self, embedding_hash: str) -> Sequence[float] | None:
  127. found = self._session.execute(
  128. text(
  129. """
  130. SELECT 1
  131. FROM public.knowledge_chunk_embeddings
  132. WHERE profile_id = CAST(:profile_id AS uuid)
  133. AND embedding_hash = :embedding_hash
  134. LIMIT 1
  135. """
  136. ),
  137. {
  138. "profile_id": self._embedding_profile_id,
  139. "embedding_hash": embedding_hash,
  140. },
  141. ).scalar_one_or_none()
  142. return (0.0,) if found is not None else None
  143. def activate(self, publication: PreparedPublication) -> None:
  144. snapshot = publication.snapshot
  145. self._lock_source(snapshot.source_type, snapshot.source_uid)
  146. self._assert_revision_is_publishable(snapshot)
  147. change_set_id = new_governance_uid()
  148. correlation_id = new_governance_uid()
  149. document_id = new_governance_uid()
  150. change_groups = (
  151. publication.diff.added,
  152. publication.diff.modified,
  153. publication.diff.deleted,
  154. )
  155. change_type = "create" if not self._active_document_id(snapshot) else "update"
  156. self._insert_change_set(
  157. change_set_id=change_set_id,
  158. correlation_id=correlation_id,
  159. publication=publication,
  160. change_type=change_type,
  161. )
  162. old_revisions = self._load_active_point_revisions(snapshot)
  163. for group in change_groups:
  164. for change in group:
  165. self._retire_old_point(change)
  166. self._retire_active_document(snapshot)
  167. self._insert_document(document_id, change_set_id, snapshot)
  168. new_revisions: dict[str, int] = {}
  169. for change in (*publication.diff.added, *publication.diff.modified):
  170. point_revision = self._next_point_revision(change.point_key)
  171. new_revisions[change.point_key] = point_revision
  172. self._insert_point(change.new, snapshot, point_revision)
  173. self._replace_dependencies(snapshot)
  174. self._insert_chunks_and_embeddings(
  175. document_id=document_id,
  176. change_set_id=change_set_id,
  177. publication=publication,
  178. )
  179. self._insert_change_items(
  180. change_set_id=change_set_id,
  181. publication=publication,
  182. old_revisions=old_revisions,
  183. new_revisions=new_revisions,
  184. )
  185. self._invalidate_cache_dependencies(publication)
  186. self._insert_projections(document_id, change_set_id, snapshot)
  187. self._session.execute(
  188. text(
  189. """
  190. UPDATE public.knowledge_change_sets
  191. SET status = 'canonical_active',
  192. updated_at = CURRENT_TIMESTAMP,
  193. activated_at = CURRENT_TIMESTAMP
  194. WHERE id = CAST(:id AS uuid)
  195. """
  196. ),
  197. {"id": change_set_id},
  198. )
  199. def _lock_source(self, source_type: str, source_uid: str) -> None:
  200. self._session.execute(
  201. text("SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))"),
  202. {"key": f"knowledge:{source_type}:{source_uid}"},
  203. )
  204. def _active_document_id(self, snapshot: KnowledgeSnapshot) -> str | None:
  205. return self._session.execute(
  206. text(
  207. """
  208. SELECT CAST(id AS text)
  209. FROM public.governance_documents
  210. WHERE object_uid = CAST(:source_uid AS uuid) AND status = 'active'
  211. """
  212. ),
  213. {"source_uid": snapshot.source_uid},
  214. ).scalar_one_or_none()
  215. def _assert_revision_is_publishable(self, snapshot: KnowledgeSnapshot) -> None:
  216. active_revision = self._session.execute(
  217. text(
  218. """
  219. SELECT object_version
  220. FROM public.governance_documents
  221. WHERE object_uid = CAST(:source_uid AS uuid) AND status = 'active'
  222. FOR UPDATE
  223. """
  224. ),
  225. {"source_uid": snapshot.source_uid},
  226. ).scalar_one_or_none()
  227. if active_revision is not None and snapshot.source_revision <= active_revision:
  228. raise ValueError("source revision is no longer publishable")
  229. def _insert_change_set(
  230. self,
  231. *,
  232. change_set_id: str,
  233. correlation_id: str,
  234. publication: PreparedPublication,
  235. change_type: str,
  236. ) -> None:
  237. snapshot = publication.snapshot
  238. self._session.execute(
  239. text(
  240. """
  241. INSERT INTO public.knowledge_change_sets (
  242. id, correlation_id, source_type, source_uid, source_revision,
  243. change_type, source_snapshot_hash, added_count, modified_count,
  244. deleted_count, status, target_generation
  245. ) VALUES (
  246. CAST(:id AS uuid), CAST(:correlation_id AS uuid), :source_type,
  247. CAST(:source_uid AS uuid), :source_revision, :change_type,
  248. :snapshot_hash, :added_count, :modified_count, :deleted_count,
  249. 'validating', :generation
  250. )
  251. """
  252. ),
  253. {
  254. "id": change_set_id,
  255. "correlation_id": correlation_id,
  256. "source_type": snapshot.source_type,
  257. "source_uid": snapshot.source_uid,
  258. "source_revision": snapshot.source_revision,
  259. "change_type": change_type,
  260. "snapshot_hash": snapshot.source_snapshot_hash,
  261. "added_count": len(publication.diff.added),
  262. "modified_count": len(publication.diff.modified),
  263. "deleted_count": len(publication.diff.deleted),
  264. "generation": self._generation,
  265. },
  266. )
  267. def _load_active_point_revisions(
  268. self, snapshot: KnowledgeSnapshot
  269. ) -> dict[str, int]:
  270. rows = self._session.execute(
  271. text(
  272. """
  273. SELECT point_key, point_revision
  274. FROM public.knowledge_points
  275. WHERE source_type = :source_type
  276. AND source_uid = CAST(:source_uid AS uuid)
  277. AND status = 'active'
  278. """
  279. ),
  280. {"source_type": snapshot.source_type, "source_uid": snapshot.source_uid},
  281. )
  282. return {row[0]: int(row[1]) for row in rows}
  283. def _retire_old_point(self, change: PointChange) -> None:
  284. if change.old is None:
  285. return
  286. target_status = "deleted" if change.change_kind == "deleted" else "superseded"
  287. self._session.execute(
  288. text(
  289. """
  290. UPDATE public.knowledge_points
  291. SET status = :status, valid_to = CURRENT_TIMESTAMP
  292. WHERE point_key = :point_key AND status = 'active'
  293. """
  294. ),
  295. {"status": target_status, "point_key": change.point_key},
  296. )
  297. def _retire_active_document(self, snapshot: KnowledgeSnapshot) -> None:
  298. self._session.execute(
  299. text(
  300. """
  301. UPDATE public.governance_documents
  302. SET status = 'superseded'
  303. WHERE object_uid = CAST(:source_uid AS uuid) AND status = 'active'
  304. """
  305. ),
  306. {"source_uid": snapshot.source_uid},
  307. )
  308. def _insert_document(
  309. self, document_id: str, change_set_id: str, snapshot: KnowledgeSnapshot
  310. ) -> None:
  311. point_by_path = {
  312. point.semantic_path: point.content for point in snapshot.points
  313. }
  314. object_name = (
  315. point_by_path.get("name") or f"{snapshot.source_type}:{snapshot.source_uid}"
  316. )
  317. domains = snapshot.permission_scope.get("business_domains", [])
  318. business_domain_uid = str(domains[0]) if domains else None
  319. content = _json(
  320. {
  321. "source_type": snapshot.source_type,
  322. "source_uid": snapshot.source_uid,
  323. "source_revision": snapshot.source_revision,
  324. "points": [
  325. {"point_key": point.point_key, "content": point.content}
  326. for point in snapshot.points
  327. ],
  328. }
  329. )
  330. self._session.execute(
  331. text(
  332. """
  333. INSERT INTO public.governance_documents (
  334. id, object_uid, object_type, object_version, object_name,
  335. business_domain_uid, permission_scope, content, content_hash,
  336. status, source_updated_at, point_set_hash, active_generation,
  337. change_set_id
  338. ) VALUES (
  339. CAST(:id AS uuid), CAST(:source_uid AS uuid), :source_type,
  340. :source_revision, :object_name, CAST(:business_domain_uid AS uuid),
  341. CAST(:permission_scope AS jsonb), :content, :snapshot_hash,
  342. 'active', CAST(:source_updated_at AS timestamptz), :point_set_hash,
  343. :generation, CAST(:change_set_id AS uuid)
  344. )
  345. """
  346. ),
  347. {
  348. "id": document_id,
  349. "source_uid": snapshot.source_uid,
  350. "source_type": snapshot.source_type,
  351. "source_revision": snapshot.source_revision,
  352. "object_name": object_name,
  353. "business_domain_uid": business_domain_uid,
  354. "permission_scope": _json(snapshot.permission_scope),
  355. "content": content,
  356. "snapshot_hash": snapshot.source_snapshot_hash,
  357. "source_updated_at": snapshot.source_updated_at,
  358. "point_set_hash": snapshot.point_set_hash,
  359. "generation": self._generation,
  360. "change_set_id": change_set_id,
  361. },
  362. )
  363. def _next_point_revision(self, point_key: str) -> int:
  364. return int(
  365. self._session.execute(
  366. text(
  367. """
  368. SELECT COALESCE(MAX(point_revision), 0) + 1
  369. FROM public.knowledge_points
  370. WHERE point_key = :point_key
  371. """
  372. ),
  373. {"point_key": point_key},
  374. ).scalar_one()
  375. )
  376. def _insert_point(
  377. self,
  378. point: KnowledgePointDraft | None,
  379. snapshot: KnowledgeSnapshot,
  380. point_revision: int,
  381. ) -> None:
  382. if point is None:
  383. raise ValueError("added or modified point is missing its new value")
  384. self._session.execute(
  385. text(
  386. """
  387. INSERT INTO public.knowledge_points (
  388. id, point_key, point_revision, source_type, source_uid,
  389. source_revision, semantic_path, content, content_hash,
  390. metadata, metadata_hash, permission_scope, permission_hash,
  391. status, valid_from, activated_at
  392. ) VALUES (
  393. CAST(:id AS uuid), :point_key, :point_revision, :source_type,
  394. CAST(:source_uid AS uuid), :source_revision, :semantic_path,
  395. :content, :content_hash, CAST(:metadata AS jsonb), :metadata_hash,
  396. CAST(:permission_scope AS jsonb), :permission_hash, 'active',
  397. CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
  398. )
  399. """
  400. ),
  401. {
  402. "id": new_governance_uid(),
  403. "point_key": point.point_key,
  404. "point_revision": point_revision,
  405. "source_type": snapshot.source_type,
  406. "source_uid": snapshot.source_uid,
  407. "source_revision": snapshot.source_revision,
  408. "semantic_path": point.semantic_path,
  409. "content": point.content,
  410. "content_hash": point.content_hash,
  411. "metadata": _json(point.metadata),
  412. "metadata_hash": point.metadata_hash,
  413. "permission_scope": _json(point.permission_scope),
  414. "permission_hash": point.permission_hash,
  415. },
  416. )
  417. def _replace_dependencies(self, snapshot: KnowledgeSnapshot) -> None:
  418. prefix = f"{snapshot.source_type}/{snapshot.source_uid}/%"
  419. self._session.execute(
  420. text(
  421. """
  422. UPDATE public.knowledge_point_dependencies
  423. SET status = 'superseded'
  424. WHERE from_point_key LIKE :prefix AND status = 'active'
  425. """
  426. ),
  427. {"prefix": prefix},
  428. )
  429. statement = text(
  430. """
  431. INSERT INTO public.knowledge_point_dependencies (
  432. from_point_key, to_point_key, relation_type, source, generation, status
  433. ) VALUES (
  434. :from_point_key, :to_point_key, :relation_type, :source,
  435. :generation, 'active'
  436. )
  437. ON CONFLICT (
  438. from_point_key, to_point_key, relation_type, source, generation
  439. ) DO UPDATE SET status = 'active'
  440. """
  441. )
  442. for dependency in snapshot.dependencies:
  443. self._session.execute(
  444. statement,
  445. {
  446. "from_point_key": dependency.from_point_key,
  447. "to_point_key": dependency.to_point_key,
  448. "relation_type": dependency.relation_type,
  449. "source": dependency.source,
  450. "generation": self._generation,
  451. },
  452. )
  453. def _insert_chunks_and_embeddings(
  454. self,
  455. *,
  456. document_id: str,
  457. change_set_id: str,
  458. publication: PreparedPublication,
  459. ) -> None:
  460. chunk_statement = text(
  461. """
  462. INSERT INTO public.governance_chunks (
  463. id, document_id, chunk_no, content, content_hash, chunk_kind,
  464. section_path, metadata, lexical_text, search_vector,
  465. primary_point_key, point_keys, point_set_hash, change_set_id
  466. ) VALUES (
  467. CAST(:id AS uuid), CAST(:document_id AS uuid), :chunk_no, :content,
  468. :content_hash, :chunk_kind, :section_path, CAST(:metadata AS jsonb),
  469. :lexical_text, to_tsvector('simple', COALESCE(:lexical_text, '')),
  470. :primary_point_key, CAST(:point_keys AS jsonb), :point_set_hash,
  471. CAST(:change_set_id AS uuid)
  472. )
  473. """
  474. )
  475. for chunk_no, chunk in enumerate(publication.chunks):
  476. chunk_id = new_governance_uid()
  477. self._session.execute(
  478. chunk_statement,
  479. {
  480. "id": chunk_id,
  481. "document_id": document_id,
  482. "chunk_no": chunk_no,
  483. "content": chunk.content,
  484. "content_hash": chunk.content_hash,
  485. "chunk_kind": chunk.chunk_kind,
  486. "section_path": chunk.section_path,
  487. "metadata": _json(chunk.metadata),
  488. "lexical_text": chunk.lexical_text,
  489. "primary_point_key": chunk.primary_point_key,
  490. "point_keys": _json(chunk.point_keys),
  491. "point_set_hash": chunk.point_set_hash,
  492. "change_set_id": change_set_id,
  493. },
  494. )
  495. embedding_hash = embedding_input_hash(chunk, self._embedding_profile_key)
  496. vector = publication.embeddings.get(embedding_hash)
  497. if vector is not None:
  498. self._session.execute(
  499. text(
  500. """
  501. INSERT INTO public.knowledge_chunk_embeddings (
  502. id, chunk_id, profile_id, embedding, embedding_hash
  503. ) VALUES (
  504. CAST(:id AS uuid), CAST(:chunk_id AS uuid),
  505. CAST(:profile_id AS uuid), CAST(:embedding AS vector),
  506. :embedding_hash
  507. )
  508. """
  509. ),
  510. {
  511. "id": new_governance_uid(),
  512. "chunk_id": chunk_id,
  513. "profile_id": self._embedding_profile_id,
  514. "embedding": _vector(vector),
  515. "embedding_hash": embedding_hash,
  516. },
  517. )
  518. continue
  519. inserted = self._session.execute(
  520. text(
  521. """
  522. INSERT INTO public.knowledge_chunk_embeddings (
  523. id, chunk_id, profile_id, embedding, embedding_hash
  524. )
  525. SELECT CAST(:id AS uuid), CAST(:chunk_id AS uuid), profile_id,
  526. embedding, embedding_hash
  527. FROM public.knowledge_chunk_embeddings
  528. WHERE profile_id = CAST(:profile_id AS uuid)
  529. AND embedding_hash = :embedding_hash
  530. LIMIT 1
  531. """
  532. ),
  533. {
  534. "id": new_governance_uid(),
  535. "chunk_id": chunk_id,
  536. "profile_id": self._embedding_profile_id,
  537. "embedding_hash": embedding_hash,
  538. },
  539. ).rowcount
  540. if inserted != 1:
  541. raise RuntimeError("reusable embedding disappeared during publication")
  542. def _insert_change_items(
  543. self,
  544. *,
  545. change_set_id: str,
  546. publication: PreparedPublication,
  547. old_revisions: Mapping[str, int],
  548. new_revisions: Mapping[str, int],
  549. ) -> None:
  550. statement = text(
  551. """
  552. INSERT INTO public.knowledge_change_items (
  553. change_set_id, point_key, change_kind, old_point_revision,
  554. new_point_revision, old_content_hash, new_content_hash,
  555. canonical_status, embedding_status, cache_status, lightrag_status
  556. ) VALUES (
  557. CAST(:change_set_id AS uuid), :point_key, :change_kind,
  558. :old_revision, :new_revision, :old_hash, :new_hash,
  559. 'complete', 'complete', 'complete', 'pending'
  560. )
  561. """
  562. )
  563. for change in (
  564. *publication.diff.added,
  565. *publication.diff.modified,
  566. *publication.diff.deleted,
  567. ):
  568. self._session.execute(
  569. statement,
  570. {
  571. "change_set_id": change_set_id,
  572. "point_key": change.point_key,
  573. "change_kind": change.change_kind,
  574. "old_revision": old_revisions.get(change.point_key),
  575. "new_revision": new_revisions.get(change.point_key),
  576. "old_hash": change.old.content_hash if change.old else None,
  577. "new_hash": change.new.content_hash if change.new else None,
  578. },
  579. )
  580. def _invalidate_cache_dependencies(self, publication: PreparedPublication) -> None:
  581. changed_keys = sorted(publication.diff.changed_point_keys)
  582. if not changed_keys:
  583. return
  584. self._session.execute(
  585. text(
  586. """
  587. DELETE FROM public.knowledge_cache_dependencies
  588. WHERE point_key = ANY(CAST(:point_keys AS text[]))
  589. """
  590. ),
  591. {"point_keys": changed_keys},
  592. )
  593. def _insert_projections(
  594. self,
  595. document_id: str,
  596. change_set_id: str,
  597. snapshot: KnowledgeSnapshot,
  598. ) -> None:
  599. statement = text(
  600. """
  601. INSERT INTO public.knowledge_index_projections (
  602. id, document_id, change_set_id, engine, generation, workspace,
  603. external_document_id, content_hash, status
  604. ) VALUES (
  605. CAST(:id AS uuid), CAST(:document_id AS uuid),
  606. CAST(:change_set_id AS uuid), :engine, :generation, :workspace,
  607. :external_document_id, :content_hash, :status
  608. )
  609. """
  610. )
  611. for engine, status in (("canonical_vector", "ready"), ("lightrag", "pending")):
  612. self._session.execute(
  613. statement,
  614. {
  615. "id": new_governance_uid(),
  616. "document_id": document_id,
  617. "change_set_id": change_set_id,
  618. "engine": engine,
  619. "generation": self._generation,
  620. "workspace": self._workspace,
  621. "external_document_id": (
  622. f"{snapshot.source_type}:{snapshot.source_uid}:"
  623. f"{snapshot.source_revision}"
  624. ),
  625. "content_hash": snapshot.source_snapshot_hash,
  626. "status": status,
  627. },
  628. )