handoffkit 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,140 @@
1
+ """Helpers for OpenAI-compatible providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import urllib.error
8
+ import urllib.request
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+ from handoffkit.errors import ProviderConfigurationError, ProviderExecutionError
13
+ from handoffkit.providers.openai_provider import (
14
+ DEFAULT_OPENAI_BASE_URL,
15
+ DEFAULT_OPENAI_MODEL,
16
+ OpenAIProvider,
17
+ )
18
+
19
+ DEFAULT_MODEL_CANDIDATES = ("gpt-5.4", DEFAULT_OPENAI_MODEL)
20
+
21
+
22
+ @dataclass
23
+ class ModelSelectionResult:
24
+ """Result returned by OpenAI-compatible model selection."""
25
+
26
+ model: str
27
+ base_url: str
28
+ models_endpoint_ok: bool
29
+ available_models: list[str] = field(default_factory=list)
30
+ attempted_models: list[str] = field(default_factory=list)
31
+ errors: dict[str, str] = field(default_factory=dict)
32
+
33
+
34
+ def list_openai_compatible_models(
35
+ *,
36
+ api_key: str | None = None,
37
+ base_url: str | None = None,
38
+ timeout: float = 20.0,
39
+ ) -> list[str]:
40
+ """Return model ids from an OpenAI-compatible `/models` endpoint."""
41
+ resolved_api_key = api_key or os.getenv("OPENAI_API_KEY")
42
+ if not resolved_api_key:
43
+ raise ProviderConfigurationError("OPENAI_API_KEY is required to list models.")
44
+ configured_base_url = base_url or os.getenv("OPENAI_BASE_URL") or DEFAULT_OPENAI_BASE_URL
45
+ resolved_base_url = configured_base_url.rstrip("/")
46
+ request = urllib.request.Request(
47
+ f"{resolved_base_url}/models",
48
+ headers={"Authorization": f"Bearer {resolved_api_key}"},
49
+ method="GET",
50
+ )
51
+ try:
52
+ with urllib.request.urlopen(request, timeout=timeout) as response:
53
+ body = json.loads(response.read().decode("utf-8"))
54
+ except urllib.error.HTTPError as exc:
55
+ detail = exc.read().decode("utf-8", errors="replace").replace(
56
+ resolved_api_key, "[redacted-api-key]"
57
+ )
58
+ raise ProviderExecutionError(
59
+ f"OpenAI-compatible models request failed with HTTP {exc.code}: {detail}"
60
+ ) from exc
61
+ except urllib.error.URLError as exc:
62
+ raise ProviderExecutionError(
63
+ f"OpenAI-compatible models request failed for {resolved_base_url}: {exc.reason}"
64
+ ) from exc
65
+
66
+ raw_items: Any = body.get("data", body if isinstance(body, list) else [])
67
+ models: list[str] = []
68
+ for item in raw_items:
69
+ if isinstance(item, dict) and isinstance(item.get("id"), str):
70
+ models.append(item["id"])
71
+ elif isinstance(item, str):
72
+ models.append(item)
73
+ return sorted(set(models))
74
+
75
+
76
+ def choose_openai_compatible_model(
77
+ *,
78
+ api_key: str | None = None,
79
+ base_url: str | None = None,
80
+ model: str | None = None,
81
+ fallback_models: tuple[str, ...] = DEFAULT_MODEL_CANDIDATES,
82
+ timeout: float = 30.0,
83
+ ) -> ModelSelectionResult:
84
+ """Choose the first working model for an OpenAI-compatible endpoint."""
85
+ resolved_api_key = api_key or os.getenv("OPENAI_API_KEY")
86
+ if not resolved_api_key:
87
+ raise ProviderConfigurationError("OPENAI_API_KEY is required to choose a model.")
88
+ configured_base_url = base_url or os.getenv("OPENAI_BASE_URL") or DEFAULT_OPENAI_BASE_URL
89
+ resolved_base_url = configured_base_url.rstrip("/")
90
+ env_model = model or os.getenv("OPENAI_MODEL")
91
+ candidates = [env_model] if env_model else list(fallback_models)
92
+ result = ModelSelectionResult(
93
+ model="",
94
+ base_url=resolved_base_url,
95
+ models_endpoint_ok=False,
96
+ attempted_models=[],
97
+ )
98
+
99
+ try:
100
+ result.available_models = list_openai_compatible_models(
101
+ api_key=resolved_api_key,
102
+ base_url=resolved_base_url,
103
+ timeout=timeout,
104
+ )
105
+ result.models_endpoint_ok = True
106
+ except ProviderExecutionError as exc:
107
+ result.errors["/models"] = str(exc)
108
+
109
+ for candidate in candidates:
110
+ if not candidate:
111
+ continue
112
+ if result.available_models and candidate not in result.available_models:
113
+ result.errors[candidate] = "Model not listed by /models endpoint."
114
+ continue
115
+ result.attempted_models.append(candidate)
116
+ provider = OpenAIProvider(
117
+ api_key=resolved_api_key,
118
+ base_url=resolved_base_url,
119
+ model=candidate,
120
+ timeout=timeout,
121
+ )
122
+ try:
123
+ response = provider.generate(
124
+ "Reply with exactly: OK",
125
+ max_tokens=8,
126
+ temperature=0,
127
+ )
128
+ except ProviderExecutionError as exc:
129
+ result.errors[candidate] = str(exc)
130
+ continue
131
+ if response.strip():
132
+ result.model = candidate
133
+ return result
134
+ result.errors[candidate] = "Model returned an empty response."
135
+
136
+ attempted = ", ".join(candidates)
137
+ raise ProviderExecutionError(
138
+ f"No OpenAI-compatible model succeeded for {resolved_base_url}. "
139
+ f"Candidates: {attempted}. Errors: {result.errors}"
140
+ )
@@ -0,0 +1,80 @@
1
+ """Minimal OpenAI-compatible HTTP provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import urllib.error
8
+ import urllib.request
9
+ from typing import Any
10
+
11
+ from handoffkit.errors import ProviderConfigurationError, ProviderExecutionError
12
+ from handoffkit.providers.base import BaseProvider
13
+
14
+ DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
15
+ DEFAULT_OPENAI_MODEL = "gpt-4o-mini"
16
+
17
+
18
+ class OpenAIProvider(BaseProvider):
19
+ """Provider that calls OpenAI or an OpenAI-compatible API."""
20
+
21
+ def __init__(
22
+ self,
23
+ model: str | None = None,
24
+ *,
25
+ api_key: str | None = None,
26
+ base_url: str | None = None,
27
+ timeout: float = 60.0,
28
+ ) -> None:
29
+ self.model = model or os.getenv("OPENAI_MODEL") or DEFAULT_OPENAI_MODEL
30
+ self.api_key = api_key or os.getenv("OPENAI_API_KEY")
31
+ configured_base_url = base_url or os.getenv("OPENAI_BASE_URL") or DEFAULT_OPENAI_BASE_URL
32
+ self.base_url = configured_base_url.rstrip("/")
33
+ self.timeout = timeout
34
+ if not self.api_key:
35
+ raise ProviderConfigurationError(
36
+ "OPENAI_API_KEY is required for OpenAIProvider. "
37
+ "Set the environment variable or pass api_key explicitly."
38
+ )
39
+
40
+ def generate(self, prompt: str, **kwargs: Any) -> str:
41
+ """Generate text using OpenAI chat completions."""
42
+ payload = {
43
+ "model": kwargs.pop("model", self.model),
44
+ "messages": [{"role": "user", "content": prompt}],
45
+ **kwargs,
46
+ }
47
+ data = json.dumps(payload).encode("utf-8")
48
+ request = urllib.request.Request(
49
+ f"{self.base_url}/chat/completions",
50
+ data=data,
51
+ headers={
52
+ "Authorization": f"Bearer {self.api_key}",
53
+ "Content-Type": "application/json",
54
+ },
55
+ method="POST",
56
+ )
57
+ try:
58
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
59
+ body = json.loads(response.read().decode("utf-8"))
60
+ except urllib.error.HTTPError as exc:
61
+ detail = exc.read().decode("utf-8", errors="replace")
62
+ raise ProviderExecutionError(
63
+ f"OpenAI-compatible request failed with HTTP {exc.code}: "
64
+ f"{self._sanitize_error_detail(detail)}"
65
+ ) from exc
66
+ except urllib.error.URLError as exc:
67
+ raise ProviderExecutionError(
68
+ f"OpenAI-compatible request failed for {self.base_url}: {exc.reason}"
69
+ ) from exc
70
+ choices = body.get("choices") or []
71
+ if not choices:
72
+ return ""
73
+ message = choices[0].get("message") or {}
74
+ return str(message.get("content", ""))
75
+
76
+ def _sanitize_error_detail(self, detail: str) -> str:
77
+ """Return provider error text without leaking the configured API key."""
78
+ if not self.api_key:
79
+ return detail
80
+ return detail.replace(self.api_key, "[redacted-api-key]")
handoffkit/py.typed ADDED
@@ -0,0 +1 @@
1
+
handoffkit/runner.py ADDED
@@ -0,0 +1,51 @@
1
+ """Team runner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ from handoffkit.agent import Agent
8
+ from handoffkit.protocol import HandoffProtocol
9
+ from handoffkit.schemas import AgentOutput, TeamRunResult
10
+
11
+
12
+ class Team:
13
+ """Sequential multi-agent team runner."""
14
+
15
+ def __init__(
16
+ self,
17
+ agents: Sequence[Agent],
18
+ *,
19
+ protocol: HandoffProtocol | None = None,
20
+ ) -> None:
21
+ if not agents:
22
+ raise ValueError("Team requires at least one agent.")
23
+ self.agents = list(agents)
24
+ self.protocol = protocol or HandoffProtocol(mode="hybrid_state")
25
+
26
+ def run(self, task: str) -> TeamRunResult:
27
+ """Run the task through all agents in sequence."""
28
+ outputs: list[AgentOutput] = []
29
+ handoffs = []
30
+
31
+ first = self.agents[0]
32
+ current_output = first.run(task)
33
+ outputs.append(AgentOutput(agent=first.name, output=current_output))
34
+
35
+ for previous, current in zip(self.agents, self.agents[1:], strict=False):
36
+ handoff = self.protocol.transfer(
37
+ from_agent=previous,
38
+ to_agent=current,
39
+ task=task,
40
+ summary=current_output,
41
+ )
42
+ handoffs.append(handoff)
43
+ current_output = current.run(task, handoff_state=handoff)
44
+ outputs.append(AgentOutput(agent=current.name, output=current_output))
45
+
46
+ return TeamRunResult(
47
+ task=task,
48
+ final_output=current_output,
49
+ agent_outputs=outputs,
50
+ handoffs=handoffs,
51
+ )
handoffkit/schemas.py ADDED
@@ -0,0 +1,45 @@
1
+ """Shared schema helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Literal
7
+
8
+ ProtocolMode = Literal["natural", "compressed", "hybrid_min", "hybrid_state"]
9
+
10
+
11
+ @dataclass
12
+ class AgentOutput:
13
+ """Output produced by a named agent."""
14
+
15
+ agent: str
16
+ output: str
17
+
18
+ def to_dict(self) -> dict[str, str]:
19
+ """Return a dictionary representation."""
20
+ return {"agent": self.agent, "output": self.output}
21
+
22
+
23
+ @dataclass
24
+ class TeamRunResult:
25
+ """Result returned by Team.run."""
26
+
27
+ task: str
28
+ final_output: str
29
+ agent_outputs: list[AgentOutput]
30
+ handoffs: list[Any]
31
+
32
+ def to_dict(self) -> dict[str, Any]:
33
+ """Return a JSON-friendly result."""
34
+ return {
35
+ "task": self.task,
36
+ "final_output": self.final_output,
37
+ "agent_outputs": [output.to_dict() for output in self.agent_outputs],
38
+ "handoffs": [
39
+ handoff.to_dict() if hasattr(handoff, "to_dict") else handoff
40
+ for handoff in self.handoffs
41
+ ],
42
+ }
43
+
44
+ def __str__(self) -> str:
45
+ return self.final_output
handoffkit/tool.py ADDED
@@ -0,0 +1,196 @@
1
+ """Tool decorator and metadata model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import inspect
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from typing import Any, TypeVar, get_args, get_origin, get_type_hints
10
+
11
+ from handoffkit.errors import ToolExecutionError
12
+
13
+ F = TypeVar("F", bound=Callable[..., Any])
14
+
15
+
16
+ def _type_name(value: Any) -> str:
17
+ """Return a readable name for a type annotation."""
18
+ if value is inspect.Signature.empty:
19
+ return "Any"
20
+ if hasattr(value, "__name__"):
21
+ return value.__name__
22
+ return str(value).replace("typing.", "")
23
+
24
+
25
+ def _json_schema_type(value: Any) -> str:
26
+ """Map a Python annotation to a simple JSON schema type."""
27
+ if value is inspect.Signature.empty or value is Any:
28
+ return "string"
29
+
30
+ origin = get_origin(value)
31
+ target = origin or value
32
+ if target is str:
33
+ return "string"
34
+ if target is int:
35
+ return "integer"
36
+ if target is float:
37
+ return "number"
38
+ if target is bool:
39
+ return "boolean"
40
+ if target is list:
41
+ return "array"
42
+ if target is dict:
43
+ return "object"
44
+ return "string"
45
+
46
+
47
+ def _json_schema_for_annotation(value: Any) -> dict[str, Any]:
48
+ """Return a compact JSON-schema-like object for one annotation."""
49
+ schema: dict[str, Any] = {"type": _json_schema_type(value)}
50
+ origin = get_origin(value)
51
+ args = get_args(value)
52
+ if (origin is list or value is list) and args:
53
+ schema["items"] = _json_schema_for_annotation(args[0])
54
+ if origin is dict and len(args) == 2:
55
+ schema["additionalProperties"] = _json_schema_for_annotation(args[1])
56
+ return schema
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class ToolParameter:
61
+ """Metadata about one tool parameter."""
62
+
63
+ name: str
64
+ annotation: str
65
+ required: bool
66
+ default: Any = None
67
+
68
+
69
+ class Tool:
70
+ """Executable wrapper around a Python function."""
71
+
72
+ def __init__(
73
+ self,
74
+ func: Callable[..., Any],
75
+ *,
76
+ name: str | None = None,
77
+ description: str | None = None,
78
+ ) -> None:
79
+ if not callable(func):
80
+ raise TypeError("Tool requires a callable.")
81
+ functools.update_wrapper(self, func)
82
+ self.func = func
83
+ self.name = name or func.__name__
84
+ self.description = description or inspect.getdoc(func) or ""
85
+ self.signature = inspect.signature(func)
86
+ self.type_hints = get_type_hints(func)
87
+ self.parameters = self._build_parameters()
88
+ self.return_type = _type_name(
89
+ self.type_hints.get("return", self.signature.return_annotation)
90
+ )
91
+
92
+ def _build_parameters(self) -> dict[str, ToolParameter]:
93
+ parameters: dict[str, ToolParameter] = {}
94
+ for name, parameter in self.signature.parameters.items():
95
+ annotation = self.type_hints.get(name, parameter.annotation)
96
+ required = parameter.default is inspect.Signature.empty
97
+ default = None if required else parameter.default
98
+ parameters[name] = ToolParameter(
99
+ name=name,
100
+ annotation=_type_name(annotation),
101
+ required=required,
102
+ default=default,
103
+ )
104
+ return parameters
105
+
106
+ @property
107
+ def original_function(self) -> Callable[..., Any]:
108
+ """Return the wrapped Python function."""
109
+ return self.func
110
+
111
+ def to_dict(self) -> dict[str, Any]:
112
+ """Serialize tool metadata to a dictionary."""
113
+ return {
114
+ "name": self.name,
115
+ "description": self.description,
116
+ "parameters": {
117
+ name: {
118
+ "type": parameter.annotation,
119
+ "required": parameter.required,
120
+ "default": parameter.default,
121
+ }
122
+ for name, parameter in self.parameters.items()
123
+ },
124
+ "return_type": self.return_type,
125
+ }
126
+
127
+ def to_schema(self) -> dict[str, Any]:
128
+ """Return a simple JSON-schema-compatible tool description."""
129
+ properties: dict[str, Any] = {}
130
+ required: list[str] = []
131
+ for name, parameter in self.signature.parameters.items():
132
+ annotation = self.type_hints.get(name, parameter.annotation)
133
+ properties[name] = _json_schema_for_annotation(annotation)
134
+ if parameter.default is inspect.Signature.empty:
135
+ required.append(name)
136
+ return {
137
+ "name": self.name,
138
+ "description": self.description,
139
+ "parameters": {
140
+ "type": "object",
141
+ "properties": properties,
142
+ "required": required,
143
+ },
144
+ }
145
+
146
+ def run(self, **kwargs: Any) -> Any:
147
+ """Run the wrapped function with keyword arguments."""
148
+ try:
149
+ bound = self.signature.bind(**kwargs)
150
+ bound.apply_defaults()
151
+ return self.func(*bound.args, **bound.kwargs)
152
+ except TypeError:
153
+ raise
154
+ except Exception as exc:
155
+ if exc.__class__.__module__.startswith("handoffkit"):
156
+ raise
157
+ raise ToolExecutionError(f"Tool {self.name!r} failed: {exc}") from exc
158
+
159
+ def __call__(self, *args: Any, **kwargs: Any) -> Any:
160
+ """Call the tool like the original function."""
161
+ if args:
162
+ bound = self.signature.bind(*args, **kwargs)
163
+ bound.apply_defaults()
164
+ return self.func(*bound.args, **bound.kwargs)
165
+ return self.run(**kwargs)
166
+
167
+ def __repr__(self) -> str:
168
+ return f"Tool(name={self.name!r})"
169
+
170
+
171
+ def tool(
172
+ func: F | None = None,
173
+ *,
174
+ name: str | None = None,
175
+ description: str | None = None,
176
+ ) -> Tool | Callable[[F], Tool]:
177
+ """Convert a function into an executable Tool.
178
+
179
+ Can be used as `@tool` or `@tool(name="custom_name")`.
180
+ """
181
+
182
+ def decorator(inner: F) -> Tool:
183
+ return Tool(inner, name=name, description=description)
184
+
185
+ if func is None:
186
+ return decorator
187
+ return decorator(func)
188
+
189
+
190
+ def ensure_tool(candidate: Tool | Callable[..., Any]) -> Tool:
191
+ """Return a Tool instance for a Tool or callable."""
192
+ if isinstance(candidate, Tool):
193
+ return candidate
194
+ if callable(candidate):
195
+ return Tool(candidate)
196
+ raise TypeError(f"Expected Tool or callable, got {type(candidate).__name__}.")
@@ -0,0 +1,15 @@
1
+ """Included tools."""
2
+
3
+ from handoffkit.tools.filesystem import file_exists, list_files, read_file, write_file
4
+ from handoffkit.tools.shell import run_command
5
+ from handoffkit.tools.text import extract_keywords, summarize_text
6
+
7
+ __all__ = [
8
+ "extract_keywords",
9
+ "file_exists",
10
+ "list_files",
11
+ "read_file",
12
+ "run_command",
13
+ "summarize_text",
14
+ "write_file",
15
+ ]
@@ -0,0 +1,34 @@
1
+ """Filesystem tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from handoffkit.tool import tool
8
+
9
+
10
+ @tool
11
+ def read_file(path: str) -> str:
12
+ """Read a UTF-8 text file."""
13
+ return Path(path).read_text(encoding="utf-8")
14
+
15
+
16
+ @tool
17
+ def write_file(path: str, content: str) -> str:
18
+ """Write content to a UTF-8 text file."""
19
+ target = Path(path)
20
+ target.parent.mkdir(parents=True, exist_ok=True)
21
+ target.write_text(content, encoding="utf-8")
22
+ return str(target)
23
+
24
+
25
+ @tool
26
+ def list_files(path: str) -> list[str]:
27
+ """List files and directories in a directory."""
28
+ return sorted(str(item) for item in Path(path).iterdir())
29
+
30
+
31
+ @tool
32
+ def file_exists(path: str) -> bool:
33
+ """Return whether a path exists."""
34
+ return Path(path).exists()
@@ -0,0 +1,53 @@
1
+ """Shell command tool with a small safety policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from handoffkit.errors import DangerousCommandError
11
+ from handoffkit.tool import tool
12
+
13
+ DANGEROUS_PATTERNS = [
14
+ re.compile(r"\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-rf|-fr)\b", re.IGNORECASE),
15
+ re.compile(r"\bdel\s+/(s|q)\b", re.IGNORECASE),
16
+ re.compile(r"\brmdir\s+/(s|q)\b", re.IGNORECASE),
17
+ re.compile(r"\brd\s+/(s|q)\b", re.IGNORECASE),
18
+ re.compile(r"\bformat\b", re.IGNORECASE),
19
+ re.compile(r"\bshutdown\b", re.IGNORECASE),
20
+ re.compile(r"\breboot\b", re.IGNORECASE),
21
+ re.compile(r"\bmkfs(\.[a-z0-9]+)?\b", re.IGNORECASE),
22
+ re.compile(r"\bdiskpart\b", re.IGNORECASE),
23
+ re.compile(r"\bRemove-Item\b.*\b-Recurse\b", re.IGNORECASE),
24
+ ]
25
+
26
+
27
+ def _assert_safe(command: str) -> None:
28
+ normalized = " ".join(command.strip().split())
29
+ for pattern in DANGEROUS_PATTERNS:
30
+ if pattern.search(normalized):
31
+ raise DangerousCommandError(f"Blocked dangerous command: {command}")
32
+
33
+
34
+ @tool
35
+ def run_command(command: str, cwd: str | None = None) -> dict[str, Any]:
36
+ """Run a shell command and return command, cwd, return code, stdout, and stderr."""
37
+ _assert_safe(command)
38
+ working_dir = str(Path(cwd).resolve()) if cwd else None
39
+ completed = subprocess.run(
40
+ command,
41
+ cwd=working_dir,
42
+ shell=True,
43
+ check=False,
44
+ capture_output=True,
45
+ text=True,
46
+ )
47
+ return {
48
+ "command": command,
49
+ "cwd": working_dir,
50
+ "returncode": completed.returncode,
51
+ "stdout": completed.stdout,
52
+ "stderr": completed.stderr,
53
+ }
@@ -0,0 +1,54 @@
1
+ """Small text tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections import Counter
7
+
8
+ from handoffkit.tool import tool
9
+
10
+ STOPWORDS = {
11
+ "a",
12
+ "an",
13
+ "and",
14
+ "are",
15
+ "as",
16
+ "at",
17
+ "be",
18
+ "by",
19
+ "for",
20
+ "from",
21
+ "in",
22
+ "is",
23
+ "it",
24
+ "of",
25
+ "on",
26
+ "or",
27
+ "that",
28
+ "the",
29
+ "to",
30
+ "with",
31
+ }
32
+
33
+
34
+ @tool
35
+ def summarize_text(text: str, max_chars: int = 500) -> str:
36
+ """Return a compact character-limited summary."""
37
+ clean = " ".join(text.strip().split())
38
+ if len(clean) <= max_chars:
39
+ return clean
40
+ if max_chars <= 3:
41
+ return clean[:max_chars]
42
+ return clean[: max_chars - 3].rstrip() + "..."
43
+
44
+
45
+ @tool
46
+ def extract_keywords(text: str) -> list[str]:
47
+ """Extract simple frequency-based keywords."""
48
+ words = [
49
+ word.lower()
50
+ for word in re.findall(r"[A-Za-z][A-Za-z0-9_+-]{2,}", text)
51
+ if word.lower() not in STOPWORDS
52
+ ]
53
+ counts = Counter(words)
54
+ return [word for word, _ in counts.most_common(10)]