194 lines
6.5 KiB
Python
194 lines
6.5 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
AI-powered agents for extracting structured information from discussions.
|
|
|
|
All automation paths (runner + workflow) now share the same ModelConfig cascade,
|
|
so CLI fallbacks (Claude → Codex → Gemini, etc.) behave consistently and surface
|
|
the same progress messages.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any, Mapping
|
|
|
|
from automation.ai_config import load_ai_settings
|
|
from automation.patcher import ModelConfig, _run_ai_command # type: ignore
|
|
|
|
|
|
_MODEL_CACHE: ModelConfig | None = None
|
|
|
|
|
|
def _get_model() -> ModelConfig:
|
|
global _MODEL_CACHE
|
|
if _MODEL_CACHE is None:
|
|
repo_root = Path.cwd().resolve()
|
|
settings = load_ai_settings(repo_root)
|
|
_MODEL_CACHE = ModelConfig(
|
|
commands=settings.runner.command_chain,
|
|
sentinel=settings.runner.sentinel,
|
|
runner_settings=settings.runner,
|
|
)
|
|
return _MODEL_CACHE
|
|
|
|
|
|
def _invoke_model(prompt: str, hint: str = "fast") -> str | None:
|
|
model = _get_model()
|
|
commands = model.get_commands_for_hint(hint)
|
|
repo_root = Path.cwd().resolve()
|
|
errors: list[str] = []
|
|
sentinel_seen = False
|
|
|
|
total = len(commands)
|
|
for idx, command in enumerate(commands, start=1):
|
|
provider_name = command.split()[0]
|
|
sys.stderr.write(f"[agents] provider {idx}/{total} → {provider_name}\n")
|
|
sys.stderr.flush()
|
|
executor, raw_stdout, stderr, returncode = _run_ai_command(command, prompt, repo_root)
|
|
|
|
if raw_stdout:
|
|
stripped = raw_stdout.strip()
|
|
if stripped == model.sentinel:
|
|
sentinel_seen = True
|
|
sys.stderr.write(
|
|
f"[agents] provider {idx}/{total} → {provider_name} returned sentinel (no change)\n"
|
|
)
|
|
sys.stderr.flush()
|
|
continue
|
|
return stripped
|
|
|
|
if returncode == 0:
|
|
errors.append(f"{executor!r} produced no output")
|
|
sys.stderr.write(
|
|
f"[agents] provider {idx}/{total} → {provider_name} returned no output\n"
|
|
)
|
|
sys.stderr.flush()
|
|
else:
|
|
errors.append(f"{executor!r} exited with {returncode}: {stderr or 'no stderr'}")
|
|
sys.stderr.write(
|
|
f"[agents] provider {idx}/{total} → {provider_name} exited with {returncode}\n"
|
|
)
|
|
sys.stderr.flush()
|
|
|
|
if sentinel_seen:
|
|
return None
|
|
|
|
if errors:
|
|
sys.stderr.write("[agents] warning: " + "; ".join(errors) + "\n")
|
|
return None
|
|
|
|
|
|
def _request_json(prompt: str, content: str, hint: str = "fast") -> dict[str, Any] | None:
|
|
full_prompt = (
|
|
"You must answer with valid JSON only. No code fences, no backticks, "
|
|
"no explanations.\n\n"
|
|
f"{prompt}\n\n"
|
|
"<discussion_content>\n"
|
|
f"{content}\n"
|
|
"</discussion_content>\n"
|
|
)
|
|
raw = _invoke_model(full_prompt, hint=hint)
|
|
if not raw:
|
|
return None
|
|
|
|
cleaned = raw.strip()
|
|
if cleaned.startswith("```"):
|
|
cleaned = cleaned.strip("`")
|
|
try:
|
|
return json.loads(cleaned)
|
|
except json.JSONDecodeError as exc:
|
|
preview = cleaned[:200].replace("\n", " ")
|
|
sys.stderr.write(f"[agents] warning: failed to parse JSON ({exc}): {preview}\n")
|
|
return None
|
|
|
|
|
|
def normalize_discussion(content: str) -> dict[str, Any] | None:
|
|
prompt = """You are a discussion normalizer. Extract structured information from the discussion content.
|
|
|
|
Return a JSON object with these fields:
|
|
{
|
|
"votes": [
|
|
{"participant": "Name", "vote": "READY|CHANGES|REJECT", "line": "original line text"}
|
|
],
|
|
"questions": [
|
|
{"participant": "Name", "question": "text", "line": "original line"}
|
|
],
|
|
"decisions": [
|
|
{"participant": "Name", "decision": "text", "rationale": "why", "line": "original line"}
|
|
],
|
|
"action_items": [
|
|
{"participant": "Name", "action": "text", "status": "TODO|ASSIGNED|DONE", "assignee": "Name or null", "line": "original line"}
|
|
],
|
|
"mentions": [
|
|
{"from": "Name", "to": "Name or @all", "context": "surrounding text"}
|
|
]
|
|
}
|
|
|
|
Only include items that are clearly present. Return empty arrays for missing categories."""
|
|
return _request_json(prompt, content, hint="fast")
|
|
|
|
|
|
def track_questions(content: str, existing_questions: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
existing_q_json = json.dumps(existing_questions, indent=2)
|
|
prompt = f"""You are a question tracking agent. Process new discussion content and track questions.
|
|
|
|
Existing tracked questions:
|
|
{existing_q_json}
|
|
|
|
For the new content:
|
|
1. Identify new questions
|
|
2. Match answers to existing questions (by context/participant names)
|
|
3. Mark questions as: OPEN, PARTIAL (some answers), or ANSWERED
|
|
|
|
Return JSON with "new_questions" and "updated_questions" arrays."""
|
|
return _request_json(prompt, content, hint="fast")
|
|
|
|
|
|
def track_action_items(content: str, existing_items: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
existing_items_json = json.dumps(existing_items, indent=2)
|
|
prompt = f"""You are an action item tracking agent. Process new discussion content and track action items.
|
|
|
|
Existing tracked items:
|
|
{existing_items_json}
|
|
|
|
For the new content:
|
|
1. Identify new action items
|
|
2. Track assignments
|
|
3. Track completions
|
|
|
|
Return JSON with "new_items" and "updated_items" arrays."""
|
|
return _request_json(prompt, content, hint="fast")
|
|
|
|
|
|
def track_decisions(content: str, existing_decisions: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
existing_decisions_json = json.dumps(existing_decisions, indent=2)
|
|
prompt = f"""You are a decision logging agent. Extract architectural and design decisions from discussions.
|
|
|
|
Existing decisions:
|
|
{existing_decisions_json}
|
|
|
|
Identify decisions with rationale, supporters, and whether they supersede previous decisions.
|
|
|
|
Return JSON with "new_decisions" and "updated_decisions" arrays."""
|
|
return _request_json(prompt, content, hint="quality")
|
|
|
|
|
|
def extract_mentions(content: str) -> list[dict[str, str]]:
|
|
"""
|
|
Lightweight fallback: detect @mentions without AI (regex-based).
|
|
"""
|
|
mentions: list[dict[str, str]] = []
|
|
for line in content.splitlines():
|
|
segments = line.split()
|
|
for segment in segments:
|
|
if segment.startswith("@") and len(segment) > 1:
|
|
mentions.append(
|
|
{
|
|
"from": "unknown",
|
|
"to": segment.lstrip("@").rstrip(":"),
|
|
"context": line.strip(),
|
|
}
|
|
)
|
|
return mentions
|