BonusLockSMith commited on
Commit
720403e
·
verified ·
1 Parent(s): 7bcb7b5

security: log only exception type (never secrets); generic client errors

Browse files
Files changed (1) hide show
  1. app.py +388 -388
app.py CHANGED
@@ -1,388 +1,388 @@
1
- #!/usr/bin/env python
2
- """Feedback Analyzer — paste or upload customer feedback in bulk and get, per item:
3
- sentiment + intent + confidence + a reason + the evidence phrase — plus a rollup
4
- (sentiment/intent mix + top themes) and a downloadable labeled CSV.
5
-
6
- Reliability: ONE Claude call with forced tool-use returns the whole batch as
7
- schema-valid JSON (Ollama fallback uses constrained JSON decoding).
8
-
9
- pip install flask requests anthropic
10
- python app.py # http://127.0.0.1:8500
11
-
12
- Project #5 of the "30 Projects in 15 Days" challenge — GritAI.
13
- """
14
- import os, io, csv, json
15
- from flask import Flask, request, jsonify, Response
16
-
17
- PORT = int(os.environ.get("PORT", "8500"))
18
- ANTHROPIC_KEY = os.environ.get("ANTHROPIC_API_KEY")
19
- ANTHROPIC_MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-5")
20
- OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434")
21
- OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:7b")
22
-
23
- PRESET_INTENTS = ["praise", "complaint", "question", "suggestion", "other"]
24
- MAX_ROWS = 40
25
- TEXT_COLS = ("text", "review", "comment", "feedback", "message", "body", "content", "response")
26
-
27
- def analysis_schema(labels):
28
- intents = labels + ["other"] if labels else PRESET_INTENTS
29
- item = {"type": "object", "properties": {
30
- "index": {"type": "integer"},
31
- "sentiment": {"type": "string", "enum": ["positive", "negative", "neutral", "mixed"]},
32
- "intent": {"type": "string", "enum": intents},
33
- "confidence": {"type": "number"},
34
- "reason": {"type": "string"},
35
- "evidence": {"type": "string"}},
36
- "required": ["index", "sentiment", "intent", "confidence", "reason", "evidence"]}
37
- return {"type": "object", "properties": {
38
- "results": {"type": "array", "items": item},
39
- "themes": {"type": "array", "items": {"type": "string"}}},
40
- "required": ["results", "themes"]}
41
-
42
- def analyze_batch(rows, labels):
43
- schema = analysis_schema(labels)
44
- numbered = "\n".join(f"[{i}] {t[:400]}" for i, t in enumerate(rows))
45
- sysp = ("You analyze customer feedback. For EACH numbered item return: sentiment (overall tone), "
46
- "intent (its primary purpose), confidence (0-1, how sure you are), reason (one short sentence), "
47
- "and evidence (the exact phrase from THAT item that supports your call). "
48
- "Also return 'themes': the 3-6 most common themes across all items, short noun phrases, most frequent first. "
49
- "Base everything only on the text; never invent details.")
50
- user = "Feedback items:\n" + numbered
51
- if ANTHROPIC_KEY:
52
- import anthropic
53
- client = anthropic.Anthropic(api_key=ANTHROPIC_KEY)
54
- r = client.messages.create(model=ANTHROPIC_MODEL, max_tokens=4096, system=sysp,
55
- tools=[{"name": "report", "description": "Return the analysis.", "input_schema": schema}],
56
- tool_choice={"type": "tool", "name": "report"},
57
- messages=[{"role": "user", "content": user}])
58
- for b in r.content:
59
- if b.type == "tool_use":
60
- return b.input
61
- return {"results": [], "themes": []}
62
- import requests
63
- r = requests.post(f"{OLLAMA_URL}/api/chat", timeout=180, json={
64
- "model": OLLAMA_MODEL, "stream": False, "format": schema,
65
- "messages": [{"role": "system", "content": sysp}, {"role": "user", "content": user}],
66
- "options": {"temperature": 0}})
67
- r.raise_for_status()
68
- return json.loads(r.json()["message"]["content"])
69
-
70
- def parse_rows(raw, is_csv):
71
- if is_csv:
72
- rows = list(csv.reader(io.StringIO(raw)))
73
- if not rows:
74
- return []
75
- low = [c.strip().lower() for c in rows[0]]
76
- idx = next((i for i, h in enumerate(low) if h in TEXT_COLS), 0)
77
- start = 1 if low[idx] in TEXT_COLS else 0 # skip header only if we recognized one
78
- return [r[idx].strip() for r in rows[start:] if len(r) > idx and r[idx].strip()]
79
- # paste mode: one item per line (predictable). Multi-paragraph reviews -> use CSV,
80
- # where csv.reader correctly keeps quoted, newline-containing fields as one item.
81
- return [l.strip() for l in raw.splitlines() if l.strip()]
82
-
83
- app = Flask(__name__)
84
- app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024
85
-
86
- @app.route("/")
87
- def home():
88
- return Response(PAGE, mimetype="text/html")
89
-
90
- @app.route("/api/analyze", methods=["POST"])
91
- def api_analyze():
92
- b = request.get_json(force=True)
93
- raw = (b.get("input") or "").strip()
94
- is_csv = bool(b.get("is_csv"))
95
- labels = [l.strip() for l in (b.get("labels") or "").split(",") if l.strip()]
96
- rows = parse_rows(raw, is_csv)
97
- if not rows:
98
- return jsonify(error="No rows found. Paste one item per line, or upload a CSV with a text column."), 400
99
- capped = rows[:MAX_ROWS]
100
- try:
101
- out = analyze_batch(capped, labels)
102
- except Exception as e:
103
- import sys; print("analyze error:", repr(e), file=sys.stderr, flush=True)
104
- return jsonify(error="Analysis failed — please try again."), 200
105
- # attach original text, order by index, compute counts in code (deterministic)
106
- by_idx = {r.get("index", i): r for i, r in enumerate(out.get("results", []))}
107
- results, sent, intent = [], {}, {}
108
- for i, t in enumerate(capped):
109
- r = dict(by_idx.get(i, {"sentiment": "neutral", "intent": "other", "confidence": 0,
110
- "reason": "", "evidence": ""}))
111
- r["text"] = t
112
- results.append(r)
113
- sent[r.get("sentiment", "neutral")] = sent.get(r.get("sentiment", "neutral"), 0) + 1
114
- intent[r.get("intent", "other")] = intent.get(r.get("intent", "other"), 0) + 1
115
- return jsonify(results=results, themes=out.get("themes", []),
116
- sentiment=sent, intent=intent, total=len(capped),
117
- truncated=len(rows) > MAX_ROWS, original=len(rows))
118
-
119
- PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8">
120
- <title>Feedback Analyzer</title>
121
- <meta name="viewport" content="width=device-width,initial-scale=1">
122
- <link rel="preconnect" href="https://fonts.googleapis.com">
123
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
124
- <link href="https://fonts.googleapis.com/css2?family=Archivo:wght@600;700;800;900&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
125
- <style>
126
- /* ============================================================================
127
- GRITAI DESIGN SYSTEM — "Engineered / Industrial"
128
- Dark · grit-orange · Archivo + IBM Plex · blueprint grid + grain.
129
- Canonical source of truth for every GritAI app (30-in-15 #1-30) + the hub.
130
- Rob-approved 2026-09-05 (#9 pilot: design-critic 5.71 -> 7.57).
131
-
132
- HOW TO USE: embed this <style> verbatim in an app's page, add the Google Fonts
133
- <link> below, then build the body from the component classes here. Keep the
134
- header (monogram + app name + tags), grit-orange as the ONLY accent, dark bg.
135
-
136
- FONTS (add to <head>):
137
- <link rel="preconnect" href="https://fonts.googleapis.com">
138
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
139
- <link href="https://fonts.googleapis.com/css2?family=Archivo:wght@600;700;800;900&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
140
- ============================================================================ */
141
- :root{
142
- --bg:#0a0b0e; --bg2:#0e1015; --panel:#111318; --panel2:#15181f;
143
- --ink:#eef0f4; --dim:#9aa1b0; --dim2:#5f6675; --line:#20242e; --line2:#2b303c;
144
- --grit:#ff5a2a; --grit2:#ff7d54; --grit-dim:#c9451f; --ok:#5fd08a; --bad:#ff6b6b;
145
- --disp:'Archivo',system-ui,sans-serif; --body:'IBM Plex Sans',system-ui,sans-serif;
146
- --mono:'IBM Plex Mono',ui-monospace,monospace;
147
- }
148
- *{box-sizing:border-box}
149
- html,body{height:100%}
150
- body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--body);font-size:15px;line-height:1.6;
151
- -webkit-font-smoothing:antialiased;position:relative;overflow-x:hidden}
152
- /* blueprint grid + vignette */
153
- body::before{content:"";position:fixed;inset:0;z-index:0;pointer-events:none;
154
- background:linear-gradient(0deg,transparent 95%,rgba(255,90,42,.04) 100%),
155
- repeating-linear-gradient(90deg,transparent 0 63px,rgba(255,255,255,.018) 63px 64px),
156
- repeating-linear-gradient(0deg,transparent 0 63px,rgba(255,255,255,.018) 63px 64px),
157
- radial-gradient(120% 80% at 50% -10%,#141821 0%,var(--bg) 55%)}
158
- /* film grain */
159
- body::after{content:"";position:fixed;inset:0;z-index:0;pointer-events:none;opacity:.05;mix-blend-mode:overlay;
160
- background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")}
161
- .wrap{position:relative;z-index:1;max-width:960px;margin:0 auto;padding:0 28px 70px}
162
-
163
- /* ---- header band (monogram SVG + wordmark + tags) ---- */
164
- header{display:flex;align-items:center;gap:16px;padding:22px 0 18px;border-bottom:1px solid var(--line);animation:fade .6s .02s both}
165
- .mark{width:38px;height:38px;flex:none}.mark svg{display:block}
166
- .brand{display:flex;flex-direction:column;line-height:1}
167
- .brand .co{font-family:var(--disp);font-weight:900;font-size:19px;letter-spacing:.5px}.brand .co b{color:var(--grit)}
168
- .brand .sub{font-family:var(--mono);font-size:10.5px;letter-spacing:.28em;text-transform:uppercase;color:var(--dim2);margin-top:5px}
169
- .tags{margin-left:auto;display:flex;gap:8px}
170
- .tag{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);border:1px solid var(--line2);border-radius:2px;padding:5px 9px}
171
- .tag.hot{color:var(--grit);border-color:var(--grit-dim)}
172
- /* canonical monogram SVG (paste in .mark):
173
- <svg width="38" height="38" viewBox="0 0 38 38" fill="none"><rect x="1" y="1" width="36" height="36" rx="2" stroke="#2b303c"/><path d="M27 12.5A9 9 0 1 0 28 22H19" stroke="#ff5a2a" stroke-width="2.4" stroke-linecap="square"/><rect x="10.5" y="10.5" width="3" height="3" fill="#ff5a2a"/></svg> */
174
-
175
- /* ---- hero ---- */
176
- .hero{padding:34px 0 26px;animation:fade .6s .12s both}
177
- .hero h1{font-family:var(--disp);font-weight:800;font-size:clamp(30px,5.4vw,52px);line-height:1.02;letter-spacing:-.02em;margin:0;max-width:16ch}
178
- .hero h1 em{font-style:normal;color:var(--grit)}
179
- .hero p{color:var(--dim);max-width:56ch;margin:16px 0 0;font-size:15.5px}
180
-
181
- /* ---- section label (01 · NAME + rule) ---- */
182
- .sec{display:flex;align-items:center;gap:14px;margin:30px 0 14px}
183
- .sec .num{font-family:var(--mono);font-size:11px;font-weight:600;color:var(--grit);letter-spacing:.1em}
184
- .sec .name{font-family:var(--mono);font-size:11px;font-weight:500;letter-spacing:.26em;text-transform:uppercase;color:var(--dim)}
185
- .sec .rule{flex:1;height:1px;background:linear-gradient(90deg,var(--line2),transparent)}
186
-
187
- /* ---- panels ---- */
188
- .panel{border:1px solid var(--line);border-radius:3px;background:linear-gradient(180deg,var(--panel),var(--bg2))}
189
- .card{border:1px solid var(--line2);border-radius:3px;background:var(--panel);padding:18px 20px}
190
- .card.accent{border-left:2px solid var(--grit)}
191
-
192
- /* ---- input field (terminal) ---- */
193
- .field{display:flex;align-items:stretch;border:1px solid var(--line2);border-radius:3px;background:var(--panel);overflow:hidden;transition:border-color .18s}
194
- .field:focus-within{border-color:var(--grit-dim)}
195
- .field .car{font-family:var(--mono);color:var(--grit);padding:16px 0 16px 16px;font-size:15px;user-select:none}
196
- .field input,.field textarea{flex:1;background:transparent;border:0;outline:none;color:var(--ink);font-family:var(--body);font-size:15.5px;padding:16px 14px;resize:vertical}
197
- .field input::placeholder,.field textarea::placeholder{color:var(--dim2)}
198
- textarea.ta{width:100%;min-height:140px;border:1px solid var(--line2);border-radius:3px;background:var(--panel);color:var(--ink);font-family:var(--body);font-size:15px;padding:14px 16px;outline:none;resize:vertical}
199
- textarea.ta:focus{border-color:var(--grit-dim)}
200
-
201
- /* ---- buttons ---- */
202
- .run{border:0;background:var(--grit);color:#140a06;font-family:var(--mono);font-weight:600;font-size:13px;letter-spacing:.14em;text-transform:uppercase;padding:0 24px;cursor:pointer;transition:.16s;box-shadow:-1px 0 0 var(--grit-dim)}
203
- .run:hover{background:var(--grit2);box-shadow:0 0 26px -4px rgba(255,90,42,.55)}
204
- .run:disabled{opacity:.55;cursor:default;box-shadow:none}
205
- .btn{display:inline-flex;align-items:center;gap:8px;border:0;border-radius:3px;background:var(--grit);color:#140a06;font-family:var(--mono);font-weight:600;font-size:13px;letter-spacing:.1em;text-transform:uppercase;padding:12px 22px;cursor:pointer;transition:.16s}
206
- .btn:hover{background:var(--grit2);box-shadow:0 0 26px -4px rgba(255,90,42,.55)}
207
- .btn.ghost{background:transparent;color:var(--dim);border:1px solid var(--line2)}
208
- .btn.ghost:hover{color:var(--grit);border-color:var(--grit-dim);background:transparent;box-shadow:none}
209
-
210
- /* ---- chips / controls ---- */
211
- .chip{font-family:var(--mono);font-size:12px;color:var(--dim);background:var(--panel);border:1px solid var(--line);border-radius:2px;padding:8px 11px;cursor:pointer;transition:.15s}
212
- .chip:hover{color:var(--grit);border-color:var(--grit-dim)}
213
- .chip .pt{color:var(--dim2);margin-right:7px}
214
- select,.select{padding:10px 12px;border:1px solid var(--line2);border-radius:3px;background:var(--panel);color:var(--ink);font-family:var(--mono);font-size:13px;outline:none;cursor:pointer}
215
- select:focus{border-color:var(--grit-dim)}
216
- label.lbl{font-family:var(--mono);font-size:11px;text-transform:uppercase;letter-spacing:.24em;color:var(--dim2);display:block;margin-bottom:6px}
217
-
218
- /* ---- mono log / trace rows ---- */
219
- .log{font-family:var(--mono);font-size:13px;line-height:1.75}
220
- .row{display:grid;grid-template-columns:22px auto 1fr;gap:0 12px;padding:13px 18px;border-bottom:1px solid var(--line)}
221
- .row:last-child{border-bottom:0}
222
- .row .mk{color:var(--grit);font-weight:600}
223
- .row .tn{color:var(--grit2);text-transform:uppercase;letter-spacing:.06em;font-weight:500}
224
- .row .rd{color:var(--ink);word-break:break-word}.row .rd .arg{color:var(--dim2)}
225
-
226
- /* ---- tables ---- */
227
- table{width:100%;border-collapse:collapse;font-size:13.5px}
228
- th{text-align:left;font-family:var(--mono);font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:var(--dim2);font-weight:500;padding:11px 14px;border-bottom:1px solid var(--line2);background:var(--bg2)}
229
- td{padding:11px 14px;border-bottom:1px solid var(--line);vertical-align:top}
230
- tr:last-child td{border-bottom:0}
231
- .tablewrap{border:1px solid var(--line);border-radius:3px;overflow:hidden;overflow-x:auto}
232
-
233
- /* ---- chat bubbles ---- */
234
- .msg{max-width:78%;padding:12px 15px;border-radius:3px;font-size:15px;line-height:1.55;margin-bottom:12px}
235
- .msg.user{margin-left:auto;background:var(--panel2);border:1px solid var(--line2)}
236
- .msg.bot{background:var(--panel);border:1px solid var(--line);border-left:2px solid var(--grit)}
237
-
238
- /* ---- badges / pills ---- */
239
- .badge{display:inline-block;font-family:var(--mono);font-size:11px;font-weight:500;letter-spacing:.06em;padding:3px 9px;border-radius:2px;border:1px solid var(--line2);color:var(--dim)}
240
- .badge.on{color:var(--grit);border-color:var(--grit-dim)}
241
- .hl{color:var(--grit);font-weight:600;font-family:var(--mono)}
242
-
243
- /* ---- answer/output prose ---- */
244
- .prose{font-size:16px;line-height:1.65}.prose p{margin:0 0 12px}.prose p:last-child{margin-bottom:0}.prose b{color:var(--ink)}
245
-
246
- /* ---- spinner ---- */
247
- .spin{display:inline-flex;gap:6px;padding:16px 18px}.spin span{width:8px;height:8px;border-radius:50%;background:var(--grit);opacity:.5;animation:bd 1.2s infinite}
248
- .spin span:nth-child(2){animation-delay:.18s}.spin span:nth-child(3){animation-delay:.36s}
249
-
250
- /* ---- footer ---- */
251
- footer{margin-top:34px;padding-top:16px;border-top:1px solid var(--line);font-family:var(--mono);font-size:11px;letter-spacing:.06em;color:var(--dim2);display:flex;gap:16px;flex-wrap:wrap;animation:fade .6s .5s both}
252
- footer .b{color:var(--dim)}footer .sp{margin-left:auto}
253
- footer a{color:var(--dim);text-decoration:none}footer a:hover{color:var(--grit)}
254
-
255
- /* ---- reveal animations ---- */
256
- @keyframes fade{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
257
- @keyframes slidein{from{opacity:0;transform:translateX(-8px)}to{opacity:1;transform:none}}
258
- @keyframes bd{0%,60%,100%{transform:translateY(0);opacity:.35}30%{transform:translateY(-6px);opacity:1}}
259
- .rvl{animation:fade .6s both}
260
-
261
- /* ---- app-specific: feedback analyzer ---- */
262
- .tools{display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin-top:14px}
263
- #go{padding-top:14px;padding-bottom:14px;border-radius:3px}
264
- .field.labelfield{flex:1;min-width:220px}
265
- .out{display:none;margin-top:8px}.out.show{display:block}
266
- .note{font-family:var(--mono);font-size:12px;color:var(--grit2);letter-spacing:.03em;margin:8px 2px 16px}
267
- .roll{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:8px}
268
- @media(max-width:640px){.roll{grid-template-columns:1fr}}
269
- .roll h3{margin:0 0 12px;font-family:var(--mono);font-size:11px;letter-spacing:.24em;text-transform:uppercase;color:var(--dim2)}
270
- .bar{display:flex;height:12px;border-radius:2px;overflow:hidden;margin-bottom:12px;background:var(--panel2);border:1px solid var(--line)}
271
- .bar i{display:block;height:100%}
272
- .legend{display:flex;flex-wrap:wrap;gap:12px;font-family:var(--mono);font-size:12px;color:var(--dim)}
273
- .legend span{display:inline-flex;align-items:center;gap:6px}
274
- .dot{width:9px;height:9px;border-radius:2px;display:inline-block}
275
- .themes{margin:0;padding:0;list-style:none}
276
- .themes li{font-family:var(--mono);font-size:13px;color:var(--ink);padding:6px 0 6px 18px;position:relative}
277
- .themes li::before{content:"\\25B8";color:var(--grit);position:absolute;left:0}
278
- .actions{display:flex;justify-content:space-between;align-items:center;gap:14px;margin:6px 2px 14px}
279
- .actions h2{margin:0;font-family:var(--mono);font-size:11px;letter-spacing:.24em;text-transform:uppercase;color:var(--dim2)}
280
- table{min-width:680px}
281
- td.txt{max-width:300px;color:var(--ink)}
282
- .badge.b-positive{color:var(--ok);border-color:rgba(95,208,138,.45)}
283
- .badge.b-negative{color:var(--bad);border-color:rgba(255,107,107,.45)}
284
- .badge.b-neutral{color:var(--dim);border-color:var(--line2)}
285
- .badge.b-mixed{color:var(--grit);border-color:var(--grit-dim)}
286
- .intent{font-family:var(--mono);font-size:12.5px;color:var(--dim);text-transform:capitalize}
287
- .conf{font-family:var(--mono);font-variant-numeric:tabular-nums;color:var(--dim);font-size:12.5px}
288
- .bar2{height:5px;background:var(--line);border-radius:2px;margin-top:5px;overflow:hidden}
289
- .bar2 i{display:block;height:100%;background:var(--grit)}
290
- .reason{color:var(--ink)}
291
- .ev{color:var(--dim);font-style:italic}
292
- </style></head>
293
- <body><div class="wrap">
294
- <header>
295
- <div class="mark"><svg width="38" height="38" viewBox="0 0 38 38" fill="none"><rect x="1" y="1" width="36" height="36" rx="2" stroke="#2b303c"/><path d="M27 12.5A9 9 0 1 0 28 22H19" stroke="#ff5a2a" stroke-width="2.4" stroke-linecap="square"/><rect x="10.5" y="10.5" width="3" height="3" fill="#ff5a2a"/></svg></div>
296
- <div class="brand"><span class="co">GRIT<b>AI</b></span><span class="sub">Feedback Analyzer</span></div>
297
- <div class="tags"><span class="tag hot">&#9656; Live</span><span class="tag">SDVOSB</span><span class="tag">Lawton OK</span></div>
298
- </header>
299
- <section class="hero">
300
- <h1>Every review, scored for <em>sentiment &amp; intent</em></h1>
301
- <p>Paste or upload customer feedback in bulk — get per-item sentiment, intent, confidence, a reason, and the exact evidence phrase, plus a rollup and a downloadable labeled CSV.</p>
302
- </section>
303
- <div class="sec"><span class="num">01</span><span class="name">Feedback</span><span class="rule"></span></div>
304
- <textarea id="src" class="ta" placeholder="Paste feedback — one item per line, or full reviews separated by a blank line. Or upload a CSV."></textarea>
305
- <div class="tools">
306
- <label class="btn ghost" for="csv">Upload CSV<input type="file" id="csv" accept=".csv,text/csv" style="display:none"></label>
307
- <span class="chip" onclick="loadSample()">Load sample</span>
308
- <div class="field labelfield"><span class="car">&rsaquo;</span><input id="labels" placeholder="Custom intent labels (optional, comma-separated)"></div>
309
- <button class="run" id="go" onclick="run()">Analyze</button>
310
- </div>
311
- <div class="out" id="out">
312
- <div id="note"></div>
313
- <div class="sec"><span class="num">02</span><span class="name">Rollup</span><span class="rule"></span></div>
314
- <div class="roll">
315
- <div class="card"><h3>Sentiment</h3><div class="bar" id="sbar"></div><div class="legend" id="sleg"></div></div>
316
- <div class="card"><h3>Top themes</h3><ul class="themes" id="themes"></ul></div>
317
- </div>
318
- <div class="sec"><span class="num">03</span><span class="name">Per-item results</span><span class="rule"></span></div>
319
- <div class="actions"><h2>Labeled feedback</h2><button class="btn ghost" id="dl" onclick="download()">Download CSV</button></div>
320
- <div class="tablewrap"><table id="tbl"><thead><tr>
321
- <th>Feedback</th><th>Sentiment</th><th>Intent</th><th>Conf.</th><th>Reason</th><th>Evidence</th>
322
- </tr></thead><tbody id="tbody"></tbody></table></div>
323
- </div>
324
- <footer>
325
- <span class="b">One batched, schema-valid AI call</span>
326
- <span>Counts computed in code</span>
327
- <span class="sp">GRITAI SOLUTIONS · 30-in-15 №5</span>
328
- </footer>
329
- </div><script>
330
- const SAMPLE=["The barista remembered my order — made my whole morning!",
331
- "App keeps crashing every time I try to check out. Fix this.",
332
- "Do you ship to Canada? Couldn't find it anywhere on the site.",
333
- "Coffee was fine but the wait was 25 minutes. Way too long.",
334
- "Absolutely love the new oat milk options, thank you!!",
335
- "You should add a loyalty program, would keep me coming back.",
336
- "Order arrived broken and no one has responded to my emails.",
337
- "Great product, terrible packaging. Two stars.",
338
- "Is the sale price going to come back? Missed it by a day.",
339
- "Staff was rude and dismissive when I asked for help.",
340
- "Best customer service I've had in years, genuinely impressed.",
341
- "Website is confusing, took me forever to find the return policy."];
342
- const $=id=>document.getElementById(id);
343
- const src=$('src'),go=$('go'),out=$('out'),csv=$('csv'),labels=$('labels');
344
- let isCsv=false,last=null;
345
- const SC={positive:'var(--ok)',negative:'var(--bad)',neutral:'var(--dim)',mixed:'var(--grit)'};
346
- function esc(s){return String(s==null?'':s).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));}
347
- function loadSample(){src.value=SAMPLE.join("\\n");isCsv=false;run();}
348
- csv.onchange=e=>{const f=e.target.files[0];if(!f)return;const r=new FileReader();
349
- r.onload=()=>{src.value=r.result;isCsv=true;run();};r.readAsText(f);};
350
- src.addEventListener('input',()=>{isCsv=false;});
351
- function run(){if(go.disabled)return;const text=src.value.trim();if(!text){src.focus();return;}
352
- go.disabled=true;go.textContent='Analyzing…';out.classList.add('show');
353
- $('note').innerHTML='';$('tbody').innerHTML='';$('themes').innerHTML='';$('sbar').innerHTML='';$('sleg').innerHTML='';
354
- $('tbody').innerHTML='<tr><td colspan="6"><span class="spin"><span></span><span></span><span></span></span></td></tr>';
355
- fetch('/api/analyze',{method:'POST',headers:{'Content-Type':'application/json'},
356
- body:JSON.stringify({input:text,is_csv:isCsv,labels:labels.value})})
357
- .then(r=>r.json()).then(d=>{
358
- if(d.error){$('tbody').innerHTML='<tr><td colspan="6">'+esc(d.error)+'</td></tr>';return;}
359
- last=d;render(d);
360
- }).catch(()=>{$('tbody').innerHTML='<tr><td colspan="6">Something went wrong — please try again.</td></tr>';})
361
- .finally(()=>{go.disabled=false;go.textContent='Analyze';});
362
- }
363
- function render(d){
364
- if(d.truncated)$('note').innerHTML='<div class="note">Showing the first '+d.total+' of '+d.original+' rows (per-run cap).</div>';
365
- // sentiment bar
366
- const order=['positive','neutral','mixed','negative'];const total=d.total||1;
367
- $('sbar').innerHTML=order.filter(s=>d.sentiment[s]).map(s=>'<i style="width:'+(100*d.sentiment[s]/total)+'%;background:'+SC[s]+'"></i>').join('');
368
- $('sleg').innerHTML=order.filter(s=>d.sentiment[s]).map(s=>'<span><span class="dot" style="background:'+SC[s]+'"></span>'+s+' '+d.sentiment[s]+'</span>').join('');
369
- $('themes').innerHTML=(d.themes||[]).map(t=>'<li>'+esc(t)+'</li>').join('')||'<li style="color:var(--dim)">—</li>';
370
- $('tbody').innerHTML=d.results.map(r=>{const c=Math.round((r.confidence||0)*100);
371
- return '<tr><td class="txt">'+esc(r.text)+'</td>'+
372
- '<td><span class="badge b-'+esc(r.sentiment)+'">'+esc(r.sentiment)+'</span></td>'+
373
- '<td class="intent">'+esc(r.intent)+'</td>'+
374
- '<td class="conf">'+c+'%<div class="bar2"><i style="width:'+c+'%"></i></div></td>'+
375
- '<td class="reason">'+esc(r.reason)+'</td>'+
376
- '<td class="ev">'+(r.evidence?'&ldquo;'+esc(r.evidence)+'&rdquo;':'')+'</td></tr>';}).join('');
377
- }
378
- function download(){if(!last)return;const cols=['text','sentiment','intent','confidence','reason','evidence'];
379
- const q=s=>'"'+String(s==null?'':s).replace(/"/g,'""')+'"';
380
- const rows=[cols.join(',')].concat(last.results.map(r=>cols.map(c=>q(r[c])).join(',')));
381
- const blob=new Blob([rows.join("\\n")],{type:'text/csv'});const a=document.createElement('a');
382
- a.href=URL.createObjectURL(blob);a.download='feedback_analyzed.csv';a.click();}
383
- </script></body></html>"""
384
-
385
- if __name__ == "__main__":
386
- backend = "Claude API" if ANTHROPIC_KEY else f"Ollama ({OLLAMA_MODEL} @ {OLLAMA_URL})"
387
- print(f"Feedback Analyzer on http://127.0.0.1:{PORT} [backend: {backend}]", flush=True)
388
- app.run(host="0.0.0.0", port=PORT, threaded=True)
 
1
+ #!/usr/bin/env python
2
+ """Feedback Analyzer — paste or upload customer feedback in bulk and get, per item:
3
+ sentiment + intent + confidence + a reason + the evidence phrase — plus a rollup
4
+ (sentiment/intent mix + top themes) and a downloadable labeled CSV.
5
+
6
+ Reliability: ONE Claude call with forced tool-use returns the whole batch as
7
+ schema-valid JSON (Ollama fallback uses constrained JSON decoding).
8
+
9
+ pip install flask requests anthropic
10
+ python app.py # http://127.0.0.1:8500
11
+
12
+ Project #5 of the "30 Projects in 15 Days" challenge — GritAI.
13
+ """
14
+ import os, io, csv, json
15
+ from flask import Flask, request, jsonify, Response
16
+
17
+ PORT = int(os.environ.get("PORT", "8500"))
18
+ ANTHROPIC_KEY = os.environ.get("ANTHROPIC_API_KEY")
19
+ ANTHROPIC_MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-5")
20
+ OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434")
21
+ OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:7b")
22
+
23
+ PRESET_INTENTS = ["praise", "complaint", "question", "suggestion", "other"]
24
+ MAX_ROWS = 40
25
+ TEXT_COLS = ("text", "review", "comment", "feedback", "message", "body", "content", "response")
26
+
27
+ def analysis_schema(labels):
28
+ intents = labels + ["other"] if labels else PRESET_INTENTS
29
+ item = {"type": "object", "properties": {
30
+ "index": {"type": "integer"},
31
+ "sentiment": {"type": "string", "enum": ["positive", "negative", "neutral", "mixed"]},
32
+ "intent": {"type": "string", "enum": intents},
33
+ "confidence": {"type": "number"},
34
+ "reason": {"type": "string"},
35
+ "evidence": {"type": "string"}},
36
+ "required": ["index", "sentiment", "intent", "confidence", "reason", "evidence"]}
37
+ return {"type": "object", "properties": {
38
+ "results": {"type": "array", "items": item},
39
+ "themes": {"type": "array", "items": {"type": "string"}}},
40
+ "required": ["results", "themes"]}
41
+
42
+ def analyze_batch(rows, labels):
43
+ schema = analysis_schema(labels)
44
+ numbered = "\n".join(f"[{i}] {t[:400]}" for i, t in enumerate(rows))
45
+ sysp = ("You analyze customer feedback. For EACH numbered item return: sentiment (overall tone), "
46
+ "intent (its primary purpose), confidence (0-1, how sure you are), reason (one short sentence), "
47
+ "and evidence (the exact phrase from THAT item that supports your call). "
48
+ "Also return 'themes': the 3-6 most common themes across all items, short noun phrases, most frequent first. "
49
+ "Base everything only on the text; never invent details.")
50
+ user = "Feedback items:\n" + numbered
51
+ if ANTHROPIC_KEY:
52
+ import anthropic
53
+ client = anthropic.Anthropic(api_key=ANTHROPIC_KEY)
54
+ r = client.messages.create(model=ANTHROPIC_MODEL, max_tokens=4096, system=sysp,
55
+ tools=[{"name": "report", "description": "Return the analysis.", "input_schema": schema}],
56
+ tool_choice={"type": "tool", "name": "report"},
57
+ messages=[{"role": "user", "content": user}])
58
+ for b in r.content:
59
+ if b.type == "tool_use":
60
+ return b.input
61
+ return {"results": [], "themes": []}
62
+ import requests
63
+ r = requests.post(f"{OLLAMA_URL}/api/chat", timeout=180, json={
64
+ "model": OLLAMA_MODEL, "stream": False, "format": schema,
65
+ "messages": [{"role": "system", "content": sysp}, {"role": "user", "content": user}],
66
+ "options": {"temperature": 0}})
67
+ r.raise_for_status()
68
+ return json.loads(r.json()["message"]["content"])
69
+
70
+ def parse_rows(raw, is_csv):
71
+ if is_csv:
72
+ rows = list(csv.reader(io.StringIO(raw)))
73
+ if not rows:
74
+ return []
75
+ low = [c.strip().lower() for c in rows[0]]
76
+ idx = next((i for i, h in enumerate(low) if h in TEXT_COLS), 0)
77
+ start = 1 if low[idx] in TEXT_COLS else 0 # skip header only if we recognized one
78
+ return [r[idx].strip() for r in rows[start:] if len(r) > idx and r[idx].strip()]
79
+ # paste mode: one item per line (predictable). Multi-paragraph reviews -> use CSV,
80
+ # where csv.reader correctly keeps quoted, newline-containing fields as one item.
81
+ return [l.strip() for l in raw.splitlines() if l.strip()]
82
+
83
+ app = Flask(__name__)
84
+ app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024
85
+
86
+ @app.route("/")
87
+ def home():
88
+ return Response(PAGE, mimetype="text/html")
89
+
90
+ @app.route("/api/analyze", methods=["POST"])
91
+ def api_analyze():
92
+ b = request.get_json(force=True)
93
+ raw = (b.get("input") or "").strip()
94
+ is_csv = bool(b.get("is_csv"))
95
+ labels = [l.strip() for l in (b.get("labels") or "").split(",") if l.strip()]
96
+ rows = parse_rows(raw, is_csv)
97
+ if not rows:
98
+ return jsonify(error="No rows found. Paste one item per line, or upload a CSV with a text column."), 400
99
+ capped = rows[:MAX_ROWS]
100
+ try:
101
+ out = analyze_batch(capped, labels)
102
+ except Exception as e:
103
+ import sys; print("analyze error:", type(e).__name__, file=sys.stderr, flush=True)
104
+ return jsonify(error="Analysis failed — please try again."), 200
105
+ # attach original text, order by index, compute counts in code (deterministic)
106
+ by_idx = {r.get("index", i): r for i, r in enumerate(out.get("results", []))}
107
+ results, sent, intent = [], {}, {}
108
+ for i, t in enumerate(capped):
109
+ r = dict(by_idx.get(i, {"sentiment": "neutral", "intent": "other", "confidence": 0,
110
+ "reason": "", "evidence": ""}))
111
+ r["text"] = t
112
+ results.append(r)
113
+ sent[r.get("sentiment", "neutral")] = sent.get(r.get("sentiment", "neutral"), 0) + 1
114
+ intent[r.get("intent", "other")] = intent.get(r.get("intent", "other"), 0) + 1
115
+ return jsonify(results=results, themes=out.get("themes", []),
116
+ sentiment=sent, intent=intent, total=len(capped),
117
+ truncated=len(rows) > MAX_ROWS, original=len(rows))
118
+
119
+ PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8">
120
+ <title>Feedback Analyzer</title>
121
+ <meta name="viewport" content="width=device-width,initial-scale=1">
122
+ <link rel="preconnect" href="https://fonts.googleapis.com">
123
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
124
+ <link href="https://fonts.googleapis.com/css2?family=Archivo:wght@600;700;800;900&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
125
+ <style>
126
+ /* ============================================================================
127
+ GRITAI DESIGN SYSTEM — "Engineered / Industrial"
128
+ Dark · grit-orange · Archivo + IBM Plex · blueprint grid + grain.
129
+ Canonical source of truth for every GritAI app (30-in-15 #1-30) + the hub.
130
+ Rob-approved 2026-09-05 (#9 pilot: design-critic 5.71 -> 7.57).
131
+
132
+ HOW TO USE: embed this <style> verbatim in an app's page, add the Google Fonts
133
+ <link> below, then build the body from the component classes here. Keep the
134
+ header (monogram + app name + tags), grit-orange as the ONLY accent, dark bg.
135
+
136
+ FONTS (add to <head>):
137
+ <link rel="preconnect" href="https://fonts.googleapis.com">
138
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
139
+ <link href="https://fonts.googleapis.com/css2?family=Archivo:wght@600;700;800;900&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
140
+ ============================================================================ */
141
+ :root{
142
+ --bg:#0a0b0e; --bg2:#0e1015; --panel:#111318; --panel2:#15181f;
143
+ --ink:#eef0f4; --dim:#9aa1b0; --dim2:#5f6675; --line:#20242e; --line2:#2b303c;
144
+ --grit:#ff5a2a; --grit2:#ff7d54; --grit-dim:#c9451f; --ok:#5fd08a; --bad:#ff6b6b;
145
+ --disp:'Archivo',system-ui,sans-serif; --body:'IBM Plex Sans',system-ui,sans-serif;
146
+ --mono:'IBM Plex Mono',ui-monospace,monospace;
147
+ }
148
+ *{box-sizing:border-box}
149
+ html,body{height:100%}
150
+ body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--body);font-size:15px;line-height:1.6;
151
+ -webkit-font-smoothing:antialiased;position:relative;overflow-x:hidden}
152
+ /* blueprint grid + vignette */
153
+ body::before{content:"";position:fixed;inset:0;z-index:0;pointer-events:none;
154
+ background:linear-gradient(0deg,transparent 95%,rgba(255,90,42,.04) 100%),
155
+ repeating-linear-gradient(90deg,transparent 0 63px,rgba(255,255,255,.018) 63px 64px),
156
+ repeating-linear-gradient(0deg,transparent 0 63px,rgba(255,255,255,.018) 63px 64px),
157
+ radial-gradient(120% 80% at 50% -10%,#141821 0%,var(--bg) 55%)}
158
+ /* film grain */
159
+ body::after{content:"";position:fixed;inset:0;z-index:0;pointer-events:none;opacity:.05;mix-blend-mode:overlay;
160
+ background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")}
161
+ .wrap{position:relative;z-index:1;max-width:960px;margin:0 auto;padding:0 28px 70px}
162
+
163
+ /* ---- header band (monogram SVG + wordmark + tags) ---- */
164
+ header{display:flex;align-items:center;gap:16px;padding:22px 0 18px;border-bottom:1px solid var(--line);animation:fade .6s .02s both}
165
+ .mark{width:38px;height:38px;flex:none}.mark svg{display:block}
166
+ .brand{display:flex;flex-direction:column;line-height:1}
167
+ .brand .co{font-family:var(--disp);font-weight:900;font-size:19px;letter-spacing:.5px}.brand .co b{color:var(--grit)}
168
+ .brand .sub{font-family:var(--mono);font-size:10.5px;letter-spacing:.28em;text-transform:uppercase;color:var(--dim2);margin-top:5px}
169
+ .tags{margin-left:auto;display:flex;gap:8px}
170
+ .tag{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);border:1px solid var(--line2);border-radius:2px;padding:5px 9px}
171
+ .tag.hot{color:var(--grit);border-color:var(--grit-dim)}
172
+ /* canonical monogram SVG (paste in .mark):
173
+ <svg width="38" height="38" viewBox="0 0 38 38" fill="none"><rect x="1" y="1" width="36" height="36" rx="2" stroke="#2b303c"/><path d="M27 12.5A9 9 0 1 0 28 22H19" stroke="#ff5a2a" stroke-width="2.4" stroke-linecap="square"/><rect x="10.5" y="10.5" width="3" height="3" fill="#ff5a2a"/></svg> */
174
+
175
+ /* ---- hero ---- */
176
+ .hero{padding:34px 0 26px;animation:fade .6s .12s both}
177
+ .hero h1{font-family:var(--disp);font-weight:800;font-size:clamp(30px,5.4vw,52px);line-height:1.02;letter-spacing:-.02em;margin:0;max-width:16ch}
178
+ .hero h1 em{font-style:normal;color:var(--grit)}
179
+ .hero p{color:var(--dim);max-width:56ch;margin:16px 0 0;font-size:15.5px}
180
+
181
+ /* ---- section label (01 · NAME + rule) ---- */
182
+ .sec{display:flex;align-items:center;gap:14px;margin:30px 0 14px}
183
+ .sec .num{font-family:var(--mono);font-size:11px;font-weight:600;color:var(--grit);letter-spacing:.1em}
184
+ .sec .name{font-family:var(--mono);font-size:11px;font-weight:500;letter-spacing:.26em;text-transform:uppercase;color:var(--dim)}
185
+ .sec .rule{flex:1;height:1px;background:linear-gradient(90deg,var(--line2),transparent)}
186
+
187
+ /* ---- panels ---- */
188
+ .panel{border:1px solid var(--line);border-radius:3px;background:linear-gradient(180deg,var(--panel),var(--bg2))}
189
+ .card{border:1px solid var(--line2);border-radius:3px;background:var(--panel);padding:18px 20px}
190
+ .card.accent{border-left:2px solid var(--grit)}
191
+
192
+ /* ---- input field (terminal) ---- */
193
+ .field{display:flex;align-items:stretch;border:1px solid var(--line2);border-radius:3px;background:var(--panel);overflow:hidden;transition:border-color .18s}
194
+ .field:focus-within{border-color:var(--grit-dim)}
195
+ .field .car{font-family:var(--mono);color:var(--grit);padding:16px 0 16px 16px;font-size:15px;user-select:none}
196
+ .field input,.field textarea{flex:1;background:transparent;border:0;outline:none;color:var(--ink);font-family:var(--body);font-size:15.5px;padding:16px 14px;resize:vertical}
197
+ .field input::placeholder,.field textarea::placeholder{color:var(--dim2)}
198
+ textarea.ta{width:100%;min-height:140px;border:1px solid var(--line2);border-radius:3px;background:var(--panel);color:var(--ink);font-family:var(--body);font-size:15px;padding:14px 16px;outline:none;resize:vertical}
199
+ textarea.ta:focus{border-color:var(--grit-dim)}
200
+
201
+ /* ---- buttons ---- */
202
+ .run{border:0;background:var(--grit);color:#140a06;font-family:var(--mono);font-weight:600;font-size:13px;letter-spacing:.14em;text-transform:uppercase;padding:0 24px;cursor:pointer;transition:.16s;box-shadow:-1px 0 0 var(--grit-dim)}
203
+ .run:hover{background:var(--grit2);box-shadow:0 0 26px -4px rgba(255,90,42,.55)}
204
+ .run:disabled{opacity:.55;cursor:default;box-shadow:none}
205
+ .btn{display:inline-flex;align-items:center;gap:8px;border:0;border-radius:3px;background:var(--grit);color:#140a06;font-family:var(--mono);font-weight:600;font-size:13px;letter-spacing:.1em;text-transform:uppercase;padding:12px 22px;cursor:pointer;transition:.16s}
206
+ .btn:hover{background:var(--grit2);box-shadow:0 0 26px -4px rgba(255,90,42,.55)}
207
+ .btn.ghost{background:transparent;color:var(--dim);border:1px solid var(--line2)}
208
+ .btn.ghost:hover{color:var(--grit);border-color:var(--grit-dim);background:transparent;box-shadow:none}
209
+
210
+ /* ---- chips / controls ---- */
211
+ .chip{font-family:var(--mono);font-size:12px;color:var(--dim);background:var(--panel);border:1px solid var(--line);border-radius:2px;padding:8px 11px;cursor:pointer;transition:.15s}
212
+ .chip:hover{color:var(--grit);border-color:var(--grit-dim)}
213
+ .chip .pt{color:var(--dim2);margin-right:7px}
214
+ select,.select{padding:10px 12px;border:1px solid var(--line2);border-radius:3px;background:var(--panel);color:var(--ink);font-family:var(--mono);font-size:13px;outline:none;cursor:pointer}
215
+ select:focus{border-color:var(--grit-dim)}
216
+ label.lbl{font-family:var(--mono);font-size:11px;text-transform:uppercase;letter-spacing:.24em;color:var(--dim2);display:block;margin-bottom:6px}
217
+
218
+ /* ---- mono log / trace rows ---- */
219
+ .log{font-family:var(--mono);font-size:13px;line-height:1.75}
220
+ .row{display:grid;grid-template-columns:22px auto 1fr;gap:0 12px;padding:13px 18px;border-bottom:1px solid var(--line)}
221
+ .row:last-child{border-bottom:0}
222
+ .row .mk{color:var(--grit);font-weight:600}
223
+ .row .tn{color:var(--grit2);text-transform:uppercase;letter-spacing:.06em;font-weight:500}
224
+ .row .rd{color:var(--ink);word-break:break-word}.row .rd .arg{color:var(--dim2)}
225
+
226
+ /* ---- tables ---- */
227
+ table{width:100%;border-collapse:collapse;font-size:13.5px}
228
+ th{text-align:left;font-family:var(--mono);font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:var(--dim2);font-weight:500;padding:11px 14px;border-bottom:1px solid var(--line2);background:var(--bg2)}
229
+ td{padding:11px 14px;border-bottom:1px solid var(--line);vertical-align:top}
230
+ tr:last-child td{border-bottom:0}
231
+ .tablewrap{border:1px solid var(--line);border-radius:3px;overflow:hidden;overflow-x:auto}
232
+
233
+ /* ---- chat bubbles ---- */
234
+ .msg{max-width:78%;padding:12px 15px;border-radius:3px;font-size:15px;line-height:1.55;margin-bottom:12px}
235
+ .msg.user{margin-left:auto;background:var(--panel2);border:1px solid var(--line2)}
236
+ .msg.bot{background:var(--panel);border:1px solid var(--line);border-left:2px solid var(--grit)}
237
+
238
+ /* ---- badges / pills ---- */
239
+ .badge{display:inline-block;font-family:var(--mono);font-size:11px;font-weight:500;letter-spacing:.06em;padding:3px 9px;border-radius:2px;border:1px solid var(--line2);color:var(--dim)}
240
+ .badge.on{color:var(--grit);border-color:var(--grit-dim)}
241
+ .hl{color:var(--grit);font-weight:600;font-family:var(--mono)}
242
+
243
+ /* ---- answer/output prose ---- */
244
+ .prose{font-size:16px;line-height:1.65}.prose p{margin:0 0 12px}.prose p:last-child{margin-bottom:0}.prose b{color:var(--ink)}
245
+
246
+ /* ---- spinner ---- */
247
+ .spin{display:inline-flex;gap:6px;padding:16px 18px}.spin span{width:8px;height:8px;border-radius:50%;background:var(--grit);opacity:.5;animation:bd 1.2s infinite}
248
+ .spin span:nth-child(2){animation-delay:.18s}.spin span:nth-child(3){animation-delay:.36s}
249
+
250
+ /* ---- footer ---- */
251
+ footer{margin-top:34px;padding-top:16px;border-top:1px solid var(--line);font-family:var(--mono);font-size:11px;letter-spacing:.06em;color:var(--dim2);display:flex;gap:16px;flex-wrap:wrap;animation:fade .6s .5s both}
252
+ footer .b{color:var(--dim)}footer .sp{margin-left:auto}
253
+ footer a{color:var(--dim);text-decoration:none}footer a:hover{color:var(--grit)}
254
+
255
+ /* ---- reveal animations ---- */
256
+ @keyframes fade{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
257
+ @keyframes slidein{from{opacity:0;transform:translateX(-8px)}to{opacity:1;transform:none}}
258
+ @keyframes bd{0%,60%,100%{transform:translateY(0);opacity:.35}30%{transform:translateY(-6px);opacity:1}}
259
+ .rvl{animation:fade .6s both}
260
+
261
+ /* ---- app-specific: feedback analyzer ---- */
262
+ .tools{display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin-top:14px}
263
+ #go{padding-top:14px;padding-bottom:14px;border-radius:3px}
264
+ .field.labelfield{flex:1;min-width:220px}
265
+ .out{display:none;margin-top:8px}.out.show{display:block}
266
+ .note{font-family:var(--mono);font-size:12px;color:var(--grit2);letter-spacing:.03em;margin:8px 2px 16px}
267
+ .roll{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:8px}
268
+ @media(max-width:640px){.roll{grid-template-columns:1fr}}
269
+ .roll h3{margin:0 0 12px;font-family:var(--mono);font-size:11px;letter-spacing:.24em;text-transform:uppercase;color:var(--dim2)}
270
+ .bar{display:flex;height:12px;border-radius:2px;overflow:hidden;margin-bottom:12px;background:var(--panel2);border:1px solid var(--line)}
271
+ .bar i{display:block;height:100%}
272
+ .legend{display:flex;flex-wrap:wrap;gap:12px;font-family:var(--mono);font-size:12px;color:var(--dim)}
273
+ .legend span{display:inline-flex;align-items:center;gap:6px}
274
+ .dot{width:9px;height:9px;border-radius:2px;display:inline-block}
275
+ .themes{margin:0;padding:0;list-style:none}
276
+ .themes li{font-family:var(--mono);font-size:13px;color:var(--ink);padding:6px 0 6px 18px;position:relative}
277
+ .themes li::before{content:"\\25B8";color:var(--grit);position:absolute;left:0}
278
+ .actions{display:flex;justify-content:space-between;align-items:center;gap:14px;margin:6px 2px 14px}
279
+ .actions h2{margin:0;font-family:var(--mono);font-size:11px;letter-spacing:.24em;text-transform:uppercase;color:var(--dim2)}
280
+ table{min-width:680px}
281
+ td.txt{max-width:300px;color:var(--ink)}
282
+ .badge.b-positive{color:var(--ok);border-color:rgba(95,208,138,.45)}
283
+ .badge.b-negative{color:var(--bad);border-color:rgba(255,107,107,.45)}
284
+ .badge.b-neutral{color:var(--dim);border-color:var(--line2)}
285
+ .badge.b-mixed{color:var(--grit);border-color:var(--grit-dim)}
286
+ .intent{font-family:var(--mono);font-size:12.5px;color:var(--dim);text-transform:capitalize}
287
+ .conf{font-family:var(--mono);font-variant-numeric:tabular-nums;color:var(--dim);font-size:12.5px}
288
+ .bar2{height:5px;background:var(--line);border-radius:2px;margin-top:5px;overflow:hidden}
289
+ .bar2 i{display:block;height:100%;background:var(--grit)}
290
+ .reason{color:var(--ink)}
291
+ .ev{color:var(--dim);font-style:italic}
292
+ </style></head>
293
+ <body><div class="wrap">
294
+ <header>
295
+ <div class="mark"><svg width="38" height="38" viewBox="0 0 38 38" fill="none"><rect x="1" y="1" width="36" height="36" rx="2" stroke="#2b303c"/><path d="M27 12.5A9 9 0 1 0 28 22H19" stroke="#ff5a2a" stroke-width="2.4" stroke-linecap="square"/><rect x="10.5" y="10.5" width="3" height="3" fill="#ff5a2a"/></svg></div>
296
+ <div class="brand"><span class="co">GRIT<b>AI</b></span><span class="sub">Feedback Analyzer</span></div>
297
+ <div class="tags"><span class="tag hot">&#9656; Live</span><span class="tag">SDVOSB</span><span class="tag">Lawton OK</span></div>
298
+ </header>
299
+ <section class="hero">
300
+ <h1>Every review, scored for <em>sentiment &amp; intent</em></h1>
301
+ <p>Paste or upload customer feedback in bulk — get per-item sentiment, intent, confidence, a reason, and the exact evidence phrase, plus a rollup and a downloadable labeled CSV.</p>
302
+ </section>
303
+ <div class="sec"><span class="num">01</span><span class="name">Feedback</span><span class="rule"></span></div>
304
+ <textarea id="src" class="ta" placeholder="Paste feedback — one item per line, or full reviews separated by a blank line. Or upload a CSV."></textarea>
305
+ <div class="tools">
306
+ <label class="btn ghost" for="csv">Upload CSV<input type="file" id="csv" accept=".csv,text/csv" style="display:none"></label>
307
+ <span class="chip" onclick="loadSample()">Load sample</span>
308
+ <div class="field labelfield"><span class="car">&rsaquo;</span><input id="labels" placeholder="Custom intent labels (optional, comma-separated)"></div>
309
+ <button class="run" id="go" onclick="run()">Analyze</button>
310
+ </div>
311
+ <div class="out" id="out">
312
+ <div id="note"></div>
313
+ <div class="sec"><span class="num">02</span><span class="name">Rollup</span><span class="rule"></span></div>
314
+ <div class="roll">
315
+ <div class="card"><h3>Sentiment</h3><div class="bar" id="sbar"></div><div class="legend" id="sleg"></div></div>
316
+ <div class="card"><h3>Top themes</h3><ul class="themes" id="themes"></ul></div>
317
+ </div>
318
+ <div class="sec"><span class="num">03</span><span class="name">Per-item results</span><span class="rule"></span></div>
319
+ <div class="actions"><h2>Labeled feedback</h2><button class="btn ghost" id="dl" onclick="download()">Download CSV</button></div>
320
+ <div class="tablewrap"><table id="tbl"><thead><tr>
321
+ <th>Feedback</th><th>Sentiment</th><th>Intent</th><th>Conf.</th><th>Reason</th><th>Evidence</th>
322
+ </tr></thead><tbody id="tbody"></tbody></table></div>
323
+ </div>
324
+ <footer>
325
+ <span class="b">One batched, schema-valid AI call</span>
326
+ <span>Counts computed in code</span>
327
+ <span class="sp">GRITAI SOLUTIONS · 30-in-15 №5</span>
328
+ </footer>
329
+ </div><script>
330
+ const SAMPLE=["The barista remembered my order — made my whole morning!",
331
+ "App keeps crashing every time I try to check out. Fix this.",
332
+ "Do you ship to Canada? Couldn't find it anywhere on the site.",
333
+ "Coffee was fine but the wait was 25 minutes. Way too long.",
334
+ "Absolutely love the new oat milk options, thank you!!",
335
+ "You should add a loyalty program, would keep me coming back.",
336
+ "Order arrived broken and no one has responded to my emails.",
337
+ "Great product, terrible packaging. Two stars.",
338
+ "Is the sale price going to come back? Missed it by a day.",
339
+ "Staff was rude and dismissive when I asked for help.",
340
+ "Best customer service I've had in years, genuinely impressed.",
341
+ "Website is confusing, took me forever to find the return policy."];
342
+ const $=id=>document.getElementById(id);
343
+ const src=$('src'),go=$('go'),out=$('out'),csv=$('csv'),labels=$('labels');
344
+ let isCsv=false,last=null;
345
+ const SC={positive:'var(--ok)',negative:'var(--bad)',neutral:'var(--dim)',mixed:'var(--grit)'};
346
+ function esc(s){return String(s==null?'':s).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));}
347
+ function loadSample(){src.value=SAMPLE.join("\\n");isCsv=false;run();}
348
+ csv.onchange=e=>{const f=e.target.files[0];if(!f)return;const r=new FileReader();
349
+ r.onload=()=>{src.value=r.result;isCsv=true;run();};r.readAsText(f);};
350
+ src.addEventListener('input',()=>{isCsv=false;});
351
+ function run(){if(go.disabled)return;const text=src.value.trim();if(!text){src.focus();return;}
352
+ go.disabled=true;go.textContent='Analyzing…';out.classList.add('show');
353
+ $('note').innerHTML='';$('tbody').innerHTML='';$('themes').innerHTML='';$('sbar').innerHTML='';$('sleg').innerHTML='';
354
+ $('tbody').innerHTML='<tr><td colspan="6"><span class="spin"><span></span><span></span><span></span></span></td></tr>';
355
+ fetch('/api/analyze',{method:'POST',headers:{'Content-Type':'application/json'},
356
+ body:JSON.stringify({input:text,is_csv:isCsv,labels:labels.value})})
357
+ .then(r=>r.json()).then(d=>{
358
+ if(d.error){$('tbody').innerHTML='<tr><td colspan="6">'+esc(d.error)+'</td></tr>';return;}
359
+ last=d;render(d);
360
+ }).catch(()=>{$('tbody').innerHTML='<tr><td colspan="6">Something went wrong — please try again.</td></tr>';})
361
+ .finally(()=>{go.disabled=false;go.textContent='Analyze';});
362
+ }
363
+ function render(d){
364
+ if(d.truncated)$('note').innerHTML='<div class="note">Showing the first '+d.total+' of '+d.original+' rows (per-run cap).</div>';
365
+ // sentiment bar
366
+ const order=['positive','neutral','mixed','negative'];const total=d.total||1;
367
+ $('sbar').innerHTML=order.filter(s=>d.sentiment[s]).map(s=>'<i style="width:'+(100*d.sentiment[s]/total)+'%;background:'+SC[s]+'"></i>').join('');
368
+ $('sleg').innerHTML=order.filter(s=>d.sentiment[s]).map(s=>'<span><span class="dot" style="background:'+SC[s]+'"></span>'+s+' '+d.sentiment[s]+'</span>').join('');
369
+ $('themes').innerHTML=(d.themes||[]).map(t=>'<li>'+esc(t)+'</li>').join('')||'<li style="color:var(--dim)">—</li>';
370
+ $('tbody').innerHTML=d.results.map(r=>{const c=Math.round((r.confidence||0)*100);
371
+ return '<tr><td class="txt">'+esc(r.text)+'</td>'+
372
+ '<td><span class="badge b-'+esc(r.sentiment)+'">'+esc(r.sentiment)+'</span></td>'+
373
+ '<td class="intent">'+esc(r.intent)+'</td>'+
374
+ '<td class="conf">'+c+'%<div class="bar2"><i style="width:'+c+'%"></i></div></td>'+
375
+ '<td class="reason">'+esc(r.reason)+'</td>'+
376
+ '<td class="ev">'+(r.evidence?'&ldquo;'+esc(r.evidence)+'&rdquo;':'')+'</td></tr>';}).join('');
377
+ }
378
+ function download(){if(!last)return;const cols=['text','sentiment','intent','confidence','reason','evidence'];
379
+ const q=s=>'"'+String(s==null?'':s).replace(/"/g,'""')+'"';
380
+ const rows=[cols.join(',')].concat(last.results.map(r=>cols.map(c=>q(r[c])).join(',')));
381
+ const blob=new Blob([rows.join("\\n")],{type:'text/csv'});const a=document.createElement('a');
382
+ a.href=URL.createObjectURL(blob);a.download='feedback_analyzed.csv';a.click();}
383
+ </script></body></html>"""
384
+
385
+ if __name__ == "__main__":
386
+ backend = "Claude API" if ANTHROPIC_KEY else f"Ollama ({OLLAMA_MODEL} @ {OLLAMA_URL})"
387
+ print(f"Feedback Analyzer on http://127.0.0.1:{PORT} [backend: {backend}]", flush=True)
388
+ app.run(host="0.0.0.0", port=PORT, threaded=True)