struai 0.0.1__py3-none-any.whl → 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,628 @@
1
+ """Tier 2: Projects, Sheets, Search API."""
2
+ import asyncio
3
+ import time
4
+ from functools import cached_property
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, BinaryIO, Dict, List, Optional, Union
7
+
8
+ from .._exceptions import JobFailedError, TimeoutError
9
+ from ..models.entities import Entity, Fact
10
+ from ..models.projects import JobStatus, Project, Sheet, SheetResult
11
+ from ..models.search import QueryResponse, SearchResponse
12
+
13
+ if TYPE_CHECKING:
14
+ from .._base import AsyncBaseClient, BaseClient
15
+
16
+
17
+ # =============================================================================
18
+ # Job handles for async sheet ingestion
19
+ # =============================================================================
20
+
21
+
22
+ class Job:
23
+ """Handle for an async sheet ingestion job (sync)."""
24
+
25
+ def __init__(self, client: "BaseClient", project_id: str, job_id: str):
26
+ self._client = client
27
+ self._project_id = project_id
28
+ self._job_id = job_id
29
+
30
+ @property
31
+ def id(self) -> str:
32
+ return self._job_id
33
+
34
+ def status(self) -> JobStatus:
35
+ """Check current job status."""
36
+ return self._client.get(
37
+ f"/projects/{self._project_id}/jobs/{self._job_id}",
38
+ cast_to=JobStatus,
39
+ )
40
+
41
+ def wait(
42
+ self,
43
+ timeout: float = 120,
44
+ poll_interval: float = 2,
45
+ ) -> SheetResult:
46
+ """Wait for job completion.
47
+
48
+ Args:
49
+ timeout: Maximum seconds to wait
50
+ poll_interval: Seconds between status checks
51
+
52
+ Returns:
53
+ SheetResult on success
54
+
55
+ Raises:
56
+ TimeoutError: If job doesn't complete in time
57
+ JobFailedError: If job fails
58
+ """
59
+ start = time.time()
60
+ while time.time() - start < timeout:
61
+ status = self.status()
62
+
63
+ if status.is_complete:
64
+ return status.result # type: ignore
65
+
66
+ if status.is_failed:
67
+ raise JobFailedError(
68
+ f"Job {self._job_id} failed: {status.error}",
69
+ job_id=self._job_id,
70
+ error=status.error or "Unknown error",
71
+ )
72
+
73
+ time.sleep(poll_interval)
74
+
75
+ raise TimeoutError(f"Job {self._job_id} did not complete within {timeout}s")
76
+
77
+
78
+ class AsyncJob:
79
+ """Handle for an async sheet ingestion job (async)."""
80
+
81
+ def __init__(self, client: "AsyncBaseClient", project_id: str, job_id: str):
82
+ self._client = client
83
+ self._project_id = project_id
84
+ self._job_id = job_id
85
+
86
+ @property
87
+ def id(self) -> str:
88
+ return self._job_id
89
+
90
+ async def status(self) -> JobStatus:
91
+ """Check current job status."""
92
+ return await self._client.get(
93
+ f"/projects/{self._project_id}/jobs/{self._job_id}",
94
+ cast_to=JobStatus,
95
+ )
96
+
97
+ async def wait(
98
+ self,
99
+ timeout: float = 120,
100
+ poll_interval: float = 2,
101
+ ) -> SheetResult:
102
+ """Wait for job completion (async)."""
103
+ start = time.time()
104
+ while time.time() - start < timeout:
105
+ status = await self.status()
106
+
107
+ if status.is_complete:
108
+ return status.result # type: ignore
109
+
110
+ if status.is_failed:
111
+ raise JobFailedError(
112
+ f"Job {self._job_id} failed: {status.error}",
113
+ job_id=self._job_id,
114
+ error=status.error or "Unknown error",
115
+ )
116
+
117
+ await asyncio.sleep(poll_interval)
118
+
119
+ raise TimeoutError(f"Job {self._job_id} did not complete within {timeout}s")
120
+
121
+
122
+ # =============================================================================
123
+ # Sheets resource
124
+ # =============================================================================
125
+
126
+
127
+ class Sheets:
128
+ """Sheet ingestion API (sync)."""
129
+
130
+ def __init__(self, client: "BaseClient", project_id: str):
131
+ self._client = client
132
+ self._project_id = project_id
133
+
134
+ def add(
135
+ self,
136
+ file: Union[str, Path, bytes, BinaryIO],
137
+ page: int,
138
+ webhook_url: Optional[str] = None,
139
+ ) -> Job:
140
+ """Add a sheet to the project (async job).
141
+
142
+ Args:
143
+ file: PDF file
144
+ page: Page number (1-indexed)
145
+ webhook_url: Optional callback URL when complete
146
+
147
+ Returns:
148
+ Job handle for polling/waiting
149
+ """
150
+ files = self._prepare_file(file)
151
+ data: Dict[str, str] = {"page": str(page)}
152
+ if webhook_url:
153
+ data["webhook_url"] = webhook_url
154
+
155
+ response = self._client.post(
156
+ f"/projects/{self._project_id}/sheets",
157
+ files=files,
158
+ data=data,
159
+ )
160
+ return Job(self._client, self._project_id, response["job_id"])
161
+
162
+ def list(self, limit: int = 100) -> List[Sheet]:
163
+ """List all sheets in project."""
164
+ response = self._client.get(
165
+ f"/projects/{self._project_id}/sheets",
166
+ params={"limit": limit},
167
+ )
168
+ return [Sheet.model_validate(s) for s in response["sheets"]]
169
+
170
+ def get(self, sheet_id: str) -> Sheet:
171
+ """Get a sheet by ID."""
172
+ return self._client.get(
173
+ f"/projects/{self._project_id}/sheets/{sheet_id}",
174
+ cast_to=Sheet,
175
+ )
176
+
177
+ def delete(self, sheet_id: str) -> None:
178
+ """Remove a sheet from the project."""
179
+ self._client.delete(f"/projects/{self._project_id}/sheets/{sheet_id}")
180
+
181
+ def _prepare_file(self, file: Union[str, Path, bytes, BinaryIO]) -> dict:
182
+ if isinstance(file, (str, Path)):
183
+ path = Path(file)
184
+ return {"file": (path.name, open(path, "rb"), "application/pdf")}
185
+ elif isinstance(file, bytes):
186
+ return {"file": ("document.pdf", file, "application/pdf")}
187
+ else:
188
+ name = getattr(file, "name", "document.pdf")
189
+ if hasattr(name, "split"):
190
+ name = Path(name).name
191
+ return {"file": (name, file, "application/pdf")}
192
+
193
+
194
+ class AsyncSheets:
195
+ """Sheet ingestion API (async)."""
196
+
197
+ def __init__(self, client: "AsyncBaseClient", project_id: str):
198
+ self._client = client
199
+ self._project_id = project_id
200
+
201
+ async def add(
202
+ self,
203
+ file: Union[str, Path, bytes, BinaryIO],
204
+ page: int,
205
+ webhook_url: Optional[str] = None,
206
+ ) -> AsyncJob:
207
+ """Add a sheet to the project (async job)."""
208
+ files = self._prepare_file(file)
209
+ data: Dict[str, str] = {"page": str(page)}
210
+ if webhook_url:
211
+ data["webhook_url"] = webhook_url
212
+
213
+ response = await self._client.post(
214
+ f"/projects/{self._project_id}/sheets",
215
+ files=files,
216
+ data=data,
217
+ )
218
+ return AsyncJob(self._client, self._project_id, response["job_id"])
219
+
220
+ async def list(self, limit: int = 100) -> List[Sheet]:
221
+ """List all sheets in project."""
222
+ response = await self._client.get(
223
+ f"/projects/{self._project_id}/sheets",
224
+ params={"limit": limit},
225
+ )
226
+ return [Sheet.model_validate(s) for s in response["sheets"]]
227
+
228
+ async def get(self, sheet_id: str) -> Sheet:
229
+ """Get a sheet by ID."""
230
+ return await self._client.get(
231
+ f"/projects/{self._project_id}/sheets/{sheet_id}",
232
+ cast_to=Sheet,
233
+ )
234
+
235
+ async def delete(self, sheet_id: str) -> None:
236
+ """Remove a sheet from the project."""
237
+ await self._client.delete(f"/projects/{self._project_id}/sheets/{sheet_id}")
238
+
239
+ def _prepare_file(self, file: Union[str, Path, bytes, BinaryIO]) -> dict:
240
+ if isinstance(file, (str, Path)):
241
+ path = Path(file)
242
+ return {"file": (path.name, open(path, "rb"), "application/pdf")}
243
+ elif isinstance(file, bytes):
244
+ return {"file": ("document.pdf", file, "application/pdf")}
245
+ else:
246
+ name = getattr(file, "name", "document.pdf")
247
+ if hasattr(name, "split"):
248
+ name = Path(name).name
249
+ return {"file": (name, file, "application/pdf")}
250
+
251
+
252
+ # =============================================================================
253
+ # Entities resource
254
+ # =============================================================================
255
+
256
+
257
+ class Entities:
258
+ """Entity retrieval API (sync)."""
259
+
260
+ def __init__(self, client: "BaseClient", project_id: str):
261
+ self._client = client
262
+ self._project_id = project_id
263
+
264
+ def list(
265
+ self,
266
+ sheet_id: Optional[str] = None,
267
+ type: Optional[str] = None,
268
+ limit: int = 100,
269
+ ) -> List[Entity]:
270
+ """List entities in project."""
271
+ params: Dict[str, Union[str, int]] = {"limit": limit}
272
+ if sheet_id:
273
+ params["sheet_id"] = sheet_id
274
+ if type:
275
+ params["type"] = type
276
+
277
+ response = self._client.get(
278
+ f"/projects/{self._project_id}/entities",
279
+ params=params,
280
+ )
281
+ return [Entity.model_validate(e) for e in response["entities"]]
282
+
283
+ def get(self, entity_id: str) -> Entity:
284
+ """Get entity with all relationships."""
285
+ return self._client.get(
286
+ f"/projects/{self._project_id}/entities/{entity_id}",
287
+ cast_to=Entity,
288
+ )
289
+
290
+
291
+ class AsyncEntities:
292
+ """Entity retrieval API (async)."""
293
+
294
+ def __init__(self, client: "AsyncBaseClient", project_id: str):
295
+ self._client = client
296
+ self._project_id = project_id
297
+
298
+ async def list(
299
+ self,
300
+ sheet_id: Optional[str] = None,
301
+ type: Optional[str] = None,
302
+ limit: int = 100,
303
+ ) -> List[Entity]:
304
+ """List entities in project."""
305
+ params: Dict[str, Union[str, int]] = {"limit": limit}
306
+ if sheet_id:
307
+ params["sheet_id"] = sheet_id
308
+ if type:
309
+ params["type"] = type
310
+
311
+ response = await self._client.get(
312
+ f"/projects/{self._project_id}/entities",
313
+ params=params,
314
+ )
315
+ return [Entity.model_validate(e) for e in response["entities"]]
316
+
317
+ async def get(self, entity_id: str) -> Entity:
318
+ """Get entity with all relationships."""
319
+ return await self._client.get(
320
+ f"/projects/{self._project_id}/entities/{entity_id}",
321
+ cast_to=Entity,
322
+ )
323
+
324
+
325
+ # =============================================================================
326
+ # Relationships resource
327
+ # =============================================================================
328
+
329
+
330
+ class Relationships:
331
+ """Relationship retrieval API (sync)."""
332
+
333
+ def __init__(self, client: "BaseClient", project_id: str):
334
+ self._client = client
335
+ self._project_id = project_id
336
+
337
+ def list(
338
+ self,
339
+ source_id: Optional[str] = None,
340
+ target_id: Optional[str] = None,
341
+ type: Optional[str] = None,
342
+ limit: int = 100,
343
+ ) -> List[Fact]:
344
+ """List relationships in project."""
345
+ params: Dict[str, Union[str, int]] = {"limit": limit}
346
+ if source_id:
347
+ params["source_id"] = source_id
348
+ if target_id:
349
+ params["target_id"] = target_id
350
+ if type:
351
+ params["type"] = type
352
+
353
+ response = self._client.get(
354
+ f"/projects/{self._project_id}/relationships",
355
+ params=params,
356
+ )
357
+ return [Fact.model_validate(r) for r in response["relationships"]]
358
+
359
+
360
+ class AsyncRelationships:
361
+ """Relationship retrieval API (async)."""
362
+
363
+ def __init__(self, client: "AsyncBaseClient", project_id: str):
364
+ self._client = client
365
+ self._project_id = project_id
366
+
367
+ async def list(
368
+ self,
369
+ source_id: Optional[str] = None,
370
+ target_id: Optional[str] = None,
371
+ type: Optional[str] = None,
372
+ limit: int = 100,
373
+ ) -> List[Fact]:
374
+ """List relationships in project."""
375
+ params: Dict[str, Union[str, int]] = {"limit": limit}
376
+ if source_id:
377
+ params["source_id"] = source_id
378
+ if target_id:
379
+ params["target_id"] = target_id
380
+ if type:
381
+ params["type"] = type
382
+
383
+ response = await self._client.get(
384
+ f"/projects/{self._project_id}/relationships",
385
+ params=params,
386
+ )
387
+ return [Fact.model_validate(r) for r in response["relationships"]]
388
+
389
+
390
+ # =============================================================================
391
+ # ProjectInstance - bound to a specific project
392
+ # =============================================================================
393
+
394
+
395
+ class ProjectInstance:
396
+ """A project with access to nested resources (sync)."""
397
+
398
+ def __init__(self, client: "BaseClient", project: Project):
399
+ self._client = client
400
+ self._project = project
401
+
402
+ @property
403
+ def id(self) -> str:
404
+ return self._project.id
405
+
406
+ @property
407
+ def name(self) -> str:
408
+ return self._project.name
409
+
410
+ @property
411
+ def description(self) -> Optional[str]:
412
+ return self._project.description
413
+
414
+ @cached_property
415
+ def sheets(self) -> Sheets:
416
+ """Sheet ingestion API."""
417
+ return Sheets(self._client, self.id)
418
+
419
+ @cached_property
420
+ def entities(self) -> Entities:
421
+ """Entity retrieval API."""
422
+ return Entities(self._client, self.id)
423
+
424
+ @cached_property
425
+ def relationships(self) -> Relationships:
426
+ """Relationship retrieval API."""
427
+ return Relationships(self._client, self.id)
428
+
429
+ def search(
430
+ self,
431
+ query: str,
432
+ limit: int = 10,
433
+ filters: Optional[Dict] = None,
434
+ include_graph_context: bool = True,
435
+ ) -> SearchResponse:
436
+ """Semantic search across project.
437
+
438
+ Args:
439
+ query: Search query text
440
+ limit: Max results to return
441
+ filters: Optional filters (sheet_id, entity_type)
442
+ include_graph_context: Include related entities
443
+
444
+ Returns:
445
+ SearchResponse with results
446
+ """
447
+ body: Dict = {
448
+ "query": query,
449
+ "limit": limit,
450
+ "include_graph_context": include_graph_context,
451
+ }
452
+ if filters:
453
+ body["filters"] = filters
454
+
455
+ return self._client.post(
456
+ f"/projects/{self.id}/search",
457
+ json=body,
458
+ cast_to=SearchResponse,
459
+ )
460
+
461
+ def query(self, question: str) -> QueryResponse:
462
+ """Ask a natural language question.
463
+
464
+ Args:
465
+ question: Question in natural language
466
+
467
+ Returns:
468
+ QueryResponse with answer and sources
469
+ """
470
+ return self._client.post(
471
+ f"/projects/{self.id}/query",
472
+ json={"question": question},
473
+ cast_to=QueryResponse,
474
+ )
475
+
476
+ def delete(self) -> None:
477
+ """Delete this project and all its data."""
478
+ self._client.delete(f"/projects/{self.id}")
479
+
480
+
481
+ class AsyncProjectInstance:
482
+ """A project with access to nested resources (async)."""
483
+
484
+ def __init__(self, client: "AsyncBaseClient", project: Project):
485
+ self._client = client
486
+ self._project = project
487
+
488
+ @property
489
+ def id(self) -> str:
490
+ return self._project.id
491
+
492
+ @property
493
+ def name(self) -> str:
494
+ return self._project.name
495
+
496
+ @property
497
+ def description(self) -> Optional[str]:
498
+ return self._project.description
499
+
500
+ @cached_property
501
+ def sheets(self) -> AsyncSheets:
502
+ """Sheet ingestion API."""
503
+ return AsyncSheets(self._client, self.id)
504
+
505
+ @cached_property
506
+ def entities(self) -> AsyncEntities:
507
+ """Entity retrieval API."""
508
+ return AsyncEntities(self._client, self.id)
509
+
510
+ @cached_property
511
+ def relationships(self) -> AsyncRelationships:
512
+ """Relationship retrieval API."""
513
+ return AsyncRelationships(self._client, self.id)
514
+
515
+ async def search(
516
+ self,
517
+ query: str,
518
+ limit: int = 10,
519
+ filters: Optional[Dict] = None,
520
+ include_graph_context: bool = True,
521
+ ) -> SearchResponse:
522
+ """Semantic search across project (async)."""
523
+ body: Dict = {
524
+ "query": query,
525
+ "limit": limit,
526
+ "include_graph_context": include_graph_context,
527
+ }
528
+ if filters:
529
+ body["filters"] = filters
530
+
531
+ return await self._client.post(
532
+ f"/projects/{self.id}/search",
533
+ json=body,
534
+ cast_to=SearchResponse,
535
+ )
536
+
537
+ async def query(self, question: str) -> QueryResponse:
538
+ """Ask a natural language question (async)."""
539
+ return await self._client.post(
540
+ f"/projects/{self.id}/query",
541
+ json={"question": question},
542
+ cast_to=QueryResponse,
543
+ )
544
+
545
+ async def delete(self) -> None:
546
+ """Delete this project and all its data."""
547
+ await self._client.delete(f"/projects/{self.id}")
548
+
549
+
550
+ # =============================================================================
551
+ # Projects resource (top-level)
552
+ # =============================================================================
553
+
554
+
555
+ class Projects:
556
+ """Tier 2: Project management API (sync)."""
557
+
558
+ def __init__(self, client: "BaseClient"):
559
+ self._client = client
560
+
561
+ def create(
562
+ self,
563
+ name: str,
564
+ description: Optional[str] = None,
565
+ ) -> ProjectInstance:
566
+ """Create a new project.
567
+
568
+ Args:
569
+ name: Project name
570
+ description: Optional description
571
+
572
+ Returns:
573
+ ProjectInstance with access to sheets, search, etc.
574
+ """
575
+ data = self._client.post(
576
+ "/projects",
577
+ json={"name": name, "description": description},
578
+ cast_to=Project,
579
+ )
580
+ return ProjectInstance(self._client, data)
581
+
582
+ def list(self, limit: int = 100) -> List[Project]:
583
+ """List all projects."""
584
+ response = self._client.get("/projects", params={"limit": limit})
585
+ return [Project.model_validate(p) for p in response["projects"]]
586
+
587
+ def get(self, project_id: str) -> ProjectInstance:
588
+ """Get a project by ID."""
589
+ data = self._client.get(f"/projects/{project_id}", cast_to=Project)
590
+ return ProjectInstance(self._client, data)
591
+
592
+ def delete(self, project_id: str) -> None:
593
+ """Delete a project and all its data."""
594
+ self._client.delete(f"/projects/{project_id}")
595
+
596
+
597
+ class AsyncProjects:
598
+ """Tier 2: Project management API (async)."""
599
+
600
+ def __init__(self, client: "AsyncBaseClient"):
601
+ self._client = client
602
+
603
+ async def create(
604
+ self,
605
+ name: str,
606
+ description: Optional[str] = None,
607
+ ) -> AsyncProjectInstance:
608
+ """Create a new project (async)."""
609
+ data = await self._client.post(
610
+ "/projects",
611
+ json={"name": name, "description": description},
612
+ cast_to=Project,
613
+ )
614
+ return AsyncProjectInstance(self._client, data)
615
+
616
+ async def list(self, limit: int = 100) -> List[Project]:
617
+ """List all projects (async)."""
618
+ response = await self._client.get("/projects", params={"limit": limit})
619
+ return [Project.model_validate(p) for p in response["projects"]]
620
+
621
+ async def get(self, project_id: str) -> AsyncProjectInstance:
622
+ """Get a project by ID (async)."""
623
+ data = await self._client.get(f"/projects/{project_id}", cast_to=Project)
624
+ return AsyncProjectInstance(self._client, data)
625
+
626
+ async def delete(self, project_id: str) -> None:
627
+ """Delete a project and all its data (async)."""
628
+ await self._client.delete(f"/projects/{project_id}")