chorus-cli 0.4.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/tools/qa.py ADDED
@@ -0,0 +1,528 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ QA Chat — Multi-turn QA conversation tool powered by Claude + pluggable messengers.
4
+
5
+ Supports Teams (Playwright browser automation) and Slack (API-based).
6
+
7
+ Usage:
8
+ echo '{"issue_number":4464,...}' | qa.py --messenger teams --auth teams-auth.json --qa "zuki dlomo"
9
+ echo '{"issue_number":4464,...}' | qa.py --messenger slack --qa "zuki dlomo"
10
+
11
+ Input (JSON on stdin):
12
+ issue_number, issue_title, issue_body, enriched_questions
13
+
14
+ Output (JSON on stdout):
15
+ completed, requirements, conversation_rounds, raw_responses
16
+
17
+ Progress is logged to stderr.
18
+ """
19
+
20
+ import anthropic
21
+ import argparse
22
+ import json
23
+ import os
24
+ import sys
25
+ import time
26
+ from abc import ABC, abstractmethod
27
+
28
+ # ── Config ──────────────────────────────────────────────────────────────────
29
+
30
+ MODEL = os.environ.get("QA_MODEL", "claude-sonnet-4-5-20250929")
31
+ MAX_ROUNDS = int(os.environ.get("QA_MAX_ROUNDS", "5"))
32
+ POLL_INTERVAL = int(os.environ.get("QA_POLL_INTERVAL", "60")) # seconds
33
+ POLL_TIMEOUT = int(os.environ.get("QA_POLL_TIMEOUT", "1800")) # 30 min
34
+
35
+ def is_token_limit_error(err):
36
+ msg = str(err)
37
+ return "token limit exceeded" in msg or "rate_limit_error" in msg
38
+
39
+ # ── Logging (stderr only) ──────────────────────────────────────────────────
40
+
41
+ def log(msg):
42
+ print(msg, file=sys.stderr, flush=True)
43
+
44
+ # ── Messenger Interface ────────────────────────────────────────────────────
45
+
46
+ class Messenger(ABC):
47
+ @abstractmethod
48
+ def connect(self):
49
+ ...
50
+
51
+ @abstractmethod
52
+ def navigate_to_chat(self, name: str):
53
+ ...
54
+
55
+ @abstractmethod
56
+ def send_message(self, text: str):
57
+ ...
58
+
59
+ @abstractmethod
60
+ def wait_for_response(self) -> str:
61
+ ...
62
+
63
+ @abstractmethod
64
+ def close(self):
65
+ ...
66
+
67
+ # ── Teams Messenger (Playwright) ──────────────────────────────────────────
68
+
69
+ class TeamsMessenger(Messenger):
70
+ def __init__(self, auth_path):
71
+ self.auth_path = auth_path
72
+ self.browser = None
73
+ self.page = None
74
+
75
+ def connect(self):
76
+ from playwright.sync_api import sync_playwright
77
+ self._pw = sync_playwright().start()
78
+ self.browser = self._pw.firefox.launch(headless=False)
79
+ context = self.browser.new_context(storage_state=self.auth_path)
80
+ self.page = context.new_page()
81
+ self.page.set_default_timeout(60000)
82
+ log(" Loading Teams...")
83
+ self.page.goto("https://teams.microsoft.com", wait_until="domcontentloaded", timeout=120000)
84
+ self.page.wait_for_timeout(60000)
85
+ log(" Teams loaded")
86
+
87
+ def navigate_to_chat(self, name):
88
+ page = self.page
89
+ log(f" Navigating to Chat section...")
90
+ chat_btn = page.locator('[data-tid="app-bar-Chat"]')
91
+ if chat_btn.count() == 0:
92
+ chat_btn = page.locator('button:has-text("Chat"), [aria-label="Chat"]').first
93
+ chat_btn.click()
94
+ page.wait_for_timeout(30000)
95
+
96
+ log(f" Searching for {name}...")
97
+ search = page.locator('#ms-searchux-input')
98
+ search.click()
99
+ page.wait_for_timeout(500)
100
+ search.fill(name)
101
+ page.wait_for_timeout(3000)
102
+
103
+ page.keyboard.press("ArrowDown")
104
+ page.wait_for_timeout(500)
105
+ page.keyboard.press("ArrowDown")
106
+ page.wait_for_timeout(500)
107
+ page.keyboard.press("Enter")
108
+
109
+ page.wait_for_timeout(5000)
110
+ log(f" Opened chat with {name}")
111
+
112
+ def send_message(self, text):
113
+ page = self.page
114
+ try:
115
+ compose = page.locator('[data-tid="ckeditor"]').first
116
+ compose.fill(text)
117
+ page.keyboard.press("Enter")
118
+ except Exception:
119
+ compose = page.locator('[role="textbox"]').first
120
+ compose.fill(text)
121
+ page.keyboard.press("Enter")
122
+ page.wait_for_timeout(1000)
123
+
124
+ def _snapshot(self):
125
+ page = self.page
126
+ msgs = page.locator('[data-tid="chat-pane-message"]')
127
+ count = msgs.count()
128
+ if count > 0:
129
+ last = msgs.last
130
+ mid = last.get_attribute("data-mid") or ""
131
+ content = last.locator("[data-message-content]")
132
+ text = (content.text_content() or "").strip() if content.count() > 0 else ""
133
+ return count, mid, text
134
+ return 0, "", ""
135
+
136
+ def wait_for_response(self):
137
+ before_count, before_mid, before_text = self._snapshot()
138
+ log(f" Tracking messages (count={before_count}, last_mid={before_mid})")
139
+
140
+ elapsed = 0
141
+ while elapsed < POLL_TIMEOUT:
142
+ self.page.wait_for_timeout(POLL_INTERVAL * 1000)
143
+ elapsed += POLL_INTERVAL
144
+
145
+ now_count, now_mid, now_text = self._snapshot()
146
+
147
+ if now_mid and now_mid != before_mid:
148
+ log(f" New message detected (mid={now_mid})")
149
+ return now_text
150
+
151
+ log(f" Waiting for QA response... ({elapsed}s, msgs={now_count})")
152
+
153
+ raise TimeoutError(f"No QA response after {POLL_TIMEOUT}s")
154
+
155
+ def close(self):
156
+ if self.browser:
157
+ self.browser.close()
158
+ if hasattr(self, '_pw'):
159
+ self._pw.stop()
160
+
161
+ # ── Slack Messenger (API) ─────────────────────────────────────────────────
162
+
163
+ class SlackMessenger(Messenger):
164
+ def __init__(self, bot_token):
165
+ self.bot_token = bot_token
166
+ self.client = None
167
+ self.channel_id = None
168
+ self.bot_user_id = None
169
+ self.last_ts = None
170
+
171
+ def connect(self):
172
+ from slack_sdk import WebClient
173
+ from slack_sdk.errors import SlackApiError
174
+
175
+ self.client = WebClient(token=self.bot_token)
176
+ auth = self.client.auth_test()
177
+ self.bot_user_id = auth["user_id"]
178
+ log(f" Slack connected as {auth['user']} (team: {auth['team']})")
179
+
180
+ def navigate_to_chat(self, name):
181
+ log(f" Finding Slack user '{name}'...")
182
+ cursor = None
183
+ target_user_id = None
184
+
185
+ while True:
186
+ kwargs = {"limit": 200}
187
+ if cursor:
188
+ kwargs["cursor"] = cursor
189
+ resp = self.client.users_list(**kwargs)
190
+
191
+ for member in resp["members"]:
192
+ if member.get("deleted") or member.get("is_bot"):
193
+ continue
194
+ profile = member.get("profile", {})
195
+ display = profile.get("display_name", "") or ""
196
+ real = profile.get("real_name", "") or ""
197
+ if name.lower() in display.lower() or name.lower() in real.lower():
198
+ target_user_id = member["id"]
199
+ log(f" Matched user: {real} ({display}) — {target_user_id}")
200
+ break
201
+
202
+ if target_user_id:
203
+ break
204
+
205
+ cursor = resp.get("response_metadata", {}).get("next_cursor")
206
+ if not cursor:
207
+ break
208
+
209
+ if not target_user_id:
210
+ raise RuntimeError(f"Could not find Slack user matching '{name}'")
211
+
212
+ dm = self.client.conversations_open(users=[target_user_id])
213
+ self.channel_id = dm["channel"]["id"]
214
+ log(f" Opened DM channel {self.channel_id}")
215
+
216
+ def send_message(self, text):
217
+ resp = self.client.chat_postMessage(channel=self.channel_id, text=text)
218
+ self.last_ts = resp["ts"]
219
+ log(f" Message sent (ts={self.last_ts})")
220
+
221
+ def wait_for_response(self):
222
+ log(f" Polling for Slack response (interval={POLL_INTERVAL}s, timeout={POLL_TIMEOUT}s)...")
223
+ elapsed = 0
224
+ while elapsed < POLL_TIMEOUT:
225
+ time.sleep(POLL_INTERVAL)
226
+ elapsed += POLL_INTERVAL
227
+
228
+ resp = self.client.conversations_history(
229
+ channel=self.channel_id,
230
+ oldest=self.last_ts,
231
+ limit=10,
232
+ )
233
+
234
+ for msg in resp.get("messages", []):
235
+ # Skip our own bot messages
236
+ if msg.get("user") == self.bot_user_id:
237
+ continue
238
+ if msg.get("bot_id"):
239
+ continue
240
+ # Found a human reply
241
+ text = msg.get("text", "").strip()
242
+ if text:
243
+ self.last_ts = msg["ts"]
244
+ log(f" Slack response received ({len(text)} chars)")
245
+ return text
246
+
247
+ log(f" Waiting for QA response... ({elapsed}s)")
248
+
249
+ raise TimeoutError(f"No QA response after {POLL_TIMEOUT}s")
250
+
251
+ def close(self):
252
+ pass # No persistent connection to tear down
253
+
254
+ # ── Shared Helpers ─────────────────────────────────────────────────────────
255
+
256
+ def split_questions(text):
257
+ """Split enriched questions into intro + individual numbered questions."""
258
+ import re
259
+ parts = re.split(r'\n(?=\d+\.\s)', text)
260
+ result = []
261
+ for part in parts:
262
+ stripped = part.strip()
263
+ if stripped:
264
+ result.append(stripped)
265
+ return result if len(result) > 1 else [text.strip()]
266
+
267
+
268
+ EVALUATE_TOOLS = [
269
+ {
270
+ "name": "evaluation",
271
+ "description": "Return your evaluation of the QA conversation.",
272
+ "input_schema": {
273
+ "type": "object",
274
+ "properties": {
275
+ "sufficient": {
276
+ "type": "boolean",
277
+ "description": "True if there is enough info to write exact developer requirements.",
278
+ },
279
+ "reasoning": {
280
+ "type": "string",
281
+ "description": "Brief explanation of why info is or isn't sufficient.",
282
+ },
283
+ "follow_up": {
284
+ "type": "string",
285
+ "description": "Follow-up message to send to QA. Required if sufficient is false. MUST be plain text — no markdown, no **bold**, no bullets. Use numbered lines (1. 2. 3.) for multiple questions.",
286
+ },
287
+ },
288
+ "required": ["sufficient", "reasoning"],
289
+ },
290
+ }
291
+ ]
292
+
293
+
294
+ def evaluate_response(client, conversation, issue_context):
295
+ convo_text = "\n\n".join(
296
+ f"{'QA' if m['from'] == 'qa' else 'Bot'}: {m['text']}" for m in conversation
297
+ )
298
+
299
+ messages = [
300
+ {
301
+ "role": "user",
302
+ "content": f"""Evaluate this QA conversation about a GitHub issue.
303
+
304
+ ISSUE:
305
+ Number: {issue_context['issue_number']}
306
+ Title: {issue_context['issue_title']}
307
+ Body: {issue_context.get('issue_body', 'N/A')}
308
+
309
+ CONVERSATION SO FAR:
310
+ {convo_text}
311
+
312
+ Decide: do you have enough specific information to write exact, actionable developer requirements?
313
+
314
+ If YES: set sufficient=true and explain why.
315
+ If NO: set sufficient=false and write a short, friendly follow-up message asking QA for the missing details. Be specific about what you still need — don't repeat questions already answered.""",
316
+ }
317
+ ]
318
+
319
+ response = client.messages.create(
320
+ model=MODEL,
321
+ max_tokens=1024,
322
+ system=(
323
+ "You are evaluating a QA conversation about a software bug/feature. "
324
+ "Your job is to decide if there is enough concrete information to write "
325
+ "exact developer requirements. Vague answers like 'it should work properly' "
326
+ "are NOT sufficient — you need specifics: exact behavior, exact UI elements, "
327
+ "exact data flows, exact error messages, etc. "
328
+ "Use the evaluation tool to return your assessment. "
329
+ "IMPORTANT: follow_up messages are sent via chat. Use plain text only — "
330
+ "no markdown, no **bold**, no *italic*, no bullet points. "
331
+ "Use numbered lines (1. 2. 3.) for multiple questions. Keep it conversational."
332
+ ),
333
+ tools=EVALUATE_TOOLS,
334
+ tool_choice={"type": "tool", "name": "evaluation"},
335
+ messages=messages,
336
+ )
337
+
338
+ if hasattr(response, "usage") and response.usage:
339
+ log(f" Evaluate tokens: {response.usage.input_tokens} in / {response.usage.output_tokens} out")
340
+
341
+ for block in response.content:
342
+ if block.type == "tool_use" and block.name == "evaluation":
343
+ return block.input
344
+
345
+ raise RuntimeError("Claude did not return evaluation tool call")
346
+
347
+
348
+ def synthesize(client, conversation, issue_context):
349
+ convo_text = "\n\n".join(
350
+ f"{'QA' if m['from'] == 'qa' else 'Bot'}: {m['text']}" for m in conversation
351
+ )
352
+
353
+ messages = [
354
+ {
355
+ "role": "user",
356
+ "content": f"""Synthesize this QA conversation into exact, actionable developer requirements.
357
+
358
+ ISSUE:
359
+ Number: {issue_context['issue_number']}
360
+ Title: {issue_context['issue_title']}
361
+ Body: {issue_context.get('issue_body', 'N/A')}
362
+
363
+ FULL QA CONVERSATION:
364
+ {convo_text}
365
+
366
+ Write a clear numbered list of requirements. Each requirement should be specific enough that a developer can implement it without asking further questions. Include exact UI elements, exact behavior, exact data flows, edge cases, and acceptance criteria where discussed.""",
367
+ }
368
+ ]
369
+
370
+ response = client.messages.create(
371
+ model=MODEL,
372
+ max_tokens=2048,
373
+ system=(
374
+ "You synthesize QA conversations into exact, actionable developer requirements. "
375
+ "Be specific and concrete. No vague language. Every requirement should be testable."
376
+ ),
377
+ messages=messages,
378
+ )
379
+
380
+ if hasattr(response, "usage") and response.usage:
381
+ log(f" Synthesize tokens: {response.usage.input_tokens} in / {response.usage.output_tokens} out")
382
+
383
+ return "".join(block.text for block in response.content if block.type == "text").strip()
384
+
385
+ # ── Main Loop ───────────────────────────────────────────────────────────────
386
+
387
+ def run_qa_chat(issue_context, messenger, qa_name):
388
+ proxy_url = os.environ.get("CODER_PROXY_URL")
389
+ if proxy_url:
390
+ client = anthropic.Anthropic(base_url=proxy_url.rstrip('/'))
391
+ else:
392
+ client = anthropic.Anthropic()
393
+ conversation = []
394
+ raw_responses = []
395
+
396
+ log(f"Opening chat with {qa_name}...")
397
+
398
+ messenger.connect()
399
+
400
+ try:
401
+ messenger.navigate_to_chat(qa_name)
402
+
403
+ # Split enriched questions into separate messages
404
+ initial_msg = issue_context["enriched_questions"]
405
+ parts = split_questions(initial_msg)
406
+ for i, part in enumerate(parts):
407
+ messenger.send_message(part)
408
+ log(f" Sent message {i + 1}/{len(parts)}")
409
+ time.sleep(1)
410
+ conversation.append({"from": "bot", "text": initial_msg})
411
+ log("Sent initial questions to QA")
412
+
413
+ rounds = 0
414
+ for round_num in range(1, MAX_ROUNDS + 1):
415
+ log(f"Round {round_num}/{MAX_ROUNDS}: waiting for QA...")
416
+ response_text = messenger.wait_for_response()
417
+ conversation.append({"from": "qa", "text": response_text})
418
+ raw_responses.append(response_text)
419
+ rounds = round_num
420
+ log(f"Round {round_num}: QA responded ({len(response_text)} chars)")
421
+
422
+ evaluation = evaluate_response(client, conversation, issue_context)
423
+ log(f"Round {round_num}: sufficient={evaluation['sufficient']} — {evaluation.get('reasoning', '')[:100]}")
424
+
425
+ if evaluation["sufficient"]:
426
+ break
427
+
428
+ if round_num < MAX_ROUNDS:
429
+ follow_up = evaluation.get("follow_up", "")
430
+ if follow_up:
431
+ parts = split_questions(follow_up)
432
+ for part in parts:
433
+ messenger.send_message(part)
434
+ time.sleep(1)
435
+ conversation.append({"from": "bot", "text": follow_up})
436
+ log(f"Round {round_num}: sent follow-up ({len(parts)} messages)")
437
+ else:
438
+ log(f"Round {round_num}: no follow-up generated, stopping")
439
+ break
440
+
441
+ finally:
442
+ messenger.close()
443
+
444
+ # Synthesize requirements from full conversation
445
+ log("Synthesizing requirements...")
446
+ requirements = synthesize(client, conversation, issue_context)
447
+
448
+ return {
449
+ "completed": True,
450
+ "requirements": requirements,
451
+ "conversation_rounds": rounds,
452
+ "raw_responses": raw_responses,
453
+ }
454
+
455
+ # ── CLI ─────────────────────────────────────────────────────────────────────
456
+
457
+ def main():
458
+ parser = argparse.ArgumentParser(description="QA Chat — multi-turn QA conversation tool")
459
+ parser.add_argument("--messenger", choices=["teams", "slack"], default="teams",
460
+ help="Messenger to use for QA chat (default: teams)")
461
+ parser.add_argument("--auth", help="Path to Teams auth state JSON (required for --messenger teams)")
462
+ parser.add_argument("--qa", required=True, help="QA person's name")
463
+ parser.add_argument("--super", action="store_true", help="Use Opus 4.6 instead of Sonnet")
464
+ args = parser.parse_args()
465
+
466
+ if args.super:
467
+ global MODEL
468
+ MODEL = "claude-opus-4-6"
469
+ log(f"Super mode: using {MODEL}")
470
+
471
+ if not os.environ.get("ANTHROPIC_API_KEY"):
472
+ log("Error: ANTHROPIC_API_KEY not set")
473
+ sys.exit(1)
474
+
475
+ # Build the appropriate messenger
476
+ if args.messenger == "teams":
477
+ if not args.auth:
478
+ log("Error: --auth is required when using --messenger teams")
479
+ sys.exit(1)
480
+ messenger = TeamsMessenger(args.auth)
481
+ elif args.messenger == "slack":
482
+ slack_token = os.environ.get("SLACK_BOT_TOKEN", "")
483
+ if not slack_token:
484
+ log("Error: SLACK_BOT_TOKEN environment variable is required for --messenger slack")
485
+ sys.exit(1)
486
+ messenger = SlackMessenger(slack_token)
487
+
488
+ # Read issue context from stdin
489
+ try:
490
+ issue_context = json.load(sys.stdin)
491
+ except json.JSONDecodeError as e:
492
+ log(f"Error: invalid JSON on stdin: {e}")
493
+ sys.exit(1)
494
+
495
+ for key in ("issue_number", "issue_title", "enriched_questions"):
496
+ if key not in issue_context:
497
+ log(f"Error: missing required field '{key}' in input JSON")
498
+ sys.exit(1)
499
+
500
+ try:
501
+ result = run_qa_chat(issue_context, messenger, args.qa)
502
+ print(json.dumps(result, indent=2))
503
+ except KeyboardInterrupt:
504
+ print(json.dumps({
505
+ "completed": False,
506
+ "requirements": "",
507
+ "conversation_rounds": 0,
508
+ "raw_responses": [],
509
+ "error": "Interrupted by user",
510
+ }, indent=2))
511
+ sys.exit(130)
512
+ except Exception as e:
513
+ if is_token_limit_error(e):
514
+ log("Token limit reached — stopping.")
515
+ else:
516
+ log(f"Fatal error: {e}")
517
+ print(json.dumps({
518
+ "completed": False,
519
+ "requirements": "",
520
+ "conversation_rounds": 0,
521
+ "raw_responses": [],
522
+ "error": str(e),
523
+ }, indent=2))
524
+ sys.exit(1)
525
+
526
+
527
+ if __name__ == "__main__":
528
+ main()
@@ -0,0 +1,3 @@
1
+ anthropic>=0.40.0
2
+ playwright
3
+ slack_sdk>=3.27.0