This commit is contained in:
LASTA_DEV01\lasta
2026-05-19 20:31:52 +09:00
parent 00407e7a08
commit e260e5f218
104 changed files with 12898 additions and 1709 deletions

View File

@@ -1,7 +1,51 @@
"""Storage module (Phase 1+).
"""Storage module for source documents and candidate review queues."""
Phase 0: No database storage yet.
Phase 1: Add SQLAlchemy models for candidate storage.
"""
from ont_platform.storage.candidate_repository import (
CandidateBatch,
CandidateNotFoundError,
CandidateRepository,
)
from ont_platform.storage.models import (
Base,
CandidateEntity,
CandidateKind,
CandidateRelation,
CandidateSource,
EvidenceSpan,
MaintenanceProposal,
MaintenanceProposalStatus,
MaintenanceRole,
MaintenanceRun,
MaintenanceRunStatus,
ProjectionStatus,
ProjectionSyncState,
ReviewDecision,
ReviewStatus,
SourceDocument,
ValidationIssue,
ValidationSeverity,
)
__all__ = []
__all__ = [
"Base",
"CandidateBatch",
"CandidateEntity",
"CandidateKind",
"CandidateNotFoundError",
"CandidateRelation",
"CandidateRepository",
"CandidateSource",
"EvidenceSpan",
"MaintenanceProposal",
"MaintenanceProposalStatus",
"MaintenanceRole",
"MaintenanceRun",
"MaintenanceRunStatus",
"ProjectionStatus",
"ProjectionSyncState",
"ReviewDecision",
"ReviewStatus",
"SourceDocument",
"ValidationIssue",
"ValidationSeverity",
]

View File

@@ -0,0 +1,445 @@
"""Repository for Phase 2 candidate and review queue storage."""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from ont_platform.core.extraction.lightweight_extractor import ExtractionResult
from ont_platform.storage.models import (
CandidateEntity,
CandidateKind,
CandidateRelation,
CandidateSource,
EvidenceSpan,
ReviewDecision,
ReviewStatus,
ValidationIssue,
ValidationSeverity,
)
@dataclass
class CandidateBatch:
"""Candidates persisted from one extraction result."""
entities: list[CandidateEntity] = field(default_factory=list)
relations: list[CandidateRelation] = field(default_factory=list)
evidence_spans: list[EvidenceSpan] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return {
"entity_count": len(self.entities),
"relation_count": len(self.relations),
"evidence_span_count": len(self.evidence_spans),
"entity_ids": [entity.id for entity in self.entities],
"relation_ids": [relation.id for relation in self.relations],
}
class CandidateNotFoundError(LookupError):
"""Raised when a candidate cannot be found."""
class CandidateRepository:
"""SQLAlchemy-backed review queue repository."""
def __init__(self, db: Session) -> None:
self.db = db
def save_lightweight_result(
self,
*,
project_id: str,
document_id: str,
result: ExtractionResult | dict[str, Any],
source_trust: float = 0.5,
validation_passed: bool = True,
) -> CandidateBatch:
payload = _result_to_dict(result)
return self._save_candidate_payload(
project_id=project_id,
document_id=document_id,
payload=payload,
source_type=CandidateSource.LIGHTWEIGHT,
created_by="lightweight",
source_trust=source_trust,
validation_passed=validation_passed,
)
def save_ontocast_result(
self,
*,
project_id: str,
document_id: str,
result: dict[str, Any],
source_trust: float = 0.7,
validation_passed: bool = False,
) -> CandidateBatch:
return self._save_candidate_payload(
project_id=project_id,
document_id=document_id,
payload=result,
source_type=CandidateSource.ONTOCAST,
created_by="ontocast",
source_trust=source_trust,
validation_passed=validation_passed,
)
def get_candidate(
self,
*,
candidate_kind: CandidateKind | str,
candidate_id: str,
) -> CandidateEntity | CandidateRelation:
kind = CandidateKind(candidate_kind)
model = _model_for_kind(kind)
candidate = self.db.get(model, candidate_id)
if candidate is None:
raise CandidateNotFoundError(f"{kind.value} candidate not found: {candidate_id}")
return candidate
def list_candidates(
self,
*,
project_id: str,
status: ReviewStatus | str | None = None,
source_type: CandidateSource | str | None = None,
) -> dict[str, list[CandidateEntity] | list[CandidateRelation]]:
entity_stmt = select(CandidateEntity).where(CandidateEntity.project_id == project_id)
relation_stmt = select(CandidateRelation).where(CandidateRelation.project_id == project_id)
if status is not None:
review_status = ReviewStatus(status)
entity_stmt = entity_stmt.where(CandidateEntity.review_status == review_status)
relation_stmt = relation_stmt.where(CandidateRelation.review_status == review_status)
if source_type is not None:
candidate_source = CandidateSource(source_type)
entity_stmt = entity_stmt.where(CandidateEntity.source_type == candidate_source)
relation_stmt = relation_stmt.where(CandidateRelation.source_type == candidate_source)
return {
"entities": list(self.db.scalars(entity_stmt.order_by(CandidateEntity.created_at))),
"relations": list(self.db.scalars(relation_stmt.order_by(CandidateRelation.created_at))),
}
def evidence_ids_exist(
self,
*,
project_id: str,
document_id: str,
evidence_ids: list[str],
) -> bool:
if not evidence_ids:
return False
stmt = select(EvidenceSpan.id).where(
EvidenceSpan.project_id == project_id,
EvidenceSpan.document_id == document_id,
EvidenceSpan.id.in_(evidence_ids),
)
found = set(self.db.scalars(stmt))
return found == set(evidence_ids)
def candidate_has_valid_evidence(self, candidate: CandidateEntity | CandidateRelation) -> bool:
evidence_ids = list(candidate.evidence_ids or [])
return self.evidence_ids_exist(
project_id=candidate.project_id,
document_id=candidate.document_id,
evidence_ids=evidence_ids,
)
def set_review_status(
self,
*,
candidate: CandidateEntity | CandidateRelation,
candidate_kind: CandidateKind,
new_status: ReviewStatus,
reviewed_by: str,
reason: str | None = None,
metadata: dict[str, Any] | None = None,
) -> ReviewDecision:
previous_status = candidate.review_status
candidate.review_status = new_status
candidate.reviewed_by = reviewed_by
candidate.reviewed_at = _utcnow()
candidate.review_reason = reason
decision = ReviewDecision(
id=f"decision_{uuid.uuid4().hex}",
project_id=candidate.project_id,
candidate_id=candidate.id,
candidate_kind=candidate_kind,
previous_status=previous_status,
new_status=new_status,
reviewed_by=reviewed_by,
reason=reason,
metadata_=metadata or {},
)
self.db.add(decision)
self.db.flush()
return decision
def review_history(
self,
*,
candidate_kind: CandidateKind | str,
candidate_id: str,
) -> list[ReviewDecision]:
kind = CandidateKind(candidate_kind)
stmt = (
select(ReviewDecision)
.where(
ReviewDecision.candidate_kind == kind,
ReviewDecision.candidate_id == candidate_id,
)
.order_by(ReviewDecision.created_at)
)
return list(self.db.scalars(stmt))
def record_validation_issues(
self,
*,
project_id: str,
document_id: str | None,
issues: list[dict[str, Any] | str],
candidate_id: str | None = None,
candidate_kind: CandidateKind | str | None = None,
source: str = "validation",
) -> list[ValidationIssue]:
"""Persist validation failures for review UI/API tracing."""
saved: list[ValidationIssue] = []
normalized_kind = CandidateKind(candidate_kind) if candidate_kind else None
for issue in issues:
issue_data = _normalize_validation_issue(issue)
model = ValidationIssue(
id=issue_data.get("id") or f"issue_{uuid.uuid4().hex}",
project_id=project_id,
document_id=document_id,
candidate_id=issue_data.get("candidate_id") or candidate_id,
candidate_kind=(
CandidateKind(issue_data["candidate_kind"])
if issue_data.get("candidate_kind")
else normalized_kind
),
severity=ValidationSeverity(issue_data.get("severity", "error")),
code=issue_data.get("code") or "validation_error",
message=issue_data.get("message") or str(issue),
source=issue_data.get("source") or source,
metadata_=issue_data.get("metadata") or {},
)
self.db.add(model)
saved.append(model)
self.db.flush()
return saved
def list_validation_issues(
self,
*,
project_id: str,
document_id: str | None = None,
candidate_id: str | None = None,
) -> list[ValidationIssue]:
stmt = select(ValidationIssue).where(ValidationIssue.project_id == project_id)
if document_id is not None:
stmt = stmt.where(ValidationIssue.document_id == document_id)
if candidate_id is not None:
stmt = stmt.where(ValidationIssue.candidate_id == candidate_id)
return list(self.db.scalars(stmt.order_by(ValidationIssue.created_at)))
def _save_candidate_payload(
self,
*,
project_id: str,
document_id: str,
payload: dict[str, Any],
source_type: CandidateSource,
created_by: str,
source_trust: float,
validation_passed: bool,
) -> CandidateBatch:
evidence_spans = self._save_evidence_spans(
project_id=project_id,
document_id=document_id,
spans=payload.get("evidence_spans") or [],
)
entities = [
self._save_entity(
project_id=project_id,
document_id=document_id,
entity=entity,
source_type=source_type,
created_by=created_by,
source_trust=source_trust,
validation_passed=validation_passed,
)
for entity in payload.get("entities") or []
]
relations = [
self._save_relation(
project_id=project_id,
document_id=document_id,
relation=relation,
source_type=source_type,
created_by=created_by,
source_trust=source_trust,
validation_passed=validation_passed,
)
for relation in payload.get("relations") or []
]
issue_payload = payload.get("validation_issues") or payload.get("validation_errors") or []
if issue_payload:
self.record_validation_issues(
project_id=project_id,
document_id=document_id,
issues=issue_payload,
source="candidate_ingest",
)
self.db.flush()
return CandidateBatch(
entities=entities,
relations=relations,
evidence_spans=evidence_spans,
)
def _save_evidence_spans(
self,
*,
project_id: str,
document_id: str,
spans: list[dict[str, Any]],
) -> list[EvidenceSpan]:
saved: list[EvidenceSpan] = []
for span in spans:
span_id = span.get("id") or f"ev_{uuid.uuid4().hex[:16]}"
existing = self.db.get(EvidenceSpan, span_id)
if existing is not None:
saved.append(existing)
continue
model = EvidenceSpan(
id=span_id,
document_id=span.get("document_id") or document_id,
project_id=span.get("project_id") or project_id,
text=span.get("text") or "",
start_offset=span.get("start_offset", 0),
end_offset=span.get("end_offset", 0),
)
self.db.add(model)
saved.append(model)
return saved
def _save_entity(
self,
*,
project_id: str,
document_id: str,
entity: dict[str, Any],
source_type: CandidateSource,
created_by: str,
source_trust: float,
validation_passed: bool,
) -> CandidateEntity:
entity_id = entity.get("id") or f"E_{uuid.uuid4().hex[:8]}"
existing = self.db.get(CandidateEntity, entity_id)
if existing is not None:
return existing
model = CandidateEntity(
id=entity_id,
project_id=project_id,
document_id=document_id,
label=entity.get("label") or entity.get("name") or entity_id,
entity_type=entity.get("entity_type") or entity.get("type") or "concept",
description=entity.get("description"),
source_type=source_type,
created_by=created_by,
confidence=float(entity.get("confidence", 0.5)),
source_trust=float(entity.get("source_trust", source_trust)),
validation_passed=bool(entity.get("validation_passed", validation_passed)),
evidence_ids=list(entity.get("evidence_ids") or []),
aliases=list(entity.get("aliases") or []),
review_status=ReviewStatus.PENDING,
metadata_={"source_type": source_type.value, "raw": entity},
)
self.db.add(model)
return model
def _save_relation(
self,
*,
project_id: str,
document_id: str,
relation: dict[str, Any],
source_type: CandidateSource,
created_by: str,
source_trust: float,
validation_passed: bool,
) -> CandidateRelation:
relation_id = relation.get("id") or f"R_{uuid.uuid4().hex[:8]}"
existing = self.db.get(CandidateRelation, relation_id)
if existing is not None:
return existing
model = CandidateRelation(
id=relation_id,
project_id=project_id,
document_id=document_id,
source_entity_id=relation.get("source_entity_id") or relation.get("source") or "",
predicate=relation.get("predicate") or relation.get("type") or "related_to",
target_entity_id=relation.get("target_entity_id") or relation.get("target") or "",
source_type=source_type,
created_by=created_by,
confidence=float(relation.get("confidence", 0.5)),
source_trust=float(relation.get("source_trust", source_trust)),
validation_passed=bool(relation.get("validation_passed", validation_passed)),
evidence_ids=list(relation.get("evidence_ids") or []),
review_status=ReviewStatus.PENDING,
metadata_={"source_type": source_type.value, "raw": relation},
)
self.db.add(model)
return model
def _result_to_dict(result: ExtractionResult | dict[str, Any]) -> dict[str, Any]:
if isinstance(result, dict):
return result
return {
"entities": result.entities,
"relations": result.relations,
"evidence_spans": result.evidence_spans,
"warnings": result.warnings,
}
def _model_for_kind(candidate_kind: CandidateKind):
return CandidateEntity if candidate_kind == CandidateKind.ENTITY else CandidateRelation
def _utcnow():
from datetime import datetime
return datetime.utcnow()
def _normalize_validation_issue(issue: dict[str, Any] | str) -> dict[str, Any]:
if isinstance(issue, dict):
data = dict(issue)
if "msg" in data and "message" not in data:
data["message"] = data["msg"]
return data
return {
"severity": "error",
"code": "validation_error",
"message": issue,
}
__all__ = [
"CandidateBatch",
"CandidateNotFoundError",
"CandidateRepository",
]

View File

@@ -0,0 +1,88 @@
"""Phase 1 in-memory document deduplication cache."""
from __future__ import annotations
from dataclasses import dataclass
from threading import RLock
@dataclass(frozen=True)
class DedupResult:
"""Result of checking whether a source document was already seen."""
is_duplicate: bool
key: str
document_id: str
existing_document_id: str | None = None
@property
def skipped_processing(self) -> bool:
return self.is_duplicate
def to_dict(self) -> dict[str, str | bool | None]:
return {
"is_duplicate": self.is_duplicate,
"key": self.key,
"document_id": self.document_id,
"existing_document_id": self.existing_document_id,
"skipped_processing": self.skipped_processing,
}
class InMemoryDedupCache:
"""Small process-local cache used until Phase 2 introduces durable storage."""
def __init__(self) -> None:
self._lock = RLock()
self._seen: dict[str, str] = {}
def check_and_remember(
self,
*,
project_id: str,
document_id: str,
content_hash: str,
fingerprint: str | None = None,
) -> DedupResult:
if not content_hash and not fingerprint:
raise ValueError("content_hash or fingerprint is required")
key_value = fingerprint or content_hash
key = f"{project_id}:{key_value}"
with self._lock:
existing_document_id = self._seen.get(key)
if existing_document_id is not None:
return DedupResult(
is_duplicate=True,
key=key,
document_id=document_id,
existing_document_id=existing_document_id,
)
self._seen[key] = document_id
return DedupResult(is_duplicate=False, key=key, document_id=document_id)
def clear(self) -> None:
with self._lock:
self._seen.clear()
def __len__(self) -> int:
return len(self._seen)
_DEFAULT_CACHE = InMemoryDedupCache()
def get_default_dedup_cache() -> InMemoryDedupCache:
return _DEFAULT_CACHE
def reset_default_dedup_cache() -> None:
_DEFAULT_CACHE.clear()
__all__ = [
"DedupResult",
"InMemoryDedupCache",
"get_default_dedup_cache",
"reset_default_dedup_cache",
]

View File

@@ -5,16 +5,16 @@ Holds extracted entity/relation candidates before final RDF conversion.
"""
from datetime import datetime
from enum import Enum
from typing import Any
from enum import StrEnum
from sqlalchemy import JSON, Column, DateTime, Float, Integer, String, Text, Enum as SQLEnum
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Integer, String, Text
from sqlalchemy import Enum as SQLEnum
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class ReviewStatus(str, Enum):
class ReviewStatus(StrEnum):
"""Review status of a candidate."""
PENDING = "pending" # Awaiting human review
@@ -23,6 +23,63 @@ class ReviewStatus(str, Enum):
REJECTED = "rejected" # Rejected by human
class CandidateSource(StrEnum):
"""Source path that produced a candidate."""
LIGHTWEIGHT = "lightweight"
ONTOCAST = "ontocast"
class CandidateKind(StrEnum):
"""Reviewable candidate kind."""
ENTITY = "entity"
RELATION = "relation"
class ValidationSeverity(StrEnum):
"""Severity of validation issue."""
ERROR = "error"
WARNING = "warning"
class ProjectionStatus(StrEnum):
"""Status of an RDF-to-Neo4j projection sync."""
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
class MaintenanceRunStatus(StrEnum):
"""Status of a maintenance loop run."""
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
class MaintenanceProposalStatus(StrEnum):
"""Human review state for maintenance proposals."""
PENDING_REVIEW = "pending_review"
APPROVED = "approved"
REJECTED = "rejected"
class MaintenanceRole(StrEnum):
"""Maintenance loop role name."""
ANALYST = "analyst"
RESEARCHER = "researcher"
CURATOR = "curator"
AUDITOR = "auditor"
FIXER = "fixer"
ADVISOR = "advisor"
class SourceDocument(Base):
"""Source document metadata."""
@@ -31,6 +88,7 @@ class SourceDocument(Base):
id = Column(String(255), primary_key=True)
project_id = Column(String(255), nullable=False, index=True)
source_url = Column(String(2048), nullable=True, index=True)
canonical_url = Column(String(2048), nullable=True)
file_path = Column(String(2048), nullable=True)
document_type = Column(String(50)) # "html", "pdf", "markdown", "docx", "inline_text"
@@ -39,15 +97,18 @@ class SourceDocument(Base):
publish_date = Column(String(50), nullable=True) # ISO-8601
language = Column(String(10), nullable=True)
sitename = Column(String(255), nullable=True)
description = Column(Text, nullable=True)
text = Column(Text)
raw_html = Column(Text, nullable=True)
body_xml = Column(Text, nullable=True)
content_hash = Column(String(64), unique=True, nullable=False, index=True)
fingerprint = Column(String(100), nullable=True, index=True)
retrieved_at = Column(DateTime, default=datetime.utcnow)
extracted_by = Column(String(100), default="trafilatura") # Source tool
metadata = Column(JSON, nullable=True) # Raw metadata
metadata_ = Column("metadata", JSON, nullable=True) # Raw metadata
created_at = Column(DateTime, default=datetime.utcnow)
@@ -80,9 +141,12 @@ class CandidateEntity(Base):
label = Column(String(512), nullable=False)
entity_type = Column(String(100), nullable=False) # "concept", "person", "org", etc.
description = Column(Text, nullable=True)
source_type = Column(SQLEnum(CandidateSource), default=CandidateSource.LIGHTWEIGHT, nullable=False, index=True)
created_by = Column(String(100), default="lightweight", nullable=False)
confidence = Column(Float, default=0.5) # 0.0 ~ 1.0
source_trust = Column(Float, default=0.5) # Trust in source
validation_passed = Column(Boolean, default=False, nullable=False)
evidence_ids = Column(JSON, nullable=True) # List of evidence span IDs
aliases = Column(JSON, nullable=True) # List of alternative names
@@ -92,10 +156,11 @@ class CandidateEntity(Base):
reviewed_at = Column(DateTime, nullable=True)
review_reason = Column(Text, nullable=True)
metadata = Column(JSON, nullable=True) # Raw LLM output, domain-specific fields
metadata_ = Column("metadata", JSON, nullable=True) # Raw LLM output, domain-specific fields
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
promoted_at = Column(DateTime, nullable=True)
class CandidateRelation(Base):
@@ -110,9 +175,12 @@ class CandidateRelation(Base):
source_entity_id = Column(String(255), nullable=False, index=True)
predicate = Column(String(255), nullable=False)
target_entity_id = Column(String(255), nullable=False, index=True)
source_type = Column(SQLEnum(CandidateSource), default=CandidateSource.LIGHTWEIGHT, nullable=False, index=True)
created_by = Column(String(100), default="lightweight", nullable=False)
confidence = Column(Float, default=0.5) # 0.0 ~ 1.0
source_trust = Column(Float, default=0.5)
validation_passed = Column(Boolean, default=False, nullable=False)
evidence_ids = Column(JSON, nullable=True) # List of evidence span IDs
@@ -121,10 +189,51 @@ class CandidateRelation(Base):
reviewed_at = Column(DateTime, nullable=True)
review_reason = Column(Text, nullable=True)
metadata = Column(JSON, nullable=True) # Raw LLM output
metadata_ = Column("metadata", JSON, nullable=True) # Raw LLM output
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
promoted_at = Column(DateTime, nullable=True)
class ReviewDecision(Base):
"""Audit trail for review status changes."""
__tablename__ = "review_decisions"
id = Column(String(255), primary_key=True)
project_id = Column(String(255), nullable=False, index=True)
candidate_id = Column(String(255), nullable=False, index=True)
candidate_kind = Column(SQLEnum(CandidateKind), nullable=False, index=True)
previous_status = Column(SQLEnum(ReviewStatus), nullable=True)
new_status = Column(SQLEnum(ReviewStatus), nullable=False, index=True)
reviewed_by = Column(String(255), nullable=False)
reason = Column(Text, nullable=True)
metadata_ = Column("metadata", JSON, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
class ValidationIssue(Base):
"""Structured validation issue stored for review and audit."""
__tablename__ = "validation_issues"
id = Column(String(255), primary_key=True)
project_id = Column(String(255), nullable=False, index=True)
document_id = Column(String(255), nullable=True, index=True)
candidate_id = Column(String(255), nullable=True, index=True)
candidate_kind = Column(SQLEnum(CandidateKind), nullable=True, index=True)
severity = Column(SQLEnum(ValidationSeverity), default=ValidationSeverity.ERROR, nullable=False)
code = Column(String(100), nullable=False, index=True)
message = Column(Text, nullable=False)
source = Column(String(100), default="validation", nullable=False)
metadata_ = Column("metadata", JSON, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
class ExtractionJob(Base):
@@ -149,6 +258,76 @@ class ExtractionJob(Base):
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
metadata = Column(JSON, nullable=True)
metadata_ = Column("metadata", JSON, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
class ProjectionSyncState(Base):
"""RDF canonical store to Neo4j projection/search sync state."""
__tablename__ = "projection_sync_states"
id = Column(String(255), primary_key=True)
project_id = Column(String(255), nullable=False, index=True)
canonical_store = Column(String(100), default="rdf_fuseki", nullable=False)
projection_store = Column(String(100), default="neo4j", nullable=False)
status = Column(SQLEnum(ProjectionStatus), default=ProjectionStatus.PENDING, index=True)
last_sync_at = Column(DateTime, nullable=True)
source_graph_hash = Column(String(128), nullable=True, index=True)
error_message = Column(Text, nullable=True)
metadata_ = Column("metadata", JSON, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class MaintenanceRun(Base):
"""One non-destructive maintenance loop run."""
__tablename__ = "maintenance_runs"
id = Column(String(255), primary_key=True)
project_id = Column(String(255), nullable=False, index=True)
status = Column(SQLEnum(MaintenanceRunStatus), default=MaintenanceRunStatus.RUNNING, index=True)
requested_by = Column(String(255), default="system", nullable=False)
started_at = Column(DateTime, default=datetime.utcnow)
completed_at = Column(DateTime, nullable=True)
error_message = Column(Text, nullable=True)
summary = Column(JSON, nullable=True)
budget_summary = Column(JSON, nullable=True)
audit_summary = Column(JSON, nullable=True)
metadata_ = Column("metadata", JSON, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
class MaintenanceProposal(Base):
"""Proposal created by the maintenance loop.
Proposals do not mutate graph/candidate state. They must be reviewed and
approved before any downstream execution layer can act on them.
"""
__tablename__ = "maintenance_proposals"
id = Column(String(255), primary_key=True)
run_id = Column(String(255), nullable=False, index=True)
project_id = Column(String(255), nullable=False, index=True)
role = Column(SQLEnum(MaintenanceRole), nullable=False, index=True)
proposal_type = Column(String(100), nullable=False, index=True)
title = Column(String(512), nullable=False)
description = Column(Text, nullable=True)
target_kind = Column(String(100), nullable=True, index=True)
target_id = Column(String(255), nullable=True, index=True)
risk_level = Column(String(50), default="low", nullable=False)
requires_human_approval = Column(Boolean, default=True, nullable=False)
status = Column(
SQLEnum(MaintenanceProposalStatus),
default=MaintenanceProposalStatus.PENDING_REVIEW,
index=True,
)
approved_by = Column(String(255), nullable=True)
approved_at = Column(DateTime, nullable=True)
rejection_reason = Column(Text, nullable=True)
metadata_ = Column("metadata", JSON, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)