From 693766d05ebc8d1b179045de3abfee9604d31a6a Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Fri, 24 Apr 2026 07:20:20 -0700 Subject: [PATCH] syncro skill: add Ollama drafting with Claude review + fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Write operations (bill, comment, create) now send a prompt to Ollama (qwen3:14b) for comment body and billing description drafting. Claude reviews the output against the rate/prepaid/formatting checklist before presenting the preview. If neither Ollama endpoint is reachable, Claude drafts directly — same review and confirmation flow either way. Co-Authored-By: Claude Sonnet 4.6 --- .claude/commands/syncro.md | 144 ++++++++++++++++++++++++++++++++++--- 1 file changed, 135 insertions(+), 9 deletions(-) diff --git a/.claude/commands/syncro.md b/.claude/commands/syncro.md index 92581d6..08c0206 100644 --- a/.claude/commands/syncro.md +++ b/.claude/commands/syncro.md @@ -66,6 +66,127 @@ case "$USER_ID" in esac ``` +### Ollama drafting + +Ollama handles prose drafting for write operations. Claude reviews the output against the hard rules below, then presents a preview. User confirms. Claude executes. + +**Availability check** — run once at the start of any write operation, reuse `$OLLAMA` for the rest of the session: + +```bash +if curl -s -m 2 http://localhost:11434/api/tags >/dev/null 2>&1; then + OLLAMA="http://localhost:11434" +elif curl -s -m 3 http://100.92.127.64:11434/api/tags >/dev/null 2>&1; then + OLLAMA="http://100.92.127.64:11434" +else + OLLAMA="" # fallback: Claude drafts directly +fi +``` + +**Draft call:** + +```bash +# Write prompt to temp file to avoid quoting hell +cat > /tmp/ollama_prompt.txt <<'ENDPROMPT' + +ENDPROMPT + +if [ -n "$OLLAMA" ]; then + DRAFT=$(py -c " +import urllib.request, json, sys +prompt = open('/tmp/ollama_prompt.txt').read() +body = json.dumps({ + 'model': 'qwen3:14b', + 'messages': [{'role': 'user', 'content': prompt}], + 'stream': False, + 'think': False +}).encode() +res = json.loads(urllib.request.urlopen( + urllib.request.Request('$OLLAMA/api/chat', body), timeout=60 +).read()) +print(res['message']['content']) +") +else + echo "[INFO] Ollama unavailable — Claude will draft directly." + DRAFT="" +fi +``` + +**When to use Ollama:** +- Comment body drafting (`/syncro comment`, `/syncro close`, billing resolution notes) +- Billing `description` field (line item billing narrative) +- Ticket initial description during `/syncro create` + +**When NOT to use Ollama:** +- JSON field selection (product_id, quantity, price_retail) — Claude owns this using the local rate table and rules +- Read operations (GET) +- Auth, credential, or security decisions + +#### Billing draft prompt template + +``` +You are a Syncro PSA billing assistant. Draft a resolution comment and billing description. + +TICKET #: +CUSTOMER: +TECH: +WORK DONE: +LABOR: min ( hrs) @ $/hr = $ + +Rules: +- comment_body must use
for line breaks. Do NOT use
    or
  • — they do not render. +- Keep it professional and factual. No filler phrases. +- line_item_description is one plain-text line, billing-facing. + +Return ONLY valid JSON, no prose before or after: +{ + "comment_subject": "Resolution", + "comment_body": " line breaks>", + "line_item_description": "", + "preview": "<2-3 sentence plain-text summary for tech review>" +} +``` + +#### Comment draft prompt template + +``` +You are a Syncro PSA tech assistant. Draft a ticket comment. + +TICKET #: +CUSTOMER: +NOTE: +VISIBILITY: <"Internal only" | "Customer-visible"> + +Rules: +- Use
    for line breaks. Do NOT use
      or
    • . +- Professional and factual. No filler. + +Return ONLY valid JSON: +{ + "subject": "Update", + "body": " line breaks>", + "preview": "" +} +``` + +#### Claude review checklist (always run before presenting to user) + +Whether the draft came from Ollama or Claude wrote it directly: + +1. `price_retail` matches the local rate table for the selected `product_id` +2. `quantity` = minutes ÷ 60 — verify the arithmetic (e.g. 45 min = 0.75, not 0.77) +3. Computed total = `price_retail × quantity` — matches what was communicated to user +4. If labor_type is `emergency` and `prepay_hours > 0`: product must be `26118`, qty must be actual_hours × 1.5 +5. `comment_body` uses `
      `, not `
        /
      • ` +6. No internal notes or credential data in a customer-visible comment body + +If a check fails: correct it and note the fix in the preview so the user can see what changed. + +#### Fallback behavior + +If `OLLAMA` is empty (neither endpoint reachable): Claude drafts the comment body and billing description directly from the same variables. All other logic — review checklist, confirmation, execution — is identical. Announce `[INFO] Ollama unavailable — drafting directly.` + +--- + ### Adding a per-user key 1. User logs into Syncro → Admin → API Tokens → New (`/api_tokens/new`) @@ -285,6 +406,8 @@ Note: "Do Not Invite" (suppress calendar invite email) is not API-controllable. - `do_not_email` — bool; if true, suppresses customer email notification - `tech` — string; overrides the authenticated user's name shown on the comment +**Drafting comment bodies:** Use Ollama (comment draft prompt template above) to generate `body` content. Run Claude review checklist. Present preview and wait for confirmation before POST. Fallback to Claude direct draft if `$OLLAMA` is empty. + **Silently ignored (do not use):** `product_id`, `minutes_spent`, `bill_time_now` — accepted but not saved. Verified 2026-04-21. **CRITICAL — response wrapper:** POST /comment returns `{"comment": {"id": ..., "subject": ..., ...}}` — NOT a flat object. Always parse as `.comment.id`, `.comment.created_at`, etc. Using `.id` returns null and looks like failure even when the comment posted successfully. This caused duplicate comments on 2026-04-22 (#32185) and 2026-04-23 (#32142) — both times the POST succeeded but null `.id` triggered a retry. @@ -452,18 +575,21 @@ When showing ticket detail, include: When `/syncro bill ` is called: 1. `GET /tickets/{id}` for ticket detail, then `GET /customers/{customer_id}` to read `prepay_hours` -2. Ask: "How many minutes should I bill, and what labor type? (remote / onsite / emergency / project / internal)" -3. Decide product + quantity using the emergency-branching table above: +2. Check Ollama availability (see "Ollama drafting" above) — do this once, reuse `$OLLAMA` +3. Ask: "How many minutes should I bill, and what labor type? (remote / onsite / emergency / project / internal)" +4. Decide product + quantity using the emergency-branching table above: - Non-prepaid + emergency → product `26184`, qty = actual hours - Prepaid + emergency → product `26118`, qty = actual hours × 1.5 - Otherwise → per `--labor` mapping below, qty = actual hours -4. `GET /products/{product_id}` → `.product.price_retail` to fetch the current rate -5. Draft the comment body and show it to the user — with the product, quantity, rate, and computed total — for review before any POST -6. Post comment: `POST /tickets/{id}/comment` -7. Add billable line item: `POST /tickets/{id}/add_line_item` with `product_id`, `quantity`, `price_retail` (from step 4), `name`, `description`, `taxable: false` -8. Create invoice: `POST /invoices` with `{"ticket_id": N, "customer_id": N, "category": "Standard"}` -9. Verify invoice: `GET /invoices/{id}` → confirm `.invoice.total` matches `qty × price_retail` -10. Update ticket status to `Invoiced` +5. Look up `price_retail` from the local rate table (do NOT fetch live — rates are baked in) +6. Send billing draft prompt to Ollama (or draft directly if `$OLLAMA` is empty) — see prompt template above +7. Run Claude review checklist on the draft output +8. Present preview to user: product, quantity, rate, computed total, comment body, line item description. Wait for confirmation. +9. Post comment: `POST /tickets/{id}/comment` +10. Add billable line item: `POST /tickets/{id}/add_line_item` with `product_id`, `quantity`, `price_retail`, `name`, `description`, `taxable: false` +11. Create invoice: `POST /invoices` with `{"ticket_id": N, "customer_id": N, "category": "Standard"}` +12. Verify invoice: `GET /invoices/{id}` → confirm `.invoice.total` matches `qty × price_retail` +13. Update ticket status to `Invoiced` **If `.invoice.total` comes back $0.00** (line items went in with null price): `PUT /tickets/{id}/update_line_item` with `price_retail` on each item, then `DELETE /invoices/{bad_id}` and re-POST `/invoices`. Recovery verified on #32203 (2026-04-23).