|
|
@@ -0,0 +1,366 @@
|
|
|
+"""PostgreSQL implementation for formal decode-content state."""
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+from typing import Any
|
|
|
+from uuid import UUID
|
|
|
+
|
|
|
+import psycopg2.extras
|
|
|
+
|
|
|
+from decode_content.models import (
|
|
|
+ ContractSnapshot,
|
|
|
+ DecodeJob,
|
|
|
+ DecodeResult,
|
|
|
+ IngestRecord,
|
|
|
+ KnowledgeParticle,
|
|
|
+ PayloadDraft,
|
|
|
+ ScopeResult,
|
|
|
+)
|
|
|
+
|
|
|
+Json = psycopg2.extras.Json
|
|
|
+psycopg2.extras.register_uuid()
|
|
|
+
|
|
|
+
|
|
|
+def _json(value: dict[str, Any] | None) -> Json:
|
|
|
+ return Json(value or {})
|
|
|
+
|
|
|
+
|
|
|
+def _stage_value(value: Any) -> str | None:
|
|
|
+ if isinstance(value, list):
|
|
|
+ return " / ".join(str(item) for item in value if item)
|
|
|
+ if value:
|
|
|
+ return str(value)
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
+class PostgresDecodeRepository:
|
|
|
+ """Repository backed by the formal PostgreSQL schema.
|
|
|
+
|
|
|
+ Like the acquisition repository, this class owns no transaction boundary;
|
|
|
+ callers pass an open connection and commit/rollback outside.
|
|
|
+ """
|
|
|
+
|
|
|
+ def __init__(self, conn: Any):
|
|
|
+ self.conn = conn
|
|
|
+
|
|
|
+ def _one(self, sql: str, params: tuple[Any, ...]) -> dict[str, Any]:
|
|
|
+ with self.conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
|
+ cur.execute(sql, params)
|
|
|
+ row = cur.fetchone()
|
|
|
+ if row is None:
|
|
|
+ raise RuntimeError("expected one row, got none")
|
|
|
+ return dict(row)
|
|
|
+
|
|
|
+ def _one_or_none(self, sql: str, params: tuple[Any, ...]) -> dict[str, Any] | None:
|
|
|
+ with self.conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
|
+ cur.execute(sql, params)
|
|
|
+ row = cur.fetchone()
|
|
|
+ return dict(row) if row else None
|
|
|
+
|
|
|
+ def _all(self, sql: str, params: tuple[Any, ...]) -> list[dict[str, Any]]:
|
|
|
+ with self.conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
|
+ cur.execute(sql, params)
|
|
|
+ return [dict(row) for row in cur.fetchall()]
|
|
|
+
|
|
|
+ def create_decode_job(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ item_id: UUID,
|
|
|
+ status: str = "pending",
|
|
|
+ metadata: dict[str, Any] | None = None,
|
|
|
+ ) -> DecodeJob:
|
|
|
+ row = self._one(
|
|
|
+ """
|
|
|
+ INSERT INTO decode_jobs(item_id, status, metadata, started_at)
|
|
|
+ VALUES (%s, %s, %s, CASE WHEN %s = 'running' THEN now() ELSE NULL END)
|
|
|
+ RETURNING *
|
|
|
+ """,
|
|
|
+ (item_id, status, _json(metadata), status),
|
|
|
+ )
|
|
|
+ return DecodeJob.model_validate(row)
|
|
|
+
|
|
|
+ def save_decode_result(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ item_id: UUID,
|
|
|
+ decode_job_id: UUID | None = None,
|
|
|
+ read_result: dict[str, Any] | None = None,
|
|
|
+ gate_result: dict[str, Any] | None = None,
|
|
|
+ framing_result: dict[str, Any] | None = None,
|
|
|
+ status: str = "draft",
|
|
|
+ ) -> DecodeResult:
|
|
|
+ row = self._one(
|
|
|
+ """
|
|
|
+ INSERT INTO decode_results(
|
|
|
+ decode_job_id, item_id, read_result, gate_result,
|
|
|
+ framing_result, status
|
|
|
+ )
|
|
|
+ VALUES (%s, %s, %s, %s, %s, %s)
|
|
|
+ RETURNING *
|
|
|
+ """,
|
|
|
+ (
|
|
|
+ decode_job_id,
|
|
|
+ item_id,
|
|
|
+ _json(read_result),
|
|
|
+ _json(gate_result),
|
|
|
+ _json(framing_result),
|
|
|
+ status,
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ if decode_job_id:
|
|
|
+ self._one(
|
|
|
+ """
|
|
|
+ UPDATE decode_jobs
|
|
|
+ SET status = %s, finished_at = now(), error_message = NULL
|
|
|
+ WHERE id = %s
|
|
|
+ RETURNING *
|
|
|
+ """,
|
|
|
+ (status, decode_job_id),
|
|
|
+ )
|
|
|
+ return DecodeResult.model_validate(row)
|
|
|
+
|
|
|
+ def get_decode_result_for_item(self, item_id: UUID) -> DecodeResult:
|
|
|
+ row = self._one(
|
|
|
+ """
|
|
|
+ SELECT * FROM decode_results
|
|
|
+ WHERE item_id = %s
|
|
|
+ ORDER BY created_at DESC
|
|
|
+ LIMIT 1
|
|
|
+ """,
|
|
|
+ (item_id,),
|
|
|
+ )
|
|
|
+ return DecodeResult.model_validate(row)
|
|
|
+
|
|
|
+ def save_knowledge_particle(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ item_id: UUID,
|
|
|
+ particle_type: str,
|
|
|
+ title: str,
|
|
|
+ decode_result_id: UUID | None = None,
|
|
|
+ parent_particle_id: UUID | None = None,
|
|
|
+ content: dict[str, Any] | None = None,
|
|
|
+ status: str = "draft",
|
|
|
+ ) -> KnowledgeParticle:
|
|
|
+ content = content or {}
|
|
|
+ creation_stage = _stage_value(content.get("创作阶段"))
|
|
|
+ if creation_stage is None:
|
|
|
+ for step in content.get("steps") or []:
|
|
|
+ creation_stage = _stage_value(step.get("创作阶段"))
|
|
|
+ if creation_stage:
|
|
|
+ break
|
|
|
+ row = self._one(
|
|
|
+ """
|
|
|
+ INSERT INTO knowledge_particles(
|
|
|
+ decode_result_id, item_id, parent_particle_id, particle_type,
|
|
|
+ title, business_stage, creation_stage, content, status
|
|
|
+ )
|
|
|
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
|
+ RETURNING *
|
|
|
+ """,
|
|
|
+ (
|
|
|
+ decode_result_id,
|
|
|
+ item_id,
|
|
|
+ parent_particle_id,
|
|
|
+ particle_type,
|
|
|
+ title,
|
|
|
+ _stage_value(content.get("业务阶段")),
|
|
|
+ creation_stage,
|
|
|
+ _json(content),
|
|
|
+ status,
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ return KnowledgeParticle.model_validate(row)
|
|
|
+
|
|
|
+ def list_knowledge_particles(self, item_id: UUID | None = None) -> list[KnowledgeParticle]:
|
|
|
+ if item_id is None:
|
|
|
+ rows = self._all(
|
|
|
+ "SELECT * FROM knowledge_particles ORDER BY created_at DESC",
|
|
|
+ (),
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ rows = self._all(
|
|
|
+ """
|
|
|
+ SELECT * FROM knowledge_particles
|
|
|
+ WHERE item_id = %s
|
|
|
+ ORDER BY sort_order, created_at
|
|
|
+ """,
|
|
|
+ (item_id,),
|
|
|
+ )
|
|
|
+ return [KnowledgeParticle.model_validate(row) for row in rows]
|
|
|
+
|
|
|
+ def save_scope_result(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ scope_type: str,
|
|
|
+ scope_value: str,
|
|
|
+ particle_id: UUID | None = None,
|
|
|
+ item_id: UUID | None = None,
|
|
|
+ evidence: dict[str, Any] | None = None,
|
|
|
+ status: str = "draft",
|
|
|
+ ) -> ScopeResult:
|
|
|
+ evidence = evidence or {}
|
|
|
+ row = self._one(
|
|
|
+ """
|
|
|
+ INSERT INTO scope_results(
|
|
|
+ particle_id, item_id, scope_type, scope_value, is_reused,
|
|
|
+ matched_scope_id, confidence, evidence, status
|
|
|
+ )
|
|
|
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
|
+ RETURNING *
|
|
|
+ """,
|
|
|
+ (
|
|
|
+ particle_id,
|
|
|
+ item_id,
|
|
|
+ scope_type,
|
|
|
+ scope_value,
|
|
|
+ evidence.get("is_reused"),
|
|
|
+ evidence.get("matched_scope_id"),
|
|
|
+ evidence.get("confidence"),
|
|
|
+ _json(evidence),
|
|
|
+ status,
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ return ScopeResult.model_validate(row)
|
|
|
+
|
|
|
+ def list_scope_results(self, item_id: UUID | None = None) -> list[ScopeResult]:
|
|
|
+ if item_id is None:
|
|
|
+ rows = self._all("SELECT * FROM scope_results ORDER BY created_at DESC", ())
|
|
|
+ else:
|
|
|
+ rows = self._all(
|
|
|
+ """
|
|
|
+ SELECT * FROM scope_results
|
|
|
+ WHERE item_id = %s
|
|
|
+ ORDER BY created_at
|
|
|
+ """,
|
|
|
+ (item_id,),
|
|
|
+ )
|
|
|
+ return [ScopeResult.model_validate(row) for row in rows]
|
|
|
+
|
|
|
+ def save_payload_draft(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ payload: dict[str, Any],
|
|
|
+ particle_id: UUID | None = None,
|
|
|
+ item_id: UUID | None = None,
|
|
|
+ review_status: str = "pending",
|
|
|
+ ingest_ready: bool = False,
|
|
|
+ status: str = "draft",
|
|
|
+ ) -> PayloadDraft:
|
|
|
+ row = self._one(
|
|
|
+ """
|
|
|
+ INSERT INTO payload_drafts(
|
|
|
+ particle_id, item_id, payload, review_status, ingest_ready, status
|
|
|
+ )
|
|
|
+ VALUES (%s, %s, %s, %s, %s, %s)
|
|
|
+ RETURNING *
|
|
|
+ """,
|
|
|
+ (particle_id, item_id, _json(payload), review_status, ingest_ready, status),
|
|
|
+ )
|
|
|
+ return PayloadDraft.model_validate(row)
|
|
|
+
|
|
|
+ def list_payload_drafts(self, item_id: UUID | None = None) -> list[PayloadDraft]:
|
|
|
+ if item_id is None:
|
|
|
+ rows = self._all(
|
|
|
+ "SELECT * FROM payload_drafts ORDER BY created_at DESC",
|
|
|
+ (),
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ rows = self._all(
|
|
|
+ """
|
|
|
+ SELECT * FROM payload_drafts
|
|
|
+ WHERE item_id = %s
|
|
|
+ ORDER BY created_at
|
|
|
+ """,
|
|
|
+ (item_id,),
|
|
|
+ )
|
|
|
+ return [PayloadDraft.model_validate(row) for row in rows]
|
|
|
+
|
|
|
+ def mark_payload_draft_ingested(self, payload_draft_id: UUID) -> PayloadDraft:
|
|
|
+ row = self._one(
|
|
|
+ """
|
|
|
+ UPDATE payload_drafts
|
|
|
+ SET review_status = 'approved',
|
|
|
+ ingest_ready = true,
|
|
|
+ status = 'ingested',
|
|
|
+ error_message = NULL
|
|
|
+ WHERE id = %s
|
|
|
+ RETURNING *
|
|
|
+ """,
|
|
|
+ (payload_draft_id,),
|
|
|
+ )
|
|
|
+ return PayloadDraft.model_validate(row)
|
|
|
+
|
|
|
+ def save_ingest_record(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ payload_draft_id: UUID | None = None,
|
|
|
+ target_system: str,
|
|
|
+ target_id: str | None = None,
|
|
|
+ status: str = "pending",
|
|
|
+ response_payload: dict[str, Any] | None = None,
|
|
|
+ error_message: str | None = None,
|
|
|
+ ) -> IngestRecord:
|
|
|
+ row = self._one(
|
|
|
+ """
|
|
|
+ INSERT INTO ingest_records(
|
|
|
+ payload_draft_id, target_system, target_id, status,
|
|
|
+ attempt_count, response_payload, error_message
|
|
|
+ )
|
|
|
+ VALUES (%s, %s, %s, %s, 1, %s, %s)
|
|
|
+ RETURNING *
|
|
|
+ """,
|
|
|
+ (
|
|
|
+ payload_draft_id,
|
|
|
+ target_system,
|
|
|
+ target_id,
|
|
|
+ status,
|
|
|
+ _json(response_payload),
|
|
|
+ error_message,
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ return IngestRecord.model_validate(row)
|
|
|
+
|
|
|
+ def list_ingest_records(self, item_id: UUID | None = None) -> list[IngestRecord]:
|
|
|
+ if item_id is None:
|
|
|
+ rows = self._all("SELECT * FROM ingest_records ORDER BY created_at DESC", ())
|
|
|
+ else:
|
|
|
+ rows = self._all(
|
|
|
+ """
|
|
|
+ SELECT ir.* FROM ingest_records ir
|
|
|
+ JOIN payload_drafts pd ON pd.id = ir.payload_draft_id
|
|
|
+ WHERE pd.item_id = %s
|
|
|
+ ORDER BY ir.created_at
|
|
|
+ """,
|
|
|
+ (item_id,),
|
|
|
+ )
|
|
|
+ return [IngestRecord.model_validate(row) for row in rows]
|
|
|
+
|
|
|
+ def save_contract_snapshot(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ contract_name: str,
|
|
|
+ contract_type: str,
|
|
|
+ version_label: str | None = None,
|
|
|
+ content_hash: str | None = None,
|
|
|
+ source_path: str | None = None,
|
|
|
+ snapshot: dict[str, Any] | None = None,
|
|
|
+ ) -> ContractSnapshot:
|
|
|
+ row = self._one(
|
|
|
+ """
|
|
|
+ INSERT INTO contract_snapshots(
|
|
|
+ contract_name, contract_type, version_label, content_hash,
|
|
|
+ source_path, snapshot
|
|
|
+ )
|
|
|
+ VALUES (%s, %s, %s, %s, %s, %s)
|
|
|
+ RETURNING *
|
|
|
+ """,
|
|
|
+ (
|
|
|
+ contract_name,
|
|
|
+ contract_type,
|
|
|
+ version_label,
|
|
|
+ content_hash,
|
|
|
+ source_path,
|
|
|
+ _json(snapshot),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ return ContractSnapshot.model_validate(row)
|