syncro skill: add Ollama drafting with Claude review + fallback

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 07:20:20 -07:00
parent daeea5f26c
commit 693766d05e

View File

@@ -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'
<prompt content here>
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 #<id>: <subject>
CUSTOMER: <customer_name>
TECH: <tech_name>
WORK DONE: <user description of work>
LABOR: <product_name> — <minutes> min (<quantity> hrs) @ $<price_retail>/hr = $<total>
Rules:
- comment_body must use <br> for line breaks. Do NOT use <ul> or <li> — 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": "<HTML with <br> line breaks>",
"line_item_description": "<one line plain text>",
"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 #<id>: <subject>
CUSTOMER: <customer_name>
NOTE: <user's note or description>
VISIBILITY: <"Internal only" | "Customer-visible">
Rules:
- Use <br> for line breaks. Do NOT use <ul> or <li>.
- Professional and factual. No filler.
Return ONLY valid JSON:
{
"subject": "Update",
"body": "<HTML with <br> line breaks>",
"preview": "<plain text for tech review>"
}
```
#### 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 `<br>`, not `<ul>/<li>`
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 <number>` 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).