gonalbz commited on
Commit
4b624a7
·
1 Parent(s): 1379bdf
README.md CHANGED
@@ -1,12 +1,3 @@
1
- ---
2
- title: Aceup Transcript Analysis
3
- colorFrom: blue
4
- colorTo: gray
5
- sdk: docker
6
- app_port: 7860
7
- short_description: Transcript analysis API and Gradio interface.
8
- ---
9
-
10
  # ml-tech-assessment
11
 
12
  ## Environment Setup
@@ -37,12 +28,10 @@ short_description: Transcript analysis API and Gradio interface.
37
  ## Environment Variables
38
 
39
  1. Create a `.env` file in the root directory of the project
40
- 2. Copy the contents of the provided `.env` file into your local `.env` file
41
-
42
- Required values:
43
 
44
- ```bash
45
- OPENAI_API_KEY=<your-openai-api-key>
46
  OPENAI_MODEL=gpt-4o-2024-08-06
47
  ```
48
 
@@ -91,7 +80,7 @@ http://127.0.0.1:8000/docs
91
  The Gradio frontend is available at:
92
 
93
  ```text
94
- http://127.0.0.1:8000
95
  ```
96
 
97
  Analyze one transcript:
@@ -117,25 +106,6 @@ curl -X POST "http://127.0.0.1:8000/analyses/batch" \
117
 
118
  Analysis results are stored in memory, so they reset when the API process restarts.
119
 
120
- ## Hugging Face Spaces Deployment
121
-
122
- This repository is configured as a Docker Space. The container serves FastAPI and the mounted Gradio UI through Uvicorn on port `7860`. Hugging Face opens the Space at `/`, where the Gradio app is served directly.
123
-
124
- Set `OPENAI_API_KEY` as a Hugging Face Space Secret. Optionally set `OPENAI_MODEL` as a Space Variable if you want to override the default model.
125
-
126
- Build and run the container locally:
127
-
128
- ```bash
129
- docker build -t aceup-transcript-analysis .
130
- docker run --rm -p 7860:7860 --env-file .env aceup-transcript-analysis
131
- ```
132
-
133
- Then open:
134
-
135
- ```text
136
- http://127.0.0.1:7860
137
- ```
138
-
139
  ## OpenAI Adapter Integration Test
140
 
141
  The live OpenAI adapter test is skipped by default so local test runs do not require network access or credentials. To run it explicitly:
 
 
 
 
 
 
 
 
 
 
1
  # ml-tech-assessment
2
 
3
  ## Environment Setup
 
28
  ## Environment Variables
29
 
30
  1. Create a `.env` file in the root directory of the project
31
+ 2. Add the OpenAI API key and optional model:
 
 
32
 
33
+ ```env
34
+ OPENAI_API_KEY=your-openai-api-key
35
  OPENAI_MODEL=gpt-4o-2024-08-06
36
  ```
37
 
 
80
  The Gradio frontend is available at:
81
 
82
  ```text
83
+ http://127.0.0.1:8000/
84
  ```
85
 
86
  Analyze one transcript:
 
106
 
107
  Analysis results are stored in memory, so they reset when the API process restarts.
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  ## OpenAI Adapter Integration Test
110
 
111
  The live OpenAI adapter test is skipped by default so local test runs do not require network access or credentials. To run it explicitly:
app/configurations.py CHANGED
@@ -1,10 +1,17 @@
 
 
1
  import pydantic_settings
2
 
3
 
 
 
 
4
  class EnvConfigs(pydantic_settings.BaseSettings):
5
- model_config =pydantic_settings.SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
 
 
 
6
 
7
  OPENAI_API_KEY: str
8
  OPENAI_MODEL: str = "gpt-4o-2024-08-06"
9
 
10
-
 
1
+ from pathlib import Path
2
+
3
  import pydantic_settings
4
 
5
 
6
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
7
+
8
+
9
  class EnvConfigs(pydantic_settings.BaseSettings):
10
+ model_config = pydantic_settings.SettingsConfigDict(
11
+ env_file=PROJECT_ROOT / ".env",
12
+ env_file_encoding="utf-8",
13
+ )
14
 
15
  OPENAI_API_KEY: str
16
  OPENAI_MODEL: str = "gpt-4o-2024-08-06"
17
 
 
app/errors.py CHANGED
@@ -10,5 +10,9 @@ class AnalysisNotFoundError(TranscriptAnalysisError):
10
  pass
11
 
12
 
 
 
 
 
13
  class LLMCompletionError(TranscriptAnalysisError):
14
  pass
 
10
  pass
11
 
12
 
13
+ class ConfigurationError(TranscriptAnalysisError):
14
+ pass
15
+
16
+
17
  class LLMCompletionError(TranscriptAnalysisError):
18
  pass
app/frontend.py CHANGED
@@ -1,13 +1,90 @@
1
  from collections.abc import Callable
 
2
 
3
  import gradio as gr
4
 
5
  from app.domain import TranscriptAnalysis
6
- from app.errors import AnalysisNotFoundError, InvalidTranscriptError, LLMCompletionError
 
 
 
 
 
7
  from app.services import TranscriptAnalysisService
8
 
9
  ServiceFactory = Callable[[], TranscriptAnalysisService]
10
  FrontendResult = tuple[str, str, str]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
 
13
  def build_gradio_app(service_factory: ServiceFactory) -> gr.Blocks:
@@ -22,65 +99,221 @@ def build_gradio_app(service_factory: ServiceFactory) -> gr.Blocks:
22
  )
23
  analyze_button = gr.Button("Analyze", variant="primary")
24
 
25
- analysis_id_output = gr.Textbox(label="Analysis ID", interactive=False)
26
- summary_output = gr.Textbox(label="Summary", lines=5, interactive=False)
27
- action_items_output = gr.Textbox(
28
- label="Suggested Next Steps",
29
- lines=6,
30
- interactive=False,
31
  )
 
 
 
 
 
 
32
 
33
- analyze_button.click(
34
- fn=lambda transcript: analyze_transcript(transcript, service_factory),
 
 
 
 
 
 
 
 
35
  inputs=transcript_input,
36
- outputs=[analysis_id_output, summary_output, action_items_output],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  )
38
 
39
  with gr.Tab("Lookup"):
40
  analysis_id_input = gr.Textbox(label="Analysis ID")
41
  lookup_button = gr.Button("Lookup", variant="primary")
42
 
43
- lookup_id_output = gr.Textbox(label="Analysis ID", interactive=False)
44
- lookup_summary_output = gr.Textbox(label="Summary", lines=5, interactive=False)
45
- lookup_action_items_output = gr.Textbox(
46
- label="Suggested Next Steps",
47
- lines=6,
48
- interactive=False,
49
  )
 
 
 
 
 
 
50
 
51
- lookup_button.click(
52
- fn=lambda analysis_id: lookup_analysis(analysis_id, service_factory),
 
 
 
 
 
 
 
 
53
  inputs=analysis_id_input,
54
- outputs=[lookup_id_output, lookup_summary_output, lookup_action_items_output],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  )
56
 
57
  return frontend
58
 
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  def analyze_transcript(transcript: str, service_factory: ServiceFactory) -> FrontendResult:
61
  try:
62
  analysis = service_factory().analyze(transcript)
63
- except (InvalidTranscriptError, LLMCompletionError) as exc:
64
  raise gr.Error(str(exc)) from exc
65
 
66
  return format_analysis(analysis)
67
 
68
 
 
 
 
 
 
 
 
69
  def lookup_analysis(analysis_id: str, service_factory: ServiceFactory) -> FrontendResult:
70
  try:
71
  analysis = service_factory().get(analysis_id.strip())
72
- except (AnalysisNotFoundError, InvalidTranscriptError, LLMCompletionError) as exc:
73
  raise gr.Error(str(exc)) from exc
74
 
75
  return format_analysis(analysis)
76
 
77
 
 
 
 
 
 
 
 
 
 
 
 
78
  def format_analysis(analysis: TranscriptAnalysis) -> FrontendResult:
79
  return analysis.id, analysis.summary, format_action_items(analysis.action_items)
80
 
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  def format_action_items(action_items: tuple[str, ...]) -> str:
83
  if not action_items:
84
  return "No suggested next steps returned."
85
 
86
- return "\n".join(f"{index}. {action_item}" for index, action_item in enumerate(action_items, start=1))
 
 
 
1
  from collections.abc import Callable
2
+ from html import escape
3
 
4
  import gradio as gr
5
 
6
  from app.domain import TranscriptAnalysis
7
+ from app.errors import (
8
+ AnalysisNotFoundError,
9
+ ConfigurationError,
10
+ InvalidTranscriptError,
11
+ LLMCompletionError,
12
+ )
13
  from app.services import TranscriptAnalysisService
14
 
15
  ServiceFactory = Callable[[], TranscriptAnalysisService]
16
  FrontendResult = tuple[str, str, str]
17
+ LoadingFrontendResult = tuple[dict[str, object], dict[str, object], str, dict[str, object]]
18
+ AnalysisFrontendResult = tuple[dict[str, object], str]
19
+ CleanupFrontendResult = tuple[dict[str, object], dict[str, object]]
20
+ FailureFrontendResult = tuple[dict[str, object], dict[str, object], str, dict[str, object]]
21
+
22
+ APP_CSS = """
23
+ .analysis-status {
24
+ margin-top: 0.75rem;
25
+ width: 100%;
26
+ }
27
+
28
+ .analysis-status__content {
29
+ align-items: center;
30
+ background: var(--background-fill-secondary);
31
+ border: 1px solid var(--border-color-primary);
32
+ border-radius: 8px;
33
+ color: var(--body-text-color);
34
+ display: flex;
35
+ font-size: 0.95rem;
36
+ gap: 0.65rem;
37
+ line-height: 1.35;
38
+ min-height: 2.75rem;
39
+ padding: 0.7rem 0.9rem;
40
+ white-space: normal;
41
+ width: 100%;
42
+ }
43
+
44
+ .analysis-status__spinner {
45
+ animation: analysis-spin 0.8s linear infinite;
46
+ border: 2px solid var(--border-color-primary);
47
+ border-top-color: var(--button-primary-background-fill);
48
+ border-radius: 999px;
49
+ flex: 0 0 auto;
50
+ height: 1rem;
51
+ width: 1rem;
52
+ }
53
+
54
+ .analysis-status--error .analysis-status__content {
55
+ border-color: var(--error-border-color, #d33);
56
+ color: var(--error-text-color, #b00020);
57
+ }
58
+
59
+ @keyframes analysis-spin {
60
+ to {
61
+ transform: rotate(360deg);
62
+ }
63
+ }
64
+
65
+ .analysis-result {
66
+ background: var(--background-fill-primary);
67
+ border: 1px solid var(--border-color-primary);
68
+ border-radius: 8px;
69
+ margin-top: 1rem;
70
+ padding: 1rem 1.125rem;
71
+ }
72
+
73
+ .analysis-result h3 {
74
+ font-size: 1rem;
75
+ font-weight: 650;
76
+ margin: 1rem 0 0.35rem;
77
+ }
78
+
79
+ .analysis-result h3:first-child {
80
+ margin-top: 0;
81
+ }
82
+
83
+ .analysis-result p,
84
+ .analysis-result ol {
85
+ margin-top: 0;
86
+ }
87
+ """
88
 
89
 
90
  def build_gradio_app(service_factory: ServiceFactory) -> gr.Blocks:
 
99
  )
100
  analyze_button = gr.Button("Analyze", variant="primary")
101
 
102
+ analysis_status_output = gr.HTML(
103
+ visible=False,
104
+ elem_classes=["analysis-status"],
 
 
 
105
  )
106
+ with gr.Group(visible=False, elem_classes=["analysis-result"]) as analysis_result_group:
107
+ analysis_result_output = gr.Markdown(
108
+ container=False,
109
+ line_breaks=True,
110
+ buttons=["copy"],
111
+ )
112
 
113
+ analyze_event = analyze_button.click(
114
+ fn=show_analyze_loading,
115
+ outputs=[
116
+ analysis_status_output,
117
+ analysis_result_group,
118
+ analysis_result_output,
119
+ analyze_button,
120
+ ],
121
+ ).then(
122
+ fn=lambda transcript: analyze_transcript_for_ui(transcript, service_factory),
123
  inputs=transcript_input,
124
+ outputs=[
125
+ analysis_result_group,
126
+ analysis_result_output,
127
+ ],
128
+ )
129
+ analyze_event.success(
130
+ fn=hide_analyze_loading,
131
+ outputs=[analysis_status_output, analyze_button],
132
+ )
133
+ analyze_event.failure(
134
+ fn=show_analyze_failure,
135
+ outputs=[
136
+ analysis_status_output,
137
+ analysis_result_group,
138
+ analysis_result_output,
139
+ analyze_button,
140
+ ],
141
  )
142
 
143
  with gr.Tab("Lookup"):
144
  analysis_id_input = gr.Textbox(label="Analysis ID")
145
  lookup_button = gr.Button("Lookup", variant="primary")
146
 
147
+ lookup_status_output = gr.HTML(
148
+ visible=False,
149
+ elem_classes=["analysis-status"],
 
 
 
150
  )
151
+ with gr.Group(visible=False, elem_classes=["analysis-result"]) as lookup_result_group:
152
+ lookup_result_output = gr.Markdown(
153
+ container=False,
154
+ line_breaks=True,
155
+ buttons=["copy"],
156
+ )
157
 
158
+ lookup_event = lookup_button.click(
159
+ fn=show_lookup_loading,
160
+ outputs=[
161
+ lookup_status_output,
162
+ lookup_result_group,
163
+ lookup_result_output,
164
+ lookup_button,
165
+ ],
166
+ ).then(
167
+ fn=lambda analysis_id: lookup_analysis_for_ui(analysis_id, service_factory),
168
  inputs=analysis_id_input,
169
+ outputs=[
170
+ lookup_result_group,
171
+ lookup_result_output,
172
+ ],
173
+ )
174
+ lookup_event.success(
175
+ fn=hide_lookup_loading,
176
+ outputs=[lookup_status_output, lookup_button],
177
+ )
178
+ lookup_event.failure(
179
+ fn=show_lookup_failure,
180
+ outputs=[
181
+ lookup_status_output,
182
+ lookup_result_group,
183
+ lookup_result_output,
184
+ lookup_button,
185
+ ],
186
  )
187
 
188
  return frontend
189
 
190
 
191
+ def show_analyze_loading() -> LoadingFrontendResult:
192
+ return show_loading("Analyzing transcript...", "Analyzing...")
193
+
194
+
195
+ def show_lookup_loading() -> LoadingFrontendResult:
196
+ return show_loading("Looking up analysis...", "Looking up...")
197
+
198
+
199
+ def show_loading(message: str, button_label: str) -> LoadingFrontendResult:
200
+ return (
201
+ gr.update(value=format_status_html(message), visible=True),
202
+ gr.update(visible=False),
203
+ "",
204
+ gr.update(value=button_label, interactive=False),
205
+ )
206
+
207
+
208
+ def hide_analyze_loading() -> CleanupFrontendResult:
209
+ return hide_loading("Analyze")
210
+
211
+
212
+ def hide_lookup_loading() -> CleanupFrontendResult:
213
+ return hide_loading("Lookup")
214
+
215
+
216
+ def hide_loading(button_label: str) -> CleanupFrontendResult:
217
+ return gr.update(value="", visible=False), gr.update(value=button_label, interactive=True)
218
+
219
+
220
+ def show_analyze_failure() -> FailureFrontendResult:
221
+ return show_failure("Analyze")
222
+
223
+
224
+ def show_lookup_failure() -> FailureFrontendResult:
225
+ return show_failure("Lookup")
226
+
227
+
228
+ def show_failure(button_label: str) -> FailureFrontendResult:
229
+ return (
230
+ gr.update(
231
+ value=format_status_html("Could not complete the request.", is_error=True),
232
+ visible=True,
233
+ ),
234
+ gr.update(visible=False),
235
+ "",
236
+ gr.update(value=button_label, interactive=True),
237
+ )
238
+
239
+
240
+ def analyze_transcript_for_ui(
241
+ transcript: str,
242
+ service_factory: ServiceFactory,
243
+ ) -> AnalysisFrontendResult:
244
+ return show_result(*analyze_transcript(transcript, service_factory))
245
+
246
+
247
  def analyze_transcript(transcript: str, service_factory: ServiceFactory) -> FrontendResult:
248
  try:
249
  analysis = service_factory().analyze(transcript)
250
+ except (ConfigurationError, InvalidTranscriptError, LLMCompletionError) as exc:
251
  raise gr.Error(str(exc)) from exc
252
 
253
  return format_analysis(analysis)
254
 
255
 
256
+ def lookup_analysis_for_ui(
257
+ analysis_id: str,
258
+ service_factory: ServiceFactory,
259
+ ) -> AnalysisFrontendResult:
260
+ return show_result(*lookup_analysis(analysis_id, service_factory))
261
+
262
+
263
  def lookup_analysis(analysis_id: str, service_factory: ServiceFactory) -> FrontendResult:
264
  try:
265
  analysis = service_factory().get(analysis_id.strip())
266
+ except (AnalysisNotFoundError, ConfigurationError, InvalidTranscriptError, LLMCompletionError) as exc:
267
  raise gr.Error(str(exc)) from exc
268
 
269
  return format_analysis(analysis)
270
 
271
 
272
+ def show_result(
273
+ analysis_id: str,
274
+ summary: str,
275
+ action_items: str,
276
+ ) -> AnalysisFrontendResult:
277
+ return (
278
+ gr.update(visible=True),
279
+ format_result_markdown(analysis_id, summary, action_items),
280
+ )
281
+
282
+
283
  def format_analysis(analysis: TranscriptAnalysis) -> FrontendResult:
284
  return analysis.id, analysis.summary, format_action_items(analysis.action_items)
285
 
286
 
287
+ def format_status_html(message: str, is_error: bool = False) -> str:
288
+ status_class = "analysis-status--error" if is_error else ""
289
+ spinner = "" if is_error else '<span class="analysis-status__spinner" aria-hidden="true"></span>'
290
+ role = "alert" if is_error else "status"
291
+
292
+ return (
293
+ f'<div class="{status_class}">'
294
+ f'<div class="analysis-status__content" role="{role}">'
295
+ f"{spinner}<span>{escape(message)}</span>"
296
+ "</div>"
297
+ "</div>"
298
+ )
299
+
300
+
301
+ def format_result_markdown(analysis_id: str, summary: str, action_items: str) -> str:
302
+ return f"""### Analysis ID
303
+ `{analysis_id}`
304
+
305
+ ### Summary
306
+ {summary}
307
+
308
+ ### Suggested Next Steps
309
+ {action_items}
310
+ """
311
+
312
+
313
  def format_action_items(action_items: tuple[str, ...]) -> str:
314
  if not action_items:
315
  return "No suggested next steps returned."
316
 
317
+ return "\n".join(
318
+ f"{index}. {action_item}" for index, action_item in enumerate(action_items, start=1)
319
+ )
app/main.py CHANGED
@@ -4,6 +4,7 @@ from typing import Annotated
4
  from fastapi import Depends, FastAPI, Query, Request
5
  from fastapi.responses import JSONResponse
6
  import gradio as gr
 
7
 
8
  from app.adapters.openai import OpenAIAdapter
9
  from app.configurations import EnvConfigs
@@ -13,8 +14,13 @@ from app.schemas import (
13
  BatchTranscriptAnalysisResponse,
14
  TranscriptAnalysisResponse,
15
  )
16
- from app.errors import AnalysisNotFoundError, InvalidTranscriptError, LLMCompletionError
17
- from app.frontend import build_gradio_app
 
 
 
 
 
18
  from app.ports import LLm
19
  from app.repositories import InMemoryTranscriptAnalysisRepository, TranscriptAnalysisRepository
20
  from app.services import TranscriptAnalysisService
@@ -30,7 +36,12 @@ _repository = InMemoryTranscriptAnalysisRepository()
30
 
31
  @lru_cache
32
  def get_env_configs() -> EnvConfigs:
33
- return EnvConfigs()
 
 
 
 
 
34
 
35
 
36
  @lru_cache
@@ -64,6 +75,11 @@ async def analysis_not_found_handler(_: Request, exc: AnalysisNotFoundError) ->
64
  return JSONResponse(status_code=404, content={"detail": str(exc)})
65
 
66
 
 
 
 
 
 
67
  @app.exception_handler(LLMCompletionError)
68
  async def llm_completion_handler(_: Request, exc: LLMCompletionError) -> JSONResponse:
69
  return JSONResponse(status_code=502, content={"detail": str(exc)})
@@ -96,4 +112,9 @@ async def analyze_transcripts_batch(
96
  return to_batch_transcript_analysis_response(analyses)
97
 
98
 
99
- app = gr.mount_gradio_app(app, build_gradio_app(get_gradio_analysis_service), path="/")
 
 
 
 
 
 
4
  from fastapi import Depends, FastAPI, Query, Request
5
  from fastapi.responses import JSONResponse
6
  import gradio as gr
7
+ from pydantic import ValidationError
8
 
9
  from app.adapters.openai import OpenAIAdapter
10
  from app.configurations import EnvConfigs
 
14
  BatchTranscriptAnalysisResponse,
15
  TranscriptAnalysisResponse,
16
  )
17
+ from app.errors import (
18
+ AnalysisNotFoundError,
19
+ ConfigurationError,
20
+ InvalidTranscriptError,
21
+ LLMCompletionError,
22
+ )
23
+ from app.frontend import APP_CSS, build_gradio_app
24
  from app.ports import LLm
25
  from app.repositories import InMemoryTranscriptAnalysisRepository, TranscriptAnalysisRepository
26
  from app.services import TranscriptAnalysisService
 
36
 
37
  @lru_cache
38
  def get_env_configs() -> EnvConfigs:
39
+ try:
40
+ return EnvConfigs()
41
+ except ValidationError as exc:
42
+ raise ConfigurationError(
43
+ "OPENAI_API_KEY is not configured. Set it in .env or as an environment variable."
44
+ ) from exc
45
 
46
 
47
  @lru_cache
 
75
  return JSONResponse(status_code=404, content={"detail": str(exc)})
76
 
77
 
78
+ @app.exception_handler(ConfigurationError)
79
+ async def configuration_error_handler(_: Request, exc: ConfigurationError) -> JSONResponse:
80
+ return JSONResponse(status_code=503, content={"detail": str(exc)})
81
+
82
+
83
  @app.exception_handler(LLMCompletionError)
84
  async def llm_completion_handler(_: Request, exc: LLMCompletionError) -> JSONResponse:
85
  return JSONResponse(status_code=502, content={"detail": str(exc)})
 
112
  return to_batch_transcript_analysis_response(analyses)
113
 
114
 
115
+ app = gr.mount_gradio_app(
116
+ app,
117
+ build_gradio_app(get_gradio_analysis_service),
118
+ path="/",
119
+ css=APP_CSS,
120
+ )
tests/api/test_transcript_analysis_api.py CHANGED
@@ -2,6 +2,7 @@ from pydantic import BaseModel
2
  import pytest
3
  from fastapi.testclient import TestClient
4
 
 
5
  from app.main import app, get_analysis_service
6
  from app.ports import LLm
7
  from app.repositories import InMemoryTranscriptAnalysisRepository
@@ -96,7 +97,27 @@ def test_analyze_batch_rejects_empty_transcript(client: TestClient) -> None:
96
  assert response.json()["detail"] == "Transcript cannot be empty."
97
 
98
 
99
- def test_gradio_ui_is_mounted_at_root(client: TestClient) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  response = client.get("/", follow_redirects=True)
101
 
102
  assert response.status_code == 200
 
2
  import pytest
3
  from fastapi.testclient import TestClient
4
 
5
+ from app.errors import ConfigurationError
6
  from app.main import app, get_analysis_service
7
  from app.ports import LLm
8
  from app.repositories import InMemoryTranscriptAnalysisRepository
 
97
  assert response.json()["detail"] == "Transcript cannot be empty."
98
 
99
 
100
+ def test_configuration_error_returns_503() -> None:
101
+ def raise_configuration_error() -> TranscriptAnalysisService:
102
+ raise ConfigurationError(
103
+ "OPENAI_API_KEY is not configured. Set it in .env or as an environment variable."
104
+ )
105
+
106
+ app.dependency_overrides[get_analysis_service] = raise_configuration_error
107
+
108
+ try:
109
+ with TestClient(app) as test_client:
110
+ response = test_client.get("/analyses", params={"transcript": "Discuss rollout plan."})
111
+ finally:
112
+ app.dependency_overrides.clear()
113
+
114
+ assert response.status_code == 503
115
+ assert response.json()["detail"] == (
116
+ "OPENAI_API_KEY is not configured. Set it in .env or as an environment variable."
117
+ )
118
+
119
+
120
+ def test_gradio_ui_is_mounted(client: TestClient) -> None:
121
  response = client.get("/", follow_redirects=True)
122
 
123
  assert response.status_code == 200
tests/frontend/test_gradio_frontend.py CHANGED
@@ -2,8 +2,18 @@ import gradio as gr
2
  import pytest
3
 
4
  from app.domain import TranscriptAnalysis
5
- from app.errors import AnalysisNotFoundError, InvalidTranscriptError
6
- from app.frontend import analyze_transcript, format_action_items, lookup_analysis
 
 
 
 
 
 
 
 
 
 
7
 
8
 
9
  class FakeService:
@@ -25,6 +35,14 @@ class FakeService:
25
  return self.analysis
26
 
27
 
 
 
 
 
 
 
 
 
28
  def test_analyze_transcript_returns_formatted_result() -> None:
29
  result = analyze_transcript("Discuss roadmap.", FakeService)
30
 
@@ -35,6 +53,18 @@ def test_analyze_transcript_returns_formatted_result() -> None:
35
  )
36
 
37
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  def test_lookup_analysis_returns_formatted_result() -> None:
39
  result = lookup_analysis(" analysis-1 ", FakeService)
40
 
@@ -45,6 +75,53 @@ def test_lookup_analysis_returns_formatted_result() -> None:
45
  )
46
 
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  def test_analyze_transcript_maps_empty_input_to_gradio_error() -> None:
49
  with pytest.raises(gr.Error, match="Transcript cannot be empty."):
50
  analyze_transcript(" ", FakeService)
@@ -55,5 +132,10 @@ def test_lookup_analysis_maps_missing_id_to_gradio_error() -> None:
55
  lookup_analysis("missing-id", FakeService)
56
 
57
 
 
 
 
 
 
58
  def test_format_action_items_handles_empty_list() -> None:
59
  assert format_action_items(()) == "No suggested next steps returned."
 
2
  import pytest
3
 
4
  from app.domain import TranscriptAnalysis
5
+ from app.errors import AnalysisNotFoundError, ConfigurationError, InvalidTranscriptError
6
+ from app.frontend import (
7
+ analyze_transcript,
8
+ analyze_transcript_for_ui,
9
+ format_action_items,
10
+ format_status_html,
11
+ hide_lookup_loading,
12
+ lookup_analysis,
13
+ lookup_analysis_for_ui,
14
+ show_analyze_failure,
15
+ show_analyze_loading,
16
+ )
17
 
18
 
19
  class FakeService:
 
35
  return self.analysis
36
 
37
 
38
+ class MisconfiguredService:
39
+ def analyze(self, transcript: str) -> TranscriptAnalysis:
40
+ raise ConfigurationError("OPENAI_API_KEY is not configured.")
41
+
42
+ def get(self, analysis_id: str) -> TranscriptAnalysis:
43
+ raise ConfigurationError("OPENAI_API_KEY is not configured.")
44
+
45
+
46
  def test_analyze_transcript_returns_formatted_result() -> None:
47
  result = analyze_transcript("Discuss roadmap.", FakeService)
48
 
 
53
  )
54
 
55
 
56
+ def test_analyze_transcript_for_ui_reveals_result_group() -> None:
57
+ result_visibility, result_markdown = analyze_transcript_for_ui(
58
+ "Discuss roadmap.",
59
+ FakeService,
60
+ )
61
+
62
+ assert result_visibility["visible"] is True
63
+ assert "### Analysis ID\n`analysis-1`" in result_markdown
64
+ assert "### Summary\nThe team aligned on next steps." in result_markdown
65
+ assert "### Suggested Next Steps\n1. Confirm owner\n2. Set deadline" in result_markdown
66
+
67
+
68
  def test_lookup_analysis_returns_formatted_result() -> None:
69
  result = lookup_analysis(" analysis-1 ", FakeService)
70
 
 
75
  )
76
 
77
 
78
+ def test_show_analyze_loading_sets_status_and_hides_results() -> None:
79
+ status_visibility, result_visibility, result_markdown, button_state = show_analyze_loading()
80
+
81
+ assert status_visibility["visible"] is True
82
+ assert "Analyzing transcript..." in status_visibility["value"]
83
+ assert result_visibility["visible"] is False
84
+ assert result_markdown == ""
85
+ assert button_state["value"] == "Analyzing..."
86
+ assert button_state["interactive"] is False
87
+
88
+
89
+ def test_lookup_analysis_for_ui_reveals_result_group() -> None:
90
+ result_visibility, result_markdown = lookup_analysis_for_ui(" analysis-1 ", FakeService)
91
+
92
+ assert result_visibility["visible"] is True
93
+ assert "### Analysis ID\n`analysis-1`" in result_markdown
94
+ assert "### Summary\nThe team aligned on next steps." in result_markdown
95
+ assert "### Suggested Next Steps\n1. Confirm owner\n2. Set deadline" in result_markdown
96
+
97
+
98
+ def test_hide_lookup_loading_clears_status_and_restores_button() -> None:
99
+ status_visibility, button_state = hide_lookup_loading()
100
+
101
+ assert status_visibility["visible"] is False
102
+ assert status_visibility["value"] == ""
103
+ assert button_state["value"] == "Lookup"
104
+ assert button_state["interactive"] is True
105
+
106
+
107
+ def test_show_analyze_failure_sets_error_status_and_restores_button() -> None:
108
+ status_visibility, result_visibility, result_markdown, button_state = show_analyze_failure()
109
+
110
+ assert status_visibility["visible"] is True
111
+ assert "Could not complete the request." in status_visibility["value"]
112
+ assert result_visibility["visible"] is False
113
+ assert result_markdown == ""
114
+ assert button_state["value"] == "Analyze"
115
+ assert button_state["interactive"] is True
116
+
117
+
118
+ def test_format_status_html_escapes_message() -> None:
119
+ status_html = format_status_html("<script>alert('x')</script>")
120
+
121
+ assert "&lt;script&gt;alert(&#x27;x&#x27;)&lt;/script&gt;" in status_html
122
+ assert "<script>" not in status_html
123
+
124
+
125
  def test_analyze_transcript_maps_empty_input_to_gradio_error() -> None:
126
  with pytest.raises(gr.Error, match="Transcript cannot be empty."):
127
  analyze_transcript(" ", FakeService)
 
132
  lookup_analysis("missing-id", FakeService)
133
 
134
 
135
+ def test_analyze_transcript_maps_configuration_errors_to_gradio_error() -> None:
136
+ with pytest.raises(gr.Error, match="OPENAI_API_KEY is not configured."):
137
+ analyze_transcript("Discuss roadmap.", MisconfiguredService)
138
+
139
+
140
  def test_format_action_items_handles_empty_list() -> None:
141
  assert format_action_items(()) == "No suggested next steps returned."