@agentunion/kite 1.0.7 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/event_hub/entry.py +305 -26
- package/core/event_hub/hub.py +8 -0
- package/core/event_hub/server.py +80 -17
- package/core/kite_log.py +241 -0
- package/core/launcher/entry.py +978 -284
- package/core/launcher/process_manager.py +456 -46
- package/core/registry/entry.py +272 -3
- package/core/registry/server.py +339 -289
- package/core/registry/store.py +10 -4
- package/extensions/agents/__init__.py +1 -0
- package/extensions/agents/assistant/__init__.py +1 -0
- package/extensions/agents/assistant/entry.py +380 -0
- package/extensions/agents/assistant/module.md +22 -0
- package/extensions/agents/assistant/server.py +236 -0
- package/extensions/channels/__init__.py +1 -0
- package/extensions/channels/acp_channel/__init__.py +1 -0
- package/extensions/channels/acp_channel/entry.py +380 -0
- package/extensions/channels/acp_channel/module.md +22 -0
- package/extensions/channels/acp_channel/server.py +236 -0
- package/extensions/event_hub_bench/entry.py +664 -379
- package/extensions/event_hub_bench/module.md +2 -1
- package/extensions/services/backup/__init__.py +1 -0
- package/extensions/services/backup/entry.py +380 -0
- package/extensions/services/backup/module.md +22 -0
- package/extensions/services/backup/server.py +244 -0
- package/extensions/services/model_service/__init__.py +1 -0
- package/extensions/services/model_service/entry.py +380 -0
- package/extensions/services/model_service/module.md +22 -0
- package/extensions/services/model_service/server.py +236 -0
- package/extensions/services/watchdog/entry.py +460 -147
- package/extensions/services/watchdog/module.md +3 -0
- package/extensions/services/watchdog/monitor.py +128 -13
- package/extensions/services/watchdog/server.py +75 -13
- package/extensions/services/web/__init__.py +1 -0
- package/extensions/services/web/config.yaml +149 -0
- package/extensions/services/web/entry.py +487 -0
- package/extensions/services/web/module.md +24 -0
- package/extensions/services/web/routes/__init__.py +1 -0
- package/extensions/services/web/routes/routes_call.py +189 -0
- package/extensions/services/web/routes/routes_config.py +512 -0
- package/extensions/services/web/routes/routes_contacts.py +98 -0
- package/extensions/services/web/routes/routes_devlog.py +99 -0
- package/extensions/services/web/routes/routes_phone.py +81 -0
- package/extensions/services/web/routes/routes_sms.py +48 -0
- package/extensions/services/web/routes/routes_stats.py +17 -0
- package/extensions/services/web/routes/routes_voicechat.py +554 -0
- package/extensions/services/web/routes/schemas.py +216 -0
- package/extensions/services/web/server.py +332 -0
- package/extensions/services/web/static/css/style.css +1064 -0
- package/extensions/services/web/static/index.html +1445 -0
- package/extensions/services/web/static/js/app.js +4671 -0
- package/extensions/services/web/vendor/__init__.py +1 -0
- package/extensions/services/web/vendor/bluetooth/__init__.py +0 -0
- package/extensions/services/web/vendor/bluetooth/audio.py +348 -0
- package/extensions/services/web/vendor/bluetooth/contacts.py +251 -0
- package/extensions/services/web/vendor/bluetooth/manager.py +395 -0
- package/extensions/services/web/vendor/bluetooth/sms.py +290 -0
- package/extensions/services/web/vendor/bluetooth/telephony.py +274 -0
- package/extensions/services/web/vendor/config.py +139 -0
- package/extensions/services/web/vendor/conversation/__init__.py +0 -0
- package/extensions/services/web/vendor/conversation/asr.py +936 -0
- package/extensions/services/web/vendor/conversation/engine.py +548 -0
- package/extensions/services/web/vendor/conversation/llm.py +534 -0
- package/extensions/services/web/vendor/conversation/mcp_tools.py +190 -0
- package/extensions/services/web/vendor/conversation/tts.py +322 -0
- package/extensions/services/web/vendor/conversation/vad.py +138 -0
- package/extensions/services/web/vendor/storage/__init__.py +1 -0
- package/extensions/services/web/vendor/storage/identity.py +312 -0
- package/extensions/services/web/vendor/storage/store.py +507 -0
- package/extensions/services/web/vendor/task/__init__.py +0 -0
- package/extensions/services/web/vendor/task/manager.py +864 -0
- package/extensions/services/web/vendor/task/models.py +45 -0
- package/extensions/services/web/vendor/task/webhook.py +263 -0
- package/extensions/services/web/vendor/tools/__init__.py +0 -0
- package/extensions/services/web/vendor/tools/registry.py +321 -0
- package/main.py +230 -90
- package/package.json +1 -1
|
@@ -0,0 +1,864 @@
|
|
|
1
|
+
"""Task manager — orchestrates the complete lifecycle of outgoing and incoming calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import uuid
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .. import config as cfg
|
|
12
|
+
from ..storage import store
|
|
13
|
+
from ..storage import identity
|
|
14
|
+
from .models import CallDirection, CallResult, EndedReason, TaskStatus
|
|
15
|
+
from .webhook import WebhookClient
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _now_iso() -> str:
|
|
21
|
+
return datetime.now(timezone.utc).isoformat()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TaskManager:
|
|
25
|
+
"""Central coordinator for call tasks.
|
|
26
|
+
|
|
27
|
+
Manages the lifecycle of both outgoing and incoming calls, bridging the
|
|
28
|
+
Bluetooth telephony layer, the AI conversation engine, and webhook
|
|
29
|
+
notifications.
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
bt_manager:
|
|
34
|
+
An instance of :class:`bluetooth.manager.BluetoothManager` that
|
|
35
|
+
provides access to the telephony and audio sub-systems.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, bt_manager: Any) -> None:
|
|
39
|
+
self.bt_manager = bt_manager
|
|
40
|
+
self.webhook = WebhookClient()
|
|
41
|
+
self.active_tasks: dict[str, dict[str, Any]] = {}
|
|
42
|
+
self._engines: dict[str, Any] = {} # task_id -> ConversationEngine
|
|
43
|
+
self._background_tasks: dict[str, asyncio.Task[None]] = {}
|
|
44
|
+
|
|
45
|
+
# ------------------------------------------------------------------
|
|
46
|
+
# Public API
|
|
47
|
+
# ------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
async def create_call_task(self, request: Any) -> dict[str, Any]:
|
|
50
|
+
"""Create a new **outgoing** call task.
|
|
51
|
+
|
|
52
|
+
Workflow
|
|
53
|
+
--------
|
|
54
|
+
1. Generate ``task_id`` (UUID4) and persist the task record.
|
|
55
|
+
2. If ``phone_number`` is already provided, jump to step 4.
|
|
56
|
+
3. If only ``purpose`` is given:
|
|
57
|
+
a. Use LLM to search the contact list for the best match.
|
|
58
|
+
b. If ``require_confirmation`` is true, send a
|
|
59
|
+
``contact_confirmation`` webhook, set status to *confirming*,
|
|
60
|
+
and return immediately so the caller can confirm.
|
|
61
|
+
c. Otherwise, use the best match directly.
|
|
62
|
+
4. Launch ``_run_call`` as a background coroutine.
|
|
63
|
+
5. Return ``{task_id, status}``.
|
|
64
|
+
"""
|
|
65
|
+
# Accept both Pydantic model and plain dict
|
|
66
|
+
if hasattr(request, "model_dump"):
|
|
67
|
+
req = request.model_dump()
|
|
68
|
+
elif isinstance(request, dict):
|
|
69
|
+
req = request
|
|
70
|
+
else:
|
|
71
|
+
req = dict(request)
|
|
72
|
+
task_id = str(uuid.uuid4())
|
|
73
|
+
|
|
74
|
+
task: dict[str, Any] = {
|
|
75
|
+
"task_id": task_id,
|
|
76
|
+
"phone_number": req.get("phone_number"),
|
|
77
|
+
"contact_name": req.get("contact_name"),
|
|
78
|
+
"user_phone": identity.get_user_phone(),
|
|
79
|
+
"purpose": req.get("purpose", ""),
|
|
80
|
+
"system_prompt": req.get("system_prompt", ""),
|
|
81
|
+
"webhook_url": req.get("webhook_url", ""),
|
|
82
|
+
"max_duration_seconds": req.get(
|
|
83
|
+
"max_duration_seconds",
|
|
84
|
+
cfg.get("call.max_duration_seconds", 300),
|
|
85
|
+
),
|
|
86
|
+
"language": req.get("language", "zh"),
|
|
87
|
+
"play_text": req.get("play_text"),
|
|
88
|
+
"require_confirmation": req.get("require_confirmation", False),
|
|
89
|
+
"direction": CallDirection.OUTGOING.value,
|
|
90
|
+
"status": TaskStatus.QUEUED.value,
|
|
91
|
+
"result": None,
|
|
92
|
+
"ended_reason": None,
|
|
93
|
+
"duration_seconds": 0,
|
|
94
|
+
"summary": "",
|
|
95
|
+
"has_recording": False,
|
|
96
|
+
"started_at": _now_iso(),
|
|
97
|
+
"ended_at": None,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# Persist initial record
|
|
101
|
+
await store.save_task(task)
|
|
102
|
+
self.active_tasks[task_id] = task
|
|
103
|
+
|
|
104
|
+
# --- Contact resolution (when phone_number is absent) -----------
|
|
105
|
+
if not task["phone_number"] and task["purpose"]:
|
|
106
|
+
matched = await self._find_contact_for_purpose(task["purpose"])
|
|
107
|
+
if matched:
|
|
108
|
+
task["contact_name"] = matched.get("name")
|
|
109
|
+
task["contact_info"] = {
|
|
110
|
+
"id": matched.get("id"),
|
|
111
|
+
"company": matched.get("company"),
|
|
112
|
+
"phone": matched.get("phone"),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if task["require_confirmation"]:
|
|
116
|
+
task["status"] = TaskStatus.CONFIRMING.value
|
|
117
|
+
await store.update_task(task_id, {"status": task["status"]})
|
|
118
|
+
# Fire-and-forget the webhook; the caller will confirm
|
|
119
|
+
# through POST /api/call/{task_id}/confirm later.
|
|
120
|
+
asyncio.create_task(
|
|
121
|
+
self.webhook.request_contact_confirmation(task, matched)
|
|
122
|
+
)
|
|
123
|
+
return {"task_id": task_id, "status": task["status"]}
|
|
124
|
+
|
|
125
|
+
# No confirmation needed — use the match directly.
|
|
126
|
+
task["phone_number"] = matched.get("phone")
|
|
127
|
+
await store.update_task(task_id, {
|
|
128
|
+
"phone_number": task["phone_number"],
|
|
129
|
+
"contact_name": task["contact_name"],
|
|
130
|
+
})
|
|
131
|
+
else:
|
|
132
|
+
# Could not find a matching contact.
|
|
133
|
+
task["status"] = TaskStatus.FAILED.value
|
|
134
|
+
task["result"] = CallResult.ERROR.value
|
|
135
|
+
task["ended_reason"] = EndedReason.ERROR.value
|
|
136
|
+
task["ended_at"] = _now_iso()
|
|
137
|
+
await store.update_task(task_id, {
|
|
138
|
+
"status": task["status"],
|
|
139
|
+
"result": task["result"],
|
|
140
|
+
"ended_reason": task["ended_reason"],
|
|
141
|
+
"ended_at": task["ended_at"],
|
|
142
|
+
})
|
|
143
|
+
self.active_tasks.pop(task_id, None)
|
|
144
|
+
return {"task_id": task_id, "status": task["status"]}
|
|
145
|
+
|
|
146
|
+
if not task["phone_number"]:
|
|
147
|
+
task["status"] = TaskStatus.FAILED.value
|
|
148
|
+
task["result"] = CallResult.ERROR.value
|
|
149
|
+
task["ended_reason"] = EndedReason.ERROR.value
|
|
150
|
+
task["ended_at"] = _now_iso()
|
|
151
|
+
await store.update_task(task_id, {
|
|
152
|
+
"status": task["status"],
|
|
153
|
+
"result": task["result"],
|
|
154
|
+
"ended_reason": task["ended_reason"],
|
|
155
|
+
"ended_at": task["ended_at"],
|
|
156
|
+
})
|
|
157
|
+
self.active_tasks.pop(task_id, None)
|
|
158
|
+
return {"task_id": task_id, "status": task["status"]}
|
|
159
|
+
|
|
160
|
+
# --- Start the call in the background --------------------------
|
|
161
|
+
bg = asyncio.create_task(self._run_call(task_id))
|
|
162
|
+
self._background_tasks[task_id] = bg
|
|
163
|
+
bg.add_done_callback(lambda _t: self._background_tasks.pop(task_id, None))
|
|
164
|
+
|
|
165
|
+
return {"task_id": task_id, "status": task["status"]}
|
|
166
|
+
|
|
167
|
+
async def confirm_task(self, task_id: str, request: Any) -> dict[str, Any]:
|
|
168
|
+
"""Handle a confirmation response for a task.
|
|
169
|
+
|
|
170
|
+
Two flavours:
|
|
171
|
+
- **Contact confirmation** — ``action="confirm"`` with an optional
|
|
172
|
+
``phone_number`` override. Triggers the outgoing call.
|
|
173
|
+
- **Incoming-call answer** — ``action="answer"`` or ``"reject"``.
|
|
174
|
+
"""
|
|
175
|
+
# Accept both Pydantic model and plain dict
|
|
176
|
+
if hasattr(request, "model_dump"):
|
|
177
|
+
req = request.model_dump()
|
|
178
|
+
elif isinstance(request, dict):
|
|
179
|
+
req = request
|
|
180
|
+
else:
|
|
181
|
+
req = dict(request)
|
|
182
|
+
|
|
183
|
+
task = self.active_tasks.get(task_id)
|
|
184
|
+
if not task:
|
|
185
|
+
task = await store.get_task(task_id)
|
|
186
|
+
if not task:
|
|
187
|
+
return {"error": "task_not_found"}
|
|
188
|
+
|
|
189
|
+
action = req.get("action", "confirm")
|
|
190
|
+
|
|
191
|
+
# --- Contact confirmation (outgoing) ---------------------------
|
|
192
|
+
if action == "confirm":
|
|
193
|
+
phone_number = req.get("phone_number") or task.get("phone_number")
|
|
194
|
+
if not phone_number:
|
|
195
|
+
# Try from contact_info
|
|
196
|
+
phone_number = (task.get("contact_info") or {}).get("phone")
|
|
197
|
+
if not phone_number:
|
|
198
|
+
return {"error": "phone_number_required"}
|
|
199
|
+
|
|
200
|
+
task["phone_number"] = phone_number
|
|
201
|
+
task["status"] = TaskStatus.QUEUED.value
|
|
202
|
+
if req.get("system_prompt"):
|
|
203
|
+
task["system_prompt"] = req["system_prompt"]
|
|
204
|
+
if req.get("purpose"):
|
|
205
|
+
task["purpose"] = req["purpose"]
|
|
206
|
+
|
|
207
|
+
await store.update_task(task_id, {
|
|
208
|
+
"phone_number": phone_number,
|
|
209
|
+
"status": task["status"],
|
|
210
|
+
"system_prompt": task.get("system_prompt", ""),
|
|
211
|
+
"purpose": task.get("purpose", ""),
|
|
212
|
+
})
|
|
213
|
+
self.active_tasks[task_id] = task
|
|
214
|
+
|
|
215
|
+
# Resolve any pending webhook future
|
|
216
|
+
self.webhook.resolve_confirmation(task_id, "confirmed")
|
|
217
|
+
|
|
218
|
+
# Launch the call
|
|
219
|
+
bg = asyncio.create_task(self._run_call(task_id))
|
|
220
|
+
self._background_tasks[task_id] = bg
|
|
221
|
+
bg.add_done_callback(lambda _t: self._background_tasks.pop(task_id, None))
|
|
222
|
+
|
|
223
|
+
return {"task_id": task_id, "status": task["status"]}
|
|
224
|
+
|
|
225
|
+
# --- Incoming-call answer / reject -----------------------------
|
|
226
|
+
if action == "answer":
|
|
227
|
+
if req.get("system_prompt"):
|
|
228
|
+
task["system_prompt"] = req["system_prompt"]
|
|
229
|
+
if req.get("purpose"):
|
|
230
|
+
task["purpose"] = req["purpose"]
|
|
231
|
+
|
|
232
|
+
task["status"] = TaskStatus.ACTIVE.value
|
|
233
|
+
await store.update_task(task_id, {"status": task["status"]})
|
|
234
|
+
self.active_tasks[task_id] = task
|
|
235
|
+
|
|
236
|
+
# Resolve the pending incoming-call confirmation future
|
|
237
|
+
self.webhook.resolve_confirmation(task_id, "answer")
|
|
238
|
+
|
|
239
|
+
return {"task_id": task_id, "status": task["status"]}
|
|
240
|
+
|
|
241
|
+
if action == "reject":
|
|
242
|
+
task["status"] = TaskStatus.CANCELLED.value
|
|
243
|
+
task["result"] = CallResult.REJECTED.value
|
|
244
|
+
task["ended_reason"] = EndedReason.CANCELLED.value
|
|
245
|
+
task["ended_at"] = _now_iso()
|
|
246
|
+
await store.update_task(task_id, {
|
|
247
|
+
"status": task["status"],
|
|
248
|
+
"result": task["result"],
|
|
249
|
+
"ended_reason": task["ended_reason"],
|
|
250
|
+
"ended_at": task["ended_at"],
|
|
251
|
+
})
|
|
252
|
+
self.active_tasks.pop(task_id, None)
|
|
253
|
+
|
|
254
|
+
# Resolve the pending incoming-call confirmation future
|
|
255
|
+
self.webhook.resolve_confirmation(task_id, "reject")
|
|
256
|
+
|
|
257
|
+
return {"task_id": task_id, "status": task["status"]}
|
|
258
|
+
|
|
259
|
+
return {"error": f"unknown_action: {action}"}
|
|
260
|
+
|
|
261
|
+
async def hangup_task(self, task_id: str) -> dict[str, Any]:
|
|
262
|
+
"""Force-hangup a task that is currently in progress."""
|
|
263
|
+
task = self.active_tasks.get(task_id)
|
|
264
|
+
if not task:
|
|
265
|
+
task = await store.get_task(task_id)
|
|
266
|
+
if not task:
|
|
267
|
+
return {"error": "task_not_found"}
|
|
268
|
+
|
|
269
|
+
# Stop the conversation engine if running
|
|
270
|
+
engine = self._engines.pop(task_id, None)
|
|
271
|
+
if engine is not None:
|
|
272
|
+
try:
|
|
273
|
+
await engine.stop()
|
|
274
|
+
except Exception:
|
|
275
|
+
logger.exception("Error stopping conversation engine for %s", task_id)
|
|
276
|
+
|
|
277
|
+
# Instruct telephony to hang up
|
|
278
|
+
try:
|
|
279
|
+
if hasattr(self.bt_manager, "telephony") and self.bt_manager.telephony:
|
|
280
|
+
await self.bt_manager.telephony.hangup()
|
|
281
|
+
except Exception:
|
|
282
|
+
logger.exception("Error sending HFP hangup for %s", task_id)
|
|
283
|
+
|
|
284
|
+
# Cancel background task
|
|
285
|
+
bg = self._background_tasks.pop(task_id, None)
|
|
286
|
+
if bg and not bg.done():
|
|
287
|
+
bg.cancel()
|
|
288
|
+
|
|
289
|
+
task["status"] = TaskStatus.COMPLETED.value
|
|
290
|
+
task["result"] = CallResult.SUCCESS.value
|
|
291
|
+
task["ended_reason"] = EndedReason.AI_HANGUP.value
|
|
292
|
+
task["ended_at"] = _now_iso()
|
|
293
|
+
|
|
294
|
+
if task.get("started_at"):
|
|
295
|
+
try:
|
|
296
|
+
start = datetime.fromisoformat(task["started_at"])
|
|
297
|
+
end = datetime.fromisoformat(task["ended_at"])
|
|
298
|
+
task["duration_seconds"] = int((end - start).total_seconds())
|
|
299
|
+
except (ValueError, TypeError):
|
|
300
|
+
pass
|
|
301
|
+
|
|
302
|
+
await store.update_task(task_id, {
|
|
303
|
+
"status": task["status"],
|
|
304
|
+
"result": task["result"],
|
|
305
|
+
"ended_reason": task["ended_reason"],
|
|
306
|
+
"ended_at": task["ended_at"],
|
|
307
|
+
"duration_seconds": task.get("duration_seconds", 0),
|
|
308
|
+
})
|
|
309
|
+
self.active_tasks.pop(task_id, None)
|
|
310
|
+
|
|
311
|
+
# Notify via webhook
|
|
312
|
+
asyncio.create_task(self.webhook.notify_call_completed(task))
|
|
313
|
+
|
|
314
|
+
return {"task_id": task_id, "status": task["status"]}
|
|
315
|
+
|
|
316
|
+
async def send_message(self, task_id: str, message: str) -> dict[str, Any]:
|
|
317
|
+
"""Inject a caller message into an active conversation.
|
|
318
|
+
|
|
319
|
+
This also resolves any pending ``in_call_confirmation`` webhook
|
|
320
|
+
future so that the conversation engine can resume.
|
|
321
|
+
"""
|
|
322
|
+
task = self.active_tasks.get(task_id)
|
|
323
|
+
if not task:
|
|
324
|
+
return {"error": "task_not_found"}
|
|
325
|
+
|
|
326
|
+
# Resolve pending webhook confirmation (if any)
|
|
327
|
+
self.webhook.resolve_confirmation(task_id, message)
|
|
328
|
+
|
|
329
|
+
# Forward to the conversation engine
|
|
330
|
+
engine = self._engines.get(task_id)
|
|
331
|
+
if engine is not None:
|
|
332
|
+
try:
|
|
333
|
+
await engine.inject_message(message)
|
|
334
|
+
except Exception:
|
|
335
|
+
logger.exception("Error injecting message into engine for %s", task_id)
|
|
336
|
+
|
|
337
|
+
# Persist the message as a call event
|
|
338
|
+
call_dir_path = task.get("call_dir")
|
|
339
|
+
if call_dir_path:
|
|
340
|
+
from pathlib import Path
|
|
341
|
+
await identity.save_call_message(Path(call_dir_path), {
|
|
342
|
+
"role": "caller",
|
|
343
|
+
"text": message,
|
|
344
|
+
"timestamp": _now_iso(),
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
return {"task_id": task_id, "status": "message_sent"}
|
|
348
|
+
|
|
349
|
+
# ------------------------------------------------------------------
|
|
350
|
+
# Incoming call handling
|
|
351
|
+
# ------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
async def handle_incoming_call(self, phone_number: str) -> None:
|
|
354
|
+
"""Called by the telephony layer when an incoming call is detected.
|
|
355
|
+
|
|
356
|
+
Workflow
|
|
357
|
+
--------
|
|
358
|
+
1. Look up the caller in the contact list.
|
|
359
|
+
2. Create a task record with ``direction=incoming``.
|
|
360
|
+
3. Send an ``incoming_call`` webhook notification.
|
|
361
|
+
4. Wait for the caller to confirm (answer/reject) or timeout.
|
|
362
|
+
5. Answer or reject the call based on the response / default policy.
|
|
363
|
+
"""
|
|
364
|
+
# 1. Contact lookup
|
|
365
|
+
contact = await store.find_contact_by_phone(phone_number)
|
|
366
|
+
contact_name = contact["name"] if contact else None
|
|
367
|
+
contact_info: dict[str, Any] | None = None
|
|
368
|
+
if contact:
|
|
369
|
+
contact_info = {
|
|
370
|
+
"company": contact.get("company"),
|
|
371
|
+
"title": contact.get("title"),
|
|
372
|
+
"notes": contact.get("notes"),
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
# 2. Create task
|
|
376
|
+
task_id = str(uuid.uuid4())
|
|
377
|
+
task: dict[str, Any] = {
|
|
378
|
+
"task_id": task_id,
|
|
379
|
+
"phone_number": phone_number,
|
|
380
|
+
"contact_name": contact_name,
|
|
381
|
+
"contact_info": contact_info,
|
|
382
|
+
"user_phone": identity.get_user_phone(),
|
|
383
|
+
"purpose": "",
|
|
384
|
+
"system_prompt": "",
|
|
385
|
+
"webhook_url": cfg.get("webhook.default_url", ""),
|
|
386
|
+
"direction": CallDirection.INCOMING.value,
|
|
387
|
+
"status": TaskStatus.ALERTING.value,
|
|
388
|
+
"result": None,
|
|
389
|
+
"ended_reason": None,
|
|
390
|
+
"duration_seconds": 0,
|
|
391
|
+
"summary": "",
|
|
392
|
+
"has_recording": False,
|
|
393
|
+
"started_at": _now_iso(),
|
|
394
|
+
"ended_at": None,
|
|
395
|
+
}
|
|
396
|
+
await store.save_task(task)
|
|
397
|
+
self.active_tasks[task_id] = task
|
|
398
|
+
|
|
399
|
+
# 3. Webhook notification
|
|
400
|
+
confirmation = await self.webhook.notify_incoming_call(task)
|
|
401
|
+
|
|
402
|
+
# 4. Decide whether to answer
|
|
403
|
+
default_action = cfg.get("call.incoming_default_action", "reject")
|
|
404
|
+
action = "reject"
|
|
405
|
+
if confirmation and confirmation.get("response") == "answer":
|
|
406
|
+
action = "answer"
|
|
407
|
+
elif confirmation is None:
|
|
408
|
+
action = default_action
|
|
409
|
+
|
|
410
|
+
# 5. Answer or reject
|
|
411
|
+
if action == "answer":
|
|
412
|
+
task["status"] = TaskStatus.ACTIVE.value
|
|
413
|
+
await store.update_task(task_id, {"status": task["status"]})
|
|
414
|
+
|
|
415
|
+
try:
|
|
416
|
+
if hasattr(self.bt_manager, "telephony") and self.bt_manager.telephony:
|
|
417
|
+
await self.bt_manager.telephony.answer()
|
|
418
|
+
except Exception:
|
|
419
|
+
logger.exception("Error answering incoming call %s", task_id)
|
|
420
|
+
task["status"] = TaskStatus.FAILED.value
|
|
421
|
+
task["result"] = CallResult.ERROR.value
|
|
422
|
+
task["ended_reason"] = EndedReason.ERROR.value
|
|
423
|
+
task["ended_at"] = _now_iso()
|
|
424
|
+
await store.update_task(task_id, {
|
|
425
|
+
"status": task["status"],
|
|
426
|
+
"result": task["result"],
|
|
427
|
+
"ended_reason": task["ended_reason"],
|
|
428
|
+
"ended_at": task["ended_at"],
|
|
429
|
+
})
|
|
430
|
+
self.active_tasks.pop(task_id, None)
|
|
431
|
+
return
|
|
432
|
+
|
|
433
|
+
# Start the call pipeline in the background
|
|
434
|
+
bg = asyncio.create_task(self._run_active_call(task_id))
|
|
435
|
+
self._background_tasks[task_id] = bg
|
|
436
|
+
bg.add_done_callback(lambda _t: self._background_tasks.pop(task_id, None))
|
|
437
|
+
else:
|
|
438
|
+
# Reject the call
|
|
439
|
+
try:
|
|
440
|
+
if hasattr(self.bt_manager, "telephony") and self.bt_manager.telephony:
|
|
441
|
+
await self.bt_manager.telephony.hangup()
|
|
442
|
+
except Exception:
|
|
443
|
+
logger.exception("Error rejecting incoming call %s", task_id)
|
|
444
|
+
|
|
445
|
+
task["status"] = TaskStatus.CANCELLED.value
|
|
446
|
+
task["result"] = CallResult.REJECTED.value
|
|
447
|
+
task["ended_reason"] = EndedReason.CANCELLED.value
|
|
448
|
+
task["ended_at"] = _now_iso()
|
|
449
|
+
await store.update_task(task_id, {
|
|
450
|
+
"status": task["status"],
|
|
451
|
+
"result": task["result"],
|
|
452
|
+
"ended_reason": task["ended_reason"],
|
|
453
|
+
"ended_at": task["ended_at"],
|
|
454
|
+
})
|
|
455
|
+
self.active_tasks.pop(task_id, None)
|
|
456
|
+
|
|
457
|
+
# ------------------------------------------------------------------
|
|
458
|
+
# Internal — run a complete outgoing call
|
|
459
|
+
# ------------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
async def _run_call(self, task_id: str) -> None:
|
|
462
|
+
"""Background coroutine that runs a complete **outgoing** call.
|
|
463
|
+
|
|
464
|
+
Steps
|
|
465
|
+
-----
|
|
466
|
+
1. Verify Bluetooth is connected.
|
|
467
|
+
2. Dial the number via telephony.
|
|
468
|
+
3. Wait for the remote party to answer (poll call state).
|
|
469
|
+
4. Start the audio pipeline.
|
|
470
|
+
5. Create and start the ``ConversationEngine``.
|
|
471
|
+
6. Wait for completion (or max duration).
|
|
472
|
+
7. Generate a call summary via LLM.
|
|
473
|
+
8. Persist all records.
|
|
474
|
+
9. Send ``call_completed`` webhook.
|
|
475
|
+
"""
|
|
476
|
+
task = self.active_tasks.get(task_id)
|
|
477
|
+
if not task:
|
|
478
|
+
logger.error("_run_call: task %s not found", task_id)
|
|
479
|
+
return
|
|
480
|
+
|
|
481
|
+
try:
|
|
482
|
+
# 1. Bluetooth readiness check
|
|
483
|
+
task["status"] = TaskStatus.CONNECTING.value
|
|
484
|
+
await store.update_task(task_id, {"status": task["status"]})
|
|
485
|
+
|
|
486
|
+
if not self._is_bluetooth_connected():
|
|
487
|
+
logger.warning("Bluetooth not connected for task %s", task_id)
|
|
488
|
+
await self._fail_task(task_id, CallResult.ERROR, EndedReason.ERROR)
|
|
489
|
+
return
|
|
490
|
+
|
|
491
|
+
# 2. Dial the number
|
|
492
|
+
task["status"] = TaskStatus.DIALING.value
|
|
493
|
+
await store.update_task(task_id, {"status": task["status"]})
|
|
494
|
+
|
|
495
|
+
try:
|
|
496
|
+
if hasattr(self.bt_manager, "telephony") and self.bt_manager.telephony:
|
|
497
|
+
await self.bt_manager.telephony.dial(task["phone_number"])
|
|
498
|
+
else:
|
|
499
|
+
logger.error("Telephony subsystem unavailable for task %s", task_id)
|
|
500
|
+
await self._fail_task(task_id, CallResult.ERROR, EndedReason.ERROR)
|
|
501
|
+
return
|
|
502
|
+
except Exception:
|
|
503
|
+
logger.exception("Dial failed for task %s", task_id)
|
|
504
|
+
await self._fail_task(task_id, CallResult.ERROR, EndedReason.ERROR)
|
|
505
|
+
return
|
|
506
|
+
|
|
507
|
+
# 3. Wait for the remote party to answer
|
|
508
|
+
task["status"] = TaskStatus.ALERTING.value
|
|
509
|
+
await store.update_task(task_id, {"status": task["status"]})
|
|
510
|
+
|
|
511
|
+
call_answered = await self._wait_for_answer(task_id)
|
|
512
|
+
if not call_answered:
|
|
513
|
+
# _wait_for_answer already updated the task status
|
|
514
|
+
return
|
|
515
|
+
|
|
516
|
+
# 4–6. Run the active call phase
|
|
517
|
+
await self._run_active_call(task_id)
|
|
518
|
+
|
|
519
|
+
except asyncio.CancelledError:
|
|
520
|
+
logger.info("Call task %s was cancelled", task_id)
|
|
521
|
+
await self._fail_task(task_id, CallResult.ERROR, EndedReason.CANCELLED)
|
|
522
|
+
except Exception:
|
|
523
|
+
logger.exception("Unexpected error in _run_call for %s", task_id)
|
|
524
|
+
await self._fail_task(task_id, CallResult.ERROR, EndedReason.ERROR)
|
|
525
|
+
|
|
526
|
+
async def _run_active_call(self, task_id: str) -> None:
|
|
527
|
+
"""Run the audio + AI pipeline once a call is connected (active).
|
|
528
|
+
|
|
529
|
+
Shared between outgoing and incoming call flows.
|
|
530
|
+
"""
|
|
531
|
+
task = self.active_tasks.get(task_id)
|
|
532
|
+
if not task:
|
|
533
|
+
return
|
|
534
|
+
|
|
535
|
+
task["status"] = TaskStatus.ACTIVE.value
|
|
536
|
+
await store.update_task(task_id, {"status": task["status"]})
|
|
537
|
+
|
|
538
|
+
# Create identity-based call directory
|
|
539
|
+
user_phone = task.get("user_phone", "")
|
|
540
|
+
contact_phone = task.get("phone_number", "")
|
|
541
|
+
started_at = task.get("started_at", _now_iso())
|
|
542
|
+
if user_phone and contact_phone:
|
|
543
|
+
# Ensure contact profile.yaml exists (auto-creates on first call,
|
|
544
|
+
# pulls owner name from config user.owners if available)
|
|
545
|
+
identity.ensure_contact_profile(
|
|
546
|
+
user_phone, contact_phone,
|
|
547
|
+
contact_name=task.get("contact_name"),
|
|
548
|
+
)
|
|
549
|
+
call_path = identity.ensure_call_dir(user_phone, contact_phone, started_at)
|
|
550
|
+
task["call_dir"] = str(call_path)
|
|
551
|
+
await store.update_task(task_id, {"call_dir": str(call_path)})
|
|
552
|
+
else:
|
|
553
|
+
task["call_dir"] = None
|
|
554
|
+
|
|
555
|
+
engine = None
|
|
556
|
+
try:
|
|
557
|
+
# 4. Start audio pipeline
|
|
558
|
+
audio_stream = None
|
|
559
|
+
if hasattr(self.bt_manager, "audio") and self.bt_manager.audio:
|
|
560
|
+
audio_stream = await self.bt_manager.audio.start()
|
|
561
|
+
|
|
562
|
+
# 5. Create conversation engine
|
|
563
|
+
try:
|
|
564
|
+
from conversation.engine import ConversationEngine
|
|
565
|
+
engine = ConversationEngine(
|
|
566
|
+
audio_pipeline=audio_stream,
|
|
567
|
+
task_info=task,
|
|
568
|
+
webhook_client=self.webhook,
|
|
569
|
+
)
|
|
570
|
+
self._engines[task_id] = engine
|
|
571
|
+
await engine.start() # blocks until conversation ends
|
|
572
|
+
except ImportError:
|
|
573
|
+
logger.warning(
|
|
574
|
+
"ConversationEngine not available; call %s will run "
|
|
575
|
+
"without AI (telephony only)",
|
|
576
|
+
task_id,
|
|
577
|
+
)
|
|
578
|
+
except Exception:
|
|
579
|
+
logger.exception("Failed to start ConversationEngine for %s", task_id)
|
|
580
|
+
|
|
581
|
+
ended_reason = EndedReason.NORMAL
|
|
582
|
+
|
|
583
|
+
except asyncio.CancelledError:
|
|
584
|
+
ended_reason = EndedReason.CANCELLED
|
|
585
|
+
except Exception:
|
|
586
|
+
logger.exception("Error during active call %s", task_id)
|
|
587
|
+
ended_reason = EndedReason.ERROR
|
|
588
|
+
finally:
|
|
589
|
+
# Clean up engine
|
|
590
|
+
if engine:
|
|
591
|
+
try:
|
|
592
|
+
await engine.stop()
|
|
593
|
+
except Exception:
|
|
594
|
+
pass
|
|
595
|
+
self._engines.pop(task_id, None)
|
|
596
|
+
|
|
597
|
+
# Stop audio pipeline
|
|
598
|
+
if hasattr(self.bt_manager, "audio") and self.bt_manager.audio:
|
|
599
|
+
try:
|
|
600
|
+
await self.bt_manager.audio.stop()
|
|
601
|
+
except Exception:
|
|
602
|
+
pass
|
|
603
|
+
|
|
604
|
+
# 7. Generate summary
|
|
605
|
+
summary = ""
|
|
606
|
+
try:
|
|
607
|
+
call_dir_str = task.get("call_dir")
|
|
608
|
+
transcript = []
|
|
609
|
+
if call_dir_str:
|
|
610
|
+
from pathlib import Path
|
|
611
|
+
transcript = await identity.load_call_messages(Path(call_dir_str))
|
|
612
|
+
if transcript:
|
|
613
|
+
summary = await self._generate_summary(task, transcript)
|
|
614
|
+
except Exception:
|
|
615
|
+
logger.exception("Failed to generate summary for %s", task_id)
|
|
616
|
+
|
|
617
|
+
# 8. Persist final records
|
|
618
|
+
task["status"] = TaskStatus.COMPLETED.value
|
|
619
|
+
task["result"] = CallResult.SUCCESS.value
|
|
620
|
+
task["ended_reason"] = ended_reason.value if isinstance(ended_reason, EndedReason) else ended_reason
|
|
621
|
+
task["ended_at"] = _now_iso()
|
|
622
|
+
task["summary"] = summary
|
|
623
|
+
task["has_recording"] = cfg.get("audio.record_calls", True)
|
|
624
|
+
|
|
625
|
+
if task.get("started_at"):
|
|
626
|
+
try:
|
|
627
|
+
start = datetime.fromisoformat(task["started_at"])
|
|
628
|
+
end = datetime.fromisoformat(task["ended_at"])
|
|
629
|
+
task["duration_seconds"] = int((end - start).total_seconds())
|
|
630
|
+
except (ValueError, TypeError):
|
|
631
|
+
pass
|
|
632
|
+
|
|
633
|
+
# Save session.json and summary.md to identity directory
|
|
634
|
+
call_dir_path = task.get("call_dir")
|
|
635
|
+
if call_dir_path:
|
|
636
|
+
from pathlib import Path
|
|
637
|
+
cd = Path(call_dir_path)
|
|
638
|
+
try:
|
|
639
|
+
session_info = {
|
|
640
|
+
"task_id": task_id,
|
|
641
|
+
"direction": task.get("direction", ""),
|
|
642
|
+
"phone_number": task.get("phone_number", ""),
|
|
643
|
+
"contact_name": task.get("contact_name", ""),
|
|
644
|
+
"started_at": task.get("started_at", ""),
|
|
645
|
+
"ended_at": task.get("ended_at", ""),
|
|
646
|
+
"duration_seconds": task.get("duration_seconds", 0),
|
|
647
|
+
"result": task.get("result", ""),
|
|
648
|
+
"llm_provider": cfg.get("llm.active_provider", ""),
|
|
649
|
+
"llm_model": cfg.get(f"llm.providers.{cfg.get('llm.active_provider', 'openai')}.model", ""),
|
|
650
|
+
"asr_provider": cfg.get("asr.provider", ""),
|
|
651
|
+
"tts_provider": cfg.get("tts.provider", ""),
|
|
652
|
+
"system_prompt": task.get("system_prompt", ""),
|
|
653
|
+
}
|
|
654
|
+
await identity.save_session_info(cd, session_info)
|
|
655
|
+
except Exception:
|
|
656
|
+
logger.exception("Failed to save session info for %s", task_id)
|
|
657
|
+
|
|
658
|
+
if summary:
|
|
659
|
+
try:
|
|
660
|
+
await identity.save_call_summary(cd, summary)
|
|
661
|
+
except Exception:
|
|
662
|
+
logger.exception("Failed to save identity summary for %s", task_id)
|
|
663
|
+
|
|
664
|
+
await store.update_task(task_id, {
|
|
665
|
+
"status": task["status"],
|
|
666
|
+
"result": task["result"],
|
|
667
|
+
"ended_reason": task["ended_reason"],
|
|
668
|
+
"ended_at": task["ended_at"],
|
|
669
|
+
"duration_seconds": task.get("duration_seconds", 0),
|
|
670
|
+
"summary": summary,
|
|
671
|
+
"has_recording": task["has_recording"],
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
self.active_tasks.pop(task_id, None)
|
|
675
|
+
|
|
676
|
+
# 9. Webhook notification
|
|
677
|
+
call_dir_str = task.get("call_dir")
|
|
678
|
+
if call_dir_str:
|
|
679
|
+
from pathlib import Path
|
|
680
|
+
task["transcript"] = await identity.load_call_messages(Path(call_dir_str))
|
|
681
|
+
else:
|
|
682
|
+
task["transcript"] = []
|
|
683
|
+
await self.webhook.notify_call_completed(task)
|
|
684
|
+
|
|
685
|
+
# ------------------------------------------------------------------
|
|
686
|
+
# Internal — helpers
|
|
687
|
+
# ------------------------------------------------------------------
|
|
688
|
+
|
|
689
|
+
def _is_bluetooth_connected(self) -> bool:
|
|
690
|
+
"""Check whether the Bluetooth HFP connection is up."""
|
|
691
|
+
if hasattr(self.bt_manager, "connected"):
|
|
692
|
+
return bool(self.bt_manager.connected)
|
|
693
|
+
return True # assume connected if we cannot check
|
|
694
|
+
|
|
695
|
+
async def _wait_for_answer(self, task_id: str, poll_interval: float = 0.5) -> bool:
|
|
696
|
+
"""Poll the telephony layer until the call is answered or fails.
|
|
697
|
+
|
|
698
|
+
Updates the task status on failure and returns ``False`` in that case.
|
|
699
|
+
"""
|
|
700
|
+
max_ring_seconds = 60
|
|
701
|
+
elapsed = 0.0
|
|
702
|
+
|
|
703
|
+
while elapsed < max_ring_seconds:
|
|
704
|
+
if not hasattr(self.bt_manager, "telephony") or not self.bt_manager.telephony:
|
|
705
|
+
await asyncio.sleep(poll_interval)
|
|
706
|
+
elapsed += poll_interval
|
|
707
|
+
continue
|
|
708
|
+
|
|
709
|
+
state = None
|
|
710
|
+
try:
|
|
711
|
+
state = await self.bt_manager.telephony.get_call_state()
|
|
712
|
+
except Exception:
|
|
713
|
+
pass
|
|
714
|
+
|
|
715
|
+
if state == "active":
|
|
716
|
+
return True
|
|
717
|
+
if state in ("disconnected", "idle", None) and elapsed > 2:
|
|
718
|
+
# Call ended before being answered
|
|
719
|
+
await self._fail_task(task_id, CallResult.NO_ANSWER, EndedReason.REMOTE_HANGUP)
|
|
720
|
+
return False
|
|
721
|
+
if state == "busy":
|
|
722
|
+
await self._fail_task(task_id, CallResult.BUSY, EndedReason.REMOTE_HANGUP)
|
|
723
|
+
return False
|
|
724
|
+
|
|
725
|
+
await asyncio.sleep(poll_interval)
|
|
726
|
+
elapsed += poll_interval
|
|
727
|
+
|
|
728
|
+
# Timed out — no answer
|
|
729
|
+
await self._fail_task(task_id, CallResult.NO_ANSWER, EndedReason.TIMEOUT)
|
|
730
|
+
return False
|
|
731
|
+
|
|
732
|
+
async def _fail_task(
|
|
733
|
+
self,
|
|
734
|
+
task_id: str,
|
|
735
|
+
result: CallResult,
|
|
736
|
+
ended_reason: EndedReason,
|
|
737
|
+
) -> None:
|
|
738
|
+
"""Mark a task as failed and persist the update."""
|
|
739
|
+
task = self.active_tasks.get(task_id)
|
|
740
|
+
if not task:
|
|
741
|
+
task = await store.get_task(task_id) or {}
|
|
742
|
+
|
|
743
|
+
task["status"] = TaskStatus.FAILED.value
|
|
744
|
+
task["result"] = result.value
|
|
745
|
+
task["ended_reason"] = ended_reason.value
|
|
746
|
+
task["ended_at"] = _now_iso()
|
|
747
|
+
|
|
748
|
+
await store.update_task(task_id, {
|
|
749
|
+
"status": task["status"],
|
|
750
|
+
"result": task["result"],
|
|
751
|
+
"ended_reason": task["ended_reason"],
|
|
752
|
+
"ended_at": task["ended_at"],
|
|
753
|
+
})
|
|
754
|
+
|
|
755
|
+
self.active_tasks.pop(task_id, None)
|
|
756
|
+
|
|
757
|
+
# Notify webhook
|
|
758
|
+
asyncio.create_task(self.webhook.notify_call_completed(task))
|
|
759
|
+
|
|
760
|
+
async def _find_contact_for_purpose(self, purpose: str) -> dict[str, Any] | None:
|
|
761
|
+
"""Use the configured LLM to find the best contact match for *purpose*.
|
|
762
|
+
|
|
763
|
+
Loads all contacts, builds a prompt asking the LLM to pick the best
|
|
764
|
+
match, and returns the selected contact dict (with an extra
|
|
765
|
+
``confidence`` key) or ``None`` if no reasonable match is found.
|
|
766
|
+
"""
|
|
767
|
+
import json
|
|
768
|
+
|
|
769
|
+
contacts, _ = await store.list_contacts(page=1, page_size=500)
|
|
770
|
+
if not contacts:
|
|
771
|
+
logger.info("No contacts in address book; cannot match for purpose")
|
|
772
|
+
return None
|
|
773
|
+
|
|
774
|
+
# Build a compact contact list for the LLM
|
|
775
|
+
contacts_text = "\n".join(
|
|
776
|
+
f"- id={c.get('id')} name={c.get('name')} phone={c.get('phone')} "
|
|
777
|
+
f"company={c.get('company', '')} title={c.get('title', '')} "
|
|
778
|
+
f"notes={c.get('notes', '')} tags={c.get('tags', [])}"
|
|
779
|
+
for c in contacts
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
prompt = (
|
|
783
|
+
"You are a contact-matching assistant. Given the following purpose "
|
|
784
|
+
"and a list of contacts, select the single best matching contact.\n\n"
|
|
785
|
+
f"Purpose: {purpose}\n\n"
|
|
786
|
+
f"Contacts:\n{contacts_text}\n\n"
|
|
787
|
+
"Respond with ONLY a JSON object: "
|
|
788
|
+
'{"id": "<contact_id>", "confidence": <0.0-1.0>}\n'
|
|
789
|
+
"If no contact is a reasonable match, respond: "
|
|
790
|
+
'{"id": null, "confidence": 0}'
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
try:
|
|
794
|
+
from conversation.llm import create_llm_provider
|
|
795
|
+
|
|
796
|
+
provider = create_llm_provider()
|
|
797
|
+
result = await provider.generate(
|
|
798
|
+
messages=[{"role": "user", "content": prompt}],
|
|
799
|
+
)
|
|
800
|
+
response_text = result.get("content", "")
|
|
801
|
+
|
|
802
|
+
result_json = json.loads(response_text)
|
|
803
|
+
contact_id = result_json.get("id")
|
|
804
|
+
confidence = result_json.get("confidence", 0)
|
|
805
|
+
|
|
806
|
+
if not contact_id or confidence < 0.3:
|
|
807
|
+
logger.info("LLM contact match confidence too low (%.2f)", confidence)
|
|
808
|
+
return None
|
|
809
|
+
|
|
810
|
+
matched = await store.get_contact(contact_id)
|
|
811
|
+
if matched:
|
|
812
|
+
matched["confidence"] = confidence
|
|
813
|
+
return matched
|
|
814
|
+
|
|
815
|
+
except ImportError:
|
|
816
|
+
logger.warning("LLM module not available; falling back to text search")
|
|
817
|
+
# Fallback: simple keyword search
|
|
818
|
+
results = await store.search_contacts(purpose)
|
|
819
|
+
if results:
|
|
820
|
+
results[0]["confidence"] = 0.5
|
|
821
|
+
return results[0]
|
|
822
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
823
|
+
logger.exception("Failed to parse LLM contact match response")
|
|
824
|
+
except Exception:
|
|
825
|
+
logger.exception("Error during LLM contact matching")
|
|
826
|
+
|
|
827
|
+
return None
|
|
828
|
+
|
|
829
|
+
async def _generate_summary(
|
|
830
|
+
self,
|
|
831
|
+
task: dict[str, Any],
|
|
832
|
+
transcript: list[dict[str, Any]],
|
|
833
|
+
) -> str:
|
|
834
|
+
"""Use the LLM to generate a concise call summary in Markdown."""
|
|
835
|
+
transcript_text = "\n".join(
|
|
836
|
+
f"[{entry.get('role', '?')}] {entry.get('text', '')}"
|
|
837
|
+
for entry in transcript
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
prompt = (
|
|
841
|
+
"Summarize the following phone call transcript concisely. "
|
|
842
|
+
"Include: key topics discussed, decisions made, action items, "
|
|
843
|
+
"and the overall outcome.\n\n"
|
|
844
|
+
f"Call purpose: {task.get('purpose', 'N/A')}\n"
|
|
845
|
+
f"Contact: {task.get('contact_name', 'Unknown')}\n"
|
|
846
|
+
f"Direction: {task.get('direction', 'unknown')}\n\n"
|
|
847
|
+
f"Transcript:\n{transcript_text}\n\n"
|
|
848
|
+
"Respond in the same language as the transcript."
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
try:
|
|
852
|
+
from conversation.llm import create_llm_provider
|
|
853
|
+
|
|
854
|
+
provider = create_llm_provider()
|
|
855
|
+
result = await provider.generate(
|
|
856
|
+
messages=[{"role": "user", "content": prompt}],
|
|
857
|
+
)
|
|
858
|
+
return result.get("content", "")
|
|
859
|
+
except ImportError:
|
|
860
|
+
logger.warning("LLM module not available; cannot generate summary")
|
|
861
|
+
return ""
|
|
862
|
+
except Exception:
|
|
863
|
+
logger.exception("Failed to generate call summary")
|
|
864
|
+
return ""
|