Spaces:
Runtime error
Runtime error
tech-envision
commited on
Commit
·
7a7b1d3
1
Parent(s):
aee9883
Add document upload support and update prompt
Browse files- README.md +10 -1
- run.py +2 -3
- src/chat.py +31 -7
- src/config.py +8 -3
- src/db.py +20 -1
- src/tools.py +2 -2
- src/vm.py +10 -1
README.md
CHANGED
@@ -6,7 +6,8 @@ database using Peewee. Histories are persisted per user and session so
|
|
6 |
conversations can be resumed with context. One example tool is included:
|
7 |
|
8 |
* **execute_terminal** – Executes a shell command inside a persistent Linux VM
|
9 |
-
with network access.
|
|
|
10 |
returned. The VM is created when a chat session starts and reused for all
|
11 |
subsequent tool calls.
|
12 |
|
@@ -22,6 +23,14 @@ python run.py
|
|
22 |
|
23 |
The script will instruct the model to run a simple shell command and print the result. Conversations are automatically persisted to `chat.db` and are now associated with a user and session.
|
24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
25 |
## Docker
|
26 |
|
27 |
A Dockerfile is provided to run the Discord bot along with an Ollama server. The image installs Ollama, pulls the LLM and embedding models, and starts both the server and the bot.
|
|
|
6 |
conversations can be resumed with context. One example tool is included:
|
7 |
|
8 |
* **execute_terminal** – Executes a shell command inside a persistent Linux VM
|
9 |
+
with network access. Use it to read uploaded documents under ``/data`` or run
|
10 |
+
other commands. Output from ``stdout`` and ``stderr`` is captured and
|
11 |
returned. The VM is created when a chat session starts and reused for all
|
12 |
subsequent tool calls.
|
13 |
|
|
|
23 |
|
24 |
The script will instruct the model to run a simple shell command and print the result. Conversations are automatically persisted to `chat.db` and are now associated with a user and session.
|
25 |
|
26 |
+
Uploaded files are stored under the `uploads` directory and mounted inside the VM at `/data`. Call ``upload_document`` on the chat session to make a file available to the model:
|
27 |
+
|
28 |
+
```python
|
29 |
+
async with ChatSession() as chat:
|
30 |
+
path_in_vm = chat.upload_document("path/to/file.pdf")
|
31 |
+
reply = await chat.chat(f"Summarize {path_in_vm}")
|
32 |
+
```
|
33 |
+
|
34 |
## Docker
|
35 |
|
36 |
A Dockerfile is provided to run the Discord bot along with an Ollama server. The image installs Ollama, pulls the LLM and embedding models, and starts both the server and the bot.
|
run.py
CHANGED
@@ -7,9 +7,8 @@ from src.chat import ChatSession
|
|
7 |
|
8 |
async def _main() -> None:
|
9 |
async with ChatSession(user="demo_user", session="demo_session") as chat:
|
10 |
-
|
11 |
-
|
12 |
-
)
|
13 |
print("\n>>>", answer)
|
14 |
|
15 |
|
|
|
7 |
|
8 |
async def _main() -> None:
|
9 |
async with ChatSession(user="demo_user", session="demo_session") as chat:
|
10 |
+
doc_path = chat.upload_document("README.md")
|
11 |
+
answer = await chat.chat(f"List the first three lines of {doc_path}")
|
|
|
12 |
print("\n>>>", answer)
|
13 |
|
14 |
|
src/chat.py
CHANGED
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|
2 |
|
3 |
from typing import List
|
4 |
import json
|
|
|
|
|
5 |
|
6 |
from ollama import AsyncClient, ChatResponse, Message
|
7 |
|
@@ -12,8 +14,16 @@ from .config import (
|
|
12 |
NUM_CTX,
|
13 |
OLLAMA_HOST,
|
14 |
SYSTEM_PROMPT,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
15 |
)
|
16 |
-
from .db import Conversation, Message as DBMessage, User, _db, init_db
|
17 |
from .log import get_logger
|
18 |
from .schema import Msg
|
19 |
from .tools import execute_terminal, set_vm
|
@@ -53,6 +63,24 @@ class ChatSession:
|
|
53 |
if not _db.is_closed():
|
54 |
_db.close()
|
55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
56 |
def _ensure_system_prompt(self) -> None:
|
57 |
if any(m.get("role") == "system" for m in self._messages):
|
58 |
return
|
@@ -84,9 +112,7 @@ class ChatSession:
|
|
84 |
return messages
|
85 |
|
86 |
@staticmethod
|
87 |
-
def _store_assistant_message(
|
88 |
-
conversation: Conversation, message: Message
|
89 |
-
) -> None:
|
90 |
"""Persist assistant messages, storing tool calls when present."""
|
91 |
|
92 |
if message.tool_calls:
|
@@ -135,9 +161,7 @@ class ChatSession:
|
|
135 |
)
|
136 |
nxt = await self.ask(messages, think=True)
|
137 |
self._store_assistant_message(conversation, nxt.message)
|
138 |
-
return await self._handle_tool_calls(
|
139 |
-
messages, nxt, conversation, depth + 1
|
140 |
-
)
|
141 |
|
142 |
return response
|
143 |
|
|
|
2 |
|
3 |
from typing import List
|
4 |
import json
|
5 |
+
import shutil
|
6 |
+
from pathlib import Path
|
7 |
|
8 |
from ollama import AsyncClient, ChatResponse, Message
|
9 |
|
|
|
14 |
NUM_CTX,
|
15 |
OLLAMA_HOST,
|
16 |
SYSTEM_PROMPT,
|
17 |
+
UPLOAD_DIR,
|
18 |
+
)
|
19 |
+
from .db import (
|
20 |
+
Conversation,
|
21 |
+
Message as DBMessage,
|
22 |
+
User,
|
23 |
+
_db,
|
24 |
+
init_db,
|
25 |
+
add_document,
|
26 |
)
|
|
|
27 |
from .log import get_logger
|
28 |
from .schema import Msg
|
29 |
from .tools import execute_terminal, set_vm
|
|
|
63 |
if not _db.is_closed():
|
64 |
_db.close()
|
65 |
|
66 |
+
def upload_document(self, file_path: str) -> str:
|
67 |
+
"""Save a document for later access inside the VM.
|
68 |
+
|
69 |
+
The file is copied into ``UPLOAD_DIR`` and recorded in the database. The
|
70 |
+
returned path is the location inside the VM (prefixed with ``/data``).
|
71 |
+
"""
|
72 |
+
|
73 |
+
src = Path(file_path)
|
74 |
+
if not src.exists():
|
75 |
+
raise FileNotFoundError(file_path)
|
76 |
+
|
77 |
+
dest = Path(UPLOAD_DIR) / self._user.username
|
78 |
+
dest.mkdir(parents=True, exist_ok=True)
|
79 |
+
target = dest / src.name
|
80 |
+
shutil.copy(src, target)
|
81 |
+
add_document(self._user.username, str(target), src.name)
|
82 |
+
return f"/data/{self._user.username}/{src.name}"
|
83 |
+
|
84 |
def _ensure_system_prompt(self) -> None:
|
85 |
if any(m.get("role") == "system" for m in self._messages):
|
86 |
return
|
|
|
112 |
return messages
|
113 |
|
114 |
@staticmethod
|
115 |
+
def _store_assistant_message(conversation: Conversation, message: Message) -> None:
|
|
|
|
|
116 |
"""Persist assistant messages, storing tool calls when present."""
|
117 |
|
118 |
if message.tool_calls:
|
|
|
161 |
)
|
162 |
nxt = await self.ask(messages, think=True)
|
163 |
self._store_assistant_message(conversation, nxt.message)
|
164 |
+
return await self._handle_tool_calls(messages, nxt, conversation, depth + 1)
|
|
|
|
|
165 |
|
166 |
return response
|
167 |
|
src/config.py
CHANGED
@@ -1,18 +1,23 @@
|
|
1 |
from __future__ import annotations
|
2 |
|
3 |
import os
|
|
|
4 |
from typing import Final
|
5 |
|
6 |
MODEL_NAME: Final[str] = os.getenv("OLLAMA_MODEL", "qwen3:1.7b")
|
7 |
-
EMBEDDING_MODEL_NAME: Final[str] = os.getenv(
|
|
|
|
|
8 |
OLLAMA_HOST: Final[str] = os.getenv("OLLAMA_HOST", "http://localhost:11434")
|
9 |
MAX_TOOL_CALL_DEPTH: Final[int] = 5
|
10 |
NUM_CTX: Final[int] = int(os.getenv("OLLAMA_NUM_CTX", "16000"))
|
|
|
11 |
|
12 |
SYSTEM_PROMPT: Final[str] = (
|
13 |
"You are a versatile AI assistant named Starlette able to orchestrate several tools to "
|
14 |
"complete tasks. Plan your responses carefully and, when needed, call one "
|
15 |
"or more tools consecutively to gather data, compute answers, or transform "
|
16 |
-
"information.
|
17 |
-
"
|
|
|
18 |
)
|
|
|
1 |
from __future__ import annotations
|
2 |
|
3 |
import os
|
4 |
+
from pathlib import Path
|
5 |
from typing import Final
|
6 |
|
7 |
MODEL_NAME: Final[str] = os.getenv("OLLAMA_MODEL", "qwen3:1.7b")
|
8 |
+
EMBEDDING_MODEL_NAME: Final[str] = os.getenv(
|
9 |
+
"OLLAMA_EMBEDDING_MODEL", "snowflake-arctic-embed:137m"
|
10 |
+
) # unused for now
|
11 |
OLLAMA_HOST: Final[str] = os.getenv("OLLAMA_HOST", "http://localhost:11434")
|
12 |
MAX_TOOL_CALL_DEPTH: Final[int] = 5
|
13 |
NUM_CTX: Final[int] = int(os.getenv("OLLAMA_NUM_CTX", "16000"))
|
14 |
+
UPLOAD_DIR: Final[str] = os.getenv("UPLOAD_DIR", str(Path.cwd() / "uploads"))
|
15 |
|
16 |
SYSTEM_PROMPT: Final[str] = (
|
17 |
"You are a versatile AI assistant named Starlette able to orchestrate several tools to "
|
18 |
"complete tasks. Plan your responses carefully and, when needed, call one "
|
19 |
"or more tools consecutively to gather data, compute answers, or transform "
|
20 |
+
"information. Uploaded documents are available under /data and can be read "
|
21 |
+
"or modified using the execute_terminal tool. Continue chaining tools until "
|
22 |
+
"the user's request is fully addressed and then deliver a concise, coherent final reply."
|
23 |
)
|
src/db.py
CHANGED
@@ -46,12 +46,22 @@ class Message(BaseModel):
|
|
46 |
created_at = DateTimeField(default=datetime.utcnow)
|
47 |
|
48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
49 |
__all__ = [
|
50 |
"_db",
|
51 |
"User",
|
52 |
"Conversation",
|
53 |
"Message",
|
|
|
54 |
"reset_history",
|
|
|
55 |
]
|
56 |
|
57 |
|
@@ -59,7 +69,7 @@ def init_db() -> None:
|
|
59 |
"""Initialise the database and create tables if they do not exist."""
|
60 |
if _db.is_closed():
|
61 |
_db.connect()
|
62 |
-
_db.create_tables([User, Conversation, Message])
|
63 |
|
64 |
|
65 |
def reset_history(username: str, session_name: str) -> int:
|
@@ -79,3 +89,12 @@ def reset_history(username: str, session_name: str) -> int:
|
|
79 |
if not Conversation.select().where(Conversation.user == user).exists():
|
80 |
user.delete_instance()
|
81 |
return deleted
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
46 |
created_at = DateTimeField(default=datetime.utcnow)
|
47 |
|
48 |
|
49 |
+
class Document(BaseModel):
|
50 |
+
id = AutoField()
|
51 |
+
user = ForeignKeyField(User, backref="documents")
|
52 |
+
file_path = CharField()
|
53 |
+
original_name = CharField()
|
54 |
+
created_at = DateTimeField(default=datetime.utcnow)
|
55 |
+
|
56 |
+
|
57 |
__all__ = [
|
58 |
"_db",
|
59 |
"User",
|
60 |
"Conversation",
|
61 |
"Message",
|
62 |
+
"Document",
|
63 |
"reset_history",
|
64 |
+
"add_document",
|
65 |
]
|
66 |
|
67 |
|
|
|
69 |
"""Initialise the database and create tables if they do not exist."""
|
70 |
if _db.is_closed():
|
71 |
_db.connect()
|
72 |
+
_db.create_tables([User, Conversation, Message, Document])
|
73 |
|
74 |
|
75 |
def reset_history(username: str, session_name: str) -> int:
|
|
|
89 |
if not Conversation.select().where(Conversation.user == user).exists():
|
90 |
user.delete_instance()
|
91 |
return deleted
|
92 |
+
|
93 |
+
|
94 |
+
def add_document(username: str, file_path: str, original_name: str) -> Document:
|
95 |
+
"""Record an uploaded document and return the created entry."""
|
96 |
+
|
97 |
+
init_db()
|
98 |
+
user, _ = User.get_or_create(username=username)
|
99 |
+
doc = Document.create(user=user, file_path=file_path, original_name=original_name)
|
100 |
+
return doc
|
src/tools.py
CHANGED
@@ -20,7 +20,7 @@ def set_vm(vm: LinuxVM | None) -> None:
|
|
20 |
def execute_terminal(command: str) -> str:
|
21 |
"""
|
22 |
Execute a shell command in a Linux terminal.
|
23 |
-
Use this tool to run
|
24 |
|
25 |
The command is executed with network access enabled. Output from both
|
26 |
``stdout`` and ``stderr`` is captured and returned. Commands are killed if
|
@@ -29,7 +29,7 @@ def execute_terminal(command: str) -> str:
|
|
29 |
timeout = 2
|
30 |
if not command:
|
31 |
return "No command provided."
|
32 |
-
|
33 |
if _VM:
|
34 |
try:
|
35 |
return _VM.execute(command, timeout=timeout)
|
|
|
20 |
def execute_terminal(command: str) -> str:
|
21 |
"""
|
22 |
Execute a shell command in a Linux terminal.
|
23 |
+
Use this tool to inspect uploaded documents under ``/data`` or run other commands.
|
24 |
|
25 |
The command is executed with network access enabled. Output from both
|
26 |
``stdout`` and ``stderr`` is captured and returned. Commands are killed if
|
|
|
29 |
timeout = 2
|
30 |
if not command:
|
31 |
return "No command provided."
|
32 |
+
|
33 |
if _VM:
|
34 |
try:
|
35 |
return _VM.execute(command, timeout=timeout)
|
src/vm.py
CHANGED
@@ -2,6 +2,9 @@ from __future__ import annotations
|
|
2 |
|
3 |
import subprocess
|
4 |
import uuid
|
|
|
|
|
|
|
5 |
|
6 |
from .log import get_logger
|
7 |
|
@@ -11,10 +14,14 @@ _LOG = get_logger(__name__)
|
|
11 |
class LinuxVM:
|
12 |
"""Manage a lightweight Linux VM using Docker."""
|
13 |
|
14 |
-
def __init__(
|
|
|
|
|
15 |
self._image = image
|
16 |
self._name = f"chat-vm-{uuid.uuid4().hex[:8]}"
|
17 |
self._running = False
|
|
|
|
|
18 |
|
19 |
def start(self) -> None:
|
20 |
"""Start the VM if it is not already running."""
|
@@ -36,6 +43,8 @@ class LinuxVM:
|
|
36 |
"-d",
|
37 |
"--name",
|
38 |
self._name,
|
|
|
|
|
39 |
self._image,
|
40 |
"sleep",
|
41 |
"infinity",
|
|
|
2 |
|
3 |
import subprocess
|
4 |
import uuid
|
5 |
+
from pathlib import Path
|
6 |
+
|
7 |
+
from .config import UPLOAD_DIR
|
8 |
|
9 |
from .log import get_logger
|
10 |
|
|
|
14 |
class LinuxVM:
|
15 |
"""Manage a lightweight Linux VM using Docker."""
|
16 |
|
17 |
+
def __init__(
|
18 |
+
self, image: str = "ubuntu:latest", host_dir: str = UPLOAD_DIR
|
19 |
+
) -> None:
|
20 |
self._image = image
|
21 |
self._name = f"chat-vm-{uuid.uuid4().hex[:8]}"
|
22 |
self._running = False
|
23 |
+
self._host_dir = Path(host_dir)
|
24 |
+
self._host_dir.mkdir(parents=True, exist_ok=True)
|
25 |
|
26 |
def start(self) -> None:
|
27 |
"""Start the VM if it is not already running."""
|
|
|
43 |
"-d",
|
44 |
"--name",
|
45 |
self._name,
|
46 |
+
"-v",
|
47 |
+
f"{self._host_dir}:/data",
|
48 |
self._image,
|
49 |
"sleep",
|
50 |
"infinity",
|