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:
NSCT Agent
2026-09-05 14:27:45 +00:00
parent f9b761ced7
commit 8ea6269f9a
6 changed files with 800 additions and 2 deletions

View File

@@ -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",
]

View File

@@ -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}

View File

@@ -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
View 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

View File

@@ -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"),
)