passiveworkers 0.1.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.
council/net/app.py ADDED
@@ -0,0 +1,390 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ council/net/app.py — the Living Council Map (Phase E, map-forward)
4
+ =================================================================
5
+ The end-user experience, served by the coordinator at GET /. A single self-contained
6
+ page (Leaflet from CDN; no build step). You sign in with a handle, ask the council a
7
+ question, and WATCH a global council of diverse minds deliberate on a world map — nodes
8
+ glow while thinking, arcs flow asker→nodes→judge→answer — then the side panel shows the
9
+ terse merge (TL;DR), where the minds AGREE vs DIFFER (by country), and a one-tap compare
10
+ of the council vs a single model with a ▲/▼ "was it more useful?" vote (the demand signal).
11
+
12
+ All server-supplied strings are HTML-escaped (same XSS discipline as the dashboard).
13
+ """
14
+
15
+ APP_HTML = r"""<!doctype html>
16
+ <html lang="en">
17
+ <head>
18
+ <meta charset="utf-8"/>
19
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
20
+ <title>Passive Workers — the Council</title>
21
+ <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
22
+ <style>
23
+ :root{--bg:#0a0e1c;--panel:#0f1730;--edge:#21305e;--ink:#e6ecff;--mut:#8aa0d0;
24
+ --good:#36d399;--warn:#fbbd23;--bad:#f87272;--acc:#6ea8ff;}
25
+ *{box-sizing:border-box} html,body{margin:0;height:100%;background:var(--bg);color:var(--ink);
26
+ font:14.5px/1.5 -apple-system,Segoe UI,Roboto,sans-serif}
27
+ #wrap{display:grid;grid-template-columns:1fr 400px;height:100vh}
28
+ #map{height:100vh;background:#0a0e1c}
29
+ #panel{background:var(--panel);border-left:1px solid var(--edge);overflow:auto;padding:18px 18px 40px}
30
+ h1{font-size:17px;margin:0} .brand{display:flex;justify-content:space-between;align-items:baseline}
31
+ .sub{color:var(--mut);font-size:12px;margin:2px 0 14px}
32
+ .me{font-size:12.5px;color:var(--mut)} .me b{color:#fff}
33
+ textarea{width:100%;background:#0c1430;color:var(--ink);border:1px solid var(--edge);border-radius:10px;
34
+ padding:10px 12px;font:inherit;resize:vertical;min-height:64px}
35
+ button{font:inherit;cursor:pointer;border:0;border-radius:10px;padding:9px 14px;color:#04122e;
36
+ background:var(--acc);font-weight:600} button.ghost{background:#1b2750;color:var(--ink)}
37
+ button:disabled{opacity:.5;cursor:default}
38
+ .row{display:flex;gap:8px;align-items:center} .between{justify-content:space-between}
39
+ .card{background:#0c1430;border:1px solid var(--edge);border-radius:12px;padding:12px 14px;margin:12px 0}
40
+ .persp{display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px dashed #1b2750}
41
+ .persp:last-child{border-bottom:0}
42
+ .dot{width:9px;height:9px;border-radius:50%;display:inline-block;flex:0 0 auto}
43
+ .pill{font-size:11px;padding:2px 7px;border-radius:999px;background:#1b2750;color:var(--mut)}
44
+ .flag{font-size:16px} .muted{color:var(--mut)} .tl{font-size:15px;line-height:1.55}
45
+ h3{font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:var(--mut);margin:14px 0 6px}
46
+ ul{margin:4px 0;padding-left:18px} li{margin:3px 0}
47
+ .agree li{color:#bfe9d4} .differ li{color:#ffd9a8}
48
+ .vote button{padding:7px 12px} .thanks{color:var(--good);font-size:13px}
49
+ .leaflet-popup-content-wrapper,.leaflet-popup-tip{background:#0f1730;color:var(--ink)}
50
+ @keyframes pulse{0%,100%{stroke-opacity:.35;fill-opacity:.15}50%{stroke-opacity:1;fill-opacity:.55}}
51
+ .thinking{animation:pulse 1.15s infinite}
52
+ @keyframes flow{to{stroke-dashoffset:-20}}
53
+ .arc{stroke-dasharray:3 7;animation:flow .7s linear infinite}
54
+ @media(max-width:820px){#wrap{grid-template-columns:1fr;grid-template-rows:42vh 1fr}#map{height:42vh}}
55
+ </style>
56
+ </head>
57
+ <body>
58
+ <div id="wrap">
59
+ <div id="map"></div>
60
+ <div id="panel">
61
+ <div class="brand"><h1>🌍 Passive&nbsp;Workers</h1><span class="me" id="me"></span></div>
62
+ <div class="sub">A global council of diverse minds — watch them deliberate.</div>
63
+ <div class="sub" id="netstat" style="margin-top:-10px"></div>
64
+
65
+ <div id="auth" class="card" style="display:none">
66
+ <div class="row between"><b>Pick a handle to begin</b></div>
67
+ <div class="row" style="margin-top:8px"><input id="handle" placeholder="e.g. ahmed"
68
+ style="flex:1;background:#0c1430;color:var(--ink);border:1px solid var(--edge);border-radius:10px;padding:9px 11px;font:inherit"/>
69
+ <button id="signin">Start</button></div>
70
+ <div class="muted" id="authmsg" style="margin-top:6px;font-size:12px"></div>
71
+ </div>
72
+
73
+ <div id="askbox">
74
+ <textarea id="q" placeholder="Ask the council anything…"></textarea>
75
+ <textarea id="items" placeholder="Items — one per line. Every computer gets a slice."
76
+ style="display:none;margin-top:6px;min-height:90px"></textarea>
77
+ <div class="row between" style="margin-top:8px">
78
+ <span class="muted" id="hint">3 diverse minds will answer · costs 35 credits</span>
79
+ <span class="row" style="gap:8px">
80
+ <select id="jtype" title="what kind of work"
81
+ style="background:#0c1430;color:var(--ink);border:1px solid var(--edge);border-radius:8px;padding:6px 8px;font:inherit">
82
+ <option value="chat" selected>💬 Ask</option>
83
+ <option value="research_report">🔬 Deep research</option>
84
+ <option value="shard_map">⚙️ Batch</option>
85
+ </select>
86
+ <select id="minds" title="how many minds answer (cost scales)"
87
+ style="background:#0c1430;color:var(--ink);border:1px solid var(--edge);border-radius:8px;padding:6px 8px;font:inherit">
88
+ <option>1</option><option>2</option><option selected>3</option><option>4</option><option>5</option>
89
+ </select>
90
+ <button id="ask">Ask the council →</button>
91
+ </span>
92
+ </div>
93
+ </div>
94
+
95
+ <div id="live" style="display:none"></div>
96
+ <div id="answer" style="display:none"></div>
97
+
98
+ <details class="card" id="histcard" style="display:none;margin-top:14px">
99
+ <summary class="muted">🕘 My questions</summary>
100
+ <div id="hist"></div>
101
+ </details>
102
+
103
+ <details class="card" style="margin-top:18px"><summary class="muted">⏻ Contribute your computer (earn credits)</summary>
104
+ <div class="muted" style="font-size:12.5px;margin-top:8px">Run a worker so your machine joins the council and earns credits for your handle:</div>
105
+ <pre id="contribute" style="white-space:pre-wrap;background:#0a1126;border:1px solid var(--edge);border-radius:8px;padding:8px;font-size:11.5px;overflow:auto"></pre>
106
+ </details>
107
+ </div>
108
+ </div>
109
+ <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
110
+ <script>
111
+ const CENTROIDS={FI:[61.9,25.7],AE:[23.4,53.8],US:[39.8,-98.6],DE:[51.2,10.4],BR:[-14.2,-51.9],
112
+ GB:[54,-2],FR:[46.6,2.2],IN:[21,78],SG:[1.35,103.8],JP:[36.2,138.3],NL:[52.1,5.3],CA:[56,-106],
113
+ AU:[-25,133],ZA:[-29,24],NG:[9,8],KE:[0.2,37.9],EG:[26,30],SA:[24,45],IQ:[33,44],TR:[39,35],
114
+ RU:[61,105],CN:[35,105],KR:[36,128],ID:[-2,118],VN:[14,108],MX:[23,-102],ES:[40,-3.7],IT:[42.8,12.8],
115
+ SE:[62,15],PL:[52,19]};
116
+ const YOU=[25.2,55.3]; // asker anchor (overridden by geolocation if allowed)
117
+ let you=YOU.slice();
118
+ function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}
119
+ function cc(s){return String(s||'').replace(/^sim-/,'').toUpperCase()}
120
+ function flag(s){const c=cc(s);if(!/^[A-Z]{2}$/.test(c))return '🖥';return String.fromCodePoint(...[...c].map(x=>0x1F1A5+x.charCodeAt(0)))}
121
+ function centroid(country){return CENTROIDS[cc(country)]||[10,-30]}
122
+ function jit(k){k=String(k||'');let h=0;for(const ch of k)h=(h*31+ch.charCodeAt(0))&255;return (h/255-.5)*7}
123
+ function statusColor(s){return s==='answered'?'#36d399':s==='thinking'?'#fbbd23':'#6ea8ff'}
124
+
125
+ const map=L.map('map',{worldCopyJump:true,zoomControl:false}).setView([28,20],2.4);
126
+ L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',{maxZoom:8,attribution:'© OSM © CARTO'}).addTo(map);
127
+ const ambient=L.layerGroup().addTo(map), jobLayer=L.layerGroup().addTo(map);
128
+
129
+ // ---- auth ----
130
+ let handle=localStorage.getItem('pw_handle'), secret=localStorage.getItem('pw_secret');
131
+ function uHeaders(){return secret?{'X-User-Secret':secret,'Content-Type':'application/json'}:{'Content-Type':'application/json'}}
132
+ async function refreshMe(){
133
+ const auth=document.getElementById('auth'),askbox=document.getElementById('askbox');
134
+ if(!secret){auth.style.display='block';askbox.style.display='none';document.getElementById('me').textContent='';return}
135
+ auth.style.display='none';askbox.style.display='';
136
+ try{const m=await (await fetch('/me',{headers:uHeaders()})).json();
137
+ document.getElementById('me').innerHTML='@'+esc(m.handle)+' · <b>'+m.balance+'</b> cr';
138
+ document.getElementById('contribute').textContent=
139
+ 'PW_COORDINATOR='+location.origin+' PW_TOKEN=<operator-token> \\\n PW_OWNER='+m.handle+
140
+ ' PW_NAME=my-pc PW_COUNTRY=<XX> PW_ANSWER_MODEL=gemma3:4b PW_LENS=practical \\\n python -m council.net.agent';
141
+ }catch(e){}
142
+ refreshHistory();
143
+ }
144
+ // ---- question history (persistent, deep-linkable artifacts) ----
145
+ async function refreshHistory(){
146
+ if(!secret)return;
147
+ try{
148
+ const r=await fetch('/jobs/mine',{headers:uHeaders()});if(!r.ok)return;
149
+ const l=await r.json(),hc=document.getElementById('histcard'),el=document.getElementById('hist');
150
+ if(!l.length){hc.style.display='none';return}
151
+ hc.style.display='';
152
+ el.innerHTML=l.map(j=>{
153
+ const ic=(j.status==='done'?'✓':j.status==='failed'?'✗':'…')+(j.type==='research_report'?' 🔬':'');
154
+ return '<div style="cursor:pointer;padding:6px 2px;border-top:1px dashed #1b2750" '+
155
+ 'onclick="openJob(\''+esc(j.job_id)+'\')">'+ic+' '+esc((j.question||'').slice(0,90))+'</div>';
156
+ }).join('');
157
+ }catch(e){}
158
+ }
159
+ document.getElementById('signin').onclick=async()=>{
160
+ const h=document.getElementById('handle').value.trim();if(!h)return;
161
+ const r=await fetch('/users',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({handle:h})});
162
+ if(r.status===409){document.getElementById('authmsg').textContent='“'+h+'” is taken — try another.';return}
163
+ if(!r.ok){document.getElementById('authmsg').textContent='Could not start. Try again.';return}
164
+ const d=await r.json();handle=d.handle;secret=d.user_secret;
165
+ localStorage.setItem('pw_handle',handle);localStorage.setItem('pw_secret',secret);
166
+ document.getElementById('auth').style.display='none';refreshMe();
167
+ };
168
+
169
+ // ---- ambient online nodes (so the map breathes before you ask) ----
170
+ function byMachine(arr){const m={};for(const x of arr||[]){(m[x.machine_key]=m[x.machine_key]||{country:x.country,items:[]}).items.push(x);}return m;}
171
+ async function ambientTick(){
172
+ try{const d=await (await fetch('/status')).json();ambient.clearLayers();
173
+ const hdr=document.getElementById('netstat');
174
+ if(hdr)hdr.textContent='🖥 '+(d.machines||0)+' machine'+((d.machines===1)?'':'s')+' · '+(d.minds||0)+' minds online';
175
+ const byM=byMachine(d.online_nodes);
176
+ for(const mk in byM){const m=byM[mk],c=centroid(m.country);
177
+ L.circleMarker([c[0]+jit(mk),c[1]+jit(mk+'x')],
178
+ {radius:5,color:'#33406b',fillColor:'#33406b',fillOpacity:.55,weight:1}).addTo(ambient)
179
+ .bindPopup('<b>'+flag(m.country)+' '+esc(cc(m.country))+'</b><br>'+m.items.length+' mind(s): '+
180
+ esc(m.items.map(x=>x.answer_model||'judge').join(', ')));
181
+ }
182
+ }catch(e){}
183
+ }
184
+
185
+ // ---- ask + live render ----
186
+ let polling=null,bwait=0,lastAns='';
187
+ function openJob(id){ // watch any job live (new ask, history click, or #job= link)
188
+ if(polling)clearInterval(polling);
189
+ bwait=0;lastAns='';
190
+ document.getElementById('answer').style.display='none';
191
+ location.hash='job='+id;
192
+ poll(id);polling=setInterval(()=>poll(id),1500);
193
+ }
194
+ // responder dial + job type: cost preview (per-mind 10 cr, research ×3, judge 5 — server defaults)
195
+ function jt(){return (document.getElementById('jtype')||{}).value||'chat'}
196
+ function updateHint(){
197
+ const n=+(document.getElementById('minds').value||3);
198
+ const t=jt();
199
+ const per=t==='research_report'?30:t==='shard_map'?20:10;
200
+ const cost=n*per+5;
201
+ const itemsEl=document.getElementById('items');
202
+ if(itemsEl)itemsEl.style.display=t==='shard_map'?'':'none';
203
+ const qEl=document.getElementById('q');
204
+ if(qEl)qEl.placeholder=t==='shard_map'?'Instruction to apply to every item (e.g. “Classify the sentiment as POS/NEG/NEU”)…'
205
+ :t==='research_report'?'Research brief — what should the world find out for you?':'Ask the council anything…';
206
+ document.getElementById('hint').textContent=
207
+ t==='research_report'? n+' computer'+(n===1?'':'s')+' will research the live web from their own countries · '+cost+' credits · ~20–40 min'
208
+ :t==='shard_map'? 'the items are SPLIT across '+n+' computer'+(n===1?'':'s')+' (≈'+n+'× faster) · '+cost+' credits'
209
+ : n+' diverse mind'+(n===1?'':'s')+' will answer · costs '+cost+' credits';
210
+ }
211
+ document.getElementById('minds').onchange=updateHint;
212
+ document.getElementById('jtype').onchange=updateHint;
213
+ document.getElementById('ask').onclick=async()=>{
214
+ if(!secret){document.getElementById('auth').style.display='block';return}
215
+ const q=document.getElementById('q').value.trim();if(!q)return;
216
+ document.getElementById('answer').style.display='none';
217
+ const minds=+(document.getElementById('minds').value||3);
218
+ const body={question:q,minds:minds,type:jt()};
219
+ if(jt()==='shard_map'){
220
+ const items=(document.getElementById('items').value||'').split('\n').map(x=>x.trim()).filter(Boolean);
221
+ if(!items.length){document.getElementById('hint').textContent='⚠ add items (one per line) for a batch job';return}
222
+ body.items=items;
223
+ }
224
+ const r=await fetch('/jobs',{method:'POST',headers:uHeaders(),body:JSON.stringify(body)});
225
+ const j=await r.json();
226
+ if(j.status==='failed'){document.getElementById('live').style.display='block';
227
+ document.getElementById('live').innerHTML='<div class="card" style="color:var(--bad)">✗ '+esc(j.error||'failed')+'</div>';return}
228
+ if(j.balance)document.getElementById('me').innerHTML='@'+esc(j.balance.handle)+' · <b>'+j.balance.balance+'</b> cr';
229
+ openJob(j.job_id);
230
+ };
231
+
232
+ function drawMap(v){
233
+ jobLayer.clearLayers();
234
+ L.circleMarker(you,{radius:7,color:'#fff',fillColor:'#6ea8ff',fillOpacity:.9,weight:2}).addTo(jobLayer).bindPopup('you (asker)');
235
+ const jc=v.judge_country?centroid(v.judge_country):null;
236
+ // group minds by physical machine — one marker per computer (honest topology)
237
+ const byM=byMachine(v.answers);
238
+ for(const mk in byM){const m=byM[mk],c=centroid(m.country);
239
+ const p=[c[0]+jit(mk),c[1]+jit(mk+'x')];
240
+ const anyThinking=m.items.some(x=>x.status_label==='thinking');
241
+ const allAnswered=m.items.every(x=>x.status_label==='answered');
242
+ const col=allAnswered?'#36d399':anyThinking?'#fbbd23':'#6ea8ff';
243
+ L.polyline([you,p],{color:col,weight:1.4,opacity:.55,className:'arc'}).addTo(jobLayer);
244
+ if(jc&&allAnswered&&mk!==v.judge_machine_key)L.polyline([p,jc],{color:'#6ea8ff',weight:1.2,opacity:.4,className:'arc'}).addTo(jobLayer);
245
+ L.circleMarker(p,{radius:9,color:col,fillColor:col,fillOpacity:.5,weight:2,className:anyThinking?'thinking':''}).addTo(jobLayer)
246
+ .bindPopup('<b>'+flag(m.country)+' '+esc(cc(m.country))+'</b> — '+m.items.length+' mind(s)<br>'+
247
+ m.items.map(x=>esc(x.model)+' · '+esc(x.lens)+' — '+esc(x.status_label)+(x.score!=null?' '+x.score:'')).join('<br>'));
248
+ }
249
+ if(jc)L.circleMarker(jc,{radius:9,color:'#c4b5fd',fillColor:'#c4b5fd',
250
+ fillOpacity:v.judge_status==='done'?.6:.25,weight:2,className:v.judge_status==='claimed'?'thinking':''})
251
+ .addTo(jobLayer).bindPopup('⚖️ judge · '+esc(cc(v.judge_country)));
252
+ }
253
+
254
+ // minimal markdown for the report deliverable (escape FIRST, then transform)
255
+ function md(t){
256
+ let h=esc(t||'');
257
+ h=h.replace(/^### (.*)$/gm,'<h4>$1</h4>').replace(/^## (.*)$/gm,'<h3>$1</h3>').replace(/^# (.*)$/gm,'<h3>$1</h3>');
258
+ h=h.replace(/\*\*([^*]+)\*\*/g,'<b>$1</b>');
259
+ h=h.replace(/(https?:\/\/[^\s<)\]]+)/g,'<a href="$1" target="_blank" rel="noopener" style="color:#6ea8ff;word-break:break-all">$1</a>');
260
+ h=h.replace(/^[-•] (.*)$/gm,'<li>$1</li>');
261
+ h=h.replace(/\n{2,}/g,'</p><p>').replace(/\n/g,'<br>');
262
+ return '<div class="tl"><p>'+h+'</p></div>';
263
+ }
264
+ function renderLive(v){
265
+ const el=document.getElementById('live');el.style.display='block';
266
+ const machines=new Set((v.answers||[]).map(a=>a.machine_key)).size;
267
+ const research=v.type==='research_report',batch=v.type==='shard_map';
268
+ let h='<div class="card"><div class="row between"><b>'+
269
+ (research
270
+ ? (v.status==='judging'?'Compiling your report 📝':v.status==='done'?'Report ready 📄':'Researching the live web from '+machines+' countr'+(machines===1?'y':'ies')+' 🔍')
271
+ : batch
272
+ ? (v.status==='judging'?'Spot-checking quality 🔎':v.status==='done'?'Batch done ⚙️':'Computers are working through the batch ⚙️')
273
+ : ('The council is '+(v.status==='judging'?'deliberating ⚖️':v.status==='done'?'decided ✓':'thinking…')))+'</b>'+
274
+ '<span class="muted">'+machines+' machine'+(machines===1?'':'s')+' · '+(v.answers||[]).filter(a=>a.status_label==='answered').length+'/'+(v.answers||[]).length+' minds</span></div>';
275
+ if(research&&v.status!=='done')h+='<div class="muted" style="font-size:12.5px;margin:6px 0 2px">Real research takes time (~20–40 min). You can close this page — the report will be under 🕘 My questions.</div>';
276
+ for(const a of v.answers||[]){
277
+ h+='<div class="persp"><span class="dot" style="background:'+statusColor(a.status_label)+'"></span>'+
278
+ '<span class="flag">'+flag(a.country)+'</span><span><b>'+esc(cc(a.country))+'</b> '+
279
+ '<span class="muted">'+esc(a.model)+' · '+esc(a.lens)+'</span></span>'+
280
+ '<span class="pill" style="margin-left:auto">'+esc(a.status_label)+(a.score!=null?' '+a.score:'')+'</span></div>';
281
+ }
282
+ el.innerHTML=h+'</div>';
283
+ }
284
+
285
+ function renderAnswer(v){
286
+ const A=document.getElementById('answer');A.style.display='block';
287
+ const co=v.council||{};
288
+ const ext=(v.baseline&&v.baseline.text)?v.baseline:null; // independent single model
289
+ const base=ext||(v.answers||[]).find(a=>a.is_baseline); // fallback: best council mind
290
+ let h;
291
+ if(v.type==='shard_map'){
292
+ // the deliverable: the assembled batch results, in input order
293
+ let rows=[];try{rows=JSON.parse(v.merged||'[]')}catch(e){}
294
+ h='<div class="card"><h3>⚙️ Batch results · '+rows.length+' items</h3>';
295
+ if(rows.length){
296
+ h+='<div style="max-height:420px;overflow:auto"><table style="width:100%;border-collapse:collapse;font-size:12.5px">'+
297
+ '<tr><th style="text-align:left;padding:4px;border-bottom:1px solid var(--edge)">item</th>'+
298
+ '<th style="text-align:left;padding:4px;border-bottom:1px solid var(--edge)">output</th></tr>'+
299
+ rows.map(r=>'<tr><td style="padding:4px;border-bottom:1px dashed #1b2750;vertical-align:top;max-width:38%">'+esc(String(r.item||'').slice(0,200))+'</td>'+
300
+ '<td style="padding:4px;border-bottom:1px dashed #1b2750">'+esc(String(r.output||'').slice(0,500))+'</td></tr>').join('')+
301
+ '</table></div>'+
302
+ '<button class="ghost" id="copyjsonl" style="margin-top:8px">copy as JSONL</button>';
303
+ }else{h+='<div class="muted">'+esc(v.merged||'(no results)')+'</div>'}
304
+ h+='</div>';
305
+ }else if(v.type==='research_report'){
306
+ // the deliverable: a cited multi-country report (markdown from the editor pass)
307
+ h='<div class="card">'+md(v.merged||'')+'</div>';
308
+ }else{
309
+ h='<div class="card"><h3>The council’s answer</h3><div class="tl">'+esc(v.merged||'')+'</div>';
310
+ if((co.consensus||[]).length){h+='<h3>Where they agree</h3><ul class="agree">'+co.consensus.map(x=>'<li>'+esc(x)+'</li>').join('')+'</ul>'}
311
+ if((co.disagreements||[]).length){h+='<h3>Where they differ</h3><ul class="differ">'+
312
+ co.disagreements.map(d=>'<li>'+esc(d.point)+(d.sides?(' — <span class="muted">'+esc(d.sides)+'</span>'):'')+'</li>').join('')+'</ul>'}
313
+ if((co.unique||[]).length){h+='<h3>Only one mind raised</h3><ul>'+co.unique.map(u=>{
314
+ const who=(v.answers||[]).find(a=>a.worker_id===u.worker_id);
315
+ return '<li>'+(who?flag(who.country)+' ':'')+esc(u.point)+'</li>'}).join('')+'</ul>'}
316
+ h+='</div>';
317
+ }
318
+ // A2 — every node's individual answer (read each one)
319
+ h+='<details class="card"><summary class="muted">▸ The '+(v.answers||[]).length+' individual answers (read each mind)</summary>';
320
+ for(const a of v.answers||[]){
321
+ h+='<div style="margin-top:10px;border-top:1px dashed #1b2750;padding-top:8px"><div class="row between">'+
322
+ '<b>'+flag(a.country)+' '+esc(cc(a.country))+' <span class="muted">'+esc(a.model)+' · '+esc(a.lens)+'</span></b>'+
323
+ '<span class="pill">'+(a.score!=null?a.score+'/10':'…')+(a.is_baseline?' · best single':'')+'</span></div>'+
324
+ '<div class="tl muted" style="margin-top:5px">'+esc(a.text||'(no answer)')+'</div></div>';
325
+ }
326
+ h+='</details>';
327
+ if(base&&v.type!=='shard_map'){
328
+ const tag=ext?((ext.source==='api'?'🌐 ':'🖥 ')+esc(ext.model)+' · independent')
329
+ :(flag(base.country)+' '+esc(base.model)+' · best council mind');
330
+ h+='<div class="card"><div class="row between"><h3 style="margin:0">vs a single model'+
331
+ ' <span class="muted">('+tag+')</span></h3>'+
332
+ '<button class="ghost" id="cmp">compare</button></div>'+
333
+ '<div id="single" class="tl muted" style="display:none;margin-top:8px">'+esc(base.text||'')+'</div></div>';}
334
+ // A3 — what just happened / credits
335
+ const rec=v.receipt||{},pay=rec.payouts||{};
336
+ h+='<details class="card"><summary class="muted">▸ What just happened · credits</summary>'+
337
+ '<div class="muted" style="font-size:12.5px;margin-top:8px">Credits are <b>non-tradeable</b> — you earn them when your computer helps answer others, and spend them when you ask. They’re not money and can’t be traded.</div>'+
338
+ '<div style="margin-top:8px">You spent <b>'+(+(rec.total_cost||0)).toFixed(0)+'</b> credits for this question.</div>'+
339
+ '<div style="margin-top:6px" class="muted">each computer’s contribution:</div><ul>';
340
+ for(const a of v.answers||[]){
341
+ const earned=(pay[a.owner]!=null)?(' → earned '+(+pay[a.owner]).toFixed(1)+' cr'):'';
342
+ const sp=(a.tokens&&a.elapsed_s)?(esc(a.tokens+' tokens in '+(+a.elapsed_s).toFixed(1)+'s · '+(a.tokens/Math.max(0.1,a.elapsed_s)).toFixed(0)+' tok/s')):'(pending)';
343
+ h+='<li>'+flag(a.country)+' '+esc(a.model)+' — '+sp+earned+'</li>';
344
+ }
345
+ h+='</ul></details>';
346
+ if(v.type!=='shard_map'){
347
+ h+='<div class="card vote"><div class="row between"><b>'+(v.type==='research_report'
348
+ ?'Was this report more useful than the instant single-model answer?'
349
+ :'Was the council more useful than one model?')+'</b></div>'+
350
+ '<div class="row" style="margin-top:8px;gap:8px"><button id="vc">▲ Council</button>'+
351
+ '<button class="ghost" id="vt">tie</button><button class="ghost" id="vs">▼ One model</button>'+
352
+ '<span class="thanks" id="thx" style="margin-left:auto"></span></div></div>';
353
+ }
354
+ A.innerHTML=h;
355
+ const c=document.getElementById('cmp');if(c)c.onclick=()=>{const s=document.getElementById('single');s.style.display=s.style.display==='none'?'block':'none';s.classList.toggle('muted')};
356
+ const cj=document.getElementById('copyjsonl');if(cj)cj.onclick=()=>{
357
+ let rows=[];try{rows=JSON.parse(v.merged||'[]')}catch(e){}
358
+ navigator.clipboard&&navigator.clipboard.writeText(rows.map(r=>JSON.stringify(r)).join('\n'));
359
+ cj.textContent='copied ✓';
360
+ };
361
+ const vote=async(verdict)=>{await fetch('/jobs/'+v.job_id+'/feedback',{method:'POST',headers:uHeaders(),
362
+ body:JSON.stringify({verdict})});const m=await (await fetch('/metrics')).json();
363
+ document.getElementById('thx').textContent='thanks! council wins '+
364
+ (m.council_win_rate==null?'—':Math.round(m.council_win_rate*100)+'%')+' so far ('+m.total+')';};
365
+ const vc=document.getElementById('vc');
366
+ if(vc){vc.onclick=()=>vote('council');
367
+ document.getElementById('vt').onclick=()=>vote('tie');
368
+ document.getElementById('vs').onclick=()=>vote('single');}
369
+ }
370
+
371
+ async function poll(id){
372
+ let v;try{v=await (await fetch('/jobs/'+id)).json()}catch(e){return}
373
+ drawMap(v);renderLive(v);
374
+ if(v.status==='done'){
375
+ // re-render only when content changes (the independent baseline may land a bit later)
376
+ const sig=((v.baseline&&v.baseline.text)?'ext':'fb')+'·'+(v.answers||[]).length;
377
+ if(sig!==lastAns){lastAns=sig;renderAnswer(v);refreshMe();}
378
+ if(polling&&((v.baseline&&v.baseline.text)||v.type==='shard_map'||++bwait>480)){clearInterval(polling);polling=null}}
379
+ if(v.status==='failed'){if(polling){clearInterval(polling);polling=null}
380
+ document.getElementById('live').innerHTML='<div class="card" style="color:var(--bad)">✗ '+esc(v.error||'failed')+'</div>';}
381
+ }
382
+
383
+ if(navigator.geolocation)navigator.geolocation.getCurrentPosition(p=>{you=[p.coords.latitude,p.coords.longitude]},()=>{},{timeout:4000});
384
+ refreshMe();ambientTick();setInterval(ambientTick,5000);updateHint();
385
+ // deep link: /#job=<id> reopens that question (shareable result)
386
+ if(secret&&location.hash.indexOf('#job=')===0)openJob(location.hash.slice(5));
387
+ </script>
388
+ </body>
389
+ </html>
390
+ """
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ council/net/baseline.py — the honest single-model baseline
4
+ ===========================================================
5
+ The demand metric ("was the council more useful than one model?") is only meaningful
6
+ if the "one model" is the asker's REAL alternative. Before this module the baseline was
7
+ the council's own best answer — that measured merge-vs-ingredient, not demand.
8
+
9
+ Order of preference:
10
+ 1. A frontier API model via an OpenAI-compatible endpoint (PW_BASELINE_API_KEY set
11
+ → OpenRouter by default). This is what an asker would actually use instead.
12
+ 2. A strong local Ollama model (PW_BASELINE_LOCAL_MODEL, default qwen3:14b) — never
13
+ a small worker-class model.
14
+ 3. None → the UI falls back to best-single-council-answer, clearly labelled.
15
+
16
+ Runs in a background thread per job (parallel with the council), result stored on the
17
+ job row. Failures are silent by design — the baseline must never block or fail a job.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import time
24
+ from typing import Optional
25
+
26
+ import requests
27
+
28
+ from .config import CONFIG
29
+
30
+ _PROMPT = "Answer the question directly and concretely. Be concise."
31
+
32
+
33
+ def generate_baseline(question: str) -> Optional[dict]:
34
+ """Return {text, model, source, elapsed_s} or None. Never raises."""
35
+ try:
36
+ if CONFIG.baseline_api_key:
37
+ return _via_api(question)
38
+ if CONFIG.baseline_local_model:
39
+ return _via_ollama(question)
40
+ except Exception as e:
41
+ # never fail the job, but never fail SILENTLY either (journalctl picks this up)
42
+ print(f"[baseline] generation failed: {type(e).__name__}: {e}", flush=True)
43
+ return None
44
+
45
+
46
+ def _via_api(question: str) -> Optional[dict]:
47
+ t0 = time.monotonic()
48
+ r = requests.post(
49
+ CONFIG.baseline_api_url,
50
+ headers={"Authorization": f"Bearer {CONFIG.baseline_api_key}",
51
+ "Content-Type": "application/json"},
52
+ json={"model": CONFIG.baseline_model,
53
+ "messages": [{"role": "system", "content": _PROMPT},
54
+ {"role": "user", "content": question}],
55
+ "max_tokens": 700},
56
+ timeout=120,
57
+ )
58
+ r.raise_for_status()
59
+ text = (r.json()["choices"][0]["message"]["content"] or "").strip()
60
+ if not text:
61
+ return None
62
+ return {"text": text, "model": CONFIG.baseline_model, "source": "api",
63
+ "elapsed_s": round(time.monotonic() - t0, 1)}
64
+
65
+
66
+ def _via_ollama(question: str) -> Optional[dict]:
67
+ t0 = time.monotonic()
68
+ r = requests.post(
69
+ f"{CONFIG.ollama_base}/api/generate",
70
+ json={"model": CONFIG.baseline_local_model,
71
+ "prompt": f"{_PROMPT}\n\nQuestion:\n{question}",
72
+ "stream": False,
73
+ "think": False, # qwen3-style models reason by default; older Ollama ignores this
74
+ "options": {"temperature": 0.4, "num_predict": 300},
75
+ # warm the baseline model too (R17) so its timing isn't skewed by reloads vs the council
76
+ "keep_alive": os.environ.get("PW_OLLAMA_KEEP_ALIVE", "30m")},
77
+ timeout=CONFIG.baseline_timeout_s,
78
+ )
79
+ r.raise_for_status()
80
+ text = (r.json().get("response") or "").strip()
81
+ if text.startswith("<think>") and "</think>" in text:
82
+ text = text.split("</think>", 1)[1].strip()
83
+ if not text:
84
+ return None
85
+ return {"text": text, "model": CONFIG.baseline_local_model, "source": "local",
86
+ "elapsed_s": round(time.monotonic() - t0, 1)}
council/net/config.py ADDED
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ council/net/config.py — provider-agnostic configuration
4
+ =======================================================
5
+ Everything that ties the coordinator to a particular host is an environment variable,
6
+ so the service can be moved from this VPS to any rented host with NO code changes
7
+ (see docs/DECISIONS.md D8).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from dataclasses import dataclass
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Config:
18
+ # Where the coordinator listens. Loopback by default — expose only behind a tunnel/reverse
19
+ # proxy you control (a non-loopback bind with a weak token is refused at startup).
20
+ host: str = os.environ.get("PW_HOST", "127.0.0.1")
21
+ port: int = int(os.environ.get("PW_PORT", "8088"))
22
+ # Persistence (a single SQLite file → easy to relocate; swap to Postgres later via this seam).
23
+ db_path: str = os.environ.get("PW_DB", "council_coordinator.db")
24
+ # Shared secret required on every write endpoint (node register, poll, submit results).
25
+ token: str = os.environ.get("PW_TOKEN", "dev-token")
26
+ # Economy knobs (mirror council.coordinator).
27
+ worker_pool: float = float(os.environ.get("PW_WORKER_POOL", "30"))
28
+ judge_fee: float = float(os.environ.get("PW_JUDGE_FEE", "5"))
29
+ # Orchestration.
30
+ fleet_size: int = int(os.environ.get("PW_FLEET_SIZE", "3")) # max answer-nodes per job
31
+ node_ttl_s: float = float(os.environ.get("PW_NODE_TTL", "60")) # node considered offline after this
32
+ max_run_s: float = float(os.environ.get("PW_MAX_RUN", "300")) # a job older than this is reaped → failed
33
+ # Honest compare baseline — the answer the council is judged AGAINST in the demand metric.
34
+ # A frontier API model if a key is set (the asker's real-world alternative), else a strong
35
+ # local model. Without either, the compare falls back to the best single council answer
36
+ # (labelled as such — that only measures merge-vs-ingredient, not real demand).
37
+ baseline_api_key: str = os.environ.get("PW_BASELINE_API_KEY", "")
38
+ baseline_api_url: str = os.environ.get("PW_BASELINE_API_URL",
39
+ "https://openrouter.ai/api/v1/chat/completions")
40
+ baseline_model: str = os.environ.get("PW_BASELINE_MODEL", "openai/gpt-4o-mini")
41
+ baseline_local_model: str = os.environ.get("PW_BASELINE_LOCAL_MODEL", "qwen3:14b")
42
+ # CPU inference on a busy box is slow; the local baseline is generated AFTER the council
43
+ # finishes (no core contention) and may take a few minutes. API baselines run immediately.
44
+ baseline_timeout_s: float = float(os.environ.get("PW_BASELINE_TIMEOUT", "600"))
45
+ ollama_base: str = os.environ.get("PW_OLLAMA_BASE", "http://127.0.0.1:11434")
46
+
47
+
48
+ CONFIG = Config()
49
+
50
+ # ---- Job-type catalog ("Upwork for computers" — see docs/DECISIONS.md D13) ----
51
+ # Each type is a different latency class with its own price and deadline. `pool_mult`
52
+ # scales the per-mind worker pool (real work costs real credits); `deadline_s` replaces
53
+ # the single global reaper wall for that job's lifetime.
54
+ JOB_TYPES: dict = {
55
+ "chat": {
56
+ "label": "Ask the council",
57
+ "eta": "3–8 min",
58
+ "pool_mult": 1.0,
59
+ "deadline_s": float(os.environ.get("PW_MAX_RUN", "600")),
60
+ },
61
+ "research_report": {
62
+ "label": "Deep research — many computers, many countries",
63
+ "eta": "20–40 min",
64
+ "pool_mult": 3.0,
65
+ "deadline_s": float(os.environ.get("PW_RESEARCH_MAX_RUN", "3600")),
66
+ },
67
+ "shard_map": {
68
+ "label": "Batch work — one big job split across computers",
69
+ "eta": "scales with items ÷ computers",
70
+ "pool_mult": 2.0,
71
+ "deadline_s": float(os.environ.get("PW_BATCH_MAX_RUN", "3600")),
72
+ },
73
+ "assisted": {
74
+ "label": "Assisted task — a person does it on their computer (with consent)",
75
+ "eta": "minutes to hours (human-paced)",
76
+ "pool_mult": 5.0, # real human-mediated work; priced above automated jobs
77
+ "deadline_s": float(os.environ.get("PW_ASSIST_MAX_RUN", "86400")), # 24h to be claimed+done
78
+ },
79
+ }