postgres.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  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 mark_running_decode_jobs_failed(self, item_id: UUID, error_message: str) -> None:
  115. with self.conn.cursor() as cur:
  116. cur.execute(
  117. """
  118. UPDATE decode_jobs
  119. SET status = 'failed', finished_at = now(), error_message = %s
  120. WHERE item_id = %s AND status = 'running'
  121. """,
  122. (error_message, item_id),
  123. )
  124. def save_knowledge_particle(
  125. self,
  126. *,
  127. item_id: UUID,
  128. particle_type: str,
  129. title: str,
  130. decode_result_id: UUID | None = None,
  131. parent_particle_id: UUID | None = None,
  132. content: dict[str, Any] | None = None,
  133. status: str = "draft",
  134. ) -> KnowledgeParticle:
  135. content = content or {}
  136. creation_stage = _stage_value(content.get("创作阶段"))
  137. if creation_stage is None:
  138. for step in content.get("steps") or []:
  139. creation_stage = _stage_value(step.get("创作阶段"))
  140. if creation_stage:
  141. break
  142. row = self._one(
  143. """
  144. INSERT INTO knowledge_particles(
  145. decode_result_id, item_id, parent_particle_id, particle_type,
  146. title, business_stage, creation_stage, content, status
  147. )
  148. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
  149. RETURNING *
  150. """,
  151. (
  152. decode_result_id,
  153. item_id,
  154. parent_particle_id,
  155. particle_type,
  156. title,
  157. _stage_value(content.get("业务阶段")),
  158. creation_stage,
  159. _json(content),
  160. status,
  161. ),
  162. )
  163. return KnowledgeParticle.model_validate(row)
  164. def list_knowledge_particles(self, item_id: UUID | None = None) -> list[KnowledgeParticle]:
  165. if item_id is None:
  166. rows = self._all(
  167. "SELECT * FROM knowledge_particles ORDER BY created_at DESC",
  168. (),
  169. )
  170. else:
  171. rows = self._all(
  172. """
  173. SELECT * FROM knowledge_particles
  174. WHERE item_id = %s
  175. ORDER BY sort_order, created_at
  176. """,
  177. (item_id,),
  178. )
  179. return [KnowledgeParticle.model_validate(row) for row in rows]
  180. def save_scope_result(
  181. self,
  182. *,
  183. scope_type: str,
  184. scope_value: str,
  185. particle_id: UUID | None = None,
  186. item_id: UUID | None = None,
  187. evidence: dict[str, Any] | None = None,
  188. status: str = "draft",
  189. ) -> ScopeResult:
  190. evidence = evidence or {}
  191. row = self._one(
  192. """
  193. INSERT INTO scope_results(
  194. particle_id, item_id, scope_type, scope_value, is_reused,
  195. matched_scope_id, confidence, evidence, status
  196. )
  197. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
  198. RETURNING *
  199. """,
  200. (
  201. particle_id,
  202. item_id,
  203. scope_type,
  204. scope_value,
  205. evidence.get("is_reused"),
  206. evidence.get("matched_scope_id"),
  207. evidence.get("confidence"),
  208. _json(evidence),
  209. status,
  210. ),
  211. )
  212. return ScopeResult.model_validate(row)
  213. def list_scope_results(self, item_id: UUID | None = None) -> list[ScopeResult]:
  214. if item_id is None:
  215. rows = self._all("SELECT * FROM scope_results ORDER BY created_at DESC", ())
  216. else:
  217. rows = self._all(
  218. """
  219. SELECT * FROM scope_results
  220. WHERE item_id = %s
  221. ORDER BY created_at
  222. """,
  223. (item_id,),
  224. )
  225. return [ScopeResult.model_validate(row) for row in rows]
  226. def save_payload_draft(
  227. self,
  228. *,
  229. payload: dict[str, Any],
  230. particle_id: UUID | None = None,
  231. item_id: UUID | None = None,
  232. review_status: str = "pending",
  233. ingest_ready: bool = False,
  234. status: str = "draft",
  235. ) -> PayloadDraft:
  236. row = self._one(
  237. """
  238. INSERT INTO payload_drafts(
  239. particle_id, item_id, payload, review_status, ingest_ready, status
  240. )
  241. VALUES (%s, %s, %s, %s, %s, %s)
  242. RETURNING *
  243. """,
  244. (particle_id, item_id, _json(payload), review_status, ingest_ready, status),
  245. )
  246. return PayloadDraft.model_validate(row)
  247. def list_payload_drafts(self, item_id: UUID | None = None) -> list[PayloadDraft]:
  248. if item_id is None:
  249. rows = self._all(
  250. "SELECT * FROM payload_drafts ORDER BY created_at DESC",
  251. (),
  252. )
  253. else:
  254. rows = self._all(
  255. """
  256. SELECT * FROM payload_drafts
  257. WHERE item_id = %s
  258. ORDER BY created_at
  259. """,
  260. (item_id,),
  261. )
  262. return [PayloadDraft.model_validate(row) for row in rows]
  263. def mark_payload_draft_ingested(self, payload_draft_id: UUID) -> PayloadDraft:
  264. row = self._one(
  265. """
  266. UPDATE payload_drafts
  267. SET review_status = 'approved',
  268. ingest_ready = true,
  269. status = 'ingested',
  270. error_message = NULL
  271. WHERE id = %s
  272. RETURNING *
  273. """,
  274. (payload_draft_id,),
  275. )
  276. return PayloadDraft.model_validate(row)
  277. def save_ingest_record(
  278. self,
  279. *,
  280. payload_draft_id: UUID | None = None,
  281. target_system: str,
  282. target_id: str | None = None,
  283. status: str = "pending",
  284. attempt_count: int = 1,
  285. response_payload: dict[str, Any] | None = None,
  286. error_message: str | None = None,
  287. ) -> IngestRecord:
  288. row = self._one(
  289. """
  290. INSERT INTO ingest_records(
  291. payload_draft_id, target_system, target_id, status,
  292. attempt_count, response_payload, error_message
  293. )
  294. VALUES (%s, %s, %s, %s, %s, %s, %s)
  295. RETURNING *
  296. """,
  297. (
  298. payload_draft_id,
  299. target_system,
  300. target_id,
  301. status,
  302. attempt_count,
  303. _json(response_payload),
  304. error_message,
  305. ),
  306. )
  307. return IngestRecord.model_validate(row)
  308. def list_ingest_records(self, item_id: UUID | None = None) -> list[IngestRecord]:
  309. if item_id is None:
  310. rows = self._all("SELECT * FROM ingest_records ORDER BY created_at DESC", ())
  311. else:
  312. rows = self._all(
  313. """
  314. SELECT ir.* FROM ingest_records ir
  315. JOIN payload_drafts pd ON pd.id = ir.payload_draft_id
  316. WHERE pd.item_id = %s
  317. ORDER BY ir.created_at
  318. """,
  319. (item_id,),
  320. )
  321. return [IngestRecord.model_validate(row) for row in rows]
  322. def save_contract_snapshot(
  323. self,
  324. *,
  325. contract_name: str,
  326. contract_type: str,
  327. version_label: str | None = None,
  328. content_hash: str | None = None,
  329. source_path: str | None = None,
  330. snapshot: dict[str, Any] | None = None,
  331. ) -> ContractSnapshot:
  332. row = self._one(
  333. """
  334. INSERT INTO contract_snapshots(
  335. contract_name, contract_type, version_label, content_hash,
  336. source_path, snapshot
  337. )
  338. VALUES (%s, %s, %s, %s, %s, %s)
  339. RETURNING *
  340. """,
  341. (
  342. contract_name,
  343. contract_type,
  344. version_label,
  345. content_hash,
  346. source_path,
  347. _json(snapshot),
  348. ),
  349. )
  350. return ContractSnapshot.model_validate(row)