-
Notifications
You must be signed in to change notification settings - Fork 280
Expand file tree
/
Copy pathmain.py
More file actions
375 lines (314 loc) · 14.5 KB
/
main.py
File metadata and controls
375 lines (314 loc) · 14.5 KB
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
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
import json
import re
from datetime import datetime, timedelta, timezone
from src.agent.capability import MatchingCapability
from src.main import AgentWorker
from src.agent.capability_worker import CapabilityWorker
EXIT_WORDS = {"stop", "exit", "quit", "done", "cancel", "end"}
DATA_FILE = "dev_standup_db.json" # persistent store
LATEST_FILE = "latest_standup.md" # latest recap export
def is_exit(text: str) -> bool:
if not text:
return False
t = text.strip().lower()
# Remove punctuation, collapse spaces
t = re.sub(r"[^\w\s]", " ", t)
t = re.sub(r"\s+", " ", t).strip()
if not t:
return False
# Exact match
if t in EXIT_WORDS:
return True
# First token match (e.g., "done please", "stop now")
first = t.split(" ", 1)[0]
if first in EXIT_WORDS:
return True
# Common phrase patterns
if "i am done" in t or "i'm done" in t or "please stop" in t:
return True
return False
def clean_json_fences(s: str) -> str:
if not s:
return ""
return s.replace("```json", "").replace("```", "").strip()
def today_key() -> str:
return datetime.now(timezone.utc).date().isoformat()
def yesterday_key() -> str:
return (datetime.now(timezone.utc).date() - timedelta(days=1)).isoformat()
class DevStandupAssistantCapability(MatchingCapability):
worker: AgentWorker = None
capability_worker: CapabilityWorker = None
# Do not change following tag of register capability
# {{register capability}}
# ---------------------------
# Storage helpers
# ---------------------------
async def load_db(self) -> dict:
cw = self.capability_worker
try:
if not await cw.check_if_file_exists(DATA_FILE, False):
return {"days": {}}
raw = await cw.read_file(DATA_FILE, False)
return json.loads(raw) if raw else {"days": {}}
except Exception as e:
self.worker.editor_logging_handler.error(f"Failed to load DB: {e}")
return {"days": {}}
async def save_db(self, db: dict) -> None:
cw = self.capability_worker
try:
if await cw.check_if_file_exists(DATA_FILE, False):
await cw.delete_file(DATA_FILE, False)
await cw.write_file(DATA_FILE, json.dumps(db), False)
except Exception as e:
self.worker.editor_logging_handler.error(f"Failed to save DB: {e}")
async def save_latest_md(self, md: str) -> None:
cw = self.capability_worker
try:
if await cw.check_if_file_exists(LATEST_FILE, False):
await cw.delete_file(LATEST_FILE, False)
await cw.write_file(LATEST_FILE, md, False)
except Exception as e:
self.worker.editor_logging_handler.error(f"Failed to save latest MD: {e}")
def ensure_day(self, db: dict, day_key: str) -> dict:
db.setdefault("days", {})
if day_key not in db["days"]:
db["days"][day_key] = {
"raw_text": "",
"updates": [], # list of {"ts_utc":..., "text":...}
"summary": "",
"created_at_utc": datetime.now(timezone.utc).isoformat(),
"updated_at_utc": datetime.now(timezone.utc).isoformat(),
}
return db["days"][day_key]
# ---------------------------
# LLM helpers
# ---------------------------
def llm_make_summary(self, day_key: str, raw_text: str, updates: list) -> str:
update_block = "\n".join([f"- {u.get('text','')}" for u in (updates or []) if u.get("text")])
prompt = (
"You are a developer standup assistant.\n"
"Write a meeting-ready standup recap in 2–4 sentences.\n"
"Keep it spoken-friendly. Mention blockers briefly if present.\n"
"Do not invent anything not in the input.\n\n"
f"Date: {day_key}\n"
f"Standup (raw):\n{raw_text}\n\n"
f"Additional updates:\n{update_block if update_block else '(none)'}"
)
return self.capability_worker.text_to_text_response(prompt)
def llm_detect_missing_followup(self, raw_text: str) -> str:
"""
Returns ONE short follow-up question tailored to what's missing.
Examples: blockers missing, next steps missing, unclear scope.
"""
prompt = (
"You are a standup assistant.\n"
"Given the user's raw standup message, decide ONE helpful follow-up question to ask.\n"
"Goals:\n"
"- If blockers aren't mentioned, ask about blockers.\n"
"- If next steps aren't mentioned, ask what they plan next.\n"
"- If it's vague, ask for one concrete detail.\n"
"Return ONLY the question, no extra text.\n\n"
f"Raw standup:\n{raw_text}"
)
q = self.capability_worker.text_to_text_response(prompt).strip()
return q if q else "Anything else you want to add?"
def llm_answer_query(self, day_key: str, day_obj: dict, user_question: str) -> str:
"""
Answers user question using stored raw_text + updates.
This is the core 'LLM retrieval' behavior (no rigid schema).
"""
raw_text = day_obj.get("raw_text", "")
updates = day_obj.get("updates", [])
update_block = "\n".join([f"- {u.get('text','')}" for u in (updates or []) if u.get("text")])
prompt = (
"You are a developer standup assistant.\n"
"Answer the user's question using ONLY the provided standup notes for that day.\n"
"If the info isn't present, say you don't have it.\n"
"Keep the answer short and spoken-friendly.\n\n"
f"Date: {day_key}\n"
f"Standup (raw):\n{raw_text}\n\n"
f"Additional updates:\n{update_block if update_block else '(none)'}\n\n"
f"User question: {user_question}"
)
return self.capability_worker.text_to_text_response(prompt)
def llm_route_intent(self, user_input: str) -> dict:
"""
Intent routing with minimal structure.
"""
prompt = (
"Classify the user's request for a developer standup assistant.\n"
"Return ONLY valid JSON.\n"
'Schema: {"intent":"...", "text":""}\n'
"Allowed intents:\n"
'- "new_standup"\n'
'- "update_today"\n'
'- "read_today"\n'
'- "read_yesterday"\n'
'- "recap_today"\n'
'- "recap_yesterday"\n'
'- "ask_today"\n'
'- "ask_yesterday"\n'
'- "clear_today"\n'
'- "help"\n'
'- "unknown"\n'
"Rules:\n"
"- If user is asking a specific question about today's content (blockers/projects/plan), use ask_today.\n"
"- If user says update today / add to today, use update_today with text field.\n"
"Examples:\n"
'- "new standup" -> {"intent":"new_standup","text":""}\n'
'- "update today: fixed staging, waiting on QA" -> {"intent":"update_today","text":"fixed staging, waiting on QA"}\n'
'- "read today" -> {"intent":"read_today","text":""}\n'
'- "recap today" -> {"intent":"recap_today","text":""}\n'
'- "what are my blockers today" -> {"intent":"ask_today","text":"what are my blockers today"}\n'
'- "what did I say about payments today" -> {"intent":"ask_today","text":"what did I say about payments today"}\n'
'- "clear today" -> {"intent":"clear_today","text":""}\n\n'
f"User: {user_input}"
)
raw = self.capability_worker.text_to_text_response(prompt)
cleaned = clean_json_fences(raw)
try:
obj = json.loads(cleaned)
return {"intent": (obj.get("intent") or "unknown").strip(), "text": (obj.get("text") or "").strip()}
except Exception:
return {"intent": "unknown", "text": ""}
# ---------------------------
# Actions
# ---------------------------
async def do_new_standup(self, db: dict) -> dict:
cw = self.capability_worker
day_key = today_key()
day = self.ensure_day(db, day_key)
await cw.speak("Okay — tell me your standup in one go.")
raw_text = await cw.user_response()
if is_exit(raw_text):
return db
day["raw_text"] = raw_text.strip()
day["updates"] = []
day["updated_at_utc"] = datetime.now(timezone.utc).isoformat()
# Tailored follow-up
follow_q = self.llm_detect_missing_followup(day["raw_text"])
await cw.speak(follow_q)
more = await cw.user_response()
if more and not is_exit(more) and more.strip().lower() not in {"no", "nope", "nothing", "that's all"}:
day["updates"].append({"ts_utc": datetime.now(timezone.utc).isoformat(), "text": more.strip()})
day["updated_at_utc"] = datetime.now(timezone.utc).isoformat()
# Create summary + export
day["summary"] = self.llm_make_summary(day_key, day["raw_text"], day["updates"])
await self.save_db(db)
md = f"*Standup — {day_key}*\n\n{day['summary']}\n"
await self.save_latest_md(md)
await cw.speak("Saved. If you want, say read today, recap today, or ask me something like blockers or a project.")
return db
async def do_update_today(self, db: dict, text: str) -> dict:
cw = self.capability_worker
day_key = today_key()
day = self.ensure_day(db, day_key)
if not text:
await cw.speak("What do you want to add to today?")
text = await cw.user_response()
if is_exit(text):
return db
day["updates"].append({"ts_utc": datetime.now(timezone.utc).isoformat(), "text": text.strip()})
day["updated_at_utc"] = datetime.now(timezone.utc).isoformat()
# Refresh summary after update
if day.get("raw_text"):
day["summary"] = self.llm_make_summary(day_key, day["raw_text"], day["updates"])
await self.save_db(db)
await cw.speak("Got it — updated today’s standup.")
return db
async def do_read_day(self, db: dict, day_key: str) -> None:
cw = self.capability_worker
day = db.get("days", {}).get(day_key)
if not day or (not day.get("raw_text") and not day.get("updates")):
await cw.speak("I don’t have anything saved for that day yet.")
return
# Natural read-back: speak summary if we have it; else speak raw in a compact way
if day.get("summary"):
await cw.speak(f"Here’s what you saved for {day_key}.")
await cw.speak(day["summary"])
return
# Fallback
await cw.speak(f"Here’s what you saved for {day_key}.")
await cw.speak(day.get("raw_text", ""))
async def do_recap_day(self, db: dict, day_key: str) -> None:
cw = self.capability_worker
day = db.get("days", {}).get(day_key)
if not day or (not day.get("raw_text") and not day.get("updates")):
await cw.speak("I don’t have anything saved for that day yet.")
return
# Ensure summary exists
if not day.get("summary") and day.get("raw_text"):
day["summary"] = self.llm_make_summary(day_key, day["raw_text"], day.get("updates", []))
await self.save_db(db)
await cw.speak(day.get("summary") or "I have notes saved, but I couldn’t generate a recap right now.")
async def do_ask_day(self, db: dict, day_key: str, question: str) -> None:
cw = self.capability_worker
day = db.get("days", {}).get(day_key)
if not day or (not day.get("raw_text") and not day.get("updates")):
await cw.speak("I don’t have anything saved for that day yet.")
return
answer = self.llm_answer_query(day_key, day, question)
await cw.speak(answer)
async def do_clear_today(self, db: dict) -> dict:
cw = self.capability_worker
dk = today_key()
if dk not in db.get("days", {}):
await cw.speak("Nothing to clear for today.")
return db
confirmed = await cw.run_confirmation_loop("Clear today’s saved standup?")
if not confirmed:
await cw.speak("Okay, keeping it.")
return db
db["days"].pop(dk, None)
await self.save_db(db)
await cw.speak("Cleared today’s standup.")
return db
async def help(self) -> None:
await self.capability_worker.speak(
"Try: new standup, update today, read today, recap today, or ask: what are my blockers today."
)
# ---------------------------
# Main loop
# ---------------------------
async def run(self):
cw = self.capability_worker
db = await self.load_db()
await cw.speak("Standup assistant ready. Say new standup, read today, recap today, or update today.")
while True:
user = await cw.user_response()
if is_exit(user):
break
route = self.llm_route_intent(user)
intent = route.get("intent", "unknown")
text = route.get("text", "")
if intent == "new_standup":
db = await self.do_new_standup(db)
elif intent == "update_today":
db = await self.do_update_today(db, text)
elif intent == "read_today":
await self.do_read_day(db, today_key())
elif intent == "read_yesterday":
await self.do_read_day(db, yesterday_key())
elif intent == "recap_today":
await self.do_recap_day(db, today_key())
elif intent == "recap_yesterday":
await self.do_recap_day(db, yesterday_key())
elif intent == "ask_today":
q = text or user
await self.do_ask_day(db, today_key(), q)
elif intent == "ask_yesterday":
q = text or user
await self.do_ask_day(db, yesterday_key(), q)
elif intent == "clear_today":
db = await self.do_clear_today(db)
elif intent == "help":
await self.help()
else:
await cw.speak("Try: new standup, read today, recap today, update today, or help.")
await cw.speak("What next?")
await cw.speak("Alright, exiting standup.")
cw.resume_normal_flow()
def call(self, worker: AgentWorker):
self.worker = worker
self.capability_worker = CapabilityWorker(self.worker)
self.worker.session_tasks.create(self.run())