Coverage for integrations / social / api_thought_experiments.py: 77.9%

240 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-12 04:49 +0000

1""" 

2Thought Experiment API Blueprint — Constitutional thought experiment endpoints. 

3 

4POST /api/social/experiments — Create new experiment 

5GET /api/social/experiments — List experiments (filter by status) 

6GET /api/social/experiments/discover — Interest-based discovery with recommendations 

7GET /api/social/experiments/core-ip — List core IP experiments 

8GET /api/social/experiments/<id> — Get experiment detail 

9GET /api/social/experiments/<id>/metrics — Live metrics (camera, build stats, compute) 

10POST /api/social/experiments/<id>/contribute — Record Spark investment 

11POST /api/social/experiments/<id>/vote — Cast vote 

12POST /api/social/experiments/<id>/advance — Advance lifecycle 

13POST /api/social/experiments/<id>/evaluate — Trigger agent evaluation 

14POST /api/social/experiments/<id>/decide — Record decision 

15GET /api/social/experiments/<id>/votes — Get all votes 

16GET /api/social/experiments/<id>/timeline — Get lifecycle timeline 

17 

18Auto Evolve: 

19POST /api/social/experiments/auto-evolve — Start democratic auto-evolve cycle 

20GET /api/social/experiments/auto-evolve/status — Get auto-evolve status 

21POST /api/social/experiments/<id>/pause-evolve — Owner pause iteration 

22POST /api/social/experiments/<id>/resume-evolve — Owner resume iteration 

23""" 

24import logging 

25 

26from flask import Blueprint, g, jsonify, request 

27 

28from .auth import require_auth, require_admin, optional_auth # noqa: F401 — optional_auth retained for re-export compat with sibling blueprints 

29 

30logger = logging.getLogger('hevolve_social') 

31 

32thought_experiments_bp = Blueprint('thought_experiments', __name__) 

33 

34 

35def _current_user_id() -> str: 

36 """Return the authenticated user's id from Flask g, as string. 

37 

38 Used to override body-supplied identity fields (creator_id, voter_id, 

39 user_id) so an authenticated caller cannot impersonate another user. 

40 """ 

41 user = getattr(g, 'user', None) 

42 if user is not None and getattr(user, 'id', None) is not None: 

43 return str(user.id) 

44 # Fallback — should not happen under @require_auth, but defensive 

45 return '' 

46 

47 

48@thought_experiments_bp.route('/api/social/experiments', methods=['POST']) 

49@require_auth 

50def create_experiment(): 

51 """Create a new thought experiment (auth required). 

52 

53 creator_id is taken from the JWT subject — any body-supplied creator_id 

54 is ignored to prevent impersonation. 

55 """ 

56 from .models import get_db 

57 from .thought_experiment_service import ThoughtExperimentService 

58 

59 body = request.get_json(silent=True) or {} 

60 creator_id = _current_user_id() or body.get('creator_id') 

61 title = body.get('title', '') 

62 hypothesis = body.get('hypothesis', '') 

63 

64 if not creator_id or not title or not hypothesis: 

65 return jsonify({ 

66 'success': False, 

67 'error': 'title and hypothesis required', 

68 }), 400 

69 

70 db = get_db() 

71 try: 

72 result = ThoughtExperimentService.create_experiment( 

73 db, creator_id, title, hypothesis, 

74 expected_outcome=body.get('expected_outcome', ''), 

75 intent_category=body.get('intent_category', 'technology'), 

76 decision_type=body.get('decision_type', 'weighted'), 

77 is_core_ip=body.get('is_core_ip', False), 

78 parent_experiment_id=body.get('parent_experiment_id'), 

79 ) 

80 if result: 

81 db.commit() 

82 return jsonify({'success': True, 'data': result}), 201 

83 else: 

84 return jsonify({ 

85 'success': False, 

86 'error': 'Experiment creation failed (ConstitutionalFilter may have blocked it)', 

87 }), 403 

88 except Exception as e: 

89 db.rollback() 

90 logger.error(f"Create experiment error: {e}") 

91 return jsonify({'success': False, 'error': str(e)}), 500 

92 finally: 

93 db.close() 

94 

95 

96@thought_experiments_bp.route('/api/social/experiments', methods=['GET']) 

97@require_auth 

98def list_experiments(): 

99 """List experiments filtered by status. Auth required — P0 hardening: 

100 experiment hypotheses + lifecycle states are agent-internal state and 

101 must not be enumerable by unauthenticated scrapers.""" 

102 from .models import get_db 

103 from .thought_experiment_service import ThoughtExperimentService 

104 

105 status = request.args.get('status') 

106 limit = request.args.get('limit', 50, type=int) 

107 

108 db = get_db() 

109 try: 

110 experiments = ThoughtExperimentService.get_active_experiments( 

111 db, status=status, limit=limit) 

112 return jsonify({'success': True, 'data': experiments}), 200 

113 except Exception as e: 

114 logger.error(f"List experiments error: {e}") 

115 return jsonify({'success': False, 'error': str(e)}), 500 

116 finally: 

117 db.close() 

118 

119 

120@thought_experiments_bp.route('/api/social/experiments/core-ip', methods=['GET']) 

121@require_auth 

122def core_ip_experiments(): 

123 """List experiments flagged as core IP. Auth required — core-IP list 

124 exposes the hive's strategic research programme; not for unauthenticated 

125 scrapers.""" 

126 from .models import get_db 

127 from .thought_experiment_service import ThoughtExperimentService 

128 

129 db = get_db() 

130 try: 

131 experiments = ThoughtExperimentService.get_core_ip_experiments(db) 

132 return jsonify({'success': True, 'data': experiments}), 200 

133 except Exception as e: 

134 logger.error(f"Core IP experiments error: {e}") 

135 return jsonify({'success': False, 'error': str(e)}), 500 

136 finally: 

137 db.close() 

138 

139 

140@thought_experiments_bp.route('/api/social/experiments/discover', methods=['GET']) 

141@require_auth 

142def discover_experiments(): 

143 """Interest-based experiment discovery with personalised recommendations. 

144 Auth required — the discovery result exposes agent-internal ranking 

145 signals and experiment catalogue; personalisation uses the JWT subject.""" 

146 from .models import get_db 

147 from .experiment_discovery_service import ExperimentDiscoveryService 

148 

149 user_id = request.args.get('user_id') 

150 intent = request.args.get('intent_category') 

151 exp_type = request.args.get('experiment_type') 

152 status = request.args.get('status') 

153 limit = request.args.get('limit', 25, type=int) 

154 offset = request.args.get('offset', 0, type=int) 

155 

156 db = get_db() 

157 try: 

158 result = ExperimentDiscoveryService.discover( 

159 db, user_id=user_id, intent_filter=intent, 

160 experiment_type=exp_type, status_filter=status, 

161 limit=limit, offset=offset) 

162 return jsonify({ 

163 'success': True, 

164 'data': result['experiments'], 

165 'meta': result['meta'], 

166 }), 200 

167 except Exception as e: 

168 logger.error(f"Discover experiments error: {e}") 

169 return jsonify({'success': False, 'error': str(e)}), 500 

170 finally: 

171 db.close() 

172 

173 

174@thought_experiments_bp.route('/api/social/experiments/<experiment_id>', methods=['GET']) 

175@require_auth 

176def get_experiment(experiment_id): 

177 """Get experiment detail with votes and timeline. Auth required.""" 

178 from .models import get_db 

179 from .thought_experiment_service import ThoughtExperimentService 

180 

181 db = get_db() 

182 try: 

183 result = ThoughtExperimentService.get_experiment_detail( 

184 db, experiment_id) 

185 if result: 

186 return jsonify({'success': True, 'data': result}), 200 

187 return jsonify({'success': False, 'error': 'not_found'}), 404 

188 except Exception as e: 

189 logger.error(f"Get experiment error: {e}") 

190 return jsonify({'success': False, 'error': str(e)}), 500 

191 finally: 

192 db.close() 

193 

194 

195@thought_experiments_bp.route('/api/social/experiments/<experiment_id>/vote', 

196 methods=['POST']) 

197@require_auth 

198def vote_experiment(experiment_id): 

199 """Cast a vote on a thought experiment (auth required). 

200 

201 voter_id is taken from the JWT subject — body-supplied voter_id is ignored. 

202 """ 

203 from .models import get_db 

204 from .thought_experiment_service import ThoughtExperimentService 

205 

206 body = request.get_json(silent=True) or {} 

207 voter_id = _current_user_id() or body.get('voter_id') 

208 if not voter_id: 

209 return jsonify({'success': False, 'error': 'voter_id required'}), 400 

210 

211 db = get_db() 

212 try: 

213 result = ThoughtExperimentService.cast_vote( 

214 db, experiment_id, voter_id, 

215 vote_value=body.get('vote_value', 0), 

216 reasoning=body.get('reasoning', ''), 

217 suggestion=body.get('suggestion', ''), 

218 voter_type=body.get('voter_type', 'human'), 

219 confidence=body.get('confidence', 1.0), 

220 ) 

221 if result and 'error' not in result: 

222 db.commit() 

223 return jsonify({'success': True, 'data': result}), 200 

224 if result and 'error' in result: 

225 return jsonify({'success': False, 'error': result['error']}), 400 

226 return jsonify({'success': False, 'error': 'not_found'}), 404 

227 except Exception as e: 

228 db.rollback() 

229 logger.error(f"Vote error: {e}") 

230 return jsonify({'success': False, 'error': str(e)}), 500 

231 finally: 

232 db.close() 

233 

234 

235@thought_experiments_bp.route('/api/social/experiments/<experiment_id>/advance', 

236 methods=['POST']) 

237@require_admin 

238def advance_experiment(experiment_id): 

239 """Advance experiment to next lifecycle phase (admin only).""" 

240 from .models import get_db 

241 from .thought_experiment_service import ThoughtExperimentService 

242 

243 body = request.get_json(silent=True) or {} 

244 target_status = body.get('target_status') 

245 

246 db = get_db() 

247 try: 

248 result = ThoughtExperimentService.advance_status( 

249 db, experiment_id, target_status=target_status) 

250 if result: 

251 db.commit() 

252 return jsonify({'success': True, 'data': result}), 200 

253 return jsonify({'success': False, 'error': 'cannot_advance'}), 400 

254 except Exception as e: 

255 db.rollback() 

256 logger.error(f"Advance error: {e}") 

257 return jsonify({'success': False, 'error': str(e)}), 500 

258 finally: 

259 db.close() 

260 

261 

262@thought_experiments_bp.route('/api/social/experiments/<experiment_id>/evaluate', 

263 methods=['POST']) 

264@require_auth 

265def evaluate_experiment(experiment_id): 

266 """Trigger agent evaluation for an experiment (auth required).""" 

267 from .models import get_db 

268 from .thought_experiment_service import ThoughtExperimentService 

269 

270 db = get_db() 

271 try: 

272 result = ThoughtExperimentService.request_agent_evaluation( 

273 db, experiment_id) 

274 if result.get('success'): 

275 db.commit() 

276 return jsonify(result), 200 if result.get('success') else 400 

277 except Exception as e: 

278 db.rollback() 

279 logger.error(f"Evaluate error: {e}") 

280 return jsonify({'success': False, 'error': str(e)}), 500 

281 finally: 

282 db.close() 

283 

284 

285@thought_experiments_bp.route('/api/social/experiments/<experiment_id>/decide', 

286 methods=['POST']) 

287@require_admin 

288def decide_experiment(experiment_id): 

289 """Record final decision for an experiment (admin only). 

290 

291 Decisions are a privileged lifecycle state change that closes the 

292 experiment — guard against replay/impersonation by restricting to admins. 

293 """ 

294 from .models import get_db 

295 from .thought_experiment_service import ThoughtExperimentService 

296 

297 body = request.get_json(silent=True) or {} 

298 decision_text = body.get('decision', '') 

299 if not decision_text: 

300 return jsonify({'success': False, 'error': 'decision required'}), 400 

301 

302 db = get_db() 

303 try: 

304 result = ThoughtExperimentService.decide( 

305 db, experiment_id, decision_text) 

306 if result and 'error' not in result: 

307 db.commit() 

308 return jsonify({'success': True, 'data': result}), 200 

309 if result and 'error' in result: 

310 return jsonify({'success': False, 'error': result['error']}), 400 

311 return jsonify({'success': False, 'error': 'not_found'}), 404 

312 except Exception as e: 

313 db.rollback() 

314 logger.error(f"Decide error: {e}") 

315 return jsonify({'success': False, 'error': str(e)}), 500 

316 finally: 

317 db.close() 

318 

319 

320@thought_experiments_bp.route('/api/social/experiments/<experiment_id>/votes', 

321 methods=['GET']) 

322@require_auth 

323def experiment_votes(experiment_id): 

324 """Get all votes for an experiment. Auth required.""" 

325 from .models import get_db 

326 from .thought_experiment_service import ThoughtExperimentService 

327 

328 db = get_db() 

329 try: 

330 votes = ThoughtExperimentService.get_experiment_votes( 

331 db, experiment_id) 

332 return jsonify({'success': True, 'data': votes}), 200 

333 except Exception as e: 

334 logger.error(f"Votes error: {e}") 

335 return jsonify({'success': False, 'error': str(e)}), 500 

336 finally: 

337 db.close() 

338 

339 

340@thought_experiments_bp.route('/api/social/experiments/<experiment_id>/timeline', 

341 methods=['GET']) 

342@require_auth 

343def experiment_timeline(experiment_id): 

344 """Get lifecycle timeline for an experiment. Auth required.""" 

345 from .models import get_db 

346 from .thought_experiment_service import ThoughtExperimentService 

347 

348 db = get_db() 

349 try: 

350 timeline = ThoughtExperimentService.get_experiment_timeline( 

351 db, experiment_id) 

352 if timeline: 

353 return jsonify({'success': True, 'data': timeline}), 200 

354 return jsonify({'success': False, 'error': 'not_found'}), 404 

355 except Exception as e: 

356 logger.error(f"Timeline error: {e}") 

357 return jsonify({'success': False, 'error': str(e)}), 500 

358 finally: 

359 db.close() 

360 

361 

362@thought_experiments_bp.route('/api/social/experiments/<experiment_id>/metrics', 

363 methods=['GET']) 

364@require_auth 

365def experiment_metrics(experiment_id): 

366 """Get live metrics for an experiment (camera feed, build stats, compute). 

367 Auth required — live camera/compute feeds are agent-internal signals.""" 

368 from .models import get_db 

369 from .experiment_discovery_service import ExperimentDiscoveryService 

370 

371 db = get_db() 

372 try: 

373 metrics = ExperimentDiscoveryService.get_experiment_metrics( 

374 db, experiment_id) 

375 if metrics: 

376 return jsonify({'success': True, 'data': metrics}), 200 

377 return jsonify({'success': False, 'error': 'not_found'}), 404 

378 except Exception as e: 

379 logger.error(f"Experiment metrics error: {e}") 

380 return jsonify({'success': False, 'error': str(e)}), 500 

381 finally: 

382 db.close() 

383 

384 

385@thought_experiments_bp.route('/api/social/experiments/<experiment_id>/contribute', 

386 methods=['POST']) 

387@require_auth 

388def contribute_to_experiment(experiment_id): 

389 """Record a Spark contribution to an experiment (auth required). 

390 

391 user_id is taken from the JWT subject — body-supplied user_id is ignored 

392 so a caller cannot spend another user's Spark balance. 

393 """ 

394 from .models import get_db 

395 from .experiment_discovery_service import ExperimentDiscoveryService 

396 

397 body = request.get_json(silent=True) or {} 

398 user_id = _current_user_id() or body.get('user_id') 

399 spark_amount = body.get('spark_amount', 0) 

400 

401 if not user_id: 

402 return jsonify({'success': False, 'error': 'user_id required'}), 400 

403 

404 db = get_db() 

405 try: 

406 result = ExperimentDiscoveryService.record_contribution( 

407 db, experiment_id, user_id, spark_amount) 

408 if result: 

409 db.commit() 

410 return jsonify({'success': True, 'data': result}), 200 

411 return jsonify({'success': False, 'error': 'not_found'}), 404 

412 except Exception as e: 

413 db.rollback() 

414 logger.error(f"Contribute error: {e}") 

415 return jsonify({'success': False, 'error': str(e)}), 500 

416 finally: 

417 db.close() 

418 

419 

420# ─── Auto Evolve Endpoints ─── 

421 

422 

423@thought_experiments_bp.route('/api/social/experiments/auto-evolve', 

424 methods=['POST']) 

425@require_admin 

426def start_auto_evolve(): 

427 """Start a democratic auto-evolve cycle (admin only). 

428 

429 Burns compute cycles across all hive nodes — restrict to admins to 

430 prevent abuse (a user could otherwise force-start N parallel cycles). 

431 

432 Gathers eligible experiments → constitutional filter → vote tally → 

433 dispatch top-N winners to type-aware iteration loops. 

434 """ 

435 body = request.get_json(silent=True) or {} 

436 user_id = _current_user_id() or body.get('user_id', 'system') 

437 max_experiments = body.get('max_experiments', 5) 

438 min_approval = body.get('min_approval_score', 0.3) 

439 

440 try: 

441 from integrations.agent_engine.auto_evolve import get_auto_evolve_orchestrator 

442 orch = get_auto_evolve_orchestrator() 

443 result = orch.start( 

444 max_experiments=int(max_experiments), 

445 min_approval_score=float(min_approval), 

446 user_id=user_id, 

447 ) 

448 status_code = 200 if result.get('success') else 409 

449 return jsonify(result), status_code 

450 except Exception as e: 

451 logger.error(f"Auto-evolve start error: {e}") 

452 return jsonify({'success': False, 'error': str(e)}), 500 

453 

454 

455@thought_experiments_bp.route('/api/social/experiments/auto-evolve/status', 

456 methods=['GET']) 

457@require_auth 

458def auto_evolve_status(): 

459 """Get the current auto-evolve cycle status. Auth required — the status 

460 payload reveals which experiments the hive is currently iterating on, 

461 which is agent-internal strategic state.""" 

462 try: 

463 from integrations.agent_engine.auto_evolve import get_auto_evolve_orchestrator 

464 orch = get_auto_evolve_orchestrator() 

465 return jsonify(orch.get_status()), 200 

466 except Exception as e: 

467 return jsonify({'error': str(e)}), 500 

468 

469 

470@thought_experiments_bp.route( 

471 '/api/social/experiments/<experiment_id>/pause-evolve', methods=['POST']) 

472@require_auth 

473def pause_evolve(experiment_id): 

474 """Pause a running experiment's evolution (auth required, owner or admin). 

475 

476 user_id is taken from the JWT subject so a caller cannot pause another 

477 user's experiment by forging the body. Downstream service still enforces 

478 the owner check via pause_experiment_evolution(..., user_id). 

479 """ 

480 body = request.get_json(silent=True) or {} 

481 user_id = _current_user_id() or body.get('user_id', '') 

482 

483 if not user_id: 

484 return jsonify({'success': False, 'error': 'user_id required'}), 400 

485 

486 try: 

487 from integrations.agent_engine.auto_evolve import pause_experiment_evolution 

488 result = pause_experiment_evolution(experiment_id, user_id) 

489 status_code = 200 if result.get('success') else 403 

490 return jsonify(result), status_code 

491 except Exception as e: 

492 return jsonify({'success': False, 'error': str(e)}), 500 

493 

494 

495@thought_experiments_bp.route( 

496 '/api/social/experiments/<experiment_id>/resume-evolve', methods=['POST']) 

497@require_auth 

498def resume_evolve(experiment_id): 

499 """Resume a paused experiment's evolution (auth required, owner or admin). 

500 

501 user_id is taken from the JWT subject; downstream service enforces owner. 

502 """ 

503 body = request.get_json(silent=True) or {} 

504 user_id = _current_user_id() or body.get('user_id', '') 

505 

506 if not user_id: 

507 return jsonify({'success': False, 'error': 'user_id required'}), 400 

508 

509 try: 

510 from integrations.agent_engine.auto_evolve import resume_experiment_evolution 

511 result = resume_experiment_evolution(experiment_id, user_id) 

512 status_code = 200 if result.get('success') else 403 

513 return jsonify(result), status_code 

514 except Exception as e: 

515 return jsonify({'success': False, 'error': str(e)}), 500