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

1""" 

2HevolveSocial - Agent Naming System (what3words-style, dot-separated) 

3 

4Local names: 2-word (adjective.noun) - unique per user, work offline. 

5 Example: swift.falcon, calm.oracle, bold.storm 

6 

7Global names: 3-word (local.name.handle) - globally unique. 

8 Example: swift.falcon.sathi, calm.oracle.john 

9 

10Handle: A unique creator tag each user picks once (like a gamertag). 

11 Reused as the suffix for all their agents' global names. 

12 

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 

21 

22from sqlalchemy.orm import Session 

23 

24logger = logging.getLogger('hevolve_social') 

25 

26# ─── Fallback Word Lists (used when LLM is unavailable) ─── 

27 

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] 

43 

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]) 

50 

51# ─── Validation ─── 

52 

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}$') 

56 

57 

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" 

65 

66 name = name.strip().lower() 

67 

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 ) 

73 

74 if len(name) > 47: 

75 return False, "Agent name too long (max 47 characters)" 

76 

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" 

81 

82 return True, None 

83 

84 

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 

89 

90 

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 

100 

101 

102# ─── Handle Validation ─── 

103 

104 

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 

115 

116 

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 

121 

122 

123# ─── Local (2-word) Name Validation ─── 

124 

125 

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 

142 

143 

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()}" 

147 

148 

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 

164 

165 

166# ─── LLM-Powered Generation ─── 

167 

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', '') 

178 

179 

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 [] 

185 

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 ) 

200 

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 [] 

216 

217 

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 

224 

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) 

243 

244 return results 

245 

246 

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. 

251 

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) 

258 

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 

276 

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)) 

281 

282 return results