diff --git a/a2a.yaml b/a2a.yaml
index fbc5986..205ce29 100644
--- a/a2a.yaml
+++ b/a2a.yaml
@@ -1,5 +1,5 @@
name: quote-judge-studio-v1
-version: 0.1.0
+version: 0.1.4
entrypoint: agent:QuoteJudgeStudioV1
expose:
public: false
diff --git a/agent.py b/agent.py
index 9afac87..57a67b5 100644
--- a/agent.py
+++ b/agent.py
@@ -43,7 +43,7 @@ MAX_QUOTES = 20
MAX_UPLOAD_BYTES = 64_000
MAX_BROWSER_UPLOADS = 3
ALLOWED_UPLOAD_MEDIA_TYPES = ("text/csv", "text/plain", "application/json")
-VERSION = "0.1.0"
+VERSION = "0.1.4"
class QuoteJudgeStudioV1Config(BaseModel):
diff --git a/frontend/build_static.py b/frontend/build_static.py
index 0e99e31..514d535 100644
--- a/frontend/build_static.py
+++ b/frontend/build_static.py
@@ -35,14 +35,15 @@ html = r'''
async function callSkill(name,args){ if(config.auth?.invokeRequiresSession && !session?.authenticated){ const href=signInUrl(); if(href) throw new Error('sign in required'); } return requestJson(`${config.endpoints.invoke}/${encodeURIComponent(name)}`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({arguments:args})}); }
const n = (v) => { const x = Number(v); return Number.isFinite(x) ? x : 0; };
function normalizedQuotes(){ return quotes.map(q => ({ vendor: String(q.vendor||'').trim(), unit_price: n(q.unit_price), quantity: Math.trunc(n(q.quantity)), delivery_days: Math.trunc(n(q.delivery_days)), warranty_months: Math.trunc(n(q.warranty_months)) })); }
- function validate(id, rows){ if(!id.trim()) return 'Comparison ID is required.'; if(!/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,79}$/.test(id.trim())) return 'Comparison ID must be 1-80 safe characters.'; if(rows.length < 2) return 'Add at least two quotes.'; const vendors = new Set(); for(const q of rows){ if(!q.vendor) return 'Every quote needs a vendor name.'; const k=q.vendor.toLowerCase(); if(vendors.has(k)) return 'Vendor names must be unique.'; vendors.add(k); if(q.unit_price<=0) return `${q.vendor} needs a positive unit price.`; if(q.quantity<=0) return `${q.vendor} needs a positive quantity.`; if(q.delivery_days<0 || q.warranty_months<0) return `${q.vendor} has a negative delivery or warranty value.`; } if(n(weights.price)+n(weights.delivery)+n(weights.warranty)<=0) return 'At least one weight must be greater than zero.'; return null; }
+ function validate(id, rows){ if(!id.trim()) return 'Comparison ID is required.'; if(!/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,79}$/.test(id.trim())) return 'Comparison ID must be 1-80 safe characters.'; if(rows.length < 2) return 'Add at least two quotes.'; const vendors = new Set(); for(const q of rows){ if(!q.vendor) return 'Every quote needs a vendor name.'; const k=q.vendor.toLowerCase(); if(vendors.has(k)) return 'Vendor names must be unique.'; vendors.add(k); if(q.unit_price<=0) return `${esc(q.vendor)} needs a positive unit price.`; if(q.quantity<=0) return `${esc(q.vendor)} needs a positive quantity.`; if(q.delivery_days<0 || q.warranty_months<0) return `${esc(q.vendor)} has a negative delivery or warranty value.`; } if(n(weights.price)+n(weights.delivery)+n(weights.warranty)<=0) return 'At least one weight must be greater than zero.'; return null; }
async function fileToBase64(file){ const buffer=await file.arrayBuffer(); let binary=''; const bytes=new Uint8Array(buffer); for(let i=0;il.trim()); const headers=lines[0].split(',').map(x=>x.trim()); return lines.slice(1).map(line=>{ const cells=line.split(',').map(x=>x.trim()); const row=Object.fromEntries(headers.map((h,i)=>[h,cells[i]||''])); return {vendor:row.vendor,unit_price:n(row.unit_price),quantity:Math.trunc(n(row.quantity)),delivery_days:Math.trunc(n(row.delivery_days)),warranty_months:Math.trunc(n(row.warranty_months))}; }); }
+ function esc(value){ return String(value ?? '').replace(/[&<>\"']/g, ch => ({'&':'&','<':'<','>':'>','\"':'"',"'":'''}[ch])); }
function updateQuote(i,field,value){ quotes = quotes.map((q,idx)=>idx===i?{...q,[field]:value}:q); render(); }
async function handleUpload(file){ uploadFile=file || null; error=null; if(file){ if(file.size>MAX_UPLOAD_BYTES){ error=`Upload must be at most ${MAX_UPLOAD_BYTES} bytes.`; render(); return; } try{ const text=await file.text(); let parsed; if((file.type||'').includes('json') || file.name.endsWith('.json')){ const raw=JSON.parse(text); parsed=Array.isArray(raw)?raw:raw.quotes; } else parsed=csvToQuotes(text); if(Array.isArray(parsed) && parsed.length) quotes=parsed; } catch(e){ error=`Preview parse failed: ${e.message}. You can still clear the file and paste rows manually.`; } } render(); }
async function compare(ev){ ev.preventDefault(); const rows=normalizedQuotes(); const validation=validate(document.getElementById('comparisonId').value, rows); if(validation){ error=validation; render(); return; } error=null; status='Comparing quotes and saving recommendation...'; render(); try{ const comparison_id=document.getElementById('comparisonId').value.trim(); const payloadWeights={price:n(weights.price),delivery:n(weights.delivery),warranty:n(weights.warranty)}; if(uploadFile){ const media_type=uploadFile.type || (uploadFile.name.endsWith('.json')?'application/json':'text/csv'); result=await callSkill('compare_quotes_upload',{comparison_id,upload:{filename:uploadFile.name,media_type,data_base64:await fileToBase64(uploadFile)},weights:payloadWeights}); } else result=await callSkill('compare_quotes',{comparison_id,quotes:rows,weights:payloadWeights}); if(!result.ok) error=result.message || result.code; } catch(e){ error=/sign in|401/i.test(e.message)?'Session required or expired. Use Sign in to run, then retry.':e.message; } status=null; render(); }
async function reload(){ error=null; status='Reopening saved comparison...'; render(); try{ result=await callSkill('get_comparison',{comparison_id:document.getElementById('comparisonId').value.trim()}); if(!result.ok) error=result.message || result.code; } catch(e){ error=e.message; } status=null; render(); }
- function render(){ const needsSignIn=Boolean(config?.auth?.invokeRequiresSession && session && !session.authenticated); const href=signInUrl(); const winner=result?.ok?result.recommendation:null; root.className='product-shell'; root.innerHTML=`QuoteJudge
Pick the best vendor quote with transparent weighted scoring.
Paste or upload quote rows, weight price/delivery/warranty, save the recommendation, and reopen it later under your A2A Cloud user account.
3 · Recommendation
${winner?winner.vendor:'No comparison yet'}
${result?.reloaded?'
Reloaded from Postgres':''}
${result?.ok?`${winner.vendor}${winner.weighted_score.toFixed(1)} weighted score${winner.rationale}
${result.quotes.map(q=>`
${q.vendor}
${q.weighted_score.toFixed(1)} `).join('')}
| Vendor | Total price | Delivery | Warranty | Weighted |
${result.quotes.map(q=>`| ${q.vendor} | ${q.total_price.toLocaleString(undefined,{style:'currency',currency:'BRL'})} | ${q.delivery_days} days | ${q.warranty_months} mo | ${q.weighted_score.toFixed(1)} |
`).join('')}
Receipt persisted: ${String(result.receipt?.persisted)} · comparison_id=${result.comparison_id}
`:'Run the success fixture or upload your own CSV/JSON to see a saved recommendation here.
'}`; document.getElementById('studio').addEventListener('submit',compare); document.getElementById('reload').addEventListener('click',reload); document.getElementById('addQuote').addEventListener('click',()=>{quotes=[...quotes,{vendor:`Vendor ${quotes.length+1}`,unit_price:0,quantity:1,delivery_days:0,warranty_months:0}];render();}); document.getElementById('upload').addEventListener('change',e=>handleUpload(e.target.files?.[0])); const clear=document.getElementById('clearUpload'); if(clear) clear.addEventListener('click',()=>{uploadFile=null;render();}); document.querySelectorAll('[data-i]').forEach(el=>el.addEventListener('input',e=>updateQuote(Number(e.target.dataset.i),e.target.dataset.f,e.target.value))); document.querySelectorAll('[data-remove]').forEach(el=>el.addEventListener('click',e=>{quotes=quotes.filter((_,i)=>i!==Number(e.target.dataset.remove));render();})); document.querySelectorAll('[data-w]').forEach(el=>el.addEventListener('input',e=>{weights={...weights,[e.target.dataset.w]:Number(e.target.value)};render();})); }
+ function render(){ const needsSignIn=Boolean(config?.auth?.invokeRequiresSession && session && !session.authenticated); const href=signInUrl(); const winner=result?.ok?result.recommendation:null; root.className='product-shell'; root.innerHTML=`QuoteJudge
Pick the best vendor quote with transparent weighted scoring.
Paste or upload quote rows, weight price/delivery/warranty, save the recommendation, and reopen it later under your A2A Cloud user account.
3 · Recommendation
${winner?esc(winner.vendor):'No comparison yet'}
${result?.reloaded?'
Reloaded from Postgres':''}
${result?.ok?`${esc(winner.vendor)}${winner.weighted_score.toFixed(1)} weighted score${esc(winner.rationale)}
${result.quotes.map(q=>`
${esc(q.vendor)}
${q.weighted_score.toFixed(1)} `).join('')}
| Vendor | Total price | Delivery | Warranty | Weighted |
${result.quotes.map(q=>`| ${esc(q.vendor)} | ${q.total_price.toLocaleString(undefined,{style:'currency',currency:'BRL'})} | ${q.delivery_days} days | ${q.warranty_months} mo | ${q.weighted_score.toFixed(1)} |
`).join('')}
Receipt persisted: ${String(result.receipt?.persisted)} · comparison_id=${esc(result.comparison_id)}
`:'Run the success fixture or upload your own CSV/JSON to see a saved recommendation here.
'}`; document.getElementById('studio').addEventListener('submit',compare); document.getElementById('reload').addEventListener('click',reload); document.getElementById('addQuote').addEventListener('click',()=>{quotes=[...quotes,{vendor:`Vendor ${quotes.length+1}`,unit_price:0,quantity:1,delivery_days:0,warranty_months:0}];render();}); document.getElementById('upload').addEventListener('change',e=>handleUpload(e.target.files?.[0])); const clear=document.getElementById('clearUpload'); if(clear) clear.addEventListener('click',()=>{uploadFile=null;render();}); document.querySelectorAll('[data-i]').forEach(el=>el.addEventListener('input',e=>updateQuote(Number(e.target.dataset.i),e.target.dataset.f,e.target.value))); document.querySelectorAll('[data-remove]').forEach(el=>el.addEventListener('click',e=>{quotes=quotes.filter((_,i)=>i!==Number(e.target.dataset.remove));render();})); document.querySelectorAll('[data-w]').forEach(el=>el.addEventListener('input',e=>{weights={...weights,[e.target.dataset.w]:Number(e.target.value)};render();})); }
try { config = await loadConfig(); try { session = await loadSession(); } catch { session = { authenticated:false }; } render(); } catch(e) { root.textContent = e.message || String(e); }
Loading QuoteJudge...
diff --git a/frontend/dist/index.html b/frontend/dist/index.html
index 1f320d4..b1e693f 100644
--- a/frontend/dist/index.html
+++ b/frontend/dist/index.html
@@ -1 +1,42 @@
-