Compare commits
19 Commits
f7ac45ddd8
..
0.0.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
cf0693f319
|
|||
|
8fac3d2bae
|
|||
|
454bb57484
|
|||
|
00cf6f8747
|
|||
|
6bd1e81a51
|
|||
|
a5a718b3e4
|
|||
|
7b65b62f58
|
|||
|
97c751c585
|
|||
|
e2f87481d6
|
|||
|
d6c2d57fd9
|
|||
|
71638fcaed
|
|||
|
767e929bf9
|
|||
|
3b6e3c34d5
|
|||
|
58a57ddc6a
|
|||
|
11adcc160a
|
|||
|
da016c23cb
|
|||
|
5850674448
|
|||
|
36c91ce500
|
|||
|
761eda6155
|
+18
-1
@@ -1,8 +1,25 @@
|
|||||||
|
MODEL_PROVIDER=ollama
|
||||||
OLLAMA_BASE_URL=http://localhost:11434
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
OLLAMA_MODEL=qwen3.5:9b
|
OLLAMA_MODEL=qwen3.5:9b
|
||||||
|
OLLAMA_NUM_CTX=64512
|
||||||
|
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
|
OPENAI_MODEL=gpt-5.4-mini
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||||
|
DEEPSEEK_MODEL=deepseek-v4-flash
|
||||||
|
DEEPSEEK_API_KEY=
|
||||||
|
MODEL_REASONING_EFFORT=medium
|
||||||
|
CODEX_COMMAND=codex
|
||||||
|
CODEX_MODEL=gpt-5.4
|
||||||
UEX_BASE_URL=https://api.uexcorp.space/2.0
|
UEX_BASE_URL=https://api.uexcorp.space/2.0
|
||||||
|
SCMDB_BASE_URL=https://scmdb.net
|
||||||
|
CORNERSTONE_BASE_URL=https://finder.cstone.space
|
||||||
|
SCWIKI_BASE_URL=https://starcitizen.tools
|
||||||
|
SCWIKI_API_BASE_URL=https://api.star-citizen.wiki
|
||||||
UEX_SECRET_KEY=
|
UEX_SECRET_KEY=
|
||||||
UEX_BEARER_TOKEN=
|
UEX_BEARER_TOKEN=
|
||||||
|
UEX_NEGOTIATION_CLOSE_ENDPOINT=marketplace_negotiations_close
|
||||||
TRADERAI_USER_NAME=
|
TRADERAI_USER_NAME=
|
||||||
TRADERAI_MEMORY_PATH=data/traderai.sqlite3
|
TRADERAI_MEMORY_PATH=
|
||||||
|
UEX_NOTIFICATION_POLL_SECONDS=300
|
||||||
REQUIRE_WRITE_APPROVAL=true
|
REQUIRE_WRITE_APPROVAL=true
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
name: Build Release EXE
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-windows-exe:
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install build dependencies
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install -e ".[dev]"
|
||||||
|
|
||||||
|
- name: Build TraderAI.exe
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
pyinstaller TraderAI.spec --noconfirm
|
||||||
|
if (-not (Test-Path -LiteralPath "dist\TraderAI.exe")) {
|
||||||
|
throw "dist\TraderAI.exe was not created."
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Attach EXE to release
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$event = Get-Content -LiteralPath $env:GITHUB_EVENT_PATH -Raw | ConvertFrom-Json
|
||||||
|
$releaseId = $event.release.id
|
||||||
|
if (-not $releaseId) {
|
||||||
|
throw "Release id was not present in the release event payload."
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $env:RELEASE_TOKEN
|
||||||
|
if ([string]::IsNullOrWhiteSpace($token)) {
|
||||||
|
$token = $env:GITEA_TOKEN
|
||||||
|
}
|
||||||
|
if ([string]::IsNullOrWhiteSpace($token)) {
|
||||||
|
throw "Set a RELEASE_TOKEN secret or enable the built-in GITHUB_TOKEN for Actions."
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiUrl = $env:GITHUB_API_URL
|
||||||
|
if ([string]::IsNullOrWhiteSpace($apiUrl)) {
|
||||||
|
$apiUrl = "$($env:GITHUB_SERVER_URL.TrimEnd('/'))/api/v1"
|
||||||
|
}
|
||||||
|
|
||||||
|
$repoParts = $env:GITHUB_REPOSITORY.Split("/", 2)
|
||||||
|
if ($repoParts.Length -ne 2) {
|
||||||
|
throw "GITHUB_REPOSITORY must look like owner/repo. Value: $env:GITHUB_REPOSITORY"
|
||||||
|
}
|
||||||
|
|
||||||
|
$owner = [uri]::EscapeDataString($repoParts[0])
|
||||||
|
$repo = [uri]::EscapeDataString($repoParts[1])
|
||||||
|
$assetPath = Resolve-Path -LiteralPath "dist\TraderAI.exe"
|
||||||
|
$uploadUrl = "$apiUrl/repos/$owner/$repo/releases/$releaseId/assets?name=TraderAI.exe"
|
||||||
|
|
||||||
|
Invoke-RestMethod `
|
||||||
|
-Method Post `
|
||||||
|
-Uri $uploadUrl `
|
||||||
|
-Headers @{ Authorization = "token $token" } `
|
||||||
|
-Form @{ attachment = Get-Item -LiteralPath $assetPath }
|
||||||
@@ -19,6 +19,7 @@ dist/
|
|||||||
*.egg-info/
|
*.egg-info/
|
||||||
.eggs/
|
.eggs/
|
||||||
pip-wheel-metadata/
|
pip-wheel-metadata/
|
||||||
|
.playwright-mcp/
|
||||||
|
|
||||||
# Test and coverage output
|
# Test and coverage output
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
# TraderAI
|
# TraderAI
|
||||||
|
|
||||||
Local Ollama-powered chat for UEX marketplace workflows.
|
Local Ollama-, DeepSeek-, OpenAI-, or Codex-powered chat for UEX marketplace workflows.
|
||||||
|
|
||||||
## What It Does
|
## What It Does
|
||||||
|
|
||||||
- Searches active UEX marketplace listings through `GET /marketplace_listings/`.
|
- Searches active/current UEX marketplace listings through `GET /marketplace_listings/`.
|
||||||
|
- Searches SCMDB mission data so the assistant can answer what Star Citizen missions pay or reward, including UEC, reputation, item rewards, blueprint rewards, partial payouts, and hauling cargo.
|
||||||
|
- Searches Cornerstone Universal Item Finder data so the assistant can find where in-game items are sold, including store/location, base price, and verified date.
|
||||||
- Reads authenticated marketplace negotiations and negotiation messages when `UEX_SECRET_KEY` or `UEX_BEARER_TOKEN` is set.
|
- Reads authenticated marketplace negotiations and negotiation messages when `UEX_SECRET_KEY` or `UEX_BEARER_TOKEN` is set.
|
||||||
- Drafts negotiation messages and marketplace listings as pending actions.
|
- Drafts negotiation messages and marketplace listings as pending actions.
|
||||||
- Requires browser approval before sending authenticated write requests to UEX.
|
- Requires browser approval before sending authenticated write requests to UEX.
|
||||||
- Maintains local SQLite memory with searchable recall for user facts, preferences, and prior chat context.
|
- Maintains local SQLite memory with searchable recall for user facts, preferences, and prior chat context.
|
||||||
- Can create one-time or recurring wake jobs that prompt the assistant later and surface the result in the UI.
|
- Can create one-time or recurring wake jobs that prompt the assistant later and surface the result in the UI.
|
||||||
- Loads the configured UEX user profile from `GET /user` so the assistant knows the current account username, display name, timezone, language, and marketplace-relevant profile details.
|
- Loads the configured UEX user profile from `GET /user` so the assistant knows the current account username, display name, timezone, language, and marketplace-relevant profile details.
|
||||||
|
- Polls authenticated `GET /user_notifications` for unread UEX notifications and surfaces new pending alerts in the chat notification queue.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
@@ -22,6 +25,12 @@ Local Ollama-powered chat for UEX marketplace workflows.
|
|||||||
```
|
```
|
||||||
|
|
||||||
3. Create `.env` from `.env.example` and set `UEX_SECRET_KEY` and/or `UEX_BEARER_TOKEN` if you want authenticated actions.
|
3. Create `.env` from `.env.example` and set `UEX_SECRET_KEY` and/or `UEX_BEARER_TOKEN` if you want authenticated actions.
|
||||||
|
If you want the cheapest hosted default, set `MODEL_PROVIDER=deepseek`, set `DEEPSEEK_API_KEY`, and keep `DEEPSEEK_MODEL=deepseek-v4-flash` unless you specifically want `deepseek-v4-pro`.
|
||||||
|
If you want to use OpenAI instead of Ollama, set `MODEL_PROVIDER=openai`, set `OPENAI_API_KEY`, and optionally change `OPENAI_MODEL` from the default `gpt-5.4-mini`.
|
||||||
|
If you want to use Codex models with ChatGPT/Codex OAuth, install the Codex CLI, set `MODEL_PROVIDER=codex`, and optionally change `CODEX_MODEL` from the default `gpt-5.4`. TraderAI uses the local `codex app-server` JSON-RPC interface for both authentication and chat turns.
|
||||||
|
`MODEL_REASONING_EFFORT` controls reasoning depth for DeepSeek, OpenAI, and Codex and defaults to `medium`.
|
||||||
|
`SCMDB_BASE_URL` defaults to `https://scmdb.net`.
|
||||||
|
`CORNERSTONE_BASE_URL` defaults to `https://finder.cstone.space`.
|
||||||
4. Install and run:
|
4. Install and run:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -33,11 +42,23 @@ Local Ollama-powered chat for UEX marketplace workflows.
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
Ollama runs locally at `http://localhost:11434` by default. This app talks to Ollama's native chat API with tool schemas, then executes approved UEX calls in the FastAPI backend.
|
Ollama runs locally at `http://localhost:11434` by default. This app can talk to Ollama's native chat API, DeepSeek's OpenAI-compatible Chat Completions API, OpenAI's Chat Completions API, or the local Codex App Server authenticated through ChatGPT/Codex OAuth, then executes approved UEX calls in the FastAPI backend. `OLLAMA_NUM_CTX` controls the per-request Ollama context window; `64512` is the default because Ollama recommends at least 64k tokens for agent-style workflows when hardware allows it. DeepSeek context caching is provider-side and automatic when repeated prompt prefixes line up.
|
||||||
|
|
||||||
|
## Releases And Updates
|
||||||
|
|
||||||
|
Change the app version before cutting a release:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\set_version.ps1 0.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a Gitea release with a matching tag such as `v0.2.0`. The release workflow builds `dist\TraderAI.exe` and attaches only that exe to the release.
|
||||||
|
|
||||||
|
The desktop app can check `https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases` from Settings > Updates. When a newer release has a `TraderAI.exe` attachment, the packaged app downloads it to the user app data update folder, exits, replaces the current exe, and relaunches.
|
||||||
|
|
||||||
UEX marketplace posting and negotiation messages are guarded because they are account-affecting write actions. The model can draft them, but the UI approval button performs the final API call.
|
UEX marketplace posting and negotiation messages are guarded because they are account-affecting write actions. The model can draft them, but the UI approval button performs the final API call.
|
||||||
|
|
||||||
The assistant gets runtime context on every chat: current date/time, authenticated UEX identity when credentials are configured, remembered user profile, last interaction time, relevant memories, and recent conversation excerpts. Memory is stored locally at `TRADERAI_MEMORY_PATH`.
|
The assistant gets runtime context on every chat: current date/time, authenticated UEX identity when credentials are configured, remembered user profile, last interaction time, relevant memories, and recent conversation excerpts. It is instructed to prefer open/current marketplace data, avoid historical sale information unless explicitly requested, and treat UEX prices as in-game aUEC/UEC credits rather than real-world dollars. Memory is stored locally at `TRADERAI_MEMORY_PATH`.
|
||||||
|
|
||||||
Wake jobs can be created from chat, for example:
|
Wake jobs can be created from chat, for example:
|
||||||
|
|
||||||
@@ -53,13 +74,18 @@ Every day at 9 AM, wake up and check whether I have marketplace followups.
|
|||||||
|
|
||||||
The scheduler accepts one-time ISO datetimes and five-field cron expressions through the `schedule_wake_job` tool. When a wake job fires, the assistant receives context like the current time and last interaction time, then places its response into the UI notification queue.
|
The scheduler accepts one-time ISO datetimes and five-field cron expressions through the `schedule_wake_job` tool. When a wake job fires, the assistant receives context like the current time and last interaction time, then places its response into the UI notification queue.
|
||||||
|
|
||||||
|
UEX notifications are checked every `UEX_NOTIFICATION_POLL_SECONDS` seconds by default. New unread notifications are deduplicated locally, then displayed in the chat through the same notification queue used by wake jobs.
|
||||||
|
|
||||||
## Sources Used
|
## Sources Used
|
||||||
|
|
||||||
- UEX SwaggerHub OpenAPI v2.1: https://app.swaggerhub.com/apis-docs/dolejska-daniel/UEX-API/v2.1
|
- UEX SwaggerHub OpenAPI v2.1: https://app.swaggerhub.com/apis-docs/dolejska-daniel/UEX-API/v2.1
|
||||||
|
- SCMDB mission data: https://scmdb.net/
|
||||||
|
- Cornerstone Universal Item Finder: https://finder.cstone.space/
|
||||||
- UEX marketplace listings docs: https://uexcorp.space/api/documentation/id/get_marketplace_listings/?is_kiosk=1
|
- UEX marketplace listings docs: https://uexcorp.space/api/documentation/id/get_marketplace_listings/?is_kiosk=1
|
||||||
- UEX negotiation message docs: https://uexcorp.space/api/documentation/id/post_marketplace_negotiations_messages/?is_kiosk=1
|
- UEX negotiation message docs: https://uexcorp.space/api/documentation/id/post_marketplace_negotiations_messages/?is_kiosk=1
|
||||||
- Ollama tool calling docs: https://docs.ollama.com/capabilities/tool-calling
|
- Ollama tool calling docs: https://docs.ollama.com/capabilities/tool-calling
|
||||||
- Ollama API streaming/tool-call reference: https://github.com/ollama/ollama/blob/main/docs/api.md
|
- Ollama API streaming/tool-call reference: https://github.com/ollama/ollama/blob/main/docs/api.md
|
||||||
|
- Ollama context length docs: https://docs.ollama.com/context-length
|
||||||
- SQLite FTS5 docs: https://www.sqlite.org/fts5.html
|
- SQLite FTS5 docs: https://www.sqlite.org/fts5.html
|
||||||
- APScheduler AsyncIO scheduler docs: https://apscheduler.readthedocs.io/en/stable/modules/schedulers/asyncio.html
|
- APScheduler AsyncIO scheduler docs: https://apscheduler.readthedocs.io/en/stable/modules/schedulers/asyncio.html
|
||||||
- Letta/MemGPT memory hierarchy background: https://docs.letta.com/concepts/letta
|
- Letta/MemGPT memory hierarchy background: https://docs.letta.com/concepts/letta
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
Add an option to use API: marketplace_trends as a tool for the AI from UEX
|
||||||
|
Closed negotiations (not open): All other negotiations show date_closed > 0 or were marked as closed by client or Riggs0. find a more relaiable way to mark them as closed/open negotations for the AI
|
||||||
|
some times when having a long tool call the assiant will not reply what it did/an awnser see ---
|
||||||
|
|
||||||
|
2026-05-06T19:41:17.786390+00:00 assistant
|
||||||
|
Looking at the historical price data for **Wikelo Favor**, here's what happened recently:
|
||||||
|
|
||||||
|
## Recent Historical Price Activity (Last Few Hours)
|
||||||
|
|
||||||
|
**Sell Orders (asking prices):**
|
||||||
|
- **1,500,000,000 UEC** (two listings at the same time)
|
||||||
|
- **1,000,000,000 UEC** (one listing)
|
||||||
|
|
||||||
|
**Buy Orders (bidding prices):**
|
||||||
|
- **300,000,000 UEC** (multiple buy orders)
|
||||||
|
|
||||||
|
**Current Active Listings:**
|
||||||
|
- **500,000,000 UEC** per unit (9 in stock)
|
||||||
|
- **1,000,000,000 UEC** per stack (5 in stock)
|
||||||
|
|
||||||
|
## Price Range Analysis
|
||||||
|
|
||||||
|
The market has been fluctuating significantly:
|
||||||
|
- **Lowest recent price:** 300M UEC (buy orders)
|
||||||
|
- **Highest recent price:** 1.5B UEC (sell orders)
|
||||||
|
- **Current active listings:** 500M - 1B UEC
|
||||||
|
|
||||||
|
**What This Means for Pricing:**
|
||||||
|
If you're looking to sell your Wikelo Favors competitively, I'd recommend pricing between **500M - 800M UEC** to match the current market, which is more realistic than the high-end 1.5B UEC listings. The 300M UEC buy orders suggest that's where many players are willing to bid, so anything between 300M-500M UEC might actually sell quickly.
|
||||||
|
|
||||||
|
Would you like me to search for more specific listings or help you create a listing at a particular price point?
|
||||||
|
2026-05-06T19:40:51.966297+00:00 user
|
||||||
|
What happened
|
||||||
|
2026-05-06T19:38:27.178306+00:00 assistant
|
||||||
|
2026-05-06T19:37:52.825713+00:00 user
|
||||||
|
I want you to use the history tools and show me what they were selling for recently so I know what to price mine at
|
||||||
|
|
||||||
|
|
||||||
|
----
|
||||||
|
I want the user to beable to see and directly respond to negotations in the app seperate the ai so it should work like this, when a negotation is refrenced the user should be able to click on it and a floating block wiht the chat messages and an area for the user to type one to the other party,
|
||||||
|
|
||||||
|
I want the message box for the user to be forced to the bottom of the viewport, right now when their are no messages if moves up to the top then its sticky on the bottom when messages happen
|
||||||
|
|
||||||
|
I want the users messages to right aligned and not have abunch of empty space to the right or left of it.
|
||||||
|
|
||||||
|
when I approve messages they dont send I see
|
||||||
|
|
||||||
|
Approval result:
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
"status": "ok",
|
||||||
|
|
||||||
|
"http_code": 200,
|
||||||
|
|
||||||
|
"data": {
|
||||||
|
|
||||||
|
"id_message": "0"
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
"message": "Sandbox mode"
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
and an error that a python dll is missing from the exe.
|
||||||
|
|
||||||
|
|
||||||
|
I want a default collapsed sidebar that has chats, they are to be like chat gpt chats where memories persist across them and the chat history is confied to them. I want the users to beable to delete them with a trash can icon, and I want them saved to disk.
|
||||||
|
|
||||||
|
|
||||||
|
the wake jobs may not be working properly, I scehudled one and
|
||||||
|
I see I'll check your UEX notifications and handle your open negotiations now!
|
||||||
|
but I dont see an output for this job. to fix this I want to have an Inbox that the AI can send messages to. It should be the output of the wake job and have the ablity to continue the coversation in a new chat.
|
||||||
|
|
||||||
|
make a popup that prompts a user to update on open when a new update is avaible.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
|
from PyInstaller.utils.hooks import collect_all
|
||||||
|
import sysconfig
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
webview_datas, webview_binaries, webview_hiddenimports = collect_all("webview")
|
||||||
|
|
||||||
|
python_binaries = []
|
||||||
|
for dll_name in ("python3.dll", f"python{sysconfig.get_python_version().replace('.', '')}.dll"):
|
||||||
|
dll_path = Path(sysconfig.get_config_var("BINDIR") or "") / dll_name
|
||||||
|
if dll_path.exists():
|
||||||
|
python_binaries.append((str(dll_path), "."))
|
||||||
|
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
["traderai\\desktop.py"],
|
||||||
|
pathex=[],
|
||||||
|
binaries=[*webview_binaries, *python_binaries],
|
||||||
|
datas=[("web", "web"), *webview_datas],
|
||||||
|
hiddenimports=[
|
||||||
|
*webview_hiddenimports,
|
||||||
|
"uvicorn.logging",
|
||||||
|
"uvicorn.loops",
|
||||||
|
"uvicorn.loops.auto",
|
||||||
|
"uvicorn.protocols",
|
||||||
|
"uvicorn.protocols.http",
|
||||||
|
"uvicorn.protocols.http.auto",
|
||||||
|
"uvicorn.protocols.websockets",
|
||||||
|
"uvicorn.protocols.websockets.auto",
|
||||||
|
"uvicorn.lifespan",
|
||||||
|
"uvicorn.lifespan.on",
|
||||||
|
],
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=[],
|
||||||
|
noarchive=False,
|
||||||
|
optimize=0,
|
||||||
|
)
|
||||||
|
pyz = PYZ(a.pure)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name="TraderAI.Debug",
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=True,
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
icon="web\\art\\LBC_Logo.ico",
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
|
from PyInstaller.utils.hooks import collect_all
|
||||||
|
import sysconfig
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
webview_datas, webview_binaries, webview_hiddenimports = collect_all("webview")
|
||||||
|
|
||||||
|
python_binaries = []
|
||||||
|
for dll_name in ("python3.dll", f"python{sysconfig.get_python_version().replace('.', '')}.dll"):
|
||||||
|
dll_path = Path(sysconfig.get_config_var("BINDIR") or "") / dll_name
|
||||||
|
if dll_path.exists():
|
||||||
|
python_binaries.append((str(dll_path), "."))
|
||||||
|
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
["traderai\\desktop.py"],
|
||||||
|
pathex=[],
|
||||||
|
binaries=[*webview_binaries, *python_binaries],
|
||||||
|
datas=[("web", "web"), *webview_datas],
|
||||||
|
hiddenimports=[
|
||||||
|
*webview_hiddenimports,
|
||||||
|
"uvicorn.logging",
|
||||||
|
"uvicorn.loops",
|
||||||
|
"uvicorn.loops.auto",
|
||||||
|
"uvicorn.protocols",
|
||||||
|
"uvicorn.protocols.http",
|
||||||
|
"uvicorn.protocols.http.auto",
|
||||||
|
"uvicorn.protocols.websockets",
|
||||||
|
"uvicorn.protocols.websockets.auto",
|
||||||
|
"uvicorn.lifespan",
|
||||||
|
"uvicorn.lifespan.on",
|
||||||
|
],
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=[],
|
||||||
|
noarchive=False,
|
||||||
|
optimize=0,
|
||||||
|
)
|
||||||
|
pyz = PYZ(a.pure)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name="TraderAI",
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=False,
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
icon="web\\art\\LBC_Logo.ico",
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
)
|
||||||
+22
-2
@@ -1,12 +1,13 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "traderai"
|
name = "traderai"
|
||||||
version = "0.1.0"
|
version = "0.0.9"
|
||||||
description = "Local Ollama-powered assistant for UEX marketplace workflows."
|
description = "Local Ollama, OpenAI, or Codex assistant for UEX marketplace workflows."
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"apscheduler>=3.10.4",
|
"apscheduler>=3.10.4",
|
||||||
"fastapi>=0.115.0",
|
"fastapi>=0.115.0",
|
||||||
"httpx>=0.27.0",
|
"httpx>=0.27.0",
|
||||||
|
"pywebview>=5.4",
|
||||||
"pydantic>=2.8.0",
|
"pydantic>=2.8.0",
|
||||||
"pydantic-settings>=2.4.0",
|
"pydantic-settings>=2.4.0",
|
||||||
"python-dotenv>=1.0.1",
|
"python-dotenv>=1.0.1",
|
||||||
@@ -16,11 +17,30 @@ dependencies = [
|
|||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
"pyinstaller>=6.11.0",
|
||||||
"pytest>=8.3.0",
|
"pytest>=8.3.0",
|
||||||
"pytest-asyncio>=0.23.8",
|
"pytest-asyncio>=0.23.8",
|
||||||
"respx>=0.21.1",
|
"respx>=0.21.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
traderai-desktop = "traderai.desktop:main"
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
pythonpath = ["."]
|
pythonpath = ["."]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["traderai*"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$Root = Split-Path -Parent $PSScriptRoot
|
||||||
|
Set-Location $Root
|
||||||
|
|
||||||
|
$Python = Join-Path $Root ".venv\Scripts\python.exe"
|
||||||
|
if (-not (Test-Path $Python)) {
|
||||||
|
$Python = "python"
|
||||||
|
}
|
||||||
|
|
||||||
|
& $Python -m ensurepip --upgrade
|
||||||
|
& $Python -m pip install -e ".[dev]"
|
||||||
|
& $Python -m PyInstaller --clean "TraderAI.spec"
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Built dist\TraderAI.exe"
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidatePattern('^v?\d+\.\d+\.\d+([-.+][0-9A-Za-z.-]+)?$')]
|
||||||
|
[string]$Version
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$repoRoot = Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")
|
||||||
|
$cleanVersion = $Version.TrimStart("v")
|
||||||
|
|
||||||
|
$pyprojectPath = Join-Path $repoRoot "pyproject.toml"
|
||||||
|
$versionPath = Join-Path $repoRoot "traderai\version.py"
|
||||||
|
$lockPath = Join-Path $repoRoot "uv.lock"
|
||||||
|
|
||||||
|
$pyproject = Get-Content -LiteralPath $pyprojectPath -Raw
|
||||||
|
$pyproject = $pyproject -replace '(?m)^version = "[^"]+"', "version = `"$cleanVersion`""
|
||||||
|
Set-Content -LiteralPath $pyprojectPath -Value $pyproject -Encoding UTF8
|
||||||
|
|
||||||
|
$versionModule = Get-Content -LiteralPath $versionPath -Raw
|
||||||
|
$versionModule = $versionModule -replace '__version__ = "[^"]+"', "__version__ = `"$cleanVersion`""
|
||||||
|
Set-Content -LiteralPath $versionPath -Value $versionModule -Encoding UTF8
|
||||||
|
|
||||||
|
if (Test-Path -LiteralPath $lockPath) {
|
||||||
|
$lock = Get-Content -LiteralPath $lockPath -Raw
|
||||||
|
$lock = $lock -replace '(?s)(name = "traderai"\s+version = ")[^"]+(")', "`${1}$cleanVersion`${2}"
|
||||||
|
Set-Content -LiteralPath $lockPath -Value $lock -Encoding UTF8
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "TraderAI version set to $cleanVersion"
|
||||||
+354
-1
@@ -1,6 +1,8 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
import asyncio
|
||||||
|
import itertools
|
||||||
|
|
||||||
from traderai.agent import OllamaAgent
|
from traderai.agent import OllamaAgent, SYSTEM_PROMPT
|
||||||
from traderai.memory import MemoryStore
|
from traderai.memory import MemoryStore
|
||||||
|
|
||||||
|
|
||||||
@@ -12,6 +14,130 @@ class EmptyTools:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class WakeTools(EmptyTools):
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def execute(self, name, arguments):
|
||||||
|
self.calls.append((name, arguments))
|
||||||
|
return {"count": 1, "notifications": [{"message": "Buyer replied"}]}
|
||||||
|
|
||||||
|
|
||||||
|
class WakeAgent(OllamaAgent):
|
||||||
|
def __init__(self, memory):
|
||||||
|
super().__init__("http://127.0.0.1:1", "missing-model", WakeTools(), memory=memory)
|
||||||
|
self.responses = [
|
||||||
|
{
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"function": {
|
||||||
|
"name": "check_uex_notifications",
|
||||||
|
"arguments": {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{"message": {"role": "assistant", "content": "I checked notifications: Buyer replied."}},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def ensure_available(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _ollama_chat(self, *args, **kwargs):
|
||||||
|
return self.responses.pop(0)
|
||||||
|
|
||||||
|
|
||||||
|
class TitleAgent(OllamaAgent):
|
||||||
|
def __init__(self, memory):
|
||||||
|
super().__init__("http://127.0.0.1:1", "missing-model", EmptyTools(), memory=memory)
|
||||||
|
|
||||||
|
async def ensure_available(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _generate_chat_title(self, first_message):
|
||||||
|
return "UEX Market Check"
|
||||||
|
|
||||||
|
async def _ollama_chat(self, *args, **kwargs):
|
||||||
|
return {"message": {"role": "assistant", "content": "Done"}}
|
||||||
|
|
||||||
|
|
||||||
|
class ImageCaptureAgent(OllamaAgent):
|
||||||
|
def __init__(self, memory):
|
||||||
|
super().__init__("http://127.0.0.1:1", "missing-model", EmptyTools(), memory=memory)
|
||||||
|
self.last_messages = None
|
||||||
|
|
||||||
|
async def ensure_available(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _chat_once(self, query="", messages=None, **kwargs):
|
||||||
|
self.last_messages = messages
|
||||||
|
return {"message": {"role": "assistant", "content": "Seen"}}
|
||||||
|
|
||||||
|
|
||||||
|
class SlowToolTools(EmptyTools):
|
||||||
|
schemas = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "slow_tool",
|
||||||
|
"description": "Slow fake tool.",
|
||||||
|
"parameters": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
async def execute(self, name, arguments):
|
||||||
|
self.calls += 1
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
return {"status": "ok", "value": "slow result"}
|
||||||
|
|
||||||
|
|
||||||
|
class SlowStreamingAgent(OllamaAgent):
|
||||||
|
def __init__(self, memory):
|
||||||
|
super().__init__("http://127.0.0.1:1", "missing-model", SlowToolTools(), memory=memory)
|
||||||
|
self.stream_calls = 0
|
||||||
|
|
||||||
|
async def health(self):
|
||||||
|
return {"online": True, "model": "test", "base_url": self.base_url}
|
||||||
|
|
||||||
|
async def _ollama_chat_stream(self, *args, **kwargs):
|
||||||
|
self.stream_calls += 1
|
||||||
|
if self.stream_calls == 1:
|
||||||
|
yield {
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [{"function": {"name": "slow_tool", "arguments": {}}}],
|
||||||
|
},
|
||||||
|
"done": True,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
yield {"message": {"role": "assistant", "content": ""}, "done": True}
|
||||||
|
|
||||||
|
|
||||||
|
class FailingAfterToolAgent(SlowStreamingAgent):
|
||||||
|
async def _ollama_chat_stream(self, *args, **kwargs):
|
||||||
|
self.stream_calls += 1
|
||||||
|
if self.stream_calls == 1:
|
||||||
|
yield {
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [{"function": {"name": "slow_tool", "arguments": {}}}],
|
||||||
|
},
|
||||||
|
"done": True,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
raise RuntimeError("ollama timed out")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_chat_events_warns_when_ollama_offline():
|
async def test_chat_events_warns_when_ollama_offline():
|
||||||
agent = OllamaAgent("http://127.0.0.1:1", "missing-model", EmptyTools())
|
agent = OllamaAgent("http://127.0.0.1:1", "missing-model", EmptyTools())
|
||||||
@@ -77,3 +203,230 @@ def test_stream_metrics_include_reading_and_writing_rates():
|
|||||||
assert metrics["reading_tokens_per_second"] == 10
|
assert metrics["reading_tokens_per_second"] == 10
|
||||||
assert metrics["writing_tokens"] == 30
|
assert metrics["writing_tokens"] == 30
|
||||||
assert metrics["writing_tokens_per_second"] == 10
|
assert metrics["writing_tokens_per_second"] == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_prompt_prefers_current_marketplace_data():
|
||||||
|
assert "open/current" in SYSTEM_PROMPT
|
||||||
|
assert "Do not use historical sale data" in SYSTEM_PROMPT
|
||||||
|
assert "aUEC/UEC credits" in SYSTEM_PROMPT
|
||||||
|
assert "never real-world dollars" in SYSTEM_PROMPT
|
||||||
|
|
||||||
|
|
||||||
|
def test_ollama_options_include_num_ctx():
|
||||||
|
agent = OllamaAgent("http://127.0.0.1:1", "missing-model", EmptyTools(), num_ctx=64000)
|
||||||
|
|
||||||
|
assert agent._ollama_options() == {"num_ctx": 64000}
|
||||||
|
|
||||||
|
|
||||||
|
def test_deepseek_tool_rounds_are_not_capped_at_ten():
|
||||||
|
agent = OllamaAgent("https://api.deepseek.com", "deepseek-v4-flash", EmptyTools(), provider="deepseek", api_key="test")
|
||||||
|
|
||||||
|
rounds = list(itertools.islice(agent._tool_rounds(), 12))
|
||||||
|
|
||||||
|
assert len(rounds) == 12
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_draft_normalization_extracts_json_and_defaults():
|
||||||
|
seed = {"title": "Wikelo Polaris", "objective": "Find parts", "kind": "buying", "constraints": {}, "items": []}
|
||||||
|
raw = 'draft:\n{"title":"Wikelo Polaris Parts","objective":"Find and draft deals for the parts below","kind":"buying","cadence":"0 */3 * * *","constraints":{"message_tone":"casual","instructions":"Prioritize cheap listings first."},"items":[{"item_name":"RCMBNT-RGL-1","desired_quantity":2}]}'
|
||||||
|
|
||||||
|
draft = OllamaAgent._normalize_plan_draft(raw, seed)
|
||||||
|
|
||||||
|
assert draft["title"] == "Wikelo Polaris Parts"
|
||||||
|
assert draft["cadence"] == "0 */3 * * *"
|
||||||
|
assert draft["constraints"]["message_tone"] == "casual"
|
||||||
|
assert draft["items"][0]["item_name"] == "RCMBNT-RGL-1"
|
||||||
|
assert draft["items"][0]["desired_quantity"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_draft_heuristic_fills_in_basic_instructions():
|
||||||
|
seed = {"title": "Watch open negotiations", "objective": "", "kind": "custom", "constraints": {}, "items": []}
|
||||||
|
|
||||||
|
draft = OllamaAgent._heuristic_plan_draft(seed)
|
||||||
|
|
||||||
|
assert draft["kind"] == "custom"
|
||||||
|
assert draft["cadence"] == "0 */4 * * *"
|
||||||
|
assert "summarize" in draft["constraints"]["instructions"].casefold()
|
||||||
|
assert draft["constraints"]["message_tone"] == "friendly and direct"
|
||||||
|
|
||||||
|
|
||||||
|
def test_codex_prompt_mentions_tools_and_images(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
agent = OllamaAgent("codex", "gpt-5.3-codex", EmptyTools(), memory=memory, provider="codex")
|
||||||
|
|
||||||
|
prompt = agent._codex_cli_prompt(
|
||||||
|
"check listing",
|
||||||
|
[
|
||||||
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Look at this",
|
||||||
|
"images": ["ZmFrZQ=="],
|
||||||
|
"image_content_types": ["image/png"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "call_123",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "search_marketplace_listings", "arguments": "{\"commodity\":\"gold\"}"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"tool_name": "search_marketplace_listings",
|
||||||
|
"tool_call_id": "call_123",
|
||||||
|
"content": "{\"ok\":true}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Available tools" in prompt
|
||||||
|
assert "attached images: 1" in prompt
|
||||||
|
assert "search_marketplace_listings" in prompt
|
||||||
|
assert "tool search_marketplace_listings" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_deepseek_openai_messages_include_reasoning_content_for_tool_turns():
|
||||||
|
agent = OllamaAgent("https://api.deepseek.com", "deepseek-v4-flash", EmptyTools(), provider="deepseek", api_key="test")
|
||||||
|
|
||||||
|
messages = agent._openai_messages(
|
||||||
|
"check listing",
|
||||||
|
[
|
||||||
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": "Check this listing"},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"reasoning_content": "I should check the current listing first.",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "call_123",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "search_marketplace_listings", "arguments": "{\"query\":\"panel\"}"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{"role": "tool", "tool_name": "search_marketplace_listings", "tool_call_id": "call_123", "content": "{\"ok\":true}"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assistant_turn = next(message for message in messages if message["role"] == "assistant")
|
||||||
|
assert assistant_turn["reasoning_content"] == "I should check the current listing first."
|
||||||
|
|
||||||
|
|
||||||
|
def test_codex_structured_response_extracts_text_and_tool_calls():
|
||||||
|
agent = OllamaAgent("codex", "gpt-5.3-codex", EmptyTools(), provider="codex")
|
||||||
|
|
||||||
|
result = agent._codex_structured_response(
|
||||||
|
{
|
||||||
|
"kind": "tool_call",
|
||||||
|
"message": "",
|
||||||
|
"tool_name": "search_marketplace_listings",
|
||||||
|
"arguments_json": "{\"commodity\":\"gold\"}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["message"]["content"] == ""
|
||||||
|
assert result["message"]["tool_calls"] == [
|
||||||
|
{
|
||||||
|
"id": result["message"]["tool_calls"][0]["id"],
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "search_marketplace_listings",
|
||||||
|
"arguments": "{\"commodity\":\"gold\"}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_codex_exec_output_reads_final_json():
|
||||||
|
agent = OllamaAgent("codex", "gpt-5.3-codex", EmptyTools(), provider="codex")
|
||||||
|
|
||||||
|
result = agent._parse_codex_exec_output(
|
||||||
|
{
|
||||||
|
"returncode": 0,
|
||||||
|
"stdout": "",
|
||||||
|
"stderr": "",
|
||||||
|
"events": [
|
||||||
|
{"type": "thread.started", "thread_id": "abc"},
|
||||||
|
{"type": "item.completed", "item": {"type": "agent_message", "text": "{\"kind\":\"final\",\"message\":\"hello\",\"tool_name\":\"\",\"arguments_json\":\"{}\"}"}},
|
||||||
|
{"type": "turn.completed"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {"kind": "final", "message": "hello", "tool_name": "", "arguments_json": "{}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wake_response_executes_tool_calls(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
agent = WakeAgent(memory)
|
||||||
|
|
||||||
|
response = await agent.generate_wake_response("Scheduled wake job fired. Check notifications.")
|
||||||
|
|
||||||
|
assert response == "I checked notifications: Buyer replied."
|
||||||
|
assert agent.tools.calls == [("check_uex_notifications", {})]
|
||||||
|
wake_rows = memory.recent_conversation(thread_id="wake")
|
||||||
|
assert wake_rows[-1]["content"] == "I checked notifications: Buyer replied."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_first_chat_message_generates_thread_title(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
thread = memory.create_thread()
|
||||||
|
agent = TitleAgent(memory)
|
||||||
|
|
||||||
|
result = await agent.chat("Check UEX market listings", thread_id=thread["id"])
|
||||||
|
|
||||||
|
assert result["message"] == "Done"
|
||||||
|
assert memory.get_thread(thread["id"])["title"] == "UEX Market Check"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_includes_pasted_images_and_memory_note(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
agent = ImageCaptureAgent(memory)
|
||||||
|
|
||||||
|
result = await agent.chat(
|
||||||
|
"",
|
||||||
|
images=[{"name": "listing.png", "content_type": "image/png", "image_data": "ZmFrZS1pbWFnZQ=="}],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["message"] == "Seen"
|
||||||
|
user_message = next(message for message in reversed(agent.last_messages) if message.get("role") == "user")
|
||||||
|
assert user_message["images"] == ["ZmFrZS1pbWFnZQ=="]
|
||||||
|
assert user_message["content"] == "Please analyze the attached image."
|
||||||
|
assert "[Attached 1 pasted image]" in memory.recent_conversation()[-2]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_events_returns_fallback_after_slow_tool_and_empty_final_response(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
agent = SlowStreamingAgent(memory)
|
||||||
|
|
||||||
|
events = [event async for event in agent.chat_events("run a slow tool")]
|
||||||
|
text = "".join(event.get("content", "") for event in events if event["type"] == "token")
|
||||||
|
|
||||||
|
assert agent.tools.calls == 1
|
||||||
|
assert "I completed the tool call" in text
|
||||||
|
assert "slow result" in text
|
||||||
|
assert events[-1]["type"] == "done"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_events_returns_tool_result_when_model_fails_after_slow_tool(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
agent = FailingAfterToolAgent(memory)
|
||||||
|
|
||||||
|
events = [event async for event in agent.chat_events("run a slow tool")]
|
||||||
|
text = "".join(event.get("content", "") for event in events if event["type"] == "token")
|
||||||
|
|
||||||
|
assert agent.tools.calls == 1
|
||||||
|
assert "local model stopped after the tool call" in text
|
||||||
|
assert "slow result" in text
|
||||||
|
assert events[-1]["type"] == "done"
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from traderai.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_provider_codex_falls_back_to_ollama():
|
||||||
|
settings = Settings(model_provider="codex")
|
||||||
|
|
||||||
|
assert settings.model_provider == "ollama"
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_provider_openai_falls_back_to_ollama():
|
||||||
|
settings = Settings(model_provider="openai")
|
||||||
|
|
||||||
|
assert settings.model_provider == "ollama"
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_provider_accepts_deepseek():
|
||||||
|
settings = Settings(model_provider="deepseek")
|
||||||
|
|
||||||
|
assert settings.model_provider == "deepseek"
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_provider_invalid_value_falls_back_to_ollama():
|
||||||
|
settings = Settings(model_provider="something-else")
|
||||||
|
|
||||||
|
assert settings.model_provider == "ollama"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reasoning_effort_normalizes_invalid_values():
|
||||||
|
settings = Settings(model_reasoning_effort="whatever")
|
||||||
|
|
||||||
|
assert settings.model_reasoning_effort == "medium"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reasoning_effort_accepts_supported_values():
|
||||||
|
settings = Settings(model_reasoning_effort="high")
|
||||||
|
|
||||||
|
assert settings.model_reasoning_effort == "high"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reasoning_effort_accepts_max():
|
||||||
|
settings = Settings(model_reasoning_effort="max")
|
||||||
|
|
||||||
|
assert settings.model_reasoning_effort == "max"
|
||||||
@@ -25,3 +25,52 @@ def test_memory_store_clear_selected_sections(tmp_path):
|
|||||||
assert snapshot["memories"] == []
|
assert snapshot["memories"] == []
|
||||||
assert snapshot["conversations"] == []
|
assert snapshot["conversations"] == []
|
||||||
assert snapshot["profile"][0]["key"] == "configured_name"
|
assert snapshot["profile"][0]["key"] == "configured_name"
|
||||||
|
|
||||||
|
|
||||||
|
def test_memory_store_separates_chat_threads_but_keeps_shared_memories(tmp_path):
|
||||||
|
store = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
first = store.create_thread("First")
|
||||||
|
second = store.create_thread("Second")
|
||||||
|
store.add_conversation("user", "first thread message", first["id"])
|
||||||
|
store.add_conversation("user", "second thread message", second["id"])
|
||||||
|
store.remember("preference", "Shared trading preference", importance=5)
|
||||||
|
|
||||||
|
first_rows = store.recent_conversation(thread_id=first["id"])
|
||||||
|
second_rows = store.recent_conversation(thread_id=second["id"])
|
||||||
|
|
||||||
|
assert [row["content"] for row in first_rows] == ["first thread message"]
|
||||||
|
assert [row["content"] for row in second_rows] == ["second thread message"]
|
||||||
|
assert store.recall("trading preference")[0]["content"] == "Shared trading preference"
|
||||||
|
|
||||||
|
|
||||||
|
def test_memory_store_renames_threads_and_deletes_outbox_items(tmp_path):
|
||||||
|
store = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
thread = store.create_thread("New chat")
|
||||||
|
store.add_outbox("Wake job result")
|
||||||
|
inbox_id = store.list_outbox()[0]["id"]
|
||||||
|
|
||||||
|
renamed = store.rename_thread(thread["id"], " Market Check ")
|
||||||
|
deleted = store.delete_outbox(inbox_id)
|
||||||
|
|
||||||
|
assert renamed["title"] == "Market Check"
|
||||||
|
assert deleted is True
|
||||||
|
assert store.list_outbox() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_memory_store_uses_absolute_path_across_working_directory_changes(tmp_path, monkeypatch):
|
||||||
|
original_cwd = tmp_path / "start"
|
||||||
|
original_cwd.mkdir()
|
||||||
|
monkeypatch.chdir(original_cwd)
|
||||||
|
|
||||||
|
store = MemoryStore("data/memory.sqlite3")
|
||||||
|
|
||||||
|
moved_cwd = tmp_path / "moved"
|
||||||
|
moved_cwd.mkdir()
|
||||||
|
monkeypatch.chdir(moved_cwd)
|
||||||
|
|
||||||
|
store.add_outbox("Notification survived cwd change")
|
||||||
|
|
||||||
|
snapshot = store.inspect()
|
||||||
|
|
||||||
|
assert store.path.is_absolute()
|
||||||
|
assert snapshot["outbox"][0]["content"] == "Notification survived cwd change"
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from traderai.memory import MemoryStore
|
||||||
|
from traderai.negotiations import NegotiationSyncService, extract_negotiation_hash
|
||||||
|
from traderai.tools import ToolRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class FakeNegotiationUEX:
|
||||||
|
def __init__(self):
|
||||||
|
self.list_calls = []
|
||||||
|
self.message_calls = []
|
||||||
|
self.posts = []
|
||||||
|
|
||||||
|
async def list_negotiations(self, id=None, id_listing=None, hash=None):
|
||||||
|
self.list_calls.append({"id": id, "id_listing": id_listing, "hash": hash})
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"hash": "open-hash",
|
||||||
|
"id_listing": 101,
|
||||||
|
"listing_slug": "rgl-open",
|
||||||
|
"listing_title": "RGL Set",
|
||||||
|
"advertiser_username": "seller_a",
|
||||||
|
"client_username": "pilot_hudson",
|
||||||
|
"date_modified": 1_780_975_053,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"hash": "closed-recent",
|
||||||
|
"id_listing": 102,
|
||||||
|
"listing_slug": "rgl-closed",
|
||||||
|
"listing_title": "Closed Deal",
|
||||||
|
"advertiser_username": "seller_b",
|
||||||
|
"client_username": "pilot_hudson",
|
||||||
|
"date_modified": 1_780_975_053,
|
||||||
|
"date_closed": 1_780_975_054,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if hash:
|
||||||
|
data = [item for item in data if item["hash"] == hash]
|
||||||
|
return {"status": "ok", "negotiations": data}
|
||||||
|
|
||||||
|
async def get_negotiation_messages(self, hash=None, id_negotiation=None):
|
||||||
|
self.message_calls.append({"hash": hash, "id_negotiation": id_negotiation})
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"id": 201,
|
||||||
|
"negotiation_hash": hash,
|
||||||
|
"user_username": "seller_a" if hash == "open-hash" else "seller_b",
|
||||||
|
"user_name": "Seller",
|
||||||
|
"message": "Still available.",
|
||||||
|
"date_added": 1_780_975_053,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def send_negotiation_message(self, **payload):
|
||||||
|
self.posts.append({"kind": "message", **payload})
|
||||||
|
return {"status": "ok", "posted": self.posts[-1]}
|
||||||
|
|
||||||
|
async def close_negotiation(self, **payload):
|
||||||
|
self.posts.append({"kind": "close", **payload})
|
||||||
|
return {"status": "ok", "posted": self.posts[-1]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_negotiation_hash_handles_uex_redirects():
|
||||||
|
assert extract_negotiation_hash("https://uexcorp.space/marketplace/negotiate/hash/abc-123") == "abc-123"
|
||||||
|
assert extract_negotiation_hash("/marketplace/negotiate/hash/def-456") == "def-456"
|
||||||
|
assert extract_negotiation_hash("/marketplace/item/info/foo") is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_startup_sync_keeps_open_and_recent_threads(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
memory.set_profile("uex_user", {"username": "pilot_hudson"})
|
||||||
|
service = NegotiationSyncService(memory, FakeNegotiationUEX())
|
||||||
|
|
||||||
|
result = await service.startup_sync()
|
||||||
|
negotiations = memory.list_negotiations(limit=10)
|
||||||
|
|
||||||
|
assert result["count"] == 2
|
||||||
|
assert {item["hash"] for item in negotiations} == {"open-hash", "closed-recent"}
|
||||||
|
detail = memory.get_negotiation("open-hash")
|
||||||
|
assert detail["messages"][0]["body"] == "Still available."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_notification_refresh_targets_only_changed_negotiation(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
memory.set_profile("uex_user", {"username": "pilot_hudson"})
|
||||||
|
fake = FakeNegotiationUEX()
|
||||||
|
service = NegotiationSyncService(memory, fake)
|
||||||
|
await service.startup_sync()
|
||||||
|
fake.message_calls.clear()
|
||||||
|
|
||||||
|
await service.handle_notifications(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 99,
|
||||||
|
"message": "seller_a: ping",
|
||||||
|
"redir": "https://uexcorp.space/marketplace/negotiate/hash/open-hash",
|
||||||
|
"date_added": 1_780_975_060,
|
||||||
|
"date_read": 0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert fake.message_calls == [{"hash": "open-hash", "id_negotiation": None}]
|
||||||
|
assert memory.get_negotiation("open-hash")["unread_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_manual_send_refreshes_local_thread(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
memory.set_profile("uex_user", {"username": "pilot_hudson"})
|
||||||
|
fake = FakeNegotiationUEX()
|
||||||
|
service = NegotiationSyncService(memory, fake)
|
||||||
|
await service.startup_sync()
|
||||||
|
|
||||||
|
result = await service.manual_send_message("open-hash", "I can buy tonight.")
|
||||||
|
|
||||||
|
assert result["posted"]["kind"] == "message"
|
||||||
|
assert fake.message_calls[-1]["hash"] == "open-hash"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_draft_negotiation_close_creates_pending_action(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
registry = ToolRegistry(FakeNegotiationUEX(), memory=memory)
|
||||||
|
|
||||||
|
result = await registry.draft_negotiation_close(
|
||||||
|
hash="open-hash",
|
||||||
|
deal_closed=True,
|
||||||
|
deal_value=1_000_000,
|
||||||
|
currency="UEC",
|
||||||
|
clarity_rating=5,
|
||||||
|
speed_rating=5,
|
||||||
|
respect_rating=5,
|
||||||
|
fairness_rating=4,
|
||||||
|
comment="Smooth trade",
|
||||||
|
)
|
||||||
|
|
||||||
|
pending = result["pending_action"]
|
||||||
|
assert pending["endpoint"] == "marketplace_negotiations_close"
|
||||||
|
assert pending["payload"]["deal_closed"] == 1
|
||||||
|
assert pending["payload"]["is_production"] == 1
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import pytest
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from traderai.memory import MemoryStore, utc_now
|
||||||
|
from traderai.plans import ContinualPlanRunner, ContinualPlanStore
|
||||||
|
from traderai.scheduler import WakeScheduler
|
||||||
|
from traderai.tools import ToolRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class BuyingUEX:
|
||||||
|
def __init__(self):
|
||||||
|
self.posts = []
|
||||||
|
|
||||||
|
async def get(self, path, params=None, authenticated=False):
|
||||||
|
if path == "marketplace_listings":
|
||||||
|
return {
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 501,
|
||||||
|
"slug": "wikelo-panel-good",
|
||||||
|
"title": "Wikelo Idris panel",
|
||||||
|
"operation": "sell",
|
||||||
|
"type": "item",
|
||||||
|
"price": 450_000,
|
||||||
|
"currency": "UEC",
|
||||||
|
"in_stock": 2,
|
||||||
|
"location": "Orison",
|
||||||
|
"user_username": "seller_a",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 502,
|
||||||
|
"slug": "wikelo-panel-expensive",
|
||||||
|
"title": "Wikelo Idris panel premium",
|
||||||
|
"operation": "sell",
|
||||||
|
"type": "item",
|
||||||
|
"price": 900_000,
|
||||||
|
"currency": "UEC",
|
||||||
|
"in_stock": 1,
|
||||||
|
"location": "Area18",
|
||||||
|
"user_username": "seller_b",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return {"data": []}
|
||||||
|
|
||||||
|
async def post(self, path, payload, authenticated=True):
|
||||||
|
self.posts.append({"path": path, "payload": payload, "authenticated": authenticated})
|
||||||
|
return {"status": "ok", "posted": self.posts[-1]}
|
||||||
|
|
||||||
|
async def delete(self, path, params=None, authenticated=True):
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
class FakePlanAgent:
|
||||||
|
def __init__(self):
|
||||||
|
self.prompts = []
|
||||||
|
|
||||||
|
async def generate_wake_response(self, wake_message):
|
||||||
|
self.prompts.append(wake_message)
|
||||||
|
return "Custom plan checked notifications and found no blockers."
|
||||||
|
|
||||||
|
|
||||||
|
def plan_stack(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
store = ContinualPlanStore(memory)
|
||||||
|
scheduler = WakeScheduler(memory)
|
||||||
|
tools = ToolRegistry(BuyingUEX(), memory=memory, scheduler=scheduler, plan_store=store)
|
||||||
|
runner = ContinualPlanRunner(store, tools, memory)
|
||||||
|
tools.plan_runner = runner
|
||||||
|
scheduler.bind_plan_runner(runner)
|
||||||
|
return memory, store, tools, runner, scheduler
|
||||||
|
|
||||||
|
|
||||||
|
def test_continual_plan_store_creates_needs_input_plan(tmp_path):
|
||||||
|
_, store, _, _, _ = plan_stack(tmp_path)
|
||||||
|
|
||||||
|
plan = store.create_plan("Wikelo Idris", objective="Get all parts", items=[])
|
||||||
|
|
||||||
|
assert plan["status"] == "needs_input"
|
||||||
|
assert plan["items"] == []
|
||||||
|
assert plan["events"][0]["kind"] == "needs_input"
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_plan_without_items_is_active(tmp_path):
|
||||||
|
_, store, _, _, _ = plan_stack(tmp_path)
|
||||||
|
|
||||||
|
plan = store.create_plan("Watch negotiations", kind="custom", objective="Check replies and summarize next steps", items=[])
|
||||||
|
|
||||||
|
assert plan["status"] == "active"
|
||||||
|
assert plan["items"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_continual_plan_store_creates_buying_checklist(tmp_path):
|
||||||
|
_, store, _, _, _ = plan_stack(tmp_path)
|
||||||
|
|
||||||
|
plan = store.create_plan(
|
||||||
|
"Wikelo Idris",
|
||||||
|
objective="Get all listed parts",
|
||||||
|
items=[{"item_name": "Wikelo Idris panel", "desired_quantity": 2, "max_unit_price": 500_000}],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert plan["status"] == "active"
|
||||||
|
assert plan["items"][0]["item_name"] == "Wikelo Idris panel"
|
||||||
|
assert plan["items"][0]["desired_quantity"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_continual_plan_store_deletes_plan_and_related_records(tmp_path):
|
||||||
|
_, store, _, _, _ = plan_stack(tmp_path)
|
||||||
|
|
||||||
|
plan = store.create_plan(
|
||||||
|
"Delete me",
|
||||||
|
objective="Remove everything",
|
||||||
|
items=[{"item_name": "Wikelo Idris panel", "desired_quantity": 1}],
|
||||||
|
)
|
||||||
|
item_id = int(plan["items"][0]["id"])
|
||||||
|
candidate = store.upsert_candidate(plan["id"], item_id, {"id": "listing-1", "title": "Panel", "price": 10}, 0.9)
|
||||||
|
store.add_negotiation(plan["id"], item_id, int(candidate["id"]), {"listing_id": "listing-1", "listing_slug": "panel", "id_negotiation": "neg-1", "hash": "hash-1"})
|
||||||
|
|
||||||
|
assert store.delete_plan(plan["id"]) is True
|
||||||
|
assert store.get_plan(plan["id"]) is None
|
||||||
|
assert store.list_items(plan["id"]) == []
|
||||||
|
assert store.list_candidates(plan["id"]) == []
|
||||||
|
assert store.list_negotiations(plan["id"]) == []
|
||||||
|
assert store.list_events(plan["id"]) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_buying_runner_tracks_candidates_and_drafts_only(tmp_path):
|
||||||
|
memory, store, tools, runner, _ = plan_stack(tmp_path)
|
||||||
|
plan = store.create_plan(
|
||||||
|
"Wikelo Idris",
|
||||||
|
objective="Get all listed parts",
|
||||||
|
items=[{"item_name": "Wikelo Idris panel", "desired_quantity": 1, "max_unit_price": 500_000}],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await runner.run_plan(plan["id"])
|
||||||
|
snapshot = store.get_plan(plan["id"])
|
||||||
|
|
||||||
|
assert result["drafted"] == 1
|
||||||
|
assert any(candidate["listing_id"] == "501" and candidate["status"] == "drafted" for candidate in snapshot["candidates"])
|
||||||
|
assert snapshot["negotiations"][0]["status"] == "drafted"
|
||||||
|
assert len(tools.pending_actions) == 1
|
||||||
|
assert not tools.uex.posts
|
||||||
|
assert "Drafted 1 negotiation" in memory.list_outbox()[0]["content"]
|
||||||
|
pending = next(iter(tools.pending_actions.values()))
|
||||||
|
assert "Tone note" not in pending.payload["message"]
|
||||||
|
assert "Polaris build" not in pending.payload["message"] or "putting together parts for a Polaris build" in pending.payload["message"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_plan_approval_logs_back_to_plan(tmp_path):
|
||||||
|
_, store, tools, runner, _ = plan_stack(tmp_path)
|
||||||
|
plan = store.create_plan(
|
||||||
|
"Wikelo Idris",
|
||||||
|
objective="Get all listed parts",
|
||||||
|
items=[{"item_name": "Wikelo Idris panel", "max_unit_price": 500_000}],
|
||||||
|
)
|
||||||
|
await runner.run_plan(plan["id"])
|
||||||
|
action_id = next(iter(tools.pending_actions))
|
||||||
|
|
||||||
|
approved = await tools.approve(action_id)
|
||||||
|
snapshot = store.get_plan(plan["id"])
|
||||||
|
|
||||||
|
assert approved["posted"]["path"] == "marketplace_negotiations_messages"
|
||||||
|
assert any(event["kind"] == "approved" for event in snapshot["events"])
|
||||||
|
assert any(negotiation["status"] == "approved" for negotiation in snapshot["negotiations"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_custom_runner_continues_plan_through_agent(tmp_path):
|
||||||
|
memory, store, tools, runner, _ = plan_stack(tmp_path)
|
||||||
|
agent = FakePlanAgent()
|
||||||
|
runner.bind_agent(agent)
|
||||||
|
plan = store.create_plan(
|
||||||
|
"Watch open negotiations",
|
||||||
|
kind="custom",
|
||||||
|
objective="Check UEX replies and recommend next action",
|
||||||
|
constraints={"instructions": "Pay attention to stale buyer replies."},
|
||||||
|
items=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await runner.run_plan(plan["id"])
|
||||||
|
snapshot = store.get_plan(plan["id"])
|
||||||
|
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
assert "Custom plan checked notifications" in result["summary"]
|
||||||
|
assert plan["id"] in agent.prompts[0]
|
||||||
|
assert any(event["kind"] == "run" for event in snapshot["events"])
|
||||||
|
assert "Custom plan checked notifications" in memory.list_outbox()[0]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scheduler_plan_run_survives_runner_error(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
store = ContinualPlanStore(memory)
|
||||||
|
plan = store.create_plan(
|
||||||
|
"Broken plan",
|
||||||
|
objective="Test failure handling",
|
||||||
|
items=[{"item_name": "Wikelo Idris panel"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
class FailingRunner:
|
||||||
|
def __init__(self, store):
|
||||||
|
self.store = store
|
||||||
|
|
||||||
|
async def run_plan(self, plan_id):
|
||||||
|
self.store.add_event(plan_id, "error", "boom")
|
||||||
|
memory.add_outbox("Broken plan: boom")
|
||||||
|
return {"error": "boom", "plan": self.store.get_plan(plan_id)}
|
||||||
|
|
||||||
|
scheduler = WakeScheduler(memory)
|
||||||
|
scheduler.bind_plan_runner(FailingRunner(store))
|
||||||
|
|
||||||
|
await scheduler._run_plan(plan["id"])
|
||||||
|
|
||||||
|
snapshot = store.get_plan(plan["id"])
|
||||||
|
assert snapshot["status"] == "active"
|
||||||
|
assert snapshot["events"][0]["kind"] == "error"
|
||||||
|
assert "boom" in memory.list_outbox()[0]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scheduler_schedules_overdue_plan_catchup_on_start(tmp_path):
|
||||||
|
memory, store, _, runner, scheduler = plan_stack(tmp_path)
|
||||||
|
plan = store.create_plan(
|
||||||
|
"Overdue plan",
|
||||||
|
objective="Check after restart",
|
||||||
|
items=[{"item_name": "Wikelo Idris panel"}],
|
||||||
|
)
|
||||||
|
store.update_schedule(plan["id"], (utc_now() - timedelta(minutes=5)).isoformat())
|
||||||
|
|
||||||
|
scheduler.start()
|
||||||
|
try:
|
||||||
|
catchup = scheduler.scheduler.get_job(scheduler._plan_catchup_job_id(plan["id"]))
|
||||||
|
snapshot = store.get_plan(plan["id"])
|
||||||
|
finally:
|
||||||
|
scheduler.shutdown()
|
||||||
|
|
||||||
|
assert catchup is not None
|
||||||
|
assert any(event["kind"] == "catchup_scheduled" for event in snapshot["events"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tools_delete_continual_plan_removes_it(tmp_path):
|
||||||
|
_, store, tools, _, _ = plan_stack(tmp_path)
|
||||||
|
plan = store.create_plan(
|
||||||
|
"Delete through tools",
|
||||||
|
objective="Remove via registry",
|
||||||
|
items=[{"item_name": "Wikelo Idris panel"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await tools.delete_continual_plan(plan["id"])
|
||||||
|
|
||||||
|
assert result["deleted"] is True
|
||||||
|
assert result["plan_id"] == plan["id"]
|
||||||
|
assert store.get_plan(plan["id"]) is None
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from traderai.agent import OllamaAgent
|
||||||
|
from traderai.memory import MemoryStore
|
||||||
|
from traderai.scheduler import WakeScheduler
|
||||||
|
|
||||||
|
|
||||||
|
class FakeUEXNotifications:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
async def get_user_notifications(self):
|
||||||
|
self.calls += 1
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"notifications": [
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"message": "A buyer replied to your listing.",
|
||||||
|
"redir": "/marketplace/negotiations/abc",
|
||||||
|
"code": "negotiation_reply",
|
||||||
|
"date_added": 123,
|
||||||
|
"date_read": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"message": "Already read.",
|
||||||
|
"date_added": 122,
|
||||||
|
"date_read": 123,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FailingUEXNotifications:
|
||||||
|
async def get_user_notifications(self):
|
||||||
|
raise RuntimeError("bad token")
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWakeAgent:
|
||||||
|
async def generate_wake_response(self, wake_message):
|
||||||
|
return f"Wake output: {wake_message}"
|
||||||
|
|
||||||
|
|
||||||
|
class ListingWakeTools:
|
||||||
|
schemas = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "search_marketplace_listings",
|
||||||
|
"description": "Search active UEX marketplace listings.",
|
||||||
|
"parameters": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
self.pending_actions = {}
|
||||||
|
|
||||||
|
async def execute(self, name, arguments):
|
||||||
|
self.calls.append((name, arguments))
|
||||||
|
return {
|
||||||
|
"count": 2,
|
||||||
|
"listings": [
|
||||||
|
{
|
||||||
|
"id": 100,
|
||||||
|
"title": "Wikelo Favor",
|
||||||
|
"operation": "sell",
|
||||||
|
"price": 500_000_000,
|
||||||
|
"currency": "UEC",
|
||||||
|
"in_stock": 9,
|
||||||
|
"advertiser": "pilot_a",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 101,
|
||||||
|
"title": "Wikelo Favor stack",
|
||||||
|
"operation": "sell",
|
||||||
|
"price": 1_000_000_000,
|
||||||
|
"currency": "UEC",
|
||||||
|
"in_stock": 5,
|
||||||
|
"advertiser": "pilot_b",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ListingWakeAgent(OllamaAgent):
|
||||||
|
def __init__(self, memory):
|
||||||
|
self.listing_tools = ListingWakeTools()
|
||||||
|
super().__init__("http://127.0.0.1:1", "missing-model", self.listing_tools, memory=memory)
|
||||||
|
self.responses = [
|
||||||
|
{
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"function": {
|
||||||
|
"name": "search_marketplace_listings",
|
||||||
|
"arguments": {
|
||||||
|
"query": "Wikelo Favor",
|
||||||
|
"operation": "sell",
|
||||||
|
"limit": 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": (
|
||||||
|
"Listing check complete: found 2 active Wikelo Favor sell listings. "
|
||||||
|
"Cheapest listing is 500,000,000 UEC with 9 in stock; the next listing is "
|
||||||
|
"1,000,000,000 UEC. Suggested next action: price near 500,000,000 UEC "
|
||||||
|
"if you want to move yours quickly."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def ensure_available(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _ollama_chat(self, *args, **kwargs):
|
||||||
|
return self.responses.pop(0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_uex_notifications_adds_unread_once(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
scheduler = WakeScheduler(memory)
|
||||||
|
scheduler.bind_uex_notifications(FakeUEXNotifications())
|
||||||
|
|
||||||
|
first = await scheduler.poll_uex_notifications()
|
||||||
|
second = await scheduler.poll_uex_notifications()
|
||||||
|
outbox = memory.inspect()["outbox"]
|
||||||
|
|
||||||
|
assert len(first) == 1
|
||||||
|
assert second == []
|
||||||
|
assert len(outbox) == 1
|
||||||
|
assert "A buyer replied to your listing." in outbox[0]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_uex_notifications_reports_failures_to_outbox(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
scheduler = WakeScheduler(memory)
|
||||||
|
scheduler.bind_uex_notifications(FailingUEXNotifications())
|
||||||
|
|
||||||
|
result = await scheduler.poll_uex_notifications()
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
assert "bad token" in memory.inspect()["outbox"][0]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wake_job_writes_agent_output_to_outbox_and_disables_one_shot(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
scheduler = WakeScheduler(memory)
|
||||||
|
scheduler.bind_agent(FakeWakeAgent())
|
||||||
|
memory.add_job("wake-test", "check notifications", "date", "2099-01-01T00:00:00+00:00")
|
||||||
|
|
||||||
|
await scheduler._run_job("wake-test", "check notifications")
|
||||||
|
snapshot = memory.inspect()
|
||||||
|
|
||||||
|
assert "Wake output:" in snapshot["outbox"][0]["content"]
|
||||||
|
assert snapshot["scheduled_jobs"][0]["enabled"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wake_job_checks_listings_and_writes_analysis_to_outbox(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
scheduler = WakeScheduler(memory)
|
||||||
|
agent = ListingWakeAgent(memory)
|
||||||
|
scheduler.bind_agent(agent)
|
||||||
|
memory.add_job("wake-listings", "check Wikelo Favor listings and analyze the market", "date", "2099-01-01T00:00:00+00:00")
|
||||||
|
|
||||||
|
await scheduler._run_job("wake-listings", "check Wikelo Favor listings and analyze the market")
|
||||||
|
snapshot = memory.inspect()
|
||||||
|
content = snapshot["outbox"][0]["content"]
|
||||||
|
|
||||||
|
assert agent.listing_tools.calls == [
|
||||||
|
(
|
||||||
|
"search_marketplace_listings",
|
||||||
|
{"query": "Wikelo Favor", "operation": "sell", "limit": 5},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
assert "Listing check complete" in content
|
||||||
|
assert "500,000,000 UEC" in content
|
||||||
|
assert "Suggested next action" in content
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import traderai.server as server
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_update_rebuilds_runtime_without_restart(monkeypatch, tmp_path):
|
||||||
|
state = {"settings": make_settings(tmp_path, model_provider="ollama", ollama_model="qwen3.5:9b")}
|
||||||
|
|
||||||
|
class FakeScheduler:
|
||||||
|
def __init__(self, memory):
|
||||||
|
self.memory = memory
|
||||||
|
|
||||||
|
def bind_agent(self, agent):
|
||||||
|
self.agent = agent
|
||||||
|
|
||||||
|
def bind_plan_runner(self, plan_runner):
|
||||||
|
self.plan_runner = plan_runner
|
||||||
|
|
||||||
|
def bind_uex_notifications(self, uex, poll_seconds=60):
|
||||||
|
self.uex = uex
|
||||||
|
self.poll_seconds = poll_seconds
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def list_jobs(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
class FakeUEXClient:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def get_user(self, username=None, authenticated=False):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
class FakeToolRegistry:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.pending_actions = {}
|
||||||
|
self.plan_runner = None
|
||||||
|
|
||||||
|
async def approve(self, action_id):
|
||||||
|
return {"approved": action_id}
|
||||||
|
|
||||||
|
async def decline(self, action_id):
|
||||||
|
return {"declined": action_id}
|
||||||
|
|
||||||
|
class FakePlanRunner:
|
||||||
|
def __init__(self, store, tools, memory, agent=None):
|
||||||
|
self.store = store
|
||||||
|
self.tools = tools
|
||||||
|
self.memory = memory
|
||||||
|
self.agent = agent
|
||||||
|
|
||||||
|
def bind_agent(self, agent):
|
||||||
|
self.agent = agent
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def fake_health(self):
|
||||||
|
return {
|
||||||
|
"online": True,
|
||||||
|
"provider": self.provider,
|
||||||
|
"model": self.model,
|
||||||
|
"model_available": True,
|
||||||
|
"message": f"{self.provider} ready",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_chat(self, content, thread_id=None, images=None):
|
||||||
|
return {"message": f"{self.provider}:{self.model}", "pending_actions": [], "thread_id": thread_id}
|
||||||
|
|
||||||
|
def fake_get_settings():
|
||||||
|
return state["settings"]
|
||||||
|
|
||||||
|
def fake_save_settings(values):
|
||||||
|
state["settings"] = make_settings(
|
||||||
|
tmp_path,
|
||||||
|
model_provider=values.get("model_provider", state["settings"].model_provider),
|
||||||
|
ollama_model=values.get("ollama_model", state["settings"].ollama_model),
|
||||||
|
codex_model=values.get("codex_model", state["settings"].codex_model),
|
||||||
|
deepseek_model=values.get("deepseek_model", state["settings"].deepseek_model),
|
||||||
|
)
|
||||||
|
return {"values": values, "fields": {}, "secrets_configured": {}, "app_data_dir": str(tmp_path)}
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "WakeScheduler", FakeScheduler)
|
||||||
|
monkeypatch.setattr(server, "UEXClient", FakeUEXClient)
|
||||||
|
monkeypatch.setattr(server, "ToolRegistry", FakeToolRegistry)
|
||||||
|
monkeypatch.setattr(server, "ContinualPlanRunner", FakePlanRunner)
|
||||||
|
monkeypatch.setattr(server, "SCMDBClient", FakeClient)
|
||||||
|
monkeypatch.setattr(server, "CornerstoneClient", FakeClient)
|
||||||
|
monkeypatch.setattr(server, "StarCitizenWikiClient", FakeClient)
|
||||||
|
monkeypatch.setattr(server, "get_settings", fake_get_settings)
|
||||||
|
monkeypatch.setattr(server, "save_settings", fake_save_settings)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"settings_payload",
|
||||||
|
lambda settings=None: {"app_data_dir": str(tmp_path), "values": {}, "fields": {}, "secrets_configured": {}},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(server.OllamaAgent, "health", fake_health)
|
||||||
|
monkeypatch.setattr(server.OllamaAgent, "chat", fake_chat)
|
||||||
|
|
||||||
|
app = server.create_app()
|
||||||
|
with TestClient(app) as client:
|
||||||
|
before = client.get("/api/health").json()
|
||||||
|
assert before["model_provider"] == "ollama"
|
||||||
|
assert before["inference"]["provider"] == "ollama"
|
||||||
|
|
||||||
|
updated = client.post(
|
||||||
|
"/api/config",
|
||||||
|
json={"values": {"model_provider": "deepseek", "deepseek_model": "deepseek-v4-flash"}},
|
||||||
|
).json()
|
||||||
|
assert updated["restart_required"] is False
|
||||||
|
|
||||||
|
after = client.get("/api/health").json()
|
||||||
|
assert after["model_provider"] == "deepseek"
|
||||||
|
assert after["inference"]["provider"] == "deepseek"
|
||||||
|
|
||||||
|
chat = client.post("/api/chat", json={"message": "hi", "thread_id": "thread-1", "images": []}).json()
|
||||||
|
assert chat["message"] == "deepseek:deepseek-v4-flash"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_draft_endpoint_returns_agent_draft(monkeypatch, tmp_path):
|
||||||
|
state = {"settings": make_settings(tmp_path)}
|
||||||
|
|
||||||
|
class FakeScheduler:
|
||||||
|
def __init__(self, memory):
|
||||||
|
self.memory = memory
|
||||||
|
|
||||||
|
def bind_agent(self, agent):
|
||||||
|
self.agent = agent
|
||||||
|
|
||||||
|
def bind_plan_runner(self, plan_runner):
|
||||||
|
self.plan_runner = plan_runner
|
||||||
|
|
||||||
|
def bind_uex_notifications(self, uex, poll_seconds=60):
|
||||||
|
self.uex = uex
|
||||||
|
self.poll_seconds = poll_seconds
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def list_jobs(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
class FakeUEXClient:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def get_user(self, username=None, authenticated=False):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
class FakeToolRegistry:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.pending_actions = {}
|
||||||
|
self.plan_runner = None
|
||||||
|
|
||||||
|
async def approve(self, action_id):
|
||||||
|
return {"approved": action_id}
|
||||||
|
|
||||||
|
async def decline(self, action_id):
|
||||||
|
return {"declined": action_id}
|
||||||
|
|
||||||
|
class FakePlanRunner:
|
||||||
|
def __init__(self, store, tools, memory, agent=None):
|
||||||
|
self.store = store
|
||||||
|
self.tools = tools
|
||||||
|
self.memory = memory
|
||||||
|
self.agent = agent
|
||||||
|
|
||||||
|
def bind_agent(self, agent):
|
||||||
|
self.agent = agent
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def fake_get_settings():
|
||||||
|
return state["settings"]
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "WakeScheduler", FakeScheduler)
|
||||||
|
monkeypatch.setattr(server, "UEXClient", FakeUEXClient)
|
||||||
|
monkeypatch.setattr(server, "ToolRegistry", FakeToolRegistry)
|
||||||
|
monkeypatch.setattr(server, "ContinualPlanRunner", FakePlanRunner)
|
||||||
|
monkeypatch.setattr(server, "SCMDBClient", FakeClient)
|
||||||
|
monkeypatch.setattr(server, "CornerstoneClient", FakeClient)
|
||||||
|
monkeypatch.setattr(server, "StarCitizenWikiClient", FakeClient)
|
||||||
|
monkeypatch.setattr(server, "get_settings", fake_get_settings)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"settings_payload",
|
||||||
|
lambda settings=None: {"app_data_dir": str(tmp_path), "values": {}, "fields": {}, "secrets_configured": {}},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_generate_plan_draft(self, title="", objective="", kind="buying", constraints=None, items=None):
|
||||||
|
return {
|
||||||
|
"title": title or "Draft title",
|
||||||
|
"objective": objective or "Draft objective",
|
||||||
|
"kind": kind,
|
||||||
|
"cadence": "0 */3 * * *",
|
||||||
|
"constraints": {"message_tone": "friendly and direct", "instructions": "Start with the best listings."},
|
||||||
|
"items": [{"item_name": "RCMBNT-RGL-1", "desired_quantity": 1, "max_unit_price": None}],
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(server.OllamaAgent, "generate_plan_draft", fake_generate_plan_draft)
|
||||||
|
|
||||||
|
app = server.create_app()
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.post(
|
||||||
|
"/api/plans/draft",
|
||||||
|
json={"title": "Polaris parts", "objective": "Find the required parts", "kind": "buying", "constraints": {}, "items": []},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
draft = response.json()["draft"]
|
||||||
|
assert draft["cadence"] == "0 */3 * * *"
|
||||||
|
assert draft["constraints"]["instructions"] == "Start with the best listings."
|
||||||
|
assert draft["items"][0]["item_name"] == "RCMBNT-RGL-1"
|
||||||
|
|
||||||
|
|
||||||
|
def make_settings(tmp_path, model_provider="ollama", ollama_model="qwen3.5:9b", codex_model="gpt-5.4", deepseek_model="deepseek-v4-flash"):
|
||||||
|
return SimpleNamespace(
|
||||||
|
traderai_memory_path=str(tmp_path / "memory.sqlite3"),
|
||||||
|
model_provider=model_provider,
|
||||||
|
ollama_base_url="http://localhost:11434",
|
||||||
|
ollama_model=ollama_model,
|
||||||
|
ollama_num_ctx=64512,
|
||||||
|
openai_base_url="https://api.openai.com/v1",
|
||||||
|
openai_api_key=None,
|
||||||
|
openai_model="gpt-5.4-mini",
|
||||||
|
deepseek_base_url="https://api.deepseek.com",
|
||||||
|
deepseek_api_key=None,
|
||||||
|
deepseek_model=deepseek_model,
|
||||||
|
model_reasoning_effort="medium",
|
||||||
|
codex_command="codex",
|
||||||
|
codex_model=codex_model,
|
||||||
|
uex_base_url="https://api.uexcorp.space/2.0",
|
||||||
|
uex_secret_key=None,
|
||||||
|
uex_bearer_token=None,
|
||||||
|
uex_negotiation_close_endpoint="marketplace_negotiations_close",
|
||||||
|
traderai_user_name=None,
|
||||||
|
uex_notification_poll_seconds=60,
|
||||||
|
require_write_approval=True,
|
||||||
|
scmdb_base_url="https://scmdb.net",
|
||||||
|
cornerstone_base_url="https://finder.cstone.space",
|
||||||
|
scwiki_base_url="https://starcitizen.tools",
|
||||||
|
scwiki_api_base_url="https://api.star-citizen.wiki",
|
||||||
|
)
|
||||||
@@ -2,12 +2,114 @@ import pytest
|
|||||||
import respx
|
import respx
|
||||||
from httpx import Response
|
from httpx import Response
|
||||||
|
|
||||||
|
from traderai.cornerstone_client import CornerstoneClient, parse_cornerstone_item_page
|
||||||
from traderai.tools import ToolRegistry
|
from traderai.tools import ToolRegistry
|
||||||
from traderai.uex_client import UEXClient
|
from traderai.uex_client import UEXClient
|
||||||
|
|
||||||
|
|
||||||
class FakeUEX:
|
class FakeUEX:
|
||||||
|
def __init__(self):
|
||||||
|
self.posts = []
|
||||||
|
self.get_calls = []
|
||||||
|
|
||||||
async def get(self, path, params=None, authenticated=False):
|
async def get(self, path, params=None, authenticated=False):
|
||||||
|
self.get_calls.append({"path": path, "params": params, "authenticated": authenticated})
|
||||||
|
if path == "commodities_prices_history":
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"id_terminal": 7,
|
||||||
|
"id_commodity": 3,
|
||||||
|
"commodity_name": "Gold",
|
||||||
|
"terminal_name": "Port Tressler",
|
||||||
|
"price_buy": 4000,
|
||||||
|
"price_sell": 5000,
|
||||||
|
"scu_buy": 100,
|
||||||
|
"scu_sell": 20,
|
||||||
|
"date_added": 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"id_terminal": 7,
|
||||||
|
"id_commodity": 3,
|
||||||
|
"commodity_name": "Gold",
|
||||||
|
"terminal_name": "Port Tressler",
|
||||||
|
"price_buy": 4200,
|
||||||
|
"price_sell": 4800,
|
||||||
|
"scu_buy": 80,
|
||||||
|
"scu_sell": 30,
|
||||||
|
"date_added": 200,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if path == "marketplace_prices_history":
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"data": [
|
||||||
|
{"id": 1, "item_name": "Widget", "operation": "sell", "price": 1000, "currency": "UEC", "date_added": 100},
|
||||||
|
{"id": 2, "item_name": "Widget", "operation": "sell", "price": 1250, "currency": "UEC", "date_added": 200},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if path == "currencies_index_history":
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"data": [
|
||||||
|
{"id": 1, "currency": "UEC", "index_value": 100.0, "basket_value": 5000.0, "date_added": 100},
|
||||||
|
{"id": 2, "currency": "UEC", "index_value": 110.0, "basket_value": 5500.0, "date_added": 200},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if path == "commodities_prices":
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"commodity_name": "Gold",
|
||||||
|
"terminal_name": "Port Tressler",
|
||||||
|
"price_buy": 4120,
|
||||||
|
"price_sell": 5020,
|
||||||
|
"scu_buy": 1200,
|
||||||
|
"verbose_note": "x" * 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"commodity_name": "Beryl",
|
||||||
|
"terminal_name": "Area18",
|
||||||
|
"price_buy": 2500,
|
||||||
|
"price_sell": 3100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if path == "marketplace_trends":
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id_item": 2791,
|
||||||
|
"item_name": "\"Quantanium\" Water Bottle",
|
||||||
|
"item_slug": "quantanium-water-bottle",
|
||||||
|
"currency": "UEC",
|
||||||
|
"price_avg_sell": "937500",
|
||||||
|
"price_avg_month_sell": "1072222",
|
||||||
|
"price_min_sell": "750000",
|
||||||
|
"price_max_sell": "1200000",
|
||||||
|
"listings_count_sell": 4,
|
||||||
|
"price_avg_buy": "500000",
|
||||||
|
"price_avg_month_buy": "525000",
|
||||||
|
"price_min_buy": "450000",
|
||||||
|
"price_max_buy": "550000",
|
||||||
|
"listings_count_buy": 2,
|
||||||
|
"total_listings_count": 6,
|
||||||
|
"negotiations_count": 18,
|
||||||
|
"negotiations_open": 7,
|
||||||
|
"negotiations_success": 9,
|
||||||
|
"link_prices": "https://uexcorp.space/marketplace/home/?id_item=2791&mode=list",
|
||||||
|
"link_prices_history": "https://uexcorp.space/marketplace/averages/?id_item=2791&quality_tier=q0&unit=unit",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
assert path == "marketplace_listings"
|
assert path == "marketplace_listings"
|
||||||
return {
|
return {
|
||||||
"data": [
|
"data": [
|
||||||
@@ -42,6 +144,260 @@ class FakeUEX:
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def delete(self, path, params=None, authenticated=True):
|
||||||
|
return {"status": "ok", "deleted": {"path": path, "params": params, "authenticated": authenticated}}
|
||||||
|
|
||||||
|
async def post(self, path, payload, authenticated=True):
|
||||||
|
self.posts.append({"path": path, "payload": payload, "authenticated": authenticated})
|
||||||
|
return {"status": "ok", "posted": self.posts[-1]}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSCMDB:
|
||||||
|
base_url = "https://scmdb.test"
|
||||||
|
|
||||||
|
async def list_versions(self):
|
||||||
|
return [
|
||||||
|
{"version": "4.8.0-ptu.1", "file": "merged-4.8.0-ptu.1.json"},
|
||||||
|
{"version": "4.7.2-live.1", "file": "merged-4.7.2-live.1.json"},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def get_data(self, version=None, channel="live"):
|
||||||
|
return {
|
||||||
|
"version": version or "4.7.2-live.1",
|
||||||
|
"factions": {
|
||||||
|
"fac-haul": {"name": "Covalex"},
|
||||||
|
"fac-bounty": {"name": "Bounty Hunters Guild"},
|
||||||
|
},
|
||||||
|
"scopes": {
|
||||||
|
"scope-rep": {"scopeName": "FactionReputation"},
|
||||||
|
},
|
||||||
|
"factionRewardsPools": [
|
||||||
|
[{"factionGuid": "fac-haul", "scopeGuid": "scope-rep", "amount": 125}],
|
||||||
|
[{"factionGuid": "fac-bounty", "scopeGuid": "scope-rep", "amount": 250}],
|
||||||
|
],
|
||||||
|
"partialRewardPayoutPools": [
|
||||||
|
[],
|
||||||
|
[{"minPercentage": 50, "maxPercentage": 99, "currencyRewardMultiplier": 0.75, "reputationMultipliers": None}],
|
||||||
|
],
|
||||||
|
"resourcePools": {
|
||||||
|
"res-tungsten": {"name": "Tungsten"},
|
||||||
|
},
|
||||||
|
"blueprintPools": {
|
||||||
|
"bp-pool": {
|
||||||
|
"name": "Ship Salvage Rewards",
|
||||||
|
"blueprints": [{"name": "Abrade Scraper Module"}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"locationPools": {
|
||||||
|
"loc-a18": {"name": "Area18"},
|
||||||
|
"loc-baijini": {"name": "Baijini Point"},
|
||||||
|
},
|
||||||
|
"contracts": [
|
||||||
|
{
|
||||||
|
"id": "mission-haul",
|
||||||
|
"debugName": "Haul_Tungsten_Test",
|
||||||
|
"title": "Move Tungsten",
|
||||||
|
"description": "Move Tungsten to Baijini Point.",
|
||||||
|
"missionType": "Hauling",
|
||||||
|
"category": "career",
|
||||||
|
"factionGuid": "fac-haul",
|
||||||
|
"rewardUEC": 50250,
|
||||||
|
"factionRewardsIndex": 0,
|
||||||
|
"partialRewardPayoutIndex": 1,
|
||||||
|
"haulingOrders": [{"resource": "res-tungsten", "minSCU": 6, "maxSCU": 6, "maxContainerSize": 1}],
|
||||||
|
"locations": ["loc-a18"],
|
||||||
|
"destinations": ["loc-baijini"],
|
||||||
|
"systems": ["Stanton"],
|
||||||
|
"illegal": False,
|
||||||
|
"canBeShared": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mission-bounty",
|
||||||
|
"debugName": "Bounty_Blueprint_Test",
|
||||||
|
"title": "Ambush Op",
|
||||||
|
"description": "Clean out targets.",
|
||||||
|
"missionType": "Bounty Hunter",
|
||||||
|
"factionGuid": "fac-bounty",
|
||||||
|
"rewardUEC": 120000,
|
||||||
|
"factionRewardsIndex": 1,
|
||||||
|
"partialRewardPayoutIndex": 0,
|
||||||
|
"itemRewards": [{"name": "Council Scrip", "amount": 5}],
|
||||||
|
"blueprintRewards": [{"blueprintPool": "bp-pool", "chance": 1, "trigger": "complete"}],
|
||||||
|
"systems": ["Pyro"],
|
||||||
|
"illegal": True,
|
||||||
|
"canBeShared": True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"legacyContracts": [
|
||||||
|
{
|
||||||
|
"id": "legacy-delivery",
|
||||||
|
"debugName": "Legacy_Delivery_Test",
|
||||||
|
"title": "Old Box Run",
|
||||||
|
"missionType": "Delivery",
|
||||||
|
"factionGuid": "fac-haul",
|
||||||
|
"rewardUEC": 1000,
|
||||||
|
"factionRewardsIndex": 0,
|
||||||
|
"partialRewardPayoutIndex": 0,
|
||||||
|
"systems": ["Stanton"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCornerstone:
|
||||||
|
base_url = "https://finder.cstone.test"
|
||||||
|
|
||||||
|
async def list_items(self):
|
||||||
|
return [
|
||||||
|
{"id": "item-abrade", "name": "Abrade Scraper Module", "sold": True},
|
||||||
|
{"id": "item-cinch", "name": "Cinch Scraper Module", "sold": True},
|
||||||
|
{"id": "item-poster", "name": "Zeus 2955 Ship Showdown Poster", "sold": False},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def get_item_page(self, item_id):
|
||||||
|
assert item_id == "item-abrade"
|
||||||
|
return {
|
||||||
|
"url": f"{self.base_url}/ShipSalvageMods1/{item_id}",
|
||||||
|
"html": """
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Star Citizen - Salvage modifier - Abrade Scraper Module</title>
|
||||||
|
<meta property="og:image" content="/images/abrade.png">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<table>
|
||||||
|
<tr><td>NAME</td><td>Abrade Scraper Module</td></tr>
|
||||||
|
<tr><td>MANUFACTURER</td><td>Greycat Industrial</td></tr>
|
||||||
|
</table>
|
||||||
|
<table>
|
||||||
|
<tr><th>LOCATION</th><th>BASE PRICE</th><th>VERIFIED</th></tr>
|
||||||
|
<tr><td>Stanton - ArcCorp - Area18 - Dumper's Depot</td><td>21 250</td><td>2956-01-29</td></tr>
|
||||||
|
<tr><td>Stanton - microTech - Port Tressler - Platinum Bay</td><td>21 250</td><td>2956-01-04</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_image_data(self, url, max_bytes=10_000_000):
|
||||||
|
assert url == f"{self.base_url}/images/abrade.png"
|
||||||
|
return {
|
||||||
|
"url": url,
|
||||||
|
"content_type": "image/png",
|
||||||
|
"size_bytes": 12,
|
||||||
|
"image_data": "ZmFrZS1pbWFnZQ==",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSCWiki:
|
||||||
|
base_url = "https://starcitizen.tools"
|
||||||
|
api_base_url = "https://api.star-citizen.wiki"
|
||||||
|
|
||||||
|
async def search_pages(self, query, limit=5):
|
||||||
|
assert query == "Carrack"
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"pageid": 415,
|
||||||
|
"title": "Carrack",
|
||||||
|
"description": "Deep-space multi-crew explorer manufactured by Anvil Aerospace",
|
||||||
|
"extract": "The Anvil Carrack is a multi-crew explorer.",
|
||||||
|
"thumbnail": "https://media.starcitizen.tools/carrack.webp",
|
||||||
|
"url": "https://starcitizen.tools/Carrack",
|
||||||
|
}
|
||||||
|
][:limit]
|
||||||
|
|
||||||
|
async def get_page_summary(self, title=None, pageid=None, chars=700):
|
||||||
|
assert title == "Carrack" or pageid == 415
|
||||||
|
return {
|
||||||
|
"pageid": 415,
|
||||||
|
"title": "Carrack",
|
||||||
|
"description": "Deep-space multi-crew explorer manufactured by Anvil Aerospace",
|
||||||
|
"extract": "The Anvil Carrack is a multi-crew explorer.",
|
||||||
|
"thumbnail": "https://media.starcitizen.tools/carrack.webp",
|
||||||
|
"url": "https://starcitizen.tools/Carrack",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def search_verse(self, query):
|
||||||
|
assert query == "Carrack"
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"type": "vehicles",
|
||||||
|
"label": "Vehicles",
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"name": "Anvil Carrack",
|
||||||
|
"class_name": "ANVL_Carrack",
|
||||||
|
"extra_label": "Exploration",
|
||||||
|
"web_url": "https://api.star-citizen.wiki/vehicles/anvl-carrack",
|
||||||
|
"api_url": "https://api.star-citizen.wiki/api/vehicles/anvl-carrack",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
async def get_vehicle(self, slug):
|
||||||
|
assert slug == "anvl-carrack"
|
||||||
|
return {
|
||||||
|
"name": "Carrack",
|
||||||
|
"game_name": "Anvil Carrack",
|
||||||
|
"slug": "anvl-carrack",
|
||||||
|
"manufacturer": {"name": "Anvil Aerospace"},
|
||||||
|
"career": "Exploration",
|
||||||
|
"role": "Expedition",
|
||||||
|
"size_class": 5,
|
||||||
|
"cargo_capacity": 456,
|
||||||
|
"crew": {"min": 6, "max": 6},
|
||||||
|
"msrp": 600,
|
||||||
|
"pledge_url": "https://robertsspaceindustries.com/pledge/ships/carrack/Carrack",
|
||||||
|
"uex_prices": {
|
||||||
|
"purchase": [
|
||||||
|
{
|
||||||
|
"price_buy": 34398000,
|
||||||
|
"terminal_name": "Astro Armada - Area 18",
|
||||||
|
"starmap_location": {"name": "Area18", "parent_name": "ArcCorp", "star_system_name": "Stanton"},
|
||||||
|
"game_version": "4.8.1-LIVE.11952564",
|
||||||
|
"date_updated": "2026-05-20T18:39:37-04:00",
|
||||||
|
"uex_link": "https://uexcorp.space/vehicles/home/list/in_game_sell/?id_terminal=148",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": {"en_EN": "The Anvil Carrack features reinforced fuel tanks for long-duration flight."},
|
||||||
|
"web_url": "https://api.star-citizen.wiki/vehicles/anvl-carrack",
|
||||||
|
"updated_at": "2026-06-08T00:34:00Z",
|
||||||
|
"version": "4.8.1-LIVE.11952564",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWikelo:
|
||||||
|
base_url = "https://wikelo-projects.test"
|
||||||
|
|
||||||
|
async def list_ship_projects(self):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": "ship-1",
|
||||||
|
"ship_name": "Polaris Wikelo Special",
|
||||||
|
"description": "Now make Polaris. Short Time Deal",
|
||||||
|
"status": "planning",
|
||||||
|
"privacy": "public",
|
||||||
|
"owner_name": "Chimpanz33",
|
||||||
|
"required_materials": [
|
||||||
|
{"material_name": "Wikelo Favor", "quantity_needed": 50.0, "quantity_collected": 0.0},
|
||||||
|
{"material_name": "Polaris Bit", "quantity_needed": 15.0, "quantity_collected": 2.0},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ship-2",
|
||||||
|
"ship_name": "Guardian",
|
||||||
|
"description": "Guardian Fight Mod",
|
||||||
|
"status": "planning",
|
||||||
|
"privacy": "public",
|
||||||
|
"owner_name": "Chimpanz33",
|
||||||
|
"required_materials": [
|
||||||
|
{"material_name": "Wikelo Favor", "quantity_needed": 20.0, "quantity_collected": 0.0},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_search_marketplace_listings_filters_locally():
|
async def test_search_marketplace_listings_filters_locally():
|
||||||
@@ -61,6 +417,32 @@ async def test_draft_message_creates_pending_action():
|
|||||||
assert pending["id"] in registry.pending_actions
|
assert pending["id"] in registry.pending_actions
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_decline_pending_action_removes_without_sending():
|
||||||
|
registry = ToolRegistry(FakeUEX())
|
||||||
|
result = await registry.draft_negotiation_message(hash="abc", message="Would you take 4500 UEC?")
|
||||||
|
action_id = result["pending_action"]["id"]
|
||||||
|
|
||||||
|
declined = await registry.decline(action_id)
|
||||||
|
|
||||||
|
assert declined["declined"] is True
|
||||||
|
assert declined["pending_action"]["id"] == action_id
|
||||||
|
assert action_id not in registry.pending_actions
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_negotiation_message_forces_production_send():
|
||||||
|
fake = FakeUEX()
|
||||||
|
registry = ToolRegistry(fake)
|
||||||
|
result = await registry.draft_negotiation_message(hash="abc", message="Ready to close", is_production=0)
|
||||||
|
action_id = result["pending_action"]["id"]
|
||||||
|
|
||||||
|
approved = await registry.approve(action_id)
|
||||||
|
|
||||||
|
assert approved["posted"]["path"] == "marketplace_negotiations_messages"
|
||||||
|
assert approved["posted"]["payload"]["is_production"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_uex_client_uses_bearer_and_secret_headers():
|
def test_uex_client_uses_bearer_and_secret_headers():
|
||||||
client = UEXClient("https://api.uexcorp.space/2.0", secret_key="secret", bearer_token="bearer")
|
client = UEXClient("https://api.uexcorp.space/2.0", secret_key="secret", bearer_token="bearer")
|
||||||
|
|
||||||
@@ -70,6 +452,436 @@ def test_uex_client_uses_bearer_and_secret_headers():
|
|||||||
assert headers["Authorization"] == "Bearer bearer"
|
assert headers["Authorization"] == "Bearer bearer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_uex_client_uses_configured_close_endpoint():
|
||||||
|
client = UEXClient(
|
||||||
|
"https://api.uexcorp.space/2.0",
|
||||||
|
secret_key="secret",
|
||||||
|
bearer_token="bearer",
|
||||||
|
negotiation_close_endpoint="custom_close_endpoint",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert client.negotiation_close_endpoint == "custom_close_endpoint"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_uex_get_projects_and_limits_results():
|
||||||
|
registry = ToolRegistry(FakeUEX())
|
||||||
|
|
||||||
|
result = await registry.execute(
|
||||||
|
"get_uex_commodities_prices",
|
||||||
|
{
|
||||||
|
"commodity_name": "Gold",
|
||||||
|
"ignored": "drop",
|
||||||
|
"fields": ["id", "commodity_name", "price_buy"],
|
||||||
|
"limit": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["resource"] == "commodities_prices"
|
||||||
|
assert result["params"] == {"commodity_name": "Gold"}
|
||||||
|
assert result["returned"] == 1
|
||||||
|
assert result["truncated"] is True
|
||||||
|
assert result["items"] == [{"id": 10, "commodity_name": "Gold", "price_buy": 4120}]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_uex_get_marketplace_listings_accepts_item_and_operation_filters():
|
||||||
|
fake = FakeUEX()
|
||||||
|
registry = ToolRegistry(fake)
|
||||||
|
|
||||||
|
result = await registry.execute(
|
||||||
|
"get_uex_marketplace_listings",
|
||||||
|
{
|
||||||
|
"id_item": 2791,
|
||||||
|
"operation": "sell",
|
||||||
|
"fields": ["id", "slug", "operation"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["params"] == {"id_item": 2791, "operation": "sell"}
|
||||||
|
assert fake.get_calls[-1]["path"] == "marketplace_listings"
|
||||||
|
assert fake.get_calls[-1]["params"] == {"id_item": 2791, "operation": "sell"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_marketplace_trends_returns_compact_wts_wtb_and_negotiation_metrics():
|
||||||
|
fake = FakeUEX()
|
||||||
|
registry = ToolRegistry(fake)
|
||||||
|
|
||||||
|
result = await registry.get_marketplace_trends(item_name="Quantanium", currency="UEC", quality_tier=0)
|
||||||
|
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
assert result["count"] == 1
|
||||||
|
assert result["filters"] == {"item_name": "Quantanium", "currency": "UEC", "quality_tier": 0}
|
||||||
|
assert fake.get_calls[-1]["path"] == "marketplace_trends"
|
||||||
|
assert fake.get_calls[-1]["params"] == {"id_item": None, "item_name": "Quantanium", "item_slug": None, "id_category": None, "currency": "UEC", "quality_tier": 0}
|
||||||
|
assert result["trends"][0] == {
|
||||||
|
"id_item": 2791,
|
||||||
|
"item_name": "\"Quantanium\" Water Bottle",
|
||||||
|
"item_slug": "quantanium-water-bottle",
|
||||||
|
"currency": "UEC",
|
||||||
|
"sell": {
|
||||||
|
"avg_price": "937500",
|
||||||
|
"avg_price_month": "1072222",
|
||||||
|
"min_price": "750000",
|
||||||
|
"max_price": "1200000",
|
||||||
|
"listings_count": 4,
|
||||||
|
},
|
||||||
|
"buy": {
|
||||||
|
"avg_price": "500000",
|
||||||
|
"avg_price_month": "525000",
|
||||||
|
"min_price": "450000",
|
||||||
|
"max_price": "550000",
|
||||||
|
"listings_count": 2,
|
||||||
|
},
|
||||||
|
"total_listings_count": 6,
|
||||||
|
"negotiations_count": 18,
|
||||||
|
"negotiations_open": 7,
|
||||||
|
"negotiations_success": 9,
|
||||||
|
"link_prices": "https://uexcorp.space/marketplace/home/?id_item=2791&mode=list",
|
||||||
|
"link_prices_history": "https://uexcorp.space/marketplace/averages/?id_item=2791&quality_tier=q0&unit=unit",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_uex_api_catalog_exposes_resources_without_live_call():
|
||||||
|
registry = ToolRegistry(FakeUEX())
|
||||||
|
|
||||||
|
result = await registry.uex_api_catalog(group="vehicles")
|
||||||
|
|
||||||
|
resources = [item["resource"] for item in result["get"]["vehicles"]]
|
||||||
|
assert "vehicles" in resources
|
||||||
|
assert "vehicles_prices" in resources
|
||||||
|
assert "wallet_add" in result["post"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_draft_delete_approves_with_delete_method():
|
||||||
|
registry = ToolRegistry(FakeUEX())
|
||||||
|
result = await registry.execute("delete_uex_marketplace_listings", {"id": 123, "label": "Remove listing"})
|
||||||
|
action_id = result["pending_action"]["id"]
|
||||||
|
|
||||||
|
approved = await registry.approve(action_id)
|
||||||
|
|
||||||
|
assert result["pending_action"]["method"] == "DELETE"
|
||||||
|
assert approved["deleted"] == {
|
||||||
|
"path": "marketplace_listings",
|
||||||
|
"params": {"id": 123},
|
||||||
|
"authenticated": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_schemas_expose_specific_uex_tools_instead_of_generic_api_tool():
|
||||||
|
registry = ToolRegistry(FakeUEX())
|
||||||
|
|
||||||
|
names = {schema["function"]["name"] for schema in registry.schemas}
|
||||||
|
|
||||||
|
assert "get_uex_commodities_prices" in names
|
||||||
|
assert "get_uex_vehicles" in names
|
||||||
|
assert "get_marketplace_trends" in names
|
||||||
|
assert "draft_uex_marketplace_advertise" in names
|
||||||
|
assert "delete_uex_marketplace_listings" in names
|
||||||
|
assert "uex_get" not in names
|
||||||
|
assert "uex_draft_post" not in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_schemas_expose_scmdb_mission_tools():
|
||||||
|
registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB())
|
||||||
|
|
||||||
|
names = {schema["function"]["name"] for schema in registry.schemas}
|
||||||
|
|
||||||
|
assert "list_scmdb_versions" in names
|
||||||
|
assert "search_scmdb_missions" in names
|
||||||
|
assert "get_scmdb_mission_rewards" in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_schemas_expose_cornerstone_item_tools():
|
||||||
|
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
|
||||||
|
|
||||||
|
names = {schema["function"]["name"] for schema in registry.schemas}
|
||||||
|
|
||||||
|
assert "search_cornerstone_items" in names
|
||||||
|
assert "get_cornerstone_item_locations" in names
|
||||||
|
assert "get_cornerstone_item_media" in names
|
||||||
|
assert "draft_marketplace_listing_with_cornerstone_image" in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_schemas_expose_scwiki_tools():
|
||||||
|
registry = ToolRegistry(FakeUEX(), scwiki=FakeSCWiki())
|
||||||
|
|
||||||
|
names = {schema["function"]["name"] for schema in registry.schemas}
|
||||||
|
|
||||||
|
assert "search_scwiki_pages" in names
|
||||||
|
assert "get_scwiki_page" in names
|
||||||
|
assert "search_scwiki_vehicles" in names
|
||||||
|
assert "get_scwiki_vehicle" in names
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_scmdb_missions_returns_reward_summary():
|
||||||
|
registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB())
|
||||||
|
|
||||||
|
result = await registry.search_scmdb_missions(query="tungsten", mission_type="hauling")
|
||||||
|
|
||||||
|
assert result["version"] == "4.7.2-live.1"
|
||||||
|
assert result["matched"] == 1
|
||||||
|
mission = result["missions"][0]
|
||||||
|
assert mission["title"] == "Move Tungsten"
|
||||||
|
assert mission["rewards"]["uec"] == 50250
|
||||||
|
assert mission["rewards"]["reputation"] == [{"faction": "Covalex", "scope": "FactionReputation", "amount": 125}]
|
||||||
|
assert mission["rewards"]["hauling"] == [
|
||||||
|
{"resource": "Tungsten", "min_scu": 6, "max_scu": 6, "max_container_size_scu": 1}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_scmdb_mission_rewards_enriches_items_blueprints_and_locations():
|
||||||
|
registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB())
|
||||||
|
|
||||||
|
result = await registry.get_scmdb_mission_rewards(debug_name="Bounty_Blueprint_Test")
|
||||||
|
|
||||||
|
mission = result["mission"]
|
||||||
|
assert mission["title"] == "Ambush Op"
|
||||||
|
assert mission["faction"] == "Bounty Hunters Guild"
|
||||||
|
assert mission["rewards"]["items"] == [{"name": "Council Scrip", "amount": 5}]
|
||||||
|
assert mission["rewards"]["blueprints"][0]["blueprints"] == ["Abrade Scraper Module"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_cornerstone_items_filters_sold_items():
|
||||||
|
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
|
||||||
|
|
||||||
|
result = await registry.search_cornerstone_items(query="scraper", sold_only=True)
|
||||||
|
|
||||||
|
assert result["matched"] == 2
|
||||||
|
assert {item["name"] for item in result["items"]} == {"Abrade Scraper Module", "Cinch Scraper Module"}
|
||||||
|
assert result["items"][0]["url"].startswith("https://finder.cstone.test/Search/item-")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_cornerstone_item_locations_parses_store_prices():
|
||||||
|
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
|
||||||
|
|
||||||
|
result = await registry.get_cornerstone_item_locations(query="abrade", location="Area18")
|
||||||
|
|
||||||
|
assert result["item"]["name"] == "Abrade Scraper Module"
|
||||||
|
assert result["item"]["general"]["manufacturer"] == "Greycat Industrial"
|
||||||
|
assert result["matched_locations"] == 1
|
||||||
|
assert result["locations"] == [
|
||||||
|
{
|
||||||
|
"location": "Stanton - ArcCorp - Area18 - Dumper's Depot",
|
||||||
|
"base_price": 21250,
|
||||||
|
"base_price_display": "21 250",
|
||||||
|
"verified": "2956-01-29",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_cornerstone_item_media_returns_absolute_image_urls():
|
||||||
|
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
|
||||||
|
|
||||||
|
result = await registry.get_cornerstone_item_media(query="abrade")
|
||||||
|
|
||||||
|
assert result["media"] == [
|
||||||
|
{
|
||||||
|
"url": "https://finder.cstone.test/images/abrade.png",
|
||||||
|
"source": "og:image",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_scwiki_pages_returns_general_knowledge_matches():
|
||||||
|
registry = ToolRegistry(FakeUEX(), scwiki=FakeSCWiki())
|
||||||
|
|
||||||
|
result = await registry.search_scwiki_pages(query="Carrack")
|
||||||
|
|
||||||
|
assert result["source"] == "https://starcitizen.tools"
|
||||||
|
assert result["matched"] == 1
|
||||||
|
assert result["pages"][0]["title"] == "Carrack"
|
||||||
|
assert result["pages"][0]["url"] == "https://starcitizen.tools/Carrack"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_scwiki_vehicle_returns_ship_prices_and_store_context():
|
||||||
|
registry = ToolRegistry(FakeUEX(), scwiki=FakeSCWiki())
|
||||||
|
|
||||||
|
result = await registry.get_scwiki_vehicle(query="Carrack")
|
||||||
|
|
||||||
|
assert result["source"] == "https://api.star-citizen.wiki"
|
||||||
|
vehicle = result["vehicle"]
|
||||||
|
assert vehicle["name"] == "Carrack"
|
||||||
|
assert vehicle["manufacturer"] == "Anvil Aerospace"
|
||||||
|
assert vehicle["msrp"] == 600
|
||||||
|
assert vehicle["purchase_locations"] == [
|
||||||
|
{
|
||||||
|
"price_buy": 34398000,
|
||||||
|
"terminal_name": "Astro Armada - Area 18",
|
||||||
|
"location": "Area18",
|
||||||
|
"parent_location": "ArcCorp",
|
||||||
|
"star_system": "Stanton",
|
||||||
|
"game_version": "4.8.1-LIVE.11952564",
|
||||||
|
"date_updated": "2026-05-20T18:39:37-04:00",
|
||||||
|
"uex_link": "https://uexcorp.space/vehicles/home/list/in_game_sell/?id_terminal=148",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_wikelo_ship_projects_returns_material_matches():
|
||||||
|
registry = ToolRegistry(FakeUEX(), wikelo=FakeWikelo())
|
||||||
|
|
||||||
|
result = await registry.search_wikelo_ship_projects(query="Polaris")
|
||||||
|
|
||||||
|
assert result["source"] == "https://wikelo-projects.test/Ships"
|
||||||
|
assert result["matched"] == 1
|
||||||
|
assert result["projects"][0]["ship_name"] == "Polaris Wikelo Special"
|
||||||
|
assert result["projects"][0]["required_materials"][0]["material_name"] == "Wikelo Favor"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_wikelo_ship_project_returns_full_requirements():
|
||||||
|
registry = ToolRegistry(FakeUEX(), wikelo=FakeWikelo())
|
||||||
|
|
||||||
|
result = await registry.get_wikelo_ship_project(ship_name="Guardian")
|
||||||
|
|
||||||
|
assert result["project"]["ship_name"] == "Guardian"
|
||||||
|
assert result["project"]["materials_count"] == 1
|
||||||
|
assert result["project"]["required_materials"] == [
|
||||||
|
{
|
||||||
|
"material_name": "Wikelo Favor",
|
||||||
|
"quantity_needed": 20,
|
||||||
|
"quantity_collected": 0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_draft_marketplace_listing_with_cornerstone_image_adds_image_data_and_redacts_display():
|
||||||
|
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
|
||||||
|
|
||||||
|
result = await registry.draft_marketplace_listing_with_cornerstone_image(
|
||||||
|
item_query="abrade",
|
||||||
|
id_category=3,
|
||||||
|
operation="sell",
|
||||||
|
type="item",
|
||||||
|
unit="unit",
|
||||||
|
title="Abrade Scraper Module",
|
||||||
|
description="Clean module, ready for pickup.",
|
||||||
|
price=21250,
|
||||||
|
currency="UEC",
|
||||||
|
language="en_US",
|
||||||
|
source="purchased_in_game",
|
||||||
|
in_stock=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
pending = result["pending_action"]
|
||||||
|
stored = registry.pending_actions[pending["id"]]
|
||||||
|
|
||||||
|
assert pending["endpoint"] == "marketplace_advertise"
|
||||||
|
assert pending["payload"]["image_data"].startswith("<base64 image data redacted")
|
||||||
|
assert stored.payload["image_data"] == "ZmFrZS1pbWFnZQ=="
|
||||||
|
assert pending["metadata"]["cornerstone_image_status"] == "included"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_draft_marketplace_listing_can_reuse_pasted_chat_image():
|
||||||
|
registry = ToolRegistry(FakeUEX())
|
||||||
|
|
||||||
|
with registry.chat_image_scope([{"name": "listing.png", "content_type": "image/png", "image_data": "ZmFrZS1pbWFnZQ=="}]):
|
||||||
|
result = await registry.draft_marketplace_listing(
|
||||||
|
id_category=3,
|
||||||
|
operation="sell",
|
||||||
|
type="item",
|
||||||
|
unit="unit",
|
||||||
|
title="Abrade Scraper Module",
|
||||||
|
description="Clean module, ready for pickup.",
|
||||||
|
price=21250,
|
||||||
|
currency="UEC",
|
||||||
|
language="en_US",
|
||||||
|
use_attached_image=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
pending = result["pending_action"]
|
||||||
|
stored = registry.pending_actions[pending["id"]]
|
||||||
|
assert pending["payload"]["image_data"].startswith("<base64 image data redacted")
|
||||||
|
assert stored.payload["image_data"] == "ZmFrZS1pbWFnZQ=="
|
||||||
|
assert pending["metadata"]["attached_chat_image_name"] == "listing.png"
|
||||||
|
assert pending["metadata"]["attached_chat_image_status"] == "included"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_cornerstone_item_page_extracts_locations():
|
||||||
|
parsed = parse_cornerstone_item_page(
|
||||||
|
"""
|
||||||
|
<html><head><title>Star Citizen - Food - Whamburger</title><meta property="og:image" content="/img/wham.png"></head>
|
||||||
|
<body><table><tr><td>NAME</td><td>Whamburger</td></tr></table>
|
||||||
|
<img src="https://example.test/extra.png" alt="Whamburger">
|
||||||
|
<table><tr><th>LOCATION</th><th>BASE PRICE</th><th>VERIFIED</th></tr>
|
||||||
|
<tr><td>Stanton - Area18 - Cubby Blast</td><td>9</td><td>2956-01-01</td></tr></table></body></html>
|
||||||
|
""",
|
||||||
|
"https://finder.cstone.test/Search/item-wham",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert parsed["name"] == "Whamburger"
|
||||||
|
assert parsed["locations"][0]["base_price"] == 9
|
||||||
|
assert parsed["media"][0]["url"] == "https://finder.cstone.test/img/wham.png"
|
||||||
|
assert parsed["media"][1]["url"] == "https://example.test/extra.png"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_cornerstone_client_accepts_json_encoded_string_payload():
|
||||||
|
respx.get("https://finder.cstone.space/GetSearch").mock(
|
||||||
|
return_value=Response(
|
||||||
|
200,
|
||||||
|
json='[{"id":"item-1","name":"Abrade Scraper Module","Sold":1}]',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client = CornerstoneClient("https://finder.cstone.space")
|
||||||
|
|
||||||
|
assert await client.list_items() == [{"id": "item-1", "name": "Abrade Scraper Module", "sold": True}]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_uex_api_index_finds_history_tools():
|
||||||
|
registry = ToolRegistry(FakeUEX())
|
||||||
|
|
||||||
|
result = await registry.execute("search_uex_api_index", {"query": "history", "history_only": True})
|
||||||
|
|
||||||
|
tools = {item["tool"] for item in result["get"]}
|
||||||
|
assert "get_uex_commodities_prices_history" in tools
|
||||||
|
assert "get_uex_marketplace_prices_history" in tools
|
||||||
|
assert "get_uex_currencies_index_history" in tools
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_summarize_commodity_price_history_returns_trend_metrics():
|
||||||
|
registry = ToolRegistry(FakeUEX())
|
||||||
|
|
||||||
|
result = await registry.execute(
|
||||||
|
"summarize_uex_commodity_price_history",
|
||||||
|
{"id_terminal": 7, "id_commodity": 3},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["resource"] == "commodities_prices_history"
|
||||||
|
assert result["count"] == 2
|
||||||
|
assert result["labels"] == {"commodity_name": "Gold", "terminal_name": "Port Tressler"}
|
||||||
|
assert result["metrics"]["price_buy"]["change"] == 200
|
||||||
|
assert result["metrics"]["price_sell"]["pct_change"] == -4.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_summarize_marketplace_and_currency_history():
|
||||||
|
registry = ToolRegistry(FakeUEX())
|
||||||
|
|
||||||
|
market = await registry.execute("summarize_uex_marketplace_price_history", {"item_name": "Widget"})
|
||||||
|
currency = await registry.execute("summarize_uex_currency_index_history", {"currency": "UEC"})
|
||||||
|
|
||||||
|
assert market["metrics"]["price"]["pct_change"] == 25.0
|
||||||
|
assert currency["metrics"]["index_value"]["change"] == 10.0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@respx.mock
|
@respx.mock
|
||||||
async def test_uex_client_get_user_normalizes_user_payload():
|
async def test_uex_client_get_user_normalizes_user_payload():
|
||||||
@@ -81,3 +893,16 @@ async def test_uex_client_get_user_normalizes_user_payload():
|
|||||||
result = await client.get_user(authenticated=True)
|
result = await client.get_user(authenticated=True)
|
||||||
|
|
||||||
assert result == {"status": "ok", "user": {"username": "pilot_hudson"}}
|
assert result == {"status": "ok", "user": {"username": "pilot_hudson"}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_uex_client_get_user_notifications_normalizes_payload():
|
||||||
|
respx.get("https://api.uexcorp.space/2.0/user_notifications/").mock(
|
||||||
|
return_value=Response(200, json={"status": "ok", "data": {"id": 7, "message": "Reply waiting", "date_read": 0}})
|
||||||
|
)
|
||||||
|
client = UEXClient("https://api.uexcorp.space/2.0", bearer_token="bearer")
|
||||||
|
|
||||||
|
result = await client.get_user_notifications()
|
||||||
|
|
||||||
|
assert result == {"status": "ok", "notifications": [{"id": 7, "message": "Reply waiting", "date_read": 0}]}
|
||||||
|
|||||||
+453
File diff suppressed because one or more lines are too long
+1630
-71
File diff suppressed because it is too large
Load Diff
+179
-5
@@ -1,22 +1,196 @@
|
|||||||
from functools import lru_cache
|
from __future__ import annotations
|
||||||
|
|
||||||
from pydantic import Field
|
from functools import lru_cache
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import Field, field_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
CONFIG_FIELDS: dict[str, dict[str, Any]] = {
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
"model_provider": {"env": "MODEL_PROVIDER", "type": "string", "secret": False},
|
||||||
|
"ollama_base_url": {"env": "OLLAMA_BASE_URL", "type": "string", "secret": False},
|
||||||
|
"ollama_model": {"env": "OLLAMA_MODEL", "type": "string", "secret": False},
|
||||||
|
"ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False},
|
||||||
|
"openai_base_url": {"env": "OPENAI_BASE_URL", "type": "string", "secret": False},
|
||||||
|
"openai_model": {"env": "OPENAI_MODEL", "type": "string", "secret": False},
|
||||||
|
"deepseek_base_url": {"env": "DEEPSEEK_BASE_URL", "type": "string", "secret": False},
|
||||||
|
"deepseek_model": {"env": "DEEPSEEK_MODEL", "type": "string", "secret": False},
|
||||||
|
"model_reasoning_effort": {"env": "MODEL_REASONING_EFFORT", "type": "string", "secret": False},
|
||||||
|
"codex_command": {"env": "CODEX_COMMAND", "type": "string", "secret": False},
|
||||||
|
"codex_model": {"env": "CODEX_MODEL", "type": "string", "secret": False},
|
||||||
|
"uex_base_url": {"env": "UEX_BASE_URL", "type": "string", "secret": False},
|
||||||
|
"scmdb_base_url": {"env": "SCMDB_BASE_URL", "type": "string", "secret": False},
|
||||||
|
"cornerstone_base_url": {"env": "CORNERSTONE_BASE_URL", "type": "string", "secret": False},
|
||||||
|
"scwiki_base_url": {"env": "SCWIKI_BASE_URL", "type": "string", "secret": False},
|
||||||
|
"scwiki_api_base_url": {"env": "SCWIKI_API_BASE_URL", "type": "string", "secret": False},
|
||||||
|
"openai_api_key": {"env": "OPENAI_API_KEY", "type": "string", "secret": True},
|
||||||
|
"deepseek_api_key": {"env": "DEEPSEEK_API_KEY", "type": "string", "secret": True},
|
||||||
|
"uex_secret_key": {"env": "UEX_SECRET_KEY", "type": "string", "secret": True},
|
||||||
|
"uex_bearer_token": {"env": "UEX_BEARER_TOKEN", "type": "string", "secret": True},
|
||||||
|
"uex_negotiation_close_endpoint": {"env": "UEX_NEGOTIATION_CLOSE_ENDPOINT", "type": "string", "secret": False},
|
||||||
|
"traderai_user_name": {"env": "TRADERAI_USER_NAME", "type": "string", "secret": False},
|
||||||
|
"traderai_memory_path": {"env": "TRADERAI_MEMORY_PATH", "type": "string", "secret": False},
|
||||||
|
"uex_notification_poll_seconds": {"env": "UEX_NOTIFICATION_POLL_SECONDS", "type": "integer", "secret": False},
|
||||||
|
"require_write_approval": {"env": "REQUIRE_WRITE_APPROVAL", "type": "boolean", "secret": False},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def app_data_dir() -> Path:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
root = os.environ.get("LOCALAPPDATA")
|
||||||
|
if root:
|
||||||
|
return Path(root) / "TraderAI"
|
||||||
|
return Path.home() / ".traderai"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_app_data_dir() -> Path:
|
||||||
|
path = app_data_dir()
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def user_config_path() -> Path:
|
||||||
|
return ensure_app_data_dir() / ".env"
|
||||||
|
|
||||||
|
|
||||||
|
def default_memory_path() -> Path:
|
||||||
|
return ensure_app_data_dir() / "traderai.sqlite3"
|
||||||
|
|
||||||
|
|
||||||
|
def log_path() -> Path:
|
||||||
|
return ensure_app_data_dir() / "TraderAI.log"
|
||||||
|
|
||||||
|
|
||||||
|
def edge_profile_dir() -> Path:
|
||||||
|
return ensure_app_data_dir() / "EdgeProfile"
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=(".env", str(user_config_path())),
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
model_provider: str = "ollama"
|
||||||
ollama_base_url: str = "http://localhost:11434"
|
ollama_base_url: str = "http://localhost:11434"
|
||||||
ollama_model: str = "qwen3.5:9b"
|
ollama_model: str = "qwen3.5:9b"
|
||||||
|
ollama_num_ctx: int = 64512
|
||||||
|
openai_base_url: str = "https://api.openai.com/v1"
|
||||||
|
openai_model: str = "gpt-5.4-mini"
|
||||||
|
deepseek_base_url: str = "https://api.deepseek.com"
|
||||||
|
deepseek_model: str = "deepseek-v4-flash"
|
||||||
|
model_reasoning_effort: str = "medium"
|
||||||
|
codex_command: str = "codex"
|
||||||
|
codex_model: str = "gpt-5.4"
|
||||||
uex_base_url: str = "https://api.uexcorp.space/2.0"
|
uex_base_url: str = "https://api.uexcorp.space/2.0"
|
||||||
|
scmdb_base_url: str = "https://scmdb.net"
|
||||||
|
cornerstone_base_url: str = "https://finder.cstone.space"
|
||||||
|
scwiki_base_url: str = "https://starcitizen.tools"
|
||||||
|
scwiki_api_base_url: str = "https://api.star-citizen.wiki"
|
||||||
|
openai_api_key: str | None = Field(default=None)
|
||||||
|
deepseek_api_key: str | None = Field(default=None)
|
||||||
uex_secret_key: str | None = Field(default=None)
|
uex_secret_key: str | None = Field(default=None)
|
||||||
uex_bearer_token: str | None = Field(default=None)
|
uex_bearer_token: str | None = Field(default=None)
|
||||||
|
uex_negotiation_close_endpoint: str = "marketplace_negotiations_close"
|
||||||
traderai_user_name: str | None = Field(default=None)
|
traderai_user_name: str | None = Field(default=None)
|
||||||
traderai_memory_path: str = "data/traderai.sqlite3"
|
traderai_memory_path: str = Field(default_factory=lambda: str(default_memory_path()))
|
||||||
|
uex_notification_poll_seconds: int = 300
|
||||||
require_write_approval: bool = True
|
require_write_approval: bool = True
|
||||||
|
|
||||||
|
@field_validator("openai_api_key", "deepseek_api_key", "uex_secret_key", "uex_bearer_token", "traderai_user_name", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _blank_optional(cls, value: Any) -> Any:
|
||||||
|
return None if value == "" else value
|
||||||
|
|
||||||
|
@field_validator("model_provider", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_model_provider(cls, value: Any) -> str:
|
||||||
|
text = str(value or "ollama").strip().casefold()
|
||||||
|
return text if text in {"ollama", "deepseek"} else "ollama"
|
||||||
|
|
||||||
|
@field_validator("model_reasoning_effort", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_reasoning_effort(cls, value: Any) -> str:
|
||||||
|
text = str(value or "medium").strip().casefold()
|
||||||
|
return text if text in {"none", "minimal", "low", "medium", "high", "xhigh", "max"} else "medium"
|
||||||
|
|
||||||
|
@field_validator("traderai_memory_path", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _blank_memory_path(cls, value: Any) -> Any:
|
||||||
|
return str(default_memory_path()) if value == "" or value is None else value
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
return Settings()
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
|
def settings_payload(settings: Settings | None = None) -> dict[str, Any]:
|
||||||
|
current = settings or get_settings()
|
||||||
|
values = current.model_dump()
|
||||||
|
secrets_configured = {}
|
||||||
|
for key, meta in CONFIG_FIELDS.items():
|
||||||
|
if meta.get("secret"):
|
||||||
|
secrets_configured[key] = bool(values.get(key))
|
||||||
|
values[key] = ""
|
||||||
|
return {
|
||||||
|
"app_data_dir": str(ensure_app_data_dir()),
|
||||||
|
"config_path": str(user_config_path()),
|
||||||
|
"log_path": str(log_path()),
|
||||||
|
"edge_profile_dir": str(edge_profile_dir()),
|
||||||
|
"values": values,
|
||||||
|
"fields": CONFIG_FIELDS,
|
||||||
|
"secrets_configured": secrets_configured,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def save_settings(values: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
current = get_settings().model_dump()
|
||||||
|
next_values = dict(current)
|
||||||
|
for key, value in values.items():
|
||||||
|
if key not in CONFIG_FIELDS:
|
||||||
|
continue
|
||||||
|
if CONFIG_FIELDS[key].get("secret") and value == "":
|
||||||
|
continue
|
||||||
|
next_values[key] = _coerce_value(key, value)
|
||||||
|
|
||||||
|
path = user_config_path()
|
||||||
|
lines = [
|
||||||
|
"# TraderAI desktop configuration",
|
||||||
|
"# Saved by the app. Environment variables still override these values.",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for key, meta in CONFIG_FIELDS.items():
|
||||||
|
value = next_values.get(key)
|
||||||
|
lines.append(f"{meta['env']}={_env_value(value)}")
|
||||||
|
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
return settings_payload(get_settings())
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_value(key: str, value: Any) -> Any:
|
||||||
|
field_type = CONFIG_FIELDS[key]["type"]
|
||||||
|
if value == "":
|
||||||
|
return None if key in {"openai_api_key", "deepseek_api_key", "uex_secret_key", "uex_bearer_token", "traderai_user_name"} else ""
|
||||||
|
if field_type == "integer":
|
||||||
|
return int(value)
|
||||||
|
if field_type == "boolean":
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
return str(value).strip().casefold() in {"1", "true", "yes", "on"}
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _env_value(value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "true" if value else "false"
|
||||||
|
text = str(value)
|
||||||
|
if not text or any(char.isspace() for char in text) or "#" in text:
|
||||||
|
return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||||
|
return text
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class CornerstoneError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CornerstoneClient:
|
||||||
|
def __init__(self, base_url: str = "https://finder.cstone.space") -> None:
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self._items: list[dict[str, Any]] | None = None
|
||||||
|
|
||||||
|
async def list_items(self) -> list[dict[str, Any]]:
|
||||||
|
if self._items is not None:
|
||||||
|
return self._items
|
||||||
|
body = await self._get_json("GetSearch")
|
||||||
|
if isinstance(body, str):
|
||||||
|
body = json.loads(body)
|
||||||
|
if not isinstance(body, list):
|
||||||
|
raise CornerstoneError("Cornerstone search response was not a list.")
|
||||||
|
self._items = [
|
||||||
|
{"id": item.get("id"), "name": item.get("name"), "sold": bool(item.get("Sold"))}
|
||||||
|
for item in body
|
||||||
|
if isinstance(item, dict) and item.get("id") and item.get("name")
|
||||||
|
]
|
||||||
|
return self._items
|
||||||
|
|
||||||
|
async def get_item_page(self, item_id: str) -> dict[str, Any]:
|
||||||
|
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{self.base_url}/Search/{item_id.strip()}",
|
||||||
|
headers={"Accept": "text/html,application/xhtml+xml"},
|
||||||
|
)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise CornerstoneError(f"Cornerstone HTTP {response.status_code}: {response.text[:240]}")
|
||||||
|
return {"url": str(response.url), "html": response.text}
|
||||||
|
|
||||||
|
async def get_image_data(self, url: str, max_bytes: int = 10_000_000) -> dict[str, Any]:
|
||||||
|
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||||
|
response = await client.get(url, headers={"Accept": "image/png,image/jpeg,image/*"})
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise CornerstoneError(f"Cornerstone image HTTP {response.status_code}: {response.text[:240]}")
|
||||||
|
content_type = response.headers.get("content-type", "").split(";")[0].strip().casefold()
|
||||||
|
if content_type not in {"image/jpeg", "image/jpg", "image/png"}:
|
||||||
|
raise CornerstoneError(f"Cornerstone image was not JPG or PNG: {content_type or 'unknown content type'}")
|
||||||
|
if len(response.content) > max_bytes:
|
||||||
|
raise CornerstoneError(f"Cornerstone image is larger than {max_bytes} bytes.")
|
||||||
|
return {
|
||||||
|
"url": str(response.url),
|
||||||
|
"content_type": content_type,
|
||||||
|
"size_bytes": len(response.content),
|
||||||
|
"image_data": base64.b64encode(response.content).decode("ascii"),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _get_json(self, path: str) -> Any:
|
||||||
|
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||||
|
response = await client.get(f"{self.base_url}/{path.lstrip('/')}", headers={"Accept": "application/json"})
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise CornerstoneError(f"Cornerstone returned non-JSON response: HTTP {response.status_code}") from exc
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise CornerstoneError(f"Cornerstone HTTP {response.status_code}: {body}")
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
class CornerstonePageParser(HTMLParser):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(convert_charrefs=True)
|
||||||
|
self.title = ""
|
||||||
|
self.tables: list[list[list[str]]] = []
|
||||||
|
self.images: list[dict[str, str]] = []
|
||||||
|
self._skip_depth = 0
|
||||||
|
self._in_title = False
|
||||||
|
self._current_table: list[list[str]] | None = None
|
||||||
|
self._current_row: list[str] | None = None
|
||||||
|
self._current_cell: list[str] | None = None
|
||||||
|
|
||||||
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||||
|
tag = tag.casefold()
|
||||||
|
if tag in {"script", "style"}:
|
||||||
|
self._skip_depth += 1
|
||||||
|
return
|
||||||
|
if self._skip_depth:
|
||||||
|
return
|
||||||
|
if tag == "title":
|
||||||
|
self._in_title = True
|
||||||
|
elif tag == "meta":
|
||||||
|
attr_map = self._attrs(attrs)
|
||||||
|
name = (attr_map.get("property") or attr_map.get("name") or "").casefold()
|
||||||
|
content = attr_map.get("content") or ""
|
||||||
|
if content and name in {"og:image", "twitter:image", "twitter:image:src"}:
|
||||||
|
self.images.append({"url": content, "source": name})
|
||||||
|
elif tag == "link":
|
||||||
|
attr_map = self._attrs(attrs)
|
||||||
|
rel = (attr_map.get("rel") or "").casefold()
|
||||||
|
href = attr_map.get("href") or ""
|
||||||
|
if href and "image_src" in rel:
|
||||||
|
self.images.append({"url": href, "source": "link:image_src"})
|
||||||
|
elif tag == "img":
|
||||||
|
attr_map = self._attrs(attrs)
|
||||||
|
url = attr_map.get("src") or attr_map.get("data-src") or attr_map.get("data-original") or ""
|
||||||
|
if url:
|
||||||
|
self.images.append(
|
||||||
|
{
|
||||||
|
"url": url,
|
||||||
|
"alt": attr_map.get("alt") or "",
|
||||||
|
"source": "img",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif tag == "table":
|
||||||
|
self._current_table = []
|
||||||
|
elif tag == "tr" and self._current_table is not None:
|
||||||
|
self._current_row = []
|
||||||
|
elif tag in {"td", "th"} and self._current_row is not None:
|
||||||
|
self._current_cell = []
|
||||||
|
|
||||||
|
def handle_endtag(self, tag: str) -> None:
|
||||||
|
tag = tag.casefold()
|
||||||
|
if tag in {"script", "style"} and self._skip_depth:
|
||||||
|
self._skip_depth -= 1
|
||||||
|
return
|
||||||
|
if self._skip_depth:
|
||||||
|
return
|
||||||
|
if tag == "title":
|
||||||
|
self._in_title = False
|
||||||
|
elif tag in {"td", "th"} and self._current_cell is not None and self._current_row is not None:
|
||||||
|
text = " ".join("".join(self._current_cell).split())
|
||||||
|
self._current_row.append(text)
|
||||||
|
self._current_cell = None
|
||||||
|
elif tag == "tr" and self._current_row is not None and self._current_table is not None:
|
||||||
|
if any(cell for cell in self._current_row):
|
||||||
|
self._current_table.append(self._current_row)
|
||||||
|
self._current_row = None
|
||||||
|
elif tag == "table" and self._current_table is not None:
|
||||||
|
if self._current_table:
|
||||||
|
self.tables.append(self._current_table)
|
||||||
|
self._current_table = None
|
||||||
|
|
||||||
|
def handle_data(self, data: str) -> None:
|
||||||
|
if self._skip_depth:
|
||||||
|
return
|
||||||
|
if self._in_title:
|
||||||
|
self.title += data
|
||||||
|
if self._current_cell is not None:
|
||||||
|
self._current_cell.append(data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _attrs(attrs: list[tuple[str, str | None]]) -> dict[str, str]:
|
||||||
|
return {key.casefold(): value or "" for key, value in attrs}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cornerstone_item_page(html: str, page_url: str | None = None) -> dict[str, Any]:
|
||||||
|
parser = CornerstonePageParser()
|
||||||
|
parser.feed(html)
|
||||||
|
info: dict[str, Any] = {"page_title": " ".join(parser.title.split())}
|
||||||
|
general: dict[str, str] = {}
|
||||||
|
locations = []
|
||||||
|
|
||||||
|
for table in parser.tables:
|
||||||
|
if not table:
|
||||||
|
continue
|
||||||
|
header = [cell.casefold() for cell in table[0]]
|
||||||
|
if len(header) >= 3 and "location" in header[0] and "price" in header[1] and "verified" in header[2]:
|
||||||
|
for row in table[1:]:
|
||||||
|
if len(row) < 3:
|
||||||
|
continue
|
||||||
|
locations.append(
|
||||||
|
{
|
||||||
|
"location": row[0],
|
||||||
|
"base_price": _parse_cornerstone_price(row[1]),
|
||||||
|
"base_price_display": row[1],
|
||||||
|
"verified": row[2],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif all(len(row) >= 2 for row in table):
|
||||||
|
for row in table:
|
||||||
|
key = row[0].strip().lower().replace(" ", "_")
|
||||||
|
value = row[1].strip()
|
||||||
|
if key and value and key not in general:
|
||||||
|
general[key] = value
|
||||||
|
|
||||||
|
info["name"] = general.get("name") or _name_from_title(info["page_title"])
|
||||||
|
media = _dedupe_media(parser.images, page_url)
|
||||||
|
if media:
|
||||||
|
info["media"] = media
|
||||||
|
if general:
|
||||||
|
info["general"] = general
|
||||||
|
info["locations"] = locations
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_cornerstone_price(value: str) -> int | None:
|
||||||
|
digits = "".join(char for char in value if char.isdigit())
|
||||||
|
return int(digits) if digits else None
|
||||||
|
|
||||||
|
|
||||||
|
def _name_from_title(title: str) -> str | None:
|
||||||
|
if " - " not in title:
|
||||||
|
return title or None
|
||||||
|
return title.rsplit(" - ", 1)[-1].strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_media(images: list[dict[str, str]], page_url: str | None = None) -> list[dict[str, str]]:
|
||||||
|
media = []
|
||||||
|
seen = set()
|
||||||
|
for image in images:
|
||||||
|
raw_url = (image.get("url") or "").strip()
|
||||||
|
if not raw_url or raw_url.startswith("data:"):
|
||||||
|
continue
|
||||||
|
url = urljoin(page_url or "", raw_url)
|
||||||
|
if url in seen:
|
||||||
|
continue
|
||||||
|
seen.add(url)
|
||||||
|
item = dict(image)
|
||||||
|
item["url"] = url
|
||||||
|
media.append(item)
|
||||||
|
return media
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
from typing import NoReturn
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from traderai.config import edge_profile_dir, log_path
|
||||||
|
|
||||||
|
|
||||||
|
def resource_path(*parts: str) -> Path:
|
||||||
|
base = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent.parent))
|
||||||
|
return base.joinpath(*parts)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
try:
|
||||||
|
_chdir_to_app_dir()
|
||||||
|
backend_port = _backend_port_from_args()
|
||||||
|
if backend_port is not None:
|
||||||
|
_run_server(backend_port)
|
||||||
|
return
|
||||||
|
_log("TraderAI desktop starting")
|
||||||
|
_log(f"cwd={Path.cwd()}")
|
||||||
|
_log(f"executable={sys.executable}")
|
||||||
|
_log(f"frozen={getattr(sys, 'frozen', False)} meipass={getattr(sys, '_MEIPASS', '')}")
|
||||||
|
port = _select_port()
|
||||||
|
url = f"http://127.0.0.1:{port}"
|
||||||
|
_log(f"selected_url={url}")
|
||||||
|
if _existing_server_ready(url):
|
||||||
|
_log("existing TraderAI backend found; opening window")
|
||||||
|
_open_window(url)
|
||||||
|
return
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
backend_process = _start_backend_process(port)
|
||||||
|
_log(f"backend process started pid={backend_process.pid}")
|
||||||
|
else:
|
||||||
|
server_thread = threading.Thread(target=_run_server, args=(port,), daemon=True)
|
||||||
|
server_thread.start()
|
||||||
|
_log("backend thread started")
|
||||||
|
_wait_for_server(url)
|
||||||
|
_log("backend health check passed")
|
||||||
|
_open_window(url)
|
||||||
|
_log("webview closed")
|
||||||
|
except Exception:
|
||||||
|
_log("fatal startup error")
|
||||||
|
_log(traceback.format_exc())
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _chdir_to_app_dir() -> None:
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
os.chdir(Path(sys.executable).resolve().parent)
|
||||||
|
|
||||||
|
|
||||||
|
def _select_port() -> int:
|
||||||
|
preferred = int(os.getenv("TRADERAI_PORT", "8765"))
|
||||||
|
if _port_available(preferred):
|
||||||
|
return preferred
|
||||||
|
_log(f"preferred port {preferred} is in use")
|
||||||
|
return _free_port()
|
||||||
|
|
||||||
|
|
||||||
|
def _backend_port_from_args() -> int | None:
|
||||||
|
args = sys.argv[1:]
|
||||||
|
if len(args) >= 2 and args[0] == "--backend-port":
|
||||||
|
return int(args[1])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _start_backend_process(port: int) -> subprocess.Popen:
|
||||||
|
command = [sys.executable, "--backend-port", str(port)]
|
||||||
|
_log(f"starting backend subprocess: {' '.join(command)}")
|
||||||
|
kwargs: dict[str, object] = {}
|
||||||
|
if sys.platform == "win32":
|
||||||
|
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||||
|
return subprocess.Popen(command, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def _port_available(port: int) -> bool:
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
|
sock.bind(("127.0.0.1", port))
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _free_port() -> int:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
return int(sock.getsockname()[1])
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_server_ready(url: str) -> bool:
|
||||||
|
try:
|
||||||
|
response = httpx.get(f"{url}/api/health", timeout=1)
|
||||||
|
return response.status_code < 500 and response.headers.get("content-type", "").startswith("application/json")
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _run_server(port: int) -> NoReturn:
|
||||||
|
try:
|
||||||
|
_log(f"backend starting on port {port}")
|
||||||
|
if sys.platform == "win32" and hasattr(asyncio, "WindowsProactorEventLoopPolicy"):
|
||||||
|
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
||||||
|
_log("set Windows Proactor event loop policy for subprocess-compatible backend")
|
||||||
|
from traderai.server import app
|
||||||
|
|
||||||
|
config = uvicorn.Config(
|
||||||
|
app,
|
||||||
|
host="127.0.0.1",
|
||||||
|
port=port,
|
||||||
|
log_level="info",
|
||||||
|
log_config=None,
|
||||||
|
lifespan="on",
|
||||||
|
)
|
||||||
|
server = uvicorn.Server(config)
|
||||||
|
server.run()
|
||||||
|
_log("backend server stopped")
|
||||||
|
raise SystemExit(0)
|
||||||
|
except BaseException:
|
||||||
|
_log("backend thread crashed")
|
||||||
|
_log(traceback.format_exc())
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_server(url: str) -> None:
|
||||||
|
deadline = time.monotonic() + 30
|
||||||
|
last_error = ""
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
response = httpx.get(f"{url}/api/health", timeout=1)
|
||||||
|
_log(f"health probe status={response.status_code}")
|
||||||
|
if response.status_code < 500:
|
||||||
|
return
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
last_error = str(exc)
|
||||||
|
_log(f"health probe failed: {last_error}")
|
||||||
|
time.sleep(0.25)
|
||||||
|
raise RuntimeError(f"TraderAI backend did not start within 30 seconds. {last_error}")
|
||||||
|
|
||||||
|
|
||||||
|
def _open_window(url: str) -> None:
|
||||||
|
mode = os.getenv("TRADERAI_DESKTOP_UI", "edge").casefold()
|
||||||
|
_log(f"ui_mode={mode}")
|
||||||
|
if mode == "webview":
|
||||||
|
_open_webview(url)
|
||||||
|
return
|
||||||
|
if _open_edge_app(url):
|
||||||
|
return
|
||||||
|
_open_browser(url)
|
||||||
|
|
||||||
|
|
||||||
|
def _open_webview(url: str) -> None:
|
||||||
|
_log("importing pywebview")
|
||||||
|
import webview
|
||||||
|
|
||||||
|
_log("creating pywebview window")
|
||||||
|
webview.create_window(
|
||||||
|
"TraderAI",
|
||||||
|
url,
|
||||||
|
width=1320,
|
||||||
|
height=860,
|
||||||
|
min_size=(980, 680),
|
||||||
|
text_select=True,
|
||||||
|
icon=str(resource_path("web", "art", "LBC_Logo.ico")),
|
||||||
|
)
|
||||||
|
_log("starting pywebview")
|
||||||
|
webview.start(gui="edgechromium", debug=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _open_edge_app(url: str) -> bool:
|
||||||
|
edge = _edge_path()
|
||||||
|
if not edge:
|
||||||
|
_log("msedge not found; falling back to default browser")
|
||||||
|
return False
|
||||||
|
profile_dir = edge_profile_dir()
|
||||||
|
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
command = [
|
||||||
|
str(edge),
|
||||||
|
f"--app={url}",
|
||||||
|
f"--user-data-dir={profile_dir}",
|
||||||
|
"--new-window",
|
||||||
|
"--no-first-run",
|
||||||
|
"--disable-features=Translate",
|
||||||
|
f"--app-icon={resource_path('web', 'art', 'LBC_Logo.ico')}",
|
||||||
|
]
|
||||||
|
_log(f"launching edge app: {' '.join(command)}")
|
||||||
|
process = subprocess.Popen(command)
|
||||||
|
_log(f"edge process id={process.pid}")
|
||||||
|
time.sleep(2)
|
||||||
|
if process.poll() is None:
|
||||||
|
process.wait()
|
||||||
|
_log("edge app process exited")
|
||||||
|
return True
|
||||||
|
_log(f"edge app process exited early code={process.returncode}; keeping backend alive")
|
||||||
|
_keep_alive()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _open_browser(url: str) -> None:
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
_log(f"opening default browser at {url}")
|
||||||
|
webbrowser.open(url)
|
||||||
|
_keep_alive()
|
||||||
|
|
||||||
|
|
||||||
|
def _keep_alive() -> None:
|
||||||
|
_log("backend staying alive; close TraderAI from Task Manager if no app window owns this process")
|
||||||
|
while True:
|
||||||
|
time.sleep(60)
|
||||||
|
|
||||||
|
|
||||||
|
def _edge_path() -> Path | None:
|
||||||
|
edge = shutil.which("msedge")
|
||||||
|
if edge:
|
||||||
|
return Path(edge)
|
||||||
|
candidates = [
|
||||||
|
Path(os.environ.get("ProgramFiles", "")) / "Microsoft" / "Edge" / "Application" / "msedge.exe",
|
||||||
|
Path(os.environ.get("ProgramFiles(x86)", "")) / "Microsoft" / "Edge" / "Application" / "msedge.exe",
|
||||||
|
Path(os.environ.get("LocalAppData", "")) / "Microsoft" / "Edge" / "Application" / "msedge.exe",
|
||||||
|
]
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate.exists():
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _log(message: str) -> None:
|
||||||
|
try:
|
||||||
|
log_path = _log_path()
|
||||||
|
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
with log_path.open("a", encoding="utf-8") as file:
|
||||||
|
file.write(f"[{timestamp}] {message}\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _log_path() -> Path:
|
||||||
|
return log_path()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+722
-14
@@ -8,6 +8,9 @@ from typing import Any
|
|||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_THREAD_ID = "default"
|
||||||
|
|
||||||
|
|
||||||
def utc_now() -> datetime:
|
def utc_now() -> datetime:
|
||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
@@ -27,6 +30,16 @@ def parse_iso(value: str) -> datetime:
|
|||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def unix_to_iso(value: Any) -> str | None:
|
||||||
|
try:
|
||||||
|
timestamp = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if timestamp <= 0:
|
||||||
|
return None
|
||||||
|
return datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
def time_since(value: str, now: datetime | None = None) -> str:
|
def time_since(value: str, now: datetime | None = None) -> str:
|
||||||
then = parse_iso(value)
|
then = parse_iso(value)
|
||||||
current = now or utc_now()
|
current = now or utc_now()
|
||||||
@@ -52,7 +65,7 @@ def _plural(value: int, unit: str) -> str:
|
|||||||
|
|
||||||
class MemoryStore:
|
class MemoryStore:
|
||||||
def __init__(self, path: str) -> None:
|
def __init__(self, path: str) -> None:
|
||||||
self.path = Path(path)
|
self.path = Path(path).expanduser().resolve()
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self._init_db()
|
self._init_db()
|
||||||
|
|
||||||
@@ -65,8 +78,16 @@ class MemoryStore:
|
|||||||
with self._connect() as db:
|
with self._connect() as db:
|
||||||
db.executescript(
|
db.executescript(
|
||||||
"""
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_threads (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS conversations (
|
CREATE TABLE IF NOT EXISTS conversations (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
thread_id TEXT,
|
||||||
role TEXT NOT NULL,
|
role TEXT NOT NULL,
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
@@ -127,27 +148,202 @@ class MemoryStore:
|
|||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
delivered_at TEXT
|
delivered_at TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS negotiation_threads (
|
||||||
|
negotiation_hash TEXT PRIMARY KEY,
|
||||||
|
uex_negotiation_id INTEGER,
|
||||||
|
listing_id INTEGER,
|
||||||
|
listing_slug TEXT,
|
||||||
|
title TEXT,
|
||||||
|
counterparty_username TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
last_message_at TEXT,
|
||||||
|
last_synced_at TEXT NOT NULL,
|
||||||
|
last_notification_id INTEGER,
|
||||||
|
last_notification_at TEXT,
|
||||||
|
unread_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
closed_at TEXT,
|
||||||
|
metadata_json TEXT NOT NULL DEFAULT '{}'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS negotiation_messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
negotiation_hash TEXT NOT NULL,
|
||||||
|
uex_message_id INTEGER,
|
||||||
|
author TEXT,
|
||||||
|
author_username TEXT,
|
||||||
|
is_me INTEGER NOT NULL DEFAULT 0,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
sent_at TEXT,
|
||||||
|
source TEXT,
|
||||||
|
raw_json TEXT NOT NULL DEFAULT '{}'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS negotiation_ratings (
|
||||||
|
negotiation_hash TEXT PRIMARY KEY,
|
||||||
|
deal_closed INTEGER NOT NULL,
|
||||||
|
deal_value REAL,
|
||||||
|
currency TEXT,
|
||||||
|
clarity_rating INTEGER,
|
||||||
|
speed_rating INTEGER,
|
||||||
|
respect_rating INTEGER,
|
||||||
|
fairness_rating INTEGER,
|
||||||
|
comment TEXT,
|
||||||
|
submitted_at TEXT NOT NULL,
|
||||||
|
raw_json TEXT NOT NULL DEFAULT '{}'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS negotiation_sync_state (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
self._ensure_column(db, "conversations", "thread_id", "TEXT")
|
||||||
def add_conversation(self, role: str, content: str) -> None:
|
now = iso_now()
|
||||||
with self._connect() as db:
|
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT INTO conversations(role, content, created_at) VALUES (?, ?, ?)",
|
"""
|
||||||
(role, content, iso_now()),
|
INSERT INTO chat_threads(id, title, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO NOTHING
|
||||||
|
""",
|
||||||
|
(DEFAULT_THREAD_ID, "New chat", now, now),
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
"UPDATE conversations SET thread_id = ? WHERE thread_id IS NULL OR thread_id = ''",
|
||||||
|
(DEFAULT_THREAD_ID,),
|
||||||
)
|
)
|
||||||
|
|
||||||
def last_interaction(self) -> dict[str, Any] | None:
|
@staticmethod
|
||||||
|
def _ensure_column(db: sqlite3.Connection, table: str, column: str, definition: str) -> None:
|
||||||
|
columns = {row["name"] for row in db.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||||
|
if column not in columns:
|
||||||
|
db.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
|
||||||
|
|
||||||
|
def ensure_thread(self, thread_id: str | None = None, title: str | None = None) -> dict[str, Any]:
|
||||||
|
now = iso_now()
|
||||||
|
resolved_id = (thread_id or DEFAULT_THREAD_ID).strip() or DEFAULT_THREAD_ID
|
||||||
|
resolved_title = (title or "New chat").strip() or "New chat"
|
||||||
with self._connect() as db:
|
with self._connect() as db:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO chat_threads(id, title, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET updated_at=excluded.updated_at
|
||||||
|
""",
|
||||||
|
(resolved_id, resolved_title, now, now),
|
||||||
|
)
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
"SELECT role, content, created_at FROM conversations ORDER BY id DESC LIMIT 1"
|
"SELECT id, title, created_at, updated_at FROM chat_threads WHERE id = ?",
|
||||||
|
(resolved_id,),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
def create_thread(self, title: str | None = None) -> dict[str, Any]:
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
thread_id = f"chat-{uuid.uuid4()}"
|
||||||
|
return self.ensure_thread(thread_id, title or "New chat")
|
||||||
|
|
||||||
|
def list_threads(self) -> list[dict[str, Any]]:
|
||||||
|
with self._connect() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.title,
|
||||||
|
t.created_at,
|
||||||
|
t.updated_at,
|
||||||
|
COUNT(c.id) AS message_count,
|
||||||
|
MAX(c.created_at) AS last_message_at
|
||||||
|
FROM chat_threads t
|
||||||
|
LEFT JOIN conversations c ON c.thread_id = t.id
|
||||||
|
GROUP BY t.id
|
||||||
|
ORDER BY COALESCE(MAX(c.created_at), t.updated_at) DESC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def delete_thread(self, thread_id: str) -> bool:
|
||||||
|
with self._connect() as db:
|
||||||
|
db.execute("DELETE FROM conversations WHERE thread_id = ?", (thread_id,))
|
||||||
|
cursor = db.execute("DELETE FROM chat_threads WHERE id = ?", (thread_id,))
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def rename_thread(self, thread_id: str, title: str) -> dict[str, Any] | None:
|
||||||
|
clean_title = self._clean_thread_title(title)
|
||||||
|
if not clean_title:
|
||||||
|
return None
|
||||||
|
now = iso_now()
|
||||||
|
with self._connect() as db:
|
||||||
|
db.execute(
|
||||||
|
"UPDATE chat_threads SET title = ?, updated_at = ? WHERE id = ?",
|
||||||
|
(clean_title, now, thread_id),
|
||||||
|
)
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT id, title, created_at, updated_at FROM chat_threads WHERE id = ?",
|
||||||
|
(thread_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
def recent_conversation(self, limit: int = 8) -> list[dict[str, Any]]:
|
def get_thread(self, thread_id: str) -> dict[str, Any] | None:
|
||||||
with self._connect() as db:
|
with self._connect() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT id, title, created_at, updated_at FROM chat_threads WHERE id = ?",
|
||||||
|
(thread_id,),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def add_conversation(self, role: str, content: str, thread_id: str | None = DEFAULT_THREAD_ID) -> None:
|
||||||
|
resolved_thread_id = (thread_id or DEFAULT_THREAD_ID).strip() or DEFAULT_THREAD_ID
|
||||||
|
self.ensure_thread(resolved_thread_id)
|
||||||
|
now = iso_now()
|
||||||
|
with self._connect() as db:
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO conversations(thread_id, role, content, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(resolved_thread_id, role, content, now),
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
"UPDATE chat_threads SET updated_at = ? WHERE id = ?",
|
||||||
|
(now, resolved_thread_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def last_interaction(self, thread_id: str | None = None) -> dict[str, Any] | None:
|
||||||
|
with self._connect() as db:
|
||||||
|
if thread_id:
|
||||||
|
row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT thread_id, role, content, created_at
|
||||||
|
FROM conversations
|
||||||
|
WHERE thread_id = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(thread_id,),
|
||||||
|
).fetchone()
|
||||||
|
else:
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT thread_id, role, content, created_at FROM conversations ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def recent_conversation(self, limit: int = 8, thread_id: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
with self._connect() as db:
|
||||||
|
if thread_id:
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
"SELECT role, content, created_at FROM conversations ORDER BY id DESC LIMIT ?",
|
"""
|
||||||
|
SELECT id, thread_id, role, content, created_at
|
||||||
|
FROM conversations
|
||||||
|
WHERE thread_id = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(thread_id, limit),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT id, thread_id, role, content, created_at FROM conversations ORDER BY id DESC LIMIT ?",
|
||||||
(limit,),
|
(limit,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(row) for row in reversed(rows)]
|
return [dict(row) for row in reversed(rows)]
|
||||||
@@ -222,13 +418,22 @@ class MemoryStore:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
conversations = db.execute(
|
conversations = db.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, role, content, created_at
|
SELECT id, thread_id, role, content, created_at
|
||||||
FROM conversations
|
FROM conversations
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""",
|
""",
|
||||||
(limit,),
|
(limit,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
threads = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, title, created_at, updated_at
|
||||||
|
FROM chat_threads
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
profile_rows = db.execute(
|
profile_rows = db.execute(
|
||||||
"SELECT key, value, updated_at FROM user_profile ORDER BY key"
|
"SELECT key, value, updated_at FROM user_profile ORDER BY key"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
@@ -239,6 +444,24 @@ class MemoryStore:
|
|||||||
"SELECT id, content, created_at, delivered_at FROM outbox ORDER BY id DESC LIMIT ?",
|
"SELECT id, content, created_at, delivered_at FROM outbox ORDER BY id DESC LIMIT ?",
|
||||||
(limit,),
|
(limit,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
negotiation_threads = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT negotiation_hash, title, counterparty_username, status, unread_count, last_message_at, last_synced_at
|
||||||
|
FROM negotiation_threads
|
||||||
|
ORDER BY COALESCE(last_message_at, last_synced_at) DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
negotiation_messages = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT negotiation_hash, author_username, is_me, body, sent_at
|
||||||
|
FROM negotiation_messages
|
||||||
|
ORDER BY COALESCE(sent_at, '') DESC, id DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
profile = []
|
profile = []
|
||||||
for row in profile_rows:
|
for row in profile_rows:
|
||||||
@@ -252,10 +475,13 @@ class MemoryStore:
|
|||||||
return {
|
return {
|
||||||
"path": str(self.path),
|
"path": str(self.path),
|
||||||
"memories": [self._memory_row(row) for row in memories],
|
"memories": [self._memory_row(row) for row in memories],
|
||||||
|
"chat_threads": [dict(row) for row in threads],
|
||||||
"conversations": [dict(row) for row in conversations],
|
"conversations": [dict(row) for row in conversations],
|
||||||
"profile": profile,
|
"profile": profile,
|
||||||
"scheduled_jobs": [dict(row) for row in jobs],
|
"scheduled_jobs": [dict(row) for row in jobs],
|
||||||
"outbox": [dict(row) for row in outbox],
|
"outbox": [dict(row) for row in outbox],
|
||||||
|
"negotiation_threads": [dict(row) for row in negotiation_threads],
|
||||||
|
"negotiation_messages": [dict(row) for row in negotiation_messages],
|
||||||
}
|
}
|
||||||
|
|
||||||
def clear(
|
def clear(
|
||||||
@@ -279,6 +505,10 @@ class MemoryStore:
|
|||||||
deleted["scheduled_jobs"] = db.execute("DELETE FROM scheduled_jobs").rowcount
|
deleted["scheduled_jobs"] = db.execute("DELETE FROM scheduled_jobs").rowcount
|
||||||
if include_outbox:
|
if include_outbox:
|
||||||
deleted["outbox"] = db.execute("DELETE FROM outbox").rowcount
|
deleted["outbox"] = db.execute("DELETE FROM outbox").rowcount
|
||||||
|
deleted["negotiation_threads"] = db.execute("DELETE FROM negotiation_threads").rowcount
|
||||||
|
deleted["negotiation_messages"] = db.execute("DELETE FROM negotiation_messages").rowcount
|
||||||
|
deleted["negotiation_ratings"] = db.execute("DELETE FROM negotiation_ratings").rowcount
|
||||||
|
deleted["negotiation_sync_state"] = db.execute("DELETE FROM negotiation_sync_state").rowcount
|
||||||
return deleted
|
return deleted
|
||||||
|
|
||||||
def set_profile(self, key: str, value: Any) -> None:
|
def set_profile(self, key: str, value: Any) -> None:
|
||||||
@@ -339,17 +569,38 @@ class MemoryStore:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
def mark_job_run(self, job_id: str, next_run_at: str | None = None) -> None:
|
def mark_job_run(self, job_id: str, next_run_at: str | None = None, enabled: bool = True) -> None:
|
||||||
with self._connect() as db:
|
with self._connect() as db:
|
||||||
db.execute(
|
db.execute(
|
||||||
"UPDATE scheduled_jobs SET last_run_at = ?, next_run_at = ? WHERE id = ?",
|
"UPDATE scheduled_jobs SET last_run_at = ?, next_run_at = ?, enabled = ? WHERE id = ?",
|
||||||
(iso_now(), next_run_at, job_id),
|
(iso_now(), next_run_at, 1 if enabled else 0, job_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
def add_outbox(self, content: str) -> None:
|
def add_outbox(self, content: str) -> None:
|
||||||
with self._connect() as db:
|
with self._connect() as db:
|
||||||
db.execute("INSERT INTO outbox(content, created_at) VALUES (?, ?)", (content, iso_now()))
|
db.execute("INSERT INTO outbox(content, created_at) VALUES (?, ?)", (content, iso_now()))
|
||||||
|
|
||||||
|
def list_outbox(self, limit: int = 100) -> list[dict[str, Any]]:
|
||||||
|
with self._connect() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT id, content, created_at, delivered_at FROM outbox ORDER BY id DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def get_outbox(self, inbox_id: int) -> dict[str, Any] | None:
|
||||||
|
with self._connect() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT id, content, created_at, delivered_at FROM outbox WHERE id = ?",
|
||||||
|
(inbox_id,),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def delete_outbox(self, inbox_id: int) -> bool:
|
||||||
|
with self._connect() as db:
|
||||||
|
cursor = db.execute("DELETE FROM outbox WHERE id = ?", (inbox_id,))
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
def undelivered_outbox(self) -> list[dict[str, Any]]:
|
def undelivered_outbox(self) -> list[dict[str, Any]]:
|
||||||
now = iso_now()
|
now = iso_now()
|
||||||
with self._connect() as db:
|
with self._connect() as db:
|
||||||
@@ -362,6 +613,18 @@ class MemoryStore:
|
|||||||
)
|
)
|
||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _thread_title(content: str) -> str:
|
||||||
|
text = " ".join(content.strip().split())
|
||||||
|
if not text:
|
||||||
|
return "New chat"
|
||||||
|
return text[:42] + ("..." if len(text) > 42 else "")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clean_thread_title(title: str) -> str:
|
||||||
|
text = " ".join(title.strip().strip('"').strip("'").split())
|
||||||
|
return text[:64]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _fts_query(query: str) -> str:
|
def _fts_query(query: str) -> str:
|
||||||
tokens = [token.replace('"', "") for token in query.split() if token.strip()]
|
tokens = [token.replace('"', "") for token in query.split() if token.strip()]
|
||||||
@@ -376,3 +639,448 @@ class MemoryStore:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
data["metadata"] = {}
|
data["metadata"] = {}
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
def set_negotiation_sync_state(self, key: str, value: Any) -> None:
|
||||||
|
with self._connect() as db:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO negotiation_sync_state(key, value, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
|
||||||
|
""",
|
||||||
|
(key, json.dumps(value), iso_now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_negotiation_sync_state(self, key: str, default: Any = None) -> Any:
|
||||||
|
with self._connect() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT value FROM negotiation_sync_state WHERE key = ?",
|
||||||
|
(key,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return json.loads(row["value"])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
def upsert_negotiation(
|
||||||
|
self,
|
||||||
|
negotiation_hash: str,
|
||||||
|
*,
|
||||||
|
uex_negotiation_id: int | None = None,
|
||||||
|
listing_id: int | None = None,
|
||||||
|
listing_slug: str | None = None,
|
||||||
|
title: str | None = None,
|
||||||
|
counterparty_username: str | None = None,
|
||||||
|
status: str = "open",
|
||||||
|
last_message_at: str | None = None,
|
||||||
|
last_synced_at: str | None = None,
|
||||||
|
last_notification_id: int | None = None,
|
||||||
|
last_notification_at: str | None = None,
|
||||||
|
unread_count: int | None = None,
|
||||||
|
closed_at: str | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
if not negotiation_hash.strip():
|
||||||
|
return
|
||||||
|
now = last_synced_at or iso_now()
|
||||||
|
with self._connect() as db:
|
||||||
|
existing = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT unread_count, metadata_json
|
||||||
|
FROM negotiation_threads
|
||||||
|
WHERE negotiation_hash = ?
|
||||||
|
""",
|
||||||
|
(negotiation_hash,),
|
||||||
|
).fetchone()
|
||||||
|
current_unread = int(existing["unread_count"]) if existing else 0
|
||||||
|
merged_metadata = {}
|
||||||
|
if existing:
|
||||||
|
try:
|
||||||
|
merged_metadata = json.loads(existing["metadata_json"])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
merged_metadata = {}
|
||||||
|
if metadata:
|
||||||
|
merged_metadata.update(metadata)
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO negotiation_threads(
|
||||||
|
negotiation_hash,
|
||||||
|
uex_negotiation_id,
|
||||||
|
listing_id,
|
||||||
|
listing_slug,
|
||||||
|
title,
|
||||||
|
counterparty_username,
|
||||||
|
status,
|
||||||
|
last_message_at,
|
||||||
|
last_synced_at,
|
||||||
|
last_notification_id,
|
||||||
|
last_notification_at,
|
||||||
|
unread_count,
|
||||||
|
closed_at,
|
||||||
|
metadata_json
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(negotiation_hash) DO UPDATE SET
|
||||||
|
uex_negotiation_id = COALESCE(excluded.uex_negotiation_id, negotiation_threads.uex_negotiation_id),
|
||||||
|
listing_id = COALESCE(excluded.listing_id, negotiation_threads.listing_id),
|
||||||
|
listing_slug = COALESCE(excluded.listing_slug, negotiation_threads.listing_slug),
|
||||||
|
title = COALESCE(excluded.title, negotiation_threads.title),
|
||||||
|
counterparty_username = COALESCE(excluded.counterparty_username, negotiation_threads.counterparty_username),
|
||||||
|
status = COALESCE(excluded.status, negotiation_threads.status),
|
||||||
|
last_message_at = COALESCE(excluded.last_message_at, negotiation_threads.last_message_at),
|
||||||
|
last_synced_at = excluded.last_synced_at,
|
||||||
|
last_notification_id = COALESCE(excluded.last_notification_id, negotiation_threads.last_notification_id),
|
||||||
|
last_notification_at = COALESCE(excluded.last_notification_at, negotiation_threads.last_notification_at),
|
||||||
|
unread_count = COALESCE(excluded.unread_count, negotiation_threads.unread_count),
|
||||||
|
closed_at = COALESCE(excluded.closed_at, negotiation_threads.closed_at),
|
||||||
|
metadata_json = excluded.metadata_json
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
negotiation_hash.strip(),
|
||||||
|
uex_negotiation_id,
|
||||||
|
listing_id,
|
||||||
|
listing_slug,
|
||||||
|
title,
|
||||||
|
counterparty_username,
|
||||||
|
status or "open",
|
||||||
|
last_message_at,
|
||||||
|
now,
|
||||||
|
last_notification_id,
|
||||||
|
last_notification_at,
|
||||||
|
current_unread if unread_count is None else max(0, int(unread_count)),
|
||||||
|
closed_at,
|
||||||
|
json.dumps(merged_metadata),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def replace_negotiation_messages(
|
||||||
|
self,
|
||||||
|
negotiation_hash: str,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
mark_read: bool = False,
|
||||||
|
) -> None:
|
||||||
|
if not negotiation_hash.strip():
|
||||||
|
return
|
||||||
|
normalized = [self._normalize_negotiation_message(negotiation_hash, item) for item in messages]
|
||||||
|
normalized = [item for item in normalized if item]
|
||||||
|
with self._connect() as db:
|
||||||
|
db.execute("DELETE FROM negotiation_messages WHERE negotiation_hash = ?", (negotiation_hash,))
|
||||||
|
for item in normalized:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO negotiation_messages(
|
||||||
|
negotiation_hash,
|
||||||
|
uex_message_id,
|
||||||
|
author,
|
||||||
|
author_username,
|
||||||
|
is_me,
|
||||||
|
body,
|
||||||
|
sent_at,
|
||||||
|
source,
|
||||||
|
raw_json
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
negotiation_hash,
|
||||||
|
item["uex_message_id"],
|
||||||
|
item["author"],
|
||||||
|
item["author_username"],
|
||||||
|
1 if item["is_me"] else 0,
|
||||||
|
item["body"],
|
||||||
|
item["sent_at"],
|
||||||
|
item["source"],
|
||||||
|
json.dumps(item["raw_json"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
last_message_at = normalized[-1]["sent_at"] if normalized else None
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE negotiation_threads
|
||||||
|
SET last_message_at = COALESCE(?, last_message_at),
|
||||||
|
last_synced_at = ?,
|
||||||
|
unread_count = CASE WHEN ? THEN 0 ELSE unread_count END
|
||||||
|
WHERE negotiation_hash = ?
|
||||||
|
""",
|
||||||
|
(last_message_at, iso_now(), 1 if mark_read else 0, negotiation_hash),
|
||||||
|
)
|
||||||
|
|
||||||
|
def mark_negotiation_notified(
|
||||||
|
self,
|
||||||
|
negotiation_hash: str,
|
||||||
|
*,
|
||||||
|
notification_id: int | None = None,
|
||||||
|
notification_at: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
with self._connect() as db:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE negotiation_threads
|
||||||
|
SET unread_count = unread_count + 1,
|
||||||
|
last_notification_id = COALESCE(?, last_notification_id),
|
||||||
|
last_notification_at = COALESCE(?, last_notification_at)
|
||||||
|
WHERE negotiation_hash = ?
|
||||||
|
""",
|
||||||
|
(notification_id, notification_at, negotiation_hash),
|
||||||
|
)
|
||||||
|
|
||||||
|
def mark_negotiation_read(self, negotiation_hash: str) -> None:
|
||||||
|
with self._connect() as db:
|
||||||
|
db.execute(
|
||||||
|
"UPDATE negotiation_threads SET unread_count = 0 WHERE negotiation_hash = ?",
|
||||||
|
(negotiation_hash,),
|
||||||
|
)
|
||||||
|
|
||||||
|
def store_negotiation_rating(self, negotiation_hash: str, payload: dict[str, Any], raw_json: dict[str, Any] | None = None) -> None:
|
||||||
|
now = iso_now()
|
||||||
|
with self._connect() as db:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO negotiation_ratings(
|
||||||
|
negotiation_hash,
|
||||||
|
deal_closed,
|
||||||
|
deal_value,
|
||||||
|
currency,
|
||||||
|
clarity_rating,
|
||||||
|
speed_rating,
|
||||||
|
respect_rating,
|
||||||
|
fairness_rating,
|
||||||
|
comment,
|
||||||
|
submitted_at,
|
||||||
|
raw_json
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(negotiation_hash) DO UPDATE SET
|
||||||
|
deal_closed = excluded.deal_closed,
|
||||||
|
deal_value = excluded.deal_value,
|
||||||
|
currency = excluded.currency,
|
||||||
|
clarity_rating = excluded.clarity_rating,
|
||||||
|
speed_rating = excluded.speed_rating,
|
||||||
|
respect_rating = excluded.respect_rating,
|
||||||
|
fairness_rating = excluded.fairness_rating,
|
||||||
|
comment = excluded.comment,
|
||||||
|
submitted_at = excluded.submitted_at,
|
||||||
|
raw_json = excluded.raw_json
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
negotiation_hash,
|
||||||
|
1 if payload.get("deal_closed") else 0,
|
||||||
|
payload.get("deal_value"),
|
||||||
|
payload.get("currency"),
|
||||||
|
payload.get("clarity_rating"),
|
||||||
|
payload.get("speed_rating"),
|
||||||
|
payload.get("respect_rating"),
|
||||||
|
payload.get("fairness_rating"),
|
||||||
|
payload.get("comment"),
|
||||||
|
now,
|
||||||
|
json.dumps(raw_json or payload),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_negotiations(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
status: str = "all",
|
||||||
|
unread_only: bool = False,
|
||||||
|
search: str = "",
|
||||||
|
limit: int = 50,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
status_filter = str(status or "all").strip().casefold()
|
||||||
|
search_filter = f"%{search.strip().casefold()}%" if search.strip() else None
|
||||||
|
clauses = []
|
||||||
|
params: list[Any] = []
|
||||||
|
if status_filter not in {"", "all"}:
|
||||||
|
clauses.append("status = ?")
|
||||||
|
params.append(status_filter)
|
||||||
|
if unread_only:
|
||||||
|
clauses.append("unread_count > 0")
|
||||||
|
if search_filter:
|
||||||
|
clauses.append(
|
||||||
|
"""
|
||||||
|
(
|
||||||
|
lower(COALESCE(title, '')) LIKE ?
|
||||||
|
OR lower(COALESCE(counterparty_username, '')) LIKE ?
|
||||||
|
OR lower(COALESCE(listing_slug, '')) LIKE ?
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
params.extend([search_filter, search_filter, search_filter])
|
||||||
|
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||||
|
with self._connect() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
negotiation_hash,
|
||||||
|
uex_negotiation_id,
|
||||||
|
listing_id,
|
||||||
|
listing_slug,
|
||||||
|
title,
|
||||||
|
counterparty_username,
|
||||||
|
status,
|
||||||
|
last_message_at,
|
||||||
|
last_synced_at,
|
||||||
|
last_notification_id,
|
||||||
|
last_notification_at,
|
||||||
|
unread_count,
|
||||||
|
closed_at,
|
||||||
|
metadata_json
|
||||||
|
FROM negotiation_threads
|
||||||
|
{where}
|
||||||
|
ORDER BY
|
||||||
|
unread_count DESC,
|
||||||
|
COALESCE(last_message_at, last_notification_at, last_synced_at) DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(*params, max(1, min(limit, 500))),
|
||||||
|
).fetchall()
|
||||||
|
return [self._negotiation_thread_row(row) for row in rows]
|
||||||
|
|
||||||
|
def get_negotiation(self, negotiation_hash: str) -> dict[str, Any] | None:
|
||||||
|
with self._connect() as db:
|
||||||
|
thread = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
negotiation_hash,
|
||||||
|
uex_negotiation_id,
|
||||||
|
listing_id,
|
||||||
|
listing_slug,
|
||||||
|
title,
|
||||||
|
counterparty_username,
|
||||||
|
status,
|
||||||
|
last_message_at,
|
||||||
|
last_synced_at,
|
||||||
|
last_notification_id,
|
||||||
|
last_notification_at,
|
||||||
|
unread_count,
|
||||||
|
closed_at,
|
||||||
|
metadata_json
|
||||||
|
FROM negotiation_threads
|
||||||
|
WHERE negotiation_hash = ?
|
||||||
|
""",
|
||||||
|
(negotiation_hash,),
|
||||||
|
).fetchone()
|
||||||
|
if not thread:
|
||||||
|
return None
|
||||||
|
messages = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
uex_message_id,
|
||||||
|
author,
|
||||||
|
author_username,
|
||||||
|
is_me,
|
||||||
|
body,
|
||||||
|
sent_at,
|
||||||
|
source,
|
||||||
|
raw_json
|
||||||
|
FROM negotiation_messages
|
||||||
|
WHERE negotiation_hash = ?
|
||||||
|
ORDER BY COALESCE(sent_at, '') ASC, id ASC
|
||||||
|
""",
|
||||||
|
(negotiation_hash,),
|
||||||
|
).fetchall()
|
||||||
|
rating = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
deal_closed,
|
||||||
|
deal_value,
|
||||||
|
currency,
|
||||||
|
clarity_rating,
|
||||||
|
speed_rating,
|
||||||
|
respect_rating,
|
||||||
|
fairness_rating,
|
||||||
|
comment,
|
||||||
|
submitted_at,
|
||||||
|
raw_json
|
||||||
|
FROM negotiation_ratings
|
||||||
|
WHERE negotiation_hash = ?
|
||||||
|
""",
|
||||||
|
(negotiation_hash,),
|
||||||
|
).fetchone()
|
||||||
|
result = self._negotiation_thread_row(thread)
|
||||||
|
result["messages"] = [self._negotiation_message_row(row) for row in messages]
|
||||||
|
result["rating"] = self._negotiation_rating_row(rating) if rating else None
|
||||||
|
return result
|
||||||
|
|
||||||
|
def search_negotiation_messages(self, query: str, limit: int = 8) -> list[dict[str, Any]]:
|
||||||
|
q = query.strip()
|
||||||
|
if not q:
|
||||||
|
return []
|
||||||
|
with self._connect() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
m.negotiation_hash,
|
||||||
|
t.title,
|
||||||
|
t.counterparty_username,
|
||||||
|
m.author_username,
|
||||||
|
m.is_me,
|
||||||
|
m.body,
|
||||||
|
m.sent_at
|
||||||
|
FROM negotiation_messages m
|
||||||
|
JOIN negotiation_threads t ON t.negotiation_hash = m.negotiation_hash
|
||||||
|
WHERE lower(m.body) LIKE ?
|
||||||
|
ORDER BY COALESCE(m.sent_at, '') DESC, m.id DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(f"%{q.casefold()}%", max(1, min(limit, 50))),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_negotiation_message(negotiation_hash: str, item: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
return None
|
||||||
|
body = str(item.get("body") or item.get("message") or item.get("content") or item.get("text") or item.get("event") or "").strip()
|
||||||
|
if not body:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"negotiation_hash": negotiation_hash,
|
||||||
|
"uex_message_id": MemoryStore._int_or_none(item.get("id") or item.get("id_message")),
|
||||||
|
"author": str(item.get("user_name") or item.get("author") or item.get("sender") or item.get("user_username") or "UEX"),
|
||||||
|
"author_username": str(item.get("user_username") or item.get("author_username") or item.get("username") or "").strip() or None,
|
||||||
|
"is_me": bool(item.get("is_me")),
|
||||||
|
"body": body,
|
||||||
|
"sent_at": unix_to_iso(item.get("date_added")) or str(item.get("sent_at") or "").strip() or None,
|
||||||
|
"source": str(item.get("api_name") or item.get("source") or "uex"),
|
||||||
|
"raw_json": item,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _negotiation_thread_row(row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]:
|
||||||
|
data = dict(row)
|
||||||
|
try:
|
||||||
|
data["metadata"] = json.loads(data.pop("metadata_json"))
|
||||||
|
except (KeyError, json.JSONDecodeError):
|
||||||
|
data["metadata"] = {}
|
||||||
|
data["hash"] = data.pop("negotiation_hash")
|
||||||
|
return data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _negotiation_message_row(row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]:
|
||||||
|
data = dict(row)
|
||||||
|
try:
|
||||||
|
data["raw_json"] = json.loads(data["raw_json"])
|
||||||
|
except (KeyError, json.JSONDecodeError):
|
||||||
|
data["raw_json"] = {}
|
||||||
|
data["is_me"] = bool(data.get("is_me"))
|
||||||
|
return data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _negotiation_rating_row(row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]:
|
||||||
|
data = dict(row)
|
||||||
|
try:
|
||||||
|
data["raw_json"] = json.loads(data["raw_json"])
|
||||||
|
except (KeyError, json.JSONDecodeError):
|
||||||
|
data["raw_json"] = {}
|
||||||
|
data["deal_closed"] = bool(data.get("deal_closed"))
|
||||||
|
return data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _int_or_none(value: Any) -> int | None:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from traderai.memory import MemoryStore, iso_now, unix_to_iso
|
||||||
|
from traderai.uex_client import UEXClient
|
||||||
|
|
||||||
|
|
||||||
|
UEX_NEGOTIATION_CLOSE_ENDPOINT = "marketplace_negotiations_close"
|
||||||
|
|
||||||
|
|
||||||
|
def extract_negotiation_hash(redir: str | None) -> str | None:
|
||||||
|
if not redir:
|
||||||
|
return None
|
||||||
|
parsed = urlparse(redir)
|
||||||
|
path = parsed.path or str(redir)
|
||||||
|
cleaned = path.strip("/")
|
||||||
|
parts = cleaned.split("/")
|
||||||
|
for index, part in enumerate(parts):
|
||||||
|
if part == "hash" and index + 1 < len(parts):
|
||||||
|
return parts[index + 1].strip() or None
|
||||||
|
if len(parts) >= 3 and parts[-3:-1] == ["marketplace", "negotiations"]:
|
||||||
|
return parts[-1].strip() or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NegotiationRefreshResult:
|
||||||
|
hash: str
|
||||||
|
refreshed: bool
|
||||||
|
summary: dict[str, Any] | None = None
|
||||||
|
messages_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class NegotiationSyncService:
|
||||||
|
def __init__(self, memory: MemoryStore, uex: UEXClient) -> None:
|
||||||
|
self.memory = memory
|
||||||
|
self.uex = uex
|
||||||
|
self.recent_days = 30
|
||||||
|
|
||||||
|
async def startup_sync(self) -> dict[str, Any]:
|
||||||
|
return await self.refresh_negotiations(seed_open_messages=True)
|
||||||
|
|
||||||
|
async def refresh_negotiations(self, *, seed_open_messages: bool = False) -> dict[str, Any]:
|
||||||
|
response = await self.uex.list_negotiations()
|
||||||
|
negotiations = response.get("negotiations") or response.get("data") or []
|
||||||
|
kept_hashes: list[str] = []
|
||||||
|
refreshed = 0
|
||||||
|
for item in negotiations:
|
||||||
|
normalized = self._normalize_negotiation_summary(item)
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
cached = self.memory.get_negotiation(normalized["negotiation_hash"])
|
||||||
|
if not self._should_keep_thread(normalized, cached):
|
||||||
|
continue
|
||||||
|
kept_hashes.append(normalized["negotiation_hash"])
|
||||||
|
self.memory.upsert_negotiation(**normalized)
|
||||||
|
if seed_open_messages and (normalized["status"] == "open" or cached is None):
|
||||||
|
result = await self.refresh_negotiation(normalized["negotiation_hash"], mark_read=False, summary=normalized)
|
||||||
|
if result.refreshed:
|
||||||
|
refreshed += 1
|
||||||
|
self.memory.set_negotiation_sync_state("last_full_negotiation_sync_at", iso_now())
|
||||||
|
return {
|
||||||
|
"count": len(kept_hashes),
|
||||||
|
"refreshed_threads": refreshed,
|
||||||
|
"negotiations": self.memory.list_negotiations(limit=200),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def refresh_negotiation(
|
||||||
|
self,
|
||||||
|
negotiation_hash: str,
|
||||||
|
*,
|
||||||
|
mark_read: bool = False,
|
||||||
|
summary: dict[str, Any] | None = None,
|
||||||
|
) -> NegotiationRefreshResult:
|
||||||
|
summary_data = summary or await self._fetch_summary_by_hash(negotiation_hash)
|
||||||
|
if summary_data:
|
||||||
|
self.memory.upsert_negotiation(**summary_data)
|
||||||
|
response = await self.uex.get_negotiation_messages(hash=negotiation_hash)
|
||||||
|
messages = response.get("messages") or response.get("data") or []
|
||||||
|
normalized_messages = [self._normalize_message(item) for item in messages if isinstance(item, dict)]
|
||||||
|
normalized_messages = [item for item in normalized_messages if item]
|
||||||
|
self.memory.replace_negotiation_messages(negotiation_hash, normalized_messages, mark_read=mark_read)
|
||||||
|
if mark_read:
|
||||||
|
self.memory.mark_negotiation_read(negotiation_hash)
|
||||||
|
return NegotiationRefreshResult(
|
||||||
|
hash=negotiation_hash,
|
||||||
|
refreshed=True,
|
||||||
|
summary=self.memory.get_negotiation(negotiation_hash),
|
||||||
|
messages_count=len(normalized_messages),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handle_notifications(self, notifications: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
if not notifications:
|
||||||
|
return []
|
||||||
|
grouped: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
passthrough: list[dict[str, Any]] = []
|
||||||
|
for item in notifications:
|
||||||
|
negotiation_hash = extract_negotiation_hash(item.get("redir"))
|
||||||
|
if not negotiation_hash:
|
||||||
|
passthrough.append(item)
|
||||||
|
continue
|
||||||
|
grouped.setdefault(negotiation_hash, []).append(item)
|
||||||
|
|
||||||
|
for negotiation_hash, items in grouped.items():
|
||||||
|
latest = max(items, key=lambda item: int(item.get("date_added") or 0))
|
||||||
|
await self.refresh_negotiation(negotiation_hash, mark_read=False)
|
||||||
|
self.memory.mark_negotiation_notified(
|
||||||
|
negotiation_hash,
|
||||||
|
notification_id=self._int_or_none(latest.get("id")),
|
||||||
|
notification_at=unix_to_iso(latest.get("date_added")) or iso_now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
for item in passthrough:
|
||||||
|
self.memory.add_outbox(self._notification_text(item))
|
||||||
|
|
||||||
|
self.memory.set_negotiation_sync_state("last_notification_sync_at", iso_now())
|
||||||
|
self.memory.set_negotiation_sync_state(
|
||||||
|
"last_seen_notification_ids",
|
||||||
|
sorted(self._int_or_none(item.get("id")) for item in notifications if self._int_or_none(item.get("id")) is not None),
|
||||||
|
)
|
||||||
|
return notifications
|
||||||
|
|
||||||
|
async def manual_send_message(self, negotiation_hash: str, message: str) -> dict[str, Any]:
|
||||||
|
result = await self.uex.send_negotiation_message(hash=negotiation_hash, message=message, is_production=1)
|
||||||
|
await self.refresh_negotiation(negotiation_hash, mark_read=True)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def manual_close_negotiation(self, negotiation_hash: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
result = await self.uex.close_negotiation(hash=negotiation_hash, **payload)
|
||||||
|
await self.refresh_negotiation(negotiation_hash, mark_read=True)
|
||||||
|
self.memory.store_negotiation_rating(negotiation_hash, payload, raw_json=result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def list_negotiations(self, *, status: str = "all", unread_only: bool = False, search: str = "", limit: int = 50) -> list[dict[str, Any]]:
|
||||||
|
return self.memory.list_negotiations(status=status, unread_only=unread_only, search=search, limit=limit)
|
||||||
|
|
||||||
|
def unread_count(self) -> int:
|
||||||
|
return sum(int(item.get("unread_count") or 0) for item in self.memory.list_negotiations(unread_only=True, limit=500))
|
||||||
|
|
||||||
|
def get_negotiation(self, negotiation_hash: str, *, mark_read: bool = True) -> dict[str, Any] | None:
|
||||||
|
negotiation = self.memory.get_negotiation(negotiation_hash)
|
||||||
|
if negotiation and mark_read:
|
||||||
|
self.memory.mark_negotiation_read(negotiation_hash)
|
||||||
|
negotiation["unread_count"] = 0
|
||||||
|
return negotiation
|
||||||
|
|
||||||
|
def search_messages(self, query: str, limit: int = 8) -> list[dict[str, Any]]:
|
||||||
|
return self.memory.search_negotiation_messages(query, limit=limit)
|
||||||
|
|
||||||
|
async def _fetch_summary_by_hash(self, negotiation_hash: str) -> dict[str, Any] | None:
|
||||||
|
response = await self.uex.list_negotiations(hash=negotiation_hash)
|
||||||
|
negotiations = response.get("negotiations") or response.get("data") or []
|
||||||
|
for item in negotiations:
|
||||||
|
normalized = self._normalize_negotiation_summary(item)
|
||||||
|
if normalized and normalized["negotiation_hash"] == negotiation_hash:
|
||||||
|
return normalized
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _normalize_negotiation_summary(self, item: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
negotiation_hash = str(item.get("hash") or item.get("negotiation_hash") or "").strip()
|
||||||
|
if not negotiation_hash:
|
||||||
|
return None
|
||||||
|
user = self.memory.get_profile().get("uex_user") or {}
|
||||||
|
current_username = str(user.get("username") or user.get("user_username") or "").strip().casefold()
|
||||||
|
advertiser_username = str(item.get("advertiser_username") or "").strip()
|
||||||
|
client_username = str(item.get("client_username") or "").strip()
|
||||||
|
is_listing_advertiser = bool(item.get("is_listing_advertiser"))
|
||||||
|
if current_username:
|
||||||
|
if advertiser_username.casefold() == current_username:
|
||||||
|
counterparty = client_username
|
||||||
|
elif client_username.casefold() == current_username:
|
||||||
|
counterparty = advertiser_username
|
||||||
|
else:
|
||||||
|
counterparty = client_username if is_listing_advertiser else advertiser_username
|
||||||
|
else:
|
||||||
|
counterparty = client_username if is_listing_advertiser else advertiser_username
|
||||||
|
closed_at = unix_to_iso(item.get("date_closed") or item.get("date_closed_client"))
|
||||||
|
metadata = {
|
||||||
|
"advertiser_name": item.get("advertiser_name"),
|
||||||
|
"advertiser_username": advertiser_username or None,
|
||||||
|
"client_name": item.get("client_name"),
|
||||||
|
"client_username": client_username or None,
|
||||||
|
"deal_value": item.get("deal_value"),
|
||||||
|
"deal_value_currency": item.get("deal_value_currency"),
|
||||||
|
"price": item.get("price"),
|
||||||
|
"unit": item.get("unit"),
|
||||||
|
"currency": item.get("currency"),
|
||||||
|
"raw": item,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"negotiation_hash": negotiation_hash,
|
||||||
|
"uex_negotiation_id": self._int_or_none(item.get("id") or item.get("id_negotiation")),
|
||||||
|
"listing_id": self._int_or_none(item.get("id_listing")),
|
||||||
|
"listing_slug": str(item.get("listing_slug") or "").strip() or None,
|
||||||
|
"title": str(item.get("listing_title") or item.get("title") or "").strip() or None,
|
||||||
|
"counterparty_username": counterparty or None,
|
||||||
|
"status": "closed" if closed_at else "open",
|
||||||
|
"last_message_at": unix_to_iso(item.get("date_modified") or item.get("date_added")),
|
||||||
|
"last_synced_at": iso_now(),
|
||||||
|
"closed_at": closed_at,
|
||||||
|
"metadata": metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _normalize_message(self, item: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
negotiation_hash = str(item.get("negotiation_hash") or "").strip()
|
||||||
|
if not negotiation_hash:
|
||||||
|
return None
|
||||||
|
user = self.memory.get_profile().get("uex_user") or {}
|
||||||
|
current_username = str(user.get("username") or user.get("user_username") or "").strip().casefold()
|
||||||
|
username = str(item.get("user_username") or "").strip()
|
||||||
|
normalized = dict(item)
|
||||||
|
normalized["is_me"] = bool(current_username and username.casefold() == current_username)
|
||||||
|
normalized["author"] = item.get("user_name") or username or "UEX"
|
||||||
|
normalized["source"] = item.get("api_name") or "uex"
|
||||||
|
normalized["body"] = item.get("message") or item.get("event") or ""
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _should_keep_thread(self, normalized: dict[str, Any], cached: dict[str, Any] | None) -> bool:
|
||||||
|
if cached:
|
||||||
|
return True
|
||||||
|
if normalized["status"] == "open":
|
||||||
|
return True
|
||||||
|
last_message_at = normalized.get("last_message_at")
|
||||||
|
if not last_message_at:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
age_seconds = max(0.0, (datetime.now(timezone.utc) - datetime.fromisoformat(last_message_at)).total_seconds())
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return age_seconds <= self.recent_days * 24 * 60 * 60
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _notification_text(item: dict[str, Any]) -> str:
|
||||||
|
message = item.get("message") or "You have a pending UEX notification."
|
||||||
|
redir = item.get("redir")
|
||||||
|
return f"UEX notification: {message}" + (f" (path `{redir}`)" if redir else "")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _int_or_none(value: Any) -> int | None:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
@@ -0,0 +1,614 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from traderai.memory import MemoryStore, iso_now
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_PLAN_CADENCE = "0 */6 * * *"
|
||||||
|
|
||||||
|
|
||||||
|
class ContinualPlanStore:
|
||||||
|
def __init__(self, memory: MemoryStore) -> None:
|
||||||
|
self.memory = memory
|
||||||
|
self._init_db()
|
||||||
|
|
||||||
|
def _init_db(self) -> None:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
db.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS continual_plans (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
objective TEXT NOT NULL,
|
||||||
|
constraints TEXT NOT NULL DEFAULT '{}',
|
||||||
|
cadence TEXT NOT NULL,
|
||||||
|
next_run_at TEXT,
|
||||||
|
last_run_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS continual_plan_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plan_id TEXT NOT NULL,
|
||||||
|
item_name TEXT NOT NULL,
|
||||||
|
desired_quantity INTEGER NOT NULL DEFAULT 1,
|
||||||
|
max_unit_price REAL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
acquired_quantity INTEGER NOT NULL DEFAULT 0,
|
||||||
|
metadata TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS continual_plan_candidates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plan_id TEXT NOT NULL,
|
||||||
|
plan_item_id INTEGER NOT NULL,
|
||||||
|
listing_id TEXT,
|
||||||
|
listing_slug TEXT,
|
||||||
|
title TEXT,
|
||||||
|
seller TEXT,
|
||||||
|
price REAL,
|
||||||
|
currency TEXT,
|
||||||
|
stock INTEGER,
|
||||||
|
location TEXT,
|
||||||
|
score REAL,
|
||||||
|
first_seen_at TEXT NOT NULL,
|
||||||
|
last_seen_at TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'current',
|
||||||
|
metadata TEXT NOT NULL DEFAULT '{}',
|
||||||
|
UNIQUE(plan_item_id, listing_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS continual_plan_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plan_id TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
metadata TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS continual_plan_negotiations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plan_id TEXT NOT NULL,
|
||||||
|
plan_item_id INTEGER,
|
||||||
|
candidate_id INTEGER,
|
||||||
|
listing_id TEXT,
|
||||||
|
listing_slug TEXT,
|
||||||
|
negotiation_id TEXT,
|
||||||
|
negotiation_hash TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'drafted',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_plan(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
kind: str = "buying",
|
||||||
|
objective: str = "",
|
||||||
|
items: list[dict[str, Any]] | None = None,
|
||||||
|
constraints: dict[str, Any] | None = None,
|
||||||
|
cadence: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
clean_items = [item for item in (items or []) if str(item.get("item_name") or item.get("name") or "").strip()]
|
||||||
|
plan_id = f"plan-{uuid.uuid4()}"
|
||||||
|
now = iso_now()
|
||||||
|
clean_kind = (kind.strip() or "buying").casefold()
|
||||||
|
resolved_status = status or ("needs_input" if clean_kind == "buying" and not clean_items else "active")
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO continual_plans(id, title, kind, status, objective, constraints, cadence, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
plan_id,
|
||||||
|
title.strip() or "Continual plan",
|
||||||
|
clean_kind,
|
||||||
|
resolved_status,
|
||||||
|
objective.strip() or title.strip(),
|
||||||
|
json.dumps(constraints or {}),
|
||||||
|
(cadence or DEFAULT_PLAN_CADENCE).strip() or DEFAULT_PLAN_CADENCE,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for item in clean_items:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO continual_plan_items(
|
||||||
|
plan_id, item_name, desired_quantity, max_unit_price, status,
|
||||||
|
acquired_quantity, metadata, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
plan_id,
|
||||||
|
str(item.get("item_name") or item.get("name")).strip(),
|
||||||
|
max(1, int(item.get("desired_quantity") or item.get("quantity") or 1)),
|
||||||
|
item.get("max_unit_price"),
|
||||||
|
max(0, int(item.get("acquired_quantity") or 0)),
|
||||||
|
json.dumps(item.get("metadata") or {}),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if clean_kind == "buying" and not clean_items:
|
||||||
|
self.add_event(plan_id, "needs_input", "Created plan, but no item checklist was provided. Add the required parts before it can run.")
|
||||||
|
elif clean_items:
|
||||||
|
self.add_event(plan_id, "created", f"Created continual {clean_kind} plan with {len(clean_items)} checklist item(s).")
|
||||||
|
else:
|
||||||
|
self.add_event(plan_id, "created", f"Created continual {clean_kind} plan.")
|
||||||
|
return self.get_plan(plan_id) or {}
|
||||||
|
|
||||||
|
def list_plans(self, include_inactive: bool = True) -> list[dict[str, Any]]:
|
||||||
|
where = "" if include_inactive else "WHERE status = 'active'"
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
f"""
|
||||||
|
SELECT *
|
||||||
|
FROM continual_plans
|
||||||
|
{where}
|
||||||
|
ORDER BY
|
||||||
|
CASE status WHEN 'active' THEN 0 WHEN 'needs_input' THEN 1 WHEN 'paused' THEN 2 ELSE 3 END,
|
||||||
|
updated_at DESC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
return [self._plan_row(row) for row in rows]
|
||||||
|
|
||||||
|
def get_plan(self, plan_id: str) -> dict[str, Any] | None:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
plan = db.execute("SELECT * FROM continual_plans WHERE id = ?", (plan_id,)).fetchone()
|
||||||
|
if not plan:
|
||||||
|
return None
|
||||||
|
data = self._plan_row(plan)
|
||||||
|
data["items"] = self.list_items(plan_id)
|
||||||
|
data["candidates"] = self.list_candidates(plan_id)
|
||||||
|
data["negotiations"] = self.list_negotiations(plan_id)
|
||||||
|
data["events"] = self.list_events(plan_id)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def list_items(self, plan_id: str) -> list[dict[str, Any]]:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT * FROM continual_plan_items WHERE plan_id = ? ORDER BY id",
|
||||||
|
(plan_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [self._json_row(row, "metadata") for row in rows]
|
||||||
|
|
||||||
|
def list_candidates(self, plan_id: str, limit: int = 100) -> list[dict[str, Any]]:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM continual_plan_candidates
|
||||||
|
WHERE plan_id = ?
|
||||||
|
ORDER BY status = 'current' DESC, score DESC, last_seen_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(plan_id, limit),
|
||||||
|
).fetchall()
|
||||||
|
return [self._json_row(row, "metadata") for row in rows]
|
||||||
|
|
||||||
|
def list_events(self, plan_id: str, limit: int = 50) -> list[dict[str, Any]]:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM continual_plan_events
|
||||||
|
WHERE plan_id = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(plan_id, limit),
|
||||||
|
).fetchall()
|
||||||
|
return [self._json_row(row, "metadata") for row in rows]
|
||||||
|
|
||||||
|
def list_negotiations(self, plan_id: str) -> list[dict[str, Any]]:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT * FROM continual_plan_negotiations WHERE plan_id = ? ORDER BY updated_at DESC",
|
||||||
|
(plan_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def set_status(self, plan_id: str, status: str) -> dict[str, Any] | None:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
db.execute(
|
||||||
|
"UPDATE continual_plans SET status = ?, updated_at = ? WHERE id = ?",
|
||||||
|
(status, iso_now(), plan_id),
|
||||||
|
)
|
||||||
|
self.add_event(plan_id, status, f"Plan status changed to {status}.")
|
||||||
|
return self.get_plan(plan_id)
|
||||||
|
|
||||||
|
def delete_plan(self, plan_id: str) -> bool:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
deleted = db.execute("DELETE FROM continual_plans WHERE id = ?", (plan_id,)).rowcount
|
||||||
|
if not deleted:
|
||||||
|
return False
|
||||||
|
db.execute("DELETE FROM continual_plan_items WHERE plan_id = ?", (plan_id,))
|
||||||
|
db.execute("DELETE FROM continual_plan_candidates WHERE plan_id = ?", (plan_id,))
|
||||||
|
db.execute("DELETE FROM continual_plan_events WHERE plan_id = ?", (plan_id,))
|
||||||
|
db.execute("DELETE FROM continual_plan_negotiations WHERE plan_id = ?", (plan_id,))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def add_event(self, plan_id: str, kind: str, message: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
|
now = iso_now()
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
cursor = db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO continual_plan_events(plan_id, kind, message, metadata, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(plan_id, kind, message, json.dumps(metadata or {}), now),
|
||||||
|
)
|
||||||
|
return {"id": cursor.lastrowid, "plan_id": plan_id, "kind": kind, "message": message, "created_at": now}
|
||||||
|
|
||||||
|
def update_schedule(self, plan_id: str, next_run_at: str | None = None, last_run_at: str | None = None) -> None:
|
||||||
|
fields = ["next_run_at = ?", "updated_at = ?"]
|
||||||
|
values: list[Any] = [next_run_at, iso_now()]
|
||||||
|
if last_run_at is not None:
|
||||||
|
fields.insert(1, "last_run_at = ?")
|
||||||
|
values.insert(1, last_run_at)
|
||||||
|
values.append(plan_id)
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
db.execute(f"UPDATE continual_plans SET {', '.join(fields)} WHERE id = ?", values)
|
||||||
|
|
||||||
|
def upsert_candidate(self, plan_id: str, plan_item_id: int, listing: dict[str, Any], score: float) -> dict[str, Any]:
|
||||||
|
now = iso_now()
|
||||||
|
listing_id = str(listing.get("id") or listing.get("listing_id") or listing.get("slug") or uuid.uuid4())
|
||||||
|
metadata = dict(listing)
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO continual_plan_candidates(
|
||||||
|
plan_id, plan_item_id, listing_id, listing_slug, title, seller, price, currency,
|
||||||
|
stock, location, score, first_seen_at, last_seen_at, status, metadata
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'current', ?)
|
||||||
|
ON CONFLICT(plan_item_id, listing_id) DO UPDATE SET
|
||||||
|
listing_slug=excluded.listing_slug,
|
||||||
|
title=excluded.title,
|
||||||
|
seller=excluded.seller,
|
||||||
|
price=excluded.price,
|
||||||
|
currency=excluded.currency,
|
||||||
|
stock=excluded.stock,
|
||||||
|
location=excluded.location,
|
||||||
|
score=excluded.score,
|
||||||
|
last_seen_at=excluded.last_seen_at,
|
||||||
|
status='current',
|
||||||
|
metadata=excluded.metadata
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
plan_id,
|
||||||
|
plan_item_id,
|
||||||
|
listing_id,
|
||||||
|
listing.get("slug"),
|
||||||
|
listing.get("title"),
|
||||||
|
listing.get("advertiser") or listing.get("user_username") or listing.get("seller"),
|
||||||
|
listing.get("price"),
|
||||||
|
listing.get("currency"),
|
||||||
|
listing.get("in_stock") or listing.get("stock"),
|
||||||
|
listing.get("location"),
|
||||||
|
score,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
json.dumps(metadata),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT * FROM continual_plan_candidates WHERE plan_item_id = ? AND listing_id = ?",
|
||||||
|
(plan_item_id, listing_id),
|
||||||
|
).fetchone()
|
||||||
|
return self._json_row(row, "metadata")
|
||||||
|
|
||||||
|
def mark_stale_candidates(self, plan_item_id: int, seen_listing_ids: set[str]) -> int:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT id, listing_id FROM continual_plan_candidates WHERE plan_item_id = ? AND status = 'current'",
|
||||||
|
(plan_item_id,),
|
||||||
|
).fetchall()
|
||||||
|
stale_ids = [row["id"] for row in rows if str(row["listing_id"]) not in seen_listing_ids]
|
||||||
|
if stale_ids:
|
||||||
|
placeholders = ",".join("?" for _ in stale_ids)
|
||||||
|
db.execute(
|
||||||
|
f"UPDATE continual_plan_candidates SET status = 'stale', last_seen_at = ? WHERE id IN ({placeholders})",
|
||||||
|
(iso_now(), *stale_ids),
|
||||||
|
)
|
||||||
|
return len(stale_ids)
|
||||||
|
|
||||||
|
def mark_candidate_drafted(self, candidate_id: int) -> None:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
db.execute("UPDATE continual_plan_candidates SET status = 'drafted', last_seen_at = ? WHERE id = ?", (iso_now(), candidate_id))
|
||||||
|
|
||||||
|
def add_negotiation(self, plan_id: str, plan_item_id: int | None, candidate_id: int | None, metadata: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
now = iso_now()
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
cursor = db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO continual_plan_negotiations(
|
||||||
|
plan_id, plan_item_id, candidate_id, listing_id, listing_slug,
|
||||||
|
negotiation_id, negotiation_hash, status, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
plan_id,
|
||||||
|
plan_item_id,
|
||||||
|
candidate_id,
|
||||||
|
metadata.get("listing_id"),
|
||||||
|
metadata.get("listing_slug"),
|
||||||
|
metadata.get("id_negotiation"),
|
||||||
|
metadata.get("hash"),
|
||||||
|
metadata.get("status") or "drafted",
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = db.execute("SELECT * FROM continual_plan_negotiations WHERE id = ?", (cursor.lastrowid,)).fetchone()
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
def has_negotiation_for_candidate(self, plan_id: str, plan_item_id: int, candidate: dict[str, Any]) -> bool:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM continual_plan_negotiations
|
||||||
|
WHERE plan_id = ?
|
||||||
|
AND plan_item_id = ?
|
||||||
|
AND (
|
||||||
|
candidate_id = ?
|
||||||
|
OR (listing_id IS NOT NULL AND listing_id = ?)
|
||||||
|
OR (listing_slug IS NOT NULL AND listing_slug = ?)
|
||||||
|
)
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
plan_id,
|
||||||
|
plan_item_id,
|
||||||
|
candidate.get("id"),
|
||||||
|
candidate.get("listing_id"),
|
||||||
|
candidate.get("listing_slug"),
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _json_row(row: Any, *json_fields: str) -> dict[str, Any]:
|
||||||
|
data = dict(row)
|
||||||
|
for field in json_fields:
|
||||||
|
try:
|
||||||
|
data[field] = json.loads(data.get(field) or "{}")
|
||||||
|
except (TypeError, json.JSONDecodeError):
|
||||||
|
data[field] = {}
|
||||||
|
return data
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _plan_row(cls, row: Any) -> dict[str, Any]:
|
||||||
|
return cls._json_row(row, "constraints")
|
||||||
|
|
||||||
|
|
||||||
|
class ContinualPlanRunner:
|
||||||
|
def __init__(self, store: ContinualPlanStore, tools: Any, memory: MemoryStore, agent: Any | None = None) -> None:
|
||||||
|
self.store = store
|
||||||
|
self.tools = tools
|
||||||
|
self.memory = memory
|
||||||
|
self.agent = agent
|
||||||
|
|
||||||
|
def bind_agent(self, agent: Any) -> None:
|
||||||
|
self.agent = agent
|
||||||
|
|
||||||
|
async def run_plan(self, plan_id: str) -> dict[str, Any]:
|
||||||
|
plan = self.store.get_plan(plan_id)
|
||||||
|
if not plan:
|
||||||
|
return {"error": f"Plan not found: {plan_id}"}
|
||||||
|
if plan["status"] != "active":
|
||||||
|
message = f"Skipped {plan['title']} because status is {plan['status']}."
|
||||||
|
self.store.add_event(plan_id, "skipped", message)
|
||||||
|
return {"status": "skipped", "summary": message, "plan": self.store.get_plan(plan_id)}
|
||||||
|
try:
|
||||||
|
if plan["kind"] == "buying":
|
||||||
|
result = await self._run_buying_plan(plan)
|
||||||
|
else:
|
||||||
|
result = await self._run_agent_plan(plan)
|
||||||
|
self.store.update_schedule(plan_id, plan.get("next_run_at"), last_run_at=iso_now())
|
||||||
|
self.memory.add_outbox(result["summary"])
|
||||||
|
return {**result, "plan": self.store.get_plan(plan_id)}
|
||||||
|
except Exception as exc:
|
||||||
|
message = f"Continual plan failed: {exc}"
|
||||||
|
self.store.add_event(plan_id, "error", message)
|
||||||
|
self.memory.add_outbox(f"{plan['title']}: {message}")
|
||||||
|
self.store.update_schedule(plan_id, plan.get("next_run_at"), last_run_at=iso_now())
|
||||||
|
return {"error": str(exc), "summary": message, "plan": self.store.get_plan(plan_id)}
|
||||||
|
|
||||||
|
async def _run_agent_plan(self, plan: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if self.agent is None:
|
||||||
|
raise RuntimeError("No agent is bound to run generic continual plans.")
|
||||||
|
prompt = self._agent_plan_prompt(plan)
|
||||||
|
response = await self.agent.generate_wake_response(prompt)
|
||||||
|
summary = f"{plan['title']}: {response}"
|
||||||
|
self.store.add_event(plan["id"], "run", "Ran generic continual plan through the agent.", {"response": response})
|
||||||
|
return {"status": "ok", "summary": summary, "checked": 0, "drafted": 0}
|
||||||
|
|
||||||
|
async def _run_buying_plan(self, plan: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
items = [item for item in plan.get("items") or [] if item.get("status") != "acquired"]
|
||||||
|
if not items:
|
||||||
|
self.store.set_status(plan["id"], "completed")
|
||||||
|
summary = f"{plan['title']}: all checklist items are marked acquired."
|
||||||
|
return {"status": "completed", "summary": summary, "drafted": 0, "checked": 0}
|
||||||
|
|
||||||
|
checked = 0
|
||||||
|
drafted = 0
|
||||||
|
best_lines = []
|
||||||
|
constraints = plan.get("constraints") or {}
|
||||||
|
excluded_sellers = {str(value).casefold() for value in constraints.get("excluded_sellers") or []}
|
||||||
|
preferred_locations = [str(value).casefold() for value in constraints.get("preferred_locations") or []]
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
response = await self.tools.search_marketplace_listings(
|
||||||
|
query=item["item_name"],
|
||||||
|
operation="sell",
|
||||||
|
type="item",
|
||||||
|
limit=25,
|
||||||
|
)
|
||||||
|
listings = response.get("listings") or response.get("data") or []
|
||||||
|
seen: set[str] = set()
|
||||||
|
candidates = []
|
||||||
|
for listing in listings:
|
||||||
|
if not isinstance(listing, dict):
|
||||||
|
continue
|
||||||
|
listing_id = str(listing.get("id") or listing.get("slug") or "")
|
||||||
|
if listing_id:
|
||||||
|
seen.add(listing_id)
|
||||||
|
if str(listing.get("advertiser") or listing.get("seller") or "").casefold() in excluded_sellers:
|
||||||
|
continue
|
||||||
|
score = self._candidate_score(listing, item, preferred_locations)
|
||||||
|
candidate = self.store.upsert_candidate(plan["id"], int(item["id"]), listing, score)
|
||||||
|
candidates.append(candidate)
|
||||||
|
stale = self.store.mark_stale_candidates(int(item["id"]), seen)
|
||||||
|
checked += 1
|
||||||
|
current_candidates = [candidate for candidate in candidates if candidate.get("status") == "current"]
|
||||||
|
current_candidates.sort(key=lambda candidate: (-float(candidate.get("score") or 0), float(candidate.get("price") or 10**18)))
|
||||||
|
best = current_candidates[0] if current_candidates else None
|
||||||
|
if not best:
|
||||||
|
best_lines.append(f"{item['item_name']}: no active matching sell listings found.")
|
||||||
|
self.store.add_event(plan["id"], "search", f"{item['item_name']}: no active candidates found.", {"stale": stale})
|
||||||
|
continue
|
||||||
|
|
||||||
|
best_lines.append(
|
||||||
|
f"{item['item_name']}: best candidate is {best.get('title') or best.get('listing_slug')} "
|
||||||
|
f"at {self._format_price(best.get('price'), best.get('currency'))} from {best.get('seller') or 'unknown seller'}."
|
||||||
|
)
|
||||||
|
self.store.add_event(
|
||||||
|
plan["id"],
|
||||||
|
"search",
|
||||||
|
f"{item['item_name']}: found {len(current_candidates)} current candidate(s); {stale} stale candidate(s) marked.",
|
||||||
|
{"best_candidate_id": best.get("id")},
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.store.has_negotiation_for_candidate(plan["id"], int(item["id"]), best) or not self._within_budget(best, item, constraints):
|
||||||
|
continue
|
||||||
|
draft = await self._draft_buying_message(plan, item, best)
|
||||||
|
if "pending_action" in draft:
|
||||||
|
drafted += 1
|
||||||
|
self.store.mark_candidate_drafted(int(best["id"]))
|
||||||
|
self.store.add_negotiation(
|
||||||
|
plan["id"],
|
||||||
|
int(item["id"]),
|
||||||
|
int(best["id"]),
|
||||||
|
{
|
||||||
|
"listing_id": best.get("listing_id"),
|
||||||
|
"listing_slug": best.get("listing_slug"),
|
||||||
|
"status": "drafted",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.store.add_event(
|
||||||
|
plan["id"],
|
||||||
|
"draft",
|
||||||
|
f"Drafted negotiation opener for {item['item_name']} candidate {best.get('listing_id')}.",
|
||||||
|
{"pending_action_id": draft["pending_action"].get("id"), "candidate_id": best.get("id")},
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = f"{plan['title']}: checked {checked} item(s). " + " ".join(best_lines[:4])
|
||||||
|
if drafted:
|
||||||
|
summary += f" Drafted {drafted} negotiation message(s) for approval."
|
||||||
|
self.store.add_event(plan["id"], "run", summary, {"checked": checked, "drafted": drafted})
|
||||||
|
return {"status": "ok", "summary": summary, "checked": checked, "drafted": drafted}
|
||||||
|
|
||||||
|
async def _draft_buying_message(self, plan: dict[str, Any], item: dict[str, Any], candidate: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
tone = (plan.get("constraints") or {}).get("message_tone") or "polite and concise"
|
||||||
|
greeting = "Hi" if "professional" in str(tone).casefold() or "polite" in str(tone).casefold() else "Hey"
|
||||||
|
build_context = self._plan_build_context(plan["objective"])
|
||||||
|
message = (
|
||||||
|
f"{greeting}, is your {candidate.get('title') or item['item_name']} listing still available "
|
||||||
|
f"for {self._format_price(candidate.get('price'), candidate.get('currency'))}? "
|
||||||
|
f"{build_context}If you still have it, I can move quickly."
|
||||||
|
).strip()
|
||||||
|
return await self.tools.draft_negotiation_message(
|
||||||
|
message=message,
|
||||||
|
id_listing=self._int_or_none(candidate.get("listing_id")),
|
||||||
|
plan_id=plan["id"],
|
||||||
|
plan_item_id=int(item["id"]),
|
||||||
|
candidate_id=int(candidate["id"]),
|
||||||
|
listing_slug=candidate.get("listing_slug"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _plan_build_context(objective: str) -> str:
|
||||||
|
text = str(objective or "").strip().rstrip(".")
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
lowered = text.casefold()
|
||||||
|
if "polaris" in lowered:
|
||||||
|
return "I'm putting together parts for a Polaris build. "
|
||||||
|
if "mission" in lowered:
|
||||||
|
return "I'm trying to wrap up a mission build. "
|
||||||
|
return "I'm sourcing parts for a build. "
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _candidate_score(listing: dict[str, Any], item: dict[str, Any], preferred_locations: list[str]) -> float:
|
||||||
|
price = float(listing.get("price") or 10**12)
|
||||||
|
max_price = item.get("max_unit_price")
|
||||||
|
budget_bonus = 40.0 if max_price and price <= float(max_price) else 0.0
|
||||||
|
stock = float(listing.get("in_stock") or listing.get("stock") or 1)
|
||||||
|
location = str(listing.get("location") or "").casefold()
|
||||||
|
location_bonus = 8.0 if preferred_locations and any(place in location for place in preferred_locations) else 0.0
|
||||||
|
return round(max(0.0, 50.0 - (price / 10_000_000.0)) + min(stock, 20.0) + budget_bonus + location_bonus, 4)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _within_budget(candidate: dict[str, Any], item: dict[str, Any], constraints: dict[str, Any]) -> bool:
|
||||||
|
price = candidate.get("price")
|
||||||
|
if price is None:
|
||||||
|
return False
|
||||||
|
max_price = item.get("max_unit_price") or constraints.get("max_unit_price")
|
||||||
|
return max_price is None or float(price) <= float(max_price)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_price(price: Any, currency: Any) -> str:
|
||||||
|
if isinstance(price, (int, float)):
|
||||||
|
return f"{price:,.0f} {currency or 'UEC'}"
|
||||||
|
return f"unknown price {currency or 'UEC'}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _int_or_none(value: Any) -> int | None:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _agent_plan_prompt(plan: dict[str, Any]) -> str:
|
||||||
|
recent_events = [
|
||||||
|
{
|
||||||
|
"kind": event.get("kind"),
|
||||||
|
"message": event.get("message"),
|
||||||
|
"created_at": event.get("created_at"),
|
||||||
|
}
|
||||||
|
for event in (plan.get("events") or [])[:8]
|
||||||
|
]
|
||||||
|
payload = {
|
||||||
|
"plan_id": plan.get("id"),
|
||||||
|
"title": plan.get("title"),
|
||||||
|
"kind": plan.get("kind"),
|
||||||
|
"objective": plan.get("objective"),
|
||||||
|
"constraints": plan.get("constraints") or {},
|
||||||
|
"items": plan.get("items") or [],
|
||||||
|
"recent_events": recent_events,
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
"Continual plan wake run. Continue this durable plan and write an Inbox-ready summary. "
|
||||||
|
"Use tools as needed. For any account-affecting marketplace write, only draft a pending action for approval. "
|
||||||
|
"Do not claim a message, offer, listing, or negotiation was sent unless an approved action result says it was sent. "
|
||||||
|
f"Plan JSON: {json.dumps(payload, ensure_ascii=True)}"
|
||||||
|
)
|
||||||
+162
-3
@@ -1,15 +1,19 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
from apscheduler.triggers.date import DateTrigger
|
from apscheduler.triggers.date import DateTrigger
|
||||||
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
from tzlocal import get_localzone
|
from tzlocal import get_localzone
|
||||||
|
|
||||||
from traderai.memory import MemoryStore, iso_now, time_since
|
from traderai.memory import MemoryStore, iso_now, parse_iso, time_since, utc_now
|
||||||
|
|
||||||
|
|
||||||
|
UEX_NOTIFICATION_JOB_ID = "uex-notification-poll"
|
||||||
|
|
||||||
|
|
||||||
class WakeScheduler:
|
class WakeScheduler:
|
||||||
@@ -17,15 +21,33 @@ class WakeScheduler:
|
|||||||
self.memory = memory
|
self.memory = memory
|
||||||
self.scheduler = AsyncIOScheduler(timezone=get_localzone())
|
self.scheduler = AsyncIOScheduler(timezone=get_localzone())
|
||||||
self.agent = None
|
self.agent = None
|
||||||
|
self.uex = None
|
||||||
|
self.plan_runner = None
|
||||||
|
self.negotiation_sync = None
|
||||||
|
self.notification_poll_seconds = 60
|
||||||
|
|
||||||
def bind_agent(self, agent: Any) -> None:
|
def bind_agent(self, agent: Any) -> None:
|
||||||
self.agent = agent
|
self.agent = agent
|
||||||
|
|
||||||
|
def bind_plan_runner(self, plan_runner: Any) -> None:
|
||||||
|
self.plan_runner = plan_runner
|
||||||
|
|
||||||
|
def bind_negotiation_sync(self, negotiation_sync: Any) -> None:
|
||||||
|
self.negotiation_sync = negotiation_sync
|
||||||
|
|
||||||
|
def bind_uex_notifications(self, uex: Any, poll_seconds: int = 60) -> None:
|
||||||
|
self.uex = uex
|
||||||
|
self.notification_poll_seconds = max(15, poll_seconds)
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
if not self.scheduler.running:
|
if not self.scheduler.running:
|
||||||
self.scheduler.start()
|
self.scheduler.start()
|
||||||
|
self._schedule_notification_poll()
|
||||||
for job in self.memory.list_jobs():
|
for job in self.memory.list_jobs():
|
||||||
self._schedule_existing(job)
|
self._schedule_existing(job)
|
||||||
|
if self.plan_runner is not None:
|
||||||
|
for plan in self.plan_runner.store.list_plans(include_inactive=False):
|
||||||
|
self.schedule_plan(plan)
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
if self.scheduler.running:
|
if self.scheduler.running:
|
||||||
@@ -48,6 +70,70 @@ class WakeScheduler:
|
|||||||
def list_jobs(self) -> list[dict[str, Any]]:
|
def list_jobs(self) -> list[dict[str, Any]]:
|
||||||
return self.memory.list_jobs()
|
return self.memory.list_jobs()
|
||||||
|
|
||||||
|
def schedule_plan(self, plan: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if self.plan_runner is None or plan.get("status") != "active":
|
||||||
|
return plan
|
||||||
|
job_id = self._plan_job_id(plan["id"])
|
||||||
|
previous_next_run = plan.get("next_run_at")
|
||||||
|
trigger = CronTrigger.from_crontab(plan.get("cadence") or "0 */6 * * *")
|
||||||
|
self.scheduler.add_job(self._run_plan, trigger=trigger, id=job_id, args=[plan["id"]], replace_existing=True)
|
||||||
|
job = self.scheduler.get_job(job_id)
|
||||||
|
next_run = job.next_run_time if job else None
|
||||||
|
self.plan_runner.store.update_schedule(plan["id"], next_run.isoformat() if next_run else None)
|
||||||
|
if self._plan_is_overdue(previous_next_run):
|
||||||
|
catchup_id = self._plan_catchup_job_id(plan["id"])
|
||||||
|
self.scheduler.add_job(
|
||||||
|
self._run_plan,
|
||||||
|
trigger=DateTrigger(run_date=datetime.now() + timedelta(seconds=5)),
|
||||||
|
id=catchup_id,
|
||||||
|
args=[plan["id"]],
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
self.plan_runner.store.add_event(
|
||||||
|
plan["id"],
|
||||||
|
"catchup_scheduled",
|
||||||
|
"Plan was overdue while the app was closed, so a one-time catch-up run was scheduled after startup.",
|
||||||
|
{"previous_next_run_at": previous_next_run},
|
||||||
|
)
|
||||||
|
return self.plan_runner.store.get_plan(plan["id"]) or plan
|
||||||
|
|
||||||
|
def unschedule_plan(self, plan_id: str) -> None:
|
||||||
|
job_id = self._plan_job_id(plan_id)
|
||||||
|
if self.scheduler.get_job(job_id):
|
||||||
|
self.scheduler.remove_job(job_id)
|
||||||
|
catchup_id = self._plan_catchup_job_id(plan_id)
|
||||||
|
if self.scheduler.get_job(catchup_id):
|
||||||
|
self.scheduler.remove_job(catchup_id)
|
||||||
|
if self.plan_runner is not None:
|
||||||
|
self.plan_runner.store.update_schedule(plan_id, None)
|
||||||
|
|
||||||
|
async def _run_plan(self, plan_id: str) -> None:
|
||||||
|
if self.plan_runner is None:
|
||||||
|
return
|
||||||
|
result = await self.plan_runner.run_plan(plan_id)
|
||||||
|
plan = result.get("plan") or self.plan_runner.store.get_plan(plan_id)
|
||||||
|
if plan and plan.get("status") == "active":
|
||||||
|
job = self.scheduler.get_job(self._plan_job_id(plan_id))
|
||||||
|
next_run = job.next_run_time if job else None
|
||||||
|
self.plan_runner.store.update_schedule(plan_id, next_run.isoformat() if next_run else None)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _plan_job_id(plan_id: str) -> str:
|
||||||
|
return f"continual-{plan_id}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _plan_catchup_job_id(plan_id: str) -> str:
|
||||||
|
return f"continual-catchup-{plan_id}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _plan_is_overdue(next_run_at: str | None) -> bool:
|
||||||
|
if not next_run_at:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return parse_iso(next_run_at) <= utc_now()
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
def _schedule_existing(self, job: dict[str, Any]) -> None:
|
def _schedule_existing(self, job: dict[str, Any]) -> None:
|
||||||
if job["trigger_type"] == "cron":
|
if job["trigger_type"] == "cron":
|
||||||
trigger = CronTrigger.from_crontab(job["trigger_value"])
|
trigger = CronTrigger.from_crontab(job["trigger_value"])
|
||||||
@@ -72,8 +158,81 @@ class WakeScheduler:
|
|||||||
)
|
)
|
||||||
if self.agent is None:
|
if self.agent is None:
|
||||||
self.memory.add_outbox(wake_message)
|
self.memory.add_outbox(wake_message)
|
||||||
|
self._mark_job_finished(job_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
text = await self.agent.generate_wake_response(wake_message)
|
text = await self.agent.generate_wake_response(wake_message)
|
||||||
|
except Exception as exc:
|
||||||
|
text = f"Wake job failed: {exc}. Job instruction: {prompt}"
|
||||||
self.memory.add_outbox(text)
|
self.memory.add_outbox(text)
|
||||||
self.memory.mark_job_run(job_id)
|
self._mark_job_finished(job_id)
|
||||||
|
|
||||||
|
def _mark_job_finished(self, job_id: str) -> None:
|
||||||
|
job = self.scheduler.get_job(job_id)
|
||||||
|
next_run = job.next_run_time if job else None
|
||||||
|
self.memory.mark_job_run(job_id, next_run.isoformat() if next_run else None, enabled=bool(next_run))
|
||||||
|
|
||||||
|
def _schedule_notification_poll(self) -> None:
|
||||||
|
if self.uex is None:
|
||||||
|
return
|
||||||
|
self.scheduler.add_job(
|
||||||
|
self.poll_uex_notifications,
|
||||||
|
trigger=IntervalTrigger(seconds=self.notification_poll_seconds),
|
||||||
|
id=UEX_NOTIFICATION_JOB_ID,
|
||||||
|
replace_existing=True,
|
||||||
|
next_run_time=datetime.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def poll_uex_notifications(self) -> list[dict[str, Any]]:
|
||||||
|
if self.uex is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await self.uex.get_user_notifications()
|
||||||
|
except Exception as exc:
|
||||||
|
self.memory.add_outbox(f"UEX notification poll failed: {exc}")
|
||||||
|
self.memory.set_profile("uex_last_notification_error", str(exc))
|
||||||
|
return []
|
||||||
|
notifications = response.get("notifications") or []
|
||||||
|
pending = [item for item in notifications if not item.get("date_read")]
|
||||||
|
profile = self.memory.get_profile()
|
||||||
|
seen = set(profile.get("uex_seen_notification_keys") or [])
|
||||||
|
new_pending = [item for item in pending if self._notification_key(item) not in seen]
|
||||||
|
|
||||||
|
if new_pending:
|
||||||
|
if self.negotiation_sync is not None:
|
||||||
|
await self.negotiation_sync.handle_notifications(new_pending)
|
||||||
|
else:
|
||||||
|
for item in new_pending:
|
||||||
|
self.memory.add_outbox(self._notification_text(item))
|
||||||
|
seen.update(self._notification_key(item) for item in new_pending)
|
||||||
|
self.memory.set_profile("uex_seen_notification_keys", sorted(seen))
|
||||||
|
self.memory.set_profile("uex_last_notification_check", iso_now())
|
||||||
|
elif notifications:
|
||||||
|
seen.update(self._notification_key(item) for item in pending)
|
||||||
|
self.memory.set_profile("uex_seen_notification_keys", sorted(seen))
|
||||||
|
self.memory.set_profile("uex_last_notification_check", iso_now())
|
||||||
|
|
||||||
|
return new_pending
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _notification_key(item: dict[str, Any]) -> str:
|
||||||
|
for key in ("code", "id"):
|
||||||
|
value = item.get(key)
|
||||||
|
if value not in (None, ""):
|
||||||
|
return f"{key}:{value}"
|
||||||
|
return f"notification:{item.get('date_added')}:{item.get('message')}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _notification_text(item: dict[str, Any]) -> str:
|
||||||
|
message = item.get("message") or "You have a pending UEX notification."
|
||||||
|
redir = item.get("redir")
|
||||||
|
code = item.get("code")
|
||||||
|
details = []
|
||||||
|
if code:
|
||||||
|
details.append(f"code `{code}`")
|
||||||
|
if redir:
|
||||||
|
details.append(f"path `{redir}`")
|
||||||
|
suffix = f" ({', '.join(details)})" if details else ""
|
||||||
|
return f"UEX notification: {message}{suffix}"
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class SCMDBError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SCMDBClient:
|
||||||
|
def __init__(self, base_url: str = "https://scmdb.net") -> None:
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self._versions: list[dict[str, Any]] | None = None
|
||||||
|
self._data_cache: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
async def list_versions(self) -> list[dict[str, Any]]:
|
||||||
|
if self._versions is not None:
|
||||||
|
return self._versions
|
||||||
|
body = await self._get_json("data/versions.json")
|
||||||
|
if not isinstance(body, list):
|
||||||
|
raise SCMDBError("SCMDB versions response was not a list.")
|
||||||
|
self._versions = [
|
||||||
|
item
|
||||||
|
for item in body
|
||||||
|
if isinstance(item, dict) and item.get("version") and item.get("file")
|
||||||
|
]
|
||||||
|
return self._versions
|
||||||
|
|
||||||
|
async def get_data(self, version: str | None = None, channel: str = "live") -> dict[str, Any]:
|
||||||
|
selected = await self.resolve_version(version=version, channel=channel)
|
||||||
|
cache_key = str(selected["version"])
|
||||||
|
if cache_key not in self._data_cache:
|
||||||
|
body = await self._get_json(f"data/{selected['file']}")
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise SCMDBError(f"SCMDB data for {cache_key} was not an object.")
|
||||||
|
self._data_cache[cache_key] = body
|
||||||
|
return self._data_cache[cache_key]
|
||||||
|
|
||||||
|
async def resolve_version(self, version: str | None = None, channel: str = "live") -> dict[str, Any]:
|
||||||
|
versions = await self.list_versions()
|
||||||
|
if not versions:
|
||||||
|
raise SCMDBError("SCMDB did not return any data versions.")
|
||||||
|
|
||||||
|
if version:
|
||||||
|
needle = version.casefold().strip()
|
||||||
|
for item in versions:
|
||||||
|
item_version = str(item["version"])
|
||||||
|
if item_version.casefold() == needle or needle in item_version.casefold():
|
||||||
|
return item
|
||||||
|
raise SCMDBError(f"SCMDB version not found: {version}")
|
||||||
|
|
||||||
|
channel = (channel or "live").casefold().strip()
|
||||||
|
if channel in {"latest", "any", "all"}:
|
||||||
|
return versions[0]
|
||||||
|
if channel not in {"live", "ptu"}:
|
||||||
|
raise SCMDBError("SCMDB channel must be live, ptu, or latest.")
|
||||||
|
for item in versions:
|
||||||
|
if f"-{channel}." in str(item["version"]).casefold():
|
||||||
|
return item
|
||||||
|
return versions[0]
|
||||||
|
|
||||||
|
async def _get_json(self, path: str) -> Any:
|
||||||
|
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||||
|
response = await client.get(f"{self.base_url}/{path.lstrip('/')}", headers={"Accept": "application/json"})
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise SCMDBError(f"SCMDB returned non-JSON response: HTTP {response.status_code}") from exc
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise SCMDBError(f"SCMDB HTTP {response.status_code}: {body}")
|
||||||
|
return body
|
||||||
+1354
-14
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class StarCitizenWikiError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class StarCitizenWikiClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str = "https://starcitizen.tools",
|
||||||
|
api_base_url: str = "https://api.star-citizen.wiki",
|
||||||
|
) -> None:
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.api_base_url = api_base_url.rstrip("/")
|
||||||
|
|
||||||
|
async def search_pages(self, query: str, limit: int = 5) -> list[dict[str, Any]]:
|
||||||
|
body = await self._get_json(
|
||||||
|
f"{self.base_url}/api.php",
|
||||||
|
params={
|
||||||
|
"action": "query",
|
||||||
|
"generator": "prefixsearch",
|
||||||
|
"gpssearch": query,
|
||||||
|
"gpslimit": max(1, min(limit, 10)),
|
||||||
|
"prop": "description|pageimages|extracts",
|
||||||
|
"exintro": 1,
|
||||||
|
"explaintext": 1,
|
||||||
|
"exchars": 320,
|
||||||
|
"piprop": "thumbnail",
|
||||||
|
"pithumbsize": 240,
|
||||||
|
"format": "json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
pages = body.get("query", {}).get("pages", {})
|
||||||
|
ordered = sorted(
|
||||||
|
(item for item in pages.values() if isinstance(item, dict)),
|
||||||
|
key=lambda item: int(item.get("index") or 0),
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"pageid": item.get("pageid"),
|
||||||
|
"title": item.get("title"),
|
||||||
|
"description": item.get("description"),
|
||||||
|
"extract": item.get("extract"),
|
||||||
|
"thumbnail": (item.get("thumbnail") or {}).get("source"),
|
||||||
|
"url": f"{self.base_url}/{quote(str(item.get('title') or '').replace(' ', '_'), safe=':/_')}",
|
||||||
|
}
|
||||||
|
for item in ordered
|
||||||
|
if item.get("title")
|
||||||
|
]
|
||||||
|
|
||||||
|
async def get_page_summary(self, title: str | None = None, pageid: int | None = None, chars: int = 700) -> dict[str, Any] | None:
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"action": "query",
|
||||||
|
"prop": "extracts|description|pageimages",
|
||||||
|
"exintro": 1,
|
||||||
|
"explaintext": 1,
|
||||||
|
"exchars": max(120, min(chars, 1200)),
|
||||||
|
"piprop": "thumbnail",
|
||||||
|
"pithumbsize": 320,
|
||||||
|
"format": "json",
|
||||||
|
}
|
||||||
|
if pageid is not None:
|
||||||
|
params["pageids"] = pageid
|
||||||
|
elif title:
|
||||||
|
params["titles"] = title
|
||||||
|
else:
|
||||||
|
raise StarCitizenWikiError("title or pageid is required")
|
||||||
|
|
||||||
|
body = await self._get_json(f"{self.base_url}/api.php", params=params)
|
||||||
|
pages = body.get("query", {}).get("pages", {})
|
||||||
|
for item in pages.values():
|
||||||
|
if isinstance(item, dict) and item.get("pageid") and item.get("title"):
|
||||||
|
return {
|
||||||
|
"pageid": item.get("pageid"),
|
||||||
|
"title": item.get("title"),
|
||||||
|
"description": item.get("description"),
|
||||||
|
"extract": item.get("extract"),
|
||||||
|
"thumbnail": (item.get("thumbnail") or {}).get("source"),
|
||||||
|
"url": f"{self.base_url}/{quote(str(item.get('title') or '').replace(' ', '_'), safe=':/_')}",
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def search_verse(self, query: str) -> list[dict[str, Any]]:
|
||||||
|
body = await self._get_json(
|
||||||
|
f"{self.api_base_url}/api/search",
|
||||||
|
params={"filter[query]": query},
|
||||||
|
)
|
||||||
|
data = body.get("data")
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
|
||||||
|
async def get_vehicle(self, slug: str) -> dict[str, Any]:
|
||||||
|
body = await self._get_json(f"{self.api_base_url}/api/vehicles/{slug.strip('/')}")
|
||||||
|
data = body.get("data")
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise StarCitizenWikiError(f"Vehicle response for {slug} was not an object.")
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def _get_json(self, url: str, params: dict[str, Any] | None = None) -> Any:
|
||||||
|
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||||
|
response = await client.get(url, params=params, headers={"Accept": "application/json"})
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise StarCitizenWikiError(f"Star Citizen Wiki returned non-JSON response: HTTP {response.status_code}") from exc
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise StarCitizenWikiError(f"Star Citizen Wiki HTTP {response.status_code}: {body}")
|
||||||
|
return body
|
||||||
+2509
-13
File diff suppressed because it is too large
Load Diff
+112
-1
@@ -10,10 +10,17 @@ class UEXError(RuntimeError):
|
|||||||
|
|
||||||
|
|
||||||
class UEXClient:
|
class UEXClient:
|
||||||
def __init__(self, base_url: str, secret_key: str | None = None, bearer_token: str | None = None) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
secret_key: str | None = None,
|
||||||
|
bearer_token: str | None = None,
|
||||||
|
negotiation_close_endpoint: str = "marketplace_negotiations_close",
|
||||||
|
) -> None:
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
self.secret_key = secret_key
|
self.secret_key = secret_key
|
||||||
self.bearer_token = bearer_token
|
self.bearer_token = bearer_token
|
||||||
|
self.negotiation_close_endpoint = negotiation_close_endpoint.strip().strip("/") or "marketplace_negotiations_close"
|
||||||
|
|
||||||
def _headers(self, authenticated: bool = False) -> dict[str, str]:
|
def _headers(self, authenticated: bool = False) -> dict[str, str]:
|
||||||
headers = {"Accept": "application/json"}
|
headers = {"Accept": "application/json"}
|
||||||
@@ -42,6 +49,101 @@ class UEXClient:
|
|||||||
data = data[0] if data else None
|
data = data[0] if data else None
|
||||||
return {"status": body.get("status"), "user": data}
|
return {"status": body.get("status"), "user": data}
|
||||||
|
|
||||||
|
async def get_user_notifications(self) -> dict[str, Any]:
|
||||||
|
body = await self.get("user_notifications", authenticated=True)
|
||||||
|
data = body.get("data") or []
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data = [data]
|
||||||
|
return {"status": body.get("status"), "notifications": data}
|
||||||
|
|
||||||
|
async def list_negotiations(
|
||||||
|
self,
|
||||||
|
id: int | None = None,
|
||||||
|
id_listing: int | None = None,
|
||||||
|
hash: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
body = await self.get(
|
||||||
|
"marketplace_negotiations",
|
||||||
|
{"id": id, "id_listing": id_listing, "hash": hash},
|
||||||
|
authenticated=True,
|
||||||
|
)
|
||||||
|
data = body.get("data") or []
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data = [data]
|
||||||
|
return {"status": body.get("status"), "negotiations": data}
|
||||||
|
|
||||||
|
async def get_negotiation_messages(self, hash: str | None = None, id_negotiation: int | None = None) -> dict[str, Any]:
|
||||||
|
body = await self.get(
|
||||||
|
"marketplace_negotiations_messages",
|
||||||
|
{"hash": hash, "id_negotiation": id_negotiation},
|
||||||
|
authenticated=True,
|
||||||
|
)
|
||||||
|
data = body.get("data") or []
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data = [data]
|
||||||
|
return {"status": body.get("status"), "messages": data}
|
||||||
|
|
||||||
|
async def send_negotiation_message(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
message: str,
|
||||||
|
hash: str | None = None,
|
||||||
|
id_negotiation: int | None = None,
|
||||||
|
is_production: int = 1,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await self.post(
|
||||||
|
"marketplace_negotiations_messages",
|
||||||
|
{
|
||||||
|
"hash": hash,
|
||||||
|
"id_negotiation": id_negotiation,
|
||||||
|
"message": message,
|
||||||
|
"is_production": is_production,
|
||||||
|
},
|
||||||
|
authenticated=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def close_negotiation(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
hash: str | None = None,
|
||||||
|
id_negotiation: int | None = None,
|
||||||
|
deal_closed: bool,
|
||||||
|
deal_value: float | None = None,
|
||||||
|
currency: str | None = None,
|
||||||
|
clarity_rating: int | None = None,
|
||||||
|
speed_rating: int | None = None,
|
||||||
|
respect_rating: int | None = None,
|
||||||
|
fairness_rating: int | None = None,
|
||||||
|
comment: str | None = None,
|
||||||
|
is_production: int = 1,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
payload = {
|
||||||
|
"hash": hash,
|
||||||
|
"id_negotiation": id_negotiation,
|
||||||
|
"deal_closed": 1 if deal_closed else 0,
|
||||||
|
"deal_value": deal_value,
|
||||||
|
"currency": currency,
|
||||||
|
"clarity_rating": clarity_rating,
|
||||||
|
"speed_rating": speed_rating,
|
||||||
|
"respect_rating": respect_rating,
|
||||||
|
"fairness_rating": fairness_rating,
|
||||||
|
"comment": comment,
|
||||||
|
"is_production": is_production,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
return await self.post(
|
||||||
|
self.negotiation_close_endpoint,
|
||||||
|
payload,
|
||||||
|
authenticated=True,
|
||||||
|
)
|
||||||
|
except UEXError as exc:
|
||||||
|
raise UEXError(
|
||||||
|
"UEX negotiation close failed via endpoint "
|
||||||
|
f"`{self.negotiation_close_endpoint}`. If UEX changed this route, set "
|
||||||
|
"`UEX_NEGOTIATION_CLOSE_ENDPOINT` to the correct endpoint and retry. "
|
||||||
|
f"Original error: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
async def post(self, path: str, payload: dict[str, Any], authenticated: bool = True) -> dict[str, Any]:
|
async def post(self, path: str, payload: dict[str, Any], authenticated: bool = True) -> dict[str, Any]:
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
@@ -51,6 +153,15 @@ class UEXClient:
|
|||||||
)
|
)
|
||||||
return self._handle_response(response)
|
return self._handle_response(response)
|
||||||
|
|
||||||
|
async def delete(self, path: str, params: dict[str, Any] | None = None, authenticated: bool = True) -> dict[str, Any]:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.delete(
|
||||||
|
f"{self.base_url}/{path.strip('/')}/",
|
||||||
|
params={k: v for k, v in (params or {}).items() if v is not None},
|
||||||
|
headers=self._headers(authenticated),
|
||||||
|
)
|
||||||
|
return self._handle_response(response)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _handle_response(response: httpx.Response) -> dict[str, Any]:
|
def _handle_response(response: httpx.Response) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
__version__ = "0.0.9"
|
||||||
|
|
||||||
|
RELEASES_URL = "https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases"
|
||||||
|
RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingConglomerate/TraderAI/releases"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class WikeloProjectsError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WikeloProjectsClient:
|
||||||
|
APP_ID = "695be2905c0b4866dfb21265"
|
||||||
|
|
||||||
|
def __init__(self, base_url: str = "https://wikelo-projects.com") -> None:
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
|
||||||
|
async def list_ship_projects(self) -> list[dict[str, Any]]:
|
||||||
|
body = await self._get_json(f"{self.base_url}/api/apps/{self.APP_ID}/entities/ShipProject")
|
||||||
|
if not isinstance(body, list):
|
||||||
|
raise WikeloProjectsError("Wikelo ship projects response was not a list.")
|
||||||
|
return [item for item in body if isinstance(item, dict)]
|
||||||
|
|
||||||
|
async def _get_json(self, url: str) -> Any:
|
||||||
|
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||||
|
response = await client.get(url, headers={"Accept": "application/json"})
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise WikeloProjectsError(f"Wikelo Projects returned non-JSON response: HTTP {response.status_code}") from exc
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise WikeloProjectsError(f"Wikelo Projects HTTP {response.status_code}: {body}")
|
||||||
|
return body
|
||||||
File diff suppressed because one or more lines are too long
@@ -2,6 +2,15 @@ version = 1
|
|||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "altgraph"
|
||||||
|
version = "0.17.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "annotated-doc"
|
name = "annotated-doc"
|
||||||
version = "0.0.4"
|
version = "0.0.4"
|
||||||
@@ -45,6 +54,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" },
|
{ url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bottle"
|
||||||
|
version = "0.13.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7a/71/cca6167c06d00c81375fd668719df245864076d284f7cb46a694cbeb5454/bottle-0.13.4.tar.gz", hash = "sha256:787e78327e12b227938de02248333d788cfe45987edca735f8f88e03472c3f47", size = 98717, upload-time = "2025-06-15T10:08:59.439Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/f6/b55ec74cfe68c6584163faa311503c20b0da4c09883a41e8e00d6726c954/bottle-0.13.4-py2.py3-none-any.whl", hash = "sha256:045684fbd2764eac9cdeb824861d1551d113e8b683d8d26e296898d3dd99a12e", size = 103807, upload-time = "2025-06-15T10:08:57.691Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2026.4.22"
|
version = "2026.4.22"
|
||||||
@@ -54,6 +72,32 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
|
{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cffi"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.3.3"
|
version = "8.3.3"
|
||||||
@@ -66,6 +110,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
|
{ url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clr-loader"
|
||||||
|
version = "0.2.10"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cffi" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/18/24/c12faf3f61614b3131b5c98d3bf0d376b49c7feaa73edca559aeb2aee080/clr_loader-0.2.10.tar.gz", hash = "sha256:81f114afbc5005bafc5efe5af1341d400e22137e275b042a8979f3feb9fc9446", size = 83605, upload-time = "2026-01-03T23:13:06.984Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/61/cf819f8e8bb4d4c74661acf2498ba8d4a296714be3478d21eaabf64f5b9b/clr_loader-0.2.10-py3-none-any.whl", hash = "sha256:ebbbf9d511a7fe95fa28a95a4e04cd195b097881dfe66158dc2c281d3536f282", size = 56483, upload-time = "2026-01-03T23:13:05.439Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorama"
|
name = "colorama"
|
||||||
version = "0.4.6"
|
version = "0.4.6"
|
||||||
@@ -182,6 +238,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "macholib"
|
||||||
|
version = "1.16.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "altgraph" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "26.2"
|
version = "26.2"
|
||||||
@@ -191,6 +259,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pefile"
|
||||||
|
version = "2024.8.26"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pluggy"
|
name = "pluggy"
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
@@ -200,6 +277,21 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proxy-tools"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f2/cf/77d3e19b7fabd03895caca7857ef51e4c409e0ca6b37ee6e9f7daa50b642/proxy_tools-0.1.0.tar.gz", hash = "sha256:ccb3751f529c047e2d8a58440d86b205303cf0fe8146f784d1cbcd94f0a28010", size = 2978, upload-time = "2014-05-05T21:02:24.606Z" }
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pycparser"
|
||||||
|
version = "3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.13.3"
|
version = "2.13.3"
|
||||||
@@ -340,6 +432,145 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyinstaller"
|
||||||
|
version = "6.20.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "altgraph" },
|
||||||
|
{ name = "macholib", marker = "sys_platform == 'darwin'" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pefile", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "pyinstaller-hooks-contrib" },
|
||||||
|
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "setuptools" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/46/60/d03d52e6690d4e9caf333dcd14550cde634ce6c118b3bc8fa3112c3186fd/pyinstaller-6.20.0.tar.gz", hash = "sha256:95c5c7e03d5d61e9dfb8ef259c699cf492bb1041beb6dbe83696608cec07347a", size = 4048728, upload-time = "2026-04-22T20:59:36.96Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/e4/e228d6d1bbb7fd62dc660a8fb202a583b023d3a3624ca95d1a9290ee4d6a/pyinstaller-6.20.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:bf3be4e1284ee78ddccba5e29f99443a12a7b4673168288ffc4c9d38c6f7b90e", size = 1047642, upload-time = "2026-04-22T20:58:32.006Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/bd/afb631bcb3f9040efebd4f6d067f0828b51710818f69fb41a2d4b7787f52/pyinstaller-6.20.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:72ae9c1fdea134afa791f58bdc9a1934d5c7609753c111e0026bfc272b32b712", size = 742494, upload-time = "2026-04-22T20:58:36.285Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/08/0729a5bac14754150e5d83b39d87d842eb42b0bffcaa03dbad6252e23a39/pyinstaller-6.20.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1031bcc307f3fbeffd4e162723e64d46dbf591c82dd0997413afb2a07328b941", size = 754191, upload-time = "2026-04-22T20:58:40.603Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/82/bc0ee4c7b97db1958eb651e0da9fb1e672e5ae53ca8867fd97701de52906/pyinstaller-6.20.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:8df3b3f347659fa2562d8d193a98ad4600133b8b8d07c268df89e4154376750e", size = 751902, upload-time = "2026-04-22T20:58:44.7Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/e7/770002d6aaa54173881cb2c49bb195ba67b97bf39bac1cdf320f28401629/pyinstaller-6.20.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:b0d3cc9dd8120d448459bd3880a12e2f9774c51443af49047801446377999a59", size = 748634, upload-time = "2026-04-22T20:58:48.579Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/db/68ba1fccb71278b2124fb90b37b7c8c0bc4c1173fba45b94466df3d9cb7f/pyinstaller-6.20.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:03696bb6350177c6bc23bcaf78e71a33c4a89b6754dd90d1be2f318e978c918b", size = 748490, upload-time = "2026-04-22T20:58:52.749Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/0f/ac77ffa996a56be3d5c8f85734a007f8347240691657f9704e7de2527fa3/pyinstaller-6.20.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:6357f1699f6af84f37e7367f031d4f68abdba65543b83990c9e8f5a4cebed0b7", size = 747650, upload-time = "2026-04-22T20:58:57.093Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/56/1ee91c3a2bc10ca1f36da10a6fd55ff7efc4dec367171eb25992a827874f/pyinstaller-6.20.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:0ab39c690abad26ba148e8f664f0478acc82a733997f4f22e757774832802da9", size = 747413, upload-time = "2026-04-22T20:59:01.174Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/55/ae264339996953c4cdf9d89d916a0a8fa26a83cf917a742fff8b9d5f3fe8/pyinstaller-6.20.0-py3-none-win32.whl", hash = "sha256:9a7637e8e44b4387b13667fdcaac86ab6b29c446c16d34d8401539b81838759c", size = 1331584, upload-time = "2026-04-22T20:59:07.201Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/8c/300f57578882cce259bfb5ae56fda3b69caa3fe9df40a176c719920ea6e2/pyinstaller-6.20.0-py3-none-win_amd64.whl", hash = "sha256:d588844e890ee80c4365867f98146636e1849bbca8e4284bbf0c809aff0f161a", size = 1391851, upload-time = "2026-04-22T20:59:14.024Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/ea/b2f8e1642aecda78c0b75c7321f708e49e10bb3c00dd4f148c40761a1527/pyinstaller-6.20.0-py3-none-win_arm64.whl", hash = "sha256:bd53282c0a73e5c95573e1ddc8e5d564d4932bec91efbaed4dc5fdff9c2ae7f2", size = 1332259, upload-time = "2026-04-22T20:59:20.509Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyinstaller-hooks-contrib"
|
||||||
|
version = "2026.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "setuptools" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1a/67/f4452d68793fb15beba4f19ef39a38a8822f0da7452b503c400d5a21f5c1/pyinstaller_hooks_contrib-2026.5.tar.gz", hash = "sha256:f066dfca8f7c45ff6336c9cf9fe25b4e48bfeb322a1aa24faaedfb8a8d1b0b08", size = 173689, upload-time = "2026-05-04T22:36:55.124Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/5c/fd465d11da4d12b50d7eb5d2ee2ceb780d8d049dbb489f3828d131e387af/pyinstaller_hooks_contrib-2026.5-py3-none-any.whl", hash = "sha256:ea1535783fbdac4626351709e83f3ea80b681d3a4745763ebb407b5e27342eb9", size = 457314, upload-time = "2026-05-04T22:36:53.598Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyobjc-core"
|
||||||
|
version = "12.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532, upload-time = "2025-11-14T10:08:28.292Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/df/d2b290708e9da86d6e7a9a2a2022b91915cf2e712a5a82e306cb6ee99792/pyobjc_core-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c918ebca280925e7fcb14c5c43ce12dcb9574a33cccb889be7c8c17f3bcce8b6", size = 671263, upload-time = "2025-11-14T09:31:35.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/5a/6b15e499de73050f4a2c88fff664ae154307d25dc04da8fb38998a428358/pyobjc_core-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:818bcc6723561f207e5b5453efe9703f34bc8781d11ce9b8be286bb415eb4962", size = 678335, upload-time = "2025-11-14T09:32:20.107Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a", size = 677370, upload-time = "2025-11-14T09:33:05.273Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/f0/4b4ed8924cd04e425f2a07269943018d43949afad1c348c3ed4d9d032787/pyobjc_core-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:177aaca84bb369a483e4961186704f64b2697708046745f8167e818d968c88fc", size = 719586, upload-time = "2025-11-14T09:33:53.302Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/98/9f4ed07162de69603144ff480be35cd021808faa7f730d082b92f7ebf2b5/pyobjc_core-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:844515f5d86395b979d02152576e7dee9cc679acc0b32dc626ef5bda315eaa43", size = 670164, upload-time = "2025-11-14T09:34:37.458Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/50/dc076965c96c7f0de25c0a32b7f8aa98133ed244deaeeacfc758783f1f30/pyobjc_core-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:453b191df1a4b80e756445b935491b974714456ae2cbae816840bd96f86db882", size = 712204, upload-time = "2025-11-14T09:35:24.148Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyobjc-framework-cocoa"
|
||||||
|
version = "12.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyobjc-core" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/07/5760735c0fffc65107e648eaf7e0991f46da442ac4493501be5380e6d9d4/pyobjc_framework_cocoa-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f52228bcf38da64b77328787967d464e28b981492b33a7675585141e1b0a01e6", size = 383812, upload-time = "2025-11-14T09:40:53.169Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/bf/ee4f27ec3920d5c6fc63c63e797c5b2cc4e20fe439217085d01ea5b63856/pyobjc_framework_cocoa-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:547c182837214b7ec4796dac5aee3aa25abc665757b75d7f44f83c994bcb0858", size = 384590, upload-time = "2025-11-14T09:41:17.336Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118", size = 384689, upload-time = "2025-11-14T09:41:41.478Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/3b/b9f61be7b9f9b4e0a6db18b3c35c4c4d589f2d04e963e2174d38c6555a92/pyobjc_framework_cocoa-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:914b74328c22d8ca261d78c23ef2befc29776e0b85555973927b338c5734ca44", size = 388843, upload-time = "2025-11-14T09:42:05.719Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/bb/f777cc9e775fc7dae77b569254570fe46eb842516b3e4fe383ab49eab598/pyobjc_framework_cocoa-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:03342a60fc0015bcdf9b93ac0b4f457d3938e9ef761b28df9564c91a14f0129a", size = 384932, upload-time = "2025-11-14T09:42:29.771Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/27/b457b7b37089cad692c8aada90119162dfb4c4a16f513b79a8b2b022b33b/pyobjc_framework_cocoa-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6ba1dc1bfa4da42d04e93d2363491275fb2e2be5c20790e561c8a9e09b8cf2cc", size = 388970, upload-time = "2025-11-14T09:42:53.964Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyobjc-framework-quartz"
|
||||||
|
version = "12.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyobjc-core" },
|
||||||
|
{ name = "pyobjc-framework-cocoa" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/18/cc59f3d4355c9456fc945eae7fe8797003c4da99212dd531ad1b0de8a0c6/pyobjc_framework_quartz-12.1.tar.gz", hash = "sha256:27f782f3513ac88ec9b6c82d9767eef95a5cf4175ce88a1e5a65875fee799608", size = 3159099, upload-time = "2025-11-14T10:21:24.31Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/ef/dcd22b743e38b3c430fce4788176c2c5afa8bfb01085b8143b02d1e75201/pyobjc_framework_quartz-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:19f99ac49a0b15dd892e155644fe80242d741411a9ed9c119b18b7466048625a", size = 217795, upload-time = "2025-11-14T09:59:46.922Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/9b/780f057e5962f690f23fdff1083a4cfda5a96d5b4d3bb49505cac4f624f2/pyobjc_framework_quartz-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7730cdce46c7e985535b5a42c31381af4aa6556e5642dc55b5e6597595e57a16", size = 218798, upload-time = "2025-11-14T10:00:01.236Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/2d/e8f495328101898c16c32ac10e7b14b08ff2c443a756a76fd1271915f097/pyobjc_framework_quartz-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:629b7971b1b43a11617f1460cd218bd308dfea247cd4ee3842eb40ca6f588860", size = 219206, upload-time = "2025-11-14T10:00:15.623Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/43/b1f0ad3b842ab150a7e6b7d97f6257eab6af241b4c7d14cb8e7fde9214b8/pyobjc_framework_quartz-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:53b84e880c358ba1ddcd7e8d5ea0407d760eca58b96f0d344829162cda5f37b3", size = 224317, upload-time = "2025-11-14T10:00:30.703Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/00/96249c5c7e5aaca5f688ca18b8d8ad05cd7886ebd639b3c71a6a4cadbe75/pyobjc_framework_quartz-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:42d306b07f05ae7d155984503e0fb1b701fecd31dcc5c79fe8ab9790ff7e0de0", size = 219558, upload-time = "2025-11-14T10:00:45.476Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/a6/708a55f3ff7a18c403b30a29a11dccfed0410485a7548c60a4b6d4cc0676/pyobjc_framework_quartz-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0cc08fddb339b2760df60dea1057453557588908e42bdc62184b6396ce2d6e9a", size = 224580, upload-time = "2025-11-14T10:01:00.091Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyobjc-framework-security"
|
||||||
|
version = "12.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyobjc-core" },
|
||||||
|
{ name = "pyobjc-framework-cocoa" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/80/aa/796e09a3e3d5cee32ebeebb7dcf421b48ea86e28c387924608a05e3f668b/pyobjc_framework_security-12.1.tar.gz", hash = "sha256:7fecb982bd2f7c4354513faf90ba4c53c190b7e88167984c2d0da99741de6da9", size = 168044, upload-time = "2025-11-14T10:22:06.334Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/3d/8d3a39cd292d7c76ab76233498189bc7170fc80f573b415308464f68c7ee/pyobjc_framework_security-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1b2d8819f0fb7b619ec7627a0d8c1cac1a57c5143579ce8ac21548165680684b", size = 41287, upload-time = "2025-11-14T10:02:54.491Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/66/5160c0f938fc0515fe8d9af146aac1b093f7ef285ce797fedae161b6c0e8/pyobjc_framework_security-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab42e55f5b782332be5442750fcd9637ee33247d57c7b1d5801bc0e24ee13278", size = 41280, upload-time = "2025-11-14T10:02:58.097Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/48/b294ed75247c5cfa00d51925a10237337d24f54961d49a179b20a4307642/pyobjc_framework_security-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:afc36661cc6eb98cd794bed1d6668791e96557d6f72d9ac70aa49022d26af1d4", size = 41284, upload-time = "2025-11-14T10:03:01.722Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/57/0d3ef78779cf5c3bba878b2f824137e50978ad4a21dabe65d8b5ae0fc0d1/pyobjc_framework_security-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9510c98ab56921d1d416437372605cc1c1f6c1ad8d3061ee56b17bf423dd5427", size = 42162, upload-time = "2025-11-14T10:03:05.337Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/4d/63c15f9449c191e7448a05ff8af4a82c39a51bb627bc96dc9697586c0f79/pyobjc_framework_security-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6319a34508fd87ab6ca3cda6f54e707196197a65b792b292705af967e225438a", size = 41348, upload-time = "2025-11-14T10:03:08.926Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/d8/5aaa2a8124ed04a9d6ca7053dc0fa64e42be51497ed8263a24b744a95598/pyobjc_framework_security-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:03d166371cefdef24908825148eb848f99ee2c0b865870a09dcbb94334dd3e0a", size = 42908, upload-time = "2025-11-14T10:03:13.01Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyobjc-framework-uniformtypeidentifiers"
|
||||||
|
version = "12.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyobjc-core" },
|
||||||
|
{ name = "pyobjc-framework-cocoa" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/65/b8/dd9d2a94509a6c16d965a7b0155e78edf520056313a80f0cd352413f0d0b/pyobjc_framework_uniformtypeidentifiers-12.1.tar.gz", hash = "sha256:64510a6df78336579e9c39b873cfcd03371c4b4be2cec8af75a8a3d07dff607d", size = 17030, upload-time = "2025-11-14T10:23:02.222Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/5f/1f10f5275b06d213c9897850f1fca9c881c741c1f9190cea6db982b71824/pyobjc_framework_uniformtypeidentifiers-12.1-py2.py3-none-any.whl", hash = "sha256:ec5411e39152304d2a7e0e426c3058fa37a00860af64e164794e0bcffee813f2", size = 4901, upload-time = "2025-11-14T10:05:51.532Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyobjc-framework-webkit"
|
||||||
|
version = "12.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyobjc-core" },
|
||||||
|
{ name = "pyobjc-framework-cocoa" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/14/10/110a50e8e6670765d25190ca7f7bfeecc47ec4a8c018cb928f4f82c56e04/pyobjc_framework_webkit-12.1.tar.gz", hash = "sha256:97a54dd05ab5266bd4f614e41add517ae62cdd5a30328eabb06792474b37d82a", size = 284531, upload-time = "2025-11-14T10:23:40.287Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/37/5082a0bbe12e48d4ffa53b0c0f09c77a4a6ffcfa119e26fa8dd77c08dc1c/pyobjc_framework_webkit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3db734877025614eaef4504fadc0fbbe1279f68686a6f106f2e614e89e0d1a9d", size = 49970, upload-time = "2025-11-14T10:07:01.413Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/67/64920c8d201a7fc27962f467c636c4e763b43845baba2e091a50a97a5d52/pyobjc_framework_webkit-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:af2c7197447638b92aafbe4847c063b6dd5e1ed83b44d3ce7e71e4c9b042ab5a", size = 50084, upload-time = "2025-11-14T10:07:05.868Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/3d/80d36280164c69220ce99372f7736a028617c207e42cb587716009eecb88/pyobjc_framework_webkit-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1da0c428c9d9891c93e0de51c9f272bfeb96d34356cdf3136cb4ad56ce32ec2d", size = 50096, upload-time = "2025-11-14T10:07:10.027Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/7a/03c29c46866e266b0c705811c55c22625c349b0a80f5cf4776454b13dc4c/pyobjc_framework_webkit-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:1a29e334d5a7dd4a4f0b5647481b6ccf8a107b92e67b2b3c6b368c899f571965", size = 50572, upload-time = "2025-11-14T10:07:14.232Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/ac/924878f239c167ffe3bfc643aee4d6dd5b357e25f6b28db227e40e9e6df3/pyobjc_framework_webkit-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:99d0d28542a266a95ee2585f51765c0331794bca461aaf4d1f5091489d475179", size = 50210, upload-time = "2025-11-14T10:07:18.926Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/86/637cda4983dc0936b73a385f3906256953ac434537b812814cb0b6d231a2/pyobjc_framework_webkit-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1aaa3bf12c7b68e1a36c0b294d2728e06f2cc220775e6dc4541d5046290e4dc8", size = 50680, upload-time = "2025-11-14T10:07:23.331Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "9.0.3"
|
version = "9.0.3"
|
||||||
@@ -378,6 +609,49 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pythonnet"
|
||||||
|
version = "3.0.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "clr-loader" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9a/d6/1afd75edd932306ae9bd2c2d961d603dc2b52fcec51b04afea464f1f6646/pythonnet-3.0.5.tar.gz", hash = "sha256:48e43ca463941b3608b32b4e236db92d8d40db4c58a75ace902985f76dac21cf", size = 239212, upload-time = "2024-12-13T08:30:44.393Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/f1/bfb6811df4745f92f14c47a29e50e89a36b1533130fcc56452d4660bd2d6/pythonnet-3.0.5-py3-none-any.whl", hash = "sha256:f6702d694d5d5b163c9f3f5cc34e0bed8d6857150237fae411fefb883a656d20", size = 297506, upload-time = "2024-12-13T08:30:40.661Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pywebview"
|
||||||
|
version = "6.2.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "bottle" },
|
||||||
|
{ name = "proxy-tools" },
|
||||||
|
{ name = "pyobjc-core", marker = "sys_platform == 'darwin'" },
|
||||||
|
{ name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" },
|
||||||
|
{ name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" },
|
||||||
|
{ name = "pyobjc-framework-security", marker = "sys_platform == 'darwin'" },
|
||||||
|
{ name = "pyobjc-framework-uniformtypeidentifiers", marker = "sys_platform == 'darwin'" },
|
||||||
|
{ name = "pyobjc-framework-webkit", marker = "sys_platform == 'darwin'" },
|
||||||
|
{ name = "pythonnet", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "qtpy", marker = "sys_platform == 'openbsd6'" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/59/4a/05307135dafba67778669d194bd1a3822a7685ec9ee8a6d7e70856c1a551/pywebview-6.2.1.tar.gz", hash = "sha256:71b7136752e40824655304d938efb62014218d1a90bd8e87e1cbdb1ce9c466af", size = 513126, upload-time = "2026-04-15T09:02:16.595Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/25/9491695c22c4842c5b3903b4dc172e0eecf67a27c0af34a71512c9b76a0a/pywebview-6.2.1-py3-none-any.whl", hash = "sha256:9d07275f53894ab4d5e2e0e996227193e7187dec276d9b624dccbce029216b46", size = 525463, upload-time = "2026-04-15T09:02:10.186Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pywin32-ctypes"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyyaml"
|
name = "pyyaml"
|
||||||
version = "6.0.3"
|
version = "6.0.3"
|
||||||
@@ -433,6 +707,18 @@ 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" },
|
{ 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 = "qtpy"
|
||||||
|
version = "2.4.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "packaging" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/70/01/392eba83c8e47b946b929d7c46e0f04b35e9671f8bb6fc36b6f7945b4de8/qtpy-2.4.3.tar.gz", hash = "sha256:db744f7832e6d3da90568ba6ccbca3ee2b3b4a890c3d6fbbc63142f6e4cdf5bb", size = 66982, upload-time = "2025-02-11T15:09:25.759Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/76/37c0ccd5ab968a6a438f9c623aeecc84c202ab2fabc6a8fd927580c15b5a/QtPy-2.4.3-py3-none-any.whl", hash = "sha256:72095afe13673e017946cc258b8d5da43314197b741ed2890e563cf384b51aa1", size = 95045, upload-time = "2025-02-11T15:09:24.162Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "respx"
|
name = "respx"
|
||||||
version = "0.23.1"
|
version = "0.23.1"
|
||||||
@@ -445,6 +731,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" },
|
{ url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "setuptools"
|
||||||
|
version = "82.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "starlette"
|
name = "starlette"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@@ -460,7 +755,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "traderai"
|
name = "traderai"
|
||||||
version = "0.1.0"
|
version = "0.0.9"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "apscheduler" },
|
{ name = "apscheduler" },
|
||||||
@@ -469,12 +764,14 @@ dependencies = [
|
|||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "pywebview" },
|
||||||
{ name = "tzlocal" },
|
{ name = "tzlocal" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "pyinstaller" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
{ name = "respx" },
|
{ name = "respx" },
|
||||||
@@ -487,9 +784,11 @@ requires-dist = [
|
|||||||
{ name = "httpx", specifier = ">=0.27.0" },
|
{ name = "httpx", specifier = ">=0.27.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.8.0" },
|
{ name = "pydantic", specifier = ">=2.8.0" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.4.0" },
|
{ name = "pydantic-settings", specifier = ">=2.4.0" },
|
||||||
|
{ name = "pyinstaller", marker = "extra == 'dev'", specifier = ">=6.11.0" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" },
|
||||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.8" },
|
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.8" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.0.1" },
|
{ name = "python-dotenv", specifier = ">=1.0.1" },
|
||||||
|
{ name = "pywebview", specifier = ">=5.4" },
|
||||||
{ name = "respx", marker = "extra == 'dev'", specifier = ">=0.21.1" },
|
{ name = "respx", marker = "extra == 'dev'", specifier = ">=0.21.1" },
|
||||||
{ name = "tzlocal", specifier = ">=5.2" },
|
{ name = "tzlocal", specifier = ">=5.2" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
|
||||||
@@ -745,3 +1044,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" },
|
{ url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1857
-22
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 201 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill-rule="evenodd" clip-rule="evenodd" d="M168.64 23.253c4.608 1.814 8.768 4.8 12.544 8.747 6.293 6.528 11.605 15.872 15.659 26.944 4.074 11.136 6.72 23.467 7.722 35.84a107.824 107.824 0 0143.712-13.568l1.088-.085c18.56-1.494 36.907 1.856 52.907 10.112a103.091 103.091 0 016.336 3.626c1.067-12.138 3.669-24.192 7.68-35.072 4.053-11.093 9.365-20.416 15.637-26.965a35.628 35.628 0 0112.566-8.747c5.482-2.133 11.306-2.517 16.981-.896 8.555 2.432 15.893 7.851 21.675 15.723 5.29 7.19 9.258 16.405 11.968 27.456 4.906 19.925 5.76 46.144 2.453 77.76l1.131.853.554.406c16.15 12.288 27.392 29.802 33.344 50.133 9.28 31.723 4.608 67.307-11.392 87.211l-.384.448.043.064c8.896 16.256 14.293 33.429 15.445 51.2l.043.64c1.365 22.72-4.267 45.589-17.365 68.053l-.15.213.214.512c10.069 24.683 13.226 49.536 9.344 74.368l-.128.832a13.888 13.888 0 01-15.936 11.435 13.83 13.83 0 01-11.31-10.43 13.828 13.828 0 01-.21-5.399c3.562-22.038.213-44.139-10.24-66.624a13.713 13.713 0 01.853-13.163l.085-.128c12.886-19.712 18.219-39.04 17.067-58.027-.981-16.618-6.933-32.938-17.067-48.49a13.737 13.737 0 013.84-18.902l.192-.128c5.184-3.392 9.963-12.053 12.374-23.893a90.218 90.218 0 00-2.027-42.112c-4.373-14.933-12.373-27.392-23.573-35.904-12.694-9.685-29.504-14.357-50.774-13.013a13.93 13.93 0 01-13.482-7.915c-6.699-14.187-16.47-24.341-28.651-30.635a70.145 70.145 0 00-37.803-7.082c-26.56 2.112-49.984 17.088-56.96 35.968a13.91 13.91 0 01-13.013 9.066c-22.763.043-40.384 5.376-53.269 14.998-11.136 8.32-18.731 19.946-22.742 33.877a86.824 86.824 0 00-1.45 40.235c2.389 11.904 7.061 21.76 12.416 27.072l.17.149c4.523 4.416 5.483 11.307 2.326 16.747-7.68 13.269-13.419 33.045-14.358 52.053-1.066 21.717 3.968 40.576 15.339 54.101l.341.406a13.711 13.711 0 012.027 14.72c-12.288 26.368-16.064 48.042-11.989 65.109a13.91 13.91 0 01-27.072 6.357c-5.184-21.717-1.664-46.592 10.09-74.624l.299-.746-.17-.256a92.574 92.574 0 01-12.758-27.926l-.107-.405a122.965 122.965 0 01-3.776-38.08c.939-19.413 5.931-39.296 13.27-55.253l.256-.555-.043-.043c-6.25-8.917-10.88-20.33-13.44-32.96l-.107-.512a114.176 114.176 0 011.984-53.12c5.59-19.52 16.576-36.288 32.768-48.405 1.28-.96 2.624-1.92 3.968-2.816-3.392-31.851-2.538-58.24 2.39-78.293 2.709-11.051 6.698-20.267 11.989-27.456 5.76-7.851 13.099-13.27 21.653-15.723 5.675-1.621 11.52-1.259 17.003.896v.021zm87.808 193.92c19.968 0 38.4 6.678 52.181 18.24 13.44 11.243 21.44 26.347 21.44 41.387 0 18.944-8.661 33.707-24.17 43.136-13.227 8-30.955 11.883-51.264 11.883-21.526 0-39.915-5.526-53.184-15.659-13.163-10.027-20.544-24.107-20.544-39.36 0-15.083 8.49-30.229 22.528-41.515 14.25-11.456 33.066-18.112 53.013-18.112zm0 19.115a65.498 65.498 0 00-40.875 13.867c-9.834 7.893-15.402 17.813-15.402 26.666 0 9.131 4.48 17.686 13.013 24.192 9.707 7.403 23.979 11.691 41.451 11.691 17.045 0 31.424-3.136 41.216-9.088 9.877-5.973 14.933-14.635 14.933-26.816 0-9.024-5.248-18.987-14.571-26.795-10.325-8.64-24.32-13.717-39.765-13.717zm14.123 25.813l.085.086a7.431 7.431 0 01-1.195 10.453l-6.229 4.907v9.514a7.999 7.999 0 01-8.021 7.958 8.004 8.004 0 01-8.022-7.958v-9.813l-5.781-4.651a7.4 7.4 0 01-1.109-10.453 7.53 7.53 0 0110.538-1.088l4.587 3.669 4.693-3.712a7.533 7.533 0 0110.454 1.088zm-107.52-40.938c10.197 0 18.496 8.32 18.496 18.581a18.564 18.564 0 01-18.518 18.581 18.559 18.559 0 01-18.496-18.56 18.565 18.565 0 015.399-13.129 18.609 18.609 0 0113.119-5.473zm185.728 0c10.24 0 18.517 8.32 18.517 18.581a18.559 18.559 0 01-18.517 18.581 18.56 18.56 0 01-18.496-18.56 18.56 18.56 0 0118.496-18.602zM158.72 49.067l-.064.042a14.06 14.06 0 00-6.08 5.078l-.107.128c-2.944 4.032-5.504 9.962-7.424 17.749-3.626 14.763-4.608 34.795-2.645 59.349 9.173-2.73 19.179-4.437 29.952-5.056l.213-.021.406-.725a69.41 69.41 0 013.157-5.099c2.624-16.448.469-36.096-5.397-52.139-2.859-7.765-6.336-13.866-9.664-17.344a13.403 13.403 0 00-2.283-1.92l-.064-.042zm195.712.853l-.043.021a13.396 13.396 0 00-2.282 1.92c-3.328 3.478-6.827 9.6-9.664 17.366-6.187 16.938-8.256 37.888-4.907 54.869l1.237 2.069.171.299h.64a110.599 110.599 0 0131.275 4.523c1.834-23.979.81-43.584-2.731-58.07-1.92-7.786-4.48-13.717-7.445-17.749l-.086-.128a14.054 14.054 0 00-6.08-5.099h-.085v-.021z" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
+316
-3
@@ -4,15 +4,68 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>TraderAI</title>
|
<title>TraderAI</title>
|
||||||
|
<link rel="icon" href="/static/art/LBC_Logo.ico" sizes="any">
|
||||||
<link rel="stylesheet" href="/static/styles.css">
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="shell">
|
<main class="shell">
|
||||||
|
<nav class="chat-rail collapsed" id="chat-rail" aria-label="Chats, plans, and inbox">
|
||||||
|
<div class="chat-rail-top">
|
||||||
|
<button class="icon-button" id="chat-sidebar-toggle" type="button" title="Chats" aria-expanded="false">
|
||||||
|
<i data-lucide="panel-left" aria-hidden="true"></i>
|
||||||
|
<span>Menu</span>
|
||||||
|
</button>
|
||||||
|
<button class="icon-button" id="new-chat" type="button" title="New chat">
|
||||||
|
<i data-lucide="square-pen" aria-hidden="true"></i>
|
||||||
|
<span>New chat</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="chat-rail-content">
|
||||||
|
<section class="chat-nav-section">
|
||||||
|
<div class="rail-heading">Chats</div>
|
||||||
|
<div class="chat-list" id="chat-list"></div>
|
||||||
|
</section>
|
||||||
|
<section class="chat-nav-section">
|
||||||
|
<div class="rail-heading-row">
|
||||||
|
<div class="rail-heading">Negotiations</div>
|
||||||
|
<div class="rail-heading-actions">
|
||||||
|
<button class="rail-icon-button" id="negotiations-refresh-all" type="button" title="Refresh all negotiations">
|
||||||
|
<i data-lucide="refresh-cw" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<button class="rail-icon-button" id="negotiations-toggle" type="button" title="Negotiations" aria-expanded="false" aria-controls="negotiation-panel">
|
||||||
|
<i data-lucide="messages-square" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="plans-rail-list" id="negotiation-list"></div>
|
||||||
|
</section>
|
||||||
|
<section class="chat-nav-section">
|
||||||
|
<div class="rail-heading-row">
|
||||||
|
<div class="rail-heading">Plans</div>
|
||||||
|
<button class="rail-icon-button" id="plans-toggle" type="button" title="Plans" aria-expanded="false" aria-controls="plans-panel">
|
||||||
|
<i data-lucide="list-checks" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="plans-rail-list" id="plans-rail-list"></div>
|
||||||
|
</section>
|
||||||
|
<section class="chat-nav-section">
|
||||||
|
<div class="rail-heading">Inbox</div>
|
||||||
|
<div class="inbox-list" id="inbox-list"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
<section class="workspace">
|
<section class="workspace">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div>
|
<div class="brand-block">
|
||||||
|
<div class="logo-wrap" aria-hidden="true">
|
||||||
|
<img src="/static/art/LBC_Logo.png" alt="">
|
||||||
|
</div>
|
||||||
|
<div class="brand-copy">
|
||||||
|
<p class="eyebrow">Lambda Banking Conglomerate</p>
|
||||||
<h1>TraderAI</h1>
|
<h1>TraderAI</h1>
|
||||||
<p>Local Ollama chat for UEX marketplace work</p>
|
<p>Institutional marketplace intelligence for UEX operations</p>
|
||||||
|
</div>
|
||||||
|
<span class="brand-short" aria-hidden="true">LBC</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="status" id="status">Ready</div>
|
<div class="status" id="status">Ready</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -20,7 +73,10 @@
|
|||||||
<div class="messages" id="messages"></div>
|
<div class="messages" id="messages"></div>
|
||||||
<div class="composer-wrap">
|
<div class="composer-wrap">
|
||||||
<form class="composer" id="chat-form">
|
<form class="composer" id="chat-form">
|
||||||
|
<div class="composer-main">
|
||||||
<textarea id="message-input" rows="2" placeholder="Search listings, draft a reply, prepare an offer..."></textarea>
|
<textarea id="message-input" rows="2" placeholder="Search listings, draft a reply, prepare an offer..."></textarea>
|
||||||
|
<div class="composer-images" id="composer-images" hidden></div>
|
||||||
|
</div>
|
||||||
<button type="submit">Send</button>
|
<button type="submit">Send</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -30,7 +86,38 @@
|
|||||||
<h2>Pending Approval</h2>
|
<h2>Pending Approval</h2>
|
||||||
<div id="pending-actions" class="pending-empty">No pending actions</div>
|
<div id="pending-actions" class="pending-empty">No pending actions</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="side-section">
|
<section class="side-section sidebar-tools">
|
||||||
|
<div class="sidebar-panel" id="settings-panel" hidden>
|
||||||
|
<div class="section-title-row">
|
||||||
|
<h2>Config</h2>
|
||||||
|
<button class="secondary small-button" id="config-refresh" type="button">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<form class="config-form" id="config-form">
|
||||||
|
<label>UEX API URL<input id="config-uex-base-url" name="uex_base_url" type="text"></label>
|
||||||
|
<label>UEX Secret Key<input id="config-uex-secret-key" name="uex_secret_key" type="password" autocomplete="off"></label>
|
||||||
|
<label>UEX Bearer Token<input id="config-uex-bearer-token" name="uex_bearer_token" type="password" autocomplete="off"></label>
|
||||||
|
<label>UEX Close Endpoint<input id="config-uex-negotiation-close-endpoint" name="uex_negotiation_close_endpoint" type="text"></label>
|
||||||
|
<label>UEX Username<input id="config-traderai-user-name" name="traderai_user_name" type="text"></label>
|
||||||
|
<label>Memory DB Path<input id="config-traderai-memory-path" name="traderai_memory_path" type="text"></label>
|
||||||
|
<label>Notification Poll Seconds<input id="config-uex-notification-poll-seconds" name="uex_notification_poll_seconds" type="number" min="15" step="15"></label>
|
||||||
|
<label class="config-check"><input id="config-require-write-approval" name="require_write_approval" type="checkbox"> Require write approval</label>
|
||||||
|
<div class="config-paths" id="config-paths"></div>
|
||||||
|
<button type="submit">Save Config</button>
|
||||||
|
<div class="config-status" id="config-status"></div>
|
||||||
|
</form>
|
||||||
|
<div class="update-box">
|
||||||
|
<div class="section-title-row">
|
||||||
|
<h2>Updates</h2>
|
||||||
|
<button class="secondary small-button" id="update-check" type="button">Check</button>
|
||||||
|
</div>
|
||||||
|
<div class="update-status" id="update-status"></div>
|
||||||
|
<div class="update-actions">
|
||||||
|
<button class="secondary small-button" id="update-open-releases" type="button">Releases</button>
|
||||||
|
<button class="small-button" id="update-install" type="button">Update</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-panel" id="memory-panel" hidden>
|
||||||
<div class="section-title-row">
|
<div class="section-title-row">
|
||||||
<h2>Memory</h2>
|
<h2>Memory</h2>
|
||||||
<button class="secondary small-button" id="memory-refresh" type="button">Refresh</button>
|
<button class="secondary small-button" id="memory-refresh" type="button">Refresh</button>
|
||||||
@@ -44,9 +131,235 @@
|
|||||||
</div>
|
</div>
|
||||||
<button class="danger-button" id="memory-clear" type="button">Clear Selected</button>
|
<button class="danger-button" id="memory-clear" type="button">Clear Selected</button>
|
||||||
<div id="memory-inspector" class="memory-inspector"></div>
|
<div id="memory-inspector" class="memory-inspector"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-panel" id="ollama-panel" hidden>
|
||||||
|
<div class="section-title-row">
|
||||||
|
<h2>Inference</h2>
|
||||||
|
<button class="secondary small-button" id="ollama-refresh" type="button">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<form class="config-form" id="ollama-config-form">
|
||||||
|
<label>Provider
|
||||||
|
<select id="model-provider" name="model_provider">
|
||||||
|
<option value="deepseek">DeepSeek V4 (Recommended)</option>
|
||||||
|
<option value="ollama">Local Ollama</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label data-provider-scope="deepseek">DeepSeek URL<input id="deepseek-base-url" name="deepseek_base_url" type="text"></label>
|
||||||
|
<label data-provider-scope="deepseek">DeepSeek API Key<input id="deepseek-api-key" name="deepseek_api_key" type="password" autocomplete="off"></label>
|
||||||
|
<label data-provider-scope="deepseek" data-manual-model="true">DeepSeek Model<input id="deepseek-model" name="deepseek_model" type="text" list="provider-models"></label>
|
||||||
|
<label data-provider-scope="ollama">Ollama URL<input id="ollama-base-url" name="ollama_base_url" type="text"></label>
|
||||||
|
<label data-provider-scope="ollama">Ollama Model<input id="ollama-model" name="ollama_model" type="text" list="provider-models"></label>
|
||||||
|
<label data-provider-scope="ollama">Context Tokens<input id="ollama-num-ctx" name="ollama_num_ctx" type="number" min="1024" step="1024"></label>
|
||||||
|
<label><span id="provider-model-label">Model</span><select id="provider-model-select"></select></label>
|
||||||
|
<label>Reasoning Effort<select id="model-reasoning-effort" name="model_reasoning_effort"></select></label>
|
||||||
|
<datalist id="provider-models"></datalist>
|
||||||
|
<button type="submit">Save Provider Config</button>
|
||||||
|
</form>
|
||||||
|
<div class="ollama-status" id="ollama-status"></div>
|
||||||
|
<div class="ollama-actions">
|
||||||
|
<button class="secondary small-button" id="ollama-download" type="button">Download</button>
|
||||||
|
<button class="secondary small-button" id="ollama-install" type="button">Auto Install</button>
|
||||||
|
<button class="secondary small-button" id="ollama-launch" type="button">Launch</button>
|
||||||
|
<button class="small-button" id="ollama-pull" type="button">Install Model</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-status" id="ollama-message"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-tool-buttons" role="tablist" aria-label="Sidebar panels">
|
||||||
|
<button class="sidebar-tool-button" id="settings-toggle" type="button" aria-expanded="false" aria-controls="settings-panel" title="Settings">
|
||||||
|
<i data-lucide="settings" aria-hidden="true"></i>
|
||||||
|
<span>Settings</span>
|
||||||
|
</button>
|
||||||
|
<button class="sidebar-tool-button" id="memory-toggle" type="button" aria-expanded="false" aria-controls="memory-panel" title="Memory">
|
||||||
|
<i data-lucide="brain" aria-hidden="true"></i>
|
||||||
|
<span>Memory</span>
|
||||||
|
</button>
|
||||||
|
<button class="sidebar-tool-button" id="ollama-toggle" type="button" aria-expanded="false" aria-controls="ollama-panel" title="Inference">
|
||||||
|
<img class="sidebar-tool-image" src="/static/art/ollama-icon.svg" alt="" onerror="this.remove();">
|
||||||
|
<i data-lucide="bot" aria-hidden="true"></i>
|
||||||
|
<span>Inference</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
|
<div class="floating-panel" id="negotiation-panel" hidden>
|
||||||
|
<div class="floating-panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">UEX negotiations</p>
|
||||||
|
<h2 id="negotiation-title">Negotiation workspace</h2>
|
||||||
|
</div>
|
||||||
|
<div class="floating-panel-actions">
|
||||||
|
<div class="negotiation-sync-pill" id="negotiation-sync-pill">Local sync</div>
|
||||||
|
<button class="icon-button light" id="negotiation-close" type="button" title="Close">
|
||||||
|
<i data-lucide="x" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="negotiation-workspace">
|
||||||
|
<aside class="negotiation-sidebar">
|
||||||
|
<div class="negotiation-sidebar-controls">
|
||||||
|
<input id="negotiation-search" type="text" placeholder="Search negotiations">
|
||||||
|
<select id="negotiation-filter">
|
||||||
|
<option value="open">Open</option>
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="closed">Closed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="negotiation-list-panel" id="negotiation-panel-list"></div>
|
||||||
|
</aside>
|
||||||
|
<section class="negotiation-thread-shell">
|
||||||
|
<div class="negotiation-thread-header" id="negotiation-thread-header">
|
||||||
|
<div class="muted">Select a negotiation to load the local thread.</div>
|
||||||
|
</div>
|
||||||
|
<div class="negotiation-messages" id="negotiation-messages"></div>
|
||||||
|
<form class="negotiation-composer" id="negotiation-form">
|
||||||
|
<textarea id="negotiation-input" rows="2" placeholder="Reply to the other party..."></textarea>
|
||||||
|
<div class="negotiation-composer-actions">
|
||||||
|
<button class="secondary small-button" id="negotiation-draft-button" type="button">Ask AI to Draft</button>
|
||||||
|
<button type="submit">Send</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="config-status" id="negotiation-status"></div>
|
||||||
|
</section>
|
||||||
|
<aside class="negotiation-detail-rail">
|
||||||
|
<div class="negotiation-detail-card" id="negotiation-meta-card">
|
||||||
|
<h3>Deal</h3>
|
||||||
|
<div class="muted">No negotiation selected.</div>
|
||||||
|
</div>
|
||||||
|
<div class="negotiation-detail-card" id="negotiation-user-card">
|
||||||
|
<h3>User</h3>
|
||||||
|
<div class="muted">No negotiation selected.</div>
|
||||||
|
</div>
|
||||||
|
<div class="negotiation-detail-card">
|
||||||
|
<h3>Actions</h3>
|
||||||
|
<div class="negotiation-action-stack">
|
||||||
|
<button class="secondary small-button" id="negotiation-open-chat" type="button">Open in AI Chat</button>
|
||||||
|
<button class="small-button" id="negotiation-refresh-button" type="button">Refresh Thread</button>
|
||||||
|
<button class="danger-button small-button" id="negotiation-end-deal" type="button">End Deal</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop" id="negotiation-close-modal" hidden>
|
||||||
|
<section class="update-modal-card negotiation-close-card">
|
||||||
|
<div class="section-title-row">
|
||||||
|
<h2>End Deal</h2>
|
||||||
|
<button class="icon-button light" id="negotiation-close-modal-close" type="button" title="Close">
|
||||||
|
<i data-lucide="x" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form class="config-form" id="negotiation-close-form">
|
||||||
|
<label>Did you close a deal?
|
||||||
|
<select id="close-deal-closed">
|
||||||
|
<option value="true">Yes</option>
|
||||||
|
<option value="false">No</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="plan-form-split">
|
||||||
|
<label>Deal value
|
||||||
|
<input id="close-deal-value" type="number" min="0" step="1" placeholder="1000000">
|
||||||
|
</label>
|
||||||
|
<label>Currency
|
||||||
|
<input id="close-currency" type="text" value="UEC">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>Clear, timely, and honest?
|
||||||
|
<input id="close-clarity" type="number" min="1" max="5" step="1" value="5">
|
||||||
|
</label>
|
||||||
|
<label>Delivery or response time?
|
||||||
|
<input id="close-speed" type="number" min="1" max="5" step="1" value="5">
|
||||||
|
</label>
|
||||||
|
<label>Respectful and easy to deal with?
|
||||||
|
<input id="close-respect" type="number" min="1" max="5" step="1" value="5">
|
||||||
|
</label>
|
||||||
|
<label>Price or offer fairness?
|
||||||
|
<input id="close-fairness" type="number" min="1" max="5" step="1" value="5">
|
||||||
|
</label>
|
||||||
|
<label>Comments
|
||||||
|
<textarea id="close-comment" rows="3" placeholder="Optional note"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="plan-form-actions">
|
||||||
|
<button class="secondary" id="close-draft-button" type="button">Draft for Approval</button>
|
||||||
|
<button type="submit">Rate Deal</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-status" id="negotiation-close-status"></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div class="floating-panel plans-floating-panel" id="plans-panel" hidden>
|
||||||
|
<div class="floating-panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Continual work</p>
|
||||||
|
<h2>Plans</h2>
|
||||||
|
</div>
|
||||||
|
<div class="floating-panel-actions">
|
||||||
|
<button class="icon-button light" id="plans-refresh" type="button" title="Refresh plans">
|
||||||
|
<i data-lucide="refresh-cw" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<button class="icon-button light" id="plans-close" type="button" title="Close">
|
||||||
|
<i data-lucide="x" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="plans-panel-body">
|
||||||
|
<aside class="plan-creator-shell">
|
||||||
|
<div class="plan-creator-card">
|
||||||
|
<div class="plan-creator-copy">
|
||||||
|
<p class="eyebrow">New continual plan</p>
|
||||||
|
<h3>Set the watch once</h3>
|
||||||
|
<p>Spin up buying runs or custom follow-up work with a title, a goal, and just enough guardrails to keep it on track.</p>
|
||||||
|
</div>
|
||||||
|
<form class="config-form plan-form-grid" id="plan-form">
|
||||||
|
<label>Title<input id="plan-title" type="text" placeholder="Wikelo Idris parts"></label>
|
||||||
|
<label>Objective<input id="plan-objective" type="text" placeholder="Find and draft deals for the parts I list"></label>
|
||||||
|
<div class="plan-form-split">
|
||||||
|
<label>Kind
|
||||||
|
<select id="plan-kind">
|
||||||
|
<option value="buying">Buying</option>
|
||||||
|
<option value="custom">Custom</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Message Tone<input id="plan-tone" type="text" placeholder="polite and concise"></label>
|
||||||
|
</div>
|
||||||
|
<label>Items<textarea id="plan-items" rows="5" placeholder="One item per line, optionally: name | quantity | max unit price"></textarea></label>
|
||||||
|
<label>Instructions<textarea id="plan-instructions" rows="4" placeholder="Extra guidance for custom or buying plans"></textarea></label>
|
||||||
|
<div class="plan-form-split">
|
||||||
|
<label>Cron Cadence<input id="plan-cadence" type="text" placeholder="0 */6 * * *"></label>
|
||||||
|
<div class="plan-form-hint">
|
||||||
|
<strong>Tip</strong>
|
||||||
|
<span>Buying plans work best with item lines. Custom plans can run with just instructions.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="plan-form-actions">
|
||||||
|
<button id="plan-autofill" type="button">AI Fill</button>
|
||||||
|
<button type="submit">Create Plan</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-status" id="plans-status"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<section class="plans-dashboard-shell">
|
||||||
|
<div class="plans-dashboard" id="plans-dashboard"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop" id="update-modal" hidden>
|
||||||
|
<section class="update-modal-card">
|
||||||
|
<div class="section-title-row">
|
||||||
|
<h2>Update Available</h2>
|
||||||
|
<button class="icon-button light" id="update-modal-close" type="button" title="Close">
|
||||||
|
<i data-lucide="x" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p id="update-modal-copy"></p>
|
||||||
|
<div class="update-actions">
|
||||||
|
<button class="secondary small-button" id="update-modal-releases" type="button">Releases</button>
|
||||||
|
<button class="small-button" id="update-modal-install" type="button">Update</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<script src="https://unpkg.com/lucide@0.562.0/dist/umd/lucide.min.js"></script>
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1974
-155
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,175 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<link href="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/695be2905c0b4866dfb21265/62b39a568_Wikapp3.webp" rel="icon" type="image/svg+xml"/>
|
||||||
|
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||||
|
<link href="/manifest.json" rel="manifest"/>
|
||||||
|
<title>
|
||||||
|
Ships | Wikelo Project Tracker
|
||||||
|
</title>
|
||||||
|
<script crossorigin="" src="/assets/index-DWqdqkK8.js" type="module">
|
||||||
|
</script>
|
||||||
|
<link crossorigin="" href="/assets/index-BzxCYXI2.css" rel="stylesheet"/>
|
||||||
|
<meta content="Ships on Wikelo Project Tracker. Track materials needed and contributed for building your Star Citizen ships." name="description"/>
|
||||||
|
<meta content="Ships | Wikelo Project Tracker" property="og:title"/>
|
||||||
|
<meta content="Ships on Wikelo Project Tracker. Track materials needed and contributed for building your Star Citizen ships." property="og:description"/>
|
||||||
|
<meta content="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/render/image/public/base44-prod/public/695be2905c0b4866dfb21265/62b39a568_Wikapp3.webp?width=1200&height=630&resize=contain" property="og:image"/>
|
||||||
|
<meta content="https://wikelo-projects.com/Ships" property="og:url"/>
|
||||||
|
<meta content="website" property="og:type"/>
|
||||||
|
<meta content="Wikelo Project Tracker" property="og:site_name"/>
|
||||||
|
<meta content="Ships | Wikelo Project Tracker" name="twitter:title"/>
|
||||||
|
<meta content="Ships on Wikelo Project Tracker. Track materials needed and contributed for building your Star Citizen ships." name="twitter:description"/>
|
||||||
|
<meta content="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/render/image/public/base44-prod/public/695be2905c0b4866dfb21265/62b39a568_Wikapp3.webp?width=1200&height=630&resize=contain" name="twitter:image"/>
|
||||||
|
<meta content="summary_large_image" name="twitter:card"/>
|
||||||
|
<meta content="https://wikelo-projects.com/Ships" name="twitter:url"/>
|
||||||
|
<meta content="yes" name="mobile-web-app-capable"/>
|
||||||
|
<meta content="black" name="apple-mobile-web-app-status-bar-style"/>
|
||||||
|
<meta content="Wikelo Project Tracker" name="apple-mobile-web-app-title"/>
|
||||||
|
<link href="https://wikelo-projects.com/Ships" rel="canonical"/>
|
||||||
|
<script data-seo-source="builder" type="application/ld+json">
|
||||||
|
{"name": "Wikelo Project Tracker", "@context": "https://schema.org", "@type": "WebSite", "url": "https://wikelo-projects.com"}
|
||||||
|
</script>
|
||||||
|
<script data-seo-source="builder" type="application/ld+json">
|
||||||
|
{"name": "Wikelo Project Tracker", "logo": "https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/695be2905c0b4866dfb21265/62b39a568_Wikapp3.webp", "@context": "https://schema.org", "@type": "Organization", "url": "https://wikelo-projects.com"}
|
||||||
|
</script>
|
||||||
|
<script data-seo-source="builder" type="application/ld+json">
|
||||||
|
{"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [{"@type": "ListItem", "position": 1, "name": "Home", "item": "https://wikelo-projects.com/"}, {"@type": "ListItem", "position": 2, "name": "Ships | Wikelo Project Tracker", "item": "https://wikelo-projects.com/Ships"}]}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root">
|
||||||
|
<div data-seo-source="builder" id="seo-snapshot" style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;">
|
||||||
|
<h1>
|
||||||
|
Ships | Wikelo Project Tracker
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
Ships on Wikelo Project Tracker. Track materials needed and contributed for building your Star Citizen ships.
|
||||||
|
</p>
|
||||||
|
<nav aria-label="Pages">
|
||||||
|
<h2>
|
||||||
|
Pages
|
||||||
|
</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="/AdminAds">
|
||||||
|
Admin Ads
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/AdvertiseWithUs">
|
||||||
|
Advertise With Us
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/ArmorProjectDetails">
|
||||||
|
Armor Project Details
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/Armors">
|
||||||
|
Armors
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/CleanupMaterials">
|
||||||
|
Cleanup Materials
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/Guide">
|
||||||
|
Guide
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/">
|
||||||
|
Home
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/Inventory">
|
||||||
|
Inventory
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/Messages">
|
||||||
|
Messages
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/NotificationSettings">
|
||||||
|
Notification Settings
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/Notifications">
|
||||||
|
Notifications
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/OrganizationDetails">
|
||||||
|
Organization Details
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/Organizations">
|
||||||
|
Organizations
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/Profile">
|
||||||
|
Profile
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/ProjectDetails">
|
||||||
|
Project Details
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/RecalculateContributionReputation">
|
||||||
|
Recalculate Contribution Reputation
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/RecalculateMaterials">
|
||||||
|
Recalculate Materials
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/RecalculateReputation">
|
||||||
|
Recalculate Reputation
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/Reputation">
|
||||||
|
Reputation
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/UpdateInfo">
|
||||||
|
Update Info
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/WeaponProjectDetails">
|
||||||
|
Weapon Project Details
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/Weapons">
|
||||||
|
Weapons
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/WikeloProjectDetails">
|
||||||
|
Wikelo Project Details
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
Reference in New Issue
Block a user