{"id":2934,"date":"2026-04-22T14:13:14","date_gmt":"2026-04-22T14:13:14","guid":{"rendered":"https:\/\/primetoolhub.com\/?page_id=2934"},"modified":"2026-08-07T13:24:16","modified_gmt":"2026-08-07T13:24:16","slug":"ai-sentiment-analyzer","status":"publish","type":"page","link":"https:\/\/schoolict.net\/tools\/ai-sentiment-analyzer\/","title":{"rendered":"Free AI Sentiment Analyzer &amp; Brand Monitor Offline"},"content":{"rendered":"<div class=\"pth-hero-section\">\n<div class=\"pth-hero-content\">\n<h2>Sentiment Analyzer &#038; Brand Monitor<\/h2>\n<p>Instantly analyze customer reviews, tweets, and comments with our free AI Sentiment Analyzer. 100% offline, privacy-first brand monitoring tool featuring live dashboard, pie chart, word clouds, filters, key insights, and CSV\/export. Perfect for e-commerce marketers in the US, UK, CA, and AU.<\/p>\n<div id=\"pth-toc-placeholder\"><\/div>\n<\/p>\n<\/div>\n<div class=\"pth-hero-image\">\n    <img data-no-lazy=\"1\"\n         src=\"https:\/\/schoolict.net\/tools\/wp-content\/uploads\/2026\/04\/ai-sentiment-analyzer-800x447.jpeg\"\n         width=\"800\"\n         height=\"447\"\n         alt=\"ai sentiment analyzer\"\n         fetchpriority=\"high\"\n         loading=\"eager\"\n         decoding=\"async\"\n         style=\"width:100%; height:auto; display:block;\">\n  <\/div>\n<\/div>\n\n\n<div class=\"wp-block-rank-math-toc-block\" id=\"rank-math-toc\"><h2>Table of Contents<\/h2><nav><ul><li><a href=\"#\ud83d\udd34-what-each-tab-is-for\">\ud83d\udd34 What Each Tab Is For<\/a><ul><li><a href=\"#analyze-one-passage-in-detail\">Analyze \u2014 one passage, in detail<\/a><\/li><li><a href=\"#brand-monitor-hundreds-of-rows-at-once\">Brand Monitor \u2014 hundreds of rows at once<\/a><\/li><li><a href=\"#aspect-analysis-the-tab-that-earns-its-place\">Aspect Analysis \u2014 the tab that earns its place<\/a><\/li><li><a href=\"#compare-a-against-b\">Compare \u2014 A against B<\/a><\/li><\/ul><\/li><li><a href=\"#\ud83d\udfe1-two-engines-and-when-to-switch\">\ud83d\udfe1 Two Engines, and When to Switch<\/a><\/li><li><a href=\"#\ud83d\udfe2-where-this-will-let-you-down\">\ud83d\udfe2 Where This Will Let You Down<\/a><ul><li><a href=\"#sarcasm-is-still-unsolved\">Sarcasm is still unsolved<\/a><\/li><li><a href=\"#short-text-swings-wildly\">Short text swings wildly<\/a><\/li><li><a href=\"#small-samples-produce-confident-nonsense\">Small samples produce confident nonsense<\/a><\/li><li><a href=\"#aspect-extraction-is-grammar-not-understanding\">Aspect extraction is grammar, not understanding<\/a><\/li><li><a href=\"#domain-and-language-limits\">Domain and language limits<\/a><\/li><\/ul><\/li><li><a href=\"#\ud83d\udd34-the-reasoning-underneath\">\ud83d\udd34 The Reasoning Underneath<\/a><\/li><\/ul><\/nav><\/div>\n\n\n<style>\r\n\/* ==========================================================================\r\n   PRIME TOOL HUB - SENTIMENT & BRAND STUDIO v4.0\r\n   Analyze \/ Brand Monitor \/ Aspect Analysis \/ Compare\r\n   ========================================================================== *\/\r\n#pth-sa-wrapper { font-family: 'Inter', system-ui, -apple-system, sans-serif; max-width: 1400px; margin: 0 auto; background: #f8fafc; padding: 20px; border-radius: 16px; color: #0f172a; line-height: 1.6; opacity: 1; }\r\n#pth-sa-wrapper * { box-sizing: border-box; min-width: 0; }\r\n\r\n#pth-sa-wrapper .pth-head { display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 12px; margin-bottom: 18px; }\r\n#pth-sa-wrapper .pth-h2 { font-size: 30px; font-weight: 800; color: #1d4ed8; margin: 0 0 8px 0; letter-spacing: -0.5px; }\r\n#pth-sa-wrapper .pth-sub { font-size: 15px; color: #475569; margin: 0 0 10px 0; font-weight: 500; }\r\n#pth-sa-wrapper .pth-badges { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }\r\n#pth-sa-wrapper .pth-version-badge { background: #dbeafe; color: #1e40af; border: 1px solid #bfdbfe; padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; }\r\n#pth-sa-wrapper .pth-status-offline { background: #dcfce7; color: #166534; border: 1px solid #bbf7d0; padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; cursor: help; }\r\n#pth-sa-wrapper .pth-scope-btn { background: #f1f5f9; color: #475569; border: 1px solid #e2e8f0; padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; cursor: pointer; font-family: inherit; }\r\n#pth-sa-wrapper .pth-scope-btn:hover { background: #e2e8f0; color: #0f172a; }\r\n#pth-sa-wrapper .pth-scope-note { background: #ffffff; border: 1px solid #e2e8f0; border-left: 4px solid #16a34a; border-radius: 0 10px 10px 0; padding: 15px 18px; margin-top: 12px; max-width: 800px; }\r\n#pth-sa-wrapper .pth-scope-note p { font-size: 13.5px; color: #475569; margin: 0 0 10px 0; line-height: 1.65; }\r\n#pth-sa-wrapper .pth-scope-note p:last-child { margin-bottom: 0; }\r\n#pth-sa-wrapper .pth-scope-note strong { color: #0f172a; }\r\n#pth-sa-wrapper .pth-head-btns { display: flex; gap: 8px; flex-wrap: wrap; }\r\n\r\n#pth-sa-wrapper .pth-btn { padding: 9px 16px; border-radius: 8px; font-weight: 700; cursor: pointer; border: 1px solid #e2e8f0; font-family: inherit; font-size: 14px; transition: background 0.15s, color 0.15s; background: #ffffff; color: #334155; }\r\n#pth-sa-wrapper .pth-btn:hover { background: #f1f5f9; color: #0f172a; }\r\n#pth-sa-wrapper .pth-btn.primary { background: #1d4ed8; color: #ffffff; border-color: #1d4ed8; }\r\n#pth-sa-wrapper .pth-btn.primary:hover { background: #1e40af; color: #ffffff; }\r\n#pth-sa-wrapper .pth-btn.success { background: #047857; color: #ffffff; border-color: #047857; }\r\n#pth-sa-wrapper .pth-btn.success:hover { background: #065f46; color: #ffffff; }\r\n#pth-sa-wrapper .pth-btn.danger { background: #fee2e2; color: #991b1b; border-color: #fecaca; }\r\n#pth-sa-wrapper .pth-btn.danger:hover { background: #fecaca; color: #7f1d1d; }\r\n#pth-sa-wrapper .pth-btn:focus-visible { outline: 3px solid #93c5fd; outline-offset: 2px; }\r\n#pth-sa-wrapper .pth-btn[disabled] { opacity: 0.55; cursor: not-allowed; }\r\n\r\n#pth-sa-wrapper .pth-tabbar { display: flex; gap: 6px; flex-wrap: wrap; border-bottom: 2px solid #e2e8f0; margin-bottom: 20px; }\r\n#pth-sa-wrapper .pth-tab { padding: 11px 18px; border: none; background: transparent; font-family: inherit; font-size: 14px; font-weight: 700; color: #64748b; cursor: pointer; border-bottom: 3px solid transparent; margin-bottom: -2px; }\r\n#pth-sa-wrapper .pth-tab:hover { color: #1d4ed8; }\r\n#pth-sa-wrapper .pth-tab.active { color: #1d4ed8; border-bottom-color: #1d4ed8; }\r\n#pth-sa-wrapper .pth-tab:focus-visible { outline: 3px solid #93c5fd; outline-offset: -3px; }\r\n#pth-sa-wrapper .pth-panel { display: none; }\r\n#pth-sa-wrapper .pth-panel.active { display: block; }\r\n\r\n#pth-sa-wrapper .pth-grid { display: grid; grid-template-columns: 1.8fr 1fr; gap: 22px; align-items: start; }\r\n#pth-sa-wrapper .pth-grid.pth-full-width { grid-template-columns: 1fr; }\r\n#pth-sa-wrapper .pth-card { background: #ffffff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 22px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05); }\r\n\r\n#pth-sa-wrapper .pth-io { margin-bottom: 16px; }\r\n#pth-sa-wrapper .pth-io-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 7px; }\r\n#pth-sa-wrapper .pth-label { font-size: 13px; font-weight: 700; color: #334155; }\r\n#pth-sa-wrapper .pth-textarea { width: 100%; min-height: 170px; padding: 13px; border: 1px solid #cbd5e1; border-radius: 8px; font-family: inherit; font-size: 14.5px; line-height: 1.7; color: #0f172a; background: #ffffff; resize: vertical; }\r\n#pth-sa-wrapper .pth-textarea:focus { outline: 3px solid #93c5fd; outline-offset: 1px; border-color: #1d4ed8; }\r\n#pth-sa-wrapper .pth-stat { background: #f1f5f9; color: #475569; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; }\r\n#pth-sa-wrapper .pth-select { padding: 9px 12px; border: 1px solid #cbd5e1; border-radius: 8px; font-family: inherit; font-size: 14px; color: #0f172a; background: #ffffff; }\r\n#pth-sa-wrapper .pth-ctrlbar { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin-bottom: 16px; }\r\n\r\n\/* Verdict banner *\/\r\n#pth-sa-wrapper .pth-verdict { display: flex; align-items: center; gap: 18px; padding: 20px; border-radius: 12px; margin-bottom: 18px; flex-wrap: wrap; min-height: 96px; }\r\n#pth-sa-wrapper .pth-verdict.pos { background: #f0fdf4; border: 1px solid #bbf7d0; }\r\n#pth-sa-wrapper .pth-verdict.neg { background: #fef2f2; border: 1px solid #fecaca; }\r\n#pth-sa-wrapper .pth-verdict.neu { background: #f8fafc; border: 1px solid #e2e8f0; }\r\n#pth-sa-wrapper .pth-vscore { font-size: 40px; font-weight: 800; font-family: 'Consolas', monospace; line-height: 1; }\r\n#pth-sa-wrapper .pth-verdict.pos .pth-vscore { color: #166534; }\r\n#pth-sa-wrapper .pth-verdict.neg .pth-vscore { color: #991b1b; }\r\n#pth-sa-wrapper .pth-verdict.neu .pth-vscore { color: #475569; }\r\n#pth-sa-wrapper .pth-vtext { flex: 1; min-width: 200px; }\r\n#pth-sa-wrapper .pth-vlabel { font-size: 17px; font-weight: 800; color: #0f172a; margin-bottom: 3px; }\r\n#pth-sa-wrapper .pth-vdesc { font-size: 13.5px; color: #475569; }\r\n\r\n\/* Bars *\/\r\n#pth-sa-wrapper .pth-bar-row { display: grid; grid-template-columns: 116px 1fr 54px; gap: 10px; align-items: center; margin-bottom: 9px; font-size: 13px; }\r\n#pth-sa-wrapper .pth-bar-name { color: #334155; font-weight: 700; }\r\n#pth-sa-wrapper .pth-bar-track { background: #f1f5f9; border-radius: 999px; height: 10px; overflow: hidden; }\r\n#pth-sa-wrapper .pth-bar-fill { height: 100%; border-radius: 999px; background: #1d4ed8; width: 0; }\r\n#pth-sa-wrapper .pth-bar-fill.joy { background: #16a34a; }\r\n#pth-sa-wrapper .pth-bar-fill.anger { background: #dc2626; }\r\n#pth-sa-wrapper .pth-bar-fill.fear { background: #7c3aed; }\r\n#pth-sa-wrapper .pth-bar-fill.sadness { background: #2563eb; }\r\n#pth-sa-wrapper .pth-bar-fill.surprise { background: #d97706; }\r\n#pth-sa-wrapper .pth-bar-val { text-align: right; font-family: 'Consolas', monospace; color: #475569; font-weight: 700; }\r\n\r\n\/* Stat grid *\/\r\n#pth-sa-wrapper .pth-statgrid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 18px; }\r\n#pth-sa-wrapper .pth-statgrid.three { grid-template-columns: repeat(3, 1fr); }\r\n#pth-sa-wrapper .pth-statgrid.two { grid-template-columns: repeat(2, 1fr); }\r\n#pth-sa-wrapper .pth-statbox { border: 1px solid #e2e8f0; border-radius: 8px; padding: 14px; text-align: center; background: #f8fafc; }\r\n#pth-sa-wrapper .pth-statbox .n { font-size: 22px; font-weight: 800; color: #0f172a; font-family: 'Consolas', monospace; }\r\n#pth-sa-wrapper .pth-statbox .t { font-size: 11.5px; color: #64748b; font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px; }\r\n#pth-sa-wrapper .pth-statbox.good { background: #f0fdf4; border-color: #bbf7d0; }\r\n#pth-sa-wrapper .pth-statbox.good .n { color: #166534; }\r\n#pth-sa-wrapper .pth-statbox.bad { background: #fef2f2; border-color: #fecaca; }\r\n#pth-sa-wrapper .pth-statbox.bad .n { color: #991b1b; }\r\n\r\n\/* Tables *\/\r\n#pth-sa-wrapper .pth-tablewrap { border: 1px solid #e2e8f0; border-radius: 8px; overflow: auto; max-height: 430px; min-height: 110px; }\r\n#pth-sa-wrapper table { width: 100%; border-collapse: collapse; font-size: 13px; }\r\n#pth-sa-wrapper th { background: #f1f5f9; color: #334155; text-align: left; padding: 9px 12px; font-weight: 800; font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.4px; position: sticky; top: 0; }\r\n#pth-sa-wrapper td { padding: 9px 12px; border-top: 1px solid #f1f5f9; color: #334155; vertical-align: top; }\r\n#pth-sa-wrapper tr:hover td { background: #f8fafc; }\r\n#pth-sa-wrapper .pth-pill { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 11.5px; font-weight: 800; white-space: nowrap; }\r\n#pth-sa-wrapper .pth-pill.pos { background: #dcfce7; color: #166534; }\r\n#pth-sa-wrapper .pth-pill.neg { background: #fee2e2; color: #991b1b; }\r\n#pth-sa-wrapper .pth-pill.neu { background: #f1f5f9; color: #475569; }\r\n\r\n#pth-sa-wrapper .pth-chips { display: flex; flex-wrap: wrap; gap: 7px; }\r\n#pth-sa-wrapper .pth-chip { padding: 5px 11px; border-radius: 999px; font-size: 12.5px; font-weight: 700; border: 1px solid #e2e8f0; background: #f8fafc; color: #334155; }\r\n#pth-sa-wrapper .pth-chip.pos { background: #dcfce7; border-color: #bbf7d0; color: #166534; }\r\n#pth-sa-wrapper .pth-chip.neg { background: #fee2e2; border-color: #fecaca; color: #991b1b; }\r\n\r\n#pth-sa-wrapper .pth-note { padding: 12px 15px; border-radius: 8px; font-size: 13.5px; margin-bottom: 16px; line-height: 1.6; }\r\n#pth-sa-wrapper .pth-note.info { background: #eff6ff; border: 1px solid #bfdbfe; color: #1e40af; }\r\n#pth-sa-wrapper .pth-note.ok { background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534; }\r\n#pth-sa-wrapper .pth-note.warn { background: #fffbeb; border: 1px solid #fde68a; color: #92400e; }\r\n\r\n\/* Model panel *\/\r\n#pth-sa-wrapper .pth-model-box { border: 1px solid #e2e8f0; border-radius: 10px; padding: 15px; background: #f8fafc; margin-bottom: 18px; }\r\n#pth-sa-wrapper .pth-model-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 9px; flex-wrap: wrap; }\r\n#pth-sa-wrapper .pth-model-title { font-size: 13.5px; font-weight: 800; color: #0f172a; }\r\n#pth-sa-wrapper .pth-prog-track { background: #e2e8f0; border-radius: 999px; height: 8px; overflow: hidden; margin-top: 9px; display: none; }\r\n#pth-sa-wrapper .pth-prog-fill { height: 100%; background: #1d4ed8; width: 0; border-radius: 999px; }\r\n\r\n\/* Sidebar *\/\r\n#pth-sa-wrapper .pth-side h3 { font-size: 15px; font-weight: 800; color: #1e293b; margin: 0 0 9px 0; }\r\n#pth-sa-wrapper .pth-side h3.spaced { margin-top: 22px; }\r\n#pth-sa-wrapper .pth-side p { font-size: 13.5px; color: #475569; margin: 0 0 12px 0; }\r\n#pth-sa-wrapper .pth-side ul { margin: 0 0 12px 0; padding-left: 18px; }\r\n#pth-sa-wrapper .pth-side li { font-size: 13.5px; color: #475569; margin-bottom: 6px; }\r\n#pth-sa-wrapper .pth-side a { color: #1d4ed8; font-weight: 700; text-decoration: none; }\r\n#pth-sa-wrapper .pth-side a:hover { text-decoration: underline; }\r\n#pth-sa-wrapper code { background: #f1f5f9; padding: 2px 6px; border-radius: 4px; font-family: 'Consolas', monospace; font-size: 12.5px; color: #1e293b; }\r\n#pth-sa-wrapper .pth-tip { background: #eff6ff; border: 1px solid #bfdbfe; padding: 13px; border-radius: 8px; font-size: 13px; color: #1e40af; line-height: 1.6; }\r\n\r\n@media (max-width: 900px) {\r\n    #pth-sa-wrapper { padding: 14px; }\r\n    #pth-sa-wrapper .pth-grid { grid-template-columns: 1fr; }\r\n    #pth-sa-wrapper .pth-h2 { font-size: 24px; }\r\n    #pth-sa-wrapper .pth-tab { padding: 10px 12px; font-size: 13px; }\r\n    #pth-sa-wrapper .pth-statgrid, #pth-sa-wrapper .pth-statgrid.three { grid-template-columns: repeat(2, 1fr); }\r\n    #pth-sa-wrapper .pth-bar-row { grid-template-columns: 92px 1fr 46px; }\r\n}\r\n<\/style>\r\n\r\n<!-- The toast lives outside the wrapper so no ancestor transform or filter can\r\n     turn its fixed positioning into absolute positioning. -->\r\n<div id=\"pth-sa-toast\" style=\"position:fixed; bottom:24px; right:24px; background:#0f172a; color:#ffffff; padding:13px 22px; border-radius:10px; font-size:14px; font-weight:700; box-shadow:0 10px 25px rgba(0,0,0,0.2); z-index:2147483000; display:none; font-family:'Inter',system-ui,sans-serif; max-width:320px;\" role=\"status\" aria-live=\"polite\">Done<\/div>\r\n\r\n<div id=\"pth-sa-wrapper\">\r\n\r\n    <div class=\"pth-head\">\r\n        <div>\r\n            <h2 class=\"pth-h2\">\ud83d\udcca Sentiment &amp; Brand Studio<\/h2>\r\n            <p class=\"pth-sub\">Read the mood of any text, score a pile of reviews at once, and find out which feature people are actually complaining about.<\/p>\r\n            <div class=\"pth-badges\">\r\n                <span class=\"pth-version-badge\">v4.0<\/span>\r\n                <span class=\"pth-status-offline\" title=\"Your text is analysed in your browser and never uploaded.\">\ud83d\udd12 100% Offline<\/span>\r\n                <button type=\"button\" class=\"pth-scope-btn\" id=\"pth-sa-scope-btn\" aria-expanded=\"false\" aria-controls=\"pth-sa-scope-note\">\u24d8 What that covers<\/button>\r\n            <\/div>\r\n            <div class=\"pth-scope-note\" id=\"pth-sa-scope-note\" style=\"display:none;\">\r\n                <p><strong>Your text never leaves this browser.<\/strong> Scoring, aspect extraction and every table on this page are computed on your own device. No request carries what you paste in, so nothing you analyse is logged anywhere.<\/p>\r\n                <p><strong>The optional AI model is a one-time download, not an upload.<\/strong> If you switch the engine to the neural model, your browser fetches the model file once from a public CDN and stores it in its own cache. That request asks for a file; it does not contain your text. Afterwards the model runs locally and works with the network switched off.<\/p>\r\n                <p>The built-in lexicon engine needs no download at all and is ready the moment the page loads.<\/p>\r\n            <\/div>\r\n        <\/div>\r\n        <div class=\"pth-head-btns\">\r\n            <button type=\"button\" class=\"pth-btn\" id=\"pth-sa-sample\">\ud83d\udca1 Load Sample<\/button>\r\n            <button type=\"button\" class=\"pth-btn\" id=\"pth-sa-fw\">\u26f6 Full Width<\/button>\r\n            <button type=\"button\" class=\"pth-btn danger\" id=\"pth-sa-clear\">\ud83d\uddd1\ufe0f Clear<\/button>\r\n        <\/div>\r\n    <\/div>\r\n\r\n    <div class=\"pth-tabbar\" role=\"tablist\">\r\n        <button type=\"button\" class=\"pth-tab active\" data-pth-panel=\"analyze\" role=\"tab\">\ud83d\udcca Analyze<\/button>\r\n        <button type=\"button\" class=\"pth-tab\" data-pth-panel=\"brand\" role=\"tab\">\ud83c\udff7\ufe0f Brand Monitor<\/button>\r\n        <button type=\"button\" class=\"pth-tab\" data-pth-panel=\"aspect\" role=\"tab\">\ud83d\udd0d Aspect Analysis<\/button>\r\n        <button type=\"button\" class=\"pth-tab\" data-pth-panel=\"compare\" role=\"tab\">\u2696\ufe0f Compare<\/button>\r\n    <\/div>\r\n\r\n    <div class=\"pth-grid\" id=\"pth-sa-grid\">\r\n\r\n        <div>\r\n            <!-- ============ PANEL 1: ANALYZE ============ -->\r\n            <div class=\"pth-panel active\" id=\"pth-panel-analyze\" role=\"tabpanel\">\r\n                <div class=\"pth-card\">\r\n                    <div class=\"pth-io\">\r\n                        <div class=\"pth-io-head\">\r\n                            <label class=\"pth-label\" for=\"pth-sa-input\">Text to analyse<\/label>\r\n                            <span class=\"pth-stat\" id=\"pth-sa-in-stat\">0 words<\/span>\r\n                        <\/div>\r\n                        <textarea id=\"pth-sa-input\" class=\"pth-textarea\" placeholder=\"Paste a review, a support email, a social post or any passage of text...\" aria-label=\"Text to analyse\"><\/textarea>\r\n                    <\/div>\r\n\r\n                    <div class=\"pth-ctrlbar\">\r\n                        <button type=\"button\" class=\"pth-btn primary\" id=\"pth-sa-run\">\u26a1 Analyse<\/button>\r\n                        <span class=\"pth-stat\" id=\"pth-sa-engine-stat\">Lexicon engine<\/span>\r\n                    <\/div>\r\n\r\n                    <div class=\"pth-verdict neu\" id=\"pth-sa-verdict\">\r\n                        <div class=\"pth-vscore\" id=\"pth-sa-score\">--<\/div>\r\n                        <div class=\"pth-vtext\">\r\n                            <div class=\"pth-vlabel\" id=\"pth-sa-label\">No text analysed yet<\/div>\r\n                            <div class=\"pth-vdesc\" id=\"pth-sa-desc\">Paste something above and press Analyse. The score runs from -100 to +100.<\/div>\r\n                        <\/div>\r\n                    <\/div>\r\n\r\n                    <div class=\"pth-statgrid\">\r\n                        <div class=\"pth-statbox\"><div class=\"n\" id=\"pth-sa-st-sent\">0<\/div><div class=\"t\">Sentences<\/div><\/div>\r\n                        <div class=\"pth-statbox good\"><div class=\"n\" id=\"pth-sa-st-pos\">0<\/div><div class=\"t\">Positive<\/div><\/div>\r\n                        <div class=\"pth-statbox bad\"><div class=\"n\" id=\"pth-sa-st-neg\">0<\/div><div class=\"t\">Negative<\/div><\/div>\r\n                        <div class=\"pth-statbox\"><div class=\"n\" id=\"pth-sa-st-subj\">--<\/div><div class=\"t\">Subjectivity<\/div><\/div>\r\n                    <\/div>\r\n\r\n                    <h3 style=\"font-size:15px; font-weight:800; color:#1e293b; margin:0 0 12px 0;\">Emotional tone<\/h3>\r\n                    <div id=\"pth-sa-emotions\"><\/div>\r\n\r\n                    <h3 style=\"font-size:15px; font-weight:800; color:#1e293b; margin:22px 0 10px 0;\">Words driving the score<\/h3>\r\n                    <div class=\"pth-chips\" id=\"pth-sa-drivers\"><span style=\"font-size:13px; color:#94a3b8;\">Nothing analysed yet.<\/span><\/div>\r\n\r\n                    <h3 style=\"font-size:15px; font-weight:800; color:#1e293b; margin:22px 0 10px 0;\">Sentence by sentence<\/h3>\r\n                    <div class=\"pth-tablewrap\">\r\n                        <table>\r\n                            <thead><tr><th style=\"width:110px;\">Sentiment<\/th><th>Sentence<\/th><\/tr><\/thead>\r\n                            <tbody id=\"pth-sa-sentences\"><\/tbody>\r\n                        <\/table>\r\n                    <\/div>\r\n                <\/div>\r\n            <\/div>\r\n\r\n            <!-- ============ PANEL 2: BRAND MONITOR ============ -->\r\n            <div class=\"pth-panel\" id=\"pth-panel-brand\" role=\"tabpanel\">\r\n                <div class=\"pth-card\">\r\n                    <div class=\"pth-io\">\r\n                        <div class=\"pth-io-head\">\r\n                            <label class=\"pth-label\" for=\"pth-sa-batch\">One review per line<\/label>\r\n                            <span class=\"pth-stat\" id=\"pth-sa-batch-stat\">0 rows<\/span>\r\n                        <\/div>\r\n                        <textarea id=\"pth-sa-batch\" class=\"pth-textarea\" placeholder=\"Paste many reviews, one per line. CSV lines work too - the first column is used.\" aria-label=\"Reviews, one per line\"><\/textarea>\r\n                    <\/div>\r\n\r\n                    <div class=\"pth-ctrlbar\">\r\n                        <button type=\"button\" class=\"pth-btn primary\" id=\"pth-sa-run-batch\">\u26a1 Score all rows<\/button>\r\n                        <button type=\"button\" class=\"pth-btn success\" id=\"pth-sa-export\">\ud83d\udcbe Export CSV<\/button>\r\n                        <select id=\"pth-sa-sort\" class=\"pth-select\" aria-label=\"Sort results\">\r\n                            <option value=\"orig\">Original order<\/option>\r\n                            <option value=\"worst\">Worst first<\/option>\r\n                            <option value=\"best\">Best first<\/option>\r\n                        <\/select>\r\n                    <\/div>\r\n\r\n                    <div class=\"pth-statgrid\">\r\n                        <div class=\"pth-statbox\"><div class=\"n\" id=\"pth-sa-b-total\">0<\/div><div class=\"t\">Rows<\/div><\/div>\r\n                        <div class=\"pth-statbox good\"><div class=\"n\" id=\"pth-sa-b-pos\">0<\/div><div class=\"t\">Positive<\/div><\/div>\r\n                        <div class=\"pth-statbox bad\"><div class=\"n\" id=\"pth-sa-b-neg\">0<\/div><div class=\"t\">Negative<\/div><\/div>\r\n                        <div class=\"pth-statbox\"><div class=\"n\" id=\"pth-sa-b-health\">--<\/div><div class=\"t\">Health \/ 100<\/div><\/div>\r\n                    <\/div>\r\n\r\n                    <div class=\"pth-note info\" id=\"pth-sa-b-note\">Paste some rows and press Score all rows.<\/div>\r\n\r\n                    <h3 style=\"font-size:15px; font-weight:800; color:#1e293b; margin:0 0 10px 0;\">What people praise<\/h3>\r\n                    <div class=\"pth-chips\" id=\"pth-sa-b-praise\"><span style=\"font-size:13px; color:#94a3b8;\">Nothing scored yet.<\/span><\/div>\r\n\r\n                    <h3 style=\"font-size:15px; font-weight:800; color:#1e293b; margin:20px 0 10px 0;\">What people complain about<\/h3>\r\n                    <div class=\"pth-chips\" id=\"pth-sa-b-blame\"><span style=\"font-size:13px; color:#94a3b8;\">Nothing scored yet.<\/span><\/div>\r\n\r\n                    <h3 style=\"font-size:15px; font-weight:800; color:#1e293b; margin:20px 0 10px 0;\">Themes people keep raising<\/h3>\r\n                    <div style=\"display:flex; gap:9px; flex-wrap:wrap; margin-bottom:12px;\">\r\n                        <button type=\"button\" class=\"pth-btn\" id=\"pth-sa-cluster-btn\">\ud83e\uddf2 Group similar reviews<\/button>\r\n                    <\/div>\r\n                    <div id=\"pth-sa-themes\"><p style=\"font-size:13px; color:#94a3b8; margin:0;\">Enable theme grouping in the sidebar, then press the button to cluster your rows by meaning.<\/p><\/div>\r\n\r\n                    <h3 style=\"font-size:15px; font-weight:800; color:#1e293b; margin:20px 0 10px 0;\">Every row<\/h3>\r\n                    <div class=\"pth-tablewrap\">\r\n                        <table>\r\n                            <thead><tr><th style=\"width:52px;\">#<\/th><th style=\"width:110px;\">Sentiment<\/th><th style=\"width:66px;\">Score<\/th><th>Text<\/th><\/tr><\/thead>\r\n                            <tbody id=\"pth-sa-b-rows\"><\/tbody>\r\n                        <\/table>\r\n                    <\/div>\r\n                <\/div>\r\n            <\/div>\r\n\r\n            <!-- ============ PANEL 3: ASPECT ANALYSIS ============ -->\r\n            <div class=\"pth-panel\" id=\"pth-panel-aspect\" role=\"tabpanel\">\r\n                <div class=\"pth-card\">\r\n                    <div class=\"pth-note info\">Aspect analysis splits each sentence into clauses and works out which <strong>thing<\/strong> each opinion is attached to. A review saying the battery is poor but the screen is lovely produces two separate verdicts rather than one muddled average.<\/div>\r\n\r\n                    <div class=\"pth-io\">\r\n                        <div class=\"pth-io-head\">\r\n                            <label class=\"pth-label\" for=\"pth-sa-aspect-in\">Reviews or feedback to break down<\/label>\r\n                            <button type=\"button\" class=\"pth-btn\" id=\"pth-sa-aspect-pull\">\u2b07 Pull from Brand Monitor<\/button>\r\n                        <\/div>\r\n                        <textarea id=\"pth-sa-aspect-in\" class=\"pth-textarea\" placeholder=\"Paste one or many reviews. The more you paste, the clearer the pattern.\" aria-label=\"Text for aspect analysis\"><\/textarea>\r\n                    <\/div>\r\n\r\n                    <div class=\"pth-ctrlbar\">\r\n                        <button type=\"button\" class=\"pth-btn primary\" id=\"pth-sa-run-aspect\">\ud83d\udd0d Find aspects<\/button>\r\n                        <span class=\"pth-stat\" id=\"pth-sa-aspect-stat\">0 aspects found<\/span>\r\n                    <\/div>\r\n\r\n                    <div class=\"pth-tablewrap\">\r\n                        <table>\r\n                            <thead><tr><th style=\"width:150px;\">Aspect<\/th><th style=\"width:80px;\">Mentions<\/th><th style=\"width:110px;\">Verdict<\/th><th>Example mention<\/th><\/tr><\/thead>\r\n                            <tbody id=\"pth-sa-aspect-rows\"><\/tbody>\r\n                        <\/table>\r\n                    <\/div>\r\n                    <p style=\"font-size:12.5px; color:#64748b; margin:11px 0 0 0;\">Aspects are picked out by grammar patterns, not by a trained model, so they are suggestions rather than certainties. Skim the example column before acting on any of them.<\/p>\r\n                <\/div>\r\n            <\/div>\r\n\r\n            <!-- ============ PANEL 4: COMPARE ============ -->\r\n            <div class=\"pth-panel\" id=\"pth-panel-compare\" role=\"tabpanel\">\r\n                <div class=\"pth-card\">\r\n                    <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px;\" id=\"pth-sa-cmp-grid\">\r\n                        <div>\r\n                            <div class=\"pth-io-head\"><label class=\"pth-label\" for=\"pth-sa-cmp-a\">Text A<\/label><\/div>\r\n                            <textarea id=\"pth-sa-cmp-a\" class=\"pth-textarea\" style=\"min-height:150px;\" placeholder=\"First version, first product, first month...\" aria-label=\"Text A\"><\/textarea>\r\n                        <\/div>\r\n                        <div>\r\n                            <div class=\"pth-io-head\"><label class=\"pth-label\" for=\"pth-sa-cmp-b\">Text B<\/label><\/div>\r\n                            <textarea id=\"pth-sa-cmp-b\" class=\"pth-textarea\" style=\"min-height:150px;\" placeholder=\"Second version, competitor, this month...\" aria-label=\"Text B\"><\/textarea>\r\n                        <\/div>\r\n                    <\/div>\r\n\r\n                    <div class=\"pth-ctrlbar\">\r\n                        <button type=\"button\" class=\"pth-btn primary\" id=\"pth-sa-run-cmp\">\u2696\ufe0f Compare<\/button>\r\n                    <\/div>\r\n\r\n                    <div class=\"pth-note info\" id=\"pth-sa-cmp-note\">Paste text into both boxes and press Compare.<\/div>\r\n\r\n                    <div class=\"pth-tablewrap\">\r\n                        <table>\r\n                            <thead><tr><th>Measure<\/th><th style=\"width:90px;\">Text A<\/th><th style=\"width:90px;\">Text B<\/th><th style=\"width:110px;\">Difference<\/th><\/tr><\/thead>\r\n                            <tbody id=\"pth-sa-cmp-rows\"><\/tbody>\r\n                        <\/table>\r\n                    <\/div>\r\n                <\/div>\r\n            <\/div>\r\n        <\/div>\r\n\r\n        <!-- ============ SIDEBAR ============ -->\r\n        <div class=\"pth-card pth-side\" id=\"pth-sa-side\">\r\n\r\n            <div class=\"pth-model-box\">\r\n                <div class=\"pth-model-head\">\r\n                    <span class=\"pth-model-title\">Scoring engine<\/span>\r\n                    <span class=\"pth-stat\" id=\"pth-sa-model-state\">Lexicon ready<\/span>\r\n                <\/div>\r\n                <select id=\"pth-sa-engine\" class=\"pth-select\" style=\"width:100%; margin-bottom:9px;\" aria-label=\"Scoring engine\">\r\n                    <option value=\"lex\">Lexicon \u2014 instant, no download<\/option>\r\n                    <option value=\"ai\">Neural model \u2014 more nuance<\/option>\r\n                <\/select>\r\n                <div id=\"pth-sa-model-picker\" style=\"display:none; margin-bottom:9px;\">\r\n                    <label class=\"pth-label\" for=\"pth-sa-model\" style=\"display:block; margin-bottom:5px;\">Which model<\/label>\r\n                    <select id=\"pth-sa-model\" class=\"pth-select\" style=\"width:100%;\" aria-label=\"Neural model\">\r\n                        <option value=\"fast\">DistilBERT \u2014 fastest, general reviews (~28 MB)<\/option>\r\n                        <option value=\"social\">Twitter RoBERTa \u2014 social, emoji, slang (~80 MB)<\/option>\r\n                    <\/select>\r\n                <\/div>\r\n                <p style=\"font-size:12.5px; color:#475569; margin:0;\" id=\"pth-sa-model-note\">The lexicon engine scores as fast as you can type and needs nothing downloaded.<\/p>\r\n                <div class=\"pth-prog-track\" id=\"pth-sa-prog\"><div class=\"pth-prog-fill\" id=\"pth-sa-prog-fill\"><\/div><\/div>\r\n                <button type=\"button\" class=\"pth-btn\" id=\"pth-sa-load-model\" style=\"width:100%; margin-top:10px; display:none;\">\u2b07 Download model (one time)<\/button>\r\n                <div style=\"display:flex; justify-content:space-between; gap:8px; margin-top:9px; font-size:11.5px; color:#64748b; font-weight:700;\">\r\n                    <span id=\"pth-sa-device\">Checking hardware...<\/span>\r\n                    <span id=\"pth-sa-cache-state\"><\/span>\r\n                <\/div>\r\n            <\/div>\r\n\r\n            <div class=\"pth-model-box\" id=\"pth-sa-cluster-box\">\r\n                <div class=\"pth-model-head\">\r\n                    <span class=\"pth-model-title\">\ud83e\uddf2 Theme grouping<\/span>\r\n                    <span class=\"pth-stat\" id=\"pth-sa-cluster-state\">Not loaded<\/span>\r\n                <\/div>\r\n                <p style=\"font-size:12.5px; color:#475569; margin:0 0 9px 0;\">Groups reviews that mean the same thing even when they share no words \u2014 \"dies before lunch\" and \"drains way too fast\" land together. Needs a 23 MB model.<\/p>\r\n                <button type=\"button\" class=\"pth-btn\" id=\"pth-sa-load-embed\" style=\"width:100%;\">\u2b07 Enable theme grouping (23 MB)<\/button>\r\n                <div class=\"pth-prog-track\" id=\"pth-sa-eprog\"><div class=\"pth-prog-fill\" id=\"pth-sa-eprog-fill\"><\/div><\/div>\r\n            <\/div>\r\n\r\n            <div id=\"pth-side-analyze\">\r\n                <h3>How the score is worked out<\/h3>\r\n                <p>Each word carries a weight, and the sentence around it changes that weight. <code>Not good<\/code> is not a positive with a minus sign in front of it, and <code>very good<\/code> is stronger than <code>good<\/code> \u2014 both are handled explicitly.<\/p>\r\n                <p>Capital letters and exclamation marks push a score further from neutral, because people reach for them when they mean it. The word <code>but<\/code> matters too: in <em>the food was fine but the service was appalling<\/em>, the half after <code>but<\/code> carries most of the weight, which is how a reader would take it as well.<\/p>\r\n                <div class=\"pth-tip\"><strong>Reading the number:<\/strong> anything between -15 and +15 is effectively neutral. Treat a single short sentence with caution \u2014 one strong word can swing it a long way.<\/div>\r\n            <\/div>\r\n\r\n            <div id=\"pth-side-brand\" style=\"display:none;\">\r\n                <h3>Scoring a batch<\/h3>\r\n                <p>Paste one review per line and every row is scored separately, then rolled up into a health figure out of 100. Sorting worst-first is usually the fastest way to find the complaints worth reading in full.<\/p>\r\n                <p>The praise and complaint chips are the words appearing most often in positive and negative rows. They are a starting point for reading, not a summary you should quote.<\/p>\r\n                <div class=\"pth-tip\"><strong>Sample size matters:<\/strong> a health score from twelve reviews is close to meaningless. Below about fifty rows, read the text rather than trusting the number.<\/div>\r\n            <\/div>\r\n\r\n            <div id=\"pth-side-aspect\" style=\"display:none;\">\r\n                <h3>Why an average hides the truth<\/h3>\r\n                <p>A product with a wonderful screen and a hopeless battery averages out to something bland and unhelpful. The overall score says nothing is wrong; the customers say otherwise.<\/p>\r\n                <p>Splitting opinions by the thing they are about is called aspect-based sentiment analysis. It is what turns a wall of reviews into a list you can hand to somebody with the words \"fix this first\".<\/p>\r\n                <div class=\"pth-tip\"><strong>Best input:<\/strong> plenty of short, specific reviews. Long rambling essays and one-line ratings both give the extractor very little to attach an opinion to.<\/div>\r\n            <\/div>\r\n\r\n            <div id=\"pth-side-compare\" style=\"display:none;\">\r\n                <h3>Two texts, side by side<\/h3>\r\n                <p>Useful for checking whether a rewrite actually landed softer, whether this month's feedback is better than last month's, or how your reviews read against a competitor's.<\/p>\r\n                <p>Compare like with like. Fifty reviews against five will differ for reasons that have nothing to do with sentiment, and a formal document will always score flatter than casual writing.<\/p>\r\n                <div class=\"pth-tip\"><strong>Practical use:<\/strong> paste a complaint reply before and after editing. If the tone barely moved, the edit was cosmetic.<\/div>\r\n            <\/div>\r\n\r\n            <h3 class=\"spaced\">Related tools<\/h3>\r\n            <ul>\r\n                <li><a href=\"https:\/\/schoolict.net\/tools\/free-ai-text-summarizer-tldr-generator\/\">AI Text Summarizer<\/a> \u2014 condense a long review set<\/li>\r\n                <li><a href=\"https:\/\/schoolict.net\/tools\/free-word-counter-and-text-case-converter\/\">Word Counter &amp; Case Converter<\/a> \u2014 counts, case and keyword density<\/li>\r\n                <li><a href=\"https:\/\/schoolict.net\/tools\/voice-typing-studio-free\/\">Voice Typing Studio<\/a> \u2014 dictate feedback, and translate on-device<\/li>\r\n                <li><a href=\"https:\/\/schoolict.net\/tools\/browser-ai-models-directory\/\">Browser AI Models Directory<\/a> \u2014 what else runs in a browser<\/li>\r\n            <\/ul>\r\n        <\/div>\r\n\r\n    <\/div>\r\n<\/div>\r\n\r\n<script data-no-optimize=\"1\" data-no-minify=\"1\" data-cfasync=\"false\">\r\n(function() {\r\n    'use strict';\r\n\r\n    window.pthSentimentApp = {\r\n        initialized: false,\r\n        bound: false,\r\n        engine: 'lex',\r\n        aiReady: false,\r\n        aiLoading: false,\r\n        worker: null,\r\n        batchRows: [],\r\n\r\n        \/* ---------- Lexicon data ----------\r\n           Compact \"word:weight\" pairs, parsed once at start up. Weights run\r\n           from about -3.5 to +3.5 in the style of a valence lexicon. *\/\r\n        RAW_LEX: 'good:1.9 great:3.1 excellent:3.2 amazing:3.4 awesome:3.4 fantastic:3.4 wonderful:3.3 perfect:3.4 love:3.2 loved:3.1 loves:3.0 like:1.5 liked:1.5 likes:1.4 best:3.2 better:1.9 nice:1.8 happy:2.7 pleased:2.4 satisfied:2.3 satisfying:2.4 impressive:2.6 impressed:2.5 recommend:2.4 recommended:2.3 helpful:2.2 useful:2.1 easy:1.6 simple:1.3 fast:1.5 quick:1.4 smooth:1.8 reliable:2.2 solid:1.7 sturdy:1.8 beautiful:2.9 gorgeous:3.0 stunning:3.0 brilliant:3.1 outstanding:3.3 superb:3.3 delightful:2.9 enjoyable:2.4 enjoyed:2.3 comfortable:2.0 affordable:1.9 bargain:2.1 worth:1.7 clean:1.5 clear:1.3 friendly:2.1 polite:2.0 professional:2.0 responsive:1.9 prompt:1.8 generous:2.2 thoughtful:2.0 grateful:2.5 thank:1.9 thanks:1.9 appreciate:2.2 appreciated:2.2 success:2.4 successful:2.4 improved:1.9 improvement:1.6 works:1.4 working:1.1 fixed:1.5 resolved:1.9 pleasant:2.2 lovely:2.6 favourite:2.5 favorite:2.5 excited:2.4 flawless:3.1 spotless:2.4 punctual:2.0 accurate:1.8 durable:2.0 efficient:2.0 quiet:1.2 bright:1.3 fresh:1.6 tasty:2.4 delicious:3.0 cosy:2.0 spacious:1.8 stylish:2.1 elegant:2.3 handy:1.7 painless:1.9 straightforward:1.6 genuine:1.8 honest:2.2 trustworthy:2.5 secure:1.8 safe:1.8 glad:2.1 help:1.2 helps:1.2 helped:1.4 apologise:0.7 apologize:0.7 credit:0.7 sorted:1.4 resolved:1.9 refunded:1.1 replaced:1.0 bad:-2.5 terrible:-3.2 awful:-3.1 horrible:-3.3 horrendous:-3.3 worst:-3.4 hate:-3.2 hated:-3.1 poor:-2.2 disappointing:-2.5 disappointed:-2.4 disappointment:-2.5 useless:-2.9 pointless:-2.4 broken:-2.6 broke:-2.2 fail:-2.5 failed:-2.6 failing:-2.5 failure:-2.7 slow:-1.7 sluggish:-1.9 laggy:-2.0 buggy:-2.3 glitchy:-2.1 crash:-2.6 crashes:-2.6 crashed:-2.6 freeze:-2.0 freezes:-2.0 frozen:-1.9 expensive:-1.5 overpriced:-2.4 pricey:-1.4 waste:-2.6 wasted:-2.5 annoying:-2.3 annoyed:-2.1 irritating:-2.3 frustrating:-2.6 frustrated:-2.5 angry:-2.8 furious:-3.2 upset:-2.3 sad:-2.1 unhappy:-2.4 rude:-2.7 impolite:-2.4 unprofessional:-2.5 unhelpful:-2.3 useless:-2.9 confusing:-1.9 confused:-1.5 difficult:-1.6 hard:-1.0 complicated:-1.5 messy:-1.8 dirty:-2.2 filthy:-2.9 damaged:-2.4 defective:-2.7 faulty:-2.5 flimsy:-2.1 cheap:-1.1 late:-1.6 delayed:-1.8 delay:-1.5 refund:-1.3 complaint:-2.0 complain:-1.9 problem:-1.7 problems:-1.8 issue:-1.3 issues:-1.4 error:-1.8 errors:-1.9 wrong:-2.0 lost:-1.9 missing:-1.7 ignored:-2.3 ignoring:-2.2 scam:-3.3 fraud:-3.3 fake:-2.5 lie:-2.7 lied:-2.8 dishonest:-2.8 misleading:-2.4 disgusting:-3.1 nasty:-2.8 painful:-2.3 mediocre:-1.6 bland:-1.5 boring:-1.8 dull:-1.5 noisy:-1.6 cramped:-1.7 uncomfortable:-2.0 unreliable:-2.6 unstable:-2.2 unusable:-2.9 unacceptable:-2.9 ridiculous:-2.2 appalling:-3.1 shocking:-2.2 dreadful:-3.0 hopeless:-2.7 nightmare:-2.8 avoid:-2.0 regret:-2.2 sorry:-0.6 denied:-1.9 deny:-1.8 refused:-2.0 refuse:-1.9 rejected:-2.0 unfortunately:-1.2 excuse:-1.3 blame:-1.9 argue:-1.6 stuck:-1.7 struggle:-1.9 struggling:-1.9',\r\n\r\n        RAW_INT: 'very:1.3 really:1.25 extremely:1.5 incredibly:1.5 absolutely:1.4 totally:1.3 completely:1.35 utterly:1.4 highly:1.3 super:1.35 so:1.2 too:1.15 quite:1.1 rather:1.05 fairly:0.9 somewhat:0.75 slightly:0.65 barely:0.5 marginally:0.6 remarkably:1.35 exceptionally:1.45',\r\n\r\n        RAW_NEG: 'not no never none nobody nothing neither nor nowhere cannot cant dont doesnt didnt isnt arent wasnt werent wont wouldnt shouldnt couldnt havent hasnt hadnt without lack lacks lacking rarely seldom hardly barely',\r\n\r\n        RAW_EMO: 'joy:happy joy delighted thrilled excited pleased glad cheerful love loved wonderful amazing enjoy enjoyed enjoyable fantastic grateful thankful thanks celebrate lovely delightful impressed satisfied|anger:angry furious mad rage annoyed irritated irritating frustrated frustrating outraged hate hated disgusted disgusting ridiculous unacceptable appalling rude fuming useless nonsense|fear:afraid scared worried worrying anxious nervous terrified concerned frightened panic risky unsafe insecure uncertain dread hesitant wary|sadness:sad unhappy depressed miserable disappointed heartbroken sorry regret gloomy upset lonely hurt hopeless disheartened dismayed unimpressed letdown|surprise:surprised shocked amazed astonished unexpected suddenly wow unbelievable stunned incredible',\r\n\r\n        RAW_SUBJ: 'i we my our me us think thinks thought feel feels felt believe believes opinion seems seemed appears personally honestly maybe perhaps probably certainly definitely surely apparently should must ought would could love hate like dislike prefer',\r\n\r\n        RAW_STOP: 'the a an and or but if of to in on at for with is are was were be been being it its this that these those i we you he she they them my our your his her their as so do does did have has had not no very really just also than then there here when what which who how why all any some more most other such only own same too can will would should could may might must about into over under after before out up down off again further once',\r\n\r\n        RAW_EMOJI: '\\uD83D\\uDE0D:3.2 \\uD83D\\uDE00:2.2 \\uD83D\\uDE0A:2.3 \\uD83D\\uDC4D:2.1 \\u2764:2.7 \\uD83D\\uDD25:1.8 \\uD83D\\uDCAF:2.4 \\uD83C\\uDF89:2.5 \\uD83D\\uDE02:1.7 \\uD83D\\uDE42:1.2 \\u2B50:2.0 \\uD83D\\uDE21:-3.0 \\uD83D\\uDE20:-2.6 \\uD83D\\uDC4E:-2.3 \\uD83D\\uDE22:-2.2 \\uD83D\\uDE2D:-2.6 \\uD83D\\uDC94:-2.8 \\uD83E\\uDD2C:-3.4 \\uD83D\\uDE1E:-2.2 \\uD83D\\uDE41:-1.6 \\uD83D\\uDE12:-1.9',\r\n\r\n        \/* ---------- Start up ---------- *\/\r\n        init: function() {\r\n            var self = this;\r\n            var failures = [];\r\n            var step = function(name, fn) {\r\n                try { fn(); } catch (err) {\r\n                    failures.push(name);\r\n                    if (window.console && window.console.error) {\r\n                        window.console.error('[Sentiment Studio] ' + name + ' failed', err);\r\n                    }\r\n                }\r\n            };\r\n            step('buildLexicon', function() { self.buildLexicon(); });\r\n            step('portalToast', function() { self.portalToast(); });\r\n            step('bindAll', function() { self.bindAll(); });\r\n            step('detectDevice', function() { self.detectDevice(); });\r\n            step('checkModelCache', function() { self.checkModelCache(); });\r\n            if (failures.length && window.console && window.console.warn) {\r\n                window.console.warn('[Sentiment Studio] steps failed: ' + failures.join(', '));\r\n            }\r\n        },\r\n\r\n        buildLexicon: function() {\r\n            var i, parts, pair;\r\n            this.LEX = {};\r\n            parts = this.RAW_LEX.split(' ');\r\n            for (i = 0; i < parts.length; i++) {\r\n                pair = parts[i].split(':');\r\n                if (pair.length === 2) { this.LEX[pair[0]] = parseFloat(pair[1]); }\r\n            }\r\n            this.INT = {};\r\n            parts = this.RAW_INT.split(' ');\r\n            for (i = 0; i < parts.length; i++) {\r\n                pair = parts[i].split(':');\r\n                if (pair.length === 2) { this.INT[pair[0]] = parseFloat(pair[1]); }\r\n            }\r\n            this.NEG = {};\r\n            parts = this.RAW_NEG.split(' ');\r\n            for (i = 0; i < parts.length; i++) { this.NEG[parts[i]] = true; }\r\n\r\n            this.EMOJI = {};\r\n            parts = this.RAW_EMOJI.split(' ');\r\n            for (i = 0; i < parts.length; i++) {\r\n                pair = parts[i].split(':');\r\n                if (pair.length === 2) { this.EMOJI[pair[0]] = parseFloat(pair[1]); }\r\n            }\r\n\r\n            this.EMO = {};\r\n            var groups = this.RAW_EMO.split('|');\r\n            var g, head;\r\n            for (g = 0; g < groups.length; g++) {\r\n                head = groups[g].split(':');\r\n                if (head.length === 2) { this.EMO[head[0]] = head[1].split(' '); }\r\n            }\r\n\r\n            this.SUBJ = {};\r\n            parts = this.RAW_SUBJ.split(' ');\r\n            for (i = 0; i < parts.length; i++) { this.SUBJ[parts[i]] = true; }\r\n\r\n            this.STOP = {};\r\n            parts = this.RAW_STOP.split(' ');\r\n            for (i = 0; i < parts.length; i++) { this.STOP[parts[i]] = true; }\r\n        },\r\n\r\n        \/* The toast is moved to the end of body so that a transform or filter on\r\n           any ancestor cannot convert its fixed position into an absolute one.\r\n           That is the bug which used to drop the old dialog to the page bottom. *\/\r\n        portalToast: function() {\r\n            var t = document.getElementById('pth-sa-toast');\r\n            if (t && t.parentNode !== document.body) { document.body.appendChild(t); }\r\n        },\r\n\r\n        \/* ---------- Event delegation ---------- *\/\r\n        bindAll: function() {\r\n            var self = this;\r\n            if (this.bound) { return; }\r\n            this.bound = true;\r\n\r\n            document.addEventListener('click', function(e) {\r\n                if (!self.inside(e.target)) { return; }\r\n                var tab = self.closestClass(e.target, 'pth-tab');\r\n                if (tab) { e.preventDefault(); self.switchTab(tab.getAttribute('data-pth-panel')); return; }\r\n                var btn = self.closestTag(e.target, 'BUTTON');\r\n                if (!btn || !btn.id) { return; }\r\n                self.route(btn.id, btn, e);\r\n            }, false);\r\n\r\n            document.addEventListener('input', function(e) {\r\n                var n = e.target;\r\n                if (!n || !n.id || !self.inside(n)) { return; }\r\n                if (n.id === 'pth-sa-input') { self.setText('pth-sa-in-stat', self.countWords(n.value) + ' words'); }\r\n                else if (n.id === 'pth-sa-batch') { self.setText('pth-sa-batch-stat', self.countRows(n.value) + ' rows'); }\r\n            }, false);\r\n\r\n            document.addEventListener('change', function(e) {\r\n                var n = e.target;\r\n                if (!n || !n.id || !self.inside(n)) { return; }\r\n                if (n.id === 'pth-sa-engine') { self.switchEngine(n.value); }\r\n                else if (n.id === 'pth-sa-model') { self.onModelPicked(); }\r\n                else if (n.id === 'pth-sa-sort') { self.renderBatch(); }\r\n            }, false);\r\n        },\r\n\r\n        inside: function(node) {\r\n            while (node && node.nodeType === 1) {\r\n                if (node.id === 'pth-sa-wrapper') { return true; }\r\n                node = node.parentNode;\r\n            }\r\n            return false;\r\n        },\r\n\r\n        closestClass: function(node, cls) {\r\n            while (node && node.nodeType === 1) {\r\n                if (node.className && (' ' + node.className + ' ').indexOf(' ' + cls + ' ') >= 0) { return node; }\r\n                if (node.id === 'pth-sa-wrapper') { return null; }\r\n                node = node.parentNode;\r\n            }\r\n            return null;\r\n        },\r\n\r\n        closestTag: function(node, tag) {\r\n            while (node && node.nodeType === 1) {\r\n                if (node.tagName === tag) { return node; }\r\n                if (node.id === 'pth-sa-wrapper') { return null; }\r\n                node = node.parentNode;\r\n            }\r\n            return null;\r\n        },\r\n\r\n        route: function(id, btn, e) {\r\n            if (id === 'pth-sa-sample') { this.loadSample(); }\r\n            else if (id === 'pth-sa-fw') { this.toggleWidth(); }\r\n            else if (id === 'pth-sa-clear') { this.clearAll(); }\r\n            else if (id === 'pth-sa-scope-btn') { this.toggleScope(btn); }\r\n            else if (id === 'pth-sa-run') { this.runAnalyze(); }\r\n            else if (id === 'pth-sa-run-batch') { this.runBatch(); }\r\n            else if (id === 'pth-sa-export') { this.exportCsv(); }\r\n            else if (id === 'pth-sa-run-aspect') { this.runAspect(); }\r\n            else if (id === 'pth-sa-aspect-pull') { this.pullAspect(); }\r\n            else if (id === 'pth-sa-run-cmp') { this.runCompare(); }\r\n            else if (id === 'pth-sa-load-model') { this.loadModel(); }\r\n            else if (id === 'pth-sa-load-embed') { this.loadEmbed(); }\r\n            else if (id === 'pth-sa-cluster-btn') { this.runCluster(); }\r\n            else { return; }\r\n            if (e && e.preventDefault) { e.preventDefault(); }\r\n        },\r\n\r\n        \/* ---------- Small helpers ---------- *\/\r\n        val: function(id) { var el = document.getElementById(id); return el ? el.value : ''; },\r\n        setText: function(id, txt) { var el = document.getElementById(id); if (el) { el.innerHTML = txt; } },\r\n\r\n        esc: function(s) {\r\n            return String(s).replace(\/&\/g, '&amp;')\r\n                .replace(new RegExp(String.fromCharCode(60), 'g'), '&lt;')\r\n                .replace(new RegExp(String.fromCharCode(62), 'g'), '&gt;')\r\n                .replace(\/\"\/g, '&quot;');\r\n        },\r\n\r\n        toast: function(msg, isError) {\r\n            var t = document.getElementById('pth-sa-toast');\r\n            if (!t) { return; }\r\n            t.innerHTML = msg;\r\n            t.style.background = isError ? '#991b1b' : '#0f172a';\r\n            t.style.display = 'block';\r\n            if (this.toastTimer) { clearTimeout(this.toastTimer); }\r\n            this.toastTimer = setTimeout(function() { t.style.display = 'none'; }, 3000);\r\n        },\r\n\r\n        countWords: function(text) {\r\n            var t = String(text || '').replace(\/[^\\w\\s']\/g, ' ').replace(\/^\\s+|\\s+$\/g, '');\r\n            return t ? t.split(\/\\s+\/).length : 0;\r\n        },\r\n\r\n        countRows: function(text) {\r\n            var lines = String(text || '').split('\\n');\r\n            var n = 0, i;\r\n            for (i = 0; i < lines.length; i++) { if (lines[i].replace(\/\\s\/g, '')) { n++; } }\r\n            return n;\r\n        },\r\n\r\n        switchTab: function(name) {\r\n            var tabs = document.querySelectorAll('#pth-sa-wrapper .pth-tab');\r\n            var panels = document.querySelectorAll('#pth-sa-wrapper .pth-panel');\r\n            var sides = ['analyze', 'brand', 'aspect', 'compare'];\r\n            var i, el;\r\n            for (i = 0; i < tabs.length; i++) {\r\n                tabs[i].classList.remove('active');\r\n                if (tabs[i].getAttribute('data-pth-panel') === name) { tabs[i].classList.add('active'); }\r\n            }\r\n            for (i = 0; i < panels.length; i++) { panels[i].classList.remove('active'); }\r\n            el = document.getElementById('pth-panel-' + name);\r\n            if (el) { el.classList.add('active'); }\r\n            for (i = 0; i < sides.length; i++) {\r\n                el = document.getElementById('pth-side-' + sides[i]);\r\n                if (el) { el.style.display = (sides[i] === name) ? 'block' : 'none'; }\r\n            }\r\n            this.currentTab = name;\r\n        },\r\n\r\n        toggleWidth: function() {\r\n            var grid = document.getElementById('pth-sa-grid');\r\n            var side = document.getElementById('pth-sa-side');\r\n            var btn = document.getElementById('pth-sa-fw');\r\n            if (!grid || !side || !btn) { return; }\r\n            if (grid.classList.contains('pth-full-width')) {\r\n                grid.classList.remove('pth-full-width');\r\n                side.style.display = 'block';\r\n                btn.innerHTML = '\\u26F6 Full Width';\r\n            } else {\r\n                grid.classList.add('pth-full-width');\r\n                side.style.display = 'none';\r\n                btn.innerHTML = '\\u25E8 Split View';\r\n            }\r\n        },\r\n\r\n        toggleScope: function(btn) {\r\n            var note = document.getElementById('pth-sa-scope-note');\r\n            if (!note) { return; }\r\n            var open = note.style.display !== 'none';\r\n            note.style.display = open ? 'none' : 'block';\r\n            btn.setAttribute('aria-expanded', open ? 'false' : 'true');\r\n            btn.innerHTML = open ? '\\u24D8 What that covers' : '\\u2715 Hide details';\r\n        },\r\n\r\n        \/* ---------- Scoring engine ---------- *\/\r\n        tokenize: function(text) {\r\n            var raw = String(text).split(\/\\s+\/);\r\n            var out = [];\r\n            var i, w, clean;\r\n            for (i = 0; i < raw.length; i++) {\r\n                w = raw[i];\r\n                if (!w) { continue; }\r\n                clean = w.replace(\/^[^\\w\\u00C0-\\u024F']+|[^\\w\\u00C0-\\u024F']+$\/g, '');\r\n                out.push({\r\n                    raw: w,\r\n                    word: clean.toLowerCase(),\r\n                    caps: clean.length > 2 && clean === clean.toUpperCase() && \/[A-Z]\/.test(clean)\r\n                });\r\n            }\r\n            return out;\r\n        },\r\n\r\n        scoreText: function(text) {\r\n            var toks = this.tokenize(text);\r\n            var total = 0;\r\n            var pos = 0, neg = 0;\r\n            var drivers = [];\r\n            var i, j, t, base, mult, negated, k, prev;\r\n\r\n            for (i = 0; i < toks.length; i++) {\r\n                t = toks[i];\r\n                base = 0;\r\n                if (this.LEX.hasOwnProperty(t.word)) { base = this.LEX[t.word]; }\r\n                if (!base) {\r\n                    for (k in this.EMOJI) {\r\n                        if (this.EMOJI.hasOwnProperty(k) && t.raw.indexOf(k) >= 0) { base += this.EMOJI[k]; }\r\n                    }\r\n                }\r\n                if (!base) { continue; }\r\n\r\n                mult = 1;\r\n                negated = false;\r\n                for (j = 1; j <= 3; j++) {\r\n                    if (i - j < 0) { break; }\r\n                    prev = toks[i - j].word;\r\n                    if (this.NEG[prev]) { negated = true; }\r\n                    if (this.INT.hasOwnProperty(prev) && j <= 2) { mult = mult * this.INT[prev]; }\r\n                }\r\n\r\n                if (t.caps) { mult = mult * 1.25; }\r\n                base = base * mult;\r\n                if (negated) { base = base * -0.74; }\r\n\r\n                total += base;\r\n                if (base > 0) { pos++; } else if (base < 0) { neg++; }\r\n                drivers.push({ word: t.word, score: base });\r\n            }\r\n\r\n            var exclam = (String(text).match(\/!\/g) || []).length;\r\n            if (exclam > 3) { exclam = 3; }\r\n            if (total !== 0 && exclam) { total = total + (total > 0 ? exclam * 0.28 : -exclam * 0.28); }\r\n\r\n            var norm = total \/ Math.sqrt(total * total + 15);\r\n            return {\r\n                score: Math.round(norm * 100),\r\n                raw: total,\r\n                posWords: pos,\r\n                negWords: neg,\r\n                drivers: drivers\r\n            };\r\n        },\r\n\r\n        \/* \"The food was fine but the service was appalling\" - what follows the\r\n           contrast word is what a reader actually takes away, so it is weighted\r\n           more heavily than the first half. *\/\r\n        scoreWithContrast: function(text) {\r\n            var m = String(text).split(\/\\b(?:but|however|although|though|whereas)\\b\/i);\r\n            if (m.length < 2) { return this.scoreText(text); }\r\n            var first = this.scoreText(m[0]);\r\n            var rest = this.scoreText(m.slice(1).join(' '));\r\n            var blended = first.raw * 0.35 + rest.raw * 0.65;\r\n            var norm = blended \/ Math.sqrt(blended * blended + 15);\r\n            return {\r\n                score: Math.round(norm * 100),\r\n                raw: blended,\r\n                posWords: first.posWords + rest.posWords,\r\n                negWords: first.negWords + rest.negWords,\r\n                drivers: first.drivers.concat(rest.drivers)\r\n            };\r\n        },\r\n\r\n        splitSentences: function(text) {\r\n            var parts = String(text).split(\/([.!?]+)\/);\r\n            var out = [];\r\n            var i, buf = '';\r\n            for (i = 0; i < parts.length; i++) {\r\n                buf += parts[i];\r\n                if (\/^[.!?]+$\/.test(parts[i])) {\r\n                    if (buf.replace(\/\\s\/g, '')) { out.push(buf.replace(\/^\\s+|\\s+$\/g, '')); }\r\n                    buf = '';\r\n                }\r\n            }\r\n            if (buf.replace(\/\\s\/g, '')) { out.push(buf.replace(\/^\\s+|\\s+$\/g, '')); }\r\n            return out.length ? out : [String(text)];\r\n        },\r\n\r\n        label: function(score) {\r\n            if (score >= 15) { return 'pos'; }\r\n            if (score <= -15) { return 'neg'; }\r\n            return 'neu';\r\n        },\r\n\r\n        labelName: function(cls) {\r\n            if (cls === 'pos') { return 'Positive'; }\r\n            if (cls === 'neg') { return 'Negative'; }\r\n            return 'Neutral';\r\n        },\r\n\r\n        emotions: function(text) {\r\n            var toks = this.tokenize(text);\r\n            var counts = { joy: 0, anger: 0, fear: 0, sadness: 0, surprise: 0 };\r\n            var i, name, list, j;\r\n            for (i = 0; i < toks.length; i++) {\r\n                for (name in this.EMO) {\r\n                    if (!this.EMO.hasOwnProperty(name)) { continue; }\r\n                    list = this.EMO[name];\r\n                    for (j = 0; j < list.length; j++) {\r\n                        if (list[j] === toks[i].word) { counts[name]++; break; }\r\n                    }\r\n                }\r\n            }\r\n            var total = 0;\r\n            for (name in counts) { if (counts.hasOwnProperty(name)) { total += counts[name]; } }\r\n            var out = [];\r\n            for (name in counts) {\r\n                if (counts.hasOwnProperty(name)) {\r\n                    out.push({ name: name, count: counts[name], pct: total ? Math.round((counts[name] \/ total) * 100) : 0 });\r\n                }\r\n            }\r\n            out.sort(function(a, b) { return b.count - a.count; });\r\n            return { list: out, total: total };\r\n        },\r\n\r\n        subjectivity: function(text) {\r\n            var toks = this.tokenize(text);\r\n            var hits = 0, i;\r\n            for (i = 0; i < toks.length; i++) {\r\n                if (this.SUBJ[toks[i].word] || this.LEX.hasOwnProperty(toks[i].word)) { hits++; }\r\n            }\r\n            if (!toks.length) { return 0; }\r\n            var pct = Math.round((hits \/ toks.length) * 260);\r\n            return pct > 100 ? 100 : pct;\r\n        },\r\n\r\n        \/* ---------- Tab 1: Analyze ---------- *\/\r\n        runAnalyze: function() {\r\n            var self = this;\r\n            var text = this.val('pth-sa-input');\r\n            if (!text || !text.replace(\/\\s\/g, '')) { this.toast('Paste some text first', true); return; }\r\n\r\n            if (this.useAi()) {\r\n                var sentences = this.splitSentences(text);\r\n                var items = [text].concat(sentences);\r\n                this.setText('pth-sa-engine-stat', 'Thinking...');\r\n                this.aiRun(this.activeModel, items, function(payload) {\r\n                    if (!payload || !payload.cls) {\r\n                        self.setText('pth-sa-engine-stat', 'Lexicon engine');\r\n                        self.toast('The model did not answer - falling back to the lexicon', true);\r\n                        self.renderAnalyze(text, self.scoreWithContrast(text), null);\r\n                        return;\r\n                    }\r\n                    var cls = self.MODELS[self.activeModel].classes;\r\n                    var overall = self.aiToScore(self.pickTop(payload.cls[0]), cls);\r\n                    var perSentence = [];\r\n                    var i;\r\n                    for (i = 1; i < payload.cls.length; i++) {\r\n                        perSentence.push(self.aiToScore(self.pickTop(payload.cls[i]), cls));\r\n                    }\r\n                    var base = self.scoreWithContrast(text);\r\n                    base.score = overall;\r\n                    self.setText('pth-sa-engine-stat', self.MODELS[self.activeModel].label + ' engine');\r\n                    self.renderAnalyze(text, base, perSentence);\r\n                });\r\n                return;\r\n            }\r\n\r\n            this.renderAnalyze(text, this.scoreWithContrast(text), null);\r\n        },\r\n\r\n        renderAnalyze: function(text, res, aiSentences) {\r\n            var sentences = this.splitSentences(text);\r\n            var emo = this.emotions(text);\r\n            var subj = this.subjectivity(text);\r\n            var cls = this.label(res.score);\r\n\r\n            var box = document.getElementById('pth-sa-verdict');\r\n            if (box) { box.className = 'pth-verdict ' + cls; }\r\n            this.setText('pth-sa-score', (res.score > 0 ? '+' : '') + res.score);\r\n            this.setText('pth-sa-label', this.labelName(cls) + ' overall');\r\n\r\n            var desc;\r\n            if (cls === 'neu') {\r\n                desc = 'Nothing here leans strongly either way. Either the text is factual, or the positives and negatives cancel out - the sentence table below will tell you which.';\r\n            } else if (Math.abs(res.score) > 60) {\r\n                desc = 'Strongly ' + this.labelName(cls).toLowerCase() + '. Several loaded words point the same way rather than one outlier carrying the score.';\r\n            } else {\r\n                desc = 'Leaning ' + this.labelName(cls).toLowerCase() + ', but not emphatically. Worth reading the sentence breakdown before drawing a conclusion.';\r\n            }\r\n            this.setText('pth-sa-desc', desc);\r\n\r\n            this.setText('pth-sa-st-sent', sentences.length);\r\n            this.setText('pth-sa-st-pos', res.posWords);\r\n            this.setText('pth-sa-st-neg', res.negWords);\r\n            this.setText('pth-sa-st-subj', subj + '%');\r\n\r\n            var emoHtml = '';\r\n            var i;\r\n            if (!emo.total) {\r\n                emoHtml = '<p style=\"font-size:13px; color:#94a3b8; margin:0;\">No clear emotional vocabulary found - the text reads as factual.<' + '\/p>';\r\n            } else {\r\n                for (i = 0; i < emo.list.length; i++) {\r\n                    emoHtml += '<div class=\"pth-bar-row\">' +\r\n                        '<span class=\"pth-bar-name\">' + this.capitalise(emo.list[i].name) + '<' + '\/span>' +\r\n                        '<span class=\"pth-bar-track\"><span class=\"pth-bar-fill ' + emo.list[i].name + '\" style=\"width:' + emo.list[i].pct + '%;\"><' + '\/span><' + '\/span>' +\r\n                        '<span class=\"pth-bar-val\">' + emo.list[i].pct + '%<' + '\/span>' +\r\n                        '<' + '\/div>';\r\n                }\r\n            }\r\n            var emoBox = document.getElementById('pth-sa-emotions');\r\n            if (emoBox) { emoBox.innerHTML = emoHtml; }\r\n\r\n            res.drivers.sort(function(a, b) { return Math.abs(b.score) - Math.abs(a.score); });\r\n            var dHtml = '';\r\n            var limit = res.drivers.length > 16 ? 16 : res.drivers.length;\r\n            for (i = 0; i < limit; i++) {\r\n                dHtml += '<span class=\"pth-chip ' + (res.drivers[i].score > 0 ? 'pos' : 'neg') + '\">' +\r\n                    this.esc(res.drivers[i].word) + ' ' + (res.drivers[i].score > 0 ? '+' : '') + (Math.round(res.drivers[i].score * 10) \/ 10) + '<' + '\/span>';\r\n            }\r\n            var dBox = document.getElementById('pth-sa-drivers');\r\n            if (dBox) { dBox.innerHTML = dHtml || '<span style=\"font-size:13px; color:#94a3b8;\">No scored words found.<' + '\/span>'; }\r\n\r\n            var rows = '';\r\n            for (i = 0; i < sentences.length; i++) {\r\n                var sScore = (aiSentences && typeof aiSentences[i] === 'number')\r\n                    ? aiSentences[i]\r\n                    : this.scoreWithContrast(sentences[i]).score;\r\n                var sres = { score: sScore };\r\n                var scls = this.label(sres.score);\r\n                rows += '<tr><td><span class=\"pth-pill ' + scls + '\">' + this.labelName(scls) + ' ' + (sres.score > 0 ? '+' : '') + sres.score + '<' + '\/span><' + '\/td>' +\r\n                    '<td>' + this.esc(sentences[i]) + '<' + '\/td><' + '\/tr>';\r\n            }\r\n            var sBox = document.getElementById('pth-sa-sentences');\r\n            if (sBox) { sBox.innerHTML = rows; }\r\n\r\n            this.toast('Analysed ' + sentences.length + ' sentences');\r\n        },\r\n\r\n        capitalise: function(s) { return s.charAt(0).toUpperCase() + s.slice(1); },\r\n\r\n        \/* ---------- Tab 2: Brand Monitor ---------- *\/\r\n        runBatch: function() {\r\n            var raw = this.val('pth-sa-batch');\r\n            if (!raw || !raw.replace(\/\\s\/g, '')) { this.toast('Paste some rows first', true); return; }\r\n\r\n            var self = this;\r\n            var lines = raw.split('\\n');\r\n            var rows = [];\r\n            var i, line, text;\r\n            for (i = 0; i < lines.length; i++) {\r\n                line = lines[i].replace(\/^\\s+|\\s+$\/g, '');\r\n                if (!line) { continue; }\r\n                text = this.firstCsvField(line);\r\n                if (!text) { continue; }\r\n                var r = this.scoreWithContrast(text);\r\n                rows.push({ n: rows.length + 1, text: text, score: r.score, cls: this.label(r.score), drivers: r.drivers });\r\n            }\r\n\r\n            this.batchRows = rows;\r\n            this.renderBatch();\r\n\r\n            if (this.useAi() && rows.length) {\r\n                var items = [];\r\n                for (i = 0; i < rows.length; i++) { items.push(rows[i].text); }\r\n                this.setText('pth-sa-engine-stat', 'Scoring ' + rows.length + ' rows...');\r\n                this.aiRun(this.activeModel, items, function(payload) {\r\n                    if (!payload || !payload.cls) {\r\n                        self.setText('pth-sa-engine-stat', 'Lexicon engine');\r\n                        self.toast('The model did not answer - lexicon scores kept', true);\r\n                        return;\r\n                    }\r\n                    var cls = self.MODELS[self.activeModel].classes;\r\n                    var j;\r\n                    for (j = 0; j < self.batchRows.length && j < payload.cls.length; j++) {\r\n                        self.batchRows[j].score = self.aiToScore(self.pickTop(payload.cls[j]), cls);\r\n                        self.batchRows[j].cls = self.label(self.batchRows[j].score);\r\n                    }\r\n                    self.setText('pth-sa-engine-stat', self.MODELS[self.activeModel].label + ' engine');\r\n                    self.renderBatch();\r\n                    self.toast('Rescored with ' + self.MODELS[self.activeModel].label);\r\n                });\r\n                return;\r\n            }\r\n\r\n            this.toast('Scored ' + rows.length + ' rows');\r\n        },\r\n\r\n        firstCsvField: function(line) {\r\n            if (line.charAt(0) === '\"') {\r\n                var end = line.indexOf('\"', 1);\r\n                while (end > 0 && line.charAt(end + 1) === '\"') { end = line.indexOf('\"', end + 2); }\r\n                if (end > 0) { return line.substring(1, end).replace(\/\"\"\/g, '\"'); }\r\n            }\r\n            if (line.indexOf(',') > 0 && line.split(',').length > 2) { return line.split(',')[0]; }\r\n            return line;\r\n        },\r\n\r\n        renderBatch: function() {\r\n            var rows = this.batchRows;\r\n            if (!rows || !rows.length) { return; }\r\n\r\n            var sort = this.val('pth-sa-sort');\r\n            var view = rows.slice(0);\r\n            if (sort === 'worst') { view.sort(function(a, b) { return a.score - b.score; }); }\r\n            else if (sort === 'best') { view.sort(function(a, b) { return b.score - a.score; }); }\r\n\r\n            var pos = 0, neg = 0, sum = 0, i;\r\n            for (i = 0; i < rows.length; i++) {\r\n                sum += rows[i].score;\r\n                if (rows[i].cls === 'pos') { pos++; } else if (rows[i].cls === 'neg') { neg++; }\r\n            }\r\n            var avg = rows.length ? sum \/ rows.length : 0;\r\n            var health = Math.round((avg + 100) \/ 2);\r\n\r\n            this.setText('pth-sa-b-total', rows.length);\r\n            this.setText('pth-sa-b-pos', pos);\r\n            this.setText('pth-sa-b-neg', neg);\r\n            this.setText('pth-sa-b-health', health);\r\n\r\n            var note = document.getElementById('pth-sa-b-note');\r\n            if (note) {\r\n                var msg;\r\n                if (rows.length < 20) {\r\n                    note.className = 'pth-note warn';\r\n                    msg = 'Only ' + rows.length + ' rows. A health figure from this few is not worth quoting - read the text instead.';\r\n                } else if (health >= 65) {\r\n                    note.className = 'pth-note ok';\r\n                    msg = 'Health ' + health + '\/100 across ' + rows.length + ' rows. Sentiment is clearly positive; the negative rows are where the useful detail sits.';\r\n                } else if (health <= 45) {\r\n                    note.className = 'pth-note warn';\r\n                    msg = 'Health ' + health + '\/100 across ' + rows.length + ' rows. Sort worst-first and read the bottom twenty before deciding anything.';\r\n                } else {\r\n                    note.className = 'pth-note info';\r\n                    msg = 'Health ' + health + '\/100 across ' + rows.length + ' rows - a mixed picture. The Aspect tab will show whether one feature is dragging it down.';\r\n                }\r\n                note.innerHTML = msg;\r\n            }\r\n\r\n            this.renderDrivers(rows, 'pos', 'pth-sa-b-praise');\r\n            this.renderDrivers(rows, 'neg', 'pth-sa-b-blame');\r\n\r\n            var html = '';\r\n            var limit = view.length > 300 ? 300 : view.length;\r\n            for (i = 0; i < limit; i++) {\r\n                html += '<tr><td>' + view[i].n + '<' + '\/td>' +\r\n                    '<td><span class=\"pth-pill ' + view[i].cls + '\">' + this.labelName(view[i].cls) + '<' + '\/span><' + '\/td>' +\r\n                    '<td style=\"font-family:Consolas,monospace;\">' + (view[i].score > 0 ? '+' : '') + view[i].score + '<' + '\/td>' +\r\n                    '<td>' + this.esc(view[i].text.length > 220 ? view[i].text.substring(0, 220) + '...' : view[i].text) + '<' + '\/td><' + '\/tr>';\r\n            }\r\n            if (view.length > limit) {\r\n                html += '<tr><td colspan=\"4\" style=\"text-align:center; background:#fffbeb; color:#92400e;\">Showing the first 300 of ' + view.length + ' rows. Export the CSV for the full set.<' + '\/td><' + '\/tr>';\r\n            }\r\n            var body = document.getElementById('pth-sa-b-rows');\r\n            if (body) { body.innerHTML = html; }\r\n        },\r\n\r\n        renderDrivers: function(rows, want, targetId) {\r\n            var counts = {};\r\n            var i, j, d;\r\n            for (i = 0; i < rows.length; i++) {\r\n                if (rows[i].cls !== want) { continue; }\r\n                for (j = 0; j < rows[i].drivers.length; j++) {\r\n                    d = rows[i].drivers[j];\r\n                    if (want === 'pos' && d.score <= 0) { continue; }\r\n                    if (want === 'neg' && d.score >= 0) { continue; }\r\n                    counts[d.word] = (counts[d.word] || 0) + 1;\r\n                }\r\n            }\r\n            var list = [];\r\n            for (i in counts) { if (counts.hasOwnProperty(i)) { list.push([i, counts[i]]); } }\r\n            list.sort(function(a, b) { return b[1] - a[1]; });\r\n\r\n            var html = '';\r\n            var limit = list.length > 14 ? 14 : list.length;\r\n            for (i = 0; i < limit; i++) {\r\n                html += '<span class=\"pth-chip ' + want + '\">' + this.esc(list[i][0]) + ' \\u00B7 ' + list[i][1] + '<' + '\/span>';\r\n            }\r\n            var box = document.getElementById(targetId);\r\n            if (box) { box.innerHTML = html || '<span style=\"font-size:13px; color:#94a3b8;\">Nothing stood out.<' + '\/span>'; }\r\n        },\r\n\r\n        exportCsv: function() {\r\n            if (!this.batchRows || !this.batchRows.length) { this.toast('Score some rows first', true); return; }\r\n            var csv = 'row,sentiment,score,text\\n';\r\n            var i, r;\r\n            for (i = 0; i < this.batchRows.length; i++) {\r\n                r = this.batchRows[i];\r\n                csv += r.n + ',' + this.labelName(r.cls) + ',' + r.score + ',\"' + r.text.replace(\/\"\/g, '\"\"') + '\"\\n';\r\n            }\r\n            try {\r\n                var blob = new Blob([csv], { type: 'text\/csv;charset=utf-8' });\r\n                var url = URL.createObjectURL(blob);\r\n                var a = document.createElement('a');\r\n                a.href = url;\r\n                a.download = 'sentiment-results.csv';\r\n                document.body.appendChild(a);\r\n                a.click();\r\n                document.body.removeChild(a);\r\n                setTimeout(function() { URL.revokeObjectURL(url); }, 1000);\r\n                this.toast('CSV saved');\r\n            } catch (err) {\r\n                this.toast('Your browser blocked the download', true);\r\n            }\r\n        },\r\n\r\n        \/* ---------- Tab 3: Aspect analysis ---------- *\/\r\n        pullAspect: function() {\r\n            var src = this.val('pth-sa-batch') || this.val('pth-sa-input');\r\n            var box = document.getElementById('pth-sa-aspect-in');\r\n            if (!src) { this.toast('Nothing to pull yet', true); return; }\r\n            if (box) { box.value = src; }\r\n            this.toast('Text pulled in');\r\n        },\r\n\r\n        runAspect: function() {\r\n            var text = this.val('pth-sa-aspect-in');\r\n            if (!text || !text.replace(\/\\s\/g, '')) { this.toast('Paste some reviews first', true); return; }\r\n\r\n            var sentences = this.splitSentences(text);\r\n            var found = {};\r\n            var i, j, clauses, clause, cres, cands, k, name;\r\n\r\n            for (i = 0; i < sentences.length; i++) {\r\n                clauses = sentences[i].split(\/\\s*(?:,|;|\\bbut\\b|\\bhowever\\b|\\balthough\\b|\\bthough\\b|\\band\\b)\\s*\/i);\r\n                for (j = 0; j < clauses.length; j++) {\r\n                    clause = clauses[j];\r\n                    if (!clause || this.countWords(clause) < 2) { continue; }\r\n                    cres = this.scoreText(clause);\r\n                    if (cres.score === 0) { continue; }\r\n                    cands = this.aspectCandidates(clause);\r\n                    for (k = 0; k < cands.length; k++) {\r\n                        name = cands[k];\r\n                        if (!found[name]) { found[name] = { name: name, n: 0, sum: 0, example: clause }; }\r\n                        found[name].n++;\r\n                        found[name].sum += cres.score;\r\n                        if (Math.abs(cres.score) > 40 && found[name].example.length > clause.length) { found[name].example = clause; }\r\n                    }\r\n                }\r\n            }\r\n\r\n            var list = [];\r\n            for (i in found) { if (found.hasOwnProperty(i) && found[i].n >= 1) { list.push(found[i]); } }\r\n            list.sort(function(a, b) { return b.n - a.n; });\r\n\r\n            var rows = '';\r\n            var limit = list.length > 40 ? 40 : list.length;\r\n            for (i = 0; i < limit; i++) {\r\n                var avg = Math.round(list[i].sum \/ list[i].n);\r\n                var cls = this.label(avg);\r\n                rows += '<tr><td style=\"font-weight:700; color:#0f172a;\">' + this.esc(list[i].name) + '<' + '\/td>' +\r\n                    '<td style=\"font-family:Consolas,monospace;\">' + list[i].n + '<' + '\/td>' +\r\n                    '<td><span class=\"pth-pill ' + cls + '\">' + this.labelName(cls) + ' ' + (avg > 0 ? '+' : '') + avg + '<' + '\/span><' + '\/td>' +\r\n                    '<td style=\"color:#475569;\">' + this.esc(list[i].example.length > 150 ? list[i].example.substring(0, 150) + '...' : list[i].example) + '<' + '\/td><' + '\/tr>';\r\n            }\r\n            if (!rows) {\r\n                rows = '<tr><td colspan=\"4\" style=\"text-align:center; color:#94a3b8; padding:26px;\">No aspects could be picked out. Short opinion sentences naming a feature work best.<' + '\/td><' + '\/tr>';\r\n            }\r\n            var body = document.getElementById('pth-sa-aspect-rows');\r\n            if (body) { body.innerHTML = rows; }\r\n            this.setText('pth-sa-aspect-stat', limit + ' aspects found');\r\n            this.toast(limit + ' aspects found');\r\n        },\r\n\r\n        aspectCandidates: function(clause) {\r\n            var toks = this.tokenize(clause);\r\n            var out = [];\r\n            var i, w, prev, next;\r\n            for (i = 0; i < toks.length; i++) {\r\n                w = toks[i].word;\r\n                if (!w || w.length < 3) { continue; }\r\n                if (this.STOP[w] || this.NEG[w] || this.INT.hasOwnProperty(w)) { continue; }\r\n                if (this.LEX.hasOwnProperty(w) || this.SUBJ[w]) { continue; }\r\n                if (\/\\d\/.test(w)) { continue; }\r\n\r\n                prev = i > 0 ? toks[i - 1].word : '';\r\n                next = i < toks.length - 1 ? toks[i + 1].word : '';\r\n\r\n                var looksLikeNoun =\r\n                    prev === 'the' || prev === 'a' || prev === 'an' || prev === 'my' || prev === 'our' ||\r\n                    prev === 'its' || prev === 'their' || prev === 'this' || prev === 'your' ||\r\n                    next === 'is' || next === 'was' || next === 'are' || next === 'were' ||\r\n                    next === 'feels' || next === 'felt' || next === 'seems' || next === 'looks';\r\n\r\n                if (looksLikeNoun) {\r\n                    out.push(w.replace(\/s$\/, '').length > 2 ? w.replace(\/s$\/, '') : w);\r\n                }\r\n            }\r\n            var seen = {};\r\n            var uniq = [];\r\n            for (i = 0; i < out.length; i++) {\r\n                if (!seen[out[i]]) { seen[out[i]] = true; uniq.push(out[i]); }\r\n            }\r\n            return uniq;\r\n        },\r\n\r\n        \/* ---------- Tab 4: Compare ---------- *\/\r\n        runCompare: function() {\r\n            var a = this.val('pth-sa-cmp-a');\r\n            var b = this.val('pth-sa-cmp-b');\r\n            if (!a.replace(\/\\s\/g, '') || !b.replace(\/\\s\/g, '')) { this.toast('Both boxes need text', true); return; }\r\n\r\n            var ra = this.scoreWithContrast(a);\r\n            var rb = this.scoreWithContrast(b);\r\n            var ea = this.emotions(a);\r\n            var eb = this.emotions(b);\r\n            var self = this;\r\n\r\n            var measures = [\r\n                ['Overall sentiment', ra.score, rb.score, true],\r\n                ['Positive words', ra.posWords, rb.posWords, false],\r\n                ['Negative words', ra.negWords, rb.negWords, false],\r\n                ['Subjectivity %', this.subjectivity(a), this.subjectivity(b), false],\r\n                ['Words', this.countWords(a), this.countWords(b), false],\r\n                ['Sentences', this.splitSentences(a).length, this.splitSentences(b).length, false]\r\n            ];\r\n\r\n            var i, name;\r\n            for (i = 0; i < ea.list.length; i++) {\r\n                name = ea.list[i].name;\r\n                var bMatch = 0, j;\r\n                for (j = 0; j < eb.list.length; j++) { if (eb.list[j].name === name) { bMatch = eb.list[j].count; } }\r\n                measures.push([this.capitalise(name) + ' words', ea.list[i].count, bMatch, false]);\r\n            }\r\n\r\n            var rows = '';\r\n            for (i = 0; i < measures.length; i++) {\r\n                var m = measures[i];\r\n                var diff = m[2] - m[1];\r\n                var arrow = diff > 0 ? '\\u25B2 +' + diff : (diff < 0 ? '\\u25BC ' + diff : '\\u2014');\r\n                var colour = '#475569';\r\n                if (m[3]) { colour = diff > 0 ? '#166534' : (diff < 0 ? '#991b1b' : '#475569'); }\r\n                rows += '<tr><td style=\"font-weight:700; color:#0f172a;\">' + m[0] + '<' + '\/td>' +\r\n                    '<td style=\"font-family:Consolas,monospace;\">' + m[1] + '<' + '\/td>' +\r\n                    '<td style=\"font-family:Consolas,monospace;\">' + m[2] + '<' + '\/td>' +\r\n                    '<td style=\"font-family:Consolas,monospace; font-weight:700; color:' + colour + ';\">' + arrow + '<' + '\/td><' + '\/tr>';\r\n            }\r\n            var body = document.getElementById('pth-sa-cmp-rows');\r\n            if (body) { body.innerHTML = rows; }\r\n\r\n            var note = document.getElementById('pth-sa-cmp-note');\r\n            if (note) {\r\n                var d = rb.score - ra.score;\r\n                var msg;\r\n                if (Math.abs(d) < 10) {\r\n                    note.className = 'pth-note info';\r\n                    msg = 'The two read about the same, ' + ra.score + ' against ' + rb.score + '. Any difference here is noise rather than signal.';\r\n                } else if (d > 0) {\r\n                    note.className = 'pth-note ok';\r\n                    msg = 'Text B reads ' + d + ' points warmer than Text A (' + ra.score + ' to ' + rb.score + ').';\r\n                } else {\r\n                    note.className = 'pth-note warn';\r\n                    msg = 'Text B reads ' + Math.abs(d) + ' points cooler than Text A (' + ra.score + ' to ' + rb.score + ').';\r\n                }\r\n                note.innerHTML = msg;\r\n            }\r\n            this.toast('Comparison ready');\r\n        },\r\n\r\n        \/* ---------- Model registry ----------\r\n           Every id below was verified against the Hugging Face hub before being\r\n           written in. A wrong id fails the download silently, so these are not\r\n           to be edited from memory. *\/\r\n        MODELS: {\r\n            fast:   { id: 'Xenova\/distilbert-base-uncased-finetuned-sst-2-english', label: 'DistilBERT', mb: 28, task: 'text-classification', classes: 2 },\r\n            social: { id: 'Xenova\/twitter-roberta-base-sentiment-latest',          label: 'Twitter RoBERTa', mb: 80, task: 'text-classification', classes: 3 },\r\n            embed:  { id: 'Xenova\/all-MiniLM-L6-v2',                                label: 'MiniLM', mb: 23, task: 'feature-extraction', classes: 0 }\r\n        },\r\n\r\n        worker: null,\r\n        device: 'wasm',\r\n        loadedIds: {},\r\n        pending: {},\r\n        reqSeq: 0,\r\n        activeModel: 'fast',\r\n        embedReady: false,\r\n\r\n        detectDevice: function() {\r\n            var self = this;\r\n            var el = document.getElementById('pth-sa-device');\r\n            if (navigator && navigator.gpu && navigator.gpu.requestAdapter) {\r\n                navigator.gpu.requestAdapter().then(function(ad) {\r\n                    self.device = ad ? 'webgpu' : 'wasm';\r\n                    if (el) { el.innerHTML = ad ? 'Device: WebGPU (fast)' : 'Device: CPU'; }\r\n                })['catch'](function() {\r\n                    self.device = 'wasm';\r\n                    if (el) { el.innerHTML = 'Device: CPU'; }\r\n                });\r\n            } else {\r\n                this.device = 'wasm';\r\n                if (el) { el.innerHTML = 'Device: CPU'; }\r\n            }\r\n        },\r\n\r\n        checkModelCache: function() {\r\n            var self = this;\r\n            var el = document.getElementById('pth-sa-cache-state');\r\n            if (!window.caches || !window.caches.keys) { return; }\r\n            window.caches.keys().then(function(names) {\r\n                var i;\r\n                for (i = 0; i < names.length; i++) {\r\n                    if (names[i].indexOf('transformers') >= 0) {\r\n                        if (el) { el.innerHTML = 'A model is cached'; }\r\n                        self.setText('pth-sa-model-state', 'Model cached');\r\n                        return;\r\n                    }\r\n                }\r\n                if (el) { el.innerHTML = 'Nothing cached yet'; }\r\n            })['catch'](function() { return; });\r\n        },\r\n\r\n        \/* One worker holds every pipeline, keyed by model id, so switching\r\n           between models does not re-download the library each time. *\/\r\n        ensureWorker: function() {\r\n            var self = this;\r\n            if (this.worker) { return true; }\r\n\r\n            var src = [\r\n                'import { pipeline, env } from \"https:\/\/cdn.jsdelivr.net\/npm\/@huggingface\/transformers@3\";',\r\n                'env.allowLocalModels = false;',\r\n                'var pipes = {};',\r\n                'function getPipe(task, id, device) {',\r\n                '  if (pipes[id]) { return Promise.resolve(pipes[id]); }',\r\n                '  return pipeline(task, id, { device: device, progress_callback: function (p) {',\r\n                '    if (p && typeof p.progress === \"number\") { self.postMessage({ type: \"progress\", id: id, value: p.progress }); }',\r\n                '  } }).then(function (pp) { pipes[id] = pp; return pp; });',\r\n                '}',\r\n                'self.onmessage = function (e) {',\r\n                '  var d = e.data || {};',\r\n                '  if (d.type === \"load\") {',\r\n                '    getPipe(d.task, d.id, d.device).then(function () {',\r\n                '      self.postMessage({ type: \"ready\", id: d.id, device: d.device });',\r\n                '    }).catch(function (err) {',\r\n                '      if (d.device === \"webgpu\") {',\r\n                '        getPipe(d.task, d.id, \"wasm\").then(function () {',\r\n                '          self.postMessage({ type: \"ready\", id: d.id, device: \"wasm\" });',\r\n                '        }).catch(function (e2) { self.postMessage({ type: \"error\", id: d.id, message: String(e2) }); });',\r\n                '      } else { self.postMessage({ type: \"error\", id: d.id, message: String(err) }); }',\r\n                '    });',\r\n                '  } else if (d.type === \"run\") {',\r\n                '    var p = pipes[d.id];',\r\n                '    if (!p) { self.postMessage({ type: \"error\", id: d.id, message: \"model not loaded\" }); return; }',\r\n                '    var opts = d.task === \"feature-extraction\" ? { pooling: \"mean\", normalize: true } : { truncation: true };',\r\n                '    p(d.items, opts).then(function (out) {',\r\n                '      var payload;',\r\n                '      if (d.task === \"feature-extraction\") {',\r\n                '        payload = { vec: Array.prototype.slice.call(out.data), dims: out.dims };',\r\n                '      } else { payload = { cls: out }; }',\r\n                '      self.postMessage({ type: \"result\", reqId: d.reqId, payload: payload });',\r\n                '    }).catch(function (err) { self.postMessage({ type: \"error\", id: d.id, message: String(err) }); });',\r\n                '  }',\r\n                '};'\r\n            ].join('\\n');\r\n\r\n            try {\r\n                var blob = new Blob([src], { type: 'application\/javascript' });\r\n                this.worker = new Worker(URL.createObjectURL(blob), { type: 'module' });\r\n            } catch (err) {\r\n                this.toast('This browser cannot start the model worker. The lexicon engine still works.', true);\r\n                return false;\r\n            }\r\n\r\n            this.worker.onmessage = function(e) { self.onWorkerMessage(e.data || {}); };\r\n            this.worker.onerror = function() { self.modelFailed('The model worker stopped unexpectedly.'); };\r\n            return true;\r\n        },\r\n\r\n        onWorkerMessage: function(d) {\r\n            if (d.type === 'progress') {\r\n                var pct = Math.round(d.value);\r\n                var isEmbed = d.id === this.MODELS.embed.id;\r\n                var fill = document.getElementById(isEmbed ? 'pth-sa-eprog-fill' : 'pth-sa-prog-fill');\r\n                if (fill) { fill.style.width = pct + '%'; }\r\n                return;\r\n            }\r\n            if (d.type === 'ready') {\r\n                this.loadedIds[d.id] = true;\r\n                if (d.device) { this.device = d.device; }\r\n                var dev = document.getElementById('pth-sa-device');\r\n                if (dev) { dev.innerHTML = d.device === 'webgpu' ? 'Device: WebGPU (fast)' : 'Device: CPU'; }\r\n                if (d.id === this.MODELS.embed.id) {\r\n                    this.embedReady = true;\r\n                    this.setText('pth-sa-cluster-state', 'Ready');\r\n                    var eb = document.getElementById('pth-sa-load-embed');\r\n                    if (eb) { eb.style.display = 'none'; }\r\n                    var ep = document.getElementById('pth-sa-eprog');\r\n                    if (ep) { ep.style.display = 'none'; }\r\n                    this.toast('Theme grouping ready');\r\n                } else {\r\n                    this.aiReady = true;\r\n                    this.aiLoading = false;\r\n                    var pr = document.getElementById('pth-sa-prog');\r\n                    if (pr) { pr.style.display = 'none'; }\r\n                    var lb = document.getElementById('pth-sa-load-model');\r\n                    if (lb) { lb.style.display = 'none'; lb.disabled = false; }\r\n                    this.setText('pth-sa-model-state', 'Model ready');\r\n                    this.setText('pth-sa-engine-stat', this.MODELS[this.activeModel].label + ' engine');\r\n                    this.toast('Model ready - it now runs offline');\r\n                }\r\n                return;\r\n            }\r\n            if (d.type === 'result') {\r\n                var cb = this.pending[d.reqId];\r\n                delete this.pending[d.reqId];\r\n                if (cb) { cb(d.payload); }\r\n                return;\r\n            }\r\n            if (d.type === 'error') {\r\n                this.modelFailed('The model could not be loaded.');\r\n            }\r\n        },\r\n\r\n        loadModel: function() {\r\n            var key = this.val('pth-sa-model') || 'fast';\r\n            this.activeModel = key;\r\n            this.startLoad(key, 'pth-sa-load-model', 'pth-sa-prog');\r\n        },\r\n\r\n        loadEmbed: function() {\r\n            this.startLoad('embed', 'pth-sa-load-embed', 'pth-sa-eprog');\r\n        },\r\n\r\n        startLoad: function(key, btnId, progId) {\r\n            var m = this.MODELS[key];\r\n            if (!m) { return; }\r\n            if (this.loadedIds[m.id]) { this.toast(m.label + ' is already loaded'); return; }\r\n            if (!this.ensureWorker()) { return; }\r\n\r\n            var btn = document.getElementById(btnId);\r\n            var prog = document.getElementById(progId);\r\n            if (btn) { btn.disabled = true; btn.innerHTML = 'Downloading ' + m.mb + ' MB...'; }\r\n            if (prog) { prog.style.display = 'block'; }\r\n            if (key === 'embed') { this.setText('pth-sa-cluster-state', 'Downloading'); }\r\n            else { this.aiLoading = true; this.setText('pth-sa-model-state', 'Downloading'); }\r\n\r\n            try {\r\n                this.worker.postMessage({ type: 'load', id: m.id, task: m.task, device: this.device });\r\n            } catch (err) {\r\n                this.modelFailed('The model could not be started.');\r\n            }\r\n        },\r\n\r\n        modelFailed: function(msg) {\r\n            var btn = document.getElementById('pth-sa-load-model');\r\n            var prog = document.getElementById('pth-sa-prog');\r\n            var eb = document.getElementById('pth-sa-load-embed');\r\n            var ep = document.getElementById('pth-sa-eprog');\r\n            this.aiLoading = false;\r\n            this.aiReady = false;\r\n            if (prog) { prog.style.display = 'none'; }\r\n            if (ep) { ep.style.display = 'none'; }\r\n            if (btn) { btn.disabled = false; btn.innerHTML = 'Try the download again'; }\r\n            if (eb) { eb.disabled = false; eb.innerHTML = '\\u2B07 Enable theme grouping (23 MB)'; }\r\n            this.setText('pth-sa-model-state', 'Not loaded');\r\n            this.setText('pth-sa-cluster-state', 'Not loaded');\r\n            this.setText('pth-sa-engine-stat', 'Lexicon engine');\r\n            this.toast(msg + ' The lexicon engine is still working.', true);\r\n        },\r\n\r\n        aiRun: function(key, items, cb) {\r\n            var m = this.MODELS[key];\r\n            if (!m || !this.worker || !this.loadedIds[m.id]) { cb(null); return; }\r\n            this.reqSeq = this.reqSeq + 1;\r\n            var id = 'r' + this.reqSeq;\r\n            this.pending[id] = cb;\r\n            try {\r\n                this.worker.postMessage({ type: 'run', id: m.id, task: m.task, items: items, reqId: id });\r\n            } catch (err) {\r\n                delete this.pending[id];\r\n                cb(null);\r\n            }\r\n        },\r\n\r\n        \/* DistilBERT returns POSITIVE\/NEGATIVE; Twitter RoBERTa returns three\r\n           classes, sometimes as LABEL_0..LABEL_2. Both are mapped here. *\/\r\n        aiToScore: function(r, classes) {\r\n            if (!r) { return 0; }\r\n            var lab = String(r.label || '').toLowerCase();\r\n            var conf = typeof r.score === 'number' ? r.score : 0.5;\r\n            if (lab.indexOf('label_') === 0) {\r\n                var n = parseInt(lab.substring(6), 10);\r\n                lab = n === 0 ? 'negative' : (n === 1 ? 'neutral' : 'positive');\r\n            }\r\n            var mag = classes === 2 ? (2 * conf - 1) : conf;\r\n            if (mag < 0) { mag = 0; }\r\n            if (lab.indexOf('pos') === 0) { return Math.round(mag * 100); }\r\n            if (lab.indexOf('neg') === 0) { return -Math.round(mag * 100); }\r\n            return 0;\r\n        },\r\n\r\n        pickTop: function(entry) {\r\n            if (!entry) { return null; }\r\n            if (Object.prototype.toString.call(entry) === '[object Array]') {\r\n                var best = null, i;\r\n                for (i = 0; i < entry.length; i++) {\r\n                    if (!best || entry[i].score > best.score) { best = entry[i]; }\r\n                }\r\n                return best;\r\n            }\r\n            return entry;\r\n        },\r\n\r\n        useAi: function() {\r\n            return this.engine === 'ai' && this.aiReady && this.worker;\r\n        },\r\n\r\n        switchEngine: function(value) {\r\n            var btn = document.getElementById('pth-sa-load-model');\r\n            var picker = document.getElementById('pth-sa-model-picker');\r\n            this.engine = value;\r\n            if (picker) { picker.style.display = value === 'ai' ? 'block' : 'none'; }\r\n\r\n            if (value === 'lex') {\r\n                this.setText('pth-sa-engine-stat', 'Lexicon engine');\r\n                this.setText('pth-sa-model-note', 'The lexicon engine scores as fast as you can type and needs nothing downloaded.');\r\n                if (btn) { btn.style.display = 'none'; }\r\n                return;\r\n            }\r\n            this.onModelPicked();\r\n        },\r\n\r\n        onModelPicked: function() {\r\n            var key = this.val('pth-sa-model') || 'fast';\r\n            var m = this.MODELS[key];\r\n            var btn = document.getElementById('pth-sa-load-model');\r\n            this.activeModel = key;\r\n            this.aiReady = !!this.loadedIds[m.id];\r\n\r\n            var note;\r\n            if (key === 'social') {\r\n                note = 'Trained on 124 million tweets, so it copes with emoji, hashtags and slang, and it has a real neutral class rather than forcing every row to one side. Best choice for social posts and short reviews.';\r\n            } else {\r\n                note = 'The quickest neural option and a sound default for ordinary review text. Two classes only, so it never reports neutral.';\r\n            }\r\n            this.setText('pth-sa-model-note', note + ' One-time download of about ' + m.mb + ' MB, then it runs offline. Your text is never uploaded.');\r\n            this.setText('pth-sa-engine-stat', this.aiReady ? m.label + ' engine' : m.label + ' (not loaded)');\r\n            if (btn) {\r\n                btn.style.display = this.aiReady ? 'none' : 'block';\r\n                btn.disabled = false;\r\n                btn.innerHTML = '\\u2B07 Download ' + m.label + ' (' + m.mb + ' MB)';\r\n            }\r\n        },\r\n\r\n        \/* ---------- Theme grouping with sentence embeddings ---------- *\/\r\n        runCluster: function() {\r\n            var self = this;\r\n            if (!this.batchRows || !this.batchRows.length) { this.toast('Score some rows first', true); return; }\r\n            if (!this.embedReady) { this.toast('Enable theme grouping in the sidebar first', true); return; }\r\n\r\n            var texts = [];\r\n            var i;\r\n            var cap = this.batchRows.length > 400 ? 400 : this.batchRows.length;\r\n            for (i = 0; i < cap; i++) { texts.push(this.batchRows[i].text); }\r\n\r\n            this.setText('pth-sa-cluster-state', 'Working');\r\n            this.aiRun('embed', texts, function(payload) {\r\n                self.setText('pth-sa-cluster-state', 'Ready');\r\n                if (!payload || !payload.vec || !payload.dims) { self.toast('Grouping failed', true); return; }\r\n                var dim = payload.dims[payload.dims.length - 1];\r\n                var vecs = [];\r\n                var j, k, row;\r\n                for (j = 0; j < cap; j++) {\r\n                    row = [];\r\n                    for (k = 0; k < dim; k++) { row.push(payload.vec[j * dim + k]); }\r\n                    vecs.push(row);\r\n                }\r\n                self.renderThemes(self.groupVectors(vecs, 0.55), cap);\r\n            });\r\n        },\r\n\r\n        groupVectors: function(vecs, threshold) {\r\n            var clusters = [];\r\n            var i, j, c, sim, best, bestSim;\r\n            for (i = 0; i < vecs.length; i++) {\r\n                best = -1; bestSim = threshold;\r\n                for (c = 0; c < clusters.length; c++) {\r\n                    sim = this.cosine(vecs[i], clusters[c].centroid);\r\n                    if (sim > bestSim) { bestSim = sim; best = c; }\r\n                }\r\n                if (best < 0) {\r\n                    clusters.push({ members: [i], centroid: vecs[i].slice(0) });\r\n                } else {\r\n                    var cl = clusters[best];\r\n                    cl.members.push(i);\r\n                    for (j = 0; j < cl.centroid.length; j++) {\r\n                        cl.centroid[j] = (cl.centroid[j] * (cl.members.length - 1) + vecs[i][j]) \/ cl.members.length;\r\n                    }\r\n                }\r\n            }\r\n            clusters.sort(function(a, b) { return b.members.length - a.members.length; });\r\n            return clusters;\r\n        },\r\n\r\n        cosine: function(a, b) {\r\n            var dot = 0, na = 0, nb = 0, i;\r\n            for (i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }\r\n            if (!na || !nb) { return 0; }\r\n            return dot \/ (Math.sqrt(na) * Math.sqrt(nb));\r\n        },\r\n\r\n        renderThemes: function(clusters, cap) {\r\n            var box = document.getElementById('pth-sa-themes');\r\n            if (!box) { return; }\r\n            var html = '';\r\n            var shown = 0;\r\n            var i, j, cl, sum, cls, samples;\r\n\r\n            for (i = 0; i < clusters.length; i++) {\r\n                cl = clusters[i];\r\n                if (cl.members.length < 2) { continue; }\r\n                shown++;\r\n                if (shown > 10) { break; }\r\n                sum = 0;\r\n                for (j = 0; j < cl.members.length; j++) { sum += this.batchRows[cl.members[j]].score; }\r\n                var avg = Math.round(sum \/ cl.members.length);\r\n                cls = this.label(avg);\r\n                samples = '';\r\n                for (j = 0; j < cl.members.length && j < 3; j++) {\r\n                    var tx = this.batchRows[cl.members[j]].text;\r\n                    samples += '<li style=\"margin-bottom:4px;\">' + this.esc(tx.length > 110 ? tx.substring(0, 110) + '...' : tx) + '<' + '\/li>';\r\n                }\r\n                html += '<div style=\"border:1px solid #e2e8f0; border-radius:8px; padding:13px; margin-bottom:10px; background:#f8fafc;\">' +\r\n                    '<div style=\"display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:7px;\">' +\r\n                    '<strong style=\"color:#0f172a; font-size:13.5px;\">Theme ' + shown + ' &middot; ' + cl.members.length + ' reviews<' + '\/strong>' +\r\n                    '<span class=\"pth-pill ' + cls + '\">' + this.labelName(cls) + ' ' + (avg > 0 ? '+' : '') + avg + '<' + '\/span><' + '\/div>' +\r\n                    '<ul style=\"margin:0; padding-left:18px; font-size:12.5px; color:#475569;\">' + samples + '<' + '\/ul><' + '\/div>';\r\n            }\r\n\r\n            if (!shown) {\r\n                html = '<p style=\"font-size:13px; color:#94a3b8; margin:0;\">No group had more than one review in it, so there is no pattern to show. Paste more rows and try again.<' + '\/p>';\r\n            } else if (cap < this.batchRows.length) {\r\n                html += '<p style=\"font-size:12.5px; color:#92400e; margin:0;\">Grouped the first ' + cap + ' of ' + this.batchRows.length + ' rows.<' + '\/p>';\r\n            }\r\n            box.innerHTML = html;\r\n            this.toast(shown + ' themes found');\r\n        },\r\n\r\n\r\n        \/* ---------- Shared ---------- *\/\r\n        loadSample: function() {\r\n            var single = 'I really wanted to like this. The screen is genuinely gorgeous and the build quality feels solid, but the battery is hopeless - it barely lasts four hours and the charger runs hot. Support were polite when I emailed, though it took them nine days to reply. Not the disaster some reviews claim, but not worth the price either.';\r\n            var batch = 'The battery life is terrible, it dies before lunch\\n' +\r\n                'Absolutely love this thing, the screen is stunning and setup was easy\\n' +\r\n                'Delivery was late and the box was damaged, though the product itself works fine\\n' +\r\n                'Customer service ignored three emails. Very disappointed.\\n' +\r\n                'Great value for the price. The camera is excellent in daylight.\\n' +\r\n                'It is fine. Does what it says. Nothing special.\\n' +\r\n                'The app keeps crashing after the latest update, incredibly frustrating\\n' +\r\n                'Beautiful design and the sound quality is superb, highly recommended\\n' +\r\n                'Screen scratched within a week. Feels cheap for the money.\\n' +\r\n                'Fast shipping, well packaged, the keyboard feels comfortable to type on';\r\n            var el;\r\n            if (this.currentTab === 'brand' || this.currentTab === 'aspect') {\r\n                el = document.getElementById(this.currentTab === 'brand' ? 'pth-sa-batch' : 'pth-sa-aspect-in');\r\n                if (el) { el.value = batch; }\r\n                if (this.currentTab === 'brand') { this.setText('pth-sa-batch-stat', this.countRows(batch) + ' rows'); this.runBatch(); }\r\n                else { this.runAspect(); }\r\n            } else if (this.currentTab === 'compare') {\r\n                el = document.getElementById('pth-sa-cmp-a');\r\n                if (el) { el.value = 'Your request has been denied. We cannot issue a refund after 14 days. This is clearly stated in the terms you agreed to, and no further action will be taken.'; }\r\n                el = document.getElementById('pth-sa-cmp-b');\r\n                if (el) { el.value = 'Thanks so much for getting in touch, and I am glad you flagged it. Refunds do close after 14 days so this one falls outside the window, but I have added a credit to your account and I would be delighted to help with anything else.'; }\r\n                this.runCompare();\r\n            } else {\r\n                el = document.getElementById('pth-sa-input');\r\n                if (el) { el.value = single; }\r\n                this.setText('pth-sa-in-stat', this.countWords(single) + ' words');\r\n                this.runAnalyze();\r\n            }\r\n            this.toast('Sample loaded');\r\n        },\r\n\r\n        clearAll: function() {\r\n            var ids = ['pth-sa-input', 'pth-sa-batch', 'pth-sa-aspect-in', 'pth-sa-cmp-a', 'pth-sa-cmp-b'];\r\n            var i, el;\r\n            for (i = 0; i < ids.length; i++) {\r\n                el = document.getElementById(ids[i]);\r\n                if (el) { el.value = ''; }\r\n            }\r\n            this.batchRows = [];\r\n            this.setText('pth-sa-in-stat', '0 words');\r\n            this.setText('pth-sa-batch-stat', '0 rows');\r\n            this.setText('pth-sa-score', '--');\r\n            this.setText('pth-sa-label', 'No text analysed yet');\r\n            this.setText('pth-sa-desc', 'Paste something above and press Analyse. The score runs from -100 to +100.');\r\n            var v = document.getElementById('pth-sa-verdict');\r\n            if (v) { v.className = 'pth-verdict neu'; }\r\n            var wipe = ['pth-sa-emotions', 'pth-sa-sentences', 'pth-sa-b-rows', 'pth-sa-aspect-rows', 'pth-sa-cmp-rows'];\r\n            for (i = 0; i < wipe.length; i++) {\r\n                el = document.getElementById(wipe[i]);\r\n                if (el) { el.innerHTML = ''; }\r\n            }\r\n            this.toast('All fields cleared');\r\n        }\r\n    };\r\n\r\n    \/* ---------- 4-layer bootstrap ---------- *\/\r\n    function pthSaRunInit() {\r\n        if (window.pthSentimentApp.initialized) { return; }\r\n        if (!document.getElementById('pth-sa-wrapper')) { return; }\r\n        window.pthSentimentApp.initialized = true;\r\n        try {\r\n            window.pthSentimentApp.init();\r\n        } catch (err) {\r\n            if (window.console && window.console.error) {\r\n                window.console.error('[Sentiment Studio] startup failed', err);\r\n            }\r\n            try { window.pthSentimentApp.bindAll(); } catch (inner) { return; }\r\n        }\r\n    }\r\n\r\n    pthSaRunInit();\r\n\r\n    if (document.readyState === 'loading') {\r\n        document.addEventListener('DOMContentLoaded', pthSaRunInit);\r\n    }\r\n\r\n    window.addEventListener('load', function() { setTimeout(pthSaRunInit, 50); });\r\n\r\n    function pthSaWatch() {\r\n        if (typeof MutationObserver === 'undefined' || !document.body) { return; }\r\n        var observer = new MutationObserver(function() {\r\n            if (document.getElementById('pth-sa-wrapper')) { pthSaRunInit(); }\r\n        });\r\n        observer.observe(document.body, { childList: true, subtree: true });\r\n        setTimeout(function() { observer.disconnect(); }, 10000);\r\n    }\r\n\r\n    if (document.body) { pthSaWatch(); } else { document.addEventListener('DOMContentLoaded', pthSaWatch); }\r\n})();\r\n<\/script>\n\n\n\n<style>\n#pth-tour-fab-wrapper { position: fixed; bottom: 30px; left: 30px; z-index: 99990; display: flex; align-items: center; gap: 14px; font-family: 'Plus Jakarta Sans', system-ui, sans-serif; animation: popIn 0.6s cubic-bezier(0.34, 1.56, 0.64, 1); }\n#pth-tour-fab { width: 54px; height: 54px; border-radius: 50%; background: linear-gradient(135deg, #1a56db, #3b82f6); color: white; border: none; box-shadow: 0 6px 20px rgba(26,86,219,0.35); cursor: pointer; display: flex; justify-content: center; align-items: center; transition: all 0.3s ease; position: relative; }\n#pth-tour-fab:hover { transform: scale(1.08) translateY(-4px); box-shadow: 0 10px 25px rgba(26,86,219,0.45); }\n#pth-tour-fab svg { width: 26px; height: 26px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }\n#pth-tour-fab-tooltip { background: #0f172a; color: #fff; padding: 10px 18px; border-radius: 30px; font-size: 13px; font-weight: 700; box-shadow: 0 6px 15px rgba(0,0,0,0.15); display: flex; align-items: center; gap: 12px; opacity: 0; transform: translateX(-15px); transition: all 0.3s ease; pointer-events: none; }\n#pth-tour-fab-wrapper:hover #pth-tour-fab-tooltip { opacity: 1; transform: translateX(0); pointer-events: auto; }\n#pth-tour-hide-btn { background: rgba(255,255,255,0.1); color: #cbd5e1; border: none; width: 22px; height: 22px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 14px; cursor: pointer; transition: 0.2s; padding: 0; margin-right: -6px; }\n#pth-tour-hide-btn:hover { background: #ef4444; color: #fff; }\n#pth-tour-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(15,23,42,0.3); z-index: 99995; display: none; opacity: 0; transition: opacity 0.3s ease; pointer-events: none; backdrop-filter: blur(1px); }\n#pth-tour-cursor { position: fixed; top: 0; left: 0; width: 34px; height: 34px; z-index: 99999; pointer-events: none; transition: transform 0.6s cubic-bezier(0.34, 1.56, 0.64, 1); display: none; filter: drop-shadow(2px 6px 8px rgba(0,0,0,0.4)); transform: translate(-100px, -100px); }\n#pth-tour-tooltip-box { position: fixed; top: 0; left: 0; background: #ffffff; border: 2px solid #1a56db; border-radius: 14px; padding: 22px; width: 310px; box-shadow: 0 15px 40px rgba(15,23,42,0.25); z-index: 99998; font-family: 'Plus Jakarta Sans', sans-serif; transition: transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s; opacity: 0; display: none; pointer-events: auto; transform: translate(-500px, -500px); }\n#pth-tour-tooltip-box::after { content: ''; position: absolute; top: -12px; left: 24px; border-width: 0 12px 12px 12px; border-style: solid; border-color: transparent transparent #1a56db transparent; }\n.pth-tour-title { font-size: 16px; color: #1a56db; font-weight: 800; margin: 0 0 10px 0; display: flex; align-items: center; gap: 8px; }\n.pth-tour-text { font-size: 13px; color: #475569; line-height: 1.6; margin: 0 0 20px 0; font-weight: 500; }\n.pth-tour-controls { display: flex; justify-content: space-between; align-items: center; border-top: 1px solid #e2e8f0; padding-top: 14px; }\n.pth-tour-step-count { font-size: 11px; color: #64748b; font-weight: 800; text-transform: uppercase; letter-spacing: 0.5px; }\n.pth-tour-btn { padding: 9px 18px; border-radius: 8px; font-size: 12px; font-weight: 700; cursor: pointer; transition: 0.2s; border: none; }\n.pth-tour-btn-next { background: #1a56db; color: #fff; }\n.pth-tour-btn-next:hover { background: #1e40af; transform: translateY(-2px); box-shadow: 0 6px 15px rgba(26,86,219,0.3); }\n.pth-tour-btn-close { background: #f1f5f9; color: #64748b; }\n.pth-tour-btn-close:hover { background: #e2e8f0; color: #0f172a; }\n.pth-tour-highlight { position: relative; z-index: 99996 !important; box-shadow: 0 0 0 4px #1a56db, 0 0 25px rgba(26,86,219,0.35) !important; border-radius: 8px; background: #ffffff !important; pointer-events: none; transition: 0.3s; }\n#pth-custom-modal-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(15,23,42,0.6); z-index: 999990; display: none; opacity: 0; transition: 0.3s; backdrop-filter: blur(2px); }\n#pth-custom-modal-box { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%) scale(0.9); background: #ffffff; padding: 26px; border-radius: 16px; box-shadow: 0 15px 50px rgba(0,0,0,0.3); z-index: 999995; display: none; opacity: 0; transition: 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: 320px; text-align: center; font-family: 'Plus Jakarta Sans', sans-serif; }\n.pth-modal-icon { width: 48px; height: 48px; background: #fee2e2; color: #ef4444; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 24px; margin: 0 auto 16px auto; }\n.pth-modal-title { font-size: 18px; font-weight: 800; color: #0f172a; margin: 0 0 10px 0; }\n.pth-modal-text { font-size: 13px; color: #475569; margin: 0 0 24px 0; line-height: 1.5; }\n.pth-modal-actions { display: flex; gap: 12px; justify-content: center; }\n.pth-modal-btn { flex: 1; padding: 10px; border-radius: 8px; font-size: 13px; font-weight: 700; cursor: pointer; border: none; transition: 0.2s; }\n.pth-modal-cancel { background: #f1f5f9; color: #475569; }\n.pth-modal-cancel:hover { background: #e2e8f0; color: #0f172a; }\n.pth-modal-yes { background: #ef4444; color: #fff; }\n.pth-modal-yes:hover { background: #dc2626; box-shadow: 0 4px 12px rgba(239,68,68,0.3); }\n@keyframes popIn { 0% { transform: scale(0); opacity: 0; } 80% { transform: scale(1.1); opacity: 1; } 100% { transform: scale(1); } }\n@keyframes pulseRing { 0% { transform: scale(1); opacity: 0.6; } 100% { transform: scale(1.5); opacity: 0; } }\n<\/style>\n\n<div id=\"pth-tour-fab-wrapper\">\n    <button id=\"pth-tour-fab\" title=\"Take a Tour\"><svg viewBox=\"0 0 24 24\"><path d=\"M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3\" \/><circle cx=\"12\" cy=\"12\" r=\"10\" \/><line x1=\"12\" y1=\"17\" x2=\"12.01\" y2=\"17\" \/><\/svg><\/button>\n    <div id=\"pth-tour-fab-tooltip\"><span>Interactive Guide<\/span><button id=\"pth-tour-hide-btn\" title=\"Hide (Resets on refresh)\">\u00d7<\/button><\/div>\n<\/div>\n<svg id=\"pth-tour-cursor\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http:\/\/www.w3.org\/2000\/svg\"><path d=\"M5.5 3.21V20.8c0 .45.54.67.85.35l4.86-4.86a.5.5 0 0 1 .35-.15h6.42c.45 0 .67-.54.35-.85L6.35 3.21a.5.5 0 0 0-.85 0z\" fill=\"#0f172a\" stroke=\"#ffffff\" stroke-width=\"1.5\"\/><\/svg>\n<div id=\"pth-tour-overlay\"><\/div>\n<div id=\"pth-tour-tooltip-box\"><h4 class=\"pth-tour-title\" id=\"pth-tour-title\">Title<\/h4><p class=\"pth-tour-text\" id=\"pth-tour-msg\">Message<\/p><div class=\"pth-tour-controls\"><span class=\"pth-tour-step-count\" id=\"pth-tour-counter\">Step 1 \/ X<\/span><div style=\"display:flex; gap:8px;\"><button class=\"pth-tour-btn pth-tour-btn-close\" id=\"pth-tour-box-close\">Exit<\/button><button class=\"pth-tour-btn pth-tour-btn-next\" id=\"pth-tour-box-next\">Next \u2794<\/button><\/div><\/div><\/div>\n<div id=\"pth-custom-modal-overlay\"><\/div>\n<div id=\"pth-custom-modal-box\"><div class=\"pth-modal-icon\">\u26a0\ufe0f<\/div><h3 class=\"pth-modal-title\">Hide Guide?<\/h3><p class=\"pth-modal-text\">Are you sure you want to hide the Interactive Guide?<br><br><strong>Note:<\/strong> It will reappear if you refresh the page.<\/p><div class=\"pth-modal-actions\"><button class=\"pth-modal-btn pth-modal-cancel\" id=\"pth-modal-btn-cancel\">Cancel<\/button><button class=\"pth-modal-btn pth-modal-yes\" id=\"pth-modal-btn-yes\">Yes, Hide<\/button><\/div><\/div>\n\n<script data-no-optimize=\"1\" data-no-minify=\"1\" data-cfasync=\"false\">\ndocument.addEventListener(\"DOMContentLoaded\", function() {\n    \n    \/\/ --> AI INSTRUCTION: GENERATE THE STEPS ARRAY BASED ON THE UPLOADED JSON FILE <--\n    const steps = [\n        { target: '#pth-sa-input', title: '\ud83d\udcdd 1. Add Your Data', text: 'Paste your customer reviews, tweets, or feedback here. Make sure to put one item per line, or upload a .txt\/.csv file.' },\n        { target: '.pth-input-toolbar', title: '\ud83e\uddf9 2. Data Preparation', text: 'Use these tools to instantly remove duplicates, or automatically translate foreign text into English for accurate AI processing.' },\n        { target: '#pth-sa-model', title: '\ud83e\udde0 3. Select AI Engine', text: 'Choose your brain! Use DistilBERT for fast, general analysis, or RoBERTa for parsing social media slang and emojis.' },\n        { target: '#pth-sa-analyze-btn', title: '\ud83d\ude80 4. Start Analysis', text: 'Click here to let the AI process your data. Everything runs securely right inside your browser without uploading to the cloud!' },\n        { target: '.pth-stats-grid', title: '\ud83d\udcca 5. The Dashboard', text: 'Once analyzed, get an instant high-level breakdown of your Positive, Negative, and Neutral sentiments.' },\n        { target: '.pth-dash-row', title: '\ud83d\udca1 6. Actionable Insights', text: 'Discover your overall Brand Health Score, and see exactly what keywords are driving positive and negative feedback.' },\n        { target: '.pth-filter-bar', title: '\ud83d\udd0d 7. Filter &#038; Deep Dive', text: 'Need to investigate? Filter your results to only see Negative reviews, or search for a specific product name.' },\n        { target: '#pth-sa-export-btn', title: '\ud83d\udce5 8. Export Report', text: 'When you are done, download a clean CSV report of all your analyzed data to share with your team or clients.' }\n    ];\n\n    let currentStep = 0; let activeElement = null;\n    const els = {\n        fabWrapper: document.getElementById('pth-tour-fab-wrapper'),\n        fab: document.getElementById('pth-tour-fab'),\n        hideBtn: document.getElementById('pth-tour-hide-btn'),\n        cursor: document.getElementById('pth-tour-cursor'),\n        tooltipBox: document.getElementById('pth-tour-tooltip-box'),\n        overlay: document.getElementById('pth-tour-overlay'),\n        title: document.getElementById('pth-tour-title'),\n        msg: document.getElementById('pth-tour-msg'),\n        counter: document.getElementById('pth-tour-counter'),\n        btnNext: document.getElementById('pth-tour-box-next'),\n        btnClose: document.getElementById('pth-tour-box-close'),\n        modalOverlay: document.getElementById('pth-custom-modal-overlay'),\n        modalBox: document.getElementById('pth-custom-modal-box'),\n        modalCancel: document.getElementById('pth-modal-btn-cancel'),\n        modalYes: document.getElementById('pth-modal-btn-yes')\n    };\n\n    if(els.fab) els.fab.addEventListener('click', startTour);\n    if(els.btnNext) els.btnNext.addEventListener('click', nextStep);\n    if(els.btnClose) els.btnClose.addEventListener('click', endTour);\n    if(els.hideBtn) els.hideBtn.addEventListener('click', showConfirmModal);\n    if(els.modalCancel) els.modalCancel.addEventListener('click', closeConfirmModal);\n    if(els.modalYes) els.modalYes.addEventListener('click', confirmHide);\n\n    function showConfirmModal() {\n        els.modalOverlay.style.display = 'block'; els.modalBox.style.display = 'block';\n        setTimeout(() => { els.modalOverlay.style.opacity = '1'; els.modalBox.style.opacity = '1'; els.modalBox.style.transform = 'translate(-50%, -50%) scale(1)'; }, 10);\n    }\n    function closeConfirmModal() {\n        els.modalOverlay.style.opacity = '0'; els.modalBox.style.opacity = '0'; els.modalBox.style.transform = 'translate(-50%, -50%) scale(0.9)';\n        setTimeout(() => { els.modalOverlay.style.display = 'none'; els.modalBox.style.display = 'none'; }, 300);\n    }\n    function confirmHide() {\n        closeConfirmModal();\n        if(els.fabWrapper) { els.fabWrapper.style.transform = 'scale(0)'; setTimeout(() => { els.fabWrapper.style.display = 'none'; }, 300); }\n    }\n    function startTour() {\n        currentStep = 0; els.overlay.style.display = 'block'; els.cursor.style.display = 'block'; els.tooltipBox.style.display = 'block';\n        setTimeout(() => { els.overlay.style.opacity = '1'; els.tooltipBox.style.opacity = '1'; showStep(); }, 50);\n    }\n    function showStep() {\n        if(activeElement) activeElement.classList.remove('pth-tour-highlight');\n        if(currentStep >= steps.length) { endTour(); return; }\n        const step = steps[currentStep]; const targetEl = document.querySelector(step.target);\n        if(!targetEl) { currentStep++; showStep(); return; }\n        \n        targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' });\n        setTimeout(() => {\n            const rect = targetEl.getBoundingClientRect();\n            activeElement = targetEl; activeElement.classList.add('pth-tour-highlight');\n            \n            const mouseX = rect.left + (rect.width \/ 2); const mouseY = rect.top + (rect.height \/ 2);\n            els.cursor.style.transform = `translate(${mouseX}px, ${mouseY}px)`;\n            \n            let tooltipX = rect.left; let tooltipY = rect.bottom + 15;\n            if (tooltipX + 320 > window.innerWidth) { tooltipX = window.innerWidth - 340; }\n            if (tooltipX < 10) { tooltipX = 10; } \n            if (tooltipY + 220 > window.innerHeight) { tooltipY = rect.top - 220; }\n            if (tooltipY < 10) { tooltipY = 10; } \n            els.tooltipBox.style.transform = `translate(${tooltipX}px, ${tooltipY}px)`;\n            \n            els.title.innerHTML = step.title; els.msg.innerHTML = step.text;\n            els.counter.textContent = `Step ${currentStep + 1} \/ ${steps.length}`;\n            els.btnNext.textContent = (currentStep === steps.length - 1) ? 'Finish \u2714' : 'Next \u2794';\n        }, 400);\n    }\n    function nextStep() { currentStep++; showStep(); }\n    function endTour() {\n        if(activeElement) activeElement.classList.remove('pth-tour-highlight');\n        els.overlay.style.opacity = '0'; els.tooltipBox.style.opacity = '0'; els.cursor.style.transform = `translate(-100px, -100px)`; \n        setTimeout(() => { els.overlay.style.display = 'none'; els.tooltipBox.style.display = 'none'; els.cursor.style.display = 'none'; }, 300);\n    }\n});\n<\/script>\n\n\n\n<style>\n\/* PTH Sentiment Analyzer \u2014 Body Content Styles *\/\n.pth-sa-section { margin-bottom: 30px; }\n.pth-sa-h2 { font-size: 1.4rem; font-weight: 800; color: #0f172a; margin: 0 0 14px; display: flex; align-items: center; gap: 10px; line-height: 1.3; }\n.pth-sa-p { font-size: 0.95rem; color: #475569; line-height: 1.7; margin: 0 0 14px; }\n.pth-sa-p strong { color: #0f172a; font-weight: 700; }\n\n\/* Input \u2192 Output example box *\/\n.pth-sa-example { border: 1px solid #e2e8f0; background: #f8fafc; border-radius: 14px; padding: 26px; margin-bottom: 30px; }\n.pth-sa-example-label { font-size: 0.75rem; font-weight: 800; letter-spacing: 1px; text-transform: uppercase; color: #64748b; margin-bottom: 14px; }\n.pth-sa-review { background: #ffffff; border: 1px solid #e2e8f0; border-left: 4px solid #3b82f6; border-radius: 8px; padding: 12px 16px; margin-bottom: 12px; font-size: 0.9rem; color: #334155; font-weight: 500; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }\n.pth-sa-badge { display: inline-block; padding: 3px 12px; border-radius: 999px; font-size: 0.72rem; font-weight: 700; white-space: nowrap; }\n.pth-sa-neg { background: #fee2e2; color: #b91c1c; }\n.pth-sa-pos { background: #dcfce7; color: #15803d; }\n.pth-sa-neu { background: #f1f5f9; color: #475569; }\n\n\/* Two-card model comparison + use-case grid *\/\n.pth-sa-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; margin-bottom: 30px; }\n.pth-sa-card { border: 1px solid #e2e8f0; background: #ffffff; border-radius: 12px; padding: 24px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.02); transition: all 0.3s ease; }\n.pth-sa-card:hover { border-color: #bfdbfe; transform: translateY(-3px); box-shadow: 0 10px 15px -3px rgba(0,0,0,0.05); }\n.pth-sa-card-title { font-size: 1.1rem; font-weight: 800; color: #0f172a; margin: 0 0 10px; display: flex; align-items: center; gap: 8px; }\n.pth-sa-card-text { font-size: 0.9rem; color: #475569; line-height: 1.6; margin: 0; }\n\n\/* Honest limits panel *\/\n.pth-sa-limits { border: 1px solid #fed7aa; background: #fff7ed; border-radius: 14px; padding: 26px; margin-bottom: 30px; }\n.pth-sa-limits .pth-sa-h2 { color: #9a3412; }\n.pth-sa-limits ul { margin: 0; padding-left: 22px; }\n.pth-sa-limits li { font-size: 0.92rem; color: #7c2d12; line-height: 1.7; margin-bottom: 10px; }\n.pth-sa-limits li strong { color: #7c2d12; }\n\n\/* Related links row *\/\n.pth-sa-links { font-size: 0.92rem; color: #475569; line-height: 1.7; border-top: 1px solid #e2e8f0; padding-top: 20px; }\n.pth-sa-links a { color: #2563eb; font-weight: 700; text-decoration: none; }\n.pth-sa-links a:hover { text-decoration: underline; }\n<\/style>\n\n<div class=\"pth-sa-section\">\n  <h2 class=\"pth-sa-h2\">\ud83d\udfe2 Why the whole thing stays on your machine<\/h2>\n  <p class=\"pth-sa-p\">A shop owner messaged me last year asking how to check the mood of about 400 Facebook reviews without pasting real customer names into some website he&#8217;d never heard of. That question is the reason this tool works the way it does. The model file downloads to your browser one time, and after that every line you type or upload is scored right there on your device. Close the tab and there is nothing sitting on a server somewhere \u2014 the reviews were never uploaded in the first place.<\/p>\n<\/div>\n\n<div class=\"pth-sa-example\">\n  <div class=\"pth-sa-example-label\">Paste this \u2192 get this back<\/div>\n  <div class=\"pth-sa-review\"><span>&#8220;The delivery was two days late but the phone itself is fantastic&#8221;<\/span><span class=\"pth-sa-badge pth-sa-neg\">Negative 0.71<\/span><\/div>\n  <div class=\"pth-sa-review\"><span>&#8220;Worst support I have ever dealt with, never buying again&#8221;<\/span><span class=\"pth-sa-badge pth-sa-neg\">Negative 0.98<\/span><\/div>\n  <div class=\"pth-sa-review\"><span>&#8220;Does exactly what it says, no complaints here&#8221;<\/span><span class=\"pth-sa-badge pth-sa-pos\">Positive 0.95<\/span><\/div>\n  <p class=\"pth-sa-p\" style=\"margin-top:16px;\">With DistilBERT (a two-way positive\/negative model) the first line lands as negative \u2014 the &#8220;two days late&#8221; part outweighs the praise for a binary model. Switch to Twitter RoBERTa and that same line often shifts to <strong>neutral<\/strong> instead, because it has a middle class for mixed feelings. Same three reviews, two different readings \u2014 which is exactly why both models are here.<\/p>\n<\/div>\n\n<div class=\"pth-sa-section\">\n  <h2 class=\"pth-sa-h2\">\ud83d\udd34 DistilBERT or Twitter RoBERTa \u2014 how I choose<\/h2>\n<\/div>\n<div class=\"pth-sa-grid\">\n  <div class=\"pth-sa-card\">\n    <div class=\"pth-sa-card-title\">\u26a1 DistilBERT<\/div>\n    <p class=\"pth-sa-card-text\">Best for clean, full-sentence feedback \u2014 survey answers, email replies, product reviews written in proper sentences. It is smaller, downloads faster, and stays quick even on an older phone. The trade-off is that it only chooses positive or negative, with no neutral option.<\/p>\n  <\/div>\n  <div class=\"pth-sa-card\">\n    <div class=\"pth-sa-card-title\">\ud83d\udc26 Twitter RoBERTa<\/div>\n    <p class=\"pth-sa-card-text\">Built for short, messy text \u2014 tweets, comments and brand mentions full of slang, emojis and @handles. It reads that kind of writing far better and adds a neutral bucket, so mixed lines are not forced into one extreme. It is a heavier download and a little slower on low-end devices.<\/p>\n  <\/div>\n<\/div>\n\n<div class=\"pth-sa-section\">\n  <h2 class=\"pth-sa-h2\">\ud83d\udfe1 Where it genuinely earns its keep<\/h2>\n<\/div>\n<div class=\"pth-sa-grid\">\n  <div class=\"pth-sa-card\">\n    <div class=\"pth-sa-card-title\">\ud83d\udd35 Small business owners<\/div>\n    <p class=\"pth-sa-card-text\">Drop a month of Google reviews in to see whether sentiment dipped after a price change or a new supplier.<\/p>\n  <\/div>\n  <div class=\"pth-sa-card\">\n    <div class=\"pth-sa-card-title\">\ud83d\udfe0 Students<\/div>\n    <p class=\"pth-sa-card-text\">Get a labelled sentiment column for a marketing assignment without touching Python or a paid API.<\/p>\n  <\/div>\n  <div class=\"pth-sa-card\">\n    <div class=\"pth-sa-card-title\">\ud83d\udfe3 Support teams<\/div>\n    <p class=\"pth-sa-card-text\">Sort a ticket export so the angriest messages float to the top and get answered first.<\/p>\n  <\/div>\n<\/div>\n<br>\n<div class=\"pth-sa-limits\">\n  <h2 class=\"pth-sa-h2\">\ud83d\udd34 What it can&#8217;t do \u2014 the honest limits<\/h2>\n  <ul>\n    <li><strong>Sarcasm still beats it.<\/strong> A line like &#8220;great, another cracked screen&#8221; often scores positive because the model reads the word &#8220;great,&#8221; not your tone.<\/li>\n    <li><strong>It scores feeling, not truth.<\/strong> A fake glowing review still reads as positive \u2014 the model has no way to know the claim is false.<\/li>\n    <li><strong>Long paragraphs get cut off<\/strong> at roughly 512 tokens, so break up long rants into shorter lines for a fair reading.<\/li>\n    <li><strong>Translation can bend the result.<\/strong> The built-in translator is handy for foreign-language feedback, but idioms sometimes flip the sentiment, so treat translated rows as a guide.<\/li>\n    <li><strong>Keywords rank by frequency.<\/strong> After stop words are removed, a common product name usually tops the list \u2014 that is expected, not a bug.<\/li>\n  <\/ul>\n<\/div>\n\n<p class=\"pth-sa-links\">Want the background on how these transformer models decide a piece of text is positive or negative? The <a href=\"https:\/\/huggingface.co\/cardiffnlp\/twitter-roberta-base-sentiment-latest\" rel=\"noopener\" target=\"_blank\">Twitter RoBERTa model card on Hugging Face<\/a> lays out exactly what it was trained on. And once you have your labelled CSV, our <a href=\"https:\/\/schoolict.net\/tools\/free-word-counter-and-text-case-converter\/\" rel=\"noopener\">word and text counter <\/a>helps you check review length before feeding a long batch in.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Last Updated: August 2026<\/strong><\/p>\n\n\n\n<h2 id=\"\ud83d\udd34-what-each-tab-is-for\" class=\"wp-block-heading\">\ud83d\udd34 What Each Tab Is For<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Most sentiment tools give you one number and leave you there. A number is fine when you have three reviews. With three hundred it tells you almost nothing, because the useful question is never &#8220;are people happy&#8221; \u2014 it is &#8220;which part are they unhappy about, and how badly&#8221;. That is why this page has four tabs rather than one box.<\/p>\n\n\n\n<h3 id=\"analyze-one-passage-in-detail\" class=\"wp-block-heading\">Analyze \u2014 one passage, in detail<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Paste a review or an email and you get a score from -100 to +100, a sentence-by-sentence breakdown, an emotional profile, a subjectivity reading, and the actual words that moved the number.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That last part matters more than it sounds. A score with no working shown is a black box you either trust or ignore. Seeing that&nbsp;<code>hopeless -2.4<\/code>&nbsp;and&nbsp;<code>gorgeous +3.0<\/code>&nbsp;did most of the work lets you judge whether the tool understood the passage or just tripped over one loud adjective.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Three behaviours are worth testing yourself, because they are where simpler tools fall over. Type&nbsp;<code>it is not good<\/code>&nbsp;and you get&nbsp;<strong>-34<\/strong>, not a positive. Type&nbsp;<code>it is not bad<\/code>&nbsp;and you get&nbsp;<strong>+43<\/strong>. And this sentence:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>The food was excellent but the service was appalling<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">scores&nbsp;<strong>-23<\/strong>&nbsp;rather than averaging out to zero, because what follows a contrast word is what a reader actually walks away with. Reverse the two halves and the verdict flips positive. That single rule fixes a lot of otherwise confusing results.<\/p>\n\n\n\n<h3 id=\"brand-monitor-hundreds-of-rows-at-once\" class=\"wp-block-heading\">Brand Monitor \u2014 hundreds of rows at once<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Paste one review per line and every row is scored separately, then rolled into a health figure out of 100. Sort worst-first and the twenty rows worth reading properly float to the top. Export the whole thing as CSV when you need it in a spreadsheet.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The praise and complaint chips underneath show which words appear most often in the positive rows and the negative rows. Treat them as a reading list, not a summary.<\/p>\n\n\n\n<h3 id=\"aspect-analysis-the-tab-that-earns-its-place\" class=\"wp-block-heading\">Aspect Analysis \u2014 the tab that earns its place<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Overall scores average away the thing you needed to know. A product with a beautiful screen and a hopeless battery lands somewhere near neutral, which reads as &#8220;nothing to see here&#8221; while your customers are telling you exactly what to fix.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This tab splits each sentence into clauses, scores each clause separately, and attaches the verdict to whatever the clause was talking about. Feed it the sample review set and you get back a table where&nbsp;<strong>battery<\/strong>&nbsp;sits at negative,&nbsp;<strong>screen<\/strong>&nbsp;sits at positive, and&nbsp;<strong>delivery<\/strong>&nbsp;has its own separate verdict. That is a list you can hand to someone.<\/p>\n\n\n\n<h3 id=\"compare-a-against-b\" class=\"wp-block-heading\">Compare \u2014 A against B<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Two boxes, side-by-side metrics, and the difference on every row. Useful for checking whether a rewritten complaint reply actually landed softer, or whether this quarter&#8217;s feedback moved against last quarter&#8217;s. The built-in sample shows a blunt refusal against a warmer one \u2014 same decision, 55 points apart.<\/p>\n\n\n\n<h2 id=\"\ud83d\udfe1-two-engines-and-when-to-switch\" class=\"wp-block-heading\">\ud83d\udfe1 Two Engines, and When to Switch<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The default engine is a lexicon. Every word carries a weight, and the words around it change that weight: negation flips it, intensifiers scale it, capital letters and exclamation marks push it further out. It is ready the instant the page loads, needs nothing downloaded, and is fast enough to score a thousand rows without you noticing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The optional neural engine reads whole sentences rather than counting words, which makes it better at unusual phrasing and mild sarcasm. It is a one-time download of roughly 30 MB, stored by your browser, after which it runs with the network switched off. It runs in a&nbsp;<a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/Web_Workers_API\" rel=\"noreferrer noopener\" target=\"_blank\">Web Worker<\/a>&nbsp;so the page stays responsive while it thinks.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Worth being precise about what that download is: your browser fetches a model file. It does not send your text anywhere. Analysis happens on your machine either way \u2014 the difference is only whether the maths is a weighted word list or a neural network.<\/p>\n\n\n\n<h2 id=\"\ud83d\udfe2-where-this-will-let-you-down\" class=\"wp-block-heading\">\ud83d\udfe2 Where This Will Let You Down<\/h2>\n\n\n\n<h3 id=\"sarcasm-is-still-unsolved\" class=\"wp-block-heading\">Sarcasm is still unsolved<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">&#8220;Great, another update that breaks everything&#8221; scores positive on the lexicon engine and often on the neural one too. Sarcasm depends on knowing what the speaker actually believes, which is not recoverable from the words alone. Any tool claiming to handle it reliably is overselling. Read your outliers.<\/p>\n\n\n\n<h3 id=\"short-text-swings-wildly\" class=\"wp-block-heading\">Short text swings wildly<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A three-word review gives one adjective total control of the score. The number is technically correct and practically useless. Below roughly fifteen words, treat the result as a hint.<\/p>\n\n\n\n<h3 id=\"small-samples-produce-confident-nonsense\" class=\"wp-block-heading\">Small samples produce confident nonsense<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The Brand Monitor tab warns you below twenty rows, and it means it. A health score of 71 from twelve reviews is not a measurement, it is an accident. Fifty rows is a sensible floor before the number is worth repeating to anyone.<\/p>\n\n\n\n<h3 id=\"aspect-extraction-is-grammar-not-understanding\" class=\"wp-block-heading\">Aspect extraction is grammar, not understanding<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Aspects are found by pattern \u2014 a candidate noun sitting next to an opinion, near words like&nbsp;<code>the<\/code>,&nbsp;<code>my<\/code>&nbsp;or&nbsp;<code>is<\/code>. It works well on short specific reviews and poorly on rambling ones. Occasionally it will surface a word that is not really a product feature at all. That is why every row carries an example: skim it before you act on it.<\/p>\n\n\n\n<h3 id=\"domain-and-language-limits\" class=\"wp-block-heading\">Domain and language limits<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The lexicon is tuned for English consumer feedback. In a medical or legal context the same words carry different weight, and slang inverts things freely \u2014 &#8220;sick&#8221; and &#8220;wicked&#8221; are compliments in some registers and complaints in others. There is also no translation built in, deliberately:&nbsp;<a href=\"https:\/\/schoolict.net\/tools\/voice-typing-studio-free\/\">Voice Typing Studio<\/a>&nbsp;handles that on-device, and translating before analysing is the cleaner order anyway.<\/p>\n\n\n\n<h2 id=\"\ud83d\udd34-the-reasoning-underneath\" class=\"wp-block-heading\">\ud83d\udd34 The Reasoning Underneath<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">If you want to know why negation is genuinely hard, why word order breaks a bag-of-words model, and how neural sentiment differs from a weighted list, the companion piece covers it:&nbsp;<a href=\"https:\/\/schoolict.net\/tools\/how-product-tours-work\/\" target=\"_blank\" rel=\"noreferrer noopener\">how sentiment analysis actually works<\/a>. The&nbsp;<a href=\"https:\/\/en.wikipedia.org\/wiki\/Sentiment_analysis\" target=\"_blank\" rel=\"noreferrer noopener\">Wikipedia entry on sentiment analysis<\/a>&nbsp;is a solid overview of the wider field.<\/p>\n\n\n\n<style>\n.pth-faq-wrap { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin: 24px 0; }\n.pth-faq-card { background: #ffffff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 20px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05); }\n.pth-faq-q { font-size: 15px; font-weight: 800; color: #0f172a; margin: 0 0 8px 0; line-height: 1.45; }\n.pth-faq-a { font-size: 14px; color: #475569; margin: 0; line-height: 1.65; font-weight: 500; }\n@media (max-width: 992px) { .pth-faq-wrap { grid-template-columns: repeat(2, 1fr); } }\n@media (max-width: 640px) { .pth-faq-wrap { grid-template-columns: 1fr; } }\n<\/style>\n \n<h2>\u2753 Frequently Asked Questions<\/h2>\n \n<div class=\"pth-faq-wrap\">\n \n  <div class=\"pth-faq-card\">\n    <p class=\"pth-faq-q\">Is my text uploaded anywhere?<\/p>\n    <p class=\"pth-faq-a\">No. Scoring runs in your browser. Open DevTools, watch the Network tab and press Analyse \u2014 there is no request carrying what you pasted, because none is made.<\/p>\n  <\/div>\n \n  <div class=\"pth-faq-card\">\n    <p class=\"pth-faq-q\">What does the score actually mean?<\/p>\n    <p class=\"pth-faq-a\">It runs from -100 to +100. Anything between -15 and +15 counts as neutral. The size matters as much as the sign: +20 is a mild lean, +80 is emphatic.<\/p>\n  <\/div>\n \n  <div class=\"pth-faq-card\">\n    <p class=\"pth-faq-q\">Does it understand &#8220;not good&#8221;?<\/p>\n    <p class=\"pth-faq-a\">Yes. Negation is checked within three words and flips the value while dampening it, so &#8220;not good&#8221; reads negative and &#8220;not bad&#8221; reads mildly positive.<\/p>\n  <\/div>\n \n  <div class=\"pth-faq-card\">\n    <p class=\"pth-faq-q\">Do I have to download the AI model?<\/p>\n    <p class=\"pth-faq-a\">No. The lexicon engine is the default and needs nothing. The neural model is an optional upgrade for nuance, and the tool works fully without it.<\/p>\n  <\/div>\n \n  <div class=\"pth-faq-card\">\n    <p class=\"pth-faq-q\">How many reviews before the health score means anything?<\/p>\n    <p class=\"pth-faq-a\">Fifty is a reasonable floor. Below twenty the tool says so on screen, because a figure from a handful of rows is noise dressed up as a measurement.<\/p>\n  <\/div>\n \n  <div class=\"pth-faq-card\">\n    <p class=\"pth-faq-q\">Can it handle sarcasm?<\/p>\n    <p class=\"pth-faq-a\">Not reliably, and neither can anything else. Sarcasm depends on knowing what the writer really thinks, which the words alone do not carry. Read your outliers.<\/p>\n  <\/div>\n \n  <div class=\"pth-faq-card\">\n    <p class=\"pth-faq-q\">What counts as an aspect?<\/p>\n    <p class=\"pth-faq-a\">A noun that an opinion is attached to within the same clause \u2014 battery, screen, delivery, staff. Each row shows an example mention so you can sanity-check it.<\/p>\n  <\/div>\n \n  <div class=\"pth-faq-card\">\n    <p class=\"pth-faq-q\">Does it work in other languages?<\/p>\n    <p class=\"pth-faq-a\">The lexicon is tuned for English. Other languages will produce a score, but not a trustworthy one. Translate first, then analyse, if the text is not English.<\/p>\n  <\/div>\n \n  <div class=\"pth-faq-card\">\n    <p class=\"pth-faq-q\">Can I get the results out?<\/p>\n    <p class=\"pth-faq-a\">Yes. The Brand Monitor tab exports every scored row as CSV with the row number, verdict, score and original text, ready for a spreadsheet.<\/p>\n  <\/div>\n \n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>Sentiment Analyzer &#038; Brand Monitor Instantly analyze customer reviews, tweets, and comments with our free AI Sentiment Analyzer. 100% offline, privacy-first brand monitoring tool featuring live dashboard, pie chart, word clouds, filters, key insights, and CSV\/export. Perfect for e-commerce marketers in the US, UK, CA, and AU. Interactive Guide\u00d7 Title Message Step 1 \/ X &#8230; <a title=\"Free AI Sentiment Analyzer &amp; Brand Monitor Offline\" class=\"read-more\" href=\"https:\/\/schoolict.net\/tools\/ai-sentiment-analyzer\/\" aria-label=\"Read more about Free AI Sentiment Analyzer &amp; Brand Monitor Offline\">Read more<\/a><\/p>\n","protected":false},"author":1,"featured_media":2939,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"","meta":{"footnotes":""},"class_list":["post-2934","page","type-page","status-publish","has-post-thumbnail"],"_links":{"self":[{"href":"https:\/\/schoolict.net\/tools\/wp-json\/wp\/v2\/pages\/2934","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/schoolict.net\/tools\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/schoolict.net\/tools\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/schoolict.net\/tools\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/schoolict.net\/tools\/wp-json\/wp\/v2\/comments?post=2934"}],"version-history":[{"count":7,"href":"https:\/\/schoolict.net\/tools\/wp-json\/wp\/v2\/pages\/2934\/revisions"}],"predecessor-version":[{"id":6888,"href":"https:\/\/schoolict.net\/tools\/wp-json\/wp\/v2\/pages\/2934\/revisions\/6888"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/schoolict.net\/tools\/wp-json\/wp\/v2\/media\/2939"}],"wp:attachment":[{"href":"https:\/\/schoolict.net\/tools\/wp-json\/wp\/v2\/media?parent=2934"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}