File size: 15,969 Bytes
a5c7816
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
#!/usr/bin/env python
"""DIY Creator (#17 of the 30-in-15) — a multi-agent team you can watch work.



A 🔍 Researcher, an ✍️ Writer, and a 🧐 Critic hand off to each other to produce a vetted build guide.

The critic scores the draft and sends it back until it passes — a real critique->revise loop, shown live.



The lesson is multi-agent orchestration: role-specialized agents, explicit handoffs, and an

independent critic (the "diamond") gating the result. Each step is its own endpoint so the browser

renders the team working in real time.



    pip install -r requirements.txt

    python app.py           # http://127.0.0.1:7860  (local Ollama by default; ANTHROPIC_API_KEY for Claude)

"""
import os

from flask import Flask, request, render_template_string, jsonify

import agents
import llm

app = Flask(__name__)
MAX_TOPIC = 160

PAGE = r"""<!doctype html><html lang="en"><head>

<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">

<title>GritAI · DIY Creator</title>

<link rel="stylesheet" href="/static/gritai.css">

<style>

  .wrap{max-width:1000px;margin:0 auto;padding:22px 20px 40px}

  .lede{color:var(--dim);font-size:14.5px;line-height:1.6;margin:6px 0 18px;max-width:70ch}

  .roles{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 18px}

  .rolechip{font-family:var(--mono);font-size:11px;letter-spacing:.04em;border:1px solid var(--line2);

    border-radius:20px;padding:5px 11px;color:var(--dim);background:var(--panel)}

  .composer{display:flex;gap:10px}

  .composer input{flex:1;background:var(--panel);border:1px solid var(--line2);border-radius:3px;color:var(--ink);

    font-family:var(--body);font-size:16px;padding:14px 16px;outline:none}

  .composer input:focus{border-color:var(--grit)}

  .go{border:0;background:var(--grit);color:#140a06;font-family:var(--mono);font-weight:600;font-size:12px;

    letter-spacing:.12em;text-transform:uppercase;padding:0 22px;cursor:pointer;border-radius:3px;white-space:nowrap}

  .go:hover{background:var(--grit2)} .go:disabled{opacity:.5;cursor:default}

  .examples{margin:10px 0 0;display:flex;gap:7px;flex-wrap:wrap}

  .ex{font-size:12px;color:var(--dim2);border:1px dashed var(--line2);border-radius:3px;padding:4px 9px;cursor:pointer}

  .ex:hover{color:var(--grit);border-color:var(--grit)}

  #lane{margin-top:26px;display:flex;flex-direction:column;gap:14px}

  .card{border:1px solid var(--line);border-radius:4px;background:var(--panel);overflow:hidden;

    animation:rise .35s ease}

  @keyframes rise{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}

  .card .hd{display:flex;align-items:center;gap:10px;padding:11px 15px;border-bottom:1px solid var(--line);

    background:var(--panel2)}

  .card .av{font-size:18px}

  .card .role{font-family:var(--disp);font-size:13px;letter-spacing:.05em;text-transform:uppercase}

  .card.researcher .role{color:#6fb6ff} .card.writer .role{color:var(--grit)} .card.critic .role{color:#c58cff}

  .card .st{margin-left:auto;font-family:var(--mono);font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--dim2)}

  .card .bd{padding:14px 16px;font-size:14px;line-height:1.65}

  .working .st{color:var(--grit)}

  .dots::after{content:'';animation:dots 1.2s steps(4,end) infinite}

  @keyframes dots{0%{content:''}25%{content:'.'}50%{content:'..'}75%{content:'...'}}

  .brief{display:grid;grid-template-columns:repeat(3,1fr);gap:8px 18px;margin-bottom:10px}

  @media(max-width:680px){.brief{grid-template-columns:1fr 1fr}}

  .brief .m{font-family:var(--mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--dim2)}

  .brief .v{font-size:14px;color:var(--ink)}

  .cols{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px}

  @media(max-width:760px){.cols{grid-template-columns:1fr}}

  .col h5{font-family:var(--mono);font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--dim);margin:0 0 6px}

  .col ul{margin:0;padding-left:16px} .col li{font-size:13px;margin:3px 0;color:var(--ink2,#cfd3dc)}

  .gate{display:flex;align-items:center;gap:12px;margin-bottom:12px}

  .score{font-family:var(--disp);font-size:26px;font-weight:700} .score small{font-size:13px;color:var(--dim2)}

  .badge{font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;padding:4px 10px;border-radius:3px}

  .badge.pass{background:rgba(95,208,138,.14);color:var(--ok);border:1px solid var(--ok)}

  .badge.revise{background:rgba(255,90,42,.13);color:var(--grit);border:1px solid var(--grit)}

  .issues{list-style:none;padding:0;margin:0}

  .issues li{display:flex;gap:9px;align-items:flex-start;padding:7px 0;border-top:1px solid var(--line);font-size:13.5px}

  .sev{font-family:var(--mono);font-size:9.5px;letter-spacing:.08em;text-transform:uppercase;padding:2px 7px;border-radius:3px;margin-top:1px;white-space:nowrap}

  .sev.high{background:rgba(255,107,107,.15);color:var(--bad)} .sev.medium{background:rgba(255,176,32,.15);color:#ffb020}

  .sev.low{background:var(--panel2);color:var(--dim)}

  .final{border:1px solid var(--grit);border-radius:4px;overflow:hidden;margin-top:8px}

  .final .hd{background:linear-gradient(90deg,rgba(255,90,42,.16),transparent);border-bottom:1px solid var(--grit)}

  .guide h2{font-family:var(--disp);font-size:17px;color:var(--ink);margin:16px 0 7px;letter-spacing:.01em}

  .guide h2:first-child{margin-top:0}

  .guide p{margin:7px 0} .guide ul,.guide ol{margin:7px 0;padding-left:22px} .guide li{margin:4px 0}

  .guide strong{color:var(--grit)} .guide code{font-family:var(--mono);font-size:.9em;background:var(--panel2);padding:1px 5px;border-radius:3px}

  .flow{text-align:center;color:var(--line2);font-size:16px;margin:-6px 0 -4px}

  .err{color:var(--bad);font-size:13.5px}

  .muted{color:var(--dim2);font-family:var(--mono);font-size:10.5px;letter-spacing:.14em;text-transform:uppercase;margin-top:22px}

</style></head><body>

<div class="wrap">

  <header style="display:flex;align-items:center;gap:13px">

    <svg width="34" height="34" viewBox="0 0 38 38" fill="none"><rect x="1" y="1" width="36" height="36" rx="2" stroke="#2b303c"/><path d="M12 26l14-14M12 12h14v14" stroke="#ff5a2a" stroke-width="2.4" stroke-linecap="square"/></svg>

    <div class="brand"><div class="co">Grit<b>AI</b> · DIY Creator</div>

    <div class="sub">A research → write → critique team that builds you a vetted guide</div></div>

    <div style="flex:1"></div><div class="tag hot">#17 · 30-in-15</div>

  </header>



  <p class="lede">Name something you want to build. A <b style="color:#6fb6ff">Researcher</b> gathers the

  materials, tools and safety; a <b style="color:var(--grit)">Writer</b> drafts a step-by-step guide; then an

  independent <b style="color:#c58cff">Critic</b> scores it and sends it back for revisions until it passes.

  You watch the whole team work.</p>



  <div class="composer">

    <input id="topic" maxlength="160" placeholder="e.g. a raised garden bed, a floating bookshelf, a cold frame…" autofocus>

    <button class="go" id="go" onclick="run()">Assemble the team</button>

  </div>

  <div class="examples" id="ex"></div>



  <div id="lane"></div>

  <p class="muted">Backend: {{ backend }} · max {{ max_rev }} critic rounds · GritAI Solutions</p>

</div>

<script>

const MAX_ROUNDS = {{ max_rev }};

const EX = ["a raised garden bed from cedar boards","a floating bookshelf","a cold frame for winter greens",

            "a pegboard tool wall","a simple wooden phone stand","a worm composting bin"];

const lane=document.getElementById('lane'), goBtn=document.getElementById('go'), topicEl=document.getElementById('topic');

const exBox=document.getElementById('ex');

EX.forEach(t=>{const s=document.createElement('span');s.className='ex';s.textContent=t;

  s.onclick=()=>{topicEl.value=t;run();};exBox.appendChild(s);});



function esc(s){return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}

function post(url,body){return fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},

  body:JSON.stringify(body)}).then(r=>r.json());}



// tiny, safe markdown -> html for the guide (headings, bold, code, ordered/unordered lists)

function md(src){

  const lines=esc(src).split(/\r?\n/); let html='', list=null;

  const close=()=>{ if(list){html+=`</${list}>`; list=null;} };

  for(let ln of lines){

    let m;

    if(/^\s*##\s+/.test(ln)){close(); html+='<h2>'+inline(ln.replace(/^\s*##\s+/,''))+'</h2>'; continue;}

    if((m=ln.match(/^\s*\d+\.\s+(.*)/))){ if(list!=='ol'){close();list='ol';html+='<ol>';} html+='<li>'+inline(m[1])+'</li>'; continue;}

    if((m=ln.match(/^\s*[-*]\s+(.*)/))){ if(list!=='ul'){close();list='ul';html+='<ul>';} html+='<li>'+inline(m[1])+'</li>'; continue;}

    close(); if(ln.trim()) html+='<p>'+inline(ln)+'</p>';

  }

  close(); return html;

}

function inline(s){return s.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>').replace(/`(.+?)`/g,'<code>$1</code>');}



function card(cls,av,role,statusHtml,bodyHtml){

  const d=document.createElement('div'); d.className='card '+cls;

  d.innerHTML=`<div class="hd"><span class="av">${av}</span><span class="role">${role}</span>

    <span class="st">${statusHtml}</span></div><div class="bd">${bodyHtml}</div>`;

  lane.appendChild(d); d.scrollIntoView({behavior:'smooth',block:'end'}); return d;

}

function flow(){const d=document.createElement('div');d.className='flow';d.textContent='↓';lane.appendChild(d);}

function setStatus(c,html,working){c.querySelector('.st').innerHTML=html; c.classList.toggle('working',!!working);}

function setBody(c,html){c.querySelector('.bd').innerHTML=html;}



function briefHtml(b){

  const list=(t,a)=>`<div class="col"><h5>${t}</h5><ul>${(a||[]).map(x=>'<li>'+esc(x)+'</li>').join('')||'<li>—</li>'}</ul></div>`;

  return `<div class="brief">

      <div><div class="m">Difficulty</div><div class="v">${esc(b.difficulty||'—')}</div></div>

      <div><div class="m">Time</div><div class="v">${esc(b.time_estimate||'—')}</div></div>

      <div><div class="m">Est. cost</div><div class="v">${esc(b.cost_estimate||'—')}</div></div>

    </div>

    <div class="cols">${list('Materials',b.materials)}${list('Tools',b.tools)}${list('Safety',b.safety)}</div>`;

}

function critiqueHtml(v){

  const cls=v.pass?'pass':'revise';

  const issues=(v.issues||[]).map(i=>`<li><span class="sev ${esc(i.severity||'low')}">${esc(i.severity||'?')}</span>

     <span>${esc(i.issue||'')}</span></li>`).join('');

  return `<div class="gate"><div class="score">${v.score.toFixed(0)}<small>/10</small></div>

      <span class="badge ${cls}">${v.pass?'✓ passes':'↻ send back'}</span>

      <span style="color:var(--dim);font-size:13px">${esc(v.summary||'')}</span></div>

      <ul class="issues">${issues||'<li style="border:0;color:var(--dim2)">No issues found.</li>'}</ul>`;

}



let busy=false;

async function run(){

  if(busy) return; const topic=topicEl.value.trim(); if(!topic) return;

  busy=true; goBtn.disabled=true; lane.innerHTML='';

  try{

    // 1) RESEARCH

    const rc=card('researcher','🔍','Researcher','<span class="dots">researching</span>',

      '<span style="color:var(--dim2)">gathering materials, tools, safety and pitfalls…</span>');

    const r1=await post('/api/research',{topic});

    if(r1.error) throw r1.error;

    setStatus(rc,'brief ready'); setBody(rc,briefHtml(r1.brief));

    let brief=r1.brief;



    // 2) WRITE

    flow();

    const wc=card('writer','✍️','Writer','<span class="dots">drafting</span>',

      '<span style="color:var(--dim2)">turning the brief into a step-by-step guide…</span>');

    let gw=await post('/api/write',{topic,brief});

    if(gw.error) throw gw.error;

    let guide=gw.guide;

    setStatus(wc,'draft ready'); setBody(wc,'<div class="guide">'+md(guide)+'</div>');



    // 3) CRITIQUE -> REVISE loop

    let passed=false, round=0, lastVerdict=null;

    while(round<MAX_ROUNDS){

      round++; flow();

      const cc=card('critic','🧐','Critic'+(MAX_ROUNDS>1?` · round ${round}`:''),'<span class="dots">reviewing</span>',

        '<span style="color:var(--dim2)">checking steps, materials and safety…</span>');

      const cv=await post('/api/critique',{topic,brief,guide});

      if(cv.error) throw cv.error;

      lastVerdict=cv.verdict; setStatus(cc,lastVerdict.pass?'passed':'sent back');

      setBody(cc,critiqueHtml(lastVerdict));

      if(lastVerdict.pass){passed=true;break;}

      if(round>=MAX_ROUNDS) break;

      flow();

      const rv=card('writer','✍️','Writer · revising','<span class="dots">revising</span>',

        '<span style="color:var(--dim2)">addressing every issue the critic raised…</span>');

      const rr=await post('/api/revise',{topic,brief,guide,verdict:lastVerdict});

      if(rr.error) throw rr.error;

      guide=rr.guide; setStatus(rv,'revised'); setBody(rv,'<div class="guide">'+md(guide)+'</div>');

    }



    // 4) FINAL

    flow();

    const label=passed?'✓ Vetted by the team':'Shipped after review'+(lastVerdict&&!lastVerdict.pass?' · minor notes remain':'');

    const fc=card('final writer','🛠️','Final Build Guide',label,'<div class="guide">'+md(guide)+'</div>');

    fc.classList.add('final');

  }catch(e){

    card('critic','⚠️','Error','', '<span class="err">'+esc(typeof e==='string'?e:'Something went wrong — is the model backend running?')+'</span>');

  }finally{ busy=false; goBtn.disabled=false; }

}

topicEl.addEventListener('keydown',e=>{if(e.key==='Enter')run();});

</script></body></html>"""


@app.route("/")
def index():
    return render_template_string(PAGE, backend=llm.backend_label(), max_rev=agents.MAX_REVISIONS)


def _topic():
    d = request.get_json(force=True, silent=True) or {}
    return (d.get("topic") or "").strip()[:MAX_TOPIC], d


@app.route("/api/research", methods=["POST"])
def api_research():
    topic, _ = _topic()
    if not topic:
        return jsonify({"error": "Tell me what you want to build."})
    try:
        return jsonify({"brief": agents.research(topic)})
    except Exception as e:
        print(f"research error: {type(e).__name__}")
        return jsonify({"error": "The researcher couldn't reach the model backend."})


@app.route("/api/write", methods=["POST"])
def api_write():
    topic, d = _topic()
    brief = d.get("brief") or {}
    if not topic:
        return jsonify({"error": "Missing project."})
    try:
        return jsonify({"guide": agents.write(topic, brief)})
    except Exception as e:
        print(f"write error: {type(e).__name__}")
        return jsonify({"error": "The writer couldn't reach the model backend."})


@app.route("/api/critique", methods=["POST"])
def api_critique():
    topic, d = _topic()
    brief, guide = d.get("brief") or {}, d.get("guide") or ""
    if not (topic and guide):
        return jsonify({"error": "Missing guide to review."})
    try:
        return jsonify({"verdict": agents.critique(topic, brief, guide)})
    except Exception as e:
        print(f"critique error: {type(e).__name__}")
        return jsonify({"error": "The critic couldn't reach the model backend."})


@app.route("/api/revise", methods=["POST"])
def api_revise():
    topic, d = _topic()
    brief, guide, verdict = d.get("brief") or {}, d.get("guide") or "", d.get("verdict") or {}
    if not (topic and guide):
        return jsonify({"error": "Missing guide to revise."})
    try:
        return jsonify({"guide": agents.revise(topic, brief, guide, verdict)})
    except Exception as e:
        print(f"revise error: {type(e).__name__}")
        return jsonify({"error": "The writer couldn't reach the model backend."})


if __name__ == "__main__":
    port = int(os.environ.get("PORT", "7860"))
    app.run(host="0.0.0.0", port=port, debug=False)