prashflow 1.0.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.
- prashflow/__init__.py +25 -0
- prashflow/__version__.py +1 -0
- prashflow/agent.py +112 -0
- prashflow/agentchat.py +5 -0
- prashflow/chat.py +38 -0
- prashflow/cli.py +43 -0
- prashflow/config.py +26 -0
- prashflow/display.py +14 -0
- prashflow/errors.py +15 -0
- prashflow/inmemory.py +87 -0
- prashflow/llm.py +206 -0
- prashflow/loaders.py +40 -0
- prashflow/mcp.py +55 -0
- prashflow/memory.py +11 -0
- prashflow/multiagent.py +140 -0
- prashflow/rag.py +227 -0
- prashflow/rerank.py +30 -0
- prashflow/retrieval.py +38 -0
- prashflow/session.py +21 -0
- prashflow/sql_ingest.py +70 -0
- prashflow/tools.py +64 -0
- prashflow/vectorstores.py +91 -0
- prashflow-1.0.0.dist-info/METADATA +665 -0
- prashflow-1.0.0.dist-info/RECORD +27 -0
- prashflow-1.0.0.dist-info/WHEEL +5 -0
- prashflow-1.0.0.dist-info/entry_points.txt +2 -0
- prashflow-1.0.0.dist-info/top_level.txt +1 -0
prashflow/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from .__version__ import __version__
|
|
2
|
+
from .rag import RAG
|
|
3
|
+
from .chat import Chat
|
|
4
|
+
from .inmemory import InMemoryDB, InMemoryVectorStore
|
|
5
|
+
from .agent import Agent
|
|
6
|
+
from .agentchat import AgentChat
|
|
7
|
+
from .multiagent import MultiAgent
|
|
8
|
+
from .tools import tool, ToolManager
|
|
9
|
+
from .config import load_config
|
|
10
|
+
from .errors import PrashFlowError
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"RAG",
|
|
14
|
+
"Chat",
|
|
15
|
+
"InMemoryDB",
|
|
16
|
+
"InMemoryVectorStore",
|
|
17
|
+
"Agent",
|
|
18
|
+
"AgentChat",
|
|
19
|
+
"MultiAgent",
|
|
20
|
+
"tool",
|
|
21
|
+
"ToolManager",
|
|
22
|
+
"load_config",
|
|
23
|
+
"PrashFlowError",
|
|
24
|
+
"__version__",
|
|
25
|
+
]
|
prashflow/__version__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.4.0"
|
prashflow/agent.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from langchain_core.messages import HumanMessage
|
|
2
|
+
from .llm import create_llm
|
|
3
|
+
from .tools import calculator_tool, web_search_tool
|
|
4
|
+
from .mcp import MCPManager
|
|
5
|
+
from .errors import ConfigurationError, AgentError
|
|
6
|
+
from .display import success
|
|
7
|
+
from .session import SessionStore
|
|
8
|
+
|
|
9
|
+
class Agent:
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
llm=None,
|
|
13
|
+
tools=None,
|
|
14
|
+
mcp_servers=None,
|
|
15
|
+
system_prompt="You are a helpful AI assistant.",
|
|
16
|
+
temperature=0,
|
|
17
|
+
max_retries=1,
|
|
18
|
+
session_id="default",
|
|
19
|
+
memory=None,
|
|
20
|
+
**legacy,
|
|
21
|
+
):
|
|
22
|
+
if llm is None:
|
|
23
|
+
llm = legacy.get("model", "ollama:qwen3:8b")
|
|
24
|
+
self.model = create_llm(llm)
|
|
25
|
+
self.system_prompt = system_prompt
|
|
26
|
+
self.max_retries = max_retries
|
|
27
|
+
self.session_id = session_id
|
|
28
|
+
self.sessions = memory if isinstance(memory, SessionStore) else SessionStore(memory)
|
|
29
|
+
self.tools = self._build_tools(tools or [])
|
|
30
|
+
self.mcp = MCPManager(mcp_servers or [])
|
|
31
|
+
self.mcp.validate()
|
|
32
|
+
self._build_graph()
|
|
33
|
+
success("Agent initialized.")
|
|
34
|
+
|
|
35
|
+
def _build_graph(self):
|
|
36
|
+
try:
|
|
37
|
+
from langgraph.prebuilt import create_react_agent
|
|
38
|
+
try:
|
|
39
|
+
self.graph = create_react_agent(
|
|
40
|
+
self.model, self.tools, prompt=self.system_prompt
|
|
41
|
+
)
|
|
42
|
+
except TypeError:
|
|
43
|
+
self.graph = create_react_agent(self.model, self.tools)
|
|
44
|
+
except Exception as exc:
|
|
45
|
+
raise AgentError("Could not initialize LangGraph agent.") from exc
|
|
46
|
+
|
|
47
|
+
def _build_tools(self, items):
|
|
48
|
+
result = []
|
|
49
|
+
for item in items:
|
|
50
|
+
if isinstance(item, str):
|
|
51
|
+
if item == "calculator":
|
|
52
|
+
result.append(calculator_tool())
|
|
53
|
+
elif item == "web_search":
|
|
54
|
+
result.append(web_search_tool())
|
|
55
|
+
else:
|
|
56
|
+
raise ConfigurationError(f"Unknown built-in tool '{item}'.")
|
|
57
|
+
elif callable(item):
|
|
58
|
+
result.append(item)
|
|
59
|
+
else:
|
|
60
|
+
raise ConfigurationError("Tool must be a string or callable.")
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
def add_tool(self, custom_tool):
|
|
64
|
+
if not callable(custom_tool):
|
|
65
|
+
raise ConfigurationError("Custom tool must be callable.")
|
|
66
|
+
self.tools.append(custom_tool)
|
|
67
|
+
self._build_graph()
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
async def discover_mcp_tools(self):
|
|
71
|
+
return await self.mcp.discover_tools()
|
|
72
|
+
|
|
73
|
+
def run(self, query):
|
|
74
|
+
if not query.strip():
|
|
75
|
+
raise ConfigurationError("Agent query cannot be empty.")
|
|
76
|
+
history = self.sessions.history(self.session_id)
|
|
77
|
+
messages = history + [{"role": "user", "content": query}]
|
|
78
|
+
last = None
|
|
79
|
+
for _ in range(self.max_retries + 1):
|
|
80
|
+
try:
|
|
81
|
+
result = self.graph.invoke({"messages": messages})
|
|
82
|
+
outputs = result.get("messages", [])
|
|
83
|
+
if not outputs:
|
|
84
|
+
raise AgentError("Agent returned no messages.")
|
|
85
|
+
answer = getattr(outputs[-1], "content", "") or ""
|
|
86
|
+
self.sessions.append(self.session_id, "user", query)
|
|
87
|
+
self.sessions.append(self.session_id, "assistant", answer)
|
|
88
|
+
return answer
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
last = exc
|
|
91
|
+
raise AgentError(f"Agent failed after {self.max_retries + 1} attempt(s).") from last
|
|
92
|
+
|
|
93
|
+
def stream(self, query):
|
|
94
|
+
if not query.strip():
|
|
95
|
+
raise ConfigurationError("Agent query cannot be empty.")
|
|
96
|
+
history = self.sessions.history(self.session_id)
|
|
97
|
+
messages = history + [{"role": "user", "content": query}]
|
|
98
|
+
parts = []
|
|
99
|
+
try:
|
|
100
|
+
for item in self.graph.stream({"messages": messages}, stream_mode="messages"):
|
|
101
|
+
msg = item[0] if isinstance(item, tuple) else item
|
|
102
|
+
text = getattr(msg, "content", "") or ""
|
|
103
|
+
if text:
|
|
104
|
+
parts.append(text)
|
|
105
|
+
yield text
|
|
106
|
+
self.sessions.append(self.session_id, "user", query)
|
|
107
|
+
self.sessions.append(self.session_id, "assistant", "".join(parts))
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
raise AgentError(f"Agent streaming failed: {exc}") from exc
|
|
110
|
+
|
|
111
|
+
def clear_session(self):
|
|
112
|
+
self.sessions.clear(self.session_id)
|
prashflow/agentchat.py
ADDED
prashflow/chat.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from .llm import create_llm
|
|
2
|
+
from .session import SessionStore
|
|
3
|
+
from .errors import ConfigurationError
|
|
4
|
+
|
|
5
|
+
class Chat:
|
|
6
|
+
"""Provider-agnostic chat with session memory and streaming."""
|
|
7
|
+
def __init__(self, llm=None, memory=None, session_id="default", max_messages=30, **legacy):
|
|
8
|
+
llm = llm or legacy.get("model") or "ollama:qwen3:8b"
|
|
9
|
+
self.llm = create_llm(llm)
|
|
10
|
+
self.sessions = memory if isinstance(memory, SessionStore) else SessionStore(memory, max_messages=max_messages)
|
|
11
|
+
self.session_id = session_id
|
|
12
|
+
|
|
13
|
+
def _messages(self, prompt):
|
|
14
|
+
return self.sessions.history(self.session_id) + [{"role": "user", "content": prompt}]
|
|
15
|
+
|
|
16
|
+
def chat(self, prompt):
|
|
17
|
+
if not prompt or not prompt.strip():
|
|
18
|
+
raise ConfigurationError("Chat prompt cannot be empty.")
|
|
19
|
+
response = self.llm.invoke(self._messages(prompt))
|
|
20
|
+
content = response.content or ""
|
|
21
|
+
self.sessions.append(self.session_id, "user", prompt)
|
|
22
|
+
self.sessions.append(self.session_id, "assistant", content)
|
|
23
|
+
return content
|
|
24
|
+
|
|
25
|
+
def stream(self, prompt):
|
|
26
|
+
if not prompt or not prompt.strip():
|
|
27
|
+
raise ConfigurationError("Chat prompt cannot be empty.")
|
|
28
|
+
parts = []
|
|
29
|
+
for chunk in self.llm.stream(self._messages(prompt)):
|
|
30
|
+
text = getattr(chunk, "content", "") or ""
|
|
31
|
+
if text:
|
|
32
|
+
parts.append(text)
|
|
33
|
+
yield text
|
|
34
|
+
self.sessions.append(self.session_id, "user", prompt)
|
|
35
|
+
self.sessions.append(self.session_id, "assistant", "".join(parts))
|
|
36
|
+
|
|
37
|
+
def clear(self):
|
|
38
|
+
self.sessions.clear(self.session_id)
|
prashflow/cli.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import typer
|
|
3
|
+
from .display import info, success, error
|
|
4
|
+
from .rag import RAG
|
|
5
|
+
|
|
6
|
+
app = typer.Typer(help="PrashFlow CLI")
|
|
7
|
+
|
|
8
|
+
@app.command()
|
|
9
|
+
def doctor():
|
|
10
|
+
import shutil, urllib.request
|
|
11
|
+
info(f"Python: {sys.version.split()[0]}")
|
|
12
|
+
if shutil.which("ollama"):
|
|
13
|
+
success("Ollama executable found.")
|
|
14
|
+
else:
|
|
15
|
+
error("Ollama not found", "Ollama is not on PATH.", "Install Ollama.")
|
|
16
|
+
raise typer.Exit(1)
|
|
17
|
+
try:
|
|
18
|
+
with urllib.request.urlopen("http://localhost:11434/api/tags", timeout=3) as r:
|
|
19
|
+
if r.status == 200:
|
|
20
|
+
success("Ollama server is reachable.")
|
|
21
|
+
except Exception:
|
|
22
|
+
error("Ollama unavailable", "Cannot reach localhost:11434.", "Run: ollama serve")
|
|
23
|
+
raise typer.Exit(1)
|
|
24
|
+
|
|
25
|
+
@app.command()
|
|
26
|
+
def ingest(source: str, db: str = "./chroma_db"):
|
|
27
|
+
try:
|
|
28
|
+
RAG(vector_db={"provider": "chroma", "path": db}).ingest(source)
|
|
29
|
+
except Exception as exc:
|
|
30
|
+
error("Ingestion failed", str(exc))
|
|
31
|
+
raise typer.Exit(1)
|
|
32
|
+
|
|
33
|
+
@app.command()
|
|
34
|
+
def ask(question: str, db: str = "./chroma_db", search_type: str = "hybrid"):
|
|
35
|
+
try:
|
|
36
|
+
rag = RAG(
|
|
37
|
+
vector_db={"provider": "chroma", "path": db},
|
|
38
|
+
retrieval={"type": search_type, "top_k": 5, "candidate_k": 20},
|
|
39
|
+
)
|
|
40
|
+
print(rag.ask(question))
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
error("Question failed", str(exc))
|
|
43
|
+
raise typer.Exit(1)
|
prashflow/config.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import yaml
|
|
4
|
+
from .errors import ConfigurationError
|
|
5
|
+
|
|
6
|
+
def expand_env(value):
|
|
7
|
+
if isinstance(value, dict):
|
|
8
|
+
return {k: expand_env(v) for k, v in value.items()}
|
|
9
|
+
if isinstance(value, list):
|
|
10
|
+
return [expand_env(v) for v in value]
|
|
11
|
+
if isinstance(value, str):
|
|
12
|
+
if value.startswith("${") and value.endswith("}"):
|
|
13
|
+
name = value[2:-1]
|
|
14
|
+
return os.getenv(name, "")
|
|
15
|
+
return value
|
|
16
|
+
return value
|
|
17
|
+
|
|
18
|
+
def load_config(path: str):
|
|
19
|
+
p = Path(path)
|
|
20
|
+
if not p.exists():
|
|
21
|
+
raise ConfigurationError(f"Configuration file not found: {path}")
|
|
22
|
+
try:
|
|
23
|
+
with p.open("r", encoding="utf-8") as f:
|
|
24
|
+
return expand_env(yaml.safe_load(f) or {})
|
|
25
|
+
except Exception as exc:
|
|
26
|
+
raise ConfigurationError(f"Invalid YAML configuration: {path}") from exc
|
prashflow/display.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from rich.console import Console
|
|
2
|
+
from rich.panel import Panel
|
|
3
|
+
|
|
4
|
+
console = Console()
|
|
5
|
+
|
|
6
|
+
def info(msg): console.print(f"[cyan]ℹ[/cyan] {msg}")
|
|
7
|
+
def success(msg): console.print(f"[green]✓[/green] {msg}")
|
|
8
|
+
def warning(msg): console.print(f"[yellow]⚠[/yellow] {msg}")
|
|
9
|
+
|
|
10
|
+
def error(title, msg, fix=None):
|
|
11
|
+
body = msg
|
|
12
|
+
if fix:
|
|
13
|
+
body += f"\n\n[bold]Fix:[/bold]\n{fix}"
|
|
14
|
+
console.print(Panel(body, title=f"❌ {title}", border_style="red"))
|
prashflow/errors.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class PrashFlowError(Exception):
|
|
2
|
+
"""Base exception for the framework."""
|
|
3
|
+
|
|
4
|
+
class ConfigurationError(PrashFlowError): pass
|
|
5
|
+
class LLMError(PrashFlowError): pass
|
|
6
|
+
class LLMConnectionError(LLMError): pass
|
|
7
|
+
class EmbeddingError(PrashFlowError): pass
|
|
8
|
+
class VectorDBError(PrashFlowError): pass
|
|
9
|
+
class DocumentLoadError(PrashFlowError): pass
|
|
10
|
+
class RetrievalError(PrashFlowError): pass
|
|
11
|
+
class RerankerError(PrashFlowError): pass
|
|
12
|
+
class DataSourceError(PrashFlowError): pass
|
|
13
|
+
class ToolExecutionError(PrashFlowError): pass
|
|
14
|
+
class AgentError(PrashFlowError): pass
|
|
15
|
+
class MCPError(PrashFlowError): pass
|
prashflow/inmemory.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
from .errors import ConfigurationError
|
|
4
|
+
|
|
5
|
+
class InMemoryDB:
|
|
6
|
+
"""Simple process-local key/value database for chat state, sessions and metadata."""
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self.data = defaultdict(dict)
|
|
9
|
+
def set(self, namespace, key, value):
|
|
10
|
+
self.data[namespace][key] = value
|
|
11
|
+
def get(self, namespace, key, default=None):
|
|
12
|
+
return self.data.get(namespace, {}).get(key, default)
|
|
13
|
+
def delete(self, namespace, key):
|
|
14
|
+
self.data.get(namespace, {}).pop(key, None)
|
|
15
|
+
def list(self, namespace):
|
|
16
|
+
return dict(self.data.get(namespace, {}))
|
|
17
|
+
def clear(self):
|
|
18
|
+
self.data.clear()
|
|
19
|
+
|
|
20
|
+
class InMemoryVectorStore:
|
|
21
|
+
"""Ephemeral vector store; useful for tests, demos and short-lived agents."""
|
|
22
|
+
def __init__(self, embeddings, **kwargs):
|
|
23
|
+
self.embeddings = embeddings
|
|
24
|
+
self.documents = []
|
|
25
|
+
self.vectors = []
|
|
26
|
+
|
|
27
|
+
def add_documents(self, documents):
|
|
28
|
+
if not documents:
|
|
29
|
+
return []
|
|
30
|
+
vectors = self.embeddings.embed_documents([d.page_content for d in documents])
|
|
31
|
+
self.documents.extend(documents)
|
|
32
|
+
self.vectors.extend(vectors)
|
|
33
|
+
return [d.metadata.get("chunk_id", str(i)) for i, d in enumerate(documents)]
|
|
34
|
+
|
|
35
|
+
def _cosine(self, a, b):
|
|
36
|
+
dot = sum(x*y for x, y in zip(a, b))
|
|
37
|
+
na = math.sqrt(sum(x*x for x in a))
|
|
38
|
+
nb = math.sqrt(sum(x*x for x in b))
|
|
39
|
+
return dot / (na * nb) if na and nb else 0.0
|
|
40
|
+
|
|
41
|
+
def _filtered(self, filter):
|
|
42
|
+
if not filter:
|
|
43
|
+
return list(range(len(self.documents)))
|
|
44
|
+
return [
|
|
45
|
+
i for i, d in enumerate(self.documents)
|
|
46
|
+
if all(d.metadata.get(k) == v for k, v in filter.items())
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
def similarity_search(self, query, k=4, filter=None):
|
|
50
|
+
q = self.embeddings.embed_query(query)
|
|
51
|
+
ids = self._filtered(filter)
|
|
52
|
+
ranked = sorted(ids, key=lambda i: self._cosine(q, self.vectors[i]), reverse=True)
|
|
53
|
+
return [self.documents[i] for i in ranked[:k]]
|
|
54
|
+
|
|
55
|
+
def similarity_search_with_score(self, query, k=4, filter=None):
|
|
56
|
+
q = self.embeddings.embed_query(query)
|
|
57
|
+
ids = self._filtered(filter)
|
|
58
|
+
ranked = sorted(
|
|
59
|
+
((i, self._cosine(q, self.vectors[i])) for i in ids),
|
|
60
|
+
key=lambda x: x[1], reverse=True
|
|
61
|
+
)
|
|
62
|
+
return [(self.documents[i], score) for i, score in ranked[:k]]
|
|
63
|
+
|
|
64
|
+
def max_marginal_relevance_search(self, query, k=4, fetch_k=20, lambda_mult=.5, filter=None):
|
|
65
|
+
q = self.embeddings.embed_query(query)
|
|
66
|
+
ids = self._filtered(filter)
|
|
67
|
+
candidates = sorted(ids, key=lambda i: self._cosine(q, self.vectors[i]), reverse=True)[:fetch_k]
|
|
68
|
+
selected = []
|
|
69
|
+
while candidates and len(selected) < k:
|
|
70
|
+
if not selected:
|
|
71
|
+
best = candidates[0]
|
|
72
|
+
else:
|
|
73
|
+
def mmr(i):
|
|
74
|
+
relevance = self._cosine(q, self.vectors[i])
|
|
75
|
+
diversity = max(
|
|
76
|
+
self._cosine(self.vectors[i], self.vectors[j])
|
|
77
|
+
for j in selected
|
|
78
|
+
)
|
|
79
|
+
return lambda_mult * relevance - (1-lambda_mult) * diversity
|
|
80
|
+
best = max(candidates, key=mmr)
|
|
81
|
+
selected.append(best)
|
|
82
|
+
candidates.remove(best)
|
|
83
|
+
return [self.documents[i] for i in selected]
|
|
84
|
+
|
|
85
|
+
def delete_collection(self):
|
|
86
|
+
self.documents.clear()
|
|
87
|
+
self.vectors.clear()
|
prashflow/llm.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
from typing import Any, Iterator
|
|
2
|
+
from .errors import ConfigurationError, LLMConnectionError
|
|
3
|
+
|
|
4
|
+
def _message_dict(message):
|
|
5
|
+
role = getattr(message, "type", "human")
|
|
6
|
+
role = {"human": "user", "ai": "assistant", "system": "system", "tool": "tool"}.get(role, role)
|
|
7
|
+
content = message.content
|
|
8
|
+
return {"role": role, "content": content}
|
|
9
|
+
|
|
10
|
+
class LiteLLMChatModel:
|
|
11
|
+
"""
|
|
12
|
+
Small LangChain-compatible ChatModel adapter over LiteLLM.
|
|
13
|
+
LiteLLM provides one OpenAI-style interface across many providers,
|
|
14
|
+
including OpenAI, Azure, Anthropic, Gemini, OpenRouter, Ollama and
|
|
15
|
+
OpenAI-compatible endpoints.
|
|
16
|
+
"""
|
|
17
|
+
def __init__(self, model, api_key=None, api_base=None, temperature=0, **kwargs):
|
|
18
|
+
self.model_name = model
|
|
19
|
+
self.api_key = api_key
|
|
20
|
+
self.api_base = api_base
|
|
21
|
+
self.temperature = temperature
|
|
22
|
+
self.kwargs = kwargs
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def _llm_type(self):
|
|
26
|
+
return "litellm"
|
|
27
|
+
|
|
28
|
+
def _call(self, messages, stream=False, **kwargs):
|
|
29
|
+
try:
|
|
30
|
+
import litellm
|
|
31
|
+
except ImportError as exc:
|
|
32
|
+
raise ConfigurationError(
|
|
33
|
+
"LiteLLM is not installed. Run: pip install 'prashflow[litellm]'"
|
|
34
|
+
) from exc
|
|
35
|
+
params = dict(self.kwargs)
|
|
36
|
+
params.update(kwargs)
|
|
37
|
+
params.update({
|
|
38
|
+
"model": self.model_name,
|
|
39
|
+
"messages": [_message_dict(m) for m in messages],
|
|
40
|
+
"temperature": self.temperature,
|
|
41
|
+
"stream": stream,
|
|
42
|
+
})
|
|
43
|
+
if self.api_key:
|
|
44
|
+
params["api_key"] = self.api_key
|
|
45
|
+
if self.api_base:
|
|
46
|
+
params["api_base"] = self.api_base
|
|
47
|
+
return litellm.completion(**params)
|
|
48
|
+
|
|
49
|
+
def invoke(self, messages, **kwargs):
|
|
50
|
+
from langchain_core.messages import AIMessage
|
|
51
|
+
if isinstance(messages, str):
|
|
52
|
+
messages = [{"role": "user", "content": messages}]
|
|
53
|
+
try:
|
|
54
|
+
if messages and isinstance(messages[0], dict):
|
|
55
|
+
class M:
|
|
56
|
+
def __init__(self, d): self.type, self.content = d.get("role"), d.get("content")
|
|
57
|
+
messages = [M(m) for m in messages]
|
|
58
|
+
response = self._call(messages, **kwargs)
|
|
59
|
+
content = response.choices[0].message.content or ""
|
|
60
|
+
return AIMessage(content=content)
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
raise LLMConnectionError(
|
|
63
|
+
f"LiteLLM call failed for model '{self.model_name}': {exc}"
|
|
64
|
+
) from exc
|
|
65
|
+
|
|
66
|
+
def stream(self, messages, **kwargs) -> Iterator[Any]:
|
|
67
|
+
from langchain_core.messages import AIMessageChunk
|
|
68
|
+
try:
|
|
69
|
+
if isinstance(messages, str):
|
|
70
|
+
class M:
|
|
71
|
+
type = "human"
|
|
72
|
+
content = messages
|
|
73
|
+
messages = [M()]
|
|
74
|
+
if messages and isinstance(messages[0], dict):
|
|
75
|
+
class M:
|
|
76
|
+
def __init__(self, d): self.type, self.content = d.get("role"), d.get("content")
|
|
77
|
+
messages = [M(m) for m in messages]
|
|
78
|
+
response = self._call(messages, stream=True, **kwargs)
|
|
79
|
+
for chunk in response:
|
|
80
|
+
content = ""
|
|
81
|
+
try:
|
|
82
|
+
content = chunk.choices[0].delta.content or ""
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
if content:
|
|
86
|
+
yield AIMessageChunk(content=content)
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
raise LLMConnectionError(
|
|
89
|
+
f"LiteLLM streaming failed for model '{self.model_name}': {exc}"
|
|
90
|
+
) from exc
|
|
91
|
+
|
|
92
|
+
def create_llm(config):
|
|
93
|
+
if isinstance(config, str):
|
|
94
|
+
if ":" in config:
|
|
95
|
+
provider, model = config.split(":", 1)
|
|
96
|
+
else:
|
|
97
|
+
provider, model = "ollama", config
|
|
98
|
+
config = {"provider": provider, "model": model}
|
|
99
|
+
config = dict(config or {})
|
|
100
|
+
provider = config.get("provider", "ollama").lower()
|
|
101
|
+
model = config.get("model")
|
|
102
|
+
if not model:
|
|
103
|
+
raise ConfigurationError("LLM model is required.")
|
|
104
|
+
|
|
105
|
+
if provider == "litellm":
|
|
106
|
+
return LiteLLMChatModel(
|
|
107
|
+
model=model,
|
|
108
|
+
api_key=config.get("api_key"),
|
|
109
|
+
api_base=config.get("base_url") or config.get("api_base"),
|
|
110
|
+
temperature=config.get("temperature", 0),
|
|
111
|
+
**config.get("extra", {}),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if provider == "ollama":
|
|
115
|
+
try:
|
|
116
|
+
from langchain_ollama import ChatOllama
|
|
117
|
+
return ChatOllama(
|
|
118
|
+
model=model,
|
|
119
|
+
base_url=config.get("base_url", "http://localhost:11434"),
|
|
120
|
+
temperature=config.get("temperature", 0),
|
|
121
|
+
)
|
|
122
|
+
except Exception as exc:
|
|
123
|
+
raise LLMConnectionError(
|
|
124
|
+
f"Could not initialize Ollama model '{model}'."
|
|
125
|
+
) from exc
|
|
126
|
+
|
|
127
|
+
if provider in {"openai", "openai-compatible"}:
|
|
128
|
+
try:
|
|
129
|
+
from langchain_openai import ChatOpenAI
|
|
130
|
+
except ImportError as exc:
|
|
131
|
+
raise ConfigurationError(
|
|
132
|
+
"Install OpenAI support: pip install 'prashflow[openai]'"
|
|
133
|
+
) from exc
|
|
134
|
+
kwargs = {
|
|
135
|
+
"model": model,
|
|
136
|
+
"temperature": config.get("temperature", 0),
|
|
137
|
+
}
|
|
138
|
+
if config.get("base_url"):
|
|
139
|
+
kwargs["base_url"] = config["base_url"]
|
|
140
|
+
if config.get("api_key"):
|
|
141
|
+
kwargs["api_key"] = config["api_key"]
|
|
142
|
+
return ChatOpenAI(**kwargs)
|
|
143
|
+
|
|
144
|
+
raise ConfigurationError(
|
|
145
|
+
f"Unsupported LLM provider '{provider}'. "
|
|
146
|
+
"Supported: ollama, openai, openai-compatible, litellm."
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def create_embeddings(config):
|
|
150
|
+
if isinstance(config, str):
|
|
151
|
+
provider, model = config.split(":", 1)
|
|
152
|
+
config = {"provider": provider, "model": model}
|
|
153
|
+
config = dict(config or {})
|
|
154
|
+
provider = config.get("provider", "ollama").lower()
|
|
155
|
+
model = config.get("model")
|
|
156
|
+
if not model:
|
|
157
|
+
raise ConfigurationError("Embedding model is required.")
|
|
158
|
+
|
|
159
|
+
if provider == "litellm":
|
|
160
|
+
try:
|
|
161
|
+
import litellm
|
|
162
|
+
except ImportError as exc:
|
|
163
|
+
raise ConfigurationError(
|
|
164
|
+
"Install LiteLLM support: pip install 'prashflow[litellm]'"
|
|
165
|
+
) from exc
|
|
166
|
+
|
|
167
|
+
class LiteLLMEmbeddings:
|
|
168
|
+
def embed_documents(self, texts):
|
|
169
|
+
result = litellm.embedding(
|
|
170
|
+
model=model,
|
|
171
|
+
input=texts,
|
|
172
|
+
api_key=config.get("api_key"),
|
|
173
|
+
api_base=config.get("base_url") or config.get("api_base"),
|
|
174
|
+
)
|
|
175
|
+
return [x["embedding"] for x in result["data"]]
|
|
176
|
+
def embed_query(self, text):
|
|
177
|
+
return self.embed_documents([text])[0]
|
|
178
|
+
return LiteLLMEmbeddings()
|
|
179
|
+
|
|
180
|
+
if provider == "ollama":
|
|
181
|
+
try:
|
|
182
|
+
from langchain_ollama import OllamaEmbeddings
|
|
183
|
+
return OllamaEmbeddings(
|
|
184
|
+
model=model,
|
|
185
|
+
base_url=config.get("base_url", "http://localhost:11434"),
|
|
186
|
+
)
|
|
187
|
+
except Exception as exc:
|
|
188
|
+
raise LLMConnectionError(
|
|
189
|
+
f"Could not initialize Ollama embeddings '{model}'."
|
|
190
|
+
) from exc
|
|
191
|
+
|
|
192
|
+
if provider in {"openai", "openai-compatible"}:
|
|
193
|
+
try:
|
|
194
|
+
from langchain_openai import OpenAIEmbeddings
|
|
195
|
+
except ImportError as exc:
|
|
196
|
+
raise ConfigurationError(
|
|
197
|
+
"Install OpenAI support: pip install 'prashflow[openai]'"
|
|
198
|
+
) from exc
|
|
199
|
+
kwargs = {"model": model}
|
|
200
|
+
if config.get("api_key"):
|
|
201
|
+
kwargs["api_key"] = config["api_key"]
|
|
202
|
+
if config.get("base_url"):
|
|
203
|
+
kwargs["base_url"] = config["base_url"]
|
|
204
|
+
return OpenAIEmbeddings(**kwargs)
|
|
205
|
+
|
|
206
|
+
raise ConfigurationError(f"Unsupported embedding provider '{provider}'.")
|
prashflow/loaders.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from langchain_community.document_loaders import (
|
|
3
|
+
PyPDFLoader, TextLoader, CSVLoader, Docx2txtLoader, WebBaseLoader
|
|
4
|
+
)
|
|
5
|
+
from .errors import DocumentLoadError
|
|
6
|
+
|
|
7
|
+
SUPPORTED = {".pdf", ".txt", ".md", ".csv", ".docx"}
|
|
8
|
+
|
|
9
|
+
def load_file(path: Path):
|
|
10
|
+
try:
|
|
11
|
+
suffix = path.suffix.lower()
|
|
12
|
+
if suffix == ".pdf": return PyPDFLoader(str(path)).load()
|
|
13
|
+
if suffix in {".txt", ".md"}: return TextLoader(str(path), encoding="utf-8").load()
|
|
14
|
+
if suffix == ".csv": return CSVLoader(str(path)).load()
|
|
15
|
+
if suffix == ".docx": return Docx2txtLoader(str(path)).load()
|
|
16
|
+
except Exception as exc:
|
|
17
|
+
raise DocumentLoadError(f"Failed to load {path}: {exc}") from exc
|
|
18
|
+
raise DocumentLoadError(f"Unsupported file type: {suffix}")
|
|
19
|
+
|
|
20
|
+
def load_source(source: str):
|
|
21
|
+
p = Path(source)
|
|
22
|
+
if p.exists():
|
|
23
|
+
if p.is_file():
|
|
24
|
+
return load_file(p)
|
|
25
|
+
docs = []
|
|
26
|
+
for f in p.rglob("*"):
|
|
27
|
+
if f.is_file() and f.suffix.lower() in SUPPORTED:
|
|
28
|
+
try:
|
|
29
|
+
docs.extend(load_file(f))
|
|
30
|
+
except Exception:
|
|
31
|
+
pass
|
|
32
|
+
if not docs:
|
|
33
|
+
raise DocumentLoadError(f"No supported documents found in {source}")
|
|
34
|
+
return docs
|
|
35
|
+
if source.startswith(("http://", "https://")):
|
|
36
|
+
try:
|
|
37
|
+
return WebBaseLoader(source).load()
|
|
38
|
+
except Exception as exc:
|
|
39
|
+
raise DocumentLoadError(f"Could not load URL {source}") from exc
|
|
40
|
+
raise DocumentLoadError(f"Source not found: {source}")
|
prashflow/mcp.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from .errors import MCPError, ConfigurationError
|
|
2
|
+
|
|
3
|
+
class MCPManager:
|
|
4
|
+
"""
|
|
5
|
+
MCP integration boundary.
|
|
6
|
+
|
|
7
|
+
The exact Python MCP SDK transport APIs have changed across SDK releases.
|
|
8
|
+
This adapter keeps server configuration in one place and exposes an
|
|
9
|
+
explicit async method for tool discovery. The synchronous Agent API can
|
|
10
|
+
consume pre-created LangChain tools, while applications using a specific
|
|
11
|
+
MCP SDK release can extend this adapter without changing Agent.
|
|
12
|
+
"""
|
|
13
|
+
def __init__(self, servers=None):
|
|
14
|
+
self.servers = servers or []
|
|
15
|
+
|
|
16
|
+
def validate(self):
|
|
17
|
+
for server in self.servers:
|
|
18
|
+
if not server.get("name"):
|
|
19
|
+
raise ConfigurationError("Every MCP server needs a name.")
|
|
20
|
+
transport = server.get("transport", "stdio")
|
|
21
|
+
if transport == "stdio":
|
|
22
|
+
if not server.get("command"):
|
|
23
|
+
raise ConfigurationError(
|
|
24
|
+
f"MCP stdio server '{server['name']}' requires command."
|
|
25
|
+
)
|
|
26
|
+
elif transport in {"streamable_http", "sse"}:
|
|
27
|
+
if not server.get("url"):
|
|
28
|
+
raise ConfigurationError(
|
|
29
|
+
f"MCP HTTP server '{server['name']}' requires url."
|
|
30
|
+
)
|
|
31
|
+
else:
|
|
32
|
+
raise ConfigurationError(
|
|
33
|
+
f"Unsupported MCP transport '{transport}'."
|
|
34
|
+
)
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
async def discover_tools(self):
|
|
38
|
+
"""
|
|
39
|
+
Return MCP tools when the installed MCP SDK/server adapter supports them.
|
|
40
|
+
|
|
41
|
+
This method deliberately fails clearly instead of pretending to have
|
|
42
|
+
connected to a server. Extend this method for your pinned MCP SDK.
|
|
43
|
+
"""
|
|
44
|
+
self.validate()
|
|
45
|
+
try:
|
|
46
|
+
import mcp # noqa: F401
|
|
47
|
+
except ImportError as exc:
|
|
48
|
+
raise MCPError(
|
|
49
|
+
"MCP support is not installed. Run: pip install 'prashflow[mcp]'"
|
|
50
|
+
) from exc
|
|
51
|
+
raise MCPError(
|
|
52
|
+
"MCP server discovery requires an SDK transport adapter. "
|
|
53
|
+
"The server configuration was validated, but no transport session "
|
|
54
|
+
"was opened by this reference implementation."
|
|
55
|
+
)
|