Spaces:
Sleeping
Sleeping
added dropbox, OCR routing logic, graph connection fix
Browse files- .claude/settings.local.json +21 -0
- .env.example +18 -6
- .gitignore +5 -0
- CLAUDE.md +1 -3
- Dockerfile +3 -3
- ONBOARDING.md +27 -84
- README.md +1 -14
- app/classifier.py +60 -0
- app/config.py +10 -3
- app/database.py +50 -0
- app/dropbox_client.py +27 -0
- app/email_processor.py +3 -2
- app/folder_resolver.py +2 -2
- app/main.py +1 -1
- app/processor.py +196 -0
- docker-compose.yml +1 -2
- entrypoint.sh +5 -3
- requirements.txt +4 -0
- scripts/dropbox_auth.py +58 -0
.claude/settings.local.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"permissions": {
|
| 3 |
+
"allow": [
|
| 4 |
+
"Bash(php --version)",
|
| 5 |
+
"Bash(git --version)",
|
| 6 |
+
"Bash(docker --version)",
|
| 7 |
+
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://wordpress.localhost)",
|
| 8 |
+
"Bash(grep -v \"^$\")",
|
| 9 |
+
"PowerShell(Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -in @\\(80, 443, 3306, 8080, 8443\\) } | Select-Object LocalAddress, LocalPort, OwningProcess)",
|
| 10 |
+
"PowerShell(winget --version 2>$null)",
|
| 11 |
+
"PowerShell(if \\($?\\) { \"winget: available\" } else { \"winget: not found\" })",
|
| 12 |
+
"PowerShell($r = Invoke-WebRequest -Uri \"http://wordpress.localhost\" -UseBasicParsing -ErrorAction SilentlyContinue; \"$\\($r.StatusCode\\) - $\\($r.Headers['Content-Type']\\)\")",
|
| 13 |
+
"PowerShell($env:PATH = \"C:\\\\xampp\\\\php;$env:PATH\"; wp --info 2>&1 | Select-Object -First 5)",
|
| 14 |
+
"Bash(xxd)",
|
| 15 |
+
"mcp__Claude_in_Chrome__tabs_context_mcp",
|
| 16 |
+
"Bash(xargs grep *)",
|
| 17 |
+
"Bash(curl -s -b /tmp/wp_c2.txt -H 'Content-Type: application/json' -H 'X-WP-Nonce: cdc16f767b' -d '{\"prompt\":\"What is your role?\",\"page\":\"sa-orchestration\"}' http://wordpress.localhost/wp-json/sa-orch/v1/sara/invoke)",
|
| 18 |
+
"Bash(curl -s -b /tmp/wp_c2.txt -H 'Content-Type: application/json' -H 'X-WP-Nonce: cdc16f767b' -d '{\"board_id\":1,\"topic\":\"jno-verification\"}' http://wordpress.localhost/wp-json/sa-orch/v1/seed/generate)"
|
| 19 |
+
]
|
| 20 |
+
}
|
| 21 |
+
}
|
.env.example
CHANGED
|
@@ -3,6 +3,9 @@ AZURE_TENANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
|
| 3 |
AZURE_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
| 4 |
AZURE_CLIENT_SECRET=your-client-secret-here
|
| 5 |
|
|
|
|
|
|
|
|
|
|
| 6 |
# Mail folder to monitor (top-level display name only; all others are ignored)
|
| 7 |
MAIL_FOLDER_NAME=Inbox
|
| 8 |
|
|
@@ -13,14 +16,23 @@ NOTIFICATION_URL=https://your-hostname.example.com/webhook/notify
|
|
| 13 |
# Graph subscription max lifetime in minutes (hard cap: 4230)
|
| 14 |
SUBSCRIPTION_EXPIRY_MINUTES=4230
|
| 15 |
|
| 16 |
-
# Server
|
| 17 |
HOST=0.0.0.0
|
| 18 |
-
PORT=
|
| 19 |
-
|
| 20 |
-
# TLS certificate paths (mounted as a read-only volume into the container)
|
| 21 |
-
TLS_CERT_FILE=/certs/server.crt
|
| 22 |
-
TLS_KEY_FILE=/certs/server.key
|
| 23 |
|
| 24 |
# Storage paths (volume-mounted)
|
| 25 |
DATABASE_PATH=/data/rcmemail.db
|
| 26 |
EMAIL_STORAGE_PATH=/emails
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
AZURE_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
| 4 |
AZURE_CLIENT_SECRET=your-client-secret-here
|
| 5 |
|
| 6 |
+
# UPN or object ID of the mailbox to monitor (required for app-only auth)
|
| 7 |
+
MAILBOX_USER=user@yourdomain.com
|
| 8 |
+
|
| 9 |
# Mail folder to monitor (top-level display name only; all others are ignored)
|
| 10 |
MAIL_FOLDER_NAME=Inbox
|
| 11 |
|
|
|
|
| 16 |
# Graph subscription max lifetime in minutes (hard cap: 4230)
|
| 17 |
SUBSCRIPTION_EXPIRY_MINUTES=4230
|
| 18 |
|
| 19 |
+
# Server (TLS is terminated by the hosting provider, not uvicorn)
|
| 20 |
HOST=0.0.0.0
|
| 21 |
+
PORT=8000
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
# Storage paths (volume-mounted)
|
| 24 |
DATABASE_PATH=/data/rcmemail.db
|
| 25 |
EMAIL_STORAGE_PATH=/emails
|
| 26 |
+
|
| 27 |
+
# Dropbox — confirm folder paths with ops/Hunter before going live
|
| 28 |
+
# App key + secret come from the Dropbox App Console (appinfo tab)
|
| 29 |
+
# Refresh token is generated once via scripts/dropbox_auth.py and stored here
|
| 30 |
+
DROPBOX_APP_KEY=your-app-key
|
| 31 |
+
DROPBOX_APP_SECRET=your-app-secret
|
| 32 |
+
DROPBOX_REFRESH_TOKEN=your-refresh-token
|
| 33 |
+
DROPBOX_PO_PATH=/RCM Supply/Inside Sales/POs
|
| 34 |
+
DROPBOX_INVOICE_PATH=/RCM Supply/Inside Sales/Invoices
|
| 35 |
+
DROPBOX_MTR_PATH=/RCM Supply/MTRs
|
| 36 |
+
|
| 37 |
+
# Processor poll interval in seconds
|
| 38 |
+
PROCESSOR_POLL_INTERVAL=10
|
.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
*.pyo
|
| 5 |
+
.pytest_cache/
|
CLAUDE.md
CHANGED
|
@@ -44,10 +44,8 @@ This SleeperAgents-internal project (RcmEmailAutomation) sits at the SA-Orchestr
|
|
| 44 |
|
| 45 |
## ── REPO LAYOUT (root level) ────────────────────────────────────────────────
|
| 46 |
|
| 47 |
-
> Paths below are **relative to this repo root** — wherever the developer cloned it. Drive letters and absolute paths from the seeding machine do not apply. See `README.md` → "Path discipline" before assuming any path.
|
| 48 |
-
|
| 49 |
```
|
| 50 |
-
|
| 51 |
├── app/ ← Python FastAPI service (production target)
|
| 52 |
│ ├── main.py
|
| 53 |
│ ├── auth.py, config.py, database.py, email_processor.py
|
|
|
|
| 44 |
|
| 45 |
## ── REPO LAYOUT (root level) ────────────────────────────────────────────────
|
| 46 |
|
|
|
|
|
|
|
| 47 |
```
|
| 48 |
+
F:\SleeperAgents\RcmEmailAutomation\
|
| 49 |
├── app/ ← Python FastAPI service (production target)
|
| 50 |
│ ├── main.py
|
| 51 |
│ ├── auth.py, config.py, database.py, email_processor.py
|
Dockerfile
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
FROM python:3.12-slim
|
| 2 |
|
| 3 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 4 |
-
build-essential \
|
| 5 |
&& rm -rf /var/lib/apt/lists/*
|
| 6 |
|
| 7 |
WORKDIR /app
|
|
@@ -14,8 +14,8 @@ RUN chmod +x /entrypoint.sh
|
|
| 14 |
|
| 15 |
COPY app/ ./app/
|
| 16 |
|
| 17 |
-
RUN mkdir -p /data /emails
|
| 18 |
|
| 19 |
-
EXPOSE
|
| 20 |
|
| 21 |
ENTRYPOINT ["/entrypoint.sh"]
|
|
|
|
| 1 |
FROM python:3.12-slim
|
| 2 |
|
| 3 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 4 |
+
build-essential tesseract-ocr poppler-utils \
|
| 5 |
&& rm -rf /var/lib/apt/lists/*
|
| 6 |
|
| 7 |
WORKDIR /app
|
|
|
|
| 14 |
|
| 15 |
COPY app/ ./app/
|
| 16 |
|
| 17 |
+
RUN mkdir -p /data /emails
|
| 18 |
|
| 19 |
+
EXPOSE 8000
|
| 20 |
|
| 21 |
ENTRYPOINT ["/entrypoint.sh"]
|
ONBOARDING.md
CHANGED
|
@@ -64,14 +64,13 @@ Run this audit before installing or running anything. Surface findings to the hu
|
|
| 64 |
|
| 65 |
### Audit checklist
|
| 66 |
|
| 67 |
-
1. **Local stack capability** — does this machine already have a local PHP + MySQL + WordPress capability?
|
| 68 |
-
- XAMPP
|
| 69 |
-
- Local by Flywheel
|
| 70 |
-
- Laragon
|
| 71 |
-
- MAMP
|
| 72 |
-
- Docker Desktop with WordPress containers
|
| 73 |
- Any custom Apache/Nginx + PHP-FPM + MySQL combination
|
| 74 |
-
- **The developer may have none of the above** — JNO must respect that and use what they have or what they explicitly approve installing. Do not install on a drive or location the developer has not authorized.
|
| 75 |
|
| 76 |
2. **PHP version available** — `php --version` (target: 7.4+, 8.1+ preferred)
|
| 77 |
|
|
@@ -145,43 +144,49 @@ After activating each, walk its setup wizard to completion. Skip any optional in
|
|
| 145 |
- FluentCRM: `wp_fc_*` tables exist (subscribers, campaigns, etc.). Admin page renders.
|
| 146 |
- Fluent Support: `wp_fs_tickets` table exists. Admin page at `?page=fluent-support` renders. **Watch for the prefix-orphan trap** — if tables ended up at a non-standard prefix (e.g., from a cloned DB), that's a separate fix not part of fresh JNO.
|
| 147 |
|
| 148 |
-
### Step 5 —
|
| 149 |
|
| 150 |
-
|
| 151 |
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
|
| 154 |
```
|
| 155 |
-
cp -r
|
| 156 |
```
|
| 157 |
|
| 158 |
-
Adjust path syntax for the
|
| 159 |
|
| 160 |
**Verify**: `<wp-install>/wp-content/plugins/sa-orchestration/sa-orchestration.php` exists.
|
| 161 |
|
| 162 |
-
If the pre-staged `sa-orchestration/` directory is missing for some reason, **stop and surface to the human**. Do not invent a clone URL. The seed should always include the plugin tree at `<repo-root>/sa-orchestration/`; absence indicates a corrupted seed, not a missing dependency.
|
| 163 |
-
|
| 164 |
### Step 6 — Place the Asterion canonical corpus
|
| 165 |
|
| 166 |
The SA-Orchestration plugin reads its canonical content from a filterable path. For local JNO, point that path at the corpus copy in this repo.
|
| 167 |
|
| 168 |
Two valid placements:
|
| 169 |
|
| 170 |
-
**
|
| 171 |
|
| 172 |
```
|
| 173 |
-
# (a) Mirror the canonical corpus into the WP install
|
| 174 |
mkdir -p <wp-install>/wp-content/sa-asterion
|
| 175 |
cp -r <this-repo>/asterion/* <wp-install>/wp-content/sa-asterion/
|
| 176 |
-
|
| 177 |
-
# (b) Install the pre-staged mu-plugin (already in this repo at <repo-root>/mu-plugins/sa-orch-spec-root.php)
|
| 178 |
-
mkdir -p <wp-install>/wp-content/mu-plugins
|
| 179 |
-
cp <this-repo>/mu-plugins/sa-orch-spec-root.php <wp-install>/wp-content/mu-plugins/sa-orch-spec-root.php
|
| 180 |
```
|
| 181 |
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
-
|
| 185 |
|
| 186 |
**Verify**: filesystem readable by the web user; `01-ontology.md` and `02-invariants.md` exist at the chosen location.
|
| 187 |
|
|
@@ -311,68 +316,6 @@ Stop. Wait for human direction.
|
|
| 311 |
|
| 312 |
---
|
| 313 |
|
| 314 |
-
### Step 14 — Configure Remote Roots (post-acceptance, optional but recommended)
|
| 315 |
-
|
| 316 |
-
SA-Orchestration v0.8.0 ships a new admin page: **SA-Orchestration → Remote Roots**. It connects this leaf install to a remote SA-Orchestration root (e.g., the dev root at `sleeperagendev.wpenginepowered.com` or a per-client root) via leaf-initiated polling. The leaf fetches Fluent Boards tasks where the authenticated user is an assignee on the root.
|
| 317 |
-
|
| 318 |
-
**Doctrine**: leaf-initiated, always up; the root never reaches into the leaf.
|
| 319 |
-
|
| 320 |
-
#### One-time setup
|
| 321 |
-
|
| 322 |
-
1. On the **root** install:
|
| 323 |
-
- Visit `<root>/wp-admin/profile.php`
|
| 324 |
-
- Scroll to **Application Passwords** → enter name (e.g. `"<leaf-host> leaf"`) → **Add New Application Password** → copy the value (one-time display)
|
| 325 |
-
|
| 326 |
-
2. On the **leaf** (this install):
|
| 327 |
-
- Open: `<wp-install>/wp-admin/admin.php?page=sa-orchestration-remote-roots`
|
| 328 |
-
- **Root URL**: e.g. `https://sleeperagendev.wpenginepowered.com`
|
| 329 |
-
- **App Username**: the WP login on the root (typically the user's email)
|
| 330 |
-
- **App Password**: paste the value from step 1
|
| 331 |
-
- **Save Remote Root**
|
| 332 |
-
|
| 333 |
-
3. Click **Fetch Now**.
|
| 334 |
-
|
| 335 |
-
#### The MVP loop
|
| 336 |
-
|
| 337 |
-
1. On the root's Fluent Boards, self-assign to any task.
|
| 338 |
-
2. Back on this leaf, click **Fetch Now**.
|
| 339 |
-
3. The new assignment appears in the table.
|
| 340 |
-
|
| 341 |
-
#### Out of MVP scope (deferred to v0.8.1+)
|
| 342 |
-
|
| 343 |
-
- Multi-root (only ONE root configurable in MVP)
|
| 344 |
-
- Adaptive polling cadence (manual fetch only in MVP; OS-level cron is Board #41)
|
| 345 |
-
- Fluent Support ticket auto-creation (just a list in MVP)
|
| 346 |
-
- Curated upstream replies (one-way down only in MVP)
|
| 347 |
-
|
| 348 |
-
---
|
| 349 |
-
|
| 350 |
-
### Updating SA-Orchestration after source changes
|
| 351 |
-
|
| 352 |
-
When the SA-Orchestration source repo ships an update (new plugin classes, bug fixes, doctrine v1.x), this repo's pre-staged `sa-orchestration/` tree is rolled forward by the seed maintainer in a separate commit. There is **no auto-update push**; updates propagate manually:
|
| 353 |
-
|
| 354 |
-
```
|
| 355 |
-
SA-Orch source → seed repos (maintainer commits) → your local clone (git pull) → your live WP (re-deploy)
|
| 356 |
-
```
|
| 357 |
-
|
| 358 |
-
To pull a source update into your live local WP install:
|
| 359 |
-
|
| 360 |
-
```
|
| 361 |
-
# 1. Pull latest pre-staged plugin tree
|
| 362 |
-
cd <repo-root>
|
| 363 |
-
git pull
|
| 364 |
-
|
| 365 |
-
# 2. Re-deploy to local WP install
|
| 366 |
-
cp -r sa-orchestration/* <wp-install>/wp-content/plugins/sa-orchestration/
|
| 367 |
-
|
| 368 |
-
# 3. Verify
|
| 369 |
-
# Visit /wp-admin/admin.php?page=sa-orchestration to confirm new admin pages.
|
| 370 |
-
```
|
| 371 |
-
|
| 372 |
-
Adjust path syntax for the developer's OS. **Note**: WP plugin "deactivate then reactivate" is NOT required for source-tree updates that don't add database tables. If a new schema migration ships, run a one-time deactivate→activate cycle.
|
| 373 |
-
|
| 374 |
-
---
|
| 375 |
-
|
| 376 |
## Rollback (if a step fails)
|
| 377 |
|
| 378 |
- **Step 1–3 fail**: WP itself isn't operational. Investigate the local stack; not an SA-Orch concern.
|
|
|
|
| 64 |
|
| 65 |
### Audit checklist
|
| 66 |
|
| 67 |
+
1. **Local stack capability** — does this machine already have a local PHP + MySQL + WordPress capability? Check for:
|
| 68 |
+
- XAMPP at `C:\xampp` or `F:\XAMPP` or similar
|
| 69 |
+
- Local by Flywheel at `C:\Users\<user>\Local Sites`
|
| 70 |
+
- Laragon at `C:\laragon`
|
| 71 |
+
- MAMP at `/Applications/MAMP` (macOS) or equivalent
|
| 72 |
+
- Docker Desktop with WordPress containers
|
| 73 |
- Any custom Apache/Nginx + PHP-FPM + MySQL combination
|
|
|
|
| 74 |
|
| 75 |
2. **PHP version available** — `php --version` (target: 7.4+, 8.1+ preferred)
|
| 76 |
|
|
|
|
| 144 |
- FluentCRM: `wp_fc_*` tables exist (subscribers, campaigns, etc.). Admin page renders.
|
| 145 |
- Fluent Support: `wp_fs_tickets` table exists. Admin page at `?page=fluent-support` renders. **Watch for the prefix-orphan trap** — if tables ended up at a non-standard prefix (e.g., from a cloned DB), that's a separate fix not part of fresh JNO.
|
| 146 |
|
| 147 |
+
### Step 5 — Clone SA-Orchestration plugin source
|
| 148 |
|
| 149 |
+
Verify the canonical SA-Orchestration repository URL with the human first. The likely location:
|
| 150 |
|
| 151 |
+
```
|
| 152 |
+
git clone https://github.com/SleeperAgents/SA-Orchestration.git /tmp/sa-orchestration-src
|
| 153 |
+
```
|
| 154 |
+
|
| 155 |
+
Then copy the plugin tree into the local WP install:
|
| 156 |
|
| 157 |
```
|
| 158 |
+
cp -r /tmp/sa-orchestration-src/plugin/sa-orchestration <wp-content>/plugins/sa-orchestration
|
| 159 |
```
|
| 160 |
|
| 161 |
+
Adjust path syntax for the dev's OS. On Windows: use the WP install's `wp-content\plugins\` path.
|
| 162 |
|
| 163 |
**Verify**: `<wp-install>/wp-content/plugins/sa-orchestration/sa-orchestration.php` exists.
|
| 164 |
|
|
|
|
|
|
|
| 165 |
### Step 6 — Place the Asterion canonical corpus
|
| 166 |
|
| 167 |
The SA-Orchestration plugin reads its canonical content from a filterable path. For local JNO, point that path at the corpus copy in this repo.
|
| 168 |
|
| 169 |
Two valid placements:
|
| 170 |
|
| 171 |
+
**A. Mirror corpus into WP content** (recommended):
|
| 172 |
|
| 173 |
```
|
|
|
|
| 174 |
mkdir -p <wp-install>/wp-content/sa-asterion
|
| 175 |
cp -r <this-repo>/asterion/* <wp-install>/wp-content/sa-asterion/
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
```
|
| 177 |
|
| 178 |
+
**B. Point the spec_root filter at this repo's `asterion/` directory directly** (no copy):
|
| 179 |
+
|
| 180 |
+
Drop a mu-plugin at `<wp-install>/wp-content/mu-plugins/sa-orch-spec-root.php`:
|
| 181 |
+
|
| 182 |
+
```php
|
| 183 |
+
<?php
|
| 184 |
+
add_filter( 'sa_orch_asterion_spec_root', function () {
|
| 185 |
+
return 'F:/SleeperAgents/RcmEmailAutomation/asterion';
|
| 186 |
+
}, 5 );
|
| 187 |
+
```
|
| 188 |
|
| 189 |
+
Choose A for cleanliness (corpus lives inside the WP install). Choose B for "single source of truth" (corpus stays in this repo, the WP install reads through). Either is acceptable for JNO.
|
| 190 |
|
| 191 |
**Verify**: filesystem readable by the web user; `01-ontology.md` and `02-invariants.md` exist at the chosen location.
|
| 192 |
|
|
|
|
| 316 |
|
| 317 |
---
|
| 318 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
## Rollback (if a step fails)
|
| 320 |
|
| 321 |
- **Step 1–3 fail**: WP itself isn't operational. Investigate the local stack; not an SA-Orch concern.
|
README.md
CHANGED
|
@@ -32,20 +32,7 @@ CR ⊇ CA ⊇ JourneySeeker ⊇ MissionNet/MeshNet ⊇ SA-Orchestration ⊇ Bran
|
|
| 32 |
|
| 33 |
Causal Relativity, Causal Agentics, JourneySeeker, the Compass Doctrine, the Story Seed pattern, and the Quest-vs-Job distinction are pre-existing intellectual property authored by **Mark Holak**. They are preserved under Invariant **I9** of the SA-Orchestration spec pack. **You may reference these concepts. You may not re-author them as if they originated at the implementation layer or at the agent layer.** When you cite them, cite them.
|
| 34 |
|
| 35 |
-
The full canonical corpus lives at
|
| 36 |
-
|
| 37 |
-
### Path discipline (read once; never violate)
|
| 38 |
-
|
| 39 |
-
This seed was authored on a different machine than the developer's. **Any absolute path that may appear in `ONBOARDING.md`, in this README, or in code excerpts is illustrative — drawn from the seeding machine — not canonical for the developer's environment.** Drive letters, root paths, and OS conventions on the seeding machine do not transfer.
|
| 40 |
-
|
| 41 |
-
Specifically:
|
| 42 |
-
|
| 43 |
-
- **Do not** install anything onto a drive letter, mount point, or path the developer has not declared.
|
| 44 |
-
- **Do not** assume any directory exists outside this repo's tree without verifying or asking.
|
| 45 |
-
- **Always** map paths through the developer's stated layout (Phase 0 surfaces this).
|
| 46 |
-
- **When in doubt about where something goes, ask.** The developer is the arbiter of their domain (see Phase 0 in `ONBOARDING.md`). Asking is cheaper than installing into the wrong place.
|
| 47 |
-
|
| 48 |
-
If the developer has not declared a drive layout in Phase 0, **ask explicitly before any install action**. Path-related questions are the kind that quietly pass when the agent assumes — and quietly destroy when the assumption is wrong.
|
| 49 |
|
| 50 |
### Doctrine carry-forward (the operating discipline)
|
| 51 |
|
|
|
|
| 32 |
|
| 33 |
Causal Relativity, Causal Agentics, JourneySeeker, the Compass Doctrine, the Story Seed pattern, and the Quest-vs-Job distinction are pre-existing intellectual property authored by **Mark Holak**. They are preserved under Invariant **I9** of the SA-Orchestration spec pack. **You may reference these concepts. You may not re-author them as if they originated at the implementation layer or at the agent layer.** When you cite them, cite them.
|
| 34 |
|
| 35 |
+
The full canonical corpus lives at `asterion/` in this repo (copied from the SA-Orchestration source of truth at `F:/SleeperAgents/SA-orchestration/asterion/`). Read it when you encounter a term you are uncertain about.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
### Doctrine carry-forward (the operating discipline)
|
| 38 |
|
app/classifier.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import io
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
_KEYWORD_MAP = (
|
| 9 |
+
("mtr", ("material test report", "mill test report", "mtr")),
|
| 10 |
+
("po", ("purchase order", "p.o.", "purchase ord")),
|
| 11 |
+
("invoice", ("invoice", "inv #", "inv#")),
|
| 12 |
+
("quote", ("quotation", "quote", "rfq", "request for quote", "bid")),
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
DocType = str # 'po' | 'invoice' | 'mtr' | 'quote' | 'unknown'
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def classify(subject: str, filename: str, file_bytes: bytes) -> DocType:
|
| 19 |
+
subject_lower = subject.lower() if subject else ""
|
| 20 |
+
filename_lower = filename.lower() if filename else ""
|
| 21 |
+
|
| 22 |
+
if "mtr" in subject_lower:
|
| 23 |
+
return "mtr"
|
| 24 |
+
if "mtr" in filename_lower:
|
| 25 |
+
return "mtr"
|
| 26 |
+
|
| 27 |
+
text = _ocr_pdf(file_bytes)
|
| 28 |
+
if not text:
|
| 29 |
+
return "unknown"
|
| 30 |
+
|
| 31 |
+
sample = text[:500].lower()
|
| 32 |
+
|
| 33 |
+
for doc_type, keywords in _KEYWORD_MAP:
|
| 34 |
+
if any(kw in sample for kw in keywords):
|
| 35 |
+
return doc_type
|
| 36 |
+
|
| 37 |
+
return "unknown"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _ocr_pdf(file_bytes: bytes) -> str:
|
| 41 |
+
try:
|
| 42 |
+
from pdf2image import convert_from_bytes
|
| 43 |
+
import pytesseract
|
| 44 |
+
except ImportError:
|
| 45 |
+
logger.warning("pdf2image or pytesseract not installed; OCR unavailable")
|
| 46 |
+
return ""
|
| 47 |
+
|
| 48 |
+
try:
|
| 49 |
+
images = convert_from_bytes(file_bytes, first_page=1, last_page=2)
|
| 50 |
+
except Exception:
|
| 51 |
+
logger.exception("pdf2image conversion failed")
|
| 52 |
+
return ""
|
| 53 |
+
|
| 54 |
+
parts: list[str] = []
|
| 55 |
+
for img in images:
|
| 56 |
+
try:
|
| 57 |
+
parts.append(pytesseract.image_to_string(img))
|
| 58 |
+
except Exception:
|
| 59 |
+
logger.exception("pytesseract OCR failed on page")
|
| 60 |
+
return "\n".join(parts)
|
app/config.py
CHANGED
|
@@ -9,18 +9,25 @@ class Settings(BaseSettings):
|
|
| 9 |
azure_client_id: str
|
| 10 |
azure_client_secret: SecretStr
|
| 11 |
|
|
|
|
| 12 |
mail_folder_name: str
|
| 13 |
notification_url: HttpUrl
|
| 14 |
subscription_expiry_minutes: int = 4230
|
| 15 |
|
| 16 |
host: str = "0.0.0.0"
|
| 17 |
-
port: int =
|
| 18 |
-
tls_cert_file: Path
|
| 19 |
-
tls_key_file: Path
|
| 20 |
|
| 21 |
database_path: Path = Path("/data/rcmemail.db")
|
| 22 |
email_storage_path: Path = Path("/emails")
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
| 25 |
|
| 26 |
|
|
|
|
| 9 |
azure_client_id: str
|
| 10 |
azure_client_secret: SecretStr
|
| 11 |
|
| 12 |
+
mailbox_user: str
|
| 13 |
mail_folder_name: str
|
| 14 |
notification_url: HttpUrl
|
| 15 |
subscription_expiry_minutes: int = 4230
|
| 16 |
|
| 17 |
host: str = "0.0.0.0"
|
| 18 |
+
port: int = 8000
|
|
|
|
|
|
|
| 19 |
|
| 20 |
database_path: Path = Path("/data/rcmemail.db")
|
| 21 |
email_storage_path: Path = Path("/emails")
|
| 22 |
|
| 23 |
+
dropbox_app_key: str
|
| 24 |
+
dropbox_app_secret: SecretStr
|
| 25 |
+
dropbox_refresh_token: SecretStr
|
| 26 |
+
dropbox_po_path: str = "/RCM Supply/Inside Sales/POs"
|
| 27 |
+
dropbox_invoice_path: str = "/RCM Supply/Inside Sales/Invoices"
|
| 28 |
+
dropbox_mtr_path: str = "/RCM Supply/MTRs"
|
| 29 |
+
processor_poll_interval: int = 10
|
| 30 |
+
|
| 31 |
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
| 32 |
|
| 33 |
|
app/database.py
CHANGED
|
@@ -32,6 +32,17 @@ CREATE TABLE IF NOT EXISTS notifications (
|
|
| 32 |
error_message TEXT,
|
| 33 |
storage_path TEXT
|
| 34 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
"""
|
| 36 |
|
| 37 |
|
|
@@ -140,6 +151,45 @@ async def update_notification_status(
|
|
| 140 |
await conn.commit()
|
| 141 |
|
| 142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
if __name__ == "__main__":
|
| 144 |
import asyncio
|
| 145 |
|
|
|
|
| 32 |
error_message TEXT,
|
| 33 |
storage_path TEXT
|
| 34 |
);
|
| 35 |
+
|
| 36 |
+
CREATE TABLE IF NOT EXISTS routing_results (
|
| 37 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 38 |
+
notification_id INTEGER NOT NULL,
|
| 39 |
+
processed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
| 40 |
+
attachment_filename TEXT,
|
| 41 |
+
document_type TEXT,
|
| 42 |
+
action TEXT,
|
| 43 |
+
destination_path TEXT,
|
| 44 |
+
error_message TEXT
|
| 45 |
+
);
|
| 46 |
"""
|
| 47 |
|
| 48 |
|
|
|
|
| 151 |
await conn.commit()
|
| 152 |
|
| 153 |
|
| 154 |
+
async def get_fetched_notifications(conn: aiosqlite.Connection) -> list[dict]:
|
| 155 |
+
cursor = await conn.execute(
|
| 156 |
+
"SELECT * FROM notifications WHERE processing_status = 'fetched' ORDER BY received_at"
|
| 157 |
+
)
|
| 158 |
+
rows = await cursor.fetchall()
|
| 159 |
+
return [dict(r) for r in rows]
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
async def set_notification_routing_status(
|
| 163 |
+
conn: aiosqlite.Connection, notif_id: int, status: str
|
| 164 |
+
) -> None:
|
| 165 |
+
await conn.execute(
|
| 166 |
+
"UPDATE notifications SET processing_status = ? WHERE id = ?",
|
| 167 |
+
(status, notif_id),
|
| 168 |
+
)
|
| 169 |
+
await conn.commit()
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
async def log_routing_result(
|
| 173 |
+
conn: aiosqlite.Connection,
|
| 174 |
+
notification_id: int,
|
| 175 |
+
filename: str | None,
|
| 176 |
+
doc_type: str | None,
|
| 177 |
+
action: str,
|
| 178 |
+
destination_path: str | None = None,
|
| 179 |
+
error_message: str | None = None,
|
| 180 |
+
) -> None:
|
| 181 |
+
await conn.execute(
|
| 182 |
+
"""
|
| 183 |
+
INSERT INTO routing_results
|
| 184 |
+
(notification_id, attachment_filename, document_type, action,
|
| 185 |
+
destination_path, error_message)
|
| 186 |
+
VALUES (?, ?, ?, ?, ?, ?)
|
| 187 |
+
""",
|
| 188 |
+
(notification_id, filename, doc_type, action, destination_path, error_message),
|
| 189 |
+
)
|
| 190 |
+
await conn.commit()
|
| 191 |
+
|
| 192 |
+
|
| 193 |
if __name__ == "__main__":
|
| 194 |
import asyncio
|
| 195 |
|
app/dropbox_client.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
import dropbox
|
| 6 |
+
from dropbox.files import WriteMode
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class DropboxClient:
|
| 12 |
+
def __init__(self, app_key: str, app_secret: str, refresh_token: str) -> None:
|
| 13 |
+
self._dbx = dropbox.Dropbox(
|
| 14 |
+
app_key=app_key,
|
| 15 |
+
app_secret=app_secret,
|
| 16 |
+
oauth2_refresh_token=refresh_token,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
def upload(self, file_bytes: bytes, dest_path: str) -> str:
|
| 20 |
+
"""Upload bytes to dest_path. Returns the path Dropbox assigned (may differ on collision)."""
|
| 21 |
+
result = self._dbx.files_upload(
|
| 22 |
+
file_bytes,
|
| 23 |
+
dest_path,
|
| 24 |
+
mode=WriteMode.add,
|
| 25 |
+
autorename=True,
|
| 26 |
+
)
|
| 27 |
+
return result.path_display
|
app/email_processor.py
CHANGED
|
@@ -4,6 +4,7 @@ from pathlib import Path
|
|
| 4 |
|
| 5 |
import aiosqlite
|
| 6 |
|
|
|
|
| 7 |
from app.database import update_notification_status
|
| 8 |
from app.graph_client import GraphClient
|
| 9 |
from app.storage import write_email
|
|
@@ -22,7 +23,7 @@ async def fetch_and_store(
|
|
| 22 |
) -> Path:
|
| 23 |
try:
|
| 24 |
email_data = await client.get(
|
| 25 |
-
f"/
|
| 26 |
params={"$expand": "attachments", "$select": (
|
| 27 |
"id,subject,from,toRecipients,ccRecipients,receivedDateTime,"
|
| 28 |
"body,uniqueBody,hasAttachments,attachments,webLink"
|
|
@@ -60,4 +61,4 @@ async def fetch_and_store(
|
|
| 60 |
async def _fetch_large_attachment(
|
| 61 |
client: GraphClient, message_id: str, attachment_id: str
|
| 62 |
) -> bytes:
|
| 63 |
-
return await client.get_raw(f"/
|
|
|
|
| 4 |
|
| 5 |
import aiosqlite
|
| 6 |
|
| 7 |
+
from app.config import settings
|
| 8 |
from app.database import update_notification_status
|
| 9 |
from app.graph_client import GraphClient
|
| 10 |
from app.storage import write_email
|
|
|
|
| 23 |
) -> Path:
|
| 24 |
try:
|
| 25 |
email_data = await client.get(
|
| 26 |
+
f"/users/{settings.mailbox_user}/messages/{message_id}",
|
| 27 |
params={"$expand": "attachments", "$select": (
|
| 28 |
"id,subject,from,toRecipients,ccRecipients,receivedDateTime,"
|
| 29 |
"body,uniqueBody,hasAttachments,attachments,webLink"
|
|
|
|
| 61 |
async def _fetch_large_attachment(
|
| 62 |
client: GraphClient, message_id: str, attachment_id: str
|
| 63 |
) -> bytes:
|
| 64 |
+
return await client.get_raw(f"/users/{settings.mailbox_user}/messages/{message_id}/attachments/{attachment_id}/$value")
|
app/folder_resolver.py
CHANGED
|
@@ -3,8 +3,8 @@ from app.graph_client import GraphClient
|
|
| 3 |
_BASE = "https://graph.microsoft.com/v1.0"
|
| 4 |
|
| 5 |
|
| 6 |
-
async def resolve_folder_id(client: GraphClient, display_name: str) -> str:
|
| 7 |
-
path = "/
|
| 8 |
params: dict = {"$top": 100}
|
| 9 |
|
| 10 |
while path:
|
|
|
|
| 3 |
_BASE = "https://graph.microsoft.com/v1.0"
|
| 4 |
|
| 5 |
|
| 6 |
+
async def resolve_folder_id(client: GraphClient, display_name: str, mailbox_user: str) -> str:
|
| 7 |
+
path = f"/users/{mailbox_user}/mailFolders"
|
| 8 |
params: dict = {"$top": 100}
|
| 9 |
|
| 10 |
while path:
|
app/main.py
CHANGED
|
@@ -52,7 +52,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator:
|
|
| 52 |
app.state.settings = settings
|
| 53 |
app.state.subscription_id = None
|
| 54 |
|
| 55 |
-
folder_id = await resolve_folder_id(client, settings.mail_folder_name)
|
| 56 |
app.state.folder_id = folder_id
|
| 57 |
logger.info("Monitoring folder '%s' (id=%s)", settings.mail_folder_name, folder_id)
|
| 58 |
|
|
|
|
| 52 |
app.state.settings = settings
|
| 53 |
app.state.subscription_id = None
|
| 54 |
|
| 55 |
+
folder_id = await resolve_folder_id(client, settings.mail_folder_name, settings.mailbox_user)
|
| 56 |
app.state.folder_id = folder_id
|
| 57 |
logger.info("Monitoring folder '%s' (id=%s)", settings.mail_folder_name, folder_id)
|
| 58 |
|
app/processor.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Standalone attachment routing process.
|
| 2 |
+
|
| 3 |
+
Run with: python -m app.processor
|
| 4 |
+
|
| 5 |
+
Polls the SQLite DB for notifications with processing_status='fetched', classifies
|
| 6 |
+
each attachment via OCR, and uploads routable documents to Dropbox.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
import json
|
| 12 |
+
import logging
|
| 13 |
+
import signal
|
| 14 |
+
import sys
|
| 15 |
+
from datetime import datetime
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
from app.classifier import classify
|
| 19 |
+
from app.config import settings
|
| 20 |
+
from app.database import (
|
| 21 |
+
get_db,
|
| 22 |
+
get_fetched_notifications,
|
| 23 |
+
log_routing_result,
|
| 24 |
+
set_notification_routing_status,
|
| 25 |
+
)
|
| 26 |
+
from app.dropbox_client import DropboxClient
|
| 27 |
+
|
| 28 |
+
logging.basicConfig(
|
| 29 |
+
level=logging.INFO,
|
| 30 |
+
format="%(asctime)s [processor] %(levelname)s %(message)s",
|
| 31 |
+
)
|
| 32 |
+
logger = logging.getLogger(__name__)
|
| 33 |
+
|
| 34 |
+
_ROUTABLE = {"po", "invoice", "mtr"}
|
| 35 |
+
|
| 36 |
+
_DEST_MAP = {
|
| 37 |
+
"po": settings.dropbox_po_path,
|
| 38 |
+
"invoice": settings.dropbox_invoice_path,
|
| 39 |
+
"mtr": settings.dropbox_mtr_path,
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
_shutdown = asyncio.Event()
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _handle_signal(*_: object) -> None:
|
| 46 |
+
logger.info("Shutdown signal received")
|
| 47 |
+
_shutdown.set()
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
async def _process_notification(
|
| 51 |
+
dbx: DropboxClient,
|
| 52 |
+
notif: dict,
|
| 53 |
+
) -> None:
|
| 54 |
+
notif_id: int = notif["id"]
|
| 55 |
+
storage_path = notif.get("storage_path")
|
| 56 |
+
if not storage_path:
|
| 57 |
+
logger.warning("Notification %d has no storage_path; skipping", notif_id)
|
| 58 |
+
async with get_db(settings.database_path) as conn:
|
| 59 |
+
await set_notification_routing_status(conn, notif_id, "routing_error")
|
| 60 |
+
await log_routing_result(
|
| 61 |
+
conn, notif_id, None, None, "error",
|
| 62 |
+
error_message="no storage_path recorded",
|
| 63 |
+
)
|
| 64 |
+
return
|
| 65 |
+
|
| 66 |
+
meta_path = Path(storage_path) / "metadata.json"
|
| 67 |
+
try:
|
| 68 |
+
meta = json.loads(meta_path.read_text())
|
| 69 |
+
except Exception as exc:
|
| 70 |
+
logger.error("Cannot read metadata.json for notification %d: %s", notif_id, exc)
|
| 71 |
+
async with get_db(settings.database_path) as conn:
|
| 72 |
+
await set_notification_routing_status(conn, notif_id, "routing_error")
|
| 73 |
+
await log_routing_result(
|
| 74 |
+
conn, notif_id, None, None, "error",
|
| 75 |
+
error_message=f"metadata read failed: {exc}",
|
| 76 |
+
)
|
| 77 |
+
return
|
| 78 |
+
|
| 79 |
+
subject: str = meta.get("subject", "")
|
| 80 |
+
received_dt: str = meta.get("received_datetime", "")
|
| 81 |
+
date_prefix = _date_prefix(received_dt)
|
| 82 |
+
attachments: list[dict] = meta.get("attachments", [])
|
| 83 |
+
|
| 84 |
+
uploaded_count = 0
|
| 85 |
+
final_status = "routing_skipped"
|
| 86 |
+
|
| 87 |
+
for att in attachments:
|
| 88 |
+
filename: str = att.get("name", "attachment")
|
| 89 |
+
att_path = Path(storage_path) / "attachments" / filename
|
| 90 |
+
|
| 91 |
+
try:
|
| 92 |
+
file_bytes = att_path.read_bytes()
|
| 93 |
+
except Exception as exc:
|
| 94 |
+
logger.error("Cannot read attachment %s: %s", att_path, exc)
|
| 95 |
+
async with get_db(settings.database_path) as conn:
|
| 96 |
+
await log_routing_result(
|
| 97 |
+
conn, notif_id, filename, None, "error",
|
| 98 |
+
error_message=f"file read failed: {exc}",
|
| 99 |
+
)
|
| 100 |
+
final_status = "routing_error"
|
| 101 |
+
continue
|
| 102 |
+
|
| 103 |
+
doc_type = classify(subject, filename, file_bytes)
|
| 104 |
+
logger.info("Notification %d / %s → %s", notif_id, filename, doc_type)
|
| 105 |
+
|
| 106 |
+
if doc_type in _ROUTABLE:
|
| 107 |
+
dest_folder = _DEST_MAP[doc_type]
|
| 108 |
+
month_folder = received_dt[:7] if received_dt else "unknown"
|
| 109 |
+
dest_path = f"{dest_folder}/{month_folder}/{date_prefix}_{filename}"
|
| 110 |
+
try:
|
| 111 |
+
actual_path = await asyncio.to_thread(dbx.upload, file_bytes, dest_path)
|
| 112 |
+
logger.info("Uploaded %s → %s", filename, actual_path)
|
| 113 |
+
async with get_db(settings.database_path) as conn:
|
| 114 |
+
await log_routing_result(
|
| 115 |
+
conn, notif_id, filename, doc_type, "uploaded",
|
| 116 |
+
destination_path=actual_path,
|
| 117 |
+
)
|
| 118 |
+
uploaded_count += 1
|
| 119 |
+
final_status = "routed"
|
| 120 |
+
except Exception as exc:
|
| 121 |
+
logger.error("Dropbox upload failed for %s: %s", filename, exc)
|
| 122 |
+
async with get_db(settings.database_path) as conn:
|
| 123 |
+
await log_routing_result(
|
| 124 |
+
conn, notif_id, filename, doc_type, "error",
|
| 125 |
+
error_message=f"upload failed: {exc}",
|
| 126 |
+
)
|
| 127 |
+
final_status = "routing_error"
|
| 128 |
+
else:
|
| 129 |
+
async with get_db(settings.database_path) as conn:
|
| 130 |
+
await log_routing_result(conn, notif_id, filename, doc_type, "skipped")
|
| 131 |
+
|
| 132 |
+
async with get_db(settings.database_path) as conn:
|
| 133 |
+
await set_notification_routing_status(conn, notif_id, final_status)
|
| 134 |
+
logger.info("Notification %d → %s", notif_id, final_status)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _date_prefix(received_dt: str) -> str:
|
| 138 |
+
try:
|
| 139 |
+
return datetime.fromisoformat(received_dt).strftime("%Y%m%d")
|
| 140 |
+
except Exception:
|
| 141 |
+
return "00000000"
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
async def run() -> None:
|
| 145 |
+
dbx = DropboxClient(
|
| 146 |
+
app_key=settings.dropbox_app_key,
|
| 147 |
+
app_secret=settings.dropbox_app_secret.get_secret_value(),
|
| 148 |
+
refresh_token=settings.dropbox_refresh_token.get_secret_value(),
|
| 149 |
+
)
|
| 150 |
+
logger.info(
|
| 151 |
+
"Processor started; polling every %ds", settings.processor_poll_interval
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
while not _shutdown.is_set():
|
| 155 |
+
try:
|
| 156 |
+
async with get_db(settings.database_path) as conn:
|
| 157 |
+
pending = await get_fetched_notifications(conn)
|
| 158 |
+
|
| 159 |
+
if pending:
|
| 160 |
+
logger.info("Found %d notification(s) to process", len(pending))
|
| 161 |
+
for notif in pending:
|
| 162 |
+
async with get_db(settings.database_path) as conn:
|
| 163 |
+
await set_notification_routing_status(
|
| 164 |
+
conn, notif["id"], "routing_queued"
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
for notif in pending:
|
| 168 |
+
await _process_notification(dbx, notif)
|
| 169 |
+
except Exception:
|
| 170 |
+
logger.exception("Unhandled error in processor loop")
|
| 171 |
+
|
| 172 |
+
try:
|
| 173 |
+
await asyncio.wait_for(
|
| 174 |
+
_shutdown.wait(), timeout=settings.processor_poll_interval
|
| 175 |
+
)
|
| 176 |
+
except asyncio.TimeoutError:
|
| 177 |
+
pass
|
| 178 |
+
|
| 179 |
+
logger.info("Processor stopped")
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def main() -> None:
|
| 183 |
+
loop = asyncio.new_event_loop()
|
| 184 |
+
asyncio.set_event_loop(loop)
|
| 185 |
+
|
| 186 |
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
| 187 |
+
loop.add_signal_handler(sig, _handle_signal)
|
| 188 |
+
|
| 189 |
+
try:
|
| 190 |
+
loop.run_until_complete(run())
|
| 191 |
+
finally:
|
| 192 |
+
loop.close()
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
if __name__ == "__main__":
|
| 196 |
+
main()
|
docker-compose.yml
CHANGED
|
@@ -3,9 +3,8 @@ services:
|
|
| 3 |
build: .
|
| 4 |
env_file: .env
|
| 5 |
ports:
|
| 6 |
-
- "
|
| 7 |
volumes:
|
| 8 |
-
- ./certs:/certs:ro
|
| 9 |
- rcmemail-data:/data
|
| 10 |
- rcmemail-emails:/emails
|
| 11 |
restart: unless-stopped
|
|
|
|
| 3 |
build: .
|
| 4 |
env_file: .env
|
| 5 |
ports:
|
| 6 |
+
- "8000:8000"
|
| 7 |
volumes:
|
|
|
|
| 8 |
- rcmemail-data:/data
|
| 9 |
- rcmemail-emails:/emails
|
| 10 |
restart: unless-stopped
|
entrypoint.sh
CHANGED
|
@@ -3,8 +3,10 @@ set -e
|
|
| 3 |
|
| 4 |
python -m app.database --init
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
exec uvicorn app.main:app \
|
| 7 |
--host "${HOST:-0.0.0.0}" \
|
| 8 |
-
--port "${PORT:-
|
| 9 |
-
--ssl-certfile "${TLS_CERT_FILE}" \
|
| 10 |
-
--ssl-keyfile "${TLS_KEY_FILE}"
|
|
|
|
| 3 |
|
| 4 |
python -m app.database --init
|
| 5 |
|
| 6 |
+
python -m app.processor &
|
| 7 |
+
PROCESSOR_PID=$!
|
| 8 |
+
trap "kill $PROCESSOR_PID 2>/dev/null; wait $PROCESSOR_PID 2>/dev/null" EXIT TERM INT
|
| 9 |
+
|
| 10 |
exec uvicorn app.main:app \
|
| 11 |
--host "${HOST:-0.0.0.0}" \
|
| 12 |
+
--port "${PORT:-8000}"
|
|
|
|
|
|
requirements.txt
CHANGED
|
@@ -5,3 +5,7 @@ httpx==0.27.0
|
|
| 5 |
pydantic-settings==2.3.0
|
| 6 |
apscheduler==3.10.4
|
| 7 |
aiosqlite==0.20.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
pydantic-settings==2.3.0
|
| 6 |
apscheduler==3.10.4
|
| 7 |
aiosqlite==0.20.0
|
| 8 |
+
dropbox==12.0.2
|
| 9 |
+
pdf2image==1.17.0
|
| 10 |
+
pytesseract==0.3.13
|
| 11 |
+
Pillow==10.4.0
|
scripts/dropbox_auth.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""One-time Dropbox OAuth setup — generates the refresh token for .env.
|
| 2 |
+
|
| 3 |
+
Usage:
|
| 4 |
+
python scripts/dropbox_auth.py
|
| 5 |
+
|
| 6 |
+
What it does:
|
| 7 |
+
1. Prints an authorization URL — open it in a browser and click Allow.
|
| 8 |
+
2. Paste the auth code back at the prompt.
|
| 9 |
+
3. Prints the refresh token to copy into DROPBOX_REFRESH_TOKEN in your .env.
|
| 10 |
+
|
| 11 |
+
After this runs once the refresh token lives in .env and no further browser
|
| 12 |
+
interaction is ever needed. The app uses it silently at runtime.
|
| 13 |
+
|
| 14 |
+
Requirements: pip install dropbox
|
| 15 |
+
"""
|
| 16 |
+
import sys
|
| 17 |
+
|
| 18 |
+
try:
|
| 19 |
+
import dropbox
|
| 20 |
+
from dropbox import DropboxOAuth2FlowNoRedirect
|
| 21 |
+
except ImportError:
|
| 22 |
+
sys.exit("dropbox package not installed — run: pip install dropbox")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def main() -> None:
|
| 26 |
+
app_key = input("Enter DROPBOX_APP_KEY: ").strip()
|
| 27 |
+
app_secret = input("Enter DROPBOX_APP_SECRET: ").strip()
|
| 28 |
+
|
| 29 |
+
auth_flow = DropboxOAuth2FlowNoRedirect(
|
| 30 |
+
app_key,
|
| 31 |
+
app_secret,
|
| 32 |
+
token_access_type="offline",
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
authorize_url = auth_flow.start()
|
| 36 |
+
print("\n--- Step 1 ---")
|
| 37 |
+
print("Open this URL in a browser and click Allow:\n")
|
| 38 |
+
print(authorize_url)
|
| 39 |
+
print()
|
| 40 |
+
|
| 41 |
+
auth_code = input("--- Step 2 --- Paste the authorization code here: ").strip()
|
| 42 |
+
|
| 43 |
+
try:
|
| 44 |
+
result = auth_flow.finish(auth_code)
|
| 45 |
+
except Exception as exc:
|
| 46 |
+
sys.exit(f"Auth failed: {exc}")
|
| 47 |
+
|
| 48 |
+
print("\n--- Done ---")
|
| 49 |
+
print("Add these to your .env file:\n")
|
| 50 |
+
print(f"DROPBOX_APP_KEY={app_key}")
|
| 51 |
+
print(f"DROPBOX_APP_SECRET={app_secret}")
|
| 52 |
+
print(f"DROPBOX_REFRESH_TOKEN={result.refresh_token}")
|
| 53 |
+
print()
|
| 54 |
+
print("The refresh token does not expire. Re-run this script only if you revoke app access.")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
if __name__ == "__main__":
|
| 58 |
+
main()
|