Remove analyze insights node, simplify research context, state, prompts & models
This commit is contained in:
parent
50b7fb4461
commit
bdcf81774d
6 changed files with 48 additions and 517 deletions
|
|
@ -1,12 +1,10 @@
|
|||
from pydantic_ai import format_as_xml
|
||||
|
||||
from haiku.rag.graph.research.dependencies import ResearchContext
|
||||
from haiku.rag.graph.research.models import InsightAnalysis
|
||||
|
||||
|
||||
def format_context_for_prompt(context: ResearchContext) -> str:
|
||||
"""Format the research context as XML for inclusion in prompts."""
|
||||
|
||||
context_data = {
|
||||
"original_question": context.original_question,
|
||||
"unanswered_questions": context.sub_questions,
|
||||
|
|
@ -27,69 +25,5 @@ def format_context_for_prompt(context: ResearchContext) -> str:
|
|||
}
|
||||
for qa in context.qa_responses
|
||||
],
|
||||
"insights": [
|
||||
{
|
||||
"id": insight.id,
|
||||
"summary": insight.summary,
|
||||
"status": insight.status.value,
|
||||
"supporting_sources": insight.supporting_sources,
|
||||
"originating_questions": insight.originating_questions,
|
||||
"notes": insight.notes,
|
||||
}
|
||||
for insight in context.insights
|
||||
],
|
||||
"gaps": [
|
||||
{
|
||||
"id": gap.id,
|
||||
"description": gap.description,
|
||||
"severity": gap.severity.value,
|
||||
"blocking": gap.blocking,
|
||||
"resolved": gap.resolved,
|
||||
"resolved_by": gap.resolved_by,
|
||||
"supporting_sources": gap.supporting_sources,
|
||||
"notes": gap.notes,
|
||||
}
|
||||
for gap in context.gaps
|
||||
],
|
||||
}
|
||||
return format_as_xml(context_data, root_tag="research_context")
|
||||
|
||||
|
||||
def format_analysis_for_prompt(
|
||||
analysis: InsightAnalysis | None,
|
||||
) -> str:
|
||||
"""Format the latest insight analysis as XML for prompts."""
|
||||
|
||||
if analysis is None:
|
||||
return "<latest_analysis />"
|
||||
|
||||
data = {
|
||||
"commentary": analysis.commentary,
|
||||
"highlights": [
|
||||
{
|
||||
"id": insight.id,
|
||||
"summary": insight.summary,
|
||||
"status": insight.status.value,
|
||||
"supporting_sources": insight.supporting_sources,
|
||||
"originating_questions": insight.originating_questions,
|
||||
"notes": insight.notes,
|
||||
}
|
||||
for insight in analysis.highlights
|
||||
],
|
||||
"gap_assessments": [
|
||||
{
|
||||
"id": gap.id,
|
||||
"description": gap.description,
|
||||
"severity": gap.severity.value,
|
||||
"blocking": gap.blocking,
|
||||
"resolved": gap.resolved,
|
||||
"resolved_by": gap.resolved_by,
|
||||
"supporting_sources": gap.supporting_sources,
|
||||
"notes": gap.notes,
|
||||
}
|
||||
for gap in analysis.gap_assessments
|
||||
],
|
||||
"resolved_gaps": analysis.resolved_gaps,
|
||||
"new_questions": analysis.new_questions,
|
||||
}
|
||||
return format_as_xml(data, root_tag="latest_analysis")
|
||||
|
|
|
|||
|
|
@ -1,14 +1,7 @@
|
|||
from collections.abc import Iterable
|
||||
|
||||
from pydantic import BaseModel, Field, PrivateAttr
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from haiku.rag.client import HaikuRAG
|
||||
from haiku.rag.graph.common.models import SearchAnswer
|
||||
from haiku.rag.graph.research.models import (
|
||||
GapRecord,
|
||||
InsightAnalysis,
|
||||
InsightRecord,
|
||||
)
|
||||
from haiku.rag.store.models import SearchResult
|
||||
|
||||
|
||||
|
|
@ -22,121 +15,11 @@ class ResearchContext(BaseModel):
|
|||
qa_responses: list[SearchAnswer] = Field(
|
||||
default_factory=list, description="Structured QA pairs used during research"
|
||||
)
|
||||
insights: list[InsightRecord] = Field(
|
||||
default_factory=list, description="Key insights discovered"
|
||||
)
|
||||
gaps: list[GapRecord] = Field(
|
||||
default_factory=list, description="Identified information gaps"
|
||||
)
|
||||
|
||||
# Private dict indexes for O(1) lookups
|
||||
_insights_by_id: dict[str, InsightRecord] = PrivateAttr(default_factory=dict)
|
||||
_gaps_by_id: dict[str, GapRecord] = PrivateAttr(default_factory=dict)
|
||||
|
||||
def model_post_init(self, __context: object) -> None:
|
||||
"""Build indexes after initialization."""
|
||||
self._insights_by_id = {ins.id: ins for ins in self.insights}
|
||||
self._gaps_by_id = {gap.id: gap for gap in self.gaps}
|
||||
|
||||
def add_qa_response(self, qa: SearchAnswer) -> None:
|
||||
"""Add a structured QA response (citations already resolved)."""
|
||||
"""Add a structured QA response."""
|
||||
self.qa_responses.append(qa)
|
||||
|
||||
def upsert_insights(self, records: Iterable[InsightRecord]) -> list[InsightRecord]:
|
||||
"""Merge one or more insights into the shared context with deduplication."""
|
||||
merged: list[InsightRecord] = []
|
||||
|
||||
for record in records:
|
||||
candidate = InsightRecord.model_validate(record)
|
||||
existing = self._insights_by_id.get(candidate.id)
|
||||
|
||||
if existing:
|
||||
# Update existing insight
|
||||
existing.summary = candidate.summary
|
||||
existing.status = candidate.status
|
||||
if candidate.notes:
|
||||
existing.notes = candidate.notes
|
||||
existing.supporting_sources = _merge_unique(
|
||||
existing.supporting_sources, candidate.supporting_sources
|
||||
)
|
||||
existing.originating_questions = _merge_unique(
|
||||
existing.originating_questions, candidate.originating_questions
|
||||
)
|
||||
merged.append(existing)
|
||||
else:
|
||||
# Add new insight
|
||||
new_insight = candidate.model_copy(deep=True)
|
||||
self.insights.append(new_insight)
|
||||
self._insights_by_id[new_insight.id] = new_insight
|
||||
merged.append(new_insight)
|
||||
|
||||
return merged
|
||||
|
||||
def upsert_gaps(self, records: Iterable[GapRecord]) -> list[GapRecord]:
|
||||
"""Merge one or more gap records into the shared context with deduplication."""
|
||||
merged: list[GapRecord] = []
|
||||
|
||||
for record in records:
|
||||
candidate = GapRecord.model_validate(record)
|
||||
existing = self._gaps_by_id.get(candidate.id)
|
||||
|
||||
if existing:
|
||||
# Update existing gap
|
||||
existing.description = candidate.description
|
||||
existing.severity = candidate.severity
|
||||
existing.blocking = candidate.blocking
|
||||
existing.resolved = candidate.resolved
|
||||
if candidate.notes:
|
||||
existing.notes = candidate.notes
|
||||
existing.supporting_sources = _merge_unique(
|
||||
existing.supporting_sources, candidate.supporting_sources
|
||||
)
|
||||
existing.resolved_by = _merge_unique(
|
||||
existing.resolved_by, candidate.resolved_by
|
||||
)
|
||||
merged.append(existing)
|
||||
else:
|
||||
# Add new gap
|
||||
new_gap = candidate.model_copy(deep=True)
|
||||
self.gaps.append(new_gap)
|
||||
self._gaps_by_id[new_gap.id] = new_gap
|
||||
merged.append(new_gap)
|
||||
|
||||
return merged
|
||||
|
||||
def mark_gap_resolved(
|
||||
self, identifier: str, resolved_by: Iterable[str] | None = None
|
||||
) -> GapRecord | None:
|
||||
"""Mark a gap as resolved by identifier."""
|
||||
gap = self._gaps_by_id.get(identifier)
|
||||
if gap is None:
|
||||
return None
|
||||
|
||||
gap.resolved = True
|
||||
gap.blocking = False
|
||||
if resolved_by:
|
||||
gap.resolved_by = _merge_unique(gap.resolved_by, list(resolved_by))
|
||||
return gap
|
||||
|
||||
def integrate_analysis(self, analysis: InsightAnalysis) -> None:
|
||||
"""Apply an analysis result to the shared context."""
|
||||
merged_insights: list[InsightRecord] = []
|
||||
if analysis.highlights:
|
||||
merged_insights = self.upsert_insights(analysis.highlights)
|
||||
analysis.highlights = merged_insights
|
||||
if analysis.gap_assessments:
|
||||
merged_gaps = self.upsert_gaps(analysis.gap_assessments)
|
||||
analysis.gap_assessments = merged_gaps
|
||||
if analysis.resolved_gaps:
|
||||
resolved_by_list = (
|
||||
[ins.id for ins in merged_insights] if merged_insights else None
|
||||
)
|
||||
for resolved in analysis.resolved_gaps:
|
||||
self.mark_gap_resolved(resolved, resolved_by=resolved_by_list)
|
||||
for question in analysis.new_questions:
|
||||
if question not in self.sub_questions:
|
||||
self.sub_questions.append(question)
|
||||
|
||||
|
||||
class ResearchDependencies(BaseModel):
|
||||
"""Dependencies for research agents with multi-agent context."""
|
||||
|
|
@ -148,8 +31,3 @@ class ResearchDependencies(BaseModel):
|
|||
search_results: list[SearchResult] = Field(
|
||||
default_factory=list, description="Search results for citation resolution"
|
||||
)
|
||||
|
||||
|
||||
def _merge_unique(existing: list[str], incoming: Iterable[str]) -> list[str]:
|
||||
"""Merge two iterables preserving order while removing duplicates."""
|
||||
return [k for k in dict.fromkeys([*existing, *incoming]) if k]
|
||||
|
|
|
|||
|
|
@ -7,19 +7,11 @@ from haiku.rag.config.models import AppConfig
|
|||
from haiku.rag.graph.common import get_model
|
||||
from haiku.rag.graph.common.models import SearchAnswer
|
||||
from haiku.rag.graph.common.nodes import create_plan_node, create_search_node
|
||||
from haiku.rag.graph.research.common import (
|
||||
format_analysis_for_prompt,
|
||||
format_context_for_prompt,
|
||||
)
|
||||
from haiku.rag.graph.research.common import format_context_for_prompt
|
||||
from haiku.rag.graph.research.dependencies import ResearchDependencies
|
||||
from haiku.rag.graph.research.models import (
|
||||
EvaluationResult,
|
||||
InsightAnalysis,
|
||||
ResearchReport,
|
||||
)
|
||||
from haiku.rag.graph.research.models import EvaluationResult, ResearchReport
|
||||
from haiku.rag.graph.research.prompts import (
|
||||
DECISION_AGENT_PROMPT,
|
||||
INSIGHT_AGENT_PROMPT,
|
||||
SYNTHESIS_AGENT_PROMPT,
|
||||
)
|
||||
from haiku.rag.graph.research.state import ResearchDeps, ResearchState
|
||||
|
|
@ -43,7 +35,6 @@ def build_research_graph(
|
|||
output_type=ResearchReport,
|
||||
)
|
||||
|
||||
# Create and register the plan node using the factory
|
||||
plan = g.step(
|
||||
create_plan_node(
|
||||
model_config=model_config,
|
||||
|
|
@ -54,7 +45,6 @@ def build_research_graph(
|
|||
)
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
# Create and register the search_one node using the factory
|
||||
search_one = g.step(
|
||||
create_search_node(
|
||||
model_config=model_config,
|
||||
|
|
@ -76,84 +66,14 @@ def build_research_graph(
|
|||
if not state.context.sub_questions:
|
||||
return None
|
||||
|
||||
# Take ALL remaining questions and process them in parallel
|
||||
batch = list(state.context.sub_questions)
|
||||
state.context.sub_questions.clear()
|
||||
return batch
|
||||
|
||||
@g.step
|
||||
async def analyze_insights(
|
||||
async def decide(
|
||||
ctx: StepContext[ResearchState, ResearchDeps, list[SearchAnswer]],
|
||||
) -> None:
|
||||
state = ctx.state
|
||||
deps = ctx.deps
|
||||
|
||||
if deps.agui_emitter:
|
||||
deps.agui_emitter.start_step("analyze_insights")
|
||||
deps.agui_emitter.update_activity(
|
||||
"analyzing", {"message": "Synthesizing insights and gaps"}
|
||||
)
|
||||
|
||||
try:
|
||||
agent = Agent(
|
||||
model=get_model(model_config, config),
|
||||
output_type=InsightAnalysis,
|
||||
instructions=INSIGHT_AGENT_PROMPT,
|
||||
retries=3,
|
||||
output_retries=3,
|
||||
deps_type=ResearchDependencies,
|
||||
)
|
||||
|
||||
context_xml = format_context_for_prompt(state.context)
|
||||
prompt = (
|
||||
"Review the latest research context and update the shared ledger of insights, gaps,"
|
||||
" and follow-up questions.\n\n"
|
||||
f"{context_xml}"
|
||||
)
|
||||
agent_deps = ResearchDependencies(
|
||||
client=deps.client,
|
||||
context=state.context,
|
||||
)
|
||||
result = await agent.run(prompt, deps=agent_deps)
|
||||
analysis: InsightAnalysis = result.output
|
||||
|
||||
state.context.integrate_analysis(analysis)
|
||||
state.last_analysis = analysis
|
||||
|
||||
# State updated with insights/gaps - emit state update and narrate
|
||||
if deps.agui_emitter:
|
||||
deps.agui_emitter.update_state(state)
|
||||
highlights = len(analysis.highlights)
|
||||
gaps = len(analysis.gap_assessments)
|
||||
resolved = len(analysis.resolved_gaps)
|
||||
parts = []
|
||||
if highlights:
|
||||
parts.append(f"{highlights} insights")
|
||||
if gaps:
|
||||
parts.append(f"{gaps} gaps")
|
||||
if resolved:
|
||||
parts.append(f"{resolved} resolved")
|
||||
summary = ", ".join(parts) if parts else "No updates"
|
||||
deps.agui_emitter.update_activity(
|
||||
"analyzing",
|
||||
{
|
||||
"stepName": "analyze_insights",
|
||||
"message": f"Analysis: {summary}",
|
||||
"insights": [
|
||||
h.model_dump(mode="json") for h in analysis.highlights
|
||||
],
|
||||
"gaps": [
|
||||
g.model_dump(mode="json") for g in analysis.gap_assessments
|
||||
],
|
||||
"resolved_gaps": list(analysis.resolved_gaps),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
if deps.agui_emitter:
|
||||
deps.agui_emitter.finish_step()
|
||||
|
||||
@g.step
|
||||
async def decide(ctx: StepContext[ResearchState, ResearchDeps, None]) -> bool:
|
||||
) -> bool:
|
||||
state = ctx.state
|
||||
deps = ctx.deps
|
||||
|
||||
|
|
@ -174,11 +94,9 @@ def build_research_graph(
|
|||
)
|
||||
|
||||
context_xml = format_context_for_prompt(state.context)
|
||||
analysis_xml = format_analysis_for_prompt(state.last_analysis)
|
||||
prompt_parts = [
|
||||
"Assess whether the research now answers the original question with adequate confidence.",
|
||||
context_xml,
|
||||
analysis_xml,
|
||||
]
|
||||
if state.last_eval is not None:
|
||||
prev = state.last_eval
|
||||
|
|
@ -205,7 +123,6 @@ def build_research_graph(
|
|||
if new_q not in state.context.sub_questions:
|
||||
state.context.sub_questions.append(new_q)
|
||||
|
||||
# State updated with evaluation - emit state update and narrate
|
||||
if deps.agui_emitter:
|
||||
deps.agui_emitter.update_state(state)
|
||||
sufficient = "Yes" if output.is_sufficient else "No"
|
||||
|
|
@ -287,8 +204,9 @@ def build_research_graph(
|
|||
.branch(g.match(type(None)).label("No questions").to(synthesize))
|
||||
),
|
||||
g.edge_from(search_one).to(collect_answers),
|
||||
g.edge_from(collect_answers).to(analyze_insights),
|
||||
g.edge_from(analyze_insights).to(decide),
|
||||
g.edge_from(collect_answers).to(
|
||||
decide
|
||||
), # Direct: collect → decide (no analyze_insights)
|
||||
)
|
||||
|
||||
# Branch based on decision
|
||||
|
|
|
|||
|
|
@ -1,149 +1,25 @@
|
|||
import uuid
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
def _deduplicate_list(items: list[str]) -> list[str]:
|
||||
"""Remove duplicates while preserving order."""
|
||||
return list(dict.fromkeys(items))
|
||||
|
||||
|
||||
class InsightStatus(str, Enum):
|
||||
OPEN = "open"
|
||||
VALIDATED = "validated"
|
||||
TENTATIVE = "tentative"
|
||||
|
||||
|
||||
class GapSeverity(str, Enum):
|
||||
LOW = "low"
|
||||
MEDIUM = "medium"
|
||||
HIGH = "high"
|
||||
|
||||
|
||||
class TrackedRecord(BaseModel):
|
||||
"""Base model for tracked entities with sources and metadata."""
|
||||
|
||||
model_config = {"validate_assignment": True}
|
||||
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4())[:8],
|
||||
description="Unique identifier for the record",
|
||||
)
|
||||
supporting_sources: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Source identifiers backing this record",
|
||||
)
|
||||
notes: str | None = Field(
|
||||
default=None,
|
||||
description="Optional elaboration or caveats",
|
||||
)
|
||||
|
||||
@field_validator("supporting_sources", mode="before")
|
||||
@classmethod
|
||||
def deduplicate_sources(cls, v: list[str]) -> list[str]:
|
||||
"""Ensure supporting_sources has no duplicates."""
|
||||
return _deduplicate_list(v) if v else []
|
||||
|
||||
|
||||
class InsightRecord(TrackedRecord):
|
||||
"""Structured insight with provenance and lifecycle metadata."""
|
||||
|
||||
summary: str = Field(description="Concise description of the insight")
|
||||
status: InsightStatus = Field(
|
||||
default=InsightStatus.OPEN,
|
||||
description="Lifecycle status for the insight",
|
||||
)
|
||||
originating_questions: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Research sub-questions that produced this insight",
|
||||
)
|
||||
|
||||
@field_validator("originating_questions", mode="before")
|
||||
@classmethod
|
||||
def deduplicate_questions(cls, v: list[str]) -> list[str]:
|
||||
"""Ensure originating_questions has no duplicates."""
|
||||
return _deduplicate_list(v) if v else []
|
||||
|
||||
|
||||
class GapRecord(TrackedRecord):
|
||||
"""Structured representation of an identified research gap."""
|
||||
|
||||
description: str = Field(description="Concrete statement of what is missing")
|
||||
severity: GapSeverity = Field(
|
||||
default=GapSeverity.MEDIUM,
|
||||
description="Severity of the gap for answering the main question",
|
||||
)
|
||||
blocking: bool = Field(
|
||||
default=True,
|
||||
description="Whether this gap blocks a confident answer",
|
||||
)
|
||||
resolved: bool = Field(
|
||||
default=False,
|
||||
description="Flag indicating if the gap has been resolved",
|
||||
)
|
||||
resolved_by: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Insight IDs or notes explaining how the gap was closed",
|
||||
)
|
||||
|
||||
@field_validator("resolved_by", mode="before")
|
||||
@classmethod
|
||||
def deduplicate_resolved_by(cls, v: list[str]) -> list[str]:
|
||||
"""Ensure resolved_by has no duplicates."""
|
||||
return _deduplicate_list(v) if v else []
|
||||
|
||||
|
||||
class InsightAnalysis(BaseModel):
|
||||
"""Output of the insight aggregation agent."""
|
||||
|
||||
highlights: list[InsightRecord] = Field(
|
||||
default_factory=list,
|
||||
description="New or updated insights discovered this iteration",
|
||||
)
|
||||
gap_assessments: list[GapRecord] = Field(
|
||||
default_factory=list,
|
||||
description="New or updated gap records based on current evidence",
|
||||
)
|
||||
resolved_gaps: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Gap identifiers or descriptions considered resolved",
|
||||
)
|
||||
new_questions: list[str] = Field(
|
||||
default_factory=list,
|
||||
max_length=3,
|
||||
description="Up to three follow-up sub-questions to pursue next",
|
||||
)
|
||||
commentary: str = Field(
|
||||
description="Short narrative summary of the incremental findings",
|
||||
)
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class EvaluationResult(BaseModel):
|
||||
"""Result of analysis and evaluation."""
|
||||
"""Result of research sufficiency evaluation."""
|
||||
|
||||
key_insights: list[str] = Field(
|
||||
description="Main insights extracted from the research so far"
|
||||
)
|
||||
new_questions: list[str] = Field(
|
||||
description="New sub-questions to add to the research (max 3)",
|
||||
max_length=3,
|
||||
default=[],
|
||||
)
|
||||
gaps: list[str] = Field(
|
||||
description="Concrete information gaps that remain", default_factory=list
|
||||
)
|
||||
confidence_score: float = Field(
|
||||
description="Confidence level in the completeness of research (0-1)",
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
)
|
||||
is_sufficient: bool = Field(
|
||||
description="Whether the research is sufficient to answer the original question"
|
||||
)
|
||||
confidence_score: float = Field(
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
description="Confidence level in the completeness of research (0-1)",
|
||||
)
|
||||
reasoning: str = Field(
|
||||
description="Explanation of why the research is or isn't complete"
|
||||
)
|
||||
new_questions: list[str] = Field(
|
||||
default_factory=list,
|
||||
max_length=3,
|
||||
description="New sub-questions to add to the research (max 3)",
|
||||
)
|
||||
|
||||
|
||||
class ResearchReport(BaseModel):
|
||||
|
|
|
|||
|
|
@ -1,75 +1,23 @@
|
|||
INSIGHT_AGENT_PROMPT = """You are the insight aggregation specialist for the
|
||||
research workflow.
|
||||
DECISION_AGENT_PROMPT = """You are the research evaluator responsible for assessing
|
||||
whether gathered evidence sufficiently answers the research question.
|
||||
|
||||
Inputs available:
|
||||
- Original research question and sub-questions
|
||||
- Question–answer pairs with supporting snippets and sources
|
||||
- Existing insights and gaps (with status metadata)
|
||||
- Original research question
|
||||
- Question-answer pairs with supporting sources
|
||||
- Previous evaluation (if any)
|
||||
|
||||
Tasks:
|
||||
1. Extract new or refined insights that advance understanding of the question.
|
||||
2. Update gap status, creating new gap entries when necessary and marking
|
||||
resolved ones explicitly.
|
||||
3. Suggest up to 3 high-impact follow-up sub_questions that would close the
|
||||
most important remaining gaps.
|
||||
1. Assess whether the collected evidence answers the original question.
|
||||
2. Provide a confidence_score in [0,1] reflecting coverage and evidence quality.
|
||||
3. Optionally propose up to 3 new sub-questions if important gaps remain.
|
||||
|
||||
Output format (map directly to fields):
|
||||
- highlights: list of insights with fields {summary, status, supporting_sources,
|
||||
originating_questions, notes}. Use status one of {validated, open, tentative}.
|
||||
supporting_sources and originating_questions must be lists of plain strings.
|
||||
- gap_assessments: list of gaps with fields {description, severity, blocking,
|
||||
resolved, resolved_by, supporting_sources, notes}. Severity must be one of
|
||||
{low, medium, high}. resolved_by and supporting_sources must be lists of plain strings.
|
||||
- resolved_gaps: list of plain strings (identifiers or descriptions for gaps now closed).
|
||||
- new_questions: list of plain strings, up to 3 standalone questions (no duplicates).
|
||||
- commentary: 1–3 sentences summarizing what changed this round.
|
||||
Output fields:
|
||||
- is_sufficient: true when the question is adequately answered
|
||||
- confidence_score: numeric in [0,1]
|
||||
- reasoning: brief explanation of the assessment
|
||||
- new_questions: list of follow-up questions (max 3), only if needed
|
||||
|
||||
All list fields must contain plain strings only, not objects.
|
||||
|
||||
Guidance:
|
||||
- Be concise and avoid repeating previously recorded information unless it
|
||||
changed materially.
|
||||
- For supporting_sources, use only the document_uri strings from the sources.
|
||||
- Only propose new sub_questions that directly address remaining gaps.
|
||||
- When marking a gap as resolved, ensure the rationale is clear via
|
||||
resolved_by or notes."""
|
||||
|
||||
DECISION_AGENT_PROMPT = """You are the research governor responsible for making
|
||||
stop/go decisions.
|
||||
|
||||
Inputs available:
|
||||
- Original research question and current plan
|
||||
- Full insight ledger with status metadata
|
||||
- Up-to-date gap tracker, including resolved indicators
|
||||
- Latest insight analysis summary (highlights, gap changes, new questions)
|
||||
- Previous evaluation decision (if any)
|
||||
|
||||
Tasks:
|
||||
1. Determine whether the collected evidence now answers the original question.
|
||||
2. Provide a confidence_score in [0,1] that reflects coverage, evidence quality,
|
||||
and agreement across sources.
|
||||
3. List the highest-priority gaps that still block a confident answer. Reference
|
||||
existing gap descriptions rather than inventing new ones.
|
||||
4. Optionally propose up to 3 new sub_questions only if they are not already in
|
||||
the current backlog.
|
||||
|
||||
Strictness:
|
||||
- Only mark research as sufficient when every critical aspect of the main
|
||||
question is addressed with reliable, corroborated evidence.
|
||||
- Treat unresolved high-severity or blocking gaps as a hard stop.
|
||||
|
||||
Output fields must line up with EvaluationResult:
|
||||
- key_insights: list of plain strings, concise bullet-ready statements.
|
||||
- new_questions: list of plain strings, follow-up sub-questions (max 3).
|
||||
- gaps: list of plain strings, remaining blockers (reuse wording from tracked gaps).
|
||||
- confidence_score: numeric in [0,1].
|
||||
- is_sufficient: true only when no blocking gaps remain.
|
||||
- reasoning: short narrative tying the decision to evidence coverage.
|
||||
|
||||
All list fields must contain plain strings only, not objects.
|
||||
|
||||
Remember: prefer maintaining continuity with the structured context over
|
||||
introducing new terminology."""
|
||||
Be strict: only mark sufficient when key aspects are addressed with reliable evidence."""
|
||||
|
||||
SYNTHESIS_AGENT_PROMPT = """You are a synthesis specialist producing the final
|
||||
research report.
|
||||
|
|
@ -77,16 +25,16 @@ research report.
|
|||
Goals:
|
||||
1. Synthesize all gathered information into a coherent narrative.
|
||||
2. Present findings clearly and concisely.
|
||||
3. Draw evidence‑based conclusions and recommendations.
|
||||
3. Draw evidence-based conclusions and recommendations.
|
||||
4. State limitations and uncertainties transparently.
|
||||
|
||||
Report guidelines (map to output fields):
|
||||
- title: concise (5–12 words), informative.
|
||||
- executive_summary: 3–5 sentences summarizing the overall answer.
|
||||
- main_findings: list of plain strings, 4–8 one‑sentence bullets reflecting evidence.
|
||||
- conclusions: list of plain strings, 2–4 bullets following logically from findings.
|
||||
- recommendations: list of plain strings, 2–5 actionable bullets tied to findings.
|
||||
- limitations: list of plain strings, 1–3 bullets describing constraints or uncertainties.
|
||||
- title: concise (5-12 words), informative.
|
||||
- executive_summary: 3-5 sentences summarizing the overall answer.
|
||||
- main_findings: list of plain strings, 4-8 one-sentence bullets reflecting evidence.
|
||||
- conclusions: list of plain strings, 2-4 bullets following logically from findings.
|
||||
- recommendations: list of plain strings, 2-5 actionable bullets tied to findings.
|
||||
- limitations: list of plain strings, 1-3 bullets describing constraints or uncertainties.
|
||||
- sources_summary: single string listing sources with document paths and page numbers.
|
||||
|
||||
All list fields must contain plain strings only, not objects.
|
||||
|
|
@ -101,7 +49,7 @@ PRESEARCH_AGENT_PROMPT = """You are a rapid research surveyor.
|
|||
Task:
|
||||
- Call gather_context once on the main question to obtain relevant text from
|
||||
the knowledge base (KB).
|
||||
- Read that context and produce a short natural‑language summary of what the
|
||||
- Read that context and produce a short natural-language summary of what the
|
||||
KB appears to contain relative to the question.
|
||||
|
||||
Rules:
|
||||
|
|
|
|||
|
|
@ -6,11 +6,7 @@ from pydantic import BaseModel, Field
|
|||
|
||||
from haiku.rag.client import HaikuRAG
|
||||
from haiku.rag.graph.research.dependencies import ResearchContext
|
||||
from haiku.rag.graph.research.models import (
|
||||
EvaluationResult,
|
||||
InsightAnalysis,
|
||||
ResearchReport,
|
||||
)
|
||||
from haiku.rag.graph.research.models import EvaluationResult, ResearchReport
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from haiku.rag.config.models import AppConfig
|
||||
|
|
@ -26,12 +22,7 @@ class ResearchDeps:
|
|||
semaphore: asyncio.Semaphore | None = None
|
||||
|
||||
def emit_log(self, message: str, state: "ResearchState | None" = None) -> None:
|
||||
"""Emit a log message through AG-UI events.
|
||||
|
||||
Args:
|
||||
message: The message to log
|
||||
state: Optional state to include in state update
|
||||
"""
|
||||
"""Emit a log message through AG-UI events."""
|
||||
if self.agui_emitter:
|
||||
self.agui_emitter.log(message)
|
||||
if state:
|
||||
|
|
@ -39,15 +30,12 @@ class ResearchDeps:
|
|||
|
||||
|
||||
class ResearchState(BaseModel):
|
||||
"""Research graph state model.
|
||||
|
||||
Fully JSON-serializable Pydantic model suitable for AG-UI state synchronization.
|
||||
"""
|
||||
"""Research graph state model."""
|
||||
|
||||
model_config = {"arbitrary_types_allowed": True}
|
||||
|
||||
context: ResearchContext = Field(
|
||||
description="Shared research context with questions, insights, and gaps"
|
||||
description="Shared research context with questions and QA responses"
|
||||
)
|
||||
iterations: int = Field(default=0, description="Current iteration number")
|
||||
max_iterations: int = Field(default=3, description="Maximum allowed iterations")
|
||||
|
|
@ -60,9 +48,6 @@ class ResearchState(BaseModel):
|
|||
last_eval: EvaluationResult | None = Field(
|
||||
default=None, description="Last evaluation result"
|
||||
)
|
||||
last_analysis: InsightAnalysis | None = Field(
|
||||
default=None, description="Last insight analysis"
|
||||
)
|
||||
search_filter: str | None = Field(
|
||||
default=None, description="SQL WHERE clause to filter search results"
|
||||
)
|
||||
|
|
@ -71,15 +56,7 @@ class ResearchState(BaseModel):
|
|||
def from_config(
|
||||
cls, context: ResearchContext, config: "AppConfig"
|
||||
) -> "ResearchState":
|
||||
"""Create a ResearchState from an AppConfig.
|
||||
|
||||
Args:
|
||||
context: The ResearchContext containing the question and settings
|
||||
config: The AppConfig object (uses config.research for state parameters)
|
||||
|
||||
Returns:
|
||||
A configured ResearchState instance
|
||||
"""
|
||||
"""Create a ResearchState from an AppConfig."""
|
||||
return cls(
|
||||
context=context,
|
||||
max_iterations=config.research.max_iterations,
|
||||
|
|
|
|||
Loading…
Reference in a new issue