clovis 0.2.0__tar.gz → 0.4.0__tar.gz
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.
- clovis-0.4.0/PKG-INFO +99 -0
- clovis-0.4.0/README.md +74 -0
- {clovis-0.2.0 → clovis-0.4.0}/pyproject.toml +2 -1
- {clovis-0.2.0 → clovis-0.4.0}/ruvector.db +0 -0
- {clovis-0.2.0 → clovis-0.4.0}/src/clovis/__init__.py +1 -1
- clovis-0.4.0/src/clovis/_client.py +231 -0
- clovis-0.4.0/src/clovis/_deep_think.py +288 -0
- clovis-0.4.0/src/clovis/_search.py +21 -0
- {clovis-0.2.0 → clovis-0.4.0}/src/clovis/_server.py +33 -4
- clovis-0.4.0/test_live.py +108 -0
- clovis-0.2.0/PKG-INFO +0 -79
- clovis-0.2.0/README.md +0 -55
- clovis-0.2.0/src/clovis/_client.py +0 -154
- clovis-0.2.0/test_live.py +0 -78
- {clovis-0.2.0 → clovis-0.4.0}/src/clovis/_cli.py +0 -0
clovis-0.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clovis
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: cloooooo — personal LLM client, prompt/context/thinking interface over local Ollama
|
|
5
|
+
Author: Clovis Sfeir
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: ai,llm,local-ai,ollama,openai
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: ddgs>=0.1
|
|
18
|
+
Requires-Dist: fastapi>=0.111
|
|
19
|
+
Requires-Dist: httpx>=0.27
|
|
20
|
+
Requires-Dist: pydantic>=2.0
|
|
21
|
+
Requires-Dist: rich>=13.0
|
|
22
|
+
Requires-Dist: typer>=0.12
|
|
23
|
+
Requires-Dist: uvicorn[standard]>=0.30
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# clovis
|
|
27
|
+
|
|
28
|
+
Client Python personnel pour un LLM local via [Ollama](https://ollama.com). Interface ultra-simple : `prompt`, `negative_prompt`, `thinking`, `context`.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install clovis
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from clovis import cloooooo
|
|
40
|
+
|
|
41
|
+
ai = cloooooo()
|
|
42
|
+
|
|
43
|
+
# Appel direct
|
|
44
|
+
print(ai("Explique les trous noirs"))
|
|
45
|
+
|
|
46
|
+
# Avec options
|
|
47
|
+
print(ai(
|
|
48
|
+
"Génère un poème sur la mer",
|
|
49
|
+
negative_prompt="pas de rimes",
|
|
50
|
+
thinking=True,
|
|
51
|
+
context="Tu es un poète du 19e siècle.",
|
|
52
|
+
))
|
|
53
|
+
|
|
54
|
+
# Streaming token par token
|
|
55
|
+
for token in ai.stream("Raconte une histoire courte"):
|
|
56
|
+
print(token, end="", flush=True)
|
|
57
|
+
|
|
58
|
+
# Conversation avec mémoire
|
|
59
|
+
conv = ai.conversation(context="Tu es un expert en finance.")
|
|
60
|
+
conv("Explique le CAPM")
|
|
61
|
+
conv("Et ses limites ?") # se souvient de la réponse précédente
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## CLI
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
clovis "Explique les trous noirs" # question directe
|
|
68
|
+
clovis "Génère un poème" --no "sans rimes" # avec negative prompt
|
|
69
|
+
clovis "Résous ce problème" --think # mode réflexion
|
|
70
|
+
clovis repl # conversation interactive
|
|
71
|
+
clovis serve --port 8000 # démarre le serveur API
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## API server
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
clovis serve --port 8000 --key sk-montoken
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Requête :
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
curl -X POST http://localhost:8000/ia \
|
|
84
|
+
-H "Authorization: Bearer sk-montoken" \
|
|
85
|
+
-H "Content-Type: application/json" \
|
|
86
|
+
-d '{"prompt": "Bonjour !", "thinking": false}'
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Réponse : `{"response": "Bonjour ! Comment puis-je t'aider ?"}`
|
|
90
|
+
|
|
91
|
+
Streaming : ajouter `"stream": true` → réponse en `text/plain` token par token.
|
|
92
|
+
|
|
93
|
+
## Config
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
export CLOVIS_MODEL="qwen3-72b-q5km" # modèle Ollama
|
|
97
|
+
export CLOVIS_OLLAMA_URL="http://localhost:11434"
|
|
98
|
+
export CLOVIS_API_KEY="sk-..." # clé API pour le serveur
|
|
99
|
+
```
|
clovis-0.4.0/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# clovis
|
|
2
|
+
|
|
3
|
+
Client Python personnel pour un LLM local via [Ollama](https://ollama.com). Interface ultra-simple : `prompt`, `negative_prompt`, `thinking`, `context`.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install clovis
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from clovis import cloooooo
|
|
15
|
+
|
|
16
|
+
ai = cloooooo()
|
|
17
|
+
|
|
18
|
+
# Appel direct
|
|
19
|
+
print(ai("Explique les trous noirs"))
|
|
20
|
+
|
|
21
|
+
# Avec options
|
|
22
|
+
print(ai(
|
|
23
|
+
"Génère un poème sur la mer",
|
|
24
|
+
negative_prompt="pas de rimes",
|
|
25
|
+
thinking=True,
|
|
26
|
+
context="Tu es un poète du 19e siècle.",
|
|
27
|
+
))
|
|
28
|
+
|
|
29
|
+
# Streaming token par token
|
|
30
|
+
for token in ai.stream("Raconte une histoire courte"):
|
|
31
|
+
print(token, end="", flush=True)
|
|
32
|
+
|
|
33
|
+
# Conversation avec mémoire
|
|
34
|
+
conv = ai.conversation(context="Tu es un expert en finance.")
|
|
35
|
+
conv("Explique le CAPM")
|
|
36
|
+
conv("Et ses limites ?") # se souvient de la réponse précédente
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## CLI
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
clovis "Explique les trous noirs" # question directe
|
|
43
|
+
clovis "Génère un poème" --no "sans rimes" # avec negative prompt
|
|
44
|
+
clovis "Résous ce problème" --think # mode réflexion
|
|
45
|
+
clovis repl # conversation interactive
|
|
46
|
+
clovis serve --port 8000 # démarre le serveur API
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## API server
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
clovis serve --port 8000 --key sk-montoken
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Requête :
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
curl -X POST http://localhost:8000/ia \
|
|
59
|
+
-H "Authorization: Bearer sk-montoken" \
|
|
60
|
+
-H "Content-Type: application/json" \
|
|
61
|
+
-d '{"prompt": "Bonjour !", "thinking": false}'
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Réponse : `{"response": "Bonjour ! Comment puis-je t'aider ?"}`
|
|
65
|
+
|
|
66
|
+
Streaming : ajouter `"stream": true` → réponse en `text/plain` token par token.
|
|
67
|
+
|
|
68
|
+
## Config
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
export CLOVIS_MODEL="qwen3-72b-q5km" # modèle Ollama
|
|
72
|
+
export CLOVIS_OLLAMA_URL="http://localhost:11434"
|
|
73
|
+
export CLOVIS_API_KEY="sk-..." # clé API pour le serveur
|
|
74
|
+
```
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "clovis"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "cloooooo — personal LLM client, prompt/context/thinking interface over local Ollama"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -28,6 +28,7 @@ dependencies = [
|
|
|
28
28
|
"typer>=0.12",
|
|
29
29
|
"pydantic>=2.0",
|
|
30
30
|
"rich>=13.0",
|
|
31
|
+
"ddgs>=0.1",
|
|
31
32
|
]
|
|
32
33
|
|
|
33
34
|
[project.scripts]
|
|
Binary file
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from typing import Iterator, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
_SERVER_URL = "https://cloooooo.com" # API publique par défaut
|
|
10
|
+
_OLLAMA_URL = "http://localhost:11434" # fallback local
|
|
11
|
+
_MODEL = "qwen3-32b"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _build_messages(
|
|
15
|
+
prompt: str,
|
|
16
|
+
context: Optional[str],
|
|
17
|
+
negative_prompt: Optional[str],
|
|
18
|
+
history: list[dict],
|
|
19
|
+
) -> list[dict]:
|
|
20
|
+
system_parts = []
|
|
21
|
+
if context:
|
|
22
|
+
system_parts.append(context)
|
|
23
|
+
if negative_prompt:
|
|
24
|
+
system_parts.append(f"Évite absolument dans ta réponse : {negative_prompt}")
|
|
25
|
+
|
|
26
|
+
messages = []
|
|
27
|
+
if system_parts:
|
|
28
|
+
messages.append({"role": "system", "content": "\n".join(system_parts)})
|
|
29
|
+
messages.extend(history)
|
|
30
|
+
messages.append({"role": "user", "content": prompt})
|
|
31
|
+
return messages
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Conversation:
|
|
35
|
+
def __init__(self, ai: "cloooooo", context: Optional[str] = None) -> None:
|
|
36
|
+
self._ai = ai
|
|
37
|
+
self._context = context
|
|
38
|
+
self._history: list[dict] = []
|
|
39
|
+
|
|
40
|
+
def __call__(self, prompt: str, *, negative_prompt: Optional[str] = None, thinking: bool = False) -> str:
|
|
41
|
+
messages = _build_messages(prompt, self._context, negative_prompt, self._history)
|
|
42
|
+
reply = self._ai._send(messages, think=thinking)
|
|
43
|
+
self._history += [{"role": "user", "content": prompt}, {"role": "assistant", "content": reply}]
|
|
44
|
+
return reply
|
|
45
|
+
|
|
46
|
+
def stream(self, prompt: str, *, negative_prompt: Optional[str] = None, thinking: bool = False) -> Iterator[str]:
|
|
47
|
+
messages = _build_messages(prompt, self._context, negative_prompt, self._history)
|
|
48
|
+
full = ""
|
|
49
|
+
for token in self._ai._stream(messages, think=thinking):
|
|
50
|
+
full += token
|
|
51
|
+
yield token
|
|
52
|
+
self._history += [{"role": "user", "content": prompt}, {"role": "assistant", "content": full}]
|
|
53
|
+
|
|
54
|
+
def reset(self) -> None:
|
|
55
|
+
self._history.clear()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class cloooooo:
|
|
59
|
+
"""
|
|
60
|
+
from clovis import cloooooo
|
|
61
|
+
|
|
62
|
+
ai = cloooooo() # → cloooooo.com (aucune config requise)
|
|
63
|
+
ai = cloooooo(local=True) # → localhost:11434
|
|
64
|
+
ai = cloooooo(server="http://...") # → serveur custom
|
|
65
|
+
|
|
66
|
+
print(ai("Explique les trous noirs"))
|
|
67
|
+
print(ai("Écris un poème", negative_prompt="sans rimes", thinking=True))
|
|
68
|
+
for token in ai.stream("Raconte une histoire"): print(token, end="", flush=True)
|
|
69
|
+
conv = ai.conversation(context="Tu es un expert en finance")
|
|
70
|
+
conv("Explique le CAPM") ; conv("Et ses limites ?")
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
server: Optional[str] = None,
|
|
76
|
+
*,
|
|
77
|
+
local: bool = False,
|
|
78
|
+
ollama_url: Optional[str] = None,
|
|
79
|
+
model: str = _MODEL,
|
|
80
|
+
) -> None:
|
|
81
|
+
# Priorité : server arg > CLOVIS_SERVER env > cloooooo.com
|
|
82
|
+
# Si local=True ou CLOVIS_OLLAMA_URL défini → mode Ollama direct
|
|
83
|
+
env_server = os.getenv("CLOVIS_SERVER")
|
|
84
|
+
env_ollama = os.getenv("CLOVIS_OLLAMA_URL")
|
|
85
|
+
|
|
86
|
+
if local or ollama_url or env_ollama:
|
|
87
|
+
self._mode = "ollama"
|
|
88
|
+
self._url = ollama_url or env_ollama or _OLLAMA_URL
|
|
89
|
+
self._model = os.getenv("CLOVIS_MODEL", model)
|
|
90
|
+
else:
|
|
91
|
+
self._mode = "server"
|
|
92
|
+
self._url = server or env_server or _SERVER_URL
|
|
93
|
+
|
|
94
|
+
self._http = httpx.Client()
|
|
95
|
+
|
|
96
|
+
def __call__(self, prompt: str, *, negative_prompt: Optional[str] = None, thinking: bool = False, context: Optional[str] = None, search: bool = False) -> str:
|
|
97
|
+
if self._mode == "server":
|
|
98
|
+
return self._call_server(prompt, negative_prompt=negative_prompt, thinking=thinking, context=context, search=search)
|
|
99
|
+
if search:
|
|
100
|
+
from ._search import web_search
|
|
101
|
+
extra = web_search(prompt)
|
|
102
|
+
if extra:
|
|
103
|
+
context = f"{extra}\n\n{context}" if context else extra
|
|
104
|
+
messages = _build_messages(prompt, context, negative_prompt, [])
|
|
105
|
+
return self._send(messages, think=thinking)
|
|
106
|
+
|
|
107
|
+
def stream(self, prompt: str, *, negative_prompt: Optional[str] = None, thinking: bool = False, context: Optional[str] = None, search: bool = False) -> Iterator[str]:
|
|
108
|
+
if self._mode == "server":
|
|
109
|
+
yield from self._stream_server(prompt, negative_prompt=negative_prompt, thinking=thinking, context=context, search=search)
|
|
110
|
+
return
|
|
111
|
+
if search:
|
|
112
|
+
from ._search import web_search
|
|
113
|
+
extra = web_search(prompt)
|
|
114
|
+
if extra:
|
|
115
|
+
context = f"{extra}\n\n{context}" if context else extra
|
|
116
|
+
messages = _build_messages(prompt, context, negative_prompt, [])
|
|
117
|
+
yield from self._stream(messages, think=thinking)
|
|
118
|
+
|
|
119
|
+
def conversation(self, context: Optional[str] = None) -> Conversation:
|
|
120
|
+
return Conversation(self, context=context)
|
|
121
|
+
|
|
122
|
+
def deep_think(
|
|
123
|
+
self,
|
|
124
|
+
prompt: str,
|
|
125
|
+
*,
|
|
126
|
+
max_iterations: int = 4,
|
|
127
|
+
searches_per_step: int = 3,
|
|
128
|
+
on_progress: "Optional[callable]" = None,
|
|
129
|
+
) -> str:
|
|
130
|
+
if self._mode == "server":
|
|
131
|
+
resp = self._http.post(
|
|
132
|
+
f"{self._url}/deep_think",
|
|
133
|
+
json={"prompt": prompt, "max_iterations": max_iterations, "searches_per_step": searches_per_step},
|
|
134
|
+
timeout=600,
|
|
135
|
+
)
|
|
136
|
+
resp.raise_for_status()
|
|
137
|
+
return resp.json()["response"]
|
|
138
|
+
from ._deep_think import deep_think as _dt
|
|
139
|
+
return _dt(
|
|
140
|
+
prompt,
|
|
141
|
+
ollama_url=self._url,
|
|
142
|
+
model=self._model,
|
|
143
|
+
max_iterations=max_iterations,
|
|
144
|
+
searches_per_step=searches_per_step,
|
|
145
|
+
on_progress=on_progress,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def deep_think_stream(
|
|
149
|
+
self,
|
|
150
|
+
prompt: str,
|
|
151
|
+
*,
|
|
152
|
+
max_iterations: int = 4,
|
|
153
|
+
searches_per_step: int = 3,
|
|
154
|
+
) -> Iterator[str]:
|
|
155
|
+
if self._mode == "server":
|
|
156
|
+
with self._http.stream(
|
|
157
|
+
"POST",
|
|
158
|
+
f"{self._url}/deep_think",
|
|
159
|
+
json={"prompt": prompt, "max_iterations": max_iterations, "searches_per_step": searches_per_step, "stream": True},
|
|
160
|
+
timeout=600,
|
|
161
|
+
) as resp:
|
|
162
|
+
resp.raise_for_status()
|
|
163
|
+
for chunk in resp.iter_text():
|
|
164
|
+
if chunk:
|
|
165
|
+
yield chunk
|
|
166
|
+
return
|
|
167
|
+
from ._deep_think import deep_think_stream as _dts
|
|
168
|
+
yield from _dts(
|
|
169
|
+
prompt,
|
|
170
|
+
ollama_url=self._url,
|
|
171
|
+
model=self._model,
|
|
172
|
+
max_iterations=max_iterations,
|
|
173
|
+
searches_per_step=searches_per_step,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# --- mode server (cloooooo.com/ia) ---
|
|
177
|
+
|
|
178
|
+
def _call_server(self, prompt: str, **kwargs) -> str:
|
|
179
|
+
resp = self._http.post(
|
|
180
|
+
f"{self._url}/ia",
|
|
181
|
+
json={"prompt": prompt, **{k: v for k, v in kwargs.items() if v is not None}},
|
|
182
|
+
timeout=120,
|
|
183
|
+
)
|
|
184
|
+
resp.raise_for_status()
|
|
185
|
+
return resp.json()["response"]
|
|
186
|
+
|
|
187
|
+
def _stream_server(self, prompt: str, **kwargs) -> Iterator[str]:
|
|
188
|
+
with self._http.stream(
|
|
189
|
+
"POST",
|
|
190
|
+
f"{self._url}/ia",
|
|
191
|
+
json={"prompt": prompt, "stream": True, **{k: v for k, v in kwargs.items() if v is not None}},
|
|
192
|
+
timeout=120,
|
|
193
|
+
) as resp:
|
|
194
|
+
resp.raise_for_status()
|
|
195
|
+
for chunk in resp.iter_text():
|
|
196
|
+
if chunk:
|
|
197
|
+
yield chunk
|
|
198
|
+
|
|
199
|
+
# --- mode ollama (local) ---
|
|
200
|
+
|
|
201
|
+
def _send(self, messages: list[dict], think: bool = False) -> str:
|
|
202
|
+
resp = self._http.post(
|
|
203
|
+
f"{self._url}/api/chat",
|
|
204
|
+
json={"model": self._model, "messages": messages, "stream": False, "think": think},
|
|
205
|
+
timeout=120,
|
|
206
|
+
)
|
|
207
|
+
resp.raise_for_status()
|
|
208
|
+
return resp.json()["message"]["content"]
|
|
209
|
+
|
|
210
|
+
def _stream(self, messages: list[dict], think: bool = False) -> Iterator[str]:
|
|
211
|
+
with self._http.stream(
|
|
212
|
+
"POST",
|
|
213
|
+
f"{self._url}/api/chat",
|
|
214
|
+
json={"model": self._model, "messages": messages, "stream": True, "think": think},
|
|
215
|
+
timeout=120,
|
|
216
|
+
) as resp:
|
|
217
|
+
resp.raise_for_status()
|
|
218
|
+
for line in resp.iter_lines():
|
|
219
|
+
if not line:
|
|
220
|
+
continue
|
|
221
|
+
data = json.loads(line)
|
|
222
|
+
token = data.get("message", {}).get("content", "")
|
|
223
|
+
if token:
|
|
224
|
+
yield token
|
|
225
|
+
if data.get("done"):
|
|
226
|
+
break
|
|
227
|
+
|
|
228
|
+
@classmethod
|
|
229
|
+
def serve(cls, port: int = 8000, host: str = "0.0.0.0", api_key: Optional[str] = None) -> None:
|
|
230
|
+
from ._server import start_server
|
|
231
|
+
start_server(host=host, port=port, api_key=api_key)
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from typing import Iterator
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
_PLAN_SYSTEM = """Tu es un assistant de recherche expert.
|
|
10
|
+
Réponds UNIQUEMENT en JSON valide, sans markdown, sans explication."""
|
|
11
|
+
|
|
12
|
+
_REFLECT_SYSTEM = """Tu es un analyste de recherche critique.
|
|
13
|
+
Réponds UNIQUEMENT en JSON valide, sans markdown, sans explication."""
|
|
14
|
+
|
|
15
|
+
_SYNTH_SYSTEM = """Tu es un expert en synthèse d'informations.
|
|
16
|
+
Tu dois produire une réponse complète, structurée et approfondie en te basant uniquement sur les recherches fournies."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _llm_json(prompt: str, system: str, ollama_url: str, model: str, timeout: int = 60) -> dict:
|
|
20
|
+
resp = httpx.post(
|
|
21
|
+
f"{ollama_url}/api/chat",
|
|
22
|
+
json={
|
|
23
|
+
"model": model,
|
|
24
|
+
"messages": [
|
|
25
|
+
{"role": "system", "content": system},
|
|
26
|
+
{"role": "user", "content": prompt},
|
|
27
|
+
],
|
|
28
|
+
"stream": False,
|
|
29
|
+
"think": False,
|
|
30
|
+
"format": "json",
|
|
31
|
+
},
|
|
32
|
+
timeout=timeout,
|
|
33
|
+
)
|
|
34
|
+
resp.raise_for_status()
|
|
35
|
+
raw = resp.json()["message"]["content"]
|
|
36
|
+
# strip markdown fences if model wraps anyway
|
|
37
|
+
raw = re.sub(r"^```(?:json)?\n?", "", raw.strip())
|
|
38
|
+
raw = re.sub(r"\n?```$", "", raw.strip())
|
|
39
|
+
return json.loads(raw)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _llm_text(messages: list[dict], ollama_url: str, model: str, timeout: int = 120, think: bool = False) -> str:
|
|
43
|
+
resp = httpx.post(
|
|
44
|
+
f"{ollama_url}/api/chat",
|
|
45
|
+
json={"model": model, "messages": messages, "stream": False, "think": think},
|
|
46
|
+
timeout=timeout,
|
|
47
|
+
)
|
|
48
|
+
resp.raise_for_status()
|
|
49
|
+
return resp.json()["message"]["content"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _llm_stream(messages: list[dict], ollama_url: str, model: str, think: bool = False) -> Iterator[str]:
|
|
53
|
+
with httpx.stream(
|
|
54
|
+
"POST",
|
|
55
|
+
f"{ollama_url}/api/chat",
|
|
56
|
+
json={"model": model, "messages": messages, "stream": True, "think": think},
|
|
57
|
+
timeout=300,
|
|
58
|
+
) as resp:
|
|
59
|
+
resp.raise_for_status()
|
|
60
|
+
for line in resp.iter_lines():
|
|
61
|
+
if not line:
|
|
62
|
+
continue
|
|
63
|
+
data = json.loads(line)
|
|
64
|
+
token = data.get("message", {}).get("content", "")
|
|
65
|
+
if token:
|
|
66
|
+
yield token
|
|
67
|
+
if data.get("done"):
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _plan(prompt: str, n_queries: int, ollama_url: str, model: str) -> list[str]:
|
|
72
|
+
"""Génère n_queries requêtes de recherche pour répondre au prompt."""
|
|
73
|
+
result = _llm_json(
|
|
74
|
+
f"""Question à approfondir : {prompt}
|
|
75
|
+
|
|
76
|
+
Génère exactement {n_queries} requêtes de recherche web complémentaires et diversifiées pour rassembler toutes les informations nécessaires.
|
|
77
|
+
|
|
78
|
+
Réponds avec ce JSON :
|
|
79
|
+
{{"queries": ["requête1", "requête2", "requête3"]}}""",
|
|
80
|
+
_PLAN_SYSTEM,
|
|
81
|
+
ollama_url,
|
|
82
|
+
model,
|
|
83
|
+
)
|
|
84
|
+
return result.get("queries", [])[:n_queries]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _reflect(
|
|
88
|
+
prompt: str,
|
|
89
|
+
accumulated: list[str],
|
|
90
|
+
iteration: int,
|
|
91
|
+
max_iterations: int,
|
|
92
|
+
n_queries: int,
|
|
93
|
+
ollama_url: str,
|
|
94
|
+
model: str,
|
|
95
|
+
) -> tuple[bool, list[str]]:
|
|
96
|
+
"""Analyse les lacunes et décide si la recherche est suffisante."""
|
|
97
|
+
context_summary = "\n\n---\n\n".join(accumulated[-6:]) # garde les 6 derniers blocs
|
|
98
|
+
result = _llm_json(
|
|
99
|
+
f"""Question initiale : {prompt}
|
|
100
|
+
|
|
101
|
+
Informations collectées jusqu'ici (itération {iteration}/{max_iterations}) :
|
|
102
|
+
{context_summary}
|
|
103
|
+
|
|
104
|
+
Analyse :
|
|
105
|
+
1. Est-ce qu'on a suffisamment d'informations pour répondre complètement et avec précision ?
|
|
106
|
+
2. Quelles lacunes importantes subsistent ?
|
|
107
|
+
3. Génère {n_queries} nouvelles requêtes pour combler ces lacunes.
|
|
108
|
+
|
|
109
|
+
Réponds avec ce JSON :
|
|
110
|
+
{{"satisfied": true/false, "missing": "description des lacunes", "follow_up_queries": ["q1", "q2", "q3"]}}""",
|
|
111
|
+
_REFLECT_SYSTEM,
|
|
112
|
+
ollama_url,
|
|
113
|
+
model,
|
|
114
|
+
timeout=90,
|
|
115
|
+
)
|
|
116
|
+
satisfied = result.get("satisfied", False)
|
|
117
|
+
queries = result.get("follow_up_queries", [])[:n_queries]
|
|
118
|
+
return satisfied, queries
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _extract(
|
|
122
|
+
prompt: str, search_results: str, ollama_url: str, model: str
|
|
123
|
+
) -> str:
|
|
124
|
+
"""Extrait et résume les informations pertinentes des résultats de recherche."""
|
|
125
|
+
return _llm_text(
|
|
126
|
+
[
|
|
127
|
+
{
|
|
128
|
+
"role": "system",
|
|
129
|
+
"content": "Tu es un extracteur d'information précis. Résume uniquement ce qui est pertinent pour la question.",
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"role": "user",
|
|
133
|
+
"content": f"""Question : {prompt}
|
|
134
|
+
|
|
135
|
+
Résultats de recherche :
|
|
136
|
+
{search_results}
|
|
137
|
+
|
|
138
|
+
Extrais et résume les informations clés et pertinentes en 3-5 points.""",
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
ollama_url,
|
|
142
|
+
model,
|
|
143
|
+
timeout=90,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def deep_think(
|
|
148
|
+
prompt: str,
|
|
149
|
+
ollama_url: str = "http://localhost:11434",
|
|
150
|
+
model: str = "qwen3-32b",
|
|
151
|
+
max_iterations: int = 4,
|
|
152
|
+
searches_per_step: int = 3,
|
|
153
|
+
on_progress: "callable | None" = None,
|
|
154
|
+
) -> str:
|
|
155
|
+
"""
|
|
156
|
+
Recherche approfondie multi-itérations avec accès internet.
|
|
157
|
+
|
|
158
|
+
Boucle : plan → search → extract → reflect → (repeat) → synthesize
|
|
159
|
+
"""
|
|
160
|
+
from ._search import web_search
|
|
161
|
+
|
|
162
|
+
def _log(msg: str):
|
|
163
|
+
if on_progress:
|
|
164
|
+
on_progress(msg)
|
|
165
|
+
|
|
166
|
+
all_context: list[str] = []
|
|
167
|
+
|
|
168
|
+
# Étape 1 : Planification
|
|
169
|
+
_log(f"[plan] Génération du plan de recherche...")
|
|
170
|
+
queries = _plan(prompt, searches_per_step, ollama_url, model)
|
|
171
|
+
_log(f"[plan] {len(queries)} requêtes générées : {queries}")
|
|
172
|
+
|
|
173
|
+
for iteration in range(1, max_iterations + 1):
|
|
174
|
+
_log(f"[iter {iteration}/{max_iterations}] Recherche en cours...")
|
|
175
|
+
|
|
176
|
+
# Étape 2 : Recherche
|
|
177
|
+
raw_results = []
|
|
178
|
+
for q in queries:
|
|
179
|
+
_log(f"[search] → {q}")
|
|
180
|
+
result = web_search(q, max_results=5)
|
|
181
|
+
if result:
|
|
182
|
+
raw_results.append(result)
|
|
183
|
+
|
|
184
|
+
if not raw_results:
|
|
185
|
+
_log("[search] Aucun résultat trouvé, arrêt.")
|
|
186
|
+
break
|
|
187
|
+
|
|
188
|
+
combined = "\n\n".join(raw_results)
|
|
189
|
+
|
|
190
|
+
# Étape 3 : Extraction
|
|
191
|
+
_log(f"[extract] Analyse des résultats...")
|
|
192
|
+
summary = _extract(prompt, combined, ollama_url, model)
|
|
193
|
+
all_context.append(f"=== Itération {iteration} ===\n{summary}")
|
|
194
|
+
_log(f"[extract] ✓ {len(summary)} chars extraits")
|
|
195
|
+
|
|
196
|
+
# Étape 4 : Réflexion (pas à la dernière itération)
|
|
197
|
+
if iteration < max_iterations:
|
|
198
|
+
_log(f"[reflect] Analyse des lacunes...")
|
|
199
|
+
satisfied, queries = _reflect(
|
|
200
|
+
prompt, all_context, iteration, max_iterations, searches_per_step, ollama_url, model
|
|
201
|
+
)
|
|
202
|
+
_log(f"[reflect] Satisfait={satisfied}, nouvelles requêtes={queries}")
|
|
203
|
+
if satisfied:
|
|
204
|
+
_log(f"[reflect] Recherche jugée complète à l'itération {iteration}.")
|
|
205
|
+
break
|
|
206
|
+
|
|
207
|
+
# Étape 5 : Synthèse finale
|
|
208
|
+
_log(f"[synthesize] Génération de la réponse finale...")
|
|
209
|
+
full_context = "\n\n".join(all_context)
|
|
210
|
+
final_messages = [
|
|
211
|
+
{"role": "system", "content": _SYNTH_SYSTEM},
|
|
212
|
+
{
|
|
213
|
+
"role": "user",
|
|
214
|
+
"content": f"""Question : {prompt}
|
|
215
|
+
|
|
216
|
+
Résultats de recherche approfondis ({len(all_context)} itérations) :
|
|
217
|
+
{full_context}
|
|
218
|
+
|
|
219
|
+
Fournis une réponse complète, structurée, sourcée et approfondie à cette question.
|
|
220
|
+
Utilise des titres, des points clés, et cite les faits importants trouvés dans la recherche.""",
|
|
221
|
+
},
|
|
222
|
+
]
|
|
223
|
+
answer = _llm_text(final_messages, ollama_url, model, timeout=300, think=True)
|
|
224
|
+
_log(f"[synthesize] ✓ Réponse générée ({len(answer)} chars)")
|
|
225
|
+
return answer
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def deep_think_stream(
|
|
229
|
+
prompt: str,
|
|
230
|
+
ollama_url: str = "http://localhost:11434",
|
|
231
|
+
model: str = "qwen3:14b",
|
|
232
|
+
max_iterations: int = 4,
|
|
233
|
+
searches_per_step: int = 3,
|
|
234
|
+
) -> Iterator[str]:
|
|
235
|
+
"""
|
|
236
|
+
Version streaming : yield des tokens de progression puis la réponse finale.
|
|
237
|
+
Les lignes commençant par '[' sont des logs de progression.
|
|
238
|
+
"""
|
|
239
|
+
from ._search import web_search
|
|
240
|
+
|
|
241
|
+
all_context: list[str] = []
|
|
242
|
+
|
|
243
|
+
yield f"[plan] Génération du plan de recherche...\n"
|
|
244
|
+
queries = _plan(prompt, searches_per_step, ollama_url, model)
|
|
245
|
+
yield f"[plan] Requêtes : {', '.join(queries)}\n"
|
|
246
|
+
|
|
247
|
+
for iteration in range(1, max_iterations + 1):
|
|
248
|
+
yield f"[iter {iteration}/{max_iterations}] Recherche...\n"
|
|
249
|
+
|
|
250
|
+
raw_results = []
|
|
251
|
+
for q in queries:
|
|
252
|
+
yield f"[search] → {q}\n"
|
|
253
|
+
result = web_search(q, max_results=5)
|
|
254
|
+
if result:
|
|
255
|
+
raw_results.append(result)
|
|
256
|
+
|
|
257
|
+
if not raw_results:
|
|
258
|
+
break
|
|
259
|
+
|
|
260
|
+
combined = "\n\n".join(raw_results)
|
|
261
|
+
yield f"[extract] Analyse...\n"
|
|
262
|
+
summary = _extract(prompt, combined, ollama_url, model)
|
|
263
|
+
all_context.append(f"=== Itération {iteration} ===\n{summary}")
|
|
264
|
+
|
|
265
|
+
if iteration < max_iterations:
|
|
266
|
+
satisfied, queries = _reflect(
|
|
267
|
+
prompt, all_context, iteration, max_iterations, searches_per_step, ollama_url, model
|
|
268
|
+
)
|
|
269
|
+
yield f"[reflect] Satisfait={satisfied}\n"
|
|
270
|
+
if satisfied:
|
|
271
|
+
break
|
|
272
|
+
|
|
273
|
+
yield f"[synthesize] Génération de la réponse finale...\n\n"
|
|
274
|
+
|
|
275
|
+
full_context = "\n\n".join(all_context)
|
|
276
|
+
final_messages = [
|
|
277
|
+
{"role": "system", "content": _SYNTH_SYSTEM},
|
|
278
|
+
{
|
|
279
|
+
"role": "user",
|
|
280
|
+
"content": f"""Question : {prompt}
|
|
281
|
+
|
|
282
|
+
Résultats de recherche approfondis ({len(all_context)} itérations) :
|
|
283
|
+
{full_context}
|
|
284
|
+
|
|
285
|
+
Fournis une réponse complète, structurée, sourcée et approfondie.""",
|
|
286
|
+
},
|
|
287
|
+
]
|
|
288
|
+
yield from _llm_stream(final_messages, ollama_url, model, think=True)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def web_search(query: str, max_results: int = 4) -> str:
|
|
5
|
+
"""Retourne un bloc de contexte avec les résultats DuckDuckGo."""
|
|
6
|
+
try:
|
|
7
|
+
from ddgs import DDGS
|
|
8
|
+
except ImportError:
|
|
9
|
+
return ""
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
with DDGS() as ddgs:
|
|
13
|
+
results = list(ddgs.text(query, max_results=max_results))
|
|
14
|
+
except Exception:
|
|
15
|
+
return ""
|
|
16
|
+
|
|
17
|
+
if not results:
|
|
18
|
+
return ""
|
|
19
|
+
|
|
20
|
+
lines = [f"- {r['title']}: {r['body']}" for r in results]
|
|
21
|
+
return "Résultats de recherche web (utilise ces informations pour répondre) :\n" + "\n".join(lines)
|
|
@@ -14,17 +14,25 @@ from ._client import cloooooo
|
|
|
14
14
|
_bearer = HTTPBearer(auto_error=False)
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
class DeepThinkRequest(BaseModel):
|
|
18
|
+
prompt: str
|
|
19
|
+
max_iterations: int = 4
|
|
20
|
+
searches_per_step: int = 3
|
|
21
|
+
stream: bool = False
|
|
22
|
+
|
|
23
|
+
|
|
17
24
|
class IARequest(BaseModel):
|
|
18
25
|
prompt: str
|
|
19
26
|
negative_prompt: Optional[str] = None
|
|
20
27
|
thinking: bool = False
|
|
21
28
|
context: Optional[str] = None
|
|
22
29
|
stream: bool = False
|
|
30
|
+
search: bool = False
|
|
23
31
|
|
|
24
32
|
|
|
25
33
|
def build_app(api_key: Optional[str] = None) -> FastAPI:
|
|
26
|
-
app = FastAPI(title="cloooooo", version="0.
|
|
27
|
-
ai = cloooooo()
|
|
34
|
+
app = FastAPI(title="cloooooo", version="0.4.0")
|
|
35
|
+
ai = cloooooo(local=True)
|
|
28
36
|
|
|
29
37
|
def _check_key(creds: Optional[HTTPAuthorizationCredentials] = Depends(_bearer)):
|
|
30
38
|
if api_key and (not creds or creds.credentials != api_key):
|
|
@@ -32,10 +40,17 @@ def build_app(api_key: Optional[str] = None) -> FastAPI:
|
|
|
32
40
|
|
|
33
41
|
@app.post("/ia", dependencies=[Depends(_check_key)])
|
|
34
42
|
async def ia(req: IARequest):
|
|
43
|
+
context = req.context
|
|
44
|
+
if req.search:
|
|
45
|
+
from ._search import web_search
|
|
46
|
+
search_ctx = web_search(req.prompt)
|
|
47
|
+
if search_ctx:
|
|
48
|
+
context = f"{search_ctx}\n\n{context}" if context else search_ctx
|
|
49
|
+
|
|
35
50
|
kwargs = dict(
|
|
36
51
|
negative_prompt=req.negative_prompt,
|
|
37
52
|
thinking=req.thinking,
|
|
38
|
-
context=
|
|
53
|
+
context=context,
|
|
39
54
|
)
|
|
40
55
|
if req.stream:
|
|
41
56
|
def generate():
|
|
@@ -45,9 +60,23 @@ def build_app(api_key: Optional[str] = None) -> FastAPI:
|
|
|
45
60
|
|
|
46
61
|
return {"response": ai(req.prompt, **kwargs)}
|
|
47
62
|
|
|
63
|
+
@app.post("/deep_think", dependencies=[Depends(_check_key)])
|
|
64
|
+
async def deep_think_endpoint(req: DeepThinkRequest):
|
|
65
|
+
from ._deep_think import deep_think as _dt, deep_think_stream as _dts
|
|
66
|
+
ollama_url = ai._url
|
|
67
|
+
model = ai._model
|
|
68
|
+
if req.stream:
|
|
69
|
+
def generate():
|
|
70
|
+
yield from _dts(req.prompt, ollama_url=ollama_url, model=model,
|
|
71
|
+
max_iterations=req.max_iterations, searches_per_step=req.searches_per_step)
|
|
72
|
+
return StreamingResponse(generate(), media_type="text/plain")
|
|
73
|
+
answer = _dt(req.prompt, ollama_url=ollama_url, model=model,
|
|
74
|
+
max_iterations=req.max_iterations, searches_per_step=req.searches_per_step)
|
|
75
|
+
return {"response": answer}
|
|
76
|
+
|
|
48
77
|
@app.get("/")
|
|
49
78
|
def root():
|
|
50
|
-
return {"status": "ok", "
|
|
79
|
+
return {"status": "ok", "endpoints": ["/ia", "/deep_think"]}
|
|
51
80
|
|
|
52
81
|
return app
|
|
53
82
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Test live end-to-end — lance avec : python3 test_live.py"""
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_import():
|
|
7
|
+
from clovis import cloooooo
|
|
8
|
+
ai = cloooooo()
|
|
9
|
+
print(f" import OK — modèle: {ai._model}, url: {ai._url}")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_basic_call():
|
|
13
|
+
from clovis import cloooooo
|
|
14
|
+
ai = cloooooo()
|
|
15
|
+
resp = ai('Réponds uniquement par le mot "ok".')
|
|
16
|
+
assert isinstance(resp, str) and len(resp) > 0
|
|
17
|
+
print(f" appel OK — réponse: {resp!r}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_negative_prompt():
|
|
21
|
+
from clovis import cloooooo
|
|
22
|
+
ai = cloooooo()
|
|
23
|
+
resp = ai("Présente-toi en 10 mots.", negative_prompt="ne mentionne pas ton nom")
|
|
24
|
+
assert isinstance(resp, str) and len(resp) > 0
|
|
25
|
+
print(f" negative_prompt OK — réponse: {resp!r}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_thinking():
|
|
29
|
+
from clovis import cloooooo
|
|
30
|
+
ai = cloooooo()
|
|
31
|
+
resp = ai("Combien font 17 × 23 ?", thinking=True)
|
|
32
|
+
assert isinstance(resp, str) and len(resp) > 0
|
|
33
|
+
print(f" thinking OK — réponse: {resp!r}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_context():
|
|
37
|
+
from clovis import cloooooo
|
|
38
|
+
ai = cloooooo()
|
|
39
|
+
resp = ai("Comment ça va ?", context="Tu es un pirate des Caraïbes. Réponds toujours en argot de marin.")
|
|
40
|
+
assert isinstance(resp, str) and len(resp) > 0
|
|
41
|
+
print(f" context OK — réponse: {resp!r}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_stream():
|
|
45
|
+
from clovis import cloooooo
|
|
46
|
+
ai = cloooooo()
|
|
47
|
+
tokens = list(ai.stream("Compte jusqu'à 5, un nombre par ligne."))
|
|
48
|
+
assert len(tokens) > 0
|
|
49
|
+
full = "".join(tokens)
|
|
50
|
+
assert len(full) > 0
|
|
51
|
+
print(f" stream OK — {len(tokens)} tokens, texte: {full!r}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_conversation():
|
|
55
|
+
from clovis import cloooooo
|
|
56
|
+
ai = cloooooo()
|
|
57
|
+
conv = ai.conversation(context="Réponds toujours en une seule phrase courte.")
|
|
58
|
+
r1 = conv("Mon prénom est Clovis.")
|
|
59
|
+
r2 = conv("Quel est mon prénom ?")
|
|
60
|
+
assert "Clovis" in r2 or "clovis" in r2.lower(), f"Prénom pas mémorisé: {r2!r}"
|
|
61
|
+
print(f" conversation OK — mémoire: {r2!r}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_conversation_stream():
|
|
65
|
+
from clovis import cloooooo
|
|
66
|
+
ai = cloooooo()
|
|
67
|
+
conv = ai.conversation()
|
|
68
|
+
tokens = list(conv.stream("Dis bonjour en 3 langues."))
|
|
69
|
+
full = "".join(tokens)
|
|
70
|
+
assert len(full) > 0
|
|
71
|
+
print(f" conversation stream OK — {len(tokens)} tokens")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_conversation_reset():
|
|
75
|
+
from clovis import cloooooo
|
|
76
|
+
ai = cloooooo()
|
|
77
|
+
conv = ai.conversation()
|
|
78
|
+
conv("Mon prénom est Clovis.")
|
|
79
|
+
conv.reset()
|
|
80
|
+
assert len(conv._history) == 0
|
|
81
|
+
print(" conversation reset OK")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
tests = [
|
|
86
|
+
test_import,
|
|
87
|
+
test_basic_call,
|
|
88
|
+
test_negative_prompt,
|
|
89
|
+
test_thinking,
|
|
90
|
+
test_context,
|
|
91
|
+
test_stream,
|
|
92
|
+
test_conversation,
|
|
93
|
+
test_conversation_stream,
|
|
94
|
+
test_conversation_reset,
|
|
95
|
+
]
|
|
96
|
+
passed = 0
|
|
97
|
+
for t in tests:
|
|
98
|
+
print(f"\n{t.__name__}")
|
|
99
|
+
try:
|
|
100
|
+
t()
|
|
101
|
+
passed += 1
|
|
102
|
+
except Exception as e:
|
|
103
|
+
print(f" FAILED: {e}")
|
|
104
|
+
import traceback; traceback.print_exc()
|
|
105
|
+
|
|
106
|
+
print(f"\n{'='*40}")
|
|
107
|
+
print(f"Résultat: {passed}/{len(tests)} tests passés")
|
|
108
|
+
sys.exit(0 if passed == len(tests) else 1)
|
clovis-0.2.0/PKG-INFO
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: clovis
|
|
3
|
-
Version: 0.2.0
|
|
4
|
-
Summary: cloooooo — personal LLM client, prompt/context/thinking interface over local Ollama
|
|
5
|
-
Author: Clovis Sfeir
|
|
6
|
-
License: MIT
|
|
7
|
-
Keywords: ai,llm,local-ai,ollama,openai
|
|
8
|
-
Classifier: Development Status :: 3 - Alpha
|
|
9
|
-
Classifier: Intended Audience :: Developers
|
|
10
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
-
Classifier: Programming Language :: Python :: 3
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
-
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
16
|
-
Requires-Python: >=3.10
|
|
17
|
-
Requires-Dist: fastapi>=0.111
|
|
18
|
-
Requires-Dist: httpx>=0.27
|
|
19
|
-
Requires-Dist: pydantic>=2.0
|
|
20
|
-
Requires-Dist: rich>=13.0
|
|
21
|
-
Requires-Dist: typer>=0.12
|
|
22
|
-
Requires-Dist: uvicorn[standard]>=0.30
|
|
23
|
-
Description-Content-Type: text/markdown
|
|
24
|
-
|
|
25
|
-
# clovis
|
|
26
|
-
|
|
27
|
-
OpenAI-compatible Python client over a local [Ollama](https://ollama.com) instance.
|
|
28
|
-
|
|
29
|
-
## Install
|
|
30
|
-
|
|
31
|
-
```bash
|
|
32
|
-
pip install clovis
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
## Usage
|
|
36
|
-
|
|
37
|
-
```python
|
|
38
|
-
from clovis import cloooooo
|
|
39
|
-
|
|
40
|
-
client = cloooooo() # connects to localhost:11434 by default
|
|
41
|
-
|
|
42
|
-
# Chat
|
|
43
|
-
resp = client.chat.completions.create(
|
|
44
|
-
model="qwen3-72b",
|
|
45
|
-
messages=[{"role": "user", "content": "Bonjour !"}]
|
|
46
|
-
)
|
|
47
|
-
print(resp.choices[0].message.content)
|
|
48
|
-
|
|
49
|
-
# Streaming
|
|
50
|
-
for chunk in client.chat.completions.create(
|
|
51
|
-
messages=[{"role": "user", "content": "Écris un poème"}],
|
|
52
|
-
stream=True,
|
|
53
|
-
):
|
|
54
|
-
print(chunk.choices[0].delta.get("content", ""), end="", flush=True)
|
|
55
|
-
|
|
56
|
-
# Conversation with auto history
|
|
57
|
-
with client.conversation(system="Tu es un expert en finance.") as conv:
|
|
58
|
-
print(conv.chat("Explique le CAPM"))
|
|
59
|
-
print(conv.chat("Et ses limites ?")) # remembers context
|
|
60
|
-
|
|
61
|
-
# Start API server
|
|
62
|
-
cloooooo.serve(port=8000, api_key="sk-...")
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
## CLI
|
|
66
|
-
|
|
67
|
-
```bash
|
|
68
|
-
clovis "Explique les trous noirs" # direct question
|
|
69
|
-
clovis repl # interactive conversation
|
|
70
|
-
clovis serve --port 8000 # start API server
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
## Config
|
|
74
|
-
|
|
75
|
-
```bash
|
|
76
|
-
export CLOVIS_MODEL="qwen3-72b"
|
|
77
|
-
export CLOVIS_OLLAMA_URL="http://localhost:11434"
|
|
78
|
-
export CLOVIS_API_KEY="sk-..."
|
|
79
|
-
```
|
clovis-0.2.0/README.md
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
# clovis
|
|
2
|
-
|
|
3
|
-
OpenAI-compatible Python client over a local [Ollama](https://ollama.com) instance.
|
|
4
|
-
|
|
5
|
-
## Install
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
pip install clovis
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
## Usage
|
|
12
|
-
|
|
13
|
-
```python
|
|
14
|
-
from clovis import cloooooo
|
|
15
|
-
|
|
16
|
-
client = cloooooo() # connects to localhost:11434 by default
|
|
17
|
-
|
|
18
|
-
# Chat
|
|
19
|
-
resp = client.chat.completions.create(
|
|
20
|
-
model="qwen3-72b",
|
|
21
|
-
messages=[{"role": "user", "content": "Bonjour !"}]
|
|
22
|
-
)
|
|
23
|
-
print(resp.choices[0].message.content)
|
|
24
|
-
|
|
25
|
-
# Streaming
|
|
26
|
-
for chunk in client.chat.completions.create(
|
|
27
|
-
messages=[{"role": "user", "content": "Écris un poème"}],
|
|
28
|
-
stream=True,
|
|
29
|
-
):
|
|
30
|
-
print(chunk.choices[0].delta.get("content", ""), end="", flush=True)
|
|
31
|
-
|
|
32
|
-
# Conversation with auto history
|
|
33
|
-
with client.conversation(system="Tu es un expert en finance.") as conv:
|
|
34
|
-
print(conv.chat("Explique le CAPM"))
|
|
35
|
-
print(conv.chat("Et ses limites ?")) # remembers context
|
|
36
|
-
|
|
37
|
-
# Start API server
|
|
38
|
-
cloooooo.serve(port=8000, api_key="sk-...")
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
## CLI
|
|
42
|
-
|
|
43
|
-
```bash
|
|
44
|
-
clovis "Explique les trous noirs" # direct question
|
|
45
|
-
clovis repl # interactive conversation
|
|
46
|
-
clovis serve --port 8000 # start API server
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
## Config
|
|
50
|
-
|
|
51
|
-
```bash
|
|
52
|
-
export CLOVIS_MODEL="qwen3-72b"
|
|
53
|
-
export CLOVIS_OLLAMA_URL="http://localhost:11434"
|
|
54
|
-
export CLOVIS_API_KEY="sk-..."
|
|
55
|
-
```
|
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import os
|
|
5
|
-
from typing import Iterator, Optional
|
|
6
|
-
|
|
7
|
-
import httpx
|
|
8
|
-
|
|
9
|
-
_OLLAMA_URL = "http://localhost:11434"
|
|
10
|
-
_MODEL = "qwen3-72b-q5km"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def _build_messages(
|
|
14
|
-
prompt: str,
|
|
15
|
-
context: Optional[str],
|
|
16
|
-
negative_prompt: Optional[str],
|
|
17
|
-
thinking: bool,
|
|
18
|
-
history: list[dict],
|
|
19
|
-
) -> list[dict]:
|
|
20
|
-
system_parts = []
|
|
21
|
-
if context:
|
|
22
|
-
system_parts.append(context)
|
|
23
|
-
if negative_prompt:
|
|
24
|
-
system_parts.append(f"Évite absolument dans ta réponse : {negative_prompt}")
|
|
25
|
-
if thinking:
|
|
26
|
-
system_parts.append("Réfléchis étape par étape avant de répondre.")
|
|
27
|
-
|
|
28
|
-
messages = []
|
|
29
|
-
if system_parts:
|
|
30
|
-
messages.append({"role": "system", "content": "\n".join(system_parts)})
|
|
31
|
-
messages.extend(history)
|
|
32
|
-
messages.append({"role": "user", "content": prompt})
|
|
33
|
-
return messages
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
class Conversation:
|
|
37
|
-
def __init__(self, ai: "cloooooo", context: Optional[str] = None) -> None:
|
|
38
|
-
self._ai = ai
|
|
39
|
-
self._context = context
|
|
40
|
-
self._history: list[dict] = []
|
|
41
|
-
|
|
42
|
-
def __call__(
|
|
43
|
-
self,
|
|
44
|
-
prompt: str,
|
|
45
|
-
*,
|
|
46
|
-
negative_prompt: Optional[str] = None,
|
|
47
|
-
thinking: bool = False,
|
|
48
|
-
) -> str:
|
|
49
|
-
messages = _build_messages(prompt, self._context, negative_prompt, thinking, self._history)
|
|
50
|
-
reply = self._ai._send(messages)
|
|
51
|
-
self._history += [{"role": "user", "content": prompt}, {"role": "assistant", "content": reply}]
|
|
52
|
-
return reply
|
|
53
|
-
|
|
54
|
-
def stream(
|
|
55
|
-
self,
|
|
56
|
-
prompt: str,
|
|
57
|
-
*,
|
|
58
|
-
negative_prompt: Optional[str] = None,
|
|
59
|
-
thinking: bool = False,
|
|
60
|
-
) -> Iterator[str]:
|
|
61
|
-
messages = _build_messages(prompt, self._context, negative_prompt, thinking, self._history)
|
|
62
|
-
full = ""
|
|
63
|
-
for token in self._ai._stream(messages):
|
|
64
|
-
full += token
|
|
65
|
-
yield token
|
|
66
|
-
self._history += [{"role": "user", "content": prompt}, {"role": "assistant", "content": full}]
|
|
67
|
-
|
|
68
|
-
def reset(self) -> None:
|
|
69
|
-
self._history.clear()
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
class cloooooo:
|
|
73
|
-
"""
|
|
74
|
-
Usage::
|
|
75
|
-
|
|
76
|
-
from clovis import cloooooo
|
|
77
|
-
|
|
78
|
-
ai = cloooooo()
|
|
79
|
-
print(ai("Explique les trous noirs"))
|
|
80
|
-
print(ai("Génère un poème", negative_prompt="pas de rimes", thinking=True))
|
|
81
|
-
|
|
82
|
-
for token in ai.stream("Raconte une histoire"):
|
|
83
|
-
print(token, end="", flush=True)
|
|
84
|
-
|
|
85
|
-
conv = ai.conversation(context="Tu es un expert en finance")
|
|
86
|
-
conv("Explique le CAPM")
|
|
87
|
-
conv("Et ses limites ?") # se souvient du contexte
|
|
88
|
-
"""
|
|
89
|
-
|
|
90
|
-
def __init__(
|
|
91
|
-
self,
|
|
92
|
-
ollama_url: str = _OLLAMA_URL,
|
|
93
|
-
model: str = _MODEL,
|
|
94
|
-
) -> None:
|
|
95
|
-
self._url = os.getenv("CLOVIS_OLLAMA_URL", ollama_url)
|
|
96
|
-
self._model = os.getenv("CLOVIS_MODEL", model)
|
|
97
|
-
self._http = httpx.Client()
|
|
98
|
-
|
|
99
|
-
def __call__(
|
|
100
|
-
self,
|
|
101
|
-
prompt: str,
|
|
102
|
-
*,
|
|
103
|
-
negative_prompt: Optional[str] = None,
|
|
104
|
-
thinking: bool = False,
|
|
105
|
-
context: Optional[str] = None,
|
|
106
|
-
) -> str:
|
|
107
|
-
messages = _build_messages(prompt, context, negative_prompt, thinking, [])
|
|
108
|
-
return self._send(messages)
|
|
109
|
-
|
|
110
|
-
def stream(
|
|
111
|
-
self,
|
|
112
|
-
prompt: str,
|
|
113
|
-
*,
|
|
114
|
-
negative_prompt: Optional[str] = None,
|
|
115
|
-
thinking: bool = False,
|
|
116
|
-
context: Optional[str] = None,
|
|
117
|
-
) -> Iterator[str]:
|
|
118
|
-
messages = _build_messages(prompt, context, negative_prompt, thinking, [])
|
|
119
|
-
return self._stream(messages)
|
|
120
|
-
|
|
121
|
-
def conversation(self, context: Optional[str] = None) -> Conversation:
|
|
122
|
-
return Conversation(self, context=context)
|
|
123
|
-
|
|
124
|
-
def _send(self, messages: list[dict]) -> str:
|
|
125
|
-
resp = self._http.post(
|
|
126
|
-
f"{self._url}/api/chat",
|
|
127
|
-
json={"model": self._model, "messages": messages, "stream": False},
|
|
128
|
-
timeout=600,
|
|
129
|
-
)
|
|
130
|
-
resp.raise_for_status()
|
|
131
|
-
return resp.json()["message"]["content"]
|
|
132
|
-
|
|
133
|
-
def _stream(self, messages: list[dict]) -> Iterator[str]:
|
|
134
|
-
with self._http.stream(
|
|
135
|
-
"POST",
|
|
136
|
-
f"{self._url}/api/chat",
|
|
137
|
-
json={"model": self._model, "messages": messages, "stream": True},
|
|
138
|
-
timeout=600,
|
|
139
|
-
) as resp:
|
|
140
|
-
resp.raise_for_status()
|
|
141
|
-
for line in resp.iter_lines():
|
|
142
|
-
if not line:
|
|
143
|
-
continue
|
|
144
|
-
data = json.loads(line)
|
|
145
|
-
token = data.get("message", {}).get("content", "")
|
|
146
|
-
if token:
|
|
147
|
-
yield token
|
|
148
|
-
if data.get("done"):
|
|
149
|
-
break
|
|
150
|
-
|
|
151
|
-
@classmethod
|
|
152
|
-
def serve(cls, port: int = 8000, host: str = "0.0.0.0", api_key: Optional[str] = None) -> None:
|
|
153
|
-
from ._server import start_server
|
|
154
|
-
start_server(host=host, port=port, api_key=api_key)
|
clovis-0.2.0/test_live.py
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
"""Test live end-to-end — lance avec : python3 test_live.py"""
|
|
2
|
-
from clovis import cloooooo, ClovisConnectionError, ClovisModelNotFound
|
|
3
|
-
|
|
4
|
-
TEST_MODEL = "gemma3:4b" # petit modèle pour le test, qwen3-72b-q5km après
|
|
5
|
-
|
|
6
|
-
def test_basic_chat():
|
|
7
|
-
client = cloooooo(model=TEST_MODEL)
|
|
8
|
-
resp = client.chat.completions.create(
|
|
9
|
-
messages=[{"role": "user", "content": "Réponds juste 'ok' et rien d'autre."}]
|
|
10
|
-
)
|
|
11
|
-
assert resp.choices[0].message.content
|
|
12
|
-
assert resp.choices[0].message.role == "assistant"
|
|
13
|
-
assert resp.usage.total_tokens > 0
|
|
14
|
-
print(f" chat OK — réponse : {resp.choices[0].message.content!r}")
|
|
15
|
-
|
|
16
|
-
def test_streaming():
|
|
17
|
-
client = cloooooo(model=TEST_MODEL)
|
|
18
|
-
chunks = list(client.chat.completions.create(
|
|
19
|
-
messages=[{"role": "user", "content": "Dis juste 'bonjour'."}],
|
|
20
|
-
stream=True,
|
|
21
|
-
))
|
|
22
|
-
full = "".join(c.choices[0].delta.get("content", "") for c in chunks)
|
|
23
|
-
assert len(full) > 0
|
|
24
|
-
print(f" streaming OK — {len(chunks)} chunks, texte : {full!r}")
|
|
25
|
-
|
|
26
|
-
def test_conversation():
|
|
27
|
-
client = cloooooo(model=TEST_MODEL)
|
|
28
|
-
with client.conversation(system="Réponds toujours en une seule phrase.") as conv:
|
|
29
|
-
r1 = conv.chat("Mon prénom est Clovis.")
|
|
30
|
-
r2 = conv.chat("Quel est mon prénom ?")
|
|
31
|
-
assert "Clovis" in r2 or "clovis" in r2.lower()
|
|
32
|
-
assert len(conv.history) == 5 # system + 2x (user+assistant)
|
|
33
|
-
print(f" conversation OK — mémoire : {r2!r}")
|
|
34
|
-
|
|
35
|
-
def test_models_list():
|
|
36
|
-
client = cloooooo(model=TEST_MODEL)
|
|
37
|
-
models = client.models()
|
|
38
|
-
assert isinstance(models, list)
|
|
39
|
-
assert len(models) > 0
|
|
40
|
-
print(f" models() OK — {models}")
|
|
41
|
-
|
|
42
|
-
def test_model_not_found():
|
|
43
|
-
client = cloooooo(model="inexistant:99b")
|
|
44
|
-
try:
|
|
45
|
-
client.chat.completions.create(
|
|
46
|
-
messages=[{"role": "user", "content": "test"}]
|
|
47
|
-
)
|
|
48
|
-
assert False, "Aurait dû lever ClovisModelNotFound"
|
|
49
|
-
except ClovisModelNotFound as e:
|
|
50
|
-
print(f" ClovisModelNotFound OK — {e}")
|
|
51
|
-
|
|
52
|
-
def test_connection_error():
|
|
53
|
-
try:
|
|
54
|
-
cloooooo(ollama_url="http://localhost:9999")
|
|
55
|
-
assert False, "Aurait dû lever ClovisConnectionError"
|
|
56
|
-
except ClovisConnectionError as e:
|
|
57
|
-
print(f" ClovisConnectionError OK — {e}")
|
|
58
|
-
|
|
59
|
-
if __name__ == "__main__":
|
|
60
|
-
tests = [
|
|
61
|
-
test_basic_chat,
|
|
62
|
-
test_streaming,
|
|
63
|
-
test_conversation,
|
|
64
|
-
test_models_list,
|
|
65
|
-
test_model_not_found,
|
|
66
|
-
test_connection_error,
|
|
67
|
-
]
|
|
68
|
-
passed = 0
|
|
69
|
-
for t in tests:
|
|
70
|
-
print(f"\n{t.__name__}")
|
|
71
|
-
try:
|
|
72
|
-
t()
|
|
73
|
-
passed += 1
|
|
74
|
-
except Exception as e:
|
|
75
|
-
print(f" FAILED: {e}")
|
|
76
|
-
|
|
77
|
-
print(f"\n{'='*40}")
|
|
78
|
-
print(f"Résultat : {passed}/{len(tests)} tests passés")
|
|
File without changes
|