ws-bom-robot-app 0.0.10__py3-none-any.whl → 0.0.11__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.
@@ -1,3 +1,4 @@
1
+ from typing import Optional
1
2
  from pydantic import BaseModel, ConfigDict
2
3
  from pydantic_settings import BaseSettings
3
4
 
@@ -25,10 +26,38 @@ class Settings(BaseSettings):
25
26
  )
26
27
 
27
28
  class RuntimeOptions(BaseModel):
29
+ def _is_multi_process() -> bool:
30
+ """
31
+ Checks if the application is running with multiple worker processes.
32
+
33
+ This function inspects the command-line arguments to determine if the
34
+ application is configured to run with more than one worker process. It
35
+ looks for the "--workers" argument and checks if the subsequent value
36
+ is greater than 1.
37
+ Sample of command-line arguments:
38
+ fastapi run main.py --port 6001 --workers 4
39
+ uvicorn main:app --port 6001 --workers 4
40
+
41
+ Returns:
42
+ bool: True if the application is running with multiple worker
43
+ processes, False otherwise.
44
+ """
45
+ import sys, os
46
+ try:
47
+ for i, arg in enumerate(sys.argv):
48
+ if arg == "--workers" and i + 1 < len(sys.argv):
49
+ workers = int(sys.argv[i + 1])
50
+ if workers > 1:
51
+ return True
52
+ except (ValueError, IndexError):
53
+ pass
54
+ # Fallback: Compare process and parent process IDs
55
+ return False #os.getpid() != os.getppid()
28
56
  debug: bool
29
57
  loader_strategy: str
30
58
  loader_show_progress: bool
31
59
  loader_silent_errors: bool
60
+ is_multi_process: bool = _is_multi_process()
32
61
 
33
62
 
34
63
  def runtime_options(self) -> RuntimeOptions:
@@ -1,99 +1,250 @@
1
- from math import floor
2
- import schedule, time, threading
3
- import asyncio
1
+ from apscheduler.schedulers.background import BackgroundScheduler
2
+ #from apscheduler.schedulers.asyncio import AsyncIOScheduler
3
+ from apscheduler.jobstores.memory import MemoryJobStore
4
+ from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
5
+ from apscheduler.triggers.cron import CronTrigger
6
+ from apscheduler.triggers.interval import IntervalTrigger
7
+ from apscheduler.triggers.date import DateTrigger
4
8
  from fastapi import APIRouter
9
+ from datetime import datetime
5
10
  from ws_bom_robot_app.task_manager import task_manager
6
11
  from ws_bom_robot_app.llm.utils.kb import kb_cleanup_data_file
7
12
  from ws_bom_robot_app.util import _log
8
- import random
9
-
10
- class RecurringJob():
11
- @staticmethod
12
- def __add_jitter(interval: int) -> int:
13
- #delay proportional with interval, min 10, max 100 sec
14
- jitter: int = max(10, min(100, floor(interval * 0.075)))
15
- return interval + random.randint(-jitter, jitter)
16
- def __init__(self, interval: int, job_func, tags: list[str]):
17
- #add a little jitter by default for better concurrency in case of multiple instances
18
- self.interval = RecurringJob.__add_jitter(interval)
19
- self.job_func = job_func
20
- self.is_coroutine = asyncio.iscoroutinefunction(job_func)
13
+ from ws_bom_robot_app.config import config
14
+
15
+ class JobstoreStrategy:
16
+ def get_jobstore(self):
17
+ raise NotImplementedError("Subclasses should implement this method")
18
+
19
+ class MemoryJobstoreStrategy(JobstoreStrategy):
20
+ def get_jobstore(self):
21
+ _log.info("Using in-memory cron jobstore.")
22
+ return {"default": MemoryJobStore()}
23
+
24
+ class PersistentJobstoreStrategy(JobstoreStrategy):
25
+ def get_jobstore(self, db_url: str = "sqlite:///.data/db/jobs.sqlite"):
26
+ _log.info(f"Using persistent crob jobstore with database URL: {db_url}.")
27
+ return {"default": SQLAlchemyJobStore(url=db_url)}
28
+
29
+ class Job:
30
+ def __init__(self, name: str, job_func, args: list = None, kwargs: dict = None, cron_expression: str = None, interval: int = None, run_at: datetime = None):
31
+ """
32
+ Job class that supports both recurring and one-time jobs.
33
+ :param job_func: The function to execute.
34
+ :param interval: Interval in seconds for recurring jobs.
35
+ :param run_at: Specific datetime for one-time jobs.
36
+ :param tags: Tags associated with the job.
37
+ """
38
+ if not (cron_expression or interval or run_at):
39
+ raise ValueError("Either 'interval' or 'run_at' must be provided.")
40
+ self.name = name
21
41
  self.job_func = job_func
22
- self.tags = tags
23
- def run(self):
24
- if self.is_coroutine:
25
- schedule.every(self.interval).seconds.do(self._run_async_job).tag(*self.tags)
26
- else:
27
- schedule.every(self.interval).seconds.do(self.job_func).tag(*self.tags)
28
- async def _run_async_job(self):
29
- await self.job_func()
42
+ self.args: list = args or []
43
+ self.kwargs: dict = kwargs or {}
44
+ self.cron_expression = cron_expression
45
+ self.interval = interval
46
+ self.run_at = run_at
47
+
48
+ def create_trigger(self):
49
+ """Create the appropriate trigger based on the job type."""
50
+ if self.cron_expression:
51
+ return CronTrigger.from_crontab(self.cron_expression)
52
+ if self.interval:
53
+ return IntervalTrigger(seconds=self.interval)
54
+ elif self.run_at:
55
+ return DateTrigger(run_date=self.run_at)
30
56
 
31
57
  class CronManager:
58
+ _list_default = [
59
+ Job('cleanup-task',task_manager.cleanup_task, interval=5 * 60),
60
+ Job('cleanup-data',kb_cleanup_data_file, interval=180 * 60),
61
+ ]
62
+ def __get_jobstore_strategy() -> JobstoreStrategy:
63
+ if config.runtime_options().is_multi_process:
64
+ return MemoryJobstoreStrategy()
65
+ return PersistentJobstoreStrategy()
66
+ def __init__(self, strategy: JobstoreStrategy = None, enable_defaults: bool = True):
67
+ self.enable_defaults = enable_defaults
68
+ if strategy is None:
69
+ strategy = CronManager.__get_jobstore_strategy()
70
+ jobstores = strategy.get_jobstore()
71
+ self.scheduler: BackgroundScheduler = BackgroundScheduler(jobstores=jobstores)
72
+ self.__scheduler_is_running = False
32
73
 
33
- _list: dict[str, RecurringJob] = {
34
- 'cleanup-task': RecurringJob(5*60, task_manager.cleanup_task, tags=["cleanup","cleanup-task"]),
35
- 'cleanup-data': RecurringJob(180*60, kb_cleanup_data_file, tags=["cleanup","cleanup-data"]),
36
- }
74
+ def add_job(self, job: Job):
75
+ """
76
+ Adds a job to the scheduler with the specified name and job details.
77
+ Args:
78
+ name (str): The unique identifier for the job.
79
+ job (Job): An instance of the Job class containing the job details.
80
+ The job details include:
81
+ - job_func: The function to be executed.
82
+ - args: The positional arguments to pass to the job function.
83
+ - kwargs: The keyword arguments to pass to the job function.
84
+ - trigger: The trigger that determines when the job should be executed.
85
+ The job will replace any existing job with the same name.
86
+ Sample usage:
87
+ recurring_job = Job(name="sample-recurring-job",job_func=example_job, interval=5, tags=tags, args=args, kwargs=kwargs)
88
+ cron_manager.add_job(recurring_job)
89
+ fire_once_job = Job(name="sample-fire-once-job",job_func=example_job, run_at=datetime.now(), tags=tags, args=args, kwargs=kwargs)
90
+ cron_manager.add_job(fire_once_job)
91
+ """
92
+ existing_job = self.scheduler.get_job(job.name)
93
+ if existing_job:
94
+ _log.info(f"Job with name '{job.name}' already exists. Skip creation.")
95
+ else:
96
+ trigger = job.create_trigger()
97
+ self.scheduler.add_job(
98
+ func=job.job_func,
99
+ args=job.args,
100
+ kwargs=job.kwargs,
101
+ trigger=trigger,
102
+ id=job.name,
103
+ name=job.name,
104
+ replace_existing=False
105
+ )
37
106
 
38
- def __init__(self):
39
- self.jobs: dict[str, RecurringJob] = CronManager._list
40
- self.__scheduler_is_running = False
41
- def add_job(self, name:str, job: RecurringJob):
42
- job = {name: job}
43
- self.jobs.append(job)
44
- return job
45
- def run_pending(self):
46
- return schedule.run_pending()
47
- def run_all(self):
48
- return schedule.run_all()
49
- def clear(self):
50
- self.__scheduler_is_running = False
51
- return schedule.clear()
52
- def get_jobs(self):
53
- return schedule.get_jobs()
54
107
  def start(self):
55
- def _target():
56
- while self.__scheduler_is_running:
57
- time.sleep(1)
58
- self.run_pending()
59
- time.sleep(59)
60
- _log.info(f"__scheduler_is_running={self.__scheduler_is_running}")
61
- #clear all jobs
62
- self.clear()
63
- #prepare jobs
64
- for job in self.jobs.values():
65
- job.run()
66
- #start scheduler
67
108
  if not self.__scheduler_is_running:
68
109
  self.__scheduler_is_running = True
69
- t = threading.Thread(target=_target,args=(),daemon=True)
70
- t.start()
110
+ self.scheduler.start()
111
+ if self.enable_defaults and CronManager._list_default:
112
+ for job in CronManager._list_default:
113
+ existing_job = self.scheduler.get_job(job.name)
114
+ if existing_job is None:
115
+ self.add_job(job)
116
+
117
+ def get_job(self, job_id: str):
118
+ return self.scheduler.get_job(job_id)
119
+
120
+ def get_jobs(self):
121
+ return self.scheduler.get_jobs()
122
+
123
+ def execute_job(self, job_id: str):
124
+ job = self.scheduler.get_job(job_id)
125
+ if job:
126
+ job.func()
127
+ else:
128
+ raise ValueError(f"Job with id '{job_id}' not found.")
129
+
130
+ def pause_job(self, job_id: str):
131
+ self.scheduler.pause_job(job_id)
132
+
133
+ def resume_job(self, job_id: str):
134
+ self.scheduler.resume_job(job_id)
135
+
136
+ def remove_job(self, job_id: str):
137
+ self.scheduler.remove_job(job_id)
138
+
139
+ def execute_recurring_jobs(self):
140
+ for job in self.scheduler.get_jobs():
141
+ if job.interval:
142
+ job.job_func()
143
+
144
+ def pause_recurring_jobs(self):
145
+ for job in self.scheduler.get_jobs():
146
+ if job.interval:
147
+ self.pause_job(job.id)
148
+
149
+ def resume_recurring_jobs(self):
150
+ for job in self.scheduler.get_jobs():
151
+ if job.interval:
152
+ self.resume_job(job.id)
153
+
154
+ def remove_recurring_jobs(self):
155
+ for job in self.scheduler.get_jobs():
156
+ if job.interval:
157
+ self.remove_job(job.id)
158
+
159
+ def clear(self):
160
+ self.__scheduler_is_running = False
161
+ self.scheduler.remove_all_jobs()
162
+
163
+ def shutdown(self):
164
+ self.scheduler.shutdown()
71
165
 
72
166
  cron_manager = CronManager()
73
167
 
168
+ # FastAPI Routes
74
169
  router = APIRouter(prefix="/api/cron", tags=["cron"])
170
+
75
171
  @router.get("/list")
76
172
  def _list():
77
- def __format(job: schedule.Job) -> dict:
173
+ def __format(job):
174
+ return {
175
+ "id": job.id,
176
+ "name": job.name,
177
+ "func": job.func_ref,
178
+ "pending": job.pending,
179
+ "trigger": str(job.trigger),
180
+ "next_run_time": job.next_run_time
181
+ }
182
+ return [__format(job) for job in cron_manager.get_jobs()]
183
+
184
+ @router.get("/default-jobs")
185
+ def _default_jobs():
186
+ def __format(job):
187
+ existing_job = cron_manager.scheduler.get_job(job.name)
78
188
  return {
79
- "job": {'module':job.job_func.__module__,'name':job.job_func.__name__},
80
- "at": job.at_time,
81
- "interval": job.interval,
82
- "last_run": job.last_run,
83
- "next_run": job.next_run,
84
- "tags": job.tags}
85
- _list = cron_manager.get_jobs()
86
- return [__format(_) for _ in _list]
189
+ "name": job.name,
190
+ "status": "exists" if existing_job else "not added"
191
+ }
192
+ return [__format(job) for job in CronManager._list_default]
193
+
194
+ @router.post("/execute-job/{job_id}")
195
+ def _execute_job(job_id: str):
196
+ try:
197
+ cron_manager.execute_job(job_id)
198
+ return {"status": f"Job {job_id} executed"}
199
+ except ValueError as e:
200
+ return {"error": str(e)}
201
+
202
+ @router.post("/pause-job/{job_id}")
203
+ def _pause_job(job_id: str):
204
+ cron_manager.pause_job(job_id)
205
+ return {"status": f"Job {job_id} paused"}
206
+
207
+ @router.post("/resume-job/{job_id}")
208
+ def _resume_job(job_id: str):
209
+ cron_manager.resume_job(job_id)
210
+ return {"status": f"Job {job_id} resumed"}
211
+
212
+ @router.delete("/remove-job/{job_id}")
213
+ def _remove_job(job_id: str):
214
+ cron_manager.remove_job(job_id)
215
+ return {"status": f"Job {job_id} removed"}
216
+
217
+ @router.post("/execute-recurring")
218
+ def _execute_recurring():
219
+ cron_manager.execute_recurring_jobs()
220
+ return {"status": "All recurring jobs executed"}
221
+
222
+ @router.post("/pause-recurring")
223
+ def _pause_recurring():
224
+ cron_manager.pause_recurring_jobs()
225
+ return {"status": "All recurring jobs paused"}
226
+
227
+ @router.post("/resume-recurring")
228
+ def _resume_recurring():
229
+ cron_manager.resume_recurring_jobs()
230
+ return {"status": "All recurring jobs resumed"}
231
+
232
+ @router.delete("/remove-recurring")
233
+ def _remove_recurring():
234
+ cron_manager.remove_recurring_jobs()
235
+ return {"status": "All recurring jobs removed"}
87
236
 
88
237
  @router.get("/start")
89
238
  def _start():
90
239
  cron_manager.start()
240
+ return {"status": "started"}
241
+
91
242
  @router.delete("/stop")
92
243
  def _stop():
93
- return {"_": cron_manager.clear()}
94
- @router.get("/run/pending")
95
- def _run_pending():
96
- return {"_": cron_manager.run_pending()}
97
- @router.get("/run/all")
98
- def _run_all():
99
- return {"_": cron_manager.run_all()}
244
+ cron_manager.clear()
245
+ return {"status": "stopped"}
246
+
247
+ @router.get("/shutdown")
248
+ def _shutdown():
249
+ cron_manager.shutdown()
250
+ return {"status": "shutdown"}
@@ -51,3 +51,4 @@ class WebhookNotifier:
51
51
  async with httpx.AsyncClient(headers=_headers,verify=False,timeout=timeout) as client:
52
52
  response = await client.post(endpoint, data=_data)
53
53
  response.raise_for_status()
54
+
@@ -5,7 +5,6 @@ from langchain_community.document_loaders.sitemap import SitemapLoader
5
5
  from langchain_community.document_transformers import MarkdownifyTransformer as markdownify
6
6
  from langchain_core.documents import Document
7
7
  from bs4 import BeautifulSoup, Tag
8
- import nest_asyncio, os
9
8
 
10
9
 
11
10
  class Sitemap(IntegrationStrategy):
ws_bom_robot_app/main.py CHANGED
@@ -1,5 +1,6 @@
1
1
  import datetime
2
2
  import platform
3
+ from fastapi.responses import FileResponse
3
4
  import uvicorn, os, sys
4
5
  from fastapi import FastAPI, Depends
5
6
  from fastapi.openapi.docs import get_swagger_ui_html
@@ -22,7 +23,10 @@ app.include_router(cron,dependencies=[Depends(authenticate)])
22
23
 
23
24
  @app.get("/")
24
25
  async def root():
25
- return {}
26
+ return health()
27
+ @app.get("/favicon.ico")
28
+ async def favicon():
29
+ return FileResponse("./favicon.ico")
26
30
 
27
31
  @app.get("/docs", include_in_schema=False)
28
32
  async def get_swagger_documentation(authenticate: bool = Depends(authenticate)):
@@ -31,7 +35,11 @@ async def get_swagger_documentation(authenticate: bool = Depends(authenticate)):
31
35
  async def openapi(authenticate: bool = Depends(authenticate)):
32
36
  return get_openapi(title=app.title, version=app.version, routes=app.routes)
33
37
 
34
- @app.get("/diag",tags=["diag"])
38
+ @app.get("/api/health",tags=["diag"])
39
+ def health():
40
+ return {"status": "ok"}
41
+
42
+ @app.get("/api/diag",tags=["diag"])
35
43
  def diag(authenticate: bool = Depends(authenticate)):
36
44
  import pkg_resources
37
45
  from ws_bom_robot_app.llm.vector_store.loader.base import Loader as wsll
@@ -57,6 +65,7 @@ def diag(authenticate: bool = Depends(authenticate)):
57
65
  "args": {k: arg for k, arg in enumerate(sys.argv)}
58
66
  },
59
67
  "os": {
68
+ "ppid": os.getppid(),
60
69
  "pid": os.getpid(),
61
70
  "cwd": os.getcwd(),
62
71
  "ws_bom_robot_app": pkg_resources.get_distribution("ws_bom_robot_app").version,
@@ -64,6 +73,7 @@ def diag(authenticate: bool = Depends(authenticate)):
64
73
  },
65
74
  },
66
75
  "config":config,
76
+ "runtime":config.runtime_options(),
67
77
  "extension": {
68
78
  "loader": ({item[0]: item[1].loader.__name__ if item[1] else None} for item in sorted(wsll._list.items(), key=lambda x: x[0]) if item[1]),
69
79
  "integration":({item[0]: type(item[1]).__name__} for item in wsim._list.items()),
@@ -2,16 +2,34 @@ import inspect
2
2
  import asyncio, os
3
3
  from datetime import datetime, timedelta
4
4
  from enum import Enum
5
- from typing import Annotated, TypeVar, Optional, Dict, Union, Any
5
+ from typing import Annotated, Literal, TypeVar, Optional, Dict, Union, Any
6
6
  from pydantic import BaseModel, ConfigDict, Field, computed_field
7
7
  from uuid import uuid4
8
8
  from fastapi import APIRouter, HTTPException
9
9
  from ws_bom_robot_app.config import config
10
10
  from ws_bom_robot_app.llm.models.base import IdentifiableEntity
11
11
  from ws_bom_robot_app.llm.utils.webhooks import WebhookNotifier
12
+ from ws_bom_robot_app.util import _log
13
+ from sqlalchemy import create_engine, Column, String, JSON, DateTime, Enum
14
+ from sqlalchemy.ext.declarative import declarative_base
15
+ from sqlalchemy.orm import sessionmaker
16
+ from abc import ABC, abstractmethod
12
17
 
13
18
  T = TypeVar('T')
14
19
 
20
+ #region models
21
+ class TaskStatistics(BaseModel):
22
+ class TaskStatisticExecutionTime(BaseModel):
23
+ min: str
24
+ max: str
25
+ avg: str
26
+ retention_days: float = config.robot_task_retention_days
27
+ total: int
28
+ pending: int
29
+ completed: int
30
+ failure: int
31
+ exec_time: TaskStatisticExecutionTime
32
+
15
33
  class TaskHeader(BaseModel):
16
34
  x_ws_bom_msg_type: Optional[str] = None
17
35
  x_ws_bom_webhooks: Optional[str] = None
@@ -20,31 +38,24 @@ class TaskHeader(BaseModel):
20
38
  )
21
39
 
22
40
  class TaskMetaData(BaseModel):
23
- start_time: datetime
24
- end_time: Optional[datetime] = None
41
+ start_time: str
42
+ end_time: Optional[str] = None
25
43
  @computed_field
26
44
  @property
27
- def elapsed_time(self) -> Union[timedelta, None]:
28
- return (datetime.now() if not self.end_time else self.end_time) - self.start_time
45
+ def elapsed_time(self) -> Union[str, None]:
46
+ return str((datetime.now() if not self.end_time else datetime.fromisoformat(self.end_time)) - datetime.fromisoformat(self.start_time))
29
47
  source: Optional[str] = None
30
48
  pid: Optional[int] = None
31
- model_config = ConfigDict(
32
- json_encoders={
33
- datetime: lambda v: v.isoformat(),
34
- timedelta: lambda v: str(v)
35
- }
36
- )
37
49
 
38
50
  class TaskStatus(IdentifiableEntity):
39
- class TaskStatusEnum(str, Enum):
40
- pending = "pending"
41
- completed = "completed"
42
- failure = "failure"
43
51
  type: Optional[str] = None
44
- status: TaskStatusEnum
52
+ status: Literal["pending", "completed", "failure"]
45
53
  result: Optional[T] = None
46
54
  metadata: TaskMetaData = None
47
55
  error: Optional[str] = None
56
+ model_config = ConfigDict(
57
+ arbitrary_types_allowed=True
58
+ )
48
59
 
49
60
  class TaskEntry(IdentifiableEntity):
50
61
  task: Annotated[asyncio.Task, Field(default=None, validate_default=False)] = None
@@ -65,9 +76,9 @@ class TaskEntry(IdentifiableEntity):
65
76
  self.status = TaskStatus(
66
77
  id=self.id,
67
78
  type=self.headers.x_ws_bom_msg_type if self.headers and self.headers.x_ws_bom_msg_type else self._get_coroutine_name(task._coro) if task else None,
68
- status=TaskStatus.TaskStatusEnum.pending,
79
+ status="pending",
69
80
  metadata=TaskMetaData(
70
- start_time=datetime.now(),
81
+ start_time=str(datetime.now().isoformat()),
71
82
  source=self._get_coroutine_name(task._coro) if task else None,
72
83
  pid=os.getpid())
73
84
  )
@@ -76,76 +87,225 @@ class TaskEntry(IdentifiableEntity):
76
87
  validate_assignment=True
77
88
  )
78
89
 
79
- class TaskManager:
80
- def __init__(self):
81
- self.tasks: Dict[str, TaskEntry] = {}
90
+ #endregion
91
+
92
+ #region interface
93
+ class TaskManagerStrategy(ABC):
94
+ @abstractmethod
95
+ def create_task(self, coroutine, headers: TaskHeader | None = None) -> IdentifiableEntity:
96
+ pass
97
+
98
+ @abstractmethod
99
+ def update_task_status(self, task: TaskEntry) -> None:
100
+ """Hook for additional behavior, such as persisting the task status."""
101
+ pass
102
+
103
+ @abstractmethod
104
+ def get_task(self, id: str) -> TaskStatus | None:
105
+ pass
106
+
107
+ @abstractmethod
108
+ def get_tasks(self) -> list[TaskStatus] | None:
109
+ pass
110
+
111
+ @abstractmethod
112
+ def remove_task(self, id: str) -> None:
113
+ pass
82
114
 
83
- def _task_done_callback(self, task_id: str, headers: TaskHeader | None = None):
115
+ @abstractmethod
116
+ def cleanup_task(self) -> None:
117
+ pass
118
+
119
+ @abstractmethod
120
+ def stats(self) -> TaskStatistics:
121
+ pass
122
+
123
+ def task_cleanup_rule(self, task: TaskEntry) -> bool:
124
+ return task.status.status in {"completed", "failure"} and datetime.fromisoformat(task.status.metadata.end_time) < datetime.now() - timedelta(days=config.robot_task_retention_days)
125
+
126
+ def task_done_callback(self, task_entry: TaskEntry, headers: TaskHeader | None = None) -> callable:
84
127
  def callback(task: asyncio.Task):
85
- if _task := self.tasks.get(task_id):
86
- try:
87
- result = _task.task.result()
88
- _task.status.status = TaskStatus.TaskStatusEnum.completed
89
- _task.status.result = result
90
- except Exception as e:
91
- _task.status.status = TaskStatus.TaskStatusEnum.failure
92
- _task.status.error = str(e)
93
- finally:
94
- _task.status.metadata.end_time = datetime.now()
95
- if headers and headers.x_ws_bom_webhooks:
96
- asyncio.create_task(
97
- WebhookNotifier().notify_webhook(_task.status,headers.x_ws_bom_webhooks)
98
- )
128
+ try:
129
+ result = task_entry.task.result()
130
+ task_entry.status.status = "completed"
131
+ task_entry.status.result = result
132
+ except Exception as e:
133
+ task_entry.status.status = "failure"
134
+ task_entry.status.error = str(e)
135
+ finally:
136
+ task_entry.status.metadata.end_time = str(datetime.now().isoformat())
137
+ #strategy-specific behavior
138
+ self.update_task_status(task_entry)
139
+ #notify webhooks
140
+ if headers and headers.x_ws_bom_webhooks:
141
+ asyncio.create_task(
142
+ WebhookNotifier().notify_webhook(task_entry.status,headers.x_ws_bom_webhooks)
143
+ )
99
144
  return callback
100
-
101
- def create_task(self, coroutine: asyncio.coroutines, headers: TaskHeader | None = None) -> IdentifiableEntity:
102
- _task = asyncio.create_task(coroutine)
145
+ def create_task_entry(self, coroutine: asyncio.coroutines, headers: TaskHeader | None = None) -> TaskEntry:
103
146
  task = TaskEntry(
104
147
  id=str(uuid4()),
105
- task=_task,
148
+ task=asyncio.create_task(coroutine),
106
149
  headers=headers)
107
- task.task.add_done_callback(self._task_done_callback(task.id, headers))
150
+ task.task.add_done_callback(self.task_done_callback(task, headers))
151
+ return task
152
+
153
+ def stats(self) -> TaskStatistics:
154
+ def __string_to_timedelta(value: str) -> timedelta:
155
+ if "." in value:
156
+ time_format = "%H:%M:%S.%f"
157
+ else:
158
+ time_format = "%H:%M:%S"
159
+ time_obj = datetime.strptime(value, time_format)
160
+ return timedelta(hours=time_obj.hour, minutes=time_obj.minute, seconds=time_obj.second, microseconds=time_obj.microsecond)
161
+ def __timedelta_to_string(td):
162
+ hours, remainder = divmod(td.total_seconds(), 3600)
163
+ minutes, seconds = divmod(remainder, 60)
164
+ return f"{int(hours):02d}:{int(minutes):02d}:{int(seconds):02d}.{td.microseconds}"
165
+ _all = self.get_tasks()
166
+ _not_pending = _all and [task for task in _all if task.status != "pending"]
167
+ _total_not_pending = len(_not_pending)
168
+ elapsed_times = [__string_to_timedelta(task.metadata.elapsed_time) for task in _not_pending]
169
+ _avg_exec_time = sum(elapsed_times, timedelta()) / _total_not_pending if _total_not_pending > 0 else timedelta()
170
+ _min_exec_time = min(elapsed_times) if _total_not_pending > 0 else timedelta()
171
+ _max_exec_time = max(elapsed_times) if _total_not_pending > 0 else timedelta()
172
+ return TaskStatistics(
173
+ total= _all and len(_all) or 0,
174
+ pending=_all and len([task for task in _all if task.status == "pending"]) or 0,
175
+ completed=_all and len([task for task in _all if task.status == "completed"]) or 0,
176
+ failure=_all and len([task for task in _all if task.status == "failure"]) or 0,
177
+ exec_time=TaskStatistics.TaskStatisticExecutionTime(
178
+ min=__timedelta_to_string(_min_exec_time),
179
+ max=__timedelta_to_string(_max_exec_time),
180
+ avg=__timedelta_to_string(_avg_exec_time)
181
+ )
182
+ )
183
+
184
+ #endregion
185
+
186
+ #memory implementation
187
+ class MemoryTaskManagerStrategy(TaskManagerStrategy):
188
+ def __init__(self):
189
+ self.tasks: Dict[str, TaskEntry] = {}
190
+
191
+ def create_task(self, coroutine: asyncio.coroutines, headers: TaskHeader | None = None) -> IdentifiableEntity:
192
+ task = self.create_task_entry(coroutine, headers)
108
193
  self.tasks[task.id] = task
109
194
  return IdentifiableEntity(id=task.id)
110
195
 
111
- def get_task(self, task_id: str) -> TaskEntry | None:
112
- if _task := self.tasks.get(task_id):
196
+ def update_task_status(self, task: TaskEntry) -> None:
197
+ """no-op for memory strategy."""
198
+ pass
199
+
200
+ def get_task(self, id: str) -> TaskStatus | None:
201
+ if _task := self.tasks.get(id):
113
202
  return _task
114
203
  return None
115
204
 
116
- def remove_task(self, task_id: str) -> None:
117
- if task_id in self.tasks:
118
- del self.tasks[task_id]
205
+ def get_tasks(self) -> list[TaskStatus] | None:
206
+ return [task.status for task in self.tasks.values()]
207
+
208
+ def remove_task(self, id: str) -> None:
209
+ if id in self.tasks:
210
+ del self.tasks[id]
119
211
 
120
212
  def cleanup_task(self):
121
- for task_id in [task_id for task_id, task in self.tasks.items()
122
- if task.status.status in {TaskStatus.TaskStatusEnum.completed, TaskStatus.TaskStatusEnum.failure}
123
- and task.status.metadata.end_time < datetime.now() - timedelta(days=config.robot_task_retention_days)]:
124
- self.remove_task(task_id)
213
+ keys = [task.id for task in self.tasks.values() if self.task_cleanup_rule(task)]
214
+ for key in keys:
215
+ self.remove_task(key)
216
+
217
+ #endregion
218
+
219
+ #db implementation
220
+ Base = declarative_base()
221
+ class TaskEntryModel(Base):
222
+ __tablename__ = "entry"
223
+ id = Column(String, primary_key=True)
224
+ status = Column(JSON)
225
+ model_config = ConfigDict(
226
+ arbitrary_types_allowed=True
227
+ )
228
+ class DatabaseTaskManagerStrategy(TaskManagerStrategy):
229
+ def __init__(self, db_url: str = "sqlite:///.data/db/tasks.sqlite"):
230
+ self.engine = create_engine(db_url)
231
+ self.Session = sessionmaker(bind=self.engine)
232
+ Base.metadata.create_all(self.engine)
233
+
234
+ def create_task(self, coroutine: asyncio.coroutines, headers: TaskHeader | None = None) -> IdentifiableEntity:
235
+ task = self.create_task_entry(coroutine, headers)
236
+ with self.Session() as session:
237
+ session.add(TaskEntryModel(id=task.id, status=task.status.model_dump()))
238
+ session.commit()
239
+ return IdentifiableEntity(id=task.id)
240
+
241
+ def update_task_status(self, task: TaskEntry) -> None:
242
+ with self.Session() as session:
243
+ session.query(TaskEntryModel).filter_by(id=task.id).update(
244
+ {"status": task.status.model_dump()}
245
+ )
246
+ session.commit()
247
+
248
+ def get_task(self, id: str) -> TaskStatus | None:
249
+ with self.Session() as session:
250
+ task = session.query(TaskEntryModel).filter_by(id=id).first()
251
+ if task:
252
+ return TaskEntry(**task.__dict__)
253
+ return None
254
+
255
+ def get_tasks(self) -> list[TaskStatus] | None:
256
+ with self.Session() as session:
257
+ tasks = session.query(TaskEntryModel).all()
258
+ if tasks:
259
+ return [TaskEntry(**task.__dict__).status for task in tasks]
260
+ return None
261
+
262
+ def remove_task(self, id: str) -> None:
263
+ with self.Session() as session:
264
+ session.query(TaskEntryModel).filter_by(id=id).delete()
265
+ session.commit()
266
+
267
+ def cleanup_task(self):
268
+ with self.Session() as session:
269
+ for task in session.query(TaskEntryModel).all():
270
+ _task = TaskEntry(**task.__dict__)
271
+ if self.task_cleanup_rule(_task):
272
+ session.query(TaskEntryModel).filter_by(id=task.id).delete()
273
+ session.commit()
274
+ #endregion
125
275
 
126
276
  # global instance
127
- task_manager = TaskManager()
277
+ def __get_taskmanager_strategy() -> TaskManagerStrategy:
278
+ if config.runtime_options().is_multi_process:
279
+ return DatabaseTaskManagerStrategy()
280
+ return MemoryTaskManagerStrategy()
281
+ task_manager = __get_taskmanager_strategy()
282
+ _log.info(f"Task manager strategy: {task_manager.__class__.__name__}")
128
283
 
284
+ #region api
129
285
  router = APIRouter(prefix="/api/task", tags=["task"])
130
- @router.get("/status/{task_id}", response_model=TaskStatus)
131
- async def _status_task(task_id: str) -> TaskStatus:
132
- task = task_manager.get_task(task_id)
286
+
287
+ @router.get("/status/{id}")
288
+ async def _status_task(id: str) -> TaskStatus:
289
+ task = task_manager.get_task(id)
133
290
  if not task:
134
291
  raise HTTPException(status_code=404, detail="Task not found")
135
292
  return task.status
293
+
136
294
  @router.get("/status")
137
- async def _status_task_list():
138
- _status_task_list = []
139
- for task in task_manager.tasks.values():
140
- _task = task_manager.get_task(task.id)
141
- _status_task_list.append(_task.status)
142
- return _status_task_list
143
- @router.delete("/status/{task_id}")
144
- async def _remove_task(task_id: str):
145
- task_manager.remove_task(task_id)
146
- return {"success":"ok"}
295
+ async def _status_task_list() -> list[TaskStatus]:
296
+ return task_manager.get_tasks()
297
+
298
+ @router.delete("/status/{id}")
299
+ async def _remove_task(id: str):
300
+ task_manager.remove_task(id)
301
+ return {"success":"ok"}
302
+
147
303
  @router.delete("/cleanup")
148
304
  async def _remove_task_list():
149
305
  task_manager.cleanup_task()
150
306
  return {"success":"ok"}
151
307
 
308
+ @router.get("/stats")
309
+ async def _stats() -> TaskStatistics:
310
+ return task_manager.stats()
311
+ #endregion
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: ws_bom_robot_app
3
- Version: 0.0.10
3
+ Version: 0.0.11
4
4
  Summary: A FastAPI application serving ws bom/robot/llm platform ai.
5
5
  Home-page: https://github.com/websolutespa/bom
6
6
  Author: Websolute Spa
@@ -11,7 +11,7 @@ Classifier: Operating System :: OS Independent
11
11
  Requires-Python: >=3.12
12
12
  Description-Content-Type: text/markdown
13
13
  Requires-Dist: standardwebhooks==1.0.0
14
- Requires-Dist: schedule==1.2.2
14
+ Requires-Dist: apscheduler==3.11.0
15
15
  Requires-Dist: aiofiles==24.1.0
16
16
  Requires-Dist: pydantic==2.9.2
17
17
  Requires-Dist: pydantic-settings==2.6.0
@@ -1,9 +1,9 @@
1
1
  ws_bom_robot_app/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  ws_bom_robot_app/auth.py,sha256=84nIbmJsMrNs0sxIQGEHbjsjc2P6ZrZZGSn8dkiL6is,895
3
- ws_bom_robot_app/config.py,sha256=VUoE6MBpYnZ8FQvUjWOLCzfs35aUvpwrz4ROFVbO3AA,1831
4
- ws_bom_robot_app/cron_manager.py,sha256=Gd76VwhvlauZ5jCrZa4gyM1NoQdynzskfRYPw6niFyk,3548
5
- ws_bom_robot_app/main.py,sha256=UPRqJkdhFBS8vmJYFJb4hgpx4pMFwOCrbJwt5MTQymE,3556
6
- ws_bom_robot_app/task_manager.py,sha256=aiQV5EJ_Q0B0mTcERVbzgaEepEUf4OtljC_UACs6pr8,5837
3
+ ws_bom_robot_app/config.py,sha256=V8anZ6Cn8BCv8jO2FMkDxqVA022jnBK2nzOQw6fzf0g,3161
4
+ ws_bom_robot_app/cron_manager.py,sha256=99VZEIEaMmZOYU71bREyBCS2BQVIZG5azTTfOOcxdww,9215
5
+ ws_bom_robot_app/main.py,sha256=JbN4WzbPd0CXW0izVfj4ljxOFeYon-PXfNrhYgbIX14,3869
6
+ ws_bom_robot_app/task_manager.py,sha256=OH-zkWS9iLvCP9eIQdtLsNARMVwIYhBp803ieeNszXo,12012
7
7
  ws_bom_robot_app/util.py,sha256=3aBK-bhsvKJwJeWOHh0c1B1BOyJ_tnUxOa1mJmFKwYQ,2618
8
8
  ws_bom_robot_app/llm/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
9
  ws_bom_robot_app/llm/agent_description.py,sha256=SDJYMmwfdMxEK3a_HDEQ19bfNKmwMSFf5hqU0VSCCIE,4705
@@ -29,17 +29,17 @@ ws_bom_robot_app/llm/utils/download.py,sha256=iAUxH_NiCpTPtGzhC4hBtxotd2HPFt2MBh
29
29
  ws_bom_robot_app/llm/utils/faiss_helper.py,sha256=DowmroVT6eIbvnA-TG84PS_D7ujvxSRIKdLuIcJmd6Q,4650
30
30
  ws_bom_robot_app/llm/utils/kb.py,sha256=jja45WCbNI7SGEgqDS99nErlwB5eY8Ga7BMnhdMHZ90,1279
31
31
  ws_bom_robot_app/llm/utils/print.py,sha256=bpLWY0KHXe7x7PWcWy8NS54ZWzHY8b4jrLRkpnDl108,818
32
- ws_bom_robot_app/llm/utils/webhooks.py,sha256=Sdwu4dkbUC3JEf9ck1hsu4gSGQdj7gcsIrhs3cOVXxk,2112
32
+ ws_bom_robot_app/llm/utils/webhooks.py,sha256=LAAZqyN6VhV13wu4X-X85TwdDgAV2rNvIwQFIIc0FJM,2114
33
33
  ws_bom_robot_app/llm/vector_store/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
34
  ws_bom_robot_app/llm/vector_store/generator.py,sha256=DM1jQB6nb03GYFMaO-qN6IX29apFZOYkxEKt6wrXdIQ,5770
35
35
  ws_bom_robot_app/llm/vector_store/integration/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
36
36
  ws_bom_robot_app/llm/vector_store/integration/base.py,sha256=eCKD3U0KPoVDMtKr2iZqauMFEKd9b2k6rqPG_YjDy0g,626
37
37
  ws_bom_robot_app/llm/vector_store/integration/manager.py,sha256=cSFlE2erMv3Uchy788mlCFdcvmyeoqdeIiGmJ9QbLhY,583
38
- ws_bom_robot_app/llm/vector_store/integration/sitemap.py,sha256=O7E6FfE0gUg6kMkJUy8t19ZnJ74zm_nusNrWWLz_zjo,4031
38
+ ws_bom_robot_app/llm/vector_store/integration/sitemap.py,sha256=A34iaXv80tUgEPf-ntEmC_Pb8GDDAvmfOnloBNOk_Io,4006
39
39
  ws_bom_robot_app/llm/vector_store/loader/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
40
40
  ws_bom_robot_app/llm/vector_store/loader/base.py,sha256=YKtJWTVQuOvRR2CLYTa0bGEbjqgmYVVud1YKQ9QLibc,4536
41
41
  ws_bom_robot_app/llm/vector_store/loader/json_loader.py,sha256=G9BoxwsevgqL72h2n28O2LpzaCYNymBkX66wru9GkCw,884
42
- ws_bom_robot_app-0.0.10.dist-info/METADATA,sha256=OfGHsmNN39wqXEptnM_p1en48OhEKQHN0KvCN1SsBp4,5674
43
- ws_bom_robot_app-0.0.10.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
44
- ws_bom_robot_app-0.0.10.dist-info/top_level.txt,sha256=Yl0akyHVbynsBX_N7wx3H3ZTkcMLjYyLJs5zBMDAKcM,17
45
- ws_bom_robot_app-0.0.10.dist-info/RECORD,,
42
+ ws_bom_robot_app-0.0.11.dist-info/METADATA,sha256=SA7_BZPmnAQ5-v2oXHWJgzF7zLc_js09h6pl96wHFzU,5678
43
+ ws_bom_robot_app-0.0.11.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
44
+ ws_bom_robot_app-0.0.11.dist-info/top_level.txt,sha256=Yl0akyHVbynsBX_N7wx3H3ZTkcMLjYyLJs5zBMDAKcM,17
45
+ ws_bom_robot_app-0.0.11.dist-info/RECORD,,