neorag 0.2.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.
neorag/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """NeoRAG: a self-hosted, fine-tunable RAG agent."""
2
+
3
+ from neorag.config import load_config
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["load_config", "__version__"]
File without changes
neorag/agent/direct.py ADDED
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from neorag.data.qa_dataset import RAG_QA_SYSTEM_PROMPT
4
+ from neorag.retrieval.passage import Passage, format_context
5
+
6
+
7
+ def direct_answer(llm, retriever, question: str) -> tuple[str, list[Passage]]:
8
+ passages = retriever.retrieve(question)
9
+ if not passages:
10
+ return "I could not find the answer in the provided documents.", []
11
+ context, _ = format_context(passages)
12
+ messages = [
13
+ {"role": "system", "content": RAG_QA_SYSTEM_PROMPT},
14
+ {"role": "user", "content": f"CONTEXT:\n{context}\n\nQUESTION: {question}"},
15
+ ]
16
+ answer = llm.generate(messages)
17
+ return answer, passages
neorag/agent/fast.py ADDED
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ from concurrent.futures import ThreadPoolExecutor
4
+
5
+ from neorag.data.qa_dataset import RAG_QA_SYSTEM_PROMPT
6
+ from neorag.retrieval.passage import Passage
7
+
8
+
9
+ def _build_context(passages: list[Passage], max_chars: int) -> tuple[str, int]:
10
+ lines = []
11
+ used = 0
12
+ count = 0
13
+ for i, p in enumerate(passages, start=1):
14
+ header = f"[{i}] (source: {p.metadata.get('source', 'unknown')})"
15
+ block = f"{header}\n{p.text}"
16
+ if used + len(block) > max_chars:
17
+ break
18
+ lines.append(block)
19
+ used += len(block)
20
+ count = i
21
+ return "\n\n".join(lines), count
22
+
23
+
24
+ def fast_answer(
25
+ llm,
26
+ retriever,
27
+ web_searcher,
28
+ question: str,
29
+ max_context_chars: int = 12000,
30
+ on_phase=None,
31
+ ) -> tuple[str, list[Passage]]:
32
+ phases = on_phase or (lambda s: None)
33
+
34
+ def fetch_web() -> list[dict]:
35
+ if web_searcher is None:
36
+ return []
37
+ try:
38
+ return web_searcher.fetch_pages(question)
39
+ except Exception:
40
+ return []
41
+
42
+ phases("searching local docs and the web")
43
+ with ThreadPoolExecutor(max_workers=2) as pool:
44
+ local_future = pool.submit(retriever.retrieve, question)
45
+ web_future = pool.submit(fetch_web)
46
+ local_passages = list(local_future.result() or [])
47
+ web_pages = web_future.result() or []
48
+
49
+ phases(f"reading {len(local_passages) + len(web_pages)} sources")
50
+ web_passages = (
51
+ web_searcher.rank_pages_to_passages(question, web_pages) if web_searcher else []
52
+ )
53
+ passages = local_passages + web_passages
54
+ if not passages:
55
+ return "I could not find the answer in the provided documents or on the web.", []
56
+
57
+ context, count = _build_context(passages, max_context_chars)
58
+ passages = passages[:count]
59
+
60
+ phases("generating answer")
61
+ messages = [
62
+ {"role": "system", "content": RAG_QA_SYSTEM_PROMPT},
63
+ {"role": "user", "content": f"CONTEXT:\n{context}\n\nQUESTION: {question}"},
64
+ ]
65
+ answer = llm.generate(messages)
66
+ return answer, passages
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ if TYPE_CHECKING:
6
+ from neorag.agent.tools import Tool
7
+
8
+ _INTRO = """You are NeoRAG, a retrieval-grounded answering agent. You answer only from information returned by tools, never from imagination. The tools may search a private document collection, the public web, or both."""
9
+
10
+ _ACTION_FORMAT = """Each turn you must output exactly ONE JSON object and nothing else. No markdown, no extra text. Format:
11
+
12
+ {"thought": "<your reasoning>", "action": "<tool name or answer/give_up>", "action_input": {<tool-specific input>}}"""
13
+
14
+ _RULES = """Rules:
15
+ 1. After each tool call you will receive an OBSERVATION containing numbered passages like [1], [2].
16
+ 2. In the final "answer", cite the numbers of the passages you used, e.g. "citations": [1, 3].
17
+ 3. Never invent facts. If observations do not contain the answer, try another tool or rephrase (up to the step limit) or choose "give_up".
18
+ 4. Keep queries short and keyword-like; rewrite failed queries using synonyms."""
19
+
20
+ _TOOL_DOC_TEMPLATE = 'Tool "{name}": {description}\nInput example: {input_example}'
21
+
22
+
23
+ def _default_input_example(tool: Any) -> str:
24
+ if "query" in tool.description.lower():
25
+ return '{"query": "<search words>"}'
26
+ return '{"value": "<input>"}'
27
+
28
+
29
+ def build_system_prompt(tools: list[Tool]) -> str:
30
+ tool_docs = [
31
+ _TOOL_DOC_TEMPLATE.format(
32
+ name=t.name, description=t.description, input_example=_default_input_example(t)
33
+ )
34
+ for t in tools
35
+ ]
36
+ tool_section = "\n\n".join(tool_docs) if tool_docs else "No tools are available; answer or give_up directly."
37
+ return f"{_INTRO}\n\n{_ACTION_FORMAT}\n\n{_RULES}\n\nAvailable tools:\n{tool_section}\n"
38
+
39
+
40
+ SYSTEM_PROMPT = (
41
+ f"{_INTRO}\n\n{_ACTION_FORMAT}\n\n{_RULES}\n\n"
42
+ 'Tools:\nTool "search": Search the private document collection. Input example: {"query": "<search words>"}\n'
43
+ )
44
+
45
+ OBSERVATION_HEADER = "OBSERVATION (numbered passages retrieved for your query)"
46
+
47
+ LAST_CHANCE_PROMPT = """Based only on the OBSERVATIONS above, now respond with exactly ONE JSON object using the "answer" action:
48
+
49
+ {"thought": "<reasoning>", "action": "answer", "action_input": {"answer": "<final answer grounded in the passages>", "citations": [<numbers>]}}
50
+
51
+ If the passages truly do not contain the answer, use the "give_up" action instead."""
52
+
53
+ NO_RESULTS_OBSERVATION = "No passages matched this query. Try different or simpler keywords."
neorag/agent/react.py ADDED
@@ -0,0 +1,208 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from dataclasses import dataclass, field
6
+
7
+ from neorag.agent.prompts import (
8
+ LAST_CHANCE_PROMPT,
9
+ NO_RESULTS_OBSERVATION,
10
+ OBSERVATION_HEADER,
11
+ build_system_prompt,
12
+ )
13
+ from neorag.agent.tools import Tool
14
+ from neorag.logging_utils import logger
15
+ from neorag.retrieval.passage import Passage
16
+
17
+
18
+ @dataclass
19
+ class AgentResult:
20
+ answer: str
21
+ citations: list[int] = field(default_factory=list)
22
+ passages: dict[int, Passage] = field(default_factory=dict)
23
+ steps_used: int = 0
24
+ gave_up: bool = False
25
+ trace: list[dict] = field(default_factory=list)
26
+
27
+
28
+ def extract_json_object(text: str) -> dict | None:
29
+ text = re.sub(r"```(?:json)?", "", text).strip()
30
+ start = text.find("{")
31
+ while start != -1:
32
+ depth = 0
33
+ in_string = False
34
+ escape = False
35
+ for i in range(start, len(text)):
36
+ ch = text[i]
37
+ if in_string:
38
+ if escape:
39
+ escape = False
40
+ elif ch == "\\":
41
+ escape = True
42
+ elif ch == '"':
43
+ in_string = False
44
+ continue
45
+ if ch == '"':
46
+ in_string = True
47
+ elif ch == "{":
48
+ depth += 1
49
+ elif ch == "}":
50
+ depth -= 1
51
+ if depth == 0:
52
+ candidate = text[start : i + 1]
53
+ try:
54
+ obj = json.loads(candidate)
55
+ return obj if isinstance(obj, dict) else None
56
+ except json.JSONDecodeError:
57
+ break
58
+ start = text.find("{", start + 1)
59
+ return None
60
+
61
+
62
+ def parse_action(text: str) -> dict:
63
+ obj = extract_json_object(text)
64
+ if not obj:
65
+ return {"thought": "", "action": "invalid", "action_input": {}}
66
+ action = str(obj.get("action", "invalid")).strip().lower()
67
+ action_input = obj.get("action_input", obj.get("action_input:", {}))
68
+ if not isinstance(action_input, dict):
69
+ action_input = {"value": action_input}
70
+ thought = str(obj.get("thought", ""))
71
+ return {"thought": thought, "action": action, "action_input": action_input}
72
+
73
+
74
+ def _format_observation(fresh: list[tuple[int, Passage]]) -> str:
75
+ if not fresh:
76
+ return NO_RESULTS_OBSERVATION
77
+ lines = [
78
+ f"[{n}] (source: {p.metadata.get('source', 'unknown')}, score: {p.score:.3f})\n{p.text}"
79
+ for n, p in fresh
80
+ ]
81
+ return "\n\n".join(lines)
82
+
83
+
84
+ class RAGAgent:
85
+ def __init__(self, llm, tools: list[Tool], max_steps: int = 5, system_prompt: str | None = None):
86
+ self.llm = llm
87
+ self.tools = {t.name: t for t in tools}
88
+ self.max_steps = max_steps
89
+ self.system_prompt = system_prompt
90
+
91
+ def run(self, question: str, on_step=None) -> AgentResult:
92
+ system_content = self.system_prompt or build_system_prompt(list(self.tools.values()))
93
+ messages: list[dict[str, str]] = [
94
+ {"role": "system", "content": system_content},
95
+ {"role": "user", "content": question},
96
+ ]
97
+ seen_ids: set[str] = set()
98
+ passage_registry: dict[int, Passage] = {}
99
+ next_number = 1
100
+ trace: list[dict] = []
101
+ gave_up = False
102
+ answer: str | None = None
103
+ citations: list[int] = []
104
+ tool_names = sorted(self.tools.keys())
105
+ consecutive_failures = 0
106
+
107
+ for step in range(1, self.max_steps + 1):
108
+ raw = self.llm.generate(messages)
109
+ parsed = parse_action(raw)
110
+ action = parsed["action"]
111
+ action_input = parsed["action_input"]
112
+ trace.append({"step": step, "thought": parsed["thought"], "action": action, "raw": raw})
113
+ if on_step is not None:
114
+ try:
115
+ on_step({"step": step, "action": action, "thought": parsed["thought"]})
116
+ except Exception:
117
+ pass
118
+ logger.info(f"[agent step {step}] action={action} thought={parsed['thought'][:120]}")
119
+
120
+ if action == "answer":
121
+ candidate = str(action_input.get("answer", "")).strip()
122
+ if candidate:
123
+ answer = candidate
124
+ citations = self._validate_citations(action_input.get("citations"), passage_registry)
125
+ break
126
+ consecutive_failures += 1
127
+ if consecutive_failures >= 2:
128
+ break
129
+
130
+ if action == "give_up":
131
+ gave_up = True
132
+ answer = str(action_input.get("answer", "")).strip() or (
133
+ "I could not find the answer in the provided documents."
134
+ )
135
+ break
136
+
137
+ tool = self.tools.get(action)
138
+ if tool is not None:
139
+ result = tool.fn(action_input)
140
+ consecutive_failures = 0
141
+ fresh = [p for p in result.passages if p.id not in seen_ids]
142
+ for p in fresh:
143
+ seen_ids.add(p.id)
144
+ numbered = []
145
+ for p in fresh:
146
+ passage_registry[next_number] = p
147
+ numbered.append((next_number, p))
148
+ next_number += 1
149
+ observation = _format_observation(numbered)
150
+ messages.append({"role": "assistant", "content": raw})
151
+ messages.append({"role": "user", "content": f"{OBSERVATION_HEADER}:\n{observation}"})
152
+ continue
153
+
154
+ messages.append({"role": "assistant", "content": raw})
155
+ consecutive_failures += 1
156
+ if consecutive_failures >= 2:
157
+ break
158
+ messages.append(
159
+ {
160
+ "role": "user",
161
+ "content": (
162
+ "That was not a valid action. Respond with exactly one JSON object using "
163
+ f'one of these actions: {", ".join(tool_names + ["answer", "give_up"])}. '
164
+ 'Example: {"thought": "...", "action": "...", "action_input": {...}}'
165
+ ),
166
+ }
167
+ )
168
+
169
+ if answer is None:
170
+ logger.info("Step limit reached; forcing a final grounded answer.")
171
+ messages.append({"role": "user", "content": LAST_CHANCE_PROMPT})
172
+ raw = self.llm.generate(messages)
173
+ parsed = parse_action(raw)
174
+ answer = str(parsed["action_input"].get("answer", "")).strip()
175
+ citations = self._validate_citations(
176
+ parsed["action_input"].get("citations"), passage_registry
177
+ )
178
+ if parsed["action"] == "give_up":
179
+ gave_up = True
180
+ if not answer:
181
+ gave_up = True
182
+ answer = "I could not find the answer in the provided documents."
183
+
184
+ return AgentResult(
185
+ answer=answer,
186
+ citations=citations,
187
+ passages=passage_registry,
188
+ steps_used=len(trace),
189
+ gave_up=gave_up,
190
+ trace=trace,
191
+ )
192
+
193
+ def _validate_citations(
194
+ self, raw_citations: object, registry: dict[int, Passage]
195
+ ) -> list[int]:
196
+ if isinstance(raw_citations, (int, float)):
197
+ raw_citations = [raw_citations]
198
+ if not isinstance(raw_citations, list):
199
+ return []
200
+ valid = []
201
+ for c in raw_citations:
202
+ try:
203
+ n = int(c)
204
+ except (TypeError, ValueError):
205
+ continue
206
+ if n in registry:
207
+ valid.append(n)
208
+ return sorted(set(valid))
neorag/agent/tools.py ADDED
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Callable
5
+
6
+ from neorag.agent.prompts import NO_RESULTS_OBSERVATION
7
+ from neorag.retrieval.passage import Passage, format_context
8
+ from neorag.retrieval.retriever import Retriever
9
+
10
+
11
+ @dataclass
12
+ class ToolResult:
13
+ observation: str
14
+ passages: list[Passage]
15
+
16
+
17
+ @dataclass
18
+ class Tool:
19
+ name: str
20
+ description: str
21
+ fn: Callable[[dict], ToolResult]
22
+
23
+
24
+ class SearchDocumentsTool:
25
+ def __init__(self, retriever: Retriever, top_k: int | None = None):
26
+ self.retriever = retriever
27
+ self.top_k = top_k
28
+
29
+ def __call__(self, action_input: dict) -> ToolResult:
30
+ query = str(action_input.get("query", "")).strip()
31
+ if not query:
32
+ return ToolResult(observation=NO_RESULTS_OBSERVATION, passages=[])
33
+ retriever = self.retriever
34
+ if self.top_k:
35
+ original_final = retriever.final_k
36
+ retriever.final_k = self.top_k
37
+ try:
38
+ passages = retriever.retrieve(query)
39
+ finally:
40
+ retriever.final_k = original_final
41
+ else:
42
+ passages = retriever.retrieve(query)
43
+ if not passages:
44
+ return ToolResult(observation=NO_RESULTS_OBSERVATION, passages=[])
45
+ text, _ = format_context(passages)
46
+ return ToolResult(observation=text, passages=passages)
47
+
48
+
49
+ def build_web_tool(web_searcher, top_k: int | None = None) -> Tool:
50
+ from neorag.web.websearch import WebSearchTool
51
+
52
+ searcher = WebSearchTool(web_searcher, top_k=top_k)
53
+ return Tool(
54
+ name="web_search",
55
+ description=(
56
+ "Search the public web for fresh information. "
57
+ 'Input: {"query": "<search words>"}. Returns numbered passages with URLs as sources.'
58
+ ),
59
+ fn=searcher,
60
+ )
61
+
62
+
63
+ def default_tools(
64
+ retriever: Retriever,
65
+ web_searcher=None,
66
+ search_top_k: int | None = None,
67
+ web_top_k: int | None = None,
68
+ ) -> list[Tool]:
69
+ search = Tool(
70
+ name="search",
71
+ description=(
72
+ "Search the private document collection. "
73
+ 'Input: {"query": "<search words>"}. Returns numbered passages.'
74
+ ),
75
+ fn=SearchDocumentsTool(retriever, top_k=search_top_k),
76
+ )
77
+ tools = [search]
78
+ if web_searcher is not None:
79
+ tools.append(build_web_tool(web_searcher, top_k=web_top_k))
80
+ return tools