diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..62d4920 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.venv +.git +tests +.pytest_cache +.ruff_cache +__pycache__ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6827d2f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,17 @@ +name: ci +on: + push: + branches: [main] + pull_request: +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: {python-version: '3.11'} + - run: sudo apt-get update && sudo apt-get install -y libpango-1.0-0 libpangoft2-1.0-0 libgdk-pixbuf-2.0-0 + - run: pip install -r requirements-dev.txt + - run: ruff check . + - run: python -m pytest -v diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml new file mode 100644 index 0000000..167f34b --- /dev/null +++ b/.github/workflows/image.yml @@ -0,0 +1,33 @@ +name: image +on: + push: + branches: [main] +permissions: + contents: read + packages: write +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/metadata-action@v5 + id: meta + with: + images: ghcr.io/siliconsaga/skipta + tags: | + type=sha,format=long + type=raw,value=latest,enable={{is_default_branch}} + - uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6715773 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.11-slim + +# WeasyPrint runtime libs + a real font for PDF output +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpango-1.0-0 libpangoft2-1.0-0 libgdk-pixbuf-2.0-0 fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ app/ +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 4907b44..1061254 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Skipta -AI-backed field amendment & signing service — a GDD showcase. A field tech dictates a change order; Gemini (Vertex AI structured output) extracts a strict parts payload; Google Sheets prices it; both parties sign on an HTML5 canvas page; the flattened PDF lands in the customer's Google Drive folder. +**Skipta** is Old Norse for exchanging and shifting — making a trade, or moving between states. The idiom *skipta máli* means "to make a difference; to alter the meaning or matter." Both are the job description: a mid-job change order trades scope, and a signed amendment alters the agreement. + +Skipta is an AI-backed field amendment & signing service — a GDD showcase. A field tech dictates a change order; Gemini (Vertex AI structured output) extracts a strict parts payload; Google Sheets prices it; both parties sign on an HTML5 canvas page; the flattened PDF lands in the customer's Google Drive folder. The amendment itself shifts states the same way: `draft` → `signed`, one row in the sheet. Design: [docs/plans/2026-07-01-skipta-field-amendments-design.md](docs/plans/2026-07-01-skipta-field-amendments-design.md) · Implementation plan: [docs/plans/2026-07-01-skipta-field-amendments-plan.md](docs/plans/2026-07-01-skipta-field-amendments-plan.md) diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/amendments.py b/app/amendments.py new file mode 100644 index 0000000..ed48d5a --- /dev/null +++ b/app/amendments.py @@ -0,0 +1,59 @@ +"""The Amendments tab is the state machine: one row per amendment, draft → signed.""" +import re +from dataclasses import astuple, dataclass + +from app.google_clients import read_values + +TAB = "Amendments" +DATA_RANGE = f"{TAB}!A2:J" + + +@dataclass +class AmendmentRecord: + amendment_id: str + created_at: str + customer_name: str + voice_text: str + extracted_json: str + line_items_json: str + total: float + status: str # "draft" | "signed" + pdf_drive_url: str + signed_at: str + + def to_row(self) -> list: + row = list(astuple(self)) + row[6] = f"{self.total:.2f}" + return row + + @classmethod + def from_row(cls, row: list) -> "AmendmentRecord": + padded = list(row) + [""] * (10 - len(row)) + padded[6] = float(padded[6] or 0) + return cls(*padded) + + +def make_amendment_id(customer_name: str, now) -> str: + cleaned = re.sub(r"['’]", "", customer_name.lower()) # straight + curly apostrophes: O'Brien → obrien, not o-brien + slug = re.sub(r"[^a-z0-9]+", "-", cleaned).strip("-") + return f"amend_{slug}_{now.strftime('%Y%m%d%H%M%S')}" + + +def append_amendment(sheets, spreadsheet_id: str, record: AmendmentRecord) -> None: + sheets.spreadsheets().values().append( + spreadsheetId=spreadsheet_id, range=DATA_RANGE, valueInputOption="RAW", body={"values": [record.to_row()]} + ).execute() + + +def find_amendment(sheets, spreadsheet_id: str, amendment_id: str): + for index, row in enumerate(read_values(sheets, spreadsheet_id, DATA_RANGE)): + if row and row[0] == amendment_id: + return index + 2, AmendmentRecord.from_row(row) # +2: 1-based rows below the header + return None + + +def mark_signed(sheets, spreadsheet_id: str, row: int, pdf_url: str, signed_at: str) -> None: + sheets.spreadsheets().values().update( + spreadsheetId=spreadsheet_id, range=f"{TAB}!H{row}:J{row}", valueInputOption="RAW", + body={"values": [["signed", pdf_url, signed_at]]}, + ).execute() diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..9def09e --- /dev/null +++ b/app/config.py @@ -0,0 +1,34 @@ +"""Environment-driven settings. A .env at the component root is honored for local dev.""" +import os +from dataclasses import dataclass, field + +from dotenv import load_dotenv + +load_dotenv() + +DEFAULT_MODELS = "gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash-001" + + +@dataclass(frozen=True) +class Settings: + project_id: str + region: str + spreadsheet_id: str + drive_folder_id: str + base_url: str + model_names: list[str] = field(default_factory=list) + max_output_tokens: int = 1024 + rate_limit_per_minute: int = 10 + + @classmethod + def from_env(cls) -> "Settings": + return cls( + project_id=os.getenv("GCP_PROJECT_ID", ""), + region=os.getenv("GCP_REGION", "us-east1"), + 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"), + 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")), + ) diff --git a/app/drive.py b/app/drive.py new file mode 100644 index 0000000..88700fd --- /dev/null +++ b/app/drive.py @@ -0,0 +1,45 @@ +"""Drive integration: locate (or create) the customer's subfolder under the shared Skipta folder, upload signed PDFs idempotently.""" +import io + +from googleapiclient.http import MediaIoBaseUpload + +FOLDER_MIME = "application/vnd.google-apps.folder" + + +def _escape(value: str) -> str: + return value.replace("\\", "\\\\").replace("'", "\\'") + + +def find_customer_folder(drive, root_folder_id: str, customer_name: str): + query = ( + f"'{_escape(root_folder_id)}' in parents and mimeType = '{FOLDER_MIME}' " + f"and name = '{_escape(customer_name)}' and trashed = false" + ) + result = drive.files().list(q=query, fields="files(id, name)", pageSize=5).execute() + files = result.get("files", []) + return files[0]["id"] if files else None + + +def ensure_customer_folder(drive, root_folder_id: str, customer_name: str) -> str: + existing = find_customer_folder(drive, root_folder_id, customer_name) + if existing: + return existing + created = drive.files().create( + body={"name": customer_name, "mimeType": FOLDER_MIME, "parents": [root_folder_id]}, fields="id" + ).execute() + return created["id"] + + +def find_file_in_folder(drive, folder_id: str, filename: str): + query = f"'{_escape(folder_id)}' in parents and name = '{_escape(filename)}' and trashed = false" + result = drive.files().list(q=query, fields="files(id, webViewLink)", pageSize=1).execute() + files = result.get("files", []) + return files[0].get("webViewLink") if files else None + + +def upload_pdf(drive, folder_id: str, filename: str, pdf_bytes: bytes) -> str: + media = MediaIoBaseUpload(io.BytesIO(pdf_bytes), mimetype="application/pdf") + created = drive.files().create( + body={"name": filename, "parents": [folder_id]}, media_body=media, fields="webViewLink" + ).execute() + return created["webViewLink"] diff --git a/app/extraction.py b/app/extraction.py new file mode 100644 index 0000000..dd05b47 --- /dev/null +++ b/app/extraction.py @@ -0,0 +1,80 @@ +"""Gemini structured-output extraction of the amendment payload. Anti-hallucination is downstream and deterministic (pricing match) — this layer only shapes the request.""" +import logging + +from pydantic import BaseModel, Field, ValidationError + +logger = logging.getLogger("skipta.extraction") + + +class BreakerRequirement(BaseModel): + amps: int = Field(..., description="Amperage of the breaker, e.g. 20, 30, 50") + poles: int = Field(..., description="Number of poles, usually 1 or 2") + quantity: int = Field(..., ge=1, description="Quantity requested") + + +class PanelRequirement(BaseModel): + max_amperage: int = Field(..., description="Maximum amperage capacity of the panel, e.g. 100, 200") + + +class AmendmentPayload(BaseModel): + customer_name: str = Field(..., min_length=1) + panel: PanelRequirement | None = None + breakers: list[BreakerRequirement] = Field(default_factory=list) + + +class ExtractionError(Exception): + """Every configured model failed to produce a schema-valid payload.""" + + +# Vertex structured-output schema (OpenAPI subset — hand-written; pydantic's json_schema +# emits $defs, which Gemini's response_schema does not accept). +AMENDMENT_SCHEMA = { + "type": "OBJECT", + "properties": { + "customer_name": {"type": "STRING", "description": "Surname or identifier of the customer"}, + "panel": { + "type": "OBJECT", + "nullable": True, + "properties": {"max_amperage": {"type": "INTEGER"}}, + "required": ["max_amperage"], + }, + "breakers": { + "type": "ARRAY", + "items": { + "type": "OBJECT", + "properties": { + "amps": {"type": "INTEGER"}, + "poles": {"type": "INTEGER"}, + "quantity": {"type": "INTEGER"}, + }, + "required": ["amps", "poles", "quantity"], + }, + }, + }, + "required": ["customer_name"], +} + +PROMPT = ( + "You are extracting a field change-order for a residential electrical job from a technician's dictated note. " + "Extract ONLY parts the note explicitly mentions — never invent parts, quantities, or a customer name.\n\n" + "Note:\n{voice_text}" +) + + +def extract_amendment(voice_text, *, model_factory, model_names, max_output_tokens): + from vertexai.generative_models import GenerationConfig + + config = GenerationConfig( + response_mime_type="application/json", + response_schema=AMENDMENT_SCHEMA, + max_output_tokens=max_output_tokens, + ) + for name in model_names: + try: + response = model_factory(name).generate_content(PROMPT.format(voice_text=voice_text), generation_config=config) + return AmendmentPayload.model_validate_json(response.text) + except (ValidationError, ValueError) as exc: + logger.warning("model %s returned schema-invalid output: %s", name, exc) + except Exception as exc: # API errors: quota, permission, model-not-found + logger.warning("model %s failed: %s", name, exc) + raise ExtractionError(f"all models failed for extraction: {model_names}") diff --git a/app/google_clients.py b/app/google_clients.py new file mode 100644 index 0000000..f7cefd0 --- /dev/null +++ b/app/google_clients.py @@ -0,0 +1,38 @@ +"""All Google client construction lives here; everything downstream takes injected clients.""" +import google.auth +from googleapiclient.discovery import build + +SCOPES = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/drive", + "https://www.googleapis.com/auth/spreadsheets", +] + + +def get_credentials(): + creds, _ = google.auth.default(scopes=SCOPES) + return creds + + +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 make_model_factory(project_id: str, region: str): + def factory(model_name: str): + import vertexai + from vertexai.generative_models import GenerativeModel + + vertexai.init(project=project_id, location=region) + return GenerativeModel(model_name) + + return factory + + +def read_values(sheets, spreadsheet_id: str, a1_range: str): + result = sheets.spreadsheets().values().get(spreadsheetId=spreadsheet_id, range=a1_range).execute() + return result.get("values", []) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..66d82c7 --- /dev/null +++ b/app/main.py @@ -0,0 +1,197 @@ +"""Skipta — field amendment service. Routes only; logic lives in the sibling modules.""" +import json +import logging +import os +from datetime import datetime, timezone +from pathlib import Path + +from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from pydantic import BaseModel, Field, field_validator +from slowapi import Limiter, _rate_limit_exceeded_handler +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.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.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") +logger = logging.getLogger("skipta") + +app = FastAPI(title="Skipta") +app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static") +templates = Jinja2Templates(directory=Path(__file__).parent / "templates") + +limiter = Limiter(key_func=get_remote_address, default_limits=[]) +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + +_settings: Settings | None = None +_clients: dict = {} + + +def get_settings() -> Settings: + global _settings + if _settings is None: + _settings = Settings.from_env() + return _settings + + +def get_sheets(): + if "sheets" not in _clients: + _clients["sheets"] = build_sheets(get_credentials()) + return _clients["sheets"] + + +def get_drive(): + if "drive" not in _clients: + _clients["drive"] = build_drive(get_credentials()) + return _clients["drive"] + + +def get_extract(): + def _extract(voice_text: str, settings: Settings): + factory = make_model_factory(settings.project_id, settings.region) + return extract_amendment( + voice_text, model_factory=factory, model_names=settings.model_names, max_output_tokens=settings.max_output_tokens + ) + + return _extract + + +class CreateAmendmentRequest(BaseModel): + voice_text: str = Field(..., min_length=1, max_length=4000) + customer_name: str | None = Field(default=None, max_length=200) + + @field_validator("voice_text") + @classmethod + def not_blank(cls, v: str) -> str: + if not v.strip(): + raise ValueError("voice_text cannot be blank") + return v.strip() + + @field_validator("customer_name") + @classmethod + def blank_to_none(cls, v: str | None) -> str | None: + if v is None: + return None + v = v.strip() + return v or None + + +@app.get("/healthz") +def healthz(): + return {"status": "healthy"} + + +@app.get("/", response_class=HTMLResponse) +def index(request: Request): + return templates.TemplateResponse(request, "index.html") + + +@app.post("/api/v1/amendments", status_code=201) +@limiter.limit(lambda: f"{get_settings().rate_limit_per_minute}/minute") +def create_amendment( + request: Request, + body: CreateAmendmentRequest, + settings: Settings = Depends(get_settings), + sheets=Depends(get_sheets), + extract=Depends(get_extract), +): + try: + payload = extract(body.voice_text, settings) + except ExtractionError as exc: + raise HTTPException(status_code=422, detail=f"Could not understand the change order: {exc}") from exc + customer_name = body.customer_name or payload.customer_name.strip() + if not customer_name: + raise HTTPException(status_code=422, detail="No customer name — provide customer_name or mention the customer in the note") + payload = payload.model_copy(update={"customer_name": customer_name}) + + panels = pricing.parse_panels(read_values(sheets, settings.spreadsheet_id, "Panels!A2:D")) + breakers = pricing.parse_breakers(read_values(sheets, settings.spreadsheet_id, "Breakers!A2:E")) + result = pricing.price_amendment(payload, panels, breakers) + + now = datetime.now(timezone.utc) + amendment_id = amendments.make_amendment_id(payload.customer_name, now) + record = AmendmentRecord( + amendment_id=amendment_id, created_at=now.isoformat(), customer_name=payload.customer_name, + voice_text=body.voice_text, extracted_json=payload.model_dump_json(), + line_items_json=json.dumps(result.items_as_dicts()), total=result.total, status="draft", + pdf_drive_url="", signed_at="", + ) + amendments.append_amendment(sheets, settings.spreadsheet_id, record) + logger.info("amendment %s created (unmatched=%s, total=%.2f)", amendment_id, result.has_unmatched, result.total) + return {"amendment_id": amendment_id, "signing_url": f"{settings.base_url}/amendments/{amendment_id}"} + + +@app.get("/amendments/{amendment_id}", response_class=HTMLResponse) +def signing_page(request: Request, amendment_id: str, settings: Settings = Depends(get_settings), sheets=Depends(get_sheets)): + found = amendments.find_amendment(sheets, settings.spreadsheet_id, amendment_id) + if found is None: + raise HTTPException(status_code=404, detail="Unknown amendment") + _, record = found + items = json.loads(record.line_items_json) + return templates.TemplateResponse( + request, "sign.html", + {"record": record, "items": items, "has_unmatched": any(not i["matched"] for i in items), "already_signed": record.status == "signed"}, + ) + + +class SignRequest(BaseModel): + crew_signature_base64: str + customer_signature_base64: str + + @field_validator("crew_signature_base64", "customer_signature_base64") + @classmethod + def must_be_png_data_url(cls, v: str) -> str: + if not v.startswith("data:image/png;base64,"): + raise ValueError("signature must be a PNG data URL") + return v + + +@app.post("/api/v1/amendments/{amendment_id}/sign") +@limiter.limit(lambda: f"{get_settings().rate_limit_per_minute}/minute") +def sign_amendment( + request: Request, + amendment_id: str, + body: SignRequest, + settings: Settings = Depends(get_settings), + sheets=Depends(get_sheets), + drive=Depends(get_drive), +): + found = amendments.find_amendment(sheets, settings.spreadsheet_id, amendment_id) + if found is None: + raise HTTPException(status_code=404, detail="Unknown amendment") + row, record = found + if record.status == "signed": + raise HTTPException(status_code=409, detail="Amendment already signed") + + signed_at = datetime.now(timezone.utc) + record.signed_at = signed_at.isoformat() + items = json.loads(record.line_items_json) + html = render_amendment_html(record, items, crew_signature=body.crew_signature_base64, customer_signature=body.customer_signature_base64) + try: + pdf_bytes = render_pdf(html) + except OSError as exc: + raise HTTPException(status_code=502, detail=f"PDF rendering unavailable: {exc}") from exc + + 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 + 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) + 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 + 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) + return {"pdf_drive_url": pdf_url} diff --git a/app/pdf.py b/app/pdf.py new file mode 100644 index 0000000..bb65f3e --- /dev/null +++ b/app/pdf.py @@ -0,0 +1,21 @@ +"""HTML → flattened PDF. WeasyPrint imports lazily: hosts without GTK libs can still run every non-PDF code path.""" +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader, select_autoescape + +_env = Environment( + loader=FileSystemLoader(Path(__file__).parent / "templates"), + autoescape=select_autoescape(["html"]), +) + + +def render_amendment_html(record, items, *, crew_signature, customer_signature) -> str: + return _env.get_template("amendment_pdf.html").render( + record=record, items=items, crew_signature=crew_signature, customer_signature=customer_signature + ) + + +def render_pdf(html: str) -> bytes: + from weasyprint import HTML + + return HTML(string=html).write_pdf() diff --git a/app/pricing.py b/app/pricing.py new file mode 100644 index 0000000..4b5c7a2 --- /dev/null +++ b/app/pricing.py @@ -0,0 +1,59 @@ +"""Deterministic pricing: match extracted requirements against the Panels/Breakers tabs. An unmatched requirement stays visible (UNMATCHED) and blocks signing — this is the guard against LLM-invented parts.""" +from dataclasses import asdict, dataclass, field + +from app.extraction import AmendmentPayload + + +@dataclass(frozen=True) +class LineItem: + kind: str # "panel" | "breaker" + spec: str + description: str + quantity: int + unit_cost: float | None + subtotal: float + matched: bool + + +@dataclass(frozen=True) +class PricingResult: + line_items: list[LineItem] = field(default_factory=list) + total: float = 0.0 + has_unmatched: bool = False + + def items_as_dicts(self) -> list[dict]: + return [asdict(i) for i in self.line_items] + + +def parse_panels(rows): + return [ + {"panel_id": r[0], "max_amperage": int(r[1]), "description": r[2], "unit_cost": float(r[3])} + for r in rows + if len(r) >= 4 + ] + + +def parse_breakers(rows): + return [ + {"breaker_id": r[0], "amps": int(r[1]), "poles": int(r[2]), "description": r[3], "unit_cost": float(r[4])} + for r in rows + if len(r) >= 5 + ] + + +def price_amendment(payload: AmendmentPayload, panels: list[dict], breakers: list[dict]) -> PricingResult: + items: list[LineItem] = [] + if payload.panel is not None: + match = next((p for p in panels if p["max_amperage"] == payload.panel.max_amperage), None) + items.append(_line_item("panel", f"{payload.panel.max_amperage}A panel", match, 1)) + for req in payload.breakers: + match = next((b for b in breakers if b["amps"] == req.amps and b["poles"] == req.poles), None) + items.append(_line_item("breaker", f"{req.amps}A {req.poles}-pole breaker", match, req.quantity)) + total = round(sum(i.subtotal for i in items), 2) + return PricingResult(line_items=items, total=total, has_unmatched=any(not i.matched for i in items)) + + +def _line_item(kind, spec, match, quantity) -> LineItem: + if match is None: + return LineItem(kind, spec, "UNMATCHED — no pricing row", quantity, None, 0.0, False) + return LineItem(kind, spec, match["description"], quantity, match["unit_cost"], round(match["unit_cost"] * quantity, 2), True) diff --git a/app/static/signature_pad.umd.min.js b/app/static/signature_pad.umd.min.js new file mode 100644 index 0000000..8b0fefc --- /dev/null +++ b/app/static/signature_pad.umd.min.js @@ -0,0 +1,6 @@ +/*! + * Signature Pad v5.0.4 | https://github.com/szimek/signature_pad + * (c) 2024 Szymon Nowak | Released under the MIT license + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).SignaturePad=e()}(this,(function(){"use strict";class t{constructor(t,e,i,n){if(isNaN(t)||isNaN(e))throw new Error(`Point is invalid: (${t}, ${e})`);this.x=+t,this.y=+e,this.pressure=i||0,this.time=n||Date.now()}distanceTo(t){return Math.sqrt(Math.pow(this.x-t.x,2)+Math.pow(this.y-t.y,2))}equals(t){return this.x===t.x&&this.y===t.y&&this.pressure===t.pressure&&this.time===t.time}velocityFrom(t){return this.time!==t.time?this.distanceTo(t)/(this.time-t.time):0}}class e{static fromPoints(t,i){const n=this.calculateControlPoints(t[0],t[1],t[2]).c2,s=this.calculateControlPoints(t[1],t[2],t[3]).c1;return new e(t[1],n,s,t[2],i.start,i.end)}static calculateControlPoints(e,i,n){const s=e.x-i.x,o=e.y-i.y,r=i.x-n.x,h=i.y-n.y,a=(e.x+i.x)/2,c=(e.y+i.y)/2,d=(i.x+n.x)/2,l=(i.y+n.y)/2,u=Math.sqrt(s*s+o*o),v=Math.sqrt(r*r+h*h),_=u+v==0?0:v/(u+v),p=d+(a-d)*_,m=l+(c-l)*_,g=i.x-p,w=i.y-m;return{c1:new t(a+g,c+w),c2:new t(d+g,l+w)}}constructor(t,e,i,n,s,o){this.startPoint=t,this.control2=e,this.control1=i,this.endPoint=n,this.startWidth=s,this.endWidth=o}length(){let t,e,i=0;for(let n=0;n<=10;n+=1){const s=n/10,o=this.point(s,this.startPoint.x,this.control1.x,this.control2.x,this.endPoint.x),r=this.point(s,this.startPoint.y,this.control1.y,this.control2.y,this.endPoint.y);if(n>0){const n=o-t,s=r-e;i+=Math.sqrt(n*n+s*s)}t=o,e=r}return i}point(t,e,i,n,s){return e*(1-t)*(1-t)*(1-t)+3*i*(1-t)*(1-t)*t+3*n*(1-t)*t*t+s*t*t*t}}class i{constructor(){try{this._et=new EventTarget}catch(t){this._et=document}}addEventListener(t,e,i){this._et.addEventListener(t,e,i)}dispatchEvent(t){return this._et.dispatchEvent(t)}removeEventListener(t,e,i){this._et.removeEventListener(t,e,i)}}class n extends i{constructor(t,e={}){var i,s,o;super(),this.canvas=t,this._drawingStroke=!1,this._isEmpty=!0,this._lastPoints=[],this._data=[],this._lastVelocity=0,this._lastWidth=0,this._handleMouseDown=t=>{this._isLeftButtonPressed(t,!0)&&!this._drawingStroke&&this._strokeBegin(this._pointerEventToSignatureEvent(t))},this._handleMouseMove=t=>{this._isLeftButtonPressed(t,!0)&&this._drawingStroke?this._strokeMoveUpdate(this._pointerEventToSignatureEvent(t)):this._strokeEnd(this._pointerEventToSignatureEvent(t),!1)},this._handleMouseUp=t=>{this._isLeftButtonPressed(t)||this._strokeEnd(this._pointerEventToSignatureEvent(t))},this._handleTouchStart=t=>{1!==t.targetTouches.length||this._drawingStroke||(t.cancelable&&t.preventDefault(),this._strokeBegin(this._touchEventToSignatureEvent(t)))},this._handleTouchMove=t=>{1===t.targetTouches.length&&(t.cancelable&&t.preventDefault(),this._drawingStroke?this._strokeMoveUpdate(this._touchEventToSignatureEvent(t)):this._strokeEnd(this._touchEventToSignatureEvent(t),!1))},this._handleTouchEnd=t=>{0===t.targetTouches.length&&(t.cancelable&&t.preventDefault(),this.canvas.removeEventListener("touchmove",this._handleTouchMove),this._strokeEnd(this._touchEventToSignatureEvent(t)))},this._handlePointerDown=t=>{t.isPrimary&&this._isLeftButtonPressed(t)&&!this._drawingStroke&&(t.preventDefault(),this._strokeBegin(this._pointerEventToSignatureEvent(t)))},this._handlePointerMove=t=>{t.isPrimary&&(this._isLeftButtonPressed(t,!0)&&this._drawingStroke?(t.preventDefault(),this._strokeMoveUpdate(this._pointerEventToSignatureEvent(t))):this._strokeEnd(this._pointerEventToSignatureEvent(t),!1))},this._handlePointerUp=t=>{t.isPrimary&&!this._isLeftButtonPressed(t)&&(t.preventDefault(),this._strokeEnd(this._pointerEventToSignatureEvent(t)))},this.velocityFilterWeight=e.velocityFilterWeight||.7,this.minWidth=e.minWidth||.5,this.maxWidth=e.maxWidth||2.5,this.throttle=null!==(i=e.throttle)&&void 0!==i?i:16,this.minDistance=null!==(s=e.minDistance)&&void 0!==s?s:5,this.dotSize=e.dotSize||0,this.penColor=e.penColor||"black",this.backgroundColor=e.backgroundColor||"rgba(0,0,0,0)",this.compositeOperation=e.compositeOperation||"source-over",this.canvasContextOptions=null!==(o=e.canvasContextOptions)&&void 0!==o?o:{},this._strokeMoveUpdate=this.throttle?function(t,e=250){let i,n,s,o=0,r=null;const h=()=>{o=Date.now(),r=null,i=t.apply(n,s),r||(n=null,s=[])};return function(...a){const c=Date.now(),d=e-(c-o);return n=this,s=a,d<=0||d>e?(r&&(clearTimeout(r),r=null),o=c,i=t.apply(n,s),r||(n=null,s=[])):r||(r=window.setTimeout(h,d)),i}}(n.prototype._strokeUpdate,this.throttle):n.prototype._strokeUpdate,this._ctx=t.getContext("2d",this.canvasContextOptions),this.clear(),this.on()}clear(){const{_ctx:t,canvas:e}=this;t.fillStyle=this.backgroundColor,t.clearRect(0,0,e.width,e.height),t.fillRect(0,0,e.width,e.height),this._data=[],this._reset(this._getPointGroupOptions()),this._isEmpty=!0}fromDataURL(t,e={}){return new Promise(((i,n)=>{const s=new Image,o=e.ratio||window.devicePixelRatio||1,r=e.width||this.canvas.width/o,h=e.height||this.canvas.height/o,a=e.xOffset||0,c=e.yOffset||0;this._reset(this._getPointGroupOptions()),s.onload=()=>{this._ctx.drawImage(s,a,c,r,h),i()},s.onerror=t=>{n(t)},s.crossOrigin="anonymous",s.src=t,this._isEmpty=!1}))}toDataURL(t="image/png",e){return"image/svg+xml"===t?("object"!=typeof e&&(e=void 0),`data:image/svg+xml;base64,${btoa(this.toSVG(e))}`):("number"!=typeof e&&(e=void 0),this.canvas.toDataURL(t,e))}on(){this.canvas.style.touchAction="none",this.canvas.style.msTouchAction="none",this.canvas.style.userSelect="none";const t=/Macintosh/.test(navigator.userAgent)&&"ontouchstart"in document;window.PointerEvent&&!t?this._handlePointerEvents():(this._handleMouseEvents(),"ontouchstart"in window&&this._handleTouchEvents())}off(){this.canvas.style.touchAction="auto",this.canvas.style.msTouchAction="auto",this.canvas.style.userSelect="auto",this.canvas.removeEventListener("pointerdown",this._handlePointerDown),this.canvas.removeEventListener("mousedown",this._handleMouseDown),this.canvas.removeEventListener("touchstart",this._handleTouchStart),this._removeMoveUpEventListeners()}_getListenerFunctions(){var t;const e=window.document===this.canvas.ownerDocument?window:null!==(t=this.canvas.ownerDocument.defaultView)&&void 0!==t?t:this.canvas.ownerDocument;return{addEventListener:e.addEventListener.bind(e),removeEventListener:e.removeEventListener.bind(e)}}_removeMoveUpEventListeners(){const{removeEventListener:t}=this._getListenerFunctions();t("pointermove",this._handlePointerMove),t("pointerup",this._handlePointerUp),t("mousemove",this._handleMouseMove),t("mouseup",this._handleMouseUp),t("touchmove",this._handleTouchMove),t("touchend",this._handleTouchEnd)}isEmpty(){return this._isEmpty}fromData(t,{clear:e=!0}={}){e&&this.clear(),this._fromData(t,this._drawCurve.bind(this),this._drawDot.bind(this)),this._data=this._data.concat(t)}toData(){return this._data}_isLeftButtonPressed(t,e){return e?1===t.buttons:!(1&~t.buttons)}_pointerEventToSignatureEvent(t){return{event:t,type:t.type,x:t.clientX,y:t.clientY,pressure:"pressure"in t?t.pressure:0}}_touchEventToSignatureEvent(t){const e=t.changedTouches[0];return{event:t,type:t.type,x:e.clientX,y:e.clientY,pressure:e.force}}_getPointGroupOptions(t){return{penColor:t&&"penColor"in t?t.penColor:this.penColor,dotSize:t&&"dotSize"in t?t.dotSize:this.dotSize,minWidth:t&&"minWidth"in t?t.minWidth:this.minWidth,maxWidth:t&&"maxWidth"in t?t.maxWidth:this.maxWidth,velocityFilterWeight:t&&"velocityFilterWeight"in t?t.velocityFilterWeight:this.velocityFilterWeight,compositeOperation:t&&"compositeOperation"in t?t.compositeOperation:this.compositeOperation}}_strokeBegin(t){if(!this.dispatchEvent(new CustomEvent("beginStroke",{detail:t,cancelable:!0})))return;const{addEventListener:e}=this._getListenerFunctions();switch(t.event.type){case"mousedown":e("mousemove",this._handleMouseMove),e("mouseup",this._handleMouseUp);break;case"touchstart":e("touchmove",this._handleTouchMove),e("touchend",this._handleTouchEnd);break;case"pointerdown":e("pointermove",this._handlePointerMove),e("pointerup",this._handlePointerUp)}this._drawingStroke=!0;const i=this._getPointGroupOptions(),n=Object.assign(Object.assign({},i),{points:[]});this._data.push(n),this._reset(i),this._strokeUpdate(t)}_strokeUpdate(t){if(!this._drawingStroke)return;if(0===this._data.length)return void this._strokeBegin(t);this.dispatchEvent(new CustomEvent("beforeUpdateStroke",{detail:t}));const e=this._createPoint(t.x,t.y,t.pressure),i=this._data[this._data.length-1],n=i.points,s=n.length>0&&n[n.length-1],o=!!s&&e.distanceTo(s)<=this.minDistance,r=this._getPointGroupOptions(i);if(!s||!s||!o){const t=this._addPoint(e,r);s?t&&this._drawCurve(t,r):this._drawDot(e,r),n.push({time:e.time,x:e.x,y:e.y,pressure:e.pressure})}this.dispatchEvent(new CustomEvent("afterUpdateStroke",{detail:t}))}_strokeEnd(t,e=!0){this._removeMoveUpEventListeners(),this._drawingStroke&&(e&&this._strokeUpdate(t),this._drawingStroke=!1,this.dispatchEvent(new CustomEvent("endStroke",{detail:t})))}_handlePointerEvents(){this._drawingStroke=!1,this.canvas.addEventListener("pointerdown",this._handlePointerDown)}_handleMouseEvents(){this._drawingStroke=!1,this.canvas.addEventListener("mousedown",this._handleMouseDown)}_handleTouchEvents(){this.canvas.addEventListener("touchstart",this._handleTouchStart)}_reset(t){this._lastPoints=[],this._lastVelocity=0,this._lastWidth=(t.minWidth+t.maxWidth)/2,this._ctx.fillStyle=t.penColor,this._ctx.globalCompositeOperation=t.compositeOperation}_createPoint(e,i,n){const s=this.canvas.getBoundingClientRect();return new t(e-s.left,i-s.top,n,(new Date).getTime())}_addPoint(t,i){const{_lastPoints:n}=this;if(n.push(t),n.length>2){3===n.length&&n.unshift(n[0]);const t=this._calculateCurveWidths(n[1],n[2],i),s=e.fromPoints(n,t);return n.shift(),s}return null}_calculateCurveWidths(t,e,i){const n=i.velocityFilterWeight*e.velocityFrom(t)+(1-i.velocityFilterWeight)*this._lastVelocity,s=this._strokeWidth(n,i),o={end:s,start:this._lastWidth};return this._lastVelocity=n,this._lastWidth=s,o}_strokeWidth(t,e){return Math.max(e.maxWidth/(t+1),e.minWidth)}_drawCurveSegment(t,e,i){const n=this._ctx;n.moveTo(t,e),n.arc(t,e,i,0,2*Math.PI,!1),this._isEmpty=!1}_drawCurve(t,e){const i=this._ctx,n=t.endWidth-t.startWidth,s=2*Math.ceil(t.length());i.beginPath(),i.fillStyle=e.penColor;for(let i=0;i0?e.dotSize:(e.minWidth+e.maxWidth)/2;i.beginPath(),this._drawCurveSegment(t.x,t.y,n),i.closePath(),i.fillStyle=e.penColor,i.fill()}_fromData(e,i,n){for(const s of e){const{points:e}=s,o=this._getPointGroupOptions(s);if(e.length>1)for(let n=0;n{const i=document.createElement("path");if(!(isNaN(t.control1.x)||isNaN(t.control1.y)||isNaN(t.control2.x)||isNaN(t.control2.y))){const n=`M ${t.startPoint.x.toFixed(3)},${t.startPoint.y.toFixed(3)} C ${t.control1.x.toFixed(3)},${t.control1.y.toFixed(3)} ${t.control2.x.toFixed(3)},${t.control2.y.toFixed(3)} ${t.endPoint.x.toFixed(3)},${t.endPoint.y.toFixed(3)}`;i.setAttribute("d",n),i.setAttribute("stroke-width",(2.25*t.endWidth).toFixed(3)),i.setAttribute("stroke",e),i.setAttribute("fill","none"),i.setAttribute("stroke-linecap","round"),o.appendChild(i)}}),((t,{penColor:e,dotSize:i,minWidth:n,maxWidth:s})=>{const r=document.createElement("circle"),h=i>0?i:(n+s)/2;r.setAttribute("r",h.toString()),r.setAttribute("cx",t.x.toString()),r.setAttribute("cy",t.y.toString()),r.setAttribute("fill",e),o.appendChild(r)})),o.outerHTML}}return n})); +//# sourceMappingURL=signature_pad.umd.min.js.map diff --git a/app/static/skipta.css b/app/static/skipta.css new file mode 100644 index 0000000..5e412d2 --- /dev/null +++ b/app/static/skipta.css @@ -0,0 +1,18 @@ +* { box-sizing: border-box; } +body { font-family: system-ui, sans-serif; margin: 0; padding: 1rem; background: #f5f5f4; color: #1c1917; } +main { max-width: 640px; margin: 0 auto; } +h1 { font-size: 1.3rem; } +textarea, input[type=text] { width: 100%; padding: .6rem; font-size: 1rem; border: 1px solid #a8a29e; border-radius: 6px; } +textarea { min-height: 8rem; } +button { width: 100%; padding: .8rem; margin-top: 1rem; font-size: 1.05rem; border: 0; border-radius: 6px; background: #1d4ed8; color: #fff; } +button:disabled { background: #a8a29e; } +table { width: 100%; border-collapse: collapse; margin: 1rem 0; background: #fff; } +th, td { border: 1px solid #d6d3d1; padding: .5rem; font-size: .9rem; text-align: left; } +.total td { font-weight: 700; } +.unmatched td { color: #b91c1c; font-weight: 600; } +.warn { background: #fef3c7; border: 1px solid #f59e0b; padding: .8rem; border-radius: 6px; } +.ok { background: #dcfce7; border: 1px solid #22c55e; padding: .8rem; border-radius: 6px; } +canvas.sig { width: 100%; height: 160px; background: #fff; border: 1px dashed #78716c; border-radius: 6px; touch-action: none; } +label { display: block; margin-top: 1rem; font-weight: 600; } +a.clear { font-size: .8rem; font-weight: 400; float: right; } +#result a { word-break: break-all; } diff --git a/app/templates/amendment_pdf.html b/app/templates/amendment_pdf.html new file mode 100644 index 0000000..efc5ca8 --- /dev/null +++ b/app/templates/amendment_pdf.html @@ -0,0 +1,42 @@ + + + + +Field Amendment — {{ record.customer_name }} ({{ record.amendment_id }}) + + + +

Field Amendment — {{ record.customer_name }}

+

Amendment {{ record.amendment_id }}, created {{ record.created_at }}.

+

This amendment to the existing statement of work covers the following change requested on site:

+
{{ record.voice_text }}
+ + + {% for item in items %} + + + + + + + + {% endfor %} + +
ItemDescriptionQtyUnitSubtotal
{{ item.spec }}{{ item.description }}{{ item.quantity }}{{ "%.2f"|format(item.unit_cost) if item.unit_cost is not none else "—" }}{{ "%.2f"|format(item.subtotal) }}
Total{{ "%.2f"|format(record.total) }}
+

By signing below, both parties agree to the described change and pricing as an amendment to the existing agreement.

+
+
{% if crew_signature %}{% endif %}
Crew — {{ record.signed_at }}
+
{% if customer_signature %}{% endif %}
Customer — {{ record.customer_name }}
+
+ + diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..d9dc021 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,29 @@ + + + + +Skipta — new field amendment + + +
+

Skipta — dictate a change order

+

Tap the text box and use your keyboard's mic to dictate the change, e.g. "Smith wants to upgrade to a 200 amp panel and add three 20 amp single pole breakers."

+ + + +

+ +
+ diff --git a/app/templates/sign.html b/app/templates/sign.html new file mode 100644 index 0000000..c74a257 --- /dev/null +++ b/app/templates/sign.html @@ -0,0 +1,60 @@ + + + + +Amendment {{ record.amendment_id }} + + + +
+

Field amendment — {{ record.customer_name }}

+

Requested change: {{ record.voice_text }}

+ + + {% for item in items %} + + + + + + {% endfor %} + +
ItemDescriptionQtyUnitSubtotal
{{ item.spec }}{{ item.description }}{{ item.quantity }}{{ "%.2f"|format(item.unit_cost) if item.unit_cost is not none else "—" }}{{ "%.2f"|format(item.subtotal) }}
Total{{ "%.2f"|format(record.total) }}
+ {% if already_signed %} +

Already signed. View the PDF in Drive.

+ {% elif has_unmatched %} +

One or more items have no pricing match (UNMATCHED). Fix the pricing sheet or re-submit the request — signing is disabled.

+ {% else %} + + + + + +

+ {% endif %} + +
+ diff --git a/k8s/base/configmap.yaml b/k8s/base/configmap.yaml new file mode 100644 index 0000000..1878062 --- /dev/null +++ b/k8s/base/configmap.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: skipta-config + namespace: skipta +data: + gcp_project_id: "teralivekubernetes" + gcp_region: "us-east1" + spreadsheet_id: "11cKYWrc4TdfdfD5ko2BkAzttCy7-q2U79LmNeQ2Rn1w" + drive_folder_id: "1kUxdx8qgOzG7-s_1zRI3FlWWG70XQGHd" + base_url: "https://skipta.cmdbee.org" diff --git a/k8s/base/deployment.yaml b/k8s/base/deployment.yaml new file mode 100644 index 0000000..e81f36a --- /dev/null +++ b/k8s/base/deployment.yaml @@ -0,0 +1,42 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: skipta + namespace: skipta +spec: + replicas: 1 + selector: + matchLabels: {app: skipta} + template: + metadata: + labels: {app: skipta} + spec: + serviceAccountName: skipta-sa + containers: + - name: skipta + image: ghcr.io/siliconsaga/skipta:latest + imagePullPolicy: Always + ports: + - containerPort: 8000 + env: + - name: GCP_PROJECT_ID + valueFrom: {configMapKeyRef: {name: skipta-config, key: gcp_project_id}} + - name: GCP_REGION + valueFrom: {configMapKeyRef: {name: skipta-config, key: gcp_region}} + - name: SKIPTA_SPREADSHEET_ID + valueFrom: {configMapKeyRef: {name: skipta-config, key: spreadsheet_id}} + - name: SKIPTA_DRIVE_FOLDER_ID + valueFrom: {configMapKeyRef: {name: skipta-config, key: drive_folder_id}} + - name: SKIPTA_BASE_URL + valueFrom: {configMapKeyRef: {name: skipta-config, key: base_url}} + readinessProbe: + httpGet: {path: /healthz, port: 8000} + initialDelaySeconds: 3 + periodSeconds: 5 + livenessProbe: + httpGet: {path: /healthz, port: 8000} + initialDelaySeconds: 15 + periodSeconds: 10 + resources: + requests: {cpu: 250m, memory: 512Mi} + limits: {cpu: 500m, memory: 1Gi} diff --git a/k8s/base/httproute.yaml b/k8s/base/httproute.yaml new file mode 100644 index 0000000..3de86c5 --- /dev/null +++ b/k8s/base/httproute.yaml @@ -0,0 +1,23 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: skipta + namespace: skipta +spec: + parentRefs: + - name: traefik-gateway + namespace: kube-system + kind: Gateway + sectionName: web + - name: traefik-gateway + namespace: kube-system + kind: Gateway + sectionName: websecure + hostnames: + - "skipta.cmdbee.org" + rules: + - matches: + - path: {type: PathPrefix, value: "/"} + backendRefs: + - name: skipta + port: 80 diff --git a/k8s/base/kustomization.yaml b/k8s/base/kustomization.yaml new file mode 100644 index 0000000..7f4e7ee --- /dev/null +++ b/k8s/base/kustomization.yaml @@ -0,0 +1,10 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: skipta +resources: +- namespace.yaml +- serviceaccount.yaml +- configmap.yaml +- deployment.yaml +- service.yaml +- httproute.yaml diff --git a/k8s/base/namespace.yaml b/k8s/base/namespace.yaml new file mode 100644 index 0000000..3909a37 --- /dev/null +++ b/k8s/base/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: skipta diff --git a/k8s/base/service.yaml b/k8s/base/service.yaml new file mode 100644 index 0000000..030d0d5 --- /dev/null +++ b/k8s/base/service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: skipta + namespace: skipta +spec: + type: ClusterIP + selector: {app: skipta} + ports: + - port: 80 + targetPort: 8000 diff --git a/k8s/base/serviceaccount.yaml b/k8s/base/serviceaccount.yaml new file mode 100644 index 0000000..c459a01 --- /dev/null +++ b/k8s/base/serviceaccount.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: skipta-sa + namespace: skipta + annotations: + iam.gke.io/gcp-service-account: skipta-gsa@teralivekubernetes.iam.gserviceaccount.com diff --git a/pyproject.toml b/pyproject.toml index f11c003..f966460 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,3 +4,7 @@ target-version = "py311" [tool.pytest.ini_options] testpaths = ["tests"] +filterwarnings = [ + # Third-party deprecation inside fastapi's TestClient import; nothing actionable here. + "ignore:Using `httpx` with `starlette.testclient` is deprecated", +] diff --git a/requirements.txt b/requirements.txt index c6ce344..7b713cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ fastapi>=0.115,<1.0 uvicorn[standard]>=0.30,<1.0 -google-cloud-aiplatform>=1.60,<2.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-auth>=2.30,<3.0 jinja2>=3.1,<4.0 diff --git a/scripts/verify_access.py b/scripts/verify_access.py new file mode 100644 index 0000000..33b2022 --- /dev/null +++ b/scripts/verify_access.py @@ -0,0 +1,17 @@ +"""One-shot access check: prints tab names of the shared spreadsheet via impersonated ADC.""" +import os +import sys + +import google.auth +from googleapiclient.discovery import build + +SCOPES = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/drive", + "https://www.googleapis.com/auth/spreadsheets", +] + +creds, _ = google.auth.default(scopes=SCOPES) +sheets = build("sheets", "v4", credentials=creds, cache_discovery=False) +meta = sheets.spreadsheets().get(spreadsheetId=sys.argv[1] if len(sys.argv) > 1 else os.environ["SKIPTA_SPREADSHEET_ID"]).execute() +print([s["properties"]["title"] for s in meta["sheets"]]) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8d2e0fd --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,53 @@ +import pytest +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.config import Settings +from tests.test_amendments import FakeSheets +from tests.test_drive import FakeDrive + +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"]] + + +class RoutedFakeSheets(FakeSheets): + """Serves pricing tabs read-only and the Amendments tab read/write, keyed by A1 range.""" + + def __init__(self, store): + super().__init__(store) + self._values.get = self._routed_get + + def _routed_get(self, spreadsheetId, range): + if range.startswith("Panels"): + self._values._result = {"values": PANEL_ROWS} + elif range.startswith("Breakers"): + self._values._result = {"values": BREAKER_ROWS} + else: + self._values._result = {"values": self._values.store} + return self._values + + +@pytest.fixture +def fakes(): + return {"sheets": RoutedFakeSheets([]), "drive": FakeDrive([{"id": "folder-smith", "name": "Smith"}])} + + +@pytest.fixture +def client(fakes): + payload = AmendmentPayload.model_validate( + {"customer_name": "Smith", "panel": {"max_amperage": 200}, "breakers": [{"amps": 20, "poles": 1, "quantity": 3}]} + ) + 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, + ) + app.dependency_overrides[get_sheets] = lambda: fakes["sheets"] + app.dependency_overrides[get_drive] = lambda: fakes["drive"] + 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. + app.state.limiter.enabled = False + yield TestClient(app) + app.state.limiter.enabled = True + app.dependency_overrides.clear() diff --git a/tests/test_amendments.py b/tests/test_amendments.py new file mode 100644 index 0000000..4d4a050 --- /dev/null +++ b/tests/test_amendments.py @@ -0,0 +1,73 @@ +from datetime import datetime, timezone + +from app.amendments import AmendmentRecord, append_amendment, find_amendment, make_amendment_id, mark_signed + + +class FakeValues: + def __init__(self, store): + self.store = store # list of rows for the Amendments tab (no header) + + def append(self, spreadsheetId, range, valueInputOption, body): + self.store.extend(body["values"]) + return self + + def get(self, spreadsheetId, range): + self._result = {"values": self.store} + return self + + def update(self, spreadsheetId, range, valueInputOption, body): + # range like "Amendments!H3:J3" — row 3 is store index 1 (row 1 = header) + row = int(range.split("!")[1][1:].split(":")[0]) + self.store[row - 2][7:10] = body["values"][0] + return self + + def execute(self): + return getattr(self, "_result", {}) + + +class FakeSheets: + def __init__(self, store): + self._values = FakeValues(store) + + def spreadsheets(self): + return self + + def values(self): + return self._values + + +def record(aid="amend_smith_20260701120000"): + return AmendmentRecord( + amendment_id=aid, created_at="2026-07-01T12:00:00+00:00", customer_name="Smith", voice_text="v", + extracted_json="{}", line_items_json="[]", total=22.5, status="draft", pdf_drive_url="", signed_at="", + ) + + +def test_amendment_id_slug(): + aid = make_amendment_id("O'Brien Jr.", datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)) + assert aid == "amend_obrien-jr_20260701120000" + aid_curly = make_amendment_id("O’Brien Jr.", datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)) + assert aid_curly == "amend_obrien-jr_20260701120000" + + +def test_append_and_find_roundtrip(): + store = [] + sheets = FakeSheets(store) + append_amendment(sheets, "sid", record()) + found = find_amendment(sheets, "sid", "amend_smith_20260701120000") + assert found is not None + row, rec = found + assert row == 2 and rec.customer_name == "Smith" and rec.total == 22.5 and rec.status == "draft" + + +def test_find_missing_returns_none(): + assert find_amendment(FakeSheets([]), "sid", "nope") is None + + +def test_mark_signed_updates_status_columns(): + store = [] + sheets = FakeSheets(store) + append_amendment(sheets, "sid", record()) + mark_signed(sheets, "sid", 2, "https://drive/x", "2026-07-01T13:00:00+00:00") + _, rec = find_amendment(sheets, "sid", "amend_smith_20260701120000") + assert rec.status == "signed" and rec.pdf_drive_url == "https://drive/x" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..d4d5df6 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,15 @@ +from app.config import Settings + + +def test_from_env_reads_and_splits(monkeypatch): + monkeypatch.setenv("GCP_PROJECT_ID", "proj") + monkeypatch.setenv("GCP_REGION", "us-east1") + monkeypatch.setenv("SKIPTA_SPREADSHEET_ID", "sheet123") + monkeypatch.setenv("SKIPTA_DRIVE_FOLDER_ID", "folder123") + monkeypatch.setenv("SKIPTA_BASE_URL", "https://skipta.cmdbee.org") + monkeypatch.setenv("SKIPTA_MODEL_NAMES", "gemini-2.5-flash, gemini-2.0-flash-001") + s = Settings.from_env() + assert s.project_id == "proj" + assert s.model_names == ["gemini-2.5-flash", "gemini-2.0-flash-001"] + assert s.max_output_tokens == 1024 + assert s.rate_limit_per_minute == 10 diff --git a/tests/test_drive.py b/tests/test_drive.py new file mode 100644 index 0000000..2254d03 --- /dev/null +++ b/tests/test_drive.py @@ -0,0 +1,68 @@ +from app.drive import FOLDER_MIME, ensure_customer_folder, find_customer_folder, find_file_in_folder, upload_pdf + + +class FakeFiles: + def __init__(self, listing): + self.listing = listing + self.created = None + + def list(self, q, fields, pageSize): + self.q = q + self._result = {"files": self.listing} + return self + + def create(self, body, media_body=None, fields=""): + self.created = {"body": body, "media": media_body} + self._result = {"id": "new-id", "webViewLink": "https://drive.google.com/file/d/abc/view"} + return self + + def execute(self): + return self._result + + +class FakeDrive: + def __init__(self, listing=()): + self._files = FakeFiles(list(listing)) + + def files(self): + return self._files + + +def test_find_folder_builds_query_and_returns_id(): + drive = FakeDrive([{"id": "folder-smith", "name": "Smith"}]) + assert find_customer_folder(drive, "root123", "Smith") == "folder-smith" + assert "'root123' in parents" in drive.files().q + assert "mimeType = 'application/vnd.google-apps.folder'" in drive.files().q + assert "name = 'Smith'" in drive.files().q # exact match — 'contains' would let Smith claim Smithson's folder + + +def test_find_folder_escapes_quotes(): + drive = FakeDrive([]) + assert find_customer_folder(drive, "root123", "O'Brien") is None + assert "O\\'Brien" in drive.files().q + + +def test_upload_pdf_returns_link_and_targets_folder(): + drive = FakeDrive() + link = upload_pdf(drive, "folder-smith", "Smith_Amendment_20260701120000.pdf", b"%PDF-1.7 fake") + assert link.startswith("https://drive.google.com/") + assert drive.files().created["body"]["parents"] == ["folder-smith"] + + +def test_ensure_customer_folder_returns_existing(): + drive = FakeDrive([{"id": "folder-smith", "name": "Smith"}]) + assert ensure_customer_folder(drive, "root123", "Smith") == "folder-smith" + assert drive.files().created is None + + +def test_ensure_customer_folder_creates_when_missing(): + drive = FakeDrive([]) + assert ensure_customer_folder(drive, "root123", "Smith") == "new-id" + assert drive.files().created["body"]["mimeType"] == FOLDER_MIME + assert drive.files().created["body"]["parents"] == ["root123"] + + +def test_find_file_in_folder_hit_and_miss(): + drive = FakeDrive([{"id": "f1", "name": "x.pdf", "webViewLink": "https://drive.google.com/file/d/f1/view"}]) + assert find_file_in_folder(drive, "folder", "x.pdf") == "https://drive.google.com/file/d/f1/view" + assert find_file_in_folder(FakeDrive([]), "folder", "x.pdf") is None diff --git a/tests/test_extraction.py b/tests/test_extraction.py new file mode 100644 index 0000000..43ab5e5 --- /dev/null +++ b/tests/test_extraction.py @@ -0,0 +1,59 @@ +import pytest + +from app.extraction import AmendmentPayload, ExtractionError, extract_amendment + +VALID_JSON = '{"customer_name": "Smith", "panel": {"max_amperage": 200}, "breakers": [{"amps": 20, "poles": 1, "quantity": 3}]}' + + +class FakeResponse: + def __init__(self, text): + self.text = text + + +class FakeModel: + def __init__(self, text=None, error=None): + self.text, self.error = text, error + + def generate_content(self, prompt, generation_config=None): + if self.error: + raise self.error + return FakeResponse(self.text) + + +def factory_for(models): + calls = [] + + def factory(name): + calls.append(name) + return models[len(calls) - 1] + + factory.calls = calls + return factory + + +def test_valid_extraction(): + factory = factory_for([FakeModel(text=VALID_JSON)]) + payload = extract_amendment("swap the panel", model_factory=factory, model_names=["m1"], max_output_tokens=512) + assert payload.customer_name == "Smith" + assert payload.panel.max_amperage == 200 + assert payload.breakers[0].quantity == 3 + + +def test_falls_back_to_next_model_on_bad_json(): + factory = factory_for([FakeModel(text="not json"), FakeModel(text=VALID_JSON)]) + payload = extract_amendment("x", model_factory=factory, model_names=["m1", "m2"], max_output_tokens=512) + assert payload.customer_name == "Smith" + assert factory.calls == ["m1", "m2"] + + +def test_all_models_fail_raises(): + factory = factory_for([FakeModel(error=RuntimeError("quota")), FakeModel(text="{}")]) + with pytest.raises(ExtractionError): + extract_amendment("x", model_factory=factory, model_names=["m1", "m2"], max_output_tokens=512) + + +def test_no_panel_is_fine(): + factory = factory_for([FakeModel(text='{"customer_name": "Jones", "breakers": []}')]) + payload = extract_amendment("x", model_factory=factory, model_names=["m1"], max_output_tokens=512) + assert payload.panel is None + assert isinstance(payload, AmendmentPayload) diff --git a/tests/test_pdf.py b/tests/test_pdf.py new file mode 100644 index 0000000..a962f12 --- /dev/null +++ b/tests/test_pdf.py @@ -0,0 +1,27 @@ +import pytest + +from app.amendments import AmendmentRecord + +try: + weasyprint = pytest.importorskip("weasyprint") +except OSError as exc: + weasyprint = None + pytestmark = pytest.mark.skip(reason=f"weasyprint system libs unavailable: {exc}") + + +def test_render_pdf_produces_pdf_bytes(): + from app.pdf import render_amendment_html, render_pdf + + record = AmendmentRecord( + amendment_id="amend_smith_20260701120000", created_at="2026-07-01T12:00:00+00:00", customer_name="Smith", + voice_text="Add three 20 amp single pole breakers", extracted_json="{}", + line_items_json="[]", total=22.5, status="draft", pdf_drive_url="", signed_at="", + ) + items = [{"kind": "breaker", "spec": "20A 1-pole breaker", "description": "20A Single-Pole Type BR", "quantity": 3, "unit_cost": 7.5, "subtotal": 22.5, "matched": True}] + html = render_amendment_html(record, items, crew_signature=None, customer_signature=None) + try: + pdf = render_pdf(html) + except OSError as exc: # missing pango/cairo on the host + pytest.skip(f"weasyprint system libs unavailable: {exc}") + assert pdf[:5] == b"%PDF-" + assert len(pdf) > 1000 diff --git a/tests/test_pdf_html.py b/tests/test_pdf_html.py new file mode 100644 index 0000000..7b511da --- /dev/null +++ b/tests/test_pdf_html.py @@ -0,0 +1,12 @@ +from app.amendments import AmendmentRecord + + +def test_render_amendment_html_contents(): + from app.pdf import render_amendment_html + + record = AmendmentRecord( + amendment_id="a", created_at="c", customer_name="Smith", voice_text="v", extracted_json="{}", + line_items_json="[]", total=22.5, status="draft", pdf_drive_url="", signed_at="", + ) + html = render_amendment_html(record, [], crew_signature=None, customer_signature=None) + assert "Smith" in html and "22.50" in html diff --git a/tests/test_pricing.py b/tests/test_pricing.py new file mode 100644 index 0000000..6877439 --- /dev/null +++ b/tests/test_pricing.py @@ -0,0 +1,37 @@ +from app.extraction import AmendmentPayload +from app.pricing import parse_breakers, parse_panels, price_amendment + +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"], + ["B-30A-2P", "30", "2", "30A Double-Pole Type BR", "18.00"], +] + + +def payload(**kw): + base = {"customer_name": "Smith", "breakers": [{"amps": 20, "poles": 1, "quantity": 3}], "panel": {"max_amperage": 200}} + base.update(kw) + return AmendmentPayload.model_validate(base) + + +def test_full_match_totals(): + result = price_amendment(payload(), parse_panels(PANEL_ROWS), parse_breakers(BREAKER_ROWS)) + assert result.has_unmatched is False + assert result.total == 245.00 + 3 * 7.50 + panel_item = next(i for i in result.line_items if i.kind == "panel") + assert panel_item.matched and panel_item.unit_cost == 245.00 + + +def test_unmatched_breaker_flags_result(): + result = price_amendment( + payload(breakers=[{"amps": 50, "poles": 2, "quantity": 1}]), parse_panels(PANEL_ROWS), parse_breakers(BREAKER_ROWS) + ) + assert result.has_unmatched is True + item = next(i for i in result.line_items if i.kind == "breaker") + assert item.matched is False and item.unit_cost is None and item.subtotal == 0.0 + assert "50A" in item.spec and "2-pole" in item.spec + + +def test_no_panel_no_panel_item(): + result = price_amendment(payload(panel=None), parse_panels(PANEL_ROWS), parse_breakers(BREAKER_ROWS)) + assert all(i.kind != "panel" for i in result.line_items) diff --git a/tests/test_routes_intake.py b/tests/test_routes_intake.py new file mode 100644 index 0000000..d4316cd --- /dev/null +++ b/tests/test_routes_intake.py @@ -0,0 +1,39 @@ +def test_healthz(client): + assert client.get("/healthz").json() == {"status": "healthy"} + + +def test_index_serves_form(client): + resp = client.get("/") + assert resp.status_code == 200 + assert "dictate" in resp.text.lower() + + +def test_create_amendment_returns_signing_url(client, fakes): + resp = client.post("/api/v1/amendments", json={"voice_text": "Smith wants a 200A panel and three 20A single pole breakers"}) + assert resp.status_code == 201 + data = resp.json() + assert data["amendment_id"].startswith("amend_smith_") + assert data["signing_url"] == f"http://testserver/amendments/{data['amendment_id']}" + assert len(fakes["sheets"]._values.store) == 1 # draft row persisted + + +def test_signing_page_renders_items(client): + created = client.post("/api/v1/amendments", json={"voice_text": "x"}).json() + page = client.get(f"/amendments/{created['amendment_id']}") + assert page.status_code == 200 + assert "20A Single-Pole Type BR" in page.text + assert "Enact" in page.text + + +def test_unknown_amendment_404(client): + assert client.get("/amendments/amend_nobody_20260101000000").status_code == 404 + + +def test_blank_voice_text_422(client): + assert client.post("/api/v1/amendments", json={"voice_text": " "}).status_code == 422 + + +def test_whitespace_customer_name_falls_back_to_extraction(client): + resp = client.post("/api/v1/amendments", json={"voice_text": "x", "customer_name": " "}) + assert resp.status_code == 201 + assert resp.json()["amendment_id"].startswith("amend_smith_") diff --git a/tests/test_routes_sign.py b/tests/test_routes_sign.py new file mode 100644 index 0000000..d8577ab --- /dev/null +++ b/tests/test_routes_sign.py @@ -0,0 +1,55 @@ +import pytest + +SIG = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==" + + +@pytest.fixture +def signed_body(): + return {"crew_signature_base64": SIG, "customer_signature_base64": SIG} + + +@pytest.fixture +def created(client): + return client.post("/api/v1/amendments", json={"voice_text": "x"}).json() + + +def test_sign_uploads_pdf_and_marks_signed(client, fakes, created, signed_body, monkeypatch): + monkeypatch.setattr("app.main.render_pdf", lambda html: b"%PDF-fake") + resp = client.post(f"/api/v1/amendments/{created['amendment_id']}/sign", json=signed_body) + assert resp.status_code == 200 + assert resp.json()["pdf_drive_url"].startswith("https://drive.google.com/") + row = fakes["sheets"]._values.store[0] + assert row[7] == "signed" and row[8].startswith("https://drive.google.com/") + assert fakes["drive"].files().created["body"]["parents"] == ["folder-smith"] + + +def test_double_sign_409(client, fakes, created, signed_body, monkeypatch): + monkeypatch.setattr("app.main.render_pdf", lambda html: b"%PDF-fake") + client.post(f"/api/v1/amendments/{created['amendment_id']}/sign", json=signed_body) + assert client.post(f"/api/v1/amendments/{created['amendment_id']}/sign", json=signed_body).status_code == 409 + + +def test_sign_unknown_404(client, signed_body): + assert client.post("/api/v1/amendments/amend_no_1/sign", json=signed_body).status_code == 404 + + +def test_sign_rejects_non_png_payload(client, created): + bad = {"crew_signature_base64": "data:text/html;base64,PGI+", "customer_signature_base64": SIG} + assert client.post(f"/api/v1/amendments/{created['amendment_id']}/sign", json=bad).status_code == 422 + + +def test_sign_creates_missing_customer_folder(client, fakes, created, signed_body, monkeypatch): + monkeypatch.setattr("app.main.render_pdf", lambda html: b"%PDF-fake") + fakes["drive"].files().listing.clear() + resp = client.post(f"/api/v1/amendments/{created['amendment_id']}/sign", json=signed_body) + assert resp.status_code == 200 + assert fakes["drive"].files().created["body"]["parents"] == ["new-id"] # upload landed in the just-created folder + + +def test_sign_retry_reuses_existing_pdf(client, fakes, created, signed_body, monkeypatch): + monkeypatch.setattr("app.main.render_pdf", lambda html: b"%PDF-fake") + fakes["drive"].files().listing[0]["webViewLink"] = "https://drive.google.com/file/d/existing/view" + resp = client.post(f"/api/v1/amendments/{created['amendment_id']}/sign", json=signed_body) + assert resp.status_code == 200 + assert resp.json()["pdf_drive_url"] == "https://drive.google.com/file/d/existing/view" + assert fakes["drive"].files().created is None # neither folder nor PDF was re-created