haiku.rag-slim 0.16.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of haiku.rag-slim might be problematic. Click here for more details.
- haiku/rag/__init__.py +0 -0
- haiku/rag/app.py +542 -0
- haiku/rag/chunker.py +65 -0
- haiku/rag/cli.py +466 -0
- haiku/rag/client.py +731 -0
- haiku/rag/config/__init__.py +74 -0
- haiku/rag/config/loader.py +94 -0
- haiku/rag/config/models.py +99 -0
- haiku/rag/embeddings/__init__.py +49 -0
- haiku/rag/embeddings/base.py +25 -0
- haiku/rag/embeddings/ollama.py +28 -0
- haiku/rag/embeddings/openai.py +26 -0
- haiku/rag/embeddings/vllm.py +29 -0
- haiku/rag/embeddings/voyageai.py +27 -0
- haiku/rag/graph/__init__.py +26 -0
- haiku/rag/graph/agui/__init__.py +53 -0
- haiku/rag/graph/agui/cli_renderer.py +135 -0
- haiku/rag/graph/agui/emitter.py +197 -0
- haiku/rag/graph/agui/events.py +254 -0
- haiku/rag/graph/agui/server.py +310 -0
- haiku/rag/graph/agui/state.py +34 -0
- haiku/rag/graph/agui/stream.py +86 -0
- haiku/rag/graph/common/__init__.py +5 -0
- haiku/rag/graph/common/models.py +42 -0
- haiku/rag/graph/common/nodes.py +265 -0
- haiku/rag/graph/common/prompts.py +46 -0
- haiku/rag/graph/common/utils.py +44 -0
- haiku/rag/graph/deep_qa/__init__.py +1 -0
- haiku/rag/graph/deep_qa/dependencies.py +27 -0
- haiku/rag/graph/deep_qa/graph.py +243 -0
- haiku/rag/graph/deep_qa/models.py +20 -0
- haiku/rag/graph/deep_qa/prompts.py +59 -0
- haiku/rag/graph/deep_qa/state.py +56 -0
- haiku/rag/graph/research/__init__.py +3 -0
- haiku/rag/graph/research/common.py +87 -0
- haiku/rag/graph/research/dependencies.py +151 -0
- haiku/rag/graph/research/graph.py +295 -0
- haiku/rag/graph/research/models.py +166 -0
- haiku/rag/graph/research/prompts.py +107 -0
- haiku/rag/graph/research/state.py +85 -0
- haiku/rag/logging.py +56 -0
- haiku/rag/mcp.py +245 -0
- haiku/rag/monitor.py +194 -0
- haiku/rag/qa/__init__.py +33 -0
- haiku/rag/qa/agent.py +93 -0
- haiku/rag/qa/prompts.py +60 -0
- haiku/rag/reader.py +135 -0
- haiku/rag/reranking/__init__.py +63 -0
- haiku/rag/reranking/base.py +13 -0
- haiku/rag/reranking/cohere.py +34 -0
- haiku/rag/reranking/mxbai.py +28 -0
- haiku/rag/reranking/vllm.py +44 -0
- haiku/rag/reranking/zeroentropy.py +59 -0
- haiku/rag/store/__init__.py +4 -0
- haiku/rag/store/engine.py +309 -0
- haiku/rag/store/models/__init__.py +4 -0
- haiku/rag/store/models/chunk.py +17 -0
- haiku/rag/store/models/document.py +17 -0
- haiku/rag/store/repositories/__init__.py +9 -0
- haiku/rag/store/repositories/chunk.py +442 -0
- haiku/rag/store/repositories/document.py +261 -0
- haiku/rag/store/repositories/settings.py +165 -0
- haiku/rag/store/upgrades/__init__.py +62 -0
- haiku/rag/store/upgrades/v0_10_1.py +64 -0
- haiku/rag/store/upgrades/v0_9_3.py +112 -0
- haiku/rag/utils.py +211 -0
- haiku_rag_slim-0.16.0.dist-info/METADATA +128 -0
- haiku_rag_slim-0.16.0.dist-info/RECORD +71 -0
- haiku_rag_slim-0.16.0.dist-info/WHEEL +4 -0
- haiku_rag_slim-0.16.0.dist-info/entry_points.txt +2 -0
- haiku_rag_slim-0.16.0.dist-info/licenses/LICENSE +7 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from enum import Enum
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field, field_validator
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _deduplicate_list(items: list[str]) -> list[str]:
|
|
8
|
+
"""Remove duplicates while preserving order."""
|
|
9
|
+
return list(dict.fromkeys(items))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InsightStatus(str, Enum):
|
|
13
|
+
OPEN = "open"
|
|
14
|
+
VALIDATED = "validated"
|
|
15
|
+
TENTATIVE = "tentative"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GapSeverity(str, Enum):
|
|
19
|
+
LOW = "low"
|
|
20
|
+
MEDIUM = "medium"
|
|
21
|
+
HIGH = "high"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TrackedRecord(BaseModel):
|
|
25
|
+
"""Base model for tracked entities with sources and metadata."""
|
|
26
|
+
|
|
27
|
+
model_config = {"validate_assignment": True}
|
|
28
|
+
|
|
29
|
+
id: str = Field(
|
|
30
|
+
default_factory=lambda: str(uuid.uuid4())[:8],
|
|
31
|
+
description="Unique identifier for the record",
|
|
32
|
+
)
|
|
33
|
+
supporting_sources: list[str] = Field(
|
|
34
|
+
default_factory=list,
|
|
35
|
+
description="Source identifiers backing this record",
|
|
36
|
+
)
|
|
37
|
+
notes: str | None = Field(
|
|
38
|
+
default=None,
|
|
39
|
+
description="Optional elaboration or caveats",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@field_validator("supporting_sources", mode="before")
|
|
43
|
+
@classmethod
|
|
44
|
+
def deduplicate_sources(cls, v: list[str]) -> list[str]:
|
|
45
|
+
"""Ensure supporting_sources has no duplicates."""
|
|
46
|
+
return _deduplicate_list(v) if v else []
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class InsightRecord(TrackedRecord):
|
|
50
|
+
"""Structured insight with provenance and lifecycle metadata."""
|
|
51
|
+
|
|
52
|
+
summary: str = Field(description="Concise description of the insight")
|
|
53
|
+
status: InsightStatus = Field(
|
|
54
|
+
default=InsightStatus.OPEN,
|
|
55
|
+
description="Lifecycle status for the insight",
|
|
56
|
+
)
|
|
57
|
+
originating_questions: list[str] = Field(
|
|
58
|
+
default_factory=list,
|
|
59
|
+
description="Research sub-questions that produced this insight",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@field_validator("originating_questions", mode="before")
|
|
63
|
+
@classmethod
|
|
64
|
+
def deduplicate_questions(cls, v: list[str]) -> list[str]:
|
|
65
|
+
"""Ensure originating_questions has no duplicates."""
|
|
66
|
+
return _deduplicate_list(v) if v else []
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class GapRecord(TrackedRecord):
|
|
70
|
+
"""Structured representation of an identified research gap."""
|
|
71
|
+
|
|
72
|
+
description: str = Field(description="Concrete statement of what is missing")
|
|
73
|
+
severity: GapSeverity = Field(
|
|
74
|
+
default=GapSeverity.MEDIUM,
|
|
75
|
+
description="Severity of the gap for answering the main question",
|
|
76
|
+
)
|
|
77
|
+
blocking: bool = Field(
|
|
78
|
+
default=True,
|
|
79
|
+
description="Whether this gap blocks a confident answer",
|
|
80
|
+
)
|
|
81
|
+
resolved: bool = Field(
|
|
82
|
+
default=False,
|
|
83
|
+
description="Flag indicating if the gap has been resolved",
|
|
84
|
+
)
|
|
85
|
+
resolved_by: list[str] = Field(
|
|
86
|
+
default_factory=list,
|
|
87
|
+
description="Insight IDs or notes explaining how the gap was closed",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
@field_validator("resolved_by", mode="before")
|
|
91
|
+
@classmethod
|
|
92
|
+
def deduplicate_resolved_by(cls, v: list[str]) -> list[str]:
|
|
93
|
+
"""Ensure resolved_by has no duplicates."""
|
|
94
|
+
return _deduplicate_list(v) if v else []
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class InsightAnalysis(BaseModel):
|
|
98
|
+
"""Output of the insight aggregation agent."""
|
|
99
|
+
|
|
100
|
+
highlights: list[InsightRecord] = Field(
|
|
101
|
+
default_factory=list,
|
|
102
|
+
description="New or updated insights discovered this iteration",
|
|
103
|
+
)
|
|
104
|
+
gap_assessments: list[GapRecord] = Field(
|
|
105
|
+
default_factory=list,
|
|
106
|
+
description="New or updated gap records based on current evidence",
|
|
107
|
+
)
|
|
108
|
+
resolved_gaps: list[str] = Field(
|
|
109
|
+
default_factory=list,
|
|
110
|
+
description="Gap identifiers or descriptions considered resolved",
|
|
111
|
+
)
|
|
112
|
+
new_questions: list[str] = Field(
|
|
113
|
+
default_factory=list,
|
|
114
|
+
max_length=3,
|
|
115
|
+
description="Up to three follow-up sub-questions to pursue next",
|
|
116
|
+
)
|
|
117
|
+
commentary: str = Field(
|
|
118
|
+
description="Short narrative summary of the incremental findings",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class EvaluationResult(BaseModel):
|
|
123
|
+
"""Result of analysis and evaluation."""
|
|
124
|
+
|
|
125
|
+
key_insights: list[str] = Field(
|
|
126
|
+
description="Main insights extracted from the research so far"
|
|
127
|
+
)
|
|
128
|
+
new_questions: list[str] = Field(
|
|
129
|
+
description="New sub-questions to add to the research (max 3)",
|
|
130
|
+
max_length=3,
|
|
131
|
+
default=[],
|
|
132
|
+
)
|
|
133
|
+
gaps: list[str] = Field(
|
|
134
|
+
description="Concrete information gaps that remain", default_factory=list
|
|
135
|
+
)
|
|
136
|
+
confidence_score: float = Field(
|
|
137
|
+
description="Confidence level in the completeness of research (0-1)",
|
|
138
|
+
ge=0.0,
|
|
139
|
+
le=1.0,
|
|
140
|
+
)
|
|
141
|
+
is_sufficient: bool = Field(
|
|
142
|
+
description="Whether the research is sufficient to answer the original question"
|
|
143
|
+
)
|
|
144
|
+
reasoning: str = Field(
|
|
145
|
+
description="Explanation of why the research is or isn't complete"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class ResearchReport(BaseModel):
|
|
150
|
+
"""Final research report structure."""
|
|
151
|
+
|
|
152
|
+
title: str = Field(description="Concise title for the research")
|
|
153
|
+
executive_summary: str = Field(description="Brief overview of key findings")
|
|
154
|
+
main_findings: list[str] = Field(
|
|
155
|
+
description="Primary research findings with supporting evidence"
|
|
156
|
+
)
|
|
157
|
+
conclusions: list[str] = Field(description="Evidence-based conclusions")
|
|
158
|
+
limitations: list[str] = Field(
|
|
159
|
+
description="Limitations of the current research", default=[]
|
|
160
|
+
)
|
|
161
|
+
recommendations: list[str] = Field(
|
|
162
|
+
description="Actionable recommendations based on findings", default=[]
|
|
163
|
+
)
|
|
164
|
+
sources_summary: str = Field(
|
|
165
|
+
description="Summary of sources used and their reliability"
|
|
166
|
+
)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
INSIGHT_AGENT_PROMPT = """You are the insight aggregation specialist for the
|
|
2
|
+
research workflow.
|
|
3
|
+
|
|
4
|
+
Inputs available:
|
|
5
|
+
- Original research question and sub-questions
|
|
6
|
+
- Question–answer pairs with supporting snippets and sources
|
|
7
|
+
- Existing insights and gaps (with status metadata)
|
|
8
|
+
|
|
9
|
+
Tasks:
|
|
10
|
+
1. Extract new or refined insights that advance understanding of the question.
|
|
11
|
+
2. Update gap status, creating new gap entries when necessary and marking
|
|
12
|
+
resolved ones explicitly.
|
|
13
|
+
3. Suggest up to 3 high-impact follow-up sub_questions that would close the
|
|
14
|
+
most important remaining gaps.
|
|
15
|
+
|
|
16
|
+
Output format (map directly to fields):
|
|
17
|
+
- highlights: list of insights with fields {summary, status, supporting_sources,
|
|
18
|
+
originating_questions, notes}. Use status one of {validated, open, tentative}.
|
|
19
|
+
- gap_assessments: list of gaps with fields {description, severity, blocking,
|
|
20
|
+
resolved, resolved_by, supporting_sources, notes}. Severity must be one of
|
|
21
|
+
{low, medium, high}. resolved_by may reference related insight summaries if no
|
|
22
|
+
stable identifier yet.
|
|
23
|
+
- resolved_gaps: list of identifiers or descriptions for gaps now closed.
|
|
24
|
+
- new_questions: up to 3 standalone, specific sub-questions (no duplicates with
|
|
25
|
+
existing ones).
|
|
26
|
+
- commentary: 1–3 sentences summarizing what changed this round.
|
|
27
|
+
|
|
28
|
+
Guidance:
|
|
29
|
+
- Be concise and avoid repeating previously recorded information unless it
|
|
30
|
+
changed materially.
|
|
31
|
+
- Tie supporting_sources to the evidence used; omit if unavailable.
|
|
32
|
+
- Only propose new sub_questions that directly address remaining gaps.
|
|
33
|
+
- When marking a gap as resolved, ensure the rationale is clear via
|
|
34
|
+
resolved_by or notes."""
|
|
35
|
+
|
|
36
|
+
DECISION_AGENT_PROMPT = """You are the research governor responsible for making
|
|
37
|
+
stop/go decisions.
|
|
38
|
+
|
|
39
|
+
Inputs available:
|
|
40
|
+
- Original research question and current plan
|
|
41
|
+
- Full insight ledger with status metadata
|
|
42
|
+
- Up-to-date gap tracker, including resolved indicators
|
|
43
|
+
- Latest insight analysis summary (highlights, gap changes, new questions)
|
|
44
|
+
- Previous evaluation decision (if any)
|
|
45
|
+
|
|
46
|
+
Tasks:
|
|
47
|
+
1. Determine whether the collected evidence now answers the original question.
|
|
48
|
+
2. Provide a confidence_score in [0,1] that reflects coverage, evidence quality,
|
|
49
|
+
and agreement across sources.
|
|
50
|
+
3. List the highest-priority gaps that still block a confident answer. Reference
|
|
51
|
+
existing gap descriptions rather than inventing new ones.
|
|
52
|
+
4. Optionally propose up to 3 new sub_questions only if they are not already in
|
|
53
|
+
the current backlog.
|
|
54
|
+
|
|
55
|
+
Strictness:
|
|
56
|
+
- Only mark research as sufficient when every critical aspect of the main
|
|
57
|
+
question is addressed with reliable, corroborated evidence.
|
|
58
|
+
- Treat unresolved high-severity or blocking gaps as a hard stop.
|
|
59
|
+
|
|
60
|
+
Output fields must line up with EvaluationResult:
|
|
61
|
+
- key_insights: concise bullet-ready statements of the most decision-relevant
|
|
62
|
+
insights (cite status if helpful).
|
|
63
|
+
- new_questions: follow-up sub-questions (max 3) meeting the specificity rules.
|
|
64
|
+
- gaps: list remaining blockers; reuse wording from the tracked gaps when
|
|
65
|
+
possible to aid downstream reconciliation.
|
|
66
|
+
- confidence_score: numeric in [0,1].
|
|
67
|
+
- is_sufficient: true only when no blocking gaps remain.
|
|
68
|
+
- reasoning: short narrative tying the decision to evidence coverage.
|
|
69
|
+
|
|
70
|
+
Remember: prefer maintaining continuity with the structured context over
|
|
71
|
+
introducing new terminology."""
|
|
72
|
+
|
|
73
|
+
SYNTHESIS_AGENT_PROMPT = """You are a synthesis specialist producing the final
|
|
74
|
+
research report.
|
|
75
|
+
|
|
76
|
+
Goals:
|
|
77
|
+
1. Synthesize all gathered information into a coherent narrative.
|
|
78
|
+
2. Present findings clearly and concisely.
|
|
79
|
+
3. Draw evidence‑based conclusions and recommendations.
|
|
80
|
+
4. State limitations and uncertainties transparently.
|
|
81
|
+
|
|
82
|
+
Report guidelines (map to output fields):
|
|
83
|
+
- title: concise (5–12 words), informative.
|
|
84
|
+
- executive_summary: 3–5 sentences summarizing the overall answer.
|
|
85
|
+
- main_findings: 4–8 one‑sentence bullets; each reflects evidence from the
|
|
86
|
+
research (do not include inline citations or snippet text).
|
|
87
|
+
- conclusions: 2–4 bullets that follow logically from findings.
|
|
88
|
+
- recommendations: 2–5 actionable bullets tied to findings.
|
|
89
|
+
- limitations: 1–3 bullets describing key constraints or uncertainties.
|
|
90
|
+
- sources_summary: 2–4 sentences summarizing sources used and their reliability.
|
|
91
|
+
|
|
92
|
+
Style:
|
|
93
|
+
- Base all content solely on the collected evidence.
|
|
94
|
+
- Be professional, objective, and specific.
|
|
95
|
+
- Avoid meta commentary and refrain from speculation beyond the evidence."""
|
|
96
|
+
|
|
97
|
+
PRESEARCH_AGENT_PROMPT = """You are a rapid research surveyor.
|
|
98
|
+
|
|
99
|
+
Task:
|
|
100
|
+
- Call gather_context once on the main question to obtain relevant text from
|
|
101
|
+
the knowledge base (KB).
|
|
102
|
+
- Read that context and produce a short natural‑language summary of what the
|
|
103
|
+
KB appears to contain relative to the question.
|
|
104
|
+
|
|
105
|
+
Rules:
|
|
106
|
+
- Base the summary strictly on the provided text; do not invent.
|
|
107
|
+
- Output only the summary as plain text (one short paragraph)."""
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from haiku.rag.client import HaikuRAG
|
|
8
|
+
from haiku.rag.graph.research.dependencies import ResearchContext
|
|
9
|
+
from haiku.rag.graph.research.models import (
|
|
10
|
+
EvaluationResult,
|
|
11
|
+
InsightAnalysis,
|
|
12
|
+
ResearchReport,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from haiku.rag.config.models import AppConfig
|
|
17
|
+
from haiku.rag.graph.agui.emitter import AGUIEmitter
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ResearchDeps:
|
|
22
|
+
"""Dependencies for research graph execution."""
|
|
23
|
+
|
|
24
|
+
client: HaikuRAG
|
|
25
|
+
agui_emitter: "AGUIEmitter[ResearchState, ResearchReport] | None" = None
|
|
26
|
+
semaphore: asyncio.Semaphore | None = None
|
|
27
|
+
|
|
28
|
+
def emit_log(self, message: str, state: "ResearchState | None" = None) -> None:
|
|
29
|
+
"""Emit a log message through AG-UI events.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
message: The message to log
|
|
33
|
+
state: Optional state to include in state update
|
|
34
|
+
"""
|
|
35
|
+
if self.agui_emitter:
|
|
36
|
+
self.agui_emitter.log(message)
|
|
37
|
+
if state:
|
|
38
|
+
self.agui_emitter.update_state(state)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ResearchState(BaseModel):
|
|
42
|
+
"""Research graph state model.
|
|
43
|
+
|
|
44
|
+
Fully JSON-serializable Pydantic model suitable for AG-UI state synchronization.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
48
|
+
|
|
49
|
+
context: ResearchContext = Field(
|
|
50
|
+
description="Shared research context with questions, insights, and gaps"
|
|
51
|
+
)
|
|
52
|
+
iterations: int = Field(default=0, description="Current iteration number")
|
|
53
|
+
max_iterations: int = Field(default=3, description="Maximum allowed iterations")
|
|
54
|
+
confidence_threshold: float = Field(
|
|
55
|
+
default=0.8, description="Confidence threshold for completion", ge=0.0, le=1.0
|
|
56
|
+
)
|
|
57
|
+
max_concurrency: int = Field(
|
|
58
|
+
default=1, description="Maximum concurrent search operations", ge=1
|
|
59
|
+
)
|
|
60
|
+
last_eval: EvaluationResult | None = Field(
|
|
61
|
+
default=None, description="Last evaluation result"
|
|
62
|
+
)
|
|
63
|
+
last_analysis: InsightAnalysis | None = Field(
|
|
64
|
+
default=None, description="Last insight analysis"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_config(
|
|
69
|
+
cls, context: ResearchContext, config: "AppConfig"
|
|
70
|
+
) -> "ResearchState":
|
|
71
|
+
"""Create a ResearchState from an AppConfig.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
context: The ResearchContext containing the question and settings
|
|
75
|
+
config: The AppConfig object (uses config.research for state parameters)
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
A configured ResearchState instance
|
|
79
|
+
"""
|
|
80
|
+
return cls(
|
|
81
|
+
context=context,
|
|
82
|
+
max_iterations=config.research.max_iterations,
|
|
83
|
+
confidence_threshold=config.research.confidence_threshold,
|
|
84
|
+
max_concurrency=config.research.max_concurrency,
|
|
85
|
+
)
|
haiku/rag/logging.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import warnings
|
|
3
|
+
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.logging import RichHandler
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_logger() -> logging.Logger:
|
|
9
|
+
"""Return the library logger configured with a Rich handler."""
|
|
10
|
+
logger = logging.getLogger("haiku.rag")
|
|
11
|
+
|
|
12
|
+
handler = RichHandler(
|
|
13
|
+
console=Console(stderr=True),
|
|
14
|
+
rich_tracebacks=True,
|
|
15
|
+
)
|
|
16
|
+
formatter = logging.Formatter("%(message)s")
|
|
17
|
+
handler.setFormatter(formatter)
|
|
18
|
+
|
|
19
|
+
logger.setLevel(logging.INFO)
|
|
20
|
+
|
|
21
|
+
# Remove any existing handlers to avoid duplicates on reconfiguration
|
|
22
|
+
for hdlr in logger.handlers[:]:
|
|
23
|
+
logger.removeHandler(hdlr)
|
|
24
|
+
|
|
25
|
+
logger.addHandler(handler)
|
|
26
|
+
# Do not let messages propagate to the root logger
|
|
27
|
+
logger.propagate = False
|
|
28
|
+
return logger
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def configure_cli_logging(level: int = logging.INFO) -> logging.Logger:
|
|
32
|
+
"""Configure logging for CLI runs.
|
|
33
|
+
|
|
34
|
+
- Silence ALL non-haiku.rag loggers by detaching root handlers and setting
|
|
35
|
+
their level to ERROR.
|
|
36
|
+
- Attach a Rich handler only to the "haiku.rag" logger.
|
|
37
|
+
- Prevent propagation so only our logger prints in the CLI.
|
|
38
|
+
"""
|
|
39
|
+
# Silence root logger completely
|
|
40
|
+
root = logging.getLogger()
|
|
41
|
+
for hdlr in root.handlers[:]:
|
|
42
|
+
root.removeHandler(hdlr)
|
|
43
|
+
root.setLevel(logging.ERROR)
|
|
44
|
+
|
|
45
|
+
# Optionally silence some commonly noisy libraries explicitly as a safeguard
|
|
46
|
+
for noisy in ("httpx", "httpcore", "docling", "urllib3", "asyncio"):
|
|
47
|
+
logging.getLogger(noisy).setLevel(logging.ERROR)
|
|
48
|
+
logging.getLogger(noisy).propagate = False
|
|
49
|
+
|
|
50
|
+
# Configure and return our app logger
|
|
51
|
+
logger = get_logger()
|
|
52
|
+
logger.setLevel(level)
|
|
53
|
+
logger.propagate = False
|
|
54
|
+
|
|
55
|
+
warnings.filterwarnings("ignore")
|
|
56
|
+
return logger
|
haiku/rag/mcp.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from fastmcp import FastMCP
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from haiku.rag.client import HaikuRAG
|
|
8
|
+
from haiku.rag.config import AppConfig, Config
|
|
9
|
+
from haiku.rag.graph.research.models import ResearchReport
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SearchResult(BaseModel):
|
|
13
|
+
document_id: str
|
|
14
|
+
content: str
|
|
15
|
+
score: float
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DocumentResult(BaseModel):
|
|
19
|
+
id: str | None
|
|
20
|
+
content: str
|
|
21
|
+
uri: str | None = None
|
|
22
|
+
title: str | None = None
|
|
23
|
+
metadata: dict[str, Any] = {}
|
|
24
|
+
created_at: str
|
|
25
|
+
updated_at: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP:
|
|
29
|
+
"""Create an MCP server with the specified database path."""
|
|
30
|
+
mcp = FastMCP("haiku-rag")
|
|
31
|
+
|
|
32
|
+
@mcp.tool()
|
|
33
|
+
async def add_document_from_file(
|
|
34
|
+
file_path: str,
|
|
35
|
+
metadata: dict[str, Any] | None = None,
|
|
36
|
+
title: str | None = None,
|
|
37
|
+
) -> str | None:
|
|
38
|
+
"""Add a document to the RAG system from a file path."""
|
|
39
|
+
try:
|
|
40
|
+
async with HaikuRAG(db_path, config=config) as rag:
|
|
41
|
+
result = await rag.create_document_from_source(
|
|
42
|
+
Path(file_path), title=title, metadata=metadata or {}
|
|
43
|
+
)
|
|
44
|
+
# Handle both single document and list of documents (directories)
|
|
45
|
+
if isinstance(result, list):
|
|
46
|
+
return result[0].id if result else None
|
|
47
|
+
return result.id
|
|
48
|
+
except Exception:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
@mcp.tool()
|
|
52
|
+
async def add_document_from_url(
|
|
53
|
+
url: str, metadata: dict[str, Any] | None = None, title: str | None = None
|
|
54
|
+
) -> str | None:
|
|
55
|
+
"""Add a document to the RAG system from a URL."""
|
|
56
|
+
try:
|
|
57
|
+
async with HaikuRAG(db_path, config=config) as rag:
|
|
58
|
+
result = await rag.create_document_from_source(
|
|
59
|
+
url, title=title, metadata=metadata or {}
|
|
60
|
+
)
|
|
61
|
+
# Handle both single document and list of documents
|
|
62
|
+
if isinstance(result, list):
|
|
63
|
+
return result[0].id if result else None
|
|
64
|
+
return result.id
|
|
65
|
+
except Exception:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
@mcp.tool()
|
|
69
|
+
async def add_document_from_text(
|
|
70
|
+
content: str,
|
|
71
|
+
uri: str | None = None,
|
|
72
|
+
metadata: dict[str, Any] | None = None,
|
|
73
|
+
title: str | None = None,
|
|
74
|
+
) -> str | None:
|
|
75
|
+
"""Add a document to the RAG system from text content."""
|
|
76
|
+
try:
|
|
77
|
+
async with HaikuRAG(db_path, config=config) as rag:
|
|
78
|
+
document = await rag.create_document(
|
|
79
|
+
content, uri, title=title, metadata=metadata or {}
|
|
80
|
+
)
|
|
81
|
+
return document.id
|
|
82
|
+
except Exception:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
@mcp.tool()
|
|
86
|
+
async def search_documents(query: str, limit: int = 5) -> list[SearchResult]:
|
|
87
|
+
"""Search the RAG system for documents using hybrid search (vector similarity + full-text search)."""
|
|
88
|
+
try:
|
|
89
|
+
async with HaikuRAG(db_path, config=config) as rag:
|
|
90
|
+
results = await rag.search(query, limit)
|
|
91
|
+
|
|
92
|
+
search_results = []
|
|
93
|
+
for chunk, score in results:
|
|
94
|
+
assert chunk.document_id is not None, (
|
|
95
|
+
"Chunk document_id should not be None in search results"
|
|
96
|
+
)
|
|
97
|
+
search_results.append(
|
|
98
|
+
SearchResult(
|
|
99
|
+
document_id=chunk.document_id,
|
|
100
|
+
content=chunk.content,
|
|
101
|
+
score=score,
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return search_results
|
|
106
|
+
except Exception:
|
|
107
|
+
return []
|
|
108
|
+
|
|
109
|
+
@mcp.tool()
|
|
110
|
+
async def get_document(document_id: str) -> DocumentResult | None:
|
|
111
|
+
"""Get a document by its ID."""
|
|
112
|
+
try:
|
|
113
|
+
async with HaikuRAG(db_path, config=config) as rag:
|
|
114
|
+
document = await rag.get_document_by_id(document_id)
|
|
115
|
+
|
|
116
|
+
if document is None:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
return DocumentResult(
|
|
120
|
+
id=document.id,
|
|
121
|
+
content=document.content,
|
|
122
|
+
uri=document.uri,
|
|
123
|
+
title=document.title,
|
|
124
|
+
metadata=document.metadata,
|
|
125
|
+
created_at=str(document.created_at),
|
|
126
|
+
updated_at=str(document.updated_at),
|
|
127
|
+
)
|
|
128
|
+
except Exception:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
@mcp.tool()
|
|
132
|
+
async def list_documents(
|
|
133
|
+
limit: int | None = None,
|
|
134
|
+
offset: int | None = None,
|
|
135
|
+
filter: str | None = None,
|
|
136
|
+
) -> list[DocumentResult]:
|
|
137
|
+
"""List all documents with optional pagination and filtering.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
limit: Maximum number of documents to return.
|
|
141
|
+
offset: Number of documents to skip.
|
|
142
|
+
filter: Optional SQL WHERE clause to filter documents.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
List of DocumentResult instances matching the criteria.
|
|
146
|
+
"""
|
|
147
|
+
try:
|
|
148
|
+
async with HaikuRAG(db_path, config=config) as rag:
|
|
149
|
+
documents = await rag.list_documents(limit, offset, filter)
|
|
150
|
+
|
|
151
|
+
return [
|
|
152
|
+
DocumentResult(
|
|
153
|
+
id=doc.id,
|
|
154
|
+
content=doc.content,
|
|
155
|
+
uri=doc.uri,
|
|
156
|
+
title=doc.title,
|
|
157
|
+
metadata=doc.metadata,
|
|
158
|
+
created_at=str(doc.created_at),
|
|
159
|
+
updated_at=str(doc.updated_at),
|
|
160
|
+
)
|
|
161
|
+
for doc in documents
|
|
162
|
+
]
|
|
163
|
+
except Exception:
|
|
164
|
+
return []
|
|
165
|
+
|
|
166
|
+
@mcp.tool()
|
|
167
|
+
async def delete_document(document_id: str) -> bool:
|
|
168
|
+
"""Delete a document by its ID."""
|
|
169
|
+
try:
|
|
170
|
+
async with HaikuRAG(db_path, config=config) as rag:
|
|
171
|
+
return await rag.delete_document(document_id)
|
|
172
|
+
except Exception:
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
@mcp.tool()
|
|
176
|
+
async def ask_question(
|
|
177
|
+
question: str,
|
|
178
|
+
cite: bool = False,
|
|
179
|
+
deep: bool = False,
|
|
180
|
+
) -> str:
|
|
181
|
+
"""Ask a question using the QA agent.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
question: The question to ask.
|
|
185
|
+
cite: Whether to include citations in the response.
|
|
186
|
+
deep: Use deep multi-agent QA for complex questions that require decomposition.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
The answer as a string.
|
|
190
|
+
"""
|
|
191
|
+
try:
|
|
192
|
+
async with HaikuRAG(db_path, config=config) as rag:
|
|
193
|
+
if deep:
|
|
194
|
+
from haiku.rag.graph.deep_qa.dependencies import DeepQAContext
|
|
195
|
+
from haiku.rag.graph.deep_qa.graph import build_deep_qa_graph
|
|
196
|
+
from haiku.rag.graph.deep_qa.state import DeepQADeps, DeepQAState
|
|
197
|
+
|
|
198
|
+
graph = build_deep_qa_graph(config=config)
|
|
199
|
+
context = DeepQAContext(
|
|
200
|
+
original_question=question, use_citations=cite
|
|
201
|
+
)
|
|
202
|
+
state = DeepQAState.from_config(context=context, config=config)
|
|
203
|
+
deps = DeepQADeps(client=rag)
|
|
204
|
+
|
|
205
|
+
result = await graph.run(state=state, deps=deps)
|
|
206
|
+
answer = result.answer
|
|
207
|
+
else:
|
|
208
|
+
answer = await rag.ask(question, cite=cite)
|
|
209
|
+
return answer
|
|
210
|
+
except Exception as e:
|
|
211
|
+
return f"Error answering question: {e!s}"
|
|
212
|
+
|
|
213
|
+
@mcp.tool()
|
|
214
|
+
async def research_question(
|
|
215
|
+
question: str,
|
|
216
|
+
) -> ResearchReport | None:
|
|
217
|
+
"""Run multi-agent research to investigate a complex question.
|
|
218
|
+
|
|
219
|
+
The research process uses multiple agents to plan, search, evaluate, and synthesize
|
|
220
|
+
information iteratively until confidence threshold is met or max iterations reached.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
question: The research question to investigate.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
A research report with findings, or None if an error occurred.
|
|
227
|
+
"""
|
|
228
|
+
try:
|
|
229
|
+
from haiku.rag.graph.research.dependencies import ResearchContext
|
|
230
|
+
from haiku.rag.graph.research.graph import build_research_graph
|
|
231
|
+
from haiku.rag.graph.research.state import ResearchDeps, ResearchState
|
|
232
|
+
|
|
233
|
+
async with HaikuRAG(db_path, config=config) as rag:
|
|
234
|
+
graph = build_research_graph(config=config)
|
|
235
|
+
context = ResearchContext(original_question=question)
|
|
236
|
+
state = ResearchState.from_config(context=context, config=config)
|
|
237
|
+
deps = ResearchDeps(client=rag)
|
|
238
|
+
|
|
239
|
+
result = await graph.run(state=state, deps=deps)
|
|
240
|
+
|
|
241
|
+
return result
|
|
242
|
+
except Exception:
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
return mcp
|