postgres.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. """PostgreSQL implementation for formal decode-content state."""
  2. from __future__ import annotations
  3. from typing import Any
  4. from uuid import UUID
  5. import psycopg2.extras
  6. from decode_content.models import (
  7. ContractSnapshot,
  8. DecodeJob,
  9. DecodeResult,
  10. IngestRecord,
  11. KnowledgeParticle,
  12. PayloadDraft,
  13. ScopeResult,
  14. )
  15. Json = psycopg2.extras.Json
  16. psycopg2.extras.register_uuid()
  17. def _json(value: dict[str, Any] | None) -> Json:
  18. return Json(value or {})
  19. def _stage_value(value: Any) -> str | None:
  20. if isinstance(value, list):
  21. return " / ".join(str(item) for item in value if item)
  22. if value:
  23. return str(value)
  24. return None
  25. class PostgresDecodeRepository:
  26. """Repository backed by the formal PostgreSQL schema.
  27. Like the acquisition repository, this class owns no transaction boundary;
  28. callers pass an open connection and commit/rollback outside.
  29. """
  30. def __init__(self, conn: Any):
  31. self.conn = conn
  32. def _one(self, sql: str, params: tuple[Any, ...]) -> dict[str, Any]:
  33. with self.conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
  34. cur.execute(sql, params)
  35. row = cur.fetchone()
  36. if row is None:
  37. raise RuntimeError("expected one row, got none")
  38. return dict(row)
  39. def _one_or_none(self, sql: str, params: tuple[Any, ...]) -> dict[str, Any] | None:
  40. with self.conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
  41. cur.execute(sql, params)
  42. row = cur.fetchone()
  43. return dict(row) if row else None
  44. def _all(self, sql: str, params: tuple[Any, ...]) -> list[dict[str, Any]]:
  45. with self.conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
  46. cur.execute(sql, params)
  47. return [dict(row) for row in cur.fetchall()]
  48. def create_decode_job(
  49. self,
  50. *,
  51. item_id: UUID,
  52. status: str = "pending",
  53. metadata: dict[str, Any] | None = None,
  54. ) -> DecodeJob:
  55. row = self._one(
  56. """
  57. INSERT INTO decode_jobs(item_id, status, metadata, started_at)
  58. VALUES (%s, %s, %s, CASE WHEN %s = 'running' THEN now() ELSE NULL END)
  59. RETURNING *
  60. """,
  61. (item_id, status, _json(metadata), status),
  62. )
  63. return DecodeJob.model_validate(row)
  64. def save_decode_result(
  65. self,
  66. *,
  67. item_id: UUID,
  68. decode_job_id: UUID | None = None,
  69. read_result: dict[str, Any] | None = None,
  70. gate_result: dict[str, Any] | None = None,
  71. framing_result: dict[str, Any] | None = None,
  72. status: str = "draft",
  73. ) -> DecodeResult:
  74. row = self._one(
  75. """
  76. INSERT INTO decode_results(
  77. decode_job_id, item_id, read_result, gate_result,
  78. framing_result, status
  79. )
  80. VALUES (%s, %s, %s, %s, %s, %s)
  81. RETURNING *
  82. """,
  83. (
  84. decode_job_id,
  85. item_id,
  86. _json(read_result),
  87. _json(gate_result),
  88. _json(framing_result),
  89. status,
  90. ),
  91. )
  92. if decode_job_id:
  93. self._one(
  94. """
  95. UPDATE decode_jobs
  96. SET status = %s, finished_at = now(), error_message = NULL
  97. WHERE id = %s
  98. RETURNING *
  99. """,
  100. (status, decode_job_id),
  101. )
  102. return DecodeResult.model_validate(row)
  103. def get_decode_result_for_item(self, item_id: UUID) -> DecodeResult:
  104. row = self._one(
  105. """
  106. SELECT * FROM decode_results
  107. WHERE item_id = %s
  108. ORDER BY created_at DESC
  109. LIMIT 1
  110. """,
  111. (item_id,),
  112. )
  113. return DecodeResult.model_validate(row)
  114. def save_knowledge_particle(
  115. self,
  116. *,
  117. item_id: UUID,
  118. particle_type: str,
  119. title: str,
  120. decode_result_id: UUID | None = None,
  121. parent_particle_id: UUID | None = None,
  122. content: dict[str, Any] | None = None,
  123. status: str = "draft",
  124. ) -> KnowledgeParticle:
  125. content = content or {}
  126. creation_stage = _stage_value(content.get("创作阶段"))
  127. if creation_stage is None:
  128. for step in content.get("steps") or []:
  129. creation_stage = _stage_value(step.get("创作阶段"))
  130. if creation_stage:
  131. break
  132. row = self._one(
  133. """
  134. INSERT INTO knowledge_particles(
  135. decode_result_id, item_id, parent_particle_id, particle_type,
  136. title, business_stage, creation_stage, content, status
  137. )
  138. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
  139. RETURNING *
  140. """,
  141. (
  142. decode_result_id,
  143. item_id,
  144. parent_particle_id,
  145. particle_type,
  146. title,
  147. _stage_value(content.get("业务阶段")),
  148. creation_stage,
  149. _json(content),
  150. status,
  151. ),
  152. )
  153. return KnowledgeParticle.model_validate(row)
  154. def list_knowledge_particles(self, item_id: UUID | None = None) -> list[KnowledgeParticle]:
  155. if item_id is None:
  156. rows = self._all(
  157. "SELECT * FROM knowledge_particles ORDER BY created_at DESC",
  158. (),
  159. )
  160. else:
  161. rows = self._all(
  162. """
  163. SELECT * FROM knowledge_particles
  164. WHERE item_id = %s
  165. ORDER BY sort_order, created_at
  166. """,
  167. (item_id,),
  168. )
  169. return [KnowledgeParticle.model_validate(row) for row in rows]
  170. def save_scope_result(
  171. self,
  172. *,
  173. scope_type: str,
  174. scope_value: str,
  175. particle_id: UUID | None = None,
  176. item_id: UUID | None = None,
  177. evidence: dict[str, Any] | None = None,
  178. status: str = "draft",
  179. ) -> ScopeResult:
  180. evidence = evidence or {}
  181. row = self._one(
  182. """
  183. INSERT INTO scope_results(
  184. particle_id, item_id, scope_type, scope_value, is_reused,
  185. matched_scope_id, confidence, evidence, status
  186. )
  187. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
  188. RETURNING *
  189. """,
  190. (
  191. particle_id,
  192. item_id,
  193. scope_type,
  194. scope_value,
  195. evidence.get("is_reused"),
  196. evidence.get("matched_scope_id"),
  197. evidence.get("confidence"),
  198. _json(evidence),
  199. status,
  200. ),
  201. )
  202. return ScopeResult.model_validate(row)
  203. def list_scope_results(self, item_id: UUID | None = None) -> list[ScopeResult]:
  204. if item_id is None:
  205. rows = self._all("SELECT * FROM scope_results ORDER BY created_at DESC", ())
  206. else:
  207. rows = self._all(
  208. """
  209. SELECT * FROM scope_results
  210. WHERE item_id = %s
  211. ORDER BY created_at
  212. """,
  213. (item_id,),
  214. )
  215. return [ScopeResult.model_validate(row) for row in rows]
  216. def save_payload_draft(
  217. self,
  218. *,
  219. payload: dict[str, Any],
  220. particle_id: UUID | None = None,
  221. item_id: UUID | None = None,
  222. review_status: str = "pending",
  223. ingest_ready: bool = False,
  224. status: str = "draft",
  225. ) -> PayloadDraft:
  226. row = self._one(
  227. """
  228. INSERT INTO payload_drafts(
  229. particle_id, item_id, payload, review_status, ingest_ready, status
  230. )
  231. VALUES (%s, %s, %s, %s, %s, %s)
  232. RETURNING *
  233. """,
  234. (particle_id, item_id, _json(payload), review_status, ingest_ready, status),
  235. )
  236. return PayloadDraft.model_validate(row)
  237. def list_payload_drafts(self, item_id: UUID | None = None) -> list[PayloadDraft]:
  238. if item_id is None:
  239. rows = self._all(
  240. "SELECT * FROM payload_drafts ORDER BY created_at DESC",
  241. (),
  242. )
  243. else:
  244. rows = self._all(
  245. """
  246. SELECT * FROM payload_drafts
  247. WHERE item_id = %s
  248. ORDER BY created_at
  249. """,
  250. (item_id,),
  251. )
  252. return [PayloadDraft.model_validate(row) for row in rows]
  253. def mark_payload_draft_ingested(self, payload_draft_id: UUID) -> PayloadDraft:
  254. row = self._one(
  255. """
  256. UPDATE payload_drafts
  257. SET review_status = 'approved',
  258. ingest_ready = true,
  259. status = 'ingested',
  260. error_message = NULL
  261. WHERE id = %s
  262. RETURNING *
  263. """,
  264. (payload_draft_id,),
  265. )
  266. return PayloadDraft.model_validate(row)
  267. def save_ingest_record(
  268. self,
  269. *,
  270. payload_draft_id: UUID | None = None,
  271. target_system: str,
  272. target_id: str | None = None,
  273. status: str = "pending",
  274. response_payload: dict[str, Any] | None = None,
  275. error_message: str | None = None,
  276. ) -> IngestRecord:
  277. row = self._one(
  278. """
  279. INSERT INTO ingest_records(
  280. payload_draft_id, target_system, target_id, status,
  281. attempt_count, response_payload, error_message
  282. )
  283. VALUES (%s, %s, %s, %s, 1, %s, %s)
  284. RETURNING *
  285. """,
  286. (
  287. payload_draft_id,
  288. target_system,
  289. target_id,
  290. status,
  291. _json(response_payload),
  292. error_message,
  293. ),
  294. )
  295. return IngestRecord.model_validate(row)
  296. def list_ingest_records(self, item_id: UUID | None = None) -> list[IngestRecord]:
  297. if item_id is None:
  298. rows = self._all("SELECT * FROM ingest_records ORDER BY created_at DESC", ())
  299. else:
  300. rows = self._all(
  301. """
  302. SELECT ir.* FROM ingest_records ir
  303. JOIN payload_drafts pd ON pd.id = ir.payload_draft_id
  304. WHERE pd.item_id = %s
  305. ORDER BY ir.created_at
  306. """,
  307. (item_id,),
  308. )
  309. return [IngestRecord.model_validate(row) for row in rows]
  310. def save_contract_snapshot(
  311. self,
  312. *,
  313. contract_name: str,
  314. contract_type: str,
  315. version_label: str | None = None,
  316. content_hash: str | None = None,
  317. source_path: str | None = None,
  318. snapshot: dict[str, Any] | None = None,
  319. ) -> ContractSnapshot:
  320. row = self._one(
  321. """
  322. INSERT INTO contract_snapshots(
  323. contract_name, contract_type, version_label, content_hash,
  324. source_path, snapshot
  325. )
  326. VALUES (%s, %s, %s, %s, %s, %s)
  327. RETURNING *
  328. """,
  329. (
  330. contract_name,
  331. contract_type,
  332. version_label,
  333. content_hash,
  334. source_path,
  335. _json(snapshot),
  336. ),
  337. )
  338. return ContractSnapshot.model_validate(row)