Expose research planning information
This commit is contained in:
20
HANDOFF.md
20
HANDOFF.md
@@ -7,6 +7,26 @@
|
|||||||
|
|
||||||
## Aktueller Stand — 2026-09-07
|
## 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
|
### Browser-E2E: Fehlerpfade und Quick-Budgets weiter repariert — 2026-09-07
|
||||||
|
|
||||||
Die nachfolgenden echten Browser-/Proxy-Tests erreichten erstmals die Crawler-
|
Die nachfolgenden echten Browser-/Proxy-Tests erreichten erstmals die Crawler-
|
||||||
|
|||||||
@@ -235,6 +235,14 @@ class ReportResponse(BaseModel):
|
|||||||
generated_at: str = ""
|
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):
|
class ErrorResponse(BaseModel):
|
||||||
"""Fehlerantwort."""
|
"""Fehlerantwort."""
|
||||||
|
|
||||||
@@ -397,6 +405,12 @@ async def _run_pipeline(run: _ResearchRunState, request: ResearchRequest, budget
|
|||||||
research_id = run.research_id
|
research_id = run.research_id
|
||||||
stage_ctx = {"research_run_id": 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(
|
logger.info(
|
||||||
"[pipeline] Starting background research: run_id=%s query='%s'",
|
"[pipeline] Starting background research: run_id=%s query='%s'",
|
||||||
research_id,
|
research_id,
|
||||||
@@ -416,6 +430,7 @@ async def _run_pipeline(run: _ResearchRunState, request: ResearchRequest, budget
|
|||||||
budget_config=budget,
|
budget_config=budget,
|
||||||
depth=request.depth,
|
depth=request.depth,
|
||||||
priority=Priority.NORMAL,
|
priority=Priority.NORMAL,
|
||||||
|
on_plan_created=store_plan,
|
||||||
)
|
)
|
||||||
|
|
||||||
stage_ctx["stage"] = "planning"
|
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)
|
logger.info("[pipeline] run_id=%s stage=synthesizing", research_id)
|
||||||
|
|
||||||
result = await orchestrator.run()
|
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"):
|
if result.get("success"):
|
||||||
run.state = ResearchRunState.COMPLETED.value
|
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(
|
@router.get(
|
||||||
"/v1/research/{research_id}/report",
|
"/v1/research/{research_id}/report",
|
||||||
response_model=ReportResponse,
|
response_model=ReportResponse,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections.abc import Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
@@ -71,6 +71,7 @@ class ResearchOrchestrator:
|
|||||||
context_budget_config: ContextBudgetConfig | None = None,
|
context_budget_config: ContextBudgetConfig | None = None,
|
||||||
depth: str = "normal",
|
depth: str = "normal",
|
||||||
priority: Priority = Priority.NORMAL,
|
priority: Priority = Priority.NORMAL,
|
||||||
|
on_plan_created: Callable[[dict[str, Any]], None] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialisiere den Orchestrator.
|
"""Initialisiere den Orchestrator.
|
||||||
|
|
||||||
@@ -90,12 +91,16 @@ class ResearchOrchestrator:
|
|||||||
Suchtiefe ("quick", "normal", "deep").
|
Suchtiefe ("quick", "normal", "deep").
|
||||||
priority : Priority
|
priority : Priority
|
||||||
LLM-Anfrage-Priorität für den Research-Pipeline.
|
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._config = config
|
||||||
self._research_id = research_id
|
self._research_id = research_id
|
||||||
self._query = query
|
self._query = query
|
||||||
self._depth = depth
|
self._depth = depth
|
||||||
self._priority = priority
|
self._priority = priority
|
||||||
|
self._on_plan_created = on_plan_created
|
||||||
|
|
||||||
# Budget
|
# Budget
|
||||||
if budget_config is not None:
|
if budget_config is not None:
|
||||||
@@ -450,6 +455,7 @@ class ResearchOrchestrator:
|
|||||||
"failed_step": step_name,
|
"failed_step": step_name,
|
||||||
"state": self._state_machine.current_state.value,
|
"state": self._state_machine.current_state.value,
|
||||||
"report": None,
|
"report": None,
|
||||||
|
"plan": self._plan,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Zeit-Tracking
|
# Zeit-Tracking
|
||||||
@@ -469,6 +475,7 @@ class ResearchOrchestrator:
|
|||||||
"failed_step": step_name,
|
"failed_step": step_name,
|
||||||
"state": self._state_machine.current_state.value,
|
"state": self._state_machine.current_state.value,
|
||||||
"report": None,
|
"report": None,
|
||||||
|
"plan": self._plan,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Alle Schritte erfolgreich → COMPLETED
|
# Alle Schritte erfolgreich → COMPLETED
|
||||||
@@ -480,6 +487,7 @@ class ResearchOrchestrator:
|
|||||||
"report": self._get_report(),
|
"report": self._get_report(),
|
||||||
"state": self._state_machine.current_state.value,
|
"state": self._state_machine.current_state.value,
|
||||||
"budget_usage": self._budget_tracker.get_usage(),
|
"budget_usage": self._budget_tracker.get_usage(),
|
||||||
|
"plan": self._plan,
|
||||||
}
|
}
|
||||||
|
|
||||||
elapsed = time.monotonic() - _start
|
elapsed = time.monotonic() - _start
|
||||||
@@ -545,6 +553,8 @@ class ResearchOrchestrator:
|
|||||||
metrics.observe(H_LLM_REQUEST_DURATION, elapsed)
|
metrics.observe(H_LLM_REQUEST_DURATION, elapsed)
|
||||||
|
|
||||||
self._plan = plan
|
self._plan = plan
|
||||||
|
if self._on_plan_created is not None:
|
||||||
|
self._on_plan_created(plan)
|
||||||
|
|
||||||
logger.info("Planning complete: topic=%s, queries=%d",
|
logger.info("Planning complete: topic=%s, queries=%d",
|
||||||
plan.get("topic", ""), len(plan.get("queries", [])))
|
plan.get("topic", ""), len(plan.get("queries", [])))
|
||||||
|
|||||||
@@ -331,6 +331,59 @@ def test_get_evidence_empty(client: TestClient) -> None:
|
|||||||
assert body["evidence"] == []
|
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
|
# GET /v1/research/{id}/report — report
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user