Spaces:
Sleeping
Sleeping
Commit ·
51aa39f
1
Parent(s): 2cf8356
fix2
Browse files- app/cleaner.py +427 -0
- app/main.py +9 -1
- app/schemas.py +4 -1
- app/settings.py +189 -0
- requirements.txt +13 -1
- settings.py +0 -0
app/cleaner.py
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import glob
|
| 2 |
+
import os
|
| 3 |
+
import re
|
| 4 |
+
import time
|
| 5 |
+
from multiprocessing import Pool, cpu_count
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from langchain.agents import create_agent
|
| 9 |
+
from langchain_community.chat_models import ChatLlamaCpp
|
| 10 |
+
from langchain_core.messages import HumanMessage, SystemMessage
|
| 11 |
+
from langchain_core.runnables import Runnable, RunnableLambda, chain
|
| 12 |
+
from langchain_core.tools import tool
|
| 13 |
+
from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint
|
| 14 |
+
from langchain_text_splitters import RecursiveCharacterTextSplitter, TextSplitter
|
| 15 |
+
from pydantic import BaseModel, Field
|
| 16 |
+
from settings import settings
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@tool
|
| 20 |
+
def remove_brackets_content(text: str) -> str:
|
| 21 |
+
"""
|
| 22 |
+
Remove all content inside square brackets [],
|
| 23 |
+
round brackets () and curly brackets {}.
|
| 24 |
+
Useful for removing sound descriptions, speaker labels,
|
| 25 |
+
stage directions like [applause], (laughs), {music}.
|
| 26 |
+
"""
|
| 27 |
+
text = re.sub(r'\[.*?\]', '', text) # [applause]
|
| 28 |
+
text = re.sub(r'\(.*?\)', '', text) # (laughs)
|
| 29 |
+
text = re.sub(r'\{.*?\}', '', text) # {music}
|
| 30 |
+
return text.strip()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@tool
|
| 34 |
+
def remove_non_alphabetic(text: str) -> str:
|
| 35 |
+
"""
|
| 36 |
+
Remove all non-alphabetic characters except spaces.
|
| 37 |
+
Keeps only letters A-Z, a-z and whitespace.
|
| 38 |
+
Useful for stripping punctuation, numbers, special symbols.
|
| 39 |
+
"""
|
| 40 |
+
text = re.sub(r'[^a-zA-Z\s]', '', text)
|
| 41 |
+
return text.strip()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@tool
|
| 45 |
+
def remove_newlines(text: str) -> str:
|
| 46 |
+
"""
|
| 47 |
+
Remove newline characters and replace them with spaces.
|
| 48 |
+
Merges multi-line subtitle blocks into single lines.
|
| 49 |
+
"""
|
| 50 |
+
text = text.replace('\n', ' ')
|
| 51 |
+
text = text.replace('\r', ' ')
|
| 52 |
+
text = re.sub(r' +', ' ', text) # collapse multiple spaces
|
| 53 |
+
return text.strip()
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@tool
|
| 57 |
+
def remove_dialog_punctuation(text: str) -> str:
|
| 58 |
+
"""
|
| 59 |
+
Remove dialog-specific punctuation: dashes at line start (- text),
|
| 60 |
+
ellipsis (...), double dashes (--), quotation marks,
|
| 61 |
+
and excessive punctuation used in subtitles.
|
| 62 |
+
"""
|
| 63 |
+
text = re.sub(r'^\s*-+\s*', '', text, flags=re.MULTILINE) # leading dashes
|
| 64 |
+
text = re.sub(r'\.{2,}', '', text) # ellipsis ...
|
| 65 |
+
text = re.sub(r'-{2,}', '', text) # double dash --
|
| 66 |
+
text = re.sub(r'["""\'\'\']+', '', text) # quotes
|
| 67 |
+
text = re.sub(r'[!?,;:]+', '', text) # dialog punctuation
|
| 68 |
+
return text.strip()
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@tool
|
| 72 |
+
def remove_timestamps(text: str) -> str:
|
| 73 |
+
"""
|
| 74 |
+
Remove SRT/VTT subtitle timestamps.
|
| 75 |
+
Handles formats like:
|
| 76 |
+
- 00:01:23,456 --> 00:01:25,789 (SRT)
|
| 77 |
+
- 00:01:23.456 --> 00:01:25.789 (VTT)
|
| 78 |
+
Also removes bare sequence numbers (1, 2, 3...) used in SRT files.
|
| 79 |
+
"""
|
| 80 |
+
# SRT timestamps
|
| 81 |
+
text = re.sub(
|
| 82 |
+
r'\d{2}:\d{2}:\d{2}[.,]\d{3}\s*-->\s*\d{2}:\d{2}:\d{2}[.,]\d{3}',
|
| 83 |
+
'', text
|
| 84 |
+
)
|
| 85 |
+
# VTT cue identifiers
|
| 86 |
+
text = re.sub(r'^\s*\d+\s*$', '', text, flags=re.MULTILINE)
|
| 87 |
+
# WEBVTT header
|
| 88 |
+
text = re.sub(r'WEBVTT.*\n?', '', text)
|
| 89 |
+
return text.strip()
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@tool
|
| 93 |
+
def remove_speaker_labels(text: str) -> str:
|
| 94 |
+
"""
|
| 95 |
+
Remove speaker labels commonly found in subtitles.
|
| 96 |
+
Handles formats like:
|
| 97 |
+
- JOHN: text
|
| 98 |
+
- John: text
|
| 99 |
+
- [JOHN]: text
|
| 100 |
+
- <v John> text (VTT format)
|
| 101 |
+
"""
|
| 102 |
+
text = re.sub(r'^[A-Z][A-Z\s]{1,20}:\s*', '', text, flags=re.MULTILINE) # JOHN:
|
| 103 |
+
text = re.sub(r'^\w[\w\s]{1,20}:\s*', '', text, flags=re.MULTILINE) # John:
|
| 104 |
+
text = re.sub(r'<v\s+[^>]+>', '', text) # <v John>
|
| 105 |
+
return text.strip()
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@tool
|
| 109 |
+
def remove_html_tags(text: str) -> str:
|
| 110 |
+
"""
|
| 111 |
+
Remove HTML/XML tags commonly found in subtitles.
|
| 112 |
+
Handles: <i>, <b>, <u>, <font color="">, <c.colorname> etc.
|
| 113 |
+
Used in SRT and VTT files for styling.
|
| 114 |
+
"""
|
| 115 |
+
text = re.sub(r'<[^>]+>', '', text)
|
| 116 |
+
return text.strip()
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@tool
|
| 120 |
+
def normalize_whitespace(text: str) -> str:
|
| 121 |
+
"""
|
| 122 |
+
Normalize all whitespace: collapse multiple spaces into one,
|
| 123 |
+
strip leading/trailing spaces from each line,
|
| 124 |
+
remove empty lines.
|
| 125 |
+
Final cleanup step — use after all other tools.
|
| 126 |
+
"""
|
| 127 |
+
lines = text.split('\n')
|
| 128 |
+
lines = [re.sub(r' +', ' ', line).strip() for line in lines]
|
| 129 |
+
lines = [line for line in lines if line] # remove empty
|
| 130 |
+
return ' '.join(lines)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@tool
|
| 134 |
+
def lowercase_text(text: str) -> str:
|
| 135 |
+
"""
|
| 136 |
+
Convert all text to lowercase.
|
| 137 |
+
Recommended for sentiment analysis preprocessing
|
| 138 |
+
to ensure uniform token representation.
|
| 139 |
+
"""
|
| 140 |
+
return text.lower()
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@tool
|
| 144 |
+
def remove_filler_words(text: str) -> str:
|
| 145 |
+
"""
|
| 146 |
+
Remove common spoken filler words that add noise for sentiment analysis.
|
| 147 |
+
Removes: um, uh, hmm, ah, oh, er, erm, hm, gonna, wanna, gotta etc.
|
| 148 |
+
"""
|
| 149 |
+
fillers = r'\b(um+|uh+|hmm+|hm+|ah+|oh+|er+|erm+|gonna|wanna|gotta|kinda|sorta|like|okay|ok|yeah|yep|nope)\b'
|
| 150 |
+
text = re.sub(fillers, '', text, flags=re.IGNORECASE)
|
| 151 |
+
text = re.sub(r' +', ' ', text)
|
| 152 |
+
return text.strip()
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
class FormattedResponse(BaseModel):
|
| 156 |
+
"""Cleaned text"""
|
| 157 |
+
CLEANED_TEXT: str = Field(description="The CLEANED subtitle text")
|
| 158 |
+
|
| 159 |
+
@chain
|
| 160 |
+
def clean_subtitle(text: str) -> FormattedResponse:
|
| 161 |
+
"""Deterministic cleaning pipeline — no LLM needed."""
|
| 162 |
+
text = remove_timestamps.invoke(text)
|
| 163 |
+
text = remove_brackets_content.invoke(text)
|
| 164 |
+
text = remove_html_tags.invoke(text)
|
| 165 |
+
text = remove_speaker_labels.invoke(text)
|
| 166 |
+
text = remove_dialog_punctuation.invoke(text)
|
| 167 |
+
text = remove_newlines.invoke(text)
|
| 168 |
+
text = remove_non_alphabetic.invoke(text)
|
| 169 |
+
text = remove_filler_words.invoke(text)
|
| 170 |
+
text = lowercase_text.invoke(text)
|
| 171 |
+
text = normalize_whitespace.invoke(text)
|
| 172 |
+
return FormattedResponse(CLEANED_TEXT=text)
|
| 173 |
+
|
| 174 |
+
class PreprocessingAgent:
|
| 175 |
+
"""
|
| 176 |
+
Agent for preprocessing.
|
| 177 |
+
|
| 178 |
+
Pipeline:
|
| 179 |
+
1. Dumping system prompt and subtitles into chat format
|
| 180 |
+
2. Passing this chat to agent, which can call tools
|
| 181 |
+
3. Passing agent's output to LLM to extract cleaned text
|
| 182 |
+
|
| 183 |
+
Second LLM is used, since single agent will struggle dealing with heavy prompt,
|
| 184 |
+
tools and requirements for structured output
|
| 185 |
+
"""
|
| 186 |
+
def __init__(self):
|
| 187 |
+
|
| 188 |
+
self.use_hugging_face = settings.preprocessor.use_hugging_face # Flag for using local or external LLM
|
| 189 |
+
self.output_path = settings.preprocessor.output_path
|
| 190 |
+
self.num_workers = cpu_count() # If using external model several workers can be used in parallel
|
| 191 |
+
|
| 192 |
+
self.prompt ="""Your goal is to clean raw subtitle text step by step using available tools.
|
| 193 |
+
## Input text format:
|
| 194 |
+
The user will provide the subtitle text marked as SUBTITLE_TEXT.
|
| 195 |
+
|
| 196 |
+
## Recommended cleaning pipeline (follow this order):
|
| 197 |
+
1. remove_timestamps — strip SRT/VTT timing info
|
| 198 |
+
2. remove_brackets_content — remove [sound], (laughter), {{music}}
|
| 199 |
+
3. remove_html_tags — strip <i>, <b>, <font> tags
|
| 200 |
+
4. remove_speaker_labels — remove JOHN:, John:, <v John>
|
| 201 |
+
5. remove_dialog_punctuation — remove ---, ..., quotes, !?;:,
|
| 202 |
+
6. remove_newlines — merge lines into single text
|
| 203 |
+
7. remove_non_alphabetic — keep only letters and spaces
|
| 204 |
+
8. remove_filler_words — remove um, uh, gonna, wanna...
|
| 205 |
+
9. lowercase_text — convert to lowercase
|
| 206 |
+
10. normalize_whitespace — final cleanup of spaces
|
| 207 |
+
|
| 208 |
+
Apply ALL steps unless the user specifies otherwise.
|
| 209 |
+
After cleaning, return the final cleaned text clearly labeled as:
|
| 210 |
+
|
| 211 |
+
CLEANED_TEXT: <result>
|
| 212 |
+
"""
|
| 213 |
+
|
| 214 |
+
def clean_text(text: str) -> str:
|
| 215 |
+
return clean_subtitle.invoke(text).CLEANED_TEXT
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _setup_pipeline(self, tools: list) -> Runnable:
|
| 219 |
+
"""
|
| 220 |
+
Initializes the pipeline
|
| 221 |
+
|
| 222 |
+
Args:
|
| 223 |
+
tools (list): list of available tools
|
| 224 |
+
|
| 225 |
+
Returns:
|
| 226 |
+
Runnable: Runnable pipeline
|
| 227 |
+
"""
|
| 228 |
+
if self.use_hugging_face:
|
| 229 |
+
endpoint = HuggingFaceEndpoint(
|
| 230 |
+
repo_id=settings.preprocessor.model,
|
| 231 |
+
task='text-generation',
|
| 232 |
+
do_sample=False,
|
| 233 |
+
temperature=0.05,
|
| 234 |
+
max_new_tokens=16_000,
|
| 235 |
+
streaming=False
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
llm = ChatHuggingFace(llm=endpoint)
|
| 239 |
+
structured_llm = llm.with_structured_output(FormattedResponse, method='json_mode')
|
| 240 |
+
else:
|
| 241 |
+
llm = ChatLlamaCpp(
|
| 242 |
+
model_path=settings.preprocessor.local_model_path,
|
| 243 |
+
n_ctx=4096,
|
| 244 |
+
n_gpu_layers=-1,
|
| 245 |
+
n_batch=512,
|
| 246 |
+
max_tokens=16_000,
|
| 247 |
+
n_threads=cpu_count() - 1,
|
| 248 |
+
temperature=0.05,
|
| 249 |
+
verbose=True,
|
| 250 |
+
f16_kv=True
|
| 251 |
+
)
|
| 252 |
+
structured_llm = llm.with_structured_output(FormattedResponse)
|
| 253 |
+
|
| 254 |
+
agent = create_agent(llm, tools, system_prompt=SystemMessage(self.prompt))
|
| 255 |
+
|
| 256 |
+
pipeline = (
|
| 257 |
+
RunnableLambda(lambda text: {
|
| 258 |
+
'messages': [HumanMessage(f'Clean the following text:\n\nSUBTITLE_TEXT:\n{text}')]
|
| 259 |
+
})
|
| 260 |
+
| agent
|
| 261 |
+
| RunnableLambda(lambda result: f"Return this cleaned text in the required format:\n\n{result['messages'][-1].content}")
|
| 262 |
+
| structured_llm
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
return pipeline
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def _init_tools(self) -> list:
|
| 269 |
+
"""
|
| 270 |
+
Provides the list of available tools. This method is required due to
|
| 271 |
+
function serialization problem (when using several workers)
|
| 272 |
+
|
| 273 |
+
Returns:
|
| 274 |
+
list: tools
|
| 275 |
+
"""
|
| 276 |
+
return [
|
| 277 |
+
remove_timestamps,
|
| 278 |
+
remove_brackets_content,
|
| 279 |
+
remove_html_tags,
|
| 280 |
+
remove_speaker_labels,
|
| 281 |
+
remove_dialog_punctuation,
|
| 282 |
+
remove_newlines,
|
| 283 |
+
remove_non_alphabetic,
|
| 284 |
+
remove_filler_words,
|
| 285 |
+
lowercase_text,
|
| 286 |
+
normalize_whitespace,
|
| 287 |
+
]
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def invoke(self, pipeline: Runnable, splitter: TextSplitter, text: str) -> str | None:
|
| 291 |
+
"""
|
| 292 |
+
Preprocesses the text. Additionally, splits it to chunks to avoid
|
| 293 |
+
context overflow
|
| 294 |
+
|
| 295 |
+
Args:
|
| 296 |
+
pipeline (Runnable): pipeline for preprocessing
|
| 297 |
+
splitter (TextSplitter): text splitter. Defaults to RecursiveCharacterSplitter
|
| 298 |
+
text (str): subtitles
|
| 299 |
+
|
| 300 |
+
Returns:
|
| 301 |
+
str | None: cleaned text
|
| 302 |
+
"""
|
| 303 |
+
texts = splitter.split_text(text)
|
| 304 |
+
|
| 305 |
+
ready_parts = []
|
| 306 |
+
for splitted_text in texts:
|
| 307 |
+
response = pipeline.invoke(splitted_text)
|
| 308 |
+
|
| 309 |
+
# Some HuggingFace models struggles with formatted response
|
| 310 |
+
# So we iterate over all keys and try to find ANY text
|
| 311 |
+
if self.use_hugging_face:
|
| 312 |
+
for key in response.keys():
|
| 313 |
+
if 'text' in key.lower():
|
| 314 |
+
ready_parts.append(response[key].strip())
|
| 315 |
+
break
|
| 316 |
+
else:
|
| 317 |
+
# Local models have a bit different schema, so they always will have this
|
| 318 |
+
ready_parts.append(response.CLEANED_TEXT.strip())
|
| 319 |
+
|
| 320 |
+
if not len(ready_parts):
|
| 321 |
+
return None
|
| 322 |
+
|
| 323 |
+
return ' '.join(ready_parts).strip()
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def analyze_file(self, pipeline: Runnable, splitter: TextSplitter, input_path: Path) -> None:
|
| 327 |
+
"""
|
| 328 |
+
Loads file with subtitles, cleans it, and saves back
|
| 329 |
+
|
| 330 |
+
Args:
|
| 331 |
+
pipeline (Runnable): preprocessing pipeline
|
| 332 |
+
splitter (_type_): text splitter
|
| 333 |
+
input_path (Path): path to subtitles
|
| 334 |
+
"""
|
| 335 |
+
with open(input_path, 'r', encoding='utf-8') as f:
|
| 336 |
+
text = f.read()
|
| 337 |
+
|
| 338 |
+
print(f'Processing {input_path.name}')
|
| 339 |
+
start_time = time.time()
|
| 340 |
+
|
| 341 |
+
content = self.invoke(pipeline, splitter, text) # cleaning logic
|
| 342 |
+
|
| 343 |
+
self.output_path.mkdir(parents=True, exist_ok=True)
|
| 344 |
+
|
| 345 |
+
with open(
|
| 346 |
+
os.path.join(self.output_path, f'{input_path.stem}.csv'), "w"
|
| 347 |
+
) as file:
|
| 348 |
+
file.write(content)
|
| 349 |
+
|
| 350 |
+
elapsed = time.time() - start_time
|
| 351 |
+
|
| 352 |
+
print(f'Saved to {self.output_path} in {elapsed:.2f}s')
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
def _select_filenames(self) -> list[Path]:
|
| 356 |
+
"""
|
| 357 |
+
Collects all the files with subtitles.
|
| 358 |
+
|
| 359 |
+
Returns:
|
| 360 |
+
list[Path]: list with path for each file
|
| 361 |
+
"""
|
| 362 |
+
names = []
|
| 363 |
+
|
| 364 |
+
for filepath in glob.glob(
|
| 365 |
+
os.path.join(settings.preprocessor.input_path, '*.txt')
|
| 366 |
+
):
|
| 367 |
+
names.append(Path(filepath))
|
| 368 |
+
|
| 369 |
+
return names
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
def _start_worker(self, files: list[Path], worker_id: int = 0) -> None:
|
| 373 |
+
"""
|
| 374 |
+
Starts worker. Each worker has its own pipeline, set of tools, and
|
| 375 |
+
text splitter
|
| 376 |
+
|
| 377 |
+
Args:
|
| 378 |
+
files (list[Path]): list of files to be preprocessed
|
| 379 |
+
"""
|
| 380 |
+
print(f'Worker {worker_id} is preparing')
|
| 381 |
+
|
| 382 |
+
# Note: When using multiprocessing, each process will serialize
|
| 383 |
+
# self and any other referenced object, but langchain tools are not
|
| 384 |
+
# serializable. So separate method for tools is required
|
| 385 |
+
tools = self._init_tools()
|
| 386 |
+
pipeline = self._setup_pipeline(tools)
|
| 387 |
+
splitter = RecursiveCharacterTextSplitter(
|
| 388 |
+
chunk_size=settings.preprocessor.chunk_size,
|
| 389 |
+
chunk_overlap=0,
|
| 390 |
+
length_function=len
|
| 391 |
+
)
|
| 392 |
+
|
| 393 |
+
print(f'Worker {worker_id} is starting preprocessing')
|
| 394 |
+
|
| 395 |
+
for file in files:
|
| 396 |
+
self.analyze_file(pipeline, splitter, file)
|
| 397 |
+
|
| 398 |
+
print(f'Worker {worker_id} is finishing')
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def start_preprocessing(self) -> None:
|
| 402 |
+
"""
|
| 403 |
+
Starts preprocessing.
|
| 404 |
+
"""
|
| 405 |
+
offset = settings.scraper.offset
|
| 406 |
+
files = self._select_filenames()
|
| 407 |
+
|
| 408 |
+
if self.use_hugging_face:
|
| 409 |
+
# Map files to workers
|
| 410 |
+
chunk_size = (len(files) + self.num_workers - 1) // self.num_workers
|
| 411 |
+
file_mapping = [
|
| 412 |
+
files[i + offset : i + offset + chunk_size]
|
| 413 |
+
for i in range(0, len(files), chunk_size)
|
| 414 |
+
]
|
| 415 |
+
|
| 416 |
+
# Collect worker's args
|
| 417 |
+
worker_args = [
|
| 418 |
+
(files, i + 1)
|
| 419 |
+
for i, files in enumerate(file_mapping)
|
| 420 |
+
]
|
| 421 |
+
|
| 422 |
+
# Each worker - separate process
|
| 423 |
+
with Pool(processes=self.num_workers) as p:
|
| 424 |
+
p.starmap(self._start_worker, worker_args)
|
| 425 |
+
else:
|
| 426 |
+
files = files[offset:]
|
| 427 |
+
self._start_worker(files, 1)
|
app/main.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
|
|
| 1 |
from fastapi import FastAPI
|
| 2 |
|
|
|
|
| 3 |
from .model import get_model
|
| 4 |
-
from .schemas import EmotionResponse, SubtitleRequest
|
| 5 |
|
| 6 |
app = FastAPI(title="Emotion Analyzer API")
|
| 7 |
|
|
@@ -19,4 +21,10 @@ async def analyze(req: SubtitleRequest):
|
|
| 19 |
|
| 20 |
return EmotionResponse(
|
| 21 |
data=df.to_dict(orient="records")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
)
|
|
|
|
| 1 |
+
|
| 2 |
from fastapi import FastAPI
|
| 3 |
|
| 4 |
+
from .cleaner import PreprocessingAgent
|
| 5 |
from .model import get_model
|
| 6 |
+
from .schemas import CleanResponse, EmotionResponse, SubtitleRequest
|
| 7 |
|
| 8 |
app = FastAPI(title="Emotion Analyzer API")
|
| 9 |
|
|
|
|
| 21 |
|
| 22 |
return EmotionResponse(
|
| 23 |
data=df.to_dict(orient="records")
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
@app.post('/clean', response_model=CleanResponse)
|
| 27 |
+
async def clean(req: SubtitleRequest):
|
| 28 |
+
return CleanResponse(
|
| 29 |
+
text=PreprocessingAgent.clean_text(req.text)
|
| 30 |
)
|
app/schemas.py
CHANGED
|
@@ -6,4 +6,7 @@ class SubtitleRequest(BaseModel):
|
|
| 6 |
|
| 7 |
|
| 8 |
class EmotionResponse(BaseModel):
|
| 9 |
-
data: list[dict]
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
class EmotionResponse(BaseModel):
|
| 9 |
+
data: list[dict]
|
| 10 |
+
|
| 11 |
+
class CleanResponse(BaseModel):
|
| 12 |
+
text: str
|
app/settings.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
|
| 7 |
+
load_dotenv() # for manual use (please, ensure that there is .env file in the same folder)
|
| 8 |
+
|
| 9 |
+
BASE_DIR = Path(__file__).resolve().parent
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ScraperSettings(BaseModel):
|
| 13 |
+
url: str = Field(
|
| 14 |
+
default='https://subslikescript.com',
|
| 15 |
+
description="Base URL for scraping"
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
output_folder: str = Field(
|
| 19 |
+
default=os.path.join(BASE_DIR, 'data'),
|
| 20 |
+
description="Directory where scraped files will be saved"
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
# Do not set False if using Docker
|
| 24 |
+
headless: bool = Field(
|
| 25 |
+
default=False,
|
| 26 |
+
description="Run browser in headless mode (disable UI)"
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
delay: int = Field(
|
| 30 |
+
default=3,
|
| 31 |
+
description="Delay between requests in seconds"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
num_workers: int = Field(
|
| 35 |
+
default=4,
|
| 36 |
+
description="Number of concurrent workers"
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
offset: int = Field(
|
| 40 |
+
default=386,
|
| 41 |
+
description="End page for the first worker at the last run"
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class EmotionAnalyzerSettings(BaseModel):
|
| 46 |
+
input_path: str = Field(
|
| 47 |
+
default='./data',
|
| 48 |
+
description="Path to preprocessed data"
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
output_path: str = Field(
|
| 52 |
+
default='./emotion_analysis/embeddings',
|
| 53 |
+
description="Path to files with embeddings"
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class PreprocessorSettings(BaseModel):
|
| 58 |
+
use_hugging_face: bool = Field(
|
| 59 |
+
default=False,
|
| 60 |
+
description="Flag to use propitiate HuggingFace model"
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
local_model_path: str = Field(
|
| 64 |
+
default='./emotion_analysis/weights/mistral_prep/model.gguf',
|
| 65 |
+
description="Path to file with local model"
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
model: str = Field(
|
| 69 |
+
default='Qwen/Qwen2.5-7B-Instruct',
|
| 70 |
+
description="Model name"
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
input_path: str = Field(
|
| 74 |
+
default='./data',
|
| 75 |
+
description="Path to raw subtitles"
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
output_path: str = Field(
|
| 79 |
+
default=Path('./preprocessing/ready_data'),
|
| 80 |
+
description="Path to folder with preprocessed data"
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
chunk_size: int = Field(
|
| 84 |
+
default=4096,
|
| 85 |
+
description="Each text is divided into chunks since " \
|
| 86 |
+
"entire sequence can not fit into context"
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
offset: int = Field(
|
| 90 |
+
default=0,
|
| 91 |
+
description="Determines how much first files will be skipped "
|
| 92 |
+
"(in case some have already been preprocessed)"
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class DBSettings(BaseModel):
|
| 97 |
+
db_url: str = Field(
|
| 98 |
+
default=os.environ['DB_URL'],
|
| 99 |
+
description="DB connection string"
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class GraphSettings(BaseModel):
|
| 104 |
+
max_depth: int = Field(
|
| 105 |
+
default=5,
|
| 106 |
+
description="Maximum graph depth (excluding the highest node (root))"
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
min_samples_leaf: int = Field(
|
| 110 |
+
default=6,
|
| 111 |
+
description="Minimum number of movies in each leaf"
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
max_nodes: int = Field(
|
| 115 |
+
default=800,
|
| 116 |
+
description="Maximum number of nodes in the graph (important for KMeans)"
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
target_leaf_size: int = Field(
|
| 120 |
+
default=50,
|
| 121 |
+
description="Desired number of movies in each leaf"
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
# Important - with current algorithm min/max_fanout can be violated for very small
|
| 125 |
+
# number of nodes. To understand the reason check the algorithm description
|
| 126 |
+
# in ./clustering/graph_creator.py
|
| 127 |
+
min_fanout: int = Field(
|
| 128 |
+
default=3,
|
| 129 |
+
description="Minimal number of successors for each node"
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
max_fanout: int = Field(
|
| 133 |
+
default=8,
|
| 134 |
+
description="Maximum number of successors for each node"
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
class NameCreatorSettings(BaseModel):
|
| 139 |
+
model_path: str = Field(
|
| 140 |
+
default='./emotion_analysis/weights/qwen/model.gguf',
|
| 141 |
+
description="Path to model"
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
class APISettings(BaseModel):
|
| 146 |
+
app_path: str = Field(
|
| 147 |
+
default='api.api:app',
|
| 148 |
+
description='Path to file with FastAPI app'
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
host: str = Field(
|
| 152 |
+
default='0.0.0.0',
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
port: int = Field(
|
| 156 |
+
default=int(os.environ['API_PORT']),
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
class MoviesValidatorSettings(BaseModel):
|
| 161 |
+
path_to_files: str = Field(
|
| 162 |
+
default='./movies_for_validation'
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
class AdminPanelSettings(BaseModel):
|
| 167 |
+
login: str = Field(
|
| 168 |
+
default=os.environ['ADMIN_LOGIN']
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
password: str = Field(
|
| 172 |
+
default=os.environ['ADMIN_PASSWORD']
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
class Settings(BaseModel):
|
| 177 |
+
scraper: ScraperSettings = ScraperSettings()
|
| 178 |
+
emotion_analyzer: EmotionAnalyzerSettings = EmotionAnalyzerSettings()
|
| 179 |
+
preprocessor: PreprocessorSettings = PreprocessorSettings()
|
| 180 |
+
name_creator: NameCreatorSettings = NameCreatorSettings()
|
| 181 |
+
movie_validator: MoviesValidatorSettings = MoviesValidatorSettings()
|
| 182 |
+
admin: AdminPanelSettings = AdminPanelSettings()
|
| 183 |
+
db: DBSettings = DBSettings()
|
| 184 |
+
graph: GraphSettings = GraphSettings()
|
| 185 |
+
api: APISettings = APISettings()
|
| 186 |
+
base_dir: str = BASE_DIR
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
settings = Settings()
|
requirements.txt
CHANGED
|
@@ -6,4 +6,16 @@ pandas
|
|
| 6 |
numpy
|
| 7 |
peft
|
| 8 |
accelerate
|
| 9 |
-
bitsandbytes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
numpy
|
| 7 |
peft
|
| 8 |
accelerate
|
| 9 |
+
bitsandbytes
|
| 10 |
+
langchain==1.2.10
|
| 11 |
+
langchain-classic==1.0.1
|
| 12 |
+
langchain-community==0.4.1
|
| 13 |
+
langchain-core==1.2.13
|
| 14 |
+
langchain-huggingface==1.2.0
|
| 15 |
+
langchain-text-splitters==1.1.0
|
| 16 |
+
langgraph==1.0.8
|
| 17 |
+
langgraph-checkpoint==4.0.0
|
| 18 |
+
langgraph-prebuilt==1.0.7
|
| 19 |
+
langgraph-sdk==0.3.6
|
| 20 |
+
langsmith==0.7.3
|
| 21 |
+
dotenv
|
settings.py
DELETED
|
File without changes
|