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:
182
src/nsct/models/plan.py
Normal file
182
src/nsct/models/plan.py
Normal 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
|
||||
Reference in New Issue
Block a user