sentienceapi 0.90.9__py3-none-any.whl → 0.90.16__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of sentienceapi might be problematic. Click here for more details.
- sentience/__init__.py +1 -1
- sentience/_extension_loader.py +40 -0
- sentience/agent.py +2 -17
- sentience/async_api.py +1160 -0
- sentience/browser.py +193 -26
- sentience/cloud_tracing.py +91 -1
- sentience/conversational_agent.py +3 -3
- sentience/extension/release.json +1 -1
- sentience/llm_provider.py +206 -0
- sentience/models.py +7 -1
- sentience/snapshot.py +18 -26
- sentience/text_search.py +41 -0
- sentience/trace_indexing/__init__.py +6 -6
- sentience/trace_indexing/index_schema.py +14 -14
- sentience/trace_indexing/indexer.py +13 -19
- sentience/wait.py +2 -2
- {sentienceapi-0.90.9.dist-info → sentienceapi-0.90.16.dist-info}/METADATA +60 -22
- {sentienceapi-0.90.9.dist-info → sentienceapi-0.90.16.dist-info}/RECORD +24 -20
- sentienceapi-0.90.16.dist-info/licenses/LICENSE +24 -0
- sentienceapi-0.90.16.dist-info/licenses/LICENSE-APACHE +201 -0
- sentienceapi-0.90.16.dist-info/licenses/LICENSE-MIT +21 -0
- sentienceapi-0.90.9.dist-info/licenses/LICENSE.md +0 -43
- {sentienceapi-0.90.9.dist-info → sentienceapi-0.90.16.dist-info}/WHEEL +0 -0
- {sentienceapi-0.90.9.dist-info → sentienceapi-0.90.16.dist-info}/entry_points.txt +0 -0
- {sentienceapi-0.90.9.dist-info → sentienceapi-0.90.16.dist-info}/top_level.txt +0 -0
sentience/browser.py
CHANGED
|
@@ -11,7 +11,8 @@ from urllib.parse import urlparse
|
|
|
11
11
|
|
|
12
12
|
from playwright.sync_api import BrowserContext, Page, Playwright, sync_playwright
|
|
13
13
|
|
|
14
|
-
from sentience.
|
|
14
|
+
from sentience._extension_loader import find_extension_path
|
|
15
|
+
from sentience.models import ProxyConfig, StorageState, Viewport
|
|
15
16
|
|
|
16
17
|
# Import stealth for bot evasion (optional - graceful fallback if not available)
|
|
17
18
|
try:
|
|
@@ -33,6 +34,9 @@ class SentienceBrowser:
|
|
|
33
34
|
proxy: str | None = None,
|
|
34
35
|
user_data_dir: str | None = None,
|
|
35
36
|
storage_state: str | Path | StorageState | dict | None = None,
|
|
37
|
+
record_video_dir: str | Path | None = None,
|
|
38
|
+
record_video_size: dict[str, int] | None = None,
|
|
39
|
+
viewport: Viewport | dict[str, int] | None = None,
|
|
36
40
|
):
|
|
37
41
|
"""
|
|
38
42
|
Initialize Sentience browser
|
|
@@ -57,6 +61,19 @@ class SentienceBrowser:
|
|
|
57
61
|
- StorageState object
|
|
58
62
|
- Dictionary with 'cookies' and/or 'origins' keys
|
|
59
63
|
If provided, browser starts with pre-injected authentication.
|
|
64
|
+
record_video_dir: Optional directory path to save video recordings.
|
|
65
|
+
If provided, browser will record video of all pages.
|
|
66
|
+
Videos are saved as .webm files in the specified directory.
|
|
67
|
+
If None, no video recording is performed.
|
|
68
|
+
record_video_size: Optional video resolution as dict with 'width' and 'height' keys.
|
|
69
|
+
Examples: {"width": 1280, "height": 800} (default)
|
|
70
|
+
{"width": 1920, "height": 1080} (1080p)
|
|
71
|
+
If None, defaults to 1280x800.
|
|
72
|
+
viewport: Optional viewport size as Viewport object or dict with 'width' and 'height' keys.
|
|
73
|
+
Examples: Viewport(width=1280, height=800) (default)
|
|
74
|
+
Viewport(width=1920, height=1080) (Full HD)
|
|
75
|
+
{"width": 1280, "height": 800} (dict also supported)
|
|
76
|
+
If None, defaults to Viewport(width=1280, height=800).
|
|
60
77
|
"""
|
|
61
78
|
self.api_key = api_key
|
|
62
79
|
# Only set api_url if api_key is provided, otherwise None (free tier)
|
|
@@ -80,6 +97,18 @@ class SentienceBrowser:
|
|
|
80
97
|
self.user_data_dir = user_data_dir
|
|
81
98
|
self.storage_state = storage_state
|
|
82
99
|
|
|
100
|
+
# Video recording support
|
|
101
|
+
self.record_video_dir = record_video_dir
|
|
102
|
+
self.record_video_size = record_video_size or {"width": 1280, "height": 800}
|
|
103
|
+
|
|
104
|
+
# Viewport configuration - convert dict to Viewport if needed
|
|
105
|
+
if viewport is None:
|
|
106
|
+
self.viewport = Viewport(width=1280, height=800)
|
|
107
|
+
elif isinstance(viewport, dict):
|
|
108
|
+
self.viewport = Viewport(width=viewport["width"], height=viewport["height"])
|
|
109
|
+
else:
|
|
110
|
+
self.viewport = viewport
|
|
111
|
+
|
|
83
112
|
self.playwright: Playwright | None = None
|
|
84
113
|
self.context: BrowserContext | None = None
|
|
85
114
|
self.page: Page | None = None
|
|
@@ -133,28 +162,8 @@ class SentienceBrowser:
|
|
|
133
162
|
|
|
134
163
|
def start(self) -> None:
|
|
135
164
|
"""Launch browser with extension loaded"""
|
|
136
|
-
# Get extension source path
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
# 1. Try relative to this file (installed package structure)
|
|
140
|
-
# sentience/browser.py -> sentience/extension/
|
|
141
|
-
package_ext_path = Path(__file__).parent / "extension"
|
|
142
|
-
|
|
143
|
-
# 2. Try development root (if running from source repo)
|
|
144
|
-
# sentience/browser.py -> ../sentience-chrome
|
|
145
|
-
dev_ext_path = Path(__file__).parent.parent.parent / "sentience-chrome"
|
|
146
|
-
|
|
147
|
-
if package_ext_path.exists() and (package_ext_path / "manifest.json").exists():
|
|
148
|
-
extension_source = package_ext_path
|
|
149
|
-
elif dev_ext_path.exists() and (dev_ext_path / "manifest.json").exists():
|
|
150
|
-
extension_source = dev_ext_path
|
|
151
|
-
else:
|
|
152
|
-
raise FileNotFoundError(
|
|
153
|
-
f"Extension not found. Checked:\n"
|
|
154
|
-
f"1. {package_ext_path}\n"
|
|
155
|
-
f"2. {dev_ext_path}\n"
|
|
156
|
-
"Make sure the extension is built and 'sentience/extension' directory exists."
|
|
157
|
-
)
|
|
165
|
+
# Get extension source path using shared utility
|
|
166
|
+
extension_source = find_extension_path()
|
|
158
167
|
|
|
159
168
|
# Create temporary extension bundle
|
|
160
169
|
# We copy it to a temp dir to avoid file locking issues and ensure clean state
|
|
@@ -197,7 +206,7 @@ class SentienceBrowser:
|
|
|
197
206
|
"user_data_dir": user_data_dir,
|
|
198
207
|
"headless": False, # IMPORTANT: See note above
|
|
199
208
|
"args": args,
|
|
200
|
-
"viewport": {"width":
|
|
209
|
+
"viewport": {"width": self.viewport.width, "height": self.viewport.height},
|
|
201
210
|
# Remove "HeadlessChrome" from User Agent automatically
|
|
202
211
|
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
|
203
212
|
}
|
|
@@ -209,6 +218,17 @@ class SentienceBrowser:
|
|
|
209
218
|
launch_params["ignore_https_errors"] = True
|
|
210
219
|
print(f"🌐 [Sentience] Using proxy: {proxy_config.server}")
|
|
211
220
|
|
|
221
|
+
# Add video recording if configured
|
|
222
|
+
if self.record_video_dir:
|
|
223
|
+
video_dir = Path(self.record_video_dir)
|
|
224
|
+
video_dir.mkdir(parents=True, exist_ok=True)
|
|
225
|
+
launch_params["record_video_dir"] = str(video_dir)
|
|
226
|
+
launch_params["record_video_size"] = self.record_video_size
|
|
227
|
+
print(f"🎥 [Sentience] Recording video to: {video_dir}")
|
|
228
|
+
print(
|
|
229
|
+
f" Resolution: {self.record_video_size['width']}x{self.record_video_size['height']}"
|
|
230
|
+
)
|
|
231
|
+
|
|
212
232
|
# Launch persistent context (required for extensions)
|
|
213
233
|
# Note: We pass headless=False to launch_persistent_context because we handle
|
|
214
234
|
# headless mode via the --headless=new arg above. This is a Playwright workaround.
|
|
@@ -390,15 +410,162 @@ class SentienceBrowser:
|
|
|
390
410
|
|
|
391
411
|
return False
|
|
392
412
|
|
|
393
|
-
def close(self) -> None:
|
|
394
|
-
"""
|
|
413
|
+
def close(self, output_path: str | Path | None = None) -> str | None:
|
|
414
|
+
"""
|
|
415
|
+
Close browser and cleanup
|
|
416
|
+
|
|
417
|
+
Args:
|
|
418
|
+
output_path: Optional path to rename the video file to.
|
|
419
|
+
If provided, the recorded video will be moved to this location.
|
|
420
|
+
Useful for giving videos meaningful names instead of random hashes.
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
Path to video file if recording was enabled, None otherwise
|
|
424
|
+
Note: Video files are saved automatically by Playwright when context closes.
|
|
425
|
+
If multiple pages exist, returns the path to the first page's video.
|
|
426
|
+
"""
|
|
427
|
+
temp_video_path = None
|
|
428
|
+
|
|
429
|
+
# Get video path before closing (if recording was enabled)
|
|
430
|
+
# Note: Playwright saves videos when pages/context close, but we can get the
|
|
431
|
+
# expected path before closing. The actual file will be available after close.
|
|
432
|
+
if self.record_video_dir:
|
|
433
|
+
try:
|
|
434
|
+
# Try to get video path from the first page
|
|
435
|
+
if self.page and self.page.video:
|
|
436
|
+
temp_video_path = self.page.video.path()
|
|
437
|
+
# If that fails, check all pages in the context
|
|
438
|
+
elif self.context:
|
|
439
|
+
for page in self.context.pages:
|
|
440
|
+
if page.video:
|
|
441
|
+
temp_video_path = page.video.path()
|
|
442
|
+
break
|
|
443
|
+
except Exception:
|
|
444
|
+
# Video path might not be available until after close
|
|
445
|
+
# In that case, we'll return None and user can check the directory
|
|
446
|
+
pass
|
|
447
|
+
|
|
448
|
+
# Close context (this triggers video file finalization)
|
|
395
449
|
if self.context:
|
|
396
450
|
self.context.close()
|
|
451
|
+
|
|
452
|
+
# Close playwright
|
|
397
453
|
if self.playwright:
|
|
398
454
|
self.playwright.stop()
|
|
455
|
+
|
|
456
|
+
# Clean up extension directory
|
|
399
457
|
if self._extension_path and os.path.exists(self._extension_path):
|
|
400
458
|
shutil.rmtree(self._extension_path)
|
|
401
459
|
|
|
460
|
+
# Rename/move video if output_path is specified
|
|
461
|
+
final_path = temp_video_path
|
|
462
|
+
if temp_video_path and output_path and os.path.exists(temp_video_path):
|
|
463
|
+
try:
|
|
464
|
+
output_path = str(output_path)
|
|
465
|
+
# Ensure parent directory exists
|
|
466
|
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
|
467
|
+
shutil.move(temp_video_path, output_path)
|
|
468
|
+
final_path = output_path
|
|
469
|
+
except Exception as e:
|
|
470
|
+
import warnings
|
|
471
|
+
|
|
472
|
+
warnings.warn(f"Failed to rename video file: {e}")
|
|
473
|
+
# Return original path if rename fails
|
|
474
|
+
final_path = temp_video_path
|
|
475
|
+
|
|
476
|
+
return final_path
|
|
477
|
+
|
|
478
|
+
@classmethod
|
|
479
|
+
def from_existing(
|
|
480
|
+
cls,
|
|
481
|
+
context: BrowserContext,
|
|
482
|
+
api_key: str | None = None,
|
|
483
|
+
api_url: str | None = None,
|
|
484
|
+
) -> "SentienceBrowser":
|
|
485
|
+
"""
|
|
486
|
+
Create SentienceBrowser from an existing Playwright BrowserContext.
|
|
487
|
+
|
|
488
|
+
This allows you to use Sentience SDK with a browser context you've already created,
|
|
489
|
+
giving you more control over browser initialization.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
context: Existing Playwright BrowserContext
|
|
493
|
+
api_key: Optional API key for server-side processing
|
|
494
|
+
api_url: Optional API URL (defaults to https://api.sentienceapi.com if api_key provided)
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
SentienceBrowser instance configured to use the existing context
|
|
498
|
+
|
|
499
|
+
Example:
|
|
500
|
+
from playwright.sync_api import sync_playwright
|
|
501
|
+
from sentience import SentienceBrowser, snapshot
|
|
502
|
+
|
|
503
|
+
with sync_playwright() as p:
|
|
504
|
+
context = p.chromium.launch_persistent_context(...)
|
|
505
|
+
browser = SentienceBrowser.from_existing(context)
|
|
506
|
+
browser.page.goto("https://example.com")
|
|
507
|
+
snap = snapshot(browser)
|
|
508
|
+
"""
|
|
509
|
+
instance = cls(api_key=api_key, api_url=api_url)
|
|
510
|
+
instance.context = context
|
|
511
|
+
instance.page = context.pages[0] if context.pages else context.new_page()
|
|
512
|
+
|
|
513
|
+
# Apply stealth if available
|
|
514
|
+
if STEALTH_AVAILABLE:
|
|
515
|
+
stealth_sync(instance.page)
|
|
516
|
+
|
|
517
|
+
# Wait for extension to be ready (if extension is loaded)
|
|
518
|
+
time.sleep(0.5)
|
|
519
|
+
|
|
520
|
+
return instance
|
|
521
|
+
|
|
522
|
+
@classmethod
|
|
523
|
+
def from_page(
|
|
524
|
+
cls,
|
|
525
|
+
page: Page,
|
|
526
|
+
api_key: str | None = None,
|
|
527
|
+
api_url: str | None = None,
|
|
528
|
+
) -> "SentienceBrowser":
|
|
529
|
+
"""
|
|
530
|
+
Create SentienceBrowser from an existing Playwright Page.
|
|
531
|
+
|
|
532
|
+
This allows you to use Sentience SDK with a page you've already created,
|
|
533
|
+
giving you more control over browser initialization.
|
|
534
|
+
|
|
535
|
+
Args:
|
|
536
|
+
page: Existing Playwright Page
|
|
537
|
+
api_key: Optional API key for server-side processing
|
|
538
|
+
api_url: Optional API URL (defaults to https://api.sentienceapi.com if api_key provided)
|
|
539
|
+
|
|
540
|
+
Returns:
|
|
541
|
+
SentienceBrowser instance configured to use the existing page
|
|
542
|
+
|
|
543
|
+
Example:
|
|
544
|
+
from playwright.sync_api import sync_playwright
|
|
545
|
+
from sentience import SentienceBrowser, snapshot
|
|
546
|
+
|
|
547
|
+
with sync_playwright() as p:
|
|
548
|
+
browser_instance = p.chromium.launch()
|
|
549
|
+
context = browser_instance.new_context()
|
|
550
|
+
page = context.new_page()
|
|
551
|
+
page.goto("https://example.com")
|
|
552
|
+
|
|
553
|
+
browser = SentienceBrowser.from_page(page)
|
|
554
|
+
snap = snapshot(browser)
|
|
555
|
+
"""
|
|
556
|
+
instance = cls(api_key=api_key, api_url=api_url)
|
|
557
|
+
instance.page = page
|
|
558
|
+
instance.context = page.context
|
|
559
|
+
|
|
560
|
+
# Apply stealth if available
|
|
561
|
+
if STEALTH_AVAILABLE:
|
|
562
|
+
stealth_sync(instance.page)
|
|
563
|
+
|
|
564
|
+
# Wait for extension to be ready (if extension is loaded)
|
|
565
|
+
time.sleep(0.5)
|
|
566
|
+
|
|
567
|
+
return instance
|
|
568
|
+
|
|
402
569
|
def __enter__(self):
|
|
403
570
|
"""Context manager entry"""
|
|
404
571
|
self.start()
|
sentience/cloud_tracing.py
CHANGED
|
@@ -213,7 +213,10 @@ class CloudTraceSink(TraceSink):
|
|
|
213
213
|
if on_progress:
|
|
214
214
|
on_progress(compressed_size, compressed_size)
|
|
215
215
|
|
|
216
|
-
#
|
|
216
|
+
# Upload trace index file
|
|
217
|
+
self._upload_index()
|
|
218
|
+
|
|
219
|
+
# Call /v1/traces/complete to report file sizes
|
|
217
220
|
self._complete_trace()
|
|
218
221
|
|
|
219
222
|
# Delete file only on successful upload
|
|
@@ -244,6 +247,93 @@ class CloudTraceSink(TraceSink):
|
|
|
244
247
|
# Non-fatal: log but don't crash
|
|
245
248
|
print(f"⚠️ Failed to generate trace index: {e}")
|
|
246
249
|
|
|
250
|
+
def _upload_index(self) -> None:
|
|
251
|
+
"""
|
|
252
|
+
Upload trace index file to cloud storage.
|
|
253
|
+
|
|
254
|
+
Called after successful trace upload to provide fast timeline rendering.
|
|
255
|
+
The index file enables O(1) step lookups without parsing the entire trace.
|
|
256
|
+
"""
|
|
257
|
+
# Construct index file path (same as trace file with .index.json extension)
|
|
258
|
+
index_path = Path(str(self._path).replace(".jsonl", ".index.json"))
|
|
259
|
+
|
|
260
|
+
if not index_path.exists():
|
|
261
|
+
if self.logger:
|
|
262
|
+
self.logger.warning("Index file not found, skipping index upload")
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
# Request index upload URL from API
|
|
267
|
+
if not self.api_key:
|
|
268
|
+
# No API key - skip index upload
|
|
269
|
+
if self.logger:
|
|
270
|
+
self.logger.info("No API key provided, skipping index upload")
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
response = requests.post(
|
|
274
|
+
f"{self.api_url}/v1/traces/index_upload",
|
|
275
|
+
headers={"Authorization": f"Bearer {self.api_key}"},
|
|
276
|
+
json={"run_id": self.run_id},
|
|
277
|
+
timeout=10,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
if response.status_code != 200:
|
|
281
|
+
if self.logger:
|
|
282
|
+
self.logger.warning(
|
|
283
|
+
f"Failed to get index upload URL: HTTP {response.status_code}"
|
|
284
|
+
)
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
upload_data = response.json()
|
|
288
|
+
index_upload_url = upload_data.get("upload_url")
|
|
289
|
+
|
|
290
|
+
if not index_upload_url:
|
|
291
|
+
if self.logger:
|
|
292
|
+
self.logger.warning("No upload URL in index upload response")
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
# Read and compress index file
|
|
296
|
+
with open(index_path, "rb") as f:
|
|
297
|
+
index_data = f.read()
|
|
298
|
+
|
|
299
|
+
compressed_index = gzip.compress(index_data)
|
|
300
|
+
index_size = len(compressed_index)
|
|
301
|
+
|
|
302
|
+
if self.logger:
|
|
303
|
+
self.logger.info(f"Index file size: {index_size / 1024:.2f} KB")
|
|
304
|
+
|
|
305
|
+
print(f"📤 [Sentience] Uploading trace index ({index_size} bytes)...")
|
|
306
|
+
|
|
307
|
+
# Upload index to cloud storage
|
|
308
|
+
index_response = requests.put(
|
|
309
|
+
index_upload_url,
|
|
310
|
+
data=compressed_index,
|
|
311
|
+
headers={
|
|
312
|
+
"Content-Type": "application/json",
|
|
313
|
+
"Content-Encoding": "gzip",
|
|
314
|
+
},
|
|
315
|
+
timeout=30,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
if index_response.status_code == 200:
|
|
319
|
+
print("✅ [Sentience] Trace index uploaded successfully")
|
|
320
|
+
|
|
321
|
+
# Delete local index file after successful upload
|
|
322
|
+
try:
|
|
323
|
+
os.remove(index_path)
|
|
324
|
+
except Exception:
|
|
325
|
+
pass # Ignore cleanup errors
|
|
326
|
+
else:
|
|
327
|
+
if self.logger:
|
|
328
|
+
self.logger.warning(f"Index upload failed: HTTP {index_response.status_code}")
|
|
329
|
+
print(f"⚠️ [Sentience] Index upload failed: HTTP {index_response.status_code}")
|
|
330
|
+
|
|
331
|
+
except Exception as e:
|
|
332
|
+
# Non-fatal: log but don't crash
|
|
333
|
+
if self.logger:
|
|
334
|
+
self.logger.warning(f"Error uploading trace index: {e}")
|
|
335
|
+
print(f"⚠️ [Sentience] Error uploading trace index: {e}")
|
|
336
|
+
|
|
247
337
|
def _complete_trace(self) -> None:
|
|
248
338
|
"""
|
|
249
339
|
Call /v1/traces/complete to report file sizes to gateway.
|
|
@@ -10,7 +10,7 @@ from typing import Any
|
|
|
10
10
|
from .agent import SentienceAgent
|
|
11
11
|
from .browser import SentienceBrowser
|
|
12
12
|
from .llm_provider import LLMProvider
|
|
13
|
-
from .models import Snapshot
|
|
13
|
+
from .models import Snapshot, SnapshotOptions
|
|
14
14
|
from .snapshot import snapshot
|
|
15
15
|
|
|
16
16
|
|
|
@@ -274,7 +274,7 @@ Create a step-by-step execution plan."""
|
|
|
274
274
|
elif action == "EXTRACT_INFO":
|
|
275
275
|
info_type = params["info_type"]
|
|
276
276
|
# Get current page snapshot and extract info
|
|
277
|
-
snap = snapshot(self.browser, limit=50)
|
|
277
|
+
snap = snapshot(self.browser, SnapshotOptions(limit=50))
|
|
278
278
|
|
|
279
279
|
# Use LLM to extract specific information
|
|
280
280
|
extracted = self._extract_information(snap, info_type)
|
|
@@ -361,7 +361,7 @@ Return JSON with extracted information:
|
|
|
361
361
|
True if condition is met, False otherwise
|
|
362
362
|
"""
|
|
363
363
|
try:
|
|
364
|
-
snap = snapshot(self.browser, limit=30)
|
|
364
|
+
snap = snapshot(self.browser, SnapshotOptions(limit=30))
|
|
365
365
|
|
|
366
366
|
# Build context
|
|
367
367
|
elements_text = "\n".join([f"{el.role}: {el.text}" for el in snap.elements[:20]])
|
sentience/extension/release.json
CHANGED
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"state": "uploaded",
|
|
68
68
|
"size": 78091,
|
|
69
69
|
"digest": "sha256:e281f8b755b61da4b8015d6172064aa9a337c14133ceceff4ab29199ee53307e",
|
|
70
|
-
"download_count":
|
|
70
|
+
"download_count": 5,
|
|
71
71
|
"created_at": "2025-12-29T03:57:09Z",
|
|
72
72
|
"updated_at": "2025-12-29T03:57:09Z",
|
|
73
73
|
"browser_download_url": "https://github.com/SentienceAPI/Sentience-Geometry-Chrome-Extension/releases/download/v2.0.7/extension-files.tar.gz"
|
sentience/llm_provider.py
CHANGED
|
@@ -263,6 +263,212 @@ class AnthropicProvider(LLMProvider):
|
|
|
263
263
|
return self._model_name
|
|
264
264
|
|
|
265
265
|
|
|
266
|
+
class GLMProvider(LLMProvider):
|
|
267
|
+
"""
|
|
268
|
+
Zhipu AI GLM provider implementation (GLM-4, GLM-4-Plus, etc.)
|
|
269
|
+
|
|
270
|
+
Requirements:
|
|
271
|
+
pip install zhipuai
|
|
272
|
+
|
|
273
|
+
Example:
|
|
274
|
+
>>> from sentience.llm_provider import GLMProvider
|
|
275
|
+
>>> llm = GLMProvider(api_key="your-api-key", model="glm-4-plus")
|
|
276
|
+
>>> response = llm.generate("You are a helpful assistant", "Hello!")
|
|
277
|
+
>>> print(response.content)
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
def __init__(self, api_key: str | None = None, model: str = "glm-4-plus"):
|
|
281
|
+
"""
|
|
282
|
+
Initialize GLM provider
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
api_key: Zhipu AI API key (or set GLM_API_KEY env var)
|
|
286
|
+
model: Model name (glm-4-plus, glm-4, glm-4-air, glm-4-flash, etc.)
|
|
287
|
+
"""
|
|
288
|
+
try:
|
|
289
|
+
from zhipuai import ZhipuAI
|
|
290
|
+
except ImportError:
|
|
291
|
+
raise ImportError("ZhipuAI package not installed. Install with: pip install zhipuai")
|
|
292
|
+
|
|
293
|
+
self.client = ZhipuAI(api_key=api_key)
|
|
294
|
+
self._model_name = model
|
|
295
|
+
|
|
296
|
+
def generate(
|
|
297
|
+
self,
|
|
298
|
+
system_prompt: str,
|
|
299
|
+
user_prompt: str,
|
|
300
|
+
temperature: float = 0.0,
|
|
301
|
+
max_tokens: int | None = None,
|
|
302
|
+
**kwargs,
|
|
303
|
+
) -> LLMResponse:
|
|
304
|
+
"""
|
|
305
|
+
Generate response using GLM API
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
system_prompt: System instruction
|
|
309
|
+
user_prompt: User query
|
|
310
|
+
temperature: Sampling temperature (0.0 = deterministic, 1.0 = creative)
|
|
311
|
+
max_tokens: Maximum tokens to generate
|
|
312
|
+
**kwargs: Additional GLM API parameters
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
LLMResponse object
|
|
316
|
+
"""
|
|
317
|
+
messages = []
|
|
318
|
+
if system_prompt:
|
|
319
|
+
messages.append({"role": "system", "content": system_prompt})
|
|
320
|
+
messages.append({"role": "user", "content": user_prompt})
|
|
321
|
+
|
|
322
|
+
# Build API parameters
|
|
323
|
+
api_params = {
|
|
324
|
+
"model": self._model_name,
|
|
325
|
+
"messages": messages,
|
|
326
|
+
"temperature": temperature,
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if max_tokens:
|
|
330
|
+
api_params["max_tokens"] = max_tokens
|
|
331
|
+
|
|
332
|
+
# Merge additional parameters
|
|
333
|
+
api_params.update(kwargs)
|
|
334
|
+
|
|
335
|
+
# Call GLM API
|
|
336
|
+
response = self.client.chat.completions.create(**api_params)
|
|
337
|
+
|
|
338
|
+
choice = response.choices[0]
|
|
339
|
+
usage = response.usage
|
|
340
|
+
|
|
341
|
+
return LLMResponse(
|
|
342
|
+
content=choice.message.content,
|
|
343
|
+
prompt_tokens=usage.prompt_tokens if usage else None,
|
|
344
|
+
completion_tokens=usage.completion_tokens if usage else None,
|
|
345
|
+
total_tokens=usage.total_tokens if usage else None,
|
|
346
|
+
model_name=response.model,
|
|
347
|
+
finish_reason=choice.finish_reason,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def supports_json_mode(self) -> bool:
|
|
351
|
+
"""GLM-4 models support JSON mode"""
|
|
352
|
+
return "glm-4" in self._model_name.lower()
|
|
353
|
+
|
|
354
|
+
@property
|
|
355
|
+
def model_name(self) -> str:
|
|
356
|
+
return self._model_name
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class GeminiProvider(LLMProvider):
|
|
360
|
+
"""
|
|
361
|
+
Google Gemini provider implementation (Gemini 2.0, Gemini 1.5 Pro, etc.)
|
|
362
|
+
|
|
363
|
+
Requirements:
|
|
364
|
+
pip install google-generativeai
|
|
365
|
+
|
|
366
|
+
Example:
|
|
367
|
+
>>> from sentience.llm_provider import GeminiProvider
|
|
368
|
+
>>> llm = GeminiProvider(api_key="your-api-key", model="gemini-2.0-flash-exp")
|
|
369
|
+
>>> response = llm.generate("You are a helpful assistant", "Hello!")
|
|
370
|
+
>>> print(response.content)
|
|
371
|
+
"""
|
|
372
|
+
|
|
373
|
+
def __init__(self, api_key: str | None = None, model: str = "gemini-2.0-flash-exp"):
|
|
374
|
+
"""
|
|
375
|
+
Initialize Gemini provider
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
api_key: Google API key (or set GEMINI_API_KEY or GOOGLE_API_KEY env var)
|
|
379
|
+
model: Model name (gemini-2.0-flash-exp, gemini-1.5-pro, gemini-1.5-flash, etc.)
|
|
380
|
+
"""
|
|
381
|
+
try:
|
|
382
|
+
import google.generativeai as genai
|
|
383
|
+
except ImportError:
|
|
384
|
+
raise ImportError(
|
|
385
|
+
"Google Generative AI package not installed. Install with: pip install google-generativeai"
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# Configure API key
|
|
389
|
+
if api_key:
|
|
390
|
+
genai.configure(api_key=api_key)
|
|
391
|
+
else:
|
|
392
|
+
import os
|
|
393
|
+
|
|
394
|
+
api_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
|
|
395
|
+
if api_key:
|
|
396
|
+
genai.configure(api_key=api_key)
|
|
397
|
+
|
|
398
|
+
self.genai = genai
|
|
399
|
+
self._model_name = model
|
|
400
|
+
self.model = genai.GenerativeModel(model)
|
|
401
|
+
|
|
402
|
+
def generate(
|
|
403
|
+
self,
|
|
404
|
+
system_prompt: str,
|
|
405
|
+
user_prompt: str,
|
|
406
|
+
temperature: float = 0.0,
|
|
407
|
+
max_tokens: int | None = None,
|
|
408
|
+
**kwargs,
|
|
409
|
+
) -> LLMResponse:
|
|
410
|
+
"""
|
|
411
|
+
Generate response using Gemini API
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
system_prompt: System instruction
|
|
415
|
+
user_prompt: User query
|
|
416
|
+
temperature: Sampling temperature (0.0 = deterministic, 2.0 = very creative)
|
|
417
|
+
max_tokens: Maximum tokens to generate
|
|
418
|
+
**kwargs: Additional Gemini API parameters
|
|
419
|
+
|
|
420
|
+
Returns:
|
|
421
|
+
LLMResponse object
|
|
422
|
+
"""
|
|
423
|
+
# Combine system and user prompts (Gemini doesn't have separate system role in all versions)
|
|
424
|
+
full_prompt = f"{system_prompt}\n\n{user_prompt}" if system_prompt else user_prompt
|
|
425
|
+
|
|
426
|
+
# Build generation config
|
|
427
|
+
generation_config = {
|
|
428
|
+
"temperature": temperature,
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if max_tokens:
|
|
432
|
+
generation_config["max_output_tokens"] = max_tokens
|
|
433
|
+
|
|
434
|
+
# Merge additional parameters
|
|
435
|
+
generation_config.update(kwargs)
|
|
436
|
+
|
|
437
|
+
# Call Gemini API
|
|
438
|
+
response = self.model.generate_content(full_prompt, generation_config=generation_config)
|
|
439
|
+
|
|
440
|
+
# Extract content
|
|
441
|
+
content = response.text if response.text else ""
|
|
442
|
+
|
|
443
|
+
# Token usage (if available)
|
|
444
|
+
prompt_tokens = None
|
|
445
|
+
completion_tokens = None
|
|
446
|
+
total_tokens = None
|
|
447
|
+
|
|
448
|
+
if hasattr(response, "usage_metadata") and response.usage_metadata:
|
|
449
|
+
prompt_tokens = response.usage_metadata.prompt_token_count
|
|
450
|
+
completion_tokens = response.usage_metadata.candidates_token_count
|
|
451
|
+
total_tokens = response.usage_metadata.total_token_count
|
|
452
|
+
|
|
453
|
+
return LLMResponse(
|
|
454
|
+
content=content,
|
|
455
|
+
prompt_tokens=prompt_tokens,
|
|
456
|
+
completion_tokens=completion_tokens,
|
|
457
|
+
total_tokens=total_tokens,
|
|
458
|
+
model_name=self._model_name,
|
|
459
|
+
finish_reason=None, # Gemini uses different finish reason format
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
def supports_json_mode(self) -> bool:
|
|
463
|
+
"""Gemini 1.5+ models support JSON mode via response_mime_type"""
|
|
464
|
+
model_lower = self._model_name.lower()
|
|
465
|
+
return any(x in model_lower for x in ["gemini-1.5", "gemini-2.0"])
|
|
466
|
+
|
|
467
|
+
@property
|
|
468
|
+
def model_name(self) -> str:
|
|
469
|
+
return self._model_name
|
|
470
|
+
|
|
471
|
+
|
|
266
472
|
class LocalLLMProvider(LLMProvider):
|
|
267
473
|
"""
|
|
268
474
|
Local LLM provider using HuggingFace Transformers
|
sentience/models.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
Pydantic models for Sentience SDK - matches spec/snapshot.schema.json
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
from typing import Literal
|
|
5
|
+
from typing import Literal, Optional
|
|
6
6
|
|
|
7
7
|
from pydantic import BaseModel, Field
|
|
8
8
|
|
|
@@ -44,6 +44,12 @@ class Element(BaseModel):
|
|
|
44
44
|
is_occluded: bool = False
|
|
45
45
|
z_index: int = 0
|
|
46
46
|
|
|
47
|
+
# ML reranking metadata (optional - can be absent or null)
|
|
48
|
+
rerank_index: int | None = None # 0-based, The rank after ML reranking
|
|
49
|
+
heuristic_index: int | None = None # 0-based, Where it would have been without ML
|
|
50
|
+
ml_probability: float | None = None # Confidence score from ONNX model (0.0 - 1.0)
|
|
51
|
+
ml_score: float | None = None # Raw logit score (optional, for debugging)
|
|
52
|
+
|
|
47
53
|
|
|
48
54
|
class Snapshot(BaseModel):
|
|
49
55
|
"""Snapshot response from extension"""
|