File size: 5,787 Bytes
47b2a99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
"""Quillwright tool endpoints for a voice agent (ElevenLabs Conversational AI).

ElevenLabs owns the phone call, the speech-to-text, the dialogue, and the (great) TTS.
Quillwright stays the source of truth: the agent calls these small JSON tools to forge
and refine an estimate, and every customer-facing number comes from a tool response —
never the agent's free speech (Facts-from-Tools, ADR-0004).

Each tool takes a ``session_id`` (the agent passes its conversation id) so refinement
turns edit the same estimate. State is in-process and demo-scoped, like the pairing store
and the Twilio call state — a durable backend is a swap behind this same interface.

Tools:
  - ``forge(session_id, description)``    → itemized estimate + total from a job description
  - ``edit(session_id, request)``         → add / remove / change a line (catalog-priced)
  - ``lookup_price(item)``                → a single catalog price (read-only)
  - ``text_estimate(session_id, to)``     → SMS the estimate PDF to the caller
"""

# session_id -> {"rows": list[dict], "job_title": str, "tax_rate": float}
_SESSIONS: dict[str, dict] = {}

_JOB_TITLE = "Phone estimate"
_TAX_RATE = 0.13


def reset_sessions() -> None:
    """Drop all voice-agent session state (tests)."""
    _SESSIONS.clear()


def _session(session_id: str) -> dict:
    return _SESSIONS.setdefault(
        session_id, {"rows": [], "job_title": _JOB_TITLE, "tax_rate": _TAX_RATE}
    )


def _rows_from_est(est: dict) -> list[dict]:
    return [
        {
            "description": li["description"],
            "quantity": li["quantity"],
            "unit": li["unit"],
            "rate": li["rate"],
        }
        for li in est["line_items"]
    ]


def _spoken_items(est: dict) -> list[dict]:
    """A compact, speech-friendly view of the lines (no internal fields)."""
    return [
        {"description": li["description"], "quantity": li["quantity"], "rate": li["rate"]}
        for li in est["line_items"]
    ]


def forge(session_id: str, description: str) -> dict:
    """Forge an estimate from a spoken job description; store it on the session."""
    from quillwright.api.estimate import forge_estimate

    forged = forge_estimate(description or "", trade="hvac")
    est = forged.get("estimate")
    if est is None or not est.get("line_items"):
        return {
            "ok": False,
            "message": "I couldn't build an estimate from that. "
            "Try naming the parts and the labor.",
        }
    sess = _session(session_id)
    sess["rows"] = _rows_from_est(est)
    return {
        "ok": True,
        "items": _spoken_items(est),
        "item_count": len(est["line_items"]),
        "total": round(est["total"], 2),
    }


def edit(session_id: str, request: str) -> dict:
    """Apply a spoken edit (add / remove / change) to the session's estimate. The catalog
    owns every price (Facts-from-Tools); returns the assistant's reply + the new total."""
    from quillwright.api.chat import chat_about_estimate

    sess = _session(session_id)
    out = chat_about_estimate(request or "", sess["rows"], tax_rate=sess["tax_rate"])
    est = out["estimate"]
    sess["rows"] = _rows_from_est(est)
    return {
        "ok": True,
        "reply": out["reply"],
        "items": _spoken_items(est),
        "item_count": len(est["line_items"]),
        "total": round(est["total"], 2),
    }


def lookup_price(item: str) -> dict:
    """A single catalog price (read-only) so the agent can answer 'how much is X?'."""
    from quillwright.api.estimate import CATALOG

    hit = CATALOG.lookup(item or "")
    if not hit:
        return {"found": False, "item": item}
    return {
        "found": True,
        "description": hit["description"],
        "rate": hit["rate"],
        "unit": hit["unit"],
    }


def text_estimate(session_id: str, to: str, base_url: str | None = None, sms=None) -> dict:
    """SMS the estimate PDF to the caller. Reuses the S10 send path + tokenized PDF link.
    ``sms`` is injectable for tests; otherwise the real Twilio MMS provider is used."""
    import os

    from quillwright.api.estimate import save_estimate_record
    from quillwright.api.pdf_links import public_pdf_url, register_pdf
    from quillwright.api.recalc import recalc_estimate
    from quillwright.api.send import _default_sms_provider, _render_pdf_bytes

    sess = _session(session_id)
    rows, job_title, tax_rate = sess["rows"], sess["job_title"], sess["tax_rate"]
    if not rows:
        return {"ok": False, "message": "There's no estimate to send yet."}
    if not to:
        return {"ok": False, "message": "I need a phone number to text it to."}

    base = (base_url if base_url is not None else os.environ.get("FF_PUBLIC_BASE_URL", "")).rstrip(
        "/"
    )
    est = recalc_estimate(rows, job_title=job_title, tax_rate=tax_rate)
    save_estimate_record(rows, job_title, tax_rate, thread=[])  # persist the draft (ADR-0013)
    n = len(est["line_items"])
    send = sms or _default_sms_provider
    try:
        pdf_bytes = _render_pdf_bytes(rows, job_title=job_title, tax_rate=tax_rate)
        token = register_pdf(pdf_bytes)
        media_url = public_pdf_url(token, base_url=base or "")
        send(
            recipient=to,
            body=(
                f"Your Quillwright estimate: {n} item{'s' if n != 1 else ''}, "
                f"total ${est['total']:.2f}. AI-generated draft — review before accepting."
            ),
            media_url=media_url,
            summary="",
        )
        return {"ok": True, "sent": True, "total": round(est["total"], 2)}
    except Exception as exc:  # noqa: BLE001 — report a clean failure to the agent
        return {"ok": False, "sent": False, "message": f"Couldn't text it: {exc}"}