docs
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
445
ontology_platform/ont_platform/storage/candidate_repository.py
Normal file
445
ontology_platform/ont_platform/storage/candidate_repository.py
Normal 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",
|
||||
]
|
||||
88
ontology_platform/ont_platform/storage/dedup_cache.py
Normal file
88
ontology_platform/ont_platform/storage/dedup_cache.py
Normal 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",
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user