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:
@@ -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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user