Aryan Mishra commited on
Commit
6b71335
Β·
1 Parent(s): d0a5e7c

Phase 3: HTMX Predict Page

Browse files
api/app/routes/pages.py CHANGED
@@ -109,3 +109,37 @@ async def monitor_page(request: Request) -> HTMLResponse:
109
  "pages/monitor.html",
110
  _base_ctx(request, "System Monitor"),
111
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  "pages/monitor.html",
110
  _base_ctx(request, "System Monitor"),
111
  )
112
+
113
+ # ── Phase 3 HTMX Endpoints ───────────────────────────────────────────────────
114
+
115
+ from fastapi import Depends, Form
116
+ from sqlalchemy.orm import Session
117
+ from api.app.middleware.dependencies import get_db
118
+ from api.app.schemas.schemas import ReviewInput
119
+ from api.app.routes.predict import predict as predict_json
120
+
121
+ @router.post("/predict/fragment", response_class=HTMLResponse)
122
+ async def predict_fragment(
123
+ request: Request,
124
+ text: str = Form(...),
125
+ language: str = Form("auto"),
126
+ db: Session = Depends(get_db)
127
+ ) -> HTMLResponse:
128
+ """
129
+ Phase 3: HTMX partial for the Predict page.
130
+ Calls the EXACT SAME prediction logic as the JSON API.
131
+ """
132
+ try:
133
+ if language == "auto":
134
+ language = None
135
+
136
+ prediction = await predict_json(ReviewInput(text=text, language=language), db)
137
+ return templates.TemplateResponse(
138
+ "partials/predict_result.html",
139
+ {"request": request, "result": prediction, "error": None}
140
+ )
141
+ except Exception as e:
142
+ return templates.TemplateResponse(
143
+ "partials/predict_result.html",
144
+ {"request": request, "result": None, "error": str(e)}
145
+ )
api/app/templates/pages/predict.html CHANGED
@@ -32,44 +32,53 @@
32
 
33
  {# ── Left: Input panel ───────────────────────────────────────────────── #}
34
  <div class="lg:col-span-7 flex flex-col">
35
- <div class="card-low flex flex-col h-full min-h-[420px]">
 
 
 
36
 
37
  <div class="flex justify-between items-center mb-lg pb-md border-b border-white/[0.06]">
38
  <h3 class="font-mono text-label-md text-[#c7c4d7] uppercase tracking-wider">
39
  Analyze Input
40
  </h3>
41
- {# Language selector placeholder β€” wired up in Phase 3 #}
42
  <div class="flex items-center gap-2">
43
- <label for="lang-select-preview"
44
- class="font-mono text-label-sm text-[#c7c4d7]">Language</label>
45
- <select id="lang-select-preview" class="input-base py-1 text-body-sm" style="width:auto;" disabled>
46
- <option>Auto-detect</option>
 
47
  </select>
48
  </div>
49
  </div>
50
 
51
- {# Textarea placeholder #}
52
  <div class="flex-1 flex flex-col mb-lg">
53
- <label for="review-text-preview"
54
- class="font-mono text-label-sm text-[#c7c4d7] mb-2">Source Text</label>
55
- <textarea id="review-text-preview"
 
 
56
  class="input-base flex-1 min-h-[260px] resize-none leading-relaxed"
57
  placeholder="Paste your review, article, or social media post here…"
58
- disabled>
59
  </textarea>
60
  <div class="flex justify-between mt-2">
61
  <span class="font-mono text-label-sm text-[#c7c4d7]/50">⌘ Enter to analyze</span>
62
- <span class="font-mono text-label-sm text-[#c7c4d7]/50">0 / 512</span>
63
  </div>
64
  </div>
65
 
66
- {# Analyze button placeholder #}
67
- <button class="btn-primary" disabled>
68
- {{ ms_icon('bolt', size=16) }}
69
- Analyze
 
 
 
 
 
70
  </button>
71
 
72
- </div>
73
  </div>
74
 
75
  {# ── Right: Results panel ─────────────────────────────────────────────── #}
@@ -92,23 +101,7 @@
92
 
93
  </div>{# /grid #}
94
 
95
- {# Phase 2 notice (removed in Phase 3) #}
96
- <div class="card border-[#c0c1ff]/20 bg-[#c0c1ff]/5">
97
- <div class="flex items-start gap-3">
98
- {{ ms_icon('info', size=18, cls='text-[#c0c1ff] mt-0.5 flex-shrink-0') }}
99
- <div>
100
- <p class="text-body-md text-[#dae2fd] font-medium mb-1">Phase 2 Infrastructure</p>
101
- <p class="text-body-sm text-[#c7c4d7]">
102
- The layout shell is in place. The
103
- <code class="font-mono text-[#c0c1ff] text-xs">POST /predict</code>
104
- API endpoint is fully operational β€” HTMX interactions will be wired in Phase 3.
105
- <a href="/docs#/Predict/predict_predict_post"
106
- target="_blank"
107
- class="text-[#c0c1ff] hover:underline ml-1">Test the API directly β†’</a>
108
- </p>
109
- </div>
110
- </div>
111
- </div>
112
 
113
  </div>
114
  {% endblock %}
 
32
 
33
  {# ── Left: Input panel ───────────────────────────────────────────────── #}
34
  <div class="lg:col-span-7 flex flex-col">
35
+ <form class="card-low flex flex-col h-full min-h-[420px] group"
36
+ hx-post="/predict/fragment"
37
+ hx-target="#result-panel"
38
+ x-data="{ text: '' }">
39
 
40
  <div class="flex justify-between items-center mb-lg pb-md border-b border-white/[0.06]">
41
  <h3 class="font-mono text-label-md text-[#c7c4d7] uppercase tracking-wider">
42
  Analyze Input
43
  </h3>
 
44
  <div class="flex items-center gap-2">
45
+ <label for="lang-select" class="font-mono text-label-sm text-[#c7c4d7]">Language</label>
46
+ <select id="lang-select" name="language" class="input-base py-1 text-body-sm" style="width:auto;">
47
+ <option value="auto">Auto-detect</option>
48
+ <option value="en">English</option>
49
+ <option value="hi">Hindi</option>
50
  </select>
51
  </div>
52
  </div>
53
 
 
54
  <div class="flex-1 flex flex-col mb-lg">
55
+ <label for="review-text" class="font-mono text-label-sm text-[#c7c4d7] mb-2">Source Text</label>
56
+ <textarea id="review-text"
57
+ name="text"
58
+ x-model="text"
59
+ required
60
  class="input-base flex-1 min-h-[260px] resize-none leading-relaxed"
61
  placeholder="Paste your review, article, or social media post here…"
62
+ @keydown.meta.enter="$el.form.dispatchEvent(new Event('submit', {cancelable: true, bubbles: true}))">
63
  </textarea>
64
  <div class="flex justify-between mt-2">
65
  <span class="font-mono text-label-sm text-[#c7c4d7]/50">⌘ Enter to analyze</span>
66
+ <span class="font-mono text-label-sm text-[#c7c4d7]/50" x-text="text.length + ' / 512'">0 / 512</span>
67
  </div>
68
  </div>
69
 
70
+ <button type="submit" class="btn-primary" :disabled="text.length === 0">
71
+ <div class="flex items-center gap-2 group-[.htmx-request]:hidden">
72
+ {{ ms_icon('bolt', size=16) }}
73
+ <span>Analyze</span>
74
+ </div>
75
+ <div class="htmx-indicator items-center gap-2">
76
+ <span class="material-symbols-outlined animate-spin" style="font-size:16px;">autorenew</span>
77
+ <span>Analyzing...</span>
78
+ </div>
79
  </button>
80
 
81
+ </form>
82
  </div>
83
 
84
  {# ── Right: Results panel ─────────────────────────────────────────────── #}
 
101
 
102
  </div>{# /grid #}
103
 
104
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
  </div>
107
  {% endblock %}
api/app/templates/partials/predict_result.html ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% from "macros/ui.html" import ms_icon, sentiment_badge, empty_state %}
2
+
3
+ {% if error %}
4
+ <div class="flex flex-col items-center justify-center gap-4 py-16 text-[#ffb4ab]">
5
+ {{ ms_icon('error', size=48, cls='opacity-80') }}
6
+ <p class="text-sm font-medium">{{ error }}</p>
7
+ </div>
8
+ {% elif result %}
9
+ {# Macro to highlight text based on aspect positions #}
10
+ {% macro render_annotated_text(text, aspects) %}
11
+ {#
12
+ For Phase 3 we do a simplified highlight.
13
+ In a real template, we'd slice the text by start_pos/end_pos.
14
+ For now, we just print the text, as doing complex string slicing in Jinja is hard.
15
+ Wait, we can pass an 'annotated_text' pre-computed from the router, but the prompt says "do NOT duplicate business logic".
16
+ Let's just output the text, or if possible, use JS or a simple replace.
17
+ Actually, the user expects "highlight-positive" etc. from the CSS we wrote in Phase 2.
18
+ #}
19
+ <div class="text-body-lg text-[#dae2fd] leading-relaxed whitespace-pre-wrap">{{ text }}</div>
20
+ {% endmacro %}
21
+
22
+ <div class="flex flex-col h-full w-full animate-fade-in">
23
+
24
+ {# ── Header metrics ── #}
25
+ <div class="flex justify-between items-center mb-md">
26
+ <div class="flex items-center gap-4">
27
+ <div class="flex items-center gap-1.5 font-mono text-label-sm text-[#c7c4d7]">
28
+ {{ ms_icon('language', size=16) }}
29
+ <span>{{ result.detected_language | upper }}</span>
30
+ </div>
31
+ <div class="flex items-center gap-1.5 font-mono text-label-sm text-[#c7c4d7]">
32
+ {{ ms_icon('timer', size=16) }}
33
+ <span>{{ result.processing_time_ms | round }}ms</span>
34
+ </div>
35
+ </div>
36
+ </div>
37
+
38
+ {# ── Annotated Text ── #}
39
+ <div class="p-md bg-[#0b1326] rounded-lg border border-white/[0.06] mb-lg">
40
+ <div class="text-body-lg text-[#dae2fd] leading-relaxed whitespace-pre-wrap" id="annotated-text-container">{{ result.text }}</div>
41
+ </div>
42
+
43
+ {# ── Aspects List ── #}
44
+ <div>
45
+ <h4 class="font-mono text-label-sm text-[#c7c4d7] mb-3 uppercase tracking-wider">Detected Aspects</h4>
46
+ {% if result.aspects %}
47
+ <div class="space-y-2">
48
+ {% for aspect in result.aspects %}
49
+ <div class="flex items-center justify-between p-3 rounded-lg bg-[#222a3d] border border-white/[0.04]">
50
+ <span class="text-body-md text-[#dae2fd] font-medium">{{ aspect.aspect }}</span>
51
+ <div class="flex items-center gap-4">
52
+ <span class="font-mono text-label-sm text-[#c7c4d7]/70">conf: {{ "%.2f"|format(aspect.confidence) }}</span>
53
+ {{ sentiment_badge(aspect.sentiment) }}
54
+ </div>
55
+ </div>
56
+ {% endfor %}
57
+ </div>
58
+ {% else %}
59
+ <div class="p-4 rounded-lg border border-dashed border-white/[0.14] text-center">
60
+ <p class="text-body-sm text-[#c7c4d7]/70">No aspects detected in this text.</p>
61
+ </div>
62
+ {% endif %}
63
+ </div>
64
+
65
+ {# ── Client-side Text Highlighting ── #}
66
+ {# We apply highlights using a small script so we don't need to change backend API #}
67
+ <script>
68
+ (function() {
69
+ const container = document.getElementById('annotated-text-container');
70
+ const text = container.textContent;
71
+ const aspects = {{ result.aspects | tojson | safe }};
72
+
73
+ if (!aspects || aspects.length === 0) return;
74
+
75
+ // Sort aspects by start position descending to not mess up indices when replacing
76
+ aspects.sort((a, b) => b.start - a.start);
77
+
78
+ let html = text;
79
+ aspects.forEach(asp => {
80
+ const cssClass = 'highlight-' + asp.sentiment;
81
+ const before = html.substring(0, asp.start);
82
+ const match = html.substring(asp.start, asp.end);
83
+ const after = html.substring(asp.end);
84
+ html = before + '<span class="' + cssClass + '">' + match + '</span>' + after;
85
+ });
86
+ container.innerHTML = html;
87
+ })();
88
+ </script>
89
+
90
+ </div>
91
+ {% else %}
92
+ {{ empty_state('psychology', 'Enter a review and click Analyze to see results') }}
93
+ {% endif %}
tests/web/test_pages.py CHANGED
@@ -143,7 +143,37 @@ class TestExistingAPIUnchanged:
143
  Re-run the core API assertions to prove Phase 2 changes introduced
144
  zero regressions. These mirror tests/api/test_api.py in spirit.
145
  """
 
 
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  def test_health_endpoint_still_returns_json(self):
148
  with _html_client() as client:
149
  response = client.get("/health")
 
143
  Re-run the core API assertions to prove Phase 2 changes introduced
144
  zero regressions. These mirror tests/api/test_api.py in spirit.
145
  """
146
+ class TestPredictFragment:
147
+ """Verify the new HTMX predict endpoint works exactly like the JSON one."""
148
 
149
+ def test_predict_fragment_english(self):
150
+ with _html_client() as client:
151
+ response = client.post(
152
+ "/predict/fragment",
153
+ data={"text": "The food was great but service was slow.", "language": "en"},
154
+ )
155
+ assert response.status_code == 200
156
+ assert "text/html" in response.headers["content-type"]
157
+ assert "Detected Aspects" in response.text
158
+ # the response might say "No aspects detected" or show the list, but both are valid HTML
159
+ assert "food" in response.text # text should be in the annotated container
160
+
161
+ def test_predict_fragment_auto(self):
162
+ with _html_client() as client:
163
+ response = client.post(
164
+ "/predict/fragment",
165
+ data={"text": "The food was great but service was slow.", "language": "auto"},
166
+ )
167
+ assert response.status_code == 200
168
+ assert "text/html" in response.headers["content-type"]
169
+ assert "EN" in response.text or "English" in response.text or "en" in response.text
170
+
171
+ def test_predict_fragment_empty_text(self):
172
+ with _html_client() as client:
173
+ # Form submission missing 'text' should trigger FastAPI 422
174
+ response = client.post("/predict/fragment", data={"language": "en"})
175
+ # Note: Depending on FastAPI validation, missing Form field might be 422
176
+ assert response.status_code == 422
177
  def test_health_endpoint_still_returns_json(self):
178
  with _html_client() as client:
179
  response = client.get("/health")