Coverage for integrations / social / agent_naming.py: 76.7%
133 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2HevolveSocial - Agent Naming System (what3words-style, dot-separated)
4Local names: 2-word (adjective.noun) - unique per user, work offline.
5 Example: swift.falcon, calm.oracle, bold.storm
7Global names: 3-word (local.name.handle) - globally unique.
8 Example: swift.falcon.sathi, calm.oracle.john
10Handle: A unique creator tag each user picks once (like a gamertag).
11 Reused as the suffix for all their agents' global names.
13Legacy 3-word names (adjective.color.noun) remain supported for backward compat.
14"""
15import os
16import re
17import json
18import random
19import logging
20from typing import List, Optional, Tuple
22from sqlalchemy.orm import Session
24logger = logging.getLogger('hevolve_social')
26# ─── Fallback Word Lists (used when LLM is unavailable) ───
28_FALLBACK_ADJ = [
29 'swift', 'calm', 'bold', 'wise', 'keen', 'bright', 'fierce', 'gentle',
30 'silent', 'mighty', 'clever', 'noble', 'wild', 'pure', 'brave', 'deep',
31 'sharp', 'proud', 'vivid', 'agile',
32]
33_FALLBACK_COLOR = [
34 'amber', 'azure', 'crimson', 'emerald', 'golden', 'indigo', 'jade',
35 'onyx', 'pearl', 'ruby', 'silver', 'topaz', 'violet', 'coral', 'teal',
36 'bronze', 'copper', 'ivory', 'cobalt', 'scarlet',
37]
38_FALLBACK_NOUN = [
39 'falcon', 'sage', 'river', 'storm', 'phoenix', 'dragon', 'oracle',
40 'guardian', 'sentinel', 'wolf', 'hawk', 'owl', 'fox', 'eagle', 'raven',
41 'ember', 'thunder', 'beacon', 'nexus', 'prism',
42]
44RESERVED_WORDS = frozenset([
45 'admin', 'root', 'system', 'bot', 'test', 'null', 'undefined',
46 'anonymous', 'moderator', 'mod', 'staff', 'support', 'help',
47 'official', 'hevolve', 'hevolvebot', 'hart', 'hartbot', 'santaclaw', 'nunba', 'api',
48 'webhook', 'internal', 'deleted', 'banned', 'suspended',
49])
51# ─── Validation ───
53_NAME_PATTERN = re.compile(r'^[a-z]{2,15}\.[a-z]{2,15}\.[a-z]{2,15}$')
54_LOCAL_NAME_PATTERN = re.compile(r'^[a-z]{2,15}\.[a-z]{2,15}$')
55_HANDLE_PATTERN = re.compile(r'^[a-z]{2,15}$')
58def validate_agent_name(name: str) -> Tuple[bool, Optional[str]]:
59 """
60 Validate agent name format.
61 Returns (True, None) if valid, (False, error_message) if invalid.
62 """
63 if not name:
64 return False, "Agent name is required"
66 name = name.strip().lower()
68 if not _NAME_PATTERN.match(name):
69 return False, (
70 "Agent name must be exactly 3 lowercase words separated by dots "
71 "(e.g. swift.amber.falcon). Each word: 2-15 letters, no numbers or special chars."
72 )
74 if len(name) > 47:
75 return False, "Agent name too long (max 47 characters)"
77 words = name.split('.')
78 for word in words:
79 if word in RESERVED_WORDS:
80 return False, f"'{word}' is a reserved word and cannot be used in agent names"
82 return True, None
85def is_name_available(db: Session, name: str) -> bool:
86 """Check if agent name is available globally."""
87 from .models import User
88 return db.query(User).filter(User.username == name).first() is None
91def validate_and_check(db: Session, name: str) -> Tuple[bool, Optional[str]]:
92 """Validate format and check availability in one call."""
93 name = name.strip().lower()
94 valid, error = validate_agent_name(name)
95 if not valid:
96 return False, error
97 if not is_name_available(db, name):
98 return False, f"Name '{name}' is already taken"
99 return True, None
102# ─── Handle Validation ───
105def validate_handle(handle: str) -> Tuple[bool, Optional[str]]:
106 """Validate a user handle (the creator tag appended to agent names)."""
107 if not handle:
108 return False, "Handle is required"
109 handle = handle.strip().lower()
110 if not _HANDLE_PATTERN.match(handle):
111 return False, "Handle must be 2-15 lowercase letters, no numbers, spaces, or dots"
112 if handle in RESERVED_WORDS:
113 return False, f"'{handle}' is reserved and cannot be used as a handle"
114 return True, None
117def is_handle_available(db: Session, handle: str) -> bool:
118 """Check if a handle is available globally."""
119 from .models import User
120 return db.query(User).filter(User.handle == handle).first() is None
123# ─── Local (2-word) Name Validation ───
126def validate_local_name(name: str) -> Tuple[bool, Optional[str]]:
127 """Validate a 2-word local agent name (e.g. swift.falcon)."""
128 if not name:
129 return False, "Agent name is required"
130 name = name.strip().lower()
131 if not _LOCAL_NAME_PATTERN.match(name):
132 return False, (
133 "Agent name must be exactly 2 lowercase words separated by a dot "
134 "(e.g. swift.falcon). Each word: 2-15 letters."
135 )
136 if len(name) > 31:
137 return False, "Agent name too long (max 31 characters)"
138 for word in name.split('.'):
139 if word in RESERVED_WORDS:
140 return False, f"'{word}' is a reserved word and cannot be used"
141 return True, None
144def compose_global_name(local_name: str, handle: str) -> str:
145 """Compose a 3-word global name from a 2-word local name and user handle."""
146 return f"{local_name.strip().lower()}.{handle.strip().lower()}"
149def check_global_availability(
150 db: Session, local_name: str, handle: str
151) -> Tuple[bool, str, Optional[str]]:
152 """
153 Check if a local name + handle combination is available globally.
154 Returns (available, global_name, error_or_None).
155 """
156 global_name = compose_global_name(local_name, handle)
157 # Validate the composed 3-word name
158 valid, error = validate_agent_name(global_name)
159 if not valid:
160 return False, global_name, error
161 if not is_name_available(db, global_name):
162 return False, global_name, f"'{global_name}' is already taken globally"
163 return True, global_name, None
166# ─── LLM-Powered Generation ───
168def _load_api_key():
169 """Load OpenAI API key from config.json (same source as hart_intelligence)."""
170 config_path = os.path.join(
171 os.path.dirname(__file__), '..', '..', 'config.json')
172 try:
173 with open(config_path, 'r') as f:
174 config = json.load(f)
175 return config.get('OPENAI_API_KEY', '')
176 except Exception:
177 return os.environ.get('OPENAI_API_KEY', '')
180def _generate_via_llm(count: int, mode: str = 'global') -> List[str]:
181 """Call LLM to generate creative agent names (2-word local or 3-word global)."""
182 api_key = _load_api_key()
183 if not api_key:
184 return []
186 if mode == 'local':
187 prompt = (
188 f"Generate {count} unique creative 2-word agent names. "
189 "Format: adjective.noun, all lowercase, separated by a dot (like what3words). "
190 "Examples: swift.falcon, calm.oracle, bold.storm, fierce.phoenix, gentle.ember. "
191 "Be creative and varied. Return ONLY the names, one per line, nothing else."
192 )
193 else:
194 prompt = (
195 f"Generate {count} unique creative 3-word agent names. "
196 "Format: adjective.color.noun, all lowercase, separated by dots (like what3words). "
197 "Examples: swift.amber.falcon, calm.jade.oracle, bold.crimson.storm. "
198 "Be creative and varied. Return ONLY the names, one per line, nothing else."
199 )
201 try:
202 import openai
203 client = openai.OpenAI(api_key=api_key)
204 response = client.chat.completions.create(
205 model="gpt-4o-mini",
206 messages=[{"role": "user", "content": prompt}],
207 temperature=1.0,
208 max_tokens=count * 30,
209 )
210 text = response.choices[0].message.content.strip()
211 names = [line.strip().lower() for line in text.split('\n') if line.strip()]
212 return names
213 except Exception as e:
214 logger.debug(f"LLM name generation failed: {e}")
215 return []
218def _generate_random_fallback(db: Session, count: int, mode: str = 'global',
219 handle: Optional[str] = None) -> List[str]:
220 """Fallback: generate names from embedded word lists when LLM unavailable."""
221 results = []
222 attempts = 0
223 max_attempts = count * 20
225 while len(results) < count and attempts < max_attempts:
226 attempts += 1
227 if mode == 'local':
228 candidate = f"{random.choice(_FALLBACK_ADJ)}.{random.choice(_FALLBACK_NOUN)}"
229 # Check global availability if handle provided
230 if handle:
231 global_name = compose_global_name(candidate, handle)
232 if candidate not in results and is_name_available(db, global_name):
233 results.append(candidate)
234 else:
235 if candidate not in results:
236 results.append(candidate)
237 else:
238 candidate = (f"{random.choice(_FALLBACK_ADJ)}."
239 f"{random.choice(_FALLBACK_COLOR)}."
240 f"{random.choice(_FALLBACK_NOUN)}")
241 if candidate not in results and is_name_available(db, candidate):
242 results.append(candidate)
244 return results
247def generate_agent_name(db: Session, count: int = 5, mode: str = 'global',
248 handle: Optional[str] = None) -> List[str]:
249 """
250 Generate unique available agent names.
252 mode='local': returns 2-word names (adjective-noun), pre-checked for global
253 availability when handle is provided.
254 mode='global': returns 3-word names (adjective-color-noun), checked globally.
255 """
256 validator = validate_local_name if mode == 'local' else validate_agent_name
257 llm_names = _generate_via_llm(count * 2, mode=mode)
259 results = []
260 for name in llm_names:
261 name = name.strip().lower()
262 valid, _ = validator(name)
263 if not valid or name in results:
264 continue
265 # Check availability
266 if mode == 'local' and handle:
267 global_name = compose_global_name(name, handle)
268 if not is_name_available(db, global_name):
269 continue
270 elif mode == 'global':
271 if not is_name_available(db, name):
272 continue
273 results.append(name)
274 if len(results) >= count:
275 break
277 # Fallback if LLM didn't produce enough
278 if len(results) < count:
279 results.extend(_generate_random_fallback(
280 db, count - len(results), mode=mode, handle=handle))
282 return results