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
« 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.
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
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
26from flask import Blueprint, g, jsonify, request
28from .auth import require_auth, require_admin, optional_auth # noqa: F401 — optional_auth retained for re-export compat with sibling blueprints
30logger = logging.getLogger('hevolve_social')
32thought_experiments_bp = Blueprint('thought_experiments', __name__)
35def _current_user_id() -> str:
36 """Return the authenticated user's id from Flask g, as string.
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 ''
48@thought_experiments_bp.route('/api/social/experiments', methods=['POST'])
49@require_auth
50def create_experiment():
51 """Create a new thought experiment (auth required).
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
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', '')
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
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()
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
105 status = request.args.get('status')
106 limit = request.args.get('limit', 50, type=int)
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()
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
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()
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
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)
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()
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
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()
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).
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
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
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()
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
243 body = request.get_json(silent=True) or {}
244 target_status = body.get('target_status')
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()
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
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()
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).
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
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
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()
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
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()
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
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()
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
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()
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).
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
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)
401 if not user_id:
402 return jsonify({'success': False, 'error': 'user_id required'}), 400
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()
420# ─── Auto Evolve Endpoints ───
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).
429 Burns compute cycles across all hive nodes — restrict to admins to
430 prevent abuse (a user could otherwise force-start N parallel cycles).
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)
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
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
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).
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', '')
483 if not user_id:
484 return jsonify({'success': False, 'error': 'user_id required'}), 400
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
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).
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', '')
506 if not user_id:
507 return jsonify({'success': False, 'error': 'user_id required'}), 400
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