Stage 4: Research Planner — LLM-basierte Recherchestrategie-Generierung

- src/nsct/agents/planner.py: ResearchPlanner LLM-Klasse mit system prompt,
  MockResearchPlanner, JSON-Extraktion und Validierung
- src/nsct/agents/validator.py: validate_plan() prüft alle required fields,
  query categories, counter_evidence, search_dimensions, confidence
- src/nsct/agents/__init__.py: Package export für ResearchPlanner, validate_plan,
  ResearchPlan
- src/nsct/models/plan.py: Pydantic v2 Schema (ResearchPlan, TimeRange,
  QueryConfig, PotentialSource) mit Validation
- src/nsct/api/planner.py: POST /research/planner Endpoint mit Debug-Support
- src/nsct/api/main.py: Mount des planner routers
- src/nsct/crawler/pdf.py: exportiere extract_pdf_content als Alias
- src/nsct/api/crawler.py: Pydantic BaseModel für Request-Models
- tests/test_planner.py: 25 Tests für Planner, Validator, Schema, API

- Search-Bias-Reduktion: 6+ Query-Typen, counter_evidence, beide Seiten
- Keine TODOs, keine unvollständigen Funktionen
- Alle Dateien syntaktisch korrekt und getestet
This commit is contained in:
NSCT Agent
2026-08-23 13:28:23 +00:00
parent a8595cc950
commit b8181deb05
9 changed files with 1388 additions and 15 deletions

182
src/nsct/models/plan.py Normal file
View File

@@ -0,0 +1,182 @@
"""Pydantic v2 schema for Research Planner output."""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field, field_validator
class TimeRange(BaseModel):
"""Time range for the expected relevant information."""
start: str | None = Field(
default=None,
description="Start date/time of the relevant time range (ISO 8601).",
)
end: str | None = Field(
default=None,
description="End date/time of the relevant time range (ISO 8601).",
)
description: str = Field(
...,
description="Human-readable description of the expected time period.",
)
@field_validator("description")
@classmethod
def _description_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("description darf nicht leer sein")
return v
class QueryConfig(BaseModel):
"""A single search query with its purpose and category."""
query: str = Field(
...,
min_length=1,
description="The actual search query text to execute.",
)
purpose: str = Field(
...,
min_length=1,
description="What this search query aims to discover.",
)
category: str = Field(
...,
description=(
"Category of the query. Must be one of: "
"primary_source, news, scientific, counter_evidence, general."
),
)
language: str = Field(
default="de",
description="Language code for the query (e.g. 'de', 'en').",
)
@field_validator("category")
@classmethod
def _valid_category(cls, v: str) -> str:
allowed = {"primary_source", "news", "scientific", "counter_evidence", "general"}
if v not in allowed:
raise ValueError(
f"category muss einer der folgenden sein: {', '.join(sorted(allowed))}"
)
return v
@field_validator("query")
@classmethod
def _query_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("query darf nicht leer sein")
return v
class PotentialSource(BaseModel):
"""A potential source type to look for."""
type: str = Field(
...,
description="Type of source: primary_source, secondary_source, or academic.",
)
description: str = Field(
...,
min_length=1,
description="Description of what to look for in this source type.",
)
@field_validator("type")
@classmethod
def _valid_type(cls, v: str) -> str:
allowed = {"primary_source", "secondary_source", "academic"}
if v not in allowed:
raise ValueError(
f"type muss einer der folgenden sein: {', '.join(sorted(allowed))}"
)
return v
@field_validator("description")
@classmethod
def _desc_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("description darf nicht leer sein")
return v
class ResearchPlan(BaseModel):
"""Structured research plan generated by the Research Planner.
The plan describes HOW to research a topic — it does NOT decide
what is true. It creates a strategy for the downstream stages
(Claim Extraction, Source Graph, etc.).
"""
topic: str = Field(
...,
min_length=1,
description="Interpreted topic of the research question.",
)
time_range: TimeRange = Field(
...,
description="Expected time range for relevant information.",
)
entities: list[str] = Field(
...,
min_length=1,
description="Important entities, persons, or organisations to track.",
)
search_dimensions: list[str] = Field(
...,
min_length=1,
description=(
"Search dimensions: primary_sources, independent_reporting, "
"counter_evidence, scientific_sources, etc."
),
)
queries: list[QueryConfig] = Field(
...,
min_length=1,
description="List of search queries with purpose and category.",
)
potential_sources: list[PotentialSource] = Field(
...,
min_length=1,
description="Types of potential sources to look for.",
)
counter_hypotheses: list[str] = Field(
default_factory=list,
description=(
"Possible alternative interpretations that must be searched for."
),
)
search_bias_mitigation: list[str] = Field(
default_factory=list,
description="Specific measures to counter search bias for this topic.",
)
estimated_depth: str = Field(
default="normal",
description="Estimated research depth: quick, normal, or deep.",
)
confidence: float = Field(
default=0.7,
ge=0.0,
le=1.0,
description="Planner confidence in the plan quality (0-1).",
)
@field_validator("topic")
@classmethod
def _topic_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("topic darf nicht leer sein")
return v
@field_validator("estimated_depth")
@classmethod
def _valid_depth(cls, v: str) -> str:
allowed = {"quick", "normal", "deep"}
if v not in allowed:
raise ValueError(f"estimated_depth muss einer der folgenden sein: {', '.join(sorted(allowed))}")
return v