Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class Settings:
spreadsheet_id: str
drive_folder_id: str
base_url: str
gcs_bucket: str = ""
model_names: list[str] = field(default_factory=list)
max_output_tokens: int = 1024
rate_limit_per_minute: int = 10
Expand All @@ -28,6 +29,7 @@ def from_env(cls) -> "Settings":
spreadsheet_id=os.getenv("SKIPTA_SPREADSHEET_ID", ""),
drive_folder_id=os.getenv("SKIPTA_DRIVE_FOLDER_ID", ""),
base_url=os.getenv("SKIPTA_BASE_URL", "http://localhost:8000"),
gcs_bucket=os.getenv("SKIPTA_GCS_BUCKET", ""),
model_names=[m.strip() for m in os.getenv("SKIPTA_MODEL_NAMES", DEFAULT_MODELS).split(",") if m.strip()],
max_output_tokens=int(os.getenv("MAX_OUTPUT_TOKENS", "1024")),
rate_limit_per_minute=int(os.getenv("RATE_LIMIT_PER_MINUTE", "10")),
Expand Down
45 changes: 0 additions & 45 deletions app/drive.py

This file was deleted.

20 changes: 20 additions & 0 deletions app/gcs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Signed-PDF archive in GCS, keyed by customer prefix. Consumer-account service accounts have zero Drive storage quota (uploads are rejected outright), so the flattened PDFs live in a public-read bucket instead; the Drive folder remains the human-side SoW archive."""


def blob_name(customer_name: str, filename: str) -> str:
return f"{customer_name.replace(' ', '_')}/{filename}"


def public_url(bucket: str, name: str) -> str:
return f"https://storage.googleapis.com/{bucket}/{name}"


def find_pdf(storage_client, bucket: str, name: str):
if storage_client.bucket(bucket).blob(name).exists():
return public_url(bucket, name)
return None


def upload_pdf(storage_client, bucket: str, name: str, pdf_bytes: bytes) -> str:
storage_client.bucket(bucket).blob(name).upload_from_string(pdf_bytes, content_type="application/pdf")
return public_url(bucket, name)
6 changes: 4 additions & 2 deletions app/google_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ def build_sheets(creds):
return build("sheets", "v4", credentials=creds, cache_discovery=False)


def build_drive(creds):
return build("drive", "v3", credentials=creds, cache_discovery=False)
def build_storage(creds, project_id: str):
from google.cloud import storage

return storage.Client(project=project_id, credentials=creds)


def make_model_factory(project_id: str, region: str):
Expand Down
22 changes: 11 additions & 11 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,11 @@
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address

from app import amendments, pricing
from app import drive as drive_mod
from app import amendments, gcs, pricing
from app.amendments import AmendmentRecord
from app.config import Settings
from app.extraction import ExtractionError, extract_amendment
from app.google_clients import build_drive, build_sheets, get_credentials, make_model_factory, read_values
from app.google_clients import build_sheets, build_storage, get_credentials, make_model_factory, read_values
from app.pdf import render_amendment_html, render_pdf

logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO").upper(), format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
Expand Down Expand Up @@ -50,10 +49,11 @@ def get_sheets():
return _clients["sheets"]


def get_drive():
if "drive" not in _clients:
_clients["drive"] = build_drive(get_credentials())
return _clients["drive"]
def get_storage():
if "storage" not in _clients:
settings = get_settings()
_clients["storage"] = build_storage(get_credentials(), settings.project_id)
return _clients["storage"]


def get_extract():
Expand Down Expand Up @@ -164,7 +164,7 @@ def sign_amendment(
body: SignRequest,
settings: Settings = Depends(get_settings),
sheets=Depends(get_sheets),
drive=Depends(get_drive),
storage=Depends(get_storage),
):
found = amendments.find_amendment(sheets, settings.spreadsheet_id, amendment_id)
if found is None:
Expand All @@ -184,13 +184,13 @@ def sign_amendment(

created_ts = amendment_id.rsplit("_", 1)[-1]
filename = f"{record.customer_name.replace(' ', '_')}_Amendment_{created_ts}.pdf" # deterministic per amendment — a retry reuses the same name
name = gcs.blob_name(record.customer_name, filename)
try:
folder_id = drive_mod.ensure_customer_folder(drive, settings.drive_folder_id, record.customer_name)
pdf_url = drive_mod.find_file_in_folder(drive, folder_id, filename) or drive_mod.upload_pdf(drive, folder_id, filename, pdf_bytes)
pdf_url = gcs.find_pdf(storage, settings.gcs_bucket, name) or gcs.upload_pdf(storage, settings.gcs_bucket, name, pdf_bytes)
amendments.mark_signed(sheets, settings.spreadsheet_id, row, pdf_url, record.signed_at)
except HTTPException:
raise
except Exception as exc: # Drive/Sheets upstream failure — surface honestly
except Exception as exc: # GCS/Sheets upstream failure — surface honestly
logger.error("sign flow failed for %s: %s", amendment_id, exc)
raise HTTPException(status_code=502, detail=f"Google API failure: {exc}") from exc
logger.info("amendment %s signed → %s", amendment_id, pdf_url)
Expand Down
14 changes: 7 additions & 7 deletions docs/plans/2026-07-01-skipta-field-amendments-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,9 @@ One FastAPI (Python 3.11+) service, single Deployment in the `skipta` namespace:
├── extraction.py — Gemini structured output (Vertex AI, Workload Identity)
├── pricing.py — Sheets Panels/Breakers lookup + totals
├── amendments.py — append Amendments row (status=draft)
├── drive.py — locate customer SoW subfolder
└── returns {amendment_id, signing_url}
[Any phone] --GET /amendments/{id}--> server-rendered signing page (Jinja2 + signature_pad)
--POST /api/v1/amendments/{id}/sign--> pdf.py (WeasyPrint) → Drive upload → row status=signed
--POST /api/v1/amendments/{id}/sign--> pdf.py (WeasyPrint) → gcs.py upload → row status=signed
```

### Modules
Expand All @@ -44,7 +43,7 @@ One FastAPI (Python 3.11+) service, single Deployment in the `skipta` namespace:
| `app/extraction.py` | Gemini structured output → `AmendmentPayload` | Vertex AI |
| `app/pricing.py` | match payload items against `Panels`/`Breakers` tabs, compute totals | Sheets |
| `app/amendments.py` | amendment state rows in the `Amendments` tab (draft→signed) | Sheets |
| `app/drive.py` | customer SoW folder search, PDF upload | Drive |
| `app/gcs.py` | signed-PDF archive: customer-prefixed blob names, idempotent find-or-upload, public URLs | GCS |
| `app/pdf.py` | render amendment HTML (with signatures) → flattened PDF | WeasyPrint |

Google clients are constructed in one place and injected, so tests swap in fakes without patching.
Expand All @@ -56,7 +55,7 @@ Google clients are constructed in one place and injected, so tests swap in fakes
| `GET /` | Mobile intake form: customer name + voice-text area (phone dictation), posts to the API |
| `POST /api/v1/amendments` | Extract → price → persist draft row → return `{amendment_id, signing_url}` |
| `GET /amendments/{id}` | Server-rendered signing page: itemized parts, totals, two signature_pad canvases |
| `POST /api/v1/amendments/{id}/sign` | Accept both Base64 signatures, render PDF, upload to Drive, flip row to `signed` |
| `POST /api/v1/amendments/{id}/sign` | Accept both Base64 signatures, render PDF, upload to the GCS archive, flip row to `signed` |
| `GET /healthz` | Probe target (ting convention) |

`amendment_id` is `amend_<slugified-customer>_<YYYYMMDDHHMMSS>` per the MVP naming convention. A small hand-rolled mobile-first stylesheet and `signature_pad.umd.js` are vendored under `app/static/` — no CDN dependency on a job site with weak signal, and no Node/Tailwind toolchain in the build (a deliberate simplification of the MVP's TailwindCSS suggestion).
Expand All @@ -75,21 +74,22 @@ One spreadsheet, three tabs. `Panels` and `Breakers` follow the MVP schema exact
- `Breakers`: `breaker_id`, `amps`, `poles`, `description`, `unit_cost`
- `Amendments` (state machine, one row per amendment): `amendment_id`, `created_at`, `customer_name`, `voice_text`, `extracted_json`, `line_items_json`, `total`, `status` (`draft` | `signed`), `pdf_drive_url`, `signed_at`

The signing page re-reads its row on every GET, so replicas and pod restarts are invisible. Drive layout: a `Skipta/` folder shared with the service account, one subfolder per customer holding their SoW doc; signed PDFs upload into that subfolder as `[Customer_Name]_Amendment_[Timestamp].pdf`.
The signing page re-reads its row on every GET, so replicas and pod restarts are invisible. Signed PDFs live in the public-read GCS bucket `skipta-amendments-teralivekubernetes` under a per-customer prefix (`Smith/Smith_Amendment_<created-ts>.pdf` — the deterministic name doubles as the retry-idempotency key), and the `pdf_drive_url` column carries the public object URL. The Drive `Skipta/` folder (shared with the service account) is the human-side archive: one subfolder per customer holding their SoW doc.

## Google auth — no tokens, no keys

One service account, `skipta-gsa@teralivekubernetes.iam.gserviceaccount.com`:

- **Vertex AI:** `roles/aiplatform.user` on project `teralivekubernetes` (mirror of `um-vertex-ai-gsa`).
- **Drive/Sheets:** no IAM role — the human shares the `Skipta/` folder and the spreadsheet with the GSA email as Editor. `drive.googleapis.com` and `sheets.googleapis.com` get enabled on the project.
- **GCS:** `roles/storage.objectUser` on the `skipta-amendments-teralivekubernetes` bucket (create + read the PDF archive); `allUsers` hold `objectViewer` so amendment links open like share links. Consumer-account service accounts have zero Drive storage quota — they cannot own Drive files or folders — which is why the PDF archive is a bucket rather than the Drive folder.
- **On GKE:** KSA `skipta-sa` in the `skipta` namespace, annotated `iam.gke.io/gcp-service-account=skipta-gsa@…`, with the `roles/iam.workloadIdentityUser` binding for `teralivekubernetes.svc.id.goog[skipta/skipta-sa]`. The workload pool is already enabled on `ttf-cluster`.
- **Locally:** `gcloud auth application-default login --impersonate-service-account=skipta-gsa@…` (requires `roles/iam.serviceAccountTokenCreator` on the GSA for the human) — dev runs as the same identity the pod uses.
- **In code:** `google.auth.default(scopes=[drive, spreadsheets, cloud-platform])`; the GKE Workload Identity metadata server honors requested scopes. Escape hatch if scope-narrowing misbehaves: self-impersonated credentials via the IAM Credentials API (`impersonated_credentials` targeting the same GSA with explicit Drive/Sheets scopes).

Config that reaches the pod is identifiers only (spreadsheet ID, Drive folder ID, project, region, model list) — a ConfigMap, no Secret.
Config that reaches the pod is identifiers only (spreadsheet ID, Drive folder ID, GCS bucket name, project, region, model list) — a ConfigMap, no Secret.

**Documented caveat:** PDFs uploaded by the GSA are owned by it and count against the service account's own ~15 GB Drive quota. Acceptable for a demo; the promote-to-real path is a Workspace Shared Drive.
**Documented caveat:** the PDF bucket is public-read for demo-tier link sharing, so amendment PDFs are world-readable to anyone holding the URL — fine for sample customers. The promote-to-real path is a private bucket with signed URLs (or a Workspace Shared Drive, which restores Drive-native storage).

## Kubernetes deployment

Expand Down
1 change: 1 addition & 0 deletions k8s/base/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ data:
gcp_region: "us-east1"
spreadsheet_id: "11cKYWrc4TdfdfD5ko2BkAzttCy7-q2U79LmNeQ2Rn1w"
drive_folder_id: "1kUxdx8qgOzG7-s_1zRI3FlWWG70XQGHd"
gcs_bucket: "skipta-amendments-teralivekubernetes"
base_url: "https://skipta.cmdbee.org"
2 changes: 2 additions & 0 deletions k8s/base/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ spec:
valueFrom: {configMapKeyRef: {name: skipta-config, key: spreadsheet_id}}
- name: SKIPTA_DRIVE_FOLDER_ID
valueFrom: {configMapKeyRef: {name: skipta-config, key: drive_folder_id}}
- name: SKIPTA_GCS_BUCKET
valueFrom: {configMapKeyRef: {name: skipta-config, key: gcs_bucket}}
- name: SKIPTA_BASE_URL
valueFrom: {configMapKeyRef: {name: skipta-config, key: base_url}}
readinessProbe:
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ uvicorn[standard]>=0.30,<1.0
# <1.160: vertexai.generative_models is past its announced removal date; 1.159 verified to still ship it
google-cloud-aiplatform>=1.60,<1.160
google-api-python-client>=2.100,<3.0
google-cloud-storage>=2.14,<4.0
google-auth>=2.30,<3.0
jinja2>=3.1,<4.0
weasyprint>=62,<70
Expand Down
10 changes: 5 additions & 5 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
from fastapi.testclient import TestClient

from app.extraction import AmendmentPayload
from app.main import app, get_drive, get_extract, get_settings, get_sheets
from app.main import app, get_extract, get_settings, get_sheets, get_storage
from app.config import Settings
from tests.test_amendments import FakeSheets
from tests.test_drive import FakeDrive
from tests.test_gcs import FakeStorageClient

PANEL_ROWS = [["P-200A-01", "200", "200A Main Lug Panel 30-Space", "245.00"]]
BREAKER_ROWS = [["B-20A-1P", "20", "1", "20A Single-Pole Type BR", "7.50"]]
Expand All @@ -30,7 +30,7 @@ def _routed_get(self, spreadsheetId, range):

@pytest.fixture
def fakes():
return {"sheets": RoutedFakeSheets([]), "drive": FakeDrive([{"id": "folder-smith", "name": "Smith"}])}
return {"sheets": RoutedFakeSheets([]), "storage": FakeStorageClient()}


@pytest.fixture
Expand All @@ -40,10 +40,10 @@ def client(fakes):
)
app.dependency_overrides[get_settings] = lambda: Settings(
project_id="p", region="r", spreadsheet_id="sid", drive_folder_id="root", base_url="http://testserver",
model_names=["fake"], max_output_tokens=64, rate_limit_per_minute=1000,
gcs_bucket="test-bucket", model_names=["fake"], max_output_tokens=64, rate_limit_per_minute=1000,
)
app.dependency_overrides[get_sheets] = lambda: fakes["sheets"]
app.dependency_overrides[get_drive] = lambda: fakes["drive"]
app.dependency_overrides[get_storage] = lambda: fakes["storage"]
app.dependency_overrides[get_extract] = lambda: (lambda voice_text, settings: payload)
# The limiter's limit-lambda calls get_settings() directly, outside Depends resolution,
# so dependency_overrides can't reach it — the suite would share one real 10/min budget.
Expand Down
68 changes: 0 additions & 68 deletions tests/test_drive.py

This file was deleted.

Loading
Loading