light-infer-chat commited on
Commit
25bbb06
·
1 Parent(s): 974fbb9
app/api/deps.py CHANGED
@@ -9,9 +9,14 @@ from app.services.database_service import DatabaseService
9
  from app.services.embeddings_service import EmbeddingService
10
  from app.services.extraction_service import ExtractionService
11
  from app.services.ocr_service import OCRService
 
12
  from app.services.web_search_service import WebSearchService
13
 
14
 
 
 
 
 
15
  def get_auth_service() -> AuthService:
16
  return AuthService()
17
 
 
9
  from app.services.embeddings_service import EmbeddingService
10
  from app.services.extraction_service import ExtractionService
11
  from app.services.ocr_service import OCRService
12
+ from app.services.text_cleaner_service import TextCleanerService
13
  from app.services.web_search_service import WebSearchService
14
 
15
 
16
+ def get_text_cleaner_service() -> TextCleanerService:
17
+ return TextCleanerService()
18
+
19
+
20
  def get_auth_service() -> AuthService:
21
  return AuthService()
22
 
app/api/v1/convert.py CHANGED
@@ -15,13 +15,15 @@ from app.config import get_settings
15
  from app.api.deps import (
16
  get_converter_service,
17
  get_extraction_service,
 
18
  require_auth,
19
  )
20
  from app.core.logger import get_logger
21
- from app.models.domain import ConversionError
22
  from app.models.schemas import ConversionMetadata, ConversionResponse, UrlRequest
23
  from app.services.converter_service import ConverterService
24
  from app.services.extraction_service import ExtractionService
 
25
 
26
  router = APIRouter()
27
  _logger = get_logger(__name__)
@@ -52,17 +54,24 @@ async def _build_response(
52
  raw_data: Optional[bytes] = None,
53
  mappings: Optional[Dict[str, Dict[str, Any]]] = None,
54
  extraction_service: ExtractionService = None,
 
 
55
  ) -> ConversionResponse:
56
  json_content: Optional[Any] = None
57
  error_message: Optional[str] = None
58
 
 
 
 
 
 
59
  if return_json and filename and extraction_service:
60
  loop = asyncio.get_running_loop()
61
  json_result = await loop.run_in_executor(
62
  _thread_pool,
63
  extraction_service.extract_structured,
64
  filename,
65
- result.markdown,
66
  mappings,
67
  raw_data,
68
  )
@@ -71,13 +80,30 @@ async def _build_response(
71
  else:
72
  json_content = json_result
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  return ConversionResponse(
75
  success=True,
76
  time_ms=round(result.duration_ms, 3),
77
- content=result.markdown,
78
  return_json=return_json,
79
  json_content=json_content,
80
- metadata=_build_metadata(result),
81
  error_message=error_message,
82
  )
83
 
@@ -109,10 +135,12 @@ async def convert_file(
109
  file: Annotated[UploadFile, File(description="File to convert")],
110
  plain_text: bool = Form(False),
111
  return_json: bool = Form(False),
 
112
  mappings: Optional[str] = Form(None, description="JSON string with field mappings"),
113
  token: str = Depends(require_auth),
114
  converter_service: ConverterService = Depends(get_converter_service),
115
  extraction_service: ExtractionService = Depends(get_extraction_service),
 
116
  ):
117
  if file is None:
118
  raise HTTPException(status_code=400, detail={"success": False, "message": "No file provided."})
@@ -137,9 +165,14 @@ async def convert_file(
137
  _raise_for_error(outcome)
138
 
139
  _logger.info("Conversion successful for %s", file.filename)
 
 
 
 
 
140
  if plain_text:
141
  from fastapi.responses import PlainTextResponse
142
- return PlainTextResponse(outcome.markdown)
143
 
144
  response = await _build_response(
145
  outcome,
@@ -148,6 +181,8 @@ async def convert_file(
148
  raw_data=raw,
149
  mappings=parsed_mappings,
150
  extraction_service=extraction_service,
 
 
151
  )
152
  _logger.info("Request completed for %s", file.filename)
153
  return response
 
15
  from app.api.deps import (
16
  get_converter_service,
17
  get_extraction_service,
18
+ get_text_cleaner_service,
19
  require_auth,
20
  )
21
  from app.core.logger import get_logger
22
+ from app.models.domain import ConversionError, count_tokens
23
  from app.models.schemas import ConversionMetadata, ConversionResponse, UrlRequest
24
  from app.services.converter_service import ConverterService
25
  from app.services.extraction_service import ExtractionService
26
+ from app.services.text_cleaner_service import TextCleanerService
27
 
28
  router = APIRouter()
29
  _logger = get_logger(__name__)
 
54
  raw_data: Optional[bytes] = None,
55
  mappings: Optional[Dict[str, Dict[str, Any]]] = None,
56
  extraction_service: ExtractionService = None,
57
+ clean_content: bool = False,
58
+ text_cleaner_service: TextCleanerService = None,
59
  ) -> ConversionResponse:
60
  json_content: Optional[Any] = None
61
  error_message: Optional[str] = None
62
 
63
+ content = result.markdown
64
+ if clean_content and text_cleaner_service:
65
+ loop = asyncio.get_running_loop()
66
+ content = await loop.run_in_executor(_thread_pool, text_cleaner_service.clean, content)
67
+
68
  if return_json and filename and extraction_service:
69
  loop = asyncio.get_running_loop()
70
  json_result = await loop.run_in_executor(
71
  _thread_pool,
72
  extraction_service.extract_structured,
73
  filename,
74
+ content,
75
  mappings,
76
  raw_data,
77
  )
 
80
  else:
81
  json_content = json_result
82
 
83
+ if clean_content and content != result.markdown:
84
+ lines = content.splitlines()
85
+ words = content.split()
86
+ import hashlib
87
+ metadata = ConversionMetadata(
88
+ source=result.source,
89
+ char_count=len(content),
90
+ word_count=len(words),
91
+ line_count=len(lines),
92
+ file_size_bytes=result.file_size_bytes,
93
+ mime_type=result.mime_type,
94
+ content_hash=hashlib.sha256(content.encode()).hexdigest(),
95
+ token_estimate=max(1, count_tokens(content)),
96
+ )
97
+ else:
98
+ metadata = _build_metadata(result)
99
+
100
  return ConversionResponse(
101
  success=True,
102
  time_ms=round(result.duration_ms, 3),
103
+ content=content,
104
  return_json=return_json,
105
  json_content=json_content,
106
+ metadata=metadata,
107
  error_message=error_message,
108
  )
109
 
 
135
  file: Annotated[UploadFile, File(description="File to convert")],
136
  plain_text: bool = Form(False),
137
  return_json: bool = Form(False),
138
+ clean_content: bool = Form(False),
139
  mappings: Optional[str] = Form(None, description="JSON string with field mappings"),
140
  token: str = Depends(require_auth),
141
  converter_service: ConverterService = Depends(get_converter_service),
142
  extraction_service: ExtractionService = Depends(get_extraction_service),
143
+ text_cleaner_service: TextCleanerService = Depends(get_text_cleaner_service),
144
  ):
145
  if file is None:
146
  raise HTTPException(status_code=400, detail={"success": False, "message": "No file provided."})
 
165
  _raise_for_error(outcome)
166
 
167
  _logger.info("Conversion successful for %s", file.filename)
168
+
169
+ content = outcome.markdown
170
+ if clean_content:
171
+ content = await loop.run_in_executor(_thread_pool, text_cleaner_service.clean, content)
172
+
173
  if plain_text:
174
  from fastapi.responses import PlainTextResponse
175
+ return PlainTextResponse(content)
176
 
177
  response = await _build_response(
178
  outcome,
 
181
  raw_data=raw,
182
  mappings=parsed_mappings,
183
  extraction_service=extraction_service,
184
+ clean_content=clean_content,
185
+ text_cleaner_service=text_cleaner_service,
186
  )
187
  _logger.info("Request completed for %s", file.filename)
188
  return response
app/models/domain.py CHANGED
@@ -1,8 +1,23 @@
1
  from __future__ import annotations
2
 
3
- import hashlib
4
  from dataclasses import dataclass, field
5
- from typing import Any, Dict, Optional
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
 
8
  @dataclass(frozen=True)
@@ -20,7 +35,7 @@ class ConversionResult:
20
 
21
  @property
22
  def token_estimate(self) -> int:
23
- return max(1, self.word_count * 4 // 3)
24
 
25
 
26
  @dataclass(frozen=True)
 
1
  from __future__ import annotations
2
 
 
3
  from dataclasses import dataclass, field
4
+
5
+ import tiktoken
6
+
7
+
8
+ _ENCODING_CACHE: dict[str, tiktoken.Encoding] = {}
9
+
10
+
11
+ def _get_encoding(name: str = "o200k_base") -> tiktoken.Encoding:
12
+ if name not in _ENCODING_CACHE:
13
+ _ENCODING_CACHE[name] = tiktoken.get_encoding(name)
14
+ return _ENCODING_CACHE[name]
15
+
16
+
17
+ def count_tokens(text: str, encoding_name: str = "o200k_base") -> int:
18
+ if not text:
19
+ return 0
20
+ return len(_get_encoding(encoding_name).encode(text, disallowed_special=()))
21
 
22
 
23
  @dataclass(frozen=True)
 
35
 
36
  @property
37
  def token_estimate(self) -> int:
38
+ return max(1, count_tokens(self.markdown))
39
 
40
 
41
  @dataclass(frozen=True)
app/services/text_cleaner_service.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import html
3
+ from cleantext import clean
4
+ from text_unidecode import unidecode
5
+
6
+
7
+ def clean_text(
8
+ text: str,
9
+ *,
10
+ # Basic cleanup
11
+ normalize_whitespace: bool = True,
12
+ remove_newlines: bool = True,
13
+ strip: bool = True,
14
+ # Character handling
15
+ to_lowercase: bool = False,
16
+ remove_punctuation: bool = False,
17
+ # Content removal
18
+ remove_urls: bool = False,
19
+ remove_emails: bool = False,
20
+ remove_phone_numbers: bool = False,
21
+ remove_numbers: bool = False,
22
+ remove_digits: bool = False,
23
+ # Escape sequences
24
+ fix_escape_sequences: bool = False,
25
+ remove_html_entities: bool = True,
26
+ # Special characters
27
+ remove_currency_symbols: bool = True,
28
+ remove_emoji: bool = True,
29
+ normalize_unicode: bool = True,
30
+ # Custom replacements
31
+ custom_replacements: dict | None = None,
32
+ ) -> str:
33
+ """
34
+ Robust generic string cleaner using validated cleantext API parameters
35
+ and text_unidecode for superior ASCII transliteration.
36
+
37
+ Parameters
38
+ ----------
39
+ text : Input string to clean.
40
+ normalize_whitespace : Normalize all whitespace variants to single space.
41
+ remove_newlines : Remove line breaks (\\n, \\r, etc.).
42
+ strip : Strip leading/trailing whitespace.
43
+ to_lowercase : Convert text to lowercase.
44
+ remove_punctuation : Remove punctuation characters.
45
+ remove_urls : Remove URLs (http/https/www).
46
+ remove_emails : Remove email addresses.
47
+ remove_phone_numbers : Remove phone numbers.
48
+ remove_numbers : Remove standalone numbers.
49
+ remove_digits : Remove all digit characters.
50
+ fix_escape_sequences : Fix literal escape sequences (\\\\n, \\\\t, etc.).
51
+ remove_html_entities : Decode HTML entities (& → &).
52
+ remove_currency_symbols: Remove $, €, £, ¥, etc.
53
+ remove_emoji : Remove emoji characters.
54
+ normalize_unicode : Apply unicode fix + transliterate to ASCII via unidecode.
55
+ custom_replacements : Dict of {exact_string: replacement} applied first.
56
+
57
+ Returns
58
+ -------
59
+ str : Cleaned string.
60
+ """
61
+
62
+ # ── Guard: handle None / non-string input safely ─────────────────────────
63
+ if text is None:
64
+ return ""
65
+ if not isinstance(text, str):
66
+ text = str(text)
67
+ if not text.strip():
68
+ return ""
69
+
70
+ # ────────────────────────────────────────────────────────────────────────
71
+ # STEP 1 ── Custom replacements (exact string match, applied first)
72
+ # ────────────────────────────────────────────────────────────────────────
73
+ if custom_replacements:
74
+ for target, replacement in custom_replacements.items():
75
+ text = text.replace(target, replacement)
76
+
77
+ # ────────────────────────────────────────────────────────────────────────
78
+ # STEP 2 ── Fix literal escape sequences BEFORE any other processing
79
+ # ────────────────────────────────────────────────────────────────────────
80
+ if fix_escape_sequences:
81
+ LITERAL_ESCAPE_MAP = [
82
+ ("\\n", " "),
83
+ ("\\t", " "),
84
+ ("\\r", " "),
85
+ ("\\v", " "),
86
+ ("\\f", " "),
87
+ ("\\a", ""),
88
+ ("\\b", ""),
89
+ ("\\\\", " "),
90
+ ("\\/", "/"),
91
+ ("\\'", "'"),
92
+ ('\\"', '"'),
93
+ ]
94
+ for literal, replacement in LITERAL_ESCAPE_MAP:
95
+ text = text.replace(literal, replacement)
96
+
97
+ # ────────────────────────────────────────────────────────────────────────
98
+ # STEP 3 ── Decode HTML entities (&amp; → &, &lt; → <, &#39; → ')
99
+ # ────────────────────────────────────────────────────────────────────────
100
+ if remove_html_entities:
101
+ text = html.unescape(text)
102
+
103
+ # ────────────────────────────────────────────────────────────────────────
104
+ # STEP 4 ── Core cleaning via cleantext (validated API parameters only)
105
+ # We disable cleantext's to_ascii because text_unidecode
106
+ # handles transliteration far more robustly in STEP 5.
107
+ # ────────────────────────────────────────────────────────────────────────
108
+ text = clean(
109
+ text,
110
+ fix_unicode=True, # Fix mojibake/encoding errors
111
+ to_ascii=False, # Defer to text_unidecode
112
+ lower=to_lowercase,
113
+ normalize_whitespace=normalize_whitespace,
114
+ no_line_breaks=remove_newlines,
115
+ strip_lines=strip,
116
+ no_urls=remove_urls,
117
+ no_emails=remove_emails,
118
+ no_phone_numbers=remove_phone_numbers,
119
+ no_numbers=remove_numbers,
120
+ no_digits=remove_digits,
121
+ no_currency_symbols=remove_currency_symbols,
122
+ no_punct=remove_punctuation,
123
+ no_emoji=remove_emoji,
124
+ replace_with_url="",
125
+ replace_with_email="",
126
+ replace_with_phone_number="",
127
+ replace_with_number="",
128
+ replace_with_digit="",
129
+ replace_with_currency_symbol="",
130
+ replace_with_punct="",
131
+ lang="en",
132
+ )
133
+
134
+ # ────────────────────────────────────────────────────────────────────────
135
+ # STEP 5 ── Robust ASCII transliteration via text_unidecode
136
+ # Converts remaining non-ASCII (accents, cyrillic, greek, etc.)
137
+ # ────────────────────────────────────────────────────────────────────────
138
+ if normalize_unicode:
139
+ text = unidecode(text)
140
+
141
+ # ────────────────────────────────────────────────────────────────────────
142
+ # STEP 6 ── Post-clean whitespace tidy-up
143
+ # Removal + transliteration may leave stray multi-spaces
144
+ # ────────────────────────────────────────────────────────────────────────
145
+ text = re.sub(r" {2,}", " ", text)
146
+ if strip:
147
+ text = text.strip()
148
+
149
+ return text
150
+
151
+
152
+ class TextCleanerService:
153
+ def clean(self, text: str, **kwargs) -> str:
154
+ return clean_text(text, **kwargs)
requirements.txt CHANGED
@@ -23,6 +23,7 @@ torch==2.12.1
23
  einops
24
  spacy>=3.7.0
25
  phonenumbers>=8.13.0
 
26
 
27
  # Async database drivers
28
  aiomysql>=0.3.2
@@ -35,4 +36,8 @@ scrapling[all]>=0.4.0
35
  # Chat / AI
36
  redis>=5.0.0
37
  defusedxml>=0.7.1
38
- chardet>=5.2.0
 
 
 
 
 
23
  einops
24
  spacy>=3.7.0
25
  phonenumbers>=8.13.0
26
+ tiktoken>=0.9.0
27
 
28
  # Async database drivers
29
  aiomysql>=0.3.2
 
36
  # Chat / AI
37
  redis>=5.0.0
38
  defusedxml>=0.7.1
39
+ chardet>=5.2.0
40
+
41
+ # Text processing
42
+ clean-text>=0.6.0
43
+ Unidecode>=1.3.8