gonalbz commited on
Commit ·
62151d3
1
Parent(s): e924c11
init
Browse files- .dockerignore +20 -0
- .gitignore +5 -0
- Dockerfile +29 -0
- README.md +140 -5
- app/__init__.py +0 -0
- app/adapters/__init__.py +0 -0
- app/adapters/openai.py +59 -0
- app/configurations.py +10 -0
- app/domain.py +8 -0
- app/errors.py +14 -0
- app/frontend.py +86 -0
- app/llm_schema.py +6 -0
- app/main.py +104 -0
- app/mappers.py +18 -0
- app/ports/__init__.py +1 -0
- app/ports/llm.py +22 -0
- app/prompts.py +11 -0
- app/repositories.py +26 -0
- app/schemas.py +15 -0
- app/services.py +104 -0
- assessment.md +55 -0
- poetry.lock +0 -0
- pyproject.toml +20 -0
- tests/__init__.py +0 -0
- tests/adapters/__init__.py +0 -0
- tests/adapters/mock_data.py +70 -0
- tests/adapters/test_openai.py +38 -0
- tests/api/test_transcript_analysis_api.py +110 -0
- tests/frontend/test_gradio_frontend.py +59 -0
- tests/services/test_transcript_analysis_service.py +116 -0
.dockerignore
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.gitignore
|
| 3 |
+
.DS_Store
|
| 4 |
+
|
| 5 |
+
__pycache__/
|
| 6 |
+
*.py[cod]
|
| 7 |
+
*.pyo
|
| 8 |
+
|
| 9 |
+
.coverage
|
| 10 |
+
.pytest_cache/
|
| 11 |
+
.mypy_cache/
|
| 12 |
+
.ruff_cache/
|
| 13 |
+
htmlcov/
|
| 14 |
+
|
| 15 |
+
.env
|
| 16 |
+
.env.*
|
| 17 |
+
.venv/
|
| 18 |
+
venv/
|
| 19 |
+
|
| 20 |
+
tests/
|
.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
.venv/
|
| 3 |
+
.pytest_cache/
|
| 4 |
+
__pycache__/
|
| 5 |
+
*.py[cod]
|
Dockerfile
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 4 |
+
PYTHONUNBUFFERED=1 \
|
| 5 |
+
PIP_NO_CACHE_DIR=1 \
|
| 6 |
+
POETRY_VERSION=2.4.1 \
|
| 7 |
+
POETRY_NO_INTERACTION=1 \
|
| 8 |
+
POETRY_VIRTUALENVS_IN_PROJECT=true
|
| 9 |
+
|
| 10 |
+
RUN useradd -m -u 1000 user
|
| 11 |
+
|
| 12 |
+
USER user
|
| 13 |
+
|
| 14 |
+
ENV HOME=/home/user \
|
| 15 |
+
PATH=/home/user/.local/bin:/home/user/app/.venv/bin:$PATH
|
| 16 |
+
|
| 17 |
+
WORKDIR $HOME/app
|
| 18 |
+
|
| 19 |
+
RUN pip install --upgrade pip && \
|
| 20 |
+
pip install "poetry==$POETRY_VERSION"
|
| 21 |
+
|
| 22 |
+
COPY --chown=user pyproject.toml poetry.lock ./
|
| 23 |
+
RUN poetry install --only main --no-root
|
| 24 |
+
|
| 25 |
+
COPY --chown=user app ./app
|
| 26 |
+
|
| 27 |
+
EXPOSE 7860
|
| 28 |
+
|
| 29 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,11 +1,146 @@
|
|
| 1 |
---
|
| 2 |
-
title: Aceup
|
| 3 |
-
emoji: 📉
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: gray
|
| 6 |
sdk: docker
|
| 7 |
-
|
| 8 |
-
|
|
|
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Aceup Transcript Analysis
|
|
|
|
| 3 |
colorFrom: blue
|
| 4 |
colorTo: gray
|
| 5 |
sdk: docker
|
| 6 |
+
app_port: 7860
|
| 7 |
+
base_path: /ui
|
| 8 |
+
short_description: Transcript analysis API and Gradio interface.
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# ml-tech-assessment
|
| 12 |
+
|
| 13 |
+
## Environment Setup
|
| 14 |
+
|
| 15 |
+
### Using Conda (Recommended)
|
| 16 |
+
|
| 17 |
+
1. Install Conda if you haven't already:
|
| 18 |
+
- Download and install [Miniconda](https://docs.conda.io/en/latest/miniconda.html) or [Anaconda](https://www.anaconda.com/products/distribution)
|
| 19 |
+
|
| 20 |
+
2. Create and activate a new conda environment:
|
| 21 |
+
```bash
|
| 22 |
+
conda create -n ml-assessment python=3.12
|
| 23 |
+
conda activate ml-assessment
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
## Installing Poetry and Dependencies
|
| 27 |
+
|
| 28 |
+
1. Install Poetry using pip:
|
| 29 |
+
```bash
|
| 30 |
+
pip install poetry
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
2. Install project dependencies:
|
| 34 |
+
```bash
|
| 35 |
+
poetry install
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
## Environment Variables
|
| 39 |
+
|
| 40 |
+
1. Create a `.env` file in the root directory of the project
|
| 41 |
+
2. Copy the contents of the provided `.env` file into your local `.env` file
|
| 42 |
+
|
| 43 |
+
Required values:
|
| 44 |
+
|
| 45 |
+
```bash
|
| 46 |
+
OPENAI_API_KEY=<your-openai-api-key>
|
| 47 |
+
OPENAI_MODEL=gpt-4o-2024-08-06
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
## Running Tests
|
| 51 |
+
|
| 52 |
+
To run the tests, make sure you have:
|
| 53 |
+
1. Activated your virtual environment
|
| 54 |
+
2. Installed all dependencies using Poetry
|
| 55 |
+
3. Created and populated the `.env` file
|
| 56 |
+
|
| 57 |
+
Then run:
|
| 58 |
+
```bash
|
| 59 |
+
pytest
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
For more detailed test output:
|
| 63 |
+
```bash
|
| 64 |
+
pytest -v
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
For test coverage report:
|
| 68 |
+
```bash
|
| 69 |
+
pytest --cov
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
## Running the API
|
| 73 |
+
|
| 74 |
+
Start the FastAPI application with:
|
| 75 |
+
|
| 76 |
+
```bash
|
| 77 |
+
poetry run uvicorn app.main:app --reload
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
If Poetry is not installed globally but dependencies are already installed in the local virtual environment, run:
|
| 81 |
+
|
| 82 |
+
```bash
|
| 83 |
+
./.venv/bin/uvicorn app.main:app --reload
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
Swagger documentation is available at:
|
| 87 |
+
|
| 88 |
+
```text
|
| 89 |
+
http://127.0.0.1:8000/docs
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
The Gradio frontend is available at:
|
| 93 |
+
|
| 94 |
+
```text
|
| 95 |
+
http://127.0.0.1:8000/ui
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
Analyze one transcript:
|
| 99 |
+
|
| 100 |
+
```bash
|
| 101 |
+
curl -G "http://127.0.0.1:8000/analyses" \
|
| 102 |
+
--data-urlencode "transcript=Discuss the launch plan and assign next steps."
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
Retrieve a stored analysis:
|
| 106 |
+
|
| 107 |
+
```bash
|
| 108 |
+
curl "http://127.0.0.1:8000/analyses/<analysis-id>"
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
Analyze multiple transcripts concurrently:
|
| 112 |
+
|
| 113 |
+
```bash
|
| 114 |
+
curl -X POST "http://127.0.0.1:8000/analyses/batch" \
|
| 115 |
+
-H "Content-Type: application/json" \
|
| 116 |
+
-d '{"transcripts":["Discuss launch risks.","Review onboarding plan."]}'
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
Analysis results are stored in memory, so they reset when the API process restarts.
|
| 120 |
+
|
| 121 |
+
## Hugging Face Spaces Deployment
|
| 122 |
+
|
| 123 |
+
This repository is configured as a Docker Space. The container serves FastAPI and the mounted Gradio UI through Uvicorn on port `7860`, and the Space opens directly at `/ui`.
|
| 124 |
+
|
| 125 |
+
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.
|
| 126 |
+
|
| 127 |
+
Build and run the container locally:
|
| 128 |
+
|
| 129 |
+
```bash
|
| 130 |
+
docker build -t aceup-transcript-analysis .
|
| 131 |
+
docker run --rm -p 7860:7860 --env-file .env aceup-transcript-analysis
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
Then open:
|
| 135 |
+
|
| 136 |
+
```text
|
| 137 |
+
http://127.0.0.1:7860/ui
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
## OpenAI Adapter Integration Test
|
| 141 |
+
|
| 142 |
+
The live OpenAI adapter test is skipped by default so local test runs do not require network access or credentials. To run it explicitly:
|
| 143 |
+
|
| 144 |
+
```bash
|
| 145 |
+
RUN_OPENAI_INTEGRATION_TESTS=1 poetry run pytest tests/adapters/test_openai.py
|
| 146 |
+
```
|
app/__init__.py
ADDED
|
File without changes
|
app/adapters/__init__.py
ADDED
|
File without changes
|
app/adapters/openai.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import openai
|
| 2 |
+
import pydantic
|
| 3 |
+
from app import ports
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class OpenAIAdapter(ports.LLm):
|
| 7 |
+
def __init__(self, api_key: str, model: str) -> None:
|
| 8 |
+
self._model = model
|
| 9 |
+
self._client = openai.OpenAI(api_key=api_key)
|
| 10 |
+
self._aclient = openai.AsyncOpenAI(api_key=api_key)
|
| 11 |
+
|
| 12 |
+
def run_completion(self, system_prompt: str, user_prompt: str, dto: type[pydantic.BaseModel]) -> pydantic.BaseModel:
|
| 13 |
+
"""
|
| 14 |
+
Executes a completion request using the OpenAI API with the provided prompts and response format.
|
| 15 |
+
|
| 16 |
+
Args:
|
| 17 |
+
system_prompt (str): The system's introductory message for the chat.
|
| 18 |
+
user_prompt (str): The user input for which a response is needed.
|
| 19 |
+
dto (Type[pydantic.BaseModel]): A Pydantic model class used to define the structure of the API response.
|
| 20 |
+
|
| 21 |
+
Returns:
|
| 22 |
+
pydantic.BaseModel: An instance of the provided DTO class populated with the API response data.
|
| 23 |
+
more info: https://platform.openai.com/docs/guides/structured-outputs?api-mode=chat
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
completion = self._client.beta.chat.completions.parse(
|
| 27 |
+
model=self._model,
|
| 28 |
+
messages=[
|
| 29 |
+
{"role": "system", "content": system_prompt},
|
| 30 |
+
{"role": "user", "content": user_prompt},
|
| 31 |
+
],
|
| 32 |
+
response_format=dto
|
| 33 |
+
)
|
| 34 |
+
return completion.choices[0].message.parsed
|
| 35 |
+
|
| 36 |
+
async def run_completion_async(self, system_prompt: str, user_prompt: str,
|
| 37 |
+
dto: type[pydantic.BaseModel]) -> pydantic.BaseModel:
|
| 38 |
+
"""
|
| 39 |
+
Executes a completion request using the OpenAI API with the provided prompts and response format.
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
system_prompt (str): The system's introductory message for the chat.
|
| 43 |
+
user_prompt (str): The user input for which a response is needed.
|
| 44 |
+
dto (Type[pydantic.BaseModel]): A Pydantic model class used to define the structure of the API response.
|
| 45 |
+
|
| 46 |
+
Returns:
|
| 47 |
+
pydantic.BaseModel: An instance of the provided DTO class populated with the API response data.
|
| 48 |
+
|
| 49 |
+
more info: https://platform.openai.com/docs/guides/structured-outputs?api-mode=chat
|
| 50 |
+
"""
|
| 51 |
+
completion = await self._aclient.beta.chat.completions.parse(
|
| 52 |
+
model=self._model,
|
| 53 |
+
messages=[
|
| 54 |
+
{"role": "system", "content": system_prompt},
|
| 55 |
+
{"role": "user", "content": user_prompt},
|
| 56 |
+
],
|
| 57 |
+
response_format=dto
|
| 58 |
+
)
|
| 59 |
+
return completion.choices[0].message.parsed
|
app/configurations.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|
app/domain.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
@dataclass(frozen=True)
|
| 5 |
+
class TranscriptAnalysis:
|
| 6 |
+
id: str
|
| 7 |
+
summary: str
|
| 8 |
+
action_items: tuple[str, ...]
|
app/errors.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
class TranscriptAnalysisError(Exception):
|
| 2 |
+
pass
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class InvalidTranscriptError(TranscriptAnalysisError):
|
| 6 |
+
pass
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class AnalysisNotFoundError(TranscriptAnalysisError):
|
| 10 |
+
pass
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class LLMCompletionError(TranscriptAnalysisError):
|
| 14 |
+
pass
|
app/frontend.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 14 |
+
with gr.Blocks(title="Transcript Analysis") as frontend:
|
| 15 |
+
gr.Markdown("# Transcript Analysis")
|
| 16 |
+
|
| 17 |
+
with gr.Tab("Analyze"):
|
| 18 |
+
transcript_input = gr.Textbox(
|
| 19 |
+
label="Transcript",
|
| 20 |
+
lines=12,
|
| 21 |
+
placeholder="Paste a transcript here...",
|
| 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))
|
app/llm_schema.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class LLMTranscriptAnalysisResponse(BaseModel):
|
| 5 |
+
summary: str
|
| 6 |
+
action_items: list[str]
|
app/main.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from functools import lru_cache
|
| 2 |
+
from typing import Annotated
|
| 3 |
+
|
| 4 |
+
from fastapi import Depends, FastAPI, Query, Request
|
| 5 |
+
from fastapi.responses import JSONResponse, RedirectResponse
|
| 6 |
+
import gradio as gr
|
| 7 |
+
|
| 8 |
+
from app.adapters.openai import OpenAIAdapter
|
| 9 |
+
from app.configurations import EnvConfigs
|
| 10 |
+
from app.mappers import to_batch_transcript_analysis_response, to_transcript_analysis_response
|
| 11 |
+
from app.schemas import (
|
| 12 |
+
BatchTranscriptAnalysisRequest,
|
| 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
|
| 21 |
+
|
| 22 |
+
app = FastAPI(
|
| 23 |
+
title="Transcript Analysis API",
|
| 24 |
+
version="0.1.0",
|
| 25 |
+
description="Analyze plain text transcripts and retrieve stored analysis results.",
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
_repository = InMemoryTranscriptAnalysisRepository()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@lru_cache
|
| 32 |
+
def get_env_configs() -> EnvConfigs:
|
| 33 |
+
return EnvConfigs()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@lru_cache
|
| 37 |
+
def get_llm() -> LLm:
|
| 38 |
+
env_configs = get_env_configs()
|
| 39 |
+
return OpenAIAdapter(env_configs.OPENAI_API_KEY, env_configs.OPENAI_MODEL)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def get_repository() -> TranscriptAnalysisRepository:
|
| 43 |
+
return _repository
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def get_analysis_service(
|
| 47 |
+
llm: Annotated[LLm, Depends(get_llm)],
|
| 48 |
+
repository: Annotated[TranscriptAnalysisRepository, Depends(get_repository)],
|
| 49 |
+
) -> TranscriptAnalysisService:
|
| 50 |
+
return TranscriptAnalysisService(llm, repository)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def get_gradio_analysis_service() -> TranscriptAnalysisService:
|
| 54 |
+
return TranscriptAnalysisService(get_llm(), get_repository())
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@app.exception_handler(InvalidTranscriptError)
|
| 58 |
+
async def invalid_transcript_handler(_: Request, exc: InvalidTranscriptError) -> JSONResponse:
|
| 59 |
+
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@app.exception_handler(AnalysisNotFoundError)
|
| 63 |
+
async def analysis_not_found_handler(_: Request, exc: AnalysisNotFoundError) -> JSONResponse:
|
| 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)})
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@app.get("/", include_in_schema=False)
|
| 73 |
+
def redirect_to_ui() -> RedirectResponse:
|
| 74 |
+
return RedirectResponse(url="/ui")
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@app.get("/analyses", response_model=TranscriptAnalysisResponse)
|
| 78 |
+
def analyze_transcript(
|
| 79 |
+
transcript: Annotated[str, Query(description="Plain text transcript to analyze.")],
|
| 80 |
+
service: Annotated[TranscriptAnalysisService, Depends(get_analysis_service)],
|
| 81 |
+
) -> TranscriptAnalysisResponse:
|
| 82 |
+
analysis = service.analyze(transcript)
|
| 83 |
+
return to_transcript_analysis_response(analysis)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@app.get("/analyses/{analysis_id}", response_model=TranscriptAnalysisResponse)
|
| 87 |
+
def get_transcript_analysis(
|
| 88 |
+
analysis_id: str,
|
| 89 |
+
service: Annotated[TranscriptAnalysisService, Depends(get_analysis_service)],
|
| 90 |
+
) -> TranscriptAnalysisResponse:
|
| 91 |
+
analysis = service.get(analysis_id)
|
| 92 |
+
return to_transcript_analysis_response(analysis)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@app.post("/analyses/batch", response_model=BatchTranscriptAnalysisResponse)
|
| 96 |
+
async def analyze_transcripts_batch(
|
| 97 |
+
request: BatchTranscriptAnalysisRequest,
|
| 98 |
+
service: Annotated[TranscriptAnalysisService, Depends(get_analysis_service)],
|
| 99 |
+
) -> BatchTranscriptAnalysisResponse:
|
| 100 |
+
analyses = await service.analyze_many(request.transcripts)
|
| 101 |
+
return to_batch_transcript_analysis_response(analyses)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
app = gr.mount_gradio_app(app, build_gradio_app(get_gradio_analysis_service), path="/ui")
|
app/mappers.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.domain import TranscriptAnalysis
|
| 2 |
+
from app.schemas import BatchTranscriptAnalysisResponse, TranscriptAnalysisResponse
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def to_transcript_analysis_response(analysis: TranscriptAnalysis) -> TranscriptAnalysisResponse:
|
| 6 |
+
return TranscriptAnalysisResponse(
|
| 7 |
+
id=analysis.id,
|
| 8 |
+
summary=analysis.summary,
|
| 9 |
+
action_items=list(analysis.action_items),
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def to_batch_transcript_analysis_response(
|
| 14 |
+
analyses: list[TranscriptAnalysis],
|
| 15 |
+
) -> BatchTranscriptAnalysisResponse:
|
| 16 |
+
return BatchTranscriptAnalysisResponse(
|
| 17 |
+
items=[to_transcript_analysis_response(analysis) for analysis in analyses]
|
| 18 |
+
)
|
app/ports/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from app.ports.llm import LLm
|
app/ports/llm.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from abc import ABC, abstractmethod
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class LLm(ABC):
|
| 6 |
+
@abstractmethod
|
| 7 |
+
def run_completion(
|
| 8 |
+
self,
|
| 9 |
+
system_prompt: str,
|
| 10 |
+
user_prompt: str,
|
| 11 |
+
dto: type[BaseModel],
|
| 12 |
+
) -> BaseModel:
|
| 13 |
+
pass
|
| 14 |
+
|
| 15 |
+
@abstractmethod
|
| 16 |
+
async def run_completion_async(
|
| 17 |
+
self,
|
| 18 |
+
system_prompt: str,
|
| 19 |
+
user_prompt: str,
|
| 20 |
+
dto: type[BaseModel],
|
| 21 |
+
) -> BaseModel:
|
| 22 |
+
pass
|
app/prompts.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
SYSTEM_PROMPT = """You are an expert business coach skilled in analyzing conversation transcripts.
|
| 2 |
+
Your job is to provide insightful, concise summaries and recommend clear, actionable next steps
|
| 3 |
+
to help clients achieve their goals effectively."""
|
| 4 |
+
|
| 5 |
+
RAW_USER_PROMPT = """Given the transcript below, generate:
|
| 6 |
+
1. A brief, insightful summary highlighting key points discussed.
|
| 7 |
+
2. A clear, structured list of recommended next actions.
|
| 8 |
+
|
| 9 |
+
Transcript:
|
| 10 |
+
{transcript}"""
|
| 11 |
+
|
app/repositories.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from threading import RLock
|
| 2 |
+
from typing import Protocol
|
| 3 |
+
|
| 4 |
+
from app.domain import TranscriptAnalysis
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class TranscriptAnalysisRepository(Protocol):
|
| 8 |
+
def save(self, analysis: TranscriptAnalysis) -> None:
|
| 9 |
+
pass
|
| 10 |
+
|
| 11 |
+
def get(self, analysis_id: str) -> TranscriptAnalysis | None:
|
| 12 |
+
pass
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class InMemoryTranscriptAnalysisRepository:
|
| 16 |
+
def __init__(self) -> None:
|
| 17 |
+
self._analyses: dict[str, TranscriptAnalysis] = {}
|
| 18 |
+
self._lock = RLock()
|
| 19 |
+
|
| 20 |
+
def save(self, analysis: TranscriptAnalysis) -> None:
|
| 21 |
+
with self._lock:
|
| 22 |
+
self._analyses[analysis.id] = analysis
|
| 23 |
+
|
| 24 |
+
def get(self, analysis_id: str) -> TranscriptAnalysis | None:
|
| 25 |
+
with self._lock:
|
| 26 |
+
return self._analyses.get(analysis_id)
|
app/schemas.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class TranscriptAnalysisResponse(BaseModel):
|
| 5 |
+
id: str
|
| 6 |
+
summary: str
|
| 7 |
+
action_items: list[str]
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class BatchTranscriptAnalysisRequest(BaseModel):
|
| 11 |
+
transcripts: list[str]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class BatchTranscriptAnalysisResponse(BaseModel):
|
| 15 |
+
items: list[TranscriptAnalysisResponse]
|
app/services.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import uuid
|
| 3 |
+
from collections.abc import Sequence
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel, ValidationError
|
| 6 |
+
|
| 7 |
+
from app import prompts
|
| 8 |
+
from app.domain import TranscriptAnalysis
|
| 9 |
+
from app.llm_schema import LLMTranscriptAnalysisResponse
|
| 10 |
+
from app.errors import AnalysisNotFoundError, InvalidTranscriptError, LLMCompletionError
|
| 11 |
+
from app.ports import LLm
|
| 12 |
+
from app.repositories import TranscriptAnalysisRepository
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class TranscriptAnalysisService:
|
| 16 |
+
def __init__(self, llm: LLm, repository: TranscriptAnalysisRepository) -> None:
|
| 17 |
+
self._llm = llm
|
| 18 |
+
self._repository = repository
|
| 19 |
+
|
| 20 |
+
def analyze(self, transcript: str) -> TranscriptAnalysis:
|
| 21 |
+
clean_transcript = self._validate_transcript(transcript)
|
| 22 |
+
analysis = self._create_analysis(clean_transcript)
|
| 23 |
+
self._repository.save(analysis)
|
| 24 |
+
return analysis
|
| 25 |
+
|
| 26 |
+
async def analyze_many(self, transcripts: Sequence[str]) -> list[TranscriptAnalysis]:
|
| 27 |
+
if not transcripts:
|
| 28 |
+
raise InvalidTranscriptError("At least one transcript is required.")
|
| 29 |
+
|
| 30 |
+
clean_transcripts = [self._validate_transcript(transcript) for transcript in transcripts]
|
| 31 |
+
analyses = await asyncio.gather(
|
| 32 |
+
*(self._create_analysis_async(transcript) for transcript in clean_transcripts)
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
for analysis in analyses:
|
| 36 |
+
self._repository.save(analysis)
|
| 37 |
+
|
| 38 |
+
return list(analyses)
|
| 39 |
+
|
| 40 |
+
def get(self, analysis_id: str) -> TranscriptAnalysis:
|
| 41 |
+
analysis = self._repository.get(analysis_id)
|
| 42 |
+
if analysis is None:
|
| 43 |
+
raise AnalysisNotFoundError(f"Transcript analysis '{analysis_id}' was not found.")
|
| 44 |
+
return analysis
|
| 45 |
+
|
| 46 |
+
def _create_analysis(self, transcript: str) -> TranscriptAnalysis:
|
| 47 |
+
response = self._run_completion(transcript)
|
| 48 |
+
return self._build_analysis(response)
|
| 49 |
+
|
| 50 |
+
async def _create_analysis_async(self, transcript: str) -> TranscriptAnalysis:
|
| 51 |
+
response = await self._run_completion_async(transcript)
|
| 52 |
+
return self._build_analysis(response)
|
| 53 |
+
|
| 54 |
+
@staticmethod
|
| 55 |
+
def _build_analysis(response: LLMTranscriptAnalysisResponse) -> TranscriptAnalysis:
|
| 56 |
+
return TranscriptAnalysis(
|
| 57 |
+
id=str(uuid.uuid4()),
|
| 58 |
+
summary=response.summary,
|
| 59 |
+
action_items=tuple(response.action_items),
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
def _run_completion(self, transcript: str) -> LLMTranscriptAnalysisResponse:
|
| 63 |
+
user_prompt = prompts.RAW_USER_PROMPT.format(transcript=transcript)
|
| 64 |
+
|
| 65 |
+
try:
|
| 66 |
+
completion = self._llm.run_completion(
|
| 67 |
+
prompts.SYSTEM_PROMPT,
|
| 68 |
+
user_prompt,
|
| 69 |
+
LLMTranscriptAnalysisResponse,
|
| 70 |
+
)
|
| 71 |
+
except Exception as exc:
|
| 72 |
+
raise LLMCompletionError("Transcript analysis failed.") from exc
|
| 73 |
+
|
| 74 |
+
return self._parse_completion_response(completion)
|
| 75 |
+
|
| 76 |
+
async def _run_completion_async(self, transcript: str) -> LLMTranscriptAnalysisResponse:
|
| 77 |
+
user_prompt = prompts.RAW_USER_PROMPT.format(transcript=transcript)
|
| 78 |
+
|
| 79 |
+
try:
|
| 80 |
+
completion = await self._llm.run_completion_async(
|
| 81 |
+
prompts.SYSTEM_PROMPT,
|
| 82 |
+
user_prompt,
|
| 83 |
+
LLMTranscriptAnalysisResponse,
|
| 84 |
+
)
|
| 85 |
+
except Exception as exc:
|
| 86 |
+
raise LLMCompletionError("Transcript analysis failed.") from exc
|
| 87 |
+
|
| 88 |
+
return self._parse_completion_response(completion)
|
| 89 |
+
|
| 90 |
+
@staticmethod
|
| 91 |
+
def _parse_completion_response(completion: BaseModel | object) -> LLMTranscriptAnalysisResponse:
|
| 92 |
+
try:
|
| 93 |
+
if isinstance(completion, BaseModel):
|
| 94 |
+
return LLMTranscriptAnalysisResponse.model_validate(completion.model_dump())
|
| 95 |
+
return LLMTranscriptAnalysisResponse.model_validate(completion)
|
| 96 |
+
except ValidationError as exc:
|
| 97 |
+
raise LLMCompletionError("Transcript analysis returned an invalid response.") from exc
|
| 98 |
+
|
| 99 |
+
@staticmethod
|
| 100 |
+
def _validate_transcript(transcript: str) -> str:
|
| 101 |
+
clean_transcript = transcript.strip()
|
| 102 |
+
if not clean_transcript:
|
| 103 |
+
raise InvalidTranscriptError("Transcript cannot be empty.")
|
| 104 |
+
return clean_transcript
|
assessment.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python Interview Task
|
| 2 |
+
|
| 3 |
+
## Scenario
|
| 4 |
+
|
| 5 |
+
You are asked to build a Python web API that analyzes plain text transcripts and returns a summary along with a list of next actions. Your implementation should clearly demonstrate good architectural practices.
|
| 6 |
+
|
| 7 |
+
## Provided Adapter (do not implement)
|
| 8 |
+
|
| 9 |
+
- **OpenAI Adapter**: Sends the transcript text to OpenAI's API along with a predefined, hardcoded prompt. This adapter returns a DTO.
|
| 10 |
+
- The transcript, system prompt, and user prompt are provided.
|
| 11 |
+
|
| 12 |
+
A file defining the interface (ports) for this adapter will be provided.
|
| 13 |
+
|
| 14 |
+
## Requirements (Point 1)
|
| 15 |
+
|
| 16 |
+
### Analyze Transcript
|
| 17 |
+
|
| 18 |
+
- Create an HTTP endpoint (e.g., using FastAPI or Flask) that accepts GET requests containing a plain text transcript.
|
| 19 |
+
- Perform basic input validation (when the transcript is empty)
|
| 20 |
+
- Invoke the provided OpenAI adapter to analyze the transcript.
|
| 21 |
+
- Store the analysis result in memory (an external DB is not required).
|
| 22 |
+
- Return a response containing:
|
| 23 |
+
- A unique ID.
|
| 24 |
+
- A summary of the transcript.
|
| 25 |
+
- A suggested list of next steps or actions based on the transcript analysis.
|
| 26 |
+
|
| 27 |
+
### Get a Transcript by ID
|
| 28 |
+
|
| 29 |
+
- Create an HTTP endpoint to get transcript analysis by ID.
|
| 30 |
+
|
| 31 |
+
### Additional Requirements
|
| 32 |
+
|
| 33 |
+
- Adhere strictly to the interfaces defined in the provided ports file.
|
| 34 |
+
|
| 35 |
+
## Optional Advanced Requirements (Point 2)
|
| 36 |
+
|
| 37 |
+
- Build an additional endpoint to support concurrent analysis of multiple transcripts within a single request:
|
| 38 |
+
- Implement asynchronous processing (e.g., using asyncio).
|
| 39 |
+
- Handle multiple transcript analyses simultaneously without blocking the main API thread.
|
| 40 |
+
|
| 41 |
+
## Success Criteria
|
| 42 |
+
|
| 43 |
+
- Code readability, modularity, and adherence to best practices.
|
| 44 |
+
- Functional correctness of the API.
|
| 45 |
+
- Swagger
|
| 46 |
+
- Clear error handling and appropriate HTTP response statuses.
|
| 47 |
+
- Testability of the code (clear separation of concerns, ease of unit testing).
|
| 48 |
+
- (Optional) Effective asynchronous processing implementation.
|
| 49 |
+
|
| 50 |
+
## Hints
|
| 51 |
+
|
| 52 |
+
- You will find a test running openai adapter. This will be the documentation to build the prompt to analyze the transcript
|
| 53 |
+
- The provided OpenAI adapter utilizes structured output, allowing you to specify a system prompt, a user prompt, and a DTO. The adapter then returns a model instance populated according to the DTO's defined structure.
|
| 54 |
+
- Create a DTO that contains the requested fields.
|
| 55 |
+
- Hexagonal (or clean) architecture consists of distinct layers. Consider creating a separate model layer for the LLM responses. Pay attention to avoiding layer coupling.
|
poetry.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
pyproject.toml
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[tool.poetry]
|
| 2 |
+
name = "ml-tech-assessment"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = ""
|
| 5 |
+
authors = ["jhonvalderrama <jhonvalderrama@aceup.com>"]
|
| 6 |
+
readme = "README.md"
|
| 7 |
+
|
| 8 |
+
[tool.poetry.dependencies]
|
| 9 |
+
python = "^3.12"
|
| 10 |
+
fastapi = "^0.115.12"
|
| 11 |
+
gradio = "^6.14.0"
|
| 12 |
+
openai = "^1.76.2"
|
| 13 |
+
pydantic-settings = "^2.9.1"
|
| 14 |
+
pytest = "^8.3.5"
|
| 15 |
+
uvicorn = "^0.34.2"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
[build-system]
|
| 19 |
+
requires = ["poetry-core"]
|
| 20 |
+
build-backend = "poetry.core.masonry.api"
|
tests/__init__.py
ADDED
|
File without changes
|
tests/adapters/__init__.py
ADDED
|
File without changes
|
tests/adapters/mock_data.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
SYSTEM_PROMPT = """You are an expert business coach skilled in analyzing conversation transcripts.
|
| 2 |
+
Your job is to provide insightful, concise summaries and recommend clear, actionable next steps
|
| 3 |
+
to help clients achieve their goals effectively."""
|
| 4 |
+
|
| 5 |
+
RAW_USER_PROMPT = """Given the transcript below, generate:
|
| 6 |
+
1. A brief, insightful summary highlighting key points discussed.
|
| 7 |
+
2. A clear, structured list of recommended next actions.
|
| 8 |
+
|
| 9 |
+
Transcript:
|
| 10 |
+
{transcript}"""
|
| 11 |
+
|
| 12 |
+
TRANSCRIPT = """Mark Foster | MCC, ACTC: Hey there, Liam. Glad we could find a few minutes for this one-on-one. How are things going?
|
| 13 |
+
|
| 14 |
+
Liam Garcia: Hey, Mark. Doing well, thanks. It’s been a busy week—my local dev environment is a bit cluttered from a new feature branch, but I’m making progress. Ready to dig in on Python best practices?
|
| 15 |
+
|
| 16 |
+
Mark Foster | MCC, ACTC: Absolutely. I know you wanted to focus on a handful of coding guidelines and how they tie into your team’s speed. Let’s start big picture: what’s motivating you to tighten up your Python practices right now?
|
| 17 |
+
|
| 18 |
+
Liam Garcia: Mainly two reasons. First, the codebase is growing, and I want to make sure we’re consistent in how we name things, structure modules, and write docstrings. Second, I’m onboarding new developers, and I’ve noticed they can get lost if we don’t have explicit standards in place.
|
| 19 |
+
|
| 20 |
+
Mark Foster | MCC, ACTC: Makes sense. So, if we look at code readability—PEP 8, docstrings, that sort of thing—what’s your first priority?
|
| 21 |
+
|
| 22 |
+
Liam Garcia: Definitely PEP 8. That’s sort of non-negotiable. I’d like us to adopt a tool like Black to auto-format. That alone can reduce the back-and-forth on code reviews. It’s a small step but a huge time-saver.
|
| 23 |
+
|
| 24 |
+
Mark Foster | MCC, ACTC: I love it. Automating style enforcement frees you up to focus on more important stuff—like logic, architecture, and performance. Any concerns about pushback from your devs?
|
| 25 |
+
|
| 26 |
+
Liam Garcia: A bit. Some folks are used to their own formatting quirks. But I keep reminding them it’s not about personal style—it’s about consistent style that benefits everyone. I think once they see the time saved, they’ll be on board.
|
| 27 |
+
|
| 28 |
+
Mark Foster | MCC, ACTC: Good call. How about docstrings? I know some devs skip them unless forced.
|
| 29 |
+
|
| 30 |
+
Liam Garcia: Right. I’m pushing for Google-style docstrings. For classes, methods, and modules, they clarify purpose and expected inputs/outputs. It’s a bit of extra effort at first, but it pays off when you come back months later or when a new dev jumps in.
|
| 31 |
+
|
| 32 |
+
Mark Foster | MCC, ACTC: So your plan is PEP 8 plus auto-formatting, then Google-style docstrings. Anything else on your radar?
|
| 33 |
+
|
| 34 |
+
Liam Garcia: Yes—test coverage. We’re aiming for 80% coverage in the short term. That ensures we catch regressions early. I’m also encouraging test-driven development for bigger features. It’s not mandatory, but I want the team comfortable with writing tests before the code whenever possible.
|
| 35 |
+
|
| 36 |
+
Mark Foster | MCC, ACTC: Great. You mentioned wanting to go faster as a team. How do you see these coding best practices speeding things up, rather than slowing them down?
|
| 37 |
+
|
| 38 |
+
Liam Garcia: Well, the time you invest in writing docstrings or running auto-format tools is minimal compared to the hassle of deciphering unstructured code. It’s like a Formula One pit stop—everyone knows their role, follows the same procedure, and the car is back on track fast. Consistency and clarity remove friction.
|
| 39 |
+
|
| 40 |
+
Mark Foster | MCC, ACTC: That’s an excellent analogy. So what’s your biggest concern about implementing all this?
|
| 41 |
+
|
| 42 |
+
Liam Garcia: Probably that initial pushback, or the fear that it’s “too much process.” But I think if I keep reminding folks it’s about removing headaches—like merges, weird naming conflicts, missing tests—they’ll adopt it.
|
| 43 |
+
|
| 44 |
+
Mark Foster | MCC, ACTC: It often helps to show quick wins. For instance, once your team sees how auto-formatting catches stray imports or how docstrings make a confusing function crystal clear, they’ll realize it’s worth it.
|
| 45 |
+
|
| 46 |
+
Liam Garcia: Exactly. I’ll start small, maybe run a pilot on one module, let them see the difference, and then expand.
|
| 47 |
+
|
| 48 |
+
Mark Foster | MCC, ACTC: That’s a solid plan, Liam. So to recap, you’re committing to:
|
| 49 |
+
|
| 50 |
+
PEP 8 compliance via Black (or a similar auto-formatting tool).
|
| 51 |
+
|
| 52 |
+
Google-style docstrings for all modules, classes, and major functions.
|
| 53 |
+
|
| 54 |
+
A drive toward 80% test coverage, with TDD on key features.
|
| 55 |
+
|
| 56 |
+
Anything else?
|
| 57 |
+
|
| 58 |
+
Liam Garcia: That’s the core. I might also do a weekly quick code review session—just me and one other developer—so we can keep each other honest on these standards.
|
| 59 |
+
|
| 60 |
+
Mark Foster | MCC, ACTC: That sounds like a perfect next step. How are you feeling as we wrap up?
|
| 61 |
+
|
| 62 |
+
Liam Garcia: Confident. I know it’ll take some nudging, but once everyone sees the impact, I think we’ll be coding cleaner, shipping faster.
|
| 63 |
+
|
| 64 |
+
Mark Foster | MCC, ACTC: Couldn’t have said it better. Thanks for the update, Liam. I look forward to hearing how it goes once you put these into practice.
|
| 65 |
+
|
| 66 |
+
Liam Garcia: Thanks, Mark. I appreciate the guidance and encouragement. We’ll talk again soon—hopefully with good news on the coverage front!
|
| 67 |
+
|
| 68 |
+
Mark Foster | MCC, ACTC: Sounds like a plan. Take care, Liam.
|
| 69 |
+
|
| 70 |
+
"""
|
tests/adapters/test_openai.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
import pydantic
|
| 4 |
+
import pytest
|
| 5 |
+
|
| 6 |
+
from app import configurations
|
| 7 |
+
from app.adapters import openai
|
| 8 |
+
from tests.adapters import mock_data
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class Response(pydantic.BaseModel):
|
| 12 |
+
summary: str
|
| 13 |
+
action_items: list[str]
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_openai_adapter() -> None:
|
| 17 |
+
if os.getenv("RUN_OPENAI_INTEGRATION_TESTS") != "1":
|
| 18 |
+
pytest.skip("Set RUN_OPENAI_INTEGRATION_TESTS=1 to run the live OpenAI adapter test.")
|
| 19 |
+
|
| 20 |
+
# Configuration
|
| 21 |
+
env_variables = configurations.EnvConfigs()
|
| 22 |
+
|
| 23 |
+
system_prompt = mock_data.SYSTEM_PROMPT
|
| 24 |
+
raw_user_prompt = mock_data.RAW_USER_PROMPT
|
| 25 |
+
transcript = mock_data.TRANSCRIPT
|
| 26 |
+
|
| 27 |
+
user_prompt = raw_user_prompt.format(
|
| 28 |
+
transcript=transcript)
|
| 29 |
+
openai_adapter = openai.OpenAIAdapter(env_variables.OPENAI_API_KEY, env_variables.OPENAI_MODEL)
|
| 30 |
+
|
| 31 |
+
# action
|
| 32 |
+
response = openai_adapter.run_completion(system_prompt, user_prompt, Response)
|
| 33 |
+
serialized_response = response.model_dump()
|
| 34 |
+
|
| 35 |
+
# assert
|
| 36 |
+
print(serialized_response)
|
| 37 |
+
assert "summary" in serialized_response.keys()
|
| 38 |
+
assert "action_items" in serialized_response.keys()
|
tests/api/test_transcript_analysis_api.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
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
|
| 8 |
+
from app.services import TranscriptAnalysisService
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class FakeLLM(LLm):
|
| 12 |
+
def run_completion(
|
| 13 |
+
self,
|
| 14 |
+
system_prompt: str,
|
| 15 |
+
user_prompt: str,
|
| 16 |
+
dto: type[BaseModel],
|
| 17 |
+
) -> BaseModel:
|
| 18 |
+
return dto(summary="API summary.", action_items=["Confirm owner", "Set deadline"])
|
| 19 |
+
|
| 20 |
+
async def run_completion_async(
|
| 21 |
+
self,
|
| 22 |
+
system_prompt: str,
|
| 23 |
+
user_prompt: str,
|
| 24 |
+
dto: type[BaseModel],
|
| 25 |
+
) -> BaseModel:
|
| 26 |
+
return dto(summary="API summary.", action_items=["Confirm owner", "Set deadline"])
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@pytest.fixture
|
| 30 |
+
def client() -> TestClient:
|
| 31 |
+
service = TranscriptAnalysisService(FakeLLM(), InMemoryTranscriptAnalysisRepository())
|
| 32 |
+
app.dependency_overrides[get_analysis_service] = lambda: service
|
| 33 |
+
|
| 34 |
+
with TestClient(app) as test_client:
|
| 35 |
+
yield test_client
|
| 36 |
+
|
| 37 |
+
app.dependency_overrides.clear()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_analyze_transcript_success(client: TestClient) -> None:
|
| 41 |
+
response = client.get("/analyses", params={"transcript": "Discuss rollout plan."})
|
| 42 |
+
|
| 43 |
+
assert response.status_code == 200
|
| 44 |
+
payload = response.json()
|
| 45 |
+
assert payload["id"]
|
| 46 |
+
assert payload["summary"] == "API summary."
|
| 47 |
+
assert payload["action_items"] == ["Confirm owner", "Set deadline"]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_analyze_transcript_rejects_empty_query(client: TestClient) -> None:
|
| 51 |
+
response = client.get("/analyses", params={"transcript": " "})
|
| 52 |
+
|
| 53 |
+
assert response.status_code == 400
|
| 54 |
+
assert response.json()["detail"] == "Transcript cannot be empty."
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_get_transcript_analysis_success(client: TestClient) -> None:
|
| 58 |
+
created = client.get("/analyses", params={"transcript": "Discuss rollout plan."}).json()
|
| 59 |
+
|
| 60 |
+
response = client.get(f"/analyses/{created['id']}")
|
| 61 |
+
|
| 62 |
+
assert response.status_code == 200
|
| 63 |
+
assert response.json() == created
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_get_transcript_analysis_returns_404(client: TestClient) -> None:
|
| 67 |
+
response = client.get("/analyses/missing-id")
|
| 68 |
+
|
| 69 |
+
assert response.status_code == 404
|
| 70 |
+
assert response.json()["detail"] == "Transcript analysis 'missing-id' was not found."
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_analyze_batch_success(client: TestClient) -> None:
|
| 74 |
+
response = client.post(
|
| 75 |
+
"/analyses/batch",
|
| 76 |
+
json={"transcripts": ["Discuss roadmap.", "Review hiring plan."]},
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
assert response.status_code == 200
|
| 80 |
+
payload = response.json()
|
| 81 |
+
assert len(payload["items"]) == 2
|
| 82 |
+
assert all(item["summary"] == "API summary." for item in payload["items"])
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_analyze_batch_rejects_empty_list(client: TestClient) -> None:
|
| 86 |
+
response = client.post("/analyses/batch", json={"transcripts": []})
|
| 87 |
+
|
| 88 |
+
assert response.status_code == 400
|
| 89 |
+
assert response.json()["detail"] == "At least one transcript is required."
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_analyze_batch_rejects_empty_transcript(client: TestClient) -> None:
|
| 93 |
+
response = client.post("/analyses/batch", json={"transcripts": ["Discuss roadmap.", " "]})
|
| 94 |
+
|
| 95 |
+
assert response.status_code == 400
|
| 96 |
+
assert response.json()["detail"] == "Transcript cannot be empty."
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def test_gradio_ui_is_mounted(client: TestClient) -> None:
|
| 100 |
+
response = client.get("/ui/", follow_redirects=True)
|
| 101 |
+
|
| 102 |
+
assert response.status_code == 200
|
| 103 |
+
assert "text/html" in response.headers["content-type"]
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def test_root_redirects_to_gradio_ui(client: TestClient) -> None:
|
| 107 |
+
response = client.get("/", follow_redirects=False)
|
| 108 |
+
|
| 109 |
+
assert response.status_code == 307
|
| 110 |
+
assert response.headers["location"] == "/ui"
|
tests/frontend/test_gradio_frontend.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
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:
|
| 10 |
+
def __init__(self) -> None:
|
| 11 |
+
self.analysis = TranscriptAnalysis(
|
| 12 |
+
id="analysis-1",
|
| 13 |
+
summary="The team aligned on next steps.",
|
| 14 |
+
action_items=("Confirm owner", "Set deadline"),
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
def analyze(self, transcript: str) -> TranscriptAnalysis:
|
| 18 |
+
if not transcript.strip():
|
| 19 |
+
raise InvalidTranscriptError("Transcript cannot be empty.")
|
| 20 |
+
return self.analysis
|
| 21 |
+
|
| 22 |
+
def get(self, analysis_id: str) -> TranscriptAnalysis:
|
| 23 |
+
if analysis_id != self.analysis.id:
|
| 24 |
+
raise AnalysisNotFoundError(f"Transcript analysis '{analysis_id}' was not found.")
|
| 25 |
+
return self.analysis
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_analyze_transcript_returns_formatted_result() -> None:
|
| 29 |
+
result = analyze_transcript("Discuss roadmap.", FakeService)
|
| 30 |
+
|
| 31 |
+
assert result == (
|
| 32 |
+
"analysis-1",
|
| 33 |
+
"The team aligned on next steps.",
|
| 34 |
+
"1. Confirm owner\n2. Set deadline",
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_lookup_analysis_returns_formatted_result() -> None:
|
| 39 |
+
result = lookup_analysis(" analysis-1 ", FakeService)
|
| 40 |
+
|
| 41 |
+
assert result == (
|
| 42 |
+
"analysis-1",
|
| 43 |
+
"The team aligned on next steps.",
|
| 44 |
+
"1. Confirm owner\n2. Set deadline",
|
| 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)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_lookup_analysis_maps_missing_id_to_gradio_error() -> None:
|
| 54 |
+
with pytest.raises(gr.Error, match="Transcript analysis 'missing-id' was not found."):
|
| 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."
|
tests/services/test_transcript_analysis_service.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
|
| 3 |
+
import pydantic
|
| 4 |
+
import pytest
|
| 5 |
+
|
| 6 |
+
from app.errors import AnalysisNotFoundError, InvalidTranscriptError, LLMCompletionError
|
| 7 |
+
from app.ports import LLm
|
| 8 |
+
from app.repositories import InMemoryTranscriptAnalysisRepository
|
| 9 |
+
from app.services import TranscriptAnalysisService
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class FakeLLM(LLm):
|
| 13 |
+
def __init__(self) -> None:
|
| 14 |
+
self.user_prompts: list[str] = []
|
| 15 |
+
self.async_user_prompts: list[str] = []
|
| 16 |
+
|
| 17 |
+
def run_completion(
|
| 18 |
+
self,
|
| 19 |
+
system_prompt: str,
|
| 20 |
+
user_prompt: str,
|
| 21 |
+
dto: type[pydantic.BaseModel],
|
| 22 |
+
) -> pydantic.BaseModel:
|
| 23 |
+
self.user_prompts.append(user_prompt)
|
| 24 |
+
return dto(summary="A concise summary.", action_items=["Follow up", "Share notes"])
|
| 25 |
+
|
| 26 |
+
async def run_completion_async(
|
| 27 |
+
self,
|
| 28 |
+
system_prompt: str,
|
| 29 |
+
user_prompt: str,
|
| 30 |
+
dto: type[pydantic.BaseModel],
|
| 31 |
+
) -> pydantic.BaseModel:
|
| 32 |
+
self.async_user_prompts.append(user_prompt)
|
| 33 |
+
return dto(summary="A concise summary.", action_items=["Follow up", "Share notes"])
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class FailingLLM(LLm):
|
| 37 |
+
def run_completion(
|
| 38 |
+
self,
|
| 39 |
+
system_prompt: str,
|
| 40 |
+
user_prompt: str,
|
| 41 |
+
dto: type[pydantic.BaseModel],
|
| 42 |
+
) -> pydantic.BaseModel:
|
| 43 |
+
raise RuntimeError("provider unavailable")
|
| 44 |
+
|
| 45 |
+
async def run_completion_async(
|
| 46 |
+
self,
|
| 47 |
+
system_prompt: str,
|
| 48 |
+
user_prompt: str,
|
| 49 |
+
dto: type[pydantic.BaseModel],
|
| 50 |
+
) -> pydantic.BaseModel:
|
| 51 |
+
raise RuntimeError("provider unavailable")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def build_service(llm: LLm | None = None) -> TranscriptAnalysisService:
|
| 55 |
+
return TranscriptAnalysisService(llm or FakeLLM(), InMemoryTranscriptAnalysisRepository())
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_analyze_returns_and_persists_result() -> None:
|
| 59 |
+
llm = FakeLLM()
|
| 60 |
+
service = build_service(llm)
|
| 61 |
+
|
| 62 |
+
analysis = service.analyze(" Discuss launch plan. ")
|
| 63 |
+
|
| 64 |
+
assert analysis.id
|
| 65 |
+
assert analysis.summary == "A concise summary."
|
| 66 |
+
assert analysis.action_items == ("Follow up", "Share notes")
|
| 67 |
+
assert service.get(analysis.id) == analysis
|
| 68 |
+
assert "Discuss launch plan." in llm.user_prompts[0]
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_analyze_rejects_empty_transcript() -> None:
|
| 72 |
+
service = build_service()
|
| 73 |
+
|
| 74 |
+
with pytest.raises(InvalidTranscriptError):
|
| 75 |
+
service.analyze(" ")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_get_raises_when_analysis_is_missing() -> None:
|
| 79 |
+
service = build_service()
|
| 80 |
+
|
| 81 |
+
with pytest.raises(AnalysisNotFoundError):
|
| 82 |
+
service.get("missing-id")
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_analyze_many_processes_and_persists_all_results() -> None:
|
| 86 |
+
llm = FakeLLM()
|
| 87 |
+
service = build_service(llm)
|
| 88 |
+
|
| 89 |
+
analyses = asyncio.run(service.analyze_many(["First transcript", "Second transcript"]))
|
| 90 |
+
|
| 91 |
+
assert len(analyses) == 2
|
| 92 |
+
assert len({analysis.id for analysis in analyses}) == 2
|
| 93 |
+
assert [service.get(analysis.id) for analysis in analyses] == analyses
|
| 94 |
+
assert len(llm.async_user_prompts) == 2
|
| 95 |
+
assert llm.user_prompts == []
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def test_analyze_many_rejects_empty_list() -> None:
|
| 99 |
+
service = build_service()
|
| 100 |
+
|
| 101 |
+
with pytest.raises(InvalidTranscriptError):
|
| 102 |
+
asyncio.run(service.analyze_many([]))
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def test_llm_errors_are_wrapped() -> None:
|
| 106 |
+
service = build_service(FailingLLM())
|
| 107 |
+
|
| 108 |
+
with pytest.raises(LLMCompletionError):
|
| 109 |
+
service.analyze("Discuss launch plan.")
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def test_analyze_many_wraps_llm_errors() -> None:
|
| 113 |
+
service = build_service(FailingLLM())
|
| 114 |
+
|
| 115 |
+
with pytest.raises(LLMCompletionError):
|
| 116 |
+
asyncio.run(service.analyze_many(["Discuss launch plan."]))
|