diff --git a/backend/.env.example b/backend/.env.example index 317e8d5..d5587d0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -53,6 +53,11 @@ SLACK_CLIENT_ID= SLACK_CLIENT_SECRET= SLACK_SIGNING_SECRET= +# --- Jira connector ------------------------------------------------------- +# https://auth.atlassian.com → get the API keys +ATLASSIAN_API= +ATLASSIAN_AUTH= + # Where the OAuth redirect and the Events API URL point. In dev these are the # ngrok/Cloudflare tunnel hostname, NOT localhost — Slack has to reach it. # BACKEND_URL must match the Redirect URL registered in the Slack app exactly. diff --git a/backend/docs/jira-integration.md b/backend/docs/jira-integration.md new file mode 100644 index 0000000..008a2c7 --- /dev/null +++ b/backend/docs/jira-integration.md @@ -0,0 +1,247 @@ +# Jira integration + +How a Jira issue becomes a memory + +## 1. The flow + +```text +Jira Cloud + │ + │ User clicks Connect + ▼ +GET /api/jira/install + │ + ▼ +Atlassian OAuth 2.0 + │ + ▼ +GET /api/jira/callback + │ + ├─ verify state + ├─ exchange code for tokens + ├─ get Jira Cloud ID + └─ store installation + │ + ▼ +POST /api/jira/sync + │ + ├─ run JQL + ├─ fetch issues + ├─ record in `source_events` + │ └─ already seen → skip + │ + └─ background: normalize → classify → persist + │ + memories + provenance + embeddings + │ + update `source_events` +```` + +Two tables are involved: + +| table | purpose | +| -------------------- | -------------------------------------------- | +| `jira_installations` | Stores Jira connection and OAuth information | +| `source_events` | Stores raw Jira issues and tracks ingestion | + +`source_events` is shared with Slack so every connector uses the same ingestion +and audit system. + +--- + +## 2. What you do on Atlassian + +### a. Create the app + +Go to: + +[https://developer.atlassian.com/console/myapps/](https://developer.atlassian.com/console/myapps/) + +Create an app using **OAuth 2.0 (3LO)**. + +### b. Add credentials + +Add these to `backend/.env`: + +```bash +JIRA_CLIENT_ID=... +JIRA_CLIENT_SECRET=... +``` + +Keep the client secret on the backend. + +### c. Configure redirect URL + +If using ngrok: + +```bash +ngrok http 8000 +``` + +Then set: + +```bash +BACKEND_URL=https://your-ngrok-url.ngrok-free.app +FRONTEND_URL=http://localhost:5173 +``` + +In the Atlassian app, add: + +```text +https://your-ngrok-url.ngrok-free.app/api/jira/callback +``` + +This must exactly match `BACKEND_URL + /api/jira/callback`. + +### d. Add scopes + +For read-only Jira ingestion: + +```text +read:jira-work +read:jira-user +offline_access +``` + +We don't need Jira write permissions. + +--- + +## 3. Connect Jira + +From the application: + +```text +Sources → Jira → Connect +``` + +This starts the OAuth flow. + +After approval: + +```text +Atlassian + ↓ +/api/jira/callback + ↓ +jira_installations + ↓ +Jira connected +``` + +--- + +## 4. Sync issues + +The connector uses JQL to fetch issues. + +Example: + +```json +{ + "jql": "project = ENG ORDER BY updated DESC", + "max_results": 100 +} +``` + +The backend fetches: + +```text +Issue key +Summary +Description +Project +Issue type +Status +Priority +Labels +Components +Reporter +Assignee +Created +Updated +Jira URL +``` + +Then: + +```text +Jira issue + ↓ +source_events + ↓ +normalize() + ↓ +AI classification + ↓ +memory + embedding +``` + +--- + +## 5. Issue updates + +Issues can change, so the external ID should include the update timestamp: + +```text +jira:{cloud_id}:{issue_key}:{updated_timestamp} +``` + +Example: + +```text +jira:abc123:ENG-123:2026-08-12T14:30:00 +``` + +This means: + +```text +Same issue + same update → skip + +Same issue + new update → ingest again +``` + +--- + +## 6. API endpoints + +| endpoint | purpose | +| ----------------------------- | ----------------------- | +| `GET /api/jira/install` | Start OAuth | +| `GET /api/jira/callback` | Handle OAuth callback | +| `GET /api/jira/status` | Check connection | +| `DELETE /api/jira/disconnect` | Disconnect Jira | +| `POST /api/jira/sync` | Fetch and ingest issues | +| `GET /api/jira/activity` | View ingestion activity | + +--- + +## 7. Configuration + +```bash +JIRA_CLIENT_ID=... +JIRA_CLIENT_SECRET=... + +BACKEND_URL=http://localhost:8000 +FRONTEND_URL=http://localhost:5173 +``` + +--- + +# End improvements + +These are **not required for the MVP**, but can be added later: + +1. **Pagination** — fetch more than 100 issues safely. +2. **Jira webhooks** — automatically ingest new/updated issues instead of polling. +3. **Token encryption** — encrypt OAuth tokens in production. +4. **Token refresh** — automatically refresh expired access tokens. +5. **Comments** — add Jira comments to the knowledge base. +6. **Attachments** — extract and index PDFs, documents, etc. +7. **Linked issues** — include relationships between Jira issues. +8. **Changelog** — track important issue history. +9. **ACL support** — preserve Jira permissions in the knowledge base. +10. **Background queue** — replace in-process `BackgroundTasks` with a real worker. +11. **Version cleanup** — remove/supersede old embeddings when an issue changes. +12. **Semantic deduplication** — avoid storing duplicate knowledge from similar issues. +13. **Multi-workspace support** — replace the current user-based workspace model. \ No newline at end of file diff --git a/backend/pyproject.toml b/backend/pyproject.toml index e52ab89..41632f7 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "python-dotenv>=1.2.2", "slack-bolt>=1.30.0", "uvicorn[standard]>=0.52.1", + "tavily-python>=0.7.27", ] [project.optional-dependencies] diff --git a/backend/src/ai/agents/ota.py b/backend/src/ai/agents/ota.py new file mode 100644 index 0000000..29a4b99 --- /dev/null +++ b/backend/src/ai/agents/ota.py @@ -0,0 +1,105 @@ +from ai.providers.llm import ChatMessage, ChatProvider, ProviderError, get_provider + + +class OtaAgent: + """ + Base Observe -> Think -> Act agent. + + Subclasses can customize the role prompts and add domain-specific + tools or behavior without changing the OTA interface. + """ + + _BASE_PROMPT = """ + You are part of an Observe-Think-Act system. + Follow your assigned role, use only the provided information, and do not invent facts. + """.strip() + + _OBSERVER_PROMPT = """ + Observe the current input or state. + Extract relevant facts, changes, constraints, and uncertainties. + Do not make decisions or take actions. + """.strip() + + _THINKER_PROMPT = """ + Analyze the observation and determine the next best action. + Use the available context and tools. + Return a concise decision and the information needed to act. + """.strip() + + _ACTOR_PROMPT = """ + Execute the selected action using the available tools or capabilities. + Do not claim an action succeeded unless it actually did. + Return the result or clearly report why the action could not be completed. + """.strip() + + def __init__(self, provider: str): + self.llm = get_provider(provider) + + def _build_prompt( + self, + role_prompt: str, + system_prompt: str = "", + ) -> str: + parts = [ + self._BASE_PROMPT, + role_prompt, + system_prompt.strip(), + ] + + return "\n\n".join( + part for part in parts if part + ) + + async def _chat( + self, + message: str, + system_prompt: str, + ) -> str: + try: + response = await self.llm.chat( + messages=[ChatMessage("user", message)], + system=system_prompt, + ) + return response.content + + except ProviderError: + raise + + async def observe( + self, + message: str, + system_prompt: str = "", + ) -> str: + return await self._chat( + message, + self._build_prompt( + self._OBSERVER_PROMPT, + system_prompt, + ), + ) + + async def think( + self, + observation: str, + system_prompt: str = "", + ) -> str: + return await self._chat( + observation, + self._build_prompt( + self._THINKER_PROMPT, + system_prompt, + ), + ) + + async def act( + self, + action: str, + system_prompt: str = "", + ) -> str: + return await self._chat( + action, + self._build_prompt( + self._ACTOR_PROMPT, + system_prompt, + ), + ) \ No newline at end of file diff --git a/backend/src/ai/memory/tools.py b/backend/src/ai/memory/tools.py new file mode 100644 index 0000000..f77890d --- /dev/null +++ b/backend/src/ai/memory/tools.py @@ -0,0 +1,33 @@ +from services.retrieval_service import search_embeddings + + +async def get_company_memory( + query: str, + limit: int = 5, +): + """ + Retrieve company information relevant to the given query. + + Use this tool when you need previously stored information about + the company, including projects, technical decisions, architecture, + discussions, integrations, or other engineering knowledge. + """ + results = await search_embeddings( + query=query, + limit=limit, + ) + + return [ + { + "id": row["id"], + "title": row["title"], + "body": row["body"], + "type": row["type"], + "scope": row["scope"], + "status": row["status"], + "score": row["score"], + } + for row in results + ] + + \ No newline at end of file diff --git a/backend/src/ai/tools/__initt__.py b/backend/src/ai/tools/__initt__.py new file mode 100644 index 0000000..47d8c97 --- /dev/null +++ b/backend/src/ai/tools/__initt__.py @@ -0,0 +1,10 @@ +from ai.memory.tools import get_company_memory +from ai.tools.calculator import calculator +from ai.tools.browser import web_search + + +__all__ = [ + "get_company_memory", + "calculator", + "web_search", +] \ No newline at end of file diff --git a/backend/src/ai/tools/browser.py b/backend/src/ai/tools/browser.py new file mode 100644 index 0000000..0d3b3f1 --- /dev/null +++ b/backend/src/ai/tools/browser.py @@ -0,0 +1,50 @@ +import os + +from tavily import AsyncTavilyClient + + +def _get_client() -> AsyncTavilyClient: + api_key = os.getenv("TAVILY_API_KEY") + + if not api_key: + raise RuntimeError( + "TAVILY_API_KEY is not configured." + ) + + return AsyncTavilyClient(api_key=api_key) + + +async def web_search( + query: str, + limit: int = 5, +): + """ + Search the web for current or external information. + + Use this when company memory does not contain the required + information or when current information is required. + """ + + if limit < 1: + limit = 1 + + if limit > 10: + limit = 10 + + client = _get_client() + + response = await client.search( + query=query, + max_results=limit, + search_depth="basic", + ) + + return [ + { + "title": result.get("title"), + "url": result.get("url"), + "content": result.get("content"), + "score": result.get("score"), + } + for result in response.get("results", []) + ] \ No newline at end of file diff --git a/backend/src/ai/tools/calculator.py b/backend/src/ai/tools/calculator.py new file mode 100644 index 0000000..fdad73b --- /dev/null +++ b/backend/src/ai/tools/calculator.py @@ -0,0 +1,56 @@ +import ast +import math +import operator + + +_OPERATORS = { + ast.Add: operator.add, + ast.Sub: operator.sub, + ast.Mult: operator.mul, + ast.Div: operator.truediv, + ast.FloorDiv: operator.floordiv, + ast.Mod: operator.mod, + ast.Pow: operator.pow, + ast.USub: operator.neg, + ast.UAdd: operator.pos, +} + + +def _evaluate(node): + if isinstance(node, ast.Constant): + if isinstance(node.value, (int, float)): + return node.value + raise ValueError("Only numbers are allowed.") + + if isinstance(node, ast.BinOp): + op = _OPERATORS.get(type(node.op)) + if op is None: + raise ValueError("Unsupported operator.") + return op(_evaluate(node.left), _evaluate(node.right)) + + if isinstance(node, ast.UnaryOp): + op = _OPERATORS.get(type(node.op)) + if op is None: + raise ValueError("Unsupported operator.") + return op(_evaluate(node.operand)) + + raise ValueError("Invalid expression.") + + +def calculator(expression: str): + """ + Evaluate a mathematical expression. + + Use this for arithmetic and numerical calculations + instead of reasoning about the result manually. + """ + tree = ast.parse(expression, mode="eval") + result = _evaluate(tree.body) + + if not math.isfinite(float(result)): + raise ValueError("Result is not finite.") + + return { + "expression": expression, + "result": result, + } \ No newline at end of file diff --git a/backend/src/app/api/jira.py b/backend/src/app/api/jira.py new file mode 100644 index 0000000..6ae8c5a --- /dev/null +++ b/backend/src/app/api/jira.py @@ -0,0 +1,694 @@ +"""The Jira connector. + +Surfaces: + + GET /api/jira/install browser, cookie-authenticated -> authorize URL + GET /api/jira/callback browser, state-authenticated -> stores token + GET /api/jira/status browser, cookie-authenticated -> connection status + DELETE /api/jira/disconnect browser, cookie-authenticated -> removes token + POST /api/jira/sync browser, cookie-authenticated -> pulls Jira issues + GET /api/jira/activity browser, cookie-authenticated -> ingestion audit feed + +Jira is initially pull-based. The sync endpoint fetches issues using JQL, +records them as source events, and processes them through the existing +normalization/ingestion pipeline. + +Later, Jira webhooks can be added without changing the rest of the pipeline. +""" + +import asyncio +import logging +import os +from datetime import UTC, datetime, timedelta +from urllib.parse import urlencode + +import httpx +import jwt +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from fastapi.responses import RedirectResponse +from pydantic import BaseModel + +from app.api.auth import UserOut, current_user +from ai.ingest import ClassificationError, ingest, normalize +from ai.ingest.events.events import JiraEventIn, JiraPayload +from ai.memory import get_store +from ai.providers.llm import ProviderError, get_provider +from db import jira as jira_db +from services import jira_client + +log = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/jira", tags=["jira"]) + +STATE_TTL = timedelta(minutes=10) + + +# --- Config ---------------------------------------------------------------- + +def _frontend_url() -> str: + return os.getenv("FRONTEND_URL", "http://localhost:5173").rstrip("/") + + +def _backend_url() -> str: + return os.getenv("BACKEND_URL", "http://localhost:8000").rstrip("/") + + +def _redirect_uri() -> str: + """ + Must exactly match the callback URL registered in the Atlassian app. + """ + return f"{_backend_url()}/api/jira/callback" + + +def _jwt_secret() -> str: + return os.getenv("JWT_SECRET", "") + + +def workspace_id_for(user: UserOut) -> str: + """ + Same tenancy model as Slack for now. + + When real teams/workspaces are introduced, this is the function + that should change. + """ + return user.id + + +# --- Response models ------------------------------------------------------- + +class InstallUrlOut(BaseModel): + url: str + + +class JiraStatusOut(BaseModel): + configured: bool + connected: bool + site_name: str | None = None + site_url: str | None = None + cloud_id: str | None = None + installed_at: datetime | None = None + + received: int = 0 + stored: int = 0 + quarantined: int = 0 + dropped: int = 0 + errors: int = 0 + + last_event_at: datetime | None = None + + +class JiraSyncIn(BaseModel): + """ + Controls what Jira issues should be pulled. + + Example: + project = ENG ORDER BY updated DESC + """ + + jql: str = "ORDER BY updated DESC" + max_results: int = 100 + + +class JiraSyncOut(BaseModel): + fetched: int + recorded: int + skipped: int + + +class SourceEventOut(BaseModel): + id: str + author: str | None + text: str | None + outcome: str + reason: str | None + occurred_at: datetime | None + memory_id: str | None + memory_title: str | None + memory_type: str | None + + +# --- Install flow ---------------------------------------------------------- + +@router.get("/install", response_model=InstallUrlOut) +async def install_url( + user: UserOut = Depends(current_user), +) -> InstallUrlOut: + """ + Build the Atlassian OAuth authorization URL. + + The frontend sends the user here to connect their Jira account/site. + """ + + if not jira_client.is_configured(): + raise HTTPException( + 503, + "Jira is not configured: set JIRA_CLIENT_ID and " + "JIRA_CLIENT_SECRET in backend/.env", + ) + + now = datetime.now(UTC) + + state = jwt.encode( + { + "uid": user.id, + "purpose": "jira_install", + "iat": now, + "exp": now + STATE_TTL, + }, + _jwt_secret(), + algorithm="HS256", + ) + + # These scopes should match the scopes configured in the Atlassian app. + scopes = [ + "read:jira-work", + "read:jira-user", + "offline_access", + ] + + query = urlencode( + { + "audience": "api.atlassian.com", + "client_id": jira_client.client_id(), + "scope": " ".join(scopes), + "redirect_uri": _redirect_uri(), + "state": state, + "response_type": "code", + "prompt": "consent", + } + ) + + return InstallUrlOut( + url=f"https://auth.atlassian.com/authorize?{query}" + ) + + +@router.get("/callback") +async def install_callback( + code: str | None = None, + state: str | None = None, + error: str | None = None, +) -> RedirectResponse: + """ + Atlassian redirects here after the user approves or rejects the install. + """ + + if error or not code or not state: + return RedirectResponse( + f"{_frontend_url()}/sources" + f"?jira=error&reason={error or 'cancelled'}" + ) + + try: + claims = jwt.decode( + state, + _jwt_secret(), + algorithms=["HS256"], + ) + except jwt.PyJWTError: + return RedirectResponse( + f"{_frontend_url()}/sources?jira=error&reason=bad_state" + ) + + if claims.get("purpose") != "jira_install": + return RedirectResponse( + f"{_frontend_url()}/sources?jira=error&reason=bad_state" + ) + + try: + installation = await jira_client.exchange_code( + code, + _redirect_uri(), + ) + + except (jira_client.JiraError, httpx.HTTPError) as exc: + log.warning("jira oauth exchange failed: %s", exc) + + return RedirectResponse( + f"{_frontend_url()}/sources" + "?jira=error&reason=exchange" + ) + + await asyncio.to_thread( + jira_db.upsert_installation, + cloud_id=installation.cloud_id, + site_url=installation.site_url, + site_name=installation.site_name, + workspace_id=claims["uid"], + access_token=installation.access_token, + refresh_token=installation.refresh_token, + installed_by=claims["uid"], + ) + + return RedirectResponse( + f"{_frontend_url()}/sources?jira=connected" + ) + + +# --- Status ---------------------------------------------------------------- + +@router.get("/status", response_model=JiraStatusOut) +async def status( + user: UserOut = Depends(current_user), +) -> JiraStatusOut: + """ + Return Jira connection and ingestion status. + """ + + workspace_id = workspace_id_for(user) + + installation = await asyncio.to_thread( + jira_db.get_installation_by_workspace, + workspace_id, + ) + + stats = await asyncio.to_thread( + jira_db.source_event_stats, + workspace_id, + "jira", + ) + + return JiraStatusOut( + configured=jira_client.is_configured(), + connected=installation is not None, + site_name=( + installation["site_name"] + if installation + else None + ), + site_url=( + installation["site_url"] + if installation + else None + ), + cloud_id=( + installation["cloud_id"] + if installation + else None + ), + installed_at=( + installation["installed_at"] + if installation + else None + ), + received=stats["received"] or 0, + stored=stats["stored"] or 0, + quarantined=stats["quarantined"] or 0, + dropped=stats["dropped"] or 0, + errors=stats.get("errors", 0) or 0, + last_event_at=stats["last_event_at"], + ) + + +# --- Disconnect ------------------------------------------------------------ + +@router.delete("/disconnect") +async def disconnect( + user: UserOut = Depends(current_user), +) -> dict[str, bool]: + """ + Remove the Jira OAuth credentials. + + Existing memories are intentionally retained. + """ + + removed = await asyncio.to_thread( + jira_db.delete_installation, + workspace_id_for(user), + ) + + return {"ok": removed} + + +# --- Activity -------------------------------------------------------------- + +@router.get( + "/activity", + response_model=list[SourceEventOut], +) +async def activity( + limit: int = 25, + user: UserOut = Depends(current_user), +) -> list[SourceEventOut]: + """ + Show recent Jira ingestion activity. + + This makes failed/skipped/processed Jira tickets debuggable. + """ + + rows = await asyncio.to_thread( + jira_db.recent_source_events, + workspace_id_for(user), + min(limit, 100), + ) + + return [ + SourceEventOut( + id=str(row["id"]), + author=row["author"], + text=row["text"], + outcome=row["outcome"], + reason=row["reason"], + occurred_at=row["occurred_at"], + memory_id=( + str(row["memory_id"]) + if row["memory_id"] + else None + ), + memory_title=row["memory_title"], + memory_type=row["memory_type"], + ) + for row in rows + ] + + +# --- Sync ------------------------------------------------------------------ + +@router.post("/sync", response_model=JiraSyncOut) +async def sync( + payload: JiraSyncIn, + tasks: BackgroundTasks, + user: UserOut = Depends(current_user), +) -> JiraSyncOut: + """ + Pull Jira issues and queue them for ingestion. + + Example JQL: + + project = ENG ORDER BY updated DESC + + The issues are recorded first so repeated syncs can be made idempotent. + """ + + workspace_id = workspace_id_for(user) + + installation = await asyncio.to_thread( + jira_db.get_installation_by_workspace, + workspace_id, + ) + + if installation is None: + raise HTTPException( + 400, + "Jira is not connected.", + ) + + max_results = min(max(payload.max_results, 1), 100) + + try: + result = await jira_client.list_issues( + token=installation["access_token"], + cloud_id=installation["cloud_id"], + jql=payload.jql, + start_at=0, + max_results=max_results, + ) + except (jira_client.JiraError, httpx.HTTPError) as exc: + log.warning( + "jira sync failed for workspace %s: %s", + workspace_id, + exc, + ) + raise HTTPException( + 502, + "Could not fetch issues from Jira.", + ) from exc + + issues = result.get("issues", []) + + recorded = 0 + skipped = 0 + + for issue in issues: + issue_key = issue.get("key") + + if not issue_key: + skipped += 1 + continue + + fields = issue.get("fields") or {} + + external_id = ( + f"jira:{installation['cloud_id']}:" + f"{issue_key}:{fields.get('updated', '')}" + ) + + occurred_at = _from_jira_timestamp( + fields.get("updated") or fields.get("created") + ) + + row_id = await asyncio.to_thread( + jira_db.record_source_event, + workspace_id=workspace_id, + source="jira", + external_id=external_id, + author=_author_name(fields), + text=_issue_text(issue), + occurred_at=occurred_at, + payload=issue, + ) + + if row_id is None: + skipped += 1 + continue + + recorded += 1 + + tasks.add_task( + process_issue, + row_id, + dict(installation), + issue, + ) + + return JiraSyncOut( + fetched=len(issues), + recorded=recorded, + skipped=skipped, + ) + + +# --- Issue processing ----------------------------------------------------------- + +async def process_issue( + row_id: str, + installation: dict, + issue: dict, +) -> None: + """ + Convert one Jira issue into the existing ingestion pipeline. + + This is intentionally separate from the Jira client: + Jira API -> JiraPayload -> normalize() -> ingest() + """ + + try: + fields = issue.get("fields") or {} + + issue_key = issue.get("key", "") + summary = fields.get("summary") or "" + description = _description_text( + fields.get("description") + ) + + project = fields.get("project") or {} + issue_type = fields.get("issuetype") or {} + status = fields.get("status") or {} + priority = fields.get("priority") or {} + + payload = JiraPayload( + issue_id=str(issue.get("id", "")), + issue_key=issue_key, + summary=summary, + description=description, + project_id=project.get("id"), + project_key=project.get("key"), + project_name=project.get("name"), + issue_type=issue_type.get("name"), + status=status.get("name"), + priority=priority.get("name"), + labels=fields.get("labels") or [], + components=[ + component.get("name") + for component in fields.get("components") or [] + if component.get("name") + ], + reporter=_user_display_name( + fields.get("reporter") + ), + assignee=_user_display_name( + fields.get("assignee") + ), + created_at=fields.get("created"), + updated_at=fields.get("updated"), + url=jira_client.issue_url( + installation["site_url"], + issue_key, + ), + ) + + request = JiraEventIn( + source="jira", + workspace_id=installation["workspace_id"], + payload=payload, + ) + + result = await ingest( + normalize(request), + workspace_id=installation["workspace_id"], + provider=get_provider(), + store=get_store(), + ) + + await asyncio.to_thread( + jira_db.finish_source_event, + row_id, + outcome=result.outcome, + reason=result.reason, + memory_id=( + result.memory.id + if result.memory + else None + ), + ) + + except (ClassificationError, ProviderError) as exc: + log.warning( + "jira ingest failed for %s: %s", + row_id, + exc, + ) + + await asyncio.to_thread( + jira_db.finish_source_event, + row_id, + outcome="error", + reason=str(exc), + ) + + except Exception as exc: + log.exception( + "jira ingest crashed for %s", + row_id, + ) + + await asyncio.to_thread( + jira_db.finish_source_event, + row_id, + outcome="error", + reason=str(exc), + ) + + +# --- Jira helpers ------------------------------------------------------- + +def _from_jira_timestamp( + value: str | None, +) -> datetime: + """ + Convert Jira's ISO timestamp into a timezone-aware datetime. + """ + + if not value: + return datetime.now(UTC) + + try: + return datetime.fromisoformat( + value.replace("Z", "+00:00") + ) + except ValueError: + return datetime.now(UTC) + + +def _user_display_name( + user: dict | None, +) -> str | None: + """ + Extract a human-readable Jira user name. + """ + + if not user: + return None + + return ( + user.get("displayName") + or user.get("emailAddress") + or user.get("accountId") + ) + + +def _author_name( + fields: dict, +) -> str | None: + """ + Get the issue reporter for the source-event audit record. + """ + + return _user_display_name( + fields.get("reporter") + ) + + +def _issue_text( + issue: dict, +) -> str: + """ + Create the short source-event text shown in the activity feed. + """ + + fields = issue.get("fields") or {} + + summary = fields.get("summary") or "" + description = _description_text( + fields.get("description") + ) + + if description: + return f"{summary}\n\n{description}" + + return summary + + +def _description_text( + description: object, +) -> str: + """ + Convert Jira's description representation into plain text. + + Jira Cloud commonly returns Atlassian Document Format (ADF), so this + should eventually be replaced by a proper ADF -> text converter. + """ + + if not description: + return "" + + if isinstance(description, str): + return description + + if not isinstance(description, dict): + return str(description) + + parts: list[str] = [] + + def walk(node: object) -> None: + if not isinstance(node, dict): + return + + text = node.get("text") + if isinstance(text, str): + parts.append(text) + + for child in node.get("content") or []: + walk(child) + + if node.get("type") in { + "paragraph", + "heading", + "blockquote", + "listItem", + }: + parts.append("\n") + + walk(description) + + return "".join(parts).strip() \ No newline at end of file diff --git a/backend/src/app/main.py b/backend/src/app/main.py index be41e05..ab62f66 100644 --- a/backend/src/app/main.py +++ b/backend/src/app/main.py @@ -7,6 +7,7 @@ from app.api.auth import router as auth_router from app.api.chat import router as chat_router from app.api.events import router as events_router +from app.api.jira import router as jira_router from app.api.retrieval import router as retrieval_router from app.api.slack import router as slack_router from app.api.workspaces import router as workspaces_router @@ -33,6 +34,7 @@ app.include_router(auth_router) app.include_router(chat_router) app.include_router(events_router) +app.include_router(jira_router) app.include_router(retrieval_router) app.include_router(slack_router) app.include_router(workspaces_router) diff --git a/backend/src/db/jira.py b/backend/src/db/jira.py new file mode 100644 index 0000000..70524aa --- /dev/null +++ b/backend/src/db/jira.py @@ -0,0 +1,317 @@ +"""Queries for the Jira connector: installations and raw inbound events. + +Jira-specific installation data lives here. + +Raw Jira issues reuse the shared `source_events` table, exactly like Slack. +That keeps the memory/knowledge pipeline source-agnostic. + +All functions here are blocking (the pool is sync). Callers on the event loop +must wrap them in asyncio.to_thread(). +""" + +import json +from typing import Any + +from db.session import get_conn + + +# --------------------------------------------------------------------------- +# Installations +# --------------------------------------------------------------------------- + +def upsert_installation( + *, + cloud_id: str, + site_url: str | None, + site_name: str | None, + workspace_id: str, + access_token: str, + refresh_token: str | None, + installed_by: str | None, +) -> dict: + """Record or refresh a Jira installation. + + Conflict is on cloud_id because a Jira Cloud site should have one active + installation record. Reconnecting the same site replaces its tokens. + """ + + with get_conn() as conn, conn.cursor() as cur: + cur.execute( + """ + INSERT INTO jira_installations + ( + cloud_id, + site_url, + site_name, + workspace_id, + access_token, + refresh_token, + installed_by + ) + VALUES (%s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (cloud_id) DO UPDATE + SET site_url = excluded.site_url, + site_name = excluded.site_name, + workspace_id = excluded.workspace_id, + access_token = excluded.access_token, + refresh_token = excluded.refresh_token, + installed_by = excluded.installed_by, + installed_at = now() + RETURNING + id, + cloud_id, + site_url, + site_name, + workspace_id, + installed_at; + """, + ( + cloud_id, + site_url, + site_name, + workspace_id, + access_token, + refresh_token, + installed_by, + ), + ) + + return cur.fetchone() + + +def get_installation_by_cloud_id( + cloud_id: str, +) -> dict | None: + """Resolve a Jira Cloud site ID to its installation.""" + + with get_conn() as conn, conn.cursor() as cur: + cur.execute( + """ + SELECT + id, + cloud_id, + site_url, + site_name, + workspace_id, + access_token, + refresh_token, + installed_at + FROM jira_installations + WHERE cloud_id = %s; + """, + (cloud_id,), + ) + + return cur.fetchone() + + +def get_installation_by_workspace( + workspace_id: str, +) -> dict | None: + """Return the Jira installation connected to this application workspace.""" + + with get_conn() as conn, conn.cursor() as cur: + cur.execute( + """ + SELECT + id, + cloud_id, + site_url, + site_name, + workspace_id, + access_token, + refresh_token, + installed_at + FROM jira_installations + WHERE workspace_id = %s + ORDER BY installed_at DESC + LIMIT 1; + """, + (workspace_id,), + ) + + return cur.fetchone() + + +def delete_installation( + workspace_id: str, +) -> bool: + """Remove Jira credentials for a workspace. + + Existing source events and memories are intentionally preserved. + """ + + with get_conn() as conn, conn.cursor() as cur: + cur.execute( + """ + DELETE FROM jira_installations + WHERE workspace_id = %s; + """, + (workspace_id,), + ) + + return cur.rowcount > 0 + + +# --------------------------------------------------------------------------- +# Raw source events +# --------------------------------------------------------------------------- + +def record_source_event( + *, + workspace_id: str, + source: str, + external_id: str, + author: str | None, + text: str | None, + occurred_at: Any, + payload: dict, +) -> str | None: + """Claim a Jira issue for processing. + + Uses the shared source_events table. + + external_id makes ingestion idempotent: + jira:{cloud_id}:{issue_key} + + If the same issue has already been recorded, returns None. + """ + + with get_conn() as conn, conn.cursor() as cur: + cur.execute( + """ + INSERT INTO source_events + ( + workspace_id, + source, + external_id, + author, + text, + occurred_at, + payload + ) + VALUES (%s, %s, %s, %s, %s, %s, %s::JSONB) + ON CONFLICT (workspace_id, external_id) DO NOTHING + RETURNING id; + """, + ( + workspace_id, + source, + external_id, + author, + text, + occurred_at, + json.dumps(payload), + ), + ) + + row = cur.fetchone() + + return str(row["id"]) if row else None + + +def finish_source_event( + event_id: str, + *, + outcome: str, + reason: str | None = None, + memory_id: str | None = None, +) -> None: + """Mark a Jira source event as successfully processed or failed.""" + + with get_conn() as conn, conn.cursor() as cur: + cur.execute( + """ + UPDATE source_events + SET + outcome = %s, + reason = %s, + memory_id = %s, + processed_at = now() + WHERE id = %s; + """, + ( + outcome, + reason, + memory_id, + event_id, + ), + ) + + +def recent_source_events( + workspace_id: str, + limit: int = 25, +) -> list[dict]: + """Return recent Jira ingestion activity for the Sources page.""" + + with get_conn() as conn, conn.cursor() as cur: + cur.execute( + """ + SELECT + e.id, + e.source, + e.external_id, + e.author, + e.text, + e.occurred_at, + e.outcome, + e.reason, + e.memory_id, + e.received_at, + m.title AS memory_title, + m.type AS memory_type + FROM source_events e + LEFT JOIN memories m + ON m.id = e.memory_id + WHERE + e.workspace_id = %s + AND e.source = 'jira' + ORDER BY e.received_at DESC + LIMIT %s; + """, + ( + workspace_id, + limit, + ), + ) + + return cur.fetchall() + + +def source_event_stats( + workspace_id: str, + source: str = "jira", +) -> dict: + """Return Jira ingestion counts for the Sources page.""" + + with get_conn() as conn, conn.cursor() as cur: + cur.execute( + """ + SELECT + count(*) AS received, + count(*) FILTER ( + WHERE outcome = 'stored' + ) AS stored, + count(*) FILTER ( + WHERE outcome = 'quarantined' + ) AS quarantined, + count(*) FILTER ( + WHERE outcome LIKE 'dropped%%' + ) AS dropped, + count(*) FILTER ( + WHERE outcome IN ('pending', 'error') + ) AS unfinished, + max(received_at) AS last_event_at + FROM source_events + WHERE + workspace_id = %s + AND source = %s; + """, + ( + workspace_id, + source, + ), + ) + + return cur.fetchone() \ No newline at end of file diff --git a/backend/src/db/migrations/004_add_jira_installations.sql b/backend/src/db/migrations/004_add_jira_installations.sql new file mode 100644 index 0000000..be67a47 --- /dev/null +++ b/backend/src/db/migrations/004_add_jira_installations.sql @@ -0,0 +1,15 @@ +CREATE TABLE jira_installations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + cloud_id TEXT NOT NULL UNIQUE, + site_url TEXT, + site_name TEXT, + + workspace_id TEXT NOT NULL, + + access_token TEXT NOT NULL, + refresh_token TEXT, + + installed_by TEXT, + installed_at TIMESTAMPTZ NOT NULL DEFAULT now() +); \ No newline at end of file diff --git a/backend/src/services/jira_client.py b/backend/src/services/jira_client.py new file mode 100644 index 0000000..90a5d15 --- /dev/null +++ b/backend/src/services/jira_client.py @@ -0,0 +1,275 @@ +"""Thin Jira Cloud REST API client. + +Only the handful of methods the connector needs. The client is responsible +only for Jira authentication and API communication. + +Higher-level ingestion, normalization, chunking, and embeddings should stay +outside this class. + +OAuth flow: + 1. User authorizes the app with Atlassian. + 2. Atlassian redirects back with a one-time code. + 3. exchange_code() exchanges the code for tokens. + 4. get_accessible_resources() tells us which Jira Cloud site(s) the + installation can access. +""" + +import os +from dataclasses import dataclass + +import httpx + + +ATLASSIAN_API = "" +ATLASSIAN_AUTH = "" + +TIMEOUT = httpx.Timeout(10.0) + + +class JiraError(RuntimeError): + """Jira/Atlassian rejected the call.""" + + +@dataclass(slots=True) +class Installation: + """What Atlassian OAuth gives us after a user approves the installation.""" + + cloud_id: str + site_url: str | None + site_name: str | None + access_token: str + refresh_token: str | None + + +def client_id() -> str: + return os.getenv("JIRA_CLIENT_ID", "") + + +def client_secret() -> str: + return os.getenv("JIRA_CLIENT_SECRET", "") + + +def is_configured() -> bool: + return bool(client_id() and client_secret()) + + +async def _call( + method: str, + token: str, + cloud_id: str, + path: str, + **params, +) -> dict: + """Make an authenticated Jira Cloud API request.""" + + url = f"{ATLASSIAN_API}/ex/jira/{cloud_id}{path}" + + async with httpx.AsyncClient(timeout=TIMEOUT) as http: + response = await http.request( + method, + url, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json", + }, + params=params, + ) + + response.raise_for_status() + + body = response.json() + + if not isinstance(body, dict): + raise JiraError(f"{path}: unexpected response") + + return body + + +async def exchange_code( + code: str, + redirect_uri: str, +) -> Installation: + """Exchange the one-time OAuth authorization code for Jira tokens.""" + + async with httpx.AsyncClient(timeout=TIMEOUT) as http: + response = await http.post( + f"{ATLASSIAN_AUTH}/oauth/token", + json={ + "grant_type": "authorization_code", + "client_id": client_id(), + "client_secret": client_secret(), + "code": code, + "redirect_uri": redirect_uri, + }, + ) + + response.raise_for_status() + + body = response.json() + + if "error" in body: + raise JiraError( + f"oauth/token: " + f"{body.get('error_description', body.get('error', 'unknown error'))}" + ) + + access_token = body.get("access_token") + refresh_token = body.get("refresh_token") + + if not access_token: + raise JiraError("oauth/token: missing access_token") + + resources = await get_accessible_resources(access_token) + + if not resources: + raise JiraError("No accessible Jira Cloud sites") + + site = resources[0] + + return Installation( + cloud_id=site.get("id", ""), + site_url=site.get("url"), + site_name=site.get("name"), + access_token=access_token, + refresh_token=refresh_token, + ) + + +async def get_accessible_resources(token: str) -> list[dict]: + """Return Jira/Atlassian sites accessible by this OAuth token.""" + + async with httpx.AsyncClient(timeout=TIMEOUT) as http: + response = await http.get( + f"{ATLASSIAN_AUTH}/oauth/token/accessible-resources", + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json", + }, + ) + + response.raise_for_status() + + body = response.json() + + if not isinstance(body, list): + raise JiraError("accessible-resources: unexpected response") + + return body + + +async def get_issue( + token: str, + cloud_id: str, + issue_key: str, +) -> dict: + """Fetch one Jira issue.""" + + return await _call( + "GET", + token, + cloud_id, + f"/rest/api/3/issue/{issue_key}", + fields=( + "summary," + "description," + "project," + "issuetype," + "status," + "priority," + "labels," + "components," + "reporter," + "assignee," + "created," + "updated" + ), + ) + + +async def list_issues( + token: str, + cloud_id: str, + jql: str, + start_at: int = 0, + max_results: int = 100, +) -> dict: + """Search Jira issues using JQL. + + Example: + project = ENG ORDER BY updated DESC + """ + + return await _call( + "GET", + token, + cloud_id, + "/rest/api/3/search", + jql=jql, + startAt=start_at, + maxResults=max_results, + fields=( + "summary," + "description," + "project," + "issuetype," + "status," + "priority," + "labels," + "components," + "reporter," + "assignee," + "created," + "updated" + ), + ) + + +async def get_comments( + token: str, + cloud_id: str, + issue_key: str, + start_at: int = 0, + max_results: int = 100, +) -> dict: + """Fetch comments for an issue. + + Keep this separate so comments can be added to the knowledge pipeline + later without changing the basic Jira issue ingestion. + """ + + return await _call( + "GET", + token, + cloud_id, + f"/rest/api/3/issue/{issue_key}/comment", + startAt=start_at, + maxResults=max_results, + ) + + +async def get_projects( + token: str, + cloud_id: str, +) -> list[dict]: + """Fetch projects available to the authenticated Jira user.""" + + body = await _call( + "GET", + token, + cloud_id, + "/rest/api/3/project", + ) + + # Jira returns a list for this endpoint, while _call normally expects + # a JSON object. If your API version returns a list directly, use a + # dedicated request here instead. + return body + + +def issue_url( + site_url: str, + issue_key: str, +) -> str: + """Build the human-readable Jira issue URL.""" + + return f"{site_url.rstrip('/')}/browse/{issue_key}" \ No newline at end of file diff --git a/backend/src/services/retrieval_service.py b/backend/src/services/retrieval_service.py index 460c90e..ae043b8 100644 --- a/backend/src/services/retrieval_service.py +++ b/backend/src/services/retrieval_service.py @@ -1,22 +1,43 @@ -from db.session import get_conn +import asyncio + from ai.providers.embedings import get_embedding_provider +from db.session import get_conn + async def search_embeddings(query: str, limit: int = 5): provider = get_embedding_provider() + + # embed() expects a batch, even for a single query. res = await provider.embed([query]) query_vec = res.vectors[0] - query_vec_str = f"[{','.join(str(x) for x in query_vec)}]" - - with get_conn() as conn: - with conn.cursor() as cur: - cur.execute( - """ - SELECT m.*, e.vector <-> %s AS score - FROM embeddings e - JOIN memories m ON m.id = e.memory_id - ORDER BY e.vector <-> %s - LIMIT %s; - """, - (query_vec_str, query_vec_str, limit), - ) - return cur.fetchall() \ No newline at end of file + + return await asyncio.to_thread( + _search, + query_vec, + limit, + ) + + +def _to_vector_literal(vector: list[float]) -> str: + """Convert a Python vector to PostgreSQL VECTOR literal syntax.""" + return "[" + ",".join(repr(float(v)) for v in vector) + "]" + + +def _search(query_vec: list[float], limit: int): + query_vec_str = _to_vector_literal(query_vec) + + with get_conn() as conn, conn.cursor() as cur: + cur.execute( + """ + SELECT + m.*, + e.vector <-> %s::VECTOR AS score + FROM embeddings e + JOIN memories m ON m.id = e.memory_id + ORDER BY e.vector <-> %s::VECTOR + LIMIT %s; + """, + (query_vec_str, query_vec_str, limit), + ) + + return cur.fetchall() \ No newline at end of file diff --git a/backend/src/services/tests/test_embeddings.py b/backend/src/services/tests/test_embeddings.py new file mode 100644 index 0000000..47f7068 --- /dev/null +++ b/backend/src/services/tests/test_embeddings.py @@ -0,0 +1,268 @@ +import asyncio +import uuid + +from db.session import get_conn +from ai.providers.embedings import get_embedding_provider + + +def to_vector_literal(vector: list[float]) -> str: + return "[" + ",".join(repr(float(v)) for v in vector) + "]" + + +def get_workspace_id() -> str: + with get_conn() as conn, conn.cursor() as cur: + cur.execute( + """ + SELECT id + FROM workspaces + LIMIT 1; + """ + ) + + row = cur.fetchone() + + if not row: + raise RuntimeError( + "No workspace found. Create a workspace first." + ) + + return str(row["id"]) + + +async def create_memory( + workspace_id: str, + title: str, + body: str, +): + provider = get_embedding_provider() + + print(f" Generating embedding for: {title}") + + # Generate embedding + res = await provider.embed([body]) + vector = res.vectors[0] + + memory_id = str(uuid.uuid4()) + + # -------------------------------------------------- + # Insert memory + # -------------------------------------------------- + + with get_conn() as conn, conn.cursor() as cur: + cur.execute( + """ + INSERT INTO memories ( + id, + workspace_id, + type, + title, + body, + scope, + status, + confidence + ) + VALUES ( + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s + ); + """, + ( + memory_id, + workspace_id, + "semantic", + title, + body, + "workspace", + "active", + 1.0, + ), + ) + + # -------------------------------------------------- + # Insert embedding + # -------------------------------------------------- + + with get_conn() as conn, conn.cursor() as cur: + cur.execute( + """ + INSERT INTO embeddings ( + id, + memory_id, + vector, + model + ) + VALUES ( + gen_random_uuid(), + %s, + %s::VECTOR, + %s + ); + """, + ( + memory_id, + to_vector_literal(vector), + res.model, + ), + ) + + return memory_id + + +async def search_embeddings( + query: str, + limit: int = 5, +): + provider = get_embedding_provider() + + print(f"\nGenerating query embedding...") + + # Generate query embedding + res = await provider.embed([query]) + + query_vector = res.vectors[0] + query_vector_str = to_vector_literal(query_vector) + + with get_conn() as conn, conn.cursor() as cur: + cur.execute( + """ + SELECT + m.id, + m.title, + m.body, + m.type, + m.scope, + m.status, + e.vector <-> %s::VECTOR AS score + FROM embeddings e + JOIN memories m + ON m.id = e.memory_id + ORDER BY e.vector <-> %s::VECTOR + LIMIT %s; + """, + ( + query_vector_str, + query_vector_str, + limit, + ), + ) + + return cur.fetchall() + + +async def main(): + # ================================================== + # 1. GET EXISTING WORKSPACE + # ================================================== + + workspace_id = get_workspace_id() + + print("=" * 70) + print("EMBEDDING TEST") + print("=" * 70) + + print(f"\nWorkspace: {workspace_id}") + + # ================================================== + # 2. CREATE TEST MEMORIES + # ================================================== + + memories = [ + ( + "Slack Integration", + "We decided to use Slack for collecting engineering conversations and technical discussions.", + ), + ( + "Database Architecture", + "CockroachDB will store structured engineering data and vector embeddings for semantic memory search.", + ), + ( + "Memory Retrieval", + "The memory system will use semantic vector search to retrieve relevant context for the AI model.", + ), + ( + "GitHub Integration", + "GitHub will provide technical facts such as commits, pull requests, branches, and code changes.", + ), + ( + "Jira Integration", + "Jira will provide structured project information including issues, tasks, priorities, and status.", + ), + ] + + print("\n" + "=" * 70) + print("CREATING MEMORIES") + print("=" * 70) + + for title, body in memories: + try: + memory_id = await create_memory( + workspace_id=workspace_id, + title=title, + body=body, + ) + + print(f" Created: {memory_id}") + print(f" Title: {title}") + + except Exception as e: + print(f"\n FAILED: {title}") + print(f" {type(e).__name__}: {e}") + + # ================================================== + # 3. SEARCH + # ================================================== + + query = ( + "How does the system retrieve relevant engineering " + "information from memory?" + ) + + print("\n" + "=" * 70) + print("SEMANTIC SEARCH") + print("=" * 70) + + print(f"\nQuery: {query}") + + try: + results = await search_embeddings( + query=query, + limit=5, + ) + + except Exception as e: + print(f"\nSearch failed:") + print(f"{type(e).__name__}: {e}") + return + + # ================================================== + # 4. PRINT RESULTS + # ================================================== + + if not results: + print("\nNo results found.") + return + + print(f"\nFound {len(results)} results:") + + for i, row in enumerate(results, 1): + print("\n" + "-" * 70) + print(f"RESULT {i}") + print("-" * 70) + + print(f"ID: {row['id']}") + print(f"Title: {row['title']}") + print(f"Body: {row['body']}") + print(f"Type: {row['type']}") + print(f"Scope: {row['scope']}") + print(f"Status: {row['status']}") + print(f"Score: {row['score']}") + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/backend/uv.lock b/backend/uv.lock index cbe20cf..56c2c58 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -71,6 +71,7 @@ dependencies = [ { name = "python-dotenv" }, { name = "python-multipart" }, { name = "slack-bolt" }, + { name = "tavily-python" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -96,6 +97,7 @@ requires-dist = [ { name = "python-dotenv", specifier = ">=1.2.2" }, { name = "python-multipart", specifier = ">=0.0.20" }, { name = "slack-bolt", specifier = ">=1.30.0" }, + { name = "tavily-python", specifier = ">=0.7.27" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.52.1" }, ] provides-extras = ["anthropic", "openai"] @@ -831,6 +833,94 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "regex" +version = "2026.7.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5", size = 416440, upload-time = "2026-07-19T00:19:48.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/b9/d11d7e501ac8fd7d617684423ebb9561e0b998481c1e4cbc0cb212c5d74a/regex-2026.7.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2cc3460cedf7579948486eab03bc9ad7089df4d7281c0f47f4afe03e8d13f02d", size = 496778, upload-time = "2026-07-19T00:17:05.677Z" }, + { url = "https://files.pythonhosted.org/packages/3f/a9/a5ab6f312f24318019170dc485d5421fe4f89e43a98640da50d95a8a7041/regex-2026.7.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0e9554c8785eac5cffe6300f69a91f58ba72bc88a5f8d661235ad7c6aa5b8ccd", size = 297122, upload-time = "2026-07-19T00:17:07.59Z" }, + { url = "https://files.pythonhosted.org/packages/b3/63/4cab4d7f2d384a144d420b763d97674cb70619c878ea6fcd7640d0e62143/regex-2026.7.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7da47a0f248977f08e2cb659ff3c17ddc13a4d39b3a7baa0a81bf5b415430f6", size = 292009, upload-time = "2026-07-19T00:17:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/22/85/102a81b218298957d4ea7d2f084fae537a71add9d6ff93c8e67284c5f45e/regex-2026.7.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93db40c8de0815baab96a06e08a984bac71f989d13bab789e382158c5d426797", size = 796708, upload-time = "2026-07-19T00:17:11.542Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/dc136af5629938a037cd2b304c12240e132ec92f38be8ff9cc89af2a1f2d/regex-2026.7.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:66bd62c59a5427746e8c44becae1d9b99d22fb13f30f492083dfb9ad7c45cc18", size = 865651, upload-time = "2026-07-19T00:17:13.312Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/67402ae3cd9c8c988a4c805d15ee3eef015e7ca4cb112cf3e640fc1f4153/regex-2026.7.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1649eb39fcc9ea80c4d2f110fde2b8ab2aef3877b98f02ab9b14e961f418c511", size = 911756, upload-time = "2026-07-19T00:17:15.015Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8e/096d00c7c480ef2ff4265349b14e2261d4ab787ba1f74e2e80d1c58079c3/regex-2026.7.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dce8ec9695f531a1b8a6f314fd4b393adcccf2ea861db480cdf97a301d01a68", size = 801798, upload-time = "2026-07-19T00:17:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f0/41/e7ecac6edb5722417f85cc67eaf386322fbe8acf6918ec2fdc37c20dd9d0/regex-2026.7.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3080a7fd38ef049bd489e01c970c97dd84ff446a885b0f1f6b26d9b1ad13ce11", size = 776933, upload-time = "2026-07-19T00:17:19.347Z" }, + { url = "https://files.pythonhosted.org/packages/6f/69/03c9b3f058d66403e0ca2c938696e81d51cd4c6d47ec5265f02f96948d9a/regex-2026.7.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d793a7988e04fcb1e2e135567443d82173225d657419ec09414a9b5a145b986", size = 784338, upload-time = "2026-07-19T00:17:21.057Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f7/b38ab3d43f284afbb618fcd15d0e77eb786ae461ce1f6bc7494619ddc0f2/regex-2026.7.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b0abe7d870f53ca5143895fef7d1041a0c831a140d3dc2c760dd7ba25d4a8b", size = 860452, upload-time = "2026-07-19T00:17:23.119Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/ff60ef0571121714f3cf9920bc183071e384a10b556d042e0fdb06cc07a5/regex-2026.7.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4e5413bd5f13d3a4e3539ca98f70f75e7fca92518dd7f117f030ebedd10b60cb", size = 765958, upload-time = "2026-07-19T00:17:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0f/bd34021162c0ab47f9a315bd56cd5642e920c8e5668a75ef6c6a6fca590d/regex-2026.7.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:73b133a9e6fb512858e7f065e96f1180aa46646bc74a83aea62f1d314f3dd035", size = 851765, upload-time = "2026-07-19T00:17:26.993Z" }, + { url = "https://files.pythonhosted.org/packages/2a/20/a2ca43edade0595cccfdc98636739f536d9e26898e7dbddc2b9e98898953/regex-2026.7.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbe6493fbd27321b1d1f2dd4f5c7e5bd4d8b1d7cab7f32fd67db3d0b2ed8248a", size = 789714, upload-time = "2026-07-19T00:17:28.699Z" }, + { url = "https://files.pythonhosted.org/packages/5d/47/e02db4015d424fc83c00ea0ac8c5e5ec14397943de9abf909d5ce3a25931/regex-2026.7.19-cp312-cp312-win32.whl", hash = "sha256:ddd67571c10869f65a5d7dde536d1e066e306cc90de57d7de4d5f34802428bb5", size = 267157, upload-time = "2026-07-19T00:17:31.051Z" }, + { url = "https://files.pythonhosted.org/packages/08/8e/c780c131f79b42ed22d1bd7da4096c2c35f813e835acd02ef0f018bd892c/regex-2026.7.19-cp312-cp312-win_amd64.whl", hash = "sha256:e30d40268a28d54ce0437031750497004c22602b8e3ab891f759b795a003b312", size = 277777, upload-time = "2026-07-19T00:17:32.848Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4c/e4d7e086449bdf379d89774bf1f89dc4a41943f3c5a6125a03905b34b5fb/regex-2026.7.19-cp312-cp312-win_arm64.whl", hash = "sha256:de9208bb427130c82a5dbfd104f92c8876fc9559278c880b3002755bbbe9c83d", size = 277136, upload-time = "2026-07-19T00:17:34.803Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3d/84165e4299ff76f3a40fe1f2abf939e976f693383a08d2beea6af62bd2c1/regex-2026.7.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40", size = 496552, upload-time = "2026-07-19T00:17:36.808Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/a65293e6e4cf28eb7ee1be5335a5386c40d6742e9f47fafc8fec785e16c7/regex-2026.7.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38", size = 296983, upload-time = "2026-07-19T00:17:38.816Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/2d0564e93d87bc48618360ddca232a2ca612bbdf53ce8465d45ca5ce14ee/regex-2026.7.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11", size = 291832, upload-time = "2026-07-19T00:17:40.726Z" }, + { url = "https://files.pythonhosted.org/packages/07/cd/42dfbabff3dfc9603c501c0e2e2c5adbb09d127b267bf5348de0af338c15/regex-2026.7.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13", size = 796775, upload-time = "2026-07-19T00:17:42.382Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/f6a4839f2b934e3eed5973fd07f5929ee97d4c98939fb275ea23c274ee16/regex-2026.7.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae", size = 865687, upload-time = "2026-07-19T00:17:44.185Z" }, + { url = "https://files.pythonhosted.org/packages/14/b0/b47d6c36049bc59806a50bd4c86ced70bbe058d787f80281b1d7a9b0e024/regex-2026.7.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da", size = 911962, upload-time = "2026-07-19T00:17:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/2a/be/ff61f28f9273658cfe23acbbac5217221f6519960ed401e61dfdab12bc35/regex-2026.7.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15", size = 801817, upload-time = "2026-07-19T00:17:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bb/8b4f7f26b333f9f79e1b453613c39bb4776f51d38ae66dd0ba31d6b354ca/regex-2026.7.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f", size = 776908, upload-time = "2026-07-19T00:17:50.183Z" }, + { url = "https://files.pythonhosted.org/packages/09/13/610110fc5921d380516d03c26b652555f08aa0d23ea78a771231873c3638/regex-2026.7.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939", size = 784426, upload-time = "2026-07-19T00:17:52.454Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f5/1ef9e2a83a5947c57ebff0b377cb5727c3d5ec1992317a320d035cd0dbb6/regex-2026.7.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96", size = 860600, upload-time = "2026-07-19T00:17:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/a0/02/073af33a3ec149241d11c80acea91e722aa0adbf05addd50f251c4fe89c3/regex-2026.7.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220", size = 765950, upload-time = "2026-07-19T00:17:56.041Z" }, + { url = "https://files.pythonhosted.org/packages/81/a9/d1e9f819dc394a568ef370cd56cf25394e957a2235f8370f23b576e5a475/regex-2026.7.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc", size = 851794, upload-time = "2026-07-19T00:17:57.897Z" }, + { url = "https://files.pythonhosted.org/packages/03/3a/8ae83eda7579feacdf984e71fb9e70635fb6f832eeddca58427ec4fca926/regex-2026.7.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2", size = 789845, upload-time = "2026-07-19T00:17:59.97Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/c195cbfe5a75fdec64d8f6554fd15237b837919d2c61bdc141d7c807b08b/regex-2026.7.19-cp313-cp313-win32.whl", hash = "sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404", size = 267135, upload-time = "2026-07-19T00:18:01.958Z" }, + { url = "https://files.pythonhosted.org/packages/b2/80/a11de8404b7272b70acb45c1c05987cce60b45d5693da2e176f0e390d564/regex-2026.7.19-cp313-cp313-win_amd64.whl", hash = "sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e", size = 277747, upload-time = "2026-07-19T00:18:04.121Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/0f5c8eff1b4f1f3d83276d365fccecf666afcc7d947420943bf394d07adb/regex-2026.7.19-cp313-cp313-win_arm64.whl", hash = "sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8", size = 277129, upload-time = "2026-07-19T00:18:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4c/44b74742052cedda40f9ae469532a037112f7311a36669a891fba8984bb0/regex-2026.7.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2", size = 501134, upload-time = "2026-07-19T00:18:07.567Z" }, + { url = "https://files.pythonhosted.org/packages/f0/45/bbd038b5e39ee5613a5a689290145b40058cc152c41de9cc23639d2b9734/regex-2026.7.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda", size = 299418, upload-time = "2026-07-19T00:18:09.38Z" }, + { url = "https://files.pythonhosted.org/packages/65/38/c5bde94b4cedfd5850d64c3f08222d8e1600e84f6ee71d9b44b4b8163f74/regex-2026.7.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff", size = 294486, upload-time = "2026-07-19T00:18:11.188Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6a/2f5e107cb26c960b781967178899daf2787a7ab151844ed3c01d6fc95474/regex-2026.7.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1", size = 811643, upload-time = "2026-07-19T00:18:12.975Z" }, + { url = "https://files.pythonhosted.org/packages/37/d4/a2f963406d7d73a62eed84ba05a258afb6cad1b21aa4517443ce40506b78/regex-2026.7.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf", size = 871081, upload-time = "2026-07-19T00:18:14.733Z" }, + { url = "https://files.pythonhosted.org/packages/45/a3/44be546340bedb15f13063f5e7fe16793ea4d9ea2e805d09bd174ac27724/regex-2026.7.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732", size = 917372, upload-time = "2026-07-19T00:18:16.724Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f6/e0870b0fd2a40dba0074e4b76e514b21313d37946c9248453e34ec43923e/regex-2026.7.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a", size = 816089, upload-time = "2026-07-19T00:18:18.617Z" }, + { url = "https://files.pythonhosted.org/packages/ae/27/957e8e22690ad6634572b39b71f130a6105f4d0718bb16849eac00fff147/regex-2026.7.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba", size = 785206, upload-time = "2026-07-19T00:18:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/a4/186e410941e731037c01166069ab86da9f65e8f8110c18009ccf4bd623ee/regex-2026.7.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc", size = 800431, upload-time = "2026-07-19T00:18:22.716Z" }, + { url = "https://files.pythonhosted.org/packages/73/9f/e4e10e023d291d64a33e246610b724493bf1ce98e0e59c9b7c837e5acfb7/regex-2026.7.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62", size = 864906, upload-time = "2026-07-19T00:18:24.772Z" }, + { url = "https://files.pythonhosted.org/packages/24/57/ccb20b6be5f1f52a053d1ba2a8f7a077edb9d918248b8490d7506c6832b3/regex-2026.7.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1", size = 773559, upload-time = "2026-07-19T00:18:27.008Z" }, + { url = "https://files.pythonhosted.org/packages/a3/82/f3b263cf8fad927dc102891da8502e718b7ff9d19af7a2a07c03865d7188/regex-2026.7.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e", size = 857739, upload-time = "2026-07-19T00:18:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/1687bd1b6c2aed5e672ccf845fc11557821fe7366d921b50889ea5ce57bf/regex-2026.7.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0", size = 804522, upload-time = "2026-07-19T00:18:31.362Z" }, + { url = "https://files.pythonhosted.org/packages/76/7c/cc4e7655181b2d9235b704f2c5e19d8eff002bbc437bae59baee0e381aca/regex-2026.7.19-cp313-cp313t-win32.whl", hash = "sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4", size = 269141, upload-time = "2026-07-19T00:18:33.479Z" }, + { url = "https://files.pythonhosted.org/packages/bb/14/961b4c7b05a2391c32dbc85e27773076671ef8f97f36cec70fe414734c02/regex-2026.7.19-cp313-cp313t-win_amd64.whl", hash = "sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974", size = 280036, upload-time = "2026-07-19T00:18:35.419Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/795644550d788ddbb6dc458c95895f8009978ea6d6ea76b005eb3f45e8c9/regex-2026.7.19-cp313-cp313t-win_arm64.whl", hash = "sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d", size = 279394, upload-time = "2026-07-19T00:18:37.454Z" }, + { url = "https://files.pythonhosted.org/packages/d2/25/0c4c452f8ef3efe456745b2f33195f5904b573fb4c2ff3f0cb9ec188461e/regex-2026.7.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd", size = 496750, upload-time = "2026-07-19T00:18:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/24/9e/b70ca6c1704f6c7cd32a9e143c86cc5968d10981eca284bad670c245ea7d/regex-2026.7.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac", size = 297093, upload-time = "2026-07-19T00:18:41.583Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/0b692da2520d51fbff19c88b83d97e4c702909dd02386c585998b7e2dbed/regex-2026.7.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5", size = 292043, upload-time = "2026-07-19T00:18:43.347Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a7/1d478e614016045a33feae57446215f9fd65b665a5ceb2f891fb3183bc52/regex-2026.7.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3", size = 797214, upload-time = "2026-07-19T00:18:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ae/11b9c9411d92c30e3d2db32df5a31133e4a99a8fc397a604fd08f6c4bffb/regex-2026.7.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053", size = 866433, upload-time = "2026-07-19T00:18:47.315Z" }, + { url = "https://files.pythonhosted.org/packages/b1/62/2b2efc4992f91d6d204b24c647c9f9412e85379d92b7c0ab9fdae622327e/regex-2026.7.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b", size = 911360, upload-time = "2026-07-19T00:18:49.588Z" }, + { url = "https://files.pythonhosted.org/packages/14/71/986ceea9aa3da548bf1357cad89b63915ec6d21ec957c8113b29ece567df/regex-2026.7.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a", size = 801275, upload-time = "2026-07-19T00:18:51.767Z" }, + { url = "https://files.pythonhosted.org/packages/15/be/ce9d9534b2cda96eab32c548261224b9b4e220a4126f098f60f42ae7b4cd/regex-2026.7.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1", size = 777131, upload-time = "2026-07-19T00:18:54.053Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/58b5c710f2c3929515a25f3a1ca0dad0dcd4518d4fff3cf23bc7adb8dcd2/regex-2026.7.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e", size = 785020, upload-time = "2026-07-19T00:18:56.579Z" }, + { url = "https://files.pythonhosted.org/packages/84/03/5fe091935b74f15fe0f97998c215cae418d1c0413f6258c7d4d2e83aa37f/regex-2026.7.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12", size = 861263, upload-time = "2026-07-19T00:18:58.64Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/d60bf82e10841eef62a9e32aac401468f05fddfbcb2942e342b1ba3d2433/regex-2026.7.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2", size = 766199, upload-time = "2026-07-19T00:19:00.705Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5d/11e64d151b0662b81d6bf644c74dc118d461df85bdf2577fadbbf751788a/regex-2026.7.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97", size = 851317, upload-time = "2026-07-19T00:19:03.015Z" }, + { url = "https://files.pythonhosted.org/packages/7c/34/532efb87488d90807bae6a443d357ee5e2728a478c597619c8aaa17cc0bd/regex-2026.7.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4", size = 789557, upload-time = "2026-07-19T00:19:05.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/90/3a8d5ca977171ec3ae21a71207d2228b2663bde14d7f7ef0e6363ecf9290/regex-2026.7.19-cp314-cp314-win32.whl", hash = "sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa", size = 272531, upload-time = "2026-07-19T00:19:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/96/e1/8862885e70409de70e8c005f57fb2e7be8d9ef0317250d60f4c9660a300d/regex-2026.7.19-cp314-cp314-win_amd64.whl", hash = "sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac", size = 280831, upload-time = "2026-07-19T00:19:09.46Z" }, + { url = "https://files.pythonhosted.org/packages/08/82/2693e53e29f9104d9de95d37ce4dd826bd32d5f9c0085d3aa6ac042675c4/regex-2026.7.19-cp314-cp314-win_arm64.whl", hash = "sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459", size = 281099, upload-time = "2026-07-19T00:19:11.398Z" }, + { url = "https://files.pythonhosted.org/packages/92/b7/9a01aa16461a18cde9d7b9c3ab21e501db2ce33725f53014342b91df2b0a/regex-2026.7.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3", size = 501121, upload-time = "2026-07-19T00:19:13.425Z" }, + { url = "https://files.pythonhosted.org/packages/f3/5e/bbaeca815dc9191c424c94a4fdc5c87c75748a64a6271821212ebdd4e1a3/regex-2026.7.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518", size = 299415, upload-time = "2026-07-19T00:19:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d6/0dd1a321afaab95eb7ff44aa0f637301786f1dc71c6b797b9ed236ed8890/regex-2026.7.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9", size = 294483, upload-time = "2026-07-19T00:19:17.879Z" }, + { url = "https://files.pythonhosted.org/packages/92/5f/40bacf91d0904f812e13bbbab3864604c463eced8afdc54aeaa50492ea95/regex-2026.7.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435", size = 811833, upload-time = "2026-07-19T00:19:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/4902744261f775aeede8b5627314b38482da29cf49a57b66a6fb753246c5/regex-2026.7.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0", size = 871270, upload-time = "2026-07-19T00:19:22.365Z" }, + { url = "https://files.pythonhosted.org/packages/16/70/6980c9be6bf21c0a60ed3e0aea39cf419ecf3b08d1d9947bc56e196ef186/regex-2026.7.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a", size = 917534, upload-time = "2026-07-19T00:19:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/52/92/8b2bd872782ce8c42691e39acb38eb8efe014e5ddb78ad7d943d6f197ce9/regex-2026.7.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276", size = 816135, upload-time = "2026-07-19T00:19:26.919Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/33a602f657bdc4041f17d79f92ab18261d255d91a06117a6e29df023e5e2/regex-2026.7.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c", size = 785492, upload-time = "2026-07-19T00:19:29.192Z" }, + { url = "https://files.pythonhosted.org/packages/9e/36/0987cf4cb271680064a70d24a475873775a151d0b7058698a006cb0cae4a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a", size = 800658, upload-time = "2026-07-19T00:19:31.392Z" }, + { url = "https://files.pythonhosted.org/packages/a8/24/c14f31c135e1ba55fa4f9a58ca98d0842512bf6188230763c31c8f449e3b/regex-2026.7.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009", size = 865073, upload-time = "2026-07-19T00:19:33.485Z" }, + { url = "https://files.pythonhosted.org/packages/14/85/181a12211f22469f24d2de1ebddfe397d2396e2c29013b9a58134a91069a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218", size = 773684, upload-time = "2026-07-19T00:19:35.599Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/bd1a0c1a62251366f8d21f41b1ea3c76994962071b8b6ea42f72d505c0f0/regex-2026.7.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966", size = 857769, upload-time = "2026-07-19T00:19:37.738Z" }, + { url = "https://files.pythonhosted.org/packages/e4/4f/f7e2dad6756b2fe1fe75dd90a628c3b45f249d39f948dd90cd2476325417/regex-2026.7.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44", size = 804546, upload-time = "2026-07-19T00:19:40.229Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d7/01d31d5bdb09bc026fab77f59a371fdf8f9b292e4810546c56182ca70498/regex-2026.7.19-cp314-cp314t-win32.whl", hash = "sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78", size = 274526, upload-time = "2026-07-19T00:19:42.398Z" }, + { url = "https://files.pythonhosted.org/packages/52/0e/cea4ce73bc0a8247a0748228ae6669984c7e1f8134b6fa66e59c0572e0ea/regex-2026.7.19-cp314-cp314t-win_amd64.whl", hash = "sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2", size = 283763, upload-time = "2026-07-19T00:19:44.644Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b6/26e41975febae63b7a6e3e02f32cff6cff2e4f10d19c929082f56aebf7c6/regex-2026.7.19-cp314-cp314t-win_arm64.whl", hash = "sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547", size = 283451, upload-time = "2026-07-19T00:19:46.639Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -889,6 +979,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, ] +[[package]] +name = "tavily-python" +version = "0.7.27" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "requests" }, + { name = "tiktoken" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/53/e97950453a215a7b8c2c44d70b216f64c0e1d9844d8b91f025104e96d233/tavily_python-0.7.27.tar.gz", hash = "sha256:3fbbee7fc7e252479b264835e6f943b4a81395429c1bd419e8024d11bf2c1831", size = 30750, upload-time = "2026-07-30T13:44:15.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/dd/cf4b6668ef06670a27ed4012f2bd3663602ad5f0e1ac9b0c23e8d45d01eb/tavily_python-0.7.27-py3-none-any.whl", hash = "sha256:e5cb40cc852d108ced8a313379b7098108642eedfbd97f821296a5e1a483e9b9", size = 21988, upload-time = "2026-07-30T13:44:14.404Z" }, +] + [[package]] name = "tenacity" version = "9.1.4" @@ -898,6 +1002,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] +[[package]] +name = "tiktoken" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/e5/5f3cb2159769d0f4324c0e9e87f9de3c4b1cd45848a96b2eb3566ad5ca77/tiktoken-0.13.0.tar.gz", hash = "sha256:c9435714c3a84c2319499de9a300c0e604449dd0799ff246458b3bb6a7f433c1", size = 38986, upload-time = "2026-05-15T04:51:27.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/8e/144bde4e01df66b34bb865557c7cd754ed08b036217ebd79c9db5e9048a9/tiktoken-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:32ac870a806cfb260a02d0cb70426aef02e038297f8ad50df5040bb5af360791", size = 1034888, upload-time = "2026-05-15T04:50:31.579Z" }, + { url = "https://files.pythonhosted.org/packages/36/18/d4ac9d20956cdebca04841316660ed584c2fecdc2b81722a28bc7ad3b1e4/tiktoken-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d9980f11429ed2d737c463bb1fb78cf330caa026adf002f714aced7849a687b", size = 982970, upload-time = "2026-05-15T04:50:32.961Z" }, + { url = "https://files.pythonhosted.org/packages/74/ed/6bb8d05b9f731f749fee5c6f5ca63e981143c826a5985877330507bd13b7/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3f277ebea5edd7b8bf03c6f9431e1d67d517530115572b2dc1d465326e8f88c7", size = 1115741, upload-time = "2026-05-15T04:50:34.475Z" }, + { url = "https://files.pythonhosted.org/packages/34/de/2ca96b07a82d972b74fe4b46de055b79c904e45c7eab699354a0bfa697dc/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:a116178fa7e1b4065bff05214360373a65cac22f965be7b3f73d00a0dbfe7649", size = 1136523, upload-time = "2026-05-15T04:50:35.782Z" }, + { url = "https://files.pythonhosted.org/packages/ee/dc/9dafec002c2d4424378563cf4cf5c7fb93631d2a55013c8b87554ee4012c/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c397ddda233208345b01bd30f2fca79ff730e55731d0108a603f9bc57f6af3b", size = 1181954, upload-time = "2026-05-15T04:50:36.99Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d0/1f8578c45b2f24759b46f0b50d31878c63c73e6bf0f2227e10ec5c5408dc/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:95097e4f89b06403976e498abf61a0ee73a7497e73fb599cb211d8197a054d91", size = 1240069, upload-time = "2026-05-15T04:50:38.221Z" }, + { url = "https://files.pythonhosted.org/packages/aa/90/28d7f154888610aa9237e541986beb62b479df29d193a5a0617dbb1514d0/tiktoken-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:8f2d16e7a7c783ad81f36e457d046d1f1c8af70b22aec8a13238efe531977c41", size = 874748, upload-time = "2026-05-15T04:50:39.587Z" }, + { url = "https://files.pythonhosted.org/packages/9c/83/b096c859c2a47c11731bf2f5885f4028b809dfe2396582883eed9cae372f/tiktoken-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5df5d1507bd245f1ccad4a074698240021239e455eb0bb4ced4e3d7181872154", size = 1034228, upload-time = "2026-05-15T04:50:40.988Z" }, + { url = "https://files.pythonhosted.org/packages/53/61/c68e123b6d753e3fc2751e9b18e732c9d8bf1e1926762e736eee935d931c/tiktoken-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fe806a50664e83a6ffd56cbd1e4f5dcc6cd32a3e7538f70dc38b1a271384545", size = 982978, upload-time = "2026-05-15T04:50:42.195Z" }, + { url = "https://files.pythonhosted.org/packages/ef/8b/96cc178cc584e65d363134500f297790b06cd48cdeb1e8fcf7bbe60f4715/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:125bc05005e747f993a83dc67934249932d6e4209854452cd4c0b1d53fba3ba2", size = 1116355, upload-time = "2026-05-15T04:50:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/86/f5/bab735d2c72ea55404b295d02d092644eb5f7cc6205e34d35eb9abfb9ab2/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5e6358911cab4adee6712da27d65573496a4f68cf8a2b5fca6a4ad10fc5748cf", size = 1135772, upload-time = "2026-05-15T04:50:44.782Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b9/6de04ebdf904edfaad87788011b3735087a0c9ea671b9027e1e4e965e8c8/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:975cbd78d085d75d26b59660e262736dcaed1e35f8f142cd6291025c01d25486", size = 1182415, upload-time = "2026-05-15T04:50:46.422Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9c/470a05f3b1caf038f44880e334d47ab674e0c80d514c66b375d14d5afa10/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ab9bc99fa020a4c283424590ecd7f3afd70c1c281cb3fa3192a6c3af9f9615", size = 1239879, upload-time = "2026-05-15T04:50:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/42/a6/c1936d16055436cb32e6c6128d68629622e00f4768562f55653752d34768/tiktoken-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:6b1615f0ff71953d19729ceb18865429c185b0a23c5353f1bbca34a394bf60f7", size = 874829, upload-time = "2026-05-15T04:50:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/d6/07/acb5992c3772b5a36284f742cfb7a5895aa4471d1848ac31464ad50d7fdf/tiktoken-0.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6eb4a5bfbc6426938026b1a334e898ac53541360d62d8c689870160cc80abd67", size = 1033600, upload-time = "2026-05-15T04:50:50.4Z" }, + { url = "https://files.pythonhosted.org/packages/14/e9/742e9aec30f59b9f161f7ff7cd072e02ea836c9e1c0854a8076dfcd40d5c/tiktoken-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:43cee3e5400573b2046fbf092cc7a5bc30164f9e4c95ce20714da929df48737a", size = 982516, upload-time = "2026-05-15T04:50:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/72/74/ca1541b053e7648254d2e4b42a253e1bb4359f2c91a0a8d49228c794e1a0/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:7de52e3f566d19b3b11bd37eea552c6c305ad74081f736882bd44d148ed4c48d", size = 1115518, upload-time = "2026-05-15T04:50:53.543Z" }, + { url = "https://files.pythonhosted.org/packages/46/e3/93825eaf5a4a504795b787e5d5dea07fbeb3dabf97aa7b450be8bde59c89/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:51384448aa508e4df84c0f7c1dc3211c7f7b8096325660ee5fc82f3e11b381ce", size = 1136867, upload-time = "2026-05-15T04:50:55.191Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/002b68de6827091d5ae90b048f326e8aad8d953520950e5ce1508879414f/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e28157350f7ebf35008dd8e9e0fdb621f976e4230c881099c85e8cf07eaa50e2", size = 1181826, upload-time = "2026-05-15T04:50:56.296Z" }, + { url = "https://files.pythonhosted.org/packages/db/c6/d393e3185a276505182f7abd93fe714f3c444a2be9180798fa052347504e/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:165cf1820ea4a354985c2490a5205d4cc74661c934aca79dd0368232fff94e0f", size = 1239489, upload-time = "2026-05-15T04:50:57.918Z" }, + { url = "https://files.pythonhosted.org/packages/b7/4d/bc07d1f1635d4897a202acc0ae11c2886eaa7325c359ba4741b47bf8e225/tiktoken-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6c43a675ca14f6f2749ba7f12075d37456015a24b859f2517b9beb4ef30807ec", size = 873820, upload-time = "2026-05-15T04:50:59.528Z" }, + { url = "https://files.pythonhosted.org/packages/8c/93/0dd6adca026a616c3a92974566b43381eea4b475ce1f36c062b8271a9ac5/tiktoken-0.13.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaaaef47c2406277181d2086484c317bf7fc433e2d5d03ff94f56b0dcec87471", size = 1034977, upload-time = "2026-05-15T04:51:00.957Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5ec6e6bc5b30bed6d93f7f2162d8f6b32437b3ba27cb527cfe004f6109c9/tiktoken-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ca8b310bd93b3772cb1b7922d915446864860f562bdfe4825c63a0aed3fb28cd", size = 983635, upload-time = "2026-05-15T04:51:02.629Z" }, + { url = "https://files.pythonhosted.org/packages/94/b0/c8ae9aff00d625c50659b4513e707a0462c4bf5d4d6cc1b802103225c02e/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:32e0c12305105002c047b3bb1070b0dd9a73b0cb3b2856a8972b810e7a4f5881", size = 1116036, upload-time = "2026-05-15T04:51:04.082Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/6a5dddd1d0a6018ecb389bd0353e6b4a515eb4d2286611bd0ace1937b9e1/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5ba5fd62507a932d1241346179e3b39bc7bf7408f03c272652d93b3bedf5db24", size = 1135544, upload-time = "2026-05-15T04:51:05.229Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b8/585032b4384b2f7dcdaddcb52865c83a701a420d09e3c2b4a2be1c450c57/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d108bc2d470fc53c8ecd24f2c0fd2b5f98c33e87cdb6aa2e9b8c5dced703d273", size = 1182217, upload-time = "2026-05-15T04:51:06.517Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b6/993ff1ded3958215fd341a847b8e5ffeb5de473f435296870d314fc91ac4/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cb99cb5127449f58d0a2d5f5ccfb390d8dbdfd919c221246caaee29d8725ed51", size = 1239404, upload-time = "2026-05-15T04:51:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3d/fef7e06e3b33e7538db0ced734cf9fe23b6832d2ac4990c119c377aec55e/tiktoken-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:115c4f26ffa11caac8b54eea35c2ad38c612c20a48d35dd15d70a02ac6f51f58", size = 918686, upload-time = "2026-05-15T04:51:08.925Z" }, + { url = "https://files.pythonhosted.org/packages/c1/82/a7fc44582bc32ab00de988a2299bf77c077f59068b233109e34b7d6ca7e6/tiktoken-0.13.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:472527e9132952f2fbf77cd290658bacf003d4d5a3fabc18e5fbd407cbae4d9b", size = 1034454, upload-time = "2026-05-15T04:51:10.035Z" }, + { url = "https://files.pythonhosted.org/packages/37/d0/24d8a890c14f432a05cea669c17bebeaa99f96a7c79523b590f564246411/tiktoken-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e2f67d27c9626cdd25fe33d9313c5cdb3d8d82da646b68d6eb8e7e9c20e6448", size = 982976, upload-time = "2026-05-15T04:51:11.23Z" }, + { url = "https://files.pythonhosted.org/packages/49/b7/2ab43f62788a9266187a9bfc1d3af99ad83e5eaa25fbef168a69cd5ad14f/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2b920b35805cd64585a37c3dc7ce65fba4d2d36016be01e1d7942482ca29093a", size = 1115526, upload-time = "2026-05-15T04:51:12.608Z" }, + { url = "https://files.pythonhosted.org/packages/64/39/1494321ed323ce7a14d88e3cd6cb9058625977df1c6961ddc492bd10a9f3/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:493af3aa28a4aaf2e3d2600a2ee717252c9bf5ab38fff94eb5a02db5ab77e5ad", size = 1136466, upload-time = "2026-05-15T04:51:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/96/d9/dfd086aa2d918c563a140720e0ce296cada1634efd2783d5cf51e05f984e/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6644c9c2b5cf3916f5a3641d7d12fdb3f006a7b3d9ff6acdaec44e29ab1ff91e", size = 1181863, upload-time = "2026-05-15T04:51:15.025Z" }, + { url = "https://files.pythonhosted.org/packages/2f/68/a18b4f307086954fdae32714cb4f85562e34f9d34ab206e61f1816aa6018/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cb65b60b9408563676d874a3a4ee573370066f0dc4e29d84e82e989c6517424", size = 1239218, upload-time = "2026-05-15T04:51:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/16/5b/f2aa703a4fc5d2dff73460a7d46cc2f3f44aa0f3dd8eeb20d2a0ecf68862/tiktoken-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:85b78cc3a2c3d48723ca751fa981f1fedccd54194ca0471b957364353a898b07", size = 918110, upload-time = "2026-05-15T04:51:17.237Z" }, +] + [[package]] name = "tqdm" version = "4.70.0" diff --git a/docker-compose-dev.yaml b/docker-compose-dev.yaml index f767f1a..a5f17e1 100644 --- a/docker-compose-dev.yaml +++ b/docker-compose-dev.yaml @@ -27,8 +27,23 @@ services: DATABASE_URL: postgresql://root@cockroach:26257/engineering_memory?sslmode=disable REDIS_URL: redis://redis:6379/0 depends_on: - - cockroach - - redis + init-db: + condition: service_completed_successfully + redis: + condition: service_started + + init-db: + build: + context: ./backend + target: dev + env_file: + - ./backend/.env + environment: + DATABASE_URL: postgresql://root@cockroach:26257/engineering_memory?sslmode=disable + command: ["uv", "run", "python", "-m", "db.init_db"] + depends_on: + cockroach: + condition: service_healthy cockroach: image: cockroachdb/cockroach:v25.3.3 @@ -38,6 +53,12 @@ services: - "8080:8080" volumes: - cockroach-data:/cockroach/cockroach-data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health?ready=1"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s redis: image: redis:7-alpine diff --git a/docker-compose-prod.yaml b/docker-compose-prod.yaml index 4424370..5626140 100644 --- a/docker-compose-prod.yaml +++ b/docker-compose-prod.yaml @@ -23,14 +23,33 @@ services: DATABASE_URL: postgresql://root@cockroach:26257/engineering_memory?sslmode=disable REDIS_URL: redis://redis:6379/0 depends_on: - - cockroach - - redis + init-db: + condition: service_completed_successfully + redis: + condition: service_started + + init-db: + build: + context: ./backend + target: prod + environment: + DATABASE_URL: postgresql://root@cockroach:26257/engineering_memory?sslmode=disable + command: ["uv", "run", "python", "-m", "db.init_db"] + depends_on: + cockroach: + condition: service_healthy cockroach: image: cockroachdb/cockroach:v25.3.3 command: start-single-node --insecure volumes: - cockroach-data:/cockroach/cockroach-data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health?ready=1"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s redis: image: redis:7-alpine