From 2c532bc9bb252b6f0dcc20937c4efa3e729a2628 Mon Sep 17 00:00:00 2001 From: faligam Date: Mon, 7 Sep 2026 12:17:38 +0200 Subject: [PATCH] Expose research planning information --- HANDOFF.md | 20 ++++++++++ src/nsct/api/rest_research.py | 34 +++++++++++++++++ src/nsct/orchestration/orchestrator.py | 12 +++++- tests/test_rest_research.py | 53 ++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 1 deletion(-) diff --git a/HANDOFF.md b/HANDOFF.md index 948a4a7..16f8647 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -7,6 +7,26 @@ ## Aktueller Stand — 2026-09-07 +### Planungsinformationen in Research-Details umgesetzt — 2026-09-07 + +Issue [Frontend #1](https://git.frerkc.de/opencode/NSCT-FrontEnd/issues/1) +ist lokal umgesetzt und in beide Container deployed: + +- Das Backend speichert den strukturierten, bereits validierten Research-Plan + unmittelbar nach der Planungsphase. `GET /v1/research/{id}/plan` liefert ihn + authentifiziert als `plan` mit `available`; weder System-Prompt noch rohe + Modellantwort werden offengelegt. Der Plan bleibt auch bei Fehlern in einer + späteren Pipeline-Phase abrufbar. +- Die Research-Detailseite enthält den Tab **Planung**. Er zeigt Thema, + Zeitraum, Suchanfragen samt Zweck/Kategorie, Perspektiven, Entitäten, + vorgesehene Quelltypen, Gegenhypothesen und Maßnahmen gegen Search-Bias. + Vor Ende der Planungsphase erscheint ein eindeutiger Wartehinweis. + +Validierung: zwei neue Endpoint-Tests erfolgreich, Backend-Syntaxprüfung, +Frontend-Produktionsbuild, beide Docker-Rebuilds sowie `/health` und +`/api/health` HTTP 200. `npm run check` hat weiterhin die 18 bereits bekannten, +unabhängigen TypeScript-Fehler; die Planansicht fügt keinen hinzu. + ### Browser-E2E: Fehlerpfade und Quick-Budgets weiter repariert — 2026-09-07 Die nachfolgenden echten Browser-/Proxy-Tests erreichten erstmals die Crawler- diff --git a/src/nsct/api/rest_research.py b/src/nsct/api/rest_research.py index 783354d..263744b 100644 --- a/src/nsct/api/rest_research.py +++ b/src/nsct/api/rest_research.py @@ -235,6 +235,14 @@ class ReportResponse(BaseModel): generated_at: str = "" +class PlanResponse(BaseModel): + """GET /v1/research/{id}/plan — validated planner output for a run.""" + + research_id: str + plan: dict[str, Any] | None = None + available: bool = False + + class ErrorResponse(BaseModel): """Fehlerantwort.""" @@ -397,6 +405,12 @@ async def _run_pipeline(run: _ResearchRunState, request: ResearchRequest, budget research_id = run.research_id stage_ctx = {"research_run_id": research_id} + def store_plan(plan: dict[str, Any]) -> None: + """Make the validated plan available as soon as planning finishes.""" + run.plan = plan + run.updated_at = _now() + _save_run(run) + logger.info( "[pipeline] Starting background research: run_id=%s query='%s'", research_id, @@ -416,6 +430,7 @@ async def _run_pipeline(run: _ResearchRunState, request: ResearchRequest, budget budget_config=budget, depth=request.depth, priority=Priority.NORMAL, + on_plan_created=store_plan, ) stage_ctx["stage"] = "planning" @@ -449,6 +464,10 @@ async def _run_pipeline(run: _ResearchRunState, request: ResearchRequest, budget logger.info("[pipeline] run_id=%s stage=synthesizing", research_id) result = await orchestrator.run() + # The plan is structured, validated output from the planner. Persist it + # independently from the final report so it remains inspectable even + # when a later pipeline step fails. + run.plan = result.get("plan") if result.get("success"): run.state = ResearchRunState.COMPLETED.value @@ -629,6 +648,21 @@ async def get_evidence(research_id: str) -> EvidenceResponse: ) +@router.get( + "/v1/research/{research_id}/plan", + response_model=PlanResponse, + summary="Get the validated research plan for a research run", +) +async def get_plan(research_id: str) -> PlanResponse: + """Return the planner's structured strategy, never its prompt or raw output.""" + run = _load_run(research_id) + return PlanResponse( + research_id=run.research_id, + plan=run.plan, + available=run.plan is not None, + ) + + @router.get( "/v1/research/{research_id}/report", response_model=ReportResponse, diff --git a/src/nsct/orchestration/orchestrator.py b/src/nsct/orchestration/orchestrator.py index ab54c37..d5ac596 100644 --- a/src/nsct/orchestration/orchestrator.py +++ b/src/nsct/orchestration/orchestrator.py @@ -11,7 +11,7 @@ import asyncio import json import logging import time -from collections.abc import Mapping +from collections.abc import Callable, Mapping from datetime import datetime, timezone from typing import Any from uuid import UUID, uuid4 @@ -71,6 +71,7 @@ class ResearchOrchestrator: context_budget_config: ContextBudgetConfig | None = None, depth: str = "normal", priority: Priority = Priority.NORMAL, + on_plan_created: Callable[[dict[str, Any]], None] | None = None, ) -> None: """Initialisiere den Orchestrator. @@ -90,12 +91,16 @@ class ResearchOrchestrator: Suchtiefe ("quick", "normal", "deep"). priority : Priority LLM-Anfrage-Priorität für den Research-Pipeline. + on_plan_created : callable | None + Optionaler Hook, der nach der validierten Planungsphase ausgeführt + wird. Der Hook erhält ausschließlich den strukturierten Plan. """ self._config = config self._research_id = research_id self._query = query self._depth = depth self._priority = priority + self._on_plan_created = on_plan_created # Budget if budget_config is not None: @@ -450,6 +455,7 @@ class ResearchOrchestrator: "failed_step": step_name, "state": self._state_machine.current_state.value, "report": None, + "plan": self._plan, } # Zeit-Tracking @@ -469,6 +475,7 @@ class ResearchOrchestrator: "failed_step": step_name, "state": self._state_machine.current_state.value, "report": None, + "plan": self._plan, } # Alle Schritte erfolgreich → COMPLETED @@ -480,6 +487,7 @@ class ResearchOrchestrator: "report": self._get_report(), "state": self._state_machine.current_state.value, "budget_usage": self._budget_tracker.get_usage(), + "plan": self._plan, } elapsed = time.monotonic() - _start @@ -545,6 +553,8 @@ class ResearchOrchestrator: metrics.observe(H_LLM_REQUEST_DURATION, elapsed) self._plan = plan + if self._on_plan_created is not None: + self._on_plan_created(plan) logger.info("Planning complete: topic=%s, queries=%d", plan.get("topic", ""), len(plan.get("queries", []))) diff --git a/tests/test_rest_research.py b/tests/test_rest_research.py index da7acab..2ef89fd 100644 --- a/tests/test_rest_research.py +++ b/tests/test_rest_research.py @@ -331,6 +331,59 @@ def test_get_evidence_empty(client: TestClient) -> None: assert body["evidence"] == [] +# --------------------------------------------------------------------------- +# GET /v1/research/{id}/plan — plan +# --------------------------------------------------------------------------- + + +def test_get_plan_returns_stored_validated_plan(client: TestClient) -> None: + """The plan endpoint exposes stored planner output without raw LLM data.""" + from nsct.api.rest_research import _ResearchRunState, _research_store + from nsct.orchestration.budget import HardBudgetConfig + + _research_store.clear() + research_id = "plan-test" + _research_store[research_id] = _ResearchRunState( + research_id=research_id, + query="Plan test", + language="de", + depth="quick", + budget=HardBudgetConfig(max_search_queries=10, max_sources=5), + plan={"topic": "Plan test", "queries": [{"query": "Plan test", "purpose": "test"}]}, + ) + + resp = client.get(f"/v1/research/{research_id}/plan") + + assert resp.status_code == 200 + assert resp.json() == { + "research_id": research_id, + "plan": {"topic": "Plan test", "queries": [{"query": "Plan test", "purpose": "test"}]}, + "available": True, + } + + +def test_get_plan_returns_unavailable_while_planning(client: TestClient) -> None: + """A newly created run has a valid empty plan response until planning ends.""" + from nsct.api.rest_research import _ResearchRunState, _research_store + from nsct.orchestration.budget import HardBudgetConfig + + _research_store.clear() + research_id = "no-plan-test" + _research_store[research_id] = _ResearchRunState( + research_id=research_id, + query="Plan test", + language="de", + depth="quick", + budget=HardBudgetConfig(max_search_queries=10, max_sources=5), + ) + + resp = client.get(f"/v1/research/{research_id}/plan") + + assert resp.status_code == 200 + assert resp.json()["plan"] is None + assert resp.json()["available"] is False + + # --------------------------------------------------------------------------- # GET /v1/research/{id}/report — report # ---------------------------------------------------------------------------