diff --git a/src/nsct/api/rest_research.py b/src/nsct/api/rest_research.py index 0f35a6c..4a7fd6f 100644 --- a/src/nsct/api/rest_research.py +++ b/src/nsct/api/rest_research.py @@ -561,9 +561,15 @@ async def _run_pipeline(run: _ResearchRunState, request: ResearchRequest, budget if result.get("success"): run.state = ResearchRunState.COMPLETED.value run.report = result.get("report") - run.sources = result.get("sources", []) - run.claims = result.get("report", {}).get("claims", []) - run.evidence_scores = result.get("report", {}).get("evidence", []) + report_data = run.report if isinstance(run.report, dict) else {} + # The orchestrator owns the collected sources. Earlier code read + # a non-existent top-level field and therefore displayed zero + # sources even though claims with valid provenance were present. + run.sources = result.get("sources", report_data.get("sources", [])) + run.claims = result.get("claims", report_data.get("claims", [])) + run.evidence_scores = result.get( + "evidence_scores", report_data.get("evidence", []) + ) metrics.increment(C_RESEARCH_COMPLETED_TOTAL) metrics.increment(C_SOURCES_FETCHED_TOTAL, len(run.sources)) @@ -789,10 +795,31 @@ async def get_report(research_id: str) -> ReportResponse: ) report_data = run.report - findings = [ - FindingItem(**f) if isinstance(f, dict) else FindingItem(text=str(f)) - for f in report_data.get("findings", report_data.get("findings_json", [])) - ] + raw_findings = report_data.get("findings", report_data.get("findings_json")) + if raw_findings is None: + # Stage 9 separates high-confidence findings and uncertainty. The + # REST contract exposes one display list, so retain both categories + # rather than silently rendering a blank report. + raw_findings = [ + *report_data.get("confident_findings", []), + *report_data.get("uncertain_areas", []), + ] + findings = [] + for finding in raw_findings: + if not isinstance(finding, dict): + findings.append(FindingItem(text=str(finding))) + continue + if "text" in finding: + findings.append(FindingItem(**finding)) + continue + source_id = finding.get("source_id") + findings.append( + FindingItem( + text=str(finding.get("claim_text", "")), + confidence=float(finding.get("confidence", 0.0)), + source_ids=[str(source_id)] if source_id else [], + ) + ) return ReportResponse( research_id=run.research_id, @@ -801,9 +828,9 @@ async def get_report(research_id: str) -> ReportResponse: findings=findings, disagreements=report_data.get("disagreements", []), uncertainties=report_data.get("uncertainties", []), - source_statistics=report_data.get("source_statistics", {}), + source_statistics=report_data.get("source_statistics", {"total": len(run.sources)}), methodology=report_data.get("methodology", ""), - generated_at=report_data.get("generated_at", _now()), + generated_at=str(report_data.get("generated_at", report_data.get("generation_timestamp", _now()))), ) diff --git a/src/nsct/orchestration/orchestrator.py b/src/nsct/orchestration/orchestrator.py index e50f594..3aa0350 100644 --- a/src/nsct/orchestration/orchestrator.py +++ b/src/nsct/orchestration/orchestrator.py @@ -485,9 +485,25 @@ class ResearchOrchestrator: self._transition_to("completed") self._budget_tracker.record_time_elapsed() + # ``result`` is the synthesis result from the final pipeline step. + # Keep it instead of replacing it with the technical intermediate + # report below. The latter is useful provenance, but not the report + # contract consumed by the REST/UI layer. + synthesis_report = result.get("report") or result.get("data") or {} + if not isinstance(synthesis_report, dict): + synthesis_report = {} report = { "success": True, - "report": self._get_report(), + "report": { + **synthesis_report, + "sources": self._sources, + "claims": [claim.model_dump(mode="json") for claim in self._claims], + }, + "sources": self._sources, + "claims": [claim.model_dump(mode="json") for claim in self._claims], + "evidence_scores": list( + self._comparison_data.get("evidence_scores", {}).values() + ) if isinstance(getattr(self, "_comparison_data", {}), dict) else [], "state": self._state_machine.current_state.value, "budget_usage": self._budget_tracker.get_usage(), "plan": self._plan, @@ -897,7 +913,9 @@ class ResearchOrchestrator: return self._fallback_synthesis() # Claims als Dicts für SynthesisStage - claims_dicts = [c.model_dump() for c in self._claims] + # Stage 9 serializes this package for the LLM. JSON mode is + # essential: the claim model contains UUIDs and datetimes. + claims_dicts = [c.model_dump(mode="json") for c in self._claims] stage = SynthesisStage( research_run_id=self._run.id if self._run else uuid4(), @@ -906,7 +924,7 @@ class ResearchOrchestrator: claims=claims_dicts, ) - result = await stage.execute() + result = await stage.execute(topic=self._query) if result.success: logger.info("Synthesis complete: report generated successfully") @@ -918,8 +936,17 @@ class ResearchOrchestrator: "report": report, } else: - logger.warning("Synthesis returned success=False — generating fallback") - return self._fallback_synthesis_data(result.errors) + # Stage 9 supplies a provenance-preserving uncertainty report + # on failure. It is a successful degraded pipeline outcome, + # not an empty technical fallback. + logger.warning("Synthesis returned success=False — returning uncertainty report") + return { + "success": True, + "data": result.data, + "report": result.data, + "warning": "Synthesis degraded to uncertainty report", + "synthesis_errors": result.errors, + } except Exception as exc: logger.error("Synthesis failed: %s — generating fallback", exc) @@ -984,13 +1011,13 @@ class ResearchOrchestrator: def _get_report(self) -> dict[str, Any]: """Erzeuge den finalen Report aus allen Zwischenspeichern.""" report: dict[str, Any] = { - "run_id": self._run.id if self._run else None, + "run_id": str(self._run.id) if self._run else None, "query": self._query, "state": self._state_machine.current_state.value, "plan": self._plan, "search_results": self._search_results, "sources": self._sources, - "claims": [c.model_dump() for c in self._claims], + "claims": [c.model_dump(mode="json") for c in self._claims], "claim_count": len(self._claims), "source_count": len(self._sources), "budget_usage": self._budget_tracker.get_usage(), diff --git a/src/nsct/stages/stage9_synthesis.py b/src/nsct/stages/stage9_synthesis.py index 04c5796..3027e2c 100644 --- a/src/nsct/stages/stage9_synthesis.py +++ b/src/nsct/stages/stage9_synthesis.py @@ -320,13 +320,16 @@ def _build_evidence_package_json( # Index scores by claim_id for fast lookup score_map: dict[str, dict[str, Any]] = {} for sc in scores: - cid = sc.get("claim_id", "") + cid = str(sc.get("claim_id", sc.get("id", ""))) if cid: score_map[cid] = sc evidence_items = [] for claim in claims: - claim_id = claim.get("claim_id", "") + # Stage 5's canonical model field is ``id`` while older stage tests + # and score records use ``claim_id``. Normalize both at the boundary + # so an extracted claim never loses its provenance in Stage 9. + claim_id = str(claim.get("claim_id") or claim.get("id", "")) score = score_map.get(claim_id, {}) # Build evidence entry with full provenance @@ -335,7 +338,7 @@ def _build_evidence_package_json( "claim_text": claim.get("claim_text", ""), "claim_type": claim.get("claim_type", "claim"), "confidence": claim.get("confidence", 1.0), - "source_id": claim.get("source_id", ""), + "source_id": str(claim.get("source_id", "")), "source_url": claim.get("source_url", ""), "source_title": claim.get("source_title"), "evidence_span": claim.get("evidence_span"), @@ -596,7 +599,7 @@ class SynthesisStage(BaseStage): return StageResult( success=True, - data=report.model_dump(), + data=report.model_dump(mode="json"), stage=self, ) @@ -732,8 +735,11 @@ class Stage9Synthesis: async def run(self) -> dict[str, Any]: """Backward-compatible async .run() method.""" result = await self._stage.execute() - if not result.success: + # A failed LLM call can still yield Stage 9's valid, provenance- + # preserving uncertainty report. Only propagate a failure when no + # usable report was produced (for example, when there are no claims). + if not result.success and not result.data: raise RuntimeError( "Stage 9 synthesis failed: " + "; ".join(result.errors) ) - return result.data \ No newline at end of file + return result.data diff --git a/tests/test_backend_pipeline_repairs.py b/tests/test_backend_pipeline_repairs.py index 3adc8cb..d8bdcef 100644 --- a/tests/test_backend_pipeline_repairs.py +++ b/tests/test_backend_pipeline_repairs.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import json from uuid import uuid4 import httpx @@ -18,6 +19,8 @@ from nsct.providers.metrics import ProviderMetrics from nsct.providers.searxng import SearXNGProvider from nsct.crawler.normalize import NormalizedDocument from nsct.api.rest_research import DEPTH_CONFIGS +from nsct.models.claim import Claim, ClaimType +from nsct.stages.stage9_synthesis import _build_evidence_package_json def _config(*, searxng_base_url: str | None = "http://searxng:8080") -> AppSettings: @@ -206,6 +209,59 @@ def test_depth_time_budgets_allow_local_35b_inference() -> None: assert DEPTH_CONFIGS["deep"].max_research_duration_seconds == 7_200 +def test_synthesis_package_keeps_stage5_uuid_claim_provenance() -> None: + """Stage 9 must accept the actual, UUID-bearing Stage 5 claim model.""" + source_id = uuid4() + claim = Claim( + research_run_id=uuid4(), + source_id=source_id, + claim_text="Eine überprüfbare Behauptung.", + evidence_span="Die belegende Passage.", + claim_type=ClaimType.CLAIM, + source_url="https://example.org/source", + ) + + package = json.loads(_build_evidence_package_json([claim.model_dump()], [], "Thema")) + + assert package["evidence"][0]["claim_id"] == str(claim.id) + assert package["evidence"][0]["source_id"] == str(source_id) + assert package["sources"][0]["source_id"] == str(source_id) + + +def test_orchestrator_synthesis_returns_a_report_for_uuid_claims() -> None: + """The production hand-off to Stage 9 must not trigger UUID JSON errors.""" + async def run() -> None: + orchestrator = ResearchOrchestrator(_config(), uuid4(), "Testthema") + await orchestrator.start() + orchestrator._state_machine = StateMachine(ResearchRunState.COMPARING) + orchestrator._claims = [ + Claim( + research_run_id=uuid4(), + source_id=uuid4(), + claim_text="Eine überprüfbare Behauptung.", + evidence_span="Die belegende Passage.", + claim_type=ClaimType.CLAIM, + source_url="https://example.org/source", + ) + ] + provider = Mock() + provider.complete = AsyncMock(return_value=json.dumps({ + "summary": "Neutraler Bericht.", + "confident_findings": [], + "uncertain_areas": [], + "contradictions": [], + })) + orchestrator._llm_provider = provider + + response = await orchestrator._step_synthesizing() + + assert response["success"] is True + assert response["report"]["summary"] == "Neutraler Bericht." + provider.complete.assert_awaited_once() + + asyncio.run(run()) + + def test_start_does_not_charge_a_phantom_planner_request() -> None: async def run() -> None: orchestrator = ResearchOrchestrator(_config(), uuid4(), "test") diff --git a/tests/test_rest_research.py b/tests/test_rest_research.py index bf7a8d0..2a46d2f 100644 --- a/tests/test_rest_research.py +++ b/tests/test_rest_research.py @@ -334,7 +334,18 @@ def test_detail_endpoints_serialize_pipeline_uuid_identifiers(client: TestClient } ], evidence_scores=[{"claim_id": claim_id, "research_run_id": uuid4()}], - report={"summary": "Zusammenfassung", "findings": [], "methodology": "Methodik"}, + report={ + "summary": "Zusammenfassung", + "confident_findings": [ + { + "claim_text": "Eine überprüfbare Behauptung.", + "confidence": 0.9, + "source_id": source_id, + } + ], + "uncertain_areas": [], + "methodology": "Methodik", + }, ) sources = client.get(f"/v1/research/{research_id}/sources") @@ -348,6 +359,8 @@ def test_detail_endpoints_serialize_pipeline_uuid_identifiers(client: TestClient assert claims.json()["claims"][0]["source_id"] == str(source_id) assert evidence.json()["evidence"][0]["claim_id"] == str(claim_id) assert report.json()["methodology"] == "Methodik" + assert report.json()["findings"][0]["text"] == "Eine überprüfbare Behauptung." + assert report.json()["findings"][0]["source_ids"] == [str(source_id)] # ---------------------------------------------------------------------------