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.

1 · Input

Quotes

${uploadFile?'':''}
VendorUnit priceQtyDelivery daysWarranty months
${quotes.map((q,i)=>`
`).join('')}

2 · Priorities

Weights

${['price','delivery','warranty'].map(k=>``).join('')}
${needsSignIn?'

Sign in with A2A Cloud to run tools and access your user-scoped database records.

':''}${status?`

${status}

`:''}${error?``:''}

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('')}
${result.quotes.map(q=>``).join('')}
VendorTotal priceDeliveryWarrantyWeighted
${q.vendor}${q.total_price.toLocaleString(undefined,{style:'currency',currency:'BRL'})}${q.delivery_days} days${q.warranty_months} mo${q.weighted_score.toFixed(1)}

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.

1 · Input

Quotes

${uploadFile?'':''}
VendorUnit priceQtyDelivery daysWarranty months
${quotes.map((q,i)=>`
`).join('')}

2 · Priorities

Weights

${['price','delivery','warranty'].map(k=>``).join('')}
${needsSignIn?'

Sign in with A2A Cloud to run tools and access your user-scoped database records.

':''}${status?`

${esc(status)}

`:''}${error?``:''}

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('')}
${result.quotes.map(q=>``).join('')}
VendorTotal priceDeliveryWarrantyWeighted
${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)}

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); } 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 @@ -QuoteJudge Studio
Loading QuoteJudge...
\ No newline at end of file + + + + + + QuoteJudge Studio + + + +
Loading QuoteJudge...
+ + + diff --git a/frontend/package.json b/frontend/package.json index 0a92df0..05f1d74 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "quote-judge-studio-v1-frontend", "private": true, - "version": "0.1.0", + "version": "0.1.4", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0 --port 5173", diff --git a/tests/test_full_stack_contract.py b/tests/test_full_stack_contract.py index 6a36f2f..5530092 100644 --- a/tests/test_full_stack_contract.py +++ b/tests/test_full_stack_contract.py @@ -49,7 +49,7 @@ def test_full_stack_product_contract(): assert migrations and all(path.read_text(encoding="utf-8").strip() for path in migrations) card = load_local_project(ROOT).agent_cls().card() - assert card.version == "0.1.0" + assert card.version == "0.1.4" skill_names = {skill.name for skill in card.skills} assert {"compare_quotes", "get_comparison", "compare_quotes_file", "compare_quotes_upload", "compare_quotes_browser_upload"} <= skill_names databases = card.runtime.platform_resources.databases