feat(stage21): implement reproducibility — research_run_hash, full provenance tracking
- compute_research_run_hash(): deterministic SHA256 from query+budget+created_at - ProvenanceEntry / ProvenanceLog: step-by-step trace with inputs, outputs, metadata, errors - ResearchRun model: research_run_hash field (64-char hex) - ResearchRunProvenance SQLAlchemy table for DB persistence - Orchestrator: auto-log provenance before/after each pipeline step - 35 tests: hash determinism, entry/log methods, storage model, integration
This commit is contained in:
@@ -6,10 +6,19 @@ from .context_budget import (
|
||||
ContextBudgetTracker,
|
||||
)
|
||||
from .models import ResearchRun
|
||||
from .state import ResearchRunState, StateMachine
|
||||
from .orchestrator import ResearchOrchestrator
|
||||
from ..provenance import ProvenanceEntry, ProvenanceLog, compute_research_run_hash
|
||||
|
||||
__all__ = [
|
||||
"ContextBudgetConfig",
|
||||
"ContextBudgetExhaustedError",
|
||||
"ContextBudgetTracker",
|
||||
"ResearchRun",
|
||||
"ResearchRunState",
|
||||
"StateMachine",
|
||||
"ResearchOrchestrator",
|
||||
"ProvenanceEntry",
|
||||
"ProvenanceLog",
|
||||
"compute_research_run_hash",
|
||||
]
|
||||
@@ -65,5 +65,11 @@ class ResearchRun(BaseModel):
|
||||
default=0,
|
||||
description="Number of claims that were extracted.",
|
||||
)
|
||||
research_run_hash: str = Field(
|
||||
default="",
|
||||
min_length=64,
|
||||
max_length=64,
|
||||
description="Deterministic SHA256 hash of query + budget + created_at (Stage 21).",
|
||||
)
|
||||
|
||||
model_config = {"frozen": True}
|
||||
@@ -39,6 +39,7 @@ from nsct.orchestration.models import ResearchRun
|
||||
from nsct.orchestration.state import ResearchRunState, StateMachine
|
||||
from nsct.providers.abstract import MultiProviderSearch, SearchProvider
|
||||
from nsct.providers.priority_queue import Priority
|
||||
from nsct.provenance import ProvenanceEntry, ProvenanceLog, compute_research_run_hash
|
||||
|
||||
try:
|
||||
from nsct.stages.stage9_synthesis import SynthesisStage
|
||||
@@ -125,6 +126,10 @@ class ResearchOrchestrator:
|
||||
self._multi_search: MultiProviderSearch | None = None
|
||||
self._llm_provider = None
|
||||
|
||||
# Provenance (Stage 21)
|
||||
self._provenance: ProvenanceLog | None = None
|
||||
self._research_run_hash: str = ""
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Properties
|
||||
# ---------------------------------------------------------------
|
||||
@@ -326,7 +331,7 @@ class ResearchOrchestrator:
|
||||
"""Erstelle den ResearchRun und initialisiere die Pipeline.
|
||||
|
||||
Setzt State auf CREATED, erstellt ResearchRun-Instanz,
|
||||
initialisiert Budget-Tracker und State Machine.
|
||||
initialisiert Budget-Tracker, State Machine und Provenance.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -341,11 +346,39 @@ class ResearchOrchestrator:
|
||||
self._run = self._create_research_run()
|
||||
self._transition_to("created")
|
||||
|
||||
# Provenance initialisieren (Stage 21)
|
||||
budget_dict = self._budget_config.model_dump()
|
||||
self._research_run_hash = compute_research_run_hash(
|
||||
self._query, budget_dict, self._run.created_at.isoformat()
|
||||
)
|
||||
self._provenance = ProvenanceLog(research_run_hash=self._research_run_hash)
|
||||
|
||||
# Start-Eintrag
|
||||
start_entry = ProvenanceEntry(
|
||||
step="pipeline_start",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
inputs={
|
||||
"query": self._query,
|
||||
"depth": self._depth,
|
||||
"created_at": self._run.created_at.isoformat(),
|
||||
},
|
||||
outputs={
|
||||
"run_id": str(self._run.id),
|
||||
"research_run_hash": self._research_run_hash,
|
||||
},
|
||||
metadata={
|
||||
"stage": "21",
|
||||
"feature": "reproducibility",
|
||||
},
|
||||
)
|
||||
self._provenance.add(start_entry)
|
||||
|
||||
logger.info(
|
||||
"Research run started: id=%s, query=%s, depth=%s",
|
||||
"Research run started: id=%s, query=%s, depth=%s, hash=%s",
|
||||
self._run.id,
|
||||
self._query[:60],
|
||||
self._depth,
|
||||
self._research_run_hash,
|
||||
)
|
||||
return self._run
|
||||
|
||||
@@ -905,6 +938,126 @@ class ResearchOrchestrator:
|
||||
"error": "Planner completely failed — using minimal fallback",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Stage 21 — Provenance helpers
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def _log_step_entry(self, step_name: str) -> None:
|
||||
"""Logge Provenance-Eintrag bei Schritt-Start.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
step_name : str
|
||||
Name des Schritts (z.B. "planning").
|
||||
"""
|
||||
if self._provenance is None:
|
||||
return
|
||||
entry = ProvenanceEntry(
|
||||
step=f"stage_{step_name}",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
inputs={
|
||||
"step": step_name,
|
||||
"run_id": str(self._run.id) if self._run else None,
|
||||
},
|
||||
outputs={},
|
||||
)
|
||||
self._provenance.add(entry)
|
||||
|
||||
def _log_step_exit(self, step_name: str, result: dict[str, Any]) -> None:
|
||||
"""Logge Provenance-Eintrag bei Schritt-Ende.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
step_name : str
|
||||
Name des Schritts.
|
||||
result : dict
|
||||
Ergebnis-Dict vom Schritt.
|
||||
"""
|
||||
if self._provenance is None:
|
||||
return
|
||||
success = result.get("success", False)
|
||||
outputs: dict[str, Any] = {
|
||||
"success": success,
|
||||
"step": step_name,
|
||||
}
|
||||
# Extrahiere nützliche Counts/Referenzen
|
||||
for key in ("plan", "url_count", "source_count", "claim_count"):
|
||||
if key in result:
|
||||
outputs[key] = result[key]
|
||||
if "data" in result and isinstance(result["data"], dict):
|
||||
data = result["data"]
|
||||
if "plan" in data and isinstance(data["plan"], dict):
|
||||
outputs["plan_topics"] = data["plan"].get("topic", "")[:80]
|
||||
if "urls" in data:
|
||||
outputs["url_count"] = len(data["urls"])
|
||||
if "sources" in data:
|
||||
outputs["source_count"] = len(data["sources"])
|
||||
if "claims" in data:
|
||||
outputs["claim_count"] = len(data["claims"])
|
||||
|
||||
error_str = result.get("error", None)
|
||||
if error_str is None and not success:
|
||||
error_str = f"step_failed"
|
||||
|
||||
entry = ProvenanceEntry(
|
||||
step=f"stage_{step_name}",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
inputs={"step": step_name},
|
||||
outputs=outputs,
|
||||
error=error_str,
|
||||
)
|
||||
self._provenance.add(entry)
|
||||
|
||||
def get_provenance(self) -> dict[str, Any]:
|
||||
"""Gibt den aktuellen Provenance-Log als Dict zurück.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Vollständiger Provenance-Log mit research_run_hash, entries und total_steps.
|
||||
"""
|
||||
if self._provenance is None:
|
||||
return ProvenanceLog(research_run_hash=self._research_run_hash).to_dict()
|
||||
return self._provenance.to_dict()
|
||||
|
||||
async def save_provenance_to_db(self, db_session, research_run_id: str) -> int:
|
||||
"""Serialisiere alle Provenance-Einträge und speichere sie in der DB.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db_session : sqlalchemy.AsyncSession
|
||||
Datenbank-Sitzung.
|
||||
research_run_id : str
|
||||
UUID als String fuer die Zuordnung.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
Anzahl gespeicherter Einträge.
|
||||
"""
|
||||
if self._provenance is None:
|
||||
return 0
|
||||
|
||||
from nsct.storage.models import ResearchRunProvenanceModel
|
||||
|
||||
saved = 0
|
||||
for entry in self._provenance.entries:
|
||||
row = ResearchRunProvenanceModel(
|
||||
research_run_id=research_run_id,
|
||||
research_run_hash=self._research_run_hash,
|
||||
step=entry.step,
|
||||
timestamp=entry.timestamp,
|
||||
inputs_json=entry.inputs,
|
||||
outputs_json=entry.outputs,
|
||||
error_json=entry.error,
|
||||
metadata_json=entry.metadata,
|
||||
)
|
||||
db_session.add(row)
|
||||
saved += 1
|
||||
|
||||
await db_session.commit()
|
||||
return saved
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
162
src/nsct/provenance.py
Normal file
162
src/nsct/provenance.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""Provenance-Modul für NSCT — research_run_hash und Provenance-Tracking (Stage 21).
|
||||
|
||||
Berechnet deterministische SHA256-Hashes fuer Research-Runs und trackt
|
||||
schrittweise Provenance-Einträge mit timestamps, inputs und outputs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def compute_research_run_hash(query: str, budget_config: dict, created_at: str) -> str:
|
||||
"""Berechne einen deterministischen SHA256-Hash fuer einen Research-Run.
|
||||
|
||||
Der Hash wird aus drei deterministischen Komponenten zusammengesetzt:
|
||||
1. Query (stripped, trim)
|
||||
2. Budget-Config (sortierte JSON-Serialisierung)
|
||||
3. created_at (ISO-String)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
query : str
|
||||
Die Forschungsfrage / Query.
|
||||
budget_config : dict
|
||||
Budget-Konfiguration als Dict.
|
||||
created_at : str
|
||||
ISO-String des Erstellungszeitpunkts.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
64 Zeichen langer Hex-Hash (SHA256).
|
||||
"""
|
||||
stripped_query = query.strip()
|
||||
|
||||
budget_json = json.dumps(budget_config, sort_keys=True, ensure_ascii=False)
|
||||
|
||||
raw = f"{stripped_query}|{budget_json}|{created_at}"
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProvenanceEntry:
|
||||
"""Ein einzelner Provenance-Eintrag fuer einen Pipeline-Schritt.
|
||||
|
||||
Felder
|
||||
------
|
||||
step : str
|
||||
Name des Schritts (z.B. "stage4_planning").
|
||||
timestamp : datetime
|
||||
Zeitpunkt des Eintrags (timezone.utc).
|
||||
inputs : dict
|
||||
Minimale Eingabe-Daten.
|
||||
outputs : dict
|
||||
Ausgabe-Daten (Referenzen, Counts).
|
||||
metadata : dict
|
||||
Beliebige Zusatzinfos.
|
||||
error : str | None
|
||||
Fehlermeldung, falls der Schritt fehlschlug.
|
||||
"""
|
||||
|
||||
step: str
|
||||
timestamp: datetime
|
||||
inputs: dict
|
||||
outputs: dict
|
||||
metadata: dict = field(default_factory=dict)
|
||||
error: str | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Konvertiere den Eintrag in ein serialisierbares Dict.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Serialisierbarer Dict repraesentation.
|
||||
"""
|
||||
return {
|
||||
"step": self.step,
|
||||
"timestamp": self.timestamp.isoformat() if self.timestamp else None,
|
||||
"inputs": self.inputs,
|
||||
"outputs": self.outputs,
|
||||
"metadata": self.metadata,
|
||||
"error": self.error,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProvenanceLog:
|
||||
"""Sammelt Provenance-Einträge fuer einen Research-Run.
|
||||
|
||||
Felder
|
||||
------
|
||||
research_run_hash : str
|
||||
Der deterministische Hash des Runs.
|
||||
entries : list[ProvenanceEntry]
|
||||
Liste aller Provenance-Einträge.
|
||||
"""
|
||||
|
||||
research_run_hash: str
|
||||
entries: list[ProvenanceEntry] = field(default_factory=list)
|
||||
|
||||
def add(self, entry: ProvenanceEntry) -> None:
|
||||
"""Fuege einen Provenance-Eintrag hinzu.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entry : ProvenanceEntry
|
||||
Der hinzuzufuegende Eintrag.
|
||||
"""
|
||||
self.entries.append(entry)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Konvertiere den gesamten Log in ein serialisierbares Dict.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Full provenance log mit allen Eintraegen und Metadaten.
|
||||
"""
|
||||
return {
|
||||
"research_run_hash": self.research_run_hash,
|
||||
"entries": [e.to_dict() for e in self.entries],
|
||||
"total_steps": len(self.entries),
|
||||
}
|
||||
|
||||
def get_entries_by_step(self, step: str) -> list[ProvenanceEntry]:
|
||||
"""Filtere Eintraege nach Schritt-Name.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
step : str
|
||||
Name des Schritts zum Filtern.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[ProvenanceEntry]
|
||||
Alle Eintraege fuer den gegebenen Schritt.
|
||||
"""
|
||||
return [e for e in self.entries if e.step == step]
|
||||
|
||||
def get_last_entry(self) -> ProvenanceEntry | None:
|
||||
"""Gib den letzten Eintrag zurueck.
|
||||
|
||||
Returns
|
||||
-------
|
||||
ProvenanceEntry | None
|
||||
Der letzte Eintrag oder None wenn leer.
|
||||
"""
|
||||
return self.entries[-1] if self.entries else None
|
||||
|
||||
def has_entries(self) -> bool:
|
||||
"""Pruefe ob Eintraege vorhanden sind.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True wenn mindestens ein Eintrag existiert.
|
||||
"""
|
||||
return len(self.entries) > 0
|
||||
@@ -754,4 +754,30 @@ class AudioClaimModel(Base):
|
||||
Index("ix_audio_claims_transcript_id", "transcript_id"),
|
||||
Index("ix_audio_claims_claim_type", "claim_type"),
|
||||
Index("ix_audio_claims_speaker_id", "speaker_id"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 21 — Research Run Provenance (Reproduzierbarkeit)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ResearchRunProvenanceModel(Base):
|
||||
"""Provenance-Table für vollständige Nachverfolgbarkeit aller Schritte (Stage 21)."""
|
||||
|
||||
__tablename__ = "research_run_provenance"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
research_run_id = Column(String(36), nullable=False, index=True)
|
||||
research_run_hash = Column(String(64), nullable=False, index=True)
|
||||
step = Column(String(128), nullable=False)
|
||||
timestamp = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
inputs_json = Column(JSON, nullable=True)
|
||||
outputs_json = Column(JSON, nullable=True)
|
||||
error_json = Column(JSON, nullable=True)
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_provenance_run_step", "research_run_id", "step"),
|
||||
Index("ix_provenance_run_timestamp", "research_run_id", "timestamp"),
|
||||
)
|
||||
Reference in New Issue
Block a user