Repository navigation
feat(cron): Clean up LLM call rows and stt/tts audio filepath reconciliation #1201
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
64505e1
afa509c
726a851
da21bd8
2343610
49afb1e
864fe12
65babdc
491038a
bed3064
a97ae04
d314a43
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -110,3 +110,5 @@ SMTP_PASSWORD= | |
| EMAILS_FROM_EMAIL= | ||
| EMAILS_FROM_NAME=Kaapi | ||
| FRONTEND_HOST= | ||
|
|
||
| LLM_CALL_RETENTION_HOURS= | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -8,7 +8,9 @@ | |||||
| from app.api.permissions import Permission, require_permission | ||||||
| from app.core.config import settings | ||||||
| from app.crud.evaluations import process_all_pending_evaluations | ||||||
| from app.models.llm.response import LlmCallRedactionResult | ||||||
| from app.services.job_monitoring import monitor_pending_jobs | ||||||
| from app.services.llm.retention import redact_aged_llm_calls | ||||||
| from app.crud.stats import StatRow, get_daily_stats | ||||||
| from app.services.stats import format_sections, post_to_discord | ||||||
|
|
||||||
|
|
@@ -23,15 +25,10 @@ | |||||
| "value": settings.CRON_INTERVAL_MINUTES, | ||||||
| "unit": "minute", | ||||||
| }, | ||||||
| # Timezone for the schedule (only affects crontab-style schedules). | ||||||
| "timezone": "UTC", | ||||||
| # Grace period (minutes) before a late check-in is marked as missed. | ||||||
| "checkin_margin": 2, | ||||||
| # Max runtime (minutes) before an in-progress run is marked as timed out. | ||||||
| "max_runtime": 2 * settings.CRON_INTERVAL_MINUTES, | ||||||
| # Consecutive failures/missed/timeouts required to open a Sentry issue. | ||||||
| "failure_issue_threshold": 2, | ||||||
| # Consecutive successful check-ins required to auto-resolve the issue. | ||||||
| "recovery_threshold": 1, | ||||||
| } | ||||||
|
|
||||||
|
|
@@ -59,6 +56,16 @@ | |||||
| } | ||||||
|
|
||||||
|
|
||||||
| LLM_CALL_RETENTION_CRON_MONITOR_CONFIG: MonitorConfig = { | ||||||
| "schedule": {"type": "crontab", "value": "0 9 * * *"}, | ||||||
| "timezone": "Asia/Kolkata", | ||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||
| "checkin_margin": 5, | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. or maybe this too. |
||||||
| "max_runtime": 30, | ||||||
| "failure_issue_threshold": 1, | ||||||
| "recovery_threshold": 1, | ||||||
| } | ||||||
|
|
||||||
|
|
||||||
| @router.get( | ||||||
| "/cron/evaluations", | ||||||
| include_in_schema=False, | ||||||
|
|
@@ -158,6 +165,35 @@ def daily_stats_cron_job(session: SessionDep) -> dict[str, list[StatRow]]: | |||||
| raise | ||||||
|
|
||||||
|
|
||||||
| @router.get( | ||||||
| "/cron/llm-call-retention", | ||||||
| include_in_schema=False, | ||||||
| dependencies=[Depends(require_permission(Permission.SUPERUSER))], | ||||||
| ) | ||||||
| @sentry_sdk.monitor( | ||||||
| monitor_slug="llm-call-retention-cron-job", | ||||||
| monitor_config=LLM_CALL_RETENTION_CRON_MONITOR_CONFIG, | ||||||
| ) | ||||||
| def llm_call_retention_cron_job(session: SessionDep) -> LlmCallRedactionResult: | ||||||
| logger.info("[llm_call_retention_cron_job] Cron job invoked") | ||||||
|
|
||||||
|
Comment on lines
+178
to
+179
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not needed. |
||||||
| try: | ||||||
| result = redact_aged_llm_calls(session=session) | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| logger.info( | ||||||
| f"[llm_call_retention_cron_job] Completed: " | ||||||
| f"rows_redacted={result.rows_redacted}, " | ||||||
| f"cutoff={result.cutoff}" | ||||||
| ) | ||||||
|
Comment on lines
+182
to
+186
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this too. |
||||||
| return result | ||||||
| except Exception as e: | ||||||
| logger.error( | ||||||
| f"[llm_call_retention_cron_job] Error executing cron job: {e}", | ||||||
| exc_info=True, | ||||||
| ) | ||||||
|
Comment on lines
+189
to
+192
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do we need this when sentry SDK is already capturing it |
||||||
| sentry_sdk.capture_exception(e) | ||||||
| raise | ||||||
|
|
||||||
|
|
||||||
| @router.get( | ||||||
| "/cron/pending-jobs", | ||||||
| include_in_schema=False, | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -177,34 +177,44 @@ def from_url(cls, url: str): | |||||||
| PENDING_PREFIX = "pending" | ||||||||
| PENDING_TTL_DAYS = 1 | ||||||||
|
|
||||||||
| # LLM call audio (STT input, TTS output). Its own literal prefix so one lifecycle | ||||||||
| # rule can expire it in step with the llm_call retention cron, which redacts the | ||||||||
| # only DB pointer to each object. | ||||||||
|
Comment on lines
+180
to
+182
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not needed.
Suggested change
|
||||||||
| LLM_AUDIO_PREFIX = "llm-audio" | ||||||||
|
|
||||||||
|
|
||||||||
| class CloudStorage(ABC): | ||||||||
| def __init__(self, project_id: int, storage_path: UUID): | ||||||||
| self.project_id = project_id | ||||||||
| self.storage_path = str(storage_path) | ||||||||
|
|
||||||||
| def url_for(self, file_path: Path, is_pending: bool = False) -> SimpleStorageName: | ||||||||
| def url_for( | ||||||||
| self, file_path: Path, root_prefix: str | None = None | ||||||||
| ) -> SimpleStorageName: | ||||||||
| """Resolve a project-relative path into a fully qualified storage name. | ||||||||
|
|
||||||||
| The single place a key is built — callers never join storage_path themselves. | ||||||||
|
|
||||||||
| Args: | ||||||||
| file_path: Path relative to the project's storage root. | ||||||||
| is_pending: Place the key under ``PENDING_PREFIX`` — the holding area for | ||||||||
| uploads that have not been registered yet. An S3 lifecycle rule deletes | ||||||||
| everything there after ``PENDING_TTL_DAYS`` (1 day), so pass True only | ||||||||
| for objects that are meant to disappear if registration never happens. | ||||||||
| Registered documents use the default (False) and are never expired. | ||||||||
| root_prefix: Literal prefix placed *ahead of* storage_path, so a single | ||||||||
| S3 lifecycle rule matches it across every project. ``PENDING_PREFIX`` | ||||||||
| is the holding area for unregistered uploads, deleted after | ||||||||
| ``PENDING_TTL_DAYS``; ``LLM_AUDIO_PREFIX`` holds LLM call audio, | ||||||||
| deleted after the llm_call retention window. Leave as None for | ||||||||
| objects that are never expired. | ||||||||
| """ | ||||||||
| if file_path.is_absolute(): | ||||||||
| raise ValueError("file_path must be relative to the project's storage root") | ||||||||
| roots = ( | ||||||||
| (PENDING_PREFIX, self.storage_path) if is_pending else (self.storage_path,) | ||||||||
| (root_prefix, self.storage_path) if root_prefix else (self.storage_path,) | ||||||||
| ) | ||||||||
| return SimpleStorageName(Path(*roots, file_path).as_posix()) | ||||||||
|
|
||||||||
| @abstractmethod | ||||||||
| def put(self, source: UploadFile, filepath: Path) -> SimpleStorageName: | ||||||||
| def put( | ||||||||
| self, source: UploadFile, filepath: Path, root_prefix: str | None = None | ||||||||
| ) -> SimpleStorageName: | ||||||||
| """Upload a file to storage""" | ||||||||
| pass | ||||||||
|
|
||||||||
|
|
@@ -269,8 +279,10 @@ def __init__(self, project_id: int, storage_path: UUID): | |||||||
| super().__init__(project_id, storage_path) | ||||||||
| self.aws = AmazonCloudStorageClient() | ||||||||
|
|
||||||||
| def put(self, source: UploadFile, file_path: Path) -> SimpleStorageName: | ||||||||
| destination = self.url_for(file_path) | ||||||||
| def put( | ||||||||
| self, source: UploadFile, file_path: Path, root_prefix: str | None = None | ||||||||
| ) -> SimpleStorageName: | ||||||||
| destination = self.url_for(file_path, root_prefix=root_prefix) | ||||||||
| kwargs = asdict(destination) | ||||||||
|
|
||||||||
| try: | ||||||||
|
|
@@ -424,7 +436,7 @@ def create_upload_ticket( | |||||||
| """ | ||||||||
| expires_in = min(expires_in, self.MAX_SIGNED_URL_EXPIRY) | ||||||||
|
|
||||||||
| name = self.url_for(file_path, is_pending=True) | ||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. you have removed |
||||||||
| name = self.url_for(file_path, root_prefix=PENDING_PREFIX) | ||||||||
| encoded = quote(filename) | ||||||||
| meta_field = f"x-amz-meta-{FILENAME_METADATA_KEY}" | ||||||||
| try: | ||||||||
|
|
||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,7 @@ | |
| import mimetypes | ||
| from collections.abc import Mapping, Sequence | ||
| from datetime import datetime | ||
| from enum import StrEnum | ||
| from io import BytesIO | ||
| from pathlib import Path | ||
| from typing import Literal | ||
|
|
@@ -18,7 +19,7 @@ | |
|
|
||
| from starlette.datastructures import Headers, UploadFile | ||
|
|
||
| from app.core.cloud.storage import CloudStorage, CloudStorageError | ||
| from app.core.cloud.storage import LLM_AUDIO_PREFIX, CloudStorage, CloudStorageError | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
@@ -48,6 +49,7 @@ def upload_to_object_store( | |
| filename: str, | ||
| subdirectory: str, | ||
| content_type: str = "application/octet-stream", | ||
| root_prefix: str | None = None, | ||
| ) -> str | None: | ||
| """ | ||
| Upload content to object store. | ||
|
|
@@ -60,6 +62,8 @@ def upload_to_object_store( | |
| filename: Name of the file | ||
| subdirectory: Subdirectory path in object store (e.g., "datasets", "stt_datasets") | ||
| content_type: MIME type of the content (default: "application/octet-stream") | ||
| root_prefix: Literal prefix ahead of the project's storage path, see | ||
| ``CloudStorage.url_for`` | ||
|
|
||
| Returns: | ||
| Object store URL as string if successful, None if failed | ||
|
|
@@ -84,7 +88,9 @@ def upload_to_object_store( | |
| headers=headers, | ||
| ) | ||
|
Comment on lines
55
to
89
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this comment keeps getting bigger and bigger slowly, let's keep it sorted or simple so the code stays clean and we don't need unwanted info.. |
||
|
|
||
| destination = storage.put(source=upload_file, file_path=file_path) | ||
| destination = storage.put( | ||
| source=upload_file, file_path=file_path, root_prefix=root_prefix | ||
| ) | ||
| object_store_url = str(destination) | ||
|
|
||
| logger.info( | ||
|
|
@@ -223,29 +229,43 @@ def load_json_from_object_store(storage: CloudStorage, url: str) -> list | dict | |
| } | ||
|
|
||
|
|
||
| class LlmAudioKindEnum(StrEnum): | ||
| STT = "stt" | ||
| TTS = "tts" | ||
|
|
||
|
Comment on lines
+232
to
+235
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it correct place to add it? I think it should be in its corresponding models folder file. |
||
|
|
||
| def upload_audio_bytes_to_s3( | ||
| storage: CloudStorage, | ||
| audio_bytes: bytes, | ||
| call_id: UUID, | ||
| mime_type: str | None, | ||
| prefix: str, | ||
| kind: LlmAudioKindEnum, | ||
| ) -> str | None: | ||
| """Upload decoded audio bytes to S3 and return the s3:// URI. | ||
| """Upload LLM call audio to S3 and return the s3:// URI. | ||
|
|
||
| The key is ``llm-audio/<storage_path>/<kind>/<call_id>.<ext>``: the literal | ||
| ``LLM_AUDIO_PREFIX`` lets one lifecycle rule expire it, and tenant ids stay out | ||
| of the key, which reaches clients inside presigned URLs. | ||
|
Comment on lines
+245
to
+248
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do we need this info, if not then we don't need to add it here, a bit from a cleanup perspective. |
||
|
|
||
| Args: | ||
| storage: CloudStorage instance | ||
| audio_bytes: Raw audio bytes | ||
| call_id: LLM call UUID used as the filename stem | ||
| mime_type: MIME type of the audio (determines file extension) | ||
| prefix: S3 subdirectory, e.g. "llm/tts/audio" or "llm/stt/audio" | ||
| kind: Whether this is STT input or TTS output | ||
|
|
||
| Returns: | ||
| s3:// URI if successful, None on failure | ||
| """ | ||
| ext = _MIME_TO_EXT.get(mime_type or "", "wav") | ||
| filename = f"{call_id}.{ext}" | ||
| return upload_to_object_store( | ||
| storage, audio_bytes, filename, prefix, mime_type or "audio/wav" | ||
| storage, | ||
| audio_bytes, | ||
| filename, | ||
| kind.value, | ||
| mime_type or "audio/wav", | ||
| root_prefix=LLM_AUDIO_PREFIX, | ||
| ) | ||
|
|
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,9 +1,11 @@ | ||||||
| import logging | ||||||
| import base64 | ||||||
| import json | ||||||
| from datetime import datetime | ||||||
| from uuid import UUID | ||||||
| from typing import Any, Literal | ||||||
|
|
||||||
| from sqlalchemy import text | ||||||
| from sqlmodel import Session, select | ||||||
|
|
||||||
| from app.core.util import now | ||||||
|
|
@@ -345,6 +347,39 @@ def get_llm_call_by_id( | |||||
| return session.exec(statement).first() | ||||||
|
|
||||||
|
|
||||||
| REDACTED_SENTINEL = "[redacted]" | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this too.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
|
|
||||||
| REDACT_LLM_CALLS_SQL = text( | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| """ | ||||||
| UPDATE llm_call | ||||||
| SET input = :redacted_sentinel, | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| content = CASE | ||||||
| WHEN jsonb_typeof(content) = 'object' | ||||||
| THEN jsonb_set(content, '{content,value}', to_jsonb(CAST(:redacted_sentinel AS text)), false) | ||||||
| ELSE content | ||||||
| END | ||||||
| WHERE updated_at <= :cutoff | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| AND ( | ||||||
| input <> :redacted_sentinel | ||||||
| OR ( | ||||||
| jsonb_typeof(content) = 'object' | ||||||
| AND content #>> '{content,value}' <> :redacted_sentinel | ||||||
| ) | ||||||
| ) | ||||||
| """ | ||||||
| ) | ||||||
|
Comment on lines
+352
to
+370
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we should keep this inside the model because there's a query that's related to the database so its place should be inside models instead of here. |
||||||
|
|
||||||
|
|
||||||
| def redact_llm_calls(*, session: Session, cutoff: datetime) -> int: | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| result = session.connection().execute( | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| REDACT_LLM_CALLS_SQL, | ||||||
| {"cutoff": cutoff, "redacted_sentinel": REDACTED_SENTINEL}, | ||||||
| ) | ||||||
| session.commit() | ||||||
|
|
||||||
| return result.rowcount | ||||||
|
|
||||||
|
|
||||||
| def get_llm_calls_by_job_id( | ||||||
| session: Session, job_id: UUID, project_id: int | ||||||
| ) -> list[LlmCall]: | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
instead of hardcoding the value here, can we pick it from
core/configlike in other cron jobs, so that we don't need to update it from the code here in the future?