Expose research planning information

This commit is contained in:
faligam
2026-09-07 12:17:38 +02:00
parent 5f237d535f
commit 2c532bc9bb
4 changed files with 118 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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